Compare commits

...

812 Commits

Author SHA1 Message Date
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
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
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
daniel31x13
cd82083e09 bump version 2024-07-26 12:00:46 -04:00
daniel31x13
061e22d225 bug fixed 2024-07-26 11:54:13 -04:00
daniel31x13
8e6f88d29f merged the two migration scripts for v2.6.1 2024-07-25 23:43:26 -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
daniel31x13
af7f0fb47c make script more efficient 2024-07-25 14:15:08 -04:00
daniel31x13
9d8ae6970c minor fix 2024-07-25 13:57:33 -04:00
daniel31x13
6cae2fb634 update version number 2024-07-25 13:45:44 -04:00
daniel31x13
5e6d46b6b9 bug fixed 2024-07-25 13:43:55 -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
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
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
Daniel
c35532a467 Merge pull request #237 from linkwarden/dev
Minor rendering fix
2023-10-19 01:56:15 -04:00
Yee Jia Wei
b3295e136d change login and register to form 2023-10-19 13:46:40 +08:00
daniel31x13
d445ea3194 settings page layout fix 2023-10-19 01:37:00 -04:00
daniel31x13
40bb9e5e52 update README.md 2023-10-19 00:29:27 -04:00
Daniel
b987dbe79b Merge pull request #235 from linkwarden/dev
Dev
2023-10-19 00:21:32 -04:00
daniel31x13
146b8576f4 cleared up old code 2023-10-19 00:20:28 -04:00
daniel31x13
42e16cbf04 minor change 2023-10-19 00:17:35 -04:00
daniel31x13
93d1b00bbe optimization 2023-10-19 00:15:42 -04:00
daniel31x13
4482c52fa9 fixed build error 2023-10-19 00:09:28 -04:00
daniel31x13
ca3ce7e3de adjustible archive formats + finalized settings page 2023-10-19 00:00:23 -04:00
daniel31x13
19467f243f redesigned settings page [still WIP] 2023-10-18 17:50:55 -04:00
daniel31x13
f5eaee8dc0 bug fixed + redesigned profile settings page 2023-10-17 16:02:07 -04:00
Daniel
86b2cd45e0 Merge pull request #230 from linkwarden/dev
added export functionality
2023-10-16 18:28:28 -04:00
daniel31x13
6259405045 added export functionality 2023-10-16 18:27:04 -04:00
Daniel
8906639147 Merge pull request #229 from linkwarden/dev
Dev
2023-10-16 13:12:37 -04:00
daniel31x13
f98500ec4e fixed filter by tags + refactored search + bug fixed + settings page [WIP] 2023-10-16 13:10:52 -04:00
daniel31x13
36a1ed209e small design improvements 2023-10-13 02:14:18 -04:00
daniel31x13
754c15d2bb added support for wayback machine 2023-10-13 02:03:38 -04:00
Daniel
943933534d Merge pull request #223 from linkwarden/dev
Update dashboard.tsx
2023-10-12 15:00:47 -04:00
daniel31x13
6a38ad961a Update dashboard.tsx 2023-10-12 14:59:24 -04:00
Daniel
a1f8176a0b Merge pull request #220 from linkwarden/dev
Dev
2023-10-11 18:50:54 -04:00
daniel31x13
2590b7d7cf updated README 2023-10-11 18:50:19 -04:00
daniel31x13
49f826f06b minor fix 2023-10-11 18:49:08 -04:00
Daniel
feef1c298a Merge pull request #219 from linkwarden/dev
Dev
2023-10-11 13:20:11 -04:00
daniel31x13
ac47ca5c54 small change 2023-10-11 13:19:47 -04:00
daniel31x13
89436d3500 small change 2023-10-11 13:19:13 -04:00
daniel31x13
3d6cb45382 added page title to public pages 2023-10-11 12:18:45 -04:00
Daniel
1e70b7a596 Merge pull request #218 from linkwarden/dev
updated README
2023-10-10 05:28:53 -04:00
daniel31x13
727b05bdbd updated README 2023-10-10 05:28:26 -04:00
Daniel
7a589cbaad Merge pull request #213 from linkwarden/dev
Dev
2023-10-09 13:07:21 -04:00
daniel31x13
3c2029e120 updated README 2023-10-09 13:06:24 -04:00
Daniel
fac826f040 Dev (#212) 2023-10-09 13:04:47 -04:00
daniel31x13
d443755e19 updated README 2023-10-09 13:03:28 -04:00
daniel31x13
156e7ffebf updated README 2023-10-09 13:02:47 -04:00
daniel31x13
ad0f765896 updated README 2023-10-09 13:01:51 -04:00
Daniel
64e2efe8d0 Merge pull request #211 from linkwarden/dev
Dev
2023-10-09 13:00:15 -04:00
daniel31x13
81354f95ff updated README 2023-10-09 12:59:27 -04:00
daniel31x13
d2eef8955e updated README 2023-10-09 12:44:31 -04:00
daniel31x13
efed225ad1 updated readme 2023-10-09 12:41:06 -04:00
Daniel
85db0d0e77 Merge pull request #210 from linkwarden/dev
UI improvements
2023-10-09 08:37:54 -04:00
daniel31x13
48eb253cac UI improvements 2023-10-09 08:35:33 -04:00
Daniel
64f9f9e2c5 Merge pull request #204 from linkwarden/dev
minor change
2023-10-06 10:23:52 +03:30
daniel31x13
417252d65e minor change 2023-10-06 10:23:14 +03:30
Daniel
5a16f09599 Merge pull request #203 from linkwarden/dev
more concise confirmation info
2023-10-06 10:21:32 +03:30
daniel31x13
a51ce8bc2d more concise confirmation info 2023-10-06 10:21:04 +03:30
Daniel
eb643ef19e Merge pull request #202 from linkwarden/dev
minor ui change
2023-10-06 09:57:07 +03:30
daniel31x13
42f770341e minor ui change 2023-10-06 09:56:30 +03:30
Daniel
2aa2a63708 Merge pull request #201 from linkwarden/dev
improved subscription page
2023-10-06 09:10:07 +03:30
daniel31x13
83938fdd0a improved subscription page 2023-10-06 09:09:16 +03:30
Daniel
09b91afc95 Merge pull request #200 from linkwarden/dev
updated README
2023-10-05 22:35:12 +03:30
daniel31x13
d2ee9c4fce updated README 2023-10-05 22:34:22 +03:30
Daniel
0132135f64 Merge pull request #199 from linkwarden/dev
updated README file
2023-10-05 22:25:44 +03:30
daniel31x13
f8b1db08db updated README file 2023-10-05 22:24:47 +03:30
Daniel
d89b776f90 Merge pull request #198 from linkwarden/dev
Revert "updated nextjs"
2023-10-05 20:29:39 +03:30
daniel31x13
610c1c80ed Revert "updated nextjs" 2023-10-05 20:27:44 +03:30
Daniel
de77c4997d Merge pull request #197 from linkwarden/dev
hot fix
2023-10-05 19:50:35 +03:30
daniel31x13
35ad8320b9 hot fix 2023-10-05 19:49:31 +03:30
Daniel
efbf132dbb Merge pull request #194 from linkwarden/dev
Dev
2023-10-05 19:32:14 +03:30
daniel31x13
f1017533d7 updated nextjs 2023-10-05 19:31:32 +03:30
daniel31x13
3282d5a615 bug fixed + small visual improvements + improved user experience 2023-10-05 19:12:35 +03:30
Daniel
8e18966952 Merge pull request #190 from linkwarden/dev
Dev
2023-10-03 14:36:50 +03:30
daniel31x13
f15e298cc3 import bookmarks from other platforms + many other improvements 2023-10-03 14:34:13 +03:30
daniel31x13
8fc8874063 renamed the "data" route to "migration" 2023-10-01 20:03:40 +03:30
Daniel
ea7f08aba2 Merge pull request #184 from linkwarden/dev
added the ability to disable registration
2023-09-28 19:08:43 +03:30
daniel31x13
fdcae013c6 added the ability to disable registration 2023-09-28 19:07:25 +03:30
Daniel
e02251c09c Merge pull request #183 from linkwarden/dev
updated the image
2023-09-28 11:29:37 +03:30
daniel31x13
7585d52750 updated the image 2023-09-28 11:28:46 +03:30
Daniel
f573b0a8f3 Merge pull request #182 from linkwarden/dev
Dev
2023-09-28 10:27:08 +03:30
daniel31x13
62cfcfef14 changed the actions file 2023-09-28 10:26:43 +03:30
Daniel
138723c721 Merge pull request #181 from peschmae/feature/github-action-arm64
chore(builds) Enable arm64 builds
2023-09-28 10:20:27 +03:30
Mathias Petermann
de8a90a80e chore(builds) Enable arm64 builds 2023-09-28 08:06:37 +02:00
Daniel
da2a14b4f2 Merge pull request #180 from linkwarden/dev
Dev
2023-09-28 00:24:39 +03:30
daniel31x13
d54760f12b minor change to README file 2023-09-28 00:24:22 +03:30
Daniel
83b15c92e5 Merge pull request #179 from linkwarden/main
main
2023-09-27 08:58:57 +03:30
daniel31x13
420d9efb7e updated docker compose file to use the image from github packages. 2023-09-27 08:56:52 +03:30
Daniel
66c1c582f5 Merge pull request #178 from linkwarden/dev
Dev
2023-09-27 05:36:53 +03:30
Daniel
d9c7b934aa Merge pull request #166 from peschmae/feature/github-workflow
Add workflow to build container images on tags
2023-09-27 05:36:23 +03:30
Daniel
38dd77cf42 Merge pull request #177 from linkwarden/dev
renamed image
2023-09-27 01:25:01 +03:30
daniel31x13
79a7605ed9 renamed image 2023-09-27 01:24:25 +03:30
Daniel
1473a66242 Merge pull request #176 from linkwarden/dev
Dev
2023-09-26 13:25:48 +03:30
Daniel
8ad84f571c minor change to README.md 2023-09-26 12:38:54 +03:30
Daniel
701d75b97e minor change to README.md 2023-09-26 11:02:08 +03:30
Daniel
185d67db0c updated README.md 2023-09-26 10:59:29 +03:30
Daniel
8109e8a6a3 fixed typo 2023-09-26 10:28:59 +03:30
Daniel
50a9d732a3 updated README.md 2023-09-26 10:27:35 +03:30
Daniel
fc066cba0d updated logo and README.md 2023-09-26 10:09:23 +03:30
Daniel
1dbae67443 Merge pull request #170 from linkwarden/dev
Dev
2023-09-25 01:14:33 +03:30
Daniel
c7d52889cc minor change 2023-09-25 01:13:36 +03:30
Daniel
2025d7649f Merge pull request #168 from peschmae/feature/slim-docker-image
Use official nodejs base image, slim down container image
2023-09-20 07:48:22 -04:00
Mathias Petermann
3eb273c25e Use official nodejs base image, slim down image
- Playwright 1.35 supports debian 11 (bullseye), for debian 12 (bookworm) support, playwright needs to be updated
- Ensure all apt & yarn caches are cleaned in the same step they are used (slims down image)
- Image uses a lot less steps now, and slimmed down form ~4GB to 2.5GB
2023-09-20 08:19:52 +02:00
Mathias Petermann
7ef6d97462 Add workflow to build container images on tags 2023-09-19 21:24:06 +02:00
Daniel
f242d8289a Merge pull request #160 from linkwarden/dev
Dev
2023-09-13 00:15:36 -04:00
Daniel
5583fd82f3 added yearly plan 2023-09-11 00:20:31 -04:00
Daniel
8fd108c74e minor fix 2023-09-06 23:13:58 -04:00
Daniel
c91ed73d64 Update README.md 2023-09-06 23:07:15 -04:00
Daniel
85788cb9ff minor rendering fix 2023-09-05 16:04:34 -04:00
Daniel
4365547867 changes and improvements 2023-09-05 14:12:27 -04:00
Daniel
6a603d7d56 styling improvements 2023-09-01 18:04:11 -04:00
Daniel
0b8d8c0645 minor fix 2023-08-31 23:39:39 -04:00
Daniel
fdfb3a927e small coloring changes 2023-08-31 00:17:27 -04:00
Daniel
11539ade6c small fix 2023-08-31 00:08:45 -04:00
Daniel
fab9a06d95 minor improvements 2023-08-31 00:00:57 -04:00
Daniel
5015f79b81 improved ux + coloring fix + improved dashboard ui 2023-08-28 14:03:06 -04:00
Daniel
e47aef8123 improved coloring 2023-08-22 18:34:46 -04:00
Daniel
acc974ecfe bug fix + ux improvements 2023-08-22 00:43:34 -04:00
Daniel
09ea45eec0 better stripe logic 2023-08-21 16:11:13 -04:00
Daniel
8dfd1598f3 refactored api routes 2023-08-20 12:00:42 -04:00
Daniel
b0e92c6253 Merge pull request #145 from linkwarden/feat/dark-mode
Feat/dark mode
2023-08-17 16:14:20 -04:00
Daniel
bf8a0df4c2 better input coloring in darkmode 2023-08-17 16:05:44 -04:00
Daniel
122b331efa final touch on darkmode 2023-08-15 21:29:38 -04:00
Daniel
da92d46f7b Merge pull request #131 from g1sbi/feat/dark-mode
Feat: Dark mode
2023-08-14 23:27:07 -04:00
Daniel
1701ba07d4 much better coloring 2023-08-14 23:25:25 -04:00
Daniel
7da89a35e2 many coloring changes and improvements 2023-08-11 01:11:02 -04:00
Daniel
1eb1467a02 confirmed dark color 2023-08-11 00:44:44 -04:00
Daniel
5d016068c7 minor change to theme toggle 2023-08-11 00:25:02 -04:00
Daniel
83349ea065 Merge branch 'feat/dark-mode' into feat/dark-mode 2023-08-10 20:50:30 -04:00
Daniel
9ef1d3db23 Merge pull request #137 from linkwarden/dev
Dev
2023-08-10 13:37:29 -04:00
Daniel
543dfd156c minor fix in the docker compose file 2023-08-10 13:36:23 -04:00
Daniel
d008c441b7 Feat/import export (#136)
* added import/export functionality
2023-08-10 12:16:44 -04:00
Gisbi
cff3b97ab7 finished working on dark mode 2023-08-07 11:44:09 +02:00
Gisbi
93ebc09faf working on dark mode 2023-08-06 16:13:45 +02:00
Daniel
3bff771c46 Merge pull request #124 from linkwarden/dev
bug fix
2023-08-06 00:59:02 -04:00
Daniel
159075b38b bug fix 2023-08-06 00:58:18 -04:00
Daniel
8a767108d3 Merge branch 'main' of https://github.com/Daniel31x13/linkwarden 2023-08-05 22:08:03 -04:00
Daniel
aeb88def6d Merge branch 'dev' of https://github.com/Daniel31x13/linkwarden 2023-08-05 22:07:55 -04:00
Daniel
89427f16f5 Merge pull request #119 from linkwarden/dev
minor fix
2023-08-04 20:32:42 -04:00
Daniel
02b7a90160 minor fix 2023-08-04 20:32:13 -04:00
Daniel
5406221f89 Merge pull request #118 from linkwarden/dev
Dev
2023-08-04 19:46:44 -04:00
Daniel
a56b8e24da updated migration file 2023-08-04 19:26:57 -04:00
Daniel
2177f12b9b Merge pull request #115 from crkos/dev
add sqlite compatibility + fix whitespace bug collections
2023-08-04 18:37:39 -04:00
Jordan Higuera Higuera
9405445332 identation fix 2023-08-04 15:10:31 -07:00
Jordan Higuera Higuera
91f9fcb500 changed default provider 2023-08-04 14:41:53 -07:00
Jordan Higuera Higuera
8747331c43 remove unused import 2023-08-04 10:13:23 -07:00
Jordan Higuera Higuera
895ef8e60f add mode insensitive in case we are using postgresql + rename table 2023-08-04 10:08:04 -07:00
Jordan Higuera Higuera
22093c0c29 add sqlite compatibility + fix whitespace bug collections 2023-08-03 21:33:51 -07:00
Daniel
c9cd2986dd Merge pull request #114 from linkwarden/dev
fixed typo
2023-08-03 19:48:50 -04:00
Daniel
264ea03e63 fixed typo 2023-08-03 19:48:23 -04:00
Daniel
5132473322 Merge pull request #113 from linkwarden/dev
Dev
2023-08-03 19:46:25 -04:00
Daniel
c6e7a329ab Merge branch 'dev' of https://github.com/Daniel31x13/linkwarden into dev 2023-08-03 19:44:53 -04:00
Daniel
21525b2920 updated readme 2023-08-03 19:44:49 -04:00
Daniel
0b0389d169 Merge pull request #112 from linkwarden/dev
Dev
2023-08-03 19:01:42 -04:00
Daniel
32e7bfe09c Update confirmation.tsx 2023-08-03 19:01:20 -04:00
Daniel
4be3125f9a easier docker setup 2023-08-03 18:31:40 -04:00
Daniel
a8009734a9 added docker bind mounts + bug fix 2023-08-03 17:54:04 -04:00
Daniel
01108a4bb4 Merge pull request #111 from linkwarden/main
Main
2023-08-03 15:18:34 -04:00
Daniel
5ba12dabdc Added Docker Support
Dev
2023-08-03 14:31:58 -04:00
Daniel
5ba3fd7b6c minor change 2023-08-03 14:25:46 -04:00
Daniel
49423ddb51 Merge pull request #106 from bijeebuss/main
bare bones docker setup
2023-08-03 14:24:24 -04:00
Daniel
44f17ba0ff Merge pull request #109 from linkwarden/dev
major bug fixed
2023-08-03 14:05:38 -04:00
michael welnick
9b56257542 Merge branch 'dev' 2023-08-03 11:05:12 -07:00
Daniel
1bb1d8140d major bug fixed 2023-08-03 14:03:06 -04:00
michael welnick
fc4d27d431 use data volume 2023-08-03 10:38:44 -07:00
michael welnick
d3300d7cc9 dockerignore and do primsa generate 2023-08-03 09:21:08 -07:00
michael welnick
922d145570 bare bones docker setup 2023-08-02 23:52:37 +00:00
Gisbi
64c417c1be working on dark mode 2023-08-02 19:53:55 +02:00
385 changed files with 26598 additions and 6967 deletions

View File

@@ -0,0 +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",
// 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 'postCreateCommand' to run commands after the container is created.
// "postCreateCommand": "yarn install",
// Configure tool-specific properties.
// "customizations": {},
// Uncomment to connect as root instead. More info: https://aka.ms/dev-containers-non-root.
"remoteUser": "root"
}

7
.dockerignore Normal file
View File

@@ -0,0 +1,7 @@
node_modules
pgdata
.env
.devcontainer
docker-compose.yml
Dockerfile
README.md

View File

@@ -1,28 +1,423 @@
NEXTAUTH_SECRET=very_sensitive_secret
NEXTAUTH_URL=http://localhost:3000/api/v1/auth
# Manual installation database settings
DATABASE_URL=postgresql://user:password@localhost:5432/linkwarden
NEXTAUTH_URL=http://localhost:3000
# Docker installation database settings
POSTGRES_PASSWORD=super_secret_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=
# 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=
PRICE_ID=
NEXT_PUBLIC_TRIAL_PERIOD_DAYS=
NEXT_PUBLIC_STRIPE_BILLING_PORTAL_URL=
BASE_URL=http://localhost:3000
NEXT_PUBLIC_PRICING=
# Proxy settings
PROXY=
PROXY_USERNAME=
PROXY_PASSWORD=
PROXY_BYPASS=
# 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=

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

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.

View File

@@ -1,8 +1,8 @@
---
name: Bug report
name: Bug Report
about: Create a report to help us improve
title: ''
labels: ''
labels: bug
assignees: ''
---

View File

@@ -1,8 +1,8 @@
---
name: Feature request
name: Feature Request
about: Suggest an idea for this project
title: ''
labels: ''
labels: enhancement
assignees: ''
---

22
.github/SECURITY.md vendored
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.

View File

@@ -1,7 +0,0 @@
version: 2
updates:
- package-ecosystem: "npm"
directory: "/"
schedule:
interval: "weekly"
target-branch: "dev"

143
.github/workflows/playwright-tests.yml vendored Normal file
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@v2
- name: Use Node.js
uses: actions/setup-node@v3
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@v3
if: always()
with:
name: playwright-report
path: test-results
retention-days: 30

49
.github/workflows/release-container.yml vendored Normal file
View File

@@ -0,0 +1,49 @@
name: Create and publish a container image on release
on:
push:
tags:
- "*"
env:
REGISTRY: ghcr.io
IMAGE_NAME: ${{ github.repository }}
jobs:
build-and-push-image:
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to the Container registry
uses: docker/login-action@v2
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Extract metadata (tags, labels) for Docker
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
- name: Build and push Docker image
uses: docker/build-push-action@v3
with:
context: .
push: true
platforms: linux/amd64,linux/arm64
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}

12
.gitignore vendored
View File

@@ -36,9 +36,21 @@ next-env.d.ts
# generated files and folders
/data
.idea
prisma/dev.db
# tests
/tests
/test-results/
/blob-report/
/playwright-report/
/playwright/.cache/
/playwright/.auth/
# docker
pgdata
certificates
/test-results/
/playwright-report/
/blob-report/
/playwright/.cache/

11
.prettierignore Normal file
View File

@@ -0,0 +1,11 @@
node_modules
.next
/public
*.lock
*.log
.github
data
pgdata

4
.prettierrc.json Normal file
View File

@@ -0,0 +1,4 @@
{
"trailingComma": "es5",
"tabWidth": 2
}

6
.vscode/settings.json vendored Normal file
View File

@@ -0,0 +1,6 @@
{
"tailwindCSS.experimental.classRegex": [
["cva\\(([^)]*)\\)", "[\"'`]([^\"'`]*).*?[\"'`]"],
["cx\\(([^)]*)\\)", "(?:'|\"|`)([^']*)(?:'|\"|`)"]
]
}

45
ARCHITECTURE.md Normal file
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).

40
Dockerfile Normal file
View File

@@ -0,0 +1,40 @@
FROM node:18.18-bullseye-slim
ARG DEBIAN_FRONTEND=noninteractive
RUN mkdir /data
WORKDIR /data
COPY ./package.json ./yarn.lock ./playwright.config.ts ./
RUN --mount=type=cache,sharing=locked,target=/usr/local/share/.cache/yarn yarn install --network-timeout 10000000
RUN apt-get update
RUN apt-get install -y \
build-essential \
curl \
libssl-dev \
pkg-config
RUN apt-get update
RUN curl https://sh.rustup.rs -sSf | bash -s -- -y
ENV PATH="/root/.cargo/bin:${PATH}"
RUN cargo install monolith
RUN npx playwright install-deps && \
apt-get clean && \
yarn cache clean
RUN yarn playwright install
COPY . .
RUN yarn prisma generate && \
yarn build
CMD yarn prisma migrate deploy && yarn start

View File

124
README.md
View File

@@ -1,95 +1,129 @@
<div align="center">
<img src="./assets/icon.png" width="100px" />
<img src="./assets/logo.png" width="100px" />
<h1>Linkwarden</h1>
<a href="https://discord.com/invite/CtuYV47nuJ"><img src="https://img.shields.io/discord/1117993124669702164?logo=discord&style=flat-square" alt="Discord"></a>
<img src="https://img.shields.io/github/commit-activity/m/linkwarden/linkwarden?style=flat-square" alt="Github Activity">
<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>
<img alt="GitHub commits since latest release" src="https://img.shields.io/github/commits-since/linkwarden/linkwarden/latest/dev?style=for-the-badge&label=COMMITS%20SINCE%20LATEST%20RELEASE">
</div>
<div align='center'>
[Homepage](https://linkwarden.app) | [Getting Started](https://docs.linkwarden.app/getting-started) | [Features](https://github.com/linkwarden/linkwarden#features) | [Roadmap](https://github.com/linkwarden/linkwarden#roadmap) | [Screenshots](https://github.com/linkwarden/linkwarden#screenshots) | [Support ❤](https://github.com/linkwarden/linkwarden#support-)
[Website](https://linkwarden.app) | [Getting Started](https://docs.linkwarden.app) | [Features](https://github.com/linkwarden/linkwarden#features) | [Roadmap](https://github.com/orgs/linkwarden/projects/1) | [Support ❤](https://github.com/linkwarden/linkwarden#support-)
</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](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 you **not** to use the old version as it is no longer maintained and has much less features. But anyway if you really wanna check it out, here it is in [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>
## Main Tech Stack
- NextJS
- TypeScript
- Tailwind
- Prisma
- Zustand
## Features
- Auto capture a screenshot and a PDF of each link.
- ✅ Organize links by 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.
- Search, filter and sort by link details.
- ✅ Responsive design and supports most browsers.
- 📸 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 and preserved formats with the world.
- 📌 Pin your favorite links to dashboard.
- 🔍 Full text search, filter and sort for easy retrieval.
- 📱 Responsive design and supports most modern browsers.
- 🌓 Dark/Light mode support.
- 🧩 Browser extension, managed by the community. [Star it here!](https://github.com/linkwarden/browser-extension)
- ⬇️ Import and export your bookmarks.
- 🔐 SSO integration. (Enterprise and Self-hosted users only)
- 📦 Installable Progressive Web App (PWA).
- 🍎 iOS Shortcut to save links to Linkwarden.
- 🔑 API keys.
- ✅ Bulk actions.
- ✨ And so many more features!
## 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! :)
## Roadmap
There are _many_ upcoming features, below are only _some_ of the 100% planned ones:
- 🐳 Docker version.
- 🌒 Dark mode.
- 📦 Import/Export your data.
- 🧩 Browser extention.
Also make sure to check out our [public roadmap](https://github.com/orgs/linkwarden/projects/1).
Make sure to check out our [public roadmap](https://github.com/orgs/linkwarden/projects/1).
## Docs
Currently, the Documentation is a bit targeted towards a more tech-savvy audience and has so much room to improve, you can find it [here](https://docs.linkwarden.app).
For information on how to get started or to set up your own instance, please visit the [documentation](https://docs.linkwarden.app).
## 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 item 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
<img src="./assets/collections.png" />
<img src="./assets/collaborators.png" />
<img src="./assets/link_details.png" />
## Support ❤
Any [donations](https://opencollective.com/linkwarden) are highly appreciated. <3
Other than using our official [Cloud](https://linkwarden.app/#pricing) offering, any [donations](https://opencollective.com/linkwarden) are highly appreciated as well!
Here are the other ways to support/cheer this project:
- Starring this repo.
- 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"/>

BIN
assets/all_collections.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 251 KiB

BIN
assets/all_links.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 564 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 362 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 324 KiB

BIN
assets/dashboard.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 786 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 58 KiB

BIN
assets/light_dashboard.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 471 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 315 KiB

BIN
assets/list_view.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 394 KiB

BIN
assets/logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 79 KiB

BIN
assets/manage_team.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 226 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 301 KiB

BIN
assets/public_page.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 330 KiB

BIN
assets/readable_view.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 345 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 254 KiB

BIN
assets/star_repo.gif Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 MiB

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

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 = {
@@ -12,23 +10,17 @@ type Props = {
export default function Checkbox({ label, state, className, onClick }: Props) {
return (
<label
className={`cursor-pointer flex items-center gap-2 text-sky-700 ${className}`}
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-700 peer-checked:block hidden"
/>
<FontAwesomeIcon
icon={faSquare}
className="w-5 h-5 text-sky-700 peer-checked:hidden block"
/>
<span className="text-sky-900 rounded select-none">{label}</span>
<span className="label-text">{label}</span>
</label>
);
}

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

View File

@@ -1,13 +1,16 @@
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 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 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 = {
collection: CollectionIncludingMembersAndLinkCount;
@@ -15,7 +18,9 @@ type Props = {
};
export default function CollectionCard({ collection, className }: Props) {
const { setModal } = useModalStore();
const { t } = useTranslation();
const { settings } = useLocalSettingsStore();
const { data: user = {} } = useUser();
const formattedDate = new Date(collection.createdAt as string).toLocaleString(
"en-US",
@@ -26,124 +31,196 @@ export default function CollectionCard({ collection, className }: Props) {
}
);
const [expandDropdown, setExpandDropdown] = useState(false);
const permissions = usePermissions(collection.id as number);
const [collectionOwner, setCollectionOwner] = useState({
id: null as unknown as number,
name: "",
username: "",
image: "",
archiveAsScreenshot: undefined as unknown as boolean,
archiveAsMonolith: undefined as unknown as boolean,
archiveAsPDF: undefined as unknown as boolean,
});
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
className={`bg-gradient-to-tr from-sky-100 from-10% via-gray-100 via-20% to-white to-100% self-stretch min-h-[12rem] rounded-2xl shadow duration-100 hover:shadow-none 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 w-52 mt-1">
{permissions === true && (
<li>
<div
role="button"
tabIndex={0}
onClick={() => {
(document?.activeElement as HTMLElement)?.blur();
setEditCollectionModal(true);
}}
>
{t("edit_collection_info")}
</div>
</li>
)}
<li>
<div
role="button"
tabIndex={0}
onClick={() => {
(document?.activeElement as HTMLElement)?.blur();
setEditCollectionSharingModal(true);
}}
>
{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);
}}
>
{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 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"
/>
{collectionOwner.id ? (
<ProfilePhoto
src={collectionOwner.image || undefined}
name={collectionOwner.name}
/>
) : undefined}
{collection.members
.sort((a, b) => (a.userId as number) - (b.userId as number))
.map((e, i) => {
return (
<ProfilePhoto
key={i}
src={e.user.image ? e.user.image : undefined}
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>
) : null}
</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 font-bold capitalize text-sky-700 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-700 border-sky-100 -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-sky-700 font-bold text-sm flex justify-end gap-1 items-center">
<FontAwesomeIcon icon={faLink} className="w-5 h-5 text-sky-500" />
{collection._count && collection._count.links}
</div>
<div className="flex items-center justify-end gap-1 text-gray-600">
<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>
) : undefined}
<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}
) : undefined}
{editCollectionSharingModal ? (
<EditCollectionSharingModal
onClose={() => setEditCollectionSharingModal(false)}
activeCollection={collection}
/>
) : undefined}
{deleteCollectionModal ? (
<DeleteCollectionModal
onClose={() => setDeleteCollectionModal(false)}
activeCollection={collection}
/>
) : undefined}
</div>
);
}

View File

@@ -0,0 +1,388 @@
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";
interface ExtendedTreeItem extends TreeItem {
data: Collection;
}
const CollectionListing = () => {
const { t } = useTranslation();
const updateCollection = useUpdateCollection();
const { data: collections = [], isLoading } = useCollections();
const { data: user = {} } = useUser();
const updateUser = useUpdateUser();
const router = useRouter();
const currentPath = router.asPath;
const [tree, setTree] = useState<TreeData | undefined>();
const initialTree = useMemo(() => {
if (
// !tree &&
collections.length > 0
) {
return buildTreeFromCollections(
collections,
router,
user.collectionOrder
);
} else return undefined;
}, [collections, user, router]);
useEffect(() => {
// if (!tree)
setTree(initialTree);
}, [initialTree]);
useEffect(() => {
if (user.username) {
if (
(!user.collectionOrder || user.collectionOrder.length === 0) &&
collections.length > 0
)
updateUser.mutate({
...user,
collectionOrder: collections
.filter(
(e) =>
e.parentId === null ||
!collections.find((i) => i.id === e.parentId)
) // Filter out collections with non-null parentId
.map((e) => e.id as number),
});
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,
});
}
}
}
}, [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`}
>
{Icon(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`}
>
<i
className="bi-folder-fill text-2xl drop-shadow"
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>
) : undefined}
<div className="drop-shadow text-neutral text-xs">
{collection._count?.links}
</div>
</div>
</Link>
</div>
</div>
);
};
const Icon = (
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>,
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: false,
data: {
id: collection.id,
parentId: collection.parentId,
name: collection.name,
description: collection.description,
color: collection.color,
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 };
};

View File

@@ -0,0 +1,21 @@
export default function dashboardItem({
name,
value,
icon,
}: {
name: string;
value: number;
icon: string;
}) {
return (
<div className="flex items-center">
<div className="w-[4rem] aspect-square flex justify-center items-center bg-primary/20 rounded-xl select-none">
<i className={`${icon} text-primary text-3xl drop-shadow`}></i>
</div>
<div className="ml-4 flex flex-col justify-center">
<p className="text-neutral text-xs tracking-wider">{name}</p>
<p className="font-thin text-5xl text-primary mt-0.5">{value}</p>
</div>
</div>
);
}

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,19 +19,72 @@ 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 (
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>();
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-sky-100 bg-gray-50 rounded-md flex flex-col z-20`}
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-slate-200 duration-100">
<p className="text-sky-900 select-none">{e.name}</p>
<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>
);
@@ -49,5 +102,5 @@ export default function Dropdown({ onClickOutside, className, items }: Props) {
);
})}
</ClickAwayHandler>
);
) : null;
}

View File

@@ -1,61 +1,135 @@
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 from "react";
import { useTranslation } from "next-i18next";
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();
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 shadow-md bg-gray-50 rounded-md p-2 z-20 w-40"
>
<p className="mb-2 text-sky-900 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 w-56 mt-1">
<li>
<label
className="label cursor-pointer flex justify-start"
tabIndex={0}
role="button"
>
<input
type="checkbox"
name="search-filter-checkbox"
className="checkbox checkbox-primary"
checked={searchFilter.name}
onChange={() =>
setSearchFilter({ ...searchFilter, name: !searchFilter.name })
}
/>
<span className="label-text">{t("name")}</span>
</label>
</li>
<li>
<label
className="label cursor-pointer flex justify-start"
tabIndex={0}
role="button"
>
<input
type="checkbox"
name="search-filter-checkbox"
className="checkbox checkbox-primary"
checked={searchFilter.url}
onChange={() =>
setSearchFilter({ ...searchFilter, url: !searchFilter.url })
}
/>
<span className="label-text">{t("link")}</span>
</label>
</li>
<li>
<label
className="label cursor-pointer flex justify-start"
tabIndex={0}
role="button"
>
<input
type="checkbox"
name="search-filter-checkbox"
className="checkbox checkbox-primary"
checked={searchFilter.description}
onChange={() =>
setSearchFilter({
...searchFilter,
description: !searchFilter.description,
})
}
/>
<span className="label-text">{t("description")}</span>
</label>
</li>
<li>
<label
className="label cursor-pointer flex justify-start"
tabIndex={0}
role="button"
>
<input
type="checkbox"
name="search-filter-checkbox"
className="checkbox checkbox-primary"
checked={searchFilter.tags}
onChange={() =>
setSearchFilter({ ...searchFilter, tags: !searchFilter.tags })
}
/>
<span className="label-text">{t("tags")}</span>
</label>
</li>
<li>
<label
className="label cursor-pointer flex justify-between"
tabIndex={0}
role="button"
>
<input
type="checkbox"
name="search-filter-checkbox"
className="checkbox checkbox-primary"
checked={searchFilter.textContent}
onChange={() =>
setSearchFilter({
...searchFilter,
textContent: !searchFilter.textContent,
})
}
/>
<span className="label-text">{t("full_content")}</span>
<div className="ml-auto badge badge-sm badge-neutral">
{t("slower")}
</div>
</label>
</li>
</ul>
</div>
);
}

View File

@@ -1,22 +1,31 @@
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;
};
export default function CollectionSelection({ onChange, defaultValue }: Props) {
const { collections } = useCollectionStore();
export default function CollectionSelection({
onChange,
defaultValue,
showDefaultValue = true,
creatable = true,
}: Props) {
const { data: collections = [] } = useCollections();
const router = useRouter();
const [options, setOptions] = useState<Options[]>([]);
@@ -36,21 +45,87 @@ 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
placeholder="Default: Unnamed Collection"
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 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}
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}
defaultValue={showDefaultValue ? defaultValue : null}
components={{
Option: customOption,
}}
// menuPosition="fixed"
/>
);
}
}

View File

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

View File

@@ -8,20 +8,27 @@ 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",
}),
control: (styles) => ({
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 +43,6 @@ export const styles: StylesConfig = {
...styles,
cursor: "pointer",
}),
clearIndicator: (styles) => ({
...styles,
visibility: "hidden",
}),
placeholder: (styles) => ({
...styles,
borderColor: "black",
@@ -62,4 +65,5 @@ export const styles: StylesConfig = {
backgroundColor: "#38bdf8",
},
}),
menuPortal: (base) => ({ ...base, zIndex: 9999 }),
};

54
components/InstallApp.tsx Normal file
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 p-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;

View File

@@ -1,233 +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";
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={`bg-gradient-to-tr from-slate-200 from-10% to-gray-50 via-20% shadow hover:shadow-none cursor-pointer 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 inline-flex rounded-md cursor-pointer hover:bg-slate-200 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 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-md 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-sky-500 font-bold">{count + 1}.</p>
<p className="text-lg text-sky-700 font-bold truncate capitalize w-full pr-8">
{link.name || link.description}
</p>
</div>
<div className="flex gap-3 items-center my-3">
<div className="flex items-center gap-1 w-full pr-20">
<FontAwesomeIcon
icon={faFolder}
className="w-4 h-4 mt-1 drop-shadow"
style={{ color: collection?.color }}
/>
<p className="text-sky-900 truncate capitalize">
{collection?.name}
</p>
</div>
</div>
<div className="flex items-center gap-1 w-full pr-20 text-gray-500">
<FontAwesomeIcon icon={faLink} className="mt-1 w-4 h-4" />
<p className="truncate w-full">{shortendURL}</p>
</div>
<div className="flex items-center gap-1 text-gray-500">
<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>
);
}

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;

View File

@@ -0,0 +1,205 @@
import { useState } from "react";
import {
CollectionIncludingMembersAndLinkCount,
LinkIncludingShortenedCollectionAndTags,
} from "@/types/global";
import usePermissions from "@/hooks/usePermissions";
import EditLinkModal from "@/components/ModalContent/EditLinkModal";
import DeleteLinkModal from "@/components/ModalContent/DeleteLinkModal";
import PreservedFormatsModal from "@/components/ModalContent/PreservedFormatsModal";
import { dropdownTriggerer } from "@/lib/client/utils";
import { useTranslation } from "next-i18next";
import { useUser } from "@/hooks/store/user";
import { useDeleteLink, useUpdateLink } from "@/hooks/store/links";
import toast from "react-hot-toast";
type Props = {
link: LinkIncludingShortenedCollectionAndTags;
collection: CollectionIncludingMembersAndLinkCount;
position?: string;
toggleShowInfo?: () => void;
linkInfo?: boolean;
alignToTop?: boolean;
flipDropdown?: boolean;
};
export default function LinkActions({
link,
toggleShowInfo,
position,
linkInfo,
alignToTop,
flipDropdown,
}: Props) {
const { t } = useTranslation();
const permissions = usePermissions(link.collection.id as number);
const [editLinkModal, setEditLinkModal] = useState(false);
const [deleteLinkModal, setDeleteLinkModal] = useState(false);
const [preservedFormatsModal, setPreservedFormatsModal] = useState(false);
const { data: user = {} } = useUser();
const updateLink = useUpdateLink();
const deleteLink = useDeleteLink();
const pinLink = async () => {
const isAlreadyPinned = link?.pinnedBy && link.pinnedBy[0] ? true : false;
const load = toast.loading(t("updating"));
await updateLink.mutateAsync(
{
...link,
pinnedBy: isAlreadyPinned ? undefined : [{ id: user.id }],
},
{
onSettled: (data, error) => {
toast.dismiss(load);
if (error) {
toast.error(error.message);
} else {
toast.success(
isAlreadyPinned ? t("link_unpinned") : t("link_pinned")
);
}
},
}
);
};
return (
<>
<div
className={`dropdown dropdown-left absolute ${
position || "top-3 right-3"
} ${alignToTop ? "" : "dropdown-end"} z-20`}
>
<div
tabIndex={0}
role="button"
onMouseDown={dropdownTriggerer}
className="btn btn-ghost btn-sm btn-square text-neutral"
>
<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 w-44 mr-1 ${
alignToTop ? "" : "translate-y-10"
}`}
>
<li>
<div
role="button"
tabIndex={0}
onClick={() => {
(document?.activeElement as HTMLElement)?.blur();
pinLink();
}}
>
{link?.pinnedBy && link.pinnedBy[0]
? t("unpin")
: t("pin_to_dashboard")}
</div>
</li>
{linkInfo !== undefined && toggleShowInfo ? (
<li>
<div
role="button"
tabIndex={0}
onClick={() => {
(document?.activeElement as HTMLElement)?.blur();
toggleShowInfo();
}}
>
{!linkInfo ? t("show_link_details") : t("hide_link_details")}
</div>
</li>
) : undefined}
{permissions === true || permissions?.canUpdate ? (
<li>
<div
role="button"
tabIndex={0}
onClick={() => {
(document?.activeElement as HTMLElement)?.blur();
setEditLinkModal(true);
}}
>
{t("edit_link")}
</div>
</li>
) : undefined}
{link.type === "url" && (
<li>
<div
role="button"
tabIndex={0}
onClick={() => {
(document?.activeElement as HTMLElement)?.blur();
setPreservedFormatsModal(true);
}}
>
{t("preserved_formats")}
</div>
</li>
)}
{permissions === true || permissions?.canDelete ? (
<li>
<div
role="button"
tabIndex={0}
onClick={async (e) => {
(document?.activeElement as HTMLElement)?.blur();
e.shiftKey
? async () => {
const load = toast.loading(t("deleting"));
await deleteLink.mutateAsync(link.id as number, {
onSettled: (data, error) => {
toast.dismiss(load);
if (error) {
toast.error(error.message);
} else {
toast.success(t("deleted"));
}
},
});
}
: setDeleteLinkModal(true);
}}
>
{t("delete")}
</div>
</li>
) : undefined}
</ul>
</div>
{editLinkModal ? (
<EditLinkModal
onClose={() => setEditLinkModal(false)}
activeLink={link}
/>
) : undefined}
{deleteLinkModal ? (
<DeleteLinkModal
onClose={() => setDeleteLinkModal(false)}
activeLink={link}
/>
) : undefined}
{preservedFormatsModal ? (
<PreservedFormatsModal
onClose={() => setPreservedFormatsModal(false)}
link={link}
/>
) : undefined}
{/* {expandedLink ? (
<ExpandedLink onClose={() => setExpandedLink(false)} link={link} />
) : undefined} */}
</>
);
}

View File

@@ -0,0 +1,260 @@
import {
ArchivedFormat,
CollectionIncludingMembersAndLinkCount,
LinkIncludingShortenedCollectionAndTags,
} from "@/types/global";
import { useEffect, 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";
type Props = {
link: LinkIncludingShortenedCollectionAndTags;
count: number;
className?: string;
flipDropdown?: boolean;
editMode?: boolean;
};
export default function LinkCard({ link, flipDropdown, editMode }: Props) {
const { t } = useTranslation();
const { data: collections = [] } = useCollections();
const { data: user = {} } = useUser();
const { setSelectedLinks, selectedLinks } = useLinkStore();
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);
useEffect(() => {
let interval: any;
if (
isVisible &&
!link.preview?.startsWith("archives") &&
link.preview !== "unavailable"
) {
interval = setInterval(async () => {
getLink.mutateAsync(link.id as number);
}, 5000);
}
return () => {
if (interval) {
clearInterval(interval);
}
};
}, [isVisible, link.preview]);
const [showInfo, setShowInfo] = useState(false);
const selectedStyle = selectedLinks.some(
(selectedLink) => selectedLink.id === link.id
)
? "border-primary bg-base-300"
: "border-neutral-content";
const selectable =
editMode &&
(permissions === true || permissions?.canCreate || permissions?.canDelete);
return (
<div
ref={ref}
className={`${selectedStyle} border border-solid border-neutral-content bg-base-200 shadow-md hover:shadow-none duration-100 rounded-2xl relative`}
onClick={() =>
selectable
? handleCheckboxClick(link)
: editMode
? toast.error(t("link_selection_error"))
: undefined
}
>
<div
className="rounded-2xl cursor-pointer h-full flex flex-col justify-between"
onClick={() =>
!editMode && window.open(generateLinkHref(link, user), "_blank")
}
>
<div>
<div className="relative rounded-t-2xl h-40 overflow-hidden">
{previewAvailable(link) ? (
<Image
src={`/api/v1/archives/${link.id}?format=${ArchivedFormat.jpeg}&preview=true`}
width={1280}
height={720}
alt=""
className="rounded-t-2xl select-none object-cover z-10 h-40 w-full shadow opacity-80 scale-105"
style={
link.type !== "image" ? { filter: "blur(1px)" } : undefined
}
draggable="false"
onError={(e) => {
const target = e.target as HTMLElement;
target.style.display = "none";
}}
/>
) : link.preview === "unavailable" ? (
<div className="bg-gray-50 duration-100 h-40 bg-opacity-80"></div>
) : (
<div className="duration-100 h-40 bg-opacity-80 skeleton rounded-none"></div>
)}
{link.type !== "image" && (
<div className="absolute top-0 left-0 right-0 bottom-0 rounded-t-2xl flex items-center justify-center shadow rounded-md">
<LinkIcon link={link} />
</div>
)}
</div>
<hr className="divider my-0 border-t border-neutral-content h-[1px]" />
</div>
<div className="flex flex-col justify-between h-full">
<div className="p-3 flex flex-col gap-2">
<p className="truncate w-full pr-9 text-primary text-sm">
{unescapeString(link.name)}
</p>
<LinkTypeBadge link={link} />
</div>
<div>
<hr className="divider mt-2 mb-1 last:hidden border-t border-neutral-content h-[1px]" />
<div className="flex justify-between text-xs text-neutral px-3 pb-1 gap-2">
<div className="cursor-pointer truncate">
{collection && (
<LinkCollection link={link} collection={collection} />
)}
</div>
<LinkDate link={link} />
</div>
</div>
</div>
</div>
{showInfo && (
<div className="p-3 absolute z-30 top-0 left-0 right-0 bottom-0 bg-base-200 rounded-[0.9rem] fade-in overflow-y-auto">
<div
onClick={() => setShowInfo(!showInfo)}
className=" float-right btn btn-sm outline-none btn-circle btn-ghost z-10"
>
<i className="bi-x text-neutral text-2xl"></i>
</div>
<p className="text-neutral text-lg font-semibold">
{t("description")}
</p>
<hr className="divider my-2 border-t border-neutral-content h-[1px]" />
<p>
{link.description ? (
unescapeString(link.description)
) : (
<span className="text-neutral text-sm">
{t("no_description")}
</span>
)}
</p>
{link.tags && link.tags[0] && (
<>
<p className="text-neutral text-lg mt-3 font-semibold">
{t("tags")}
</p>
<hr className="divider my-2 border-t border-neutral-content h-[1px]" />
<div className="flex gap-3 items-center flex-wrap mt-2 truncate relative">
<div className="flex gap-1 items-center flex-wrap">
{link.tags.map((e, i) => (
<Link
href={"/tags/" + e.id}
key={i}
onClick={(e) => {
e.stopPropagation();
}}
className="btn btn-xs btn-ghost truncate max-w-[19rem]"
>
#{e.name}
</Link>
))}
</div>
</div>
</>
)}
</div>
)}
<LinkActions
link={link}
collection={collection}
position="top-[10.75rem] right-3"
toggleShowInfo={() => setShowInfo(!showInfo)}
linkInfo={showInfo}
flipDropdown={flipDropdown}
/>
</div>
);
}

View File

@@ -0,0 +1,33 @@
import {
CollectionIncludingMembersAndLinkCount,
LinkIncludingShortenedCollectionAndTags,
} from "@/types/global";
import Link from "next/link";
import React from "react";
export default function LinkCollection({
link,
collection,
}: {
link: LinkIncludingShortenedCollectionAndTags;
collection: CollectionIncludingMembersAndLinkCount;
}) {
return (
<>
<Link
href={`/collections/${link.collection.id}`}
onClick={(e) => {
e.stopPropagation();
}}
className="flex items-center gap-1 max-w-full w-fit hover:opacity-70 duration-100 select-none"
title={collection?.name}
>
<i
className="bi-folder-fill text-lg drop-shadow"
style={{ color: collection?.color }}
></i>
<p className="truncate capitalize">{collection?.name}</p>
</Link>
</>
);
}

View File

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

View File

@@ -0,0 +1,103 @@
import { LinkIncludingShortenedCollectionAndTags } from "@/types/global";
import Image from "next/image";
import isValidUrl from "@/lib/shared/isValidUrl";
import React from "react";
export default function LinkIcon({
link,
className,
size,
}: {
link: LinkIncludingShortenedCollectionAndTags;
className?: string;
size?: "small" | "medium";
}) {
let iconClasses: string =
"bg-white shadow rounded-md border-[2px] flex item-center justify-center border-white select-none z-10 " +
(className || "");
let dimension;
switch (size) {
case "small":
dimension = " w-8 h-8";
break;
case "medium":
dimension = " w-12 h-12";
break;
default:
size = "medium";
dimension = " w-12 h-12";
break;
}
const url =
isValidUrl(link.url || "") && link.url ? new URL(link.url) : undefined;
const [showFavicon, setShowFavicon] = React.useState<boolean>(true);
return (
<>
{link.type === "url" && url ? (
showFavicon ? (
<Image
src={`https://t2.gstatic.com/faviconV2?client=SOCIAL&type=FAVICON&fallback_opts=TYPE,SIZE,URL&url=${link.url}&size=32`}
width={64}
height={64}
alt=""
className={iconClasses + dimension}
draggable="false"
onError={() => {
setShowFavicon(false);
}}
/>
) : (
<LinkPlaceholderIcon
iconClasses={iconClasses + dimension}
size={size}
icon="bi-link-45deg"
/>
)
) : link.type === "pdf" ? (
<LinkPlaceholderIcon
iconClasses={iconClasses + dimension}
size={size}
icon="bi-file-earmark-pdf"
/>
) : link.type === "image" ? (
<LinkPlaceholderIcon
iconClasses={iconClasses + dimension}
size={size}
icon="bi-file-earmark-image"
/>
) : // : link.type === "monolith" ? (
// <LinkPlaceholderIcon
// iconClasses={iconClasses + dimension}
// size={size}
// icon="bi-filetype-html"
// />
// )
undefined}
</>
);
}
const LinkPlaceholderIcon = ({
iconClasses,
size,
icon,
}: {
iconClasses: string;
size?: "small" | "medium";
icon: string;
}) => {
return (
<div
className={`${
size === "small" ? "text-2xl" : "text-4xl"
} text-black aspect-square ${iconClasses}`}
>
<i className={`${icon} m-auto`}></i>
</div>
);
};

View File

@@ -0,0 +1,172 @@
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";
type Props = {
link: LinkIncludingShortenedCollectionAndTags;
count: number;
className?: string;
flipDropdown?: boolean;
editMode?: boolean;
};
export default function LinkCardCompact({
link,
flipDropdown,
editMode,
}: Props) {
const { t } = useTranslation();
const { data: collections = [] } = useCollections();
const { data: user = {} } = useUser();
const { setSelectedLinks, selectedLinks } = useLinkStore();
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 [showInfo, setShowInfo] = useState(false);
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);
return (
<>
<div
className={`${selectedStyle} border relative items-center flex ${
!showInfo && !isPWA() ? "hover:bg-base-300 p-3" : "py-3"
} duration-200 rounded-lg w-full`}
onClick={() =>
selectable
? handleCheckboxClick(link)
: editMode
? toast.error(t("link_selection_error"))
: undefined
}
>
{/* {showCheckbox &&
editMode &&
(permissions === true ||
permissions?.canCreate ||
permissions?.canDelete) && (
<input
type="checkbox"
className="checkbox checkbox-primary my-auto mr-2"
checked={selectedLinks.some(
(selectedLink) => selectedLink.id === link.id
)}
onChange={() => handleCheckboxClick(link)}
/>
)} */}
<div
className="flex items-center cursor-pointer w-full"
onClick={() =>
!editMode && window.open(generateLinkHref(link, user), "_blank")
}
>
<div className="shrink-0">
<LinkIcon link={link} className="w-12 h-12 text-4xl" />
</div>
<div className="w-[calc(100%-56px)] ml-2">
<p className="line-clamp-1 mr-8 text-primary select-none">
{link.name ? (
unescapeString(link.name)
) : (
<div className="mt-2">
<LinkTypeBadge link={link} />
</div>
)}
</p>
<div className="mt-1 flex flex-col sm:flex-row sm:items-center gap-2 text-xs text-neutral">
<div className="flex items-center gap-x-3 text-neutral flex-wrap">
{collection ? (
<LinkCollection link={link} collection={collection} />
) : undefined}
{link.name && <LinkTypeBadge link={link} />}
<LinkDate link={link} />
</div>
</div>
</div>
</div>
<LinkActions
link={link}
collection={collection}
position="top-3 right-3"
flipDropdown={flipDropdown}
// toggleShowInfo={() => setShowInfo(!showInfo)}
// linkInfo={showInfo}
/>
</div>
<div
className="last:hidden rounded-none"
style={{
borderTop: "1px solid var(--fallback-bc,oklch(var(--bc)/0.1))",
}}
></div>
</>
);
}

View File

@@ -0,0 +1,275 @@
import {
ArchivedFormat,
CollectionIncludingMembersAndLinkCount,
LinkIncludingShortenedCollectionAndTags,
} from "@/types/global";
import { useEffect, 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";
type Props = {
link: LinkIncludingShortenedCollectionAndTags;
count: number;
className?: string;
flipDropdown?: boolean;
editMode?: boolean;
};
export default function LinkMasonry({ link, flipDropdown, editMode }: Props) {
const { t } = useTranslation();
const { data: collections = [] } = useCollections();
const { data: user = {} } = useUser();
const { setSelectedLinks, selectedLinks } = useLinkStore();
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);
useEffect(() => {
let interval: any;
if (
isVisible &&
!link.preview?.startsWith("archives") &&
link.preview !== "unavailable"
) {
interval = setInterval(async () => {
getLink.mutateAsync(link.id as number);
}, 5000);
}
return () => {
if (interval) {
clearInterval(interval);
}
};
}, [isVisible, link.preview]);
const [showInfo, setShowInfo] = useState(false);
const selectedStyle = selectedLinks.some(
(selectedLink) => selectedLink.id === link.id
)
? "border-primary bg-base-300"
: "border-neutral-content";
const selectable =
editMode &&
(permissions === true || permissions?.canCreate || permissions?.canDelete);
return (
<div
ref={ref}
className={`${selectedStyle} border border-solid border-neutral-content bg-base-200 shadow-md hover:shadow-none duration-100 rounded-2xl relative`}
onClick={() =>
selectable
? handleCheckboxClick(link)
: editMode
? toast.error(t("link_selection_error"))
: undefined
}
>
<div
className="rounded-2xl cursor-pointer"
onClick={() =>
!editMode && window.open(generateLinkHref(link, user), "_blank")
}
>
<div className="relative rounded-t-2xl overflow-hidden">
{previewAvailable(link) ? (
<Image
src={`/api/v1/archives/${link.id}?format=${ArchivedFormat.jpeg}&preview=true`}
width={1280}
height={720}
alt=""
className="rounded-t-2xl select-none object-cover z-10 h-40 w-full shadow opacity-80 scale-105"
style={
link.type !== "image" ? { filter: "blur(1px)" } : undefined
}
draggable="false"
onError={(e) => {
const target = e.target as HTMLElement;
target.style.display = "none";
}}
/>
) : link.preview === "unavailable" ? null : (
<div className="duration-100 h-40 bg-opacity-80 skeleton rounded-none"></div>
)}
{link.type !== "image" && (
<div className="absolute top-0 left-0 right-0 bottom-0 rounded-t-2xl flex items-center justify-center shadow rounded-md">
<LinkIcon link={link} />
</div>
)}
</div>
{link.preview !== "unavailable" && (
<hr className="divider my-0 last:hidden border-t border-neutral-content h-[1px]" />
)}
<div className="p-3 flex flex-col gap-2">
<p className="hyphens-auto w-full pr-9 text-primary text-sm">
{unescapeString(link.name)}
</p>
<LinkTypeBadge link={link} />
{link.description && (
<p className="hyphens-auto text-sm">
{unescapeString(link.description)}
</p>
)}
{link.tags && link.tags[0] && (
<div className="flex gap-1 items-center flex-wrap">
{link.tags.map((e, i) => (
<Link
href={"/tags/" + e.id}
key={i}
onClick={(e) => {
e.stopPropagation();
}}
className="btn btn-xs btn-ghost truncate max-w-[19rem]"
>
#{e.name}
</Link>
))}
</div>
)}
</div>
<hr className="divider mt-2 mb-1 last:hidden border-t border-neutral-content h-[1px]" />
<div className="flex flex-wrap justify-between text-xs text-neutral px-3 pb-1 w-full gap-x-2">
{collection && <LinkCollection link={link} collection={collection} />}
<LinkDate link={link} />
</div>
</div>
{showInfo && (
<div className="p-3 absolute z-30 top-0 left-0 right-0 bottom-0 bg-base-200 rounded-2xl fade-in overflow-y-auto">
<div
onClick={() => setShowInfo(!showInfo)}
className=" float-right btn btn-sm outline-none btn-circle btn-ghost z-10"
>
<i className="bi-x text-neutral text-2xl"></i>
</div>
<p className="text-neutral text-lg font-semibold">
{t("description")}
</p>
<hr className="divider my-2 last:hidden border-t border-neutral-content h-[1px]" />
<p>
{link.description ? (
unescapeString(link.description)
) : (
<span className="text-neutral text-sm">
{t("no_description")}
</span>
)}
</p>
{link.tags && link.tags[0] && (
<>
<p className="text-neutral text-lg mt-3 font-semibold">
{t("tags")}
</p>
<hr className="divider my-2 last:hidden border-t border-neutral-content h-[1px]" />
<div className="flex gap-3 items-center flex-wrap mt-2 truncate relative">
<div className="flex gap-1 items-center flex-wrap">
{link.tags.map((e, i) => (
<Link
href={"/tags/" + e.id}
key={i}
onClick={(e) => {
e.stopPropagation();
}}
className="btn btn-xs btn-ghost truncate max-w-[19rem]"
>
#{e.name}
</Link>
))}
</div>
</div>
</>
)}
</div>
)}
<LinkActions
link={link}
collection={collection}
position={
link.preview !== "unavailable"
? "top-[10.75rem] right-3"
: "top-[.75rem] right-3"
}
toggleShowInfo={() => setShowInfo(!showInfo)}
linkInfo={showInfo}
flipDropdown={flipDropdown}
/>
</div>
);
}

View File

@@ -0,0 +1,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>
);
}

View File

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

View File

@@ -1,7 +0,0 @@
export default function Loading() {
return (
<div>
<p>Loading...</p>
</div>
);
}

View File

@@ -0,0 +1,98 @@
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 w-40 mb-1 -ml-12">
<li>
<div
onClick={() => {
(document?.activeElement as HTMLElement)?.blur();
setNewLinkModal(true);
}}
tabIndex={0}
role="button"
>
{t("new_link")}
</div>
</li>
<li>
<div
onClick={() => {
(document?.activeElement as HTMLElement)?.blur();
setUploadFileModal(true);
}}
tabIndex={0}
role="button"
>
{t("upload_file")}
</div>
</li>
<li>
<div
onClick={() => {
(document?.activeElement as HTMLElement)?.blur();
setNewCollectionModal(true);
}}
tabIndex={0}
role="button"
>
{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)} />
) : undefined}
{newCollectionModal ? (
<NewCollectionModal onClose={() => setNewCollectionModal(false)} />
) : undefined}
{uploadFileModal ? (
<UploadFileModal onClose={() => setUploadFileModal(false)} />
) : undefined}
</>
);
}

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

93
components/Modal.tsx Normal file
View File

@@ -0,0 +1,93 @@
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 && setTimeout(() => toggleModal(), 100)}
dismissible={dismissible}
>
<Drawer.Portal>
<Drawer.Overlay className="fixed inset-0 bg-black/40" />
<ClickAwayHandler
onClickOutside={() => dismissible && setDrawerIsOpen(false)}
>
<Drawer.Content className="flex flex-col rounded-t-2xl min-h-max mt-24 fixed bottom-0 left-0 right-0 z-30">
<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>
</ClickAwayHandler>
</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>
);
}
}

View File

@@ -1,128 +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";
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-sky-700 mb-2">
Name
<RequiredBadge />
</p>
<div className="flex flex-col gap-3">
<input
value={collection.name}
onChange={(e) =>
setCollection({ ...collection, name: e.target.value })
}
type="text"
placeholder="e.g. Example Collection"
className="w-full rounded-md p-3 border-sky-100 border-solid border outline-none focus:border-sky-700 duration-100"
/>
<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-sky-700 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-sky-700 hover:bg-slate-200 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-sky-700 mb-2">Description</p>
<textarea
className="w-full h-[11.4rem] resize-none border rounded-md duration-100 bg-white p-3 outline-none border-sky-100 focus:border-sky-700"
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>
);
}

View File

@@ -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";
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-gray-500">
<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-sky-900 select-none text-center">
To confirm, type &quot;
<span className="font-bold text-sky-700">{collection.name}</span>
&quot; in the box below:
</p>
<input
autoFocus
value={inputField}
onChange={(e) => setInputField(e.target.value)}
type="text"
placeholder={`Type "${collection.name}" Here.`}
className="w-72 sm:w-96 rounded-md p-3 mx-auto border-sky-100 border-solid border outline-none focus:border-sky-700 duration-100"
/>
</div>
</>
) : (
<p className="text-gray-500">
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 cursor-pointer"
: "cursor-not-allowed bg-red-300"
: "bg-red-500 hover:bg-red-400 cursor-pointer"
}`}
onClick={submit}
>
<FontAwesomeIcon
icon={permissions === true ? faTrashCan : faRightFromBracket}
className="h-5"
/>
{permissions === true ? "Delete" : "Leave"} Collection
</div>
</div>
);
}

View File

@@ -1,457 +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";
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-sky-700">Make Public</p>
<Checkbox
label="Make this a public collection."
state={collection.isPublic}
onClick={() =>
setCollection({ ...collection, isPublic: !collection.isPublic })
}
/>
<p className="text-gray-500 text-sm">
This will let <b>Anyone</b> to view this collection.
</p>
</>
)}
{collection.isPublic ? (
<div>
<p className="text-sm text-sky-700 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-3 border-sky-100 border-solid border outline-none hover:border-sky-700 duration-100 cursor-text"
>
{publicCollectionURL}
</div>
</div>
) : null}
{permissions !== true && collection.isPublic && <hr />}
{permissions === true && (
<>
<p className="text-sm text-sky-700">Member Management</p>
<div className="flex items-center gap-2">
<input
value={member.user.username || ""}
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
)
}
type="text"
placeholder="Username (without the '@')"
className="w-full rounded-md p-3 border-sky-100 border-solid border outline-none focus:border-sky-700 duration-100"
/>
<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-12 h-12 p-3 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 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 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 h-4 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-sky-700">
{e.user.name}
</p>
<p className="text-sky-900">@{e.user.username}</p>
</div>
</div>
<div className="flex sm:block items-center gap-5 min-w-[10rem]">
<div>
<p
className={`font-bold text-sm text-sky-700 ${
permissions === true ? "" : "mb-2"
}`}
>
Permissions
</p>
{permissions === true && (
<p className="text-xs text-gray-500 mb-2">
(Click to toggle.)
</p>
)}
</div>
{permissions !== true &&
!e.canCreate &&
!e.canUpdate &&
!e.canDelete ? (
<p className="text-sm text-gray-500">
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-sky-900 peer-checked:bg-sky-700 text-sm ${
permissions === true
? "hover:bg-slate-200 duration-75"
: ""
} peer-checked:text-white 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-sky-900 peer-checked:bg-sky-700 text-sm ${
permissions === true
? "hover:bg-slate-200 duration-75"
: ""
} peer-checked:text-white 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-sky-900 peer-checked:bg-sky-700 text-sm ${
permissions === true
? "hover:bg-slate-200 duration-75"
: ""
} peer-checked:text-white rounded p-1 select-none`}
>
Delete
</span>
</label>
</div>
)}
</div>
</div>
);
})}
</div>
</>
)}
<div
className="relative border px-2 rounded-md border-sky-100 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-sky-700">
{collectionOwner.name}
</p>
<FontAwesomeIcon
icon={faCrown}
className="w-3 h-3 text-yellow-500"
/>
</div>
<p className="text-sky-900">@{collectionOwner.username}</p>
</div>
</div>
<div className="flex flex-col justify-center min-w-[10rem]">
<p className={`font-bold text-sm text-sky-700`}>Permissions</p>
<p className="text-sky-700">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>
);
}

View File

@@ -1,120 +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-sky-700 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-sky-700">
{method === "UPDATE" && (
<>
{isOwner && (
<Tab
className={({ selected }) =>
selected
? "px-2 py-1 bg-sky-200 duration-100 rounded-md outline-none"
: "px-2 py-1 hover:bg-slate-200 rounded-md duration-100 outline-none"
}
>
Collection Info
</Tab>
)}
<Tab
className={({ selected }) =>
selected
? "px-2 py-1 bg-sky-200 duration-100 rounded-md outline-none"
: "px-2 py-1 hover:bg-slate-200 rounded-md duration-100 outline-none"
}
>
{isOwner ? "Share & Collaborate" : "View Team"}
</Tab>
<Tab
className={({ selected }) =>
selected
? "px-2 py-1 bg-sky-200 duration-100 rounded-md outline-none"
: "px-2 py-1 hover:bg-slate-200 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>
);
}

View File

@@ -1,208 +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";
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 { 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 (router.query.id) {
const currentCollection = collections.find(
(e) => e.id == Number(router.query.id)
);
if (currentCollection && currentCollection.ownerId)
setLink({
...link,
collection: {
id: currentCollection.id,
name: currentCollection.name,
ownerId: currentCollection.ownerId,
},
});
}
}, []);
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 my-2 text-center truncate w-full"
title={link.url}
>
<Link href={link.url} target="_blank" className=" font-bold">
{link.url}
</Link>
</p>
) : null}
{method === "CREATE" ? (
<div>
<p className="text-sm text-sky-700 mb-2 font-bold">
Address (URL)
<RequiredBadge />
</p>
<input
value={link.url}
onChange={(e) => setLink({ ...link, url: e.target.value })}
type="text"
placeholder="e.g. http://example.com/"
className="w-full rounded-md p-2 border-sky-100 border-solid border outline-none focus:border-sky-700 duration-100"
/>
</div>
) : null}
<hr />
<div className="grid sm:grid-cols-2 gap-3">
<div>
<p className="text-sm text-sky-700 mb-2">Collection</p>
<CollectionSelection
onChange={setCollection}
// defaultValue={{
// label: link.collection.name,
// value: link.collection.id,
// }}
defaultValue={
link.collection.name && link.collection.id
? {
value: link.collection.id,
label: link.collection.name,
}
: undefined
}
/>
</div>
<div>
<p className="text-sm text-sky-700 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-sky-700 mb-2">Name</p>
<input
value={link.name}
onChange={(e) => setLink({ ...link, name: e.target.value })}
type="text"
placeholder="e.g. Example Link"
className="w-full rounded-md p-2 border-sky-100 border-solid border outline-none focus:border-sky-700 duration-100"
/>
</div>
<div className="sm:col-span-2">
<p className="text-sm text-sky-700 mb-2">Description</p>
<textarea
value={link.description}
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 border-solid border outline-none focus:border-sky-700 duration-100"
/>
</div>
</div>
<SubmitButton
onClick={submit}
label={method === "CREATE" ? "Add" : "Save"}
icon={method === "CREATE" ? faPlus : faPenToSquare}
loading={submitLoader}
className={`mx-auto mt-2`}
/>
</div>
);
}

View File

@@ -1,287 +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,
} 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";
type Props = {
link: LinkIncludingShortenedCollectionAndTags;
};
export default function LinkDetails({ link }: Props) {
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) {
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]
)})`;
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]);
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">
{!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 rounded-md shadow border-[3px] border-white bg-white 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 flex-col min-h-[3rem] justify-end drop-shadow">
<p className="text-2xl text-sky-700 capitalize break-words hyphens-auto">
{link.name}
</p>
<Link
href={link.url}
target="_blank"
rel="noreferrer"
className="text-sm text-gray-500 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-sky-900 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-sky-700 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-gray-500 max-h-[20rem] my-3 rounded-md overflow-y-auto hyphens-auto">
{link.description}
</div>
</>
)}
<div className="flex justify-between items-center">
<div className="flex items-center gap-1 text-gray-500">
<FontAwesomeIcon icon={faBoxArchive} className="w-4 h-4" />
<p className=" text-gray-500">Archived Formats:</p>
</div>
<div
className="flex items-center gap-1 text-gray-500"
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 p-2 border border-sky-100 rounded-md">
<div className="flex gap-2 items-center">
<div className="text-white bg-sky-300 p-2 rounded-md">
<FontAwesomeIcon icon={faFileImage} className="w-6 h-6" />
</div>
<p className="text-gray-500">Screenshot</p>
</div>
<div className="flex text-sky-500 gap-1">
<Link
href={`/api/archives/${link.collectionId}/${link.id}.png`}
target="_blank"
rel="noreferrer"
className="cursor-pointer hover:bg-slate-200 duration-100 p-2 rounded-md"
>
<FontAwesomeIcon
icon={faArrowUpRightFromSquare}
className="w-5 h-5"
/>
</Link>
<div
onClick={() => handleDownload("png")}
className="cursor-pointer hover:bg-slate-200 duration-100 p-2 rounded-md"
>
<FontAwesomeIcon
icon={faCloudArrowDown}
className="w-5 h-5 cursor-pointer"
/>
</div>
</div>
</div>
<div className="flex justify-between items-center p-2 border border-sky-100 rounded-md">
<div className="flex gap-2 items-center">
<div className="text-white bg-sky-300 p-2 rounded-md">
<FontAwesomeIcon icon={faFilePdf} className="w-6 h-6" />
</div>
<p className="text-gray-500">PDF</p>
</div>
<div className="flex text-sky-500 gap-1">
<Link
href={`/api/archives/${link.collectionId}/${link.id}.pdf`}
target="_blank"
rel="noreferrer"
className="cursor-pointer hover:bg-slate-200 duration-100 p-2 rounded-md"
>
<FontAwesomeIcon
icon={faArrowUpRightFromSquare}
className="w-5 h-5"
/>
</Link>
<div
onClick={() => handleDownload("pdf")}
className="cursor-pointer hover:bg-slate-200 duration-100 p-2 rounded-md"
>
<FontAwesomeIcon
icon={faCloudArrowDown}
className="w-5 h-5 cursor-pointer"
/>
</div>
</div>
</div>
</div>
</div>
);
}

View File

@@ -1,91 +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-sky-700 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-sky-700 ${
isOwnerOrMod ? "" : "pb-8"
}`}
>
{method === "UPDATE" && isOwnerOrMod && (
<>
<Tab
className={({ selected }) =>
selected
? "px-2 py-1 bg-sky-200 duration-100 rounded-md outline-none"
: "px-2 py-1 hover:bg-slate-200 rounded-md duration-100 outline-none"
}
>
Link Details
</Tab>
<Tab
className={({ selected }) =>
selected
? "px-2 py-1 bg-sky-200 duration-100 rounded-md outline-none"
: "px-2 py-1 hover:bg-slate-200 rounded-md duration-100 outline-none"
}
>
Edit Link
</Tab>
</>
)}
</Tab.List>
<Tab.Panels>
{activeLink && method === "UPDATE" && (
<Tab.Panel>
<LinkDetails link={activeLink} />
</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>
);
}

View File

@@ -1,43 +0,0 @@
import { useState } from "react";
import SubmitButton from "@/components/SubmitButton";
import { toast } from "react-hot-toast";
import { useRouter } from "next/router";
import { faArrowUpRightFromSquare } from "@fortawesome/free-solid-svg-icons";
export default function PaymentPortal() {
const [submitLoader, setSubmitLoader] = useState(false);
const router = useRouter();
const submit = () => {
setSubmitLoader(true);
const load = toast.loading("Redirecting to billing portal...");
router.push(process.env.NEXT_PUBLIC_STRIPE_BILLING_PORTAL_URL as string);
};
return (
<div className="mx-auto sm:w-[35rem] w-80">
<div className="max-w-[25rem] w-full mx-auto flex flex-col gap-3 justify-between">
<p className="text-md text-gray-500">
To manage/cancel your subsciption, visit the billing portal.
</p>
<SubmitButton
onClick={submit}
loading={submitLoader}
label="Go to Billing Portal"
icon={faArrowUpRightFromSquare}
className="mx-auto mt-2"
/>
<p className="text-md text-gray-500">
If you still need help or encountered any issues, feel free to reach
out to us at:{" "}
<a className="font-semibold" href="mailto:support@linkwarden.app">
support@linkwarden.app
</a>
</p>
</div>
</div>
);
}

View File

@@ -1,113 +0,0 @@
import { Dispatch, SetStateAction, useEffect, useState } from "react";
import { AccountSettings } from "@/types/global";
import useAccountStore from "@/store/account";
import { signOut, useSession } from "next-auth/react";
import { faPenToSquare } from "@fortawesome/free-regular-svg-icons";
import SubmitButton from "@/components/SubmitButton";
import { toast } from "react-hot-toast";
type Props = {
togglePasswordFormModal: Function;
setUser: Dispatch<SetStateAction<AccountSettings>>;
user: AccountSettings;
};
export default function ChangePassword({
togglePasswordFormModal,
setUser,
user,
}: Props) {
const [newPassword, setNewPassword1] = useState("");
const [newPassword2, setNewPassword2] = useState("");
const [submitLoader, setSubmitLoader] = useState(false);
const { account, updateAccount } = useAccountStore();
const { update, data } = useSession();
useEffect(() => {
if (
!(newPassword == "" || newPassword2 == "") &&
newPassword === newPassword2
) {
setUser({ ...user, newPassword });
}
}, [newPassword, newPassword2]);
const submit = async () => {
if (newPassword == "" || newPassword2 == "") {
toast.error("Please fill all the fields.");
}
if (newPassword !== newPassword2)
return toast.error("Passwords do not match.");
else if (newPassword.length < 8)
return toast.error("Passwords must be at least 8 characters.");
setSubmitLoader(true);
const load = toast.loading("Applying...");
const response = await updateAccount({
...user,
});
toast.dismiss(load);
if (response.ok) {
toast.success("Settings Applied!");
if (user.email !== account.email) {
update({
id: data?.user.id,
});
signOut();
} else if (
user.username !== account.username ||
user.name !== account.name
)
update({
id: data?.user.id,
});
setUser({ ...user, newPassword: undefined });
togglePasswordFormModal();
} else toast.error(response.data as string);
setSubmitLoader(false);
};
return (
<div className="mx-auto sm:w-[35rem] w-80">
<div className="max-w-[25rem] w-full mx-auto flex flex-col gap-3 justify-between">
<p className="text-sm text-sky-700">New Password</p>
<input
value={newPassword}
onChange={(e) => setNewPassword1(e.target.value)}
type="password"
placeholder="••••••••••••••"
className="w-full rounded-md p-3 mx-auto border-sky-100 border-solid border outline-none focus:border-sky-700 duration-100"
/>
<p className="text-sm text-sky-700">Confirm New Password</p>
<input
value={newPassword2}
onChange={(e) => setNewPassword2(e.target.value)}
type="password"
placeholder="••••••••••••••"
className="w-full rounded-md p-3 mx-auto border-sky-100 border-solid border outline-none focus:border-sky-700 duration-100"
/>
<SubmitButton
onClick={submit}
loading={submitLoader}
label="Apply Settings"
icon={faPenToSquare}
className="mx-auto mt-2"
/>
</div>
</div>
);
}

View File

@@ -1,127 +0,0 @@
import { Dispatch, SetStateAction, useEffect, useState } from "react";
import Checkbox from "../../Checkbox";
import useAccountStore from "@/store/account";
import { AccountSettings } from "@/types/global";
import { signOut, useSession } from "next-auth/react";
import { faPenToSquare } from "@fortawesome/free-regular-svg-icons";
import SubmitButton from "../../SubmitButton";
import { toast } from "react-hot-toast";
type Props = {
toggleSettingsModal: Function;
setUser: Dispatch<SetStateAction<AccountSettings>>;
user: AccountSettings;
};
export default function PrivacySettings({
toggleSettingsModal,
setUser,
user,
}: Props) {
const { update, data } = useSession();
const { account, updateAccount } = useAccountStore();
const [submitLoader, setSubmitLoader] = useState(false);
const [whitelistedUsersTextbox, setWhiteListedUsersTextbox] = useState(
user.whitelistedUsers.join(", ")
);
useEffect(() => {
setUser({
...user,
whitelistedUsers: stringToArray(whitelistedUsersTextbox),
});
}, [whitelistedUsersTextbox]);
useEffect(() => {
setUser({ ...user, newPassword: undefined });
}, []);
const stringToArray = (str: string) => {
const stringWithoutSpaces = str.replace(/\s+/g, "");
const wordsArray = stringWithoutSpaces.split(",");
return wordsArray;
};
const submit = async () => {
setSubmitLoader(true);
const load = toast.loading("Applying...");
const response = await updateAccount({
...user,
});
toast.dismiss(load);
if (response.ok) {
toast.success("Settings Applied!");
if (user.email !== account.email) {
update({
id: data?.user.id,
});
signOut();
} else if (
user.username !== account.username ||
user.name !== account.name
)
update({
id: data?.user.id,
});
setUser({ ...user, newPassword: undefined });
toggleSettingsModal();
} else toast.error(response.data as string);
setSubmitLoader(false);
};
return (
<div className="flex flex-col gap-3 justify-between sm:w-[35rem] w-80">
<div>
<p className="text-sm text-sky-700 mb-2">Profile Visibility</p>
<Checkbox
label="Make profile private"
state={user.isPrivate}
className="text-sm sm:text-base"
onClick={() => setUser({ ...user, isPrivate: !user.isPrivate })}
/>
<p className="text-gray-500 text-sm">
This will limit who can find and add you to other Collections.
</p>
{user.isPrivate && (
<div>
<p className="text-sm text-sky-700 my-2">Whitelisted Users</p>
<p className="text-gray-500 text-sm mb-3">
Please provide the Username of the users you wish to grant
visibility to your profile. Separated by comma.
</p>
<textarea
className="w-full resize-none border rounded-md duration-100 bg-white p-2 outline-none border-sky-100 focus:border-sky-700"
placeholder="Your profile is hidden from everyone right now..."
value={whitelistedUsersTextbox}
onChange={(e) => {
setWhiteListedUsersTextbox(e.target.value);
}}
/>
</div>
)}
</div>
<SubmitButton
onClick={submit}
loading={submitLoader}
label="Apply Settings"
icon={faPenToSquare}
className="mx-auto mt-2"
/>
</div>
);
}

View File

@@ -1,205 +0,0 @@
import { Dispatch, SetStateAction, useEffect, useState } from "react";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faClose } from "@fortawesome/free-solid-svg-icons";
import useAccountStore from "@/store/account";
import { AccountSettings } from "@/types/global";
import { signOut, useSession } from "next-auth/react";
import { resizeImage } from "@/lib/client/resizeImage";
import { faPenToSquare } from "@fortawesome/free-regular-svg-icons";
import SubmitButton from "../../SubmitButton";
import ProfilePhoto from "../../ProfilePhoto";
import { toast } from "react-hot-toast";
type Props = {
toggleSettingsModal: Function;
setUser: Dispatch<SetStateAction<AccountSettings>>;
user: AccountSettings;
};
const emailEnabled = process.env.NEXT_PUBLIC_EMAIL_PROVIDER;
export default function ProfileSettings({
toggleSettingsModal,
setUser,
user,
}: Props) {
const { update, data } = useSession();
const { account, updateAccount } = useAccountStore();
const [profileStatus, setProfileStatus] = useState(true);
const [submitLoader, setSubmitLoader] = useState(false);
const handleProfileStatus = (e: boolean) => {
setProfileStatus(!e);
};
const handleImageUpload = async (e: any) => {
const file: File = e.target.files[0];
const fileExtension = file.name.split(".").pop()?.toLowerCase();
const allowedExtensions = ["png", "jpeg", "jpg"];
if (allowedExtensions.includes(fileExtension as string)) {
const resizedFile = await resizeImage(file);
if (
resizedFile.size < 1048576 // 1048576 Bytes == 1MB
) {
const reader = new FileReader();
reader.onload = () => {
setUser({ ...user, profilePic: reader.result as string });
};
reader.readAsDataURL(resizedFile);
} else {
toast.error("Please select a PNG or JPEG file thats less than 1MB.");
}
} else {
toast.error("Invalid file format.");
}
};
useEffect(() => {
setUser({ ...user, newPassword: undefined });
}, []);
const submit = async () => {
setSubmitLoader(true);
const load = toast.loading("Applying...");
const response = await updateAccount({
...user,
});
toast.dismiss(load);
if (response.ok) {
toast.success("Settings Applied!");
if (user.email !== account.email) {
update({
id: data?.user.id,
});
signOut();
} else if (
user.username !== account.username ||
user.name !== account.name
)
update({
id: data?.user.id,
});
setUser({ ...user, newPassword: undefined });
toggleSettingsModal();
} else toast.error(response.data as string);
setSubmitLoader(false);
};
return (
<div className="flex flex-col gap-3 justify-between sm:w-[35rem] w-80">
<div className="grid sm:grid-cols-2 gap-3 auto-rows-auto">
<div className="sm:row-span-2 sm:justify-self-center mx-auto mb-3">
<p className="text-sm text-sky-700 mb-2 text-center">Profile Photo</p>
<div className="w-28 h-28 flex items-center justify-center rounded-full relative">
<ProfilePhoto
src={user.profilePic}
className="h-auto w-28"
status={handleProfileStatus}
/>
{profileStatus && (
<div
onClick={() =>
setUser({
...user,
profilePic: "",
})
}
className="absolute top-1 left-1 w-5 h-5 flex items-center justify-center border p-1 bg-white border-slate-200 rounded-full text-gray-500 hover:text-red-500 duration-100 cursor-pointer"
>
<FontAwesomeIcon icon={faClose} className="w-3 h-3" />
</div>
)}
<div className="absolute -bottom-3 left-0 right-0 mx-auto w-fit text-center">
<label
htmlFor="upload-photo"
title="PNG or JPG (Max: 3MB)"
className="border border-slate-200 rounded-md bg-white px-2 text-center select-none cursor-pointer text-sky-900 duration-100 hover:border-sky-700"
>
Browse...
<input
type="file"
name="photo"
id="upload-photo"
accept=".png, .jpeg, .jpg"
className="hidden"
onChange={handleImageUpload}
/>
</label>
</div>
</div>
</div>
<div className="flex flex-col gap-3">
<div>
<p className="text-sm text-sky-700 mb-2">Display Name</p>
<input
type="text"
value={user.name}
onChange={(e) => setUser({ ...user, name: e.target.value })}
className="w-full rounded-md p-2 border-sky-100 border-solid border outline-none focus:border-sky-700 duration-100"
/>
</div>
<div>
<p className="text-sm text-sky-700 mb-2">Username</p>
<input
type="text"
value={user.username || ""}
onChange={(e) => setUser({ ...user, username: e.target.value })}
className="w-full rounded-md p-2 border-sky-100 border-solid border outline-none focus:border-sky-700 duration-100"
/>
</div>
{emailEnabled ? (
<div>
<p className="text-sm text-sky-700 mb-2">Email</p>
<input
type="text"
value={user.email || ""}
onChange={(e) => setUser({ ...user, email: e.target.value })}
className="w-full rounded-md p-2 border-sky-100 border-solid border outline-none focus:border-sky-700 duration-100"
/>
</div>
) : undefined}
{user.email !== account.email ? (
<p className="text-gray-500">
You will need to log back in after you apply this Email.
</p>
) : undefined}
</div>
</div>
{/* <hr /> TODO: Export functionality
<p className="text-sky-700">Data Settings</p>
<div className="w-fit">
<div className="border border-sky-100 rounded-md bg-white px-2 py-1 text-center select-none cursor-pointer text-sky-900 duration-100 hover:border-sky-700">
Export Data
</div>
</div> */}
<SubmitButton
onClick={submit}
loading={submitLoader}
label="Apply Settings"
icon={faPenToSquare}
className="mx-auto mt-2"
/>
</div>
);
}

View File

@@ -1,107 +0,0 @@
import { Tab } from "@headlessui/react";
import { AccountSettings } from "@/types/global";
import { useState } from "react";
import ChangePassword from "./ChangePassword";
import ProfileSettings from "./ProfileSettings";
import PrivacySettings from "./PrivacySettings";
import BillingPortal from "./BillingPortal";
type Props = {
toggleSettingsModal: Function;
activeUser: AccountSettings;
className?: string;
defaultIndex?: number;
};
const STRIPE_BILLING_PORTAL_URL =
process.env.NEXT_PUBLIC_STRIPE_BILLING_PORTAL_URL;
export default function UserModal({
className,
defaultIndex,
toggleSettingsModal,
activeUser,
}: Props) {
const [user, setUser] = useState<AccountSettings>(activeUser);
return (
<div className={className}>
<Tab.Group defaultIndex={defaultIndex}>
<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-sky-700">
<Tab
className={({ selected }) =>
selected
? "px-2 py-1 bg-sky-200 duration-100 rounded-md outline-none"
: "px-2 py-1 hover:bg-slate-200 rounded-md duration-100 outline-none"
}
>
Profile Settings
</Tab>
<Tab
className={({ selected }) =>
selected
? "px-2 py-1 bg-sky-200 duration-100 rounded-md outline-none"
: "px-2 py-1 hover:bg-slate-200 rounded-md duration-100 outline-none"
}
>
Privacy Settings
</Tab>
<Tab
className={({ selected }) =>
selected
? "px-2 py-1 bg-sky-200 duration-100 rounded-md outline-none"
: "px-2 py-1 hover:bg-slate-200 rounded-md duration-100 outline-none"
}
>
Password
</Tab>
{STRIPE_BILLING_PORTAL_URL ? (
<Tab
className={({ selected }) =>
selected
? "px-2 py-1 bg-sky-200 duration-100 rounded-md outline-none"
: "px-2 py-1 hover:bg-slate-200 rounded-md duration-100 outline-none"
}
>
Billing Portal
</Tab>
) : undefined}
</Tab.List>
<Tab.Panels>
<Tab.Panel>
<ProfileSettings
toggleSettingsModal={toggleSettingsModal}
setUser={setUser}
user={user}
/>
</Tab.Panel>
<Tab.Panel>
<PrivacySettings
toggleSettingsModal={toggleSettingsModal}
setUser={setUser}
user={user}
/>
</Tab.Panel>
<Tab.Panel>
<ChangePassword
togglePasswordFormModal={toggleSettingsModal}
setUser={setUser}
user={user}
/>
</Tab.Panel>
{STRIPE_BILLING_PORTAL_URL ? (
<Tab.Panel>
<BillingPortal />
</Tab.Panel>
) : undefined}
</Tab.Panels>
</Tab.Group>
</div>
);
}

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 rounded-2xl border-solid border shadow-lg p-5 bg-white">
<div
onClick={toggleModal as MouseEventHandler<HTMLDivElement>}
className="absolute top-5 left-5 inline-flex rounded-md cursor-pointer hover:bg-slate-200 duration-100 z-20 p-2"
>
<FontAwesomeIcon
icon={faChevronLeft}
className="w-4 h-4 text-gray-500"
/>
</div>
{children}
</div>
</ClickAwayHandler>
</div>
);
}

View File

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

View File

@@ -0,0 +1,113 @@
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) => {
toast.dismiss(load);
if (error) {
toast.error(error.message);
} else {
setSelectedLinks([]);
onClose();
toast.success(t("updated"));
}
},
}
);
setSubmitLoader(false);
}
};
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>
);
}

View File

@@ -0,0 +1,108 @@
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) => {
toast.dismiss(load);
if (error) {
toast.error(error.message);
} else {
onClose();
toast.success(t("deleted"));
router.push("/collections");
}
},
});
setSubmitLoader(false);
}
};
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>
);
}

View File

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

View File

@@ -0,0 +1,55 @@
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";
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();
},
});
setSubmitLoader(false);
}
};
return (
<Modal toggleModal={onClose}>
<p className="text-xl font-thin text-red-500">{t("delete_user")}</p>
<div className="divider mb-3 mt-1"></div>
<div className="flex flex-col gap-3">
<p>{t("confirm_user_deletion")}</p>
<div role="alert" className="alert alert-warning">
<i className="bi-exclamation-triangle text-xl" />
<span>
<b>{t("warning")}:</b> {t("irreversible_action_warning")}
</span>
</div>
<Button className="ml-auto" intent="destructive" onClick={submit}>
<i className="bi-trash text-xl" />
{t("delete_confirmation")}
</Button>
</div>
</Modal>
);
}

View File

@@ -0,0 +1,121 @@
import React, { useState } from "react";
import TextInput from "@/components/TextInput";
import { HexColorPicker } from "react-colorful";
import { CollectionIncludingMembersAndLinkCount } from "@/types/global";
import Modal from "../Modal";
import { useTranslation } from "next-i18next";
import { useUpdateCollection } from "@/hooks/store/collections";
import toast from "react-hot-toast";
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) => {
toast.dismiss(load);
if (error) {
toast.error(error.message);
} else {
onClose();
toast.success(t("updated"));
}
},
});
setSubmitLoader(false);
}
};
return (
<Modal toggleModal={onClose}>
<p className="text-xl font-thin">{t("edit_collection_info")}</p>
<div className="divider mb-3 mt-1"></div>
<div className="flex flex-col gap-3">
<div className="flex flex-col sm:flex-row gap-3">
<div className="w-full">
<p className="mb-2">{t("name")}</p>
<div className="flex flex-col gap-3">
<TextInput
className="bg-base-200"
value={collection.name}
placeholder={t("collection_name_placeholder")}
onChange={(e) =>
setCollection({ ...collection, name: e.target.value })
}
/>
<div>
<p className="w-full mb-2">{t("color")}</p>
<div className="color-picker flex justify-between items-center">
<HexColorPicker
color={collection.color}
onChange={(color) =>
setCollection({ ...collection, color })
}
/>
<div className="flex flex-col gap-2 items-center w-32">
<i
className="bi-folder-fill text-5xl"
style={{ color: collection.color }}
></i>
<div
className="btn btn-ghost btn-xs"
onClick={() =>
setCollection({ ...collection, color: "#0ea5e9" })
}
>
{t("reset")}
</div>
</div>
</div>
</div>
</div>
</div>
<div className="w-full">
<p className="mb-2">{t("description")}</p>
<textarea
className="w-full h-[13rem] resize-none border rounded-md duration-100 bg-base-200 p-2 outline-none border-neutral-content focus:border-primary"
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>
);
}

View File

@@ -0,0 +1,466 @@
import React, { useEffect, useState } from "react";
import TextInput from "@/components/TextInput";
import toast from "react-hot-toast";
import { 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";
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) => {
toast.dismiss(load);
if (error) {
toast.error(error.message);
} else {
onClose();
toast.success(t("updated"));
}
},
});
setSubmitLoader(false);
}
};
const { data: user = {} } = useUser();
const permissions = usePermissions(collection.id as number);
const currentURL = new URL(document.URL);
const publicCollectionURL = `${currentURL.origin}/public/collections/${collection.id}`;
const [memberUsername, setMemberUsername] = useState("");
const [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 () => {
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],
});
setMemberUsername("");
};
return (
<Modal toggleModal={onClose}>
<p className="text-xl font-thin">
{permissions === true ? t("share_and_collaborate") : t("team")}
</p>
<div className="divider mb-3 mt-1"></div>
<div className="flex flex-col gap-3">
{permissions === true && (
<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 className={permissions === true ? "pl-5" : ""}>
<p className="mb-2">{t("sharable_link_guide")}</p>
<div
onClick={() => {
try {
navigator.clipboard
.writeText(publicCollectionURL)
.then(() => toast.success(t("copied")));
} catch (err) {
console.log(err);
}
}}
className="w-full hide-scrollbar overflow-x-auto whitespace-nowrap rounded-md p-2 bg-base-200 border-neutral-content border-solid border outline-none hover:border-primary dark:hover:border-primary duration-100 cursor-text"
>
{publicCollectionURL}
</div>
</div>
) : null}
{permissions === true && <div className="divider my-3"></div>}
{permissions === true && (
<>
<p>{t("members")}</p>
<div className="flex items-center gap-2">
<TextInput
value={memberUsername || ""}
className="bg-base-200"
placeholder={t("members_username_placeholder")}
onChange={(e) => setMemberUsername(e.target.value)}
onKeyDown={(e) =>
e.key === "Enter" &&
addMemberToCollection(
user.username as string,
memberUsername || "",
collection,
setMemberState,
t
)
}
/>
<div
onClick={() =>
addMemberToCollection(
user.username as string,
memberUsername || "",
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 ? (
<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 w-64 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">
{t("viewer")}
</p>
<p>{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">
{t("contributor")}
</p>
<p>{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">
{t("admin")}
</p>
<p>{t("admin_desc")}</p>
</div>
</label>
</li>
</ul>
</div>
) : (
<p className="text-sm text-neutral">
{roleLabel}
</p>
)}
{permissions === true && (
<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 && (
<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>
);
}

View File

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

View File

@@ -0,0 +1,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>
);
}

View File

@@ -0,0 +1,137 @@
import React, { useEffect, useState } from "react";
import TextInput from "@/components/TextInput";
import { HexColorPicker } from "react-colorful";
import { Collection } from "@prisma/client";
import Modal from "../Modal";
import { CollectionIncludingMembersAndLinkCount } from "@/types/global";
import { useTranslation } from "next-i18next";
import { useCreateCollection } from "@/hooks/store/collections";
import toast from "react-hot-toast";
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) => {
toast.dismiss(load);
if (error) {
toast.error(error.message);
} else {
onClose();
toast.success(t("created"));
}
},
});
setSubmitLoader(false);
};
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 sm:flex-row gap-3">
<div className="w-full">
<p className="mb-2">{t("name")}</p>
<div className="flex flex-col gap-2">
<TextInput
className="bg-base-200"
value={collection.name}
placeholder={t("collection_name_placeholder")}
onChange={(e) =>
setCollection({ ...collection, name: e.target.value })
}
/>
<div>
<p className="w-full mb-2">{t("color")}</p>
<div className="color-picker flex justify-between items-center">
<HexColorPicker
color={collection.color}
onChange={(color) =>
setCollection({ ...collection, color })
}
/>
<div className="flex flex-col gap-2 items-center w-32">
<i
className={"bi-folder-fill text-5xl"}
style={{ color: collection.color }}
></i>
<div
className="btn btn-ghost btn-xs"
onClick={() =>
setCollection({ ...collection, color: "#0ea5e9" })
}
>
{t("reset")}
</div>
</div>
</div>
</div>
</div>
</div>
<div className="w-full">
<p className="mb-2">{t("description")}</p>
<textarea
className="w-full h-[13rem] resize-none border rounded-md duration-100 bg-base-200 p-2 outline-none border-neutral-content focus:border-primary"
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>
);
}

View File

@@ -0,0 +1,193 @@
import React, { useEffect, useState } from "react";
import CollectionSelection from "@/components/InputSelect/CollectionSelection";
import TagSelection from "@/components/InputSelect/TagSelection";
import TextInput from "@/components/TextInput";
import unescapeString from "@/lib/client/unescapeString";
import { LinkIncludingShortenedCollectionAndTags } from "@/types/global";
import { useSession } from "next-auth/react";
import { useRouter } from "next/router";
import Modal from "../Modal";
import { useTranslation } from "next-i18next";
import { useCollections } from "@/hooks/store/collections";
import { useAddLink } from "@/hooks/store/links";
import toast from "react-hot-toast";
type Props = {
onClose: Function;
};
export default function NewLinkModal({ onClose }: Props) {
const { t } = useTranslation();
const { data } = useSession();
const initial = {
name: "",
url: "",
description: "",
type: "url",
tags: [],
preview: "",
image: "",
pdf: "",
readable: "",
monolith: "",
textContent: "",
collection: {
name: "",
ownerId: data?.user.id as number,
},
} as LinkIncludingShortenedCollectionAndTags;
const [link, setLink] =
useState<LinkIncludingShortenedCollectionAndTags>(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 = null;
setLink({
...link,
collection: { id: e?.value, name: e?.label, ownerId: e?.ownerId },
});
};
const setTags = (e: any) => {
const tagNames = e.map((e: any) => ({ name: e.label }));
setLink({ ...link, tags: tagNames });
};
useEffect(() => {
if (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,
ownerId: currentCollection.ownerId,
},
});
} else
setLink({
...initial,
collection: { name: "Unorganized", ownerId: data?.user.id as number },
});
}, []);
const submit = async () => {
if (!submitLoader) {
setSubmitLoader(true);
const load = toast.loading(t("creating_link"));
await addLink.mutateAsync(link, {
onSettled: (data, error) => {
toast.dismiss(load);
if (error) {
toast.error(error.message);
} else {
onClose();
toast.success(t("link_created"));
}
},
});
setSubmitLoader(false);
}
};
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={{
label: link.collection.name,
value: link.collection.id,
}}
/>
) : null}
</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) as string}
onChange={(e) =>
setLink({ ...link, description: e.target.value })
}
placeholder={t("link_description_placeholder")}
className="resize-none w-full rounded-md p-2 border-neutral-content bg-base-200 focus:border-primary border-solid border outline-none duration-100"
/>
</div>
</div>
</div>
) : undefined}
</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>
);
}

View File

@@ -0,0 +1,240 @@
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";
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) => {
toast.dismiss(load);
if (error) {
toast.error(error.message);
} else {
setNewToken(data.secretKey);
}
},
});
setSubmitLoader(false);
}
};
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>
<TextInput
spellCheck={false}
value={newToken}
onChange={() => {}}
className="w-full"
/>
<button
onClick={() => {
navigator.clipboard.writeText(newToken);
toast.success(t("copied_to_clipboard"));
}}
className="btn btn-primary w-fit mx-auto"
>
{t("copy_to_clipboard")}
</button>
</div>
) : (
<>
<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 w-full sm:w-52 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">{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">{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">{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">{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">{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>
);
}

View File

@@ -0,0 +1,141 @@
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) {
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();
},
});
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>
) : undefined}
<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>
);
}

View File

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

View File

@@ -0,0 +1,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>
);
}

View File

@@ -0,0 +1,231 @@
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";
type Props = {
onClose: Function;
};
export default function UploadFileModal({ onClose }: Props) {
const { t } = useTranslation();
const { data } = useSession();
const initial = {
name: "",
url: "",
description: "",
type: "url",
tags: [],
preview: "",
image: "",
pdf: "",
readable: "",
monolith: "",
textContent: "",
collection: {
name: "",
ownerId: data?.user.id as number,
},
} as LinkIncludingShortenedCollectionAndTags;
const [link, setLink] =
useState<LinkIncludingShortenedCollectionAndTags>(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 = null;
setLink({
...link,
collection: { id: e?.value, name: e?.label, ownerId: e?.ownerId },
});
};
const setTags = (e: any) => {
const tagNames = e.map((e: any) => {
return { name: e.label };
});
setLink({ ...link, tags: tagNames });
};
useEffect(() => {
setOptionsExpanded(false);
if (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,
ownerId: currentCollection.ownerId,
},
});
} else
setLink({
...initial,
collection: { name: "Unorganized", ownerId: data?.user.id as number },
});
}, [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) => {
toast.dismiss(load);
if (error) {
toast.error(error.message);
} else {
onClose();
toast.success(t("created_success"));
}
},
}
);
setSubmitLoader(false);
}
};
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,.html"
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={{
label: link.collection.name,
value: link.collection.id,
}}
/>
) : null}
</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) => ({
label: e.name,
value: e.id,
}))}
/>
</div>
<div className="sm:col-span-2">
<p className="mb-2">{t("description")}</p>
<textarea
value={unescapeString(link.description) as string}
onChange={(e) =>
setLink({ ...link, description: e.target.value })
}
placeholder={t("description_placeholder")}
className="resize-none w-full rounded-md p-2 border-neutral-content bg-base-200 focus:border-sky-300 dark:focus:border-sky-600 border-solid border outline-none duration-100"
/>
</div>
</div>
</div>
) : undefined}
<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>
);
}

View File

@@ -1,63 +0,0 @@
import useModalStore from "@/store/modals";
import Modal from "./Modal";
import LinkModal from "./Modal/Link";
import {
AccountSettings,
CollectionIncludingMembersAndLinkCount,
LinkIncludingShortenedCollectionAndTags,
} from "@/types/global";
import CollectionModal from "./Modal/Collection";
import UserModal from "./Modal/User";
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 if (modal && modal.modal === "ACCOUNT")
return (
<Modal toggleModal={toggleModal}>
<UserModal
toggleSettingsModal={toggleModal}
defaultIndex={modal.defaultIndex}
activeUser={modal.active as AccountSettings}
/>
</Modal>
);
else return <></>;
}

View File

@@ -1,127 +1,134 @@
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 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 [sidebar, setSidebar] = useState(false);
const router = useRouter();
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";
};
const [newLinkModal, setNewLinkModal] = useState(false);
const [newCollectionModal, setNewCollectionModal] = useState(false);
const [uploadFileModal, setUploadFileModal] = useState(false);
return (
<div className="flex justify-between gap-2 items-center px-5 py-2 border-solid border-b-sky-100 border-b h-16">
<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={toggleSidebar}
className="inline-flex lg:hidden gap-1 items-center select-none cursor-pointer p-[0.687rem] text-sky-700 rounded-md duration-100 hover:bg-slate-200"
onClick={() => {
setSidebar(true);
document.body.style.overflow = "hidden";
}}
className="text-neutral btn btn-square btn-sm btn-ghost lg:hidden sm:inline-flex"
>
<FontAwesomeIcon icon={faBars} className="w-5 h-5" />
<i className="bi-list text-2xl leading-none"></i>
</div>
<Search />
<SearchBar />
<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 text-sky-700 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>
<ToggleDarkMode className="hidden sm:inline-grid" />
<div className="relative">
<div
className="flex gap-1 group sm:hover:bg-slate-200 sm:hover:p-1 sm:hover:pr-2 duration-100 h-10 rounded-full items-center w-fit bg-white cursor-pointer"
onClick={() => setProfileDropdown(!profileDropdown)}
id="profile-dropdown"
>
<ProfilePhoto
src={account.profilePic}
className="sm:group-hover:h-8 sm:group-hover:w-8 duration-100 border-[3px]"
/>
<p
id="profile-dropdown"
className="font-bold text-sky-700 leading-3 hidden sm:block select-none truncate max-w-[8rem] py-1"
<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"
>
{account.name}
</p>
</div>
{profileDropdown ? (
<Dropdown
items={[
{
name: "Settings",
onClick: () => {
setModal({
modal: "ACCOUNT",
state: true,
active: account,
});
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}
{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 className="" />
</div>
</ClickAwayHandler>
<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>
) : null}
</div>
<ul className="dropdown-content z-[1] menu shadow bg-base-200 border border-neutral-content rounded-box w-40 mt-1">
<li>
<div
onClick={() => {
(document?.activeElement as HTMLElement)?.blur();
setNewLinkModal(true);
}}
tabIndex={0}
role="button"
>
{t("new_link")}
</div>
</li>
<li>
<div
onClick={() => {
(document?.activeElement as HTMLElement)?.blur();
setUploadFileModal(true);
}}
tabIndex={0}
role="button"
>
{t("upload_file")}
</div>
</li>
<li>
<div
onClick={() => {
(document?.activeElement as HTMLElement)?.blur();
setNewCollectionModal(true);
}}
tabIndex={0}
role="button"
>
{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>
) : null}
{newLinkModal ? (
<NewLinkModal onClose={() => setNewLinkModal(false)} />
) : undefined}
{newCollectionModal ? (
<NewCollectionModal onClose={() => setNewCollectionModal(false)} />
) : undefined}
{uploadFileModal ? (
<UploadFileModal onClose={() => setUploadFileModal(false)} />
) : undefined}
</div>
);
}

View File

@@ -1,38 +1,47 @@
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";
export default function NoLinksFound() {
const { setModal } = useModalStore();
type Props = {
text?: string;
};
export default function NoLinksFound({ text }: Props) {
const { t } = useTranslation();
const [newLinkModal, setNewLinkModal] = useState(false);
return (
<div className="border border-solid border-sky-100 w-full p-10 rounded-2xl">
<p className="text-center text-3xl text-sky-700">
You haven&apos;t created any Links Here
<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>
<br />
<div className="text-center text-sky-900 text-sm flex items-baseline justify-center gap-1 w-full">
<p>Start by creating a</p>{" "}
<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-[7.2rem] items-center font-semibold select-none cursor-pointer p-2 px-3 rounded-full 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-9 absolute duration-100"
/>
<span className="block group-hover:opacity-0 text-right w-full duration-100">
New Link
<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">
{t("create_new_link")}
</span>
</div>
</div>
{newLinkModal ? (
<NewLinkModal onClose={() => setNewLinkModal(false)} />
) : undefined}
</div>
);
}

23
components/PageHeader.tsx Normal file
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>
);
}

View File

@@ -0,0 +1,85 @@
import {
ArchivedFormat,
LinkIncludingShortenedCollectionAndTags,
} from "@/types/global";
import Link from "next/link";
import { useRouter } from "next/router";
import { useGetLink } from "@/hooks/store/links";
type Props = {
name: string;
icon: string;
format: ArchivedFormat;
link: LinkIncludingShortenedCollectionAndTags;
downloadable?: boolean;
};
export default function PreservedFormatRow({
name,
icon,
format,
link,
downloadable,
}: Props) {
const getLink = useGetLink();
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 pr-1 border border-neutral-content rounded-md">
<div className="flex gap-2 items-center">
<div className="bg-primary text-primary-content p-2 rounded-l-md">
<i className={`${icon} text-2xl`} />
</div>
<p>{name}</p>
</div>
<div className="flex gap-1">
{downloadable || false ? (
<div
onClick={() => handleDownload()}
className="btn btn-sm btn-square"
>
<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"
>
<i className="bi-box-arrow-up-right text-xl text-neutral" />
</Link>
</div>
</div>
);
}

View File

@@ -0,0 +1,90 @@
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 ${
isAdmin ? "w-48" : "w-40"
} mt-1`}
>
<li>
<Link
href="/settings/account"
onClick={() => (document?.activeElement as HTMLElement)?.blur()}
tabIndex={0}
role="button"
>
{t("settings")}
</Link>
</li>
<li className="block sm:hidden">
<div
onClick={() => {
(document?.activeElement as HTMLElement)?.blur();
handleToggle();
}}
tabIndex={0}
role="button"
>
{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"
>
{t("server_administration")}
</Link>
</li>
) : null}
<li>
<div
onClick={() => {
(document?.activeElement as HTMLElement)?.blur();
signOut();
}}
tabIndex={0}
role="button"
>
{t("logout")}
</div>
</li>
</ul>
</div>
);
}

View File

@@ -1,49 +1,64 @@
import React, { useEffect, useState } from "react";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faUser } from "@fortawesome/free-solid-svg-icons";
import Image from "next/image";
import avatarExists from "@/lib/client/avatarExists";
type Props = {
src: string;
src?: string;
className?: string;
emptyImage?: boolean;
status?: Function;
priority?: boolean;
name?: string;
large?: boolean;
};
export default function ProfilePhoto({
src,
className,
emptyImage,
status,
priority,
name,
large,
}: Props) {
const [error, setError] = useState<boolean>(emptyImage || true);
const checkAvatarExistence = async () => {
const canPass = await avatarExists(src);
setError(!canPass);
};
const [image, setImage] = useState("");
useEffect(() => {
if (src) checkAvatarExistence();
if (src && !src?.includes("base64") && !src.startsWith("http"))
setImage(`/api/v1/${src.replace("uploads/", "").replace(".jpg", "")}`);
else if (!src) setImage("");
else {
setImage(src);
}
}, [src]);
status && status(error || !src);
}, [src, error]);
return error || !src ? (
return !image ? (
<div
className={`bg-sky-500 text-white h-10 w-10 aspect-square shadow rounded-full border border-slate-200 flex items-center justify-center ${className}`}
className={`avatar drop-shadow-md placeholder ${className || ""} ${
large ? "w-28 h-28" : "w-8 h-8"
}`}
>
<FontAwesomeIcon icon={faUser} className="w-1/2 h-1/2 aspect-square" />
<div className="bg-base-100 text-neutral rounded-full w-full h-full ring-2 ring-neutral-content select-none">
{name ? (
<span className="text-2xl capitalize">{name.slice(0, 1)}</span>
) : (
<i className={`bi-person ${large ? "text-5xl" : "text-xl"}`}></i>
)}
</div>
</div>
) : (
<Image
alt=""
src={src}
height={112}
width={112}
className={`h-10 w-10 shadow rounded-full aspect-square border border-slate-200 ${className}`}
/>
<div
className={`avatar skeleton rounded-full drop-shadow-md ${
className || ""
} ${large ? "w-28 h-28" : "w-8 h-8"}`}
>
<div className="rounded-full w-full h-full ring-2 ring-neutral-content">
<Image
alt=""
src={image}
height={112}
width={112}
priority={priority}
draggable={false}
onError={() => setImage("")}
className="aspect-square rounded-full"
/>
</div>
</div>
);
}

View File

@@ -1,97 +0,0 @@
import { faChevronRight } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import Image from "next/image";
import { Link as LinkType, Tag } from "@prisma/client";
import isValidUrl from "@/lib/client/isValidUrl";
interface LinksIncludingTags extends LinkType {
tags: Tag[];
}
type Props = {
link: LinksIncludingTags;
count: number;
};
export default function LinkCard({ link, count }: Props) {
const url = isValidUrl(link.url) ? new URL(link.url) : undefined;
const formattedDate = new Date(
link.createdAt as unknown as string
).toLocaleString("en-US", {
year: "numeric",
month: "short",
day: "numeric",
});
return (
<a href={link.url} target="_blank" rel="noreferrer" className="rounded-3xl">
<div className="bg-gradient-to-tr from-slate-200 from-10% to-gray-50 via-20% shadow-md sm:hover:shadow-none duration-100 rounded-3xl cursor-pointer p-5 flex items-start relative gap-5 sm:gap-10 group/item">
{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=""
className="select-none mt-3 z-10 rounded-md shadow border-[3px] border-white bg-white"
draggable="false"
onError={(e) => {
const target = e.target as HTMLElement;
target.style.display = "none";
}}
/>
<Image
src={`https://t2.gstatic.com/faviconV2?client=SOCIAL&type=FAVICON&fallback_opts=TYPE,SIZE,URL&url=${url.origin}&size=32`}
width={80}
height={80}
alt=""
className="blur-sm absolute left-2 opacity-40 select-none hidden sm:block"
draggable="false"
onError={(e) => {
const target = e.target as HTMLElement;
target.style.display = "none";
}}
/>
</>
)}
<div className="flex justify-between items-center gap-5 w-full h-full z-0">
<div className="flex flex-col justify-between">
<div className="flex items-baseline gap-1">
<p className="text-sm text-sky-500 font-bold">{count + 1}.</p>
<p className="text-lg text-sky-700 font-bold">{link.name}</p>
</div>
<p className="text-gray-500 text-sm font-medium">
{link.description}
</p>
<div className="flex gap-3 items-center flex-wrap my-3">
<div className="flex gap-1 items-center flex-wrap mt-1">
{link.tags.map((e, i) => (
<p
key={i}
className="px-2 py-1 bg-sky-200 text-sky-700 text-xs rounded-3xl cursor-pointer truncate max-w-[10rem]"
>
{e.name}
</p>
))}
</div>
</div>
<div className="flex gap-2 items-center flex-wrap mt-2">
<p className="text-gray-500">{formattedDate}</p>
<div className="text-sky-500 font-bold flex items-center gap-1">
<p>{url ? url.host : link.url}</p>
</div>
</div>
</div>
<div className="hidden sm:group-hover/item:block duration-100 text-slate-500">
<FontAwesomeIcon
icon={faChevronRight}
className="w-7 h-7 slide-right-with-fade"
/>
</div>
</div>
</div>
</a>
);
}

View File

@@ -1,5 +1,3 @@
import { faCircle, faCircleCheck } from "@fortawesome/free-regular-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { ChangeEventHandler } from "react";
type Props = {
@@ -18,15 +16,7 @@ export default function RadioButton({ label, state, onClick }: Props) {
checked={state}
onChange={onClick}
/>
<FontAwesomeIcon
icon={faCircleCheck}
className="w-5 h-5 text-sky-700 peer-checked:block hidden"
/>
<FontAwesomeIcon
icon={faCircle}
className="w-5 h-5 text-sky-700 peer-checked:hidden block"
/>
<span className="text-sky-900 rounded select-none">{label}</span>
<span className="rounded select-none">{label}</span>
</label>
);
}

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