Compare commits

...

614 Commits

Author SHA1 Message Date
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
Daniel
84e99b55c9 Merge pull request #959 from linkwarden/dev
revert variable name
2025-01-06 13:32:38 -05:00
daniel31x13
22c4fbf613 revert variable name 2025-01-06 13:32:02 -05:00
Daniel
0e1b51177b Merge pull request #958 from linkwarden/dev
v2.9.1
2025-01-06 13:28:45 -05:00
daniel31x13
44499c1277 bump version 2025-01-06 13:28:25 -05:00
daniel31x13
9d986356a7 switch to react-query for fetching the config 2025-01-06 12:56:17 -05:00
daniel31x13
1d854e16aa bug fix 2025-01-06 12:39:16 -05:00
daniel31x13
02c02fc3b9 use SSR for public ENV 2025-01-06 11:32:44 -05:00
Daniel
99bdc7d55e Merge pull request #954 from linkwarden/dev
bug fixed
2025-01-06 05:44:35 -05:00
daniel31x13
62c7bbbb74 bug fixed 2025-01-06 05:43:22 -05:00
Daniel
dfb31ab1b3 Merge pull request #948 from linkwarden/dev
Linkwarden v2.9.0
2025-01-06 05:09:24 -05:00
daniel31x13
e0c0b76eb0 update README 2025-01-05 19:25:00 -05:00
daniel31x13
9bc261bc85 minor change 2025-01-02 23:16:03 -05:00
daniel31x13
b2d2e23539 fix: better green 2025-01-02 16:37:07 -05:00
daniel31x13
04c69bb05f feat: add option to open link details 2025-01-02 16:33:58 -05:00
daniel31x13
0cc1fd8407 WIP 2025-01-02 16:26:38 -05:00
daniel31x13
848e0bf50e feat: quick look at the preserved formats 2025-01-02 15:42:47 -05:00
daniel31x13
c3981c7fff feat: support for multiple themes 2025-01-02 13:48:35 -05:00
daniel31x13
ec7d6f4a6b minor improvement 2024-12-31 18:43:11 -05:00
daniel31x13
9d8b602839 feat: choose what to show in dashboard 2024-12-31 18:42:34 -05:00
daniel31x13
f0f57fb1a9 bug fix 2024-12-31 17:01:27 -05:00
daniel31x13
88820361e9 update README 2024-12-31 08:36:06 -05:00
daniel31x13
be47c78e4d small improvement to the public collection page 2024-12-30 06:05:15 -05:00
daniel31x13
fa059d1b00 bug fixed 2024-12-30 04:15:43 -05:00
daniel31x13
bcfec38adf bug fixed 2024-12-30 04:07:48 -05:00
AverageHelper
0344467cb7 feat: Use the same decoder that JSDom uses to encode 2024-12-29 01:37:22 -07:00
daniel31x13
454ed7b7eb more efficient docker-compose file 2024-12-29 02:04:28 -05:00
Daniel
51da37a22f Merge pull request #893 from zodac/dev
Adding healthcheck to docker image
2024-12-29 01:43:00 -05:00
Daniel
6edbc4f438 Merge pull request #902 from keizie/fix/import-pin
fix(import): save pinnedLinks from Linkwarden export json
2024-12-29 01:17:00 -05:00
AverageHelper
899ddafd90 fix(import-html): Work around parser bug that mangles attribute values 2024-12-28 23:11:50 -07:00
Daniel
755721f1c2 Merge pull request #920 from Zalaxx/dev
fix: HTML backup with Monolith when using Docker
2024-12-29 01:04:46 -05:00
Daniel
0b20f61913 Merge pull request #928 from il516/rss-feeds
feat(rss): Add RSS feeds for collections
2024-12-25 08:08:00 -05:00
daniel31x13
e9cf93d769 minor improvement 2024-12-25 08:07:15 -05:00
Daniel
f94d10a3d3 Merge pull request #934 from mrkhachaturov/main
russian language support
2024-12-24 09:31:34 -05:00
Daniel
6a95f6efdc Merge branch 'dev' into main 2024-12-24 09:30:19 -05:00
Daniel
150eeb9f11 Merge pull request #875 from dereulenspiegel/omnivore-import
Omnivore import
2024-12-24 09:27:48 -05:00
daniel31x13
fe77625289 finalized the implementation 2024-12-24 09:22:53 -05:00
zodac
63e7377df4 Installing curl 2024-12-24 21:48:16 +13:00
Ruben Khachaturov
458eae9a3c russian language support 2024-12-23 08:42:48 +03:00
daniel31x13
7ef2afae7f small change 2024-12-22 14:07:10 -05:00
Isaac Wise
a1d02f110d Fix RSS Subscription form & move endpoint 2024-12-19 17:16:07 -06:00
Isaac Wise
378fec06bb Format & Lint 2024-12-19 16:06:24 -06:00
Isaac Wise
e04997c8c4 Add RSS Feed button 2024-12-19 16:05:34 -06:00
Isaac Wise
aecb90b4a4 use correct protocol 2024-12-19 01:02:56 -06:00
Isaac Wise
dac6ec966c rss feeds 2024-12-19 00:44:53 -06:00
daniel31x13
582159f454 resolved conflicts 2024-12-14 10:28:46 -05:00
dereulenspiegel
cf02b7a099 Rebased to dev and ran formatting 2024-12-13 18:18:32 +01:00
dereulenspiegel
7b4d324852 Previous import mechanism is restored to previous behavior, while omnivore uses new binary import path 2024-12-13 18:17:57 +01:00
dereulenspiegel
8932b9929a Moving omnivore import to specific binary upload route 2024-12-13 18:17:56 +01:00
dereulenspiegel
6b416f23f0 Now limiting the read size for imports in a configuration compatible way to the previous implementation 2024-12-13 18:17:55 +01:00
dereulenspiegel
fb25cf5c75 Adopted other import functions to handle streams instead of text 2024-12-13 18:17:54 +01:00
dereulenspiegel
c8f33f4800 Returning 200 success to avoid stalled responses 2024-12-13 18:17:53 +01:00
dereulenspiegel
95f818959b Made the import links dropdown menu reusable to avoid code duplication 2024-12-13 18:17:52 +01:00
dereulenspiegel
a0aabde322 Fixed the unnecessary creation of multiple import collections 2024-12-13 18:17:51 +01:00
dereulenspiegel
0e3ca5b51f Made importBookmarks reusable 2024-12-13 18:17:46 +01:00
dereulenspiegel
a733cc69a3 Import should now work on testdata 2024-12-13 18:17:03 +01:00
dereulenspiegel
cbc88ebcb2 Revert "Added adm-zip dependency to be able to handle zip files for importing omnivore exports"
This reverts commit 47367c44c1.
2024-12-13 18:17:02 +01:00
dereulenspiegel
c3b78a8f82 Added adm-zip dependency to be able to handle zip files for importing omnivore exports 2024-12-13 18:17:00 +01:00
dereulenspiegel
8ad9ab7755 Added basic infrastructure to be able to have omnivore imports 2024-12-13 18:16:58 +01:00
daniel31x13
4bf220c786 minimize the usage of external dependencies 2024-12-11 09:36:42 -05:00
Daniel
ed8f2d3777 Merge pull request #894 from KittyKatt/dev
Sub-collections included in collection page and total link count
2024-12-10 07:15:35 -05:00
daniel31x13
5b87799fdd improvements 2024-12-10 07:14:17 -05:00
Daniel
97abe5de0c Merge branch 'dev' into omnivore-import 2024-12-10 06:25:01 -05:00
daniel31x13
731b259329 update version number 2024-12-09 23:48:41 -05:00
daniel31x13
63cef8e6b0 minor fix 2024-12-09 22:38:51 -05:00
Daniel
18db677c29 Merge pull request #921 from linkwarden/ai-tagging
Ai tagging
2024-12-09 22:36:20 -05:00
Daniel
6071aa617f Merge branch 'dev' into ai-tagging 2024-12-09 22:33:03 -05:00
daniel31x13
f270adbffa fully implemented ai tagging 2024-12-09 22:31:39 -05:00
daniel31x13
6259048431 remove pending format logic 2024-12-09 16:17:39 -05:00
daniel31x13
40f4a5acd9 cleaner code + add "aiTagged" field 2024-12-09 14:10:20 -05:00
Zalax
4240d37d77 Merge branch 'linkwarden:dev' into dev 2024-12-09 19:07:11 +00:00
Zalax
0abe065c0c fix: HTML backup with Monolith when using Docker
ca-certificates is needed to not have certificates error when monolith is retrieving a website's resources.

It does not happend with other backup format because the retrieving is done with a headless chrome and thus has its own certificate store
2024-12-09 20:06:06 +01:00
Daniel
346f41a12c Merge pull request #911 from AmadeusGraves/dev
Update Spanish Lang
2024-12-08 07:42:09 -05:00
Daniel
2ffbede170 Merge pull request #910 from il516/rss-feed-subscriptions
feat(rss): Add RSS Subscriptions
2024-12-08 07:41:14 -05:00
daniel31x13
c148c2b953 improvements 2024-12-08 07:37:27 -05:00
daniel31x13
a872f218fb added a base prompt for the tag generation... 2024-12-05 14:04:02 -05:00
Isaac Wise
ff1f87cb35 uncomment collection table cell 2024-12-05 02:49:37 -06:00
AmadeusGraves
94b143c91a Update common.json
Upload translation to spanish.
2024-12-05 09:48:07 +01:00
Isaac Wise
52a11040f6 Fix capitalization 2024-12-05 02:22:38 -06:00
Isaac Wise
e9c43d75fe remove console log 2024-12-05 02:04:21 -06:00
Isaac Wise
47b226cf1f format 2024-12-05 02:01:47 -06:00
Isaac Wise
266f834018 RSS form 2024-12-05 01:53:53 -06:00
Isaac Wise
c9885c0b73 format 2024-12-04 16:56:26 -06:00
Isaac
ec885a7db2 Merge branch 'linkwarden:main' into rss-feed-subscriptions 2024-12-04 16:52:52 -06:00
Isaac Wise
5665fbb412 Include collection name in table 2024-12-04 16:51:41 -06:00
Isaac Wise
9e933bd630 Update yarn lock 2024-12-04 01:55:29 -06:00
Isaac Wise
3572e101fb Table & Delete modal 2024-12-04 01:53:25 -06:00
Isaac Wise
fb06549330 Delete RSS Subscription 2024-12-04 01:18:11 -06:00
Isaac Wise
991f12566f Start RSS Subscriptions 2024-12-04 01:12:05 -06:00
dereulenspiegel
160845319f Now limiting the read size for imports in a configuration compatible way to the previous implementation 2024-12-01 15:27:16 +01:00
Daniel
bff5a7ae9a Merge pull request #903 from keizie/fix/import-url-slice
fix(import): keep url not sliced
2024-12-01 06:26:56 -05:00
Daniel
6e01c135dc increase the url limits 2024-12-01 06:25:13 -05:00
Daniel
f32e5a8d05 Merge pull request #897 from 7Adrian/7Adrian-pl-language
Added Polish "polski" language.
2024-12-01 06:02:55 -05:00
Daniel
e9003e5c45 added locale to configs 2024-12-01 05:56:38 -05:00
keizie
9065756686 fix(import): save pinnedLinks from Linkwarden export json 2024-12-01 15:25:30 +09:00
keizie
881df93c02 fix(import): keep url not sliced 2024-12-01 13:01:43 +09:00
Daniel
ed7fab0473 Merge pull request #899 from linkwarden/dev
minor fix
2024-11-30 16:13:46 -05:00
daniel31x13
38d054e143 minor fix 2024-11-30 16:12:10 -05:00
Daniel
5e89658a11 Merge pull request #898 from linkwarden/dev
Dev
2024-11-30 16:10:47 -05:00
daniel31x13
d964e02ba1 update readme 2024-11-30 16:09:31 -05:00
daniel31x13
a36eb23096 update version number 2024-11-30 15:55:36 -05:00
7Adrian
8f875d15b0 Added Polish "polski" language.
Add Polish language to public/locales/pl/common.json.
2024-11-30 17:38:48 +01:00
Daniel
9f660ff70f Merge pull request #885 from Go-rom/2.8.3-french-translation
2.8.3 french translation
2024-11-29 23:32:52 -05:00
Daniel
e6f43bbbfa Merge pull request #896 from keizie/fix-monolith-error
fix(monolith): quote url
2024-11-29 23:31:10 -05:00
keizie
1609868149 fix(monolith): quote url
when url include ampersand shell fails
```
/bin/sh: 1: amp: not found
/bin/sh: 1: -j: not found
Uncaught Monolith error...
```
2024-11-30 11:07:56 +09:00
daniel31x13
9075618e00 added ai tagging to the settings 2024-11-29 12:57:42 -05:00
Katie Bohnenkamper
179cd18ac5 Sub-collections shown on collection page 2024-11-29 00:25:50 -06:00
Katie Bohnenkamper
5a5fa9ed6c Sub-collections included in link count on tree 2024-11-29 00:24:59 -06:00
zodac
5279d94b8c Adding healthcheck to docker image 2024-11-29 13:43:37 +13:00
Gorom
5c3848e833 Update french translation 2024-11-20 17:15:59 +01:00
Gorom
27d7bbabb3 Update french translation 2024-11-20 16:56:41 +01:00
dereulenspiegel
8ff1346bf1 Adopted other import functions to handle streams instead of text 2024-11-19 12:51:21 +01:00
dereulenspiegel
71119d511e Returning 200 success to avoid stalled responses 2024-11-19 12:48:13 +01:00
dereulenspiegel
b7eb8f2c2f Made the import links dropdown menu reusable to avoid code duplication 2024-11-19 12:47:03 +01:00
dereulenspiegel
3d54cc05a4 Fixed the unnecessary creation of multiple import collections 2024-11-18 18:02:02 +01:00
dereulenspiegel
8973bdd94e Made importBookmarks reusable 2024-11-18 17:54:36 +01:00
dereulenspiegel
1af37f3619 Import should now work on testdata 2024-11-18 17:39:06 +01:00
daniel31x13
5303d63e4b add option to disable preservation 2024-11-17 16:06:05 -05:00
Daniel
05a30e1ec6 Merge pull request #867 from clemenstyp/fixed-placeholder
fixed placeholder in german translation (delete collection)
2024-11-16 09:13:21 -05:00
daniel31x13
b1a55785b5 import dates as well 2024-11-16 08:58:03 -05:00
Clemens Eyhoff
24b47e9d4b fixed an other uppercase placeholder 2024-11-16 14:49:07 +01:00
Clemens Eyhoff
34d19f9dbe fixed placeholder (was upper case) 2024-11-16 12:46:44 +01:00
dereulenspiegel
3a70e138b5 Revert "Added adm-zip dependency to be able to handle zip files for importing omnivore exports"
This reverts commit 47367c44c1.
2024-11-15 12:50:04 +01:00
dereulenspiegel
47367c44c1 Added adm-zip dependency to be able to handle zip files for importing omnivore exports 2024-11-15 12:43:17 +01:00
dereulenspiegel
e1a31481ad Added basic infrastructure to be able to have omnivore imports 2024-11-15 12:39:13 +01:00
Daniel
95dddd7da0 Merge pull request #859 from linkwarden/dev
Dev
2024-11-14 15:45:17 -05:00
daniel31x13
1a949ecdc6 bug fix 2024-11-14 15:44:31 -05:00
daniel31x13
2e6f1c207c bug fixed 2024-11-14 15:43:37 -05:00
Daniel
6aa0fa9465 Merge pull request #857 from linkwarden/dev
minor UI improvement
2024-11-14 11:01:41 -05:00
daniel31x13
8677df0340 minor UI improvement 2024-11-14 11:01:02 -05:00
Daniel
125f6ac619 Merge pull request #856 from linkwarden/dev
update version
2024-11-14 08:51:33 -05:00
daniel31x13
89ecf5c529 update version 2024-11-14 08:50:52 -05:00
Daniel
fa78d6057f Merge pull request #855 from linkwarden/dev
bug fix
2024-11-14 08:50:10 -05:00
daniel31x13
cfc28be898 bug fix 2024-11-14 08:48:18 -05:00
Daniel
c8efd4f9db Merge pull request #852 from linkwarden/dev
created check-branch.yml
2024-11-14 02:43:50 -05:00
daniel31x13
ada4e53b46 created check-branch.yml 2024-11-14 02:42:01 -05:00
Daniel
91494b0188 Merge pull request #849 from linkwarden/dev
increase staticPageGenerationTimeout
2024-11-13 22:20:17 -05:00
daniel31x13
e9fd6ec4d5 increase staticPageGenerationTimeout 2024-11-13 22:19:18 -05:00
Daniel
f08f4058dc Merge pull request #846 from linkwarden/dev
update Dockerfile
2024-11-13 09:23:59 -05:00
daniel31x13
d60200205a update Dockerfile 2024-11-13 09:23:22 -05:00
Daniel
de38eb2963 Merge pull request #845 from linkwarden/dev
revert dockerfile to working state
2024-11-13 06:14:46 -05:00
daniel31x13
f22dd4535d revert dockerfile to working state 2024-11-13 06:12:39 -05:00
Daniel
043589b301 Merge pull request #841 from linkwarden/dev
update version number
2024-11-13 00:01:50 -05:00
daniel31x13
4556827d79 update version number 2024-11-13 00:01:12 -05:00
Daniel
98ebd6d7bc Merge pull request #840 from linkwarden/dev
Dev
2024-11-12 23:59:47 -05:00
daniel31x13
0a3ca4a1d4 Merge branch 'dev' of https://github.com/linkwarden/linkwarden into dev 2024-11-12 23:58:53 -05:00
daniel31x13
106410f55a revert change 2024-11-12 23:58:50 -05:00
Daniel
1ffe1b68a9 Merge pull request #839 from linkwarden/dev
Dev
2024-11-12 23:15:52 -05:00
Daniel
91ab0e609b Merge pull request #833 from click0/Ukrainian-translation
Corrected Ukrainian translation for November 12, 2024
2024-11-12 23:13:38 -05:00
daniel31x13
cbb7a666cd bug fix 2024-11-12 23:12:20 -05:00
Daniel
e8cf14334f Merge pull request #838 from linkwarden/dev
added status badges
2024-11-12 22:50:33 -05:00
daniel31x13
019790791b added status badges 2024-11-12 22:49:38 -05:00
Daniel
e41ba2668f Merge pull request #837 from linkwarden/dev
bug fix
2024-11-12 22:18:25 -05:00
daniel31x13
66a09fdc4b bug fix 2024-11-12 22:16:42 -05:00
Daniel
e50143ca7e Merge pull request #835 from linkwarden/dev
minor change
2024-11-12 18:22:28 -05:00
daniel31x13
162b120e55 minor change 2024-11-12 18:21:58 -05:00
Daniel
b4dd47aa37 Merge pull request #834 from linkwarden/dev
updated README
2024-11-12 17:40:32 -05:00
daniel31x13
256c232a85 updated README 2024-11-12 17:39:48 -05:00
vlad11
b7ddf22662 Corrected Ukrainian translation for November 12, 2024
Signed-off-by: vlad11 <admin@support.od.ua>
2024-11-13 00:12:32 +02:00
Daniel
5f60e9833e Merge pull request #831 from linkwarden/dev
bug fix
2024-11-12 16:18:15 -05:00
daniel31x13
ceed23ff51 bug fix 2024-11-12 16:17:38 -05:00
daniel31x13
a4c83dc82f small fix 2024-11-12 08:36:40 -05:00
daniel31x13
46f81ebf25 add info to inviteModal 2024-11-10 16:42:04 -05:00
daniel31x13
0ac5009a4a minor change 2024-11-10 00:27:13 -05:00
daniel31x13
6842da4283 new feature: open all links 2024-11-09 23:59:12 -05:00
daniel31x13
78ecf3ddb5 bug fix 2024-11-09 23:33:13 -05:00
daniel31x13
e39645e135 bug fix 2024-11-09 23:11:03 -05:00
daniel31x13
836360f99d bug fixed 2024-11-09 23:07:01 -05:00
daniel31x13
9c9fd969bc minor fix 2024-11-09 15:27:15 -05:00
daniel31x13
213105942b minor change 2024-11-09 15:02:59 -05:00
daniel31x13
0b7acb35b7 minor change 2024-11-09 14:14:13 -05:00
daniel31x13
9b58ea5c98 minor change 2024-11-09 13:45:11 -05:00
daniel31x13
c85c3bb0d7 minor fix 2024-11-08 18:03:00 -05:00
daniel31x13
7ca574b76f bug fixes 2024-11-08 17:57:50 -05:00
daniel31x13
8593df4673 bug fixed 2024-11-08 17:21:20 -05:00
daniel31x13
ddc2079f4b minor fix 2024-11-08 12:25:31 -05:00
daniel31x13
0de5caffa1 minor fix 2024-11-08 05:24:18 -05:00
daniel31x13
b14e77bdf9 minor fix 2024-11-08 04:48:31 -05:00
daniel31x13
8d366ae7d8 minor fix 2024-11-07 16:54:51 -05:00
daniel31x13
a18938ba2a minor fix 2024-11-07 16:46:26 -05:00
daniel31x13
6eac8423f8 added survey 2024-11-07 11:09:36 -05:00
daniel31x13
cbf93dcf06 minor improvement 2024-11-07 07:32:06 -05:00
daniel31x13
2993347dc7 sleeker dashboard items 2024-11-07 02:09:56 -05:00
daniel31x13
cc45c8fc3e minor improvement 2024-11-07 01:17:30 -05:00
daniel31x13
d5602a09cd minor fix 2024-11-07 01:14:23 -05:00
daniel31x13
736e98ac7d improvements 2024-11-07 01:12:05 -05:00
daniel31x13
7eaff332a9 bug fixed 2024-11-07 00:20:57 -05:00
daniel31x13
7931e2d7b6 better logic when showing link icons 2024-11-07 00:19:12 -05:00
daniel31x13
ac3888f9b3 icon picker is now much more efficient 2024-11-06 23:57:20 -05:00
daniel31x13
ac8add8c5d small improvement 2024-11-06 22:53:21 -05:00
daniel31x13
a6a0f6965b bug fixed 2024-11-06 03:36:02 -05:00
Daniel
b2c5c3c6dd Merge pull request #792 from jvanbruegge/prisma-update
Update prisma to v5
2024-11-03 14:20:24 -05:00
Daniel
4555874725 Merge branch 'dev' into prisma-update 2024-11-03 14:19:28 -05:00
daniel31x13
0f5b70eda7 update prisma 2024-11-03 03:59:39 -05:00
daniel31x13
d1c3748681 minor improvement 2024-11-03 03:34:21 -05:00
Daniel
2524139113 Merge pull request #816 from linkwarden/main
Merge main to dev
2024-11-03 03:19:40 -05:00
Daniel
6c2b86fc4b Merge branch 'dev' into main 2024-11-03 03:19:29 -05:00
Daniel
d0e0526655 Merge pull request #815 from Green-Kite/dev
update german translation
2024-11-03 03:10:40 -05:00
Green-Kite
43e94ebd0b update german translation
updated german translation
2024-11-03 07:24:38 +01:00
Daniel
aeafe6e15d Merge pull request #789 from jvanbruegge/playwright-path
Allow to specify a custom playwright browser path
2024-11-02 21:59:12 -04:00
daniel31x13
5ec221d87d update .env.sample 2024-11-02 21:58:44 -04:00
Daniel
d6d6442bc4 Merge pull request #809 from Green-Kite/dev
update german translation
2024-11-02 20:57:31 -04:00
Daniel
d12d12518e Merge pull request #636 from bjoerndot/tags-in-public-collection
Tags in public collection
2024-11-02 20:55:16 -04:00
daniel31x13
02ced62832 final change 2024-11-02 20:45:31 -04:00
daniel31x13
4febe1ace5 minor changes 2024-11-02 20:43:53 -04:00
daniel31x13
2e1e94112f make tags visible on public collections 2024-11-02 18:16:38 -04:00
daniel31x13
d86bbcd940 minor fix 2024-11-02 18:07:16 -04:00
daniel31x13
eed80ca812 add migration 2024-11-02 18:01:36 -04:00
Daniel
394251c1f1 Merge branch 'dev' into tags-in-public-collection 2024-11-02 17:56:43 -04:00
Daniel
68cdde91ad Merge pull request #813 from linkwarden/feat/team-support
Feat/seats support
2024-11-02 17:52:17 -04:00
Green-Kite
1ef286a38c update german translation
German translation updated
2024-11-01 10:08:14 +01:00
daniel31x13
508844dd9d bug fixes 2024-10-30 16:47:40 -04:00
daniel31x13
fa1f9873d5 minor change 2024-10-30 13:56:07 -04:00
Oliver Schwamb
891803547e retrieve all links for collection 2024-10-30 12:10:53 +01:00
Oliver Schwamb
24d45f8e8e Merge remote-tracking branch 'upstream/dev' into tags-in-public-collection 2024-10-30 12:10:30 +01:00
daniel31x13
f95350405c rename variable 2024-10-29 18:14:35 -04:00
daniel31x13
665019dc59 finalizing team support 2024-10-29 18:08:47 -04:00
daniel31x13
b09de5a8af updated verify max link logic 2024-10-26 13:44:52 -04:00
daniel31x13
cfd33e9bd1 bug fixed 2024-10-26 10:58:27 -04:00
daniel31x13
d3d2d5069e add member onboarding 2024-10-26 09:42:21 -04:00
daniel31x13
cffc74caa4 add team invitation functionality [WIP] 2024-10-21 13:59:05 -04:00
Jan van Brügge
3cd8eadee3 Update prisma to v5 2024-10-08 16:25:36 +01:00
daniel31x13
d146ec296c bug fixed 2024-10-07 23:43:44 -04:00
Jan van Brügge
fb4aa42eef Allow to specify a custom playwright browser path 2024-10-07 15:05:48 +01:00
daniel31x13
f68582e28c bug fixed 2024-10-07 00:57:36 -04:00
daniel31x13
d042c82cb0 add subscription webhook 2024-10-06 01:59:31 -04:00
Daniel
8738dd45e9 Merge pull request #771 from click0/main
Corrected Ukrainian translation.
2024-09-19 14:13:37 -04:00
Vladyslav V. Prodan
839de18d7a Merge branch 'linkwarden:main' into main 2024-09-19 00:46:06 +03:00
vlad11
2ba0851fee Corrected Ukrainian translation.
Signed-off-by: vlad11 <admin@support.od.ua>
2024-09-19 00:36:16 +03:00
daniel31x13
d99972a335 minor fix 2024-09-18 12:10:45 -04:00
daniel31x13
e071b9eb07 minor fix 2024-09-18 11:39:31 -04:00
daniel31x13
eb00d151b7 added locale to the config file 2024-09-18 11:06:31 -04:00
Daniel
22aaa52b3e Merge pull request #770 from bennyz327/dev
feat(lang): add traditional chinese translate
2024-09-18 11:04:03 -04:00
Benny Chou
4541277b28 feat(lang): add traditional chinese translate 2024-09-18 15:21:19 +08:00
Daniel
39faece9d7 Merge pull request #769 from linkwarden/main
Merge pull request #766 from linkwarden/daniel31x13-patch-1
2024-09-17 14:08:44 -04:00
daniel31x13
a21b0760de remove unused type 2024-09-17 14:06:03 -04:00
daniel31x13
04149fe86b Merge branch 'dev' of https://github.com/linkwarden/linkwarden into dev 2024-09-17 14:03:07 -04:00
daniel31x13
ff6e71d494 add schema validation for PUT requests 2024-09-17 14:03:05 -04:00
Daniel
5b02c1cfc9 Merge pull request #765 from ochtum/dev
Translated the added IDs.
2024-09-15 13:53:00 -04:00
Daniel
1ff13e8aa0 Merge pull request #767 from linkwarden/daniel31x13-patch-1
Update .env.sample
2024-09-15 13:52:47 -04:00
Daniel
eaf4524598 Merge pull request #766 from linkwarden/daniel31x13-patch-1
Update .env.sample
2024-09-15 13:52:17 -04:00
Daniel
a276065288 Update .env.sample 2024-09-15 13:51:09 -04:00
daniel31x13
1cf7421b76 added zod for post requests 2024-09-14 16:00:19 -04:00
武田 淳一
ed4a334024 Translated the added IDs. 2024-09-14 13:24:29 +09:00
Daniel
a5b1952e0d Merge pull request #710 from arran4/patch-1
Please 'EXPOSE' port 3000
2024-09-13 01:57:56 -04:00
Daniel
01826b1634 Merge branch 'dev' into patch-1 2024-09-13 01:57:39 -04:00
daniel31x13
3b17d4ddfe bug fixed 2024-09-13 00:37:58 -04:00
Daniel
f104fa095f Merge pull request #672 from jlssmt/logging
disabled query logging as default
2024-09-12 23:36:01 -04:00
Daniel
b08e6690f3 Merge pull request #689 from stumpylog/chore/update-actions
Chore: Updates actions to their latest versions
2024-09-12 23:16:27 -04:00
Daniel
33a654d21a Merge pull request #688 from stumpylog/feature/docker-file-reduce
fix: reduce Docker image size
2024-09-12 23:06:08 -04:00
Daniel
e1262142f8 Merge pull request #764 from click0/main
Corrected Ukrainian translation for September 12, 2024.
2024-09-12 19:04:50 -04:00
vlad11
0a43279665 Corrected Ukrainian translation for September 12, 2024.
Signed-off-by: vlad11 <admin@support.od.ua>
2024-09-13 01:29:34 +03:00
daniel31x13
5491ac74a5 add nl and tr translations 2024-09-12 17:06:36 -04:00
Daniel
bbcfca4cde Merge pull request #716 from kgnfth/main
feat(translations): Add Dutch and Turkish translations
2024-09-12 17:05:19 -04:00
daniel31x13
bf9a7d4fa0 add german translation 2024-09-12 17:01:38 -04:00
Daniel
edf4e489ec Merge pull request #711 from Green-Kite/main
Add German Translation
2024-09-12 17:00:49 -04:00
daniel31x13
20c5a20851 add spanish translation 2024-09-12 17:00:26 -04:00
Daniel
6f47a20e87 Merge pull request #717 from joser93/es-patch-1
Patch for Spanish translation.
2024-09-12 16:56:31 -04:00
Daniel
384937e210 Merge pull request #714 from phampyk/main
Added Spanish translation
2024-09-12 16:54:58 -04:00
Daniel
d22d989c91 Merge pull request #724 from ochtum/main
Created Japanese Translate
2024-09-12 16:53:24 -04:00
Daniel
4e0294322f Merge branch 'dev' into main 2024-09-12 16:53:12 -04:00
daniel31x13
75d5061bdf minor fix 2024-09-12 16:52:26 -04:00
Daniel
0150a9a6e3 Merge pull request #762 from rdeavila/dev
Update pt-BR translation
2024-09-12 16:49:38 -04:00
Daniel
87b79ffbac Merge pull request #726 from CoffeeAnon/feat/set-max-workers
Add Configurable Playwright Concurrency via Environment Variable
2024-09-12 16:43:32 -04:00
Rodrigo de Avila
5a40677191 Update pt-BR translation 2024-09-12 17:42:17 -03:00
Daniel
95ce2f30a8 Merge pull request #734 from click0/main
Added Ukranian translation
2024-09-12 16:33:28 -04:00
Daniel
e6a0ecbab5 Merge branch 'dev' into main 2024-09-12 16:32:39 -04:00
daniel31x13
e4c9cf8a38 add locale to config 2024-09-12 16:30:20 -04:00
Daniel
eaca3d7453 Merge pull request #746 from rdeavila/main
Add Brazilian Portuguese (pt-BR) support
2024-09-12 16:18:06 -04:00
Rodrigo de Avila
fbe3642be4 Merge branch 'linkwarden:main' into main 2024-09-12 17:16:22 -03:00
daniel31x13
bc32abbb92 Merge branch 'main' into dev
merge main to dev
2024-09-12 16:10:35 -04:00
daniel31x13
38f731f313 minor change 2024-09-12 15:46:16 -04:00
daniel31x13
aaf3590542 members with edit permission can now refresh preservation as well + bug fix 2024-09-12 15:30:15 -04:00
daniel31x13
8bb6e32bfa urls are now editable 2024-09-12 15:03:14 -04:00
daniel31x13
7bd3872195 bug fixed + optimizations 2024-09-12 13:47:18 -04:00
daniel31x13
906779010e collection closing bug fixed 2024-09-12 12:46:38 -04:00
daniel31x13
b0f87e8659 bug fixed 2024-09-12 11:59:20 -04:00
daniel31x13
653b1bc396 bug fix 2024-09-11 02:29:50 -04:00
daniel31x13
9b1506a64e add pin to hover view + add number of pins to dashboard + bug fixes 2024-09-11 01:38:38 -04:00
daniel31x13
fb1869ca7a fix dashboard bug 2024-09-10 00:09:33 -04:00
daniel31x13
5e7835b4d5 minor improvement 2024-09-09 23:27:55 -04:00
daniel31x13
0a91c47f83 minor change 2024-09-09 23:07:22 -04:00
daniel31x13
dc9db05e75 fully implemented the custom slider for the number of columns to show 2024-09-09 23:05:57 -04:00
daniel31x13
e1149c2733 minor fix 2024-09-09 19:16:28 -04:00
daniel31x13
0591d7c134 remove unused import 2024-09-09 19:09:09 -04:00
daniel31x13
4602269dd8 add number of columns slider 2024-09-09 19:05:30 -04:00
daniel31x13
9ae6a22236 minor improvement 2024-09-09 12:18:45 -04:00
daniel31x13
442da02956 minor fix 2024-09-04 23:17:58 -04:00
daniel31x13
dfcc271343 bug fix 2024-09-04 23:02:19 -04:00
daniel31x13
43d50dfd1b minor change 2024-09-04 22:39:10 -04:00
daniel31x13
40bb3e6fae fix build error 2024-09-04 22:29:54 -04:00
Daniel
3e077fa247 Merge pull request #754 from linkwarden/feat/customizable-links
Feat/customizable links
2024-09-04 22:20:16 -04:00
daniel31x13
3de8872f26 upload preview functionality 2024-09-04 22:19:40 -04:00
daniel31x13
e9072bba51 minor improvement 2024-08-30 18:10:50 -04:00
daniel31x13
d20c915970 improved edit view 2024-08-30 17:29:15 -04:00
daniel31x13
1a378de267 minor improvement 2024-08-30 10:54:27 -04:00
daniel31x13
d594159c15 minor improvement 2024-08-30 10:47:29 -04:00
daniel31x13
aee10fa406 better edit view 2024-08-30 02:38:58 -04:00
daniel31x13
820d686c37 minor improvement 2024-08-29 18:26:15 -04:00
daniel31x13
4189062c4c bug fixed 2024-08-29 12:53:37 -04:00
Daniel
1461caf68a Merge pull request #748 from linkwarden/hotfix
bug fix
2024-08-29 12:49:48 -04:00
daniel31x13
e7c7fedf8b bug fix 2024-08-29 12:47:23 -04:00
daniel31x13
b7adbbc86f improvements 2024-08-28 20:48:35 -04:00
daniel31x13
975716937f minor improvement 2024-08-28 20:30:57 -04:00
daniel31x13
2d0e52f65b better looking detail modal 2024-08-28 20:22:11 -04:00
Rodrigo de Avila
e9afe0ef25 Add Brazilian Portuguese (pt-BR) support 2024-08-28 10:26:27 -03:00
José Roberto Sánchez
a38133d618 Improved translations based on comments from @jmiguelr 2024-08-27 11:32:15 -06:00
daniel31x13
6498ae794b custom preview initial commit 2024-08-26 21:04:52 -04:00
daniel31x13
0371695eb3 choose to show which detail in each views 2024-08-26 19:56:04 -04:00
daniel31x13
9ae9c7c81a refactored view dropdown 2024-08-26 18:47:10 -04:00
daniel31x13
642374c2e5 remove commented code 2024-08-26 16:22:59 -04:00
daniel31x13
f368c2aa81 less padding for list view 2024-08-26 16:11:02 -04:00
daniel31x13
fae9e95fa9 added custom icons for links 2024-08-24 15:50:29 -04:00
Daniel
03639adc22 Merge pull request #735 from IsaacWise06/issue-691
Add new collection drop down
2024-08-22 22:48:56 -04:00
Isaac Wise
9fe829771d Add new collection drop down 2024-08-22 17:09:14 -05:00
vlad11
ed7b268c2b Created Ukranian Translate.
Signed-off-by: vlad11 <admin@support.od.ua>
2024-08-22 03:07:25 +03:00
daniel31x13
bf1a6efd2e custom icons fully implemented for collections 2024-08-20 19:25:35 -04:00
daniel31x13
6df2e44213 added translation to icon picker component + other fixes and improvements 2024-08-20 18:11:20 -04:00
daniel31x13
ae2324ecd3 progressed icon picker component 2024-08-20 16:59:01 -04:00
daniel31x13
accbd4cbfa bug fixes 2024-08-19 23:53:43 -04:00
Daniel
5f4e0d4262 Merge pull request #731 from linkwarden/hotfix
bugs fixed
2024-08-19 23:37:30 -04:00
daniel31x13
c072fed99f bugs fixed 2024-08-19 23:36:28 -04:00
Daniel
b4a9f917b5 Merge pull request #728 from linkwarden/hotfix
hotfix
2024-08-19 19:30:26 -04:00
daniel31x13
078e5ba95f minor change 2024-08-19 19:30:01 -04:00
daniel31x13
495509c888 bug fix 2024-08-19 19:25:13 -04:00
daniel31x13
dc388ebba5 improved iconPicker component + other improvements 2024-08-19 18:14:09 -04:00
Dan Jacobsen
21578bac8d feat: add configurable max workers 2024-08-19 12:44:59 -07:00
武田 淳一
1062e07065 Created Japanese Translate 2024-08-20 00:50:07 +09:00
daniel31x13
2893d3caf2 minor improvement 2024-08-18 16:52:08 -04:00
Daniel
9f74f62330 Merge pull request #722 from linkwarden/dev
Dev
2024-08-18 16:41:48 -04:00
Daniel
c6e3147bb6 Merge pull request #678 from IsaacWise06/fixes
General Fixes
2024-08-18 16:40:48 -04:00
daniel31x13
1260e8c093 fixes 2024-08-18 16:39:43 -04:00
Daniel
5cb4bdced3 Merge pull request #721 from linkwarden/feat/customizable-links
small improvements
2024-08-18 14:47:55 -04:00
Daniel
03b4240b8b Merge pull request #720 from linkwarden/revert-719-feat/customizable-links
Revert "Feat/customizable links"
2024-08-18 14:47:29 -04:00
Daniel
9a3e82470a Revert "Feat/customizable links" 2024-08-18 14:46:52 -04:00
Daniel
ee2319996b Merge pull request #719 from linkwarden/feat/customizable-links
Feat/customizable links
2024-08-18 14:46:21 -04:00
daniel31x13
c979adfe69 small improvements 2024-08-18 14:45:40 -04:00
Isaac Wise
2b83522eaa Merge branch 'dev' into fixes 2024-08-18 13:21:02 -05:00
Daniel
8c738d4a99 Merge pull request #718 from linkwarden/feat/customizable-links
Feat/customizable links
2024-08-18 14:11:47 -04:00
Isaac Wise
63678b7f1e format 2024-08-18 13:06:36 -05:00
Isaac Wise
b73e845299 Fix building 2024-08-18 13:06:19 -05:00
Isaac Wise
898b126231 Fix merge conflicts 2024-08-18 13:03:09 -05:00
daniel31x13
17d1cb45e3 minor improvement 2024-08-18 13:49:33 -04:00
José Roberto Sánchez
0aad2d9e4b Change email and fixed some typos. Overall is a good translation and I hope is merged soon. :D 2024-08-18 11:07:13 -06:00
daniel31x13
c18a5f4162 added details drawer 2024-08-18 02:55:59 -04:00
Teal'c
df7814385a feat(translations): Add Dutch and Turkish translations
- Added Dutch (nl) translations.
- Added Turkish (tr) translations.

#216
2024-08-18 04:39:35 +03:00
Ana
d568f22e00 Upload Spanish translation 2024-08-18 00:42:33 +01:00
Green-Kite
6bd1c90417 Create German Translation
updated German translation
2024-08-17 09:57:57 +02:00
daniel31x13
a40026040c icon picker component 2024-08-16 23:00:37 -04:00
Arran Ubels
334ad9f3dc Please 'EXPOSE' port 3000
This is so I can setup up Synology correctly
2024-08-17 11:56:00 +10:00
Daniel
f944345745 Merge pull request #708 from linkwarden/dev
bump version
2024-08-16 13:45:28 -04:00
daniel31x13
6b647573f0 bump version 2024-08-16 13:44:53 -04:00
Daniel
d81493e021 Merge pull request #707 from linkwarden/dev
bug fix
2024-08-16 13:43:57 -04:00
daniel31x13
03f4523d57 bug fix 2024-08-16 13:42:55 -04:00
Daniel
c24e76adac Merge pull request #706 from linkwarden/dev
v2.7.0
2024-08-16 12:36:43 -04:00
daniel31x13
5d26617251 bug fixed 2024-08-16 12:35:04 -04:00
daniel31x13
0e47ad9920 bump version 2024-08-15 16:42:36 -04:00
daniel31x13
ca45076b6c minor fix 2024-08-15 15:37:47 -04:00
Daniel
3bf6dcad2f Merge pull request #692 from phillibl/main
Update [...nextauth].ts to allow existing SSO user sign
2024-08-15 13:45:07 -04:00
daniel31x13
23860b8511 minor fix 2024-08-15 11:00:29 -04:00
daniel31x13
8758976f8d minor fix 2024-08-15 10:30:44 -04:00
Daniel
550dbd2bf0 Merge pull request #704 from shichen437/dev
feat(lang): add chinese translate
2024-08-15 08:49:26 -04:00
shichen437
04d2b3c6b2 feat(lang): add chinese translate 2024-08-15 17:20:46 +08:00
Trenton Holmes
cc1c17363b Also install a single browser (Chromium) through Playwright 2024-08-14 19:48:16 -07:00
daniel31x13
7bd0e29538 small improvement 2024-08-14 20:07:06 -04:00
daniel31x13
5baf55694c minor improvement 2024-08-14 19:23:51 -04:00
daniel31x13
193a70c6e8 fix dropdown text wrapping in other languages 2024-08-14 19:13:19 -04:00
daniel31x13
5b430cf31e add french translation 2024-08-14 17:49:53 -04:00
Daniel
684609a1dd Merge pull request #654 from zarevskaya/patch-1
Add french translation
2024-08-14 17:43:15 -04:00
Daniel
ebb2016915 Merge pull request #671 from jlssmt/main
handle undefined
2024-08-14 17:40:28 -04:00
daniel31x13
c103b66694 Merge branch 'dev' of https://github.com/linkwarden/linkwarden into dev 2024-08-14 17:26:40 -04:00
daniel31x13
863bcc3838 bug fixed 2024-08-14 17:26:38 -04:00
Daniel
66b0aacc3f Merge pull request #660 from IsaacWise06/issue-646
fix(collections): Redirect to dashboard or login for non-public collections
2024-08-14 17:04:25 -04:00
Daniel
299498ffa6 Merge pull request #703 from linkwarden/chore/react-query-implementation
Chore/react query implementation
2024-08-14 16:45:40 -04:00
phillibl
c5602dc79f Merge pull request #1 from phillibl/phillibl-SSO-user-signin
Update [...nextauth].ts to allow existing SSO user sign
2024-08-07 05:44:19 -04:00
phillibl
0158e58d90 Update [...nextauth].ts
Fixed issue where sign in would fail for existing user if DISABLE_NEW_SSO_USERS  = true
2024-08-07 05:29:10 -04:00
Trenton Holmes
602f399119 Updates actions to their latest versions 2024-08-04 15:15:33 -07:00
Trenton Holmes
012caab606 Use multi-stage building for the monolith binary 2024-08-04 15:01:54 -07:00
jlssmt
102690fc10 handle undefined 2024-08-02 09:07:13 +02:00
Daniel
237499fd03 Merge pull request #684 from linkwarden/daniel31x13-patch-1
Update README
2024-08-01 15:55:20 -04:00
Daniel
9a287d1aef Merge pull request #683 from linkwarden/daniel31x13-patch-1
Update README.md
2024-08-01 15:54:29 -04:00
Daniel
299a2331ff Update README.md 2024-08-01 15:54:00 -04:00
Isaac Wise
a1248fe62f Fix issue with link action dropdown 2024-07-27 20:19:24 -05:00
Isaac Wise
8f7e0b8d09 Remove type assertions 2024-07-27 20:01:51 -05:00
Isaac Wise
9d91d2064b Merge branch 'linkwarden:main' into issue-646 2024-07-27 17:57:25 -05:00
Isaac Wise
d631754b50 Keep any types for selects 2024-07-27 17:45:54 -05:00
Isaac Wise
94be3a7448 format 2024-07-27 17:41:13 -05:00
Isaac Wise
4faf389a2b Fix more types and use logical ANDs 2024-07-27 17:40:07 -05:00
Isaac Wise
ff31732ba3 remove more ternaries 2024-07-27 16:17:38 -05:00
Isaac Wise
fa051c0d4d Merge branch 'linkwarden:main' into fixes 2024-07-26 16:42:51 -05:00
Isaac Wise
02cb93065f Redact all ids when exporting data 2024-07-26 16:41:19 -05:00
Daniel
15a0084fb7 Merge pull request #677 from linkwarden/dev
bump version
2024-07-26 12:01:38 -04:00
Daniel
c0abf2f411 Merge pull request #676 from linkwarden/dev
bug fixed
2024-07-26 11:55:07 -04:00
Daniel
a886437589 Merge pull request #674 from linkwarden/dev
merged the two migration scripts for v2.6.1
2024-07-25 23:44:46 -04:00
Isaac Wise
0b8a9b4310 Fix some any types 2024-07-25 18:58:52 -05:00
jlssmt
ce1aa5a0ec disabled query logging as default 2024-07-25 23:19:33 +02:00
Daniel
a82c4ef85f Merge pull request #670 from linkwarden/dev
Dev
2024-07-25 14:24:24 -04:00
Daniel
7036b46084 Merge pull request #668 from linkwarden/dev
made script more efficient
2024-07-25 14:16:16 -04:00
Daniel
2bba8198b8 Merge pull request #667 from linkwarden/dev
minor fix
2024-07-25 13:58:13 -04:00
Daniel
96a70a9689 Merge pull request #666 from linkwarden/dev
update version number
2024-07-25 13:46:59 -04:00
Daniel
288fd9df87 Merge pull request #665 from linkwarden/dev
bug fixed
2024-07-25 13:45:03 -04:00
Isaac Wise
e79b98d3b0 Replace useless ternarys with logical ANDs 2024-07-22 22:34:36 -05:00
Isaac Wise
7d43ed52a4 format 2024-07-22 17:50:24 -05:00
Isaac Wise
614653bf29 Merge branch 'linkwarden:main' into issue-646 2024-07-22 17:41:34 -05:00
Isaac Wise
1b9dafbe47 Handle 400 error code when accesing a non public collection 2024-07-22 17:39:38 -05:00
zarev
abc93f1bf9 Update common.json
Correction
2024-07-20 09:55:51 +02:00
zarev
c23964a46d Create common.json
In french, if you want it ;)
2024-07-19 22:37:46 +02:00
Daniel
a76e996fc1 Merge pull request #653 from linkwarden/dev
v2.6.0
2024-07-19 08:59:54 -04:00
Oliver Schwamb
abb73f80bd Reworked access to tags as public viewer 2024-07-10 10:22:58 +02:00
Oliver Schwamb
e8d0cce58a Added allLinksOfCollection to linksStore
Removed duplicated tags
Fixed overflow for line
added disclosure for tags in public collection
2024-07-09 13:50:08 +02:00
Oliver Schwamb
e045c18b7d Only show tags within collection 2024-07-05 10:50:32 +02:00
Oliver Schwamb
a1f48bbd79 Tags in public collection 2024-07-05 10:40:40 +02:00
306 changed files with 19007 additions and 5153 deletions

View File

@@ -1,11 +1,12 @@
NEXTAUTH_SECRET=very_sensitive_secret
NEXTAUTH_URL=http://localhost:3000/api/v1/auth
NEXTAUTH_SECRET=
# Manual installation database settings
DATABASE_URL=postgresql://user:password@localhost:5432/linkwarden
# Example: DATABASE_URL=postgresql://user:password@localhost:5432/linkwarden
DATABASE_URL=
# Docker installation database settings
POSTGRES_PASSWORD=super_secret_password
POSTGRES_PASSWORD=
# Additional Optional Settings
PAGINATION_TAKE_COUNT=
@@ -14,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=
@@ -26,13 +26,47 @@ 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=
# AI Settings
NEXT_PUBLIC_OLLAMA_ENDPOINT_URL=
OLLAMA_MODEL=
# https://sdk.vercel.ai/providers/ai-sdk-providers/openai#model-capabilities
OPENAI_API_KEY=
OPENAI_MODEL=
# 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#model-capabilities
ANTHROPIC_API_KEY=
ANTHROPIC_MODEL=
# https://github.com/OpenRouterTeam/ai-sdk-provider
OPENROUTER_API_KEY=
OPENROUTER_MODEL=
# MeiliSearch Settings
MEILI_HOST=
MEILI_MASTER_KEY=
# AWS S3 Settings
SPACES_KEY=
@@ -216,6 +250,7 @@ NEXT_PUBLIC_GITLAB_ENABLED=
GITLAB_CUSTOM_NAME=
GITLAB_CLIENT_ID=
GITLAB_CLIENT_SECRET=
GITLAB_AUTH_URL=
# Google
NEXT_PUBLIC_GOOGLE_ENABLED=

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.

18
.github/workflows/check-branch.yml vendored Normal file
View File

@@ -0,0 +1,18 @@
name: Check pull request source branch
on:
pull_request_target:
types:
- opened
- reopened
- synchronize
- edited
jobs:
check-branches:
runs-on: ubuntu-latest
steps:
- name: Check branches
run: |
if [ ${{ github.head_ref }} != "dev" ] && [ ${{ github.base_ref }} == "main" ]; then
echo "Merge requests to main branch are only allowed from dev branch. Please rebase your changes to dev branch."
exit 1
fi

View File

@@ -59,10 +59,10 @@ jobs:
--health-retries 5
steps:
- uses: actions/checkout@v2
- uses: actions/checkout@v4
- name: Use Node.js
uses: actions/setup-node@v3
uses: actions/setup-node@v4
with:
node-version: "18"
cache: 'yarn'
@@ -135,7 +135,7 @@ jobs:
- name: Run Tests
run: npx playwright test --grep ${{ matrix.test_case }}
- uses: actions/upload-artifact@v3
- uses: actions/upload-artifact@v4
if: always()
with:
name: playwright-report

View File

@@ -1,6 +1,7 @@
name: Create and publish a container image on release
on:
workflow_dispatch:
push:
tags:
- "*"
@@ -27,7 +28,7 @@ jobs:
uses: docker/setup-buildx-action@v3
- name: Log in to the Container registry
uses: docker/login-action@v2
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
@@ -40,7 +41,7 @@ jobs:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
- name: Build and push Docker image
uses: docker/build-push-action@v3
uses: docker/build-push-action@v6
with:
context: .
push: true

3
.gitignore vendored
View File

@@ -54,3 +54,6 @@ certificates
/playwright-report/
/blob-report/
/playwright/.cache/
# meilisearch
data.ms

View File

@@ -1,4 +1,16 @@
FROM node:18.18-bullseye-slim
# Stage: monolith-builder
# 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.85-bullseye AS monolith-builder
RUN set -eux && cargo install --locked monolith
# Stage: main-app
# 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
ARG DEBIAN_FRONTEND=noninteractive
@@ -8,33 +20,35 @@ WORKDIR /data
COPY ./package.json ./yarn.lock ./playwright.config.ts ./
RUN --mount=type=cache,sharing=locked,target=/usr/local/share/.cache/yarn yarn install --network-timeout 10000000
RUN --mount=type=cache,sharing=locked,target=/usr/local/share/.cache/yarn \
set -eux && \
yarn install --network-timeout 10000000 && \
# 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 && \
apt-get autoremove && \
apt-get clean && \
rm -rf /var/lib/apt/lists/*
RUN apt-get update
# Copy the compiled monolith binary from the builder stage
COPY --from=monolith-builder /usr/local/cargo/bin/monolith /usr/local/bin/monolith
RUN apt-get install -y \
build-essential \
curl \
libssl-dev \
pkg-config
RUN apt-get update
RUN curl https://sh.rustup.rs -sSf | bash -s -- -y
ENV PATH="/root/.cargo/bin:${PATH}"
RUN cargo install monolith
RUN npx playwright install-deps && \
RUN set -eux && \
npx playwright install --with-deps chromium && \
apt-get clean && \
yarn cache clean
RUN yarn playwright install
COPY . .
RUN yarn prisma generate && \
yarn build
CMD yarn prisma migrate deploy && yarn start
HEALTHCHECK --interval=30s \
--timeout=5s \
--start-period=10s \
--retries=3 \
CMD [ "/usr/bin/curl", "--silent", "--fail", "http://127.0.0.1:3000/" ]
EXPOSE 3000
CMD yarn prisma migrate deploy && yarn start

View File

@@ -1,17 +1,21 @@
<div align="center">
<img src="./assets/logo.png" width="100px" />
<h1>Linkwarden</h1>
<h3>Bookmark Preservation for Individuals and Teams</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://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>
<img alt="GitHub commits since latest release" src="https://img.shields.io/github/commits-since/linkwarden/linkwarden/latest/dev?style=for-the-badge&label=COMMITS%20SINCE%20LATEST%20RELEASE">
<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://opencollective.com/linkwarden"><img src="https://img.shields.io/opencollective/all/linkwarden" alt="Open Collective"></a>
</div>
<div align='center'>
[Website](https://linkwarden.app) | [Getting Started](https://docs.linkwarden.app) | [Features](https://github.com/linkwarden/linkwarden#features) | [Roadmap](https://github.com/orgs/linkwarden/projects/1) | [Support ❤](https://github.com/linkwarden/linkwarden#support-)
[« LAUNCH DEMO »](https://demo.linkwarden.app)
[Cloud](https://cloud.linkwarden.app) · [Website](https://linkwarden.app) · [Features](https://github.com/linkwarden/linkwarden#features) · [Docs](https://docs.linkwarden.app)
</div>
@@ -24,7 +28,7 @@ The objective is to organize useful webpages and articles you find across the we
Additionally, Linkwarden is designed with collaboration in mind, sharing links with the public and/or allowing multiple users to work together seamlessly.
> [!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](https://docs.linkwarden.app/self-hosting/installation) Linkwarden, no problem! You'll still have access to all the premium features.
> 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" />
@@ -46,19 +50,11 @@ Additionally, Linkwarden is designed with collaboration in mind, sharing links w
<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.
@@ -67,14 +63,20 @@ We've forked the old version from the current repository into [this repo](https:
- 🔍 Full text search, filter and sort for easy retrieval.
- 📱 Responsive design and supports most modern browsers.
- 🌓 Dark/Light mode support.
- 🧩 Browser extension, managed by the community. [Star it here!](https://github.com/linkwarden/browser-extension)
- 🧩 Browser extension. [Star it here!](https://github.com/linkwarden/browser-extension)
- 🔄 Browser Synchronization (using [Floccus](https://floccus.org)!)
- ⬇️ Import and export your bookmarks.
- 🔐 SSO integration. (Enterprise and Self-hosted users only)
- 📦 Installable Progressive Web App (PWA).
- 🍎 iOS Shortcut to save links to Linkwarden.
- 🍎 iOS Shortcut to save Links to Linkwarden.
- 🔑 API keys.
- ✅ Bulk actions.
- ✨ And so many more features!
- 👥 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!)
## Like what we're doing? Give us a Star ⭐
@@ -98,9 +100,14 @@ We _usually_ go after the [popular suggestions](https://github.com/linkwarden/li
Make sure to check out our [public roadmap](https://github.com/orgs/linkwarden/projects/1).
## Docs
## 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
@@ -110,7 +117,7 @@ If you want to contribute, Thanks! Start by checking our [public roadmap](https:
If you found a security vulnerability, please do **not** create a public issue, instead send an email to [security@linkwarden.app](mailto:security@linkwarden.app) stating the vulnerability. Thanks!
## Support
## Support <3
Other than using our official [Cloud](https://linkwarden.app/#pricing) offering, any [donations](https://opencollective.com/linkwarden) are highly appreciated as well!

View File

@@ -21,7 +21,7 @@ export default function Announcement({ toggleAnnouncementBar }: Props) {
<Link
href={`https://blog.linkwarden.app/releases/${announcementId}`}
target="_blank"
className="underline"
className="underline decoration-dotted underline-offset-4 hover:text-primary duration-100"
key={0}
/>,
]}

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

@@ -1,5 +1,8 @@
import Link from "next/link";
import { CollectionIncludingMembersAndLinkCount } from "@/types/global";
import {
AccountSettings,
CollectionIncludingMembersAndLinkCount,
} from "@/types/global";
import React, { useEffect, useState } from "react";
import ProfilePhoto from "./ProfilePhoto";
import usePermissions from "@/hooks/usePermissions";
@@ -12,18 +15,17 @@ import { dropdownTriggerer } from "@/lib/client/utils";
import { useTranslation } from "next-i18next";
import { useUser } from "@/hooks/store/user";
type Props = {
export default function CollectionCard({
collection,
}: {
collection: CollectionIncludingMembersAndLinkCount;
className?: string;
};
export default function CollectionCard({ collection, className }: Props) {
}) {
const { t } = useTranslation();
const { settings } = useLocalSettingsStore();
const { data: user = {} } = useUser();
const formattedDate = new Date(collection.createdAt as string).toLocaleString(
"en-US",
t("locale"),
{
year: "numeric",
month: "short",
@@ -33,15 +35,9 @@ export default function CollectionCard({ collection, className }: Props) {
const permissions = usePermissions(collection.id as number);
const [collectionOwner, setCollectionOwner] = useState({
id: null as unknown as number,
name: "",
username: "",
image: "",
archiveAsScreenshot: undefined as unknown as boolean,
archiveAsMonolith: undefined as unknown as boolean,
archiveAsPDF: undefined as unknown as boolean,
});
const [collectionOwner, setCollectionOwner] = useState<
Partial<AccountSettings>
>({});
useEffect(() => {
const fetchOwner = async () => {
@@ -78,9 +74,9 @@ export default function CollectionCard({ collection, className }: Props) {
onMouseDown={dropdownTriggerer}
className="btn btn-ghost btn-sm btn-square text-neutral"
>
<i className="bi-three-dots text-xl" title="More"></i>
<i className="bi-three-dots text-xl" title={t("more")}></i>
</div>
<ul className="dropdown-content z-[30] menu shadow bg-base-200 border border-neutral-content rounded-box w-52 mt-1">
<ul className="dropdown-content z-[30] menu shadow bg-base-200 border border-neutral-content rounded-box mt-1">
{permissions === true && (
<li>
<div
@@ -90,6 +86,7 @@ export default function CollectionCard({ collection, className }: Props) {
(document?.activeElement as HTMLElement)?.blur();
setEditCollectionModal(true);
}}
className="whitespace-nowrap"
>
{t("edit_collection_info")}
</div>
@@ -103,6 +100,7 @@ export default function CollectionCard({ collection, className }: Props) {
(document?.activeElement as HTMLElement)?.blur();
setEditCollectionSharingModal(true);
}}
className="whitespace-nowrap"
>
{permissions === true
? t("share_and_collaborate")
@@ -117,6 +115,7 @@ export default function CollectionCard({ collection, className }: Props) {
(document?.activeElement as HTMLElement)?.blur();
setDeleteCollectionModal(true);
}}
className="whitespace-nowrap"
>
{permissions === true
? t("delete_collection")
@@ -129,12 +128,12 @@ export default function CollectionCard({ collection, className }: Props) {
className="flex items-center absolute bottom-3 left-3 z-10 btn px-2 btn-ghost rounded-full"
onClick={() => setEditCollectionSharingModal(true)}
>
{collectionOwner.id ? (
{collectionOwner.id && (
<ProfilePhoto
src={collectionOwner.image || undefined}
name={collectionOwner.name}
/>
) : undefined}
)}
{collection.members
.sort((a, b) => (a.userId as number) - (b.userId as number))
.map((e, i) => {
@@ -148,13 +147,13 @@ export default function CollectionCard({ collection, className }: Props) {
);
})
.slice(0, 3)}
{collection.members.length - 3 > 0 ? (
{collection.members.length - 3 > 0 && (
<div className={`avatar drop-shadow-md placeholder -ml-3`}>
<div className="bg-base-100 text-neutral rounded-full w-8 h-8 ring-2 ring-neutral-content">
<span>+{collection.members.length - 3}</span>
</div>
</div>
) : null}
)}
</div>
<Link
href={`/collections/${collection.id}`}
@@ -178,15 +177,15 @@ export default function CollectionCard({ collection, className }: Props) {
<div className="flex justify-end items-center">
<div className="text-right">
<div className="font-bold text-sm flex justify-end gap-1 items-center">
{collection.isPublic ? (
{collection.isPublic && (
<i
className="bi-globe2 drop-shadow text-neutral"
title="This collection is being shared publicly."
title={t("collection_publicly_shared")}
></i>
) : undefined}
)}
<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>
@@ -194,7 +193,7 @@ export default function CollectionCard({ collection, className }: Props) {
<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>
@@ -203,24 +202,24 @@ export default function CollectionCard({ collection, className }: Props) {
</div>
</div>
</Link>
{editCollectionModal ? (
{editCollectionModal && (
<EditCollectionModal
onClose={() => setEditCollectionModal(false)}
activeCollection={collection}
/>
) : undefined}
{editCollectionSharingModal ? (
)}
{editCollectionSharingModal && (
<EditCollectionSharingModal
onClose={() => setEditCollectionSharingModal(false)}
activeCollection={collection}
/>
) : undefined}
{deleteCollectionModal ? (
)}
{deleteCollectionModal && (
<DeleteCollectionModal
onClose={() => setDeleteCollectionModal(false)}
activeCollection={collection}
/>
) : undefined}
)}
</div>
);
}

View File

@@ -17,6 +17,8 @@ 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 Icon from "./Icon";
import { IconWeight } from "@phosphor-icons/react";
interface ExtendedTreeItem extends TreeItem {
data: Collection;
@@ -27,7 +29,7 @@ const CollectionListing = () => {
const updateCollection = useUpdateCollection();
const { data: collections = [], isLoading } = useCollections();
const { data: user = {} } = useUser();
const { data: user = {}, refetch } = useUser();
const updateUser = useUpdateUser();
const router = useRouter();
@@ -36,25 +38,23 @@ const CollectionListing = () => {
const [tree, setTree] = useState<TreeData | undefined>();
const initialTree = useMemo(() => {
if (
// !tree &&
collections.length > 0
) {
if (collections.length > 0) {
return buildTreeFromCollections(
collections,
router,
tree,
user.collectionOrder
);
} else return undefined;
}, [collections, user, router]);
useEffect(() => {
// if (!tree)
setTree(initialTree);
}, [initialTree]);
useEffect(() => {
if (user.username) {
refetch();
if (
(!user.collectionOrder || user.collectionOrder.length === 0) &&
collections.length > 0
@@ -62,11 +62,7 @@ const CollectionListing = () => {
updateUser.mutate({
...user,
collectionOrder: collections
.filter(
(e) =>
e.parentId === null ||
!collections.find((i) => i.id === e.parentId)
) // Filter out collections with non-null parentId
.filter((e) => e.parentId === null)
.map((e) => e.id as number),
});
else {
@@ -100,7 +96,7 @@ const CollectionListing = () => {
}
}
}
}, [collections]);
}, [user, collections]);
const onExpand = (movedCollectionId: ItemId) => {
setTree((currentTree) =>
@@ -116,6 +112,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
@@ -152,7 +223,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(
@@ -173,42 +249,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) {
@@ -256,7 +300,7 @@ const renderItem = (
: "hover:bg-neutral/20"
} duration-100 flex gap-1 items-center pr-2 pl-1 rounded-md`}
>
{Icon(item as ExtendedTreeItem, onExpand, onCollapse)}
{Dropdown(item as ExtendedTreeItem, onExpand, onCollapse)}
<Link
href={`/collections/${collection.id}`}
@@ -264,20 +308,31 @@ const renderItem = (
{...provided.dragHandleProps}
>
<div
className={`py-1 cursor-pointer flex items-center gap-2 w-full rounded-md h-8 capitalize`}
className={`py-1 cursor-pointer flex items-center gap-2 w-full rounded-md h-8`}
>
<i
className="bi-folder-fill text-2xl drop-shadow"
style={{ color: collection.color }}
></i>
{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>
)}
<p className="truncate w-full">{collection.name}</p>
{collection.isPublic ? (
{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>
) : undefined}
)}
<div className="drop-shadow text-neutral text-xs">
{collection._count?.links}
</div>
@@ -288,7 +343,7 @@ const renderItem = (
);
};
const Icon = (
const Dropdown = (
item: ExtendedTreeItem,
onExpand: (id: ItemId) => void,
onCollapse: (id: ItemId) => void
@@ -311,6 +366,7 @@ const Icon = (
const buildTreeFromCollections = (
collections: CollectionIncludingMembersAndLinkCount[],
router: ReturnType<typeof useRouter>,
tree?: TreeData,
order?: number[]
): TreeData => {
if (order) {
@@ -319,19 +375,38 @@ const buildTreeFromCollections = (
});
}
function getTotalLinkCount(collectionId: number): number {
const collection = items[collectionId];
if (!collection) {
return 0;
}
let totalLinkCount = (collection.data as any)._count?.links || 0;
if (collection.hasChildren) {
collection.children.forEach((childId) => {
totalLinkCount += getTotalLinkCount(childId as number);
});
}
return totalLinkCount;
}
const items: { [key: string]: ExtendedTreeItem } = collections.reduce(
(acc: any, collection) => {
acc[collection.id as number] = {
id: collection.id,
children: [],
hasChildren: false,
isExpanded: false,
isExpanded: tree?.items[collection.id as number]?.isExpanded || false,
data: {
id: collection.id,
parentId: collection.parentId,
name: collection.name,
description: collection.description,
color: collection.color,
icon: collection.icon,
iconWeight: collection.iconWeight,
isPublic: collection.isPublic,
ownerId: collection.ownerId,
createdAt: collection.createdAt,
@@ -370,6 +445,14 @@ const buildTreeFromCollections = (
}
});
collections.forEach((collection) => {
const collectionId = collection.id;
if (items[collectionId as number] && collection.id) {
const linkCount = getTotalLinkCount(collectionId as number);
(items[collectionId as number].data as any)._count.links = linkCount;
}
});
const rootId = "root";
items[rootId] = {
id: rootId,

View File

@@ -0,0 +1,49 @@
import React, { ReactNode } from "react";
import Button from "./ui/Button";
import { useTranslation } from "react-i18next";
import Modal from "./Modal";
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>
<div className="divider mb-3 mt-1"></div>
{children}
<div className="w-full flex items-center justify-end gap-2 mt-3">
<Button
intent="ghost"
className="hover:bg-base-200"
onClick={() => toggleModal()}
>
{t("cancel")}
</Button>
<Button
intent="destructive"
onClick={async () => {
await onConfirmed();
toggleModal();
}}
>
{t("confirm")}
</Button>
</div>
</Modal>
);
}

63
components/CopyButton.tsx Normal file
View File

@@ -0,0 +1,63 @@
import React, { useState } from "react";
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
type="button"
className="text-xl text-neutral btn btn-sm btn-square btn-ghost"
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,13 +8,15 @@ export default function dashboardItem({
icon: string;
}) {
return (
<div className="flex items-center">
<div className="w-[4rem] aspect-square flex justify-center items-center bg-primary/20 rounded-xl select-none">
<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="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>
<div className="ml-4 flex flex-col justify-center">
<p className="text-neutral text-xs tracking-wider">{name}</p>
<p className="font-thin text-5xl text-primary mt-0.5">{value}</p>
<p className="text-neutral text-xs tracking-wider text-right">{name}</p>
<p className="font-thin text-4xl text-primary mt-0.5 text-right">
{value || 0}
</p>
</div>
</div>
);

83
components/Drawer.tsx Normal file
View File

@@ -0,0 +1,83 @@
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;
};
export default function Drawer({
toggleDrawer,
className,
children,
dismissible = true,
}: Props) {
const [drawerIsOpen, setDrawerIsOpen] = React.useState(true);
const { width } = useWindowDimensions();
useEffect(() => {
if (width >= 640) {
document.body.style.overflow = "hidden";
document.body.style.position = "relative";
return () => {
document.body.style.overflow = "auto";
document.body.style.position = "";
};
}
}, []);
if (width < 640) {
return (
<D.Root
open={drawerIsOpen}
onClose={() => dismissible && setDrawerIsOpen(false)}
onAnimationEnd={(isOpen) => !isOpen && toggleDrawer()}
dismissible={dismissible}
>
<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">
<div
className={clsx(
"p-4 bg-base-100 rounded-t-2xl flex-1 border-neutral-content border-t overflow-y-auto",
className
)}
data-testid="mobile-modal-container"
>
<div data-testid="mobile-modal-slider" />
{children}
</div>
</D.Content>
</D.Portal>
</D.Root>
);
} else {
return (
<D.Root
open={drawerIsOpen}
onClose={() => dismissible && setDrawerIsOpen(false)}
onAnimationEnd={(isOpen) => !isOpen && toggleDrawer()}
dismissible={dismissible}
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 max-w-6xl min-w-[30rem] mt-24 fixed bottom-0 right-0 z-40 !select-auto focus:outline-none">
<div
className={clsx(
"p-4 bg-base-100 flex-1 border-neutral-content border-l overflow-y-auto",
className
)}
>
{children}
</div>
</D.Content>
</D.Portal>
</D.Root>
);
}
}

View File

@@ -60,47 +60,49 @@ export default function Dropdown({
}
}, [points, dropdownHeight]);
return !points || pos ? (
<ClickAwayHandler
onMount={(e) => {
setDropdownHeight(e.height);
setDropdownWidth(e.width);
}}
style={
points
? {
position: "fixed",
top: `${pos?.y}px`,
left: `${pos?.x}px`,
}
: undefined
}
onClickOutside={onClickOutside}
className={`${
className || ""
} py-1 shadow-md border border-neutral-content bg-base-200 rounded-md flex flex-col z-20`}
>
{items.map((e, i) => {
const inner = e && (
<div className="cursor-pointer rounded-md">
<div className="flex items-center gap-2 py-1 px-2 hover:bg-base-100 duration-100">
<p className="select-none">{e.name}</p>
return (
(!points || pos) && (
<ClickAwayHandler
onMount={(e) => {
setDropdownHeight(e.height);
setDropdownWidth(e.width);
}}
style={
points
? {
position: "fixed",
top: `${pos?.y}px`,
left: `${pos?.x}px`,
}
: undefined
}
onClickOutside={onClickOutside}
className={`${
className || ""
} py-1 shadow-md border border-neutral-content bg-base-200 rounded-md flex flex-col z-20`}
>
{items.map((e, i) => {
const inner = e && (
<div className="cursor-pointer rounded-md">
<div className="flex items-center gap-2 py-1 px-2 hover:bg-base-100 duration-100">
<p className="select-none">{e.name}</p>
</div>
</div>
</div>
);
);
return e && e.href ? (
<Link key={i} href={e.href}>
{inner}
</Link>
) : (
e && (
<div key={i} onClick={e.onClick}>
return e && e.href ? (
<Link key={i} href={e.href}>
{inner}
</div>
)
);
})}
</ClickAwayHandler>
) : null;
</Link>
) : (
e && (
<div key={i} onClick={e.onClick}>
{inner}
</div>
)
);
})}
</ClickAwayHandler>
)
);
}

View File

@@ -1,135 +0,0 @@
import { dropdownTriggerer } from "@/lib/client/utils";
import React from "react";
import { useTranslation } from "next-i18next";
type Props = {
setSearchFilter: Function;
searchFilter: {
name: boolean;
url: boolean;
description: boolean;
textContent: boolean;
tags: boolean;
};
};
export default function FilterSearchDropdown({
setSearchFilter,
searchFilter,
}: Props) {
const { t } = useTranslation();
return (
<div className="dropdown dropdown-bottom dropdown-end">
<div
tabIndex={0}
role="button"
onMouseDown={dropdownTriggerer}
className="btn btn-sm btn-square btn-ghost"
>
<i className="bi-funnel text-neutral text-2xl"></i>
</div>
<ul className="dropdown-content z-[30] menu shadow bg-base-200 border border-neutral-content rounded-box w-56 mt-1">
<li>
<label
className="label cursor-pointer flex justify-start"
tabIndex={0}
role="button"
>
<input
type="checkbox"
name="search-filter-checkbox"
className="checkbox checkbox-primary"
checked={searchFilter.name}
onChange={() =>
setSearchFilter({ ...searchFilter, name: !searchFilter.name })
}
/>
<span className="label-text">{t("name")}</span>
</label>
</li>
<li>
<label
className="label cursor-pointer flex justify-start"
tabIndex={0}
role="button"
>
<input
type="checkbox"
name="search-filter-checkbox"
className="checkbox checkbox-primary"
checked={searchFilter.url}
onChange={() =>
setSearchFilter({ ...searchFilter, url: !searchFilter.url })
}
/>
<span className="label-text">{t("link")}</span>
</label>
</li>
<li>
<label
className="label cursor-pointer flex justify-start"
tabIndex={0}
role="button"
>
<input
type="checkbox"
name="search-filter-checkbox"
className="checkbox checkbox-primary"
checked={searchFilter.description}
onChange={() =>
setSearchFilter({
...searchFilter,
description: !searchFilter.description,
})
}
/>
<span className="label-text">{t("description")}</span>
</label>
</li>
<li>
<label
className="label cursor-pointer flex justify-start"
tabIndex={0}
role="button"
>
<input
type="checkbox"
name="search-filter-checkbox"
className="checkbox checkbox-primary"
checked={searchFilter.tags}
onChange={() =>
setSearchFilter({ ...searchFilter, tags: !searchFilter.tags })
}
/>
<span className="label-text">{t("tags")}</span>
</label>
</li>
<li>
<label
className="label cursor-pointer flex justify-between"
tabIndex={0}
role="button"
>
<input
type="checkbox"
name="search-filter-checkbox"
className="checkbox checkbox-primary"
checked={searchFilter.textContent}
onChange={() =>
setSearchFilter({
...searchFilter,
textContent: !searchFilter.textContent,
})
}
/>
<span className="label-text">{t("full_content")}</span>
<div className="ml-auto badge badge-sm badge-neutral">
{t("slower")}
</div>
</label>
</li>
</ul>
</div>
);
}

18
components/Icon.tsx Normal file
View File

@@ -0,0 +1,18 @@
import React, { forwardRef } from "react";
import * as Icons from "@phosphor-icons/react";
type Props = {
icon: string;
} & Icons.IconProps;
const Icon = forwardRef<SVGSVGElement, Props>(({ icon, ...rest }, ref) => {
const IconComponent: any = Icons[icon as keyof typeof Icons];
if (!IconComponent) {
return null;
} else return <IconComponent ref={ref} {...rest} />;
});
Icon.displayName = "Icon";
export default Icon;

91
components/IconGrid.tsx Normal file
View File

@@ -0,0 +1,91 @@
import { icons } from "@/lib/client/icons";
import Fuse from "fuse.js";
import { forwardRef, useMemo } from "react";
import { FixedSizeGrid as Grid } from "react-window";
const fuse = new Fuse(icons, {
keys: [{ name: "name", weight: 4 }, "tags", "categories"],
threshold: 0.2,
useExtendedSearch: true,
});
type Props = {
query: string;
color: string;
weight: "light" | "regular" | "bold" | "fill" | "duotone" | "thin";
iconName?: string;
setIconName: Function;
};
const IconGrid = ({ query, color, weight, iconName, setIconName }: Props) => {
const filteredIcons = useMemo(() => {
if (!query) {
return icons;
}
return fuse.search(query).map((result) => result.item);
}, [query]);
const columnCount = 6;
const rowCount = Math.ceil(filteredIcons.length / columnCount);
const GUTTER_SIZE = 5;
const Cell = ({ columnIndex, rowIndex, style }: any) => {
const index = rowIndex * columnCount + columnIndex;
if (index >= filteredIcons.length) return null; // Prevent overflow
const icon = filteredIcons[index];
const IconComponent = icon.Icon;
return (
<div
style={{
...style,
left: style.left + GUTTER_SIZE,
top: style.top + GUTTER_SIZE,
width: style.width - GUTTER_SIZE,
height: style.height - GUTTER_SIZE,
}}
onClick={() => setIconName(icon.pascal_name)}
className={`cursor-pointer p-[6px] rounded-lg bg-base-100 w-full ${
icon.pascal_name === iconName
? "outline outline-1 outline-primary"
: ""
}`}
>
<IconComponent size={32} weight={weight} color={color} />
</div>
);
};
const InnerElementType = forwardRef(({ style, ...rest }: any, ref) => (
<div
ref={ref}
style={{
...style,
paddingLeft: GUTTER_SIZE,
paddingTop: GUTTER_SIZE,
}}
{...rest}
/>
));
InnerElementType.displayName = "InnerElementType";
return (
<Grid
columnCount={columnCount}
rowCount={rowCount}
columnWidth={50}
rowHeight={50}
innerElementType={InnerElementType}
width={320}
height={158}
itemData={filteredIcons}
className="hide-scrollbar ml-[4px] w-fit"
>
{Cell}
</Grid>
);
};
export default IconGrid;

80
components/IconPicker.tsx Normal file
View File

@@ -0,0 +1,80 @@
import React, { useState } from "react";
import TextInput from "./TextInput";
import Popover from "./Popover";
import { HexColorPicker } from "react-colorful";
import { useTranslation } from "next-i18next";
import Icon from "./Icon";
import { IconWeight } from "@phosphor-icons/react";
import IconGrid from "./IconGrid";
import IconPopover from "./IconPopover";
import clsx from "clsx";
type Props = {
alignment?: string;
color: string;
setColor: Function;
iconName?: string;
setIconName: Function;
weight: "light" | "regular" | "bold" | "fill" | "duotone" | "thin";
setWeight: Function;
hideDefaultIcon?: boolean;
reset: Function;
className?: string;
};
const IconPicker = ({
alignment,
color,
setColor,
iconName,
setIconName,
weight,
setWeight,
hideDefaultIcon,
className,
reset,
}: Props) => {
const { t } = useTranslation();
const [iconPicker, setIconPicker] = useState(false);
return (
<div className="relative">
<div
onClick={() => setIconPicker(!iconPicker)}
className="btn btn-square w-20 h-20"
>
{iconName ? (
<Icon
icon={iconName}
size={60}
weight={(weight || "regular") as IconWeight}
color={color}
/>
) : !iconName && hideDefaultIcon ? (
<p className="p-1">{t("set_custom_icon")}</p>
) : (
<i className="bi-folder-fill text-6xl" style={{ color: color }}></i>
)}
</div>
{iconPicker && (
<IconPopover
alignment={alignment}
color={color}
setColor={setColor}
iconName={iconName}
setIconName={setIconName}
weight={weight}
setWeight={setWeight}
reset={reset}
onClose={() => setIconPicker(false)}
className={clsx(
className,
alignment || "lg:-translate-x-1/3 top-20 left-0"
)}
/>
)}
</div>
);
};
export default IconPicker;

161
components/IconPopover.tsx Normal file
View File

@@ -0,0 +1,161 @@
import React, { useState } from "react";
import ReactDOM from "react-dom";
import TextInput from "./TextInput";
import Popover from "./Popover";
import { HexColorPicker } from "react-colorful";
import { useTranslation } from "next-i18next";
import IconGrid from "./IconGrid";
import clsx from "clsx";
import useWindowDimensions from "@/hooks/useWindowDimensions";
type Props = {
alignment?: string;
color: string;
setColor: Function;
iconName?: string;
setIconName: Function;
weight: "light" | "regular" | "bold" | "fill" | "duotone" | "thin";
setWeight: Function;
reset: Function;
className?: string;
onClose: Function;
top?: number;
left?: number;
};
const IconPopover = ({
alignment,
color,
setColor,
iconName,
setIconName,
weight,
setWeight,
reset,
className,
onClose,
top,
left,
}: Props) => {
const { t } = useTranslation();
const [query, setQuery] = useState("");
const content = (
<Popover
onClose={() => onClose()}
className={clsx(
className,
"fade-in bg-base-200 border border-neutral-content p-3 w-[22.5rem] rounded-lg shadow-md pointer-events-auto",
top !== undefined && left !== undefined && `z-[1000]`
)}
style={{ top: top, left: left }}
>
<div className="flex flex-col gap-3 w-full h-full">
<TextInput
className="p-2 rounded w-full h-7 text-sm"
placeholder={t("search")}
value={query}
onChange={(e) => setQuery(e.target.value)}
/>
<div className="grid grid-cols-6 gap-1 w-full overflow-y-auto h-44 border border-neutral-content bg-base-100 rounded-md p-2">
<IconGrid
query={query}
color={color}
weight={weight}
iconName={iconName}
setIconName={setIconName}
/>
</div>
<div className="flex gap-3 color-picker w-full justify-between">
<HexColorPicker
color={color}
onChange={(e) => setColor(e)}
className="border border-neutral-content rounded-lg"
/>
<div className="grid grid-cols-2 gap-3 text-sm">
<label className="flex items-center cursor-pointer">
<input
type="radio"
className="radio radio-primary mr-2"
value="regular"
checked={weight === "regular"}
onChange={() => setWeight("regular")}
/>
{t("regular")}
</label>
<label className="flex items-center cursor-pointer">
<input
type="radio"
className="radio radio-primary mr-2"
value="thin"
checked={weight === "thin"}
onChange={() => setWeight("thin")}
/>
{t("thin")}
</label>
<label className="flex items-center cursor-pointer">
<input
type="radio"
className="radio radio-primary mr-2"
value="light"
checked={weight === "light"}
onChange={() => setWeight("light")}
/>
{t("light_icon")}
</label>
<label className="flex items-center cursor-pointer">
<input
type="radio"
className="radio radio-primary mr-2"
value="bold"
checked={weight === "bold"}
onChange={() => setWeight("bold")}
/>
{t("bold")}
</label>
<label className="flex items-center cursor-pointer">
<input
type="radio"
className="radio radio-primary mr-2"
value="fill"
checked={weight === "fill"}
onChange={() => setWeight("fill")}
/>
{t("fill")}
</label>
<label className="flex items-center cursor-pointer">
<input
type="radio"
className="radio radio-primary mr-2"
value="duotone"
checked={weight === "duotone"}
onChange={() => setWeight("duotone")}
/>
{t("duotone")}
</label>
</div>
</div>
<div className="flex flex-row gap-2 justify-between items-center mt-2">
<div
className="btn btn-ghost btn-xs w-fit"
onClick={reset as React.MouseEventHandler<HTMLDivElement>}
>
{t("reset_defaults")}
</div>
<p className="text-neutral text-xs">{t("click_out_to_apply")}</p>
</div>
</div>
</Popover>
);
if (top !== undefined && left !== undefined) {
return ReactDOM.createPortal(content, document.body);
}
return content;
};
export default IconPopover;

View File

@@ -0,0 +1,104 @@
import React from "react";
import importBookmarks from "@/lib/client/importBookmarks";
import { MigrationFormat } from "@/types/global";
import { useTranslation } from "next-i18next";
import { dropdownTriggerer } from "@/lib/client/utils";
type Props = {};
const ImportDropdown = ({}: Props) => {
const { t } = useTranslation();
return (
<div className="dropdown dropdown-bottom">
<div
tabIndex={0}
role="button"
onMouseDown={dropdownTriggerer}
className="inline-flex items-center gap-2 text-sm btn bg-neutral-content text-secondary-foreground hover:bg-neutral-content/80 border border-neutral/30 hover:border hover:border-neutral/30"
id="import-dropdown"
>
<i className="bi-cloud-upload text-xl duration-100"></i>
<p>{t("import_links")}</p>
</div>
<ul className="shadow menu dropdown-content z-[1] bg-base-200 border border-neutral-content rounded-box mt-1">
<li>
<label
tabIndex={0}
role="button"
htmlFor="import-linkwarden-file"
title={t("from_linkwarden")}
className="whitespace-nowrap"
>
{t("from_linkwarden")}
<input
type="file"
name="photo"
id="import-linkwarden-file"
accept=".json"
className="hidden"
onChange={(e) => importBookmarks(e, MigrationFormat.linkwarden)}
/>
</label>
</li>
<li>
<label
tabIndex={0}
role="button"
htmlFor="import-html-file"
title={t("from_html")}
className="whitespace-nowrap"
>
{t("from_html")}
<input
type="file"
name="photo"
id="import-html-file"
accept=".html"
className="hidden"
onChange={(e) => importBookmarks(e, MigrationFormat.htmlFile)}
/>
</label>
</li>
<li>
<label
tabIndex={0}
role="button"
htmlFor="import-wallabag-file"
title={t("from_wallabag")}
className="whitespace-nowrap"
>
{t("from_wallabag")}
<input
type="file"
name="photo"
id="import-wallabag-file"
accept=".json"
className="hidden"
onChange={(e) => importBookmarks(e, MigrationFormat.wallabag)}
/>
</label>
</li>
<li>
<label
tabIndex={0}
role="button"
htmlFor="import-omnivore-file"
title={t("from_omnivore")}
className="whitespace-nowrap"
>
{t("from_omnivore")}
<input
type="file"
name="photo"
id="import-omnivore-file"
accept=".zip"
className="hidden"
onChange={(e) => importBookmarks(e, MigrationFormat.omnivore)}
/>
</label>
</li>
</ul>
</div>
);
};
export default ImportDropdown;

View File

@@ -1,10 +1,11 @@
import { useRouter } from "next/router";
import { useEffect, useState } from "react";
import { styles } from "./styles";
import { Options } from "./types";
import { Option } from "./types";
import CreatableSelect from "react-select/creatable";
import Select from "react-select";
import { useCollections } from "@/hooks/store/collections";
import clsx from "clsx";
type Props = {
onChange: any;
@@ -16,6 +17,9 @@ type Props = {
}
| undefined;
creatable?: boolean;
autoFocus?: boolean;
onBlur?: any;
className?: string;
};
export default function CollectionSelection({
@@ -23,12 +27,15 @@ export default function CollectionSelection({
defaultValue,
showDefaultValue = true,
creatable = true,
autoFocus,
onBlur,
className,
}: Props) {
const { data: collections = [] } = useCollections();
const router = useRouter();
const [options, setOptions] = useState<Options[]>([]);
const [options, setOptions] = useState<Option[]>([]);
const collectionId = Number(router.query.id);
@@ -43,20 +50,6 @@ export default function CollectionSelection({
};
}
useEffect(() => {
const formatedCollections = collections.map((e) => {
return {
value: e.id,
label: e.name,
ownerId: e.ownerId,
count: e._count,
parentId: e.parentId,
};
});
setOptions(formatedCollections);
}, [collections]);
const getParentNames = (parentId: number): string[] => {
const parentNames = [];
const parent = collections.find((e) => e.id === parentId);
@@ -72,24 +65,39 @@ export default function CollectionSelection({
return parentNames.reverse();
};
useEffect(() => {
const formattedCollections = collections
.map((e) => {
return {
value: e.id,
label: e.name,
parentsLabel:
((e.parentId && getParentNames(e.parentId).join(" > ") + " > ") ||
"") + e.name,
ownerId: e.ownerId,
count: e._count,
parentId: e.parentId,
};
})
.sort((a, b) => {
return a.parentsLabel.localeCompare(b.parentsLabel);
});
setOptions(formattedCollections);
}, [collections]);
const customOption = ({ data, innerProps }: any) => {
return (
<div
{...innerProps}
className="px-2 py-2 last:border-0 border-b border-neutral-content hover:bg-neutral-content cursor-pointer"
className="px-2 py-2 last:border-0 border-b border-neutral-content hover:bg-neutral-content duration-100 cursor-pointer"
>
<div className="flex w-full justify-between items-center">
<span>{data.label}</span>
<span className="text-sm text-neutral">{data.count?.links}</span>
</div>
<div className="text-xs text-gray-600 dark:text-gray-300">
{getParentNames(data?.parentId).length > 0 ? (
<>
{getParentNames(data.parentId).join(" > ")} {">"} {data.label}
</>
) : (
data.label
)}
{data.parentsLabel}
</div>
</div>
);
@@ -99,11 +107,13 @@ export default function CollectionSelection({
return (
<CreatableSelect
isClearable={false}
className="react-select-container"
className={clsx("react-select-container", className)}
classNamePrefix="react-select"
onChange={onChange}
options={options}
styles={styles}
autoFocus={autoFocus}
onBlur={onBlur}
defaultValue={showDefaultValue ? defaultValue : null}
components={{
Option: customOption,
@@ -115,12 +125,14 @@ export default function CollectionSelection({
return (
<Select
isClearable={false}
className="react-select-container"
className={clsx("react-select-container", className)}
classNamePrefix="react-select"
onChange={onChange}
options={options}
styles={styles}
autoFocus={autoFocus}
defaultValue={showDefaultValue ? defaultValue : null}
onBlur={onBlur}
components={{
Option: customOption,
}}

View File

@@ -1,28 +1,41 @@
import { useEffect, useState } from "react";
import CreatableSelect from "react-select/creatable";
import { styles } from "./styles";
import { Options } from "./types";
import { ArchivalTagOption, Option } from "./types";
import { useTags } from "@/hooks/store/tags";
import { useTranslation } from "next-i18next";
type Props = {
onChange: any;
onChange: (e: any) => void;
options?: Option[] | ArchivalTagOption[];
isArchivalSelection?: boolean;
defaultValue?: {
value: number;
value?: number;
label: string;
}[];
autoFocus?: boolean;
onBlur?: any;
};
export default function TagSelection({ onChange, defaultValue }: Props) {
export default function TagSelection({
onChange,
options,
isArchivalSelection,
defaultValue,
autoFocus,
onBlur,
}: Props) {
const { data: tags = [] } = useTags();
const { t } = useTranslation();
const [options, setOptions] = useState<Options[]>([]);
const [tagOptions, setTagOptions] = useState<Option[]>([]);
useEffect(() => {
const formatedCollections = tags.map((e: any) => {
return { value: e.id, label: e.name };
});
setOptions(formatedCollections);
setTagOptions(formatedCollections);
}, [tags]);
return (
@@ -31,11 +44,14 @@ export default function TagSelection({ onChange, defaultValue }: Props) {
className="react-select-container"
classNamePrefix="react-select"
onChange={onChange}
options={options}
options={isArchivalSelection ? options : tagOptions}
styles={styles}
defaultValue={defaultValue}
// menuPosition="fixed"
value={isArchivalSelection ? [] : undefined}
defaultValue={isArchivalSelection ? undefined : defaultValue}
placeholder={t("tag_selection_placeholder")}
isMulti
autoFocus={autoFocus}
onBlur={onBlur}
/>
);
}

View File

@@ -14,7 +14,11 @@ export const styles: StylesConfig = {
? "oklch(var(--p))"
: "oklch(var(--nc))",
},
transition: "all 50ms",
transition: "all 100ms",
}),
menu: (styles) => ({
...styles,
zIndex: 10,
}),
control: (styles, state) => ({
...styles,
@@ -46,23 +50,33 @@ export const styles: StylesConfig = {
placeholder: (styles) => ({
...styles,
borderColor: "black",
color: "oklch(var(--n))",
}),
multiValue: (styles) => {
return {
...styles,
backgroundColor: "#0ea5e9",
color: "white",
backgroundColor: "oklch(var(--b2))",
color: "oklch(var(--bc))",
display: "flex",
alignItems: "center",
gap: "0.1rem",
marginRight: "0.4rem",
};
},
multiValueLabel: (styles) => ({
...styles,
color: "white",
color: "oklch(var(--bc))",
}),
multiValueRemove: (styles) => ({
...styles,
height: "1.2rem",
width: "1.2rem",
borderRadius: "100px",
transition: "all 100ms",
color: "oklch(var(--w))",
":hover": {
color: "white",
backgroundColor: "#38bdf8",
color: "red",
backgroundColor: "oklch(var(--nc))",
},
}),
menuPortal: (base) => ({ ...base, zIndex: 9999 }),

View File

@@ -1,4 +1,19 @@
export interface Options {
export interface Option {
label: string;
value?: string | number;
__isNew__?: boolean;
}
export interface ArchivalTagOption extends Option {
archiveAsScreenshot?: boolean | null;
archiveAsMonolith?: boolean | null;
archiveAsPDF?: boolean | null;
archiveAsReadable?: boolean | null;
archiveAsWaybackMachine?: boolean | null;
aiTag?: boolean | null;
}
export type ArchivalOptionKeys = keyof Omit<
ArchivalTagOption,
"label" | "value" | "__isNew__"
>;

View File

@@ -8,7 +8,7 @@ const InstallApp = (props: Props) => {
const [isOpen, setIsOpen] = useState(true);
return isOpen && !isPWA() ? (
<div className="fixed left-0 right-0 bottom-10 w-full p-5">
<div className="fixed left-0 right-0 bottom-10 w-full px-5">
<div className="mx-auto w-fit p-2 flex justify-between gap-2 items-center border border-neutral-content rounded-xl bg-base-300 backdrop-blur-md bg-opacity-80 max-w-md">
<svg
xmlns="http://www.w3.org/2000/svg"

667
components/LinkDetails.tsx Normal file
View File

@@ -0,0 +1,667 @@
import React, { useEffect, useState } from "react";
import {
LinkIncludingShortenedCollectionAndTags,
ArchivedFormat,
} from "@/types/global";
import Link from "next/link";
import {
atLeastOneFormatAvailable,
formatAvailable,
} from "@/lib/shared/formatStats";
import PreservedFormatRow from "@/components/PreserverdFormatRow";
import getPublicUserData from "@/lib/client/getPublicUserData";
import { useTranslation } from "next-i18next";
import { BeatLoader } from "react-spinners";
import { useUser } from "@/hooks/store/user";
import { useGetLink, useUpdateLink, useUpdateFile } from "@/hooks/store/links";
import LinkIcon from "./LinkViews/LinkComponents/LinkIcon";
import CopyButton from "./CopyButton";
import { useRouter } from "next/router";
import Icon from "./Icon";
import { IconWeight } from "@phosphor-icons/react";
import Image from "next/image";
import clsx from "clsx";
import toast from "react-hot-toast";
import CollectionSelection from "./InputSelect/CollectionSelection";
import TagSelection from "./InputSelect/TagSelection";
import unescapeString from "@/lib/client/unescapeString";
import IconPopover from "./IconPopover";
import TextInput from "./TextInput";
import usePermissions from "@/hooks/usePermissions";
import oklchVariableToHex from "@/lib/client/oklchVariableToHex";
type Props = {
className?: string;
activeLink: LinkIncludingShortenedCollectionAndTags;
standalone?: boolean;
mode?: "view" | "edit";
setMode?: Function;
onUpdateArchive?: Function;
};
export default function LinkDetails({
className,
activeLink,
standalone,
mode = "view",
setMode,
onUpdateArchive,
}: Props) {
const [link, setLink] =
useState<LinkIncludingShortenedCollectionAndTags>(activeLink);
useEffect(() => {
setLink(activeLink);
}, [activeLink]);
const permissions = usePermissions(link.collection.id as number);
const { t } = useTranslation();
const getLink = useGetLink();
const { data: user = {} } = useUser();
const [collectionOwner, setCollectionOwner] = useState({
id: null as unknown as number,
name: "",
username: "",
image: "",
archiveAsScreenshot: undefined as unknown as boolean,
archiveAsMonolith: undefined as unknown as boolean,
archiveAsPDF: undefined as unknown as boolean,
});
useEffect(() => {
const fetchOwner = async () => {
if (link.collection.ownerId !== user.id) {
const owner = await getPublicUserData(
link.collection.ownerId as number
);
setCollectionOwner(owner);
} else if (link.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.archiveAsScreenshot as boolean,
archiveAsPDF: user.archiveAsPDF as boolean,
});
}
};
fetchOwner();
}, [link.collection.ownerId]);
const isReady = () => {
return (
link &&
(collectionOwner.archiveAsScreenshot === true ? link.pdf : true) &&
(collectionOwner.archiveAsMonolith === true ? link.monolith : true) &&
(collectionOwner.archiveAsPDF === true ? link.pdf : true) &&
link.readable
);
};
useEffect(() => {
(async () => {
await getLink.mutateAsync({
id: link.id as number,
});
})();
let interval: any;
if (!isReady()) {
interval = setInterval(async () => {
await getLink.mutateAsync({
id: link.id as number,
});
}, 5000);
} else {
if (interval) {
clearInterval(interval);
}
}
return () => {
if (interval) {
clearInterval(interval);
}
};
}, [link.monolith]);
const router = useRouter();
const isPublicRoute = router.pathname.startsWith("/public") ? true : false;
const updateLink = useUpdateLink();
const updateFile = useUpdateFile();
const submit = async (e?: any) => {
e?.preventDefault();
const { updatedAt: b, ...oldLink } = activeLink;
const { updatedAt: a, ...newLink } = link;
if (JSON.stringify(oldLink) === JSON.stringify(newLink)) {
return;
}
const load = toast.loading(t("updating"));
await updateLink.mutateAsync(link, {
onSettled: (data, error) => {
toast.dismiss(load);
if (error) {
toast.error(error.message);
} else {
toast.success(t("updated"));
setMode && setMode("view");
setLink(data);
}
},
});
};
const setCollection = (e: any) => {
if (e?.__isNew__) e.value = null;
setLink({
...link,
collection: { id: e?.value, name: e?.label, ownerId: e?.ownerId },
});
};
const setTags = (e: any) => {
const tagNames = e.map((e: any) => ({ name: e.label }));
setLink({ ...link, tags: tagNames });
};
const [iconPopover, setIconPopover] = useState(false);
return (
<div className={clsx(className)} data-vaul-no-drag>
<div
className={clsx(
standalone && "sm:border sm:border-neutral-content sm:rounded-2xl p-5"
)}
>
<div
className={clsx(
"overflow-hidden select-none relative group h-40 opacity-80",
standalone
? "sm:max-w-xl -mx-5 -mt-5 sm:rounded-t-2xl"
: "-mx-4 -mt-4"
)}
>
{formatAvailable(link, "preview") ? (
<Image
src={`/api/v1/archives/${link.id}?format=${ArchivedFormat.jpeg}&preview=true&updatedAt=${link.updatedAt}`}
width={1280}
height={720}
alt=""
className="object-cover scale-105 object-center h-full"
style={{
filter: "blur(1px)",
}}
onError={(e) => {
const target = e.target as HTMLElement;
target.style.display = "none";
}}
/>
) : link.preview === "unavailable" ? (
<div className="bg-gray-50 duration-100 h-40"></div>
) : (
<div className="duration-100 h-40 skeleton rounded-none"></div>
)}
{!standalone &&
(permissions === true || permissions?.canUpdate) &&
!isPublicRoute && (
<div className="absolute top-0 bottom-0 left-0 right-0 opacity-0 group-hover:opacity-100 duration-100 flex justify-end items-end">
<label className="btn btn-xs mb-2 mr-3 opacity-50 hover:opacity-100">
{t("upload_banner")}
<input
type="file"
accept="image/jpg, image/jpeg, image/png"
onChange={async (e) => {
const file = e.target.files?.[0];
if (!file) return;
const load = toast.loading(t("updating"));
await updateFile.mutateAsync(
{
linkId: link.id as number,
file,
},
{
onSettled: (data, error) => {
toast.dismiss(load);
if (error) {
toast.error(error.message);
} else {
toast.success(t("updated"));
setLink({ updatedAt: data.updatedAt, ...link });
}
},
}
);
}}
className="hidden"
/>
</label>
</div>
)}
</div>
{!standalone &&
(permissions === true || permissions?.canUpdate) &&
!isPublicRoute ? (
<div className="-mt-14 ml-8 relative w-fit pb-2">
<div className="tooltip tooltip-bottom" data-tip={t("change_icon")}>
<LinkIcon
link={link}
className="hover:bg-opacity-70 duration-100 cursor-pointer"
onClick={() => setIconPopover(true)}
/>
</div>
{iconPopover && (
<IconPopover
color={link.color || oklchVariableToHex("--p")}
setColor={(color: string) => setLink({ ...link, color })}
weight={(link.iconWeight || "regular") as IconWeight}
setWeight={(iconWeight: string) =>
setLink({ ...link, iconWeight })
}
iconName={link.icon as string}
setIconName={(icon: string) => setLink({ ...link, icon })}
reset={() =>
setLink({
...link,
color: "",
icon: "",
iconWeight: "",
})
}
className="top-12"
onClose={() => {
setIconPopover(false);
submit();
}}
/>
)}
</div>
) : (
<div className="-mt-14 ml-8 relative w-fit pb-2">
<LinkIcon link={link} onClick={() => setIconPopover(true)} />
</div>
)}
<div className="sm:px-8 p-5 pb-8 pt-2">
{mode === "view" && (
<div className="text-xl mt-2 pr-7">
<p
className={clsx("relative w-fit", !link.name && "text-neutral")}
>
{unescapeString(link.name) || t("untitled")}
</p>
</div>
)}
{mode === "edit" && (
<>
<br />
<div>
<p className="text-sm mb-2 text-neutral relative w-fit flex justify-between">
{t("name")}
</p>
<TextInput
value={link.name}
onChange={(e) => setLink({ ...link, name: e.target.value })}
placeholder={t("placeholder_example_link")}
className="bg-base-200"
/>
</div>
</>
)}
{link.url && mode === "view" ? (
<>
<br />
<p className="text-sm mb-2 text-neutral">{t("link")}</p>
<div className="relative">
<div className="rounded-md p-2 bg-base-200 hide-scrollbar overflow-x-auto whitespace-nowrap flex justify-between items-center gap-2 pr-14">
<Link href={link.url} title={link.url} target="_blank">
{link.url}
</Link>
<div className="absolute right-0 px-2 bg-base-200">
<CopyButton text={link.url} />
</div>
</div>
</div>
</>
) : activeLink.url ? (
<>
<br />
<div>
<p className="text-sm mb-2 text-neutral relative w-fit flex justify-between">
{t("link")}
</p>
<TextInput
value={link.url || ""}
onChange={(e) => setLink({ ...link, url: e.target.value })}
placeholder={t("placeholder_example_link")}
className="bg-base-200"
/>
</div>
</>
) : undefined}
<br />
<div className="relative">
<p className="text-sm mb-2 text-neutral relative w-fit flex justify-between">
{t("collection")}
</p>
{mode === "view" ? (
<div className="relative">
<Link
href={
isPublicRoute
? `/public/collections/${link.collection.id}`
: `/collections/${link.collection.id}`
}
className="rounded-md p-2 bg-base-200 border border-base-200 hide-scrollbar overflow-x-auto whitespace-nowrap flex justify-between items-center gap-2 pr-14"
>
<p>{link.collection.name}</p>
<div className="absolute right-0 px-2 bg-base-200">
{link.collection.icon ? (
<Icon
icon={link.collection.icon}
size={30}
weight={
(link.collection.iconWeight ||
"regular") as IconWeight
}
color={link.collection.color}
/>
) : (
<i
className="bi-folder-fill text-2xl"
style={{ color: link.collection.color }}
></i>
)}
</div>
</Link>
</div>
) : (
<CollectionSelection
onChange={setCollection}
defaultValue={
link.collection.id
? { value: link.collection.id, label: link.collection.name }
: { value: null as unknown as number, label: "Unorganized" }
}
creatable={false}
/>
)}
</div>
<br />
<div className="relative">
<p className="text-sm mb-2 text-neutral relative w-fit flex justify-between">
{t("tags")}
</p>
{mode === "view" ? (
<div className="flex gap-2 flex-wrap rounded-md p-2 bg-base-200 border border-base-200 w-full text-xs">
{link.tags && link.tags[0] ? (
link.tags.map((tag) =>
isPublicRoute ? (
<div
key={tag.id}
className="bg-base-200 p-1 hover:bg-neutral-content rounded-md duration-100"
>
{tag.name}
</div>
) : (
<Link
href={"/tags/" + tag.id}
key={tag.id}
className="bg-base-200 p-1 hover:bg-neutral-content btn btn-xs btn-ghost rounded-md"
>
{tag.name}
</Link>
)
)
) : (
<div className="text-neutral text-base">{t("no_tags")}</div>
)}
</div>
) : (
<TagSelection
onChange={setTags}
defaultValue={link.tags.map((e) => ({
label: e.name,
value: e.id,
}))}
/>
)}
</div>
<br />
<div className="relative">
<p className="text-sm mb-2 text-neutral relative w-fit flex justify-between">
{t("description")}
</p>
{mode === "view" ? (
<div className="rounded-md p-2 bg-base-200 hyphens-auto">
{link.description ? (
<p>{link.description}</p>
) : (
<p className="text-neutral">{t("no_description_provided")}</p>
)}
</div>
) : (
<textarea
value={unescapeString(link.description) as string}
onChange={(e) =>
setLink({ ...link, description: e.target.value })
}
placeholder={t("link_description_placeholder")}
className="resize-none w-full rounded-md p-2 h-32 border-neutral-content bg-base-200 focus:border-primary border-solid border outline-none duration-100"
/>
)}
</div>
{mode === "view" && (
<div>
<br />
<div className="flex gap-1 items-center mb-2">
<p
className="text-sm text-neutral"
title={t("available_formats")}
>
{link.url ? t("preserved_formats") : t("content")}
</p>
{onUpdateArchive &&
(permissions === true || permissions?.canUpdate) &&
!isPublicRoute && (
<div
className="tooltip tooltip-bottom"
data-tip={t("refresh_preserved_formats")}
>
<button
className="btn btn-xs btn-ghost btn-square text-neutral"
onClick={() => onUpdateArchive()}
>
<i className="bi-arrow-clockwise text-sm" />
</button>
</div>
)}
</div>
<div className={`flex flex-col rounded-md p-3 bg-base-200`}>
{formatAvailable(link, "monolith") ? (
<>
<PreservedFormatRow
name={t("webpage")}
icon={"bi-filetype-html"}
format={ArchivedFormat.monolith}
link={link}
downloadable={true}
/>
<hr className="m-3 border-t border-neutral-content" />
</>
) : undefined}
{formatAvailable(link, "image") ? (
<>
<PreservedFormatRow
name={t("screenshot")}
icon={"bi-file-earmark-image"}
format={
link?.image?.endsWith("png")
? ArchivedFormat.png
: ArchivedFormat.jpeg
}
link={link}
downloadable={true}
/>
<hr className="m-3 border-t border-neutral-content" />
</>
) : undefined}
{formatAvailable(link, "pdf") ? (
<>
<PreservedFormatRow
name={t("pdf")}
icon={"bi-file-earmark-pdf"}
format={ArchivedFormat.pdf}
link={link}
downloadable={true}
/>
<hr className="m-3 border-t border-neutral-content" />
</>
) : undefined}
{formatAvailable(link, "readable") ? (
<>
<PreservedFormatRow
name={t("readable")}
icon={"bi-file-earmark-text"}
format={ArchivedFormat.readability}
link={link}
/>
<hr className="m-3 border-t border-neutral-content" />
</>
) : undefined}
{!isReady() && !atLeastOneFormatAvailable(link) ? (
<div
className={`w-full h-full flex flex-col justify-center p-10`}
>
<BeatLoader
color="oklch(var(--p))"
className="mx-auto mb-3"
size={30}
/>
<p className="text-center text-2xl">
{t("preservation_in_queue")}
</p>
<p className="text-center text-lg">
{t("check_back_later")}
</p>
</div>
) : link.url &&
!isReady() &&
atLeastOneFormatAvailable(link) ? (
<div
className={`w-full h-full flex flex-col justify-center p-5`}
>
<BeatLoader
color="oklch(var(--p))"
className="mx-auto mb-3"
size={20}
/>
<p className="text-center">{t("there_are_more_formats")}</p>
<p className="text-center text-sm">
{t("check_back_later")}
</p>
</div>
) : undefined}
{link.url && (
<Link
href={`https://web.archive.org/web/${link?.url?.replace(
/(^\w+:|^)\/\//,
""
)}`}
target="_blank"
className="text-neutral mx-auto duration-100 hover:opacity-60 flex gap-2 w-1/2 justify-center items-center text-sm"
>
<p className="whitespace-nowrap">
{t("view_latest_snapshot")}
</p>
<i className="bi-box-arrow-up-right" />
</Link>
)}
</div>
</div>
)}
{mode === "view" ? (
<>
<br />
<p className="text-neutral text-xs text-center">
{t("saved")}{" "}
{new Date(link.createdAt || "").toLocaleDateString("en-US", {
month: "short",
day: "numeric",
year: "numeric",
})}{" "}
at{" "}
{new Date(link.createdAt || "").toLocaleTimeString("en-US", {
hour: "numeric",
minute: "numeric",
})}
</p>
</>
) : (
<>
<br />
<div className="flex justify-end items-center">
<button
className={clsx(
"btn btn-accent text-white",
JSON.stringify(activeLink) === JSON.stringify(link)
? "btn-disabled"
: "dark:border-violet-400"
)}
onClick={submit}
>
{t("save_changes")}
</button>
</div>
</>
)}
</div>
</div>
</div>
);
}

View File

@@ -1,5 +1,4 @@
import React, { Dispatch, SetStateAction, useEffect, useState } from "react";
import FilterSearchDropdown from "./FilterSearchDropdown";
import SortDropdown from "./SortDropdown";
import ViewDropdown from "./ViewDropdown";
import { TFunction } from "i18next";
@@ -17,20 +16,6 @@ type Props = {
t: TFunction<"translation", undefined>;
viewMode: ViewMode;
setViewMode: Dispatch<SetStateAction<ViewMode>>;
searchFilter?: {
name: boolean;
url: boolean;
description: boolean;
tags: boolean;
textContent: boolean;
};
setSearchFilter?: (filter: {
name: boolean;
url: boolean;
description: boolean;
tags: boolean;
textContent: boolean;
}) => void;
sortBy: Sort;
setSortBy: Dispatch<SetStateAction<Sort>>;
editMode?: boolean;
@@ -42,8 +27,6 @@ const LinkListOptions = ({
t,
viewMode,
setViewMode,
searchFilter,
setSearchFilter,
sortBy,
setSortBy,
editMode,
@@ -122,12 +105,6 @@ const LinkListOptions = ({
<i className="bi-pencil-fill text-neutral text-xl"></i>
</div>
)}
{searchFilter && setSearchFilter && (
<FilterSearchDropdown
searchFilter={searchFilter}
setSearchFilter={setSearchFilter}
/>
)}
<SortDropdown
sortBy={sortBy}
setSort={(value) => {

View File

@@ -4,202 +4,195 @@ import {
LinkIncludingShortenedCollectionAndTags,
} from "@/types/global";
import usePermissions from "@/hooks/usePermissions";
import EditLinkModal from "@/components/ModalContent/EditLinkModal";
import DeleteLinkModal from "@/components/ModalContent/DeleteLinkModal";
import PreservedFormatsModal from "@/components/ModalContent/PreservedFormatsModal";
import { dropdownTriggerer } from "@/lib/client/utils";
import { useTranslation } from "next-i18next";
import { useUser } from "@/hooks/store/user";
import { useDeleteLink, useUpdateLink } from "@/hooks/store/links";
import { useDeleteLink, useGetLink } from "@/hooks/store/links";
import toast from "react-hot-toast";
import LinkModal from "@/components/ModalContent/LinkModal";
import { useRouter } from "next/router";
import clsx from "clsx";
import usePinLink from "@/lib/client/pinLink";
type Props = {
link: LinkIncludingShortenedCollectionAndTags;
collection: CollectionIncludingMembersAndLinkCount;
position?: string;
toggleShowInfo?: () => void;
linkInfo?: boolean;
alignToTop?: boolean;
flipDropdown?: boolean;
btnStyle?: string;
linkModal: boolean;
setLinkModal: (value: boolean) => void;
};
export default function LinkActions({
link,
toggleShowInfo,
position,
linkInfo,
alignToTop,
flipDropdown,
btnStyle,
linkModal,
setLinkModal,
}: Props) {
const { t } = useTranslation();
const permissions = usePermissions(link.collection.id as number);
const getLink = useGetLink();
const pinLink = usePinLink();
const [editLinkModal, setEditLinkModal] = useState(false);
const [deleteLinkModal, setDeleteLinkModal] = useState(false);
const [preservedFormatsModal, setPreservedFormatsModal] = useState(false);
const { data: user = {} } = useUser();
const updateLink = useUpdateLink();
const deleteLink = useDeleteLink();
const pinLink = async () => {
const isAlreadyPinned = link?.pinnedBy && link.pinnedBy[0] ? true : false;
const updateArchive = async () => {
const load = toast.loading(t("sending_request"));
const load = toast.loading(t("updating"));
const response = await fetch(`/api/v1/links/${link?.id}/archive`, {
method: "PUT",
});
await updateLink.mutateAsync(
{
...link,
pinnedBy: isAlreadyPinned ? undefined : [{ id: user.id }],
},
{
onSettled: (data, error) => {
toast.dismiss(load);
const data = await response.json();
toast.dismiss(load);
if (error) {
toast.error(error.message);
} else {
toast.success(
isAlreadyPinned ? t("link_unpinned") : t("link_pinned")
);
}
},
}
);
if (response.ok) {
await getLink.mutateAsync({ id: link.id as number });
toast.success(t("link_being_archived"));
} else toast.error(data.response);
};
const router = useRouter();
const isPublicRoute = router.pathname.startsWith("/public") ? true : false;
return (
<>
<div
className={`dropdown dropdown-left absolute ${
position || "top-3 right-3"
} ${alignToTop ? "" : "dropdown-end"} z-20`}
>
{isPublicRoute ? (
<div
tabIndex={0}
role="button"
className="absolute top-3 right-3 group-hover:opacity-100 group-focus-within:opacity-100 opacity-0 duration-100"
onMouseDown={dropdownTriggerer}
className="btn btn-ghost btn-sm btn-square text-neutral"
onClick={() => setLinkModal(true)}
>
<i title="More" className="bi-three-dots text-xl" />
<div className={clsx("btn btn-sm btn-square text-neutral", btnStyle)}>
<i title="More" className="bi-info-circle text-xl" />
</div>
</div>
<ul
className={`dropdown-content z-[20] menu shadow bg-base-200 border border-neutral-content rounded-box w-44 mr-1 ${
alignToTop ? "" : "translate-y-10"
}`}
) : (
<div
className={`dropdown dropdown-end absolute top-3 right-3 group-hover:opacity-100 group-focus-within:opacity-100 opacity-0 duration-100 z-20`}
>
<li>
<div
role="button"
tabIndex={0}
onClick={() => {
(document?.activeElement as HTMLElement)?.blur();
pinLink();
}}
>
{link?.pinnedBy && link.pinnedBy[0]
? t("unpin")
: t("pin_to_dashboard")}
</div>
</li>
{linkInfo !== undefined && toggleShowInfo ? (
<div
tabIndex={0}
role="button"
onMouseDown={dropdownTriggerer}
className={clsx("btn btn-sm btn-square text-neutral", btnStyle)}
>
<i title="More" className="bi-three-dots text-xl" />
</div>
<ul
className={
"dropdown-content z-[20] menu shadow bg-base-200 border border-neutral-content rounded-box mt-1"
}
>
<li>
<div
role="button"
tabIndex={0}
onClick={() => {
(document?.activeElement as HTMLElement)?.blur();
toggleShowInfo();
pinLink(link);
}}
className="whitespace-nowrap"
>
{!linkInfo ? t("show_link_details") : t("hide_link_details")}
{link?.pinnedBy && link.pinnedBy[0]
? t("unpin")
: t("pin_to_dashboard")}
</div>
</li>
) : undefined}
{permissions === true || permissions?.canUpdate ? (
<li>
<div
role="button"
tabIndex={0}
onClick={() => {
(document?.activeElement as HTMLElement)?.blur();
setEditLinkModal(true);
setLinkModal(true);
}}
className="whitespace-nowrap"
>
{t("edit_link")}
{t("show_link_details")}
</div>
</li>
) : undefined}
{link.type === "url" && (
<li>
<div
role="button"
tabIndex={0}
onClick={() => {
(document?.activeElement as HTMLElement)?.blur();
setPreservedFormatsModal(true);
}}
>
{t("preserved_formats")}
</div>
</li>
)}
{permissions === true || permissions?.canDelete ? (
<li>
<div
role="button"
tabIndex={0}
onClick={async (e) => {
(document?.activeElement as HTMLElement)?.blur();
e.shiftKey
? async () => {
const load = toast.loading(t("deleting"));
{(permissions === true || permissions?.canUpdate) && (
<li>
<div
role="button"
tabIndex={0}
onClick={() => {
(document?.activeElement as HTMLElement)?.blur();
setEditLinkModal(true);
}}
className="whitespace-nowrap"
>
{t("edit_link")}
</div>
</li>
)}
{(permissions === true || permissions?.canDelete) && (
<li>
<div
role="button"
tabIndex={0}
onClick={async (e) => {
(document?.activeElement as HTMLElement)?.blur();
console.log(e.shiftKey);
e.shiftKey
? (async () => {
const load = toast.loading(t("deleting"));
await deleteLink.mutateAsync(link.id as number, {
onSettled: (data, error) => {
toast.dismiss(load);
await deleteLink.mutateAsync(link.id as number, {
onSettled: (data, error) => {
toast.dismiss(load);
if (error) {
toast.error(error.message);
} else {
toast.success(t("deleted"));
}
},
});
}
: setDeleteLinkModal(true);
}}
>
{t("delete")}
</div>
</li>
) : undefined}
</ul>
</div>
{editLinkModal ? (
<EditLinkModal
if (error) {
toast.error(error.message);
} else {
toast.success(t("deleted"));
}
},
});
})()
: setDeleteLinkModal(true);
}}
className="whitespace-nowrap"
>
{t("delete")}
</div>
</li>
)}
</ul>
</div>
)}
{editLinkModal && (
<LinkModal
onClose={() => setEditLinkModal(false)}
activeLink={link}
onPin={() => pinLink(link)}
onUpdateArchive={updateArchive}
onDelete={() => setDeleteLinkModal(true)}
link={link}
activeMode="edit"
/>
) : undefined}
{deleteLinkModal ? (
)}
{deleteLinkModal && (
<DeleteLinkModal
onClose={() => setDeleteLinkModal(false)}
activeLink={link}
/>
) : undefined}
{preservedFormatsModal ? (
<PreservedFormatsModal
onClose={() => setPreservedFormatsModal(false)}
)}
{linkModal && (
<LinkModal
onClose={() => setLinkModal(false)}
onPin={() => pinLink(link)}
onUpdateArchive={updateArchive}
onDelete={() => setDeleteLinkModal(true)}
link={link}
/>
) : undefined}
{/* {expandedLink ? (
<ExpandedLink onClose={() => setExpandedLink(false)} link={link} />
) : undefined} */}
)}
</>
);
}

View File

@@ -3,18 +3,19 @@ import {
CollectionIncludingMembersAndLinkCount,
LinkIncludingShortenedCollectionAndTags,
} from "@/types/global";
import { useEffect, useRef, useState } from "react";
import { useEffect, useMemo, useRef, useState } from "react";
import useLinkStore from "@/store/links";
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 { previewAvailable } from "@/lib/shared/getArchiveValidity";
import Link from "next/link";
import {
atLeastOneFormatAvailable,
formatAvailable,
} from "@/lib/shared/formatStats";
import LinkIcon from "./LinkIcon";
import useOnScreen from "@/hooks/useOnScreen";
import { generateLinkHref } from "@/lib/client/generateLinkHref";
import usePermissions from "@/hooks/usePermissions";
import toast from "react-hot-toast";
import LinkTypeBadge from "./LinkTypeBadge";
@@ -22,18 +23,39 @@ import { useTranslation } from "next-i18next";
import { useCollections } from "@/hooks/store/collections";
import { useUser } from "@/hooks/store/user";
import { useGetLink, useLinks } from "@/hooks/store/links";
import { useRouter } from "next/router";
import useLocalSettingsStore from "@/store/localSettings";
import LinkPin from "./LinkPin";
import LinkFormats from "./LinkFormats";
import openLink from "@/lib/client/openLink";
type Props = {
link: LinkIncludingShortenedCollectionAndTags;
count: number;
columns: number;
className?: string;
flipDropdown?: boolean;
editMode?: boolean;
};
export default function LinkCard({ link, flipDropdown, editMode }: Props) {
export default function LinkCard({ link, columns, editMode }: Props) {
const { t } = useTranslation();
const heightMap = {
1: "h-44",
2: "h-40",
3: "h-36",
4: "h-32",
5: "h-28",
6: "h-24",
7: "h-20",
8: "h-20",
};
const imageHeightClass = useMemo(
() => (columns ? heightMap[columns as keyof typeof heightMap] : "h-40"),
[columns]
);
const { data: collections = [] } = useCollections();
const { data: user = {} } = useUser();
@@ -41,8 +63,10 @@ export default function LinkCard({ link, flipDropdown, editMode }: Props) {
const { setSelectedLinks, selectedLinks } = useLinkStore();
const {
data: { data: links = [] },
} = useLinks();
settings: { show },
} = useLocalSettingsStore();
const { links } = useLinks();
const getLink = useGetLink();
useEffect(() => {
@@ -90,8 +114,13 @@ export default function LinkCard({ link, flipDropdown, editMode }: Props) {
const isVisible = useOnScreen(ref);
const permissions = usePermissions(collection?.id as number);
const router = useRouter();
const isPublicRoute = router.pathname.startsWith("/public") ? true : false;
const [linkModal, setLinkModal] = useState(false);
useEffect(() => {
let interval: any;
let interval: NodeJS.Timeout | null = null;
if (
isVisible &&
@@ -99,7 +128,10 @@ export default function LinkCard({ link, flipDropdown, editMode }: Props) {
link.preview !== "unavailable"
) {
interval = setInterval(async () => {
getLink.mutateAsync(link.id as number);
getLink.mutateAsync({
id: link.id as number,
isPublicRoute: isPublicRoute,
});
}, 5000);
}
@@ -110,8 +142,6 @@ export default function LinkCard({ link, flipDropdown, editMode }: Props) {
};
}, [isVisible, link.preview]);
const [showInfo, setShowInfo] = useState(false);
const selectedStyle = selectedLinks.some(
(selectedLink) => selectedLink.id === link.id
)
@@ -125,7 +155,7 @@ export default function LinkCard({ link, flipDropdown, editMode }: Props) {
return (
<div
ref={ref}
className={`${selectedStyle} border border-solid border-neutral-content bg-base-200 shadow-md hover:shadow-none duration-100 rounded-2xl relative`}
className={`${selectedStyle} border border-solid border-neutral-content bg-base-200 shadow-md hover:shadow-none duration-100 rounded-2xl relative group`}
onClick={() =>
selectable
? handleCheckboxClick(link)
@@ -137,124 +167,91 @@ export default function LinkCard({ link, flipDropdown, editMode }: Props) {
<div
className="rounded-2xl cursor-pointer h-full flex flex-col justify-between"
onClick={() =>
!editMode && window.open(generateLinkHref(link, user), "_blank")
!editMode && openLink(link, user, () => setLinkModal(true))
}
>
<div>
<div className="relative rounded-t-2xl h-40 overflow-hidden">
{previewAvailable(link) ? (
<Image
src={`/api/v1/archives/${link.id}?format=${ArchivedFormat.jpeg}&preview=true`}
width={1280}
height={720}
alt=""
className="rounded-t-2xl select-none object-cover z-10 h-40 w-full shadow opacity-80 scale-105"
style={
link.type !== "image" ? { filter: "blur(1px)" } : undefined
}
draggable="false"
onError={(e) => {
const target = e.target as HTMLElement;
target.style.display = "none";
}}
/>
) : link.preview === "unavailable" ? (
<div className="bg-gray-50 duration-100 h-40 bg-opacity-80"></div>
) : (
<div className="duration-100 h-40 bg-opacity-80 skeleton rounded-none"></div>
)}
{link.type !== "image" && (
<div className="absolute top-0 left-0 right-0 bottom-0 rounded-t-2xl flex items-center justify-center shadow rounded-md">
<LinkIcon link={link} />
</div>
)}
</div>
<hr className="divider my-0 border-t border-neutral-content h-[1px]" />
</div>
<div className="flex flex-col justify-between h-full">
<div className="p-3 flex flex-col gap-2">
<p className="truncate w-full pr-9 text-primary text-sm">
{unescapeString(link.name)}
</p>
<LinkTypeBadge link={link} />
</div>
{show.image && (
<div>
<hr className="divider mt-2 mb-1 last:hidden border-t border-neutral-content h-[1px]" />
<div className="flex justify-between text-xs text-neutral px-3 pb-1 gap-2">
<div className="cursor-pointer truncate">
{collection && (
<LinkCollection link={link} collection={collection} />
<div
className={`relative rounded-t-2xl ${imageHeightClass} 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-2xl select-none object-cover z-10 ${imageHeightClass} 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";
}}
/>
) : link.preview === "unavailable" ? (
<div
className={`bg-gray-50 ${imageHeightClass} bg-opacity-80`}
></div>
) : (
<div
className={`${imageHeightClass} bg-opacity-80 skeleton rounded-none`}
></div>
)}
{show.icon && (
<div className="absolute top-0 left-0 right-0 bottom-0 rounded-t-2xl 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>
<LinkDate link={link} />
</div>
<hr className="divider my-0 border-t border-neutral-content h-[1px]" />
</div>
)}
<div className="flex flex-col justify-between h-full min-h-24">
<div className="p-3 flex flex-col gap-2">
{show.name && (
<p className="truncate w-full text-primary text-sm">
{unescapeString(link.name)}
</p>
)}
{show.link && <LinkTypeBadge link={link} />}
</div>
{(show.collection || show.date) && (
<div>
<hr className="divider mt-2 mb-1 last:hidden border-t border-neutral-content h-[1px]" />
<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} />
</div>
)}
{show.date && <LinkDate link={link} />}
</div>
</div>
)}
</div>
</div>
{showInfo && (
<div className="p-3 absolute z-30 top-0 left-0 right-0 bottom-0 bg-base-200 rounded-[0.9rem] fade-in overflow-y-auto">
<div
onClick={() => setShowInfo(!showInfo)}
className=" float-right btn btn-sm outline-none btn-circle btn-ghost z-10"
>
<i className="bi-x text-neutral text-2xl"></i>
</div>
<p className="text-neutral text-lg font-semibold">
{t("description")}
</p>
<hr className="divider my-2 border-t border-neutral-content h-[1px]" />
<p>
{link.description ? (
unescapeString(link.description)
) : (
<span className="text-neutral text-sm">
{t("no_description")}
</span>
)}
</p>
{link.tags && link.tags[0] && (
<>
<p className="text-neutral text-lg mt-3 font-semibold">
{t("tags")}
</p>
<hr className="divider my-2 border-t border-neutral-content h-[1px]" />
<div className="flex gap-3 items-center flex-wrap mt-2 truncate relative">
<div className="flex gap-1 items-center flex-wrap">
{link.tags.map((e, i) => (
<Link
href={"/tags/" + e.id}
key={i}
onClick={(e) => {
e.stopPropagation();
}}
className="btn btn-xs btn-ghost truncate max-w-[19rem]"
>
#{e.name}
</Link>
))}
</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-2xl duration-100"></div>
<LinkActions
link={link}
collection={collection}
position="top-[10.75rem] right-3"
toggleShowInfo={() => setShowInfo(!showInfo)}
linkInfo={showInfo}
flipDropdown={flipDropdown}
linkModal={linkModal}
setLinkModal={(e) => setLinkModal(e)}
/>
{!isPublicRoute && <LinkPin link={link} />}
</div>
);
}

View File

@@ -1,8 +1,11 @@
import Icon from "@/components/Icon";
import {
CollectionIncludingMembersAndLinkCount,
LinkIncludingShortenedCollectionAndTags,
} from "@/types/global";
import { IconWeight } from "@phosphor-icons/react";
import Link from "next/link";
import { useRouter } from "next/router";
import React from "react";
export default function LinkCollection({
@@ -12,7 +15,11 @@ export default function LinkCollection({
link: LinkIncludingShortenedCollectionAndTags;
collection: CollectionIncludingMembersAndLinkCount;
}) {
return (
const router = useRouter();
const isPublicRoute = router.pathname.startsWith("/public") ? true : false;
return !isPublicRoute && collection?.name ? (
<>
<Link
href={`/collections/${link.collection.id}`}
@@ -22,12 +29,21 @@ export default function LinkCollection({
className="flex items-center gap-1 max-w-full w-fit hover:opacity-70 duration-100 select-none"
title={collection?.name}
>
<i
className="bi-folder-fill text-lg drop-shadow"
style={{ color: collection?.color }}
></i>
{link.collection.icon ? (
<Icon
icon={link.collection.icon}
size={20}
weight={(link.collection.iconWeight || "regular") as IconWeight}
color={link.collection.color}
/>
) : (
<i
className="bi-folder-fill text-lg"
style={{ color: link.collection.color }}
></i>
)}
<p className="truncate capitalize">{collection?.name}</p>
</Link>
</>
);
) : null;
}

View File

@@ -0,0 +1,95 @@
import { formatAvailable } from "@/lib/shared/formatStats";
import {
ArchivedFormat,
LinkIncludingShortenedCollectionAndTags,
} from "@/types/global";
import { useTranslation } from "next-i18next";
import Link from "next/link";
import { useRouter } from "next/router";
export default function LinkFormats({
link,
}: {
link: LinkIncludingShortenedCollectionAndTags;
}) {
const { t } = useTranslation();
const router = useRouter();
let isPublic = router.pathname.startsWith("/public") ? true : undefined;
return (
<div className="flex gap-1 text-neutral">
{formatAvailable(link, "monolith") && (
<Link
href={`${isPublic ? "/public" : ""}/preserved/${link?.id}?format=${
ArchivedFormat.monolith
}`}
target="_blank"
onClick={(e) => {
e.stopPropagation();
}}
className="hover:opacity-70 duration-100"
>
<i
className="bi-filetype-html text-md leading-none"
title={t("webpage")}
></i>
</Link>
)}
{formatAvailable(link, "image") && (
<Link
href={`${isPublic ? "/public" : ""}/preserved/${link?.id}?format=${
link?.image?.endsWith("png")
? ArchivedFormat.png
: ArchivedFormat.jpeg
}`}
target="_blank"
onClick={(e) => {
e.stopPropagation();
}}
className="hover:opacity-70 duration-100"
>
<i
className="bi-file-earmark-image text-md leading-none"
title={t("image")}
></i>
</Link>
)}
{formatAvailable(link, "pdf") && (
<Link
href={`${isPublic ? "/public" : ""}/preserved/${link?.id}?format=${
ArchivedFormat.pdf
}`}
target="_blank"
onClick={(e) => {
e.stopPropagation();
}}
className="hover:opacity-70 duration-100"
>
<i
className="bi-file-earmark-pdf text-md leading-none"
title={t("pdf")}
></i>
</Link>
)}
{formatAvailable(link, "readable") && (
<Link
href={`${isPublic ? "/public" : ""}/preserved/${link?.id}?format=${
ArchivedFormat.readability
}`}
target="_blank"
onClick={(e) => {
e.stopPropagation();
}}
className="hover:opacity-70 duration-100"
>
<i
className="bi-file-earmark-text text-md leading-none"
title={t("readable")}
></i>
</Link>
)}
</div>
);
}

View File

@@ -1,73 +1,77 @@
import { LinkIncludingShortenedCollectionAndTags } from "@/types/global";
import Image from "next/image";
import isValidUrl from "@/lib/shared/isValidUrl";
import React from "react";
import React, { useState } from "react";
import Icon from "@/components/Icon";
import { IconWeight } from "@phosphor-icons/react";
import clsx from "clsx";
import oklchVariableToHex from "@/lib/client/oklchVariableToHex";
export default function LinkIcon({
link,
className,
size,
hideBackground,
onClick,
}: {
link: LinkIncludingShortenedCollectionAndTags;
className?: string;
size?: "small" | "medium";
hideBackground?: boolean;
onClick?: Function;
}) {
let iconClasses: string =
"bg-white shadow rounded-md border-[2px] flex item-center justify-center border-white select-none z-10 " +
(className || "");
let dimension;
switch (size) {
case "small":
dimension = " w-8 h-8";
break;
case "medium":
dimension = " w-12 h-12";
break;
default:
size = "medium";
dimension = " w-12 h-12";
break;
}
let iconClasses: string = clsx(
"rounded flex item-center justify-center shadow select-none z-10 w-12 h-12",
!hideBackground &&
"rounded-md backdrop-blur-xl bg-white/30 dark:bg-black/30 bg-opacity-50 p-1",
className
);
const url =
isValidUrl(link.url || "") && link.url ? new URL(link.url) : undefined;
const [showFavicon, setShowFavicon] = React.useState<boolean>(true);
const [faviconLoaded, setFaviconLoaded] = useState(false);
return (
<>
{link.type === "url" && url ? (
showFavicon ? (
<div onClick={() => onClick && onClick()}>
{link.icon ? (
<div className={iconClasses}>
<Icon
icon={link.icon}
size={30}
weight={(link.iconWeight || "regular") as IconWeight}
color={link.color || oklchVariableToHex("--p")}
className="m-auto"
/>
</div>
) : link.type === "url" && url ? (
<>
<Image
src={`https://t2.gstatic.com/faviconV2?client=SOCIAL&type=FAVICON&fallback_opts=TYPE,SIZE,URL&url=${link.url}&size=32`}
width={64}
height={64}
alt=""
className={iconClasses + dimension}
className={clsx(
iconClasses,
faviconLoaded ? "" : "absolute opacity-0"
)}
draggable="false"
onError={() => {
setShowFavicon(false);
}}
onLoadingComplete={() => setFaviconLoaded(true)}
onError={() => setFaviconLoaded(false)}
/>
) : (
<LinkPlaceholderIcon
iconClasses={iconClasses + dimension}
size={size}
icon="bi-link-45deg"
/>
)
{!faviconLoaded && (
<LinkPlaceholderIcon
iconClasses={iconClasses}
icon="bi-link-45deg"
/>
)}
</>
) : link.type === "pdf" ? (
<LinkPlaceholderIcon
iconClasses={iconClasses + dimension}
size={size}
iconClasses={iconClasses}
icon="bi-file-earmark-pdf"
/>
) : link.type === "image" ? (
<LinkPlaceholderIcon
iconClasses={iconClasses + dimension}
size={size}
iconClasses={iconClasses}
icon="bi-file-earmark-image"
/>
) : // : link.type === "monolith" ? (
@@ -78,24 +82,23 @@ export default function LinkIcon({
// />
// )
undefined}
</>
</div>
);
}
const LinkPlaceholderIcon = ({
iconClasses,
size,
icon,
}: {
iconClasses: string;
size?: "small" | "medium";
icon: string;
}) => {
return (
<div
className={`${
size === "small" ? "text-2xl" : "text-4xl"
} text-black aspect-square ${iconClasses}`}
className={clsx(
iconClasses,
"aspect-square text-4xl text-[oklch(var(--p))]"
)}
>
<i className={`${icon} m-auto`}></i>
</div>

View File

@@ -10,7 +10,6 @@ import LinkDate from "@/components/LinkViews/LinkComponents/LinkDate";
import LinkCollection from "@/components/LinkViews/LinkComponents/LinkCollection";
import LinkIcon from "@/components/LinkViews/LinkComponents/LinkIcon";
import { isPWA } from "@/lib/client/utils";
import { generateLinkHref } from "@/lib/client/generateLinkHref";
import usePermissions from "@/hooks/usePermissions";
import toast from "react-hot-toast";
import LinkTypeBadge from "./LinkTypeBadge";
@@ -18,20 +17,21 @@ import { useTranslation } from "next-i18next";
import { useCollections } from "@/hooks/store/collections";
import { useUser } from "@/hooks/store/user";
import { useLinks } from "@/hooks/store/links";
import useLocalSettingsStore from "@/store/localSettings";
import LinkPin from "./LinkPin";
import { useRouter } from "next/router";
import { atLeastOneFormatAvailable } from "@/lib/shared/formatStats";
import LinkFormats from "./LinkFormats";
import openLink from "@/lib/client/openLink";
type Props = {
link: LinkIncludingShortenedCollectionAndTags;
count: number;
className?: string;
flipDropdown?: boolean;
editMode?: boolean;
};
export default function LinkCardCompact({
link,
flipDropdown,
editMode,
}: Props) {
export default function LinkCardCompact({ link, editMode }: Props) {
const { t } = useTranslation();
const { data: collections = [] } = useCollections();
@@ -39,6 +39,10 @@ export default function LinkCardCompact({
const { data: user = {} } = useUser();
const { setSelectedLinks, selectedLinks } = useLinkStore();
const {
settings: { show },
} = useLocalSettingsStore();
const { links } = useLinks();
useEffect(() => {
@@ -80,8 +84,6 @@ export default function LinkCardCompact({
const permissions = usePermissions(collection?.id as number);
const [showInfo, setShowInfo] = useState(false);
const selectedStyle = selectedLinks.some(
(selectedLink) => selectedLink.id === link.id
)
@@ -92,12 +94,18 @@ export default function LinkCardCompact({
editMode &&
(permissions === true || permissions?.canCreate || permissions?.canDelete);
const router = useRouter();
let isPublic = router.pathname.startsWith("/public") ? true : undefined;
const [linkModal, setLinkModal] = useState(false);
return (
<>
<div
className={`${selectedStyle} border relative items-center flex ${
!showInfo && !isPWA() ? "hover:bg-base-300 p-3" : "py-3"
} duration-200 rounded-lg w-full`}
className={`${selectedStyle} rounded-md border relative group items-center flex ${
!isPWA() ? "hover:bg-base-300 px-2 py-1" : "py-1"
} duration-200`}
onClick={() =>
selectable
? handleCheckboxClick(link)
@@ -106,67 +114,55 @@ export default function LinkCardCompact({
: undefined
}
>
{/* {showCheckbox &&
editMode &&
(permissions === true ||
permissions?.canCreate ||
permissions?.canDelete) && (
<input
type="checkbox"
className="checkbox checkbox-primary my-auto mr-2"
checked={selectedLinks.some(
(selectedLink) => selectedLink.id === link.id
)}
onChange={() => handleCheckboxClick(link)}
/>
)} */}
<div
className="flex items-center cursor-pointer w-full"
className="flex items-center cursor-pointer w-full min-h-12"
onClick={() =>
!editMode && window.open(generateLinkHref(link, user), "_blank")
!editMode && openLink(link, user, () => setLinkModal(true))
}
>
<div className="shrink-0">
<LinkIcon link={link} className="w-12 h-12 text-4xl" />
</div>
{show.icon && (
<div className="shrink-0">
<LinkIcon link={link} hideBackground />
</div>
)}
<div className="w-[calc(100%-56px)] ml-2">
<p className="line-clamp-1 mr-8 text-primary select-none">
{link.name ? (
unescapeString(link.name)
) : (
<div className="mt-2">
<LinkTypeBadge link={link} />
</div>
)}
</p>
{show.name && (
<div className="flex gap-1 mr-20">
<p className="truncate text-primary">
{unescapeString(link.name)}
</p>
{show.preserved_formats &&
link.type === "url" &&
atLeastOneFormatAvailable(link) && (
<div className="pl-1 inline-block text-lg">
<LinkFormats link={link} />
</div>
)}
</div>
)}
<div className="mt-1 flex flex-col sm:flex-row sm:items-center gap-2 text-xs text-neutral">
<div className="flex items-center gap-x-3 text-neutral flex-wrap">
{collection ? (
{show.link && <LinkTypeBadge link={link} />}
{show.collection && (
<LinkCollection link={link} collection={collection} />
) : undefined}
{link.name && <LinkTypeBadge link={link} />}
<LinkDate link={link} />
)}
{show.date && <LinkDate link={link} />}
</div>
</div>
</div>
</div>
{!isPublic && <LinkPin link={link} btnStyle="btn-ghost" />}
<LinkActions
link={link}
collection={collection}
position="top-3 right-3"
flipDropdown={flipDropdown}
// toggleShowInfo={() => setShowInfo(!showInfo)}
// linkInfo={showInfo}
btnStyle="btn-ghost"
linkModal={linkModal}
setLinkModal={(e) => setLinkModal(e)}
/>
</div>
<div
className="last:hidden rounded-none"
style={{
borderTop: "1px solid var(--fallback-bc,oklch(var(--bc)/0.1))",
}}
></div>
<div className="last:hidden rounded-none my-0 mx-1 border-t border-base-300 h-[1px]"></div>
</>
);
}

View File

@@ -3,18 +3,20 @@ import {
CollectionIncludingMembersAndLinkCount,
LinkIncludingShortenedCollectionAndTags,
} from "@/types/global";
import { useEffect, useRef, useState } from "react";
import { useEffect, useMemo, useRef, useState } from "react";
import useLinkStore from "@/store/links";
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 { previewAvailable } from "@/lib/shared/getArchiveValidity";
import {
atLeastOneFormatAvailable,
formatAvailable,
} from "@/lib/shared/formatStats";
import Link from "next/link";
import LinkIcon from "./LinkIcon";
import useOnScreen from "@/hooks/useOnScreen";
import { generateLinkHref } from "@/lib/client/generateLinkHref";
import usePermissions from "@/hooks/usePermissions";
import toast from "react-hot-toast";
import LinkTypeBadge from "./LinkTypeBadge";
@@ -22,23 +24,47 @@ import { useTranslation } from "next-i18next";
import { useCollections } from "@/hooks/store/collections";
import { useUser } from "@/hooks/store/user";
import { useGetLink, useLinks } from "@/hooks/store/links";
import useLocalSettingsStore from "@/store/localSettings";
import clsx from "clsx";
import LinkPin from "./LinkPin";
import { useRouter } from "next/router";
import LinkFormats from "./LinkFormats";
import openLink from "@/lib/client/openLink";
type Props = {
link: LinkIncludingShortenedCollectionAndTags;
count: number;
className?: string;
flipDropdown?: boolean;
columns: number;
editMode?: boolean;
};
export default function LinkMasonry({ link, flipDropdown, editMode }: Props) {
export default function LinkMasonry({ link, editMode, columns }: Props) {
const { t } = useTranslation();
const heightMap = {
1: "h-44",
2: "h-40",
3: "h-36",
4: "h-32",
5: "h-28",
6: "h-24",
7: "h-20",
8: "h-20",
};
const imageHeightClass = useMemo(
() => (columns ? heightMap[columns as keyof typeof heightMap] : "h-40"),
[columns]
);
const { data: collections = [] } = useCollections();
const { data: user = {} } = useUser();
const { setSelectedLinks, selectedLinks } = useLinkStore();
const {
settings: { show },
} = useLocalSettingsStore();
const { links } = useLinks();
const getLink = useGetLink();
@@ -87,8 +113,12 @@ export default function LinkMasonry({ link, flipDropdown, editMode }: Props) {
const isVisible = useOnScreen(ref);
const permissions = usePermissions(collection?.id as number);
const router = useRouter();
let isPublic = router.pathname.startsWith("/public") ? true : undefined;
useEffect(() => {
let interval: any;
let interval: NodeJS.Timeout | null = null;
if (
isVisible &&
@@ -96,7 +126,7 @@ export default function LinkMasonry({ link, flipDropdown, editMode }: Props) {
link.preview !== "unavailable"
) {
interval = setInterval(async () => {
getLink.mutateAsync(link.id as number);
getLink.mutateAsync({ id: link.id as number });
}, 5000);
}
@@ -107,8 +137,6 @@ export default function LinkMasonry({ link, flipDropdown, editMode }: Props) {
};
}, [isVisible, link.preview]);
const [showInfo, setShowInfo] = useState(false);
const selectedStyle = selectedLinks.some(
(selectedLink) => selectedLink.id === link.id
)
@@ -119,10 +147,12 @@ export default function LinkMasonry({ link, flipDropdown, editMode }: Props) {
editMode &&
(permissions === true || permissions?.canCreate || permissions?.canDelete);
const [linkModal, setLinkModal] = useState(false);
return (
<div
ref={ref}
className={`${selectedStyle} border border-solid border-neutral-content bg-base-200 shadow-md hover:shadow-none duration-100 rounded-2xl relative`}
className={`${selectedStyle} border border-solid border-neutral-content bg-base-200 shadow-md hover:shadow-none duration-100 rounded-2xl relative group`}
onClick={() =>
selectable
? handleCheckboxClick(link)
@@ -134,54 +164,65 @@ export default function LinkMasonry({ link, flipDropdown, editMode }: Props) {
<div
className="rounded-2xl cursor-pointer"
onClick={() =>
!editMode && window.open(generateLinkHref(link, user), "_blank")
!editMode && openLink(link, user, () => setLinkModal(true))
}
>
<div className="relative rounded-t-2xl overflow-hidden">
{previewAvailable(link) ? (
<Image
src={`/api/v1/archives/${link.id}?format=${ArchivedFormat.jpeg}&preview=true`}
width={1280}
height={720}
alt=""
className="rounded-t-2xl select-none object-cover z-10 h-40 w-full shadow opacity-80 scale-105"
style={
link.type !== "image" ? { filter: "blur(1px)" } : undefined
}
draggable="false"
onError={(e) => {
const target = e.target as HTMLElement;
target.style.display = "none";
}}
/>
) : link.preview === "unavailable" ? null : (
<div className="duration-100 h-40 bg-opacity-80 skeleton rounded-none"></div>
)}
{link.type !== "image" && (
<div className="absolute top-0 left-0 right-0 bottom-0 rounded-t-2xl flex items-center justify-center shadow rounded-md">
<LinkIcon link={link} />
{show.image && formatAvailable(link, "preview") && (
<div>
<div className="relative rounded-t-2xl 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-2xl select-none object-cover z-10 ${imageHeightClass} 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";
}}
/>
) : link.preview === "unavailable" ? null : (
<div
className={`duration-100 ${imageHeightClass} bg-opacity-80 skeleton rounded-none`}
></div>
)}
{show.icon && (
<div className="absolute top-0 left-0 right-0 bottom-0 rounded-t-2xl flex items-center justify-center rounded-md">
<LinkIcon link={link} />
</div>
)}
</div>
)}
</div>
{link.preview !== "unavailable" && (
<hr className="divider my-0 last:hidden border-t border-neutral-content h-[1px]" />
<hr className="divider my-0 border-t border-neutral-content h-[1px]" />
</div>
)}
<div className="p-3 flex flex-col gap-2">
<p className="hyphens-auto w-full pr-9 text-primary text-sm">
{unescapeString(link.name)}
</p>
<div className="p-3 flex flex-col gap-2 h-full min-h-14">
{show.name && (
<div className="hyphens-auto w-full text-primary text-sm">
{unescapeString(link.name)}
{show.preserved_formats &&
link.type === "url" &&
atLeastOneFormatAvailable(link) && (
<div className="pl-1 inline-block">
<LinkFormats link={link} />
</div>
)}
</div>
)}
<LinkTypeBadge link={link} />
{show.link && <LinkTypeBadge link={link} />}
{link.description && (
<p className="hyphens-auto text-sm">
{show.description && link.description && (
<p className={clsx("hyphens-auto text-sm w-full")}>
{unescapeString(link.description)}
</p>
)}
{link.tags && link.tags[0] && (
{show.tags && link.tags && link.tags[0] && (
<div className="flex gap-1 items-center flex-wrap">
{link.tags.map((e, i) => (
<Link
@@ -199,77 +240,31 @@ export default function LinkMasonry({ link, flipDropdown, editMode }: Props) {
)}
</div>
<hr className="divider mt-2 mb-1 last:hidden border-t border-neutral-content h-[1px]" />
{(show.collection || show.date) && (
<div>
<hr className="divider mt-2 mb-1 last:hidden border-t border-neutral-content h-[1px]" />
<div className="flex flex-wrap justify-between text-xs text-neutral px-3 pb-1 w-full gap-x-2">
{collection && <LinkCollection link={link} collection={collection} />}
<LinkDate link={link} />
</div>
<div className="flex flex-wrap justify-between items-center text-xs text-neutral px-3 pb-1 w-full gap-x-2">
{!isPublic && show.collection && (
<div className="cursor-pointer truncate">
<LinkCollection link={link} collection={collection} />
</div>
)}
{show.date && <LinkDate link={link} />}
</div>
</div>
)}
</div>
{showInfo && (
<div className="p-3 absolute z-30 top-0 left-0 right-0 bottom-0 bg-base-200 rounded-2xl fade-in overflow-y-auto">
<div
onClick={() => setShowInfo(!showInfo)}
className=" float-right btn btn-sm outline-none btn-circle btn-ghost z-10"
>
<i className="bi-x text-neutral text-2xl"></i>
</div>
<p className="text-neutral text-lg font-semibold">
{t("description")}
</p>
<hr className="divider my-2 last:hidden border-t border-neutral-content h-[1px]" />
<p>
{link.description ? (
unescapeString(link.description)
) : (
<span className="text-neutral text-sm">
{t("no_description")}
</span>
)}
</p>
{link.tags && link.tags[0] && (
<>
<p className="text-neutral text-lg mt-3 font-semibold">
{t("tags")}
</p>
<hr className="divider my-2 last:hidden border-t border-neutral-content h-[1px]" />
<div className="flex gap-3 items-center flex-wrap mt-2 truncate relative">
<div className="flex gap-1 items-center flex-wrap">
{link.tags.map((e, i) => (
<Link
href={"/tags/" + e.id}
key={i}
onClick={(e) => {
e.stopPropagation();
}}
className="btn btn-xs btn-ghost truncate max-w-[19rem]"
>
#{e.name}
</Link>
))}
</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-2xl duration-100"></div>
<LinkActions
link={link}
collection={collection}
position={
link.preview !== "unavailable"
? "top-[10.75rem] right-3"
: "top-[.75rem] right-3"
}
toggleShowInfo={() => setShowInfo(!showInfo)}
linkInfo={showInfo}
flipDropdown={flipDropdown}
linkModal={linkModal}
setLinkModal={(e) => setLinkModal(e)}
/>
{!isPublic && <LinkPin link={link} />}
</div>
);
}

View File

@@ -0,0 +1,34 @@
import { LinkIncludingShortenedCollectionAndTags } from "@/types/global";
import { useRouter } from "next/router";
import clsx from "clsx";
import usePinLink from "@/lib/client/pinLink";
type Props = {
link: LinkIncludingShortenedCollectionAndTags;
btnStyle?: string;
};
export default function LinkPin({ link, btnStyle }: Props) {
const pinLink = usePinLink();
const router = useRouter();
const isPublicRoute = router.pathname.startsWith("/public") ? true : false;
const isAlreadyPinned = link?.pinnedBy && link.pinnedBy[0] ? true : false;
return (
<div
className="absolute top-3 right-[3.25rem] group-hover:opacity-100 group-focus-within:opacity-100 opacity-0 duration-100"
onClick={() => pinLink(link)}
>
<div className={clsx("btn btn-sm btn-square text-neutral", btnStyle)}>
<i
title="Pin"
className={clsx(
"text-xl",
isAlreadyPinned ? "bi-pin-fill" : "bi-pin"
)}
/>
</div>
</div>
);
}

View File

@@ -1,6 +1,5 @@
import { LinkIncludingShortenedCollectionAndTags } from "@/types/global";
import Link from "next/link";
import React from "react";
export default function LinkTypeBadge({
link,
@@ -17,6 +16,17 @@ export default function LinkTypeBadge({
}
}
const typeIcon = () => {
switch (link.type) {
case "pdf":
return "bi-file-earmark-pdf";
case "image":
return "bi-file-earmark-image";
default:
return "bi-link-45deg";
}
};
return link.url && shortendURL ? (
<Link
href={link.url || ""}
@@ -31,6 +41,9 @@ export default function LinkTypeBadge({
<p className="text-xs truncate">{shortendURL}</p>
</Link>
) : (
<div className="badge badge-primary badge-sm select-none">{link.type}</div>
<div className="flex gap-1 item-center select-none text-neutral duration-100 max-w-full w-fit">
<i className={typeIcon() + ` text-md leading-none`}></i>
<p className="text-xs truncate">{link.type}</p>
</div>
);
}

View File

@@ -3,7 +3,7 @@ import {
LinkIncludingShortenedCollectionAndTags,
ViewMode,
} from "@/types/global";
import { useEffect } from "react";
import { useEffect, useState } from "react";
import { useInView } from "react-intersection-observer";
import LinkMasonry from "@/components/LinkViews/LinkComponents/LinkMasonry";
import Masonry from "react-masonry-css";
@@ -11,6 +11,7 @@ import resolveConfig from "tailwindcss/resolveConfig";
import tailwindConfig from "../../tailwind.config.js";
import { useMemo } from "react";
import LinkList from "@/components/LinkViews/LinkComponents/LinkList";
import useLocalSettingsStore from "@/store/localSettings";
export function CardView({
links,
@@ -27,16 +28,68 @@ export function CardView({
hasNextPage?: boolean;
placeHolderRef?: any;
}) {
const settings = useLocalSettingsStore((state) => state.settings);
const gridMap = {
1: "grid-cols-1",
2: "grid-cols-2",
3: "grid-cols-3",
4: "grid-cols-4",
5: "grid-cols-5",
6: "grid-cols-6",
7: "grid-cols-7",
8: "grid-cols-8",
};
const getColumnCount = () => {
const width = window.innerWidth;
if (width >= 1901) return 5;
if (width >= 1501) return 4;
if (width >= 881) return 3;
if (width >= 551) return 2;
return 1;
};
const [columnCount, setColumnCount] = useState(
settings.columns || getColumnCount()
);
const gridColClass = useMemo(
() => gridMap[columnCount as keyof typeof gridMap],
[columnCount]
);
useEffect(() => {
const handleResize = () => {
if (settings.columns === 0) {
// Only recalculate if zustandColumns is zero
setColumnCount(getColumnCount());
}
};
if (settings.columns === 0) {
window.addEventListener("resize", handleResize);
}
setColumnCount(settings.columns || getColumnCount());
return () => {
if (settings.columns === 0) {
window.removeEventListener("resize", handleResize);
}
};
}, [settings.columns]);
return (
<div className="grid min-[1901px]:grid-cols-5 min-[1501px]:grid-cols-4 min-[881px]:grid-cols-3 min-[551px]:grid-cols-2 grid-cols-1 gap-5 pb-5">
<div className={`${gridColClass} grid gap-5 pb-5`}>
{links?.map((e, i) => {
return (
<LinkCard
key={i}
link={e}
count={i}
flipDropdown={i === links.length - 1}
editMode={editMode}
columns={columnCount}
/>
);
})}
@@ -76,6 +129,58 @@ export function MasonryView({
hasNextPage?: boolean;
placeHolderRef?: any;
}) {
const settings = useLocalSettingsStore((state) => state.settings);
const gridMap = {
1: "grid-cols-1",
2: "grid-cols-2",
3: "grid-cols-3",
4: "grid-cols-4",
5: "grid-cols-5",
6: "grid-cols-6",
7: "grid-cols-7",
8: "grid-cols-8",
};
const getColumnCount = () => {
const width = window.innerWidth;
if (width >= 1901) return 5;
if (width >= 1501) return 4;
if (width >= 881) return 3;
if (width >= 551) return 2;
return 1;
};
const [columnCount, setColumnCount] = useState(
settings.columns || getColumnCount()
);
const gridColClass = useMemo(
() => gridMap[columnCount as keyof typeof gridMap],
[columnCount]
);
useEffect(() => {
const handleResize = () => {
if (settings.columns === 0) {
// Only recalculate if zustandColumns is zero
setColumnCount(getColumnCount());
}
};
if (settings.columns === 0) {
window.addEventListener("resize", handleResize);
}
setColumnCount(settings.columns || getColumnCount());
return () => {
if (settings.columns === 0) {
window.removeEventListener("resize", handleResize);
}
};
}, [settings.columns]);
const fullConfig = resolveConfig(tailwindConfig as any);
const breakpointColumnsObj = useMemo(() => {
@@ -90,18 +195,19 @@ export function MasonryView({
return (
<Masonry
breakpointCols={breakpointColumnsObj}
breakpointCols={
settings.columns === 0 ? breakpointColumnsObj : columnCount
}
columnClassName="flex flex-col gap-5 !w-full"
className="grid min-[1901px]:grid-cols-5 min-[1501px]:grid-cols-4 min-[881px]:grid-cols-3 min-[551px]:grid-cols-2 grid-cols-1 gap-5 pb-5"
className={`${gridColClass} grid gap-5 pb-5`}
>
{links?.map((e, i) => {
return (
<LinkMasonry
key={i}
link={e}
count={i}
flipDropdown={i === links.length - 1}
editMode={editMode}
columns={columnCount}
/>
);
})}
@@ -142,17 +248,9 @@ export function ListView({
placeHolderRef?: any;
}) {
return (
<div className="flex gap-1 flex-col">
<div className="flex flex-col">
{links?.map((e, i) => {
return (
<LinkList
key={i}
link={e}
count={i}
flipDropdown={i === links.length - 1}
editMode={editMode}
/>
);
return <LinkList key={i} link={e} count={i} editMode={editMode} />;
})}
{(hasNextPage || isLoading) &&
@@ -161,13 +259,13 @@ export function ListView({
<div
ref={e === 1 ? placeHolderRef : undefined}
key={i}
className="flex gap-4 p-4"
className="flex gap-2 py-2 px-1"
>
<div className="skeleton h-16 w-16"></div>
<div className="flex flex-col gap-4 w-full">
<div className="skeleton h-3 w-2/3"></div>
<div className="skeleton h-3 w-full"></div>
<div className="skeleton h-3 w-1/3"></div>
<div className="skeleton h-12 w-12"></div>
<div className="flex flex-col gap-3 w-full">
<div className="skeleton h-2 w-2/3"></div>
<div className="skeleton h-2 w-full"></div>
<div className="skeleton h-2 w-1/3"></div>
</div>
</div>
);

View File

@@ -41,7 +41,7 @@ export default function MobileNavigation({}: Props) {
<i className="bi-plus text-5xl pointer-events-none"></i>
</span>
</div>
<ul className="dropdown-content z-[1] menu shadow bg-base-200 border border-neutral-content rounded-box w-40 mb-1 -ml-12">
<ul className="dropdown-content z-[1] menu shadow bg-base-200 border border-neutral-content rounded-box mb-1 -ml-12">
<li>
<div
onClick={() => {
@@ -50,6 +50,7 @@ export default function MobileNavigation({}: Props) {
}}
tabIndex={0}
role="button"
className="whitespace-nowrap"
>
{t("new_link")}
</div>
@@ -62,6 +63,7 @@ export default function MobileNavigation({}: Props) {
}}
tabIndex={0}
role="button"
className="whitespace-nowrap"
>
{t("upload_file")}
</div>
@@ -74,6 +76,7 @@ export default function MobileNavigation({}: Props) {
}}
tabIndex={0}
role="button"
className="whitespace-nowrap"
>
{t("new_collection")}
</div>
@@ -84,15 +87,13 @@ export default function MobileNavigation({}: Props) {
<MobileNavigationButton href={`/collections`} icon={"bi-folder"} />
</div>
</div>
{newLinkModal ? (
<NewLinkModal onClose={() => setNewLinkModal(false)} />
) : undefined}
{newCollectionModal ? (
{newLinkModal && <NewLinkModal onClose={() => setNewLinkModal(false)} />}
{newCollectionModal && (
<NewCollectionModal onClose={() => setNewCollectionModal(false)} />
) : undefined}
{uploadFileModal ? (
)}
{uploadFileModal && (
<UploadFileModal onClose={() => setUploadFileModal(false)} />
) : undefined}
)}
</>
);
}

View File

@@ -1,6 +1,7 @@
import React, { MouseEventHandler, ReactNode, useEffect } from "react";
import ClickAwayHandler from "@/components/ClickAwayHandler";
import { Drawer } from "vaul";
import useWindowDimensions from "@/hooks/useWindowDimensions";
type Props = {
toggleModal: Function;
@@ -16,9 +17,10 @@ export default function Modal({
dismissible = true,
}: 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,32 +30,29 @@ export default function Modal({
}
}, []);
if (window.innerWidth < 640) {
if (width < 640) {
return (
<Drawer.Root
open={drawerIsOpen}
onClose={() => dismissible && setTimeout(() => toggleModal(), 100)}
onClose={() => dismissible && setDrawerIsOpen(false)}
onAnimationEnd={(isOpen) => !isOpen && toggleModal()}
dismissible={dismissible}
>
<Drawer.Portal>
<Drawer.Overlay className="fixed inset-0 bg-black/40" />
<ClickAwayHandler
onClickOutside={() => dismissible && setDrawerIsOpen(false)}
>
<Drawer.Content className="flex flex-col rounded-t-2xl min-h-max mt-24 fixed bottom-0 left-0 right-0 z-30">
<Drawer.Content className="flex flex-col rounded-t-2xl h-[90%] mt-24 fixed bottom-0 left-0 right-0 z-30">
<div
className="p-4 bg-base-100 rounded-t-2xl flex-1 border-neutral-content border-t overflow-y-auto"
data-testid="mobile-modal-container"
>
<div
className="p-4 bg-base-100 rounded-t-2xl flex-1 border-neutral-content border-t overflow-y-auto"
data-testid="mobile-modal-container"
>
<div
className="mx-auto w-12 h-1.5 flex-shrink-0 rounded-full bg-neutral mb-5"
data-testid="mobile-modal-slider"
/>
className="mx-auto w-12 h-1.5 flex-shrink-0 rounded-full bg-neutral mb-5"
data-testid="mobile-modal-slider"
/>
{children}
</div>
</Drawer.Content>
</ClickAwayHandler>
{children}
</div>
</Drawer.Content>
</Drawer.Portal>
</Drawer.Root>
);

View File

@@ -46,6 +46,7 @@ export default function BulkEditLinksModal({ onClose }: Props) {
},
{
onSettled: (data, error) => {
setSubmitLoader(false);
toast.dismiss(load);
if (error) {
@@ -58,8 +59,6 @@ export default function BulkEditLinksModal({ onClose }: Props) {
},
}
);
setSubmitLoader(false);
}
};

View File

@@ -44,6 +44,7 @@ export default function DeleteCollectionModal({
deleteCollection.mutateAsync(collection.id as number, {
onSettled: (data, error) => {
setSubmitLoader(false);
toast.dismiss(load);
if (error) {
@@ -55,8 +56,6 @@ export default function DeleteCollectionModal({
}
},
});
setSubmitLoader(false);
}
};

View File

@@ -0,0 +1,60 @@
import React, { useEffect, useState } from "react";
import Modal from "../Modal";
import Button from "../ui/Button";
import { useTranslation } from "next-i18next";
import toast from "react-hot-toast";
import { RssSubscription } from "@prisma/client";
import { useDeleteRssSubscription } from "@/hooks/store/rss";
type Props = {
onClose: Function;
rssSubscription: RssSubscription;
};
export default function DeleteRssSubscriptionModal({
onClose,
rssSubscription,
}: Props) {
const { t } = useTranslation();
const [subscription, setSubscription] =
useState<RssSubscription>(rssSubscription);
const deleteRssSubscription = useDeleteRssSubscription();
useEffect(() => {
setSubscription(rssSubscription);
}, []);
const submit = async () => {
const load = toast.loading(t("deleting"));
await deleteRssSubscription.mutateAsync(subscription.id, {
onSettled: (_, error) => {
toast.dismiss(load);
if (error) {
toast.error(error.message);
} else {
onClose();
toast.success(t("rss_subscription_deleted"));
}
},
});
};
return (
<Modal toggleModal={onClose}>
<p className="text-xl font-thin text-red-500">{t("delete_link")}</p>
<div className="divider mb-3 mt-1"></div>
<div className="flex flex-col gap-3">
<p>{t("rss_deletion_confirmation")}</p>
<Button className="ml-auto" intent="destructive" onClick={submit}>
<i className="bi-trash text-xl" />
{t("delete")}
</Button>
</div>
</Modal>
);
}

View File

@@ -3,6 +3,8 @@ import Button from "../ui/Button";
import { useTranslation } from "next-i18next";
import { useDeleteUser } from "@/hooks/store/admin/users";
import { useState } from "react";
import { useSession } from "next-auth/react";
import { useConfig } from "@/hooks/store/config";
type Props = {
onClose: Function;
@@ -23,31 +25,43 @@ export default function DeleteUserModal({ onClose, userId }: Props) {
onSuccess: () => {
onClose();
},
onSettled: (data, error) => {
setSubmitLoader(false);
},
});
setSubmitLoader(false);
}
};
const { data } = useSession();
const { data: config } = useConfig();
const isAdmin = data?.user?.id === config?.ADMIN;
return (
<Modal toggleModal={onClose}>
<p className="text-xl font-thin text-red-500">{t("delete_user")}</p>
<p className="text-xl font-thin text-red-500">
{isAdmin ? t("delete_user") : t("remove_user")}
</p>
<div className="divider mb-3 mt-1"></div>
<div className="flex flex-col gap-3">
<p>{t("confirm_user_deletion")}</p>
<p>{t("confirm_user_removal_desc")}</p>
<div role="alert" className="alert alert-warning">
<i className="bi-exclamation-triangle text-xl" />
<span>
<b>{t("warning")}:</b> {t("irreversible_action_warning")}
</span>
</div>
{isAdmin && (
<div role="alert" className="alert alert-warning">
<i className="bi-exclamation-triangle text-xl" />
<span>
<b>{t("warning")}:</b> {t("irreversible_action_warning")}
</span>
</div>
)}
<Button className="ml-auto" intent="destructive" onClick={submit}>
<i className="bi-trash text-xl" />
{t("delete_confirmation")}
{isAdmin ? t("delete_confirmation") : t("confirm")}
</Button>
</div>
</Modal>

View File

@@ -1,11 +1,13 @@
import React, { useState } from "react";
import TextInput from "@/components/TextInput";
import { HexColorPicker } from "react-colorful";
import { CollectionIncludingMembersAndLinkCount } from "@/types/global";
import Modal from "../Modal";
import { useTranslation } from "next-i18next";
import { useUpdateCollection } from "@/hooks/store/collections";
import toast from "react-hot-toast";
import IconPicker from "../IconPicker";
import { IconWeight } from "@phosphor-icons/react";
import oklchVariableToHex from "@/lib/client/oklchVariableToHex";
type Props = {
onClose: Function;
@@ -34,6 +36,7 @@ export default function EditCollectionModal({
await updateCollection.mutateAsync(collection, {
onSettled: (data, error) => {
setSubmitLoader(false);
toast.dismiss(load);
if (error) {
@@ -44,8 +47,6 @@ export default function EditCollectionModal({
}
},
});
setSubmitLoader(false);
}
};
@@ -56,10 +57,32 @@ export default function EditCollectionModal({
<div className="divider mb-3 mt-1"></div>
<div className="flex flex-col gap-3">
<div className="flex flex-col sm:flex-row gap-3">
<div className="w-full">
<p className="mb-2">{t("name")}</p>
<div className="flex flex-col gap-3">
<div className="flex flex-col gap-3">
<div className="flex gap-3 items-end">
<IconPicker
color={collection.color}
setColor={(color: string) =>
setCollection({ ...collection, color })
}
weight={(collection.iconWeight || "regular") as IconWeight}
setWeight={(iconWeight: string) =>
setCollection({ ...collection, iconWeight })
}
iconName={collection.icon as string}
setIconName={(icon: string) =>
setCollection({ ...collection, icon })
}
reset={() =>
setCollection({
...collection,
color: oklchVariableToHex("--p"),
icon: "",
iconWeight: "",
})
}
/>
<div className="w-full">
<p className="mb-2">{t("name")}</p>
<TextInput
className="bg-base-200"
value={collection.name}
@@ -68,38 +91,13 @@ export default function EditCollectionModal({
setCollection({ ...collection, name: e.target.value })
}
/>
<div>
<p className="w-full mb-2">{t("color")}</p>
<div className="color-picker flex justify-between items-center">
<HexColorPicker
color={collection.color}
onChange={(color) =>
setCollection({ ...collection, color })
}
/>
<div className="flex flex-col gap-2 items-center w-32">
<i
className="bi-folder-fill text-5xl"
style={{ color: collection.color }}
></i>
<div
className="btn btn-ghost btn-xs"
onClick={() =>
setCollection({ ...collection, color: "#0ea5e9" })
}
>
{t("reset")}
</div>
</div>
</div>
</div>
</div>
</div>
<div className="w-full">
<p className="mb-2">{t("description")}</p>
<textarea
className="w-full h-[13rem] resize-none border rounded-md duration-100 bg-base-200 p-2 outline-none border-neutral-content focus:border-primary"
className="w-full h-32 resize-none border rounded-md duration-100 bg-base-200 p-2 outline-none border-neutral-content focus:border-primary"
placeholder={t("collection_description_placeholder")}
value={collection.description}
onChange={(e) =>

View File

@@ -1,7 +1,11 @@
import React, { useEffect, useState } from "react";
import TextInput from "@/components/TextInput";
import toast from "react-hot-toast";
import { CollectionIncludingMembersAndLinkCount, Member } from "@/types/global";
import {
AccountSettings,
CollectionIncludingMembersAndLinkCount,
Member,
} from "@/types/global";
import getPublicUserData from "@/lib/client/getPublicUserData";
import usePermissions from "@/hooks/usePermissions";
import ProfilePhoto from "../ProfilePhoto";
@@ -11,6 +15,8 @@ import { dropdownTriggerer } from "@/lib/client/utils";
import { useTranslation } from "next-i18next";
import { useUpdateCollection } from "@/hooks/store/collections";
import { useUser } from "@/hooks/store/user";
import CopyButton from "../CopyButton";
import { useRouter } from "next/router";
type Props = {
onClose: Function;
@@ -40,6 +46,7 @@ export default function EditCollectionSharingModal({
await updateCollection.mutateAsync(collection, {
onSettled: (data, error) => {
setSubmitLoader(false);
toast.dismiss(load);
if (error) {
@@ -50,8 +57,6 @@ export default function EditCollectionSharingModal({
}
},
});
setSubmitLoader(false);
}
};
@@ -62,17 +67,11 @@ export default function EditCollectionSharingModal({
const publicCollectionURL = `${currentURL.origin}/public/collections/${collection.id}`;
const [memberUsername, setMemberUsername] = useState("");
const [memberIdentifier, setMemberIdentifier] = useState("");
const [collectionOwner, setCollectionOwner] = useState({
id: null as unknown as number,
name: "",
username: "",
image: "",
archiveAsScreenshot: undefined as unknown as boolean,
archiveAsMonolith: undefined as unknown as boolean,
archiveAsPDF: undefined as unknown as boolean,
});
const [collectionOwner, setCollectionOwner] = useState<
Partial<AccountSettings>
>({});
useEffect(() => {
const fetchOwner = async () => {
@@ -93,19 +92,25 @@ export default function EditCollectionSharingModal({
members: [...collection.members, newMember],
});
setMemberUsername("");
setMemberIdentifier("");
};
const router = useRouter();
const isPublicRoute = router.pathname.startsWith("/public") ? true : false;
return (
<Modal toggleModal={onClose}>
<p className="text-xl font-thin">
{permissions === true ? t("share_and_collaborate") : t("team")}
{permissions === true && !isPublicRoute
? t("share_and_collaborate")
: t("team")}
</p>
<div className="divider mb-3 mt-1"></div>
<div className="flex flex-col gap-3">
{permissions === true && (
{permissions === true && !isPublicRoute && (
<div>
<p>{t("make_collection_public")}</p>
@@ -132,43 +137,35 @@ export default function EditCollectionSharingModal({
</div>
)}
{collection.isPublic ? (
<div className={permissions === true ? "pl-5" : ""}>
<p className="mb-2">{t("sharable_link_guide")}</p>
<div
onClick={() => {
try {
navigator.clipboard
.writeText(publicCollectionURL)
.then(() => toast.success(t("copied")));
} catch (err) {
console.log(err);
}
}}
className="w-full hide-scrollbar overflow-x-auto whitespace-nowrap rounded-md p-2 bg-base-200 border-neutral-content border-solid border outline-none hover:border-primary dark:hover:border-primary duration-100 cursor-text"
>
{collection.isPublic && (
<div>
<p className="mb-2">{t("sharable_link")}</p>
<div className="w-full hide-scrollbar overflow-x-auto whitespace-nowrap rounded-md p-2 bg-base-200 border-neutral-content border-solid border flex items-center gap-2 justify-between">
{publicCollectionURL}
<CopyButton text={publicCollectionURL} />
</div>
</div>
) : null}
)}
{permissions === true && <div className="divider my-3"></div>}
{permissions === true && !isPublicRoute && (
<div className="divider my-3"></div>
)}
{permissions === true && (
{permissions === true && !isPublicRoute && (
<>
<p>{t("members")}</p>
<div className="flex items-center gap-2">
<TextInput
value={memberUsername || ""}
value={memberIdentifier || ""}
className="bg-base-200"
placeholder={t("members_username_placeholder")}
onChange={(e) => setMemberUsername(e.target.value)}
placeholder={t("add_member_placeholder")}
onChange={(e) => setMemberIdentifier(e.target.value)}
onKeyDown={(e) =>
e.key === "Enter" &&
addMemberToCollection(
user.username as string,
memberUsername || "",
user,
memberIdentifier.replace(/^@/, "") || "",
collection,
setMemberState,
t
@@ -179,8 +176,8 @@ export default function EditCollectionSharingModal({
<div
onClick={() =>
addMemberToCollection(
user.username as string,
memberUsername || "",
user,
memberIdentifier.replace(/^@/, "") || "",
collection,
setMemberState,
t
@@ -266,7 +263,7 @@ export default function EditCollectionSharingModal({
</div>
<div className={"flex items-center gap-2"}>
{permissions === true ? (
{permissions === true && !isPublicRoute ? (
<div className="dropdown dropdown-bottom dropdown-end">
<div
tabIndex={0}
@@ -277,7 +274,7 @@ export default function EditCollectionSharingModal({
{roleLabel}
<i className="bi-chevron-down"></i>
</div>
<ul className="dropdown-content z-[30] menu shadow bg-base-200 border border-neutral-content rounded-xl w-64 mt-1">
<ul className="dropdown-content z-[30] menu shadow bg-base-200 border border-neutral-content rounded-xl mt-1">
<li>
<label
className="label cursor-pointer flex justify-start"
@@ -316,10 +313,12 @@ export default function EditCollectionSharingModal({
}}
/>
<div>
<p className="font-bold">
<p className="font-bold whitespace-nowrap">
{t("viewer")}
</p>
<p>{t("viewer_desc")}</p>
<p className="whitespace-nowrap">
{t("viewer_desc")}
</p>
</div>
</label>
</li>
@@ -361,10 +360,12 @@ export default function EditCollectionSharingModal({
}}
/>
<div>
<p className="font-bold">
<p className="font-bold whitespace-nowrap">
{t("contributor")}
</p>
<p>{t("contributor_desc")}</p>
<p className="whitespace-nowrap">
{t("contributor_desc")}
</p>
</div>
</label>
</li>
@@ -406,10 +407,12 @@ export default function EditCollectionSharingModal({
}}
/>
<div>
<p className="font-bold">
<p className="font-bold whitespace-nowrap">
{t("admin")}
</p>
<p>{t("admin_desc")}</p>
<p className="whitespace-nowrap">
{t("admin_desc")}
</p>
</div>
</label>
</li>
@@ -421,7 +424,7 @@ export default function EditCollectionSharingModal({
</p>
)}
{permissions === true && (
{permissions === true && !isPublicRoute && (
<i
className={
"bi-x text-xl btn btn-sm btn-square btn-ghost text-neutral hover:text-red-500 dark:hover:text-red-500 duration-100 cursor-pointer"
@@ -452,7 +455,7 @@ export default function EditCollectionSharingModal({
</>
)}
{permissions === true && (
{permissions === true && !isPublicRoute && (
<button
className="btn btn-accent dark:border-violet-400 text-white w-fit ml-auto mt-3"
onClick={submit}

View File

@@ -1,154 +0,0 @@
import React, { useEffect, useState } from "react";
import CollectionSelection from "@/components/InputSelect/CollectionSelection";
import TagSelection from "@/components/InputSelect/TagSelection";
import TextInput from "@/components/TextInput";
import unescapeString from "@/lib/client/unescapeString";
import { LinkIncludingShortenedCollectionAndTags } from "@/types/global";
import Link from "next/link";
import Modal from "../Modal";
import { useTranslation } from "next-i18next";
import { useUpdateLink } from "@/hooks/store/links";
import toast from "react-hot-toast";
type Props = {
onClose: Function;
activeLink: LinkIncludingShortenedCollectionAndTags;
};
export default function EditLinkModal({ onClose, activeLink }: Props) {
const { t } = useTranslation();
const [link, setLink] =
useState<LinkIncludingShortenedCollectionAndTags>(activeLink);
let shortenedURL;
try {
shortenedURL = new URL(link.url || "").host.toLowerCase();
} catch (error) {
console.log(error);
}
const [submitLoader, setSubmitLoader] = useState(false);
const updateLink = useUpdateLink();
const setCollection = (e: any) => {
if (e?.__isNew__) e.value = null;
setLink({
...link,
collection: { id: e?.value, name: e?.label, ownerId: e?.ownerId },
});
};
const setTags = (e: any) => {
const tagNames = e.map((e: any) => ({ name: e.label }));
setLink({ ...link, tags: tagNames });
};
useEffect(() => {
setLink(activeLink);
}, []);
const submit = async () => {
if (!submitLoader) {
setSubmitLoader(true);
const load = toast.loading(t("updating"));
await updateLink.mutateAsync(link, {
onSettled: (data, error) => {
toast.dismiss(load);
if (error) {
toast.error(error.message);
} else {
onClose();
toast.success(t("updated"));
}
},
});
setSubmitLoader(false);
}
};
return (
<Modal toggleModal={onClose}>
<p className="text-xl font-thin">{t("edit_link")}</p>
<div className="divider mb-3 mt-1"></div>
{link.url ? (
<Link
href={link.url}
className="truncate text-neutral flex gap-2 mb-5 w-fit max-w-full"
title={link.url}
target="_blank"
>
<i className="bi-link-45deg text-xl" />
<p>{shortenedURL}</p>
</Link>
) : undefined}
<div className="w-full">
<p className="mb-2">{t("name")}</p>
<TextInput
value={link.name}
onChange={(e) => setLink({ ...link, name: e.target.value })}
placeholder={t("placeholder_example_link")}
className="bg-base-200"
/>
</div>
<div className="mt-5">
<div className="grid sm:grid-cols-2 gap-3">
<div>
<p className="mb-2">{t("collection")}</p>
{link.collection.name ? (
<CollectionSelection
onChange={setCollection}
defaultValue={
link.collection.id
? { value: link.collection.id, label: link.collection.name }
: { value: null as unknown as number, label: "Unorganized" }
}
creatable={false}
/>
) : null}
</div>
<div>
<p className="mb-2">{t("tags")}</p>
<TagSelection
onChange={setTags}
defaultValue={link.tags.map((e) => ({
label: e.name,
value: e.id,
}))}
/>
</div>
<div className="sm:col-span-2">
<p className="mb-2">{t("description")}</p>
<textarea
value={unescapeString(link.description) as string}
onChange={(e) =>
setLink({ ...link, description: e.target.value })
}
placeholder={t("link_description_placeholder")}
className="resize-none w-full rounded-md p-2 border-neutral-content bg-base-200 focus:border-sky-300 dark:focus:border-sky-600 border-solid border outline-none duration-100"
/>
</div>
</div>
</div>
<div className="flex justify-end items-center mt-5">
<button
className="btn btn-accent dark:border-violet-400 text-white"
onClick={submit}
>
{t("save_changes")}
</button>
</div>
</Modal>
);
}

View File

@@ -0,0 +1,131 @@
import toast from "react-hot-toast";
import Modal from "../Modal";
import TextInput from "../TextInput";
import { FormEvent, useState } from "react";
import { useTranslation, Trans } from "next-i18next";
import { useAddUser } from "@/hooks/store/admin/users";
import Link from "next/link";
import { signIn } from "next-auth/react";
type Props = {
onClose: Function;
};
type FormData = {
username?: string;
email?: string;
invite: boolean;
};
const emailEnabled = process.env.NEXT_PUBLIC_EMAIL_PROVIDER === "true";
export default function InviteModal({ onClose }: Props) {
const { t } = useTranslation();
const addUser = useAddUser();
const [form, setForm] = useState<FormData>({
username: emailEnabled ? undefined : "",
email: emailEnabled ? "" : undefined,
invite: true,
});
const [submitLoader, setSubmitLoader] = useState(false);
async function submit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
if (!submitLoader) {
const checkFields = () => {
if (emailEnabled) {
return form.email !== "";
} else {
return form.username !== "";
}
};
if (checkFields()) {
setSubmitLoader(true);
await addUser.mutateAsync(form, {
onSettled: () => {
setSubmitLoader(false);
},
onSuccess: async () => {
await signIn("invite", {
email: form.email,
callbackUrl: "/member-onboarding",
redirect: false,
});
onClose();
},
});
} else {
toast.error(t("fill_all_fields_error"));
}
}
}
return (
<Modal toggleModal={onClose}>
<p className="text-xl font-thin">{t("invite_user")}</p>
<div className="divider mb-3 mt-1"></div>
<p className="mb-3">{t("invite_user_desc")}</p>
<form onSubmit={submit}>
{emailEnabled ? (
<div>
<TextInput
placeholder={t("placeholder_email")}
className="bg-base-200"
onChange={(e) => setForm({ ...form, email: e.target.value })}
value={form.email}
/>
</div>
) : (
<div>
<p className="mb-2">
{t("username")}{" "}
{emailEnabled && (
<span className="text-xs text-neutral">{t("optional")}</span>
)}
</p>
<TextInput
placeholder={t("placeholder_john")}
className="bg-base-200"
onChange={(e) => setForm({ ...form, username: e.target.value })}
value={form.username}
/>
</div>
)}
<div role="note" className="alert alert-note mt-5">
<i className="bi-exclamation-triangle text-xl" />
<span>
<p>{t("invite_user_note")}</p>
<p className="mb-1">
{t("invite_user_price", {
price: 4,
priceAnnual: 36,
})}
</p>
<Link
href="https://docs.linkwarden.app/billing/seats#how-seats-affect-billing"
className="font-semibold whitespace-nowrap hover:opacity-80 duration-100"
target="_blank"
>
{t("learn_more")} <i className="bi-box-arrow-up-right"></i>
</Link>
</span>
</div>
<div className="flex justify-between items-center mt-5">
<button
className="btn btn-accent dark:border-violet-400 text-white ml-auto"
type="submit"
>
{t("send_invitation")}
</button>
</div>
</form>
</Modal>
);
}

View File

@@ -0,0 +1,177 @@
import React, { useState } from "react";
import { LinkIncludingShortenedCollectionAndTags } from "@/types/global";
import { useTranslation } from "next-i18next";
import { useDeleteLink } from "@/hooks/store/links";
import Drawer from "../Drawer";
import LinkDetails from "../LinkDetails";
import Link from "next/link";
import usePermissions from "@/hooks/usePermissions";
import { useRouter } from "next/router";
import { dropdownTriggerer } from "@/lib/client/utils";
import toast from "react-hot-toast";
import Tab from "../Tab";
type Props = {
onClose: Function;
onDelete: Function;
onUpdateArchive: Function;
onPin: Function;
link: LinkIncludingShortenedCollectionAndTags;
activeMode?: "view" | "edit";
};
export default function LinkModal({
onClose,
onDelete,
onUpdateArchive,
onPin,
link,
activeMode,
}: Props) {
const { t } = useTranslation();
const permissions = usePermissions(link.collection.id as number);
const router = useRouter();
const isPublicRoute = router.pathname.startsWith("/public") ? true : false;
const deleteLink = useDeleteLink();
const [mode, setMode] = useState<"view" | "edit">(activeMode || "view");
return (
<Drawer
toggleDrawer={onClose}
className="sm:h-screen items-center relative"
>
<div className="absolute top-3 left-0 right-0 flex justify-between px-3">
<div
className="bi-x text-xl btn btn-sm btn-circle text-base-content opacity-50 hover:opacity-100 z-10"
onClick={() => onClose()}
></div>
{(permissions === true || permissions?.canUpdate) && !isPublicRoute && (
<Tab
tabs={[
{ name: "View" },
{
name: "Edit",
},
].map((tab) => ({
name: tab.name,
}))}
activeTabIndex={mode === "view" ? 0 : 1}
setActiveTabIndex={(index: any) =>
setMode(index === 0 ? "view" : "edit")
}
className="w-fit absolute left-1/2 -translate-x-1/2 rounded-full bg-base-100/50 text-sm shadow-md z-10"
/>
)}
<div className="flex gap-2">
{!isPublicRoute && (
<div className={`dropdown dropdown-end z-20`}>
<div
tabIndex={0}
role="button"
onMouseDown={dropdownTriggerer}
className="btn btn-sm btn-circle text-base-content opacity-50 hover:opacity-100 z-10"
>
<i title="More" className="bi-three-dots text-xl" />
</div>
<ul
className={`dropdown-content z-[20] menu shadow bg-base-200 border border-neutral-content rounded-box`}
>
{
<li>
<div
role="button"
tabIndex={0}
onClick={() => {
(document?.activeElement as HTMLElement)?.blur();
onPin();
}}
className="whitespace-nowrap"
>
{link?.pinnedBy && link.pinnedBy[0]
? t("unpin")
: t("pin_to_dashboard")}
</div>
</li>
}
{link.type === "url" &&
(permissions === true || permissions?.canUpdate) && (
<li>
<div
role="button"
tabIndex={0}
onClick={() => {
(document?.activeElement as HTMLElement)?.blur();
onUpdateArchive();
}}
className="whitespace-nowrap"
>
{t("refresh_preserved_formats")}
</div>
</li>
)}
{(permissions === true || permissions?.canDelete) && (
<li>
<div
role="button"
tabIndex={0}
onClick={async (e) => {
(document?.activeElement as HTMLElement)?.blur();
console.log(e.shiftKey);
if (e.shiftKey) {
const load = toast.loading(t("deleting"));
await deleteLink.mutateAsync(link.id as number, {
onSettled: (data, error) => {
toast.dismiss(load);
if (error) {
toast.error(error.message);
} else {
toast.success(t("deleted"));
}
},
});
onClose();
} else {
onDelete();
onClose();
}
}}
className="whitespace-nowrap"
>
{t("delete")}
</div>
</li>
)}
</ul>
</div>
)}
{link.url && (
<Link
href={link.url}
target="_blank"
className="bi-box-arrow-up-right btn-circle text-base-content opacity-50 hover:opacity-100 btn btn-sm select-none z-10"
></Link>
)}
</div>
</div>
<div className="w-full">
<LinkDetails
activeLink={link}
className="sm:mt-0 -mt-11"
mode={mode}
setMode={(mode: "view" | "edit") => setMode(mode)}
onUpdateArchive={onUpdateArchive}
/>
</div>
</Drawer>
);
}

View File

@@ -1,12 +1,14 @@
import React, { useEffect, useState } from "react";
import TextInput from "@/components/TextInput";
import { HexColorPicker } from "react-colorful";
import { Collection } from "@prisma/client";
import Modal from "../Modal";
import { CollectionIncludingMembersAndLinkCount } from "@/types/global";
import { useTranslation } from "next-i18next";
import { useCreateCollection } from "@/hooks/store/collections";
import toast from "react-hot-toast";
import IconPicker from "../IconPicker";
import { IconWeight } from "@phosphor-icons/react";
import oklchVariableToHex from "@/lib/client/oklchVariableToHex";
type Props = {
onClose: Function;
@@ -15,18 +17,19 @@ type Props = {
export default function NewCollectionModal({ onClose, parent }: Props) {
const { t } = useTranslation();
const initial = {
parentId: parent?.id,
name: "",
description: "",
color: "#0ea5e9",
color: oklchVariableToHex("--p"), // Use resolved color
} as Partial<Collection>;
const [collection, setCollection] = useState<Partial<Collection>>(initial);
useEffect(() => {
setCollection(initial);
}, []);
}, [parent]);
const [submitLoader, setSubmitLoader] = useState(false);
@@ -42,6 +45,7 @@ export default function NewCollectionModal({ onClose, parent }: Props) {
await createCollection.mutateAsync(collection, {
onSettled: (data, error) => {
setSubmitLoader(false);
toast.dismiss(load);
if (error) {
@@ -52,8 +56,6 @@ export default function NewCollectionModal({ onClose, parent }: Props) {
}
},
});
setSubmitLoader(false);
};
return (
@@ -72,10 +74,32 @@ export default function NewCollectionModal({ onClose, parent }: Props) {
<div className="divider mb-3 mt-1"></div>
<div className="flex flex-col gap-3">
<div className="flex flex-col sm:flex-row gap-3">
<div className="w-full">
<p className="mb-2">{t("name")}</p>
<div className="flex flex-col gap-2">
<div className="flex flex-col gap-3">
<div className="flex gap-3 items-end">
<IconPicker
color={collection.color || oklchVariableToHex("--p")}
setColor={(color: string) =>
setCollection({ ...collection, color })
}
weight={(collection.iconWeight || "regular") as IconWeight}
setWeight={(iconWeight: string) =>
setCollection({ ...collection, iconWeight })
}
iconName={collection.icon as string}
setIconName={(icon: string) =>
setCollection({ ...collection, icon })
}
reset={() =>
setCollection({
...collection,
color: oklchVariableToHex("--p"),
icon: "",
iconWeight: "",
})
}
/>
<div className="w-full">
<p className="mb-2">{t("name")}</p>
<TextInput
className="bg-base-200"
value={collection.name}
@@ -84,38 +108,13 @@ export default function NewCollectionModal({ onClose, parent }: Props) {
setCollection({ ...collection, name: e.target.value })
}
/>
<div>
<p className="w-full mb-2">{t("color")}</p>
<div className="color-picker flex justify-between items-center">
<HexColorPicker
color={collection.color}
onChange={(color) =>
setCollection({ ...collection, color })
}
/>
<div className="flex flex-col gap-2 items-center w-32">
<i
className={"bi-folder-fill text-5xl"}
style={{ color: collection.color }}
></i>
<div
className="btn btn-ghost btn-xs"
onClick={() =>
setCollection({ ...collection, color: "#0ea5e9" })
}
>
{t("reset")}
</div>
</div>
</div>
</div>
</div>
</div>
<div className="w-full">
<p className="mb-2">{t("description")}</p>
<textarea
className="w-full h-[13rem] resize-none border rounded-md duration-100 bg-base-200 p-2 outline-none border-neutral-content focus:border-primary"
className="w-full h-32 resize-none border rounded-md duration-100 bg-base-200 p-2 outline-none border-neutral-content focus:border-primary"
placeholder={t("collection_description_placeholder")}
value={collection.description}
onChange={(e) =>

View File

@@ -3,14 +3,13 @@ import CollectionSelection from "@/components/InputSelect/CollectionSelection";
import TagSelection from "@/components/InputSelect/TagSelection";
import TextInput from "@/components/TextInput";
import unescapeString from "@/lib/client/unescapeString";
import { LinkIncludingShortenedCollectionAndTags } from "@/types/global";
import { useSession } from "next-auth/react";
import { useRouter } from "next/router";
import Modal from "../Modal";
import { useTranslation } from "next-i18next";
import { useCollections } from "@/hooks/store/collections";
import { useAddLink } from "@/hooks/store/links";
import toast from "react-hot-toast";
import { PostLinkSchemaType } from "@/lib/shared/schemaValidation";
type Props = {
onClose: Function;
@@ -18,27 +17,19 @@ type Props = {
export default function NewLinkModal({ onClose }: Props) {
const { t } = useTranslation();
const { data } = useSession();
const initial = {
name: "",
url: "",
description: "",
type: "url",
tags: [],
preview: "",
image: "",
pdf: "",
readable: "",
monolith: "",
textContent: "",
collection: {
id: undefined,
name: "",
ownerId: data?.user.id as number,
},
} as LinkIncludingShortenedCollectionAndTags;
} as PostLinkSchemaType;
const [link, setLink] =
useState<LinkIncludingShortenedCollectionAndTags>(initial);
const [link, setLink] = useState<PostLinkSchemaType>(initial);
const addLink = useAddLink();
@@ -48,40 +39,38 @@ export default function NewLinkModal({ onClose }: Props) {
const { data: collections = [] } = useCollections();
const setCollection = (e: any) => {
if (e?.__isNew__) e.value = null;
if (e?.__isNew__) e.value = undefined;
setLink({
...link,
collection: { id: e?.value, name: e?.label, ownerId: e?.ownerId },
collection: { id: e?.value, name: e?.label },
});
};
const setTags = (e: any) => {
const tagNames = e.map((e: any) => ({ name: e.label }));
const setTags = (selectedOptions: any = []) => {
const tagNames = selectedOptions.map((option: any) => ({
name: option.label,
}));
setLink({ ...link, tags: tagNames });
};
useEffect(() => {
if (router.query.id) {
if (router.pathname.startsWith("/collections/") && router.query.id) {
const currentCollection = collections.find(
(e) => e.id == Number(router.query.id)
);
if (
currentCollection &&
currentCollection.ownerId &&
router.asPath.startsWith("/collections/")
)
if (currentCollection && currentCollection.ownerId)
setLink({
...initial,
collection: {
id: currentCollection.id,
name: currentCollection.name,
ownerId: currentCollection.ownerId,
},
});
} else
setLink({
...initial,
collection: { name: "Unorganized", ownerId: data?.user.id as number },
collection: { name: "Unorganized" },
});
}, []);
@@ -93,18 +82,17 @@ export default function NewLinkModal({ onClose }: Props) {
await addLink.mutateAsync(link, {
onSettled: (data, error) => {
setSubmitLoader(false);
toast.dismiss(load);
if (error) {
toast.error(error.message);
toast.error(t(error.message));
} else {
onClose();
toast.success(t("link_created"));
}
},
});
setSubmitLoader(false);
}
};
@@ -124,19 +112,19 @@ export default function NewLinkModal({ onClose }: Props) {
</div>
<div className="sm:col-span-2 col-span-5">
<p className="mb-2">{t("collection")}</p>
{link.collection.name ? (
{link.collection?.name && (
<CollectionSelection
onChange={setCollection}
defaultValue={{
label: link.collection.name,
value: link.collection.id,
value: link.collection?.id,
label: link.collection?.name || "Unorganized",
}}
/>
) : null}
)}
</div>
</div>
<div className={"mt-2"}>
{optionsExpanded ? (
{optionsExpanded && (
<div className="mt-5">
<div className="grid sm:grid-cols-2 gap-3">
<div>
@@ -152,26 +140,28 @@ export default function NewLinkModal({ onClose }: Props) {
<p className="mb-2">{t("tags")}</p>
<TagSelection
onChange={setTags}
defaultValue={link.tags.map((e) => ({
label: e.name,
value: e.id,
}))}
defaultValue={
link.tags?.map((e) => ({
label: e.name,
value: e.id,
})) || []
}
/>
</div>
<div className="sm:col-span-2">
<p className="mb-2">{t("description")}</p>
<textarea
value={unescapeString(link.description) as string}
value={unescapeString(link.description || "") || ""}
onChange={(e) =>
setLink({ ...link, description: e.target.value })
}
placeholder={t("link_description_placeholder")}
className="resize-none w-full rounded-md p-2 border-neutral-content bg-base-200 focus:border-primary border-solid border outline-none duration-100"
className="resize-none w-full h-32 rounded-md p-2 border-neutral-content bg-base-200 focus:border-primary border-solid border outline-none duration-100"
/>
</div>
</div>
</div>
) : undefined}
)}
</div>
<div className="flex justify-between items-center mt-5">
<div

View File

@@ -0,0 +1,107 @@
import React, { useState } from "react";
import Modal from "../Modal";
import { useTranslation } from "next-i18next";
import { useAddRssSubscription } from "@/hooks/store/rss";
import toast from "react-hot-toast";
import TextInput from "../TextInput";
import CollectionSelection from "../InputSelect/CollectionSelection";
type Props = {
onClose: Function;
};
export default function NewRssSubscriptionModal({ onClose }: Props) {
const { t } = useTranslation();
const addRssSubscription = useAddRssSubscription();
const [submitLoader, setSubmitLoader] = useState(false);
const [form, setForm] = useState({
name: "",
url: "",
collectionId: 0,
collectionName: "",
});
const submit = async () => {
if (submitLoader) return;
if (
!form.name ||
!form.url ||
(!form.collectionId && !form.collectionName)
) {
return toast.error(t("fill_all_fields"));
}
setSubmitLoader(true);
const load = toast.loading(t("creating"));
await addRssSubscription.mutateAsync(form, {
onSettled: (_, error) => {
setSubmitLoader(false);
toast.dismiss(load);
if (error) {
toast.error(error.message);
} else {
onClose();
toast.success(t("created"));
}
},
});
};
return (
<Modal toggleModal={onClose}>
<>
<p className="text-xl font-thin">{t("create_rss_subscription")}</p>
<div className="divider mb-3 mt-1"></div>
<div className="flex sm:flex-row flex-col gap-3 items-center">
<div className="w-full">
<label>{t("name")}</label>
<TextInput
type="text"
placeholder="Sample RSS"
className="bg-base-200 mt-2"
value={form.name}
onChange={(e) => setForm({ ...form, name: e.target.value })}
/>
</div>
<div className="w-full">
<label>{t("collection")}</label>
<CollectionSelection
className="mt-2"
onChange={(e: any) => {
if (e?.__isNew__) e.value = undefined;
setForm({
...form,
collectionId: e?.value,
collectionName: e?.label,
});
}}
/>
</div>
</div>
<div className="w-full mt-3">
<label>{t("link")}</label>
<TextInput
type="text"
placeholder="https://example.com/rss"
className="bg-base-200 mt-2"
value={form.url}
onChange={(e) => setForm({ ...form, url: e.target.value })}
/>
</div>
<div className="flex justify-end items-center mt-5">
<button
className="btn btn-accent dark:border-violet-400 text-white"
onClick={submit}
>
{t("create_rss_subscription")}
</button>
</div>
</>
</Modal>
);
}

View File

@@ -7,6 +7,7 @@ import { dropdownTriggerer } from "@/lib/client/utils";
import Button from "../ui/Button";
import { useTranslation } from "next-i18next";
import { useAddToken } from "@/hooks/store/tokens";
import CopyButton from "../CopyButton";
type Props = {
onClose: Function;
@@ -33,6 +34,7 @@ export default function NewTokenModal({ onClose }: Props) {
await addToken.mutateAsync(token, {
onSettled: (data, error) => {
setSubmitLoader(false);
toast.dismiss(load);
if (error) {
@@ -42,8 +44,6 @@ export default function NewTokenModal({ onClose }: Props) {
}
},
});
setSubmitLoader(false);
}
};
@@ -68,21 +68,14 @@ export default function NewTokenModal({ onClose }: Props) {
<div className="flex flex-col justify-center space-y-4">
<p className="text-xl font-thin">{t("access_token_created")}</p>
<p>{t("token_creation_notice")}</p>
<TextInput
spellCheck={false}
value={newToken}
onChange={() => {}}
className="w-full"
/>
<button
onClick={() => {
navigator.clipboard.writeText(newToken);
toast.success(t("copied_to_clipboard"));
}}
className="btn btn-primary w-fit mx-auto"
>
{t("copy_to_clipboard")}
</button>
<div className="relative">
<div className="w-full hide-scrollbar overflow-x-auto whitespace-nowrap rounded-md p-2 bg-base-200 border-neutral-content border-solid border flex items-center gap-2 justify-between pr-14">
{newToken}
<div className="absolute right-0 px-2 border-neutral-content border-solid border-r bg-base-200">
<CopyButton text={newToken} />
</div>
</div>
</div>
</div>
) : (
<>
@@ -115,7 +108,7 @@ export default function NewTokenModal({ onClose }: Props) {
>
{getLabel(token.expires)}
</Button>
<ul className="dropdown-content z-[30] menu shadow bg-base-200 border border-neutral-content rounded-xl w-full sm:w-52 mt-1">
<ul className="dropdown-content z-[30] menu shadow bg-base-200 border border-neutral-content rounded-xl mt-1">
<li>
<label
className="label cursor-pointer flex justify-start"
@@ -135,7 +128,9 @@ export default function NewTokenModal({ onClose }: Props) {
});
}}
/>
<span className="label-text">{t("7_days")}</span>
<span className="label-text whitespace-nowrap">
{t("7_days")}
</span>
</label>
</li>
<li>
@@ -154,7 +149,9 @@ export default function NewTokenModal({ onClose }: Props) {
setToken({ ...token, expires: TokenExpiry.oneMonth });
}}
/>
<span className="label-text">{t("30_days")}</span>
<span className="label-text whitespace-nowrap">
{t("30_days")}
</span>
</label>
</li>
<li>
@@ -176,7 +173,9 @@ export default function NewTokenModal({ onClose }: Props) {
});
}}
/>
<span className="label-text">{t("60_days")}</span>
<span className="label-text whitespace-nowrap">
{t("60_days")}
</span>
</label>
</li>
<li>
@@ -198,7 +197,9 @@ export default function NewTokenModal({ onClose }: Props) {
});
}}
/>
<span className="label-text">{t("90_days")}</span>
<span className="label-text whitespace-nowrap">
{t("90_days")}
</span>
</label>
</li>
<li>
@@ -217,7 +218,9 @@ export default function NewTokenModal({ onClose }: Props) {
setToken({ ...token, expires: TokenExpiry.never });
}}
/>
<span className="label-text">{t("no_expiration")}</span>
<span className="label-text whitespace-nowrap">
{t("no_expiration")}
</span>
</label>
</li>
</ul>

View File

@@ -35,6 +35,9 @@ export default function NewUserModal({ onClose }: Props) {
event.preventDefault();
if (!submitLoader) {
if (form.password.length < 8)
return toast.error(t("password_length_error"));
const checkFields = () => {
if (emailEnabled) {
return form.name !== "" && form.email !== "" && form.password !== "";
@@ -52,9 +55,10 @@ export default function NewUserModal({ onClose }: Props) {
onSuccess: () => {
onClose();
},
onSettled: () => {
setSubmitLoader(false);
},
});
setSubmitLoader(false);
} else {
toast.error(t("fill_all_fields_error"));
}
@@ -79,7 +83,7 @@ export default function NewUserModal({ onClose }: Props) {
/>
</div>
{emailEnabled ? (
{emailEnabled && (
<div>
<p className="mb-2">{t("email")}</p>
<TextInput
@@ -89,7 +93,7 @@ export default function NewUserModal({ onClose }: Props) {
value={form.email}
/>
</div>
) : undefined}
)}
<div>
<p className="mb-2">

View File

@@ -1,248 +0,0 @@
import React, { useEffect, useState } from "react";
import {
LinkIncludingShortenedCollectionAndTags,
ArchivedFormat,
} from "@/types/global";
import toast from "react-hot-toast";
import Link from "next/link";
import Modal from "../Modal";
import { useRouter } from "next/router";
import { useSession } from "next-auth/react";
import {
pdfAvailable,
readabilityAvailable,
monolithAvailable,
screenshotAvailable,
} from "@/lib/shared/getArchiveValidity";
import PreservedFormatRow from "@/components/PreserverdFormatRow";
import getPublicUserData from "@/lib/client/getPublicUserData";
import { useTranslation } from "next-i18next";
import { BeatLoader } from "react-spinners";
import { useUser } from "@/hooks/store/user";
import { useGetLink } from "@/hooks/store/links";
type Props = {
onClose: Function;
link: LinkIncludingShortenedCollectionAndTags;
};
export default function PreservedFormatsModal({ onClose, link }: Props) {
const { t } = useTranslation();
const session = useSession();
const getLink = useGetLink();
const { data: user = {} } = useUser();
const router = useRouter();
let isPublic = router.pathname.startsWith("/public") ? true : undefined;
const [collectionOwner, setCollectionOwner] = useState({
id: null as unknown as number,
name: "",
username: "",
image: "",
archiveAsScreenshot: undefined as unknown as boolean,
archiveAsMonolith: undefined as unknown as boolean,
archiveAsPDF: undefined as unknown as boolean,
});
useEffect(() => {
const fetchOwner = async () => {
if (link.collection.ownerId !== user.id) {
const owner = await getPublicUserData(
link.collection.ownerId as number
);
setCollectionOwner(owner);
} else if (link.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.archiveAsScreenshot as boolean,
archiveAsPDF: user.archiveAsPDF as boolean,
});
}
};
fetchOwner();
}, [link.collection.ownerId]);
const isReady = () => {
return (
link &&
(collectionOwner.archiveAsScreenshot === true
? link.pdf && link.pdf !== "pending"
: true) &&
(collectionOwner.archiveAsMonolith === true
? link.monolith && link.monolith !== "pending"
: true) &&
(collectionOwner.archiveAsPDF === true
? link.pdf && link.pdf !== "pending"
: true) &&
link.readable &&
link.readable !== "pending"
);
};
const atLeastOneFormatAvailable = () => {
return (
screenshotAvailable(link) ||
pdfAvailable(link) ||
readabilityAvailable(link) ||
monolithAvailable(link)
);
};
useEffect(() => {
(async () => {
await getLink.mutateAsync(link.id as number);
})();
let interval: any;
if (!isReady()) {
interval = setInterval(async () => {
await getLink.mutateAsync(link.id as number);
}, 5000);
} else {
if (interval) {
clearInterval(interval);
}
}
return () => {
if (interval) {
clearInterval(interval);
}
};
}, [link?.monolith]);
const updateArchive = async () => {
const load = toast.loading(t("sending_request"));
const response = await fetch(`/api/v1/links/${link?.id}/archive`, {
method: "PUT",
});
const data = await response.json();
toast.dismiss(load);
if (response.ok) {
await getLink.mutateAsync(link?.id as number);
toast.success(t("link_being_archived"));
} else toast.error(data.response);
};
return (
<Modal toggleModal={onClose}>
<p className="text-xl font-thin">{t("preserved_formats")}</p>
<div className="divider mb-2 mt-1"></div>
{screenshotAvailable(link) ||
pdfAvailable(link) ||
readabilityAvailable(link) ||
monolithAvailable(link) ? (
<p className="mb-3">{t("available_formats")}</p>
) : (
""
)}
<div className={`flex flex-col gap-3`}>
{monolithAvailable(link) ? (
<PreservedFormatRow
name={t("webpage")}
icon={"bi-filetype-html"}
format={ArchivedFormat.monolith}
link={link}
downloadable={true}
/>
) : undefined}
{screenshotAvailable(link) ? (
<PreservedFormatRow
name={t("screenshot")}
icon={"bi-file-earmark-image"}
format={
link?.image?.endsWith("png")
? ArchivedFormat.png
: ArchivedFormat.jpeg
}
link={link}
downloadable={true}
/>
) : undefined}
{pdfAvailable(link) ? (
<PreservedFormatRow
name={t("pdf")}
icon={"bi-file-earmark-pdf"}
format={ArchivedFormat.pdf}
link={link}
downloadable={true}
/>
) : undefined}
{readabilityAvailable(link) ? (
<PreservedFormatRow
name={t("readable")}
icon={"bi-file-earmark-text"}
format={ArchivedFormat.readability}
link={link}
/>
) : undefined}
{!isReady() && !atLeastOneFormatAvailable() ? (
<div className={`w-full h-full flex flex-col justify-center p-10`}>
<BeatLoader
color="oklch(var(--p))"
className="mx-auto mb-3"
size={30}
/>
<p className="text-center text-2xl">{t("preservation_in_queue")}</p>
<p className="text-center text-lg">{t("check_back_later")}</p>
</div>
) : !isReady() && atLeastOneFormatAvailable() ? (
<div className={`w-full h-full flex flex-col justify-center p-5`}>
<BeatLoader
color="oklch(var(--p))"
className="mx-auto mb-3"
size={20}
/>
<p className="text-center">{t("there_are_more_formats")}</p>
<p className="text-center text-sm">{t("check_back_later")}</p>
</div>
) : undefined}
<div
className={`flex flex-col sm:flex-row gap-3 items-center justify-center ${
isReady() ? "sm:mt " : ""
}`}
>
<Link
href={`https://web.archive.org/web/${link?.url?.replace(
/(^\w+:|^)\/\//,
""
)}`}
target="_blank"
className="text-neutral duration-100 hover:opacity-60 flex gap-2 w-1/2 justify-center items-center text-sm"
>
<p className="whitespace-nowrap">{t("view_latest_snapshot")}</p>
<i className="bi-box-arrow-up-right" />
</Link>
{link?.collection.ownerId === session.data?.user.id && (
<div className="btn btn-outline" onClick={updateArchive}>
<div>
<p>{t("refresh_preserved_formats")}</p>
<p className="text-xs">
{t("this_deletes_current_preservations")}
</p>
</div>
</div>
)}
</div>
</div>
</Modal>
);
}

View File

@@ -0,0 +1,67 @@
import React, { useState } from "react";
import Modal from "../Modal";
import Button from "../ui/Button";
import { useTranslation } from "next-i18next";
type Props = {
onClose: Function;
submit: Function;
};
export default function SurveyModal({ onClose, submit }: Props) {
const { t } = useTranslation();
const [referer, setReferrer] = useState("rather_not_say");
const [other, setOther] = useState("");
return (
<Modal toggleModal={onClose}>
<p className="text-xl font-thin">{t("quick_survey")}</p>
<div className="divider mb-3 mt-1"></div>
<div className="flex flex-col gap-4">
<p>{t("how_did_you_discover_linkwarden")}</p>
<select
onChange={(e) => {
setReferrer(e.target.value);
setOther("");
}}
className="select border border-neutral-content focus:outline-none focus:border-primary duration-100 w-full bg-base-200 rounded-[0.375rem] min-h-0 h-[2.625rem] leading-4 p-2"
>
<option value="rather_not_say">{t("rather_not_say")}</option>
<option value="search_engine">{t("search_engine")}</option>
<option value="people_recommendation">
{t("people_recommendation")}
</option>
<option value="reddit">{t("reddit")}</option>
<option value="github">{t("github")}</option>
<option value="twitter">{t("twitter")}</option>
<option value="mastodon">{t("mastodon")}</option>
<option value="lemmy">{t("lemmy")}</option>
<option value="other">{t("other")}</option>
</select>
{referer === "other" && (
<input
type="text"
placeholder={t("please_specify")}
onChange={(e) => {
setOther(e.target.value);
}}
value={other}
className="input border border-neutral-content focus:border-primary focus:outline-none duration-100 w-full bg-base-200 rounded-[0.375rem] min-h-0 h-[2.625rem] leading-4 p-2"
/>
)}
<Button
className="ml-auto mt-3"
intent="accent"
onClick={() => submit(referer, other)}
>
{t("submit")}
</Button>
</div>
</Modal>
);
}

View File

@@ -3,17 +3,14 @@ import CollectionSelection from "@/components/InputSelect/CollectionSelection";
import TagSelection from "@/components/InputSelect/TagSelection";
import TextInput from "@/components/TextInput";
import unescapeString from "@/lib/client/unescapeString";
import {
LinkIncludingShortenedCollectionAndTags,
ArchivedFormat,
} from "@/types/global";
import { useSession } from "next-auth/react";
import { useRouter } from "next/router";
import toast from "react-hot-toast";
import Modal from "../Modal";
import { useTranslation } from "next-i18next";
import { useCollections } from "@/hooks/store/collections";
import { useUploadFile } from "@/hooks/store/links";
import { PostLinkSchemaType } from "@/lib/shared/schemaValidation";
import { useConfig } from "@/hooks/store/config";
type Props = {
onClose: Function;
@@ -21,28 +18,20 @@ type Props = {
export default function UploadFileModal({ onClose }: Props) {
const { t } = useTranslation();
const { data } = useSession();
const { data: config } = useConfig();
const initial = {
name: "",
url: "",
description: "",
type: "url",
tags: [],
preview: "",
image: "",
pdf: "",
readable: "",
monolith: "",
textContent: "",
collection: {
id: undefined,
name: "",
ownerId: data?.user.id as number,
},
} as LinkIncludingShortenedCollectionAndTags;
} as PostLinkSchemaType;
const [link, setLink] =
useState<LinkIncludingShortenedCollectionAndTags>(initial);
const [link, setLink] = useState<PostLinkSchemaType>(initial);
const [file, setFile] = useState<File>();
const uploadFile = useUploadFile();
@@ -52,11 +41,11 @@ export default function UploadFileModal({ onClose }: Props) {
const { data: collections = [] } = useCollections();
const setCollection = (e: any) => {
if (e?.__isNew__) e.value = null;
if (e?.__isNew__) e.value = undefined;
setLink({
...link,
collection: { id: e?.value, name: e?.label, ownerId: e?.ownerId },
collection: { id: e?.value, name: e?.label },
});
};
@@ -70,10 +59,11 @@ export default function UploadFileModal({ onClose }: Props) {
useEffect(() => {
setOptionsExpanded(false);
if (router.query.id) {
if (router.pathname.startsWith("/collections/") && router.query.id) {
const currentCollection = collections.find(
(e) => e.id == Number(router.query.id)
);
if (
currentCollection &&
currentCollection.ownerId &&
@@ -84,36 +74,17 @@ export default function UploadFileModal({ onClose }: Props) {
collection: {
id: currentCollection.id,
name: currentCollection.name,
ownerId: currentCollection.ownerId,
},
});
} else
setLink({
...initial,
collection: { name: "Unorganized", ownerId: data?.user.id as number },
collection: { name: "Unorganized" },
});
}, [router, collections]);
const submit = async () => {
if (!submitLoader && file) {
let fileType: ArchivedFormat | null = null;
let linkType: "url" | "image" | "monolith" | "pdf" | null = null;
if (file?.type === "image/jpg" || file.type === "image/jpeg") {
fileType = ArchivedFormat.jpeg;
linkType = "image";
} else if (file.type === "image/png") {
fileType = ArchivedFormat.png;
linkType = "image";
} else if (file.type === "application/pdf") {
fileType = ArchivedFormat.pdf;
linkType = "pdf";
}
// else if (file.type === "text/html") {
// fileType = ArchivedFormat.monolith;
// linkType = "monolith";
// }
setSubmitLoader(true);
const load = toast.loading(t("creating"));
@@ -122,6 +93,7 @@ export default function UploadFileModal({ onClose }: Props) {
{ link, file },
{
onSettled: (data, error) => {
setSubmitLoader(false);
toast.dismiss(load);
if (error) {
@@ -133,8 +105,6 @@ export default function UploadFileModal({ onClose }: Props) {
},
}
);
setSubmitLoader(false);
}
};
@@ -150,31 +120,31 @@ export default function UploadFileModal({ onClose }: Props) {
<label className="btn h-10 btn-sm w-full border border-neutral-content hover:border-neutral-content flex justify-between">
<input
type="file"
accept=".pdf,.png,.jpg,.jpeg,.html"
accept=".pdf,.png,.jpg,.jpeg"
className="cursor-pointer custom-file-input"
onChange={(e) => e.target.files && setFile(e.target.files[0])}
/>
</label>
<p className="text-xs font-semibold mt-2">
{t("file_types", {
size: process.env.NEXT_PUBLIC_MAX_FILE_BUFFER || 10,
size: config?.MAX_FILE_BUFFER || 10,
})}
</p>
</div>
<div className="sm:col-span-2 col-span-5">
<p className="mb-2">{t("collection")}</p>
{link.collection.name ? (
{link.collection?.name && (
<CollectionSelection
onChange={setCollection}
defaultValue={{
label: link.collection.name,
value: link.collection.id,
value: link.collection?.id,
label: link.collection?.name || "Unorganized",
}}
/>
) : null}
)}
</div>
</div>
{optionsExpanded ? (
{optionsExpanded && (
<div className="mt-5">
<div className="grid sm:grid-cols-2 gap-3">
<div>
@@ -190,26 +160,26 @@ export default function UploadFileModal({ onClose }: Props) {
<p className="mb-2">{t("tags")}</p>
<TagSelection
onChange={setTags}
defaultValue={link.tags.map((e) => ({
label: e.name,
defaultValue={link.tags?.map((e) => ({
value: e.id,
label: e.name,
}))}
/>
</div>
<div className="sm:col-span-2">
<p className="mb-2">{t("description")}</p>
<textarea
value={unescapeString(link.description) as string}
value={unescapeString(link.description || "") || ""}
onChange={(e) =>
setLink({ ...link, description: e.target.value })
}
placeholder={t("description_placeholder")}
className="resize-none w-full rounded-md p-2 border-neutral-content bg-base-200 focus:border-sky-300 dark:focus:border-sky-600 border-solid border outline-none duration-100"
className="resize-none w-full h-32 rounded-md p-2 border-neutral-content bg-base-200 focus:border-primary border-solid border outline-none duration-100"
/>
</div>
</div>
</div>
) : undefined}
)}
<div className="flex justify-between items-center mt-5">
<div
onClick={() => setOptionsExpanded(!optionsExpanded)}

View File

@@ -22,10 +22,13 @@ export default function Navbar() {
const { width } = useWindowDimensions();
useEffect(() => {
setSidebar(false);
document.body.style.overflow = "auto";
if (sidebar) setSidebar(false);
}, [width, router]);
useEffect(() => {
document.body.style.overflow = "auto";
}, [sidebar]);
const toggleSidebar = () => {
setSidebar(false);
document.body.style.overflow = "auto";
@@ -66,7 +69,7 @@ export default function Navbar() {
</span>
</div>
</div>
<ul className="dropdown-content z-[1] menu shadow bg-base-200 border border-neutral-content rounded-box w-40 mt-1">
<ul className="dropdown-content z-[1] menu shadow bg-base-200 border border-neutral-content rounded-box mt-1">
<li>
<div
onClick={() => {
@@ -75,6 +78,7 @@ export default function Navbar() {
}}
tabIndex={0}
role="button"
className="whitespace-nowrap flex gap-2"
>
{t("new_link")}
</div>
@@ -87,6 +91,7 @@ export default function Navbar() {
}}
tabIndex={0}
role="button"
className="whitespace-nowrap flex gap-2"
>
{t("upload_file")}
</div>
@@ -99,6 +104,7 @@ export default function Navbar() {
}}
tabIndex={0}
role="button"
className="whitespace-nowrap flex gap-2"
>
{t("new_collection")}
</div>
@@ -111,7 +117,7 @@ export default function Navbar() {
<MobileNavigation />
{sidebar ? (
{sidebar && (
<div className="fixed top-0 bottom-0 right-0 left-0 bg-black bg-opacity-10 backdrop-blur-sm flex items-center fade-in z-40">
<ClickAwayHandler className="h-full" onClickOutside={toggleSidebar}>
<div className="slide-right h-full shadow-lg">
@@ -119,16 +125,14 @@ export default function Navbar() {
</div>
</ClickAwayHandler>
</div>
) : null}
{newLinkModal ? (
<NewLinkModal onClose={() => setNewLinkModal(false)} />
) : undefined}
{newCollectionModal ? (
)}
{newLinkModal && <NewLinkModal onClose={() => setNewLinkModal(false)} />}
{newCollectionModal && (
<NewCollectionModal onClose={() => setNewCollectionModal(false)} />
) : undefined}
{uploadFileModal ? (
)}
{uploadFileModal && (
<UploadFileModal onClose={() => setUploadFileModal(false)} />
) : undefined}
)}
</div>
);
}

View File

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

View File

@@ -1,16 +1,19 @@
import clsx from "clsx";
import React from "react";
export default function PageHeader({
title,
description,
icon,
className,
}: {
title: string;
description?: string;
icon: string;
className?: string;
}) {
return (
<div className="flex items-center gap-3">
<div className={clsx("flex items-center gap-3", className)}>
<i
className={`${icon} text-primary sm:text-3xl text-2xl drop-shadow`}
></i>

23
components/Popover.tsx Normal file
View File

@@ -0,0 +1,23 @@
import React from "react";
import ClickAwayHandler from "./ClickAwayHandler";
type Props = {
children: React.ReactNode;
onClose: Function;
className?: string;
style?: React.CSSProperties;
};
const Popover = ({ children, className, onClose, style }: Props) => {
return (
<ClickAwayHandler
onClickOutside={() => onClose()}
className={`absolute z-50 ${className || ""}`}
style={style}
>
{children}
</ClickAwayHandler>
);
};
export default Popover;

View File

@@ -0,0 +1,250 @@
import React, { useEffect, useRef, useState } from "react";
import clsx from "clsx";
import { useRouter } from "next/router";
import { useTranslation } from "next-i18next";
import { BeatLoader } from "react-spinners";
import Tab from "../Tab";
import ReadableView from "@/components/Preservation/ReadableView";
import { PreservationSkeleton } from "../Skeletons";
import {
LinkIncludingShortenedCollectionAndTags,
ArchivedFormat,
} from "@/types/global";
import {
atLeastOneFormatAvailable,
formatAvailable,
} from "@/lib/shared/formatStats";
type Props = {
link?: LinkIncludingShortenedCollectionAndTags;
format?: ArchivedFormat;
};
function findAvailableImageFormat(
link: LinkIncludingShortenedCollectionAndTags
) {
return formatAvailable(link, "image")
? link?.image?.endsWith(".png")
? ArchivedFormat.png
: link?.image?.endsWith(".jpeg") || link?.image?.endsWith(".jpg")
? ArchivedFormat.jpeg
: null
: null;
}
export const PreservationContent: React.FC<Props> = ({ link, format }) => {
const router = useRouter();
const { t } = useTranslation();
const [pdfLoaded, setPdfLoaded] = useState(false);
const [monolithLoaded, setMonolithLoaded] = useState(false);
const [imageLoaded, setImageLoaded] = useState(false);
const prevFormatRef = useRef<ArchivedFormat | undefined>();
const [currentFormat, setCurrentFormat] = useState<ArchivedFormat>(
format ?? ArchivedFormat.readability
);
const screenshotFormat = findAvailableImageFormat(link!);
const potentialTabs = [
{
type: "readable" as const,
format: ArchivedFormat.readability,
icon: "bi-file-earmark-text",
name: "Readable",
},
{
type: "image" as const,
format: screenshotFormat,
icon: "bi-file-earmark-image",
name: "Screenshot",
},
{
type: "monolith" as const,
format: ArchivedFormat.monolith,
icon: "bi-filetype-html",
name: "Webpage",
},
{
type: "pdf" as const,
format: ArchivedFormat.pdf,
icon: "bi-file-earmark-pdf",
name: "PDF",
},
].filter((tab) => {
if (tab.format == null) return false;
return formatAvailable(link!, tab.type);
});
const activeTabIndex = potentialTabs.findIndex(
(tab) => tab.format === currentFormat
);
const validActiveTabIndex = activeTabIndex >= 0 ? activeTabIndex : 0;
function handleTabChange(newIndex: number) {
if (newIndex >= potentialTabs.length) return;
const newFormat = potentialTabs[newIndex].format!;
setCurrentFormat(newFormat);
router.push(
{
pathname: router.pathname,
query: { ...router.query, format: newFormat },
},
undefined,
{ shallow: true }
);
}
useEffect(() => {
if (!router.isReady) return;
const queryVal = router.query.format;
if (queryVal) {
const qFormat = parseInt(queryVal as string, 10);
const allFormats = [
ArchivedFormat.readability,
ArchivedFormat.monolith,
ArchivedFormat.jpeg,
ArchivedFormat.png,
ArchivedFormat.pdf,
];
if (allFormats.includes(qFormat)) {
setCurrentFormat(qFormat);
return;
}
}
if (format !== undefined) {
setCurrentFormat(format);
} else if (potentialTabs.length > 0) {
setCurrentFormat(potentialTabs[0].format!);
}
}, [router.query.format, router.isReady, format, potentialTabs]);
useEffect(() => {
if (prevFormatRef.current !== currentFormat) {
setPdfLoaded(false);
setMonolithLoaded(false);
setImageLoaded(false);
prevFormatRef.current = currentFormat;
}
}, [currentFormat]);
if (!link?.id) return null;
const renderFormat = () => {
switch (currentFormat) {
case ArchivedFormat.readability:
return (
<div className="overflow-auto w-full h-full">
<ReadableView link={link} />
</div>
);
case ArchivedFormat.monolith:
return (
<>
{!monolithLoaded && (
<PreservationSkeleton className="max-w-screen-lg h-screen" />
)}
<iframe
src={`/api/v1/archives/${link.id}?format=${ArchivedFormat.monolith}&_=${link.updatedAt}`}
className={clsx(
"w-full border-none h-screen",
monolithLoaded ? "block" : "hidden"
)}
onLoad={() => setMonolithLoaded(true)}
/>
</>
);
case ArchivedFormat.pdf:
return (
<>
{!pdfLoaded && (
<PreservationSkeleton className="max-w-screen-lg h-screen" />
)}
<iframe
src={`/api/v1/archives/${link.id}?format=${ArchivedFormat.pdf}&_=${link.updatedAt}`}
className={clsx(
"w-full border-none h-screen",
pdfLoaded ? "block" : "hidden"
)}
onLoad={() => setPdfLoaded(true)}
/>
</>
);
case ArchivedFormat.png:
case ArchivedFormat.jpeg:
return (
<>
{!imageLoaded && (
<PreservationSkeleton className="max-w-screen-lg h-screen" />
)}
<div
className={clsx(
"overflow-auto flex items-start",
imageLoaded && "h-screen"
)}
>
<img
alt=""
src={`/api/v1/archives/${link.id}?format=${currentFormat}`}
className={clsx("w-fit mx-auto", !imageLoaded && "hidden")}
onLoad={(e) => {
const img = e.currentTarget;
setImageLoaded(true);
setTimeout(() => {
if (img.naturalHeight < window.innerHeight) {
img.parentElement?.classList.replace(
"items-start",
"items-center"
);
}
}, 1);
}}
loading="eager"
/>
</div>
</>
);
default:
return null;
}
};
return (
<div className="relative bg-base-200">
{link.url && potentialTabs.length > 1 && (
<Tab
tabs={potentialTabs.map((tab) => ({
icon: tab.icon,
name: tab.name,
}))}
activeTabIndex={validActiveTabIndex}
setActiveTabIndex={handleTabChange}
className="w-fit absolute left-1/2 -translate-x-1/2 rounded-full bg-base-100 top-2 text-sm shadow-md"
hideName
/>
)}
{!atLeastOneFormatAvailable(link) ? (
<div className={`w-full h-full flex flex-col justify-center p-10`}>
<BeatLoader
color="oklch(var(--p))"
className="mx-auto mb-3"
size={30}
/>
<p className="text-center text-2xl">{t("preservation_in_queue")}</p>
<p className="text-center text-lg">{t("check_back_later")}</p>
</div>
) : (
renderFormat()
)}
</div>
);
};

View File

@@ -0,0 +1,40 @@
import React, { useEffect } from "react";
import { useRouter } from "next/router";
import { useGetLink } from "@/hooks/store/links";
import { PreservationContent } from "./PreservationContent";
export default function PreservationPageContent() {
const router = useRouter();
const isPublicRoute = router.pathname.startsWith("/public");
const { data: link, mutateAsync: fetchLink, error } = useGetLink();
useEffect(() => {
fetchLink({
id: Number(router.query.id),
isPublicRoute,
});
let interval: NodeJS.Timeout | null = null;
if (
link &&
(!link?.image || !link?.pdf || !link?.readable || !link?.monolith)
) {
interval = setInterval(() => {
fetchLink({ id: link.id as number });
}, 5000);
} else if (interval) {
clearInterval(interval);
}
return () => {
if (interval) clearInterval(interval);
};
}, []);
return (
<div className="w-screen h-screen bg-base-200">
<PreservationContent link={link} format={Number(router.query.format)} />
</div>
);
}

View File

@@ -0,0 +1,412 @@
import React, { useEffect, useState } from "react";
import clsx from "clsx";
import { PreservationSkeleton } from "../Skeletons";
import { useTranslation } from "next-i18next";
import { useRouter } from "next/router";
import Link from "next/link";
import unescapeString from "@/lib/client/unescapeString";
import isValidUrl from "@/lib/shared/isValidUrl";
import LinkDate from "../LinkViews/LinkComponents/LinkDate";
import usePermissions from "@/hooks/usePermissions";
import {
LinkIncludingShortenedCollectionAndTags,
ArchivedFormat,
} from "@/types/global";
import ClickAwayHandler from "@/components/ClickAwayHandler";
import {
useGetLinkHighlights,
usePostHighlight,
useRemoveHighlight,
} from "@/hooks/store/highlights";
import { Highlight } from "@prisma/client";
type Props = {
link: LinkIncludingShortenedCollectionAndTags;
};
export default function ReadableView({ link }: Props) {
const { t } = useTranslation();
const router = useRouter();
const isPublicRoute = router.pathname.startsWith("/public");
const permissions = usePermissions(link?.collection?.id as number);
const postHighlight = usePostHighlight(link?.id as number);
const { data: linkHighlights } = useGetLinkHighlights(link?.id as number);
const deleteHighlight = useRemoveHighlight(link?.id as number);
const [linkContent, setLinkContent] = useState("");
const [selectionMenu, setSelectionMenu] = useState<{
show: boolean;
highlightId: number | null;
}>({
show: false,
highlightId: null,
});
const [menuPosition, setMenuPosition] = useState({ x: 0, y: 0 });
useEffect(() => {
async function fetchLinkContent() {
if (link?.readable?.startsWith("archives")) {
const response = await fetch(
`/api/v1/archives/${link?.id}?format=${ArchivedFormat.readability}&_=${link.updatedAt}`
);
const data = await response.json();
setLinkContent(data?.content ?? "");
}
}
fetchLinkContent();
}, [link]);
const handleMouseUp = (e: React.MouseEvent) => {
const target = e.target as HTMLElement;
const highlightId = Number(target.dataset.highlightId);
const selection = window.getSelection();
if (highlightId) {
const rect = target.getBoundingClientRect();
setSelectionMenu({
show: true,
highlightId: highlightId,
});
setMenuPosition({
x: rect.left + window.scrollX + rect.width / 2,
y: rect.top + window.scrollY - 5,
});
return;
} else if (
selection &&
selection.rangeCount > 0 &&
!selection.isCollapsed
) {
const range = selection.getRangeAt(0);
const rect = range.getBoundingClientRect();
if (rect && rect.width && rect.height) {
const scrollTop = window.scrollY || document.documentElement.scrollTop;
const scrollLeft =
window.scrollX || document.documentElement.scrollLeft;
setMenuPosition({
x: rect.left + scrollLeft + rect.width / 2,
y: rect.top + scrollTop - 5,
});
setSelectionMenu({
show: true,
highlightId: selectionMenu.highlightId,
});
}
}
};
function getHighlightedSection(color: string) {
const selection = window.getSelection?.();
if (!selection || selection.isCollapsed) return null;
const range = selection.getRangeAt(0);
if (!range) return null;
const container = document.getElementById("readable-view");
if (!container || !container.contains(range.commonAncestorContainer)) {
return null;
}
let startOffset = -1;
let endOffset = -1;
let currentOffset = 0;
const treeWalker = document.createTreeWalker(
container,
NodeFilter.SHOW_TEXT
);
while (treeWalker.nextNode()) {
const node = treeWalker.currentNode;
const nodeLength = node.textContent?.length ?? 0;
if (node === range.startContainer) {
startOffset = currentOffset + range.startOffset;
}
if (node === range.endContainer) {
endOffset = currentOffset + range.endOffset;
break;
}
currentOffset += nodeLength;
}
if (startOffset === -1 || endOffset === -1) {
return null;
}
return {
linkId: link?.id,
color,
text: range.toString(),
startOffset,
endOffset,
};
}
function getHighlightedHtml(
htmlContent: string,
highlights: Highlight[]
): string {
if (!htmlContent || !highlights || highlights.length === 0) {
return htmlContent;
}
const container = document.createElement("div");
container.innerHTML = htmlContent;
const sortedHighlights = [...highlights].sort(
(a, b) => a.startOffset - b.startOffset
);
for (const highlight of sortedHighlights) {
applyHighlight(container, highlight);
}
return container.innerHTML;
}
function applyHighlight(container: HTMLElement, highlight: Highlight) {
let currentOffset = 0;
const treeWalker = document.createTreeWalker(
container,
NodeFilter.SHOW_TEXT
);
const rangesToWrap: Array<{
node: Text;
start: number;
end: number;
}> = [];
while (treeWalker.nextNode()) {
const node = treeWalker.currentNode as Text;
const nodeLength = node.textContent?.length ?? 0;
const nodeStart = currentOffset;
const nodeEnd = nodeStart + nodeLength;
if (nodeStart < highlight.endOffset && nodeEnd > highlight.startOffset) {
rangesToWrap.push({
node,
start: Math.max(0, highlight.startOffset - nodeStart),
end: Math.min(nodeLength, highlight.endOffset - nodeStart),
});
}
currentOffset += nodeLength;
}
rangesToWrap.forEach(({ node, start, end }) => {
if (start > 0) {
node.splitText(start);
node = node.nextSibling as Text;
end -= start;
}
if (end < node.length) {
node.splitText(end);
}
const highlightWrapper = document.createElement("span");
highlightWrapper.dataset.highlightId = highlight.id.toString();
highlightWrapper.classList.add("cursor-pointer");
if (highlight.color === "yellow") {
highlightWrapper.classList.add("bg-yellow-500/70");
} else if (highlight.color === "red") {
highlightWrapper.classList.add("bg-red-500/70");
} else if (highlight.color === "blue") {
highlightWrapper.classList.add("bg-blue-500/70");
} else if (highlight.color === "green") {
highlightWrapper.classList.add("bg-green-500/70");
}
node.parentNode?.insertBefore(highlightWrapper, node);
highlightWrapper.appendChild(node);
});
}
const highlightedHtml = React.useMemo(() => {
return getHighlightedHtml(linkContent, linkHighlights || []);
}, [linkContent, linkHighlights]);
const handleHighlightSelection = async (
color: "yellow" | "red" | "blue" | "green",
highlightId: number | null
) => {
let selection = getHighlightedSection(color);
if (highlightId) {
selection =
linkHighlights?.find((h) => h.id === selectionMenu.highlightId) ?? null;
if (selection) selection.color = color;
}
if (!selection && !highlightId) return;
postHighlight.mutate(selection as Highlight, {
onSuccess: (data) => {
if (data) {
setSelectionMenu({
show: true,
highlightId: data.id,
});
}
},
});
};
const handleMenuClickOutside = () => {
setSelectionMenu({
show: false,
highlightId: null,
});
if (window.getSelection) {
window.getSelection()?.removeAllRanges();
}
};
return (
<div className="flex flex-col gap-3 items-start p-3 max-w-screen-lg mx-auto bg-base-200 mt-10">
<div className="flex gap-3 items-start">
<div className="flex flex-col w-full gap-1">
<p className="md:text-4xl text-2xl">
{unescapeString(link?.name || link?.description || link?.url || "")}
</p>
{link?.url && (
<Link
href={link?.url || ""}
title={link?.url}
target="_blank"
className="hover:opacity-60 duration-100 break-all text-sm flex items-center gap-1 text-neutral w-fit"
>
<i className="bi-link-45deg" />
{isValidUrl(link?.url || "") && new URL(link?.url as string).host}
</Link>
)}
</div>
</div>
<div className="text-sm text-neutral flex justify-between w-full gap-2">
<LinkDate link={link} />
</div>
{link?.readable?.startsWith("archives") ? (
<>
{linkContent ? (
<div
className={clsx("p-3 rounded-md w-full bg-base-200")}
onMouseUp={handleMouseUp}
>
<div
id="readable-view"
className="line-break px-1 reader-view read-only"
dangerouslySetInnerHTML={{ __html: highlightedHtml }}
/>
{selectionMenu.show &&
!isPublicRoute &&
(permissions === true || permissions?.canUpdate) && (
<ClickAwayHandler
onClickOutside={handleMenuClickOutside}
className="absolute bg-base-100 p-2 z-[9999]
whitespace-nowrap -translate-x-1/2
-translate-y-full rounded-lg shadow-md border border-neutral-content"
style={{
left: menuPosition.x,
top: menuPosition.y,
}}
>
<div className="flex items-center gap-3 justify-between select-none">
<div className="flex items-center gap-3">
{["yellow", "red", "blue", "green"].map((color) => (
<button
key={color}
onClick={() =>
handleHighlightSelection(
color as "yellow" | "red" | "blue" | "green",
selectionMenu.highlightId
)
}
className={`w-5 h-5 rounded-full ${
color === "yellow"
? "bg-yellow-300"
: color === "red"
? "bg-red-500"
: color === "blue"
? "bg-blue-500"
: "bg-green-500"
} hover:opacity-70 duration-100 relative`}
title={`${
color.charAt(0).toUpperCase() + color.slice(1)
} Highlight`}
>
{selectionMenu.highlightId &&
linkHighlights?.find(
(h) => h.id === selectionMenu.highlightId
)?.color === color && (
<i className="bi-check2 text-sm text-black absolute inset-0 flex items-center justify-center" />
)}
</button>
))}
</div>
{selectionMenu.highlightId && (
<div className="flex items-center gap-3">
<button
onClick={() => {
deleteHighlight.mutate(
selectionMenu.highlightId as number
);
setSelectionMenu({
show: false,
highlightId: null,
});
}}
className="hover:opacity-70 duration-100"
title="Delete"
>
<i className="bi-trash" />
</button>
</div>
)}
</div>
</ClickAwayHandler>
)}
</div>
) : (
<PreservationSkeleton className="h-fit" />
)}
</>
) : (
<div className={`w-full h-full flex flex-col justify-center`}>
<svg
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
fill="currentColor"
className="w-1/4 min-w-[7rem] max-w-[15rem] h-auto mx-auto mb-5"
viewBox="0 0 16 16"
>
<path d="m14.12 10.163 1.715.858c.22.11.22.424 0 .534L8.267 15.34a.598.598 0 0 1-.534 0L.165 11.555a.299.299 0 0 1 0-.534l1.716-.858 5.317 2.659c.505.252 1.1.252 1.604 0l5.317-2.66zM7.733.063a.598.598 0 0 1 .534 0l7.568 3.784a.3.3 0 0 1 0 .535L8.267 8.165a.598.598 0 0 1-.534 0L.165 4.382a.299.299 0 0 1 0-.535L7.733.063z" />
<path d="m14.12 6.576 1.715.858c.22.11.22.424 0 .534l-7.568 3.784a.598.598 0 0 1-.534 0L.165 7.968a.299.299 0 0 1 0-.534l1.716-.858 5.317 2.659c.505.252 1.1.252 1.604 0l5.317-2.659z" />
</svg>
<p className="text-center text-2xl">
{t("link_preservation_in_queue")}
</p>
<p className="text-center text-lg mt-2">{t("check_back_later")}</p>
</div>
)}
</div>
);
}

View File

@@ -4,7 +4,6 @@ import {
} from "@/types/global";
import Link from "next/link";
import { useRouter } from "next/router";
import { useGetLink } from "@/hooks/store/links";
type Props = {
name: string;
@@ -21,8 +20,6 @@ export default function PreservedFormatRow({
link,
downloadable,
}: Props) {
const getLink = useGetLink();
const router = useRouter();
let isPublic = router.pathname.startsWith("/public") ? true : undefined;
@@ -52,11 +49,9 @@ export default function PreservedFormatRow({
};
return (
<div className="flex justify-between items-center pr-1 border border-neutral-content rounded-md">
<div className="flex justify-between items-center">
<div className="flex gap-2 items-center">
<div className="bg-primary text-primary-content p-2 rounded-l-md">
<i className={`${icon} text-2xl`} />
</div>
<i className={`${icon} text-2xl text-primary`} />
<p>{name}</p>
</div>
@@ -64,7 +59,7 @@ export default function PreservedFormatRow({
{downloadable || false ? (
<div
onClick={() => handleDownload()}
className="btn btn-sm btn-square"
className="btn btn-sm btn-square btn-ghost"
>
<i className="bi-cloud-arrow-down text-xl text-neutral" />
</div>
@@ -75,9 +70,9 @@ export default function PreservedFormatRow({
isPublic ? "/public" : ""
}/preserved/${link?.id}?format=${format}`}
target="_blank"
className="btn btn-sm btn-square"
className="btn btn-sm btn-square btn-ghost"
>
<i className="bi-box-arrow-up-right text-xl text-neutral" />
<i className="bi-box-arrow-up-right text-lg text-neutral" />
</Link>
</div>
</div>

View File

@@ -5,13 +5,16 @@ import Link from "next/link";
import { signOut } from "next-auth/react";
import { useTranslation } from "next-i18next";
import { useUser } from "@/hooks/store/user";
import { useConfig } from "@/hooks/store/config";
export default function ProfileDropdown() {
const { t } = useTranslation();
const { settings, updateSettings } = useLocalSettingsStore();
const { data: user = {} } = useUser();
const isAdmin = user.id === Number(process.env.NEXT_PUBLIC_ADMIN || 1);
const { data: config } = useConfig();
const isAdmin = user.id === (config?.ADMIN || 1);
const handleToggle = () => {
const newTheme = settings.theme === "dark" ? "light" : "dark";
@@ -32,9 +35,7 @@ export default function ProfileDropdown() {
/>
</div>
<ul
className={`dropdown-content z-[1] menu shadow bg-base-200 border border-neutral-content rounded-box ${
isAdmin ? "w-48" : "w-40"
} mt-1`}
className={`dropdown-content z-[1] menu shadow bg-base-200 border border-neutral-content rounded-box mt-1`}
>
<li>
<Link
@@ -42,6 +43,7 @@ export default function ProfileDropdown() {
onClick={() => (document?.activeElement as HTMLElement)?.blur()}
tabIndex={0}
role="button"
className="whitespace-nowrap"
>
{t("settings")}
</Link>
@@ -54,24 +56,26 @@ export default function ProfileDropdown() {
}}
tabIndex={0}
role="button"
className="whitespace-nowrap"
>
{t("switch_to", {
theme: settings.theme === "light" ? t("dark") : t("light"),
})}
</div>
</li>
{isAdmin ? (
{isAdmin && (
<li>
<Link
href="/admin"
onClick={() => (document?.activeElement as HTMLElement)?.blur()}
tabIndex={0}
role="button"
className="whitespace-nowrap"
>
{t("server_administration")}
</Link>
</li>
) : null}
)}
<li>
<div
onClick={() => {
@@ -80,6 +84,7 @@ export default function ProfileDropdown() {
}}
tabIndex={0}
role="button"
className="whitespace-nowrap"
>
{t("logout")}
</div>

View File

@@ -5,7 +5,7 @@ type Props = {
src?: string;
className?: string;
priority?: boolean;
name?: string;
name?: string | null;
large?: boolean;
};

View File

@@ -1,18 +1,25 @@
import clsx from "clsx";
import { ChangeEventHandler } from "react";
type Props = {
label: string;
className?: string;
state: boolean;
onClick: ChangeEventHandler<HTMLInputElement>;
};
export default function RadioButton({ label, state, onClick }: Props) {
export default function RadioButton({
label,
state,
onClick,
className,
}: Props) {
return (
<label className="cursor-pointer flex items-center gap-2">
<input
type="radio"
value={label}
className="peer sr-only"
className={clsx("peer sr-only", className)}
checked={state}
onChange={onClick}
/>

View File

@@ -1,289 +0,0 @@
import unescapeString from "@/lib/client/unescapeString";
import { readabilityAvailable } from "@/lib/shared/getArchiveValidity";
import isValidUrl from "@/lib/shared/isValidUrl";
import {
ArchivedFormat,
CollectionIncludingMembersAndLinkCount,
LinkIncludingShortenedCollectionAndTags,
} from "@/types/global";
import ColorThief, { RGBColor } from "colorthief";
import DOMPurify from "dompurify";
import Image from "next/image";
import Link from "next/link";
import { useRouter } from "next/router";
import React, { useEffect, useMemo, useState } from "react";
import LinkActions from "./LinkViews/LinkComponents/LinkActions";
import { useTranslation } from "next-i18next";
import { useCollections } from "@/hooks/store/collections";
import { useGetLink } from "@/hooks/store/links";
type LinkContent = {
title: string;
content: string;
textContent: string;
length: number;
excerpt: string;
byline: string;
dir: string;
siteName: string;
lang: string;
};
type Props = {
link: LinkIncludingShortenedCollectionAndTags;
};
export default function ReadableView({ link }: Props) {
const { t } = useTranslation();
const [linkContent, setLinkContent] = useState<LinkContent>();
const [imageError, setImageError] = useState<boolean>(false);
const [colorPalette, setColorPalette] = useState<RGBColor[]>();
const [date, setDate] = useState<Date | string>();
const colorThief = new ColorThief();
const router = useRouter();
const getLink = useGetLink();
const { data: collections = [] } = useCollections();
const collection = useMemo(() => {
return collections.find(
(e) => e.id === link.collection.id
) as CollectionIncludingMembersAndLinkCount;
}, [collections, link]);
useEffect(() => {
const fetchLinkContent = async () => {
if (router.query.id && readabilityAvailable(link)) {
const response = await fetch(
`/api/v1/archives/${link?.id}?format=${ArchivedFormat.readability}`
);
const data = await response?.json();
setLinkContent(data);
}
};
fetchLinkContent();
setDate(link.importDate || link.createdAt);
}, [link]);
useEffect(() => {
if (link) getLink.mutateAsync(link?.id as number);
let interval: any;
if (
link &&
(link?.image === "pending" ||
link?.pdf === "pending" ||
link?.readable === "pending" ||
link?.monolith === "pending" ||
!link?.image ||
!link?.pdf ||
!link?.readable ||
!link?.monolith)
) {
interval = setInterval(
() => getLink.mutateAsync(link.id as number),
5000
);
} else {
if (interval) {
clearInterval(interval);
}
}
return () => {
if (interval) {
clearInterval(interval);
}
};
}, [link?.image, link?.pdf, link?.readable, link?.monolith]);
const rgbToHex = (r: number, g: number, b: number): string =>
"#" +
[r, g, b]
.map((x) => {
const hex = x.toString(16);
return hex.length === 1 ? "0" + hex : hex;
})
.join("");
useEffect(() => {
const banner = document.getElementById("link-banner");
const bannerInner = document.getElementById("link-banner-inner");
if (colorPalette && banner && bannerInner) {
if (colorPalette[0] && colorPalette[1]) {
banner.style.background = `linear-gradient(to bottom, ${rgbToHex(
colorPalette[0][0],
colorPalette[0][1],
colorPalette[0][2]
)}20, ${rgbToHex(
colorPalette[1][0],
colorPalette[1][1],
colorPalette[1][2]
)}20)`;
}
if (colorPalette[2] && colorPalette[3]) {
bannerInner.style.background = `linear-gradient(to bottom, ${rgbToHex(
colorPalette[2][0],
colorPalette[2][1],
colorPalette[2][2]
)}30, ${rgbToHex(
colorPalette[3][0],
colorPalette[3][1],
colorPalette[3][2]
)})30`;
}
}
}, [colorPalette]);
return (
<div className={`flex flex-col max-w-screen-md h-full mx-auto p-5`}>
<div
id="link-banner"
className="link-banner relative bg-opacity-10 border-neutral-content p-3 border mb-3"
>
<div id="link-banner-inner" className="link-banner-inner"></div>
<div className={`flex flex-col gap-3 items-start`}>
<div className="flex gap-3 items-start">
{!imageError && link?.url && (
<Image
src={`https://t2.gstatic.com/faviconV2?client=SOCIAL&type=FAVICON&fallback_opts=TYPE,SIZE,URL&url=${link.url}&size=32`}
width={42}
height={42}
alt=""
id={"favicon-" + link.id}
className="bg-white shadow rounded-md p-1 select-none mt-1"
draggable="false"
onLoad={(e) => {
try {
const color = colorThief.getPalette(
e.target as HTMLImageElement,
4
);
setColorPalette(color);
} catch (err) {
console.log(err);
}
}}
onError={(e) => {
setImageError(true);
}}
/>
)}
<div className="flex flex-col">
<p className="text-xl pr-10">
{unescapeString(
link?.name || link?.description || link?.url || ""
)}
</p>
{link?.url ? (
<Link
href={link?.url || ""}
title={link?.url}
target="_blank"
className="hover:opacity-60 duration-100 break-all text-sm flex items-center gap-1 text-neutral w-fit"
>
<i className="bi-link-45deg"></i>
{isValidUrl(link?.url || "")
? new URL(link?.url as string).host
: undefined}
</Link>
) : undefined}
</div>
</div>
<div className="flex gap-1 items-center flex-wrap">
<Link
href={`/collections/${link?.collection.id}`}
className="flex items-center gap-1 cursor-pointer hover:opacity-60 duration-100 mr-2 z-10"
>
<i
className="bi-folder-fill drop-shadow text-2xl"
style={{ color: link?.collection.color }}
></i>
<p
title={link?.collection.name}
className="text-lg truncate max-w-[12rem]"
>
{link?.collection.name}
</p>
</Link>
{link?.tags?.map((e, i) => (
<Link key={i} href={`/tags/${e.id}`} className="z-10">
<p
title={e.name}
className="btn btn-xs btn-ghost truncate max-w-[19rem]"
>
#{e.name}
</p>
</Link>
))}
</div>
<p className="min-w-fit text-sm text-neutral">
{date
? new Date(date).toLocaleString("en-US", {
year: "numeric",
month: "long",
day: "numeric",
})
: undefined}
</p>
{link?.name ? <p>{unescapeString(link?.description)}</p> : undefined}
</div>
<LinkActions
link={link}
collection={collection}
position="top-3 right-3"
alignToTop
/>
</div>
<div className="flex flex-col gap-5 h-full">
{link?.readable?.startsWith("archives") ? (
<div
className="line-break px-1 reader-view"
dangerouslySetInnerHTML={{
__html: DOMPurify.sanitize(linkContent?.content || "") || "",
}}
></div>
) : (
<div
className={`w-full h-full flex flex-col justify-center p-10 ${
link?.readable === "pending" || !link?.readable ? "skeleton" : ""
}`}
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
fill="currentColor"
className="w-1/4 min-w-[7rem] max-w-[15rem] h-auto mx-auto mb-5"
viewBox="0 0 16 16"
>
<path d="m14.12 10.163 1.715.858c.22.11.22.424 0 .534L8.267 15.34a.598.598 0 0 1-.534 0L.165 11.555a.299.299 0 0 1 0-.534l1.716-.858 5.317 2.659c.505.252 1.1.252 1.604 0l5.317-2.66zM7.733.063a.598.598 0 0 1 .534 0l7.568 3.784a.3.3 0 0 1 0 .535L8.267 8.165a.598.598 0 0 1-.534 0L.165 4.382a.299.299 0 0 1 0-.535L7.733.063z" />
<path d="m14.12 6.576 1.715.858c.22.11.22.424 0 .534l-7.568 3.784a.598.598 0 0 1-.534 0L.165 7.968a.299.299 0 0 1 0-.534l1.716-.858 5.317 2.659c.505.252 1.1.252 1.604 0l5.317-2.659z" />
</svg>
<p className="text-center text-2xl">
{t("link_preservation_in_queue")}
</p>
<p className="text-center text-lg mt-2">{t("check_back_later")}</p>
</div>
)}
</div>
</div>
);
}

View File

@@ -1,6 +1,7 @@
import { useEffect, useState } from "react";
import { useRouter } from "next/router";
import { toast } from "react-hot-toast";
import { useTranslation } from "next-i18next";
type Props = {
placeholder?: string;
@@ -8,7 +9,7 @@ type Props = {
export default function SearchBar({ placeholder }: Props) {
const router = useRouter();
const { t } = useTranslation();
const [searchQuery, setSearchQuery] = useState("");
useEffect(() => {
@@ -29,11 +30,11 @@ export default function SearchBar({ placeholder }: Props) {
<input
id="search-box"
type="text"
placeholder={placeholder || "Search for Links"}
placeholder={placeholder || t("search_for_links")}
value={searchQuery}
onChange={(e) => {
e.target.value.includes("%") &&
toast.error("The search query should not contain '%'.");
toast.error(t("search_query_invalid_symbol"));
setSearchQuery(e.target.value.replace("%", ""));
}}
onKeyDown={(e) => {

View File

@@ -2,11 +2,17 @@ import Link from "next/link";
import { useRouter } from "next/router";
import React, { useEffect, useState } from "react";
import { useTranslation } from "next-i18next";
import { useUser } from "@/hooks/store/user";
import { useConfig } from "@/hooks/store/config";
export default function SettingsSidebar({ className }: { className?: string }) {
const { t } = useTranslation();
const LINKWARDEN_VERSION = process.env.version;
const { data: user } = useUser();
const { data: config } = useConfig();
const isAdmin = user.id === (config?.ADMIN || 1);
const router = useRouter();
const [active, setActive] = useState("");
@@ -47,6 +53,19 @@ export default function SettingsSidebar({ className }: { className?: string }) {
</div>
</Link>
<Link href="/settings/rss-subscriptions">
<div
className={`${
active === "/settings/rss-subscriptions"
? "bg-primary/20"
: "hover:bg-neutral/20"
} duration-100 py-5 px-2 cursor-pointer flex items-center gap-2 w-full rounded-md h-8`}
>
<i className="bi-rss text-primary text-2xl"></i>
<p className="truncate w-full pr-7">RSS Subscriptions</p>
</div>
</Link>
<Link href="/settings/access-tokens">
<div
className={`${
@@ -73,7 +92,22 @@ export default function SettingsSidebar({ className }: { className?: string }) {
</div>
</Link>
{process.env.NEXT_PUBLIC_STRIPE && (
{isAdmin && (
<Link href="/settings/worker">
<div
className={`${
active === "/settings/worker"
? "bg-primary/20"
: "hover:bg-neutral/20"
} duration-100 py-5 px-2 cursor-pointer flex items-center gap-2 w-full rounded-md h-8`}
>
<i className="bi-gear-wide-connected text-primary text-2xl"></i>
<p className="truncate w-full pr-7">{t("worker")}</p>
</div>
</Link>
)}
{process.env.NEXT_PUBLIC_STRIPE && !user.parentSubscriptionId && (
<Link href="/settings/billing">
<div
className={`${
@@ -82,7 +116,7 @@ export default function SettingsSidebar({ className }: { className?: string }) {
: "hover:bg-neutral/20"
} duration-100 py-5 px-2 cursor-pointer flex items-center gap-2 w-full rounded-md h-8`}
>
<i className="bi-credit-card text-primary text-xl"></i>
<i className="bi-credit-card text-primary text-2xl"></i>
<p className="truncate w-full pr-7">{t("billing")}</p>
</div>
</Link>
@@ -101,7 +135,7 @@ export default function SettingsSidebar({ className }: { className?: string }) {
<div
className={`hover:bg-neutral/20 duration-100 py-2 px-2 cursor-pointer flex items-center gap-2 w-full rounded-md h-8`}
>
<i className="bi-question-circle text-primary text-xl"></i>
<i className="bi-question-circle text-primary text-2xl"></i>
<p className="truncate w-full pr-7">{t("help")}</p>
</div>
</Link>
@@ -109,7 +143,7 @@ export default function SettingsSidebar({ className }: { className?: string }) {
<div
className={`hover:bg-neutral/20 duration-100 py-2 px-2 cursor-pointer flex items-center gap-2 w-full rounded-md h-8`}
>
<i className="bi-github text-primary text-xl"></i>
<i className="bi-github text-primary text-2xl"></i>
<p className="truncate w-full pr-7">{t("github")}</p>
</div>
</Link>
@@ -117,7 +151,7 @@ export default function SettingsSidebar({ className }: { className?: string }) {
<div
className={`hover:bg-neutral/20 duration-100 py-2 px-2 cursor-pointer flex items-center gap-2 w-full rounded-md h-8`}
>
<i className="bi-twitter-x text-primary text-xl"></i>
<i className="bi-twitter-x text-primary text-2xl"></i>
<p className="truncate w-full pr-7">{t("twitter")}</p>
</div>
</Link>
@@ -125,7 +159,7 @@ export default function SettingsSidebar({ className }: { className?: string }) {
<div
className={`hover:bg-neutral/20 duration-100 py-2 px-2 cursor-pointer flex items-center gap-2 w-full rounded-md h-8`}
>
<i className="bi-mastodon text-primary text-xl"></i>
<i className="bi-mastodon text-primary text-2xl"></i>
<p className="truncate w-full pr-7">{t("mastodon")}</p>
</div>
</Link>

View File

@@ -14,6 +14,7 @@ export default function SidebarHighlightLink({
return (
<Link href={href}>
<div
title={title}
className={`${
active || false
? "bg-primary/20"
@@ -28,7 +29,7 @@ export default function SidebarHighlightLink({
<i className={`${icon} text-primary text-2xl drop-shadow`}></i>
</div>
<div className={"mt-1"}>
<p className="truncate w-full font-semibold text-sm">{title}</p>
<p className="truncate w-full font-semibold text-xs">{title}</p>
</div>
</div>
</Link>

24
components/Skeletons.tsx Normal file
View File

@@ -0,0 +1,24 @@
import clsx from "clsx";
export const PreservationSkeleton = ({ className }: { className?: string }) => (
<div
className={clsx(
"p-5 m-auto w-full flex flex-col items-center gap-5 bg-base-200 justify-center",
className
)}
>
<div className="w-full mr-auto h-4 skeleton rounded-md"></div>
<div className="w-full mr-auto h-4 skeleton rounded-md"></div>
<div className="w-full mr-auto h-4 skeleton rounded-md"></div>
<div className="w-3/4 mr-auto h-4 skeleton rounded-md"></div>
<div className="w-5/6 mr-auto h-4 skeleton rounded-md"></div>
<div className="w-3/4 mr-auto h-4 skeleton rounded-md"></div>
<div className="w-full mr-auto h-4 skeleton rounded-md"></div>
<div className="w-full mr-auto h-4 skeleton rounded-md"></div>
<div className="w-5/6 mr-auto h-4 skeleton rounded-md"></div>
</div>
);
export const ImageSkeleton = () => (
<div className="w-[80%] h-[80%] bg-neutral-content rounded-2xl mx-auto"></div>
);

View File

@@ -3,6 +3,8 @@ import { Sort } from "@/types/global";
import { dropdownTriggerer } from "@/lib/client/utils";
import { TFunction } from "i18next";
import useLocalSettingsStore from "@/store/localSettings";
import { resetInfiniteQueryPagination } from "@/hooks/store/links";
import { useQueryClient } from "@tanstack/react-query";
type Props = {
sortBy: Sort;
@@ -12,6 +14,7 @@ type Props = {
export default function SortDropdown({ sortBy, setSort, t }: Props) {
const { updateSettings } = useLocalSettingsStore();
const queryClient = useQueryClient();
useEffect(() => {
updateSettings({ sortBy });
@@ -27,7 +30,7 @@ export default function SortDropdown({ sortBy, setSort, t }: Props) {
>
<i className="bi-chevron-expand text-neutral text-2xl"></i>
</div>
<ul className="dropdown-content z-[30] menu shadow bg-base-200 border border-neutral-content rounded-xl w-52 mt-1">
<ul className="dropdown-content z-[30] menu shadow bg-base-200 border border-neutral-content rounded-xl mt-1">
<li>
<label
className="label cursor-pointer flex justify-start"
@@ -39,9 +42,14 @@ export default function SortDropdown({ sortBy, setSort, t }: Props) {
name="sort-radio"
className="radio checked:bg-primary"
checked={sortBy === Sort.DateNewestFirst}
onChange={() => setSort(Sort.DateNewestFirst)}
onChange={() => {
resetInfiniteQueryPagination(queryClient, ["links"]);
setSort(Sort.DateNewestFirst);
}}
/>
<span className="label-text">{t("date_newest_first")}</span>
<span className="label-text whitespace-nowrap">
{t("date_newest_first")}
</span>
</label>
</li>
<li>
@@ -55,9 +63,14 @@ export default function SortDropdown({ sortBy, setSort, t }: Props) {
name="sort-radio"
className="radio checked:bg-primary"
checked={sortBy === Sort.DateOldestFirst}
onChange={() => setSort(Sort.DateOldestFirst)}
onChange={() => {
resetInfiniteQueryPagination(queryClient, ["links"]);
setSort(Sort.DateOldestFirst);
}}
/>
<span className="label-text">{t("date_oldest_first")}</span>
<span className="label-text whitespace-nowrap">
{t("date_oldest_first")}
</span>
</label>
</li>
<li>
@@ -71,9 +84,12 @@ export default function SortDropdown({ sortBy, setSort, t }: Props) {
name="sort-radio"
className="radio checked:bg-primary"
checked={sortBy === Sort.NameAZ}
onChange={() => setSort(Sort.NameAZ)}
onChange={() => {
resetInfiniteQueryPagination(queryClient, ["links"]);
setSort(Sort.NameAZ);
}}
/>
<span className="label-text">{t("name_az")}</span>
<span className="label-text whitespace-nowrap">{t("name_az")}</span>
</label>
</li>
<li>
@@ -87,41 +103,12 @@ export default function SortDropdown({ sortBy, setSort, t }: Props) {
name="sort-radio"
className="radio checked:bg-primary"
checked={sortBy === Sort.NameZA}
onChange={() => setSort(Sort.NameZA)}
onChange={() => {
resetInfiniteQueryPagination(queryClient, ["links"]);
setSort(Sort.NameZA);
}}
/>
<span className="label-text">{t("name_za")}</span>
</label>
</li>
<li>
<label
className="label cursor-pointer flex justify-start"
tabIndex={0}
role="button"
>
<input
type="radio"
name="sort-radio"
className="radio checked:bg-primary"
checked={sortBy === Sort.DescriptionAZ}
onChange={() => setSort(Sort.DescriptionAZ)}
/>
<span className="label-text">{t("description_az")}</span>
</label>
</li>
<li>
<label
className="label cursor-pointer flex justify-start"
tabIndex={0}
role="button"
>
<input
type="radio"
name="sort-radio"
className="radio checked:bg-primary"
checked={sortBy === Sort.DescriptionZA}
onChange={() => setSort(Sort.DescriptionZA)}
/>
<span className="label-text">{t("description_za")}</span>
<span className="label-text whitespace-nowrap">{t("name_za")}</span>
</label>
</li>
</ul>

77
components/Tab.tsx Normal file
View File

@@ -0,0 +1,77 @@
import clsx from "clsx";
import { useEffect, useRef, useState } from "react";
type Props = {
tabs: { name?: string; icon?: string }[];
activeTabIndex: number;
setActiveTabIndex: Function;
className?: string;
hideName?: boolean;
};
const Tab = ({
tabs,
activeTabIndex,
setActiveTabIndex,
className,
hideName,
}: Props) => {
const tabsRef = useRef<(HTMLElement | null)[]>([]);
const [tabUnderlineWidth, setTabUnderlineWidth] = useState(0);
const [tabUnderlineLeft, setTabUnderlineLeft] = useState(0);
const [isFirstRender, setIsFirstRender] = useState(true);
useEffect(() => {
const timer = setTimeout(() => {
setIsFirstRender(false);
}, 100);
return () => clearTimeout(timer);
}, []);
useEffect(() => {
if (activeTabIndex === null) {
return;
}
const setTabPosition = () => {
const currentTab = tabsRef.current[activeTabIndex] as HTMLElement;
setTabUnderlineLeft(currentTab?.offsetLeft ?? 0);
setTabUnderlineWidth(currentTab?.clientWidth ?? 0);
};
setTabPosition();
}, [activeTabIndex]);
return (
<div className={clsx("flew-row flex backdrop-blur-sm h-8 px-1", className)}>
<span
className={`absolute bottom-0 top-0 -z-10 flex overflow-hidden rounded-3xl py-1 ${
isFirstRender ? "" : "duration-100"
}`}
style={{ left: tabUnderlineLeft, width: tabUnderlineWidth }}
>
<span className="h-full w-full rounded-3xl bg-primary/50" />
</span>
{tabs.map((tab, index) => {
const isActive = activeTabIndex === index;
return (
<button
key={index}
ref={(el) => (tabsRef.current[index] = el)}
className={`${
isActive ? `` : `hover:opacity-75 duration-100`
} my-auto cursor-pointer select-none rounded-full px-2 text-center flex gap-1 items-center`}
onClick={() => setActiveTabIndex(index)}
title={tab.name}
>
{tab.icon && <i className={`text-lg ${tab.icon}`}></i>}
{!hideName && tab.name && <p>{tab.name}</p>}
</button>
);
})}
</div>
);
};
export default Tab;

View File

@@ -1,28 +1,34 @@
import useLocalSettingsStore from "@/store/localSettings";
import { useEffect, useState } from "react";
import { useEffect, useState, ChangeEvent } from "react";
import { useTranslation } from "next-i18next";
import clsx from "clsx";
type Props = {
className?: string;
align?: "left" | "right";
};
export default function ToggleDarkMode({ className }: Props) {
export default function ToggleDarkMode({ className, align }: Props) {
const { t } = useTranslation();
const { settings, updateSettings } = useLocalSettingsStore();
const [theme, setTheme] = useState(localStorage.getItem("theme"));
const [theme, setTheme] = useState<string | null>(
localStorage.getItem("theme")
);
const handleToggle = (e: any) => {
const handleToggle = (e: ChangeEvent<HTMLInputElement>) => {
setTheme(e.target.checked ? "dark" : "light");
};
useEffect(() => {
updateSettings({ theme: theme as string });
if (theme) {
updateSettings({ theme });
}
}, [theme]);
return (
<div
className="tooltip tooltip-bottom"
className={clsx("tooltip", align ? `tooltip-${align}` : "tooltip-bottom")}
data-tip={t("switch_to", {
theme: settings.theme === "light" ? "Dark" : "Light",
})}
@@ -34,7 +40,7 @@ export default function ToggleDarkMode({ className }: Props) {
type="checkbox"
onChange={handleToggle}
className="theme-controller"
checked={localStorage.getItem("theme") === "light" ? false : true}
checked={theme === "dark"}
/>
<i className="bi-sun-fill text-xl swap-on"></i>
<i className="bi-moon-fill text-xl swap-off"></i>

View File

@@ -1,4 +1,5 @@
import DeleteUserModal from "@/components/ModalContent/DeleteUserModal";
import { useConfig } from "@/hooks/store/config";
import { User as U } from "@prisma/client";
import { TFunction } from "i18next";
@@ -7,18 +8,26 @@ interface User extends U {
active: boolean;
};
}
type UserModal = {
isOpen: boolean;
userId: number | null;
};
const UserListing = (
users: User[],
deleteUserModal: UserModal,
setDeleteUserModal: Function,
t: TFunction<"translation", undefined>
) => {
interface UserListingProps {
users: User[];
deleteUserModal: UserModal;
setDeleteUserModal: (modal: UserModal) => void;
t: TFunction<"translation", undefined>;
}
const UserListing: React.FC<UserListingProps> = ({
users,
deleteUserModal,
setDeleteUserModal,
t,
}) => {
const { data: config } = useConfig();
return (
<div className="overflow-x-auto whitespace-nowrap w-full">
<table className="table w-full">
@@ -26,9 +35,7 @@ const UserListing = (
<tr>
<th></th>
<th>{t("username")}</th>
{process.env.NEXT_PUBLIC_EMAIL_PROVIDER === "true" && (
<th>{t("email")}</th>
)}
{config?.EMAIL_PROVIDER && <th>{t("email")}</th>}
{process.env.NEXT_PUBLIC_STRIPE === "true" && (
<th>{t("subscribed")}</th>
)}
@@ -46,9 +53,7 @@ const UserListing = (
<td>
{user.username ? user.username : <b>{t("not_available")}</b>}
</td>
{process.env.NEXT_PUBLIC_EMAIL_PROVIDER === "true" && (
<td>{user.email}</td>
)}
{config?.EMAIL_PROVIDER && <td>{user.email}</td>}
{process.env.NEXT_PUBLIC_STRIPE === "true" && (
<td>
{user.subscriptions?.active ? (
@@ -74,12 +79,12 @@ const UserListing = (
</tbody>
</table>
{deleteUserModal.isOpen && deleteUserModal.userId ? (
{deleteUserModal.isOpen && deleteUserModal.userId && (
<DeleteUserModal
onClose={() => setDeleteUserModal({ isOpen: false, userId: null })}
userId={deleteUserModal.userId}
/>
) : null}
)}
</div>
);
};

View File

@@ -1,7 +1,8 @@
import React, { Dispatch, SetStateAction, useEffect } from "react";
import useLocalSettingsStore from "@/store/localSettings";
import { ViewMode } from "@/types/global";
import { dropdownTriggerer } from "@/lib/client/utils";
import { useTranslation } from "next-i18next";
type Props = {
viewMode: ViewMode;
@@ -9,64 +10,138 @@ type Props = {
};
export default function ViewDropdown({ viewMode, setViewMode }: Props) {
const { updateSettings } = useLocalSettingsStore();
const onChangeViewMode = (
e: React.MouseEvent<HTMLButtonElement>,
viewMode: ViewMode
) => {
setViewMode(viewMode);
};
const { settings, updateSettings } = useLocalSettingsStore((state) => state);
const { t } = useTranslation();
useEffect(() => {
updateSettings({ viewMode });
}, [viewMode]);
}, [viewMode, updateSettings]);
const onChangeViewMode = (mode: ViewMode) => {
setViewMode(mode);
updateSettings({ viewMode });
};
const toggleShowSetting = (setting: keyof typeof settings.show) => {
const newShowSettings = {
...settings.show,
[setting]: !settings.show[setting],
};
updateSettings({ show: newShowSettings });
};
const onColumnsChange = (e: React.ChangeEvent<HTMLInputElement>) => {
updateSettings({ columns: Number(e.target.value) });
};
return (
<div className="p-1 flex flex-row gap-1 border border-neutral-content rounded-[0.625rem]">
<button
onClick={(e) => onChangeViewMode(e, ViewMode.Card)}
className={`btn btn-square btn-sm btn-ghost ${
viewMode == ViewMode.Card
? "bg-primary/20 hover:bg-primary/20"
: "hover:bg-neutral/20"
}`}
<div className="dropdown dropdown-bottom dropdown-end">
<div
tabIndex={0}
role="button"
onMouseDown={dropdownTriggerer}
className="btn btn-sm btn-square btn-ghost border-none"
>
<i className="bi-grid w-4 h-4 text-neutral"></i>
</button>
<button
onClick={(e) => onChangeViewMode(e, ViewMode.Masonry)}
className={`btn btn-square btn-sm btn-ghost ${
viewMode == ViewMode.Masonry
? "bg-primary/20 hover:bg-primary/20"
: "hover:bg-neutral/20"
}`}
{viewMode === ViewMode.Card ? (
<i className="bi-grid w-4 h-4 text-neutral"></i>
) : viewMode === ViewMode.Masonry ? (
<i className="bi-columns-gap w-4 h-4 text-neutral"></i>
) : (
<i className="bi-view-stacked w-4 h-4 text-neutral"></i>
)}
</div>
<ul
tabIndex={0}
className="dropdown-content z-[30] menu shadow bg-base-200 min-w-52 border border-neutral-content rounded-xl mt-1"
>
<i className="bi bi-columns-gap w-4 h-4 text-neutral"></i>
</button>
<button
onClick={(e) => onChangeViewMode(e, ViewMode.List)}
className={`btn btn-square btn-sm btn-ghost ${
viewMode == ViewMode.List
? "bg-primary/20 hover:bg-primary/20"
: "hover:bg-neutral/20"
}`}
>
<i className="bi bi-view-stacked w-4 h-4 text-neutral"></i>
</button>
{/* <button
onClick={(e) => onChangeViewMode(e, ViewMode.Grid)}
className={`btn btn-square btn-sm btn-ghost ${
viewMode == ViewMode.Grid
? "bg-primary/20 hover:bg-primary/20"
: "hover:bg-neutral/20"
}`}
>
<i className="bi-columns-gap w-4 h-4 text-neutral"></i>
</button> */}
<p className="mb-1 text-sm text-neutral">{t("view")}</p>
<div className="p-1 flex w-full justify-between gap-1 border border-neutral-content rounded-[0.625rem]">
<button
onClick={(e) => onChangeViewMode(ViewMode.Card)}
className={`btn w-[31%] btn-sm btn-ghost ${
viewMode === ViewMode.Card
? "bg-primary/20 hover:bg-primary/20"
: "hover:bg-neutral/20"
}`}
>
<i className="bi-grid text-lg text-neutral"></i>
</button>
<button
onClick={(e) => onChangeViewMode(ViewMode.Masonry)}
className={`btn w-[31%] btn-sm btn-ghost ${
viewMode === ViewMode.Masonry
? "bg-primary/20 hover:bg-primary/20"
: "hover:bg-neutral/20"
}`}
>
<i className="bi-columns-gap text-lg text-neutral"></i>
</button>
<button
onClick={(e) => onChangeViewMode(ViewMode.List)}
className={`btn w-[31%] btn-sm btn-ghost ${
viewMode === ViewMode.List
? "bg-primary/20 hover:bg-primary/20"
: "hover:bg-neutral/20"
}`}
>
<i className="bi-view-stacked text-lg text-neutral"></i>
</button>
</div>
<p className="mb-1 mt-2 text-sm text-neutral">{t("show")}</p>
{Object.entries(settings.show)
.filter((e) =>
settings.viewMode === ViewMode.List // Hide tags, image, and description checkboxes in list view
? e[0] !== "tags" && e[0] !== "image" && e[0] !== "description"
: settings.viewMode === ViewMode.Card // Hide tags and description checkboxes in card view
? e[0] !== "tags" && e[0] !== "description"
: true
)
.map(([key, value]) => (
<li key={key}>
<label className="label cursor-pointer flex justify-start">
<input
type="checkbox"
className="checkbox checkbox-primary"
checked={value}
onChange={() =>
toggleShowSetting(key as keyof typeof settings.show)
}
/>
<span className="label-text whitespace-nowrap">{t(key)}</span>
</label>
</li>
))}
{settings.viewMode !== ViewMode.List && (
<>
<p className="mb-1 mt-2 text-sm text-neutral">
{t("columns")}:{" "}
{settings.columns === 0 ? t("default") : settings.columns}
</p>
<div>
<input
type="range"
min={0}
max="8"
value={settings.columns}
onChange={(e) => onColumnsChange(e)}
className="range range-xs range-primary"
step="1"
/>
<div className="flex w-full justify-between px-2 text-xs text-neutral select-none">
<span>|</span>
<span>|</span>
<span>|</span>
<span>|</span>
<span>|</span>
<span>|</span>
<span>|</span>
<span>|</span>
<span>|</span>
</div>
</div>
</>
)}
</ul>
</div>
);
}

17
components/ui/Divider.tsx Normal file
View File

@@ -0,0 +1,17 @@
import clsx from "clsx";
import React from "react";
type Props = {
className?: string;
vertical?: boolean;
};
function Divider({ className, vertical = false }: Props) {
return vertical ? (
<hr className={clsx("border-neutral-content border-l h-full", className)} />
) : (
<hr className={clsx("border-neutral-content border-t", className)} />
);
}
export default Divider;

View File

@@ -1,4 +1,3 @@
version: "3.5"
services:
postgres:
image: postgres:16-alpine
@@ -11,11 +10,19 @@ services:
environment:
- DATABASE_URL=postgresql://postgres:${POSTGRES_PASSWORD}@postgres:5432/postgres
restart: always
# build: . # uncomment this line to build from source
image: ghcr.io/linkwarden/linkwarden:latest # comment this line to build from source
# build: . # uncomment to build from source
image: ghcr.io/linkwarden/linkwarden:latest # comment to build from source
ports:
- 3000:3000
volumes:
- ./data:/data/data
depends_on:
- postgres
- meilisearch
meilisearch:
image: getmeili/meilisearch:v1.12.8
restart: always
env_file:
- .env
volumes:
- ./meili_data:/meili_data

View File

@@ -2,19 +2,17 @@ import axios, { AxiosError } from "axios"
axios.defaults.baseURL = "http://localhost:3000"
export async function seedUser (username?: string, password?: string, name?: string) {
export async function seedUser(username?: string, password?: string, name?: string) {
try {
return await axios.post("/api/v1/users", {
username: username || "test",
password: password || "password",
name: name || "Test User",
})
} catch (e: any) {
if (e instanceof AxiosError) {
if (e.response?.status === 400) {
return
}
}
throw e
} catch (error) {
const axiosError = error as AxiosError;
if (axiosError && axiosError.response?.status === 400) return
throw error
}
}

View File

@@ -11,9 +11,6 @@ const useUsers = () => {
queryFn: async () => {
const response = await fetch("/api/v1/users");
if (!response.ok) {
if (response.status === 401) {
window.location.href = "/dashboard";
}
throw new Error("Failed to fetch users.");
}
@@ -30,8 +27,6 @@ const useAddUser = () => {
return useMutation({
mutationFn: async (body: any) => {
if (body.password.length < 8) throw new Error(t("password_length_error"));
const load = toast.loading(t("creating_account"));
const response = await fetch("/api/v1/users", {

24
hooks/store/config.tsx Normal file
View File

@@ -0,0 +1,24 @@
import { useQuery } from "@tanstack/react-query";
export type Config = {
DISABLE_REGISTRATION: boolean | null;
ADMIN: number | null;
RSS_POLLING_INTERVAL_MINUTES: number | null;
EMAIL_PROVIDER: boolean | null;
MAX_FILE_BUFFER: number | null;
AI_ENABLED: boolean | null;
};
const useConfig = () => {
return useQuery({
queryKey: ["config"],
queryFn: async () => {
const response = await fetch("/api/v1/config");
const data = await response.json();
return data.response as Config;
},
});
};
export { useConfig };

View File

@@ -1,4 +1,3 @@
import { LinkIncludingShortenedCollectionAndTags } from "@/types/global";
import { useQuery } from "@tanstack/react-query";
import { useSession } from "next-auth/react";
@@ -7,11 +6,11 @@ const useDashboardData = () => {
return useQuery({
queryKey: ["dashboardData"],
queryFn: async (): Promise<LinkIncludingShortenedCollectionAndTags[]> => {
const response = await fetch("/api/v1/dashboard");
queryFn: async () => {
const response = await fetch("/api/v2/dashboard");
const data = await response.json();
return data.response;
return data.data;
},
enabled: status === "authenticated",
});

View File

@@ -0,0 +1,87 @@
import {
useQuery,
useMutation,
useQueryClient,
UseQueryResult,
} from "@tanstack/react-query";
import { useSession } from "next-auth/react";
import { Highlight } from "@prisma/client";
import { PostHighlightSchemaType } from "@/lib/shared/schemaValidation";
const useGetLinkHighlights = (
linkId: number
): UseQueryResult<Highlight[], Error> => {
const { status } = useSession();
return useQuery({
queryKey: ["highlights", linkId],
queryFn: async () => {
const response = await fetch(`/api/v1/links/${linkId}/highlights`);
if (!response.ok) throw new Error("Failed to fetch highlights.");
const data = await response.json();
return data.response;
},
enabled: status === "authenticated",
});
};
const usePostHighlight = (linkId: number) => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (highlight: PostHighlightSchemaType) => {
const response = await fetch("/api/v1/highlights", {
body: JSON.stringify({ ...highlight }),
headers: {
"Content-Type": "application/json",
},
method: "POST",
});
const data = await response.json();
if (!response.ok) throw new Error(data.response);
return data.response;
},
onSuccess: (data: Highlight) => {
queryClient.setQueryData(
["highlights", linkId],
(oldData: Highlight[]) => {
const index = oldData.findIndex((h) => h?.id === data?.id);
if (index !== -1) {
const newData = [...oldData];
newData[index] = data;
return newData;
} else {
return [...oldData, data];
}
}
);
},
});
};
const useRemoveHighlight = (linkId: number) => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (highlightId: number) => {
const response = await fetch(`/api/v1/highlights/${highlightId}`, {
method: "DELETE",
});
const data = await response.json();
if (!response.ok) throw new Error(data.response);
return data.response;
},
onSuccess: (data) => {
queryClient.setQueryData(["highlights", linkId], (oldData: any) =>
oldData.filter((highlight: Highlight) => highlight.id !== data)
);
},
});
};
export { useGetLinkHighlights, usePostHighlight, useRemoveHighlight };

View File

@@ -1,7 +1,5 @@
import {
InfiniteData,
useInfiniteQuery,
UseInfiniteQueryResult,
useQueryClient,
useMutation,
} from "@tanstack/react-query";
@@ -13,6 +11,9 @@ import {
} from "@/types/global";
import { useRouter } from "next/router";
import { useSession } from "next-auth/react";
import { PostLinkSchemaType } from "@/lib/shared/schemaValidation";
import getFormatFromContentType from "@/lib/shared/getFormatFromContentType";
import getLinkTypeFromFormat from "@/lib/shared/getLinkTypeFromFormat";
const useLinks = (params: LinkRequestQuery = {}) => {
const router = useRouter();
@@ -20,23 +21,15 @@ const useLinks = (params: LinkRequestQuery = {}) => {
const queryParamsObject = {
sort: params.sort ?? Number(window.localStorage.getItem("sortBy")) ?? 0,
collectionId:
params.collectionId ?? router.pathname === "/collections/[id]"
? router.query.id
: undefined,
params.collectionId ??
(router.pathname === "/collections/[id]" ? router.query.id : undefined),
tagId:
params.tagId ?? router.pathname === "/tags/[id]"
? router.query.id
: undefined,
params.tagId ??
(router.pathname === "/tags/[id]" ? router.query.id : undefined),
pinnedOnly:
params.pinnedOnly ?? router.pathname === "/links/pinned"
? true
: undefined,
params.pinnedOnly ??
(router.pathname === "/links/pinned" ? true : undefined),
searchQueryString: params.searchQueryString,
searchByName: params.searchByName,
searchByUrl: params.searchByUrl,
searchByDescription: params.searchByDescription,
searchByTextContent: params.searchByTextContent,
searchByTags: params.searchByTags,
} as LinkRequestQuery;
const queryString = buildQueryString(queryParamsObject);
@@ -44,17 +37,14 @@ const useLinks = (params: LinkRequestQuery = {}) => {
const { data, ...rest } = useFetchLinks(queryString);
const links = useMemo(() => {
return data?.pages.reduce((acc, page) => {
return [...acc, ...page];
}, []);
return data?.pages?.flatMap((page) => page?.links ?? []) ?? [];
}, [data]);
const memoizedData = useMemo(() => ({ ...data, ...rest }), [data, rest]);
return {
links,
data: { ...data, ...rest },
} as {
links: LinkIncludingShortenedCollectionAndTags[];
data: UseInfiniteQueryResult<InfiniteData<any, unknown>, Error>;
data: memoizedData,
};
};
@@ -65,7 +55,7 @@ const useFetchLinks = (params: string) => {
queryKey: ["links", { params }],
queryFn: async (params) => {
const response = await fetch(
"/api/v1/links?cursor=" +
"/api/v1/search?cursor=" +
params.pageParam +
((params.queryKey[1] as any).params
? "&" + (params.queryKey[1] as any).params
@@ -73,15 +63,18 @@ const useFetchLinks = (params: string) => {
);
const data = await response.json();
return data.response;
return {
links: data.data.links as LinkIncludingShortenedCollectionAndTags[],
nextCursor: data.data.nextCursor as number | null,
};
},
initialPageParam: 0,
refetchOnWindowFocus: false,
getNextPageParam: (lastPage) => {
if (lastPage.length === 0) {
if (lastPage.nextCursor === null) {
return undefined;
}
return lastPage.at(-1).id;
return lastPage.nextCursor;
},
enabled: status === "authenticated",
});
@@ -103,7 +96,15 @@ const useAddLink = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (link: LinkIncludingShortenedCollectionAndTags) => {
mutationFn: async (link: PostLinkSchemaType) => {
if (link.url || link.type === "url") {
try {
new URL(link.url || "");
} catch (error) {
throw new Error("invalid_url_guide");
}
}
const response = await fetch("/api/v1/links", {
method: "POST",
headers: {
@@ -118,16 +119,25 @@ const useAddLink = () => {
return data.response;
},
onSuccess: (data) => {
onSuccess: (data: LinkIncludingShortenedCollectionAndTags[]) => {
queryClient.setQueryData(["dashboardData"], (oldData: any) => {
if (!oldData) return undefined;
return [data, ...oldData];
if (!oldData?.links) return undefined;
return {
...oldData,
links: [data, ...oldData?.links],
};
});
queryClient.setQueriesData({ queryKey: ["links"] }, (oldData: any) => {
if (!oldData) return undefined;
return {
pages: [[data, ...oldData?.pages[0]], ...oldData?.pages.slice(1)],
pages: [
{
links: [data, ...oldData?.pages?.[0]?.links],
nextCursor: oldData?.pages?.[0]?.nextCursor,
},
...oldData?.pages?.slice(1),
],
pageParams: oldData?.pageParams,
};
});
@@ -159,21 +169,8 @@ const useUpdateLink = () => {
return data.response;
},
onSuccess: (data) => {
queryClient.setQueryData(["dashboardData"], (oldData: any) => {
if (!oldData) return undefined;
return oldData.map((e: any) => (e.id === data.id ? data : e));
});
queryClient.setQueriesData({ queryKey: ["links"] }, (oldData: any) => {
if (!oldData) return undefined;
return {
pages: oldData.pages.map((page: any) =>
page.map((item: any) => (item.id === data.id ? data : item))
),
pageParams: oldData.pageParams,
};
});
queryClient.invalidateQueries({ queryKey: ["links"] });
queryClient.invalidateQueries({ queryKey: ["dashboardData"] });
queryClient.invalidateQueries({ queryKey: ["collections"] });
queryClient.invalidateQueries({ queryKey: ["tags"] });
queryClient.invalidateQueries({ queryKey: ["publicLinks"] });
@@ -198,16 +195,21 @@ const useDeleteLink = () => {
},
onSuccess: (data) => {
queryClient.setQueryData(["dashboardData"], (oldData: any) => {
if (!oldData) return undefined;
return oldData.filter((e: any) => e.id !== data.id);
if (!oldData?.links) return undefined;
return {
...oldData,
links: oldData.links.filter((e: any) => e.id !== data.id),
};
});
queryClient.setQueriesData({ queryKey: ["links"] }, (oldData: any) => {
if (!oldData) return undefined;
if (!oldData?.pages?.[0]) return undefined;
return {
pages: oldData.pages.map((page: any) =>
page.filter((item: any) => item.id !== data.id)
),
pages: oldData.pages.map((page: any) => ({
links: page.links.filter((item: any) => item.id !== data.id),
nextCursor: page.nextCursor,
})),
pageParams: oldData.pageParams,
};
});
@@ -222,32 +224,80 @@ const useDeleteLink = () => {
const useGetLink = () => {
const queryClient = useQueryClient();
const router = useRouter();
return useMutation({
mutationFn: async (id: number) => {
const response = await fetch(`/api/v1/links/${id}`);
mutationFn: async ({
id,
isPublicRoute = router.pathname.startsWith("/public") ? true : undefined,
}: {
id: number;
isPublicRoute?: boolean;
}) => {
const path = isPublicRoute
? `/api/v1/public/links/${id}`
: `/api/v1/links/${id}`;
const response = await fetch(path);
const data = await response.json();
if (!response.ok) throw new Error(data.response);
return data.response;
},
onSuccess: (data) => {
onSuccess: (data: LinkIncludingShortenedCollectionAndTags) => {
queryClient.setQueryData(["dashboardData"], (oldData: any) => {
if (!oldData) return undefined;
return oldData.map((e: any) => (e.id === data.id ? data : e));
if (!oldData?.links) return undefined;
return {
...oldData,
links: oldData.links.map((e: any) => (e.id === data.id ? data : e)),
};
});
queryClient.setQueriesData({ queryKey: ["links"] }, (oldData: any) => {
if (!oldData) return undefined;
const newPages = oldData.pages?.map((page: any) => {
if (!page?.links) {
return page;
}
return {
...page,
links: page.links.map((item: any) =>
item.id === data.id ? data : item
),
};
});
return {
pages: oldData.pages.map((page: any) =>
page.map((item: any) => (item.id === data.id ? data : item))
),
pageParams: oldData.pageParams,
...oldData,
pages: newPages,
};
});
queryClient.invalidateQueries({ queryKey: ["publicLinks"] });
queryClient.setQueriesData(
{ queryKey: ["publicLinks"] },
(oldData: any) => {
if (!oldData) return undefined;
const newPages = oldData.pages?.map((page: any) => {
if (!page?.links) {
return page;
}
return {
...page,
links: page.links.map((item: any) =>
item.id === data.id ? data : item
),
};
});
return {
...oldData,
pages: newPages,
};
}
);
},
});
};
@@ -272,21 +322,18 @@ const useBulkDeleteLinks = () => {
return linkIds;
},
onSuccess: (data) => {
queryClient.setQueryData(["dashboardData"], (oldData: any) => {
if (!oldData) return undefined;
return oldData.filter((e: any) => !data.includes(e.id));
});
queryClient.setQueriesData({ queryKey: ["links"] }, (oldData: any) => {
if (!oldData) return undefined;
return {
pages: oldData.pages.map((page: any) =>
page.filter((item: any) => !data.includes(item.id))
),
pages: oldData.pages.map((page: any) => ({
links: page.links.filter((item: any) => !data.includes(item.id)),
nextCursor: page.nextCursor,
})),
pageParams: oldData.pageParams,
};
});
queryClient.invalidateQueries({ queryKey: ["dashboardData"] });
queryClient.invalidateQueries({ queryKey: ["collections"] });
queryClient.invalidateQueries({ queryKey: ["tags"] });
queryClient.invalidateQueries({ queryKey: ["publicLinks"] });
@@ -299,21 +346,8 @@ const useUploadFile = () => {
return useMutation({
mutationFn: async ({ link, file }: any) => {
let fileType: ArchivedFormat | null = null;
let linkType: "url" | "image" | "pdf" | null = null;
if (file?.type === "image/jpg" || file.type === "image/jpeg") {
fileType = ArchivedFormat.jpeg;
linkType = "image";
} else if (file.type === "image/png") {
fileType = ArchivedFormat.png;
linkType = "image";
} else if (file.type === "application/pdf") {
fileType = ArchivedFormat.pdf;
linkType = "pdf";
} else {
return { ok: false, data: "Invalid file type." };
}
let format = getFormatFromContentType(file?.type);
let linkType = getLinkTypeFromFormat(format);
const response = await fetch("/api/v1/links", {
body: JSON.stringify({
@@ -336,7 +370,7 @@ const useUploadFile = () => {
file && formBody.append("file", file);
await fetch(
`/api/v1/archives/${(data as any).response.id}?format=${fileType}`,
`/api/v1/archives/${(data as any).response.id}?format=${format}`,
{
body: formBody,
method: "POST",
@@ -348,14 +382,23 @@ const useUploadFile = () => {
},
onSuccess: (data) => {
queryClient.setQueryData(["dashboardData"], (oldData: any) => {
if (!oldData) return undefined;
return [data, ...oldData];
if (!oldData?.links) return undefined;
return {
...oldData,
links: [data, ...oldData?.links],
};
});
queryClient.setQueriesData({ queryKey: ["links"] }, (oldData: any) => {
if (!oldData) return undefined;
return {
pages: [[data, ...oldData?.pages[0]], ...oldData?.pages.slice(1)],
pages: [
{
links: [data, ...oldData?.pages?.[0]?.links],
nextCursor: oldData?.pages?.[0].nextCursor,
},
...oldData?.pages?.slice(1),
],
pageParams: oldData?.pageParams,
};
});
@@ -367,6 +410,54 @@ const useUploadFile = () => {
});
};
const useUpdateFile = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async ({
linkId,
file,
isPreview,
}: {
linkId: number;
file: File;
isPreview?: boolean;
}) => {
const formBody = new FormData();
let format = getFormatFromContentType(file?.type);
if (isPreview) format = ArchivedFormat.jpeg;
if (!linkId || !file)
throw new Error("Error generating preview: Invalid parameters");
formBody.append("file", file);
const res = await fetch(
`/api/v1/archives/${linkId}?format=` +
format +
(isPreview ? "&preview=true" : ""),
{
body: formBody,
method: "POST",
}
);
const data = res.json();
return data;
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["links"] });
queryClient.invalidateQueries({ queryKey: ["dashboardData"] });
queryClient.invalidateQueries({ queryKey: ["collections"] });
queryClient.invalidateQueries({ queryKey: ["tags"] });
queryClient.invalidateQueries({ queryKey: ["publicLinks"] });
},
});
};
const useBulkEditLinks = () => {
const queryClient = useQueryClient();
@@ -397,27 +488,9 @@ const useBulkEditLinks = () => {
return data.response;
},
onSuccess: (data, { links, newData, removePreviousTags }) => {
queryClient.setQueryData(["dashboardData"], (oldData: any) => {
if (!oldData) return undefined;
return oldData.map((e: any) =>
data.find((d: any) => d.id === e.id) ? data : e
);
});
// TODO: Fix this
// queryClient.setQueriesData({ queryKey: ["links"] }, (oldData: any) => {
// if (!oldData) return undefined;
// return {
// pages: oldData.pages.map((page: any) => for (item of links) {
// page.map((item: any) => (item.id === data.id ? data : item))
// }
// ),
// pageParams: oldData.pageParams,
// };
// });
queryClient.invalidateQueries({ queryKey: ["links"] }); // Temporary workaround
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["links"] });
queryClient.invalidateQueries({ queryKey: ["dashboardData"] });
queryClient.invalidateQueries({ queryKey: ["collections"] });
queryClient.invalidateQueries({ queryKey: ["tags"] });
queryClient.invalidateQueries({ queryKey: ["publicLinks"] });
@@ -425,6 +498,22 @@ const useBulkEditLinks = () => {
});
};
const resetInfiniteQueryPagination = async (
queryClient: any,
queryKey: any
) => {
queryClient.setQueriesData({ queryKey }, (oldData: any) => {
if (!oldData) return undefined;
return {
pages: oldData.pages.slice(0, 1),
pageParams: oldData.pageParams.slice(0, 1),
};
});
await queryClient.invalidateQueries(queryKey);
};
export {
useLinks,
useAddLink,
@@ -434,4 +523,6 @@ export {
useUploadFile,
useGetLink,
useBulkEditLinks,
resetInfiniteQueryPagination,
useUpdateFile,
};

View File

@@ -1,8 +1,4 @@
import {
InfiniteData,
useInfiniteQuery,
UseInfiniteQueryResult,
} from "@tanstack/react-query";
import { useInfiniteQuery } from "@tanstack/react-query";
import { useMemo } from "react";
import {
LinkIncludingShortenedCollectionAndTags,
@@ -25,11 +21,6 @@ const usePublicLinks = (params: LinkRequestQuery = {}) => {
? true
: undefined,
searchQueryString: params.searchQueryString,
searchByName: params.searchByName,
searchByUrl: params.searchByUrl,
searchByDescription: params.searchByDescription,
searchByTextContent: params.searchByTextContent,
searchByTags: params.searchByTags,
} as LinkRequestQuery;
const queryString = buildQueryString(queryParamsObject);
@@ -37,23 +28,20 @@ const usePublicLinks = (params: LinkRequestQuery = {}) => {
const { data, ...rest } = useFetchLinks(queryString);
const links = useMemo(() => {
return data?.pages.reduce((acc, page) => {
return [...acc, ...page];
}, []);
return data?.pages?.flatMap((page) => page?.links ?? []) ?? [];
}, [data]);
const memoizedData = useMemo(() => ({ ...data, ...rest }), [data, rest]);
return {
links,
data: { ...data, ...rest },
} as {
links: LinkIncludingShortenedCollectionAndTags[];
data: UseInfiniteQueryResult<InfiniteData<any, unknown>, Error>;
data: memoizedData,
};
};
const useFetchLinks = (params: string) => {
return useInfiniteQuery({
queryKey: ["links", { params }],
queryKey: ["publicLinks", { params }],
queryFn: async (params) => {
const response = await fetch(
"/api/v1/public/collections/links?cursor=" +
@@ -62,18 +50,20 @@ const useFetchLinks = (params: string) => {
? "&" + (params.queryKey[1] as any).params
: "")
);
const data = await response.json();
return data.response;
return {
links: data.data.links as LinkIncludingShortenedCollectionAndTags[],
nextCursor: data.data.nextCursor as number | null,
};
},
initialPageParam: 0,
refetchOnWindowFocus: false,
getNextPageParam: (lastPage) => {
if (lastPage.length === 0) {
if (lastPage.nextCursor === null) {
return undefined;
}
return lastPage.at(-1).id;
return lastPage.nextCursor;
},
});
};

View File

@@ -0,0 +1,29 @@
import { Tag } from "@prisma/client";
import { useQuery, UseQueryResult } from "@tanstack/react-query";
import { useSession } from "next-auth/react";
import { useRouter } from "next/router";
type TagIncludingCount = Tag & { _count: { links: number } };
const usePublicTags = (): UseQueryResult<TagIncludingCount[]> => {
const { status } = useSession();
const router = useRouter();
return useQuery({
queryKey: ["tags"],
queryFn: async () => {
const response = await fetch(
"/api/v1/public/collections/tags" +
"?collectionId=" +
router.query.id || ""
);
if (!response.ok) throw new Error("Failed to fetch tags.");
const data = await response.json();
return data.response;
},
});
};
export { usePublicTags };

69
hooks/store/rss.tsx Normal file
View File

@@ -0,0 +1,69 @@
import { RssSubscription } from "@prisma/client";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { useSession } from "next-auth/react";
interface RssSubscriptionWithCollectionName extends RssSubscription {
collection: {
name: string;
};
}
const useRssSubscriptions = () => {
const { status } = useSession();
return useQuery({
queryKey: ["rss-subscriptions"],
queryFn: async () => {
const response = await fetch("/api/v1/rss");
if (!response.ok) throw new Error("Failed to fetch rss subscriptions.");
const data = await response.json();
return data.response as RssSubscriptionWithCollectionName[];
},
enabled: status === "authenticated",
});
};
const useAddRssSubscription = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (body: Partial<RssSubscription>) => {
const response = await fetch("/api/v1/rss", {
body: JSON.stringify(body),
method: "POST",
headers: {
"Content-Type": "application/json",
},
});
const data = await response.json();
if (!response.ok) throw new Error(data.response);
return data.response;
},
onSuccess: (data) => {
queryClient.invalidateQueries({ queryKey: ["rss-subscriptions"] });
},
});
};
const useDeleteRssSubscription = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (rssSubscriptionId: number) => {
const response = await fetch(`/api/v1/rss/${rssSubscriptionId}`, {
method: "DELETE",
});
const data = await response.json();
if (!response.ok) throw new Error(data.response);
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["rss-subscriptions"] });
},
});
};
export { useRssSubscriptions, useAddRssSubscription, useDeleteRssSubscription };

View File

@@ -1,8 +1,15 @@
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import {
useQuery,
useMutation,
useQueryClient,
UseQueryResult,
} from "@tanstack/react-query";
import { TagIncludingLinkCount } from "@/types/global";
import { useSession } from "next-auth/react";
import { Tag } from "@prisma/client";
import { ArchivalTagOption } from "@/components/InputSelect/types";
const useTags = () => {
const useTags = (): UseQueryResult<Tag[], Error> => {
const { status } = useSession();
return useQuery({
@@ -46,6 +53,44 @@ const useUpdateTag = () => {
});
};
const useUpdateArchivalTags = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (tags: ArchivalTagOption[]) => {
const response = await fetch("/api/v1/tags", {
body: JSON.stringify({ tags }),
headers: {
"Content-Type": "application/json",
},
method: "POST",
});
const data = await response.json();
if (!response.ok) throw new Error(data.response);
return data.response;
},
onSuccess: (data: TagIncludingLinkCount[]) => {
queryClient.setQueryData(
["tags"],
(oldData: TagIncludingLinkCount[] = []) => {
const updatedTags = oldData.map((tag) => {
const updatedTag = data.find((t) => t.id === tag.id);
return updatedTag ? { ...tag, ...updatedTag } : tag;
});
const newTags = data.filter(
(t) => !oldData.some((tag) => tag.id === t.id)
);
return [...updatedTags, ...newTags];
}
);
},
});
};
const useRemoveTag = () => {
const queryClient = useQueryClient();
@@ -68,4 +113,4 @@ const useRemoveTag = () => {
});
};
export { useTags, useUpdateTag, useRemoveTag };
export { useTags, useUpdateTag, useUpdateArchivalTags, useRemoveTag };

View File

@@ -27,6 +27,9 @@ const useAddToken = () => {
const response = await fetch("/api/v1/tokens", {
body: JSON.stringify(body),
method: "POST",
headers: {
"Content-Type": "application/json",
},
});
const data = await response.json();

176
hooks/useArchivalTags.ts Normal file
View File

@@ -0,0 +1,176 @@
import { Tag } from "@prisma/client";
import {
ArchivalOptionKeys,
ArchivalTagOption,
} from "../components/InputSelect/types";
import { useState, useEffect } from "react";
import { useTranslation } from "react-i18next";
import isArchivalTag from "@/lib/shared/isArchivalTag";
const useArchivalTags = (initialTags: Tag[]) => {
const [archivalTags, setArchivalTags] = useState<ArchivalTagOption[]>([]);
const [options, setOptions] = useState<ArchivalTagOption[]>([]);
const { t } = useTranslation();
useEffect(() => {
if (!initialTags) return;
const transformTag = (tag: Tag): ArchivalTagOption => ({
label: tag.name,
archiveAsScreenshot: tag.archiveAsScreenshot || false,
archiveAsMonolith: tag.archiveAsMonolith || false,
archiveAsPDF: tag.archiveAsPDF || false,
archiveAsReadable: tag.archiveAsReadable || false,
archiveAsWaybackMachine: tag.archiveAsWaybackMachine || false,
aiTag: tag.aiTag || false,
});
const archival = initialTags.filter(isArchivalTag).map(transformTag);
const nonArchival = initialTags
.filter((tag) => !isArchivalTag(tag))
.map(transformTag);
setArchivalTags(archival);
setOptions(nonArchival);
}, [initialTags]);
const addTags = (newTags: ArchivalTagOption[]) => {
const newTag = newTags.map(({ value, ...tag }) => {
// Check if a tag with the same label already exists
const existingTag = archivalTags.find(
(archiveTag) => archiveTag.label === tag.label
);
// If it exists, return the existing tag with archive values set to false
if (existingTag) {
return {
...existingTag,
archiveAsScreenshot: false,
archiveAsMonolith: false,
archiveAsPDF: false,
archiveAsReadable: false,
archiveAsWaybackMachine: false,
aiTag: false,
};
}
// If it doesn't exist, create a new tag with default values
return {
...tag,
archiveAsScreenshot: false,
archiveAsMonolith: false,
archiveAsPDF: false,
archiveAsReadable: false,
archiveAsWaybackMachine: false,
aiTag: false,
};
});
// Filter out any existing tags with matching labels before adding new ones
setArchivalTags((prev) => {
const filteredPrev = prev.filter(
(prevTag) => !newTags.some(({ label }) => label === prevTag.label)
);
return [...filteredPrev, ...newTag];
});
setOptions((prev) =>
prev.filter(
(option) => !newTags.some(({ label }) => label === option.label)
)
);
};
const toggleOption = (
tag: ArchivalTagOption,
option: keyof ArchivalTagOption
) => {
setArchivalTags((prev) =>
prev.map((t) =>
t.label === tag.label ? { ...t, [option]: !t[option] } : t
)
);
};
const removeTag = (tagToDelete: ArchivalTagOption) => {
if (!tagToDelete.__isNew__) {
// Set all the values to null so we can delete the archive settings from the database
setArchivalTags((prev) =>
prev.map((t) =>
t.label === tagToDelete.label
? {
...t,
archiveAsScreenshot: null,
archiveAsMonolith: null,
archiveAsPDF: null,
archiveAsReadable: null,
archiveAsWaybackMachine: null,
aiTag: null,
}
: t
)
);
const resetTag: ArchivalTagOption = {
...tagToDelete,
archiveAsScreenshot: false,
archiveAsMonolith: false,
archiveAsPDF: false,
archiveAsReadable: false,
archiveAsWaybackMachine: false,
aiTag: false,
};
setOptions((prev) => {
if (!prev.some((t) => t.label === resetTag.label)) {
return [...prev, resetTag];
}
return prev;
});
} else {
setArchivalTags((prev) =>
prev.filter((t) => t.label !== tagToDelete.label)
);
}
};
const ARCHIVAL_OPTIONS: {
type: ArchivalOptionKeys;
icon: string;
label: string;
}[] = [
{ type: "aiTag", icon: "bi-tag", label: t("ai_tagging") },
{
type: "archiveAsScreenshot",
icon: "bi-file-earmark-image",
label: t("screenshot"),
},
{
type: "archiveAsMonolith",
icon: "bi-filetype-html",
label: t("webpage"),
},
{ type: "archiveAsPDF", icon: "bi-file-earmark-pdf", label: t("pdf") },
{
type: "archiveAsReadable",
icon: "bi-file-earmark-text",
label: t("readable"),
},
{
type: "archiveAsWaybackMachine",
icon: "bi-archive",
label: t("archive_org_snapshot"),
},
];
return {
ARCHIVAL_OPTIONS,
archivalTags,
options,
addTags,
toggleOption,
removeTag,
};
};
export { useArchivalTags };

View File

@@ -5,7 +5,6 @@ import useLocalSettingsStore from "@/store/localSettings";
export default function useInitialData() {
const { status, data } = useSession();
const { setSettings } = useLocalSettingsStore();
useEffect(() => {
setSettings();
}, [status, data]);

View File

@@ -26,16 +26,8 @@ export default function useSort<
if (sortBy === Sort.NameAZ)
setData(dataArray.sort((a, b) => a.name.localeCompare(b.name)));
else if (sortBy === Sort.DescriptionAZ)
setData(
dataArray.sort((a, b) => a.description.localeCompare(b.description))
);
else if (sortBy === Sort.NameZA)
setData(dataArray.sort((a, b) => b.name.localeCompare(a.name)));
else if (sortBy === Sort.DescriptionZA)
setData(
dataArray.sort((a, b) => b.description.localeCompare(a.description))
);
else if (sortBy === Sort.DateNewestFirst)
setData(
dataArray.sort(

View File

@@ -1,4 +1,4 @@
import React, { useState, useEffect } from "react";
import { useState, useEffect } from "react";
export default function useWindowDimensions() {
const [dimensions, setDimensions] = useState({

View File

@@ -23,7 +23,10 @@ export default function AuthRedirect({ children }: Props) {
const isUnauthenticated = status === "unauthenticated";
const isPublicPage = router.pathname.startsWith("/public");
const hasInactiveSubscription =
user.id && !user.subscription?.active && stripeEnabled;
user.id &&
!user.subscription?.active &&
!user.parentSubscription?.active &&
stripeEnabled;
// There are better ways of doing this... but this one works for now
const routes = [
@@ -49,6 +52,8 @@ export default function AuthRedirect({ children }: Props) {
} else {
if (isLoggedIn && hasInactiveSubscription) {
redirectTo("/subscribe");
} else if (isLoggedIn && !user.name && user.parentSubscriptionId) {
redirectTo("/member-onboarding");
} else if (
isLoggedIn &&
!routes.some((e) => router.pathname.startsWith(e.path) && e.isProtected)

View File

@@ -23,7 +23,7 @@ export default function CenteredForm({
data-testid={dataTestId}
>
<div className="m-auto flex flex-col gap-2 w-full">
{settings.theme ? (
{settings.theme && (
<Image
src={`/linkwarden_${
settings.theme === "dark" ? "dark" : "light"
@@ -33,12 +33,12 @@ export default function CenteredForm({
alt="Linkwarden"
className="h-12 w-fit mx-auto"
/>
) : undefined}
{text ? (
)}
{text && (
<p className="text-lg max-w-[30rem] min-w-80 w-full mx-auto font-semibold px-2 text-center">
{text}
</p>
) : undefined}
)}
{children}
<p className="text-center text-xs text-neutral mb-5">
<Trans

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