Compare commits

..

331 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
Daniel bc46f6f64b Merge pull request #407 from linkwarden/dev
updated .env.sample
2024-01-05 14:23:16 -05:00
Daniel 07eb242c26 Merge pull request #400 from linkwarden/dev
updated version
2024-01-02 15:15:41 -05:00
Daniel f71acd86df Merge pull request #399 from linkwarden/dev
bug fixed + improved docker image
2024-01-02 15:12:56 -05:00
336 changed files with 14616 additions and 7122 deletions
+49 -7
View File
@@ -15,11 +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=
@@ -33,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=
@@ -65,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=
@@ -72,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=
@@ -166,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=
+143
View File
@@ -0,0 +1,143 @@
name: Linkwarden Playwright Tests
on:
push:
branches:
- main
- qacomet/**
pull_request:
workflow_dispatch:
env:
PGHOST: localhost
PGPORT: 5432
PGUSER: postgres
PGPASSWORD: password
PGDATABASE: postgres
TEST_POSTGRES_USER: test_linkwarden_user
TEST_POSTGRES_PASSWORD: password
TEST_POSTGRES_DATABASE: test_linkwarden_db
TEST_POSTGRES_DATABASE_TEMPLATE: test_linkwarden_db_template
TEST_POSTGRES_HOST: localhost
TEST_POSTGREST_PORT: 5432
PRODUCTION_POSTGRES_DATABASE: linkwarden_db
NEXTAUTH_SECRET: very_sensitive_secret
NEXTAUTH_URL: http://localhost:3000/api/v1/auth
# Manual installation database settings
DATABASE_URL: postgresql://test_linkwarden_user:password@localhost:5432/test_linkwarden_db
# Docker installation database settings
POSTGRES_PASSWORD: password
TEST_USERNAME: test-user
TEST_PASSWORD: password
jobs:
playwright-test-runner:
strategy:
matrix:
test_case: ['@login']
timeout-minutes: 20
runs-on:
- ubuntu-22.04
services:
postgres:
image: postgres:16-alpine
env:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: password
POSTGRES_DB: postgres
ports:
- 5432:5432
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
steps:
- uses: actions/checkout@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
+8 -1
View File
@@ -42,8 +42,15 @@ prisma/dev.db
# tests
/tests
/test-results/
/blob-report/
/playwright-report/
/playwright/.cache/
/playwright/.auth/
# docker
pgdata
pgdata
certificates
/test-results/
/playwright-report/
/blob-report/
/playwright/.cache/
+1 -1
View File
@@ -1,6 +1,6 @@
node_modules
.next
public
/public
*.lock
*.log
+6 -1
View File
@@ -1 +1,6 @@
{}
{
"tailwindCSS.experimental.classRegex": [
["cva\\(([^)]*)\\)", "[\"'`]([^\"'`]*).*?[\"'`]"],
["cx\\(([^)]*)\\)", "(?:'|\"|`)([^']*)(?:'|\"|`)"]
]
}
+45
View File
@@ -0,0 +1,45 @@
# Architecture
This is a summary of the architecture of Linkwarden. It's intended as a primer for collaborators to get a high-level understanding of the project.
When you start Linkwarden, there are mainly two components that run:
- The NextJS app, This is the main app and it's responsible for serving the frontend and handling the API routes.
- [The Background Worker](https://github.com/linkwarden/linkwarden/blob/main/scripts/worker.ts), This is a separate `ts-node` process that runs in the background and is responsible for archiving links.
## Main Tech Stack
- [NextJS](https://github.com/vercel/next.js)
- [TypeScript](https://github.com/microsoft/TypeScript)
- [Tailwind](https://github.com/tailwindlabs/tailwindcss)
- [DaisyUI](https://github.com/saadeghi/daisyui)
- [Prisma](https://github.com/prisma/prisma)
- [Playwright](https://github.com/microsoft/playwright)
- [Zustand](https://github.com/pmndrs/zustand)
## Folder Structure
Here's a summary of the main files and folders in the project:
```
linkwarden
├── components # React components
├── hooks # React reusable hooks
├── layouts # Layouts for pages
├── lib
│   ├── api # Server-side functions (controllers, etc.)
│   ├── client # Client-side functions
│   └── shared # Shared functions between client and server
├── pages # Pages and API routes
├── prisma # Prisma schema and migrations
├── scripts
│   ├── migration # Scripts for breaking changes
│   └── worker.ts # Background worker for archiving links
├── store # Zustand stores
├── styles # Styles
└── types # TypeScript types
```
## Versioning
We use semantic versioning for the project. You can track the changes from the [Releases](https://github.com/linkwarden/linkwarden/releases).
+19 -2
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 --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
+6 -2
View File
@@ -57,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.
@@ -70,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 ⭐
-101
View File
@@ -1,101 +0,0 @@
# Using Android gitignore template: https://github.com/github/gitignore/blob/HEAD/Android.gitignore
# Built application files
*.apk
*.aar
*.ap_
*.aab
# Files for the ART/Dalvik VM
*.dex
# Java class files
*.class
# Generated files
bin/
gen/
out/
# Uncomment the following line in case you need and you don't have the release build type files in your app
# release/
# Gradle files
.gradle/
build/
# Local configuration file (sdk path, etc)
local.properties
# Proguard folder generated by Eclipse
proguard/
# Log Files
*.log
# Android Studio Navigation editor temp files
.navigation/
# Android Studio captures folder
captures/
# IntelliJ
*.iml
.idea/workspace.xml
.idea/tasks.xml
.idea/gradle.xml
.idea/assetWizardSettings.xml
.idea/dictionaries
.idea/libraries
# Android Studio 3 in .gitignore file.
.idea/caches
.idea/modules.xml
# Comment next line if keeping position of elements in Navigation Editor is relevant for you
.idea/navEditor.xml
# Keystore files
# Uncomment the following lines if you do not want to check your keystore files in.
#*.jks
#*.keystore
# External native build folder generated in Android Studio 2.2 and later
.externalNativeBuild
.cxx/
# Google Services (e.g. APIs or Firebase)
# google-services.json
# Freeline
freeline.py
freeline/
freeline_project_description.json
# fastlane
fastlane/report.xml
fastlane/Preview.html
fastlane/screenshots
fastlane/test_output
fastlane/readme.md
# Version control
vcs.xml
# lint
lint/intermediates/
lint/generated/
lint/outputs/
lint/tmp/
# lint/reports/
# Android Profiling
*.hprof
# Cordova plugins for Capacitor
capacitor-cordova-android-plugins
# Copied web assets
app/src/main/assets/public
# Generated Config files
app/src/main/assets/capacitor.config.json
app/src/main/assets/capacitor.plugins.json
app/src/main/res/xml/config.xml
-2
View File
@@ -1,2 +0,0 @@
/build/*
!/build/.npmkeep
-54
View File
@@ -1,54 +0,0 @@
apply plugin: 'com.android.application'
android {
namespace "com.example.app"
compileSdkVersion rootProject.ext.compileSdkVersion
defaultConfig {
applicationId "com.example.app"
minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion
versionCode 1
versionName "1.0"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
aaptOptions {
// Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.
// Default: https://android.googlesource.com/platform/frameworks/base/+/282e181b58cf72b6ca770dc7ca5f91f135444502/tools/aapt/AaptAssets.cpp#61
ignoreAssetsPattern '!.svn:!.git:!.ds_store:!*.scc:.*:!CVS:!thumbs.db:!picasa.ini:!*~'
}
}
buildTypes {
release {
minifyEnabled false
proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro'
}
}
}
repositories {
flatDir{
dirs '../capacitor-cordova-android-plugins/src/main/libs', 'libs'
}
}
dependencies {
implementation fileTree(include: ['*.jar'], dir: 'libs')
implementation "androidx.appcompat:appcompat:$androidxAppCompatVersion"
implementation "androidx.coordinatorlayout:coordinatorlayout:$androidxCoordinatorLayoutVersion"
implementation "androidx.core:core-splashscreen:$coreSplashScreenVersion"
implementation project(':capacitor-android')
testImplementation "junit:junit:$junitVersion"
androidTestImplementation "androidx.test.ext:junit:$androidxJunitVersion"
androidTestImplementation "androidx.test.espresso:espresso-core:$androidxEspressoCoreVersion"
implementation project(':capacitor-cordova-android-plugins')
}
apply from: 'capacitor.build.gradle'
try {
def servicesJSON = file('google-services.json')
if (servicesJSON.text) {
apply plugin: 'com.google.gms.google-services'
}
} catch(Exception e) {
logger.info("google-services.json not found, google-services plugin not applied. Push Notifications won't work")
}
-19
View File
@@ -1,19 +0,0 @@
// DO NOT EDIT THIS FILE! IT IS GENERATED EACH TIME "capacitor update" IS RUN
android {
compileOptions {
sourceCompatibility JavaVersion.VERSION_17
targetCompatibility JavaVersion.VERSION_17
}
}
apply from: "../capacitor-cordova-android-plugins/cordova.variables.gradle"
dependencies {
}
if (hasProperty('postBuildExtras')) {
postBuildExtras()
}
-21
View File
@@ -1,21 +0,0 @@
# Add project specific ProGuard rules here.
# You can control the set of applied configuration files using the
# proguardFiles setting in build.gradle.
#
# For more details, see
# http://developer.android.com/guide/developing/tools/proguard.html
# If your project uses WebView with JS, uncomment the following
# and specify the fully qualified class name to the JavaScript interface
# class:
#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
# public *;
#}
# Uncomment this to preserve the line number information for
# debugging stack traces.
#-keepattributes SourceFile,LineNumberTable
# If you keep the line number information, uncomment this to
# hide the original source file name.
#-renamesourcefileattribute SourceFile
@@ -1,26 +0,0 @@
package com.getcapacitor.myapp;
import static org.junit.Assert.*;
import android.content.Context;
import androidx.test.ext.junit.runners.AndroidJUnit4;
import androidx.test.platform.app.InstrumentationRegistry;
import org.junit.Test;
import org.junit.runner.RunWith;
/**
* Instrumented test, which will execute on an Android device.
*
* @see <a href="http://d.android.com/tools/testing">Testing documentation</a>
*/
@RunWith(AndroidJUnit4.class)
public class ExampleInstrumentedTest {
@Test
public void useAppContext() throws Exception {
// Context of the app under test.
Context appContext = InstrumentationRegistry.getInstrumentation().getTargetContext();
assertEquals("com.getcapacitor.app", appContext.getPackageName());
}
}
-41
View File
@@ -1,41 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<application
android:allowBackup="true"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:theme="@style/AppTheme">
<activity
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|locale|smallestScreenSize|screenLayout|uiMode"
android:name=".MainActivity"
android:label="@string/title_activity_main"
android:theme="@style/AppTheme.NoActionBarLaunch"
android:launchMode="singleTask"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
<provider
android:name="androidx.core.content.FileProvider"
android:authorities="${applicationId}.fileprovider"
android:exported="false"
android:grantUriPermissions="true">
<meta-data
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/file_paths"></meta-data>
</provider>
</application>
<!-- Permissions -->
<uses-permission android:name="android.permission.INTERNET" />
</manifest>
@@ -1,5 +0,0 @@
package com.example.app;
import com.getcapacitor.BridgeActivity;
public class MainActivity extends BridgeActivity {}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

@@ -1,34 +0,0 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:aapt="http://schemas.android.com/aapt"
android:width="108dp"
android:height="108dp"
android:viewportHeight="108"
android:viewportWidth="108">
<path
android:fillType="evenOdd"
android:pathData="M32,64C32,64 38.39,52.99 44.13,50.95C51.37,48.37 70.14,49.57 70.14,49.57L108.26,87.69L108,109.01L75.97,107.97L32,64Z"
android:strokeColor="#00000000"
android:strokeWidth="1">
<aapt:attr name="android:fillColor">
<gradient
android:endX="78.5885"
android:endY="90.9159"
android:startX="48.7653"
android:startY="61.0927"
android:type="linear">
<item
android:color="#44000000"
android:offset="0.0" />
<item
android:color="#00000000"
android:offset="1.0" />
</gradient>
</aapt:attr>
</path>
<path
android:fillColor="#FFFFFF"
android:fillType="nonZero"
android:pathData="M66.94,46.02L66.94,46.02C72.44,50.07 76,56.61 76,64L32,64C32,56.61 35.56,50.11 40.98,46.06L36.18,41.19C35.45,40.45 35.45,39.3 36.18,38.56C36.91,37.81 38.05,37.81 38.78,38.56L44.25,44.05C47.18,42.57 50.48,41.71 54,41.71C57.48,41.71 60.78,42.57 63.68,44.05L69.11,38.56C69.84,37.81 70.98,37.81 71.71,38.56C72.44,39.3 72.44,40.45 71.71,41.19L66.94,46.02ZM62.94,56.92C64.08,56.92 65,56.01 65,54.88C65,53.76 64.08,52.85 62.94,52.85C61.8,52.85 60.88,53.76 60.88,54.88C60.88,56.01 61.8,56.92 62.94,56.92ZM45.06,56.92C46.2,56.92 47.13,56.01 47.13,54.88C47.13,53.76 46.2,52.85 45.06,52.85C43.92,52.85 43,53.76 43,54.88C43,56.01 43.92,56.92 45.06,56.92Z"
android:strokeColor="#00000000"
android:strokeWidth="1" />
</vector>
@@ -1,170 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportHeight="108"
android:viewportWidth="108">
<path
android:fillColor="#26A69A"
android:pathData="M0,0h108v108h-108z" />
<path
android:fillColor="#00000000"
android:pathData="M9,0L9,108"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M19,0L19,108"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M29,0L29,108"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M39,0L39,108"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M49,0L49,108"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M59,0L59,108"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M69,0L69,108"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M79,0L79,108"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M89,0L89,108"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M99,0L99,108"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M0,9L108,9"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M0,19L108,19"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M0,29L108,29"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M0,39L108,39"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M0,49L108,49"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M0,59L108,59"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M0,69L108,69"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M0,79L108,79"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M0,89L108,89"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M0,99L108,99"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M19,29L89,29"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M19,39L89,39"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M19,49L89,49"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M19,59L89,59"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M19,69L89,69"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M19,79L89,79"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M29,19L29,89"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M39,19L39,89"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M49,19L49,89"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M59,19L59,89"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M69,19L69,89"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M79,19L79,89"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
</vector>
Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.9 KiB

@@ -1,12 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.coordinatorlayout.widget.CoordinatorLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:context=".MainActivity">
<WebView
android:layout_width="match_parent"
android:layout_height="match_parent" />
</androidx.coordinatorlayout.widget.CoordinatorLayout>
@@ -1,5 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/ic_launcher_background"/>
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
</adaptive-icon>
@@ -1,5 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/ic_launcher_background"/>
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
</adaptive-icon>
Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

@@ -1,4 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="ic_launcher_background">#FFFFFF</color>
</resources>
@@ -1,7 +0,0 @@
<?xml version='1.0' encoding='utf-8'?>
<resources>
<string name="app_name">linkwarden</string>
<string name="title_activity_main">linkwarden</string>
<string name="package_name">com.example.app</string>
<string name="custom_url_scheme">com.example.app</string>
</resources>
@@ -1,22 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- Base application theme. -->
<style name="AppTheme" parent="Theme.AppCompat.Light.DarkActionBar">
<!-- Customize your theme here. -->
<item name="colorPrimary">@color/colorPrimary</item>
<item name="colorPrimaryDark">@color/colorPrimaryDark</item>
<item name="colorAccent">@color/colorAccent</item>
</style>
<style name="AppTheme.NoActionBar" parent="Theme.AppCompat.DayNight.NoActionBar">
<item name="windowActionBar">false</item>
<item name="windowNoTitle">true</item>
<item name="android:background">@null</item>
</style>
<style name="AppTheme.NoActionBarLaunch" parent="Theme.SplashScreen">
<item name="android:background">@drawable/splash</item>
</style>
</resources>
@@ -1,5 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<paths xmlns:android="http://schemas.android.com/apk/res/android">
<external-path name="my_images" path="." />
<cache-path name="my_cache_images" path="." />
</paths>
@@ -1,18 +0,0 @@
package com.getcapacitor.myapp;
import static org.junit.Assert.*;
import org.junit.Test;
/**
* Example local unit test, which will execute on the development machine (host).
*
* @see <a href="http://d.android.com/tools/testing">Testing documentation</a>
*/
public class ExampleUnitTest {
@Test
public void addition_isCorrect() throws Exception {
assertEquals(4, 2 + 2);
}
}
-29
View File
@@ -1,29 +0,0 @@
// Top-level build file where you can add configuration options common to all sub-projects/modules.
buildscript {
repositories {
google()
mavenCentral()
}
dependencies {
classpath 'com.android.tools.build:gradle:8.0.0'
classpath 'com.google.gms:google-services:4.3.15'
// NOTE: Do not place your application dependencies here; they belong
// in the individual module build.gradle files
}
}
apply from: "variables.gradle"
allprojects {
repositories {
google()
mavenCentral()
}
}
task clean(type: Delete) {
delete rootProject.buildDir
}
-3
View File
@@ -1,3 +0,0 @@
// DO NOT EDIT THIS FILE! IT IS GENERATED EACH TIME "capacitor update" IS RUN
include ':capacitor-android'
project(':capacitor-android').projectDir = new File('../node_modules/@capacitor/android/capacitor')
-22
View File
@@ -1,22 +0,0 @@
# Project-wide Gradle settings.
# IDE (e.g. Android Studio) users:
# Gradle settings configured through the IDE *will override*
# any settings specified in this file.
# For more details on how to configure your build environment visit
# http://www.gradle.org/docs/current/userguide/build_environment.html
# Specifies the JVM arguments used for the daemon process.
# The setting is particularly useful for tweaking memory settings.
org.gradle.jvmargs=-Xmx1536m
# When configured, Gradle will run in incubating parallel mode.
# This option should only be used with decoupled projects. More details, visit
# http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects
# org.gradle.parallel=true
# AndroidX package structure to make it clearer which packages are bundled with the
# Android operating system, and which are packaged with your app's APK
# https://developer.android.com/topic/libraries/support-library/androidx-rn
android.useAndroidX=true
Binary file not shown.
-6
View File
@@ -1,6 +0,0 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.0.2-all.zip
networkTimeout=10000
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
-244
View File
@@ -1,244 +0,0 @@
#!/bin/sh
#
# Copyright © 2015-2021 the original authors.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# https://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#
##############################################################################
#
# Gradle start up script for POSIX generated by Gradle.
#
# Important for running:
#
# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
# noncompliant, but you have some other compliant shell such as ksh or
# bash, then to run this script, type that shell name before the whole
# command line, like:
#
# ksh Gradle
#
# Busybox and similar reduced shells will NOT work, because this script
# requires all of these POSIX shell features:
# * functions;
# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
# * compound commands having a testable exit status, especially «case»;
# * various built-in commands including «command», «set», and «ulimit».
#
# Important for patching:
#
# (2) This script targets any POSIX shell, so it avoids extensions provided
# by Bash, Ksh, etc; in particular arrays are avoided.
#
# The "traditional" practice of packing multiple parameters into a
# space-separated string is a well documented source of bugs and security
# problems, so this is (mostly) avoided, by progressively accumulating
# options in "$@", and eventually passing that to Java.
#
# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
# see the in-line comments for details.
#
# There are tweaks for specific operating systems such as AIX, CygWin,
# Darwin, MinGW, and NonStop.
#
# (3) This script is generated from the Groovy template
# https://github.com/gradle/gradle/blob/HEAD/subprojects/plugins/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
# within the Gradle project.
#
# You can find Gradle at https://github.com/gradle/gradle/.
#
##############################################################################
# Attempt to set APP_HOME
# Resolve links: $0 may be a link
app_path=$0
# Need this for daisy-chained symlinks.
while
APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
[ -h "$app_path" ]
do
ls=$( ls -ld "$app_path" )
link=${ls#*' -> '}
case $link in #(
/*) app_path=$link ;; #(
*) app_path=$APP_HOME$link ;;
esac
done
# This is normally unused
# shellcheck disable=SC2034
APP_BASE_NAME=${0##*/}
APP_HOME=$( cd "${APP_HOME:-./}" && pwd -P ) || exit
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
# Use the maximum available, or set MAX_FD != -1 to use that value.
MAX_FD=maximum
warn () {
echo "$*"
} >&2
die () {
echo
echo "$*"
echo
exit 1
} >&2
# OS specific support (must be 'true' or 'false').
cygwin=false
msys=false
darwin=false
nonstop=false
case "$( uname )" in #(
CYGWIN* ) cygwin=true ;; #(
Darwin* ) darwin=true ;; #(
MSYS* | MINGW* ) msys=true ;; #(
NONSTOP* ) nonstop=true ;;
esac
CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
# Determine the Java command to use to start the JVM.
if [ -n "$JAVA_HOME" ] ; then
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
# IBM's JDK on AIX uses strange locations for the executables
JAVACMD=$JAVA_HOME/jre/sh/java
else
JAVACMD=$JAVA_HOME/bin/java
fi
if [ ! -x "$JAVACMD" ] ; then
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
else
JAVACMD=java
which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
# Increase the maximum file descriptors if we can.
if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
case $MAX_FD in #(
max*)
# In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC3045
MAX_FD=$( ulimit -H -n ) ||
warn "Could not query maximum file descriptor limit"
esac
case $MAX_FD in #(
'' | soft) :;; #(
*)
# In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC3045
ulimit -n "$MAX_FD" ||
warn "Could not set maximum file descriptor limit to $MAX_FD"
esac
fi
# Collect all arguments for the java command, stacking in reverse order:
# * args from the command line
# * the main class name
# * -classpath
# * -D...appname settings
# * --module-path (only if needed)
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
# For Cygwin or MSYS, switch paths to Windows format before running java
if "$cygwin" || "$msys" ; then
APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" )
JAVACMD=$( cygpath --unix "$JAVACMD" )
# Now convert the arguments - kludge to limit ourselves to /bin/sh
for arg do
if
case $arg in #(
-*) false ;; # don't mess with options #(
/?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
[ -e "$t" ] ;; #(
*) false ;;
esac
then
arg=$( cygpath --path --ignore --mixed "$arg" )
fi
# Roll the args list around exactly as many times as the number of
# args, so each arg winds up back in the position where it started, but
# possibly modified.
#
# NB: a `for` loop captures its iteration list before it begins, so
# changing the positional parameters here affects neither the number of
# iterations, nor the values presented in `arg`.
shift # remove old arg
set -- "$@" "$arg" # push replacement arg
done
fi
# Collect all arguments for the java command;
# * $DEFAULT_JVM_OPTS, $JAVA_OPTS, and $GRADLE_OPTS can contain fragments of
# shell script including quotes and variable substitutions, so put them in
# double quotes to make sure that they get re-expanded; and
# * put everything else in single quotes, so that it's not re-expanded.
set -- \
"-Dorg.gradle.appname=$APP_BASE_NAME" \
-classpath "$CLASSPATH" \
org.gradle.wrapper.GradleWrapperMain \
"$@"
# Stop when "xargs" is not available.
if ! command -v xargs >/dev/null 2>&1
then
die "xargs is not available"
fi
# Use "xargs" to parse quoted args.
#
# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
#
# In Bash we could simply go:
#
# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
# set -- "${ARGS[@]}" "$@"
#
# but POSIX shell has neither arrays nor command substitution, so instead we
# post-process each arg (as a line of input to sed) to backslash-escape any
# character that might be a shell metacharacter, then use eval to reverse
# that process (while maintaining the separation between arguments), and wrap
# the whole thing up as a single "set" statement.
#
# This will of course break if any of these variables contains a newline or
# an unmatched quote.
#
eval "set -- $(
printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
xargs -n1 |
sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
tr '\n' ' '
)" '"$@"'
exec "$JAVACMD" "$@"
-92
View File
@@ -1,92 +0,0 @@
@rem
@rem Copyright 2015 the original author or authors.
@rem
@rem Licensed under the Apache License, Version 2.0 (the "License");
@rem you may not use this file except in compliance with the License.
@rem You may obtain a copy of the License at
@rem
@rem https://www.apache.org/licenses/LICENSE-2.0
@rem
@rem Unless required by applicable law or agreed to in writing, software
@rem distributed under the License is distributed on an "AS IS" BASIS,
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
@rem See the License for the specific language governing permissions and
@rem limitations under the License.
@rem
@if "%DEBUG%"=="" @echo off
@rem ##########################################################################
@rem
@rem Gradle startup script for Windows
@rem
@rem ##########################################################################
@rem Set local scope for the variables with windows NT shell
if "%OS%"=="Windows_NT" setlocal
set DIRNAME=%~dp0
if "%DIRNAME%"=="" set DIRNAME=.
@rem This is normally unused
set APP_BASE_NAME=%~n0
set APP_HOME=%DIRNAME%
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
@rem Find java.exe
if defined JAVA_HOME goto findJavaFromJavaHome
set JAVA_EXE=java.exe
%JAVA_EXE% -version >NUL 2>&1
if %ERRORLEVEL% equ 0 goto execute
echo.
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
echo.
echo Please set the JAVA_HOME variable in your environment to match the
echo location of your Java installation.
goto fail
:findJavaFromJavaHome
set JAVA_HOME=%JAVA_HOME:"=%
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
if exist "%JAVA_EXE%" goto execute
echo.
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME%
echo.
echo Please set the JAVA_HOME variable in your environment to match the
echo location of your Java installation.
goto fail
:execute
@rem Setup the command line
set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
@rem Execute Gradle
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %*
:end
@rem End local scope for the variables with windows NT shell
if %ERRORLEVEL% equ 0 goto mainEnd
:fail
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
rem the _cmd.exe /c_ return code!
set EXIT_CODE=%ERRORLEVEL%
if %EXIT_CODE% equ 0 set EXIT_CODE=1
if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
exit /b %EXIT_CODE%
:mainEnd
if "%OS%"=="Windows_NT" endlocal
:omega
-5
View File
@@ -1,5 +0,0 @@
include ':app'
include ':capacitor-cordova-android-plugins'
project(':capacitor-cordova-android-plugins').projectDir = new File('./capacitor-cordova-android-plugins/')
apply from: 'capacitor.settings.gradle'
-16
View File
@@ -1,16 +0,0 @@
ext {
minSdkVersion = 22
compileSdkVersion = 33
targetSdkVersion = 33
androidxActivityVersion = '1.7.0'
androidxAppCompatVersion = '1.6.1'
androidxCoordinatorLayoutVersion = '1.2.0'
androidxCoreVersion = '1.10.0'
androidxFragmentVersion = '1.5.6'
coreSplashScreenVersion = '1.0.0'
androidxWebkitVersion = '1.6.1'
junitVersion = '4.13.2'
androidxJunitVersion = '1.1.5'
androidxEspressoCoreVersion = '3.5.1'
cordovaAndroidVersion = '10.1.1'
}
-19
View File
@@ -1,19 +0,0 @@
import { CapacitorConfig } from "@capacitor/cli";
const config: CapacitorConfig = {
appId: "com.example.app",
appName: "linkwarden",
webDir: "out",
server: {
androidScheme: "https",
url: "http://localhost:3000",
cleartext: true,
},
plugins: {
CapacitorHttp: {
enabled: true,
},
},
};
export default config;
-29
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>
);
}
+39
View File
@@ -0,0 +1,39 @@
import Link from "next/link";
import React, { MouseEventHandler } from "react";
import { Trans } from "next-i18next";
type Props = {
toggleAnnouncementBar: MouseEventHandler<HTMLButtonElement>;
};
export default function Announcement({ toggleAnnouncementBar }: Props) {
const announcementId = localStorage.getItem("announcementId");
return (
<div className="fixed mx-auto bottom-20 sm:bottom-10 w-full pointer-events-none p-5 z-30">
<div className="mx-auto pointer-events-auto p-2 flex justify-between gap-2 items-center border border-primary shadow-xl rounded-xl bg-base-300 backdrop-blur-sm bg-opacity-80 max-w-md">
<i className="bi-stars text-2xl text-yellow-600 dark:text-yellow-500"></i>
<p className="w-4/5 text-center text-sm sm:text-base">
<Trans
i18nKey="new_version_announcement"
values={{ version: announcementId }}
components={[
<Link
href={`https://blog.linkwarden.app/releases/${announcementId}`}
target="_blank"
className="underline"
key={0}
/>,
]}
/>
</p>
<button
onClick={toggleAnnouncementBar}
className="btn btn-ghost btn-square btn-sm"
>
<i className="bi-x text-xl"></i>
</button>
</div>
</div>
);
}
-33
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>
);
}
+25 -6
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);
+27 -17
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}
+388
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 };
};
+3 -3
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>
);
+44 -39
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,16 +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>
</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"
@@ -36,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>
@@ -54,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>
@@ -72,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>
@@ -114,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>
+91 -17
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"
/>
);
}
}
+3 -3
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
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;
+217
View File
@@ -0,0 +1,217 @@
import React, { Dispatch, SetStateAction, useEffect, useState } from "react";
import FilterSearchDropdown from "./FilterSearchDropdown";
import SortDropdown from "./SortDropdown";
import ViewDropdown from "./ViewDropdown";
import { TFunction } from "i18next";
import BulkDeleteLinksModal from "./ModalContent/BulkDeleteLinksModal";
import BulkEditLinksModal from "./ModalContent/BulkEditLinksModal";
import useCollectivePermissions from "@/hooks/useCollectivePermissions";
import { useRouter } from "next/router";
import useLinkStore from "@/store/links";
import { Sort, ViewMode } from "@/types/global";
import { useBulkDeleteLinks, useLinks } from "@/hooks/store/links";
import toast from "react-hot-toast";
type Props = {
children: React.ReactNode;
t: TFunction<"translation", undefined>;
viewMode: ViewMode;
setViewMode: Dispatch<SetStateAction<ViewMode>>;
searchFilter?: {
name: boolean;
url: boolean;
description: boolean;
tags: boolean;
textContent: boolean;
};
setSearchFilter?: (filter: {
name: boolean;
url: boolean;
description: boolean;
tags: boolean;
textContent: boolean;
}) => void;
sortBy: Sort;
setSortBy: Dispatch<SetStateAction<Sort>>;
editMode?: boolean;
setEditMode?: (mode: boolean) => void;
};
const LinkListOptions = ({
children,
t,
viewMode,
setViewMode,
searchFilter,
setSearchFilter,
sortBy,
setSortBy,
editMode,
setEditMode,
}: Props) => {
const { selectedLinks, setSelectedLinks } = useLinkStore();
const deleteLinksById = useBulkDeleteLinks();
const { links } = useLinks();
const router = useRouter();
const [bulkDeleteLinksModal, setBulkDeleteLinksModal] = useState(false);
const [bulkEditLinksModal, setBulkEditLinksModal] = useState(false);
useEffect(() => {
if (editMode && setEditMode) return setEditMode(false);
}, [router]);
const collectivePermissions = useCollectivePermissions(
selectedLinks.map((link) => link.collectionId as number)
);
const handleSelectAll = () => {
if (selectedLinks.length === links.length) {
setSelectedLinks([]);
} else {
setSelectedLinks(links.map((link) => link));
}
};
const bulkDeleteLinks = async () => {
const load = toast.loading(t("deleting"));
await deleteLinksById.mutateAsync(
selectedLinks.map((link) => link.id as number),
{
onSettled: (data, error) => {
toast.dismiss(load);
if (error) {
toast.error(error.message);
} else {
setSelectedLinks([]);
toast.success(t("deleted"));
}
},
}
);
};
return (
<>
<div className="flex justify-between items-center">
{children}
<div className="flex gap-3 items-center justify-end">
<div className="flex gap-2 items-center mt-2">
{links &&
links.length > 0 &&
editMode !== undefined &&
setEditMode && (
<div
role="button"
onClick={() => {
setEditMode(!editMode);
setSelectedLinks([]);
}}
className={`btn btn-square btn-sm btn-ghost ${
editMode
? "bg-primary/20 hover:bg-primary/20"
: "hover:bg-neutral/20"
}`}
>
<i className="bi-pencil-fill text-neutral text-xl"></i>
</div>
)}
{searchFilter && setSearchFilter && (
<FilterSearchDropdown
searchFilter={searchFilter}
setSearchFilter={setSearchFilter}
/>
)}
<SortDropdown
sortBy={sortBy}
setSort={(value) => {
setSortBy(value);
}}
t={t}
/>
<ViewDropdown viewMode={viewMode} setViewMode={setViewMode} />
</div>
</div>
</div>
{links && editMode && links.length > 0 && (
<div className="w-full flex justify-between items-center min-h-[32px]">
<div className="flex gap-3 ml-3">
<input
type="checkbox"
className="checkbox checkbox-primary"
onChange={() => handleSelectAll()}
checked={
selectedLinks.length === links.length && links.length > 0
}
/>
{selectedLinks.length > 0 ? (
<span>
{selectedLinks.length === 1
? t("link_selected")
: t("links_selected", { count: selectedLinks.length })}
</span>
) : (
<span>{t("nothing_selected")}</span>
)}
</div>
<div className="flex gap-3">
<button
onClick={() => setBulkEditLinksModal(true)}
className="btn btn-sm btn-accent text-white w-fit ml-auto"
disabled={
selectedLinks.length === 0 ||
!(
collectivePermissions === true ||
collectivePermissions?.canUpdate
)
}
>
{t("edit")}
</button>
<button
onClick={(e) => {
(document?.activeElement as HTMLElement)?.blur();
e.shiftKey ? bulkDeleteLinks() : setBulkDeleteLinksModal(true);
}}
className="btn btn-sm bg-red-500 hover:bg-red-400 text-white w-fit ml-auto"
disabled={
selectedLinks.length === 0 ||
!(
collectivePermissions === true ||
collectivePermissions?.canDelete
)
}
>
{t("delete")}
</button>
</div>
</div>
)}
{bulkDeleteLinksModal && (
<BulkDeleteLinksModal
onClose={() => {
setBulkDeleteLinksModal(false);
}}
/>
)}
{bulkEditLinksModal && (
<BulkEditLinksModal
onClose={() => {
setBulkEditLinksModal(false);
}}
/>
)}
</>
);
};
export default LinkListOptions;
-16
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>
);
}
-16
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>
);
}
-16
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>
);
}
@@ -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 ? (
@@ -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>
);
}
@@ -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.preventDefault();
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>
</>
);
}
@@ -6,17 +6,16 @@ export default function LinkDate({
}: {
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>
@@ -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>
);
}
@@ -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>
);
};
@@ -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>
</>
);
}
@@ -5,7 +5,6 @@ import {
} 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";
@@ -13,25 +12,58 @@ 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 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 LinkGrid({ link, count, className }: Props) {
const { collections } = useCollectionStore();
export default function LinkMasonry({ link, flipDropdown, editMode }: Props) {
const { t } = useTranslation();
const { links, getLink } = useLinkStore();
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 {
shortendURL = new URL(link.url || "").host.toLowerCase();
if (link.url) {
shortendURL = new URL(link.url).host.toLowerCase();
}
} catch (error) {
console.log(error);
}
@@ -53,6 +85,7 @@ export default function LinkGrid({ link, count, className }: Props) {
const ref = useRef<HTMLDivElement>(null);
const isVisible = useOnScreen(ref);
const permissions = usePermissions(collection?.id as number);
useEffect(() => {
let interval: any;
@@ -63,7 +96,7 @@ export default function LinkGrid({ link, count, className }: Props) {
link.preview !== "unavailable"
) {
interval = setInterval(async () => {
getLink(link.id as number);
getLink.mutateAsync(link.id as number);
}, 5000);
}
@@ -76,17 +109,35 @@ export default function LinkGrid({ link, count, className }: Props) {
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="border border-solid border-neutral-content bg-base-200 shadow-md hover:shadow-none duration-100 rounded-2xl relative"
className={`${selectedStyle} border border-solid border-neutral-content bg-base-200 shadow-md hover:shadow-none duration-100 rounded-2xl relative`}
onClick={() =>
selectable
? handleCheckboxClick(link)
: editMode
? toast.error(t("link_selection_error"))
: undefined
}
>
<Link
href={link.url || ""}
target="_blank"
<div
className="rounded-2xl cursor-pointer"
onClick={() =>
!editMode && window.open(generateLinkHref(link, user), "_blank")
}
>
<div className="relative rounded-t-2xl h-40 overflow-hidden">
<div className="relative rounded-t-2xl overflow-hidden">
{previewAvailable(link) ? (
<Image
src={`/api/v1/archives/${link.id}?format=${ArchivedFormat.jpeg}&preview=true`}
@@ -94,59 +145,69 @@ export default function LinkGrid({ link, count, className }: Props) {
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)" }}
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>
) : (
) : link.preview === "unavailable" ? null : (
<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>
{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 last:hidden border-t border-neutral-content h-[1px]" />
{link.preview !== "unavailable" && (
<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}
<div className="p-3 flex flex-col gap-2">
<p className="hyphens-auto w-full pr-9 text-primary text-sm">
{unescapeString(link.name)}
</p>
<div title={link.url || ""} className="w-fit">
<div className="flex gap-1 item-center select-none text-neutral mt-1">
<i className="bi-link-45deg text-lg mt-[0.15rem] leading-none"></i>
<p className="text-sm truncate">{shortendURL}</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>
)}
</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>
<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>
</Link>
</div>
{showInfo ? (
{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)}
@@ -154,7 +215,9 @@ export default function LinkGrid({ link, count, className }: Props) {
>
<i className="bi-x text-neutral text-2xl"></i>
</div>
<p className="text-neutral text-lg font-semibold">Description</p>
<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>
@@ -162,13 +225,15 @@ export default function LinkGrid({ link, count, className }: Props) {
unescapeString(link.description)
) : (
<span className="text-neutral text-sm">
No description provided.
{t("no_description")}
</span>
)}
</p>
{link.tags[0] ? (
{link.tags && link.tags[0] && (
<>
<p className="text-neutral text-lg mt-3 font-semibold">Tags</p>
<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]" />
@@ -189,16 +254,21 @@ export default function LinkGrid({ link, count, className }: Props) {
</div>
</div>
</>
) : undefined}
)}
</div>
) : undefined}
)}
<LinkActions
link={link}
collection={collection}
position="top-[10.75rem] right-3"
position={
link.preview !== "unavailable"
? "top-[10.75rem] right-3"
: "top-[.75rem] right-3"
}
toggleShowInfo={() => setShowInfo(!showInfo)}
linkInfo={showInfo}
flipDropdown={flipDropdown}
/>
</div>
);
@@ -0,0 +1,36 @@
import { LinkIncludingShortenedCollectionAndTags } from "@/types/global";
import Link from "next/link";
import React from "react";
export default function LinkTypeBadge({
link,
}: {
link: LinkIncludingShortenedCollectionAndTags;
}) {
let shortendURL;
if (link.type === "url" && link.url) {
try {
shortendURL = new URL(link.url).host.toLowerCase();
} catch (error) {
console.log(error);
}
}
return link.url && shortendURL ? (
<Link
href={link.url || ""}
target="_blank"
title={link.url || ""}
onClick={(e) => {
e.stopPropagation();
}}
className="flex gap-1 item-center select-none text-neutral hover:opacity-70 duration-100 max-w-full w-fit"
>
<i className="bi-link-45deg text-lg leading-none"></i>
<p className="text-xs truncate">{shortendURL}</p>
</Link>
) : (
<div className="badge badge-primary badge-sm select-none">{link.type}</div>
);
}
-111
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>
);
}
-153
View File
@@ -1,153 +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`}
>
<Link
href={link.url || ""}
target="_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>
</Link>
<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>
</>
);
}
+238
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) : [];
-7
View File
@@ -1,7 +0,0 @@
export default function Loading() {
return (
<div>
<p>Loading...</p>
</div>
);
}
+98
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}
</>
);
}
+45
View File
@@ -0,0 +1,45 @@
import { isPWA } from "@/lib/client/utils";
import Link from "next/link";
import { useRouter } from "next/router";
import { useEffect, useState } from "react";
export default function MobileNavigationButton({
href,
icon,
}: {
href: string;
icon: string;
}) {
const router = useRouter();
const [active, setActive] = useState(false);
useEffect(() => {
setActive(href === router.asPath);
}, [router]);
return (
<Link
href={href}
className="w-full active:scale-[80%] duration-200 select-none"
draggable="false"
style={{ WebkitTouchCallout: "none" }}
onContextMenu={(e) => {
if (isPWA()) {
e.preventDefault();
e.stopPropagation();
return false;
} else return null;
}}
>
<div
className={`py-2 cursor-pointer gap-2 w-full rounded-full capitalize flex items-center justify-center`}
>
<i
className={`${icon} text-primary text-3xl drop-shadow duration-200 rounded-full w-14 h-14 text-center pt-[0.65rem] ${
active || false ? "bg-primary/20" : ""
}`}
></i>
</div>
</Link>
);
}
+79 -24
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>
);
}
}
@@ -0,0 +1,73 @@
import React from "react";
import useLinkStore from "@/store/links";
import Modal from "../Modal";
import Button from "../ui/Button";
import { useTranslation } from "next-i18next";
import { useBulkDeleteLinks } from "@/hooks/store/links";
import toast from "react-hot-toast";
type Props = {
onClose: Function;
};
export default function BulkDeleteLinksModal({ onClose }: Props) {
const { t } = useTranslation();
const { selectedLinks, setSelectedLinks } = useLinkStore();
const deleteLinksById = useBulkDeleteLinks();
const deleteLink = async () => {
const load = toast.loading(t("deleting"));
await deleteLinksById.mutateAsync(
selectedLinks.map((link) => link.id as number),
{
onSettled: (data, error) => {
toast.dismiss(load);
if (error) {
toast.error(error.message);
} else {
setSelectedLinks([]);
onClose();
toast.success(t("deleted"));
}
},
}
);
};
return (
<Modal toggleModal={onClose}>
<p className="text-xl font-thin text-red-500">
{selectedLinks.length === 1
? t("delete_link")
: t("delete_links", { count: selectedLinks.length })}
</p>
<div className="divider mb-3 mt-1"></div>
<div className="flex flex-col gap-3">
<p>
{selectedLinks.length === 1
? t("link_deletion_confirmation_message")
: t("links_deletion_confirmation_message", {
count: selectedLinks.length,
})}
</p>
<div role="alert" className="alert alert-warning">
<i className="bi-exclamation-triangle text-xl" />
<span>{t("warning_irreversible")}</span>
</div>
<p>{t("shift_key_tip")}</p>
<Button className="ml-auto" intent="destructive" onClick={deleteLink}>
<i className="bi-trash text-xl" />
{t("delete")}
</Button>
</div>
</Modal>
);
}
@@ -0,0 +1,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>
);
}
@@ -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>
);
+29 -30
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" />
<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}
>
<Button className="ml-auto" intent="destructive" onClick={submit}>
<i className="bi-trash text-xl" />
Delete
</button>
{t("delete")}
</Button>
</div>
</Modal>
);
@@ -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>
);
}
+34 -31
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>

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