Compare commits

...

119 Commits
v2.13.2 ... dev

Author SHA1 Message Date
daniel31x13
9edb450b6a fix: add useEffect to reset faviconLoaded state on link.url change 2026-02-23 19:30:00 -05:00
daniel31x13
4fa1f57351 feat: improve timeout handling in archiveHandler and refactor handleMonolith for better signal management 2026-02-23 18:26:47 -05:00
daniel31x13
f3d30085de feat: enhance useUpdateLink to optimistically update links 2026-02-19 19:37:25 -05:00
daniel31x13
da8761387f feat: enhance useDeleteCollection with improved error handling and optimistic updates 2026-02-19 18:06:47 -05:00
daniel31x13
c9fd573b31 feat: enhance deleteLink functionality with optimistic rendering and improved error handling 2026-02-19 17:30:01 -05:00
daniel31x13
c99f9edd9a removed unused console.log 2026-02-19 17:12:56 -05:00
daniel31x13
389a96dadc feat(link): update useAddLink to accept additional parameters for improved error handling 2026-02-19 17:11:42 -05:00
daniel31x13
c8b1129e4f feat(worker): index links instantly without waiting for them to be preserved 2026-02-19 01:20:24 -05:00
daniel31x13
b9fd802288 feat(link): implement link submission with optimistic UI updates and toast notifications 2026-02-18 20:58:00 -05:00
daniel31x13
549299743c feat(search): display note in search bar when there are unindexed links 2026-02-18 19:52:55 -05:00
daniel31x13
21b6ab3de4 adjust z-index for searchbar dropdown 2026-02-18 17:39:21 -05:00
daniel31x13
155ca17b55 refactor: always hide email address from the public 2026-02-18 16:48:39 -05:00
daniel31x13
686e3b44e1 remove whitelist and isPrivate due to low demand and high overhead 2026-02-18 16:36:15 -05:00
daniel31x13
f13c5e1cfc refactor(dashboard): remove hasUnIndexedLinks from getDashboardData response 2026-02-18 15:58:27 -05:00
daniel31x13
7e34d98bc4 Refactor imports to use global types from "@linkwarden/types/global" instead of "@linkwarden/types" across components 2026-02-18 15:40:12 -05:00
daniel31x13
e9c1c5217b refactor: update import paths to use specific utility modules 2026-02-18 15:33:20 -05:00
daniel31x13
209e0faa1b add hasUnIndexedLinks fields to dashboard data 2026-02-18 15:32:40 -05:00
daniel31x13
27a86c0b28 fix(auth): improve token validation error handling and add timeout alerts 2026-02-18 14:55:01 -05:00
daniel31x13
0198a9148e feat(search): add advanced search operators and suggestions to SearchBar component 2026-02-17 17:20:39 -05:00
daniel31x13
45dc95122a feat(import): add integration tests for importFromHTMLFile function 2026-02-13 16:11:25 -05:00
daniel31x13
8c9cd34ec3 feat(admin): implement admin layout and sidebar, add user administration and background jobs pages 2026-02-12 15:36:38 -05:00
daniel31x13
6b3dba3faf Refactor worker-related functionality and update UI components
- Updated ConfirmationModal to use a callback for toggleModal.
- Modified DeleteUserModal to handle admin checks more robustly.
- Removed unnecessary config usage in SettingsSidebar and updated links.
- Cleaned up TagListing by removing unused context logging.
- Enhanced admin page to redirect non-admin users to the dashboard.
- Simplified API for archiving links by removing unused actions.
- Updated billing settings page for better UI consistency.
- Adjusted password settings page for responsive design.
- Deleted obsolete worker-console page and redirected to background-jobs.
- Added new background-jobs page with worker stats and preservation actions.
- Introduced new API endpoints for fetching worker stats and managing preservations.
- Created new hooks for managing worker-related actions in the router.
- Updated localization files to reflect new UI changes and actions.
- Removed deprecated preservation file handling from filesystem management.
2026-02-12 15:16:22 -05:00
daniel31x13
81ae7c64a9 fix(mobile): update version to 1.1.1 and improve text styling in Collections and Tags components 2026-02-10 23:01:55 -05:00
daniel31x13
d39a0ed5b2 fix: remove Cache-Control header from avatar response 2026-02-07 16:37:30 -05:00
daniel31x13
ffc9971ce6 fix: update Hacker News badge link and count in README.md 2026-02-07 16:19:14 -05:00
daniel31x13
a8a9ad602f feat: add metaDescription field to Link model and update archiveHandler logic 2026-02-07 15:53:30 -05:00
daniel31x13
bc750bd588 refactor: center content in SettingsLayout for improved layout 2026-02-07 13:10:04 -05:00
daniel31x13
e3de382739 move worker page to worker-console page 2026-02-07 12:20:28 -05:00
daniel31x13
57601413d4 minor improvement 2026-02-07 11:59:53 -05:00
daniel31x13
af8a650096 add translation for "back to dashboard" in SettingsLayout 2026-02-05 20:42:14 -05:00
daniel31x13
b445fde85a enhance settings pages with icons for better UX 2026-02-05 20:38:49 -05:00
daniel31x13
7bbdec0f85 improved settings UX 2026-02-05 20:05:54 -05:00
daniel31x13
4743aa8144 improved settings page 2026-02-05 18:56:03 -05:00
daniel31x13
fedd19770e add Trendshift badge to README 2026-02-05 17:41:28 -05:00
daniel31x13
6c5253121c remove unused line 2026-02-02 19:41:52 -05:00
daniel31x13
6536b34c41 revert clickable dashboard items PR 2026-02-02 19:41:18 -05:00
daniel31x13
2c812e11e4 add a 10s timeout to providers 2026-02-02 19:35:25 -05:00
daniel31x13
13305c06c4 revert bump version 2026-02-02 19:19:27 -05:00
daniel31x13
3cbbeb55a4 bump version 2026-02-02 18:20:36 -05:00
daniel31x13
6bb261c81a bug fixed 2026-02-02 18:20:08 -05:00
daniel31x13
dbad316bac update icons 2026-02-02 17:13:13 -05:00
daniel31x13
cc37543324 add preferred collection + edit links when sharing 2026-02-01 23:11:51 -05:00
daniel31x13
7c9307dd84 add tag editing functionality to the mobile app 2026-02-01 20:36:47 -05:00
daniel31x13
c794c0814e Fixes #1539 2026-01-31 16:06:44 -05:00
Daniel
78d6d1c70a Merge pull request #1568 from 9helix/feat/resursive-share
Ability to propagate changes in the collection's permissions to all subcollections
2026-01-30 15:01:07 -05:00
daniel31x13
f79f57ccda refactor: update terminology for subcollection member propagation 2026-01-30 14:58:48 -05:00
Daniel
eb8402448d Merge pull request #1588 from roelven/improve-ai-tag-prompts-dev
Improve AI tag generation prompts
2026-01-27 04:50:59 +03:00
daniel31x13
350cdb485a small fix 2026-01-26 20:50:17 -05:00
Roel van der Ven
8bd3bd3763 Improve AI tag generation prompts
Enhanced all three tagging mode prompts (GENERATE, PREDEFINED, EXISTING)
with stricter rules and better guidance to address common issues:

- Add explicit Title Case and UPPERCASE acronym formatting rules
- Add junk tag filtering (verbs, UI elements, vague words)
- Add concrete examples showing good vs bad tags
- Add stricter category guidance (prefer nouns over verbs)
- Improve existing/predefined mode instructions with exact match requirements
- Emphasize tag reuse priority in EXISTING mode

These improvements address:
- Case inconsistencies (Music vs music vs MUSIC)
- Junk tags (verbs like "read", "sign", UI elements like "sign up")
- Semantic duplicates (AI, ML, Machine Learning as separate tags)
- Tag proliferation (better reuse of existing tags)

Tested with Ollama (gemma3b), OpenAI GPT-4, and Anthropic Claude.
Results show ~70% reduction in duplicate tags and ~90% reduction in junk tags.

Fixes #1073
Helps with #1123, #1147, #1010

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-01-20 12:57:06 +01:00
daniel31x13
f2cfbf0b10 only add the collection admin to the subcollection 2026-01-19 04:11:22 -05:00
Daniel
513c03dcae Merge pull request #1561 from 9helix/fix/admin-subcollection-permission
Fix/admin sub-collection permission
2026-01-17 15:49:24 +03:00
daniel31x13
98b7e38139 small change 2026-01-17 07:48:04 -05:00
daniel31x13
9b5c08655a Merge branch 'dev' of https://github.com/linkwarden/linkwarden into dev 2026-01-17 05:34:57 -05:00
daniel31x13
caf706e8ea add vitest 2026-01-17 05:34:53 -05:00
Daniel
d54f7da5a5 Merge pull request #1560 from 9helix/feature/dashboard-stats-navigation-links
feat(dashboard): add navigation links to dashboard stats items
2026-01-07 03:32:46 +03:30
Daniel
ae2e3c80db Merge pull request #1488 from krim404/main
Add configurable HTTP timeout for Authentik provider
2026-01-05 22:01:40 +03:30
daniel31x13
06285ce6d7 small change 2026-01-05 13:31:20 -05:00
daniel31x13
fdf48abd29 add pr template 2026-01-05 09:38:33 -05:00
daniel31x13
59252759f2 revert version number to 0.0.0 in package.json (since we're already tracking the version in app.json) 2026-01-04 16:44:17 -05:00
daniel31x13
dd96d80d42 bump version 2026-01-03 12:24:59 -05:00
daniel31x13
f8efbe95e6 bug fixed 2026-01-03 11:07:01 -05:00
9helix
b4b6edd618 fix: improperly adding parent collection members into subcollection 2026-01-02 23:19:14 +01:00
9helix
d066378076 feat: add ability to propagate members and permissions to subcollections 2026-01-02 18:58:18 +01:00
Dino Gržinić
cddfc5dba6 feat: share newly created subcollection with users from parent collection 2026-01-02 00:00:43 +01:00
Dino Gržinić
4bdcfa0ee7 feat: allow collection admins to create subcollection 2026-01-01 19:57:34 +01:00
daniel31x13
cf84474921 fix infinite loading bug + enable corepack during eas submit 2025-12-31 08:57:46 -05:00
Dino Gržinić
95e662358f fix(dashboard): fix layout shrinking when items are wrapped in links 2025-12-30 12:43:09 +01:00
9helix
eb31acbc30 feat(dashboard): add navigation links to dashboard stats items 2025-12-30 01:14:48 +01:00
9helix
7a34d836be fix: allow admin members to create subcollections 2025-12-30 01:02:40 +01:00
daniel31x13
eccf27425c fix(mobile): fix infinite spinner bug + fix android dark mode bug 2025-12-29 02:03:37 -05:00
Daniel
3926e566b7 Merge pull request #1556 from linkwarden/dev
v2.13.5
2025-12-28 12:40:56 +03:30
daniel31x13
bca333be26 bump version to v2.13.5 2025-12-28 04:10:24 -05:00
Daniel
02a1e3b455 Merge pull request #1555 from khanguyen74/ai-tagging-fix
Update ai tagging response model
2025-12-28 12:15:25 +03:30
daniel31x13
5b0c66b5e2 minor improvement 2025-12-28 03:44:38 -05:00
Kha Nguyen
7c0c823c41 update ai tagging response model
- llm only needs to return a text containing all the tags
2025-12-26 17:44:07 -06:00
Daniel
a8d2c55d12 Merge pull request #1551 from linkwarden/dev
improved README + add sponsor links
2025-12-26 07:57:58 +03:30
daniel31x13
37410fcf97 improved README + add sponsor links 2025-12-25 23:25:14 -05:00
Daniel
0ab4a2d883 Merge pull request #1548 from linkwarden/dev
v2.13.4
2025-12-26 01:06:45 +03:30
Daniel
756b896fe6 New Crowdin updates (#1542)
* New translations common.json (Japanese)

* New translations common.json (Japanese)

* New translations common.json (Japanese)

* New translations common.json (Italian)

---------

Co-authored-by: LinkwardenBot <bot@linkwarden.app>
2025-12-25 16:36:01 -05:00
daniel31x13
e3e3611b54 bump version 2025-12-25 16:31:14 -05:00
Daniel
4bf65f8ebd Merge pull request #1547 from khanguyen74/fix-announcement
fix new version announcement keeps showing
2025-12-26 00:59:13 +03:30
Kha Nguyen
6956c71aa2 fix new version announcement keeps showing 2025-12-25 13:20:17 -06:00
Daniel
e0f357513c Merge pull request #1541 from linkwarden/dev
Remove unnecessary .yarn copy from Dockerfile
2025-12-22 20:18:35 -05:00
daniel31x13
daeb859990 Remove unnecessary .yarn copy from Dockerfile 2025-12-22 20:17:58 -05:00
Daniel
f072bcd0b0 Merge pull request #1540 from linkwarden/dev
v2.13.3
2025-12-22 18:51:06 -05:00
daniel31x13
a497dc953a Add PRISMA_HIDE_UPDATE_MESSAGE environment variable to Dockerfile 2025-12-22 18:17:25 -05:00
daniel31x13
dcf6d72c01 bug fix 2025-12-22 17:36:09 -05:00
daniel31x13
d749487fb6 Add meili_data to .dockerignore and .gitignore 2025-12-22 17:21:18 -05:00
Daniel
7079355013 Merge pull request #1537 from linkwarden/chore/tech-debts
Chore/tech debts
2025-12-21 18:16:27 -05:00
daniel31x13
46762b0d36 minor change 2025-12-21 18:10:31 -05:00
Daniel
389e5df117 Chore/tech debts (#1536)
* build(deps): bump the npm_and_yarn group across 5 directories with 22 updates

Bumps the npm_and_yarn group with 18 updates in the / directory:

| Package | From | To |
| --- | --- | --- |
| [axios](https://github.com/axios/axios) | `1.5.1` | `1.12.0` |
| [dompurify](https://github.com/cure53/DOMPurify) | `3.0.6` | `3.2.4` |
| [formidable](https://github.com/node-formidable/formidable) | `3.5.1` | `3.5.4` |
| [next](https://github.com/vercel/next.js) | `13.4.12` | `14.2.35` |
| [next-auth](https://github.com/nextauthjs/next-auth) | `4.22.1` | `4.24.12` |
| [playwright](https://github.com/microsoft/playwright) | `1.55.0` | `1.55.1` |
| [@mozilla/readability](https://github.com/mozilla/readability) | `0.4.4` | `0.6.0` |
| [ai](https://github.com/vercel/ai) | `4.3.9` | `5.0.52` |
| [nodemailer](https://github.com/nodemailer/nodemailer) | `6.9.3` | `7.0.11` |
| [brace-expansion](https://github.com/juliangruber/brace-expansion) | `1.1.11` | `1.1.12` |
| [braces](https://github.com/micromatch/braces) | `3.0.2` | `3.0.3` |
| [form-data](https://github.com/form-data/form-data) | `3.0.3` | `3.0.4` |
| [js-yaml](https://github.com/nodeca/js-yaml) | `3.14.1` | `3.14.2` |
| [micromatch](https://github.com/micromatch/micromatch) | `4.0.5` | `4.0.8` |
| [min-document](https://github.com/Raynos/min-document) | `2.19.0` | `2.19.2` |
| [nanoid](https://github.com/ai/nanoid) | `3.3.6` | `3.3.8` |
| [node-forge](https://github.com/digitalbazaar/forge) | `1.3.1` | `1.3.3` |
| [tar](https://github.com/isaacs/node-tar) | `6.1.13` | `6.2.1` |

Bumps the npm_and_yarn group with 1 update in the /apps/web directory: [next](https://github.com/vercel/next.js).
Bumps the npm_and_yarn group with 2 updates in the /apps/worker directory: [@mozilla/readability](https://github.com/mozilla/readability) and [ai](https://github.com/vercel/ai).
Bumps the npm_and_yarn group with 1 update in the /packages/lib directory: [nodemailer](https://github.com/nodemailer/nodemailer).
Bumps the npm_and_yarn group with 1 update in the /packages/router directory: [next](https://github.com/vercel/next.js).


Updates `axios` from 1.5.1 to 1.12.0
- [Release notes](https://github.com/axios/axios/releases)
- [Changelog](https://github.com/axios/axios/blob/v1.x/CHANGELOG.md)
- [Commits](https://github.com/axios/axios/compare/v1.5.1...v1.12.0)

Updates `dompurify` from 3.0.6 to 3.2.4
- [Release notes](https://github.com/cure53/DOMPurify/releases)
- [Commits](https://github.com/cure53/DOMPurify/compare/3.0.6...3.2.4)

Updates `formidable` from 3.5.1 to 3.5.4
- [Release notes](https://github.com/node-formidable/formidable/releases)
- [Changelog](https://github.com/node-formidable/formidable/blob/master/CHANGELOG.md)
- [Commits](https://github.com/node-formidable/formidable/commits)

Updates `next` from 13.4.12 to 14.2.35
- [Release notes](https://github.com/vercel/next.js/releases)
- [Changelog](https://github.com/vercel/next.js/blob/canary/release.js)
- [Commits](https://github.com/vercel/next.js/compare/v13.4.12...v14.2.35)

Updates `next-auth` from 4.22.1 to 4.24.12
- [Release notes](https://github.com/nextauthjs/next-auth/releases)
- [Commits](https://github.com/nextauthjs/next-auth/compare/next-auth@4.22.1...next-auth@4.24.12)

Updates `playwright` from 1.55.0 to 1.55.1
- [Release notes](https://github.com/microsoft/playwright/releases)
- [Commits](https://github.com/microsoft/playwright/compare/v1.55.0...v1.55.1)

Updates `postcss` from 8.4.26 to 8.5.3
- [Release notes](https://github.com/postcss/postcss/releases)
- [Changelog](https://github.com/postcss/postcss/blob/main/CHANGELOG.md)
- [Commits](https://github.com/postcss/postcss/compare/8.4.26...8.5.3)

Updates `@mozilla/readability` from 0.4.4 to 0.6.0
- [Changelog](https://github.com/mozilla/readability/blob/main/CHANGELOG.md)
- [Commits](https://github.com/mozilla/readability/compare/0.4.4...0.6.0)

Updates `ai` from 4.3.9 to 5.0.52
- [Release notes](https://github.com/vercel/ai/releases)
- [Changelog](https://github.com/vercel/ai/blob/main/CHANGELOG.md)
- [Commits](https://github.com/vercel/ai/compare/ai@4.3.9...ai@5.0.52)

Updates `nodemailer` from 6.9.3 to 7.0.11
- [Release notes](https://github.com/nodemailer/nodemailer/releases)
- [Changelog](https://github.com/nodemailer/nodemailer/blob/master/CHANGELOG.md)
- [Commits](https://github.com/nodemailer/nodemailer/compare/v6.9.3...v7.0.11)

Updates `@babel/runtime` from 7.21.5 to 7.27.0
- [Release notes](https://github.com/babel/babel/releases)
- [Changelog](https://github.com/babel/babel/blob/main/CHANGELOG.md)
- [Commits](https://github.com/babel/babel/commits/v7.27.0/packages/babel-runtime)

Updates `brace-expansion` from 1.1.11 to 1.1.12
- [Release notes](https://github.com/juliangruber/brace-expansion/releases)
- [Commits](https://github.com/juliangruber/brace-expansion/compare/1.1.11...v1.1.12)

Updates `braces` from 3.0.2 to 3.0.3
- [Changelog](https://github.com/micromatch/braces/blob/master/CHANGELOG.md)
- [Commits](https://github.com/micromatch/braces/compare/3.0.2...3.0.3)

Updates `follow-redirects` from 1.15.3 to 1.15.11
- [Release notes](https://github.com/follow-redirects/follow-redirects/releases)
- [Commits](https://github.com/follow-redirects/follow-redirects/compare/v1.15.3...v1.15.11)

Updates `form-data` from 3.0.3 to 3.0.4
- [Release notes](https://github.com/form-data/form-data/releases)
- [Changelog](https://github.com/form-data/form-data/blob/master/CHANGELOG.md)
- [Commits](https://github.com/form-data/form-data/compare/v3.0.3...v3.0.4)

Updates `jose` from 4.14.4 to 4.15.9
- [Release notes](https://github.com/panva/jose/releases)
- [Changelog](https://github.com/panva/jose/blob/v4.15.9/CHANGELOG.md)
- [Commits](https://github.com/panva/jose/compare/v4.14.4...v4.15.9)

Updates `js-yaml` from 3.14.1 to 3.14.2
- [Changelog](https://github.com/nodeca/js-yaml/blob/master/CHANGELOG.md)
- [Commits](https://github.com/nodeca/js-yaml/compare/3.14.1...3.14.2)

Updates `micromatch` from 4.0.5 to 4.0.8
- [Release notes](https://github.com/micromatch/micromatch/releases)
- [Changelog](https://github.com/micromatch/micromatch/blob/master/CHANGELOG.md)
- [Commits](https://github.com/micromatch/micromatch/compare/4.0.5...4.0.8)

Updates `min-document` from 2.19.0 to 2.19.2
- [Commits](https://github.com/Raynos/min-document/compare/v2.19.0...v2.19.2)

Updates `nanoid` from 3.3.6 to 3.3.8
- [Release notes](https://github.com/ai/nanoid/releases)
- [Changelog](https://github.com/ai/nanoid/blob/main/CHANGELOG.md)
- [Commits](https://github.com/ai/nanoid/compare/3.3.6...3.3.8)

Updates `node-forge` from 1.3.1 to 1.3.3
- [Changelog](https://github.com/digitalbazaar/forge/blob/main/CHANGELOG.md)
- [Commits](https://github.com/digitalbazaar/forge/compare/v1.3.1...v1.3.3)

Updates `tar` from 6.1.13 to 6.2.1
- [Release notes](https://github.com/isaacs/node-tar/releases)
- [Changelog](https://github.com/isaacs/node-tar/blob/main/CHANGELOG.md)
- [Commits](https://github.com/isaacs/node-tar/compare/v6.1.13...v6.2.1)

Updates `next` from 13.4.12 to 14.2.35
- [Release notes](https://github.com/vercel/next.js/releases)
- [Changelog](https://github.com/vercel/next.js/blob/canary/release.js)
- [Commits](https://github.com/vercel/next.js/compare/v13.4.12...v14.2.35)

Updates `@mozilla/readability` from 0.4.4 to 0.6.0
- [Changelog](https://github.com/mozilla/readability/blob/main/CHANGELOG.md)
- [Commits](https://github.com/mozilla/readability/compare/0.4.4...0.6.0)

Updates `ai` from 4.3.19 to 5.0.113
- [Release notes](https://github.com/vercel/ai/releases)
- [Changelog](https://github.com/vercel/ai/blob/main/CHANGELOG.md)
- [Commits](https://github.com/vercel/ai/compare/ai@4.3.9...ai@5.0.52)

Updates `nodemailer` from 6.10.1 to 7.0.11
- [Release notes](https://github.com/nodemailer/nodemailer/releases)
- [Changelog](https://github.com/nodemailer/nodemailer/blob/master/CHANGELOG.md)
- [Commits](https://github.com/nodemailer/nodemailer/compare/v6.9.3...v7.0.11)

Updates `next` from 13.4.12 to 14.2.35
- [Release notes](https://github.com/vercel/next.js/releases)
- [Changelog](https://github.com/vercel/next.js/blob/canary/release.js)
- [Commits](https://github.com/vercel/next.js/compare/v13.4.12...v14.2.35)

---
updated-dependencies:
- dependency-name: axios
  dependency-version: 1.12.0
  dependency-type: direct:production
  dependency-group: npm_and_yarn
- dependency-name: dompurify
  dependency-version: 3.2.4
  dependency-type: direct:production
  dependency-group: npm_and_yarn
- dependency-name: formidable
  dependency-version: 3.5.4
  dependency-type: direct:production
  dependency-group: npm_and_yarn
- dependency-name: next
  dependency-version: 14.2.35
  dependency-type: direct:production
  dependency-group: npm_and_yarn
- dependency-name: next-auth
  dependency-version: 4.24.12
  dependency-type: direct:production
  dependency-group: npm_and_yarn
- dependency-name: playwright
  dependency-version: 1.55.1
  dependency-type: direct:production
  dependency-group: npm_and_yarn
- dependency-name: postcss
  dependency-version: 8.5.3
  dependency-type: direct:development
  dependency-group: npm_and_yarn
- dependency-name: "@mozilla/readability"
  dependency-version: 0.6.0
  dependency-type: direct:production
  dependency-group: npm_and_yarn
- dependency-name: ai
  dependency-version: 5.0.52
  dependency-type: direct:production
  dependency-group: npm_and_yarn
- dependency-name: nodemailer
  dependency-version: 7.0.11
  dependency-type: direct:production
  dependency-group: npm_and_yarn
- dependency-name: "@babel/runtime"
  dependency-version: 7.27.0
  dependency-type: indirect
  dependency-group: npm_and_yarn
- dependency-name: brace-expansion
  dependency-version: 1.1.12
  dependency-type: indirect
  dependency-group: npm_and_yarn
- dependency-name: braces
  dependency-version: 3.0.3
  dependency-type: indirect
  dependency-group: npm_and_yarn
- dependency-name: follow-redirects
  dependency-version: 1.15.11
  dependency-type: indirect
  dependency-group: npm_and_yarn
- dependency-name: form-data
  dependency-version: 3.0.4
  dependency-type: indirect
  dependency-group: npm_and_yarn
- dependency-name: jose
  dependency-version: 4.15.9
  dependency-type: indirect
  dependency-group: npm_and_yarn
- dependency-name: js-yaml
  dependency-version: 3.14.2
  dependency-type: indirect
  dependency-group: npm_and_yarn
- dependency-name: micromatch
  dependency-version: 4.0.8
  dependency-type: indirect
  dependency-group: npm_and_yarn
- dependency-name: min-document
  dependency-version: 2.19.2
  dependency-type: indirect
  dependency-group: npm_and_yarn
- dependency-name: nanoid
  dependency-version: 3.3.8
  dependency-type: indirect
  dependency-group: npm_and_yarn
- dependency-name: node-forge
  dependency-version: 1.3.3
  dependency-type: indirect
  dependency-group: npm_and_yarn
- dependency-name: tar
  dependency-version: 6.2.1
  dependency-type: indirect
  dependency-group: npm_and_yarn
- dependency-name: next
  dependency-version: 14.2.35
  dependency-type: direct:production
  dependency-group: npm_and_yarn
- dependency-name: "@mozilla/readability"
  dependency-version: 0.6.0
  dependency-type: direct:production
  dependency-group: npm_and_yarn
- dependency-name: ai
  dependency-version: 5.0.113
  dependency-type: direct:production
  dependency-group: npm_and_yarn
- dependency-name: nodemailer
  dependency-version: 7.0.11
  dependency-type: direct:production
  dependency-group: npm_and_yarn
- dependency-name: next
  dependency-version: 14.2.35
  dependency-type: direct:production
  dependency-group: npm_and_yarn
...

Signed-off-by: dependabot[bot] <support@github.com>

* bug fixes and improvements

* always show navbar in reader view

* bug fix and small performance improvement

* minor fix

* Refactor link selection management and bulk actions

- Replaced the use of selectedLinks with selectedIds in the link store for better performance and clarity.
- Updated LinkListOptions, BulkDeleteLinksModal, and BulkEditLinksModal components to utilize the new selection management.
- Modified LinkCard, LinkMasonry, and LinkList components to handle selection state through props.
- Enhanced updateLinks API to support bulk updates with improved tag management.
- Cleaned up unused imports and code related to previous selection methods.

* move refetching logic to Links component

* move disableDraggable and user hook out of each card to improve efficiency

* cleaner code

* memoize components and increase performance

* fix: update announcement links to use the correct domain

* feat: add favicon field to Link model + update packages + bug fix

* feat: implement favicon fetching API and update Link model for favicon support

* feat: add priority attribute to Image components in Sidebar

* Refactor pages to use consistent layout handling (yes, I forgot to do that until now :P)

* bump version

* Refactor setting pages to use consistent layout handling

* upgrade yarn to 4.12.0

* fix DnD bug

* Enhance announcement handling by adding support for announcement messages

* slimmed down the docker image size

* update Node and yarn versions in playwright tests workflow

* small fix

* fix attempt 2

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-12-21 18:09:05 -05:00
daniel31x13
865bff2214 fix attempt 2 2025-12-21 17:58:30 -05:00
daniel31x13
33553e22d5 small fix 2025-12-21 17:52:44 -05:00
daniel31x13
1323787294 update Node and yarn versions in playwright tests workflow 2025-12-21 17:46:20 -05:00
Daniel
0a812fa72b Merge branch 'dev' into chore/tech-debts 2025-12-21 17:41:05 -05:00
Daniel
9faf9d844e New Crowdin updates (#1535)
* New translations common.json (Chinese Traditional)

* New translations common.json (Chinese Traditional)

---------

Co-authored-by: LinkwardenBot <bot@linkwarden.app>
2025-12-21 17:39:31 -05:00
daniel31x13
1558854f78 slimmed down the docker image size 2025-12-21 10:26:23 -05:00
daniel31x13
ab19df767c Enhance announcement handling by adding support for announcement messages 2025-12-20 15:33:52 -05:00
daniel31x13
218fd504bf fix DnD bug 2025-12-20 15:06:32 -05:00
daniel31x13
02158d7621 upgrade yarn to 4.12.0 2025-12-20 14:05:28 -05:00
daniel31x13
9edb42d181 Refactor setting pages to use consistent layout handling 2025-12-19 05:50:59 -05:00
daniel31x13
8ba370bf62 bump version 2025-12-19 05:29:00 -05:00
daniel31x13
a32934ee9d Refactor pages to use consistent layout handling (yes, I forgot to do that until now :P) 2025-12-19 04:59:32 -05:00
daniel31x13
ff5ba2097d feat: add priority attribute to Image components in Sidebar 2025-12-19 03:14:49 -05:00
daniel31x13
703f84403e feat: implement favicon fetching API and update Link model for favicon support 2025-12-17 02:38:54 -05:00
daniel31x13
f9ec18c51a feat: add favicon field to Link model + update packages + bug fix 2025-12-16 02:23:36 -05:00
daniel31x13
3d0651d4af fix: update announcement links to use the correct domain 2025-12-16 01:13:00 -05:00
daniel31x13
f30bf63c24 memoize components and increase performance 2025-12-15 13:08:58 -05:00
daniel31x13
e2d89a56d6 cleaner code 2025-12-14 22:31:01 -05:00
daniel31x13
40c3ccca93 move disableDraggable and user hook out of each card to improve efficiency 2025-12-14 18:20:57 -05:00
daniel31x13
1e515d5284 move refetching logic to Links component 2025-12-14 17:19:49 -05:00
daniel31x13
cb9cdc92c8 Refactor link selection management and bulk actions
- Replaced the use of selectedLinks with selectedIds in the link store for better performance and clarity.
- Updated LinkListOptions, BulkDeleteLinksModal, and BulkEditLinksModal components to utilize the new selection management.
- Modified LinkCard, LinkMasonry, and LinkList components to handle selection state through props.
- Enhanced updateLinks API to support bulk updates with improved tag management.
- Cleaned up unused imports and code related to previous selection methods.
2025-12-14 11:57:27 -05:00
daniel31x13
639f777b8a minor fix 2025-12-14 01:57:29 -05:00
daniel31x13
48b7384490 bug fix and small performance improvement 2025-12-14 01:51:50 -05:00
daniel31x13
3bff1650c7 always show navbar in reader view 2025-12-13 22:43:34 -05:00
daniel31x13
cadea5c654 bug fixes and improvements 2025-12-13 22:29:36 -05:00
dependabot[bot]
e9b7c21ea0 build(deps): bump the npm_and_yarn group across 5 directories with 22 updates
Bumps the npm_and_yarn group with 18 updates in the / directory:

| Package | From | To |
| --- | --- | --- |
| [axios](https://github.com/axios/axios) | `1.5.1` | `1.12.0` |
| [dompurify](https://github.com/cure53/DOMPurify) | `3.0.6` | `3.2.4` |
| [formidable](https://github.com/node-formidable/formidable) | `3.5.1` | `3.5.4` |
| [next](https://github.com/vercel/next.js) | `13.4.12` | `14.2.35` |
| [next-auth](https://github.com/nextauthjs/next-auth) | `4.22.1` | `4.24.12` |
| [playwright](https://github.com/microsoft/playwright) | `1.55.0` | `1.55.1` |
| [@mozilla/readability](https://github.com/mozilla/readability) | `0.4.4` | `0.6.0` |
| [ai](https://github.com/vercel/ai) | `4.3.9` | `5.0.52` |
| [nodemailer](https://github.com/nodemailer/nodemailer) | `6.9.3` | `7.0.11` |
| [brace-expansion](https://github.com/juliangruber/brace-expansion) | `1.1.11` | `1.1.12` |
| [braces](https://github.com/micromatch/braces) | `3.0.2` | `3.0.3` |
| [form-data](https://github.com/form-data/form-data) | `3.0.3` | `3.0.4` |
| [js-yaml](https://github.com/nodeca/js-yaml) | `3.14.1` | `3.14.2` |
| [micromatch](https://github.com/micromatch/micromatch) | `4.0.5` | `4.0.8` |
| [min-document](https://github.com/Raynos/min-document) | `2.19.0` | `2.19.2` |
| [nanoid](https://github.com/ai/nanoid) | `3.3.6` | `3.3.8` |
| [node-forge](https://github.com/digitalbazaar/forge) | `1.3.1` | `1.3.3` |
| [tar](https://github.com/isaacs/node-tar) | `6.1.13` | `6.2.1` |

Bumps the npm_and_yarn group with 1 update in the /apps/web directory: [next](https://github.com/vercel/next.js).
Bumps the npm_and_yarn group with 2 updates in the /apps/worker directory: [@mozilla/readability](https://github.com/mozilla/readability) and [ai](https://github.com/vercel/ai).
Bumps the npm_and_yarn group with 1 update in the /packages/lib directory: [nodemailer](https://github.com/nodemailer/nodemailer).
Bumps the npm_and_yarn group with 1 update in the /packages/router directory: [next](https://github.com/vercel/next.js).


Updates `axios` from 1.5.1 to 1.12.0
- [Release notes](https://github.com/axios/axios/releases)
- [Changelog](https://github.com/axios/axios/blob/v1.x/CHANGELOG.md)
- [Commits](https://github.com/axios/axios/compare/v1.5.1...v1.12.0)

Updates `dompurify` from 3.0.6 to 3.2.4
- [Release notes](https://github.com/cure53/DOMPurify/releases)
- [Commits](https://github.com/cure53/DOMPurify/compare/3.0.6...3.2.4)

Updates `formidable` from 3.5.1 to 3.5.4
- [Release notes](https://github.com/node-formidable/formidable/releases)
- [Changelog](https://github.com/node-formidable/formidable/blob/master/CHANGELOG.md)
- [Commits](https://github.com/node-formidable/formidable/commits)

Updates `next` from 13.4.12 to 14.2.35
- [Release notes](https://github.com/vercel/next.js/releases)
- [Changelog](https://github.com/vercel/next.js/blob/canary/release.js)
- [Commits](https://github.com/vercel/next.js/compare/v13.4.12...v14.2.35)

Updates `next-auth` from 4.22.1 to 4.24.12
- [Release notes](https://github.com/nextauthjs/next-auth/releases)
- [Commits](https://github.com/nextauthjs/next-auth/compare/next-auth@4.22.1...next-auth@4.24.12)

Updates `playwright` from 1.55.0 to 1.55.1
- [Release notes](https://github.com/microsoft/playwright/releases)
- [Commits](https://github.com/microsoft/playwright/compare/v1.55.0...v1.55.1)

Updates `postcss` from 8.4.26 to 8.5.3
- [Release notes](https://github.com/postcss/postcss/releases)
- [Changelog](https://github.com/postcss/postcss/blob/main/CHANGELOG.md)
- [Commits](https://github.com/postcss/postcss/compare/8.4.26...8.5.3)

Updates `@mozilla/readability` from 0.4.4 to 0.6.0
- [Changelog](https://github.com/mozilla/readability/blob/main/CHANGELOG.md)
- [Commits](https://github.com/mozilla/readability/compare/0.4.4...0.6.0)

Updates `ai` from 4.3.9 to 5.0.52
- [Release notes](https://github.com/vercel/ai/releases)
- [Changelog](https://github.com/vercel/ai/blob/main/CHANGELOG.md)
- [Commits](https://github.com/vercel/ai/compare/ai@4.3.9...ai@5.0.52)

Updates `nodemailer` from 6.9.3 to 7.0.11
- [Release notes](https://github.com/nodemailer/nodemailer/releases)
- [Changelog](https://github.com/nodemailer/nodemailer/blob/master/CHANGELOG.md)
- [Commits](https://github.com/nodemailer/nodemailer/compare/v6.9.3...v7.0.11)

Updates `@babel/runtime` from 7.21.5 to 7.27.0
- [Release notes](https://github.com/babel/babel/releases)
- [Changelog](https://github.com/babel/babel/blob/main/CHANGELOG.md)
- [Commits](https://github.com/babel/babel/commits/v7.27.0/packages/babel-runtime)

Updates `brace-expansion` from 1.1.11 to 1.1.12
- [Release notes](https://github.com/juliangruber/brace-expansion/releases)
- [Commits](https://github.com/juliangruber/brace-expansion/compare/1.1.11...v1.1.12)

Updates `braces` from 3.0.2 to 3.0.3
- [Changelog](https://github.com/micromatch/braces/blob/master/CHANGELOG.md)
- [Commits](https://github.com/micromatch/braces/compare/3.0.2...3.0.3)

Updates `follow-redirects` from 1.15.3 to 1.15.11
- [Release notes](https://github.com/follow-redirects/follow-redirects/releases)
- [Commits](https://github.com/follow-redirects/follow-redirects/compare/v1.15.3...v1.15.11)

Updates `form-data` from 3.0.3 to 3.0.4
- [Release notes](https://github.com/form-data/form-data/releases)
- [Changelog](https://github.com/form-data/form-data/blob/master/CHANGELOG.md)
- [Commits](https://github.com/form-data/form-data/compare/v3.0.3...v3.0.4)

Updates `jose` from 4.14.4 to 4.15.9
- [Release notes](https://github.com/panva/jose/releases)
- [Changelog](https://github.com/panva/jose/blob/v4.15.9/CHANGELOG.md)
- [Commits](https://github.com/panva/jose/compare/v4.14.4...v4.15.9)

Updates `js-yaml` from 3.14.1 to 3.14.2
- [Changelog](https://github.com/nodeca/js-yaml/blob/master/CHANGELOG.md)
- [Commits](https://github.com/nodeca/js-yaml/compare/3.14.1...3.14.2)

Updates `micromatch` from 4.0.5 to 4.0.8
- [Release notes](https://github.com/micromatch/micromatch/releases)
- [Changelog](https://github.com/micromatch/micromatch/blob/master/CHANGELOG.md)
- [Commits](https://github.com/micromatch/micromatch/compare/4.0.5...4.0.8)

Updates `min-document` from 2.19.0 to 2.19.2
- [Commits](https://github.com/Raynos/min-document/compare/v2.19.0...v2.19.2)

Updates `nanoid` from 3.3.6 to 3.3.8
- [Release notes](https://github.com/ai/nanoid/releases)
- [Changelog](https://github.com/ai/nanoid/blob/main/CHANGELOG.md)
- [Commits](https://github.com/ai/nanoid/compare/3.3.6...3.3.8)

Updates `node-forge` from 1.3.1 to 1.3.3
- [Changelog](https://github.com/digitalbazaar/forge/blob/main/CHANGELOG.md)
- [Commits](https://github.com/digitalbazaar/forge/compare/v1.3.1...v1.3.3)

Updates `tar` from 6.1.13 to 6.2.1
- [Release notes](https://github.com/isaacs/node-tar/releases)
- [Changelog](https://github.com/isaacs/node-tar/blob/main/CHANGELOG.md)
- [Commits](https://github.com/isaacs/node-tar/compare/v6.1.13...v6.2.1)

Updates `next` from 13.4.12 to 14.2.35
- [Release notes](https://github.com/vercel/next.js/releases)
- [Changelog](https://github.com/vercel/next.js/blob/canary/release.js)
- [Commits](https://github.com/vercel/next.js/compare/v13.4.12...v14.2.35)

Updates `@mozilla/readability` from 0.4.4 to 0.6.0
- [Changelog](https://github.com/mozilla/readability/blob/main/CHANGELOG.md)
- [Commits](https://github.com/mozilla/readability/compare/0.4.4...0.6.0)

Updates `ai` from 4.3.19 to 5.0.113
- [Release notes](https://github.com/vercel/ai/releases)
- [Changelog](https://github.com/vercel/ai/blob/main/CHANGELOG.md)
- [Commits](https://github.com/vercel/ai/compare/ai@4.3.9...ai@5.0.52)

Updates `nodemailer` from 6.10.1 to 7.0.11
- [Release notes](https://github.com/nodemailer/nodemailer/releases)
- [Changelog](https://github.com/nodemailer/nodemailer/blob/master/CHANGELOG.md)
- [Commits](https://github.com/nodemailer/nodemailer/compare/v6.9.3...v7.0.11)

Updates `next` from 13.4.12 to 14.2.35
- [Release notes](https://github.com/vercel/next.js/releases)
- [Changelog](https://github.com/vercel/next.js/blob/canary/release.js)
- [Commits](https://github.com/vercel/next.js/compare/v13.4.12...v14.2.35)

---
updated-dependencies:
- dependency-name: axios
  dependency-version: 1.12.0
  dependency-type: direct:production
  dependency-group: npm_and_yarn
- dependency-name: dompurify
  dependency-version: 3.2.4
  dependency-type: direct:production
  dependency-group: npm_and_yarn
- dependency-name: formidable
  dependency-version: 3.5.4
  dependency-type: direct:production
  dependency-group: npm_and_yarn
- dependency-name: next
  dependency-version: 14.2.35
  dependency-type: direct:production
  dependency-group: npm_and_yarn
- dependency-name: next-auth
  dependency-version: 4.24.12
  dependency-type: direct:production
  dependency-group: npm_and_yarn
- dependency-name: playwright
  dependency-version: 1.55.1
  dependency-type: direct:production
  dependency-group: npm_and_yarn
- dependency-name: postcss
  dependency-version: 8.5.3
  dependency-type: direct:development
  dependency-group: npm_and_yarn
- dependency-name: "@mozilla/readability"
  dependency-version: 0.6.0
  dependency-type: direct:production
  dependency-group: npm_and_yarn
- dependency-name: ai
  dependency-version: 5.0.52
  dependency-type: direct:production
  dependency-group: npm_and_yarn
- dependency-name: nodemailer
  dependency-version: 7.0.11
  dependency-type: direct:production
  dependency-group: npm_and_yarn
- dependency-name: "@babel/runtime"
  dependency-version: 7.27.0
  dependency-type: indirect
  dependency-group: npm_and_yarn
- dependency-name: brace-expansion
  dependency-version: 1.1.12
  dependency-type: indirect
  dependency-group: npm_and_yarn
- dependency-name: braces
  dependency-version: 3.0.3
  dependency-type: indirect
  dependency-group: npm_and_yarn
- dependency-name: follow-redirects
  dependency-version: 1.15.11
  dependency-type: indirect
  dependency-group: npm_and_yarn
- dependency-name: form-data
  dependency-version: 3.0.4
  dependency-type: indirect
  dependency-group: npm_and_yarn
- dependency-name: jose
  dependency-version: 4.15.9
  dependency-type: indirect
  dependency-group: npm_and_yarn
- dependency-name: js-yaml
  dependency-version: 3.14.2
  dependency-type: indirect
  dependency-group: npm_and_yarn
- dependency-name: micromatch
  dependency-version: 4.0.8
  dependency-type: indirect
  dependency-group: npm_and_yarn
- dependency-name: min-document
  dependency-version: 2.19.2
  dependency-type: indirect
  dependency-group: npm_and_yarn
- dependency-name: nanoid
  dependency-version: 3.3.8
  dependency-type: indirect
  dependency-group: npm_and_yarn
- dependency-name: node-forge
  dependency-version: 1.3.3
  dependency-type: indirect
  dependency-group: npm_and_yarn
- dependency-name: tar
  dependency-version: 6.2.1
  dependency-type: indirect
  dependency-group: npm_and_yarn
- dependency-name: next
  dependency-version: 14.2.35
  dependency-type: direct:production
  dependency-group: npm_and_yarn
- dependency-name: "@mozilla/readability"
  dependency-version: 0.6.0
  dependency-type: direct:production
  dependency-group: npm_and_yarn
- dependency-name: ai
  dependency-version: 5.0.113
  dependency-type: direct:production
  dependency-group: npm_and_yarn
- dependency-name: nodemailer
  dependency-version: 7.0.11
  dependency-type: direct:production
  dependency-group: npm_and_yarn
- dependency-name: next
  dependency-version: 14.2.35
  dependency-type: direct:production
  dependency-group: npm_and_yarn
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-12-13 20:47:58 +00:00
daniel31x13
03ca0c5e3d fix(web): update version to v2.13.2 in package.json 2025-12-12 10:55:24 -05:00
Krim
27997b8f4b Add configurable HTTP timeout for Authentik provider 2025-11-09 15:10:44 +01:00
245 changed files with 28360 additions and 17811 deletions

View File

@@ -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

14
.github/FUNDING.yml vendored
View File

@@ -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
View 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

View File

@@ -61,12 +61,18 @@ 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: |
echo "Initializing Databases"
@@ -74,7 +80,7 @@ jobs:
psql -h localhost -U postgres -d postgres -c "CREATE DATABASE ${{ env.TEST_POSTGRES_DATABASE }} OWNER ${{ env.TEST_POSTGRES_USER }};"
- name: Install packages
run: yarn install -y
run: yarn install --immutable
- name: Cache playwright dependencies
uses: awalsh128/cache-apt-pkgs-action@latest

5
.gitignore vendored
View File

@@ -2,6 +2,7 @@
node_modules
/.pnp
.pnp.js
.yarn/install-state.gz
# testing
/coverage
@@ -48,9 +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
View File

@@ -0,0 +1 @@
nodeLinker: node-modules

View File

@@ -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 \

View File

@@ -3,8 +3,10 @@
<h1>Linkwarden</h1>
<h3>Bookmarks, Evolved</h3>
<a href="https://trendshift.io/repositories/4006" target="_blank"><img src="https://trendshift.io/api/badge/repositories/4006" alt="linkwarden%2Flinkwarden | Trendshift" style="width: 250px; height: 55px;" width="250" height="55"/></a>
<a href="https://discord.com/invite/CtuYV47nuJ"><img src="https://img.shields.io/discord/1117993124669702164?logo=discord&style=flat" alt="Discord"></a>
<a href="https://twitter.com/LinkwardenHQ"><img src="https://img.shields.io/twitter/follow/linkwarden" alt="Twitter"></a> <a href="https://news.ycombinator.com/item?id=36942308"><img src="https://img.shields.io/badge/Hacker%20News-280-%23FF6600"></img></a>
<a href="https://twitter.com/LinkwardenHQ"><img src="https://img.shields.io/twitter/follow/linkwarden" alt="Twitter"></a> <a href="https://news.ycombinator.com/item?id=43856801"><img src="https://img.shields.io/badge/Hacker%20News-301-%23FF6600"></img></a>
<a href="https://github.com/linkwarden/linkwarden/releases"><img alt="GitHub release" src="https://img.shields.io/github/v/release/linkwarden/linkwarden"></a>
<a href="https://crowdin.com/project/linkwarden">
@@ -38,32 +40,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 youll 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 youll need to deploy and maintain a Linkwarden instance on a server.
After creating an account, download the app from your preferred store:
[![Download on the App Store](./assets/app_store.png)](https://apps.apple.com/app/linkwarden/id6752550960)
[![Get it on Google Play](./assets/google_play.png)](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 ⭐

View File

@@ -1 +0,0 @@
node-linker=hoisted

View File

@@ -2,7 +2,7 @@
"expo": {
"name": "Linkwarden",
"slug": "linkwarden",
"version": "1.0.0",
"version": "1.1.1",
"orientation": "portrait",
"icon": "./assets/images/icon.png",
"scheme": "linkwarden",
@@ -53,7 +53,9 @@
[
"expo-share-intent",
{
"iosAppGroupIdentifier": "group.app.linkwarden"
"iosAppGroupIdentifier": "group.app.linkwarden",
"iosActivationRules": "SUBQUERY (extensionItems, $extensionItem, SUBQUERY ($extensionItem.attachments, $attachment, (ANY $attachment.registeredTypeIdentifiers UTI-CONFORMS-TO \"public.url\" OR ANY $attachment.registeredTypeIdentifiers UTI-CONFORMS-TO \"public.text\" OR ANY $attachment.registeredTypeIdentifiers UTI-CONFORMS-TO \"public.plain-text\")).@count > 0).@count > 0",
"androidIntentFilters": ["text/*"]
}
],
[

View File

@@ -14,7 +14,7 @@ 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";
import { CollectionIncludingMembersAndLinkCount } from "@linkwarden/types/global";
export default function CollectionsScreen() {
const { colorScheme } = useColorScheme();
@@ -44,7 +44,7 @@ export default function CollectionsScreen() {
collapsableChildren={false}
>
{collections.isLoading ? (
<View className="flex justify-center h-full items-center">
<View className="flex justify-center h-screen items-center">
<ActivityIndicator size="large" />
<Text className="text-base mt-2.5 text-neutral">Loading...</Text>
</View>

View File

@@ -27,8 +27,8 @@ export default function Layout() {
Platform.OS === "ios"
? "transparent"
: colorScheme === "dark"
? rawTheme["dark"]["base-100"]
: "white",
? rawTheme["dark"]["base-100"]
: "white",
},
}}
>

View File

@@ -1,4 +1,11 @@
import { Platform, ScrollView, StyleSheet } from "react-native";
import {
ActivityIndicator,
Platform,
ScrollView,
StyleSheet,
Text,
View,
} from "react-native";
import React, { useEffect, useMemo, useState } from "react";
import { useDashboardData } from "@linkwarden/router/dashboardData";
import useAuthStore from "@/store/auth";
@@ -53,22 +60,36 @@ export default function DashboardScreen() {
});
}, [dashboardSections]);
const [pullRefreshing, setPullRefreshing] = useState(false);
const onRefresh = async () => {
setPullRefreshing(true);
try {
await Promise.all([
dashboardData.refetch(),
userData.refetch(),
collectionsData.refetch(),
tagsData.refetch(),
]);
} finally {
setPullRefreshing(false);
}
};
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 (
<ScrollView
refreshControl={
<Spinner
refreshing={
dashboardData.isRefetching ||
userData.isRefetching ||
collectionsData.isRefetching ||
tagsData.isRefetching
}
onRefresh={() => {
dashboardData.refetch();
userData.refetch();
collectionsData.refetch();
tagsData.refetch();
}}
refreshing={pullRefreshing}
onRefresh={onRefresh}
progressBackgroundColor={
rawTheme[colorScheme as ThemeName]["base-200"]
}

View File

@@ -19,6 +19,7 @@ export default function Layout() {
headerLargeStyle: {
backgroundColor: rawTheme[colorScheme as ThemeName]["base-100"],
},
headerBackTitle: "Back",
headerStyle: {
backgroundColor:
Platform.OS === "ios"
@@ -28,6 +29,15 @@ export default function Layout() {
: "white",
},
}}
/>
>
<Stack.Screen name="index" />
<Stack.Screen
name="preferredCollection"
options={{
headerTitle: "Preferred Collection",
headerLargeTitle: false,
}}
/>
</Stack>
);
}

View File

@@ -16,7 +16,9 @@ import { useEffect, useState } from "react";
import {
AppWindowMac,
Check,
ChevronRight,
ExternalLink,
Folder,
LogOut,
Mail,
Moon,
@@ -25,6 +27,7 @@ import {
} from "lucide-react-native";
import useDataStore from "@/store/data";
import * as Clipboard from "expo-clipboard";
import { useRouter } from "expo-router";
export default function SettingsScreen() {
const { signOut, auth } = useAuthStore();
@@ -40,6 +43,8 @@ export default function SettingsScreen() {
updateData({ theme: override });
}, [override]);
const router = useRouter();
return (
<View
style={styles.container}
@@ -196,6 +201,33 @@ export default function SettingsScreen() {
</View>
</View>
<View>
<Text className="mb-4 mx-4 text-neutral">Save Shared Links To</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={() => router.navigate("/settings/preferredCollection")}
>
<View className="flex-row items-center gap-2">
<Folder
size={20}
color={rawTheme[colorScheme as ThemeName].neutral}
/>
<Text className="text-base-content">Preferred collection</Text>
</View>
<View className="flex-row items-center gap-2">
<Text numberOfLines={1} className="text-neutral max-w-[140px]">
{data.preferredCollection?.name || "None"}
</Text>
<ChevronRight
size={20}
color={rawTheme[colorScheme as ThemeName].neutral}
/>
</View>
</TouchableOpacity>
</View>
</View>
<View>
<Text className="mb-4 mx-4 text-neutral">Contact Us</Text>
<View className="bg-base-200 rounded-xl flex-col">

View File

@@ -0,0 +1,99 @@
import { View, Text, FlatList, TouchableOpacity } from "react-native";
import React, { useCallback, useMemo, useState } from "react";
import useAuthStore from "@/store/auth";
import useDataStore from "@/store/data";
import { useCollections } from "@linkwarden/router/collections";
import { CollectionIncludingMembersAndLinkCount } from "@linkwarden/types/global";
import Input from "@/components/ui/Input";
import { Folder, Check } from "lucide-react-native";
import { useColorScheme } from "nativewind";
import { rawTheme, ThemeName } from "@/lib/colors";
const PreferredCollectionScreen = () => {
const { auth } = useAuthStore();
const { data, updateData } = useDataStore();
const collections = useCollections(auth);
const { colorScheme } = useColorScheme();
const [searchQuery, setSearchQuery] = useState("");
const filteredCollections = useMemo(() => {
if (!collections.data) return [];
const q = searchQuery.trim().toLowerCase();
if (q === "") return collections.data;
return collections.data.filter((col) => col.name.toLowerCase().includes(q));
}, [collections.data, searchQuery]);
const renderCollection = useCallback(
({
item: collection,
}: {
item: CollectionIncludingMembersAndLinkCount;
}) => {
const isSelected = data.preferredCollection?.id === collection.id;
return (
<TouchableOpacity
className="bg-base-200 rounded-lg px-4 py-3 mb-3 flex-row items-center justify-between"
onPress={() => updateData({ preferredCollection: collection })}
>
<View className="flex-row items-center gap-2 w-[70%]">
<Folder
size={20}
fill={collection.color || "gray"}
color={collection.color || "gray"}
/>
<Text numberOfLines={1} className="text-base-content">
{collection.name}
</Text>
</View>
<View className="flex-row items-center gap-2">
{isSelected ? (
<Check
size={16}
color={rawTheme[colorScheme as ThemeName].primary}
/>
) : null}
<Text className="text-neutral">
{collection._count?.links ?? 0}
</Text>
</View>
</TouchableOpacity>
);
},
[colorScheme, data.preferredCollection?.id, updateData]
);
return (
<View className="flex-1 bg-base-100">
<FlatList
data={filteredCollections}
keyExtractor={(item) => item.id?.toString() || ""}
renderItem={renderCollection}
contentContainerStyle={{
paddingHorizontal: 20,
paddingTop: 20,
paddingBottom: 20,
}}
contentInsetAdjustmentBehavior="automatic"
ListHeaderComponent={
<Input
placeholder="Search collections"
className="mb-4 bg-base-200 h-10"
value={searchQuery}
onChangeText={setSearchQuery}
/>
}
ListEmptyComponent={
<Text
style={{ textAlign: "center", marginTop: 20 }}
className="text-neutral"
>
No collections match {searchQuery}
</Text>
}
/>
</View>
);
};
export default PreferredCollectionScreen;

View File

@@ -13,7 +13,7 @@ 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 { TagIncludingLinkCount } from "@linkwarden/types/global";
import { useTags } from "@linkwarden/router/tags";
export default function TagsScreen() {
@@ -42,7 +42,7 @@ export default function TagsScreen() {
collapsableChildren={false}
>
{tags.isLoading ? (
<View className="flex justify-center h-full items-center">
<View className="flex justify-center h-screen items-center">
<ActivityIndicator size="large" />
<Text className="text-base mt-2.5 text-neutral">Loading...</Text>
</View>

View File

@@ -33,7 +33,7 @@ import useTmpStore from "@/store/tmp";
import {
LinkIncludingShortenedCollectionAndTags,
MobileAuth,
} from "@linkwarden/types";
} from "@linkwarden/types/global";
import { useDeleteLink, useUpdateLink } from "@linkwarden/router/links";
import { deleteLinkCache } from "@/lib/cache";
import { queryClient } from "@/lib/queryClient";
@@ -120,8 +120,8 @@ const RootComponent = ({
auth: MobileAuth;
}) => {
const { colorScheme } = useColorScheme();
const updateLink = useUpdateLink(auth);
const deleteLink = useDeleteLink(auth);
const updateLink = useUpdateLink({ auth, Alert });
const deleteLink = useDeleteLink({ auth, Alert });
const { tmp } = useTmpStore();
@@ -229,12 +229,12 @@ const RootComponent = ({
{tmp.link && tmp.user && (
<DropdownMenu.Item
key="pin-link"
onSelect={async () => {
onSelect={() => {
const isAlreadyPinned =
tmp.link?.pinnedBy && tmp.link.pinnedBy[0]
? true
: false;
await updateLink.mutateAsync({
updateLink.mutateAsync({
...(tmp.link as LinkIncludingShortenedCollectionAndTags),
pinnedBy: (isAlreadyPinned
? [{ id: undefined }]
@@ -282,18 +282,15 @@ const RootComponent = ({
{
text: "Delete",
style: "destructive",
onPress: () => {
onPress: async () => {
deleteLink.mutate(
tmp.link?.id as number,
{
onSuccess: async () => {
await deleteLinkCache(
tmp.link?.id as number
);
},
}
tmp.link?.id as number
);
// go back
await deleteLinkCache(
tmp.link?.id as number
);
router.back();
},
},

View File

@@ -1,11 +1,11 @@
import React, { useEffect } from "react";
import React, { useEffect, useState } from "react";
import {
SafeAreaView,
View,
Text,
StyleSheet,
ActivityIndicator,
Alert,
TouchableOpacity,
} from "react-native";
import { Redirect, useRouter } from "expo-router";
import useAuthStore from "@/store/auth";
@@ -14,20 +14,29 @@ import { Check } from "lucide-react-native";
import { useAddLink } from "@linkwarden/router/links";
import { rawTheme, ThemeName } from "@/lib/colors";
import { useColorScheme } from "nativewind";
import { SheetManager } from "react-native-actions-sheet";
import { LinkIncludingShortenedCollectionAndTags } from "@linkwarden/types/global";
export default function IncomingScreen() {
const { auth } = useAuthStore();
const router = useRouter();
const { data, updateData } = useDataStore();
const addLink = useAddLink(auth);
const addLink = useAddLink({ auth });
const { colorScheme } = useColorScheme();
const [showSuccess, setShowSuccess] = useState(false);
const [link, setLink] = useState<LinkIncludingShortenedCollectionAndTags>();
useEffect(() => {
if (auth.status === "authenticated" && data.shareIntent.url)
addLink.mutate(
{ url: data.shareIntent.url },
{
onSuccess: () => {
url: data.shareIntent.url,
collection: { id: data.preferredCollection?.id },
},
{
onSuccess: (e) => {
setLink(e as unknown as LinkIncludingShortenedCollectionAndTags);
setShowSuccess(true);
setTimeout(() => {
updateData({
shareIntent: {
@@ -36,7 +45,7 @@ export default function IncomingScreen() {
},
});
router.replace("/dashboard");
}, 1000);
}, 1500);
},
onError: (error) => {
Alert.alert("Error", "There was an error adding the link.");
@@ -50,49 +59,39 @@ export default function IncomingScreen() {
return (
<SafeAreaView className="flex-1 bg-base-100">
{data?.shareIntent.url ? (
<View className="flex-1 items-center justify-center">
<Check
size={140}
className="mb-3 text-base-content"
color={rawTheme[colorScheme as ThemeName].primary}
/>
<Text className="text-2xl font-semibold text-base-content">
Link Saved!
</Text>
</View>
) : (
<View className="flex-1 items-center justify-center">
<ActivityIndicator size="large" />
<Text className="mt-3 text-base text-base-content opacity-70">
One sec {String(data?.shareIntent.url)}
</Text>
</View>
)}
<View className="flex-1 items-center justify-center">
{data?.shareIntent.url && showSuccess && link ? (
<>
<Check
size={140}
className="mb-3 text-base-content"
color={rawTheme[colorScheme as ThemeName].primary}
/>
<Text className="text-2xl font-semibold text-base-content">
Link Saved!
</Text>
<TouchableOpacity
className="w-fit mx-auto mt-5"
onPress={() =>
SheetManager.show("edit-link-sheet", {
payload: {
link: link,
},
})
}
>
<Text className="text-neutral text-center w-fit">Edit Link</Text>
</TouchableOpacity>
</>
) : (
<>
<ActivityIndicator size="large" />
<Text className="mt-3 text-base text-base-content opacity-70">
One sec
</Text>
</>
)}
</View>
</SafeAreaView>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: "#fff",
},
center: {
flex: 1,
alignItems: "center",
justifyContent: "center",
},
check: {
marginBottom: 12,
},
title: {
fontSize: 28,
fontWeight: "600",
},
subtitle: {
marginTop: 12,
fontSize: 16,
opacity: 0.7,
},
});

View File

@@ -20,56 +20,58 @@ export default function HomeScreen() {
return (
<Animated.View
entering={SlideInDown.springify().damping(100).stiffness(300)}
className="flex-col justify-end h-full bg-primary relative"
className="flex-col justify-end h-full"
>
<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>
<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>
<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>
<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>
</Animated.View>
);
}

View File

@@ -6,7 +6,7 @@ import { useLocalSearchParams } from "expo-router";
import { useUser } from "@linkwarden/router/user";
import { useGetLink } from "@linkwarden/router/links";
import useTmpStore from "@/store/tmp";
import { ArchivedFormat } from "@linkwarden/types";
import { ArchivedFormat } from "@linkwarden/types/global";
import ReadableFormat from "@/components/Formats/ReadableFormat";
import ImageFormat from "@/components/Formats/ImageFormat";
import PdfFormat from "@/components/Formats/PdfFormat";

Binary file not shown.

Before

Width:  |  Height:  |  Size: 241 KiB

After

Width:  |  Height:  |  Size: 237 KiB

View File

@@ -1,4 +1,4 @@
import { Alert, Platform, Text, View } from "react-native";
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";
@@ -12,7 +12,7 @@ import { useSafeAreaInsets } from "react-native-safe-area-context";
export default function AddLinkSheet() {
const actionSheetRef = useRef<ActionSheetRef>(null);
const { auth } = useAuthStore();
const addLink = useAddLink(auth);
const addLink = useAddLink({ auth, Alert });
const [link, setLink] = useState("");
const { colorScheme } = useColorScheme();
@@ -23,7 +23,7 @@ export default function AddLinkSheet() {
ref={actionSheetRef}
gestureEnabled
indicatorStyle={{
backgroundColor: rawTheme[colorScheme as ThemeName]["neutral-content"],
display: "none",
}}
containerStyle={{
backgroundColor: rawTheme[colorScheme as ThemeName]["base-200"],
@@ -31,6 +31,10 @@ export default function AddLinkSheet() {
safeAreaInsets={insets}
>
<View className="px-8 py-5">
<Text className="font-semibold text-lg mx-auto mb-5 text-base-content">
New Link
</Text>
<Input
placeholder="e.g. https://example.com"
className="mb-4 bg-base-100"
@@ -39,21 +43,12 @@ export default function AddLinkSheet() {
/>
<Button
onPress={() =>
addLink.mutate(
{ url: link },
{
onSuccess: () => {
actionSheetRef.current?.hide();
setLink("");
},
onError: (error) => {
Alert.alert("Error", "There was an error adding the link.");
console.error("Error adding link:", error);
},
}
)
}
onPress={() => {
addLink.mutate({ url: link });
actionSheetRef.current?.hide();
setLink("");
}}
isLoading={addLink.isPending}
variant="accent"
className="mb-2"

View File

@@ -1,4 +1,4 @@
import { View, Text, Alert, Platform } from "react-native";
import { View, Text, Alert, TouchableOpacity } from "react-native";
import { useCallback, useEffect, useMemo, useState } from "react";
import ActionSheet, {
FlatList,
@@ -15,13 +15,15 @@ import useAuthStore from "@/store/auth";
import {
CollectionIncludingMembersAndLinkCount,
LinkIncludingShortenedCollectionAndTags,
} from "@linkwarden/types";
TagIncludingLinkCount,
} from "@linkwarden/types/global";
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 { Folder, ChevronRight, ChevronLeft, Check } from "lucide-react-native";
import useTmpStore from "@/store/tmp";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { useTags } from "@linkwarden/router/tags";
const Main = (props: SheetProps<"edit-link-sheet">) => {
const { auth } = useAuthStore();
@@ -31,7 +33,7 @@ const Main = (props: SheetProps<"edit-link-sheet">) => {
const [link, setLink] = useState<
LinkIncludingShortenedCollectionAndTags | undefined
>(props.payload?.link);
const editLink = useUpdateLink(auth);
const updateLink = useUpdateLink({ auth, Alert });
const router = useSheetRouter("edit-link-sheet");
const { colorScheme } = useColorScheme();
@@ -45,6 +47,10 @@ const Main = (props: SheetProps<"edit-link-sheet">) => {
return (
<View className="px-8 py-5">
<Text className="font-semibold text-lg mx-auto mb-5 text-base-content">
Edit Link
</Text>
<Input
placeholder="Name"
className="mb-4 bg-base-100"
@@ -82,23 +88,29 @@ const Main = (props: SheetProps<"edit-link-sheet">) => {
/>
</Button>
{/* <Button variant="input" className="mb-4 h-auto">
<Button
variant="input"
className="mb-4 h-auto"
onPress={() => router?.navigate("tags", { link })}
>
{link?.tags && link?.tags.length > 0 ? (
<View className="flex-row flex-wrap items-center gap-2 w-[90%]">
{link.tags.map((tag) => (
<View
key={tag.id}
className="bg-gray-200 rounded-md h-7 px-2 py-1"
className="bg-neutral rounded-md h-7 px-2 py-1"
>
<Text numberOfLines={1}>{tag.name}</Text>
<Text numberOfLines={1} className="text-base-100">
{tag.name}
</Text>
</View>
))}
</View>
) : (
<Text className="text-gray-500">No tags</Text>
<Text className="text-neutral">No tags</Text>
)}
<ChevronRight size={16} color={"gray"} />
</Button> */}
</Button>
<Input
multiline
@@ -112,23 +124,15 @@ const Main = (props: SheetProps<"edit-link-sheet">) => {
/>
<Button
onPress={() =>
editLink.mutate(link as LinkIncludingShortenedCollectionAndTags, {
onSuccess: () => {
if (link && tmp.link)
updateTmp({
link,
});
SheetManager.hide("edit-link-sheet");
},
onError: (error) => {
Alert.alert("Error", "There was an error editing the link.");
console.error("Error editing link:", error);
},
})
}
isLoading={editLink.isPending}
onPress={() => {
updateLink.mutate(link as LinkIncludingShortenedCollectionAndTags);
if (link && tmp.link)
updateTmp({
link,
});
SheetManager.hide("edit-link-sheet");
}}
isLoading={updateLink.isPending}
variant="accent"
className="mb-2"
>
@@ -150,7 +154,7 @@ const Main = (props: SheetProps<"edit-link-sheet">) => {
const Collections = () => {
const { auth } = useAuthStore();
const addLink = useAddLink(auth);
const addLink = useAddLink({ auth });
const [searchQuery, setSearchQuery] = useState("");
const router = useSheetRouter("edit-link-sheet");
const { link: currentLink } = useSheetRouteParams<
@@ -175,13 +179,11 @@ const Collections = () => {
item: CollectionIncludingMembersAndLinkCount;
}) => {
const onSelect = () => {
// 1. Create a brand-new link object with the new collection
const updatedLink = {
...currentLink!,
...currentLink,
collection,
};
// 2. Navigate back to "main", passing the updated link as payload
router?.popToTop();
router?.navigate("main", { link: updatedLink });
};
@@ -216,16 +218,32 @@ const Collections = () => {
);
return (
<View className="px-8 py-5 max-h-[80vh]">
<View className="py-5 max-h-[80vh]">
<TouchableOpacity
className="flex-row items-center gap-1 top-6 left-8 absolute"
onPress={() => {
router?.popToTop();
router?.navigate("main", { link: currentLink });
}}
>
<ChevronLeft
size={18}
color={rawTheme[colorScheme as ThemeName]["primary"]}
/>
<Text className="text-primary">Back</Text>
</TouchableOpacity>
<Text className="font-semibold text-lg mx-auto mb-5 text-base-content">
Collection
</Text>
<Input
placeholder="Search collections"
className="mb-4 bg-base-100"
className="mb-4 bg-base-100 mx-8"
value={searchQuery}
onChangeText={setSearchQuery}
/>
<FlatList
data={filteredCollections}
data={[...filteredCollections]}
keyExtractor={(e, i) => i.toString()}
renderItem={renderItem}
ListEmptyComponent={
@@ -236,7 +254,106 @@ const Collections = () => {
No collections match {searchQuery}
</Text>
}
contentContainerStyle={{ paddingBottom: 20 }}
contentContainerClassName="px-8"
/>
</View>
);
};
const Tags = () => {
const { auth } = useAuthStore();
const addLink = useAddLink({ auth });
const [searchQuery, setSearchQuery] = useState("");
const router = useSheetRouter("edit-link-sheet");
const params = useSheetRouteParams("edit-link-sheet", "tags");
const tags = useTags(auth);
const { colorScheme } = useColorScheme();
const [updatedLink, setUpdatedLink] =
useState<LinkIncludingShortenedCollectionAndTags>(params.link);
const filteredTags = useMemo(() => {
if (!tags.data) return [];
const q = searchQuery.trim().toLowerCase();
if (q === "") return tags.data;
return tags.data.filter((tag) => tag.name.toLowerCase().includes(q));
}, [tags.data, searchQuery]);
const renderItem = useCallback(
({ item: tag }: { item: TagIncludingLinkCount }) => {
const onSelect = () => {
const isSelected = (updatedLink?.tags || []).some(
(t) => t.id === tag.id
);
const nextTags = isSelected
? (updatedLink?.tags || []).filter((t) => t.id !== tag.id)
: [...(updatedLink?.tags || []), tag];
setUpdatedLink({
...updatedLink,
tags: nextTags,
});
};
return (
<Button variant="input" className="mb-2" onPress={onSelect}>
<View className="flex-row items-center gap-2 w-[75%]">
<Text numberOfLines={1} className="w-full text-base-content">
{tag.name}
</Text>
</View>
<View className="flex-row items-center gap-2">
{updatedLink?.tags.find((e) => e.id === tag.id) && (
<Check
size={16}
color={rawTheme[colorScheme as ThemeName].primary}
/>
)}
<Text className="text-neutral">{tag._count?.links ?? 0}</Text>
</View>
</Button>
);
},
[addLink, params.link, router]
);
return (
<View className="py-5 max-h-[80vh]">
<TouchableOpacity
className="flex-row items-center gap-1 top-6 left-8 absolute"
onPress={() => {
router?.popToTop();
router?.navigate("main", { link: updatedLink });
}}
>
<ChevronLeft
size={18}
color={rawTheme[colorScheme as ThemeName]["primary"]}
/>
<Text className="text-primary">Back</Text>
</TouchableOpacity>
<Text className="font-semibold text-lg mx-auto mb-5 text-base-content">
Tags
</Text>
<Input
placeholder="Search tags"
className="mb-4 bg-base-100 mx-8"
value={searchQuery}
onChangeText={setSearchQuery}
/>
<FlatList
data={filteredTags}
keyExtractor={(e, i) => i.toString()}
renderItem={renderItem}
ListEmptyComponent={
<Text
style={{ textAlign: "center", marginTop: 20 }}
className="text-neutral"
>
No tags match {searchQuery}
</Text>
}
contentContainerClassName="px-8"
/>
</View>
);
@@ -251,6 +368,10 @@ const routes: Route[] = [
name: "collections",
component: Collections,
},
{
name: "tags",
component: Tags,
},
];
export default function EditLinkSheet() {
@@ -262,9 +383,8 @@ export default function EditLinkSheet() {
<ActionSheet
gestureEnabled
indicatorStyle={{
backgroundColor: rawTheme[colorScheme as ThemeName]["neutral-content"],
display: "none",
}}
enableRouterBackNavigation={true}
routes={routes}
initialRoute="main"
containerStyle={{

View File

@@ -1,4 +1,4 @@
import { Alert, Platform, Text, View } from "react-native";
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";
@@ -26,7 +26,7 @@ export default function NewCollectionSheet() {
ref={actionSheetRef}
gestureEnabled
indicatorStyle={{
backgroundColor: rawTheme[colorScheme as ThemeName]["neutral-content"],
display: "none",
}}
containerStyle={{
backgroundColor: rawTheme[colorScheme as ThemeName]["base-200"],
@@ -34,6 +34,10 @@ export default function NewCollectionSheet() {
safeAreaInsets={insets}
>
<View className="px-8 py-5">
<Text className="font-semibold text-lg mx-auto mb-5 text-base-content">
New Collection
</Text>
<Input
placeholder="Name"
className="mb-4 bg-base-100"

View File

@@ -7,7 +7,7 @@ import SupportSheet from "./SupportSheet";
import AddLinkSheet from "./AddLinkSheet";
import EditLinkSheet from "./EditLinkSheet";
import NewCollectionSheet from "./NewCollectionSheet";
import { LinkIncludingShortenedCollectionAndTags } from "@linkwarden/types";
import { LinkIncludingShortenedCollectionAndTags } from "@linkwarden/types/global";
registerSheet("support-sheet", SupportSheet);
registerSheet("add-link-sheet", AddLinkSheet);
@@ -29,6 +29,9 @@ declare module "react-native-actions-sheet" {
collections: RouteDefinition<{
link: LinkIncludingShortenedCollectionAndTags;
}>;
tags: RouteDefinition<{
link: LinkIncludingShortenedCollectionAndTags;
}>;
};
}>;
"new-collection-sheet": SheetDefinition;

View File

@@ -1,6 +1,6 @@
import { View, Text, Pressable, Platform, Alert } from "react-native";
import { decode } from "html-entities";
import { CollectionIncludingMembersAndLinkCount } from "@linkwarden/types";
import { CollectionIncludingMembersAndLinkCount } from "@linkwarden/types/global";
import useAuthStore from "@/store/auth";
import { useRouter } from "expo-router";
import * as ContextMenu from "zeego/context-menu";
@@ -19,7 +19,7 @@ const CollectionListing = ({ collection }: Props) => {
const router = useRouter();
const { colorScheme } = useColorScheme();
const deleteCollection = useDeleteCollection(auth);
const deleteCollection = useDeleteCollection({ auth, Alert });
return (
<ContextMenu.Root>

View File

@@ -17,7 +17,7 @@ import {
Hash,
Link,
} from "lucide-react-native";
import { LinkIncludingShortenedCollectionAndTags } from "@linkwarden/types";
import { LinkIncludingShortenedCollectionAndTags } from "@linkwarden/types/global";
import LinkListing from "@/components/LinkListing";
import { useColorScheme } from "nativewind";
import { useRouter } from "expo-router";

View File

@@ -2,7 +2,7 @@ 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 { ArchivedFormat } from "@linkwarden/types/global";
import { Link as LinkType } from "@linkwarden/prisma/client";
import WebView from "react-native-webview";
import { Image, Platform, ScrollView } from "react-native";

View File

@@ -2,7 +2,7 @@ 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 { ArchivedFormat } from "@linkwarden/types/global";
import { Link as LinkType } from "@linkwarden/prisma/client";
import Pdf from "react-native-pdf";

View File

@@ -11,7 +11,7 @@ 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 { ArchivedFormat } from "@linkwarden/types/global";
import { Link as LinkType } from "@linkwarden/prisma/client";
type Props = {

View File

@@ -2,7 +2,7 @@ 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 { ArchivedFormat } from "@linkwarden/types/global";
import { Link as LinkType } from "@linkwarden/prisma/client";
import WebView from "react-native-webview";

View File

@@ -9,8 +9,8 @@ import {
Linking,
} from "react-native";
import { decode } from "html-entities";
import { LinkIncludingShortenedCollectionAndTags } from "@linkwarden/types";
import { ArchivedFormat } from "@linkwarden/types";
import { LinkIncludingShortenedCollectionAndTags } from "@linkwarden/types/global";
import { ArchivedFormat } from "@linkwarden/types/global";
import getFormatBasedOnPreference from "@linkwarden/lib/getFormatBasedOnPreference";
import getOriginalFormat from "@linkwarden/lib/getOriginalFormat";
import {
@@ -40,12 +40,12 @@ type Props = {
const LinkListing = ({ link, dashboard }: Props) => {
const { auth } = useAuthStore();
const router = useRouter();
const updateLink = useUpdateLink(auth);
const updateLink = useUpdateLink({ auth, Alert });
const { data: user } = useUser(auth);
const { colorScheme } = useColorScheme();
const { data } = useDataStore();
const deleteLink = useDeleteLink(auth);
const deleteLink = useDeleteLink({ auth, Alert });
const [url, setUrl] = useState("");
@@ -57,7 +57,7 @@ const LinkListing = ({ link, dashboard }: Props) => {
} catch (error) {
console.log(error);
}
}, [link]);
}, [link.url]);
return (
<ContextMenu.Root>
@@ -122,8 +122,8 @@ const LinkListing = ({ link, dashboard }: Props) => {
<View className="flex flex-row gap-1 items-center mt-1.5 pr-1.5 self-start rounded-md">
<Folder
size={16}
fill={link.collection.color || ""}
color={link.collection.color || ""}
fill={link.collection.color || "#0ea5e9"}
color={link.collection.color || "#0ea5e9"}
/>
<Text
numberOfLines={1}
@@ -215,11 +215,11 @@ const LinkListing = ({ link, dashboard }: Props) => {
<ContextMenu.Item
key="pin-link"
onSelect={async () => {
onSelect={() => {
const isAlreadyPinned =
link?.pinnedBy && link.pinnedBy[0] ? true : false;
await updateLink.mutateAsync({
updateLink.mutateAsync({
...link,
pinnedBy: (isAlreadyPinned
? [{ id: undefined }]
@@ -319,12 +319,10 @@ const LinkListing = ({ link, dashboard }: Props) => {
{
text: "Delete",
style: "destructive",
onPress: () => {
deleteLink.mutate(link.id as number, {
onSuccess: async () => {
await deleteLinkCache(link.id as number);
},
});
onPress: async () => {
deleteLink.mutate(link.id as number);
await deleteLinkCache(link.id as number);
},
},
]

View File

@@ -7,7 +7,7 @@ import {
} from "react-native";
import LinkListing from "@/components/LinkListing";
import React, { useState } from "react";
import { LinkIncludingShortenedCollectionAndTags } from "@linkwarden/types";
import { LinkIncludingShortenedCollectionAndTags } from "@linkwarden/types/global";
import Spinner from "@/components/ui/Spinner";
import { rawTheme, ThemeName } from "@/lib/colors";
import { useColorScheme } from "nativewind";
@@ -28,7 +28,7 @@ export default function Links({ links, data }: Props) {
const [promptedRefetch, setPromptedRefetch] = useState(false);
return data.isLoading ? (
<View className="flex justify-center h-full items-center">
<View className="flex justify-center h-screen items-center">
<ActivityIndicator size="large" />
<Text className="text-base mt-2.5 text-neutral">Loading...</Text>
</View>

View File

@@ -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>
);
}

View File

@@ -1,6 +1,6 @@
import { View, Text, Pressable, Platform, Alert } from "react-native";
import { decode } from "html-entities";
import { TagIncludingLinkCount } from "@linkwarden/types";
import { TagIncludingLinkCount } from "@linkwarden/types/global";
import useAuthStore from "@/store/auth";
import { useRouter } from "expo-router";
import * as ContextMenu from "zeego/context-menu";

View File

@@ -4,6 +4,11 @@
"appVersionSource": "remote"
},
"build": {
"preview": {
"android": {
"buildType": "apk"
}
},
"development": {
"developmentClient": true,
"distribution": "internal"
@@ -15,6 +20,7 @@
}
},
"production": {
"corepack": true,
"distribution": "store",
"autoIncrement": true,
"channel": "production"

View File

@@ -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",
@@ -76,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",

View File

@@ -1,15 +1,12 @@
import { create } from "zustand";
import * as SecureStore from "expo-secure-store";
import { router } from "expo-router";
import { MobileAuth } from "@linkwarden/types";
import { MobileAuth } from "@linkwarden/types/global";
import { Alert } from "react-native";
import * as FileSystem from "expo-file-system";
import { queryClient } from "@/lib/queryClient";
import { mmkvPersister } from "@/lib/queryPersister";
import { clearCache } from "@/lib/cache";
const CACHE_DIR = FileSystem.documentDirectory + "archivedData/";
type AuthStore = {
auth: MobileAuth;
signIn: (
@@ -55,13 +52,20 @@ const useAuthStore = create<AuthStore>((set) => ({
console.log("Signing into", instance);
if (token) {
// make a request to the API to validate the token
await fetch(instance + "/api/v1/users/me", {
method: "GET",
headers: {
Authorization: `Bearer ${token}`,
},
}).then(async (res) => {
try {
// make a request to the API to validate the token
const res = await Promise.race([
fetch(instance + "/api/v1/users/me", {
method: "GET",
headers: {
Authorization: `Bearer ${token}`,
},
}),
new Promise<Response>((_, reject) =>
setTimeout(() => reject(new Error("TIMEOUT")), 30000)
),
]);
if (res.ok) {
await SecureStore.setItemAsync("INSTANCE", instance);
await SecureStore.setItemAsync("TOKEN", token);
@@ -76,33 +80,56 @@ const useAuthStore = create<AuthStore>((set) => ({
} else {
Alert.alert("Error", "Invalid token");
}
});
} 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."
);
}
}
} else {
await fetch(instance + "/api/v1/session", {
method: "POST",
body: JSON.stringify({ username, password }),
headers: {
"Content-Type": "application/json",
},
}).then(async (res) => {
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",
},
});
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 () => {

View File

@@ -1,5 +1,5 @@
import { create } from "zustand";
import { MobileData } from "@linkwarden/types";
import { MobileData } from "@linkwarden/types/global";
import AsyncStorage from "@react-native-async-storage/async-storage";
import { colorScheme } from "nativewind";
@@ -15,13 +15,14 @@ const useDataStore = create<DataStore>((set, get) => ({
hasShareIntent: false,
url: "",
},
theme: "light",
theme: "system",
preferredBrowser: "app",
preferredCollection: null,
},
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 } }));

View File

@@ -1,5 +1,5 @@
import { create } from "zustand";
import { LinkIncludingShortenedCollectionAndTags } from "@linkwarden/types";
import { LinkIncludingShortenedCollectionAndTags } from "@linkwarden/types/global";
import { User } from "@linkwarden/prisma/client";
type Tmp = {

View File

@@ -0,0 +1,125 @@
import Link from "next/link";
import { useRouter } from "next/router";
import React, { useEffect, useState } from "react";
import { useTranslation } from "next-i18next";
import { useUser } from "@linkwarden/router/user";
import Image from "next/image";
export default function AdminSidebar({ className }: { className?: string }) {
const { t } = useTranslation();
const LINKWARDEN_VERSION = process.env.version;
const { data: user } = useUser();
const router = useRouter();
const [active, setActive] = useState("");
useEffect(() => {
setActive(router.asPath);
}, [router]);
return (
<div
className={`bg-base-200 h-screen w-80 overflow-y-auto border-solid border border-base-200 border-r-neutral-content p-2 z-20 flex flex-col gap-5 justify-between ${
className || ""
}`}
>
<div className="flex flex-col gap-1">
<div className="flex items-center justify-between mb-4">
{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
/>
)}
</div>
<Link href="/admin/user-administration">
<div
className={`${
active === "/admin/user-administration"
? "bg-primary/20"
: "hover:bg-neutral/20"
} duration-200 cursor-pointer flex items-center gap-2 rounded-lg px-3 py-1`}
>
<i className="bi-people text-primary text-xl drop-shadow"></i>
<p className="truncate w-full font-semibold text-sm">
{t("user_administration")}
</p>
</div>
</Link>
<Link href="/admin/background-jobs">
<div
className={`${
active === "/admin/background-jobs"
? "bg-primary/20"
: "hover:bg-neutral/20"
} duration-200 cursor-pointer flex items-center gap-2 rounded-lg px-3 py-1`}
>
<i className="bi-gear-wide-connected text-primary text-xl drop-shadow"></i>
<p className="truncate w-full font-semibold text-sm">
{t("background_jobs")}
</p>
</div>
</Link>
</div>
<div className="flex flex-col gap-1">
<Link
href={`https://github.com/linkwarden/linkwarden/releases`}
target="_blank"
className="text-neutral text-sm ml-2 hover:opacity-50 duration-100"
>
{t("linkwarden_version", { version: LINKWARDEN_VERSION })}
</Link>
<Link href="https://docs.linkwarden.app" target="_blank">
<div
className={`hover:bg-neutral/20 duration-200 cursor-pointer flex items-center gap-2 rounded-lg px-3 py-1`}
>
<i className="bi-question-circle text-primary text-xl drop-shadow"></i>
<p className="truncate w-full font-semibold text-sm">{t("help")}</p>
</div>
</Link>
<Link href="https://github.com/linkwarden/linkwarden" target="_blank">
<div
className={`hover:bg-neutral/20 duration-200 cursor-pointer flex items-center gap-2 rounded-lg px-3 py-1`}
>
<i className="bi-github text-primary text-xl drop-shadow"></i>
<p className="truncate w-full font-semibold text-sm">{t("github")}</p>
</div>
</Link>
<Link href="https://twitter.com/LinkwardenHQ" target="_blank">
<div
className={`hover:bg-neutral/20 duration-200 cursor-pointer flex items-center gap-2 rounded-lg px-3 py-1`}
>
<i className="bi-twitter-x text-primary text-xl drop-shadow"></i>
<p className="truncate w-full font-semibold text-sm">{t("twitter")}</p>
</div>
</Link>
<Link href="https://fosstodon.org/@linkwarden" target="_blank">
<div
className={`hover:bg-neutral/20 duration-200 cursor-pointer flex items-center gap-2 rounded-lg px-3 py-1`}
>
<i className="bi-mastodon text-primary text-xl drop-shadow"></i>
<p className="truncate w-full font-semibold text-sm">{t("mastodon")}</p>
</div>
</Link>
</div>
</div>
);
}

View File

@@ -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">
<Trans
i18nKey="new_version_announcement"
values={{ version: announcementId }}
components={[
<Link
href={`https://blog.linkwarden.app/releases/${announcementId}`}
target="_blank"
className="underline decoration-dotted underline-offset-4 hover:text-primary duration-100"
key={0}
/>,
]}
/>
{announcementId ? (
<Trans
i18nKey="new_version_announcement"
values={{ version: announcementId }}
components={[
<Link
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>

View File

@@ -1,4 +1,3 @@
import useLocalSettingsStore from "@/store/localSettings";
import Image from "next/image";
import Link from "next/link";
import React, { ReactNode } from "react";
@@ -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>

View File

@@ -2,7 +2,7 @@ import Link from "next/link";
import {
AccountSettings,
CollectionIncludingMembersAndLinkCount,
} from "@linkwarden/types";
} from "@linkwarden/types/global";
import React, { useEffect, useState } from "react";
import ProfilePhoto from "./ProfilePhoto";
import usePermissions from "@/hooks/usePermissions";

View File

@@ -11,7 +11,7 @@ import Tree, {
} from "@atlaskit/tree";
import { Collection } from "@linkwarden/prisma/client";
import Link from "next/link";
import { CollectionIncludingMembersAndLinkCount } from "@linkwarden/types";
import { CollectionIncludingMembersAndLinkCount } from "@linkwarden/types/global";
import { useRouter } from "next/router";
import toast from "react-hot-toast";
import { useTranslation } from "next-i18next";
@@ -23,7 +23,7 @@ import { useUpdateUser, useUser } from "@linkwarden/router/user";
import Icon from "./Icon";
import { IconWeight } from "@phosphor-icons/react";
import Droppable from "./Droppable";
import { cn } from "@linkwarden/lib";
import { cn } from "@linkwarden/lib/utils";
import { Active, useDndContext } from "@dnd-kit/core";
interface ExtendedTreeItem extends TreeItem {
@@ -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}

View File

@@ -23,7 +23,7 @@ export default function ConfirmationModal({
const { t } = useTranslation();
return (
<Modal toggleModal={toggleModal} className={className}>
<Modal toggleModal={() => toggleModal()} className={className}>
<p className="text-xl font-thin">{title}</p>
<Separator className="mb-3 mt-1" />
{children}

View File

@@ -29,7 +29,7 @@ import {
useSensors,
} from "@dnd-kit/core";
import { restrictToParentElement } from "@dnd-kit/modifiers";
import { cn } from "@linkwarden/lib";
import { cn } from "@linkwarden/lib/utils";
import toast from "react-hot-toast";
interface DashboardSectionOption {
@@ -274,7 +274,7 @@ export default function DashboardLayoutDropdown() {
>
<div className="flex flex-col gap-1">
<div className="flex flex-col gap-1 mx-2">
<p className="text-sm text-neutral mb-1">
<p className="text-xs font-bold text-neutral mb-1">
{t("display_on_dashboard")}
</p>

View File

@@ -1,9 +1,9 @@
import { LinkIncludingShortenedCollectionAndTags } from "@linkwarden/types";
import { LinkIncludingShortenedCollectionAndTags } from "@linkwarden/types/global";
import useLocalSettingsStore from "@/store/localSettings";
import {
ArchivedFormat,
CollectionIncludingMembersAndLinkCount,
} from "@linkwarden/types";
} from "@linkwarden/types/global";
import { useEffect, useRef, useState } from "react";
import unescapeString from "@/lib/client/unescapeString";
import LinkActions from "@/components/LinkViews/LinkComponents/LinkActions";
@@ -17,7 +17,7 @@ import {
import useOnScreen from "@/hooks/useOnScreen";
import { useCollections } from "@linkwarden/router/collections";
import { useUser } from "@linkwarden/router/user";
import { useGetLink, useLinks } from "@linkwarden/router/links";
import { useGetLink } from "@linkwarden/router/links";
import { useRouter } from "next/router";
import openLink from "@/lib/client/openLink";
import LinkIcon from "./LinkViews/LinkComponents/LinkIcon";
@@ -26,7 +26,8 @@ import LinkTypeBadge from "./LinkViews/LinkComponents/LinkTypeBadge";
import LinkPin from "./LinkViews/LinkComponents/LinkPin";
import { Separator } from "./ui/separator";
import { useDraggable } from "@dnd-kit/core";
import { cn } from "@linkwarden/lib";
import { cn } from "@linkwarden/lib/utils";
import { useTranslation } from "next-i18next";
export function DashboardLinks({
links,
@@ -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,
},
});
@@ -78,8 +82,6 @@ export function Card({ link, editMode, dashboardType }: Props) {
settings: { show },
} = useLocalSettingsStore();
const { links } = useLinks();
const router = useRouter();
const isPublicRoute = router.pathname.startsWith("/public") ? true : false;
@@ -98,7 +100,7 @@ export function Card({ link, editMode, dashboardType }: Props) {
(e) => e.id === link.collection.id
) as CollectionIncludingMembersAndLinkCount
);
}, [collections, links]);
}, [collections, link]);
const ref = useRef<HTMLDivElement>(null);
const isVisible = useOnScreen(ref);
@@ -163,6 +165,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>
@@ -206,7 +209,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} />}
@@ -220,7 +227,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"

View File

@@ -11,13 +11,15 @@ import {
useSensors,
} from "@dnd-kit/core";
import LinkIcon from "./LinkViews/LinkComponents/LinkIcon";
import { LinkIncludingShortenedCollectionAndTags } from "@linkwarden/types";
import { LinkIncludingShortenedCollectionAndTags } from "@linkwarden/types/global";
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 updateLink = useUpdateLink({ toast, t });
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,70 +85,151 @@ 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
const isFromRecentSection = active.data.current?.dashboardType === "recent";
setActiveLink(null);
const mutateWithToast = async (
updatedLink: LinkIncludingShortenedCollectionAndTags,
opts?: { invalidateDashboardOnError?: boolean }
) => {
updateLink.mutateAsync(updatedLink);
};
// 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;
}
// 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 = {
const allTags: { name: string }[] = (activeLink.tags ?? []).map(
(tag) => ({
name: tag.name,
})
);
const updatedLink: LinkIncludingShortenedCollectionAndTags = {
...activeLink,
tags: newTags as any,
tags: [...allTags, { name: tagName }] as any,
};
} else {
const collectionId = over.data.current?.id as number;
const collectionName = over.data.current?.name as string;
const ownerId = over.data.current?.ownerId as number;
// Immediately hide the drag overlay
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,
},
};
await mutateWithToast(updatedLink, {
invalidateDashboardOnError: typeof queryClient !== "undefined",
});
return;
}
const load = toast.loading(t("updating"));
await updateLink.mutateAsync(updatedLink, {
onSettled: (_, error) => {
toast.dismiss(load);
if (error) {
toast.error(error.message);
} else {
toast.success(t("updated"));
// 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}

View File

@@ -1,6 +1,6 @@
import React from "react";
import importBookmarks from "@/lib/client/importBookmarks";
import { MigrationFormat } from "@linkwarden/types";
import { MigrationFormat } from "@linkwarden/types/global";
import { useTranslation } from "next-i18next";
import {
DropdownMenu,

View File

@@ -2,7 +2,7 @@ import React, { useEffect, useState } from "react";
import {
LinkIncludingShortenedCollectionAndTags,
ArchivedFormat,
} from "@linkwarden/types";
} from "@linkwarden/types/global";
import Link from "next/link";
import {
atLeastOneFormatAvailable,
@@ -113,7 +113,7 @@ export default function LinkDetails({
);
};
const updateLink = useUpdateLink();
const updateLink = useUpdateLink({ toast, t });
const updateFile = useUpdateFile();
const submit = async (e?: any) => {
@@ -126,21 +126,9 @@ export default function LinkDetails({
return;
}
const load = toast.loading(t("updating"));
updateLink.mutateAsync(link);
await updateLink.mutateAsync(link, {
onSettled: (data, error) => {
toast.dismiss(load);
if (error) {
toast.error(error.message);
} else {
toast.success(t("updated"));
setMode && setMode("view");
setLink(data);
}
},
});
setMode && setMode("view");
};
const setCollection = (e: any) => {
@@ -187,6 +175,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>

View File

@@ -4,14 +4,13 @@ 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 {
LinkIncludingShortenedCollectionAndTags,
Sort,
ViewMode,
} from "@linkwarden/types";
} from "@linkwarden/types/global";
import { useArchiveAction, useBulkDeleteLinks } from "@linkwarden/router/links";
import toast from "react-hot-toast";
import { Button } from "@/components/ui/button";
@@ -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),
{
onSettled: (data, error) => {
toast.dismiss(load);
const ids = Object.keys(selectedIds).map(Number);
if (error) {
toast.error(error.message);
} else {
setSelectedLinks([]);
setEditMode?.(false);
toast.success(t("deleted"));
}
},
}
);
await deleteLinksById.mutateAsync(ids, {
onSettled: (data, error) => {
toast.dismiss(load);
if (error) {
toast.error(error.message);
} else {
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>

View File

@@ -1,11 +1,7 @@
import { useState } from "react";
import {
CollectionIncludingMembersAndLinkCount,
LinkIncludingShortenedCollectionAndTags,
} from "@linkwarden/types";
import { LinkIncludingShortenedCollectionAndTags } from "@linkwarden/types/global";
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();
@@ -58,7 +54,7 @@ export default function LinkActions({
const [refreshPreservationsModal, setRefreshPreservationsModal] =
useState(false);
const deleteLink = useDeleteLink();
const deleteLink = useDeleteLink({ toast, t });
const updateArchive = async () => {
const load = toast.loading(t("sending_request"));
@@ -135,13 +131,7 @@ export default function LinkActions({
onClick={async (e) => {
if (e.shiftKey) {
const load = toast.loading(t("deleting"));
await deleteLink.mutateAsync(link.id as number, {
onSettled: (data, error) => {
toast.dismiss(load);
if (error) toast.error(error.message);
else toast.success(t("deleted"));
},
});
await deleteLink.mutateAsync(link.id as number);
} else {
setDeleteLinkModal(true);
}

View File

@@ -2,9 +2,8 @@ import {
ArchivedFormat,
CollectionIncludingMembersAndLinkCount,
LinkIncludingShortenedCollectionAndTags,
} from "@linkwarden/types";
import { useEffect, useMemo, useRef, useState } from "react";
import useLinkStore from "@/store/links";
} from "@linkwarden/types/global";
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,146 +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]);
}
};
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={() =>
@@ -197,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
@@ -240,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} />}
@@ -256,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"
/>
@@ -266,3 +185,5 @@ export default function LinkCard({ link, columns, editMode }: Props) {
</div>
);
}
export default React.memo(LinkCard);

View File

@@ -2,23 +2,20 @@ import Icon from "@/components/Icon";
import {
CollectionIncludingMembersAndLinkCount,
LinkIncludingShortenedCollectionAndTags,
} from "@linkwarden/types";
} from "@linkwarden/types/global";
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);

View File

@@ -1,11 +1,7 @@
import { LinkIncludingShortenedCollectionAndTags } from "@linkwarden/types";
import { LinkIncludingShortenedCollectionAndTags } from "@linkwarden/types/global";
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);

View File

@@ -2,7 +2,7 @@ import { formatAvailable } from "@linkwarden/lib/formatStats";
import {
ArchivedFormat,
LinkIncludingShortenedCollectionAndTags,
} from "@linkwarden/types";
} from "@linkwarden/types/global";
import { useTranslation } from "next-i18next";
import Link from "next/link";
import { useRouter } from "next/router";

View File

@@ -1,13 +1,13 @@
import { LinkIncludingShortenedCollectionAndTags } from "@linkwarden/types";
import { LinkIncludingShortenedCollectionAndTags } from "@linkwarden/types/global";
import Image from "next/image";
import isValidUrl from "@/lib/shared/isValidUrl";
import React, { useState } from "react";
import React, { useEffect, useState } from "react";
import Icon from "@/components/Icon";
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,
@@ -30,6 +30,10 @@ export default function LinkIcon({
const [faviconLoaded, setFaviconLoaded] = useState(false);
useEffect(() => {
setFaviconLoaded(false);
}, [link.url]);
return (
<div onClick={() => onClick && onClick()}>
{link.icon ? (
@@ -45,17 +49,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 +108,5 @@ const LinkPlaceholderIcon = ({
</div>
);
};
export default React.memo(LinkIcon);

View File

@@ -1,114 +1,62 @@
import {
CollectionIncludingMembersAndLinkCount,
LinkIncludingShortenedCollectionAndTags,
} from "@linkwarden/types";
import { useEffect, useState } from "react";
import useLinkStore from "@/store/links";
} from "@linkwarden/types/global";
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);

View File

@@ -2,9 +2,8 @@ import {
ArchivedFormat,
CollectionIncludingMembersAndLinkCount,
LinkIncludingShortenedCollectionAndTags,
} from "@linkwarden/types";
import { useEffect, useMemo, useRef, useState } from "react";
import useLinkStore from "@/store/links";
} from "@linkwarden/types/global";
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,142 +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 { cn } from "@linkwarden/lib/utils";
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]);
}
};
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);
@@ -160,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
@@ -195,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
@@ -258,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} />}
@@ -273,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"
/>
@@ -283,3 +203,5 @@ export default function LinkMasonry({ link, editMode, columns }: Props) {
</div>
);
}
export default React.memo(LinkMasonry);

View File

@@ -1,4 +1,4 @@
import { LinkIncludingShortenedCollectionAndTags } from "@linkwarden/types";
import { LinkIncludingShortenedCollectionAndTags } from "@linkwarden/types/global";
import { useRouter } from "next/router";
import clsx from "clsx";
import usePinLink from "@/lib/client/pinLink";

View File

@@ -1,8 +1,8 @@
import { LinkIncludingShortenedCollectionAndTags } from "@linkwarden/types";
import { LinkIncludingShortenedCollectionAndTags } from "@linkwarden/types/global";
import Link from "next/link";
import { useEffect, useState } from "react";
import React, { useEffect, useState } from "react";
export default function LinkTypeBadge({
function LinkTypeBadge({
link,
}: {
link: LinkIncludingShortenedCollectionAndTags;
@@ -50,3 +50,5 @@ export default function LinkTypeBadge({
</div>
);
}
export default React.memo(LinkTypeBadge);

View File

@@ -1,32 +1,50 @@
import LinkCard from "@/components/LinkViews/LinkComponents/LinkCard";
import {
CollectionIncludingMembersAndLinkCount,
LinkIncludingShortenedCollectionAndTags,
ViewMode,
} from "@linkwarden/types";
} from "@linkwarden/types/global";
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}
>
<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>
);
})}
{(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,75 +257,100 @@ 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}
>
<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>
);
})}
{(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"
>
<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>
<div className="skeleton h-2 w-full"></div>
<div className="skeleton h-2 w-1/3"></div>
</div>
</div>
);
})}
{(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>
<div className="skeleton h-2 w-full"></div>
<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) {
useData.fetchNextPage();
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) : [];

View File

@@ -66,7 +66,13 @@ export default function MobileNavigation({}: Props) {
<MobileNavigationButton href={`/collections`} icon={"bi-folder"} />
</div>
</div>
{newLinkModal && <NewLinkModal onClose={() => setNewLinkModal(false)} />}
{newLinkModal && (
<NewLinkModal
onClose={() => {
setNewLinkModal(false);
}}
/>
)}
{newCollectionModal && (
<NewCollectionModal onClose={() => setNewCollectionModal(false)} />
)}

View File

@@ -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),
{
onSettled: (data, error) => {
toast.dismiss(load);
await deleteLinksById.mutateAsync(ids, {
onSettled: (data, error) => {
toast.dismiss(load);
if (error) {
toast.error(error.message);
} else {
setSelectedLinks([]);
onClose();
toast.success(t("deleted"));
}
},
}
);
if (error) {
toast.error(error.message);
} else {
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>

View File

@@ -2,7 +2,7 @@ import React, { useState } from "react";
import CollectionSelection from "@/components/InputSelect/CollectionSelection";
import TagSelection from "@/components/InputSelect/TagSelection";
import useLinkStore from "@/store/links";
import { LinkIncludingShortenedCollectionAndTags } from "@linkwarden/types";
import { LinkIncludingShortenedCollectionAndTags } from "@linkwarden/types/global";
import toast from "react-hot-toast";
import Modal from "../Modal";
import { useTranslation } from "next-i18next";
@@ -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" />

View File

@@ -1,6 +1,5 @@
import React, { useEffect, useState } from "react";
import TextInput from "@/components/TextInput";
import { CollectionIncludingMembersAndLinkCount } from "@linkwarden/types";
import { CollectionIncludingMembersAndLinkCount } from "@linkwarden/types/global";
import { useRouter } from "next/router";
import usePermissions from "@/hooks/usePermissions";
import Modal from "../Modal";
@@ -22,7 +21,6 @@ export default function DeleteCollectionModal({
const { t } = useTranslation();
const [collection, setCollection] =
useState<CollectionIncludingMembersAndLinkCount>(activeCollection);
const [submitLoader, setSubmitLoader] = useState(false);
const router = useRouter();
const permissions = usePermissions(collection.id as number);
@@ -30,32 +28,15 @@ export default function DeleteCollectionModal({
setCollection(activeCollection);
}, []);
const deleteCollection = useDeleteCollection();
const deleteCollection = useDeleteCollection({ toast, t });
const submit = async () => {
if (!submitLoader) {
setSubmitLoader(true);
if (!collection) return null;
if (!collection) return null;
setSubmitLoader(true);
deleteCollection.mutateAsync(collection.id as number);
const load = toast.loading(t("deleting_collection"));
deleteCollection.mutateAsync(collection.id as number, {
onSettled: (data, error) => {
setSubmitLoader(false);
toast.dismiss(load);
if (error) {
toast.error(error.message);
} else {
onClose();
toast.success(t("deleted"));
router.push("/collections");
}
},
});
}
onClose();
router.push("/collections");
};
return (

View File

@@ -1,5 +1,5 @@
import React, { useEffect, useState } from "react";
import { LinkIncludingShortenedCollectionAndTags } from "@linkwarden/types";
import { LinkIncludingShortenedCollectionAndTags } from "@linkwarden/types/global";
import Modal from "../Modal";
import { useRouter } from "next/router";
import { Button } from "@/components/ui/button";
@@ -18,7 +18,7 @@ export default function DeleteLinkModal({ onClose, activeLink }: Props) {
const [link, setLink] =
useState<LinkIncludingShortenedCollectionAndTags>(activeLink);
const deleteLink = useDeleteLink();
const deleteLink = useDeleteLink({ toast, t });
const router = useRouter();
useEffect(() => {
@@ -26,26 +26,15 @@ export default function DeleteLinkModal({ onClose, activeLink }: Props) {
}, []);
const submit = async () => {
const load = toast.loading(t("deleting"));
deleteLink.mutateAsync(link.id as number);
await deleteLink.mutateAsync(link.id as number, {
onSettled: (data, error) => {
toast.dismiss(load);
if (error) {
toast.error(error.message);
} else {
if (
router.pathname.startsWith("/links/[id]") ||
router.pathname.startsWith("/preserved/[id]")
) {
router.push("/dashboard");
}
toast.success(t("deleted"));
onClose();
}
},
});
if (
router.pathname.startsWith("/links/[id]") ||
router.pathname.startsWith("/preserved/[id]")
) {
router.push("/dashboard");
}
onClose();
};
return (

View File

@@ -1,5 +1,5 @@
import React, { useEffect, useState } from "react";
import { TagIncludingLinkCount } from "@linkwarden/types";
import { TagIncludingLinkCount } from "@linkwarden/types/global";
import Modal from "../Modal";
import { Button } from "@/components/ui/button";
import { useTranslation } from "next-i18next";

View File

@@ -37,7 +37,7 @@ export default function DeleteUserModal({ onClose, userId }: Props) {
const { data: config } = useConfig();
const isAdmin = data?.user?.id === config?.ADMIN;
const isAdmin = data?.user?.id === (config?.ADMIN || 1);
return (
<Modal toggleModal={onClose}>

View File

@@ -1,6 +1,6 @@
import React, { useState } from "react";
import TextInput from "@/components/TextInput";
import { CollectionIncludingMembersAndLinkCount } from "@linkwarden/types";
import { CollectionIncludingMembersAndLinkCount } from "@linkwarden/types/global";
import Modal from "../Modal";
import { useTranslation } from "next-i18next";
import { useUpdateCollection } from "@linkwarden/router/collections";

View File

@@ -5,7 +5,7 @@ import {
AccountSettings,
CollectionIncludingMembersAndLinkCount,
Member,
} from "@linkwarden/types";
} from "@linkwarden/types/global";
import getPublicUserData from "@/lib/client/getPublicUserData";
import usePermissions from "@/hooks/usePermissions";
import ProfilePhoto from "../ProfilePhoto";
@@ -41,6 +41,9 @@ export default function EditCollectionSharingModal({
const [collection, setCollection] =
useState<CollectionIncludingMembersAndLinkCount>(activeCollection);
const [propagateToSubcollections, setPropagateToSubcollections] =
useState(false);
const [submitLoader, setSubmitLoader] = useState(false);
const updateCollection = useUpdateCollection();
@@ -53,19 +56,22 @@ export default function EditCollectionSharingModal({
const load = toast.loading(t("updating_collection"));
await updateCollection.mutateAsync(collection, {
onSettled: (data, error) => {
setSubmitLoader(false);
toast.dismiss(load);
await updateCollection.mutateAsync(
{ ...collection, propagateToSubcollections },
{
onSettled: (data, error) => {
setSubmitLoader(false);
toast.dismiss(load);
if (error) {
toast.error(error.message);
} else {
onClose();
toast.success(t("updated"));
}
},
});
if (error) {
toast.error(error.message);
} else {
onClose();
toast.success(t("updated"));
}
},
}
);
}
};
@@ -365,6 +371,27 @@ export default function EditCollectionSharingModal({
</>
)}
{permissions === true && !isPublicRoute && (
<div>
<label className="label cursor-pointer justify-start gap-2">
<input
type="checkbox"
checked={propagateToSubcollections}
onChange={() =>
setPropagateToSubcollections(!propagateToSubcollections)
}
className="checkbox checkbox-primary"
/>
<span className="label-text">
{t("apply_members_roles_to_subcollections")}
</span>
</label>
<p className="text-neutral text-sm">
{t("apply_members_roles_to_subcollections_desc")}
</p>
</div>
)}
{permissions === true && !isPublicRoute && (
<Button
variant="accent"

View File

@@ -1,5 +1,5 @@
import React, { useState } from "react";
import { LinkIncludingShortenedCollectionAndTags } from "@linkwarden/types";
import { LinkIncludingShortenedCollectionAndTags } from "@linkwarden/types/global";
import { useTranslation } from "next-i18next";
import { useDeleteLink } from "@linkwarden/router/links";
import Drawer from "../Drawer";
@@ -43,7 +43,7 @@ export default function LinkModal({
const isPublicRoute = router.pathname.startsWith("/public") ? true : false;
const deleteLink = useDeleteLink();
const deleteLink = useDeleteLink({ toast, t });
const [mode, setMode] = useState<"view" | "edit">(activeMode || "view");
@@ -51,13 +51,8 @@ export default function LinkModal({
setTimeout(() => (document.body.style.pointerEvents = ""), 0);
if (e.shiftKey && link.id) {
const loading = toast.loading(t("deleting"));
await deleteLink.mutateAsync(link.id, {
onSettled: (data, error) => {
toast.dismiss(loading);
error ? toast.error(error.message) : toast.success(t("deleted"));
},
});
deleteLink.mutateAsync(link.id);
onClose();
} else {
onDelete();

View File

@@ -2,7 +2,7 @@ import React, { useEffect, useLayoutEffect, useRef, useState } from "react";
import TextInput from "@/components/TextInput";
import { Collection } from "@linkwarden/prisma/client";
import Modal from "../Modal";
import { CollectionIncludingMembersAndLinkCount } from "@linkwarden/types";
import { CollectionIncludingMembersAndLinkCount } from "@linkwarden/types/global";
import { useTranslation } from "next-i18next";
import { useCreateCollection } from "@linkwarden/router/collections";
import toast from "react-hot-toast";

View File

@@ -7,14 +7,17 @@ import { useRouter } from "next/router";
import Modal from "../Modal";
import { useTranslation } from "next-i18next";
import { useCollections } from "@linkwarden/router/collections";
import { useAddLink } from "@linkwarden/router/links";
import toast from "react-hot-toast";
import { PostLinkSchemaType } from "@linkwarden/lib/schemaValidation";
import {
PostLinkSchema,
PostLinkSchemaType,
} from "@linkwarden/lib/schemaValidation";
import { Button } from "@/components/ui/button";
import { Separator } from "../ui/separator";
import { useAddLink } from "@linkwarden/router/links";
type Props = {
onClose: Function;
onClose: () => void;
};
export default function NewLinkModal({ onClose }: Props) {
@@ -31,10 +34,13 @@ export default function NewLinkModal({ onClose }: Props) {
},
} as PostLinkSchemaType;
const addLink = useAddLink({
toast,
t,
});
const inputRef = useRef<HTMLInputElement>(null);
const [link, setLink] = useState<PostLinkSchemaType>(initial);
const addLink = useAddLink();
const [submitLoader, setSubmitLoader] = useState(false);
const [optionsExpanded, setOptionsExpanded] = useState(false);
const router = useRouter();
const { data: collections = [] } = useCollections();
@@ -80,22 +86,17 @@ export default function NewLinkModal({ onClose }: Props) {
}, []);
const submit = async () => {
if (!submitLoader) {
setSubmitLoader(true);
const load = toast.loading(t("creating_link"));
await addLink.mutateAsync(link, {
onSettled: (data, error) => {
setSubmitLoader(false);
toast.dismiss(load);
if (error) {
toast.error(t(error.message));
} else {
onClose();
toast.success(t("link_created"));
}
},
});
}
const dataValidation = PostLinkSchema.safeParse(link);
if (!dataValidation.success)
return toast.error(
`Error: ${
dataValidation.error.issues[0].message
} [${dataValidation.error.issues[0].path.join(", ")}]`
);
addLink.mutateAsync(link);
onClose();
};
return (

View File

@@ -1,6 +1,6 @@
import React, { useLayoutEffect, useRef, useState } from "react";
import TextInput from "@/components/TextInput";
import { TokenExpiry } from "@linkwarden/types";
import { TokenExpiry } from "@linkwarden/types/global";
import toast from "react-hot-toast";
import Modal from "../Modal";
import { Button } from "@/components/ui/button";

View File

@@ -26,12 +26,20 @@ import {
} from "@/components/ui/tooltip";
import { useUser } from "@linkwarden/router/user";
import Link from "next/link";
import SettingsSidebar from "@/components/SettingsSidebar";
import AdminSidebar from "@/components/AdminSidebar";
const STRIPE_ENABLED = process.env.NEXT_PUBLIC_STRIPE === "true";
const TRIAL_PERIOD_DAYS =
Number(process.env.NEXT_PUBLIC_TRIAL_PERIOD_DAYS) || 14;
export default function Navbar() {
export default function Navbar({
settings,
admin,
}: {
settings?: boolean;
admin?: boolean;
}) {
const { t } = useTranslation();
const router = useRouter();
const { data: user } = useUser();
@@ -162,13 +170,23 @@ export default function Navbar() {
<div className="fixed top-0 bottom-0 right-0 left-0 bg-black bg-opacity-10 backdrop-blur-sm flex items-center fade-in z-40">
<ClickAwayHandler className="h-full" onClickOutside={toggleSidebar}>
<div className="slide-right h-full shadow-lg">
<Sidebar />
{admin ? (
<AdminSidebar />
) : settings ? (
<SettingsSidebar />
) : (
<Sidebar />
)}
</div>
</ClickAwayHandler>
</div>
)}
{newLinkModal && (
<NewLinkModal onClose={() => setNewLinkModal(false)} />
<NewLinkModal
onClose={() => {
setNewLinkModal(false);
}}
/>
)}
{newCollectionModal && (
<NewCollectionModal onClose={() => setNewCollectionModal(false)} />

View File

@@ -40,7 +40,13 @@ export default function NoLinksFound({ text }: Props) {
</span>
</Button>
</div>
{newLinkModal && <NewLinkModal onClose={() => setNewLinkModal(false)} />}
{newLinkModal && (
<NewLinkModal
onClose={() => {
setNewLinkModal(false);
}}
/>
)}
</div>
);
}

View File

@@ -8,11 +8,8 @@ import { PreservationSkeleton } from "../Skeletons";
import {
LinkIncludingShortenedCollectionAndTags,
ArchivedFormat,
} from "@linkwarden/types";
import {
atLeastOneFormatAvailable,
formatAvailable,
} from "@linkwarden/lib/formatStats";
} from "@linkwarden/types/global";
import { formatAvailable } from "@linkwarden/lib/formatStats";
import getLinkTypeFromFormat from "@linkwarden/lib/getLinkTypeFromFormat";
type Props = {
@@ -66,6 +63,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 +133,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) => {

View File

@@ -2,7 +2,7 @@ import {
ArchivedFormat,
CollectionIncludingMembersAndLinkCount,
LinkIncludingShortenedCollectionAndTags,
} from "@linkwarden/types";
} from "@linkwarden/types/global";
import React, { useEffect, useState } from "react";
import {
DropdownMenu,
@@ -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

View File

@@ -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)} />

View File

@@ -11,7 +11,7 @@ import usePermissions from "@/hooks/usePermissions";
import {
LinkIncludingShortenedCollectionAndTags,
ArchivedFormat,
} from "@linkwarden/types";
} from "@linkwarden/types/global";
import ClickAwayHandler from "@/components/ClickAwayHandler";
import {
useGetLinkHighlights,

View File

@@ -1,7 +1,7 @@
import {
ArchivedFormat,
LinkIncludingShortenedCollectionAndTags,
} from "@linkwarden/types";
} from "@linkwarden/types/global";
import Link from "next/link";
import { useRouter } from "next/router";
import { Button } from "@/components/ui/button";

View File

@@ -65,7 +65,7 @@ export default function ProfileDropdown() {
{isAdmin && (
<DropdownMenuItem asChild>
<Link
href="/admin"
href="/admin/user-administration"
onClick={() => (document?.activeElement as HTMLElement)?.blur()}
className="whitespace-nowrap"
>

View File

@@ -57,6 +57,7 @@ export default function ProfilePhoto({
draggable={false}
onError={() => setImage("")}
className="aspect-square rounded-full"
unoptimized
/>
</div>
</div>

View File

@@ -1,16 +1,82 @@
import { useEffect, useState } from "react";
import { useEffect, useRef, useState } from "react";
import Link from "next/link";
import { useRouter } from "next/router";
import { toast } from "react-hot-toast";
import { useTranslation } from "next-i18next";
import { Button } from "@/components/ui/button";
import { useUser } from "@linkwarden/router/user";
type Props = {
placeholder?: string;
};
const ADVANCED_SEARCH_OPERATORS = [
{
operator: "name:",
labelKey: "search_operator_name",
icon: "bi-type",
},
{
operator: "url:",
labelKey: "search_operator_url",
icon: "bi-link-45deg",
},
{
operator: "tag:",
labelKey: "search_operator_tag",
icon: "bi-tag",
},
{
operator: "collection:",
labelKey: "search_operator_collection",
icon: "bi-folder2",
},
{
operator: "before:",
labelKey: "search_operator_before",
icon: "bi-calendar-minus",
},
{
operator: "after:",
labelKey: "search_operator_after",
icon: "bi-calendar-plus",
},
{
operator: "public:true",
labelKey: "search_operator_public",
icon: "bi-globe2",
},
{
operator: "description:",
labelKey: "search_operator_description",
icon: "bi-card-text",
},
{
operator: "type:",
labelKey: "search_operator_type",
icon: "bi-file-earmark",
},
{
operator: "pinned:true",
labelKey: "search_operator_pinned",
icon: "bi-pin-angle",
},
{
operator: "!",
labelKey: "search_operator_exclude",
icon: "bi-slash-circle",
},
] as const;
export default function SearchBar({ placeholder }: Props) {
const router = useRouter();
const { t } = useTranslation();
const [searchQuery, setSearchQuery] = useState("");
const [showSuggestions, setShowSuggestions] = useState(false);
const inputRef = useRef<HTMLInputElement>(null);
const { data: user } = useUser();
const [dismissSearchNote, setDismissSearchNote] = useState(false);
useEffect(() => {
router.query.q
@@ -18,6 +84,15 @@ export default function SearchBar({ placeholder }: Props) {
: setSearchQuery("");
}, [router.query.q]);
const handleSuggestionClick = (operator: string) => {
setSearchQuery((prev) => {
const needsSpace = prev.length > 0 && !prev.endsWith(" ");
return `${prev}${needsSpace ? " " : ""}${operator}`;
});
setShowSuggestions(false);
requestAnimationFrame(() => inputRef.current?.focus());
};
return (
<div className="flex items-center relative group">
<label
@@ -30,8 +105,15 @@ export default function SearchBar({ placeholder }: Props) {
<input
id="search-box"
type="text"
ref={inputRef}
placeholder={placeholder || t("search_for_links")}
value={searchQuery}
onFocus={() => {
setShowSuggestions(true);
}}
onBlur={() => {
setShowSuggestions(false);
}}
onChange={(e) => {
e.target.value.includes("%") &&
toast.error(t("search_query_invalid_symbol"));
@@ -57,9 +139,75 @@ export default function SearchBar({ placeholder }: Props) {
}
}
}}
style={{ transition: "width 0.2s ease-in-out" }}
className="border border-neutral-content bg-base-200 focus:border-primary py-1 rounded-md pl-9 pr-2 w-full max-w-[15rem] md:focus:w-80 md:w-[15rem] md:max-w-full outline-none"
className="border border-neutral-content bg-base-200 focus:border-primary py-1 rounded-md pl-9 pr-2 w-full max-w-[15rem] md:w-80 md:max-w-full outline-none"
/>
{showSuggestions && (
<div className="absolute left-0 top-full mt-2 w-full z-50">
<div
className="border border-neutral-content bg-base-200 shadow-md rounded-md px-2 py-1 flex flex-col gap-1"
onMouseDown={(e) => e.preventDefault()}
>
<div className="flex items-center justify-between">
<p className="text-xs font-bold text-neutral">
{t("search_operators")}
</p>
</div>
<div className="flex flex-col gap-1">
{ADVANCED_SEARCH_OPERATORS.map((entry) => (
<button
key={entry.operator}
type="button"
className="flex items-center gap-2 justify-between rounded-md px-2 py-1 text-left hover:bg-neutral-content duration-100"
onClick={() => handleSuggestionClick(entry.operator)}
>
<div className="flex items-center gap-2">
<i className={`${entry.icon} text-primary text-sm`} />
<span className="text-xs text-neutral">
{t(entry.labelKey)}
</span>
</div>
<span className="font-mono text-xs px-1 rounded-md bg-base-100 border border-neutral-content text-base-content">
{entry.operator}
</span>
</button>
))}
</div>
<div className="flex justify-end">
<Button asChild variant="ghost" size="sm" className="text-xs">
<Link
href="https://docs.linkwarden.app/Usage/advanced-search"
target="_blank"
className="flex items-center gap-1"
>
{t("learn_more")}
<i className="bi-box-arrow-up-right text-xs" />
</Link>
</Button>
</div>
{/* {user?.hasUnIndexedLinks && !dismissSearchNote ? (
<div
role="alert"
className="border border-neutral p-2 my-1 rounded flex flex-col gap-2"
>
<p className="text-xs text-neutral">
<i className="bi-info-circle text-primary mr-1" />
<b>{t("note")}:</b> {t("search_unindexed_links_in_bg_info")}
</p>
<Button
variant="ghost"
size="sm"
className="w-full"
onClick={() => setDismissSearchNote(true)}
>
Dismiss
</Button>
</div>
) : undefined} */}
</div>
</div>
)}
</div>
);
}

View File

@@ -3,15 +3,13 @@ import { useRouter } from "next/router";
import React, { useEffect, useState } from "react";
import { useTranslation } from "next-i18next";
import { useUser } from "@linkwarden/router/user";
import { useConfig } from "@linkwarden/router/config";
import Image from "next/image";
export default function SettingsSidebar({ className }: { className?: string }) {
const { t } = useTranslation();
const LINKWARDEN_VERSION = process.env.version;
const { data: user } = useUser();
const { data: config } = useConfig();
const isAdmin = user?.id === (config?.ADMIN || 1);
const router = useRouter();
const [active, setActive] = useState("");
@@ -22,21 +20,46 @@ export default function SettingsSidebar({ className }: { className?: string }) {
return (
<div
className={`bg-base-100 h-full w-64 overflow-y-auto border-solid border border-base-100 border-r-neutral-content p-5 z-20 flex flex-col gap-5 justify-between ${
className={`bg-base-200 h-screen w-80 overflow-y-auto border-solid border border-base-200 border-r-neutral-content p-2 z-20 flex flex-col gap-5 justify-between ${
className || ""
}`}
>
<div className="flex flex-col gap-1">
<div className="flex items-center justify-between mb-4">
{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
/>
)}
</div>
<Link href="/settings/account">
<div
className={`${
active === "/settings/account"
? "bg-primary/20"
: "hover:bg-neutral/20"
} duration-100 py-5 px-2 cursor-pointer flex items-center gap-2 w-full rounded-md h-8`}
} duration-200 cursor-pointer flex items-center gap-2 rounded-lg px-3 py-1`}
>
<i className="bi-person text-primary text-xl"></i>
<p className="truncate w-full pr-7">{t("account")}</p>
<i className="bi-person text-primary text-xl drop-shadow"></i>
<p className="truncate w-full font-semibold text-sm">
{t("account")}
</p>
</div>
</Link>
@@ -46,10 +69,12 @@ export default function SettingsSidebar({ className }: { className?: string }) {
active === "/settings/preference"
? "bg-primary/20"
: "hover:bg-neutral/20"
} duration-100 py-5 px-2 cursor-pointer flex items-center gap-2 w-full rounded-md h-8`}
} duration-200 cursor-pointer flex items-center gap-2 rounded-lg px-3 py-1`}
>
<i className="bi-sliders text-primary text-xl"></i>
<p className="truncate w-full pr-7">{t("preference")}</p>
<i className="bi-sliders text-primary text-xl drop-shadow"></i>
<p className="truncate w-full font-semibold text-sm">
{t("preference")}
</p>
</div>
</Link>
@@ -59,10 +84,12 @@ export default function SettingsSidebar({ className }: { className?: string }) {
active === "/settings/rss-subscriptions"
? "bg-primary/20"
: "hover:bg-neutral/20"
} duration-100 py-5 px-2 cursor-pointer flex items-center gap-2 w-full rounded-md h-8`}
} duration-200 cursor-pointer flex items-center gap-2 rounded-lg px-3 py-1`}
>
<i className="bi-rss text-primary text-xl"></i>
<p className="truncate w-full pr-7">RSS Subscriptions</p>
<i className="bi-rss text-primary text-xl drop-shadow"></i>
<p className="truncate w-full font-semibold text-sm">
RSS Subscriptions
</p>
</div>
</Link>
@@ -72,10 +99,12 @@ export default function SettingsSidebar({ className }: { className?: string }) {
active === "/settings/access-tokens"
? "bg-primary/20"
: "hover:bg-neutral/20"
} duration-100 py-5 px-2 cursor-pointer flex items-center gap-2 w-full rounded-md h-8`}
} duration-200 cursor-pointer flex items-center gap-2 rounded-lg px-3 py-1`}
>
<i className="bi-key text-primary text-xl"></i>
<p className="truncate w-full pr-7">{t("access_tokens")}</p>
<i className="bi-key text-primary text-xl drop-shadow"></i>
<p className="truncate w-full font-semibold text-sm">
{t("access_tokens")}
</p>
</div>
</Link>
@@ -85,28 +114,15 @@ export default function SettingsSidebar({ className }: { className?: string }) {
active === "/settings/password"
? "bg-primary/20"
: "hover:bg-neutral/20"
} duration-100 py-5 px-2 cursor-pointer flex items-center gap-2 w-full rounded-md h-8`}
} duration-200 cursor-pointer flex items-center gap-2 rounded-lg px-3 py-1`}
>
<i className="bi-lock text-primary text-xl"></i>
<p className="truncate w-full pr-7">{t("password")}</p>
<i className="bi-lock text-primary text-xl drop-shadow"></i>
<p className="truncate w-full font-semibold text-sm">
{t("password")}
</p>
</div>
</Link>
{isAdmin && (
<Link href="/settings/worker">
<div
className={`${
active === "/settings/worker"
? "bg-primary/20"
: "hover:bg-neutral/20"
} duration-100 py-5 px-2 cursor-pointer flex items-center gap-2 w-full rounded-md h-8`}
>
<i className="bi-gear-wide-connected text-primary text-xl"></i>
<p className="truncate w-full pr-7">{t("worker")}</p>
</div>
</Link>
)}
{process.env.NEXT_PUBLIC_STRIPE && !user?.parentSubscriptionId && (
<Link href="/settings/billing">
<div
@@ -114,10 +130,12 @@ export default function SettingsSidebar({ className }: { className?: string }) {
active === "/settings/billing"
? "bg-primary/20"
: "hover:bg-neutral/20"
} duration-100 py-5 px-2 cursor-pointer flex items-center gap-2 w-full rounded-md h-8`}
} duration-200 cursor-pointer flex items-center gap-2 rounded-lg px-3 py-1`}
>
<i className="bi-credit-card text-primary text-xl"></i>
<p className="truncate w-full pr-7">{t("billing")}</p>
<i className="bi-credit-card text-primary text-xl drop-shadow"></i>
<p className="truncate w-full font-semibold text-sm">
{t("billing")}
</p>
</div>
</Link>
)}
@@ -133,34 +151,40 @@ export default function SettingsSidebar({ className }: { className?: string }) {
</Link>
<Link href="https://docs.linkwarden.app" target="_blank">
<div
className={`hover:bg-neutral/20 duration-100 py-2 px-2 cursor-pointer flex items-center gap-2 w-full rounded-md h-8`}
className={`hover:bg-neutral/20 duration-200 cursor-pointer flex items-center gap-2 rounded-lg px-3 py-1`}
>
<i className="bi-question-circle text-primary text-xl"></i>
<p className="truncate w-full pr-7">{t("help")}</p>
<i className="bi-question-circle text-primary text-xl drop-shadow"></i>
<p className="truncate w-full font-semibold text-sm">{t("help")}</p>
</div>
</Link>
<Link href="https://github.com/linkwarden/linkwarden" target="_blank">
<div
className={`hover:bg-neutral/20 duration-100 py-2 px-2 cursor-pointer flex items-center gap-2 w-full rounded-md h-8`}
className={`hover:bg-neutral/20 duration-200 cursor-pointer flex items-center gap-2 rounded-lg px-3 py-1`}
>
<i className="bi-github text-primary text-xl"></i>
<p className="truncate w-full pr-7">{t("github")}</p>
<i className="bi-github text-primary text-xl drop-shadow"></i>
<p className="truncate w-full font-semibold text-sm">
{t("github")}
</p>
</div>
</Link>
<Link href="https://twitter.com/LinkwardenHQ" target="_blank">
<div
className={`hover:bg-neutral/20 duration-100 py-2 px-2 cursor-pointer flex items-center gap-2 w-full rounded-md h-8`}
className={`hover:bg-neutral/20 duration-200 cursor-pointer flex items-center gap-2 rounded-lg px-3 py-1`}
>
<i className="bi-twitter-x text-primary text-xl"></i>
<p className="truncate w-full pr-7">{t("twitter")}</p>
<i className="bi-twitter-x text-primary text-xl drop-shadow"></i>
<p className="truncate w-full font-semibold text-sm">
{t("twitter")}
</p>
</div>
</Link>
<Link href="https://fosstodon.org/@linkwarden" target="_blank">
<div
className={`hover:bg-neutral/20 duration-100 py-2 px-2 cursor-pointer flex items-center gap-2 w-full rounded-md h-8`}
className={`hover:bg-neutral/20 duration-200 cursor-pointer flex items-center gap-2 rounded-lg px-3 py-1`}
>
<i className="bi-mastodon text-primary text-xl"></i>
<p className="truncate w-full pr-7">{t("mastodon")}</p>
<i className="bi-mastodon text-primary text-xl drop-shadow"></i>
<p className="truncate w-full font-semibold text-sm">
{t("mastodon")}
</p>
</div>
</Link>
</div>

View File

@@ -6,7 +6,7 @@ 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";
@@ -90,6 +90,7 @@ export default function Sidebar({
alt="Linkwarden Icon"
className="h-8 w-auto cursor-pointer"
onClick={() => router.push("/dashboard")}
priority
/>
) : user?.theme === "light" ? (
<Image
@@ -99,6 +100,7 @@ export default function Sidebar({
alt="Linkwarden"
className="h-9 w-auto cursor-pointer"
onClick={() => router.push("/dashboard")}
priority
/>
) : (
<Image
@@ -108,6 +110,7 @@ export default function Sidebar({
alt="Linkwarden"
className="h-9 w-auto cursor-pointer"
onClick={() => router.push("/dashboard")}
priority
/>
)}

View File

@@ -1,5 +1,5 @@
import React, { Dispatch, SetStateAction, useEffect } from "react";
import { Sort } from "@linkwarden/types";
import { Sort } from "@linkwarden/types/global";
import { TFunction } from "i18next";
import useLocalSettingsStore from "@/store/localSettings";
import { resetInfiniteQueryPagination } from "@linkwarden/router/links";

View File

@@ -8,7 +8,7 @@ import {
} from "@/components/ui/dropdown-menu";
import { Button } from "./ui/button";
import { Checkbox } from "./ui/checkbox";
import { TagIncludingLinkCount } from "@linkwarden/types";
import { TagIncludingLinkCount } from "@linkwarden/types/global";
import DeleteTagModal from "./ModalContent/DeleteTagModal";
import { cn } from "@/lib/utils";
import { useRouter } from "next/router";

View File

@@ -2,14 +2,15 @@ import { Tag } from "@linkwarden/prisma/client";
import Link from "next/link";
import { useTranslation } from "react-i18next";
import Droppable from "./Droppable";
import { cn } from "@linkwarden/lib";
import { cn } from "@linkwarden/lib/utils";
import { useDndContext } from "@dnd-kit/core";
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();

View File

@@ -1,4 +1,4 @@
import { cn } from "@linkwarden/lib";
import { cn } from "@linkwarden/lib/utils";
import React, { forwardRef } from "react";
export type TextInputProps = React.ComponentPropsWithoutRef<"input">;

View File

@@ -6,7 +6,7 @@ import {
DropdownMenuSeparator,
} from "@/components/ui/dropdown-menu";
import useLocalSettingsStore from "@/store/localSettings";
import { ViewMode } from "@linkwarden/types";
import { ViewMode } from "@linkwarden/types/global";
import { useTranslation } from "next-i18next";
import { Button } from "@/components/ui/button";
import { useEffect } from "react";
@@ -72,7 +72,7 @@ export default function ViewDropdown({
{!dashboard && (
<>
<div className="px-1">
<p className="text-sm text-neutral mb-1">{t("view")}</p>
<p className="text-xs font-bold text-neutral mb-1">{t("view")}</p>
<div className="flex gap-1 border-border">
{[ViewMode.Card, ViewMode.Masonry, ViewMode.List].map(
(mode) => {
@@ -112,7 +112,7 @@ export default function ViewDropdown({
</>
)}
<p className="text-sm text-neutral px-1 mb-1">{t("show")}</p>
<p className="text-xs font-bold text-neutral px-1 mb-1">{t("show")}</p>
{visibleShows.map((key) => (
<DropdownMenuCheckboxItem
key={key}
@@ -131,7 +131,7 @@ export default function ViewDropdown({
<DropdownMenuSeparator />
<div className="px-1">
<p className="text-sm text-neutral mb-1">
<p className="text-xs font-bold text-neutral mb-1">
{t("columns")}:{" "}
{settings.columns === 0 ? t("default") : settings.columns}
</p>

View File

@@ -5,7 +5,7 @@ import {
} from "@linkwarden/types/inputSelect";
import { useState, useEffect } from "react";
import { useTranslation } from "next-i18next";
import { isArchivalTag } from "@linkwarden/lib";
import { isArchivalTag } from "@linkwarden/lib/isArchivalTag";
const useArchivalTags = (initialTags: Tag[]) => {
const [archivalTags, setArchivalTags] = useState<ArchivalTagOption[]>([]);

View File

@@ -1,4 +1,4 @@
import { Member } from "@linkwarden/types";
import { Member } from "@linkwarden/types/global";
import { useEffect, useState } from "react";
import { useCollections } from "@linkwarden/router/collections";
import { useUser } from "@linkwarden/router/user";

View File

@@ -1,4 +1,4 @@
import { Member } from "@linkwarden/types";
import { Member } from "@linkwarden/types/global";
import { useEffect, useState } from "react";
import { useCollections } from "@linkwarden/router/collections";
import { useUser } from "@linkwarden/router/user";

View File

@@ -2,7 +2,7 @@ import {
CollectionIncludingMembersAndLinkCount,
LinkIncludingShortenedCollectionAndTags,
Sort,
} from "@linkwarden/types";
} from "@linkwarden/types/global";
import { SetStateAction, useEffect } from "react";
type Props<

View File

@@ -0,0 +1,38 @@
import AdminSidebar from "@/components/AdminSidebar";
import Navbar from "@/components/Navbar";
import React, { ReactNode } from "react";
import Link from "next/link";
import { Button } from "@/components/ui/button";
import { useTranslation } from "next-i18next";
interface Props {
children: ReactNode;
}
export default function AdminLayout({ children }: Props) {
const { t } = useTranslation();
return (
<div className="flex" data-testid="admin-wrapper">
<div className="hidden lg:block">
<AdminSidebar />
</div>
<div className="lg:w-[calc(100%-320px)] w-full sm:pb-0 pb-20 flex flex-col h-screen overflow-y-auto">
<Navbar admin />
<div className="p-5 mx-auto w-full max-w-7xl">
<div className="gap-2 mb-3">
<Button asChild variant="ghost" size="sm" className="text-neutral">
<Link href="/dashboard">
<i className="bi-chevron-left text-md" />
<p>{t("back_to_dashboard")}</p>
</Link>
</Button>
</div>
{children}
</div>
</div>
</div>
);
}

View File

@@ -3,6 +3,8 @@ import Announcement from "@/components/Announcement";
import Sidebar from "@/components/Sidebar";
import { ReactNode, useEffect, useState } from "react";
import getLatestVersion from "@/lib/client/getLatestVersion";
import DragNDrop from "@/components/DragNDrop";
import { LinkIncludingShortenedCollectionAndTags } from "@linkwarden/types/global";
interface Props {
children: ReactNode;
@@ -40,29 +42,34 @@ export default function MainLayout({ children }: Props) {
const toggleAnnouncementBar = () => setShowAnnouncement(!showAnnouncement);
const toggleSidebar = () => setSidebarIsCollapsed(!sidebarIsCollapsed);
return (
<div className="flex" data-testid="dashboard-wrapper">
{showAnnouncement && (
<Announcement toggleAnnouncementBar={toggleAnnouncementBar} />
)}
<div className="hidden lg:block">
<Sidebar
className={`${sidebarIsCollapsed ? "w-14" : "w-80"}`}
toggleSidebar={toggleSidebar}
sidebarIsCollapsed={sidebarIsCollapsed}
/>
</div>
const [activeLink, setActiveLink] =
useState<LinkIncludingShortenedCollectionAndTags | null>(null);
<div
className={`${
sidebarIsCollapsed
? "lg:w-[calc(100%-56px)]"
: "lg:w-[calc(100%-320px)]"
} w-full sm:pb-0 pb-20 flex flex-col h-screen overflow-y-auto`}
>
<Navbar />
{children}
return (
<DragNDrop activeLink={activeLink} setActiveLink={setActiveLink}>
<div className="flex" data-testid="dashboard-wrapper">
{showAnnouncement && (
<Announcement toggleAnnouncementBar={toggleAnnouncementBar} />
)}
<div className="hidden lg:block">
<Sidebar
className={`${sidebarIsCollapsed ? "w-14" : "w-80"}`}
toggleSidebar={toggleSidebar}
sidebarIsCollapsed={sidebarIsCollapsed}
/>
</div>
<div
className={`${
sidebarIsCollapsed
? "lg:w-[calc(100%-56px)]"
: "lg:w-[calc(100%-320px)]"
} w-full sm:pb-0 pb-20 flex flex-col h-screen overflow-y-auto`}
>
<Navbar />
{children}
</div>
</div>
</div>
</DragNDrop>
);
}

Some files were not shown because too many files have changed in this diff Show More