Compare commits

...

262 Commits

Author SHA1 Message Date
daniel31x13
4e14149dfe Accepted incoming changes 2024-11-12 10:09:02 -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
daniel31x13
dbd096ab76 Revert "undo commit"
This reverts commit 9103f67db5.
2024-11-03 03:27:52 -05:00
daniel31x13
e37702aa14 Merge branch 'main' of https://github.com/linkwarden/linkwarden 2024-11-03 03:25:06 -05:00
daniel31x13
9103f67db5 undo commit 2024-11-03 03:25:01 -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
daniel31x13
8031432995 bug fix 2024-08-14 16:44:07 -04:00
daniel31x13
9cc3a7206e changes and improvements 2024-08-14 15:22:28 -04:00
daniel31x13
d15d965139 added skeleton loading 2024-08-14 13:14:06 -04:00
daniel31x13
bc04ea0fe8 fixed other views alongside card view 2024-08-13 03:19:28 -04:00
daniel31x13
bd34dacf21 bugs fixed 2024-08-13 03:01:02 -04:00
daniel31x13
80f366cd7b refactored link state management + a lot of other changes... 2024-08-13 00:08:57 -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
daniel31x13
a73e5fa6c6 add initialData to queries 2024-08-01 18:40:08 -04:00
daniel31x13
75b1ae738f remove unused code 2024-08-01 17:43:46 -04:00
daniel31x13
8563a09a07 refactor token store 2024-08-01 17:42:57 -04:00
daniel31x13
da8dc83b8f refactor tags store 2024-08-01 17:23:51 -04:00
daniel31x13
e889509697 refactor (admin/)users store 2024-08-01 16:54:19 -04: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
daniel31x13
be5400f7cb rename users hook to user 2024-07-31 14:15:50 -04:00
daniel31x13
099bc9e054 remove old code 2024-07-30 23:23:58 -04:00
daniel31x13
5c5dd967c4 refactor account store + much smoother collection listing updates 2024-07-30 23:19:29 -04:00
daniel31x13
d1ed33b532 bug fix 2024-07-30 14:59:18 -04:00
daniel31x13
05c5bdf63c refactor collections store 2024-07-30 14:57:09 -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
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
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
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
133 changed files with 10424 additions and 3368 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=
@@ -33,6 +34,8 @@ SCREENSHOT_MAX_BUFFER=
READABILITY_MAX_BUFFER=
PREVIEW_MAX_BUFFER=
IMPORT_LIMIT=
PLAYWRIGHT_LAUNCH_OPTIONS_EXECUTABLE_PATH=
MAX_WORKERS=
# AWS S3 Settings
SPACES_KEY=

View File

@@ -1,17 +1,18 @@
<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>
<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://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>
</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)
</div>
@@ -24,7 +25,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" />
@@ -71,10 +72,14 @@ We've forked the old version from the current repository into [this repo](https:
- ⬇️ Import and export your bookmarks.
- 🔐 SSO integration. (Enterprise and Self-hosted users only)
- 📦 Installable Progressive Web App (PWA).
- 🍏 iOS and MacOS Apps, maintained by [JGeek00](https://github.com/JGeek00).
- 🍎 iOS Shortcut to save links to Linkwarden.
- 🔑 API keys.
- ✅ Bulk actions.
- ✨ And so many more features!
- 👥 User administration.
- 🌐 Support for Other Languages (i18n).
- 📁 Image and PDF Uploads.
- ✨ And many more features. (Literally!)
## Like what we're doing? Give us a Star ⭐
@@ -98,7 +103,7 @@ 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
## Documentation
For information on how to get started or to set up your own instance, please visit the [documentation](https://docs.linkwarden.app).
@@ -110,7 +115,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

@@ -5,12 +5,12 @@ import ProfilePhoto from "./ProfilePhoto";
import usePermissions from "@/hooks/usePermissions";
import useLocalSettingsStore from "@/store/localSettings";
import getPublicUserData from "@/lib/client/getPublicUserData";
import useAccountStore from "@/store/account";
import EditCollectionModal from "./ModalContent/EditCollectionModal";
import EditCollectionSharingModal from "./ModalContent/EditCollectionSharingModal";
import DeleteCollectionModal from "./ModalContent/DeleteCollectionModal";
import { dropdownTriggerer } from "@/lib/client/utils";
import { useTranslation } from "next-i18next";
import { useUser } from "@/hooks/store/user";
type Props = {
collection: CollectionIncludingMembersAndLinkCount;
@@ -20,7 +20,7 @@ type Props = {
export default function CollectionCard({ collection, className }: Props) {
const { t } = useTranslation();
const { settings } = useLocalSettingsStore();
const { account } = useAccountStore();
const { data: user = {} } = useUser();
const formattedDate = new Date(collection.createdAt as string).toLocaleString(
"en-US",
@@ -45,18 +45,18 @@ export default function CollectionCard({ collection, className }: Props) {
useEffect(() => {
const fetchOwner = async () => {
if (collection && collection.ownerId !== account.id) {
if (collection && collection.ownerId !== user.id) {
const owner = await getPublicUserData(collection.ownerId as number);
setCollectionOwner(owner);
} else if (collection && collection.ownerId === account.id) {
} else if (collection && collection.ownerId === user.id) {
setCollectionOwner({
id: account.id as number,
name: account.name,
username: account.username as string,
image: account.image as string,
archiveAsScreenshot: account.archiveAsScreenshot as boolean,
archiveAsMonolith: account.archiveAsMonolith as boolean,
archiveAsPDF: account.archiveAsPDF as boolean,
id: user.id as number,
name: user.name,
username: user.username as string,
image: user.image as string,
archiveAsScreenshot: user.archiveAsScreenshot as boolean,
archiveAsMonolith: user.archiveAsMonolith as boolean,
archiveAsPDF: user.archiveAsPDF as boolean,
});
}
};
@@ -80,7 +80,7 @@ export default function CollectionCard({ collection, className }: Props) {
>
<i className="bi-three-dots text-xl" title="More"></i>
</div>
<ul className="dropdown-content z-[30] menu shadow bg-base-200 border border-neutral-content rounded-box 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 +90,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 +104,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 +119,7 @@ export default function CollectionCard({ collection, className }: Props) {
(document?.activeElement as HTMLElement)?.blur();
setDeleteCollectionModal(true);
}}
className="whitespace-nowrap"
>
{permissions === true
? t("delete_collection")

View File

@@ -9,14 +9,14 @@ import Tree, {
TreeSourcePosition,
TreeDestinationPosition,
} from "@atlaskit/tree";
import useCollectionStore from "@/store/collections";
import { Collection } from "@prisma/client";
import Link from "next/link";
import { CollectionIncludingMembersAndLinkCount } from "@/types/global";
import { useRouter } from "next/router";
import useAccountStore from "@/store/account";
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";
interface ExtendedTreeItem extends TreeItem {
data: Collection;
@@ -24,53 +24,50 @@ interface ExtendedTreeItem extends TreeItem {
const CollectionListing = () => {
const { t } = useTranslation();
const { collections, updateCollection } = useCollectionStore();
const { account, updateAccount } = useAccountStore();
const updateCollection = useUpdateCollection();
const { data: collections = [], isLoading } = useCollections();
const { data: user = {}, refetch } = useUser();
const updateUser = useUpdateUser();
const router = useRouter();
const currentPath = router.asPath;
const [tree, setTree] = useState<TreeData | undefined>();
const initialTree = useMemo(() => {
if (collections.length > 0) {
return buildTreeFromCollections(
collections,
router,
account.collectionOrder
user.collectionOrder
);
}
return undefined;
}, [collections, router]);
const [tree, setTree] = useState(initialTree);
} else return undefined;
}, [collections, user, router]);
useEffect(() => {
setTree(initialTree);
}, [initialTree]);
useEffect(() => {
if (account.username) {
if (user.username) {
refetch();
if (
(!account.collectionOrder || account.collectionOrder.length === 0) &&
(!user.collectionOrder || user.collectionOrder.length === 0) &&
collections.length > 0
)
updateAccount({
...account,
updateUser.mutate({
...user,
collectionOrder: collections
.filter(
(e) =>
e.parentId === null ||
!collections.find((i) => i.id === e.parentId)
) // Filter out collections with non-null parentId
.map((e) => e.id as number), // Use "as number" to assert that e.id is a number
.filter((e) => e.parentId === null)
.map((e) => e.id as number),
});
else {
const newCollectionOrder: number[] = [
...(account.collectionOrder || []),
];
const newCollectionOrder: number[] = [...(user.collectionOrder || [])];
// Start with collections that are in both account.collectionOrder and collections
const existingCollectionIds = collections.map((c) => c.id as number);
const filteredCollectionOrder = account.collectionOrder.filter((id) =>
const filteredCollectionOrder = user.collectionOrder.filter((id: any) =>
existingCollectionIds.includes(id)
);
@@ -78,7 +75,7 @@ const CollectionListing = () => {
collections.forEach((collection) => {
if (
!filteredCollectionOrder.includes(collection.id as number) &&
(!collection.parentId || collection.ownerId === account.id)
(!collection.parentId || collection.ownerId === user.id)
) {
filteredCollectionOrder.push(collection.id as number);
}
@@ -87,16 +84,16 @@ const CollectionListing = () => {
// check if the newCollectionOrder is the same as the old one
if (
JSON.stringify(newCollectionOrder) !==
JSON.stringify(account.collectionOrder)
JSON.stringify(user.collectionOrder)
) {
updateAccount({
...account,
updateUser.mutateAsync({
...user,
collectionOrder: newCollectionOrder,
});
}
}
}
}, [collections]);
}, [user, collections]);
const onExpand = (movedCollectionId: ItemId) => {
setTree((currentTree) =>
@@ -138,9 +135,9 @@ const CollectionListing = () => {
);
if (
(movedCollection?.ownerId !== account.id &&
(movedCollection?.ownerId !== user.id &&
destination.parentId !== source.parentId) ||
(destinationCollection?.ownerId !== account.id &&
(destinationCollection?.ownerId !== user.id &&
destination.parentId !== "root")
) {
return toast.error(t("cant_change_collection_you_dont_own"));
@@ -148,18 +145,25 @@ const CollectionListing = () => {
setTree((currentTree) => moveItemOnTree(currentTree!, source, destination));
const updatedCollectionOrder = [...account.collectionOrder];
const updatedCollectionOrder = [...user.collectionOrder];
if (source.parentId !== destination.parentId) {
await updateCollection({
...movedCollection,
parentId:
destination.parentId && destination.parentId !== "root"
? Number(destination.parentId)
: destination.parentId === "root"
? "root"
: null,
} as any);
await updateCollection.mutateAsync(
{
...movedCollection,
parentId:
destination.parentId && destination.parentId !== "root"
? Number(destination.parentId)
: destination.parentId === "root"
? "root"
: null,
},
{
onError: (error) => {
toast.error(error.message);
},
}
);
}
if (
@@ -172,8 +176,8 @@ const CollectionListing = () => {
updatedCollectionOrder.splice(destination.index, 0, movedCollectionId);
await updateAccount({
...account,
await updateUser.mutateAsync({
...user,
collectionOrder: updatedCollectionOrder,
});
} else if (
@@ -182,8 +186,8 @@ const CollectionListing = () => {
) {
updatedCollectionOrder.splice(destination.index, 0, movedCollectionId);
await updateAccount({
...account,
updateUser.mutate({
...user,
collectionOrder: updatedCollectionOrder,
});
} else if (
@@ -193,14 +197,22 @@ const CollectionListing = () => {
) {
updatedCollectionOrder.splice(source.index, 1);
await updateAccount({
...account,
await updateUser.mutateAsync({
...user,
collectionOrder: updatedCollectionOrder,
});
}
};
if (!tree) {
if (isLoading) {
return (
<div className="flex flex-col gap-4">
<div className="skeleton h-4 w-full"></div>
<div className="skeleton h-4 w-full"></div>
<div className="skeleton h-4 w-full"></div>
</div>
);
} else if (!tree) {
return (
<p className="text-neutral text-xs font-semibold truncate w-full px-2 mt-5 mb-8">
{t("you_have_no_collections")}

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

81
components/Drawer.tsx Normal file
View File

@@ -0,0 +1,81 @@
import React, { ReactNode, useEffect } from "react";
import { Drawer as D } from "vaul";
import clsx from "clsx";
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);
useEffect(() => {
if (window.innerWidth >= 640) {
document.body.style.overflow = "hidden";
document.body.style.position = "relative";
return () => {
document.body.style.overflow = "auto";
document.body.style.position = "";
};
}
}, []);
if (window.innerWidth < 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 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

@@ -1,6 +1,8 @@
import { dropdownTriggerer } from "@/lib/client/utils";
import React from "react";
import React, { useEffect } from "react";
import { useTranslation } from "next-i18next";
import { resetInfiniteQueryPagination } from "@/hooks/store/links";
import { useQueryClient } from "@tanstack/react-query";
type Props = {
setSearchFilter: Function;
@@ -18,6 +20,7 @@ export default function FilterSearchDropdown({
searchFilter,
}: Props) {
const { t } = useTranslation();
const queryClient = useQueryClient();
return (
<div className="dropdown dropdown-bottom dropdown-end">
@@ -29,7 +32,7 @@ export default function FilterSearchDropdown({
>
<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">
<ul className="dropdown-content z-[30] menu shadow bg-base-200 border border-neutral-content rounded-box mt-1">
<li>
<label
className="label cursor-pointer flex justify-start"
@@ -41,11 +44,12 @@ export default function FilterSearchDropdown({
name="search-filter-checkbox"
className="checkbox checkbox-primary"
checked={searchFilter.name}
onChange={() =>
setSearchFilter({ ...searchFilter, name: !searchFilter.name })
}
onChange={() => {
resetInfiniteQueryPagination(queryClient, ["links"]);
setSearchFilter({ ...searchFilter, name: !searchFilter.name });
}}
/>
<span className="label-text">{t("name")}</span>
<span className="label-text whitespace-nowrap">{t("name")}</span>
</label>
</li>
<li>
@@ -59,11 +63,12 @@ export default function FilterSearchDropdown({
name="search-filter-checkbox"
className="checkbox checkbox-primary"
checked={searchFilter.url}
onChange={() =>
setSearchFilter({ ...searchFilter, url: !searchFilter.url })
}
onChange={() => {
resetInfiniteQueryPagination(queryClient, ["links"]);
setSearchFilter({ ...searchFilter, url: !searchFilter.url });
}}
/>
<span className="label-text">{t("link")}</span>
<span className="label-text whitespace-nowrap">{t("link")}</span>
</label>
</li>
<li>
@@ -77,14 +82,17 @@ export default function FilterSearchDropdown({
name="search-filter-checkbox"
className="checkbox checkbox-primary"
checked={searchFilter.description}
onChange={() =>
onChange={() => {
resetInfiniteQueryPagination(queryClient, ["links"]);
setSearchFilter({
...searchFilter,
description: !searchFilter.description,
})
}
});
}}
/>
<span className="label-text">{t("description")}</span>
<span className="label-text whitespace-nowrap">
{t("description")}
</span>
</label>
</li>
<li>
@@ -98,11 +106,12 @@ export default function FilterSearchDropdown({
name="search-filter-checkbox"
className="checkbox checkbox-primary"
checked={searchFilter.tags}
onChange={() =>
setSearchFilter({ ...searchFilter, tags: !searchFilter.tags })
}
onChange={() => {
resetInfiniteQueryPagination(queryClient, ["links"]);
setSearchFilter({ ...searchFilter, tags: !searchFilter.tags });
}}
/>
<span className="label-text">{t("tags")}</span>
<span className="label-text whitespace-nowrap">{t("tags")}</span>
</label>
</li>
<li>
@@ -116,15 +125,18 @@ export default function FilterSearchDropdown({
name="search-filter-checkbox"
className="checkbox checkbox-primary"
checked={searchFilter.textContent}
onChange={() =>
onChange={() => {
resetInfiniteQueryPagination(queryClient, ["links"]);
setSearchFilter({
...searchFilter,
textContent: !searchFilter.textContent,
})
}
});
}}
/>
<span className="label-text">{t("full_content")}</span>
<div className="ml-auto badge badge-sm badge-neutral">
<span className="label-text whitespace-nowrap">
{t("full_content")}
</span>
<div className="ml-auto badge badge-sm badge-neutral whitespace-nowrap">
{t("slower")}
</div>
</label>

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;

147
components/IconPopover.tsx Normal file
View File

@@ -0,0 +1,147 @@
import React, { useState } from "react";
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";
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;
};
const IconPopover = ({
alignment,
color,
setColor,
iconName,
setIconName,
weight,
setWeight,
reset,
className,
onClose,
}: Props) => {
const { t } = useTranslation();
const [query, setQuery] = useState("");
return (
<Popover
onClose={() => onClose()}
className={clsx(
className,
"fade-in bg-base-200 border border-neutral-content p-3 w-[22.5rem] rounded-lg shadow-md"
)}
>
<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>
);
};
export default IconPopover;

View File

@@ -1,10 +1,10 @@
import useCollectionStore from "@/store/collections";
import { useRouter } from "next/router";
import { useEffect, useState } from "react";
import { styles } from "./styles";
import { Options } from "./types";
import CreatableSelect from "react-select/creatable";
import Select from "react-select";
import { useCollections } from "@/hooks/store/collections";
type Props = {
onChange: any;
@@ -24,7 +24,8 @@ export default function CollectionSelection({
showDefaultValue = true,
creatable = true,
}: Props) {
const { collections } = useCollectionStore();
const { data: collections = [] } = useCollections();
const router = useRouter();
const [options, setOptions] = useState<Options[]>([]);

View File

@@ -1,8 +1,8 @@
import useTagStore from "@/store/tags";
import { useEffect, useState } from "react";
import CreatableSelect from "react-select/creatable";
import { styles } from "./styles";
import { Options } from "./types";
import { useTags } from "@/hooks/store/tags";
type Props = {
onChange: any;
@@ -13,12 +13,12 @@ type Props = {
};
export default function TagSelection({ onChange, defaultValue }: Props) {
const { tags } = useTagStore();
const { data: tags = [] } = useTags();
const [options, setOptions] = useState<Options[]>([]);
useEffect(() => {
const formatedCollections = tags.map((e) => {
const formatedCollections = tags.map((e: any) => {
return { value: e.id, label: e.name };
});

View File

@@ -16,6 +16,10 @@ export const styles: StylesConfig = {
},
transition: "all 50ms",
}),
menu: (styles) => ({
...styles,
zIndex: 10,
}),
control: (styles, state) => ({
...styles,
fontFamily: font,

View File

@@ -8,7 +8,7 @@ const InstallApp = (props: Props) => {
const [isOpen, setIsOpen] = useState(true);
return isOpen && !isPWA() ? (
<div className="absolute left-0 right-0 bottom-10 w-full p-5">
<div className="fixed left-0 right-0 bottom-10 w-full">
<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"

687
components/LinkDetails.tsx Normal file
View File

@@ -0,0 +1,687 @@
import React, { useEffect, useState } from "react";
import {
LinkIncludingShortenedCollectionAndTags,
ArchivedFormat,
} from "@/types/global";
import Link from "next/link";
import {
pdfAvailable,
readabilityAvailable,
monolithAvailable,
screenshotAvailable,
previewAvailable,
} 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,
useUpdateLink,
useUpdatePreview,
} 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";
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 && 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({
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 updatePreview = useUpdatePreview();
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"
)}
>
{previewAvailable(link) ? (
<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_preview_image")}
<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 updatePreview.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 || "#006796"}
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="max-w-xl 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("file")}
</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`}>
{monolithAvailable(link) ? (
<>
<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}
{screenshotAvailable(link) ? (
<>
<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}
{pdfAvailable(link) ? (
<>
<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}
{readabilityAvailable(link) ? (
<>
<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() ? (
<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() ? (
<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

@@ -5,17 +5,18 @@ import ViewDropdown from "./ViewDropdown";
import { TFunction } from "i18next";
import BulkDeleteLinksModal from "./ModalContent/BulkDeleteLinksModal";
import BulkEditLinksModal from "./ModalContent/BulkEditLinksModal";
import toast from "react-hot-toast";
import useCollectivePermissions from "@/hooks/useCollectivePermissions";
import { useRouter } from "next/router";
import useLinkStore from "@/store/links";
import { Sort } from "@/types/global";
import { Sort, ViewMode } from "@/types/global";
import { useBulkDeleteLinks, useLinks } from "@/hooks/store/links";
import toast from "react-hot-toast";
type Props = {
children: React.ReactNode;
t: TFunction<"translation", undefined>;
viewMode: string;
setViewMode: Dispatch<SetStateAction<string>>;
viewMode: ViewMode;
setViewMode: Dispatch<SetStateAction<ViewMode>>;
searchFilter?: {
name: boolean;
url: boolean;
@@ -48,8 +49,11 @@ const LinkListOptions = ({
editMode,
setEditMode,
}: Props) => {
const { links, selectedLinks, setSelectedLinks, deleteLinksById } =
useLinkStore();
const { selectedLinks, setSelectedLinks } = useLinkStore();
const deleteLinksById = useBulkDeleteLinks();
const { links } = useLinks();
const router = useRouter();
@@ -73,23 +77,23 @@ const LinkListOptions = ({
};
const bulkDeleteLinks = async () => {
const load = toast.loading(t("deleting_selections"));
const load = toast.loading(t("deleting"));
const response = await deleteLinksById(
selectedLinks.map((link) => link.id as number)
await deleteLinksById.mutateAsync(
selectedLinks.map((link) => link.id as number),
{
onSettled: (data, error) => {
toast.dismiss(load);
if (error) {
toast.error(error.message);
} else {
setSelectedLinks([]);
toast.success(t("deleted"));
}
},
}
);
toast.dismiss(load);
if (response.ok) {
toast.success(
selectedLinks.length === 1
? t("link_deleted")
: t("links_deleted", { count: selectedLinks.length })
);
} else {
toast.error(response.data as string);
}
};
return (
@@ -99,57 +103,64 @@ const LinkListOptions = ({
<div className="flex gap-3 items-center justify-end">
<div className="flex gap-2 items-center mt-2">
{links.length > 0 && editMode !== undefined && setEditMode && (
<div
role="button"
onClick={() => {
setEditMode(!editMode);
setSelectedLinks([]);
}}
className={`btn btn-square btn-sm btn-ghost ${
editMode
? "bg-primary/20 hover:bg-primary/20"
: "hover:bg-neutral/20"
}`}
>
<i className="bi-pencil-fill text-neutral text-xl"></i>
</div>
)}
{links &&
links.length > 0 &&
editMode !== undefined &&
setEditMode && (
<div
role="button"
onClick={() => {
setEditMode(!editMode);
setSelectedLinks([]);
}}
className={`btn btn-square btn-sm btn-ghost ${
editMode
? "bg-primary/20 hover:bg-primary/20"
: "hover:bg-neutral/20"
}`}
>
<i className="bi-pencil-fill text-neutral text-xl"></i>
</div>
)}
{searchFilter && setSearchFilter && (
<FilterSearchDropdown
searchFilter={searchFilter}
setSearchFilter={setSearchFilter}
/>
)}
<SortDropdown sortBy={sortBy} setSort={setSortBy} t={t} />
<SortDropdown
sortBy={sortBy}
setSort={(value) => {
setSortBy(value);
}}
t={t}
/>
<ViewDropdown viewMode={viewMode} setViewMode={setViewMode} />
</div>
</div>
</div>
{editMode && links.length > 0 && (
{links && editMode && links.length > 0 && (
<div className="w-full flex justify-between items-center min-h-[32px]">
{links.length > 0 && (
<div className="flex gap-3 ml-3">
<input
type="checkbox"
className="checkbox checkbox-primary"
onChange={() => handleSelectAll()}
checked={
selectedLinks.length === links.length && links.length > 0
}
/>
{selectedLinks.length > 0 ? (
<span>
{selectedLinks.length === 1
? t("link_selected")
: t("links_selected", { count: selectedLinks.length })}
</span>
) : (
<span>{t("nothing_selected")}</span>
)}
</div>
)}
<div className="flex gap-3 ml-3">
<input
type="checkbox"
className="checkbox checkbox-primary"
onChange={() => handleSelectAll()}
checked={
selectedLinks.length === links.length && links.length > 0
}
/>
{selectedLinks.length > 0 ? (
<span>
{selectedLinks.length === 1
? t("link_selected")
: t("links_selected", { count: selectedLinks.length })}
</span>
) : (
<span>{t("nothing_selected")}</span>
)}
</div>
<div className="flex gap-3">
<button
onClick={() => setBulkEditLinksModal(true)}

View File

@@ -1,39 +0,0 @@
import LinkCard from "@/components/LinkViews/LinkCard";
import { LinkIncludingShortenedCollectionAndTags } from "@/types/global";
import { link } from "fs";
import { GridLoader } from "react-spinners";
export default function CardView({
links,
editMode,
isLoading,
}: {
links: LinkIncludingShortenedCollectionAndTags[];
editMode?: boolean;
isLoading?: boolean;
}) {
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">
{links.map((e, i) => {
return (
<LinkCard
key={i}
link={e}
count={i}
flipDropdown={i === links.length - 1}
editMode={editMode}
/>
);
})}
{isLoading && links.length > 0 && (
<GridLoader
color="oklch(var(--p))"
loading={true}
size={20}
className="fixed top-5 right-5 opacity-50 z-30"
/>
)}
</div>
);
}

View File

@@ -1,38 +0,0 @@
import LinkList from "@/components/LinkViews/LinkList";
import { LinkIncludingShortenedCollectionAndTags } from "@/types/global";
import { GridLoader } from "react-spinners";
export default function ListView({
links,
editMode,
isLoading,
}: {
links: LinkIncludingShortenedCollectionAndTags[];
editMode?: boolean;
isLoading?: boolean;
}) {
return (
<div className="flex gap-1 flex-col">
{links.map((e, i) => {
return (
<LinkList
key={i}
link={e}
count={i}
flipDropdown={i === links.length - 1}
editMode={editMode}
/>
);
})}
{isLoading && links.length > 0 && (
<GridLoader
color="oklch(var(--p))"
loading={true}
size={20}
className="fixed top-5 right-5 opacity-50 z-30"
/>
)}
</div>
);
}

View File

@@ -1,58 +0,0 @@
import LinkMasonry from "@/components/LinkViews/LinkMasonry";
import { LinkIncludingShortenedCollectionAndTags } from "@/types/global";
import { GridLoader } from "react-spinners";
import Masonry from "react-masonry-css";
import resolveConfig from "tailwindcss/resolveConfig";
import tailwindConfig from "../../../tailwind.config.js";
import { useMemo } from "react";
export default function MasonryView({
links,
editMode,
isLoading,
}: {
links: LinkIncludingShortenedCollectionAndTags[];
editMode?: boolean;
isLoading?: boolean;
}) {
const fullConfig = resolveConfig(tailwindConfig as any);
const breakpointColumnsObj = useMemo(() => {
return {
default: 5,
1900: 4,
1500: 3,
880: 2,
550: 1,
};
}, []);
return (
<Masonry
breakpointCols={breakpointColumnsObj}
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"
>
{links.map((e, i) => {
return (
<LinkMasonry
key={i}
link={e}
count={i}
flipDropdown={i === links.length - 1}
editMode={editMode}
/>
);
})}
{isLoading && links.length > 0 && (
<GridLoader
color="oklch(var(--p))"
loading={true}
size={20}
className="fixed top-5 right-5 opacity-50 z-30"
/>
)}
</Masonry>
);
}

View File

@@ -1,254 +0,0 @@
import {
ArchivedFormat,
CollectionIncludingMembersAndLinkCount,
LinkIncludingShortenedCollectionAndTags,
} from "@/types/global";
import { useEffect, useRef, useState } from "react";
import useLinkStore from "@/store/links";
import useCollectionStore from "@/store/collections";
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 LinkIcon from "./LinkComponents/LinkIcon";
import useOnScreen from "@/hooks/useOnScreen";
import { generateLinkHref } from "@/lib/client/generateLinkHref";
import useAccountStore from "@/store/account";
import usePermissions from "@/hooks/usePermissions";
import toast from "react-hot-toast";
import LinkTypeBadge from "./LinkComponents/LinkTypeBadge";
import { useTranslation } from "next-i18next";
type Props = {
link: LinkIncludingShortenedCollectionAndTags;
count: number;
className?: string;
flipDropdown?: boolean;
editMode?: boolean;
};
export default function LinkCard({ link, flipDropdown, editMode }: Props) {
const { t } = useTranslation();
const viewMode = localStorage.getItem("viewMode") || "card";
const { collections } = useCollectionStore();
const { account } = useAccountStore();
const { links, getLink, setSelectedLinks, selectedLinks } = useLinkStore();
useEffect(() => {
if (!editMode) {
setSelectedLinks([]);
}
}, [editMode]);
const handleCheckboxClick = (
link: LinkIncludingShortenedCollectionAndTags
) => {
if (selectedLinks.includes(link)) {
setSelectedLinks(selectedLinks.filter((e) => e !== link));
} else {
setSelectedLinks([...selectedLinks, link]);
}
};
let shortendURL;
try {
if (link.url) {
shortendURL = new URL(link.url).host.toLowerCase();
}
} catch (error) {
console.log(error);
}
const [collection, setCollection] =
useState<CollectionIncludingMembersAndLinkCount>(
collections.find(
(e) => e.id === link.collection.id
) as CollectionIncludingMembersAndLinkCount
);
useEffect(() => {
setCollection(
collections.find(
(e) => e.id === link.collection.id
) as CollectionIncludingMembersAndLinkCount
);
}, [collections, links]);
const ref = useRef<HTMLDivElement>(null);
const isVisible = useOnScreen(ref);
const permissions = usePermissions(collection?.id as number);
useEffect(() => {
let interval: any;
if (
isVisible &&
!link.preview?.startsWith("archives") &&
link.preview !== "unavailable"
) {
interval = setInterval(async () => {
getLink(link.id as number);
}, 5000);
}
return () => {
if (interval) {
clearInterval(interval);
}
};
}, [isVisible, link.preview]);
const [showInfo, setShowInfo] = useState(false);
const selectedStyle = selectedLinks.some(
(selectedLink) => selectedLink.id === link.id
)
? "border-primary bg-base-300"
: "border-neutral-content";
const selectable =
editMode &&
(permissions === true || permissions?.canCreate || permissions?.canDelete);
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`}
onClick={() =>
selectable
? handleCheckboxClick(link)
: editMode
? toast.error(t("link_selection_error"))
: undefined
}
>
<div
className="rounded-2xl cursor-pointer h-full flex flex-col justify-between"
onClick={() =>
!editMode && window.open(generateLinkHref(link, account), "_blank")
}
>
<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>
<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>
<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>
)}
<LinkActions
link={link}
collection={collection}
position="top-[10.75rem] right-3"
toggleShowInfo={() => setShowInfo(!showInfo)}
linkInfo={showInfo}
flipDropdown={flipDropdown}
/>
</div>
);
}

View File

@@ -4,192 +4,189 @@ 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 useLinkStore from "@/store/links";
import { toast } from "react-hot-toast";
import useAccountStore from "@/store/account";
import { dropdownTriggerer } from "@/lib/client/utils";
import { useTranslation } from "next-i18next";
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;
};
export default function LinkActions({
link,
toggleShowInfo,
position,
linkInfo,
alignToTop,
flipDropdown,
}: Props) {
export default function LinkActions({ link, btnStyle }: Props) {
const { t } = useTranslation();
const permissions = usePermissions(link.collection.id as number);
const getLink = useGetLink();
const pinLink = usePinLink();
const [editLinkModal, setEditLinkModal] = useState(false);
const [linkModal, setLinkModal] = useState(false);
const [deleteLinkModal, setDeleteLinkModal] = useState(false);
const [preservedFormatsModal, setPreservedFormatsModal] = useState(false);
const { account } = useAccountStore();
const deleteLink = useDeleteLink();
const { removeLink, updateLink } = useLinkStore();
const updateArchive = async () => {
const load = toast.loading(t("sending_request"));
const pinLink = async () => {
const isAlreadyPinned = link?.pinnedBy && link.pinnedBy[0];
const load = toast.loading(t("applying"));
const response = await updateLink({
...link,
pinnedBy: isAlreadyPinned ? undefined : [{ id: account.id }],
const response = await fetch(`/api/v1/links/${link?.id}/archive`, {
method: "PUT",
});
const data = await response.json();
toast.dismiss(load);
if (response.ok) {
toast.success(isAlreadyPinned ? t("link_unpinned") : t("link_unpinned"));
} else {
toast.error(response.data as string);
}
await getLink.mutateAsync({ id: link.id as number });
toast.success(t("link_being_archived"));
} else toast.error(data.response);
};
const deleteLink = async () => {
const load = toast.loading(t("deleting"));
const router = useRouter();
const response = await removeLink(link.id as number);
toast.dismiss(load);
if (response.ok) {
toast.success(t("deleted"));
} else {
toast.error(response.data as string);
}
};
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={(e) => {
(document?.activeElement as HTMLElement)?.blur();
e.shiftKey ? deleteLink() : setDeleteLinkModal(true);
}}
>
{t("delete")}
</div>
</li>
) : undefined}
</ul>
</div>
{(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"));
{editLinkModal ? (
<EditLinkModal
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);
}}
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)}
activeLink={link}
)}
{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

@@ -0,0 +1,241 @@
import {
ArchivedFormat,
CollectionIncludingMembersAndLinkCount,
LinkIncludingShortenedCollectionAndTags,
} from "@/types/global";
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 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";
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";
type Props = {
link: LinkIncludingShortenedCollectionAndTags;
count: number;
columns: number;
className?: string;
editMode?: boolean;
};
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();
const { setSelectedLinks, selectedLinks } = useLinkStore();
const {
settings: { show },
} = useLocalSettingsStore();
const {
data: { data: links = [] },
} = useLinks();
const getLink = useGetLink();
useEffect(() => {
if (!editMode) {
setSelectedLinks([]);
}
}, [editMode]);
const handleCheckboxClick = (
link: LinkIncludingShortenedCollectionAndTags
) => {
if (selectedLinks.includes(link)) {
setSelectedLinks(selectedLinks.filter((e) => e !== link));
} else {
setSelectedLinks([...selectedLinks, link]);
}
};
let shortendURL;
try {
if (link.url) {
shortendURL = new URL(link.url).host.toLowerCase();
}
} catch (error) {
console.log(error);
}
const [collection, setCollection] =
useState<CollectionIncludingMembersAndLinkCount>(
collections.find(
(e) => e.id === link.collection.id
) as CollectionIncludingMembersAndLinkCount
);
useEffect(() => {
setCollection(
collections.find(
(e) => e.id === link.collection.id
) as CollectionIncludingMembersAndLinkCount
);
}, [collections, links]);
const ref = useRef<HTMLDivElement>(null);
const isVisible = useOnScreen(ref);
const permissions = usePermissions(collection?.id as number);
const router = useRouter();
const isPublicRoute = router.pathname.startsWith("/public") ? true : false;
useEffect(() => {
let interval: NodeJS.Timeout | null = null;
if (
isVisible &&
!link.preview?.startsWith("archives") &&
link.preview !== "unavailable"
) {
interval = setInterval(async () => {
getLink.mutateAsync({
id: link.id as number,
isPublicRoute: isPublicRoute,
});
}, 5000);
}
return () => {
if (interval) {
clearInterval(interval);
}
};
}, [isVisible, link.preview]);
const selectedStyle = selectedLinks.some(
(selectedLink) => selectedLink.id === link.id
)
? "border-primary bg-base-300"
: "border-neutral-content";
const selectable =
editMode &&
(permissions === true || permissions?.canCreate || permissions?.canDelete);
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 group`}
onClick={() =>
selectable
? handleCheckboxClick(link)
: editMode
? toast.error(t("link_selection_error"))
: undefined
}
>
<div
className="rounded-2xl cursor-pointer h-full flex flex-col justify-between"
onClick={() =>
!editMode && window.open(generateLinkHref(link, user), "_blank")
}
>
{show.image && (
<div>
<div
className={`relative rounded-t-2xl ${imageHeightClass} overflow-hidden`}
>
{previewAvailable(link) ? (
<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>
)}
</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>
{/* 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} />
{!isPublicRoute && <LinkPin link={link} />}
</div>
);
}

View File

@@ -15,20 +15,24 @@ export default function LinkCollection({
}) {
const router = useRouter();
return (
<Link
href={`/collections/${link.collection.id}`}
onClick={(e) => {
e.stopPropagation();
}}
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>
<p className="truncate capitalize">{collection?.name}</p>
</Link>
);
const isPublicRoute = router.pathname.startsWith("/public") ? true : false;
return !isPublicRoute && collection?.name ? (
<>
<Link
href={`/collections/${link.collection.id}`}
onClick={(e) => {
e.stopPropagation();
}}
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>
<p className="truncate capitalize">{collection?.name}</p>
</Link>
</>
) : null;
}

View File

@@ -1,73 +1,75 @@
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";
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 bg-white backdrop-blur-lg 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 || "#006796"}
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,25 +80,19 @@ 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}`}
>
<div className={clsx(iconClasses, "aspect-square text-4xl text-[#006796]")}>
<i className={`${icon} m-auto`}></i>
</div>
);

View File

@@ -4,7 +4,6 @@ import {
} from "@/types/global";
import { useEffect, useState } from "react";
import useLinkStore from "@/store/links";
import useCollectionStore from "@/store/collections";
import unescapeString from "@/lib/client/unescapeString";
import LinkActions from "@/components/LinkViews/LinkComponents/LinkActions";
import LinkDate from "@/components/LinkViews/LinkComponents/LinkDate";
@@ -12,30 +11,37 @@ 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 useAccountStore from "@/store/account";
import usePermissions from "@/hooks/usePermissions";
import toast from "react-hot-toast";
import LinkTypeBadge from "./LinkComponents/LinkTypeBadge";
import LinkTypeBadge from "./LinkTypeBadge";
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";
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 { collections } = useCollectionStore();
const { account } = useAccountStore();
const { links, setSelectedLinks, selectedLinks } = useLinkStore();
const { data: collections = [] } = useCollections();
const { data: user = {} } = useUser();
const { setSelectedLinks, selectedLinks } = useLinkStore();
const {
settings: { show },
} = useLocalSettingsStore();
const { links } = useLinks();
useEffect(() => {
if (!editMode) {
@@ -76,8 +82,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
)
@@ -88,12 +92,15 @@ export default function LinkCardCompact({
editMode &&
(permissions === true || permissions?.canCreate || permissions?.canDelete);
const router = useRouter();
let isPublic = router.pathname.startsWith("/public") ? true : undefined;
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 w-full`}
onClick={() =>
selectable
? handleCheckboxClick(link)
@@ -102,67 +109,40 @@ 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, account), "_blank")
!editMode && window.open(generateLinkHref(link, user), "_blank")
}
>
<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 && (
<p className="line-clamp-1 mr-8 text-primary select-none">
{unescapeString(link.name)}
</p>
)}
<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>
<LinkActions
link={link}
collection={collection}
position="top-3 right-3"
flipDropdown={flipDropdown}
// toggleShowInfo={() => setShowInfo(!showInfo)}
// linkInfo={showInfo}
/>
{!isPublic && <LinkPin link={link} btnStyle="btn-ghost" />}
<LinkActions link={link} collection={collection} btnStyle="btn-ghost" />
</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

@@ -0,0 +1,252 @@
import {
ArchivedFormat,
CollectionIncludingMembersAndLinkCount,
LinkIncludingShortenedCollectionAndTags,
} from "@/types/global";
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 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";
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";
type Props = {
link: LinkIncludingShortenedCollectionAndTags;
columns: number;
editMode?: boolean;
};
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();
useEffect(() => {
if (!editMode) {
setSelectedLinks([]);
}
}, [editMode]);
const handleCheckboxClick = (
link: LinkIncludingShortenedCollectionAndTags
) => {
if (selectedLinks.includes(link)) {
setSelectedLinks(selectedLinks.filter((e) => e !== link));
} else {
setSelectedLinks([...selectedLinks, link]);
}
};
let shortendURL;
try {
if (link.url) {
shortendURL = new URL(link.url).host.toLowerCase();
}
} catch (error) {
console.log(error);
}
const [collection, setCollection] =
useState<CollectionIncludingMembersAndLinkCount>(
collections.find(
(e) => e.id === link.collection.id
) as CollectionIncludingMembersAndLinkCount
);
useEffect(() => {
setCollection(
collections.find(
(e) => e.id === link.collection.id
) as CollectionIncludingMembersAndLinkCount
);
}, [collections, links]);
const ref = useRef<HTMLDivElement>(null);
const isVisible = useOnScreen(ref);
const permissions = usePermissions(collection?.id as number);
const router = useRouter();
let isPublic = router.pathname.startsWith("/public") ? true : undefined;
useEffect(() => {
let interval: NodeJS.Timeout | null = null;
if (
isVisible &&
!link.preview?.startsWith("archives") &&
link.preview !== "unavailable"
) {
interval = setInterval(async () => {
getLink.mutateAsync({ id: link.id as number });
}, 5000);
}
return () => {
if (interval) {
clearInterval(interval);
}
};
}, [isVisible, link.preview]);
const selectedStyle = selectedLinks.some(
(selectedLink) => selectedLink.id === link.id
)
? "border-primary bg-base-300"
: "border-neutral-content";
const selectable =
editMode &&
(permissions === true || permissions?.canCreate || permissions?.canDelete);
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 group`}
onClick={() =>
selectable
? handleCheckboxClick(link)
: editMode
? toast.error(t("link_selection_error"))
: undefined
}
>
<div
className="rounded-2xl cursor-pointer"
onClick={() =>
!editMode && window.open(generateLinkHref(link, user), "_blank")
}
>
{show.image && previewAvailable(link) && (
<div>
<div className="relative rounded-t-2xl overflow-hidden">
{previewAvailable(link) ? (
<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>
<hr className="divider my-0 border-t border-neutral-content h-[1px]" />
</div>
)}
<div className="p-3 flex flex-col gap-2 h-full min-h-14">
{show.name && (
<p className="hyphens-auto w-full text-primary text-sm">
{unescapeString(link.name)}
</p>
)}
{show.link && <LinkTypeBadge link={link} />}
{show.description && link.description && (
<p className={clsx("hyphens-auto text-sm w-full")}>
{unescapeString(link.description)}
</p>
)}
{show.tags && link.tags && link.tags[0] && (
<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>
{(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 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>
{/* 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} />
{!isPublic && <LinkPin link={link} />}
</div>
);
}

View File

@@ -1,271 +0,0 @@
import {
ArchivedFormat,
CollectionIncludingMembersAndLinkCount,
LinkIncludingShortenedCollectionAndTags,
} from "@/types/global";
import { useEffect, useRef, useState } from "react";
import useLinkStore from "@/store/links";
import useCollectionStore from "@/store/collections";
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 LinkIcon from "./LinkComponents/LinkIcon";
import useOnScreen from "@/hooks/useOnScreen";
import { generateLinkHref } from "@/lib/client/generateLinkHref";
import useAccountStore from "@/store/account";
import usePermissions from "@/hooks/usePermissions";
import toast from "react-hot-toast";
import LinkTypeBadge from "./LinkComponents/LinkTypeBadge";
import { useTranslation } from "next-i18next";
type Props = {
link: LinkIncludingShortenedCollectionAndTags;
count: number;
className?: string;
flipDropdown?: boolean;
editMode?: boolean;
};
export default function LinkMasonry({ link, flipDropdown, editMode }: Props) {
const { t } = useTranslation();
const { collections } = useCollectionStore();
const { account } = useAccountStore();
const { links, getLink, setSelectedLinks, selectedLinks } = useLinkStore();
useEffect(() => {
if (!editMode) {
setSelectedLinks([]);
}
}, [editMode]);
const handleCheckboxClick = (
link: LinkIncludingShortenedCollectionAndTags
) => {
if (selectedLinks.includes(link)) {
setSelectedLinks(selectedLinks.filter((e) => e !== link));
} else {
setSelectedLinks([...selectedLinks, link]);
}
};
let shortendURL;
try {
if (link.url) {
shortendURL = new URL(link.url).host.toLowerCase();
}
} catch (error) {
console.log(error);
}
const [collection, setCollection] =
useState<CollectionIncludingMembersAndLinkCount>(
collections.find(
(e) => e.id === link.collection.id
) as CollectionIncludingMembersAndLinkCount
);
useEffect(() => {
setCollection(
collections.find(
(e) => e.id === link.collection.id
) as CollectionIncludingMembersAndLinkCount
);
}, [collections, links]);
const ref = useRef<HTMLDivElement>(null);
const isVisible = useOnScreen(ref);
const permissions = usePermissions(collection?.id as number);
useEffect(() => {
let interval: any;
if (
isVisible &&
!link.preview?.startsWith("archives") &&
link.preview !== "unavailable"
) {
interval = setInterval(async () => {
getLink(link.id as number);
}, 5000);
}
return () => {
if (interval) {
clearInterval(interval);
}
};
}, [isVisible, link.preview]);
const [showInfo, setShowInfo] = useState(false);
const selectedStyle = selectedLinks.some(
(selectedLink) => selectedLink.id === link.id
)
? "border-primary bg-base-300"
: "border-neutral-content";
const selectable =
editMode &&
(permissions === true || permissions?.canCreate || permissions?.canDelete);
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`}
onClick={() =>
selectable
? handleCheckboxClick(link)
: editMode
? toast.error(t("link_selection_error"))
: undefined
}
>
<div
className="rounded-2xl cursor-pointer"
onClick={() =>
!editMode && window.open(generateLinkHref(link, account), "_blank")
}
>
<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} />
</div>
)}
</div>
{link.preview !== "unavailable" && (
<hr className="divider my-0 last:hidden border-t border-neutral-content h-[1px]" />
)}
<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>
<LinkTypeBadge link={link} />
{link.description && (
<p className="hyphens-auto text-sm">
{unescapeString(link.description)}
</p>
)}
{link.tags && link.tags[0] && (
<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>
<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>
{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>
)}
<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}
/>
</div>
);
}

View File

@@ -0,0 +1,238 @@
import LinkCard from "@/components/LinkViews/LinkComponents/LinkCard";
import {
LinkIncludingShortenedCollectionAndTags,
ViewMode,
} from "@/types/global";
import { useEffect } from "react";
import { useInView } from "react-intersection-observer";
import LinkMasonry from "@/components/LinkViews/LinkComponents/LinkMasonry";
import Masonry from "react-masonry-css";
import resolveConfig from "tailwindcss/resolveConfig";
import tailwindConfig from "../../tailwind.config.js";
import { useMemo } from "react";
import LinkList from "@/components/LinkViews/LinkComponents/LinkList";
export function CardView({
links,
editMode,
isLoading,
placeholders,
hasNextPage,
placeHolderRef,
}: {
links?: LinkIncludingShortenedCollectionAndTags[];
editMode?: boolean;
isLoading?: boolean;
placeholders?: number[];
hasNextPage?: boolean;
placeHolderRef?: any;
}) {
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">
{links?.map((e, i) => {
return (
<LinkCard
key={i}
link={e}
count={i}
flipDropdown={i === links.length - 1}
editMode={editMode}
/>
);
})}
{(hasNextPage || isLoading) &&
placeholders?.map((e, i) => {
return (
<div
className="flex flex-col gap-4"
ref={e === 1 ? placeHolderRef : undefined}
key={i}
>
<div className="skeleton h-40 w-full"></div>
<div className="skeleton h-3 w-2/3"></div>
<div className="skeleton h-3 w-full"></div>
<div className="skeleton h-3 w-full"></div>
<div className="skeleton h-3 w-1/3"></div>
</div>
);
})}
</div>
);
}
export function MasonryView({
links,
editMode,
isLoading,
placeholders,
hasNextPage,
placeHolderRef,
}: {
links?: LinkIncludingShortenedCollectionAndTags[];
editMode?: boolean;
isLoading?: boolean;
placeholders?: number[];
hasNextPage?: boolean;
placeHolderRef?: any;
}) {
const fullConfig = resolveConfig(tailwindConfig as any);
const breakpointColumnsObj = useMemo(() => {
return {
default: 5,
1900: 4,
1500: 3,
880: 2,
550: 1,
};
}, []);
return (
<Masonry
breakpointCols={breakpointColumnsObj}
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"
>
{links?.map((e, i) => {
return (
<LinkMasonry
key={i}
link={e}
count={i}
flipDropdown={i === links.length - 1}
editMode={editMode}
/>
);
})}
{(hasNextPage || isLoading) &&
placeholders?.map((e, i) => {
return (
<div
className="flex flex-col gap-4"
ref={e === 1 ? placeHolderRef : undefined}
key={i}
>
<div className="skeleton h-40 w-full"></div>
<div className="skeleton h-3 w-2/3"></div>
<div className="skeleton h-3 w-full"></div>
<div className="skeleton h-3 w-full"></div>
<div className="skeleton h-3 w-1/3"></div>
</div>
);
})}
</Masonry>
);
}
export function ListView({
links,
editMode,
isLoading,
placeholders,
hasNextPage,
placeHolderRef,
}: {
links?: LinkIncludingShortenedCollectionAndTags[];
editMode?: boolean;
isLoading?: boolean;
placeholders?: number[];
hasNextPage?: boolean;
placeHolderRef?: any;
}) {
return (
<div className="flex gap-1 flex-col">
{links?.map((e, i) => {
return (
<LinkList
key={i}
link={e}
count={i}
flipDropdown={i === links.length - 1}
editMode={editMode}
/>
);
})}
{(hasNextPage || isLoading) &&
placeholders?.map((e, i) => {
return (
<div
ref={e === 1 ? placeHolderRef : undefined}
key={i}
className="flex gap-4 p-4"
>
<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>
</div>
);
})}
</div>
);
}
export default function Links({
layout,
links,
editMode,
placeholderCount,
useData,
}: {
layout: ViewMode;
links?: LinkIncludingShortenedCollectionAndTags[];
editMode?: boolean;
placeholderCount?: number;
useData?: any;
}) {
const { ref, inView } = useInView();
useEffect(() => {
if (inView && useData?.fetchNextPage && useData?.hasNextPage) {
useData.fetchNextPage();
}
}, [useData, inView]);
if (layout === ViewMode.List) {
return (
<ListView
links={links}
editMode={editMode}
isLoading={useData?.isLoading}
placeholders={placeholderCountToArray(placeholderCount)}
hasNextPage={useData?.hasNextPage}
placeHolderRef={ref}
/>
);
} else if (layout === ViewMode.Masonry) {
return (
<MasonryView
links={links}
editMode={editMode}
isLoading={useData?.isLoading}
placeholders={placeholderCountToArray(placeholderCount)}
hasNextPage={useData?.hasNextPage}
placeHolderRef={ref}
/>
);
} else {
// Default to card view
return (
<CardView
links={links}
editMode={editMode}
isLoading={useData?.isLoading}
placeholders={placeholderCountToArray(placeholderCount)}
hasNextPage={useData?.hasNextPage}
placeHolderRef={ref}
/>
);
}
}
const placeholderCountToArray = (num?: number) =>
num ? Array.from({ length: num }, (_, i) => i + 1) : [];

View File

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

View File

@@ -32,28 +32,25 @@ export default function Modal({
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

@@ -1,9 +1,10 @@
import React from "react";
import useLinkStore from "@/store/links";
import toast from "react-hot-toast";
import Modal from "../Modal";
import Button from "../ui/Button";
import { useTranslation } from "next-i18next";
import { useBulkDeleteLinks } from "@/hooks/store/links";
import toast from "react-hot-toast";
type Props = {
onClose: Function;
@@ -11,22 +12,29 @@ type Props = {
export default function BulkDeleteLinksModal({ onClose }: Props) {
const { t } = useTranslation();
const { selectedLinks, setSelectedLinks, deleteLinksById } = useLinkStore();
const { selectedLinks, setSelectedLinks } = useLinkStore();
const deleteLinksById = useBulkDeleteLinks();
const deleteLink = async () => {
const load = toast.loading(t("deleting"));
const response = await deleteLinksById(
selectedLinks.map((link) => link.id as number)
await deleteLinksById.mutateAsync(
selectedLinks.map((link) => link.id as number),
{
onSettled: (data, error) => {
toast.dismiss(load);
if (error) {
toast.error(error.message);
} else {
setSelectedLinks([]);
onClose();
toast.success(t("deleted"));
}
},
}
);
toast.dismiss(load);
if (response.ok) {
toast.success(t("deleted"));
setSelectedLinks([]);
onClose();
} else toast.error(response.data as string);
};
return (

View File

@@ -6,6 +6,7 @@ import { LinkIncludingShortenedCollectionAndTags } from "@/types/global";
import toast from "react-hot-toast";
import Modal from "../Modal";
import { useTranslation } from "next-i18next";
import { useBulkEditLinks } from "@/hooks/store/links";
type Props = {
onClose: Function;
@@ -13,13 +14,14 @@ type Props = {
export default function BulkEditLinksModal({ onClose }: Props) {
const { t } = useTranslation();
const { updateLinks, selectedLinks, setSelectedLinks } = useLinkStore();
const { selectedLinks, setSelectedLinks } = useLinkStore();
const [submitLoader, setSubmitLoader] = useState(false);
const [removePreviousTags, setRemovePreviousTags] = useState(false);
const [updatedValues, setUpdatedValues] = useState<
Pick<LinkIncludingShortenedCollectionAndTags, "tags" | "collectionId">
>({ tags: [] });
const updateLinks = useBulkEditLinks();
const setCollection = (e: any) => {
const collectionId = e?.value || null;
setUpdatedValues((prevValues) => ({ ...prevValues, collectionId }));
@@ -36,22 +38,28 @@ export default function BulkEditLinksModal({ onClose }: Props) {
const load = toast.loading(t("updating"));
const response = await updateLinks(
selectedLinks,
removePreviousTags,
updatedValues
await updateLinks.mutateAsync(
{
links: selectedLinks,
newData: updatedValues,
removePreviousTags,
},
{
onSettled: (data, error) => {
toast.dismiss(load);
if (error) {
toast.error(error.message);
} else {
setSelectedLinks([]);
onClose();
toast.success(t("updated"));
}
},
}
);
toast.dismiss(load);
if (response.ok) {
toast.success(t("updated"));
setSelectedLinks([]);
onClose();
} else toast.error(response.data as string);
setSubmitLoader(false);
return response;
}
};

View File

@@ -1,13 +1,13 @@
import React, { useEffect, useState } from "react";
import TextInput from "@/components/TextInput";
import useCollectionStore from "@/store/collections";
import toast from "react-hot-toast";
import { CollectionIncludingMembersAndLinkCount } from "@/types/global";
import { useRouter } from "next/router";
import usePermissions from "@/hooks/usePermissions";
import Modal from "../Modal";
import Button from "../ui/Button";
import { useTranslation } from "next-i18next";
import { useDeleteCollection } from "@/hooks/store/collections";
import toast from "react-hot-toast";
type Props = {
onClose: Function;
@@ -22,7 +22,6 @@ export default function DeleteCollectionModal({
const [collection, setCollection] =
useState<CollectionIncludingMembersAndLinkCount>(activeCollection);
const [submitLoader, setSubmitLoader] = useState(false);
const { removeCollection } = useCollectionStore();
const router = useRouter();
const [inputField, setInputField] = useState("");
const permissions = usePermissions(collection.id as number);
@@ -31,6 +30,8 @@ export default function DeleteCollectionModal({
setCollection(activeCollection);
}, []);
const deleteCollection = useDeleteCollection();
const submit = async () => {
if (permissions === true && collection.name !== inputField) return;
if (!submitLoader) {
@@ -41,17 +42,19 @@ export default function DeleteCollectionModal({
const load = toast.loading(t("deleting_collection"));
let response = await removeCollection(collection.id as number);
deleteCollection.mutateAsync(collection.id as number, {
onSettled: (data, error) => {
toast.dismiss(load);
toast.dismiss(load);
if (response.ok) {
toast.success(t("deleted"));
onClose();
router.push("/collections");
} else {
toast.error(response.data as string);
}
if (error) {
toast.error(error.message);
} else {
onClose();
toast.success(t("deleted"));
router.push("/collections");
}
},
});
setSubmitLoader(false);
}

View File

@@ -1,11 +1,11 @@
import React, { useEffect, useState } from "react";
import useLinkStore from "@/store/links";
import { LinkIncludingShortenedCollectionAndTags } from "@/types/global";
import toast from "react-hot-toast";
import Modal from "../Modal";
import { useRouter } from "next/router";
import Button from "../ui/Button";
import { useTranslation } from "next-i18next";
import { useDeleteLink } from "@/hooks/store/links";
import toast from "react-hot-toast";
type Props = {
onClose: Function;
@@ -16,31 +16,32 @@ export default function DeleteLinkModal({ onClose, activeLink }: Props) {
const { t } = useTranslation();
const [link, setLink] =
useState<LinkIncludingShortenedCollectionAndTags>(activeLink);
const { removeLink } = useLinkStore();
const deleteLink = useDeleteLink();
const router = useRouter();
useEffect(() => {
setLink(activeLink);
}, []);
const deleteLink = async () => {
const submit = async () => {
const load = toast.loading(t("deleting"));
const response = await removeLink(link.id as number);
await deleteLink.mutateAsync(link.id as number, {
onSettled: (data, error) => {
toast.dismiss(load);
toast.dismiss(load);
if (response.ok) {
toast.success(t("deleted"));
} else {
toast.error(response.data as string);
}
if (router.pathname.startsWith("/links/[id]")) {
router.push("/dashboard");
}
onClose();
if (error) {
toast.error(error.message);
} else {
if (router.pathname.startsWith("/links/[id]")) {
router.push("/dashboard");
}
toast.success(t("deleted"));
onClose();
}
},
});
};
return (
@@ -61,7 +62,7 @@ export default function DeleteLinkModal({ onClose, activeLink }: Props) {
<p>{t("shift_key_tip")}</p>
<Button className="ml-auto" intent="destructive" onClick={deleteLink}>
<Button className="ml-auto" intent="destructive" onClick={submit}>
<i className="bi-trash text-xl" />
{t("delete")}
</Button>

View File

@@ -1,8 +1,8 @@
import toast from "react-hot-toast";
import Modal from "../Modal";
import useUserStore from "@/store/admin/users";
import Button from "../ui/Button";
import { useTranslation } from "next-i18next";
import { useDeleteUser } from "@/hooks/store/admin/users";
import { useState } from "react";
type Props = {
onClose: Function;
@@ -11,22 +11,22 @@ type Props = {
export default function DeleteUserModal({ onClose, userId }: Props) {
const { t } = useTranslation();
const { removeUser } = useUserStore();
const deleteUser = async () => {
const load = toast.loading(t("deleting_user"));
const [submitLoader, setSubmitLoader] = useState(false);
const deleteUser = useDeleteUser();
const response = await removeUser(userId);
const submit = async () => {
if (!submitLoader) {
setSubmitLoader(true);
toast.dismiss(load);
await deleteUser.mutateAsync(userId, {
onSuccess: () => {
onClose();
},
});
if (response.ok) {
toast.success(t("user_deleted"));
} else {
toast.error(response.data as string);
setSubmitLoader(false);
}
onClose();
};
return (
@@ -45,7 +45,7 @@ export default function DeleteUserModal({ onClose, userId }: Props) {
</span>
</div>
<Button className="ml-auto" intent="destructive" onClick={deleteUser}>
<Button className="ml-auto" intent="destructive" onClick={submit}>
<i className="bi-trash text-xl" />
{t("delete_confirmation")}
</Button>

View File

@@ -1,11 +1,11 @@
import React, { useState } from "react";
import TextInput from "@/components/TextInput";
import useCollectionStore from "@/store/collections";
import toast from "react-hot-toast";
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";
type Props = {
onClose: Function;
@@ -21,7 +21,7 @@ export default function EditCollectionModal({
useState<CollectionIncludingMembersAndLinkCount>(activeCollection);
const [submitLoader, setSubmitLoader] = useState(false);
const { updateCollection } = useCollectionStore();
const updateCollection = useUpdateCollection();
const submit = async () => {
if (!submitLoader) {
@@ -32,14 +32,18 @@ export default function EditCollectionModal({
const load = toast.loading(t("updating_collection"));
let response = await updateCollection(collection as any);
await updateCollection.mutateAsync(collection, {
onSettled: (data, error) => {
toast.dismiss(load);
toast.dismiss(load);
if (response.ok) {
toast.success(t("updated"));
onClose();
} else toast.error(response.data as string);
if (error) {
toast.error(error.message);
} else {
onClose();
toast.success(t("updated"));
}
},
});
setSubmitLoader(false);
}

View File

@@ -1,16 +1,22 @@
import React, { useEffect, useState } from "react";
import TextInput from "@/components/TextInput";
import useCollectionStore from "@/store/collections";
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 useAccountStore from "@/store/account";
import usePermissions from "@/hooks/usePermissions";
import ProfilePhoto from "../ProfilePhoto";
import addMemberToCollection from "@/lib/client/addMemberToCollection";
import Modal from "../Modal";
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;
@@ -27,7 +33,7 @@ export default function EditCollectionSharingModal({
useState<CollectionIncludingMembersAndLinkCount>(activeCollection);
const [submitLoader, setSubmitLoader] = useState(false);
const { updateCollection } = useCollectionStore();
const updateCollection = useUpdateCollection();
const submit = async () => {
if (!submitLoader) {
@@ -36,41 +42,36 @@ export default function EditCollectionSharingModal({
setSubmitLoader(true);
const load = toast.loading(t("updating"));
const load = toast.loading(t("updating_collection"));
let response;
await updateCollection.mutateAsync(collection, {
onSettled: (data, error) => {
setSubmitLoader(false);
toast.dismiss(load);
response = await updateCollection(collection as any);
toast.dismiss(load);
if (response.ok) {
toast.success(t("updated"));
onClose();
} else toast.error(response.data as string);
setSubmitLoader(false);
if (error) {
toast.error(error.message);
} else {
onClose();
toast.success(t("updated"));
}
},
});
}
};
const { account } = useAccountStore();
const { data: user = {} } = useUser();
const permissions = usePermissions(collection.id as number);
const currentURL = new URL(document.URL);
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 () => {
@@ -91,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>
@@ -130,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(
account.username as string,
memberUsername || "",
user,
memberIdentifier.replace(/^@/, "") || "",
collection,
setMemberState,
t
@@ -177,8 +176,8 @@ export default function EditCollectionSharingModal({
<div
onClick={() =>
addMemberToCollection(
account.username as string,
memberUsername || "",
user,
memberIdentifier.replace(/^@/, "") || "",
collection,
setMemberState,
t
@@ -264,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}
@@ -275,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"
@@ -314,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>
@@ -359,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>
@@ -404,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>
@@ -419,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"
@@ -450,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

@@ -3,12 +3,12 @@ import CollectionSelection from "@/components/InputSelect/CollectionSelection";
import TagSelection from "@/components/InputSelect/TagSelection";
import TextInput from "@/components/TextInput";
import unescapeString from "@/lib/client/unescapeString";
import useLinkStore from "@/store/links";
import { LinkIncludingShortenedCollectionAndTags } from "@/types/global";
import toast from "react-hot-toast";
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;
@@ -27,9 +27,10 @@ export default function EditLinkModal({ onClose, activeLink }: Props) {
console.log(error);
}
const { updateLink } = useLinkStore();
const [submitLoader, setSubmitLoader] = useState(false);
const updateLink = useUpdateLink();
const setCollection = (e: any) => {
if (e?.__isNew__) e.value = null;
setLink({
@@ -50,19 +51,23 @@ export default function EditLinkModal({ onClose, activeLink }: Props) {
const submit = async () => {
if (!submitLoader) {
setSubmitLoader(true);
const load = toast.loading(t("updating"));
let response = await updateLink(link);
toast.dismiss(load);
if (response.ok) {
toast.success(t("updated"));
onClose();
} else {
toast.error(response.data as string);
}
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 response;
}
};

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,186 @@
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 clsx from "clsx";
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 && (
<div className="flex gap-1 h-8 rounded-full bg-neutral-content bg-opacity-50 text-base-content p-1 text-xs duration-100 select-none z-10">
<div
className={clsx(
"py-1 px-2 cursor-pointer duration-100 rounded-full font-semibold",
mode === "view" && "bg-primary bg-opacity-50"
)}
onClick={() => {
setMode("view");
}}
>
View
</div>
<div
className={clsx(
"py-1 px-2 cursor-pointer duration-100 rounded-full font-semibold",
mode === "edit" && "bg-primary bg-opacity-50"
)}
onClick={() => {
setMode("edit");
}}
>
Edit
</div>
</div>
)}
<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,14 +1,12 @@
import React, { useEffect, useState } from "react";
import TextInput from "@/components/TextInput";
import useCollectionStore from "@/store/collections";
import toast from "react-hot-toast";
import { HexColorPicker } from "react-colorful";
import { Collection } from "@prisma/client";
import Modal from "../Modal";
import { CollectionIncludingMembersAndLinkCount } from "@/types/global";
import useAccountStore from "@/store/account";
import { useSession } from "next-auth/react";
import { useTranslation } from "next-i18next";
import { useCreateCollection } from "@/hooks/store/collections";
import toast from "react-hot-toast";
type Props = {
onClose: Function;
@@ -25,15 +23,14 @@ export default function NewCollectionModal({ onClose, parent }: Props) {
} as Partial<Collection>;
const [collection, setCollection] = useState<Partial<Collection>>(initial);
const { setAccount } = useAccountStore();
const { data } = useSession();
useEffect(() => {
setCollection(initial);
}, []);
const [submitLoader, setSubmitLoader] = useState(false);
const { addCollection } = useCollectionStore();
const createCollection = useCreateCollection();
const submit = async () => {
if (submitLoader) return;
@@ -43,16 +40,18 @@ export default function NewCollectionModal({ onClose, parent }: Props) {
const load = toast.loading(t("creating"));
let response = await addCollection(collection as any);
toast.dismiss(load);
await createCollection.mutateAsync(collection, {
onSettled: (data, error) => {
toast.dismiss(load);
if (response.ok) {
toast.success(t("created_success"));
if (response.data) {
setAccount(data?.user.id as number);
onClose();
}
} else toast.error(response.data as string);
if (error) {
toast.error(error.message);
} else {
onClose();
toast.success(t("created"));
}
},
});
setSubmitLoader(false);
};

View File

@@ -1,17 +1,16 @@
import React, { useEffect, useState } from "react";
import { Toaster } from "react-hot-toast";
import CollectionSelection from "@/components/InputSelect/CollectionSelection";
import TagSelection from "@/components/InputSelect/TagSelection";
import TextInput from "@/components/TextInput";
import unescapeString from "@/lib/client/unescapeString";
import useCollectionStore from "@/store/collections";
import useLinkStore from "@/store/links";
import { LinkIncludingShortenedCollectionAndTags } 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 { useAddLink } from "@/hooks/store/links";
import toast from "react-hot-toast";
type Props = {
onClose: Function;
@@ -40,11 +39,13 @@ export default function NewLinkModal({ onClose }: Props) {
const [link, setLink] =
useState<LinkIncludingShortenedCollectionAndTags>(initial);
const { addLink } = useLinkStore();
const addLink = useAddLink();
const [submitLoader, setSubmitLoader] = useState(false);
const [optionsExpanded, setOptionsExpanded] = useState(false);
const router = useRouter();
const { collections } = useCollectionStore();
const { data: collections = [] } = useCollections();
const setCollection = (e: any) => {
if (e?.__isNew__) e.value = null;
@@ -87,15 +88,22 @@ export default function NewLinkModal({ onClose }: Props) {
const submit = async () => {
if (!submitLoader) {
setSubmitLoader(true);
const load = toast.loading(t("creating_link"));
const response = await addLink(link);
toast.dismiss(load);
if (response.ok) {
toast.success(t("link_created"));
onClose();
} else {
toast.error(response.data as string);
}
await addLink.mutateAsync(link, {
onSettled: (data, error) => {
toast.dismiss(load);
if (error) {
toast.error(error.message);
} else {
onClose();
toast.success(t("link_created"));
}
},
});
setSubmitLoader(false);
}
};

View File

@@ -3,10 +3,10 @@ import TextInput from "@/components/TextInput";
import { TokenExpiry } from "@/types/global";
import toast from "react-hot-toast";
import Modal from "../Modal";
import useTokenStore from "@/store/tokens";
import { dropdownTriggerer } from "@/lib/client/utils";
import Button from "../ui/Button";
import { useTranslation } from "next-i18next";
import { useAddToken } from "@/hooks/store/tokens";
type Props = {
onClose: Function;
@@ -15,7 +15,7 @@ type Props = {
export default function NewTokenModal({ onClose }: Props) {
const { t } = useTranslation();
const [newToken, setNewToken] = useState("");
const { addToken } = useTokenStore();
const addToken = useAddToken();
const initial = {
name: "",
@@ -28,16 +28,20 @@ export default function NewTokenModal({ onClose }: Props) {
const submit = async () => {
if (!submitLoader) {
setSubmitLoader(true);
const load = toast.loading(t("creating_token"));
const { ok, data } = await addToken(token);
await addToken.mutateAsync(token, {
onSettled: (data, error) => {
toast.dismiss(load);
toast.dismiss(load);
if (ok) {
toast.success(t("token_created"));
setNewToken((data as any).secretKey);
} else toast.error(data as string);
if (error) {
toast.error(error.message);
} else {
setNewToken(data.secretKey);
}
},
});
setSubmitLoader(false);
}
@@ -111,7 +115,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"
@@ -131,7 +135,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>
@@ -150,7 +156,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>
@@ -172,7 +180,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>
@@ -194,7 +204,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>
@@ -213,7 +225,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

@@ -1,9 +1,9 @@
import toast from "react-hot-toast";
import Modal from "../Modal";
import useUserStore from "@/store/admin/users";
import TextInput from "../TextInput";
import { FormEvent, useState } from "react";
import { useTranslation, Trans } from "next-i18next";
import { useAddUser } from "@/hooks/store/admin/users";
type Props = {
onClose: Function;
@@ -20,7 +20,9 @@ const emailEnabled = process.env.NEXT_PUBLIC_EMAIL_PROVIDER === "true";
export default function NewUserModal({ onClose }: Props) {
const { t } = useTranslation();
const { addUser } = useUserStore();
const addUser = useAddUser();
const [form, setForm] = useState<FormData>({
name: "",
username: "",
@@ -44,24 +46,15 @@ export default function NewUserModal({ onClose }: Props) {
};
if (checkFields()) {
if (form.password.length < 8)
return toast.error(t("password_length_error"));
setSubmitLoader(true);
const load = toast.loading(t("creating_account"));
await addUser.mutateAsync(form, {
onSuccess: () => {
onClose();
},
});
const response = await addUser(form);
toast.dismiss(load);
setSubmitLoader(false);
if (response.ok) {
toast.success(t("user_created"));
onClose();
} else {
toast.error(response.data as string);
}
} else {
toast.error(t("fill_all_fields_error"));
}

View File

@@ -1,5 +1,4 @@
import React, { useEffect, useState } from "react";
import useLinkStore from "@/store/links";
import {
LinkIncludingShortenedCollectionAndTags,
ArchivedFormat,
@@ -16,23 +15,22 @@ import {
screenshotAvailable,
} from "@/lib/shared/getArchiveValidity";
import PreservedFormatRow from "@/components/PreserverdFormatRow";
import useAccountStore from "@/store/account";
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;
activeLink: LinkIncludingShortenedCollectionAndTags;
link: LinkIncludingShortenedCollectionAndTags;
};
export default function PreservedFormatsModal({ onClose, activeLink }: Props) {
export default function PreservedFormatsModal({ onClose, link }: Props) {
const { t } = useTranslation();
const session = useSession();
const { getLink } = useLinkStore();
const { account } = useAccountStore();
const [link, setLink] =
useState<LinkIncludingShortenedCollectionAndTags>(activeLink);
const getLink = useGetLink();
const { data: user = {} } = useUser();
const router = useRouter();
let isPublic = router.pathname.startsWith("/public") ? true : undefined;
@@ -49,20 +47,20 @@ export default function PreservedFormatsModal({ onClose, activeLink }: Props) {
useEffect(() => {
const fetchOwner = async () => {
if (link.collection.ownerId !== account.id) {
if (link.collection.ownerId !== user.id) {
const owner = await getPublicUserData(
link.collection.ownerId as number
);
setCollectionOwner(owner);
} else if (link.collection.ownerId === account.id) {
} else if (link.collection.ownerId === user.id) {
setCollectionOwner({
id: account.id as number,
name: account.name,
username: account.username as string,
image: account.image as string,
archiveAsScreenshot: account.archiveAsScreenshot as boolean,
archiveAsMonolith: account.archiveAsScreenshot as boolean,
archiveAsPDF: account.archiveAsPDF as boolean,
id: user.id as number,
name: user.name,
username: user.username as string,
image: user.image as string,
archiveAsScreenshot: user.archiveAsScreenshot as boolean,
archiveAsMonolith: user.archiveAsScreenshot as boolean,
archiveAsPDF: user.archiveAsPDF as boolean,
});
}
};
@@ -98,20 +96,14 @@ export default function PreservedFormatsModal({ onClose, activeLink }: Props) {
useEffect(() => {
(async () => {
const data = await getLink(link.id as number, isPublic);
setLink(
(data as any).response as LinkIncludingShortenedCollectionAndTags
);
await getLink.mutateAsync(link.id as number);
})();
let interval: any;
if (!isReady()) {
interval = setInterval(async () => {
const data = await getLink(link.id as number, isPublic);
setLink(
(data as any).response as LinkIncludingShortenedCollectionAndTags
);
await getLink.mutateAsync(link.id as number);
}, 5000);
} else {
if (interval) {
@@ -137,10 +129,8 @@ export default function PreservedFormatsModal({ onClose, activeLink }: Props) {
toast.dismiss(load);
if (response.ok) {
const newLink = await getLink(link?.id as number);
setLink(
(newLink as any).response as LinkIncludingShortenedCollectionAndTags
);
await getLink.mutateAsync(link?.id as number);
toast.success(t("link_being_archived"));
} else toast.error(data.response);
};
@@ -164,7 +154,7 @@ export default function PreservedFormatsModal({ onClose, activeLink }: Props) {
name={t("webpage")}
icon={"bi-filetype-html"}
format={ArchivedFormat.monolith}
activeLink={link}
link={link}
downloadable={true}
/>
) : undefined}
@@ -178,7 +168,7 @@ export default function PreservedFormatsModal({ onClose, activeLink }: Props) {
? ArchivedFormat.png
: ArchivedFormat.jpeg
}
activeLink={link}
link={link}
downloadable={true}
/>
) : undefined}
@@ -188,7 +178,7 @@ export default function PreservedFormatsModal({ onClose, activeLink }: Props) {
name={t("pdf")}
icon={"bi-file-earmark-pdf"}
format={ArchivedFormat.pdf}
activeLink={link}
link={link}
downloadable={true}
/>
) : undefined}
@@ -198,7 +188,7 @@ export default function PreservedFormatsModal({ onClose, activeLink }: Props) {
name={t("readable")}
icon={"bi-file-earmark-text"}
format={ArchivedFormat.readability}
activeLink={link}
link={link}
/>
) : undefined}

View File

@@ -1,10 +1,10 @@
import React, { useEffect, useState } from "react";
import useTokenStore from "@/store/tokens";
import toast from "react-hot-toast";
import Modal from "../Modal";
import Button from "../ui/Button";
import { useTranslation } from "next-i18next";
import { AccessToken } from "@prisma/client";
import { useRevokeToken } from "@/hooks/store/tokens";
import toast from "react-hot-toast";
type Props = {
onClose: Function;
@@ -15,7 +15,7 @@ export default function DeleteTokenModal({ onClose, activeToken }: Props) {
const { t } = useTranslation();
const [token, setToken] = useState<AccessToken>(activeToken);
const { revokeToken } = useTokenStore();
const revokeToken = useRevokeToken();
useEffect(() => {
setToken(activeToken);
@@ -24,17 +24,18 @@ export default function DeleteTokenModal({ onClose, activeToken }: Props) {
const deleteLink = async () => {
const load = toast.loading(t("deleting"));
const response = await revokeToken(token.id as number);
await revokeToken.mutateAsync(token.id, {
onSettled: (data, error) => {
toast.dismiss(load);
toast.dismiss(load);
if (response.ok) {
toast.success(t("token_revoked"));
} else {
toast.error(response.data as string);
}
onClose();
if (error) {
toast.error(error.message);
} else {
onClose();
toast.success(t("token_revoked"));
}
},
});
};
return (

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,8 +3,6 @@ import CollectionSelection from "@/components/InputSelect/CollectionSelection";
import TagSelection from "@/components/InputSelect/TagSelection";
import TextInput from "@/components/TextInput";
import unescapeString from "@/lib/client/unescapeString";
import useCollectionStore from "@/store/collections";
import useLinkStore from "@/store/links";
import {
LinkIncludingShortenedCollectionAndTags,
ArchivedFormat,
@@ -14,6 +12,8 @@ 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";
type Props = {
onClose: Function;
@@ -45,11 +45,11 @@ export default function UploadFileModal({ onClose }: Props) {
useState<LinkIncludingShortenedCollectionAndTags>(initial);
const [file, setFile] = useState<File>();
const { uploadFile } = useLinkStore();
const uploadFile = useUploadFile();
const [submitLoader, setSubmitLoader] = useState(false);
const [optionsExpanded, setOptionsExpanded] = useState(false);
const router = useRouter();
const { collections } = useCollectionStore();
const { data: collections = [] } = useCollections();
const setCollection = (e: any) => {
if (e?.__isNew__) e.value = null;
@@ -115,20 +115,26 @@ export default function UploadFileModal({ onClose }: Props) {
// }
setSubmitLoader(true);
const load = toast.loading(t("creating"));
const response = await uploadFile(link, file);
await uploadFile.mutateAsync(
{ link, file },
{
onSettled: (data, error) => {
toast.dismiss(load);
toast.dismiss(load);
if (response.ok) {
toast.success(t("created_success"));
onClose();
} else {
toast.error(response.data as string);
}
if (error) {
toast.error(error.message);
} else {
onClose();
toast.success(t("created_success"));
}
},
}
);
setSubmitLoader(false);
return response;
}
};

View File

@@ -66,7 +66,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 +75,7 @@ export default function Navbar() {
}}
tabIndex={0}
role="button"
className="whitespace-nowrap"
>
{t("new_link")}
</div>
@@ -87,6 +88,7 @@ export default function Navbar() {
}}
tabIndex={0}
role="button"
className="whitespace-nowrap"
>
{t("upload_file")}
</div>
@@ -99,6 +101,7 @@ export default function Navbar() {
}}
tabIndex={0}
role="button"
className="whitespace-nowrap"
>
{t("new_collection")}
</div>

View File

@@ -1,19 +1,16 @@
import React, { useEffect, useState } from "react";
import useLinkStore from "@/store/links";
import {
ArchivedFormat,
LinkIncludingShortenedCollectionAndTags,
} from "@/types/global";
import toast from "react-hot-toast";
import Link from "next/link";
import { useRouter } from "next/router";
import { useSession } from "next-auth/react";
import { useGetLink } from "@/hooks/store/links";
type Props = {
name: string;
icon: string;
format: ArchivedFormat;
activeLink: LinkIncludingShortenedCollectionAndTags;
link: LinkIncludingShortenedCollectionAndTags;
downloadable?: boolean;
};
@@ -21,48 +18,15 @@ export default function PreservedFormatRow({
name,
icon,
format,
activeLink,
link,
downloadable,
}: Props) {
const session = useSession();
const { getLink } = useLinkStore();
const [link, setLink] =
useState<LinkIncludingShortenedCollectionAndTags>(activeLink);
const getLink = useGetLink();
const router = useRouter();
let isPublic = router.pathname.startsWith("/public") ? true : undefined;
useEffect(() => {
(async () => {
const data = await getLink(link.id as number, isPublic);
setLink(
(data as any).response as LinkIncludingShortenedCollectionAndTags
);
})();
let interval: any;
if (link?.image === "pending" || link?.pdf === "pending") {
interval = setInterval(async () => {
const data = await getLink(link.id as number, isPublic);
setLink(
(data as any).response as LinkIncludingShortenedCollectionAndTags
);
}, 5000);
} else {
if (interval) {
clearInterval(interval);
}
}
return () => {
if (interval) {
clearInterval(interval);
}
};
}, [link?.image, link?.pdf, link?.readable, link?.monolith]);
const handleDownload = () => {
const path = `/api/v1/archives/${link?.id}?format=${format}`;
fetch(path)

View File

@@ -1,17 +1,17 @@
import useLocalSettingsStore from "@/store/localSettings";
import { dropdownTriggerer } from "@/lib/client/utils";
import ProfilePhoto from "./ProfilePhoto";
import useAccountStore from "@/store/account";
import Link from "next/link";
import { signOut } from "next-auth/react";
import { useTranslation } from "next-i18next";
import { useUser } from "@/hooks/store/user";
export default function ProfileDropdown() {
const { t } = useTranslation();
const { settings, updateSettings } = useLocalSettingsStore();
const { account } = useAccountStore();
const { data: user = {} } = useUser();
const isAdmin = account.id === Number(process.env.NEXT_PUBLIC_ADMIN || 1);
const isAdmin = user.id === Number(process.env.NEXT_PUBLIC_ADMIN || 1);
const handleToggle = () => {
const newTheme = settings.theme === "dark" ? "light" : "dark";
@@ -27,14 +27,12 @@ export default function ProfileDropdown() {
className="btn btn-circle btn-ghost"
>
<ProfilePhoto
src={account.image ? account.image : undefined}
src={user.image ? user.image : undefined}
priority={true}
/>
</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 +40,7 @@ export default function ProfileDropdown() {
onClick={() => (document?.activeElement as HTMLElement)?.blur()}
tabIndex={0}
role="button"
className="whitespace-nowrap"
>
{t("settings")}
</Link>
@@ -54,24 +53,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 +81,7 @@ export default function ProfileDropdown() {
}}
tabIndex={0}
role="button"
className="whitespace-nowrap"
>
{t("logout")}
</div>

View File

@@ -1,7 +1,6 @@
import unescapeString from "@/lib/client/unescapeString";
import { readabilityAvailable } from "@/lib/shared/getArchiveValidity";
import isValidUrl from "@/lib/shared/isValidUrl";
import useLinkStore from "@/store/links";
import {
ArchivedFormat,
CollectionIncludingMembersAndLinkCount,
@@ -14,8 +13,9 @@ import Link from "next/link";
import { useRouter } from "next/router";
import React, { useEffect, useMemo, useState } from "react";
import LinkActions from "./LinkViews/LinkComponents/LinkActions";
import useCollectionStore from "@/store/collections";
import { useTranslation } from "next-i18next";
import { useCollections } from "@/hooks/store/collections";
import { useGetLink } from "@/hooks/store/links";
type LinkContent = {
title: string;
@@ -45,8 +45,8 @@ export default function ReadableView({ link }: Props) {
const router = useRouter();
const { getLink } = useLinkStore();
const { collections } = useCollectionStore();
const getLink = useGetLink();
const { data: collections = [] } = useCollections();
const collection = useMemo(() => {
return collections.find(
@@ -73,7 +73,7 @@ export default function ReadableView({ link }: Props) {
}, [link]);
useEffect(() => {
if (link) getLink(link?.id as number);
if (link) getLink.mutateAsync(link?.id as number);
let interval: any;
if (
@@ -87,7 +87,10 @@ export default function ReadableView({ link }: Props) {
!link?.readable ||
!link?.monolith)
) {
interval = setInterval(() => getLink(link.id as number), 5000);
interval = setInterval(
() => getLink.mutateAsync(link.id as number),
5000
);
} else {
if (interval) {
clearInterval(interval);

View File

@@ -1,5 +1,3 @@
import useCollectionStore from "@/store/collections";
import useTagStore from "@/store/tags";
import Link from "next/link";
import { useRouter } from "next/router";
import { useEffect, useState } from "react";
@@ -7,6 +5,8 @@ import { Disclosure, Transition } from "@headlessui/react";
import SidebarHighlightLink from "@/components/SidebarHighlightLink";
import CollectionListing from "@/components/CollectionListing";
import { useTranslation } from "next-i18next";
import { useCollections } from "@/hooks/store/collections";
import { useTags } from "@/hooks/store/tags";
export default function Sidebar({ className }: { className?: string }) {
const { t } = useTranslation();
@@ -22,8 +22,9 @@ export default function Sidebar({ className }: { className?: string }) {
}
);
const { collections } = useCollectionStore();
const { tags } = useTagStore();
const { data: collections } = useCollections();
const { data: tags = [], isLoading } = useTags();
const [active, setActive] = useState("");
const router = useRouter();
@@ -127,10 +128,16 @@ export default function Sidebar({ className }: { className?: string }) {
leaveTo="transform opacity-0 -translate-y-3"
>
<Disclosure.Panel className="flex flex-col gap-1">
{tags[0] ? (
{isLoading ? (
<div className="flex flex-col gap-4">
<div className="skeleton h-4 w-full"></div>
<div className="skeleton h-4 w-full"></div>
<div className="skeleton h-4 w-full"></div>
</div>
) : tags[0] ? (
tags
.sort((a, b) => a.name.localeCompare(b.name))
.map((e, i) => {
.sort((a: any, b: any) => a.name.localeCompare(b.name))
.map((e: any, i: any) => {
return (
<Link key={i} href={`/tags/${e.id}`}>
<div

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>

View File

@@ -1,7 +1,10 @@
import React, { Dispatch, SetStateAction } from "react";
import React, { Dispatch, SetStateAction, useEffect } from "react";
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;
@@ -10,6 +13,13 @@ type Props = {
};
export default function SortDropdown({ sortBy, setSort, t }: Props) {
const { updateSettings } = useLocalSettingsStore();
const queryClient = useQueryClient();
useEffect(() => {
updateSettings({ sortBy });
}, [sortBy]);
return (
<div className="dropdown dropdown-bottom dropdown-end">
<div
@@ -20,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"
@@ -32,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>
@@ -48,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>
@@ -64,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>
@@ -80,9 +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>
<span className="label-text whitespace-nowrap">{t("name_za")}</span>
</label>
</li>
<li>
@@ -96,9 +122,14 @@ export default function SortDropdown({ sortBy, setSort, t }: Props) {
name="sort-radio"
className="radio checked:bg-primary"
checked={sortBy === Sort.DescriptionAZ}
onChange={() => setSort(Sort.DescriptionAZ)}
onChange={() => {
resetInfiniteQueryPagination(queryClient, ["links"]);
setSort(Sort.DescriptionAZ);
}}
/>
<span className="label-text">{t("description_az")}</span>
<span className="label-text whitespace-nowrap">
{t("description_az")}
</span>
</label>
</li>
<li>
@@ -112,9 +143,14 @@ export default function SortDropdown({ sortBy, setSort, t }: Props) {
name="sort-radio"
className="radio checked:bg-primary"
checked={sortBy === Sort.DescriptionZA}
onChange={() => setSort(Sort.DescriptionZA)}
onChange={() => {
resetInfiniteQueryPagination(queryClient, ["links"]);
setSort(Sort.DescriptionZA);
}}
/>
<span className="label-text">{t("description_za")}</span>
<span className="label-text whitespace-nowrap">
{t("description_za")}
</span>
</label>
</li>
</ul>

View File

@@ -1,72 +1,147 @@
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: string;
setViewMode: Dispatch<SetStateAction<string>>;
viewMode: ViewMode;
setViewMode: Dispatch<SetStateAction<ViewMode>>;
};
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 as ViewMode });
}, [viewMode]);
updateSettings({ 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;

272
components/ui/Loader.tsx Normal file
View File

@@ -0,0 +1,272 @@
import React from "react";
type Props = {
className?: string;
color: string;
size: string;
};
const Loader = (props: Props) => {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 100 100"
preserveAspectRatio="xMidYMid"
width={props.size}
height={props.size}
className={props.className}
style={{
shapeRendering: "auto",
display: "block",
background: "rgba(255, 255, 255, 0)",
}}
xmlnsXlink="http://www.w3.org/1999/xlink"
>
<g>
<g transform="rotate(0 50 50)">
<rect
fill={props.color}
height="12"
width="6"
ry="1.8"
rx="1.8"
y="24"
x="47"
>
<animate
repeatCount="indefinite"
begin="-0.9166666666666666s"
dur="1s"
keyTimes="0;1"
values="1;0"
attributeName="opacity"
></animate>
</rect>
</g>
<g transform="rotate(30 50 50)">
<rect
fill={props.color}
height="12"
width="6"
ry="1.8"
rx="1.8"
y="24"
x="47"
>
<animate
repeatCount="indefinite"
begin="-0.8333333333333334s"
dur="1s"
keyTimes="0;1"
values="1;0"
attributeName="opacity"
></animate>
</rect>
</g>
<g transform="rotate(60 50 50)">
<rect
fill={props.color}
height="12"
width="6"
ry="1.8"
rx="1.8"
y="24"
x="47"
>
<animate
repeatCount="indefinite"
begin="-0.75s"
dur="1s"
keyTimes="0;1"
values="1;0"
attributeName="opacity"
></animate>
</rect>
</g>
<g transform="rotate(90 50 50)">
<rect
fill={props.color}
height="12"
width="6"
ry="1.8"
rx="1.8"
y="24"
x="47"
>
<animate
repeatCount="indefinite"
begin="-0.6666666666666666s"
dur="1s"
keyTimes="0;1"
values="1;0"
attributeName="opacity"
></animate>
</rect>
</g>
<g transform="rotate(120 50 50)">
<rect
fill={props.color}
height="12"
width="6"
ry="1.8"
rx="1.8"
y="24"
x="47"
>
<animate
repeatCount="indefinite"
begin="-0.5833333333333334s"
dur="1s"
keyTimes="0;1"
values="1;0"
attributeName="opacity"
></animate>
</rect>
</g>
<g transform="rotate(150 50 50)">
<rect
fill={props.color}
height="12"
width="6"
ry="1.8"
rx="1.8"
y="24"
x="47"
>
<animate
repeatCount="indefinite"
begin="-0.5s"
dur="1s"
keyTimes="0;1"
values="1;0"
attributeName="opacity"
></animate>
</rect>
</g>
<g transform="rotate(180 50 50)">
<rect
fill={props.color}
height="12"
width="6"
ry="1.8"
rx="1.8"
y="24"
x="47"
>
<animate
repeatCount="indefinite"
begin="-0.4166666666666667s"
dur="1s"
keyTimes="0;1"
values="1;0"
attributeName="opacity"
></animate>
</rect>
</g>
<g transform="rotate(210 50 50)">
<rect
fill={props.color}
height="12"
width="6"
ry="1.8"
rx="1.8"
y="24"
x="47"
>
<animate
repeatCount="indefinite"
begin="-0.3333333333333333s"
dur="1s"
keyTimes="0;1"
values="1;0"
attributeName="opacity"
></animate>
</rect>
</g>
<g transform="rotate(240 50 50)">
<rect
fill={props.color}
height="12"
width="6"
ry="1.8"
rx="1.8"
y="24"
x="47"
>
<animate
repeatCount="indefinite"
begin="-0.25s"
dur="1s"
keyTimes="0;1"
values="1;0"
attributeName="opacity"
></animate>
</rect>
</g>
<g transform="rotate(270 50 50)">
<rect
fill={props.color}
height="12"
width="6"
ry="1.8"
rx="1.8"
y="24"
x="47"
>
<animate
repeatCount="indefinite"
begin="-0.16666666666666666s"
dur="1s"
keyTimes="0;1"
values="1;0"
attributeName="opacity"
></animate>
</rect>
</g>
<g transform="rotate(300 50 50)">
<rect
fill={props.color}
height="12"
width="6"
ry="1.8"
rx="1.8"
y="24"
x="47"
>
<animate
repeatCount="indefinite"
begin="-0.08333333333333333s"
dur="1s"
keyTimes="0;1"
values="1;0"
attributeName="opacity"
></animate>
</rect>
</g>
<g transform="rotate(330 50 50)">
<rect
fill={props.color}
height="12"
width="6"
ry="1.8"
rx="1.8"
y="24"
x="47"
>
<animate
repeatCount="indefinite"
begin="0s"
dur="1s"
keyTimes="0;1"
values="1;0"
attributeName="opacity"
></animate>
</rect>
</g>
<g></g>
</g>
</svg>
);
};
export default Loader;

View File

@@ -0,0 +1,93 @@
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import toast from "react-hot-toast";
import { useTranslation } from "next-i18next";
import { useSession } from "next-auth/react";
const useUsers = () => {
const { status } = useSession();
return useQuery({
queryKey: ["users"],
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.");
}
const data = await response.json();
return data.response;
},
enabled: status === "authenticated",
});
};
const useAddUser = () => {
const queryClient = useQueryClient();
const { t } = useTranslation();
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", {
method: "POST",
body: JSON.stringify(body),
headers: {
"Content-Type": "application/json",
},
});
const data = await response.json();
if (!response.ok) throw new Error(data.response);
toast.dismiss(load);
return data.response;
},
onSuccess: (data) => {
queryClient.setQueryData(["users"], (oldData: any) => [...oldData, data]);
toast.success(t("user_created"));
},
onError: (error) => {
toast.error(error.message);
},
});
};
const useDeleteUser = () => {
const queryClient = useQueryClient();
const { t } = useTranslation();
return useMutation({
mutationFn: async (userId: number) => {
const load = toast.loading(t("deleting_user"));
const response = await fetch(`/api/v1/users/${userId}`, {
method: "DELETE",
});
const data = await response.json();
if (!response.ok) throw new Error(data.response);
toast.dismiss(load);
return data.response;
},
onSuccess: (data, variables) => {
queryClient.setQueryData(["users"], (oldData: any) =>
oldData.filter((user: any) => user.id !== variables)
);
toast.success(t("user_deleted"));
},
onError: (error) => {
toast.error(error.message);
},
});
};
export { useUsers, useAddUser, useDeleteUser };

116
hooks/store/collections.tsx Normal file
View File

@@ -0,0 +1,116 @@
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { CollectionIncludingMembersAndLinkCount } from "@/types/global";
import { useSession } from "next-auth/react";
const useCollections = () => {
const { status } = useSession();
return useQuery({
queryKey: ["collections"],
queryFn: async (): Promise<CollectionIncludingMembersAndLinkCount[]> => {
const response = await fetch("/api/v1/collections");
const data = await response.json();
return data.response;
},
enabled: status === "authenticated",
});
};
const useCreateCollection = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (body: any) => {
const response = await fetch("/api/v1/collections", {
body: JSON.stringify(body),
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) => {
return queryClient.setQueryData(["collections"], (oldData: any) => {
return [...oldData, data];
});
},
});
};
const useUpdateCollection = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (body: any) => {
const response = await fetch(`/api/v1/collections/${body.id}`, {
method: "PUT",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(body),
});
const data = await response.json();
if (!response.ok) throw new Error(data.response);
return data.response;
},
onSuccess: (data) => {
{
return queryClient.setQueryData(["collections"], (oldData: any) => {
return oldData.map((collection: any) =>
collection.id === data.id ? data : collection
);
});
}
},
// onMutate: async (data) => {
// await queryClient.cancelQueries({ queryKey: ["collections"] });
// queryClient.setQueryData(["collections"], (oldData: any) => {
// return oldData.map((collection: any) =>
// collection.id === data.id ? data : collection
// )
// });
// },
});
};
const useDeleteCollection = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (id: number) => {
const response = await fetch(`/api/v1/collections/${id}`, {
method: "DELETE",
headers: {
"Content-Type": "application/json",
},
});
const data = await response.json();
if (!response.ok) throw new Error(data.response);
return data.response;
},
onSuccess: (data) => {
return queryClient.setQueryData(["collections"], (oldData: any) => {
return oldData.filter((collection: any) => collection.id !== data.id);
});
},
});
};
export {
useCollections,
useCreateCollection,
useUpdateCollection,
useDeleteCollection,
};

View File

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

457
hooks/store/links.tsx Normal file
View File

@@ -0,0 +1,457 @@
import {
InfiniteData,
useInfiniteQuery,
UseInfiniteQueryResult,
useQueryClient,
useMutation,
} from "@tanstack/react-query";
import { useMemo } from "react";
import {
ArchivedFormat,
LinkIncludingShortenedCollectionAndTags,
LinkRequestQuery,
} from "@/types/global";
import { useRouter } from "next/router";
import { useSession } from "next-auth/react";
const useLinks = (params: LinkRequestQuery = {}) => {
const router = useRouter();
const queryParamsObject = {
sort: params.sort ?? Number(window.localStorage.getItem("sortBy")) ?? 0,
collectionId:
params.collectionId ?? router.pathname === "/collections/[id]"
? router.query.id
: undefined,
tagId:
params.tagId ?? router.pathname === "/tags/[id]"
? router.query.id
: undefined,
pinnedOnly:
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);
const { data, ...rest } = useFetchLinks(queryString);
const links = useMemo(() => {
return data?.pages.reduce((acc, page) => {
return [...acc, ...page];
}, []);
}, [data]);
return {
links,
data: { ...data, ...rest },
} as {
links: LinkIncludingShortenedCollectionAndTags[];
data: UseInfiniteQueryResult<InfiniteData<any, unknown>, Error>;
};
};
const useFetchLinks = (params: string) => {
const { status } = useSession();
return useInfiniteQuery({
queryKey: ["links", { params }],
queryFn: async (params) => {
const response = await fetch(
"/api/v1/links?cursor=" +
params.pageParam +
((params.queryKey[1] as any).params
? "&" + (params.queryKey[1] as any).params
: "")
);
const data = await response.json();
return data.response;
},
initialPageParam: 0,
refetchOnWindowFocus: false,
getNextPageParam: (lastPage) => {
if (lastPage.length === 0) {
return undefined;
}
return lastPage.at(-1).id;
},
enabled: status === "authenticated",
});
};
const buildQueryString = (params: LinkRequestQuery) => {
return Object.keys(params)
.filter((key) => params[key as keyof LinkRequestQuery] !== undefined)
.map(
(key) =>
`${encodeURIComponent(key)}=${encodeURIComponent(
params[key as keyof LinkRequestQuery] as string
)}`
)
.join("&");
};
const useAddLink = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (link: LinkIncludingShortenedCollectionAndTags) => {
const response = await fetch("/api/v1/links", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(link),
});
const data = await response.json();
if (!response.ok) throw new Error(data.response);
return data.response;
},
onSuccess: (data) => {
queryClient.setQueryData(["dashboardData"], (oldData: any) => {
if (!oldData) return undefined;
return [data, ...oldData];
});
queryClient.setQueriesData({ queryKey: ["links"] }, (oldData: any) => {
if (!oldData) return undefined;
return {
pages: [[data, ...oldData?.pages[0]], ...oldData?.pages.slice(1)],
pageParams: oldData?.pageParams,
};
});
queryClient.invalidateQueries({ queryKey: ["collections"] });
queryClient.invalidateQueries({ queryKey: ["tags"] });
queryClient.invalidateQueries({ queryKey: ["publicLinks"] });
},
});
};
const useUpdateLink = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (link: LinkIncludingShortenedCollectionAndTags) => {
const response = await fetch(`/api/v1/links/${link.id}`, {
method: "PUT",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(link),
});
const data = await response.json();
if (!response.ok) throw new Error(data.response);
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"] }); // Temporary workaround
queryClient.invalidateQueries({ queryKey: ["dashboardData"] }); // Temporary workaround
queryClient.invalidateQueries({ queryKey: ["collections"] });
queryClient.invalidateQueries({ queryKey: ["tags"] });
queryClient.invalidateQueries({ queryKey: ["publicLinks"] });
},
});
};
const useDeleteLink = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (id: number) => {
const response = await fetch(`/api/v1/links/${id}`, {
method: "DELETE",
});
const data = await response.json();
if (!response.ok) throw new Error(data.response);
return data.response;
},
onSuccess: (data) => {
queryClient.setQueryData(["dashboardData"], (oldData: any) => {
if (!oldData) return undefined;
return oldData.filter((e: any) => e.id !== data.id);
});
queryClient.setQueriesData({ queryKey: ["links"] }, (oldData: any) => {
if (!oldData) return undefined;
return {
pages: oldData.pages.map((page: any) =>
page.filter((item: any) => item.id !== data.id)
),
pageParams: oldData.pageParams,
};
});
queryClient.invalidateQueries({ queryKey: ["collections"] });
queryClient.invalidateQueries({ queryKey: ["tags"] });
queryClient.invalidateQueries({ queryKey: ["publicLinks"] });
},
});
};
const useGetLink = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (id: number) => {
const response = await fetch(`/api/v1/links/${id}`);
const data = await response.json();
if (!response.ok) throw new Error(data.response);
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: ["publicLinks"] });
},
});
};
const useBulkDeleteLinks = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (linkIds: number[]) => {
const response = await fetch("/api/v1/links", {
method: "DELETE",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ linkIds }),
});
const data = await response.json();
if (!response.ok) throw new Error(data.response);
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))
),
pageParams: oldData.pageParams,
};
});
queryClient.invalidateQueries({ queryKey: ["collections"] });
queryClient.invalidateQueries({ queryKey: ["tags"] });
queryClient.invalidateQueries({ queryKey: ["publicLinks"] });
},
});
};
const useUploadFile = () => {
const queryClient = useQueryClient();
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." };
}
const response = await fetch("/api/v1/links", {
body: JSON.stringify({
...link,
type: linkType,
name: link.name ? link.name : file.name,
}),
headers: {
"Content-Type": "application/json",
},
method: "POST",
});
const data = await response.json();
if (!response.ok) throw new Error(data.response);
if (response.ok) {
const formBody = new FormData();
file && formBody.append("file", file);
await fetch(
`/api/v1/archives/${(data as any).response.id}?format=${fileType}`,
{
body: formBody,
method: "POST",
}
);
}
return data.response;
},
onSuccess: (data) => {
queryClient.setQueryData(["dashboardData"], (oldData: any) => {
if (!oldData) return undefined;
return [data, ...oldData];
});
queryClient.setQueriesData({ queryKey: ["links"] }, (oldData: any) => {
if (!oldData) return undefined;
return {
pages: [[data, ...oldData?.pages[0]], ...oldData?.pages.slice(1)],
pageParams: oldData?.pageParams,
};
});
queryClient.invalidateQueries({ queryKey: ["collections"] });
queryClient.invalidateQueries({ queryKey: ["tags"] });
queryClient.invalidateQueries({ queryKey: ["publicLinks"] });
},
});
};
const useBulkEditLinks = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async ({
links,
newData,
removePreviousTags,
}: {
links: LinkIncludingShortenedCollectionAndTags[];
newData: Pick<
LinkIncludingShortenedCollectionAndTags,
"tags" | "collectionId"
>;
removePreviousTags: boolean;
}) => {
const response = await fetch("/api/v1/links", {
method: "PUT",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ links, newData, removePreviousTags }),
});
const data = await response.json();
if (!response.ok) throw new Error(data.response);
return data.response;
},
onSuccess: (data, { links, newData, removePreviousTags }) => {
// TODO: Fix these
// queryClient.setQueryData(["dashboardData"], (oldData: any) => {
// if (!oldData) return undefined;
// return oldData.map((e: any) =>
// data.find((d: any) => d.id === e.id) ? data : e
// );
// });
// 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
queryClient.invalidateQueries({ queryKey: ["dashboardData"] }); // Temporary workaround
queryClient.invalidateQueries({ queryKey: ["collections"] });
queryClient.invalidateQueries({ queryKey: ["tags"] });
queryClient.invalidateQueries({ queryKey: ["publicLinks"] });
},
});
};
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,
useUpdateLink,
useDeleteLink,
useBulkDeleteLinks,
useUploadFile,
useGetLink,
useBulkEditLinks,
resetInfiniteQueryPagination,
};

View File

@@ -0,0 +1,93 @@
import {
InfiniteData,
useInfiniteQuery,
UseInfiniteQueryResult,
} from "@tanstack/react-query";
import { useMemo } from "react";
import {
LinkIncludingShortenedCollectionAndTags,
LinkRequestQuery,
} from "@/types/global";
import { useRouter } from "next/router";
const usePublicLinks = (params: LinkRequestQuery = {}) => {
const router = useRouter();
const queryParamsObject = {
sort: params.sort ?? Number(window.localStorage.getItem("sortBy")) ?? 0,
collectionId: params.collectionId ?? router.query.id,
tagId:
params.tagId ?? router.pathname === "/tags/[id]"
? router.query.id
: undefined,
pinnedOnly:
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);
const { data, ...rest } = useFetchLinks(queryString);
const links = useMemo(() => {
return data?.pages.reduce((acc, page) => {
return [...acc, ...page];
}, []);
}, [data]);
return {
links,
data: { ...data, ...rest },
} as {
links: LinkIncludingShortenedCollectionAndTags[];
data: UseInfiniteQueryResult<InfiniteData<any, unknown>, Error>;
};
};
const useFetchLinks = (params: string) => {
return useInfiniteQuery({
queryKey: ["links", { params }],
queryFn: async (params) => {
const response = await fetch(
"/api/v1/public/collections/links?cursor=" +
params.pageParam +
((params.queryKey[1] as any).params
? "&" + (params.queryKey[1] as any).params
: "")
);
const data = await response.json();
return data.response;
},
initialPageParam: 0,
refetchOnWindowFocus: false,
getNextPageParam: (lastPage) => {
if (lastPage.length === 0) {
return undefined;
}
return lastPage.at(-1).id;
},
});
};
const buildQueryString = (params: LinkRequestQuery) => {
return Object.keys(params)
.filter((key) => params[key as keyof LinkRequestQuery] !== undefined)
.map(
(key) =>
`${encodeURIComponent(key)}=${encodeURIComponent(
params[key as keyof LinkRequestQuery] as string
)}`
)
.join("&");
};
export { usePublicLinks };

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

71
hooks/store/tags.tsx Normal file
View File

@@ -0,0 +1,71 @@
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { TagIncludingLinkCount } from "@/types/global";
import { useSession } from "next-auth/react";
const useTags = () => {
const { status } = useSession();
return useQuery({
queryKey: ["tags"],
queryFn: async () => {
const response = await fetch("/api/v1/tags");
if (!response.ok) throw new Error("Failed to fetch tags.");
const data = await response.json();
return data.response;
},
enabled: status === "authenticated",
});
};
const useUpdateTag = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (tag: TagIncludingLinkCount) => {
const response = await fetch(`/api/v1/tags/${tag.id}`, {
body: JSON.stringify(tag),
headers: {
"Content-Type": "application/json",
},
method: "PUT",
});
const data = await response.json();
if (!response.ok) throw new Error(data.response);
return data.response;
},
onSuccess: (data) => {
queryClient.setQueryData(["tags"], (oldData: any) =>
oldData.map((tag: TagIncludingLinkCount) =>
tag.id === data.id ? data : tag
)
);
},
});
};
const useRemoveTag = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (tagId: number) => {
const response = await fetch(`/api/v1/tags/${tagId}`, {
method: "DELETE",
});
const data = await response.json();
if (!response.ok) throw new Error(data.response);
return data.response;
},
onSuccess: (data, variables) => {
queryClient.setQueryData(["tags"], (oldData: any) =>
oldData.filter((tag: TagIncludingLinkCount) => tag.id !== variables)
);
},
});
};
export { useTags, useUpdateTag, useRemoveTag };

68
hooks/store/tokens.tsx Normal file
View File

@@ -0,0 +1,68 @@
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { AccessToken } from "@prisma/client";
import { useSession } from "next-auth/react";
const useTokens = () => {
const { status } = useSession();
return useQuery({
queryKey: ["tokens"],
queryFn: async () => {
const response = await fetch("/api/v1/tokens");
if (!response.ok) throw new Error("Failed to fetch tokens.");
const data = await response.json();
return data.response as AccessToken[];
},
enabled: status === "authenticated",
});
};
const useAddToken = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (body: Partial<AccessToken>) => {
const response = await fetch("/api/v1/tokens", {
body: JSON.stringify(body),
method: "POST",
});
const data = await response.json();
if (!response.ok) throw new Error(data.response);
return data.response;
},
onSuccess: (data) => {
queryClient.setQueryData(["tokens"], (oldData: AccessToken[]) => [
...oldData,
data.token,
]);
},
});
};
const useRevokeToken = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (tokenId: number) => {
const response = await fetch(`/api/v1/tokens/${tokenId}`, {
method: "DELETE",
});
const data = await response.json();
if (!response.ok) throw new Error(data.response);
return data.response;
},
onSuccess: (data, variables) => {
queryClient.setQueryData(["tokens"], (oldData: AccessToken[]) =>
oldData.filter((token: Partial<AccessToken>) => token.id !== variables)
);
},
});
};
export { useTokens, useAddToken, useRevokeToken };

53
hooks/store/user.tsx Normal file
View File

@@ -0,0 +1,53 @@
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { useSession } from "next-auth/react";
const useUser = () => {
const { data, status } = useSession();
const userId = data?.user.id;
return useQuery({
queryKey: ["user"],
queryFn: async () => {
const response = await fetch(`/api/v1/users/${userId}`);
if (!response.ok) throw new Error("Failed to fetch user data.");
const data = await response.json();
return data.response;
},
enabled: !!userId && status === "authenticated",
placeholderData: {},
});
};
const useUpdateUser = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (user: any) => {
const response = await fetch(`/api/v1/users/${user.id}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(user),
});
const data = await response.json();
if (!response.ok) throw new Error(data.response);
return data;
},
onSuccess: (data) => {
queryClient.setQueryData(["user"], data.response);
},
onMutate: async (user) => {
await queryClient.cancelQueries({ queryKey: ["user"] });
queryClient.setQueryData(["user"], (oldData: any) => {
return { ...oldData, ...user };
});
},
});
};
export { useUser, useUpdateUser };

View File

@@ -1,12 +1,12 @@
import useAccountStore from "@/store/account";
import useCollectionStore from "@/store/collections";
import { Member } from "@/types/global";
import { useEffect, useState } from "react";
import { useCollections } from "./store/collections";
import { useUser } from "./store/user";
export default function useCollectivePermissions(collectionIds: number[]) {
const { collections } = useCollectionStore();
const { data: collections = [] } = useCollections();
const { account } = useAccountStore();
const { data: user = {} } = useUser();
const [permissions, setPermissions] = useState<Member | true>();
useEffect(() => {
@@ -15,7 +15,7 @@ export default function useCollectivePermissions(collectionIds: number[]) {
if (collection) {
let getPermission: Member | undefined = collection.members.find(
(e) => e.userId === account.id
(e) => e.userId === user.id
);
if (
@@ -25,10 +25,10 @@ export default function useCollectivePermissions(collectionIds: number[]) {
)
getPermission = undefined;
setPermissions(account.id === collection.ownerId || getPermission);
setPermissions(user.id === collection.ownerId || getPermission);
}
}
}, [account, collections, collectionIds]);
}, [user, collections, collectionIds]);
return permissions;
}

View File

@@ -1,34 +1,14 @@
import useCollectionStore from "@/store/collections";
import { useEffect } from "react";
import { useSession } from "next-auth/react";
import useTagStore from "@/store/tags";
import useAccountStore from "@/store/account";
import useLocalSettingsStore from "@/store/localSettings";
export default function useInitialData() {
const { status, data } = useSession();
const { setCollections } = useCollectionStore();
const { setTags } = useTagStore();
// const { setLinks } = useLinkStore();
const { account, setAccount } = useAccountStore();
const { setSettings } = useLocalSettingsStore();
useEffect(() => {
setSettings();
if (status === "authenticated") {
// Get account info
setAccount(data?.user.id as number);
}
}, [status, data]);
// Get the rest of the data
useEffect(() => {
if (account.id && (!process.env.NEXT_PUBLIC_STRIPE || account.username)) {
setCollections();
setTags();
// setLinks();
}
}, [account]);
return status;
}

View File

@@ -1,103 +0,0 @@
import { LinkRequestQuery } from "@/types/global";
import { useEffect, useState } from "react";
import useDetectPageBottom from "./useDetectPageBottom";
import { useRouter } from "next/router";
import useLinkStore from "@/store/links";
export default function useLinks(
{
sort,
collectionId,
tagId,
pinnedOnly,
searchQueryString,
searchByName,
searchByUrl,
searchByDescription,
searchByTags,
searchByTextContent,
}: LinkRequestQuery = { sort: 0 }
) {
const { links, setLinks, resetLinks, selectedLinks, setSelectedLinks } =
useLinkStore();
const router = useRouter();
const [isLoading, setIsLoading] = useState(true);
const { reachedBottom, setReachedBottom } = useDetectPageBottom();
const getLinks = async (isInitialCall: boolean, cursor?: number) => {
const params = {
sort,
cursor,
collectionId,
tagId,
pinnedOnly,
searchQueryString,
searchByName,
searchByUrl,
searchByDescription,
searchByTags,
searchByTextContent,
};
const buildQueryString = (params: LinkRequestQuery) => {
return Object.keys(params)
.filter((key) => params[key as keyof LinkRequestQuery] !== undefined)
.map(
(key) =>
`${encodeURIComponent(key)}=${encodeURIComponent(
params[key as keyof LinkRequestQuery] as string
)}`
)
.join("&");
};
let queryString = buildQueryString(params);
let basePath;
if (router.pathname === "/dashboard") basePath = "/api/v1/dashboard";
else if (router.pathname.startsWith("/public/collections/[id]")) {
queryString = queryString + "&collectionId=" + router.query.id;
basePath = "/api/v1/public/collections/links";
} else basePath = "/api/v1/links";
setIsLoading(true);
const response = await fetch(`${basePath}?${queryString}`);
const data = await response.json();
setIsLoading(false);
if (response.ok) setLinks(data.response, isInitialCall);
};
useEffect(() => {
// Save the selected links before resetting the links
// and then restore the selected links after resetting the links
const previouslySelected = selectedLinks;
resetLinks();
setSelectedLinks(previouslySelected);
getLinks(true);
}, [
router,
sort,
searchQueryString,
searchByName,
searchByUrl,
searchByDescription,
searchByTextContent,
searchByTags,
]);
useEffect(() => {
if (reachedBottom) getLinks(false, links?.at(-1)?.id);
setReachedBottom(false);
}, [reachedBottom]);
return { isLoading };
}

View File

@@ -1,12 +1,12 @@
import useAccountStore from "@/store/account";
import useCollectionStore from "@/store/collections";
import { Member } from "@/types/global";
import { useEffect, useState } from "react";
import { useCollections } from "./store/collections";
import { useUser } from "./store/user";
export default function usePermissions(collectionId: number) {
const { collections } = useCollectionStore();
const { data: collections = [] } = useCollections();
const { account } = useAccountStore();
const { data: user = {} } = useUser();
const [permissions, setPermissions] = useState<Member | true>();
useEffect(() => {
@@ -14,7 +14,7 @@ export default function usePermissions(collectionId: number) {
if (collection) {
let getPermission: Member | undefined = collection.members.find(
(e) => e.userId === account.id
(e) => e.userId === user.id
);
if (
@@ -24,9 +24,9 @@ export default function usePermissions(collectionId: number) {
)
getPermission = undefined;
setPermissions(account.id === collection.ownerId || getPermission);
setPermissions(user.id === collection.ownerId || getPermission);
}
}, [account, collections, collectionId]);
}, [user, collections, collectionId]);
return permissions;
}

View File

@@ -2,7 +2,7 @@ import { ReactNode, useEffect, useState } from "react";
import { useRouter } from "next/router";
import { useSession } from "next-auth/react";
import useInitialData from "@/hooks/useInitialData";
import useAccountStore from "@/store/account";
import { useUser } from "@/hooks/store/user";
interface Props {
children: ReactNode;
@@ -14,7 +14,7 @@ export default function AuthRedirect({ children }: Props) {
const router = useRouter();
const { status } = useSession();
const [shouldRenderChildren, setShouldRenderChildren] = useState(false);
const { account } = useAccountStore();
const { data: user = {} } = useUser();
useInitialData();
@@ -23,7 +23,7 @@ export default function AuthRedirect({ children }: Props) {
const isUnauthenticated = status === "unauthenticated";
const isPublicPage = router.pathname.startsWith("/public");
const hasInactiveSubscription =
account.id && !account.subscription?.active && stripeEnabled;
user.id && !user.subscription?.active && stripeEnabled;
// There are better ways of doing this... but this one works for now
const routes = [
@@ -63,7 +63,7 @@ export default function AuthRedirect({ children }: Props) {
setShouldRenderChildren(true);
}
}
}, [status, account, router.pathname]);
}, [status, user, router.pathname]);
function redirectTo(destination: string) {
router.push(destination).then(() => setShouldRenderChildren(true));

View File

@@ -43,6 +43,9 @@ export default async function archiveHandler(link: LinksAndCollectionAndOwner) {
password: process.env.PROXY_PASSWORD,
};
}
if (process.env.PLAYWRIGHT_LAUNCH_OPTIONS_EXECUTABLE_PATH) {
browserOptions.executablePath = process.env.PLAYWRIGHT_LAUNCH_OPTIONS_EXECUTABLE_PATH;
}
const browser = await chromium.launch(browserOptions);
const context = await browser.newContext({

View File

@@ -57,8 +57,8 @@ export default async function deleteCollection(
},
});
await removeFolder({ filePath: `archives/${collectionId}` });
await removeFolder({ filePath: `archives/preview/${collectionId}` });
removeFolder({ filePath: `archives/${collectionId}` });
removeFolder({ filePath: `archives/preview/${collectionId}` });
await removeFromOrders(userId, collectionId);
@@ -100,8 +100,8 @@ async function deleteSubCollections(collectionId: number) {
where: { id: subCollection.id },
});
await removeFolder({ filePath: `archives/${subCollection.id}` });
await removeFolder({ filePath: `archives/preview/${subCollection.id}` });
removeFolder({ filePath: `archives/${subCollection.id}` });
removeFolder({ filePath: `archives/preview/${subCollection.id}` });
}
}

View File

@@ -5,7 +5,7 @@ export default async function getDashboardData(
userId: number,
query: LinkRequestQuery
) {
let order: any;
let order: any = { id: "desc" };
if (query.sort === Sort.DateNewestFirst) order = { id: "desc" };
else if (query.sort === Sort.DateOldestFirst) order = { id: "asc" };
else if (query.sort === Sort.NameAZ) order = { name: "asc" };
@@ -42,7 +42,7 @@ export default async function getDashboardData(
select: { id: true },
},
},
orderBy: order || { id: "desc" },
orderBy: order,
});
const recentlyAddedLinks = await prisma.link.findMany({
@@ -67,10 +67,18 @@ export default async function getDashboardData(
select: { id: true },
},
},
orderBy: order || { id: "desc" },
orderBy: order,
});
const links = [...recentlyAddedLinks, ...pinnedLinks].sort(
const combinedLinks = [...recentlyAddedLinks, ...pinnedLinks];
const uniqueLinks = Array.from(
combinedLinks
.reduce((map, item) => map.set(item.id, item), new Map())
.values()
);
const links = uniqueLinks.sort(
(a, b) => (new Date(b.id) as any) - (new Date(a.id) as any)
);

View File

@@ -5,7 +5,7 @@ export default async function getLink(userId: number, query: LinkRequestQuery) {
const POSTGRES_IS_ENABLED =
process.env.DATABASE_URL?.startsWith("postgresql");
let order: any;
let order: any = { id: "desc" };
if (query.sort === Sort.DateNewestFirst) order = { id: "desc" };
else if (query.sort === Sort.DateOldestFirst) order = { id: "asc" };
else if (query.sort === Sort.NameAZ) order = { name: "asc" };
@@ -146,7 +146,7 @@ export default async function getLink(userId: number, query: LinkRequestQuery) {
select: { id: true },
},
},
orderBy: order || { id: "desc" },
orderBy: order,
});
return { response: links, status: 200 };

View File

@@ -86,9 +86,11 @@ export default async function postLink(
else if (contentType === "image/png") imageExtension = "png";
}
if (!link.tags) link.tags = [];
const newLink = await prisma.link.create({
data: {
url: link.url?.trim().replace(/\/+$/, "") || null,
url: link.url?.trim() || null,
name,
description: link.description,
type: linkType,
@@ -98,7 +100,7 @@ export default async function postLink(
},
},
tags: {
connectOrCreate: link.tags.map((tag) => ({
connectOrCreate: link.tags?.map((tag) => ({
where: {
name_ownerId: {
name: tag.name.trim(),

View File

@@ -63,7 +63,8 @@ async function processBookmarks(
) as Element;
if (collectionName) {
const collectionNameContent = (collectionName.children[0] as TextNode)?.content;
const collectionNameContent = (collectionName.children[0] as TextNode)
?.content;
if (collectionNameContent) {
collectionId = await createCollection(
userId,
@@ -274,4 +275,3 @@ function processNodes(nodes: Node[]) {
nodes.forEach(findAndProcessDL);
return nodes;
}

View File

@@ -1,46 +1,68 @@
import { prisma } from "@/lib/api/db";
export default async function getTags(userId: number) {
// Remove empty tags
await prisma.tag.deleteMany({
where: {
ownerId: userId,
links: {
none: {},
export default async function getTags({
userId,
collectionId,
}: {
userId?: number;
collectionId?: number;
}) {
if (userId) {
// Remove empty tags
await prisma.tag.deleteMany({
where: {
ownerId: userId,
links: {
none: {},
},
},
},
});
});
const tags = await prisma.tag.findMany({
where: {
OR: [
{ ownerId: userId }, // Tags owned by the user
{
links: {
some: {
collection: {
members: {
some: {
userId, // Tags from collections where the user is a member
const tags = await prisma.tag.findMany({
where: {
OR: [
{ ownerId: userId }, // Tags owned by the user
{
links: {
some: {
collection: {
members: {
some: {
userId, // Tags from collections where the user is a member
},
},
},
},
},
},
},
],
},
include: {
_count: {
select: { links: true },
],
},
},
// orderBy: {
// links: {
// _count: "desc",
// },
// },
});
include: {
_count: {
select: { links: true },
},
},
// orderBy: {
// links: {
// _count: "desc",
// },
// },
});
return { response: tags, status: 200 };
return { response: tags, status: 200 };
} else if (collectionId) {
const tags = await prisma.tag.findMany({
where: {
links: {
some: {
collection: {
id: collectionId,
},
},
},
},
});
return { response: tags, status: 200 };
}
}

View File

@@ -1,7 +1,9 @@
import { prisma } from "@/lib/api/db";
import type { NextApiRequest, NextApiResponse } from "next";
import bcrypt from "bcrypt";
import isServerAdmin from "../../isServerAdmin";
import { PostUserSchema } from "@/lib/shared/schemaValidation";
import isAuthenticatedRequest from "../../isAuthenticatedRequest";
import { Subscription, User } from "@prisma/client";
const emailEnabled =
process.env.EMAIL_FROM && process.env.EMAIL_SERVER ? true : false;
@@ -12,60 +14,59 @@ interface Data {
status: number;
}
interface User {
name: string;
username?: string;
email?: string;
password: string;
}
export default async function postUser(
req: NextApiRequest,
res: NextApiResponse
): Promise<Data> {
let isAdmin = await isServerAdmin({ req });
const parentUser = await isAuthenticatedRequest({ req });
const isAdmin =
parentUser && parentUser.id === Number(process.env.NEXT_PUBLIC_ADMIN || 1);
if (process.env.NEXT_PUBLIC_DISABLE_REGISTRATION === "true" && !isAdmin) {
return { response: "Registration is disabled.", status: 400 };
}
const body: User = req.body;
const dataValidation = PostUserSchema().safeParse(req.body);
const checkHasEmptyFields = emailEnabled
? !body.password || !body.name || !body.email
: !body.username || !body.password || !body.name;
if (!body.password || body.password.length < 8)
return { response: "Password must be at least 8 characters.", status: 400 };
if (checkHasEmptyFields)
return { response: "Please fill out all the fields.", status: 400 };
// Check email (if enabled)
const checkEmail =
/^(([^<>()[\]\.,;:\s@\"]+(\.[^<>()[\]\.,;:\s@\"]+)*)|(\".+\"))@(([^<>()[\]\.,;:\s@\"]+\.)+[^<>()[\]\.,;:\s@\"]{2,})$/i;
if (emailEnabled && !checkEmail.test(body.email?.toLowerCase() || ""))
return { response: "Please enter a valid email.", status: 400 };
// Check username (if email was disabled)
const checkUsername = RegExp("^[a-z0-9_-]{3,31}$");
if (!emailEnabled && !checkUsername.test(body.username?.toLowerCase() || ""))
if (!dataValidation.success) {
return {
response:
"Username has to be between 3-30 characters, no spaces and special characters are allowed.",
response: `Error: ${
dataValidation.error.issues[0].message
} [${dataValidation.error.issues[0].path.join(", ")}]`,
status: 400,
};
}
const { name, email, password, invite } = dataValidation.data;
let { username } = dataValidation.data;
if (invite && (!stripeEnabled || !emailEnabled)) {
return { response: "You are not authorized to invite users.", status: 401 };
} else if (invite && !parentUser) {
return { response: "You must be logged in to invite users.", status: 401 };
}
const autoGeneratedUsername = "user" + Math.round(Math.random() * 1000000000);
if (!username) {
username = autoGeneratedUsername;
}
if (!emailEnabled && !password) {
return {
response: "Password is required.",
status: 400,
};
}
const checkIfUserExists = await prisma.user.findFirst({
where: {
OR: [
{
email: body.email ? body.email.toLowerCase().trim() : undefined,
email: email ? email.toLowerCase().trim() : undefined,
},
{
username: body.username
? body.username.toLowerCase().trim()
: undefined,
username: username ? username.toLowerCase().trim() : undefined,
},
],
},
@@ -77,64 +78,57 @@ export default async function postUser(
const saltRounds = 10;
const hashedPassword = bcrypt.hashSync(body.password, saltRounds);
const hashedPassword = bcrypt.hashSync(password || "", saltRounds);
// Subscription dates
const currentPeriodStart = new Date();
const currentPeriodEnd = new Date();
currentPeriodEnd.setFullYear(currentPeriodEnd.getFullYear() + 1000); // end date is in 1000 years...
if (isAdmin) {
const user = await prisma.user.create({
data: {
name: body.name,
username: emailEnabled
? autoGeneratedUsername
: (body.username as string).toLowerCase().trim(),
email: emailEnabled ? body.email?.toLowerCase().trim() : undefined,
password: hashedPassword,
emailVerified: new Date(),
subscriptions: stripeEnabled
const user = await prisma.user.create({
data: {
name: name,
username: emailEnabled ? username || autoGeneratedUsername : username,
email: emailEnabled ? email : undefined,
emailVerified: isAdmin ? new Date() : undefined,
password: password ? hashedPassword : undefined,
parentSubscription:
parentUser && invite
? {
connect: {
id: (parentUser.subscriptions as Subscription).id,
},
}
: undefined,
subscriptions:
stripeEnabled && isAdmin
? {
create: {
stripeSubscriptionId:
"fake_sub_" + Math.round(Math.random() * 10000000000000),
active: true,
currentPeriodStart,
currentPeriodEnd,
currentPeriodStart: new Date(),
currentPeriodEnd: new Date(
new Date().setFullYear(new Date().getFullYear() + 1000)
), // 1000 years from now
},
}
: undefined,
},
select: {
id: true,
username: true,
email: true,
emailVerified: true,
subscriptions: {
select: {
active: true,
},
select: isAdmin
? {
id: true,
username: true,
email: true,
emailVerified: true,
password: true,
subscriptions: {
select: {
active: true,
},
},
},
createdAt: true,
},
});
createdAt: true,
}
: undefined,
});
return { response: user, status: 201 };
} else {
await prisma.user.create({
data: {
name: body.name,
username: emailEnabled
? autoGeneratedUsername
: (body.username as string).toLowerCase().trim(),
email: emailEnabled ? body.email?.toLowerCase().trim() : undefined,
password: hashedPassword,
},
});
return { response: "User successfully created.", status: 201 };
}
const { password: pass, ...userWithoutPassword } = user as User;
return { response: userWithoutPassword, status: 201 };
} else {
return { response: "Email or Username already exists.", status: 400 };
}

View File

@@ -4,15 +4,28 @@ import removeFolder from "@/lib/api/storage/removeFolder";
import Stripe from "stripe";
import { DeleteUserBody } from "@/types/global";
import removeFile from "@/lib/api/storage/removeFile";
import updateSeats from "@/lib/api/stripe/updateSeats";
export default async function deleteUserById(
userId: number,
body: DeleteUserBody,
isServerAdmin?: boolean
isServerAdmin: boolean,
queryId: number
) {
// First, we retrieve the user from the database
const user = await prisma.user.findUnique({
where: { id: userId },
include: {
subscriptions: {
include: {
user: true,
},
},
parentSubscription: {
include: {
user: true,
},
},
},
});
if (!user) {
@@ -23,24 +36,70 @@ export default async function deleteUserById(
}
if (!isServerAdmin) {
if (user.password) {
const isPasswordValid = bcrypt.compareSync(
body.password,
user.password as string
);
if (queryId === userId) {
if (user.password) {
const isPasswordValid = bcrypt.compareSync(
body.password,
user.password
);
if (!isPasswordValid && !isServerAdmin) {
if (!isPasswordValid && !isServerAdmin) {
return {
response: "Invalid credentials.",
status: 401,
};
}
} else {
return {
response: "Invalid credentials.",
status: 401, // Unauthorized
response:
"User has no password. Please reset your password from the forgot password page.",
status: 401,
};
}
} else {
return {
response:
"User has no password. Please reset your password from the forgot password page.",
status: 401, // Unauthorized
};
if (user.parentSubscriptionId) {
return {
response: "Permission denied.",
status: 401,
};
} else {
if (!user.subscriptions) {
return {
response: "User has no subscription.",
status: 401,
};
}
const findChild = await prisma.user.findFirst({
where: { id: queryId, parentSubscriptionId: user.subscriptions?.id },
});
if (!findChild)
return {
response: "Permission denied.",
status: 401,
};
const removeUser = await prisma.user.update({
where: { id: findChild.id },
data: {
parentSubscription: {
disconnect: true,
},
},
});
if (removeUser.emailVerified)
await updateSeats(
user.subscriptions.stripeSubscriptionId,
user.subscriptions.quantity - 1
);
return {
response: "Account removed from subscription.",
status: 200,
};
}
}
}
@@ -50,27 +109,27 @@ export default async function deleteUserById(
async (prisma) => {
// Delete Access Tokens
await prisma.accessToken.deleteMany({
where: { userId },
where: { userId: queryId },
});
// Delete whitelisted users
await prisma.whitelistedUser.deleteMany({
where: { userId },
where: { userId: queryId },
});
// Delete links
await prisma.link.deleteMany({
where: { collection: { ownerId: userId } },
where: { collection: { ownerId: queryId } },
});
// Delete tags
await prisma.tag.deleteMany({
where: { ownerId: userId },
where: { ownerId: queryId },
});
// Find collections that the user owns
const collections = await prisma.collection.findMany({
where: { ownerId: userId },
where: { ownerId: queryId },
});
for (const collection of collections) {
@@ -89,29 +148,29 @@ export default async function deleteUserById(
// Delete collections after cleaning up related data
await prisma.collection.deleteMany({
where: { ownerId: userId },
where: { ownerId: queryId },
});
// Delete subscription
if (process.env.STRIPE_SECRET_KEY)
await prisma.subscription
.delete({
where: { userId },
where: { userId: queryId },
})
.catch((err) => console.log(err));
await prisma.usersAndCollections.deleteMany({
where: {
OR: [{ userId: userId }, { collection: { ownerId: userId } }],
OR: [{ userId: queryId }, { collection: { ownerId: queryId } }],
},
});
// Delete user's avatar
await removeFile({ filePath: `uploads/avatar/${userId}.jpg` });
await removeFile({ filePath: `uploads/avatar/${queryId}.jpg` });
// Finally, delete the user
await prisma.user.delete({
where: { id: userId },
where: { id: queryId },
});
},
{ timeout: 20000 }
@@ -124,24 +183,36 @@ export default async function deleteUserById(
});
try {
const listByEmail = await stripe.customers.list({
email: user.email?.toLowerCase(),
expand: ["data.subscriptions"],
});
if (user.subscriptions?.id) {
const listByEmail = await stripe.customers.list({
email: user.email?.toLowerCase(),
expand: ["data.subscriptions"],
});
if (listByEmail.data[0].subscriptions?.data[0].id) {
const deleted = await stripe.subscriptions.cancel(
listByEmail.data[0].subscriptions?.data[0].id,
{
cancellation_details: {
comment: body.cancellation_details?.comment,
feedback: body.cancellation_details?.feedback,
},
}
if (listByEmail.data[0].subscriptions?.data[0].id) {
const deleted = await stripe.subscriptions.cancel(
listByEmail.data[0].subscriptions?.data[0].id,
{
cancellation_details: {
comment: body.cancellation_details?.comment,
feedback: body.cancellation_details?.feedback,
},
}
);
return {
response: deleted,
status: 200,
};
}
} else if (user.parentSubscription?.id && user && user.emailVerified) {
await updateSeats(
user.parentSubscription.stripeSubscriptionId,
user.parentSubscription.quantity - 1
);
return {
response: deleted,
response: "User account and all related data deleted successfully.",
status: 200,
};
}

View File

@@ -212,6 +212,8 @@ export default async function updateUserById(
archiveAsWaybackMachine: data.archiveAsWaybackMachine,
linksRouteTo: data.linksRouteTo,
preventDuplicateLinks: data.preventDuplicateLinks,
referredBy:
!user?.referredBy && data.referredBy ? data.referredBy : undefined,
password:
data.newPassword && data.newPassword !== ""
? newHashedPassword

View File

@@ -24,7 +24,12 @@ const generatePreview = async (
1024 * 1024 * Number(process.env.PREVIEW_MAX_BUFFER || 0.1)
) {
console.log("Error generating preview: Buffer size exceeded");
return;
return prisma.link.update({
where: { id: linkId },
data: {
preview: "unavailable",
},
});
}
await createFile({

View File

@@ -39,7 +39,7 @@ const handleMonolith = async (link: Link, content: string) => {
});
});
} catch (err) {
console.log("Error running MONOLITH:", err);
console.log("Uncaught Monolith error...");
}
};

View File

@@ -1,17 +1,10 @@
import { CollectionIncludingMembersAndLinkCount } from "@/types/global";
import { Dispatch, SetStateAction } from "react";
const getPublicCollectionData = async (
collectionId: number,
setData: Dispatch<
SetStateAction<CollectionIncludingMembersAndLinkCount | undefined>
>
) => {
const getPublicCollectionData = async (collectionId: number) => {
const res = await fetch("/api/v1/public/collections/" + collectionId);
const data = await res.json();
if (res.status === 400)
return { response: "Collection not found.", status: 400 };
setData(data.response);
const data = await res.json();
return data;
};

View File

@@ -0,0 +1,211 @@
import { ArchivedFormat, TokenExpiry } from "@/types/global";
import { LinksRouteTo } from "@prisma/client";
import { z } from "zod";
// const stringField = z.string({
// errorMap: (e) => ({
// message: `Invalid ${e.path}.`,
// }),
// });
export const ForgotPasswordSchema = z.object({
email: z.string().email(),
});
export const ResetPasswordSchema = z.object({
token: z.string(),
password: z.string().min(8),
});
export const VerifyEmailSchema = z.object({
token: z.string(),
});
export const PostTokenSchema = z.object({
name: z.string().max(50),
expires: z.nativeEnum(TokenExpiry),
});
export type PostTokenSchemaType = z.infer<typeof PostTokenSchema>;
export const PostUserSchema = () => {
const emailEnabled =
process.env.EMAIL_FROM && process.env.EMAIL_SERVER ? true : false;
return z.object({
name: z.string().trim().min(1).max(50).optional(),
password: z.string().min(8).max(2048).optional(),
email: emailEnabled
? z.string().trim().email().toLowerCase()
: z.string().optional(),
username: emailEnabled
? z.string().optional()
: z
.string()
.trim()
.toLowerCase()
.min(3)
.max(50)
.regex(/^[a-z0-9_-]{3,50}$/),
invite: z.boolean().optional(),
});
};
export const UpdateUserSchema = () => {
const emailEnabled =
process.env.EMAIL_FROM && process.env.EMAIL_SERVER ? true : false;
return z.object({
name: z.string().trim().min(1).max(50).optional(),
email: emailEnabled
? z.string().trim().email().toLowerCase()
: z.string().optional(),
username: z
.string()
.trim()
.toLowerCase()
.min(3)
.max(30)
.regex(/^[a-z0-9_-]{3,30}$/),
image: z.string().nullish(),
password: z.string().min(8).max(2048).optional(),
newPassword: z.string().min(8).max(2048).optional(),
oldPassword: z.string().min(8).max(2048).optional(),
archiveAsScreenshot: z.boolean().optional(),
archiveAsPDF: z.boolean().optional(),
archiveAsMonolith: z.boolean().optional(),
archiveAsWaybackMachine: z.boolean().optional(),
locale: z.string().max(20).optional(),
isPrivate: z.boolean().optional(),
preventDuplicateLinks: z.boolean().optional(),
collectionOrder: z.array(z.number()).optional(),
linksRouteTo: z.nativeEnum(LinksRouteTo).optional(),
whitelistedUsers: z.array(z.string().max(50)).optional(),
referredBy: z.string().max(100).nullish(),
});
};
export const PostSessionSchema = z.object({
username: z.string().min(3).max(50),
password: z.string().min(8),
sessionName: z.string().trim().max(50).optional(),
});
export const PostLinkSchema = z.object({
type: z.enum(["url", "pdf", "image"]),
url: z.string().trim().max(2048).url().optional(),
name: z.string().trim().max(2048).optional(),
description: z.string().trim().max(2048).optional(),
collection: z
.object({
id: z.number().optional(),
name: z.string().trim().max(2048).optional(),
})
.optional(),
tags:
z
.array(
z.object({
id: z.number().optional(),
name: z.string().trim().max(50),
})
)
.optional() || [],
});
export type PostLinkSchemaType = z.infer<typeof PostLinkSchema>;
export const UpdateLinkSchema = z.object({
id: z.number(),
name: z.string().trim().max(2048).optional(),
url: z.string().trim().max(2048).optional(),
description: z.string().trim().max(2048).optional(),
icon: z.string().trim().max(50).nullish(),
iconWeight: z.string().trim().max(50).nullish(),
color: z.string().trim().max(10).nullish(),
collection: z.object({
id: z.number(),
ownerId: z.number(),
}),
tags: z.array(
z.object({
id: z.number().optional(),
name: z.string().trim().max(50),
})
),
pinnedBy: z
.array(
z
.object({
id: z.number().optional(),
})
.optional()
)
.optional(),
});
export type UpdateLinkSchemaType = z.infer<typeof UpdateLinkSchema>;
const ACCEPTED_TYPES = [
"image/jpeg",
"image/jpg",
"image/png",
"application/pdf",
];
const NEXT_PUBLIC_MAX_FILE_BUFFER = Number(
process.env.NEXT_PUBLIC_MAX_FILE_BUFFER || 10
);
const MAX_FILE_SIZE = NEXT_PUBLIC_MAX_FILE_BUFFER * 1024 * 1024;
export const UploadFileSchema = z.object({
file: z
.any()
.refine((files) => files?.length == 1, "File is required.")
.refine(
(files) => files?.[0]?.size <= MAX_FILE_SIZE,
`Max file size is ${MAX_FILE_SIZE}MB.`
)
.refine(
(files) => ACCEPTED_TYPES.includes(files?.[0]?.mimetype),
`Only ${ACCEPTED_TYPES.join(", ")} files are accepted.`
),
id: z.number(),
format: z.nativeEnum(ArchivedFormat),
});
export const PostCollectionSchema = z.object({
name: z.string().trim().max(2048),
description: z.string().trim().max(2048).optional(),
color: z.string().trim().max(10).optional(),
icon: z.string().trim().max(50).optional(),
iconWeight: z.string().trim().max(50).optional(),
parentId: z.number().optional(),
});
export type PostCollectionSchemaType = z.infer<typeof PostCollectionSchema>;
export const UpdateCollectionSchema = z.object({
id: z.number(),
name: z.string().trim().max(2048),
description: z.string().trim().max(2048).optional(),
color: z.string().trim().max(10).optional(),
isPublic: z.boolean().optional(),
icon: z.string().trim().max(50).nullish(),
iconWeight: z.string().trim().max(50).nullish(),
parentId: z.union([z.number(), z.literal("root")]).nullish(),
members: z.array(
z.object({
userId: z.number(),
canCreate: z.boolean(),
canUpdate: z.boolean(),
canDelete: z.boolean(),
})
),
});
export type UpdateCollectionSchemaType = z.infer<typeof UpdateCollectionSchema>;
export const UpdateTagSchema = z.object({
name: z.string().trim().max(50),
});
export type UpdateTagSchemaType = z.infer<typeof UpdateTagSchema>;

View File

@@ -2,7 +2,7 @@
module.exports = {
i18n: {
defaultLocale: "en",
locales: ["en","it"],
locales: ["en", "it", "fr", "zh"],
},
reloadOnPrerender: process.env.NODE_ENV === "development",
};

View File

@@ -1,6 +1,6 @@
{
"name": "linkwarden",
"version": "v2.6.2",
"version": "v2.8.0",
"main": "index.js",
"repository": "https://github.com/linkwarden/linkwarden.git",
"author": "Daniel31X13 <daniel31x13@gmail.com>",
@@ -25,8 +25,12 @@
"@aws-sdk/client-s3": "^3.379.1",
"@headlessui/react": "^1.7.15",
"@mozilla/readability": "^0.4.4",
"@prisma/client": "^4.16.2",
"@phosphor-icons/core": "^2.1.1",
"@phosphor-icons/react": "^2.1.7",
"@prisma/client": "^5.21.1",
"@stripe/stripe-js": "^1.54.1",
"@tanstack/react-query": "^5.51.15",
"@tanstack/react-query-devtools": "^5.51.15",
"@types/crypto-js": "^4.1.1",
"@types/formidable": "^3.4.5",
"@types/node": "^20.10.4",
@@ -48,6 +52,7 @@
"eslint-config-next": "13.4.9",
"formidable": "^3.5.1",
"framer-motion": "^10.16.4",
"fuse.js": "^7.0.0",
"handlebars": "^4.7.8",
"himalaya": "^1.1.0",
"i18next": "^23.11.5",
@@ -67,13 +72,16 @@
"react-hot-toast": "^2.4.1",
"react-i18next": "^14.1.2",
"react-image-file-resizer": "^0.4.8",
"react-intersection-observer": "^9.13.0",
"react-masonry-css": "^1.0.16",
"react-select": "^5.7.4",
"react-spinners": "^0.13.8",
"react-spinners": "^0.14.1",
"react-window": "^1.8.10",
"socks-proxy-agent": "^8.0.2",
"stripe": "^12.13.0",
"tailwind-merge": "^2.3.0",
"vaul": "^0.8.8",
"vaul": "^1.1.1",
"zod": "^3.23.8",
"zustand": "^4.3.8"
},
"devDependencies": {
@@ -82,14 +90,15 @@
"@types/dompurify": "^3.0.4",
"@types/jsdom": "^21.1.3",
"@types/node-fetch": "^2.6.10",
"@types/react-window": "^1.8.8",
"@types/shelljs": "^0.8.15",
"autoprefixer": "^10.4.14",
"daisyui": "^4.4.2",
"nodemon": "^3.0.2",
"postcss": "^8.4.26",
"prettier": "3.1.1",
"prisma": "^4.16.2",
"tailwindcss": "^3.3.3",
"prisma": "^5.21.1",
"tailwindcss": "^3.4.10",
"ts-node": "^10.9.2",
"typescript": "4.9.4"
}

View File

@@ -11,7 +11,16 @@ import { Session } from "next-auth";
import { isPWA } from "@/lib/client/utils";
// import useInitialData from "@/hooks/useInitialData";
import { appWithTranslation } from "next-i18next";
import nextI18nextConfig from "../next-i18next.config";
import { ReactQueryDevtools } from "@tanstack/react-query-devtools";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 1000 * 30,
},
},
});
function App({
Component,
@@ -29,82 +38,76 @@ function App({
}, []);
return (
<SessionProvider
session={pageProps.session}
refetchOnWindowFocus={false}
basePath="/api/v1/auth"
>
<Head>
<title>Linkwarden</title>
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="theme-color" content="#000000" />
<link
rel="apple-touch-icon"
sizes="180x180"
href="/apple-touch-icon.png"
/>
<link
rel="icon"
type="image/png"
sizes="32x32"
href="/favicon-32x32.png"
/>
<link
rel="icon"
type="image/png"
sizes="16x16"
href="/favicon-16x16.png"
/>
<link rel="manifest" href="/site.webmanifest" />
</Head>
<AuthRedirect>
{/* <GetData> */}
<Toaster
position="top-center"
reverseOrder={false}
toastOptions={{
className:
"border border-sky-100 dark:border-neutral-700 dark:bg-neutral-800 dark:text-white",
}}
>
{(t) => (
<ToastBar toast={t}>
{({ icon, message }) => (
<div
className="flex flex-row"
data-testid="toast-message-container"
data-type={t.type}
>
{icon}
<span data-testid="toast-message">{message}</span>
{t.type !== "loading" && (
<button
className="btn btn-xs outline-none btn-circle btn-ghost"
data-testid="close-toast-button"
onClick={() => toast.dismiss(t.id)}
>
<i className="bi bi-x"></i>
</button>
)}
</div>
)}
</ToastBar>
)}
</Toaster>
<Component {...pageProps} />
{/* </GetData> */}
</AuthRedirect>
</SessionProvider>
<QueryClientProvider client={queryClient}>
<SessionProvider
session={pageProps.session}
refetchOnWindowFocus={false}
basePath="/api/v1/auth"
>
<Head>
<title>Linkwarden</title>
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="theme-color" content="#000000" />
<link
rel="apple-touch-icon"
sizes="180x180"
href="/apple-touch-icon.png"
/>
<link
rel="icon"
type="image/png"
sizes="32x32"
href="/favicon-32x32.png"
/>
<link
rel="icon"
type="image/png"
sizes="16x16"
href="/favicon-16x16.png"
/>
<link rel="manifest" href="/site.webmanifest" />
</Head>
<AuthRedirect>
{/* <GetData> */}
<Toaster
position="top-center"
reverseOrder={false}
toastOptions={{
className:
"border border-sky-100 dark:border-neutral-700 dark:bg-neutral-800 dark:text-white",
}}
>
{(t) => (
<ToastBar toast={t}>
{({ icon, message }) => (
<div
className="flex flex-row"
data-testid="toast-message-container"
data-type={t.type}
>
{icon}
<span data-testid="toast-message">{message}</span>
{t.type !== "loading" && (
<button
className="btn btn-xs outline-none btn-circle btn-ghost"
data-testid="close-toast-button"
onClick={() => toast.dismiss(t.id)}
>
<i className="bi bi-x"></i>
</button>
)}
</div>
)}
</ToastBar>
)}
</Toaster>
<Component {...pageProps} />
{/* </GetData> */}
</AuthRedirect>
</SessionProvider>
<ReactQueryDevtools initialIsOpen={false} />
</QueryClientProvider>
);
}
export default appWithTranslation(App);
// function GetData({ children }: { children: React.ReactNode }) {
// const status = useInitialData();
// return typeof window !== "undefined" && status !== "loading" ? (
// children
// ) : (
// <></>
// );
// }

View File

@@ -1,11 +1,11 @@
import NewUserModal from "@/components/ModalContent/NewUserModal";
import useUserStore from "@/store/admin/users";
import { User as U } from "@prisma/client";
import Link from "next/link";
import { useEffect, useState } from "react";
import { useState } from "react";
import { useTranslation } from "next-i18next";
import getServerSideProps from "@/lib/client/getServerSideProps";
import UserListing from "@/components/UserListing";
import { useUsers } from "@/hooks/store/admin/users";
interface User extends U {
subscriptions: {
@@ -21,7 +21,7 @@ type UserModal = {
export default function Admin() {
const { t } = useTranslation();
const { users, setUsers } = useUserStore();
const { data: users = [] } = useUsers();
const [searchQuery, setSearchQuery] = useState("");
const [filteredUsers, setFilteredUsers] = useState<User[]>();
@@ -33,10 +33,6 @@ export default function Admin() {
const [newUserModal, setNewUserModal] = useState(false);
useEffect(() => {
setUsers();
}, []);
return (
<div className="max-w-6xl mx-auto p-5">
<div className="flex sm:flex-row flex-col justify-between gap-2">
@@ -71,7 +67,7 @@ export default function Admin() {
if (users) {
setFilteredUsers(
users.filter((user) =>
users.filter((user: any) =>
JSON.stringify(user)
.toLowerCase()
.includes(e.target.value.toLowerCase())

View File

@@ -1186,10 +1186,42 @@ export default async function auth(req: NextApiRequest, res: NextApiResponse) {
providerAccountId: account?.providerAccountId,
},
});
if (existingUser && newSsoUsersDisabled) {
if (!existingUser && newSsoUsersDisabled) {
return false;
}
// If user is already registered, link the provider
if (user.email && account) {
const findUser = await prisma.user.findFirst({
where: {
email: user.email,
},
include: {
accounts: true,
},
});
if (findUser && findUser.accounts.length === 0) {
await prisma.account.create({
data: {
userId: findUser.id,
type: account.type,
provider: account.provider,
providerAccountId: account.providerAccountId,
id_token: account.id_token,
access_token: account.access_token,
refresh_token: account.refresh_token,
expires_at: account.expires_at,
token_type: account.token_type,
scope: account.scope,
session_state: account.session_state,
},
});
}
}
}
return true;
},
async jwt({ token, trigger, user }) {
@@ -1198,13 +1230,28 @@ export default async function auth(req: NextApiRequest, res: NextApiResponse) {
token.id = user?.id as number;
if (trigger === "signUp") {
const checkIfUserExists = await prisma.user.findUnique({
const userExists = await prisma.user.findUnique({
where: {
id: token.id,
},
include: {
accounts: true,
},
});
if (checkIfUserExists && !checkIfUserExists.username) {
// Verify SSO user email
if (userExists && userExists.accounts.length > 0) {
await prisma.user.update({
where: {
id: userExists.id,
},
data: {
emailVerified: new Date(),
},
});
}
if (userExists && !userExists.username) {
const autoGeneratedUsername =
"user" + Math.round(Math.random() * 1000000000);
@@ -1217,6 +1264,22 @@ export default async function auth(req: NextApiRequest, res: NextApiResponse) {
},
});
}
} else if (trigger === "signIn") {
const user = await prisma.user.findUnique({
where: {
id: token.id,
},
});
if (user && !user.username) {
const autoGeneratedUsername =
"user" + Math.round(Math.random() * 1000000000);
await prisma.user.update({
where: { id: user.id },
data: { username: autoGeneratedUsername },
});
}
}
return token;
@@ -1224,6 +1287,8 @@ export default async function auth(req: NextApiRequest, res: NextApiResponse) {
async session({ session, token }) {
session.user.id = token.id;
console.log("session", session);
if (STRIPE_SECRET_KEY) {
const user = await prisma.user.findUnique({
where: {
@@ -1235,6 +1300,7 @@ export default async function auth(req: NextApiRequest, res: NextApiResponse) {
});
if (user) {
//
const subscribedUser = await verifySubscription(user);
}
}

View File

@@ -0,0 +1,42 @@
import getTags from "@/lib/api/controllers/tags/getTags";
import { prisma } from "@/lib/api/db";
import { LinkRequestQuery } from "@/types/global";
import type { NextApiRequest, NextApiResponse } from "next";
export default async function collections(
req: NextApiRequest,
res: NextApiResponse
) {
if (req.method === "GET") {
// Convert the type of the request query to "LinkRequestQuery"
const convertedData: Omit<LinkRequestQuery, "tagId"> = {
sort: Number(req.query.sort as string),
collectionId: req.query.collectionId
? Number(req.query.collectionId as string)
: undefined,
};
if (!convertedData.collectionId) {
return res
.status(400)
.json({ response: "Please choose a valid collection." });
}
const collection = await prisma.collection.findFirst({
where: {
id: convertedData.collectionId,
isPublic: true,
},
});
if (!collection) {
return res.status(404).json({ response: "Collection not found." });
}
const tags = await getTags({
collectionId: collection.id,
});
return res.status(tags?.status || 500).json({ response: tags?.response });
}
}

View File

@@ -7,7 +7,9 @@ export default async function tags(req: NextApiRequest, res: NextApiResponse) {
if (!user) return;
if (req.method === "GET") {
const tags = await getTags(user.id);
return res.status(tags.status).json({ response: tags.response });
const tags = await getTags({
userId: user.id,
});
return res.status(tags?.status || 500).json({ response: tags?.response });
}
}

View File

@@ -1,5 +1,3 @@
import useCollectionStore from "@/store/collections";
import useLinkStore from "@/store/links";
import {
CollectionIncludingMembersAndLinkCount,
Sort,
@@ -9,23 +7,22 @@ import { useRouter } from "next/router";
import React, { useEffect, useState } from "react";
import MainLayout from "@/layouts/MainLayout";
import ProfilePhoto from "@/components/ProfilePhoto";
import useLinks from "@/hooks/useLinks";
import usePermissions from "@/hooks/usePermissions";
import NoLinksFound from "@/components/NoLinksFound";
import useLocalSettingsStore from "@/store/localSettings";
import useAccountStore from "@/store/account";
import getPublicUserData from "@/lib/client/getPublicUserData";
import EditCollectionModal from "@/components/ModalContent/EditCollectionModal";
import EditCollectionSharingModal from "@/components/ModalContent/EditCollectionSharingModal";
import DeleteCollectionModal from "@/components/ModalContent/DeleteCollectionModal";
import CardView from "@/components/LinkViews/Layouts/CardView";
import ListView from "@/components/LinkViews/Layouts/ListView";
import { dropdownTriggerer } from "@/lib/client/utils";
import NewCollectionModal from "@/components/ModalContent/NewCollectionModal";
import MasonryView from "@/components/LinkViews/Layouts/MasonryView";
import getServerSideProps from "@/lib/client/getServerSideProps";
import { useTranslation } from "next-i18next";
import LinkListOptions from "@/components/LinkListOptions";
import { useCollections } from "@/hooks/store/collections";
import { useUser } from "@/hooks/store/user";
import { useLinks } from "@/hooks/store/links";
import Links from "@/components/LinkViews/Links";
export default function Index() {
const { t } = useTranslation();
@@ -33,25 +30,29 @@ export default function Index() {
const router = useRouter();
const { links } = useLinkStore();
const { collections } = useCollectionStore();
const { data: collections = [] } = useCollections();
const [sortBy, setSortBy] = useState<Sort>(Sort.DateNewestFirst);
const [sortBy, setSortBy] = useState<Sort>(
Number(localStorage.getItem("sortBy")) ?? Sort.DateNewestFirst
);
const { links, data } = useLinks({
sort: sortBy,
collectionId: Number(router.query.id),
});
const [activeCollection, setActiveCollection] =
useState<CollectionIncludingMembersAndLinkCount>();
const permissions = usePermissions(activeCollection?.id as number);
useLinks({ collectionId: Number(router.query.id), sort: sortBy });
useEffect(() => {
setActiveCollection(
collections.find((e) => e.id === Number(router.query.id))
);
}, [router, collections]);
const { account } = useAccountStore();
const { data: user = {} } = useUser();
const [collectionOwner, setCollectionOwner] = useState({
id: null as unknown as number,
@@ -65,20 +66,20 @@ export default function Index() {
useEffect(() => {
const fetchOwner = async () => {
if (activeCollection && activeCollection.ownerId !== account.id) {
if (activeCollection && activeCollection.ownerId !== user.id) {
const owner = await getPublicUserData(
activeCollection.ownerId as number
);
setCollectionOwner(owner);
} else if (activeCollection && activeCollection.ownerId === account.id) {
} else if (activeCollection && activeCollection.ownerId === user.id) {
setCollectionOwner({
id: account.id as number,
name: account.name,
username: account.username as string,
image: account.image as string,
archiveAsScreenshot: account.archiveAsScreenshot as boolean,
archiveAsMonolith: account.archiveAsScreenshot as boolean,
archiveAsPDF: account.archiveAsPDF as boolean,
id: user.id as number,
name: user.name,
username: user.username as string,
image: user.image as string,
archiveAsScreenshot: user.archiveAsScreenshot as boolean,
archiveAsMonolith: user.archiveAsScreenshot as boolean,
archiveAsPDF: user.archiveAsPDF as boolean,
});
}
};
@@ -97,19 +98,10 @@ export default function Index() {
if (editMode) return setEditMode(false);
}, [router]);
const [viewMode, setViewMode] = useState<string>(
localStorage.getItem("viewMode") || ViewMode.Card
const [viewMode, setViewMode] = useState<ViewMode>(
(localStorage.getItem("viewMode") as ViewMode) || ViewMode.Card
);
const linkView = {
[ViewMode.Card]: CardView,
[ViewMode.List]: ListView,
[ViewMode.Masonry]: MasonryView,
};
// @ts-ignore
const LinkComponent = linkView[viewMode];
return (
<MainLayout>
<div
@@ -142,7 +134,22 @@ export default function Index() {
>
<i className="bi-three-dots text-xl" title="More"></i>
</div>
<ul className="dropdown-content z-[30] menu shadow bg-base-200 border border-neutral-content rounded-box w-52 mt-1">
<ul className="dropdown-content z-[30] 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();
for (const link of links) {
if (link.url) window.open(link.url, "_blank");
}
}}
className="whitespace-nowrap"
>
{t("open_all_links")}
</div>
</li>
{permissions === true && (
<li>
<div
@@ -152,6 +159,7 @@ export default function Index() {
(document?.activeElement as HTMLElement)?.blur();
setEditCollectionModal(true);
}}
className="whitespace-nowrap"
>
{t("edit_collection_info")}
</div>
@@ -165,6 +173,7 @@ export default function Index() {
(document?.activeElement as HTMLElement)?.blur();
setEditCollectionSharingModal(true);
}}
className="whitespace-nowrap"
>
{permissions === true
? t("share_and_collaborate")
@@ -180,6 +189,7 @@ export default function Index() {
(document?.activeElement as HTMLElement)?.blur();
setNewCollectionModal(true);
}}
className="whitespace-nowrap"
>
{t("create_subcollection")}
</div>
@@ -193,6 +203,7 @@ export default function Index() {
(document?.activeElement as HTMLElement)?.blur();
setDeleteCollectionModal(true);
}}
className="whitespace-nowrap"
>
{permissions === true
? t("delete_collection")
@@ -323,16 +334,14 @@ export default function Index() {
</p>
</LinkListOptions>
{links.some((e) => e.collectionId === Number(router.query.id)) ? (
<LinkComponent
editMode={editMode}
links={links.filter(
(e) => e.collection.id === activeCollection?.id
)}
/>
) : (
<NoLinksFound />
)}
<Links
editMode={editMode}
links={links}
layout={viewMode}
placeholderCount={1}
useData={data}
/>
{!data.isLoading && links && !links[0] && <NoLinksFound />}
</div>
{activeCollection && (
<>

View File

@@ -1,4 +1,3 @@
import useCollectionStore from "@/store/collections";
import CollectionCard from "@/components/CollectionCard";
import { useState } from "react";
import MainLayout from "@/layouts/MainLayout";
@@ -10,11 +9,14 @@ import NewCollectionModal from "@/components/ModalContent/NewCollectionModal";
import PageHeader from "@/components/PageHeader";
import getServerSideProps from "@/lib/client/getServerSideProps";
import { useTranslation } from "next-i18next";
import { useCollections } from "@/hooks/store/collections";
export default function Collections() {
const { t } = useTranslation();
const { collections } = useCollectionStore();
const [sortBy, setSortBy] = useState<Sort>(Sort.DateNewestFirst);
const { data: collections = [] } = useCollections();
const [sortBy, setSortBy] = useState<Sort>(
Number(localStorage.getItem("sortBy")) ?? Sort.DateNewestFirst
);
const [sortedCollections, setSortedCollections] = useState(collections);
const { data } = useSession();

View File

@@ -43,6 +43,12 @@ export default function EmailConfirmaion() {
<div className="divider my-3"></div>
{router.query.email && typeof router.query.email === "string" && (
<p className="text-center font-bold mb-3 break-all">
{decodeURIComponent(router.query.email)}
</p>
)}
<p>{t("verification_email_sent_desc")}</p>
<div className="mx-auto w-fit mt-3">

View File

@@ -1,36 +1,37 @@
import useLinkStore from "@/store/links";
import useCollectionStore from "@/store/collections";
import useTagStore from "@/store/tags";
import MainLayout from "@/layouts/MainLayout";
import { useEffect, useState } from "react";
import useLinks from "@/hooks/useLinks";
import { useEffect, useMemo, useState } from "react";
import Link from "next/link";
import useWindowDimensions from "@/hooks/useWindowDimensions";
import React from "react";
import { toast } from "react-hot-toast";
import { MigrationFormat, MigrationRequest, ViewMode } from "@/types/global";
import DashboardItem from "@/components/DashboardItem";
import NewLinkModal from "@/components/ModalContent/NewLinkModal";
import PageHeader from "@/components/PageHeader";
import CardView from "@/components/LinkViews/Layouts/CardView";
import ListView from "@/components/LinkViews/Layouts/ListView";
import ViewDropdown from "@/components/ViewDropdown";
import { dropdownTriggerer } from "@/lib/client/utils";
import MasonryView from "@/components/LinkViews/Layouts/MasonryView";
import getServerSideProps from "@/lib/client/getServerSideProps";
import { useTranslation } from "next-i18next";
import { useCollections } from "@/hooks/store/collections";
import { useTags } from "@/hooks/store/tags";
import { useDashboardData } from "@/hooks/store/dashboardData";
import Links from "@/components/LinkViews/Links";
import useLocalSettingsStore from "@/store/localSettings";
import { useUpdateUser, useUser } from "@/hooks/store/user";
import SurveyModal from "@/components/ModalContent/SurveyModal";
export default function Dashboard() {
const { t } = useTranslation();
const { collections } = useCollectionStore();
const { links } = useLinkStore();
const { tags } = useTagStore();
const { data: collections = [] } = useCollections();
const {
data: { links = [], numberOfPinnedLinks } = { links: [] },
...dashboardData
} = useDashboardData();
const { data: tags = [] } = useTags();
const { data: account = [] } = useUser();
const [numberOfLinks, setNumberOfLinks] = useState(0);
const [showLinks, setShowLinks] = useState(3);
useLinks({ pinnedOnly: true, sort: 0 });
const { settings } = useLocalSettingsStore();
useEffect(() => {
setNumberOfLinks(
@@ -42,29 +43,44 @@ export default function Dashboard() {
);
}, [collections]);
const handleNumberOfLinksToShow = () => {
if (window.innerWidth > 1900) {
setShowLinks(10);
} else if (window.innerWidth > 1500) {
setShowLinks(8);
} else if (window.innerWidth > 880) {
setShowLinks(6);
} else if (window.innerWidth > 550) {
setShowLinks(4);
} else setShowLinks(2);
};
const { width } = useWindowDimensions();
useEffect(() => {
handleNumberOfLinksToShow();
}, [width]);
if (
process.env.NEXT_PUBLIC_STRIPE === "true" &&
account &&
account.id &&
account.referredBy === null &&
// if user is using Linkwarden for more than 3 days
new Date().getTime() - new Date(account.createdAt).getTime() >
3 * 24 * 60 * 60 * 1000
) {
setTimeout(() => {
setShowsSurveyModal(true);
}, 1000);
}
}, [account]);
const importBookmarks = async (e: any, format: MigrationFormat) => {
const file: File = e.target.files[0];
const numberOfLinksToShow = useMemo(() => {
if (window.innerWidth > 1900) {
return 10;
} else if (window.innerWidth > 1500) {
return 8;
} else if (window.innerWidth > 880) {
return 6;
} else if (window.innerWidth > 550) {
return 4;
} else {
return 2;
}
}, []);
const importBookmarks = async (
e: React.ChangeEvent<HTMLInputElement>,
format: MigrationFormat
) => {
const file: File | null = e.target.files && e.target.files[0];
if (file) {
var reader = new FileReader();
const reader = new FileReader();
reader.readAsText(file, "UTF-8");
reader.onload = async function (e) {
const load = toast.loading("Importing...");
@@ -76,42 +92,89 @@ export default function Dashboard() {
data: request,
};
const response = await fetch("/api/v1/migration", {
method: "POST",
body: JSON.stringify(body),
});
try {
const response = await fetch("/api/v1/migration", {
method: "POST",
body: JSON.stringify(body),
});
const data = await response.json();
if (!response.ok) {
const errorData = await response.json();
toast.dismiss(load);
toast.dismiss(load);
toast.error(
errorData.response ||
"Failed to import bookmarks. Please try again."
);
return;
}
toast.success("Imported the Bookmarks! Reloading the page...");
await response.json();
toast.dismiss(load);
toast.success("Imported the Bookmarks! Reloading the page...");
setTimeout(() => {
location.reload();
}, 2000);
setTimeout(() => {
location.reload();
}, 2000);
} catch (error) {
console.error("Request failed", error);
toast.dismiss(load);
toast.error(
"An error occurred while importing bookmarks. Please check the logs for more info."
);
}
};
reader.onerror = function (e) {
console.log("Error:", e);
console.log("Error reading file:", e);
toast.error(
"Failed to read the file. Please make sure the file is correct and try again."
);
};
}
};
const [newLinkModal, setNewLinkModal] = useState(false);
const [viewMode, setViewMode] = useState<string>(
localStorage.getItem("viewMode") || ViewMode.Card
const [viewMode, setViewMode] = useState<ViewMode>(
(localStorage.getItem("viewMode") as ViewMode) || ViewMode.Card
);
const linkView = {
[ViewMode.Card]: CardView,
// [ViewMode.Grid]: ,
[ViewMode.List]: ListView,
[ViewMode.Masonry]: MasonryView,
};
const [showSurveyModal, setShowsSurveyModal] = useState(false);
// @ts-ignore
const LinkComponent = linkView[viewMode];
const { data: user } = useUser();
const updateUser = useUpdateUser();
const [submitLoader, setSubmitLoader] = useState(false);
const submitSurvey = async (referer: string, other?: string) => {
if (submitLoader) return;
setSubmitLoader(true);
const load = toast.loading(t("applying"));
await updateUser.mutateAsync(
{
...user,
referredBy: referer === "other" ? "Other: " + other : referer,
},
{
onSettled: (data, error) => {
console.log(data, error);
setSubmitLoader(false);
toast.dismiss(load);
if (error) {
toast.error(error.message);
} else {
toast.success(t("thanks_for_feedback"));
setShowsSurveyModal(false);
}
},
}
);
};
return (
<MainLayout>
@@ -125,32 +188,30 @@ export default function Dashboard() {
<ViewDropdown viewMode={viewMode} setViewMode={setViewMode} />
</div>
<div>
<div className="flex justify-evenly flex-col xl:flex-row xl:items-center gap-2 xl:w-full h-full rounded-2xl p-8 border border-neutral-content bg-base-200">
<DashboardItem
name={numberOfLinks === 1 ? t("link") : t("links")}
value={numberOfLinks}
icon={"bi-link-45deg"}
/>
<div className="xl:flex flex flex-col sm:grid grid-cols-2 gap-3 xl:flex-row xl:justify-evenly xl:w-full h-full">
<DashboardItem
name={numberOfLinks === 1 ? t("link") : t("links")}
value={numberOfLinks}
icon={"bi-link-45deg"}
/>
<div className="divider xl:divider-horizontal"></div>
<DashboardItem
name={collections.length === 1 ? t("collection") : t("collections")}
value={collections.length}
icon={"bi-folder"}
/>
<DashboardItem
name={
collections.length === 1 ? t("collection") : t("collections")
}
value={collections.length}
icon={"bi-folder"}
/>
<DashboardItem
name={tags.length === 1 ? t("tag") : t("tags")}
value={tags.length}
icon={"bi-hash"}
/>
<div className="divider xl:divider-horizontal"></div>
<DashboardItem
name={tags.length === 1 ? t("tag") : t("tags")}
value={tags.length}
icon={"bi-hash"}
/>
</div>
<DashboardItem
name={t("pinned")}
value={numberOfPinnedLinks}
icon={"bi-pin-angle"}
/>
</div>
<div className="flex justify-between items-center">
@@ -171,15 +232,31 @@ export default function Dashboard() {
</div>
<div
style={{ flex: links[0] ? "0 1 auto" : "1 1 auto" }}
style={{
flex: links || dashboardData.isLoading ? "0 1 auto" : "1 1 auto",
}}
className="flex flex-col 2xl:flex-row items-start 2xl:gap-2"
>
{links[0] ? (
{dashboardData.isLoading ? (
<div className="w-full">
<LinkComponent links={links.slice(0, showLinks)} />
<Links
layout={viewMode}
placeholderCount={settings.columns || 1}
useData={dashboardData}
/>
</div>
) : links && links[0] && !dashboardData.isLoading ? (
<div className="w-full">
<Links
links={links.slice(
0,
settings.columns ? settings.columns * 2 : numberOfLinksToShow
)}
layout={viewMode}
/>
</div>
) : (
<div className="sky-shadow flex flex-col justify-center h-full border border-solid border-neutral-content w-full mx-auto p-10 rounded-2xl bg-base-200">
<div className="flex flex-col justify-center h-full border border-solid border-neutral-content w-full mx-auto p-10 rounded-2xl bg-base-200 bg-gradient-to-tr from-neutral-content/70 to-50% to-base-200">
<p className="text-center text-2xl">
{t("view_added_links_here")}
</p>
@@ -211,13 +288,14 @@ export default function Dashboard() {
<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 w-60">
<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
@@ -238,6 +316,7 @@ export default function Dashboard() {
role="button"
htmlFor="import-html-file"
title={t("from_html")}
className="whitespace-nowrap"
>
{t("from_html")}
<input
@@ -258,6 +337,7 @@ export default function Dashboard() {
role="button"
htmlFor="import-wallabag-file"
title={t("from_wallabag")}
className="whitespace-nowrap"
>
{t("from_wallabag")}
<input
@@ -300,18 +380,32 @@ export default function Dashboard() {
style={{ flex: "1 1 auto" }}
className="flex flex-col 2xl:flex-row items-start 2xl:gap-2"
>
{links.some((e) => e.pinnedBy && e.pinnedBy[0]) ? (
{dashboardData.isLoading ? (
<div className="w-full">
<LinkComponent
<Links
layout={viewMode}
placeholderCount={settings.columns || 1}
useData={dashboardData}
/>
</div>
) : links?.some((e: any) => e.pinnedBy && e.pinnedBy[0]) ? (
<div className="w-full">
<Links
links={links
.filter((e) => e.pinnedBy && e.pinnedBy[0])
.slice(0, showLinks)}
.filter((e: any) => e.pinnedBy && e.pinnedBy[0])
.slice(
0,
settings.columns
? settings.columns * 2
: numberOfLinksToShow
)}
layout={viewMode}
/>
</div>
) : (
<div
style={{ flex: "1 1 auto" }}
className="flex flex-col gap-2 justify-center h-full border border-solid border-neutral-content w-full mx-auto p-10 rounded-2xl bg-base-200"
className="flex flex-col gap-2 justify-center h-full border border-solid border-neutral-content w-full mx-auto p-10 rounded-2xl bg-base-200 bg-gradient-to-tr from-neutral-content/70 to-50% to-base-200"
>
<i className="bi-pin mx-auto text-6xl text-primary"></i>
<p className="text-center text-2xl">
@@ -324,9 +418,15 @@ export default function Dashboard() {
)}
</div>
</div>
{newLinkModal ? (
<NewLinkModal onClose={() => setNewLinkModal(false)} />
) : undefined}
{showSurveyModal && (
<SurveyModal
submit={submitSurvey}
onClose={() => {
setShowsSurveyModal(false);
}}
/>
)}
{newLinkModal && <NewLinkModal onClose={() => setNewLinkModal(false)} />}
</MainLayout>
);
}

View File

@@ -1,26 +1,28 @@
import NoLinksFound from "@/components/NoLinksFound";
import useLinks from "@/hooks/useLinks";
import { useLinks } from "@/hooks/store/links";
import MainLayout from "@/layouts/MainLayout";
import useLinkStore from "@/store/links";
import React, { useEffect, useState } from "react";
import PageHeader from "@/components/PageHeader";
import { Sort, ViewMode } from "@/types/global";
import CardView from "@/components/LinkViews/Layouts/CardView";
import ListView from "@/components/LinkViews/Layouts/ListView";
import { useRouter } from "next/router";
import MasonryView from "@/components/LinkViews/Layouts/MasonryView";
import LinkListOptions from "@/components/LinkListOptions";
import getServerSideProps from "@/lib/client/getServerSideProps";
import { useTranslation } from "next-i18next";
import Links from "@/components/LinkViews/Links";
export default function Links() {
export default function Index() {
const { t } = useTranslation();
const { links } = useLinkStore();
const [viewMode, setViewMode] = useState<string>(
localStorage.getItem("viewMode") || ViewMode.Card
const [viewMode, setViewMode] = useState<ViewMode>(
(localStorage.getItem("viewMode") as ViewMode) || ViewMode.Card
);
const [sortBy, setSortBy] = useState<Sort>(Sort.DateNewestFirst);
const [sortBy, setSortBy] = useState<Sort>(
Number(localStorage.getItem("sortBy")) ?? Sort.DateNewestFirst
);
const { links, data } = useLinks({
sort: sortBy,
});
const router = useRouter();
@@ -30,17 +32,6 @@ export default function Links() {
if (editMode) return setEditMode(false);
}, [router]);
useLinks({ sort: sortBy });
const linkView = {
[ViewMode.Card]: CardView,
[ViewMode.List]: ListView,
[ViewMode.Masonry]: MasonryView,
};
// @ts-ignore
const LinkComponent = linkView[viewMode];
return (
<MainLayout>
<div className="p-5 flex flex-col gap-5 w-full h-full">
@@ -60,11 +51,16 @@ export default function Links() {
/>
</LinkListOptions>
{links[0] ? (
<LinkComponent editMode={editMode} links={links} />
) : (
{!data.isLoading && links && !links[0] && (
<NoLinksFound text={t("you_have_not_added_any_links")} />
)}
<Links
editMode={editMode}
links={links}
layout={viewMode}
placeholderCount={1}
useData={data}
/>
</div>
</MainLayout>
);

View File

@@ -1,45 +1,32 @@
import useLinks from "@/hooks/useLinks";
import MainLayout from "@/layouts/MainLayout";
import useLinkStore from "@/store/links";
import React, { useEffect, useState } from "react";
import PageHeader from "@/components/PageHeader";
import { Sort, ViewMode } from "@/types/global";
import CardView from "@/components/LinkViews/Layouts/CardView";
import ListView from "@/components/LinkViews/Layouts/ListView";
import { useRouter } from "next/router";
import MasonryView from "@/components/LinkViews/Layouts/MasonryView";
import { useTranslation } from "next-i18next";
import getServerSideProps from "@/lib/client/getServerSideProps";
import LinkListOptions from "@/components/LinkListOptions";
import { useLinks } from "@/hooks/store/links";
import Links from "@/components/LinkViews/Links";
export default function PinnedLinks() {
const { t } = useTranslation();
const { links } = useLinkStore();
const [viewMode, setViewMode] = useState<string>(
localStorage.getItem("viewMode") || ViewMode.Card
const [viewMode, setViewMode] = useState<ViewMode>(
(localStorage.getItem("viewMode") as ViewMode) || ViewMode.Card
);
const [sortBy, setSortBy] = useState<Sort>(
Number(localStorage.getItem("sortBy")) ?? Sort.DateNewestFirst
);
const [sortBy, setSortBy] = useState<Sort>(Sort.DateNewestFirst);
useLinks({ sort: sortBy, pinnedOnly: true });
const { links, data } = useLinks({
sort: sortBy,
pinnedOnly: true,
});
const router = useRouter();
const [editMode, setEditMode] = useState(false);
useEffect(() => {
if (editMode) return setEditMode(false);
}, [router]);
const linkView = {
[ViewMode.Card]: CardView,
[ViewMode.List]: ListView,
[ViewMode.Masonry]: MasonryView,
};
// @ts-ignore
const LinkComponent = linkView[viewMode];
return (
<MainLayout>
<div className="p-5 flex flex-col gap-5 w-full h-full">
@@ -59,9 +46,7 @@ export default function PinnedLinks() {
/>
</LinkListOptions>
{links.some((e) => e.pinnedBy && e.pinnedBy[0]) ? (
<LinkComponent editMode={editMode} links={links} />
) : (
{!data.isLoading && links && !links[0] && (
<div
style={{ flex: "1 1 auto" }}
className="flex flex-col gap-2 justify-center h-full w-full mx-auto p-10"
@@ -82,6 +67,13 @@ export default function PinnedLinks() {
</p>
</div>
)}
<Links
editMode={editMode}
links={links}
layout={viewMode}
placeholderCount={1}
useData={data}
/>
</div>
</MainLayout>
);

View File

@@ -1,67 +1,70 @@
import React, { useEffect, useState } from "react";
import useLinkStore from "@/store/links";
import React, { useEffect } from "react";
import { useRouter } from "next/router";
import {
ArchivedFormat,
LinkIncludingShortenedCollectionAndTags,
} from "@/types/global";
import { ArchivedFormat } from "@/types/global";
import ReadableView from "@/components/ReadableView";
import getServerSideProps from "@/lib/client/getServerSideProps";
import { useGetLink } from "@/hooks/store/links";
import clsx from "clsx";
export default function Index() {
const { links, getLink } = useLinkStore();
const [link, setLink] = useState<LinkIncludingShortenedCollectionAndTags>();
const getLink = useGetLink();
const router = useRouter();
useEffect(() => {
const fetchLink = async () => {
if (router.query.id) {
await getLink(Number(router.query.id));
}
};
fetchLink();
if (router.query.id) {
getLink.mutateAsync({ id: Number(router.query.id) });
}
}, []);
useEffect(() => {
if (links[0]) setLink(links.find((e) => e.id === Number(router.query.id)));
}, [links]);
return (
<div className="relative">
<div className={clsx(getLink.isPending ? "flex h-screen" : "relative")}>
{/* <div className="fixed left-1/2 transform -translate-x-1/2 w-fit py-1 px-3 bg-base-200 border border-neutral-content rounded-md">
Readable
</div> */}
{link && Number(router.query.format) === ArchivedFormat.readability && (
<ReadableView link={link} />
)}
{link && Number(router.query.format) === ArchivedFormat.monolith && (
{getLink.data?.id &&
Number(router.query.format) === ArchivedFormat.readability ? (
<ReadableView link={getLink.data} />
) : getLink.data?.id &&
Number(router.query.format) === ArchivedFormat.monolith ? (
<iframe
src={`/api/v1/archives/${link.id}?format=${ArchivedFormat.monolith}`}
src={`/api/v1/archives/${getLink.data.id}?format=${ArchivedFormat.monolith}`}
className="w-full h-screen border-none"
></iframe>
)}
{link && Number(router.query.format) === ArchivedFormat.pdf && (
) : getLink.data?.id &&
Number(router.query.format) === ArchivedFormat.pdf ? (
<iframe
src={`/api/v1/archives/${link.id}?format=${ArchivedFormat.pdf}`}
src={`/api/v1/archives/${getLink.data.id}?format=${ArchivedFormat.pdf}`}
className="w-full h-screen border-none"
></iframe>
)}
{link && Number(router.query.format) === ArchivedFormat.png && (
) : getLink.data?.id &&
Number(router.query.format) === ArchivedFormat.png ? (
<img
alt=""
src={`/api/v1/archives/${link.id}?format=${ArchivedFormat.png}`}
src={`/api/v1/archives/${getLink.data.id}?format=${ArchivedFormat.png}`}
className="w-fit mx-auto"
/>
)}
{link && Number(router.query.format) === ArchivedFormat.jpeg && (
) : getLink.data?.id &&
Number(router.query.format) === ArchivedFormat.jpeg ? (
<img
alt=""
src={`/api/v1/archives/${link.id}?format=${ArchivedFormat.jpeg}`}
src={`/api/v1/archives/${getLink.data.id}?format=${ArchivedFormat.jpeg}`}
className="w-fit mx-auto"
/>
) : getLink.error ? (
<p>404 - Not found</p>
) : (
<div className="max-w-3xl p-5 m-auto w-full flex flex-col items-center gap-5">
<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>
)}
</div>
);

View File

@@ -1,6 +1,7 @@
"use client";
import getPublicCollectionData from "@/lib/client/getPublicCollectionData";
import {
AccountSettings,
CollectionIncludingMembersAndLinkCount,
Sort,
ViewMode,
@@ -8,8 +9,6 @@ import {
import { useRouter } from "next/router";
import React, { useEffect, useState } from "react";
import Head from "next/head";
import useLinks from "@/hooks/useLinks";
import useLinkStore from "@/store/links";
import ProfilePhoto from "@/components/ProfilePhoto";
import ToggleDarkMode from "@/components/ToggleDarkMode";
import getPublicUserData from "@/lib/client/getPublicUserData";
@@ -18,33 +17,49 @@ import Link from "next/link";
import useLocalSettingsStore from "@/store/localSettings";
import SearchBar from "@/components/SearchBar";
import EditCollectionSharingModal from "@/components/ModalContent/EditCollectionSharingModal";
import CardView from "@/components/LinkViews/Layouts/CardView";
import ListView from "@/components/LinkViews/Layouts/ListView";
import MasonryView from "@/components/LinkViews/Layouts/MasonryView";
import { useTranslation } from "next-i18next";
import getServerSideProps from "@/lib/client/getServerSideProps";
import useCollectionStore from "@/store/collections";
import LinkListOptions from "@/components/LinkListOptions";
import { usePublicLinks } from "@/hooks/store/publicLinks";
import Links from "@/components/LinkViews/Links";
import { usePublicTags } from "@/hooks/store/publicTags";
export default function PublicCollections() {
const { t } = useTranslation();
const { links } = useLinkStore();
const { settings } = useLocalSettingsStore();
const { collections } = useCollectionStore();
const router = useRouter();
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>
>({});
const handleTagSelection = (tag: string | undefined) => {
if (tag) {
Object.keys(searchFilter).forEach(
(v) =>
(searchFilter[
v as keyof {
name: boolean;
url: boolean;
description: boolean;
tags: boolean;
textContent: boolean;
}
] = false)
);
searchFilter.tags = true;
return router.push(
"/public/collections/" +
router.query.id +
"?q=" +
encodeURIComponent(tag || "")
);
} else {
return router.push("/public/collections/" + router.query.id);
}
};
const [searchFilter, setSearchFilter] = useState({
name: true,
@@ -54,9 +69,13 @@ export default function PublicCollections() {
textContent: false,
});
const [sortBy, setSortBy] = useState<Sort>(Sort.DateNewestFirst);
const [sortBy, setSortBy] = useState<Sort>(
Number(localStorage.getItem("sortBy")) ?? Sort.DateNewestFirst
);
useLinks({
const { data: tags } = usePublicTags();
const { links, data } = usePublicLinks({
sort: sortBy,
searchQueryString: router.query.q
? decodeURIComponent(router.query.q as string)
@@ -67,197 +86,233 @@ export default function PublicCollections() {
searchByTextContent: searchFilter.textContent,
searchByTags: searchFilter.tags,
});
const [collection, setCollection] =
useState<CollectionIncludingMembersAndLinkCount>();
useEffect(() => {
if (router.query.id) {
getPublicCollectionData(Number(router.query.id), setCollection);
getPublicCollectionData(Number(router.query.id)).then((res) => {
if (res.status === 400) {
router.push("/dashboard");
} else {
setCollection(res.response);
}
});
}
}, [collections]);
}, []);
useEffect(() => {
const fetchOwner = async () => {
if (collection) {
const owner = await getPublicUserData(collection.ownerId as number);
setCollectionOwner(owner);
}
};
fetchOwner();
if (collection) {
getPublicUserData(collection.ownerId as number).then((owner) =>
setCollectionOwner(owner)
);
}
}, [collection]);
const [editCollectionSharingModal, setEditCollectionSharingModal] =
useState(false);
const [viewMode, setViewMode] = useState<string>(
localStorage.getItem("viewMode") || ViewMode.Card
const [viewMode, setViewMode] = useState<ViewMode>(
(localStorage.getItem("viewMode") as ViewMode) || ViewMode.Card
);
const linkView = {
[ViewMode.Card]: CardView,
[ViewMode.List]: ListView,
[ViewMode.Masonry]: MasonryView,
};
if (!collection) return <></>;
else
return (
<div
className="h-96"
style={{
backgroundImage: `linear-gradient(${collection?.color}30 10%, ${
settings.theme === "dark" ? "#262626" : "#f3f4f6"
} 13rem, ${settings.theme === "dark" ? "#171717" : "#ffffff"} 100%)`,
}}
>
{collection && (
<Head>
<title>{collection.name} | Linkwarden</title>
<meta
property="og:title"
content={`${collection.name} | Linkwarden`}
key="title"
/>
</Head>
)}
<div className="lg:w-3/4 w-full mx-auto p-5 bg">
<div className="flex items-center justify-between">
<p className="text-4xl font-thin mb-2 capitalize mt-10">
{collection.name}
</p>
<div className="flex gap-2 items-center mt-8 min-w-fit">
<ToggleDarkMode />
// @ts-ignore
const LinkComponent = linkView[viewMode];
return collection ? (
<div
className="h-96"
style={{
backgroundImage: `linear-gradient(${collection?.color}30 10%, ${
settings.theme === "dark" ? "#262626" : "#f3f4f6"
} 13rem, ${settings.theme === "dark" ? "#171717" : "#ffffff"} 100%)`,
}}
>
{collection ? (
<Head>
<title>{collection.name} | Linkwarden</title>
<meta
property="og:title"
content={`${collection.name} | Linkwarden`}
key="title"
/>
</Head>
) : undefined}
<div className="lg:w-3/4 w-full mx-auto p-5 bg">
<div className="flex items-center justify-between">
<p className="text-4xl font-thin mb-2 capitalize mt-10">
{collection.name}
</p>
<div className="flex gap-2 items-center mt-8 min-w-fit">
<ToggleDarkMode />
<Link href="https://linkwarden.app/" target="_blank">
<Image
src={`/icon.png`}
width={551}
height={551}
alt="Linkwarden"
title={t("list_created_with_linkwarden")}
className="h-8 w-fit mx-auto rounded"
/>
</Link>
<Link href="https://linkwarden.app/" target="_blank">
<Image
src={`/icon.png`}
width={551}
height={551}
alt="Linkwarden"
title={t("list_created_with_linkwarden")}
className="h-8 w-fit mx-auto rounded"
/>
</Link>
</div>
</div>
</div>
<div className="mt-3">
<div className={`min-w-[15rem]`}>
<div className="flex gap-1 justify-center sm:justify-end items-center w-fit">
<div
className="flex items-center btn px-2 btn-ghost rounded-full"
onClick={() => setEditCollectionSharingModal(true)}
>
{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) => {
return (
<ProfilePhoto
key={i}
src={e.user.image ? e.user.image : undefined}
className="-ml-3"
name={e.user.name}
/>
);
})
.slice(0, 3)}
{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>
<p className="text-neutral text-sm">
{collection.members.length > 0 &&
collection.members.length === 1
? t("by_author_and_other", {
author: collectionOwner.name,
count: collection.members.length,
<div className="mt-3">
<div className={`min-w-[15rem]`}>
<div className="flex gap-1 justify-center sm:justify-end items-center w-fit">
<div
className="flex items-center btn px-2 btn-ghost rounded-full"
onClick={() => setEditCollectionSharingModal(true)}
>
{collectionOwner.id && (
<ProfilePhoto
src={collectionOwner.image || undefined}
name={collectionOwner.name}
/>
)}
{collection.members
.sort((a, b) => (a.userId as number) - (b.userId as number))
.map((e, i) => {
return (
<ProfilePhoto
key={i}
src={e.user.image ? e.user.image : undefined}
className="-ml-3"
name={e.user.name}
/>
);
})
: collection.members.length > 0 &&
collection.members.length !== 1
? t("by_author_and_others", {
.slice(0, 3)}
{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>
)}
</div>
<p className="text-neutral text-sm">
{collection.members.length > 0 &&
collection.members.length === 1
? t("by_author_and_other", {
author: collectionOwner.name,
count: collection.members.length,
})
: t("by_author", {
author: collectionOwner.name,
})}
</p>
: collection.members.length > 0 &&
collection.members.length !== 1
? t("by_author_and_others", {
author: collectionOwner.name,
count: collection.members.length,
})
: t("by_author", {
author: collectionOwner.name,
})}
</p>
</div>
</div>
</div>
</div>
<p className="mt-5">{collection.description}</p>
<p className="mt-5">{collection.description}</p>
<div className="divider mt-5 mb-0"></div>
<div className="divider mt-5 mb-0"></div>
<div className="flex mb-5 mt-10 flex-col gap-5">
<LinkListOptions
t={t}
viewMode={viewMode}
setViewMode={setViewMode}
sortBy={sortBy}
setSortBy={setSortBy}
searchFilter={searchFilter}
setSearchFilter={setSearchFilter}
>
<SearchBar
placeholder={
collection._count?.links === 1
? t("search_count_link", {
count: collection._count?.links,
})
: t("search_count_links", {
count: collection._count?.links,
})
}
/>
</LinkListOptions>
{links[0] ? (
<LinkComponent
links={links
.filter((e) => e.collectionId === Number(router.query.id))
.map((e, i) => {
<div className="flex mb-5 mt-10 flex-col gap-5">
<LinkListOptions
t={t}
viewMode={viewMode}
setViewMode={setViewMode}
sortBy={sortBy}
setSortBy={setSortBy}
searchFilter={searchFilter}
setSearchFilter={setSearchFilter}
>
<SearchBar
placeholder={
collection._count?.links === 1
? t("search_count_link", {
count: collection._count?.links,
})
: t("search_count_links", {
count: collection._count?.links,
})
}
/>
</LinkListOptions>
{tags && tags[0] && (
<div className="flex gap-2 mt-2 mb-6 flex-wrap">
<button
className="max-w-full"
onClick={() => handleTagSelection(undefined)}
>
<div
className={`${
!router.query.q
? "bg-primary/20"
: "bg-neutral-content/20 hover:bg-neutral/20"
} duration-100 py-1 px-2 cursor-pointer flex items-center gap-2 rounded-md h-8`}
>
<p className="truncate px-3">{t("all_links")}</p>
</div>
</button>
{tags
.map((t) => t.name)
.filter((item, pos, self) => self.indexOf(item) === pos)
.sort((a, b) => a.localeCompare(b))
.map((e, i) => {
const active = router.query.q === e;
return (
<button
className="max-w-full"
key={i}
onClick={() => handleTagSelection(e)}
>
<div
className={`${
active
? "bg-primary/20"
: "bg-neutral-content/20 hover:bg-neutral/20"
} duration-100 py-1 px-2 cursor-pointer flex items-center gap-2 rounded-md h-8`}
>
<i className="bi-hash text-2xl text-primary drop-shadow"></i>
<p className="truncate pr-3">{e}</p>
</div>
</button>
);
})}
</div>
)}
<Links
links={
links?.map((e, i) => {
const linkWithCollectionData = {
...e,
collection: collection, // Append collection data
};
return linkWithCollectionData;
})}
}) as any
}
layout={viewMode}
placeholderCount={1}
useData={data}
/>
) : (
<p>{t("collection_is_empty")}</p>
)}
{!data.isLoading && links && !links[0] && (
<p>{t("nothing_found")}</p>
)}
{/* <p className="text-center text-neutral">
{/* <p className="text-center text-neutral">
List created with <span className="text-black">Linkwarden.</span>
</p> */}
</div>
</div>
{editCollectionSharingModal && (
<EditCollectionSharingModal
onClose={() => setEditCollectionSharingModal(false)}
activeCollection={collection}
/>
)}
</div>
{editCollectionSharingModal ? (
<EditCollectionSharingModal
onClose={() => setEditCollectionSharingModal(false)}
activeCollection={collection}
/>
) : undefined}
</div>
) : (
<></>
);
);
}
export { getServerSideProps };

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