Compare commits

...

166 Commits

Author SHA1 Message Date
Daniel
c7ab767872 Merge pull request #1575 from linkwarden/dev
Dev
2026-01-05 18:09:36 +03:30
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
daniel31x13
cf84474921 fix infinite loading bug + enable corepack during eas submit 2025-12-31 08:57:46 -05: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
Daniel
1ee3e01cfd Merge pull request #1514 from linkwarden/dev
New Crowdin updates (#1471)
2025-12-11 01:25:58 -05:00
Daniel
e2e9395f8e New Crowdin updates (#1471)
* New translations common.json (Russian)

* New translations common.json (Turkish)

* New translations common.json (Turkish)

* New translations common.json (French)

* New translations common.json (Italian)

* New translations common.json (Russian)

* New translations common.json (Italian)

* New translations common.json (Italian)

---------

Co-authored-by: LinkwardenBot <bot@linkwarden.app>
2025-12-11 01:24:08 -05:00
Daniel
f415e8c4bb Merge pull request #1510 from linkwarden/dev
Dev
2025-12-07 15:30:41 -05:00
daniel31x13
6a73d7c594 minor change 2025-12-07 11:46:45 -05:00
daniel31x13
24206c0953 fix(mobile): integrate react-native-edge-to-edge for improved UI handling and add SafeAreaView for better layout management 2025-12-06 22:14:33 -05:00
daniel31x13
7610f844f7 minor improvement 2025-11-23 13:18:15 -05:00
daniel31x13
a7c1aeb876 fix(mobile): fix image not loading on iOS 2025-11-23 12:42:43 -05:00
daniel31x13
dd4c925a56 remove unnecessary plugin 2025-11-22 20:30:55 -05:00
daniel31x13
a4c55fb455 feat(mobile): add offline caching for all formats + bug fix 2025-11-22 20:28:04 -05:00
daniel31x13
451d17a2cb minor change 2025-11-21 05:48:56 -05:00
daniel31x13
530a12a86f mobile: bug fix + code cleanup 2025-11-21 05:33:53 -05:00
daniel31x13
e908f9c534 feat(mobile+web): add open in default browser option + cleaner code 2025-11-20 06:59:13 -05:00
daniel31x13
9af731c7eb feat(mobile): add open in browser option to context menu 2025-11-17 17:49:02 -05:00
daniel31x13
0548331937 mobile: fix android icon 2025-11-16 18:20:11 -05:00
daniel31x13
9eb4c883ff mobile: bug fix 2025-11-16 17:07:58 -05:00
daniel31x13
17b578361a mobile: minor fix 2025-11-16 16:58:57 -05:00
daniel31x13
08a220f424 small fix 2025-11-16 15:54:53 -05:00
daniel31x13
d7b6ce04e8 mobile: bug fix and DRYer code 2025-11-16 15:49:12 -05:00
daniel31x13
ece88eed5c feat(mobile): refetch loading previews 2025-11-16 15:29:50 -05:00
daniel31x13
257bdf9877 feat(mobile): add loader to preview 2025-11-16 14:44:20 -05:00
daniel31x13
0c512345b1 revert clickable dashboard stats 2025-11-16 13:23:08 -05:00
daniel31x13
9fc9e597e1 fix(mobile): replace router.push with router.navigate to prevent duplicate screens 2025-11-16 10:29:15 -05:00
daniel31x13
03ffc3c379 feat(mobile): clickable dashboard stats 2025-11-16 10:17:15 -05:00
daniel31x13
eb66d72589 feat(mobile): add tags page + many improvements 2025-11-16 10:10:16 -05:00
daniel31x13
3ab026aa37 feat(mobile): add collection page 2025-11-16 07:16:15 -05:00
daniel31x13
bebbf5ad80 feat(mobile): add collection functionality 2025-11-16 06:03:43 -05:00
daniel31x13
8f1612d10b feat(mobile): add share, edit, pin, delete and open in web browser support inside opened link pages 2025-11-15 15:58:35 -05:00
daniel31x13
26da55dfb9 feat(mobile): add loading indicator to link listing pages 2025-11-13 05:07:18 -05:00
daniel31x13
ffee9d0551 feat(mobile): add loading indicator to buttons 2025-11-13 03:19:49 -05:00
daniel31x13
fa4d9313ff fix(mobile): fix pull to refresh disappearing on iOS 2025-11-12 14:42:38 -05:00
daniel31x13
0ff5092561 fix(mobile): fix iPhone 17 crashing bug 2025-11-12 10:51:49 -05:00
daniel31x13
fd676eda34 fix(mobile): set theme as system defaults 2025-11-08 12:37:45 -05:00
daniel31x13
c46583131e fix(mobile): fix collection and tags not updating bug 2025-11-08 12:35:26 -05:00
daniel31x13
1d8fadf18d append .env.local to gitignore 2025-11-08 11:51:34 -05:00
daniel31x13
6b988193b3 fix: adjust SVG height calculation for responsive design in HomeScreen 2025-11-01 11:28:31 -04:00
Daniel
aab7c703ad Merge pull request #1477 from linkwarden/dev
feat: add error handling for dashboard layout updates with toast noti…
2025-10-30 18:34:00 +03:30
daniel31x13
98c1a1f035 feat: add error handling for dashboard layout updates with toast notifications 2025-10-30 11:03:17 -04:00
Daniel
f51f5f376f Merge pull request #1476 from linkwarden/dev
Dev
2025-10-30 18:16:47 +03:30
daniel31x13
e0f3bc3bc2 feat: add service account file configuration and update dashboard API for demo mode 2025-10-30 10:44:04 -04:00
Daniel
c839e7ac4c New Crowdin updates (#1460)
* New translations common.json (French)

* New translations common.json (French)

---------

Co-authored-by: LinkwardenBot <bot@linkwarden.app>
2025-10-22 18:04:28 -04:00
Daniel
b620bbaad7 Merge pull request #1466 from linkwarden/dev
Dev
2025-10-22 16:07:17 +03:30
daniel31x13
21779df1c2 fix: email feedback during trial 2025-10-22 08:36:24 -04:00
daniel31x13
86e721c159 update trial period messaging and conditions in Subscribe component 2025-10-20 13:54:24 -04:00
Daniel
463883383b Merge pull request #1455 from linkwarden/dev
v2.13.1
2025-10-15 15:25:59 +03:30
daniel31x13
f13ab8500a bump version 2025-10-15 07:03:11 -04:00
Daniel
3c7bcfe3e4 Merge pull request #1451 from supercoolspy/fix/singlefile-title
Fix/singlefile title
2025-10-15 14:29:38 +03:30
daniel31x13
476a9d78a4 bug fixed 2025-10-15 06:58:14 -04:00
Daniel
fa2d439b3e Merge pull request #1448 from supercoolspy/fix/singlefile-view
fix: support using monolith content for other archive formats
2025-10-15 14:21:03 +03:30
daniel31x13
0907c3caa2 bug fix 2025-10-15 06:49:45 -04:00
Daniel
a4266b1a62 New Crowdin updates (#1441)
* New translations common.json (Spanish)

* New translations common.json (Spanish)

* New translations common.json (Chinese Simplified)

* New translations common.json (Chinese Simplified)

---------

Co-authored-by: LinkwardenBot <bot@linkwarden.app>
2025-10-14 19:41:48 -04:00
daniel31x13
05bebd8703 swap from base64 images to url for emails 2025-10-14 19:11:45 -04:00
daniel31x13
2c727ccd47 improved email templates 2025-10-14 19:03:49 -04:00
daniel31x13
1205cdce1c remove unused import 2025-10-13 17:29:04 -04:00
daniel31x13
6ae4c37d0c minor fix 2025-10-13 17:28:35 -04:00
daniel31x13
4148c0f5fb add trial ended email for cloud 2025-10-13 17:26:09 -04:00
daniel31x13
f7e7fda779 feat(mobile): proper deployment 2025-10-13 10:56:29 -04:00
Spy
fbafa3df4e fix: get title from HTML with monolith 2025-10-11 16:11:13 -07:00
Spy
7d45249e8f fix: support using monolith content for other archives 2025-10-10 22:44:26 -07:00
Daniel
96472243db Merge pull request #1290 from jvanbruegge/fix-http-links
fix INVALID_PROTOCOL when saving http website
2025-10-04 04:08:02 -04:00
Daniel
4ef0e6bd86 Merge pull request #1271 from Tchoupinax/main
fix: add support for password manager for login page
2025-10-04 04:01:39 -04:00
daniel31x13
daf5dc4f22 fix merge conflict 2025-10-04 04:01:14 -04:00
Daniel
43f8da30d3 Merge pull request #1215 from claflico/feat/synology-auth
Add Synology OIDC as login option based upon Authelia settings successful login
2025-10-04 03:56:22 -04:00
daniel31x13
d2e5dbd521 update yarn.lock 2025-10-04 03:54:37 -04:00
Daniel
9c05aaf2df Merge pull request #1214 from tcriess/perplexity
add ai-sdk/perplexity provider
2025-10-04 03:52:06 -04:00
Daniel
4f2e26c31f Merge pull request #1426 from linkwarden/i18n
New Crowdin updates
2025-10-04 03:29:14 -04:00
LinkwardenBot
328e031ebd New translations common.json (Portuguese, Brazilian) 2025-10-03 15:26:53 +00:00
LinkwardenBot
93d4e58306 New translations common.json (Portuguese, Brazilian) 2025-10-03 15:26:53 +00:00
LinkwardenBot
2255fb3a6c New translations common.json (Dutch) 2025-10-03 15:26:53 +00:00
LinkwardenBot
d23a935cae New translations common.json (Ukrainian) 2025-10-03 15:26:53 +00:00
LinkwardenBot
57bde730f8 New translations common.json (Ukrainian) 2025-10-03 15:26:53 +00:00
LinkwardenBot
01e0587012 New translations common.json (Ukrainian) 2025-10-03 15:26:53 +00:00
LinkwardenBot
c9f8b233d5 New translations common.json (Ukrainian) 2025-10-03 15:26:53 +00:00
LinkwardenBot
2685188ba6 New translations common.json (Ukrainian) 2025-10-03 15:26:53 +00:00
LinkwardenBot
942672ea95 New translations common.json (Romanian) 2025-10-03 15:26:53 +00:00
LinkwardenBot
ac57cc0202 New translations common.json (Chinese Traditional) 2025-10-03 15:26:53 +00:00
LinkwardenBot
69b5919a96 New translations common.json (Ukrainian) 2025-10-03 15:26:53 +00:00
LinkwardenBot
09823fe776 New translations common.json (Polish) 2025-10-03 15:26:53 +00:00
LinkwardenBot
a97cb229ff New translations common.json (Dutch) 2025-10-03 15:26:53 +00:00
LinkwardenBot
cbf3756f16 New translations common.json (Japanese) 2025-10-03 15:26:53 +00:00
LinkwardenBot
900f62487b New translations common.json (Italian) 2025-10-03 15:26:53 +00:00
LinkwardenBot
bc6f9a55e4 New translations common.json (German) 2025-10-03 15:26:53 +00:00
LinkwardenBot
e7b7a7f46a New translations common.json (Spanish) 2025-10-03 15:26:53 +00:00
LinkwardenBot
f3b23dadd1 New translations common.json (French) 2025-10-03 15:26:53 +00:00
LinkwardenBot
8de57d3875 New translations common.json (Russian) 2025-10-03 15:26:53 +00:00
LinkwardenBot
bfb6b25c28 New translations common.json (Portuguese, Brazilian) 2025-10-03 15:26:53 +00:00
LinkwardenBot
a24786a513 New translations common.json (Chinese Simplified) 2025-10-03 15:26:52 +00:00
LinkwardenBot
6b9f181585 New translations common.json (Turkish) 2025-10-03 15:26:52 +00:00
Daniel
b21e2c6ffd Merge pull request #1425 from linkwarden/dev
Dev
2025-09-26 14:30:02 -04:00
daniel31x13
96dcbcfb79 Merge branch 'dev' of https://github.com/linkwarden/linkwarden into dev 2025-09-26 14:29:39 -04:00
daniel31x13
9c45f933cd typo 2025-09-26 14:29:38 -04:00
Daniel
427c062075 Merge pull request #1424 from linkwarden/dev
Dev
2025-09-26 14:29:11 -04:00
Daniel
2130422c2a Merge pull request #1418 from linkwarden/i18n
New Crowdin updates
2025-09-26 14:28:41 -04:00
daniel31x13
8133900e76 added subscription banner 2025-09-26 14:27:39 -04:00
Thorsten Riess
9ad4a3ee87 add ai-sdk/perplexity provider 2025-09-26 06:46:47 +02:00
LinkwardenBot
8c6169320a New translations common.json (Romanian) 2025-09-25 13:45:44 +00:00
LinkwardenBot
ea0916d826 New translations common.json (Chinese Traditional) 2025-09-25 13:45:44 +00:00
LinkwardenBot
14550a89e6 New translations common.json (Ukrainian) 2025-09-25 13:45:44 +00:00
LinkwardenBot
c1c5b3e953 New translations common.json (Polish) 2025-09-25 13:45:44 +00:00
LinkwardenBot
bebe9671cb New translations common.json (Dutch) 2025-09-25 13:45:44 +00:00
LinkwardenBot
bcf12e25a1 New translations common.json (Japanese) 2025-09-25 13:45:44 +00:00
LinkwardenBot
4d9725f66c New translations common.json (Italian) 2025-09-25 13:45:44 +00:00
LinkwardenBot
aa045801cc New translations common.json (German) 2025-09-25 13:45:44 +00:00
LinkwardenBot
8dd54a1c26 New translations common.json (Spanish) 2025-09-25 13:45:44 +00:00
LinkwardenBot
ce76eb0b74 New translations common.json (French) 2025-09-25 13:45:44 +00:00
LinkwardenBot
453dfbcfb8 New translations common.json (Russian) 2025-09-25 13:45:43 +00:00
LinkwardenBot
0ca4f72e53 New translations common.json (Portuguese, Brazilian) 2025-09-25 13:45:43 +00:00
LinkwardenBot
645e8dc4b2 New translations common.json (Chinese Simplified) 2025-09-25 13:45:43 +00:00
LinkwardenBot
bab44a942a New translations common.json (Turkish) 2025-09-25 13:45:43 +00:00
Jan van Brügge
327826d760 fix INVALID_PROTOCOL when saving http website
Using a https agent to fetch a http site causes this error:
TypeError [ERR_INVALID_PROTOCOL]: Protocol "http:" not supported. Expected "https:"
2025-07-14 01:34:58 +01:00
Tchoupinax
2441470849 fix: add support for password manager for login page 2025-06-29 11:20:18 +02:00
Cory Claflin
be532d5455 Add Synology OIDC as login option based upon Authelia settings successful login 2025-06-06 22:22:35 -05:00
185 changed files with 28747 additions and 18868 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

View File

@@ -68,6 +68,10 @@ ANTHROPIC_MODEL=
OPENROUTER_API_KEY=
OPENROUTER_MODEL=
# https://ai-sdk.dev/providers/ai-sdk-providers/perplexity
PERPLEXITY_API_KEY=
PERPLEXITY_MODEL=
# MeiliSearch Settings
MEILI_HOST=
MEILI_MASTER_KEY=
@@ -127,10 +131,10 @@ AUTH0_CLIENT_SECRET=
AUTH0_CLIENT_ID=
# Authelia
NEXT_PUBLIC_AUTHELIA_ENABLED=""
AUTHELIA_CLIENT_ID=""
AUTHELIA_CLIENT_SECRET=""
AUTHELIA_WELLKNOWN_URL=""
NEXT_PUBLIC_AUTHELIA_ENABLED=
AUTHELIA_CLIENT_ID=
AUTHELIA_CLIENT_SECRET=
AUTHELIA_WELLKNOWN_URL=
# Authentik
NEXT_PUBLIC_AUTHENTIK_ENABLED=
@@ -399,6 +403,13 @@ STRAVA_CUSTOM_NAME=
STRAVA_CLIENT_ID=
STRAVA_CLIENT_SECRET=
# Synology
NEXT_PUBLIC_SYNOLOGY_ENABLED=
SYNOLOGY_CUSTOM_NAME=
SYNOLOGY_CLIENT_ID=
SYNOLOGY_CLIENT_SECRET=
SYNOLOGY_WELLKNOWN_URL=
# Todoist
NEXT_PUBLIC_TODOIST_ENABLED=
TODOIST_CUSTOM_NAME=

14
.github/FUNDING.yml vendored
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,11 +61,17 @@ jobs:
steps:
- uses: actions/checkout@v4
- name: Use Node.js
- name: Use Node.js and Enable Yarn 4
uses: actions/setup-node@v4
with:
node-version: "18"
cache: 'yarn'
node-version: "20"
- name: Enable Yarn 4
run: |
sudo rm -f /usr/bin/yarn /usr/bin/yarnpkg || true
corepack enable
corepack prepare yarn@4.12.0 --activate
yarn --version
- name: Initialize PostgreSQL
run: |
@@ -74,7 +80,7 @@ jobs:
psql -h localhost -U postgres -d postgres -c "CREATE DATABASE ${{ env.TEST_POSTGRES_DATABASE }} OWNER ${{ env.TEST_POSTGRES_USER }};"
- name: Install packages
run: yarn install -y
run: yarn install --immutable
- name: Cache playwright dependencies
uses: awalsh128/cache-apt-pkgs-action@latest

7
.gitignore vendored
View File

@@ -2,6 +2,7 @@
node_modules
/.pnp
.pnp.js
.yarn/install-state.gz
# testing
/coverage
@@ -48,7 +49,11 @@ certificates
# generated files and folders
/data
/data.ms
meilisearch
meili_data
.idea
prisma/dev.db
data.ms
.turbo
service-account-file.json

1
.yarnrc.yml Normal file
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

@@ -38,32 +38,49 @@ Linkwarden is also designed with collaboration in mind, enabling you to share li
## Features
- 📸 Auto capture a screenshot, PDF, and single html file of each webpage.
- 📖 Reader view of the webpage, with the ability to highlight and annotate text.
- 🏛️ Send your webpage to Wayback Machine ([archive.org](https://archive.org)) for a snapshot. (Optional)
- ✨ Local AI Tagging to automatically tag your links based on their content (Optional).
- 📂 Organize links by collection, sub-collection, name, description and multiple tags.
- 👥 Collaborate on gathering links in a collection.
- 🎛️ Customize the permissions of each member.
- 🌐 Share your collected links and preserved formats with the world.
- 📌 Pin your favorite links to dashboard.
- 🔍 Full text search, filter and sort for easy retrieval.
- 📱 Responsive design and supports most modern browsers.
- 🌓 Dark/Light mode support.
- 🧩 Browser extension. [Star it here!](https://github.com/linkwarden/browser-extension)
- 📸 Auto capture a screenshot, PDF, and single html file of each webpage
- 📖 Reader view of the webpage, with the ability to highlight and annotate text
- 🏛️ Send your webpage to Wayback Machine ([archive.org](https://archive.org)) for a snapshot (optional)
- ✨ Local AI Tagging to automatically tag your links based on their content (optional)
- 📂 Organize links by collection, sub-collection, name, description and multiple tags
- 👥 Collaborate on gathering links in a collection
- 🎛️ Customize the permissions of each member
- 🌐 Share your collected links and preserved formats with the world
- 📱 Native iOS and android mobile apps
- 🔍 Full text search, filter and sort for easy retrieval
- 🌓 Dark/Light mode support
- 🧩 Browser extension (star it [here](https://github.com/linkwarden/browser-extension)!)
- 🔄 Browser Synchronization (using [Floccus](https://floccus.org)!)
- Import and export your bookmarks.
- 🔐 SSO integration. (Enterprise and Self-hosted users only)
- 📦 Installable Progressive Web App (PWA).
- 🍎 iOS Shortcut to save Links to Linkwarden.
- 🔑 API keys.
- ✅ Bulk actions.
- 👥 User administration.
- 🌐 Support for Other Languages (i18n).
- 📁 Image and PDF Uploads.
- 🎨 Custom Icons for Links and Collections.
- 🔔 RSS Feed Subscription.
- ✨ And many more features. (Literally!)
- Upload from SingleFile (check out the [guide](https://docs.linkwarden.app/Usage/upload-from-singlefile))
- 🔐 SSO integration (Enterprise and Self-hosted users only)
- 🍎 iOS Shortcut to save links to Linkwarden
- 🔑 API keys
- ✅ Bulk actions
- 👥 User administration
- 🌐 Support for other languages (i18n)
- 📁 Image and PDF uploads
- 🎨 Custom icons for links and collections
- 🔔 RSS feed subscription
- ✨ And many more features (literally!)
## Get Our Official Mobile App
<img src="./assets/mobile_apps.png" alt="Different screens (iPad, Pixel, and iPhone)" width="400" />
> [!IMPORTANT]
> To use the app 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 ⭐

42
apps/mobile/.easignore Normal file
View File

@@ -0,0 +1,42 @@
# Learn more https://docs.github.com/en/get-started/getting-started-with-git/ignoring-files
# dependencies
node_modules/
# Expo
.expo/
dist/
web-build/
expo-env.d.ts
# Native
*.orig.*
*.jks
*.p8
*.p12
*.key
*.mobileprovision
# Metro
.metro-health-check*
# debug
npm-debug.*
yarn-debug.*
yarn-error.*
# macOS
.DS_Store
*.pem
# local env files
.env*.local
# typescript
*.tsbuildinfo
app-example
.env
ios/
android/

View File

@@ -40,3 +40,7 @@ app-example
ios/
android/
service-account-file.json
.env.local

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.0.1",
"orientation": "portrait",
"icon": "./assets/images/icon.png",
"scheme": "linkwarden",
@@ -10,11 +10,20 @@
"newArchEnabled": true,
"ios": {
"supportsTablet": true,
"bundleIdentifier": "app.linkwarden"
"bundleIdentifier": "app.linkwarden",
"entitlements": {
"com.apple.security.application-groups": ["group.app.linkwarden"]
},
"infoPlist": {
"ITSAppUsesNonExemptEncryption": false,
"NSAppTransportSecurity": {
"NSAllowsArbitraryLoads": true
}
}
},
"android": {
"adaptiveIcon": {
"foregroundImage": "./assets/images/adaptive-icon.png",
"foregroundImage": "./assets/images/maskable_logo.jpeg",
"backgroundColor": "#ffffff"
},
"package": "app.linkwarden"
@@ -41,7 +50,35 @@
}
],
"expo-secure-store",
"expo-share-intent",
[
"expo-share-intent",
{
"iosAppGroupIdentifier": "group.app.linkwarden"
}
],
[
"expo-build-properties",
{
"android": {
"enableProguardInReleaseBuilds": true,
"extraProguardRules": "-keep public class com.horcrux.svg.** {*;}",
"allowBackup": false,
"compileSdkVersion": 35,
"targetSdkVersion": 35,
"buildToolsVersion": "35.0.0",
"usesCleartextTraffic": true
}
}
],
[
"react-native-edge-to-edge",
{
"android": {
"parentTheme": "Default",
"enforceNavigationBarContrast": false
}
}
],
"./plugins/with-daynight-transparent-nav"
],
"experiments": {
@@ -55,6 +92,15 @@
"androidNavigationBar": {
"backgroundColor": "#ffffff",
"barStyle": "dark-content"
}
},
"extra": {
"router": {
"origin": false
},
"eas": {
"projectId": "34f82639-7a25-4ebe-81c8-2db521b612cf"
}
},
"owner": "linkwarden"
}
}

View File

@@ -5,7 +5,7 @@ import HapticTab from "@/components/HapticTab";
import TabBarBackground from "@/components/ui/TabBarBackground";
import { useColorScheme } from "nativewind";
import { rawTheme, ThemeName } from "@/lib/colors";
import { House, Link, Settings } from "lucide-react-native";
import { Folder, Hash, House, Link, Settings } from "lucide-react-native";
export default function TabLayout() {
const { colorScheme } = useColorScheme();
@@ -23,11 +23,15 @@ export default function TabLayout() {
borderTopWidth: 0,
elevation: 0,
backgroundColor: rawTheme[colorScheme as ThemeName]["base-200"],
paddingLeft: 5,
paddingRight: 5,
},
default: {
borderTopWidth: 0,
backgroundColor: rawTheme[colorScheme as ThemeName]["base-200"],
elevation: 0,
paddingLeft: 5,
paddingRight: 5,
},
}),
}}
@@ -37,7 +41,7 @@ export default function TabLayout() {
options={{
title: "Dashboard",
headerShown: false,
tabBarIcon: ({ color }) => <House size={26} color={color} />,
tabBarIcon: ({ color }) => <House size={24} color={color} />,
}}
/>
<Tabs.Screen
@@ -45,7 +49,23 @@ export default function TabLayout() {
options={{
title: "Links",
headerShown: false,
tabBarIcon: ({ color }) => <Link size={26} color={color} />,
tabBarIcon: ({ color }) => <Link size={24} color={color} />,
}}
/>
<Tabs.Screen
name="collections"
options={{
title: "Collections",
headerShown: false,
tabBarIcon: ({ color }) => <Folder size={24} color={color} />,
}}
/>
<Tabs.Screen
name="tags"
options={{
title: "Tags",
headerShown: false,
tabBarIcon: ({ color }) => <Hash size={24} color={color} />,
}}
/>
<Tabs.Screen
@@ -53,7 +73,7 @@ export default function TabLayout() {
options={{
title: "Settings",
headerShown: false,
tabBarIcon: ({ color }) => <Settings size={26} color={color} />,
tabBarIcon: ({ color }) => <Settings size={24} color={color} />,
}}
/>
</Tabs>

View File

@@ -0,0 +1,62 @@
import { useLinks } from "@linkwarden/router/links";
import { View, StyleSheet, Platform } from "react-native";
import useAuthStore from "@/store/auth";
import { useLocalSearchParams, useNavigation } from "expo-router";
import React, { useEffect } from "react";
import { useCollections } from "@linkwarden/router/collections";
import Links from "@/components/Links";
export default function LinksScreen() {
const { auth } = useAuthStore();
const { search, id } = useLocalSearchParams<{
search?: string;
id: string;
}>();
const { links, data } = useLinks(
{
sort: 0,
searchQueryString: decodeURIComponent(search ?? ""),
collectionId: Number(id),
},
auth
);
const collections = useCollections(auth);
const navigation = useNavigation();
useEffect(() => {
const activeCollection = collections.data?.filter(
(e) => e.id === Number(id)
)[0];
if (activeCollection?.name)
navigation?.setOptions?.({
headerTitle: activeCollection?.name,
headerSearchBarOptions: {
placeholder: `Search ${activeCollection.name}`,
},
});
}, [navigation]);
return (
<View
style={styles.container}
className="h-full bg-base-100"
collapsable={false}
collapsableChildren={false}
>
<Links links={links} data={data} />
</View>
);
}
const styles = StyleSheet.create({
container: Platform.select({
ios: {
paddingBottom: 83,
},
default: {},
}),
});

View File

@@ -0,0 +1,44 @@
import { Stack, useRouter } from "expo-router";
import { useColorScheme } from "nativewind";
import { rawTheme, ThemeName } from "@/lib/colors";
import { Platform } from "react-native";
export default function Layout() {
const router = useRouter();
const { colorScheme } = useColorScheme();
return (
<Stack
screenOptions={{
headerTitle: "Collections",
headerLargeTitle: true,
headerTransparent: Platform.OS === "ios" ? true : false,
headerShadowVisible: false,
headerBlurEffect:
colorScheme === "dark" ? "systemMaterialDark" : "systemMaterial",
headerTintColor: colorScheme === "dark" ? "white" : "black",
headerSearchBarOptions: {
placeholder: "Search Collections",
autoCapitalize: "none",
onChangeText: (e) => {
router.setParams({
search: encodeURIComponent(e.nativeEvent.text),
});
},
headerIconColor: colorScheme === "dark" ? "white" : "black",
},
headerLargeStyle: {
backgroundColor: rawTheme[colorScheme as ThemeName]["base-100"],
},
headerStyle: {
backgroundColor:
Platform.OS === "ios"
? "transparent"
: colorScheme === "dark"
? rawTheme["dark"]["base-100"]
: "white",
},
}}
/>
);
}

View File

@@ -0,0 +1,94 @@
import {
View,
StyleSheet,
FlatList,
Platform,
Text,
ActivityIndicator,
} from "react-native";
import useAuthStore from "@/store/auth";
import CollectionListing from "@/components/CollectionListing";
import { useLocalSearchParams } from "expo-router";
import React, { useEffect, useState } from "react";
import Spinner from "@/components/ui/Spinner";
import { rawTheme, ThemeName } from "@/lib/colors";
import { useColorScheme } from "nativewind";
import { useCollections } from "@linkwarden/router/collections";
import { CollectionIncludingMembersAndLinkCount } from "@linkwarden/types";
export default function CollectionsScreen() {
const { colorScheme } = useColorScheme();
const { auth } = useAuthStore();
const { search } = useLocalSearchParams<{ search?: string }>();
const collections = useCollections(auth);
const [filteredCollections, setFilteredCollections] = useState<
CollectionIncludingMembersAndLinkCount[]
>([]);
useEffect(() => {
const filter =
collections.data?.filter((e) =>
e.name.includes(decodeURIComponent(search || ""))
) || [];
setFilteredCollections(filter);
}, [search, collections.data]);
return (
<View
style={styles.container}
className="h-full bg-base-100"
collapsable={false}
collapsableChildren={false}
>
{collections.isLoading ? (
<View className="flex justify-center h-screen items-center">
<ActivityIndicator size="large" />
<Text className="text-base mt-2.5 text-neutral">Loading...</Text>
</View>
) : (
<FlatList
contentInsetAdjustmentBehavior="automatic"
ListHeaderComponent={() => <></>}
data={filteredCollections}
refreshControl={
<Spinner
refreshing={collections.isRefetching}
onRefresh={() => collections.refetch()}
progressBackgroundColor={
rawTheme[colorScheme as ThemeName]["base-200"]
}
colors={[rawTheme[colorScheme as ThemeName]["base-content"]]}
/>
}
refreshing={collections.isRefetching}
initialNumToRender={4}
keyExtractor={(item) => item.id?.toString() || ""}
renderItem={({ item }) => <CollectionListing collection={item} />}
onEndReachedThreshold={0.5}
ItemSeparatorComponent={() => (
<View className="bg-neutral-content h-px" />
)}
ListEmptyComponent={
<View className="flex justify-center py-10 items-center">
<Text className="text-center text-xl text-neutral">
Nothing found...
</Text>
</View>
}
/>
)}
</View>
);
}
const styles = StyleSheet.create({
container: Platform.select({
ios: {
paddingBottom: 83,
},
default: {},
}),
});

View File

@@ -1,20 +1,10 @@
import { useLinks } from "@linkwarden/router/links";
import { View, StyleSheet, FlatList, Platform } from "react-native";
import { View, StyleSheet, Platform } from "react-native";
import useAuthStore from "@/store/auth";
import LinkListing from "@/components/LinkListing";
import { useLocalSearchParams, useNavigation } from "expo-router";
import React, { useEffect, useState } from "react";
import { LinkIncludingShortenedCollectionAndTags } from "@linkwarden/types";
import React, { useEffect, useMemo } from "react";
import { useCollections } from "@linkwarden/router/collections";
import Spinner from "@/components/ui/Spinner";
import { rawTheme, ThemeName } from "@/lib/colors";
import { useColorScheme } from "nativewind";
const RenderItem = React.memo(
({ item }: { item: LinkIncludingShortenedCollectionAndTags }) => {
return <LinkListing link={item} />;
}
);
import Links from "@/components/Links";
export default function LinksScreen() {
const { auth } = useAuthStore();
@@ -23,24 +13,32 @@ export default function LinksScreen() {
section?: "pinned-links" | "recent-links" | "collection";
collectionId?: string;
}>();
const { colorScheme } = useColorScheme();
const navigation = useNavigation();
const collections = useCollections(auth);
const title = useMemo(() => {
if (section === "pinned-links") return "Pinned Links";
if (section === "recent-links") return "Recent Links";
if (section === "collection") {
return (
collections.data?.find((c) => c.id?.toString() === collectionId)
?.name || "Collection"
);
}
return "Links";
}, [section, collections.data, collectionId]);
useEffect(() => {
navigation.setOptions({
headerTitle:
section === "pinned-links"
? "Pinned Links"
: section === "recent-links"
? "Recent Links"
: section === "collection"
? collections.data?.find((c) => c.id?.toString() === collectionId)
?.name || "Collection"
: "Links",
headerTitle: title,
headerSearchBarOptions: {
placeholder: `Search ${title}`,
},
});
}, [section, navigation]);
}, [title, navigation]);
const { links, data } = useLinks(
{
@@ -59,29 +57,7 @@ export default function LinksScreen() {
collapsable={false}
collapsableChildren={false}
>
<FlatList
contentInsetAdjustmentBehavior="automatic"
ListHeaderComponent={() => <></>}
data={links || []}
refreshControl={
<Spinner
refreshing={data.isRefetching}
onRefresh={() => data.refetch()}
progressBackgroundColor={
rawTheme[colorScheme as ThemeName]["base-200"]
}
colors={[rawTheme[colorScheme as ThemeName]["base-content"]]}
/>
}
initialNumToRender={4}
keyExtractor={(item) => item.id?.toString() || ""}
renderItem={({ item }) => (
<RenderItem item={item} key={item.id?.toString()} />
)}
onEndReached={() => data.fetchNextPage()}
onEndReachedThreshold={0.5}
ItemSeparatorComponent={() => <View className="bg-base-200 h-px" />}
/>
<Links links={links} data={data} />
</View>
);
}

View File

@@ -6,7 +6,7 @@ import { Platform, TouchableOpacity } from "react-native";
import { SheetManager } from "react-native-actions-sheet";
import * as DropdownMenu from "zeego/dropdown-menu";
export default function RootLayout() {
export default function Layout() {
const router = useRouter();
const { colorScheme } = useColorScheme();
@@ -27,8 +27,8 @@ export default function RootLayout() {
Platform.OS === "ios"
? "transparent"
: colorScheme === "dark"
? rawTheme["dark"]["base-100"]
: "white",
? rawTheme["dark"]["base-100"]
: "white",
},
}}
>
@@ -54,9 +54,12 @@ export default function RootLayout() {
>
<DropdownMenu.ItemTitle>New Link</DropdownMenu.ItemTitle>
</DropdownMenu.Item>
<DropdownMenu.Item key="more-options" disabled>
<DropdownMenu.Item
key="new-collection"
onSelect={() => SheetManager.show("new-collection-sheet")}
>
<DropdownMenu.ItemTitle>
More Coming Soon!
New Collection
</DropdownMenu.ItemTitle>
</DropdownMenu.Item>
</DropdownMenu.Content>

View File

@@ -1,36 +1,22 @@
import {
FlatList,
ActivityIndicator,
Platform,
ScrollView,
StyleSheet,
Text,
TouchableOpacity,
View,
} from "react-native";
import { SafeAreaView } from "react-native-safe-area-context";
import React, { useEffect, useMemo, useState } from "react";
import { useDashboardData } from "@linkwarden/router/dashboardData";
import useAuthStore from "@/store/auth";
import React, { useEffect, useMemo, useState } from "react";
import { DashboardSection, DashboardSectionType } from "@prisma/client";
import { DashboardSection as DashboardSectionType } from "@linkwarden/prisma/client";
import { useUser } from "@linkwarden/router/user";
import { useCollections } from "@linkwarden/router/collections";
import { useTags } from "@linkwarden/router/tags";
import clsx from "clsx";
import DashboardItem from "@/components/DashboardItem";
import { LinkIncludingShortenedCollectionAndTags } from "@linkwarden/types";
import LinkListing from "@/components/LinkListing";
import { useRouter } from "expo-router";
import { rawTheme, ThemeName } from "@/lib/colors";
import { useColorScheme } from "nativewind";
import {
Clock8,
ChevronRight,
Pin,
Folder,
Hash,
Link,
} from "lucide-react-native";
import Spinner from "@/components/ui/Spinner";
import DashboardSection from "@/components/DashboardSection";
export default function DashboardScreen() {
const { auth } = useAuthStore();
@@ -41,15 +27,13 @@ export default function DashboardScreen() {
...dashboardData
} = useDashboardData(auth);
const { data: user, ...userData } = useUser(auth);
const { data: collections = [] } = useCollections(auth);
const { data: tags = [] } = useTags(auth);
const { data: collections = [], ...collectionsData } = useCollections(auth);
const { data: tags = [], ...tagsData } = useTags(auth);
const { colorScheme } = useColorScheme();
const router = useRouter();
const [dashboardSections, setDashboardSections] = useState<
DashboardSection[]
DashboardSectionType[]
>(user?.dashboardSections || []);
const [numberOfLinks, setNumberOfLinks] = useState(0);
@@ -76,320 +60,73 @@ export default function DashboardScreen() {
});
}, [dashboardSections]);
interface SectionProps {
sectionData: { type: DashboardSectionType };
collection?: any;
links?: any[];
tagsLength: number;
numberOfLinks: number;
collectionsLength: number;
numberOfPinnedLinks: number;
dashboardData: { isLoading: boolean };
collectionLinks?: any[];
}
const [pullRefreshing, setPullRefreshing] = useState(false);
const Section: React.FC<SectionProps> = ({
sectionData,
collection,
links = [],
tagsLength,
numberOfLinks,
collectionsLength,
numberOfPinnedLinks,
dashboardData,
collectionLinks = [],
}) => {
switch (sectionData.type) {
case DashboardSectionType.STATS:
return (
<View className="flex-col gap-4 max-w-full px-5">
<View className="flex-row gap-4">
<DashboardItem
name={numberOfLinks === 1 ? "Link" : "Links"}
value={numberOfLinks}
icon={<Link size={23} color="white" />}
color="#9c00cc"
/>
<DashboardItem
name={collectionsLength === 1 ? "Collection" : "Collections"}
value={collectionsLength}
icon={<Folder size={23} color="white" fill="white" />}
color="#0096cc"
/>
</View>
<View className="flex-row gap-4">
<DashboardItem
name={tagsLength === 1 ? "Tag" : "Tags"}
value={tagsLength}
icon={<Hash size={23} color="white" />}
color="#00cc99"
/>
<DashboardItem
name={"Pinned Links"}
value={numberOfPinnedLinks}
icon={<Pin size={23} color="white" fill="white" />}
color="#cc6d00"
/>
</View>
</View>
);
case DashboardSectionType.RECENT_LINKS:
return (
<>
<View className="flex-row justify-between items-center px-5">
<View className="flex-row gap-2 items-center">
<View className={"flex-row items-center gap-2"}>
<Clock8
size={30}
color={rawTheme[colorScheme as ThemeName].primary}
/>
<Text className="text-2xl capitalize text-base-content">
Recent Links
</Text>
</View>
</View>
<TouchableOpacity
className="flex-row items-center text-sm gap-1"
onPress={() =>
router.navigate("/(tabs)/dashboard/recent-links")
}
>
<Text className="text-primary">View All</Text>
<ChevronRight
size={15}
color={rawTheme[colorScheme as ThemeName].primary}
/>
</TouchableOpacity>
</View>
{dashboardData.isLoading ||
(links.length > 0 && !dashboardData.isLoading) ? (
<FlatList
horizontal
showsHorizontalScrollIndicator={false}
directionalLockEnabled
data={links || []}
refreshing={dashboardData.isLoading}
initialNumToRender={2}
keyExtractor={(item) => item.id?.toString() || ""}
renderItem={({ item }) => (
<RenderItem item={item} key={item.id?.toString()} />
)}
ItemSeparatorComponent={() => <View style={{ width: 10 }} />}
contentContainerStyle={{
paddingHorizontal: 20,
}}
/>
) : (
<View className="flex-col gap-2 justify-center items-center h-40 p-10 rounded-xl bg-base-200 mx-5">
<Clock8
size={40}
color={rawTheme[colorScheme as ThemeName].primary}
/>
<Text className="text-center text-xl text-neutral">
No Recent Links
</Text>
{/* <View className="text-center w-full mt-4 flex-row flex-wrap gap-4 justify-center">
<Button onPress={() => setNewLinkModal(true)} variant="accent">
<Icon name="bi-plus-lg" className="text-xl" />
<Text>{t("add_link")}</Text>
</Button>
<ImportDropdown />
</View> */}
</View>
)}
</>
);
case DashboardSectionType.PINNED_LINKS:
return (
<>
<View className="flex-row justify-between items-center px-5">
<View className="flex-row gap-2 items-center">
<View className={"flex-row items-center gap-2"}>
<Pin
size={30}
color={rawTheme[colorScheme as ThemeName].primary}
/>
<Text className="text-2xl capitalize text-base-content">
Pinned Links
</Text>
</View>
</View>
<TouchableOpacity
className="flex-row items-center text-sm gap-1"
onPress={() =>
router.navigate("/(tabs)/dashboard/pinned-links")
}
>
<Text className="text-primary">View All</Text>
<ChevronRight
size={15}
color={rawTheme[colorScheme as ThemeName].primary}
/>
</TouchableOpacity>
</View>
{dashboardData.isLoading ||
links?.some((e: any) => e.pinnedBy && e.pinnedBy[0]) ? (
<FlatList
horizontal
showsHorizontalScrollIndicator={false}
data={
links.filter((e: any) => e.pinnedBy && e.pinnedBy[0]) || []
}
refreshing={dashboardData.isLoading}
initialNumToRender={2}
keyExtractor={(item) => item.id?.toString() || ""}
renderItem={({ item }) => (
<RenderItem item={item} key={item.id?.toString()} />
)}
ItemSeparatorComponent={() => <View style={{ width: 10 }} />}
contentContainerStyle={{
paddingHorizontal: 20,
}}
/>
) : (
<View className="flex-col gap-2 justify-center items-center h-40 p-10 rounded-xl bg-base-200 mx-5">
<Pin
size={40}
color={rawTheme[colorScheme as ThemeName].primary}
/>
<Text className="text-center text-xl text-neutral">
No Pinned Links
</Text>
</View>
)}
</>
);
case DashboardSectionType.COLLECTION:
return collection?.id ? (
<>
<View className="flex-row justify-between items-center px-5">
<View className="flex-row gap-2 items-center max-w-[60%]">
<View className={clsx("flex-row items-center gap-2")}>
<Folder
size={30}
fill={collection.color || "#0ea5e9"}
color={collection.color || "#0ea5e9"}
/>
<Text
className="text-2xl capitalize w-full text-base-content"
numberOfLines={1}
>
{collection.name}
</Text>
</View>
</View>
<TouchableOpacity
className="flex-row items-center text-sm gap-1 whitespace-nowrap"
onPress={() =>
router.navigate(
`/(tabs)/dashboard/collection?collectionId=${collection.id}`
)
}
>
<Text className="text-primary">View All</Text>
<ChevronRight
size={15}
color={rawTheme[colorScheme as ThemeName].primary}
/>
</TouchableOpacity>
</View>
{dashboardData.isLoading || collectionLinks.length > 0 ? (
<FlatList
horizontal
showsHorizontalScrollIndicator={false}
data={collectionLinks || []}
refreshing={dashboardData.isLoading}
initialNumToRender={2}
keyExtractor={(item) => item.id?.toString() || ""}
renderItem={({ item }) => (
<RenderItem item={item} key={item.id?.toString()} />
)}
ItemSeparatorComponent={() => <View style={{ width: 10 }} />}
contentContainerStyle={{
paddingHorizontal: 20,
}}
/>
) : (
<View className="flex-col gap-2 justify-center items-center h-40 p-10 rounded-xl bg-base-200 mx-5">
<Text className="text-center text-xl text-neutral">
Empty Collection
</Text>
</View>
)}
</>
) : null;
default:
return null;
const onRefresh = async () => {
setPullRefreshing(true);
try {
await Promise.all([
dashboardData.refetch(),
userData.refetch(),
collectionsData.refetch(),
tagsData.refetch(),
]);
} finally {
setPullRefreshing(false);
}
};
const RenderItem = React.memo(
({ item }: { item: LinkIncludingShortenedCollectionAndTags }) => {
return <LinkListing link={item} dashboard />;
}
);
if (orderedSections.length === 0 && dashboardData.isLoading)
return (
<View className="flex justify-center h-screen items-center bg-base-100">
<ActivityIndicator size="large" />
<Text className="text-base mt-2.5 text-neutral">Loading...</Text>
</View>
);
return (
<SafeAreaView
style={styles.container}
collapsable={false}
collapsableChildren={false}
className="bg-base-100 h-full"
<ScrollView
refreshControl={
<Spinner
refreshing={pullRefreshing}
onRefresh={onRefresh}
progressBackgroundColor={
rawTheme[colorScheme as ThemeName]["base-200"]
}
colors={[rawTheme[colorScheme as ThemeName]["base-content"]]}
/>
}
contentContainerStyle={styles.container}
className="bg-base-100"
contentInsetAdjustmentBehavior="automatic"
>
<ScrollView
refreshControl={
<Spinner
refreshing={dashboardData.isLoading || userData.isLoading}
onRefresh={() => {
dashboardData.refetch();
userData.refetch();
}}
progressBackgroundColor={
rawTheme[colorScheme as ThemeName]["base-200"]
{orderedSections.map((sectionData, i) => {
if (!collections || !collections[0]) return null;
const collection = collections.find(
(c) => c.id === sectionData.collectionId
);
return (
<DashboardSection
key={sectionData.id}
sectionData={sectionData}
collection={collection}
collectionLinks={
sectionData.collectionId
? collectionLinks[sectionData.collectionId]
: []
}
colors={[rawTheme[colorScheme as ThemeName]["base-content"]]}
links={links}
tagsLength={tags.length}
numberOfLinks={numberOfLinks}
collectionsLength={collections.length}
numberOfPinnedLinks={numberOfPinnedLinks}
dashboardData={dashboardData}
/>
}
contentContainerStyle={{
flexDirection: "column",
gap: 15,
paddingVertical: 20,
}}
className="bg-base-100"
contentInsetAdjustmentBehavior="automatic"
>
{orderedSections.map((sectionData) => {
return (
<Section
key={sectionData.id}
sectionData={sectionData}
collection={collections.find(
(c) => c.id === sectionData.collectionId
)}
collectionLinks={
sectionData.collectionId
? collectionLinks[sectionData.collectionId]
: []
}
links={links}
tagsLength={tags.length}
numberOfLinks={numberOfLinks}
collectionsLength={collections.length}
numberOfPinnedLinks={numberOfPinnedLinks}
dashboardData={dashboardData}
/>
);
})}
</ScrollView>
</SafeAreaView>
);
})}
</ScrollView>
);
}
@@ -397,7 +134,14 @@ const styles = StyleSheet.create({
container: Platform.select({
ios: {
paddingBottom: 49,
flexDirection: "column",
gap: 15,
paddingVertical: 20,
},
default: {
flexDirection: "column",
gap: 15,
paddingVertical: 20,
},
default: {},
}),
});

View File

@@ -3,7 +3,7 @@ import { useColorScheme } from "nativewind";
import { rawTheme, ThemeName } from "@/lib/colors";
import { Platform } from "react-native";
export default function RootLayout() {
export default function Layout() {
const router = useRouter();
const { colorScheme } = useColorScheme();
@@ -18,7 +18,7 @@ export default function RootLayout() {
colorScheme === "dark" ? "systemMaterialDark" : "systemMaterial",
headerTintColor: colorScheme === "dark" ? "white" : "black",
headerSearchBarOptions: {
placeholder: "Search",
placeholder: "Search Links",
autoCapitalize: "none",
onChangeText: (e) => {
router.setParams({

View File

@@ -1,22 +1,11 @@
import { useLinks } from "@linkwarden/router/links";
import { View, StyleSheet, FlatList, Platform } from "react-native";
import { View, StyleSheet, Platform } from "react-native";
import useAuthStore from "@/store/auth";
import LinkListing from "@/components/LinkListing";
import { useLocalSearchParams } from "expo-router";
import React from "react";
import { LinkIncludingShortenedCollectionAndTags } from "@linkwarden/types";
import Spinner from "@/components/ui/Spinner";
import { rawTheme, ThemeName } from "@/lib/colors";
import { useColorScheme } from "nativewind";
const RenderItem = React.memo(
({ item }: { item: LinkIncludingShortenedCollectionAndTags }) => {
return <LinkListing link={item} />;
}
);
import Links from "@/components/Links";
export default function LinksScreen() {
const { colorScheme } = useColorScheme();
const { auth } = useAuthStore();
const { search } = useLocalSearchParams<{ search?: string }>();
@@ -35,32 +24,7 @@ export default function LinksScreen() {
collapsable={false}
collapsableChildren={false}
>
<FlatList
contentInsetAdjustmentBehavior="automatic"
ListHeaderComponent={() => <></>}
data={links || []}
refreshControl={
<Spinner
refreshing={data.isRefetching}
onRefresh={() => data.refetch()}
progressBackgroundColor={
rawTheme[colorScheme as ThemeName]["base-200"]
}
colors={[rawTheme[colorScheme as ThemeName]["base-content"]]}
/>
}
refreshing={data.isRefetching}
initialNumToRender={4}
keyExtractor={(item) => item.id?.toString() || ""}
renderItem={({ item }) => (
<RenderItem item={item} key={item.id?.toString()} />
)}
onEndReached={() => data.fetchNextPage()}
onEndReachedThreshold={0.5}
ItemSeparatorComponent={() => (
<View className="bg-neutral-content h-px" />
)}
/>
<Links links={links} data={data} />
</View>
);
}

View File

@@ -3,7 +3,7 @@ import { useColorScheme } from "nativewind";
import { rawTheme, ThemeName } from "@/lib/colors";
import { Platform } from "react-native";
export default function RootLayout() {
export default function Layout() {
const { colorScheme } = useColorScheme();
return (

View File

@@ -14,9 +14,9 @@ import { useColorScheme } from "nativewind";
import { rawTheme, ThemeName } from "@/lib/colors";
import { useEffect, useState } from "react";
import {
AppWindowMac,
Check,
FileText,
Globe,
ExternalLink,
LogOut,
Mail,
Moon,
@@ -24,7 +24,6 @@ import {
Sun,
} from "lucide-react-native";
import useDataStore from "@/store/data";
import { ArchivedFormat } from "@/types/global";
import * as Clipboard from "expo-clipboard";
export default function SettingsScreen() {
@@ -145,26 +144,24 @@ export default function SettingsScreen() {
</View>
<View>
<Text className="mb-4 mx-4 text-neutral">
Default Behavior for Opening Links
</Text>
<Text className="mb-4 mx-4 text-neutral">Preferred Browser</Text>
<View className="bg-base-200 rounded-xl flex-col">
<TouchableOpacity
className="flex-row gap-2 items-center justify-between py-3 px-4"
onPress={() =>
updateData({
preferredFormat: null,
preferredBrowser: "app",
})
}
>
<View className="flex-row items-center gap-2">
<Globe
<AppWindowMac
size={20}
color={rawTheme[colorScheme as ThemeName].neutral}
/>
<Text className="text-base-content">Open original content</Text>
<Text className="text-base-content">In app browser</Text>
</View>
{data.preferredFormat === null ? (
{data.preferredBrowser === "app" ? (
<Check
size={20}
color={rawTheme[colorScheme as ThemeName].primary}
@@ -176,18 +173,20 @@ export default function SettingsScreen() {
className="flex-row gap-2 items-center justify-between py-3 px-4"
onPress={() =>
updateData({
preferredFormat: ArchivedFormat.readability,
preferredBrowser: "system",
})
}
>
<View className="flex-row items-center gap-2">
<FileText
<ExternalLink
size={20}
color={rawTheme[colorScheme as ThemeName].neutral}
/>
<Text className="text-base-content">Open reader view</Text>
<Text className="text-base-content">
System default browser
</Text>
</View>
{data.preferredFormat === ArchivedFormat.readability ? (
{data.preferredBrowser === "system" ? (
<Check
size={20}
color={rawTheme[colorScheme as ThemeName].primary}
@@ -230,7 +229,13 @@ export default function SettingsScreen() {
}
const styles = StyleSheet.create({
container: {
flex: 1,
},
container: Platform.select({
ios: {
flex: 1,
paddingBottom: 83,
},
default: {
flex: 1,
},
}),
});

View File

@@ -0,0 +1,60 @@
import { useLinks } from "@linkwarden/router/links";
import { View, StyleSheet, Platform } from "react-native";
import useAuthStore from "@/store/auth";
import { useLocalSearchParams, useNavigation } from "expo-router";
import React, { useEffect } from "react";
import { useTags } from "@linkwarden/router/tags";
import Links from "@/components/Links";
export default function LinksScreen() {
const { auth } = useAuthStore();
const { search, id } = useLocalSearchParams<{
search?: string;
id: string;
}>();
const { links, data } = useLinks(
{
sort: 0,
searchQueryString: decodeURIComponent(search ?? ""),
tagId: Number(id),
},
auth
);
const tags = useTags(auth);
const navigation = useNavigation();
useEffect(() => {
const activeTag = tags.data?.filter((e) => e.id === Number(id))[0];
if (activeTag?.name)
navigation?.setOptions?.({
headerTitle: activeTag?.name,
headerSearchBarOptions: {
placeholder: `Search ${activeTag.name}`,
},
});
}, [navigation]);
return (
<View
style={styles.container}
className="h-full bg-base-100"
collapsable={false}
collapsableChildren={false}
>
<Links links={links} data={data} />
</View>
);
}
const styles = StyleSheet.create({
container: Platform.select({
ios: {
paddingBottom: 83,
},
default: {},
}),
});

View File

@@ -0,0 +1,44 @@
import { Stack, useRouter } from "expo-router";
import { useColorScheme } from "nativewind";
import { rawTheme, ThemeName } from "@/lib/colors";
import { Platform } from "react-native";
export default function Layout() {
const router = useRouter();
const { colorScheme } = useColorScheme();
return (
<Stack
screenOptions={{
headerTitle: "Tags",
headerLargeTitle: true,
headerTransparent: Platform.OS === "ios" ? true : false,
headerShadowVisible: false,
headerBlurEffect:
colorScheme === "dark" ? "systemMaterialDark" : "systemMaterial",
headerTintColor: colorScheme === "dark" ? "white" : "black",
headerSearchBarOptions: {
placeholder: "Search Tags",
autoCapitalize: "none",
onChangeText: (e) => {
router.setParams({
search: encodeURIComponent(e.nativeEvent.text),
});
},
headerIconColor: colorScheme === "dark" ? "white" : "black",
},
headerLargeStyle: {
backgroundColor: rawTheme[colorScheme as ThemeName]["base-100"],
},
headerStyle: {
backgroundColor:
Platform.OS === "ios"
? "transparent"
: colorScheme === "dark"
? rawTheme["dark"]["base-100"]
: "white",
},
}}
/>
);
}

View File

@@ -0,0 +1,92 @@
import {
View,
StyleSheet,
FlatList,
Platform,
Text,
ActivityIndicator,
} from "react-native";
import useAuthStore from "@/store/auth";
import TagListing from "@/components/TagListing";
import { useLocalSearchParams } from "expo-router";
import React, { useEffect, useState } from "react";
import Spinner from "@/components/ui/Spinner";
import { rawTheme, ThemeName } from "@/lib/colors";
import { useColorScheme } from "nativewind";
import { TagIncludingLinkCount } from "@linkwarden/types";
import { useTags } from "@linkwarden/router/tags";
export default function TagsScreen() {
const { colorScheme } = useColorScheme();
const { auth } = useAuthStore();
const { search } = useLocalSearchParams<{ search?: string }>();
const tags = useTags(auth);
const [filteredTags, setFilteredTags] = useState<TagIncludingLinkCount[]>([]);
useEffect(() => {
const filter =
tags.data?.filter((e) =>
e.name.includes(decodeURIComponent(search || ""))
) || [];
setFilteredTags(filter);
}, [search, tags.data]);
return (
<View
style={styles.container}
className="h-full bg-base-100"
collapsable={false}
collapsableChildren={false}
>
{tags.isLoading ? (
<View className="flex justify-center h-screen items-center">
<ActivityIndicator size="large" />
<Text className="text-base mt-2.5 text-neutral">Loading...</Text>
</View>
) : (
<FlatList
contentInsetAdjustmentBehavior="automatic"
ListHeaderComponent={() => <></>}
data={filteredTags}
refreshControl={
<Spinner
refreshing={tags.isRefetching}
onRefresh={() => tags.refetch()}
progressBackgroundColor={
rawTheme[colorScheme as ThemeName]["base-200"]
}
colors={[rawTheme[colorScheme as ThemeName]["base-content"]]}
/>
}
refreshing={tags.isRefetching}
initialNumToRender={4}
keyExtractor={(item) => item.id?.toString() || ""}
renderItem={({ item }) => <TagListing tag={item} />}
onEndReachedThreshold={0.5}
ItemSeparatorComponent={() => (
<View className="bg-neutral-content h-px" />
)}
ListEmptyComponent={
<View className="flex justify-center py-10 items-center">
<Text className="text-center text-xl text-neutral">
Nothing found...
</Text>
</View>
}
/>
)}
</View>
);
}
const styles = StyleSheet.create({
container: Platform.select({
ios: {
paddingBottom: 83,
},
default: {},
}),
});

View File

@@ -1,4 +1,5 @@
import {
router,
Stack,
usePathname,
useRootNavigationState,
@@ -8,32 +9,39 @@ import { PersistQueryClientProvider } from "@tanstack/react-query-persist-client
import { mmkvPersister } from "@/lib/queryPersister";
import { useState, useEffect } from "react";
import "../styles/global.css";
import { SheetProvider } from "react-native-actions-sheet";
import { SheetManager, SheetProvider } from "react-native-actions-sheet";
import "@/components/ActionSheets/Sheets";
import { useColorScheme } from "nativewind";
import { lightTheme, darkTheme } from "../lib/theme";
import { Platform, View } from "react-native";
import {
Alert,
Linking,
Platform,
Share,
TouchableOpacity,
View,
} from "react-native";
import { rawTheme, ThemeName } from "@/lib/colors";
import { useShareIntent } from "expo-share-intent";
import useDataStore from "@/store/data";
import useAuthStore from "@/store/auth";
import { QueryClient } from "@tanstack/react-query";
import * as FileSystem from "expo-file-system";
const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 1000 * 60 * 60 * 24,
refetchOnMount: true,
refetchOnWindowFocus: false,
refetchOnReconnect: false,
},
},
});
import { KeyboardProvider } from "react-native-keyboard-controller";
import * as DropdownMenu from "zeego/dropdown-menu";
import { Compass, Ellipsis } from "lucide-react-native";
import { Chromium } from "@/components/ui/Icons";
import useTmpStore from "@/store/tmp";
import {
LinkIncludingShortenedCollectionAndTags,
MobileAuth,
} from "@linkwarden/types";
import { useDeleteLink, useUpdateLink } from "@linkwarden/router/links";
import { deleteLinkCache } from "@/lib/cache";
import { queryClient } from "@/lib/queryClient";
import getOriginalFormat from "@linkwarden/lib/getOriginalFormat";
import { StatusBar } from "expo-status-bar";
export default function RootLayout() {
const [isLoading, setIsLoading] = useState(true);
const { colorScheme } = useColorScheme();
const { hasShareIntent, shareIntent, error, resetShareIntent } =
useShareIntent();
const { updateData, setData, data } = useDataStore();
@@ -49,20 +57,6 @@ export default function RootLayout() {
setData();
}, []);
useEffect(() => {
(async () => {
if (auth.status === "unauthenticated") {
queryClient.cancelQueries();
queryClient.clear();
mmkvPersister.removeClient?.();
const CACHE_DIR =
FileSystem.documentDirectory + "archivedData/readable/";
await FileSystem.deleteAsync(CACHE_DIR, { idempotent: true });
}
})();
}, [auth.status]);
useEffect(() => {
if (!rootNavState?.key) return;
@@ -113,27 +107,42 @@ export default function RootLayout() {
queryClient.invalidateQueries();
}}
>
<View
style={[{ flex: 1 }, colorScheme === "dark" ? darkTheme : lightTheme]}
>
<RootComponent isLoading={isLoading} auth={auth} />
</PersistQueryClientProvider>
);
}
const RootComponent = ({
isLoading,
auth,
}: {
isLoading: boolean;
auth: MobileAuth;
}) => {
const { colorScheme } = useColorScheme();
const updateLink = useUpdateLink(auth);
const deleteLink = useDeleteLink(auth);
const { tmp } = useTmpStore();
return (
<View
style={[{ flex: 1 }, colorScheme === "dark" ? darkTheme : lightTheme]}
>
<KeyboardProvider>
<SheetProvider>
<StatusBar
style={colorScheme === "dark" ? "light" : "dark"}
backgroundColor={rawTheme[colorScheme as ThemeName]["base-100"]}
/>
{!isLoading && (
<Stack
screenOptions={{
navigationBarColor:
rawTheme[colorScheme as ThemeName]["base-200"],
headerShown: false,
contentStyle: {
backgroundColor:
rawTheme[colorScheme as ThemeName]["base-100"],
},
...Platform.select({
android: {
statusBarStyle: colorScheme === "dark" ? "light" : "dark",
statusBarBackgroundColor:
rawTheme[colorScheme as ThemeName]["base-100"],
},
}),
}}
>
{/* <Stack.Screen name="(tabs)" /> */}
@@ -144,58 +153,173 @@ export default function RootLayout() {
headerBackTitle: "Back",
headerTitle: "",
headerTintColor: colorScheme === "dark" ? "white" : "black",
navigationBarColor:
rawTheme[colorScheme as ThemeName]["base-100"],
headerStyle: {
backgroundColor:
colorScheme === "dark"
? rawTheme["dark"]["base-100"]
: "white",
},
headerRight: () => (
<View className="flex-row gap-5">
<TouchableOpacity
onPress={() => {
if (tmp.link) {
if (tmp.link.url) {
return Linking.openURL(tmp.link.url);
} else {
const format = getOriginalFormat(tmp.link);
return Linking.openURL(
format !== null
? auth.instance +
`/preserved/${tmp.link.id}?format=${format}`
: tmp.link.url || ""
);
}
}
}}
>
{Platform.OS === "ios" ? (
<Compass
size={21}
color={
rawTheme[colorScheme as ThemeName]["base-content"]
}
/>
) : (
<Chromium
stroke={
rawTheme[colorScheme as ThemeName]["base-content"]
}
/>
)}
</TouchableOpacity>
<DropdownMenu.Root>
<DropdownMenu.Trigger>
<TouchableOpacity>
<Ellipsis
size={21}
color={
rawTheme[colorScheme as ThemeName][
"base-content"
]
}
/>
</TouchableOpacity>
</DropdownMenu.Trigger>
<DropdownMenu.Content>
{tmp.link?.url && (
<DropdownMenu.Item
key="share"
onSelect={async () => {
await Share.share({
...(Platform.OS === "android"
? { message: tmp.link?.url as string }
: { url: tmp.link?.url as string }),
});
}}
>
<DropdownMenu.ItemTitle>
Share
</DropdownMenu.ItemTitle>
</DropdownMenu.Item>
)}
{tmp.link && tmp.user && (
<DropdownMenu.Item
key="pin-link"
onSelect={async () => {
const isAlreadyPinned =
tmp.link?.pinnedBy && tmp.link.pinnedBy[0]
? true
: false;
await updateLink.mutateAsync({
...(tmp.link as LinkIncludingShortenedCollectionAndTags),
pinnedBy: (isAlreadyPinned
? [{ id: undefined }]
: [{ id: tmp.user?.id }]) as any,
});
}}
>
<DropdownMenu.ItemTitle>
{tmp.link.pinnedBy && tmp.link.pinnedBy[0]
? "Unpin Link"
: "Pin Link"}
</DropdownMenu.ItemTitle>
</DropdownMenu.Item>
)}
{tmp.link && (
<DropdownMenu.Item
key="edit-link"
onSelect={() => {
SheetManager.show("edit-link-sheet", {
payload: {
link: tmp.link as LinkIncludingShortenedCollectionAndTags,
},
});
}}
>
<DropdownMenu.ItemTitle>
Edit Link
</DropdownMenu.ItemTitle>
</DropdownMenu.Item>
)}
{tmp.link && (
<DropdownMenu.Item
key="delete-link"
onSelect={() => {
return Alert.alert(
"Delete Link",
"Are you sure you want to delete this link? This action cannot be undone.",
[
{
text: "Cancel",
style: "cancel",
},
{
text: "Delete",
style: "destructive",
onPress: () => {
deleteLink.mutate(
tmp.link?.id as number,
{
onSuccess: async () => {
await deleteLinkCache(
tmp.link?.id as number
);
},
}
);
// go back
router.back();
},
},
]
);
}}
>
<DropdownMenu.ItemTitle>
Delete
</DropdownMenu.ItemTitle>
</DropdownMenu.Item>
)}
</DropdownMenu.Content>
</DropdownMenu.Root>
</View>
),
}}
/>
<Stack.Screen
name="login"
options={{
navigationBarColor:
rawTheme[colorScheme as ThemeName]["base-100"],
...Platform.select({
android: {
statusBarStyle:
colorScheme === "light" ? "light" : "dark",
statusBarBackgroundColor:
rawTheme[colorScheme as ThemeName]["primary"],
},
}),
}}
/>
<Stack.Screen
name="index"
options={{
navigationBarColor:
rawTheme[colorScheme as ThemeName]["base-100"],
...Platform.select({
android: {
statusBarStyle:
colorScheme === "light" ? "light" : "dark",
statusBarBackgroundColor:
rawTheme[colorScheme as ThemeName]["primary"],
},
}),
}}
/>
<Stack.Screen
name="incoming"
options={{
navigationBarColor:
rawTheme[colorScheme as ThemeName]["base-100"],
}}
/>
<Stack.Screen name="login" />
<Stack.Screen name="index" />
<Stack.Screen name="incoming" />
<Stack.Screen name="+not-found" />
</Stack>
)}
</SheetProvider>
</View>
</PersistQueryClientProvider>
</KeyboardProvider>
</View>
);
}
};

View File

@@ -7,6 +7,7 @@ import { View, Text, Dimensions, TouchableOpacity, Image } from "react-native";
import { SheetManager } from "react-native-actions-sheet";
import Svg, { Path } from "react-native-svg";
import Animated, { SlideInDown } from "react-native-reanimated";
import { SafeAreaView } from "react-native-safe-area-context";
export default function HomeScreen() {
const { auth } = useAuthStore();
@@ -19,52 +20,57 @@ 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>
</View>
<Svg
viewBox="0 0 1440 320"
width={Dimensions.get("screen").width}
height={100}
>
<Path
fill={rawTheme[colorScheme as ThemeName]["base-100"]}
fill-opacity="1"
d="M0,256L48,234.7C96,213,192,171,288,176C384,181,480,235,576,266.7C672,299,768,309,864,277.3C960,245,1056,171,1152,122.7C1248,75,1344,53,1392,42.7L1440,32L1440,320L1392,320C1344,320,1248,320,1152,320C1056,320,960,320,864,320C768,320,672,320,576,320C480,320,384,320,288,320C192,320,96,320,48,320L0,320Z"
/>
</Svg>
<View className="flex-col justify-end h-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.push("/login")}
<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}
>
<Text className="text-white text-xl">Get Started</Text>
</Button>
<TouchableOpacity
className="w-fit mx-auto"
onPress={() => SheetManager.show("support-sheet")}
<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"
>
<Text className="text-neutral text-center w-fit">Need help?</Text>
</TouchableOpacity>
<Button
variant="accent"
size="lg"
onPress={() => router.navigate("/login")}
>
<Text className="text-white text-xl">Get Started</Text>
</Button>
<TouchableOpacity
className="w-fit mx-auto"
onPress={() => SheetManager.show("support-sheet")}
>
<Text className="text-neutral text-center w-fit">Need help?</Text>
</TouchableOpacity>
</SafeAreaView>
</View>
</Animated.View>
);

View File

@@ -1,168 +1,93 @@
import React, { useEffect, useMemo, useState } from "react";
import {
View,
ActivityIndicator,
Text,
ScrollView,
TouchableOpacity,
} from "react-native";
import React, { useEffect, useState } from "react";
import { View, ActivityIndicator, Text, Platform } from "react-native";
import { WebView } from "react-native-webview";
import * as FileSystem from "expo-file-system";
import NetInfo from "@react-native-community/netinfo";
import useAuthStore from "@/store/auth";
import { useLocalSearchParams, useRouter } from "expo-router";
import { useLocalSearchParams } from "expo-router";
import { useUser } from "@linkwarden/router/user";
import { generateLinkHref } from "@linkwarden/lib/generateLinkHref";
import { useWindowDimensions } from "react-native";
import RenderHtml from "@linkwarden/react-native-render-html";
import ElementNotSupported from "@/components/ElementNotSupported";
import { decode } from "html-entities";
import { useGetLink } from "@linkwarden/router/links";
import { useColorScheme } from "nativewind";
import { rawTheme, ThemeName } from "@/lib/colors";
import { CalendarDays, Link } from "lucide-react-native";
const CACHE_DIR = FileSystem.documentDirectory + "archivedData/readable/";
const htmlPath = (id: string) => `${CACHE_DIR}link_${id}.html`;
async function ensureCacheDir() {
const info = await FileSystem.getInfoAsync(CACHE_DIR);
if (!info.exists) {
await FileSystem.makeDirectoryAsync(CACHE_DIR, { intermediates: true });
}
}
import useTmpStore from "@/store/tmp";
import { ArchivedFormat } from "@linkwarden/types";
import ReadableFormat from "@/components/Formats/ReadableFormat";
import ImageFormat from "@/components/Formats/ImageFormat";
import PdfFormat from "@/components/Formats/PdfFormat";
import WebpageFormat from "@/components/Formats/WebpageFormat";
import { useSafeAreaInsets } from "react-native-safe-area-context";
export default function LinkScreen() {
const { auth } = useAuthStore();
const { id, format } = useLocalSearchParams();
const { data: user } = useUser(auth);
const [url, setUrl] = useState<string>();
const [htmlContent, setHtmlContent] = useState<string>("");
const [isLoading, setIsLoading] = useState(true);
const { width } = useWindowDimensions();
const router = useRouter();
const { colorScheme } = useColorScheme();
const { data: link } = useGetLink({ id: Number(id), auth, enabled: true });
const { updateTmp } = useTmpStore();
useEffect(() => {
async function loadCacheOrFetch() {
await ensureCacheDir();
const htmlFile = htmlPath(id as string);
if (link?.id && user?.id)
updateTmp({
link,
user: {
id: user.id,
},
});
const [htmlInfo] = await Promise.all([FileSystem.getInfoAsync(htmlFile)]);
return () =>
updateTmp({
link: null,
});
}, [link, user]);
if (format === "3" && htmlInfo.exists) {
const rawHtml = await FileSystem.readAsStringAsync(htmlFile);
setHtmlContent(rawHtml);
setIsLoading(false);
useEffect(() => {
if (user?.id && link?.id && format) {
setUrl(`${auth.instance}/api/v1/archives/${link.id}?format=${format}`);
} else if (!url) {
if (link?.url) {
setUrl(link.url);
}
const net = await NetInfo.fetch();
if (net.isConnected) {
await fetchLinkData();
}
}
if (user?.id && link?.id && !url) {
loadCacheOrFetch();
}
}, [user, link]);
async function fetchLinkData() {
if (link?.id && format === "3") {
const apiUrl = `${auth.instance}/api/v1/archives/${link.id}?format=${format}`;
setUrl(apiUrl);
try {
const response = await fetch(apiUrl, {
headers: { Authorization: `Bearer ${auth.session}` },
});
const html = (await response.json()).content;
setHtmlContent(html);
await FileSystem.writeAsStringAsync(htmlPath(id as string), html, {
encoding: FileSystem.EncodingType.UTF8,
});
} catch (e) {
console.error("Failed to fetch HTML content", e);
} finally {
setIsLoading(false);
}
} else if (link?.id && !format && user) {
setUrl(
generateLinkHref(link, { ...user, password: "" }, auth.instance, true)
);
} else if (link?.id && format) {
setUrl(`${auth.instance}/api/v1/archives/${link.id}?format=${format}`);
}
}
const insets = useSafeAreaInsets();
return (
<>
{format === "3" && htmlContent ? (
<ScrollView
className="flex-1 bg-base-100"
contentContainerClassName="p-4"
nestedScrollEnabled
>
<Text className="text-2xl font-bold mb-2.5 text-base-content">
{decode(link?.name || link?.description || link?.url || "")}
</Text>
<TouchableOpacity
className="flex-row items-center gap-1 mb-2.5 pr-5"
onPress={() => router.replace(`/links/${id}`)}
>
<Link
size={16}
color={rawTheme[colorScheme as ThemeName]["neutral"]}
/>
<Text className="text-base text-neutral flex-1" numberOfLines={1}>
{link?.url}
</Text>
</TouchableOpacity>
<View className="flex-row items-center gap-1 mb-2.5">
<CalendarDays
size={16}
color={rawTheme[colorScheme as ThemeName]["neutral"]}
/>
<Text className="text-base text-neutral">
{new Date(
(link?.importDate || link?.createdAt) as string
).toLocaleString("en-US", {
year: "numeric",
month: "short",
day: "numeric",
})}
</Text>
</View>
<View className="border-t border-neutral-content mt-2.5 mb-5" />
<RenderHtml
contentWidth={width}
source={{ html: htmlContent }}
renderers={{
table: () => (
<ElementNotSupported
onPress={() => router.replace(`/links/${id}`)}
/>
),
}}
tagsStyles={{
p: { fontSize: 18, lineHeight: 28, marginVertical: 10 },
}}
baseStyle={{
color: rawTheme[colorScheme as ThemeName]["base-content"],
}}
/>
</ScrollView>
<View
className="flex-1"
style={{ paddingBottom: Platform.OS === "android" ? insets.bottom : 0 }}
>
{link?.id && Number(format) === ArchivedFormat.readability ? (
<ReadableFormat
link={link as any}
setIsLoading={(state) => setIsLoading(state)}
/>
) : link?.id &&
(Number(format) === ArchivedFormat.jpeg ||
Number(format) === ArchivedFormat.png) ? (
<ImageFormat
link={link as any}
setIsLoading={(state) => setIsLoading(state)}
format={Number(format)}
/>
) : link?.id && Number(format) === ArchivedFormat.pdf ? (
<PdfFormat
link={link as any}
setIsLoading={(state) => setIsLoading(state)}
/>
) : link?.id && Number(format) === ArchivedFormat.monolith ? (
<WebpageFormat
link={link as any}
setIsLoading={(state) => setIsLoading(state)}
/>
) : url ? (
<WebView
className={isLoading ? "opacity-0" : "flex-1"}
source={{
uri: url,
headers: format ? { Authorization: `Bearer ${auth.session}` } : {},
headers:
format || link?.type !== "url"
? { Authorization: `Bearer ${auth.session}` }
: {},
}}
onLoadEnd={() => setIsLoading(false)}
/>
@@ -181,6 +106,6 @@ export default function LinkScreen() {
<Text className="text-base mt-2.5 text-neutral">Loading...</Text>
</View>
)}
</>
</View>
);
}

View File

@@ -8,11 +8,17 @@ import { useEffect, useState } from "react";
import { View, Text, Dimensions, TouchableOpacity, Image } from "react-native";
import { SheetManager } from "react-native-actions-sheet";
import Svg, { Path } from "react-native-svg";
import {
KeyboardStickyView,
KeyboardToolbar,
} from "react-native-keyboard-controller";
import { SafeAreaView } from "react-native-safe-area-context";
export default function HomeScreen() {
const { auth, signIn } = useAuthStore();
const { colorScheme } = useColorScheme();
const [method, setMethod] = useState<"password" | "token">("password");
const [isLoading, setIsLoading] = useState(false);
const [form, setForm] = useState({
user: "",
@@ -50,117 +56,142 @@ export default function HomeScreen() {
}
return (
<View className="flex-col justify-end h-full bg-primary relative">
<View className="my-auto">
<Image
source={require("@/assets/images/linkwarden.png")}
className="w-[120px] h-[120px] mx-auto"
/>
</View>
<Text className="text-base-100 text-7xl font-bold ml-8">Login</Text>
<View>
<Text className="text-base-100 text-2xl mx-8 mt-3" numberOfLines={1}>
Login to{" "}
{form.instance === "https://cloud.linkwarden.app"
? "cloud.linkwarden.app"
: form.instance}
</Text>
<TouchableOpacity
onPress={() => {
if (showInstanceField) {
setForm({ ...form, instance: "https://cloud.linkwarden.app" });
}
setShowInstanceField(!showInstanceField);
}}
className="mx-8 mt-1 self-start"
>
<Text className="text-neutral-content text-sm">
{!showInstanceField ? "Change server" : "Use official server"}
</Text>
</TouchableOpacity>
</View>
<Svg
viewBox="0 0 1440 320"
width={Dimensions.get("screen").width}
height={100}
>
<Path
fill={rawTheme[colorScheme as ThemeName]["base-100"]}
fill-opacity="1"
d="M0,256L48,234.7C96,213,192,171,288,176C384,181,480,235,576,266.7C672,299,768,309,864,277.3C960,245,1056,171,1152,122.7C1248,75,1344,53,1392,42.7L1440,32L1440,320L1392,320C1344,320,1248,320,1152,320C1056,320,960,320,864,320C768,320,672,320,576,320C480,320,384,320,288,320C192,320,96,320,48,320L0,320Z"
/>
</Svg>
<View className="flex-col justify-end h-auto duration-100 pt-10 bg-base-100 -mt-2 pb-10 gap-4 w-full px-4">
{showInstanceField && (
<Input
className="w-full text-xl p-3 leading-tight h-12"
textAlignVertical="center"
placeholder="Instance URL"
selectTextOnFocus={false}
value={form.instance}
onChangeText={(text) => setForm({ ...form, instance: text })}
/>
)}
{method === "password" ? (
<>
<Input
className="w-full text-xl p-3 leading-tight h-12"
textAlignVertical="center"
placeholder="Email or Username"
value={form.user}
onChangeText={(text) => setForm({ ...form, user: text })}
<>
<KeyboardStickyView className="flex-col justify-end h-full bg-base-100 relative">
<View className="flex-col justify-end h-full bg-primary relative">
<View className="my-auto">
<Image
source={require("@/assets/images/linkwarden.png")}
className="w-[120px] h-[120px] mx-auto"
/>
<Input
className="w-full text-xl p-3 leading-tight h-12"
textAlignVertical="center"
placeholder="Password"
secureTextEntry
value={form.password}
onChangeText={(text) => setForm({ ...form, password: text })}
</View>
<Text className="text-base-100 text-7xl font-bold ml-8">Login</Text>
<View>
<Text
className="text-base-100 text-2xl mx-8 mt-3"
numberOfLines={1}
>
Login to{" "}
{form.instance === "https://cloud.linkwarden.app"
? "cloud.linkwarden.app"
: form.instance}
</Text>
<TouchableOpacity
onPress={() => {
if (showInstanceField) {
setForm({
...form,
instance: "https://cloud.linkwarden.app",
});
}
setShowInstanceField(!showInstanceField);
}}
className="mx-8 mt-1 self-start"
>
<Text className="text-neutral-content text-sm">
{!showInstanceField ? "Change server" : "Use official server"}
</Text>
</TouchableOpacity>
</View>
<Svg
viewBox="0 0 1440 320"
width={Dimensions.get("screen").width}
height={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"
/>
</>
) : (
<Input
className="w-full text-xl p-3 leading-tight h-12"
textAlignVertical="center"
placeholder="Access Token"
secureTextEntry
value={form.token}
onChangeText={(text) => setForm({ ...form, token: text })}
/>
)}
</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"
>
{showInstanceField && (
<Input
className="w-full text-xl p-3 leading-tight h-12"
textAlignVertical="center"
placeholder="Instance URL"
selectTextOnFocus={false}
value={form.instance}
onChangeText={(text) => setForm({ ...form, instance: text })}
/>
)}
{method === "password" ? (
<>
<Input
className="w-full text-xl p-3 leading-tight h-12"
textAlignVertical="center"
placeholder="Email or Username"
value={form.user}
onChangeText={(text) => setForm({ ...form, user: text })}
/>
<Input
className="w-full text-xl p-3 leading-tight h-12"
textAlignVertical="center"
placeholder="Password"
secureTextEntry
value={form.password}
onChangeText={(text) => setForm({ ...form, password: text })}
/>
</>
) : (
<Input
className="w-full text-xl p-3 leading-tight h-12"
textAlignVertical="center"
placeholder="Access Token"
secureTextEntry
value={form.token}
onChangeText={(text) => setForm({ ...form, token: text })}
/>
)}
<TouchableOpacity
onPress={() =>
setMethod(method === "password" ? "token" : "password")
}
className="w-fit mx-auto"
>
<Text className="text-primary w-fit text-center">
{method === "password"
? "Login with Access Token"
: "Login with Username/Password"}
</Text>
</TouchableOpacity>
<TouchableOpacity
onPress={() =>
setMethod(method === "password" ? "token" : "password")
}
className="w-fit mx-auto"
>
<Text className="text-primary w-fit text-center">
{method === "password"
? "Login with Access Token"
: "Login with Username/Password"}
</Text>
</TouchableOpacity>
<Button
variant="accent"
size="lg"
onPress={() => {
if (((form.user && form.password) || form.token) && form.instance) {
signIn(form.user, form.password, form.instance, form.token);
}
}}
>
<Text className="text-white text-xl">Login</Text>
</Button>
<TouchableOpacity
className="w-fit mx-auto"
onPress={() => SheetManager.show("support-sheet")}
>
<Text className="text-neutral text-center w-fit">Need help?</Text>
</TouchableOpacity>
</View>
</View>
<Button
variant="accent"
size="lg"
isLoading={isLoading}
onPress={async () => {
if (
((form.user && form.password) || form.token) &&
form.instance
) {
setIsLoading(true);
await signIn(
form.user,
form.password,
form.instance,
form.token
);
setIsLoading(false);
}
}}
>
<Text className="text-white text-xl">Login</Text>
</Button>
<TouchableOpacity
className="w-fit mx-auto"
onPress={() => SheetManager.show("support-sheet")}
>
<Text className="text-neutral text-center w-fit">Need help?</Text>
</TouchableOpacity>
</SafeAreaView>
</View>
</KeyboardStickyView>
<KeyboardToolbar />
</>
);
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.7 KiB

View File

@@ -7,6 +7,7 @@ import { useAddLink } from "@linkwarden/router/links";
import useAuthStore from "@/store/auth";
import { rawTheme, ThemeName } from "@/lib/colors";
import { useColorScheme } from "nativewind";
import { useSafeAreaInsets } from "react-native-safe-area-context";
export default function AddLinkSheet() {
const actionSheetRef = useRef<ActionSheetRef>(null);
@@ -15,6 +16,8 @@ export default function AddLinkSheet() {
const [link, setLink] = useState("");
const { colorScheme } = useColorScheme();
const insets = useSafeAreaInsets();
return (
<ActionSheet
ref={actionSheetRef}
@@ -25,6 +28,7 @@ export default function AddLinkSheet() {
containerStyle={{
backgroundColor: rawTheme[colorScheme as ThemeName]["base-200"],
}}
safeAreaInsets={insets}
>
<View className="px-8 py-5">
<Input
@@ -50,6 +54,7 @@ export default function AddLinkSheet() {
}
)
}
isLoading={addLink.isPending}
variant="accent"
className="mb-2"
>

View File

@@ -20,6 +20,8 @@ import { useCollections } from "@linkwarden/router/collections";
import { rawTheme, ThemeName } from "@/lib/colors";
import { useColorScheme } from "nativewind";
import { Folder, ChevronRight, Check } from "lucide-react-native";
import useTmpStore from "@/store/tmp";
import { useSafeAreaInsets } from "react-native-safe-area-context";
const Main = (props: SheetProps<"edit-link-sheet">) => {
const { auth } = useAuthStore();
@@ -39,6 +41,8 @@ const Main = (props: SheetProps<"edit-link-sheet">) => {
}
}, [params?.link]);
const { tmp, updateTmp } = useTmpStore();
return (
<View className="px-8 py-5">
<Input
@@ -111,6 +115,11 @@ const Main = (props: SheetProps<"edit-link-sheet">) => {
onPress={() =>
editLink.mutate(link as LinkIncludingShortenedCollectionAndTags, {
onSuccess: () => {
if (link && tmp.link)
updateTmp({
link,
});
SheetManager.hide("edit-link-sheet");
},
onError: (error) => {
@@ -119,6 +128,7 @@ const Main = (props: SheetProps<"edit-link-sheet">) => {
},
})
}
isLoading={editLink.isPending}
variant="accent"
className="mb-2"
>
@@ -246,6 +256,8 @@ const routes: Route[] = [
export default function EditLinkSheet() {
const { colorScheme } = useColorScheme();
const insets = useSafeAreaInsets();
return (
<ActionSheet
gestureEnabled
@@ -258,6 +270,7 @@ export default function EditLinkSheet() {
containerStyle={{
backgroundColor: rawTheme[colorScheme as ThemeName]["base-200"],
}}
safeAreaInsets={insets}
/>
);
}

View File

@@ -0,0 +1,92 @@
import { Alert, Text, View } from "react-native";
import { useRef, useState } from "react";
import ActionSheet, { ActionSheetRef } from "react-native-actions-sheet";
import Input from "@/components/ui/Input";
import { Button } from "@/components/ui/Button";
import useAuthStore from "@/store/auth";
import { rawTheme, ThemeName } from "@/lib/colors";
import { useColorScheme } from "nativewind";
import { useCreateCollection } from "@linkwarden/router/collections";
import { useSafeAreaInsets } from "react-native-safe-area-context";
export default function NewCollectionSheet() {
const actionSheetRef = useRef<ActionSheetRef>(null);
const { auth } = useAuthStore();
const createCollection = useCreateCollection(auth);
const [collection, setCollection] = useState({
name: "",
description: "",
});
const { colorScheme } = useColorScheme();
const insets = useSafeAreaInsets();
return (
<ActionSheet
ref={actionSheetRef}
gestureEnabled
indicatorStyle={{
backgroundColor: rawTheme[colorScheme as ThemeName]["neutral-content"],
}}
containerStyle={{
backgroundColor: rawTheme[colorScheme as ThemeName]["base-200"],
}}
safeAreaInsets={insets}
>
<View className="px-8 py-5">
<Input
placeholder="Name"
className="mb-4 bg-base-100"
value={collection.name}
onChangeText={(text) => setCollection({ ...collection, name: text })}
/>
<Input
placeholder="Description"
className="mb-4 bg-base-100"
value={collection.description}
onChangeText={(text) =>
setCollection({ ...collection, description: text })
}
/>
<Button
onPress={() =>
createCollection.mutate(
{ name: collection.name, description: collection.description },
{
onSuccess: () => {
actionSheetRef.current?.hide();
setCollection({ name: "", description: "" });
},
onError: (error) => {
Alert.alert(
"Error",
"There was an error creating the collection."
);
console.error("Error creating collection:", error);
},
}
)
}
isLoading={createCollection.isPending}
variant="accent"
className="mb-2"
>
<Text className="text-white">Save Collection</Text>
</Button>
<Button
onPress={() => {
actionSheetRef.current?.hide();
setCollection({ name: "", description: "" });
}}
variant="outline"
className="mb-2"
>
<Text className="text-base-content">Cancel</Text>
</Button>
</View>
</ActionSheet>
);
}

View File

@@ -6,11 +6,13 @@ import {
import SupportSheet from "./SupportSheet";
import AddLinkSheet from "./AddLinkSheet";
import EditLinkSheet from "./EditLinkSheet";
import NewCollectionSheet from "./NewCollectionSheet";
import { LinkIncludingShortenedCollectionAndTags } from "@linkwarden/types";
registerSheet("support-sheet", SupportSheet);
registerSheet("add-link-sheet", AddLinkSheet);
registerSheet("edit-link-sheet", EditLinkSheet);
registerSheet("new-collection-sheet", NewCollectionSheet);
declare module "react-native-actions-sheet" {
interface Sheets {
@@ -29,6 +31,7 @@ declare module "react-native-actions-sheet" {
}>;
};
}>;
"new-collection-sheet": SheetDefinition;
}
}

View File

@@ -5,6 +5,7 @@ import { rawTheme, ThemeName } from "@/lib/colors";
import { useColorScheme } from "nativewind";
import * as Clipboard from "expo-clipboard";
import { Button } from "../ui/Button";
import { useSafeAreaInsets } from "react-native-safe-area-context";
export default function SupportSheet() {
const { colorScheme } = useColorScheme();
@@ -13,11 +14,11 @@ export default function SupportSheet() {
async function handleEmailPress() {
await Clipboard.setStringAsync("support@linkwarden.app");
setCopied(true);
setTimeout(() => {
setCopied(false);
}, 2000);
setTimeout(() => setCopied(false), 2000);
}
const insets = useSafeAreaInsets();
return (
<ActionSheet
gestureEnabled
@@ -27,6 +28,7 @@ export default function SupportSheet() {
containerStyle={{
backgroundColor: rawTheme[colorScheme as ThemeName]["base-100"],
}}
safeAreaInsets={insets}
>
<View className="px-8 py-5 flex-col gap-4">
<Text className="text-2xl font-bold text-base-content">Need help?</Text>

View File

@@ -0,0 +1,132 @@
import { View, Text, Pressable, Platform, Alert } from "react-native";
import { decode } from "html-entities";
import { CollectionIncludingMembersAndLinkCount } from "@linkwarden/types";
import useAuthStore from "@/store/auth";
import { useRouter } from "expo-router";
import * as ContextMenu from "zeego/context-menu";
import { cn } from "@linkwarden/lib/utils";
import { rawTheme, ThemeName } from "@/lib/colors";
import { useColorScheme } from "nativewind";
import { CalendarDays, Folder, Link } from "lucide-react-native";
import { useDeleteCollection } from "@linkwarden/router/collections";
type Props = {
collection: CollectionIncludingMembersAndLinkCount;
};
const CollectionListing = ({ collection }: Props) => {
const { auth } = useAuthStore();
const router = useRouter();
const { colorScheme } = useColorScheme();
const deleteCollection = useDeleteCollection(auth);
return (
<ContextMenu.Root>
<ContextMenu.Trigger asChild>
<Pressable
className={cn(
"p-5 flex-row justify-between",
"bg-base-100",
Platform.OS !== "android" && "active:bg-base-200/50"
)}
onLongPress={() => {}}
onPress={() => router.navigate(`/collections/${collection.id}`)}
android_ripple={{
color: colorScheme === "dark" ? "rgba(255,255,255,0.2)" : "#ddd",
borderless: false,
}}
>
<View className="w-full">
<View className="w-[90%] flex-col justify-between gap-3">
<View className="flex flex-row gap-2 items-center pr-1.5 self-start rounded-md">
<Folder
size={16}
fill={collection.color || ""}
color={collection.color || ""}
/>
<Text
numberOfLines={2}
className="font-medium text-lg text-base-content"
>
{decode(collection.name)}
</Text>
</View>
{collection.description && (
<Text
numberOfLines={2}
className="font-light text-sm text-base-content"
>
{decode(collection.description)}
</Text>
)}
</View>
<View className="flex-row gap-3">
<View className="flex flex-row gap-1 items-center mt-5 self-start">
<CalendarDays
size={16}
color={rawTheme[colorScheme as ThemeName]["neutral"]}
/>
<Text
numberOfLines={1}
className="font-light text-xs text-base-content"
>
{new Date(collection.createdAt as string).toLocaleString(
"en-US",
{
year: "numeric",
month: "short",
day: "numeric",
}
)}
</Text>
</View>
<View className="flex flex-row gap-1 items-center mt-5 self-start">
<Link
size={16}
color={rawTheme[colorScheme as ThemeName]["neutral"]}
/>
<Text
numberOfLines={1}
className="font-light text-xs text-base-content"
>
{collection._count?.links}
</Text>
</View>
</View>
</View>
</Pressable>
</ContextMenu.Trigger>
<ContextMenu.Content avoidCollisions>
<ContextMenu.Item
key="delete-collection"
onSelect={() => {
return Alert.alert(
"Delete Collection",
"Are you sure you want to delete this collection? This action cannot be undone.",
[
{
text: "Cancel",
style: "cancel",
},
{
text: "Delete",
style: "destructive",
onPress: () => {
deleteCollection.mutate(collection.id as number);
},
},
]
);
}}
>
<ContextMenu.ItemTitle>Delete</ContextMenu.ItemTitle>
</ContextMenu.Item>
</ContextMenu.Content>
</ContextMenu.Root>
);
};
export default CollectionListing;

View File

@@ -0,0 +1,346 @@
import {
FlatList,
Text,
TouchableOpacity,
View,
ViewToken,
} from "react-native";
import React from "react";
import clsx from "clsx";
import DashboardItem from "@/components/DashboardItem";
import { rawTheme, ThemeName } from "@/lib/colors";
import {
Clock8,
ChevronRight,
Pin,
Folder,
Hash,
Link,
} from "lucide-react-native";
import { LinkIncludingShortenedCollectionAndTags } from "@linkwarden/types";
import LinkListing from "@/components/LinkListing";
import { useColorScheme } from "nativewind";
import { useRouter } from "expo-router";
// Don't use prisma client's DashboardSectionType, it'll crash in production (React Native)
type DashboardSectionType =
| "STATS"
| "RECENT_LINKS"
| "PINNED_LINKS"
| "COLLECTION";
type DashboardSectionProps = {
sectionData: { type: DashboardSectionType };
collection?: any;
links?: any[];
tagsLength: number;
numberOfLinks: number;
collectionsLength: number;
numberOfPinnedLinks: number;
dashboardData: {
isLoading: boolean;
refetch: Function;
isRefetching: boolean;
};
collectionLinks?: any[];
};
const DashboardSection: React.FC<DashboardSectionProps> = ({
sectionData,
collection,
links = [],
tagsLength,
numberOfLinks,
collectionsLength,
numberOfPinnedLinks,
dashboardData,
collectionLinks = [],
}) => {
const { colorScheme } = useColorScheme();
const router = useRouter();
switch (sectionData.type) {
case "STATS":
return (
<View className="flex-col gap-4 max-w-full px-5">
<View className="flex-row gap-4">
<DashboardItem
name={numberOfLinks === 1 ? "Link" : "Links"}
value={numberOfLinks}
icon={<Link size={23} color="white" />}
color="#9c00cc"
/>
<DashboardItem
name={collectionsLength === 1 ? "Collection" : "Collections"}
value={collectionsLength}
icon={<Folder size={23} color="white" fill="white" />}
color="#0096cc"
/>
</View>
<View className="flex-row gap-4">
<DashboardItem
name={tagsLength === 1 ? "Tag" : "Tags"}
value={tagsLength}
icon={<Hash size={23} color="white" />}
color="#00cc99"
/>
<DashboardItem
name={"Pinned Links"}
value={numberOfPinnedLinks}
icon={<Pin size={23} color="white" fill="white" />}
color="#cc6d00"
/>
</View>
</View>
);
case "RECENT_LINKS":
return (
<>
<View className="flex-row justify-between items-center px-5">
<View className="flex-row gap-2 items-center">
<View className={"flex-row items-center gap-2"}>
<Clock8
size={30}
color={rawTheme[colorScheme as ThemeName].primary}
/>
<Text className="text-2xl capitalize text-base-content">
Recent Links
</Text>
</View>
</View>
<TouchableOpacity
className="flex-row items-center text-sm gap-1"
onPress={() => router.navigate("/(tabs)/dashboard/recent-links")}
>
<Text className="text-primary">View All</Text>
<ChevronRight
size={15}
color={rawTheme[colorScheme as ThemeName].primary}
/>
</TouchableOpacity>
</View>
{dashboardData.isLoading ||
(links.length > 0 && !dashboardData.isLoading) ? (
<FlatList
horizontal
showsHorizontalScrollIndicator={false}
directionalLockEnabled
data={links || []}
refreshing={dashboardData.isLoading}
initialNumToRender={2}
keyExtractor={(item) => item.id?.toString() || ""}
renderItem={({ item }) => (
<RenderItem item={item} key={item.id?.toString()} />
)}
ItemSeparatorComponent={() => <View style={{ width: 10 }} />}
contentContainerStyle={{
paddingHorizontal: 20,
}}
onViewableItemsChanged={({
viewableItems,
}: {
viewableItems: ViewToken[];
}) => {
const links = viewableItems.map(
(e) => e.item
) as LinkIncludingShortenedCollectionAndTags[];
if (
!dashboardData.isRefetching &&
links.some((e) => e.id && !e.preview)
) {
dashboardData.refetch();
}
}}
viewabilityConfig={{ itemVisiblePercentThreshold: 50 }}
/>
) : (
<View className="flex-col gap-2 justify-center items-center h-40 p-10 rounded-xl bg-base-200 mx-5">
<Clock8
size={40}
color={rawTheme[colorScheme as ThemeName].primary}
/>
<Text className="text-center text-xl text-neutral">
No Recent Links
</Text>
{/* <View className="text-center w-full mt-4 flex-row flex-wrap gap-4 justify-center">
<Button onPress={() => setNewLinkModal(true)} variant="accent">
<Icon name="bi-plus-lg" className="text-xl" />
<Text>{t("add_link")}</Text>
</Button>
<ImportDropdown />
</View> */}
</View>
)}
</>
);
case "PINNED_LINKS":
return (
<>
<View className="flex-row justify-between items-center px-5">
<View className="flex-row gap-2 items-center">
<View className={"flex-row items-center gap-2"}>
<Pin
size={30}
color={rawTheme[colorScheme as ThemeName].primary}
/>
<Text className="text-2xl capitalize text-base-content">
Pinned Links
</Text>
</View>
</View>
<TouchableOpacity
className="flex-row items-center text-sm gap-1"
onPress={() => router.navigate("/(tabs)/dashboard/pinned-links")}
>
<Text className="text-primary">View All</Text>
<ChevronRight
size={15}
color={rawTheme[colorScheme as ThemeName].primary}
/>
</TouchableOpacity>
</View>
{dashboardData.isLoading ||
links?.some((e: any) => e.pinnedBy && e.pinnedBy[0]) ? (
<FlatList
horizontal
showsHorizontalScrollIndicator={false}
data={links.filter((e: any) => e.pinnedBy && e.pinnedBy[0]) || []}
refreshing={dashboardData.isLoading}
initialNumToRender={2}
keyExtractor={(item) => item.id?.toString() || ""}
renderItem={({ item }) => (
<RenderItem item={item} key={item.id?.toString()} />
)}
ItemSeparatorComponent={() => <View style={{ width: 10 }} />}
contentContainerStyle={{
paddingHorizontal: 20,
}}
onViewableItemsChanged={({
viewableItems,
}: {
viewableItems: ViewToken[];
}) => {
const links = viewableItems.map(
(e) => e.item
) as LinkIncludingShortenedCollectionAndTags[];
if (
!dashboardData.isRefetching &&
links.some((e) => e.id && !e.preview)
) {
dashboardData.refetch();
}
}}
viewabilityConfig={{ itemVisiblePercentThreshold: 50 }}
/>
) : (
<View className="flex-col gap-2 justify-center items-center h-40 p-10 rounded-xl bg-base-200 mx-5">
<Pin
size={40}
color={rawTheme[colorScheme as ThemeName].primary}
/>
<Text className="text-center text-xl text-neutral">
No Pinned Links
</Text>
</View>
)}
</>
);
case "COLLECTION":
return collection?.id ? (
<>
<View className="flex-row justify-between items-center px-5">
<View className="flex-row gap-2 items-center max-w-[60%]">
<View className={clsx("flex-row items-center gap-2")}>
<Folder
size={30}
fill={collection.color || "#0ea5e9"}
color={collection.color || "#0ea5e9"}
/>
<Text
className="text-2xl capitalize w-full text-base-content"
numberOfLines={1}
>
{collection.name}
</Text>
</View>
</View>
<TouchableOpacity
className="flex-row items-center text-sm gap-1 whitespace-nowrap"
onPress={() =>
router.navigate(
`/(tabs)/dashboard/collection?collectionId=${collection.id}`
)
}
>
<Text className="text-primary">View All</Text>
<ChevronRight
size={15}
color={rawTheme[colorScheme as ThemeName].primary}
/>
</TouchableOpacity>
</View>
{dashboardData.isLoading || collectionLinks.length > 0 ? (
<FlatList
horizontal
showsHorizontalScrollIndicator={false}
data={collectionLinks || []}
refreshing={dashboardData.isLoading}
initialNumToRender={2}
keyExtractor={(item) => item.id?.toString() || ""}
renderItem={({ item }) => (
<RenderItem item={item} key={item.id?.toString()} />
)}
ItemSeparatorComponent={() => <View style={{ width: 10 }} />}
contentContainerStyle={{
paddingHorizontal: 20,
}}
onViewableItemsChanged={({
viewableItems,
}: {
viewableItems: ViewToken[];
}) => {
const links = viewableItems.map(
(e) => e.item
) as LinkIncludingShortenedCollectionAndTags[];
if (
!dashboardData.isRefetching &&
links.some((e) => e.id && !e.preview)
) {
dashboardData.refetch();
}
}}
viewabilityConfig={{ itemVisiblePercentThreshold: 50 }}
/>
) : (
<View className="flex-col gap-2 justify-center items-center h-40 p-10 rounded-xl bg-base-200 mx-5">
<Text className="text-center text-xl text-neutral">
Empty Collection
</Text>
</View>
)}
</>
) : null;
default:
return null;
}
};
export default DashboardSection;
const RenderItem = React.memo(
({ item }: { item: LinkIncludingShortenedCollectionAndTags }) => {
return <LinkListing link={item} dashboard />;
}
);

View File

@@ -0,0 +1,136 @@
import React, { useEffect, useState } from "react";
import * as FileSystem from "expo-file-system";
import NetInfo from "@react-native-community/netinfo";
import useAuthStore from "@/store/auth";
import { ArchivedFormat } from "@linkwarden/types";
import { Link as LinkType } from "@linkwarden/prisma/client";
import WebView from "react-native-webview";
import { Image, Platform, ScrollView } from "react-native";
type Props = {
link: LinkType;
setIsLoading: (state: boolean) => void;
format: ArchivedFormat.png | ArchivedFormat.jpeg;
};
export default function ImageFormat({ link, setIsLoading, format }: Props) {
const FORMAT = format;
const extension = format === ArchivedFormat.png ? "png" : "jpeg";
const { auth } = useAuthStore();
const [content, setContent] = useState<string>("");
const [dimension, setDimension] = useState<{
width: number;
height: number;
}>();
useEffect(() => {
if (content)
Image.getSize(content, (width, height) => {
setDimension({ width, height });
});
}, [content]);
useEffect(() => {
async function loadCacheOrFetch() {
const filePath =
FileSystem.documentDirectory +
`archivedData/${extension}/link_${link.id}.${extension}`;
await FileSystem.makeDirectoryAsync(
filePath.substring(0, filePath.lastIndexOf("/")),
{
intermediates: true,
}
).catch(() => {});
const [info] = await Promise.all([FileSystem.getInfoAsync(filePath)]);
if (info.exists) {
setContent(filePath);
}
const net = await NetInfo.fetch();
if (net.isConnected) {
const apiUrl = `${auth.instance}/api/v1/archives/${link.id}?format=${FORMAT}`;
try {
const result = await FileSystem.downloadAsync(apiUrl, filePath, {
headers: { Authorization: `Bearer ${auth.session}` },
});
setContent(result.uri);
} catch (e) {
console.error("Failed to fetch content", e);
}
}
}
loadCacheOrFetch();
}, [link]);
if (Platform.OS === "ios")
return (
content &&
dimension && (
<ScrollView maximumZoomScale={10}>
<Image
source={{ uri: content }}
onLoadEnd={() => setIsLoading(false)}
style={{
width: "100%",
height: "auto",
aspectRatio: dimension.width / dimension.height,
}}
resizeMode="contain"
/>
</ScrollView>
)
);
else
return (
content && (
<WebView
style={{
flex: 1,
}}
source={{
baseUrl: content,
html: `
<!DOCTYPE html>
<html>
<head>
<style>
body {
margin: 0;
padding: 0;
display: flex;
justify-content: center;
align-items: center;
}
img {
max-width: 100%;
height: auto;
}
</style>
</head>
<body>
<img src="${content}" />
</body>
</html>
`,
}}
scalesPageToFit
originWhitelist={["*"]}
mixedContentMode="always"
javaScriptEnabled={true}
allowFileAccess={true}
allowFileAccessFromFileURLs={true}
allowUniversalAccessFromFileURLs={true}
onLoadEnd={() => setIsLoading(false)}
/>
)
);
}

View File

@@ -0,0 +1,72 @@
import React, { useEffect, useState } from "react";
import * as FileSystem from "expo-file-system";
import NetInfo from "@react-native-community/netinfo";
import useAuthStore from "@/store/auth";
import { ArchivedFormat } from "@linkwarden/types";
import { Link as LinkType } from "@linkwarden/prisma/client";
import Pdf from "react-native-pdf";
type Props = {
link: LinkType;
setIsLoading: (state: boolean) => void;
};
export default function PdfFormat({ link, setIsLoading }: Props) {
const FORMAT = ArchivedFormat.pdf;
const { auth } = useAuthStore();
const [content, setContent] = useState<string>("");
useEffect(() => {
async function loadCacheOrFetch() {
const filePath =
FileSystem.documentDirectory + `archivedData/pdf/link_${link.id}.pdf`;
await FileSystem.makeDirectoryAsync(
filePath.substring(0, filePath.lastIndexOf("/")),
{
intermediates: true,
}
).catch(() => {});
const [info] = await Promise.all([FileSystem.getInfoAsync(filePath)]);
if (info.exists) {
setContent(filePath);
}
const net = await NetInfo.fetch();
if (net.isConnected) {
const apiUrl = `${auth.instance}/api/v1/archives/${link.id}?format=${FORMAT}`;
try {
const result = await FileSystem.downloadAsync(apiUrl, filePath, {
headers: { Authorization: `Bearer ${auth.session}` },
});
setContent(result.uri);
} catch (e) {
console.error("Failed to fetch content", e);
}
}
}
loadCacheOrFetch();
}, [link]);
return (
content && (
<Pdf
style={{
flex: 1,
}}
source={{ uri: content }}
onLoadComplete={() => setIsLoading(false)}
onPressLink={(uri) => {
console.log(`Link pressed: ${uri}`);
}}
/>
)
);
}

View File

@@ -0,0 +1,139 @@
import React, { useEffect, useState } from "react";
import { View, Text, ScrollView, TouchableOpacity } from "react-native";
import * as FileSystem from "expo-file-system";
import NetInfo from "@react-native-community/netinfo";
import useAuthStore from "@/store/auth";
import { useRouter } from "expo-router";
import { useWindowDimensions } from "react-native";
import RenderHtml from "@linkwarden/react-native-render-html";
import ElementNotSupported from "@/components/ElementNotSupported";
import { decode } from "html-entities";
import { useColorScheme } from "nativewind";
import { rawTheme, ThemeName } from "@/lib/colors";
import { CalendarDays, Link } from "lucide-react-native";
import { ArchivedFormat } from "@linkwarden/types";
import { Link as LinkType } from "@linkwarden/prisma/client";
type Props = {
link: LinkType;
setIsLoading: (state: boolean) => void;
};
export default function ReadableFormat({ link, setIsLoading }: Props) {
const FORMAT = ArchivedFormat.readability;
const { auth } = useAuthStore();
const [content, setContent] = useState<string>("");
const { width } = useWindowDimensions();
const router = useRouter();
const { colorScheme } = useColorScheme();
useEffect(() => {
async function loadCacheOrFetch() {
const filePath =
FileSystem.documentDirectory +
`archivedData/readable/link_${link.id}.html`;
await FileSystem.makeDirectoryAsync(
filePath.substring(0, filePath.lastIndexOf("/")),
{
intermediates: true,
}
).catch(() => {});
const [info] = await Promise.all([FileSystem.getInfoAsync(filePath)]);
if (info.exists) {
const rawContent = await FileSystem.readAsStringAsync(filePath);
setContent(rawContent);
}
const net = await NetInfo.fetch();
if (net.isConnected) {
const apiUrl = `${auth.instance}/api/v1/archives/${link.id}?format=${FORMAT}`;
try {
const response = await fetch(apiUrl, {
headers: { Authorization: `Bearer ${auth.session}` },
});
const data = (await response.json()).content;
setContent(data);
await FileSystem.writeAsStringAsync(filePath, data, {
encoding: FileSystem.EncodingType.UTF8,
});
} catch (e) {
console.error("Failed to fetch content", e);
}
}
}
loadCacheOrFetch();
}, [link]);
return (
content && (
<ScrollView
className="flex-1 bg-base-100"
contentContainerClassName="p-4"
nestedScrollEnabled
>
<Text className="text-2xl font-bold mb-2.5 text-base-content">
{decode(link.name || link.description || link.url || "")}
</Text>
<TouchableOpacity
className="flex-row items-center gap-1 mb-2.5 pr-5"
onPress={() => router.replace(`/links/${link.id}`)}
>
<Link
size={16}
color={rawTheme[colorScheme as ThemeName]["neutral"]}
/>
<Text className="text-base text-neutral flex-1" numberOfLines={1}>
{link.url}
</Text>
</TouchableOpacity>
<View className="flex-row items-center gap-1 mb-2.5">
<CalendarDays
size={16}
color={rawTheme[colorScheme as ThemeName]["neutral"]}
/>
<Text className="text-base text-neutral">
{new Date(link?.importDate || link.createdAt).toLocaleString(
"en-US",
{
year: "numeric",
month: "short",
day: "numeric",
}
)}
</Text>
</View>
<View className="border-t border-neutral-content mt-2.5 mb-5" />
<RenderHtml
contentWidth={width}
source={{ html: content }}
renderers={{
table: () => (
<ElementNotSupported
onPress={() => router.replace(`/links/${link.id}`)}
/>
),
}}
onHTMLLoaded={() => setIsLoading(false)}
tagsStyles={{
p: { fontSize: 18, lineHeight: 28, marginVertical: 10 },
}}
baseStyle={{
color: rawTheme[colorScheme as ThemeName]["base-content"],
}}
/>
</ScrollView>
)
);
}

View File

@@ -0,0 +1,80 @@
import React, { useEffect, useState } from "react";
import * as FileSystem from "expo-file-system";
import NetInfo from "@react-native-community/netinfo";
import useAuthStore from "@/store/auth";
import { ArchivedFormat } from "@linkwarden/types";
import { Link as LinkType } from "@linkwarden/prisma/client";
import WebView from "react-native-webview";
type Props = {
link: LinkType;
setIsLoading: (state: boolean) => void;
};
export default function WebpageFormat({ link, setIsLoading }: Props) {
const FORMAT = ArchivedFormat.monolith;
const { auth } = useAuthStore();
const [content, setContent] = useState<string>("");
useEffect(() => {
async function loadCacheOrFetch() {
const filePath =
FileSystem.documentDirectory +
`archivedData/webpage/link_${link.id}.html`;
await FileSystem.makeDirectoryAsync(
filePath.substring(0, filePath.lastIndexOf("/")),
{
intermediates: true,
}
).catch(() => {});
const [info] = await Promise.all([FileSystem.getInfoAsync(filePath)]);
if (info.exists) {
setContent(filePath);
}
const net = await NetInfo.fetch();
if (net.isConnected) {
const apiUrl = `${auth.instance}/api/v1/archives/${link.id}?format=${FORMAT}`;
try {
const result = await FileSystem.downloadAsync(apiUrl, filePath, {
headers: { Authorization: `Bearer ${auth.session}` },
});
setContent(result.uri);
} catch (e) {
console.error("Failed to fetch content", e);
}
}
}
loadCacheOrFetch();
}, [link]);
return (
content && (
<WebView
style={{
flex: 1,
}}
source={{
uri: content,
baseUrl: FileSystem.documentDirectory,
}}
scalesPageToFit
originWhitelist={["*"]}
mixedContentMode="always"
javaScriptEnabled={true}
allowFileAccess={true}
allowFileAccessFromFileURLs={true}
allowUniversalAccessFromFileURLs={true}
onLoadEnd={() => setIsLoading(false)}
/>
)
);
}

View File

@@ -1,7 +1,18 @@
import { View, Text, Image, Pressable, Platform, Alert } from "react-native";
import {
View,
Text,
Image,
Pressable,
Platform,
Alert,
ActivityIndicator,
Linking,
} from "react-native";
import { decode } from "html-entities";
import { LinkIncludingShortenedCollectionAndTags } from "@linkwarden/types";
import { ArchivedFormat } from "@linkwarden/types";
import getFormatBasedOnPreference from "@linkwarden/lib/getFormatBasedOnPreference";
import getOriginalFormat from "@linkwarden/lib/getOriginalFormat";
import {
atLeastOneFormatAvailable,
formatAvailable,
@@ -18,6 +29,8 @@ import { rawTheme, ThemeName } from "@/lib/colors";
import { useColorScheme } from "nativewind";
import { CalendarDays, Folder } from "lucide-react-native";
import useDataStore from "@/store/data";
import { useEffect, useState } from "react";
import { deleteLinkCache } from "@/lib/cache";
type Props = {
link: LinkIncludingShortenedCollectionAndTags;
@@ -34,15 +47,17 @@ const LinkListing = ({ link, dashboard }: Props) => {
const deleteLink = useDeleteLink(auth);
let shortendURL;
const [url, setUrl] = useState("");
try {
if (link.url) {
shortendURL = new URL(link.url).host.toLowerCase();
useEffect(() => {
try {
if (link.url) {
setUrl(new URL(link.url).host.toLowerCase());
}
} catch (error) {
console.log(error);
}
} catch (error) {
console.log(error);
}
}, [link]);
return (
<ContextMenu.Root>
@@ -55,13 +70,27 @@ const LinkListing = ({ link, dashboard }: Props) => {
dashboard && "rounded-xl"
)}
onLongPress={() => {}}
onPress={() =>
router.push(
data.preferredFormat
? `/links/${link.id}?format=${data.preferredFormat}`
: `/links/${link.id}`
)
}
onPress={() => {
if (user) {
const format = getFormatBasedOnPreference({
link,
preference: user.linksRouteTo,
});
data.preferredBrowser === "app"
? router.navigate(
format !== null
? `/links/${link.id}?format=${format}`
: `/links/${link.id}`
)
: Linking.openURL(
format !== null
? auth.instance +
`/preserved/${link?.id}?format=${format}`
: (link.url as string)
);
}
}}
android_ripple={{
color: colorScheme === "dark" ? "rgba(255,255,255,0.2)" : "#ddd",
borderless: false,
@@ -81,12 +110,12 @@ const LinkListing = ({ link, dashboard }: Props) => {
{decode(link.name || link.description || link.url)}
</Text>
{shortendURL && (
{url && (
<Text
numberOfLines={1}
className="mt-1.5 font-light text-sm text-base-content"
>
{shortendURL}
{url}
</Text>
)}
@@ -117,6 +146,11 @@ const LinkListing = ({ link, dashboard }: Props) => {
}}
className="rounded-md h-[60px] w-[90px] object-cover scale-105"
/>
) : !link.preview ? (
<ActivityIndicator
size="small"
className="h-[60px] w-[90px]"
/>
) : (
<View className="h-[60px] w-[90px]" />
)}
@@ -144,21 +178,39 @@ const LinkListing = ({ link, dashboard }: Props) => {
<ContextMenu.Content avoidCollisions>
<ContextMenu.Item
key="open-link"
onSelect={() => router.push(`/links/${link.id}`)}
>
<ContextMenu.ItemTitle>Open Link</ContextMenu.ItemTitle>
</ContextMenu.Item>
key="open-original"
onSelect={() => {
if (link) {
const format = getOriginalFormat(link);
{link.url && (
<ContextMenu.Item
key="copy-url"
onSelect={async () => {
await Clipboard.setStringAsync(link.url as string);
}}
>
<ContextMenu.ItemTitle>Copy URL</ContextMenu.ItemTitle>
</ContextMenu.Item>
data.preferredBrowser === "app"
? router.navigate(
format !== null
? `/links/${link.id}?format=${format}`
: `/links/${link.id}`
)
: Linking.openURL(
format !== null
? auth.instance +
`/preserved/${link?.id}?format=${format}`
: (link.url as string)
);
}
}}
>
<ContextMenu.ItemTitle>Open Original</ContextMenu.ItemTitle>
</ContextMenu.Item>
{link?.url && (
<>
<ContextMenu.Item
key="copy-url"
onSelect={async () => {
await Clipboard.setStringAsync(link.url as string);
}}
>
<ContextMenu.ItemTitle>Copy URL</ContextMenu.ItemTitle>
</ContextMenu.Item>
</>
)}
<ContextMenu.Item
@@ -201,7 +253,7 @@ const LinkListing = ({ link, dashboard }: Props) => {
<ContextMenu.Item
key="preserved-formats-webpage"
onSelect={() =>
router.push(
router.navigate(
`/links/${link.id}?format=${ArchivedFormat.monolith}`
)
}
@@ -213,7 +265,7 @@ const LinkListing = ({ link, dashboard }: Props) => {
<ContextMenu.Item
key="preserved-formats-screenshot"
onSelect={() =>
router.push(
router.navigate(
`/links/${link.id}?format=${
link.image?.endsWith(".png")
? ArchivedFormat.png
@@ -229,7 +281,7 @@ const LinkListing = ({ link, dashboard }: Props) => {
<ContextMenu.Item
key="preserved-formats-pdf"
onSelect={() =>
router.push(
router.navigate(
`/links/${link.id}?format=${ArchivedFormat.pdf}`
)
}
@@ -241,7 +293,7 @@ const LinkListing = ({ link, dashboard }: Props) => {
<ContextMenu.Item
key="preserved-formats-readable"
onSelect={() =>
router.push(
router.navigate(
`/links/${link.id}?format=${ArchivedFormat.readability}`
)
}
@@ -268,7 +320,11 @@ const LinkListing = ({ link, dashboard }: Props) => {
text: "Delete",
style: "destructive",
onPress: () => {
deleteLink.mutate(link.id as number);
deleteLink.mutate(link.id as number, {
onSuccess: async () => {
await deleteLinkCache(link.id as number);
},
});
},
},
]

View File

@@ -0,0 +1,87 @@
import {
View,
FlatList,
Text,
ActivityIndicator,
ViewToken,
} from "react-native";
import LinkListing from "@/components/LinkListing";
import React, { useState } from "react";
import { LinkIncludingShortenedCollectionAndTags } from "@linkwarden/types";
import Spinner from "@/components/ui/Spinner";
import { rawTheme, ThemeName } from "@/lib/colors";
import { useColorScheme } from "nativewind";
const RenderItem = React.memo(
({ item }: { item: LinkIncludingShortenedCollectionAndTags }) => {
return <LinkListing link={item} />;
}
);
type Props = {
links: LinkIncludingShortenedCollectionAndTags[];
data: any;
};
export default function Links({ links, data }: Props) {
const { colorScheme } = useColorScheme();
const [promptedRefetch, setPromptedRefetch] = useState(false);
return data.isLoading ? (
<View className="flex justify-center h-screen items-center">
<ActivityIndicator size="large" />
<Text className="text-base mt-2.5 text-neutral">Loading...</Text>
</View>
) : (
<FlatList
contentInsetAdjustmentBehavior="automatic"
ListHeaderComponent={() => <></>}
data={links || []}
refreshControl={
<Spinner
refreshing={data.isRefetching && promptedRefetch}
onRefresh={async () => {
setPromptedRefetch(true);
await data.refetch();
setPromptedRefetch(false);
}}
progressBackgroundColor={
rawTheme[colorScheme as ThemeName]["base-200"]
}
colors={[rawTheme[colorScheme as ThemeName]["base-content"]]}
/>
}
refreshing={data.isRefetching && promptedRefetch}
initialNumToRender={4}
keyExtractor={(item) => item.id?.toString() || ""}
renderItem={({ item }) => (
<RenderItem item={item} key={item.id?.toString()} />
)}
onEndReached={() => data.fetchNextPage()}
onEndReachedThreshold={0.5}
ItemSeparatorComponent={() => (
<View className="bg-neutral-content h-px" />
)}
ListEmptyComponent={
<View className="flex justify-center py-10 items-center">
<Text className="text-center text-xl text-neutral">
Nothing found...
</Text>
</View>
}
onViewableItemsChanged={({
viewableItems,
}: {
viewableItems: ViewToken[];
}) => {
const links = viewableItems.map(
(e) => e.item
) as LinkIncludingShortenedCollectionAndTags[];
if (!data.isRefetching && links.some((e) => e.id && !e.preview))
data.refetch();
}}
viewabilityConfig={{ itemVisiblePercentThreshold: 50 }}
/>
);
}

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

@@ -0,0 +1,120 @@
import { View, Text, Pressable, Platform, Alert } from "react-native";
import { decode } from "html-entities";
import { TagIncludingLinkCount } from "@linkwarden/types";
import useAuthStore from "@/store/auth";
import { useRouter } from "expo-router";
import * as ContextMenu from "zeego/context-menu";
import { cn } from "@linkwarden/lib/utils";
import { rawTheme, ThemeName } from "@/lib/colors";
import { useColorScheme } from "nativewind";
import { CalendarDays, Hash, Link } from "lucide-react-native";
import { useRemoveTag } from "@linkwarden/router/tags";
type Props = {
tag: TagIncludingLinkCount;
};
const TagListing = ({ tag }: Props) => {
const { auth } = useAuthStore();
const router = useRouter();
const { colorScheme } = useColorScheme();
const deleteCollection = useRemoveTag(auth);
return (
<ContextMenu.Root>
<ContextMenu.Trigger asChild>
<Pressable
className={cn(
"p-5 flex-row justify-between",
"bg-base-100",
Platform.OS !== "android" && "active:bg-base-200/50"
)}
onLongPress={() => {}}
onPress={() => router.navigate(`/tags/${tag.id}`)}
android_ripple={{
color: colorScheme === "dark" ? "rgba(255,255,255,0.2)" : "#ddd",
borderless: false,
}}
>
<View className="w-full">
<View className="w-[90%] flex-col justify-between gap-3">
<View className="flex flex-row gap-2 items-center pr-1.5 self-start rounded-md">
<Hash
size={16}
color={rawTheme[colorScheme as ThemeName]["primary"]}
/>
<Text
numberOfLines={2}
className="font-medium text-lg text-base-content"
>
{decode(tag.name)}
</Text>
</View>
</View>
<View className="flex-row gap-3">
<View className="flex flex-row gap-1 items-center mt-5 self-start">
<CalendarDays
size={16}
color={rawTheme[colorScheme as ThemeName]["neutral"]}
/>
<Text
numberOfLines={1}
className="font-light text-xs text-base-content"
>
{new Date(tag.createdAt).toLocaleString("en-US", {
year: "numeric",
month: "short",
day: "numeric",
})}
</Text>
</View>
<View className="flex flex-row gap-1 items-center mt-5 self-start">
<Link
size={16}
color={rawTheme[colorScheme as ThemeName]["neutral"]}
/>
<Text
numberOfLines={1}
className="font-light text-xs text-base-content"
>
{tag._count?.links}
</Text>
</View>
</View>
</View>
</Pressable>
</ContextMenu.Trigger>
<ContextMenu.Content avoidCollisions>
<ContextMenu.Item
key="delete-tag"
onSelect={() => {
return Alert.alert(
"Delete Tag",
"Are you sure you want to delete this Tag? This action cannot be undone.",
[
{
text: "Cancel",
style: "cancel",
},
{
text: "Delete",
style: "destructive",
onPress: () => {
deleteCollection.mutate(tag.id as number);
},
},
]
);
}}
>
<ContextMenu.ItemTitle>Delete</ContextMenu.ItemTitle>
</ContextMenu.Item>
</ContextMenu.Content>
</ContextMenu.Root>
);
};
export default TagListing;

View File

@@ -0,0 +1,21 @@
import React from "react";
import Svg, { Path, Circle, SvgProps } from "react-native-svg";
export const Chromium = (props: SvgProps) => (
<Svg
width={21}
height={21}
viewBox="0 0 24 24"
fill="none"
strokeWidth={2}
strokeLinecap="round"
strokeLinejoin="round"
{...props}
>
<Path d="M10.88 21.94 15.46 14" />
<Path d="M21.17 8H12" />
<Path d="M3.95 6.06 8.54 14" />
<Circle cx={12} cy={12} r={10} />
<Circle cx={12} cy={12} r={4} />
</Svg>
);

41
apps/mobile/eas.json Normal file
View File

@@ -0,0 +1,41 @@
{
"cli": {
"version": ">= 16.20.1",
"appVersionSource": "remote"
},
"build": {
"preview": {
"android": {
"buildType": "apk"
}
},
"development": {
"developmentClient": true,
"distribution": "internal"
},
"simulator": {
"extends": "development",
"ios": {
"simulator": true
}
},
"production": {
"corepack": true,
"distribution": "store",
"autoIncrement": true,
"channel": "production"
}
},
"submit": {
"production": {
"ios": {
"ascAppId": "6752550960"
},
"android": {
"serviceAccountKeyPath": "./service-account-file.json",
"track": "internal",
"releaseStatus": "draft"
}
}
}
}

33
apps/mobile/lib/cache.ts Normal file
View File

@@ -0,0 +1,33 @@
import * as FileSystem from "expo-file-system";
export const clearCache = async () => {
await Promise.all([
FileSystem.deleteAsync(FileSystem.documentDirectory + "archivedData", {
idempotent: true,
}),
FileSystem.deleteAsync(FileSystem.documentDirectory + "mmkv", {
idempotent: true,
}),
]);
};
export const deleteLinkCache = async (linkId: number) => {
const readablePath =
FileSystem.documentDirectory + `archivedData/readable/link_${linkId}.html`;
const webpagePath =
FileSystem.documentDirectory + `archivedData/webpage/link_${linkId}.html`;
const jpegPath =
FileSystem.documentDirectory + `archivedData/jpeg/link_${linkId}.jpeg`;
const pngPath =
FileSystem.documentDirectory + `archivedData/png/link_${linkId}.png`;
const pdfPath =
FileSystem.documentDirectory + `archivedData/pdf/link_${linkId}.pdf`;
await Promise.all([
FileSystem.deleteAsync(readablePath, { idempotent: true }),
FileSystem.deleteAsync(webpagePath, { idempotent: true }),
FileSystem.deleteAsync(jpegPath, { idempotent: true }),
FileSystem.deleteAsync(pngPath, { idempotent: true }),
FileSystem.deleteAsync(pdfPath, { idempotent: true }),
]);
};

View File

@@ -0,0 +1,14 @@
import { QueryClient } from "@tanstack/react-query";
const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 1000 * 60 * 60 * 24,
refetchOnMount: true,
refetchOnWindowFocus: false,
refetchOnReconnect: false,
},
},
});
export { queryClient };

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",
@@ -16,6 +16,7 @@
"dependencies": {
"@expo/vector-icons": "^14.0.2",
"@linkwarden/lib": "*",
"@linkwarden/prisma": "*",
"@linkwarden/react-native-render-html": "^6.3.4",
"@linkwarden/router": "*",
"@linkwarden/types": "*",
@@ -30,6 +31,7 @@
"expo": "~52.0.18",
"expo-application": "~6.0.2",
"expo-blur": "~14.0.1",
"expo-build-properties": "~0.13.3",
"expo-clipboard": "~7.0.1",
"expo-constants": "~17.0.3",
"expo-dev-client": "~5.0.6",
@@ -44,6 +46,7 @@
"expo-status-bar": "~2.0.0",
"expo-symbols": "~0.2.0",
"expo-system-ui": "~4.0.6",
"expo-updates": "~0.27.4",
"expo-web-browser": "~14.0.1",
"html-entities": "^2.6.0",
"lucide-react-native": "^0.536.0",
@@ -52,10 +55,14 @@
"react-dom": "18.3.1",
"react-native": "0.76.9",
"react-native-actions-sheet": "^0.9.7",
"react-native-blob-util": "^0.23.2",
"react-native-edge-to-edge": "^1.7.0",
"react-native-gesture-handler": "~2.20.2",
"react-native-ios-context-menu": "3.1.0",
"react-native-ios-utilities": "5.1.2",
"react-native-ios-context-menu": "3.1.3",
"react-native-ios-utilities": "5.1.7",
"react-native-keyboard-controller": "^1.19.0",
"react-native-mmkv": "^3.2.0",
"react-native-pdf": "^7.0.3",
"react-native-reanimated": "3.16.2",
"react-native-safe-area-context": "4.12.0",
"react-native-screens": "~4.1.0",
@@ -69,7 +76,7 @@
"devDependencies": {
"@babel/core": "^7.25.2",
"@types/jest": "^29.5.12",
"@types/react": "~18.3.12",
"@types/react": "18.3.1",
"@types/react-test-renderer": "^18.3.0",
"jest": "^29.2.1",
"jest-expo": "~52.0.2",

View File

@@ -3,6 +3,9 @@ import * as SecureStore from "expo-secure-store";
import { router } from "expo-router";
import { MobileAuth } from "@linkwarden/types";
import { Alert } from "react-native";
import { queryClient } from "@/lib/queryClient";
import { mmkvPersister } from "@/lib/queryPersister";
import { clearCache } from "@/lib/cache";
type AuthStore = {
auth: MobileAuth;
@@ -10,10 +13,10 @@ type AuthStore = {
username: string,
password: string,
instance: string,
token: string
) => void;
signOut: () => void;
setAuth: () => void;
token?: string
) => Promise<void>;
signOut: () => Promise<void>;
setAuth: () => Promise<void>;
};
const useAuthStore = create<AuthStore>((set) => ({
@@ -72,37 +75,54 @@ const useAuthStore = create<AuthStore>((set) => ({
}
});
} 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 () => {
await SecureStore.deleteItemAsync("TOKEN");
await SecureStore.deleteItemAsync("INSTANCE");
queryClient.cancelQueries();
queryClient.clear();
mmkvPersister.removeClient?.();
await clearCache();
set({
auth: {
instance: "",

View File

@@ -15,13 +15,13 @@ const useDataStore = create<DataStore>((set, get) => ({
hasShareIntent: false,
url: "",
},
theme: "light",
preferredFormat: null,
theme: "system",
preferredBrowser: "app",
},
setData: async () => {
const dataString = JSON.parse((await AsyncStorage.getItem("data")) || "{}");
colorScheme.set(dataString.theme || "light");
colorScheme.set(dataString.theme || "system");
if (dataString)
set((state) => ({ data: { ...state.data, ...dataString } }));

26
apps/mobile/store/tmp.ts Normal file
View File

@@ -0,0 +1,26 @@
import { create } from "zustand";
import { LinkIncludingShortenedCollectionAndTags } from "@linkwarden/types";
import { User } from "@linkwarden/prisma/client";
type Tmp = {
link: LinkIncludingShortenedCollectionAndTags | null;
user: Pick<User, "id"> | null;
};
type TmpStore = {
tmp: Tmp;
updateTmp: (newData: Partial<Tmp>) => void;
};
const useTmpStore = create<TmpStore>((set, get) => ({
tmp: {
link: null,
user: null,
},
updateTmp: async (patch) => {
const merged = { ...get().tmp, ...patch };
set({ tmp: merged });
},
}));
export default useTmpStore;

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

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

@@ -30,6 +30,7 @@ import {
} from "@dnd-kit/core";
import { restrictToParentElement } from "@dnd-kit/modifiers";
import { cn } from "@linkwarden/lib";
import toast from "react-hot-toast";
interface DashboardSectionOption {
type: DashboardSectionType;
@@ -185,7 +186,13 @@ export default function DashboardLayoutDropdown() {
return s;
});
updateDashboardLayout.mutateAsync(updatedSections);
updateDashboardLayout.mutateAsync(updatedSections, {
onSettled: (data, error) => {
if (error) {
toast.error(error.message);
}
},
});
};
const handleReorder = (sourceId: string, destId: string) => {
@@ -217,7 +224,13 @@ export default function DashboardLayoutDropdown() {
const disabledSections = filteredSections.filter((s) => !s.enabled);
const updated = [...reorderedWithNewOrders, ...disabledSections];
updateDashboardLayout.mutateAsync(updated);
updateDashboardLayout.mutateAsync(updated, {
onSettled: (data, error) => {
if (error) {
toast.error(error.message);
}
},
});
};
const handleDragEnd = (event: DragEndEvent) => {

View File

@@ -27,6 +27,7 @@ import LinkPin from "./LinkViews/LinkComponents/LinkPin";
import { Separator } from "./ui/separator";
import { useDraggable } from "@dnd-kit/core";
import { cn } from "@linkwarden/lib";
import { useTranslation } from "next-i18next";
export function DashboardLinks({
links,
@@ -63,10 +64,13 @@ type Props = {
};
export function Card({ link, editMode, dashboardType }: Props) {
const { t } = useTranslation();
const { attributes, listeners, setNodeRef, isDragging } = useDraggable({
id: `${link.id}-${dashboardType}`,
data: {
linkId: link.id,
link,
dashboardType,
},
});
@@ -85,16 +89,6 @@ export function Card({ link, editMode, dashboardType }: Props) {
const { refetch } = useGetLink({ id: link.id as number, isPublicRoute });
let shortendURL;
try {
if (link.url) {
shortendURL = new URL(link.url).host.toLowerCase();
}
} catch (error) {
console.log(error);
}
const [collection, setCollection] =
useState<CollectionIncludingMembersAndLinkCount>(
collections.find(
@@ -173,6 +167,7 @@ export function Card({ link, editMode, dashboardType }: Props) {
const target = e.target as HTMLElement;
target.style.display = "none";
}}
unoptimized
/>
) : link.preview === "unavailable" ? (
<div className={`bg-gray-50 h-40 bg-opacity-80`}></div>
@@ -216,7 +211,11 @@ export function Card({ link, editMode, dashboardType }: Props) {
<div className="flex justify-between items-center text-xs text-neutral px-3 pb-1 gap-2">
{show.collection && !isPublicRoute && (
<div className="cursor-pointer truncate">
<LinkCollection link={link} collection={collection} />
<LinkCollection
link={link}
collection={collection}
isPublicRoute={false}
/>
</div>
)}
{show.date && <LinkDate link={link} />}
@@ -230,7 +229,7 @@ export function Card({ link, editMode, dashboardType }: Props) {
<div className="absolute pointer-events-none top-0 left-0 right-0 bottom-0 bg-base-100 bg-opacity-0 group-hover:bg-opacity-20 group-focus-within:opacity-20 rounded-xl duration-100"></div>
<LinkActions
link={link}
collection={collection}
t={t}
linkModal={linkModal}
setLinkModal={(e) => setLinkModal(e)}
className="absolute top-3 right-3 group-hover:opacity-100 group-focus-within:opacity-100 opacity-0 duration-100 text-neutral z-20"

View File

@@ -15,9 +15,11 @@ import { LinkIncludingShortenedCollectionAndTags } from "@linkwarden/types";
import toast from "react-hot-toast";
import { useUpdateLink } from "@linkwarden/router/links";
import { useTranslation } from "react-i18next";
import { restrictToWindowEdges, snapCenterToCursor } from "@dnd-kit/modifiers";
import { snapCenterToCursor } from "@dnd-kit/modifiers";
import { customCollisionDetectionAlgorithm } from "@/lib/utils";
import { useUpdateTag } from "@linkwarden/router/tags";
import usePinLink from "@/lib/client/pinLink";
import { useQueryClient } from "@tanstack/react-query";
import { useUser } from "@linkwarden/router/user";
interface DragNDropProps {
children: React.ReactNode;
@@ -28,7 +30,6 @@ interface DragNDropProps {
/**
* All links available for drag and drop
*/
links: LinkIncludingShortenedCollectionAndTags[];
setActiveLink: (link: LinkIncludingShortenedCollectionAndTags | null) => void;
/**
* Override the default sensors used for drag and drop.
@@ -47,14 +48,15 @@ interface DragNDropProps {
export default function DragNDrop({
children,
activeLink,
links,
setActiveLink,
sensors: sensorProp,
onDragEnd: onDragEndProp,
}: DragNDropProps) {
const { t } = useTranslation();
const updateTag = useUpdateTag();
const updateLink = useUpdateLink();
const pinLink = usePinLink();
const { data: user } = useUser();
const queryClient = useQueryClient();
const mouseSensor = useSensor(MouseSensor, {
// Require the mouse to move by 10 pixels before activating
activationConstraint: {
@@ -72,10 +74,10 @@ export default function DragNDrop({
const sensors = useSensors(mouseSensor, touchSensor);
const handleDragStart = (event: DragStartEvent) => {
const draggedLink = links.find(
(link: any) => link.id === event.active.data.current?.linkId
setActiveLink(
(event.active.data.current
?.link as LinkIncludingShortenedCollectionAndTags) ?? null
);
setActiveLink(draggedLink || null);
};
const handleDragOverCancel = () => {
@@ -83,70 +85,169 @@ 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 }
) => {
const load = toast.loading(t("updating"));
await updateLink.mutateAsync(updatedLink, {
onSettled: async (_, error) => {
toast.dismiss(load);
if (error) {
if (
opts?.invalidateDashboardOnError &&
typeof queryClient !== "undefined"
) {
await queryClient.invalidateQueries({
queryKey: ["dashboardData"],
});
}
toast.error(error.message);
} else {
toast.success(t("updated"));
}
},
});
};
// DROP ON TAG
if (overData?.type === "tag") {
const tagName = overData?.name as string | undefined;
if (!tagName) return;
const isTagAlreadyExists = activeLink.tags?.some(
(tag) => tag.name === tagName
);
if (isTagAlreadyExists) {
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

@@ -187,6 +187,7 @@ export default function LinkDetails({
const target = e.target as HTMLElement;
target.style.display = "none";
}}
unoptimized
/>
) : link.preview === "unavailable" ? (
<div className="bg-gray-50 duration-100 h-40"></div>

View File

@@ -4,7 +4,6 @@ import ViewDropdown from "./ViewDropdown";
import { TFunction } from "i18next";
import BulkDeleteLinksModal from "./ModalContent/BulkDeleteLinksModal";
import BulkEditLinksModal from "./ModalContent/BulkEditLinksModal";
import useCollectivePermissions from "@/hooks/useCollectivePermissions";
import { useRouter } from "next/router";
import useLinkStore from "@/store/links";
import {
@@ -46,7 +45,8 @@ const LinkListOptions = ({
setEditMode,
links,
}: Props) => {
const { selectedLinks, setSelectedLinks } = useLinkStore();
const { selectedIds, setSelected, clearSelected, selectionCount } =
useLinkStore();
const deleteLinksById = useBulkDeleteLinks();
const refreshPreservations = useArchiveAction();
@@ -62,45 +62,42 @@ const LinkListOptions = ({
if (editMode && setEditMode) return setEditMode(false);
}, [router]);
const collectivePermissions = useCollectivePermissions(
selectedLinks.map((link) => link.collectionId as number)
);
const handleSelectAll = () => {
if (selectedLinks.length === links.length) {
setSelectedLinks([]);
if (selectionCount === links.length) {
clearSelected();
} else {
setSelectedLinks(links.map((link) => link));
setSelected(links.map((link) => link.id as number));
}
};
const bulkDeleteLinks = async () => {
const load = toast.loading(t("deleting"));
await deleteLinksById.mutateAsync(
selectedLinks.map((link) => link.id as number),
{
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";
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();

View File

@@ -3,8 +3,7 @@ import {
CollectionIncludingMembersAndLinkCount,
LinkIncludingShortenedCollectionAndTags,
} from "@linkwarden/types";
import { useEffect, useMemo, useRef, useState } from "react";
import useLinkStore from "@/store/links";
import React, { useRef, useState } from "react";
import unescapeString from "@/lib/client/unescapeString";
import LinkActions from "@/components/LinkViews/LinkComponents/LinkActions";
import LinkDate from "@/components/LinkViews/LinkComponents/LinkDate";
@@ -15,15 +14,8 @@ import {
formatAvailable,
} from "@linkwarden/lib/formatStats";
import LinkIcon from "./LinkIcon";
import useOnScreen from "@/hooks/useOnScreen";
import usePermissions from "@/hooks/usePermissions";
import toast from "react-hot-toast";
import LinkTypeBadge from "./LinkTypeBadge";
import { useTranslation } from "next-i18next";
import { useCollections } from "@linkwarden/router/collections";
import { useUser } from "@linkwarden/router/user";
import { useGetLink, useLinks } from "@linkwarden/router/links";
import { useRouter } from "next/router";
import useLocalSettingsStore from "@/store/localSettings";
import LinkPin from "./LinkPin";
import LinkFormats from "./LinkFormats";
@@ -31,156 +23,68 @@ import openLink from "@/lib/client/openLink";
import { Separator } from "@/components/ui/separator";
import { useDraggable } from "@dnd-kit/core";
import { cn } from "@/lib/utils";
import useMediaQuery from "@/hooks/useMediaQuery";
import { TFunction } from "i18next";
type Props = {
link: LinkIncludingShortenedCollectionAndTags;
columns: number;
className?: string;
collection: CollectionIncludingMembersAndLinkCount;
isPublicRoute: boolean;
t: TFunction<"translation", undefined>;
user: any;
disableDraggable: boolean;
isSelected: boolean;
toggleSelected: (id: number) => void;
imageHeightClass: string;
editMode?: boolean;
};
export default function LinkCard({ link, columns, editMode }: Props) {
const { t } = useTranslation();
// we don't want to use the draggable feature for screen under 1023px since the sidebar is hidden
const isSmallScreen = useMediaQuery("(max-width: 1023px)");
function LinkCard({
link,
collection,
isPublicRoute,
t,
user,
disableDraggable,
isSelected,
toggleSelected,
imageHeightClass,
editMode,
}: Props) {
const { attributes, listeners, setNodeRef, isDragging } = useDraggable({
id: link.id?.toString() ?? "",
data: {
linkId: link.id,
link,
},
disabled: isSmallScreen,
disabled: disableDraggable,
});
const heightMap = {
1: "h-44",
2: "h-40",
3: "h-36",
4: "h-32",
5: "h-28",
6: "h-24",
7: "h-20",
8: "h-20",
};
const imageHeightClass = useMemo(
() => (columns ? heightMap[columns as keyof typeof heightMap] : "h-40"),
[columns]
);
const { data: collections = [] } = useCollections();
const { data: user } = useUser();
const { setSelectedLinks, selectedLinks } = useLinkStore();
const {
settings: { show },
} = useLocalSettingsStore();
const { links } = useLinks();
const router = useRouter();
const isPublicRoute = router.pathname.startsWith("/public") ? true : false;
const { refetch } = useGetLink({ id: link.id as number, isPublicRoute });
useEffect(() => {
if (!editMode) {
setSelectedLinks([]);
}
}, [editMode]);
const handleCheckboxClick = (
link: LinkIncludingShortenedCollectionAndTags
) => {
if (selectedLinks.includes(link)) {
setSelectedLinks(selectedLinks.filter((e) => e !== link));
} else {
setSelectedLinks([...selectedLinks, link]);
}
};
let shortendURL;
try {
if (link.url) {
shortendURL = new URL(link.url).host.toLowerCase();
}
} catch (error) {
console.log(error);
}
const [collection, setCollection] =
useState<CollectionIncludingMembersAndLinkCount>(
collections.find(
(e) => e.id === link.collection.id
) as CollectionIncludingMembersAndLinkCount
);
useEffect(() => {
setCollection(
collections.find(
(e) => e.id === link.collection.id
) as CollectionIncludingMembersAndLinkCount
);
}, [collections, links]);
const ref = useRef<HTMLDivElement>(null);
const isVisible = useOnScreen(ref);
const permissions = usePermissions(collection?.id as number);
const [linkModal, setLinkModal] = useState(false);
useEffect(() => {
let interval: NodeJS.Timeout | null = null;
if (
isVisible &&
!link.preview?.startsWith("archives") &&
link.preview !== "unavailable"
) {
interval = setInterval(async () => {
refetch().catch((error) => {
console.error("Error refetching link:", error);
});
}, 5000);
}
return () => {
if (interval) {
clearInterval(interval);
}
};
}, [isVisible, link.preview]);
const isLinkSelected = selectedLinks.some(
(selectedLink) => selectedLink.id === link.id
);
const selectable =
editMode &&
(permissions === true || permissions?.canCreate || permissions?.canDelete);
return (
<div
ref={setNodeRef}
className={cn(
"border border-solid border-neutral-content bg-base-200 shadow-md hover:shadow-none duration-100 rounded-xl relative group",
isLinkSelected && "border-primary bg-base-300",
isSelected && "border-primary bg-base-300",
isDragging ? "opacity-30" : "opacity-100",
"relative group touch-manipulation select-none"
)}
onClick={() =>
selectable
? handleCheckboxClick(link)
editMode
? toggleSelected(link.id as number)
: editMode
? toast.error(t("link_selection_error"))
: undefined
}
>
<div ref={ref}>
<div ref={ref} className="h-full">
<div
className="rounded-xl cursor-pointer h-full flex flex-col justify-between"
onClick={() =>
@@ -207,6 +111,7 @@ export default function LinkCard({ link, columns, editMode }: Props) {
const target = e.target as HTMLElement;
target.style.display = "none";
}}
unoptimized
/>
) : link.preview === "unavailable" ? (
<div
@@ -250,9 +155,13 @@ export default function LinkCard({ link, columns, editMode }: Props) {
<Separator className="mb-1" />
<div className="flex justify-between items-center text-xs text-neutral px-3 pb-1 gap-2">
{show.collection && !isPublicRoute && (
{show.collection && !isPublicRoute && collection && (
<div className="cursor-pointer truncate">
<LinkCollection link={link} collection={collection} />
<LinkCollection
link={link}
collection={collection}
isPublicRoute={isPublicRoute}
/>
</div>
)}
{show.date && <LinkDate link={link} />}
@@ -266,8 +175,8 @@ export default function LinkCard({ link, columns, editMode }: Props) {
<div className="absolute pointer-events-none top-0 left-0 right-0 bottom-0 bg-base-100 bg-opacity-0 group-hover:bg-opacity-20 group-focus-within:opacity-20 rounded-xl duration-100"></div>
<LinkActions
link={link}
collection={collection}
linkModal={linkModal}
t={t}
setLinkModal={(e) => setLinkModal(e)}
className="absolute top-3 right-3 group-hover:opacity-100 group-focus-within:opacity-100 opacity-0 duration-100 text-neutral z-20"
/>
@@ -276,3 +185,5 @@ export default function LinkCard({ link, columns, editMode }: Props) {
</div>
);
}
export default React.memo(LinkCard);

View File

@@ -5,20 +5,17 @@ import {
} from "@linkwarden/types";
import { IconWeight } from "@phosphor-icons/react";
import Link from "next/link";
import { useRouter } from "next/router";
import React from "react";
export default function LinkCollection({
function LinkCollection({
link,
collection,
isPublicRoute,
}: {
link: LinkIncludingShortenedCollectionAndTags;
collection: CollectionIncludingMembersAndLinkCount;
isPublicRoute: boolean;
}) {
const router = useRouter();
const isPublicRoute = router.pathname.startsWith("/public") ? true : false;
return !isPublicRoute && collection?.name ? (
<>
<Link
@@ -47,3 +44,5 @@ export default function LinkCollection({
</>
) : null;
}
export default React.memo(LinkCollection);

View File

@@ -1,11 +1,7 @@
import { LinkIncludingShortenedCollectionAndTags } from "@linkwarden/types";
import React from "react";
export default function LinkDate({
link,
}: {
link: LinkIncludingShortenedCollectionAndTags;
}) {
function LinkDate({ link }: { link: LinkIncludingShortenedCollectionAndTags }) {
const formattedDate = new Date(
(link.importDate || link.createdAt) as string
).toLocaleString("en-US", {
@@ -21,3 +17,5 @@ export default function LinkDate({
</div>
);
}
export default React.memo(LinkDate);

View File

@@ -7,7 +7,7 @@ import { IconWeight } from "@phosphor-icons/react";
import clsx from "clsx";
import oklchVariableToHex from "@/lib/client/oklchVariableToHex";
export default function LinkIcon({
function LinkIcon({
link,
className,
hideBackground,
@@ -45,17 +45,17 @@ export default function LinkIcon({
) : link.type === "url" && url ? (
<>
<Image
src={`https://t2.gstatic.com/faviconV2?client=SOCIAL&type=FAVICON&fallback_opts=TYPE,SIZE,URL&url=${link.url}&size=64`}
src={`/api/v1/getFavicon?url=${encodeURIComponent(url.origin)}`}
width={64}
height={64}
alt=""
unoptimized
className={clsx(
iconClasses,
faviconLoaded ? "" : "absolute opacity-0"
)}
draggable="false"
onLoadingComplete={() => setFaviconLoaded(true)}
onError={() => setFaviconLoaded(false)}
onLoad={() => setFaviconLoaded(true)}
/>
{!faviconLoaded && (
<LinkPlaceholderIcon
@@ -104,3 +104,5 @@ const LinkPlaceholderIcon = ({
</div>
);
};
export default React.memo(LinkIcon);

View File

@@ -2,113 +2,61 @@ import {
CollectionIncludingMembersAndLinkCount,
LinkIncludingShortenedCollectionAndTags,
} from "@linkwarden/types";
import { useEffect, useState } from "react";
import useLinkStore from "@/store/links";
import React, { useState } from "react";
import unescapeString from "@/lib/client/unescapeString";
import LinkActions from "@/components/LinkViews/LinkComponents/LinkActions";
import LinkDate from "@/components/LinkViews/LinkComponents/LinkDate";
import LinkCollection from "@/components/LinkViews/LinkComponents/LinkCollection";
import LinkIcon from "@/components/LinkViews/LinkComponents/LinkIcon";
import { cn, isPWA } from "@/lib/utils";
import usePermissions from "@/hooks/usePermissions";
import toast from "react-hot-toast";
import LinkTypeBadge from "./LinkTypeBadge";
import { useTranslation } from "next-i18next";
import { useCollections } from "@linkwarden/router/collections";
import { useUser } from "@linkwarden/router/user";
import { useLinks } from "@linkwarden/router/links";
import useLocalSettingsStore from "@/store/localSettings";
import LinkPin from "./LinkPin";
import { useRouter } from "next/router";
import { atLeastOneFormatAvailable } from "@linkwarden/lib/formatStats";
import LinkFormats from "./LinkFormats";
import openLink from "@/lib/client/openLink";
import { useDraggable } from "@dnd-kit/core";
import useMediaQuery from "@/hooks/useMediaQuery";
import { TFunction } from "i18next";
type Props = {
link: LinkIncludingShortenedCollectionAndTags;
collection: CollectionIncludingMembersAndLinkCount;
isPublicRoute: boolean;
t: TFunction<"translation", undefined>;
disableDraggable: boolean;
user: any;
isSelected: boolean;
toggleSelected: (id: number) => void;
count: number;
className?: string;
editMode?: boolean;
};
export default function LinkCardCompact({ link, editMode }: Props) {
const { t } = useTranslation();
const isSmallScreen = useMediaQuery("(max-width: 1023px)");
function LinkList({
link,
collection,
isPublicRoute,
t,
disableDraggable,
user,
isSelected,
toggleSelected,
editMode,
}: Props) {
const { attributes, listeners, setNodeRef, isDragging } = useDraggable({
id: link.id?.toString() ?? "",
data: {
linkId: link.id,
link,
},
disabled: isSmallScreen,
disabled: disableDraggable,
});
const { data: collections = [] } = useCollections();
const { data: user } = useUser();
const { setSelectedLinks, selectedLinks } = useLinkStore();
const {
settings: { show },
} = useLocalSettingsStore();
const { links } = useLinks();
useEffect(() => {
if (!editMode) {
setSelectedLinks([]);
}
}, [editMode]);
const handleCheckboxClick = (
link: LinkIncludingShortenedCollectionAndTags
) => {
const linkIndex = selectedLinks.findIndex(
(selectedLink) => selectedLink.id === link.id
);
if (linkIndex !== -1) {
const updatedLinks = [...selectedLinks];
updatedLinks.splice(linkIndex, 1);
setSelectedLinks(updatedLinks);
} else {
setSelectedLinks([...selectedLinks, link]);
}
};
const [collection, setCollection] =
useState<CollectionIncludingMembersAndLinkCount>(
collections.find(
(e) => e.id === link.collection.id
) as CollectionIncludingMembersAndLinkCount
);
useEffect(() => {
setCollection(
collections.find(
(e) => e.id === link.collection.id
) as CollectionIncludingMembersAndLinkCount
);
}, [collections, links]);
const permissions = usePermissions(collection?.id as number);
const selectedStyle = selectedLinks.some(
(selectedLink) => selectedLink.id === link.id
)
? "border border-primary bg-base-300"
: "border-transparent";
const selectable =
editMode &&
(permissions === true || permissions?.canCreate || permissions?.canDelete);
const router = useRouter();
let isPublic = router.pathname.startsWith("/public") ? true : undefined;
const [linkModal, setLinkModal] = useState(false);
return (
@@ -117,14 +65,16 @@ export default function LinkCardCompact({ link, editMode }: Props) {
ref={setNodeRef}
className={cn(
"rounded-md border relative group items-center flex",
selectedStyle,
isSelected
? "border border-primary bg-base-300"
: "border-transparent",
!isPWA() ? "hover:bg-base-300 px-2 py-1" : "py-1",
isDragging ? "opacity-30" : "opacity-100",
"duration-200, touch-manipulation select-none"
)}
onClick={() =>
selectable
? handleCheckboxClick(link)
editMode
? toggleSelected(link.id as number)
: editMode
? toast.error(t("link_selection_error"))
: undefined
@@ -163,19 +113,23 @@ export default function LinkCardCompact({ link, editMode }: Props) {
<div className="mt-1 flex flex-col sm:flex-row sm:items-center gap-2 text-xs text-neutral">
<div className="flex items-center gap-x-3 text-neutral flex-wrap">
{show.link && <LinkTypeBadge link={link} />}
{show.collection && (
<LinkCollection link={link} collection={collection} />
{show.collection && collection && (
<LinkCollection
link={link}
collection={collection}
isPublicRoute={isPublicRoute}
/>
)}
{show.date && <LinkDate link={link} />}
</div>
</div>
</div>
</div>
{!isPublic && <LinkPin link={link} />}
{!isPublicRoute && <LinkPin link={link} />}
<LinkActions
link={link}
collection={collection}
linkModal={linkModal}
t={t}
setLinkModal={(e) => setLinkModal(e)}
className="absolute top-3 right-3 group-hover:opacity-100 group-focus-within:opacity-100 opacity-0 duration-100 text-neutral z-20"
/>
@@ -184,3 +138,5 @@ export default function LinkCardCompact({ link, editMode }: Props) {
</>
);
}
export default React.memo(LinkList);

View File

@@ -3,8 +3,7 @@ import {
CollectionIncludingMembersAndLinkCount,
LinkIncludingShortenedCollectionAndTags,
} from "@linkwarden/types";
import { useEffect, useMemo, useRef, useState } from "react";
import useLinkStore from "@/store/links";
import React, { useRef, useState } from "react";
import unescapeString from "@/lib/client/unescapeString";
import LinkActions from "@/components/LinkViews/LinkComponents/LinkActions";
import LinkDate from "@/components/LinkViews/LinkComponents/LinkDate";
@@ -16,152 +15,58 @@ import {
} from "@linkwarden/lib/formatStats";
import Link from "next/link";
import LinkIcon from "./LinkIcon";
import useOnScreen from "@/hooks/useOnScreen";
import usePermissions from "@/hooks/usePermissions";
import toast from "react-hot-toast";
import LinkTypeBadge from "./LinkTypeBadge";
import { useTranslation } from "next-i18next";
import { useCollections } from "@linkwarden/router/collections";
import { useUser } from "@linkwarden/router/user";
import { useGetLink, useLinks } from "@linkwarden/router/links";
import useLocalSettingsStore from "@/store/localSettings";
import clsx from "clsx";
import LinkPin from "./LinkPin";
import { useRouter } from "next/router";
import LinkFormats from "./LinkFormats";
import openLink from "@/lib/client/openLink";
import { Button } from "@/components/ui/button";
import { Separator } from "@/components/ui/separator";
import { useDraggable } from "@dnd-kit/core";
import useMediaQuery from "@/hooks/useMediaQuery";
import { cn } from "@linkwarden/lib";
import { TFunction } from "i18next";
type Props = {
link: LinkIncludingShortenedCollectionAndTags;
columns: number;
collection: CollectionIncludingMembersAndLinkCount;
isPublicRoute: boolean;
t: TFunction<"translation", undefined>;
disableDraggable: boolean;
user: any;
isSelected: boolean;
toggleSelected: (id: number) => void;
imageHeightClass: string;
editMode?: boolean;
};
export default function LinkMasonry({ link, editMode, columns }: Props) {
const { t } = useTranslation();
// we don't want to use the draggable feature for screen under 1023px since the sidebar is hidden
const isSmallScreen = useMediaQuery("(max-width: 1023px)");
function LinkMasonry({
link,
collection,
isPublicRoute,
t,
disableDraggable,
user,
isSelected,
toggleSelected,
imageHeightClass,
editMode,
}: Props) {
const { attributes, listeners, setNodeRef, isDragging } = useDraggable({
id: link.id?.toString() ?? "",
data: {
linkId: link.id,
link,
},
disabled: isSmallScreen,
disabled: disableDraggable,
});
const heightMap = {
1: "h-44",
2: "h-40",
3: "h-36",
4: "h-32",
5: "h-28",
6: "h-24",
7: "h-20",
8: "h-20",
};
const imageHeightClass = useMemo(
() => (columns ? heightMap[columns as keyof typeof heightMap] : "h-40"),
[columns]
);
const { data: collections = [] } = useCollections();
const { data: user } = useUser();
const { setSelectedLinks, selectedLinks } = useLinkStore();
const {
settings: { show },
} = useLocalSettingsStore();
const { links } = useLinks();
const router = useRouter();
let isPublicRoute = router.pathname.startsWith("/public") ? true : undefined;
const { refetch } = useGetLink({ id: link.id as number, isPublicRoute });
useEffect(() => {
if (!editMode) {
setSelectedLinks([]);
}
}, [editMode]);
const handleCheckboxClick = (
link: LinkIncludingShortenedCollectionAndTags
) => {
if (selectedLinks.includes(link)) {
setSelectedLinks(selectedLinks.filter((e) => e !== link));
} else {
setSelectedLinks([...selectedLinks, link]);
}
};
let shortendURL;
try {
if (link.url) {
shortendURL = new URL(link.url).host.toLowerCase();
}
} catch (error) {
console.log(error);
}
const [collection, setCollection] =
useState<CollectionIncludingMembersAndLinkCount>(
collections.find(
(e) => e.id === link.collection.id
) as CollectionIncludingMembersAndLinkCount
);
useEffect(() => {
setCollection(
collections.find(
(e) => e.id === link.collection.id
) as CollectionIncludingMembersAndLinkCount
);
}, [collections, links]);
const ref = useRef<HTMLDivElement>(null);
const isVisible = useOnScreen(ref);
const permissions = usePermissions(collection?.id as number);
useEffect(() => {
let interval: NodeJS.Timeout | null = null;
if (
isVisible &&
!link.preview?.startsWith("archives") &&
link.preview !== "unavailable"
) {
interval = setInterval(async () => {
refetch().catch((error) => {
console.error("Error refetching link:", error);
});
}, 5000);
}
return () => {
if (interval) {
clearInterval(interval);
}
};
}, [isVisible, link.preview]);
const isLinkSelected = selectedLinks.some(
(selectedLink) => selectedLink.id === link.id
);
const selectable =
editMode &&
(permissions === true || permissions?.canCreate || permissions?.canDelete);
const [linkModal, setLinkModal] = useState(false);
@@ -170,11 +75,11 @@ export default function LinkMasonry({ link, editMode, columns }: Props) {
ref={setNodeRef}
className={cn(
"border border-solid border-neutral-content bg-base-200 shadow-md hover:shadow-none duration-100 rounded-xl relative group",
isLinkSelected && "border-primary bg-base-300"
isSelected && "border-primary bg-base-300"
)}
onClick={() =>
selectable
? handleCheckboxClick(link)
editMode
? toggleSelected(link.id as number)
: editMode
? toast.error(t("link_selection_error"))
: undefined
@@ -205,6 +110,7 @@ export default function LinkMasonry({ link, editMode, columns }: Props) {
const target = e.target as HTMLElement;
target.style.display = "none";
}}
unoptimized
/>
) : link.preview === "unavailable" ? null : (
<div
@@ -268,9 +174,13 @@ export default function LinkMasonry({ link, editMode, columns }: Props) {
<Separator className="mb-1" />
<div className="flex flex-wrap justify-between items-center text-xs text-neutral px-3 pb-1 w-full gap-x-2">
{!isPublicRoute && show.collection && (
{!isPublicRoute && show.collection && collection && (
<div className="cursor-pointer truncate">
<LinkCollection link={link} collection={collection} />
<LinkCollection
link={link}
collection={collection}
isPublicRoute={isPublicRoute}
/>
</div>
)}
{show.date && <LinkDate link={link} />}
@@ -283,8 +193,8 @@ export default function LinkMasonry({ link, editMode, columns }: Props) {
<div className="absolute pointer-events-none top-0 left-0 right-0 bottom-0 bg-base-100 bg-opacity-0 group-hover:bg-opacity-20 group-focus-within:opacity-20 rounded-xl duration-100"></div>
<LinkActions
link={link}
collection={collection}
linkModal={linkModal}
t={t}
setLinkModal={(e) => setLinkModal(e)}
className="absolute top-3 right-3 group-hover:opacity-100 group-focus-within:opacity-100 opacity-0 duration-100 text-neutral z-20"
/>
@@ -293,3 +203,5 @@ export default function LinkMasonry({ link, editMode, columns }: Props) {
</div>
);
}
export default React.memo(LinkMasonry);

View File

@@ -1,20 +1,23 @@
import { LinkIncludingShortenedCollectionAndTags } from "@linkwarden/types";
import Link from "next/link";
import React, { useEffect, useState } from "react";
export default function LinkTypeBadge({
function LinkTypeBadge({
link,
}: {
link: LinkIncludingShortenedCollectionAndTags;
}) {
let shortendURL;
const [url, setUrl] = useState("");
if (link.type === "url" && link.url) {
try {
shortendURL = new URL(link.url).host.toLowerCase();
} catch (error) {
console.log(error);
useEffect(() => {
if (link.type === "url" && link.url) {
try {
setUrl(new URL(link.url).host.toLowerCase());
} catch (error) {
console.log(error);
}
}
}
}, [link]);
const typeIcon = () => {
switch (link.type) {
@@ -27,7 +30,7 @@ export default function LinkTypeBadge({
}
};
return link.url && shortendURL ? (
return link.url && url ? (
<Link
href={link.url || ""}
target="_blank"
@@ -38,7 +41,7 @@ export default function LinkTypeBadge({
className="flex gap-1 item-center select-none text-neutral hover:opacity-70 duration-100 max-w-full w-fit"
>
<i className="bi-link-45deg text-lg leading-none"></i>
<p className="text-xs truncate">{shortendURL}</p>
<p className="text-xs truncate">{url}</p>
</Link>
) : (
<div className="flex gap-1 item-center select-none text-neutral duration-100 max-w-full w-fit">
@@ -47,3 +50,5 @@ export default function LinkTypeBadge({
</div>
);
}
export default React.memo(LinkTypeBadge);

View File

@@ -1,5 +1,6 @@
import LinkCard from "@/components/LinkViews/LinkComponents/LinkCard";
import {
CollectionIncludingMembersAndLinkCount,
LinkIncludingShortenedCollectionAndTags,
ViewMode,
} from "@linkwarden/types";
@@ -7,26 +8,43 @@ import { useEffect, useState } from "react";
import { useInView } from "react-intersection-observer";
import LinkMasonry from "@/components/LinkViews/LinkComponents/LinkMasonry";
import Masonry from "react-masonry-css";
import resolveConfig from "tailwindcss/resolveConfig";
import tailwindConfig from "../../tailwind.config.js";
import { useMemo } from "react";
import LinkList from "@/components/LinkViews/LinkComponents/LinkList";
import useLocalSettingsStore from "@/store/localSettings";
import { useCollections } from "@linkwarden/router/collections";
import { useRouter } from "next/router";
import { useTranslation } from "next-i18next";
import { TFunction } from "i18next";
import useLinkStore from "@/store/links";
import useMediaQuery from "@/hooks/useMediaQuery";
import { useUser } from "@linkwarden/router/user";
export function CardView({
function CardView({
links,
collectionsById,
isPublicRoute,
t,
user,
disableDraggable,
isSelected,
toggleSelected,
editMode,
isLoading,
placeholders,
hasNextPage,
placeHolderRef,
}: {
links?: LinkIncludingShortenedCollectionAndTags[];
editMode?: boolean;
isLoading?: boolean;
placeholders?: number[];
hasNextPage?: boolean;
placeHolderRef?: any;
links: LinkIncludingShortenedCollectionAndTags[];
collectionsById: Map<number, CollectionIncludingMembersAndLinkCount>;
isPublicRoute: boolean;
t: TFunction<"translation", undefined>;
user: any;
disableDraggable: boolean;
isSelected: (id: number) => boolean;
toggleSelected: (id: number) => void;
editMode: boolean;
isLoading: boolean;
hasNextPage: boolean;
placeHolderRef: any;
}) {
const settings = useLocalSettingsStore((state) => state.settings);
@@ -59,6 +77,23 @@ export function CardView({
[columnCount]
);
const heightMap = {
1: "h-44",
2: "h-40",
3: "h-36",
4: "h-32",
5: "h-28",
6: "h-24",
7: "h-20",
8: "h-20",
};
const imageHeightClass = useMemo(
() =>
columnCount ? heightMap[columnCount as keyof typeof heightMap] : "h-40",
[columnCount]
);
useEffect(() => {
const handleResize = () => {
if (settings.columns === 0) {
@@ -82,51 +117,66 @@ export function CardView({
return (
<div className={`${gridColClass} grid gap-5 pb-5`}>
{links?.map((e, i) => {
{links?.map((e) => {
const collection = collectionsById.get(e.collection.id as number);
const selected = isSelected(e.id as number);
return (
<LinkCard
key={i}
key={e.id}
link={e}
collection={collection as CollectionIncludingMembersAndLinkCount}
isPublicRoute={isPublicRoute}
t={t}
user={user}
disableDraggable={disableDraggable}
isSelected={selected}
toggleSelected={toggleSelected}
editMode={editMode}
columns={columnCount}
imageHeightClass={imageHeightClass}
/>
);
})}
{(hasNextPage || isLoading) &&
placeholders?.map((e, i) => {
return (
<div
className="flex flex-col gap-4"
ref={e === 1 ? placeHolderRef : undefined}
key={i}
>
<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

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

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

@@ -24,10 +24,17 @@ import {
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { useUser } from "@linkwarden/router/user";
import Link from "next/link";
const STRIPE_ENABLED = process.env.NEXT_PUBLIC_STRIPE === "true";
const TRIAL_PERIOD_DAYS =
Number(process.env.NEXT_PUBLIC_TRIAL_PERIOD_DAYS) || 14;
export default function Navbar() {
const { t } = useTranslation();
const router = useRouter();
const { data: user } = useUser();
const [sidebar, setSidebar] = useState(false);
@@ -50,87 +57,126 @@ export default function Navbar() {
const [newCollectionModal, setNewCollectionModal] = useState(false);
const [uploadFileModal, setUploadFileModal] = useState(false);
const [daysLeft, setDaysLeft] = useState<number>(0);
const [isTrialing, setIsTrialing] = useState<boolean>(false);
useEffect(() => {
if (user?.createdAt) {
const trialEndTime =
new Date(user.createdAt).getTime() +
(1 + Number(TRIAL_PERIOD_DAYS)) * 86400000; // Add 1 to account for the current day
setDaysLeft(Math.floor((trialEndTime - Date.now()) / 86400000));
}
}, [user]);
useEffect(() => {
const isTrialing =
user?.id &&
!user?.subscription?.active &&
!user.parentSubscription?.active;
setIsTrialing(Boolean(isTrialing));
}, [user, daysLeft]);
return (
<div className="flex justify-between gap-2 items-center pl-3 pr-4 py-2 border-solid border-b-neutral-content border-b">
<Button
variant="ghost"
size="icon"
className="text-neutral lg:hidden sm:inline-flex"
onClick={() => {
setSidebar(true);
document.body.style.overflow = "hidden";
}}
>
<i className="bi-list text-xl leading-none" />
</Button>
<>
{STRIPE_ENABLED && isTrialing && (
<Link
href="/subscribe"
className="w-full text-sm cursor-pointer select-none bg-base-200"
>
<p className="w-full text-center flex items-center justify-center gap-1 underline decoration-dotted underline-offset-4 hover:opacity-70 duration-200 py-1 px-2">
<i className="bi-clock text-primary" />
{daysLeft === 1
? t("trial_left_singular")
: t("trial_left_plural", { count: daysLeft })}
</p>
</Link>
)}
<div className="flex justify-between gap-2 items-center pl-3 pr-4 py-2 border-solid border-b-neutral-content border-b">
<Button
variant="ghost"
size="icon"
className="text-neutral lg:hidden sm:inline-flex"
onClick={() => {
setSidebar(true);
document.body.style.overflow = "hidden";
}}
>
<i className="bi-list text-xl leading-none" />
</Button>
<SearchBar />
<SearchBar />
<div className="flex items-center gap-2">
<ToggleDarkMode hideInMobile />
<div className="flex items-center gap-2">
<ToggleDarkMode hideInMobile />
<DropdownMenu>
<DropdownMenuTrigger className="hidden sm:inline-grid">
<TooltipProvider>
<Tooltip>
<TooltipTrigger>
<Button
variant="accent"
size="sm"
className="min-w-[3.4rem] h-[2rem] relative"
>
<span>
<i className="bi-plus text-4xl absolute -top-[0.3rem] left-0 pointer-events-none" />
</span>
<span>
<i className="bi-caret-down-fill text-xs absolute top-[0.6rem] right-[0.4rem] pointer-events-none" />
</span>
</Button>
</TooltipTrigger>
<TooltipContent>
<p>{t("create_new")}</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</DropdownMenuTrigger>
<DropdownMenu>
<DropdownMenuTrigger className="hidden sm:inline-grid">
<TooltipProvider>
<Tooltip>
<TooltipTrigger>
<Button
variant="accent"
size="sm"
className="min-w-[3.4rem] h-[2rem] relative"
>
<span>
<i className="bi-plus text-4xl absolute -top-[0.3rem] left-0 pointer-events-none" />
</span>
<span>
<i className="bi-caret-down-fill text-xs absolute top-[0.6rem] right-[0.4rem] pointer-events-none" />
</span>
</Button>
</TooltipTrigger>
<TooltipContent>
<p>{t("create_new")}</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onSelect={() => setNewLinkModal(true)}>
<i className="bi-link-45deg" />
{t("new_link")}
</DropdownMenuItem>
<DropdownMenuItem onSelect={() => setUploadFileModal(true)}>
<i className="bi-file-earmark-arrow-up" />
{t("upload_file")}
</DropdownMenuItem>
<DropdownMenuItem onSelect={() => setNewCollectionModal(true)}>
<i className="bi-folder" />
{t("new_collection")}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<DropdownMenuContent align="end">
<DropdownMenuItem onSelect={() => setNewLinkModal(true)}>
<i className="bi-link-45deg" />
{t("new_link")}
</DropdownMenuItem>
<DropdownMenuItem onSelect={() => setUploadFileModal(true)}>
<i className="bi-file-earmark-arrow-up" />
{t("upload_file")}
</DropdownMenuItem>
<DropdownMenuItem onSelect={() => setNewCollectionModal(true)}>
<i className="bi-folder" />
{t("new_collection")}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<ProfileDropdown />
</div>
<MobileNavigation />
{sidebar && (
<div className="fixed top-0 bottom-0 right-0 left-0 bg-black bg-opacity-10 backdrop-blur-sm flex items-center fade-in z-40">
<ClickAwayHandler className="h-full" onClickOutside={toggleSidebar}>
<div className="slide-right h-full shadow-lg">
<Sidebar />
</div>
</ClickAwayHandler>
<ProfileDropdown />
</div>
)}
{newLinkModal && <NewLinkModal onClose={() => setNewLinkModal(false)} />}
{newCollectionModal && (
<NewCollectionModal onClose={() => setNewCollectionModal(false)} />
)}
{uploadFileModal && (
<UploadFileModal onClose={() => setUploadFileModal(false)} />
)}
</div>
<MobileNavigation />
{sidebar && (
<div className="fixed top-0 bottom-0 right-0 left-0 bg-black bg-opacity-10 backdrop-blur-sm flex items-center fade-in z-40">
<ClickAwayHandler className="h-full" onClickOutside={toggleSidebar}>
<div className="slide-right h-full shadow-lg">
<Sidebar />
</div>
</ClickAwayHandler>
</div>
)}
{newLinkModal && (
<NewLinkModal onClose={() => setNewLinkModal(false)} />
)}
{newCollectionModal && (
<NewCollectionModal onClose={() => setNewCollectionModal(false)} />
)}
{uploadFileModal && (
<UploadFileModal onClose={() => setUploadFileModal(false)} />
)}
</div>
</>
);
}

View File

@@ -66,6 +66,16 @@ export const PreservationContent: React.FC<Props> = ({ link, format }) => {
}
}, [currentFormat]);
const imgRef = useRef<HTMLImageElement | null>(null);
useEffect(() => {
const img = imgRef.current;
if (!img) return;
if (img.complete && img.naturalWidth > 0) {
setImageLoaded(true);
}
}, [currentFormat, link?.id, link?.updatedAt]);
if (!link?.id) return null;
const renderFormat = () => {
@@ -126,6 +136,7 @@ export const PreservationContent: React.FC<Props> = ({ link, format }) => {
>
<img
alt=""
ref={imgRef}
src={`/api/v1/archives/${link.id}?format=${currentFormat}`}
className={clsx("w-fit mx-auto", !imageLoaded && "hidden")}
onLoad={(e) => {

View File

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

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

@@ -9,10 +9,14 @@ interface TagListingProps {
tags: Tag[];
active?: string;
}
export function TagListing({ tags, active }: TagListingProps) {
export default function TagListing({ tags, active }: TagListingProps) {
const { active: droppableActive } = useDndContext();
const { t } = useTranslation();
const ctx = useDndContext();
console.log("DndContext active?", ctx.active);
if (!tags[0]) {
return (
<div

View File

@@ -3,6 +3,10 @@ import Announcement from "@/components/Announcement";
import Sidebar from "@/components/Sidebar";
import { ReactNode, useEffect, useState } from "react";
import getLatestVersion from "@/lib/client/getLatestVersion";
import { DndContext } from "@dnd-kit/core";
import DragNDrop from "@/components/DragNDrop";
import { useLinks } from "@linkwarden/router/links";
import { LinkIncludingShortenedCollectionAndTags } from "@linkwarden/types";
interface Props {
children: ReactNode;
@@ -40,29 +44,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>
);
}

View File

@@ -1,10 +1,11 @@
import { LinkIncludingShortenedCollectionAndTags } from "@linkwarden/types";
import updateLinkById from "../linkId/updateLinkById";
import { UpdateLinkSchemaType } from "@linkwarden/lib/schemaValidation";
import { prisma } from "@linkwarden/prisma";
export default async function updateLinks(
userId: number,
links: UpdateLinkSchemaType[],
links: { id: number }[],
removePreviousTags: boolean,
newData: Pick<
LinkIncludingShortenedCollectionAndTags,
@@ -13,19 +14,35 @@ export default async function updateLinks(
) {
let allUpdatesSuccessful = true;
// Have to use a loop here rather than updateMany, see the following:
// https://github.com/prisma/prisma/issues/3143
for (const link of links) {
let updatedTags = [...link.tags, ...(newData.tags ?? [])];
const ids = links.map((l) => l.id);
if (removePreviousTags) {
// If removePreviousTags is true, replace the existing tags with new tags
updatedTags = [...(newData.tags ?? [])];
}
const dbLinks = await prisma.link.findMany({
where: { id: { in: ids } },
select: {
id: true,
name: true,
url: true,
description: true,
icon: true,
iconWeight: true,
color: true,
collectionId: true,
collection: { select: { id: true, ownerId: true } },
tags: { select: { name: true } },
},
});
// Map id -> link for quick lookup
const byId = new Map(dbLinks.map((l) => [l.id, l]));
for (const l of links) {
const link = byId.get(l.id);
if (!link) continue;
const updatedData: UpdateLinkSchemaType = {
...link,
tags: updatedTags,
tags: [...(newData.tags ?? [])],
collection: {
...link.collection,
id: newData.collectionId ?? link.collection.id,
@@ -35,7 +52,8 @@ export default async function updateLinks(
const updatedLink = await updateLinkById(
userId,
link.id as number,
updatedData
updatedData,
removePreviousTags
);
if (updatedLink.status !== 200) {

View File

@@ -11,7 +11,8 @@ import {
export default async function updateLinkById(
userId: number,
linkId: number,
body: UpdateLinkSchemaType
body: UpdateLinkSchemaType,
removePreviousTags?: boolean
) {
const dataValidation = UpdateLinkSchema.safeParse(body);
@@ -105,6 +106,30 @@ export default async function updateLinkById(
},
});
const uniqueTags = (() => {
const seen = new Set<string>();
return (data.tags ?? []).filter((t) => {
const key = t.name;
if (!key) return false;
if (seen.has(key)) return false;
seen.add(key);
return true;
});
})();
const tagConnectOrCreate = uniqueTags.map((tag) => ({
where: {
name_ownerId: {
name: tag.name,
ownerId: data.collection.ownerId,
},
},
create: {
name: tag.name,
owner: { connect: { id: data.collection.ownerId } },
},
}));
if (
data.url &&
oldLink &&
@@ -134,31 +159,21 @@ export default async function updateLinkById(
readable: oldLink?.url !== data.url ? null : undefined,
monolith: oldLink?.url !== data.url ? null : undefined,
preview: oldLink?.url !== data.url ? null : undefined,
lastPreserved: oldLink?.url !== data.url ? null : undefined,
indexVersion: null,
collection: {
connect: {
id: data.collection.id,
},
},
tags: {
set: [],
connectOrCreate: data.tags.map((tag) => ({
where: {
name_ownerId: {
name: tag.name,
ownerId: data.collection.ownerId,
},
tags: removePreviousTags
? {
set: [],
connectOrCreate: tagConnectOrCreate,
}
: {
connectOrCreate: tagConnectOrCreate,
},
create: {
name: tag.name,
owner: {
connect: {
id: data.collection.ownerId,
},
},
},
})),
},
pinnedBy: data?.pinnedBy
? data.pinnedBy[0]?.id === userId
? { connect: { id: userId } }

View File

@@ -5,6 +5,7 @@ import { DeleteUserBody } from "@linkwarden/types";
import updateSeats from "@/lib/api/stripe/updateSeats";
import { meiliClient } from "@linkwarden/lib";
import stripeSDK from "@/lib/api/stripe/stripeSDK";
import transporter from "@linkwarden/lib/transporter";
export default async function deleteUserById(
userId: number,
@@ -134,6 +135,23 @@ export default async function deleteUserById(
if (process.env.STRIPE_SECRET_KEY) {
const stripe = stripeSDK();
// Send an email about cancellation reason if provided
if (
body.cancellation_details?.comment ||
body.cancellation_details?.feedback ||
user.acceptPromotionalEmails
)
await transporter.sendMail({
from: process.env.EMAIL_FROM,
to: "hello@linkwarden.app",
subject: "Linkwarden User Cancellation",
text: `User: ${user.email}\nFeedback: ${
body.cancellation_details?.feedback || "N/A"
}\nComment: ${
body.cancellation_details?.comment || "N/A"
}\nPromotional Emails: ${String(user.acceptPromotionalEmails)}`,
});
try {
if (user.subscriptions?.id && queryId !== userId) {
const subscription = await prisma.subscription.findFirst({

View File

@@ -1,6 +1,6 @@
import { randomBytes } from "crypto";
import { prisma } from "@linkwarden/prisma";
import transporter from "./transporter";
import transporter from "@linkwarden/lib/transporter";
import Handlebars from "handlebars";
import { readFileSync } from "fs";
import path from "path";
@@ -51,7 +51,7 @@ export default async function sendChangeEmailVerificationRequest(
baseUrl: process.env.BASE_URL,
oldEmail,
newEmail,
verifyUrl: `${process.env.BASE_URL}/auth/verify-email?token=${token}`,
url: `${process.env.BASE_URL}/auth/verify-email?token=${token}`,
}),
});
}

View File

@@ -1,7 +1,7 @@
import { readFileSync } from "fs";
import path from "path";
import Handlebars from "handlebars";
import transporter from "./transporter";
import transporter from "@linkwarden/lib/transporter";
type Params = {
parentSubscriptionEmail: string;

View File

@@ -1,6 +1,6 @@
import { randomBytes } from "crypto";
import { prisma } from "@linkwarden/prisma";
import transporter from "./transporter";
import transporter from "@linkwarden/lib/transporter";
import Handlebars from "handlebars";
import { readFileSync } from "fs";
import path from "path";
@@ -37,7 +37,6 @@ export default async function sendPasswordResetRequest(
subject: "Linkwarden: Reset password instructions",
html: emailTemplate({
user,
baseUrl: process.env.BASE_URL,
url: `${process.env.BASE_URL}/auth/reset-password?token=${token}`,
}),
});

View File

@@ -1,7 +1,7 @@
import { readFileSync } from "fs";
import path from "path";
import Handlebars from "handlebars";
import transporter from "./transporter";
import transporter from "@linkwarden/lib/transporter";
type Params = {
identifier: string;

View File

@@ -1,16 +1,24 @@
export default async function getLatestVersion(setShowAnnouncement: Function) {
const announcementId = localStorage.getItem("announcementId");
const announcementMessage = localStorage.getItem("announcementMessage");
const response = await fetch(
`https://blog.linkwarden.app/latest-announcement.json`
`https://linkwarden.app/blog/latest-announcement.json`
);
const data = await response.json();
const latestAnnouncement = data.id;
const latestMessage = data.message;
if (announcementId !== latestAnnouncement) {
if (
announcementId != latestAnnouncement ||
announcementMessage != latestMessage
) {
setShowAnnouncement(true);
localStorage.setItem("announcementId", latestAnnouncement);
if (latestAnnouncement)
localStorage.setItem("announcementId", latestAnnouncement);
if (latestMessage)
localStorage.setItem("announcementMessage", latestMessage);
}
}

View File

@@ -1,8 +1,5 @@
import {
AccountSettings,
LinkIncludingShortenedCollectionAndTags,
} from "@linkwarden/types";
import { generateLinkHref } from "@linkwarden/lib/generateLinkHref";
import { LinkIncludingShortenedCollectionAndTags } from "@linkwarden/types";
import getFormatBasedOnPreference from "@linkwarden/lib/getFormatBasedOnPreference";
import { LinksRouteTo } from "@linkwarden/prisma/client";
const openLink = (
@@ -13,7 +10,17 @@ const openLink = (
if (user.linksRouteTo === LinksRouteTo.DETAILS) {
openModal();
} else {
window.open(generateLinkHref(link, user), "_blank");
const format = getFormatBasedOnPreference({
link,
preference: user.linksRouteTo,
});
window.open(
format !== null
? `/preserved/${link?.id}?format=${format}`
: (link.url as string),
"_blank"
);
}
};

View File

@@ -1,17 +1,23 @@
import fetch from "node-fetch";
import https from "https";
import http from "http";
import { HttpsProxyAgent } from "https-proxy-agent";
import { SocksProxyAgent } from "socks-proxy-agent";
export default async function fetchTitleAndHeaders(url: string) {
export default async function fetchTitleAndHeaders(
url: string,
content?: string
) {
if (!url?.startsWith("http://") && !url?.startsWith("https://"))
return { title: "", headers: null };
try {
const httpsAgent = new https.Agent({
rejectUnauthorized:
process.env.IGNORE_UNAUTHORIZED_CA === "true" ? false : true,
});
const httpsAgent = url?.startsWith("http://")
? new http.Agent({})
: new https.Agent({
rejectUnauthorized:
process.env.IGNORE_UNAUTHORIZED_CA === "true" ? false : true,
});
// fetchOpts allows a proxy to be defined
let fetchOpts = {
@@ -45,13 +51,20 @@ export default async function fetchTitleAndHeaders(url: string) {
const response = await Promise.race([responsePromise, timeoutPromise]);
if ((response as any)?.status) {
const text = await (response as any).text();
let text: string;
if (content) {
text = content;
} else {
text = await (response as any).text();
}
const headers = (response as Response)?.headers || null;
// regular expression to find the <title> tag
let match = text.match(/<title.*>([^<]*)<\/title>/);
const title = match?.[1] || "";
const headers = (response as Response)?.headers || null;
return { title, headers };
} else {

View File

@@ -7,14 +7,9 @@ const nextConfig = {
reactStrictMode: true,
staticPageGenerationTimeout: 1000,
images: {
// For fetching the favicons
domains: ["t2.gstatic.com"],
// For profile pictures (Google OAuth)
remotePatterns: [
{
hostname: "*.googleusercontent.com",
},
// For profile pictures (Google OAuth)
{ hostname: "*.googleusercontent.com" },
],
minimumCacheTTL: 10,

View File

@@ -1,6 +1,6 @@
{
"name": "@linkwarden/web",
"version": "v2.13.0",
"version": "v2.13.5",
"main": "index.js",
"repository": "https://github.com/linkwarden/linkwarden.git",
"author": "Daniel31X13 <daniel31x13@gmail.com>",
@@ -39,14 +39,6 @@
"@stripe/stripe-js": "^7.8.0",
"@tanstack/react-query": "^5.51.15",
"@tanstack/react-query-devtools": "^5.51.15",
"@types/crypto-js": "^4.1.1",
"@types/formidable": "^3.4.5",
"@types/node": "^20.10.4",
"@types/nodemailer": "^6.4.8",
"@types/papaparse": "^5.3.16",
"@types/react": "18.3.20",
"@types/react-dom": "18.3.7",
"@types/rss": "^0.0.32",
"axios": "^1.5.1",
"bcrypt": "^5.1.0",
"bootstrap-icons": "^1.11.2",
@@ -54,8 +46,7 @@
"clsx": "^2.1.1",
"colorjs.io": "^0.5.2",
"csstype": "^3.1.2",
"dompurify": "^3.0.6",
"eslint": "8.46.0",
"dompurify": "^3.2.4",
"eslint-config-next": "13.4.9",
"formidable": "^3.5.1",
"fuse.js": "^7.0.0",
@@ -68,16 +59,16 @@
"jszip": "^3.10.1",
"lucide-react": "^0.511.0",
"micro": "^10.0.1",
"next": "13.4.12",
"next": "14.2.35",
"next-auth": "^4.22.1",
"next-i18next": "^15.3.0",
"node-fetch": "^2.7.0",
"nodemailer": "^6.9.3",
"nodemailer": "^7.0.11",
"papaparse": "^5.5.3",
"playwright": "^1.55.0",
"playwright": "1.57.0",
"react": "18.3.1",
"react-colorful": "^5.6.1",
"react-dom": "18.2.0",
"react-dom": "18.3.1",
"react-hot-toast": "^2.4.1",
"react-i18next": "^14.1.2",
"react-image-file-resizer": "^0.4.8",
@@ -88,25 +79,33 @@
"react-window": "^1.8.10",
"rss": "^1.2.2",
"rss-parser": "^3.13.0",
"sharp": "^0.34.5",
"socks-proxy-agent": "^8.0.2",
"stripe": "^18.4.0",
"tailwind-merge": "^3.3.0",
"tailwindcss": "^3.4.17",
"tailwindcss-animate": "^1.0.7",
"vaul": "^1.1.1",
"zod": "^3.23.8",
"zod": "^4.1.13",
"zustand": "^4.3.8"
},
"devDependencies": {
"@playwright/test": "^1.55.0",
"@playwright/test": "1.57.0",
"@types/bcrypt": "^5.0.0",
"@types/dompurify": "^3.0.4",
"@types/crypto-js": "^4.1.1",
"@types/formidable": "^3.4.5",
"@types/jsdom": "^21.1.3",
"@types/node": "^20.10.4",
"@types/node-fetch": "^2.6.10",
"@types/nodemailer": "^7.0.4",
"@types/papaparse": "^5.3.16",
"@types/react": "18.3.1",
"@types/react-dom": "18.3.1",
"@types/react-window": "^1.8.8",
"@types/rss": "^0.0.32",
"@types/shelljs": "^0.8.15",
"autoprefixer": "^10.4.14",
"daisyui": "^4.4.2",
"eslint": "8.46.0",
"postcss": "^8.4.26",
"prettier": "3.1.1",
"tailwindcss": "^3.4.17",

View File

@@ -1,4 +1,4 @@
import React, { useEffect } from "react";
import React, { ReactElement, ReactNode, useEffect } from "react";
import "@/styles/globals.css";
import "bootstrap-icons/font/bootstrap-icons.css";
import { SessionProvider } from "next-auth/react";
@@ -13,6 +13,7 @@ import { isPWA } from "@/lib/utils";
import { appWithTranslation } from "next-i18next";
import { ReactQueryDevtools } from "@tanstack/react-query-devtools";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { NextPage } from "next";
const queryClient = new QueryClient({
defaultOptions: {
@@ -22,12 +23,19 @@ const queryClient = new QueryClient({
},
});
function App({
Component,
pageProps,
}: AppProps<{
session: Session;
}>) {
export type NextPageWithLayout<P = {}, IP = P> = NextPage<P, IP> & {
getLayout?: (page: ReactElement) => ReactNode;
};
type PageProps = { session?: Session | null };
type AppPropsWithLayout = AppProps<PageProps> & {
Component: NextPageWithLayout<PageProps>;
};
function App({ Component, pageProps }: AppPropsWithLayout) {
const getLayout = Component.getLayout ?? ((page) => page);
useEffect(() => {
if (isPWA()) {
const meta = document.createElement("meta");
@@ -98,7 +106,7 @@ function App({
</ToastBar>
)}
</Toaster>
<Component {...pageProps} />
{getLayout(<Component {...pageProps} />)}
{/* </GetData> */}
</AuthRedirect>
</SessionProvider>

View File

@@ -16,6 +16,7 @@ import { ArchivedFormat } from "@linkwarden/types";
export const config = {
api: {
bodyParser: false,
responseLimit: "50mb",
},
};
@@ -129,7 +130,10 @@ async function handleGet(req: NextApiRequest, res: NextApiResponse) {
: `archives/${collection.id}/${linkId + suffix}`;
const { file, contentType, status } = await readFile(filePath);
res.setHeader("Content-Type", contentType).status(status as number);
res
.setHeader("Content-Type", contentType)
.setHeader("Cache-Control", "private, max-age=31536000, immutable")
.status(status as number);
return res.send(file);
}

View File

@@ -146,7 +146,19 @@ async function handlePost(req: NextApiRequest, res: NextApiResponse) {
if (!collection) {
throw new Error("Collection not found.");
}
const { title = "" } = url ? await fetchTitleAndHeaders(url) : {};
// Generate a preview if it's an image
const { mimetype } = files.file[0];
const isPDF = mimetype?.includes("pdf");
const isImage = mimetype?.includes("image");
const isHTML = mimetype === "text/html";
const { title = "" } = url
? await fetchTitleAndHeaders(
url,
isHTML && !isPreview ? fileBuffer.toString("utf-8") : undefined
)
: {};
const link = await prisma.link.create({
data: {
@@ -162,15 +174,14 @@ async function handlePost(req: NextApiRequest, res: NextApiResponse) {
},
},
url,
// temporarily prevent archiveHandler and other processes from overwriting the file while we're uploading it
lastPreserved: new Date(0).toISOString(),
aiTagged: true,
indexVersion: 1,
},
});
// Generate a preview if it's an image
const { mimetype } = files.file[0];
const isPDF = mimetype?.includes("pdf");
const isImage = mimetype?.includes("image");
const isHTML = mimetype === "text/html";
if (isImage) {
const collectionId = collection.id;
createFolder({ filePath: `archives/preview/${collectionId}` });
@@ -203,6 +214,10 @@ async function handlePost(req: NextApiRequest, res: NextApiResponse) {
: undefined,
clientSide: true,
updatedAt: new Date().toISOString(),
lastPreserved: null,
aiTagged: false,
indexVersion: null,
},
});

View File

@@ -139,7 +139,7 @@ if (emailEnabled) {
server: process.env.EMAIL_SERVER,
from: process.env.EMAIL_FROM,
maxAge: 1200,
async sendVerificationRequest({ identifier, url, provider, token }) {
async sendVerificationRequest({ identifier, url, provider, token }: any) {
const recentVerificationRequestsCount =
await prisma.verificationToken.count({
where: {
@@ -160,13 +160,13 @@ if (emailEnabled) {
token,
});
},
}),
} as any),
EmailProvider({
id: "invite",
server: process.env.EMAIL_SERVER,
from: process.env.EMAIL_FROM,
maxAge: 1200,
async sendVerificationRequest({ identifier, url, provider, token }) {
async sendVerificationRequest({ identifier, url, provider, token }: any) {
const parentSubscriptionEmail = (
await prisma.user.findFirst({
where: {
@@ -210,7 +210,7 @@ if (emailEnabled) {
token,
});
},
})
} as any)
);
}
@@ -1068,6 +1068,35 @@ if (process.env.NEXT_PUBLIC_STRAVA_ENABLED === "true") {
};
}
// Synology
if (process.env.NEXT_PUBLIC_SYNOLOGY_ENABLED === "true") {
providers.push({
id: "synology",
name: "Synology",
type: "oauth",
clientId: process.env.SYNOLOGY_CLIENT_ID!,
clientSecret: process.env.SYNOLOGY_CLIENT_SECRET!,
wellKnown: process.env.SYNOLOGY_WELLKNOWN_URL!,
authorization: { params: { scope: "openid email profile" } },
idToken: true,
checks: ["pkce", "state"],
profile(profile) {
return {
id: profile.sub,
name: profile.name,
email: profile.email,
username: profile.preferred_username,
};
},
});
const _linkAccount = adapter.linkAccount;
adapter.linkAccount = (account) => {
const { "not-before-policy": _, refresh_expires_in, ...data } = account;
return _linkAccount ? _linkAccount(data) : undefined;
};
}
// Todoist
if (process.env.NEXT_PUBLIC_TODOIST_ENABLED === "true") {
providers.push(

View File

@@ -94,6 +94,7 @@ export default async function Index(req: NextApiRequest, res: NextApiResponse) {
return res
.setHeader("Content-Type", contentType)
.setHeader("Cache-Control", "private, max-age=31536000, immutable")
.status(status as number)
.send(file);
}

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