Compare commits

...

364 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
290 changed files with 15306 additions and 5354 deletions

View File

@@ -1,22 +1,22 @@
// For format details, see https://aka.ms/devcontainer.json. For config options, see the
// README at: https://github.com/devcontainers/templates/tree/main/src/typescript-node
{
"name": "Node.js & TypeScript",
// Or use a Dockerfile or Docker Compose file. More info: https://containers.dev/guide/dockerfile
"image": "mcr.microsoft.com/devcontainers/typescript-node:1-20-bullseye",
"name": "Node.js & TypeScript",
// Or use a Dockerfile or Docker Compose file. More info: https://containers.dev/guide/dockerfile
"image": "mcr.microsoft.com/devcontainers/typescript-node:1-20-bullseye",
// Features to add to the dev container. More info: https://containers.dev/features.
// "features": {},
// Features to add to the dev container. More info: https://containers.dev/features.
// "features": {},
// Use 'forwardPorts' to make a list of ports inside the container available locally.
// "forwardPorts": [],
// Use 'forwardPorts' to make a list of ports inside the container available locally.
// "forwardPorts": [],
// Use 'postCreateCommand' to run commands after the container is created.
// "postCreateCommand": "yarn install",
// Use 'postCreateCommand' to run commands after the container is created.
// "postCreateCommand": "yarn install",
// Configure tool-specific properties.
// "customizations": {},
// Configure tool-specific properties.
// "customizations": {},
// Uncomment to connect as root instead. More info: https://aka.ms/dev-containers-non-root.
"remoteUser": "root"
// Uncomment to connect as root instead. More info: https://aka.ms/dev-containers-non-root.
"remoteUser": "root"
}

View File

@@ -1,5 +1,5 @@
NEXTAUTH_SECRET=very_sensitive_secret
NEXTAUTH_URL=http://localhost:3000
NEXTAUTH_URL=http://localhost:3000/api/v1/auth
# Manual installation database settings
DATABASE_URL=postgresql://user:password@localhost:5432/linkwarden
@@ -15,9 +15,24 @@ NEXT_PUBLIC_DISABLE_REGISTRATION=
NEXT_PUBLIC_CREDENTIALS_ENABLED=
DISABLE_NEW_SSO_USERS=
RE_ARCHIVE_LIMIT=
NEXT_PUBLIC_MAX_FILE_SIZE=
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=
@@ -31,11 +46,21 @@ SPACES_FORCE_PATH_STYLE=
NEXT_PUBLIC_EMAIL_PROVIDER=
EMAIL_FROM=
EMAIL_SERVER=
BASE_URL=
# Proxy settings
PROXY=
PROXY_USERNAME=
PROXY_PASSWORD=
PROXY_BYPASS=
#
# SSO Providers
#
# PDF archive settings
PDF_MARGIN_TOP=
PDF_MARGIN_BOTTOM=
#################
# SSO Providers #
#################
# 42 School
NEXT_PUBLIC_FORTYTWO_ENABLED=
@@ -63,6 +88,12 @@ 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=
@@ -70,12 +101,25 @@ 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=
BATLLENET_ISSUER=
BATTLENET_ISSUER=
# Box
NEXT_PUBLIC_BOX_ENABLED=
@@ -164,8 +208,8 @@ FUSIONAUTH_TENANT_ID=
# GitHub
NEXT_PUBLIC_GITHUB_ENABLED=
GITHUB_CUSTOM_NAME=
GITHUB_CLIENT_ID=
GITHUB_CLIENT_SECRET=
GITHUB_ID=
GITHUB_SECRET=
# GitLab
NEXT_PUBLIC_GITLAB_ENABLED=

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.

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

9
.gitignore vendored
View File

@@ -42,8 +42,15 @@ prisma/dev.db
# tests
/tests
/test-results/
/blob-report/
/playwright-report/
/playwright/.cache/
/playwright/.auth/
# docker
pgdata
pgdata
certificates
/test-results/
/playwright-report/
/blob-report/
/playwright/.cache/

11
.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
}

View File

@@ -1 +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).

View File

@@ -8,16 +8,33 @@ WORKDIR /data
COPY ./package.json ./yarn.lock ./playwright.config.ts ./
# Increase timeout to pass github actions arm64 build
RUN yarn install --network-timeout 10000000
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
CMD yarn prisma migrate deploy && yarn start

View File

View File

@@ -17,7 +17,9 @@
## 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.
@@ -27,17 +29,21 @@ Additionally, Linkwarden is designed with collaboration in mind, sharing links w
<img src="./assets/dashboard.png" />
<div align="center">
<img src="./assets/all_links.png" width="32%" />
<img src="./assets/all_links.jpg" width="23%" />
<img src="./assets/all_collections.png" width="32%" />
<img src="./assets/list_view.jpg" width="23%" />
<img src="./assets/manage_team.png" width="32%" />
<img src="./assets/all_collections.jpg" width="23%" />
<img src="./assets/readable_view.png" width="32%" />
<img src="./assets/manage_team.jpg" width="23%" />
<img src="./assets/public_page.png" width="32%" />
<img src="./assets/readable_view.jpg" width="23%" />
<img src="./assets/light_mode.png" width="32%" />
<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>
@@ -51,9 +57,9 @@ We've forked the old version from the current repository into [this repo](https:
## Features
- 📸 Auto capture a screenshot, PDF, and readable view of each webpage.
- 📸 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, name, description and multiple tags.
- 📂 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.
@@ -64,6 +70,10 @@ We've forked the old version from the current repository into [this repo](https:
- 🧩 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 ⭐

BIN
assets/all_collections.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 251 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 654 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: 799 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 369 KiB

After

Width:  |  Height:  |  Size: 786 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: 785 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 634 KiB

BIN
assets/list_view.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 394 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 115 KiB

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.

Before

Width:  |  Height:  |  Size: 664 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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 657 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: 945 KiB

View File

@@ -1,29 +0,0 @@
type Props = {
onClick?: Function;
label: string;
loading?: boolean;
className?: string;
type?: "button" | "submit" | "reset" | undefined;
};
export default function AccentSubmitButton({
onClick,
label,
loading,
className,
type,
}: Props) {
return (
<button
type={type ? type : undefined}
className={`border primary-btn-gradient select-none duration-200 bg-black border-[oklch(var(--p))] hover:border-[#0070b5] rounded-lg text-center px-4 py-2 text-white active:scale-95 tracking-wider w-fit flex justify-center items-center gap-2 ${
className || ""
}`}
onClick={() => {
if (loading !== undefined && !loading && onClick) onClick();
}}
>
<p className="font-bold">{label}</p>
</button>
);
}

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,33 +0,0 @@
import Link from "next/link";
import React, { MouseEventHandler } from "react";
type Props = {
toggleAnnouncementBar: MouseEventHandler<HTMLButtonElement>;
};
export default function AnnouncementBar({ toggleAnnouncementBar }: Props) {
return (
<div className="fixed w-full z-20 bg-base-200">
<div className="w-full h-10 rainbow flex items-center justify-center">
<div className="w-fit font-semibold">
🎉 See what&apos;s new in{" "}
<Link
href="https://blog.linkwarden.app/releases/v2.4"
target="_blank"
className="underline hover:opacity-50 duration-100"
>
Linkwarden v2.4
</Link>
! 🥳
</div>
<button
className="fixed right-3 hover:opacity-50 duration-100"
onClick={toggleAnnouncementBar}
>
<i className="bi-x text-neutral text-2xl"></i>
</button>
</div>
</div>
);
}

View File

@@ -8,19 +8,38 @@ type Props = {
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);

View File

@@ -5,10 +5,12 @@ import ProfilePhoto from "./ProfilePhoto";
import usePermissions from "@/hooks/usePermissions";
import useLocalSettingsStore from "@/store/localSettings";
import getPublicUserData from "@/lib/client/getPublicUserData";
import useAccountStore from "@/store/account";
import EditCollectionModal from "./ModalContent/EditCollectionModal";
import EditCollectionSharingModal from "./ModalContent/EditCollectionSharingModal";
import DeleteCollectionModal from "./ModalContent/DeleteCollectionModal";
import { dropdownTriggerer } from "@/lib/client/utils";
import { useTranslation } from "next-i18next";
import { useUser } from "@/hooks/store/user";
type Props = {
collection: CollectionIncludingMembersAndLinkCount;
@@ -16,8 +18,9 @@ type Props = {
};
export default function CollectionCard({ collection, className }: Props) {
const { t } = useTranslation();
const { settings } = useLocalSettingsStore();
const { account } = useAccountStore();
const { data: user = {} } = useUser();
const formattedDate = new Date(collection.createdAt as string).toLocaleString(
"en-US",
@@ -36,22 +39,24 @@ export default function CollectionCard({ collection, className }: Props) {
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 !== account.id) {
if (collection && collection.ownerId !== user.id) {
const owner = await getPublicUserData(collection.ownerId as number);
setCollectionOwner(owner);
} else if (collection && collection.ownerId === account.id) {
} else if (collection && collection.ownerId === user.id) {
setCollectionOwner({
id: account.id as number,
name: account.name,
username: account.username as string,
image: account.image as string,
archiveAsScreenshot: account.archiveAsScreenshot as boolean,
archiveAsPDF: account.archiveAsPDF as boolean,
id: user.id as number,
name: user.name,
username: user.username as string,
image: user.image as string,
archiveAsScreenshot: user.archiveAsScreenshot as boolean,
archiveAsMonolith: user.archiveAsMonolith as boolean,
archiveAsPDF: user.archiveAsPDF as boolean,
});
}
};
@@ -70,12 +75,13 @@ export default function CollectionCard({ collection, className }: Props) {
<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-[1] menu shadow bg-base-200 border border-neutral-content rounded-box w-52 mt-1">
{permissions === true ? (
<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"
@@ -85,10 +91,10 @@ export default function CollectionCard({ collection, className }: Props) {
setEditCollectionModal(true);
}}
>
Edit Collection Info
{t("edit_collection_info")}
</div>
</li>
) : undefined}
)}
<li>
<div
role="button"
@@ -98,7 +104,9 @@ export default function CollectionCard({ collection, className }: Props) {
setEditCollectionSharingModal(true);
}}
>
{permissions === true ? "Share and Collaborate" : "View Team"}
{permissions === true
? t("share_and_collaborate")
: t("view_team")}
</div>
</li>
<li>
@@ -110,7 +118,9 @@ export default function CollectionCard({ collection, className }: Props) {
setDeleteCollectionModal(true);
}}
>
{permissions === true ? "Delete Collection" : "Leave Collection"}
{permissions === true
? t("delete_collection")
: t("leave_collection")}
</div>
</li>
</ul>
@@ -170,7 +180,7 @@ export default function CollectionCard({ collection, className }: Props) {
<div className="font-bold text-sm flex justify-end gap-1 items-center">
{collection.isPublic ? (
<i
className="bi-globe-americas drop-shadow text-neutral"
className="bi-globe2 drop-shadow text-neutral"
title="This collection is being shared publicly."
></i>
) : undefined}

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

@@ -9,12 +9,12 @@ export default function dashboardItem({
}) {
return (
<div className="flex items-center">
<div className="w-[4.7rem] aspect-square flex justify-center items-center bg-primary/20 rounded-xl select-none">
<i className={`${icon} text-primary text-4xl drop-shadow`}></i>
<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-6xl text-primary mt-0.5">{value}</p>
<p className="font-thin text-5xl text-primary mt-0.5">{value}</p>
</div>
</div>
);

View File

@@ -1,4 +1,6 @@
import { dropdownTriggerer } from "@/lib/client/utils";
import React from "react";
import { useTranslation } from "next-i18next";
type Props = {
setSearchFilter: Function;
@@ -15,18 +17,19 @@ export default function FilterSearchDropdown({
setSearchFilter,
searchFilter,
}: Props) {
const { t } = useTranslation();
return (
<div className="dropdown dropdown-bottom dropdown-end">
<div
tabIndex={0}
role="button"
onMouseDown={dropdownTriggerer}
className="btn btn-sm btn-square btn-ghost"
>
<i
className="bi-funnel text-neutral text-2xl"
></i>
<i className="bi-funnel text-neutral text-2xl"></i>
</div>
<ul className="dropdown-content z-[30] menu shadow bg-base-200 border border-neutral-content rounded-box w-44 mt-1">
<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"
@@ -38,11 +41,11 @@ export default function FilterSearchDropdown({
name="search-filter-checkbox"
className="checkbox checkbox-primary"
checked={searchFilter.name}
onChange={() => {
setSearchFilter({ ...searchFilter, name: !searchFilter.name });
}}
onChange={() =>
setSearchFilter({ ...searchFilter, name: !searchFilter.name })
}
/>
<span className="label-text">Name</span>
<span className="label-text">{t("name")}</span>
</label>
</li>
<li>
@@ -56,11 +59,11 @@ export default function FilterSearchDropdown({
name="search-filter-checkbox"
className="checkbox checkbox-primary"
checked={searchFilter.url}
onChange={() => {
setSearchFilter({ ...searchFilter, url: !searchFilter.url });
}}
onChange={() =>
setSearchFilter({ ...searchFilter, url: !searchFilter.url })
}
/>
<span className="label-text">Link</span>
<span className="label-text">{t("link")}</span>
</label>
</li>
<li>
@@ -74,35 +77,14 @@ export default function FilterSearchDropdown({
name="search-filter-checkbox"
className="checkbox checkbox-primary"
checked={searchFilter.description}
onChange={() => {
onChange={() =>
setSearchFilter({
...searchFilter,
description: !searchFilter.description,
});
}}
})
}
/>
<span className="label-text">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.textContent}
onChange={() => {
setSearchFilter({
...searchFilter,
textContent: !searchFilter.textContent,
});
}}
/>
<span className="label-text">Full Content</span>
<span className="label-text">{t("description")}</span>
</label>
</li>
<li>
@@ -116,14 +98,35 @@ export default function FilterSearchDropdown({
name="search-filter-checkbox"
className="checkbox checkbox-primary"
checked={searchFilter.tags}
onChange={() => {
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,
tags: !searchFilter.tags,
});
}}
textContent: !searchFilter.textContent,
})
}
/>
<span className="label-text">Tags</span>
<span className="label-text">{t("full_content")}</span>
<div className="ml-auto badge badge-sm badge-neutral">
{t("slower")}
</div>
</label>
</li>
</ul>

View File

@@ -1,22 +1,31 @@
import useCollectionStore from "@/store/collections";
import { useRouter } from "next/router";
import { useEffect, useState } from "react";
import { styles } from "./styles";
import { Options } from "./types";
import CreatableSelect from "react-select/creatable";
import Select from "react-select";
import { useCollections } from "@/hooks/store/collections";
type Props = {
onChange: any;
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,22 +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 (
<CreatableSelect
isClearable={false}
className="react-select-container"
classNamePrefix="react-select"
onChange={onChange}
options={options}
styles={styles}
defaultValue={defaultValue}
// menuPosition="fixed"
/>
);
const getParentNames = (parentId: number): string[] => {
const parentNames = [];
const parent = collections.find((e) => e.id === parentId);
if (parent) {
parentNames.push(parent.name);
if (parent.parentId) {
parentNames.push(...getParentNames(parent.parentId));
}
}
// Have the top level parent at beginning
return parentNames.reverse();
};
const customOption = ({ data, innerProps }: any) => {
return (
<div
{...innerProps}
className="px-2 py-2 last:border-0 border-b border-neutral-content hover:bg-neutral-content 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 };
});

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

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

@@ -1,16 +0,0 @@
import LinkCard from "@/components/LinkViews/LinkCard";
import { LinkIncludingShortenedCollectionAndTags } from "@/types/global";
export default function CardView({
links,
}: {
links: LinkIncludingShortenedCollectionAndTags[];
}) {
return (
<div className="grid min-[1900px]:grid-cols-4 xl:grid-cols-3 sm:grid-cols-2 grid-cols-1 gap-5">
{links.map((e, i) => {
return <LinkCard key={i} link={e} count={i} />;
})}
</div>
);
}

View File

@@ -1,16 +0,0 @@
import LinkGrid from "@/components/LinkViews/LinkGrid";
import { LinkIncludingShortenedCollectionAndTags } from "@/types/global";
export default function GridView({
links,
}: {
links: LinkIncludingShortenedCollectionAndTags[];
}) {
return (
<div className="grid 2xl:grid-cols-3 xl:grid-cols-2 grid-cols-1 gap-5">
{links.map((e, i) => {
return <LinkGrid link={e} count={i} key={i} />;
})}
</div>
);
}

View File

@@ -1,16 +0,0 @@
import LinkList from "@/components/LinkViews/LinkList";
import { LinkIncludingShortenedCollectionAndTags } from "@/types/global";
export default function ListView({
links,
}: {
links: LinkIncludingShortenedCollectionAndTags[];
}) {
return (
<div className="flex flex-col">
{links.map((e, i) => {
return <LinkList key={i} link={e} count={i} />;
})}
</div>
);
}

View File

@@ -1,203 +0,0 @@
import {
ArchivedFormat,
CollectionIncludingMembersAndLinkCount,
LinkIncludingShortenedCollectionAndTags,
} from "@/types/global";
import { useEffect, useRef, useState } from "react";
import useLinkStore from "@/store/links";
import useCollectionStore from "@/store/collections";
import unescapeString from "@/lib/client/unescapeString";
import LinkActions from "@/components/LinkViews/LinkComponents/LinkActions";
import LinkDate from "@/components/LinkViews/LinkComponents/LinkDate";
import LinkCollection from "@/components/LinkViews/LinkComponents/LinkCollection";
import Image from "next/image";
import { previewAvailable } from "@/lib/shared/getArchiveValidity";
import Link from "next/link";
import LinkIcon from "./LinkComponents/LinkIcon";
import LinkGroupedIconURL from "./LinkComponents/LinkGroupedIconURL";
import useOnScreen from "@/hooks/useOnScreen";
type Props = {
link: LinkIncludingShortenedCollectionAndTags;
count: number;
className?: string;
};
export default function LinkGrid({ link, count, className }: Props) {
const { collections } = useCollectionStore();
const { links, getLink } = useLinkStore();
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 ref = useRef<HTMLDivElement>(null);
const isVisible = useOnScreen(ref);
useEffect(() => {
let interval: any;
if (
isVisible &&
!link.preview?.startsWith("archives") &&
link.preview !== "unavailable"
) {
interval = setInterval(async () => {
getLink(link.id as number);
}, 5000);
}
return () => {
if (interval) {
clearInterval(interval);
}
};
}, [isVisible, link.preview]);
const [showInfo, setShowInfo] = useState(false);
return (
<div
ref={ref}
className="border border-solid border-neutral-content bg-base-200 shadow-md hover:shadow-none duration-100 rounded-2xl relative"
>
<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={{ filter: "blur(2px)" }}
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>
)}
<div
style={
{
// background:
// "radial-gradient(circle, rgba(255, 255, 255, 0.5), transparent)",
}
}
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 last:hidden border-t border-neutral-content h-[1px]" />
<div className="p-3 mt-1">
<p className="truncate w-full pr-8 text-primary">
{unescapeString(link.name || link.description) || link.url}
</p>
<Link
href={link.url || ""}
target="_blank"
title={link.url || ""}
className="w-fit"
>
<div className="flex gap-1 item-center select-none text-neutral mt-1 hover:opacity-60 duration-100">
<i className="bi-link-45deg text-lg mt-[0.15rem] leading-none"></i>
<p className="text-sm truncate">{shortendURL}</p>
</div>
</Link>
</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">
<div className="cursor-pointer w-fit">
{collection ? (
<LinkCollection link={link} collection={collection} />
) : undefined}
</div>
<LinkDate link={link} />
</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">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">
No description provided.
</span>
)}
</p>
{link.tags[0] ? (
<>
<p className="text-neutral text-lg mt-3 font-semibold">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>
</>
) : undefined}
</div>
) : undefined}
<LinkActions
link={link}
collection={collection}
position="top-[10.75rem] right-3"
toggleShowInfo={() => setShowInfo(!showInfo)}
linkInfo={showInfo}
/>
</div>
);
}

View File

@@ -7,9 +7,11 @@ import usePermissions from "@/hooks/usePermissions";
import EditLinkModal from "@/components/ModalContent/EditLinkModal";
import DeleteLinkModal from "@/components/ModalContent/DeleteLinkModal";
import PreservedFormatsModal from "@/components/ModalContent/PreservedFormatsModal";
import useLinkStore from "@/store/links";
import { toast } from "react-hot-toast";
import useAccountStore from "@/store/account";
import { dropdownTriggerer } from "@/lib/client/utils";
import { useTranslation } from "next-i18next";
import { useUser } from "@/hooks/store/user";
import { useDeleteLink, useUpdateLink } from "@/hooks/store/links";
import toast from "react-hot-toast";
type Props = {
link: LinkIncludingShortenedCollectionAndTags;
@@ -17,6 +19,8 @@ type Props = {
position?: string;
toggleShowInfo?: () => void;
linkInfo?: boolean;
alignToTop?: boolean;
flipDropdown?: boolean;
};
export default function LinkActions({
@@ -24,41 +28,46 @@ export default function LinkActions({
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 { account } = useAccountStore();
const { data: user = {} } = useUser();
const { removeLink, updateLink } = useLinkStore();
const updateLink = useUpdateLink();
const deleteLink = useDeleteLink();
const pinLink = async () => {
const isAlreadyPinned = link?.pinnedBy && link.pinnedBy[0];
const isAlreadyPinned = link?.pinnedBy && link.pinnedBy[0] ? true : false;
const load = toast.loading("Applying...");
const load = toast.loading(t("updating"));
const response = await updateLink({
...link,
pinnedBy: isAlreadyPinned ? undefined : [{ id: account.id }],
});
await updateLink.mutateAsync(
{
...link,
pinnedBy: isAlreadyPinned ? undefined : [{ id: user.id }],
},
{
onSettled: (data, error) => {
toast.dismiss(load);
toast.dismiss(load);
response.ok &&
toast.success(`Link ${isAlreadyPinned ? "Unpinned!" : "Pinned!"}`);
};
const deleteLink = async () => {
const load = toast.loading("Deleting...");
const response = await removeLink(link.id as number);
toast.dismiss(load);
response.ok && toast.success(`Link Deleted.`);
if (error) {
toast.error(error.message);
} else {
toast.success(
isAlreadyPinned ? t("link_unpinned") : t("link_pinned")
);
}
},
}
);
};
return (
@@ -66,32 +75,35 @@ export default function LinkActions({
<div
className={`dropdown dropdown-left absolute ${
position || "top-3 right-3"
} z-20`}
} ${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">
{permissions === true ? (
<li>
<div
role="button"
tabIndex={0}
onClick={() => {
(document?.activeElement as HTMLElement)?.blur();
pinLink();
}}
>
{link?.pinnedBy && link.pinnedBy[0]
? "Unpin"
: "Pin to Dashboard"}
</div>
</li>
) : undefined}
<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
@@ -102,7 +114,7 @@ export default function LinkActions({
toggleShowInfo();
}}
>
{!linkInfo ? "Show" : "Hide"} Link Details
{!linkInfo ? t("show_link_details") : t("hide_link_details")}
</div>
</li>
) : undefined}
@@ -116,33 +128,51 @@ export default function LinkActions({
setEditLinkModal(true);
}}
>
Edit Link
{t("edit_link")}
</div>
</li>
) : undefined}
<li>
<div
role="button"
tabIndex={0}
onClick={() => {
(document?.activeElement as HTMLElement)?.blur();
setPreservedFormatsModal(true);
}}
>
Preserved Formats
</div>
</li>
{link.type === "url" && (
<li>
<div
role="button"
tabIndex={0}
onClick={() => {
(document?.activeElement as HTMLElement)?.blur();
setPreservedFormatsModal(true);
}}
>
{t("preserved_formats")}
</div>
</li>
)}
{permissions === true || permissions?.canDelete ? (
<li>
<div
role="button"
tabIndex={0}
onClick={(e) => {
onClick={async (e) => {
(document?.activeElement as HTMLElement)?.blur();
e.shiftKey ? deleteLink() : setDeleteLinkModal(true);
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);
}}
>
Delete
{t("delete")}
</div>
</li>
) : undefined}
@@ -164,7 +194,7 @@ export default function LinkActions({
{preservedFormatsModal ? (
<PreservedFormatsModal
onClose={() => setPreservedFormatsModal(false)}
activeLink={link}
link={link}
/>
) : undefined}
{/* {expandedLink ? (

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

@@ -2,7 +2,7 @@ import {
CollectionIncludingMembersAndLinkCount,
LinkIncludingShortenedCollectionAndTags,
} from "@/types/global";
import { useRouter } from "next/router";
import Link from "next/link";
import React from "react";
export default function LinkCollection({
@@ -12,22 +12,22 @@ export default function LinkCollection({
link: LinkIncludingShortenedCollectionAndTags;
collection: CollectionIncludingMembersAndLinkCount;
}) {
const router = useRouter();
return (
<div
onClick={(e) => {
e.stopPropagation();
router.push(`/collections/${link.collection.id}`);
}}
className="flex items-center gap-1 max-w-full w-fit hover:opacity-70 duration-100"
title={collection?.name}
>
<i
className="bi-folder-fill text-lg drop-shadow"
style={{ color: collection?.color }}
></i>
<p className="truncate capitalize">{collection?.name}</p>
</div>
<>
<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

@@ -1,20 +1,21 @@
import { LinkIncludingShortenedCollectionAndTags } from "@/types/global";
import React from "react";
export default function LinkDate({ link }: {
export default function LinkDate({
link,
}: {
link: LinkIncludingShortenedCollectionAndTags;
}) {
const formattedDate = new Date(link.createdAt as string).toLocaleString(
"en-US",
{
year: "numeric",
month: "short",
day: "numeric",
},
);
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">
<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

@@ -1,53 +0,0 @@
import { LinkIncludingShortenedCollectionAndTags } from "@/types/global";
import Image from "next/image";
import isValidUrl from "@/lib/shared/isValidUrl";
import React from "react";
import Link from "next/link";
export default function LinkGroupedIconURL({
link,
}: {
link: LinkIncludingShortenedCollectionAndTags;
}) {
const url =
isValidUrl(link.url || "") && link.url ? new URL(link.url) : undefined;
const [showFavicon, setShowFavicon] = React.useState<boolean>(true);
let shortendURL;
try {
shortendURL = new URL(link.url || "").host.toLowerCase();
} catch (error) {
console.log(error);
}
return (
<Link href={link.url || ""} target="_blank">
<div className="bg-white shadow-md rounded-md border-[2px] flex gap-1 item-center justify-center border-white select-none z-10 max-w-full">
{link.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="w-5 h-5 rounded"
draggable="false"
onError={() => {
setShowFavicon(false);
}}
/>
) : showFavicon === false ? (
<i className="bi-link-45deg text-xl leading-none text-black"></i>
) : link.type === "pdf" ? (
<i className={`bi-file-earmark-pdf`}></i>
) : link.type === "image" ? (
<i className={`bi-file-earmark-image`}></i>
) : undefined}
<p className="truncate bg-white text-black mr-1">
<p className="text-sm">{shortendURL}</p>
</p>
</div>
</Link>
);
}

View File

@@ -5,44 +5,99 @@ import React from "react";
export default function LinkIcon({
link,
width,
className,
size,
}: {
link: LinkIncludingShortenedCollectionAndTags;
width?: string;
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 iconClasses: string =
"bg-white shadow rounded-md border-[2px] flex item-center justify-center border-white select-none z-10" +
" " +
(width || "w-12");
const [showFavicon, setShowFavicon] = React.useState<boolean>(true);
return (
<>
{link.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}
draggable="false"
onError={() => {
setShowFavicon(false);
}}
/>
) : showFavicon === false ? (
<div className={iconClasses}>
<i className="bi-link-45deg text-4xl text-black"></i>
</div>
{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" ? (
<i className={`bi-file-earmark-pdf ${iconClasses}`}></i>
<LinkPlaceholderIcon
iconClasses={iconClasses + dimension}
size={size}
icon="bi-file-earmark-pdf"
/>
) : link.type === "image" ? (
<i className={`bi-file-earmark-image ${iconClasses}`}></i>
) : undefined}
<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

@@ -1,111 +0,0 @@
import {
CollectionIncludingMembersAndLinkCount,
LinkIncludingShortenedCollectionAndTags,
} from "@/types/global";
import { useEffect, useState } from "react";
import useLinkStore from "@/store/links";
import useCollectionStore from "@/store/collections";
import unescapeString from "@/lib/client/unescapeString";
import LinkActions from "@/components/LinkViews/LinkComponents/LinkActions";
import LinkDate from "@/components/LinkViews/LinkComponents/LinkDate";
import LinkCollection from "@/components/LinkViews/LinkComponents/LinkCollection";
import LinkIcon from "@/components/LinkViews/LinkComponents/LinkIcon";
import Link from "next/link";
type Props = {
link: LinkIncludingShortenedCollectionAndTags;
count: number;
className?: string;
};
export default function LinkGrid({ link, count, className }: Props) {
const { collections } = useCollectionStore();
const { links } = useLinkStore();
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]);
return (
<div className="border border-solid border-neutral-content bg-base-200 shadow-md hover:shadow-none duration-100 rounded-2xl relative p-3">
<div
onClick={() => link.url && window.open(link.url || "", "_blank")}
className="cursor-pointer"
>
<LinkIcon link={link} width="w-12 mb-3" />
<p className="truncate w-full">
{unescapeString(link.name || link.description) || link.url}
</p>
<div className="mt-1 flex flex-col text-xs text-neutral">
<div className="flex items-center gap-2">
<LinkCollection link={link} collection={collection} />
&middot;
{link.url ? (
<div
onClick={(e) => {
e.preventDefault();
window.open(link.url || "", "_blank");
}}
className="flex items-center hover:opacity-60 cursor-pointer duration-100"
>
<p className="truncate">{shortendURL}</p>
</div>
) : (
<div className="badge badge-primary badge-sm my-1">
{link.type}
</div>
)}
</div>
<LinkDate link={link} />
</div>
<p className="truncate">{unescapeString(link.description)}</p>
{link.tags[0] ? (
<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>
) : undefined}
</div>
<LinkActions
toggleShowInfo={() => {}}
linkInfo={false}
link={link}
collection={collection}
/>
</div>
);
}

View File

@@ -1,152 +0,0 @@
import {
CollectionIncludingMembersAndLinkCount,
LinkIncludingShortenedCollectionAndTags,
} from "@/types/global";
import { useEffect, useState } from "react";
import useLinkStore from "@/store/links";
import useCollectionStore from "@/store/collections";
import unescapeString from "@/lib/client/unescapeString";
import LinkActions from "@/components/LinkViews/LinkComponents/LinkActions";
import LinkDate from "@/components/LinkViews/LinkComponents/LinkDate";
import LinkCollection from "@/components/LinkViews/LinkComponents/LinkCollection";
import LinkIcon from "@/components/LinkViews/LinkComponents/LinkIcon";
import Link from "next/link";
type Props = {
link: LinkIncludingShortenedCollectionAndTags;
count: number;
className?: string;
};
export default function LinkCardCompact({ link, count, className }: Props) {
const { collections } = useCollectionStore();
const { links } = useLinkStore();
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 [showInfo, setShowInfo] = useState(false);
return (
<>
<div
className={`border-neutral-content relative ${
!showInfo ? "hover:bg-base-300" : ""
} duration-200 rounded-lg`}
>
<div
onClick={() => link.url && window.open(link.url || "", "_blank")}
className="flex items-center cursor-pointer py-3 px-3"
>
<div className="shrink-0">
<LinkIcon link={link} width="sm:w-12 w-8" />
</div>
<div className="w-[calc(100%-56px)] ml-2">
<p className="line-clamp-1 mr-8 text-primary">
{unescapeString(link.name || link.description) || link.url}
</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-2">
{collection ? (
<>
<LinkCollection link={link} collection={collection} />
&middot;
</>
) : undefined}
{link.url ? (
<div className="flex items-center gap-1 max-w-full w-fit text-neutral">
<i className="bi-link-45deg text-base" />
<p className="truncate w-full">{shortendURL}</p>
</div>
) : (
<div className="badge badge-primary badge-sm my-1">
{link.type}
</div>
)}
</div>
<span className="hidden sm:block">&middot;</span>
<LinkDate link={link} />
</div>
</div>
</div>
<LinkActions
link={link}
collection={collection}
position="top-3 right-3"
// toggleShowInfo={() => setShowInfo(!showInfo)}
// linkInfo={showInfo}
/>
{showInfo ? (
<div>
<div className="pb-3 mt-1 px-3">
<p className="text-neutral text-lg font-semibold">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">
No description provided.
</span>
)}
</p>
{link.tags[0] ? (
<>
<p className="text-neutral text-lg mt-3 font-semibold">
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>
</>
) : undefined}
</div>
</div>
) : undefined}
</div>
<div className="divider my-0 last:hidden h-[1px]"></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>
);
}

View File

@@ -1,38 +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 }: Props) {
useEffect(() => {
document.body.style.overflow = "hidden";
return () => {
document.body.style.overflow = "auto";
};
});
export default function Modal({
toggleModal,
className,
children,
dismissible = true,
}: Props) {
const [drawerIsOpen, setDrawerIsOpen] = React.useState(true);
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">
<ClickAwayHandler
onClickOutside={toggleModal}
className={`w-full mt-auto sm:m-auto sm:w-11/12 sm:max-w-2xl ${
className || ""
}`}
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}
>
<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">
<div
onClick={toggleModal as MouseEventHandler<HTMLDivElement>}
className="absolute top-4 right-3 btn btn-sm outline-none btn-circle btn-ghost z-10"
<Drawer.Portal>
<Drawer.Overlay className="fixed inset-0 bg-black/40" />
<ClickAwayHandler
onClickOutside={() => dismissible && setDrawerIsOpen(false)}
>
<i className="bi-x text-neutral text-2xl"></i>
<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>
{children}
</div>
</ClickAwayHandler>
</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

@@ -1,11 +1,13 @@
import React, { useEffect, useState } from "react";
import TextInput from "@/components/TextInput";
import useCollectionStore from "@/store/collections";
import toast from "react-hot-toast";
import { CollectionIncludingMembersAndLinkCount } from "@/types/global";
import { useRouter } from "next/router";
import usePermissions from "@/hooks/usePermissions";
import Modal from "../Modal";
import Button from "../ui/Button";
import { useTranslation } from "next-i18next";
import { useDeleteCollection } from "@/hooks/store/collections";
import toast from "react-hot-toast";
type Props = {
onClose: Function;
@@ -16,42 +18,43 @@ 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 [submitLoader, setSubmitLoader] = useState(false);
const { removeCollection } = useCollectionStore();
const router = useRouter();
const [inputField, setInputField] = useState("");
const permissions = usePermissions(collection.id as number);
const deleteCollection = useDeleteCollection();
const submit = async () => {
if (permissions === true) if (collection.name !== inputField) return null;
if (permissions === true && collection.name !== inputField) return;
if (!submitLoader) {
setSubmitLoader(true);
if (!collection) return null;
setSubmitLoader(true);
const load = toast.loading("Deleting...");
const load = toast.loading(t("deleting_collection"));
let response;
deleteCollection.mutateAsync(collection.id as number, {
onSettled: (data, error) => {
toast.dismiss(load);
response = await removeCollection(collection.id as any);
toast.dismiss(load);
if (response.ok) {
toast.success(`Deleted.`);
onClose();
router.push("/collections");
} else toast.error(response.data as string);
if (error) {
toast.error(error.message);
} else {
onClose();
toast.success(t("deleted"));
router.push("/collections");
}
},
});
setSubmitLoader(false);
}
@@ -60,7 +63,7 @@ export default function DeleteCollectionModal({
return (
<Modal toggleModal={onClose}>
<p className="text-xl font-thin text-red-500">
{permissions === true ? "Delete" : "Leave"} Collection
{permissions === true ? t("delete_collection") : t("leave_collection")}
</p>
<div className="divider mb-3 mt-1"></div>
@@ -68,48 +71,37 @@ export default function DeleteCollectionModal({
<div className="flex flex-col gap-3">
{permissions === true ? (
<>
<div className="flex flex-col gap-3">
<p>
To confirm, type &quot;
<span className="font-bold">{collection.name}</span>
&quot; in the box below:
</p>
<TextInput
value={inputField}
onChange={(e) => setInputField(e.target.value)}
placeholder={`Type "${collection.name}" Here.`}
className="w-3/4 mx-auto"
/>
</div>
<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>Warning:</b> Deleting this collection will permanently erase
all its contents, and it will become inaccessible to everyone,
including members with previous access.
<b>{t("warning")}: </b>
{t("deletion_warning")}
</span>
</div>
</>
) : (
<p>Click the button below to leave the current collection.</p>
<p>{t("leave_prompt")}</p>
)}
<button
<Button
disabled={permissions === true && inputField !== collection.name}
className={`ml-auto btn w-fit text-white flex items-center gap-2 duration-100 ${
permissions === true
? inputField === collection.name
? "bg-red-500 hover:bg-red-400 hover:dark:bg-red-600 cursor-pointer"
: "cursor-not-allowed bg-red-300 dark:bg-red-900"
: "bg-red-500 hover:bg-red-400 hover:dark:bg-red-600 cursor-pointer"
}`}
onClick={submit}
intent="destructive"
className="ml-auto"
>
<i className="bi-trash text-xl"></i>
{permissions === true ? "Delete" : "Leave"} Collection
</button>
{permissions === true ? t("delete") : t("leave")}
</Button>
</div>
</Modal>
);

View File

@@ -1,9 +1,11 @@
import React, { useEffect, useState } from "react";
import useLinkStore from "@/store/links";
import { LinkIncludingShortenedCollectionAndTags } from "@/types/global";
import toast from "react-hot-toast";
import Modal from "../Modal";
import { useRouter } from "next/router";
import Button from "../ui/Button";
import { useTranslation } from "next-i18next";
import { useDeleteLink } from "@/hooks/store/links";
import toast from "react-hot-toast";
type Props = {
onClose: Function;
@@ -11,62 +13,59 @@ type Props = {
};
export default function DeleteLinkModal({ onClose, activeLink }: Props) {
const { t } = useTranslation();
const [link, setLink] =
useState<LinkIncludingShortenedCollectionAndTags>(activeLink);
const { removeLink } = useLinkStore();
const [submitLoader, setSubmitLoader] = useState(false);
const deleteLink = useDeleteLink();
const router = useRouter();
useEffect(() => {
setLink(activeLink);
}, []);
const deleteLink = async () => {
const load = toast.loading("Deleting...");
const submit = async () => {
const load = toast.loading(t("deleting"));
const response = await removeLink(link.id as number);
await deleteLink.mutateAsync(link.id as number, {
onSettled: (data, error) => {
toast.dismiss(load);
toast.dismiss(load);
response.ok && toast.success(`Link Deleted.`);
if (router.pathname.startsWith("/links/[id]")) {
router.push("/dashboard");
}
onClose();
if (error) {
toast.error(error.message);
} else {
if (router.pathname.startsWith("/links/[id]")) {
router.push("/dashboard");
}
toast.success(t("deleted"));
onClose();
}
},
});
};
return (
<Modal toggleModal={onClose}>
<p className="text-xl font-thin text-red-500">Delete Link</p>
<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>Are you sure you want to delete this Link?</p>
<p>{t("link_deletion_confirmation_message")}</p>
<div role="alert" className="alert alert-warning">
<i className="bi-exclamation-triangle text-xl"/>
<i className="bi-exclamation-triangle text-xl" />
<span>
<b>Warning:</b> This action is irreversible!
<b>{t("warning")}:</b> {t("irreversible_warning")}
</span>
</div>
<p>
Hold the <kbd className="kbd kbd-sm">Shift</kbd> key while clicking
&apos;Delete&apos; to bypass this confirmation in the future.
</p>
<p>{t("shift_key_tip")}</p>
<button
className={`ml-auto btn w-fit text-white flex items-center gap-2 duration-100 bg-red-500 hover:bg-red-400 hover:dark:bg-red-600 cursor-pointer`}
onClick={deleteLink}
>
<i className="bi-trash text-xl"/>
Delete
</button>
<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

@@ -1,10 +1,11 @@
import React, { useState } from "react";
import TextInput from "@/components/TextInput";
import useCollectionStore from "@/store/collections";
import toast from "react-hot-toast";
import { HexColorPicker } from "react-colorful";
import { CollectionIncludingMembersAndLinkCount } from "@/types/global";
import Modal from "../Modal";
import { useTranslation } from "next-i18next";
import { useUpdateCollection } from "@/hooks/store/collections";
import toast from "react-hot-toast";
type Props = {
onClose: Function;
@@ -15,11 +16,12 @@ export default function EditCollectionModal({
onClose,
activeCollection,
}: Props) {
const { t } = useTranslation();
const [collection, setCollection] =
useState<CollectionIncludingMembersAndLinkCount>(activeCollection);
const [submitLoader, setSubmitLoader] = useState(false);
const { updateCollection } = useCollectionStore();
const updateCollection = useUpdateCollection();
const submit = async () => {
if (!submitLoader) {
@@ -28,18 +30,20 @@ export default function EditCollectionModal({
setSubmitLoader(true);
const load = toast.loading("Updating...");
const load = toast.loading(t("updating_collection"));
let response;
await updateCollection.mutateAsync(collection, {
onSettled: (data, error) => {
toast.dismiss(load);
response = await updateCollection(collection as any);
toast.dismiss(load);
if (response.ok) {
toast.success(`Updated!`);
onClose();
} else toast.error(response.data as string);
if (error) {
toast.error(error.message);
} else {
onClose();
toast.success(t("updated"));
}
},
});
setSubmitLoader(false);
}
@@ -47,29 +51,35 @@ export default function EditCollectionModal({
return (
<Modal toggleModal={onClose}>
<p className="text-xl font-thin">Edit Collection Info</p>
<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">Name</p>
<p className="mb-2">{t("name")}</p>
<div className="flex flex-col gap-3">
<TextInput
className="bg-base-200"
value={collection.name}
placeholder="e.g. Example Collection"
placeholder={t("collection_name_placeholder")}
onChange={(e) =>
setCollection({ ...collection, name: e.target.value })
}
/>
<div>
<p className="w-full mb-2">Color</p>
<div className="color-picker flex justify-between">
<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 drop-shadow"
className="bi-folder-fill text-5xl"
style={{ color: collection.color }}
></i>
<div
@@ -78,29 +88,22 @@ export default function EditCollectionModal({
setCollection({ ...collection, color: "#0ea5e9" })
}
>
Reset
{t("reset")}
</div>
</div>
<HexColorPicker
color={collection.color}
onChange={(e) => setCollection({ ...collection, color: e })}
/>
</div>
</div>
</div>
</div>
<div className="w-full">
<p className="mb-2">Description</p>
<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="The purpose of this Collection..."
placeholder={t("collection_description_placeholder")}
value={collection.description}
onChange={(e) =>
setCollection({
...collection,
description: e.target.value,
})
setCollection({ ...collection, description: e.target.value })
}
/>
</div>
@@ -110,7 +113,7 @@ export default function EditCollectionModal({
className="btn btn-accent dark:border-violet-400 text-white w-fit ml-auto"
onClick={submit}
>
Save
{t("save_changes")}
</button>
</div>
</Modal>

View File

@@ -1,14 +1,16 @@
import React, { useEffect, useState } from "react";
import TextInput from "@/components/TextInput";
import useCollectionStore from "@/store/collections";
import toast from "react-hot-toast";
import { CollectionIncludingMembersAndLinkCount, Member } from "@/types/global";
import getPublicUserData from "@/lib/client/getPublicUserData";
import useAccountStore from "@/store/account";
import usePermissions from "@/hooks/usePermissions";
import ProfilePhoto from "../ProfilePhoto";
import addMemberToCollection from "@/lib/client/addMemberToCollection";
import Modal from "../Modal";
import { dropdownTriggerer } from "@/lib/client/utils";
import { useTranslation } from "next-i18next";
import { useUpdateCollection } from "@/hooks/store/collections";
import { useUser } from "@/hooks/store/user";
type Props = {
onClose: Function;
@@ -19,11 +21,13 @@ export default function EditCollectionSharingModal({
onClose,
activeCollection,
}: Props) {
const { t } = useTranslation();
const [collection, setCollection] =
useState<CollectionIncludingMembersAndLinkCount>(activeCollection);
const [submitLoader, setSubmitLoader] = useState(false);
const { updateCollection } = useCollectionStore();
const updateCollection = useUpdateCollection();
const submit = async () => {
if (!submitLoader) {
@@ -32,24 +36,26 @@ export default function EditCollectionSharingModal({
setSubmitLoader(true);
const load = toast.loading("Updating...");
const load = toast.loading(t("updating_collection"));
let response;
await updateCollection.mutateAsync(collection, {
onSettled: (data, error) => {
toast.dismiss(load);
response = await updateCollection(collection as any);
toast.dismiss(load);
if (response.ok) {
toast.success(`Updated!`);
onClose();
} else toast.error(response.data as string);
if (error) {
toast.error(error.message);
} else {
onClose();
toast.success(t("updated"));
}
},
});
setSubmitLoader(false);
}
};
const { account } = useAccountStore();
const { data: user = {} } = useUser();
const permissions = usePermissions(collection.id as number);
const currentURL = new URL(document.URL);
@@ -64,6 +70,7 @@ export default function EditCollectionSharingModal({
username: "",
image: "",
archiveAsScreenshot: undefined as unknown as boolean,
archiveAsMonolith: undefined as unknown as boolean,
archiveAsPDF: undefined as unknown as boolean,
});
@@ -92,7 +99,7 @@ export default function EditCollectionSharingModal({
return (
<Modal toggleModal={onClose}>
<p className="text-xl font-thin">
{permissions === true ? "Share and Collaborate" : "Team"}
{permissions === true ? t("share_and_collaborate") : t("team")}
</p>
<div className="divider mb-3 mt-1"></div>
@@ -100,7 +107,7 @@ export default function EditCollectionSharingModal({
<div className="flex flex-col gap-3">
{permissions === true && (
<div>
<p>Make Public</p>
<p>{t("make_collection_public")}</p>
<label className="label cursor-pointer justify-start gap-2">
<input
@@ -114,25 +121,26 @@ export default function EditCollectionSharingModal({
}
className="checkbox checkbox-primary"
/>
<span className="label-text">Make this a public collection</span>
<span className="label-text">
{t("make_collection_public_checkbox")}
</span>
</label>
<p className="text-neutral text-sm">
This will let <b>Anyone</b> to view this collection and it&apos;s
users.
{t("make_collection_public_desc")}
</p>
</div>
)}
{collection.isPublic ? (
<div className={permissions === true ? "pl-5" : ""}>
<p className="mb-2">Sharable Link (Click to copy)</p>
<p className="mb-2">{t("sharable_link_guide")}</p>
<div
onClick={() => {
try {
navigator.clipboard
.writeText(publicCollectionURL)
.then(() => toast.success("Copied!"));
.then(() => toast.success(t("copied")));
} catch (err) {
console.log(err);
}
@@ -148,21 +156,22 @@ export default function EditCollectionSharingModal({
{permissions === true && (
<>
<p>Members</p>
<p>{t("members")}</p>
<div className="flex items-center gap-2">
<TextInput
value={memberUsername || ""}
className="bg-base-200"
placeholder="Username (without the '@')"
placeholder={t("members_username_placeholder")}
onChange={(e) => setMemberUsername(e.target.value)}
onKeyDown={(e) =>
e.key === "Enter" &&
addMemberToCollection(
account.username as string,
user.username as string,
memberUsername || "",
collection,
setMemberState
setMemberState,
t
)
}
/>
@@ -170,10 +179,11 @@ export default function EditCollectionSharingModal({
<div
onClick={() =>
addMemberToCollection(
account.username as string,
user.username as string,
memberUsername || "",
collection,
setMemberState
setMemberState,
t
)
}
className="btn btn-accent dark:border-violet-400 text-white btn-square btn-sm h-10 w-10"
@@ -213,7 +223,7 @@ export default function EditCollectionSharingModal({
</div>
</div>
<div>
<p className="text-sm font-bold">Owner</p>
<p className="text-sm font-bold">{t("owner")}</p>
</div>
</div>
</div>
@@ -225,19 +235,16 @@ export default function EditCollectionSharingModal({
.map((e, i) => {
const roleLabel =
e.canCreate && e.canUpdate && e.canDelete
? "Admin"
? t("admin")
: e.canCreate && !e.canUpdate && !e.canDelete
? "Contributor"
: !e.canCreate && !e.canUpdate && !e.canDelete
? "Viewer"
: undefined;
? t("contributor")
: !e.canCreate && !e.canUpdate && !e.canDelete
? t("viewer")
: undefined;
return (
<>
<div
key={i}
className="relative p-3 bg-base-200 rounded-xl flex gap-2 justify-between border-none"
>
<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"}
>
@@ -264,6 +271,7 @@ export default function EditCollectionSharingModal({
<div
tabIndex={0}
role="button"
onMouseDown={dropdownTriggerer}
className="btn btn-sm btn-primary font-normal"
>
{roleLabel}
@@ -308,8 +316,10 @@ export default function EditCollectionSharingModal({
}}
/>
<div>
<p className="font-bold">Viewer</p>
<p>Read-only access</p>
<p className="font-bold">
{t("viewer")}
</p>
<p>{t("viewer_desc")}</p>
</div>
</label>
</li>
@@ -351,8 +361,10 @@ export default function EditCollectionSharingModal({
}}
/>
<div>
<p className="font-bold">Contributor</p>
<p>Can view and create Links</p>
<p className="font-bold">
{t("contributor")}
</p>
<p>{t("contributor_desc")}</p>
</div>
</label>
</li>
@@ -394,8 +406,10 @@ export default function EditCollectionSharingModal({
}}
/>
<div>
<p className="font-bold">Admin</p>
<p>Full access to all Links</p>
<p className="font-bold">
{t("admin")}
</p>
<p>{t("admin_desc")}</p>
</div>
</label>
</li>
@@ -412,7 +426,7 @@ export default function EditCollectionSharingModal({
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="Remove Member"
title={t("remove_member")}
onClick={() => {
const updatedMembers =
collection.members.filter((member) => {
@@ -431,7 +445,7 @@ export default function EditCollectionSharingModal({
</div>
</div>
<div className="divider my-0 last:hidden h-[3px]"></div>
</>
</React.Fragment>
);
})}
</div>
@@ -443,7 +457,7 @@ export default function EditCollectionSharingModal({
className="btn btn-accent dark:border-violet-400 text-white w-fit ml-auto mt-3"
onClick={submit}
>
Save
{t("save_changes")}
</button>
)}
</div>

View File

@@ -3,11 +3,12 @@ import CollectionSelection from "@/components/InputSelect/CollectionSelection";
import TagSelection from "@/components/InputSelect/TagSelection";
import TextInput from "@/components/TextInput";
import unescapeString from "@/lib/client/unescapeString";
import useLinkStore from "@/store/links";
import { LinkIncludingShortenedCollectionAndTags } from "@/types/global";
import toast from "react-hot-toast";
import Link from "next/link";
import Modal from "../Modal";
import { useTranslation } from "next-i18next";
import { useUpdateLink } from "@/hooks/store/links";
import toast from "react-hot-toast";
type Props = {
onClose: Function;
@@ -15,23 +16,23 @@ type Props = {
};
export default function EditLinkModal({ onClose, activeLink }: Props) {
const { t } = useTranslation();
const [link, setLink] =
useState<LinkIncludingShortenedCollectionAndTags>(activeLink);
let shortendURL;
let shortenedURL;
try {
shortendURL = new URL(link.url || "").host.toLowerCase();
shortenedURL = new URL(link.url || "").host.toLowerCase();
} catch (error) {
console.log(error);
}
const { updateLink } = useLinkStore();
const [submitLoader, setSubmitLoader] = useState(false);
const updateLink = useUpdateLink();
const setCollection = (e: any) => {
if (e?.__isNew__) e.value = null;
setLink({
...link,
collection: { id: e?.value, name: e?.label, ownerId: e?.ownerId },
@@ -39,10 +40,7 @@ export default function EditLinkModal({ onClose, activeLink }: Props) {
};
const setTags = (e: any) => {
const tagNames = e.map((e: any) => {
return { name: e.label };
});
const tagNames = e.map((e: any) => ({ name: e.label }));
setLink({ ...link, tags: tagNames });
};
@@ -54,28 +52,28 @@ export default function EditLinkModal({ onClose, activeLink }: Props) {
if (!submitLoader) {
setSubmitLoader(true);
let response;
const load = toast.loading(t("updating"));
const load = toast.loading("Updating...");
await updateLink.mutateAsync(link, {
onSettled: (data, error) => {
toast.dismiss(load);
response = await updateLink(link);
toast.dismiss(load);
if (response.ok) {
toast.success(`Updated!`);
onClose();
} else toast.error(response.data as string);
if (error) {
toast.error(error.message);
} else {
onClose();
toast.success(t("updated"));
}
},
});
setSubmitLoader(false);
return response;
}
};
return (
<Modal toggleModal={onClose}>
<p className="text-xl font-thin">Edit Link</p>
<p className="text-xl font-thin">{t("edit_link")}</p>
<div className="divider mb-3 mt-1"></div>
@@ -86,66 +84,57 @@ export default function EditLinkModal({ onClose, activeLink }: Props) {
title={link.url}
target="_blank"
>
<i className="bi-link-45deg text-xl"/>
<p>{shortendURL}</p>
<i className="bi-link-45deg text-xl" />
<p>{shortenedURL}</p>
</Link>
) : undefined}
<div className="w-full">
<p className="mb-2">Name</p>
<p className="mb-2">{t("name")}</p>
<TextInput
value={link.name}
onChange={(e) => setLink({ ...link, name: e.target.value })}
placeholder="e.g. Example Link"
placeholder={t("placeholder_example_link")}
className="bg-base-200"
/>
</div>
<div className="mt-5">
{/* <hr className="mb-3 border border-neutral-content" /> */}
<div className="grid sm:grid-cols-2 gap-3">
<div>
<p className="mb-2">Collection</p>
<p className="mb-2">{t("collection")}</p>
{link.collection.name ? (
<CollectionSelection
onChange={setCollection}
// defaultValue={{
// label: link.collection.name,
// value: link.collection.id,
// }}
defaultValue={
link.collection.id
? {
value: link.collection.id,
label: link.collection.name,
}
: {
value: null as unknown as number,
label: "Unorganized",
}
? { 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">Tags</p>
<p className="mb-2">{t("tags")}</p>
<TagSelection
onChange={setTags}
defaultValue={link.tags.map((e) => {
return { label: e.name, value: e.id };
})}
defaultValue={link.tags.map((e) => ({
label: e.name,
value: e.id,
}))}
/>
</div>
<div className="sm:col-span-2">
<p className="mb-2">Description</p>
<p className="mb-2">{t("description")}</p>
<textarea
value={unescapeString(link.description) as string}
onChange={(e) =>
setLink({ ...link, description: e.target.value })
}
placeholder="Will be auto generated if nothing is provided."
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>
@@ -157,7 +146,7 @@ export default function EditLinkModal({ onClose, activeLink }: Props) {
className="btn btn-accent dark:border-violet-400 text-white"
onClick={submit}
>
Save
{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

@@ -1,21 +1,26 @@
import React, { useEffect, useState } from "react";
import TextInput from "@/components/TextInput";
import useCollectionStore from "@/store/collections";
import toast from "react-hot-toast";
import { HexColorPicker } from "react-colorful";
import { Collection } from "@prisma/client";
import Modal from "../Modal";
import { CollectionIncludingMembersAndLinkCount } from "@/types/global";
import { 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 }: Props) {
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);
@@ -24,7 +29,8 @@ export default function NewCollectionModal({ onClose }: Props) {
}, []);
const [submitLoader, setSubmitLoader] = useState(false);
const { addCollection } = useCollectionStore();
const createCollection = useCreateCollection();
const submit = async () => {
if (submitLoader) return;
@@ -32,41 +38,61 @@ export default function NewCollectionModal({ onClose }: Props) {
setSubmitLoader(true);
const load = toast.loading("Creating...");
const load = toast.loading(t("creating"));
let response = await addCollection(collection as any);
toast.dismiss(load);
await createCollection.mutateAsync(collection, {
onSettled: (data, error) => {
toast.dismiss(load);
if (response.ok) {
toast.success("Created!");
onClose();
} else toast.error(response.data as string);
if (error) {
toast.error(error.message);
} else {
onClose();
toast.success(t("created"));
}
},
});
setSubmitLoader(false);
};
return (
<Modal toggleModal={onClose}>
<p className="text-xl font-thin">Create a New Collection</p>
{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">Name</p>
<div className="flex flex-col gap-3">
<p className="mb-2">{t("name")}</p>
<div className="flex flex-col gap-2">
<TextInput
className="bg-base-200"
value={collection.name}
placeholder="e.g. Example Collection"
placeholder={t("collection_name_placeholder")}
onChange={(e) =>
setCollection({ ...collection, name: e.target.value })
}
/>
<div>
<p className="w-full mb-2">Color</p>
<div className="color-picker flex justify-between">
<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"}
@@ -78,29 +104,22 @@ export default function NewCollectionModal({ onClose }: Props) {
setCollection({ ...collection, color: "#0ea5e9" })
}
>
Reset
{t("reset")}
</div>
</div>
<HexColorPicker
color={collection.color}
onChange={(e) => setCollection({ ...collection, color: e })}
/>
</div>
</div>
</div>
</div>
<div className="w-full">
<p className="mb-2">Description</p>
<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="The purpose of this Collection..."
placeholder={t("collection_description_placeholder")}
value={collection.description}
onChange={(e) =>
setCollection({
...collection,
description: e.target.value,
})
setCollection({ ...collection, description: e.target.value })
}
/>
</div>
@@ -110,7 +129,7 @@ export default function NewCollectionModal({ onClose }: Props) {
className="btn btn-accent dark:border-violet-400 text-white w-fit ml-auto"
onClick={submit}
>
Create Collection
{t("create_collection_button")}
</button>
</div>
</Modal>

View File

@@ -1,24 +1,24 @@
import React, { useEffect, useState } from "react";
import { Toaster } from "react-hot-toast";
import CollectionSelection from "@/components/InputSelect/CollectionSelection";
import TagSelection from "@/components/InputSelect/TagSelection";
import TextInput from "@/components/TextInput";
import unescapeString from "@/lib/client/unescapeString";
import useCollectionStore from "@/store/collections";
import useLinkStore from "@/store/links";
import { LinkIncludingShortenedCollectionAndTags } from "@/types/global";
import { useSession } from "next-auth/react";
import { useRouter } from "next/router";
import toast from "react-hot-toast";
import Modal from "../Modal";
import { useTranslation } from "next-i18next";
import { useCollections } from "@/hooks/store/collections";
import { useAddLink } from "@/hooks/store/links";
import toast from "react-hot-toast";
type Props = {
onClose: Function;
};
export default function NewLinkModal({ onClose }: Props) {
const { t } = useTranslation();
const { data } = useSession();
const initial = {
name: "",
url: "",
@@ -29,6 +29,7 @@ export default function NewLinkModal({ onClose }: Props) {
image: "",
pdf: "",
readable: "",
monolith: "",
textContent: "",
collection: {
name: "",
@@ -39,17 +40,15 @@ export default function NewLinkModal({ onClose }: Props) {
const [link, setLink] =
useState<LinkIncludingShortenedCollectionAndTags>(initial);
const { addLink } = useLinkStore();
const addLink = useAddLink();
const [submitLoader, setSubmitLoader] = useState(false);
const [optionsExpanded, setOptionsExpanded] = useState(false);
const router = useRouter();
const { collections } = useCollectionStore();
const { data: collections = [] } = useCollections();
const setCollection = (e: any) => {
if (e?.__isNew__) e.value = null;
setLink({
...link,
collection: { id: e?.value, name: e?.label, ownerId: e?.ownerId },
@@ -57,10 +56,7 @@ export default function NewLinkModal({ onClose }: Props) {
};
const setTags = (e: any) => {
const tagNames = e.map((e: any) => {
return { name: e.label };
});
const tagNames = e.map((e: any) => ({ name: e.label }));
setLink({ ...link, tags: tagNames });
};
@@ -69,7 +65,6 @@ export default function NewLinkModal({ onClose }: Props) {
const currentCollection = collections.find(
(e) => e.id == Number(router.query.id)
);
if (
currentCollection &&
currentCollection.ownerId &&
@@ -86,10 +81,7 @@ export default function NewLinkModal({ onClose }: Props) {
} else
setLink({
...initial,
collection: {
name: "Unorganized",
ownerId: data?.user.id as number,
},
collection: { name: "Unorganized", ownerId: data?.user.id as number },
});
}, []);
@@ -97,43 +89,41 @@ export default function NewLinkModal({ onClose }: Props) {
if (!submitLoader) {
setSubmitLoader(true);
let response;
const load = toast.loading(t("creating_link"));
const load = toast.loading("Creating...");
await addLink.mutateAsync(link, {
onSettled: (data, error) => {
toast.dismiss(load);
response = await addLink(link);
toast.dismiss(load);
if (response.ok) {
toast.success(`Created!`);
onClose();
} else toast.error(response.data as string);
if (error) {
toast.error(error.message);
} else {
onClose();
toast.success(t("link_created"));
}
},
});
setSubmitLoader(false);
return response;
}
};
return (
<Modal toggleModal={onClose}>
<p className="text-xl font-thin">Create a New Link</p>
<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">Link</p>
<p className="mb-2">{t("link")}</p>
<TextInput
value={link.url || ""}
onChange={(e) => setLink({ ...link, url: e.target.value })}
placeholder="e.g. http://example.com/"
placeholder={t("link_url_placeholder")}
className="bg-base-200"
/>
</div>
<div className="sm:col-span-2 col-span-5">
<p className="mb-2">Collection</p>
<p className="mb-2">{t("collection")}</p>
{link.collection.name ? (
<CollectionSelection
onChange={setCollection}
@@ -145,68 +135,57 @@ export default function NewLinkModal({ onClose }: Props) {
) : null}
</div>
</div>
<div className={"mt-2"}>
{optionsExpanded ? (
<div className="mt-5">
{/* <hr className="mb-3 border border-neutral-content" /> */}
<div className="grid sm:grid-cols-2 gap-3">
<div>
<p className="mb-2">Name</p>
<p className="mb-2">{t("name")}</p>
<TextInput
value={link.name}
onChange={(e) => setLink({ ...link, name: e.target.value })}
placeholder="e.g. Example Link"
placeholder={t("link_name_placeholder")}
className="bg-base-200"
/>
</div>
<div>
<p className="mb-2">Tags</p>
<p className="mb-2">{t("tags")}</p>
<TagSelection
onChange={setTags}
defaultValue={link.tags.map((e) => {
return { label: e.name, value: e.id };
})}
defaultValue={link.tags.map((e) => ({
label: e.name,
value: e.id,
}))}
/>
</div>
<div className="sm:col-span-2">
<p className="mb-2">Description</p>
<p className="mb-2">{t("description")}</p>
<textarea
value={unescapeString(link.description) as string}
onChange={(e) =>
setLink({ ...link, description: e.target.value })
}
placeholder="Will be auto generated if nothing is provided."
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"
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 className="font-normal">
{optionsExpanded ? "Hide" : "More"} Options
</p>
<i
className={`${
optionsExpanded ? "bi-chevron-up" : "bi-chevron-down"
}`}
></i>
<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}
>
Create Link
{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

@@ -1,8 +1,7 @@
import React, { useEffect, useState } from "react";
import useLinkStore from "@/store/links";
import {
ArchivedFormat,
LinkIncludingShortenedCollectionAndTags,
ArchivedFormat,
} from "@/types/global";
import toast from "react-hot-toast";
import Link from "next/link";
@@ -12,26 +11,26 @@ import { useSession } from "next-auth/react";
import {
pdfAvailable,
readabilityAvailable,
monolithAvailable,
screenshotAvailable,
} from "@/lib/shared/getArchiveValidity";
import PreservedFormatRow from "@/components/PreserverdFormatRow";
import useAccountStore from "@/store/account";
import getPublicUserData from "@/lib/client/getPublicUserData";
import { useTranslation } from "next-i18next";
import { BeatLoader } from "react-spinners";
import { useUser } from "@/hooks/store/user";
import { useGetLink } from "@/hooks/store/links";
type Props = {
onClose: Function;
activeLink: LinkIncludingShortenedCollectionAndTags;
link: LinkIncludingShortenedCollectionAndTags;
};
export default function PreservedFormatsModal({ onClose, activeLink }: Props) {
export default function PreservedFormatsModal({ onClose, link }: Props) {
const { t } = useTranslation();
const session = useSession();
const { getLink } = useLinkStore();
const { account } = useAccountStore();
const [link, setLink] =
useState<LinkIncludingShortenedCollectionAndTags>(activeLink);
const getLink = useGetLink();
const { data: user = {} } = useUser();
const router = useRouter();
let isPublic = router.pathname.startsWith("/public") ? true : undefined;
@@ -42,24 +41,26 @@ export default function PreservedFormatsModal({ onClose, activeLink }: Props) {
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 !== account.id) {
if (link.collection.ownerId !== user.id) {
const owner = await getPublicUserData(
link.collection.ownerId as number
);
setCollectionOwner(owner);
} else if (link.collection.ownerId === account.id) {
} else if (link.collection.ownerId === user.id) {
setCollectionOwner({
id: account.id as number,
name: account.name,
username: account.username as string,
image: account.image as string,
archiveAsScreenshot: account.archiveAsScreenshot as boolean,
archiveAsPDF: account.archiveAsPDF as boolean,
id: user.id as number,
name: user.name,
username: user.username as string,
image: user.image as string,
archiveAsScreenshot: user.archiveAsScreenshot as boolean,
archiveAsMonolith: user.archiveAsScreenshot as boolean,
archiveAsPDF: user.archiveAsPDF as boolean,
});
}
};
@@ -69,32 +70,40 @@ export default function PreservedFormatsModal({ onClose, activeLink }: Props) {
const isReady = () => {
return (
collectionOwner.archiveAsScreenshot ===
(link && link.pdf && link.pdf !== "pending") &&
collectionOwner.archiveAsPDF ===
(link && link.pdf && link.pdf !== "pending") &&
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 () => {
const data = await getLink(link.id as number, isPublic);
setLink(
(data as any).response as LinkIncludingShortenedCollectionAndTags
);
await getLink.mutateAsync(link.id as number);
})();
let interval: any;
if (!isReady()) {
interval = setInterval(async () => {
const data = await getLink(link.id as number, isPublic);
setLink(
(data as any).response as LinkIncludingShortenedCollectionAndTags
);
await getLink.mutateAsync(link.id as number);
}, 5000);
} else {
if (interval) {
@@ -107,94 +116,104 @@ export default function PreservedFormatsModal({ onClose, activeLink }: Props) {
clearInterval(interval);
}
};
}, [link?.image, link?.pdf, link?.readable]);
}, [link?.monolith]);
const updateArchive = async () => {
const load = toast.loading("Sending request...");
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) {
const newLink = await getLink(link?.id as number);
setLink(
(newLink as any).response as LinkIncludingShortenedCollectionAndTags
);
toast.success(`Link is being archived...`);
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">Preserved Formats</p>
<p className="text-xl font-thin">{t("preserved_formats")}</p>
<div className="divider mb-2 mt-1"></div>
{isReady() &&
(screenshotAvailable(link) ||
pdfAvailable(link) ||
readabilityAvailable(link)) ? (
<p className="mb-3">
The following formats are available for this link:
</p>
{screenshotAvailable(link) ||
pdfAvailable(link) ||
readabilityAvailable(link) ||
monolithAvailable(link) ? (
<p className="mb-3">{t("available_formats")}</p>
) : (
""
)}
<div className={`flex flex-col gap-3`}>
{isReady() ? (
<>
{screenshotAvailable(link) ? (
<PreservedFormatRow
name={"Screenshot"}
icon={"bi-file-earmark-image"}
format={
link?.image?.endsWith("png")
? ArchivedFormat.png
: ArchivedFormat.jpeg
}
activeLink={link}
downloadable={true}
/>
) : undefined}
{monolithAvailable(link) ? (
<PreservedFormatRow
name={t("webpage")}
icon={"bi-filetype-html"}
format={ArchivedFormat.monolith}
link={link}
downloadable={true}
/>
) : undefined}
{pdfAvailable(link) ? (
<PreservedFormatRow
name={"PDF"}
icon={"bi-file-earmark-pdf"}
format={ArchivedFormat.pdf}
activeLink={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}
{readabilityAvailable(link) ? (
<PreservedFormatRow
name={"Readable"}
icon={"bi-file-earmark-text"}
format={ArchivedFormat.readability}
activeLink={link}
/>
) : undefined}
</>
) : (
<div
className={`w-full h-full flex flex-col justify-center p-10 skeleton bg-base-200`}
>
<i className="bi-stack drop-shadow text-primary text-8xl mx-auto mb-5"></i>
<p className="text-center text-2xl">
Link preservation is in the queue
</p>
<p className="text-center text-lg">
Please check back later to see the result
</p>
{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 ${
@@ -207,26 +226,21 @@ export default function PreservedFormatsModal({ onClose, activeLink }: Props) {
""
)}`}
target="_blank"
className={`text-neutral duration-100 hover:opacity-60 flex gap-2 w-1/2 justify-center items-center text-sm`}
className="text-neutral duration-100 hover:opacity-60 flex gap-2 w-1/2 justify-center items-center text-sm"
>
<p className="whitespace-nowrap">
View latest snapshot on archive.org
</p>
<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 w-1/2 btn-outline`}
onClick={() => updateArchive()}
>
{link?.collection.ownerId === session.data?.user.id && (
<div className="btn btn-outline" onClick={updateArchive}>
<div>
<p>Refresh Preserved Formats</p>
<p>{t("refresh_preserved_formats")}</p>
<p className="text-xs">
This deletes the current preservations
{t("this_deletes_current_preservations")}
</p>
</div>
</div>
) : undefined}
)}
</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

@@ -3,22 +3,24 @@ import CollectionSelection from "@/components/InputSelect/CollectionSelection";
import TagSelection from "@/components/InputSelect/TagSelection";
import TextInput from "@/components/TextInput";
import unescapeString from "@/lib/client/unescapeString";
import useCollectionStore from "@/store/collections";
import useLinkStore from "@/store/links";
import {
ArchivedFormat,
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 = {
@@ -31,6 +33,7 @@ export default function UploadFileModal({ onClose }: Props) {
image: "",
pdf: "",
readable: "",
monolith: "",
textContent: "",
collection: {
name: "",
@@ -40,16 +43,13 @@ export default function UploadFileModal({ onClose }: Props) {
const [link, setLink] =
useState<LinkIncludingShortenedCollectionAndTags>(initial);
const [file, setFile] = useState<File>();
const { addLink } = useLinkStore();
const uploadFile = useUploadFile();
const [submitLoader, setSubmitLoader] = useState(false);
const [optionsExpanded, setOptionsExpanded] = useState(false);
const router = useRouter();
const { collections } = useCollectionStore();
const { data: collections = [] } = useCollections();
const setCollection = (e: any) => {
if (e?.__isNew__) e.value = null;
@@ -74,7 +74,6 @@ export default function UploadFileModal({ onClose }: Props) {
const currentCollection = collections.find(
(e) => e.id == Number(router.query.id)
);
if (
currentCollection &&
currentCollection.ownerId &&
@@ -91,17 +90,14 @@ export default function UploadFileModal({ onClose }: Props) {
} else
setLink({
...initial,
collection: {
name: "Unorganized",
ownerId: data?.user.id as number,
},
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" | "pdf" | null = null;
let linkType: "url" | "image" | "monolith" | "pdf" | null = null;
if (file?.type === "image/jpg" || file.type === "image/jpeg") {
fileType = ArchivedFormat.jpeg;
@@ -113,70 +109,60 @@ export default function UploadFileModal({ onClose }: Props) {
fileType = ArchivedFormat.pdf;
linkType = "pdf";
}
// else if (file.type === "text/html") {
// fileType = ArchivedFormat.monolith;
// linkType = "monolith";
// }
if (fileType !== null && linkType !== null) {
setSubmitLoader(true);
setSubmitLoader(true);
let response;
const load = toast.loading(t("creating"));
const load = toast.loading("Creating...");
await uploadFile.mutateAsync(
{ link, file },
{
onSettled: (data, error) => {
toast.dismiss(load);
response = await addLink({
...link,
type: linkType,
name: link.name ? link.name : file.name.replace(/\.[^/.]+$/, ""),
});
toast.dismiss(load);
if (response.ok) {
const formBody = new FormData();
file && formBody.append("file", file);
await fetch(
`/api/v1/archives/${
(response.data as LinkIncludingShortenedCollectionAndTags).id
}?format=${fileType}`,
{
body: formBody,
method: "POST",
if (error) {
toast.error(error.message);
} else {
onClose();
toast.success(t("created_success"));
}
);
toast.success(`Created!`);
onClose();
} else toast.error(response.data as string);
},
}
);
setSubmitLoader(false);
return response;
}
setSubmitLoader(false);
}
};
return (
<Modal toggleModal={onClose}>
<div className="flex gap-2 items-start">
<p className="text-xl font-thin">Upload File</p>
<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">File</p>
<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"
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">
PDF, PNG, JPG (Up to {process.env.NEXT_PUBLIC_MAX_FILE_SIZE || 30}
MB)
{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">Collection</p>
<p className="mb-2">{t("collection")}</p>
{link.collection.name ? (
<CollectionSelection
onChange={setCollection}
@@ -190,36 +176,34 @@ export default function UploadFileModal({ onClose }: Props) {
</div>
{optionsExpanded ? (
<div className="mt-5">
{/* <hr className="mb-3 border border-neutral-content" /> */}
<div className="grid sm:grid-cols-2 gap-3">
<div>
<p className="mb-2">Name</p>
<p className="mb-2">{t("name")}</p>
<TextInput
value={link.name}
onChange={(e) => setLink({ ...link, name: e.target.value })}
placeholder="e.g. Example Link"
placeholder={t("example_link")}
className="bg-base-200"
/>
</div>
<div>
<p className="mb-2">Tags</p>
<p className="mb-2">{t("tags")}</p>
<TagSelection
onChange={setTags}
defaultValue={link.tags.map((e) => {
return { label: e.name, value: e.id };
})}
defaultValue={link.tags.map((e) => ({
label: e.name,
value: e.id,
}))}
/>
</div>
<div className="sm:col-span-2">
<p className="mb-2">Description</p>
<p className="mb-2">{t("description")}</p>
<textarea
value={unescapeString(link.description) as string}
onChange={(e) =>
setLink({ ...link, description: e.target.value })
}
placeholder="Will be auto generated if nothing is provided."
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>
@@ -231,14 +215,15 @@ export default function UploadFileModal({ onClose }: Props) {
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 ? "Hide" : "More"} Options</p>
<p>
{optionsExpanded ? t("hide") : t("more")} {t("options")}
</p>
</div>
<button
className="btn btn-accent dark:border-violet-400 text-white"
onClick={submit}
>
Create Link
{t("upload_file")}
</button>
</div>
</Modal>

View File

@@ -1,48 +1,34 @@
import { signOut } from "next-auth/react";
import { useEffect, useState } from "react";
import ClickAwayHandler from "@/components/ClickAwayHandler";
import Sidebar from "@/components/Sidebar";
import { useRouter } from "next/router";
import SearchBar from "@/components/SearchBar";
import useAccountStore from "@/store/account";
import ProfilePhoto from "@/components/ProfilePhoto";
import useWindowDimensions from "@/hooks/useWindowDimensions";
import ToggleDarkMode from "./ToggleDarkMode";
import useLocalSettingsStore from "@/store/localSettings";
import NewLinkModal from "./ModalContent/NewLinkModal";
import NewCollectionModal from "./ModalContent/NewCollectionModal";
import Link from "next/link";
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 { settings, updateSettings } = useLocalSettingsStore();
const { account } = useAccountStore();
const { t } = useTranslation();
const router = useRouter();
const [sidebar, setSidebar] = useState(false);
const { width } = useWindowDimensions();
const handleToggle = () => {
if (settings.theme === "dark") {
updateSettings({ theme: "light" });
} else {
updateSettings({ theme: "dark" });
}
};
useEffect(() => {
setSidebar(false);
}, [width]);
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);
@@ -52,8 +38,11 @@ export default function Navbar() {
return (
<div className="flex justify-between gap-2 items-center pl-3 pr-4 py-2 border-solid border-b-neutral-content border-b">
<div
onClick={toggleSidebar}
className="text-neutral btn btn-square btn-sm btn-ghost lg:hidden"
onClick={() => {
setSidebar(true);
document.body.style.overflow = "hidden";
}}
className="text-neutral btn btn-square btn-sm btn-ghost lg:hidden sm:inline-flex"
>
<i className="bi-list text-2xl leading-none"></i>
</div>
@@ -61,11 +50,12 @@ export default function Navbar() {
<div className="flex items-center gap-2">
<ToggleDarkMode className="hidden sm:inline-grid" />
<div className="dropdown dropdown-end">
<div className="tooltip tooltip-bottom" data-tip="Create New...">
<div className="dropdown dropdown-end sm:inline-block hidden">
<div className="tooltip tooltip-bottom" data-tip={t("create_new")}>
<div
tabIndex={0}
role="button"
onMouseDown={dropdownTriggerer}
className="flex min-w-[3.4rem] items-center btn btn-accent dark:border-violet-400 text-white btn-sm max-h-[2rem] px-2 relative"
>
<span>
@@ -86,10 +76,10 @@ export default function Navbar() {
tabIndex={0}
role="button"
>
New Link
{t("new_link")}
</div>
</li>
{/* <li>
<li>
<div
onClick={() => {
(document?.activeElement as HTMLElement)?.blur();
@@ -98,9 +88,9 @@ export default function Navbar() {
tabIndex={0}
role="button"
>
Upload File
{t("upload_file")}
</div>
</li> */}
</li>
<li>
<div
onClick={() => {
@@ -110,57 +100,17 @@ export default function Navbar() {
tabIndex={0}
role="button"
>
New Collection
{t("new_collection")}
</div>
</li>
</ul>
</div>
<div className="dropdown dropdown-end">
<div tabIndex={0} role="button" className="btn btn-circle btn-ghost">
<ProfilePhoto
src={account.image ? account.image : undefined}
priority={true}
/>
</div>
<ul className="dropdown-content z-[1] menu shadow bg-base-200 border border-neutral-content rounded-box w-40 mt-1">
<li>
<Link
href="/settings/account"
onClick={() => (document?.activeElement as HTMLElement)?.blur()}
tabIndex={0}
role="button"
>
Settings
</Link>
</li>
<li>
<div
onClick={() => {
(document?.activeElement as HTMLElement)?.blur();
handleToggle();
}}
tabIndex={0}
role="button"
>
Switch to {settings.theme === "light" ? "Dark" : "Light"}
</div>
</li>
<li>
<div
onClick={() => {
(document?.activeElement as HTMLElement)?.blur();
signOut();
}}
tabIndex={0}
role="button"
>
Logout
</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}>

View File

@@ -1,11 +1,13 @@
import React, { useState } from "react";
import NewLinkModal from "./ModalContent/NewLinkModal";
import { useTranslation } from "next-i18next";
type Props = {
text?: string;
};
export default function NoLinksFound({ text }: Props) {
const { t } = useTranslation();
const [newLinkModal, setNewLinkModal] = useState(false);
return (
@@ -23,9 +25,7 @@ export default function NoLinksFound({ text }: Props) {
<p className="text-center text-xl sm:text-2xl">
{text || "You haven't created any Links Here"}
</p>
<p className="text-center text-sm sm:text-base">
Start your journey by creating a new Link!
</p>
<p className="text-center text-sm sm:text-base">{t("start_journey")}</p>
<div className="text-center w-full mt-4">
<div
onClick={() => {
@@ -35,7 +35,7 @@ export default function NoLinksFound({ text }: Props) {
>
<i className="bi-plus-lg text-3xl left-2 group-hover:ml-[4rem] absolute duration-100"></i>
<span className="group-hover:opacity-0 text-right w-full duration-100">
Create New Link
{t("create_new_link")}
</span>
</div>
</div>

View File

@@ -12,7 +12,7 @@ export default function PageHeader({
return (
<div className="flex items-center gap-3">
<i
className={`${icon} text-primary text-3xl sm:text-4xl drop-shadow`}
className={`${icon} text-primary sm:text-3xl text-2xl drop-shadow`}
></i>
<div>
<p className="text-3xl capitalize font-thin">{title}</p>

View File

@@ -1,19 +1,16 @@
import React, { useEffect, useState } from "react";
import useLinkStore from "@/store/links";
import {
ArchivedFormat,
LinkIncludingShortenedCollectionAndTags,
} from "@/types/global";
import toast from "react-hot-toast";
import Link from "next/link";
import { useRouter } from "next/router";
import { useSession } from "next-auth/react";
import { useGetLink } from "@/hooks/store/links";
type Props = {
name: string;
icon: string;
format: ArchivedFormat;
activeLink: LinkIncludingShortenedCollectionAndTags;
link: LinkIncludingShortenedCollectionAndTags;
downloadable?: boolean;
};
@@ -21,58 +18,30 @@ export default function PreservedFormatRow({
name,
icon,
format,
activeLink,
link,
downloadable,
}: Props) {
const session = useSession();
const { getLink } = useLinkStore();
const [link, setLink] =
useState<LinkIncludingShortenedCollectionAndTags>(activeLink);
const getLink = useGetLink();
const router = useRouter();
let isPublic = router.pathname.startsWith("/public") ? true : undefined;
useEffect(() => {
(async () => {
const data = await getLink(link.id as number, isPublic);
setLink(
(data as any).response as LinkIncludingShortenedCollectionAndTags
);
})();
let interval: any;
if (link?.image === "pending" || link?.pdf === "pending") {
interval = setInterval(async () => {
const data = await getLink(link.id as number, isPublic);
setLink(
(data as any).response as LinkIncludingShortenedCollectionAndTags
);
}, 5000);
} else {
if (interval) {
clearInterval(interval);
}
}
return () => {
if (interval) {
clearInterval(interval);
}
};
}, [link?.image, link?.pdf, link?.readable]);
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 link = document.createElement("a");
link.href = path;
link.download = format === ArchivedFormat.pdf ? "PDF" : "Screenshot";
link.click();
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");
}
@@ -102,9 +71,9 @@ export default function PreservedFormatRow({
) : undefined}
<Link
href={`${isPublic ? "/public" : ""}/preserved/${
link?.id
}?format=${format}`}
href={`${
isPublic ? "/public" : ""
}/preserved/${link?.id}?format=${format}`}
target="_blank"
className="btn btn-sm btn-square"
>

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

@@ -19,7 +19,7 @@ export default function ProfilePhoto({
const [image, setImage] = useState("");
useEffect(() => {
if (src && !src?.includes("base64"))
if (src && !src?.includes("base64") && !src.startsWith("http"))
setImage(`/api/v1/${src.replace("uploads/", "").replace(".jpg", "")}`);
else if (!src) setImage("");
else {
@@ -45,7 +45,7 @@ export default function ProfilePhoto({
<div
className={`avatar skeleton rounded-full drop-shadow-md ${
className || ""
} ${large || "w-8 h-8"}`}
} ${large ? "w-28 h-28" : "w-8 h-8"}`}
>
<div className="rounded-full w-full h-full ring-2 ring-neutral-content">
<Image

View File

@@ -16,14 +16,6 @@ export default function RadioButton({ label, state, onClick }: Props) {
checked={state}
onChange={onClick}
/>
{/*<FontAwesomeIcon*/}
{/* icon={faCircleCheck}*/}
{/* className="w-5 h-5 text-primary peer-checked:block hidden"*/}
{/*/>*/}
{/*<FontAwesomeIcon*/}
{/* icon={faCircle}*/}
{/* className="w-5 h-5 text-primary peer-checked:hidden block"*/}
{/*/>*/}
<span className="rounded select-none">{label}</span>
</label>
);

View File

@@ -1,9 +1,9 @@
import unescapeString from "@/lib/client/unescapeString";
import { readabilityAvailable } from "@/lib/shared/getArchiveValidity";
import isValidUrl from "@/lib/shared/isValidUrl";
import useLinkStore from "@/store/links";
import {
ArchivedFormat,
CollectionIncludingMembersAndLinkCount,
LinkIncludingShortenedCollectionAndTags,
} from "@/types/global";
import ColorThief, { RGBColor } from "colorthief";
@@ -11,7 +11,11 @@ import DOMPurify from "dompurify";
import Image from "next/image";
import Link from "next/link";
import { useRouter } from "next/router";
import React, { useEffect, useState } from "react";
import React, { useEffect, useMemo, useState } from "react";
import LinkActions from "./LinkViews/LinkComponents/LinkActions";
import { useTranslation } from "next-i18next";
import { useCollections } from "@/hooks/store/collections";
import { useGetLink } from "@/hooks/store/links";
type LinkContent = {
title: string;
@@ -30,15 +34,25 @@ type Props = {
};
export default function ReadableView({ link }: Props) {
const { t } = useTranslation();
const [linkContent, setLinkContent] = useState<LinkContent>();
const [imageError, setImageError] = useState<boolean>(false);
const [colorPalette, setColorPalette] = useState<RGBColor[]>();
const [date, setDate] = useState<Date | string>();
const colorThief = new ColorThief();
const router = useRouter();
const { links, getLink } = useLinkStore();
const getLink = useGetLink();
const { data: collections = [] } = useCollections();
const collection = useMemo(() => {
return collections.find(
(e) => e.id === link.collection.id
) as CollectionIncludingMembersAndLinkCount;
}, [collections, link]);
useEffect(() => {
const fetchLinkContent = async () => {
@@ -54,10 +68,12 @@ export default function ReadableView({ link }: Props) {
};
fetchLinkContent();
setDate(link.importDate || link.createdAt);
}, [link]);
useEffect(() => {
if (link) getLink(link?.id as number);
if (link) getLink.mutateAsync(link?.id as number);
let interval: any;
if (
@@ -65,11 +81,16 @@ export default function ReadableView({ link }: Props) {
(link?.image === "pending" ||
link?.pdf === "pending" ||
link?.readable === "pending" ||
link?.monolith === "pending" ||
!link?.image ||
!link?.pdf ||
!link?.readable)
!link?.readable ||
!link?.monolith)
) {
interval = setInterval(() => getLink(link.id as number), 5000);
interval = setInterval(
() => getLink.mutateAsync(link.id as number),
5000
);
} else {
if (interval) {
clearInterval(interval);
@@ -81,7 +102,7 @@ export default function ReadableView({ link }: Props) {
clearInterval(interval);
}
};
}, [link?.image, link?.pdf, link?.readable]);
}, [link?.image, link?.pdf, link?.readable, link?.monolith]);
const rgbToHex = (r: number, g: number, b: number): string =>
"#" +
@@ -124,10 +145,10 @@ export default function ReadableView({ link }: Props) {
}, [colorPalette]);
return (
<div className={`flex flex-col max-w-screen-md h-full mx-auto py-5`}>
<div className={`flex flex-col max-w-screen-md h-full mx-auto p-5`}>
<div
id="link-banner"
className="link-banner bg-opacity-10 border-neutral-content p-3 border mb-3"
className="link-banner relative bg-opacity-10 border-neutral-content p-3 border mb-3"
>
<div id="link-banner-inner" className="link-banner-inner"></div>
@@ -160,7 +181,7 @@ export default function ReadableView({ link }: Props) {
/>
)}
<div className="flex flex-col">
<p className="text-xl">
<p className="text-xl pr-10">
{unescapeString(
link?.name || link?.description || link?.url || ""
)}
@@ -198,7 +219,7 @@ export default function ReadableView({ link }: Props) {
{link?.collection.name}
</p>
</Link>
{link?.tags.map((e, i) => (
{link?.tags?.map((e, i) => (
<Link key={i} href={`/tags/${e.id}`} className="z-10">
<p
title={e.name}
@@ -211,8 +232,8 @@ export default function ReadableView({ link }: Props) {
</div>
<p className="min-w-fit text-sm text-neutral">
{link?.createdAt
? new Date(link?.createdAt).toLocaleString("en-US", {
{date
? new Date(date).toLocaleString("en-US", {
year: "numeric",
month: "long",
day: "numeric",
@@ -222,6 +243,13 @@ export default function ReadableView({ link }: Props) {
{link?.name ? <p>{unescapeString(link?.description)}</p> : undefined}
</div>
<LinkActions
link={link}
collection={collection}
position="top-3 right-3"
alignToTop
/>
</div>
<div className="flex flex-col gap-5 h-full">
@@ -250,11 +278,9 @@ export default function ReadableView({ link }: Props) {
<path d="m14.12 6.576 1.715.858c.22.11.22.424 0 .534l-7.568 3.784a.598.598 0 0 1-.534 0L.165 7.968a.299.299 0 0 1 0-.534l1.716-.858 5.317 2.659c.505.252 1.1.252 1.604 0l5.317-2.659z" />
</svg>
<p className="text-center text-2xl">
The Link preservation is currently in the queue
</p>
<p className="text-center text-lg mt-2">
Please check back later to see the result
{t("link_preservation_in_queue")}
</p>
<p className="text-center text-lg mt-2">{t("check_back_later")}</p>
</div>
)}
</div>

View File

@@ -47,11 +47,11 @@ export default function SearchBar({ placeholder }: Props) {
"/public/collections/" +
router.query.id +
"?q=" +
encodeURIComponent(searchQuery || ""),
encodeURIComponent(searchQuery || "")
);
} else {
return router.push(
"/search?q=" + encodeURIComponent(searchQuery),
"/search?q=" + encodeURIComponent(searchQuery)
);
}
}

View File

@@ -1,20 +1,18 @@
import useCollectionStore from "@/store/collections";
import Link from "next/link";
import { useRouter } from "next/router";
import React, { useEffect, useState } from "react";
import { useTranslation } from "next-i18next";
export default function SettingsSidebar({ className }: { className?: string }) {
const LINKWARDEN_VERSION = "v2.4.0";
const { collections } = useCollectionStore();
const { t } = useTranslation();
const LINKWARDEN_VERSION = process.env.version;
const router = useRouter();
const [active, setActive] = useState("");
useEffect(() => {
setActive(router.asPath);
}, [router, collections]);
}, [router]);
return (
<div
@@ -26,84 +24,69 @@ export default function SettingsSidebar({ className }: { className?: string }) {
<Link href="/settings/account">
<div
className={`${
active === `/settings/account`
active === "/settings/account"
? "bg-primary/20"
: "hover:bg-neutral/20"
} duration-100 py-5 px-2 cursor-pointer flex items-center gap-2 w-full rounded-md h-8`}
>
<i className="bi-person text-primary text-2xl"></i>
<p className="truncate w-full pr-7">Account</p>
<p className="truncate w-full pr-7">{t("account")}</p>
</div>
</Link>
<Link href="/settings/appearance">
<Link href="/settings/preference">
<div
className={`${
active === `/settings/appearance`
active === "/settings/preference"
? "bg-primary/20"
: "hover:bg-neutral/20"
} duration-100 py-5 px-2 cursor-pointer flex items-center gap-2 w-full rounded-md h-8`}
>
<i className="bi-palette text-primary text-2xl"></i>
<p className="truncate w-full pr-7">Appearance</p>
<i className="bi-sliders text-primary text-2xl"></i>
<p className="truncate w-full pr-7">{t("preference")}</p>
</div>
</Link>
<Link href="/settings/archive">
<Link href="/settings/access-tokens">
<div
className={`${
active === `/settings/archive`
? "bg-primary/20"
: "hover:bg-neutral/20"
} duration-100 py-5 px-2 cursor-pointer flex items-center gap-2 w-full rounded-md h-8`}
>
<i className="bi-archive text-primary text-2xl"></i>
<p className="truncate w-full pr-7">Archive</p>
</div>
</Link>
<Link href="/settings/api">
<div
className={`${
active === `/settings/api`
active === "/settings/access-tokens"
? "bg-primary/20"
: "hover:bg-neutral/20"
} duration-100 py-5 px-2 cursor-pointer flex items-center gap-2 w-full rounded-md h-8`}
>
<i className="bi-key text-primary text-2xl"></i>
<p className="truncate w-full pr-7">API Keys</p>
<p className="truncate w-full pr-7">{t("access_tokens")}</p>
</div>
</Link>
<Link href="/settings/password">
<div
className={`${
active === `/settings/password`
active === "/settings/password"
? "bg-primary/20"
: "hover:bg-neutral/20"
} duration-100 py-5 px-2 cursor-pointer flex items-center gap-2 w-full rounded-md h-8`}
>
<i className="bi-lock text-primary text-2xl"></i>
<p className="truncate w-full pr-7">Password</p>
<p className="truncate w-full pr-7">{t("password")}</p>
</div>
</Link>
{process.env.NEXT_PUBLIC_STRIPE ? (
{process.env.NEXT_PUBLIC_STRIPE && (
<Link href="/settings/billing">
<div
className={`${
active === `/settings/billing`
active === "/settings/billing"
? "bg-primary/20"
: "hover:bg-neutral/20"
} duration-100 py-5 px-2 cursor-pointer flex items-center gap-2 w-full rounded-md h-8`}
>
<i className="bi-credit-card text-primary text-xl"></i>
<p className="truncate w-full pr-7">Billing</p>
<p className="truncate w-full pr-7">{t("billing")}</p>
</div>
</Link>
) : undefined}
)}
</div>
<div className="flex flex-col gap-1">
@@ -112,42 +95,38 @@ export default function SettingsSidebar({ className }: { className?: string }) {
target="_blank"
className="text-neutral text-sm ml-2 hover:opacity-50 duration-100"
>
Linkwarden {LINKWARDEN_VERSION}
{t("linkwarden_version", { version: LINKWARDEN_VERSION })}
</Link>
<Link href="https://docs.linkwarden.app" target="_blank">
<div
className={`hover:bg-neutral/20 duration-100 py-2 px-2 cursor-pointer flex items-center gap-2 w-full rounded-md h-8`}
>
<i className="bi-question-circle text-primary text-xl"></i>
<p className="truncate w-full pr-7">Help</p>
<p className="truncate w-full pr-7">{t("help")}</p>
</div>
</Link>
<Link href="https://github.com/linkwarden/linkwarden" target="_blank">
<div
className={`hover:bg-neutral/20 duration-100 py-2 px-2 cursor-pointer flex items-center gap-2 w-full rounded-md h-8`}
>
<i className="bi-github text-primary text-xl"></i>
<p className="truncate w-full pr-7">GitHub</p>
<p className="truncate w-full pr-7">{t("github")}</p>
</div>
</Link>
<Link href="https://twitter.com/LinkwardenHQ" target="_blank">
<div
className={`hover:bg-neutral/20 duration-100 py-2 px-2 cursor-pointer flex items-center gap-2 w-full rounded-md h-8`}
>
<i className="bi-twitter-x text-primary text-xl"></i>
<p className="truncate w-full pr-7">Twitter</p>
<p className="truncate w-full pr-7">{t("twitter")}</p>
</div>
</Link>
<Link href="https://fosstodon.org/@linkwarden" target="_blank">
<div
className={`hover:bg-neutral/20 duration-100 py-2 px-2 cursor-pointer flex items-center gap-2 w-full rounded-md h-8`}
>
<i className="bi-mastodon text-primary text-xl"></i>
<p className="truncate w-full pr-7">Mastodon</p>
<p className="truncate w-full pr-7">{t("mastodon")}</p>
</div>
</Link>
</div>

View File

@@ -1,12 +1,15 @@
import useCollectionStore from "@/store/collections";
import useTagStore from "@/store/tags";
import Link from "next/link";
import { useRouter } from "next/router";
import { useEffect, useState } from "react";
import { Disclosure, Transition } from "@headlessui/react";
import SidebarHighlightLink from "@/components/SidebarHighlightLink";
import CollectionListing from "@/components/CollectionListing";
import { useTranslation } from "next-i18next";
import { useCollections } from "@/hooks/store/collections";
import { useTags } from "@/hooks/store/tags";
export default function Sidebar({ className }: { className?: string }) {
const { t } = useTranslation();
const [tagDisclosure, setTagDisclosure] = useState<boolean>(() => {
const storedValue = localStorage.getItem("tagDisclosure");
return storedValue ? storedValue === "true" : true;
@@ -19,13 +22,13 @@ export default function Sidebar({ className }: { className?: string }) {
}
);
const { collections } = useCollectionStore();
const { tags } = useTagStore();
const { data: collections } = useCollections();
const { data: tags = [], isLoading } = useTags();
const [active, setActive] = useState("");
const router = useRouter();
const [active, setActive] = useState("");
useEffect(() => {
localStorage.setItem("tagDisclosure", tagDisclosure ? "true" : "false");
}, [tagDisclosure]);
@@ -44,31 +47,31 @@ export default function Sidebar({ className }: { className?: string }) {
return (
<div
id="sidebar"
className={`bg-base-200 h-full w-72 lg:w-80 overflow-y-auto border-solid border border-base-200 border-r-neutral-content p-2 z-20 ${
className={`bg-base-200 h-full w-80 overflow-y-auto border-solid border border-base-200 border-r-neutral-content p-2 z-20 ${
className || ""
}`}
>
<div className="grid grid-cols-2 gap-2">
<SidebarHighlightLink
title={"Dashboard"}
title={t("dashboard")}
href={`/dashboard`}
icon={"bi-house"}
active={active === `/dashboard`}
/>
<SidebarHighlightLink
title={"Pinned"}
title={t("pinned")}
href={`/links/pinned`}
icon={"bi-pin-angle"}
active={active === `/links/pinned`}
/>
<SidebarHighlightLink
title={"All Links"}
title={t("all_links")}
href={`/links`}
icon={"bi-link-45deg"}
active={active === `/links`}
/>
<SidebarHighlightLink
title={"All Collections"}
title={t("all_collections")}
href={`/collections`}
icon={"bi-folder"}
active={active === `/collections`}
@@ -82,7 +85,7 @@ export default function Sidebar({ className }: { className?: string }) {
}}
className="flex items-center justify-between w-full text-left mb-2 pl-2 font-bold text-neutral mt-5"
>
<p className="text-sm">Collections</p>
<p className="text-sm">{t("collections")}</p>
<i
className={`bi-chevron-down ${
collectionDisclosure ? "rotate-reverse" : "rotate"
@@ -97,48 +100,8 @@ export default function Sidebar({ className }: { className?: string }) {
leaveFrom="transform opacity-100 translate-y-0"
leaveTo="transform opacity-0 -translate-y-3"
>
<Disclosure.Panel className="flex flex-col gap-1">
{collections[0] ? (
collections
.sort((a, b) => a.name.localeCompare(b.name))
.map((e, i) => {
return (
<Link key={i} href={`/collections/${e.id}`}>
<div
className={`${
active === `/collections/${e.id}`
? "bg-primary/20"
: "hover:bg-neutral/20"
} duration-100 py-1 px-2 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: e.color }}
></i>
<p className="truncate w-full">{e.name}</p>
{e.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">
{e._count?.links}
</div>
</div>
</Link>
);
})
) : (
<div
className={`duration-100 py-1 px-2 flex items-center gap-2 w-full rounded-md h-8 capitalize`}
>
<p className="text-neutral text-xs font-semibold truncate w-full pr-7">
You Have No Collections...
</p>
</div>
)}
<Disclosure.Panel>
<CollectionListing />
</Disclosure.Panel>
</Transition>
</Disclosure>
@@ -149,7 +112,7 @@ export default function Sidebar({ className }: { className?: string }) {
}}
className="flex items-center justify-between w-full text-left mb-2 pl-2 font-bold text-neutral mt-5"
>
<p className="text-sm">Tags</p>
<p className="text-sm">{t("tags")}</p>
<i
className={`bi-chevron-down ${
tagDisclosure ? "rotate-reverse" : "rotate"
@@ -165,10 +128,16 @@ export default function Sidebar({ className }: { className?: string }) {
leaveTo="transform opacity-0 -translate-y-3"
>
<Disclosure.Panel className="flex flex-col gap-1">
{tags[0] ? (
{isLoading ? (
<div className="flex flex-col gap-4">
<div className="skeleton h-4 w-full"></div>
<div className="skeleton h-4 w-full"></div>
<div className="skeleton h-4 w-full"></div>
</div>
) : tags[0] ? (
tags
.sort((a, b) => a.name.localeCompare(b.name))
.map((e, i) => {
.sort((a: any, b: any) => a.name.localeCompare(b.name))
.map((e: any, i: any) => {
return (
<Link key={i} href={`/tags/${e.id}`}>
<div
@@ -192,7 +161,7 @@ export default function Sidebar({ className }: { className?: string }) {
className={`duration-100 py-1 px-2 flex items-center gap-2 w-full rounded-md h-8 capitalize`}
>
<p className="text-neutral text-xs font-semibold truncate w-full pr-7">
You Have No Tags...
{t("you_have_no_tags")}
</p>
</div>
)}

View File

@@ -1,18 +1,29 @@
import React, { Dispatch, SetStateAction } from "react";
import React, { Dispatch, SetStateAction, useEffect } from "react";
import { Sort } from "@/types/global";
import { dropdownTriggerer } from "@/lib/client/utils";
import { TFunction } from "i18next";
import useLocalSettingsStore from "@/store/localSettings";
type Props = {
sortBy: Sort;
setSort: Dispatch<SetStateAction<Sort>>;
t: TFunction<"translation", undefined>;
};
export default function SortDropdown({ sortBy, setSort }: Props) {
export default function SortDropdown({ sortBy, setSort, t }: Props) {
const { updateSettings } = useLocalSettingsStore();
useEffect(() => {
updateSettings({ sortBy });
}, [sortBy]);
return (
<div className="dropdown dropdown-bottom dropdown-end">
<div
tabIndex={0}
role="button"
className="btn btn-sm btn-square btn-ghost"
onMouseDown={dropdownTriggerer}
className="btn btn-sm btn-square btn-ghost border-none"
>
<i className="bi-chevron-expand text-neutral text-2xl"></i>
</div>
@@ -27,13 +38,10 @@ export default function SortDropdown({ sortBy, setSort }: Props) {
type="radio"
name="sort-radio"
className="radio checked:bg-primary"
value="Date (Newest First)"
checked={sortBy === Sort.DateNewestFirst}
onChange={() => {
setSort(Sort.DateNewestFirst);
}}
onChange={() => setSort(Sort.DateNewestFirst)}
/>
<span className="label-text">Date (Newest First)</span>
<span className="label-text">{t("date_newest_first")}</span>
</label>
</li>
<li>
@@ -46,11 +54,10 @@ export default function SortDropdown({ sortBy, setSort }: Props) {
type="radio"
name="sort-radio"
className="radio checked:bg-primary"
value="Date (Oldest First)"
checked={sortBy === Sort.DateOldestFirst}
onChange={() => setSort(Sort.DateOldestFirst)}
/>
<span className="label-text">Date (Oldest First)</span>
<span className="label-text">{t("date_oldest_first")}</span>
</label>
</li>
<li>
@@ -63,11 +70,10 @@ export default function SortDropdown({ sortBy, setSort }: Props) {
type="radio"
name="sort-radio"
className="radio checked:bg-primary"
value="Name (A-Z)"
checked={sortBy === Sort.NameAZ}
onChange={() => setSort(Sort.NameAZ)}
/>
<span className="label-text">Name (A-Z)</span>
<span className="label-text">{t("name_az")}</span>
</label>
</li>
<li>
@@ -80,11 +86,10 @@ export default function SortDropdown({ sortBy, setSort }: Props) {
type="radio"
name="sort-radio"
className="radio checked:bg-primary"
value="Name (Z-A)"
checked={sortBy === Sort.NameZA}
onChange={() => setSort(Sort.NameZA)}
/>
<span className="label-text">Name (Z-A)</span>
<span className="label-text">{t("name_za")}</span>
</label>
</li>
<li>
@@ -97,11 +102,10 @@ export default function SortDropdown({ sortBy, setSort }: Props) {
type="radio"
name="sort-radio"
className="radio checked:bg-primary"
value="Description (A-Z)"
checked={sortBy === Sort.DescriptionAZ}
onChange={() => setSort(Sort.DescriptionAZ)}
/>
<span className="label-text">Description (A-Z)</span>
<span className="label-text">{t("description_az")}</span>
</label>
</li>
<li>
@@ -114,11 +118,10 @@ export default function SortDropdown({ sortBy, setSort }: Props) {
type="radio"
name="sort-radio"
className="radio checked:bg-primary"
value="Description (Z-A)"
checked={sortBy === Sort.DescriptionZA}
onChange={() => setSort(Sort.DescriptionZA)}
/>
<span className="label-text">Description (Z-A)</span>
<span className="label-text">{t("description_za")}</span>
</label>
</li>
</ul>

View File

@@ -8,6 +8,8 @@ type Props = {
onChange: ChangeEventHandler<HTMLInputElement>;
onKeyDown?: KeyboardEventHandler<HTMLInputElement> | undefined;
className?: string;
spellCheck?: boolean;
"data-testid"?: string;
};
export default function TextInput({
@@ -18,9 +20,13 @@ export default function TextInput({
onChange,
onKeyDown,
className,
spellCheck,
"data-testid": dataTestId,
}: Props) {
return (
<input
data-testid={dataTestId}
spellCheck={spellCheck}
autoFocus={autoFocus}
type={type ? type : "text"}
placeholder={placeholder}

View File

@@ -1,11 +1,13 @@
import useLocalSettingsStore from "@/store/localSettings";
import { useEffect, useState } from "react";
import { useTranslation } from "next-i18next";
type Props = {
className?: string;
};
export default function ToggleDarkMode({ className }: Props) {
const { t } = useTranslation();
const { settings, updateSettings } = useLocalSettingsStore();
const [theme, setTheme] = useState(localStorage.getItem("theme"));
@@ -21,7 +23,9 @@ export default function ToggleDarkMode({ className }: Props) {
return (
<div
className="tooltip tooltip-bottom"
data-tip={`Switch to ${settings.theme === "light" ? "Dark" : "Light"}`}
data-tip={t("switch_to", {
theme: settings.theme === "light" ? "Dark" : "Light",
})}
>
<label
className={`swap swap-rotate btn-square text-neutral btn btn-ghost btn-sm ${className}`}

View File

@@ -0,0 +1,87 @@
import DeleteUserModal from "@/components/ModalContent/DeleteUserModal";
import { User as U } from "@prisma/client";
import { TFunction } from "i18next";
interface User extends U {
subscriptions: {
active: boolean;
};
}
type UserModal = {
isOpen: boolean;
userId: number | null;
};
const UserListing = (
users: User[],
deleteUserModal: UserModal,
setDeleteUserModal: Function,
t: TFunction<"translation", undefined>
) => {
return (
<div className="overflow-x-auto whitespace-nowrap w-full">
<table className="table w-full">
<thead>
<tr>
<th></th>
<th>{t("username")}</th>
{process.env.NEXT_PUBLIC_EMAIL_PROVIDER === "true" && (
<th>{t("email")}</th>
)}
{process.env.NEXT_PUBLIC_STRIPE === "true" && (
<th>{t("subscribed")}</th>
)}
<th>{t("created_at")}</th>
<th></th>
</tr>
</thead>
<tbody>
{users.map((user, index) => (
<tr
key={index}
className="group hover:bg-neutral-content hover:bg-opacity-30 duration-100"
>
<td className="text-primary">{index + 1}</td>
<td>
{user.username ? user.username : <b>{t("not_available")}</b>}
</td>
{process.env.NEXT_PUBLIC_EMAIL_PROVIDER === "true" && (
<td>{user.email}</td>
)}
{process.env.NEXT_PUBLIC_STRIPE === "true" && (
<td>
{user.subscriptions?.active ? (
<i className="bi bi-check text-green-500"></i>
) : (
<i className="bi bi-x text-red-500"></i>
)}
</td>
)}
<td>{new Date(user.createdAt).toLocaleString()}</td>
<td className="relative">
<button
className="btn btn-sm btn-ghost duration-100 hidden group-hover:block absolute z-20 right-[0.35rem] top-[0.35rem]"
onClick={() =>
setDeleteUserModal({ isOpen: true, userId: user.id })
}
>
<i className="bi bi-trash"></i>
</button>
</td>
</tr>
))}
</tbody>
</table>
{deleteUserModal.isOpen && deleteUserModal.userId ? (
<DeleteUserModal
onClose={() => setDeleteUserModal({ isOpen: false, userId: null })}
userId={deleteUserModal.userId}
/>
) : null}
</div>
);
};
export default UserListing;

View File

@@ -1,11 +1,11 @@
import React, { Dispatch, SetStateAction, useEffect, useState } from "react";
import React, { Dispatch, SetStateAction, useEffect } from "react";
import useLocalSettingsStore from "@/store/localSettings";
import { ViewMode } from "@/types/global";
type Props = {
viewMode: string;
setViewMode: Dispatch<SetStateAction<string>>;
viewMode: ViewMode;
setViewMode: Dispatch<SetStateAction<ViewMode>>;
};
export default function ViewDropdown({ viewMode, setViewMode }: Props) {
@@ -19,7 +19,7 @@ export default function ViewDropdown({ viewMode, setViewMode }: Props) {
};
useEffect(() => {
updateSettings({ viewMode: viewMode as ViewMode });
updateSettings({ viewMode });
}, [viewMode]);
return (
@@ -35,6 +35,17 @@ export default function ViewDropdown({ viewMode, setViewMode }: Props) {
<i className="bi-grid w-4 h-4 text-neutral"></i>
</button>
<button
onClick={(e) => onChangeViewMode(e, ViewMode.Masonry)}
className={`btn btn-square btn-sm btn-ghost ${
viewMode == ViewMode.Masonry
? "bg-primary/20 hover:bg-primary/20"
: "hover:bg-neutral/20"
}`}
>
<i className="bi bi-columns-gap w-4 h-4 text-neutral"></i>
</button>
<button
onClick={(e) => onChangeViewMode(e, ViewMode.List)}
className={`btn btn-square btn-sm btn-ghost ${

62
components/ui/Button.tsx Normal file
View File

@@ -0,0 +1,62 @@
import { cn } from "@/lib/client/utils";
import { cva, type VariantProps } from "class-variance-authority";
const buttonVariants = cva(
"select-none relative duration-200 rounded-lg text-center w-fit flex justify-center items-center gap-2 disabled:pointer-events-none disabled:opacity-50",
{
variants: {
intent: {
accent:
"bg-accent text-white hover:bg-accent/80 border border-violet-400",
primary: "bg-primary text-primary-content hover:bg-primary/80",
secondary:
"bg-neutral-content text-secondary-foreground hover:bg-neutral-content/80 border border-neutral/30",
destructive:
"bg-error text-white hover:bg-error/80 border border-neutral/60",
outline:
"border border-input bg-background hover:bg-accent hover:text-accent-content",
ghost: "hover:bg-accent hover:text-accent-foreground",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
small: "h-7 px-2",
medium: "h-10 px-4 py-2",
large: "h-12 px-7 py-2",
full: "px-4 py-2 w-full",
icon: "h-10 w-10",
},
loading: {
true: "cursor-wait",
},
},
defaultVariants: {
intent: "primary",
size: "medium",
},
}
);
export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {}
const Button: React.FC<ButtonProps> = ({
className,
intent,
size,
children,
disabled,
loading = false,
...props
}) => (
<button
className={cn(buttonVariants({ intent, size, className }))}
disabled={loading || disabled}
{...props}
>
{children}
</button>
);
export default Button;

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

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

View File

@@ -11,7 +11,8 @@ services:
environment:
- DATABASE_URL=postgresql://postgres:${POSTGRES_PASSWORD}@postgres:5432/postgres
restart: always
image: ghcr.io/linkwarden/linkwarden:latest
# build: . # uncomment this line to build from source
image: ghcr.io/linkwarden/linkwarden:latest # comment this line to build from source
ports:
- 3000:3000
volumes:

2
e2e/.env.example Normal file
View File

@@ -0,0 +1,2 @@
TEST_USERNAME=test
TEST_PASSWORD=password

20
e2e/data/user.ts Normal file
View File

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

View File

@@ -0,0 +1,10 @@
import { Locator, Page } from "playwright";
import { BasePage } from "./page";
export class DashboardPage extends BasePage {
container: Locator;
constructor(page: Page) {
super(page);
this.container = this.page.getByTestId("dashboard-wrapper");
}
}

View File

@@ -0,0 +1,45 @@
import { Locator, Page } from "@playwright/test";
export class BaseModal {
page: Page;
container: Locator;
mobileContainer: Locator;
closeModalButton: Locator;
mobileModalSlider: Locator;
constructor(page: Page) {
this.page = page;
this.container = page.getByTestId("modal-container");
this.mobileContainer = page.getByTestId("mobile-modal-container");
this.closeModalButton = this.container.getByTestId("close-modal-button");
this.mobileModalSlider = this.mobileContainer.getByTestId(
"mobile-modal-slider"
);
}
async close() {
if (await this.container.isVisible()) {
await this.closeModalButton.click();
}
if (await this.mobileContainer.isVisible()) {
const box = await this.mobileModalSlider.boundingBox();
if (!box) {
return;
}
const pageHeight = await this.page.evaluate(() => window.innerHeight);
const startX = box.x + box.width / 2;
const startY = box.y + box.height / 2;
await this.page.mouse.move(startX, startY);
await this.page.mouse.down();
await this.page.mouse.move(startX, startY + pageHeight / 2);
await this.page.mouse.up();
}
}
async isOpen() {
return (
(await this.container.isVisible()) ||
(await this.mobileContainer.isVisible())
);
}
}

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