Compare commits

..

950 Commits

Author SHA1 Message Date
daniel31x13 5303d63e4b add option to disable preservation 2024-11-17 16:06:05 -05:00
Daniel 05a30e1ec6 Merge pull request #867 from clemenstyp/fixed-placeholder
fixed placeholder in german translation (delete collection)
2024-11-16 09:13:21 -05:00
daniel31x13 b1a55785b5 import dates as well 2024-11-16 08:58:03 -05:00
Clemens Eyhoff 24b47e9d4b fixed an other uppercase placeholder 2024-11-16 14:49:07 +01:00
Clemens Eyhoff 34d19f9dbe fixed placeholder (was upper case) 2024-11-16 12:46:44 +01:00
Daniel 95dddd7da0 Merge pull request #859 from linkwarden/dev
Dev
2024-11-14 15:45:17 -05:00
daniel31x13 1a949ecdc6 bug fix 2024-11-14 15:44:31 -05:00
daniel31x13 2e6f1c207c bug fixed 2024-11-14 15:43:37 -05:00
Daniel 6aa0fa9465 Merge pull request #857 from linkwarden/dev
minor UI improvement
2024-11-14 11:01:41 -05:00
daniel31x13 8677df0340 minor UI improvement 2024-11-14 11:01:02 -05:00
Daniel 125f6ac619 Merge pull request #856 from linkwarden/dev
update version
2024-11-14 08:51:33 -05:00
daniel31x13 89ecf5c529 update version 2024-11-14 08:50:52 -05:00
Daniel fa78d6057f Merge pull request #855 from linkwarden/dev
bug fix
2024-11-14 08:50:10 -05:00
daniel31x13 cfc28be898 bug fix 2024-11-14 08:48:18 -05:00
Daniel c8efd4f9db Merge pull request #852 from linkwarden/dev
created check-branch.yml
2024-11-14 02:43:50 -05:00
daniel31x13 ada4e53b46 created check-branch.yml 2024-11-14 02:42:01 -05:00
Daniel 91494b0188 Merge pull request #849 from linkwarden/dev
increase staticPageGenerationTimeout
2024-11-13 22:20:17 -05:00
daniel31x13 e9fd6ec4d5 increase staticPageGenerationTimeout 2024-11-13 22:19:18 -05:00
Daniel f08f4058dc Merge pull request #846 from linkwarden/dev
update Dockerfile
2024-11-13 09:23:59 -05:00
daniel31x13 d60200205a update Dockerfile 2024-11-13 09:23:22 -05:00
Daniel de38eb2963 Merge pull request #845 from linkwarden/dev
revert dockerfile to working state
2024-11-13 06:14:46 -05:00
daniel31x13 f22dd4535d revert dockerfile to working state 2024-11-13 06:12:39 -05:00
Daniel 043589b301 Merge pull request #841 from linkwarden/dev
update version number
2024-11-13 00:01:50 -05:00
daniel31x13 4556827d79 update version number 2024-11-13 00:01:12 -05:00
Daniel 98ebd6d7bc Merge pull request #840 from linkwarden/dev
Dev
2024-11-12 23:59:47 -05:00
daniel31x13 0a3ca4a1d4 Merge branch 'dev' of https://github.com/linkwarden/linkwarden into dev 2024-11-12 23:58:53 -05:00
daniel31x13 106410f55a revert change 2024-11-12 23:58:50 -05:00
Daniel 1ffe1b68a9 Merge pull request #839 from linkwarden/dev
Dev
2024-11-12 23:15:52 -05:00
Daniel 91ab0e609b Merge pull request #833 from click0/Ukrainian-translation
Corrected Ukrainian translation for November 12, 2024
2024-11-12 23:13:38 -05:00
daniel31x13 cbb7a666cd bug fix 2024-11-12 23:12:20 -05:00
Daniel e8cf14334f Merge pull request #838 from linkwarden/dev
added status badges
2024-11-12 22:50:33 -05:00
daniel31x13 019790791b added status badges 2024-11-12 22:49:38 -05:00
Daniel e41ba2668f Merge pull request #837 from linkwarden/dev
bug fix
2024-11-12 22:18:25 -05:00
daniel31x13 66a09fdc4b bug fix 2024-11-12 22:16:42 -05:00
Daniel e50143ca7e Merge pull request #835 from linkwarden/dev
minor change
2024-11-12 18:22:28 -05:00
daniel31x13 162b120e55 minor change 2024-11-12 18:21:58 -05:00
Daniel b4dd47aa37 Merge pull request #834 from linkwarden/dev
updated README
2024-11-12 17:40:32 -05:00
daniel31x13 256c232a85 updated README 2024-11-12 17:39:48 -05:00
vlad11 b7ddf22662 Corrected Ukrainian translation for November 12, 2024
Signed-off-by: vlad11 <admin@support.od.ua>
2024-11-13 00:12:32 +02:00
Daniel 5f60e9833e Merge pull request #831 from linkwarden/dev
bug fix
2024-11-12 16:18:15 -05:00
daniel31x13 ceed23ff51 bug fix 2024-11-12 16:17:38 -05:00
daniel31x13 a4c83dc82f small fix 2024-11-12 08:36:40 -05:00
daniel31x13 46f81ebf25 add info to inviteModal 2024-11-10 16:42:04 -05:00
daniel31x13 0ac5009a4a minor change 2024-11-10 00:27:13 -05:00
daniel31x13 6842da4283 new feature: open all links 2024-11-09 23:59:12 -05:00
daniel31x13 78ecf3ddb5 bug fix 2024-11-09 23:33:13 -05:00
daniel31x13 e39645e135 bug fix 2024-11-09 23:11:03 -05:00
daniel31x13 836360f99d bug fixed 2024-11-09 23:07:01 -05:00
daniel31x13 9c9fd969bc minor fix 2024-11-09 15:27:15 -05:00
daniel31x13 213105942b minor change 2024-11-09 15:02:59 -05:00
daniel31x13 0b7acb35b7 minor change 2024-11-09 14:14:13 -05:00
daniel31x13 9b58ea5c98 minor change 2024-11-09 13:45:11 -05:00
daniel31x13 c85c3bb0d7 minor fix 2024-11-08 18:03:00 -05:00
daniel31x13 7ca574b76f bug fixes 2024-11-08 17:57:50 -05:00
daniel31x13 8593df4673 bug fixed 2024-11-08 17:21:20 -05:00
daniel31x13 ddc2079f4b minor fix 2024-11-08 12:25:31 -05:00
daniel31x13 0de5caffa1 minor fix 2024-11-08 05:24:18 -05:00
daniel31x13 b14e77bdf9 minor fix 2024-11-08 04:48:31 -05:00
daniel31x13 8d366ae7d8 minor fix 2024-11-07 16:54:51 -05:00
daniel31x13 a18938ba2a minor fix 2024-11-07 16:46:26 -05:00
daniel31x13 6eac8423f8 added survey 2024-11-07 11:09:36 -05:00
daniel31x13 cbf93dcf06 minor improvement 2024-11-07 07:32:06 -05:00
daniel31x13 2993347dc7 sleeker dashboard items 2024-11-07 02:09:56 -05:00
daniel31x13 cc45c8fc3e minor improvement 2024-11-07 01:17:30 -05:00
daniel31x13 d5602a09cd minor fix 2024-11-07 01:14:23 -05:00
daniel31x13 736e98ac7d improvements 2024-11-07 01:12:05 -05:00
daniel31x13 7eaff332a9 bug fixed 2024-11-07 00:20:57 -05:00
daniel31x13 7931e2d7b6 better logic when showing link icons 2024-11-07 00:19:12 -05:00
daniel31x13 ac3888f9b3 icon picker is now much more efficient 2024-11-06 23:57:20 -05:00
daniel31x13 ac8add8c5d small improvement 2024-11-06 22:53:21 -05:00
daniel31x13 a6a0f6965b bug fixed 2024-11-06 03:36:02 -05:00
Daniel b2c5c3c6dd Merge pull request #792 from jvanbruegge/prisma-update
Update prisma to v5
2024-11-03 14:20:24 -05:00
Daniel 4555874725 Merge branch 'dev' into prisma-update 2024-11-03 14:19:28 -05:00
daniel31x13 0f5b70eda7 update prisma 2024-11-03 03:59:39 -05:00
daniel31x13 d1c3748681 minor improvement 2024-11-03 03:34:21 -05:00
Daniel 2524139113 Merge pull request #816 from linkwarden/main
Merge main to dev
2024-11-03 03:19:40 -05:00
Daniel 6c2b86fc4b Merge branch 'dev' into main 2024-11-03 03:19:29 -05:00
Daniel d0e0526655 Merge pull request #815 from Green-Kite/dev
update german translation
2024-11-03 03:10:40 -05:00
Green-Kite 43e94ebd0b update german translation
updated german translation
2024-11-03 07:24:38 +01:00
Daniel aeafe6e15d Merge pull request #789 from jvanbruegge/playwright-path
Allow to specify a custom playwright browser path
2024-11-02 21:59:12 -04:00
daniel31x13 5ec221d87d update .env.sample 2024-11-02 21:58:44 -04:00
Daniel d6d6442bc4 Merge pull request #809 from Green-Kite/dev
update german translation
2024-11-02 20:57:31 -04:00
Daniel d12d12518e Merge pull request #636 from bjoerndot/tags-in-public-collection
Tags in public collection
2024-11-02 20:55:16 -04:00
daniel31x13 02ced62832 final change 2024-11-02 20:45:31 -04:00
daniel31x13 4febe1ace5 minor changes 2024-11-02 20:43:53 -04:00
daniel31x13 2e1e94112f make tags visible on public collections 2024-11-02 18:16:38 -04:00
daniel31x13 d86bbcd940 minor fix 2024-11-02 18:07:16 -04:00
daniel31x13 eed80ca812 add migration 2024-11-02 18:01:36 -04:00
Daniel 394251c1f1 Merge branch 'dev' into tags-in-public-collection 2024-11-02 17:56:43 -04:00
Daniel 68cdde91ad Merge pull request #813 from linkwarden/feat/team-support
Feat/seats support
2024-11-02 17:52:17 -04:00
Green-Kite 1ef286a38c update german translation
German translation updated
2024-11-01 10:08:14 +01:00
daniel31x13 508844dd9d bug fixes 2024-10-30 16:47:40 -04:00
daniel31x13 fa1f9873d5 minor change 2024-10-30 13:56:07 -04:00
Oliver Schwamb 891803547e retrieve all links for collection 2024-10-30 12:10:53 +01:00
Oliver Schwamb 24d45f8e8e Merge remote-tracking branch 'upstream/dev' into tags-in-public-collection 2024-10-30 12:10:30 +01:00
daniel31x13 f95350405c rename variable 2024-10-29 18:14:35 -04:00
daniel31x13 665019dc59 finalizing team support 2024-10-29 18:08:47 -04:00
daniel31x13 b09de5a8af updated verify max link logic 2024-10-26 13:44:52 -04:00
daniel31x13 cfd33e9bd1 bug fixed 2024-10-26 10:58:27 -04:00
daniel31x13 d3d2d5069e add member onboarding 2024-10-26 09:42:21 -04:00
daniel31x13 cffc74caa4 add team invitation functionality [WIP] 2024-10-21 13:59:05 -04:00
Jan van Brügge 3cd8eadee3 Update prisma to v5 2024-10-08 16:25:36 +01:00
daniel31x13 d146ec296c bug fixed 2024-10-07 23:43:44 -04:00
Jan van Brügge fb4aa42eef Allow to specify a custom playwright browser path 2024-10-07 15:05:48 +01:00
daniel31x13 f68582e28c bug fixed 2024-10-07 00:57:36 -04:00
daniel31x13 d042c82cb0 add subscription webhook 2024-10-06 01:59:31 -04:00
Daniel 8738dd45e9 Merge pull request #771 from click0/main
Corrected Ukrainian translation.
2024-09-19 14:13:37 -04:00
Vladyslav V. Prodan 839de18d7a Merge branch 'linkwarden:main' into main 2024-09-19 00:46:06 +03:00
vlad11 2ba0851fee Corrected Ukrainian translation.
Signed-off-by: vlad11 <admin@support.od.ua>
2024-09-19 00:36:16 +03:00
daniel31x13 d99972a335 minor fix 2024-09-18 12:10:45 -04:00
daniel31x13 e071b9eb07 minor fix 2024-09-18 11:39:31 -04:00
daniel31x13 eb00d151b7 added locale to the config file 2024-09-18 11:06:31 -04:00
Daniel 22aaa52b3e Merge pull request #770 from bennyz327/dev
feat(lang): add traditional chinese translate
2024-09-18 11:04:03 -04:00
Benny Chou 4541277b28 feat(lang): add traditional chinese translate 2024-09-18 15:21:19 +08:00
Daniel 39faece9d7 Merge pull request #769 from linkwarden/main
Merge pull request #766 from linkwarden/daniel31x13-patch-1
2024-09-17 14:08:44 -04:00
daniel31x13 a21b0760de remove unused type 2024-09-17 14:06:03 -04:00
daniel31x13 04149fe86b Merge branch 'dev' of https://github.com/linkwarden/linkwarden into dev 2024-09-17 14:03:07 -04:00
daniel31x13 ff6e71d494 add schema validation for PUT requests 2024-09-17 14:03:05 -04:00
Daniel 5b02c1cfc9 Merge pull request #765 from ochtum/dev
Translated the added IDs.
2024-09-15 13:53:00 -04:00
Daniel 1ff13e8aa0 Merge pull request #767 from linkwarden/daniel31x13-patch-1
Update .env.sample
2024-09-15 13:52:47 -04:00
Daniel eaf4524598 Merge pull request #766 from linkwarden/daniel31x13-patch-1
Update .env.sample
2024-09-15 13:52:17 -04:00
Daniel a276065288 Update .env.sample 2024-09-15 13:51:09 -04:00
daniel31x13 1cf7421b76 added zod for post requests 2024-09-14 16:00:19 -04:00
武田 淳一 ed4a334024 Translated the added IDs. 2024-09-14 13:24:29 +09:00
Daniel a5b1952e0d Merge pull request #710 from arran4/patch-1
Please 'EXPOSE' port 3000
2024-09-13 01:57:56 -04:00
Daniel 01826b1634 Merge branch 'dev' into patch-1 2024-09-13 01:57:39 -04:00
daniel31x13 3b17d4ddfe bug fixed 2024-09-13 00:37:58 -04:00
Daniel f104fa095f Merge pull request #672 from jlssmt/logging
disabled query logging as default
2024-09-12 23:36:01 -04:00
Daniel b08e6690f3 Merge pull request #689 from stumpylog/chore/update-actions
Chore: Updates actions to their latest versions
2024-09-12 23:16:27 -04:00
Daniel 33a654d21a Merge pull request #688 from stumpylog/feature/docker-file-reduce
fix: reduce Docker image size
2024-09-12 23:06:08 -04:00
Daniel e1262142f8 Merge pull request #764 from click0/main
Corrected Ukrainian translation for September 12, 2024.
2024-09-12 19:04:50 -04:00
vlad11 0a43279665 Corrected Ukrainian translation for September 12, 2024.
Signed-off-by: vlad11 <admin@support.od.ua>
2024-09-13 01:29:34 +03:00
daniel31x13 5491ac74a5 add nl and tr translations 2024-09-12 17:06:36 -04:00
Daniel bbcfca4cde Merge pull request #716 from kgnfth/main
feat(translations): Add Dutch and Turkish translations
2024-09-12 17:05:19 -04:00
daniel31x13 bf9a7d4fa0 add german translation 2024-09-12 17:01:38 -04:00
Daniel edf4e489ec Merge pull request #711 from Green-Kite/main
Add German Translation
2024-09-12 17:00:49 -04:00
daniel31x13 20c5a20851 add spanish translation 2024-09-12 17:00:26 -04:00
Daniel 6f47a20e87 Merge pull request #717 from joser93/es-patch-1
Patch for Spanish translation.
2024-09-12 16:56:31 -04:00
Daniel 384937e210 Merge pull request #714 from phampyk/main
Added Spanish translation
2024-09-12 16:54:58 -04:00
Daniel d22d989c91 Merge pull request #724 from ochtum/main
Created Japanese Translate
2024-09-12 16:53:24 -04:00
Daniel 4e0294322f Merge branch 'dev' into main 2024-09-12 16:53:12 -04:00
daniel31x13 75d5061bdf minor fix 2024-09-12 16:52:26 -04:00
Daniel 0150a9a6e3 Merge pull request #762 from rdeavila/dev
Update pt-BR translation
2024-09-12 16:49:38 -04:00
Daniel 87b79ffbac Merge pull request #726 from CoffeeAnon/feat/set-max-workers
Add Configurable Playwright Concurrency via Environment Variable
2024-09-12 16:43:32 -04:00
Rodrigo de Avila 5a40677191 Update pt-BR translation 2024-09-12 17:42:17 -03:00
Daniel 95ce2f30a8 Merge pull request #734 from click0/main
Added Ukranian translation
2024-09-12 16:33:28 -04:00
Daniel e6a0ecbab5 Merge branch 'dev' into main 2024-09-12 16:32:39 -04:00
daniel31x13 e4c9cf8a38 add locale to config 2024-09-12 16:30:20 -04:00
Daniel eaca3d7453 Merge pull request #746 from rdeavila/main
Add Brazilian Portuguese (pt-BR) support
2024-09-12 16:18:06 -04:00
Rodrigo de Avila fbe3642be4 Merge branch 'linkwarden:main' into main 2024-09-12 17:16:22 -03:00
daniel31x13 bc32abbb92 Merge branch 'main' into dev
merge main to dev
2024-09-12 16:10:35 -04:00
daniel31x13 38f731f313 minor change 2024-09-12 15:46:16 -04:00
daniel31x13 aaf3590542 members with edit permission can now refresh preservation as well + bug fix 2024-09-12 15:30:15 -04:00
daniel31x13 8bb6e32bfa urls are now editable 2024-09-12 15:03:14 -04:00
daniel31x13 7bd3872195 bug fixed + optimizations 2024-09-12 13:47:18 -04:00
daniel31x13 906779010e collection closing bug fixed 2024-09-12 12:46:38 -04:00
daniel31x13 b0f87e8659 bug fixed 2024-09-12 11:59:20 -04:00
daniel31x13 653b1bc396 bug fix 2024-09-11 02:29:50 -04:00
daniel31x13 9b1506a64e add pin to hover view + add number of pins to dashboard + bug fixes 2024-09-11 01:38:38 -04:00
daniel31x13 fb1869ca7a fix dashboard bug 2024-09-10 00:09:33 -04:00
daniel31x13 5e7835b4d5 minor improvement 2024-09-09 23:27:55 -04:00
daniel31x13 0a91c47f83 minor change 2024-09-09 23:07:22 -04:00
daniel31x13 dc9db05e75 fully implemented the custom slider for the number of columns to show 2024-09-09 23:05:57 -04:00
daniel31x13 e1149c2733 minor fix 2024-09-09 19:16:28 -04:00
daniel31x13 0591d7c134 remove unused import 2024-09-09 19:09:09 -04:00
daniel31x13 4602269dd8 add number of columns slider 2024-09-09 19:05:30 -04:00
daniel31x13 9ae6a22236 minor improvement 2024-09-09 12:18:45 -04:00
daniel31x13 442da02956 minor fix 2024-09-04 23:17:58 -04:00
daniel31x13 dfcc271343 bug fix 2024-09-04 23:02:19 -04:00
daniel31x13 43d50dfd1b minor change 2024-09-04 22:39:10 -04:00
daniel31x13 40bb3e6fae fix build error 2024-09-04 22:29:54 -04:00
Daniel 3e077fa247 Merge pull request #754 from linkwarden/feat/customizable-links
Feat/customizable links
2024-09-04 22:20:16 -04:00
daniel31x13 3de8872f26 upload preview functionality 2024-09-04 22:19:40 -04:00
daniel31x13 e9072bba51 minor improvement 2024-08-30 18:10:50 -04:00
daniel31x13 d20c915970 improved edit view 2024-08-30 17:29:15 -04:00
daniel31x13 1a378de267 minor improvement 2024-08-30 10:54:27 -04:00
daniel31x13 d594159c15 minor improvement 2024-08-30 10:47:29 -04:00
daniel31x13 aee10fa406 better edit view 2024-08-30 02:38:58 -04:00
daniel31x13 820d686c37 minor improvement 2024-08-29 18:26:15 -04:00
daniel31x13 4189062c4c bug fixed 2024-08-29 12:53:37 -04:00
Daniel 1461caf68a Merge pull request #748 from linkwarden/hotfix
bug fix
2024-08-29 12:49:48 -04:00
daniel31x13 e7c7fedf8b bug fix 2024-08-29 12:47:23 -04:00
daniel31x13 b7adbbc86f improvements 2024-08-28 20:48:35 -04:00
daniel31x13 975716937f minor improvement 2024-08-28 20:30:57 -04:00
daniel31x13 2d0e52f65b better looking detail modal 2024-08-28 20:22:11 -04:00
Rodrigo de Avila e9afe0ef25 Add Brazilian Portuguese (pt-BR) support 2024-08-28 10:26:27 -03:00
José Roberto Sánchez a38133d618 Improved translations based on comments from @jmiguelr 2024-08-27 11:32:15 -06:00
daniel31x13 6498ae794b custom preview initial commit 2024-08-26 21:04:52 -04:00
daniel31x13 0371695eb3 choose to show which detail in each views 2024-08-26 19:56:04 -04:00
daniel31x13 9ae9c7c81a refactored view dropdown 2024-08-26 18:47:10 -04:00
daniel31x13 642374c2e5 remove commented code 2024-08-26 16:22:59 -04:00
daniel31x13 f368c2aa81 less padding for list view 2024-08-26 16:11:02 -04:00
daniel31x13 fae9e95fa9 added custom icons for links 2024-08-24 15:50:29 -04:00
Daniel 03639adc22 Merge pull request #735 from IsaacWise06/issue-691
Add new collection drop down
2024-08-22 22:48:56 -04:00
Isaac Wise 9fe829771d Add new collection drop down 2024-08-22 17:09:14 -05:00
vlad11 ed7b268c2b Created Ukranian Translate.
Signed-off-by: vlad11 <admin@support.od.ua>
2024-08-22 03:07:25 +03:00
daniel31x13 bf1a6efd2e custom icons fully implemented for collections 2024-08-20 19:25:35 -04:00
daniel31x13 6df2e44213 added translation to icon picker component + other fixes and improvements 2024-08-20 18:11:20 -04:00
daniel31x13 ae2324ecd3 progressed icon picker component 2024-08-20 16:59:01 -04:00
daniel31x13 accbd4cbfa bug fixes 2024-08-19 23:53:43 -04:00
Daniel 5f4e0d4262 Merge pull request #731 from linkwarden/hotfix
bugs fixed
2024-08-19 23:37:30 -04:00
daniel31x13 c072fed99f bugs fixed 2024-08-19 23:36:28 -04:00
Daniel b4a9f917b5 Merge pull request #728 from linkwarden/hotfix
hotfix
2024-08-19 19:30:26 -04:00
daniel31x13 078e5ba95f minor change 2024-08-19 19:30:01 -04:00
daniel31x13 495509c888 bug fix 2024-08-19 19:25:13 -04:00
daniel31x13 dc388ebba5 improved iconPicker component + other improvements 2024-08-19 18:14:09 -04:00
Dan Jacobsen 21578bac8d feat: add configurable max workers 2024-08-19 12:44:59 -07:00
武田 淳一 1062e07065 Created Japanese Translate 2024-08-20 00:50:07 +09:00
daniel31x13 2893d3caf2 minor improvement 2024-08-18 16:52:08 -04:00
Daniel 9f74f62330 Merge pull request #722 from linkwarden/dev
Dev
2024-08-18 16:41:48 -04:00
Daniel c6e3147bb6 Merge pull request #678 from IsaacWise06/fixes
General Fixes
2024-08-18 16:40:48 -04:00
daniel31x13 1260e8c093 fixes 2024-08-18 16:39:43 -04:00
Daniel 5cb4bdced3 Merge pull request #721 from linkwarden/feat/customizable-links
small improvements
2024-08-18 14:47:55 -04:00
Daniel 03b4240b8b Merge pull request #720 from linkwarden/revert-719-feat/customizable-links
Revert "Feat/customizable links"
2024-08-18 14:47:29 -04:00
Daniel 9a3e82470a Revert "Feat/customizable links" 2024-08-18 14:46:52 -04:00
Daniel ee2319996b Merge pull request #719 from linkwarden/feat/customizable-links
Feat/customizable links
2024-08-18 14:46:21 -04:00
daniel31x13 c979adfe69 small improvements 2024-08-18 14:45:40 -04:00
Isaac Wise 2b83522eaa Merge branch 'dev' into fixes 2024-08-18 13:21:02 -05:00
Daniel 8c738d4a99 Merge pull request #718 from linkwarden/feat/customizable-links
Feat/customizable links
2024-08-18 14:11:47 -04:00
Isaac Wise 63678b7f1e format 2024-08-18 13:06:36 -05:00
Isaac Wise b73e845299 Fix building 2024-08-18 13:06:19 -05:00
Isaac Wise 898b126231 Fix merge conflicts 2024-08-18 13:03:09 -05:00
daniel31x13 17d1cb45e3 minor improvement 2024-08-18 13:49:33 -04:00
José Roberto Sánchez 0aad2d9e4b Change email and fixed some typos. Overall is a good translation and I hope is merged soon. :D 2024-08-18 11:07:13 -06:00
daniel31x13 c18a5f4162 added details drawer 2024-08-18 02:55:59 -04:00
Teal'c df7814385a feat(translations): Add Dutch and Turkish translations
- Added Dutch (nl) translations.
- Added Turkish (tr) translations.

#216
2024-08-18 04:39:35 +03:00
Ana d568f22e00 Upload Spanish translation 2024-08-18 00:42:33 +01:00
Green-Kite 6bd1c90417 Create German Translation
updated German translation
2024-08-17 09:57:57 +02:00
daniel31x13 a40026040c icon picker component 2024-08-16 23:00:37 -04:00
Arran Ubels 334ad9f3dc Please 'EXPOSE' port 3000
This is so I can setup up Synology correctly
2024-08-17 11:56:00 +10:00
Daniel f944345745 Merge pull request #708 from linkwarden/dev
bump version
2024-08-16 13:45:28 -04:00
daniel31x13 6b647573f0 bump version 2024-08-16 13:44:53 -04:00
Daniel d81493e021 Merge pull request #707 from linkwarden/dev
bug fix
2024-08-16 13:43:57 -04:00
daniel31x13 03f4523d57 bug fix 2024-08-16 13:42:55 -04:00
Daniel c24e76adac Merge pull request #706 from linkwarden/dev
v2.7.0
2024-08-16 12:36:43 -04:00
daniel31x13 5d26617251 bug fixed 2024-08-16 12:35:04 -04:00
daniel31x13 0e47ad9920 bump version 2024-08-15 16:42:36 -04:00
daniel31x13 ca45076b6c minor fix 2024-08-15 15:37:47 -04:00
Daniel 3bf6dcad2f Merge pull request #692 from phillibl/main
Update [...nextauth].ts to allow existing SSO user sign
2024-08-15 13:45:07 -04:00
daniel31x13 23860b8511 minor fix 2024-08-15 11:00:29 -04:00
daniel31x13 8758976f8d minor fix 2024-08-15 10:30:44 -04:00
Daniel 550dbd2bf0 Merge pull request #704 from shichen437/dev
feat(lang): add chinese translate
2024-08-15 08:49:26 -04:00
shichen437 04d2b3c6b2 feat(lang): add chinese translate 2024-08-15 17:20:46 +08:00
Trenton Holmes cc1c17363b Also install a single browser (Chromium) through Playwright 2024-08-14 19:48:16 -07:00
daniel31x13 7bd0e29538 small improvement 2024-08-14 20:07:06 -04:00
daniel31x13 5baf55694c minor improvement 2024-08-14 19:23:51 -04:00
daniel31x13 193a70c6e8 fix dropdown text wrapping in other languages 2024-08-14 19:13:19 -04:00
daniel31x13 5b430cf31e add french translation 2024-08-14 17:49:53 -04:00
Daniel 684609a1dd Merge pull request #654 from zarevskaya/patch-1
Add french translation
2024-08-14 17:43:15 -04:00
Daniel ebb2016915 Merge pull request #671 from jlssmt/main
handle undefined
2024-08-14 17:40:28 -04:00
daniel31x13 c103b66694 Merge branch 'dev' of https://github.com/linkwarden/linkwarden into dev 2024-08-14 17:26:40 -04:00
daniel31x13 863bcc3838 bug fixed 2024-08-14 17:26:38 -04:00
Daniel 66b0aacc3f Merge pull request #660 from IsaacWise06/issue-646
fix(collections): Redirect to dashboard or login for non-public collections
2024-08-14 17:04:25 -04:00
Daniel 299498ffa6 Merge pull request #703 from linkwarden/chore/react-query-implementation
Chore/react query implementation
2024-08-14 16:45:40 -04:00
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
Daniel 15a0084fb7 Merge pull request #677 from linkwarden/dev
bump version
2024-07-26 12:01:38 -04:00
daniel31x13 cd82083e09 bump version 2024-07-26 12:00:46 -04:00
Daniel c0abf2f411 Merge pull request #676 from linkwarden/dev
bug fixed
2024-07-26 11:55:07 -04:00
daniel31x13 061e22d225 bug fixed 2024-07-26 11:54:13 -04:00
Daniel a886437589 Merge pull request #674 from linkwarden/dev
merged the two migration scripts for v2.6.1
2024-07-25 23:44:46 -04:00
daniel31x13 8e6f88d29f merged the two migration scripts for v2.6.1 2024-07-25 23:43:26 -04:00
Isaac Wise 0b8a9b4310 Fix some any types 2024-07-25 18:58:52 -05:00
jlssmt ce1aa5a0ec disabled query logging as default 2024-07-25 23:19:33 +02:00
Daniel a82c4ef85f Merge pull request #670 from linkwarden/dev
Dev
2024-07-25 14:24:24 -04:00
daniel31x13 6983e41576 minor improvement 2024-07-25 14:23:33 -04:00
daniel31x13 7e96ba63df minor improvement 2024-07-25 14:21:39 -04:00
Daniel 7036b46084 Merge pull request #668 from linkwarden/dev
made script more efficient
2024-07-25 14:16:16 -04:00
daniel31x13 af7f0fb47c make script more efficient 2024-07-25 14:15:08 -04:00
Daniel 2bba8198b8 Merge pull request #667 from linkwarden/dev
minor fix
2024-07-25 13:58:13 -04:00
daniel31x13 9d8ae6970c minor fix 2024-07-25 13:57:33 -04:00
Daniel 96a70a9689 Merge pull request #666 from linkwarden/dev
update version number
2024-07-25 13:46:59 -04:00
daniel31x13 6cae2fb634 update version number 2024-07-25 13:45:44 -04:00
Daniel 288fd9df87 Merge pull request #665 from linkwarden/dev
bug fixed
2024-07-25 13:45:03 -04:00
daniel31x13 5e6d46b6b9 bug fixed 2024-07-25 13:43:55 -04:00
Isaac Wise e79b98d3b0 Replace useless ternarys with logical ANDs 2024-07-22 22:34:36 -05:00
Isaac Wise 7d43ed52a4 format 2024-07-22 17:50:24 -05:00
Isaac Wise 614653bf29 Merge branch 'linkwarden:main' into issue-646 2024-07-22 17:41:34 -05:00
Isaac Wise 1b9dafbe47 Handle 400 error code when accesing a non public collection 2024-07-22 17:39:38 -05:00
zarev abc93f1bf9 Update common.json
Correction
2024-07-20 09:55:51 +02:00
zarev c23964a46d Create common.json
In french, if you want it ;)
2024-07-19 22:37:46 +02:00
Daniel a76e996fc1 Merge pull request #653 from linkwarden/dev
v2.6.0
2024-07-19 08:59:54 -04:00
daniel31x13 2264abd384 bug fixed 2024-07-18 20:29:33 -04:00
daniel31x13 6544e3ecbb added translations to demo info + minor improvement 2024-07-18 16:48:14 -04:00
daniel31x13 a8ffbc87d1 UI improvements 2024-07-18 16:29:59 -04:00
daniel31x13 92c7f40956 bug fixed 2024-07-18 10:46:21 -04:00
daniel31x13 6c29d905d9 minor fix 2024-07-18 10:27:32 -04:00
daniel31x13 9b85a2b1bb minor improvements 2024-07-18 09:51:16 -04:00
Daniel cebe746ca7 Merge pull request #638 from danilo-tecnosys/italian-language
Added Italian translation for common.js and add language tag ‘it’ in next-i18next.config.js
2024-07-18 09:33:38 -04:00
Daniel 5b0297bfe0 Merge pull request #651 from linkwarden:feat/demo-mode
added read-only mode + visual improvements
2024-07-16 20:42:27 -04:00
daniel31x13 9c5226ee51 added read-only mode + visual improvements 2024-07-16 20:33:33 -04:00
daniel31x13 6d30912812 Revert "simplified the dockerfile"
This reverts commit 78111f010b.
2024-07-13 18:13:32 -04:00
daniel31x13 78111f010b simplified the dockerfile 2024-07-13 17:58:38 -04:00
Oliver Schwamb abb73f80bd Reworked access to tags as public viewer 2024-07-10 10:22:58 +02:00
Oliver Schwamb e8d0cce58a Added allLinksOfCollection to linksStore
Removed duplicated tags
Fixed overflow for line
added disclosure for tags in public collection
2024-07-09 13:50:08 +02:00
Danilo a2637d4526 Rename common.js to common.json 2024-07-07 19:43:14 +02:00
Danilo 479995366a Update next-i18next.config.js for italian language 2024-07-07 19:36:20 +02:00
Danilo 7edd7f893b Create common.js in italian language 2024-07-07 19:34:06 +02:00
daniel31x13 0185ec57c7 add import limit for the environment variables 2024-07-05 11:36:16 -04: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
daniel31x13 7c95761990 added button for administration 2024-07-03 17:29:33 -04:00
Daniel c67526e54c Merge pull request #633 from linkwarden/twihno-azure-ad
Twihno azure ad
2024-06-30 15:09:26 +03:30
daniel31x13 8db5307747 Merge branch 'azure-ad' of https://github.com/twihno/linkwarden into twihno-azure-ad 2024-06-30 07:32:10 -04:00
Daniel 54beb50576 Merge pull request #598 from LeonKohli/main
Fix bookmark import issue with missing folder names
2024-06-30 14:42:07 +03:30
Daniel 9ab01da369 Merge pull request #619 from linkwarden/feat/single-file
Feat/Monolith + Optimizations
2024-06-30 01:28:22 +03:30
daniel31x13 78c80a5fea bug fixed 2024-06-29 17:54:31 -04:00
daniel31x13 644b827669 improved archive handler 2024-06-29 17:18:38 -04:00
daniel31x13 d66c784d3f check size in image and pdf handler 2024-06-28 12:20:56 -04:00
daniel31x13 1e2ed6c293 minor change 2024-06-28 12:12:52 -04:00
daniel31x13 576d50f467 add configurable limits to the buffer sizes 2024-06-28 12:12:16 -04:00
daniel31x13 06234e42df archive screenshots as jpeg instead of png + bug fix 2024-06-28 09:39:31 -04:00
daniel31x13 8a901ba0e9 cleaner code 2024-06-28 09:14:09 -04:00
daniel31x13 39422e54df minor bug fix 2024-06-27 22:23:47 -04:00
daniel31x13 a71f42af6e use monolith instead of singlefile 2024-06-27 21:58:07 -04:00
daniel31x13 5b8e1d53cc small changes 2024-06-27 21:39:15 -04:00
Daniel 52f7cbb10b Merge pull request #627 from linkwarden/dev
Dev
2024-06-28 05:05:11 +03:30
daniel31x13 22b2734494 fixed monolith for docker users 2024-06-27 18:19:07 -04:00
daniel31x13 9fa9fe5db0 added support for monolith 2024-06-27 12:39:03 -04:00
Daniel 6003c6c449 Merge pull request #626 from linkwarden/feat/add-session-route
added the route
2024-06-27 05:14:25 +03:30
daniel31x13 239589eaed added the route 2024-06-26 21:38:34 -04:00
daniel31x13 586074ef43 new dashboard route 2024-06-26 17:59:23 -04:00
daniel31x13 afd5e5f036 remove console.log in a file 2024-06-26 14:08:21 -04:00
daniel31x13 8082efdc67 small fix 2024-06-26 14:07:24 -04:00
daniel31x13 3618ba907d code refactoring 2024-06-26 13:54:03 -04:00
daniel31x13 c68f9d68ad small changes 2024-06-18 12:27:29 -04:00
daniel31x13 359d22e61b Merge branch 'dev' of https://github.com/linkwarden/linkwarden into feat/single-file 2024-06-18 12:19:52 -04:00
Daniel c216a92474 Merge pull request #618 from linkwarden/feat/client-i18n
Feat/client i18n
2024-06-10 05:36:53 +03:30
Daniel e45384c855 Update index.tsx 2024-06-09 22:02:14 -04:00
daniel31x13 eadc98fbbe fix build errors 2024-06-09 10:41:06 -04:00
daniel31x13 8505667f73 renamed "AccentSubmitButton" to "Button" 2024-06-09 10:02:45 -04:00
daniel31x13 71678ba9dd client side i18n fully implemented 2024-06-09 09:27:16 -04:00
daniel31x13 d261bd39ec added internationalization to pages [WIP] 2024-06-04 16:59:49 -04:00
daniel31x13 2c87459f35 remove experimental locales 2024-05-28 17:16:27 -04:00
daniel31x13 83cd9f6a06 minor fix 2024-05-28 17:14:10 -04:00
daniel31x13 adcc4e85ac remove comments 2024-05-28 17:12:10 -04:00
daniel31x13 f921ecaa96 add language selection to the settings page 2024-05-28 17:10:25 -04:00
daniel31x13 deb6ed7ec8 code refactoring + add locale field to user table 2024-05-28 15:55:19 -04:00
daniel31x13 17cdb7efa4 initial commit for i18n 2024-05-27 17:42:29 -04:00
Thomas Schuster 7e98de6122 fix azure errors 2024-05-26 17:31:22 +02:00
Thomas Schuster 5f34f03355 fix github documentation 2024-05-26 17:31:11 +02:00
Thomas Schuster 4344183564 fix build error 2024-05-26 17:30:49 +02:00
Thomas Schuster bc3ec3cc54 fix small mistakes 2024-05-26 16:52:53 +02:00
Thomas Schuster fc97735703 fix battlenet typo 2024-05-26 16:50:55 +02:00
Thomas Schuster 8f38c82ed7 add azure ad authentication 2024-05-26 16:50:03 +02:00
daniel31x13 b0ea14737f added new import format 2024-05-25 18:26:24 -04:00
daniel31x13 75d91fbac7 minor change 2024-05-24 20:42:27 -04:00
daniel31x13 bcb6aea119 names should be auto generated instead of descriptions + add default value to name field 2024-05-24 20:41:31 -04:00
daniel31x13 cb50de96a3 minor fix 2024-05-24 19:47:45 -04:00
daniel31x13 fc66dac933 small change 2024-05-24 19:15:33 -04:00
daniel31x13 f310cd79ad refactor components 2024-05-24 19:13:04 -04:00
daniel31x13 d262041f33 refactor code to improve readability and maintainability + redesigned announcement bar 2024-05-24 17:12:47 -04:00
daniel31x13 a498f3a10d refactor code to improve readability and maintainability + redesigned announcement bar 2024-05-22 20:56:56 -04:00
daniel31x13 811628a952 Fix deleteUserById response message for users without password 2024-05-21 08:00:44 -04:00
daniel31x13 0fd10396f4 Refactor password update functionality 2024-05-21 07:08:08 -04:00
daniel31x13 329019b34e finalized password reset + code refactoring 2024-05-20 19:23:11 -04:00
daniel31x13 73dda21573 password reset functionality [WIP] 2024-05-18 23:57:00 -04:00
daniel31x13 27061ada43 add PasswordResetToken table 2024-05-18 12:18:43 -04:00
daniel31x13 78fa417f06 minor change 2024-05-18 12:16:00 -04:00
daniel31x13 f0621dac2e small improvement 2024-05-17 03:15:18 -04:00
daniel31x13 90efec3c6e removed unnecessary comment 2024-05-16 15:52:09 -04:00
daniel31x13 142af9b5c0 small improvements 2024-05-16 15:50:43 -04:00
daniel31x13 f68ca100a1 refactored email verification 2024-05-16 15:02:22 -04:00
Daniel db446d450f Merge pull request #579 from codingmatty/main
Add link actions to readable view
2024-05-14 19:50:42 +03:30
daniel31x13 7442799836 minor fix 2024-05-14 12:14:22 -04:00
daniel31x13 341154e928 auto assign username upon registration 2024-05-13 00:27:29 -04:00
daniel31x13 65b29830f0 enable modifying profile settings for SSO users 2024-05-12 22:28:34 -04:00
LeonKohli 74030b26c5 Fix bookmark import issue with missing folder names 2024-05-08 21:00:12 +02:00
daniel31x13 861f8e55f4 bug fixed + add support for google profile pics 2024-05-07 16:59:00 -04:00
daniel31x13 2dd49ff844 minor improvement 2024-05-03 17:08:58 -04:00
Daniel f1655aad15 Merge pull request #592 from linkwarden/user-administration
User administration
2024-05-04 00:05:07 +03:30
daniel31x13 80ad01a2d0 minor fix 2024-05-03 10:51:11 -04:00
daniel31x13 915d08a315 finalized administration panel 2024-05-03 10:22:45 -04:00
daniel31x13 08c2ff278f delete user functionality 2024-05-02 09:17:56 -04:00
daniel31x13 58d71a863b small improvement 2024-05-01 17:15:00 -04:00
daniel31x13 b4ea7dcd8e improved masonry look 2024-04-27 12:23:33 -04:00
daniel31x13 6f4759d928 added tags and description directly inside masonry view 2024-04-27 11:03:08 -04:00
Daniel eb8eb74a32 Merge pull request #576 from QAComet/qacomet/login-tests
Added playwright test setup and login tests
2024-04-27 09:01:52 +03:30
daniel31x13 30ef557f43 small improvements 2024-04-26 12:18:31 -04:00
Daniel 7fb50337d3 Merge pull request #585 from IsaacWise06/issue-559
fix(links): Show bulk actions on search page
2024-04-26 19:08:28 +03:30
daniel31x13 d66019bfea bug fixed 2024-04-25 23:56:36 -04:00
Isaac Wise ba1e096cff Merge branch 'dev' into issue-559 2024-04-24 15:38:14 -05:00
Daniel 9354842065 Merge pull request #580 from IsaacWise06/masonry-view
feat(links): Added Masonry View
2024-04-24 23:53:12 +03:30
QAComet 464d2f920d feat(e2e-workflow): cache apt packages 2024-04-24 14:00:51 -06:00
QAComet 1c55ec8d97 feat(e2e): update github workflow to use matrix with playwright tags, cache workflow setup steps 2024-04-24 13:51:25 -06:00
QAComet d181d5db20 fix: update to apply prettier on e2e/tests/public/ 2024-04-24 13:44:39 -06:00
daniel31x13 154d0d5fb6 add search to user admin 2024-04-24 09:16:34 -04:00
Isaac Wise ca076b1be8 Added bulk edit/delete to search page 2024-04-23 21:19:18 -05:00
Isaac Wise 4f6368fcbf Format & Lint 2024-04-23 20:53:33 -05:00
Isaac Wise 2b04bcb1df Added Masonry View 2024-04-23 20:48:15 -05:00
Matthew Jacobs 1a96ca32f9 add link actions to readable view 2024-04-24 00:56:00 +00:00
QAComet d37b25c5a2 feat: add github workflow for e2e tests 2024-04-23 09:35:25 -06:00
daniel31x13 7856e76b15 basic user listing 2024-04-22 18:00:59 -04:00
daniel31x13 04547e1bdf update playwright 2024-04-22 08:19:21 -04:00
daniel31x13 f37a4b9c9e replace maskable logo 2024-04-21 19:21:30 -04:00
QAComet 163bf6a0cc test(e2e): add login tests 2024-04-21 17:18:05 -06:00
QAComet 489ad14c3b fix: add additional playwright folders to .gitignore 2024-04-21 17:17:47 -06:00
QAComet 7c14cf7bf1 feat(e2e): add default test setup, update playwright config 2024-04-21 17:17:27 -06:00
QAComet b8d7bd57c8 feat(e2e): add default environment variables 2024-04-21 17:16:50 -06:00
QAComet ce7a94e492 feat(e2e): add fixtures and data seeding 2024-04-21 17:16:30 -06:00
QAComet cd09843b99 feat(e2e): add data-testids to components 2024-04-21 17:15:27 -06:00
Daniel 389db59b28 Merge pull request #570 from QAComet/qacomet/add-toast-button
Add close button and data-testids to toast messages
2024-04-20 10:49:30 -04:00
daniel31x13 b702aa0401 small improvement 2024-04-20 10:49:06 -04:00
daniel31x13 9a92b4d229 code cleanup 2024-04-19 06:16:11 -04:00
QAComet 8278878673 feat: add close button and data-testids to toast messages 2024-04-18 11:34:29 -06:00
daniel31x13 4640c1c966 hotfix 2024-04-18 06:14:28 -04:00
Daniel 49fbbe966c Merge pull request #568 from linkwarden/hotfix/title-fetching
minor fix
2024-04-17 18:31:40 -04:00
daniel31x13 3610e73d3b minor fix 2024-04-17 18:18:50 -04:00
Daniel 76a5dcb90b Merge pull request #567 from linkwarden/hotfix/title-fetching
Hotfix/title fetching
2024-04-17 18:11:03 -04:00
Daniel e51fba41e7 Merge pull request #563 from linkwarden/hotfix/title-fetching
update version number
2024-04-17 18:07:05 -04:00
daniel31x13 e8edd1c9a0 update version number 2024-04-17 18:06:04 -04:00
Daniel f30c652676 Merge pull request #562 from linkwarden/hotfix/title-fetching
added a new env var + bug fixed
2024-04-17 18:03:36 -04:00
daniel31x13 8cf621bc62 added a new env var + bug fixed 2024-04-17 18:02:54 -04:00
Daniel a89274fc03 Merge pull request #507 from GoodM4ven/missing-duplicate-checks
[Enhancement] Accounting for "www." prefix for duplicates
2024-04-15 08:09:10 +03:30
Daniel baadd6c06b Merge branch 'dev' into missing-duplicate-checks 2024-04-15 08:08:22 +03:30
daniel31x13 4a71af8a67 remove trailing slashes + small improvement 2024-04-15 00:37:18 -04:00
daniel31x13 ece09c6f3b minor change 2024-04-09 04:43:20 -04:00
Daniel 189db27c5b Merge pull request #521 from chrisbsmith/authelia
Adds OIDC support for Authelia
2024-04-09 05:20:45 +03:30
Daniel 68d8d403cf Merge pull request #556 from linkwarden/feat/file-uploads
Feat/file uploads
2024-04-09 03:08:11 +03:30
daniel31x13 07b87be7f1 many bug fixes and improvements 2024-04-08 19:35:06 -04:00
daniel31x13 e67fef1d04 progressed file uploads feature (almost done!) 2024-04-01 02:56:54 -04:00
Daniel 87eb2471ff Merge pull request #543 from linkwarden/dev
make the status of the script independent from the app
2024-03-27 19:39:09 +03:30
daniel31x13 58b6f7339c make the status of the script independent from the app 2024-03-27 12:08:19 -04:00
daniel31x13 c659711181 make the status of the script independent from the app 2024-03-27 12:07:29 -04:00
Daniel 5503483502 Merge pull request #542 from linkwarden/dev
Dev
2024-03-27 10:58:27 +03:30
daniel31x13 a6d018fb53 Merge branch 'dev' of https://github.com/linkwarden/linkwarden into dev 2024-03-27 03:28:02 -04:00
daniel31x13 3929f32e63 minor fix 2024-03-27 03:27:59 -04:00
Daniel c08522386b Merge pull request #541 from linkwarden/dev
Dev
2024-03-27 10:52:31 +03:30
Daniel b51a876904 Merge pull request #537 from paulhovey/import_date
Import pinboard description and date
2024-03-27 10:51:39 +03:30
daniel31x13 2e2d7baee1 fix imports 2024-03-27 03:20:00 -04:00
daniel31x13 2b8f7d4be2 code improvements 2024-03-26 01:38:08 -04:00
daniel31x13 797ddc4b73 minor fix 2024-03-24 18:27:21 -04:00
Daniel 237d301f88 Merge pull request #525 from rutkai/archive-singlefile
Add Single file archive method.
2024-03-24 08:11:20 +03:30
daniel31x13 6d7d364853 code formatter + add maskable icon 2024-03-24 00:36:42 -04:00
Paul Hovey 495af0a752 adds description and tags parsing for pinboard html import 2024-03-23 14:57:34 -05:00
Daniel 388b9d9184 Merge pull request #531 from linkwarden/dev
added architecture.md file + renamed license file
2024-03-20 17:27:07 +03:30
daniel31x13 ede3882a94 uncomment code 2024-03-20 09:56:14 -04:00
daniel31x13 e5fcf18fa4 added architecture.md file + renamed license file 2024-03-18 18:36:59 -04:00
Daniel a3d3b353a1 Merge pull request #528 from linkwarden/dev
Dev
2024-03-18 02:41:49 +03:30
daniel31x13 546e216ac9 fix browser extension bug 2024-03-17 19:07:51 -04:00
daniel31x13 ffc037b854 bug fixed 2024-03-16 20:09:58 -04:00
András Rutkai 5fe6a5b19a Add Single file archive method. 2024-03-15 19:43:56 +01:00
Chris Smith cc2d7c863d Add Authelia as a custom oidc source
set a path to browsers outside of /root

Grant root ownership over /data

set umask + perms after yarn build

revert local testing to upstream
2024-03-14 15:01:19 -04:00
Daniel 53a65774f0 Merge pull request #518 from linkwarden/dev
support for arbitrary values in manual installation
2024-03-13 17:26:53 +03:30
daniel31x13 5990d4ce2d support for arbitrary values in manual installation 2024-03-13 09:56:13 -04:00
Daniel ce2eb8eafb Merge pull request #517 from linkwarden/dev
support for other ports in manual installation
2024-03-13 17:21:07 +03:30
daniel31x13 bae4cf1d4f support for other ports in manual installation 2024-03-13 09:48:16 -04:00
Daniel 4e20d71a41 Merge pull request #509 from linkwarden/dev
improved UX + improved performance
2024-03-10 13:39:04 +03:30
daniel31x13 4a0e75c6e5 improved UX + improved performance 2024-03-10 06:08:28 -04:00
GoodM4ven cac90524ed [Enhancement] Accounting for "www." prefix for duplicates 2024-03-08 14:34:56 +03:00
Daniel 9fce74971f Merge pull request #500 from linkwarden/dev
update announcement version number
2024-03-07 02:31:21 +03:30
daniel31x13 3feeecdc1d update announcement version number 2024-03-06 18:00:54 -05:00
Daniel bde7b9aae0 Merge pull request #497 from linkwarden/dev
improved performance
2024-03-06 17:38:45 +03:30
daniel31x13 bda0dc6c87 improved performance 2024-03-06 09:06:38 -05:00
Daniel 7dd254af48 Merge pull request #495 from linkwarden/dev
more efficient logic for the background script
2024-03-06 02:58:42 +03:30
daniel31x13 a57c3114d8 more efficient logic for the background script 2024-03-05 18:28:11 -05:00
Daniel 3969cc5abd Merge pull request #494 from linkwarden/dev
v2.5.0
2024-03-05 22:05:54 +03:30
daniel31x13 252d41886a updated readme 2024-03-05 13:27:15 -05:00
daniel31x13 d8bab2eb24 update version number 2024-03-05 12:13:50 -05:00
daniel31x13 9bfba6037e minor fix 2024-03-05 12:11:40 -05:00
Daniel e59ab23b3d Merge pull request #484 from IsaacWise06/fix-bulk-delete
fix(links): Bulk deleting all links still showing buttons
2024-03-05 17:59:47 +03:30
daniel31x13 01b3b4485e remove old library 2024-03-05 09:23:55 -05:00
daniel31x13 8c76b0d141 minor fix 2024-03-05 09:11:56 -05:00
Daniel d2b867c438 Merge pull request #492 from IsaacWise06/issue/442
feat(links): Allow the user to enable/disable merging duplicates
2024-03-05 17:33:32 +03:30
daniel31x13 f26cd31694 final touch 2024-03-05 09:03:04 -05:00
daniel31x13 8dcd2c67d2 bug fixes 2024-03-05 08:50:47 -05:00
Isaac Wise 750aa294d0 Allow users to enable merging links 2024-03-04 23:24:30 -06:00
Daniel 281b376eac Merge pull request #483 from IsaacWise06/reorder-collections
feat(collections): Reorder top-level collections in the sidebar
2024-03-04 17:26:41 +03:30
daniel31x13 837241186f fully added subcollection tree functionality 2024-03-04 08:56:16 -05:00
daniel31x13 51cf8172ff minor fix 2024-03-02 09:07:33 -05:00
daniel31x13 9c51a65f31 improvements to the ui 2024-03-01 14:02:55 -05:00
daniel31x13 a451e9fa2e minor fix 2024-03-01 09:33:58 -05:00
daniel31x13 ba4860a910 small improvement 2024-03-01 08:37:20 -05:00
daniel31x13 84aeac96ce finished the collection listing ui 2024-03-01 06:59:14 -05:00
daniel31x13 ac70c9e29c refactored collection listing component [WIP] 2024-02-29 00:05:38 -05:00
daniel31x13 f77ef58396 Refactor collection selection component and update navbar styling 2024-02-28 10:33:53 -05:00
daniel31x13 4442ce8705 fix collectionOrder updating + remove index 2024-02-26 23:59:10 -05:00
daniel31x13 4ff7298a3b enable strict mode and fixing the Droppable issue 2024-02-26 22:29:23 -05:00
Daniel a8be4d8f2f Merge pull request #486 from linkwarden/feat/fix-export
bug fix
2024-02-23 20:41:58 +03:30
daniel31x13 f183f122e9 bug fix 2024-02-23 12:11:03 -05:00
Isaac Wise 5164f287d4 format 2024-02-22 03:15:14 -06:00
Isaac Wise 439c562002 Fix deleting all links in a collection with bulk action 2024-02-22 03:14:07 -06:00
Isaac Wise cc02ab3615 removed unused deps 2024-02-22 02:55:04 -06:00
Isaac Wise d2e59d48c2 format 2024-02-22 02:51:24 -06:00
Isaac Wise dbfdb587b6 fix reordering 2024-02-22 02:27:14 -06:00
Isaac Wise 7fd9f5b806 Update order when new collection is created 2024-02-22 02:24:10 -06:00
Isaac Wise 69ac3eb01f Use short-term storage as well 2024-02-22 02:04:01 -06:00
Isaac Wise 44272540aa Make sidebar collections sortable 2024-02-22 01:51:51 -06:00
daniel31x13 0dda77db1e minor improvement 2024-02-19 16:16:53 -05:00
Daniel 60aa7b830e Merge pull request #478 from go-compile/main
feat: Socks5 proxy support
2024-02-20 00:09:16 +03:30
daniel31x13 b6ad2b5900 final touch 2024-02-19 15:38:36 -05:00
daniel31x13 aee1828c15 swapped npm with yarn 2024-02-19 14:42:44 -05:00
Daniel 67bf6b7d75 Merge pull request #476 from IsaacWise06/collection-duplicate-names
feat(collections): Allow collections to be the same name
2024-02-19 23:07:38 +03:30
daniel31x13 bbc2e4c457 final touch 2024-02-19 14:37:07 -05:00
go-compile 1f28d9d461 fix: npm switch to yarn packages 2024-02-19 10:42:07 +00:00
Go Compile df1da9f1f8 Merge branch 'dev' into main 2024-02-18 22:02:35 +00:00
go-compile b476b3ccd4 feat: add deps socks-proxy-agent node-fetch deps for proxy 2024-02-18 21:43:53 +00:00
go-compile ae561ff227 feat: proxy archiver and pdf margin settings 2024-02-18 21:42:51 +00:00
Daniel d438381ebd Merge pull request #477 from linkwarden/fix/imports
Fix/imports
2024-02-18 19:38:31 +03:30
daniel31x13 72266d1cd5 final touch 2024-02-18 11:07:50 -05:00
Isaac Wise f560422427 Allow collections with the same name 2024-02-18 03:32:31 -06:00
daniel31x13 7b7b979b20 refactored import and add support for subcollections 2024-02-17 20:08:34 -05:00
Daniel c3c74b8162 Merge pull request #472 from IsaacWise06/fix/imports
Importing sub-collections fix
2024-02-18 04:33:28 +03:30
Isaac Wise 0e60dee47d Subcollections when importing 2024-02-15 11:26:42 -06:00
daniel31x13 c3f72c4be8 minor cleanup 2024-02-14 10:35:59 -05:00
Daniel 79bd95f650 Merge pull request #466 from IsaacWise06/issue-433
feat(links): Allow users to bulk edit/delete links
2024-02-14 16:41:05 +03:30
daniel31x13 88d73703f8 final touch 2024-02-14 08:10:45 -05:00
daniel31x13 41df9d0c82 minor improvement 2024-02-13 14:35:31 -05:00
daniel31x13 0b2e78332a improvements 2024-02-13 10:55:51 -05:00
Isaac Wise 558ba11db7 Merge branch 'dev' into issue-433 2024-02-13 09:18:11 -06:00
daniel31x13 155c77cbc4 final polishing 2024-02-13 05:54:18 -05:00
Isaac Wise a3c487d074 Don't show the edit button if the user can't edit/delete any links 2024-02-12 01:54:47 -06:00
Isaac Wise 1cff2db876 Fix redirect when there is no tag 2024-02-11 03:17:49 -06:00
Isaac Wise 2112176d6e Fixed disabled buttons 2024-02-11 02:49:27 -06:00
Isaac Wise aef33d859e make entire item clickable when in edit mode 2024-02-11 02:38:41 -06:00
Isaac Wise 5128bd44d8 keep edit buttons visible 2024-02-11 02:02:14 -06:00
Isaac Wise 0a77ee90a7 format 2024-02-11 01:29:11 -06:00
Isaac Wise e2c6993a6d Redirect if the tag does not exist 2024-02-11 01:26:44 -06:00
Isaac Wise e1c4a8575b Checkbox to remove previous tags 2024-02-11 01:21:25 -06:00
Isaac Wise 0c531760e8 Only show edit icon if there are links 2024-02-11 01:08:28 -06:00
Isaac Wise 5f468cd95d Add bulk actions to pinned and all links pages 2024-02-11 01:06:46 -06:00
Isaac Wise 63597a041f Fix sorting links when editing and handle not providing any data 2024-02-11 01:01:52 -06:00
Isaac Wise e753f1dded bulk actions on tags page 2024-02-11 00:19:59 -06:00
Isaac Wise 8ecedf7cae Edit Mode 2024-02-10 23:55:00 -06:00
Isaac Wise 44daffbae6 Use memoization for permission checking 2024-02-10 23:40:26 -06:00
Isaac Wise d5f262200b Get rid of unused import 2024-02-10 22:41:32 -06:00
Isaac Wise ccd3fcb8c1 revert some changes 2024-02-10 19:54:38 -06:00
Isaac Wise 059fcecc5f format 2024-02-10 18:34:25 -06:00
Isaac Wise 58e2fb22c9 Added comment about loop 2024-02-10 18:06:32 -06:00
Isaac Wise 2ace10c058 fix building again 2024-02-10 16:59:00 -06:00
Isaac Wise 4b8f4c4179 fix build error 2024-02-10 16:58:06 -06:00
Isaac Wise 8f62f4dffb Merge branch 'issue-433' of https://github.com/isaacwise06/linkwarden into issue-433 2024-02-10 16:49:37 -06:00
Isaac Wise 95dc3b31db Fix merge conflicts 2024-02-10 16:49:32 -06:00
Isaac Wise ebdeedc2ec Don't show select all if there are no links & fix public view 2024-02-10 16:45:25 -06:00
Isaac Wise 325c41254d center image 2024-02-10 16:35:58 -06:00
Isaac Wise fda782ec44 revert previous change 2024-02-10 16:25:38 -06:00
Isaac Wise 080be856cc Finished editing links 2024-02-10 16:23:59 -06:00
Isaac Wise e1ef638f0e Only show checkbox if the user has perms 2024-02-10 16:04:30 -06:00
Isaac Wise 582607e726 Pass the entire link to the store & fix bulk update function 2024-02-10 15:53:46 -06:00
Daniel 9eaa106766 Merge pull request #463 from IsaacWise06/fix-building
Fixed builds failing
2024-02-10 12:07:20 +03:30
Isaac Wise e0705ece4f Fixed builds failing 2024-02-10 02:34:52 -06:00
Isaac Wise da0533ac36 change routes and add todos 2024-02-10 02:29:15 -06:00
daniel31x13 e3d9912378 fixed the imports 2024-02-10 02:47:58 -05:00
Isaac Wise 26997475fd Fix permission checking 2024-02-10 01:38:19 -06:00
Isaac Wise ea31eb47ae Finished bulk delete links 2024-02-10 00:37:48 -06:00
Isaac Wise 193c66123b Don't show checkboxes on dashboard 2024-02-09 23:56:36 -06:00
Isaac Wise eba9d3c86d Display checkbox on card & reset when collection is changed 2024-02-09 23:51:02 -06:00
Isaac Wise b51355b406 Check all and display actions 2024-02-09 23:43:23 -06:00
Isaac Wise 0a070deebd Added selectedLinks to store & checkbox on list view 2024-02-09 23:24:22 -06:00
daniel31x13 c78aa2da0d minor improvement 2024-02-08 08:48:22 -05:00
Daniel aef55d65a1 Merge pull request #459 from IsaacWise06/issue/367
feat(links): Allow users to choose what happens when they click a link
2024-02-08 17:15:41 +03:30
daniel31x13 efddd55841 change the checkboxes to radio button 2024-02-08 08:45:14 -05:00
daniel31x13 f7a53d53e2 fix update collection bug 2024-02-08 08:25:45 -05:00
Isaac Wise ef08edf1fb Verify the preference is available 2024-02-08 00:59:17 -06:00
Isaac Wise 39261de45e rename function 2024-02-08 00:44:41 -06:00
Isaac Wise cc915c8a64 Allow users to choose what clicking links opens 2024-02-08 00:42:58 -06:00
daniel31x13 7d9cc1f1f0 added "linksRouteTo" field to the prisma schema 2024-02-07 10:30:09 -05:00
daniel31x13 b06cb7c379 merged the appearance and archive page into preference 2024-02-07 10:20:25 -05:00
Daniel d5bd095827 Merge pull request #456 from IsaacWise06/issue/334
feat(collections): Allow a contributor to pin a link from a collection to their dashboard
2024-02-07 18:19:15 +03:30
daniel31x13 daed2d82f4 minor improvements 2024-02-07 09:48:40 -05:00
Daniel 39e022f87b Merge pull request #457 from linkwarden/feat/sub-collections
Feat/sub collections
2024-02-07 01:17:09 +03:30
daniel31x13 3600f6398a fully added support for sub-collections 2024-02-06 16:46:05 -05:00
daniel31x13 392d98f090 bug fixed 2024-02-06 07:46:57 -05:00
daniel31x13 6252b61b89 improved collection page 2024-02-06 07:44:08 -05:00
daniel31x13 00bfdfb926 add support for subcollections to the navbar 2024-02-05 02:42:54 -05:00
Isaac Wise 2d0093172a Allow contributors to pin a link in a shared to a collection to their dashboard 2024-02-04 23:43:59 -06:00
Daniel 34e0115a0f Merge pull request #445 from jan-tee/main
Added env var switch to support screen captures from HTTPS sites with untrusted certificates
2024-02-03 16:36:01 +03:30
daniel31x13 dba2453453 added support for nested collection (backend) 2024-02-03 07:57:29 -05:00
Jan T ae3cf104b7 Added environment variable "IGNORE_SSL_ERRORS" to instruct playwright/Chromium to ignore SSL errors; this is useful to support generation of browser screenshots from sources with self-signed certificates or untrusted CAs, but also opens the possibility to index sites with rejected certificates; so it should not be enabled as a default behavior. 2024-01-29 09:49:50 +01:00
daniel31x13 8534572662 fixed dropdown 2024-01-24 16:19:24 -05:00
Daniel 2901db7035 Merge pull request #441 from linkwarden/feat/api-keys
Feat/api keys
2024-01-25 00:19:17 +03:30
daniel31x13 5be194235c finalized adding support for access tokens 2024-01-24 15:48:40 -05:00
daniel31x13 05563134b4 finished access token creation feature 2024-01-24 12:51:16 -05:00
daniel31x13 39db72a201 improved drawer + added pwa installation notice 2024-01-23 06:29:15 -05:00
daniel31x13 1d14d17e7a bug fix in drawer 2024-01-21 00:02:59 -05:00
daniel31x13 1716e1d408 enhanced modal for mobile view 2024-01-20 01:28:08 -05:00
daniel31x13 4591f8ebc7 many more improvements to the PWA 2024-01-20 00:34:49 -05:00
daniel31x13 86bcd5ef07 improvements to the PWA and mobile layout 2024-01-19 08:01:21 -05:00
daniel31x13 047e156cfb updated version number 2024-01-17 13:02:44 -05:00
Daniel dfe9fec4b4 Merge pull request #431 from linkwarden/main
main
2024-01-17 20:53:24 +03:30
Daniel cf8e409bb3 Merge pull request #430 from linkwarden/hotfix/file-size-error
bypass error
2024-01-17 19:08:25 +03:30
daniel31x13 3565ad3e7c bypass error 2024-01-17 10:30:35 -05:00
daniel31x13 f35bc7b9fd minor fix 2024-01-17 10:25:24 -05:00
daniel31x13 23f4142414 improved responsiveness 2024-01-15 03:39:53 -05:00
daniel31x13 ee3dca92cd improved PWA 2024-01-14 15:40:00 -05:00
daniel31x13 4e47a6bffb improved mobile pwa user experience 2024-01-14 11:16:42 -05:00
daniel31x13 d4f59d7f32 improvements to the pwa 2024-01-14 10:09:09 -05:00
daniel31x13 d91ebb3fa2 added post key route 2024-01-13 01:20:06 -05:00
Daniel 0c78187a10 Merge pull request #414 from linkwarden/dev
minor update to README.md
2024-01-08 10:23:52 -05:00
daniel31x13 834d25a99e minor update to README.md 2024-01-08 10:23:24 -05:00
Daniel bc46f6f64b Merge pull request #407 from linkwarden/dev
updated .env.sample
2024-01-05 14:23:16 -05:00
daniel31x13 a67980b29d updated .env.sample 2024-01-05 14:21:58 -05:00
Daniel 07eb242c26 Merge pull request #400 from linkwarden/dev
updated version
2024-01-02 15:15:41 -05:00
daniel31x13 7880551c4d updated version 2024-01-02 15:15:14 -05:00
Daniel f71acd86df Merge pull request #399 from linkwarden/dev
bug fixed + improved docker image
2024-01-02 15:12:56 -05:00
daniel31x13 98fbb5b678 bug fixed 2024-01-02 15:11:38 -05:00
Daniel 0c2c837028 Merge pull request #398 from modem7/yarn-cache
Implement docker cache mount for yarn
2024-01-02 12:41:34 -05:00
modem7 a5b166f41d implement docker cache mount for yarn 2024-01-02 17:39:50 +00:00
Daniel 89de1829c2 Merge pull request #395 from linkwarden/dev
Revert "updated README.md"
2024-01-02 07:16:56 -05:00
daniel31x13 fbca98984b Revert "updated README.md"
This reverts commit 4da2310e95.
2024-01-02 07:16:11 -05:00
Daniel 06ab784441 Merge pull request #394 from linkwarden/dev
updated README.md
2024-01-02 07:12:50 -05:00
daniel31x13 4da2310e95 updated README.md 2024-01-02 07:12:18 -05:00
Daniel a8f4072f1c Merge pull request #393 from linkwarden/dev
updated SECURITY.md
2024-01-02 07:01:28 -05:00
daniel31x13 93bcfc67fe updated SECURITY.md 2024-01-02 07:01:04 -05:00
Daniel ba49946974 Merge pull request #391 from linkwarden/dev
Dev
2024-01-01 17:13:16 -05:00
Daniel d16b296b15 Merge pull request #390 from QAComet/qacomet/worker-environment-variables
fix: load environment variables in the worker script
2024-01-01 17:12:46 -05:00
QAComet 3fc61ac5ce fix: load environment variables in the worker script 2024-01-01 15:09:55 -07:00
daniel31x13 ced51e4801 minor fix 2024-01-01 10:37:20 -05:00
Daniel 254c090605 Merge pull request #387 from linkwarden/dev
Dev
2023-12-31 16:05:41 -05:00
daniel31x13 2a83ced9d8 updated README 2023-12-31 16:05:18 -05:00
daniel31x13 52d333f085 updated README 2023-12-31 16:03:19 -05:00
Daniel fbbb97b4cd Merge pull request #385 from linkwarden/dev
added an extra environment variable
2023-12-31 10:46:50 -05:00
daniel31x13 4e29330472 added an extra environment variable 2023-12-31 10:46:09 -05:00
Daniel 44c82ff426 Merge pull request #384 from linkwarden/dev
replaced link outer component with <Link> tag for better accessibility
2023-12-31 10:06:06 -05:00
daniel31x13 29e0370808 replaced link outer component with <Link> tag for better accessibility 2023-12-31 10:05:30 -05:00
Daniel 74399c1708 Merge pull request #383 from linkwarden/dev
update version number
2023-12-31 08:03:36 -05:00
daniel31x13 1dde8a6088 update version number 2023-12-31 08:03:15 -05:00
daniel31x13 e872c25332 small improvement + better error handling 2023-12-31 07:55:45 -05:00
Daniel dea1e12700 Merge pull request #379 from linkwarden/dev
bugs fixed
2023-12-30 09:33:39 -05:00
daniel31x13 055869883a bugs fixed 2023-12-30 09:31:53 -05:00
Daniel a5d3926d84 Merge pull request #376 from linkwarden/dev
hotfix
2023-12-30 00:05:30 -05:00
daniel31x13 eee6a807da hotfix 2023-12-30 00:03:45 -05:00
Daniel e24ae15a73 Merge pull request #375 from linkwarden/dev
Dev
2023-12-29 23:59:34 -05:00
daniel31x13 f1dadf1546 throw error if a link was blocking the queue for too long 2023-12-29 23:59:00 -05:00
daniel31x13 ee6dcdcc5b added prettier settings 2023-12-29 12:21:22 -05:00
Daniel e4cf682217 Merge pull request #372 from linkwarden/dev
minor bug fixed
2023-12-29 10:53:41 -05:00
daniel31x13 e4fc8948fa minor bug fixed 2023-12-29 10:52:08 -05:00
Daniel 9cfdb714c3 Merge pull request #366 from linkwarden/dev
bugs fixed
2023-12-27 19:44:32 -05:00
daniel31x13 ca093008b7 bugs fixed 2023-12-27 19:44:11 -05:00
Daniel 3dc99eff9d Merge pull request #364 from linkwarden/dev
updated README
2023-12-27 18:27:59 -05:00
daniel31x13 530e83ba34 updated README 2023-12-27 18:27:30 -05:00
Daniel 4ae75634ca Merge pull request #363 from linkwarden/dev
updated README
2023-12-27 18:17:44 -05:00
daniel31x13 372ed248f1 updated README 2023-12-27 18:17:16 -05:00
Daniel 8b3b7445c3 Merge pull request #362 from linkwarden/dev
minor change to README
2023-12-27 16:06:50 -05:00
daniel31x13 327bb10a08 minor change to README 2023-12-27 16:06:23 -05:00
Daniel f9f2a8ca64 Merge pull request #361 from linkwarden/dev
Dev
2023-12-27 16:02:20 -05:00
daniel31x13 31c56b5009 minor change 2023-12-27 15:52:06 -05:00
daniel31x13 7cf59bd430 minor improvement 2023-12-27 15:10:12 -05:00
daniel31x13 feb18b8c7a minor fix 2023-12-27 15:06:18 -05:00
daniel31x13 ea9f940517 minor fix 2023-12-27 15:01:48 -05:00
daniel31x13 76a0151e43 minor improvement 2023-12-27 15:00:48 -05:00
daniel31x13 a78d9774a7 updated README 2023-12-27 14:55:34 -05:00
daniel31x13 dfbd56acc9 minor fix 2023-12-25 20:43:31 -05:00
daniel31x13 8a34413482 updated README 2023-12-25 11:18:17 -05:00
daniel31x13 2e5f2deee7 minor fix 2023-12-24 10:45:48 -05:00
daniel31x13 320cddf224 updated public page 2023-12-24 07:30:45 -05:00
daniel31x13 86820c402b bug fix + improvements + bundled up the app 2023-12-24 06:46:08 -05:00
daniel31x13 e27fb90f14 finalized link card 2023-12-23 19:00:53 -05:00
daniel31x13 848a33a53e better link card 2023-12-23 12:11:47 -05:00
daniel31x13 98106b9f25 added seed script + renamed table fields + added field 2023-12-22 13:13:43 -05:00
Daniel 385bdc2343 Merge pull request #358 from linkwarden:feat/customizable-view
Feat/customizable-view
2023-12-21 19:25:41 +03:30
daniel31x13 e9b47a69c5 improvements 2023-12-21 10:55:07 -05:00
daniel31x13 1511ee1def minor change 2023-12-21 05:11:26 -05:00
daniel31x13 cb5e6de8b8 better component naming + folder structure 2023-12-21 05:08:56 -05:00
daniel31x13 b7387b1e08 minor change 2023-12-20 10:20:06 -05:00
daniel31x13 c0bca32462 bug fixed 2023-12-20 02:49:07 -05:00
daniel31x13 79bf67f879 minor fix 2023-12-20 02:28:19 -05:00
daniel31x13 8529602252 improved user experience 2023-12-19 18:28:48 -05:00
Daniel ad895eee17 Merge pull request #352 from YeeJiaWei/collection-sharing-layout
Update collection sharing permission to predefined role
2023-12-20 01:52:58 +03:30
Daniel 31cf3c4f01 Merge branch 'dev' into collection-sharing-layout 2023-12-20 01:52:51 +03:30
daniel31x13 55c43d6f9e many bug fixes and improvements 2023-12-19 17:20:09 -05:00
daniel31x13 b65787358f improve link refresh logic + many changes and improvements 2023-12-19 11:50:43 -05:00
Yee Jia Wei 9e2f70d2eb update collection sharing permission to predefined 2023-12-18 14:56:37 +08:00
Daniel 71b99bb25c Merge pull request #351 from YeeJiaWei/collection-background-fix
Fix collection background stretching
2023-12-18 09:21:13 +03:30
Yee Jia Wei 82452555e5 update bg height to fix height 2023-12-18 13:05:41 +08:00
Daniel ce746f33fd Merge pull request #347 from linkwarden/bootstrap-icons
Bootstrap icons
2023-12-18 08:26:13 +03:30
daniel31x13 7131fde897 final touch 2023-12-17 23:55:38 -05:00
Daniel a6e0af6b6e Merge pull request #350 from YeeJiaWei/sidebar-highlight-link
Sidebar highlight link
2023-12-18 08:10:44 +03:30
Daniel f961ec0109 Merge branch 'bootstrap-icons' into sidebar-highlight-link 2023-12-18 08:10:21 +03:30
daniel31x13 1677e132f3 final touch 2023-12-17 23:32:33 -05:00
Daniel 141ca8f60b Merge pull request #349 from YeeJiaWei/bootstrap-icons
Bootstrap icons
2023-12-18 06:49:02 +03:30
Yee Jia Wei fbc083c373 add icon to link create modal
more option collapse icon
2023-12-18 08:30:14 +08:00
Yee Jia Wei feda50464c update missing collection color picker icon 2023-12-18 08:28:52 +08:00
Yee Jia Wei 5e0f38c0d5 update sidebar main link 2023-12-18 07:54:33 +08:00
Yee Jia Wei e9acb548bf remove fontawesome package 2023-12-18 06:28:42 +08:00
Yee Jia Wei e1f036adb2 minor fix 2023-12-18 06:06:55 +08:00
Yee Jia Wei 1da960a3cf update public list icons 2023-12-18 06:06:49 +08:00
Yee Jia Wei 8c73ce60e9 update collection sharing model icons 2023-12-18 05:59:52 +08:00
Yee Jia Wei a481903b50 update tag page's icons 2023-12-18 05:44:06 +08:00
Yee Jia Wei 6632c0507b remove icon from accent submit button
as it is not used currently
2023-12-18 05:42:25 +08:00
Yee Jia Wei fc679d1150 update collection page icon 2023-12-17 17:32:59 +08:00
Yee Jia Wei d849b37f6c update setting pages's icon 2023-12-17 17:32:33 +08:00
Yee Jia Wei dc6c17f8c4 update setting sidebar icons 2023-12-17 17:18:37 +08:00
Yee Jia Wei e2cf627ccd update not link found icons 2023-12-17 17:18:37 +08:00
Yee Jia Wei de13a109c6 update readable view icons 2023-12-17 17:18:37 +08:00
Yee Jia Wei 15c6213840 update modal icons 2023-12-17 17:18:37 +08:00
Yee Jia Wei 42f9dacffd update announcement bar icon 2023-12-17 17:18:37 +08:00
Yee Jia Wei 4210913277 update search page icons 2023-12-17 17:18:37 +08:00
Yee Jia Wei 5f095b5631 minor fix 2023-12-17 16:47:32 +08:00
Yee Jia Wei cf1306d2c4 update collection action icons 2023-12-17 16:46:21 +08:00
Yee Jia Wei 500f7a338c update link component icons 2023-12-17 16:30:09 +08:00
Yee Jia Wei e910172558 update link action icons 2023-12-17 16:22:08 +08:00
Yee Jia Wei d906391ae2 minor fix 2023-12-17 15:35:09 +08:00
Daniel ae541bf2f5 Merge pull request #346 from YeeJiaWei/bootstrap-icons
Bootstrap icons
2023-12-17 10:02:57 +03:30
Daniel c28c73ce18 Merge branch 'dev' into bootstrap-icons 2023-12-17 10:00:44 +03:30
Daniel f7d8ff9881 Merge pull request #345 from linkwarden/feat/multiple-link-views
Feat/multiple link views
2023-12-17 09:56:54 +03:30
daniel31x13 c5b083e802 implemented list view in other components as well 2023-12-17 01:25:46 -05:00
daniel31x13 4d691e0cce minor fix 2023-12-17 00:11:15 -05:00
daniel31x13 f5e7e373a8 improvements 2023-12-16 15:06:26 -05:00
Yee Jia Wei ef33f2c948 update link, collection header icons 2023-12-16 23:34:29 +08:00
Yee Jia Wei d976761280 add page header component 2023-12-16 23:34:29 +08:00
Yee Jia Wei 04ede17bfd update homepage icons 2023-12-16 23:11:35 +08:00
Yee Jia Wei 9119402dac update navbar icons 2023-12-16 22:46:29 +08:00
Yee Jia Wei d52afd66f3 update sidebar icons 2023-12-16 22:16:25 +08:00
Yee Jia Wei ae87b5698e add bootstrap-icons package 2023-12-16 22:15:38 +08:00
Daniel 1955cca589 Merge pull request #342 from YeeJiaWei/compact-list-view
link compact list view
2023-12-16 16:04:26 +03:30
Yee Jia Wei 8df0eab2a2 add store to localstorage 2023-12-16 12:57:50 +08:00
Yee Jia Wei e0bb7ffa08 update responsive 2023-12-16 12:16:56 +08:00
Yee Jia Wei 7c35fe409f Update LinkCard.tsx 2023-12-16 12:08:28 +08:00
Yee Jia Wei ce9b4b05d4 smaller link icon size 2023-12-16 12:05:35 +08:00
Yee Jia Wei b246cdbc44 change link icon to component 2023-12-16 12:05:19 +08:00
Yee Jia Wei a2b1513dbc remove duplicated link 2023-12-16 11:52:50 +08:00
Yee Jia Wei bcfbdf3e49 link compact list view 2023-12-16 11:25:39 +08:00
daniel31x13 f8ad08f5ed improvements 2023-12-15 16:18:54 -05:00
daniel31x13 530ec69d1c better UX + more consistent layout 2023-12-15 15:47:08 -05:00
daniel31x13 b74ff01ce6 finalized archiveHandler/background worker logic 2023-12-13 17:32:01 -05:00
daniel31x13 a001f70b9d improvements 2023-12-13 06:59:36 -05:00
daniel31x13 ca3eb29c48 Merge branch 'dev' of https://github.com/linkwarden/linkwarden into dev 2023-12-13 06:33:14 -05:00
Daniel 9af695deaf Merge pull request #339 from treyg/manifest-name-update
fix: update manifest name for PWA display
2023-12-13 15:02:48 +03:30
daniel31x13 650fa693bd minor changes 2023-12-12 13:26:53 -05:00
daniel31x13 099024518f minor fix 2023-12-12 10:43:40 -05:00
Trey Gordon 6d227750c3 fix: update manifest name for PWA display 2023-12-11 15:35:44 -05:00
daniel31x13 6ba2aab0ba improved archive logic 2023-12-11 03:05:47 -05:00
daniel31x13 375a55dd37 added auto-archive script + minor improvements 2023-12-10 15:26:44 -05:00
daniel31x13 8e49ccf723 minor change 2023-12-08 11:01:47 -05:00
Daniel ab83d1d0c6 Merge pull request #332 from linkwarden/feat/refactor-login
Feat/refactor login
2023-12-08 01:14:51 +03:30
daniel31x13 fc9de564b6 minor improvements 2023-12-07 16:33:01 -05:00
daniel31x13 8786f8b5fe minor fix 2023-12-07 12:41:27 -05:00
Daniel a6a9402425 Merge pull request #327 from shxshxshxshx/feature/login-refactor
feat: refactored login
2023-12-07 21:05:14 +03:30
Daniel b36dd49e16 Merge branch 'feat/extra-login-providers' into feature/login-refactor 2023-12-07 21:05:02 +03:30
Daniel add781451a Merge pull request #331 from linkwarden/feat/extra-login-providers
Feat/Added Authentic Support
2023-12-07 21:02:17 +03:30
daniel31x13 e6979d4e75 visual improvements 2023-12-07 12:29:45 -05:00
Sebastian Hierholzer 9868ab61c9 fix: typo 2023-12-07 14:49:56 +01:00
Sebastian Hierholzer c367992116 fix: update .env.sample 2023-12-07 13:26:34 +01:00
Sebastian Hierholzer 64d361fa23 fix: undefined variables failing to parse 2023-12-07 13:25:34 +01:00
Sebastian Hierholzer 760c0b0026 Merge remote-tracking branch 'origin/feature/login-refactor' into feature/login-refactor 2023-12-07 13:06:55 +01:00
Sebastian Hierholzer 3f4b7117bd fix: add getLogins boilerplate 2023-12-07 13:06:19 +01:00
Sebastian Hierholzer 361795ed47 feat: refactored login 2023-12-07 13:06:11 +01:00
daniel31x13 93e4897c0b code formatting 2023-12-07 06:35:08 -05:00
Daniel 5b44bbcf59 Merge pull request #321 from Jacq/main
Added Authentik provider and option to disable standard login
2023-12-07 09:15:41 +03:30
Daniel 8ba2cecf06 Merge branch 'feat/extra-login-providers' into main 2023-12-07 09:14:25 +03:30
Daniel 9cd165f2ce Merge pull request #330 from linkwarden/feat/handle-files
Feat/handle files
2023-12-07 09:04:18 +03:30
daniel31x13 ce5b1f444a updated links id page 2023-12-07 00:33:05 -05:00
daniel31x13 4b1017f45b [WIP] 2023-12-06 16:13:11 -05:00
daniel31x13 6f77882ffc minor change 2023-12-05 15:21:28 -05:00
daniel31x13 f8811a49c0 improved UI 2023-12-05 15:17:36 -05:00
daniel31x13 0e6b47d068 improved UI 2023-12-05 04:39:01 -05:00
daniel31x13 a3106e072b improved UI 2023-12-05 04:12:48 -05:00
daniel31x13 1f20180a51 fixes and improvements 2023-12-04 10:24:45 -05:00
daniel31x13 ee05975e10 minor change 2023-12-03 23:52:50 -05:00
daniel31x13 9c65e3e215 implemented basic support for pdf, png and jpg 2023-12-03 23:52:32 -05:00
Sebastian Hierholzer ba72de19ef fix: add getLogins boilerplate 2023-12-03 22:19:02 +01:00
Sebastian Hierholzer ffc927759e feat: refactored login 2023-12-03 21:01:28 +01:00
daniel31x13 33be9e5d83 minor patch 2023-12-02 15:23:26 -05:00
daniel31x13 c447b36540 minor fix 2023-12-02 13:34:28 -05:00
daniel31x13 18e0b8b010 removed extra fields 2023-12-02 13:33:07 -05:00
Daniel 2042b94680 Merge pull request #325 from linkwarden/dev
Dev
2023-12-02 21:58:44 +03:30
Daniel 104c79cd99 Merge branch 'feat/handle-files' into dev 2023-12-02 21:58:23 +03:30
Daniel 1b1e4108ec Merge pull request #324 from linkwarden/feat/improve-ui-ux-1
added daisyUI components
2023-12-02 21:21:44 +03:30
daniel31x13 e253485e3d minor improvement to link card 2023-12-02 12:50:24 -05:00
daniel31x13 e2a5f36008 minor improvement to link cards 2023-12-02 12:48:34 -05:00
daniel31x13 17721c91b6 improved UX 2023-12-02 04:47:53 -05:00
daniel31x13 230110e912 added confirmation when deleting a link + more spacing for dropdowns 2023-12-02 04:42:51 -05:00
daniel31x13 4ac7110fb4 minor improvement 2023-12-01 17:48:48 -05:00
daniel31x13 e8a91bb551 renamed modal contents 2023-12-01 17:44:34 -05:00
daniel31x13 9e4502c015 improved DX 2023-12-01 17:42:45 -05:00
daniel31x13 a36769c521 many bug fixes + add links and collections together + more changes 2023-12-01 16:29:17 -05:00
daniel31x13 a3c6d9b42e more modals replaced 2023-12-01 14:00:52 -05:00
daniel31x13 2c9541734a minor improvement 2023-12-01 12:23:40 -05:00
daniel31x13 d40373032a replaced viewteam modal with new modal 2023-12-01 12:17:00 -05:00
daniel31x13 732a5227d3 recreated modals and many other components 2023-12-01 12:01:56 -05:00
daniel31x13 6d51b6de53 minor change 2023-11-30 11:47:24 -05:00
daniel31x13 2fd21c8219 redesigned link collection page 2023-11-30 06:55:37 -05:00
daniel31x13 cfc308f521 refactoring [WIP] 2023-11-30 06:13:42 -05:00
daniel31x13 5850a423f9 minor fix 2023-11-30 04:39:51 -05:00
daniel31x13 bef8ad976d recreated the rest of the dropdowns using daisyui 2023-11-30 04:36:40 -05:00
daniel31x13 64a1f352cf recreated SortDropdown components 2023-11-29 15:17:51 -05:00
daniel31x13 93e0fe6172 improved modals 2023-11-29 09:41:24 -05:00
daniel31x13 692b9b99e7 implemented new modal 2023-11-29 00:46:31 -05:00
daniel31x13 3b2b9e8279 new modal [WIP] 2023-11-28 14:24:52 -05:00
daniel31x13 82b743fa8d new modal [WIP] 2023-11-28 05:39:45 -05:00
daniel31x13 1ca6d72f82 improved collection card and avatar grouping 2023-11-28 00:29:11 -05:00
daniel31x13 916c69602d recreated many components 2023-11-27 16:38:38 -05:00
Jacq b1dd9d66b6 Update [...nextauth].ts
Added Authentik provider
Added option to disable standard login with NEXT_PUBLIC_DISABLE_LOGIN=true
2023-11-26 12:53:51 +01:00
daniel31x13 b51b08b0f4 changed classNames 2023-11-26 05:17:08 -05:00
daniel31x13 0a398d1fd9 daisy ui [WIP] 2023-11-25 05:54:43 -05:00
daniel31x13 d53dd93bb7 swapped some gray colors with neutral 2023-11-25 05:39:56 -05:00
daniel31x13 3c9d171f4d minor change 2023-11-25 03:33:17 -05:00
daniel31x13 af80614b3a bug fixed 2023-11-25 03:27:34 -05:00
daniel31x13 b88fa446be webpages can now be a image or pdf 2023-11-25 03:19:02 -05:00
daniel31x13 a33d68c03a Merge branch 'use-daisyui-for-better-dx' of https://github.com/linkwarden/linkwarden into use-daisyui-for-better-dx 2023-11-24 13:59:32 -05:00
Daniel ba7024db83 Merge pull request #317 from linkwarden/dev
Dev
2023-11-24 22:27:45 +03:30
Daniel 0f40578ca9 Merge pull request #316 from linkwarden/dev
bug fixed
2023-11-24 21:59:16 +03:30
daniel31x13 676c7c3a5d bug fixed 2023-11-24 13:28:47 -05:00
Daniel 544585afd9 Merge pull request #315 from linkwarden/dev
Dev
2023-11-24 21:22:49 +03:30
daniel31x13 87196b1190 minor change 2023-11-24 12:52:07 -05:00
daniel31x13 94d1bbbfba bug fixed 2023-11-24 12:51:43 -05:00
daniel31x13 cbd0ec6aa7 minor change 2023-11-24 12:39:54 -05:00
Daniel f78eefbb3b Merge pull request #314 from linkwarden/dev
v2.3.0
2023-11-24 20:52:57 +03:30
daniel31x13 a8172a9dbe replace tw colors with semantic colors [WIP] 2023-11-24 09:39:38 -05:00
daniel31x13 75d4fce8ec removed extra classNames 2023-11-24 08:39:55 -05:00
daniel31x13 73954fe78e minor change 2023-11-24 08:09:40 -05:00
daniel31x13 14f9378375 minor fix 2023-11-24 08:07:08 -05:00
daniel31x13 3afd5fef6e used daisyUI for dark mode 2023-11-24 07:50:16 -05:00
daniel31x13 b8b6fe24bc WIP 2023-11-24 03:06:33 -05:00
daniel31x13 828e8eae2e updated README 2023-11-23 15:47:34 -05:00
daniel31x13 1b53fb139d updated README 2023-11-23 15:43:38 -05:00
daniel31x13 c5d9f2c127 improvements 2023-11-23 09:03:47 -05:00
daniel31x13 f25b83bc09 updated README 2023-11-23 05:20:21 -05:00
daniel31x13 9a15ca9684 updated README 2023-11-20 16:03:38 -05:00
daniel31x13 557494747d improvements + updated README 2023-11-20 15:58:32 -05:00
daniel31x13 5968bc6c9c temporarily disabled daisyUI 2023-11-20 12:53:15 -05:00
daniel31x13 cf7b18e012 added apikey model 2023-11-20 12:48:41 -05:00
daniel31x13 9ad277c784 bug fixed 2023-11-19 22:32:23 -05:00
daniel31x13 9f181fb15e bug fixed 2023-11-19 22:28:02 -05:00
daniel31x13 0c6911aaf0 updated route + bug fixed 2023-11-19 16:22:27 -05:00
daniel31x13 bd16136946 minor change to README 2023-11-19 14:45:44 -05:00
daniel31x13 9eee3eea1d minor change to README 2023-11-19 14:44:41 -05:00
daniel31x13 0579395e93 minor change 2023-11-19 14:40:11 -05:00
daniel31x13 988d647521 more concise .env.sample 2023-11-19 09:19:35 -05:00
daniel31x13 e628b3a6d5 rename "Refresh Format" to "Refresh Link" 2023-11-19 09:15:29 -05:00
Daniel c73f13a9b0 Merge pull request #310 from linkwarden/keycloak-integration
Keycloak integration
2023-11-19 17:30:10 +03:30
daniel31x13 9a28552af5 bug fix 2023-11-19 08:56:03 -05:00
daniel31x13 9938d21499 minor changes 2023-11-19 08:38:05 -05:00
Daniel eb78fb71d9 Merge pull request #284 from BTLzdravtech/dev
feat: Basic support for Keycloak (OIDC) + fix s3 integration + custom s3 (minio) support
2023-11-19 16:55:09 +03:30
Daniel e2fdb11a67 Merge pull request #309 from linkwarden/improved-public-page
minor fix
2023-11-19 16:53:49 +03:30
daniel31x13 e2f9439d40 minor fix 2023-11-19 08:18:10 -05:00
Daniel 30c9c86e22 Merge pull request #308 from linkwarden/improved-public-page
Improved public page
2023-11-19 16:43:24 +03:30
daniel31x13 614d92f050 finished the public page 2023-11-19 08:12:37 -05:00
daniel31x13 b50ec09727 minor fix 2023-11-16 06:52:19 -05:00
daniel31x13 01602bafec improved public page [WIP] 2023-11-16 06:51:28 -05:00
daniel31x13 d972ec2dab better public page [WIP] 2023-11-16 03:22:16 -05:00
daniel31x13 021f7c9481 added view team modal 2023-11-15 23:31:13 -05:00
daniel31x13 59815f47d8 refactored public page endpoints 2023-11-15 13:12:06 -05:00
Daniel b868318548 Merge pull request #299 from linkwarden/dev
swapped changelog url
2023-11-12 00:45:27 +03:30
daniel31x13 09ee81bf11 swapped changelog url 2023-11-11 16:15:07 -05:00
Daniel 31663faa5a Merge pull request #298 from linkwarden/dev
small change
2023-11-12 00:41:47 +03:30
daniel31x13 88a8c21aa4 small change 2023-11-11 16:11:08 -05:00
Daniel cd8081e610 Merge pull request #297 from linkwarden/dev
minor fix
2023-11-12 00:37:26 +03:30
daniel31x13 1e0aaed833 minor fix 2023-11-11 16:06:50 -05:00
Daniel 34eec78ba4 Merge pull request #296 from linkwarden/dev
minor visual changes and fixes
2023-11-12 00:28:25 +03:30
daniel31x13 11c834c61b minor visual changes and fixes 2023-11-11 15:56:45 -05:00
Daniel aefcd6d311 Merge pull request #295 from linkwarden/dev
Dev
2023-11-11 23:31:47 +03:30
daniel31x13 dd09fd9026 update version number 2023-11-11 15:00:48 -05:00
daniel31x13 b19d6694ec add route for pinned links + better dashboard UX 2023-11-11 14:57:46 -05:00
daniel31x13 49b1ea4875 more customizable link icons 2023-11-11 14:00:38 -05:00
Daniel ea82fb5825 Merge pull request #291 from linkwarden/visual-improvements
Visual improvements
2023-11-11 07:37:25 +03:30
daniel31x13 e3b32fd791 improved dashboard design + blurred icons based on personal preferences 2023-11-10 22:32:56 -05:00
daniel31x13 3dfbccaf23 better looking dashboard 2023-11-09 11:44:49 -05:00
Tomáš Hruška 836dc10c2b fix: s3 integration + custom s3 (minio) support 2023-11-09 11:41:29 +01:00
Tomáš Hruška 946eed3773 feat: basic support for Keycloak (OIDC) 2023-11-09 11:41:08 +01:00
Daniel 359507c014 Merge pull request #278 from linkwarden/dev
minor fix
2023-11-08 03:01:41 +03:30
daniel31x13 518b94b1f4 minor fix 2023-11-07 18:30:55 -05:00
Daniel f21033bd7a Merge pull request #277 from linkwarden/dev
Dev
2023-11-08 02:52:06 +03:30
daniel31x13 fbc1d4b113 hardcoded import size limit to 10mb to pass build error 2023-11-07 18:21:27 -05:00
daniel31x13 dba62d7028 updated README 2023-11-07 17:55:11 -05:00
Daniel 3aafc0960c Merge pull request #274 from linkwarden/dev
Linkwarden v2.0
2023-11-07 23:25:42 +03:30
daniel31x13 a2f03ff468 added hover effect to announcement bar components 2023-11-07 14:54:37 -05:00
daniel31x13 c300da172b added url to announcment bar 2023-11-07 14:51:22 -05:00
daniel31x13 2f4af7f3d9 added announcement bar 2023-11-07 13:06:42 -05:00
daniel31x13 cb5b1751c0 bug fix 2023-11-07 08:03:35 -05:00
daniel31x13 6f5245cbc4 minor change 2023-11-06 10:54:39 -05:00
daniel31x13 9bee9b8ae4 bug fix 2023-11-06 10:06:14 -05:00
daniel31x13 7bdef522c1 bug fixed 2023-11-06 10:01:39 -05:00
daniel31x13 c8edc3844b code refactoring + many security/bug fixes 2023-11-06 08:25:57 -05:00
daniel31x13 b5a28f68ad remove tag functionality 2023-11-03 00:09:50 -04:00
daniel31x13 ae1889e757 support for bearer tokens 2023-11-02 14:59:31 -04:00
daniel31x13 b458fad567 WIP changes 2023-11-02 01:52:49 -04:00
daniel31x13 b1b0d98eb2 search by text content functionality 2023-11-01 06:01:26 -04:00
daniel31x13 b1c6a3faf1 readable format styling 2023-10-31 18:02:41 -04:00
daniel31x13 56a281ae3d rearchive protection 2023-10-31 15:44:58 -04:00
daniel31x13 ccafc997fc minor change 2023-10-31 05:41:19 -04:00
daniel31x13 417c16d08b minor UI change 2023-10-31 05:39:05 -04:00
daniel31x13 dbeefecec6 better design 2023-10-31 05:35:45 -04:00
daniel31x13 35665ce292 minor fix 2023-10-30 15:21:16 -04:00
daniel31x13 fb61812356 fully added reader view support 2023-10-30 15:20:15 -04:00
daniel31x13 ed91c4267b changed readable format to json 2023-10-30 00:50:43 -04:00
daniel31x13 c9c62b615b finished implementing readable mode api side 2023-10-30 00:30:45 -04:00
daniel31x13 de20fb7bc1 minor change 2023-10-29 12:56:38 -04:00
daniel31x13 16024f40be added new api route + fixed dropdown 2023-10-29 00:57:24 -04:00
daniel31x13 2856e23a4a fixed the dropdown 2023-10-28 12:50:11 -04:00
daniel31x13 db47a2a142 [WIP] dropdown bug 2023-10-28 07:20:35 -04:00
daniel31x13 ac795cdbdc added rearchive functionallity + dropdown fix [WIP] 2023-10-28 05:57:53 -04:00
daniel31x13 9b6038201c bug fixed 2023-10-28 01:46:51 -04:00
daniel31x13 9486d699c9 bug fixed 2023-10-28 01:42:31 -04:00
daniel31x13 cdcfabec0b refactored how avatars are being handled 2023-10-28 00:45:14 -04:00
Daniel f9eedadb9f Merge pull request #265 from linkwarden/dependabot/npm_and_yarn/crypto-js-4.2.0
Bump crypto-js from 4.1.1 to 4.2.0
2023-10-27 23:04:37 -04:00
daniel31x13 c08e7d4580 updated prisma schema 2023-10-27 16:06:42 -04:00
daniel31x13 ea86737835 bugs fixed 2023-10-26 18:49:46 -04:00
dependabot[bot] 788fc56caf Bump crypto-js from 4.1.1 to 4.2.0
Bumps [crypto-js](https://github.com/brix/crypto-js) from 4.1.1 to 4.2.0.
- [Commits](https://github.com/brix/crypto-js/compare/4.1.1...4.2.0)

---
updated-dependencies:
- dependency-name: crypto-js
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
2023-10-25 23:23:38 +00:00
daniel31x13 966136dab6 created migration script [WIP] 2023-10-25 15:42:36 -04:00
Daniel 4454e615b6 Merge pull request #262 from linkwarden/dev
Dev
2023-10-24 17:23:14 -04:00
Daniel 91748ac5d2 Update issue templates 2023-10-24 17:22:45 -04:00
daniel31x13 2be2a83c62 minor fix 2023-10-24 17:11:25 -04:00
daniel31x13 c38c5b2cc5 improved user experience 2023-10-24 17:03:33 -04:00
daniel31x13 cb8c2d5f10 finished adding profile deletion functionality + bug fix 2023-10-24 15:57:37 -04:00
Daniel 8fdc503f55 Merge pull request #258 from linkwarden/dev
Dev
2023-10-23 15:25:19 -04:00
daniel31x13 97d8c35d2a added delete user endpoint 2023-10-23 15:24:22 -04:00
Daniel 4ffbc4e2f6 Update issue templates 2023-10-23 22:04:44 +03:30
daniel31x13 4252b79586 added recent links to dashboard 2023-10-23 10:45:48 -04:00
Daniel ee4554ae95 Merge pull request #253 from linkwarden/dev
fixed UI bug
2023-10-23 01:56:24 -04:00
daniel31x13 697b139493 fixed UI bug 2023-10-23 01:55:44 -04:00
Daniel a4ea023c51 Merge pull request #252 from linkwarden/dev
Dev
2023-10-23 01:46:23 -04:00
daniel31x13 bcae97a296 bug fixed 2023-10-23 01:45:31 -04:00
Daniel 565ee92d20 Merge pull request #236 from YeeJiaWei/login-with-enter
html form for login & register using enter key
2023-10-23 01:21:05 -04:00
daniel31x13 ec4bfa6ba9 merged "AuthSubmitButton" with the "SubmitButton" + updated the other pages that needed this change 2023-10-23 01:20:08 -04:00
Daniel 68f0f03d0d Merge pull request #251 from linkwarden/main
update issue templates
2023-10-23 00:31:39 -04:00
Daniel 86cfdd508a Merge pull request #250 from linkwarden/dev
refactored/cleaned up API + added support for renaming tags
2023-10-23 00:30:30 -04:00
daniel31x13 ed24685aaf refactored/cleaned up API + added support for renaming tags 2023-10-23 00:28:39 -04:00
Daniel 84d4153b5c Update issue templates 2023-10-23 00:26:26 -04:00
Daniel ad525b8b00 Merge pull request #243 from linkwarden/dev
increase timeout to pass github actions arm64 build
2023-10-20 23:59:26 -04:00
daniel31x13 24cced9dba increase timeout to pass github actions arm64 build 2023-10-20 23:58:38 -04:00
Daniel 3626ea613c Merge pull request #242 from linkwarden/dev
minor change to DockerFile
2023-10-20 23:07:25 -04:00
daniel31x13 aaebdc5da7 minor change to DockerFile 2023-10-20 23:06:09 -04:00
Daniel 748f181bc2 Merge pull request #241 from linkwarden/dev
downgrade node to pass build
2023-10-20 22:50:10 -04:00
daniel31x13 d7705b585e downgrade node to pass build 2023-10-20 22:49:43 -04:00
Daniel a996dda349 Merge pull request #240 from linkwarden/dev
minor cleanup + updated twitter handle
2023-10-20 15:35:17 -04:00
daniel31x13 db389c05a8 minor cleanup + updated twitter handle 2023-10-20 15:34:47 -04:00
Yee Jia Wei b3295e136d change login and register to form 2023-10-19 13:46:40 +08:00
419 changed files with 33728 additions and 7421 deletions
+13 -13
View File
@@ -1,22 +1,22 @@
// For format details, see https://aka.ms/devcontainer.json. For config options, see the
// README at: https://github.com/devcontainers/templates/tree/main/src/typescript-node
{
"name": "Node.js & TypeScript",
// Or use a Dockerfile or Docker Compose file. More info: https://containers.dev/guide/dockerfile
"image": "mcr.microsoft.com/devcontainers/typescript-node:1-20-bullseye",
"name": "Node.js & TypeScript",
// Or use a Dockerfile or Docker Compose file. More info: https://containers.dev/guide/dockerfile
"image": "mcr.microsoft.com/devcontainers/typescript-node:1-20-bullseye",
// Features to add to the dev container. More info: https://containers.dev/features.
// "features": {},
// Features to add to the dev container. More info: https://containers.dev/features.
// "features": {},
// Use 'forwardPorts' to make a list of ports inside the container available locally.
// "forwardPorts": [],
// Use 'forwardPorts' to make a list of ports inside the container available locally.
// "forwardPorts": [],
// Use 'postCreateCommand' to run commands after the container is created.
// "postCreateCommand": "yarn install",
// Use 'postCreateCommand' to run commands after the container is created.
// "postCreateCommand": "yarn install",
// Configure tool-specific properties.
// "customizations": {},
// Configure tool-specific properties.
// "customizations": {},
// Uncomment to connect as root instead. More info: https://aka.ms/dev-containers-non-root.
"remoteUser": "root"
// Uncomment to connect as root instead. More info: https://aka.ms/dev-containers-non-root.
"remoteUser": "root"
}
+408 -14
View File
@@ -1,33 +1,427 @@
NEXTAUTH_SECRET=very_sensitive_secret
DATABASE_URL=postgresql://user:password@localhost:5432/linkwarden
NEXTAUTH_URL=http://localhost:3000
NEXTAUTH_URL=http://localhost:3000/api/v1/auth
NEXTAUTH_SECRET=
# Manual installation database settings
# Example: DATABASE_URL=postgresql://user:password@localhost:5432/linkwarden
DATABASE_URL=
# Docker installation database settings
POSTGRES_PASSWORD=
# Additional Optional Settings
PAGINATION_TAKE_COUNT=
STORAGE_FOLDER=
AUTOSCROLL_TIMEOUT=
NEXT_PUBLIC_DISABLE_REGISTRATION=
NEXT_PUBLIC_CREDENTIALS_ENABLED=
DISABLE_NEW_SSO_USERS=
RE_ARCHIVE_LIMIT=
MAX_LINKS_PER_USER=
ARCHIVE_TAKE_COUNT=
BROWSER_TIMEOUT=
IGNORE_UNAUTHORIZED_CA=
IGNORE_HTTPS_ERRORS=
IGNORE_URL_SIZE_LIMIT=
NEXT_PUBLIC_DEMO=
NEXT_PUBLIC_DEMO_USERNAME=
NEXT_PUBLIC_DEMO_PASSWORD=
NEXT_PUBLIC_ADMIN=
NEXT_PUBLIC_MAX_FILE_BUFFER=
MONOLITH_MAX_BUFFER=
MONOLITH_CUSTOM_OPTIONS=
PDF_MAX_BUFFER=
SCREENSHOT_MAX_BUFFER=
READABILITY_MAX_BUFFER=
PREVIEW_MAX_BUFFER=
IMPORT_LIMIT=
PLAYWRIGHT_LAUNCH_OPTIONS_EXECUTABLE_PATH=
MAX_WORKERS=
DISABLE_PRESERVATION=
# AWS S3 Settings
SPACES_KEY=
SPACES_SECRET=
SPACES_ENDPOINT=
SPACES_BUCKET_NAME=
SPACES_REGION=
SPACES_FORCE_PATH_STYLE=
# SMTP Settings
NEXT_PUBLIC_EMAIL_PROVIDER=
EMAIL_FROM=
EMAIL_SERVER=
BASE_URL=
# Stripe settings (You don't need these, it's for the cloud instance payments)
NEXT_PUBLIC_STRIPE_IS_ACTIVE=
STRIPE_SECRET_KEY=
MONTHLY_PRICE_ID=
YEARLY_PRICE_ID=
NEXT_PUBLIC_TRIAL_PERIOD_DAYS=
NEXT_PUBLIC_STRIPE_BILLING_PORTAL_URL=
BASE_URL=http://localhost:3000
# Proxy settings
PROXY=
PROXY_USERNAME=
PROXY_PASSWORD=
PROXY_BYPASS=
# Docker postgres settings
POSTGRES_PASSWORD=
# PDF archive settings
PDF_MARGIN_TOP=
PDF_MARGIN_BOTTOM=
#################
# SSO Providers #
#################
# 42 School
NEXT_PUBLIC_FORTYTWO_ENABLED=
FORTYTWO_CUSTOM_NAME=
FORTYTWO_CLIENT_ID=
FORTYTWO_CLIENT_SECRET=
# Apple
NEXT_PUBLIC_APPLE_ENABLED=
APPLE_CUSTOM_NAME=
APPLE_ID=
APPLE_SECRET=
# Atlassian
NEXT_PUBLIC_ATLASSIAN_ENABLED=
ATLASSIAN_CUSTOM_NAME=
ATLASSIAN_CLIENT_ID=
ATLASSIAN_CLIENT_SECRET=
ATLASSIAN_SCOPE=
# Auth0
NEXT_PUBLIC_AUTH0_ENABLED=
AUTH0_CUSTOM_NAME=
AUTH0_ISSUER=
AUTH0_CLIENT_SECRET=
AUTH0_CLIENT_ID=
# Authelia
NEXT_PUBLIC_AUTHELIA_ENABLED=""
AUTHELIA_CLIENT_ID=""
AUTHELIA_CLIENT_SECRET=""
AUTHELIA_WELLKNOWN_URL=""
# Authentik
NEXT_PUBLIC_AUTHENTIK_ENABLED=
AUTHENTIK_CUSTOM_NAME=
AUTHENTIK_ISSUER=
AUTHENTIK_CLIENT_ID=
AUTHENTIK_CLIENT_SECRET=
# Azure AD B2C
NEXT_PUBLIC_AZURE_AD_B2C_ENABLED=
AZURE_AD_B2C_TENANT_NAME=
AZURE_AD_B2C_CLIENT_ID=
AZURE_AD_B2C_CLIENT_SECRET=
AZURE_AD_B2C_PRIMARY_USER_FLOW=
# Azure AD
NEXT_PUBLIC_AZURE_AD_ENABLED=
AZURE_AD_CLIENT_ID=
AZURE_AD_CLIENT_SECRET=
AZURE_AD_TENANT_ID=
# Battle.net
NEXT_PUBLIC_BATTLENET_ENABLED=
BATTLENET_CUSTOM_NAME=
BATTLENET_CLIENT_ID=
BATTLENET_CLIENT_SECRET=
BATTLENET_ISSUER=
# Box
NEXT_PUBLIC_BOX_ENABLED=
BOX_CUSTOM_NAME=
BOX_CLIENT_ID=
BOX_CLIENT_SECRET=
# Bungie
NEXT_PUBLIC_BUNGIE_ENABLED=
BUNGIE_CUSTOM_NAME=
BUNGIE_CLIENT_ID=
BUNGIE_CLIENT_SECRET=
BUNGIE_API_KEY=
# Cognito
NEXT_PUBLIC_COGNITO_ENABLED=
COGNITO_CUSTOM_NAME=
COGNITO_CLIENT_ID=
COGNITO_CLIENT_SECRET=
COGNITO_ISSUER=
# Coinbase
NEXT_PUBLIC_COINBASE_ENABLED=
COINBASE_CUSTOM_NAME=
COINBASE_CLIENT_ID=
COINBASE_CLIENT_SECRET=
# Discord
NEXT_PUBLIC_DISCORD_ENABLED=
DISCORD_CUSTOM_NAME=
DISCORD_CLIENT_ID=
DISCORD_CLIENT_SECRET=
# Dropbox
NEXT_PUBLIC_DROPBOX_ENABLED=
DROPBOX_CUSTOM_NAME=
DROPBOX_CLIENT_ID=
DROPBOX_CLIENT_SECRET=
# DuendeIndentityServer6
NEXT_PUBLIC_DUENDE_IDS6_ENABLED=
DUENDE_IDS6_CUSTOM_NAME=
DUENDE_IDS6_CLIENT_ID=
DUENDE_IDS6_CLIENT_SECRET=
DUENDE_IDS6_ISSUER=
# EVE Online
NEXT_PUBLIC_EVEONLINE_ENABLED=
EVEONLINE_CUSTOM_NAME=
EVEONLINE_CLIENT_ID=
EVEONLINE_CLIENT_SECRET=
# Facebook
NEXT_PUBLIC_FACEBOOK_ENABLED=
FACEBOOK_CUSTOM_NAME=
FACEBOOK_CLIENT_ID=
FACEBOOK_CLIENT_SECRET=
# FACEIT
NEXT_PUBLIC_FACEIT_ENABLED=
FACEIT_CUSTOM_NAME=
FACEIT_CLIENT_ID=
FACEIT_CLIENT_SECRET=
# Foursquare
NEXT_PUBLIC_FOURSQUARE_ENABLED=
FOURSQUARE_CUSTOM_NAME=
FOURSQUARE_CLIENT_ID=
FOURSQUARE_CLIENT_SECRET=
FOURSQUARE_APIVERSION=
# Freshbooks
NEXT_PUBLIC_FRESHBOOKS_ENABLED=
FRESHBOOKS_CUSTOM_NAME=
FRESHBOOKS_CLIENT_ID=
FRESHBOOKS_CLIENT_SECRET=
# FusionAuth
NEXT_PUBLIC_FUSIONAUTH_ENABLED=
FUSIONAUTH_CUSTOM_NAME=
FUSIONAUTH_CLIENT_ID=
FUSIONAUTH_CLIENT_SECRET=
FUSIONAUTH_ISSUER=
FUSIONAUTH_TENANT_ID=
# GitHub
NEXT_PUBLIC_GITHUB_ENABLED=
GITHUB_CUSTOM_NAME=
GITHUB_ID=
GITHUB_SECRET=
# GitLab
NEXT_PUBLIC_GITLAB_ENABLED=
GITLAB_CUSTOM_NAME=
GITLAB_CLIENT_ID=
GITLAB_CLIENT_SECRET=
# Google
NEXT_PUBLIC_GOOGLE_ENABLED=
GOOGLE_CUSTOM_NAME=
GOOGLE_CLIENT_ID=
GOOGLE_CLIENT_SECRET=
# HubSpot
NEXT_PUBLIC_HUBSPOT_ENABLED=
HUBSPOT_CUSTOM_NAME=
HUBSPOT_CLIENT_ID=
HUBSPOT_CLIENT_SECRET=
# IdentityServer4
NEXT_PUBLIC_IDS4_ENABLED=
IDS4_CUSTOM_NAME=
IDS4_CLIENT_ID=
IDS4_CLIENT_SECRET=
IDS4_ISSUER=
# Kakao
NEXT_PUBLIC_KAKAO_ENABLED=
KAKAO_CUSTOM_NAME=
KAKAO_CLIENT_ID=
KAKAO_CLIENT_SECRET=
# Keycloak
NEXT_PUBLIC_KEYCLOAK_ENABLED=
KEYCLOAK_CUSTOM_NAME=
KEYCLOAK_ISSUER=
KEYCLOAK_CLIENT_ID=
KEYCLOAK_CLIENT_SECRET=
# LINE
NEXT_PUBLIC_LINE_ENABLED=
LINE_CUSTOM_NAME=
LINE_CLIENT_ID=
LINE_CLIENT_SECRET=
# LinkedIn
NEXT_PUBLIC_LINKEDIN_ENABLED=
LINKEDIN_CUSTOM_NAME=
LINKEDIN_CLIENT_ID=
LINKEDIN_CLIENT_SECRET=
# Mailchimp
NEXT_PUBLIC_MAILCHIMP_ENABLED=
MAILCHIMP_CUSTOM_NAME=
MAILCHIMP_CLIENT_ID=
MAILCHIMP_CLIENT_SECRET=
# Mail.ru
NEXT_PUBLIC_MAILRU_ENABLED=
MAILRU_CUSTOM_NAME=
MAILRU_CLIENT_ID=
MAILRU_CLIENT_SECRET=
# Naver
NEXT_PUBLIC_NAVER_ENABLED=
NAVER_CUSTOM_NAME=
NAVER_CLIENT_ID=
NAVER_CLIENT_SECRET=
# Netlify
NEXT_PUBLIC_NETLIFY_ENABLED=
NETLIFY_CUSTOM_NAME=
NETLIFY_CLIENT_ID=
NETLIFY_CLIENT_SECRET=
# Okta
NEXT_PUBLIC_OKTA_ENABLED=
OKTA_CUSTOM_NAME=
OKTA_CLIENT_ID=
OKTA_CLIENT_SECRET=
OKTA_ISSUER=
# OneLogin
NEXT_PUBLIC_ONELOGIN_ENABLED=
ONELOGIN_CUSTOM_NAME=
ONELOGIN_CLIENT_ID=
ONELOGIN_CLIENT_SECRET=
ONELOGIN_ISSUER=
# Osso
NEXT_PUBLIC_OSSO_ENABLED=
OSSO_CUSTOM_NAME=
OSSO_CLIENT_ID=
OSSO_CLIENT_SECRET=
OSSO_ISSUER=
# osu!
NEXT_PUBLIC_OSU_ENABLED=
OSU_CUSTOM_NAME=
OSU_CLIENT_ID=
OSU_CLIENT_SECRET=
# Patreon
NEXT_PUBLIC_PATREON_ENABLED=
PATREON_CUSTOM_NAME=
PATREON_CLIENT_ID=
PATREON_CLIENT_SECRET=
# Pinterest
NEXT_PUBLIC_PINTEREST_ENABLED=
PINTEREST_CUSTOM_NAME=
PINTEREST_CLIENT_ID=
PINTEREST_CLIENT_SECRET=
# Pipedrive
NEXT_PUBLIC_PIPEDRIVE_ENABLED=
PIPEDRIVE_CUSTOM_NAME=
PIPEDRIVE_CLIENT_ID=
PIPEDRIVE_CLIENT_SECRET=
# Reddit
NEXT_PUBLIC_REDDIT_ENABLED=
REDDIT_CUSTOM_NAME=
REDDIT_CLIENT_ID=
REDDIT_CLIENT_SECRET=
# Salesforce
NEXT_PUBLIC_SALESFORCE_ENABLED=
SALESFORCE_CUSTOM_NAME=
SALESFORCE_CLIENT_ID=
SALESFORCE_CLIENT_SECRET=
# Slack
NEXT_PUBLIC_SLACK_ENABLED=
SLACK_CUSTOM_NAME=
SLACK_CLIENT_ID=
SLACK_CLIENT_SECRET=
# Spotify
NEXT_PUBLIC_SPOTIFY_ENABLED=
SPOTIFY_CUSTOM_NAME=
SPOTIFY_CLIENT_ID=
SPOTIFY_CLIENT_SECRET=
# Strava
NEXT_PUBLIC_STRAVA_ENABLED=
STRAVA_CUSTOM_NAME=
STRAVA_CLIENT_ID=
STRAVA_CLIENT_SECRET=
# Todoist
NEXT_PUBLIC_TODOIST_ENABLED=
TODOIST_CUSTOM_NAME=
TODOIST_CLIENT_ID=
TODOIST_CLIENT_SECRET=
# Twitch
NEXT_PUBLIC_TWITCH_ENABLED=
TWITCH_CUSTOM_NAME=
TWITCH_CLIENT_ID=
TWITCH_CLIENT_SECRET=
# United Effects
NEXT_PUBLIC_UNITED_EFFECTS_ENABLED=
UNITED_EFFECTS_CUSTOM_NAME=
UNITED_EFFECTS_CLIENT_ID=
UNITED_EFFECTS_CLIENT_SECRET=
UNITED_EFFECTS_ISSUER=
# VK
NEXT_PUBLIC_VK_ENABLED=
VK_CUSTOM_NAME=
VK_CLIENT_ID=
VK_CLIENT_SECRET=
# Wikimedia
NEXT_PUBLIC_WIKIMEDIA_ENABLED=
WIKIMEDIA_CUSTOM_NAME=
WIKIMEDIA_CLIENT_ID=
WIKIMEDIA_CLIENT_SECRET=
# Wordpress.com
NEXT_PUBLIC_WORDPRESS_ENABLED=
WORDPRESS_CUSTOM_NAME=
WORDPRESS_CLIENT_ID=
WORDPRESS_CLIENT_SECRET=
# Yandex
NEXT_PUBLIC_YANDEX_ENABLED=
YANDEX_CUSTOM_NAME=
YANDEX_CLIENT_ID=
YANDEX_CLIENT_SECRET=
# Zitadel
NEXT_PUBLIC_ZITADEL_ENABLED=
ZITADEL_CUSTOM_NAME=
ZITADEL_CLIENT_ID=
ZITADEL_CLIENT_SECRET=
ZITADEL_ISSUER=
# Zoho
NEXT_PUBLIC_ZOHO_ENABLED=
ZOHO_CUSTOM_NAME=
ZOHO_CLIENT_ID=
ZOHO_CLIENT_SECRET=
# Zoom
NEXT_PUBLIC_ZOOM_ENABLED=
ZOOM_CUSTOM_NAME=
ZOOM_CLIENT_ID=
ZOOM_CLIENT_SECRET=
+2 -1
View File
@@ -1,6 +1,7 @@
{
"extends": "next/core-web-vitals",
"rules": {
"react-hooks/exhaustive-deps": "off"
"react-hooks/exhaustive-deps": "off",
"@next/next/no-img-element": "off"
}
}
+20
View File
@@ -0,0 +1,20 @@
---
name: Ask a Question
about: Ask about a particular topic
title: ''
labels: question
assignees: ''
---
**Is your question related to a problem or code? Please describe.**
A clear and concise description of what the problem or code is. Ex. I'm confused about how [...] works, or I'm facing an issue when [...]
**Describe what you've tried to solve this question**
Explain what steps or research you've already taken to try and understand or solve this.
**Include any code or screenshots (if applicable)**
Add any code snippets, error messages, or screenshots that might help others understand your question better.
**Additional context**
Include any additional context or details that might help get a clearer understanding of your question.
@@ -1,8 +1,8 @@
---
name: Bug report
name: Bug Report
about: Create a report to help us improve
title: ''
labels: ''
labels: bug
assignees: ''
---
@@ -1,8 +1,8 @@
---
name: Feature request
name: Feature Request
about: Suggest an idea for this project
title: ''
labels: ''
labels: enhancement
assignees: ''
---
+12 -10
View File
@@ -1,17 +1,19 @@
# Security Policy
# Security
## Supported Versions
The Linkwarden team and community take security bugs in Linkwarden seriously. We appreciate your efforts to responsibly disclose your findings, and will make every effort to acknowledge your contributions.
| Version | Supported |
| ------- | --------- |
| 1.x.x | ✅ |
# Reporting Security Issues
## Reporting a Vulnerability
**Please do not report security vulnerabilities through public GitHub issues.**
First off, we really appreciate the time you spent!
Instead, please use the GitHub Security Advisory ["Report a Vulnerability"](https://github.com/linkwarden/linkwarden/security/advisories/new) tab.
If you found a vulnerability, these are the ways you can reach us:
You should receive a response within 24 hours. If for some reason you do not, please follow up via email to ensure we received your original message:
[security@linkwarden.app](mailto:security@linkwarden.app)
Email: [security@linkwarden.app](mailto:security@linkwarden.app)
Or you can directly DM me via Twitter: [@daniel31x13](https://twitter.com/Daniel31X13).
After the initial reply to your report, the security team will keep you informed of the progress towards a fix and full announcement, and may ask for additional information or guidance.
# Preferred Languages
We prefer all communications to be in English.
+18
View File
@@ -0,0 +1,18 @@
name: Check pull request source branch
on:
pull_request_target:
types:
- opened
- reopened
- synchronize
- edited
jobs:
check-branches:
runs-on: ubuntu-latest
steps:
- name: Check branches
run: |
if [ ${{ github.head_ref }} != "dev" ] && [ ${{ github.base_ref }} == "main" ]; then
echo "Merge requests to main branch are only allowed from dev branch. Please rebase your changes to dev branch."
exit 1
fi
+143
View File
@@ -0,0 +1,143 @@
name: Linkwarden Playwright Tests
on:
push:
branches:
- main
- qacomet/**
pull_request:
workflow_dispatch:
env:
PGHOST: localhost
PGPORT: 5432
PGUSER: postgres
PGPASSWORD: password
PGDATABASE: postgres
TEST_POSTGRES_USER: test_linkwarden_user
TEST_POSTGRES_PASSWORD: password
TEST_POSTGRES_DATABASE: test_linkwarden_db
TEST_POSTGRES_DATABASE_TEMPLATE: test_linkwarden_db_template
TEST_POSTGRES_HOST: localhost
TEST_POSTGREST_PORT: 5432
PRODUCTION_POSTGRES_DATABASE: linkwarden_db
NEXTAUTH_SECRET: very_sensitive_secret
NEXTAUTH_URL: http://localhost:3000/api/v1/auth
# Manual installation database settings
DATABASE_URL: postgresql://test_linkwarden_user:password@localhost:5432/test_linkwarden_db
# Docker installation database settings
POSTGRES_PASSWORD: password
TEST_USERNAME: test-user
TEST_PASSWORD: password
jobs:
playwright-test-runner:
strategy:
matrix:
test_case: ['@login']
timeout-minutes: 20
runs-on:
- ubuntu-22.04
services:
postgres:
image: postgres:16-alpine
env:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: password
POSTGRES_DB: postgres
ports:
- 5432:5432
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
steps:
- uses: actions/checkout@v4
- name: Use Node.js
uses: actions/setup-node@v4
with:
node-version: "18"
cache: 'yarn'
- name: Initialize PostgreSQL
run: |
echo "Initializing Databases"
psql -h localhost -U postgres -d postgres -c "CREATE USER ${{ env.TEST_POSTGRES_USER }} WITH PASSWORD '${{ env.TEST_POSTGRES_PASSWORD }}';"
psql -h localhost -U postgres -d postgres -c "CREATE DATABASE ${{ env.TEST_POSTGRES_DATABASE }} OWNER ${{ env.TEST_POSTGRES_USER }};"
- name: Install packages
run: yarn install -y
- name: Cache playwright dependencies
uses: awalsh128/cache-apt-pkgs-action@latest
with:
packages: |
ffmpeg fonts-freefont-ttf fonts-ipafont-gothic fonts-tlwg-loma-otf
fonts-unifont fonts-wqy-zenhei gstreamer1.0-libav gstreamer1.0-plugins-bad
gstreamer1.0-plugins-base gstreamer1.0-plugins-good libaa1 libass9
libasyncns0 libavc1394-0 libavcodec58 libavdevice58 libavfilter7
libavformat58 libavutil56 libbluray2 libbs2b0 libcaca0 libcdio-cdda2
libcdio-paranoia2 libcdio19 libcdparanoia0 libchromaprint1 libcodec2-1.0
libdc1394-25 libdca0 libdecor-0-0 libdv4 libdvdnav4 libdvdread8 libegl-mesa0
libegl1 libevdev2 libevent-2.1-7 libfaad2 libffi7 libflac8 libflite1
libfluidsynth3 libfreeaptx0 libgles2 libgme0 libgsm1 libgssdp-1.2-0
libgstreamer-gl1.0-0 libgstreamer-plugins-bad1.0-0
libgstreamer-plugins-base1.0-0 libgstreamer-plugins-good1.0-0 libgupnp-1.2-1
libgupnp-igd-1.0-4 libharfbuzz-icu0 libhyphen0 libiec61883-0
libinstpatch-1.0-2 libjack-jackd2-0 libkate1 libldacbt-enc2 liblilv-0-0
libltc11 libmanette-0.2-0 libmfx1 libmjpegutils-2.1-0 libmodplug1
libmp3lame0 libmpcdec6 libmpeg2encpp-2.1-0 libmpg123-0 libmplex2-2.1-0
libmysofa1 libnice10 libnotify4 libopenal-data libopenal1 libopengl0
libopenh264-6 libopenmpt0 libopenni2-0 libopus0 liborc-0.4-0
libpocketsphinx3 libpostproc55 libpulse0 libqrencode4 libraw1394-11
librubberband2 libsamplerate0 libsbc1 libsdl2-2.0-0 libserd-0-0 libshine3
libshout3 libsndfile1 libsndio7.0 libsord-0-0 libsoundtouch1 libsoup-3.0-0
libsoup-3.0-common libsoxr0 libspandsp2 libspeex1 libsphinxbase3
libsratom-0-0 libsrt1.4-gnutls libsrtp2-1 libssh-gcrypt-4 libswresample3
libswscale5 libtag1v5 libtag1v5-vanilla libtheora0 libtwolame0 libudfread0
libv4l-0 libv4lconvert0 libva-drm2 libva-x11-2 libva2 libvdpau1
libvidstab1.1 libvisual-0.4-0 libvo-aacenc0 libvo-amrwbenc0 libvorbisenc2
libvpx7 libwavpack1 libwebrtc-audio-processing1 libwildmidi2 libwoff1
libx264-163 libxcb-shape0 libxv1 libxvidcore4 libzbar0 libzimg2
libzvbi-common libzvbi0 libzxingcore1 ocl-icd-libopencl1 timgm6mb-soundfont
xfonts-cyrillic xfonts-encodings xfonts-scalable xfonts-utils
- name: Cache playwright browsers
id: cache-playwright
uses: actions/cache@v4
with:
path: ~/.cache/
key: ${{ runner.os }}-playwright-${{ hashFiles('**/yarn.lock') }}
restore-keys: |
${{ runner.os }}-playwright-
- name: Install playwright
if: steps.cache-playwright.outputs.cache-hit != 'true'
run: yarn playwright install --with-deps
- name: Setup project
run: |
yarn prisma generate
yarn build
yarn prisma migrate deploy
- name: Start linkwarden server and worker
run: yarn start &
- name: Run Tests
run: npx playwright test --grep ${{ matrix.test_case }}
- uses: actions/upload-artifact@v4
if: always()
with:
name: playwright-report
path: test-results
retention-days: 30
+3 -2
View File
@@ -1,6 +1,7 @@
name: Create and publish a container image on release
on:
workflow_dispatch:
push:
tags:
- "*"
@@ -27,7 +28,7 @@ jobs:
uses: docker/setup-buildx-action@v3
- name: Log in to the Container registry
uses: docker/login-action@v2
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
@@ -40,7 +41,7 @@ jobs:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
- name: Build and push Docker image
uses: docker/build-push-action@v3
uses: docker/build-push-action@v6
with:
context: .
push: true
+8 -1
View File
@@ -42,8 +42,15 @@ prisma/dev.db
# tests
/tests
/test-results/
/blob-report/
/playwright-report/
/playwright/.cache/
/playwright/.auth/
# docker
pgdata
pgdata
certificates
/test-results/
/playwright-report/
/blob-report/
/playwright/.cache/
+11
View File
@@ -0,0 +1,11 @@
node_modules
.next
/public
*.lock
*.log
.github
data
pgdata
+4
View File
@@ -0,0 +1,4 @@
{
"trailingComma": "es5",
"tabWidth": 2
}
+6
View File
@@ -0,0 +1,6 @@
{
"tailwindCSS.experimental.classRegex": [
["cva\\(([^)]*)\\)", "[\"'`]([^\"'`]*).*?[\"'`]"],
["cx\\(([^)]*)\\)", "(?:'|\"|`)([^']*)(?:'|\"|`)"]
]
}
+45
View File
@@ -0,0 +1,45 @@
# Architecture
This is a summary of the architecture of Linkwarden. It's intended as a primer for collaborators to get a high-level understanding of the project.
When you start Linkwarden, there are mainly two components that run:
- The NextJS app, This is the main app and it's responsible for serving the frontend and handling the API routes.
- [The Background Worker](https://github.com/linkwarden/linkwarden/blob/main/scripts/worker.ts), This is a separate `ts-node` process that runs in the background and is responsible for archiving links.
## Main Tech Stack
- [NextJS](https://github.com/vercel/next.js)
- [TypeScript](https://github.com/microsoft/TypeScript)
- [Tailwind](https://github.com/tailwindlabs/tailwindcss)
- [DaisyUI](https://github.com/saadeghi/daisyui)
- [Prisma](https://github.com/prisma/prisma)
- [Playwright](https://github.com/microsoft/playwright)
- [Zustand](https://github.com/pmndrs/zustand)
## Folder Structure
Here's a summary of the main files and folders in the project:
```
linkwarden
├── components # React components
├── hooks # React reusable hooks
├── layouts # Layouts for pages
├── lib
│   ├── api # Server-side functions (controllers, etc.)
│   ├── client # Client-side functions
│   └── shared # Shared functions between client and server
├── pages # Pages and API routes
├── prisma # Prisma schema and migrations
├── scripts
│   ├── migration # Scripts for breaking changes
│   └── worker.ts # Background worker for archiving links
├── store # Zustand stores
├── styles # Styles
└── types # TypeScript types
```
## Versioning
We use semantic versioning for the project. You can track the changes from the [Releases](https://github.com/linkwarden/linkwarden/releases).
+24 -4
View File
@@ -1,5 +1,16 @@
# playwright doesnt support debian image
FROM node:20-bullseye-slim
# Stage: monolith-builder
# Purpose: Uses the Rust image to build monolith
# Notes:
# - Fine to leave extra here, as only the resulting binary is copied out
FROM docker.io/rust:1.80-bullseye AS monolith-builder
RUN set -eux && cargo install --locked monolith
# Stage: main-app
# Purpose: Compiles the frontend and
# Notes:
# - Nothing extra should be left here. All commands should cleanup
FROM node:18.18-bullseye-slim AS main-app
ARG DEBIAN_FRONTEND=noninteractive
@@ -9,8 +20,15 @@ WORKDIR /data
COPY ./package.json ./yarn.lock ./playwright.config.ts ./
RUN yarn && \
npx playwright install-deps && \
RUN --mount=type=cache,sharing=locked,target=/usr/local/share/.cache/yarn \
set -eux && \
yarn install --network-timeout 10000000
# Copy the compiled monolith binary from the builder stage
COPY --from=monolith-builder /usr/local/cargo/bin/monolith /usr/local/bin/monolith
RUN set -eux && \
npx playwright install --with-deps chromium && \
apt-get clean && \
yarn cache clean
@@ -19,4 +37,6 @@ COPY . .
RUN yarn prisma generate && \
yarn build
EXPOSE 3000
CMD yarn prisma migrate deploy && yarn start
View File
+80 -42
View File
@@ -1,94 +1,127 @@
<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-square" alt="Discord"></a>
<img alt="GitHub commits since latest release (by SemVer including pre-releases)" src="https://img.shields.io/github/commits-since/linkwarden/linkwarden/v1.1.0/dev">
<img src="https://img.shields.io/github/languages/top/linkwarden/linkwarden?style=flat-square" alt="Top Language">
<img src="https://img.shields.io/github/stars/linkwarden/linkwarden?style=flat-square" alt="Github Stars">
<a href="https://discord.com/invite/CtuYV47nuJ"><img src="https://img.shields.io/discord/1117993124669702164?logo=discord&style=flat" alt="Discord"></a>
<a href="https://twitter.com/LinkwardenHQ"><img src="https://img.shields.io/twitter/follow/linkwarden" alt="Twitter"></a> <a href="https://news.ycombinator.com/item?id=36942308"><img src="https://img.shields.io/badge/Hacker%20News-280-%23FF6600"></img></a>
<a href="https://github.com/linkwarden/linkwarden/actions/workflows/release-container.yml"><img alt="GitHub Actions Workflow Status" src="https://img.shields.io/github/actions/workflow/status/linkwarden/linkwarden/release-container.yml"></a>
<a href="https://opencollective.com/linkwarden"><img src="https://img.shields.io/opencollective/all/linkwarden" alt="Open Collective"></a>
</div>
<div align='center'>
[Website](https://linkwarden.app) | [Getting Started](https://docs.linkwarden.app) | [Features](https://github.com/linkwarden/linkwarden#features) | [Roadmap](https://github.com/orgs/linkwarden/projects/1) | [Screenshots](https://github.com/linkwarden/linkwarden#screenshots) | [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>
## Intro & motivation
**Linkwarden is a self-hosted, open-source collaborative bookmark manager to collect, organize and archive webpages.** The objective is to organize useful webpages and articles you find across the web in one place, and since useful webpages can go away (see the inevitability of [Link Rot](https://www.howtogeek.com/786227/what-is-link-rot-and-how-does-it-threaten-the-web/)), Linkwarden also saves a copy of each webpage as a Screenshot and PDF, ensuring accessibility even if the original content is no longer available.
**Linkwarden is a self-hosted, open-source collaborative bookmark manager to collect, organize and archive webpages.**
The objective is to organize useful webpages and articles you find across the web in one place, and since useful webpages can go away (see the inevitability of [Link Rot](https://www.howtogeek.com/786227/what-is-link-rot-and-how-does-it-threaten-the-web/)), Linkwarden also saves a copy of each webpage as a Screenshot and PDF, ensuring accessibility even if the original content is no longer available.
Additionally, Linkwarden is designed with collaboration in mind, sharing links with the public and/or allowing multiple users to work together seamlessly.
<img src="./assets/showcase_image.png" />
> [!TIP]
> Our official [Cloud](https://linkwarden.app/#pricing) offering provides the simplest way to begin using Linkwarden and it's the preferred choice for many due to its time-saving benefits. <br> Your subscription supports our hosting infrastructure and ongoing development. <br> Alternatively, if you prefer self-hosting Linkwarden, you can do so by following our [Installation documentation](https://docs.linkwarden.app/self-hosting/installation).
> **Note**
> 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.
<img src="./assets/dashboard.png" />
<div align="center">
<img src="./assets/all_links.jpg" width="23%" />
<img src="./assets/list_view.jpg" width="23%" />
<img src="./assets/all_collections.jpg" width="23%" />
<img src="./assets/manage_team.jpg" width="23%" />
<img src="./assets/readable_view.jpg" width="23%" />
<img src="./assets/preserved_formats.jpg" width="23%" />
<img src="./assets/public_page.jpg" width="23%" />
<img src="./assets/light_dashboard.jpg" width="23%" />
</div>
<details>
<summary><b>A bit of a "history"</b></summary>
Linkwarden has been completely rebuilt and redesigned from ground up, so pretty much the only thing it has in common with its predecessor is the idea behind it - bookmark management.
**What happened to the old version?**
We highly recommend that you don't use the old version because it is no longer maintained and has far fewer features. However, if you still want to check it out, we've forked the old version from the current repository into [this repo](https://github.com/linkwarden/linkwarden-old).
We've forked the old version from the current repository into [this repo](https://github.com/linkwarden/linkwarden-old).
</details>
## Features
- 📸 Auto capture a screenshot and a PDF of each link.
- 🏛️ Send your webpage to Wayback Machine archive.org for a snapshot.
- 📂 Organize links by collection, name, description and multiple tags.
- 📸 Auto capture a screenshot, PDF, single html file, and readable view of each webpage.
- 🏛️ Send your webpage to Wayback Machine ([archive.org](https://archive.org)) for a snapshot. (Optional)
- 📂 Organize links by collection, sub-collection, name, description and multiple tags.
- 👥 Collaborate on gathering links in a collection.
- 🔐 Customize the permissions of each member.
- 🌐 Share your collected links with the world.
- 🎛️ Customize the permissions of each member.
- 🌐 Share your collected links and preserved formats with the world.
- 📌 Pin your favorite links to dashboard.
- 🔍 Search, filter and sort by link details.
- 📱 Responsive design and supports most browsers.
- 🔍 Full text search, filter and sort for easy retrieval.
- 📱 Responsive design and supports most modern browsers.
- 🌓 Dark/Light mode support.
- 🧩 Browser extension, managed by the community [check it out!](https://github.com/linkwarden/browser-extension)
- ⬇️ Import your bookmarks from other browsers.
- 🧩 Browser extension, managed by the community. [Star it here!](https://github.com/linkwarden/browser-extension)
- 🔄 Browser Synchronization (using [Floccus](https://floccus.org)!)
- ⬇️ Import and export your bookmarks.
- 🔐 SSO integration. (Enterprise and Self-hosted users only)
- 📦 Installable Progressive Web App (PWA).
- 🍏 iOS and MacOS Apps, maintained by [JGeek00](https://github.com/JGeek00).
- 🍎 iOS Shortcut to save Links to Linkwarden.
- 🔑 API keys.
- ✅ Bulk actions.
- 👥 User administration.
- 🌐 Support for Other Languages (i18n).
- 📁 Image and PDF Uploads.
- 🎨 Custom Icons for Links and Collections.
- ⚙️ Customizable View and Adjustable Columns.
- ✨ And many more features. (Literally!)
## Like what we're doing? Give us a Star ⭐
![Star Us](https://raw.githubusercontent.com/linkwarden/linkwarden/main/assets/star_repo.gif)
## We're building our Community 🌐
Join and follow us in the following platforms to stay up to date about the most recent features and for support:
<a href="https://discord.com/invite/CtuYV47nuJ"><img src="https://img.shields.io/discord/1117993124669702164?logo=discord&style=flat" alt="Discord"></a>
<a href="https://twitter.com/LinkwardenHQ"><img src="https://img.shields.io/twitter/follow/linkwarden" alt="Twitter"></a>
<a href="https://fosstodon.org/@linkwarden"><img src="https://img.shields.io/mastodon/follow/110748840237143200?domain=https%3A%2F%2Ffosstodon.org" alt="Mastodon"></a>
## Suggestions
We usually go after the [popular suggestions](https://github.com/linkwarden/linkwarden/issues?q=is%3Aissue+is%3Aopen+sort%3Areactions-%2B1-desc). Feel free to open a [new issue](https://github.com/linkwarden/linkwarden/issues/new?assignees=&labels=enhancement&projects=&template=feature_request.md&title=) to suggest one - others might be interested too! :)
We _usually_ go after the [popular suggestions](https://github.com/linkwarden/linkwarden/issues?q=is%3Aissue+is%3Aopen+sort%3Areactions-%2B1-desc). Feel free to open a [new issue](https://github.com/linkwarden/linkwarden/issues/new?assignees=&labels=enhancement&projects=&template=feature_request.md&title=) to suggest one - others might be interested too! :)
## Roadmap
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).
## Main Tech Stack
- NextJS
- TypeScript
- Tailwind
- Prisma
- Zustand
## Development
If you want to contribute, Thanks! Start by checking our [public roadmap](https://github.com/orgs/linkwarden/projects/1), there you'll see a [README for contributers](https://github.com/orgs/linkwarden/projects/1?pane=issue&itemId=34708277) for the rest of the info on how to contribute to this repo.
If you want to contribute, Thanks! Start by checking our [public roadmap](https://github.com/orgs/linkwarden/projects/1), there you'll see a [README for contributers](https://github.com/orgs/linkwarden/projects/1?pane=issue&itemId=34708277) for the rest of the info on how to contribute and the main tech stack.
## Security
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!
## Screenshots
<div align="center">
<img src="./assets/collections.png" height="150" />
<img src="./assets/collaborators.png" height="150" />
<img src="./assets/link_details.png" height="150" />
</div>
## 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!
@@ -96,7 +129,12 @@ Here are the other ways to support/cheer this project:
- Starring this repository.
- Joining us on [Discord](https://discord.com/invite/CtuYV47nuJ).
- Following @daniel31x13 on [Mastodon](https://mastodon.social/@daniel31x13), [Twitter](https://twitter.com/daniel31x13) and [GitHub](https://github.com/daniel31x13).
- Referring Linkwarden to a friend.
If you did any of the above, Thanksss! Otherwise thanks.
## Thanks to All the Contributors 💪
Huge thanks to these guys for spending their time helping Linkwarden grow. They rock! ⚡️
<img src="https://contributors-img.web.app/image?repo=linkwarden/linkwarden" alt="Contributors"/>
Binary file not shown.

After

Width:  |  Height:  |  Size: 251 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 564 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 415 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 474 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 786 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 471 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 387 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 394 KiB

BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 115 KiB

After

Width:  |  Height:  |  Size: 79 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 226 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 301 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 330 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 345 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 663 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 MiB

+39
View File
@@ -0,0 +1,39 @@
import Link from "next/link";
import React, { MouseEventHandler } from "react";
import { Trans } from "next-i18next";
type Props = {
toggleAnnouncementBar: MouseEventHandler<HTMLButtonElement>;
};
export default function Announcement({ toggleAnnouncementBar }: Props) {
const announcementId = localStorage.getItem("announcementId");
return (
<div className="fixed mx-auto bottom-20 sm:bottom-10 w-full pointer-events-none p-5 z-30">
<div className="mx-auto pointer-events-auto p-2 flex justify-between gap-2 items-center border border-primary shadow-xl rounded-xl bg-base-300 backdrop-blur-sm bg-opacity-80 max-w-md">
<i className="bi-stars text-2xl text-yellow-600 dark:text-yellow-500"></i>
<p className="w-4/5 text-center text-sm sm:text-base">
<Trans
i18nKey="new_version_announcement"
values={{ version: announcementId }}
components={[
<Link
href={`https://blog.linkwarden.app/releases/${announcementId}`}
target="_blank"
className="underline"
key={0}
/>,
]}
/>
</p>
<button
onClick={toggleAnnouncementBar}
className="btn btn-ghost btn-square btn-sm"
>
<i className="bi-x text-xl"></i>
</button>
</div>
</div>
);
}
+7 -15
View File
@@ -1,5 +1,3 @@
import { faSquare, faSquareCheck } from "@fortawesome/free-regular-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { ChangeEventHandler } from "react";
type Props = {
@@ -11,24 +9,18 @@ type Props = {
export default function Checkbox({ label, state, className, onClick }: Props) {
return (
<label className={`cursor-pointer flex items-center gap-2 ${className}`}>
<label
className={`label cursor-pointer flex gap-2 justify-start ${
className || ""
}`}
>
<input
type="checkbox"
checked={state}
onChange={onClick}
className="peer sr-only"
className="checkbox checkbox-primary"
/>
<FontAwesomeIcon
icon={faSquareCheck}
className="w-5 h-5 text-sky-500 dark:text-sky-500 peer-checked:block hidden"
/>
<FontAwesomeIcon
icon={faSquare}
className="w-5 h-5 text-sky-500 dark:text-sky-500 peer-checked:hidden block"
/>
<span className="text-black dark:text-white rounded select-none">
{label}
</span>
<span className="label-text">{label}</span>
</label>
);
}
+39 -8
View File
@@ -4,21 +4,42 @@ type Props = {
children: ReactNode;
onClickOutside: Function;
className?: string;
style?: React.CSSProperties;
onMount?: (rect: DOMRect) => void;
};
function getZIndex(element: HTMLElement): number {
let zIndex = 0;
while (element) {
const zIndexStyle = window
.getComputedStyle(element)
.getPropertyValue("z-index");
const numericZIndex = Number(zIndexStyle);
if (zIndexStyle !== "auto" && !isNaN(numericZIndex)) {
zIndex = numericZIndex;
break;
}
element = element.parentElement as HTMLElement;
}
return zIndex;
}
function useOutsideAlerter(
ref: RefObject<HTMLElement>,
onClickOutside: Function
) {
useEffect(() => {
function handleClickOutside(event: Event) {
if (
ref.current &&
!ref.current.contains(event.target as HTMLInputElement)
) {
onClickOutside(event);
function handleClickOutside(event: MouseEvent) {
const clickedElement = event.target as HTMLElement;
if (ref.current && !ref.current.contains(clickedElement)) {
const refZIndex = getZIndex(ref.current);
const clickedZIndex = getZIndex(clickedElement);
if (clickedZIndex <= refZIndex) {
onClickOutside(event);
}
}
}
document.addEventListener("mousedown", handleClickOutside);
return () => {
document.removeEventListener("mousedown", handleClickOutside);
@@ -30,12 +51,22 @@ export default function ClickAwayHandler({
children,
onClickOutside,
className,
style,
onMount,
}: Props) {
const wrapperRef = useRef(null);
const wrapperRef = useRef<HTMLDivElement | null>(null);
useOutsideAlerter(wrapperRef, onClickOutside);
useEffect(() => {
if (wrapperRef.current && onMount) {
const rect = wrapperRef.current.getBoundingClientRect();
onMount(rect); // Pass the bounding rectangle to the parent
}
}, []);
return (
<div ref={wrapperRef} className={className}>
<div ref={wrapperRef} className={className} style={style}>
{children}
</div>
);
+192 -127
View File
@@ -1,24 +1,28 @@
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faEllipsis, faLink } from "@fortawesome/free-solid-svg-icons";
import Link from "next/link";
import { CollectionIncludingMembersAndLinkCount } from "@/types/global";
import Dropdown from "./Dropdown";
import { useState } from "react";
import {
AccountSettings,
CollectionIncludingMembersAndLinkCount,
} from "@/types/global";
import React, { useEffect, useState } from "react";
import ProfilePhoto from "./ProfilePhoto";
import { faCalendarDays } from "@fortawesome/free-regular-svg-icons";
import useModalStore from "@/store/modals";
import usePermissions from "@/hooks/usePermissions";
import { useTheme } from "next-themes";
import useLocalSettingsStore from "@/store/localSettings";
import getPublicUserData from "@/lib/client/getPublicUserData";
import EditCollectionModal from "./ModalContent/EditCollectionModal";
import EditCollectionSharingModal from "./ModalContent/EditCollectionSharingModal";
import DeleteCollectionModal from "./ModalContent/DeleteCollectionModal";
import { dropdownTriggerer } from "@/lib/client/utils";
import { useTranslation } from "next-i18next";
import { useUser } from "@/hooks/store/user";
type Props = {
export default function CollectionCard({
collection,
}: {
collection: CollectionIncludingMembersAndLinkCount;
className?: string;
};
export default function CollectionCard({ collection, className }: Props) {
const { setModal } = useModalStore();
const { theme } = useTheme();
}) {
const { t } = useTranslation();
const { settings } = useLocalSettingsStore();
const { data: user = {} } = useUser();
const formattedDate = new Date(collection.createdAt as string).toLocaleString(
"en-US",
@@ -29,132 +33,193 @@ export default function CollectionCard({ collection, className }: Props) {
}
);
const [expandDropdown, setExpandDropdown] = useState(false);
const permissions = usePermissions(collection.id as number);
const [collectionOwner, setCollectionOwner] = useState<
Partial<AccountSettings>
>({});
useEffect(() => {
const fetchOwner = async () => {
if (collection && collection.ownerId !== user.id) {
const owner = await getPublicUserData(collection.ownerId as number);
setCollectionOwner(owner);
} else if (collection && collection.ownerId === user.id) {
setCollectionOwner({
id: user.id as number,
name: user.name,
username: user.username as string,
image: user.image as string,
archiveAsScreenshot: user.archiveAsScreenshot as boolean,
archiveAsMonolith: user.archiveAsMonolith as boolean,
archiveAsPDF: user.archiveAsPDF as boolean,
});
}
};
fetchOwner();
}, [collection]);
const [editCollectionModal, setEditCollectionModal] = useState(false);
const [editCollectionSharingModal, setEditCollectionSharingModal] =
useState(false);
const [deleteCollectionModal, setDeleteCollectionModal] = useState(false);
return (
<div
style={{
backgroundImage: `linear-gradient(45deg, ${collection.color}30 10%, ${
theme === "dark" ? "#262626" : "#f3f4f6"
} 50%, ${theme === "dark" ? "#262626" : "#f9fafb"} 100%)`,
}}
className={`border border-solid border-sky-100 dark:border-neutral-700 self-stretch min-h-[12rem] rounded-2xl shadow duration-100 hover:shadow-none hover:opacity-80 group relative ${className}`}
>
<div className="relative">
<div className="dropdown dropdown-bottom dropdown-end absolute top-3 right-3 z-20">
<div
tabIndex={0}
role="button"
onMouseDown={dropdownTriggerer}
className="btn btn-ghost btn-sm btn-square text-neutral"
>
<i className="bi-three-dots text-xl" title="More"></i>
</div>
<ul className="dropdown-content z-[30] menu shadow bg-base-200 border border-neutral-content rounded-box mt-1">
{permissions === true && (
<li>
<div
role="button"
tabIndex={0}
onClick={() => {
(document?.activeElement as HTMLElement)?.blur();
setEditCollectionModal(true);
}}
className="whitespace-nowrap"
>
{t("edit_collection_info")}
</div>
</li>
)}
<li>
<div
role="button"
tabIndex={0}
onClick={() => {
(document?.activeElement as HTMLElement)?.blur();
setEditCollectionSharingModal(true);
}}
className="whitespace-nowrap"
>
{permissions === true
? t("share_and_collaborate")
: t("view_team")}
</div>
</li>
<li>
<div
role="button"
tabIndex={0}
onClick={() => {
(document?.activeElement as HTMLElement)?.blur();
setDeleteCollectionModal(true);
}}
className="whitespace-nowrap"
>
{permissions === true
? t("delete_collection")
: t("leave_collection")}
</div>
</li>
</ul>
</div>
<div
onClick={() => setExpandDropdown(!expandDropdown)}
id={"expand-dropdown" + collection.id}
className="inline-flex absolute top-5 right-5 rounded-md cursor-pointer hover:bg-slate-200 hover:dark:bg-neutral-700 duration-100 p-1"
className="flex items-center absolute bottom-3 left-3 z-10 btn px-2 btn-ghost rounded-full"
onClick={() => setEditCollectionSharingModal(true)}
>
<FontAwesomeIcon
icon={faEllipsis}
id={"expand-dropdown" + collection.id}
className="w-5 h-5 text-gray-500 dark:text-gray-300"
/>
{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}
name={e.user.name}
className="-ml-3"
/>
);
})
.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>
<Link
href={`/collections/${collection.id}`}
className="flex flex-col gap-2 justify-between min-h-[12rem] h-full select-none p-5"
style={{
backgroundImage: `linear-gradient(45deg, ${collection.color}30 10%, ${
settings.theme === "dark" ? "oklch(var(--b2))" : "oklch(var(--b2))"
} 50%, ${
settings.theme === "dark" ? "oklch(var(--b2))" : "oklch(var(--b2))"
} 100%)`,
}}
className="card card-compact shadow-md hover:shadow-none duration-200 border border-neutral-content"
>
<p className="text-2xl capitalize text-black dark:text-white break-words line-clamp-3 w-4/5">
{collection.name}
</p>
<div className="flex justify-between items-center">
<div className="flex items-center w-full">
{collection.members
.sort((a, b) => (a.userId as number) - (b.userId as number))
.map((e, i) => {
return (
<ProfilePhoto
key={i}
src={`/api/avatar/${e.userId}?${Date.now()}`}
className="-mr-3 border-[3px]"
/>
);
})
.slice(0, 4)}
{collection.members.length - 4 > 0 ? (
<div className="h-10 w-10 text-white flex items-center justify-center rounded-full border-[3px] bg-sky-600 dark:bg-sky-600 border-slate-200 dark:border-neutral-700 -mr-3">
+{collection.members.length - 4}
</div>
) : null}
<div className="card-body flex flex-col justify-between min-h-[12rem]">
<div className="flex justify-between">
<p className="card-title break-words line-clamp-2 w-full">
{collection.name}
</p>
<div className="w-8 h-8 ml-10"></div>
</div>
<div className="text-right w-40">
<div className="text-black dark:text-white font-bold text-sm flex justify-end gap-1 items-center">
<FontAwesomeIcon
icon={faLink}
className="w-5 h-5 text-gray-500 dark:text-gray-300"
/>
{collection._count && collection._count.links}
</div>
<div className="flex items-center justify-end gap-1 text-gray-500 dark:text-gray-300">
<FontAwesomeIcon icon={faCalendarDays} className="w-4 h-4" />
<p className="font-bold text-xs">{formattedDate}</p>
<div className="flex justify-end items-center">
<div className="text-right">
<div className="font-bold text-sm flex justify-end gap-1 items-center">
{collection.isPublic && (
<i
className="bi-globe2 drop-shadow text-neutral"
title="This collection is being shared publicly."
></i>
)}
<i
className="bi-link-45deg text-lg text-neutral"
title="This collection is being shared publicly."
></i>
{collection._count && collection._count.links}
</div>
<div className="flex items-center justify-end gap-1 text-neutral">
<p className="font-bold text-xs flex gap-1 items-center">
<i
className="bi-calendar3 text-neutral"
title="This collection is being shared publicly."
></i>
{formattedDate}
</p>
</div>
</div>
</div>
</div>
</Link>
{expandDropdown ? (
<Dropdown
items={[
permissions === true
? {
name: "Edit Collection Info",
onClick: () => {
collection &&
setModal({
modal: "COLLECTION",
state: true,
method: "UPDATE",
isOwner: permissions === true,
active: collection,
});
setExpandDropdown(false);
},
}
: undefined,
{
name: permissions === true ? "Share/Collaborate" : "View Team",
onClick: () => {
collection &&
setModal({
modal: "COLLECTION",
state: true,
method: "UPDATE",
isOwner: permissions === true,
active: collection,
defaultIndex: permissions === true ? 1 : 0,
});
setExpandDropdown(false);
},
},
{
name:
permissions === true ? "Delete Collection" : "Leave Collection",
onClick: () => {
collection &&
setModal({
modal: "COLLECTION",
state: true,
method: "UPDATE",
isOwner: permissions === true,
active: collection,
defaultIndex: permissions === true ? 2 : 1,
});
setExpandDropdown(false);
},
},
]}
onClickOutside={(e: Event) => {
const target = e.target as HTMLInputElement;
if (target.id !== "expand-dropdown" + collection.id)
setExpandDropdown(false);
}}
className="absolute top-[3.2rem] right-5 z-10"
{editCollectionModal && (
<EditCollectionModal
onClose={() => setEditCollectionModal(false)}
activeCollection={collection}
/>
) : null}
)}
{editCollectionSharingModal && (
<EditCollectionSharingModal
onClose={() => setEditCollectionSharingModal(false)}
activeCollection={collection}
/>
)}
{deleteCollectionModal && (
<DeleteCollectionModal
onClose={() => setDeleteCollectionModal(false)}
activeCollection={collection}
/>
)}
</div>
);
}
+398
View File
@@ -0,0 +1,398 @@
import React, { useEffect, useMemo, useState } from "react";
import Tree, {
mutateTree,
moveItemOnTree,
RenderItemParams,
TreeItem,
TreeData,
ItemId,
TreeSourcePosition,
TreeDestinationPosition,
} from "@atlaskit/tree";
import { Collection } from "@prisma/client";
import Link from "next/link";
import { CollectionIncludingMembersAndLinkCount } from "@/types/global";
import { useRouter } from "next/router";
import toast from "react-hot-toast";
import { useTranslation } from "next-i18next";
import { useCollections, useUpdateCollection } from "@/hooks/store/collections";
import { useUpdateUser, useUser } from "@/hooks/store/user";
import Icon from "./Icon";
import { IconWeight } from "@phosphor-icons/react";
interface ExtendedTreeItem extends TreeItem {
data: Collection;
}
const CollectionListing = () => {
const { t } = useTranslation();
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,
tree,
user.collectionOrder
);
} else return undefined;
}, [collections, user, router]);
useEffect(() => {
setTree(initialTree);
}, [initialTree]);
useEffect(() => {
if (user.username) {
refetch();
if (
(!user.collectionOrder || user.collectionOrder.length === 0) &&
collections.length > 0
)
updateUser.mutate({
...user,
collectionOrder: collections
.filter((e) => e.parentId === null)
.map((e) => e.id as number),
});
else {
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 = user.collectionOrder.filter((id: any) =>
existingCollectionIds.includes(id)
);
// Add new collections that are not in account.collectionOrder and meet the specific conditions
collections.forEach((collection) => {
if (
!filteredCollectionOrder.includes(collection.id as number) &&
(!collection.parentId || collection.ownerId === user.id)
) {
filteredCollectionOrder.push(collection.id as number);
}
});
// check if the newCollectionOrder is the same as the old one
if (
JSON.stringify(newCollectionOrder) !==
JSON.stringify(user.collectionOrder)
) {
updateUser.mutateAsync({
...user,
collectionOrder: newCollectionOrder,
});
}
}
}
}, [user, collections]);
const onExpand = (movedCollectionId: ItemId) => {
setTree((currentTree) =>
mutateTree(currentTree!, movedCollectionId, { isExpanded: true })
);
};
const onCollapse = (movedCollectionId: ItemId) => {
setTree((currentTree) =>
mutateTree(currentTree as TreeData, movedCollectionId, {
isExpanded: false,
})
);
};
const onDragEnd = async (
source: TreeSourcePosition,
destination: TreeDestinationPosition | undefined
) => {
if (!destination || !tree) {
return;
}
if (
source.index === destination.index &&
source.parentId === destination.parentId
) {
return;
}
const movedCollectionId = Number(
tree.items[source.parentId].children[source.index]
);
const movedCollection = collections.find((c) => c.id === movedCollectionId);
const destinationCollection = collections.find(
(c) => c.id === Number(destination.parentId)
);
if (
(movedCollection?.ownerId !== user.id &&
destination.parentId !== source.parentId) ||
(destinationCollection?.ownerId !== user.id &&
destination.parentId !== "root")
) {
return toast.error(t("cant_change_collection_you_dont_own"));
}
setTree((currentTree) => moveItemOnTree(currentTree!, source, destination));
const updatedCollectionOrder = [...user.collectionOrder];
if (source.parentId !== destination.parentId) {
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 (
destination.index !== undefined &&
destination.parentId === source.parentId &&
source.parentId === "root"
) {
updatedCollectionOrder.includes(movedCollectionId) &&
updatedCollectionOrder.splice(source.index, 1);
updatedCollectionOrder.splice(destination.index, 0, movedCollectionId);
await updateUser.mutateAsync({
...user,
collectionOrder: updatedCollectionOrder,
});
} else if (
destination.index !== undefined &&
destination.parentId === "root"
) {
updatedCollectionOrder.splice(destination.index, 0, movedCollectionId);
updateUser.mutate({
...user,
collectionOrder: updatedCollectionOrder,
});
} else if (
source.parentId === "root" &&
destination.parentId &&
destination.parentId !== "root"
) {
updatedCollectionOrder.splice(source.index, 1);
await updateUser.mutateAsync({
...user,
collectionOrder: updatedCollectionOrder,
});
}
};
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")}
</p>
);
} else
return (
<Tree
tree={tree}
renderItem={(itemProps) => renderItem({ ...itemProps }, currentPath)}
onExpand={onExpand}
onCollapse={onCollapse}
onDragEnd={onDragEnd}
isDragEnabled
isNestingEnabled
/>
);
};
export default CollectionListing;
const renderItem = (
{ item, onExpand, onCollapse, provided }: RenderItemParams,
currentPath: string
) => {
const collection = item.data;
return (
<div ref={provided.innerRef} {...provided.draggableProps} className="mb-1">
<div
className={`${
currentPath === `/collections/${collection.id}`
? "bg-primary/20 is-active"
: "hover:bg-neutral/20"
} duration-100 flex gap-1 items-center pr-2 pl-1 rounded-md`}
>
{Dropdown(item as ExtendedTreeItem, onExpand, onCollapse)}
<Link
href={`/collections/${collection.id}`}
className="w-full"
{...provided.dragHandleProps}
>
<div
className={`py-1 cursor-pointer flex items-center gap-2 w-full rounded-md h-8 capitalize`}
>
{collection.icon ? (
<Icon
icon={collection.icon}
size={30}
weight={(collection.iconWeight || "regular") as IconWeight}
color={collection.color}
className="-mr-[0.15rem]"
/>
) : (
<i
className="bi-folder-fill text-2xl"
style={{ color: collection.color }}
></i>
)}
<p className="truncate w-full">{collection.name}</p>
{collection.isPublic && (
<i
className="bi-globe2 text-sm text-black/50 dark:text-white/50 drop-shadow"
title="This collection is being shared publicly."
></i>
)}
<div className="drop-shadow text-neutral text-xs">
{collection._count?.links}
</div>
</div>
</Link>
</div>
</div>
);
};
const Dropdown = (
item: ExtendedTreeItem,
onExpand: (id: ItemId) => void,
onCollapse: (id: ItemId) => void
) => {
if (item.children && item.children.length > 0) {
return item.isExpanded ? (
<button onClick={() => onCollapse(item.id)}>
<div className="bi-caret-down-fill opacity-50 hover:opacity-100 duration-200"></div>
</button>
) : (
<button onClick={() => onExpand(item.id)}>
<div className="bi-caret-right-fill opacity-40 hover:opacity-100 duration-200"></div>
</button>
);
}
// return <span>&bull;</span>;
return <div></div>;
};
const buildTreeFromCollections = (
collections: CollectionIncludingMembersAndLinkCount[],
router: ReturnType<typeof useRouter>,
tree?: TreeData,
order?: number[]
): TreeData => {
if (order) {
collections.sort((a: any, b: any) => {
return order.indexOf(a.id) - order.indexOf(b.id);
});
}
const items: { [key: string]: ExtendedTreeItem } = collections.reduce(
(acc: any, collection) => {
acc[collection.id as number] = {
id: collection.id,
children: [],
hasChildren: false,
isExpanded: tree?.items[collection.id as number]?.isExpanded || false,
data: {
id: collection.id,
parentId: collection.parentId,
name: collection.name,
description: collection.description,
color: collection.color,
icon: collection.icon,
iconWeight: collection.iconWeight,
isPublic: collection.isPublic,
ownerId: collection.ownerId,
createdAt: collection.createdAt,
updatedAt: collection.updatedAt,
_count: {
links: collection._count?.links,
},
},
};
return acc;
},
{}
);
const activeCollectionId = Number(router.asPath.split("/collections/")[1]);
if (activeCollectionId) {
for (const item in items) {
const collection = items[item];
if (Number(item) === activeCollectionId && collection.data.parentId) {
// get all the parents of the active collection recursively until root and set isExpanded to true
let parentId = collection.data.parentId || null;
while (parentId && items[parentId]) {
items[parentId].isExpanded = true;
parentId = items[parentId].data.parentId;
}
}
}
}
collections.forEach((collection) => {
const parentId = collection.parentId;
if (parentId && items[parentId] && collection.id) {
items[parentId].children.push(collection.id);
items[parentId].hasChildren = true;
}
});
const rootId = "root";
items[rootId] = {
id: rootId,
children: (collections
.filter(
(c) =>
c.parentId === null || !collections.find((i) => i.id === c.parentId)
)
.map((c) => c.id) || "") as unknown as string[],
hasChildren: true,
isExpanded: true,
data: { name: "Root" } as Collection,
};
return { rootId, items };
};
+32
View File
@@ -0,0 +1,32 @@
import React, { useState } from "react";
type Props = {
text: string;
};
const CopyButton: React.FC<Props> = ({ text }) => {
const [copied, setCopied] = useState(false);
const handleCopy = async () => {
try {
await navigator.clipboard.writeText(text);
setCopied(true);
setTimeout(() => {
setCopied(false);
}, 1000);
} catch (err) {
console.log(err);
}
};
return (
<div
className={`text-xl text-neutral btn btn-sm btn-square btn-ghost ${
copied ? "bi-check2 text-success" : "bi-copy"
}`}
onClick={handleCopy}
></div>
);
};
export default CopyButton;
+23
View File
@@ -0,0 +1,23 @@
export default function dashboardItem({
name,
value,
icon,
}: {
name: string;
value: number;
icon: string;
}) {
return (
<div className="flex items-center justify-between w-full rounded-2xl border border-neutral-content p-3 bg-gradient-to-tr from-neutral-content/70 to-50% to-base-200">
<div className="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 text-right">{name}</p>
<p className="font-thin text-4xl text-primary mt-0.5 text-right">
{value || 0}
</p>
</div>
</div>
);
}
+81
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>
);
}
}
+82 -27
View File
@@ -1,5 +1,5 @@
import Link from "next/link";
import React, { MouseEventHandler } from "react";
import React, { MouseEventHandler, useEffect, useState } from "react";
import ClickAwayHandler from "./ClickAwayHandler";
type MenuItem =
@@ -19,35 +19,90 @@ type Props = {
onClickOutside: Function;
className?: string;
items: MenuItem[];
points?: { x: number; y: number };
style?: React.CSSProperties;
};
export default function Dropdown({ onClickOutside, className, items }: Props) {
return (
<ClickAwayHandler
onClickOutside={onClickOutside}
className={`${className} py-1 shadow-md border border-sky-100 dark:border-neutral-700 bg-gray-50 dark:bg-neutral-800 rounded-md flex flex-col z-20`}
>
{items.map((e, i) => {
const inner = e && (
<div className="cursor-pointer rounded-md">
<div className="flex items-center gap-2 py-1 px-2 hover:bg-slate-200 dark:hover:bg-neutral-700 duration-100">
<p className="text-black dark:text-white select-none">{e.name}</p>
</div>
</div>
);
export default function Dropdown({
points,
onClickOutside,
className,
items,
}: Props) {
const [pos, setPos] = useState<{ x: number; y: number }>();
const [dropdownHeight, setDropdownHeight] = useState<number>();
const [dropdownWidth, setDropdownWidth] = useState<number>();
return e && e.href ? (
<Link key={i} href={e.href}>
{inner}
</Link>
) : (
e && (
<div key={i} onClick={e.onClick}>
{inner}
function convertRemToPixels(rem: number) {
return (
rem * parseFloat(getComputedStyle(document.documentElement).fontSize)
);
}
useEffect(() => {
if (points) {
let finalX = points.x;
let finalY = points.y;
// Check for x-axis overflow (left side)
if (dropdownWidth && points.x + dropdownWidth > window.innerWidth) {
finalX = points.x - dropdownWidth;
}
// Check for y-axis overflow (bottom side)
if (dropdownHeight && points.y + dropdownHeight > window.innerHeight) {
finalY =
window.innerHeight -
(dropdownHeight + (window.innerHeight - points.y));
}
setPos({ x: finalX, y: finalY });
}
}, [points, dropdownHeight]);
return (
(!points || pos) && (
<ClickAwayHandler
onMount={(e) => {
setDropdownHeight(e.height);
setDropdownWidth(e.width);
}}
style={
points
? {
position: "fixed",
top: `${pos?.y}px`,
left: `${pos?.x}px`,
}
: undefined
}
onClickOutside={onClickOutside}
className={`${
className || ""
} py-1 shadow-md border border-neutral-content bg-base-200 rounded-md flex flex-col z-20`}
>
{items.map((e, i) => {
const inner = e && (
<div className="cursor-pointer rounded-md">
<div className="flex items-center gap-2 py-1 px-2 hover:bg-base-100 duration-100">
<p className="select-none">{e.name}</p>
</div>
</div>
)
);
})}
</ClickAwayHandler>
);
return e && e.href ? (
<Link key={i} href={e.href}>
{inner}
</Link>
) : (
e && (
<div key={i} onClick={e.onClick}>
{inner}
</div>
)
);
})}
</ClickAwayHandler>
)
);
}
+134 -50
View File
@@ -1,63 +1,147 @@
import React, { SetStateAction } from "react";
import ClickAwayHandler from "./ClickAwayHandler";
import Checkbox from "./Checkbox";
import { LinkSearchFilter } from "@/types/global";
import { dropdownTriggerer } from "@/lib/client/utils";
import React, { useEffect } from "react";
import { useTranslation } from "next-i18next";
import { resetInfiniteQueryPagination } from "@/hooks/store/links";
import { useQueryClient } from "@tanstack/react-query";
type Props = {
setFilterDropdown: (value: SetStateAction<boolean>) => void;
setSearchFilter: Function;
searchFilter: LinkSearchFilter;
searchFilter: {
name: boolean;
url: boolean;
description: boolean;
textContent: boolean;
tags: boolean;
};
};
export default function FilterSearchDropdown({
setFilterDropdown,
setSearchFilter,
searchFilter,
}: Props) {
const { t } = useTranslation();
const queryClient = useQueryClient();
return (
<ClickAwayHandler
onClickOutside={(e: Event) => {
const target = e.target as HTMLInputElement;
if (target.id !== "filter-dropdown") setFilterDropdown(false);
}}
className="absolute top-8 right-0 border border-sky-100 dark:border-neutral-700 shadow-md bg-gray-50 dark:bg-neutral-800 rounded-md p-2 z-20 w-40"
>
<p className="mb-2 text-black dark:text-white text-center font-semibold">
Filter by
</p>
<div className="flex flex-col gap-2">
<Checkbox
label="Name"
state={searchFilter.name}
onClick={() =>
setSearchFilter({ ...searchFilter, name: !searchFilter.name })
}
/>
<Checkbox
label="Link"
state={searchFilter.url}
onClick={() =>
setSearchFilter({ ...searchFilter, url: !searchFilter.url })
}
/>
<Checkbox
label="Description"
state={searchFilter.description}
onClick={() =>
setSearchFilter({
...searchFilter,
description: !searchFilter.description,
})
}
/>
<Checkbox
label="Tags"
state={searchFilter.tags}
onClick={() =>
setSearchFilter({ ...searchFilter, tags: !searchFilter.tags })
}
/>
<div className="dropdown dropdown-bottom dropdown-end">
<div
tabIndex={0}
role="button"
onMouseDown={dropdownTriggerer}
className="btn btn-sm btn-square btn-ghost"
>
<i className="bi-funnel text-neutral text-2xl"></i>
</div>
</ClickAwayHandler>
<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"
tabIndex={0}
role="button"
>
<input
type="checkbox"
name="search-filter-checkbox"
className="checkbox checkbox-primary"
checked={searchFilter.name}
onChange={() => {
resetInfiniteQueryPagination(queryClient, ["links"]);
setSearchFilter({ ...searchFilter, name: !searchFilter.name });
}}
/>
<span className="label-text whitespace-nowrap">{t("name")}</span>
</label>
</li>
<li>
<label
className="label cursor-pointer flex justify-start"
tabIndex={0}
role="button"
>
<input
type="checkbox"
name="search-filter-checkbox"
className="checkbox checkbox-primary"
checked={searchFilter.url}
onChange={() => {
resetInfiniteQueryPagination(queryClient, ["links"]);
setSearchFilter({ ...searchFilter, url: !searchFilter.url });
}}
/>
<span className="label-text whitespace-nowrap">{t("link")}</span>
</label>
</li>
<li>
<label
className="label cursor-pointer flex justify-start"
tabIndex={0}
role="button"
>
<input
type="checkbox"
name="search-filter-checkbox"
className="checkbox checkbox-primary"
checked={searchFilter.description}
onChange={() => {
resetInfiniteQueryPagination(queryClient, ["links"]);
setSearchFilter({
...searchFilter,
description: !searchFilter.description,
});
}}
/>
<span className="label-text whitespace-nowrap">
{t("description")}
</span>
</label>
</li>
<li>
<label
className="label cursor-pointer flex justify-start"
tabIndex={0}
role="button"
>
<input
type="checkbox"
name="search-filter-checkbox"
className="checkbox checkbox-primary"
checked={searchFilter.tags}
onChange={() => {
resetInfiniteQueryPagination(queryClient, ["links"]);
setSearchFilter({ ...searchFilter, tags: !searchFilter.tags });
}}
/>
<span className="label-text whitespace-nowrap">{t("tags")}</span>
</label>
</li>
<li>
<label
className="label cursor-pointer flex justify-between"
tabIndex={0}
role="button"
>
<input
type="checkbox"
name="search-filter-checkbox"
className="checkbox checkbox-primary"
checked={searchFilter.textContent}
onChange={() => {
resetInfiniteQueryPagination(queryClient, ["links"]);
setSearchFilter({
...searchFilter,
textContent: !searchFilter.textContent,
});
}}
/>
<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>
</li>
</ul>
</div>
);
}
+18
View File
@@ -0,0 +1,18 @@
import React, { forwardRef } from "react";
import * as Icons from "@phosphor-icons/react";
type Props = {
icon: string;
} & Icons.IconProps;
const Icon = forwardRef<SVGSVGElement, Props>(({ icon, ...rest }, ref) => {
const IconComponent: any = Icons[icon as keyof typeof Icons];
if (!IconComponent) {
return null;
} else return <IconComponent ref={ref} {...rest} />;
});
Icon.displayName = "Icon";
export default Icon;
+91
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;
+83
View File
@@ -0,0 +1,83 @@
import React, { useState } from "react";
import TextInput from "./TextInput";
import Popover from "./Popover";
import { HexColorPicker } from "react-colorful";
import { useTranslation } from "next-i18next";
import Icon from "./Icon";
import { IconWeight } from "@phosphor-icons/react";
import IconGrid from "./IconGrid";
import IconPopover from "./IconPopover";
import clsx from "clsx";
type Props = {
alignment?: string;
color: string;
setColor: Function;
iconName?: string;
setIconName: Function;
weight: "light" | "regular" | "bold" | "fill" | "duotone" | "thin";
setWeight: Function;
hideDefaultIcon?: boolean;
reset: Function;
className?: string;
};
const IconPicker = ({
alignment,
color,
setColor,
iconName,
setIconName,
weight,
setWeight,
hideDefaultIcon,
className,
reset,
}: Props) => {
const { t } = useTranslation();
const [iconPicker, setIconPicker] = useState(false);
return (
<div className="relative">
<div
onClick={() => setIconPicker(!iconPicker)}
className="btn btn-square w-20 h-20"
>
{iconName ? (
<Icon
icon={iconName}
size={60}
weight={(weight || "regular") as IconWeight}
color={color || "#0ea5e9"}
/>
) : !iconName && hideDefaultIcon ? (
<p className="p-1">{t("set_custom_icon")}</p>
) : (
<i
className="bi-folder-fill text-6xl"
style={{ color: color || "#0ea5e9" }}
></i>
)}
</div>
{iconPicker && (
<IconPopover
alignment={alignment}
color={color}
setColor={setColor}
iconName={iconName}
setIconName={setIconName}
weight={weight}
setWeight={setWeight}
reset={reset}
onClose={() => setIconPicker(false)}
className={clsx(
className,
alignment || "lg:-translate-x-1/3 top-20 left-0"
)}
/>
)}
</div>
);
};
export default IconPicker;
+147
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;
+100 -18
View File
@@ -1,22 +1,35 @@
import useCollectionStore from "@/store/collections";
import { useRouter } from "next/router";
import { useEffect, useState } from "react";
import Select from "react-select";
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;
defaultValue:
showDefaultValue?: boolean;
defaultValue?:
| {
label: string;
value?: number;
}
| undefined;
creatable?: boolean;
autoFocus?: boolean;
onBlur?: any;
};
export default function CollectionSelection({ onChange, defaultValue }: Props) {
const { collections } = useCollectionStore();
export default function CollectionSelection({
onChange,
defaultValue,
showDefaultValue = true,
creatable = true,
autoFocus,
onBlur,
}: Props) {
const { data: collections = [] } = useCollections();
const router = useRouter();
const [options, setOptions] = useState<Options[]>([]);
@@ -36,22 +49,91 @@ export default function CollectionSelection({ onChange, defaultValue }: Props) {
useEffect(() => {
const formatedCollections = collections.map((e) => {
return { value: e.id, label: e.name, ownerId: e.ownerId };
return {
value: e.id,
label: e.name,
ownerId: e.ownerId,
count: e._count,
parentId: e.parentId,
};
});
setOptions(formatedCollections);
}, [collections]);
return (
<Select
isClearable
className="react-select-container"
classNamePrefix="react-select"
onChange={onChange}
options={options}
styles={styles}
defaultValue={defaultValue}
// menuPosition="fixed"
/>
);
const getParentNames = (parentId: number): string[] => {
const parentNames = [];
const parent = collections.find((e) => e.id === parentId);
if (parent) {
parentNames.push(parent.name);
if (parent.parentId) {
parentNames.push(...getParentNames(parent.parentId));
}
}
// Have the top level parent at beginning
return parentNames.reverse();
};
const customOption = ({ data, innerProps }: any) => {
return (
<div
{...innerProps}
className="px-2 py-2 last:border-0 border-b border-neutral-content hover:bg-neutral-content duration-100 cursor-pointer"
>
<div className="flex w-full justify-between items-center">
<span>{data.label}</span>
<span className="text-sm text-neutral">{data.count?.links}</span>
</div>
<div className="text-xs text-gray-600 dark:text-gray-300">
{getParentNames(data?.parentId).length > 0 ? (
<>
{getParentNames(data.parentId).join(" > ")} {">"} {data.label}
</>
) : (
data.label
)}
</div>
</div>
);
};
if (creatable) {
return (
<CreatableSelect
isClearable={false}
className="react-select-container"
classNamePrefix="react-select"
onChange={onChange}
options={options}
styles={styles}
autoFocus={autoFocus}
onBlur={onBlur}
defaultValue={showDefaultValue ? defaultValue : null}
components={{
Option: customOption,
}}
// menuPosition="fixed"
/>
);
} else {
return (
<Select
isClearable={false}
className="react-select-container"
classNamePrefix="react-select"
onChange={onChange}
options={options}
styles={styles}
autoFocus={autoFocus}
defaultValue={showDefaultValue ? defaultValue : null}
onBlur={onBlur}
components={{
Option: customOption,
}}
// menuPosition="fixed"
/>
);
}
}
+15 -7
View File
@@ -1,24 +1,31 @@
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;
defaultValue?: {
value: number;
value?: number;
label: string;
}[];
autoFocus?: boolean;
onBlur?: any;
};
export default function TagSelection({ onChange, defaultValue }: Props) {
const { tags } = useTagStore();
export default function TagSelection({
onChange,
defaultValue,
autoFocus,
onBlur,
}: Props) {
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 };
});
@@ -27,15 +34,16 @@ export default function TagSelection({ onChange, defaultValue }: Props) {
return (
<CreatableSelect
isClearable
isClearable={false}
className="react-select-container"
classNamePrefix="react-select"
onChange={onChange}
options={options}
styles={styles}
defaultValue={defaultValue}
// menuPosition="fixed"
isMulti
autoFocus={autoFocus}
onBlur={onBlur}
/>
);
}
+33 -16
View File
@@ -8,20 +8,31 @@ export const styles: StylesConfig = {
...styles,
fontFamily: font,
cursor: "pointer",
backgroundColor: state.isSelected ? "#0ea5e9" : "inherit",
backgroundColor: state.isSelected ? "oklch(var(--p))" : "inherit",
"&:hover": {
backgroundColor: state.isSelected ? "#0ea5e9" : "#e2e8f0",
backgroundColor: state.isSelected
? "oklch(var(--p))"
: "oklch(var(--nc))",
},
transition: "all 50ms",
transition: "all 100ms",
}),
control: (styles) => ({
menu: (styles) => ({
...styles,
zIndex: 10,
}),
control: (styles, state) => ({
...styles,
fontFamily: font,
border: "none",
borderRadius: "0.375rem",
border: state.isFocused
? "1px solid oklch(var(--p))"
: "1px solid oklch(var(--nc))",
boxShadow: "none",
minHeight: "2.6rem",
}),
container: (styles) => ({
container: (styles, state) => ({
...styles,
border: "1px solid #e0f2fe",
height: "full",
borderRadius: "0.375rem",
lineHeight: "1.25rem",
// "@media screen and (min-width: 1024px)": {
@@ -36,10 +47,6 @@ export const styles: StylesConfig = {
...styles,
cursor: "pointer",
}),
clearIndicator: (styles) => ({
...styles,
visibility: "hidden",
}),
placeholder: (styles) => ({
...styles,
borderColor: "black",
@@ -47,19 +54,29 @@ export const styles: StylesConfig = {
multiValue: (styles) => {
return {
...styles,
backgroundColor: "#0ea5e9",
color: "white",
backgroundColor: "oklch(var(--b2))",
color: "oklch(var(--bc))",
display: "flex",
alignItems: "center",
gap: "0.1rem",
marginRight: "0.4rem",
};
},
multiValueLabel: (styles) => ({
...styles,
color: "white",
color: "oklch(var(--bc))",
}),
multiValueRemove: (styles) => ({
...styles,
height: "1.2rem",
width: "1.2rem",
borderRadius: "100px",
transition: "all 100ms",
color: "oklch(var(--w))",
":hover": {
color: "white",
backgroundColor: "#38bdf8",
color: "red",
backgroundColor: "oklch(var(--nc))",
},
}),
menuPortal: (base) => ({ ...base, zIndex: 9999 }),
};
+54
View File
@@ -0,0 +1,54 @@
import { isPWA } from "@/lib/client/utils";
import React, { useState } from "react";
import { Trans } from "next-i18next";
type Props = {};
const InstallApp = (props: Props) => {
const [isOpen, setIsOpen] = useState(true);
return isOpen && !isPWA() ? (
<div className="fixed left-0 right-0 bottom-10 w-full px-5">
<div className="mx-auto w-fit p-2 flex justify-between gap-2 items-center border border-neutral-content rounded-xl bg-base-300 backdrop-blur-md bg-opacity-80 max-w-md">
<svg
xmlns="http://www.w3.org/2000/svg"
className="w-8 h-8"
viewBox="0 0 50 50"
>
<path
fill="currentColor"
d="M30.3 13.7L25 8.4l-5.3 5.3l-1.4-1.4L25 5.6l6.7 6.7z"
/>
<path fill="currentColor" d="M24 7h2v21h-2z" />
<path
fill="currentColor"
d="M35 40H15c-1.7 0-3-1.3-3-3V19c0-1.7 1.3-3 3-3h7v2h-7c-.6 0-1 .4-1 1v18c0 .6.4 1 1 1h20c.6 0 1-.4 1-1V19c0-.6-.4-1-1-1h-7v-2h7c1.7 0 3 1.3 3 3v18c0 1.7-1.3 3-3 3"
/>
</svg>
<p className="w-4/5 text-[0.92rem]">
<Trans
i18nKey="pwa_install_prompt"
components={[
<a
className="underline"
target="_blank"
href="https://docs.linkwarden.app/getting-started/pwa-installation"
key={0}
/>,
]}
/>
</p>
<button
onClick={() => setIsOpen(false)}
className="btn btn-ghost btn-square btn-sm"
>
<i className="bi-x text-xl"></i>
</button>
</div>
</div>
) : (
<></>
);
};
export default InstallApp;
-248
View File
@@ -1,248 +0,0 @@
import {
CollectionIncludingMembersAndLinkCount,
LinkIncludingShortenedCollectionAndTags,
} from "@/types/global";
import {
faFolder,
faEllipsis,
faLink,
} from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { useEffect, useState } from "react";
import Image from "next/image";
import Dropdown from "./Dropdown";
import useLinkStore from "@/store/links";
import useCollectionStore from "@/store/collections";
import useAccountStore from "@/store/account";
import useModalStore from "@/store/modals";
import { faCalendarDays } from "@fortawesome/free-regular-svg-icons";
import usePermissions from "@/hooks/usePermissions";
import { toast } from "react-hot-toast";
import isValidUrl from "@/lib/client/isValidUrl";
import Link from "next/link";
import unescapeString from "@/lib/client/unescapeString";
type Props = {
link: LinkIncludingShortenedCollectionAndTags;
count: number;
className?: string;
};
export default function LinkCard({ link, count, className }: Props) {
const { setModal } = useModalStore();
const permissions = usePermissions(link.collection.id as number);
const [expandDropdown, setExpandDropdown] = useState(false);
const { collections } = useCollectionStore();
const { links } = useLinkStore();
const { account } = useAccountStore();
let shortendURL;
try {
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 { removeLink, updateLink } = useLinkStore();
const pinLink = async () => {
const isAlreadyPinned = link?.pinnedBy && link.pinnedBy[0];
const load = toast.loading("Applying...");
setExpandDropdown(false);
const response = await updateLink({
...link,
pinnedBy: isAlreadyPinned ? undefined : [{ id: account.id }],
});
toast.dismiss(load);
response.ok &&
toast.success(`Link ${isAlreadyPinned ? "Unpinned!" : "Pinned!"}`);
};
const deleteLink = async () => {
const load = toast.loading("Deleting...");
const response = await removeLink(link);
toast.dismiss(load);
response.ok && toast.success(`Link Deleted.`);
setExpandDropdown(false);
};
const url = isValidUrl(link.url) ? new URL(link.url) : undefined;
const formattedDate = new Date(link.createdAt as string).toLocaleString(
"en-US",
{
year: "numeric",
month: "short",
day: "numeric",
}
);
return (
<div
className={`h-fit border border-solid border-sky-100 dark:border-neutral-700 bg-gradient-to-tr from-slate-200 dark:from-neutral-800 from-10% to-gray-50 dark:to-[#303030] via-20% shadow hover:shadow-none duration-100 rounded-2xl relative group ${className}`}
>
{(permissions === true ||
permissions?.canUpdate ||
permissions?.canDelete) && (
<div
onClick={() => setExpandDropdown(!expandDropdown)}
id={"expand-dropdown" + link.id}
className="text-gray-500 dark:text-gray-300 inline-flex rounded-md cursor-pointer hover:bg-slate-200 dark:hover:bg-neutral-700 absolute right-5 top-5 z-10 duration-100 p-1"
>
<FontAwesomeIcon
icon={faEllipsis}
title="More"
className="w-5 h-5"
id={"expand-dropdown" + link.id}
/>
</div>
)}
<div
onClick={() => {
setModal({
modal: "LINK",
state: true,
method: "UPDATE",
isOwnerOrMod:
permissions === true || (permissions?.canUpdate as boolean),
active: link,
});
}}
className="flex items-start cursor-pointer gap-5 sm:gap-10 h-full w-full p-5"
>
{url && (
<Image
src={`https://t2.gstatic.com/faviconV2?client=SOCIAL&type=FAVICON&fallback_opts=TYPE,SIZE,URL&url=${url.origin}&size=32`}
width={64}
height={64}
alt=""
className="blur-sm absolute w-16 group-hover:opacity-80 duration-100 rounded-2xl bottom-5 right-5 opacity-60 select-none"
draggable="false"
onError={(e) => {
const target = e.target as HTMLElement;
target.style.display = "none";
}}
/>
)}
<div className="flex justify-between gap-5 w-full h-full z-0">
<div className="flex flex-col justify-between w-full">
<div className="flex items-baseline gap-1">
<p className="text-sm text-gray-500 dark:text-gray-300">
{count + 1}
</p>
<p className="text-lg text-black dark:text-white truncate capitalize w-full pr-8">
{unescapeString(link.name || link.description)}
</p>
</div>
<Link
href={`/collections/${link.collection.id}`}
onClick={(e) => {
e.stopPropagation();
}}
className="flex items-center gap-1 max-w-full w-fit my-3 hover:opacity-70 duration-100"
>
<FontAwesomeIcon
icon={faFolder}
className="w-4 h-4 mt-1 drop-shadow"
style={{ color: collection?.color }}
/>
<p className="text-black dark:text-white truncate capitalize w-full">
{collection?.name}
</p>
</Link>
<Link
href={link.url}
target="_blank"
onClick={(e) => {
e.stopPropagation();
}}
className="flex items-center gap-1 max-w-full w-fit text-gray-500 dark:text-gray-300 hover:opacity-70 duration-100"
>
<FontAwesomeIcon icon={faLink} className="mt-1 w-4 h-4" />
<p className="truncate w-full">{shortendURL}</p>
</Link>
<div className="flex items-center gap-1 text-gray-500 dark:text-gray-300">
<FontAwesomeIcon icon={faCalendarDays} className="w-4 h-4" />
<p>{formattedDate}</p>
</div>
</div>
</div>
</div>
{expandDropdown ? (
<Dropdown
items={[
permissions === true
? {
name:
link?.pinnedBy && link.pinnedBy[0]
? "Unpin"
: "Pin to Dashboard",
onClick: pinLink,
}
: undefined,
permissions === true || permissions?.canUpdate
? {
name: "Edit",
onClick: () => {
setModal({
modal: "LINK",
state: true,
method: "UPDATE",
isOwnerOrMod:
permissions === true || permissions?.canUpdate,
active: link,
defaultIndex: 1,
});
setExpandDropdown(false);
},
}
: undefined,
permissions === true || permissions?.canDelete
? {
name: "Delete",
onClick: deleteLink,
}
: undefined,
]}
onClickOutside={(e: Event) => {
const target = e.target as HTMLInputElement;
if (target.id !== "expand-dropdown" + link.id)
setExpandDropdown(false);
}}
className="absolute top-12 right-5 w-36"
/>
) : null}
</div>
);
}
+687
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>
);
}
+217
View File
@@ -0,0 +1,217 @@
import React, { Dispatch, SetStateAction, useEffect, useState } from "react";
import FilterSearchDropdown from "./FilterSearchDropdown";
import SortDropdown from "./SortDropdown";
import ViewDropdown from "./ViewDropdown";
import { TFunction } from "i18next";
import BulkDeleteLinksModal from "./ModalContent/BulkDeleteLinksModal";
import BulkEditLinksModal from "./ModalContent/BulkEditLinksModal";
import useCollectivePermissions from "@/hooks/useCollectivePermissions";
import { useRouter } from "next/router";
import useLinkStore from "@/store/links";
import { 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: ViewMode;
setViewMode: Dispatch<SetStateAction<ViewMode>>;
searchFilter?: {
name: boolean;
url: boolean;
description: boolean;
tags: boolean;
textContent: boolean;
};
setSearchFilter?: (filter: {
name: boolean;
url: boolean;
description: boolean;
tags: boolean;
textContent: boolean;
}) => void;
sortBy: Sort;
setSortBy: Dispatch<SetStateAction<Sort>>;
editMode?: boolean;
setEditMode?: (mode: boolean) => void;
};
const LinkListOptions = ({
children,
t,
viewMode,
setViewMode,
searchFilter,
setSearchFilter,
sortBy,
setSortBy,
editMode,
setEditMode,
}: Props) => {
const { selectedLinks, setSelectedLinks } = useLinkStore();
const deleteLinksById = useBulkDeleteLinks();
const { links } = useLinks();
const router = useRouter();
const [bulkDeleteLinksModal, setBulkDeleteLinksModal] = useState(false);
const [bulkEditLinksModal, setBulkEditLinksModal] = useState(false);
useEffect(() => {
if (editMode && setEditMode) return setEditMode(false);
}, [router]);
const collectivePermissions = useCollectivePermissions(
selectedLinks.map((link) => link.collectionId as number)
);
const handleSelectAll = () => {
if (selectedLinks.length === links.length) {
setSelectedLinks([]);
} else {
setSelectedLinks(links.map((link) => link));
}
};
const bulkDeleteLinks = async () => {
const load = toast.loading(t("deleting"));
await deleteLinksById.mutateAsync(
selectedLinks.map((link) => link.id as number),
{
onSettled: (data, error) => {
toast.dismiss(load);
if (error) {
toast.error(error.message);
} else {
setSelectedLinks([]);
toast.success(t("deleted"));
}
},
}
);
};
return (
<>
<div className="flex justify-between items-center">
{children}
<div className="flex gap-3 items-center justify-end">
<div className="flex gap-2 items-center mt-2">
{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={(value) => {
setSortBy(value);
}}
t={t}
/>
<ViewDropdown viewMode={viewMode} setViewMode={setViewMode} />
</div>
</div>
</div>
{links && editMode && links.length > 0 && (
<div className="w-full flex justify-between items-center min-h-[32px]">
<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)}
className="btn btn-sm btn-accent text-white w-fit ml-auto"
disabled={
selectedLinks.length === 0 ||
!(
collectivePermissions === true ||
collectivePermissions?.canUpdate
)
}
>
{t("edit")}
</button>
<button
onClick={(e) => {
(document?.activeElement as HTMLElement)?.blur();
e.shiftKey ? bulkDeleteLinks() : setBulkDeleteLinksModal(true);
}}
className="btn btn-sm bg-red-500 hover:bg-red-400 text-white w-fit ml-auto"
disabled={
selectedLinks.length === 0 ||
!(
collectivePermissions === true ||
collectivePermissions?.canDelete
)
}
>
{t("delete")}
</button>
</div>
</div>
)}
{bulkDeleteLinksModal && (
<BulkDeleteLinksModal
onClose={() => {
setBulkDeleteLinksModal(false);
}}
/>
)}
{bulkEditLinksModal && (
<BulkEditLinksModal
onClose={() => {
setBulkEditLinksModal(false);
}}
/>
)}
</>
);
};
export default LinkListOptions;
@@ -0,0 +1,192 @@
import { useState } from "react";
import {
CollectionIncludingMembersAndLinkCount,
LinkIncludingShortenedCollectionAndTags,
} from "@/types/global";
import usePermissions from "@/hooks/usePermissions";
import DeleteLinkModal from "@/components/ModalContent/DeleteLinkModal";
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;
btnStyle?: string;
};
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 deleteLink = useDeleteLink();
const updateArchive = async () => {
const load = toast.loading(t("sending_request"));
const response = await fetch(`/api/v1/links/${link?.id}/archive`, {
method: "PUT",
});
const data = await response.json();
toast.dismiss(load);
if (response.ok) {
await getLink.mutateAsync({ id: link.id as number });
toast.success(t("link_being_archived"));
} else toast.error(data.response);
};
const router = useRouter();
const isPublicRoute = router.pathname.startsWith("/public") ? true : false;
return (
<>
{isPublicRoute ? (
<div
className="absolute top-3 right-3 group-hover:opacity-100 group-focus-within:opacity-100 opacity-0 duration-100"
onMouseDown={dropdownTriggerer}
onClick={() => setLinkModal(true)}
>
<div className={clsx("btn btn-sm btn-square text-neutral", btnStyle)}>
<i title="More" className="bi-info-circle text-xl" />
</div>
</div>
) : (
<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`}
>
<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();
pinLink(link);
}}
className="whitespace-nowrap"
>
{link?.pinnedBy && link.pinnedBy[0]
? t("unpin")
: t("pin_to_dashboard")}
</div>
</li>
<li>
<div
role="button"
tabIndex={0}
onClick={() => {
(document?.activeElement as HTMLElement)?.blur();
setLinkModal(true);
}}
className="whitespace-nowrap"
>
{t("show_link_details")}
</div>
</li>
{(permissions === true || permissions?.canUpdate) && (
<li>
<div
role="button"
tabIndex={0}
onClick={() => {
(document?.activeElement as HTMLElement)?.blur();
setEditLinkModal(true);
}}
className="whitespace-nowrap"
>
{t("edit_link")}
</div>
</li>
)}
{(permissions === true || permissions?.canDelete) && (
<li>
<div
role="button"
tabIndex={0}
onClick={async (e) => {
(document?.activeElement as HTMLElement)?.blur();
console.log(e.shiftKey);
e.shiftKey
? (async () => {
const load = toast.loading(t("deleting"));
await deleteLink.mutateAsync(link.id as number, {
onSettled: (data, error) => {
toast.dismiss(load);
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)}
onPin={() => pinLink(link)}
onUpdateArchive={updateArchive}
onDelete={() => setDeleteLinkModal(true)}
link={link}
activeMode="edit"
/>
)}
{deleteLinkModal && (
<DeleteLinkModal
onClose={() => setDeleteLinkModal(false)}
activeLink={link}
/>
)}
{linkModal && (
<LinkModal
onClose={() => setLinkModal(false)}
onPin={() => pinLink(link)}
onUpdateArchive={updateArchive}
onDelete={() => setDeleteLinkModal(true)}
link={link}
/>
)}
</>
);
}
@@ -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>
);
}
@@ -0,0 +1,49 @@
import Icon from "@/components/Icon";
import {
CollectionIncludingMembersAndLinkCount,
LinkIncludingShortenedCollectionAndTags,
} from "@/types/global";
import { IconWeight } from "@phosphor-icons/react";
import Link from "next/link";
import { useRouter } from "next/router";
import React from "react";
export default function LinkCollection({
link,
collection,
}: {
link: LinkIncludingShortenedCollectionAndTags;
collection: CollectionIncludingMembersAndLinkCount;
}) {
const router = useRouter();
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}
>
{link.collection.icon ? (
<Icon
icon={link.collection.icon}
size={20}
weight={(link.collection.iconWeight || "regular") as IconWeight}
color={link.collection.color}
/>
) : (
<i
className="bi-folder-fill text-lg"
style={{ color: link.collection.color }}
></i>
)}
<p className="truncate capitalize">{collection?.name}</p>
</Link>
</>
) : null;
}
@@ -0,0 +1,23 @@
import { LinkIncludingShortenedCollectionAndTags } from "@/types/global";
import React from "react";
export default function LinkDate({
link,
}: {
link: LinkIncludingShortenedCollectionAndTags;
}) {
const formattedDate = new Date(
(link.importDate || link.createdAt) as string
).toLocaleString("en-US", {
year: "numeric",
month: "short",
day: "numeric",
});
return (
<div className="flex items-center gap-1 text-neutral min-w-fit">
<i className="bi-calendar3 text-lg"></i>
<p>{formattedDate}</p>
</div>
);
}
@@ -0,0 +1,99 @@
import { LinkIncludingShortenedCollectionAndTags } from "@/types/global";
import Image from "next/image";
import isValidUrl from "@/lib/shared/isValidUrl";
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,
hideBackground,
onClick,
}: {
link: LinkIncludingShortenedCollectionAndTags;
className?: string;
hideBackground?: boolean;
onClick?: Function;
}) {
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 [faviconLoaded, setFaviconLoaded] = useState(false);
return (
<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={clsx(
iconClasses,
faviconLoaded ? "" : "absolute opacity-0"
)}
draggable="false"
onLoadingComplete={() => setFaviconLoaded(true)}
onError={() => setFaviconLoaded(false)}
/>
{!faviconLoaded && (
<LinkPlaceholderIcon
iconClasses={iconClasses}
icon="bi-link-45deg"
/>
)}
</>
) : link.type === "pdf" ? (
<LinkPlaceholderIcon
iconClasses={iconClasses}
icon="bi-file-earmark-pdf"
/>
) : link.type === "image" ? (
<LinkPlaceholderIcon
iconClasses={iconClasses}
icon="bi-file-earmark-image"
/>
) : // : link.type === "monolith" ? (
// <LinkPlaceholderIcon
// iconClasses={iconClasses + dimension}
// size={size}
// icon="bi-filetype-html"
// />
// )
undefined}
</div>
);
}
const LinkPlaceholderIcon = ({
iconClasses,
icon,
}: {
iconClasses: string;
icon: string;
}) => {
return (
<div className={clsx(iconClasses, "aspect-square text-4xl text-[#006796]")}>
<i className={`${icon} m-auto`}></i>
</div>
);
};
@@ -0,0 +1,148 @@
import {
CollectionIncludingMembersAndLinkCount,
LinkIncludingShortenedCollectionAndTags,
} from "@/types/global";
import { useEffect, 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 LinkIcon from "@/components/LinkViews/LinkComponents/LinkIcon";
import { isPWA } from "@/lib/client/utils";
import { generateLinkHref } from "@/lib/client/generateLinkHref";
import usePermissions from "@/hooks/usePermissions";
import toast from "react-hot-toast";
import LinkTypeBadge from "./LinkTypeBadge";
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;
editMode?: boolean;
};
export default function LinkCardCompact({ link, editMode }: Props) {
const { t } = useTranslation();
const { data: collections = [] } = useCollections();
const { data: user = {} } = useUser();
const { setSelectedLinks, selectedLinks } = useLinkStore();
const {
settings: { show },
} = useLocalSettingsStore();
const { links } = useLinks();
useEffect(() => {
if (!editMode) {
setSelectedLinks([]);
}
}, [editMode]);
const handleCheckboxClick = (
link: LinkIncludingShortenedCollectionAndTags
) => {
const linkIndex = selectedLinks.findIndex(
(selectedLink) => selectedLink.id === link.id
);
if (linkIndex !== -1) {
const updatedLinks = [...selectedLinks];
updatedLinks.splice(linkIndex, 1);
setSelectedLinks(updatedLinks);
} else {
setSelectedLinks([...selectedLinks, link]);
}
};
const [collection, setCollection] =
useState<CollectionIncludingMembersAndLinkCount>(
collections.find(
(e) => e.id === link.collection.id
) as CollectionIncludingMembersAndLinkCount
);
useEffect(() => {
setCollection(
collections.find(
(e) => e.id === link.collection.id
) as CollectionIncludingMembersAndLinkCount
);
}, [collections, links]);
const permissions = usePermissions(collection?.id as number);
const selectedStyle = selectedLinks.some(
(selectedLink) => selectedLink.id === link.id
)
? "border border-primary bg-base-300"
: "border-transparent";
const selectable =
editMode &&
(permissions === true || permissions?.canCreate || permissions?.canDelete);
const router = useRouter();
let isPublic = router.pathname.startsWith("/public") ? true : undefined;
return (
<>
<div
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)
: editMode
? toast.error(t("link_selection_error"))
: undefined
}
>
<div
className="flex items-center cursor-pointer w-full min-h-12"
onClick={() =>
!editMode && window.open(generateLinkHref(link, user), "_blank")
}
>
{show.icon && (
<div className="shrink-0">
<LinkIcon link={link} hideBackground />
</div>
)}
<div className="w-[calc(100%-56px)] ml-2">
{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">
{show.link && <LinkTypeBadge link={link} />}
{show.collection && (
<LinkCollection link={link} collection={collection} />
)}
{show.date && <LinkDate link={link} />}
</div>
</div>
</div>
</div>
{!isPublic && <LinkPin link={link} btnStyle="btn-ghost" />}
<LinkActions link={link} collection={collection} btnStyle="btn-ghost" />
</div>
<div className="last:hidden rounded-none my-0 mx-1 border-t border-base-300 h-[1px]"></div>
</>
);
}
@@ -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>
);
}
@@ -0,0 +1,34 @@
import { LinkIncludingShortenedCollectionAndTags } from "@/types/global";
import { useRouter } from "next/router";
import clsx from "clsx";
import usePinLink from "@/lib/client/pinLink";
type Props = {
link: LinkIncludingShortenedCollectionAndTags;
btnStyle?: string;
};
export default function LinkPin({ link, btnStyle }: Props) {
const pinLink = usePinLink();
const router = useRouter();
const isPublicRoute = router.pathname.startsWith("/public") ? true : false;
const isAlreadyPinned = link?.pinnedBy && link.pinnedBy[0] ? true : false;
return (
<div
className="absolute top-3 right-[3.25rem] group-hover:opacity-100 group-focus-within:opacity-100 opacity-0 duration-100"
onClick={() => pinLink(link)}
>
<div className={clsx("btn btn-sm btn-square text-neutral", btnStyle)}>
<i
title="Pin"
className={clsx(
"text-xl",
isAlreadyPinned ? "bi-pin-fill" : "bi-pin"
)}
/>
</div>
</div>
);
}
@@ -0,0 +1,36 @@
import { LinkIncludingShortenedCollectionAndTags } from "@/types/global";
import Link from "next/link";
import React from "react";
export default function LinkTypeBadge({
link,
}: {
link: LinkIncludingShortenedCollectionAndTags;
}) {
let shortendURL;
if (link.type === "url" && link.url) {
try {
shortendURL = new URL(link.url).host.toLowerCase();
} catch (error) {
console.log(error);
}
}
return link.url && shortendURL ? (
<Link
href={link.url || ""}
target="_blank"
title={link.url || ""}
onClick={(e) => {
e.stopPropagation();
}}
className="flex gap-1 item-center select-none text-neutral hover:opacity-70 duration-100 max-w-full w-fit"
>
<i className="bi-link-45deg text-lg leading-none"></i>
<p className="text-xs truncate">{shortendURL}</p>
</Link>
) : (
<div className="badge badge-primary badge-sm select-none">{link.type}</div>
);
}
+336
View File
@@ -0,0 +1,336 @@
import LinkCard from "@/components/LinkViews/LinkComponents/LinkCard";
import {
LinkIncludingShortenedCollectionAndTags,
ViewMode,
} from "@/types/global";
import { useEffect, useState } from "react";
import { useInView } from "react-intersection-observer";
import LinkMasonry from "@/components/LinkViews/LinkComponents/LinkMasonry";
import Masonry from "react-masonry-css";
import resolveConfig from "tailwindcss/resolveConfig";
import tailwindConfig from "../../tailwind.config.js";
import { useMemo } from "react";
import LinkList from "@/components/LinkViews/LinkComponents/LinkList";
import useLocalSettingsStore from "@/store/localSettings";
export function CardView({
links,
editMode,
isLoading,
placeholders,
hasNextPage,
placeHolderRef,
}: {
links?: LinkIncludingShortenedCollectionAndTags[];
editMode?: boolean;
isLoading?: boolean;
placeholders?: number[];
hasNextPage?: boolean;
placeHolderRef?: any;
}) {
const settings = useLocalSettingsStore((state) => state.settings);
const gridMap = {
1: "grid-cols-1",
2: "grid-cols-2",
3: "grid-cols-3",
4: "grid-cols-4",
5: "grid-cols-5",
6: "grid-cols-6",
7: "grid-cols-7",
8: "grid-cols-8",
};
const getColumnCount = () => {
const width = window.innerWidth;
if (width >= 1901) return 5;
if (width >= 1501) return 4;
if (width >= 881) return 3;
if (width >= 551) return 2;
return 1;
};
const [columnCount, setColumnCount] = useState(
settings.columns || getColumnCount()
);
const gridColClass = useMemo(
() => gridMap[columnCount as keyof typeof gridMap],
[columnCount]
);
useEffect(() => {
const handleResize = () => {
if (settings.columns === 0) {
// Only recalculate if zustandColumns is zero
setColumnCount(getColumnCount());
}
};
if (settings.columns === 0) {
window.addEventListener("resize", handleResize);
}
setColumnCount(settings.columns || getColumnCount());
return () => {
if (settings.columns === 0) {
window.removeEventListener("resize", handleResize);
}
};
}, [settings.columns]);
return (
<div className={`${gridColClass} grid gap-5 pb-5`}>
{links?.map((e, i) => {
return (
<LinkCard
key={i}
link={e}
count={i}
editMode={editMode}
columns={columnCount}
/>
);
})}
{(hasNextPage || isLoading) &&
placeholders?.map((e, i) => {
return (
<div
className="flex flex-col gap-4"
ref={e === 1 ? placeHolderRef : undefined}
key={i}
>
<div className="skeleton h-40 w-full"></div>
<div className="skeleton h-3 w-2/3"></div>
<div className="skeleton h-3 w-full"></div>
<div className="skeleton h-3 w-full"></div>
<div className="skeleton h-3 w-1/3"></div>
</div>
);
})}
</div>
);
}
export function MasonryView({
links,
editMode,
isLoading,
placeholders,
hasNextPage,
placeHolderRef,
}: {
links?: LinkIncludingShortenedCollectionAndTags[];
editMode?: boolean;
isLoading?: boolean;
placeholders?: number[];
hasNextPage?: boolean;
placeHolderRef?: any;
}) {
const settings = useLocalSettingsStore((state) => state.settings);
const gridMap = {
1: "grid-cols-1",
2: "grid-cols-2",
3: "grid-cols-3",
4: "grid-cols-4",
5: "grid-cols-5",
6: "grid-cols-6",
7: "grid-cols-7",
8: "grid-cols-8",
};
const getColumnCount = () => {
const width = window.innerWidth;
if (width >= 1901) return 5;
if (width >= 1501) return 4;
if (width >= 881) return 3;
if (width >= 551) return 2;
return 1;
};
const [columnCount, setColumnCount] = useState(
settings.columns || getColumnCount()
);
const gridColClass = useMemo(
() => gridMap[columnCount as keyof typeof gridMap],
[columnCount]
);
useEffect(() => {
const handleResize = () => {
if (settings.columns === 0) {
// Only recalculate if zustandColumns is zero
setColumnCount(getColumnCount());
}
};
if (settings.columns === 0) {
window.addEventListener("resize", handleResize);
}
setColumnCount(settings.columns || getColumnCount());
return () => {
if (settings.columns === 0) {
window.removeEventListener("resize", handleResize);
}
};
}, [settings.columns]);
const fullConfig = resolveConfig(tailwindConfig as any);
const breakpointColumnsObj = useMemo(() => {
return {
default: 5,
1900: 4,
1500: 3,
880: 2,
550: 1,
};
}, []);
return (
<Masonry
breakpointCols={
settings.columns === 0 ? breakpointColumnsObj : columnCount
}
columnClassName="flex flex-col gap-5 !w-full"
className={`${gridColClass} grid gap-5 pb-5`}
>
{links?.map((e, i) => {
return (
<LinkMasonry
key={i}
link={e}
editMode={editMode}
columns={columnCount}
/>
);
})}
{(hasNextPage || isLoading) &&
placeholders?.map((e, i) => {
return (
<div
className="flex flex-col gap-4"
ref={e === 1 ? placeHolderRef : undefined}
key={i}
>
<div className="skeleton h-40 w-full"></div>
<div className="skeleton h-3 w-2/3"></div>
<div className="skeleton h-3 w-full"></div>
<div className="skeleton h-3 w-full"></div>
<div className="skeleton h-3 w-1/3"></div>
</div>
);
})}
</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 flex-col">
{links?.map((e, i) => {
return <LinkList key={i} link={e} count={i} editMode={editMode} />;
})}
{(hasNextPage || isLoading) &&
placeholders?.map((e, i) => {
return (
<div
ref={e === 1 ? placeHolderRef : undefined}
key={i}
className="flex gap-2 py-2 px-1"
>
<div className="skeleton h-12 w-12"></div>
<div className="flex flex-col gap-3 w-full">
<div className="skeleton h-2 w-2/3"></div>
<div className="skeleton h-2 w-full"></div>
<div className="skeleton h-2 w-1/3"></div>
</div>
</div>
);
})}
</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) : [];
-7
View File
@@ -1,7 +0,0 @@
export default function Loading() {
return (
<div>
<p>Loading...</p>
</div>
);
}
+99
View File
@@ -0,0 +1,99 @@
import { dropdownTriggerer, isIphone, isPWA } from "@/lib/client/utils";
import React from "react";
import { useState } from "react";
import NewLinkModal from "./ModalContent/NewLinkModal";
import NewCollectionModal from "./ModalContent/NewCollectionModal";
import UploadFileModal from "./ModalContent/UploadFileModal";
import MobileNavigationButton from "./MobileNavigationButton";
import { useTranslation } from "next-i18next";
type Props = {};
export default function MobileNavigation({}: Props) {
const { t } = useTranslation();
const [newLinkModal, setNewLinkModal] = useState(false);
const [newCollectionModal, setNewCollectionModal] = useState(false);
const [uploadFileModal, setUploadFileModal] = useState(false);
return (
<>
<div
className={`fixed bottom-0 left-0 right-0 z-30 duration-200 sm:hidden`}
>
<div
className={`w-full flex bg-base-100 ${
isIphone() && isPWA() ? "pb-5" : ""
} border-solid border-t-neutral-content border-t`}
>
<MobileNavigationButton href={`/dashboard`} icon={"bi-house"} />
<MobileNavigationButton
href={`/links/pinned`}
icon={"bi-pin-angle"}
/>
<div className="dropdown dropdown-top -mt-4">
<div
tabIndex={0}
role="button"
onMouseDown={dropdownTriggerer}
className={`flex items-center btn btn-accent dark:border-violet-400 text-white btn-circle w-20 h-20 px-2 relative`}
>
<span>
<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 mb-1 -ml-12">
<li>
<div
onClick={() => {
(document?.activeElement as HTMLElement)?.blur();
setNewLinkModal(true);
}}
tabIndex={0}
role="button"
className="whitespace-nowrap"
>
{t("new_link")}
</div>
</li>
<li>
<div
onClick={() => {
(document?.activeElement as HTMLElement)?.blur();
setUploadFileModal(true);
}}
tabIndex={0}
role="button"
className="whitespace-nowrap"
>
{t("upload_file")}
</div>
</li>
<li>
<div
onClick={() => {
(document?.activeElement as HTMLElement)?.blur();
setNewCollectionModal(true);
}}
tabIndex={0}
role="button"
className="whitespace-nowrap"
>
{t("new_collection")}
</div>
</li>
</ul>
</div>
<MobileNavigationButton href={`/links`} icon={"bi-link-45deg"} />
<MobileNavigationButton href={`/collections`} icon={"bi-folder"} />
</div>
</div>
{newLinkModal && <NewLinkModal onClose={() => setNewLinkModal(false)} />}
{newCollectionModal && (
<NewCollectionModal onClose={() => setNewCollectionModal(false)} />
)}
{uploadFileModal && (
<UploadFileModal onClose={() => setUploadFileModal(false)} />
)}
</>
);
}
+45
View File
@@ -0,0 +1,45 @@
import { isPWA } from "@/lib/client/utils";
import Link from "next/link";
import { useRouter } from "next/router";
import { useEffect, useState } from "react";
export default function MobileNavigationButton({
href,
icon,
}: {
href: string;
icon: string;
}) {
const router = useRouter();
const [active, setActive] = useState(false);
useEffect(() => {
setActive(href === router.asPath);
}, [router]);
return (
<Link
href={href}
className="w-full active:scale-[80%] duration-200 select-none"
draggable="false"
style={{ WebkitTouchCallout: "none" }}
onContextMenu={(e) => {
if (isPWA()) {
e.preventDefault();
e.stopPropagation();
return false;
} else return null;
}}
>
<div
className={`py-2 cursor-pointer gap-2 w-full rounded-full capitalize flex items-center justify-center`}
>
<i
className={`${icon} text-primary text-3xl drop-shadow duration-200 rounded-full w-14 h-14 text-center pt-[0.65rem] ${
active || false ? "bg-primary/20" : ""
}`}
></i>
</div>
</Link>
);
}
+90
View File
@@ -0,0 +1,90 @@
import React, { MouseEventHandler, ReactNode, useEffect } from "react";
import ClickAwayHandler from "@/components/ClickAwayHandler";
import { Drawer } from "vaul";
type Props = {
toggleModal: Function;
children: ReactNode;
className?: string;
dismissible?: boolean;
};
export default function Modal({
toggleModal,
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 (
<Drawer.Root
open={drawerIsOpen}
onClose={() => dismissible && setDrawerIsOpen(false)}
onAnimationEnd={(isOpen) => !isOpen && toggleModal()}
dismissible={dismissible}
>
<Drawer.Portal>
<Drawer.Overlay className="fixed inset-0 bg-black/40" />
<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="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>
</Drawer.Portal>
</Drawer.Root>
);
} else {
return (
<div
className="overflow-y-auto pt-2 sm:py-2 fixed top-0 bottom-0 right-0 left-0 bg-black bg-opacity-10 backdrop-blur-sm flex justify-center items-center fade-in z-40"
data-testid="modal-outer"
>
<ClickAwayHandler
onClickOutside={() => dismissible && toggleModal()}
className={`w-full mt-auto sm:m-auto sm:w-11/12 sm:max-w-2xl ${
className || ""
}`}
>
<div
className="slide-up mt-auto sm:m-auto relative border-neutral-content rounded-t-2xl sm:rounded-2xl border-t sm:border shadow-2xl p-5 bg-base-100 overflow-y-auto sm:overflow-y-visible"
data-testid="modal-container"
>
{dismissible && (
<div
onClick={toggleModal as MouseEventHandler<HTMLDivElement>}
className="absolute top-4 right-3 btn btn-sm outline-none btn-circle btn-ghost z-10"
>
<i
className="bi-x text-neutral text-2xl"
data-testid="close-modal-button"
></i>
</div>
)}
{children}
</div>
</ClickAwayHandler>
</div>
);
}
}
@@ -1,129 +0,0 @@
import { Dispatch, SetStateAction, useState } from "react";
import {
faFolder,
faPenToSquare,
faPlus,
} from "@fortawesome/free-solid-svg-icons";
import useCollectionStore from "@/store/collections";
import { CollectionIncludingMembersAndLinkCount } from "@/types/global";
import RequiredBadge from "../../RequiredBadge";
import SubmitButton from "@/components/SubmitButton";
import { HexColorPicker } from "react-colorful";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { toast } from "react-hot-toast";
import TextInput from "@/components/TextInput";
type Props = {
toggleCollectionModal: Function;
setCollection: Dispatch<
SetStateAction<CollectionIncludingMembersAndLinkCount>
>;
collection: CollectionIncludingMembersAndLinkCount;
method: "CREATE" | "UPDATE";
};
export default function CollectionInfo({
toggleCollectionModal,
setCollection,
collection,
method,
}: Props) {
const [submitLoader, setSubmitLoader] = useState(false);
const { updateCollection, addCollection } = useCollectionStore();
const submit = async () => {
if (!collection) return null;
setSubmitLoader(true);
const load = toast.loading(
method === "UPDATE" ? "Applying..." : "Creating..."
);
let response;
if (method === "CREATE") response = await addCollection(collection);
else response = await updateCollection(collection);
toast.dismiss(load);
if (response.ok) {
toast.success(
`Collection ${method === "UPDATE" ? "Saved!" : "Created!"}`
);
toggleCollectionModal();
} else toast.error(response.data as string);
setSubmitLoader(false);
};
return (
<div className="flex flex-col gap-3 sm:w-[35rem] w-80">
<div className="flex flex-col sm:flex-row gap-3">
<div className="w-full">
<p className="text-sm text-black dark:text-white mb-2">
Name
<RequiredBadge />
</p>
<div className="flex flex-col gap-3">
<TextInput
value={collection.name}
placeholder="e.g. Example Collection"
onChange={(e) =>
setCollection({ ...collection, name: e.target.value })
}
/>
<div className="color-picker flex justify-between">
<div className="flex flex-col justify-between items-center w-32">
<p className="text-sm w-full text-black dark:text-white mb-2">
Icon Color
</p>
<div style={{ color: collection.color }}>
<FontAwesomeIcon
icon={faFolder}
className="w-12 h-12 drop-shadow"
/>
</div>
<div
className="py-1 px-2 rounded-md text-xs font-semibold cursor-pointer text-black dark:text-white hover:bg-slate-200 hover:dark:bg-neutral-700 duration-100"
onClick={() =>
setCollection({ ...collection, color: "#0ea5e9" })
}
>
Reset
</div>
</div>
<HexColorPicker
color={collection.color}
onChange={(e) => setCollection({ ...collection, color: e })}
/>
</div>
</div>
</div>
<div className="w-full">
<p className="text-sm text-black dark:text-white mb-2">Description</p>
<textarea
className="w-full h-[11.4rem] resize-none border rounded-md duration-100 bg-gray-50 dark:bg-neutral-950 p-2 outline-none border-sky-100 dark:border-neutral-700 focus:border-sky-300 dark:focus:border-sky-600"
placeholder="The purpose of this Collection..."
value={collection.description}
onChange={(e) =>
setCollection({
...collection,
description: e.target.value,
})
}
/>
</div>
</div>
<SubmitButton
onClick={submit}
loading={submitLoader}
label={method === "CREATE" ? "Add" : "Save"}
icon={method === "CREATE" ? faPlus : faPenToSquare}
className="mx-auto mt-2"
/>
</div>
);
}
@@ -1,124 +0,0 @@
import React, { useState } from "react";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import {
faRightFromBracket,
faTrashCan,
} from "@fortawesome/free-solid-svg-icons";
import { CollectionIncludingMembersAndLinkCount } from "@/types/global";
import useCollectionStore from "@/store/collections";
import { useRouter } from "next/router";
import usePermissions from "@/hooks/usePermissions";
import { toast } from "react-hot-toast";
import TextInput from "@/components/TextInput";
type Props = {
toggleDeleteCollectionModal: Function;
collection: CollectionIncludingMembersAndLinkCount;
};
export default function DeleteCollection({
toggleDeleteCollectionModal,
collection,
}: Props) {
const [inputField, setInputField] = useState("");
const { removeCollection } = useCollectionStore();
const router = useRouter();
const submit = async () => {
if (permissions === true) if (collection.name !== inputField) return null;
const load = toast.loading("Deleting...");
const response = await removeCollection(collection.id as number);
toast.dismiss(load);
if (response.ok) {
toast.success("Collection Deleted.");
toggleDeleteCollectionModal();
router.push("/collections");
}
};
const permissions = usePermissions(collection.id as number);
return (
<div className="flex flex-col gap-3 justify-between sm:w-[35rem] w-80">
{permissions === true ? (
<>
<p className="text-red-500 font-bold text-center">Warning!</p>
<div className="max-h-[20rem] overflow-y-auto">
<div className="text-black dark:text-white">
<p>
Please note that deleting the collection will permanently remove
all its contents, including the following:
</p>
<div className="p-3">
<li className="list-inside">
Links: All links within the collection will be permanently
deleted.
</li>
<li className="list-inside">
Tags: All tags associated with the collection will be removed.
</li>
<li className="list-inside">
Screenshots/PDFs: Any screenshots or PDFs attached to links
within this collection will be permanently deleted.
</li>
<li className="list-inside">
Members: Any members who have been granted access to the
collection will lose their permissions and no longer be able
to view or interact with the content.
</li>
</div>
<p>
Please double-check that you have backed up any essential data
and have informed the relevant members about this action.
</p>
</div>
</div>
<div className="flex flex-col gap-3">
<p className="text-black dark:text-white text-center">
To confirm, type &quot;
<span className="font-bold">{collection.name}</span>
&quot; in the box below:
</p>
<TextInput
autoFocus={true}
value={inputField}
onChange={(e) => setInputField(e.target.value)}
placeholder={`Type "${collection.name}" Here.`}
className="w-3/4 mx-auto"
/>
</div>
</>
) : (
<p className="text-black dark:text-white">
Click the button below to leave the current collection.
</p>
)}
<div
className={`mx-auto mt-2 text-white flex items-center gap-2 py-2 px-5 rounded-md select-none font-bold duration-100 ${
permissions === true
? inputField === collection.name
? "bg-red-500 hover:bg-red-400 hover:dark:bg-red-600 cursor-pointer"
: "cursor-not-allowed bg-red-300 dark:bg-red-900"
: "bg-red-500 hover:bg-red-400 hover:dark:bg-red-600 cursor-pointer"
}`}
onClick={submit}
>
<FontAwesomeIcon
icon={permissions === true ? faTrashCan : faRightFromBracket}
className="h-5"
/>
{permissions === true ? "Delete" : "Leave"} Collection
</div>
</div>
);
}
@@ -1,464 +0,0 @@
import { Dispatch, SetStateAction, useEffect, useState } from "react";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import {
faClose,
faCrown,
faPenToSquare,
faPlus,
faUserPlus,
} from "@fortawesome/free-solid-svg-icons";
import useCollectionStore from "@/store/collections";
import { CollectionIncludingMembersAndLinkCount, Member } from "@/types/global";
import { useSession } from "next-auth/react";
import addMemberToCollection from "@/lib/client/addMemberToCollection";
import Checkbox from "../../Checkbox";
import SubmitButton from "@/components/SubmitButton";
import ProfilePhoto from "@/components/ProfilePhoto";
import usePermissions from "@/hooks/usePermissions";
import { toast } from "react-hot-toast";
import getPublicUserData from "@/lib/client/getPublicUserData";
import TextInput from "@/components/TextInput";
type Props = {
toggleCollectionModal: Function;
setCollection: Dispatch<
SetStateAction<CollectionIncludingMembersAndLinkCount>
>;
collection: CollectionIncludingMembersAndLinkCount;
method: "CREATE" | "UPDATE";
};
export default function TeamManagement({
toggleCollectionModal,
setCollection,
collection,
method,
}: Props) {
const permissions = usePermissions(collection.id as number);
const currentURL = new URL(document.URL);
const publicCollectionURL = `${currentURL.origin}/public/collections/${collection.id}`;
const [member, setMember] = useState<Member>({
canCreate: false,
canUpdate: false,
canDelete: false,
user: {
name: "",
username: "",
},
});
const [collectionOwner, setCollectionOwner] = useState({
id: null,
name: "",
username: "",
});
useEffect(() => {
const fetchOwner = async () => {
const owner = await getPublicUserData({ id: collection.ownerId });
setCollectionOwner(owner);
};
fetchOwner();
}, []);
const { addCollection, updateCollection } = useCollectionStore();
const session = useSession();
const setMemberState = (newMember: Member) => {
if (!collection) return null;
setCollection({
...collection,
members: [...collection.members, newMember],
});
setMember({
canCreate: false,
canUpdate: false,
canDelete: false,
user: {
name: "",
username: "",
},
});
};
const [submitLoader, setSubmitLoader] = useState(false);
const submit = async () => {
if (!collection) return null;
setSubmitLoader(true);
const load = toast.loading(
method === "UPDATE" ? "Applying..." : "Creating..."
);
let response;
if (method === "CREATE") response = await addCollection(collection);
else response = await updateCollection(collection);
toast.dismiss(load);
if (response.ok) {
toast.success("Collection Saved!");
toggleCollectionModal();
} else toast.error(response.data as string);
setSubmitLoader(false);
};
return (
<div className="flex flex-col gap-3 sm:w-[35rem] w-80">
{permissions === true && (
<>
<p className="text-sm text-black dark:text-white">Make Public</p>
<Checkbox
label="Make this a public collection."
state={collection.isPublic}
onClick={() =>
setCollection({ ...collection, isPublic: !collection.isPublic })
}
/>
<p className="text-gray-500 dark:text-gray-300 text-sm">
This will let <b>Anyone</b> to view this collection.
</p>
</>
)}
{collection.isPublic ? (
<div>
<p className="text-sm text-black dark:text-white mb-2">
Public Link (Click to copy)
</p>
<div
onClick={() => {
try {
navigator.clipboard
.writeText(publicCollectionURL)
.then(() => toast.success("Copied!"));
} catch (err) {
console.log(err);
}
}}
className="w-full hide-scrollbar overflow-x-auto whitespace-nowrap rounded-md p-2 dark:bg-neutral-950 border-sky-100 dark:border-neutral-700 border-solid border outline-none hover:border-sky-300 dark:hover:border-sky-600 duration-100 cursor-text"
>
{publicCollectionURL}
</div>
</div>
) : null}
{permissions !== true && collection.isPublic && (
<hr className="mb-3 border border-sky-100 dark:border-neutral-700" />
)}
{permissions === true && (
<>
<p className="text-sm text-black dark:text-white">
Member Management
</p>
<div className="flex items-center gap-2">
<TextInput
value={member.user.username || ""}
placeholder="Username (without the '@')"
onChange={(e) => {
setMember({
...member,
user: { ...member.user, username: e.target.value },
});
}}
onKeyDown={(e) =>
e.key === "Enter" &&
addMemberToCollection(
session.data?.user.username as string,
member.user.username || "",
collection,
setMemberState
)
}
/>
<div
onClick={() =>
addMemberToCollection(
session.data?.user.username as string,
member.user.username || "",
collection,
setMemberState
)
}
className="flex items-center justify-center bg-sky-700 hover:bg-sky-600 duration-100 text-white w-10 h-10 p-2 rounded-md cursor-pointer"
>
<FontAwesomeIcon icon={faUserPlus} className="w-5 h-5" />
</div>
</div>
</>
)}
{collection?.members[0]?.user && (
<>
<p className="text-center text-gray-500 dark:text-gray-300 text-xs sm:text-sm">
(All Members have <b>Read</b> access to this collection.)
</p>
<div className="flex flex-col gap-3 rounded-md">
{collection.members
.sort((a, b) => (a.userId as number) - (b.userId as number))
.map((e, i) => {
return (
<div
key={i}
className="relative border p-2 rounded-md border-sky-100 dark:border-neutral-700 flex flex-col sm:flex-row sm:items-center gap-2 justify-between"
>
{permissions === true && (
<FontAwesomeIcon
icon={faClose}
className="absolute right-2 top-2 text-gray-500 dark:text-gray-300 h-4 hover:text-red-500 dark:hover:text-red-500 duration-100 cursor-pointer"
title="Remove Member"
onClick={() => {
const updatedMembers = collection.members.filter(
(member) => {
return member.user.username !== e.user.username;
}
);
setCollection({
...collection,
members: updatedMembers,
});
}}
/>
)}
<div className="flex items-center gap-2">
<ProfilePhoto
src={`/api/avatar/${e.userId}?${Date.now()}`}
className="border-[3px]"
/>
<div>
<p className="text-sm font-bold text-black dark:text-white">
{e.user.name}
</p>
<p className="text-gray-500 dark:text-gray-300">
@{e.user.username}
</p>
</div>
</div>
<div className="flex sm:block items-center justify-between gap-5 min-w-[10rem]">
<div>
<p
className={`font-bold text-sm text-black dark:text-white ${
permissions === true ? "" : "mb-2"
}`}
>
Permissions
</p>
{permissions === true && (
<p className="text-xs text-gray-500 dark:text-gray-300 mb-2">
(Click to toggle.)
</p>
)}
</div>
{permissions !== true &&
!e.canCreate &&
!e.canUpdate &&
!e.canDelete ? (
<p className="text-sm text-gray-500 dark:text-gray-300">
Has no permissions.
</p>
) : (
<div>
<label
className={
permissions === true
? "cursor-pointer mr-1"
: "mr-1"
}
>
<input
type="checkbox"
id="canCreate"
className="peer sr-only"
checked={e.canCreate}
onChange={() => {
if (permissions === true) {
const updatedMembers = collection.members.map(
(member) => {
if (
member.user.username === e.user.username
) {
return {
...member,
canCreate: !e.canCreate,
};
}
return member;
}
);
setCollection({
...collection,
members: updatedMembers,
});
}
}}
/>
<span
className={`text-black dark:text-white peer-checked:bg-sky-200 dark:peer-checked:bg-sky-600 text-sm ${
permissions === true
? "hover:bg-slate-200 hover:dark:bg-neutral-700 duration-100"
: ""
} rounded p-1 select-none`}
>
Create
</span>
</label>
<label
className={
permissions === true
? "cursor-pointer mr-1"
: "mr-1"
}
>
<input
type="checkbox"
id="canUpdate"
className="peer sr-only"
checked={e.canUpdate}
onChange={() => {
if (permissions === true) {
const updatedMembers = collection.members.map(
(member) => {
if (
member.user.username === e.user.username
) {
return {
...member,
canUpdate: !e.canUpdate,
};
}
return member;
}
);
setCollection({
...collection,
members: updatedMembers,
});
}
}}
/>
<span
className={`text-black dark:text-white peer-checked:bg-sky-200 dark:peer-checked:bg-sky-600 text-sm ${
permissions === true
? "hover:bg-slate-200 hover:dark:bg-neutral-700 duration-100"
: ""
} rounded p-1 select-none`}
>
Update
</span>
</label>
<label
className={
permissions === true
? "cursor-pointer mr-1"
: "mr-1"
}
>
<input
type="checkbox"
id="canDelete"
className="peer sr-only"
checked={e.canDelete}
onChange={() => {
if (permissions === true) {
const updatedMembers = collection.members.map(
(member) => {
if (
member.user.username === e.user.username
) {
return {
...member,
canDelete: !e.canDelete,
};
}
return member;
}
);
setCollection({
...collection,
members: updatedMembers,
});
}
}}
/>
<span
className={`text-black dark:text-white peer-checked:bg-sky-200 dark:peer-checked:bg-sky-600 text-sm ${
permissions === true
? "hover:bg-slate-200 hover:dark:bg-neutral-700 duration-100"
: ""
} rounded p-1 select-none`}
>
Delete
</span>
</label>
</div>
)}
</div>
</div>
);
})}
</div>
</>
)}
<div
className="relative border px-2 rounded-md border-sky-100 dark:border-neutral-700 flex min-h-[7rem] sm:min-h-[5rem] gap-2 justify-between"
title={`'@${collectionOwner.username}' is the owner of this collection.`}
>
<div className="flex items-center gap-2">
<ProfilePhoto
src={`/api/avatar/${collection.ownerId}?${Date.now()}`}
className="border-[3px]"
/>
<div>
<div className="flex items-center gap-1">
<p className="text-sm font-bold text-black dark:text-white">
{collectionOwner.name}
</p>
<FontAwesomeIcon
icon={faCrown}
className="w-3 h-3 text-yellow-500"
/>
</div>
<p className="text-gray-500 dark:text-gray-300">
@{collectionOwner.username}
</p>
</div>
</div>
<div className="flex flex-col justify-center min-w-[10rem] text-black dark:text-white">
<p className={`font-bold text-sm`}>Permissions</p>
<p>Full Access (Owner)</p>
</div>
</div>
{permissions === true && (
<SubmitButton
onClick={submit}
loading={submitLoader}
label={method === "CREATE" ? "Add" : "Save"}
icon={method === "CREATE" ? faPlus : faPenToSquare}
className="mx-auto mt-2"
/>
)}
</div>
);
}
-122
View File
@@ -1,122 +0,0 @@
import { Tab } from "@headlessui/react";
import CollectionInfo from "./CollectionInfo";
import { CollectionIncludingMembersAndLinkCount } from "@/types/global";
import TeamManagement from "./TeamManagement";
import { useState } from "react";
import DeleteCollection from "./DeleteCollection";
type Props =
| {
toggleCollectionModal: Function;
activeCollection: CollectionIncludingMembersAndLinkCount;
method: "UPDATE";
isOwner: boolean;
className?: string;
defaultIndex?: number;
}
| {
toggleCollectionModal: Function;
activeCollection?: CollectionIncludingMembersAndLinkCount;
method: "CREATE";
isOwner: boolean;
className?: string;
defaultIndex?: number;
};
export default function CollectionModal({
className,
defaultIndex,
toggleCollectionModal,
isOwner,
activeCollection,
method,
}: Props) {
const [collection, setCollection] =
useState<CollectionIncludingMembersAndLinkCount>(
activeCollection || {
name: "",
description: "",
color: "#0ea5e9",
isPublic: false,
members: [],
}
);
return (
<div className={className}>
<Tab.Group defaultIndex={defaultIndex}>
{method === "CREATE" && (
<p className="text-xl text-black dark:text-white text-center">
New Collection
</p>
)}
<Tab.List className="flex justify-center flex-col max-w-[15rem] sm:max-w-[30rem] mx-auto sm:flex-row gap-2 sm:gap-3 mb-5 text-black dark:text-white">
{method === "UPDATE" && (
<>
{isOwner && (
<Tab
className={({ selected }) =>
selected
? "px-2 py-1 bg-sky-200 dark:bg-sky-800 dark:text-white duration-100 rounded-md outline-none"
: "px-2 py-1 hover:bg-slate-200 hover:dark:bg-neutral-700 hover:dark:text-white rounded-md duration-100 outline-none"
}
>
Collection Info
</Tab>
)}
<Tab
className={({ selected }) =>
selected
? "px-2 py-1 bg-sky-200 dark:bg-sky-800 dark:text-white duration-100 rounded-md outline-none"
: "px-2 py-1 hover:bg-slate-200 hover:dark:bg-neutral-700 hover:dark:text-white rounded-md duration-100 outline-none"
}
>
{isOwner ? "Share & Collaborate" : "View Team"}
</Tab>
<Tab
className={({ selected }) =>
selected
? "px-2 py-1 bg-sky-200 dark:bg-sky-800 dark:text-white duration-100 rounded-md outline-none"
: "px-2 py-1 hover:bg-slate-200 hover:dark:bg-neutral-700 hover:dark:text-white rounded-md duration-100 outline-none"
}
>
{isOwner ? "Delete Collection" : "Leave Collection"}
</Tab>
</>
)}
</Tab.List>
<Tab.Panels>
{(isOwner || method === "CREATE") && (
<Tab.Panel>
<CollectionInfo
toggleCollectionModal={toggleCollectionModal}
setCollection={setCollection}
collection={collection}
method={method}
/>
</Tab.Panel>
)}
{method === "UPDATE" && (
<>
<Tab.Panel>
<TeamManagement
toggleCollectionModal={toggleCollectionModal}
setCollection={setCollection}
collection={collection}
method={method}
/>
</Tab.Panel>
<Tab.Panel>
<DeleteCollection
toggleDeleteCollectionModal={toggleCollectionModal}
collection={collection}
/>
</Tab.Panel>
</>
)}
</Tab.Panels>
</Tab.Group>
</div>
);
}
-276
View File
@@ -1,276 +0,0 @@
import React, { useEffect, useState } from "react";
import CollectionSelection from "@/components/InputSelect/CollectionSelection";
import TagSelection from "@/components/InputSelect/TagSelection";
import { LinkIncludingShortenedCollectionAndTags } from "@/types/global";
import { faPenToSquare } from "@fortawesome/free-regular-svg-icons";
import useLinkStore from "@/store/links";
import { faPlus } from "@fortawesome/free-solid-svg-icons";
import RequiredBadge from "../../RequiredBadge";
import { useSession } from "next-auth/react";
import useCollectionStore from "@/store/collections";
import { useRouter } from "next/router";
import SubmitButton from "../../SubmitButton";
import { toast } from "react-hot-toast";
import Link from "next/link";
import TextInput from "@/components/TextInput";
import unescapeString from "@/lib/client/unescapeString";
type Props =
| {
toggleLinkModal: Function;
method: "CREATE";
activeLink?: LinkIncludingShortenedCollectionAndTags;
}
| {
toggleLinkModal: Function;
method: "UPDATE";
activeLink: LinkIncludingShortenedCollectionAndTags;
};
export default function AddOrEditLink({
toggleLinkModal,
method,
activeLink,
}: Props) {
const [submitLoader, setSubmitLoader] = useState(false);
const [optionsExpanded, setOptionsExpanded] = useState(
method === "UPDATE" ? true : false
);
const { data } = useSession();
const [link, setLink] = useState<LinkIncludingShortenedCollectionAndTags>(
activeLink || {
name: "",
url: "",
description: "",
tags: [],
collection: {
name: "",
ownerId: data?.user.id as number,
},
}
);
const { updateLink, addLink } = useLinkStore();
const router = useRouter();
const { collections } = useCollectionStore();
useEffect(() => {
if (method === "CREATE") {
if (router.query.id) {
const currentCollection = collections.find(
(e) => e.id == Number(router.query.id)
);
if (
currentCollection &&
currentCollection.ownerId &&
router.asPath.startsWith("/collections/")
)
setLink({
...link,
collection: {
id: currentCollection.id,
name: currentCollection.name,
ownerId: currentCollection.ownerId,
},
});
} else
setLink({
...link,
collection: {
// id: ,
name: "Unorganized",
ownerId: data?.user.id as number,
},
});
}
}, []);
const setTags = (e: any) => {
const tagNames = e.map((e: any) => {
return { name: e.label };
});
setLink({ ...link, tags: tagNames });
};
const setCollection = (e: any) => {
if (e?.__isNew__) e.value = null;
setLink({
...link,
collection: { id: e?.value, name: e?.label, ownerId: e?.ownerId },
});
};
const submit = async () => {
setSubmitLoader(true);
let response;
const load = toast.loading(
method === "UPDATE" ? "Applying..." : "Creating..."
);
if (method === "UPDATE") response = await updateLink(link);
else response = await addLink(link);
toast.dismiss(load);
if (response.ok) {
toast.success(`Link ${method === "UPDATE" ? "Saved!" : "Created!"}`);
toggleLinkModal();
} else toast.error(response.data as string);
setSubmitLoader(false);
return response;
};
return (
<div className="flex flex-col gap-3 sm:w-[35rem] w-80">
{method === "UPDATE" ? (
<p
className="text-gray-500 dark:text-gray-300 text-center truncate w-full"
title={link.url}
>
Editing:{" "}
<Link href={link.url} target="_blank">
{link.url}
</Link>
</p>
) : null}
{method === "CREATE" ? (
<div className="grid grid-flow-row-dense sm:grid-cols-5 gap-3">
<div className="sm:col-span-3 col-span-5">
<p className="text-sm text-black dark:text-white mb-2 font-bold">
Address (URL)
<RequiredBadge />
</p>
<TextInput
value={link.url}
onChange={(e) => setLink({ ...link, url: e.target.value })}
placeholder="e.g. http://example.com/"
/>
</div>
<div className="sm:col-span-2 col-span-5">
<p className="text-sm text-black dark:text-white mb-2">
Collection
</p>
{link.collection.name ? (
<CollectionSelection
onChange={setCollection}
// defaultValue={{
// label: link.collection.name,
// value: link.collection.id,
// }}
defaultValue={
link.collection.id
? {
value: link.collection.id,
label: link.collection.name,
}
: {
value: null as unknown as number,
label: "Unorganized",
}
}
/>
) : null}
</div>
</div>
) : undefined}
{optionsExpanded ? (
<div>
<hr className="mb-3 border border-sky-100 dark:border-neutral-700" />
<div className="grid sm:grid-cols-2 gap-3">
<div className={`${method === "UPDATE" ? "sm:col-span-2" : ""}`}>
<p className="text-sm text-black dark:text-white mb-2">Name</p>
<TextInput
value={link.name}
onChange={(e) => setLink({ ...link, name: e.target.value })}
placeholder="e.g. Example Link"
/>
</div>
{method === "UPDATE" ? (
<div>
<p className="text-sm text-black dark:text-white mb-2">
Collection
</p>
{link.collection.name ? (
<CollectionSelection
onChange={setCollection}
defaultValue={
link.collection.name && link.collection.id
? {
value: link.collection.id,
label: link.collection.name,
}
: {
value: null as unknown as number,
label: "Unorganized",
}
}
/>
) : undefined}
</div>
) : undefined}
<div>
<p className="text-sm text-black dark:text-white mb-2">Tags</p>
<TagSelection
onChange={setTags}
defaultValue={link.tags.map((e) => {
return { label: e.name, value: e.id };
})}
/>
</div>
<div className="sm:col-span-2">
<p className="text-sm text-black dark:text-white mb-2">
Description
</p>
<textarea
value={unescapeString(link.description) as string}
onChange={(e) =>
setLink({ ...link, description: e.target.value })
}
placeholder={
method === "CREATE"
? "Will be auto generated if nothing is provided."
: ""
}
className="resize-none w-full rounded-md p-2 border-sky-100 bg-gray-50 dark:border-neutral-700 focus:border-sky-300 dark:focus:border-sky-600 border-solid border outline-none duration-100 dark:bg-neutral-950"
/>
</div>
</div>
</div>
) : undefined}
<div className="flex justify-between items-center mt-2">
<div
onClick={() => setOptionsExpanded(!optionsExpanded)}
className={`${
method === "UPDATE" ? "hidden" : ""
} rounded-md cursor-pointer hover:bg-slate-200 hover:dark:bg-neutral-700 duration-100 py-1 px-2 w-fit text-sm`}
>
{optionsExpanded ? "Hide" : "More"} Options
</div>
<SubmitButton
onClick={submit}
label={method === "CREATE" ? "Add" : "Save"}
icon={method === "CREATE" ? faPlus : faPenToSquare}
loading={submitLoader}
className={`${method === "CREATE" ? "" : "mx-auto"}`}
/>
</div>
</div>
);
}
-326
View File
@@ -1,326 +0,0 @@
import {
CollectionIncludingMembersAndLinkCount,
LinkIncludingShortenedCollectionAndTags,
} from "@/types/global";
import Image from "next/image";
import ColorThief, { RGBColor } from "colorthief";
import { useEffect, useState } from "react";
import Link from "next/link";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import {
faArrowUpRightFromSquare,
faBoxArchive,
faCloudArrowDown,
faFolder,
faGlobe,
} from "@fortawesome/free-solid-svg-icons";
import useCollectionStore from "@/store/collections";
import {
faCalendarDays,
faFileImage,
faFilePdf,
} from "@fortawesome/free-regular-svg-icons";
import isValidUrl from "@/lib/client/isValidUrl";
import { useTheme } from "next-themes";
import unescapeString from "@/lib/client/unescapeString";
type Props = {
link: LinkIncludingShortenedCollectionAndTags;
isOwnerOrMod: boolean;
};
export default function LinkDetails({ link, isOwnerOrMod }: Props) {
const { theme } = useTheme();
const [imageError, setImageError] = useState<boolean>(false);
const formattedDate = new Date(link.createdAt as string).toLocaleString(
"en-US",
{
year: "numeric",
month: "short",
day: "numeric",
}
);
const { collections } = useCollectionStore();
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]);
const [colorPalette, setColorPalette] = useState<RGBColor[]>();
const colorThief = new ColorThief();
const url = isValidUrl(link.url) ? new URL(link.url) : undefined;
const rgbToHex = (r: number, g: number, b: number): string =>
"#" +
[r, g, b]
.map((x) => {
const hex = x.toString(16);
return hex.length === 1 ? "0" + hex : hex;
})
.join("");
useEffect(() => {
const banner = document.getElementById("link-banner");
const bannerInner = document.getElementById("link-banner-inner");
if (colorPalette && banner && bannerInner) {
if (colorPalette[0] && colorPalette[1]) {
banner.style.background = `linear-gradient(to right, ${rgbToHex(
colorPalette[0][0],
colorPalette[0][1],
colorPalette[0][2]
)}, ${rgbToHex(
colorPalette[1][0],
colorPalette[1][1],
colorPalette[1][2]
)})`;
}
if (colorPalette[2] && colorPalette[3]) {
bannerInner.style.background = `linear-gradient(to right, ${rgbToHex(
colorPalette[2][0],
colorPalette[2][1],
colorPalette[2][2]
)}, ${rgbToHex(
colorPalette[3][0],
colorPalette[3][1],
colorPalette[3][2]
)})`;
}
}
}, [colorPalette, theme]);
const handleDownload = (format: "png" | "pdf") => {
const path = `/api/archives/${link.collection.id}/${link.id}.${format}`;
fetch(path)
.then((response) => {
if (response.ok) {
// Create a temporary link and click it to trigger the download
const link = document.createElement("a");
link.href = path;
link.download = format === "pdf" ? "PDF" : "Screenshot";
link.click();
} else {
console.error("Failed to download file");
}
})
.catch((error) => {
console.error("Error:", error);
});
};
return (
<div
className={`flex flex-col gap-3 sm:w-[35rem] w-80 ${
isOwnerOrMod ? "" : "mt-12"
} ${theme === "dark" ? "banner-dark-mode" : "banner-light-mode"}`}
>
{!imageError && (
<div id="link-banner" className="link-banner h-32 -mx-5 -mt-5 relative">
<div id="link-banner-inner" className="link-banner-inner"></div>
</div>
)}
<div
className={`relative flex gap-5 items-start ${!imageError && "-mt-24"}`}
>
{!imageError && url && (
<Image
src={`https://t2.gstatic.com/faviconV2?client=SOCIAL&type=FAVICON&fallback_opts=TYPE,SIZE,URL&url=${url.origin}&size=32`}
width={42}
height={42}
alt=""
id={"favicon-" + link.id}
className="select-none mt-2 w-10 rounded-md shadow border-[3px] border-white dark:border-neutral-900 bg-white dark:bg-neutral-900 aspect-square"
draggable="false"
onLoad={(e) => {
try {
const color = colorThief.getPalette(
e.target as HTMLImageElement,
4
);
setColorPalette(color);
} catch (err) {
console.log(err);
}
}}
onError={(e) => {
setImageError(true);
}}
/>
)}
<div className="flex w-full flex-col min-h-[3rem] justify-center drop-shadow">
<p className="text-2xl text-black dark:text-white capitalize break-words hyphens-auto">
{unescapeString(link.name)}
</p>
<Link
href={link.url}
target="_blank"
className={`${
link.name ? "text-sm" : "text-xl"
} text-gray-500 dark:text-gray-300 break-all hover:underline cursor-pointer w-fit`}
>
{url ? url.host : link.url}
</Link>
</div>
</div>
<div className="flex gap-1 items-center flex-wrap">
<Link
href={`/collections/${link.collection.id}`}
className="flex items-center gap-1 cursor-pointer hover:opacity-60 duration-100 mr-2 z-10"
>
<FontAwesomeIcon
icon={faFolder}
className="w-5 h-5 drop-shadow"
style={{ color: collection?.color }}
/>
<p
title={collection?.name}
className="text-black dark:text-white text-lg truncate max-w-[12rem]"
>
{collection?.name}
</p>
</Link>
{link.tags.map((e, i) => (
<Link key={i} href={`/tags/${e.id}`} className="z-10">
<p
title={e.name}
className="px-2 py-1 bg-sky-200 text-black dark:text-white dark:bg-sky-900 text-xs rounded-3xl cursor-pointer hover:opacity-60 duration-100 truncate max-w-[19rem]"
>
{e.name}
</p>
</Link>
))}
</div>
{link.description && (
<>
<div className="text-black dark:text-white max-h-[20rem] my-3 rounded-md overflow-y-auto hyphens-auto">
{unescapeString(link.description)}
</div>
</>
)}
<div className="flex justify-between items-center">
<div className="flex items-center gap-1 text-gray-500 dark:text-gray-300">
<FontAwesomeIcon icon={faBoxArchive} className="w-4 h-4" />
<p>Archived Formats:</p>
</div>
<div
className="flex items-center gap-1 text-gray-500 dark:text-gray-300"
title={"Created at: " + formattedDate}
>
<FontAwesomeIcon icon={faCalendarDays} className="w-4 h-4" />
<p>{formattedDate}</p>
</div>
</div>
<div className="flex flex-col gap-2">
<div className="flex justify-between items-center pr-1 border border-sky-100 dark:border-neutral-700 rounded-md">
<div className="flex gap-2 items-center">
<div className="text-white bg-sky-300 dark:bg-sky-600 p-2 rounded-l-md">
<FontAwesomeIcon icon={faFileImage} className="w-6 h-6" />
</div>
<p className="text-black dark:text-white">Screenshot</p>
</div>
<div className="flex text-black dark:text-white gap-1">
<div
onClick={() => handleDownload("png")}
className="cursor-pointer hover:bg-slate-200 hover:dark:bg-neutral-700 duration-100 p-2 rounded-md"
>
<FontAwesomeIcon
icon={faCloudArrowDown}
className="w-5 h-5 cursor-pointer text-sky-500 dark:text-sky-500"
/>
</div>
<Link
href={`/api/archives/${link.collectionId}/${link.id}.png`}
target="_blank"
className="cursor-pointer hover:bg-slate-200 hover:dark:bg-neutral-700 duration-100 p-2 rounded-md"
>
<FontAwesomeIcon
icon={faArrowUpRightFromSquare}
className="w-5 h-5 text-sky-500 dark:text-sky-500"
/>
</Link>
</div>
</div>
<div className="flex justify-between items-center pr-1 border border-sky-100 dark:border-neutral-700 rounded-md">
<div className="flex gap-2 items-center">
<div className="text-white bg-sky-300 dark:bg-sky-600 p-2 rounded-l-md">
<FontAwesomeIcon icon={faFilePdf} className="w-6 h-6" />
</div>
<p className="text-black dark:text-white">PDF</p>
</div>
<div className="flex text-black dark:text-white gap-1">
<div
onClick={() => handleDownload("pdf")}
className="cursor-pointer hover:bg-slate-200 hover:dark:bg-neutral-700 duration-100 p-2 rounded-md"
>
<FontAwesomeIcon
icon={faCloudArrowDown}
className="w-5 h-5 cursor-pointer text-sky-500 dark:text-sky-500"
/>
</div>
<Link
href={`/api/archives/${link.collectionId}/${link.id}.pdf`}
target="_blank"
className="cursor-pointer hover:bg-slate-200 hover:dark:bg-neutral-700 duration-100 p-2 rounded-md"
>
<FontAwesomeIcon
icon={faArrowUpRightFromSquare}
className="w-5 h-5 text-sky-500 dark:text-sky-500"
/>
</Link>
</div>
</div>
<div className="flex justify-between items-center pr-1 border border-sky-100 dark:border-neutral-700 rounded-md">
<div className="flex gap-2 items-center">
<div className="text-white bg-sky-300 dark:bg-sky-600 p-2 rounded-l-md">
<FontAwesomeIcon icon={faGlobe} className="w-6 h-6" />
</div>
<p className="text-black dark:text-white">
Latest archive.org Snapshot
</p>
</div>
<Link
href={`https://web.archive.org/web/${link.url.replace(
/(^\w+:|^)\/\//,
""
)}`}
target="_blank"
className="cursor-pointer hover:bg-slate-200 hover:dark:bg-neutral-700 duration-100 p-2 rounded-md"
>
<FontAwesomeIcon
icon={faArrowUpRightFromSquare}
className="w-5 h-5 text-sky-500 dark:text-sky-500"
/>
</Link>
</div>
</div>
</div>
);
}
-89
View File
@@ -1,89 +0,0 @@
import { Tab } from "@headlessui/react";
import { LinkIncludingShortenedCollectionAndTags } from "@/types/global";
import AddOrEditLink from "./AddOrEditLink";
import LinkDetails from "./LinkDetails";
type Props =
| {
toggleLinkModal: Function;
method: "CREATE";
isOwnerOrMod?: boolean;
activeLink?: LinkIncludingShortenedCollectionAndTags;
defaultIndex?: number;
className?: string;
}
| {
toggleLinkModal: Function;
method: "UPDATE";
isOwnerOrMod: boolean;
activeLink: LinkIncludingShortenedCollectionAndTags;
defaultIndex?: number;
className?: string;
};
export default function LinkModal({
className,
defaultIndex,
toggleLinkModal,
isOwnerOrMod,
activeLink,
method,
}: Props) {
return (
<div className={className}>
<Tab.Group defaultIndex={defaultIndex}>
{method === "CREATE" && (
<p className="text-xl text-black dark:text-white text-center">
New Link
</p>
)}
<Tab.List className="flex justify-center flex-col max-w-[15rem] sm:max-w-[30rem] mx-auto sm:flex-row gap-2 sm:gap-3 mb-5 text-black dark:text-white">
{method === "UPDATE" && isOwnerOrMod && (
<>
<Tab
className={({ selected }) =>
selected
? "px-2 py-1 bg-sky-200 dark:bg-sky-800 duration-100 rounded-md outline-none"
: "px-2 py-1 hover:bg-slate-200 hover:dark:bg-neutral-700 rounded-md duration-100 outline-none"
}
>
Link Details
</Tab>
<Tab
className={({ selected }) =>
selected
? "px-2 py-1 bg-sky-200 dark:bg-sky-800 duration-100 rounded-md outline-none"
: "px-2 py-1 hover:bg-slate-200 hover:dark:bg-neutral-700 rounded-md duration-100 outline-none"
}
>
Edit Link
</Tab>
</>
)}
</Tab.List>
<Tab.Panels>
{activeLink && method === "UPDATE" && (
<Tab.Panel>
<LinkDetails link={activeLink} isOwnerOrMod={isOwnerOrMod} />
</Tab.Panel>
)}
<Tab.Panel>
{activeLink && method === "UPDATE" ? (
<AddOrEditLink
toggleLinkModal={toggleLinkModal}
method="UPDATE"
activeLink={activeLink}
/>
) : (
<AddOrEditLink
toggleLinkModal={toggleLinkModal}
method="CREATE"
/>
)}
</Tab.Panel>
</Tab.Panels>
</Tab.Group>
</div>
);
}
-34
View File
@@ -1,34 +0,0 @@
import { MouseEventHandler, ReactNode } from "react";
import ClickAwayHandler from "@/components/ClickAwayHandler";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faChevronLeft } from "@fortawesome/free-solid-svg-icons";
type Props = {
toggleModal: Function;
children: ReactNode;
className?: string;
};
export default function Modal({ toggleModal, className, children }: Props) {
return (
<div className="overflow-y-auto py-2 fixed top-0 bottom-0 right-0 left-0 bg-gray-500 bg-opacity-10 backdrop-blur-sm flex justify-center items-center fade-in z-30">
<ClickAwayHandler
onClickOutside={toggleModal}
className={`m-auto ${className}`}
>
<div className="slide-up relative border-sky-100 dark:border-neutral-700 rounded-2xl border-solid border shadow-lg p-5 bg-white dark:bg-neutral-900">
<div
onClick={toggleModal as MouseEventHandler<HTMLDivElement>}
className="absolute top-5 left-5 inline-flex rounded-md cursor-pointer hover:bg-slate-200 hover:dark:bg-neutral-700 duration-100 z-20 p-2"
>
<FontAwesomeIcon
icon={faChevronLeft}
className="w-4 h-4 text-gray-500 dark:text-gray-300"
/>
</div>
{children}
</div>
</ClickAwayHandler>
</div>
);
}
@@ -0,0 +1,73 @@
import React from "react";
import useLinkStore from "@/store/links";
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;
};
export default function BulkDeleteLinksModal({ onClose }: Props) {
const { t } = useTranslation();
const { selectedLinks, setSelectedLinks } = useLinkStore();
const deleteLinksById = useBulkDeleteLinks();
const deleteLink = async () => {
const load = toast.loading(t("deleting"));
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"));
}
},
}
);
};
return (
<Modal toggleModal={onClose}>
<p className="text-xl font-thin text-red-500">
{selectedLinks.length === 1
? t("delete_link")
: t("delete_links", { count: selectedLinks.length })}
</p>
<div className="divider mb-3 mt-1"></div>
<div className="flex flex-col gap-3">
<p>
{selectedLinks.length === 1
? t("link_deletion_confirmation_message")
: t("links_deletion_confirmation_message", {
count: selectedLinks.length,
})}
</p>
<div role="alert" className="alert alert-warning">
<i className="bi-exclamation-triangle text-xl" />
<span>{t("warning_irreversible")}</span>
</div>
<p>{t("shift_key_tip")}</p>
<Button className="ml-auto" intent="destructive" onClick={deleteLink}>
<i className="bi-trash text-xl" />
{t("delete")}
</Button>
</div>
</Modal>
);
}
@@ -0,0 +1,112 @@
import React, { useState } from "react";
import CollectionSelection from "@/components/InputSelect/CollectionSelection";
import TagSelection from "@/components/InputSelect/TagSelection";
import useLinkStore from "@/store/links";
import { LinkIncludingShortenedCollectionAndTags } from "@/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;
};
export default function BulkEditLinksModal({ onClose }: Props) {
const { t } = useTranslation();
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 }));
};
const setTags = (e: any) => {
const tags = e.map((tag: any) => ({ name: tag.label }));
setUpdatedValues((prevValues) => ({ ...prevValues, tags }));
};
const submit = async () => {
if (!submitLoader) {
setSubmitLoader(true);
const load = toast.loading(t("updating"));
await updateLinks.mutateAsync(
{
links: selectedLinks,
newData: updatedValues,
removePreviousTags,
},
{
onSettled: (data, error) => {
setSubmitLoader(false);
toast.dismiss(load);
if (error) {
toast.error(error.message);
} else {
setSelectedLinks([]);
onClose();
toast.success(t("updated"));
}
},
}
);
}
};
return (
<Modal toggleModal={onClose}>
<p className="text-xl font-thin">
{selectedLinks.length === 1
? t("edit_link")
: t("edit_links", { count: selectedLinks.length })}
</p>
<div className="divider mb-3 mt-1"></div>
<div className="mt-5">
<div className="grid sm:grid-cols-2 gap-3">
<div>
<p className="mb-2">{t("move_to_collection")}</p>
<CollectionSelection
showDefaultValue={false}
onChange={setCollection}
creatable={false}
/>
</div>
<div>
<p className="mb-2">{t("add_tags")}</p>
<TagSelection onChange={setTags} />
</div>
</div>
<div className="sm:ml-auto w-1/2 p-3">
<label className="flex items-center gap-2 ">
<input
type="checkbox"
className="checkbox checkbox-primary"
checked={removePreviousTags}
onChange={(e) => setRemovePreviousTags(e.target.checked)}
/>
{t("remove_previous_tags")}
</label>
</div>
</div>
<div className="flex justify-end items-center mt-5">
<button
className="btn btn-accent dark:border-violet-400 text-white"
onClick={submit}
>
{t("save_changes")}
</button>
</div>
</Modal>
);
}
@@ -0,0 +1,107 @@
import React, { useEffect, useState } from "react";
import TextInput from "@/components/TextInput";
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;
activeCollection: CollectionIncludingMembersAndLinkCount;
};
export default function DeleteCollectionModal({
onClose,
activeCollection,
}: Props) {
const { t } = useTranslation();
const [collection, setCollection] =
useState<CollectionIncludingMembersAndLinkCount>(activeCollection);
const [submitLoader, setSubmitLoader] = useState(false);
const router = useRouter();
const [inputField, setInputField] = useState("");
const permissions = usePermissions(collection.id as number);
useEffect(() => {
setCollection(activeCollection);
}, []);
const deleteCollection = useDeleteCollection();
const submit = async () => {
if (permissions === true && collection.name !== inputField) return;
if (!submitLoader) {
setSubmitLoader(true);
if (!collection) return null;
setSubmitLoader(true);
const load = toast.loading(t("deleting_collection"));
deleteCollection.mutateAsync(collection.id as number, {
onSettled: (data, error) => {
setSubmitLoader(false);
toast.dismiss(load);
if (error) {
toast.error(error.message);
} else {
onClose();
toast.success(t("deleted"));
router.push("/collections");
}
},
});
}
};
return (
<Modal toggleModal={onClose}>
<p className="text-xl font-thin text-red-500">
{permissions === true ? t("delete_collection") : t("leave_collection")}
</p>
<div className="divider mb-3 mt-1"></div>
<div className="flex flex-col gap-3">
{permissions === true ? (
<>
<p>{t("confirm_deletion_prompt", { name: collection.name })}</p>
<TextInput
value={inputField}
onChange={(e) => setInputField(e.target.value)}
placeholder={t("type_name_placeholder", {
name: collection.name,
})}
className="w-3/4 mx-auto"
/>
<div role="alert" className="alert alert-warning">
<i className="bi-exclamation-triangle text-xl"></i>
<span>
<b>{t("warning")}: </b>
{t("deletion_warning")}
</span>
</div>
</>
) : (
<p>{t("leave_prompt")}</p>
)}
<Button
disabled={permissions === true && inputField !== collection.name}
onClick={submit}
intent="destructive"
className="ml-auto"
>
<i className="bi-trash text-xl"></i>
{permissions === true ? t("delete") : t("leave")}
</Button>
</div>
</Modal>
);
}
@@ -0,0 +1,72 @@
import React, { useEffect, useState } from "react";
import { LinkIncludingShortenedCollectionAndTags } from "@/types/global";
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;
activeLink: LinkIncludingShortenedCollectionAndTags;
};
export default function DeleteLinkModal({ onClose, activeLink }: Props) {
const { t } = useTranslation();
const [link, setLink] =
useState<LinkIncludingShortenedCollectionAndTags>(activeLink);
const deleteLink = useDeleteLink();
const router = useRouter();
useEffect(() => {
setLink(activeLink);
}, []);
const submit = async () => {
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 {
if (router.pathname.startsWith("/links/[id]")) {
router.push("/dashboard");
}
toast.success(t("deleted"));
onClose();
}
},
});
};
return (
<Modal toggleModal={onClose}>
<p className="text-xl font-thin text-red-500">{t("delete_link")}</p>
<div className="divider mb-3 mt-1"></div>
<div className="flex flex-col gap-3">
<p>{t("link_deletion_confirmation_message")}</p>
<div role="alert" className="alert alert-warning">
<i className="bi-exclamation-triangle text-xl" />
<span>
<b>{t("warning")}:</b> {t("irreversible_warning")}
</span>
</div>
<p>{t("shift_key_tip")}</p>
<Button className="ml-auto" intent="destructive" onClick={submit}>
<i className="bi-trash text-xl" />
{t("delete")}
</Button>
</div>
</Modal>
);
}
@@ -0,0 +1,65 @@
import Modal from "../Modal";
import Button from "../ui/Button";
import { useTranslation } from "next-i18next";
import { useDeleteUser } from "@/hooks/store/admin/users";
import { useState } from "react";
import { useSession } from "next-auth/react";
type Props = {
onClose: Function;
userId: number;
};
export default function DeleteUserModal({ onClose, userId }: Props) {
const { t } = useTranslation();
const [submitLoader, setSubmitLoader] = useState(false);
const deleteUser = useDeleteUser();
const submit = async () => {
if (!submitLoader) {
setSubmitLoader(true);
await deleteUser.mutateAsync(userId, {
onSuccess: () => {
onClose();
},
onSettled: (data, error) => {
setSubmitLoader(false);
},
});
}
};
const { data } = useSession();
const isAdmin = data?.user?.id === Number(process.env.NEXT_PUBLIC_ADMIN);
return (
<Modal toggleModal={onClose}>
<p className="text-xl font-thin text-red-500">
{isAdmin ? t("delete_user") : t("remove_user")}
</p>
<div className="divider mb-3 mt-1"></div>
<div className="flex flex-col gap-3">
<p>{t("confirm_user_deletion")}</p>
<p>{t("confirm_user_removal_desc")}</p>
{isAdmin && (
<div role="alert" className="alert alert-warning">
<i className="bi-exclamation-triangle text-xl" />
<span>
<b>{t("warning")}:</b> {t("irreversible_action_warning")}
</span>
</div>
)}
<Button className="ml-auto" intent="destructive" onClick={submit}>
<i className="bi-trash text-xl" />
{isAdmin ? t("delete_confirmation") : t("confirm")}
</Button>
</div>
</Modal>
);
}
@@ -0,0 +1,118 @@
import React, { useState } from "react";
import TextInput from "@/components/TextInput";
import { CollectionIncludingMembersAndLinkCount } from "@/types/global";
import Modal from "../Modal";
import { useTranslation } from "next-i18next";
import { useUpdateCollection } from "@/hooks/store/collections";
import toast from "react-hot-toast";
import IconPicker from "../IconPicker";
import { IconWeight } from "@phosphor-icons/react";
type Props = {
onClose: Function;
activeCollection: CollectionIncludingMembersAndLinkCount;
};
export default function EditCollectionModal({
onClose,
activeCollection,
}: Props) {
const { t } = useTranslation();
const [collection, setCollection] =
useState<CollectionIncludingMembersAndLinkCount>(activeCollection);
const [submitLoader, setSubmitLoader] = useState(false);
const updateCollection = useUpdateCollection();
const submit = async () => {
if (!submitLoader) {
setSubmitLoader(true);
if (!collection) return null;
setSubmitLoader(true);
const load = toast.loading(t("updating_collection"));
await updateCollection.mutateAsync(collection, {
onSettled: (data, error) => {
setSubmitLoader(false);
toast.dismiss(load);
if (error) {
toast.error(error.message);
} else {
onClose();
toast.success(t("updated"));
}
},
});
}
};
return (
<Modal toggleModal={onClose}>
<p className="text-xl font-thin">{t("edit_collection_info")}</p>
<div className="divider mb-3 mt-1"></div>
<div className="flex flex-col gap-3">
<div className="flex flex-col gap-3">
<div className="flex gap-3 items-end">
<IconPicker
color={collection.color}
setColor={(color: string) =>
setCollection({ ...collection, color })
}
weight={(collection.iconWeight || "regular") as IconWeight}
setWeight={(iconWeight: string) =>
setCollection({ ...collection, iconWeight })
}
iconName={collection.icon as string}
setIconName={(icon: string) =>
setCollection({ ...collection, icon })
}
reset={() =>
setCollection({
...collection,
color: "#0ea5e9",
icon: "",
iconWeight: "",
})
}
/>
<div className="w-full">
<p className="mb-2">{t("name")}</p>
<TextInput
className="bg-base-200"
value={collection.name}
placeholder={t("collection_name_placeholder")}
onChange={(e) =>
setCollection({ ...collection, name: e.target.value })
}
/>
</div>
</div>
<div className="w-full">
<p className="mb-2">{t("description")}</p>
<textarea
className="w-full h-32 resize-none border rounded-md duration-100 bg-base-200 p-2 outline-none border-neutral-content focus:border-primary"
placeholder={t("collection_description_placeholder")}
value={collection.description}
onChange={(e) =>
setCollection({ ...collection, description: e.target.value })
}
/>
</div>
</div>
<button
className="btn btn-accent dark:border-violet-400 text-white w-fit ml-auto"
onClick={submit}
>
{t("save_changes")}
</button>
</div>
</Modal>
);
}
@@ -0,0 +1,469 @@
import React, { useEffect, useState } from "react";
import TextInput from "@/components/TextInput";
import toast from "react-hot-toast";
import {
AccountSettings,
CollectionIncludingMembersAndLinkCount,
Member,
} from "@/types/global";
import getPublicUserData from "@/lib/client/getPublicUserData";
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;
activeCollection: CollectionIncludingMembersAndLinkCount;
};
export default function EditCollectionSharingModal({
onClose,
activeCollection,
}: Props) {
const { t } = useTranslation();
const [collection, setCollection] =
useState<CollectionIncludingMembersAndLinkCount>(activeCollection);
const [submitLoader, setSubmitLoader] = useState(false);
const updateCollection = useUpdateCollection();
const submit = async () => {
if (!submitLoader) {
setSubmitLoader(true);
if (!collection) return null;
setSubmitLoader(true);
const load = toast.loading(t("updating_collection"));
await updateCollection.mutateAsync(collection, {
onSettled: (data, error) => {
setSubmitLoader(false);
toast.dismiss(load);
if (error) {
toast.error(error.message);
} else {
onClose();
toast.success(t("updated"));
}
},
});
}
};
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 [memberIdentifier, setMemberIdentifier] = useState("");
const [collectionOwner, setCollectionOwner] = useState<
Partial<AccountSettings>
>({});
useEffect(() => {
const fetchOwner = async () => {
const owner = await getPublicUserData(collection.ownerId as number);
setCollectionOwner(owner);
};
fetchOwner();
setCollection(activeCollection);
}, []);
const setMemberState = (newMember: Member) => {
if (!collection) return null;
setCollection({
...collection,
members: [...collection.members, newMember],
});
setMemberIdentifier("");
};
const router = useRouter();
const isPublicRoute = router.pathname.startsWith("/public") ? true : false;
return (
<Modal toggleModal={onClose}>
<p className="text-xl font-thin">
{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 && !isPublicRoute && (
<div>
<p>{t("make_collection_public")}</p>
<label className="label cursor-pointer justify-start gap-2">
<input
type="checkbox"
checked={collection.isPublic}
onChange={() =>
setCollection({
...collection,
isPublic: !collection.isPublic,
})
}
className="checkbox checkbox-primary"
/>
<span className="label-text">
{t("make_collection_public_checkbox")}
</span>
</label>
<p className="text-neutral text-sm">
{t("make_collection_public_desc")}
</p>
</div>
)}
{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>
)}
{permissions === true && !isPublicRoute && (
<div className="divider my-3"></div>
)}
{permissions === true && !isPublicRoute && (
<>
<p>{t("members")}</p>
<div className="flex items-center gap-2">
<TextInput
value={memberIdentifier || ""}
className="bg-base-200"
placeholder={t("add_member_placeholder")}
onChange={(e) => setMemberIdentifier(e.target.value)}
onKeyDown={(e) =>
e.key === "Enter" &&
addMemberToCollection(
user,
memberIdentifier.replace(/^@/, "") || "",
collection,
setMemberState,
t
)
}
/>
<div
onClick={() =>
addMemberToCollection(
user,
memberIdentifier.replace(/^@/, "") || "",
collection,
setMemberState,
t
)
}
className="btn btn-accent dark:border-violet-400 text-white btn-square btn-sm h-10 w-10"
>
<i className="bi-person-add text-xl"></i>
</div>
</div>
</>
)}
{collection?.members[0]?.user && (
<>
<div className="flex flex-col divide-y divide-neutral-content border border-neutral-content rounded-xl bg-base-200">
<div
className="relative p-3 bg-base-200 rounded-xl flex gap-2 justify-between"
title={`@${collectionOwner.username} is the owner of this collection`}
>
<div className={"flex items-center justify-between w-full"}>
<div className={"flex items-center"}>
<div className={"shrink-0"}>
<ProfilePhoto
src={
collectionOwner.image
? collectionOwner.image
: undefined
}
name={collectionOwner.name}
/>
</div>
<div className={"grow ml-2"}>
<p className="text-sm font-semibold">
{collectionOwner.name}
</p>
<p className="text-xs text-neutral">
@{collectionOwner.username}
</p>
</div>
</div>
<div>
<p className="text-sm font-bold">{t("owner")}</p>
</div>
</div>
</div>
<div className="divider my-0 last:hidden h-[3px]"></div>
{collection.members
.sort((a, b) => (a.userId as number) - (b.userId as number))
.map((e, i) => {
const roleLabel =
e.canCreate && e.canUpdate && e.canDelete
? t("admin")
: e.canCreate && !e.canUpdate && !e.canDelete
? t("contributor")
: !e.canCreate && !e.canUpdate && !e.canDelete
? t("viewer")
: undefined;
return (
<React.Fragment key={i}>
<div className="relative p-3 bg-base-200 rounded-xl flex gap-2 justify-between border-none">
<div
className={"flex items-center justify-between w-full"}
>
<div className={"flex items-center"}>
<div className={"shrink-0"}>
<ProfilePhoto
src={e.user.image ? e.user.image : undefined}
name={e.user.name}
/>
</div>
<div className={"grow ml-2"}>
<p className="text-sm font-semibold">
{e.user.name}
</p>
<p className="text-xs text-neutral">
@{e.user.username}
</p>
</div>
</div>
<div className={"flex items-center gap-2"}>
{permissions === true && !isPublicRoute ? (
<div className="dropdown dropdown-bottom dropdown-end">
<div
tabIndex={0}
role="button"
onMouseDown={dropdownTriggerer}
className="btn btn-sm btn-primary font-normal"
>
{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 mt-1">
<li>
<label
className="label cursor-pointer flex justify-start"
tabIndex={0}
role="button"
>
<input
type="radio"
name={`role-radio-${e.userId}`}
className="radio checked:bg-primary"
checked={
!e.canCreate &&
!e.canUpdate &&
!e.canDelete
}
onChange={() => {
const updatedMember = {
...e,
canCreate: false,
canUpdate: false,
canDelete: false,
};
const updatedMembers =
collection.members.map((member) =>
member.userId === e.userId
? updatedMember
: member
);
setCollection({
...collection,
members: updatedMembers,
});
(
document?.activeElement as HTMLElement
)?.blur();
}}
/>
<div>
<p className="font-bold whitespace-nowrap">
{t("viewer")}
</p>
<p className="whitespace-nowrap">
{t("viewer_desc")}
</p>
</div>
</label>
</li>
<li>
<label
className="label cursor-pointer flex justify-start"
tabIndex={0}
role="button"
>
<input
type="radio"
name={`role-radio-${e.userId}`}
className="radio checked:bg-primary"
checked={
e.canCreate &&
!e.canUpdate &&
!e.canDelete
}
onChange={() => {
const updatedMember = {
...e,
canCreate: true,
canUpdate: false,
canDelete: false,
};
const updatedMembers =
collection.members.map((member) =>
member.userId === e.userId
? updatedMember
: member
);
setCollection({
...collection,
members: updatedMembers,
});
(
document?.activeElement as HTMLElement
)?.blur();
}}
/>
<div>
<p className="font-bold whitespace-nowrap">
{t("contributor")}
</p>
<p className="whitespace-nowrap">
{t("contributor_desc")}
</p>
</div>
</label>
</li>
<li>
<label
className="label cursor-pointer flex justify-start"
tabIndex={0}
role="button"
>
<input
type="radio"
name={`role-radio-${e.userId}`}
className="radio checked:bg-primary"
checked={
e.canCreate &&
e.canUpdate &&
e.canDelete
}
onChange={() => {
const updatedMember = {
...e,
canCreate: true,
canUpdate: true,
canDelete: true,
};
const updatedMembers =
collection.members.map((member) =>
member.userId === e.userId
? updatedMember
: member
);
setCollection({
...collection,
members: updatedMembers,
});
(
document?.activeElement as HTMLElement
)?.blur();
}}
/>
<div>
<p className="font-bold whitespace-nowrap">
{t("admin")}
</p>
<p className="whitespace-nowrap">
{t("admin_desc")}
</p>
</div>
</label>
</li>
</ul>
</div>
) : (
<p className="text-sm text-neutral">
{roleLabel}
</p>
)}
{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"
}
title={t("remove_member")}
onClick={() => {
const updatedMembers =
collection.members.filter((member) => {
return (
member.user.username !== e.user.username
);
});
setCollection({
...collection,
members: updatedMembers,
});
}}
/>
)}
</div>
</div>
</div>
<div className="divider my-0 last:hidden h-[3px]"></div>
</React.Fragment>
);
})}
</div>
</>
)}
{permissions === true && !isPublicRoute && (
<button
className="btn btn-accent dark:border-violet-400 text-white w-fit ml-auto mt-3"
onClick={submit}
>
{t("save_changes")}
</button>
)}
</div>
</Modal>
);
}
@@ -0,0 +1,74 @@
import React, { useState } from "react";
import TextInput from "@/components/TextInput";
import Modal from "../Modal";
import { useTranslation } from "next-i18next";
type Props = {
onClose: Function;
onSubmit: Function;
oldEmail: string;
newEmail: string;
};
export default function EmailChangeVerificationModal({
onClose,
onSubmit,
oldEmail,
newEmail,
}: Props) {
const { t } = useTranslation();
const [password, setPassword] = useState("");
return (
<Modal toggleModal={onClose}>
<p className="text-xl font-thin">{t("confirm_password")}</p>
<div className="divider mb-3 mt-1"></div>
<div className="flex flex-col gap-5">
<p>
{t("password_change_warning")}
{process.env.NEXT_PUBLIC_STRIPE === "true" && t("stripe_update_note")}
</p>
<p>
{t("sso_will_be_removed_warning", {
service:
process.env.NEXT_PUBLIC_GOOGLE_ENABLED === "true" ? "Google" : "",
})}
</p>
<div>
<p>{t("old_email")}</p>
<p className="text-neutral">{oldEmail}</p>
</div>
<div>
<p>{t("new_email")}</p>
<p className="text-neutral">{newEmail}</p>
</div>
<div className="w-full">
<p className="mb-2">{t("password")}</p>
<TextInput
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="••••••••••••••"
className="bg-base-200"
type="password"
autoFocus
/>
</div>
<div className="flex justify-end items-center">
<button
className="btn btn-accent dark:border-violet-400 text-white"
onClick={() => onSubmit(password)}
>
{t("confirm")}
</button>
</div>
</div>
</Modal>
);
}
+131
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>
);
}
+186
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>
);
}
@@ -0,0 +1,134 @@
import React, { useEffect, useState } from "react";
import TextInput from "@/components/TextInput";
import { Collection } from "@prisma/client";
import Modal from "../Modal";
import { CollectionIncludingMembersAndLinkCount } from "@/types/global";
import { useTranslation } from "next-i18next";
import { useCreateCollection } from "@/hooks/store/collections";
import toast from "react-hot-toast";
import IconPicker from "../IconPicker";
import { IconWeight } from "@phosphor-icons/react";
type Props = {
onClose: Function;
parent?: CollectionIncludingMembersAndLinkCount;
};
export default function NewCollectionModal({ onClose, parent }: Props) {
const { t } = useTranslation();
const initial = {
parentId: parent?.id,
name: "",
description: "",
color: "#0ea5e9",
} as Partial<Collection>;
const [collection, setCollection] = useState<Partial<Collection>>(initial);
useEffect(() => {
setCollection(initial);
}, []);
const [submitLoader, setSubmitLoader] = useState(false);
const createCollection = useCreateCollection();
const submit = async () => {
if (submitLoader) return;
if (!collection) return null;
setSubmitLoader(true);
const load = toast.loading(t("creating"));
await createCollection.mutateAsync(collection, {
onSettled: (data, error) => {
setSubmitLoader(false);
toast.dismiss(load);
if (error) {
toast.error(error.message);
} else {
onClose();
toast.success(t("created"));
}
},
});
};
return (
<Modal toggleModal={onClose}>
{parent?.id ? (
<>
<p className="text-xl font-thin">{t("new_sub_collection")}</p>
<p className="capitalize text-sm">
{t("for_collection", { name: parent.name })}
</p>
</>
) : (
<p className="text-xl font-thin">{t("create_new_collection")}</p>
)}
<div className="divider mb-3 mt-1"></div>
<div className="flex flex-col gap-3">
<div className="flex flex-col gap-3">
<div className="flex gap-3 items-end">
<IconPicker
color={collection.color || "#0ea5e9"}
setColor={(color: string) =>
setCollection({ ...collection, color })
}
weight={(collection.iconWeight || "regular") as IconWeight}
setWeight={(iconWeight: string) =>
setCollection({ ...collection, iconWeight })
}
iconName={collection.icon as string}
setIconName={(icon: string) =>
setCollection({ ...collection, icon })
}
reset={() =>
setCollection({
...collection,
color: "#0ea5e9",
icon: "",
iconWeight: "",
})
}
/>
<div className="w-full">
<p className="mb-2">{t("name")}</p>
<TextInput
className="bg-base-200"
value={collection.name}
placeholder={t("collection_name_placeholder")}
onChange={(e) =>
setCollection({ ...collection, name: e.target.value })
}
/>
</div>
</div>
<div className="w-full">
<p className="mb-2">{t("description")}</p>
<textarea
className="w-full h-32 resize-none border rounded-md duration-100 bg-base-200 p-2 outline-none border-neutral-content focus:border-primary"
placeholder={t("collection_description_placeholder")}
value={collection.description}
onChange={(e) =>
setCollection({ ...collection, description: e.target.value })
}
/>
</div>
</div>
<button
className="btn btn-accent dark:border-violet-400 text-white w-fit ml-auto"
onClick={submit}
>
{t("create_collection_button")}
</button>
</div>
</Modal>
);
}
+179
View File
@@ -0,0 +1,179 @@
import React, { useEffect, useState } from "react";
import CollectionSelection from "@/components/InputSelect/CollectionSelection";
import TagSelection from "@/components/InputSelect/TagSelection";
import TextInput from "@/components/TextInput";
import unescapeString from "@/lib/client/unescapeString";
import { useRouter } from "next/router";
import Modal from "../Modal";
import { useTranslation } from "next-i18next";
import { useCollections } from "@/hooks/store/collections";
import { useAddLink } from "@/hooks/store/links";
import toast from "react-hot-toast";
import { PostLinkSchemaType } from "@/lib/shared/schemaValidation";
type Props = {
onClose: Function;
};
export default function NewLinkModal({ onClose }: Props) {
const { t } = useTranslation();
const initial = {
name: "",
url: "",
description: "",
type: "url",
tags: [],
collection: {
id: undefined,
name: "",
},
} as PostLinkSchemaType;
const [link, setLink] = useState<PostLinkSchemaType>(initial);
const addLink = useAddLink();
const [submitLoader, setSubmitLoader] = useState(false);
const [optionsExpanded, setOptionsExpanded] = useState(false);
const router = useRouter();
const { data: collections = [] } = useCollections();
const setCollection = (e: any) => {
if (e?.__isNew__) e.value = undefined;
setLink({
...link,
collection: { id: e?.value, name: e?.label },
});
};
const setTags = (e: any) => {
const tagNames = e.map((e: any) => ({ name: e.label }));
setLink({ ...link, tags: tagNames });
};
useEffect(() => {
if (router.pathname.startsWith("/collections/") && router.query.id) {
const currentCollection = collections.find(
(e) => e.id == Number(router.query.id)
);
if (currentCollection && currentCollection.ownerId)
setLink({
...initial,
collection: {
id: currentCollection.id,
name: currentCollection.name,
},
});
} else
setLink({
...initial,
collection: { name: "Unorganized" },
});
}, []);
const submit = async () => {
if (!submitLoader) {
setSubmitLoader(true);
const load = toast.loading(t("creating_link"));
await addLink.mutateAsync(link, {
onSettled: (data, error) => {
setSubmitLoader(false);
toast.dismiss(load);
if (error) {
toast.error(t(error.message));
} else {
onClose();
toast.success(t("link_created"));
}
},
});
}
};
return (
<Modal toggleModal={onClose}>
<p className="text-xl font-thin">{t("create_new_link")}</p>
<div className="divider mb-3 mt-1"></div>
<div className="grid grid-flow-row-dense sm:grid-cols-5 gap-3">
<div className="sm:col-span-3 col-span-5">
<p className="mb-2">{t("link")}</p>
<TextInput
value={link.url || ""}
onChange={(e) => setLink({ ...link, url: e.target.value })}
placeholder={t("link_url_placeholder")}
className="bg-base-200"
/>
</div>
<div className="sm:col-span-2 col-span-5">
<p className="mb-2">{t("collection")}</p>
{link.collection?.name && (
<CollectionSelection
onChange={setCollection}
defaultValue={{
value: link.collection?.id,
label: link.collection?.name || "Unorganized",
}}
/>
)}
</div>
</div>
<div className={"mt-2"}>
{optionsExpanded && (
<div className="mt-5">
<div className="grid sm:grid-cols-2 gap-3">
<div>
<p className="mb-2">{t("name")}</p>
<TextInput
value={link.name}
onChange={(e) => setLink({ ...link, name: e.target.value })}
placeholder={t("link_name_placeholder")}
className="bg-base-200"
/>
</div>
<div>
<p className="mb-2">{t("tags")}</p>
<TagSelection
onChange={setTags}
defaultValue={link.tags?.map((e) => ({
label: e.name,
value: e.id,
}))}
/>
</div>
<div className="sm:col-span-2">
<p className="mb-2">{t("description")}</p>
<textarea
value={unescapeString(link.description || "") || ""}
onChange={(e) =>
setLink({ ...link, description: e.target.value })
}
placeholder={t("link_description_placeholder")}
className="resize-none w-full h-32 rounded-md p-2 border-neutral-content bg-base-200 focus:border-primary border-solid border outline-none duration-100"
/>
</div>
</div>
</div>
)}
</div>
<div className="flex justify-between items-center mt-5">
<div
onClick={() => setOptionsExpanded(!optionsExpanded)}
className={`rounded-md cursor-pointer btn btn-sm btn-ghost duration-100 flex items-center px-2 w-fit text-sm`}
>
<p>{optionsExpanded ? t("hide_options") : t("more_options")}</p>
<i className={`bi-chevron-${optionsExpanded ? "up" : "down"}`}></i>
</div>
<button
className="btn btn-accent dark:border-violet-400 text-white"
onClick={submit}
>
{t("create_link")}
</button>
</div>
</Modal>
);
}
+243
View File
@@ -0,0 +1,243 @@
import React, { useState } from "react";
import TextInput from "@/components/TextInput";
import { TokenExpiry } from "@/types/global";
import toast from "react-hot-toast";
import Modal from "../Modal";
import { dropdownTriggerer } from "@/lib/client/utils";
import Button from "../ui/Button";
import { useTranslation } from "next-i18next";
import { useAddToken } from "@/hooks/store/tokens";
import CopyButton from "../CopyButton";
type Props = {
onClose: Function;
};
export default function NewTokenModal({ onClose }: Props) {
const { t } = useTranslation();
const [newToken, setNewToken] = useState("");
const addToken = useAddToken();
const initial = {
name: "",
expires: 0 as TokenExpiry,
};
const [token, setToken] = useState(initial as any);
const [submitLoader, setSubmitLoader] = useState(false);
const submit = async () => {
if (!submitLoader) {
setSubmitLoader(true);
const load = toast.loading(t("creating_token"));
await addToken.mutateAsync(token, {
onSettled: (data, error) => {
setSubmitLoader(false);
toast.dismiss(load);
if (error) {
toast.error(error.message);
} else {
setNewToken(data.secretKey);
}
},
});
}
};
const getLabel = (expiry: TokenExpiry) => {
switch (expiry) {
case TokenExpiry.sevenDays:
return t("7_days");
case TokenExpiry.oneMonth:
return t("30_days");
case TokenExpiry.twoMonths:
return t("60_days");
case TokenExpiry.threeMonths:
return t("90_days");
case TokenExpiry.never:
return t("no_expiration");
}
};
return (
<Modal toggleModal={onClose}>
{newToken ? (
<div className="flex flex-col justify-center space-y-4">
<p className="text-xl font-thin">{t("access_token_created")}</p>
<p>{t("token_creation_notice")}</p>
<div className="relative">
<div className="w-full hide-scrollbar overflow-x-auto whitespace-nowrap rounded-md p-2 bg-base-200 border-neutral-content border-solid border flex items-center gap-2 justify-between pr-14">
{newToken}
<div className="absolute right-0 px-2 border-neutral-content border-solid border-r bg-base-200">
<CopyButton text={newToken} />
</div>
</div>
</div>
</div>
) : (
<>
<p className="text-xl font-thin">{t("create_access_token")}</p>
<div className="divider mb-3 mt-1"></div>
<div className="flex sm:flex-row flex-col gap-2 items-center">
<div className="w-full">
<p className="mb-2">{t("name")}</p>
<TextInput
value={token.name}
onChange={(e) => setToken({ ...token, name: e.target.value })}
placeholder={t("token_name_placeholder")}
className="bg-base-200"
/>
</div>
<div className="w-full sm:w-fit">
<p className="mb-2">{t("expires_in")}</p>
<div className="dropdown dropdown-bottom dropdown-end w-full">
<Button
tabIndex={0}
role="button"
intent="secondary"
onMouseDown={dropdownTriggerer}
className="whitespace-nowrap w-32"
>
{getLabel(token.expires)}
</Button>
<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"
tabIndex={0}
role="button"
>
<input
type="radio"
name="sort-radio"
className="radio checked:bg-primary"
checked={token.expires === TokenExpiry.sevenDays}
onChange={() => {
(document?.activeElement as HTMLElement)?.blur();
setToken({
...token,
expires: TokenExpiry.sevenDays,
});
}}
/>
<span className="label-text whitespace-nowrap">
{t("7_days")}
</span>
</label>
</li>
<li>
<label
className="label cursor-pointer flex justify-start"
tabIndex={0}
role="button"
>
<input
type="radio"
name="sort-radio"
className="radio checked:bg-primary"
checked={token.expires === TokenExpiry.oneMonth}
onChange={() => {
(document?.activeElement as HTMLElement)?.blur();
setToken({ ...token, expires: TokenExpiry.oneMonth });
}}
/>
<span className="label-text whitespace-nowrap">
{t("30_days")}
</span>
</label>
</li>
<li>
<label
className="label cursor-pointer flex justify-start"
tabIndex={0}
role="button"
>
<input
type="radio"
name="sort-radio"
className="radio checked:bg-primary"
checked={token.expires === TokenExpiry.twoMonths}
onChange={() => {
(document?.activeElement as HTMLElement)?.blur();
setToken({
...token,
expires: TokenExpiry.twoMonths,
});
}}
/>
<span className="label-text whitespace-nowrap">
{t("60_days")}
</span>
</label>
</li>
<li>
<label
className="label cursor-pointer flex justify-start"
tabIndex={0}
role="button"
>
<input
type="radio"
name="sort-radio"
className="radio checked:bg-primary"
checked={token.expires === TokenExpiry.threeMonths}
onChange={() => {
(document?.activeElement as HTMLElement)?.blur();
setToken({
...token,
expires: TokenExpiry.threeMonths,
});
}}
/>
<span className="label-text whitespace-nowrap">
{t("90_days")}
</span>
</label>
</li>
<li>
<label
className="label cursor-pointer flex justify-start"
tabIndex={0}
role="button"
>
<input
type="radio"
name="sort-radio"
className="radio checked:bg-primary"
checked={token.expires === TokenExpiry.never}
onChange={() => {
(document?.activeElement as HTMLElement)?.blur();
setToken({ ...token, expires: TokenExpiry.never });
}}
/>
<span className="label-text whitespace-nowrap">
{t("no_expiration")}
</span>
</label>
</li>
</ul>
</div>
</div>
</div>
<div className="flex justify-end items-center mt-5">
<button
className="btn btn-accent dark:border-violet-400 text-white"
onClick={submit}
>
{t("create_token")}
</button>
</div>
</>
)}
</Modal>
);
}
+145
View File
@@ -0,0 +1,145 @@
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";
type Props = {
onClose: Function;
};
type FormData = {
name: string;
username?: string;
email?: string;
password: string;
};
const emailEnabled = process.env.NEXT_PUBLIC_EMAIL_PROVIDER === "true";
export default function NewUserModal({ onClose }: Props) {
const { t } = useTranslation();
const addUser = useAddUser();
const [form, setForm] = useState<FormData>({
name: "",
username: "",
email: emailEnabled ? "" : undefined,
password: "",
});
const [submitLoader, setSubmitLoader] = useState(false);
async function submit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
if (!submitLoader) {
if (form.password.length < 8)
return toast.error(t("password_length_error"));
const checkFields = () => {
if (emailEnabled) {
return form.name !== "" && form.email !== "" && form.password !== "";
} else {
return (
form.name !== "" && form.username !== "" && form.password !== ""
);
}
};
if (checkFields()) {
setSubmitLoader(true);
await addUser.mutateAsync(form, {
onSuccess: () => {
onClose();
},
onSettled: () => {
setSubmitLoader(false);
},
});
} else {
toast.error(t("fill_all_fields_error"));
}
}
}
return (
<Modal toggleModal={onClose}>
<p className="text-xl font-thin">{t("create_new_user")}</p>
<div className="divider mb-3 mt-1"></div>
<form onSubmit={submit}>
<div className="grid sm:grid-cols-2 gap-3">
<div>
<p className="mb-2">{t("display_name")}</p>
<TextInput
placeholder={t("placeholder_johnny")}
className="bg-base-200"
onChange={(e) => setForm({ ...form, name: e.target.value })}
value={form.name}
/>
</div>
{emailEnabled && (
<div>
<p className="mb-2">{t("email")}</p>
<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>
<p className="mb-2">{t("password")}</p>
<TextInput
placeholder="••••••••••••••"
className="bg-base-200"
onChange={(e) => setForm({ ...form, password: e.target.value })}
value={form.password}
/>
</div>
</div>
<div role="note" className="alert alert-note mt-5">
<i className="bi-exclamation-triangle text-xl" />
<span>
<Trans
i18nKey="password_change_note"
components={[<b key={0} />]}
/>
</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("create_user")}
</button>
</div>
</form>
</Modal>
);
}
@@ -0,0 +1,57 @@
import React, { useEffect, useState } from "react";
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;
activeToken: AccessToken;
};
export default function DeleteTokenModal({ onClose, activeToken }: Props) {
const { t } = useTranslation();
const [token, setToken] = useState<AccessToken>(activeToken);
const revokeToken = useRevokeToken();
useEffect(() => {
setToken(activeToken);
}, [activeToken]);
const deleteLink = async () => {
const load = toast.loading(t("deleting"));
await revokeToken.mutateAsync(token.id, {
onSettled: (data, error) => {
toast.dismiss(load);
if (error) {
toast.error(error.message);
} else {
onClose();
toast.success(t("token_revoked"));
}
},
});
};
return (
<Modal toggleModal={onClose}>
<p className="text-xl font-thin text-red-500">{t("revoke_token")}</p>
<div className="divider mb-3 mt-1"></div>
<div className="flex flex-col gap-3">
<p>{t("revoke_confirmation")}</p>
<Button className="ml-auto" intent="destructive" onClick={deleteLink}>
<i className="bi-trash text-xl" />
{t("revoke")}
</Button>
</div>
</Modal>
);
}
+67
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>
);
}
+223
View File
@@ -0,0 +1,223 @@
import React, { useEffect, useState } from "react";
import CollectionSelection from "@/components/InputSelect/CollectionSelection";
import TagSelection from "@/components/InputSelect/TagSelection";
import TextInput from "@/components/TextInput";
import unescapeString from "@/lib/client/unescapeString";
import {
LinkIncludingShortenedCollectionAndTags,
ArchivedFormat,
} from "@/types/global";
import { useSession } from "next-auth/react";
import { useRouter } from "next/router";
import toast from "react-hot-toast";
import Modal from "../Modal";
import { useTranslation } from "next-i18next";
import { useCollections } from "@/hooks/store/collections";
import { useUploadFile } from "@/hooks/store/links";
import { PostLinkSchemaType } from "@/lib/shared/schemaValidation";
type Props = {
onClose: Function;
};
export default function UploadFileModal({ onClose }: Props) {
const { t } = useTranslation();
const { data } = useSession();
const initial = {
name: "",
description: "",
type: "url",
tags: [],
collection: {
id: undefined,
name: "",
},
} as PostLinkSchemaType;
const [link, setLink] = useState<PostLinkSchemaType>(initial);
const [file, setFile] = useState<File>();
const uploadFile = useUploadFile();
const [submitLoader, setSubmitLoader] = useState(false);
const [optionsExpanded, setOptionsExpanded] = useState(false);
const router = useRouter();
const { data: collections = [] } = useCollections();
const setCollection = (e: any) => {
if (e?.__isNew__) e.value = undefined;
setLink({
...link,
collection: { id: e?.value, name: e?.label },
});
};
const setTags = (e: any) => {
const tagNames = e.map((e: any) => {
return { name: e.label };
});
setLink({ ...link, tags: tagNames });
};
useEffect(() => {
setOptionsExpanded(false);
if (router.pathname.startsWith("/collections/") && router.query.id) {
const currentCollection = collections.find(
(e) => e.id == Number(router.query.id)
);
if (
currentCollection &&
currentCollection.ownerId &&
router.asPath.startsWith("/collections/")
)
setLink({
...initial,
collection: {
id: currentCollection.id,
name: currentCollection.name,
},
});
} else
setLink({
...initial,
collection: { name: "Unorganized" },
});
}, [router, collections]);
const submit = async () => {
if (!submitLoader && file) {
let fileType: ArchivedFormat | null = null;
let linkType: "url" | "image" | "monolith" | "pdf" | null = null;
if (file?.type === "image/jpg" || file.type === "image/jpeg") {
fileType = ArchivedFormat.jpeg;
linkType = "image";
} else if (file.type === "image/png") {
fileType = ArchivedFormat.png;
linkType = "image";
} else if (file.type === "application/pdf") {
fileType = ArchivedFormat.pdf;
linkType = "pdf";
}
// else if (file.type === "text/html") {
// fileType = ArchivedFormat.monolith;
// linkType = "monolith";
// }
setSubmitLoader(true);
const load = toast.loading(t("creating"));
await uploadFile.mutateAsync(
{ link, file },
{
onSettled: (data, error) => {
setSubmitLoader(false);
toast.dismiss(load);
if (error) {
toast.error(error.message);
} else {
onClose();
toast.success(t("created_success"));
}
},
}
);
}
};
return (
<Modal toggleModal={onClose}>
<div className="flex gap-2 items-start">
<p className="text-xl font-thin">{t("upload_file")}</p>
</div>
<div className="divider mb-3 mt-1"></div>
<div className="grid grid-flow-row-dense sm:grid-cols-5 gap-3">
<div className="sm:col-span-3 col-span-5">
<p className="mb-2">{t("file")}</p>
<label className="btn h-10 btn-sm w-full border border-neutral-content hover:border-neutral-content flex justify-between">
<input
type="file"
accept=".pdf,.png,.jpg,.jpeg"
className="cursor-pointer custom-file-input"
onChange={(e) => e.target.files && setFile(e.target.files[0])}
/>
</label>
<p className="text-xs font-semibold mt-2">
{t("file_types", {
size: process.env.NEXT_PUBLIC_MAX_FILE_BUFFER || 10,
})}
</p>
</div>
<div className="sm:col-span-2 col-span-5">
<p className="mb-2">{t("collection")}</p>
{link.collection?.name && (
<CollectionSelection
onChange={setCollection}
defaultValue={{
value: link.collection?.id,
label: link.collection?.name || "Unorganized",
}}
/>
)}
</div>
</div>
{optionsExpanded && (
<div className="mt-5">
<div className="grid sm:grid-cols-2 gap-3">
<div>
<p className="mb-2">{t("name")}</p>
<TextInput
value={link.name}
onChange={(e) => setLink({ ...link, name: e.target.value })}
placeholder={t("example_link")}
className="bg-base-200"
/>
</div>
<div>
<p className="mb-2">{t("tags")}</p>
<TagSelection
onChange={setTags}
defaultValue={link.tags?.map((e) => ({
value: e.id,
label: e.name,
}))}
/>
</div>
<div className="sm:col-span-2">
<p className="mb-2">{t("description")}</p>
<textarea
value={unescapeString(link.description || "") || ""}
onChange={(e) =>
setLink({ ...link, description: e.target.value })
}
placeholder={t("description_placeholder")}
className="resize-none w-full h-32 rounded-md p-2 border-neutral-content bg-base-200 focus:border-primary border-solid border outline-none duration-100"
/>
</div>
</div>
</div>
)}
<div className="flex justify-between items-center mt-5">
<div
onClick={() => setOptionsExpanded(!optionsExpanded)}
className={`rounded-md cursor-pointer btn btn-sm btn-ghost duration-100 flex items-center px-2 w-fit text-sm`}
>
<p>
{optionsExpanded ? t("hide") : t("more")} {t("options")}
</p>
</div>
<button
className="btn btn-accent dark:border-violet-400 text-white"
onClick={submit}
>
{t("upload_file")}
</button>
</div>
</Modal>
);
}
-51
View File
@@ -1,51 +0,0 @@
import useModalStore from "@/store/modals";
import Modal from "./Modal";
import LinkModal from "./Modal/Link";
import {
CollectionIncludingMembersAndLinkCount,
LinkIncludingShortenedCollectionAndTags,
} from "@/types/global";
import CollectionModal from "./Modal/Collection";
import { useEffect } from "react";
import { useRouter } from "next/router";
export default function ModalManagement() {
const { modal, setModal } = useModalStore();
const toggleModal = () => {
setModal(null);
};
const router = useRouter();
useEffect(() => {
toggleModal();
}, [router]);
if (modal && modal.modal === "LINK")
return (
<Modal toggleModal={toggleModal}>
<LinkModal
toggleLinkModal={toggleModal}
method={modal.method}
isOwnerOrMod={modal.isOwnerOrMod as boolean}
defaultIndex={modal.defaultIndex}
activeLink={modal.active as LinkIncludingShortenedCollectionAndTags}
/>
</Modal>
);
else if (modal && modal.modal === "COLLECTION")
return (
<Modal toggleModal={toggleModal}>
<CollectionModal
toggleCollectionModal={toggleModal}
method={modal.method}
isOwner={modal.isOwner as boolean}
defaultIndex={modal.defaultIndex}
activeCollection={
modal.active as CollectionIncludingMembersAndLinkCount
}
/>
</Modal>
);
else return <></>;
}
+113 -116
View File
@@ -1,138 +1,135 @@
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { signOut } from "next-auth/react";
import { faPlus, faBars } from "@fortawesome/free-solid-svg-icons";
import { useEffect, useState } from "react";
import Dropdown from "@/components/Dropdown";
import ClickAwayHandler from "@/components/ClickAwayHandler";
import Sidebar from "@/components/Sidebar";
import { useRouter } from "next/router";
import Search from "@/components/Search";
import useAccountStore from "@/store/account";
import ProfilePhoto from "@/components/ProfilePhoto";
import useModalStore from "@/store/modals";
import { useTheme } from "next-themes";
import SearchBar from "@/components/SearchBar";
import useWindowDimensions from "@/hooks/useWindowDimensions";
import ToggleDarkMode from "./ToggleDarkMode";
import NewLinkModal from "./ModalContent/NewLinkModal";
import NewCollectionModal from "./ModalContent/NewCollectionModal";
import UploadFileModal from "./ModalContent/UploadFileModal";
import { dropdownTriggerer } from "@/lib/client/utils";
import MobileNavigation from "./MobileNavigation";
import ProfileDropdown from "./ProfileDropdown";
import { useTranslation } from "next-i18next";
export default function Navbar() {
const { setModal } = useModalStore();
const { account } = useAccountStore();
const [profileDropdown, setProfileDropdown] = useState(false);
const { t } = useTranslation();
const router = useRouter();
const { theme, setTheme } = useTheme();
const handleToggle = () => {
if (theme === "dark") {
setTheme("light");
} else {
setTheme("dark");
}
};
const [sidebar, setSidebar] = useState(false);
window.addEventListener("resize", () => setSidebar(false));
const { width } = useWindowDimensions();
useEffect(() => {
setSidebar(false);
}, [router]);
document.body.style.overflow = "auto";
}, [width, router]);
const toggleSidebar = () => {
setSidebar(!sidebar);
setSidebar(false);
document.body.style.overflow = "auto";
};
return (
<div className="flex justify-between gap-2 items-center px-5 py-2 border-solid border-b-sky-100 dark:border-b-neutral-700 border-b h-16">
<div
onClick={toggleSidebar}
className="inline-flex lg:hidden gap-1 items-center select-none cursor-pointer p-[0.687rem] text-gray-500 dark:text-gray-300 rounded-md duration-100 hover:bg-slate-200 dark:hover:bg-neutral-700"
>
<FontAwesomeIcon icon={faBars} className="w-5 h-5" />
</div>
<Search />
<div className="flex items-center gap-2">
<div
onClick={() => {
setModal({
modal: "LINK",
state: true,
method: "CREATE",
});
}}
className="inline-flex gap-1 relative sm:w-[7.2rem] items-center font-semibold select-none cursor-pointer p-[0.687rem] sm:p-2 sm:px-3 rounded-md sm:rounded-full hover:bg-sky-100 dark:hover:bg-sky-800 sm:dark:hover:bg-sky-600 text-sky-500 sm:text-white sm:bg-sky-700 sm:hover:bg-sky-600 duration-100 group"
>
<FontAwesomeIcon
icon={faPlus}
className="w-5 h-5 sm:group-hover:ml-9 sm:absolute duration-100"
/>
<span className="hidden sm:block group-hover:opacity-0 text-right w-full duration-100">
New Link
</span>
</div>
<div className="relative">
<div
className="flex gap-1 group sm:hover:bg-slate-200 sm:hover:dark:bg-neutral-700 sm:hover:p-1 sm:hover:pr-2 duration-100 h-10 rounded-full items-center w-fit cursor-pointer"
onClick={() => setProfileDropdown(!profileDropdown)}
id="profile-dropdown"
>
<ProfilePhoto
src={account.profilePic}
priority={true}
className="sm:group-hover:h-8 sm:group-hover:w-8 duration-100 border-[3px]"
/>
<p
id="profile-dropdown"
className="font-bold text-black dark:text-white leading-3 hidden sm:block select-none truncate max-w-[8rem] py-1"
>
{account.name}
</p>
</div>
{profileDropdown ? (
<Dropdown
items={[
{
name: "Settings",
href: "/settings/account",
},
{
name: `Switch to ${theme === "light" ? "Dark" : "Light"}`,
onClick: () => {
handleToggle();
setProfileDropdown(!profileDropdown);
},
},
{
name: "Logout",
onClick: () => {
signOut();
setProfileDropdown(!profileDropdown);
},
},
]}
onClickOutside={(e: Event) => {
const target = e.target as HTMLInputElement;
if (target.id !== "profile-dropdown") setProfileDropdown(false);
}}
className="absolute top-11 right-0 z-20 w-36"
/>
) : null}
const [newLinkModal, setNewLinkModal] = useState(false);
const [newCollectionModal, setNewCollectionModal] = useState(false);
const [uploadFileModal, setUploadFileModal] = useState(false);
{sidebar ? (
<div className="fixed top-0 bottom-0 right-0 left-0 bg-gray-500 bg-opacity-10 backdrop-blur-sm flex items-center fade-in z-30">
<ClickAwayHandler
className="h-full"
onClickOutside={toggleSidebar}
>
<div className="slide-right h-full shadow-lg">
<Sidebar />
</div>
</ClickAwayHandler>
</div>
) : null}
</div>
return (
<div className="flex justify-between gap-2 items-center pl-3 pr-4 py-2 border-solid border-b-neutral-content border-b">
<div
onClick={() => {
setSidebar(true);
document.body.style.overflow = "hidden";
}}
className="text-neutral btn btn-square btn-sm btn-ghost lg:hidden sm:inline-flex"
>
<i className="bi-list text-2xl leading-none"></i>
</div>
<SearchBar />
<div className="flex items-center gap-2">
<ToggleDarkMode className="hidden sm:inline-grid" />
<div className="dropdown dropdown-end sm:inline-block hidden">
<div className="tooltip tooltip-bottom" data-tip={t("create_new")}>
<div
tabIndex={0}
role="button"
onMouseDown={dropdownTriggerer}
className="flex min-w-[3.4rem] items-center btn btn-accent dark:border-violet-400 text-white btn-sm max-h-[2rem] px-2 relative"
>
<span>
<i className="bi-plus text-4xl absolute -top-[0.3rem] left-0 pointer-events-none"></i>
</span>
<span>
<i className="bi-caret-down-fill text-xs absolute top-2 right-[0.3rem] pointer-events-none"></i>
</span>
</div>
</div>
<ul className="dropdown-content z-[1] menu shadow bg-base-200 border border-neutral-content rounded-box mt-1">
<li>
<div
onClick={() => {
(document?.activeElement as HTMLElement)?.blur();
setNewLinkModal(true);
}}
tabIndex={0}
role="button"
className="whitespace-nowrap"
>
{t("new_link")}
</div>
</li>
<li>
<div
onClick={() => {
(document?.activeElement as HTMLElement)?.blur();
setUploadFileModal(true);
}}
tabIndex={0}
role="button"
className="whitespace-nowrap"
>
{t("upload_file")}
</div>
</li>
<li>
<div
onClick={() => {
(document?.activeElement as HTMLElement)?.blur();
setNewCollectionModal(true);
}}
tabIndex={0}
role="button"
className="whitespace-nowrap"
>
{t("new_collection")}
</div>
</li>
</ul>
</div>
<ProfileDropdown />
</div>
<MobileNavigation />
{sidebar && (
<div className="fixed top-0 bottom-0 right-0 left-0 bg-black bg-opacity-10 backdrop-blur-sm flex items-center fade-in z-40">
<ClickAwayHandler className="h-full" onClickOutside={toggleSidebar}>
<div className="slide-right h-full shadow-lg">
<Sidebar />
</div>
</ClickAwayHandler>
</div>
)}
{newLinkModal && <NewLinkModal onClose={() => setNewLinkModal(false)} />}
{newCollectionModal && (
<NewCollectionModal onClose={() => setNewCollectionModal(false)} />
)}
{uploadFileModal && (
<UploadFileModal onClose={() => setUploadFileModal(false)} />
)}
</div>
);
}
+24 -19
View File
@@ -1,40 +1,45 @@
import { faPlus } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import React from "react";
import useModalStore from "@/store/modals";
import React, { useState } from "react";
import NewLinkModal from "./ModalContent/NewLinkModal";
import { useTranslation } from "next-i18next";
type Props = {
text?: string;
};
export default function NoLinksFound({ text }: Props) {
const { setModal } = useModalStore();
const { t } = useTranslation();
const [newLinkModal, setNewLinkModal] = useState(false);
return (
<div className="border border-solid border-sky-100 dark:border-neutral-700 w-full p-10 rounded-2xl bg-gray-50 dark:bg-neutral-800">
<p className="text-center text-2xl text-black dark:text-white">
<div className="w-full h-full flex flex-col justify-center p-3">
<svg
xmlns="http://www.w3.org/2000/svg"
className="w-1/4 min-w-[7rem] max-w-[15rem] h-auto mx-auto mb-5 text-primary drop-shadow"
fill="currentColor"
viewBox="0 0 16 16"
>
<path d="M9.752 6.193c.599.6 1.73.437 2.528-.362.798-.799.96-1.932.362-2.531-.599-.6-1.73-.438-2.528.361-.798.8-.96 1.933-.362 2.532" />
<path d="M15.811 3.312c-.363 1.534-1.334 3.626-3.64 6.218l-.24 2.408a2.56 2.56 0 0 1-.732 1.526L8.817 15.85a.51.51 0 0 1-.867-.434l.27-1.899c.04-.28-.013-.593-.131-.956a9.42 9.42 0 0 0-.249-.657l-.082-.202c-.815-.197-1.578-.662-2.191-1.277-.614-.615-1.079-1.379-1.275-2.195l-.203-.083a9.556 9.556 0 0 0-.655-.248c-.363-.119-.675-.172-.955-.132l-1.896.27A.51.51 0 0 1 .15 7.17l2.382-2.386c.41-.41.947-.67 1.524-.734h.006l2.4-.238C9.005 1.55 11.087.582 12.623.208c.89-.217 1.59-.232 2.08-.188.244.023.435.06.57.093.067.017.12.033.16.045.184.06.279.13.351.295l.029.073a3.475 3.475 0 0 1 .157.721c.055.485.051 1.178-.159 2.065Zm-4.828 7.475.04-.04-.107 1.081a1.536 1.536 0 0 1-.44.913l-1.298 1.3.054-.38c.072-.506-.034-.993-.172-1.418a8.548 8.548 0 0 0-.164-.45c.738-.065 1.462-.38 2.087-1.006ZM5.205 5c-.625.626-.94 1.351-1.004 2.09a8.497 8.497 0 0 0-.45-.164c-.424-.138-.91-.244-1.416-.172l-.38.054 1.3-1.3c.245-.246.566-.401.91-.44l1.08-.107-.04.039Zm9.406-3.961c-.38-.034-.967-.027-1.746.163-1.558.38-3.917 1.496-6.937 4.521-.62.62-.799 1.34-.687 2.051.107.676.483 1.362 1.048 1.928.564.565 1.25.941 1.924 1.049.71.112 1.429-.067 2.048-.688 3.079-3.083 4.192-5.444 4.556-6.987.183-.771.18-1.345.138-1.713a2.835 2.835 0 0 0-.045-.283 3.078 3.078 0 0 0-.3-.041Z" />
<path d="M7.009 12.139a7.632 7.632 0 0 1-1.804-1.352A7.568 7.568 0 0 1 3.794 8.86c-1.102.992-1.965 5.054-1.839 5.18.125.126 3.936-.896 5.054-1.902Z" />
</svg>
<p className="text-center text-xl sm:text-2xl">
{text || "You haven't created any Links Here"}
</p>
<div className="text-center text-black dark:text-white w-full mt-4">
<p className="text-center text-sm sm:text-base">{t("start_journey")}</p>
<div className="text-center w-full mt-4">
<div
onClick={() => {
setModal({
modal: "LINK",
state: true,
method: "CREATE",
});
setNewLinkModal(true);
}}
className="inline-flex gap-1 relative w-[11.4rem] items-center font-semibold select-none cursor-pointer p-2 px-3 rounded-full dark:hover:bg-sky-600 text-white bg-sky-700 hover:bg-sky-600 duration-100 group"
className="inline-flex gap-1 relative w-[11rem] items-center btn btn-accent dark:border-violet-400 text-white group"
>
<FontAwesomeIcon
icon={faPlus}
className="w-5 h-5 group-hover:ml-[4.325rem] absolute duration-100"
/>
<i className="bi-plus-lg text-3xl left-2 group-hover:ml-[4rem] absolute duration-100"></i>
<span className="group-hover:opacity-0 text-right w-full duration-100">
Create New Link
{t("create_new_link")}
</span>
</div>
</div>
{newLinkModal && <NewLinkModal onClose={() => setNewLinkModal(false)} />}
</div>
);
}
+23
View File
@@ -0,0 +1,23 @@
import React from "react";
export default function PageHeader({
title,
description,
icon,
}: {
title: string;
description?: string;
icon: string;
}) {
return (
<div className="flex items-center gap-3">
<i
className={`${icon} text-primary sm:text-3xl text-2xl drop-shadow`}
></i>
<div>
<p className="text-3xl capitalize font-thin">{title}</p>
<p className="text-xs sm:text-sm">{description}</p>
</div>
</div>
);
}
+21
View File
@@ -0,0 +1,21 @@
import React from "react";
import ClickAwayHandler from "./ClickAwayHandler";
type Props = {
children: React.ReactNode;
onClose: Function;
className?: string;
};
const Popover = ({ children, className, onClose }: Props) => {
return (
<ClickAwayHandler
onClickOutside={() => onClose()}
className={`absolute z-50 ${className || ""}`}
>
{children}
</ClickAwayHandler>
);
};
export default Popover;
+80
View File
@@ -0,0 +1,80 @@
import {
ArchivedFormat,
LinkIncludingShortenedCollectionAndTags,
} from "@/types/global";
import Link from "next/link";
import { useRouter } from "next/router";
type Props = {
name: string;
icon: string;
format: ArchivedFormat;
link: LinkIncludingShortenedCollectionAndTags;
downloadable?: boolean;
};
export default function PreservedFormatRow({
name,
icon,
format,
link,
downloadable,
}: Props) {
const router = useRouter();
let isPublic = router.pathname.startsWith("/public") ? true : undefined;
const handleDownload = () => {
const path = `/api/v1/archives/${link?.id}?format=${format}`;
fetch(path)
.then((response) => {
if (response.ok) {
// Create a temporary link and click it to trigger the download
const anchorElement = document.createElement("a");
anchorElement.href = path;
anchorElement.download =
format === ArchivedFormat.monolith
? "Webpage"
: format === ArchivedFormat.pdf
? "PDF"
: "Screenshot";
anchorElement.click();
} else {
console.error("Failed to download file");
}
})
.catch((error) => {
console.error("Error:", error);
});
};
return (
<div className="flex justify-between items-center">
<div className="flex gap-2 items-center">
<i className={`${icon} text-2xl text-primary`} />
<p>{name}</p>
</div>
<div className="flex gap-1">
{downloadable || false ? (
<div
onClick={() => handleDownload()}
className="btn btn-sm btn-square btn-ghost"
>
<i className="bi-cloud-arrow-down text-xl text-neutral" />
</div>
) : undefined}
<Link
href={`${
isPublic ? "/public" : ""
}/preserved/${link?.id}?format=${format}`}
target="_blank"
className="btn btn-sm btn-square btn-ghost"
>
<i className="bi-box-arrow-up-right text-lg text-neutral" />
</Link>
</div>
</div>
);
}
+92
View File
@@ -0,0 +1,92 @@
import useLocalSettingsStore from "@/store/localSettings";
import { dropdownTriggerer } from "@/lib/client/utils";
import ProfilePhoto from "./ProfilePhoto";
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 { data: user = {} } = useUser();
const isAdmin = user.id === Number(process.env.NEXT_PUBLIC_ADMIN || 1);
const handleToggle = () => {
const newTheme = settings.theme === "dark" ? "light" : "dark";
updateSettings({ theme: newTheme });
};
return (
<div className="dropdown dropdown-end">
<div
tabIndex={0}
role="button"
onMouseDown={dropdownTriggerer}
className="btn btn-circle btn-ghost"
>
<ProfilePhoto
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 mt-1`}
>
<li>
<Link
href="/settings/account"
onClick={() => (document?.activeElement as HTMLElement)?.blur()}
tabIndex={0}
role="button"
className="whitespace-nowrap"
>
{t("settings")}
</Link>
</li>
<li className="block sm:hidden">
<div
onClick={() => {
(document?.activeElement as HTMLElement)?.blur();
handleToggle();
}}
tabIndex={0}
role="button"
className="whitespace-nowrap"
>
{t("switch_to", {
theme: settings.theme === "light" ? t("dark") : t("light"),
})}
</div>
</li>
{isAdmin && (
<li>
<Link
href="/admin"
onClick={() => (document?.activeElement as HTMLElement)?.blur()}
tabIndex={0}
role="button"
className="whitespace-nowrap"
>
{t("server_administration")}
</Link>
</li>
)}
<li>
<div
onClick={() => {
(document?.activeElement as HTMLElement)?.blur();
signOut();
}}
tabIndex={0}
role="button"
className="whitespace-nowrap"
>
{t("logout")}
</div>
</li>
</ul>
</div>
);
}

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