Compare commits

...

1008 Commits
v2.9.1 ... main

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
Daniel
9fb24a7329 Merge pull request #1417 from linkwarden/dev
v2.13.0
2025-09-25 09:39:51 -04:00
Daniel
3023316087 Merge pull request #1401 from linkwarden/i18n
New Crowdin updates
2025-09-25 09:38:08 -04:00
Daniel
b5e8b00125 Merge pull request #1416 from linkwarden/feat/improvements
Feat/improvements
2025-09-25 09:37:21 -04:00
daniel31x13
3c3d2d94fa updated labels 2025-09-25 09:25:00 -04:00
daniel31x13
3476d5fbf0 bug fixed 2025-09-24 19:57:01 -04:00
daniel31x13
4ca5c5a177 minor fix 2025-09-24 19:34:21 -04:00
daniel31x13
feedf88b97 minor fix 2025-09-24 19:16:05 -04:00
daniel31x13
80b8029f50 bug fixed 2025-09-24 17:44:40 -04:00
daniel31x13
fc328f3fd3 minor fix 2025-09-24 16:34:58 -04:00
daniel31x13
6a40a70e6d small improvement 2025-09-24 15:32:36 -04:00
daniel31x13
b336b04d71 remove banner 2025-09-24 13:11:21 -04:00
daniel31x13
d1e3badf21 add banner 2025-09-22 10:32:42 -04:00
daniel31x13
3b916dfb71 bug fixed 2025-09-22 09:43:40 -04:00
daniel31x13
6f0ee8eb73 improvements 2025-09-22 09:36:24 -04:00
daniel31x13
20b538c1fb minor improvement 2025-09-22 08:58:51 -04:00
daniel31x13
4541f1435b minor fix 2025-09-22 08:57:49 -04:00
daniel31x13
37c1100e37 improvements 2025-09-22 08:55:49 -04:00
LinkwardenBot
3cc1be0a9d New translations common.json (Dutch) 2025-09-21 16:04:28 +00:00
LinkwardenBot
4b2c2ad66d New translations common.json (Chinese Simplified) 2025-09-21 16:04:28 +00:00
LinkwardenBot
28b755fe37 New translations common.json (Romanian) 2025-09-21 16:04:28 +00:00
LinkwardenBot
c021753585 New translations common.json (English) 2025-09-21 16:04:28 +00:00
LinkwardenBot
bcb9d671c4 New translations common.json (Chinese Traditional) 2025-09-21 16:04:28 +00:00
LinkwardenBot
d2d9477493 New translations common.json (Ukrainian) 2025-09-21 16:04:28 +00:00
LinkwardenBot
4951f8113d New translations common.json (Polish) 2025-09-21 16:04:28 +00:00
LinkwardenBot
67a2c62d26 New translations common.json (Dutch) 2025-09-21 16:04:28 +00:00
LinkwardenBot
8a0264081b New translations common.json (Japanese) 2025-09-21 16:04:28 +00:00
LinkwardenBot
c30ed6c784 New translations common.json (Italian) 2025-09-21 16:04:28 +00:00
LinkwardenBot
ab0a91cf28 New translations common.json (German) 2025-09-21 16:04:28 +00:00
LinkwardenBot
9b5c7909ec New translations common.json (Spanish) 2025-09-21 16:04:28 +00:00
LinkwardenBot
3aa9e33b4e New translations common.json (French) 2025-09-21 16:04:28 +00:00
LinkwardenBot
7e387e504d New translations common.json (Russian) 2025-09-21 16:04:28 +00:00
LinkwardenBot
d689b24d07 New translations common.json (Portuguese, Brazilian) 2025-09-21 16:04:28 +00:00
LinkwardenBot
84de737d92 New translations common.json (Chinese Simplified) 2025-09-21 16:04:28 +00:00
LinkwardenBot
5f6934e7ea New translations common.json (Turkish) 2025-09-21 16:04:28 +00:00
LinkwardenBot
884e6dcf96 New translations common.json (Italian) 2025-09-21 16:04:28 +00:00
LinkwardenBot
2f3e388d1e New translations common.json (Italian) 2025-09-21 16:04:28 +00:00
daniel31x13
bbbd7a242d minor fix 2025-09-19 17:11:48 -04:00
daniel31x13
d91089ed48 bug fixed 2025-09-19 17:09:08 -04:00
daniel31x13
c6bdb8ee5a minor fix 2025-09-19 01:32:38 -04:00
daniel31x13
d9d2e3b78f init 2025-09-18 20:23:40 -04:00
Daniel
7f8d6dcd50 Merge pull request #1406 from linkwarden/feat/tag-management
Feat/tag management
2025-09-17 19:48:34 -04:00
daniel31x13
f4d3b8f657 many fixes and improvements 2025-09-17 19:47:57 -04:00
Daniel
0d1ed7bdd9 Merge pull request #1400 from linkwarden/i18n
New Crowdin updates
2025-09-13 12:50:34 -04:00
LinkwardenBot
01fc6db008 New translations common.json (Italian) 2025-09-13 16:49:59 +00:00
Daniel
1ad0bf14a6 Merge pull request #1373 from linkwarden/i18n
New Crowdin updates
2025-09-13 12:49:11 -04:00
LinkwardenBot
af623cd8e5 New translations common.json (German) 2025-09-10 20:59:34 +00:00
LinkwardenBot
f7a909f18e New translations common.json (German) 2025-09-10 20:59:34 +00:00
LinkwardenBot
3c7a6b53a5 New translations common.json (Dutch) 2025-09-10 20:59:34 +00:00
LinkwardenBot
8b192bedf4 New translations common.json (Romanian) 2025-09-10 20:59:34 +00:00
LinkwardenBot
a57130f838 New translations common.json (Romanian) 2025-09-10 20:59:34 +00:00
LinkwardenBot
6ffed2568a New translations common.json (Romanian) 2025-09-10 20:59:34 +00:00
LinkwardenBot
f7a796fc2b New translations common.json (Romanian) 2025-09-10 20:59:34 +00:00
LinkwardenBot
e55bf7064e New translations common.json (Romanian) 2025-09-10 20:59:34 +00:00
LinkwardenBot
705ec3726b New translations common.json (Japanese) 2025-09-10 20:59:34 +00:00
LinkwardenBot
e343a1829b New translations common.json (Japanese) 2025-09-10 20:59:34 +00:00
LinkwardenBot
cf27bccb6e New translations common.json (German) 2025-09-10 20:59:34 +00:00
LinkwardenBot
07d93c609d New translations common.json (French) 2025-09-10 20:59:33 +00:00
LinkwardenBot
e6818dcbd0 New translations common.json (Italian) 2025-09-10 20:59:33 +00:00
LinkwardenBot
98902a90ac New translations common.json (Polish) 2025-09-10 20:59:33 +00:00
LinkwardenBot
27618029d8 New translations common.json (Spanish) 2025-09-10 20:59:33 +00:00
daniel31x13
9e6a22320b minor fix 2025-09-08 18:01:18 -04:00
daniel31x13
15ce6810c8 feat(mobile): apply dark mode to spinner 2025-09-03 16:19:43 -04:00
daniel31x13
dc237b36f3 fixes and improvements 2025-09-03 15:56:48 -04:00
daniel31x13
4348221210 minor improvement 2025-09-03 13:58:33 -04:00
daniel31x13
cb1f42e0a2 bug fix 2025-09-01 20:17:11 -04:00
Daniel
8526442782 Merge pull request #1382 from khanguyen74/fix-no-title-single-file-integration
Fix link title not being set when saving via SingleFile extension
2025-08-29 20:04:28 -04:00
daniel31x13
a75ac115de minor change 2025-08-29 16:26:54 -04:00
daniel31x13
06e04fa11e improvements 2025-08-29 15:30:59 -04:00
daniel31x13
3dfa53cf01 small improvement 2025-08-29 11:40:24 -04:00
Kha Nguyen
4acc639a8a fix: add link title when creating via SingleFile extension 2025-08-28 22:43:57 -05:00
daniel31x13
07665cee7e feat(mobile): improvements 2025-08-28 20:53:57 -04:00
Daniel
9aefa3cf3b Merge pull request #1379 from linkwarden/dev
bug fix
2025-08-28 16:33:53 -04:00
daniel31x13
36be3d8772 bug fix 2025-08-28 16:33:07 -04:00
Daniel
1af9aaf11f Merge pull request #1372 from linkwarden/dev
Dev
2025-08-27 13:41:28 -04:00
daniel31x13
69b86a473a Merge branch 'dev' of https://github.com/linkwarden/linkwarden into dev 2025-08-27 13:40:58 -04:00
daniel31x13
22fde2d367 bump version 2025-08-27 13:40:57 -04:00
Daniel
2b63d7e863 Merge pull request #1371 from linkwarden/dev
Dev
2025-08-27 13:38:20 -04:00
Daniel
0aa6b0b4ae Merge pull request #1369 from linkwarden/i18n
New Crowdin updates
2025-08-27 13:37:46 -04:00
daniel31x13
ee489534ec bump version 2025-08-27 13:35:16 -04:00
LinkwardenBot
9f9d96edfe New translations common.json (Russian) 2025-08-27 16:16:00 +00:00
Daniel
cf71bb8a8a Merge pull request #1367 from linkwarden/dev
v2.12.0
2025-08-27 11:19:44 -04:00
daniel31x13
9ed374c4c3 minor fix 2025-08-27 11:02:26 -04:00
daniel31x13
1119f80ffc fix(web): bug fixed 2025-08-27 08:48:33 -04:00
daniel31x13
0f62beb0ab minor improvement 2025-08-26 21:44:53 -04:00
daniel31x13
395f5c01e1 init 2025-08-26 17:14:20 -04:00
daniel31x13
803f344680 minor improvement 2025-08-26 12:05:56 -04:00
daniel31x13
5f243d4fa2 mobile: improved login page 2025-08-25 18:53:56 -04:00
daniel31x13
dcd6b1be48 feat(mobile): clear cache when unauthenticated 2025-08-25 13:18:58 -04:00
daniel31x13
fed53fd187 fix(mobile): bug fix and improvements 2025-08-22 17:51:52 -04:00
daniel31x13
e30bcaefe3 feat(mobile): option to choose links default behavior 2025-08-22 15:50:24 -04:00
daniel31x13
a81111dfe5 feat(mobile): persist theme switching 2025-08-22 15:12:02 -04:00
daniel31x13
ce99562e16 fix(web): lower the minimum height for link cards 2025-08-22 13:46:19 -04:00
daniel31x13
c849e0aeda minor fix 2025-08-22 12:35:56 -04:00
daniel31x13
6f87e02ebc feat(mobile): share intent functionality fully implemented + bug fix 2025-08-22 11:57:51 -04:00
daniel31x13
59ef7baeeb fix(mobile): android dark mode bug 2025-08-22 10:58:58 -04:00
daniel31x13
47068d3352 fix(mobile): minor improvement 2025-08-22 10:19:05 -04:00
daniel31x13
9153958811 feat(mobile): add share sheet page 2025-08-21 14:36:19 -04:00
Daniel
3adbb10be8 Merge pull request #1357 from linkwarden/v2.11.8
V2.11.8
2025-08-20 16:26:27 -04:00
Daniel
5f3bfa4bda Merge pull request #1356 from linkwarden/v2.11.8
V2.11.8
2025-08-20 14:46:47 -04:00
Daniel
e5a278bb6a Merge branch 'main' into v2.11.8 2025-08-20 14:46:06 -04:00
Daniel
4bc3eb4bb5 Merge pull request #1350 from linkwarden/i18n
New Crowdin updates
2025-08-20 14:44:01 -04:00
Daniel
20fbc1552d Merge pull request #1353 from linkwarden/feat/update-stripes-api
Update Stripe's API
2025-08-20 14:40:12 -04:00
Daniel
7cf3812e2c Merge branch 'v2.11.8' into feat/update-stripes-api 2025-08-20 14:40:04 -04:00
Daniel
1ce72e6a03 Merge pull request #1355 from linkwarden/fix/link-batch-selection
Fix/link batch selection
2025-08-20 14:37:08 -04:00
daniel31x13
711477743f bump version 2025-08-20 14:36:40 -04:00
daniel31x13
1c55ac571a remove extra log 2025-08-20 13:59:10 -04:00
daniel31x13
5128efc05c bug fix 2025-08-20 13:43:24 -04:00
daniel31x13
2b6f48ad83 fix bug 2025-08-20 11:59:28 -04:00
LinkwardenBot
435a44dd47 New translations common.json (Portuguese, Brazilian) 2025-08-19 06:14:51 +00:00
LinkwardenBot
244f78a686 New translations common.json (Russian) 2025-08-19 06:14:50 +00:00
daniel31x13
fbb9f7af23 minor fix 2025-08-17 08:32:27 -04:00
daniel31x13
c08da0f990 use new api 2025-08-17 07:18:28 -04:00
Daniel
495d3111a1 Merge pull request #1349 from linkwarden/hotfix
minor fix
2025-08-14 13:11:21 -04:00
Daniel
c8f63cedc1 Merge pull request #1348 from linkwarden/hotfix
minor fix
2025-08-14 13:10:54 -04:00
daniel31x13
221930f88d minor fix 2025-08-14 13:10:24 -04:00
Daniel
58812811d0 Merge pull request #1346 from linkwarden/hotfix
Hotfix
2025-08-14 12:54:02 -04:00
Daniel
f2b521d34b Merge pull request #1345 from linkwarden/hotfix
small fix for new self-hosted users
2025-08-14 12:53:32 -04:00
daniel31x13
56b6791621 bump version 2025-08-14 12:52:42 -04:00
daniel31x13
bd26f90738 small fix for new self-hosted users 2025-08-14 12:51:55 -04:00
daniel31x13
63782f1627 fix(mobile): minor fix to the dashboard page 2025-08-13 18:26:44 -04:00
daniel31x13
078e38c7d8 remove unused imports 2025-08-13 15:21:31 -04:00
Daniel
11806563c8 Merge pull request #1343 from linkwarden/fix/fairer-link-picking
Fix/fairer link picking
2025-08-13 01:06:42 -04:00
Daniel
6da64e6535 Merge pull request #1342 from linkwarden/fix/fairer-link-picking
use a single browser with separate context
2025-08-13 00:11:47 -04:00
daniel31x13
577f8600ae use a single browser with separate context 2025-08-13 00:10:18 -04:00
Daniel
e6c2bc860f Merge pull request #1341 from linkwarden/fix/fairer-link-picking
remove unused code
2025-08-12 22:54:23 -04:00
daniel31x13
c54fbbc985 remove unused code 2025-08-12 22:54:01 -04:00
Daniel
c7d38733ce Merge pull request #1340 from linkwarden/fix/fairer-link-picking
improvement
2025-08-12 22:53:24 -04:00
daniel31x13
794c2f2657 improvement 2025-08-12 22:52:33 -04:00
Daniel
d73f236b36 Merge pull request #1339 from linkwarden/fix/fairer-link-picking
improvement
2025-08-12 22:09:09 -04:00
daniel31x13
658434afaf improvement 2025-08-12 22:08:38 -04:00
Daniel
a191861748 Merge pull request #1338 from linkwarden/fix/fairer-link-picking
fix user blocking queue
2025-08-12 18:21:27 -04:00
daniel31x13
eb3f1eeb5b fix user blocking queue 2025-08-12 17:58:25 -04:00
daniel31x13
c911f132f6 add native folders to gitignore 2025-08-12 11:05:48 -04:00
daniel31x13
89122ccd5c feat(mobile): add share sheet functionality 2025-08-12 10:39:01 -04:00
daniel31x13
ac8dacd570 fix(mobile): refetch on mount 2025-08-11 16:02:14 -04:00
daniel31x13
0b942cbb29 feat(web): add singlefile upload route 2025-08-11 15:11:32 -04:00
Daniel
f8cfe8e556 Merge pull request #1332 from khanguyen74/feat/allow-upload-html-archive
Feat: allow uploading archives
2025-08-11 12:00:13 -04:00
daniel31x13
2be7f314bb made the upload functionality api-only 2025-08-11 11:59:56 -04:00
Kha Nguyen
f083b45b78 allow uploading asset if user has access to collection 2025-08-10 22:51:55 -05:00
Kha Nguyen
0e7d2ef716 add Crowdin for failed upload message 2025-08-10 21:49:36 -05:00
Kha Nguyen
581e27e8c6 feat(web): add upload button to preservation nav bar 2025-08-10 21:42:11 -05:00
Kha Nguyen
c20f6d8015 remove upload buttons on link detail drawer 2025-08-10 21:40:45 -05:00
daniel31x13
91dafd40f1 Merge branch 'dev' of https://github.com/linkwarden/linkwarden into dev 2025-08-10 18:12:58 -04:00
daniel31x13
d0cf951731 minor fix 2025-08-10 18:12:57 -04:00
Daniel
c75685c435 Merge pull request #1330 from khanguyen74/fix/collection-list-sort
Fix: properly handle collection list on dashboard page
2025-08-10 17:31:36 -04:00
Kha Nguyen
3bc8bb676e fix: properly sort collection list on dashboard page 2025-08-10 12:08:59 -05:00
Daniel
63f77b19a9 Merge pull request #1329 from linkwarden/i18n
New Crowdin updates
2025-08-10 07:38:24 -04:00
Daniel
8521f4f1e9 Merge pull request #1325 from teynar/fix/mismatched-link-favicon-size
Fix: request 64px gstatic favicon for consistent icon display
2025-08-10 07:34:50 -04:00
Daniel
a866de6931 Merge pull request #1331 from khanguyen74/fix/update-drag-n-drop-search-page
fix: properly handle drag-and-drop
2025-08-10 07:27:26 -04:00
daniel31x13
d661c5c609 minor fix 2025-08-10 07:15:09 -04:00
daniel31x13
7b12a82a67 Revert "Merge branch 'dev' of https://github.com/linkwarden/linkwarden into dev" 2025-08-08 17:43:36 -04:00
daniel31x13
374c64ff0f Merge branch 'dev' of https://github.com/linkwarden/linkwarden into dev 2025-08-08 17:35:34 -04:00
daniel31x13
7558068f7a minor update 2025-08-08 17:35:29 -04:00
Kha Nguyen
c03557c080 ask for confirmation before refresh preserved data 2025-08-08 14:34:29 -05:00
daniel31x13
f15b8df6f3 feat(mobile): share sheet base commit 2025-08-08 14:14:37 -04:00
Kha Nguyen
91e337ea8c add options to replace other preserved formats 2025-08-08 13:10:44 -05:00
Kha Nguyen
65f0075a6f feat(web): add option to upload html archive 2025-08-08 12:37:29 -05:00
Kha Nguyen
a8928e9de4 fix: change drag handle to inside link card
- fixes issue where dragging link details drawer also causing the link
to be dragged
2025-08-08 10:21:32 -05:00
Kha Nguyen
fb7e2286a7 fix(web): use DragNDrop wrapper for search page 2025-08-08 09:02:25 -05:00
LinkwardenBot
f057434c3b New translations common.json (Chinese Simplified) 2025-08-08 04:09:14 +00:00
LinkwardenBot
c7c4959659 New translations common.json (Chinese Simplified) 2025-08-08 04:09:14 +00:00
LinkwardenBot
c6ed7bb8e8 New translations common.json (Chinese Traditional) 2025-08-08 04:09:14 +00:00
LinkwardenBot
7351b5265d New translations common.json (Ukrainian) 2025-08-08 04:09:14 +00:00
LinkwardenBot
fcc4b89249 New translations common.json (Polish) 2025-08-08 04:09:14 +00:00
LinkwardenBot
04ea06e319 New translations common.json (Dutch) 2025-08-08 04:09:14 +00:00
LinkwardenBot
57347f2b90 New translations common.json (Japanese) 2025-08-08 04:09:14 +00:00
LinkwardenBot
35ae395447 New translations common.json (Italian) 2025-08-08 04:09:14 +00:00
LinkwardenBot
553680d9eb New translations common.json (German) 2025-08-08 04:09:14 +00:00
LinkwardenBot
1048989598 New translations common.json (Spanish) 2025-08-08 04:09:14 +00:00
LinkwardenBot
b5f96f5ed3 New translations common.json (French) 2025-08-08 04:09:14 +00:00
LinkwardenBot
e22ed9bb3d New translations common.json (Russian) 2025-08-08 04:09:13 +00:00
LinkwardenBot
51ee97c7ef New translations common.json (Portuguese, Brazilian) 2025-08-08 04:09:13 +00:00
LinkwardenBot
c25bc658f4 New translations common.json (Chinese Simplified) 2025-08-08 04:09:13 +00:00
LinkwardenBot
3b8a6051e9 New translations common.json (Turkish) 2025-08-08 04:09:13 +00:00
Kha Nguyen
b8a6c77150 fix(web): fix collection sortable list on dashboard page 2025-08-07 20:41:17 -05:00
daniel31x13
a9937d218c minor update 2025-08-07 20:10:43 -04:00
daniel31x13
d3302daa3a remove comment 2025-08-07 18:34:17 -04:00
Daniel
dac2b4c30a Merge pull request #1322 from khanguyen74/fix/email-input-disappear
Fix: email input disappear in user modal
2025-08-07 18:26:17 -04:00
Daniel
28e3e23f8a Merge pull request #1310 from khanguyen74/fix/subscription-check-fix
Fix subscription check blocking user creation in admin dashboard
2025-08-07 18:04:29 -04:00
Daniel
80375843af Merge pull request #1320 from khanguyen74/feature/add-drag-and-drop
feat: add drag and drop to move links between collection on dashboard page
2025-08-07 17:41:46 -04:00
daniel31x13
232098191a a bit more concise alert 2025-08-07 17:33:39 -04:00
Kha Nguyen
b59ad36192 feat(web): add drag and drop to tag on sidebar 2025-08-07 15:13:01 -05:00
Kha Nguyen
ebdc8b9db2 refactor drag and drop pages 2025-08-07 15:12:36 -05:00
Kha Nguyen
7a76de5726 add TagListing component
refactor tags out of sidebar
2025-08-07 13:49:28 -05:00
Kha Nguyen
a0ceca443c feat(web): add drag-n-drop to tag page 2025-08-07 13:18:54 -05:00
Kha Nguyen
21cb65edbd clean up 2025-08-07 12:55:05 -05:00
Kha Nguyen
c58f8f3086 feat(web): add drag-n-drop to collection page 2025-08-07 12:53:03 -05:00
Kha Nguyen
04419aa9e0 feat(web): add drag-n-drop to search page 2025-08-07 12:47:58 -05:00
Kha Nguyen
e916ea53fc feat(web): add drag-n-drop to pinned page 2025-08-07 12:36:59 -05:00
Kha Nguyen
20fde711d1 refactor links page 2025-08-07 12:36:41 -05:00
Kha Nguyen
652931e5be prevent hover effect on sidebar collection list when dragging over 2025-08-07 12:21:56 -05:00
Daniel
88d4e0203f Merge pull request #1302 from linkwarden/i18n
New Crowdin updates
2025-08-07 12:57:32 -04:00
Kha Nguyen
87ae5da6f3 add drag-n-drop to masonry view 2025-08-07 00:20:33 -05:00
Kha Nguyen
6a9a897529 add drag-n-drop to list view 2025-08-07 00:18:51 -05:00
Kha Nguyen
92a1c4a5f0 disable drag and drop on small screen on links page 2025-08-07 00:14:56 -05:00
Kha Nguyen
1ca2b4e534 refactor how selected link className is applied 2025-08-06 23:58:31 -05:00
daniel31x13
139f99d050 fix(mobile): fix android not updating queries 2025-08-06 20:32:59 -04:00
daniel31x13
c5bfd20833 fix(mobile): bug fixed 2025-08-06 19:55:27 -04:00
Kha Nguyen
26ab9584ca use favicon as overlay when dragging links 2025-08-06 18:38:28 -05:00
Kha Nguyen
76056d6b1a remove drag handle 2025-08-06 18:36:54 -05:00
daniel31x13
bc129af07a fix(mobile): apply dark mode to android statusbar 2025-08-06 19:34:05 -04:00
Kha Nguyen
16a83496bc ui change to droppable area 2025-08-06 18:31:06 -05:00
Kha Nguyen
e270a6b957 use favicon as overlay component when dragging 2025-08-06 17:27:10 -05:00
Kha Nguyen
ae6dbf7745 remove dropping link placeholder 2025-08-06 17:03:40 -05:00
daniel31x13
ee8a6634f0 fix(mobile): bug fix 2025-08-06 17:46:34 -04:00
Teynar
a7f8de0a2e fix(link-icon): request 64px gstatic favicon for consistent icon display
Previously, a 32px favicon was requested and scaled to 64px, causing blurriness.
2025-08-06 23:39:59 +02:00
Kha Nguyen
1f369401df drag/drop ui improvement 2025-08-06 16:24:04 -05:00
Kha Nguyen
a4375e3b57 update links page 2025-08-06 15:23:15 -05:00
Kha Nguyen
532f6fb1f1 make LinkCard draggable 2025-08-06 15:22:56 -05:00
daniel31x13
1e1bd8f333 fix(mobile): bugs fixed 2025-08-06 16:17:28 -04:00
Kha Nguyen
c999259102 refactor custom collision algorithm function 2025-08-06 15:03:03 -05:00
Kha Nguyen
13883e0cbc add drag and drop to links page 2025-08-06 15:02:19 -05:00
Kha Nguyen
5acfdc2f89 update logic to set active link when the link is dragged 2025-08-06 14:10:32 -05:00
Kha Nguyen
8e1767d7d0 fix(web): fix user modal not showing email input
- properly check for EMAIL_PROVIDER environment variable
2025-08-05 22:22:09 -05:00
Kha Nguyen
477f534a17 revert className changes in LinkPin component 2025-08-05 20:46:44 -05:00
daniel31x13
032359a357 fix(mobile): fix paddings on android 2025-08-05 19:18:16 -04:00
daniel31x13
87c66b81e0 feat(mobile): add cross-platform icon library 2025-08-05 18:03:23 -04:00
LinkwardenBot
8eb3b5ce5d New translations common.json (Russian) 2025-08-05 21:48:41 +00:00
LinkwardenBot
ee13d3551d New translations common.json (Turkish) 2025-08-05 21:48:41 +00:00
LinkwardenBot
52a4441b6f New translations common.json (Portuguese, Brazilian) 2025-08-05 21:48:41 +00:00
LinkwardenBot
04e73942a6 New translations common.json (Portuguese, Brazilian) 2025-08-05 21:48:41 +00:00
LinkwardenBot
6234278c9f New translations common.json (Turkish) 2025-08-05 21:48:41 +00:00
LinkwardenBot
e39d0b1bbd New translations common.json (Turkish) 2025-08-05 21:48:41 +00:00
LinkwardenBot
b703eb5654 New translations common.json (Chinese Simplified) 2025-08-05 21:48:40 +00:00
LinkwardenBot
0b296dc557 New translations common.json (Chinese Simplified) 2025-08-05 21:48:40 +00:00
LinkwardenBot
3ba2beb769 New translations common.json (Chinese Simplified) 2025-08-05 21:48:40 +00:00
LinkwardenBot
4b477f82bf New translations common.json (Turkish) 2025-08-05 21:48:40 +00:00
LinkwardenBot
30ef7863d5 New translations common.json (Turkish) 2025-08-05 21:48:40 +00:00
daniel31x13
b78aa39f79 fix(mobile): bugs fixed 2025-08-05 16:42:32 -04:00
Kha Nguyen
787e4c49a4 remove LinkAction open control prop
should be addressed in a separate PR
2025-08-03 09:04:55 -05:00
Kha Nguyen
6e60031e40 update collision algorithm 2025-08-02 23:22:29 -05:00
Kha Nguyen
23148b22d7 feat: the whole dashboard link is now draggable 2025-08-02 23:14:58 -05:00
Kha Nguyen
07945f946d improve mobile support for drag and drop 2025-08-02 23:01:45 -05:00
Kha Nguyen
702508498e allow drag and drop link to collections in side bar 2025-08-02 21:07:26 -05:00
Kha Nguyen
289818716c optimistically update pinned link when drop to pinned links section 2025-08-02 18:49:41 -05:00
Kha Nguyen
75419df40b feat: show link placeholder on pinned link section on drag 2025-08-02 18:16:34 -05:00
Kha Nguyen
586ef5ed58 feat: show placeholder on target collection 2025-08-02 16:56:21 -05:00
daniel31x13
ee53a170ed feat(mobile): add system defaults to themes 2025-07-31 20:13:28 -04:00
daniel31x13
ea48e797e3 small fix 2025-07-31 20:02:29 -04:00
daniel31x13
b294abd65b minor improvement 2025-07-31 18:54:47 -04:00
daniel31x13
f7fcbfe635 minor improvement 2025-07-31 18:48:05 -04:00
daniel31x13
2c01013eb3 feat(mobile): improved theme colors 2025-07-31 17:26:28 -04:00
daniel31x13
a36af7d673 feat(mobile): completed implementing dark mode 2025-07-31 16:21:57 -04:00
daniel31x13
f4c8030a1b feat(mobile): initial commit for theme settings 2025-07-30 19:44:57 -04:00
Daniel
0a735cd2f6 Merge pull request #1313 from linkwarden/dev
Dev
2025-07-29 19:12:59 -04:00
daniel31x13
d870ffecd1 feat(web): optimization 2025-07-29 19:11:02 -04:00
Kha Nguyen
360eadb08d show toast when drop a link to collection it already belongs to
- only applies if dragged from recent section
2025-07-28 19:43:24 -05:00
Kha Nguyen
35eec60ac9 improve drag n drop on touch devices 2025-07-28 19:01:18 -05:00
daniel31x13
dae7d2be3b feat(mobile): small improvement to the settings page 2025-07-28 16:19:49 -04:00
daniel31x13
e0abe1df39 feat(mobile): improved dashboard 2025-07-28 16:01:55 -04:00
Kha Nguyen
4ae03168bb optimistically update the collection after dropping link 2025-07-28 12:38:35 -05:00
Kha Nguyen
9a6429b85b update how link card look while dragging 2025-07-28 11:45:11 -05:00
Kha Nguyen
20997ba8bc feat(web): change link drag and drop logic 2025-07-28 00:55:53 -05:00
Kha Nguyen
4fe2ef7134 feat(web): handle pinning link by drag-n-drop 2025-07-27 01:29:43 -05:00
Kha Nguyen
63b6f3e66a chore: add dependencies 2025-07-26 01:33:06 -05:00
Kha Nguyen
5c07687e1a fix(web): show more button on link card while dropdown menu opens 2025-07-26 01:32:51 -05:00
Kha Nguyen
019bc783a4 feat(web): allow drag and drop to move a link to new collection 2025-07-26 01:31:13 -05:00
Kha Nguyen
3dfa12dbbf feat(web): add droppable dashboard links 2025-07-26 01:30:13 -05:00
Kha Nguyen
20eecd682e fix: only require subscription check when Stripe is enabled 2025-07-25 20:41:58 -05:00
daniel31x13
de3ca46ef0 feat(mobile): finished building the dashboard 2025-07-24 18:32:55 -04:00
daniel31x13
a77c5c20cd feat(mobile): add copy to clipboard functionality 2025-07-23 22:22:33 -04:00
daniel31x13
822415c695 feat(mobile): add edit link functionality 2025-07-22 20:11:46 -04:00
daniel31x13
3de6e9965a chore(mobile): implement sheet manager 2025-07-22 12:19:48 -04:00
daniel31x13
8555d26d99 feat(mobile): add link functionality + delete link functionality 2025-07-21 17:40:27 -04:00
daniel31x13
c8c8fb7875 bug fixed 2025-07-21 13:50:44 -04:00
Daniel
64ae49a3a4 Merge pull request #1299 from linkwarden/dev
bug fixed
2025-07-19 23:13:58 -04:00
daniel31x13
1bcbf2011d bump version 2025-07-19 23:13:41 -04:00
daniel31x13
d43c4e3e95 bug fixed 2025-07-19 23:10:10 -04:00
Daniel
6253635e78 Merge pull request #1296 from linkwarden/dev
minor fix
2025-07-18 16:33:21 -04:00
daniel31x13
b30f554fa7 minor fix 2025-07-18 16:10:06 -04:00
Daniel
f80432eda1 Merge pull request #1292 from linkwarden/dev
Dev
2025-07-16 17:52:28 -04:00
Daniel
267a14c535 Merge pull request #1293 from linkwarden/i18n
New Crowdin updates
2025-07-16 17:51:40 -04:00
daniel31x13
e24e338e67 update readme 2025-07-16 17:44:31 -04:00
Daniel
0299f7bd4b New translations common.json (Portuguese, Brazilian) 2025-07-16 16:39:16 -04:00
Daniel
db4b570577 New translations common.json (Chinese Traditional) 2025-07-16 16:39:15 -04:00
Daniel
8b5b1ae17e New translations common.json (Chinese Simplified) 2025-07-16 16:39:14 -04:00
Daniel
566927f6ff New translations common.json (Ukrainian) 2025-07-16 16:39:13 -04:00
Daniel
82d37e00e2 New translations common.json (Turkish) 2025-07-16 16:39:11 -04:00
Daniel
9be8b540d0 New translations common.json (Russian) 2025-07-16 16:39:10 -04:00
Daniel
6c01456c76 New translations common.json (Polish) 2025-07-16 16:39:09 -04:00
Daniel
dd6cde5b93 New translations common.json (Dutch) 2025-07-16 16:39:08 -04:00
Daniel
a984f8eca2 New translations common.json (Japanese) 2025-07-16 16:39:07 -04:00
Daniel
9fe910e205 New translations common.json (Italian) 2025-07-16 16:39:06 -04:00
Daniel
c76ac2f4c7 New translations common.json (German) 2025-07-16 16:39:05 -04:00
Daniel
71b1ff7da0 New translations common.json (Spanish) 2025-07-16 16:39:04 -04:00
Daniel
2bb1393eca New translations common.json (French) 2025-07-16 16:39:03 -04:00
daniel31x13
44f094f473 added a confirmation modal for re-preserving links 2025-07-16 16:31:27 -04:00
daniel31x13
ef98de61ee bug fixed 2025-07-14 17:28:10 -04:00
daniel31x13
1897e9c8ce minor fix 2025-07-14 17:07:00 -04:00
daniel31x13
659910a76d minor improvement 2025-07-14 17:02:56 -04: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
Daniel
f3e31edb7d Merge pull request #1289 from linkwarden/dev
v2.11.4
2025-07-12 16:41:22 -04:00
daniel31x13
936f7d9614 Merge branch 'dev' of https://github.com/linkwarden/linkwarden into dev 2025-07-12 15:18:47 -04:00
daniel31x13
966c271bd3 bump version 2025-07-12 15:18:45 -04:00
Daniel
95c243df18 Merge pull request #1285 from linkwarden/i18n
New Crowdin updates
2025-07-12 15:17:23 -04:00
daniel31x13
89efd237fe improved UX 2025-07-12 15:15:56 -04:00
LinkwardenBot
899426772b New translations common.json (Russian) 2025-07-06 12:36:47 +00:00
LinkwardenBot
55582433c6 New translations common.json (Russian) 2025-07-06 12:36:46 +00:00
Daniel
395f357fcb Merge pull request #1282 from linkwarden/dev
v2.11.3
2025-07-05 00:32:01 -04:00
Daniel
a14485c6dd Merge pull request #1272 from linkwarden/i18n
New Crowdin updates
2025-07-05 00:30:26 -04:00
LinkwardenBot
2351a83c48 New translations common.json (Spanish) 2025-07-05 04:29:26 +00:00
LinkwardenBot
e761c1d17a New translations common.json (French) 2025-07-05 04:29:26 +00:00
LinkwardenBot
ea01ab7b0f New translations common.json (Spanish) 2025-07-05 04:29:26 +00:00
LinkwardenBot
583bc077fb New translations common.json (Spanish) 2025-07-05 04:29:26 +00:00
daniel31x13
63ed780bb0 small fix 2025-07-05 00:24:35 -04:00
Tchoupinax
2441470849 fix: add support for password manager for login page 2025-06-29 11:20:18 +02:00
daniel31x13
5032bc3d13 remove extra logging 2025-06-28 14:44:53 -04:00
Daniel
7cec177ef0 Merge pull request #1267 from linkwarden/dev
Dev
2025-06-28 21:01:07 +03:30
daniel31x13
cb104a3a64 Merge branch 'dev' of https://github.com/linkwarden/linkwarden into dev 2025-06-28 13:30:52 -04:00
daniel31x13
3fe98f9346 bump version 2025-06-28 13:30:50 -04:00
Daniel
22a311297a Merge pull request #1266 from linkwarden/i18n
New Crowdin updates
2025-06-28 20:42:41 +03:30
LinkwardenBot
c9da55034c New translations common.json (French) 2025-06-28 17:11:53 +00:00
LinkwardenBot
6d7f69929f New translations common.json (French) 2025-06-28 17:11:53 +00:00
LinkwardenBot
77dd17f051 New translations common.json (German) 2025-06-28 17:11:53 +00:00
daniel31x13
3e015fa76c small fix 2025-06-28 13:10:53 -04:00
Daniel
c56259cb72 Merge pull request #1263 from linkwarden/dev
small fix
2025-06-28 03:57:50 +03:30
daniel31x13
a05fbd7141 small fix 2025-06-27 20:27:03 -04:00
Daniel
e00852f5df Merge pull request #1262 from linkwarden/dev
minor fix for the demo instance
2025-06-28 03:17:27 +03:30
daniel31x13
3067b6e2d3 minor fix for the demo instance 2025-06-27 19:44:00 -04:00
Daniel
a49a57c6ea Merge pull request #1260 from linkwarden/dev
bump version
2025-06-28 00:50:34 +03:30
daniel31x13
32efa56f77 bump version 2025-06-27 17:20:12 -04:00
Daniel
a8c6ef6fa0 Merge pull request #1259 from linkwarden/dev
bug fixed
2025-06-28 00:44:01 +03:30
daniel31x13
21fadfe389 bug fixed 2025-06-27 17:13:23 -04:00
Daniel
1e80fb33c4 Merge pull request #1258 from linkwarden/dev
Dev
2025-06-28 00:37:43 +03:30
daniel31x13
edf72aa042 bug fixed 2025-06-27 17:00:38 -04:00
daniel31x13
74d5dfb404 bug fix 2025-06-27 16:52:23 -04:00
daniel31x13
86971dcead bug fix 2025-06-27 16:25:08 -04:00
daniel31x13
9b0c3bf405 bug fix 2025-06-27 16:07:34 -04:00
Daniel
3ce30ec0f6 Merge pull request #1255 from linkwarden/dev
Dev
2025-06-27 20:34:28 +03:30
daniel31x13
e08684328a improved performance 2025-06-27 13:03:21 -04:00
Daniel
fe06ecc3f6 Merge pull request #1254 from linkwarden/main
sync
2025-06-27 19:24:12 +03:30
daniel31x13
42d877e2b5 small fix 2025-06-27 11:52:54 -04:00
Daniel
94cba5394f Merge pull request #1253 from linkwarden/dev
minor fix
2025-06-27 16:56:46 +03:30
daniel31x13
70999b83c5 minor fix 2025-06-27 09:26:15 -04:00
Daniel
d89aa4a4e7 Merge pull request #1249 from linkwarden/dev
v2.11.0
2025-06-27 16:47:00 +03:30
daniel31x13
c933970cce bug fixed 2025-06-27 08:16:01 -04:00
daniel31x13
8f10930127 add autoFocus to modals 2025-06-27 06:41:23 -04:00
daniel31x13
30c1a064e0 add omit field to link textContent 2025-06-27 06:21:47 -04:00
daniel31x13
d207c89d8e small improvements 2025-06-26 19:39:12 -04:00
daniel31x13
4712dc9b26 bug fixed 2025-06-26 17:55:19 -04:00
daniel31x13
1203f62ab3 Merge branch 'dev' of https://github.com/linkwarden/linkwarden into dev 2025-06-26 16:10:11 -04:00
daniel31x13
c1df0db729 improvements 2025-06-26 16:10:09 -04:00
Daniel
ee5814a0e4 Merge pull request #1248 from linkwarden/i18n
New Crowdin updates
2025-06-26 21:50:46 +03:30
daniel31x13
126d98d1b8 small improvement 2025-06-26 13:54:50 -04:00
daniel31x13
ebbe68dc9c bug fixed 2025-06-26 13:45:08 -04:00
daniel31x13
5a763bdce0 minor fix 2025-06-26 13:37:48 -04:00
Daniel
3b36cf09f4 New translations common.json (Russian) 2025-06-26 20:57:10 +03:30
daniel31x13
fb017a7655 small improvement 2025-06-26 13:18:02 -04:00
daniel31x13
b462531da8 small improvement to the seed script 2025-06-26 13:13:50 -04:00
daniel31x13
acbefe4b6e update logo 2025-06-26 12:43:18 -04:00
LinkwardenBot
e5296dd5c9 New translations common.json (Portuguese, Brazilian) 2025-06-26 15:52:19 +00:00
LinkwardenBot
a8295f94b8 New translations common.json (English) 2025-06-26 15:52:19 +00:00
LinkwardenBot
8607c066b8 New translations common.json (Chinese Traditional) 2025-06-26 15:52:19 +00:00
LinkwardenBot
63bc36d5ef New translations common.json (Chinese Simplified) 2025-06-26 15:52:19 +00:00
LinkwardenBot
74a671d165 New translations common.json (Ukrainian) 2025-06-26 15:52:19 +00:00
LinkwardenBot
b95c4b2d97 New translations common.json (Turkish) 2025-06-26 15:52:19 +00:00
LinkwardenBot
9a4f74e4a9 New translations common.json (Russian) 2025-06-26 15:52:19 +00:00
LinkwardenBot
e43fd9359b New translations common.json (Polish) 2025-06-26 15:52:19 +00:00
LinkwardenBot
be011955b5 New translations common.json (Dutch) 2025-06-26 15:52:19 +00:00
LinkwardenBot
5660093f1a New translations common.json (Japanese) 2025-06-26 15:52:19 +00:00
LinkwardenBot
a7b13e27f9 New translations common.json (Italian) 2025-06-26 15:52:19 +00:00
LinkwardenBot
f25edb1302 New translations common.json (German) 2025-06-26 15:52:19 +00:00
LinkwardenBot
592254daba New translations common.json (Spanish) 2025-06-26 15:52:19 +00:00
LinkwardenBot
395a7a58aa New translations common.json (French) 2025-06-26 15:52:19 +00:00
LinkwardenBot
7fcd73b61b New translations common.json (Portuguese, Brazilian) 2025-06-26 15:52:19 +00:00
LinkwardenBot
b091e133a7 New translations common.json (Chinese Traditional) 2025-06-26 15:52:19 +00:00
LinkwardenBot
5c8e339864 New translations common.json (Chinese Simplified) 2025-06-26 15:52:19 +00:00
LinkwardenBot
35808ef03f New translations common.json (Ukrainian) 2025-06-26 15:52:19 +00:00
LinkwardenBot
1fc7f436a1 New translations common.json (Turkish) 2025-06-26 15:52:19 +00:00
LinkwardenBot
2e15d43e4b New translations common.json (Russian) 2025-06-26 15:52:19 +00:00
LinkwardenBot
8b25a54b26 New translations common.json (Polish) 2025-06-26 15:52:19 +00:00
LinkwardenBot
6c4f694f17 New translations common.json (Dutch) 2025-06-26 15:52:19 +00:00
LinkwardenBot
a6370dd24b New translations common.json (Japanese) 2025-06-26 15:52:19 +00:00
LinkwardenBot
873f51379d New translations common.json (Italian) 2025-06-26 15:52:19 +00:00
LinkwardenBot
701824dd18 New translations common.json (German) 2025-06-26 15:52:19 +00:00
LinkwardenBot
e39876790b New translations common.json (Spanish) 2025-06-26 15:52:19 +00:00
LinkwardenBot
db7135abdf New translations common.json (French) 2025-06-26 15:52:19 +00:00
daniel31x13
0b89ab3e35 minor improvement 2025-06-26 11:07:03 -04:00
Daniel
707ec84ee2 Merge pull request #1247 from linkwarden/i18n
New Crowdin updates
2025-06-26 02:04:38 +03:30
LinkwardenBot
ba03f99666 New translations common.json (Portuguese, Brazilian) 2025-06-25 22:34:12 +00:00
LinkwardenBot
6cbf9c7153 New translations common.json (English) 2025-06-25 22:34:12 +00:00
LinkwardenBot
b230c12f61 New translations common.json (Chinese Traditional) 2025-06-25 22:34:12 +00:00
LinkwardenBot
f3cd1845b7 New translations common.json (Chinese Simplified) 2025-06-25 22:34:12 +00:00
LinkwardenBot
580c87880b New translations common.json (Ukrainian) 2025-06-25 22:34:12 +00:00
LinkwardenBot
52a10aba1e New translations common.json (Turkish) 2025-06-25 22:34:12 +00:00
LinkwardenBot
5dcf73570e New translations common.json (Russian) 2025-06-25 22:34:12 +00:00
LinkwardenBot
5cffa0bd4f New translations common.json (Polish) 2025-06-25 22:34:12 +00:00
LinkwardenBot
6a8bb8b6e8 New translations common.json (Dutch) 2025-06-25 22:34:12 +00:00
LinkwardenBot
3e3a368131 New translations common.json (Japanese) 2025-06-25 22:34:12 +00:00
LinkwardenBot
6b7fca78e1 New translations common.json (Italian) 2025-06-25 22:34:12 +00:00
LinkwardenBot
d356fc9fd5 New translations common.json (German) 2025-06-25 22:34:12 +00:00
LinkwardenBot
f198cc5923 New translations common.json (Spanish) 2025-06-25 22:34:12 +00:00
LinkwardenBot
4e50ead03e New translations common.json (French) 2025-06-25 22:34:12 +00:00
LinkwardenBot
c716a986a3 New translations common.json (Portuguese, Brazilian) 2025-06-25 22:34:12 +00:00
LinkwardenBot
fcf9548552 New translations common.json (English) 2025-06-25 22:34:12 +00:00
LinkwardenBot
918d0d46b2 New translations common.json (Chinese Traditional) 2025-06-25 22:34:12 +00:00
LinkwardenBot
a57ec28f3d New translations common.json (Chinese Simplified) 2025-06-25 22:34:12 +00:00
LinkwardenBot
fc4ba6a1fc New translations common.json (Ukrainian) 2025-06-25 22:34:12 +00:00
LinkwardenBot
b05c96360f New translations common.json (Turkish) 2025-06-25 22:34:12 +00:00
LinkwardenBot
37f13af8aa New translations common.json (Russian) 2025-06-25 22:34:12 +00:00
LinkwardenBot
1e24580010 New translations common.json (Polish) 2025-06-25 22:34:12 +00:00
LinkwardenBot
69c713bf50 New translations common.json (Dutch) 2025-06-25 22:34:12 +00:00
LinkwardenBot
e9853bc6f5 New translations common.json (Japanese) 2025-06-25 22:34:12 +00:00
LinkwardenBot
dbb055699a New translations common.json (Italian) 2025-06-25 22:34:12 +00:00
LinkwardenBot
da3d4e2c0a New translations common.json (German) 2025-06-25 22:34:12 +00:00
LinkwardenBot
27d88c2218 New translations common.json (Spanish) 2025-06-25 22:34:12 +00:00
LinkwardenBot
b59ccfffa1 New translations common.json (French) 2025-06-25 22:34:12 +00:00
daniel31x13
4bc0476e38 small fix 2025-06-25 18:30:51 -04:00
daniel31x13
eba81441ea minor fix 2025-06-25 18:24:59 -04:00
daniel31x13
4bd91f0b95 minor fix 2025-06-25 18:19:22 -04:00
daniel31x13
134757648d small fix 2025-06-25 18:09:31 -04:00
daniel31x13
86a5d0f965 small fix 2025-06-25 18:04:10 -04:00
daniel31x13
8a838f382a add locale-action.yml 2025-06-25 17:54:37 -04:00
daniel31x13
dd2361d1cf update readme 2025-06-25 16:10:16 -04:00
daniel31x13
a251e5e526 Revert "New translations common.json (Portuguese, Brazilian)"
This reverts commit f5633089f9.
2025-06-25 16:08:49 -04:00
daniel31x13
1e8a0fd1c9 Revert "add translation readme"
This reverts commit 1339b5d625.

revert
2025-06-25 15:40:12 -04:00
LinkwardenBot
d4cc066c76 Merge pull request #1245 from linkwarden/LinkwardenBot-patch-1
Update .prettierignore
2025-06-25 15:08:56 -04:00
LinkwardenBot
a1f2f3484b Update .prettierignore 2025-06-25 15:08:02 -04:00
daniel31x13
1339b5d625 add translation readme 2025-06-25 12:59:30 -04:00
Daniel
40abb89602 Merge pull request #1243 from linkwarden/dev-i18n
New Crowdin updates
2025-06-25 18:29:48 +03:30
Daniel
0a8d48a07a New translations common.json (English) 2025-06-25 18:19:34 +03:30
daniel31x13
b3530225f4 remove test 2025-06-25 10:48:40 -04:00
daniel31x13
78726a2f04 crowdin test commit 2025-06-25 10:43:10 -04:00
Daniel
c44f044505 Merge pull request #1242 from linkwarden/dev-i18n
New Crowdin updates
2025-06-25 18:11:19 +03:30
Daniel
8508360dc7 New translations common.json (Portuguese, Brazilian) 2025-06-25 18:09:30 +03:30
Daniel
c16b1c621c New translations common.json (English) 2025-06-25 18:09:28 +03:30
Daniel
e098fe1c39 New translations common.json (Chinese Traditional) 2025-06-25 18:09:27 +03:30
Daniel
7f8c856f6c New translations common.json (Chinese Simplified) 2025-06-25 18:09:26 +03:30
Daniel
8b1a88f326 New translations common.json (Ukrainian) 2025-06-25 18:09:25 +03:30
Daniel
9ced58eae8 New translations common.json (Turkish) 2025-06-25 18:09:24 +03:30
Daniel
3b2e5089d4 New translations common.json (Russian) 2025-06-25 18:09:23 +03:30
Daniel
3822475fa4 New translations common.json (Polish) 2025-06-25 18:09:21 +03:30
Daniel
929e23850b New translations common.json (Dutch) 2025-06-25 18:09:20 +03:30
Daniel
3645c6f0ff New translations common.json (Japanese) 2025-06-25 18:09:19 +03:30
Daniel
1ad44a81a3 New translations common.json (Italian) 2025-06-25 18:09:17 +03:30
Daniel
d4a808e808 New translations common.json (German) 2025-06-25 18:09:16 +03:30
Daniel
3953bb000c New translations common.json (Spanish) 2025-06-25 18:09:15 +03:30
Daniel
2be900a3f0 New translations common.json (French) 2025-06-25 18:09:14 +03:30
Daniel
92d335ddbe Merge pull request #1241 from linkwarden/dev-i18n
New Crowdin updates
2025-06-25 18:00:01 +03:30
Daniel
f5633089f9 New translations common.json (Portuguese, Brazilian) 2025-06-25 17:54:56 +03:30
Daniel
1b6f8f1fc8 New translations common.json (English) 2025-06-25 17:54:55 +03:30
Daniel
5cd0fbdf6f New translations common.json (Chinese Traditional) 2025-06-25 17:54:54 +03:30
Daniel
05a20d9279 New translations common.json (Chinese Simplified) 2025-06-25 17:54:52 +03:30
Daniel
91a5c548d8 New translations common.json (Ukrainian) 2025-06-25 17:54:51 +03:30
Daniel
95f3ae382a New translations common.json (Turkish) 2025-06-25 17:54:50 +03:30
Daniel
6e3354ff0b New translations common.json (Russian) 2025-06-25 17:54:49 +03:30
Daniel
9d92e0103c New translations common.json (Polish) 2025-06-25 17:54:48 +03:30
Daniel
a57fc3f1c2 New translations common.json (Dutch) 2025-06-25 17:54:47 +03:30
Daniel
cc3a719611 New translations common.json (Japanese) 2025-06-25 17:54:45 +03:30
Daniel
16107c8369 New translations common.json (Italian) 2025-06-25 17:54:44 +03:30
Daniel
43f6314ce2 New translations common.json (German) 2025-06-25 17:54:43 +03:30
Daniel
5884687abc New translations common.json (Spanish) 2025-06-25 17:54:42 +03:30
Daniel
835fe3de17 New translations common.json (French) 2025-06-25 17:54:40 +03:30
Daniel
f8839dbdd2 New translations common.json (Portuguese, Brazilian) 2025-06-25 17:39:39 +03:30
Daniel
b0987581c0 New translations common.json (Chinese Traditional) 2025-06-25 17:39:37 +03:30
Daniel
1a02ba64a8 New translations common.json (Chinese Simplified) 2025-06-25 17:39:36 +03:30
Daniel
0ed87d7ffc New translations common.json (Ukrainian) 2025-06-25 17:39:35 +03:30
Daniel
ddd47d74e3 New translations common.json (Turkish) 2025-06-25 17:39:33 +03:30
Daniel
213b0aafee New translations common.json (Russian) 2025-06-25 17:39:32 +03:30
Daniel
396e41a232 New translations common.json (Polish) 2025-06-25 17:39:30 +03:30
Daniel
e04c256bd3 New translations common.json (Dutch) 2025-06-25 17:39:29 +03:30
Daniel
2023d6984f New translations common.json (Japanese) 2025-06-25 17:39:28 +03:30
Daniel
43ebd72aca New translations common.json (Italian) 2025-06-25 17:39:26 +03:30
Daniel
572fa267d0 New translations common.json (German) 2025-06-25 17:39:25 +03:30
Daniel
846a233639 New translations common.json (Spanish) 2025-06-25 17:39:24 +03:30
Daniel
a6e3ae1de5 New translations common.json (French) 2025-06-25 17:39:22 +03:30
daniel31x13
c0159e5a27 minor fix 2025-06-25 09:38:16 -04:00
Daniel
ee1785ca6e Update Crowdin configuration file 2025-06-25 16:18:04 +03:30
daniel31x13
7bf4db9b25 refactor(web): use separator component 2025-06-24 14:52:09 -04:00
daniel31x13
803a97b7e0 code cleanup 2025-06-24 13:48:04 -04:00
daniel31x13
87715b8f62 revert crowdin.yml removal 2025-06-24 13:01:15 -04:00
Daniel
276a2a30c8 Update Crowdin configuration file 2025-06-24 20:29:10 +03:30
daniel31x13
bbad712e0d remove files 2025-06-24 12:51:57 -04:00
daniel31x13
32f481f65b minor fix 2025-06-24 12:34:41 -04:00
daniel31x13
b7e260e180 minor fix 2025-06-24 12:29:41 -04:00
daniel31x13
df89364c0d minor fix 2025-06-24 12:17:47 -04:00
daniel31x13
77731dcf8a minor fix 2025-06-24 12:16:05 -04:00
daniel31x13
292fadaa1e move file 2025-06-24 12:15:11 -04:00
daniel31x13
fc801f98a9 add crowdin.yml file 2025-06-24 12:10:47 -04:00
daniel31x13
a005e05d72 minor fix 2025-06-23 12:43:05 -04:00
daniel31x13
6124062a85 add crowdin file 2025-06-20 06:48:07 -04:00
Daniel
1a69058c7a Merge pull request #1226 from linkwarden/hotfix
hotfix
2025-06-12 14:38:04 +03:30
Daniel
4580f8cd26 Merge branch 'dev' into hotfix 2025-06-12 14:37:48 +03:30
Daniel
63050ae7c2 Merge pull request #1225 from linkwarden/hotfix
hotfix
2025-06-12 14:36:22 +03:30
Daniel
82f1bd943e hotfix 2025-06-12 14:35:34 +03:30
daniel31x13
6a8ce9614b fix build error 2025-06-11 11:55:47 -04:00
Daniel
60bb97e4ca Merge pull request #1223 from linkwarden/feat/import-from-pocket
feat(web): add import from pocket
2025-06-11 19:12:49 +03:30
daniel31x13
c9f2799618 feat(web): add import from pocket 2025-06-11 11:40:34 -04:00
Daniel
96b508c41a Merge pull request #1222 from linkwarden/feat/redesigned-dashboard
Feat/redesigned dashboard
2025-06-11 16:33:44 +03:30
daniel31x13
a9206ca6c9 bug fixed 2025-06-10 18:18:57 -04:00
daniel31x13
441e97e4d9 minor improvement 2025-06-10 17:46:05 -04:00
daniel31x13
a918d2c960 small change 2025-06-10 17:31:06 -04:00
daniel31x13
c9860c535b add placeholder 2025-06-10 17:28:19 -04:00
daniel31x13
c2a660fd50 better looking view 2025-06-10 17:11:42 -04:00
daniel31x13
1790638012 WIP 2025-06-10 16:32:03 -04:00
daniel31x13
c9c3941688 improvements 2025-06-10 12:55:33 -04:00
daniel31x13
9f376a633c improvements 2025-06-10 08:53:09 -04:00
daniel31x13
fa0c08a1b2 minor fix 2025-06-10 07:50:47 -04:00
daniel31x13
4e0d7f5d8e add default rows 2025-06-10 07:44:21 -04:00
Daniel
59b6b7228e Merge pull request #1216 from il516/dashboard-layout
feat(dashboard): add dashboard layout reordering
2025-06-10 14:47:22 +03:30
daniel31x13
352389958c bug fixed 2025-06-09 05:16:24 -04:00
daniel31x13
6c75f3ee58 WIP 2025-06-09 04:18:37 -04:00
daniel31x13
792d03f906 small tweaks 2025-06-08 08:46:12 -04:00
Isaac
82f4921038 fix building 2025-06-07 19:05:42 -05:00
Isaac
ff497b5f66 uncomment dropdown 2025-06-07 18:45:44 -05:00
Isaac
e012c8953c fix returned links 2025-06-07 18:42:48 -05:00
Isaac
06d8cf3cb8 format 2025-06-07 18:29:19 -05:00
Isaac
7ce78fc314 final fixes 2025-06-07 18:27:57 -05:00
Isaac
de5a43cea4 fix type 2025-06-06 23:58:41 -05:00
Isaac
6e47cc6897 Merge branch 'linkwarden:main' into dashboard-layout 2025-06-06 23:49:53 -05:00
Isaac
62b1bcbc59 fixes 2025-06-06 23:49:35 -05:00
Cory Claflin
be532d5455 Add Synology OIDC as login option based upon Authelia settings successful login 2025-06-06 22:22:35 -05:00
Isaac
8718b1acfe Merge branch 'dev' of https://github.com/IsaacWise06/linkwarden into dashboard-layout 2025-06-06 22:14:46 -05:00
Isaac
f36d36dec7 optimistic update 2025-06-06 21:44:01 -05:00
Isaac
8b6ded9179 Better drag and drop 2025-06-06 21:21:14 -05:00
Isaac
4333d2686c Improve endpoint & reordering when a collection is deleted 2025-06-06 21:06:34 -05:00
daniel31x13
78c1f6246f minor fix 2025-06-06 16:52:41 -04:00
daniel31x13
941bc04fc8 small improvement 2025-06-06 16:45:52 -04:00
daniel31x13
cfa36fc8da bug fixed 2025-06-06 16:33:47 -04:00
daniel31x13
9388073cbf bug fix 2025-06-06 16:11:54 -04:00
daniel31x13
90eeba5191 feat(web): add commenting functionality 2025-06-06 11:18:03 -04:00
daniel31x13
95846c1d09 feat(web): add comments for highlights ui 2025-06-06 10:06:40 -04:00
daniel31x13
31b8092472 small improvement 2025-06-06 08:53:33 -04:00
daniel31x13
2a62c1ee1a feat(web): added highlights drawer 2025-06-06 08:49:22 -04:00
daniel31x13
5a028e98e3 minor improvement 2025-06-05 09:06:27 -04:00
Daniel
8ed5147762 Merge pull request #1209 from Talkabout/dev
improve preview generation for webpages in case site does not provide an icon
2025-06-05 00:52:07 +03:30
Daniel
a20c4a67a8 Merge branch 'dev' into dev 2025-06-05 00:51:52 +03:30
daniel31x13
bf8ac7d801 minor fix 2025-06-04 17:16:21 -04:00
Daniel
2971871a51 Merge pull request #1196 from Vlad1mir-D/fix/952-invalid-image-url
fix(worker): determine origin for ogImageUrl (closes #952)
2025-06-05 00:17:44 +03:30
Daniel
834714a941 Merge pull request #1182 from redrathnure/fix-file-handling
fix: "Error copying file" when no src file
2025-06-04 17:24:26 +03:30
daniel31x13
08523b2234 minor fix 2025-06-04 09:52:48 -04:00
daniel31x13
479fea3ea4 minor improvement 2025-06-03 17:22:53 -04:00
daniel31x13
a34f1d7308 bug fixes 2025-06-03 17:15:21 -04:00
Daniel
b18ce2239a Merge pull request #1210 from linkwarden/feat/improved-readable-page
Feat/improved readable page
2025-06-03 23:57:19 +03:30
daniel31x13
17f32152c3 minor fix 2025-06-03 16:23:06 -04:00
daniel31x13
47c711fea6 feat(web): finished readable dropdown 2025-06-03 15:57:32 -04:00
Isaac
4423a30a72 format 2025-06-02 15:52:08 -05:00
Isaac
cefe5e9e1b Bug fixes 2025-06-02 15:47:28 -05:00
Talkabout
d30fb4645e improve preview generation for webpages in case site does not provide an icon 2025-06-02 18:08:48 +02:00
daniel31x13
ae099dce4f minor improvement 2025-06-02 03:17:46 -04:00
daniel31x13
7c740f01d2 feat(web): sleeker alert + remove collection name confirmation requirement 2025-06-02 03:12:50 -04:00
daniel31x13
2b8964ca64 feat(web): add a text style dropdown to readable view 2025-06-02 02:46:24 -04:00
Isaac
048842efa4 format 2025-06-01 17:14:28 -05:00
Isaac
29d90db991 basic endpoint, drag and drop, and ordering on dashboard 2025-06-01 17:13:54 -05:00
Isaac
c99067ac37 Start endpoint 2025-05-31 22:46:58 -05:00
Isaac
4a66e1ec9c Dropdown, database & start endpoint 2025-05-31 17:19:02 -05:00
daniel31x13
1211b6b1ef feat(web): hide navbar in readable view 2025-05-30 12:26:57 -04:00
Isaac
fbb1ce9687 another constraint 2025-05-29 23:35:09 -05:00
Isaac
3959a42a30 database 2025-05-29 23:19:22 -05:00
Isaac
759cb15148 fix 2025-05-29 23:13:23 -05:00
Isaac
8ecd35acfe Start reordering dropdown 2025-05-29 23:10:44 -05:00
daniel31x13
db9ea8eef4 improved readable view 2025-05-28 12:00:07 -04:00
daniel31x13
b32151eb7e WIP 2025-05-28 09:36:25 -04:00
daniel31x13
d377fa6eb0 minor fix 2025-05-27 21:48:01 -04:00
daniel31x13
4b958faf7e improved tabs 2025-05-27 17:58:40 -04:00
daniel31x13
6981ad6d7a refactor(web): use the new tooltip component 2025-05-27 15:20:23 -04:00
daniel31x13
2d97f3a138 small improvement 2025-05-27 12:47:33 -04:00
daniel31x13
e668b67a3d refactor(web): use the new button component for consistency and improved styling 2025-05-27 12:44:29 -04:00
Daniel
41eb7df457 Merge pull request #1202 from linkwarden/feat/improved-ui
Feat/improved UI
2025-05-26 20:02:08 +03:30
Daniel
7725a5dd8b Merge branch 'dev' into feat/improved-ui 2025-05-26 20:01:41 +03:30
daniel31x13
37588acc0d Merge branch 'dev' of https://github.com/linkwarden/linkwarden into dev 2025-05-26 12:27:43 -04:00
daniel31x13
6ad2511fbc Merge branch 'PeterDaveHelloKitchen-zh-TW' into dev 2025-05-26 12:26:17 -04:00
daniel31x13
f3cb12c962 Merge branch 'zh-TW' of https://github.com/PeterDaveHelloKitchen/linkwarden into PeterDaveHelloKitchen-zh-TW 2025-05-26 12:25:30 -04:00
Daniel
99fa0b1d8b Merge pull request #1188 from SoCuul/fix/name-capitalization
fix(web): fix capitalization of collection names
2025-05-26 19:49:35 +03:30
Daniel
9db7ff329a Merge pull request #1201 from il516/bulk-refresh-preservations
feat(links): Add refresh preserved formats to bulk actions
2025-05-26 19:47:32 +03:30
daniel31x13
6bdf0f1c91 small change 2025-05-26 12:16:21 -04:00
Isaac
cd23f10480 format 2025-05-25 15:11:20 -05:00
Isaac
bea11edbe8 tooltip 2025-05-25 15:09:36 -05:00
Isaac
d565958e5f Fix permissions 2025-05-24 22:47:33 -05:00
daniel31x13
7222b19745 minor change 2025-05-24 17:05:32 -04:00
daniel31x13
b9be960c7e improved dropdown UI 2025-05-24 17:01:15 -04:00
Isaac
b337045ab2 Start bulk refreshing preserved formats 2025-05-23 21:23:07 -05:00
Vladimir
14db14ff07 fix(worker): determine origin for ogImageUrl (closes #952) 2025-05-24 02:20:04 +03:00
daniel31x13
6ca2774b28 replaced button component with shadcn button 2025-05-23 18:49:25 -04:00
daniel31x13
c3d702ad53 minor fix 2025-05-23 18:07:40 -04:00
daniel31x13
921d8a3718 base comment for shadcn 2025-05-23 17:42:48 -04:00
daniel31x13
2b258680b1 minor improvement to the dropdown component 2025-05-23 16:58:56 -04:00
daniel31x13
82f5a7026f feat(web): make things less rounded 2025-05-23 15:52:46 -04:00
daniel31x13
daef130728 feat(mobile): add modal component 2025-05-23 10:23:38 -04:00
daniel31x13
a181b43529 refactor(mobile): use nativewind instead of regular styles 2025-05-22 11:57:33 -04:00
Daniel
968bd04b40 Merge pull request #1194 from linkwarden/hotfix
minor fix
2025-05-22 13:31:52 +03:30
Daniel
46c20af530 fix logo 2025-05-22 13:30:00 +03:30
daniel31x13
cfb4081112 minor fix 2025-05-22 05:55:55 -04:00
daniel31x13
61ca5cf3e5 feat(mobile): add persistence 2025-05-19 20:14:50 -04:00
daniel31x13
cecbf694f5 fix(mobile): replace react-native-render-html package 2025-05-14 13:36:24 -04:00
daniel31x13
0e122c7485 feat(mobile): view readable formats 2025-05-14 09:36:55 -04:00
daniel31x13
c6713f67f4 feat(mobile): preserved formats basic viewing functionality 2025-05-12 12:02:31 -04:00
daniel31x13
dc8e763b76 feat(mobile): add search functionality 2025-05-12 06:27:10 -04:00
daniel31x13
2a52a0c79f fix(web): improved prompt output 2025-05-12 04:09:54 -04:00
SoCuul
08e1f499e1 fix(web): fix capitalization of collection names 2025-05-10 00:52:32 -07:00
Peter Dave Hello
63196e7b99 Update and improve zh-TW Traditional Chinese locale 2025-05-07 22:34:28 +08:00
Maxim Medvedev
7cae35ce8d fix: "Error copying file" when no src file 2025-05-06 18:52:06 +02:00
daniel31x13
369b3d6207 fix(web): active tabs now are adjusted properly 2025-05-06 08:51:10 -04:00
Daniel
3762d971e9 Merge pull request #1181 from linkwarden/dev
minor fix
2025-05-06 06:40:59 +03:30
daniel31x13
f3d8a3cc95 minor fix 2025-05-05 23:09:33 -04:00
Daniel
56efeecbfd Merge pull request #1180 from linkwarden/dev
v2.10.2
2025-05-06 06:08:22 +03:30
Daniel
be8aef987a Merge pull request #1174 from wnor543/dev
feat(web): add support for http/https proxies
2025-05-06 06:03:47 +03:30
daniel31x13
4b9de5cd96 add https-proxy-agent to worker as well 2025-05-05 22:33:11 -04:00
daniel31x13
59949e21ee bump version 2025-05-05 22:17:34 -04:00
Daniel
6fda674529 Merge pull request #1179 from linkwarden/dev
fix(worker): prevent sendToWayback from crashing
2025-05-06 02:49:50 +03:30
daniel31x13
4318207c39 fix(worker): prevent sendToWayback from crashing 2025-05-05 19:18:56 -04:00
Daniel
c48ad18379 Merge pull request #1178 from linkwarden/dev
Dev
2025-05-06 02:40:50 +03:30
daniel31x13
d647c60f04 fix(worker): exit monolith processes appropriately 2025-05-05 19:08:52 -04:00
wnor543
f83a9e899c feat(web): add support for http/https proxies 2025-05-05 11:01:45 +00:00
daniel31x13
b7c0cef8de feat(mobile): open saved items in webview 2025-05-04 13:40:43 -04:00
daniel31x13
14340afb99 code cleanup 2025-05-04 12:01:24 -04:00
Daniel
b903a12f8d Merge pull request #1171 from linkwarden/dev
minor fix
2025-05-03 19:14:04 +03:30
daniel31x13
c6f7c18441 minor fix 2025-05-03 11:42:36 -04:00
Daniel
6d334be82e Merge pull request #1170 from linkwarden/dev
v2.10.1
2025-05-03 18:14:02 +03:30
daniel31x13
195cb99c90 log processing queue to console 2025-05-03 09:10:39 -04:00
daniel31x13
2ebd311e0e small fix 2025-05-03 09:10:10 -04:00
Daniel
1da1f17ea3 Merge pull request #1168 from simcop2387/main
Implement more of the vercel openai sdk bits to allow for api compatible servers
2025-05-03 16:02:59 +03:30
daniel31x13
bc36513952 used the "@ai-sdk/openai-compatible" package instead 2025-05-03 08:15:13 -04:00
daniel31x13
b04ab898d7 bug fix 2025-05-02 11:31:01 -04:00
Ryan Voots
778dd764f6 Merge remote-tracking branch 'upstream/dev' 2025-05-02 09:11:50 -04:00
daniel31x13
64f8922741 bump meilisearch timeout and make it configurable 2025-05-02 00:28:21 -04:00
Ryan Voots
c8ed9ac72d Implement more of the vercel openai sdk bits to allow for api compatible proxies and servers 2025-05-01 22:15:08 -04:00
daniel31x13
6cfd26da32 bug fix 2025-05-01 05:48:50 -04:00
daniel31x13
b56cf3faa4 bug fix 2025-05-01 05:33:59 -04:00
daniel31x13
f7526df008 bug fix 2025-04-30 11:52:14 -04:00
daniel31x13
bbeba7f50f improved mobile app + fixed a bug in the worker script 2025-04-30 10:16:24 -04:00
daniel31x13
7e5fa3eacd minor improvement 2025-04-27 11:52:39 -04:00
daniel31x13
89826bd721 add loader 2025-04-27 11:21:34 -04:00
daniel31x13
fb819b6142 ui fix 2025-04-27 09:51:39 -04:00
daniel31x13
2e131403d4 add LinkListing component 2025-04-27 09:43:02 -04:00
daniel31x13
83db594fde bug fixed 2025-04-27 01:31:18 -04:00
daniel31x13
83b002d585 remove old readme 2025-04-24 06:07:36 -04:00
Daniel
7207259cdf Merge pull request #1155 from linkwarden/mobile-app
Mobile-app
2025-04-24 12:09:51 +03:30
daniel31x13
7c647ae02d bug fix 2025-04-23 16:44:13 -04:00
daniel31x13
0630ea536e bug fixed 2025-04-23 15:54:21 -04:00
daniel31x13
f2f73fc894 bug fixed 2025-04-23 10:49:11 -04:00
daniel31x13
7887f55dd0 bug fix 2025-04-21 20:04:16 -04:00
daniel31x13
5ba759bb41 base commit for mobile app 2025-04-21 19:05:49 -04:00
daniel31x13
f325be0364 remove old architecture file 2025-04-21 12:55:14 -04:00
daniel31x13
f3c8647ff2 move react-query to the @linkwarden/router package 2025-04-21 10:14:08 -04:00
daniel31x13
ceb6b8f8e7 add router directory 2025-04-21 09:28:59 -04:00
daniel31x13
94d953d449 adapt docker to the new folder structure + bug fix 2025-04-19 17:48:01 -04:00
daniel31x13
7f74fd75c9 made everything functional across the workspaces 2025-04-19 10:48:22 -04:00
daniel31x13
4151b37f9f separate prisma as a package 2025-04-14 13:26:55 -04:00
daniel31x13
1b45286aaf move files to /apps/web directory 2025-04-10 18:38:59 -04:00
Daniel
d578fdc0c4 Merge pull request #1104 from linkwarden/dev
v2.10.0
2025-04-08 00:42:32 +03:30
Daniel
2f2747cfc8 Merge pull request #1083 from sinsky/openrouter-provider
OpenRouter AI Provider
2025-04-05 16:59:46 +03:30
Daniel
f254bd85b5 Merge pull request #1109 from TheMeier/gitlab_url
feat(gitlab-auth): allow to configure GitLab instance
2025-04-04 21:51:53 +03:30
daniel31x13
a321d12307 small fix 2025-04-04 14:02:32 -04:00
daniel31x13
0e5e3ea00e finished highlight feature implementation 2025-04-04 13:39:54 -04:00
daniel31x13
6599fd7f5a minor fix 2025-04-04 11:29:46 -04:00
daniel31x13
225288c742 minor fix 2025-04-04 11:29:17 -04:00
daniel31x13
9bf77e849f minor improvement 2025-04-04 11:16:10 -04:00
daniel31x13
44ae6d0dbf WIP 2025-04-01 02:17:00 -04:00
Christoph Maser
3684204a03 feat(gitlab-auth): allow to configure GitLab instance
This change allows to configure the GitLab instance URL in the
`.env` file. This is useful for self-hosted GitLab instances.
2025-03-30 16:49:29 +02:00
daniel31x13
5d17b628cc small change 2025-03-20 23:41:24 -04:00
daniel31x13
197a5b3b74 bug fix 2025-03-20 23:25:32 -04:00
daniel31x13
278b674ea7 small change 2025-03-20 08:00:50 -04:00
daniel31x13
78e8078d6b upped the size limits for the preservations 2025-03-20 00:11:04 -04:00
daniel31x13
2f05dbad5d improvements to docker 2025-03-19 23:47:31 -04:00
daniel31x13
0987475e41 revert change 2025-03-19 11:56:42 -04:00
daniel31x13
41e34ba4ec small improvement 2025-03-19 11:52:43 -04:00
daniel31x13
dd78c1570f bug fix 2025-03-18 09:20:40 -04:00
daniel31x13
5bf90c56de bug fix 2025-03-18 08:33:24 -04:00
daniel31x13
35643faf85 bug fixed 2025-03-12 10:36:47 -04:00
Daniel
72c5a05324 Merge pull request #1093 from linkwarden/feat/text-highlighting
Feat/text highlighting
2025-03-12 17:34:49 +03:30
daniel31x13
0de7988b29 update highlight functionality 2025-03-11 20:12:30 -04:00
daniel31x13
43d5f0a205 remove highlight client side functionality 2025-03-11 19:57:26 -04:00
daniel31x13
d703ff072c add remove highlight server side 2025-03-11 09:22:47 -04:00
daniel31x13
6f80fa62d2 view highlights + bug fixed 2025-03-10 22:46:56 -04:00
daniel31x13
34b914b91f clear comments 2025-03-10 17:02:31 -04:00
daniel31x13
c6c8dab5db post highlight functionality 2025-03-09 14:42:06 -04:00
sinsky
d4bbdebe31 added openrouter ai provider 2025-03-07 02:09:36 +09:00
daniel31x13
2f5c431fa7 small improvement 2025-03-04 08:56:14 -05:00
daniel31x13
6d92ce64bd bug fix 2025-03-04 08:48:45 -05:00
daniel31x13
6c006bb748 bug fix 2025-03-04 08:42:03 -05:00
daniel31x13
d2cb7604fa improved preservation page 2025-03-04 08:29:12 -05:00
daniel31x13
dd061e9dc8 wip 2025-03-04 07:06:22 -05:00
daniel31x13
63c50d96d7 small improvement 2025-03-03 22:47:50 -05:00
daniel31x13
1360a03eb5 revert modal 2025-03-03 18:01:02 -05:00
daniel31x13
902c724f39 remove tiptap 2025-03-03 17:22:57 -05:00
daniel31x13
1677e5e0ab bug fixed 2025-03-02 11:56:40 -05:00
daniel31x13
1989510aac Merge branch 'dev' of https://github.com/linkwarden/linkwarden into dev 2025-03-02 05:55:52 -05:00
daniel31x13
ef3cf4bcfa add line to .env.sample 2025-03-02 05:55:46 -05:00
Daniel
645df6c0aa Merge pull request #1069 from weikinhuang/remote-playwright
Add PLAYWRIGHT_WS_URL to connect to a remote chrome instance
2025-03-02 14:24:56 +03:30
Daniel
dfad34c3dd Merge pull request #1042 from clemenstyp/azure-ai-provider
Azure AI provider
2025-03-02 13:46:37 +03:30
Daniel
f5abe4e1a2 Merge pull request #1067 from sur5r/linkcounttitlefix
Fix collection card link count title
2025-03-02 13:42:57 +03:30
Daniel
79e447c58d Merge pull request #1076 from linkwarden/feat/meilisearch-implementation
Feat/meilisearch implementation
2025-03-02 13:38:49 +03:30
daniel31x13
bc013e7819 implement the meilisearch for the public page 2025-02-28 06:02:41 -05:00
Wei Kin Huang
12844f2529 Add PLAYWRIGHT_WS_URL to connect to a remote chrome instance 2025-02-26 20:00:25 -05:00
daniel31x13
92a66933ce remove unused code 2025-02-26 09:51:54 -05:00
daniel31x13
bc01b6d4a9 remove filter dropdown 2025-02-26 09:18:22 -05:00
Jakob Haufe
23650bb684 Fix collection card link count title
The link icon on the collection card has a wrong title which is even
misleading if the collection is NOT shared publicly.

This was originally introduced in cf1306d2c4
and seemingly never noticed.
2025-02-25 11:21:14 +01:00
daniel31x13
140ee8c65d bug fix 2025-02-22 16:53:56 -05:00
daniel31x13
7c153d841a bug fix 2025-02-22 16:38:59 -05:00
daniel31x13
e06f52642a improvements 2025-02-22 11:18:53 -05:00
daniel31x13
874a909d1c minor improvement 2025-02-22 10:28:01 -05:00
daniel31x13
bbe702925b add negative queries 2025-02-22 10:22:42 -05:00
daniel31x13
49262d52cf bug fix 2025-02-22 07:52:23 -05:00
daniel31x13
5a468b44a1 wip 2025-02-22 07:19:48 -05:00
daniel31x13
6678f9c971 bump timeout 2025-02-16 13:22:39 -05:00
daniel31x13
b5a27968de bug fix 2025-02-16 12:43:51 -05:00
daniel31x13
4f1c6855aa bug fix 2025-02-16 12:11:43 -05:00
daniel31x13
50c7fcd012 bug fix 2025-02-16 11:23:03 -05:00
daniel31x13
de63ba523e small change 2025-02-16 11:16:13 -05:00
daniel31x13
5e2362ea62 bug fixed 2025-02-16 11:12:37 -05:00
daniel31x13
db94b01859 update take logic 2025-02-16 09:58:55 -05:00
daniel31x13
9bd821baa9 refactored search 2025-02-15 17:14:26 -05:00
daniel31x13
b69b535155 add package 2025-02-11 05:54:41 -05:00
Clemens Eyhoff
0bee36eced added azure ai provider 2025-02-11 10:55:05 +01:00
daniel31x13
d044a6cbba pass version to config route 2025-02-10 05:07:52 -05:00
Daniel
8095010835 Merge pull request #1039 from il516/worker
feat(archival): Implement archival tags and support deletion/updating of existing preservations
2025-02-09 21:03:41 +03:30
daniel31x13
bd21c3d8c3 more improved UI 2025-02-09 12:32:38 -05:00
daniel31x13
2bacf6e07d minor improvements 2025-02-09 10:40:20 -05:00
Isaac
5c73d7097f don't need this 2025-02-08 22:32:25 -06:00
Isaac
f77236d49e FIX 2025-02-08 22:29:13 -06:00
Isaac
1152571e49 format 2025-02-08 22:20:28 -06:00
Isaac
eda41d7132 Fix console warning 2025-02-08 22:20:16 -06:00
Isaac
dd58a8565a more fixes 2025-02-08 22:14:18 -06:00
Isaac
64d20df6f1 Fix tag selection & deleting new tags 2025-02-08 21:47:51 -06:00
Isaac
cd3f5f7e70 revert 2025-02-08 21:13:12 -06:00
Isaac
fca04dd0a0 fix building 2025-02-08 21:11:01 -06:00
Isaac
e024b324ed format 2025-02-08 20:57:14 -06:00
Isaac
f012eaee33 move api route & update schema 2025-02-08 20:56:36 -06:00
Isaac
794f8f07fa use existing TagSelection 2025-02-08 20:26:53 -06:00
Isaac
0a9aa774cc use existing modal for confirmation modal 2025-02-08 20:09:36 -06:00
Isaac
ebbc23f581 Make sure the user has an AI tagging method set 2025-02-07 20:39:01 -06:00
Isaac
bf7722aa2e format 2025-02-07 17:45:14 -06:00
Isaac
33637a8bca Regenerate preview when choosing to represerve 2025-02-07 17:44:26 -06:00
Isaac
80962c5df6 format & lint 2025-02-07 14:58:01 -06:00
Isaac
7fa9621de1 Merge branch 'dev' into worker 2025-02-07 14:52:30 -06:00
Isaac
d17fed9c85 Finish delete/update preservations 2025-02-07 14:45:18 -06:00
Isaac
81536e61f7 Update text & add third option 2025-02-06 21:32:53 -06:00
Isaac
c790781315 Start worker archival update/delete options 2025-02-06 21:21:18 -06:00
Isaac
e7a067b358 Verify user is admin 2025-02-06 18:06:01 -06:00
Isaac
4156126a71 add archival delete/update route 2025-02-06 18:04:52 -06:00
Isaac
95f456bbb6 Add tag archival settings to the worker 2025-02-06 17:34:49 -06:00
Isaac
102cd1a6cf Fix endpoint & useMutation callback 2025-02-06 16:49:26 -06:00
Isaac
395dc5c0cb Track changes to prevent useless requests 2025-02-06 16:20:19 -06:00
Isaac
7f0c8f4bbf finish endpoint 2025-02-05 18:48:41 -06:00
Isaac
671f27ccde fix deleting archival tags 2025-02-05 17:46:36 -06:00
Isaac
c90b53376d Archival tags post route 2025-02-05 16:47:16 -06:00
daniel31x13
13ed7b6cdc bug fixed 2025-02-05 06:49:38 -05:00
Daniel
3c71301cbc Merge pull request #1021 from stuzer05/dev
Add collection select sorting, fix inconsistent sidebar reorder
2025-02-05 15:15:40 +03:30
daniel31x13
833a871731 remove extra logging 2025-02-05 05:56:54 -05:00
daniel31x13
7b3a4e48c1 remove note modal to focus on advanced bookmarking functionality 2025-02-05 05:50:18 -05:00
Isaac
76112534f6 remove duplicate functions 2025-02-04 17:06:43 -06:00
Isaac
adafe36cd7 Merge branch 'worker' of https://github.com/il516/linkwarden into worker 2025-02-04 16:58:00 -06:00
Isaac
0123791b8a use a hook to manage state for archival tags input 2025-02-04 16:51:56 -06:00
Isaac
81f150c0ce Merge branch 'linkwarden:main' into worker 2025-02-04 01:50:12 -06:00
Isaac
9177ad6a72 complete toggling options 2025-02-03 21:53:16 -06:00
Isaac
d360f612e2 add ai tagging & fix select 2025-02-03 21:13:41 -06:00
Daniel
b0496e2e65 Merge pull request #942 from AverageHelper/avg/decode-html-entities
[Fix] Work around parser bug that mangles attribute values
2025-02-03 19:35:28 +03:30
daniel31x13
c5751386fa undo changes to yarn.lock 2025-02-03 11:05:10 -05:00
Daniel
406647d687 Merge pull request #1003 from Kur0den/dev
Fix mistranslation and add new translation in Japanese
2025-02-03 18:11:46 +03:30
Daniel
5949a0965c Merge pull request #1017 from YeeJiaWei/fix/double-overflow
fixed height causing double overflow
2025-02-03 18:01:24 +03:30
daniel31x13
c015bed8a7 small improvement 2025-02-03 09:30:52 -05:00
daniel31x13
504af53f18 bug fix 2025-02-03 08:26:25 -05:00
daniel31x13
d822db440b improved UX 2025-02-03 08:22:18 -05:00
daniel31x13
181e1009e5 fix build errors 2025-02-03 07:47:46 -05:00
Daniel
db16044949 Merge pull request #1028 from il516/more-ai-providers
feat(tags): Ability to use OpenAI/Anthropic & auto tag existing links
2025-02-03 16:09:03 +03:30
daniel31x13
1626a277e0 better UX 2025-02-03 07:38:16 -05:00
daniel31x13
616efffed2 improvements 2025-02-03 06:57:22 -05:00
daniel31x13
c9dd143d59 remove extra logging 2025-02-03 04:14:15 -05:00
Isaac
0a4d62491d Start tag archival options 2025-02-02 15:59:37 -06:00
daniel31x13
9e417b7f35 bug fixed 2025-02-02 11:30:29 -05:00
daniel31x13
81e9b27683 small change 2025-02-02 03:09:20 -05:00
Isaac
942ae4af99 format 2025-02-01 18:03:26 -06:00
Isaac
f7e9119450 Remove comment 2025-02-01 18:03:10 -06:00
Isaac
d71216a908 Add user option to archive as readable 2025-02-01 18:02:12 -06:00
Isaac
f61ce5563d Max tag name length 2025-02-01 17:10:31 -06:00
Isaac
575d91832e format 2025-02-01 17:07:56 -06:00
Isaac
15fe4575f9 Use existing tags for ai tagging 2025-02-01 17:04:54 -06:00
daniel31x13
9ba9b06ae3 remove comments 2025-02-01 06:27:14 -05:00
daniel31x13
cf16344aef add note modal 2025-02-01 06:25:39 -05:00
Isaac
29ed07c74a format 2025-01-29 17:25:52 -06:00
Isaac
493e0e2f6b Merge branch 'more-ai-providers' of https://github.com/il516/linkwarden into more-ai-providers 2025-01-29 17:04:52 -06:00
Isaac
d76d99844c remove console log 2025-01-29 17:04:28 -06:00
Isaac
533f29706e Merge branch 'linkwarden:main' into more-ai-providers 2025-01-29 15:28:14 -06:00
Isaac
88cf45c7c2 format 2025-01-29 14:12:48 -06:00
Isaac
b100129d80 use env interval 2025-01-29 14:12:10 -06:00
Isaac
a267d4ed3a format 2025-01-29 14:09:15 -06:00
Isaac
9d8d5f0fa0 auto tag existing links & use meta description 2025-01-29 14:08:34 -06:00
daniel31x13
aa6b068d92 remove unused imports 2025-01-29 04:37:08 -05:00
daniel31x13
4b230e01d3 fix typo 2025-01-29 03:01:13 -05:00
daniel31x13
d140e2109f wip 2025-01-29 02:44:13 -05:00
Isaac
d5703ba70e Fix ollama 2025-01-28 20:01:56 -06:00
Isaac
5f12046a49 Provide list of models for openai & anthropic 2025-01-28 19:26:52 -06:00
Isaac
edad55d608 use provided ollama URL 2025-01-28 19:22:00 -06:00
Isaac
fac46de09c Add OpenAI & Anthropic 2025-01-28 19:19:16 -06:00
stuzer05
007de56cd3 Commit 2025-01-27 10:45:33 +02:00
stuzer05
8efdf6d87b Commit 2025-01-27 10:34:23 +02:00
daniel31x13
69225e0642 minor improvement 2025-01-27 02:40:13 -05:00
daniel31x13
0aa23e27b3 minor fix 2025-01-27 02:16:23 -05:00
daniel31x13
3bf2daddd1 improvements 2025-01-27 02:08:12 -05:00
stuzer05
71644e6e4e Commit 2025-01-27 08:55:51 +02:00
daniel31x13
6e7f92c046 improvements 2025-01-27 00:59:57 -05:00
daniel31x13
8d4504262b small fix 2025-01-27 00:34:36 -05:00
daniel31x13
20224c835a small improvement 2025-01-27 00:25:19 -05:00
daniel31x13
c3873b030f full width expanded mode 2025-01-27 00:20:17 -05:00
daniel31x13
cf8a202afa small fix 2025-01-27 00:05:34 -05:00
daniel31x13
c79b1e6492 cleaner code 2025-01-27 00:04:33 -05:00
daniel31x13
e21e3ecaae minor change 2025-01-26 14:12:39 -05:00
daniel31x13
b84840e12c wip 2025-01-25 11:35:01 -05:00
daniel31x13
f8a130ae6e add component 2025-01-25 10:11:34 -05:00
daniel31x13
852de0d587 minor fix 2025-01-24 18:00:50 -05:00
daniel31x13
eecfb112e3 small change 2025-01-24 17:45:01 -05:00
daniel31x13
81a35655e9 add portal wrapper component 2025-01-24 17:28:29 -05:00
Yee Jia Wei
c45b44cdbc fixed height causing double overflow 2025-01-25 02:56:35 +08:00
daniel31x13
ecc48f8fe2 wip 2025-01-24 06:10:44 -05:00
daniel31x13
e1c4f85e53 small fix 2025-01-22 04:01:22 -05:00
daniel31x13
f8c96b493c wip 2025-01-22 03:49:16 -05:00
daniel31x13
63904f6d41 small fix 2025-01-19 12:37:32 -05:00
Daniel
9083d9a01b Merge pull request #1008 from linkwarden:patch
add issue template
2025-01-19 10:53:51 -05:00
Daniel
806cba8110 Merge pull request #1007 from linkwarden/patch
add issue template
2025-01-19 10:51:50 -05:00
daniel31x13
ae24358a77 add issue template 2025-01-19 10:50:44 -05:00
daniel31x13
f0dfd5568e small fix 2025-01-18 16:19:20 -05:00
daniel31x13
9d17600124 bug fix 2025-01-18 15:58:58 -05:00
daniel31x13
6a72d7894b bug fixed 2025-01-18 00:56:31 -05:00
daniel31x13
00c33d48f0 wip 2025-01-18 00:36:55 -05:00
daniel31x13
6573c683f6 support for markdown for readable content 2025-01-15 22:11:26 -05:00
Kur0den0010
5412545c6f Change translate in Japanese
Change start_journey
2025-01-16 10:54:18 +09:00
Kur0den0010
eee06e2be9 Add new translation in Japanese
Add invalid_url_guide to search_query_invalid_symbol
2025-01-16 10:52:01 +09:00
Kur0den0010
c3e8097aac Add missing translation keys in Japanese
Add invalid_url_guide to search_query_invalid_symbol
2025-01-16 10:45:16 +09:00
Kur0den0010
2d97feef17 Add missing translation in Japanese
Add from_omnivore
2025-01-16 10:43:42 +09:00
Kur0den0010
74c0a40622 Fix mistranslation in Japanese
Change created
2025-01-16 10:42:06 +09:00
daniel31x13
56741b123b minor fix 2025-01-15 19:02:06 -05:00
daniel31x13
e8c6cc45f4 minor fix 2025-01-15 18:33:22 -05:00
daniel31x13
d0c999655c improved preservation view 2025-01-15 18:32:29 -05:00
daniel31x13
05594e6507 redesigned preserved view 2025-01-15 12:10:17 -05:00
daniel31x13
b59663ea91 WIP 2025-01-15 05:56:11 -05:00
daniel31x13
8d2029a19d wip 2025-01-15 05:04:21 -05:00
daniel31x13
cee2f0a759 WIP 2025-01-15 02:57:27 -05:00
daniel31x13
032f96191e WIP 2025-01-15 02:33:39 -05:00
Daniel
e87bfc83bf Merge pull request #996 from linkwarden/dev
v2.9.3
2025-01-13 11:17:32 -05:00
daniel31x13
aa0c7d64f4 bug fixes 2025-01-13 11:12:41 -05:00
Daniel
b8a1839fb6 Merge pull request #972 from siberianspot/main
Localization and small fixes
2025-01-12 18:46:32 -05:00
Daniel
629574c6b2 Merge branch 'dev' into main 2025-01-12 18:45:51 -05:00
daniel31x13
1bb7d3cc5c fixes #981 2025-01-11 09:47:49 -05:00
daniel31x13
6b811c3e7d undo 2025-01-11 09:44:27 -05:00
daniel31x13
a941eec569 bug fix 2025-01-11 08:47:54 -05:00
Daniel
5ed79f0f5b Merge pull request #990 from linkwarden/dev
update README
2025-01-10 15:09:26 -05:00
daniel31x13
010ca8eeae update README 2025-01-10 14:59:07 -05:00
Denis Bryukhanov
5840ffc620 Merge pull request #1 from linkwarden/main
Merge with latest
2025-01-08 01:53:44 +07:00
siberian
14754a23f1 Adding missing localizations, Fix Russian localization, Correcting a duplicate value in the settings 2025-01-08 01:50:21 +07:00
Daniel
1a501b5365 Merge pull request #967 from linkwarden/dev
Dev
2025-01-07 08:28:11 -05:00
daniel31x13
3bc9bbf074 bump version 2025-01-07 08:26:37 -05:00
daniel31x13
09a52dd260 bug fixed 2025-01-07 08:26:04 -05:00
AverageHelper
0344467cb7 feat: Use the same decoder that JSDom uses to encode 2024-12-29 01:37:22 -07:00
AverageHelper
899ddafd90 fix(import-html): Work around parser bug that mangles attribute values 2024-12-28 23:11:50 -07:00
670 changed files with 52288 additions and 20657 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

@@ -15,7 +15,6 @@ AUTOSCROLL_TIMEOUT=
NEXT_PUBLIC_DISABLE_REGISTRATION=
NEXT_PUBLIC_CREDENTIALS_ENABLED=
DISABLE_NEW_SSO_USERS=
RE_ARCHIVE_LIMIT=
MAX_LINKS_PER_USER=
ARCHIVE_TAKE_COUNT=
BROWSER_TIMEOUT=
@@ -27,23 +26,56 @@ NEXT_PUBLIC_DEMO_USERNAME=
NEXT_PUBLIC_DEMO_PASSWORD=
NEXT_PUBLIC_ADMIN=
NEXT_PUBLIC_MAX_FILE_BUFFER=
MONOLITH_MAX_BUFFER=
MONOLITH_CUSTOM_OPTIONS=
PDF_MAX_BUFFER=
SCREENSHOT_MAX_BUFFER=
READABILITY_MAX_BUFFER=
PREVIEW_MAX_BUFFER=
MONOLITH_MAX_BUFFER=
MONOLITH_CUSTOM_OPTIONS=
IMPORT_LIMIT=
PLAYWRIGHT_LAUNCH_OPTIONS_EXECUTABLE_PATH=
PLAYWRIGHT_WS_URL=
MAX_WORKERS=
DISABLE_PRESERVATION=
NEXT_PUBLIC_RSS_POLLING_INTERVAL_MINUTES=
RSS_SUBSCRIPTION_LIMIT_PER_USER=
TEXT_CONTENT_LIMIT=
SEARCH_FILTER_LIMIT=
INDEX_TAKE_COUNT=
MEILI_TIMEOUT=
# AI Settings
NEXT_PUBLIC_OLLAMA_ENDPOINT_URL=
OLLAMA_MODEL=
# https://ai-sdk.dev/providers/openai-compatible-providers
OPENAI_API_KEY=
OPENAI_MODEL=
# Optional: Set a custom OpenAI base URL and name (for third-party providers)
CUSTOM_OPENAI_BASE_URL=
CUSTOM_OPENAI_NAME=
# https://sdk.vercel.ai/providers/ai-sdk-providers/azure
AZURE_API_KEY=
AZURE_RESOURCE_NAME=
AZURE_MODEL=
# https://sdk.vercel.ai/providers/ai-sdk-providers/anthropic
ANTHROPIC_API_KEY=
ANTHROPIC_MODEL=
# https://github.com/OpenRouterTeam/ai-sdk-provider
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=
# AWS S3 Settings
SPACES_KEY=
SPACES_SECRET=
@@ -99,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=
@@ -226,6 +258,7 @@ NEXT_PUBLIC_GITLAB_ENABLED=
GITLAB_CUSTOM_NAME=
GITLAB_CLIENT_ID=
GITLAB_CLIENT_SECRET=
GITLAB_AUTH_URL=
# Google
NEXT_PUBLIC_GOOGLE_ENABLED=
@@ -370,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

View File

@@ -0,0 +1,13 @@
name: Installation Problem
title: Installation Problem
description: Report an issue with installation
labels: installation
body:
- type: textarea
id: feature-description
validations:
required: true
attributes:
label: For installation issues, please visit discord.linkwarden.app
description: "Invite link: https://discord.com/invite/CtuYV47nuJ"
placeholder: Please do not submit installation issues on GitHub.

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

42
.github/workflows/locale-action.yml vendored Normal file
View File

@@ -0,0 +1,42 @@
name: Manage i18n pull requests
on:
pull_request:
types: [opened, reopened, synchronize, ready_for_review]
permissions:
contents: write
pull-requests: write
jobs:
rewrite-author:
if: github.event.pull_request.head.ref == 'i18n'
runs-on: ubuntu-latest
steps:
- name: Checkout i18n branch
uses: actions/checkout@v4
with:
ref: ${{ github.event.pull_request.head.ref }}
fetch-depth: 0
- name: Skip if already rewritten
run: |
if [ "$(git show -s --format='%an')" = 'LinkwardenBot' ]; then
echo "Already rewritten skipping."
exit 0
fi
- name: Configure bot identity
run: |
git config user.name "LinkwardenBot"
git config user.email "bot@linkwarden.app"
- name: Amend just the PR commits
env:
BASE_SHA: ${{ github.event.pull_request.base.sha }}
run: |
git rebase --committer-date-is-author-date --exec 'git commit --amend --no-edit --allow-empty --author="LinkwardenBot <bot@linkwarden.app>"' "$BASE_SHA"
- name: Push rewritten history
run: git push --force-with-lease origin HEAD:i18n

View File

@@ -61,12 +61,18 @@ jobs:
steps:
- uses: actions/checkout@v4
- name: Use Node.js
- name: Use Node.js and Enable Yarn 4
uses: actions/setup-node@v4
with:
node-version: "18"
cache: 'yarn'
node-version: "20"
- name: Enable Yarn 4
run: |
sudo rm -f /usr/bin/yarn /usr/bin/yarnpkg || true
corepack enable
corepack prepare yarn@4.12.0 --activate
yarn --version
- name: Initialize PostgreSQL
run: |
echo "Initializing Databases"
@@ -74,7 +80,7 @@ jobs:
psql -h localhost -U postgres -d postgres -c "CREATE DATABASE ${{ env.TEST_POSTGRES_DATABASE }} OWNER ${{ env.TEST_POSTGRES_USER }};"
- name: Install packages
run: yarn install -y
run: yarn install --immutable
- name: Cache playwright dependencies
uses: awalsh128/cache-apt-pkgs-action@latest
@@ -119,21 +125,17 @@ jobs:
restore-keys: |
${{ runner.os }}-playwright-
- name: Install playwright
if: steps.cache-playwright.outputs.cache-hit != 'true'
run: yarn playwright install --with-deps
- name: Setup project
run: |
yarn prisma generate
yarn build
yarn prisma migrate deploy
yarn prisma:generate
yarn web:build
yarn prisma:deploy
- name: Start linkwarden server and worker
run: yarn start &
run: yarn concurrently:start &
- name: Run Tests
run: npx playwright test --grep ${{ matrix.test_case }}
run: yarn workspace @linkwarden/web playwright test --grep ${{ matrix.test_case }}
- uses: actions/upload-artifact@v4
if: always()

37
.gitignore vendored
View File

@@ -1,13 +1,14 @@
# dependencies
/node_modules
node_modules
/.pnp
.pnp.js
.yarn/install-state.gz
# testing
/coverage
# next.js
/.next/
.next
/out/
# production
@@ -34,23 +35,25 @@ yarn-error.log*
*.tsbuildinfo
next-env.d.ts
# generated files and folders
/data
.idea
prisma/dev.db
# tests
/tests
/test-results/
/blob-report/
/playwright-report/
/playwright/.cache/
/playwright/.auth/
/apps/web/tests
/apps/web/test-results/
/apps/web/blob-report/
/apps/web/playwright-report/
/apps/web/playwright/.cache/
/apps/web/playwright/.auth/
# docker
pgdata
certificates
/test-results/
/playwright-report/
/blob-report/
/playwright/.cache/
# generated files and folders
/data
/data.ms
meilisearch
meili_data
.idea
prisma/dev.db
.turbo
service-account-file.json

1
.yarnrc.yml Normal file
View File

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

View File

@@ -1,45 +0,0 @@
# Architecture
This is a summary of the architecture of Linkwarden. It's intended as a primer for collaborators to get a high-level understanding of the project.
When you start Linkwarden, there are mainly two components that run:
- The NextJS app, This is the main app and it's responsible for serving the frontend and handling the API routes.
- [The Background Worker](https://github.com/linkwarden/linkwarden/blob/main/scripts/worker.ts), This is a separate `ts-node` process that runs in the background and is responsible for archiving links.
## Main Tech Stack
- [NextJS](https://github.com/vercel/next.js)
- [TypeScript](https://github.com/microsoft/TypeScript)
- [Tailwind](https://github.com/tailwindlabs/tailwindcss)
- [DaisyUI](https://github.com/saadeghi/daisyui)
- [Prisma](https://github.com/prisma/prisma)
- [Playwright](https://github.com/microsoft/playwright)
- [Zustand](https://github.com/pmndrs/zustand)
## Folder Structure
Here's a summary of the main files and folders in the project:
```
linkwarden
├── components # React components
├── hooks # React reusable hooks
├── layouts # Layouts for pages
├── lib
│   ├── api # Server-side functions (controllers, etc.)
│   ├── client # Client-side functions
│   └── shared # Shared functions between client and server
├── pages # Pages and API routes
├── prisma # Prisma schema and migrations
├── scripts
│   ├── migration # Scripts for breaking changes
│   └── worker.ts # Background worker for archiving links
├── store # Zustand stores
├── styles # Styles
└── types # TypeScript types
```
## Versioning
We use semantic versioning for the project. You can track the changes from the [Releases](https://github.com/linkwarden/linkwarden/releases).

View File

@@ -2,7 +2,7 @@
# Purpose: Uses the Rust image to build monolith
# Notes:
# - Fine to leave extra here, as only the resulting binary is copied out
FROM docker.io/rust:1.80-bullseye AS monolith-builder
FROM docker.io/rust:1.86-bullseye AS monolith-builder
RUN set -eux && cargo install --locked monolith
@@ -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:18.18-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,11 +24,21 @@ RUN mkdir /data
WORKDIR /data
COPY ./package.json ./yarn.lock ./playwright.config.ts ./
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/
COPY ./packages ./packages
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 && \
@@ -34,14 +50,14 @@ RUN --mount=type=cache,sharing=locked,target=/usr/local/share/.cache/yarn \
COPY --from=monolith-builder /usr/local/cargo/bin/monolith /usr/local/bin/monolith
RUN set -eux && \
npx playwright install --with-deps chromium && \
apt-get clean && \
yarn cache clean
COPY . .
RUN yarn prisma generate && \
yarn build
RUN yarn prisma:generate && \
yarn web:build && \
rm -rf apps/web/.next/cache
HEALTHCHECK --interval=30s \
--timeout=5s \
@@ -51,4 +67,4 @@ HEALTHCHECK --interval=30s \
EXPOSE 3000
CMD yarn prisma migrate deploy && yarn start
CMD ["sh", "-c", "yarn prisma:deploy && yarn concurrently:start"]

134
README.md
View File

@@ -1,12 +1,14 @@
<div align="center">
<img src="./assets/logo.png" width="100px" />
<h1>Linkwarden</h1>
<h3>Bookmark Preservation for Individuals and Teams</h3>
<h3>Bookmarks, Evolved</h3>
<a href="https://discord.com/invite/CtuYV47nuJ"><img src="https://img.shields.io/discord/1117993124669702164?logo=discord&style=flat" alt="Discord"></a>
<a href="https://twitter.com/LinkwardenHQ"><img src="https://img.shields.io/twitter/follow/linkwarden" alt="Twitter"></a> <a href="https://news.ycombinator.com/item?id=36942308"><img src="https://img.shields.io/badge/Hacker%20News-280-%23FF6600"></img></a>
<a href="https://github.com/linkwarden/linkwarden/releases"><img alt="GitHub release" src="https://img.shields.io/github/v/release/linkwarden/linkwarden"></a>
<a href="https://crowdin.com/project/linkwarden">
<img src="https://badges.crowdin.net/linkwarden/localized.svg" alt="Crowdin" /></a>
<a href="https://opencollective.com/linkwarden"><img src="https://img.shields.io/opencollective/all/linkwarden" alt="Open Collective"></a>
</div>
@@ -15,78 +17,70 @@
[« LAUNCH DEMO »](https://demo.linkwarden.app)
[Cloud](https://cloud.linkwarden.app) · [Website](https://linkwarden.app) · [Features](https://github.com/linkwarden/linkwarden#features)
[Cloud](https://cloud.linkwarden.app) · [Website](https://linkwarden.app) · [Features](https://github.com/linkwarden/linkwarden#features) · [Docs](https://docs.linkwarden.app)
<img src="./assets/home.png" />
</div>
## Intro & motivation
**Linkwarden is a self-hosted, open-source collaborative bookmark manager to collect, organize and archive webpages.**
**Linkwarden is a self-hosted, open-source collaborative bookmark manager to collect, read, annotate, and fully preserve what matters, all in one place.**
The objective is to organize useful webpages and articles you find across the web in one place, and since useful webpages can go away (see the inevitability of [Link Rot](https://www.howtogeek.com/786227/what-is-link-rot-and-how-does-it-threaten-the-web/)), Linkwarden also saves a copy of each webpage as a Screenshot and PDF, ensuring accessibility even if the original content is no longer available.
The objective is to organize useful webpages and articles you find across the web in one place, and since useful webpages can go away (see the inevitability of [Link Rot](https://en.wikipedia.org/wiki/Link_rot)), Linkwarden also saves a copy of each webpage as a Screenshot and PDF, ensuring accessibility even if the original content is no longer available.
Additionally, Linkwarden is designed with collaboration in mind, sharing links with the public and/or allowing multiple users to work together seamlessly.
In addition to preservation, Linkwarden provides a user-friendly reading and annotation experience that blends the simplicity of a “read-it-later” tool with the reliability of a web archive. Whether youre highlighting key ideas, jotting down thoughts, or revisiting content long after its disappeared from the web, Linkwarden keeps your knowledge accessible and organized.
Linkwarden is also designed with collaboration in mind, enabling you to share links with the public and/or collaborate seamlessly with multiple users.
> [!TIP]
> Our official [Cloud](https://linkwarden.app/#pricing) offering provides the simplest way to begin using Linkwarden and it's the preferred choice for many due to its time-saving benefits. <br> Your subscription supports our hosting infrastructure and ongoing development. <br> Alternatively, if you prefer self-hosting Linkwarden, you can do so by following our [Installation documentation](https://docs.linkwarden.app/self-hosting/installation).
<img src="./assets/dashboard.png" />
<div align="center">
<img src="./assets/all_links.jpg" width="23%" />
<img src="./assets/list_view.jpg" width="23%" />
<img src="./assets/all_collections.jpg" width="23%" />
<img src="./assets/manage_team.jpg" width="23%" />
<img src="./assets/readable_view.jpg" width="23%" />
<img src="./assets/preserved_formats.jpg" width="23%" />
<img src="./assets/public_page.jpg" width="23%" />
<img src="./assets/light_dashboard.jpg" width="23%" />
</div>
<details>
<summary><b>A bit of a "history"</b></summary>
Linkwarden has been completely rebuilt and redesigned from ground up, so pretty much the only thing it has in common with its predecessor is the idea behind it - bookmark management.
**What happened to the old version?**
We've forked the old version from the current repository into [this repo](https://github.com/linkwarden/linkwarden-old).
</details>
## Features
- 📸 Auto capture a screenshot, PDF, single html file, and readable view of each webpage.
- 🏛️ 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 and MacOS Apps, maintained by [JGeek00](https://github.com/JGeek00).
- 🍎 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 ⭐
@@ -104,19 +98,33 @@ Join and follow us in the following platforms to stay up to date about the most
## Suggestions
We _usually_ go after the [popular suggestions](https://github.com/linkwarden/linkwarden/issues?q=is%3Aissue+is%3Aopen+sort%3Areactions-%2B1-desc). Feel free to open a [new issue](https://github.com/linkwarden/linkwarden/issues/new?assignees=&labels=enhancement&projects=&template=feature_request.md&title=) to suggest one - others might be interested too! :)
We _usually_ go after the [popular suggestions](https://github.com/linkwarden/linkwarden/issues?q=is%3Aissue%20is%3Aopen%20sort%3Areactions-%2B1-desc). Feel free to open a [new issue](https://github.com/linkwarden/linkwarden/issues/new?assignees=&labels=enhancement&projects=&template=feature_request.md&title=) to suggest one - others might be interested too! :)
## Roadmap
Make sure to check out our [public roadmap](https://github.com/orgs/linkwarden/projects/1).
## Documentation
## Community Projects
For information on how to get started or to set up your own instance, please visit the [documentation](https://docs.linkwarden.app).
Here are some community-maintained projects that are built around Linkwarden:
- [My Links](https://apps.apple.com/ca/app/my-links-for-linkwarden/id6504573402) - iOS and MacOS Apps, maintained by [JGeek00](https://github.com/JGeek00).
- [LinkDroid](https://fossdroid.com/a/linkdroid-for-linkwarden.html) - Android App with share sheet integration, [source code](https://github.com/Dacid99/LinkDroid-for-Linkwarden).
- [LinkGuardian](https://github.com/Elbullazul/LinkGuardian) - An Android client for Linkwarden. Built with Kotlin and Jetpack compose.
- [StarWarden](https://github.com/rtuszik/starwarden) - A browser extension to save your starred GitHub repositories to Linkwarden.
## Development
If you want to contribute, Thanks! Start by checking our [public roadmap](https://github.com/orgs/linkwarden/projects/1), there you'll see a [README for contributers](https://github.com/orgs/linkwarden/projects/1?pane=issue&itemId=34708277) for the rest of the info on how to contribute and the main tech stack.
If you want to contribute, Thanks! Start by choosing one of our [popular suggestions](https://github.com/linkwarden/linkwarden/issues?q=is%3Aissue%20is%3Aopen%20sort%3Areactions-%2B1-desc), just please stay in touch with [@daniel31x13](https://github.com/daniel31x13) before starting.
# Translations
If you want to help us translate Linkwarden to your language, please check out our [Crowdin page](https://crowdin.com/project/linkwarden) and start translating. We would love to have your help!
To start translating a new language, please create an issue so we can set it up for you. New languages will be added once they reach at least 50% translation completion.
<a href="https://crowdin.com/project/linkwarden">
<img src="https://badges.crowdin.net/linkwarden/localized.svg" alt="Crowdin" /></a>
## Security

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/

2
apps/mobile/.env.sample Normal file
View File

@@ -0,0 +1,2 @@
LINKWARDEN_URL=
EXPO_PUBLIC_SHOW_LOGS=

46
apps/mobile/.gitignore vendored Normal file
View File

@@ -0,0 +1,46 @@
# 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/
service-account-file.json
.env.local

106
apps/mobile/app.json Normal file
View File

@@ -0,0 +1,106 @@
{
"expo": {
"name": "Linkwarden",
"slug": "linkwarden",
"version": "1.0.1",
"orientation": "portrait",
"icon": "./assets/images/icon.png",
"scheme": "linkwarden",
"userInterfaceStyle": "automatic",
"newArchEnabled": true,
"ios": {
"supportsTablet": true,
"bundleIdentifier": "app.linkwarden",
"entitlements": {
"com.apple.security.application-groups": ["group.app.linkwarden"]
},
"infoPlist": {
"ITSAppUsesNonExemptEncryption": false,
"NSAppTransportSecurity": {
"NSAllowsArbitraryLoads": true
}
}
},
"android": {
"adaptiveIcon": {
"foregroundImage": "./assets/images/maskable_logo.jpeg",
"backgroundColor": "#ffffff"
},
"package": "app.linkwarden"
},
"web": {
"bundler": "metro",
"output": "static",
"favicon": "./assets/images/favicon.png"
},
"plugins": [
"expo-router",
[
"expo-splash-screen",
{
"image": "./assets/images/splash-icon.png",
"imageWidth": 200,
"resizeMode": "contain",
"backgroundColor": "#FFFFFF",
"dark": {
"image": "./assets/images/splash-icon.png",
"resizeMode": "contain",
"backgroundColor": "#171717"
}
}
],
"expo-secure-store",
[
"expo-share-intent",
{
"iosAppGroupIdentifier": "group.app.linkwarden"
}
],
[
"expo-build-properties",
{
"android": {
"enableProguardInReleaseBuilds": true,
"extraProguardRules": "-keep public class com.horcrux.svg.** {*;}",
"allowBackup": false,
"compileSdkVersion": 35,
"targetSdkVersion": 35,
"buildToolsVersion": "35.0.0",
"usesCleartextTraffic": true
}
}
],
[
"react-native-edge-to-edge",
{
"android": {
"parentTheme": "Default",
"enforceNavigationBarContrast": false
}
}
],
"./plugins/with-daynight-transparent-nav"
],
"experiments": {
"typedRoutes": true
},
"androidStatusBar": {
"backgroundColor": "#ffffff",
"barStyle": "dark-content",
"translucent": false
},
"androidNavigationBar": {
"backgroundColor": "#ffffff",
"barStyle": "dark-content"
},
"extra": {
"router": {
"origin": false
},
"eas": {
"projectId": "34f82639-7a25-4ebe-81c8-2db521b612cf"
}
},
"owner": "linkwarden"
}
}

View File

@@ -0,0 +1,81 @@
import { Tabs } from "expo-router";
import React from "react";
import { Platform } from "react-native";
import HapticTab from "@/components/HapticTab";
import TabBarBackground from "@/components/ui/TabBarBackground";
import { useColorScheme } from "nativewind";
import { rawTheme, ThemeName } from "@/lib/colors";
import { Folder, Hash, House, Link, Settings } from "lucide-react-native";
export default function TabLayout() {
const { colorScheme } = useColorScheme();
return (
<Tabs
screenOptions={{
tabBarBackground: TabBarBackground,
tabBarActiveTintColor: rawTheme[colorScheme as ThemeName].primary,
tabBarInactiveTintColor: rawTheme[colorScheme as ThemeName].neutral,
tabBarButton: HapticTab,
tabBarStyle: Platform.select({
ios: {
position: "absolute",
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,
},
}),
}}
>
<Tabs.Screen
name="dashboard"
options={{
title: "Dashboard",
headerShown: false,
tabBarIcon: ({ color }) => <House size={24} color={color} />,
}}
/>
<Tabs.Screen
name="links"
options={{
title: "Links",
headerShown: false,
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
name="settings"
options={{
title: "Settings",
headerShown: false,
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

@@ -0,0 +1,72 @@
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, useMemo } from "react";
import { useCollections } from "@linkwarden/router/collections";
import Links from "@/components/Links";
export default function LinksScreen() {
const { auth } = useAuthStore();
const { search, section, collectionId } = useLocalSearchParams<{
search?: string;
section?: "pinned-links" | "recent-links" | "collection";
collectionId?: string;
}>();
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: title,
headerSearchBarOptions: {
placeholder: `Search ${title}`,
},
});
}, [title, navigation]);
const { links, data } = useLinks(
{
sort: 0,
searchQueryString: decodeURIComponent(search ?? ""),
collectionId: Number(collectionId),
pinnedOnly: section === "pinned-links",
},
auth
);
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,89 @@
import { rawTheme, ThemeName } from "@/lib/colors";
import { Stack, useRouter } from "expo-router";
import { Plus } from "lucide-react-native";
import { useColorScheme } from "nativewind";
import { Platform, TouchableOpacity } from "react-native";
import { SheetManager } from "react-native-actions-sheet";
import * as DropdownMenu from "zeego/dropdown-menu";
export default function Layout() {
const router = useRouter();
const { colorScheme } = useColorScheme();
return (
<Stack
screenOptions={{
headerLargeTitle: true,
headerTransparent: Platform.OS === "ios" ? true : false,
headerShadowVisible: false,
headerBlurEffect:
colorScheme === "dark" ? "systemMaterialDark" : "systemMaterial",
headerTintColor: 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",
},
}}
>
<Stack.Screen
name="index"
options={{
headerTitle: "Dashboard",
headerRight: () => (
<DropdownMenu.Root>
<DropdownMenu.Trigger>
<TouchableOpacity>
<Plus
size={21}
color={rawTheme[colorScheme as ThemeName].primary}
/>
</TouchableOpacity>
</DropdownMenu.Trigger>
<DropdownMenu.Content>
<DropdownMenu.Item
key="new-link"
onSelect={() => SheetManager.show("add-link-sheet")}
>
<DropdownMenu.ItemTitle>New Link</DropdownMenu.ItemTitle>
</DropdownMenu.Item>
<DropdownMenu.Item
key="new-collection"
onSelect={() => SheetManager.show("new-collection-sheet")}
>
<DropdownMenu.ItemTitle>
New Collection
</DropdownMenu.ItemTitle>
</DropdownMenu.Item>
</DropdownMenu.Content>
</DropdownMenu.Root>
),
}}
/>
<Stack.Screen
name="[section]"
options={{
headerTitle: "Links",
headerBackTitle: "Back",
headerSearchBarOptions: {
placeholder: "Search",
autoCapitalize: "none",
headerIconColor: colorScheme === "dark" ? "white" : "black",
onChangeText: (e) => {
router.setParams({
search: encodeURIComponent(e.nativeEvent.text),
});
},
},
}}
/>
</Stack>
);
}

View File

@@ -0,0 +1,147 @@
import {
ActivityIndicator,
Platform,
ScrollView,
StyleSheet,
Text,
View,
} from "react-native";
import React, { useEffect, useMemo, useState } from "react";
import { useDashboardData } from "@linkwarden/router/dashboardData";
import useAuthStore from "@/store/auth";
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 { rawTheme, ThemeName } from "@/lib/colors";
import { useColorScheme } from "nativewind";
import Spinner from "@/components/ui/Spinner";
import DashboardSection from "@/components/DashboardSection";
export default function DashboardScreen() {
const { auth } = useAuthStore();
const {
data: { links = [], numberOfPinnedLinks, collectionLinks = {} } = {
links: [],
},
...dashboardData
} = useDashboardData(auth);
const { data: user, ...userData } = useUser(auth);
const { data: collections = [], ...collectionsData } = useCollections(auth);
const { data: tags = [], ...tagsData } = useTags(auth);
const { colorScheme } = useColorScheme();
const [dashboardSections, setDashboardSections] = useState<
DashboardSectionType[]
>(user?.dashboardSections || []);
const [numberOfLinks, setNumberOfLinks] = useState(0);
useEffect(() => {
setDashboardSections(user?.dashboardSections || []);
}, [user?.dashboardSections]);
useEffect(() => {
setNumberOfLinks(
collections?.reduce?.(
(accumulator, collection) =>
accumulator + (collection._count as any).links,
0
)
);
}, [collections]);
const orderedSections = useMemo(() => {
return [...dashboardSections].sort((a, b) => {
const orderA = a.order ?? Number.MAX_SAFE_INTEGER;
const orderB = b.order ?? Number.MAX_SAFE_INTEGER;
return orderA - orderB;
});
}, [dashboardSections]);
const [pullRefreshing, setPullRefreshing] = useState(false);
const onRefresh = async () => {
setPullRefreshing(true);
try {
await Promise.all([
dashboardData.refetch(),
userData.refetch(),
collectionsData.refetch(),
tagsData.refetch(),
]);
} finally {
setPullRefreshing(false);
}
};
if (orderedSections.length === 0 && dashboardData.isLoading)
return (
<View className="flex justify-center h-screen items-center bg-base-100">
<ActivityIndicator size="large" />
<Text className="text-base mt-2.5 text-neutral">Loading...</Text>
</View>
);
return (
<ScrollView
refreshControl={
<Spinner
refreshing={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"
>
{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]
: []
}
links={links}
tagsLength={tags.length}
numberOfLinks={numberOfLinks}
collectionsLength={collections.length}
numberOfPinnedLinks={numberOfPinnedLinks}
dashboardData={dashboardData}
/>
);
})}
</ScrollView>
);
}
const styles = StyleSheet.create({
container: Platform.select({
ios: {
paddingBottom: 49,
flexDirection: "column",
gap: 15,
paddingVertical: 20,
},
default: {
flexDirection: "column",
gap: 15,
paddingVertical: 20,
},
}),
});

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: "Links",
headerLargeTitle: true,
headerTransparent: Platform.OS === "ios" ? true : false,
headerShadowVisible: false,
headerBlurEffect:
colorScheme === "dark" ? "systemMaterialDark" : "systemMaterial",
headerTintColor: colorScheme === "dark" ? "white" : "black",
headerSearchBarOptions: {
placeholder: "Search Links",
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,39 @@
import { useLinks } from "@linkwarden/router/links";
import { View, StyleSheet, Platform } from "react-native";
import useAuthStore from "@/store/auth";
import { useLocalSearchParams } from "expo-router";
import React from "react";
import Links from "@/components/Links";
export default function LinksScreen() {
const { auth } = useAuthStore();
const { search } = useLocalSearchParams<{ search?: string }>();
const { links, data } = useLinks(
{
sort: 0,
searchQueryString: decodeURIComponent(search ?? ""),
},
auth
);
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,33 @@
import { Stack } from "expo-router";
import { useColorScheme } from "nativewind";
import { rawTheme, ThemeName } from "@/lib/colors";
import { Platform } from "react-native";
export default function Layout() {
const { colorScheme } = useColorScheme();
return (
<Stack
screenOptions={{
headerTitle: "Settings",
headerLargeTitle: true,
headerTransparent: Platform.OS === "ios" ? true : false,
headerShadowVisible: false,
headerBlurEffect:
colorScheme === "dark" ? "systemMaterialDark" : "systemMaterial",
headerTintColor: 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,241 @@
import useAuthStore from "@/store/auth";
import { useUser } from "@linkwarden/router/user";
import {
View,
Text,
StyleSheet,
ScrollView,
TouchableOpacity,
Platform,
Alert,
} from "react-native";
import { nativeApplicationVersion } from "expo-application";
import { useColorScheme } from "nativewind";
import { rawTheme, ThemeName } from "@/lib/colors";
import { useEffect, useState } from "react";
import {
AppWindowMac,
Check,
ExternalLink,
LogOut,
Mail,
Moon,
Smartphone,
Sun,
} from "lucide-react-native";
import useDataStore from "@/store/data";
import * as Clipboard from "expo-clipboard";
export default function SettingsScreen() {
const { signOut, auth } = useAuthStore();
const { data: user } = useUser(auth);
const { colorScheme, setColorScheme } = useColorScheme();
const { data, updateData } = useDataStore();
const [override, setOverride] = useState<"light" | "dark" | "system">(
data.theme || "system"
);
useEffect(() => {
setColorScheme(override);
updateData({ theme: override });
}, [override]);
return (
<View
style={styles.container}
collapsable={false}
collapsableChildren={false}
className="bg-base-100"
>
<ScrollView
contentContainerStyle={{
padding: 20,
}}
contentContainerClassName="flex-col gap-6"
contentInsetAdjustmentBehavior="automatic"
>
<View className="bg-base-200 rounded-xl px-4 py-3">
<Text className="font-semibold text-xl text-base-content">
Your account
</Text>
<Text className="text-neutral mt-2 mb-3">
{user?.email || "@" + user?.username}
</Text>
<View className="h-px bg-neutral-content -mr-4" />
<TouchableOpacity
className="flex-row items-center mt-3"
onPress={() =>
Alert.alert("Sign Out", "Are you sure you want to sign out?", [
{
text: "Cancel",
style: "cancel",
},
{
text: "Sign Out",
style: "destructive",
onPress: () => {
signOut();
},
},
])
}
>
<Text className="flex-1 text-base text-red-500">Sign Out</Text>
<LogOut size={18} color="red" />
</TouchableOpacity>
</View>
<View>
<Text className="mb-4 mx-4 text-neutral">Theme</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={() => setOverride("system")}
>
<View className="flex-row items-center gap-2">
<Smartphone
size={20}
color={rawTheme[colorScheme as ThemeName].neutral}
/>
<Text className="text-neutral">System Defaults</Text>
</View>
{override === "system" ? (
<Check
size={20}
color={rawTheme[colorScheme as ThemeName].primary}
/>
) : null}
</TouchableOpacity>
<View className="h-px bg-neutral-content ml-12" />
<TouchableOpacity
className="flex-row gap-2 items-center justify-between py-3 px-4"
onPress={() => setOverride("light")}
>
<View className="flex-row items-center gap-2">
<Sun size={20} color="orange" />
<Text className="text-orange-500 dark:text-orange-400">
Light
</Text>
</View>
{override === "light" ? (
<Check
size={20}
color={rawTheme[colorScheme as ThemeName].primary}
/>
) : null}
</TouchableOpacity>
<View className="h-px bg-neutral-content ml-12" />
<TouchableOpacity
className="flex-row gap-2 items-center justify-between py-3 px-4"
onPress={() => setOverride("dark")}
>
<View className="flex-row items-center gap-2">
<Moon size={20} color="royalblue" />
<Text className="text-blue-600 dark:text-blue-400">Dark</Text>
</View>
{override === "dark" ? (
<Check
size={20}
color={rawTheme[colorScheme as ThemeName].primary}
/>
) : null}
</TouchableOpacity>
</View>
</View>
<View>
<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({
preferredBrowser: "app",
})
}
>
<View className="flex-row items-center gap-2">
<AppWindowMac
size={20}
color={rawTheme[colorScheme as ThemeName].neutral}
/>
<Text className="text-base-content">In app browser</Text>
</View>
{data.preferredBrowser === "app" ? (
<Check
size={20}
color={rawTheme[colorScheme as ThemeName].primary}
/>
) : null}
</TouchableOpacity>
<View className="h-px bg-neutral-content ml-12" />
<TouchableOpacity
className="flex-row gap-2 items-center justify-between py-3 px-4"
onPress={() =>
updateData({
preferredBrowser: "system",
})
}
>
<View className="flex-row items-center gap-2">
<ExternalLink
size={20}
color={rawTheme[colorScheme as ThemeName].neutral}
/>
<Text className="text-base-content">
System default browser
</Text>
</View>
{data.preferredBrowser === "system" ? (
<Check
size={20}
color={rawTheme[colorScheme as ThemeName].primary}
/>
) : null}
</TouchableOpacity>
</View>
</View>
<View>
<Text className="mb-4 mx-4 text-neutral">Contact Us</Text>
<View className="bg-base-200 rounded-xl flex-col">
<TouchableOpacity
className="flex-row gap-2 items-center justify-between py-3 px-4"
onPress={async () => {
await Clipboard.setStringAsync("support@linkwarden.app");
Alert.alert("Copied to clipboard", "support@linkwarden.app");
}}
>
<View className="flex-row items-center gap-2">
<Mail
size={20}
color={rawTheme[colorScheme as ThemeName].neutral}
/>
<Text className="text-base-content">
support@linkwarden.app
</Text>
</View>
</TouchableOpacity>
</View>
</View>
<Text className="mx-auto text-sm text-neutral">
Linkwarden for {Platform.OS === "ios" ? "iOS" : "Android"}{" "}
{nativeApplicationVersion}
</Text>
</ScrollView>
</View>
);
}
const styles = StyleSheet.create({
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

@@ -0,0 +1,20 @@
import { Link, Stack } from "expo-router";
import { View, StyleSheet } from "react-native";
export default function NotFoundScreen() {
return (
<>
<Stack.Screen options={{ title: "Oops! This screen doesn't exist." }} />
<View style={styles.container}>
<Link href="/">Go to home screen</Link>
</View>
</>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
justifyContent: "center",
alignItems: "center",
},
});

325
apps/mobile/app/_layout.tsx Normal file
View File

@@ -0,0 +1,325 @@
import {
router,
Stack,
usePathname,
useRootNavigationState,
useRouter,
} from "expo-router";
import { PersistQueryClientProvider } from "@tanstack/react-query-persist-client";
import { mmkvPersister } from "@/lib/queryPersister";
import { useState, useEffect } from "react";
import "../styles/global.css";
import { SheetManager, SheetProvider } from "react-native-actions-sheet";
import "@/components/ActionSheets/Sheets";
import { useColorScheme } from "nativewind";
import { lightTheme, darkTheme } from "../lib/theme";
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 { 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 { hasShareIntent, shareIntent, error, resetShareIntent } =
useShareIntent();
const { updateData, setData, data } = useDataStore();
const router = useRouter();
const pathname = usePathname();
const { auth, setAuth } = useAuthStore();
const rootNavState = useRootNavigationState();
useEffect(() => {
setAuth();
setData();
}, []);
useEffect(() => {
if (!rootNavState?.key) return;
if (hasShareIntent && shareIntent.webUrl) {
updateData({
shareIntent: {
hasShareIntent: true,
url: shareIntent.webUrl || "",
},
});
resetShareIntent();
}
const needsRewrite =
((typeof pathname === "string" && pathname.startsWith("/dataUrl=")) ||
hasShareIntent) &&
pathname !== "/incoming";
if (needsRewrite) {
router.replace("/incoming");
}
if (hasShareIntent) {
resetShareIntent();
router.replace("/incoming");
}
}, [
rootNavState?.key,
hasShareIntent,
pathname,
shareIntent?.webUrl,
data.shareIntent,
]);
return (
<PersistQueryClientProvider
client={queryClient}
persistOptions={{
persister: mmkvPersister,
maxAge: Infinity,
dehydrateOptions: {
shouldDehydrateMutation: () => true,
shouldDehydrateQuery: () => true,
},
}}
onSuccess={() => {
setIsLoading(false);
queryClient.invalidateQueries();
}}
>
<RootComponent isLoading={isLoading} auth={auth} />
</PersistQueryClientProvider>
);
}
const RootComponent = ({
isLoading,
auth,
}: {
isLoading: boolean;
auth: MobileAuth;
}) => {
const { colorScheme } = useColorScheme();
const updateLink = useUpdateLink(auth);
const deleteLink = useDeleteLink(auth);
const { tmp } = useTmpStore();
return (
<View
style={[{ flex: 1 }, colorScheme === "dark" ? darkTheme : lightTheme]}
>
<KeyboardProvider>
<SheetProvider>
<StatusBar
style={colorScheme === "dark" ? "light" : "dark"}
backgroundColor={rawTheme[colorScheme as ThemeName]["base-100"]}
/>
{!isLoading && (
<Stack
screenOptions={{
headerShown: false,
contentStyle: {
backgroundColor:
rawTheme[colorScheme as ThemeName]["base-100"],
},
}}
>
{/* <Stack.Screen name="(tabs)" /> */}
<Stack.Screen
name="links/[id]"
options={{
headerShown: true,
headerBackTitle: "Back",
headerTitle: "",
headerTintColor: colorScheme === "dark" ? "white" : "black",
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" />
<Stack.Screen name="index" />
<Stack.Screen name="incoming" />
<Stack.Screen name="+not-found" />
</Stack>
)}
</SheetProvider>
</KeyboardProvider>
</View>
);
};

View File

@@ -0,0 +1,98 @@
import React, { useEffect } from "react";
import {
SafeAreaView,
View,
Text,
StyleSheet,
ActivityIndicator,
Alert,
} from "react-native";
import { Redirect, useRouter } from "expo-router";
import useAuthStore from "@/store/auth";
import useDataStore from "@/store/data";
import { Check } from "lucide-react-native";
import { useAddLink } from "@linkwarden/router/links";
import { rawTheme, ThemeName } from "@/lib/colors";
import { useColorScheme } from "nativewind";
export default function IncomingScreen() {
const { auth } = useAuthStore();
const router = useRouter();
const { data, updateData } = useDataStore();
const addLink = useAddLink(auth);
const { colorScheme } = useColorScheme();
useEffect(() => {
if (auth.status === "authenticated" && data.shareIntent.url)
addLink.mutate(
{ url: data.shareIntent.url },
{
onSuccess: () => {
setTimeout(() => {
updateData({
shareIntent: {
hasShareIntent: false,
url: "",
},
});
router.replace("/dashboard");
}, 1000);
},
onError: (error) => {
Alert.alert("Error", "There was an error adding the link.");
console.error("Error adding link:", error);
},
}
);
}, [auth, data.shareIntent.url]);
if (auth.status === "unauthenticated") return <Redirect href="/" />;
return (
<SafeAreaView className="flex-1 bg-base-100">
{data?.shareIntent.url ? (
<View className="flex-1 items-center justify-center">
<Check
size={140}
className="mb-3 text-base-content"
color={rawTheme[colorScheme as ThemeName].primary}
/>
<Text className="text-2xl font-semibold text-base-content">
Link Saved!
</Text>
</View>
) : (
<View className="flex-1 items-center justify-center">
<ActivityIndicator size="large" />
<Text className="mt-3 text-base text-base-content opacity-70">
One sec {String(data?.shareIntent.url)}
</Text>
</View>
)}
</SafeAreaView>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: "#fff",
},
center: {
flex: 1,
alignItems: "center",
justifyContent: "center",
},
check: {
marginBottom: 12,
},
title: {
fontSize: 28,
fontWeight: "600",
},
subtitle: {
marginTop: 12,
fontSize: 16,
opacity: 0.7,
},
});

77
apps/mobile/app/index.tsx Normal file
View File

@@ -0,0 +1,77 @@
import { Button } from "@/components/ui/Button";
import { rawTheme, ThemeName } from "@/lib/colors";
import useAuthStore from "@/store/auth";
import { Redirect, router } from "expo-router";
import { useColorScheme } from "nativewind";
import { View, Text, Dimensions, TouchableOpacity, Image } from "react-native";
import { SheetManager } from "react-native-actions-sheet";
import Svg, { Path } from "react-native-svg";
import Animated, { SlideInDown } from "react-native-reanimated";
import { SafeAreaView } from "react-native-safe-area-context";
export default function HomeScreen() {
const { auth } = useAuthStore();
const { colorScheme } = useColorScheme();
if (auth.session) {
return <Redirect href="/dashboard" />;
}
return (
<Animated.View
entering={SlideInDown.springify().damping(100).stiffness(300)}
className="flex-col justify-end h-full"
>
<View className="h-full bg-primary relative">
<View className="my-auto">
<Image
source={require("@/assets/images/linkwarden.png")}
className="w-[120px] h-[120px] mx-auto"
/>
<Text className="text-base-100 text-4xl font-semibold mt-7 mx-auto">
Linkwarden
</Text>
</View>
<View>
<Text className="text-base-100 text-xl text-center font-semibold mx-4 mt-3">
Welcome to the official mobile app for Linkwarden!
</Text>
<Text className="text-base-100 text-xl text-center mx-4 mt-3">
Expect regular improvements and new features as we continue refining
the experience.
</Text>
</View>
<Svg
viewBox="0 0 1440 320"
width={Dimensions.get("screen").width}
height={Dimensions.get("screen").width * (320 / 1440) + 2}
>
<Path
fill={rawTheme[colorScheme as ThemeName]["base-100"]}
fill-opacity="1"
d="M0,256L48,234.7C96,213,192,171,288,176C384,181,480,235,576,266.7C672,299,768,309,864,277.3C960,245,1056,171,1152,122.7C1248,75,1344,53,1392,42.7L1440,32L1440,320L1392,320C1344,320,1248,320,1152,320C1056,320,960,320,864,320C768,320,672,320,576,320C480,320,384,320,288,320C192,320,96,320,48,320L0,320Z"
/>
</Svg>
<SafeAreaView
edges={["bottom"]}
className="flex-col justify-end h-auto duration-100 pt-10 bg-base-100 -mt-2 pb-10 gap-4 w-full px-4"
>
<Button
variant="accent"
size="lg"
onPress={() => router.navigate("/login")}
>
<Text className="text-white text-xl">Get Started</Text>
</Button>
<TouchableOpacity
className="w-fit mx-auto"
onPress={() => SheetManager.show("support-sheet")}
>
<Text className="text-neutral text-center w-fit">Need help?</Text>
</TouchableOpacity>
</SafeAreaView>
</View>
</Animated.View>
);
}

View File

@@ -0,0 +1,111 @@
import React, { useEffect, useState } from "react";
import { View, ActivityIndicator, Text, Platform } from "react-native";
import { WebView } from "react-native-webview";
import useAuthStore from "@/store/auth";
import { useLocalSearchParams } from "expo-router";
import { useUser } from "@linkwarden/router/user";
import { useGetLink } from "@linkwarden/router/links";
import useTmpStore from "@/store/tmp";
import { ArchivedFormat } from "@linkwarden/types";
import 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 [isLoading, setIsLoading] = useState(true);
const { data: link } = useGetLink({ id: Number(id), auth, enabled: true });
const { updateTmp } = useTmpStore();
useEffect(() => {
if (link?.id && user?.id)
updateTmp({
link,
user: {
id: user.id,
},
});
return () =>
updateTmp({
link: null,
});
}, [link, user]);
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);
}
}
}, [user, link]);
const insets = useSafeAreaInsets();
return (
<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 || link?.type !== "url"
? { Authorization: `Bearer ${auth.session}` }
: {},
}}
onLoadEnd={() => setIsLoading(false)}
/>
) : (
<View className="flex-1 justify-center items-center bg-base-100 p-5">
<Text className="text-base text-neutral">
No link data available. Please check your network connection or try
again later.
</Text>
</View>
)}
{isLoading && (
<View className="absolute inset-0 flex-1 justify-center items-center bg-base-100 p-5">
<ActivityIndicator size="large" />
<Text className="text-base mt-2.5 text-neutral">Loading...</Text>
</View>
)}
</View>
);
}

197
apps/mobile/app/login.tsx Normal file
View File

@@ -0,0 +1,197 @@
import { Button } from "@/components/ui/Button";
import Input from "@/components/ui/Input";
import { rawTheme, ThemeName } from "@/lib/colors";
import useAuthStore from "@/store/auth";
import { Redirect } from "expo-router";
import { useColorScheme } from "nativewind";
import { useEffect, useState } from "react";
import { View, Text, Dimensions, TouchableOpacity, Image } from "react-native";
import { SheetManager } from "react-native-actions-sheet";
import Svg, { Path } from "react-native-svg";
import {
KeyboardStickyView,
KeyboardToolbar,
} from "react-native-keyboard-controller";
import { SafeAreaView } from "react-native-safe-area-context";
export default function HomeScreen() {
const { auth, signIn } = useAuthStore();
const { colorScheme } = useColorScheme();
const [method, setMethod] = useState<"password" | "token">("password");
const [isLoading, setIsLoading] = useState(false);
const [form, setForm] = useState({
user: "",
password: "",
token: "",
instance: auth.instance || "https://cloud.linkwarden.app",
});
const [showInstanceField, setShowInstanceField] = useState(
form.instance !== "https://cloud.linkwarden.app"
);
useEffect(() => {
setForm((prev) => ({
...prev,
instance: auth.instance || "https://cloud.linkwarden.app",
}));
}, [auth.instance]);
useEffect(() => {
setShowInstanceField(form.instance !== "https://cloud.linkwarden.app");
}, [form.instance]);
useEffect(() => {
setForm((prev) => ({
...prev,
token: "",
user: "",
password: "",
}));
}, [method]);
if (auth.status === "authenticated") {
return <Redirect href="/dashboard" />;
}
return (
<>
<KeyboardStickyView className="flex-col justify-end h-full bg-base-100 relative">
<View className="flex-col justify-end h-full bg-primary relative">
<View className="my-auto">
<Image
source={require("@/assets/images/linkwarden.png")}
className="w-[120px] h-[120px] mx-auto"
/>
</View>
<Text className="text-base-100 text-7xl font-bold ml-8">Login</Text>
<View>
<Text
className="text-base-100 text-2xl mx-8 mt-3"
numberOfLines={1}
>
Login to{" "}
{form.instance === "https://cloud.linkwarden.app"
? "cloud.linkwarden.app"
: form.instance}
</Text>
<TouchableOpacity
onPress={() => {
if (showInstanceField) {
setForm({
...form,
instance: "https://cloud.linkwarden.app",
});
}
setShowInstanceField(!showInstanceField);
}}
className="mx-8 mt-1 self-start"
>
<Text className="text-neutral-content text-sm">
{!showInstanceField ? "Change server" : "Use official server"}
</Text>
</TouchableOpacity>
</View>
<Svg
viewBox="0 0 1440 320"
width={Dimensions.get("screen").width}
height={Dimensions.get("screen").width * (320 / 1440) + 2}
>
<Path
fill={rawTheme[colorScheme as ThemeName]["base-100"]}
fill-opacity="1"
d="M0,256L48,234.7C96,213,192,171,288,176C384,181,480,235,576,266.7C672,299,768,309,864,277.3C960,245,1056,171,1152,122.7C1248,75,1344,53,1392,42.7L1440,32L1440,320L1392,320C1344,320,1248,320,1152,320C1056,320,960,320,864,320C768,320,672,320,576,320C480,320,384,320,288,320C192,320,96,320,48,320L0,320Z"
/>
</Svg>
<SafeAreaView
edges={["bottom"]}
className="flex-col justify-end h-auto duration-100 pt-10 bg-base-100 -mt-2 pb-10 gap-4 w-full px-4"
>
{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>
<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.

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 238 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 241 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 82 B

View File

@@ -0,0 +1,9 @@
module.exports = function (api) {
api.cache(true);
return {
presets: [
["babel-preset-expo", { jsxImportSource: "nativewind" }],
"nativewind/babel",
],
};
};

View File

@@ -0,0 +1,77 @@
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 { 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);
const { auth } = useAuthStore();
const addLink = useAddLink(auth);
const [link, setLink] = useState("");
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="e.g. https://example.com"
className="mb-4 bg-base-100"
value={link}
onChangeText={setLink}
/>
<Button
onPress={() =>
addLink.mutate(
{ url: link },
{
onSuccess: () => {
actionSheetRef.current?.hide();
setLink("");
},
onError: (error) => {
Alert.alert("Error", "There was an error adding the link.");
console.error("Error adding link:", error);
},
}
)
}
isLoading={addLink.isPending}
variant="accent"
className="mb-2"
>
<Text className="text-white">Save to Linkwarden</Text>
</Button>
<Button
onPress={() => {
actionSheetRef.current?.hide();
setLink("");
}}
variant="outline"
className="mb-2"
>
<Text className="text-base-content">Cancel</Text>
</Button>
</View>
</ActionSheet>
);
}

View File

@@ -0,0 +1,276 @@
import { View, Text, Alert } from "react-native";
import { useCallback, useEffect, useMemo, useState } from "react";
import ActionSheet, {
FlatList,
Route,
SheetManager,
SheetProps,
useSheetRouteParams,
useSheetRouter,
} from "react-native-actions-sheet";
import Input from "@/components/ui/Input";
import { Button } from "@/components/ui/Button";
import { useAddLink, useUpdateLink } from "@linkwarden/router/links";
import useAuthStore from "@/store/auth";
import {
CollectionIncludingMembersAndLinkCount,
LinkIncludingShortenedCollectionAndTags,
} from "@linkwarden/types";
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();
const params = useSheetRouteParams("edit-link-sheet", "main");
const [link, setLink] = useState<
LinkIncludingShortenedCollectionAndTags | undefined
>(props.payload?.link);
const editLink = useUpdateLink(auth);
const router = useSheetRouter("edit-link-sheet");
const { colorScheme } = useColorScheme();
useEffect(() => {
if (params?.link) {
setLink(params.link);
}
}, [params?.link]);
const { tmp, updateTmp } = useTmpStore();
return (
<View className="px-8 py-5">
<Input
placeholder="Name"
className="mb-4 bg-base-100"
value={link?.name || ""}
onChangeText={(text) => link?.id && setLink({ ...link, name: text })}
/>
{props.payload?.link?.url && (
<Input
placeholder="URL"
className="mb-4 bg-base-100"
value={link?.url || ""}
onChangeText={(text) => link?.id && setLink({ ...link, url: text })}
/>
)}
<Button
variant="input"
className="mb-4"
onPress={() => router?.navigate("collections", { link })}
>
<View className="flex-row items-center gap-2 w-[90%]">
<Folder
size={20}
fill={link?.collection.color || "gray"}
color={link?.collection.color || "gray"}
/>
<Text numberOfLines={1} className="w-[90%] text-base-content">
{link?.collection.name}
</Text>
</View>
<ChevronRight
size={16}
color={rawTheme[colorScheme as ThemeName]["neutral"]}
/>
</Button>
{/* <Button variant="input" className="mb-4 h-auto">
{link?.tags && link?.tags.length > 0 ? (
<View className="flex-row flex-wrap items-center gap-2 w-[90%]">
{link.tags.map((tag) => (
<View
key={tag.id}
className="bg-gray-200 rounded-md h-7 px-2 py-1"
>
<Text numberOfLines={1}>{tag.name}</Text>
</View>
))}
</View>
) : (
<Text className="text-gray-500">No tags</Text>
)}
<ChevronRight size={16} color={"gray"} />
</Button> */}
<Input
multiline
textAlignVertical="top"
placeholder="Description"
className="mb-4 h-28 bg-base-100"
value={link?.description || ""}
onChangeText={(text) =>
link?.id && setLink({ ...link, description: text })
}
/>
<Button
onPress={() =>
editLink.mutate(link as LinkIncludingShortenedCollectionAndTags, {
onSuccess: () => {
if (link && tmp.link)
updateTmp({
link,
});
SheetManager.hide("edit-link-sheet");
},
onError: (error) => {
Alert.alert("Error", "There was an error editing the link.");
console.error("Error editing link:", error);
},
})
}
isLoading={editLink.isPending}
variant="accent"
className="mb-2"
>
<Text className="text-white">Save</Text>
</Button>
<Button
onPress={() => {
SheetManager.hide("edit-link-sheet");
}}
variant="outline"
className="mb-2"
>
<Text className="text-base-content">Cancel</Text>
</Button>
</View>
);
};
const Collections = () => {
const { auth } = useAuthStore();
const addLink = useAddLink(auth);
const [searchQuery, setSearchQuery] = useState("");
const router = useSheetRouter("edit-link-sheet");
const { link: currentLink } = useSheetRouteParams<
"edit-link-sheet",
"collections"
>("edit-link-sheet", "collections");
const params = useSheetRouteParams("edit-link-sheet", "collections");
const collections = useCollections(auth);
const { colorScheme } = useColorScheme();
const filteredCollections = useMemo(() => {
if (!collections.data) return [];
const q = searchQuery.trim().toLowerCase();
if (q === "") return collections.data;
return collections.data.filter((col) => col.name.toLowerCase().includes(q));
}, [collections.data, searchQuery]);
const renderItem = useCallback(
({
item: collection,
}: {
item: CollectionIncludingMembersAndLinkCount;
}) => {
const onSelect = () => {
// 1. Create a brand-new link object with the new collection
const updatedLink = {
...currentLink!,
collection,
};
// 2. Navigate back to "main", passing the updated link as payload
router?.popToTop();
router?.navigate("main", { link: updatedLink });
};
return (
<Button variant="input" className="mb-2" onPress={onSelect}>
<View className="flex-row items-center gap-2 w-[75%]">
<Folder
size={20}
fill={collection.color || "gray"}
color={collection.color || "gray"}
/>
<Text numberOfLines={1} className="w-full text-base-content">
{collection.name}
</Text>
</View>
<View className="flex-row items-center gap-2">
{params.link?.collection.id === collection.id && (
<Check
size={16}
color={rawTheme[colorScheme as ThemeName].primary}
/>
)}
<Text className="text-neutral">
{collection._count?.links ?? 0}
</Text>
</View>
</Button>
);
},
[addLink, params.link, router]
);
return (
<View className="px-8 py-5 max-h-[80vh]">
<Input
placeholder="Search collections"
className="mb-4 bg-base-100"
value={searchQuery}
onChangeText={setSearchQuery}
/>
<FlatList
data={filteredCollections}
keyExtractor={(e, i) => i.toString()}
renderItem={renderItem}
ListEmptyComponent={
<Text
style={{ textAlign: "center", marginTop: 20 }}
className="text-neutral"
>
No collections match {searchQuery}
</Text>
}
contentContainerStyle={{ paddingBottom: 20 }}
/>
</View>
);
};
const routes: Route[] = [
{
name: "main",
component: Main,
},
{
name: "collections",
component: Collections,
},
];
export default function EditLinkSheet() {
const { colorScheme } = useColorScheme();
const insets = useSafeAreaInsets();
return (
<ActionSheet
gestureEnabled
indicatorStyle={{
backgroundColor: rawTheme[colorScheme as ThemeName]["neutral-content"],
}}
enableRouterBackNavigation={true}
routes={routes}
initialRoute="main"
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

@@ -0,0 +1,38 @@
import {
registerSheet,
RouteDefinition,
SheetDefinition,
} from "react-native-actions-sheet";
import SupportSheet from "./SupportSheet";
import AddLinkSheet from "./AddLinkSheet";
import EditLinkSheet from "./EditLinkSheet";
import NewCollectionSheet from "./NewCollectionSheet";
import { LinkIncludingShortenedCollectionAndTags } from "@linkwarden/types";
registerSheet("support-sheet", SupportSheet);
registerSheet("add-link-sheet", AddLinkSheet);
registerSheet("edit-link-sheet", EditLinkSheet);
registerSheet("new-collection-sheet", NewCollectionSheet);
declare module "react-native-actions-sheet" {
interface Sheets {
"support-sheet": SheetDefinition;
"add-link-sheet": SheetDefinition;
"edit-link-sheet": SheetDefinition<{
payload: {
link: LinkIncludingShortenedCollectionAndTags;
};
routes: {
main: RouteDefinition<{
link: LinkIncludingShortenedCollectionAndTags;
}>;
collections: RouteDefinition<{
link: LinkIncludingShortenedCollectionAndTags;
}>;
};
}>;
"new-collection-sheet": SheetDefinition;
}
}
export {};

View File

@@ -0,0 +1,47 @@
import { Text, View } from "react-native";
import { useState } from "react";
import ActionSheet from "react-native-actions-sheet";
import { rawTheme, ThemeName } from "@/lib/colors";
import { useColorScheme } from "nativewind";
import * as Clipboard from "expo-clipboard";
import { Button } from "../ui/Button";
import { useSafeAreaInsets } from "react-native-safe-area-context";
export default function SupportSheet() {
const { colorScheme } = useColorScheme();
const [copied, setCopied] = useState(false);
async function handleEmailPress() {
await Clipboard.setStringAsync("support@linkwarden.app");
setCopied(true);
setTimeout(() => setCopied(false), 2000);
}
const insets = useSafeAreaInsets();
return (
<ActionSheet
gestureEnabled
indicatorStyle={{
backgroundColor: rawTheme[colorScheme as ThemeName]["neutral-content"],
}}
containerStyle={{
backgroundColor: rawTheme[colorScheme as ThemeName]["base-100"],
}}
safeAreaInsets={insets}
>
<View className="px-8 py-5 flex-col gap-4">
<Text className="text-2xl font-bold text-base-content">Need help?</Text>
<Text className="text-base-content">
Whether you have a question or need assistance, feel free to reach out
to us at support@linkwarden.app
</Text>
<Button onPress={handleEmailPress} variant="outline">
<Text className="text-base-content">
{copied ? "Copied!" : "Copy Support Email"}
</Text>
</Button>
</View>
</ActionSheet>
);
}

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,35 @@
import { Text, View } from "react-native";
export default function DashboardItem({
name,
value,
icon,
color,
}: {
name: string;
value: number;
icon: React.ReactNode;
color: string;
}) {
return (
<View className="flex-1 flex-col gap-2 rounded-xl bg-base-200 p-3">
<View className="flex-row justify-between">
<View
className="flex-col gap-2 rounded-full aspect-square flex justify-center items-center"
style={{
backgroundColor: color,
}}
>
{icon}
</View>
<Text
className="text-4xl text-base-content mt-0.5 text-right max-w-[75%]"
numberOfLines={1}
>
{value || 0}
</Text>
</View>
<Text className="font-semibold text-neutral">{name}</Text>
</View>
);
}

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,18 @@
import { View, Text, Button } from "react-native";
export default function ElementNotSupported({
message = "This element is currently not supported in this view.",
buttonTitle = "Open original",
onPress,
}: {
message?: string;
buttonTitle?: string;
onPress: () => void;
}) {
return (
<View className="border-y border-neutral-content my-2 py-5 flex justify-center items-center">
<Text className="text-neutral">{message}</Text>
<Button onPress={onPress} title={buttonTitle} />
</View>
);
}

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

@@ -0,0 +1,18 @@
import { BottomTabBarButtonProps } from "@react-navigation/bottom-tabs";
import { PlatformPressable } from "@react-navigation/elements";
import * as Haptics from "expo-haptics";
export default function HapticTab(props: BottomTabBarButtonProps) {
return (
<PlatformPressable
{...props}
onPressIn={(ev) => {
if (process.env.EXPO_OS === "ios") {
// Add a soft haptic feedback when pressing down on the tabs.
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
}
props.onPressIn?.(ev);
}}
/>
);
}

View File

@@ -0,0 +1,341 @@
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,
} from "@linkwarden/lib/formatStats";
import useAuthStore from "@/store/auth";
import { useRouter } from "expo-router";
import * as ContextMenu from "zeego/context-menu";
import { useDeleteLink, useUpdateLink } from "@linkwarden/router/links";
import { SheetManager } from "react-native-actions-sheet";
import * as Clipboard from "expo-clipboard";
import { cn } from "@linkwarden/lib/utils";
import { useUser } from "@linkwarden/router/user";
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;
dashboard?: boolean;
};
const LinkListing = ({ link, dashboard }: Props) => {
const { auth } = useAuthStore();
const router = useRouter();
const updateLink = useUpdateLink(auth);
const { data: user } = useUser(auth);
const { colorScheme } = useColorScheme();
const { data } = useDataStore();
const deleteLink = useDeleteLink(auth);
const [url, setUrl] = useState("");
useEffect(() => {
try {
if (link.url) {
setUrl(new URL(link.url).host.toLowerCase());
}
} catch (error) {
console.log(error);
}
}, [link]);
return (
<ContextMenu.Root>
<ContextMenu.Trigger asChild>
<Pressable
className={cn(
"p-5 flex-row justify-between",
dashboard ? "bg-base-200" : "bg-base-100",
Platform.OS !== "android" && "active:bg-base-200/50",
dashboard && "rounded-xl"
)}
onLongPress={() => {}}
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,
}}
>
<View
className={cn(
"flex-row justify-between",
dashboard ? "w-80" : "w-full"
)}
>
<View className="w-[65%] flex-col justify-between">
<Text
numberOfLines={2}
className="font-medium text-lg text-base-content"
>
{decode(link.name || link.description || link.url)}
</Text>
{url && (
<Text
numberOfLines={1}
className="mt-1.5 font-light text-sm text-base-content"
>
{url}
</Text>
)}
<View className="flex flex-row gap-1 items-center mt-1.5 pr-1.5 self-start rounded-md">
<Folder
size={16}
fill={link.collection.color || ""}
color={link.collection.color || ""}
/>
<Text
numberOfLines={1}
className="font-light text-xs text-base-content"
>
{link.collection.name}
</Text>
</View>
</View>
<View className="flex-col items-end">
<View className="rounded-lg overflow-hidden relative">
{formatAvailable(link, "preview") ? (
<Image
source={{
uri: `${auth.instance}/api/v1/archives/${link.id}?format=${ArchivedFormat.jpeg}&preview=true&updatedAt=${link.updatedAt}`,
headers: {
Authorization: `Bearer ${auth.session}`,
},
}}
className="rounded-md h-[60px] w-[90px] object-cover scale-105"
/>
) : !link.preview ? (
<ActivityIndicator
size="small"
className="h-[60px] w-[90px]"
/>
) : (
<View className="h-[60px] w-[90px]" />
)}
</View>
<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(link.createdAt as string).toLocaleString("en-US", {
year: "numeric",
month: "short",
day: "numeric",
})}
</Text>
</View>
</View>
</View>
</Pressable>
</ContextMenu.Trigger>
<ContextMenu.Content avoidCollisions>
<ContextMenu.Item
key="open-original"
onSelect={() => {
if (link) {
const format = getOriginalFormat(link);
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
key="pin-link"
onSelect={async () => {
const isAlreadyPinned =
link?.pinnedBy && link.pinnedBy[0] ? true : false;
await updateLink.mutateAsync({
...link,
pinnedBy: (isAlreadyPinned
? [{ id: undefined }]
: [{ id: user?.id }]) as any,
});
}}
>
<ContextMenu.ItemTitle>
{link.pinnedBy && link.pinnedBy[0] ? "Unpin Link" : "Pin Link"}
</ContextMenu.ItemTitle>
</ContextMenu.Item>
<ContextMenu.Item
key="edit-link"
onSelect={() => {
SheetManager.show("edit-link-sheet", {
payload: { link: link },
});
}}
>
<ContextMenu.ItemTitle>Edit Link</ContextMenu.ItemTitle>
</ContextMenu.Item>
{link.url && atLeastOneFormatAvailable(link) && (
<ContextMenu.Sub>
<ContextMenu.SubTrigger key="preserved-formats">
<ContextMenu.ItemTitle>Preserved Formats</ContextMenu.ItemTitle>
</ContextMenu.SubTrigger>
<ContextMenu.SubContent>
{formatAvailable(link, "monolith") && (
<ContextMenu.Item
key="preserved-formats-webpage"
onSelect={() =>
router.navigate(
`/links/${link.id}?format=${ArchivedFormat.monolith}`
)
}
>
<ContextMenu.ItemTitle>Webpage</ContextMenu.ItemTitle>
</ContextMenu.Item>
)}
{formatAvailable(link, "image") && (
<ContextMenu.Item
key="preserved-formats-screenshot"
onSelect={() =>
router.navigate(
`/links/${link.id}?format=${
link.image?.endsWith(".png")
? ArchivedFormat.png
: ArchivedFormat.jpeg
}`
)
}
>
<ContextMenu.ItemTitle>Screenshot</ContextMenu.ItemTitle>
</ContextMenu.Item>
)}
{formatAvailable(link, "pdf") && (
<ContextMenu.Item
key="preserved-formats-pdf"
onSelect={() =>
router.navigate(
`/links/${link.id}?format=${ArchivedFormat.pdf}`
)
}
>
<ContextMenu.ItemTitle>PDF</ContextMenu.ItemTitle>
</ContextMenu.Item>
)}
{formatAvailable(link, "readable") && (
<ContextMenu.Item
key="preserved-formats-readable"
onSelect={() =>
router.navigate(
`/links/${link.id}?format=${ArchivedFormat.readability}`
)
}
>
<ContextMenu.ItemTitle>Readable</ContextMenu.ItemTitle>
</ContextMenu.Item>
)}
</ContextMenu.SubContent>
</ContextMenu.Sub>
)}
<ContextMenu.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(link.id as number, {
onSuccess: async () => {
await deleteLinkCache(link.id as number);
},
});
},
},
]
);
}}
>
<ContextMenu.ItemTitle>Delete</ContextMenu.ItemTitle>
</ContextMenu.Item>
</ContextMenu.Content>
</ContextMenu.Root>
);
};
export default LinkListing;

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

@@ -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,84 @@
import React, { forwardRef } from "react";
import {
TouchableOpacity,
Text,
View,
ActivityIndicator,
type TouchableOpacityProps,
} from "react-native";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@linkwarden/lib/utils";
const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 rounded-lg disabled:opacity-50 disabled:pointer-events-none",
{
variants: {
variant: {
default: "bg-slate-500 text-white",
primary: "bg-primary text-base-content",
accent: "bg-accent border border-violet-400 text-white",
destructive: "bg-destructive text-white",
outline: "border border-base-content",
secondary: "bg-secondary text-secondary-foreground",
input:
"bg-base-100 rounded-lg px-4 justify-between flex-row font-normal",
metal: "bg-neutral-content text-base-content border border-neutral/30",
ghost: "",
simple: "bg-base-200",
link: "text-primary underline-offset-",
},
size: {
default: "h-10 px-4 py-2",
sm: "h-8 px-2 py-1 text-xs",
lg: "h-12 px-8",
full: "w-full px-4 py-2",
icon: "h-8 w-8",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
);
export type ButtonProps = TouchableOpacityProps &
VariantProps<typeof buttonVariants> & {
isLoading?: boolean;
children: React.ReactNode;
className?: string;
};
export const Button = forwardRef<
React.ElementRef<typeof TouchableOpacity>,
ButtonProps
>(
(
{
variant = "default",
size,
className,
isLoading = false,
children,
disabled,
...props
},
ref
) => {
const combinedClasses = cn(buttonVariants({ variant, size }), className);
return (
<TouchableOpacity
ref={ref}
className={combinedClasses}
activeOpacity={0.8}
disabled={disabled || isLoading}
{...props}
>
{isLoading ? <ActivityIndicator /> : children}
</TouchableOpacity>
);
}
);
Button.displayName = "Button";

View File

@@ -0,0 +1,32 @@
import { SymbolView, SymbolViewProps, SymbolWeight } from "expo-symbols";
import { StyleProp, ViewStyle } from "react-native";
export function IconSymbol({
name,
size = 24,
color,
style,
weight = "regular",
}: {
name: SymbolViewProps["name"];
size?: number;
color: string;
style?: StyleProp<ViewStyle>;
weight?: SymbolWeight;
}) {
return (
<SymbolView
weight={weight}
tintColor={color}
resizeMode="scaleAspectFit"
name={name}
style={[
{
width: size,
height: size,
},
style,
]}
/>
);
}

View File

@@ -0,0 +1,50 @@
// This file is a fallback for using MaterialIcons on Android and web.
import MaterialIcons from "@expo/vector-icons/MaterialIcons";
import { SymbolWeight } from "expo-symbols";
import React from "react";
import { OpaqueColorValue, StyleProp, TextStyle } from "react-native";
// Add your SFSymbol to MaterialIcons mappings here.
const MAPPING = {
// See MaterialIcons here: https://icons.expo.fyi
// See SF Symbols in the SF Symbols app on Mac.
"house.fill": "home",
"paperplane.fill": "send",
"chevron.left.forwardslash.chevron.right": "code",
"chevron.right": "chevron-right",
} as Partial<
Record<
import("expo-symbols").SymbolViewProps["name"],
React.ComponentProps<typeof MaterialIcons>["name"]
>
>;
export type IconSymbolName = keyof typeof MAPPING;
/**
* An icon component that uses native SFSymbols on iOS, and MaterialIcons on Android and web. This ensures a consistent look across platforms, and optimal resource usage.
*
* Icon `name`s are based on SFSymbols and require manual mapping to MaterialIcons.
*/
export function IconSymbol({
name,
size = 24,
color,
style,
}: {
name: IconSymbolName;
size?: number;
color: string | OpaqueColorValue;
style?: StyleProp<TextStyle>;
weight?: SymbolWeight;
}) {
return (
<MaterialIcons
color={color}
size={size}
name={MAPPING[name]}
style={style}
/>
);
}

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

View File

@@ -0,0 +1,20 @@
import React, { forwardRef } from "react";
import { TextInput, TextInputProps } from "react-native";
import { cn } from "@linkwarden/lib/utils";
const Input = forwardRef<TextInput, TextInputProps>(
({ className, ...props }, ref) => {
return (
<TextInput
ref={ref}
className={cn(
"bg-base-200 text-base-content rounded-lg px-4 py-2",
className
)}
{...props}
/>
);
}
);
export default Input;

View File

@@ -0,0 +1,10 @@
import React, { forwardRef } from "react";
import { RefreshControl, RefreshControlProps } from "react-native";
const Spinner = forwardRef<RefreshControl, RefreshControlProps>(
(props, ref) => {
return <RefreshControl ref={ref} {...props} />;
}
);
export default Spinner;

View File

@@ -0,0 +1,22 @@
import { useBottomTabBarHeight } from "@react-navigation/bottom-tabs";
import { BlurView } from "expo-blur";
import { StyleSheet } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
export default function BlurTabBarBackground() {
return (
<BlurView
// System chrome material automatically adapts to the system's theme
// and matches the native tab bar appearance on iOS.
tint="systemChromeMaterial"
intensity={100}
style={StyleSheet.absoluteFill}
/>
);
}
export function useBottomTabOverflow() {
const tabHeight = useBottomTabBarHeight();
const { bottom } = useSafeAreaInsets();
return tabHeight - bottom;
}

View File

@@ -0,0 +1,6 @@
// This is a shim for web and Android where the tab bar is generally opaque.
export default undefined;
export function useBottomTabOverflow() {
return 0;
}

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 }),
]);
};

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

@@ -0,0 +1,33 @@
// lib/theme/colors.ts
export type ThemeName = "light" | "dark";
export const rawTheme = {
light: {
primary: "#0369A1",
secondary: "#0891B2",
accent: "#6D28D9",
neutral: "#6B7280",
"neutral-content": "#D1D5DB",
"base-100": "#FFFFFF",
"base-200": "#F3F4F6",
"base-content": "#0A0A0A",
info: "#A5F3FC",
success: "#22C55E",
warning: "#FACC15",
error: "#DC2626",
},
dark: {
primary: "#7DD3FC",
secondary: "#22D3EE",
accent: "#6D28D9",
neutral: "#9CA3AF",
"neutral-content": "#404040",
"base-100": "#171717",
"base-200": "#262626",
"base-content": "#FAFAFA",
info: "#009EE4",
success: "#00B17D",
warning: "#EAC700",
error: "#F1293C",
},
};

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

@@ -0,0 +1,31 @@
import { MMKV } from "react-native-mmkv";
import { Persister } from "@tanstack/react-query-persist-client";
const storage = new MMKV({ id: "react-query" });
export const mmkvPersister: Persister = {
persistClient: async (client) => {
try {
const json = JSON.stringify(client);
storage.set("REACT_QUERY_CACHE", json);
} catch (e) {
console.error("Error persisting client:", e);
}
},
restoreClient: async () => {
try {
const json = storage.getString("REACT_QUERY_CACHE");
return json ? JSON.parse(json) : undefined;
} catch (e) {
console.error("Error restoring client:", e);
return undefined;
}
},
removeClient: async () => {
try {
storage.delete("REACT_QUERY_CACHE");
} catch (e) {
console.error("Error removing client:", e);
}
},
};

23
apps/mobile/lib/theme.ts Normal file
View File

@@ -0,0 +1,23 @@
import { vars } from "nativewind";
import { rawTheme, ThemeName } from "./colors";
const hexToRgb = (hex: string) => {
const [r, g, b] = hex
.replace(/^#/, "")
.match(/.{2}/g)!
.map((h) => parseInt(h, 16));
return `${r} ${g} ${b}`;
};
const makeVars = (scheme: ThemeName) =>
vars(
Object.fromEntries(
Object.entries(rawTheme[scheme]).map(([key, hex]) => [
`--color-${key}`,
hexToRgb(hex),
])
) as Record<string, string>
);
export const lightTheme = makeVars("light");
export const darkTheme = makeVars("dark");

View File

@@ -0,0 +1,6 @@
const { getDefaultConfig } = require("expo/metro-config");
const { withNativeWind } = require("nativewind/metro");
const config = getDefaultConfig(__dirname);
module.exports = withNativeWind(config, { input: "./styles/global.css" });

3
apps/mobile/nativewind-env.d.ts vendored Normal file
View File

@@ -0,0 +1,3 @@
/// <reference types="nativewind/types" />
// NOTE: This file should not be edited and should be committed with your source code. It is generated by NativeWind.

87
apps/mobile/package.json Normal file
View File

@@ -0,0 +1,87 @@
{
"name": "@linkwarden/mobile",
"main": "expo-router/entry",
"version": "0.0.0",
"scripts": {
"start": "expo start",
"android": "expo run:android",
"ios": "expo run:ios",
"web": "expo start --web",
"test": "jest --watchAll",
"lint": "expo lint"
},
"jest": {
"preset": "jest-expo"
},
"dependencies": {
"@expo/vector-icons": "^14.0.2",
"@linkwarden/lib": "*",
"@linkwarden/prisma": "*",
"@linkwarden/react-native-render-html": "^6.3.4",
"@linkwarden/router": "*",
"@linkwarden/types": "*",
"@react-native-async-storage/async-storage": "^2.2.0",
"@react-native-community/netinfo": "11.4.1",
"@react-native-menu/menu": "1.2.2",
"@react-navigation/bottom-tabs": "^7.0.0",
"@react-navigation/native": "^7.0.0",
"@tanstack/react-query": "^5.51.15",
"@tanstack/react-query-persist-client": "^5.51.15",
"class-variance-authority": "^0.7.1",
"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",
"expo-file-system": "~18.0.12",
"expo-font": "~13.0.1",
"expo-haptics": "~14.0.0",
"expo-linking": "~7.0.5",
"expo-router": "4.0.20",
"expo-secure-store": "~14.0.0",
"expo-share-intent": "^3.2.3",
"expo-splash-screen": "~0.29.18",
"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",
"nativewind": "^4.1.23",
"react": "18.3.1",
"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.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",
"react-native-svg": "^15.12.1",
"react-native-web": "~0.19.13",
"react-native-webview": "13.12.5",
"tailwindcss": "^3.4.17",
"zeego": "^3.0.6",
"zustand": "^5.0.2"
},
"devDependencies": {
"@babel/core": "^7.25.2",
"@types/jest": "^29.5.12",
"@types/react": "18.3.1",
"@types/react-test-renderer": "^18.3.0",
"jest": "^29.2.1",
"jest-expo": "~52.0.2",
"react-test-renderer": "18.3.1",
"typescript": "^5.8.3"
},
"private": true
}

View File

@@ -0,0 +1,43 @@
const { withAndroidStyles } = require("@expo/config-plugins");
function mutateStylesXml(xml) {
const styles = xml.resources?.style ?? [];
let appTheme = styles.find((s) => s.$?.name === "AppTheme");
if (!appTheme) {
appTheme = {
$: { name: "AppTheme", parent: "Theme.AppCompat.DayNight.NoActionBar" },
item: [],
};
styles.push(appTheme);
}
appTheme.$ = appTheme.$ || {};
appTheme.$.parent = "Theme.AppCompat.DayNight.NoActionBar";
appTheme.item = appTheme.item ?? [];
appTheme.item = appTheme.item.filter(
(i) => i?.$?.name !== "android:textColor"
);
const navItem = appTheme.item.find(
(i) => i.$?.name === "android:navigationBarColor"
);
if (navItem) {
navItem._ = "@android:color/transparent";
} else {
appTheme.item.push({
$: { name: "android:navigationBarColor" },
_: "@android:color/transparent",
});
}
xml.resources.style = styles;
return xml;
}
module.exports = function withDayNightTransparentNav(config) {
return withAndroidStyles(config, (c) => {
c.modResults = mutateStylesXml(c.modResults);
return c;
});
};

138
apps/mobile/store/auth.ts Normal file
View File

@@ -0,0 +1,138 @@
import { create } from "zustand";
import * as SecureStore from "expo-secure-store";
import { router } from "expo-router";
import { MobileAuth } from "@linkwarden/types";
import { Alert } from "react-native";
import { queryClient } from "@/lib/queryClient";
import { mmkvPersister } from "@/lib/queryPersister";
import { clearCache } from "@/lib/cache";
type AuthStore = {
auth: MobileAuth;
signIn: (
username: string,
password: string,
instance: string,
token?: string
) => Promise<void>;
signOut: () => Promise<void>;
setAuth: () => Promise<void>;
};
const useAuthStore = create<AuthStore>((set) => ({
auth: {
instance: "",
session: null,
status: "loading" as const,
},
setAuth: async () => {
const session = await SecureStore.getItemAsync("TOKEN");
const instance = await SecureStore.getItemAsync("INSTANCE");
if (session) {
set({
auth: {
instance,
session,
status: "authenticated",
},
});
} else {
set({
auth: {
instance: instance || "https://cloud.linkwarden.app",
session: null,
status: "unauthenticated",
},
});
}
},
signIn: async (username, password, instance, token) => {
if (process.env.EXPO_PUBLIC_SHOW_LOGS === "true")
console.log("Signing into", instance);
if (token) {
// make a request to the API to validate the token
await fetch(instance + "/api/v1/users/me", {
method: "GET",
headers: {
Authorization: `Bearer ${token}`,
},
}).then(async (res) => {
if (res.ok) {
await SecureStore.setItemAsync("INSTANCE", instance);
await SecureStore.setItemAsync("TOKEN", token);
set({
auth: {
session: token,
instance,
status: "authenticated",
},
});
router.replace("/(tabs)/dashboard");
} else {
Alert.alert("Error", "Invalid token");
}
});
} else {
try {
const res = await Promise.race([
fetch(`${instance}/api/v1/session`, {
method: "POST",
body: JSON.stringify({ username, password }),
headers: { "Content-Type": "application/json" },
}),
new Promise<Response>((_, reject) =>
setTimeout(() => reject(new Error("TIMEOUT")), 30000)
),
]);
if (res.ok) {
const data = await res.json();
const session = (data as any).response.token;
await SecureStore.setItemAsync("TOKEN", session);
await SecureStore.setItemAsync("INSTANCE", instance);
set({ auth: { session, instance, status: "authenticated" } });
router.replace("/(tabs)/dashboard");
} else {
Alert.alert("Error", "Invalid credentials");
}
} catch (err: any) {
if (err?.message === "TIMEOUT") {
Alert.alert(
"Request timed out",
"Unable to reach the server in time. Please check your network configuration and try again."
);
} else {
Alert.alert(
"Network error",
"Could not connect to the server. Please check your network configuration and try again."
);
}
}
}
},
signOut: async () => {
await SecureStore.deleteItemAsync("TOKEN");
await SecureStore.deleteItemAsync("INSTANCE");
queryClient.cancelQueries();
queryClient.clear();
mmkvPersister.removeClient?.();
await clearCache();
set({
auth: {
instance: "",
session: null,
status: "unauthenticated",
},
});
router.replace("/");
},
}));
export default useAuthStore;

37
apps/mobile/store/data.ts Normal file
View File

@@ -0,0 +1,37 @@
import { create } from "zustand";
import { MobileData } from "@linkwarden/types";
import AsyncStorage from "@react-native-async-storage/async-storage";
import { colorScheme } from "nativewind";
type DataStore = {
data: MobileData;
updateData: (newData: Partial<MobileData>) => void;
setData: () => void;
};
const useDataStore = create<DataStore>((set, get) => ({
data: {
shareIntent: {
hasShareIntent: false,
url: "",
},
theme: "system",
preferredBrowser: "app",
},
setData: async () => {
const dataString = JSON.parse((await AsyncStorage.getItem("data")) || "{}");
colorScheme.set(dataString.theme || "system");
if (dataString)
set((state) => ({ data: { ...state.data, ...dataString } }));
},
updateData: async (patch) => {
const merged = { ...get().data, ...patch };
const { shareIntent, ...persistable } = merged;
await AsyncStorage.setItem("data", JSON.stringify(persistable));
set({ data: merged });
},
}));
export default useDataStore;

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

@@ -0,0 +1,3 @@
@tailwind base;
@tailwind components;
@tailwind utilities;

View File

@@ -0,0 +1,39 @@
/** @type {import('tailwindcss').Config} */
const { rawTheme } = require("./lib/colors");
const hexToRgb = (hex) => {
const [r, g, b] = hex
.replace(/^#/, "")
.match(/.{2}/g)
.map((h) => parseInt(h, 16));
return `${r} ${g} ${b}`;
};
module.exports = {
content: ["./app/**/*.{js,jsx,ts,tsx}", "./components/**/*.{js,jsx,ts,tsx}"],
presets: [require("nativewind/preset")],
darkMode: "media",
theme: {
extend: {
colors: Object.fromEntries(
Object.keys(rawTheme.light).map((key) => [
key,
`rgb(var(--color-${key}) / <alpha-value>)`,
])
),
},
},
plugins: [
({ addBase }) => {
addBase({
":root": Object.fromEntries(
Object.entries(rawTheme.light).map(([key, hex]) => [
`--color-${key}`,
hexToRgb(hex),
])
),
});
},
],
};

18
apps/mobile/tsconfig.json Normal file
View File

@@ -0,0 +1,18 @@
{
"extends": "expo/tsconfig.base",
"compilerOptions": {
"strict": true,
"paths": {
"@/*": [
"./*"
]
}
},
"include": [
"**/*.ts",
"**/*.tsx",
".expo/types/**/*.ts",
"expo-env.d.ts",
"nativewind-env.d.ts"
]
}

View File

@@ -0,0 +1,43 @@
export enum Sort {
DateNewestFirst,
DateOldestFirst,
NameAZ,
NameZA,
DescriptionAZ,
DescriptionZA,
}
export type LinkRequestQuery = {
sort?: Sort;
cursor?: number;
collectionId?: number;
tagId?: number;
pinnedOnly?: boolean;
searchQueryString?: string;
searchByName?: boolean;
searchByUrl?: boolean;
searchByDescription?: boolean;
searchByTextContent?: boolean;
searchByTags?: boolean;
};
export type LinkIncludingShortenedCollectionAndTags = {
id: number;
name: string;
url: string;
description: string;
type: "url" | "image" | "pdf";
preview: string | null;
createdAt: string;
updatedAt: string;
collectionId: number;
tags: { id: number; name: string }[];
};
export enum ArchivedFormat {
png = 0,
jpeg = 1,
pdf = 2,
readability = 3,
monolith = 4,
}

1
apps/mobile/types/nativewind-env.d.ts vendored Normal file
View File

@@ -0,0 +1 @@
/// <reference types="nativewind/types" />

21
apps/web/components.json Normal file
View File

@@ -0,0 +1,21 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "default",
"rsc": false,
"tsx": true,
"tailwind": {
"config": "tailwind.config.js",
"css": "styles/globals.css",
"baseColor": "neutral",
"cssVariables": true,
"prefix": ""
},
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
"ui": "@/components/ui",
"lib": "@/lib",
"hooks": "@/hooks"
},
"iconLibrary": "lucide"
}

View File

@@ -0,0 +1,52 @@
import Link from "next/link";
import React, { MouseEventHandler } from "react";
import { Trans } from "next-i18next";
import { Button } from "@/components/ui/button";
type Props = {
toggleAnnouncementBar: MouseEventHandler<HTMLButtonElement>;
};
export default function Announcement({ toggleAnnouncementBar }: Props) {
const announcementId = localStorage.getItem("announcementId");
const announcementMessage = localStorage.getItem("announcementMessage");
return (
<div className="fixed mx-auto bottom-20 sm:bottom-10 w-full pointer-events-none p-5 z-30">
<div className="mx-auto pointer-events-auto p-2 flex justify-between gap-2 items-center border border-primary shadow-xl rounded-xl bg-base-300 backdrop-blur-sm bg-opacity-80 max-w-md">
<i className="bi-stars text-xl text-yellow-600 dark:text-yellow-500"></i>
<p className="w-4/5 text-center text-sm sm:text-base">
{announcementId ? (
<Trans
i18nKey="new_version_announcement"
values={{ version: announcementId }}
components={[
<Link
href={`https://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>
</Button>
</div>
</div>
);
}

View File

@@ -1,8 +1,8 @@
import useLocalSettingsStore from "@/store/localSettings";
import Image from "next/image";
import Link from "next/link";
import React, { ReactNode } from "react";
import { Trans } from "next-i18next";
import { useUser } from "@linkwarden/router/user";
interface Props {
text?: string;
@@ -15,7 +15,7 @@ export default function CenteredForm({
children,
"data-testid": dataTestId,
}: Props) {
const { settings } = useLocalSettingsStore();
const { data: user } = useUser();
return (
<div
@@ -23,15 +23,21 @@ export default function CenteredForm({
data-testid={dataTestId}
>
<div className="m-auto flex flex-col gap-2 w-full">
{settings.theme && (
{user?.theme === "light" ? (
<Image
src={`/linkwarden_${
settings.theme === "dark" ? "dark" : "light"
}.png`}
src={"/linkwarden_light.png"}
width={640}
height={136}
alt="Linkwarden"
className="h-12 w-fit mx-auto"
className="h-12 w-auto mx-auto"
/>
) : (
<Image
src={"/linkwarden_dark.png"}
width={640}
height={136}
alt="Linkwarden"
className="h-12 w-auto mx-auto"
/>
)}
{text && (
@@ -45,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

@@ -5,20 +5,29 @@ type Props = {
state: boolean;
className?: string;
onClick: ChangeEventHandler<HTMLInputElement>;
disabled?: boolean;
};
export default function Checkbox({ label, state, className, onClick }: Props) {
export default function Checkbox({
label,
state,
className,
onClick,
disabled,
}: Props) {
return (
<label
className={`label cursor-pointer flex gap-2 justify-start ${
className || ""
}`}
aria-disabled={disabled}
>
<input
type="checkbox"
checked={state}
onChange={onClick}
className="checkbox checkbox-primary"
disabled={disabled}
/>
<span className="label-text">{label}</span>
</label>

View File

@@ -31,6 +31,11 @@ function useOutsideAlerter(
useEffect(() => {
function handleClickOutside(event: MouseEvent) {
const clickedElement = event.target as HTMLElement;
if (clickedElement.closest("[data-ignore-click-away]")) {
return;
}
if (ref.current && !ref.current.contains(clickedElement)) {
const refZIndex = getZIndex(ref.current);
const clickedZIndex = getZIndex(clickedElement);

View File

@@ -2,18 +2,24 @@ import Link from "next/link";
import {
AccountSettings,
CollectionIncludingMembersAndLinkCount,
} from "@/types/global";
} from "@linkwarden/types";
import React, { useEffect, useState } from "react";
import ProfilePhoto from "./ProfilePhoto";
import usePermissions from "@/hooks/usePermissions";
import useLocalSettingsStore from "@/store/localSettings";
import getPublicUserData from "@/lib/client/getPublicUserData";
import EditCollectionModal from "./ModalContent/EditCollectionModal";
import EditCollectionSharingModal from "./ModalContent/EditCollectionSharingModal";
import DeleteCollectionModal from "./ModalContent/DeleteCollectionModal";
import { dropdownTriggerer } from "@/lib/client/utils";
import { useTranslation } from "next-i18next";
import { useUser } from "@/hooks/store/user";
import { useUser } from "@linkwarden/router/user";
import {
DropdownMenu,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
} from "@/components/ui/dropdown-menu";
import { Button } from "./ui/button";
export default function CollectionCard({
collection,
@@ -21,11 +27,10 @@ export default function CollectionCard({
collection: CollectionIncludingMembersAndLinkCount;
}) {
const { t } = useTranslation();
const { settings } = useLocalSettingsStore();
const { data: user = {} } = useUser();
const { data: user } = useUser();
const formattedDate = new Date(collection.createdAt as string).toLocaleString(
"en-US",
t("locale"),
{
year: "numeric",
month: "short",
@@ -41,18 +46,18 @@ export default function CollectionCard({
useEffect(() => {
const fetchOwner = async () => {
if (collection && collection.ownerId !== user.id) {
if (collection && collection.ownerId !== user?.id) {
const owner = await getPublicUserData(collection.ownerId as number);
setCollectionOwner(owner);
} else if (collection && collection.ownerId === user.id) {
} else if (collection && collection.ownerId === user?.id) {
setCollectionOwner({
id: user.id as number,
name: user.name,
username: user.username as string,
image: user.image as string,
archiveAsScreenshot: user.archiveAsScreenshot as boolean,
archiveAsMonolith: user.archiveAsMonolith as boolean,
archiveAsPDF: user.archiveAsPDF as boolean,
id: user?.id as number,
name: user?.name,
username: user?.username as string,
image: user?.image as string,
archiveAsScreenshot: user?.archiveAsScreenshot as boolean,
archiveAsMonolith: user?.archiveAsMonolith as boolean,
archiveAsPDF: user?.archiveAsPDF as boolean,
});
}
};
@@ -67,65 +72,60 @@ export default function CollectionCard({
return (
<div className="relative">
<div className="dropdown dropdown-bottom dropdown-end absolute top-3 right-3 z-20">
<div
tabIndex={0}
role="button"
onMouseDown={dropdownTriggerer}
className="btn btn-ghost btn-sm btn-square text-neutral"
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="icon"
className="absolute top-3 right-3 z-20"
>
<i title="More" className="bi-three-dots text-xl text-neutral" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent
sideOffset={4}
side="bottom"
align="end"
className="z-[30]"
>
<i className="bi-three-dots text-xl" title="More"></i>
</div>
<ul className="dropdown-content z-[30] menu shadow bg-base-200 border border-neutral-content rounded-box mt-1">
{permissions === true && (
<li>
<div
role="button"
tabIndex={0}
onClick={() => {
(document?.activeElement as HTMLElement)?.blur();
setEditCollectionModal(true);
}}
className="whitespace-nowrap"
>
{t("edit_collection_info")}
</div>
</li>
<DropdownMenuItem onSelect={() => setEditCollectionModal(true)}>
<i className="bi-pencil-square" />
{t("edit_collection_info")}
</DropdownMenuItem>
)}
<li>
<div
role="button"
tabIndex={0}
onClick={() => {
(document?.activeElement as HTMLElement)?.blur();
setEditCollectionSharingModal(true);
}}
className="whitespace-nowrap"
>
{permissions === true
? t("share_and_collaborate")
: t("view_team")}
</div>
</li>
<li>
<div
role="button"
tabIndex={0}
onClick={() => {
(document?.activeElement as HTMLElement)?.blur();
setDeleteCollectionModal(true);
}}
className="whitespace-nowrap"
>
{permissions === true
? t("delete_collection")
: t("leave_collection")}
</div>
</li>
</ul>
</div>
<DropdownMenuItem
onSelect={() => setEditCollectionSharingModal(true)}
>
<i className="bi-globe" />
{permissions === true ? t("share_and_collaborate") : t("view_team")}
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
onSelect={() => setDeleteCollectionModal(true)}
className="text-error"
>
{permissions === true ? (
<>
<i className="bi-trash" />
{t("delete_collection")}
</>
) : (
<>
<i className="bi-box-arrow-left" />
{t("leave_collection")}
</>
)}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<div
className="flex items-center absolute bottom-3 left-3 z-10 btn px-2 btn-ghost rounded-full"
className="flex items-center absolute bottom-3 left-3 z-10 px-1 py-1 rounded-full cursor-pointer hover:bg-base-content/20 transition-colors duration-200"
onClick={() => setEditCollectionSharingModal(true)}
>
{collectionOwner.id && (
@@ -159,9 +159,9 @@ export default function CollectionCard({
href={`/collections/${collection.id}`}
style={{
backgroundImage: `linear-gradient(45deg, ${collection.color}30 10%, ${
settings.theme === "dark" ? "oklch(var(--b2))" : "oklch(var(--b2))"
user?.theme === "dark" ? "oklch(var(--b2))" : "oklch(var(--b2))"
} 50%, ${
settings.theme === "dark" ? "oklch(var(--b2))" : "oklch(var(--b2))"
user?.theme === "dark" ? "oklch(var(--b2))" : "oklch(var(--b2))"
} 100%)`,
}}
className="card card-compact shadow-md hover:shadow-none duration-200 border border-neutral-content"
@@ -180,12 +180,12 @@ export default function CollectionCard({
{collection.isPublic && (
<i
className="bi-globe2 drop-shadow text-neutral"
title="This collection is being shared publicly."
title={t("collection_publicly_shared")}
></i>
)}
<i
className="bi-link-45deg text-lg text-neutral"
title="This collection is being shared publicly."
title={t("links")}
></i>
{collection._count && collection._count.links}
</div>
@@ -193,7 +193,7 @@ export default function CollectionCard({
<p className="font-bold text-xs flex gap-1 items-center">
<i
className="bi-calendar3 text-neutral"
title="This collection is being shared publicly."
title={t("collection_publicly_shared")}
></i>
{formattedDate}
</p>

View File

@@ -9,31 +9,37 @@ import Tree, {
TreeSourcePosition,
TreeDestinationPosition,
} from "@atlaskit/tree";
import { Collection } from "@prisma/client";
import { Collection } from "@linkwarden/prisma/client";
import Link from "next/link";
import { CollectionIncludingMembersAndLinkCount } from "@/types/global";
import { CollectionIncludingMembersAndLinkCount } from "@linkwarden/types";
import { useRouter } from "next/router";
import toast from "react-hot-toast";
import { useTranslation } from "next-i18next";
import { useCollections, useUpdateCollection } from "@/hooks/store/collections";
import { useUpdateUser, useUser } from "@/hooks/store/user";
import {
useCollections,
useUpdateCollection,
} from "@linkwarden/router/collections";
import { useUpdateUser, useUser } from "@linkwarden/router/user";
import Icon from "./Icon";
import { IconWeight } from "@phosphor-icons/react";
import Droppable from "./Droppable";
import { cn } from "@linkwarden/lib";
import { Active, useDndContext } from "@dnd-kit/core";
interface ExtendedTreeItem extends TreeItem {
data: Collection;
}
const CollectionListing = () => {
const { active: droppableActive } = useDndContext();
const { t } = useTranslation();
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>();
@@ -43,18 +49,18 @@ const CollectionListing = () => {
collections,
router,
tree,
user.collectionOrder
user?.collectionOrder
);
} else return undefined;
}, [collections, user, router]);
}, [collections, user]);
useEffect(() => {
setTree(initialTree);
}, [initialTree]);
useEffect(() => {
if (user.username) {
refetch();
if (user?.username) {
// refetch();
if (
(!user.collectionOrder || user.collectionOrder.length === 0) &&
collections.length > 0
@@ -112,6 +118,81 @@ const CollectionListing = () => {
);
};
function reorderTreeItems(
tree: TreeData,
movedCollectionId: ItemId,
source: TreeSourcePosition,
destination: TreeDestinationPosition
) {
// Same parent reordering
if (source.parentId === destination.parentId) {
const parent = tree.items[source.parentId];
const children = [...parent.children];
// Remove from source index
children.splice(source.index, 1);
// Insert at destination index
if (destination.index !== undefined) {
children.splice(destination.index, 0, movedCollectionId);
}
parent.children = children;
return tree;
}
// Different parent move
const sourceParent = tree.items[source.parentId];
const destinationParent = tree.items[destination.parentId];
// Remove from source parent
sourceParent.children = sourceParent.children.filter(
(id) => id !== movedCollectionId
);
// Initialize children array if it doesn't exist
if (!destinationParent.children) {
destinationParent.children = [];
}
// If destination index is not specified, add to the end
const destinationIndex =
destination.index !== undefined
? destination.index
: destinationParent.children.length;
// Add to destination parent
destinationParent.children.splice(destinationIndex, 0, movedCollectionId);
// Update destination parent properties
destinationParent.hasChildren = true;
destinationParent.isExpanded = true;
// Update the moved item's parent ID
tree.items[movedCollectionId].data.parentId = destination.parentId;
return tree;
}
function flattenTreeIds(
tree: TreeData,
nodeId: ItemId = "root",
result: Array<ItemId> = []
) {
const node = tree.items[nodeId];
if (nodeId !== "root") {
result.push(node.id);
}
if (node.children && node.children.length > 0) {
node.children.forEach((childId) => {
flattenTreeIds(tree, childId, result);
});
}
return result;
}
const onDragEnd = async (
source: TreeSourcePosition,
destination: TreeDestinationPosition | undefined
@@ -138,9 +219,9 @@ const CollectionListing = () => {
);
if (
(movedCollection?.ownerId !== user.id &&
(movedCollection?.ownerId !== user?.id &&
destination.parentId !== source.parentId) ||
(destinationCollection?.ownerId !== user.id &&
(destinationCollection?.ownerId !== user?.id &&
destination.parentId !== "root")
) {
return toast.error(t("cant_change_collection_you_dont_own"));
@@ -148,7 +229,12 @@ const CollectionListing = () => {
setTree((currentTree) => moveItemOnTree(currentTree!, source, destination));
const updatedCollectionOrder = [...user.collectionOrder];
const newTree = reorderTreeItems(
tree,
movedCollectionId,
source,
destination
);
if (source.parentId !== destination.parentId) {
await updateCollection.mutateAsync(
@@ -169,42 +255,10 @@ const CollectionListing = () => {
);
}
if (
destination.index !== undefined &&
destination.parentId === source.parentId &&
source.parentId === "root"
) {
updatedCollectionOrder.includes(movedCollectionId) &&
updatedCollectionOrder.splice(source.index, 1);
updatedCollectionOrder.splice(destination.index, 0, movedCollectionId);
await updateUser.mutateAsync({
...user,
collectionOrder: updatedCollectionOrder,
});
} else if (
destination.index !== undefined &&
destination.parentId === "root"
) {
updatedCollectionOrder.splice(destination.index, 0, movedCollectionId);
updateUser.mutate({
...user,
collectionOrder: updatedCollectionOrder,
});
} else if (
source.parentId === "root" &&
destination.parentId &&
destination.parentId !== "root"
) {
updatedCollectionOrder.splice(source.index, 1);
await updateUser.mutateAsync({
...user,
collectionOrder: updatedCollectionOrder,
});
}
await updateUser.mutateAsync({
...user,
collectionOrder: flattenTreeIds(newTree),
});
};
if (isLoading) {
@@ -225,7 +279,9 @@ const CollectionListing = () => {
return (
<Tree
tree={tree}
renderItem={(itemProps) => renderItem({ ...itemProps }, currentPath)}
renderItem={(itemProps) =>
renderItem({ ...itemProps }, router.asPath, droppableActive)
}
onExpand={onExpand}
onCollapse={onCollapse}
onDragEnd={onDragEnd}
@@ -239,59 +295,77 @@ export default CollectionListing;
const renderItem = (
{ item, onExpand, onCollapse, provided }: RenderItemParams,
currentPath: string
currentPath: string,
droppableActive: Active | null
) => {
const collection = item.data;
return (
<div ref={provided.innerRef} {...provided.draggableProps} className="mb-1">
<Droppable
id={`side-bar-collection-${collection.id}`}
data={{
name: collection.name,
id: collection.id,
ownerId: collection.ownerId,
}}
className="group"
>
<div
className={`${
currentPath === `/collections/${collection.id}`
? "bg-primary/20 is-active"
: "hover:bg-neutral/20"
} duration-100 flex gap-1 items-center pr-2 pl-1 rounded-md`}
ref={provided.innerRef}
{...provided.draggableProps}
className="mb-1"
>
{Dropdown(item as ExtendedTreeItem, onExpand, onCollapse)}
<Link
href={`/collections/${collection.id}`}
className="w-full"
{...provided.dragHandleProps}
<div
className={cn(
currentPath === `/collections/${collection.id}`
? "bg-primary/20 is-active"
: droppableActive
? "select-none"
: "hover:bg-neutral/20",
"duration-100 flex gap-1 items-center pr-2 pl-1 rounded-md"
)}
>
<div
className={`py-1 cursor-pointer flex items-center gap-2 w-full rounded-md h-8`}
{Dropdown(item as ExtendedTreeItem, onExpand, onCollapse)}
<Link
href={`/collections/${collection.id}`}
className="w-full"
{...provided.dragHandleProps}
>
{collection.icon ? (
<Icon
icon={collection.icon}
size={30}
weight={(collection.iconWeight || "regular") as IconWeight}
color={collection.color}
className="-mr-[0.15rem]"
/>
) : (
<i
className="bi-folder-fill text-2xl"
style={{ color: collection.color }}
></i>
)}
<div
className={`py-1 cursor-pointer flex items-center gap-2 w-full rounded-md h-8`}
>
{collection.icon ? (
<Icon
icon={collection.icon}
size={30}
weight={(collection.iconWeight || "regular") as IconWeight}
color={collection.color}
className="-mr-[0.15rem]"
/>
) : (
<i
className="bi-folder-fill text-xl"
style={{ color: collection.color }}
></i>
)}
<p className="truncate w-full">{collection.name}</p>
<p className="truncate w-full">{collection.name}</p>
{collection.isPublic && (
<i
className="bi-globe2 text-sm text-black/50 dark:text-white/50 drop-shadow"
title="This collection is being shared publicly."
></i>
)}
<div className="drop-shadow text-neutral text-xs">
{collection._count?.links}
{collection.isPublic && (
<i
className="bi-globe2 text-sm text-black/50 dark:text-white/50 drop-shadow"
title="This collection is being shared publicly."
></i>
)}
<div className="drop-shadow text-neutral text-xs">
{collection._count?.links}
</div>
</div>
</div>
</Link>
</Link>
</div>
</div>
</div>
</Droppable>
);
};

View File

@@ -0,0 +1,50 @@
import React, { ReactNode } from "react";
import { Button } from "@/components/ui/button";
import { useTranslation } from "next-i18next";
import Modal from "./Modal";
import { Separator } from "./ui/separator";
type Props = {
toggleModal: Function;
className?: string;
children: ReactNode;
title: string;
onConfirmed: Function;
dismissible?: boolean;
};
export default function ConfirmationModal({
toggleModal,
className,
children,
title,
onConfirmed,
}: Props) {
const { t } = useTranslation();
return (
<Modal toggleModal={toggleModal} className={className}>
<p className="text-xl font-thin">{title}</p>
<Separator className="mb-3 mt-1" />
{children}
<div className="w-full flex items-center justify-end gap-2 mt-3">
<Button
variant="ghost"
className="hover:bg-base-200"
onClick={() => toggleModal()}
>
{t("cancel")}
</Button>
<Button
variant="destructive"
onClick={async () => {
await onConfirmed();
toggleModal();
}}
>
{t("confirm")}
</Button>
</div>
</Modal>
);
}

View File

@@ -0,0 +1,60 @@
import React, { useState } from "react";
import { Button } from "./ui/button";
type Props = {
text: string;
};
const CopyButton: React.FC<Props> = ({ text }) => {
const [copied, setCopied] = useState(false);
const handleCopy = async () => {
try {
await navigator.clipboard.writeText(text);
setCopied(true);
setTimeout(() => {
setCopied(false);
}, 1000);
} catch (err) {
console.log(err);
}
};
return (
<Button variant="ghost" type="button" size="icon" onClick={handleCopy}>
{copied ? (
<svg
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
fill="currentColor"
className="h-5 w-5 text-success"
viewBox="0 0 16 16"
>
<path d="M13.854 3.646a.5.5 0 0 1 0 .708l-7 7a.5.5 0 0 1-.708 0l-3.5-3.5a.5.5 0 1 1 .708-.708L6.5 10.293l6.646-6.647a.5.5 0 0 1 .708 0" />
</svg>
) : (
<svg
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
fill="currentColor"
className="h-5 w-5"
viewBox="0 0 16 16"
>
<path
fillRule="evenodd"
d="M4 2a2 2 0 0 1 2-2h8a2 2 0 0 1 2 2v8a2
2 0 0 1-2 2H6a2 2 0 0 1-2-2zm2-1a1 1 0 0 0-1
1v8a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1V2a1 1 0
0 0-1-1zM2 5a1 1 0 0 0-1 1v8a1 1 0 0
0 1 1h8a1 1 0 0 0 1-1v-1h1v1a2 2 0 0 1-2
2H2a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h1v1z"
/>
</svg>
)}
</Button>
);
};
export default CopyButton;

View File

@@ -8,7 +8,7 @@ export default function dashboardItem({
icon: string;
}) {
return (
<div className="flex items-center justify-between w-full rounded-2xl border border-neutral-content p-3 bg-gradient-to-tr from-neutral-content/70 to-50% to-base-200">
<div className="flex items-center justify-between w-full rounded-xl border border-neutral-content p-3 bg-gradient-to-tr from-neutral-content/70 to-50% to-base-200">
<div className="w-14 aspect-square flex justify-center items-center bg-primary/20 rounded-xl select-none">
<i className={`${icon} text-primary text-3xl drop-shadow`}></i>
</div>

View File

@@ -0,0 +1,411 @@
import React, { useState, useMemo, useEffect } from "react";
import {
DropdownMenu,
DropdownMenuTrigger,
DropdownMenuContent,
} from "@/components/ui/dropdown-menu";
import { useTranslation } from "next-i18next";
import { Button } from "@/components/ui/button";
import TextInput from "./TextInput";
import { useCollections } from "@linkwarden/router/collections";
import {
DashboardSection,
DashboardSectionType,
} from "@linkwarden/prisma/client";
import { useUser } from "@linkwarden/router/user";
import { useUpdateDashboardLayout } from "@linkwarden/router/dashboardData";
import {
SortableContext,
useSortable,
verticalListSortingStrategy,
} from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
import {
DndContext,
DragEndEvent,
MouseSensor,
TouchSensor,
useSensor,
useSensors,
} 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;
name: string;
collectionId?: number;
enabled: boolean;
order?: number;
}
export default function DashboardLayoutDropdown() {
const { t } = useTranslation();
const { data: user } = useUser();
const { data: collections = [] } = useCollections();
const updateDashboardLayout = useUpdateDashboardLayout();
const [searchTerm, setSearchTerm] = useState("");
const mouseSensor = useSensor(MouseSensor, {
// Require the mouse to move by 10 pixels before activating
activationConstraint: {
distance: 10,
},
});
const touchSensor = useSensor(TouchSensor, {
// Press delay of 200ms, with tolerance of 5px of movement
activationConstraint: {
delay: 200,
tolerance: 5,
},
});
const sensors = useSensors(mouseSensor, touchSensor);
const [dashboardSections, setDashboardSections] = useState<
DashboardSection[]
>(user?.dashboardSections || []);
useEffect(() => {
setDashboardSections(user?.dashboardSections || []);
}, [user?.dashboardSections]);
const getSectionOrder = (
type: DashboardSectionType,
collectionId?: number
): number | undefined => {
const section = dashboardSections.find(
(section) =>
section.type === type &&
(type === DashboardSectionType.COLLECTION
? section.collectionId === collectionId
: true)
);
return section?.order;
};
const isSectionEnabled = (
type: DashboardSectionType,
collectionId?: number
): boolean => {
return dashboardSections.some(
(section) =>
section.type === type &&
(type === DashboardSectionType.COLLECTION
? section.collectionId === collectionId
: true)
);
};
const defaultSections: DashboardSectionOption[] = useMemo(
() => [
{
type: DashboardSectionType.STATS,
name: t("dashboard_stats"),
enabled: isSectionEnabled(DashboardSectionType.STATS),
order: getSectionOrder(DashboardSectionType.STATS),
},
{
type: DashboardSectionType.RECENT_LINKS,
name: t("recent_links"),
enabled: isSectionEnabled(DashboardSectionType.RECENT_LINKS),
order: getSectionOrder(DashboardSectionType.RECENT_LINKS),
},
{
type: DashboardSectionType.PINNED_LINKS,
name: t("pinned_links"),
enabled: isSectionEnabled(DashboardSectionType.PINNED_LINKS),
order: getSectionOrder(DashboardSectionType.PINNED_LINKS),
},
],
[dashboardSections]
);
const collectionSections = useMemo(
() =>
collections.map((collection) => ({
type: DashboardSectionType.COLLECTION,
name: collection.name,
collectionId: collection.id,
enabled: isSectionEnabled(
DashboardSectionType.COLLECTION,
collection.id
),
order: getSectionOrder(DashboardSectionType.COLLECTION, collection.id),
})),
[collections, dashboardSections]
);
const allSections = useMemo(
() => [...defaultSections, ...collectionSections],
[collectionSections, defaultSections]
);
const filteredSections = useMemo(() => {
let sections = allSections;
if (searchTerm.trim()) {
sections = sections.filter((section) =>
section.name.toLowerCase().includes(searchTerm.toLowerCase())
);
}
const enabledSections = sections
.filter((section) => section.enabled)
.sort((a, b) => {
if (a.order !== undefined && b.order !== undefined) {
return a.order - b.order;
}
if (a.order !== undefined) return -1;
if (b.order !== undefined) return 1;
return 0;
});
const disabledSections = sections.filter((section) => !section.enabled);
return [...enabledSections, ...disabledSections];
}, [allSections, searchTerm]);
const getSectionId = (section: DashboardSectionOption) =>
`${section.type}-${section.collectionId ?? "default"}`;
const handleCheckboxChange = (section: DashboardSectionOption) => {
const enabledSections = allSections.filter((s) => s.enabled);
const highestOrder =
enabledSections.length > 0
? Math.max(...enabledSections.map((s) => s.order ?? 0))
: -1;
const updatedSections = allSections.map((s) => {
if (s.type === section.type && s.collectionId === section.collectionId) {
return {
...s,
enabled: !s.enabled,
order: !s.enabled ? highestOrder + 1 : undefined,
};
}
return s;
});
updateDashboardLayout.mutateAsync(updatedSections, {
onSettled: (data, error) => {
if (error) {
toast.error(error.message);
}
},
});
};
const handleReorder = (sourceId: string, destId: string) => {
if (sourceId === destId) return;
// Get only enabled sections for reordering
const enabledSections = filteredSections.filter((s) => s.enabled);
const sourceIndex = enabledSections.findIndex(
(s) => getSectionId(s) === sourceId
);
const destIndex = enabledSections.findIndex(
(s) => getSectionId(s) === destId
);
if (sourceIndex < 0 || destIndex < 0) return;
// Reorder only the enabled sections
const reorderedEnabled = [...enabledSections];
const [moved] = reorderedEnabled.splice(sourceIndex, 1);
reorderedEnabled.splice(destIndex, 0, moved);
// Assign new order values based on the reordered enabled sections
const reorderedWithNewOrders = reorderedEnabled.map((section, idx) => ({
...section,
order: idx,
}));
// Get disabled sections and combine with reordered enabled sections
const disabledSections = filteredSections.filter((s) => !s.enabled);
const updated = [...reorderedWithNewOrders, ...disabledSections];
updateDashboardLayout.mutateAsync(updated, {
onSettled: (data, error) => {
if (error) {
toast.error(error.message);
}
},
});
};
const handleDragEnd = (event: DragEndEvent) => {
const { active, over } = event;
if (!over || active.id === over.id) {
return;
}
const sourceId = active.id as string;
const destId = over.id as string;
// Only allow reordering enabled sections
const sourceSection = filteredSections.find(
(s) => getSectionId(s) === sourceId
);
const destSection = filteredSections.find(
(s) => getSectionId(s) === destId
);
if (sourceSection?.enabled && destSection?.enabled) {
handleReorder(sourceId, destId);
}
};
// Only include enabled sections in the sortable context
const sortableItems = filteredSections
.filter((section) => section.enabled)
.map(getSectionId);
return (
<DropdownMenu modal>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="sm" className="h-8">
<i className="bi-sliders2-vertical text-neutral" />
{t("edit_layout")}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent
className="min-w-72 pt-1 px-0 pb-0 select-none"
align="end"
>
<div className="flex flex-col gap-1">
<div className="flex flex-col gap-1 mx-2">
<p className="text-sm text-neutral mb-1">
{t("display_on_dashboard")}
</p>
<TextInput
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="py-0 bg-base-100"
placeholder={t("search")}
/>
</div>
<DndContext
modifiers={[restrictToParentElement]}
onDragEnd={handleDragEnd}
sensors={sensors}
>
<SortableContext
items={sortableItems}
strategy={verticalListSortingStrategy}
>
<ul className="max-h-60 overflow-y-auto px-2 pb-2">
{filteredSections.map((section) => {
const color =
section.type === "COLLECTION"
? collections.find((c) => c.id === section.collectionId)
?.color
: undefined;
return (
<DraggableListItem
key={getSectionId(section)}
section={{ ...section, color }}
onCheckboxChange={handleCheckboxChange}
/>
);
})}
{filteredSections.length === 0 && (
<li className="text-sm py-2 text-center text-neutral">
{t("no_results_found")}
</li>
)}
</ul>
</SortableContext>
</DndContext>
</div>
</DropdownMenuContent>
</DropdownMenu>
);
}
interface DraggableListItemProps {
section: DashboardSectionOption & { color?: string };
onCheckboxChange: (section: DashboardSectionOption) => void;
}
function DraggableListItem({
section,
onCheckboxChange,
}: DraggableListItemProps) {
const sectionId = `${section.type}-${section.collectionId ?? "default"}`;
const {
attributes,
listeners,
setNodeRef,
transform,
transition,
isDragging,
} = useSortable({
id: sectionId,
disabled: !section.enabled,
});
const style = {
transform: CSS.Transform.toString(transform),
transition,
};
return (
<li
ref={setNodeRef}
style={style}
{...attributes}
{...listeners}
className={cn(
"select-none py-1 px-1 flex items-center justify-between",
section.enabled
? "cursor-grab active:cursor-grabbing"
: "cursor-default",
isDragging && "opacity-50"
)}
>
<div className="flex items-center gap-2">
<input
id={`section-${section.type}-${section.collectionId ?? "default"}`}
className="checkbox checkbox-primary"
type="checkbox"
checked={section.enabled}
onChange={() => onCheckboxChange(section)}
/>
<label
htmlFor={`section-${section.type}-${
section.collectionId ?? "default"
}`}
className={`text-sm pointer-events-none ${
section.enabled ? "opacity-100" : "opacity-50"
}`}
>
<i
className={`bi-${
section.type === "STATS"
? "bar-chart-line"
: section.type === "RECENT_LINKS"
? "clock"
: section.type === "PINNED_LINKS"
? "pin"
: "folder-fill"
} ${section.type !== "COLLECTION" ? "text-primary" : ""} mr-1`}
style={
section.type === "COLLECTION" ? { color: section.color } : {}
}
/>
{section.name}
</label>
</div>
<i
className={`bi-grip-vertical text-neutral ${
section.enabled ? "opacity-100" : "opacity-50"
}`}
/>
</li>
);
}

View File

@@ -0,0 +1,241 @@
import { LinkIncludingShortenedCollectionAndTags } from "@linkwarden/types";
import useLocalSettingsStore from "@/store/localSettings";
import {
ArchivedFormat,
CollectionIncludingMembersAndLinkCount,
} from "@linkwarden/types";
import { useEffect, useRef, 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 Image from "next/image";
import {
atLeastOneFormatAvailable,
formatAvailable,
} from "@linkwarden/lib/formatStats";
import useOnScreen from "@/hooks/useOnScreen";
import { useCollections } from "@linkwarden/router/collections";
import { useUser } from "@linkwarden/router/user";
import { useGetLink, useLinks } from "@linkwarden/router/links";
import { useRouter } from "next/router";
import openLink from "@/lib/client/openLink";
import LinkIcon from "./LinkViews/LinkComponents/LinkIcon";
import LinkFormats from "./LinkViews/LinkComponents/LinkFormats";
import LinkTypeBadge from "./LinkViews/LinkComponents/LinkTypeBadge";
import LinkPin from "./LinkViews/LinkComponents/LinkPin";
import { Separator } from "./ui/separator";
import { useDraggable } from "@dnd-kit/core";
import { cn } from "@linkwarden/lib";
import { useTranslation } from "next-i18next";
export function DashboardLinks({
links,
isLoading,
type,
}: {
links?: LinkIncludingShortenedCollectionAndTags[];
isLoading?: boolean;
type?: "collection" | "recent";
}) {
return (
<div
className={`flex gap-5 overflow-x-auto overflow-y-hidden hide-scrollbar w-full min-h-fit`}
>
{isLoading ? (
<div className="flex flex-col gap-4 min-w-60 w-60">
<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>
) : (
links?.map((e, i) => <Card key={i} link={e} dashboardType={type} />)
)}
</div>
);
}
type Props = {
link: LinkIncludingShortenedCollectionAndTags;
editMode?: boolean;
dashboardType?: "collection" | "recent";
};
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,
},
});
const { data: collections = [] } = useCollections();
const { data: user } = useUser();
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 });
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 [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]);
return (
<div
ref={setNodeRef}
className={cn(
isDragging ? "opacity-30" : "opacity-100",
"relative group touch-manipulation select-none"
)}
>
<div
ref={ref}
className={`min-w-60 w-60 border border-solid border-neutral-content bg-base-200 duration-100 rounded-xl relative group h-full`}
>
<div
className="rounded-xl cursor-pointer h-full w-full flex flex-col justify-between"
onClick={() =>
!editMode && openLink(link, user, () => setLinkModal(true))
}
{...listeners}
{...attributes}
>
{show.image && (
<div>
<div className={`relative rounded-t-xl h-40 overflow-hidden`}>
{formatAvailable(link, "preview") ? (
<Image
src={`/api/v1/archives/${link.id}?format=${ArchivedFormat.jpeg}&preview=true&updatedAt=${link.updatedAt}`}
width={1280}
height={720}
alt=""
className={`rounded-t-xl select-none object-cover z-10 h-40 w-full shadow opacity-80 scale-105`}
style={show.icon ? { filter: "blur(1px)" } : undefined}
draggable="false"
onError={(e) => {
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>
) : (
<div
className={`h-40 bg-opacity-80 skeleton rounded-none`}
></div>
)}
{show.icon && (
<div className="absolute top-0 left-0 right-0 bottom-0 rounded-t-xl flex items-center justify-center rounded-md">
<LinkIcon link={link} />
</div>
)}
{show.preserved_formats &&
link.type === "url" &&
atLeastOneFormatAvailable(link) && (
<div className="absolute bottom-0 right-0 m-2 bg-base-200 bg-opacity-60 px-1 rounded-md">
<LinkFormats link={link} />
</div>
)}
</div>
<Separator />
</div>
)}
<div className="flex flex-col justify-between h-full min-h-11">
<div className="p-3 flex flex-col justify-between h-full gap-2">
{show.name && (
<p className="line-clamp-2 w-full text-primary text-sm">
{unescapeString(link.name)}
</p>
)}
{show.link && <LinkTypeBadge link={link} />}
</div>
{(show.collection || show.date) && (
<div>
<Separator className="mb-1" />
<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}
isPublicRoute={false}
/>
</div>
)}
{show.date && <LinkDate link={link} />}
</div>
</div>
)}
</div>
</div>
{/* Overlay on hover */}
<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}
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"
/>
{!isPublicRoute && <LinkPin link={link} />}
</div>
</div>
);
}

View File

@@ -0,0 +1,276 @@
import {
DndContext,
DragEndEvent,
DragOverlay,
DragStartEvent,
MouseSensor,
SensorDescriptor,
SensorOptions,
TouchSensor,
useSensor,
useSensors,
} from "@dnd-kit/core";
import LinkIcon from "./LinkViews/LinkComponents/LinkIcon";
import { LinkIncludingShortenedCollectionAndTags } from "@linkwarden/types";
import toast from "react-hot-toast";
import { useUpdateLink } from "@linkwarden/router/links";
import { useTranslation } from "react-i18next";
import { snapCenterToCursor } from "@dnd-kit/modifiers";
import { customCollisionDetectionAlgorithm } from "@/lib/utils";
import usePinLink from "@/lib/client/pinLink";
import { useQueryClient } from "@tanstack/react-query";
import { useUser } from "@linkwarden/router/user";
interface DragNDropProps {
children: React.ReactNode;
/**
* The currently active link being dragged
*/
activeLink: LinkIncludingShortenedCollectionAndTags | null;
/**
* All links available for drag and drop
*/
setActiveLink: (link: LinkIncludingShortenedCollectionAndTags | null) => void;
/**
* Override the default sensors used for drag and drop.
*/
sensors?: SensorDescriptor<SensorOptions>[];
/**
* Override onDragEnd function.
*/
onDragEnd?: (event: DragEndEvent) => void;
}
/**
* Wrapper component for drag and drop functionality.
*/
export default function DragNDrop({
children,
activeLink,
setActiveLink,
sensors: sensorProp,
onDragEnd: onDragEndProp,
}: DragNDropProps) {
const { t } = useTranslation();
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: {
distance: 10,
},
});
const touchSensor = useSensor(TouchSensor, {
// Press delay of 250ms, with tolerance of 5px of movement
activationConstraint: {
delay: 200,
tolerance: 5,
},
});
const sensors = useSensors(mouseSensor, touchSensor);
const handleDragStart = (event: DragStartEvent) => {
setActiveLink(
(event.active.data.current
?.link as LinkIncludingShortenedCollectionAndTags) ?? null
);
};
const handleDragOverCancel = () => {
setActiveLink(null);
};
const handleDragEnd = async (event: DragEndEvent) => {
if (onDragEndProp) {
onDragEndProp(event);
return;
}
const { over, active } = event;
if (!over || !activeLink) return;
const overData = over.data.current;
const targetId = String(over.id);
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;
}
const allTags: { name: string }[] = (activeLink.tags ?? []).map(
(tag) => ({
name: tag.name,
})
);
const updatedLink: LinkIncludingShortenedCollectionAndTags = {
...activeLink,
tags: [...allTags, { name: tagName }] as any,
};
await mutateWithToast(updatedLink, {
invalidateDashboardOnError: typeof queryClient !== "undefined",
});
return;
}
// DROP ON DASHBOARD "PINNED" SECTION
const isPinnedSection = targetId === "pinned-links-section";
const canPin =
typeof pinLink === "function" &&
typeof user !== "undefined" &&
typeof user?.id !== "undefined";
if (isPinnedSection && canPin) {
if (Array.isArray(activeLink.pinnedBy) && !activeLink.pinnedBy.length) {
if (typeof queryClient !== "undefined") {
const optimisticallyPinned = {
...activeLink,
pinnedBy: [user!.id],
};
queryClient.setQueryData(["dashboardData"], (oldData: any) => {
if (!oldData?.links) return oldData;
return {
...oldData,
links: oldData.links.map((l: any) =>
l.id === optimisticallyPinned.id ? optimisticallyPinned : l
),
};
});
}
pinLink(activeLink);
}
return;
}
// DROP ON COLLECTION (dashboard + sidebar)
const collectionId = overData?.id as number | undefined;
const collectionName = overData?.name as string | undefined;
const ownerId = overData?.ownerId as number | undefined;
if (!collectionId || !collectionName || typeof ownerId === "undefined")
return;
const isSameCollection = activeLink.collection?.id === collectionId;
if (isSameCollection) {
if (isFromRecentSection) toast.error(t("link_already_in_collection"));
return;
}
const updatedLink: LinkIncludingShortenedCollectionAndTags = {
...activeLink,
collection: {
id: collectionId,
name: collectionName,
ownerId,
},
};
if (typeof queryClient !== "undefined") {
queryClient.setQueryData(["dashboardData"], (oldData: any) => {
if (!oldData?.links) return oldData;
return {
...oldData,
links: oldData.links.map((l: any) =>
l.id === updatedLink.id ? updatedLink : l
),
};
});
queryClient.setQueryData(["dashboardData"], (oldData: any) => {
if (!oldData?.collectionLinks) return oldData;
const oldCollectionId = activeLink.collection?.id;
if (!oldCollectionId) return oldData;
return {
...oldData,
collectionLinks: {
...oldData.collectionLinks,
[oldCollectionId]: (
oldData.collectionLinks[oldCollectionId] || []
).filter((l: any) => l.id !== updatedLink.id),
[collectionId]: [
...(oldData.collectionLinks[collectionId] || []),
updatedLink,
],
},
};
});
}
await mutateWithToast(updatedLink, {
invalidateDashboardOnError: typeof queryClient !== "undefined",
});
};
return (
<DndContext
onDragStart={handleDragStart}
onDragEnd={handleDragEnd}
onDragCancel={handleDragOverCancel}
modifiers={[snapCenterToCursor]}
sensors={sensorProp ? sensorProp : sensors}
collisionDetection={customCollisionDetectionAlgorithm}
>
{!!activeLink && (
// when drag end, immediately hide the overlay
<DragOverlay
style={{
zIndex: 100,
pointerEvents: "none",
}}
>
<div className="w-fit h-fit">
<LinkIcon link={activeLink} />
</div>
</DragOverlay>
)}
{children}
</DndContext>
);
}

View File

@@ -1,12 +1,14 @@
import React, { ReactNode, useEffect } from "react";
import { Drawer as D } from "vaul";
import clsx from "clsx";
import useWindowDimensions from "@/hooks/useWindowDimensions";
type Props = {
toggleDrawer: Function;
children: ReactNode;
className?: string;
dismissible?: boolean;
direction?: "left" | "right";
};
export default function Drawer({
@@ -14,11 +16,13 @@ export default function Drawer({
className,
children,
dismissible = true,
direction,
}: Props) {
const [drawerIsOpen, setDrawerIsOpen] = React.useState(true);
const { width } = useWindowDimensions();
useEffect(() => {
if (window.innerWidth >= 640) {
if (width >= 640) {
document.body.style.overflow = "hidden";
document.body.style.position = "relative";
return () => {
@@ -28,7 +32,7 @@ export default function Drawer({
}
}, []);
if (window.innerWidth < 640) {
if (width < 640) {
return (
<D.Root
open={drawerIsOpen}
@@ -38,10 +42,10 @@ export default function Drawer({
>
<D.Portal>
<D.Overlay className="fixed inset-0 bg-black/40" />
<D.Content className="flex flex-col rounded-t-2xl mt-24 fixed bottom-0 left-0 right-0 z-30 h-[90%] !select-auto focus:outline-none">
<D.Content className="flex flex-col rounded-t-xl mt-24 fixed bottom-0 left-0 right-0 z-30 h-[90%] !select-auto focus:outline-none">
<div
className={clsx(
"p-4 bg-base-100 rounded-t-2xl flex-1 border-neutral-content border-t overflow-y-auto",
"p-4 bg-base-100 rounded-t-xl flex-1 border-neutral-content border-t overflow-y-auto",
className
)}
data-testid="mobile-modal-container"
@@ -60,14 +64,20 @@ export default function Drawer({
onClose={() => dismissible && setDrawerIsOpen(false)}
onAnimationEnd={(isOpen) => !isOpen && toggleDrawer()}
dismissible={dismissible}
direction="right"
direction={direction || "right"}
>
<D.Portal>
<D.Overlay className="fixed inset-0 bg-black/10 z-20" />
<D.Content className="bg-white flex flex-col h-full w-2/5 min-w-[30rem] mt-24 fixed bottom-0 right-0 z-40 !select-auto focus:outline-none">
<D.Content
className={clsx(
"bg-white flex flex-col h-full w-2/5 max-w-6xl min-w-[30rem] mt-24 fixed bottom-0 z-40 !select-auto focus:outline-none",
direction === "left" ? "left-0" : "right-0"
)}
>
<div
className={clsx(
"p-4 bg-base-100 flex-1 border-neutral-content border-l overflow-y-auto",
"p-4 bg-base-100 flex-1 border-neutral-content overflow-y-auto",
direction === "left" ? "border-r" : "border-l",
className
)}
>

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