Compare commits
247 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c7ab767872 | ||
|
|
fdf48abd29 | ||
|
|
59252759f2 | ||
|
|
dd96d80d42 | ||
|
|
f8efbe95e6 | ||
|
|
cf84474921 | ||
|
|
eccf27425c | ||
|
|
3926e566b7 | ||
|
|
bca333be26 | ||
|
|
02a1e3b455 | ||
|
|
5b0c66b5e2 | ||
|
|
7c0c823c41 | ||
|
|
a8d2c55d12 | ||
|
|
37410fcf97 | ||
|
|
0ab4a2d883 | ||
|
|
756b896fe6 | ||
|
|
e3e3611b54 | ||
|
|
4bf65f8ebd | ||
|
|
6956c71aa2 | ||
|
|
e0f357513c | ||
|
|
daeb859990 | ||
|
|
f072bcd0b0 | ||
|
|
a497dc953a | ||
|
|
dcf6d72c01 | ||
|
|
d749487fb6 | ||
|
|
7079355013 | ||
|
|
46762b0d36 | ||
|
|
389e5df117 | ||
|
|
865bff2214 | ||
|
|
33553e22d5 | ||
|
|
1323787294 | ||
|
|
0a812fa72b | ||
|
|
9faf9d844e | ||
|
|
1558854f78 | ||
|
|
ab19df767c | ||
|
|
218fd504bf | ||
|
|
02158d7621 | ||
|
|
9edb42d181 | ||
|
|
8ba370bf62 | ||
|
|
a32934ee9d | ||
|
|
ff5ba2097d | ||
|
|
703f84403e | ||
|
|
f9ec18c51a | ||
|
|
3d0651d4af | ||
|
|
f30bf63c24 | ||
|
|
e2d89a56d6 | ||
|
|
40c3ccca93 | ||
|
|
1e515d5284 | ||
|
|
cb9cdc92c8 | ||
|
|
639f777b8a | ||
|
|
48b7384490 | ||
|
|
3bff1650c7 | ||
|
|
cadea5c654 | ||
|
|
e9b7c21ea0 | ||
|
|
03ca0c5e3d | ||
|
|
1ee3e01cfd | ||
|
|
e2e9395f8e | ||
|
|
f415e8c4bb | ||
|
|
6a73d7c594 | ||
|
|
24206c0953 | ||
|
|
7610f844f7 | ||
|
|
a7c1aeb876 | ||
|
|
dd4c925a56 | ||
|
|
a4c55fb455 | ||
|
|
451d17a2cb | ||
|
|
530a12a86f | ||
|
|
e908f9c534 | ||
|
|
9af731c7eb | ||
|
|
0548331937 | ||
|
|
9eb4c883ff | ||
|
|
17b578361a | ||
|
|
08a220f424 | ||
|
|
d7b6ce04e8 | ||
|
|
ece88eed5c | ||
|
|
257bdf9877 | ||
|
|
0c512345b1 | ||
|
|
9fc9e597e1 | ||
|
|
03ffc3c379 | ||
|
|
eb66d72589 | ||
|
|
3ab026aa37 | ||
|
|
bebbf5ad80 | ||
|
|
8f1612d10b | ||
|
|
26da55dfb9 | ||
|
|
ffee9d0551 | ||
|
|
fa4d9313ff | ||
|
|
0ff5092561 | ||
|
|
fd676eda34 | ||
|
|
c46583131e | ||
|
|
1d8fadf18d | ||
|
|
6b988193b3 | ||
|
|
aab7c703ad | ||
|
|
98c1a1f035 | ||
|
|
f51f5f376f | ||
|
|
e0f3bc3bc2 | ||
|
|
c839e7ac4c | ||
|
|
b620bbaad7 | ||
|
|
21779df1c2 | ||
|
|
86e721c159 | ||
|
|
463883383b | ||
|
|
f13ab8500a | ||
|
|
3c7bcfe3e4 | ||
|
|
476a9d78a4 | ||
|
|
fa2d439b3e | ||
|
|
0907c3caa2 | ||
|
|
a4266b1a62 | ||
|
|
05bebd8703 | ||
|
|
2c727ccd47 | ||
|
|
1205cdce1c | ||
|
|
6ae4c37d0c | ||
|
|
4148c0f5fb | ||
|
|
f7e7fda779 | ||
|
|
fbafa3df4e | ||
|
|
7d45249e8f | ||
|
|
96472243db | ||
|
|
4ef0e6bd86 | ||
|
|
daf5dc4f22 | ||
|
|
43f8da30d3 | ||
|
|
d2e5dbd521 | ||
|
|
9c05aaf2df | ||
|
|
4f2e26c31f | ||
|
|
328e031ebd | ||
|
|
93d4e58306 | ||
|
|
2255fb3a6c | ||
|
|
d23a935cae | ||
|
|
57bde730f8 | ||
|
|
01e0587012 | ||
|
|
c9f8b233d5 | ||
|
|
2685188ba6 | ||
|
|
942672ea95 | ||
|
|
ac57cc0202 | ||
|
|
69b5919a96 | ||
|
|
09823fe776 | ||
|
|
a97cb229ff | ||
|
|
cbf3756f16 | ||
|
|
900f62487b | ||
|
|
bc6f9a55e4 | ||
|
|
e7b7a7f46a | ||
|
|
f3b23dadd1 | ||
|
|
8de57d3875 | ||
|
|
bfb6b25c28 | ||
|
|
a24786a513 | ||
|
|
6b9f181585 | ||
|
|
b21e2c6ffd | ||
|
|
96dcbcfb79 | ||
|
|
9c45f933cd | ||
|
|
427c062075 | ||
|
|
2130422c2a | ||
|
|
8133900e76 | ||
|
|
9ad4a3ee87 | ||
|
|
8c6169320a | ||
|
|
ea0916d826 | ||
|
|
14550a89e6 | ||
|
|
c1c5b3e953 | ||
|
|
bebe9671cb | ||
|
|
bcf12e25a1 | ||
|
|
4d9725f66c | ||
|
|
aa045801cc | ||
|
|
8dd54a1c26 | ||
|
|
ce76eb0b74 | ||
|
|
453dfbcfb8 | ||
|
|
0ca4f72e53 | ||
|
|
645e8dc4b2 | ||
|
|
bab44a942a | ||
|
|
9fb24a7329 | ||
|
|
3023316087 | ||
|
|
b5e8b00125 | ||
|
|
3c3d2d94fa | ||
|
|
3476d5fbf0 | ||
|
|
4ca5c5a177 | ||
|
|
feedf88b97 | ||
|
|
80b8029f50 | ||
|
|
fc328f3fd3 | ||
|
|
6a40a70e6d | ||
|
|
b336b04d71 | ||
|
|
d1e3badf21 | ||
|
|
3b916dfb71 | ||
|
|
6f0ee8eb73 | ||
|
|
20b538c1fb | ||
|
|
4541f1435b | ||
|
|
37c1100e37 | ||
|
|
3cc1be0a9d | ||
|
|
4b2c2ad66d | ||
|
|
28b755fe37 | ||
|
|
c021753585 | ||
|
|
bcb9d671c4 | ||
|
|
d2d9477493 | ||
|
|
4951f8113d | ||
|
|
67a2c62d26 | ||
|
|
8a0264081b | ||
|
|
c30ed6c784 | ||
|
|
ab0a91cf28 | ||
|
|
9b5c7909ec | ||
|
|
3aa9e33b4e | ||
|
|
7e387e504d | ||
|
|
d689b24d07 | ||
|
|
84de737d92 | ||
|
|
5f6934e7ea | ||
|
|
884e6dcf96 | ||
|
|
2f3e388d1e | ||
|
|
bbbd7a242d | ||
|
|
d91089ed48 | ||
|
|
c6bdb8ee5a | ||
|
|
d9d2e3b78f | ||
|
|
7f8d6dcd50 | ||
|
|
f4d3b8f657 | ||
|
|
0d1ed7bdd9 | ||
|
|
01fc6db008 | ||
|
|
1ad0bf14a6 | ||
|
|
af623cd8e5 | ||
|
|
f7a909f18e | ||
|
|
3c7a6b53a5 | ||
|
|
8b192bedf4 | ||
|
|
a57130f838 | ||
|
|
6ffed2568a | ||
|
|
f7a796fc2b | ||
|
|
e55bf7064e | ||
|
|
705ec3726b | ||
|
|
e343a1829b | ||
|
|
cf27bccb6e | ||
|
|
07d93c609d | ||
|
|
e6818dcbd0 | ||
|
|
98902a90ac | ||
|
|
27618029d8 | ||
|
|
9e6a22320b | ||
|
|
15ce6810c8 | ||
|
|
dc237b36f3 | ||
|
|
4348221210 | ||
|
|
cb1f42e0a2 | ||
|
|
8526442782 | ||
|
|
a75ac115de | ||
|
|
06e04fa11e | ||
|
|
3dfa53cf01 | ||
|
|
4acc639a8a | ||
|
|
07665cee7e | ||
|
|
9aefa3cf3b | ||
|
|
36be3d8772 | ||
|
|
1af9aaf11f | ||
|
|
69b86a473a | ||
|
|
22fde2d367 | ||
|
|
2b63d7e863 | ||
|
|
0aa6b0b4ae | ||
|
|
ee489534ec | ||
|
|
9f9d96edfe | ||
|
|
395f5c01e1 | ||
|
|
327826d760 | ||
|
|
2441470849 | ||
|
|
be532d5455 |
@@ -5,3 +5,11 @@ pgdata
|
|||||||
docker-compose.yml
|
docker-compose.yml
|
||||||
Dockerfile
|
Dockerfile
|
||||||
README.md
|
README.md
|
||||||
|
.yarn/install-state.gz
|
||||||
|
./apps/mobile
|
||||||
|
**/.next/cache
|
||||||
|
**/.next/cache/**
|
||||||
|
data
|
||||||
|
data.ms
|
||||||
|
.git
|
||||||
|
meili_data
|
||||||
19
.env.sample
@@ -68,6 +68,10 @@ ANTHROPIC_MODEL=
|
|||||||
OPENROUTER_API_KEY=
|
OPENROUTER_API_KEY=
|
||||||
OPENROUTER_MODEL=
|
OPENROUTER_MODEL=
|
||||||
|
|
||||||
|
# https://ai-sdk.dev/providers/ai-sdk-providers/perplexity
|
||||||
|
PERPLEXITY_API_KEY=
|
||||||
|
PERPLEXITY_MODEL=
|
||||||
|
|
||||||
# MeiliSearch Settings
|
# MeiliSearch Settings
|
||||||
MEILI_HOST=
|
MEILI_HOST=
|
||||||
MEILI_MASTER_KEY=
|
MEILI_MASTER_KEY=
|
||||||
@@ -127,10 +131,10 @@ AUTH0_CLIENT_SECRET=
|
|||||||
AUTH0_CLIENT_ID=
|
AUTH0_CLIENT_ID=
|
||||||
|
|
||||||
# Authelia
|
# Authelia
|
||||||
NEXT_PUBLIC_AUTHELIA_ENABLED=""
|
NEXT_PUBLIC_AUTHELIA_ENABLED=
|
||||||
AUTHELIA_CLIENT_ID=""
|
AUTHELIA_CLIENT_ID=
|
||||||
AUTHELIA_CLIENT_SECRET=""
|
AUTHELIA_CLIENT_SECRET=
|
||||||
AUTHELIA_WELLKNOWN_URL=""
|
AUTHELIA_WELLKNOWN_URL=
|
||||||
|
|
||||||
# Authentik
|
# Authentik
|
||||||
NEXT_PUBLIC_AUTHENTIK_ENABLED=
|
NEXT_PUBLIC_AUTHENTIK_ENABLED=
|
||||||
@@ -399,6 +403,13 @@ STRAVA_CUSTOM_NAME=
|
|||||||
STRAVA_CLIENT_ID=
|
STRAVA_CLIENT_ID=
|
||||||
STRAVA_CLIENT_SECRET=
|
STRAVA_CLIENT_SECRET=
|
||||||
|
|
||||||
|
# Synology
|
||||||
|
NEXT_PUBLIC_SYNOLOGY_ENABLED=
|
||||||
|
SYNOLOGY_CUSTOM_NAME=
|
||||||
|
SYNOLOGY_CLIENT_ID=
|
||||||
|
SYNOLOGY_CLIENT_SECRET=
|
||||||
|
SYNOLOGY_WELLKNOWN_URL=
|
||||||
|
|
||||||
# Todoist
|
# Todoist
|
||||||
NEXT_PUBLIC_TODOIST_ENABLED=
|
NEXT_PUBLIC_TODOIST_ENABLED=
|
||||||
TODOIST_CUSTOM_NAME=
|
TODOIST_CUSTOM_NAME=
|
||||||
|
|||||||
14
.github/FUNDING.yml
vendored
@@ -1,13 +1,3 @@
|
|||||||
# These are supported funding model platforms
|
|
||||||
|
|
||||||
github: # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2]
|
|
||||||
patreon: # Replace with a single Patreon username
|
|
||||||
open_collective: linkwarden
|
open_collective: linkwarden
|
||||||
ko_fi: # Replace with a single Ko-fi username
|
github: daniel31x13
|
||||||
tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel
|
buy_me_a_coffee: daniel31x13
|
||||||
community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry
|
|
||||||
liberapay: # Replace with a single Liberapay username
|
|
||||||
issuehunt: # Replace with a single IssueHunt username
|
|
||||||
otechie: # Replace with a single Otechie username
|
|
||||||
lfx_crowdfunding: # Replace with a single LFX Crowdfunding project-name e.g., cloud-foundry
|
|
||||||
custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2']
|
|
||||||
46
.github/pull_request_template.md
vendored
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
## What does this PR do?
|
||||||
|
|
||||||
|
<!-- Please include a summary of the change and which issue is fixed. Please also include relevant motivation and context. List any dependencies that are required for this change. -->
|
||||||
|
|
||||||
|
- Fixes #XXXX (GitHub issue number)
|
||||||
|
|
||||||
|
## Visual Demo
|
||||||
|
|
||||||
|
A visual demonstration is strongly recommended, for both the original and new change **(video / image)**.
|
||||||
|
|
||||||
|
#### Video Demo (if applicable):
|
||||||
|
|
||||||
|
- Show screen recordings of the issue or feature.
|
||||||
|
- Demonstrate how to reproduce the issue, the behavior before and after the change.
|
||||||
|
|
||||||
|
#### Image Demo (if applicable):
|
||||||
|
|
||||||
|
- Add side-by-side screenshots of the original and updated change.
|
||||||
|
- Highlight any significant change(s).
|
||||||
|
|
||||||
|
## AI Assistance (Required)
|
||||||
|
|
||||||
|
We allow AI-assisted development, but reviewers need transparency to assess risk, maintainability, and correctness.
|
||||||
|
|
||||||
|
#### AI usage level (check one)
|
||||||
|
|
||||||
|
- [ ] None (no AI used)
|
||||||
|
- [ ] Light (spellcheck/rewording/comments/docs only)
|
||||||
|
- [ ] Medium (AI suggested small code changes/snippets that I adapted)
|
||||||
|
- [ ] Heavy (AI significantly shaped the implementation or architecture)
|
||||||
|
|
||||||
|
#### Which tool(s) where used?
|
||||||
|
|
||||||
|
- e.g., ChatGPT, Copilot, Cursor, etc.
|
||||||
|
|
||||||
|
## What was verified by the author?
|
||||||
|
|
||||||
|
<!-- Add what you personally checked to ensure correctness and safety. -->
|
||||||
|
|
||||||
|
- [ ] I reviewed **and** understood all AI/human generated code
|
||||||
|
- [ ] I validated behavior locally (tests/manual verification)
|
||||||
|
- [ ] I checked edge cases and failure modes
|
||||||
|
|
||||||
|
## Submission Acknowledgement
|
||||||
|
|
||||||
|
- [ ] I acknowledge that a decent size PR without self-review might be rejected
|
||||||
14
.github/workflows/playwright-tests.yml
vendored
@@ -61,12 +61,18 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
- name: Use Node.js
|
- name: Use Node.js and Enable Yarn 4
|
||||||
uses: actions/setup-node@v4
|
uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: "18"
|
node-version: "20"
|
||||||
cache: 'yarn'
|
|
||||||
|
|
||||||
|
- name: Enable Yarn 4
|
||||||
|
run: |
|
||||||
|
sudo rm -f /usr/bin/yarn /usr/bin/yarnpkg || true
|
||||||
|
corepack enable
|
||||||
|
corepack prepare yarn@4.12.0 --activate
|
||||||
|
yarn --version
|
||||||
|
|
||||||
- name: Initialize PostgreSQL
|
- name: Initialize PostgreSQL
|
||||||
run: |
|
run: |
|
||||||
echo "Initializing Databases"
|
echo "Initializing Databases"
|
||||||
@@ -74,7 +80,7 @@ jobs:
|
|||||||
psql -h localhost -U postgres -d postgres -c "CREATE DATABASE ${{ env.TEST_POSTGRES_DATABASE }} OWNER ${{ env.TEST_POSTGRES_USER }};"
|
psql -h localhost -U postgres -d postgres -c "CREATE DATABASE ${{ env.TEST_POSTGRES_DATABASE }} OWNER ${{ env.TEST_POSTGRES_USER }};"
|
||||||
|
|
||||||
- name: Install packages
|
- name: Install packages
|
||||||
run: yarn install -y
|
run: yarn install --immutable
|
||||||
|
|
||||||
- name: Cache playwright dependencies
|
- name: Cache playwright dependencies
|
||||||
uses: awalsh128/cache-apt-pkgs-action@latest
|
uses: awalsh128/cache-apt-pkgs-action@latest
|
||||||
|
|||||||
9
.gitignore
vendored
@@ -2,6 +2,7 @@
|
|||||||
node_modules
|
node_modules
|
||||||
/.pnp
|
/.pnp
|
||||||
.pnp.js
|
.pnp.js
|
||||||
|
.yarn/install-state.gz
|
||||||
|
|
||||||
# testing
|
# testing
|
||||||
/coverage
|
/coverage
|
||||||
@@ -48,7 +49,11 @@ certificates
|
|||||||
|
|
||||||
# generated files and folders
|
# generated files and folders
|
||||||
/data
|
/data
|
||||||
|
/data.ms
|
||||||
|
meilisearch
|
||||||
|
meili_data
|
||||||
.idea
|
.idea
|
||||||
prisma/dev.db
|
prisma/dev.db
|
||||||
data.ms
|
.turbo
|
||||||
.turbo
|
|
||||||
|
service-account-file.json
|
||||||
1
.yarnrc.yml
Normal file
@@ -0,0 +1 @@
|
|||||||
|
nodeLinker: node-modules
|
||||||
17
Dockerfile
@@ -10,7 +10,13 @@ RUN set -eux && cargo install --locked monolith
|
|||||||
# Purpose: Compiles the frontend and
|
# Purpose: Compiles the frontend and
|
||||||
# Notes:
|
# Notes:
|
||||||
# - Nothing extra should be left here. All commands should cleanup
|
# - Nothing extra should be left here. All commands should cleanup
|
||||||
FROM node:22.14-bullseye-slim AS main-app
|
FROM node:20.19.6-bullseye-slim AS main-app
|
||||||
|
|
||||||
|
ENV YARN_HTTP_TIMEOUT=10000000
|
||||||
|
|
||||||
|
ENV COREPACK_ENABLE_DOWNLOAD_PROMPT=0
|
||||||
|
|
||||||
|
ENV PRISMA_HIDE_UPDATE_MESSAGE=1
|
||||||
|
|
||||||
ARG DEBIAN_FRONTEND=noninteractive
|
ARG DEBIAN_FRONTEND=noninteractive
|
||||||
|
|
||||||
@@ -18,6 +24,10 @@ RUN mkdir /data
|
|||||||
|
|
||||||
WORKDIR /data
|
WORKDIR /data
|
||||||
|
|
||||||
|
RUN corepack enable
|
||||||
|
|
||||||
|
COPY ./.yarnrc.yml ./
|
||||||
|
|
||||||
COPY ./apps/web/package.json ./apps/web/playwright.config.ts ./apps/web/
|
COPY ./apps/web/package.json ./apps/web/playwright.config.ts ./apps/web/
|
||||||
|
|
||||||
COPY ./apps/worker/package.json ./apps/worker/
|
COPY ./apps/worker/package.json ./apps/worker/
|
||||||
@@ -28,7 +38,7 @@ COPY ./yarn.lock ./package.json ./
|
|||||||
|
|
||||||
RUN --mount=type=cache,sharing=locked,target=/usr/local/share/.cache/yarn \
|
RUN --mount=type=cache,sharing=locked,target=/usr/local/share/.cache/yarn \
|
||||||
set -eux && \
|
set -eux && \
|
||||||
yarn install --network-timeout 10000000 && \
|
yarn workspaces focus linkwarden @linkwarden/web @linkwarden/worker && \
|
||||||
# Install curl for healthcheck, and ca-certificates to prevent monolith from failing to retrieve resources due to invalid certificates
|
# Install curl for healthcheck, and ca-certificates to prevent monolith from failing to retrieve resources due to invalid certificates
|
||||||
apt-get update && \
|
apt-get update && \
|
||||||
apt-get install -yqq --no-install-recommends curl ca-certificates && \
|
apt-get install -yqq --no-install-recommends curl ca-certificates && \
|
||||||
@@ -46,7 +56,8 @@ RUN set -eux && \
|
|||||||
COPY . .
|
COPY . .
|
||||||
|
|
||||||
RUN yarn prisma:generate && \
|
RUN yarn prisma:generate && \
|
||||||
yarn web:build
|
yarn web:build && \
|
||||||
|
rm -rf apps/web/.next/cache
|
||||||
|
|
||||||
HEALTHCHECK --interval=30s \
|
HEALTHCHECK --interval=30s \
|
||||||
--timeout=5s \
|
--timeout=5s \
|
||||||
|
|||||||
67
README.md
@@ -38,32 +38,49 @@ Linkwarden is also designed with collaboration in mind, enabling you to share li
|
|||||||
|
|
||||||
## Features
|
## Features
|
||||||
|
|
||||||
- 📸 Auto capture a screenshot, PDF, and single html file of each webpage.
|
- 📸 Auto capture a screenshot, PDF, and single html file of each webpage
|
||||||
- 📖 Reader view of the webpage, with the ability to highlight and annotate text.
|
- 📖 Reader view of the webpage, with the ability to highlight and annotate text
|
||||||
- 🏛️ Send your webpage to Wayback Machine ([archive.org](https://archive.org)) for a snapshot. (Optional)
|
- 🏛️ Send your webpage to Wayback Machine ([archive.org](https://archive.org)) for a snapshot (optional)
|
||||||
- ✨ Local AI Tagging to automatically tag your links based on their content (Optional).
|
- ✨ Local AI Tagging to automatically tag your links based on their content (optional)
|
||||||
- 📂 Organize links by collection, sub-collection, name, description and multiple tags.
|
- 📂 Organize links by collection, sub-collection, name, description and multiple tags
|
||||||
- 👥 Collaborate on gathering links in a collection.
|
- 👥 Collaborate on gathering links in a collection
|
||||||
- 🎛️ Customize the permissions of each member.
|
- 🎛️ Customize the permissions of each member
|
||||||
- 🌐 Share your collected links and preserved formats with the world.
|
- 🌐 Share your collected links and preserved formats with the world
|
||||||
- 📌 Pin your favorite links to dashboard.
|
- 📱 Native iOS and android mobile apps
|
||||||
- 🔍 Full text search, filter and sort for easy retrieval.
|
- 🔍 Full text search, filter and sort for easy retrieval
|
||||||
- 📱 Responsive design and supports most modern browsers.
|
- 🌓 Dark/Light mode support
|
||||||
- 🌓 Dark/Light mode support.
|
- 🧩 Browser extension (star it [here](https://github.com/linkwarden/browser-extension)!)
|
||||||
- 🧩 Browser extension. [Star it here!](https://github.com/linkwarden/browser-extension)
|
|
||||||
- 🔄 Browser Synchronization (using [Floccus](https://floccus.org)!)
|
- 🔄 Browser Synchronization (using [Floccus](https://floccus.org)!)
|
||||||
- ⬇️ Import and export your bookmarks.
|
- ⬆️ Upload from SingleFile (check out the [guide](https://docs.linkwarden.app/Usage/upload-from-singlefile))
|
||||||
- 🔐 SSO integration. (Enterprise and Self-hosted users only)
|
- 🔐 SSO integration (Enterprise and Self-hosted users only)
|
||||||
- 📦 Installable Progressive Web App (PWA).
|
- 🍎 iOS Shortcut to save links to Linkwarden
|
||||||
- 🍎 iOS Shortcut to save Links to Linkwarden.
|
- 🔑 API keys
|
||||||
- 🔑 API keys.
|
- ✅ Bulk actions
|
||||||
- ✅ Bulk actions.
|
- 👥 User administration
|
||||||
- 👥 User administration.
|
- 🌐 Support for other languages (i18n)
|
||||||
- 🌐 Support for Other Languages (i18n).
|
- 📁 Image and PDF uploads
|
||||||
- 📁 Image and PDF Uploads.
|
- 🎨 Custom icons for links and collections
|
||||||
- 🎨 Custom Icons for Links and Collections.
|
- 🔔 RSS feed subscription
|
||||||
- 🔔 RSS Feed Subscription.
|
- ✨ And many more features (literally!)
|
||||||
- ✨ And many more features. (Literally!)
|
|
||||||
|
## Get Our Official Mobile App
|
||||||
|
|
||||||
|
<img src="./assets/mobile_apps.png" alt="Different screens (iPad, Pixel, and iPhone)" width="400" />
|
||||||
|
|
||||||
|
> [!IMPORTANT]
|
||||||
|
> To use the app you’ll first need a Linkwarden account.
|
||||||
|
|
||||||
|
To create an account, you can choose between:
|
||||||
|
|
||||||
|
- [**Linkwarden Cloud**](https://linkwarden.app/#pricing) – instant setup, and your subscription directly supports ongoing development.
|
||||||
|
- [**Self-hosted Linkwarden**](https://docs.linkwarden.app/self-hosting/installation) – free, but you’ll need to deploy and maintain a Linkwarden instance on a server.
|
||||||
|
|
||||||
|
After creating an account, download the app from your preferred store:
|
||||||
|
|
||||||
|
[](https://apps.apple.com/app/linkwarden/id6752550960)
|
||||||
|
[](https://play.google.com/store/apps/details?id=app.linkwarden)
|
||||||
|
|
||||||
|
(To get the app as an APK outside Google Play, check out our [builds](https://github.com/linkwarden/builds) repository.)
|
||||||
|
|
||||||
## Like what we're doing? Give us a Star ⭐
|
## Like what we're doing? Give us a Star ⭐
|
||||||
|
|
||||||
|
|||||||
42
apps/mobile/.easignore
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
# Learn more https://docs.github.com/en/get-started/getting-started-with-git/ignoring-files
|
||||||
|
|
||||||
|
# dependencies
|
||||||
|
node_modules/
|
||||||
|
|
||||||
|
# Expo
|
||||||
|
.expo/
|
||||||
|
dist/
|
||||||
|
web-build/
|
||||||
|
expo-env.d.ts
|
||||||
|
|
||||||
|
# Native
|
||||||
|
*.orig.*
|
||||||
|
*.jks
|
||||||
|
*.p8
|
||||||
|
*.p12
|
||||||
|
*.key
|
||||||
|
*.mobileprovision
|
||||||
|
|
||||||
|
# Metro
|
||||||
|
.metro-health-check*
|
||||||
|
|
||||||
|
# debug
|
||||||
|
npm-debug.*
|
||||||
|
yarn-debug.*
|
||||||
|
yarn-error.*
|
||||||
|
|
||||||
|
# macOS
|
||||||
|
.DS_Store
|
||||||
|
*.pem
|
||||||
|
|
||||||
|
# local env files
|
||||||
|
.env*.local
|
||||||
|
|
||||||
|
# typescript
|
||||||
|
*.tsbuildinfo
|
||||||
|
|
||||||
|
app-example
|
||||||
|
.env
|
||||||
|
|
||||||
|
ios/
|
||||||
|
android/
|
||||||
6
apps/mobile/.gitignore
vendored
@@ -39,4 +39,8 @@ app-example
|
|||||||
.env
|
.env
|
||||||
|
|
||||||
ios/
|
ios/
|
||||||
android/
|
android/
|
||||||
|
|
||||||
|
service-account-file.json
|
||||||
|
|
||||||
|
.env.local
|
||||||
|
|||||||
@@ -1 +0,0 @@
|
|||||||
node-linker=hoisted
|
|
||||||
@@ -2,7 +2,7 @@
|
|||||||
"expo": {
|
"expo": {
|
||||||
"name": "Linkwarden",
|
"name": "Linkwarden",
|
||||||
"slug": "linkwarden",
|
"slug": "linkwarden",
|
||||||
"version": "1.0.0",
|
"version": "1.0.1",
|
||||||
"orientation": "portrait",
|
"orientation": "portrait",
|
||||||
"icon": "./assets/images/icon.png",
|
"icon": "./assets/images/icon.png",
|
||||||
"scheme": "linkwarden",
|
"scheme": "linkwarden",
|
||||||
@@ -10,14 +10,23 @@
|
|||||||
"newArchEnabled": true,
|
"newArchEnabled": true,
|
||||||
"ios": {
|
"ios": {
|
||||||
"supportsTablet": true,
|
"supportsTablet": true,
|
||||||
"bundleIdentifier": "com.anonymous.linkwarden"
|
"bundleIdentifier": "app.linkwarden",
|
||||||
|
"entitlements": {
|
||||||
|
"com.apple.security.application-groups": ["group.app.linkwarden"]
|
||||||
|
},
|
||||||
|
"infoPlist": {
|
||||||
|
"ITSAppUsesNonExemptEncryption": false,
|
||||||
|
"NSAppTransportSecurity": {
|
||||||
|
"NSAllowsArbitraryLoads": true
|
||||||
|
}
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"android": {
|
"android": {
|
||||||
"adaptiveIcon": {
|
"adaptiveIcon": {
|
||||||
"foregroundImage": "./assets/images/adaptive-icon.png",
|
"foregroundImage": "./assets/images/maskable_logo.jpeg",
|
||||||
"backgroundColor": "#ffffff"
|
"backgroundColor": "#ffffff"
|
||||||
},
|
},
|
||||||
"package": "com.anonymous.linkwarden"
|
"package": "app.linkwarden"
|
||||||
},
|
},
|
||||||
"web": {
|
"web": {
|
||||||
"bundler": "metro",
|
"bundler": "metro",
|
||||||
@@ -32,11 +41,44 @@
|
|||||||
"image": "./assets/images/splash-icon.png",
|
"image": "./assets/images/splash-icon.png",
|
||||||
"imageWidth": 200,
|
"imageWidth": 200,
|
||||||
"resizeMode": "contain",
|
"resizeMode": "contain",
|
||||||
"backgroundColor": "#ffffff"
|
"backgroundColor": "#FFFFFF",
|
||||||
|
"dark": {
|
||||||
|
"image": "./assets/images/splash-icon.png",
|
||||||
|
"resizeMode": "contain",
|
||||||
|
"backgroundColor": "#171717"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"expo-secure-store",
|
"expo-secure-store",
|
||||||
"expo-share-intent",
|
[
|
||||||
|
"expo-share-intent",
|
||||||
|
{
|
||||||
|
"iosAppGroupIdentifier": "group.app.linkwarden"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"expo-build-properties",
|
||||||
|
{
|
||||||
|
"android": {
|
||||||
|
"enableProguardInReleaseBuilds": true,
|
||||||
|
"extraProguardRules": "-keep public class com.horcrux.svg.** {*;}",
|
||||||
|
"allowBackup": false,
|
||||||
|
"compileSdkVersion": 35,
|
||||||
|
"targetSdkVersion": 35,
|
||||||
|
"buildToolsVersion": "35.0.0",
|
||||||
|
"usesCleartextTraffic": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"react-native-edge-to-edge",
|
||||||
|
{
|
||||||
|
"android": {
|
||||||
|
"parentTheme": "Default",
|
||||||
|
"enforceNavigationBarContrast": false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
"./plugins/with-daynight-transparent-nav"
|
"./plugins/with-daynight-transparent-nav"
|
||||||
],
|
],
|
||||||
"experiments": {
|
"experiments": {
|
||||||
@@ -50,6 +92,15 @@
|
|||||||
"androidNavigationBar": {
|
"androidNavigationBar": {
|
||||||
"backgroundColor": "#ffffff",
|
"backgroundColor": "#ffffff",
|
||||||
"barStyle": "dark-content"
|
"barStyle": "dark-content"
|
||||||
}
|
},
|
||||||
|
"extra": {
|
||||||
|
"router": {
|
||||||
|
"origin": false
|
||||||
|
},
|
||||||
|
"eas": {
|
||||||
|
"projectId": "34f82639-7a25-4ebe-81c8-2db521b612cf"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"owner": "linkwarden"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import HapticTab from "@/components/HapticTab";
|
|||||||
import TabBarBackground from "@/components/ui/TabBarBackground";
|
import TabBarBackground from "@/components/ui/TabBarBackground";
|
||||||
import { useColorScheme } from "nativewind";
|
import { useColorScheme } from "nativewind";
|
||||||
import { rawTheme, ThemeName } from "@/lib/colors";
|
import { rawTheme, ThemeName } from "@/lib/colors";
|
||||||
import { House, Link, Settings } from "lucide-react-native";
|
import { Folder, Hash, House, Link, Settings } from "lucide-react-native";
|
||||||
|
|
||||||
export default function TabLayout() {
|
export default function TabLayout() {
|
||||||
const { colorScheme } = useColorScheme();
|
const { colorScheme } = useColorScheme();
|
||||||
@@ -23,11 +23,15 @@ export default function TabLayout() {
|
|||||||
borderTopWidth: 0,
|
borderTopWidth: 0,
|
||||||
elevation: 0,
|
elevation: 0,
|
||||||
backgroundColor: rawTheme[colorScheme as ThemeName]["base-200"],
|
backgroundColor: rawTheme[colorScheme as ThemeName]["base-200"],
|
||||||
|
paddingLeft: 5,
|
||||||
|
paddingRight: 5,
|
||||||
},
|
},
|
||||||
default: {
|
default: {
|
||||||
borderTopWidth: 0,
|
borderTopWidth: 0,
|
||||||
backgroundColor: rawTheme[colorScheme as ThemeName]["base-200"],
|
backgroundColor: rawTheme[colorScheme as ThemeName]["base-200"],
|
||||||
elevation: 0,
|
elevation: 0,
|
||||||
|
paddingLeft: 5,
|
||||||
|
paddingRight: 5,
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
}}
|
}}
|
||||||
@@ -37,7 +41,7 @@ export default function TabLayout() {
|
|||||||
options={{
|
options={{
|
||||||
title: "Dashboard",
|
title: "Dashboard",
|
||||||
headerShown: false,
|
headerShown: false,
|
||||||
tabBarIcon: ({ color }) => <House size={26} color={color} />,
|
tabBarIcon: ({ color }) => <House size={24} color={color} />,
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<Tabs.Screen
|
<Tabs.Screen
|
||||||
@@ -45,7 +49,23 @@ export default function TabLayout() {
|
|||||||
options={{
|
options={{
|
||||||
title: "Links",
|
title: "Links",
|
||||||
headerShown: false,
|
headerShown: false,
|
||||||
tabBarIcon: ({ color }) => <Link size={26} color={color} />,
|
tabBarIcon: ({ color }) => <Link size={24} color={color} />,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Tabs.Screen
|
||||||
|
name="collections"
|
||||||
|
options={{
|
||||||
|
title: "Collections",
|
||||||
|
headerShown: false,
|
||||||
|
tabBarIcon: ({ color }) => <Folder size={24} color={color} />,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Tabs.Screen
|
||||||
|
name="tags"
|
||||||
|
options={{
|
||||||
|
title: "Tags",
|
||||||
|
headerShown: false,
|
||||||
|
tabBarIcon: ({ color }) => <Hash size={24} color={color} />,
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<Tabs.Screen
|
<Tabs.Screen
|
||||||
@@ -53,7 +73,7 @@ export default function TabLayout() {
|
|||||||
options={{
|
options={{
|
||||||
title: "Settings",
|
title: "Settings",
|
||||||
headerShown: false,
|
headerShown: false,
|
||||||
tabBarIcon: ({ color }) => <Settings size={26} color={color} />,
|
tabBarIcon: ({ color }) => <Settings size={24} color={color} />,
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
|
|||||||
62
apps/mobile/app/(tabs)/collections/[id].tsx
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
import { useLinks } from "@linkwarden/router/links";
|
||||||
|
import { View, StyleSheet, Platform } from "react-native";
|
||||||
|
import useAuthStore from "@/store/auth";
|
||||||
|
import { useLocalSearchParams, useNavigation } from "expo-router";
|
||||||
|
import React, { useEffect } from "react";
|
||||||
|
import { useCollections } from "@linkwarden/router/collections";
|
||||||
|
import Links from "@/components/Links";
|
||||||
|
|
||||||
|
export default function LinksScreen() {
|
||||||
|
const { auth } = useAuthStore();
|
||||||
|
const { search, id } = useLocalSearchParams<{
|
||||||
|
search?: string;
|
||||||
|
id: string;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const { links, data } = useLinks(
|
||||||
|
{
|
||||||
|
sort: 0,
|
||||||
|
searchQueryString: decodeURIComponent(search ?? ""),
|
||||||
|
collectionId: Number(id),
|
||||||
|
},
|
||||||
|
auth
|
||||||
|
);
|
||||||
|
|
||||||
|
const collections = useCollections(auth);
|
||||||
|
|
||||||
|
const navigation = useNavigation();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const activeCollection = collections.data?.filter(
|
||||||
|
(e) => e.id === Number(id)
|
||||||
|
)[0];
|
||||||
|
|
||||||
|
if (activeCollection?.name)
|
||||||
|
navigation?.setOptions?.({
|
||||||
|
headerTitle: activeCollection?.name,
|
||||||
|
headerSearchBarOptions: {
|
||||||
|
placeholder: `Search ${activeCollection.name}`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}, [navigation]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View
|
||||||
|
style={styles.container}
|
||||||
|
className="h-full bg-base-100"
|
||||||
|
collapsable={false}
|
||||||
|
collapsableChildren={false}
|
||||||
|
>
|
||||||
|
<Links links={links} data={data} />
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
container: Platform.select({
|
||||||
|
ios: {
|
||||||
|
paddingBottom: 83,
|
||||||
|
},
|
||||||
|
default: {},
|
||||||
|
}),
|
||||||
|
});
|
||||||
44
apps/mobile/app/(tabs)/collections/_layout.tsx
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
import { Stack, useRouter } from "expo-router";
|
||||||
|
import { useColorScheme } from "nativewind";
|
||||||
|
import { rawTheme, ThemeName } from "@/lib/colors";
|
||||||
|
import { Platform } from "react-native";
|
||||||
|
|
||||||
|
export default function Layout() {
|
||||||
|
const router = useRouter();
|
||||||
|
const { colorScheme } = useColorScheme();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Stack
|
||||||
|
screenOptions={{
|
||||||
|
headerTitle: "Collections",
|
||||||
|
headerLargeTitle: true,
|
||||||
|
headerTransparent: Platform.OS === "ios" ? true : false,
|
||||||
|
headerShadowVisible: false,
|
||||||
|
headerBlurEffect:
|
||||||
|
colorScheme === "dark" ? "systemMaterialDark" : "systemMaterial",
|
||||||
|
headerTintColor: colorScheme === "dark" ? "white" : "black",
|
||||||
|
headerSearchBarOptions: {
|
||||||
|
placeholder: "Search Collections",
|
||||||
|
autoCapitalize: "none",
|
||||||
|
onChangeText: (e) => {
|
||||||
|
router.setParams({
|
||||||
|
search: encodeURIComponent(e.nativeEvent.text),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
headerIconColor: colorScheme === "dark" ? "white" : "black",
|
||||||
|
},
|
||||||
|
headerLargeStyle: {
|
||||||
|
backgroundColor: rawTheme[colorScheme as ThemeName]["base-100"],
|
||||||
|
},
|
||||||
|
headerStyle: {
|
||||||
|
backgroundColor:
|
||||||
|
Platform.OS === "ios"
|
||||||
|
? "transparent"
|
||||||
|
: colorScheme === "dark"
|
||||||
|
? rawTheme["dark"]["base-100"]
|
||||||
|
: "white",
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
94
apps/mobile/app/(tabs)/collections/index.tsx
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
import {
|
||||||
|
View,
|
||||||
|
StyleSheet,
|
||||||
|
FlatList,
|
||||||
|
Platform,
|
||||||
|
Text,
|
||||||
|
ActivityIndicator,
|
||||||
|
} from "react-native";
|
||||||
|
import useAuthStore from "@/store/auth";
|
||||||
|
import CollectionListing from "@/components/CollectionListing";
|
||||||
|
import { useLocalSearchParams } from "expo-router";
|
||||||
|
import React, { useEffect, useState } from "react";
|
||||||
|
import Spinner from "@/components/ui/Spinner";
|
||||||
|
import { rawTheme, ThemeName } from "@/lib/colors";
|
||||||
|
import { useColorScheme } from "nativewind";
|
||||||
|
import { useCollections } from "@linkwarden/router/collections";
|
||||||
|
import { CollectionIncludingMembersAndLinkCount } from "@linkwarden/types";
|
||||||
|
|
||||||
|
export default function CollectionsScreen() {
|
||||||
|
const { colorScheme } = useColorScheme();
|
||||||
|
const { auth } = useAuthStore();
|
||||||
|
const { search } = useLocalSearchParams<{ search?: string }>();
|
||||||
|
|
||||||
|
const collections = useCollections(auth);
|
||||||
|
|
||||||
|
const [filteredCollections, setFilteredCollections] = useState<
|
||||||
|
CollectionIncludingMembersAndLinkCount[]
|
||||||
|
>([]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const filter =
|
||||||
|
collections.data?.filter((e) =>
|
||||||
|
e.name.includes(decodeURIComponent(search || ""))
|
||||||
|
) || [];
|
||||||
|
|
||||||
|
setFilteredCollections(filter);
|
||||||
|
}, [search, collections.data]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View
|
||||||
|
style={styles.container}
|
||||||
|
className="h-full bg-base-100"
|
||||||
|
collapsable={false}
|
||||||
|
collapsableChildren={false}
|
||||||
|
>
|
||||||
|
{collections.isLoading ? (
|
||||||
|
<View className="flex justify-center h-screen items-center">
|
||||||
|
<ActivityIndicator size="large" />
|
||||||
|
<Text className="text-base mt-2.5 text-neutral">Loading...</Text>
|
||||||
|
</View>
|
||||||
|
) : (
|
||||||
|
<FlatList
|
||||||
|
contentInsetAdjustmentBehavior="automatic"
|
||||||
|
ListHeaderComponent={() => <></>}
|
||||||
|
data={filteredCollections}
|
||||||
|
refreshControl={
|
||||||
|
<Spinner
|
||||||
|
refreshing={collections.isRefetching}
|
||||||
|
onRefresh={() => collections.refetch()}
|
||||||
|
progressBackgroundColor={
|
||||||
|
rawTheme[colorScheme as ThemeName]["base-200"]
|
||||||
|
}
|
||||||
|
colors={[rawTheme[colorScheme as ThemeName]["base-content"]]}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
refreshing={collections.isRefetching}
|
||||||
|
initialNumToRender={4}
|
||||||
|
keyExtractor={(item) => item.id?.toString() || ""}
|
||||||
|
renderItem={({ item }) => <CollectionListing collection={item} />}
|
||||||
|
onEndReachedThreshold={0.5}
|
||||||
|
ItemSeparatorComponent={() => (
|
||||||
|
<View className="bg-neutral-content h-px" />
|
||||||
|
)}
|
||||||
|
ListEmptyComponent={
|
||||||
|
<View className="flex justify-center py-10 items-center">
|
||||||
|
<Text className="text-center text-xl text-neutral">
|
||||||
|
Nothing found...
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
container: Platform.select({
|
||||||
|
ios: {
|
||||||
|
paddingBottom: 83,
|
||||||
|
},
|
||||||
|
default: {},
|
||||||
|
}),
|
||||||
|
});
|
||||||
@@ -1,17 +1,10 @@
|
|||||||
import { useLinks } from "@linkwarden/router/links";
|
import { useLinks } from "@linkwarden/router/links";
|
||||||
import { View, StyleSheet, FlatList, Platform } from "react-native";
|
import { View, StyleSheet, Platform } from "react-native";
|
||||||
import useAuthStore from "@/store/auth";
|
import useAuthStore from "@/store/auth";
|
||||||
import LinkListing from "@/components/LinkListing";
|
|
||||||
import { useLocalSearchParams, useNavigation } from "expo-router";
|
import { useLocalSearchParams, useNavigation } from "expo-router";
|
||||||
import React, { useEffect, useState } from "react";
|
import React, { useEffect, useMemo } from "react";
|
||||||
import { LinkIncludingShortenedCollectionAndTags } from "@linkwarden/types";
|
|
||||||
import { useCollections } from "@linkwarden/router/collections";
|
import { useCollections } from "@linkwarden/router/collections";
|
||||||
|
import Links from "@/components/Links";
|
||||||
const RenderItem = React.memo(
|
|
||||||
({ item }: { item: LinkIncludingShortenedCollectionAndTags }) => {
|
|
||||||
return <LinkListing link={item} />;
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
export default function LinksScreen() {
|
export default function LinksScreen() {
|
||||||
const { auth } = useAuthStore();
|
const { auth } = useAuthStore();
|
||||||
@@ -24,19 +17,28 @@ export default function LinksScreen() {
|
|||||||
const navigation = useNavigation();
|
const navigation = useNavigation();
|
||||||
const collections = useCollections(auth);
|
const collections = useCollections(auth);
|
||||||
|
|
||||||
|
const title = useMemo(() => {
|
||||||
|
if (section === "pinned-links") return "Pinned Links";
|
||||||
|
if (section === "recent-links") return "Recent Links";
|
||||||
|
|
||||||
|
if (section === "collection") {
|
||||||
|
return (
|
||||||
|
collections.data?.find((c) => c.id?.toString() === collectionId)
|
||||||
|
?.name || "Collection"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return "Links";
|
||||||
|
}, [section, collections.data, collectionId]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
navigation.setOptions({
|
navigation.setOptions({
|
||||||
headerTitle:
|
headerTitle: title,
|
||||||
section === "pinned-links"
|
headerSearchBarOptions: {
|
||||||
? "Pinned Links"
|
placeholder: `Search ${title}`,
|
||||||
: section === "recent-links"
|
},
|
||||||
? "Recent Links"
|
|
||||||
: section === "collection"
|
|
||||||
? collections.data?.find((c) => c.id?.toString() === collectionId)
|
|
||||||
?.name || "Collection"
|
|
||||||
: "Links",
|
|
||||||
});
|
});
|
||||||
}, [section, navigation]);
|
}, [title, navigation]);
|
||||||
|
|
||||||
const { links, data } = useLinks(
|
const { links, data } = useLinks(
|
||||||
{
|
{
|
||||||
@@ -55,21 +57,7 @@ export default function LinksScreen() {
|
|||||||
collapsable={false}
|
collapsable={false}
|
||||||
collapsableChildren={false}
|
collapsableChildren={false}
|
||||||
>
|
>
|
||||||
<FlatList
|
<Links links={links} data={data} />
|
||||||
contentInsetAdjustmentBehavior="automatic"
|
|
||||||
ListHeaderComponent={() => <></>}
|
|
||||||
data={links || []}
|
|
||||||
onRefresh={() => data.refetch()}
|
|
||||||
refreshing={data.isRefetching}
|
|
||||||
initialNumToRender={4}
|
|
||||||
keyExtractor={(item) => item.id?.toString() || ""}
|
|
||||||
renderItem={({ item }) => (
|
|
||||||
<RenderItem item={item} key={item.id?.toString()} />
|
|
||||||
)}
|
|
||||||
onEndReached={() => data.fetchNextPage()}
|
|
||||||
onEndReachedThreshold={0.5}
|
|
||||||
ItemSeparatorComponent={() => <View className="bg-base-200 h-px" />}
|
|
||||||
/>
|
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import { Platform, TouchableOpacity } from "react-native";
|
|||||||
import { SheetManager } from "react-native-actions-sheet";
|
import { SheetManager } from "react-native-actions-sheet";
|
||||||
import * as DropdownMenu from "zeego/dropdown-menu";
|
import * as DropdownMenu from "zeego/dropdown-menu";
|
||||||
|
|
||||||
export default function RootLayout() {
|
export default function Layout() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const { colorScheme } = useColorScheme();
|
const { colorScheme } = useColorScheme();
|
||||||
|
|
||||||
@@ -27,8 +27,8 @@ export default function RootLayout() {
|
|||||||
Platform.OS === "ios"
|
Platform.OS === "ios"
|
||||||
? "transparent"
|
? "transparent"
|
||||||
: colorScheme === "dark"
|
: colorScheme === "dark"
|
||||||
? rawTheme["dark"]["base-100"]
|
? rawTheme["dark"]["base-100"]
|
||||||
: "white",
|
: "white",
|
||||||
},
|
},
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@@ -54,9 +54,12 @@ export default function RootLayout() {
|
|||||||
>
|
>
|
||||||
<DropdownMenu.ItemTitle>New Link</DropdownMenu.ItemTitle>
|
<DropdownMenu.ItemTitle>New Link</DropdownMenu.ItemTitle>
|
||||||
</DropdownMenu.Item>
|
</DropdownMenu.Item>
|
||||||
<DropdownMenu.Item key="more-options" disabled>
|
<DropdownMenu.Item
|
||||||
|
key="new-collection"
|
||||||
|
onSelect={() => SheetManager.show("new-collection-sheet")}
|
||||||
|
>
|
||||||
<DropdownMenu.ItemTitle>
|
<DropdownMenu.ItemTitle>
|
||||||
More Coming Soon!
|
New Collection
|
||||||
</DropdownMenu.ItemTitle>
|
</DropdownMenu.ItemTitle>
|
||||||
</DropdownMenu.Item>
|
</DropdownMenu.Item>
|
||||||
</DropdownMenu.Content>
|
</DropdownMenu.Content>
|
||||||
|
|||||||
@@ -1,36 +1,22 @@
|
|||||||
import {
|
import {
|
||||||
FlatList,
|
ActivityIndicator,
|
||||||
Platform,
|
Platform,
|
||||||
RefreshControl,
|
|
||||||
ScrollView,
|
ScrollView,
|
||||||
StyleSheet,
|
StyleSheet,
|
||||||
Text,
|
Text,
|
||||||
TouchableOpacity,
|
|
||||||
View,
|
View,
|
||||||
} from "react-native";
|
} from "react-native";
|
||||||
import { SafeAreaView } from "react-native-safe-area-context";
|
import React, { useEffect, useMemo, useState } from "react";
|
||||||
import { useDashboardData } from "@linkwarden/router/dashboardData";
|
import { useDashboardData } from "@linkwarden/router/dashboardData";
|
||||||
import useAuthStore from "@/store/auth";
|
import useAuthStore from "@/store/auth";
|
||||||
import React, { useEffect, useMemo, useState } from "react";
|
import { DashboardSection as DashboardSectionType } from "@linkwarden/prisma/client";
|
||||||
import { DashboardSection, DashboardSectionType } from "@prisma/client";
|
|
||||||
import { useUser } from "@linkwarden/router/user";
|
import { useUser } from "@linkwarden/router/user";
|
||||||
import { useCollections } from "@linkwarden/router/collections";
|
import { useCollections } from "@linkwarden/router/collections";
|
||||||
import { useTags } from "@linkwarden/router/tags";
|
import { useTags } from "@linkwarden/router/tags";
|
||||||
import clsx from "clsx";
|
|
||||||
import DashboardItem from "@/components/DashboardItem";
|
|
||||||
import { LinkIncludingShortenedCollectionAndTags } from "@linkwarden/types";
|
|
||||||
import LinkListing from "@/components/LinkListing";
|
|
||||||
import { useRouter } from "expo-router";
|
|
||||||
import { rawTheme, ThemeName } from "@/lib/colors";
|
import { rawTheme, ThemeName } from "@/lib/colors";
|
||||||
import { useColorScheme } from "nativewind";
|
import { useColorScheme } from "nativewind";
|
||||||
import {
|
import Spinner from "@/components/ui/Spinner";
|
||||||
Clock8,
|
import DashboardSection from "@/components/DashboardSection";
|
||||||
ChevronRight,
|
|
||||||
Pin,
|
|
||||||
Folder,
|
|
||||||
Hash,
|
|
||||||
Link,
|
|
||||||
} from "lucide-react-native";
|
|
||||||
|
|
||||||
export default function DashboardScreen() {
|
export default function DashboardScreen() {
|
||||||
const { auth } = useAuthStore();
|
const { auth } = useAuthStore();
|
||||||
@@ -41,15 +27,13 @@ export default function DashboardScreen() {
|
|||||||
...dashboardData
|
...dashboardData
|
||||||
} = useDashboardData(auth);
|
} = useDashboardData(auth);
|
||||||
const { data: user, ...userData } = useUser(auth);
|
const { data: user, ...userData } = useUser(auth);
|
||||||
const { data: collections = [] } = useCollections(auth);
|
const { data: collections = [], ...collectionsData } = useCollections(auth);
|
||||||
const { data: tags = [] } = useTags(auth);
|
const { data: tags = [], ...tagsData } = useTags(auth);
|
||||||
|
|
||||||
const { colorScheme } = useColorScheme();
|
const { colorScheme } = useColorScheme();
|
||||||
|
|
||||||
const router = useRouter();
|
|
||||||
|
|
||||||
const [dashboardSections, setDashboardSections] = useState<
|
const [dashboardSections, setDashboardSections] = useState<
|
||||||
DashboardSection[]
|
DashboardSectionType[]
|
||||||
>(user?.dashboardSections || []);
|
>(user?.dashboardSections || []);
|
||||||
|
|
||||||
const [numberOfLinks, setNumberOfLinks] = useState(0);
|
const [numberOfLinks, setNumberOfLinks] = useState(0);
|
||||||
@@ -76,318 +60,73 @@ export default function DashboardScreen() {
|
|||||||
});
|
});
|
||||||
}, [dashboardSections]);
|
}, [dashboardSections]);
|
||||||
|
|
||||||
interface SectionProps {
|
const [pullRefreshing, setPullRefreshing] = useState(false);
|
||||||
sectionData: { type: DashboardSectionType };
|
|
||||||
collection?: any;
|
|
||||||
links?: any[];
|
|
||||||
tagsLength: number;
|
|
||||||
numberOfLinks: number;
|
|
||||||
collectionsLength: number;
|
|
||||||
numberOfPinnedLinks: number;
|
|
||||||
dashboardData: { isLoading: boolean };
|
|
||||||
collectionLinks?: any[];
|
|
||||||
}
|
|
||||||
|
|
||||||
const Section: React.FC<SectionProps> = ({
|
const onRefresh = async () => {
|
||||||
sectionData,
|
setPullRefreshing(true);
|
||||||
collection,
|
try {
|
||||||
links = [],
|
await Promise.all([
|
||||||
tagsLength,
|
dashboardData.refetch(),
|
||||||
numberOfLinks,
|
userData.refetch(),
|
||||||
collectionsLength,
|
collectionsData.refetch(),
|
||||||
numberOfPinnedLinks,
|
tagsData.refetch(),
|
||||||
dashboardData,
|
]);
|
||||||
collectionLinks = [],
|
} finally {
|
||||||
}) => {
|
setPullRefreshing(false);
|
||||||
switch (sectionData.type) {
|
|
||||||
case DashboardSectionType.STATS:
|
|
||||||
return (
|
|
||||||
<View className="flex-col gap-4 max-w-full px-5">
|
|
||||||
<View className="flex-row gap-4">
|
|
||||||
<DashboardItem
|
|
||||||
name={numberOfLinks === 1 ? "Link" : "Links"}
|
|
||||||
value={numberOfLinks}
|
|
||||||
icon={<Link size={23} color="white" />}
|
|
||||||
color="#9c00cc"
|
|
||||||
/>
|
|
||||||
<DashboardItem
|
|
||||||
name={collectionsLength === 1 ? "Collection" : "Collections"}
|
|
||||||
value={collectionsLength}
|
|
||||||
icon={<Folder size={23} color="white" fill="white" />}
|
|
||||||
color="#0096cc"
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
<View className="flex-row gap-4">
|
|
||||||
<DashboardItem
|
|
||||||
name={tagsLength === 1 ? "Tag" : "Tags"}
|
|
||||||
value={tagsLength}
|
|
||||||
icon={<Hash size={23} color="white" />}
|
|
||||||
color="#00cc99"
|
|
||||||
/>
|
|
||||||
<DashboardItem
|
|
||||||
name={"Pinned Links"}
|
|
||||||
value={numberOfPinnedLinks}
|
|
||||||
icon={<Pin size={23} color="white" fill="white" />}
|
|
||||||
color="#cc6d00"
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
|
|
||||||
case DashboardSectionType.RECENT_LINKS:
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<View className="flex-row justify-between items-center px-5">
|
|
||||||
<View className="flex-row gap-2 items-center">
|
|
||||||
<View className={"flex-row items-center gap-2"}>
|
|
||||||
<Clock8
|
|
||||||
size={30}
|
|
||||||
color={rawTheme[colorScheme as ThemeName].primary}
|
|
||||||
/>
|
|
||||||
<Text className="text-2xl capitalize text-base-content">
|
|
||||||
Recent Links
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
<TouchableOpacity
|
|
||||||
className="flex-row items-center text-sm gap-1"
|
|
||||||
onPress={() =>
|
|
||||||
router.navigate("/(tabs)/dashboard/recent-links")
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<Text className="text-primary">View All</Text>
|
|
||||||
<ChevronRight
|
|
||||||
size={15}
|
|
||||||
color={rawTheme[colorScheme as ThemeName].primary}
|
|
||||||
/>
|
|
||||||
</TouchableOpacity>
|
|
||||||
</View>
|
|
||||||
|
|
||||||
{dashboardData.isLoading ||
|
|
||||||
(links.length > 0 && !dashboardData.isLoading) ? (
|
|
||||||
<FlatList
|
|
||||||
horizontal
|
|
||||||
showsHorizontalScrollIndicator={false}
|
|
||||||
directionalLockEnabled
|
|
||||||
data={links || []}
|
|
||||||
refreshing={dashboardData.isLoading}
|
|
||||||
initialNumToRender={2}
|
|
||||||
keyExtractor={(item) => item.id?.toString() || ""}
|
|
||||||
renderItem={({ item }) => (
|
|
||||||
<RenderItem item={item} key={item.id?.toString()} />
|
|
||||||
)}
|
|
||||||
ItemSeparatorComponent={() => <View style={{ width: 10 }} />}
|
|
||||||
contentContainerStyle={{
|
|
||||||
paddingHorizontal: 20,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<View className="flex-col gap-2 justify-center items-center h-40 p-10 rounded-xl bg-base-200 mx-5">
|
|
||||||
<Clock8
|
|
||||||
size={40}
|
|
||||||
color={rawTheme[colorScheme as ThemeName].primary}
|
|
||||||
/>
|
|
||||||
<Text className="text-center text-xl text-neutral">
|
|
||||||
No Recent Links
|
|
||||||
</Text>
|
|
||||||
|
|
||||||
{/* <View className="text-center w-full mt-4 flex-row flex-wrap gap-4 justify-center">
|
|
||||||
<Button onPress={() => setNewLinkModal(true)} variant="accent">
|
|
||||||
<Icon name="bi-plus-lg" className="text-xl" />
|
|
||||||
<Text>{t("add_link")}</Text>
|
|
||||||
</Button>
|
|
||||||
<ImportDropdown />
|
|
||||||
</View> */}
|
|
||||||
</View>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
|
|
||||||
case DashboardSectionType.PINNED_LINKS:
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<View className="flex-row justify-between items-center px-5">
|
|
||||||
<View className="flex-row gap-2 items-center">
|
|
||||||
<View className={"flex-row items-center gap-2"}>
|
|
||||||
<Pin
|
|
||||||
size={30}
|
|
||||||
color={rawTheme[colorScheme as ThemeName].primary}
|
|
||||||
/>
|
|
||||||
<Text className="text-2xl capitalize text-base-content">
|
|
||||||
Pinned Links
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
<TouchableOpacity
|
|
||||||
className="flex-row items-center text-sm gap-1"
|
|
||||||
onPress={() =>
|
|
||||||
router.navigate("/(tabs)/dashboard/pinned-links")
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<Text className="text-primary">View All</Text>
|
|
||||||
<ChevronRight
|
|
||||||
size={15}
|
|
||||||
color={rawTheme[colorScheme as ThemeName].primary}
|
|
||||||
/>
|
|
||||||
</TouchableOpacity>
|
|
||||||
</View>
|
|
||||||
|
|
||||||
{dashboardData.isLoading ||
|
|
||||||
links?.some((e: any) => e.pinnedBy && e.pinnedBy[0]) ? (
|
|
||||||
<FlatList
|
|
||||||
horizontal
|
|
||||||
showsHorizontalScrollIndicator={false}
|
|
||||||
data={
|
|
||||||
links.filter((e: any) => e.pinnedBy && e.pinnedBy[0]) || []
|
|
||||||
}
|
|
||||||
// onRefresh={() => data.refetch()}
|
|
||||||
refreshing={dashboardData.isLoading}
|
|
||||||
initialNumToRender={2}
|
|
||||||
keyExtractor={(item) => item.id?.toString() || ""}
|
|
||||||
renderItem={({ item }) => (
|
|
||||||
<RenderItem item={item} key={item.id?.toString()} />
|
|
||||||
)}
|
|
||||||
ItemSeparatorComponent={() => <View style={{ width: 10 }} />}
|
|
||||||
contentContainerStyle={{
|
|
||||||
paddingHorizontal: 20,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<View className="flex-col gap-2 justify-center items-center h-40 p-10 rounded-xl bg-base-200 mx-5">
|
|
||||||
<Pin
|
|
||||||
size={40}
|
|
||||||
color={rawTheme[colorScheme as ThemeName].primary}
|
|
||||||
/>
|
|
||||||
<Text className="text-center text-xl text-neutral">
|
|
||||||
No Pinned Links
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
|
|
||||||
case DashboardSectionType.COLLECTION:
|
|
||||||
return collection?.id ? (
|
|
||||||
<>
|
|
||||||
<View className="flex-row justify-between items-center px-5">
|
|
||||||
<View className="flex-row gap-2 items-center max-w-[60%]">
|
|
||||||
<View className={clsx("flex-row items-center gap-2")}>
|
|
||||||
<Folder
|
|
||||||
size={30}
|
|
||||||
fill={collection.color || "#0ea5e9"}
|
|
||||||
color={collection.color || "#0ea5e9"}
|
|
||||||
/>
|
|
||||||
<Text
|
|
||||||
className="text-2xl capitalize w-full text-base-content"
|
|
||||||
numberOfLines={1}
|
|
||||||
>
|
|
||||||
{collection.name}
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
<TouchableOpacity
|
|
||||||
className="flex-row items-center text-sm gap-1 whitespace-nowrap"
|
|
||||||
onPress={() =>
|
|
||||||
router.navigate(
|
|
||||||
`/(tabs)/dashboard/collection?collectionId=${collection.id}`
|
|
||||||
)
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<Text className="text-primary">View All</Text>
|
|
||||||
<ChevronRight
|
|
||||||
size={15}
|
|
||||||
color={rawTheme[colorScheme as ThemeName].primary}
|
|
||||||
/>
|
|
||||||
</TouchableOpacity>
|
|
||||||
</View>
|
|
||||||
|
|
||||||
{dashboardData.isLoading || collectionLinks.length > 0 ? (
|
|
||||||
<FlatList
|
|
||||||
horizontal
|
|
||||||
showsHorizontalScrollIndicator={false}
|
|
||||||
data={collectionLinks || []}
|
|
||||||
// onRefresh={() => data.refetch()}
|
|
||||||
refreshing={dashboardData.isLoading}
|
|
||||||
initialNumToRender={2}
|
|
||||||
keyExtractor={(item) => item.id?.toString() || ""}
|
|
||||||
renderItem={({ item }) => (
|
|
||||||
<RenderItem item={item} key={item.id?.toString()} />
|
|
||||||
)}
|
|
||||||
ItemSeparatorComponent={() => <View style={{ width: 10 }} />}
|
|
||||||
contentContainerStyle={{
|
|
||||||
paddingHorizontal: 20,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<View className="flex-col gap-2 justify-center items-center h-40 p-10 rounded-xl bg-base-200 mx-5">
|
|
||||||
<Text className="text-center text-xl text-neutral">
|
|
||||||
Empty Collection
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
) : null;
|
|
||||||
|
|
||||||
default:
|
|
||||||
return null;
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const RenderItem = React.memo(
|
if (orderedSections.length === 0 && dashboardData.isLoading)
|
||||||
({ item }: { item: LinkIncludingShortenedCollectionAndTags }) => {
|
return (
|
||||||
return <LinkListing link={item} dashboard />;
|
<View className="flex justify-center h-screen items-center bg-base-100">
|
||||||
}
|
<ActivityIndicator size="large" />
|
||||||
);
|
<Text className="text-base mt-2.5 text-neutral">Loading...</Text>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SafeAreaView
|
<ScrollView
|
||||||
style={styles.container}
|
refreshControl={
|
||||||
collapsable={false}
|
<Spinner
|
||||||
collapsableChildren={false}
|
refreshing={pullRefreshing}
|
||||||
className="bg-base-100 h-full"
|
onRefresh={onRefresh}
|
||||||
|
progressBackgroundColor={
|
||||||
|
rawTheme[colorScheme as ThemeName]["base-200"]
|
||||||
|
}
|
||||||
|
colors={[rawTheme[colorScheme as ThemeName]["base-content"]]}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
contentContainerStyle={styles.container}
|
||||||
|
className="bg-base-100"
|
||||||
|
contentInsetAdjustmentBehavior="automatic"
|
||||||
>
|
>
|
||||||
<ScrollView
|
{orderedSections.map((sectionData, i) => {
|
||||||
refreshControl={
|
if (!collections || !collections[0]) return null;
|
||||||
<RefreshControl
|
|
||||||
refreshing={dashboardData.isLoading || userData.isLoading}
|
const collection = collections.find(
|
||||||
onRefresh={() => {
|
(c) => c.id === sectionData.collectionId
|
||||||
dashboardData.refetch();
|
);
|
||||||
userData.refetch();
|
|
||||||
}}
|
return (
|
||||||
|
<DashboardSection
|
||||||
|
key={sectionData.id}
|
||||||
|
sectionData={sectionData}
|
||||||
|
collection={collection}
|
||||||
|
collectionLinks={
|
||||||
|
sectionData.collectionId
|
||||||
|
? collectionLinks[sectionData.collectionId]
|
||||||
|
: []
|
||||||
|
}
|
||||||
|
links={links}
|
||||||
|
tagsLength={tags.length}
|
||||||
|
numberOfLinks={numberOfLinks}
|
||||||
|
collectionsLength={collections.length}
|
||||||
|
numberOfPinnedLinks={numberOfPinnedLinks}
|
||||||
|
dashboardData={dashboardData}
|
||||||
/>
|
/>
|
||||||
}
|
);
|
||||||
contentContainerStyle={{
|
})}
|
||||||
flexDirection: "column",
|
</ScrollView>
|
||||||
gap: 15,
|
|
||||||
paddingVertical: 20,
|
|
||||||
}}
|
|
||||||
className="bg-base-100"
|
|
||||||
contentInsetAdjustmentBehavior="automatic"
|
|
||||||
>
|
|
||||||
{orderedSections.map((sectionData) => {
|
|
||||||
return (
|
|
||||||
<Section
|
|
||||||
key={sectionData.id}
|
|
||||||
sectionData={sectionData}
|
|
||||||
collection={collections.find(
|
|
||||||
(c) => c.id === sectionData.collectionId
|
|
||||||
)}
|
|
||||||
collectionLinks={
|
|
||||||
sectionData.collectionId
|
|
||||||
? collectionLinks[sectionData.collectionId]
|
|
||||||
: []
|
|
||||||
}
|
|
||||||
links={links}
|
|
||||||
tagsLength={tags.length}
|
|
||||||
numberOfLinks={numberOfLinks}
|
|
||||||
collectionsLength={collections.length}
|
|
||||||
numberOfPinnedLinks={numberOfPinnedLinks}
|
|
||||||
dashboardData={dashboardData}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</ScrollView>
|
|
||||||
</SafeAreaView>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -395,7 +134,14 @@ const styles = StyleSheet.create({
|
|||||||
container: Platform.select({
|
container: Platform.select({
|
||||||
ios: {
|
ios: {
|
||||||
paddingBottom: 49,
|
paddingBottom: 49,
|
||||||
|
flexDirection: "column",
|
||||||
|
gap: 15,
|
||||||
|
paddingVertical: 20,
|
||||||
|
},
|
||||||
|
default: {
|
||||||
|
flexDirection: "column",
|
||||||
|
gap: 15,
|
||||||
|
paddingVertical: 20,
|
||||||
},
|
},
|
||||||
default: {},
|
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { useColorScheme } from "nativewind";
|
|||||||
import { rawTheme, ThemeName } from "@/lib/colors";
|
import { rawTheme, ThemeName } from "@/lib/colors";
|
||||||
import { Platform } from "react-native";
|
import { Platform } from "react-native";
|
||||||
|
|
||||||
export default function RootLayout() {
|
export default function Layout() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const { colorScheme } = useColorScheme();
|
const { colorScheme } = useColorScheme();
|
||||||
|
|
||||||
@@ -18,7 +18,7 @@ export default function RootLayout() {
|
|||||||
colorScheme === "dark" ? "systemMaterialDark" : "systemMaterial",
|
colorScheme === "dark" ? "systemMaterialDark" : "systemMaterial",
|
||||||
headerTintColor: colorScheme === "dark" ? "white" : "black",
|
headerTintColor: colorScheme === "dark" ? "white" : "black",
|
||||||
headerSearchBarOptions: {
|
headerSearchBarOptions: {
|
||||||
placeholder: "Search",
|
placeholder: "Search Links",
|
||||||
autoCapitalize: "none",
|
autoCapitalize: "none",
|
||||||
onChangeText: (e) => {
|
onChangeText: (e) => {
|
||||||
router.setParams({
|
router.setParams({
|
||||||
|
|||||||
@@ -1,16 +1,9 @@
|
|||||||
import { useLinks } from "@linkwarden/router/links";
|
import { useLinks } from "@linkwarden/router/links";
|
||||||
import { View, StyleSheet, FlatList, Platform } from "react-native";
|
import { View, StyleSheet, Platform } from "react-native";
|
||||||
import useAuthStore from "@/store/auth";
|
import useAuthStore from "@/store/auth";
|
||||||
import LinkListing from "@/components/LinkListing";
|
|
||||||
import { useLocalSearchParams } from "expo-router";
|
import { useLocalSearchParams } from "expo-router";
|
||||||
import React from "react";
|
import React from "react";
|
||||||
import { LinkIncludingShortenedCollectionAndTags } from "@linkwarden/types";
|
import Links from "@/components/Links";
|
||||||
|
|
||||||
const RenderItem = React.memo(
|
|
||||||
({ item }: { item: LinkIncludingShortenedCollectionAndTags }) => {
|
|
||||||
return <LinkListing link={item} />;
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
export default function LinksScreen() {
|
export default function LinksScreen() {
|
||||||
const { auth } = useAuthStore();
|
const { auth } = useAuthStore();
|
||||||
@@ -31,23 +24,7 @@ export default function LinksScreen() {
|
|||||||
collapsable={false}
|
collapsable={false}
|
||||||
collapsableChildren={false}
|
collapsableChildren={false}
|
||||||
>
|
>
|
||||||
<FlatList
|
<Links links={links} data={data} />
|
||||||
contentInsetAdjustmentBehavior="automatic"
|
|
||||||
ListHeaderComponent={() => <></>}
|
|
||||||
data={links || []}
|
|
||||||
onRefresh={() => data.refetch()}
|
|
||||||
refreshing={data.isRefetching}
|
|
||||||
initialNumToRender={4}
|
|
||||||
keyExtractor={(item) => item.id?.toString() || ""}
|
|
||||||
renderItem={({ item }) => (
|
|
||||||
<RenderItem item={item} key={item.id?.toString()} />
|
|
||||||
)}
|
|
||||||
onEndReached={() => data.fetchNextPage()}
|
|
||||||
onEndReachedThreshold={0.5}
|
|
||||||
ItemSeparatorComponent={() => (
|
|
||||||
<View className="bg-neutral-content h-px" />
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { useColorScheme } from "nativewind";
|
|||||||
import { rawTheme, ThemeName } from "@/lib/colors";
|
import { rawTheme, ThemeName } from "@/lib/colors";
|
||||||
import { Platform } from "react-native";
|
import { Platform } from "react-native";
|
||||||
|
|
||||||
export default function RootLayout() {
|
export default function Layout() {
|
||||||
const { colorScheme } = useColorScheme();
|
const { colorScheme } = useColorScheme();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -14,16 +14,17 @@ import { useColorScheme } from "nativewind";
|
|||||||
import { rawTheme, ThemeName } from "@/lib/colors";
|
import { rawTheme, ThemeName } from "@/lib/colors";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import {
|
import {
|
||||||
|
AppWindowMac,
|
||||||
Check,
|
Check,
|
||||||
FileText,
|
ExternalLink,
|
||||||
Globe,
|
|
||||||
LogOut,
|
LogOut,
|
||||||
|
Mail,
|
||||||
Moon,
|
Moon,
|
||||||
Smartphone,
|
Smartphone,
|
||||||
Sun,
|
Sun,
|
||||||
} from "lucide-react-native";
|
} from "lucide-react-native";
|
||||||
import useDataStore from "@/store/data";
|
import useDataStore from "@/store/data";
|
||||||
import { ArchivedFormat } from "@/types/global";
|
import * as Clipboard from "expo-clipboard";
|
||||||
|
|
||||||
export default function SettingsScreen() {
|
export default function SettingsScreen() {
|
||||||
const { signOut, auth } = useAuthStore();
|
const { signOut, auth } = useAuthStore();
|
||||||
@@ -143,26 +144,24 @@ export default function SettingsScreen() {
|
|||||||
</View>
|
</View>
|
||||||
|
|
||||||
<View>
|
<View>
|
||||||
<Text className="mb-4 mx-4 text-neutral">
|
<Text className="mb-4 mx-4 text-neutral">Preferred Browser</Text>
|
||||||
Default Behavior for Opening Links
|
|
||||||
</Text>
|
|
||||||
<View className="bg-base-200 rounded-xl flex-col">
|
<View className="bg-base-200 rounded-xl flex-col">
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
className="flex-row gap-2 items-center justify-between py-3 px-4"
|
className="flex-row gap-2 items-center justify-between py-3 px-4"
|
||||||
onPress={() =>
|
onPress={() =>
|
||||||
updateData({
|
updateData({
|
||||||
preferredFormat: null,
|
preferredBrowser: "app",
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<View className="flex-row items-center gap-2">
|
<View className="flex-row items-center gap-2">
|
||||||
<Globe
|
<AppWindowMac
|
||||||
size={20}
|
size={20}
|
||||||
color={rawTheme[colorScheme as ThemeName].neutral}
|
color={rawTheme[colorScheme as ThemeName].neutral}
|
||||||
/>
|
/>
|
||||||
<Text className="text-base-content">Open original content</Text>
|
<Text className="text-base-content">In app browser</Text>
|
||||||
</View>
|
</View>
|
||||||
{data.preferredFormat === null ? (
|
{data.preferredBrowser === "app" ? (
|
||||||
<Check
|
<Check
|
||||||
size={20}
|
size={20}
|
||||||
color={rawTheme[colorScheme as ThemeName].primary}
|
color={rawTheme[colorScheme as ThemeName].primary}
|
||||||
@@ -174,18 +173,20 @@ export default function SettingsScreen() {
|
|||||||
className="flex-row gap-2 items-center justify-between py-3 px-4"
|
className="flex-row gap-2 items-center justify-between py-3 px-4"
|
||||||
onPress={() =>
|
onPress={() =>
|
||||||
updateData({
|
updateData({
|
||||||
preferredFormat: ArchivedFormat.readability,
|
preferredBrowser: "system",
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<View className="flex-row items-center gap-2">
|
<View className="flex-row items-center gap-2">
|
||||||
<FileText
|
<ExternalLink
|
||||||
size={20}
|
size={20}
|
||||||
color={rawTheme[colorScheme as ThemeName].neutral}
|
color={rawTheme[colorScheme as ThemeName].neutral}
|
||||||
/>
|
/>
|
||||||
<Text className="text-base-content">Open reader view</Text>
|
<Text className="text-base-content">
|
||||||
|
System default browser
|
||||||
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
{data.preferredFormat === ArchivedFormat.readability ? (
|
{data.preferredBrowser === "system" ? (
|
||||||
<Check
|
<Check
|
||||||
size={20}
|
size={20}
|
||||||
color={rawTheme[colorScheme as ThemeName].primary}
|
color={rawTheme[colorScheme as ThemeName].primary}
|
||||||
@@ -195,6 +196,29 @@ export default function SettingsScreen() {
|
|||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
|
<View>
|
||||||
|
<Text className="mb-4 mx-4 text-neutral">Contact Us</Text>
|
||||||
|
<View className="bg-base-200 rounded-xl flex-col">
|
||||||
|
<TouchableOpacity
|
||||||
|
className="flex-row gap-2 items-center justify-between py-3 px-4"
|
||||||
|
onPress={async () => {
|
||||||
|
await Clipboard.setStringAsync("support@linkwarden.app");
|
||||||
|
Alert.alert("Copied to clipboard", "support@linkwarden.app");
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<View className="flex-row items-center gap-2">
|
||||||
|
<Mail
|
||||||
|
size={20}
|
||||||
|
color={rawTheme[colorScheme as ThemeName].neutral}
|
||||||
|
/>
|
||||||
|
<Text className="text-base-content">
|
||||||
|
support@linkwarden.app
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
<Text className="mx-auto text-sm text-neutral">
|
<Text className="mx-auto text-sm text-neutral">
|
||||||
Linkwarden for {Platform.OS === "ios" ? "iOS" : "Android"}{" "}
|
Linkwarden for {Platform.OS === "ios" ? "iOS" : "Android"}{" "}
|
||||||
{nativeApplicationVersion}
|
{nativeApplicationVersion}
|
||||||
@@ -205,7 +229,13 @@ export default function SettingsScreen() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
const styles = StyleSheet.create({
|
||||||
container: {
|
container: Platform.select({
|
||||||
flex: 1,
|
ios: {
|
||||||
},
|
flex: 1,
|
||||||
|
paddingBottom: 83,
|
||||||
|
},
|
||||||
|
default: {
|
||||||
|
flex: 1,
|
||||||
|
},
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
|
|||||||
60
apps/mobile/app/(tabs)/tags/[id].tsx
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
import { useLinks } from "@linkwarden/router/links";
|
||||||
|
import { View, StyleSheet, Platform } from "react-native";
|
||||||
|
import useAuthStore from "@/store/auth";
|
||||||
|
import { useLocalSearchParams, useNavigation } from "expo-router";
|
||||||
|
import React, { useEffect } from "react";
|
||||||
|
import { useTags } from "@linkwarden/router/tags";
|
||||||
|
import Links from "@/components/Links";
|
||||||
|
|
||||||
|
export default function LinksScreen() {
|
||||||
|
const { auth } = useAuthStore();
|
||||||
|
const { search, id } = useLocalSearchParams<{
|
||||||
|
search?: string;
|
||||||
|
id: string;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const { links, data } = useLinks(
|
||||||
|
{
|
||||||
|
sort: 0,
|
||||||
|
searchQueryString: decodeURIComponent(search ?? ""),
|
||||||
|
tagId: Number(id),
|
||||||
|
},
|
||||||
|
auth
|
||||||
|
);
|
||||||
|
|
||||||
|
const tags = useTags(auth);
|
||||||
|
|
||||||
|
const navigation = useNavigation();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const activeTag = tags.data?.filter((e) => e.id === Number(id))[0];
|
||||||
|
|
||||||
|
if (activeTag?.name)
|
||||||
|
navigation?.setOptions?.({
|
||||||
|
headerTitle: activeTag?.name,
|
||||||
|
headerSearchBarOptions: {
|
||||||
|
placeholder: `Search ${activeTag.name}`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}, [navigation]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View
|
||||||
|
style={styles.container}
|
||||||
|
className="h-full bg-base-100"
|
||||||
|
collapsable={false}
|
||||||
|
collapsableChildren={false}
|
||||||
|
>
|
||||||
|
<Links links={links} data={data} />
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
container: Platform.select({
|
||||||
|
ios: {
|
||||||
|
paddingBottom: 83,
|
||||||
|
},
|
||||||
|
default: {},
|
||||||
|
}),
|
||||||
|
});
|
||||||
44
apps/mobile/app/(tabs)/tags/_layout.tsx
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
import { Stack, useRouter } from "expo-router";
|
||||||
|
import { useColorScheme } from "nativewind";
|
||||||
|
import { rawTheme, ThemeName } from "@/lib/colors";
|
||||||
|
import { Platform } from "react-native";
|
||||||
|
|
||||||
|
export default function Layout() {
|
||||||
|
const router = useRouter();
|
||||||
|
const { colorScheme } = useColorScheme();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Stack
|
||||||
|
screenOptions={{
|
||||||
|
headerTitle: "Tags",
|
||||||
|
headerLargeTitle: true,
|
||||||
|
headerTransparent: Platform.OS === "ios" ? true : false,
|
||||||
|
headerShadowVisible: false,
|
||||||
|
headerBlurEffect:
|
||||||
|
colorScheme === "dark" ? "systemMaterialDark" : "systemMaterial",
|
||||||
|
headerTintColor: colorScheme === "dark" ? "white" : "black",
|
||||||
|
headerSearchBarOptions: {
|
||||||
|
placeholder: "Search Tags",
|
||||||
|
autoCapitalize: "none",
|
||||||
|
onChangeText: (e) => {
|
||||||
|
router.setParams({
|
||||||
|
search: encodeURIComponent(e.nativeEvent.text),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
headerIconColor: colorScheme === "dark" ? "white" : "black",
|
||||||
|
},
|
||||||
|
headerLargeStyle: {
|
||||||
|
backgroundColor: rawTheme[colorScheme as ThemeName]["base-100"],
|
||||||
|
},
|
||||||
|
headerStyle: {
|
||||||
|
backgroundColor:
|
||||||
|
Platform.OS === "ios"
|
||||||
|
? "transparent"
|
||||||
|
: colorScheme === "dark"
|
||||||
|
? rawTheme["dark"]["base-100"]
|
||||||
|
: "white",
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
92
apps/mobile/app/(tabs)/tags/index.tsx
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
import {
|
||||||
|
View,
|
||||||
|
StyleSheet,
|
||||||
|
FlatList,
|
||||||
|
Platform,
|
||||||
|
Text,
|
||||||
|
ActivityIndicator,
|
||||||
|
} from "react-native";
|
||||||
|
import useAuthStore from "@/store/auth";
|
||||||
|
import TagListing from "@/components/TagListing";
|
||||||
|
import { useLocalSearchParams } from "expo-router";
|
||||||
|
import React, { useEffect, useState } from "react";
|
||||||
|
import Spinner from "@/components/ui/Spinner";
|
||||||
|
import { rawTheme, ThemeName } from "@/lib/colors";
|
||||||
|
import { useColorScheme } from "nativewind";
|
||||||
|
import { TagIncludingLinkCount } from "@linkwarden/types";
|
||||||
|
import { useTags } from "@linkwarden/router/tags";
|
||||||
|
|
||||||
|
export default function TagsScreen() {
|
||||||
|
const { colorScheme } = useColorScheme();
|
||||||
|
const { auth } = useAuthStore();
|
||||||
|
const { search } = useLocalSearchParams<{ search?: string }>();
|
||||||
|
|
||||||
|
const tags = useTags(auth);
|
||||||
|
|
||||||
|
const [filteredTags, setFilteredTags] = useState<TagIncludingLinkCount[]>([]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const filter =
|
||||||
|
tags.data?.filter((e) =>
|
||||||
|
e.name.includes(decodeURIComponent(search || ""))
|
||||||
|
) || [];
|
||||||
|
|
||||||
|
setFilteredTags(filter);
|
||||||
|
}, [search, tags.data]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View
|
||||||
|
style={styles.container}
|
||||||
|
className="h-full bg-base-100"
|
||||||
|
collapsable={false}
|
||||||
|
collapsableChildren={false}
|
||||||
|
>
|
||||||
|
{tags.isLoading ? (
|
||||||
|
<View className="flex justify-center h-screen items-center">
|
||||||
|
<ActivityIndicator size="large" />
|
||||||
|
<Text className="text-base mt-2.5 text-neutral">Loading...</Text>
|
||||||
|
</View>
|
||||||
|
) : (
|
||||||
|
<FlatList
|
||||||
|
contentInsetAdjustmentBehavior="automatic"
|
||||||
|
ListHeaderComponent={() => <></>}
|
||||||
|
data={filteredTags}
|
||||||
|
refreshControl={
|
||||||
|
<Spinner
|
||||||
|
refreshing={tags.isRefetching}
|
||||||
|
onRefresh={() => tags.refetch()}
|
||||||
|
progressBackgroundColor={
|
||||||
|
rawTheme[colorScheme as ThemeName]["base-200"]
|
||||||
|
}
|
||||||
|
colors={[rawTheme[colorScheme as ThemeName]["base-content"]]}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
refreshing={tags.isRefetching}
|
||||||
|
initialNumToRender={4}
|
||||||
|
keyExtractor={(item) => item.id?.toString() || ""}
|
||||||
|
renderItem={({ item }) => <TagListing tag={item} />}
|
||||||
|
onEndReachedThreshold={0.5}
|
||||||
|
ItemSeparatorComponent={() => (
|
||||||
|
<View className="bg-neutral-content h-px" />
|
||||||
|
)}
|
||||||
|
ListEmptyComponent={
|
||||||
|
<View className="flex justify-center py-10 items-center">
|
||||||
|
<Text className="text-center text-xl text-neutral">
|
||||||
|
Nothing found...
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
container: Platform.select({
|
||||||
|
ios: {
|
||||||
|
paddingBottom: 83,
|
||||||
|
},
|
||||||
|
default: {},
|
||||||
|
}),
|
||||||
|
});
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import {
|
import {
|
||||||
|
router,
|
||||||
Stack,
|
Stack,
|
||||||
usePathname,
|
usePathname,
|
||||||
useRootNavigationState,
|
useRootNavigationState,
|
||||||
@@ -8,32 +9,39 @@ import { PersistQueryClientProvider } from "@tanstack/react-query-persist-client
|
|||||||
import { mmkvPersister } from "@/lib/queryPersister";
|
import { mmkvPersister } from "@/lib/queryPersister";
|
||||||
import { useState, useEffect } from "react";
|
import { useState, useEffect } from "react";
|
||||||
import "../styles/global.css";
|
import "../styles/global.css";
|
||||||
import { SheetProvider } from "react-native-actions-sheet";
|
import { SheetManager, SheetProvider } from "react-native-actions-sheet";
|
||||||
import "@/components/ActionSheets/Sheets";
|
import "@/components/ActionSheets/Sheets";
|
||||||
import { useColorScheme } from "nativewind";
|
import { useColorScheme } from "nativewind";
|
||||||
import { lightTheme, darkTheme } from "../lib/theme";
|
import { lightTheme, darkTheme } from "../lib/theme";
|
||||||
import { Platform, View } from "react-native";
|
import {
|
||||||
|
Alert,
|
||||||
|
Linking,
|
||||||
|
Platform,
|
||||||
|
Share,
|
||||||
|
TouchableOpacity,
|
||||||
|
View,
|
||||||
|
} from "react-native";
|
||||||
import { rawTheme, ThemeName } from "@/lib/colors";
|
import { rawTheme, ThemeName } from "@/lib/colors";
|
||||||
import { useShareIntent } from "expo-share-intent";
|
import { useShareIntent } from "expo-share-intent";
|
||||||
import useDataStore from "@/store/data";
|
import useDataStore from "@/store/data";
|
||||||
import useAuthStore from "@/store/auth";
|
import useAuthStore from "@/store/auth";
|
||||||
import { QueryClient } from "@tanstack/react-query";
|
import { KeyboardProvider } from "react-native-keyboard-controller";
|
||||||
import * as FileSystem from "expo-file-system";
|
import * as DropdownMenu from "zeego/dropdown-menu";
|
||||||
|
import { Compass, Ellipsis } from "lucide-react-native";
|
||||||
const queryClient = new QueryClient({
|
import { Chromium } from "@/components/ui/Icons";
|
||||||
defaultOptions: {
|
import useTmpStore from "@/store/tmp";
|
||||||
queries: {
|
import {
|
||||||
staleTime: 1000 * 60 * 60 * 24,
|
LinkIncludingShortenedCollectionAndTags,
|
||||||
refetchOnMount: true,
|
MobileAuth,
|
||||||
refetchOnWindowFocus: false,
|
} from "@linkwarden/types";
|
||||||
refetchOnReconnect: false,
|
import { useDeleteLink, useUpdateLink } from "@linkwarden/router/links";
|
||||||
},
|
import { deleteLinkCache } from "@/lib/cache";
|
||||||
},
|
import { queryClient } from "@/lib/queryClient";
|
||||||
});
|
import getOriginalFormat from "@linkwarden/lib/getOriginalFormat";
|
||||||
|
import { StatusBar } from "expo-status-bar";
|
||||||
|
|
||||||
export default function RootLayout() {
|
export default function RootLayout() {
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
const { colorScheme } = useColorScheme();
|
|
||||||
const { hasShareIntent, shareIntent, error, resetShareIntent } =
|
const { hasShareIntent, shareIntent, error, resetShareIntent } =
|
||||||
useShareIntent();
|
useShareIntent();
|
||||||
const { updateData, setData, data } = useDataStore();
|
const { updateData, setData, data } = useDataStore();
|
||||||
@@ -49,20 +57,6 @@ export default function RootLayout() {
|
|||||||
setData();
|
setData();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
(async () => {
|
|
||||||
if (auth.status === "unauthenticated") {
|
|
||||||
queryClient.cancelQueries();
|
|
||||||
queryClient.clear();
|
|
||||||
mmkvPersister.removeClient?.();
|
|
||||||
|
|
||||||
const CACHE_DIR =
|
|
||||||
FileSystem.documentDirectory + "archivedData/readable/";
|
|
||||||
await FileSystem.deleteAsync(CACHE_DIR, { idempotent: true });
|
|
||||||
}
|
|
||||||
})();
|
|
||||||
}, [auth.status]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!rootNavState?.key) return;
|
if (!rootNavState?.key) return;
|
||||||
|
|
||||||
@@ -113,27 +107,42 @@ export default function RootLayout() {
|
|||||||
queryClient.invalidateQueries();
|
queryClient.invalidateQueries();
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<View
|
<RootComponent isLoading={isLoading} auth={auth} />
|
||||||
style={[{ flex: 1 }, colorScheme === "dark" ? darkTheme : lightTheme]}
|
</PersistQueryClientProvider>
|
||||||
>
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const RootComponent = ({
|
||||||
|
isLoading,
|
||||||
|
auth,
|
||||||
|
}: {
|
||||||
|
isLoading: boolean;
|
||||||
|
auth: MobileAuth;
|
||||||
|
}) => {
|
||||||
|
const { colorScheme } = useColorScheme();
|
||||||
|
const updateLink = useUpdateLink(auth);
|
||||||
|
const deleteLink = useDeleteLink(auth);
|
||||||
|
|
||||||
|
const { tmp } = useTmpStore();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View
|
||||||
|
style={[{ flex: 1 }, colorScheme === "dark" ? darkTheme : lightTheme]}
|
||||||
|
>
|
||||||
|
<KeyboardProvider>
|
||||||
<SheetProvider>
|
<SheetProvider>
|
||||||
|
<StatusBar
|
||||||
|
style={colorScheme === "dark" ? "light" : "dark"}
|
||||||
|
backgroundColor={rawTheme[colorScheme as ThemeName]["base-100"]}
|
||||||
|
/>
|
||||||
{!isLoading && (
|
{!isLoading && (
|
||||||
<Stack
|
<Stack
|
||||||
screenOptions={{
|
screenOptions={{
|
||||||
navigationBarColor:
|
|
||||||
rawTheme[colorScheme as ThemeName]["base-200"],
|
|
||||||
headerShown: false,
|
headerShown: false,
|
||||||
contentStyle: {
|
contentStyle: {
|
||||||
backgroundColor:
|
backgroundColor:
|
||||||
rawTheme[colorScheme as ThemeName]["base-100"],
|
rawTheme[colorScheme as ThemeName]["base-100"],
|
||||||
},
|
},
|
||||||
...Platform.select({
|
|
||||||
android: {
|
|
||||||
statusBarStyle: colorScheme === "dark" ? "light" : "dark",
|
|
||||||
statusBarBackgroundColor:
|
|
||||||
rawTheme[colorScheme as ThemeName]["base-100"],
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{/* <Stack.Screen name="(tabs)" /> */}
|
{/* <Stack.Screen name="(tabs)" /> */}
|
||||||
@@ -144,43 +153,173 @@ export default function RootLayout() {
|
|||||||
headerBackTitle: "Back",
|
headerBackTitle: "Back",
|
||||||
headerTitle: "",
|
headerTitle: "",
|
||||||
headerTintColor: colorScheme === "dark" ? "white" : "black",
|
headerTintColor: colorScheme === "dark" ? "white" : "black",
|
||||||
navigationBarColor:
|
|
||||||
rawTheme[colorScheme as ThemeName]["base-100"],
|
|
||||||
headerStyle: {
|
headerStyle: {
|
||||||
backgroundColor:
|
backgroundColor:
|
||||||
colorScheme === "dark"
|
colorScheme === "dark"
|
||||||
? rawTheme["dark"]["base-100"]
|
? rawTheme["dark"]["base-100"]
|
||||||
: "white",
|
: "white",
|
||||||
},
|
},
|
||||||
|
headerRight: () => (
|
||||||
|
<View className="flex-row gap-5">
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={() => {
|
||||||
|
if (tmp.link) {
|
||||||
|
if (tmp.link.url) {
|
||||||
|
return Linking.openURL(tmp.link.url);
|
||||||
|
} else {
|
||||||
|
const format = getOriginalFormat(tmp.link);
|
||||||
|
|
||||||
|
return Linking.openURL(
|
||||||
|
format !== null
|
||||||
|
? auth.instance +
|
||||||
|
`/preserved/${tmp.link.id}?format=${format}`
|
||||||
|
: tmp.link.url || ""
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{Platform.OS === "ios" ? (
|
||||||
|
<Compass
|
||||||
|
size={21}
|
||||||
|
color={
|
||||||
|
rawTheme[colorScheme as ThemeName]["base-content"]
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<Chromium
|
||||||
|
stroke={
|
||||||
|
rawTheme[colorScheme as ThemeName]["base-content"]
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</TouchableOpacity>
|
||||||
|
<DropdownMenu.Root>
|
||||||
|
<DropdownMenu.Trigger>
|
||||||
|
<TouchableOpacity>
|
||||||
|
<Ellipsis
|
||||||
|
size={21}
|
||||||
|
color={
|
||||||
|
rawTheme[colorScheme as ThemeName][
|
||||||
|
"base-content"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</DropdownMenu.Trigger>
|
||||||
|
|
||||||
|
<DropdownMenu.Content>
|
||||||
|
{tmp.link?.url && (
|
||||||
|
<DropdownMenu.Item
|
||||||
|
key="share"
|
||||||
|
onSelect={async () => {
|
||||||
|
await Share.share({
|
||||||
|
...(Platform.OS === "android"
|
||||||
|
? { message: tmp.link?.url as string }
|
||||||
|
: { url: tmp.link?.url as string }),
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<DropdownMenu.ItemTitle>
|
||||||
|
Share
|
||||||
|
</DropdownMenu.ItemTitle>
|
||||||
|
</DropdownMenu.Item>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{tmp.link && tmp.user && (
|
||||||
|
<DropdownMenu.Item
|
||||||
|
key="pin-link"
|
||||||
|
onSelect={async () => {
|
||||||
|
const isAlreadyPinned =
|
||||||
|
tmp.link?.pinnedBy && tmp.link.pinnedBy[0]
|
||||||
|
? true
|
||||||
|
: false;
|
||||||
|
await updateLink.mutateAsync({
|
||||||
|
...(tmp.link as LinkIncludingShortenedCollectionAndTags),
|
||||||
|
pinnedBy: (isAlreadyPinned
|
||||||
|
? [{ id: undefined }]
|
||||||
|
: [{ id: tmp.user?.id }]) as any,
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<DropdownMenu.ItemTitle>
|
||||||
|
{tmp.link.pinnedBy && tmp.link.pinnedBy[0]
|
||||||
|
? "Unpin Link"
|
||||||
|
: "Pin Link"}
|
||||||
|
</DropdownMenu.ItemTitle>
|
||||||
|
</DropdownMenu.Item>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{tmp.link && (
|
||||||
|
<DropdownMenu.Item
|
||||||
|
key="edit-link"
|
||||||
|
onSelect={() => {
|
||||||
|
SheetManager.show("edit-link-sheet", {
|
||||||
|
payload: {
|
||||||
|
link: tmp.link as LinkIncludingShortenedCollectionAndTags,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<DropdownMenu.ItemTitle>
|
||||||
|
Edit Link
|
||||||
|
</DropdownMenu.ItemTitle>
|
||||||
|
</DropdownMenu.Item>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{tmp.link && (
|
||||||
|
<DropdownMenu.Item
|
||||||
|
key="delete-link"
|
||||||
|
onSelect={() => {
|
||||||
|
return Alert.alert(
|
||||||
|
"Delete Link",
|
||||||
|
"Are you sure you want to delete this link? This action cannot be undone.",
|
||||||
|
[
|
||||||
|
{
|
||||||
|
text: "Cancel",
|
||||||
|
style: "cancel",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
text: "Delete",
|
||||||
|
style: "destructive",
|
||||||
|
onPress: () => {
|
||||||
|
deleteLink.mutate(
|
||||||
|
tmp.link?.id as number,
|
||||||
|
{
|
||||||
|
onSuccess: async () => {
|
||||||
|
await deleteLinkCache(
|
||||||
|
tmp.link?.id as number
|
||||||
|
);
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
// go back
|
||||||
|
router.back();
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<DropdownMenu.ItemTitle>
|
||||||
|
Delete
|
||||||
|
</DropdownMenu.ItemTitle>
|
||||||
|
</DropdownMenu.Item>
|
||||||
|
)}
|
||||||
|
</DropdownMenu.Content>
|
||||||
|
</DropdownMenu.Root>
|
||||||
|
</View>
|
||||||
|
),
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<Stack.Screen
|
<Stack.Screen name="login" />
|
||||||
name="login"
|
<Stack.Screen name="index" />
|
||||||
options={{
|
<Stack.Screen name="incoming" />
|
||||||
navigationBarColor:
|
|
||||||
rawTheme[colorScheme as ThemeName]["base-100"],
|
|
||||||
...Platform.select({
|
|
||||||
android: {
|
|
||||||
statusBarStyle:
|
|
||||||
colorScheme === "light" ? "light" : "dark",
|
|
||||||
statusBarBackgroundColor:
|
|
||||||
rawTheme[colorScheme as ThemeName]["primary"],
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<Stack.Screen
|
|
||||||
name="incoming"
|
|
||||||
options={{
|
|
||||||
navigationBarColor:
|
|
||||||
rawTheme[colorScheme as ThemeName]["base-100"],
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<Stack.Screen name="+not-found" />
|
<Stack.Screen name="+not-found" />
|
||||||
</Stack>
|
</Stack>
|
||||||
)}
|
)}
|
||||||
</SheetProvider>
|
</SheetProvider>
|
||||||
</View>
|
</KeyboardProvider>
|
||||||
</PersistQueryClientProvider>
|
</View>
|
||||||
);
|
);
|
||||||
}
|
};
|
||||||
|
|||||||
@@ -46,7 +46,7 @@ export default function IncomingScreen() {
|
|||||||
);
|
);
|
||||||
}, [auth, data.shareIntent.url]);
|
}, [auth, data.shareIntent.url]);
|
||||||
|
|
||||||
if (auth.status === "unauthenticated") return <Redirect href="/login" />;
|
if (auth.status === "unauthenticated") return <Redirect href="/" />;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SafeAreaView className="flex-1 bg-base-100">
|
<SafeAreaView className="flex-1 bg-base-100">
|
||||||
|
|||||||
@@ -1,12 +1,77 @@
|
|||||||
|
import { Button } from "@/components/ui/Button";
|
||||||
|
import { rawTheme, ThemeName } from "@/lib/colors";
|
||||||
import useAuthStore from "@/store/auth";
|
import useAuthStore from "@/store/auth";
|
||||||
import { Redirect } from "expo-router";
|
import { Redirect, router } from "expo-router";
|
||||||
|
import { useColorScheme } from "nativewind";
|
||||||
|
import { View, Text, Dimensions, TouchableOpacity, Image } from "react-native";
|
||||||
|
import { SheetManager } from "react-native-actions-sheet";
|
||||||
|
import Svg, { Path } from "react-native-svg";
|
||||||
|
import Animated, { SlideInDown } from "react-native-reanimated";
|
||||||
|
import { SafeAreaView } from "react-native-safe-area-context";
|
||||||
|
|
||||||
export default function HomeScreen() {
|
export default function HomeScreen() {
|
||||||
const { auth } = useAuthStore();
|
const { auth } = useAuthStore();
|
||||||
|
const { colorScheme } = useColorScheme();
|
||||||
|
|
||||||
if (auth.session) {
|
if (auth.session) {
|
||||||
return <Redirect href="/(tabs)/dashboard" />;
|
return <Redirect href="/dashboard" />;
|
||||||
} else {
|
|
||||||
return <Redirect href="/login" />;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Animated.View
|
||||||
|
entering={SlideInDown.springify().damping(100).stiffness(300)}
|
||||||
|
className="flex-col justify-end h-full"
|
||||||
|
>
|
||||||
|
<View className="h-full bg-primary relative">
|
||||||
|
<View className="my-auto">
|
||||||
|
<Image
|
||||||
|
source={require("@/assets/images/linkwarden.png")}
|
||||||
|
className="w-[120px] h-[120px] mx-auto"
|
||||||
|
/>
|
||||||
|
<Text className="text-base-100 text-4xl font-semibold mt-7 mx-auto">
|
||||||
|
Linkwarden
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<View>
|
||||||
|
<Text className="text-base-100 text-xl text-center font-semibold mx-4 mt-3">
|
||||||
|
Welcome to the official mobile app for Linkwarden!
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<Text className="text-base-100 text-xl text-center mx-4 mt-3">
|
||||||
|
Expect regular improvements and new features as we continue refining
|
||||||
|
the experience.
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<Svg
|
||||||
|
viewBox="0 0 1440 320"
|
||||||
|
width={Dimensions.get("screen").width}
|
||||||
|
height={Dimensions.get("screen").width * (320 / 1440) + 2}
|
||||||
|
>
|
||||||
|
<Path
|
||||||
|
fill={rawTheme[colorScheme as ThemeName]["base-100"]}
|
||||||
|
fill-opacity="1"
|
||||||
|
d="M0,256L48,234.7C96,213,192,171,288,176C384,181,480,235,576,266.7C672,299,768,309,864,277.3C960,245,1056,171,1152,122.7C1248,75,1344,53,1392,42.7L1440,32L1440,320L1392,320C1344,320,1248,320,1152,320C1056,320,960,320,864,320C768,320,672,320,576,320C480,320,384,320,288,320C192,320,96,320,48,320L0,320Z"
|
||||||
|
/>
|
||||||
|
</Svg>
|
||||||
|
<SafeAreaView
|
||||||
|
edges={["bottom"]}
|
||||||
|
className="flex-col justify-end h-auto duration-100 pt-10 bg-base-100 -mt-2 pb-10 gap-4 w-full px-4"
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
variant="accent"
|
||||||
|
size="lg"
|
||||||
|
onPress={() => router.navigate("/login")}
|
||||||
|
>
|
||||||
|
<Text className="text-white text-xl">Get Started</Text>
|
||||||
|
</Button>
|
||||||
|
<TouchableOpacity
|
||||||
|
className="w-fit mx-auto"
|
||||||
|
onPress={() => SheetManager.show("support-sheet")}
|
||||||
|
>
|
||||||
|
<Text className="text-neutral text-center w-fit">Need help?</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</SafeAreaView>
|
||||||
|
</View>
|
||||||
|
</Animated.View>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,168 +1,93 @@
|
|||||||
import React, { useEffect, useMemo, useState } from "react";
|
import React, { useEffect, useState } from "react";
|
||||||
import {
|
import { View, ActivityIndicator, Text, Platform } from "react-native";
|
||||||
View,
|
|
||||||
ActivityIndicator,
|
|
||||||
Text,
|
|
||||||
ScrollView,
|
|
||||||
TouchableOpacity,
|
|
||||||
} from "react-native";
|
|
||||||
import { WebView } from "react-native-webview";
|
import { WebView } from "react-native-webview";
|
||||||
import * as FileSystem from "expo-file-system";
|
|
||||||
import NetInfo from "@react-native-community/netinfo";
|
|
||||||
import useAuthStore from "@/store/auth";
|
import useAuthStore from "@/store/auth";
|
||||||
import { useLocalSearchParams, useRouter } from "expo-router";
|
import { useLocalSearchParams } from "expo-router";
|
||||||
import { useUser } from "@linkwarden/router/user";
|
import { useUser } from "@linkwarden/router/user";
|
||||||
import { generateLinkHref } from "@linkwarden/lib/generateLinkHref";
|
|
||||||
import { useWindowDimensions } from "react-native";
|
|
||||||
import RenderHtml from "@linkwarden/react-native-render-html";
|
|
||||||
import ElementNotSupported from "@/components/ElementNotSupported";
|
|
||||||
import { decode } from "html-entities";
|
|
||||||
import { useGetLink } from "@linkwarden/router/links";
|
import { useGetLink } from "@linkwarden/router/links";
|
||||||
import { useColorScheme } from "nativewind";
|
import useTmpStore from "@/store/tmp";
|
||||||
import { rawTheme, ThemeName } from "@/lib/colors";
|
import { ArchivedFormat } from "@linkwarden/types";
|
||||||
import { CalendarDays, Link } from "lucide-react-native";
|
import ReadableFormat from "@/components/Formats/ReadableFormat";
|
||||||
|
import ImageFormat from "@/components/Formats/ImageFormat";
|
||||||
const CACHE_DIR = FileSystem.documentDirectory + "archivedData/readable/";
|
import PdfFormat from "@/components/Formats/PdfFormat";
|
||||||
const htmlPath = (id: string) => `${CACHE_DIR}link_${id}.html`;
|
import WebpageFormat from "@/components/Formats/WebpageFormat";
|
||||||
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
async function ensureCacheDir() {
|
|
||||||
const info = await FileSystem.getInfoAsync(CACHE_DIR);
|
|
||||||
if (!info.exists) {
|
|
||||||
await FileSystem.makeDirectoryAsync(CACHE_DIR, { intermediates: true });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function LinkScreen() {
|
export default function LinkScreen() {
|
||||||
const { auth } = useAuthStore();
|
const { auth } = useAuthStore();
|
||||||
const { id, format } = useLocalSearchParams();
|
const { id, format } = useLocalSearchParams();
|
||||||
const { data: user } = useUser(auth);
|
const { data: user } = useUser(auth);
|
||||||
const [url, setUrl] = useState<string>();
|
const [url, setUrl] = useState<string>();
|
||||||
const [htmlContent, setHtmlContent] = useState<string>("");
|
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
const { width } = useWindowDimensions();
|
|
||||||
const router = useRouter();
|
|
||||||
const { colorScheme } = useColorScheme();
|
|
||||||
|
|
||||||
const { data: link } = useGetLink({ id: Number(id), auth, enabled: true });
|
const { data: link } = useGetLink({ id: Number(id), auth, enabled: true });
|
||||||
|
|
||||||
|
const { updateTmp } = useTmpStore();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
async function loadCacheOrFetch() {
|
if (link?.id && user?.id)
|
||||||
await ensureCacheDir();
|
updateTmp({
|
||||||
const htmlFile = htmlPath(id as string);
|
link,
|
||||||
|
user: {
|
||||||
|
id: user.id,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
const [htmlInfo] = await Promise.all([FileSystem.getInfoAsync(htmlFile)]);
|
return () =>
|
||||||
|
updateTmp({
|
||||||
|
link: null,
|
||||||
|
});
|
||||||
|
}, [link, user]);
|
||||||
|
|
||||||
if (format === "3" && htmlInfo.exists) {
|
useEffect(() => {
|
||||||
const rawHtml = await FileSystem.readAsStringAsync(htmlFile);
|
if (user?.id && link?.id && format) {
|
||||||
setHtmlContent(rawHtml);
|
setUrl(`${auth.instance}/api/v1/archives/${link.id}?format=${format}`);
|
||||||
setIsLoading(false);
|
} else if (!url) {
|
||||||
|
if (link?.url) {
|
||||||
|
setUrl(link.url);
|
||||||
}
|
}
|
||||||
|
|
||||||
const net = await NetInfo.fetch();
|
|
||||||
if (net.isConnected) {
|
|
||||||
await fetchLinkData();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (user?.id && link?.id && !url) {
|
|
||||||
loadCacheOrFetch();
|
|
||||||
}
|
}
|
||||||
}, [user, link]);
|
}, [user, link]);
|
||||||
|
|
||||||
async function fetchLinkData() {
|
const insets = useSafeAreaInsets();
|
||||||
if (link?.id && format === "3") {
|
|
||||||
const apiUrl = `${auth.instance}/api/v1/archives/${link.id}?format=${format}`;
|
|
||||||
setUrl(apiUrl);
|
|
||||||
try {
|
|
||||||
const response = await fetch(apiUrl, {
|
|
||||||
headers: { Authorization: `Bearer ${auth.session}` },
|
|
||||||
});
|
|
||||||
const html = (await response.json()).content;
|
|
||||||
setHtmlContent(html);
|
|
||||||
await FileSystem.writeAsStringAsync(htmlPath(id as string), html, {
|
|
||||||
encoding: FileSystem.EncodingType.UTF8,
|
|
||||||
});
|
|
||||||
} catch (e) {
|
|
||||||
console.error("Failed to fetch HTML content", e);
|
|
||||||
} finally {
|
|
||||||
setIsLoading(false);
|
|
||||||
}
|
|
||||||
} else if (link?.id && !format && user) {
|
|
||||||
setUrl(
|
|
||||||
generateLinkHref(link, { ...user, password: "" }, auth.instance, true)
|
|
||||||
);
|
|
||||||
} else if (link?.id && format) {
|
|
||||||
setUrl(`${auth.instance}/api/v1/archives/${link.id}?format=${format}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<View
|
||||||
{format === "3" && htmlContent ? (
|
className="flex-1"
|
||||||
<ScrollView
|
style={{ paddingBottom: Platform.OS === "android" ? insets.bottom : 0 }}
|
||||||
className="flex-1 bg-base-100"
|
>
|
||||||
contentContainerClassName="p-4"
|
{link?.id && Number(format) === ArchivedFormat.readability ? (
|
||||||
nestedScrollEnabled
|
<ReadableFormat
|
||||||
>
|
link={link as any}
|
||||||
<Text className="text-2xl font-bold mb-2.5 text-base-content">
|
setIsLoading={(state) => setIsLoading(state)}
|
||||||
{decode(link?.name || link?.description || link?.url || "")}
|
/>
|
||||||
</Text>
|
) : link?.id &&
|
||||||
|
(Number(format) === ArchivedFormat.jpeg ||
|
||||||
<TouchableOpacity
|
Number(format) === ArchivedFormat.png) ? (
|
||||||
className="flex-row items-center gap-1 mb-2.5 pr-5"
|
<ImageFormat
|
||||||
onPress={() => router.replace(`/links/${id}`)}
|
link={link as any}
|
||||||
>
|
setIsLoading={(state) => setIsLoading(state)}
|
||||||
<Link
|
format={Number(format)}
|
||||||
size={16}
|
/>
|
||||||
color={rawTheme[colorScheme as ThemeName]["neutral"]}
|
) : link?.id && Number(format) === ArchivedFormat.pdf ? (
|
||||||
/>
|
<PdfFormat
|
||||||
<Text className="text-base text-neutral flex-1" numberOfLines={1}>
|
link={link as any}
|
||||||
{link?.url}
|
setIsLoading={(state) => setIsLoading(state)}
|
||||||
</Text>
|
/>
|
||||||
</TouchableOpacity>
|
) : link?.id && Number(format) === ArchivedFormat.monolith ? (
|
||||||
|
<WebpageFormat
|
||||||
<View className="flex-row items-center gap-1 mb-2.5">
|
link={link as any}
|
||||||
<CalendarDays
|
setIsLoading={(state) => setIsLoading(state)}
|
||||||
size={16}
|
/>
|
||||||
color={rawTheme[colorScheme as ThemeName]["neutral"]}
|
|
||||||
/>
|
|
||||||
<Text className="text-base text-neutral">
|
|
||||||
{new Date(
|
|
||||||
(link?.importDate || link?.createdAt) as string
|
|
||||||
).toLocaleString("en-US", {
|
|
||||||
year: "numeric",
|
|
||||||
month: "short",
|
|
||||||
day: "numeric",
|
|
||||||
})}
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
|
|
||||||
<View className="border-t border-neutral-content mt-2.5 mb-5" />
|
|
||||||
|
|
||||||
<RenderHtml
|
|
||||||
contentWidth={width}
|
|
||||||
source={{ html: htmlContent }}
|
|
||||||
renderers={{
|
|
||||||
table: () => (
|
|
||||||
<ElementNotSupported
|
|
||||||
onPress={() => router.replace(`/links/${id}`)}
|
|
||||||
/>
|
|
||||||
),
|
|
||||||
}}
|
|
||||||
tagsStyles={{
|
|
||||||
p: { fontSize: 18, lineHeight: 28, marginVertical: 10 },
|
|
||||||
}}
|
|
||||||
baseStyle={{
|
|
||||||
color: rawTheme[colorScheme as ThemeName]["base-content"],
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</ScrollView>
|
|
||||||
) : url ? (
|
) : url ? (
|
||||||
<WebView
|
<WebView
|
||||||
className={isLoading ? "opacity-0" : "flex-1"}
|
className={isLoading ? "opacity-0" : "flex-1"}
|
||||||
source={{
|
source={{
|
||||||
uri: url,
|
uri: url,
|
||||||
headers: format ? { Authorization: `Bearer ${auth.session}` } : {},
|
headers:
|
||||||
|
format || link?.type !== "url"
|
||||||
|
? { Authorization: `Bearer ${auth.session}` }
|
||||||
|
: {},
|
||||||
}}
|
}}
|
||||||
onLoadEnd={() => setIsLoading(false)}
|
onLoadEnd={() => setIsLoading(false)}
|
||||||
/>
|
/>
|
||||||
@@ -181,6 +106,6 @@ export default function LinkScreen() {
|
|||||||
<Text className="text-base mt-2.5 text-neutral">Loading...</Text>
|
<Text className="text-base mt-2.5 text-neutral">Loading...</Text>
|
||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
</>
|
</View>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,101 +4,194 @@ import { rawTheme, ThemeName } from "@/lib/colors";
|
|||||||
import useAuthStore from "@/store/auth";
|
import useAuthStore from "@/store/auth";
|
||||||
import { Redirect } from "expo-router";
|
import { Redirect } from "expo-router";
|
||||||
import { useColorScheme } from "nativewind";
|
import { useColorScheme } from "nativewind";
|
||||||
import { useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { View, Text, Dimensions, TouchableOpacity } from "react-native";
|
import { View, Text, Dimensions, TouchableOpacity, Image } from "react-native";
|
||||||
|
import { SheetManager } from "react-native-actions-sheet";
|
||||||
import Svg, { Path } from "react-native-svg";
|
import Svg, { Path } from "react-native-svg";
|
||||||
|
import {
|
||||||
|
KeyboardStickyView,
|
||||||
|
KeyboardToolbar,
|
||||||
|
} from "react-native-keyboard-controller";
|
||||||
|
import { SafeAreaView } from "react-native-safe-area-context";
|
||||||
|
|
||||||
export default function HomeScreen() {
|
export default function HomeScreen() {
|
||||||
const { auth, signIn } = useAuthStore();
|
const { auth, signIn } = useAuthStore();
|
||||||
const { colorScheme } = useColorScheme();
|
const { colorScheme } = useColorScheme();
|
||||||
const [method, setMethod] = useState<"password" | "token">("password");
|
const [method, setMethod] = useState<"password" | "token">("password");
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
|
||||||
const [form, setForm] = useState({
|
const [form, setForm] = useState({
|
||||||
user: "",
|
user: "",
|
||||||
password: "",
|
password: "",
|
||||||
token: "",
|
token: "",
|
||||||
instance: "",
|
instance: auth.instance || "https://cloud.linkwarden.app",
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const [showInstanceField, setShowInstanceField] = useState(
|
||||||
|
form.instance !== "https://cloud.linkwarden.app"
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setForm((prev) => ({
|
||||||
|
...prev,
|
||||||
|
instance: auth.instance || "https://cloud.linkwarden.app",
|
||||||
|
}));
|
||||||
|
}, [auth.instance]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setShowInstanceField(form.instance !== "https://cloud.linkwarden.app");
|
||||||
|
}, [form.instance]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setForm((prev) => ({
|
||||||
|
...prev,
|
||||||
|
token: "",
|
||||||
|
user: "",
|
||||||
|
password: "",
|
||||||
|
}));
|
||||||
|
}, [method]);
|
||||||
|
|
||||||
if (auth.status === "authenticated") {
|
if (auth.status === "authenticated") {
|
||||||
return <Redirect href="/dashboard" />;
|
return <Redirect href="/dashboard" />;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View className="flex-col justify-end h-full bg-primary">
|
<>
|
||||||
<Text className="text-base-100 text-7xl font-bold ml-8">Login</Text>
|
<KeyboardStickyView className="flex-col justify-end h-full bg-base-100 relative">
|
||||||
<Svg
|
<View className="flex-col justify-end h-full bg-primary relative">
|
||||||
viewBox="0 0 1440 320"
|
<View className="my-auto">
|
||||||
width={Dimensions.get("screen").width}
|
<Image
|
||||||
height={100}
|
source={require("@/assets/images/linkwarden.png")}
|
||||||
>
|
className="w-[120px] h-[120px] mx-auto"
|
||||||
<Path
|
|
||||||
fill={rawTheme[colorScheme as ThemeName]["base-100"]}
|
|
||||||
fill-opacity="1"
|
|
||||||
d="M0,256L48,234.7C96,213,192,171,288,176C384,181,480,235,576,266.7C672,299,768,309,864,277.3C960,245,1056,171,1152,122.7C1248,75,1344,53,1392,42.7L1440,32L1440,320L1392,320C1344,320,1248,320,1152,320C1056,320,960,320,864,320C768,320,672,320,576,320C480,320,384,320,288,320C192,320,96,320,48,320L0,320Z"
|
|
||||||
/>
|
|
||||||
</Svg>
|
|
||||||
<View className="flex-col justify-end h-1/3 bg-base-100 -mt-2 pb-10 gap-4 w-full px-4">
|
|
||||||
{method === "password" ? (
|
|
||||||
<>
|
|
||||||
<Input
|
|
||||||
className="w-full text-xl p-3 leading-tight"
|
|
||||||
textAlignVertical="center"
|
|
||||||
placeholder="Email or Username"
|
|
||||||
value={form.user}
|
|
||||||
onChangeText={(text) => setForm({ ...form, user: text })}
|
|
||||||
/>
|
/>
|
||||||
|
</View>
|
||||||
<Input
|
<Text className="text-base-100 text-7xl font-bold ml-8">Login</Text>
|
||||||
className="w-full text-xl p-3 leading-tight"
|
<View>
|
||||||
textAlignVertical="center"
|
<Text
|
||||||
placeholder="Password"
|
className="text-base-100 text-2xl mx-8 mt-3"
|
||||||
secureTextEntry
|
numberOfLines={1}
|
||||||
value={form.password}
|
>
|
||||||
onChangeText={(text) => setForm({ ...form, password: text })}
|
Login to{" "}
|
||||||
|
{form.instance === "https://cloud.linkwarden.app"
|
||||||
|
? "cloud.linkwarden.app"
|
||||||
|
: form.instance}
|
||||||
|
</Text>
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={() => {
|
||||||
|
if (showInstanceField) {
|
||||||
|
setForm({
|
||||||
|
...form,
|
||||||
|
instance: "https://cloud.linkwarden.app",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
setShowInstanceField(!showInstanceField);
|
||||||
|
}}
|
||||||
|
className="mx-8 mt-1 self-start"
|
||||||
|
>
|
||||||
|
<Text className="text-neutral-content text-sm">
|
||||||
|
{!showInstanceField ? "Change server" : "Use official server"}
|
||||||
|
</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
<Svg
|
||||||
|
viewBox="0 0 1440 320"
|
||||||
|
width={Dimensions.get("screen").width}
|
||||||
|
height={Dimensions.get("screen").width * (320 / 1440) + 2}
|
||||||
|
>
|
||||||
|
<Path
|
||||||
|
fill={rawTheme[colorScheme as ThemeName]["base-100"]}
|
||||||
|
fill-opacity="1"
|
||||||
|
d="M0,256L48,234.7C96,213,192,171,288,176C384,181,480,235,576,266.7C672,299,768,309,864,277.3C960,245,1056,171,1152,122.7C1248,75,1344,53,1392,42.7L1440,32L1440,320L1392,320C1344,320,1248,320,1152,320C1056,320,960,320,864,320C768,320,672,320,576,320C480,320,384,320,288,320C192,320,96,320,48,320L0,320Z"
|
||||||
/>
|
/>
|
||||||
</>
|
</Svg>
|
||||||
) : (
|
<SafeAreaView
|
||||||
<Input
|
edges={["bottom"]}
|
||||||
className="w-full text-xl p-3 leading-tight"
|
className="flex-col justify-end h-auto duration-100 pt-10 bg-base-100 -mt-2 pb-10 gap-4 w-full px-4"
|
||||||
textAlignVertical="center"
|
>
|
||||||
placeholder="Access Token"
|
{showInstanceField && (
|
||||||
secureTextEntry
|
<Input
|
||||||
value={form.token}
|
className="w-full text-xl p-3 leading-tight h-12"
|
||||||
onChangeText={(text) => setForm({ ...form, token: text })}
|
textAlignVertical="center"
|
||||||
/>
|
placeholder="Instance URL"
|
||||||
)}
|
selectTextOnFocus={false}
|
||||||
|
value={form.instance}
|
||||||
|
onChangeText={(text) => setForm({ ...form, instance: text })}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{method === "password" ? (
|
||||||
|
<>
|
||||||
|
<Input
|
||||||
|
className="w-full text-xl p-3 leading-tight h-12"
|
||||||
|
textAlignVertical="center"
|
||||||
|
placeholder="Email or Username"
|
||||||
|
value={form.user}
|
||||||
|
onChangeText={(text) => setForm({ ...form, user: text })}
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
className="w-full text-xl p-3 leading-tight h-12"
|
||||||
|
textAlignVertical="center"
|
||||||
|
placeholder="Password"
|
||||||
|
secureTextEntry
|
||||||
|
value={form.password}
|
||||||
|
onChangeText={(text) => setForm({ ...form, password: text })}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<Input
|
||||||
|
className="w-full text-xl p-3 leading-tight h-12"
|
||||||
|
textAlignVertical="center"
|
||||||
|
placeholder="Access Token"
|
||||||
|
secureTextEntry
|
||||||
|
value={form.token}
|
||||||
|
onChangeText={(text) => setForm({ ...form, token: text })}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
onPress={() =>
|
onPress={() =>
|
||||||
setMethod(method === "password" ? "token" : "password")
|
setMethod(method === "password" ? "token" : "password")
|
||||||
}
|
}
|
||||||
className="w-fit mx-auto"
|
className="w-fit mx-auto"
|
||||||
>
|
>
|
||||||
<Text className="text-primary w-fit text-center">
|
<Text className="text-primary w-fit text-center">
|
||||||
{method === "password"
|
{method === "password"
|
||||||
? "Login with Access Token instead"
|
? "Login with Access Token"
|
||||||
: "Login with Username/Password instead"}
|
: "Login with Username/Password"}
|
||||||
</Text>
|
</Text>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
variant="accent"
|
variant="accent"
|
||||||
size="lg"
|
size="lg"
|
||||||
onPress={() =>
|
isLoading={isLoading}
|
||||||
signIn(
|
onPress={async () => {
|
||||||
form.user,
|
if (
|
||||||
form.password,
|
((form.user && form.password) || form.token) &&
|
||||||
form.instance ? form.instance : undefined
|
form.instance
|
||||||
)
|
) {
|
||||||
}
|
setIsLoading(true);
|
||||||
>
|
await signIn(
|
||||||
<Text className="text-white">Login</Text>
|
form.user,
|
||||||
</Button>
|
form.password,
|
||||||
<TouchableOpacity className="w-fit mx-auto">
|
form.instance,
|
||||||
<Text className="text-neutral text-center w-fit">Need help?</Text>
|
form.token
|
||||||
</TouchableOpacity>
|
);
|
||||||
</View>
|
setIsLoading(false);
|
||||||
</View>
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text className="text-white text-xl">Login</Text>
|
||||||
|
</Button>
|
||||||
|
<TouchableOpacity
|
||||||
|
className="w-fit mx-auto"
|
||||||
|
onPress={() => SheetManager.show("support-sheet")}
|
||||||
|
>
|
||||||
|
<Text className="text-neutral text-center w-fit">Need help?</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</SafeAreaView>
|
||||||
|
</View>
|
||||||
|
</KeyboardStickyView>
|
||||||
|
<KeyboardToolbar />
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 17 KiB |
|
Before Width: | Height: | Size: 22 KiB After Width: | Height: | Size: 238 KiB |
BIN
apps/mobile/assets/images/linkwarden.png
Normal file
|
After Width: | Height: | Size: 241 KiB |
BIN
apps/mobile/assets/images/maskable_logo.jpeg
Normal file
|
After Width: | Height: | Size: 8.7 KiB |
|
Before Width: | Height: | Size: 5.0 KiB |
|
Before Width: | Height: | Size: 6.2 KiB |
|
Before Width: | Height: | Size: 14 KiB |
|
Before Width: | Height: | Size: 21 KiB |
|
Before Width: | Height: | Size: 17 KiB After Width: | Height: | Size: 82 B |
@@ -7,6 +7,7 @@ import { useAddLink } from "@linkwarden/router/links";
|
|||||||
import useAuthStore from "@/store/auth";
|
import useAuthStore from "@/store/auth";
|
||||||
import { rawTheme, ThemeName } from "@/lib/colors";
|
import { rawTheme, ThemeName } from "@/lib/colors";
|
||||||
import { useColorScheme } from "nativewind";
|
import { useColorScheme } from "nativewind";
|
||||||
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
|
|
||||||
export default function AddLinkSheet() {
|
export default function AddLinkSheet() {
|
||||||
const actionSheetRef = useRef<ActionSheetRef>(null);
|
const actionSheetRef = useRef<ActionSheetRef>(null);
|
||||||
@@ -15,6 +16,8 @@ export default function AddLinkSheet() {
|
|||||||
const [link, setLink] = useState("");
|
const [link, setLink] = useState("");
|
||||||
const { colorScheme } = useColorScheme();
|
const { colorScheme } = useColorScheme();
|
||||||
|
|
||||||
|
const insets = useSafeAreaInsets();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ActionSheet
|
<ActionSheet
|
||||||
ref={actionSheetRef}
|
ref={actionSheetRef}
|
||||||
@@ -25,6 +28,7 @@ export default function AddLinkSheet() {
|
|||||||
containerStyle={{
|
containerStyle={{
|
||||||
backgroundColor: rawTheme[colorScheme as ThemeName]["base-200"],
|
backgroundColor: rawTheme[colorScheme as ThemeName]["base-200"],
|
||||||
}}
|
}}
|
||||||
|
safeAreaInsets={insets}
|
||||||
>
|
>
|
||||||
<View className="px-8 py-5">
|
<View className="px-8 py-5">
|
||||||
<Input
|
<Input
|
||||||
@@ -50,6 +54,7 @@ export default function AddLinkSheet() {
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
isLoading={addLink.isPending}
|
||||||
variant="accent"
|
variant="accent"
|
||||||
className="mb-2"
|
className="mb-2"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -20,6 +20,8 @@ import { useCollections } from "@linkwarden/router/collections";
|
|||||||
import { rawTheme, ThemeName } from "@/lib/colors";
|
import { rawTheme, ThemeName } from "@/lib/colors";
|
||||||
import { useColorScheme } from "nativewind";
|
import { useColorScheme } from "nativewind";
|
||||||
import { Folder, ChevronRight, Check } from "lucide-react-native";
|
import { Folder, ChevronRight, Check } from "lucide-react-native";
|
||||||
|
import useTmpStore from "@/store/tmp";
|
||||||
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
|
|
||||||
const Main = (props: SheetProps<"edit-link-sheet">) => {
|
const Main = (props: SheetProps<"edit-link-sheet">) => {
|
||||||
const { auth } = useAuthStore();
|
const { auth } = useAuthStore();
|
||||||
@@ -39,6 +41,8 @@ const Main = (props: SheetProps<"edit-link-sheet">) => {
|
|||||||
}
|
}
|
||||||
}, [params?.link]);
|
}, [params?.link]);
|
||||||
|
|
||||||
|
const { tmp, updateTmp } = useTmpStore();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View className="px-8 py-5">
|
<View className="px-8 py-5">
|
||||||
<Input
|
<Input
|
||||||
@@ -111,6 +115,11 @@ const Main = (props: SheetProps<"edit-link-sheet">) => {
|
|||||||
onPress={() =>
|
onPress={() =>
|
||||||
editLink.mutate(link as LinkIncludingShortenedCollectionAndTags, {
|
editLink.mutate(link as LinkIncludingShortenedCollectionAndTags, {
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
|
if (link && tmp.link)
|
||||||
|
updateTmp({
|
||||||
|
link,
|
||||||
|
});
|
||||||
|
|
||||||
SheetManager.hide("edit-link-sheet");
|
SheetManager.hide("edit-link-sheet");
|
||||||
},
|
},
|
||||||
onError: (error) => {
|
onError: (error) => {
|
||||||
@@ -119,6 +128,7 @@ const Main = (props: SheetProps<"edit-link-sheet">) => {
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
isLoading={editLink.isPending}
|
||||||
variant="accent"
|
variant="accent"
|
||||||
className="mb-2"
|
className="mb-2"
|
||||||
>
|
>
|
||||||
@@ -246,6 +256,8 @@ const routes: Route[] = [
|
|||||||
export default function EditLinkSheet() {
|
export default function EditLinkSheet() {
|
||||||
const { colorScheme } = useColorScheme();
|
const { colorScheme } = useColorScheme();
|
||||||
|
|
||||||
|
const insets = useSafeAreaInsets();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ActionSheet
|
<ActionSheet
|
||||||
gestureEnabled
|
gestureEnabled
|
||||||
@@ -258,6 +270,7 @@ export default function EditLinkSheet() {
|
|||||||
containerStyle={{
|
containerStyle={{
|
||||||
backgroundColor: rawTheme[colorScheme as ThemeName]["base-200"],
|
backgroundColor: rawTheme[colorScheme as ThemeName]["base-200"],
|
||||||
}}
|
}}
|
||||||
|
safeAreaInsets={insets}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
92
apps/mobile/components/ActionSheets/NewCollectionSheet.tsx
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
import { Alert, Text, View } from "react-native";
|
||||||
|
import { useRef, useState } from "react";
|
||||||
|
import ActionSheet, { ActionSheetRef } from "react-native-actions-sheet";
|
||||||
|
import Input from "@/components/ui/Input";
|
||||||
|
import { Button } from "@/components/ui/Button";
|
||||||
|
import useAuthStore from "@/store/auth";
|
||||||
|
import { rawTheme, ThemeName } from "@/lib/colors";
|
||||||
|
import { useColorScheme } from "nativewind";
|
||||||
|
import { useCreateCollection } from "@linkwarden/router/collections";
|
||||||
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
|
|
||||||
|
export default function NewCollectionSheet() {
|
||||||
|
const actionSheetRef = useRef<ActionSheetRef>(null);
|
||||||
|
const { auth } = useAuthStore();
|
||||||
|
const createCollection = useCreateCollection(auth);
|
||||||
|
const [collection, setCollection] = useState({
|
||||||
|
name: "",
|
||||||
|
description: "",
|
||||||
|
});
|
||||||
|
const { colorScheme } = useColorScheme();
|
||||||
|
|
||||||
|
const insets = useSafeAreaInsets();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ActionSheet
|
||||||
|
ref={actionSheetRef}
|
||||||
|
gestureEnabled
|
||||||
|
indicatorStyle={{
|
||||||
|
backgroundColor: rawTheme[colorScheme as ThemeName]["neutral-content"],
|
||||||
|
}}
|
||||||
|
containerStyle={{
|
||||||
|
backgroundColor: rawTheme[colorScheme as ThemeName]["base-200"],
|
||||||
|
}}
|
||||||
|
safeAreaInsets={insets}
|
||||||
|
>
|
||||||
|
<View className="px-8 py-5">
|
||||||
|
<Input
|
||||||
|
placeholder="Name"
|
||||||
|
className="mb-4 bg-base-100"
|
||||||
|
value={collection.name}
|
||||||
|
onChangeText={(text) => setCollection({ ...collection, name: text })}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Input
|
||||||
|
placeholder="Description"
|
||||||
|
className="mb-4 bg-base-100"
|
||||||
|
value={collection.description}
|
||||||
|
onChangeText={(text) =>
|
||||||
|
setCollection({ ...collection, description: text })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
onPress={() =>
|
||||||
|
createCollection.mutate(
|
||||||
|
{ name: collection.name, description: collection.description },
|
||||||
|
{
|
||||||
|
onSuccess: () => {
|
||||||
|
actionSheetRef.current?.hide();
|
||||||
|
setCollection({ name: "", description: "" });
|
||||||
|
},
|
||||||
|
onError: (error) => {
|
||||||
|
Alert.alert(
|
||||||
|
"Error",
|
||||||
|
"There was an error creating the collection."
|
||||||
|
);
|
||||||
|
console.error("Error creating collection:", error);
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
isLoading={createCollection.isPending}
|
||||||
|
variant="accent"
|
||||||
|
className="mb-2"
|
||||||
|
>
|
||||||
|
<Text className="text-white">Save Collection</Text>
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
onPress={() => {
|
||||||
|
actionSheetRef.current?.hide();
|
||||||
|
setCollection({ name: "", description: "" });
|
||||||
|
}}
|
||||||
|
variant="outline"
|
||||||
|
className="mb-2"
|
||||||
|
>
|
||||||
|
<Text className="text-base-content">Cancel</Text>
|
||||||
|
</Button>
|
||||||
|
</View>
|
||||||
|
</ActionSheet>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -3,15 +3,20 @@ import {
|
|||||||
RouteDefinition,
|
RouteDefinition,
|
||||||
SheetDefinition,
|
SheetDefinition,
|
||||||
} from "react-native-actions-sheet";
|
} from "react-native-actions-sheet";
|
||||||
|
import SupportSheet from "./SupportSheet";
|
||||||
import AddLinkSheet from "./AddLinkSheet";
|
import AddLinkSheet from "./AddLinkSheet";
|
||||||
import EditLinkSheet from "./EditLinkSheet";
|
import EditLinkSheet from "./EditLinkSheet";
|
||||||
|
import NewCollectionSheet from "./NewCollectionSheet";
|
||||||
import { LinkIncludingShortenedCollectionAndTags } from "@linkwarden/types";
|
import { LinkIncludingShortenedCollectionAndTags } from "@linkwarden/types";
|
||||||
|
|
||||||
|
registerSheet("support-sheet", SupportSheet);
|
||||||
registerSheet("add-link-sheet", AddLinkSheet);
|
registerSheet("add-link-sheet", AddLinkSheet);
|
||||||
registerSheet("edit-link-sheet", EditLinkSheet);
|
registerSheet("edit-link-sheet", EditLinkSheet);
|
||||||
|
registerSheet("new-collection-sheet", NewCollectionSheet);
|
||||||
|
|
||||||
declare module "react-native-actions-sheet" {
|
declare module "react-native-actions-sheet" {
|
||||||
interface Sheets {
|
interface Sheets {
|
||||||
|
"support-sheet": SheetDefinition;
|
||||||
"add-link-sheet": SheetDefinition;
|
"add-link-sheet": SheetDefinition;
|
||||||
"edit-link-sheet": SheetDefinition<{
|
"edit-link-sheet": SheetDefinition<{
|
||||||
payload: {
|
payload: {
|
||||||
@@ -26,6 +31,7 @@ declare module "react-native-actions-sheet" {
|
|||||||
}>;
|
}>;
|
||||||
};
|
};
|
||||||
}>;
|
}>;
|
||||||
|
"new-collection-sheet": SheetDefinition;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
47
apps/mobile/components/ActionSheets/SupportSheet.tsx
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
import { Text, View } from "react-native";
|
||||||
|
import { useState } from "react";
|
||||||
|
import ActionSheet from "react-native-actions-sheet";
|
||||||
|
import { rawTheme, ThemeName } from "@/lib/colors";
|
||||||
|
import { useColorScheme } from "nativewind";
|
||||||
|
import * as Clipboard from "expo-clipboard";
|
||||||
|
import { Button } from "../ui/Button";
|
||||||
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
|
|
||||||
|
export default function SupportSheet() {
|
||||||
|
const { colorScheme } = useColorScheme();
|
||||||
|
const [copied, setCopied] = useState(false);
|
||||||
|
|
||||||
|
async function handleEmailPress() {
|
||||||
|
await Clipboard.setStringAsync("support@linkwarden.app");
|
||||||
|
setCopied(true);
|
||||||
|
setTimeout(() => setCopied(false), 2000);
|
||||||
|
}
|
||||||
|
|
||||||
|
const insets = useSafeAreaInsets();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ActionSheet
|
||||||
|
gestureEnabled
|
||||||
|
indicatorStyle={{
|
||||||
|
backgroundColor: rawTheme[colorScheme as ThemeName]["neutral-content"],
|
||||||
|
}}
|
||||||
|
containerStyle={{
|
||||||
|
backgroundColor: rawTheme[colorScheme as ThemeName]["base-100"],
|
||||||
|
}}
|
||||||
|
safeAreaInsets={insets}
|
||||||
|
>
|
||||||
|
<View className="px-8 py-5 flex-col gap-4">
|
||||||
|
<Text className="text-2xl font-bold text-base-content">Need help?</Text>
|
||||||
|
<Text className="text-base-content">
|
||||||
|
Whether you have a question or need assistance, feel free to reach out
|
||||||
|
to us at support@linkwarden.app
|
||||||
|
</Text>
|
||||||
|
<Button onPress={handleEmailPress} variant="outline">
|
||||||
|
<Text className="text-base-content">
|
||||||
|
{copied ? "Copied!" : "Copy Support Email"}
|
||||||
|
</Text>
|
||||||
|
</Button>
|
||||||
|
</View>
|
||||||
|
</ActionSheet>
|
||||||
|
);
|
||||||
|
}
|
||||||
132
apps/mobile/components/CollectionListing.tsx
Normal file
@@ -0,0 +1,132 @@
|
|||||||
|
import { View, Text, Pressable, Platform, Alert } from "react-native";
|
||||||
|
import { decode } from "html-entities";
|
||||||
|
import { CollectionIncludingMembersAndLinkCount } from "@linkwarden/types";
|
||||||
|
import useAuthStore from "@/store/auth";
|
||||||
|
import { useRouter } from "expo-router";
|
||||||
|
import * as ContextMenu from "zeego/context-menu";
|
||||||
|
import { cn } from "@linkwarden/lib/utils";
|
||||||
|
import { rawTheme, ThemeName } from "@/lib/colors";
|
||||||
|
import { useColorScheme } from "nativewind";
|
||||||
|
import { CalendarDays, Folder, Link } from "lucide-react-native";
|
||||||
|
import { useDeleteCollection } from "@linkwarden/router/collections";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
collection: CollectionIncludingMembersAndLinkCount;
|
||||||
|
};
|
||||||
|
|
||||||
|
const CollectionListing = ({ collection }: Props) => {
|
||||||
|
const { auth } = useAuthStore();
|
||||||
|
const router = useRouter();
|
||||||
|
const { colorScheme } = useColorScheme();
|
||||||
|
|
||||||
|
const deleteCollection = useDeleteCollection(auth);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ContextMenu.Root>
|
||||||
|
<ContextMenu.Trigger asChild>
|
||||||
|
<Pressable
|
||||||
|
className={cn(
|
||||||
|
"p-5 flex-row justify-between",
|
||||||
|
"bg-base-100",
|
||||||
|
Platform.OS !== "android" && "active:bg-base-200/50"
|
||||||
|
)}
|
||||||
|
onLongPress={() => {}}
|
||||||
|
onPress={() => router.navigate(`/collections/${collection.id}`)}
|
||||||
|
android_ripple={{
|
||||||
|
color: colorScheme === "dark" ? "rgba(255,255,255,0.2)" : "#ddd",
|
||||||
|
borderless: false,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<View className="w-full">
|
||||||
|
<View className="w-[90%] flex-col justify-between gap-3">
|
||||||
|
<View className="flex flex-row gap-2 items-center pr-1.5 self-start rounded-md">
|
||||||
|
<Folder
|
||||||
|
size={16}
|
||||||
|
fill={collection.color || ""}
|
||||||
|
color={collection.color || ""}
|
||||||
|
/>
|
||||||
|
<Text
|
||||||
|
numberOfLines={2}
|
||||||
|
className="font-medium text-lg text-base-content"
|
||||||
|
>
|
||||||
|
{decode(collection.name)}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
{collection.description && (
|
||||||
|
<Text
|
||||||
|
numberOfLines={2}
|
||||||
|
className="font-light text-sm text-base-content"
|
||||||
|
>
|
||||||
|
{decode(collection.description)}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View className="flex-row gap-3">
|
||||||
|
<View className="flex flex-row gap-1 items-center mt-5 self-start">
|
||||||
|
<CalendarDays
|
||||||
|
size={16}
|
||||||
|
color={rawTheme[colorScheme as ThemeName]["neutral"]}
|
||||||
|
/>
|
||||||
|
<Text
|
||||||
|
numberOfLines={1}
|
||||||
|
className="font-light text-xs text-base-content"
|
||||||
|
>
|
||||||
|
{new Date(collection.createdAt as string).toLocaleString(
|
||||||
|
"en-US",
|
||||||
|
{
|
||||||
|
year: "numeric",
|
||||||
|
month: "short",
|
||||||
|
day: "numeric",
|
||||||
|
}
|
||||||
|
)}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<View className="flex flex-row gap-1 items-center mt-5 self-start">
|
||||||
|
<Link
|
||||||
|
size={16}
|
||||||
|
color={rawTheme[colorScheme as ThemeName]["neutral"]}
|
||||||
|
/>
|
||||||
|
<Text
|
||||||
|
numberOfLines={1}
|
||||||
|
className="font-light text-xs text-base-content"
|
||||||
|
>
|
||||||
|
{collection._count?.links}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</Pressable>
|
||||||
|
</ContextMenu.Trigger>
|
||||||
|
|
||||||
|
<ContextMenu.Content avoidCollisions>
|
||||||
|
<ContextMenu.Item
|
||||||
|
key="delete-collection"
|
||||||
|
onSelect={() => {
|
||||||
|
return Alert.alert(
|
||||||
|
"Delete Collection",
|
||||||
|
"Are you sure you want to delete this collection? This action cannot be undone.",
|
||||||
|
[
|
||||||
|
{
|
||||||
|
text: "Cancel",
|
||||||
|
style: "cancel",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
text: "Delete",
|
||||||
|
style: "destructive",
|
||||||
|
onPress: () => {
|
||||||
|
deleteCollection.mutate(collection.id as number);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ContextMenu.ItemTitle>Delete</ContextMenu.ItemTitle>
|
||||||
|
</ContextMenu.Item>
|
||||||
|
</ContextMenu.Content>
|
||||||
|
</ContextMenu.Root>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default CollectionListing;
|
||||||
346
apps/mobile/components/DashboardSection.tsx
Normal file
@@ -0,0 +1,346 @@
|
|||||||
|
import {
|
||||||
|
FlatList,
|
||||||
|
Text,
|
||||||
|
TouchableOpacity,
|
||||||
|
View,
|
||||||
|
ViewToken,
|
||||||
|
} from "react-native";
|
||||||
|
import React from "react";
|
||||||
|
import clsx from "clsx";
|
||||||
|
import DashboardItem from "@/components/DashboardItem";
|
||||||
|
import { rawTheme, ThemeName } from "@/lib/colors";
|
||||||
|
import {
|
||||||
|
Clock8,
|
||||||
|
ChevronRight,
|
||||||
|
Pin,
|
||||||
|
Folder,
|
||||||
|
Hash,
|
||||||
|
Link,
|
||||||
|
} from "lucide-react-native";
|
||||||
|
import { LinkIncludingShortenedCollectionAndTags } from "@linkwarden/types";
|
||||||
|
import LinkListing from "@/components/LinkListing";
|
||||||
|
import { useColorScheme } from "nativewind";
|
||||||
|
import { useRouter } from "expo-router";
|
||||||
|
|
||||||
|
// Don't use prisma client's DashboardSectionType, it'll crash in production (React Native)
|
||||||
|
type DashboardSectionType =
|
||||||
|
| "STATS"
|
||||||
|
| "RECENT_LINKS"
|
||||||
|
| "PINNED_LINKS"
|
||||||
|
| "COLLECTION";
|
||||||
|
|
||||||
|
type DashboardSectionProps = {
|
||||||
|
sectionData: { type: DashboardSectionType };
|
||||||
|
collection?: any;
|
||||||
|
links?: any[];
|
||||||
|
tagsLength: number;
|
||||||
|
numberOfLinks: number;
|
||||||
|
collectionsLength: number;
|
||||||
|
numberOfPinnedLinks: number;
|
||||||
|
dashboardData: {
|
||||||
|
isLoading: boolean;
|
||||||
|
refetch: Function;
|
||||||
|
isRefetching: boolean;
|
||||||
|
};
|
||||||
|
collectionLinks?: any[];
|
||||||
|
};
|
||||||
|
|
||||||
|
const DashboardSection: React.FC<DashboardSectionProps> = ({
|
||||||
|
sectionData,
|
||||||
|
collection,
|
||||||
|
links = [],
|
||||||
|
tagsLength,
|
||||||
|
numberOfLinks,
|
||||||
|
collectionsLength,
|
||||||
|
numberOfPinnedLinks,
|
||||||
|
dashboardData,
|
||||||
|
collectionLinks = [],
|
||||||
|
}) => {
|
||||||
|
const { colorScheme } = useColorScheme();
|
||||||
|
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
switch (sectionData.type) {
|
||||||
|
case "STATS":
|
||||||
|
return (
|
||||||
|
<View className="flex-col gap-4 max-w-full px-5">
|
||||||
|
<View className="flex-row gap-4">
|
||||||
|
<DashboardItem
|
||||||
|
name={numberOfLinks === 1 ? "Link" : "Links"}
|
||||||
|
value={numberOfLinks}
|
||||||
|
icon={<Link size={23} color="white" />}
|
||||||
|
color="#9c00cc"
|
||||||
|
/>
|
||||||
|
<DashboardItem
|
||||||
|
name={collectionsLength === 1 ? "Collection" : "Collections"}
|
||||||
|
value={collectionsLength}
|
||||||
|
icon={<Folder size={23} color="white" fill="white" />}
|
||||||
|
color="#0096cc"
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
<View className="flex-row gap-4">
|
||||||
|
<DashboardItem
|
||||||
|
name={tagsLength === 1 ? "Tag" : "Tags"}
|
||||||
|
value={tagsLength}
|
||||||
|
icon={<Hash size={23} color="white" />}
|
||||||
|
color="#00cc99"
|
||||||
|
/>
|
||||||
|
<DashboardItem
|
||||||
|
name={"Pinned Links"}
|
||||||
|
value={numberOfPinnedLinks}
|
||||||
|
icon={<Pin size={23} color="white" fill="white" />}
|
||||||
|
color="#cc6d00"
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
|
||||||
|
case "RECENT_LINKS":
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<View className="flex-row justify-between items-center px-5">
|
||||||
|
<View className="flex-row gap-2 items-center">
|
||||||
|
<View className={"flex-row items-center gap-2"}>
|
||||||
|
<Clock8
|
||||||
|
size={30}
|
||||||
|
color={rawTheme[colorScheme as ThemeName].primary}
|
||||||
|
/>
|
||||||
|
<Text className="text-2xl capitalize text-base-content">
|
||||||
|
Recent Links
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
<TouchableOpacity
|
||||||
|
className="flex-row items-center text-sm gap-1"
|
||||||
|
onPress={() => router.navigate("/(tabs)/dashboard/recent-links")}
|
||||||
|
>
|
||||||
|
<Text className="text-primary">View All</Text>
|
||||||
|
<ChevronRight
|
||||||
|
size={15}
|
||||||
|
color={rawTheme[colorScheme as ThemeName].primary}
|
||||||
|
/>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{dashboardData.isLoading ||
|
||||||
|
(links.length > 0 && !dashboardData.isLoading) ? (
|
||||||
|
<FlatList
|
||||||
|
horizontal
|
||||||
|
showsHorizontalScrollIndicator={false}
|
||||||
|
directionalLockEnabled
|
||||||
|
data={links || []}
|
||||||
|
refreshing={dashboardData.isLoading}
|
||||||
|
initialNumToRender={2}
|
||||||
|
keyExtractor={(item) => item.id?.toString() || ""}
|
||||||
|
renderItem={({ item }) => (
|
||||||
|
<RenderItem item={item} key={item.id?.toString()} />
|
||||||
|
)}
|
||||||
|
ItemSeparatorComponent={() => <View style={{ width: 10 }} />}
|
||||||
|
contentContainerStyle={{
|
||||||
|
paddingHorizontal: 20,
|
||||||
|
}}
|
||||||
|
onViewableItemsChanged={({
|
||||||
|
viewableItems,
|
||||||
|
}: {
|
||||||
|
viewableItems: ViewToken[];
|
||||||
|
}) => {
|
||||||
|
const links = viewableItems.map(
|
||||||
|
(e) => e.item
|
||||||
|
) as LinkIncludingShortenedCollectionAndTags[];
|
||||||
|
|
||||||
|
if (
|
||||||
|
!dashboardData.isRefetching &&
|
||||||
|
links.some((e) => e.id && !e.preview)
|
||||||
|
) {
|
||||||
|
dashboardData.refetch();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
viewabilityConfig={{ itemVisiblePercentThreshold: 50 }}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<View className="flex-col gap-2 justify-center items-center h-40 p-10 rounded-xl bg-base-200 mx-5">
|
||||||
|
<Clock8
|
||||||
|
size={40}
|
||||||
|
color={rawTheme[colorScheme as ThemeName].primary}
|
||||||
|
/>
|
||||||
|
<Text className="text-center text-xl text-neutral">
|
||||||
|
No Recent Links
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
{/* <View className="text-center w-full mt-4 flex-row flex-wrap gap-4 justify-center">
|
||||||
|
<Button onPress={() => setNewLinkModal(true)} variant="accent">
|
||||||
|
<Icon name="bi-plus-lg" className="text-xl" />
|
||||||
|
<Text>{t("add_link")}</Text>
|
||||||
|
</Button>
|
||||||
|
<ImportDropdown />
|
||||||
|
</View> */}
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
|
case "PINNED_LINKS":
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<View className="flex-row justify-between items-center px-5">
|
||||||
|
<View className="flex-row gap-2 items-center">
|
||||||
|
<View className={"flex-row items-center gap-2"}>
|
||||||
|
<Pin
|
||||||
|
size={30}
|
||||||
|
color={rawTheme[colorScheme as ThemeName].primary}
|
||||||
|
/>
|
||||||
|
<Text className="text-2xl capitalize text-base-content">
|
||||||
|
Pinned Links
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
<TouchableOpacity
|
||||||
|
className="flex-row items-center text-sm gap-1"
|
||||||
|
onPress={() => router.navigate("/(tabs)/dashboard/pinned-links")}
|
||||||
|
>
|
||||||
|
<Text className="text-primary">View All</Text>
|
||||||
|
<ChevronRight
|
||||||
|
size={15}
|
||||||
|
color={rawTheme[colorScheme as ThemeName].primary}
|
||||||
|
/>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{dashboardData.isLoading ||
|
||||||
|
links?.some((e: any) => e.pinnedBy && e.pinnedBy[0]) ? (
|
||||||
|
<FlatList
|
||||||
|
horizontal
|
||||||
|
showsHorizontalScrollIndicator={false}
|
||||||
|
data={links.filter((e: any) => e.pinnedBy && e.pinnedBy[0]) || []}
|
||||||
|
refreshing={dashboardData.isLoading}
|
||||||
|
initialNumToRender={2}
|
||||||
|
keyExtractor={(item) => item.id?.toString() || ""}
|
||||||
|
renderItem={({ item }) => (
|
||||||
|
<RenderItem item={item} key={item.id?.toString()} />
|
||||||
|
)}
|
||||||
|
ItemSeparatorComponent={() => <View style={{ width: 10 }} />}
|
||||||
|
contentContainerStyle={{
|
||||||
|
paddingHorizontal: 20,
|
||||||
|
}}
|
||||||
|
onViewableItemsChanged={({
|
||||||
|
viewableItems,
|
||||||
|
}: {
|
||||||
|
viewableItems: ViewToken[];
|
||||||
|
}) => {
|
||||||
|
const links = viewableItems.map(
|
||||||
|
(e) => e.item
|
||||||
|
) as LinkIncludingShortenedCollectionAndTags[];
|
||||||
|
|
||||||
|
if (
|
||||||
|
!dashboardData.isRefetching &&
|
||||||
|
links.some((e) => e.id && !e.preview)
|
||||||
|
) {
|
||||||
|
dashboardData.refetch();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
viewabilityConfig={{ itemVisiblePercentThreshold: 50 }}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<View className="flex-col gap-2 justify-center items-center h-40 p-10 rounded-xl bg-base-200 mx-5">
|
||||||
|
<Pin
|
||||||
|
size={40}
|
||||||
|
color={rawTheme[colorScheme as ThemeName].primary}
|
||||||
|
/>
|
||||||
|
<Text className="text-center text-xl text-neutral">
|
||||||
|
No Pinned Links
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
|
case "COLLECTION":
|
||||||
|
return collection?.id ? (
|
||||||
|
<>
|
||||||
|
<View className="flex-row justify-between items-center px-5">
|
||||||
|
<View className="flex-row gap-2 items-center max-w-[60%]">
|
||||||
|
<View className={clsx("flex-row items-center gap-2")}>
|
||||||
|
<Folder
|
||||||
|
size={30}
|
||||||
|
fill={collection.color || "#0ea5e9"}
|
||||||
|
color={collection.color || "#0ea5e9"}
|
||||||
|
/>
|
||||||
|
<Text
|
||||||
|
className="text-2xl capitalize w-full text-base-content"
|
||||||
|
numberOfLines={1}
|
||||||
|
>
|
||||||
|
{collection.name}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
<TouchableOpacity
|
||||||
|
className="flex-row items-center text-sm gap-1 whitespace-nowrap"
|
||||||
|
onPress={() =>
|
||||||
|
router.navigate(
|
||||||
|
`/(tabs)/dashboard/collection?collectionId=${collection.id}`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Text className="text-primary">View All</Text>
|
||||||
|
<ChevronRight
|
||||||
|
size={15}
|
||||||
|
color={rawTheme[colorScheme as ThemeName].primary}
|
||||||
|
/>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{dashboardData.isLoading || collectionLinks.length > 0 ? (
|
||||||
|
<FlatList
|
||||||
|
horizontal
|
||||||
|
showsHorizontalScrollIndicator={false}
|
||||||
|
data={collectionLinks || []}
|
||||||
|
refreshing={dashboardData.isLoading}
|
||||||
|
initialNumToRender={2}
|
||||||
|
keyExtractor={(item) => item.id?.toString() || ""}
|
||||||
|
renderItem={({ item }) => (
|
||||||
|
<RenderItem item={item} key={item.id?.toString()} />
|
||||||
|
)}
|
||||||
|
ItemSeparatorComponent={() => <View style={{ width: 10 }} />}
|
||||||
|
contentContainerStyle={{
|
||||||
|
paddingHorizontal: 20,
|
||||||
|
}}
|
||||||
|
onViewableItemsChanged={({
|
||||||
|
viewableItems,
|
||||||
|
}: {
|
||||||
|
viewableItems: ViewToken[];
|
||||||
|
}) => {
|
||||||
|
const links = viewableItems.map(
|
||||||
|
(e) => e.item
|
||||||
|
) as LinkIncludingShortenedCollectionAndTags[];
|
||||||
|
|
||||||
|
if (
|
||||||
|
!dashboardData.isRefetching &&
|
||||||
|
links.some((e) => e.id && !e.preview)
|
||||||
|
) {
|
||||||
|
dashboardData.refetch();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
viewabilityConfig={{ itemVisiblePercentThreshold: 50 }}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<View className="flex-col gap-2 justify-center items-center h-40 p-10 rounded-xl bg-base-200 mx-5">
|
||||||
|
<Text className="text-center text-xl text-neutral">
|
||||||
|
Empty Collection
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
) : null;
|
||||||
|
|
||||||
|
default:
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export default DashboardSection;
|
||||||
|
|
||||||
|
const RenderItem = React.memo(
|
||||||
|
({ item }: { item: LinkIncludingShortenedCollectionAndTags }) => {
|
||||||
|
return <LinkListing link={item} dashboard />;
|
||||||
|
}
|
||||||
|
);
|
||||||
136
apps/mobile/components/Formats/ImageFormat.tsx
Normal file
@@ -0,0 +1,136 @@
|
|||||||
|
import React, { useEffect, useState } from "react";
|
||||||
|
import * as FileSystem from "expo-file-system";
|
||||||
|
import NetInfo from "@react-native-community/netinfo";
|
||||||
|
import useAuthStore from "@/store/auth";
|
||||||
|
import { ArchivedFormat } from "@linkwarden/types";
|
||||||
|
import { Link as LinkType } from "@linkwarden/prisma/client";
|
||||||
|
import WebView from "react-native-webview";
|
||||||
|
import { Image, Platform, ScrollView } from "react-native";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
link: LinkType;
|
||||||
|
setIsLoading: (state: boolean) => void;
|
||||||
|
format: ArchivedFormat.png | ArchivedFormat.jpeg;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function ImageFormat({ link, setIsLoading, format }: Props) {
|
||||||
|
const FORMAT = format;
|
||||||
|
|
||||||
|
const extension = format === ArchivedFormat.png ? "png" : "jpeg";
|
||||||
|
|
||||||
|
const { auth } = useAuthStore();
|
||||||
|
const [content, setContent] = useState<string>("");
|
||||||
|
const [dimension, setDimension] = useState<{
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (content)
|
||||||
|
Image.getSize(content, (width, height) => {
|
||||||
|
setDimension({ width, height });
|
||||||
|
});
|
||||||
|
}, [content]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
async function loadCacheOrFetch() {
|
||||||
|
const filePath =
|
||||||
|
FileSystem.documentDirectory +
|
||||||
|
`archivedData/${extension}/link_${link.id}.${extension}`;
|
||||||
|
|
||||||
|
await FileSystem.makeDirectoryAsync(
|
||||||
|
filePath.substring(0, filePath.lastIndexOf("/")),
|
||||||
|
{
|
||||||
|
intermediates: true,
|
||||||
|
}
|
||||||
|
).catch(() => {});
|
||||||
|
|
||||||
|
const [info] = await Promise.all([FileSystem.getInfoAsync(filePath)]);
|
||||||
|
|
||||||
|
if (info.exists) {
|
||||||
|
setContent(filePath);
|
||||||
|
}
|
||||||
|
|
||||||
|
const net = await NetInfo.fetch();
|
||||||
|
|
||||||
|
if (net.isConnected) {
|
||||||
|
const apiUrl = `${auth.instance}/api/v1/archives/${link.id}?format=${FORMAT}`;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await FileSystem.downloadAsync(apiUrl, filePath, {
|
||||||
|
headers: { Authorization: `Bearer ${auth.session}` },
|
||||||
|
});
|
||||||
|
|
||||||
|
setContent(result.uri);
|
||||||
|
} catch (e) {
|
||||||
|
console.error("Failed to fetch content", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
loadCacheOrFetch();
|
||||||
|
}, [link]);
|
||||||
|
|
||||||
|
if (Platform.OS === "ios")
|
||||||
|
return (
|
||||||
|
content &&
|
||||||
|
dimension && (
|
||||||
|
<ScrollView maximumZoomScale={10}>
|
||||||
|
<Image
|
||||||
|
source={{ uri: content }}
|
||||||
|
onLoadEnd={() => setIsLoading(false)}
|
||||||
|
style={{
|
||||||
|
width: "100%",
|
||||||
|
height: "auto",
|
||||||
|
aspectRatio: dimension.width / dimension.height,
|
||||||
|
}}
|
||||||
|
resizeMode="contain"
|
||||||
|
/>
|
||||||
|
</ScrollView>
|
||||||
|
)
|
||||||
|
);
|
||||||
|
else
|
||||||
|
return (
|
||||||
|
content && (
|
||||||
|
<WebView
|
||||||
|
style={{
|
||||||
|
flex: 1,
|
||||||
|
}}
|
||||||
|
source={{
|
||||||
|
baseUrl: content,
|
||||||
|
html: `
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<style>
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
img {
|
||||||
|
max-width: 100%;
|
||||||
|
height: auto;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<img src="${content}" />
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
`,
|
||||||
|
}}
|
||||||
|
scalesPageToFit
|
||||||
|
originWhitelist={["*"]}
|
||||||
|
mixedContentMode="always"
|
||||||
|
javaScriptEnabled={true}
|
||||||
|
allowFileAccess={true}
|
||||||
|
allowFileAccessFromFileURLs={true}
|
||||||
|
allowUniversalAccessFromFileURLs={true}
|
||||||
|
onLoadEnd={() => setIsLoading(false)}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
72
apps/mobile/components/Formats/PdfFormat.tsx
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
import React, { useEffect, useState } from "react";
|
||||||
|
import * as FileSystem from "expo-file-system";
|
||||||
|
import NetInfo from "@react-native-community/netinfo";
|
||||||
|
import useAuthStore from "@/store/auth";
|
||||||
|
import { ArchivedFormat } from "@linkwarden/types";
|
||||||
|
import { Link as LinkType } from "@linkwarden/prisma/client";
|
||||||
|
import Pdf from "react-native-pdf";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
link: LinkType;
|
||||||
|
setIsLoading: (state: boolean) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function PdfFormat({ link, setIsLoading }: Props) {
|
||||||
|
const FORMAT = ArchivedFormat.pdf;
|
||||||
|
|
||||||
|
const { auth } = useAuthStore();
|
||||||
|
const [content, setContent] = useState<string>("");
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
async function loadCacheOrFetch() {
|
||||||
|
const filePath =
|
||||||
|
FileSystem.documentDirectory + `archivedData/pdf/link_${link.id}.pdf`;
|
||||||
|
|
||||||
|
await FileSystem.makeDirectoryAsync(
|
||||||
|
filePath.substring(0, filePath.lastIndexOf("/")),
|
||||||
|
{
|
||||||
|
intermediates: true,
|
||||||
|
}
|
||||||
|
).catch(() => {});
|
||||||
|
|
||||||
|
const [info] = await Promise.all([FileSystem.getInfoAsync(filePath)]);
|
||||||
|
|
||||||
|
if (info.exists) {
|
||||||
|
setContent(filePath);
|
||||||
|
}
|
||||||
|
|
||||||
|
const net = await NetInfo.fetch();
|
||||||
|
|
||||||
|
if (net.isConnected) {
|
||||||
|
const apiUrl = `${auth.instance}/api/v1/archives/${link.id}?format=${FORMAT}`;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await FileSystem.downloadAsync(apiUrl, filePath, {
|
||||||
|
headers: { Authorization: `Bearer ${auth.session}` },
|
||||||
|
});
|
||||||
|
|
||||||
|
setContent(result.uri);
|
||||||
|
} catch (e) {
|
||||||
|
console.error("Failed to fetch content", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
loadCacheOrFetch();
|
||||||
|
}, [link]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
content && (
|
||||||
|
<Pdf
|
||||||
|
style={{
|
||||||
|
flex: 1,
|
||||||
|
}}
|
||||||
|
source={{ uri: content }}
|
||||||
|
onLoadComplete={() => setIsLoading(false)}
|
||||||
|
onPressLink={(uri) => {
|
||||||
|
console.log(`Link pressed: ${uri}`);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
139
apps/mobile/components/Formats/ReadableFormat.tsx
Normal file
@@ -0,0 +1,139 @@
|
|||||||
|
import React, { useEffect, useState } from "react";
|
||||||
|
import { View, Text, ScrollView, TouchableOpacity } from "react-native";
|
||||||
|
import * as FileSystem from "expo-file-system";
|
||||||
|
import NetInfo from "@react-native-community/netinfo";
|
||||||
|
import useAuthStore from "@/store/auth";
|
||||||
|
import { useRouter } from "expo-router";
|
||||||
|
import { useWindowDimensions } from "react-native";
|
||||||
|
import RenderHtml from "@linkwarden/react-native-render-html";
|
||||||
|
import ElementNotSupported from "@/components/ElementNotSupported";
|
||||||
|
import { decode } from "html-entities";
|
||||||
|
import { useColorScheme } from "nativewind";
|
||||||
|
import { rawTheme, ThemeName } from "@/lib/colors";
|
||||||
|
import { CalendarDays, Link } from "lucide-react-native";
|
||||||
|
import { ArchivedFormat } from "@linkwarden/types";
|
||||||
|
import { Link as LinkType } from "@linkwarden/prisma/client";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
link: LinkType;
|
||||||
|
setIsLoading: (state: boolean) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function ReadableFormat({ link, setIsLoading }: Props) {
|
||||||
|
const FORMAT = ArchivedFormat.readability;
|
||||||
|
|
||||||
|
const { auth } = useAuthStore();
|
||||||
|
const [content, setContent] = useState<string>("");
|
||||||
|
const { width } = useWindowDimensions();
|
||||||
|
const router = useRouter();
|
||||||
|
const { colorScheme } = useColorScheme();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
async function loadCacheOrFetch() {
|
||||||
|
const filePath =
|
||||||
|
FileSystem.documentDirectory +
|
||||||
|
`archivedData/readable/link_${link.id}.html`;
|
||||||
|
|
||||||
|
await FileSystem.makeDirectoryAsync(
|
||||||
|
filePath.substring(0, filePath.lastIndexOf("/")),
|
||||||
|
{
|
||||||
|
intermediates: true,
|
||||||
|
}
|
||||||
|
).catch(() => {});
|
||||||
|
|
||||||
|
const [info] = await Promise.all([FileSystem.getInfoAsync(filePath)]);
|
||||||
|
|
||||||
|
if (info.exists) {
|
||||||
|
const rawContent = await FileSystem.readAsStringAsync(filePath);
|
||||||
|
setContent(rawContent);
|
||||||
|
}
|
||||||
|
|
||||||
|
const net = await NetInfo.fetch();
|
||||||
|
|
||||||
|
if (net.isConnected) {
|
||||||
|
const apiUrl = `${auth.instance}/api/v1/archives/${link.id}?format=${FORMAT}`;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(apiUrl, {
|
||||||
|
headers: { Authorization: `Bearer ${auth.session}` },
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = (await response.json()).content;
|
||||||
|
setContent(data);
|
||||||
|
await FileSystem.writeAsStringAsync(filePath, data, {
|
||||||
|
encoding: FileSystem.EncodingType.UTF8,
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
console.error("Failed to fetch content", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
loadCacheOrFetch();
|
||||||
|
}, [link]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
content && (
|
||||||
|
<ScrollView
|
||||||
|
className="flex-1 bg-base-100"
|
||||||
|
contentContainerClassName="p-4"
|
||||||
|
nestedScrollEnabled
|
||||||
|
>
|
||||||
|
<Text className="text-2xl font-bold mb-2.5 text-base-content">
|
||||||
|
{decode(link.name || link.description || link.url || "")}
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<TouchableOpacity
|
||||||
|
className="flex-row items-center gap-1 mb-2.5 pr-5"
|
||||||
|
onPress={() => router.replace(`/links/${link.id}`)}
|
||||||
|
>
|
||||||
|
<Link
|
||||||
|
size={16}
|
||||||
|
color={rawTheme[colorScheme as ThemeName]["neutral"]}
|
||||||
|
/>
|
||||||
|
<Text className="text-base text-neutral flex-1" numberOfLines={1}>
|
||||||
|
{link.url}
|
||||||
|
</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
|
||||||
|
<View className="flex-row items-center gap-1 mb-2.5">
|
||||||
|
<CalendarDays
|
||||||
|
size={16}
|
||||||
|
color={rawTheme[colorScheme as ThemeName]["neutral"]}
|
||||||
|
/>
|
||||||
|
<Text className="text-base text-neutral">
|
||||||
|
{new Date(link?.importDate || link.createdAt).toLocaleString(
|
||||||
|
"en-US",
|
||||||
|
{
|
||||||
|
year: "numeric",
|
||||||
|
month: "short",
|
||||||
|
day: "numeric",
|
||||||
|
}
|
||||||
|
)}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View className="border-t border-neutral-content mt-2.5 mb-5" />
|
||||||
|
|
||||||
|
<RenderHtml
|
||||||
|
contentWidth={width}
|
||||||
|
source={{ html: content }}
|
||||||
|
renderers={{
|
||||||
|
table: () => (
|
||||||
|
<ElementNotSupported
|
||||||
|
onPress={() => router.replace(`/links/${link.id}`)}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
}}
|
||||||
|
onHTMLLoaded={() => setIsLoading(false)}
|
||||||
|
tagsStyles={{
|
||||||
|
p: { fontSize: 18, lineHeight: 28, marginVertical: 10 },
|
||||||
|
}}
|
||||||
|
baseStyle={{
|
||||||
|
color: rawTheme[colorScheme as ThemeName]["base-content"],
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</ScrollView>
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
80
apps/mobile/components/Formats/WebpageFormat.tsx
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
import React, { useEffect, useState } from "react";
|
||||||
|
import * as FileSystem from "expo-file-system";
|
||||||
|
import NetInfo from "@react-native-community/netinfo";
|
||||||
|
import useAuthStore from "@/store/auth";
|
||||||
|
import { ArchivedFormat } from "@linkwarden/types";
|
||||||
|
import { Link as LinkType } from "@linkwarden/prisma/client";
|
||||||
|
import WebView from "react-native-webview";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
link: LinkType;
|
||||||
|
setIsLoading: (state: boolean) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function WebpageFormat({ link, setIsLoading }: Props) {
|
||||||
|
const FORMAT = ArchivedFormat.monolith;
|
||||||
|
|
||||||
|
const { auth } = useAuthStore();
|
||||||
|
const [content, setContent] = useState<string>("");
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
async function loadCacheOrFetch() {
|
||||||
|
const filePath =
|
||||||
|
FileSystem.documentDirectory +
|
||||||
|
`archivedData/webpage/link_${link.id}.html`;
|
||||||
|
|
||||||
|
await FileSystem.makeDirectoryAsync(
|
||||||
|
filePath.substring(0, filePath.lastIndexOf("/")),
|
||||||
|
{
|
||||||
|
intermediates: true,
|
||||||
|
}
|
||||||
|
).catch(() => {});
|
||||||
|
|
||||||
|
const [info] = await Promise.all([FileSystem.getInfoAsync(filePath)]);
|
||||||
|
|
||||||
|
if (info.exists) {
|
||||||
|
setContent(filePath);
|
||||||
|
}
|
||||||
|
|
||||||
|
const net = await NetInfo.fetch();
|
||||||
|
|
||||||
|
if (net.isConnected) {
|
||||||
|
const apiUrl = `${auth.instance}/api/v1/archives/${link.id}?format=${FORMAT}`;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await FileSystem.downloadAsync(apiUrl, filePath, {
|
||||||
|
headers: { Authorization: `Bearer ${auth.session}` },
|
||||||
|
});
|
||||||
|
|
||||||
|
setContent(result.uri);
|
||||||
|
} catch (e) {
|
||||||
|
console.error("Failed to fetch content", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
loadCacheOrFetch();
|
||||||
|
}, [link]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
content && (
|
||||||
|
<WebView
|
||||||
|
style={{
|
||||||
|
flex: 1,
|
||||||
|
}}
|
||||||
|
source={{
|
||||||
|
uri: content,
|
||||||
|
baseUrl: FileSystem.documentDirectory,
|
||||||
|
}}
|
||||||
|
scalesPageToFit
|
||||||
|
originWhitelist={["*"]}
|
||||||
|
mixedContentMode="always"
|
||||||
|
javaScriptEnabled={true}
|
||||||
|
allowFileAccess={true}
|
||||||
|
allowFileAccessFromFileURLs={true}
|
||||||
|
allowUniversalAccessFromFileURLs={true}
|
||||||
|
onLoadEnd={() => setIsLoading(false)}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,7 +1,18 @@
|
|||||||
import { View, Text, Image, Pressable, Platform, Alert } from "react-native";
|
import {
|
||||||
|
View,
|
||||||
|
Text,
|
||||||
|
Image,
|
||||||
|
Pressable,
|
||||||
|
Platform,
|
||||||
|
Alert,
|
||||||
|
ActivityIndicator,
|
||||||
|
Linking,
|
||||||
|
} from "react-native";
|
||||||
import { decode } from "html-entities";
|
import { decode } from "html-entities";
|
||||||
import { LinkIncludingShortenedCollectionAndTags } from "@linkwarden/types";
|
import { LinkIncludingShortenedCollectionAndTags } from "@linkwarden/types";
|
||||||
import { ArchivedFormat } from "@linkwarden/types";
|
import { ArchivedFormat } from "@linkwarden/types";
|
||||||
|
import getFormatBasedOnPreference from "@linkwarden/lib/getFormatBasedOnPreference";
|
||||||
|
import getOriginalFormat from "@linkwarden/lib/getOriginalFormat";
|
||||||
import {
|
import {
|
||||||
atLeastOneFormatAvailable,
|
atLeastOneFormatAvailable,
|
||||||
formatAvailable,
|
formatAvailable,
|
||||||
@@ -18,6 +29,8 @@ import { rawTheme, ThemeName } from "@/lib/colors";
|
|||||||
import { useColorScheme } from "nativewind";
|
import { useColorScheme } from "nativewind";
|
||||||
import { CalendarDays, Folder } from "lucide-react-native";
|
import { CalendarDays, Folder } from "lucide-react-native";
|
||||||
import useDataStore from "@/store/data";
|
import useDataStore from "@/store/data";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { deleteLinkCache } from "@/lib/cache";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
link: LinkIncludingShortenedCollectionAndTags;
|
link: LinkIncludingShortenedCollectionAndTags;
|
||||||
@@ -34,15 +47,17 @@ const LinkListing = ({ link, dashboard }: Props) => {
|
|||||||
|
|
||||||
const deleteLink = useDeleteLink(auth);
|
const deleteLink = useDeleteLink(auth);
|
||||||
|
|
||||||
let shortendURL;
|
const [url, setUrl] = useState("");
|
||||||
|
|
||||||
try {
|
useEffect(() => {
|
||||||
if (link.url) {
|
try {
|
||||||
shortendURL = new URL(link.url).host.toLowerCase();
|
if (link.url) {
|
||||||
|
setUrl(new URL(link.url).host.toLowerCase());
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.log(error);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
}, [link]);
|
||||||
console.log(error);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ContextMenu.Root>
|
<ContextMenu.Root>
|
||||||
@@ -55,13 +70,27 @@ const LinkListing = ({ link, dashboard }: Props) => {
|
|||||||
dashboard && "rounded-xl"
|
dashboard && "rounded-xl"
|
||||||
)}
|
)}
|
||||||
onLongPress={() => {}}
|
onLongPress={() => {}}
|
||||||
onPress={() =>
|
onPress={() => {
|
||||||
router.push(
|
if (user) {
|
||||||
data.preferredFormat
|
const format = getFormatBasedOnPreference({
|
||||||
? `/links/${link.id}?format=${data.preferredFormat}`
|
link,
|
||||||
: `/links/${link.id}`
|
preference: user.linksRouteTo,
|
||||||
)
|
});
|
||||||
}
|
|
||||||
|
data.preferredBrowser === "app"
|
||||||
|
? router.navigate(
|
||||||
|
format !== null
|
||||||
|
? `/links/${link.id}?format=${format}`
|
||||||
|
: `/links/${link.id}`
|
||||||
|
)
|
||||||
|
: Linking.openURL(
|
||||||
|
format !== null
|
||||||
|
? auth.instance +
|
||||||
|
`/preserved/${link?.id}?format=${format}`
|
||||||
|
: (link.url as string)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}}
|
||||||
android_ripple={{
|
android_ripple={{
|
||||||
color: colorScheme === "dark" ? "rgba(255,255,255,0.2)" : "#ddd",
|
color: colorScheme === "dark" ? "rgba(255,255,255,0.2)" : "#ddd",
|
||||||
borderless: false,
|
borderless: false,
|
||||||
@@ -81,12 +110,12 @@ const LinkListing = ({ link, dashboard }: Props) => {
|
|||||||
{decode(link.name || link.description || link.url)}
|
{decode(link.name || link.description || link.url)}
|
||||||
</Text>
|
</Text>
|
||||||
|
|
||||||
{shortendURL && (
|
{url && (
|
||||||
<Text
|
<Text
|
||||||
numberOfLines={1}
|
numberOfLines={1}
|
||||||
className="mt-1.5 font-light text-sm text-base-content"
|
className="mt-1.5 font-light text-sm text-base-content"
|
||||||
>
|
>
|
||||||
{shortendURL}
|
{url}
|
||||||
</Text>
|
</Text>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -117,6 +146,11 @@ const LinkListing = ({ link, dashboard }: Props) => {
|
|||||||
}}
|
}}
|
||||||
className="rounded-md h-[60px] w-[90px] object-cover scale-105"
|
className="rounded-md h-[60px] w-[90px] object-cover scale-105"
|
||||||
/>
|
/>
|
||||||
|
) : !link.preview ? (
|
||||||
|
<ActivityIndicator
|
||||||
|
size="small"
|
||||||
|
className="h-[60px] w-[90px]"
|
||||||
|
/>
|
||||||
) : (
|
) : (
|
||||||
<View className="h-[60px] w-[90px]" />
|
<View className="h-[60px] w-[90px]" />
|
||||||
)}
|
)}
|
||||||
@@ -144,21 +178,39 @@ const LinkListing = ({ link, dashboard }: Props) => {
|
|||||||
|
|
||||||
<ContextMenu.Content avoidCollisions>
|
<ContextMenu.Content avoidCollisions>
|
||||||
<ContextMenu.Item
|
<ContextMenu.Item
|
||||||
key="open-link"
|
key="open-original"
|
||||||
onSelect={() => router.push(`/links/${link.id}`)}
|
onSelect={() => {
|
||||||
>
|
if (link) {
|
||||||
<ContextMenu.ItemTitle>Open Link</ContextMenu.ItemTitle>
|
const format = getOriginalFormat(link);
|
||||||
</ContextMenu.Item>
|
|
||||||
|
|
||||||
{link.url && (
|
data.preferredBrowser === "app"
|
||||||
<ContextMenu.Item
|
? router.navigate(
|
||||||
key="copy-url"
|
format !== null
|
||||||
onSelect={async () => {
|
? `/links/${link.id}?format=${format}`
|
||||||
await Clipboard.setStringAsync(link.url as string);
|
: `/links/${link.id}`
|
||||||
}}
|
)
|
||||||
>
|
: Linking.openURL(
|
||||||
<ContextMenu.ItemTitle>Copy URL</ContextMenu.ItemTitle>
|
format !== null
|
||||||
</ContextMenu.Item>
|
? auth.instance +
|
||||||
|
`/preserved/${link?.id}?format=${format}`
|
||||||
|
: (link.url as string)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ContextMenu.ItemTitle>Open Original</ContextMenu.ItemTitle>
|
||||||
|
</ContextMenu.Item>
|
||||||
|
{link?.url && (
|
||||||
|
<>
|
||||||
|
<ContextMenu.Item
|
||||||
|
key="copy-url"
|
||||||
|
onSelect={async () => {
|
||||||
|
await Clipboard.setStringAsync(link.url as string);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ContextMenu.ItemTitle>Copy URL</ContextMenu.ItemTitle>
|
||||||
|
</ContextMenu.Item>
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<ContextMenu.Item
|
<ContextMenu.Item
|
||||||
@@ -201,7 +253,7 @@ const LinkListing = ({ link, dashboard }: Props) => {
|
|||||||
<ContextMenu.Item
|
<ContextMenu.Item
|
||||||
key="preserved-formats-webpage"
|
key="preserved-formats-webpage"
|
||||||
onSelect={() =>
|
onSelect={() =>
|
||||||
router.push(
|
router.navigate(
|
||||||
`/links/${link.id}?format=${ArchivedFormat.monolith}`
|
`/links/${link.id}?format=${ArchivedFormat.monolith}`
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -213,7 +265,7 @@ const LinkListing = ({ link, dashboard }: Props) => {
|
|||||||
<ContextMenu.Item
|
<ContextMenu.Item
|
||||||
key="preserved-formats-screenshot"
|
key="preserved-formats-screenshot"
|
||||||
onSelect={() =>
|
onSelect={() =>
|
||||||
router.push(
|
router.navigate(
|
||||||
`/links/${link.id}?format=${
|
`/links/${link.id}?format=${
|
||||||
link.image?.endsWith(".png")
|
link.image?.endsWith(".png")
|
||||||
? ArchivedFormat.png
|
? ArchivedFormat.png
|
||||||
@@ -229,7 +281,7 @@ const LinkListing = ({ link, dashboard }: Props) => {
|
|||||||
<ContextMenu.Item
|
<ContextMenu.Item
|
||||||
key="preserved-formats-pdf"
|
key="preserved-formats-pdf"
|
||||||
onSelect={() =>
|
onSelect={() =>
|
||||||
router.push(
|
router.navigate(
|
||||||
`/links/${link.id}?format=${ArchivedFormat.pdf}`
|
`/links/${link.id}?format=${ArchivedFormat.pdf}`
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -241,7 +293,7 @@ const LinkListing = ({ link, dashboard }: Props) => {
|
|||||||
<ContextMenu.Item
|
<ContextMenu.Item
|
||||||
key="preserved-formats-readable"
|
key="preserved-formats-readable"
|
||||||
onSelect={() =>
|
onSelect={() =>
|
||||||
router.push(
|
router.navigate(
|
||||||
`/links/${link.id}?format=${ArchivedFormat.readability}`
|
`/links/${link.id}?format=${ArchivedFormat.readability}`
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -268,7 +320,11 @@ const LinkListing = ({ link, dashboard }: Props) => {
|
|||||||
text: "Delete",
|
text: "Delete",
|
||||||
style: "destructive",
|
style: "destructive",
|
||||||
onPress: () => {
|
onPress: () => {
|
||||||
deleteLink.mutate(link.id as number);
|
deleteLink.mutate(link.id as number, {
|
||||||
|
onSuccess: async () => {
|
||||||
|
await deleteLinkCache(link.id as number);
|
||||||
|
},
|
||||||
|
});
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|||||||
87
apps/mobile/components/Links.tsx
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
import {
|
||||||
|
View,
|
||||||
|
FlatList,
|
||||||
|
Text,
|
||||||
|
ActivityIndicator,
|
||||||
|
ViewToken,
|
||||||
|
} from "react-native";
|
||||||
|
import LinkListing from "@/components/LinkListing";
|
||||||
|
import React, { useState } from "react";
|
||||||
|
import { LinkIncludingShortenedCollectionAndTags } from "@linkwarden/types";
|
||||||
|
import Spinner from "@/components/ui/Spinner";
|
||||||
|
import { rawTheme, ThemeName } from "@/lib/colors";
|
||||||
|
import { useColorScheme } from "nativewind";
|
||||||
|
|
||||||
|
const RenderItem = React.memo(
|
||||||
|
({ item }: { item: LinkIncludingShortenedCollectionAndTags }) => {
|
||||||
|
return <LinkListing link={item} />;
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
links: LinkIncludingShortenedCollectionAndTags[];
|
||||||
|
data: any;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function Links({ links, data }: Props) {
|
||||||
|
const { colorScheme } = useColorScheme();
|
||||||
|
const [promptedRefetch, setPromptedRefetch] = useState(false);
|
||||||
|
|
||||||
|
return data.isLoading ? (
|
||||||
|
<View className="flex justify-center h-screen items-center">
|
||||||
|
<ActivityIndicator size="large" />
|
||||||
|
<Text className="text-base mt-2.5 text-neutral">Loading...</Text>
|
||||||
|
</View>
|
||||||
|
) : (
|
||||||
|
<FlatList
|
||||||
|
contentInsetAdjustmentBehavior="automatic"
|
||||||
|
ListHeaderComponent={() => <></>}
|
||||||
|
data={links || []}
|
||||||
|
refreshControl={
|
||||||
|
<Spinner
|
||||||
|
refreshing={data.isRefetching && promptedRefetch}
|
||||||
|
onRefresh={async () => {
|
||||||
|
setPromptedRefetch(true);
|
||||||
|
await data.refetch();
|
||||||
|
setPromptedRefetch(false);
|
||||||
|
}}
|
||||||
|
progressBackgroundColor={
|
||||||
|
rawTheme[colorScheme as ThemeName]["base-200"]
|
||||||
|
}
|
||||||
|
colors={[rawTheme[colorScheme as ThemeName]["base-content"]]}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
refreshing={data.isRefetching && promptedRefetch}
|
||||||
|
initialNumToRender={4}
|
||||||
|
keyExtractor={(item) => item.id?.toString() || ""}
|
||||||
|
renderItem={({ item }) => (
|
||||||
|
<RenderItem item={item} key={item.id?.toString()} />
|
||||||
|
)}
|
||||||
|
onEndReached={() => data.fetchNextPage()}
|
||||||
|
onEndReachedThreshold={0.5}
|
||||||
|
ItemSeparatorComponent={() => (
|
||||||
|
<View className="bg-neutral-content h-px" />
|
||||||
|
)}
|
||||||
|
ListEmptyComponent={
|
||||||
|
<View className="flex justify-center py-10 items-center">
|
||||||
|
<Text className="text-center text-xl text-neutral">
|
||||||
|
Nothing found...
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
}
|
||||||
|
onViewableItemsChanged={({
|
||||||
|
viewableItems,
|
||||||
|
}: {
|
||||||
|
viewableItems: ViewToken[];
|
||||||
|
}) => {
|
||||||
|
const links = viewableItems.map(
|
||||||
|
(e) => e.item
|
||||||
|
) as LinkIncludingShortenedCollectionAndTags[];
|
||||||
|
|
||||||
|
if (!data.isRefetching && links.some((e) => e.id && !e.preview))
|
||||||
|
data.refetch();
|
||||||
|
}}
|
||||||
|
viewabilityConfig={{ itemVisiblePercentThreshold: 50 }}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,17 +0,0 @@
|
|||||||
import { PropsWithChildren } from "react";
|
|
||||||
import { IconSymbol } from "../ui/IconSymbol";
|
|
||||||
import ModalBase from "../ModalBase";
|
|
||||||
import { Text } from "react-native";
|
|
||||||
|
|
||||||
type Props = PropsWithChildren<{
|
|
||||||
isVisible: boolean;
|
|
||||||
onClose: () => void;
|
|
||||||
}>;
|
|
||||||
|
|
||||||
export default function AddLink({ isVisible, onClose }: Props) {
|
|
||||||
return (
|
|
||||||
// <ModalBase isVisible={isVisible} onClose={onClose}>
|
|
||||||
<Text>Hi</Text>
|
|
||||||
// </ModalBase>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
120
apps/mobile/components/TagListing.tsx
Normal file
@@ -0,0 +1,120 @@
|
|||||||
|
import { View, Text, Pressable, Platform, Alert } from "react-native";
|
||||||
|
import { decode } from "html-entities";
|
||||||
|
import { TagIncludingLinkCount } from "@linkwarden/types";
|
||||||
|
import useAuthStore from "@/store/auth";
|
||||||
|
import { useRouter } from "expo-router";
|
||||||
|
import * as ContextMenu from "zeego/context-menu";
|
||||||
|
import { cn } from "@linkwarden/lib/utils";
|
||||||
|
import { rawTheme, ThemeName } from "@/lib/colors";
|
||||||
|
import { useColorScheme } from "nativewind";
|
||||||
|
import { CalendarDays, Hash, Link } from "lucide-react-native";
|
||||||
|
import { useRemoveTag } from "@linkwarden/router/tags";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
tag: TagIncludingLinkCount;
|
||||||
|
};
|
||||||
|
|
||||||
|
const TagListing = ({ tag }: Props) => {
|
||||||
|
const { auth } = useAuthStore();
|
||||||
|
const router = useRouter();
|
||||||
|
const { colorScheme } = useColorScheme();
|
||||||
|
|
||||||
|
const deleteCollection = useRemoveTag(auth);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ContextMenu.Root>
|
||||||
|
<ContextMenu.Trigger asChild>
|
||||||
|
<Pressable
|
||||||
|
className={cn(
|
||||||
|
"p-5 flex-row justify-between",
|
||||||
|
"bg-base-100",
|
||||||
|
Platform.OS !== "android" && "active:bg-base-200/50"
|
||||||
|
)}
|
||||||
|
onLongPress={() => {}}
|
||||||
|
onPress={() => router.navigate(`/tags/${tag.id}`)}
|
||||||
|
android_ripple={{
|
||||||
|
color: colorScheme === "dark" ? "rgba(255,255,255,0.2)" : "#ddd",
|
||||||
|
borderless: false,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<View className="w-full">
|
||||||
|
<View className="w-[90%] flex-col justify-between gap-3">
|
||||||
|
<View className="flex flex-row gap-2 items-center pr-1.5 self-start rounded-md">
|
||||||
|
<Hash
|
||||||
|
size={16}
|
||||||
|
color={rawTheme[colorScheme as ThemeName]["primary"]}
|
||||||
|
/>
|
||||||
|
<Text
|
||||||
|
numberOfLines={2}
|
||||||
|
className="font-medium text-lg text-base-content"
|
||||||
|
>
|
||||||
|
{decode(tag.name)}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View className="flex-row gap-3">
|
||||||
|
<View className="flex flex-row gap-1 items-center mt-5 self-start">
|
||||||
|
<CalendarDays
|
||||||
|
size={16}
|
||||||
|
color={rawTheme[colorScheme as ThemeName]["neutral"]}
|
||||||
|
/>
|
||||||
|
<Text
|
||||||
|
numberOfLines={1}
|
||||||
|
className="font-light text-xs text-base-content"
|
||||||
|
>
|
||||||
|
{new Date(tag.createdAt).toLocaleString("en-US", {
|
||||||
|
year: "numeric",
|
||||||
|
month: "short",
|
||||||
|
day: "numeric",
|
||||||
|
})}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<View className="flex flex-row gap-1 items-center mt-5 self-start">
|
||||||
|
<Link
|
||||||
|
size={16}
|
||||||
|
color={rawTheme[colorScheme as ThemeName]["neutral"]}
|
||||||
|
/>
|
||||||
|
<Text
|
||||||
|
numberOfLines={1}
|
||||||
|
className="font-light text-xs text-base-content"
|
||||||
|
>
|
||||||
|
{tag._count?.links}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</Pressable>
|
||||||
|
</ContextMenu.Trigger>
|
||||||
|
|
||||||
|
<ContextMenu.Content avoidCollisions>
|
||||||
|
<ContextMenu.Item
|
||||||
|
key="delete-tag"
|
||||||
|
onSelect={() => {
|
||||||
|
return Alert.alert(
|
||||||
|
"Delete Tag",
|
||||||
|
"Are you sure you want to delete this Tag? This action cannot be undone.",
|
||||||
|
[
|
||||||
|
{
|
||||||
|
text: "Cancel",
|
||||||
|
style: "cancel",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
text: "Delete",
|
||||||
|
style: "destructive",
|
||||||
|
onPress: () => {
|
||||||
|
deleteCollection.mutate(tag.id as number);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ContextMenu.ItemTitle>Delete</ContextMenu.ItemTitle>
|
||||||
|
</ContextMenu.Item>
|
||||||
|
</ContextMenu.Content>
|
||||||
|
</ContextMenu.Root>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default TagListing;
|
||||||
21
apps/mobile/components/ui/Icons.tsx
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
import React from "react";
|
||||||
|
import Svg, { Path, Circle, SvgProps } from "react-native-svg";
|
||||||
|
|
||||||
|
export const Chromium = (props: SvgProps) => (
|
||||||
|
<Svg
|
||||||
|
width={21}
|
||||||
|
height={21}
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
strokeWidth={2}
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<Path d="M10.88 21.94 15.46 14" />
|
||||||
|
<Path d="M21.17 8H12" />
|
||||||
|
<Path d="M3.95 6.06 8.54 14" />
|
||||||
|
<Circle cx={12} cy={12} r={10} />
|
||||||
|
<Circle cx={12} cy={12} r={4} />
|
||||||
|
</Svg>
|
||||||
|
);
|
||||||
10
apps/mobile/components/ui/Spinner.tsx
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import React, { forwardRef } from "react";
|
||||||
|
import { RefreshControl, RefreshControlProps } from "react-native";
|
||||||
|
|
||||||
|
const Spinner = forwardRef<RefreshControl, RefreshControlProps>(
|
||||||
|
(props, ref) => {
|
||||||
|
return <RefreshControl ref={ref} {...props} />;
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
export default Spinner;
|
||||||
41
apps/mobile/eas.json
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
{
|
||||||
|
"cli": {
|
||||||
|
"version": ">= 16.20.1",
|
||||||
|
"appVersionSource": "remote"
|
||||||
|
},
|
||||||
|
"build": {
|
||||||
|
"preview": {
|
||||||
|
"android": {
|
||||||
|
"buildType": "apk"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"development": {
|
||||||
|
"developmentClient": true,
|
||||||
|
"distribution": "internal"
|
||||||
|
},
|
||||||
|
"simulator": {
|
||||||
|
"extends": "development",
|
||||||
|
"ios": {
|
||||||
|
"simulator": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"production": {
|
||||||
|
"corepack": true,
|
||||||
|
"distribution": "store",
|
||||||
|
"autoIncrement": true,
|
||||||
|
"channel": "production"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"submit": {
|
||||||
|
"production": {
|
||||||
|
"ios": {
|
||||||
|
"ascAppId": "6752550960"
|
||||||
|
},
|
||||||
|
"android": {
|
||||||
|
"serviceAccountKeyPath": "./service-account-file.json",
|
||||||
|
"track": "internal",
|
||||||
|
"releaseStatus": "draft"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
33
apps/mobile/lib/cache.ts
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
import * as FileSystem from "expo-file-system";
|
||||||
|
|
||||||
|
export const clearCache = async () => {
|
||||||
|
await Promise.all([
|
||||||
|
FileSystem.deleteAsync(FileSystem.documentDirectory + "archivedData", {
|
||||||
|
idempotent: true,
|
||||||
|
}),
|
||||||
|
FileSystem.deleteAsync(FileSystem.documentDirectory + "mmkv", {
|
||||||
|
idempotent: true,
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const deleteLinkCache = async (linkId: number) => {
|
||||||
|
const readablePath =
|
||||||
|
FileSystem.documentDirectory + `archivedData/readable/link_${linkId}.html`;
|
||||||
|
const webpagePath =
|
||||||
|
FileSystem.documentDirectory + `archivedData/webpage/link_${linkId}.html`;
|
||||||
|
const jpegPath =
|
||||||
|
FileSystem.documentDirectory + `archivedData/jpeg/link_${linkId}.jpeg`;
|
||||||
|
const pngPath =
|
||||||
|
FileSystem.documentDirectory + `archivedData/png/link_${linkId}.png`;
|
||||||
|
const pdfPath =
|
||||||
|
FileSystem.documentDirectory + `archivedData/pdf/link_${linkId}.pdf`;
|
||||||
|
|
||||||
|
await Promise.all([
|
||||||
|
FileSystem.deleteAsync(readablePath, { idempotent: true }),
|
||||||
|
FileSystem.deleteAsync(webpagePath, { idempotent: true }),
|
||||||
|
FileSystem.deleteAsync(jpegPath, { idempotent: true }),
|
||||||
|
FileSystem.deleteAsync(pngPath, { idempotent: true }),
|
||||||
|
FileSystem.deleteAsync(pdfPath, { idempotent: true }),
|
||||||
|
]);
|
||||||
|
};
|
||||||
14
apps/mobile/lib/queryClient.ts
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
import { QueryClient } from "@tanstack/react-query";
|
||||||
|
|
||||||
|
const queryClient = new QueryClient({
|
||||||
|
defaultOptions: {
|
||||||
|
queries: {
|
||||||
|
staleTime: 1000 * 60 * 60 * 24,
|
||||||
|
refetchOnMount: true,
|
||||||
|
refetchOnWindowFocus: false,
|
||||||
|
refetchOnReconnect: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export { queryClient };
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "@linkwarden/mobile",
|
"name": "@linkwarden/mobile",
|
||||||
"main": "expo-router/entry",
|
"main": "expo-router/entry",
|
||||||
"version": "1.0.0",
|
"version": "0.0.0",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"start": "expo start",
|
"start": "expo start",
|
||||||
"android": "expo run:android",
|
"android": "expo run:android",
|
||||||
@@ -16,6 +16,7 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@expo/vector-icons": "^14.0.2",
|
"@expo/vector-icons": "^14.0.2",
|
||||||
"@linkwarden/lib": "*",
|
"@linkwarden/lib": "*",
|
||||||
|
"@linkwarden/prisma": "*",
|
||||||
"@linkwarden/react-native-render-html": "^6.3.4",
|
"@linkwarden/react-native-render-html": "^6.3.4",
|
||||||
"@linkwarden/router": "*",
|
"@linkwarden/router": "*",
|
||||||
"@linkwarden/types": "*",
|
"@linkwarden/types": "*",
|
||||||
@@ -30,6 +31,7 @@
|
|||||||
"expo": "~52.0.18",
|
"expo": "~52.0.18",
|
||||||
"expo-application": "~6.0.2",
|
"expo-application": "~6.0.2",
|
||||||
"expo-blur": "~14.0.1",
|
"expo-blur": "~14.0.1",
|
||||||
|
"expo-build-properties": "~0.13.3",
|
||||||
"expo-clipboard": "~7.0.1",
|
"expo-clipboard": "~7.0.1",
|
||||||
"expo-constants": "~17.0.3",
|
"expo-constants": "~17.0.3",
|
||||||
"expo-dev-client": "~5.0.6",
|
"expo-dev-client": "~5.0.6",
|
||||||
@@ -44,6 +46,7 @@
|
|||||||
"expo-status-bar": "~2.0.0",
|
"expo-status-bar": "~2.0.0",
|
||||||
"expo-symbols": "~0.2.0",
|
"expo-symbols": "~0.2.0",
|
||||||
"expo-system-ui": "~4.0.6",
|
"expo-system-ui": "~4.0.6",
|
||||||
|
"expo-updates": "~0.27.4",
|
||||||
"expo-web-browser": "~14.0.1",
|
"expo-web-browser": "~14.0.1",
|
||||||
"html-entities": "^2.6.0",
|
"html-entities": "^2.6.0",
|
||||||
"lucide-react-native": "^0.536.0",
|
"lucide-react-native": "^0.536.0",
|
||||||
@@ -52,10 +55,14 @@
|
|||||||
"react-dom": "18.3.1",
|
"react-dom": "18.3.1",
|
||||||
"react-native": "0.76.9",
|
"react-native": "0.76.9",
|
||||||
"react-native-actions-sheet": "^0.9.7",
|
"react-native-actions-sheet": "^0.9.7",
|
||||||
|
"react-native-blob-util": "^0.23.2",
|
||||||
|
"react-native-edge-to-edge": "^1.7.0",
|
||||||
"react-native-gesture-handler": "~2.20.2",
|
"react-native-gesture-handler": "~2.20.2",
|
||||||
"react-native-ios-context-menu": "3.1.0",
|
"react-native-ios-context-menu": "3.1.3",
|
||||||
"react-native-ios-utilities": "5.1.2",
|
"react-native-ios-utilities": "5.1.7",
|
||||||
|
"react-native-keyboard-controller": "^1.19.0",
|
||||||
"react-native-mmkv": "^3.2.0",
|
"react-native-mmkv": "^3.2.0",
|
||||||
|
"react-native-pdf": "^7.0.3",
|
||||||
"react-native-reanimated": "3.16.2",
|
"react-native-reanimated": "3.16.2",
|
||||||
"react-native-safe-area-context": "4.12.0",
|
"react-native-safe-area-context": "4.12.0",
|
||||||
"react-native-screens": "~4.1.0",
|
"react-native-screens": "~4.1.0",
|
||||||
@@ -69,7 +76,7 @@
|
|||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@babel/core": "^7.25.2",
|
"@babel/core": "^7.25.2",
|
||||||
"@types/jest": "^29.5.12",
|
"@types/jest": "^29.5.12",
|
||||||
"@types/react": "~18.3.12",
|
"@types/react": "18.3.1",
|
||||||
"@types/react-test-renderer": "^18.3.0",
|
"@types/react-test-renderer": "^18.3.0",
|
||||||
"jest": "^29.2.1",
|
"jest": "^29.2.1",
|
||||||
"jest-expo": "~52.0.2",
|
"jest-expo": "~52.0.2",
|
||||||
|
|||||||
@@ -2,19 +2,28 @@ import { create } from "zustand";
|
|||||||
import * as SecureStore from "expo-secure-store";
|
import * as SecureStore from "expo-secure-store";
|
||||||
import { router } from "expo-router";
|
import { router } from "expo-router";
|
||||||
import { MobileAuth } from "@linkwarden/types";
|
import { MobileAuth } from "@linkwarden/types";
|
||||||
|
import { Alert } from "react-native";
|
||||||
|
import { queryClient } from "@/lib/queryClient";
|
||||||
|
import { mmkvPersister } from "@/lib/queryPersister";
|
||||||
|
import { clearCache } from "@/lib/cache";
|
||||||
|
|
||||||
type AuthStore = {
|
type AuthStore = {
|
||||||
auth: MobileAuth;
|
auth: MobileAuth;
|
||||||
signIn: (username: string, password: string, instance?: string) => void;
|
signIn: (
|
||||||
signOut: () => void;
|
username: string,
|
||||||
setAuth: () => void;
|
password: string,
|
||||||
|
instance: string,
|
||||||
|
token?: string
|
||||||
|
) => Promise<void>;
|
||||||
|
signOut: () => Promise<void>;
|
||||||
|
setAuth: () => Promise<void>;
|
||||||
};
|
};
|
||||||
|
|
||||||
const useAuthStore = create<AuthStore>((set) => ({
|
const useAuthStore = create<AuthStore>((set) => ({
|
||||||
auth: {
|
auth: {
|
||||||
instance: "",
|
instance: "",
|
||||||
session: null,
|
session: null,
|
||||||
status: "loading",
|
status: "loading" as const,
|
||||||
},
|
},
|
||||||
setAuth: async () => {
|
setAuth: async () => {
|
||||||
const session = await SecureStore.getItemAsync("TOKEN");
|
const session = await SecureStore.getItemAsync("TOKEN");
|
||||||
@@ -31,57 +40,89 @@ const useAuthStore = create<AuthStore>((set) => ({
|
|||||||
} else {
|
} else {
|
||||||
set({
|
set({
|
||||||
auth: {
|
auth: {
|
||||||
instance: "",
|
instance: instance || "https://cloud.linkwarden.app",
|
||||||
session: null,
|
session: null,
|
||||||
status: "unauthenticated",
|
status: "unauthenticated",
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
signIn: async (
|
signIn: async (username, password, instance, token) => {
|
||||||
username,
|
|
||||||
password,
|
|
||||||
instance = process.env.NODE_ENV === "production"
|
|
||||||
? "https://cloud.linkwarden.app"
|
|
||||||
: (process.env.EXPO_PUBLIC_LINKWARDEN_URL as string)
|
|
||||||
) => {
|
|
||||||
if (process.env.EXPO_PUBLIC_SHOW_LOGS === "true")
|
if (process.env.EXPO_PUBLIC_SHOW_LOGS === "true")
|
||||||
console.log("Signing into", instance);
|
console.log("Signing into", instance);
|
||||||
|
|
||||||
await fetch(instance + "/api/v1/session", {
|
if (token) {
|
||||||
method: "POST",
|
// make a request to the API to validate the token
|
||||||
body: JSON.stringify({ username, password }),
|
await fetch(instance + "/api/v1/users/me", {
|
||||||
headers: {
|
method: "GET",
|
||||||
"Content-Type": "application/json",
|
headers: {
|
||||||
},
|
Authorization: `Bearer ${token}`,
|
||||||
}).then(async (res) => {
|
},
|
||||||
if (res.ok) {
|
}).then(async (res) => {
|
||||||
const data = await res.json();
|
if (res.ok) {
|
||||||
const session = (data as any).response.token;
|
await SecureStore.setItemAsync("INSTANCE", instance);
|
||||||
await SecureStore.setItemAsync("TOKEN", session);
|
await SecureStore.setItemAsync("TOKEN", token);
|
||||||
await SecureStore.setItemAsync("INSTANCE", instance);
|
set({
|
||||||
set({
|
auth: {
|
||||||
auth: {
|
session: token,
|
||||||
session,
|
instance,
|
||||||
instance,
|
status: "authenticated",
|
||||||
status: "authenticated",
|
},
|
||||||
},
|
});
|
||||||
});
|
router.replace("/(tabs)/dashboard");
|
||||||
|
} else {
|
||||||
|
Alert.alert("Error", "Invalid token");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
try {
|
||||||
|
const res = await Promise.race([
|
||||||
|
fetch(`${instance}/api/v1/session`, {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({ username, password }),
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
}),
|
||||||
|
new Promise<Response>((_, reject) =>
|
||||||
|
setTimeout(() => reject(new Error("TIMEOUT")), 30000)
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
|
||||||
router.replace("/(tabs)/dashboard");
|
if (res.ok) {
|
||||||
} else {
|
const data = await res.json();
|
||||||
set({
|
const session = (data as any).response.token;
|
||||||
auth: {
|
|
||||||
instance,
|
await SecureStore.setItemAsync("TOKEN", session);
|
||||||
session: null,
|
await SecureStore.setItemAsync("INSTANCE", instance);
|
||||||
status: "unauthenticated",
|
set({ auth: { session, instance, status: "authenticated" } });
|
||||||
},
|
router.replace("/(tabs)/dashboard");
|
||||||
});
|
} else {
|
||||||
|
Alert.alert("Error", "Invalid credentials");
|
||||||
|
}
|
||||||
|
} catch (err: any) {
|
||||||
|
if (err?.message === "TIMEOUT") {
|
||||||
|
Alert.alert(
|
||||||
|
"Request timed out",
|
||||||
|
"Unable to reach the server in time. Please check your network configuration and try again."
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
Alert.alert(
|
||||||
|
"Network error",
|
||||||
|
"Could not connect to the server. Please check your network configuration and try again."
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
}
|
||||||
},
|
},
|
||||||
signOut: async () => {
|
signOut: async () => {
|
||||||
await SecureStore.deleteItemAsync("TOKEN");
|
await SecureStore.deleteItemAsync("TOKEN");
|
||||||
|
await SecureStore.deleteItemAsync("INSTANCE");
|
||||||
|
|
||||||
|
queryClient.cancelQueries();
|
||||||
|
queryClient.clear();
|
||||||
|
mmkvPersister.removeClient?.();
|
||||||
|
|
||||||
|
await clearCache();
|
||||||
|
|
||||||
set({
|
set({
|
||||||
auth: {
|
auth: {
|
||||||
instance: "",
|
instance: "",
|
||||||
@@ -90,7 +131,7 @@ const useAuthStore = create<AuthStore>((set) => ({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
router.replace("/login");
|
router.replace("/");
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
|||||||
@@ -15,13 +15,13 @@ const useDataStore = create<DataStore>((set, get) => ({
|
|||||||
hasShareIntent: false,
|
hasShareIntent: false,
|
||||||
url: "",
|
url: "",
|
||||||
},
|
},
|
||||||
theme: "light",
|
theme: "system",
|
||||||
preferredFormat: null,
|
preferredBrowser: "app",
|
||||||
},
|
},
|
||||||
setData: async () => {
|
setData: async () => {
|
||||||
const dataString = JSON.parse((await AsyncStorage.getItem("data")) || "{}");
|
const dataString = JSON.parse((await AsyncStorage.getItem("data")) || "{}");
|
||||||
|
|
||||||
colorScheme.set(dataString.theme || "light");
|
colorScheme.set(dataString.theme || "system");
|
||||||
|
|
||||||
if (dataString)
|
if (dataString)
|
||||||
set((state) => ({ data: { ...state.data, ...dataString } }));
|
set((state) => ({ data: { ...state.data, ...dataString } }));
|
||||||
|
|||||||
26
apps/mobile/store/tmp.ts
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
import { create } from "zustand";
|
||||||
|
import { LinkIncludingShortenedCollectionAndTags } from "@linkwarden/types";
|
||||||
|
import { User } from "@linkwarden/prisma/client";
|
||||||
|
|
||||||
|
type Tmp = {
|
||||||
|
link: LinkIncludingShortenedCollectionAndTags | null;
|
||||||
|
user: Pick<User, "id"> | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
type TmpStore = {
|
||||||
|
tmp: Tmp;
|
||||||
|
updateTmp: (newData: Partial<Tmp>) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
const useTmpStore = create<TmpStore>((set, get) => ({
|
||||||
|
tmp: {
|
||||||
|
link: null,
|
||||||
|
user: null,
|
||||||
|
},
|
||||||
|
updateTmp: async (patch) => {
|
||||||
|
const merged = { ...get().tmp, ...patch };
|
||||||
|
set({ tmp: merged });
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
export default useTmpStore;
|
||||||
@@ -35,9 +35,9 @@ export type LinkIncludingShortenedCollectionAndTags = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export enum ArchivedFormat {
|
export enum ArchivedFormat {
|
||||||
png,
|
png = 0,
|
||||||
jpeg,
|
jpeg = 1,
|
||||||
pdf,
|
pdf = 2,
|
||||||
readability,
|
readability = 3,
|
||||||
monolith,
|
monolith = 4,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,24 +9,39 @@ type Props = {
|
|||||||
|
|
||||||
export default function Announcement({ toggleAnnouncementBar }: Props) {
|
export default function Announcement({ toggleAnnouncementBar }: Props) {
|
||||||
const announcementId = localStorage.getItem("announcementId");
|
const announcementId = localStorage.getItem("announcementId");
|
||||||
|
const announcementMessage = localStorage.getItem("announcementMessage");
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="fixed mx-auto bottom-20 sm:bottom-10 w-full pointer-events-none p-5 z-30">
|
<div className="fixed mx-auto bottom-20 sm:bottom-10 w-full pointer-events-none p-5 z-30">
|
||||||
<div className="mx-auto pointer-events-auto p-2 flex justify-between gap-2 items-center border border-primary shadow-xl rounded-xl bg-base-300 backdrop-blur-sm bg-opacity-80 max-w-md">
|
<div className="mx-auto pointer-events-auto p-2 flex justify-between gap-2 items-center border border-primary shadow-xl rounded-xl bg-base-300 backdrop-blur-sm bg-opacity-80 max-w-md">
|
||||||
<i className="bi-stars text-xl text-yellow-600 dark:text-yellow-500"></i>
|
<i className="bi-stars text-xl text-yellow-600 dark:text-yellow-500"></i>
|
||||||
<p className="w-4/5 text-center text-sm sm:text-base">
|
<p className="w-4/5 text-center text-sm sm:text-base">
|
||||||
<Trans
|
{announcementId ? (
|
||||||
i18nKey="new_version_announcement"
|
<Trans
|
||||||
values={{ version: announcementId }}
|
i18nKey="new_version_announcement"
|
||||||
components={[
|
values={{ version: announcementId }}
|
||||||
<Link
|
components={[
|
||||||
href={`https://blog.linkwarden.app/releases/${announcementId}`}
|
<Link
|
||||||
target="_blank"
|
href={`https://linkwarden.app/blog/releases/${announcementId}`}
|
||||||
className="underline decoration-dotted underline-offset-4 hover:text-primary duration-100"
|
target="_blank"
|
||||||
key={0}
|
className="underline decoration-dotted underline-offset-4 hover:text-primary duration-100"
|
||||||
/>,
|
key={0}
|
||||||
]}
|
/>,
|
||||||
/>
|
]}
|
||||||
|
/>
|
||||||
|
) : announcementMessage ? (
|
||||||
|
<Trans
|
||||||
|
i18nKey={announcementMessage}
|
||||||
|
components={[
|
||||||
|
<Link
|
||||||
|
href={`https://linkwarden.app/blog`}
|
||||||
|
target="_blank"
|
||||||
|
className="underline decoration-dotted underline-offset-4 hover:text-primary duration-100"
|
||||||
|
key={0}
|
||||||
|
/>,
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
) : undefined}
|
||||||
</p>
|
</p>
|
||||||
<Button variant="ghost" size="icon" onClick={toggleAnnouncementBar}>
|
<Button variant="ghost" size="icon" onClick={toggleAnnouncementBar}>
|
||||||
<i className="bi-x text-xl"></i>
|
<i className="bi-x text-xl"></i>
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import useLocalSettingsStore from "@/store/localSettings";
|
|
||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import React, { ReactNode } from "react";
|
import React, { ReactNode } from "react";
|
||||||
@@ -30,7 +29,7 @@ export default function CenteredForm({
|
|||||||
width={640}
|
width={640}
|
||||||
height={136}
|
height={136}
|
||||||
alt="Linkwarden"
|
alt="Linkwarden"
|
||||||
className="h-12 w-fit mx-auto"
|
className="h-12 w-auto mx-auto"
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<Image
|
<Image
|
||||||
@@ -38,7 +37,7 @@ export default function CenteredForm({
|
|||||||
width={640}
|
width={640}
|
||||||
height={136}
|
height={136}
|
||||||
alt="Linkwarden"
|
alt="Linkwarden"
|
||||||
className="h-12 w-fit mx-auto"
|
className="h-12 w-auto mx-auto"
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{text && (
|
{text && (
|
||||||
@@ -52,7 +51,11 @@ export default function CenteredForm({
|
|||||||
values={{ date: new Date().getFullYear() }}
|
values={{ date: new Date().getFullYear() }}
|
||||||
i18nKey="all_rights_reserved"
|
i18nKey="all_rights_reserved"
|
||||||
components={[
|
components={[
|
||||||
<Link href="https://linkwarden.app" className="font-semibold" />,
|
<Link
|
||||||
|
href="https://linkwarden.app"
|
||||||
|
className="font-semibold"
|
||||||
|
key="linkwarden-website-key"
|
||||||
|
/>,
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
</p>
|
</p>
|
||||||
@@ -79,7 +79,7 @@ export default function CollectionCard({
|
|||||||
size="icon"
|
size="icon"
|
||||||
className="absolute top-3 right-3 z-20"
|
className="absolute top-3 right-3 z-20"
|
||||||
>
|
>
|
||||||
<i title="More" className="bi-three-dots text-xl" />
|
<i title="More" className="bi-three-dots text-xl text-neutral" />
|
||||||
</Button>
|
</Button>
|
||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
|
|
||||||
|
|||||||
@@ -36,11 +36,10 @@ const CollectionListing = () => {
|
|||||||
const updateCollection = useUpdateCollection();
|
const updateCollection = useUpdateCollection();
|
||||||
const { data: collections = [], isLoading } = useCollections();
|
const { data: collections = [], isLoading } = useCollections();
|
||||||
|
|
||||||
const { data: user, refetch } = useUser();
|
const { data: user } = useUser();
|
||||||
const updateUser = useUpdateUser();
|
const updateUser = useUpdateUser();
|
||||||
|
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const currentPath = router.asPath;
|
|
||||||
|
|
||||||
const [tree, setTree] = useState<TreeData | undefined>();
|
const [tree, setTree] = useState<TreeData | undefined>();
|
||||||
|
|
||||||
@@ -53,7 +52,7 @@ const CollectionListing = () => {
|
|||||||
user?.collectionOrder
|
user?.collectionOrder
|
||||||
);
|
);
|
||||||
} else return undefined;
|
} else return undefined;
|
||||||
}, [collections, user, router]);
|
}, [collections, user]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setTree(initialTree);
|
setTree(initialTree);
|
||||||
@@ -281,7 +280,7 @@ const CollectionListing = () => {
|
|||||||
<Tree
|
<Tree
|
||||||
tree={tree}
|
tree={tree}
|
||||||
renderItem={(itemProps) =>
|
renderItem={(itemProps) =>
|
||||||
renderItem({ ...itemProps }, currentPath, droppableActive)
|
renderItem({ ...itemProps }, router.asPath, droppableActive)
|
||||||
}
|
}
|
||||||
onExpand={onExpand}
|
onExpand={onExpand}
|
||||||
onCollapse={onCollapse}
|
onCollapse={onCollapse}
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ import {
|
|||||||
} from "@dnd-kit/core";
|
} from "@dnd-kit/core";
|
||||||
import { restrictToParentElement } from "@dnd-kit/modifiers";
|
import { restrictToParentElement } from "@dnd-kit/modifiers";
|
||||||
import { cn } from "@linkwarden/lib";
|
import { cn } from "@linkwarden/lib";
|
||||||
|
import toast from "react-hot-toast";
|
||||||
|
|
||||||
interface DashboardSectionOption {
|
interface DashboardSectionOption {
|
||||||
type: DashboardSectionType;
|
type: DashboardSectionType;
|
||||||
@@ -185,7 +186,13 @@ export default function DashboardLayoutDropdown() {
|
|||||||
return s;
|
return s;
|
||||||
});
|
});
|
||||||
|
|
||||||
updateDashboardLayout.mutateAsync(updatedSections);
|
updateDashboardLayout.mutateAsync(updatedSections, {
|
||||||
|
onSettled: (data, error) => {
|
||||||
|
if (error) {
|
||||||
|
toast.error(error.message);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleReorder = (sourceId: string, destId: string) => {
|
const handleReorder = (sourceId: string, destId: string) => {
|
||||||
@@ -217,7 +224,13 @@ export default function DashboardLayoutDropdown() {
|
|||||||
const disabledSections = filteredSections.filter((s) => !s.enabled);
|
const disabledSections = filteredSections.filter((s) => !s.enabled);
|
||||||
const updated = [...reorderedWithNewOrders, ...disabledSections];
|
const updated = [...reorderedWithNewOrders, ...disabledSections];
|
||||||
|
|
||||||
updateDashboardLayout.mutateAsync(updated);
|
updateDashboardLayout.mutateAsync(updated, {
|
||||||
|
onSettled: (data, error) => {
|
||||||
|
if (error) {
|
||||||
|
toast.error(error.message);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDragEnd = (event: DragEndEvent) => {
|
const handleDragEnd = (event: DragEndEvent) => {
|
||||||
@@ -268,7 +281,7 @@ export default function DashboardLayoutDropdown() {
|
|||||||
<TextInput
|
<TextInput
|
||||||
value={searchTerm}
|
value={searchTerm}
|
||||||
onChange={(e) => setSearchTerm(e.target.value)}
|
onChange={(e) => setSearchTerm(e.target.value)}
|
||||||
className="py-0"
|
className="py-0 bg-base-100"
|
||||||
placeholder={t("search")}
|
placeholder={t("search")}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ import LinkPin from "./LinkViews/LinkComponents/LinkPin";
|
|||||||
import { Separator } from "./ui/separator";
|
import { Separator } from "./ui/separator";
|
||||||
import { useDraggable } from "@dnd-kit/core";
|
import { useDraggable } from "@dnd-kit/core";
|
||||||
import { cn } from "@linkwarden/lib";
|
import { cn } from "@linkwarden/lib";
|
||||||
|
import { useTranslation } from "next-i18next";
|
||||||
|
|
||||||
export function DashboardLinks({
|
export function DashboardLinks({
|
||||||
links,
|
links,
|
||||||
@@ -39,7 +40,7 @@ export function DashboardLinks({
|
|||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={`flex gap-5 overflow-x-auto overflow-y-hidden hide-scrollbar w-full min-h-72`}
|
className={`flex gap-5 overflow-x-auto overflow-y-hidden hide-scrollbar w-full min-h-fit`}
|
||||||
>
|
>
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<div className="flex flex-col gap-4 min-w-60 w-60">
|
<div className="flex flex-col gap-4 min-w-60 w-60">
|
||||||
@@ -63,10 +64,13 @@ type Props = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export function Card({ link, editMode, dashboardType }: Props) {
|
export function Card({ link, editMode, dashboardType }: Props) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
const { attributes, listeners, setNodeRef, isDragging } = useDraggable({
|
const { attributes, listeners, setNodeRef, isDragging } = useDraggable({
|
||||||
id: `${link.id}-${dashboardType}`,
|
id: `${link.id}-${dashboardType}`,
|
||||||
data: {
|
data: {
|
||||||
linkId: link.id,
|
linkId: link.id,
|
||||||
|
link,
|
||||||
dashboardType,
|
dashboardType,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -85,16 +89,6 @@ export function Card({ link, editMode, dashboardType }: Props) {
|
|||||||
|
|
||||||
const { refetch } = useGetLink({ id: link.id as number, isPublicRoute });
|
const { refetch } = useGetLink({ id: link.id as number, isPublicRoute });
|
||||||
|
|
||||||
let shortendURL;
|
|
||||||
|
|
||||||
try {
|
|
||||||
if (link.url) {
|
|
||||||
shortendURL = new URL(link.url).host.toLowerCase();
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.log(error);
|
|
||||||
}
|
|
||||||
|
|
||||||
const [collection, setCollection] =
|
const [collection, setCollection] =
|
||||||
useState<CollectionIncludingMembersAndLinkCount>(
|
useState<CollectionIncludingMembersAndLinkCount>(
|
||||||
collections.find(
|
collections.find(
|
||||||
@@ -173,6 +167,7 @@ export function Card({ link, editMode, dashboardType }: Props) {
|
|||||||
const target = e.target as HTMLElement;
|
const target = e.target as HTMLElement;
|
||||||
target.style.display = "none";
|
target.style.display = "none";
|
||||||
}}
|
}}
|
||||||
|
unoptimized
|
||||||
/>
|
/>
|
||||||
) : link.preview === "unavailable" ? (
|
) : link.preview === "unavailable" ? (
|
||||||
<div className={`bg-gray-50 h-40 bg-opacity-80`}></div>
|
<div className={`bg-gray-50 h-40 bg-opacity-80`}></div>
|
||||||
@@ -198,7 +193,7 @@ export function Card({ link, editMode, dashboardType }: Props) {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="flex flex-col justify-between h-full min-h-24">
|
<div className="flex flex-col justify-between h-full min-h-11">
|
||||||
<div className="p-3 flex flex-col justify-between h-full gap-2">
|
<div className="p-3 flex flex-col justify-between h-full gap-2">
|
||||||
{show.name && (
|
{show.name && (
|
||||||
<p className="line-clamp-2 w-full text-primary text-sm">
|
<p className="line-clamp-2 w-full text-primary text-sm">
|
||||||
@@ -216,7 +211,11 @@ export function Card({ link, editMode, dashboardType }: Props) {
|
|||||||
<div className="flex justify-between items-center text-xs text-neutral px-3 pb-1 gap-2">
|
<div className="flex justify-between items-center text-xs text-neutral px-3 pb-1 gap-2">
|
||||||
{show.collection && !isPublicRoute && (
|
{show.collection && !isPublicRoute && (
|
||||||
<div className="cursor-pointer truncate">
|
<div className="cursor-pointer truncate">
|
||||||
<LinkCollection link={link} collection={collection} />
|
<LinkCollection
|
||||||
|
link={link}
|
||||||
|
collection={collection}
|
||||||
|
isPublicRoute={false}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{show.date && <LinkDate link={link} />}
|
{show.date && <LinkDate link={link} />}
|
||||||
@@ -230,7 +229,7 @@ export function Card({ link, editMode, dashboardType }: Props) {
|
|||||||
<div className="absolute pointer-events-none top-0 left-0 right-0 bottom-0 bg-base-100 bg-opacity-0 group-hover:bg-opacity-20 group-focus-within:opacity-20 rounded-xl duration-100"></div>
|
<div className="absolute pointer-events-none top-0 left-0 right-0 bottom-0 bg-base-100 bg-opacity-0 group-hover:bg-opacity-20 group-focus-within:opacity-20 rounded-xl duration-100"></div>
|
||||||
<LinkActions
|
<LinkActions
|
||||||
link={link}
|
link={link}
|
||||||
collection={collection}
|
t={t}
|
||||||
linkModal={linkModal}
|
linkModal={linkModal}
|
||||||
setLinkModal={(e) => setLinkModal(e)}
|
setLinkModal={(e) => setLinkModal(e)}
|
||||||
className="absolute top-3 right-3 group-hover:opacity-100 group-focus-within:opacity-100 opacity-0 duration-100 text-neutral z-20"
|
className="absolute top-3 right-3 group-hover:opacity-100 group-focus-within:opacity-100 opacity-0 duration-100 text-neutral z-20"
|
||||||
|
|||||||
@@ -15,9 +15,11 @@ import { LinkIncludingShortenedCollectionAndTags } from "@linkwarden/types";
|
|||||||
import toast from "react-hot-toast";
|
import toast from "react-hot-toast";
|
||||||
import { useUpdateLink } from "@linkwarden/router/links";
|
import { useUpdateLink } from "@linkwarden/router/links";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { restrictToWindowEdges, snapCenterToCursor } from "@dnd-kit/modifiers";
|
import { snapCenterToCursor } from "@dnd-kit/modifiers";
|
||||||
import { customCollisionDetectionAlgorithm } from "@/lib/utils";
|
import { customCollisionDetectionAlgorithm } from "@/lib/utils";
|
||||||
import { useUpdateTag } from "@linkwarden/router/tags";
|
import usePinLink from "@/lib/client/pinLink";
|
||||||
|
import { useQueryClient } from "@tanstack/react-query";
|
||||||
|
import { useUser } from "@linkwarden/router/user";
|
||||||
|
|
||||||
interface DragNDropProps {
|
interface DragNDropProps {
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
@@ -28,7 +30,6 @@ interface DragNDropProps {
|
|||||||
/**
|
/**
|
||||||
* All links available for drag and drop
|
* All links available for drag and drop
|
||||||
*/
|
*/
|
||||||
links: LinkIncludingShortenedCollectionAndTags[];
|
|
||||||
setActiveLink: (link: LinkIncludingShortenedCollectionAndTags | null) => void;
|
setActiveLink: (link: LinkIncludingShortenedCollectionAndTags | null) => void;
|
||||||
/**
|
/**
|
||||||
* Override the default sensors used for drag and drop.
|
* Override the default sensors used for drag and drop.
|
||||||
@@ -47,14 +48,15 @@ interface DragNDropProps {
|
|||||||
export default function DragNDrop({
|
export default function DragNDrop({
|
||||||
children,
|
children,
|
||||||
activeLink,
|
activeLink,
|
||||||
links,
|
|
||||||
setActiveLink,
|
setActiveLink,
|
||||||
sensors: sensorProp,
|
sensors: sensorProp,
|
||||||
onDragEnd: onDragEndProp,
|
onDragEnd: onDragEndProp,
|
||||||
}: DragNDropProps) {
|
}: DragNDropProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const updateTag = useUpdateTag();
|
|
||||||
const updateLink = useUpdateLink();
|
const updateLink = useUpdateLink();
|
||||||
|
const pinLink = usePinLink();
|
||||||
|
const { data: user } = useUser();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
const mouseSensor = useSensor(MouseSensor, {
|
const mouseSensor = useSensor(MouseSensor, {
|
||||||
// Require the mouse to move by 10 pixels before activating
|
// Require the mouse to move by 10 pixels before activating
|
||||||
activationConstraint: {
|
activationConstraint: {
|
||||||
@@ -72,10 +74,10 @@ export default function DragNDrop({
|
|||||||
const sensors = useSensors(mouseSensor, touchSensor);
|
const sensors = useSensors(mouseSensor, touchSensor);
|
||||||
|
|
||||||
const handleDragStart = (event: DragStartEvent) => {
|
const handleDragStart = (event: DragStartEvent) => {
|
||||||
const draggedLink = links.find(
|
setActiveLink(
|
||||||
(link: any) => link.id === event.active.data.current?.linkId
|
(event.active.data.current
|
||||||
|
?.link as LinkIncludingShortenedCollectionAndTags) ?? null
|
||||||
);
|
);
|
||||||
setActiveLink(draggedLink || null);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDragOverCancel = () => {
|
const handleDragOverCancel = () => {
|
||||||
@@ -83,70 +85,169 @@ export default function DragNDrop({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleDragEnd = async (event: DragEndEvent) => {
|
const handleDragEnd = async (event: DragEndEvent) => {
|
||||||
// If an onDragEnd prop is provided, use it instead of the default behavior
|
|
||||||
if (onDragEndProp) {
|
if (onDragEndProp) {
|
||||||
onDragEndProp(event);
|
onDragEndProp(event);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const { over } = event;
|
|
||||||
|
const { over, active } = event;
|
||||||
if (!over || !activeLink) return;
|
if (!over || !activeLink) return;
|
||||||
|
|
||||||
let updatedLink: LinkIncludingShortenedCollectionAndTags | null = null;
|
const overData = over.data.current;
|
||||||
|
const targetId = String(over.id);
|
||||||
|
|
||||||
// if the link is dropped over a tag
|
const isFromRecentSection = active.data.current?.dashboardType === "recent";
|
||||||
if (over.data.current?.type === "tag") {
|
|
||||||
const isTagAlreadyExists = activeLink.tags.some(
|
setActiveLink(null);
|
||||||
(tag) => tag.name === over.data.current?.name
|
|
||||||
|
const mutateWithToast = async (
|
||||||
|
updatedLink: LinkIncludingShortenedCollectionAndTags,
|
||||||
|
opts?: { invalidateDashboardOnError?: boolean }
|
||||||
|
) => {
|
||||||
|
const load = toast.loading(t("updating"));
|
||||||
|
await updateLink.mutateAsync(updatedLink, {
|
||||||
|
onSettled: async (_, error) => {
|
||||||
|
toast.dismiss(load);
|
||||||
|
if (error) {
|
||||||
|
if (
|
||||||
|
opts?.invalidateDashboardOnError &&
|
||||||
|
typeof queryClient !== "undefined"
|
||||||
|
) {
|
||||||
|
await queryClient.invalidateQueries({
|
||||||
|
queryKey: ["dashboardData"],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
toast.error(error.message);
|
||||||
|
} else {
|
||||||
|
toast.success(t("updated"));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// DROP ON TAG
|
||||||
|
if (overData?.type === "tag") {
|
||||||
|
const tagName = overData?.name as string | undefined;
|
||||||
|
if (!tagName) return;
|
||||||
|
|
||||||
|
const isTagAlreadyExists = activeLink.tags?.some(
|
||||||
|
(tag) => tag.name === tagName
|
||||||
);
|
);
|
||||||
if (isTagAlreadyExists) {
|
if (isTagAlreadyExists) {
|
||||||
toast.error(t("tag_already_added"));
|
toast.error(t("tag_already_added"));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// to match the tags structure required to update the link
|
|
||||||
const allTags: { name: string }[] = activeLink.tags.map((tag) => ({
|
const allTags: { name: string }[] = (activeLink.tags ?? []).map(
|
||||||
name: tag.name,
|
(tag) => ({
|
||||||
}));
|
name: tag.name,
|
||||||
const newTags = [...allTags, { name: over.data.current?.name as string }];
|
})
|
||||||
updatedLink = {
|
);
|
||||||
|
|
||||||
|
const updatedLink: LinkIncludingShortenedCollectionAndTags = {
|
||||||
...activeLink,
|
...activeLink,
|
||||||
tags: newTags as any,
|
tags: [...allTags, { name: tagName }] as any,
|
||||||
};
|
};
|
||||||
} else {
|
|
||||||
const collectionId = over.data.current?.id as number;
|
|
||||||
const collectionName = over.data.current?.name as string;
|
|
||||||
const ownerId = over.data.current?.ownerId as number;
|
|
||||||
|
|
||||||
// Immediately hide the drag overlay
|
await mutateWithToast(updatedLink, {
|
||||||
setActiveLink(null);
|
invalidateDashboardOnError: typeof queryClient !== "undefined",
|
||||||
|
});
|
||||||
// if the link dropped over the same collection, toast
|
return;
|
||||||
if (activeLink.collection.id === collectionId) {
|
|
||||||
toast.error(t("link_already_in_collection"));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
updatedLink = {
|
|
||||||
...activeLink,
|
|
||||||
collection: {
|
|
||||||
id: collectionId,
|
|
||||||
name: collectionName,
|
|
||||||
ownerId,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const load = toast.loading(t("updating"));
|
// DROP ON DASHBOARD "PINNED" SECTION
|
||||||
await updateLink.mutateAsync(updatedLink, {
|
const isPinnedSection = targetId === "pinned-links-section";
|
||||||
onSettled: (_, error) => {
|
|
||||||
toast.dismiss(load);
|
const canPin =
|
||||||
if (error) {
|
typeof pinLink === "function" &&
|
||||||
toast.error(error.message);
|
typeof user !== "undefined" &&
|
||||||
} else {
|
typeof user?.id !== "undefined";
|
||||||
toast.success(t("updated"));
|
|
||||||
|
if (isPinnedSection && canPin) {
|
||||||
|
if (Array.isArray(activeLink.pinnedBy) && !activeLink.pinnedBy.length) {
|
||||||
|
if (typeof queryClient !== "undefined") {
|
||||||
|
const optimisticallyPinned = {
|
||||||
|
...activeLink,
|
||||||
|
pinnedBy: [user!.id],
|
||||||
|
};
|
||||||
|
|
||||||
|
queryClient.setQueryData(["dashboardData"], (oldData: any) => {
|
||||||
|
if (!oldData?.links) return oldData;
|
||||||
|
return {
|
||||||
|
...oldData,
|
||||||
|
links: oldData.links.map((l: any) =>
|
||||||
|
l.id === optimisticallyPinned.id ? optimisticallyPinned : l
|
||||||
|
),
|
||||||
|
};
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pinLink(activeLink);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// DROP ON COLLECTION (dashboard + sidebar)
|
||||||
|
const collectionId = overData?.id as number | undefined;
|
||||||
|
const collectionName = overData?.name as string | undefined;
|
||||||
|
const ownerId = overData?.ownerId as number | undefined;
|
||||||
|
|
||||||
|
if (!collectionId || !collectionName || typeof ownerId === "undefined")
|
||||||
|
return;
|
||||||
|
|
||||||
|
const isSameCollection = activeLink.collection?.id === collectionId;
|
||||||
|
if (isSameCollection) {
|
||||||
|
if (isFromRecentSection) toast.error(t("link_already_in_collection"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const updatedLink: LinkIncludingShortenedCollectionAndTags = {
|
||||||
|
...activeLink,
|
||||||
|
collection: {
|
||||||
|
id: collectionId,
|
||||||
|
name: collectionName,
|
||||||
|
ownerId,
|
||||||
},
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
if (typeof queryClient !== "undefined") {
|
||||||
|
queryClient.setQueryData(["dashboardData"], (oldData: any) => {
|
||||||
|
if (!oldData?.links) return oldData;
|
||||||
|
return {
|
||||||
|
...oldData,
|
||||||
|
links: oldData.links.map((l: any) =>
|
||||||
|
l.id === updatedLink.id ? updatedLink : l
|
||||||
|
),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
queryClient.setQueryData(["dashboardData"], (oldData: any) => {
|
||||||
|
if (!oldData?.collectionLinks) return oldData;
|
||||||
|
|
||||||
|
const oldCollectionId = activeLink.collection?.id;
|
||||||
|
if (!oldCollectionId) return oldData;
|
||||||
|
|
||||||
|
return {
|
||||||
|
...oldData,
|
||||||
|
collectionLinks: {
|
||||||
|
...oldData.collectionLinks,
|
||||||
|
[oldCollectionId]: (
|
||||||
|
oldData.collectionLinks[oldCollectionId] || []
|
||||||
|
).filter((l: any) => l.id !== updatedLink.id),
|
||||||
|
[collectionId]: [
|
||||||
|
...(oldData.collectionLinks[collectionId] || []),
|
||||||
|
updatedLink,
|
||||||
|
],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
await mutateWithToast(updatedLink, {
|
||||||
|
invalidateDashboardOnError: typeof queryClient !== "undefined",
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DndContext
|
<DndContext
|
||||||
onDragStart={handleDragStart}
|
onDragStart={handleDragStart}
|
||||||
|
|||||||
@@ -187,6 +187,7 @@ export default function LinkDetails({
|
|||||||
const target = e.target as HTMLElement;
|
const target = e.target as HTMLElement;
|
||||||
target.style.display = "none";
|
target.style.display = "none";
|
||||||
}}
|
}}
|
||||||
|
unoptimized
|
||||||
/>
|
/>
|
||||||
) : link.preview === "unavailable" ? (
|
) : link.preview === "unavailable" ? (
|
||||||
<div className="bg-gray-50 duration-100 h-40"></div>
|
<div className="bg-gray-50 duration-100 h-40"></div>
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import ViewDropdown from "./ViewDropdown";
|
|||||||
import { TFunction } from "i18next";
|
import { TFunction } from "i18next";
|
||||||
import BulkDeleteLinksModal from "./ModalContent/BulkDeleteLinksModal";
|
import BulkDeleteLinksModal from "./ModalContent/BulkDeleteLinksModal";
|
||||||
import BulkEditLinksModal from "./ModalContent/BulkEditLinksModal";
|
import BulkEditLinksModal from "./ModalContent/BulkEditLinksModal";
|
||||||
import useCollectivePermissions from "@/hooks/useCollectivePermissions";
|
|
||||||
import { useRouter } from "next/router";
|
import { useRouter } from "next/router";
|
||||||
import useLinkStore from "@/store/links";
|
import useLinkStore from "@/store/links";
|
||||||
import {
|
import {
|
||||||
@@ -46,7 +45,8 @@ const LinkListOptions = ({
|
|||||||
setEditMode,
|
setEditMode,
|
||||||
links,
|
links,
|
||||||
}: Props) => {
|
}: Props) => {
|
||||||
const { selectedLinks, setSelectedLinks } = useLinkStore();
|
const { selectedIds, setSelected, clearSelected, selectionCount } =
|
||||||
|
useLinkStore();
|
||||||
|
|
||||||
const deleteLinksById = useBulkDeleteLinks();
|
const deleteLinksById = useBulkDeleteLinks();
|
||||||
const refreshPreservations = useArchiveAction();
|
const refreshPreservations = useArchiveAction();
|
||||||
@@ -62,45 +62,42 @@ const LinkListOptions = ({
|
|||||||
if (editMode && setEditMode) return setEditMode(false);
|
if (editMode && setEditMode) return setEditMode(false);
|
||||||
}, [router]);
|
}, [router]);
|
||||||
|
|
||||||
const collectivePermissions = useCollectivePermissions(
|
|
||||||
selectedLinks.map((link) => link.collectionId as number)
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleSelectAll = () => {
|
const handleSelectAll = () => {
|
||||||
if (selectedLinks.length === links.length) {
|
if (selectionCount === links.length) {
|
||||||
setSelectedLinks([]);
|
clearSelected();
|
||||||
} else {
|
} else {
|
||||||
setSelectedLinks(links.map((link) => link));
|
setSelected(links.map((link) => link.id as number));
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const bulkDeleteLinks = async () => {
|
const bulkDeleteLinks = async () => {
|
||||||
const load = toast.loading(t("deleting"));
|
const load = toast.loading(t("deleting"));
|
||||||
|
|
||||||
await deleteLinksById.mutateAsync(
|
const ids = Object.keys(selectedIds).map(Number);
|
||||||
selectedLinks.map((link) => link.id as number),
|
|
||||||
{
|
|
||||||
onSettled: (data, error) => {
|
|
||||||
toast.dismiss(load);
|
|
||||||
|
|
||||||
if (error) {
|
await deleteLinksById.mutateAsync(ids, {
|
||||||
toast.error(error.message);
|
onSettled: (data, error) => {
|
||||||
} else {
|
toast.dismiss(load);
|
||||||
setSelectedLinks([]);
|
|
||||||
setEditMode?.(false);
|
if (error) {
|
||||||
toast.success(t("deleted"));
|
toast.error(error.message);
|
||||||
}
|
} else {
|
||||||
},
|
clearSelected();
|
||||||
}
|
setEditMode?.(false);
|
||||||
);
|
toast.success(t("deleted"));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const bulkRefreshPreservations = async () => {
|
const bulkRefreshPreservations = async () => {
|
||||||
const load = toast.loading(t("sending_request"));
|
const load = toast.loading(t("sending_request"));
|
||||||
|
|
||||||
|
const ids = Object.keys(selectedIds).map(Number);
|
||||||
|
|
||||||
await refreshPreservations.mutateAsync(
|
await refreshPreservations.mutateAsync(
|
||||||
{
|
{
|
||||||
linkIds: selectedLinks.map((link) => link.id as number),
|
linkIds: ids,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
onSettled: (data, error) => {
|
onSettled: (data, error) => {
|
||||||
@@ -108,7 +105,7 @@ const LinkListOptions = ({
|
|||||||
if (error) {
|
if (error) {
|
||||||
toast.error(error.message);
|
toast.error(error.message);
|
||||||
} else {
|
} else {
|
||||||
setSelectedLinks([]);
|
clearSelected();
|
||||||
setEditMode?.(false);
|
setEditMode?.(false);
|
||||||
toast.success(t("links_being_archived"));
|
toast.success(t("links_being_archived"));
|
||||||
}
|
}
|
||||||
@@ -133,7 +130,7 @@ const LinkListOptions = ({
|
|||||||
size="icon"
|
size="icon"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setEditMode(!editMode);
|
setEditMode(!editMode);
|
||||||
setSelectedLinks([]);
|
clearSelected();
|
||||||
}}
|
}}
|
||||||
className={
|
className={
|
||||||
editMode ? "bg-primary/20 hover:bg-primary/20" : ""
|
editMode ? "bg-primary/20 hover:bg-primary/20" : ""
|
||||||
@@ -161,15 +158,15 @@ const LinkListOptions = ({
|
|||||||
type="checkbox"
|
type="checkbox"
|
||||||
className="checkbox checkbox-primary"
|
className="checkbox checkbox-primary"
|
||||||
onChange={() => handleSelectAll()}
|
onChange={() => handleSelectAll()}
|
||||||
checked={
|
checked={selectionCount === links.length && links.length > 0}
|
||||||
selectedLinks.length === links.length && links.length > 0
|
|
||||||
}
|
|
||||||
/>
|
/>
|
||||||
{selectedLinks.length > 0 ? (
|
{selectionCount > 0 ? (
|
||||||
<span>
|
<span>
|
||||||
{selectedLinks.length === 1
|
{selectionCount === 1
|
||||||
? t("link_selected")
|
? t("link_selected")
|
||||||
: t("links_selected", { count: selectedLinks.length })}
|
: t("links_selected", {
|
||||||
|
count: selectionCount,
|
||||||
|
})}
|
||||||
</span>
|
</span>
|
||||||
) : (
|
) : (
|
||||||
<span>{t("nothing_selected")}</span>
|
<span>{t("nothing_selected")}</span>
|
||||||
@@ -183,7 +180,7 @@ const LinkListOptions = ({
|
|||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
onClick={() => setBulkRefreshPreservationsModal(true)}
|
onClick={() => setBulkRefreshPreservationsModal(true)}
|
||||||
disabled={selectedLinks.length === 0}
|
disabled={selectionCount === 0}
|
||||||
>
|
>
|
||||||
<i className="bi-arrow-clockwise" />
|
<i className="bi-arrow-clockwise" />
|
||||||
</Button>
|
</Button>
|
||||||
@@ -201,13 +198,7 @@ const LinkListOptions = ({
|
|||||||
onClick={() => setBulkEditLinksModal(true)}
|
onClick={() => setBulkEditLinksModal(true)}
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
disabled={
|
disabled={selectionCount === 0}
|
||||||
selectedLinks.length === 0 ||
|
|
||||||
!(
|
|
||||||
collectivePermissions === true ||
|
|
||||||
collectivePermissions?.canUpdate
|
|
||||||
)
|
|
||||||
}
|
|
||||||
>
|
>
|
||||||
<i className="bi-pencil-square" />
|
<i className="bi-pencil-square" />
|
||||||
</Button>
|
</Button>
|
||||||
@@ -229,13 +220,7 @@ const LinkListOptions = ({
|
|||||||
}}
|
}}
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
disabled={
|
disabled={selectionCount === 0}
|
||||||
selectedLinks.length === 0 ||
|
|
||||||
!(
|
|
||||||
collectivePermissions === true ||
|
|
||||||
collectivePermissions?.canDelete
|
|
||||||
)
|
|
||||||
}
|
|
||||||
>
|
>
|
||||||
<i className="bi-trash text-error" />
|
<i className="bi-trash text-error" />
|
||||||
</Button>
|
</Button>
|
||||||
@@ -278,10 +263,10 @@ const LinkListOptions = ({
|
|||||||
title={t("refresh_preserved_formats")}
|
title={t("refresh_preserved_formats")}
|
||||||
>
|
>
|
||||||
<p className="mb-5">
|
<p className="mb-5">
|
||||||
{selectedLinks.length === 1
|
{selectionCount === 1
|
||||||
? t("refresh_preserved_formats_confirmation_desc")
|
? t("refresh_preserved_formats_confirmation_desc")
|
||||||
: t("refresh_multiple_preserved_formats_confirmation_desc", {
|
: t("refresh_multiple_preserved_formats_confirmation_desc", {
|
||||||
count: selectedLinks.length,
|
count: selectionCount,
|
||||||
})}
|
})}
|
||||||
</p>
|
</p>
|
||||||
</ConfirmationModal>
|
</ConfirmationModal>
|
||||||
|
|||||||
@@ -1,11 +1,7 @@
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import {
|
import { LinkIncludingShortenedCollectionAndTags } from "@linkwarden/types";
|
||||||
CollectionIncludingMembersAndLinkCount,
|
|
||||||
LinkIncludingShortenedCollectionAndTags,
|
|
||||||
} from "@linkwarden/types";
|
|
||||||
import usePermissions from "@/hooks/usePermissions";
|
import usePermissions from "@/hooks/usePermissions";
|
||||||
import DeleteLinkModal from "@/components/ModalContent/DeleteLinkModal";
|
import DeleteLinkModal from "@/components/ModalContent/DeleteLinkModal";
|
||||||
import { useTranslation } from "next-i18next";
|
|
||||||
import { useDeleteLink, useGetLink } from "@linkwarden/router/links";
|
import { useDeleteLink, useGetLink } from "@linkwarden/router/links";
|
||||||
import toast from "react-hot-toast";
|
import toast from "react-hot-toast";
|
||||||
import LinkModal from "@/components/ModalContent/LinkModal";
|
import LinkModal from "@/components/ModalContent/LinkModal";
|
||||||
@@ -21,25 +17,25 @@ import {
|
|||||||
} from "@/components/ui/dropdown-menu";
|
} from "@/components/ui/dropdown-menu";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import ConfirmationModal from "@/components/ConfirmationModal";
|
import ConfirmationModal from "@/components/ConfirmationModal";
|
||||||
|
import { TFunction } from "i18next";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
link: LinkIncludingShortenedCollectionAndTags;
|
link: LinkIncludingShortenedCollectionAndTags;
|
||||||
collection: CollectionIncludingMembersAndLinkCount;
|
|
||||||
linkModal: boolean;
|
linkModal: boolean;
|
||||||
className?: string;
|
|
||||||
setLinkModal: (value: boolean) => void;
|
setLinkModal: (value: boolean) => void;
|
||||||
|
t: TFunction<"translation", undefined>;
|
||||||
|
className?: string;
|
||||||
ghost?: boolean;
|
ghost?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function LinkActions({
|
export default function LinkActions({
|
||||||
link,
|
link,
|
||||||
linkModal,
|
linkModal,
|
||||||
className,
|
t,
|
||||||
setLinkModal,
|
setLinkModal,
|
||||||
|
className,
|
||||||
ghost,
|
ghost,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
const { t } = useTranslation();
|
|
||||||
|
|
||||||
const permissions = usePermissions(link.collection.id as number);
|
const permissions = usePermissions(link.collection.id as number);
|
||||||
|
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|||||||
@@ -3,8 +3,7 @@ import {
|
|||||||
CollectionIncludingMembersAndLinkCount,
|
CollectionIncludingMembersAndLinkCount,
|
||||||
LinkIncludingShortenedCollectionAndTags,
|
LinkIncludingShortenedCollectionAndTags,
|
||||||
} from "@linkwarden/types";
|
} from "@linkwarden/types";
|
||||||
import { useEffect, useMemo, useRef, useState } from "react";
|
import React, { useRef, useState } from "react";
|
||||||
import useLinkStore from "@/store/links";
|
|
||||||
import unescapeString from "@/lib/client/unescapeString";
|
import unescapeString from "@/lib/client/unescapeString";
|
||||||
import LinkActions from "@/components/LinkViews/LinkComponents/LinkActions";
|
import LinkActions from "@/components/LinkViews/LinkComponents/LinkActions";
|
||||||
import LinkDate from "@/components/LinkViews/LinkComponents/LinkDate";
|
import LinkDate from "@/components/LinkViews/LinkComponents/LinkDate";
|
||||||
@@ -15,15 +14,8 @@ import {
|
|||||||
formatAvailable,
|
formatAvailable,
|
||||||
} from "@linkwarden/lib/formatStats";
|
} from "@linkwarden/lib/formatStats";
|
||||||
import LinkIcon from "./LinkIcon";
|
import LinkIcon from "./LinkIcon";
|
||||||
import useOnScreen from "@/hooks/useOnScreen";
|
|
||||||
import usePermissions from "@/hooks/usePermissions";
|
|
||||||
import toast from "react-hot-toast";
|
import toast from "react-hot-toast";
|
||||||
import LinkTypeBadge from "./LinkTypeBadge";
|
import LinkTypeBadge from "./LinkTypeBadge";
|
||||||
import { useTranslation } from "next-i18next";
|
|
||||||
import { useCollections } from "@linkwarden/router/collections";
|
|
||||||
import { useUser } from "@linkwarden/router/user";
|
|
||||||
import { useGetLink, useLinks } from "@linkwarden/router/links";
|
|
||||||
import { useRouter } from "next/router";
|
|
||||||
import useLocalSettingsStore from "@/store/localSettings";
|
import useLocalSettingsStore from "@/store/localSettings";
|
||||||
import LinkPin from "./LinkPin";
|
import LinkPin from "./LinkPin";
|
||||||
import LinkFormats from "./LinkFormats";
|
import LinkFormats from "./LinkFormats";
|
||||||
@@ -31,156 +23,68 @@ import openLink from "@/lib/client/openLink";
|
|||||||
import { Separator } from "@/components/ui/separator";
|
import { Separator } from "@/components/ui/separator";
|
||||||
import { useDraggable } from "@dnd-kit/core";
|
import { useDraggable } from "@dnd-kit/core";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import useMediaQuery from "@/hooks/useMediaQuery";
|
import { TFunction } from "i18next";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
link: LinkIncludingShortenedCollectionAndTags;
|
link: LinkIncludingShortenedCollectionAndTags;
|
||||||
columns: number;
|
collection: CollectionIncludingMembersAndLinkCount;
|
||||||
className?: string;
|
isPublicRoute: boolean;
|
||||||
|
t: TFunction<"translation", undefined>;
|
||||||
|
user: any;
|
||||||
|
disableDraggable: boolean;
|
||||||
|
isSelected: boolean;
|
||||||
|
toggleSelected: (id: number) => void;
|
||||||
|
imageHeightClass: string;
|
||||||
editMode?: boolean;
|
editMode?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function LinkCard({ link, columns, editMode }: Props) {
|
function LinkCard({
|
||||||
const { t } = useTranslation();
|
link,
|
||||||
|
collection,
|
||||||
// we don't want to use the draggable feature for screen under 1023px since the sidebar is hidden
|
isPublicRoute,
|
||||||
const isSmallScreen = useMediaQuery("(max-width: 1023px)");
|
t,
|
||||||
|
user,
|
||||||
|
disableDraggable,
|
||||||
|
isSelected,
|
||||||
|
toggleSelected,
|
||||||
|
imageHeightClass,
|
||||||
|
editMode,
|
||||||
|
}: Props) {
|
||||||
const { attributes, listeners, setNodeRef, isDragging } = useDraggable({
|
const { attributes, listeners, setNodeRef, isDragging } = useDraggable({
|
||||||
id: link.id?.toString() ?? "",
|
id: link.id?.toString() ?? "",
|
||||||
data: {
|
data: {
|
||||||
linkId: link.id,
|
linkId: link.id,
|
||||||
|
link,
|
||||||
},
|
},
|
||||||
disabled: isSmallScreen,
|
disabled: disableDraggable,
|
||||||
});
|
});
|
||||||
|
|
||||||
const heightMap = {
|
|
||||||
1: "h-44",
|
|
||||||
2: "h-40",
|
|
||||||
3: "h-36",
|
|
||||||
4: "h-32",
|
|
||||||
5: "h-28",
|
|
||||||
6: "h-24",
|
|
||||||
7: "h-20",
|
|
||||||
8: "h-20",
|
|
||||||
};
|
|
||||||
|
|
||||||
const imageHeightClass = useMemo(
|
|
||||||
() => (columns ? heightMap[columns as keyof typeof heightMap] : "h-40"),
|
|
||||||
[columns]
|
|
||||||
);
|
|
||||||
|
|
||||||
const { data: collections = [] } = useCollections();
|
|
||||||
|
|
||||||
const { data: user } = useUser();
|
|
||||||
|
|
||||||
const { setSelectedLinks, selectedLinks } = useLinkStore();
|
|
||||||
|
|
||||||
const {
|
const {
|
||||||
settings: { show },
|
settings: { show },
|
||||||
} = useLocalSettingsStore();
|
} = useLocalSettingsStore();
|
||||||
|
|
||||||
const { links } = useLinks();
|
|
||||||
|
|
||||||
const router = useRouter();
|
|
||||||
const isPublicRoute = router.pathname.startsWith("/public") ? true : false;
|
|
||||||
|
|
||||||
const { refetch } = useGetLink({ id: link.id as number, isPublicRoute });
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!editMode) {
|
|
||||||
setSelectedLinks([]);
|
|
||||||
}
|
|
||||||
}, [editMode]);
|
|
||||||
|
|
||||||
const handleCheckboxClick = (
|
|
||||||
link: LinkIncludingShortenedCollectionAndTags
|
|
||||||
) => {
|
|
||||||
if (selectedLinks.includes(link)) {
|
|
||||||
setSelectedLinks(selectedLinks.filter((e) => e !== link));
|
|
||||||
} else {
|
|
||||||
setSelectedLinks([...selectedLinks, link]);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
let shortendURL;
|
|
||||||
|
|
||||||
try {
|
|
||||||
if (link.url) {
|
|
||||||
shortendURL = new URL(link.url).host.toLowerCase();
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.log(error);
|
|
||||||
}
|
|
||||||
|
|
||||||
const [collection, setCollection] =
|
|
||||||
useState<CollectionIncludingMembersAndLinkCount>(
|
|
||||||
collections.find(
|
|
||||||
(e) => e.id === link.collection.id
|
|
||||||
) as CollectionIncludingMembersAndLinkCount
|
|
||||||
);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
setCollection(
|
|
||||||
collections.find(
|
|
||||||
(e) => e.id === link.collection.id
|
|
||||||
) as CollectionIncludingMembersAndLinkCount
|
|
||||||
);
|
|
||||||
}, [collections, links]);
|
|
||||||
|
|
||||||
const ref = useRef<HTMLDivElement>(null);
|
const ref = useRef<HTMLDivElement>(null);
|
||||||
const isVisible = useOnScreen(ref);
|
|
||||||
const permissions = usePermissions(collection?.id as number);
|
|
||||||
|
|
||||||
const [linkModal, setLinkModal] = useState(false);
|
const [linkModal, setLinkModal] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
let interval: NodeJS.Timeout | null = null;
|
|
||||||
|
|
||||||
if (
|
|
||||||
isVisible &&
|
|
||||||
!link.preview?.startsWith("archives") &&
|
|
||||||
link.preview !== "unavailable"
|
|
||||||
) {
|
|
||||||
interval = setInterval(async () => {
|
|
||||||
refetch().catch((error) => {
|
|
||||||
console.error("Error refetching link:", error);
|
|
||||||
});
|
|
||||||
}, 5000);
|
|
||||||
}
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
if (interval) {
|
|
||||||
clearInterval(interval);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}, [isVisible, link.preview]);
|
|
||||||
|
|
||||||
const isLinkSelected = selectedLinks.some(
|
|
||||||
(selectedLink) => selectedLink.id === link.id
|
|
||||||
);
|
|
||||||
|
|
||||||
const selectable =
|
|
||||||
editMode &&
|
|
||||||
(permissions === true || permissions?.canCreate || permissions?.canDelete);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
ref={setNodeRef}
|
ref={setNodeRef}
|
||||||
className={cn(
|
className={cn(
|
||||||
"border border-solid border-neutral-content bg-base-200 shadow-md hover:shadow-none duration-100 rounded-xl relative group",
|
"border border-solid border-neutral-content bg-base-200 shadow-md hover:shadow-none duration-100 rounded-xl relative group",
|
||||||
isLinkSelected && "border-primary bg-base-300",
|
isSelected && "border-primary bg-base-300",
|
||||||
isDragging ? "opacity-30" : "opacity-100",
|
isDragging ? "opacity-30" : "opacity-100",
|
||||||
"relative group touch-manipulation select-none"
|
"relative group touch-manipulation select-none"
|
||||||
)}
|
)}
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
selectable
|
editMode
|
||||||
? handleCheckboxClick(link)
|
? toggleSelected(link.id as number)
|
||||||
: editMode
|
: editMode
|
||||||
? toast.error(t("link_selection_error"))
|
? toast.error(t("link_selection_error"))
|
||||||
: undefined
|
: undefined
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<div ref={ref}>
|
<div ref={ref} className="h-full">
|
||||||
<div
|
<div
|
||||||
className="rounded-xl cursor-pointer h-full flex flex-col justify-between"
|
className="rounded-xl cursor-pointer h-full flex flex-col justify-between"
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
@@ -207,6 +111,7 @@ export default function LinkCard({ link, columns, editMode }: Props) {
|
|||||||
const target = e.target as HTMLElement;
|
const target = e.target as HTMLElement;
|
||||||
target.style.display = "none";
|
target.style.display = "none";
|
||||||
}}
|
}}
|
||||||
|
unoptimized
|
||||||
/>
|
/>
|
||||||
) : link.preview === "unavailable" ? (
|
) : link.preview === "unavailable" ? (
|
||||||
<div
|
<div
|
||||||
@@ -250,9 +155,13 @@ export default function LinkCard({ link, columns, editMode }: Props) {
|
|||||||
<Separator className="mb-1" />
|
<Separator className="mb-1" />
|
||||||
|
|
||||||
<div className="flex justify-between items-center text-xs text-neutral px-3 pb-1 gap-2">
|
<div className="flex justify-between items-center text-xs text-neutral px-3 pb-1 gap-2">
|
||||||
{show.collection && !isPublicRoute && (
|
{show.collection && !isPublicRoute && collection && (
|
||||||
<div className="cursor-pointer truncate">
|
<div className="cursor-pointer truncate">
|
||||||
<LinkCollection link={link} collection={collection} />
|
<LinkCollection
|
||||||
|
link={link}
|
||||||
|
collection={collection}
|
||||||
|
isPublicRoute={isPublicRoute}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{show.date && <LinkDate link={link} />}
|
{show.date && <LinkDate link={link} />}
|
||||||
@@ -266,8 +175,8 @@ export default function LinkCard({ link, columns, editMode }: Props) {
|
|||||||
<div className="absolute pointer-events-none top-0 left-0 right-0 bottom-0 bg-base-100 bg-opacity-0 group-hover:bg-opacity-20 group-focus-within:opacity-20 rounded-xl duration-100"></div>
|
<div className="absolute pointer-events-none top-0 left-0 right-0 bottom-0 bg-base-100 bg-opacity-0 group-hover:bg-opacity-20 group-focus-within:opacity-20 rounded-xl duration-100"></div>
|
||||||
<LinkActions
|
<LinkActions
|
||||||
link={link}
|
link={link}
|
||||||
collection={collection}
|
|
||||||
linkModal={linkModal}
|
linkModal={linkModal}
|
||||||
|
t={t}
|
||||||
setLinkModal={(e) => setLinkModal(e)}
|
setLinkModal={(e) => setLinkModal(e)}
|
||||||
className="absolute top-3 right-3 group-hover:opacity-100 group-focus-within:opacity-100 opacity-0 duration-100 text-neutral z-20"
|
className="absolute top-3 right-3 group-hover:opacity-100 group-focus-within:opacity-100 opacity-0 duration-100 text-neutral z-20"
|
||||||
/>
|
/>
|
||||||
@@ -276,3 +185,5 @@ export default function LinkCard({ link, columns, editMode }: Props) {
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export default React.memo(LinkCard);
|
||||||
|
|||||||
@@ -5,20 +5,17 @@ import {
|
|||||||
} from "@linkwarden/types";
|
} from "@linkwarden/types";
|
||||||
import { IconWeight } from "@phosphor-icons/react";
|
import { IconWeight } from "@phosphor-icons/react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useRouter } from "next/router";
|
|
||||||
import React from "react";
|
import React from "react";
|
||||||
|
|
||||||
export default function LinkCollection({
|
function LinkCollection({
|
||||||
link,
|
link,
|
||||||
collection,
|
collection,
|
||||||
|
isPublicRoute,
|
||||||
}: {
|
}: {
|
||||||
link: LinkIncludingShortenedCollectionAndTags;
|
link: LinkIncludingShortenedCollectionAndTags;
|
||||||
collection: CollectionIncludingMembersAndLinkCount;
|
collection: CollectionIncludingMembersAndLinkCount;
|
||||||
|
isPublicRoute: boolean;
|
||||||
}) {
|
}) {
|
||||||
const router = useRouter();
|
|
||||||
|
|
||||||
const isPublicRoute = router.pathname.startsWith("/public") ? true : false;
|
|
||||||
|
|
||||||
return !isPublicRoute && collection?.name ? (
|
return !isPublicRoute && collection?.name ? (
|
||||||
<>
|
<>
|
||||||
<Link
|
<Link
|
||||||
@@ -47,3 +44,5 @@ export default function LinkCollection({
|
|||||||
</>
|
</>
|
||||||
) : null;
|
) : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export default React.memo(LinkCollection);
|
||||||
|
|||||||
@@ -1,11 +1,7 @@
|
|||||||
import { LinkIncludingShortenedCollectionAndTags } from "@linkwarden/types";
|
import { LinkIncludingShortenedCollectionAndTags } from "@linkwarden/types";
|
||||||
import React from "react";
|
import React from "react";
|
||||||
|
|
||||||
export default function LinkDate({
|
function LinkDate({ link }: { link: LinkIncludingShortenedCollectionAndTags }) {
|
||||||
link,
|
|
||||||
}: {
|
|
||||||
link: LinkIncludingShortenedCollectionAndTags;
|
|
||||||
}) {
|
|
||||||
const formattedDate = new Date(
|
const formattedDate = new Date(
|
||||||
(link.importDate || link.createdAt) as string
|
(link.importDate || link.createdAt) as string
|
||||||
).toLocaleString("en-US", {
|
).toLocaleString("en-US", {
|
||||||
@@ -21,3 +17,5 @@ export default function LinkDate({
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export default React.memo(LinkDate);
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import { IconWeight } from "@phosphor-icons/react";
|
|||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
import oklchVariableToHex from "@/lib/client/oklchVariableToHex";
|
import oklchVariableToHex from "@/lib/client/oklchVariableToHex";
|
||||||
|
|
||||||
export default function LinkIcon({
|
function LinkIcon({
|
||||||
link,
|
link,
|
||||||
className,
|
className,
|
||||||
hideBackground,
|
hideBackground,
|
||||||
@@ -45,17 +45,17 @@ export default function LinkIcon({
|
|||||||
) : link.type === "url" && url ? (
|
) : link.type === "url" && url ? (
|
||||||
<>
|
<>
|
||||||
<Image
|
<Image
|
||||||
src={`https://t2.gstatic.com/faviconV2?client=SOCIAL&type=FAVICON&fallback_opts=TYPE,SIZE,URL&url=${link.url}&size=64`}
|
src={`/api/v1/getFavicon?url=${encodeURIComponent(url.origin)}`}
|
||||||
width={64}
|
width={64}
|
||||||
height={64}
|
height={64}
|
||||||
alt=""
|
alt=""
|
||||||
|
unoptimized
|
||||||
className={clsx(
|
className={clsx(
|
||||||
iconClasses,
|
iconClasses,
|
||||||
faviconLoaded ? "" : "absolute opacity-0"
|
faviconLoaded ? "" : "absolute opacity-0"
|
||||||
)}
|
)}
|
||||||
draggable="false"
|
draggable="false"
|
||||||
onLoadingComplete={() => setFaviconLoaded(true)}
|
onLoad={() => setFaviconLoaded(true)}
|
||||||
onError={() => setFaviconLoaded(false)}
|
|
||||||
/>
|
/>
|
||||||
{!faviconLoaded && (
|
{!faviconLoaded && (
|
||||||
<LinkPlaceholderIcon
|
<LinkPlaceholderIcon
|
||||||
@@ -104,3 +104,5 @@ const LinkPlaceholderIcon = ({
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export default React.memo(LinkIcon);
|
||||||
|
|||||||
@@ -2,113 +2,61 @@ import {
|
|||||||
CollectionIncludingMembersAndLinkCount,
|
CollectionIncludingMembersAndLinkCount,
|
||||||
LinkIncludingShortenedCollectionAndTags,
|
LinkIncludingShortenedCollectionAndTags,
|
||||||
} from "@linkwarden/types";
|
} from "@linkwarden/types";
|
||||||
import { useEffect, useState } from "react";
|
import React, { useState } from "react";
|
||||||
import useLinkStore from "@/store/links";
|
|
||||||
import unescapeString from "@/lib/client/unescapeString";
|
import unescapeString from "@/lib/client/unescapeString";
|
||||||
import LinkActions from "@/components/LinkViews/LinkComponents/LinkActions";
|
import LinkActions from "@/components/LinkViews/LinkComponents/LinkActions";
|
||||||
import LinkDate from "@/components/LinkViews/LinkComponents/LinkDate";
|
import LinkDate from "@/components/LinkViews/LinkComponents/LinkDate";
|
||||||
import LinkCollection from "@/components/LinkViews/LinkComponents/LinkCollection";
|
import LinkCollection from "@/components/LinkViews/LinkComponents/LinkCollection";
|
||||||
import LinkIcon from "@/components/LinkViews/LinkComponents/LinkIcon";
|
import LinkIcon from "@/components/LinkViews/LinkComponents/LinkIcon";
|
||||||
import { cn, isPWA } from "@/lib/utils";
|
import { cn, isPWA } from "@/lib/utils";
|
||||||
import usePermissions from "@/hooks/usePermissions";
|
|
||||||
import toast from "react-hot-toast";
|
import toast from "react-hot-toast";
|
||||||
import LinkTypeBadge from "./LinkTypeBadge";
|
import LinkTypeBadge from "./LinkTypeBadge";
|
||||||
import { useTranslation } from "next-i18next";
|
|
||||||
import { useCollections } from "@linkwarden/router/collections";
|
|
||||||
import { useUser } from "@linkwarden/router/user";
|
|
||||||
import { useLinks } from "@linkwarden/router/links";
|
|
||||||
import useLocalSettingsStore from "@/store/localSettings";
|
import useLocalSettingsStore from "@/store/localSettings";
|
||||||
import LinkPin from "./LinkPin";
|
import LinkPin from "./LinkPin";
|
||||||
import { useRouter } from "next/router";
|
|
||||||
import { atLeastOneFormatAvailable } from "@linkwarden/lib/formatStats";
|
import { atLeastOneFormatAvailable } from "@linkwarden/lib/formatStats";
|
||||||
import LinkFormats from "./LinkFormats";
|
import LinkFormats from "./LinkFormats";
|
||||||
import openLink from "@/lib/client/openLink";
|
import openLink from "@/lib/client/openLink";
|
||||||
import { useDraggable } from "@dnd-kit/core";
|
import { useDraggable } from "@dnd-kit/core";
|
||||||
import useMediaQuery from "@/hooks/useMediaQuery";
|
import { TFunction } from "i18next";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
link: LinkIncludingShortenedCollectionAndTags;
|
link: LinkIncludingShortenedCollectionAndTags;
|
||||||
|
collection: CollectionIncludingMembersAndLinkCount;
|
||||||
|
isPublicRoute: boolean;
|
||||||
|
t: TFunction<"translation", undefined>;
|
||||||
|
disableDraggable: boolean;
|
||||||
|
user: any;
|
||||||
|
isSelected: boolean;
|
||||||
|
toggleSelected: (id: number) => void;
|
||||||
count: number;
|
count: number;
|
||||||
className?: string;
|
className?: string;
|
||||||
editMode?: boolean;
|
editMode?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function LinkCardCompact({ link, editMode }: Props) {
|
function LinkList({
|
||||||
const { t } = useTranslation();
|
link,
|
||||||
|
collection,
|
||||||
const isSmallScreen = useMediaQuery("(max-width: 1023px)");
|
isPublicRoute,
|
||||||
|
t,
|
||||||
|
disableDraggable,
|
||||||
|
user,
|
||||||
|
isSelected,
|
||||||
|
toggleSelected,
|
||||||
|
editMode,
|
||||||
|
}: Props) {
|
||||||
const { attributes, listeners, setNodeRef, isDragging } = useDraggable({
|
const { attributes, listeners, setNodeRef, isDragging } = useDraggable({
|
||||||
id: link.id?.toString() ?? "",
|
id: link.id?.toString() ?? "",
|
||||||
data: {
|
data: {
|
||||||
linkId: link.id,
|
linkId: link.id,
|
||||||
|
link,
|
||||||
},
|
},
|
||||||
disabled: isSmallScreen,
|
disabled: disableDraggable,
|
||||||
});
|
});
|
||||||
|
|
||||||
const { data: collections = [] } = useCollections();
|
|
||||||
|
|
||||||
const { data: user } = useUser();
|
|
||||||
const { setSelectedLinks, selectedLinks } = useLinkStore();
|
|
||||||
|
|
||||||
const {
|
const {
|
||||||
settings: { show },
|
settings: { show },
|
||||||
} = useLocalSettingsStore();
|
} = useLocalSettingsStore();
|
||||||
|
|
||||||
const { links } = useLinks();
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!editMode) {
|
|
||||||
setSelectedLinks([]);
|
|
||||||
}
|
|
||||||
}, [editMode]);
|
|
||||||
|
|
||||||
const handleCheckboxClick = (
|
|
||||||
link: LinkIncludingShortenedCollectionAndTags
|
|
||||||
) => {
|
|
||||||
const linkIndex = selectedLinks.findIndex(
|
|
||||||
(selectedLink) => selectedLink.id === link.id
|
|
||||||
);
|
|
||||||
|
|
||||||
if (linkIndex !== -1) {
|
|
||||||
const updatedLinks = [...selectedLinks];
|
|
||||||
updatedLinks.splice(linkIndex, 1);
|
|
||||||
setSelectedLinks(updatedLinks);
|
|
||||||
} else {
|
|
||||||
setSelectedLinks([...selectedLinks, link]);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const [collection, setCollection] =
|
|
||||||
useState<CollectionIncludingMembersAndLinkCount>(
|
|
||||||
collections.find(
|
|
||||||
(e) => e.id === link.collection.id
|
|
||||||
) as CollectionIncludingMembersAndLinkCount
|
|
||||||
);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
setCollection(
|
|
||||||
collections.find(
|
|
||||||
(e) => e.id === link.collection.id
|
|
||||||
) as CollectionIncludingMembersAndLinkCount
|
|
||||||
);
|
|
||||||
}, [collections, links]);
|
|
||||||
|
|
||||||
const permissions = usePermissions(collection?.id as number);
|
|
||||||
|
|
||||||
const selectedStyle = selectedLinks.some(
|
|
||||||
(selectedLink) => selectedLink.id === link.id
|
|
||||||
)
|
|
||||||
? "border border-primary bg-base-300"
|
|
||||||
: "border-transparent";
|
|
||||||
|
|
||||||
const selectable =
|
|
||||||
editMode &&
|
|
||||||
(permissions === true || permissions?.canCreate || permissions?.canDelete);
|
|
||||||
|
|
||||||
const router = useRouter();
|
|
||||||
|
|
||||||
let isPublic = router.pathname.startsWith("/public") ? true : undefined;
|
|
||||||
|
|
||||||
const [linkModal, setLinkModal] = useState(false);
|
const [linkModal, setLinkModal] = useState(false);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -117,14 +65,16 @@ export default function LinkCardCompact({ link, editMode }: Props) {
|
|||||||
ref={setNodeRef}
|
ref={setNodeRef}
|
||||||
className={cn(
|
className={cn(
|
||||||
"rounded-md border relative group items-center flex",
|
"rounded-md border relative group items-center flex",
|
||||||
selectedStyle,
|
isSelected
|
||||||
|
? "border border-primary bg-base-300"
|
||||||
|
: "border-transparent",
|
||||||
!isPWA() ? "hover:bg-base-300 px-2 py-1" : "py-1",
|
!isPWA() ? "hover:bg-base-300 px-2 py-1" : "py-1",
|
||||||
isDragging ? "opacity-30" : "opacity-100",
|
isDragging ? "opacity-30" : "opacity-100",
|
||||||
"duration-200, touch-manipulation select-none"
|
"duration-200, touch-manipulation select-none"
|
||||||
)}
|
)}
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
selectable
|
editMode
|
||||||
? handleCheckboxClick(link)
|
? toggleSelected(link.id as number)
|
||||||
: editMode
|
: editMode
|
||||||
? toast.error(t("link_selection_error"))
|
? toast.error(t("link_selection_error"))
|
||||||
: undefined
|
: undefined
|
||||||
@@ -163,19 +113,23 @@ export default function LinkCardCompact({ link, editMode }: Props) {
|
|||||||
<div className="mt-1 flex flex-col sm:flex-row sm:items-center gap-2 text-xs text-neutral">
|
<div className="mt-1 flex flex-col sm:flex-row sm:items-center gap-2 text-xs text-neutral">
|
||||||
<div className="flex items-center gap-x-3 text-neutral flex-wrap">
|
<div className="flex items-center gap-x-3 text-neutral flex-wrap">
|
||||||
{show.link && <LinkTypeBadge link={link} />}
|
{show.link && <LinkTypeBadge link={link} />}
|
||||||
{show.collection && (
|
{show.collection && collection && (
|
||||||
<LinkCollection link={link} collection={collection} />
|
<LinkCollection
|
||||||
|
link={link}
|
||||||
|
collection={collection}
|
||||||
|
isPublicRoute={isPublicRoute}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
{show.date && <LinkDate link={link} />}
|
{show.date && <LinkDate link={link} />}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{!isPublic && <LinkPin link={link} />}
|
{!isPublicRoute && <LinkPin link={link} />}
|
||||||
<LinkActions
|
<LinkActions
|
||||||
link={link}
|
link={link}
|
||||||
collection={collection}
|
|
||||||
linkModal={linkModal}
|
linkModal={linkModal}
|
||||||
|
t={t}
|
||||||
setLinkModal={(e) => setLinkModal(e)}
|
setLinkModal={(e) => setLinkModal(e)}
|
||||||
className="absolute top-3 right-3 group-hover:opacity-100 group-focus-within:opacity-100 opacity-0 duration-100 text-neutral z-20"
|
className="absolute top-3 right-3 group-hover:opacity-100 group-focus-within:opacity-100 opacity-0 duration-100 text-neutral z-20"
|
||||||
/>
|
/>
|
||||||
@@ -184,3 +138,5 @@ export default function LinkCardCompact({ link, editMode }: Props) {
|
|||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export default React.memo(LinkList);
|
||||||
|
|||||||
@@ -3,8 +3,7 @@ import {
|
|||||||
CollectionIncludingMembersAndLinkCount,
|
CollectionIncludingMembersAndLinkCount,
|
||||||
LinkIncludingShortenedCollectionAndTags,
|
LinkIncludingShortenedCollectionAndTags,
|
||||||
} from "@linkwarden/types";
|
} from "@linkwarden/types";
|
||||||
import { useEffect, useMemo, useRef, useState } from "react";
|
import React, { useRef, useState } from "react";
|
||||||
import useLinkStore from "@/store/links";
|
|
||||||
import unescapeString from "@/lib/client/unescapeString";
|
import unescapeString from "@/lib/client/unescapeString";
|
||||||
import LinkActions from "@/components/LinkViews/LinkComponents/LinkActions";
|
import LinkActions from "@/components/LinkViews/LinkComponents/LinkActions";
|
||||||
import LinkDate from "@/components/LinkViews/LinkComponents/LinkDate";
|
import LinkDate from "@/components/LinkViews/LinkComponents/LinkDate";
|
||||||
@@ -16,152 +15,58 @@ import {
|
|||||||
} from "@linkwarden/lib/formatStats";
|
} from "@linkwarden/lib/formatStats";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import LinkIcon from "./LinkIcon";
|
import LinkIcon from "./LinkIcon";
|
||||||
import useOnScreen from "@/hooks/useOnScreen";
|
|
||||||
import usePermissions from "@/hooks/usePermissions";
|
|
||||||
import toast from "react-hot-toast";
|
import toast from "react-hot-toast";
|
||||||
import LinkTypeBadge from "./LinkTypeBadge";
|
import LinkTypeBadge from "./LinkTypeBadge";
|
||||||
import { useTranslation } from "next-i18next";
|
|
||||||
import { useCollections } from "@linkwarden/router/collections";
|
|
||||||
import { useUser } from "@linkwarden/router/user";
|
|
||||||
import { useGetLink, useLinks } from "@linkwarden/router/links";
|
|
||||||
import useLocalSettingsStore from "@/store/localSettings";
|
import useLocalSettingsStore from "@/store/localSettings";
|
||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
import LinkPin from "./LinkPin";
|
import LinkPin from "./LinkPin";
|
||||||
import { useRouter } from "next/router";
|
|
||||||
import LinkFormats from "./LinkFormats";
|
import LinkFormats from "./LinkFormats";
|
||||||
import openLink from "@/lib/client/openLink";
|
import openLink from "@/lib/client/openLink";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Separator } from "@/components/ui/separator";
|
import { Separator } from "@/components/ui/separator";
|
||||||
import { useDraggable } from "@dnd-kit/core";
|
import { useDraggable } from "@dnd-kit/core";
|
||||||
import useMediaQuery from "@/hooks/useMediaQuery";
|
|
||||||
import { cn } from "@linkwarden/lib";
|
import { cn } from "@linkwarden/lib";
|
||||||
|
import { TFunction } from "i18next";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
link: LinkIncludingShortenedCollectionAndTags;
|
link: LinkIncludingShortenedCollectionAndTags;
|
||||||
columns: number;
|
collection: CollectionIncludingMembersAndLinkCount;
|
||||||
|
isPublicRoute: boolean;
|
||||||
|
t: TFunction<"translation", undefined>;
|
||||||
|
disableDraggable: boolean;
|
||||||
|
user: any;
|
||||||
|
isSelected: boolean;
|
||||||
|
toggleSelected: (id: number) => void;
|
||||||
|
imageHeightClass: string;
|
||||||
editMode?: boolean;
|
editMode?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function LinkMasonry({ link, editMode, columns }: Props) {
|
function LinkMasonry({
|
||||||
const { t } = useTranslation();
|
link,
|
||||||
|
collection,
|
||||||
// we don't want to use the draggable feature for screen under 1023px since the sidebar is hidden
|
isPublicRoute,
|
||||||
const isSmallScreen = useMediaQuery("(max-width: 1023px)");
|
t,
|
||||||
|
disableDraggable,
|
||||||
|
user,
|
||||||
|
isSelected,
|
||||||
|
toggleSelected,
|
||||||
|
imageHeightClass,
|
||||||
|
editMode,
|
||||||
|
}: Props) {
|
||||||
const { attributes, listeners, setNodeRef, isDragging } = useDraggable({
|
const { attributes, listeners, setNodeRef, isDragging } = useDraggable({
|
||||||
id: link.id?.toString() ?? "",
|
id: link.id?.toString() ?? "",
|
||||||
data: {
|
data: {
|
||||||
linkId: link.id,
|
linkId: link.id,
|
||||||
|
link,
|
||||||
},
|
},
|
||||||
disabled: isSmallScreen,
|
disabled: disableDraggable,
|
||||||
});
|
});
|
||||||
|
|
||||||
const heightMap = {
|
|
||||||
1: "h-44",
|
|
||||||
2: "h-40",
|
|
||||||
3: "h-36",
|
|
||||||
4: "h-32",
|
|
||||||
5: "h-28",
|
|
||||||
6: "h-24",
|
|
||||||
7: "h-20",
|
|
||||||
8: "h-20",
|
|
||||||
};
|
|
||||||
|
|
||||||
const imageHeightClass = useMemo(
|
|
||||||
() => (columns ? heightMap[columns as keyof typeof heightMap] : "h-40"),
|
|
||||||
[columns]
|
|
||||||
);
|
|
||||||
|
|
||||||
const { data: collections = [] } = useCollections();
|
|
||||||
const { data: user } = useUser();
|
|
||||||
|
|
||||||
const { setSelectedLinks, selectedLinks } = useLinkStore();
|
|
||||||
|
|
||||||
const {
|
const {
|
||||||
settings: { show },
|
settings: { show },
|
||||||
} = useLocalSettingsStore();
|
} = useLocalSettingsStore();
|
||||||
|
|
||||||
const { links } = useLinks();
|
|
||||||
|
|
||||||
const router = useRouter();
|
|
||||||
|
|
||||||
let isPublicRoute = router.pathname.startsWith("/public") ? true : undefined;
|
|
||||||
|
|
||||||
const { refetch } = useGetLink({ id: link.id as number, isPublicRoute });
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!editMode) {
|
|
||||||
setSelectedLinks([]);
|
|
||||||
}
|
|
||||||
}, [editMode]);
|
|
||||||
|
|
||||||
const handleCheckboxClick = (
|
|
||||||
link: LinkIncludingShortenedCollectionAndTags
|
|
||||||
) => {
|
|
||||||
if (selectedLinks.includes(link)) {
|
|
||||||
setSelectedLinks(selectedLinks.filter((e) => e !== link));
|
|
||||||
} else {
|
|
||||||
setSelectedLinks([...selectedLinks, link]);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
let shortendURL;
|
|
||||||
|
|
||||||
try {
|
|
||||||
if (link.url) {
|
|
||||||
shortendURL = new URL(link.url).host.toLowerCase();
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.log(error);
|
|
||||||
}
|
|
||||||
|
|
||||||
const [collection, setCollection] =
|
|
||||||
useState<CollectionIncludingMembersAndLinkCount>(
|
|
||||||
collections.find(
|
|
||||||
(e) => e.id === link.collection.id
|
|
||||||
) as CollectionIncludingMembersAndLinkCount
|
|
||||||
);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
setCollection(
|
|
||||||
collections.find(
|
|
||||||
(e) => e.id === link.collection.id
|
|
||||||
) as CollectionIncludingMembersAndLinkCount
|
|
||||||
);
|
|
||||||
}, [collections, links]);
|
|
||||||
|
|
||||||
const ref = useRef<HTMLDivElement>(null);
|
const ref = useRef<HTMLDivElement>(null);
|
||||||
const isVisible = useOnScreen(ref);
|
|
||||||
const permissions = usePermissions(collection?.id as number);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
let interval: NodeJS.Timeout | null = null;
|
|
||||||
|
|
||||||
if (
|
|
||||||
isVisible &&
|
|
||||||
!link.preview?.startsWith("archives") &&
|
|
||||||
link.preview !== "unavailable"
|
|
||||||
) {
|
|
||||||
interval = setInterval(async () => {
|
|
||||||
refetch().catch((error) => {
|
|
||||||
console.error("Error refetching link:", error);
|
|
||||||
});
|
|
||||||
}, 5000);
|
|
||||||
}
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
if (interval) {
|
|
||||||
clearInterval(interval);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}, [isVisible, link.preview]);
|
|
||||||
|
|
||||||
const isLinkSelected = selectedLinks.some(
|
|
||||||
(selectedLink) => selectedLink.id === link.id
|
|
||||||
);
|
|
||||||
|
|
||||||
const selectable =
|
|
||||||
editMode &&
|
|
||||||
(permissions === true || permissions?.canCreate || permissions?.canDelete);
|
|
||||||
|
|
||||||
const [linkModal, setLinkModal] = useState(false);
|
const [linkModal, setLinkModal] = useState(false);
|
||||||
|
|
||||||
@@ -170,11 +75,11 @@ export default function LinkMasonry({ link, editMode, columns }: Props) {
|
|||||||
ref={setNodeRef}
|
ref={setNodeRef}
|
||||||
className={cn(
|
className={cn(
|
||||||
"border border-solid border-neutral-content bg-base-200 shadow-md hover:shadow-none duration-100 rounded-xl relative group",
|
"border border-solid border-neutral-content bg-base-200 shadow-md hover:shadow-none duration-100 rounded-xl relative group",
|
||||||
isLinkSelected && "border-primary bg-base-300"
|
isSelected && "border-primary bg-base-300"
|
||||||
)}
|
)}
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
selectable
|
editMode
|
||||||
? handleCheckboxClick(link)
|
? toggleSelected(link.id as number)
|
||||||
: editMode
|
: editMode
|
||||||
? toast.error(t("link_selection_error"))
|
? toast.error(t("link_selection_error"))
|
||||||
: undefined
|
: undefined
|
||||||
@@ -205,6 +110,7 @@ export default function LinkMasonry({ link, editMode, columns }: Props) {
|
|||||||
const target = e.target as HTMLElement;
|
const target = e.target as HTMLElement;
|
||||||
target.style.display = "none";
|
target.style.display = "none";
|
||||||
}}
|
}}
|
||||||
|
unoptimized
|
||||||
/>
|
/>
|
||||||
) : link.preview === "unavailable" ? null : (
|
) : link.preview === "unavailable" ? null : (
|
||||||
<div
|
<div
|
||||||
@@ -268,9 +174,13 @@ export default function LinkMasonry({ link, editMode, columns }: Props) {
|
|||||||
<Separator className="mb-1" />
|
<Separator className="mb-1" />
|
||||||
|
|
||||||
<div className="flex flex-wrap justify-between items-center text-xs text-neutral px-3 pb-1 w-full gap-x-2">
|
<div className="flex flex-wrap justify-between items-center text-xs text-neutral px-3 pb-1 w-full gap-x-2">
|
||||||
{!isPublicRoute && show.collection && (
|
{!isPublicRoute && show.collection && collection && (
|
||||||
<div className="cursor-pointer truncate">
|
<div className="cursor-pointer truncate">
|
||||||
<LinkCollection link={link} collection={collection} />
|
<LinkCollection
|
||||||
|
link={link}
|
||||||
|
collection={collection}
|
||||||
|
isPublicRoute={isPublicRoute}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{show.date && <LinkDate link={link} />}
|
{show.date && <LinkDate link={link} />}
|
||||||
@@ -283,8 +193,8 @@ export default function LinkMasonry({ link, editMode, columns }: Props) {
|
|||||||
<div className="absolute pointer-events-none top-0 left-0 right-0 bottom-0 bg-base-100 bg-opacity-0 group-hover:bg-opacity-20 group-focus-within:opacity-20 rounded-xl duration-100"></div>
|
<div className="absolute pointer-events-none top-0 left-0 right-0 bottom-0 bg-base-100 bg-opacity-0 group-hover:bg-opacity-20 group-focus-within:opacity-20 rounded-xl duration-100"></div>
|
||||||
<LinkActions
|
<LinkActions
|
||||||
link={link}
|
link={link}
|
||||||
collection={collection}
|
|
||||||
linkModal={linkModal}
|
linkModal={linkModal}
|
||||||
|
t={t}
|
||||||
setLinkModal={(e) => setLinkModal(e)}
|
setLinkModal={(e) => setLinkModal(e)}
|
||||||
className="absolute top-3 right-3 group-hover:opacity-100 group-focus-within:opacity-100 opacity-0 duration-100 text-neutral z-20"
|
className="absolute top-3 right-3 group-hover:opacity-100 group-focus-within:opacity-100 opacity-0 duration-100 text-neutral z-20"
|
||||||
/>
|
/>
|
||||||
@@ -293,3 +203,5 @@ export default function LinkMasonry({ link, editMode, columns }: Props) {
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export default React.memo(LinkMasonry);
|
||||||
|
|||||||
@@ -1,20 +1,23 @@
|
|||||||
import { LinkIncludingShortenedCollectionAndTags } from "@linkwarden/types";
|
import { LinkIncludingShortenedCollectionAndTags } from "@linkwarden/types";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
|
import React, { useEffect, useState } from "react";
|
||||||
|
|
||||||
export default function LinkTypeBadge({
|
function LinkTypeBadge({
|
||||||
link,
|
link,
|
||||||
}: {
|
}: {
|
||||||
link: LinkIncludingShortenedCollectionAndTags;
|
link: LinkIncludingShortenedCollectionAndTags;
|
||||||
}) {
|
}) {
|
||||||
let shortendURL;
|
const [url, setUrl] = useState("");
|
||||||
|
|
||||||
if (link.type === "url" && link.url) {
|
useEffect(() => {
|
||||||
try {
|
if (link.type === "url" && link.url) {
|
||||||
shortendURL = new URL(link.url).host.toLowerCase();
|
try {
|
||||||
} catch (error) {
|
setUrl(new URL(link.url).host.toLowerCase());
|
||||||
console.log(error);
|
} catch (error) {
|
||||||
|
console.log(error);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}, [link]);
|
||||||
|
|
||||||
const typeIcon = () => {
|
const typeIcon = () => {
|
||||||
switch (link.type) {
|
switch (link.type) {
|
||||||
@@ -27,7 +30,7 @@ export default function LinkTypeBadge({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
return link.url && shortendURL ? (
|
return link.url && url ? (
|
||||||
<Link
|
<Link
|
||||||
href={link.url || ""}
|
href={link.url || ""}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
@@ -38,7 +41,7 @@ export default function LinkTypeBadge({
|
|||||||
className="flex gap-1 item-center select-none text-neutral hover:opacity-70 duration-100 max-w-full w-fit"
|
className="flex gap-1 item-center select-none text-neutral hover:opacity-70 duration-100 max-w-full w-fit"
|
||||||
>
|
>
|
||||||
<i className="bi-link-45deg text-lg leading-none"></i>
|
<i className="bi-link-45deg text-lg leading-none"></i>
|
||||||
<p className="text-xs truncate">{shortendURL}</p>
|
<p className="text-xs truncate">{url}</p>
|
||||||
</Link>
|
</Link>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex gap-1 item-center select-none text-neutral duration-100 max-w-full w-fit">
|
<div className="flex gap-1 item-center select-none text-neutral duration-100 max-w-full w-fit">
|
||||||
@@ -47,3 +50,5 @@ export default function LinkTypeBadge({
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export default React.memo(LinkTypeBadge);
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import LinkCard from "@/components/LinkViews/LinkComponents/LinkCard";
|
import LinkCard from "@/components/LinkViews/LinkComponents/LinkCard";
|
||||||
import {
|
import {
|
||||||
|
CollectionIncludingMembersAndLinkCount,
|
||||||
LinkIncludingShortenedCollectionAndTags,
|
LinkIncludingShortenedCollectionAndTags,
|
||||||
ViewMode,
|
ViewMode,
|
||||||
} from "@linkwarden/types";
|
} from "@linkwarden/types";
|
||||||
@@ -7,26 +8,43 @@ import { useEffect, useState } from "react";
|
|||||||
import { useInView } from "react-intersection-observer";
|
import { useInView } from "react-intersection-observer";
|
||||||
import LinkMasonry from "@/components/LinkViews/LinkComponents/LinkMasonry";
|
import LinkMasonry from "@/components/LinkViews/LinkComponents/LinkMasonry";
|
||||||
import Masonry from "react-masonry-css";
|
import Masonry from "react-masonry-css";
|
||||||
import resolveConfig from "tailwindcss/resolveConfig";
|
|
||||||
import tailwindConfig from "../../tailwind.config.js";
|
|
||||||
import { useMemo } from "react";
|
import { useMemo } from "react";
|
||||||
import LinkList from "@/components/LinkViews/LinkComponents/LinkList";
|
import LinkList from "@/components/LinkViews/LinkComponents/LinkList";
|
||||||
import useLocalSettingsStore from "@/store/localSettings";
|
import useLocalSettingsStore from "@/store/localSettings";
|
||||||
|
import { useCollections } from "@linkwarden/router/collections";
|
||||||
|
import { useRouter } from "next/router";
|
||||||
|
import { useTranslation } from "next-i18next";
|
||||||
|
import { TFunction } from "i18next";
|
||||||
|
import useLinkStore from "@/store/links";
|
||||||
|
import useMediaQuery from "@/hooks/useMediaQuery";
|
||||||
|
import { useUser } from "@linkwarden/router/user";
|
||||||
|
|
||||||
export function CardView({
|
function CardView({
|
||||||
links,
|
links,
|
||||||
|
collectionsById,
|
||||||
|
isPublicRoute,
|
||||||
|
t,
|
||||||
|
user,
|
||||||
|
disableDraggable,
|
||||||
|
isSelected,
|
||||||
|
toggleSelected,
|
||||||
editMode,
|
editMode,
|
||||||
isLoading,
|
isLoading,
|
||||||
placeholders,
|
|
||||||
hasNextPage,
|
hasNextPage,
|
||||||
placeHolderRef,
|
placeHolderRef,
|
||||||
}: {
|
}: {
|
||||||
links?: LinkIncludingShortenedCollectionAndTags[];
|
links: LinkIncludingShortenedCollectionAndTags[];
|
||||||
editMode?: boolean;
|
collectionsById: Map<number, CollectionIncludingMembersAndLinkCount>;
|
||||||
isLoading?: boolean;
|
isPublicRoute: boolean;
|
||||||
placeholders?: number[];
|
t: TFunction<"translation", undefined>;
|
||||||
hasNextPage?: boolean;
|
user: any;
|
||||||
placeHolderRef?: any;
|
disableDraggable: boolean;
|
||||||
|
isSelected: (id: number) => boolean;
|
||||||
|
toggleSelected: (id: number) => void;
|
||||||
|
editMode: boolean;
|
||||||
|
isLoading: boolean;
|
||||||
|
hasNextPage: boolean;
|
||||||
|
placeHolderRef: any;
|
||||||
}) {
|
}) {
|
||||||
const settings = useLocalSettingsStore((state) => state.settings);
|
const settings = useLocalSettingsStore((state) => state.settings);
|
||||||
|
|
||||||
@@ -59,6 +77,23 @@ export function CardView({
|
|||||||
[columnCount]
|
[columnCount]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const heightMap = {
|
||||||
|
1: "h-44",
|
||||||
|
2: "h-40",
|
||||||
|
3: "h-36",
|
||||||
|
4: "h-32",
|
||||||
|
5: "h-28",
|
||||||
|
6: "h-24",
|
||||||
|
7: "h-20",
|
||||||
|
8: "h-20",
|
||||||
|
};
|
||||||
|
|
||||||
|
const imageHeightClass = useMemo(
|
||||||
|
() =>
|
||||||
|
columnCount ? heightMap[columnCount as keyof typeof heightMap] : "h-40",
|
||||||
|
[columnCount]
|
||||||
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleResize = () => {
|
const handleResize = () => {
|
||||||
if (settings.columns === 0) {
|
if (settings.columns === 0) {
|
||||||
@@ -82,51 +117,66 @@ export function CardView({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={`${gridColClass} grid gap-5 pb-5`}>
|
<div className={`${gridColClass} grid gap-5 pb-5`}>
|
||||||
{links?.map((e, i) => {
|
{links?.map((e) => {
|
||||||
|
const collection = collectionsById.get(e.collection.id as number);
|
||||||
|
const selected = isSelected(e.id as number);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<LinkCard
|
<LinkCard
|
||||||
key={i}
|
key={e.id}
|
||||||
link={e}
|
link={e}
|
||||||
|
collection={collection as CollectionIncludingMembersAndLinkCount}
|
||||||
|
isPublicRoute={isPublicRoute}
|
||||||
|
t={t}
|
||||||
|
user={user}
|
||||||
|
disableDraggable={disableDraggable}
|
||||||
|
isSelected={selected}
|
||||||
|
toggleSelected={toggleSelected}
|
||||||
editMode={editMode}
|
editMode={editMode}
|
||||||
columns={columnCount}
|
imageHeightClass={imageHeightClass}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|
||||||
{(hasNextPage || isLoading) &&
|
{(hasNextPage || isLoading) && (
|
||||||
placeholders?.map((e, i) => {
|
<div className="flex flex-col gap-4" ref={placeHolderRef}>
|
||||||
return (
|
<div className="skeleton h-40 w-full"></div>
|
||||||
<div
|
<div className="skeleton h-3 w-2/3"></div>
|
||||||
className="flex flex-col gap-4"
|
<div className="skeleton h-3 w-full"></div>
|
||||||
ref={e === 1 ? placeHolderRef : undefined}
|
<div className="skeleton h-3 w-full"></div>
|
||||||
key={i}
|
<div className="skeleton h-3 w-1/3"></div>
|
||||||
>
|
</div>
|
||||||
<div className="skeleton h-40 w-full"></div>
|
)}
|
||||||
<div className="skeleton h-3 w-2/3"></div>
|
|
||||||
<div className="skeleton h-3 w-full"></div>
|
|
||||||
<div className="skeleton h-3 w-full"></div>
|
|
||||||
<div className="skeleton h-3 w-1/3"></div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function MasonryView({
|
function MasonryView({
|
||||||
links,
|
links,
|
||||||
|
collectionsById,
|
||||||
|
isPublicRoute,
|
||||||
|
t,
|
||||||
|
disableDraggable,
|
||||||
|
user,
|
||||||
|
isSelected,
|
||||||
|
toggleSelected,
|
||||||
editMode,
|
editMode,
|
||||||
isLoading,
|
isLoading,
|
||||||
placeholders,
|
|
||||||
hasNextPage,
|
hasNextPage,
|
||||||
placeHolderRef,
|
placeHolderRef,
|
||||||
}: {
|
}: {
|
||||||
links?: LinkIncludingShortenedCollectionAndTags[];
|
links: LinkIncludingShortenedCollectionAndTags[];
|
||||||
editMode?: boolean;
|
collectionsById: Map<number, CollectionIncludingMembersAndLinkCount>;
|
||||||
isLoading?: boolean;
|
isPublicRoute: boolean;
|
||||||
placeholders?: number[];
|
t: TFunction<"translation", undefined>;
|
||||||
hasNextPage?: boolean;
|
disableDraggable: boolean;
|
||||||
placeHolderRef?: any;
|
user: any;
|
||||||
|
isSelected: (id: number) => boolean;
|
||||||
|
toggleSelected: (id: number) => void;
|
||||||
|
editMode: boolean;
|
||||||
|
isLoading: boolean;
|
||||||
|
hasNextPage: boolean;
|
||||||
|
placeHolderRef: any;
|
||||||
}) {
|
}) {
|
||||||
const settings = useLocalSettingsStore((state) => state.settings);
|
const settings = useLocalSettingsStore((state) => state.settings);
|
||||||
|
|
||||||
@@ -159,6 +209,23 @@ export function MasonryView({
|
|||||||
[columnCount]
|
[columnCount]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const heightMap = {
|
||||||
|
1: "h-44",
|
||||||
|
2: "h-40",
|
||||||
|
3: "h-36",
|
||||||
|
4: "h-32",
|
||||||
|
5: "h-28",
|
||||||
|
6: "h-24",
|
||||||
|
7: "h-20",
|
||||||
|
8: "h-20",
|
||||||
|
};
|
||||||
|
|
||||||
|
const imageHeightClass = useMemo(
|
||||||
|
() =>
|
||||||
|
columnCount ? heightMap[columnCount as keyof typeof heightMap] : "h-40",
|
||||||
|
[columnCount]
|
||||||
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleResize = () => {
|
const handleResize = () => {
|
||||||
if (settings.columns === 0) {
|
if (settings.columns === 0) {
|
||||||
@@ -180,17 +247,7 @@ export function MasonryView({
|
|||||||
};
|
};
|
||||||
}, [settings.columns]);
|
}, [settings.columns]);
|
||||||
|
|
||||||
const fullConfig = resolveConfig(tailwindConfig as any);
|
const breakpointColumnsObj = { default: 5, 1900: 4, 1500: 3, 880: 2, 550: 1 };
|
||||||
|
|
||||||
const breakpointColumnsObj = useMemo(() => {
|
|
||||||
return {
|
|
||||||
default: 5,
|
|
||||||
1900: 4,
|
|
||||||
1500: 3,
|
|
||||||
880: 2,
|
|
||||||
550: 1,
|
|
||||||
};
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Masonry
|
<Masonry
|
||||||
@@ -200,75 +257,100 @@ export function MasonryView({
|
|||||||
columnClassName="flex flex-col gap-5 !w-full"
|
columnClassName="flex flex-col gap-5 !w-full"
|
||||||
className={`${gridColClass} grid gap-5 pb-5`}
|
className={`${gridColClass} grid gap-5 pb-5`}
|
||||||
>
|
>
|
||||||
{links?.map((e, i) => {
|
{links?.map((e) => {
|
||||||
|
const collection = collectionsById.get(e.collection.id as number);
|
||||||
|
const selected = isSelected(e.id as number);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<LinkMasonry
|
<LinkMasonry
|
||||||
key={i}
|
key={e.id}
|
||||||
link={e}
|
link={e}
|
||||||
|
collection={collection as CollectionIncludingMembersAndLinkCount}
|
||||||
|
isPublicRoute={isPublicRoute}
|
||||||
|
t={t}
|
||||||
|
disableDraggable={disableDraggable}
|
||||||
|
user={user}
|
||||||
|
isSelected={selected}
|
||||||
|
toggleSelected={toggleSelected}
|
||||||
|
imageHeightClass={imageHeightClass}
|
||||||
editMode={editMode}
|
editMode={editMode}
|
||||||
columns={columnCount}
|
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|
||||||
{(hasNextPage || isLoading) &&
|
{(hasNextPage || isLoading) && (
|
||||||
placeholders?.map((e, i) => {
|
<div className="flex flex-col gap-4" ref={placeHolderRef}>
|
||||||
return (
|
<div className="skeleton h-40 w-full"></div>
|
||||||
<div
|
<div className="skeleton h-3 w-2/3"></div>
|
||||||
className="flex flex-col gap-4"
|
<div className="skeleton h-3 w-full"></div>
|
||||||
ref={e === 1 ? placeHolderRef : undefined}
|
<div className="skeleton h-3 w-full"></div>
|
||||||
key={i}
|
<div className="skeleton h-3 w-1/3"></div>
|
||||||
>
|
</div>
|
||||||
<div className="skeleton h-40 w-full"></div>
|
)}
|
||||||
<div className="skeleton h-3 w-2/3"></div>
|
|
||||||
<div className="skeleton h-3 w-full"></div>
|
|
||||||
<div className="skeleton h-3 w-full"></div>
|
|
||||||
<div className="skeleton h-3 w-1/3"></div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</Masonry>
|
</Masonry>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ListView({
|
function ListView({
|
||||||
links,
|
links,
|
||||||
|
collectionsById,
|
||||||
|
isPublicRoute,
|
||||||
|
t,
|
||||||
|
disableDraggable,
|
||||||
|
user,
|
||||||
|
isSelected,
|
||||||
|
toggleSelected,
|
||||||
editMode,
|
editMode,
|
||||||
isLoading,
|
isLoading,
|
||||||
placeholders,
|
|
||||||
hasNextPage,
|
hasNextPage,
|
||||||
placeHolderRef,
|
placeHolderRef,
|
||||||
}: {
|
}: {
|
||||||
links?: LinkIncludingShortenedCollectionAndTags[];
|
links: LinkIncludingShortenedCollectionAndTags[];
|
||||||
editMode?: boolean;
|
collectionsById: Map<number, CollectionIncludingMembersAndLinkCount>;
|
||||||
isLoading?: boolean;
|
isPublicRoute: boolean;
|
||||||
placeholders?: number[];
|
t: TFunction<"translation", undefined>;
|
||||||
hasNextPage?: boolean;
|
disableDraggable: boolean;
|
||||||
placeHolderRef?: any;
|
user: any;
|
||||||
|
isSelected: (id: number) => boolean;
|
||||||
|
toggleSelected: (id: number) => void;
|
||||||
|
editMode: boolean;
|
||||||
|
isLoading: boolean;
|
||||||
|
hasNextPage: boolean;
|
||||||
|
placeHolderRef: any;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col">
|
<div className="flex flex-col">
|
||||||
{links?.map((e, i) => {
|
{links?.map((e, i) => {
|
||||||
return <LinkList key={i} link={e} count={i} editMode={editMode} />;
|
const collection = collectionsById.get(e.collection.id as number);
|
||||||
|
const selected = isSelected(e.id as number);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<LinkList
|
||||||
|
key={e.id}
|
||||||
|
link={e}
|
||||||
|
collection={collection as CollectionIncludingMembersAndLinkCount}
|
||||||
|
isPublicRoute={isPublicRoute}
|
||||||
|
t={t}
|
||||||
|
disableDraggable={disableDraggable}
|
||||||
|
user={user}
|
||||||
|
isSelected={selected}
|
||||||
|
toggleSelected={toggleSelected}
|
||||||
|
count={i}
|
||||||
|
editMode={editMode}
|
||||||
|
/>
|
||||||
|
);
|
||||||
})}
|
})}
|
||||||
|
|
||||||
{(hasNextPage || isLoading) &&
|
{(hasNextPage || isLoading) && (
|
||||||
placeholders?.map((e, i) => {
|
<div ref={placeHolderRef} className="flex gap-2 py-2 px-1">
|
||||||
return (
|
<div className="skeleton h-12 w-12"></div>
|
||||||
<div
|
<div className="flex flex-col gap-3 w-full">
|
||||||
ref={e === 1 ? placeHolderRef : undefined}
|
<div className="skeleton h-2 w-2/3"></div>
|
||||||
key={i}
|
<div className="skeleton h-2 w-full"></div>
|
||||||
className="flex gap-2 py-2 px-1"
|
<div className="skeleton h-2 w-1/3"></div>
|
||||||
>
|
</div>
|
||||||
<div className="skeleton h-12 w-12"></div>
|
</div>
|
||||||
<div className="flex flex-col gap-3 w-full">
|
)}
|
||||||
<div className="skeleton h-2 w-2/3"></div>
|
|
||||||
<div className="skeleton h-2 w-full"></div>
|
|
||||||
<div className="skeleton h-2 w-1/3"></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -277,30 +359,88 @@ export default function Links({
|
|||||||
layout,
|
layout,
|
||||||
links,
|
links,
|
||||||
editMode,
|
editMode,
|
||||||
placeholderCount,
|
|
||||||
useData,
|
useData,
|
||||||
}: {
|
}: {
|
||||||
layout: ViewMode;
|
layout: ViewMode;
|
||||||
links?: LinkIncludingShortenedCollectionAndTags[];
|
links?: LinkIncludingShortenedCollectionAndTags[];
|
||||||
editMode?: boolean;
|
editMode?: boolean;
|
||||||
placeholderCount?: number;
|
|
||||||
useData?: any;
|
useData?: any;
|
||||||
}) {
|
}) {
|
||||||
const { ref, inView } = useInView();
|
const { ref, inView } = useInView();
|
||||||
|
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
const isPublicRoute = router.pathname.startsWith("/public") ? true : false;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (inView && useData?.fetchNextPage && useData?.hasNextPage) {
|
if (!inView) return;
|
||||||
useData.fetchNextPage();
|
if (!useData.hasNextPage) return;
|
||||||
|
if (useData.isFetchingNextPage) return;
|
||||||
|
|
||||||
|
useData.fetchNextPage();
|
||||||
|
}, [
|
||||||
|
inView,
|
||||||
|
useData.hasNextPage,
|
||||||
|
useData.isFetchingNextPage,
|
||||||
|
useData.fetchNextPage,
|
||||||
|
]);
|
||||||
|
|
||||||
|
const { data: collections = [] } = useCollections();
|
||||||
|
|
||||||
|
const collectionsById = useMemo(() => {
|
||||||
|
const m = new Map<number, (typeof collections)[number]>();
|
||||||
|
for (const c of collections) m.set(c.id as any, c);
|
||||||
|
return m;
|
||||||
|
}, [collections]);
|
||||||
|
|
||||||
|
const { clearSelected, isSelected, toggleSelected } = useLinkStore();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!editMode) {
|
||||||
|
clearSelected();
|
||||||
}
|
}
|
||||||
}, [useData, inView]);
|
}, [editMode]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let interval: NodeJS.Timeout | null = null;
|
||||||
|
|
||||||
|
if (
|
||||||
|
links?.some(
|
||||||
|
(e) => !e.preview?.startsWith("archives") && e.preview !== "unavailable"
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
interval = setInterval(async () => {
|
||||||
|
useData.refetch().catch((error: any) => {
|
||||||
|
console.error("Error refetching link:", error);
|
||||||
|
});
|
||||||
|
}, 5000);
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
if (interval) {
|
||||||
|
clearInterval(interval);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, [links]);
|
||||||
|
|
||||||
|
const disableDraggable = useMediaQuery("(max-width: 1023px)");
|
||||||
|
|
||||||
|
const { data: user } = useUser();
|
||||||
|
|
||||||
if (layout === ViewMode.List) {
|
if (layout === ViewMode.List) {
|
||||||
return (
|
return (
|
||||||
<ListView
|
<ListView
|
||||||
links={links}
|
links={links || []}
|
||||||
editMode={editMode}
|
collectionsById={collectionsById}
|
||||||
|
isPublicRoute={isPublicRoute}
|
||||||
|
t={t}
|
||||||
|
disableDraggable={disableDraggable}
|
||||||
|
user={user}
|
||||||
|
toggleSelected={toggleSelected}
|
||||||
|
isSelected={isSelected}
|
||||||
|
editMode={editMode || false}
|
||||||
isLoading={useData?.isLoading}
|
isLoading={useData?.isLoading}
|
||||||
placeholders={placeholderCountToArray(placeholderCount)}
|
|
||||||
hasNextPage={useData?.hasNextPage}
|
hasNextPage={useData?.hasNextPage}
|
||||||
placeHolderRef={ref}
|
placeHolderRef={ref}
|
||||||
/>
|
/>
|
||||||
@@ -308,10 +448,16 @@ export default function Links({
|
|||||||
} else if (layout === ViewMode.Masonry) {
|
} else if (layout === ViewMode.Masonry) {
|
||||||
return (
|
return (
|
||||||
<MasonryView
|
<MasonryView
|
||||||
links={links}
|
links={links || []}
|
||||||
editMode={editMode}
|
collectionsById={collectionsById}
|
||||||
|
isPublicRoute={isPublicRoute}
|
||||||
|
t={t}
|
||||||
|
disableDraggable={disableDraggable}
|
||||||
|
user={user}
|
||||||
|
toggleSelected={toggleSelected}
|
||||||
|
isSelected={isSelected}
|
||||||
|
editMode={editMode || false}
|
||||||
isLoading={useData?.isLoading}
|
isLoading={useData?.isLoading}
|
||||||
placeholders={placeholderCountToArray(placeholderCount)}
|
|
||||||
hasNextPage={useData?.hasNextPage}
|
hasNextPage={useData?.hasNextPage}
|
||||||
placeHolderRef={ref}
|
placeHolderRef={ref}
|
||||||
/>
|
/>
|
||||||
@@ -320,16 +466,19 @@ export default function Links({
|
|||||||
// Default to card view
|
// Default to card view
|
||||||
return (
|
return (
|
||||||
<CardView
|
<CardView
|
||||||
links={links}
|
links={links || []}
|
||||||
editMode={editMode}
|
collectionsById={collectionsById}
|
||||||
|
isPublicRoute={isPublicRoute}
|
||||||
|
t={t}
|
||||||
|
user={user}
|
||||||
|
disableDraggable={disableDraggable}
|
||||||
|
toggleSelected={toggleSelected}
|
||||||
|
isSelected={isSelected}
|
||||||
|
editMode={editMode || false}
|
||||||
isLoading={useData?.isLoading}
|
isLoading={useData?.isLoading}
|
||||||
placeholders={placeholderCountToArray(placeholderCount)}
|
|
||||||
hasNextPage={useData?.hasNextPage}
|
hasNextPage={useData?.hasNextPage}
|
||||||
placeHolderRef={ref}
|
placeHolderRef={ref}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const placeholderCountToArray = (num?: number) =>
|
|
||||||
num ? Array.from({ length: num }, (_, i) => i + 1) : [];
|
|
||||||
|
|||||||
@@ -13,47 +13,45 @@ type Props = {
|
|||||||
|
|
||||||
export default function BulkDeleteLinksModal({ onClose }: Props) {
|
export default function BulkDeleteLinksModal({ onClose }: Props) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { selectedLinks, setSelectedLinks } = useLinkStore();
|
const { selectedIds, clearSelected, selectionCount } = useLinkStore();
|
||||||
|
|
||||||
const deleteLinksById = useBulkDeleteLinks();
|
const deleteLinksById = useBulkDeleteLinks();
|
||||||
|
|
||||||
const deleteLink = async () => {
|
const deleteLink = async () => {
|
||||||
const load = toast.loading(t("deleting"));
|
const load = toast.loading(t("deleting"));
|
||||||
|
const ids = Object.keys(selectedIds).map(Number);
|
||||||
|
|
||||||
await deleteLinksById.mutateAsync(
|
await deleteLinksById.mutateAsync(ids, {
|
||||||
selectedLinks.map((link) => link.id as number),
|
onSettled: (data, error) => {
|
||||||
{
|
toast.dismiss(load);
|
||||||
onSettled: (data, error) => {
|
|
||||||
toast.dismiss(load);
|
|
||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
toast.error(error.message);
|
toast.error(error.message);
|
||||||
} else {
|
} else {
|
||||||
setSelectedLinks([]);
|
clearSelected();
|
||||||
onClose();
|
onClose();
|
||||||
toast.success(t("deleted"));
|
toast.success(t("deleted"));
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
}
|
});
|
||||||
);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Modal toggleModal={onClose}>
|
<Modal toggleModal={onClose}>
|
||||||
<p className="text-xl font-thin text-red-500">
|
<p className="text-xl font-thin text-red-500">
|
||||||
{selectedLinks.length === 1
|
{selectionCount === 1
|
||||||
? t("delete_link")
|
? t("delete_link")
|
||||||
: t("delete_links", { count: selectedLinks.length })}
|
: t("delete_links", { count: selectionCount })}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<Separator className="my-3" />
|
<Separator className="my-3" />
|
||||||
|
|
||||||
<div className="flex flex-col gap-3">
|
<div className="flex flex-col gap-3">
|
||||||
<p>
|
<p>
|
||||||
{selectedLinks.length === 1
|
{selectionCount === 1
|
||||||
? t("link_deletion_confirmation_message")
|
? t("link_deletion_confirmation_message")
|
||||||
: t("links_deletion_confirmation_message", {
|
: t("links_deletion_confirmation_message", {
|
||||||
count: selectedLinks.length,
|
count: selectionCount,
|
||||||
})}
|
})}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
|||||||
73
apps/web/components/ModalContent/BulkDeleteTagsModal.tsx
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
import React from "react";
|
||||||
|
import Modal from "../Modal";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { useTranslation } from "next-i18next";
|
||||||
|
import toast from "react-hot-toast";
|
||||||
|
import { Separator } from "../ui/separator";
|
||||||
|
import { useBulkTagDeletion } from "@linkwarden/router/tags";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
onClose: Function;
|
||||||
|
selectedTags: number[];
|
||||||
|
setSelectedTags: (tags: number[]) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function BulkDeleteTagsModal({
|
||||||
|
onClose,
|
||||||
|
selectedTags,
|
||||||
|
setSelectedTags,
|
||||||
|
}: Props) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
const deleteTagsById = useBulkTagDeletion();
|
||||||
|
|
||||||
|
const deleteTag = async () => {
|
||||||
|
const load = toast.loading(t("deleting"));
|
||||||
|
|
||||||
|
await deleteTagsById.mutateAsync(
|
||||||
|
{
|
||||||
|
tagIds: selectedTags,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
onSettled: (data, error) => {
|
||||||
|
toast.dismiss(load);
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
toast.error(error.message);
|
||||||
|
} else {
|
||||||
|
setSelectedTags([]);
|
||||||
|
onClose();
|
||||||
|
toast.success(t("deleted"));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal toggleModal={onClose}>
|
||||||
|
<p className="text-xl font-thin text-red-500">
|
||||||
|
{selectedTags.length === 1
|
||||||
|
? t("delete_tag")
|
||||||
|
: t("delete_tags", { count: selectedTags.length })}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<Separator className="my-3" />
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-3">
|
||||||
|
<p>
|
||||||
|
{selectedTags.length === 1
|
||||||
|
? t("tag_deletion_confirmation_message")
|
||||||
|
: t("tags_deletion_confirmation_message", {
|
||||||
|
count: selectedTags.length,
|
||||||
|
})}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<Button className="ml-auto" variant="destructive" onClick={deleteTag}>
|
||||||
|
<i className="bi-trash text-xl" />
|
||||||
|
{t("delete")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -16,7 +16,7 @@ type Props = {
|
|||||||
|
|
||||||
export default function BulkEditLinksModal({ onClose }: Props) {
|
export default function BulkEditLinksModal({ onClose }: Props) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { selectedLinks, setSelectedLinks } = useLinkStore();
|
const { selectedIds, clearSelected, selectionCount } = useLinkStore();
|
||||||
const [submitLoader, setSubmitLoader] = useState(false);
|
const [submitLoader, setSubmitLoader] = useState(false);
|
||||||
const [removePreviousTags, setRemovePreviousTags] = useState(false);
|
const [removePreviousTags, setRemovePreviousTags] = useState(false);
|
||||||
const [updatedValues, setUpdatedValues] = useState<
|
const [updatedValues, setUpdatedValues] = useState<
|
||||||
@@ -40,9 +40,13 @@ export default function BulkEditLinksModal({ onClose }: Props) {
|
|||||||
|
|
||||||
const load = toast.loading(t("updating"));
|
const load = toast.loading(t("updating"));
|
||||||
|
|
||||||
|
const links = Object.keys(selectedIds).map((k) => ({
|
||||||
|
id: Number(k),
|
||||||
|
}));
|
||||||
|
|
||||||
await updateLinks.mutateAsync(
|
await updateLinks.mutateAsync(
|
||||||
{
|
{
|
||||||
links: selectedLinks,
|
links,
|
||||||
newData: updatedValues,
|
newData: updatedValues,
|
||||||
removePreviousTags,
|
removePreviousTags,
|
||||||
},
|
},
|
||||||
@@ -54,7 +58,7 @@ export default function BulkEditLinksModal({ onClose }: Props) {
|
|||||||
if (error) {
|
if (error) {
|
||||||
toast.error(error.message);
|
toast.error(error.message);
|
||||||
} else {
|
} else {
|
||||||
setSelectedLinks([]);
|
clearSelected();
|
||||||
onClose();
|
onClose();
|
||||||
toast.success(t("updated"));
|
toast.success(t("updated"));
|
||||||
}
|
}
|
||||||
@@ -67,9 +71,9 @@ export default function BulkEditLinksModal({ onClose }: Props) {
|
|||||||
return (
|
return (
|
||||||
<Modal toggleModal={onClose}>
|
<Modal toggleModal={onClose}>
|
||||||
<p className="text-xl font-thin">
|
<p className="text-xl font-thin">
|
||||||
{selectedLinks.length === 1
|
{selectionCount === 1
|
||||||
? t("edit_link")
|
? t("edit_link")
|
||||||
: t("edit_links", { count: selectedLinks.length })}
|
: t("edit_links", { count: selectionCount })}
|
||||||
</p>
|
</p>
|
||||||
<Separator className="my-3" />
|
<Separator className="my-3" />
|
||||||
|
|
||||||
|
|||||||
58
apps/web/components/ModalContent/DeleteTagModal.tsx
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
import React, { useEffect, useState } from "react";
|
||||||
|
import { TagIncludingLinkCount } from "@linkwarden/types";
|
||||||
|
import Modal from "../Modal";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { useTranslation } from "next-i18next";
|
||||||
|
import toast from "react-hot-toast";
|
||||||
|
import { Separator } from "../ui/separator";
|
||||||
|
import { useRemoveTag } from "@linkwarden/router/tags";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
onClose: Function;
|
||||||
|
activeTag: TagIncludingLinkCount;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function DeleteTagModal({ onClose, activeTag }: Props) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const [tag, setTag] = useState<TagIncludingLinkCount>(activeTag);
|
||||||
|
|
||||||
|
const deleteTag = useRemoveTag();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setTag(activeTag);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const submit = async () => {
|
||||||
|
const load = toast.loading(t("deleting"));
|
||||||
|
|
||||||
|
await deleteTag.mutateAsync(tag.id as number, {
|
||||||
|
onSettled: (data, error) => {
|
||||||
|
toast.dismiss(load);
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
toast.error(error.message);
|
||||||
|
} else {
|
||||||
|
toast.success(t("deleted"));
|
||||||
|
onClose();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal toggleModal={onClose}>
|
||||||
|
<p className="text-xl font-thin text-red-500">{t("delete_tag")}</p>
|
||||||
|
|
||||||
|
<Separator className="my-3" />
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-3">
|
||||||
|
<p>{t("tag_deletion_confirmation_message")}</p>
|
||||||
|
|
||||||
|
<Button className="ml-auto" variant="destructive" onClick={submit}>
|
||||||
|
<i className="bi-trash text-xl" />
|
||||||
|
{t("delete")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
75
apps/web/components/ModalContent/MergeTagsModal.tsx
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
import React, { useState } from "react";
|
||||||
|
import Modal from "../Modal";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { useTranslation } from "next-i18next";
|
||||||
|
import toast from "react-hot-toast";
|
||||||
|
import { Separator } from "../ui/separator";
|
||||||
|
import { useMergeTags } from "@linkwarden/router/tags";
|
||||||
|
import TextInput from "../TextInput";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
onClose: Function;
|
||||||
|
selectedTags: number[];
|
||||||
|
setSelectedTags: (tags: number[]) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function MergeTagsModal({
|
||||||
|
onClose,
|
||||||
|
selectedTags,
|
||||||
|
setSelectedTags,
|
||||||
|
}: Props) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
const [newTagName, setNewTagName] = useState("");
|
||||||
|
|
||||||
|
const mergeTags = useMergeTags();
|
||||||
|
|
||||||
|
const merge = async () => {
|
||||||
|
const load = toast.loading(t("merging"));
|
||||||
|
|
||||||
|
await mergeTags.mutateAsync(
|
||||||
|
{
|
||||||
|
tagIds: selectedTags,
|
||||||
|
newTagName,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
onSettled: (data, error) => {
|
||||||
|
toast.dismiss(load);
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
toast.error(error.message);
|
||||||
|
} else {
|
||||||
|
setSelectedTags([]);
|
||||||
|
onClose();
|
||||||
|
toast.success(t("deleted"));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal toggleModal={onClose}>
|
||||||
|
<p className="text-xl font-thin">
|
||||||
|
{t("merge_count_tags", { count: selectedTags.length })}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<Separator className="my-3" />
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-3">
|
||||||
|
<p>{t("rename_tag_instruction")}</p>
|
||||||
|
|
||||||
|
<TextInput
|
||||||
|
value={newTagName}
|
||||||
|
onChange={(e) => setNewTagName(e.target.value)}
|
||||||
|
placeholder={t("tag_name_placeholder")}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Button className="ml-auto" variant="accent" onClick={merge}>
|
||||||
|
<i className="bi-intersect text-xl" />
|
||||||
|
{t("merge_tags")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
77
apps/web/components/ModalContent/NewTagModal.tsx
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
import React, { useLayoutEffect, useRef, useState } from "react";
|
||||||
|
import TextInput from "@/components/TextInput";
|
||||||
|
import toast from "react-hot-toast";
|
||||||
|
import Modal from "../Modal";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { useTranslation } from "next-i18next";
|
||||||
|
import { Separator } from "../ui/separator";
|
||||||
|
import { useUpsertTags } from "@linkwarden/router/tags";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
onClose: Function;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function NewTagModal({ onClose }: Props) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const upsertTags = useUpsertTags();
|
||||||
|
|
||||||
|
const initial = {
|
||||||
|
label: "",
|
||||||
|
};
|
||||||
|
|
||||||
|
const [tag, setTag] = useState(initial);
|
||||||
|
const [submitLoader, setSubmitLoader] = useState(false);
|
||||||
|
|
||||||
|
const submit = async () => {
|
||||||
|
if (!submitLoader) {
|
||||||
|
setSubmitLoader(true);
|
||||||
|
|
||||||
|
const load = toast.loading(t("creating"));
|
||||||
|
|
||||||
|
await upsertTags.mutateAsync([tag], {
|
||||||
|
onSettled: (data, error) => {
|
||||||
|
setSubmitLoader(false);
|
||||||
|
toast.dismiss(load);
|
||||||
|
if (error) {
|
||||||
|
toast.error(t(error.message));
|
||||||
|
} else {
|
||||||
|
onClose();
|
||||||
|
toast.success(t("created"));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
inputRef.current?.focus();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal toggleModal={onClose}>
|
||||||
|
<p className="text-xl font-thin">{t("create_new_tag")}</p>
|
||||||
|
|
||||||
|
<Separator className="my-3" />
|
||||||
|
|
||||||
|
<div className="w-full">
|
||||||
|
<p className="mb-2">{t("name")}</p>
|
||||||
|
|
||||||
|
<TextInput
|
||||||
|
ref={inputRef}
|
||||||
|
value={tag.label}
|
||||||
|
onChange={(e) => setTag({ ...tag, label: e.target.value })}
|
||||||
|
className="bg-base-200"
|
||||||
|
placeholder={t("tag_name_placeholder")}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex justify-end items-center mt-5">
|
||||||
|
<Button variant="accent" onClick={submit} disabled={!tag.label.trim()}>
|
||||||
|
{t("create_new_tag")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -24,10 +24,17 @@ import {
|
|||||||
TooltipProvider,
|
TooltipProvider,
|
||||||
TooltipTrigger,
|
TooltipTrigger,
|
||||||
} from "@/components/ui/tooltip";
|
} from "@/components/ui/tooltip";
|
||||||
|
import { useUser } from "@linkwarden/router/user";
|
||||||
|
import Link from "next/link";
|
||||||
|
|
||||||
|
const STRIPE_ENABLED = process.env.NEXT_PUBLIC_STRIPE === "true";
|
||||||
|
const TRIAL_PERIOD_DAYS =
|
||||||
|
Number(process.env.NEXT_PUBLIC_TRIAL_PERIOD_DAYS) || 14;
|
||||||
|
|
||||||
export default function Navbar() {
|
export default function Navbar() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
const { data: user } = useUser();
|
||||||
|
|
||||||
const [sidebar, setSidebar] = useState(false);
|
const [sidebar, setSidebar] = useState(false);
|
||||||
|
|
||||||
@@ -50,87 +57,126 @@ export default function Navbar() {
|
|||||||
const [newCollectionModal, setNewCollectionModal] = useState(false);
|
const [newCollectionModal, setNewCollectionModal] = useState(false);
|
||||||
const [uploadFileModal, setUploadFileModal] = useState(false);
|
const [uploadFileModal, setUploadFileModal] = useState(false);
|
||||||
|
|
||||||
|
const [daysLeft, setDaysLeft] = useState<number>(0);
|
||||||
|
const [isTrialing, setIsTrialing] = useState<boolean>(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (user?.createdAt) {
|
||||||
|
const trialEndTime =
|
||||||
|
new Date(user.createdAt).getTime() +
|
||||||
|
(1 + Number(TRIAL_PERIOD_DAYS)) * 86400000; // Add 1 to account for the current day
|
||||||
|
|
||||||
|
setDaysLeft(Math.floor((trialEndTime - Date.now()) / 86400000));
|
||||||
|
}
|
||||||
|
}, [user]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const isTrialing =
|
||||||
|
user?.id &&
|
||||||
|
!user?.subscription?.active &&
|
||||||
|
!user.parentSubscription?.active;
|
||||||
|
|
||||||
|
setIsTrialing(Boolean(isTrialing));
|
||||||
|
}, [user, daysLeft]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex justify-between gap-2 items-center pl-3 pr-4 py-2 border-solid border-b-neutral-content border-b">
|
<>
|
||||||
<Button
|
{STRIPE_ENABLED && isTrialing && (
|
||||||
variant="ghost"
|
<Link
|
||||||
size="icon"
|
href="/subscribe"
|
||||||
className="text-neutral lg:hidden sm:inline-flex"
|
className="w-full text-sm cursor-pointer select-none bg-base-200"
|
||||||
onClick={() => {
|
>
|
||||||
setSidebar(true);
|
<p className="w-full text-center flex items-center justify-center gap-1 underline decoration-dotted underline-offset-4 hover:opacity-70 duration-200 py-1 px-2">
|
||||||
document.body.style.overflow = "hidden";
|
<i className="bi-clock text-primary" />
|
||||||
}}
|
{daysLeft === 1
|
||||||
>
|
? t("trial_left_singular")
|
||||||
<i className="bi-list text-xl leading-none" />
|
: t("trial_left_plural", { count: daysLeft })}
|
||||||
</Button>
|
</p>
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
<div className="flex justify-between gap-2 items-center pl-3 pr-4 py-2 border-solid border-b-neutral-content border-b">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="text-neutral lg:hidden sm:inline-flex"
|
||||||
|
onClick={() => {
|
||||||
|
setSidebar(true);
|
||||||
|
document.body.style.overflow = "hidden";
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<i className="bi-list text-xl leading-none" />
|
||||||
|
</Button>
|
||||||
|
|
||||||
<SearchBar />
|
<SearchBar />
|
||||||
|
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<ToggleDarkMode hideInMobile />
|
<ToggleDarkMode hideInMobile />
|
||||||
|
|
||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
<DropdownMenuTrigger className="hidden sm:inline-grid">
|
<DropdownMenuTrigger className="hidden sm:inline-grid">
|
||||||
<TooltipProvider>
|
<TooltipProvider>
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
<TooltipTrigger>
|
<TooltipTrigger>
|
||||||
<Button
|
<Button
|
||||||
variant="accent"
|
variant="accent"
|
||||||
size="sm"
|
size="sm"
|
||||||
className="min-w-[3.4rem] h-[2rem] relative"
|
className="min-w-[3.4rem] h-[2rem] relative"
|
||||||
>
|
>
|
||||||
<span>
|
<span>
|
||||||
<i className="bi-plus text-4xl absolute -top-[0.3rem] left-0 pointer-events-none" />
|
<i className="bi-plus text-4xl absolute -top-[0.3rem] left-0 pointer-events-none" />
|
||||||
</span>
|
</span>
|
||||||
<span>
|
<span>
|
||||||
<i className="bi-caret-down-fill text-xs absolute top-[0.6rem] right-[0.4rem] pointer-events-none" />
|
<i className="bi-caret-down-fill text-xs absolute top-[0.6rem] right-[0.4rem] pointer-events-none" />
|
||||||
</span>
|
</span>
|
||||||
</Button>
|
</Button>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent>
|
<TooltipContent>
|
||||||
<p>{t("create_new")}</p>
|
<p>{t("create_new")}</p>
|
||||||
</TooltipContent>
|
</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</TooltipProvider>
|
</TooltipProvider>
|
||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
|
|
||||||
<DropdownMenuContent align="end">
|
<DropdownMenuContent align="end">
|
||||||
<DropdownMenuItem onSelect={() => setNewLinkModal(true)}>
|
<DropdownMenuItem onSelect={() => setNewLinkModal(true)}>
|
||||||
<i className="bi-link-45deg" />
|
<i className="bi-link-45deg" />
|
||||||
{t("new_link")}
|
{t("new_link")}
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
<DropdownMenuItem onSelect={() => setUploadFileModal(true)}>
|
<DropdownMenuItem onSelect={() => setUploadFileModal(true)}>
|
||||||
<i className="bi-file-earmark-arrow-up" />
|
<i className="bi-file-earmark-arrow-up" />
|
||||||
{t("upload_file")}
|
{t("upload_file")}
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
<DropdownMenuItem onSelect={() => setNewCollectionModal(true)}>
|
<DropdownMenuItem onSelect={() => setNewCollectionModal(true)}>
|
||||||
<i className="bi-folder" />
|
<i className="bi-folder" />
|
||||||
{t("new_collection")}
|
{t("new_collection")}
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
</DropdownMenuContent>
|
</DropdownMenuContent>
|
||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
|
|
||||||
<ProfileDropdown />
|
<ProfileDropdown />
|
||||||
</div>
|
|
||||||
|
|
||||||
<MobileNavigation />
|
|
||||||
|
|
||||||
{sidebar && (
|
|
||||||
<div className="fixed top-0 bottom-0 right-0 left-0 bg-black bg-opacity-10 backdrop-blur-sm flex items-center fade-in z-40">
|
|
||||||
<ClickAwayHandler className="h-full" onClickOutside={toggleSidebar}>
|
|
||||||
<div className="slide-right h-full shadow-lg">
|
|
||||||
<Sidebar />
|
|
||||||
</div>
|
|
||||||
</ClickAwayHandler>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
{newLinkModal && <NewLinkModal onClose={() => setNewLinkModal(false)} />}
|
<MobileNavigation />
|
||||||
{newCollectionModal && (
|
|
||||||
<NewCollectionModal onClose={() => setNewCollectionModal(false)} />
|
{sidebar && (
|
||||||
)}
|
<div className="fixed top-0 bottom-0 right-0 left-0 bg-black bg-opacity-10 backdrop-blur-sm flex items-center fade-in z-40">
|
||||||
{uploadFileModal && (
|
<ClickAwayHandler className="h-full" onClickOutside={toggleSidebar}>
|
||||||
<UploadFileModal onClose={() => setUploadFileModal(false)} />
|
<div className="slide-right h-full shadow-lg">
|
||||||
)}
|
<Sidebar />
|
||||||
</div>
|
</div>
|
||||||
|
</ClickAwayHandler>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{newLinkModal && (
|
||||||
|
<NewLinkModal onClose={() => setNewLinkModal(false)} />
|
||||||
|
)}
|
||||||
|
{newCollectionModal && (
|
||||||
|
<NewCollectionModal onClose={() => setNewCollectionModal(false)} />
|
||||||
|
)}
|
||||||
|
{uploadFileModal && (
|
||||||
|
<UploadFileModal onClose={() => setUploadFileModal(false)} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -34,8 +34,8 @@ export default function NoLinksFound({ text }: Props) {
|
|||||||
}}
|
}}
|
||||||
variant="accent"
|
variant="accent"
|
||||||
>
|
>
|
||||||
<i className="bi-plus-lg text-3xl left-2 group-hover:ml-[4rem] absolute duration-100"></i>
|
<i className="bi-plus-lg text-xl duration-100"></i>
|
||||||
<span className="group-hover:opacity-0 text-right w-full duration-100">
|
<span className="group-hover:opacity-0 w-full duration-100">
|
||||||
{t("create_new_link")}
|
{t("create_new_link")}
|
||||||
</span>
|
</span>
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -66,6 +66,16 @@ export const PreservationContent: React.FC<Props> = ({ link, format }) => {
|
|||||||
}
|
}
|
||||||
}, [currentFormat]);
|
}, [currentFormat]);
|
||||||
|
|
||||||
|
const imgRef = useRef<HTMLImageElement | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const img = imgRef.current;
|
||||||
|
if (!img) return;
|
||||||
|
if (img.complete && img.naturalWidth > 0) {
|
||||||
|
setImageLoaded(true);
|
||||||
|
}
|
||||||
|
}, [currentFormat, link?.id, link?.updatedAt]);
|
||||||
|
|
||||||
if (!link?.id) return null;
|
if (!link?.id) return null;
|
||||||
|
|
||||||
const renderFormat = () => {
|
const renderFormat = () => {
|
||||||
@@ -126,6 +136,7 @@ export const PreservationContent: React.FC<Props> = ({ link, format }) => {
|
|||||||
>
|
>
|
||||||
<img
|
<img
|
||||||
alt=""
|
alt=""
|
||||||
|
ref={imgRef}
|
||||||
src={`/api/v1/archives/${link.id}?format=${currentFormat}`}
|
src={`/api/v1/archives/${link.id}?format=${currentFormat}`}
|
||||||
className={clsx("w-fit mx-auto", !imageLoaded && "hidden")}
|
className={clsx("w-fit mx-auto", !imageLoaded && "hidden")}
|
||||||
onLoad={(e) => {
|
onLoad={(e) => {
|
||||||
|
|||||||
@@ -28,11 +28,10 @@ import HighlightDrawer from "../HighlightDrawer";
|
|||||||
type Props = {
|
type Props = {
|
||||||
link: LinkIncludingShortenedCollectionAndTags;
|
link: LinkIncludingShortenedCollectionAndTags;
|
||||||
format?: ArchivedFormat;
|
format?: ArchivedFormat;
|
||||||
showNavbar: boolean;
|
|
||||||
className?: string;
|
className?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
const PreservationNavbar = ({ link, format, showNavbar, className }: Props) => {
|
const PreservationNavbar = ({ link, format, className }: Props) => {
|
||||||
const { data: collections = [] } = useCollections();
|
const { data: collections = [] } = useCollections();
|
||||||
|
|
||||||
const [collection, setCollection] =
|
const [collection, setCollection] =
|
||||||
@@ -83,8 +82,7 @@ const PreservationNavbar = ({ link, format, showNavbar, className }: Props) => {
|
|||||||
<>
|
<>
|
||||||
<div
|
<div
|
||||||
className={clsx(
|
className={clsx(
|
||||||
"p-2 z-10 bg-base-100 flex gap-2 justify-between transform transition-transform duration-200 ease-in-out fixed top-0 left-0 right-0",
|
"p-2 z-10 bg-base-100 flex gap-2 justify-between fixed top-0 left-0 right-0",
|
||||||
showNavbar ? "translate-y-0" : "-translate-y-full",
|
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
@@ -217,7 +215,7 @@ const PreservationNavbar = ({ link, format, showNavbar, className }: Props) => {
|
|||||||
<ToggleDarkMode />
|
<ToggleDarkMode />
|
||||||
<LinkActions
|
<LinkActions
|
||||||
link={link}
|
link={link}
|
||||||
collection={collection}
|
t={t}
|
||||||
linkModal={linkModal}
|
linkModal={linkModal}
|
||||||
setLinkModal={(e) => setLinkModal(e)}
|
setLinkModal={(e) => setLinkModal(e)}
|
||||||
ghost
|
ghost
|
||||||
|
|||||||
@@ -3,15 +3,12 @@ import { useRouter } from "next/router";
|
|||||||
import { useGetLink, useLinks } from "@linkwarden/router/links";
|
import { useGetLink, useLinks } from "@linkwarden/router/links";
|
||||||
import { PreservationContent } from "./PreservationContent";
|
import { PreservationContent } from "./PreservationContent";
|
||||||
import PreservationNavbar from "./PreservationNavbar";
|
import PreservationNavbar from "./PreservationNavbar";
|
||||||
import { ArchivedFormat } from "@linkwarden/types";
|
|
||||||
|
|
||||||
export default function PreservationPageContent() {
|
export default function PreservationPageContent() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const { links } = useLinks();
|
const { links } = useLinks();
|
||||||
|
|
||||||
const [showNavbar, setShowNavbar] = useState(true);
|
|
||||||
const scrollRef = useRef<HTMLDivElement>(null);
|
const scrollRef = useRef<HTMLDivElement>(null);
|
||||||
const lastScrollTop = useRef(0);
|
|
||||||
|
|
||||||
let isPublicRoute = router.pathname.startsWith("/public") ? true : undefined;
|
let isPublicRoute = router.pathname.startsWith("/public") ? true : undefined;
|
||||||
|
|
||||||
@@ -41,41 +38,13 @@ export default function PreservationPageContent() {
|
|||||||
};
|
};
|
||||||
}, [links]);
|
}, [links]);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const container = scrollRef.current;
|
|
||||||
if (!container) return;
|
|
||||||
|
|
||||||
const onScroll = () => {
|
|
||||||
const st = container.scrollTop;
|
|
||||||
// if scrolling down and beyond a small threshold, hide
|
|
||||||
if (st - 10 > lastScrollTop.current) {
|
|
||||||
if (Number(router.query.format) === ArchivedFormat.readability)
|
|
||||||
setShowNavbar(false);
|
|
||||||
}
|
|
||||||
// if scrolling up, show
|
|
||||||
else if (st < lastScrollTop.current - 10) {
|
|
||||||
setShowNavbar(true);
|
|
||||||
}
|
|
||||||
lastScrollTop.current = st <= 0 ? 0 : st; // for Mobile or negative
|
|
||||||
};
|
|
||||||
|
|
||||||
container.addEventListener("scroll", onScroll, { passive: true });
|
|
||||||
return () => container.removeEventListener("scroll", onScroll);
|
|
||||||
}, [router.query.format]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
{link?.id && (
|
{link?.id && (
|
||||||
<PreservationNavbar
|
<PreservationNavbar link={link} format={Number(router.query.format)} />
|
||||||
link={link}
|
|
||||||
format={Number(router.query.format)}
|
|
||||||
showNavbar={showNavbar}
|
|
||||||
/>
|
|
||||||
)}
|
)}
|
||||||
<div
|
<div
|
||||||
className={`bg-base-200 overflow-y-auto w-screen ${
|
className={`bg-base-200 overflow-y-auto w-screen h-[calc(100vh-3.1rem)] mt-[3.1rem]`}
|
||||||
showNavbar ? "h-[calc(100vh-3.1rem)] mt-[3.1rem]" : "h-screen"
|
|
||||||
}`}
|
|
||||||
ref={scrollRef}
|
ref={scrollRef}
|
||||||
>
|
>
|
||||||
<PreservationContent link={link} format={Number(router.query.format)} />
|
<PreservationContent link={link} format={Number(router.query.format)} />
|
||||||
|
|||||||
@@ -57,6 +57,7 @@ export default function ProfilePhoto({
|
|||||||
draggable={false}
|
draggable={false}
|
||||||
onError={() => setImage("")}
|
onError={() => setImage("")}
|
||||||
className="aspect-square rounded-full"
|
className="aspect-square rounded-full"
|
||||||
|
unoptimized
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import Link from "next/link";
|
|
||||||
import { useRouter } from "next/router";
|
import { useRouter } from "next/router";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { Disclosure, Transition } from "@headlessui/react";
|
import { Disclosure, Transition } from "@headlessui/react";
|
||||||
@@ -7,9 +6,27 @@ import CollectionListing from "@/components/CollectionListing";
|
|||||||
import { useTranslation } from "next-i18next";
|
import { useTranslation } from "next-i18next";
|
||||||
import { useCollections } from "@linkwarden/router/collections";
|
import { useCollections } from "@linkwarden/router/collections";
|
||||||
import { useTags } from "@linkwarden/router/tags";
|
import { useTags } from "@linkwarden/router/tags";
|
||||||
import { TagListing } from "./TagListing";
|
import TagListing from "./TagListing";
|
||||||
|
import { Button } from "./ui/button";
|
||||||
|
import { useUser } from "@linkwarden/router/user";
|
||||||
|
import Image from "next/image";
|
||||||
|
import {
|
||||||
|
Tooltip,
|
||||||
|
TooltipContent,
|
||||||
|
TooltipProvider,
|
||||||
|
TooltipTrigger,
|
||||||
|
} from "@/components/ui/tooltip";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
export default function Sidebar({ className }: { className?: string }) {
|
export default function Sidebar({
|
||||||
|
className,
|
||||||
|
toggleSidebar,
|
||||||
|
sidebarIsCollapsed,
|
||||||
|
}: {
|
||||||
|
className?: string;
|
||||||
|
toggleSidebar?: () => void;
|
||||||
|
sidebarIsCollapsed?: boolean;
|
||||||
|
}) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [tagDisclosure, setTagDisclosure] = useState<boolean>(() => {
|
const [tagDisclosure, setTagDisclosure] = useState<boolean>(() => {
|
||||||
const storedValue = localStorage.getItem("tagDisclosure");
|
const storedValue = localStorage.getItem("tagDisclosure");
|
||||||
@@ -30,6 +47,8 @@ export default function Sidebar({ className }: { className?: string }) {
|
|||||||
|
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
|
const { data: user } = useUser();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
localStorage.setItem("tagDisclosure", tagDisclosure ? "true" : "false");
|
localStorage.setItem("tagDisclosure", tagDisclosure ? "true" : "false");
|
||||||
}, [tagDisclosure]);
|
}, [tagDisclosure]);
|
||||||
@@ -48,99 +67,204 @@ export default function Sidebar({ className }: { className?: string }) {
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
id="sidebar"
|
id="sidebar"
|
||||||
className={`bg-base-200 h-full w-80 overflow-y-auto border-solid border border-base-200 border-r-neutral-content p-2 z-20 ${
|
className={cn(
|
||||||
className || ""
|
"bg-base-200 h-screen overflow-y-auto border-solid border border-base-200 border-r-neutral-content p-2 z-20",
|
||||||
}`}
|
className,
|
||||||
|
sidebarIsCollapsed ? "w-14" : "w-80"
|
||||||
|
)}
|
||||||
>
|
>
|
||||||
<div className="grid grid-cols-2 gap-2">
|
<div
|
||||||
<SidebarHighlightLink
|
className={cn(
|
||||||
title={t("dashboard")}
|
"flex flex-col",
|
||||||
href={`/dashboard`}
|
sidebarIsCollapsed
|
||||||
icon={"bi-house"}
|
? "my-auto h-full justify-between items-center gap-3"
|
||||||
active={active === `/dashboard`}
|
: "gap-1"
|
||||||
/>
|
)}
|
||||||
<SidebarHighlightLink
|
>
|
||||||
title={t("pinned")}
|
<div className="flex items-center justify-between mb-4">
|
||||||
href={`/links/pinned`}
|
{sidebarIsCollapsed ? (
|
||||||
icon={"bi-pin-angle"}
|
<Image
|
||||||
active={active === `/links/pinned`}
|
src={"/icon.png"}
|
||||||
/>
|
width={640}
|
||||||
<SidebarHighlightLink
|
height={136}
|
||||||
title={t("all_links")}
|
alt="Linkwarden Icon"
|
||||||
href={`/links`}
|
className="h-8 w-auto cursor-pointer"
|
||||||
icon={"bi-link-45deg"}
|
onClick={() => router.push("/dashboard")}
|
||||||
active={active === `/links`}
|
priority
|
||||||
/>
|
/>
|
||||||
<SidebarHighlightLink
|
) : user?.theme === "light" ? (
|
||||||
title={t("all_collections")}
|
<Image
|
||||||
href={`/collections`}
|
src={"/linkwarden_light.png"}
|
||||||
icon={"bi-folder"}
|
width={640}
|
||||||
active={active === `/collections`}
|
height={136}
|
||||||
/>
|
alt="Linkwarden"
|
||||||
|
className="h-9 w-auto cursor-pointer"
|
||||||
|
onClick={() => router.push("/dashboard")}
|
||||||
|
priority
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<Image
|
||||||
|
src={"/linkwarden_dark.png"}
|
||||||
|
width={640}
|
||||||
|
height={136}
|
||||||
|
alt="Linkwarden"
|
||||||
|
className="h-9 w-auto cursor-pointer"
|
||||||
|
onClick={() => router.push("/dashboard")}
|
||||||
|
priority
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!sidebarIsCollapsed && (
|
||||||
|
<div className="hidden lg:block">
|
||||||
|
<TooltipProvider>
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
onClick={toggleSidebar}
|
||||||
|
size={"icon"}
|
||||||
|
>
|
||||||
|
<i className={`bi-layout-sidebar`} />
|
||||||
|
</Button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>
|
||||||
|
{sidebarIsCollapsed
|
||||||
|
? t("expand_sidebar")
|
||||||
|
: t("shrink_sidebar")}
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</TooltipProvider>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"flex flex-col",
|
||||||
|
sidebarIsCollapsed ? "my-auto justify-center gap-3" : "gap-1"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<SidebarHighlightLink
|
||||||
|
title={t("dashboard")}
|
||||||
|
href={`/dashboard`}
|
||||||
|
icon={"bi-house"}
|
||||||
|
active={active === `/dashboard`}
|
||||||
|
sidebarIsCollapsed={sidebarIsCollapsed}
|
||||||
|
/>
|
||||||
|
<SidebarHighlightLink
|
||||||
|
title={t("links")}
|
||||||
|
href={`/links`}
|
||||||
|
icon={"bi-link-45deg"}
|
||||||
|
active={active === `/links`}
|
||||||
|
sidebarIsCollapsed={sidebarIsCollapsed}
|
||||||
|
/>
|
||||||
|
<SidebarHighlightLink
|
||||||
|
title={t("pinned")}
|
||||||
|
href={`/links/pinned`}
|
||||||
|
icon={"bi-pin-angle"}
|
||||||
|
active={active === `/links/pinned`}
|
||||||
|
sidebarIsCollapsed={sidebarIsCollapsed}
|
||||||
|
/>
|
||||||
|
<SidebarHighlightLink
|
||||||
|
title={t("collections")}
|
||||||
|
href={`/collections`}
|
||||||
|
icon={"bi-folder"}
|
||||||
|
active={active === `/collections`}
|
||||||
|
sidebarIsCollapsed={sidebarIsCollapsed}
|
||||||
|
/>
|
||||||
|
<SidebarHighlightLink
|
||||||
|
title={t("tags")}
|
||||||
|
href={`/tags`}
|
||||||
|
icon={"bi-hash"}
|
||||||
|
active={active === `/tags`}
|
||||||
|
sidebarIsCollapsed={sidebarIsCollapsed}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{sidebarIsCollapsed && (
|
||||||
|
<TooltipProvider>
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<Button variant="ghost" onClick={toggleSidebar} size={"icon"}>
|
||||||
|
<i className={`bi-layout-sidebar`} />
|
||||||
|
</Button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent side="right">
|
||||||
|
{sidebarIsCollapsed ? t("expand_sidebar") : t("shrink_sidebar")}
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</TooltipProvider>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Disclosure defaultOpen={collectionDisclosure}>
|
{sidebarIsCollapsed ? (
|
||||||
<Disclosure.Button
|
<></>
|
||||||
onClick={() => {
|
) : (
|
||||||
setCollectionDisclosure(!collectionDisclosure);
|
<>
|
||||||
}}
|
<Disclosure defaultOpen={collectionDisclosure}>
|
||||||
className="flex items-center justify-between w-full text-left mb-2 pl-2 font-bold text-neutral mt-5"
|
<Disclosure.Button
|
||||||
>
|
onClick={() => {
|
||||||
<p className="text-sm">{t("collections")}</p>
|
setCollectionDisclosure(!collectionDisclosure);
|
||||||
<i
|
}}
|
||||||
className={`bi-chevron-down ${
|
className="flex items-center justify-between w-full text-left mb-2 pl-2 font-bold text-neutral mt-5"
|
||||||
collectionDisclosure ? "rotate-reverse" : "rotate"
|
>
|
||||||
}`}
|
<p className="text-sm">{t("collections")}</p>
|
||||||
></i>
|
<i
|
||||||
</Disclosure.Button>
|
className={`bi-chevron-down ${
|
||||||
<Transition
|
collectionDisclosure ? "rotate-reverse" : "rotate"
|
||||||
enter="transition duration-100 ease-out"
|
}`}
|
||||||
enterFrom="transform opacity-0 -translate-y-3"
|
></i>
|
||||||
enterTo="transform opacity-100 translate-y-0"
|
</Disclosure.Button>
|
||||||
leave="transition duration-100 ease-out"
|
<Transition
|
||||||
leaveFrom="transform opacity-100 translate-y-0"
|
enter="transition duration-100 ease-out"
|
||||||
leaveTo="transform opacity-0 -translate-y-3"
|
enterFrom="transform opacity-0 -translate-y-3"
|
||||||
>
|
enterTo="transform opacity-100 translate-y-0"
|
||||||
<Disclosure.Panel>
|
leave="transition duration-100 ease-out"
|
||||||
<CollectionListing />
|
leaveFrom="transform opacity-100 translate-y-0"
|
||||||
</Disclosure.Panel>
|
leaveTo="transform opacity-0 -translate-y-3"
|
||||||
</Transition>
|
>
|
||||||
</Disclosure>
|
<Disclosure.Panel>
|
||||||
<Disclosure defaultOpen={tagDisclosure}>
|
<CollectionListing />
|
||||||
<Disclosure.Button
|
</Disclosure.Panel>
|
||||||
onClick={() => {
|
</Transition>
|
||||||
setTagDisclosure(!tagDisclosure);
|
</Disclosure>
|
||||||
}}
|
<Disclosure defaultOpen={tagDisclosure}>
|
||||||
className="flex items-center justify-between w-full text-left mb-2 pl-2 font-bold text-neutral mt-5"
|
<Disclosure.Button
|
||||||
>
|
onClick={() => {
|
||||||
<p className="text-sm">{t("tags")}</p>
|
setTagDisclosure(!tagDisclosure);
|
||||||
<i
|
}}
|
||||||
className={`bi-chevron-down ${
|
className="flex items-center justify-between w-full text-left mb-2 pl-2 font-bold text-neutral mt-5"
|
||||||
tagDisclosure ? "rotate-reverse" : "rotate"
|
>
|
||||||
}`}
|
<p className="text-sm">{t("tags")}</p>
|
||||||
></i>
|
<i
|
||||||
</Disclosure.Button>
|
className={`bi-chevron-down ${
|
||||||
<Transition
|
tagDisclosure ? "rotate-reverse" : "rotate"
|
||||||
enter="transition duration-100 ease-out"
|
}`}
|
||||||
enterFrom="transform opacity-0 -translate-y-3"
|
></i>
|
||||||
enterTo="transform opacity-100 translate-y-0"
|
</Disclosure.Button>
|
||||||
leave="transition duration-100 ease-out"
|
<Transition
|
||||||
leaveFrom="transform opacity-100 translate-y-0"
|
enter="transition duration-100 ease-out"
|
||||||
leaveTo="transform opacity-0 -translate-y-3"
|
enterFrom="transform opacity-0 -translate-y-3"
|
||||||
>
|
enterTo="transform opacity-100 translate-y-0"
|
||||||
<Disclosure.Panel className="flex flex-col gap-1">
|
leave="transition duration-100 ease-out"
|
||||||
{isLoading ? (
|
leaveFrom="transform opacity-100 translate-y-0"
|
||||||
<div className="flex flex-col gap-4">
|
leaveTo="transform opacity-0 -translate-y-3"
|
||||||
<div className="skeleton h-4 w-full"></div>
|
>
|
||||||
<div className="skeleton h-4 w-full"></div>
|
<Disclosure.Panel className="flex flex-col gap-1">
|
||||||
<div className="skeleton h-4 w-full"></div>
|
{isLoading ? (
|
||||||
</div>
|
<div className="flex flex-col gap-4">
|
||||||
) : (
|
<div className="skeleton h-4 w-full"></div>
|
||||||
<TagListing tags={tags} active={active} />
|
<div className="skeleton h-4 w-full"></div>
|
||||||
)}
|
<div className="skeleton h-4 w-full"></div>
|
||||||
</Disclosure.Panel>
|
</div>
|
||||||
</Transition>
|
) : (
|
||||||
</Disclosure>
|
<TagListing tags={tags} active={active} />
|
||||||
|
)}
|
||||||
|
</Disclosure.Panel>
|
||||||
|
</Transition>
|
||||||
|
</Disclosure>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,37 +1,56 @@
|
|||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
|
import {
|
||||||
|
Tooltip,
|
||||||
|
TooltipContent,
|
||||||
|
TooltipProvider,
|
||||||
|
TooltipTrigger,
|
||||||
|
} from "@/components/ui/tooltip";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
export default function SidebarHighlightLink({
|
export default function SidebarHighlightLink({
|
||||||
title,
|
title,
|
||||||
href,
|
href,
|
||||||
icon,
|
icon,
|
||||||
active,
|
active,
|
||||||
|
sidebarIsCollapsed,
|
||||||
}: {
|
}: {
|
||||||
title: string;
|
title: string;
|
||||||
href: string;
|
href: string;
|
||||||
icon: string;
|
icon: string;
|
||||||
active?: boolean;
|
active?: boolean;
|
||||||
|
sidebarIsCollapsed?: boolean;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<Link href={href}>
|
<TooltipProvider>
|
||||||
<div
|
<Tooltip>
|
||||||
title={title}
|
<TooltipTrigger asChild>
|
||||||
className={`${
|
<Link href={href} title={title}>
|
||||||
active || false
|
<div
|
||||||
? "bg-primary/20"
|
className={cn(
|
||||||
: "bg-neutral-content/20 hover:bg-neutral/20"
|
active ? "bg-primary/20" : "hover:bg-neutral/20",
|
||||||
} duration-200 px-3 py-2 cursor-pointer gap-2 w-full rounded-lg capitalize`}
|
"duration-200 cursor-pointer flex items-center gap-2 capitalize",
|
||||||
>
|
sidebarIsCollapsed
|
||||||
<div
|
? "rounded-md h-8 w-8"
|
||||||
className={
|
: "rounded-lg px-3 py-1"
|
||||||
"w-10 h-10 inline-flex items-center justify-center bg-black/10 dark:bg-white/5 rounded-full"
|
)}
|
||||||
}
|
>
|
||||||
>
|
<i
|
||||||
<i className={`${icon} text-primary text-xl drop-shadow`}></i>
|
className={cn(
|
||||||
</div>
|
icon,
|
||||||
<div className={"mt-1"}>
|
"text-primary text-xl drop-shadow",
|
||||||
<p className="truncate w-full font-semibold text-xs">{title}</p>
|
sidebarIsCollapsed && "w-full text-center"
|
||||||
</div>
|
)}
|
||||||
</div>
|
></i>
|
||||||
</Link>
|
{!sidebarIsCollapsed && (
|
||||||
|
<p className="truncate w-full font-semibold text-sm">{title}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
</TooltipTrigger>
|
||||||
|
{sidebarIsCollapsed && (
|
||||||
|
<TooltipContent side="right">{title}</TooltipContent>
|
||||||
|
)}
|
||||||
|
</Tooltip>
|
||||||
|
</TooltipProvider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
118
apps/web/components/TagCard.tsx
Normal file
@@ -0,0 +1,118 @@
|
|||||||
|
import React, { useState } from "react";
|
||||||
|
import { useTranslation } from "next-i18next";
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
} from "@/components/ui/dropdown-menu";
|
||||||
|
import { Button } from "./ui/button";
|
||||||
|
import { Checkbox } from "./ui/checkbox";
|
||||||
|
import { TagIncludingLinkCount } from "@linkwarden/types";
|
||||||
|
import DeleteTagModal from "./ModalContent/DeleteTagModal";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { useRouter } from "next/router";
|
||||||
|
export default function TagCard({
|
||||||
|
tag,
|
||||||
|
editMode,
|
||||||
|
selected,
|
||||||
|
onSelect,
|
||||||
|
}: {
|
||||||
|
tag: TagIncludingLinkCount;
|
||||||
|
editMode: boolean;
|
||||||
|
selected: boolean;
|
||||||
|
onSelect: (tagId: number) => void;
|
||||||
|
}) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
const formattedDate = new Date(tag.createdAt).toLocaleString(t("locale"), {
|
||||||
|
year: "numeric",
|
||||||
|
month: "short",
|
||||||
|
day: "numeric",
|
||||||
|
});
|
||||||
|
|
||||||
|
const [deleteTagModal, setDeleteTagModal] = useState(false);
|
||||||
|
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"relative rounded-xl p-2 shadow-md cursor-pointer hover:shadow-none hover:bg-opacity-70 duration-200 border border-neutral-content",
|
||||||
|
editMode ? "bg-base-300" : "bg-base-200",
|
||||||
|
selected && "border-primary"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{editMode ? (
|
||||||
|
<Checkbox
|
||||||
|
checked={selected}
|
||||||
|
className="absolute top-3 right-3 z-20 pointer-events-none"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="absolute top-2 right-2 z-20"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<i title="More" className="bi-three-dots text-xl text-neutral" />
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
|
||||||
|
<DropdownMenuContent
|
||||||
|
sideOffset={4}
|
||||||
|
side="bottom"
|
||||||
|
align="end"
|
||||||
|
className="z-[30]"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<DropdownMenuItem
|
||||||
|
onSelect={() => setDeleteTagModal(true)}
|
||||||
|
className="text-error"
|
||||||
|
>
|
||||||
|
{t("delete_tag")}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div
|
||||||
|
className="flex gap-2 flex-col"
|
||||||
|
onClick={() =>
|
||||||
|
editMode ? onSelect(tag.id) : router.push(`/tags/${tag.id}`)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<h2 className="truncate leading-tight py-1 pr-8" title={tag.name}>
|
||||||
|
{tag.name}
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<div className="flex justify-between items-center mt-auto">
|
||||||
|
<div className="text-xs flex gap-1 items-center">
|
||||||
|
<i
|
||||||
|
className="bi-calendar3 text-neutral"
|
||||||
|
title={t("collection_publicly_shared")}
|
||||||
|
></i>
|
||||||
|
{formattedDate}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="text-xs flex gap-1 items-center">
|
||||||
|
<i
|
||||||
|
className="bi-link-45deg text-lg leading-none text-neutral"
|
||||||
|
title={t("collection_publicly_shared")}
|
||||||
|
></i>
|
||||||
|
{tag._count?.links}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{deleteTagModal && (
|
||||||
|
<DeleteTagModal
|
||||||
|
onClose={() => setDeleteTagModal(false)}
|
||||||
|
activeTag={tag}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -9,10 +9,14 @@ interface TagListingProps {
|
|||||||
tags: Tag[];
|
tags: Tag[];
|
||||||
active?: string;
|
active?: string;
|
||||||
}
|
}
|
||||||
export function TagListing({ tags, active }: TagListingProps) {
|
|
||||||
|
export default function TagListing({ tags, active }: TagListingProps) {
|
||||||
const { active: droppableActive } = useDndContext();
|
const { active: droppableActive } = useDndContext();
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
const ctx = useDndContext();
|
||||||
|
console.log("DndContext active?", ctx.active);
|
||||||
|
|
||||||
if (!tags[0]) {
|
if (!tags[0]) {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
|
|||||||