Compare commits
238 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 | ||
|
|
395f5c01e1 | ||
|
|
327826d760 | ||
|
|
2441470849 | ||
|
|
be532d5455 |
@@ -5,3 +5,11 @@ pgdata
|
||||
docker-compose.yml
|
||||
Dockerfile
|
||||
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_MODEL=
|
||||
|
||||
# https://ai-sdk.dev/providers/ai-sdk-providers/perplexity
|
||||
PERPLEXITY_API_KEY=
|
||||
PERPLEXITY_MODEL=
|
||||
|
||||
# MeiliSearch Settings
|
||||
MEILI_HOST=
|
||||
MEILI_MASTER_KEY=
|
||||
@@ -127,10 +131,10 @@ AUTH0_CLIENT_SECRET=
|
||||
AUTH0_CLIENT_ID=
|
||||
|
||||
# Authelia
|
||||
NEXT_PUBLIC_AUTHELIA_ENABLED=""
|
||||
AUTHELIA_CLIENT_ID=""
|
||||
AUTHELIA_CLIENT_SECRET=""
|
||||
AUTHELIA_WELLKNOWN_URL=""
|
||||
NEXT_PUBLIC_AUTHELIA_ENABLED=
|
||||
AUTHELIA_CLIENT_ID=
|
||||
AUTHELIA_CLIENT_SECRET=
|
||||
AUTHELIA_WELLKNOWN_URL=
|
||||
|
||||
# Authentik
|
||||
NEXT_PUBLIC_AUTHENTIK_ENABLED=
|
||||
@@ -399,6 +403,13 @@ STRAVA_CUSTOM_NAME=
|
||||
STRAVA_CLIENT_ID=
|
||||
STRAVA_CLIENT_SECRET=
|
||||
|
||||
# Synology
|
||||
NEXT_PUBLIC_SYNOLOGY_ENABLED=
|
||||
SYNOLOGY_CUSTOM_NAME=
|
||||
SYNOLOGY_CLIENT_ID=
|
||||
SYNOLOGY_CLIENT_SECRET=
|
||||
SYNOLOGY_WELLKNOWN_URL=
|
||||
|
||||
# Todoist
|
||||
NEXT_PUBLIC_TODOIST_ENABLED=
|
||||
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
|
||||
ko_fi: # Replace with a single Ko-fi username
|
||||
tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel
|
||||
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']
|
||||
github: daniel31x13
|
||||
buy_me_a_coffee: daniel31x13
|
||||
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,11 +61,17 @@ jobs:
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Use Node.js
|
||||
- name: Use Node.js and Enable Yarn 4
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "18"
|
||||
cache: 'yarn'
|
||||
node-version: "20"
|
||||
|
||||
- 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
|
||||
run: |
|
||||
@@ -74,7 +80,7 @@ jobs:
|
||||
psql -h localhost -U postgres -d postgres -c "CREATE DATABASE ${{ env.TEST_POSTGRES_DATABASE }} OWNER ${{ env.TEST_POSTGRES_USER }};"
|
||||
|
||||
- name: Install packages
|
||||
run: yarn install -y
|
||||
run: yarn install --immutable
|
||||
|
||||
- name: Cache playwright dependencies
|
||||
uses: awalsh128/cache-apt-pkgs-action@latest
|
||||
|
||||
7
.gitignore
vendored
@@ -2,6 +2,7 @@
|
||||
node_modules
|
||||
/.pnp
|
||||
.pnp.js
|
||||
.yarn/install-state.gz
|
||||
|
||||
# testing
|
||||
/coverage
|
||||
@@ -48,7 +49,11 @@ certificates
|
||||
|
||||
# generated files and folders
|
||||
/data
|
||||
/data.ms
|
||||
meilisearch
|
||||
meili_data
|
||||
.idea
|
||||
prisma/dev.db
|
||||
data.ms
|
||||
.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
|
||||
# Notes:
|
||||
# - 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
|
||||
|
||||
@@ -18,6 +24,10 @@ RUN mkdir /data
|
||||
|
||||
WORKDIR /data
|
||||
|
||||
RUN corepack enable
|
||||
|
||||
COPY ./.yarnrc.yml ./
|
||||
|
||||
COPY ./apps/web/package.json ./apps/web/playwright.config.ts ./apps/web/
|
||||
|
||||
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 \
|
||||
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
|
||||
apt-get update && \
|
||||
apt-get install -yqq --no-install-recommends curl ca-certificates && \
|
||||
@@ -46,7 +56,8 @@ RUN set -eux && \
|
||||
COPY . .
|
||||
|
||||
RUN yarn prisma:generate && \
|
||||
yarn web:build
|
||||
yarn web:build && \
|
||||
rm -rf apps/web/.next/cache
|
||||
|
||||
HEALTHCHECK --interval=30s \
|
||||
--timeout=5s \
|
||||
|
||||
67
README.md
@@ -38,32 +38,49 @@ Linkwarden is also designed with collaboration in mind, enabling you to share li
|
||||
|
||||
## Features
|
||||
|
||||
- 📸 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.
|
||||
- 🏛️ 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).
|
||||
- 📂 Organize links by collection, sub-collection, name, description and multiple tags.
|
||||
- 👥 Collaborate on gathering links in a collection.
|
||||
- 🎛️ Customize the permissions of each member.
|
||||
- 🌐 Share your collected links and preserved formats with the world.
|
||||
- 📌 Pin your favorite links to dashboard.
|
||||
- 🔍 Full text search, filter and sort for easy retrieval.
|
||||
- 📱 Responsive design and supports most modern browsers.
|
||||
- 🌓 Dark/Light mode support.
|
||||
- 🧩 Browser extension. [Star it here!](https://github.com/linkwarden/browser-extension)
|
||||
- 📸 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
|
||||
- 🏛️ 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)
|
||||
- 📂 Organize links by collection, sub-collection, name, description and multiple tags
|
||||
- 👥 Collaborate on gathering links in a collection
|
||||
- 🎛️ Customize the permissions of each member
|
||||
- 🌐 Share your collected links and preserved formats with the world
|
||||
- 📱 Native iOS and android mobile apps
|
||||
- 🔍 Full text search, filter and sort for easy retrieval
|
||||
- 🌓 Dark/Light mode support
|
||||
- 🧩 Browser extension (star it [here](https://github.com/linkwarden/browser-extension)!)
|
||||
- 🔄 Browser Synchronization (using [Floccus](https://floccus.org)!)
|
||||
- ⬇️ Import and export your bookmarks.
|
||||
- 🔐 SSO integration. (Enterprise and Self-hosted users only)
|
||||
- 📦 Installable Progressive Web App (PWA).
|
||||
- 🍎 iOS Shortcut to save Links to Linkwarden.
|
||||
- 🔑 API keys.
|
||||
- ✅ Bulk actions.
|
||||
- 👥 User administration.
|
||||
- 🌐 Support for Other Languages (i18n).
|
||||
- 📁 Image and PDF Uploads.
|
||||
- 🎨 Custom Icons for Links and Collections.
|
||||
- 🔔 RSS Feed Subscription.
|
||||
- ✨ And many more features. (Literally!)
|
||||
- ⬆️ Upload from SingleFile (check out the [guide](https://docs.linkwarden.app/Usage/upload-from-singlefile))
|
||||
- 🔐 SSO integration (Enterprise and Self-hosted users only)
|
||||
- 🍎 iOS Shortcut to save links to Linkwarden
|
||||
- 🔑 API keys
|
||||
- ✅ Bulk actions
|
||||
- 👥 User administration
|
||||
- 🌐 Support for other languages (i18n)
|
||||
- 📁 Image and PDF uploads
|
||||
- 🎨 Custom icons for links and collections
|
||||
- 🔔 RSS feed subscription
|
||||
- ✨ 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 ⭐
|
||||
|
||||
|
||||
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/
|
||||
4
apps/mobile/.gitignore
vendored
@@ -40,3 +40,7 @@ app-example
|
||||
|
||||
ios/
|
||||
android/
|
||||
|
||||
service-account-file.json
|
||||
|
||||
.env.local
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
node-linker=hoisted
|
||||
@@ -2,7 +2,7 @@
|
||||
"expo": {
|
||||
"name": "Linkwarden",
|
||||
"slug": "linkwarden",
|
||||
"version": "1.0.0",
|
||||
"version": "1.0.1",
|
||||
"orientation": "portrait",
|
||||
"icon": "./assets/images/icon.png",
|
||||
"scheme": "linkwarden",
|
||||
@@ -10,14 +10,23 @@
|
||||
"newArchEnabled": true,
|
||||
"ios": {
|
||||
"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": {
|
||||
"adaptiveIcon": {
|
||||
"foregroundImage": "./assets/images/adaptive-icon.png",
|
||||
"foregroundImage": "./assets/images/maskable_logo.jpeg",
|
||||
"backgroundColor": "#ffffff"
|
||||
},
|
||||
"package": "com.anonymous.linkwarden"
|
||||
"package": "app.linkwarden"
|
||||
},
|
||||
"web": {
|
||||
"bundler": "metro",
|
||||
@@ -32,11 +41,44 @@
|
||||
"image": "./assets/images/splash-icon.png",
|
||||
"imageWidth": 200,
|
||||
"resizeMode": "contain",
|
||||
"backgroundColor": "#ffffff"
|
||||
"backgroundColor": "#FFFFFF",
|
||||
"dark": {
|
||||
"image": "./assets/images/splash-icon.png",
|
||||
"resizeMode": "contain",
|
||||
"backgroundColor": "#171717"
|
||||
}
|
||||
}
|
||||
],
|
||||
"expo-secure-store",
|
||||
[
|
||||
"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"
|
||||
],
|
||||
"experiments": {
|
||||
@@ -50,6 +92,15 @@
|
||||
"androidNavigationBar": {
|
||||
"backgroundColor": "#ffffff",
|
||||
"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 { useColorScheme } from "nativewind";
|
||||
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() {
|
||||
const { colorScheme } = useColorScheme();
|
||||
@@ -23,11 +23,15 @@ export default function TabLayout() {
|
||||
borderTopWidth: 0,
|
||||
elevation: 0,
|
||||
backgroundColor: rawTheme[colorScheme as ThemeName]["base-200"],
|
||||
paddingLeft: 5,
|
||||
paddingRight: 5,
|
||||
},
|
||||
default: {
|
||||
borderTopWidth: 0,
|
||||
backgroundColor: rawTheme[colorScheme as ThemeName]["base-200"],
|
||||
elevation: 0,
|
||||
paddingLeft: 5,
|
||||
paddingRight: 5,
|
||||
},
|
||||
}),
|
||||
}}
|
||||
@@ -37,7 +41,7 @@ export default function TabLayout() {
|
||||
options={{
|
||||
title: "Dashboard",
|
||||
headerShown: false,
|
||||
tabBarIcon: ({ color }) => <House size={26} color={color} />,
|
||||
tabBarIcon: ({ color }) => <House size={24} color={color} />,
|
||||
}}
|
||||
/>
|
||||
<Tabs.Screen
|
||||
@@ -45,7 +49,23 @@ export default function TabLayout() {
|
||||
options={{
|
||||
title: "Links",
|
||||
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
|
||||
@@ -53,7 +73,7 @@ export default function TabLayout() {
|
||||
options={{
|
||||
title: "Settings",
|
||||
headerShown: false,
|
||||
tabBarIcon: ({ color }) => <Settings size={26} color={color} />,
|
||||
tabBarIcon: ({ color }) => <Settings size={24} color={color} />,
|
||||
}}
|
||||
/>
|
||||
</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 { View, StyleSheet, FlatList, Platform } from "react-native";
|
||||
import { View, StyleSheet, Platform } from "react-native";
|
||||
import useAuthStore from "@/store/auth";
|
||||
import LinkListing from "@/components/LinkListing";
|
||||
import { useLocalSearchParams, useNavigation } from "expo-router";
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { LinkIncludingShortenedCollectionAndTags } from "@linkwarden/types";
|
||||
import React, { useEffect, useMemo } from "react";
|
||||
import { useCollections } from "@linkwarden/router/collections";
|
||||
|
||||
const RenderItem = React.memo(
|
||||
({ item }: { item: LinkIncludingShortenedCollectionAndTags }) => {
|
||||
return <LinkListing link={item} />;
|
||||
}
|
||||
);
|
||||
import Links from "@/components/Links";
|
||||
|
||||
export default function LinksScreen() {
|
||||
const { auth } = useAuthStore();
|
||||
@@ -24,19 +17,28 @@ export default function LinksScreen() {
|
||||
const navigation = useNavigation();
|
||||
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(() => {
|
||||
navigation.setOptions({
|
||||
headerTitle:
|
||||
section === "pinned-links"
|
||||
? "Pinned Links"
|
||||
: section === "recent-links"
|
||||
? "Recent Links"
|
||||
: section === "collection"
|
||||
? collections.data?.find((c) => c.id?.toString() === collectionId)
|
||||
?.name || "Collection"
|
||||
: "Links",
|
||||
headerTitle: title,
|
||||
headerSearchBarOptions: {
|
||||
placeholder: `Search ${title}`,
|
||||
},
|
||||
});
|
||||
}, [section, navigation]);
|
||||
}, [title, navigation]);
|
||||
|
||||
const { links, data } = useLinks(
|
||||
{
|
||||
@@ -55,21 +57,7 @@ export default function LinksScreen() {
|
||||
collapsable={false}
|
||||
collapsableChildren={false}
|
||||
>
|
||||
<FlatList
|
||||
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" />}
|
||||
/>
|
||||
<Links links={links} data={data} />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@ import { Platform, TouchableOpacity } from "react-native";
|
||||
import { SheetManager } from "react-native-actions-sheet";
|
||||
import * as DropdownMenu from "zeego/dropdown-menu";
|
||||
|
||||
export default function RootLayout() {
|
||||
export default function Layout() {
|
||||
const router = useRouter();
|
||||
const { colorScheme } = useColorScheme();
|
||||
|
||||
@@ -54,9 +54,12 @@ export default function RootLayout() {
|
||||
>
|
||||
<DropdownMenu.ItemTitle>New Link</DropdownMenu.ItemTitle>
|
||||
</DropdownMenu.Item>
|
||||
<DropdownMenu.Item key="more-options" disabled>
|
||||
<DropdownMenu.Item
|
||||
key="new-collection"
|
||||
onSelect={() => SheetManager.show("new-collection-sheet")}
|
||||
>
|
||||
<DropdownMenu.ItemTitle>
|
||||
More Coming Soon!
|
||||
New Collection
|
||||
</DropdownMenu.ItemTitle>
|
||||
</DropdownMenu.Item>
|
||||
</DropdownMenu.Content>
|
||||
|
||||
@@ -1,36 +1,22 @@
|
||||
import {
|
||||
FlatList,
|
||||
ActivityIndicator,
|
||||
Platform,
|
||||
RefreshControl,
|
||||
ScrollView,
|
||||
StyleSheet,
|
||||
Text,
|
||||
TouchableOpacity,
|
||||
View,
|
||||
} 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 useAuthStore from "@/store/auth";
|
||||
import React, { useEffect, useMemo, useState } from "react";
|
||||
import { DashboardSection, DashboardSectionType } from "@prisma/client";
|
||||
import { DashboardSection as DashboardSectionType } from "@linkwarden/prisma/client";
|
||||
import { useUser } from "@linkwarden/router/user";
|
||||
import { useCollections } from "@linkwarden/router/collections";
|
||||
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 { useColorScheme } from "nativewind";
|
||||
import {
|
||||
Clock8,
|
||||
ChevronRight,
|
||||
Pin,
|
||||
Folder,
|
||||
Hash,
|
||||
Link,
|
||||
} from "lucide-react-native";
|
||||
import Spinner from "@/components/ui/Spinner";
|
||||
import DashboardSection from "@/components/DashboardSection";
|
||||
|
||||
export default function DashboardScreen() {
|
||||
const { auth } = useAuthStore();
|
||||
@@ -41,15 +27,13 @@ export default function DashboardScreen() {
|
||||
...dashboardData
|
||||
} = useDashboardData(auth);
|
||||
const { data: user, ...userData } = useUser(auth);
|
||||
const { data: collections = [] } = useCollections(auth);
|
||||
const { data: tags = [] } = useTags(auth);
|
||||
const { data: collections = [], ...collectionsData } = useCollections(auth);
|
||||
const { data: tags = [], ...tagsData } = useTags(auth);
|
||||
|
||||
const { colorScheme } = useColorScheme();
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
const [dashboardSections, setDashboardSections] = useState<
|
||||
DashboardSection[]
|
||||
DashboardSectionType[]
|
||||
>(user?.dashboardSections || []);
|
||||
|
||||
const [numberOfLinks, setNumberOfLinks] = useState(0);
|
||||
@@ -76,302 +60,58 @@ export default function DashboardScreen() {
|
||||
});
|
||||
}, [dashboardSections]);
|
||||
|
||||
interface SectionProps {
|
||||
sectionData: { type: DashboardSectionType };
|
||||
collection?: any;
|
||||
links?: any[];
|
||||
tagsLength: number;
|
||||
numberOfLinks: number;
|
||||
collectionsLength: number;
|
||||
numberOfPinnedLinks: number;
|
||||
dashboardData: { isLoading: boolean };
|
||||
collectionLinks?: any[];
|
||||
}
|
||||
const [pullRefreshing, setPullRefreshing] = useState(false);
|
||||
|
||||
const Section: React.FC<SectionProps> = ({
|
||||
sectionData,
|
||||
collection,
|
||||
links = [],
|
||||
tagsLength,
|
||||
numberOfLinks,
|
||||
collectionsLength,
|
||||
numberOfPinnedLinks,
|
||||
dashboardData,
|
||||
collectionLinks = [],
|
||||
}) => {
|
||||
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 onRefresh = async () => {
|
||||
setPullRefreshing(true);
|
||||
try {
|
||||
await Promise.all([
|
||||
dashboardData.refetch(),
|
||||
userData.refetch(),
|
||||
collectionsData.refetch(),
|
||||
tagsData.refetch(),
|
||||
]);
|
||||
} finally {
|
||||
setPullRefreshing(false);
|
||||
}
|
||||
};
|
||||
|
||||
const RenderItem = React.memo(
|
||||
({ item }: { item: LinkIncludingShortenedCollectionAndTags }) => {
|
||||
return <LinkListing link={item} dashboard />;
|
||||
}
|
||||
if (orderedSections.length === 0 && dashboardData.isLoading)
|
||||
return (
|
||||
<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 (
|
||||
<SafeAreaView
|
||||
style={styles.container}
|
||||
collapsable={false}
|
||||
collapsableChildren={false}
|
||||
className="bg-base-100 h-full"
|
||||
>
|
||||
<ScrollView
|
||||
refreshControl={
|
||||
<RefreshControl
|
||||
refreshing={dashboardData.isLoading || userData.isLoading}
|
||||
onRefresh={() => {
|
||||
dashboardData.refetch();
|
||||
userData.refetch();
|
||||
}}
|
||||
<Spinner
|
||||
refreshing={pullRefreshing}
|
||||
onRefresh={onRefresh}
|
||||
progressBackgroundColor={
|
||||
rawTheme[colorScheme as ThemeName]["base-200"]
|
||||
}
|
||||
colors={[rawTheme[colorScheme as ThemeName]["base-content"]]}
|
||||
/>
|
||||
}
|
||||
contentContainerStyle={{
|
||||
flexDirection: "column",
|
||||
gap: 15,
|
||||
paddingVertical: 20,
|
||||
}}
|
||||
contentContainerStyle={styles.container}
|
||||
className="bg-base-100"
|
||||
contentInsetAdjustmentBehavior="automatic"
|
||||
>
|
||||
{orderedSections.map((sectionData) => {
|
||||
{orderedSections.map((sectionData, i) => {
|
||||
if (!collections || !collections[0]) return null;
|
||||
|
||||
const collection = collections.find(
|
||||
(c) => c.id === sectionData.collectionId
|
||||
);
|
||||
|
||||
return (
|
||||
<Section
|
||||
<DashboardSection
|
||||
key={sectionData.id}
|
||||
sectionData={sectionData}
|
||||
collection={collections.find(
|
||||
(c) => c.id === sectionData.collectionId
|
||||
)}
|
||||
collection={collection}
|
||||
collectionLinks={
|
||||
sectionData.collectionId
|
||||
? collectionLinks[sectionData.collectionId]
|
||||
@@ -387,7 +127,6 @@ export default function DashboardScreen() {
|
||||
);
|
||||
})}
|
||||
</ScrollView>
|
||||
</SafeAreaView>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -395,7 +134,14 @@ const styles = StyleSheet.create({
|
||||
container: Platform.select({
|
||||
ios: {
|
||||
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 { Platform } from "react-native";
|
||||
|
||||
export default function RootLayout() {
|
||||
export default function Layout() {
|
||||
const router = useRouter();
|
||||
const { colorScheme } = useColorScheme();
|
||||
|
||||
@@ -18,7 +18,7 @@ export default function RootLayout() {
|
||||
colorScheme === "dark" ? "systemMaterialDark" : "systemMaterial",
|
||||
headerTintColor: colorScheme === "dark" ? "white" : "black",
|
||||
headerSearchBarOptions: {
|
||||
placeholder: "Search",
|
||||
placeholder: "Search Links",
|
||||
autoCapitalize: "none",
|
||||
onChangeText: (e) => {
|
||||
router.setParams({
|
||||
|
||||
@@ -1,16 +1,9 @@
|
||||
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 LinkListing from "@/components/LinkListing";
|
||||
import { useLocalSearchParams } from "expo-router";
|
||||
import React from "react";
|
||||
import { LinkIncludingShortenedCollectionAndTags } from "@linkwarden/types";
|
||||
|
||||
const RenderItem = React.memo(
|
||||
({ item }: { item: LinkIncludingShortenedCollectionAndTags }) => {
|
||||
return <LinkListing link={item} />;
|
||||
}
|
||||
);
|
||||
import Links from "@/components/Links";
|
||||
|
||||
export default function LinksScreen() {
|
||||
const { auth } = useAuthStore();
|
||||
@@ -31,23 +24,7 @@ export default function LinksScreen() {
|
||||
collapsable={false}
|
||||
collapsableChildren={false}
|
||||
>
|
||||
<FlatList
|
||||
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" />
|
||||
)}
|
||||
/>
|
||||
<Links links={links} data={data} />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@ import { useColorScheme } from "nativewind";
|
||||
import { rawTheme, ThemeName } from "@/lib/colors";
|
||||
import { Platform } from "react-native";
|
||||
|
||||
export default function RootLayout() {
|
||||
export default function Layout() {
|
||||
const { colorScheme } = useColorScheme();
|
||||
|
||||
return (
|
||||
|
||||
@@ -14,16 +14,17 @@ import { useColorScheme } from "nativewind";
|
||||
import { rawTheme, ThemeName } from "@/lib/colors";
|
||||
import { useEffect, useState } from "react";
|
||||
import {
|
||||
AppWindowMac,
|
||||
Check,
|
||||
FileText,
|
||||
Globe,
|
||||
ExternalLink,
|
||||
LogOut,
|
||||
Mail,
|
||||
Moon,
|
||||
Smartphone,
|
||||
Sun,
|
||||
} from "lucide-react-native";
|
||||
import useDataStore from "@/store/data";
|
||||
import { ArchivedFormat } from "@/types/global";
|
||||
import * as Clipboard from "expo-clipboard";
|
||||
|
||||
export default function SettingsScreen() {
|
||||
const { signOut, auth } = useAuthStore();
|
||||
@@ -143,26 +144,24 @@ export default function SettingsScreen() {
|
||||
</View>
|
||||
|
||||
<View>
|
||||
<Text className="mb-4 mx-4 text-neutral">
|
||||
Default Behavior for Opening Links
|
||||
</Text>
|
||||
<Text className="mb-4 mx-4 text-neutral">Preferred Browser</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={() =>
|
||||
updateData({
|
||||
preferredFormat: null,
|
||||
preferredBrowser: "app",
|
||||
})
|
||||
}
|
||||
>
|
||||
<View className="flex-row items-center gap-2">
|
||||
<Globe
|
||||
<AppWindowMac
|
||||
size={20}
|
||||
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>
|
||||
{data.preferredFormat === null ? (
|
||||
{data.preferredBrowser === "app" ? (
|
||||
<Check
|
||||
size={20}
|
||||
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"
|
||||
onPress={() =>
|
||||
updateData({
|
||||
preferredFormat: ArchivedFormat.readability,
|
||||
preferredBrowser: "system",
|
||||
})
|
||||
}
|
||||
>
|
||||
<View className="flex-row items-center gap-2">
|
||||
<FileText
|
||||
<ExternalLink
|
||||
size={20}
|
||||
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>
|
||||
{data.preferredFormat === ArchivedFormat.readability ? (
|
||||
{data.preferredBrowser === "system" ? (
|
||||
<Check
|
||||
size={20}
|
||||
color={rawTheme[colorScheme as ThemeName].primary}
|
||||
@@ -195,6 +196,29 @@ export default function SettingsScreen() {
|
||||
</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">
|
||||
Linkwarden for {Platform.OS === "ios" ? "iOS" : "Android"}{" "}
|
||||
{nativeApplicationVersion}
|
||||
@@ -205,7 +229,13 @@ export default function SettingsScreen() {
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
container: Platform.select({
|
||||
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 {
|
||||
router,
|
||||
Stack,
|
||||
usePathname,
|
||||
useRootNavigationState,
|
||||
@@ -8,32 +9,39 @@ import { PersistQueryClientProvider } from "@tanstack/react-query-persist-client
|
||||
import { mmkvPersister } from "@/lib/queryPersister";
|
||||
import { useState, useEffect } from "react";
|
||||
import "../styles/global.css";
|
||||
import { SheetProvider } from "react-native-actions-sheet";
|
||||
import { SheetManager, SheetProvider } from "react-native-actions-sheet";
|
||||
import "@/components/ActionSheets/Sheets";
|
||||
import { useColorScheme } from "nativewind";
|
||||
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 { useShareIntent } from "expo-share-intent";
|
||||
import useDataStore from "@/store/data";
|
||||
import useAuthStore from "@/store/auth";
|
||||
import { QueryClient } from "@tanstack/react-query";
|
||||
import * as FileSystem from "expo-file-system";
|
||||
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
staleTime: 1000 * 60 * 60 * 24,
|
||||
refetchOnMount: true,
|
||||
refetchOnWindowFocus: false,
|
||||
refetchOnReconnect: false,
|
||||
},
|
||||
},
|
||||
});
|
||||
import { KeyboardProvider } from "react-native-keyboard-controller";
|
||||
import * as DropdownMenu from "zeego/dropdown-menu";
|
||||
import { Compass, Ellipsis } from "lucide-react-native";
|
||||
import { Chromium } from "@/components/ui/Icons";
|
||||
import useTmpStore from "@/store/tmp";
|
||||
import {
|
||||
LinkIncludingShortenedCollectionAndTags,
|
||||
MobileAuth,
|
||||
} from "@linkwarden/types";
|
||||
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() {
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const { colorScheme } = useColorScheme();
|
||||
const { hasShareIntent, shareIntent, error, resetShareIntent } =
|
||||
useShareIntent();
|
||||
const { updateData, setData, data } = useDataStore();
|
||||
@@ -49,20 +57,6 @@ export default function RootLayout() {
|
||||
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(() => {
|
||||
if (!rootNavState?.key) return;
|
||||
|
||||
@@ -113,27 +107,42 @@ export default function RootLayout() {
|
||||
queryClient.invalidateQueries();
|
||||
}}
|
||||
>
|
||||
<RootComponent isLoading={isLoading} auth={auth} />
|
||||
</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>
|
||||
<StatusBar
|
||||
style={colorScheme === "dark" ? "light" : "dark"}
|
||||
backgroundColor={rawTheme[colorScheme as ThemeName]["base-100"]}
|
||||
/>
|
||||
{!isLoading && (
|
||||
<Stack
|
||||
screenOptions={{
|
||||
navigationBarColor:
|
||||
rawTheme[colorScheme as ThemeName]["base-200"],
|
||||
headerShown: false,
|
||||
contentStyle: {
|
||||
backgroundColor:
|
||||
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)" /> */}
|
||||
@@ -144,43 +153,173 @@ export default function RootLayout() {
|
||||
headerBackTitle: "Back",
|
||||
headerTitle: "",
|
||||
headerTintColor: colorScheme === "dark" ? "white" : "black",
|
||||
navigationBarColor:
|
||||
rawTheme[colorScheme as ThemeName]["base-100"],
|
||||
headerStyle: {
|
||||
backgroundColor:
|
||||
colorScheme === "dark"
|
||||
? rawTheme["dark"]["base-100"]
|
||||
: "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"]
|
||||
}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="login"
|
||||
options={{
|
||||
navigationBarColor:
|
||||
rawTheme[colorScheme as ThemeName]["base-100"],
|
||||
...Platform.select({
|
||||
android: {
|
||||
statusBarStyle:
|
||||
colorScheme === "light" ? "light" : "dark",
|
||||
statusBarBackgroundColor:
|
||||
rawTheme[colorScheme as ThemeName]["primary"],
|
||||
) : (
|
||||
<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,
|
||||
},
|
||||
}),
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="incoming"
|
||||
options={{
|
||||
navigationBarColor:
|
||||
rawTheme[colorScheme as ThemeName]["base-100"],
|
||||
});
|
||||
}}
|
||||
>
|
||||
<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 name="login" />
|
||||
<Stack.Screen name="index" />
|
||||
<Stack.Screen name="incoming" />
|
||||
<Stack.Screen name="+not-found" />
|
||||
</Stack>
|
||||
)}
|
||||
</SheetProvider>
|
||||
</KeyboardProvider>
|
||||
</View>
|
||||
</PersistQueryClientProvider>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -46,7 +46,7 @@ export default function IncomingScreen() {
|
||||
);
|
||||
}, [auth, data.shareIntent.url]);
|
||||
|
||||
if (auth.status === "unauthenticated") return <Redirect href="/login" />;
|
||||
if (auth.status === "unauthenticated") return <Redirect href="/" />;
|
||||
|
||||
return (
|
||||
<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 { 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() {
|
||||
const { auth } = useAuthStore();
|
||||
const { colorScheme } = useColorScheme();
|
||||
|
||||
if (auth.session) {
|
||||
return <Redirect href="/(tabs)/dashboard" />;
|
||||
} else {
|
||||
return <Redirect href="/login" />;
|
||||
return <Redirect href="/dashboard" />;
|
||||
}
|
||||
|
||||
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 {
|
||||
View,
|
||||
ActivityIndicator,
|
||||
Text,
|
||||
ScrollView,
|
||||
TouchableOpacity,
|
||||
} from "react-native";
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { View, ActivityIndicator, Text, Platform } from "react-native";
|
||||
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 { useLocalSearchParams, useRouter } from "expo-router";
|
||||
import { useLocalSearchParams } from "expo-router";
|
||||
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 { useColorScheme } from "nativewind";
|
||||
import { rawTheme, ThemeName } from "@/lib/colors";
|
||||
import { CalendarDays, Link } from "lucide-react-native";
|
||||
|
||||
const CACHE_DIR = FileSystem.documentDirectory + "archivedData/readable/";
|
||||
const htmlPath = (id: string) => `${CACHE_DIR}link_${id}.html`;
|
||||
|
||||
async function ensureCacheDir() {
|
||||
const info = await FileSystem.getInfoAsync(CACHE_DIR);
|
||||
if (!info.exists) {
|
||||
await FileSystem.makeDirectoryAsync(CACHE_DIR, { intermediates: true });
|
||||
}
|
||||
}
|
||||
import useTmpStore from "@/store/tmp";
|
||||
import { ArchivedFormat } from "@linkwarden/types";
|
||||
import ReadableFormat from "@/components/Formats/ReadableFormat";
|
||||
import ImageFormat from "@/components/Formats/ImageFormat";
|
||||
import PdfFormat from "@/components/Formats/PdfFormat";
|
||||
import WebpageFormat from "@/components/Formats/WebpageFormat";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
|
||||
export default function LinkScreen() {
|
||||
const { auth } = useAuthStore();
|
||||
const { id, format } = useLocalSearchParams();
|
||||
const { data: user } = useUser(auth);
|
||||
const [url, setUrl] = useState<string>();
|
||||
const [htmlContent, setHtmlContent] = useState<string>("");
|
||||
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 { updateTmp } = useTmpStore();
|
||||
|
||||
useEffect(() => {
|
||||
async function loadCacheOrFetch() {
|
||||
await ensureCacheDir();
|
||||
const htmlFile = htmlPath(id as string);
|
||||
if (link?.id && user?.id)
|
||||
updateTmp({
|
||||
link,
|
||||
user: {
|
||||
id: user.id,
|
||||
},
|
||||
});
|
||||
|
||||
const [htmlInfo] = await Promise.all([FileSystem.getInfoAsync(htmlFile)]);
|
||||
return () =>
|
||||
updateTmp({
|
||||
link: null,
|
||||
});
|
||||
}, [link, user]);
|
||||
|
||||
if (format === "3" && htmlInfo.exists) {
|
||||
const rawHtml = await FileSystem.readAsStringAsync(htmlFile);
|
||||
setHtmlContent(rawHtml);
|
||||
setIsLoading(false);
|
||||
useEffect(() => {
|
||||
if (user?.id && link?.id && format) {
|
||||
setUrl(`${auth.instance}/api/v1/archives/${link.id}?format=${format}`);
|
||||
} 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]);
|
||||
|
||||
async function fetchLinkData() {
|
||||
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}`);
|
||||
}
|
||||
}
|
||||
const insets = useSafeAreaInsets();
|
||||
|
||||
return (
|
||||
<>
|
||||
{format === "3" && htmlContent ? (
|
||||
<ScrollView
|
||||
className="flex-1 bg-base-100"
|
||||
contentContainerClassName="p-4"
|
||||
nestedScrollEnabled
|
||||
<View
|
||||
className="flex-1"
|
||||
style={{ paddingBottom: Platform.OS === "android" ? insets.bottom : 0 }}
|
||||
>
|
||||
<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/${id}`)}
|
||||
>
|
||||
<Link
|
||||
size={16}
|
||||
color={rawTheme[colorScheme as ThemeName]["neutral"]}
|
||||
{link?.id && Number(format) === ArchivedFormat.readability ? (
|
||||
<ReadableFormat
|
||||
link={link as any}
|
||||
setIsLoading={(state) => setIsLoading(state)}
|
||||
/>
|
||||
<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"]}
|
||||
) : link?.id &&
|
||||
(Number(format) === ArchivedFormat.jpeg ||
|
||||
Number(format) === ArchivedFormat.png) ? (
|
||||
<ImageFormat
|
||||
link={link as any}
|
||||
setIsLoading={(state) => setIsLoading(state)}
|
||||
format={Number(format)}
|
||||
/>
|
||||
<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}`)}
|
||||
) : link?.id && Number(format) === ArchivedFormat.pdf ? (
|
||||
<PdfFormat
|
||||
link={link as any}
|
||||
setIsLoading={(state) => setIsLoading(state)}
|
||||
/>
|
||||
),
|
||||
}}
|
||||
tagsStyles={{
|
||||
p: { fontSize: 18, lineHeight: 28, marginVertical: 10 },
|
||||
}}
|
||||
baseStyle={{
|
||||
color: rawTheme[colorScheme as ThemeName]["base-content"],
|
||||
}}
|
||||
) : link?.id && Number(format) === ArchivedFormat.monolith ? (
|
||||
<WebpageFormat
|
||||
link={link as any}
|
||||
setIsLoading={(state) => setIsLoading(state)}
|
||||
/>
|
||||
</ScrollView>
|
||||
) : url ? (
|
||||
<WebView
|
||||
className={isLoading ? "opacity-0" : "flex-1"}
|
||||
source={{
|
||||
uri: url,
|
||||
headers: format ? { Authorization: `Bearer ${auth.session}` } : {},
|
||||
headers:
|
||||
format || link?.type !== "url"
|
||||
? { Authorization: `Bearer ${auth.session}` }
|
||||
: {},
|
||||
}}
|
||||
onLoadEnd={() => setIsLoading(false)}
|
||||
/>
|
||||
@@ -181,6 +106,6 @@ export default function LinkScreen() {
|
||||
<Text className="text-base mt-2.5 text-neutral">Loading...</Text>
|
||||
</View>
|
||||
)}
|
||||
</>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -4,33 +4,99 @@ import { rawTheme, ThemeName } from "@/lib/colors";
|
||||
import useAuthStore from "@/store/auth";
|
||||
import { Redirect } from "expo-router";
|
||||
import { useColorScheme } from "nativewind";
|
||||
import { useState } from "react";
|
||||
import { View, Text, Dimensions, TouchableOpacity } from "react-native";
|
||||
import { useEffect, useState } from "react";
|
||||
import { View, Text, Dimensions, TouchableOpacity, Image } from "react-native";
|
||||
import { SheetManager } from "react-native-actions-sheet";
|
||||
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() {
|
||||
const { auth, signIn } = useAuthStore();
|
||||
const { colorScheme } = useColorScheme();
|
||||
const [method, setMethod] = useState<"password" | "token">("password");
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
const [form, setForm] = useState({
|
||||
user: "",
|
||||
password: "",
|
||||
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") {
|
||||
return <Redirect href="/dashboard" />;
|
||||
}
|
||||
|
||||
return (
|
||||
<View className="flex-col justify-end h-full bg-primary">
|
||||
<>
|
||||
<KeyboardStickyView className="flex-col justify-end h-full bg-base-100 relative">
|
||||
<View className="flex-col justify-end h-full bg-primary relative">
|
||||
<View className="my-auto">
|
||||
<Image
|
||||
source={require("@/assets/images/linkwarden.png")}
|
||||
className="w-[120px] h-[120px] mx-auto"
|
||||
/>
|
||||
</View>
|
||||
<Text className="text-base-100 text-7xl font-bold ml-8">Login</Text>
|
||||
<View>
|
||||
<Text
|
||||
className="text-base-100 text-2xl mx-8 mt-3"
|
||||
numberOfLines={1}
|
||||
>
|
||||
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={100}
|
||||
height={Dimensions.get("screen").width * (320 / 1440) + 2}
|
||||
>
|
||||
<Path
|
||||
fill={rawTheme[colorScheme as ThemeName]["base-100"]}
|
||||
@@ -38,19 +104,31 @@ export default function HomeScreen() {
|
||||
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">
|
||||
<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"
|
||||
>
|
||||
{showInstanceField && (
|
||||
<Input
|
||||
className="w-full text-xl p-3 leading-tight h-12"
|
||||
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"
|
||||
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"
|
||||
className="w-full text-xl p-3 leading-tight h-12"
|
||||
textAlignVertical="center"
|
||||
placeholder="Password"
|
||||
secureTextEntry
|
||||
@@ -60,7 +138,7 @@ export default function HomeScreen() {
|
||||
</>
|
||||
) : (
|
||||
<Input
|
||||
className="w-full text-xl p-3 leading-tight"
|
||||
className="w-full text-xl p-3 leading-tight h-12"
|
||||
textAlignVertical="center"
|
||||
placeholder="Access Token"
|
||||
secureTextEntry
|
||||
@@ -77,28 +155,43 @@ export default function HomeScreen() {
|
||||
>
|
||||
<Text className="text-primary w-fit text-center">
|
||||
{method === "password"
|
||||
? "Login with Access Token instead"
|
||||
: "Login with Username/Password instead"}
|
||||
? "Login with Access Token"
|
||||
: "Login with Username/Password"}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
|
||||
<Button
|
||||
variant="accent"
|
||||
size="lg"
|
||||
onPress={() =>
|
||||
signIn(
|
||||
isLoading={isLoading}
|
||||
onPress={async () => {
|
||||
if (
|
||||
((form.user && form.password) || form.token) &&
|
||||
form.instance
|
||||
) {
|
||||
setIsLoading(true);
|
||||
await signIn(
|
||||
form.user,
|
||||
form.password,
|
||||
form.instance ? form.instance : undefined
|
||||
)
|
||||
form.instance,
|
||||
form.token
|
||||
);
|
||||
setIsLoading(false);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Text className="text-white">Login</Text>
|
||||
<Text className="text-white text-xl">Login</Text>
|
||||
</Button>
|
||||
<TouchableOpacity className="w-fit mx-auto">
|
||||
<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>
|
||||
</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 { rawTheme, ThemeName } from "@/lib/colors";
|
||||
import { useColorScheme } from "nativewind";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
|
||||
export default function AddLinkSheet() {
|
||||
const actionSheetRef = useRef<ActionSheetRef>(null);
|
||||
@@ -15,6 +16,8 @@ export default function AddLinkSheet() {
|
||||
const [link, setLink] = useState("");
|
||||
const { colorScheme } = useColorScheme();
|
||||
|
||||
const insets = useSafeAreaInsets();
|
||||
|
||||
return (
|
||||
<ActionSheet
|
||||
ref={actionSheetRef}
|
||||
@@ -25,6 +28,7 @@ export default function AddLinkSheet() {
|
||||
containerStyle={{
|
||||
backgroundColor: rawTheme[colorScheme as ThemeName]["base-200"],
|
||||
}}
|
||||
safeAreaInsets={insets}
|
||||
>
|
||||
<View className="px-8 py-5">
|
||||
<Input
|
||||
@@ -50,6 +54,7 @@ export default function AddLinkSheet() {
|
||||
}
|
||||
)
|
||||
}
|
||||
isLoading={addLink.isPending}
|
||||
variant="accent"
|
||||
className="mb-2"
|
||||
>
|
||||
|
||||
@@ -20,6 +20,8 @@ import { useCollections } from "@linkwarden/router/collections";
|
||||
import { rawTheme, ThemeName } from "@/lib/colors";
|
||||
import { useColorScheme } from "nativewind";
|
||||
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 { auth } = useAuthStore();
|
||||
@@ -39,6 +41,8 @@ const Main = (props: SheetProps<"edit-link-sheet">) => {
|
||||
}
|
||||
}, [params?.link]);
|
||||
|
||||
const { tmp, updateTmp } = useTmpStore();
|
||||
|
||||
return (
|
||||
<View className="px-8 py-5">
|
||||
<Input
|
||||
@@ -111,6 +115,11 @@ const Main = (props: SheetProps<"edit-link-sheet">) => {
|
||||
onPress={() =>
|
||||
editLink.mutate(link as LinkIncludingShortenedCollectionAndTags, {
|
||||
onSuccess: () => {
|
||||
if (link && tmp.link)
|
||||
updateTmp({
|
||||
link,
|
||||
});
|
||||
|
||||
SheetManager.hide("edit-link-sheet");
|
||||
},
|
||||
onError: (error) => {
|
||||
@@ -119,6 +128,7 @@ const Main = (props: SheetProps<"edit-link-sheet">) => {
|
||||
},
|
||||
})
|
||||
}
|
||||
isLoading={editLink.isPending}
|
||||
variant="accent"
|
||||
className="mb-2"
|
||||
>
|
||||
@@ -246,6 +256,8 @@ const routes: Route[] = [
|
||||
export default function EditLinkSheet() {
|
||||
const { colorScheme } = useColorScheme();
|
||||
|
||||
const insets = useSafeAreaInsets();
|
||||
|
||||
return (
|
||||
<ActionSheet
|
||||
gestureEnabled
|
||||
@@ -258,6 +270,7 @@ export default function EditLinkSheet() {
|
||||
containerStyle={{
|
||||
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,
|
||||
SheetDefinition,
|
||||
} from "react-native-actions-sheet";
|
||||
import SupportSheet from "./SupportSheet";
|
||||
import AddLinkSheet from "./AddLinkSheet";
|
||||
import EditLinkSheet from "./EditLinkSheet";
|
||||
import NewCollectionSheet from "./NewCollectionSheet";
|
||||
import { LinkIncludingShortenedCollectionAndTags } from "@linkwarden/types";
|
||||
|
||||
registerSheet("support-sheet", SupportSheet);
|
||||
registerSheet("add-link-sheet", AddLinkSheet);
|
||||
registerSheet("edit-link-sheet", EditLinkSheet);
|
||||
registerSheet("new-collection-sheet", NewCollectionSheet);
|
||||
|
||||
declare module "react-native-actions-sheet" {
|
||||
interface Sheets {
|
||||
"support-sheet": SheetDefinition;
|
||||
"add-link-sheet": SheetDefinition;
|
||||
"edit-link-sheet": SheetDefinition<{
|
||||
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 { LinkIncludingShortenedCollectionAndTags } from "@linkwarden/types";
|
||||
import { ArchivedFormat } from "@linkwarden/types";
|
||||
import getFormatBasedOnPreference from "@linkwarden/lib/getFormatBasedOnPreference";
|
||||
import getOriginalFormat from "@linkwarden/lib/getOriginalFormat";
|
||||
import {
|
||||
atLeastOneFormatAvailable,
|
||||
formatAvailable,
|
||||
@@ -18,6 +29,8 @@ import { rawTheme, ThemeName } from "@/lib/colors";
|
||||
import { useColorScheme } from "nativewind";
|
||||
import { CalendarDays, Folder } from "lucide-react-native";
|
||||
import useDataStore from "@/store/data";
|
||||
import { useEffect, useState } from "react";
|
||||
import { deleteLinkCache } from "@/lib/cache";
|
||||
|
||||
type Props = {
|
||||
link: LinkIncludingShortenedCollectionAndTags;
|
||||
@@ -34,15 +47,17 @@ const LinkListing = ({ link, dashboard }: Props) => {
|
||||
|
||||
const deleteLink = useDeleteLink(auth);
|
||||
|
||||
let shortendURL;
|
||||
const [url, setUrl] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
try {
|
||||
if (link.url) {
|
||||
shortendURL = new URL(link.url).host.toLowerCase();
|
||||
setUrl(new URL(link.url).host.toLowerCase());
|
||||
}
|
||||
} catch (error) {
|
||||
console.log(error);
|
||||
}
|
||||
}, [link]);
|
||||
|
||||
return (
|
||||
<ContextMenu.Root>
|
||||
@@ -55,13 +70,27 @@ const LinkListing = ({ link, dashboard }: Props) => {
|
||||
dashboard && "rounded-xl"
|
||||
)}
|
||||
onLongPress={() => {}}
|
||||
onPress={() =>
|
||||
router.push(
|
||||
data.preferredFormat
|
||||
? `/links/${link.id}?format=${data.preferredFormat}`
|
||||
onPress={() => {
|
||||
if (user) {
|
||||
const format = getFormatBasedOnPreference({
|
||||
link,
|
||||
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={{
|
||||
color: colorScheme === "dark" ? "rgba(255,255,255,0.2)" : "#ddd",
|
||||
borderless: false,
|
||||
@@ -81,12 +110,12 @@ const LinkListing = ({ link, dashboard }: Props) => {
|
||||
{decode(link.name || link.description || link.url)}
|
||||
</Text>
|
||||
|
||||
{shortendURL && (
|
||||
{url && (
|
||||
<Text
|
||||
numberOfLines={1}
|
||||
className="mt-1.5 font-light text-sm text-base-content"
|
||||
>
|
||||
{shortendURL}
|
||||
{url}
|
||||
</Text>
|
||||
)}
|
||||
|
||||
@@ -117,6 +146,11 @@ const LinkListing = ({ link, dashboard }: Props) => {
|
||||
}}
|
||||
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]" />
|
||||
)}
|
||||
@@ -144,13 +178,30 @@ const LinkListing = ({ link, dashboard }: Props) => {
|
||||
|
||||
<ContextMenu.Content avoidCollisions>
|
||||
<ContextMenu.Item
|
||||
key="open-link"
|
||||
onSelect={() => router.push(`/links/${link.id}`)}
|
||||
>
|
||||
<ContextMenu.ItemTitle>Open Link</ContextMenu.ItemTitle>
|
||||
</ContextMenu.Item>
|
||||
key="open-original"
|
||||
onSelect={() => {
|
||||
if (link) {
|
||||
const format = getOriginalFormat(link);
|
||||
|
||||
{link.url && (
|
||||
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)
|
||||
);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<ContextMenu.ItemTitle>Open Original</ContextMenu.ItemTitle>
|
||||
</ContextMenu.Item>
|
||||
{link?.url && (
|
||||
<>
|
||||
<ContextMenu.Item
|
||||
key="copy-url"
|
||||
onSelect={async () => {
|
||||
@@ -159,6 +210,7 @@ const LinkListing = ({ link, dashboard }: Props) => {
|
||||
>
|
||||
<ContextMenu.ItemTitle>Copy URL</ContextMenu.ItemTitle>
|
||||
</ContextMenu.Item>
|
||||
</>
|
||||
)}
|
||||
|
||||
<ContextMenu.Item
|
||||
@@ -201,7 +253,7 @@ const LinkListing = ({ link, dashboard }: Props) => {
|
||||
<ContextMenu.Item
|
||||
key="preserved-formats-webpage"
|
||||
onSelect={() =>
|
||||
router.push(
|
||||
router.navigate(
|
||||
`/links/${link.id}?format=${ArchivedFormat.monolith}`
|
||||
)
|
||||
}
|
||||
@@ -213,7 +265,7 @@ const LinkListing = ({ link, dashboard }: Props) => {
|
||||
<ContextMenu.Item
|
||||
key="preserved-formats-screenshot"
|
||||
onSelect={() =>
|
||||
router.push(
|
||||
router.navigate(
|
||||
`/links/${link.id}?format=${
|
||||
link.image?.endsWith(".png")
|
||||
? ArchivedFormat.png
|
||||
@@ -229,7 +281,7 @@ const LinkListing = ({ link, dashboard }: Props) => {
|
||||
<ContextMenu.Item
|
||||
key="preserved-formats-pdf"
|
||||
onSelect={() =>
|
||||
router.push(
|
||||
router.navigate(
|
||||
`/links/${link.id}?format=${ArchivedFormat.pdf}`
|
||||
)
|
||||
}
|
||||
@@ -241,7 +293,7 @@ const LinkListing = ({ link, dashboard }: Props) => {
|
||||
<ContextMenu.Item
|
||||
key="preserved-formats-readable"
|
||||
onSelect={() =>
|
||||
router.push(
|
||||
router.navigate(
|
||||
`/links/${link.id}?format=${ArchivedFormat.readability}`
|
||||
)
|
||||
}
|
||||
@@ -268,7 +320,11 @@ const LinkListing = ({ link, dashboard }: Props) => {
|
||||
text: "Delete",
|
||||
style: "destructive",
|
||||
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",
|
||||
"main": "expo-router/entry",
|
||||
"version": "1.0.0",
|
||||
"version": "0.0.0",
|
||||
"scripts": {
|
||||
"start": "expo start",
|
||||
"android": "expo run:android",
|
||||
@@ -16,6 +16,7 @@
|
||||
"dependencies": {
|
||||
"@expo/vector-icons": "^14.0.2",
|
||||
"@linkwarden/lib": "*",
|
||||
"@linkwarden/prisma": "*",
|
||||
"@linkwarden/react-native-render-html": "^6.3.4",
|
||||
"@linkwarden/router": "*",
|
||||
"@linkwarden/types": "*",
|
||||
@@ -30,6 +31,7 @@
|
||||
"expo": "~52.0.18",
|
||||
"expo-application": "~6.0.2",
|
||||
"expo-blur": "~14.0.1",
|
||||
"expo-build-properties": "~0.13.3",
|
||||
"expo-clipboard": "~7.0.1",
|
||||
"expo-constants": "~17.0.3",
|
||||
"expo-dev-client": "~5.0.6",
|
||||
@@ -44,6 +46,7 @@
|
||||
"expo-status-bar": "~2.0.0",
|
||||
"expo-symbols": "~0.2.0",
|
||||
"expo-system-ui": "~4.0.6",
|
||||
"expo-updates": "~0.27.4",
|
||||
"expo-web-browser": "~14.0.1",
|
||||
"html-entities": "^2.6.0",
|
||||
"lucide-react-native": "^0.536.0",
|
||||
@@ -52,10 +55,14 @@
|
||||
"react-dom": "18.3.1",
|
||||
"react-native": "0.76.9",
|
||||
"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-ios-context-menu": "3.1.0",
|
||||
"react-native-ios-utilities": "5.1.2",
|
||||
"react-native-ios-context-menu": "3.1.3",
|
||||
"react-native-ios-utilities": "5.1.7",
|
||||
"react-native-keyboard-controller": "^1.19.0",
|
||||
"react-native-mmkv": "^3.2.0",
|
||||
"react-native-pdf": "^7.0.3",
|
||||
"react-native-reanimated": "3.16.2",
|
||||
"react-native-safe-area-context": "4.12.0",
|
||||
"react-native-screens": "~4.1.0",
|
||||
@@ -69,7 +76,7 @@
|
||||
"devDependencies": {
|
||||
"@babel/core": "^7.25.2",
|
||||
"@types/jest": "^29.5.12",
|
||||
"@types/react": "~18.3.12",
|
||||
"@types/react": "18.3.1",
|
||||
"@types/react-test-renderer": "^18.3.0",
|
||||
"jest": "^29.2.1",
|
||||
"jest-expo": "~52.0.2",
|
||||
|
||||
@@ -2,19 +2,28 @@ import { create } from "zustand";
|
||||
import * as SecureStore from "expo-secure-store";
|
||||
import { router } from "expo-router";
|
||||
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 = {
|
||||
auth: MobileAuth;
|
||||
signIn: (username: string, password: string, instance?: string) => void;
|
||||
signOut: () => void;
|
||||
setAuth: () => void;
|
||||
signIn: (
|
||||
username: string,
|
||||
password: string,
|
||||
instance: string,
|
||||
token?: string
|
||||
) => Promise<void>;
|
||||
signOut: () => Promise<void>;
|
||||
setAuth: () => Promise<void>;
|
||||
};
|
||||
|
||||
const useAuthStore = create<AuthStore>((set) => ({
|
||||
auth: {
|
||||
instance: "",
|
||||
session: null,
|
||||
status: "loading",
|
||||
status: "loading" as const,
|
||||
},
|
||||
setAuth: async () => {
|
||||
const session = await SecureStore.getItemAsync("TOKEN");
|
||||
@@ -31,57 +40,89 @@ const useAuthStore = create<AuthStore>((set) => ({
|
||||
} else {
|
||||
set({
|
||||
auth: {
|
||||
instance: "",
|
||||
instance: instance || "https://cloud.linkwarden.app",
|
||||
session: null,
|
||||
status: "unauthenticated",
|
||||
},
|
||||
});
|
||||
}
|
||||
},
|
||||
signIn: async (
|
||||
username,
|
||||
password,
|
||||
instance = process.env.NODE_ENV === "production"
|
||||
? "https://cloud.linkwarden.app"
|
||||
: (process.env.EXPO_PUBLIC_LINKWARDEN_URL as string)
|
||||
) => {
|
||||
signIn: async (username, password, instance, token) => {
|
||||
if (process.env.EXPO_PUBLIC_SHOW_LOGS === "true")
|
||||
console.log("Signing into", instance);
|
||||
|
||||
await fetch(instance + "/api/v1/session", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ username, password }),
|
||||
if (token) {
|
||||
// make a request to the API to validate the token
|
||||
await fetch(instance + "/api/v1/users/me", {
|
||||
method: "GET",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
}).then(async (res) => {
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
const session = (data as any).response.token;
|
||||
await SecureStore.setItemAsync("TOKEN", session);
|
||||
await SecureStore.setItemAsync("INSTANCE", instance);
|
||||
await SecureStore.setItemAsync("TOKEN", token);
|
||||
set({
|
||||
auth: {
|
||||
session,
|
||||
session: token,
|
||||
instance,
|
||||
status: "authenticated",
|
||||
},
|
||||
});
|
||||
|
||||
router.replace("/(tabs)/dashboard");
|
||||
} else {
|
||||
set({
|
||||
auth: {
|
||||
instance,
|
||||
session: null,
|
||||
status: "unauthenticated",
|
||||
},
|
||||
});
|
||||
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)
|
||||
),
|
||||
]);
|
||||
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
const session = (data as any).response.token;
|
||||
|
||||
await SecureStore.setItemAsync("TOKEN", session);
|
||||
await SecureStore.setItemAsync("INSTANCE", instance);
|
||||
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 () => {
|
||||
await SecureStore.deleteItemAsync("TOKEN");
|
||||
await SecureStore.deleteItemAsync("INSTANCE");
|
||||
|
||||
queryClient.cancelQueries();
|
||||
queryClient.clear();
|
||||
mmkvPersister.removeClient?.();
|
||||
|
||||
await clearCache();
|
||||
|
||||
set({
|
||||
auth: {
|
||||
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,
|
||||
url: "",
|
||||
},
|
||||
theme: "light",
|
||||
preferredFormat: null,
|
||||
theme: "system",
|
||||
preferredBrowser: "app",
|
||||
},
|
||||
setData: async () => {
|
||||
const dataString = JSON.parse((await AsyncStorage.getItem("data")) || "{}");
|
||||
|
||||
colorScheme.set(dataString.theme || "light");
|
||||
colorScheme.set(dataString.theme || "system");
|
||||
|
||||
if (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 {
|
||||
png,
|
||||
jpeg,
|
||||
pdf,
|
||||
readability,
|
||||
monolith,
|
||||
png = 0,
|
||||
jpeg = 1,
|
||||
pdf = 2,
|
||||
readability = 3,
|
||||
monolith = 4,
|
||||
}
|
||||
|
||||
@@ -9,24 +9,39 @@ type Props = {
|
||||
|
||||
export default function Announcement({ toggleAnnouncementBar }: Props) {
|
||||
const announcementId = localStorage.getItem("announcementId");
|
||||
const announcementMessage = localStorage.getItem("announcementMessage");
|
||||
|
||||
return (
|
||||
<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">
|
||||
<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">
|
||||
{announcementId ? (
|
||||
<Trans
|
||||
i18nKey="new_version_announcement"
|
||||
values={{ version: announcementId }}
|
||||
components={[
|
||||
<Link
|
||||
href={`https://blog.linkwarden.app/releases/${announcementId}`}
|
||||
href={`https://linkwarden.app/blog/releases/${announcementId}`}
|
||||
target="_blank"
|
||||
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>
|
||||
<Button variant="ghost" size="icon" onClick={toggleAnnouncementBar}>
|
||||
<i className="bi-x text-xl"></i>
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import useLocalSettingsStore from "@/store/localSettings";
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import React, { ReactNode } from "react";
|
||||
@@ -30,7 +29,7 @@ export default function CenteredForm({
|
||||
width={640}
|
||||
height={136}
|
||||
alt="Linkwarden"
|
||||
className="h-12 w-fit mx-auto"
|
||||
className="h-12 w-auto mx-auto"
|
||||
/>
|
||||
) : (
|
||||
<Image
|
||||
@@ -38,7 +37,7 @@ export default function CenteredForm({
|
||||
width={640}
|
||||
height={136}
|
||||
alt="Linkwarden"
|
||||
className="h-12 w-fit mx-auto"
|
||||
className="h-12 w-auto mx-auto"
|
||||
/>
|
||||
)}
|
||||
{text && (
|
||||
@@ -52,7 +51,11 @@ export default function CenteredForm({
|
||||
values={{ date: new Date().getFullYear() }}
|
||||
i18nKey="all_rights_reserved"
|
||||
components={[
|
||||
<Link href="https://linkwarden.app" className="font-semibold" />,
|
||||
<Link
|
||||
href="https://linkwarden.app"
|
||||
className="font-semibold"
|
||||
key="linkwarden-website-key"
|
||||
/>,
|
||||
]}
|
||||
/>
|
||||
</p>
|
||||
@@ -79,7 +79,7 @@ export default function CollectionCard({
|
||||
size="icon"
|
||||
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>
|
||||
</DropdownMenuTrigger>
|
||||
|
||||
|
||||
@@ -36,11 +36,10 @@ const CollectionListing = () => {
|
||||
const updateCollection = useUpdateCollection();
|
||||
const { data: collections = [], isLoading } = useCollections();
|
||||
|
||||
const { data: user, refetch } = useUser();
|
||||
const { data: user } = useUser();
|
||||
const updateUser = useUpdateUser();
|
||||
|
||||
const router = useRouter();
|
||||
const currentPath = router.asPath;
|
||||
|
||||
const [tree, setTree] = useState<TreeData | undefined>();
|
||||
|
||||
@@ -53,7 +52,7 @@ const CollectionListing = () => {
|
||||
user?.collectionOrder
|
||||
);
|
||||
} else return undefined;
|
||||
}, [collections, user, router]);
|
||||
}, [collections, user]);
|
||||
|
||||
useEffect(() => {
|
||||
setTree(initialTree);
|
||||
@@ -281,7 +280,7 @@ const CollectionListing = () => {
|
||||
<Tree
|
||||
tree={tree}
|
||||
renderItem={(itemProps) =>
|
||||
renderItem({ ...itemProps }, currentPath, droppableActive)
|
||||
renderItem({ ...itemProps }, router.asPath, droppableActive)
|
||||
}
|
||||
onExpand={onExpand}
|
||||
onCollapse={onCollapse}
|
||||
|
||||
@@ -30,6 +30,7 @@ import {
|
||||
} from "@dnd-kit/core";
|
||||
import { restrictToParentElement } from "@dnd-kit/modifiers";
|
||||
import { cn } from "@linkwarden/lib";
|
||||
import toast from "react-hot-toast";
|
||||
|
||||
interface DashboardSectionOption {
|
||||
type: DashboardSectionType;
|
||||
@@ -185,7 +186,13 @@ export default function DashboardLayoutDropdown() {
|
||||
return s;
|
||||
});
|
||||
|
||||
updateDashboardLayout.mutateAsync(updatedSections);
|
||||
updateDashboardLayout.mutateAsync(updatedSections, {
|
||||
onSettled: (data, error) => {
|
||||
if (error) {
|
||||
toast.error(error.message);
|
||||
}
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const handleReorder = (sourceId: string, destId: string) => {
|
||||
@@ -217,7 +224,13 @@ export default function DashboardLayoutDropdown() {
|
||||
const disabledSections = filteredSections.filter((s) => !s.enabled);
|
||||
const updated = [...reorderedWithNewOrders, ...disabledSections];
|
||||
|
||||
updateDashboardLayout.mutateAsync(updated);
|
||||
updateDashboardLayout.mutateAsync(updated, {
|
||||
onSettled: (data, error) => {
|
||||
if (error) {
|
||||
toast.error(error.message);
|
||||
}
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const handleDragEnd = (event: DragEndEvent) => {
|
||||
@@ -268,7 +281,7 @@ export default function DashboardLayoutDropdown() {
|
||||
<TextInput
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
className="py-0"
|
||||
className="py-0 bg-base-100"
|
||||
placeholder={t("search")}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -27,6 +27,7 @@ import LinkPin from "./LinkViews/LinkComponents/LinkPin";
|
||||
import { Separator } from "./ui/separator";
|
||||
import { useDraggable } from "@dnd-kit/core";
|
||||
import { cn } from "@linkwarden/lib";
|
||||
import { useTranslation } from "next-i18next";
|
||||
|
||||
export function DashboardLinks({
|
||||
links,
|
||||
@@ -39,7 +40,7 @@ export function DashboardLinks({
|
||||
}) {
|
||||
return (
|
||||
<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 ? (
|
||||
<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) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const { attributes, listeners, setNodeRef, isDragging } = useDraggable({
|
||||
id: `${link.id}-${dashboardType}`,
|
||||
data: {
|
||||
linkId: link.id,
|
||||
link,
|
||||
dashboardType,
|
||||
},
|
||||
});
|
||||
@@ -85,16 +89,6 @@ export function Card({ link, editMode, dashboardType }: Props) {
|
||||
|
||||
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] =
|
||||
useState<CollectionIncludingMembersAndLinkCount>(
|
||||
collections.find(
|
||||
@@ -173,6 +167,7 @@ export function Card({ link, editMode, dashboardType }: Props) {
|
||||
const target = e.target as HTMLElement;
|
||||
target.style.display = "none";
|
||||
}}
|
||||
unoptimized
|
||||
/>
|
||||
) : link.preview === "unavailable" ? (
|
||||
<div className={`bg-gray-50 h-40 bg-opacity-80`}></div>
|
||||
@@ -198,7 +193,7 @@ export function Card({ link, editMode, dashboardType }: Props) {
|
||||
</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">
|
||||
{show.name && (
|
||||
<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">
|
||||
{show.collection && !isPublicRoute && (
|
||||
<div className="cursor-pointer truncate">
|
||||
<LinkCollection link={link} collection={collection} />
|
||||
<LinkCollection
|
||||
link={link}
|
||||
collection={collection}
|
||||
isPublicRoute={false}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{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>
|
||||
<LinkActions
|
||||
link={link}
|
||||
collection={collection}
|
||||
t={t}
|
||||
linkModal={linkModal}
|
||||
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"
|
||||
|
||||
@@ -15,9 +15,11 @@ import { LinkIncludingShortenedCollectionAndTags } from "@linkwarden/types";
|
||||
import toast from "react-hot-toast";
|
||||
import { useUpdateLink } from "@linkwarden/router/links";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { restrictToWindowEdges, snapCenterToCursor } from "@dnd-kit/modifiers";
|
||||
import { snapCenterToCursor } from "@dnd-kit/modifiers";
|
||||
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 {
|
||||
children: React.ReactNode;
|
||||
@@ -28,7 +30,6 @@ interface DragNDropProps {
|
||||
/**
|
||||
* All links available for drag and drop
|
||||
*/
|
||||
links: LinkIncludingShortenedCollectionAndTags[];
|
||||
setActiveLink: (link: LinkIncludingShortenedCollectionAndTags | null) => void;
|
||||
/**
|
||||
* Override the default sensors used for drag and drop.
|
||||
@@ -47,14 +48,15 @@ interface DragNDropProps {
|
||||
export default function DragNDrop({
|
||||
children,
|
||||
activeLink,
|
||||
links,
|
||||
setActiveLink,
|
||||
sensors: sensorProp,
|
||||
onDragEnd: onDragEndProp,
|
||||
}: DragNDropProps) {
|
||||
const { t } = useTranslation();
|
||||
const updateTag = useUpdateTag();
|
||||
const updateLink = useUpdateLink();
|
||||
const pinLink = usePinLink();
|
||||
const { data: user } = useUser();
|
||||
const queryClient = useQueryClient();
|
||||
const mouseSensor = useSensor(MouseSensor, {
|
||||
// Require the mouse to move by 10 pixels before activating
|
||||
activationConstraint: {
|
||||
@@ -72,10 +74,10 @@ export default function DragNDrop({
|
||||
const sensors = useSensors(mouseSensor, touchSensor);
|
||||
|
||||
const handleDragStart = (event: DragStartEvent) => {
|
||||
const draggedLink = links.find(
|
||||
(link: any) => link.id === event.active.data.current?.linkId
|
||||
setActiveLink(
|
||||
(event.active.data.current
|
||||
?.link as LinkIncludingShortenedCollectionAndTags) ?? null
|
||||
);
|
||||
setActiveLink(draggedLink || null);
|
||||
};
|
||||
|
||||
const handleDragOverCancel = () => {
|
||||
@@ -83,63 +85,38 @@ export default function DragNDrop({
|
||||
};
|
||||
|
||||
const handleDragEnd = async (event: DragEndEvent) => {
|
||||
// If an onDragEnd prop is provided, use it instead of the default behavior
|
||||
if (onDragEndProp) {
|
||||
onDragEndProp(event);
|
||||
return;
|
||||
}
|
||||
const { over } = event;
|
||||
|
||||
const { over, active } = event;
|
||||
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
|
||||
if (over.data.current?.type === "tag") {
|
||||
const isTagAlreadyExists = activeLink.tags.some(
|
||||
(tag) => tag.name === over.data.current?.name
|
||||
);
|
||||
if (isTagAlreadyExists) {
|
||||
toast.error(t("tag_already_added"));
|
||||
return;
|
||||
}
|
||||
// to match the tags structure required to update the link
|
||||
const allTags: { name: string }[] = activeLink.tags.map((tag) => ({
|
||||
name: tag.name,
|
||||
}));
|
||||
const newTags = [...allTags, { name: over.data.current?.name as string }];
|
||||
updatedLink = {
|
||||
...activeLink,
|
||||
tags: newTags 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;
|
||||
const isFromRecentSection = active.data.current?.dashboardType === "recent";
|
||||
|
||||
// Immediately hide the drag overlay
|
||||
setActiveLink(null);
|
||||
|
||||
// if the link dropped over the same collection, toast
|
||||
if (activeLink.collection.id === collectionId) {
|
||||
toast.error(t("link_already_in_collection"));
|
||||
return;
|
||||
}
|
||||
|
||||
updatedLink = {
|
||||
...activeLink,
|
||||
collection: {
|
||||
id: collectionId,
|
||||
name: collectionName,
|
||||
ownerId,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const mutateWithToast = async (
|
||||
updatedLink: LinkIncludingShortenedCollectionAndTags,
|
||||
opts?: { invalidateDashboardOnError?: boolean }
|
||||
) => {
|
||||
const load = toast.loading(t("updating"));
|
||||
await updateLink.mutateAsync(updatedLink, {
|
||||
onSettled: (_, error) => {
|
||||
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"));
|
||||
@@ -147,6 +124,130 @@ export default function DragNDrop({
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
// 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) {
|
||||
toast.error(t("tag_already_added"));
|
||||
return;
|
||||
}
|
||||
|
||||
const allTags: { name: string }[] = (activeLink.tags ?? []).map(
|
||||
(tag) => ({
|
||||
name: tag.name,
|
||||
})
|
||||
);
|
||||
|
||||
const updatedLink: LinkIncludingShortenedCollectionAndTags = {
|
||||
...activeLink,
|
||||
tags: [...allTags, { name: tagName }] as any,
|
||||
};
|
||||
|
||||
await mutateWithToast(updatedLink, {
|
||||
invalidateDashboardOnError: typeof queryClient !== "undefined",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// DROP ON DASHBOARD "PINNED" SECTION
|
||||
const isPinnedSection = targetId === "pinned-links-section";
|
||||
|
||||
const canPin =
|
||||
typeof pinLink === "function" &&
|
||||
typeof user !== "undefined" &&
|
||||
typeof user?.id !== "undefined";
|
||||
|
||||
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 (
|
||||
<DndContext
|
||||
onDragStart={handleDragStart}
|
||||
|
||||
@@ -187,6 +187,7 @@ export default function LinkDetails({
|
||||
const target = e.target as HTMLElement;
|
||||
target.style.display = "none";
|
||||
}}
|
||||
unoptimized
|
||||
/>
|
||||
) : link.preview === "unavailable" ? (
|
||||
<div className="bg-gray-50 duration-100 h-40"></div>
|
||||
|
||||
@@ -4,7 +4,6 @@ import ViewDropdown from "./ViewDropdown";
|
||||
import { TFunction } from "i18next";
|
||||
import BulkDeleteLinksModal from "./ModalContent/BulkDeleteLinksModal";
|
||||
import BulkEditLinksModal from "./ModalContent/BulkEditLinksModal";
|
||||
import useCollectivePermissions from "@/hooks/useCollectivePermissions";
|
||||
import { useRouter } from "next/router";
|
||||
import useLinkStore from "@/store/links";
|
||||
import {
|
||||
@@ -46,7 +45,8 @@ const LinkListOptions = ({
|
||||
setEditMode,
|
||||
links,
|
||||
}: Props) => {
|
||||
const { selectedLinks, setSelectedLinks } = useLinkStore();
|
||||
const { selectedIds, setSelected, clearSelected, selectionCount } =
|
||||
useLinkStore();
|
||||
|
||||
const deleteLinksById = useBulkDeleteLinks();
|
||||
const refreshPreservations = useArchiveAction();
|
||||
@@ -62,45 +62,42 @@ const LinkListOptions = ({
|
||||
if (editMode && setEditMode) return setEditMode(false);
|
||||
}, [router]);
|
||||
|
||||
const collectivePermissions = useCollectivePermissions(
|
||||
selectedLinks.map((link) => link.collectionId as number)
|
||||
);
|
||||
|
||||
const handleSelectAll = () => {
|
||||
if (selectedLinks.length === links.length) {
|
||||
setSelectedLinks([]);
|
||||
if (selectionCount === links.length) {
|
||||
clearSelected();
|
||||
} else {
|
||||
setSelectedLinks(links.map((link) => link));
|
||||
setSelected(links.map((link) => link.id as number));
|
||||
}
|
||||
};
|
||||
|
||||
const bulkDeleteLinks = async () => {
|
||||
const load = toast.loading(t("deleting"));
|
||||
|
||||
await deleteLinksById.mutateAsync(
|
||||
selectedLinks.map((link) => link.id as number),
|
||||
{
|
||||
const ids = Object.keys(selectedIds).map(Number);
|
||||
|
||||
await deleteLinksById.mutateAsync(ids, {
|
||||
onSettled: (data, error) => {
|
||||
toast.dismiss(load);
|
||||
|
||||
if (error) {
|
||||
toast.error(error.message);
|
||||
} else {
|
||||
setSelectedLinks([]);
|
||||
clearSelected();
|
||||
setEditMode?.(false);
|
||||
toast.success(t("deleted"));
|
||||
}
|
||||
},
|
||||
}
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
const bulkRefreshPreservations = async () => {
|
||||
const load = toast.loading(t("sending_request"));
|
||||
|
||||
const ids = Object.keys(selectedIds).map(Number);
|
||||
|
||||
await refreshPreservations.mutateAsync(
|
||||
{
|
||||
linkIds: selectedLinks.map((link) => link.id as number),
|
||||
linkIds: ids,
|
||||
},
|
||||
{
|
||||
onSettled: (data, error) => {
|
||||
@@ -108,7 +105,7 @@ const LinkListOptions = ({
|
||||
if (error) {
|
||||
toast.error(error.message);
|
||||
} else {
|
||||
setSelectedLinks([]);
|
||||
clearSelected();
|
||||
setEditMode?.(false);
|
||||
toast.success(t("links_being_archived"));
|
||||
}
|
||||
@@ -133,7 +130,7 @@ const LinkListOptions = ({
|
||||
size="icon"
|
||||
onClick={() => {
|
||||
setEditMode(!editMode);
|
||||
setSelectedLinks([]);
|
||||
clearSelected();
|
||||
}}
|
||||
className={
|
||||
editMode ? "bg-primary/20 hover:bg-primary/20" : ""
|
||||
@@ -161,15 +158,15 @@ const LinkListOptions = ({
|
||||
type="checkbox"
|
||||
className="checkbox checkbox-primary"
|
||||
onChange={() => handleSelectAll()}
|
||||
checked={
|
||||
selectedLinks.length === links.length && links.length > 0
|
||||
}
|
||||
checked={selectionCount === links.length && links.length > 0}
|
||||
/>
|
||||
{selectedLinks.length > 0 ? (
|
||||
{selectionCount > 0 ? (
|
||||
<span>
|
||||
{selectedLinks.length === 1
|
||||
{selectionCount === 1
|
||||
? t("link_selected")
|
||||
: t("links_selected", { count: selectedLinks.length })}
|
||||
: t("links_selected", {
|
||||
count: selectionCount,
|
||||
})}
|
||||
</span>
|
||||
) : (
|
||||
<span>{t("nothing_selected")}</span>
|
||||
@@ -183,7 +180,7 @@ const LinkListOptions = ({
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => setBulkRefreshPreservationsModal(true)}
|
||||
disabled={selectedLinks.length === 0}
|
||||
disabled={selectionCount === 0}
|
||||
>
|
||||
<i className="bi-arrow-clockwise" />
|
||||
</Button>
|
||||
@@ -201,13 +198,7 @@ const LinkListOptions = ({
|
||||
onClick={() => setBulkEditLinksModal(true)}
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
disabled={
|
||||
selectedLinks.length === 0 ||
|
||||
!(
|
||||
collectivePermissions === true ||
|
||||
collectivePermissions?.canUpdate
|
||||
)
|
||||
}
|
||||
disabled={selectionCount === 0}
|
||||
>
|
||||
<i className="bi-pencil-square" />
|
||||
</Button>
|
||||
@@ -229,13 +220,7 @@ const LinkListOptions = ({
|
||||
}}
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
disabled={
|
||||
selectedLinks.length === 0 ||
|
||||
!(
|
||||
collectivePermissions === true ||
|
||||
collectivePermissions?.canDelete
|
||||
)
|
||||
}
|
||||
disabled={selectionCount === 0}
|
||||
>
|
||||
<i className="bi-trash text-error" />
|
||||
</Button>
|
||||
@@ -278,10 +263,10 @@ const LinkListOptions = ({
|
||||
title={t("refresh_preserved_formats")}
|
||||
>
|
||||
<p className="mb-5">
|
||||
{selectedLinks.length === 1
|
||||
{selectionCount === 1
|
||||
? t("refresh_preserved_formats_confirmation_desc")
|
||||
: t("refresh_multiple_preserved_formats_confirmation_desc", {
|
||||
count: selectedLinks.length,
|
||||
count: selectionCount,
|
||||
})}
|
||||
</p>
|
||||
</ConfirmationModal>
|
||||
|
||||
@@ -1,11 +1,7 @@
|
||||
import { useState } from "react";
|
||||
import {
|
||||
CollectionIncludingMembersAndLinkCount,
|
||||
LinkIncludingShortenedCollectionAndTags,
|
||||
} from "@linkwarden/types";
|
||||
import { LinkIncludingShortenedCollectionAndTags } from "@linkwarden/types";
|
||||
import usePermissions from "@/hooks/usePermissions";
|
||||
import DeleteLinkModal from "@/components/ModalContent/DeleteLinkModal";
|
||||
import { useTranslation } from "next-i18next";
|
||||
import { useDeleteLink, useGetLink } from "@linkwarden/router/links";
|
||||
import toast from "react-hot-toast";
|
||||
import LinkModal from "@/components/ModalContent/LinkModal";
|
||||
@@ -21,25 +17,25 @@ import {
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import ConfirmationModal from "@/components/ConfirmationModal";
|
||||
import { TFunction } from "i18next";
|
||||
|
||||
type Props = {
|
||||
link: LinkIncludingShortenedCollectionAndTags;
|
||||
collection: CollectionIncludingMembersAndLinkCount;
|
||||
linkModal: boolean;
|
||||
className?: string;
|
||||
setLinkModal: (value: boolean) => void;
|
||||
t: TFunction<"translation", undefined>;
|
||||
className?: string;
|
||||
ghost?: boolean;
|
||||
};
|
||||
|
||||
export default function LinkActions({
|
||||
link,
|
||||
linkModal,
|
||||
className,
|
||||
t,
|
||||
setLinkModal,
|
||||
className,
|
||||
ghost,
|
||||
}: Props) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const permissions = usePermissions(link.collection.id as number);
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
@@ -3,8 +3,7 @@ import {
|
||||
CollectionIncludingMembersAndLinkCount,
|
||||
LinkIncludingShortenedCollectionAndTags,
|
||||
} from "@linkwarden/types";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import useLinkStore from "@/store/links";
|
||||
import React, { useRef, useState } from "react";
|
||||
import unescapeString from "@/lib/client/unescapeString";
|
||||
import LinkActions from "@/components/LinkViews/LinkComponents/LinkActions";
|
||||
import LinkDate from "@/components/LinkViews/LinkComponents/LinkDate";
|
||||
@@ -15,15 +14,8 @@ import {
|
||||
formatAvailable,
|
||||
} from "@linkwarden/lib/formatStats";
|
||||
import LinkIcon from "./LinkIcon";
|
||||
import useOnScreen from "@/hooks/useOnScreen";
|
||||
import usePermissions from "@/hooks/usePermissions";
|
||||
import toast from "react-hot-toast";
|
||||
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 LinkPin from "./LinkPin";
|
||||
import LinkFormats from "./LinkFormats";
|
||||
@@ -31,156 +23,68 @@ import openLink from "@/lib/client/openLink";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { useDraggable } from "@dnd-kit/core";
|
||||
import { cn } from "@/lib/utils";
|
||||
import useMediaQuery from "@/hooks/useMediaQuery";
|
||||
import { TFunction } from "i18next";
|
||||
|
||||
type Props = {
|
||||
link: LinkIncludingShortenedCollectionAndTags;
|
||||
columns: number;
|
||||
className?: string;
|
||||
collection: CollectionIncludingMembersAndLinkCount;
|
||||
isPublicRoute: boolean;
|
||||
t: TFunction<"translation", undefined>;
|
||||
user: any;
|
||||
disableDraggable: boolean;
|
||||
isSelected: boolean;
|
||||
toggleSelected: (id: number) => void;
|
||||
imageHeightClass: string;
|
||||
editMode?: boolean;
|
||||
};
|
||||
|
||||
export default function LinkCard({ link, columns, editMode }: Props) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
// we don't want to use the draggable feature for screen under 1023px since the sidebar is hidden
|
||||
const isSmallScreen = useMediaQuery("(max-width: 1023px)");
|
||||
function LinkCard({
|
||||
link,
|
||||
collection,
|
||||
isPublicRoute,
|
||||
t,
|
||||
user,
|
||||
disableDraggable,
|
||||
isSelected,
|
||||
toggleSelected,
|
||||
imageHeightClass,
|
||||
editMode,
|
||||
}: Props) {
|
||||
const { attributes, listeners, setNodeRef, isDragging } = useDraggable({
|
||||
id: link.id?.toString() ?? "",
|
||||
data: {
|
||||
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 {
|
||||
settings: { show },
|
||||
} = 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 isVisible = useOnScreen(ref);
|
||||
const permissions = usePermissions(collection?.id as number);
|
||||
|
||||
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 (
|
||||
<div
|
||||
ref={setNodeRef}
|
||||
className={cn(
|
||||
"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",
|
||||
"relative group touch-manipulation select-none"
|
||||
)}
|
||||
onClick={() =>
|
||||
selectable
|
||||
? handleCheckboxClick(link)
|
||||
editMode
|
||||
? toggleSelected(link.id as number)
|
||||
: editMode
|
||||
? toast.error(t("link_selection_error"))
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<div ref={ref}>
|
||||
<div ref={ref} className="h-full">
|
||||
<div
|
||||
className="rounded-xl cursor-pointer h-full flex flex-col justify-between"
|
||||
onClick={() =>
|
||||
@@ -207,6 +111,7 @@ export default function LinkCard({ link, columns, editMode }: Props) {
|
||||
const target = e.target as HTMLElement;
|
||||
target.style.display = "none";
|
||||
}}
|
||||
unoptimized
|
||||
/>
|
||||
) : link.preview === "unavailable" ? (
|
||||
<div
|
||||
@@ -250,9 +155,13 @@ export default function LinkCard({ link, columns, editMode }: Props) {
|
||||
<Separator className="mb-1" />
|
||||
|
||||
<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">
|
||||
<LinkCollection link={link} collection={collection} />
|
||||
<LinkCollection
|
||||
link={link}
|
||||
collection={collection}
|
||||
isPublicRoute={isPublicRoute}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{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>
|
||||
<LinkActions
|
||||
link={link}
|
||||
collection={collection}
|
||||
linkModal={linkModal}
|
||||
t={t}
|
||||
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"
|
||||
/>
|
||||
@@ -276,3 +185,5 @@ export default function LinkCard({ link, columns, editMode }: Props) {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default React.memo(LinkCard);
|
||||
|
||||
@@ -5,20 +5,17 @@ import {
|
||||
} from "@linkwarden/types";
|
||||
import { IconWeight } from "@phosphor-icons/react";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/router";
|
||||
import React from "react";
|
||||
|
||||
export default function LinkCollection({
|
||||
function LinkCollection({
|
||||
link,
|
||||
collection,
|
||||
isPublicRoute,
|
||||
}: {
|
||||
link: LinkIncludingShortenedCollectionAndTags;
|
||||
collection: CollectionIncludingMembersAndLinkCount;
|
||||
isPublicRoute: boolean;
|
||||
}) {
|
||||
const router = useRouter();
|
||||
|
||||
const isPublicRoute = router.pathname.startsWith("/public") ? true : false;
|
||||
|
||||
return !isPublicRoute && collection?.name ? (
|
||||
<>
|
||||
<Link
|
||||
@@ -47,3 +44,5 @@ export default function LinkCollection({
|
||||
</>
|
||||
) : null;
|
||||
}
|
||||
|
||||
export default React.memo(LinkCollection);
|
||||
|
||||
@@ -1,11 +1,7 @@
|
||||
import { LinkIncludingShortenedCollectionAndTags } from "@linkwarden/types";
|
||||
import React from "react";
|
||||
|
||||
export default function LinkDate({
|
||||
link,
|
||||
}: {
|
||||
link: LinkIncludingShortenedCollectionAndTags;
|
||||
}) {
|
||||
function LinkDate({ link }: { link: LinkIncludingShortenedCollectionAndTags }) {
|
||||
const formattedDate = new Date(
|
||||
(link.importDate || link.createdAt) as string
|
||||
).toLocaleString("en-US", {
|
||||
@@ -21,3 +17,5 @@ export default function LinkDate({
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default React.memo(LinkDate);
|
||||
|
||||
@@ -7,7 +7,7 @@ import { IconWeight } from "@phosphor-icons/react";
|
||||
import clsx from "clsx";
|
||||
import oklchVariableToHex from "@/lib/client/oklchVariableToHex";
|
||||
|
||||
export default function LinkIcon({
|
||||
function LinkIcon({
|
||||
link,
|
||||
className,
|
||||
hideBackground,
|
||||
@@ -45,17 +45,17 @@ export default function LinkIcon({
|
||||
) : link.type === "url" && url ? (
|
||||
<>
|
||||
<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}
|
||||
height={64}
|
||||
alt=""
|
||||
unoptimized
|
||||
className={clsx(
|
||||
iconClasses,
|
||||
faviconLoaded ? "" : "absolute opacity-0"
|
||||
)}
|
||||
draggable="false"
|
||||
onLoadingComplete={() => setFaviconLoaded(true)}
|
||||
onError={() => setFaviconLoaded(false)}
|
||||
onLoad={() => setFaviconLoaded(true)}
|
||||
/>
|
||||
{!faviconLoaded && (
|
||||
<LinkPlaceholderIcon
|
||||
@@ -104,3 +104,5 @@ const LinkPlaceholderIcon = ({
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default React.memo(LinkIcon);
|
||||
|
||||
@@ -2,113 +2,61 @@ import {
|
||||
CollectionIncludingMembersAndLinkCount,
|
||||
LinkIncludingShortenedCollectionAndTags,
|
||||
} from "@linkwarden/types";
|
||||
import { useEffect, useState } from "react";
|
||||
import useLinkStore from "@/store/links";
|
||||
import React, { useState } from "react";
|
||||
import unescapeString from "@/lib/client/unescapeString";
|
||||
import LinkActions from "@/components/LinkViews/LinkComponents/LinkActions";
|
||||
import LinkDate from "@/components/LinkViews/LinkComponents/LinkDate";
|
||||
import LinkCollection from "@/components/LinkViews/LinkComponents/LinkCollection";
|
||||
import LinkIcon from "@/components/LinkViews/LinkComponents/LinkIcon";
|
||||
import { cn, isPWA } from "@/lib/utils";
|
||||
import usePermissions from "@/hooks/usePermissions";
|
||||
import toast from "react-hot-toast";
|
||||
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 LinkPin from "./LinkPin";
|
||||
import { useRouter } from "next/router";
|
||||
import { atLeastOneFormatAvailable } from "@linkwarden/lib/formatStats";
|
||||
import LinkFormats from "./LinkFormats";
|
||||
import openLink from "@/lib/client/openLink";
|
||||
import { useDraggable } from "@dnd-kit/core";
|
||||
import useMediaQuery from "@/hooks/useMediaQuery";
|
||||
import { TFunction } from "i18next";
|
||||
|
||||
type Props = {
|
||||
link: LinkIncludingShortenedCollectionAndTags;
|
||||
collection: CollectionIncludingMembersAndLinkCount;
|
||||
isPublicRoute: boolean;
|
||||
t: TFunction<"translation", undefined>;
|
||||
disableDraggable: boolean;
|
||||
user: any;
|
||||
isSelected: boolean;
|
||||
toggleSelected: (id: number) => void;
|
||||
count: number;
|
||||
className?: string;
|
||||
editMode?: boolean;
|
||||
};
|
||||
|
||||
export default function LinkCardCompact({ link, editMode }: Props) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const isSmallScreen = useMediaQuery("(max-width: 1023px)");
|
||||
function LinkList({
|
||||
link,
|
||||
collection,
|
||||
isPublicRoute,
|
||||
t,
|
||||
disableDraggable,
|
||||
user,
|
||||
isSelected,
|
||||
toggleSelected,
|
||||
editMode,
|
||||
}: Props) {
|
||||
const { attributes, listeners, setNodeRef, isDragging } = useDraggable({
|
||||
id: link.id?.toString() ?? "",
|
||||
data: {
|
||||
linkId: link.id,
|
||||
link,
|
||||
},
|
||||
disabled: isSmallScreen,
|
||||
disabled: disableDraggable,
|
||||
});
|
||||
|
||||
const { data: collections = [] } = useCollections();
|
||||
|
||||
const { data: user } = useUser();
|
||||
const { setSelectedLinks, selectedLinks } = useLinkStore();
|
||||
|
||||
const {
|
||||
settings: { show },
|
||||
} = 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);
|
||||
|
||||
return (
|
||||
@@ -117,14 +65,16 @@ export default function LinkCardCompact({ link, editMode }: Props) {
|
||||
ref={setNodeRef}
|
||||
className={cn(
|
||||
"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",
|
||||
isDragging ? "opacity-30" : "opacity-100",
|
||||
"duration-200, touch-manipulation select-none"
|
||||
)}
|
||||
onClick={() =>
|
||||
selectable
|
||||
? handleCheckboxClick(link)
|
||||
editMode
|
||||
? toggleSelected(link.id as number)
|
||||
: editMode
|
||||
? toast.error(t("link_selection_error"))
|
||||
: 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="flex items-center gap-x-3 text-neutral flex-wrap">
|
||||
{show.link && <LinkTypeBadge link={link} />}
|
||||
{show.collection && (
|
||||
<LinkCollection link={link} collection={collection} />
|
||||
{show.collection && collection && (
|
||||
<LinkCollection
|
||||
link={link}
|
||||
collection={collection}
|
||||
isPublicRoute={isPublicRoute}
|
||||
/>
|
||||
)}
|
||||
{show.date && <LinkDate link={link} />}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{!isPublic && <LinkPin link={link} />}
|
||||
{!isPublicRoute && <LinkPin link={link} />}
|
||||
<LinkActions
|
||||
link={link}
|
||||
collection={collection}
|
||||
linkModal={linkModal}
|
||||
t={t}
|
||||
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"
|
||||
/>
|
||||
@@ -184,3 +138,5 @@ export default function LinkCardCompact({ link, editMode }: Props) {
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default React.memo(LinkList);
|
||||
|
||||
@@ -3,8 +3,7 @@ import {
|
||||
CollectionIncludingMembersAndLinkCount,
|
||||
LinkIncludingShortenedCollectionAndTags,
|
||||
} from "@linkwarden/types";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import useLinkStore from "@/store/links";
|
||||
import React, { useRef, useState } from "react";
|
||||
import unescapeString from "@/lib/client/unescapeString";
|
||||
import LinkActions from "@/components/LinkViews/LinkComponents/LinkActions";
|
||||
import LinkDate from "@/components/LinkViews/LinkComponents/LinkDate";
|
||||
@@ -16,152 +15,58 @@ import {
|
||||
} from "@linkwarden/lib/formatStats";
|
||||
import Link from "next/link";
|
||||
import LinkIcon from "./LinkIcon";
|
||||
import useOnScreen from "@/hooks/useOnScreen";
|
||||
import usePermissions from "@/hooks/usePermissions";
|
||||
import toast from "react-hot-toast";
|
||||
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 clsx from "clsx";
|
||||
import LinkPin from "./LinkPin";
|
||||
import { useRouter } from "next/router";
|
||||
import LinkFormats from "./LinkFormats";
|
||||
import openLink from "@/lib/client/openLink";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { useDraggable } from "@dnd-kit/core";
|
||||
import useMediaQuery from "@/hooks/useMediaQuery";
|
||||
import { cn } from "@linkwarden/lib";
|
||||
import { TFunction } from "i18next";
|
||||
|
||||
type Props = {
|
||||
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;
|
||||
};
|
||||
|
||||
export default function LinkMasonry({ link, editMode, columns }: Props) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
// we don't want to use the draggable feature for screen under 1023px since the sidebar is hidden
|
||||
const isSmallScreen = useMediaQuery("(max-width: 1023px)");
|
||||
function LinkMasonry({
|
||||
link,
|
||||
collection,
|
||||
isPublicRoute,
|
||||
t,
|
||||
disableDraggable,
|
||||
user,
|
||||
isSelected,
|
||||
toggleSelected,
|
||||
imageHeightClass,
|
||||
editMode,
|
||||
}: Props) {
|
||||
const { attributes, listeners, setNodeRef, isDragging } = useDraggable({
|
||||
id: link.id?.toString() ?? "",
|
||||
data: {
|
||||
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 {
|
||||
settings: { show },
|
||||
} = 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 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);
|
||||
|
||||
@@ -170,11 +75,11 @@ export default function LinkMasonry({ link, editMode, columns }: Props) {
|
||||
ref={setNodeRef}
|
||||
className={cn(
|
||||
"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={() =>
|
||||
selectable
|
||||
? handleCheckboxClick(link)
|
||||
editMode
|
||||
? toggleSelected(link.id as number)
|
||||
: editMode
|
||||
? toast.error(t("link_selection_error"))
|
||||
: undefined
|
||||
@@ -205,6 +110,7 @@ export default function LinkMasonry({ link, editMode, columns }: Props) {
|
||||
const target = e.target as HTMLElement;
|
||||
target.style.display = "none";
|
||||
}}
|
||||
unoptimized
|
||||
/>
|
||||
) : link.preview === "unavailable" ? null : (
|
||||
<div
|
||||
@@ -268,9 +174,13 @@ export default function LinkMasonry({ link, editMode, columns }: Props) {
|
||||
<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">
|
||||
{!isPublicRoute && show.collection && (
|
||||
{!isPublicRoute && show.collection && collection && (
|
||||
<div className="cursor-pointer truncate">
|
||||
<LinkCollection link={link} collection={collection} />
|
||||
<LinkCollection
|
||||
link={link}
|
||||
collection={collection}
|
||||
isPublicRoute={isPublicRoute}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{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>
|
||||
<LinkActions
|
||||
link={link}
|
||||
collection={collection}
|
||||
linkModal={linkModal}
|
||||
t={t}
|
||||
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"
|
||||
/>
|
||||
@@ -293,3 +203,5 @@ export default function LinkMasonry({ link, editMode, columns }: Props) {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default React.memo(LinkMasonry);
|
||||
|
||||
@@ -1,20 +1,23 @@
|
||||
import { LinkIncludingShortenedCollectionAndTags } from "@linkwarden/types";
|
||||
import Link from "next/link";
|
||||
import React, { useEffect, useState } from "react";
|
||||
|
||||
export default function LinkTypeBadge({
|
||||
function LinkTypeBadge({
|
||||
link,
|
||||
}: {
|
||||
link: LinkIncludingShortenedCollectionAndTags;
|
||||
}) {
|
||||
let shortendURL;
|
||||
const [url, setUrl] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
if (link.type === "url" && link.url) {
|
||||
try {
|
||||
shortendURL = new URL(link.url).host.toLowerCase();
|
||||
setUrl(new URL(link.url).host.toLowerCase());
|
||||
} catch (error) {
|
||||
console.log(error);
|
||||
}
|
||||
}
|
||||
}, [link]);
|
||||
|
||||
const typeIcon = () => {
|
||||
switch (link.type) {
|
||||
@@ -27,7 +30,7 @@ export default function LinkTypeBadge({
|
||||
}
|
||||
};
|
||||
|
||||
return link.url && shortendURL ? (
|
||||
return link.url && url ? (
|
||||
<Link
|
||||
href={link.url || ""}
|
||||
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"
|
||||
>
|
||||
<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>
|
||||
) : (
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
export default React.memo(LinkTypeBadge);
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import LinkCard from "@/components/LinkViews/LinkComponents/LinkCard";
|
||||
import {
|
||||
CollectionIncludingMembersAndLinkCount,
|
||||
LinkIncludingShortenedCollectionAndTags,
|
||||
ViewMode,
|
||||
} from "@linkwarden/types";
|
||||
@@ -7,26 +8,43 @@ import { useEffect, useState } from "react";
|
||||
import { useInView } from "react-intersection-observer";
|
||||
import LinkMasonry from "@/components/LinkViews/LinkComponents/LinkMasonry";
|
||||
import Masonry from "react-masonry-css";
|
||||
import resolveConfig from "tailwindcss/resolveConfig";
|
||||
import tailwindConfig from "../../tailwind.config.js";
|
||||
import { useMemo } from "react";
|
||||
import LinkList from "@/components/LinkViews/LinkComponents/LinkList";
|
||||
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,
|
||||
collectionsById,
|
||||
isPublicRoute,
|
||||
t,
|
||||
user,
|
||||
disableDraggable,
|
||||
isSelected,
|
||||
toggleSelected,
|
||||
editMode,
|
||||
isLoading,
|
||||
placeholders,
|
||||
hasNextPage,
|
||||
placeHolderRef,
|
||||
}: {
|
||||
links?: LinkIncludingShortenedCollectionAndTags[];
|
||||
editMode?: boolean;
|
||||
isLoading?: boolean;
|
||||
placeholders?: number[];
|
||||
hasNextPage?: boolean;
|
||||
placeHolderRef?: any;
|
||||
links: LinkIncludingShortenedCollectionAndTags[];
|
||||
collectionsById: Map<number, CollectionIncludingMembersAndLinkCount>;
|
||||
isPublicRoute: boolean;
|
||||
t: TFunction<"translation", undefined>;
|
||||
user: 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);
|
||||
|
||||
@@ -59,6 +77,23 @@ export function CardView({
|
||||
[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(() => {
|
||||
const handleResize = () => {
|
||||
if (settings.columns === 0) {
|
||||
@@ -82,51 +117,66 @@ export function CardView({
|
||||
|
||||
return (
|
||||
<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 (
|
||||
<LinkCard
|
||||
key={i}
|
||||
key={e.id}
|
||||
link={e}
|
||||
collection={collection as CollectionIncludingMembersAndLinkCount}
|
||||
isPublicRoute={isPublicRoute}
|
||||
t={t}
|
||||
user={user}
|
||||
disableDraggable={disableDraggable}
|
||||
isSelected={selected}
|
||||
toggleSelected={toggleSelected}
|
||||
editMode={editMode}
|
||||
columns={columnCount}
|
||||
imageHeightClass={imageHeightClass}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
||||
{(hasNextPage || isLoading) &&
|
||||
placeholders?.map((e, i) => {
|
||||
return (
|
||||
<div
|
||||
className="flex flex-col gap-4"
|
||||
ref={e === 1 ? placeHolderRef : undefined}
|
||||
key={i}
|
||||
>
|
||||
{(hasNextPage || isLoading) && (
|
||||
<div className="flex flex-col gap-4" ref={placeHolderRef}>
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
export function MasonryView({
|
||||
function MasonryView({
|
||||
links,
|
||||
collectionsById,
|
||||
isPublicRoute,
|
||||
t,
|
||||
disableDraggable,
|
||||
user,
|
||||
isSelected,
|
||||
toggleSelected,
|
||||
editMode,
|
||||
isLoading,
|
||||
placeholders,
|
||||
hasNextPage,
|
||||
placeHolderRef,
|
||||
}: {
|
||||
links?: LinkIncludingShortenedCollectionAndTags[];
|
||||
editMode?: boolean;
|
||||
isLoading?: boolean;
|
||||
placeholders?: number[];
|
||||
hasNextPage?: boolean;
|
||||
placeHolderRef?: any;
|
||||
links: LinkIncludingShortenedCollectionAndTags[];
|
||||
collectionsById: Map<number, CollectionIncludingMembersAndLinkCount>;
|
||||
isPublicRoute: boolean;
|
||||
t: TFunction<"translation", undefined>;
|
||||
disableDraggable: boolean;
|
||||
user: any;
|
||||
isSelected: (id: number) => boolean;
|
||||
toggleSelected: (id: number) => void;
|
||||
editMode: boolean;
|
||||
isLoading: boolean;
|
||||
hasNextPage: boolean;
|
||||
placeHolderRef: any;
|
||||
}) {
|
||||
const settings = useLocalSettingsStore((state) => state.settings);
|
||||
|
||||
@@ -159,6 +209,23 @@ export function MasonryView({
|
||||
[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(() => {
|
||||
const handleResize = () => {
|
||||
if (settings.columns === 0) {
|
||||
@@ -180,17 +247,7 @@ export function MasonryView({
|
||||
};
|
||||
}, [settings.columns]);
|
||||
|
||||
const fullConfig = resolveConfig(tailwindConfig as any);
|
||||
|
||||
const breakpointColumnsObj = useMemo(() => {
|
||||
return {
|
||||
default: 5,
|
||||
1900: 4,
|
||||
1500: 3,
|
||||
880: 2,
|
||||
550: 1,
|
||||
};
|
||||
}, []);
|
||||
const breakpointColumnsObj = { default: 5, 1900: 4, 1500: 3, 880: 2, 550: 1 };
|
||||
|
||||
return (
|
||||
<Masonry
|
||||
@@ -200,66 +257,92 @@ export function MasonryView({
|
||||
columnClassName="flex flex-col gap-5 !w-full"
|
||||
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 (
|
||||
<LinkMasonry
|
||||
key={i}
|
||||
key={e.id}
|
||||
link={e}
|
||||
collection={collection as CollectionIncludingMembersAndLinkCount}
|
||||
isPublicRoute={isPublicRoute}
|
||||
t={t}
|
||||
disableDraggable={disableDraggable}
|
||||
user={user}
|
||||
isSelected={selected}
|
||||
toggleSelected={toggleSelected}
|
||||
imageHeightClass={imageHeightClass}
|
||||
editMode={editMode}
|
||||
columns={columnCount}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
||||
{(hasNextPage || isLoading) &&
|
||||
placeholders?.map((e, i) => {
|
||||
return (
|
||||
<div
|
||||
className="flex flex-col gap-4"
|
||||
ref={e === 1 ? placeHolderRef : undefined}
|
||||
key={i}
|
||||
>
|
||||
{(hasNextPage || isLoading) && (
|
||||
<div className="flex flex-col gap-4" ref={placeHolderRef}>
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
export function ListView({
|
||||
function ListView({
|
||||
links,
|
||||
collectionsById,
|
||||
isPublicRoute,
|
||||
t,
|
||||
disableDraggable,
|
||||
user,
|
||||
isSelected,
|
||||
toggleSelected,
|
||||
editMode,
|
||||
isLoading,
|
||||
placeholders,
|
||||
hasNextPage,
|
||||
placeHolderRef,
|
||||
}: {
|
||||
links?: LinkIncludingShortenedCollectionAndTags[];
|
||||
editMode?: boolean;
|
||||
isLoading?: boolean;
|
||||
placeholders?: number[];
|
||||
hasNextPage?: boolean;
|
||||
placeHolderRef?: any;
|
||||
links: LinkIncludingShortenedCollectionAndTags[];
|
||||
collectionsById: Map<number, CollectionIncludingMembersAndLinkCount>;
|
||||
isPublicRoute: boolean;
|
||||
t: TFunction<"translation", undefined>;
|
||||
disableDraggable: boolean;
|
||||
user: any;
|
||||
isSelected: (id: number) => boolean;
|
||||
toggleSelected: (id: number) => void;
|
||||
editMode: boolean;
|
||||
isLoading: boolean;
|
||||
hasNextPage: boolean;
|
||||
placeHolderRef: any;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex flex-col">
|
||||
{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) &&
|
||||
placeholders?.map((e, i) => {
|
||||
return (
|
||||
<div
|
||||
ref={e === 1 ? placeHolderRef : undefined}
|
||||
key={i}
|
||||
className="flex gap-2 py-2 px-1"
|
||||
>
|
||||
{(hasNextPage || isLoading) && (
|
||||
<div ref={placeHolderRef} className="flex gap-2 py-2 px-1">
|
||||
<div className="skeleton h-12 w-12"></div>
|
||||
<div className="flex flex-col gap-3 w-full">
|
||||
<div className="skeleton h-2 w-2/3"></div>
|
||||
@@ -267,8 +350,7 @@ export function ListView({
|
||||
<div className="skeleton h-2 w-1/3"></div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -277,30 +359,88 @@ export default function Links({
|
||||
layout,
|
||||
links,
|
||||
editMode,
|
||||
placeholderCount,
|
||||
useData,
|
||||
}: {
|
||||
layout: ViewMode;
|
||||
links?: LinkIncludingShortenedCollectionAndTags[];
|
||||
editMode?: boolean;
|
||||
placeholderCount?: number;
|
||||
useData?: any;
|
||||
}) {
|
||||
const { ref, inView } = useInView();
|
||||
|
||||
const { t } = useTranslation();
|
||||
const router = useRouter();
|
||||
|
||||
const isPublicRoute = router.pathname.startsWith("/public") ? true : false;
|
||||
|
||||
useEffect(() => {
|
||||
if (inView && useData?.fetchNextPage && useData?.hasNextPage) {
|
||||
if (!inView) return;
|
||||
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) {
|
||||
return (
|
||||
<ListView
|
||||
links={links}
|
||||
editMode={editMode}
|
||||
links={links || []}
|
||||
collectionsById={collectionsById}
|
||||
isPublicRoute={isPublicRoute}
|
||||
t={t}
|
||||
disableDraggable={disableDraggable}
|
||||
user={user}
|
||||
toggleSelected={toggleSelected}
|
||||
isSelected={isSelected}
|
||||
editMode={editMode || false}
|
||||
isLoading={useData?.isLoading}
|
||||
placeholders={placeholderCountToArray(placeholderCount)}
|
||||
hasNextPage={useData?.hasNextPage}
|
||||
placeHolderRef={ref}
|
||||
/>
|
||||
@@ -308,10 +448,16 @@ export default function Links({
|
||||
} else if (layout === ViewMode.Masonry) {
|
||||
return (
|
||||
<MasonryView
|
||||
links={links}
|
||||
editMode={editMode}
|
||||
links={links || []}
|
||||
collectionsById={collectionsById}
|
||||
isPublicRoute={isPublicRoute}
|
||||
t={t}
|
||||
disableDraggable={disableDraggable}
|
||||
user={user}
|
||||
toggleSelected={toggleSelected}
|
||||
isSelected={isSelected}
|
||||
editMode={editMode || false}
|
||||
isLoading={useData?.isLoading}
|
||||
placeholders={placeholderCountToArray(placeholderCount)}
|
||||
hasNextPage={useData?.hasNextPage}
|
||||
placeHolderRef={ref}
|
||||
/>
|
||||
@@ -320,16 +466,19 @@ export default function Links({
|
||||
// Default to card view
|
||||
return (
|
||||
<CardView
|
||||
links={links}
|
||||
editMode={editMode}
|
||||
links={links || []}
|
||||
collectionsById={collectionsById}
|
||||
isPublicRoute={isPublicRoute}
|
||||
t={t}
|
||||
user={user}
|
||||
disableDraggable={disableDraggable}
|
||||
toggleSelected={toggleSelected}
|
||||
isSelected={isSelected}
|
||||
editMode={editMode || false}
|
||||
isLoading={useData?.isLoading}
|
||||
placeholders={placeholderCountToArray(placeholderCount)}
|
||||
hasNextPage={useData?.hasNextPage}
|
||||
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) {
|
||||
const { t } = useTranslation();
|
||||
const { selectedLinks, setSelectedLinks } = useLinkStore();
|
||||
const { selectedIds, clearSelected, selectionCount } = useLinkStore();
|
||||
|
||||
const deleteLinksById = useBulkDeleteLinks();
|
||||
|
||||
const deleteLink = async () => {
|
||||
const load = toast.loading(t("deleting"));
|
||||
const ids = Object.keys(selectedIds).map(Number);
|
||||
|
||||
await deleteLinksById.mutateAsync(
|
||||
selectedLinks.map((link) => link.id as number),
|
||||
{
|
||||
await deleteLinksById.mutateAsync(ids, {
|
||||
onSettled: (data, error) => {
|
||||
toast.dismiss(load);
|
||||
|
||||
if (error) {
|
||||
toast.error(error.message);
|
||||
} else {
|
||||
setSelectedLinks([]);
|
||||
clearSelected();
|
||||
onClose();
|
||||
toast.success(t("deleted"));
|
||||
}
|
||||
},
|
||||
}
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal toggleModal={onClose}>
|
||||
<p className="text-xl font-thin text-red-500">
|
||||
{selectedLinks.length === 1
|
||||
{selectionCount === 1
|
||||
? t("delete_link")
|
||||
: t("delete_links", { count: selectedLinks.length })}
|
||||
: t("delete_links", { count: selectionCount })}
|
||||
</p>
|
||||
|
||||
<Separator className="my-3" />
|
||||
|
||||
<div className="flex flex-col gap-3">
|
||||
<p>
|
||||
{selectedLinks.length === 1
|
||||
{selectionCount === 1
|
||||
? t("link_deletion_confirmation_message")
|
||||
: t("links_deletion_confirmation_message", {
|
||||
count: selectedLinks.length,
|
||||
count: selectionCount,
|
||||
})}
|
||||
</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) {
|
||||
const { t } = useTranslation();
|
||||
const { selectedLinks, setSelectedLinks } = useLinkStore();
|
||||
const { selectedIds, clearSelected, selectionCount } = useLinkStore();
|
||||
const [submitLoader, setSubmitLoader] = useState(false);
|
||||
const [removePreviousTags, setRemovePreviousTags] = useState(false);
|
||||
const [updatedValues, setUpdatedValues] = useState<
|
||||
@@ -40,9 +40,13 @@ export default function BulkEditLinksModal({ onClose }: Props) {
|
||||
|
||||
const load = toast.loading(t("updating"));
|
||||
|
||||
const links = Object.keys(selectedIds).map((k) => ({
|
||||
id: Number(k),
|
||||
}));
|
||||
|
||||
await updateLinks.mutateAsync(
|
||||
{
|
||||
links: selectedLinks,
|
||||
links,
|
||||
newData: updatedValues,
|
||||
removePreviousTags,
|
||||
},
|
||||
@@ -54,7 +58,7 @@ export default function BulkEditLinksModal({ onClose }: Props) {
|
||||
if (error) {
|
||||
toast.error(error.message);
|
||||
} else {
|
||||
setSelectedLinks([]);
|
||||
clearSelected();
|
||||
onClose();
|
||||
toast.success(t("updated"));
|
||||
}
|
||||
@@ -67,9 +71,9 @@ export default function BulkEditLinksModal({ onClose }: Props) {
|
||||
return (
|
||||
<Modal toggleModal={onClose}>
|
||||
<p className="text-xl font-thin">
|
||||
{selectedLinks.length === 1
|
||||
{selectionCount === 1
|
||||
? t("edit_link")
|
||||
: t("edit_links", { count: selectedLinks.length })}
|
||||
: t("edit_links", { count: selectionCount })}
|
||||
</p>
|
||||
<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,
|
||||
TooltipTrigger,
|
||||
} 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() {
|
||||
const { t } = useTranslation();
|
||||
const router = useRouter();
|
||||
const { data: user } = useUser();
|
||||
|
||||
const [sidebar, setSidebar] = useState(false);
|
||||
|
||||
@@ -50,7 +57,43 @@ export default function Navbar() {
|
||||
const [newCollectionModal, setNewCollectionModal] = 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 (
|
||||
<>
|
||||
{STRIPE_ENABLED && isTrialing && (
|
||||
<Link
|
||||
href="/subscribe"
|
||||
className="w-full text-sm cursor-pointer select-none bg-base-200"
|
||||
>
|
||||
<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">
|
||||
<i className="bi-clock text-primary" />
|
||||
{daysLeft === 1
|
||||
? t("trial_left_singular")
|
||||
: t("trial_left_plural", { count: daysLeft })}
|
||||
</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"
|
||||
@@ -124,7 +167,9 @@ export default function Navbar() {
|
||||
</ClickAwayHandler>
|
||||
</div>
|
||||
)}
|
||||
{newLinkModal && <NewLinkModal onClose={() => setNewLinkModal(false)} />}
|
||||
{newLinkModal && (
|
||||
<NewLinkModal onClose={() => setNewLinkModal(false)} />
|
||||
)}
|
||||
{newCollectionModal && (
|
||||
<NewCollectionModal onClose={() => setNewCollectionModal(false)} />
|
||||
)}
|
||||
@@ -132,5 +177,6 @@ export default function Navbar() {
|
||||
<UploadFileModal onClose={() => setUploadFileModal(false)} />
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -34,8 +34,8 @@ export default function NoLinksFound({ text }: Props) {
|
||||
}}
|
||||
variant="accent"
|
||||
>
|
||||
<i className="bi-plus-lg text-3xl left-2 group-hover:ml-[4rem] absolute duration-100"></i>
|
||||
<span className="group-hover:opacity-0 text-right w-full duration-100">
|
||||
<i className="bi-plus-lg text-xl duration-100"></i>
|
||||
<span className="group-hover:opacity-0 w-full duration-100">
|
||||
{t("create_new_link")}
|
||||
</span>
|
||||
</Button>
|
||||
|
||||
@@ -66,6 +66,16 @@ export const PreservationContent: React.FC<Props> = ({ link, format }) => {
|
||||
}
|
||||
}, [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;
|
||||
|
||||
const renderFormat = () => {
|
||||
@@ -126,6 +136,7 @@ export const PreservationContent: React.FC<Props> = ({ link, format }) => {
|
||||
>
|
||||
<img
|
||||
alt=""
|
||||
ref={imgRef}
|
||||
src={`/api/v1/archives/${link.id}?format=${currentFormat}`}
|
||||
className={clsx("w-fit mx-auto", !imageLoaded && "hidden")}
|
||||
onLoad={(e) => {
|
||||
|
||||
@@ -28,11 +28,10 @@ import HighlightDrawer from "../HighlightDrawer";
|
||||
type Props = {
|
||||
link: LinkIncludingShortenedCollectionAndTags;
|
||||
format?: ArchivedFormat;
|
||||
showNavbar: boolean;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
const PreservationNavbar = ({ link, format, showNavbar, className }: Props) => {
|
||||
const PreservationNavbar = ({ link, format, className }: Props) => {
|
||||
const { data: collections = [] } = useCollections();
|
||||
|
||||
const [collection, setCollection] =
|
||||
@@ -83,8 +82,7 @@ const PreservationNavbar = ({ link, format, showNavbar, className }: Props) => {
|
||||
<>
|
||||
<div
|
||||
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",
|
||||
showNavbar ? "translate-y-0" : "-translate-y-full",
|
||||
"p-2 z-10 bg-base-100 flex gap-2 justify-between fixed top-0 left-0 right-0",
|
||||
className
|
||||
)}
|
||||
>
|
||||
@@ -217,7 +215,7 @@ const PreservationNavbar = ({ link, format, showNavbar, className }: Props) => {
|
||||
<ToggleDarkMode />
|
||||
<LinkActions
|
||||
link={link}
|
||||
collection={collection}
|
||||
t={t}
|
||||
linkModal={linkModal}
|
||||
setLinkModal={(e) => setLinkModal(e)}
|
||||
ghost
|
||||
|
||||
@@ -3,15 +3,12 @@ import { useRouter } from "next/router";
|
||||
import { useGetLink, useLinks } from "@linkwarden/router/links";
|
||||
import { PreservationContent } from "./PreservationContent";
|
||||
import PreservationNavbar from "./PreservationNavbar";
|
||||
import { ArchivedFormat } from "@linkwarden/types";
|
||||
|
||||
export default function PreservationPageContent() {
|
||||
const router = useRouter();
|
||||
const { links } = useLinks();
|
||||
|
||||
const [showNavbar, setShowNavbar] = useState(true);
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
const lastScrollTop = useRef(0);
|
||||
|
||||
let isPublicRoute = router.pathname.startsWith("/public") ? true : undefined;
|
||||
|
||||
@@ -41,41 +38,13 @@ export default function PreservationPageContent() {
|
||||
};
|
||||
}, [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 (
|
||||
<div>
|
||||
{link?.id && (
|
||||
<PreservationNavbar
|
||||
link={link}
|
||||
format={Number(router.query.format)}
|
||||
showNavbar={showNavbar}
|
||||
/>
|
||||
<PreservationNavbar link={link} format={Number(router.query.format)} />
|
||||
)}
|
||||
<div
|
||||
className={`bg-base-200 overflow-y-auto w-screen ${
|
||||
showNavbar ? "h-[calc(100vh-3.1rem)] mt-[3.1rem]" : "h-screen"
|
||||
}`}
|
||||
className={`bg-base-200 overflow-y-auto w-screen h-[calc(100vh-3.1rem)] mt-[3.1rem]`}
|
||||
ref={scrollRef}
|
||||
>
|
||||
<PreservationContent link={link} format={Number(router.query.format)} />
|
||||
|
||||
@@ -57,6 +57,7 @@ export default function ProfilePhoto({
|
||||
draggable={false}
|
||||
onError={() => setImage("")}
|
||||
className="aspect-square rounded-full"
|
||||
unoptimized
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/router";
|
||||
import { useEffect, useState } from "react";
|
||||
import { Disclosure, Transition } from "@headlessui/react";
|
||||
@@ -7,9 +6,27 @@ import CollectionListing from "@/components/CollectionListing";
|
||||
import { useTranslation } from "next-i18next";
|
||||
import { useCollections } from "@linkwarden/router/collections";
|
||||
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 [tagDisclosure, setTagDisclosure] = useState<boolean>(() => {
|
||||
const storedValue = localStorage.getItem("tagDisclosure");
|
||||
@@ -30,6 +47,8 @@ export default function Sidebar({ className }: { className?: string }) {
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
const { data: user } = useUser();
|
||||
|
||||
useEffect(() => {
|
||||
localStorage.setItem("tagDisclosure", tagDisclosure ? "true" : "false");
|
||||
}, [tagDisclosure]);
|
||||
@@ -48,37 +67,140 @@ export default function Sidebar({ className }: { className?: string }) {
|
||||
return (
|
||||
<div
|
||||
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 || ""
|
||||
}`}
|
||||
className={cn(
|
||||
"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={cn(
|
||||
"flex flex-col",
|
||||
sidebarIsCollapsed
|
||||
? "my-auto h-full justify-between items-center gap-3"
|
||||
: "gap-1"
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
{sidebarIsCollapsed ? (
|
||||
<Image
|
||||
src={"/icon.png"}
|
||||
width={640}
|
||||
height={136}
|
||||
alt="Linkwarden Icon"
|
||||
className="h-8 w-auto cursor-pointer"
|
||||
onClick={() => router.push("/dashboard")}
|
||||
priority
|
||||
/>
|
||||
) : user?.theme === "light" ? (
|
||||
<Image
|
||||
src={"/linkwarden_light.png"}
|
||||
width={640}
|
||||
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"
|
||||
)}
|
||||
>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<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("all_links")}
|
||||
href={`/links`}
|
||||
icon={"bi-link-45deg"}
|
||||
active={active === `/links`}
|
||||
/>
|
||||
<SidebarHighlightLink
|
||||
title={t("all_collections")}
|
||||
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>
|
||||
|
||||
{sidebarIsCollapsed ? (
|
||||
<></>
|
||||
) : (
|
||||
<>
|
||||
<Disclosure defaultOpen={collectionDisclosure}>
|
||||
<Disclosure.Button
|
||||
onClick={() => {
|
||||
@@ -141,6 +263,8 @@ export default function Sidebar({ className }: { className?: string }) {
|
||||
</Disclosure.Panel>
|
||||
</Transition>
|
||||
</Disclosure>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,37 +1,56 @@
|
||||
import Link from "next/link";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export default function SidebarHighlightLink({
|
||||
title,
|
||||
href,
|
||||
icon,
|
||||
active,
|
||||
sidebarIsCollapsed,
|
||||
}: {
|
||||
title: string;
|
||||
href: string;
|
||||
icon: string;
|
||||
active?: boolean;
|
||||
sidebarIsCollapsed?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<Link href={href}>
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Link href={href} title={title}>
|
||||
<div
|
||||
title={title}
|
||||
className={`${
|
||||
active || false
|
||||
? "bg-primary/20"
|
||||
: "bg-neutral-content/20 hover:bg-neutral/20"
|
||||
} duration-200 px-3 py-2 cursor-pointer gap-2 w-full rounded-lg capitalize`}
|
||||
className={cn(
|
||||
active ? "bg-primary/20" : "hover:bg-neutral/20",
|
||||
"duration-200 cursor-pointer flex items-center gap-2 capitalize",
|
||||
sidebarIsCollapsed
|
||||
? "rounded-md h-8 w-8"
|
||||
: "rounded-lg px-3 py-1"
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={
|
||||
"w-10 h-10 inline-flex items-center justify-center bg-black/10 dark:bg-white/5 rounded-full"
|
||||
}
|
||||
>
|
||||
<i className={`${icon} text-primary text-xl drop-shadow`}></i>
|
||||
</div>
|
||||
<div className={"mt-1"}>
|
||||
<p className="truncate w-full font-semibold text-xs">{title}</p>
|
||||
</div>
|
||||
<i
|
||||
className={cn(
|
||||
icon,
|
||||
"text-primary text-xl drop-shadow",
|
||||
sidebarIsCollapsed && "w-full text-center"
|
||||
)}
|
||||
></i>
|
||||
{!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[];
|
||||
active?: string;
|
||||
}
|
||||
export function TagListing({ tags, active }: TagListingProps) {
|
||||
|
||||
export default function TagListing({ tags, active }: TagListingProps) {
|
||||
const { active: droppableActive } = useDndContext();
|
||||
const { t } = useTranslation();
|
||||
|
||||
const ctx = useDndContext();
|
||||
console.log("DndContext active?", ctx.active);
|
||||
|
||||
if (!tags[0]) {
|
||||
return (
|
||||
<div
|
||||
|
||||