Compare commits

..

1179 Commits

Author SHA1 Message Date
Daniel
9aefa3cf3b Merge pull request #1379 from linkwarden/dev
bug fix
2025-08-28 16:33:53 -04:00
daniel31x13
36be3d8772 bug fix 2025-08-28 16:33:07 -04:00
Daniel
1af9aaf11f Merge pull request #1372 from linkwarden/dev
Dev
2025-08-27 13:41:28 -04:00
daniel31x13
69b86a473a Merge branch 'dev' of https://github.com/linkwarden/linkwarden into dev 2025-08-27 13:40:58 -04:00
daniel31x13
22fde2d367 bump version 2025-08-27 13:40:57 -04:00
Daniel
2b63d7e863 Merge pull request #1371 from linkwarden/dev
Dev
2025-08-27 13:38:20 -04:00
Daniel
0aa6b0b4ae Merge pull request #1369 from linkwarden/i18n
New Crowdin updates
2025-08-27 13:37:46 -04:00
daniel31x13
ee489534ec bump version 2025-08-27 13:35:16 -04:00
LinkwardenBot
9f9d96edfe New translations common.json (Russian) 2025-08-27 16:16:00 +00:00
Daniel
cf71bb8a8a Merge pull request #1367 from linkwarden/dev
v2.12.0
2025-08-27 11:19:44 -04:00
daniel31x13
9ed374c4c3 minor fix 2025-08-27 11:02:26 -04:00
daniel31x13
1119f80ffc fix(web): bug fixed 2025-08-27 08:48:33 -04:00
daniel31x13
0f62beb0ab minor improvement 2025-08-26 21:44:53 -04:00
daniel31x13
803f344680 minor improvement 2025-08-26 12:05:56 -04:00
daniel31x13
5f243d4fa2 mobile: improved login page 2025-08-25 18:53:56 -04:00
daniel31x13
dcd6b1be48 feat(mobile): clear cache when unauthenticated 2025-08-25 13:18:58 -04:00
daniel31x13
fed53fd187 fix(mobile): bug fix and improvements 2025-08-22 17:51:52 -04:00
daniel31x13
e30bcaefe3 feat(mobile): option to choose links default behavior 2025-08-22 15:50:24 -04:00
daniel31x13
a81111dfe5 feat(mobile): persist theme switching 2025-08-22 15:12:02 -04:00
daniel31x13
ce99562e16 fix(web): lower the minimum height for link cards 2025-08-22 13:46:19 -04:00
daniel31x13
c849e0aeda minor fix 2025-08-22 12:35:56 -04:00
daniel31x13
6f87e02ebc feat(mobile): share intent functionality fully implemented + bug fix 2025-08-22 11:57:51 -04:00
daniel31x13
59ef7baeeb fix(mobile): android dark mode bug 2025-08-22 10:58:58 -04:00
daniel31x13
47068d3352 fix(mobile): minor improvement 2025-08-22 10:19:05 -04:00
daniel31x13
9153958811 feat(mobile): add share sheet page 2025-08-21 14:36:19 -04:00
Daniel
3adbb10be8 Merge pull request #1357 from linkwarden/v2.11.8
V2.11.8
2025-08-20 16:26:27 -04:00
Daniel
5f3bfa4bda Merge pull request #1356 from linkwarden/v2.11.8
V2.11.8
2025-08-20 14:46:47 -04:00
Daniel
e5a278bb6a Merge branch 'main' into v2.11.8 2025-08-20 14:46:06 -04:00
Daniel
4bc3eb4bb5 Merge pull request #1350 from linkwarden/i18n
New Crowdin updates
2025-08-20 14:44:01 -04:00
Daniel
20fbc1552d Merge pull request #1353 from linkwarden/feat/update-stripes-api
Update Stripe's API
2025-08-20 14:40:12 -04:00
Daniel
7cf3812e2c Merge branch 'v2.11.8' into feat/update-stripes-api 2025-08-20 14:40:04 -04:00
Daniel
1ce72e6a03 Merge pull request #1355 from linkwarden/fix/link-batch-selection
Fix/link batch selection
2025-08-20 14:37:08 -04:00
daniel31x13
711477743f bump version 2025-08-20 14:36:40 -04:00
daniel31x13
1c55ac571a remove extra log 2025-08-20 13:59:10 -04:00
daniel31x13
5128efc05c bug fix 2025-08-20 13:43:24 -04:00
daniel31x13
2b6f48ad83 fix bug 2025-08-20 11:59:28 -04:00
LinkwardenBot
435a44dd47 New translations common.json (Portuguese, Brazilian) 2025-08-19 06:14:51 +00:00
LinkwardenBot
244f78a686 New translations common.json (Russian) 2025-08-19 06:14:50 +00:00
daniel31x13
fbb9f7af23 minor fix 2025-08-17 08:32:27 -04:00
daniel31x13
c08da0f990 use new api 2025-08-17 07:18:28 -04:00
Daniel
495d3111a1 Merge pull request #1349 from linkwarden/hotfix
minor fix
2025-08-14 13:11:21 -04:00
Daniel
c8f63cedc1 Merge pull request #1348 from linkwarden/hotfix
minor fix
2025-08-14 13:10:54 -04:00
daniel31x13
221930f88d minor fix 2025-08-14 13:10:24 -04:00
Daniel
58812811d0 Merge pull request #1346 from linkwarden/hotfix
Hotfix
2025-08-14 12:54:02 -04:00
Daniel
f2b521d34b Merge pull request #1345 from linkwarden/hotfix
small fix for new self-hosted users
2025-08-14 12:53:32 -04:00
daniel31x13
56b6791621 bump version 2025-08-14 12:52:42 -04:00
daniel31x13
bd26f90738 small fix for new self-hosted users 2025-08-14 12:51:55 -04:00
daniel31x13
63782f1627 fix(mobile): minor fix to the dashboard page 2025-08-13 18:26:44 -04:00
daniel31x13
078e38c7d8 remove unused imports 2025-08-13 15:21:31 -04:00
Daniel
11806563c8 Merge pull request #1343 from linkwarden/fix/fairer-link-picking
Fix/fairer link picking
2025-08-13 01:06:42 -04:00
Daniel
6da64e6535 Merge pull request #1342 from linkwarden/fix/fairer-link-picking
use a single browser with separate context
2025-08-13 00:11:47 -04:00
daniel31x13
577f8600ae use a single browser with separate context 2025-08-13 00:10:18 -04:00
Daniel
e6c2bc860f Merge pull request #1341 from linkwarden/fix/fairer-link-picking
remove unused code
2025-08-12 22:54:23 -04:00
daniel31x13
c54fbbc985 remove unused code 2025-08-12 22:54:01 -04:00
Daniel
c7d38733ce Merge pull request #1340 from linkwarden/fix/fairer-link-picking
improvement
2025-08-12 22:53:24 -04:00
daniel31x13
794c2f2657 improvement 2025-08-12 22:52:33 -04:00
Daniel
d73f236b36 Merge pull request #1339 from linkwarden/fix/fairer-link-picking
improvement
2025-08-12 22:09:09 -04:00
daniel31x13
658434afaf improvement 2025-08-12 22:08:38 -04:00
Daniel
a191861748 Merge pull request #1338 from linkwarden/fix/fairer-link-picking
fix user blocking queue
2025-08-12 18:21:27 -04:00
daniel31x13
eb3f1eeb5b fix user blocking queue 2025-08-12 17:58:25 -04:00
daniel31x13
c911f132f6 add native folders to gitignore 2025-08-12 11:05:48 -04:00
daniel31x13
89122ccd5c feat(mobile): add share sheet functionality 2025-08-12 10:39:01 -04:00
daniel31x13
ac8dacd570 fix(mobile): refetch on mount 2025-08-11 16:02:14 -04:00
daniel31x13
0b942cbb29 feat(web): add singlefile upload route 2025-08-11 15:11:32 -04:00
Daniel
f8cfe8e556 Merge pull request #1332 from khanguyen74/feat/allow-upload-html-archive
Feat: allow uploading archives
2025-08-11 12:00:13 -04:00
daniel31x13
2be7f314bb made the upload functionality api-only 2025-08-11 11:59:56 -04:00
Kha Nguyen
f083b45b78 allow uploading asset if user has access to collection 2025-08-10 22:51:55 -05:00
Kha Nguyen
0e7d2ef716 add Crowdin for failed upload message 2025-08-10 21:49:36 -05:00
Kha Nguyen
581e27e8c6 feat(web): add upload button to preservation nav bar 2025-08-10 21:42:11 -05:00
Kha Nguyen
c20f6d8015 remove upload buttons on link detail drawer 2025-08-10 21:40:45 -05:00
daniel31x13
91dafd40f1 Merge branch 'dev' of https://github.com/linkwarden/linkwarden into dev 2025-08-10 18:12:58 -04:00
daniel31x13
d0cf951731 minor fix 2025-08-10 18:12:57 -04:00
Daniel
c75685c435 Merge pull request #1330 from khanguyen74/fix/collection-list-sort
Fix: properly handle collection list on dashboard page
2025-08-10 17:31:36 -04:00
Kha Nguyen
3bc8bb676e fix: properly sort collection list on dashboard page 2025-08-10 12:08:59 -05:00
Daniel
63f77b19a9 Merge pull request #1329 from linkwarden/i18n
New Crowdin updates
2025-08-10 07:38:24 -04:00
Daniel
8521f4f1e9 Merge pull request #1325 from teynar/fix/mismatched-link-favicon-size
Fix: request 64px gstatic favicon for consistent icon display
2025-08-10 07:34:50 -04:00
Daniel
a866de6931 Merge pull request #1331 from khanguyen74/fix/update-drag-n-drop-search-page
fix: properly handle drag-and-drop
2025-08-10 07:27:26 -04:00
daniel31x13
d661c5c609 minor fix 2025-08-10 07:15:09 -04:00
daniel31x13
7b12a82a67 Revert "Merge branch 'dev' of https://github.com/linkwarden/linkwarden into dev" 2025-08-08 17:43:36 -04:00
daniel31x13
374c64ff0f Merge branch 'dev' of https://github.com/linkwarden/linkwarden into dev 2025-08-08 17:35:34 -04:00
daniel31x13
7558068f7a minor update 2025-08-08 17:35:29 -04:00
Kha Nguyen
c03557c080 ask for confirmation before refresh preserved data 2025-08-08 14:34:29 -05:00
daniel31x13
f15b8df6f3 feat(mobile): share sheet base commit 2025-08-08 14:14:37 -04:00
Kha Nguyen
91e337ea8c add options to replace other preserved formats 2025-08-08 13:10:44 -05:00
Kha Nguyen
65f0075a6f feat(web): add option to upload html archive 2025-08-08 12:37:29 -05:00
Kha Nguyen
a8928e9de4 fix: change drag handle to inside link card
- fixes issue where dragging link details drawer also causing the link
to be dragged
2025-08-08 10:21:32 -05:00
Kha Nguyen
fb7e2286a7 fix(web): use DragNDrop wrapper for search page 2025-08-08 09:02:25 -05:00
LinkwardenBot
f057434c3b New translations common.json (Chinese Simplified) 2025-08-08 04:09:14 +00:00
LinkwardenBot
c7c4959659 New translations common.json (Chinese Simplified) 2025-08-08 04:09:14 +00:00
LinkwardenBot
c6ed7bb8e8 New translations common.json (Chinese Traditional) 2025-08-08 04:09:14 +00:00
LinkwardenBot
7351b5265d New translations common.json (Ukrainian) 2025-08-08 04:09:14 +00:00
LinkwardenBot
fcc4b89249 New translations common.json (Polish) 2025-08-08 04:09:14 +00:00
LinkwardenBot
04ea06e319 New translations common.json (Dutch) 2025-08-08 04:09:14 +00:00
LinkwardenBot
57347f2b90 New translations common.json (Japanese) 2025-08-08 04:09:14 +00:00
LinkwardenBot
35ae395447 New translations common.json (Italian) 2025-08-08 04:09:14 +00:00
LinkwardenBot
553680d9eb New translations common.json (German) 2025-08-08 04:09:14 +00:00
LinkwardenBot
1048989598 New translations common.json (Spanish) 2025-08-08 04:09:14 +00:00
LinkwardenBot
b5f96f5ed3 New translations common.json (French) 2025-08-08 04:09:14 +00:00
LinkwardenBot
e22ed9bb3d New translations common.json (Russian) 2025-08-08 04:09:13 +00:00
LinkwardenBot
51ee97c7ef New translations common.json (Portuguese, Brazilian) 2025-08-08 04:09:13 +00:00
LinkwardenBot
c25bc658f4 New translations common.json (Chinese Simplified) 2025-08-08 04:09:13 +00:00
LinkwardenBot
3b8a6051e9 New translations common.json (Turkish) 2025-08-08 04:09:13 +00:00
Kha Nguyen
b8a6c77150 fix(web): fix collection sortable list on dashboard page 2025-08-07 20:41:17 -05:00
daniel31x13
a9937d218c minor update 2025-08-07 20:10:43 -04:00
daniel31x13
d3302daa3a remove comment 2025-08-07 18:34:17 -04:00
Daniel
dac2b4c30a Merge pull request #1322 from khanguyen74/fix/email-input-disappear
Fix: email input disappear in user modal
2025-08-07 18:26:17 -04:00
Daniel
28e3e23f8a Merge pull request #1310 from khanguyen74/fix/subscription-check-fix
Fix subscription check blocking user creation in admin dashboard
2025-08-07 18:04:29 -04:00
Daniel
80375843af Merge pull request #1320 from khanguyen74/feature/add-drag-and-drop
feat: add drag and drop to move links between collection on dashboard page
2025-08-07 17:41:46 -04:00
daniel31x13
232098191a a bit more concise alert 2025-08-07 17:33:39 -04:00
Kha Nguyen
b59ad36192 feat(web): add drag and drop to tag on sidebar 2025-08-07 15:13:01 -05:00
Kha Nguyen
ebdc8b9db2 refactor drag and drop pages 2025-08-07 15:12:36 -05:00
Kha Nguyen
7a76de5726 add TagListing component
refactor tags out of sidebar
2025-08-07 13:49:28 -05:00
Kha Nguyen
a0ceca443c feat(web): add drag-n-drop to tag page 2025-08-07 13:18:54 -05:00
Kha Nguyen
21cb65edbd clean up 2025-08-07 12:55:05 -05:00
Kha Nguyen
c58f8f3086 feat(web): add drag-n-drop to collection page 2025-08-07 12:53:03 -05:00
Kha Nguyen
04419aa9e0 feat(web): add drag-n-drop to search page 2025-08-07 12:47:58 -05:00
Kha Nguyen
e916ea53fc feat(web): add drag-n-drop to pinned page 2025-08-07 12:36:59 -05:00
Kha Nguyen
20fde711d1 refactor links page 2025-08-07 12:36:41 -05:00
Kha Nguyen
652931e5be prevent hover effect on sidebar collection list when dragging over 2025-08-07 12:21:56 -05:00
Daniel
88d4e0203f Merge pull request #1302 from linkwarden/i18n
New Crowdin updates
2025-08-07 12:57:32 -04:00
Kha Nguyen
87ae5da6f3 add drag-n-drop to masonry view 2025-08-07 00:20:33 -05:00
Kha Nguyen
6a9a897529 add drag-n-drop to list view 2025-08-07 00:18:51 -05:00
Kha Nguyen
92a1c4a5f0 disable drag and drop on small screen on links page 2025-08-07 00:14:56 -05:00
Kha Nguyen
1ca2b4e534 refactor how selected link className is applied 2025-08-06 23:58:31 -05:00
daniel31x13
139f99d050 fix(mobile): fix android not updating queries 2025-08-06 20:32:59 -04:00
daniel31x13
c5bfd20833 fix(mobile): bug fixed 2025-08-06 19:55:27 -04:00
Kha Nguyen
26ab9584ca use favicon as overlay when dragging links 2025-08-06 18:38:28 -05:00
Kha Nguyen
76056d6b1a remove drag handle 2025-08-06 18:36:54 -05:00
daniel31x13
bc129af07a fix(mobile): apply dark mode to android statusbar 2025-08-06 19:34:05 -04:00
Kha Nguyen
16a83496bc ui change to droppable area 2025-08-06 18:31:06 -05:00
Kha Nguyen
e270a6b957 use favicon as overlay component when dragging 2025-08-06 17:27:10 -05:00
Kha Nguyen
ae6dbf7745 remove dropping link placeholder 2025-08-06 17:03:40 -05:00
daniel31x13
ee8a6634f0 fix(mobile): bug fix 2025-08-06 17:46:34 -04:00
Teynar
a7f8de0a2e fix(link-icon): request 64px gstatic favicon for consistent icon display
Previously, a 32px favicon was requested and scaled to 64px, causing blurriness.
2025-08-06 23:39:59 +02:00
Kha Nguyen
1f369401df drag/drop ui improvement 2025-08-06 16:24:04 -05:00
Kha Nguyen
a4375e3b57 update links page 2025-08-06 15:23:15 -05:00
Kha Nguyen
532f6fb1f1 make LinkCard draggable 2025-08-06 15:22:56 -05:00
daniel31x13
1e1bd8f333 fix(mobile): bugs fixed 2025-08-06 16:17:28 -04:00
Kha Nguyen
c999259102 refactor custom collision algorithm function 2025-08-06 15:03:03 -05:00
Kha Nguyen
13883e0cbc add drag and drop to links page 2025-08-06 15:02:19 -05:00
Kha Nguyen
5acfdc2f89 update logic to set active link when the link is dragged 2025-08-06 14:10:32 -05:00
Kha Nguyen
8e1767d7d0 fix(web): fix user modal not showing email input
- properly check for EMAIL_PROVIDER environment variable
2025-08-05 22:22:09 -05:00
Kha Nguyen
477f534a17 revert className changes in LinkPin component 2025-08-05 20:46:44 -05:00
daniel31x13
032359a357 fix(mobile): fix paddings on android 2025-08-05 19:18:16 -04:00
daniel31x13
87c66b81e0 feat(mobile): add cross-platform icon library 2025-08-05 18:03:23 -04:00
LinkwardenBot
8eb3b5ce5d New translations common.json (Russian) 2025-08-05 21:48:41 +00:00
LinkwardenBot
ee13d3551d New translations common.json (Turkish) 2025-08-05 21:48:41 +00:00
LinkwardenBot
52a4441b6f New translations common.json (Portuguese, Brazilian) 2025-08-05 21:48:41 +00:00
LinkwardenBot
04e73942a6 New translations common.json (Portuguese, Brazilian) 2025-08-05 21:48:41 +00:00
LinkwardenBot
6234278c9f New translations common.json (Turkish) 2025-08-05 21:48:41 +00:00
LinkwardenBot
e39d0b1bbd New translations common.json (Turkish) 2025-08-05 21:48:41 +00:00
LinkwardenBot
b703eb5654 New translations common.json (Chinese Simplified) 2025-08-05 21:48:40 +00:00
LinkwardenBot
0b296dc557 New translations common.json (Chinese Simplified) 2025-08-05 21:48:40 +00:00
LinkwardenBot
3ba2beb769 New translations common.json (Chinese Simplified) 2025-08-05 21:48:40 +00:00
LinkwardenBot
4b477f82bf New translations common.json (Turkish) 2025-08-05 21:48:40 +00:00
LinkwardenBot
30ef7863d5 New translations common.json (Turkish) 2025-08-05 21:48:40 +00:00
daniel31x13
b78aa39f79 fix(mobile): bugs fixed 2025-08-05 16:42:32 -04:00
Kha Nguyen
787e4c49a4 remove LinkAction open control prop
should be addressed in a separate PR
2025-08-03 09:04:55 -05:00
Kha Nguyen
6e60031e40 update collision algorithm 2025-08-02 23:22:29 -05:00
Kha Nguyen
23148b22d7 feat: the whole dashboard link is now draggable 2025-08-02 23:14:58 -05:00
Kha Nguyen
07945f946d improve mobile support for drag and drop 2025-08-02 23:01:45 -05:00
Kha Nguyen
702508498e allow drag and drop link to collections in side bar 2025-08-02 21:07:26 -05:00
Kha Nguyen
289818716c optimistically update pinned link when drop to pinned links section 2025-08-02 18:49:41 -05:00
Kha Nguyen
75419df40b feat: show link placeholder on pinned link section on drag 2025-08-02 18:16:34 -05:00
Kha Nguyen
586ef5ed58 feat: show placeholder on target collection 2025-08-02 16:56:21 -05:00
daniel31x13
ee53a170ed feat(mobile): add system defaults to themes 2025-07-31 20:13:28 -04:00
daniel31x13
ea48e797e3 small fix 2025-07-31 20:02:29 -04:00
daniel31x13
b294abd65b minor improvement 2025-07-31 18:54:47 -04:00
daniel31x13
f7fcbfe635 minor improvement 2025-07-31 18:48:05 -04:00
daniel31x13
2c01013eb3 feat(mobile): improved theme colors 2025-07-31 17:26:28 -04:00
daniel31x13
a36af7d673 feat(mobile): completed implementing dark mode 2025-07-31 16:21:57 -04:00
daniel31x13
f4c8030a1b feat(mobile): initial commit for theme settings 2025-07-30 19:44:57 -04:00
Daniel
0a735cd2f6 Merge pull request #1313 from linkwarden/dev
Dev
2025-07-29 19:12:59 -04:00
daniel31x13
d870ffecd1 feat(web): optimization 2025-07-29 19:11:02 -04:00
Kha Nguyen
360eadb08d show toast when drop a link to collection it already belongs to
- only applies if dragged from recent section
2025-07-28 19:43:24 -05:00
Kha Nguyen
35eec60ac9 improve drag n drop on touch devices 2025-07-28 19:01:18 -05:00
daniel31x13
dae7d2be3b feat(mobile): small improvement to the settings page 2025-07-28 16:19:49 -04:00
daniel31x13
e0abe1df39 feat(mobile): improved dashboard 2025-07-28 16:01:55 -04:00
Kha Nguyen
4ae03168bb optimistically update the collection after dropping link 2025-07-28 12:38:35 -05:00
Kha Nguyen
9a6429b85b update how link card look while dragging 2025-07-28 11:45:11 -05:00
Kha Nguyen
20997ba8bc feat(web): change link drag and drop logic 2025-07-28 00:55:53 -05:00
Kha Nguyen
4fe2ef7134 feat(web): handle pinning link by drag-n-drop 2025-07-27 01:29:43 -05:00
Kha Nguyen
63b6f3e66a chore: add dependencies 2025-07-26 01:33:06 -05:00
Kha Nguyen
5c07687e1a fix(web): show more button on link card while dropdown menu opens 2025-07-26 01:32:51 -05:00
Kha Nguyen
019bc783a4 feat(web): allow drag and drop to move a link to new collection 2025-07-26 01:31:13 -05:00
Kha Nguyen
3dfa12dbbf feat(web): add droppable dashboard links 2025-07-26 01:30:13 -05:00
Kha Nguyen
20eecd682e fix: only require subscription check when Stripe is enabled 2025-07-25 20:41:58 -05:00
daniel31x13
de3ca46ef0 feat(mobile): finished building the dashboard 2025-07-24 18:32:55 -04:00
daniel31x13
a77c5c20cd feat(mobile): add copy to clipboard functionality 2025-07-23 22:22:33 -04:00
daniel31x13
822415c695 feat(mobile): add edit link functionality 2025-07-22 20:11:46 -04:00
daniel31x13
3de6e9965a chore(mobile): implement sheet manager 2025-07-22 12:19:48 -04:00
daniel31x13
8555d26d99 feat(mobile): add link functionality + delete link functionality 2025-07-21 17:40:27 -04:00
daniel31x13
c8c8fb7875 bug fixed 2025-07-21 13:50:44 -04:00
Daniel
64ae49a3a4 Merge pull request #1299 from linkwarden/dev
bug fixed
2025-07-19 23:13:58 -04:00
daniel31x13
1bcbf2011d bump version 2025-07-19 23:13:41 -04:00
daniel31x13
d43c4e3e95 bug fixed 2025-07-19 23:10:10 -04:00
Daniel
6253635e78 Merge pull request #1296 from linkwarden/dev
minor fix
2025-07-18 16:33:21 -04:00
daniel31x13
b30f554fa7 minor fix 2025-07-18 16:10:06 -04:00
Daniel
f80432eda1 Merge pull request #1292 from linkwarden/dev
Dev
2025-07-16 17:52:28 -04:00
Daniel
267a14c535 Merge pull request #1293 from linkwarden/i18n
New Crowdin updates
2025-07-16 17:51:40 -04:00
daniel31x13
e24e338e67 update readme 2025-07-16 17:44:31 -04:00
Daniel
0299f7bd4b New translations common.json (Portuguese, Brazilian) 2025-07-16 16:39:16 -04:00
Daniel
db4b570577 New translations common.json (Chinese Traditional) 2025-07-16 16:39:15 -04:00
Daniel
8b5b1ae17e New translations common.json (Chinese Simplified) 2025-07-16 16:39:14 -04:00
Daniel
566927f6ff New translations common.json (Ukrainian) 2025-07-16 16:39:13 -04:00
Daniel
82d37e00e2 New translations common.json (Turkish) 2025-07-16 16:39:11 -04:00
Daniel
9be8b540d0 New translations common.json (Russian) 2025-07-16 16:39:10 -04:00
Daniel
6c01456c76 New translations common.json (Polish) 2025-07-16 16:39:09 -04:00
Daniel
dd6cde5b93 New translations common.json (Dutch) 2025-07-16 16:39:08 -04:00
Daniel
a984f8eca2 New translations common.json (Japanese) 2025-07-16 16:39:07 -04:00
Daniel
9fe910e205 New translations common.json (Italian) 2025-07-16 16:39:06 -04:00
Daniel
c76ac2f4c7 New translations common.json (German) 2025-07-16 16:39:05 -04:00
Daniel
71b1ff7da0 New translations common.json (Spanish) 2025-07-16 16:39:04 -04:00
Daniel
2bb1393eca New translations common.json (French) 2025-07-16 16:39:03 -04:00
daniel31x13
44f094f473 added a confirmation modal for re-preserving links 2025-07-16 16:31:27 -04:00
daniel31x13
ef98de61ee bug fixed 2025-07-14 17:28:10 -04:00
daniel31x13
1897e9c8ce minor fix 2025-07-14 17:07:00 -04:00
daniel31x13
659910a76d minor improvement 2025-07-14 17:02:56 -04:00
Daniel
f3e31edb7d Merge pull request #1289 from linkwarden/dev
v2.11.4
2025-07-12 16:41:22 -04:00
daniel31x13
936f7d9614 Merge branch 'dev' of https://github.com/linkwarden/linkwarden into dev 2025-07-12 15:18:47 -04:00
daniel31x13
966c271bd3 bump version 2025-07-12 15:18:45 -04:00
Daniel
95c243df18 Merge pull request #1285 from linkwarden/i18n
New Crowdin updates
2025-07-12 15:17:23 -04:00
daniel31x13
89efd237fe improved UX 2025-07-12 15:15:56 -04:00
LinkwardenBot
899426772b New translations common.json (Russian) 2025-07-06 12:36:47 +00:00
LinkwardenBot
55582433c6 New translations common.json (Russian) 2025-07-06 12:36:46 +00:00
Daniel
395f357fcb Merge pull request #1282 from linkwarden/dev
v2.11.3
2025-07-05 00:32:01 -04:00
Daniel
a14485c6dd Merge pull request #1272 from linkwarden/i18n
New Crowdin updates
2025-07-05 00:30:26 -04:00
LinkwardenBot
2351a83c48 New translations common.json (Spanish) 2025-07-05 04:29:26 +00:00
LinkwardenBot
e761c1d17a New translations common.json (French) 2025-07-05 04:29:26 +00:00
LinkwardenBot
ea01ab7b0f New translations common.json (Spanish) 2025-07-05 04:29:26 +00:00
LinkwardenBot
583bc077fb New translations common.json (Spanish) 2025-07-05 04:29:26 +00:00
daniel31x13
63ed780bb0 small fix 2025-07-05 00:24:35 -04:00
daniel31x13
5032bc3d13 remove extra logging 2025-06-28 14:44:53 -04:00
Daniel
7cec177ef0 Merge pull request #1267 from linkwarden/dev
Dev
2025-06-28 21:01:07 +03:30
daniel31x13
cb104a3a64 Merge branch 'dev' of https://github.com/linkwarden/linkwarden into dev 2025-06-28 13:30:52 -04:00
daniel31x13
3fe98f9346 bump version 2025-06-28 13:30:50 -04:00
Daniel
22a311297a Merge pull request #1266 from linkwarden/i18n
New Crowdin updates
2025-06-28 20:42:41 +03:30
LinkwardenBot
c9da55034c New translations common.json (French) 2025-06-28 17:11:53 +00:00
LinkwardenBot
6d7f69929f New translations common.json (French) 2025-06-28 17:11:53 +00:00
LinkwardenBot
77dd17f051 New translations common.json (German) 2025-06-28 17:11:53 +00:00
daniel31x13
3e015fa76c small fix 2025-06-28 13:10:53 -04:00
Daniel
c56259cb72 Merge pull request #1263 from linkwarden/dev
small fix
2025-06-28 03:57:50 +03:30
daniel31x13
a05fbd7141 small fix 2025-06-27 20:27:03 -04:00
Daniel
e00852f5df Merge pull request #1262 from linkwarden/dev
minor fix for the demo instance
2025-06-28 03:17:27 +03:30
daniel31x13
3067b6e2d3 minor fix for the demo instance 2025-06-27 19:44:00 -04:00
Daniel
a49a57c6ea Merge pull request #1260 from linkwarden/dev
bump version
2025-06-28 00:50:34 +03:30
daniel31x13
32efa56f77 bump version 2025-06-27 17:20:12 -04:00
Daniel
a8c6ef6fa0 Merge pull request #1259 from linkwarden/dev
bug fixed
2025-06-28 00:44:01 +03:30
daniel31x13
21fadfe389 bug fixed 2025-06-27 17:13:23 -04:00
Daniel
1e80fb33c4 Merge pull request #1258 from linkwarden/dev
Dev
2025-06-28 00:37:43 +03:30
daniel31x13
edf72aa042 bug fixed 2025-06-27 17:00:38 -04:00
daniel31x13
74d5dfb404 bug fix 2025-06-27 16:52:23 -04:00
daniel31x13
86971dcead bug fix 2025-06-27 16:25:08 -04:00
daniel31x13
9b0c3bf405 bug fix 2025-06-27 16:07:34 -04:00
Daniel
3ce30ec0f6 Merge pull request #1255 from linkwarden/dev
Dev
2025-06-27 20:34:28 +03:30
daniel31x13
e08684328a improved performance 2025-06-27 13:03:21 -04:00
Daniel
fe06ecc3f6 Merge pull request #1254 from linkwarden/main
sync
2025-06-27 19:24:12 +03:30
daniel31x13
42d877e2b5 small fix 2025-06-27 11:52:54 -04:00
Daniel
94cba5394f Merge pull request #1253 from linkwarden/dev
minor fix
2025-06-27 16:56:46 +03:30
daniel31x13
70999b83c5 minor fix 2025-06-27 09:26:15 -04:00
Daniel
d89aa4a4e7 Merge pull request #1249 from linkwarden/dev
v2.11.0
2025-06-27 16:47:00 +03:30
daniel31x13
c933970cce bug fixed 2025-06-27 08:16:01 -04:00
daniel31x13
8f10930127 add autoFocus to modals 2025-06-27 06:41:23 -04:00
daniel31x13
30c1a064e0 add omit field to link textContent 2025-06-27 06:21:47 -04:00
daniel31x13
d207c89d8e small improvements 2025-06-26 19:39:12 -04:00
daniel31x13
4712dc9b26 bug fixed 2025-06-26 17:55:19 -04:00
daniel31x13
1203f62ab3 Merge branch 'dev' of https://github.com/linkwarden/linkwarden into dev 2025-06-26 16:10:11 -04:00
daniel31x13
c1df0db729 improvements 2025-06-26 16:10:09 -04:00
Daniel
ee5814a0e4 Merge pull request #1248 from linkwarden/i18n
New Crowdin updates
2025-06-26 21:50:46 +03:30
daniel31x13
126d98d1b8 small improvement 2025-06-26 13:54:50 -04:00
daniel31x13
ebbe68dc9c bug fixed 2025-06-26 13:45:08 -04:00
daniel31x13
5a763bdce0 minor fix 2025-06-26 13:37:48 -04:00
Daniel
3b36cf09f4 New translations common.json (Russian) 2025-06-26 20:57:10 +03:30
daniel31x13
fb017a7655 small improvement 2025-06-26 13:18:02 -04:00
daniel31x13
b462531da8 small improvement to the seed script 2025-06-26 13:13:50 -04:00
daniel31x13
acbefe4b6e update logo 2025-06-26 12:43:18 -04:00
LinkwardenBot
e5296dd5c9 New translations common.json (Portuguese, Brazilian) 2025-06-26 15:52:19 +00:00
LinkwardenBot
a8295f94b8 New translations common.json (English) 2025-06-26 15:52:19 +00:00
LinkwardenBot
8607c066b8 New translations common.json (Chinese Traditional) 2025-06-26 15:52:19 +00:00
LinkwardenBot
63bc36d5ef New translations common.json (Chinese Simplified) 2025-06-26 15:52:19 +00:00
LinkwardenBot
74a671d165 New translations common.json (Ukrainian) 2025-06-26 15:52:19 +00:00
LinkwardenBot
b95c4b2d97 New translations common.json (Turkish) 2025-06-26 15:52:19 +00:00
LinkwardenBot
9a4f74e4a9 New translations common.json (Russian) 2025-06-26 15:52:19 +00:00
LinkwardenBot
e43fd9359b New translations common.json (Polish) 2025-06-26 15:52:19 +00:00
LinkwardenBot
be011955b5 New translations common.json (Dutch) 2025-06-26 15:52:19 +00:00
LinkwardenBot
5660093f1a New translations common.json (Japanese) 2025-06-26 15:52:19 +00:00
LinkwardenBot
a7b13e27f9 New translations common.json (Italian) 2025-06-26 15:52:19 +00:00
LinkwardenBot
f25edb1302 New translations common.json (German) 2025-06-26 15:52:19 +00:00
LinkwardenBot
592254daba New translations common.json (Spanish) 2025-06-26 15:52:19 +00:00
LinkwardenBot
395a7a58aa New translations common.json (French) 2025-06-26 15:52:19 +00:00
LinkwardenBot
7fcd73b61b New translations common.json (Portuguese, Brazilian) 2025-06-26 15:52:19 +00:00
LinkwardenBot
b091e133a7 New translations common.json (Chinese Traditional) 2025-06-26 15:52:19 +00:00
LinkwardenBot
5c8e339864 New translations common.json (Chinese Simplified) 2025-06-26 15:52:19 +00:00
LinkwardenBot
35808ef03f New translations common.json (Ukrainian) 2025-06-26 15:52:19 +00:00
LinkwardenBot
1fc7f436a1 New translations common.json (Turkish) 2025-06-26 15:52:19 +00:00
LinkwardenBot
2e15d43e4b New translations common.json (Russian) 2025-06-26 15:52:19 +00:00
LinkwardenBot
8b25a54b26 New translations common.json (Polish) 2025-06-26 15:52:19 +00:00
LinkwardenBot
6c4f694f17 New translations common.json (Dutch) 2025-06-26 15:52:19 +00:00
LinkwardenBot
a6370dd24b New translations common.json (Japanese) 2025-06-26 15:52:19 +00:00
LinkwardenBot
873f51379d New translations common.json (Italian) 2025-06-26 15:52:19 +00:00
LinkwardenBot
701824dd18 New translations common.json (German) 2025-06-26 15:52:19 +00:00
LinkwardenBot
e39876790b New translations common.json (Spanish) 2025-06-26 15:52:19 +00:00
LinkwardenBot
db7135abdf New translations common.json (French) 2025-06-26 15:52:19 +00:00
daniel31x13
0b89ab3e35 minor improvement 2025-06-26 11:07:03 -04:00
Daniel
707ec84ee2 Merge pull request #1247 from linkwarden/i18n
New Crowdin updates
2025-06-26 02:04:38 +03:30
LinkwardenBot
ba03f99666 New translations common.json (Portuguese, Brazilian) 2025-06-25 22:34:12 +00:00
LinkwardenBot
6cbf9c7153 New translations common.json (English) 2025-06-25 22:34:12 +00:00
LinkwardenBot
b230c12f61 New translations common.json (Chinese Traditional) 2025-06-25 22:34:12 +00:00
LinkwardenBot
f3cd1845b7 New translations common.json (Chinese Simplified) 2025-06-25 22:34:12 +00:00
LinkwardenBot
580c87880b New translations common.json (Ukrainian) 2025-06-25 22:34:12 +00:00
LinkwardenBot
52a10aba1e New translations common.json (Turkish) 2025-06-25 22:34:12 +00:00
LinkwardenBot
5dcf73570e New translations common.json (Russian) 2025-06-25 22:34:12 +00:00
LinkwardenBot
5cffa0bd4f New translations common.json (Polish) 2025-06-25 22:34:12 +00:00
LinkwardenBot
6a8bb8b6e8 New translations common.json (Dutch) 2025-06-25 22:34:12 +00:00
LinkwardenBot
3e3a368131 New translations common.json (Japanese) 2025-06-25 22:34:12 +00:00
LinkwardenBot
6b7fca78e1 New translations common.json (Italian) 2025-06-25 22:34:12 +00:00
LinkwardenBot
d356fc9fd5 New translations common.json (German) 2025-06-25 22:34:12 +00:00
LinkwardenBot
f198cc5923 New translations common.json (Spanish) 2025-06-25 22:34:12 +00:00
LinkwardenBot
4e50ead03e New translations common.json (French) 2025-06-25 22:34:12 +00:00
LinkwardenBot
c716a986a3 New translations common.json (Portuguese, Brazilian) 2025-06-25 22:34:12 +00:00
LinkwardenBot
fcf9548552 New translations common.json (English) 2025-06-25 22:34:12 +00:00
LinkwardenBot
918d0d46b2 New translations common.json (Chinese Traditional) 2025-06-25 22:34:12 +00:00
LinkwardenBot
a57ec28f3d New translations common.json (Chinese Simplified) 2025-06-25 22:34:12 +00:00
LinkwardenBot
fc4ba6a1fc New translations common.json (Ukrainian) 2025-06-25 22:34:12 +00:00
LinkwardenBot
b05c96360f New translations common.json (Turkish) 2025-06-25 22:34:12 +00:00
LinkwardenBot
37f13af8aa New translations common.json (Russian) 2025-06-25 22:34:12 +00:00
LinkwardenBot
1e24580010 New translations common.json (Polish) 2025-06-25 22:34:12 +00:00
LinkwardenBot
69c713bf50 New translations common.json (Dutch) 2025-06-25 22:34:12 +00:00
LinkwardenBot
e9853bc6f5 New translations common.json (Japanese) 2025-06-25 22:34:12 +00:00
LinkwardenBot
dbb055699a New translations common.json (Italian) 2025-06-25 22:34:12 +00:00
LinkwardenBot
da3d4e2c0a New translations common.json (German) 2025-06-25 22:34:12 +00:00
LinkwardenBot
27d88c2218 New translations common.json (Spanish) 2025-06-25 22:34:12 +00:00
LinkwardenBot
b59ccfffa1 New translations common.json (French) 2025-06-25 22:34:12 +00:00
daniel31x13
4bc0476e38 small fix 2025-06-25 18:30:51 -04:00
daniel31x13
eba81441ea minor fix 2025-06-25 18:24:59 -04:00
daniel31x13
4bd91f0b95 minor fix 2025-06-25 18:19:22 -04:00
daniel31x13
134757648d small fix 2025-06-25 18:09:31 -04:00
daniel31x13
86a5d0f965 small fix 2025-06-25 18:04:10 -04:00
daniel31x13
8a838f382a add locale-action.yml 2025-06-25 17:54:37 -04:00
daniel31x13
dd2361d1cf update readme 2025-06-25 16:10:16 -04:00
daniel31x13
a251e5e526 Revert "New translations common.json (Portuguese, Brazilian)"
This reverts commit f5633089f9.
2025-06-25 16:08:49 -04:00
daniel31x13
1e8a0fd1c9 Revert "add translation readme"
This reverts commit 1339b5d625.

revert
2025-06-25 15:40:12 -04:00
LinkwardenBot
d4cc066c76 Merge pull request #1245 from linkwarden/LinkwardenBot-patch-1
Update .prettierignore
2025-06-25 15:08:56 -04:00
LinkwardenBot
a1f2f3484b Update .prettierignore 2025-06-25 15:08:02 -04:00
daniel31x13
1339b5d625 add translation readme 2025-06-25 12:59:30 -04:00
Daniel
40abb89602 Merge pull request #1243 from linkwarden/dev-i18n
New Crowdin updates
2025-06-25 18:29:48 +03:30
Daniel
0a8d48a07a New translations common.json (English) 2025-06-25 18:19:34 +03:30
daniel31x13
b3530225f4 remove test 2025-06-25 10:48:40 -04:00
daniel31x13
78726a2f04 crowdin test commit 2025-06-25 10:43:10 -04:00
Daniel
c44f044505 Merge pull request #1242 from linkwarden/dev-i18n
New Crowdin updates
2025-06-25 18:11:19 +03:30
Daniel
8508360dc7 New translations common.json (Portuguese, Brazilian) 2025-06-25 18:09:30 +03:30
Daniel
c16b1c621c New translations common.json (English) 2025-06-25 18:09:28 +03:30
Daniel
e098fe1c39 New translations common.json (Chinese Traditional) 2025-06-25 18:09:27 +03:30
Daniel
7f8c856f6c New translations common.json (Chinese Simplified) 2025-06-25 18:09:26 +03:30
Daniel
8b1a88f326 New translations common.json (Ukrainian) 2025-06-25 18:09:25 +03:30
Daniel
9ced58eae8 New translations common.json (Turkish) 2025-06-25 18:09:24 +03:30
Daniel
3b2e5089d4 New translations common.json (Russian) 2025-06-25 18:09:23 +03:30
Daniel
3822475fa4 New translations common.json (Polish) 2025-06-25 18:09:21 +03:30
Daniel
929e23850b New translations common.json (Dutch) 2025-06-25 18:09:20 +03:30
Daniel
3645c6f0ff New translations common.json (Japanese) 2025-06-25 18:09:19 +03:30
Daniel
1ad44a81a3 New translations common.json (Italian) 2025-06-25 18:09:17 +03:30
Daniel
d4a808e808 New translations common.json (German) 2025-06-25 18:09:16 +03:30
Daniel
3953bb000c New translations common.json (Spanish) 2025-06-25 18:09:15 +03:30
Daniel
2be900a3f0 New translations common.json (French) 2025-06-25 18:09:14 +03:30
Daniel
92d335ddbe Merge pull request #1241 from linkwarden/dev-i18n
New Crowdin updates
2025-06-25 18:00:01 +03:30
Daniel
f5633089f9 New translations common.json (Portuguese, Brazilian) 2025-06-25 17:54:56 +03:30
Daniel
1b6f8f1fc8 New translations common.json (English) 2025-06-25 17:54:55 +03:30
Daniel
5cd0fbdf6f New translations common.json (Chinese Traditional) 2025-06-25 17:54:54 +03:30
Daniel
05a20d9279 New translations common.json (Chinese Simplified) 2025-06-25 17:54:52 +03:30
Daniel
91a5c548d8 New translations common.json (Ukrainian) 2025-06-25 17:54:51 +03:30
Daniel
95f3ae382a New translations common.json (Turkish) 2025-06-25 17:54:50 +03:30
Daniel
6e3354ff0b New translations common.json (Russian) 2025-06-25 17:54:49 +03:30
Daniel
9d92e0103c New translations common.json (Polish) 2025-06-25 17:54:48 +03:30
Daniel
a57fc3f1c2 New translations common.json (Dutch) 2025-06-25 17:54:47 +03:30
Daniel
cc3a719611 New translations common.json (Japanese) 2025-06-25 17:54:45 +03:30
Daniel
16107c8369 New translations common.json (Italian) 2025-06-25 17:54:44 +03:30
Daniel
43f6314ce2 New translations common.json (German) 2025-06-25 17:54:43 +03:30
Daniel
5884687abc New translations common.json (Spanish) 2025-06-25 17:54:42 +03:30
Daniel
835fe3de17 New translations common.json (French) 2025-06-25 17:54:40 +03:30
Daniel
f8839dbdd2 New translations common.json (Portuguese, Brazilian) 2025-06-25 17:39:39 +03:30
Daniel
b0987581c0 New translations common.json (Chinese Traditional) 2025-06-25 17:39:37 +03:30
Daniel
1a02ba64a8 New translations common.json (Chinese Simplified) 2025-06-25 17:39:36 +03:30
Daniel
0ed87d7ffc New translations common.json (Ukrainian) 2025-06-25 17:39:35 +03:30
Daniel
ddd47d74e3 New translations common.json (Turkish) 2025-06-25 17:39:33 +03:30
Daniel
213b0aafee New translations common.json (Russian) 2025-06-25 17:39:32 +03:30
Daniel
396e41a232 New translations common.json (Polish) 2025-06-25 17:39:30 +03:30
Daniel
e04c256bd3 New translations common.json (Dutch) 2025-06-25 17:39:29 +03:30
Daniel
2023d6984f New translations common.json (Japanese) 2025-06-25 17:39:28 +03:30
Daniel
43ebd72aca New translations common.json (Italian) 2025-06-25 17:39:26 +03:30
Daniel
572fa267d0 New translations common.json (German) 2025-06-25 17:39:25 +03:30
Daniel
846a233639 New translations common.json (Spanish) 2025-06-25 17:39:24 +03:30
Daniel
a6e3ae1de5 New translations common.json (French) 2025-06-25 17:39:22 +03:30
daniel31x13
c0159e5a27 minor fix 2025-06-25 09:38:16 -04:00
Daniel
ee1785ca6e Update Crowdin configuration file 2025-06-25 16:18:04 +03:30
daniel31x13
7bf4db9b25 refactor(web): use separator component 2025-06-24 14:52:09 -04:00
daniel31x13
803a97b7e0 code cleanup 2025-06-24 13:48:04 -04:00
daniel31x13
87715b8f62 revert crowdin.yml removal 2025-06-24 13:01:15 -04:00
Daniel
276a2a30c8 Update Crowdin configuration file 2025-06-24 20:29:10 +03:30
daniel31x13
bbad712e0d remove files 2025-06-24 12:51:57 -04:00
daniel31x13
32f481f65b minor fix 2025-06-24 12:34:41 -04:00
daniel31x13
b7e260e180 minor fix 2025-06-24 12:29:41 -04:00
daniel31x13
df89364c0d minor fix 2025-06-24 12:17:47 -04:00
daniel31x13
77731dcf8a minor fix 2025-06-24 12:16:05 -04:00
daniel31x13
292fadaa1e move file 2025-06-24 12:15:11 -04:00
daniel31x13
fc801f98a9 add crowdin.yml file 2025-06-24 12:10:47 -04:00
daniel31x13
a005e05d72 minor fix 2025-06-23 12:43:05 -04:00
daniel31x13
6124062a85 add crowdin file 2025-06-20 06:48:07 -04:00
Daniel
1a69058c7a Merge pull request #1226 from linkwarden/hotfix
hotfix
2025-06-12 14:38:04 +03:30
Daniel
4580f8cd26 Merge branch 'dev' into hotfix 2025-06-12 14:37:48 +03:30
Daniel
63050ae7c2 Merge pull request #1225 from linkwarden/hotfix
hotfix
2025-06-12 14:36:22 +03:30
Daniel
82f1bd943e hotfix 2025-06-12 14:35:34 +03:30
daniel31x13
6a8ce9614b fix build error 2025-06-11 11:55:47 -04:00
Daniel
60bb97e4ca Merge pull request #1223 from linkwarden/feat/import-from-pocket
feat(web): add import from pocket
2025-06-11 19:12:49 +03:30
daniel31x13
c9f2799618 feat(web): add import from pocket 2025-06-11 11:40:34 -04:00
Daniel
96b508c41a Merge pull request #1222 from linkwarden/feat/redesigned-dashboard
Feat/redesigned dashboard
2025-06-11 16:33:44 +03:30
daniel31x13
a9206ca6c9 bug fixed 2025-06-10 18:18:57 -04:00
daniel31x13
441e97e4d9 minor improvement 2025-06-10 17:46:05 -04:00
daniel31x13
a918d2c960 small change 2025-06-10 17:31:06 -04:00
daniel31x13
c9860c535b add placeholder 2025-06-10 17:28:19 -04:00
daniel31x13
c2a660fd50 better looking view 2025-06-10 17:11:42 -04:00
daniel31x13
1790638012 WIP 2025-06-10 16:32:03 -04:00
daniel31x13
c9c3941688 improvements 2025-06-10 12:55:33 -04:00
daniel31x13
9f376a633c improvements 2025-06-10 08:53:09 -04:00
daniel31x13
fa0c08a1b2 minor fix 2025-06-10 07:50:47 -04:00
daniel31x13
4e0d7f5d8e add default rows 2025-06-10 07:44:21 -04:00
Daniel
59b6b7228e Merge pull request #1216 from il516/dashboard-layout
feat(dashboard): add dashboard layout reordering
2025-06-10 14:47:22 +03:30
daniel31x13
352389958c bug fixed 2025-06-09 05:16:24 -04:00
daniel31x13
6c75f3ee58 WIP 2025-06-09 04:18:37 -04:00
daniel31x13
792d03f906 small tweaks 2025-06-08 08:46:12 -04:00
Isaac
82f4921038 fix building 2025-06-07 19:05:42 -05:00
Isaac
ff497b5f66 uncomment dropdown 2025-06-07 18:45:44 -05:00
Isaac
e012c8953c fix returned links 2025-06-07 18:42:48 -05:00
Isaac
06d8cf3cb8 format 2025-06-07 18:29:19 -05:00
Isaac
7ce78fc314 final fixes 2025-06-07 18:27:57 -05:00
Isaac
de5a43cea4 fix type 2025-06-06 23:58:41 -05:00
Isaac
6e47cc6897 Merge branch 'linkwarden:main' into dashboard-layout 2025-06-06 23:49:53 -05:00
Isaac
62b1bcbc59 fixes 2025-06-06 23:49:35 -05:00
Isaac
8718b1acfe Merge branch 'dev' of https://github.com/IsaacWise06/linkwarden into dashboard-layout 2025-06-06 22:14:46 -05:00
Isaac
f36d36dec7 optimistic update 2025-06-06 21:44:01 -05:00
Isaac
8b6ded9179 Better drag and drop 2025-06-06 21:21:14 -05:00
Isaac
4333d2686c Improve endpoint & reordering when a collection is deleted 2025-06-06 21:06:34 -05:00
daniel31x13
78c1f6246f minor fix 2025-06-06 16:52:41 -04:00
daniel31x13
941bc04fc8 small improvement 2025-06-06 16:45:52 -04:00
daniel31x13
cfa36fc8da bug fixed 2025-06-06 16:33:47 -04:00
daniel31x13
9388073cbf bug fix 2025-06-06 16:11:54 -04:00
daniel31x13
90eeba5191 feat(web): add commenting functionality 2025-06-06 11:18:03 -04:00
daniel31x13
95846c1d09 feat(web): add comments for highlights ui 2025-06-06 10:06:40 -04:00
daniel31x13
31b8092472 small improvement 2025-06-06 08:53:33 -04:00
daniel31x13
2a62c1ee1a feat(web): added highlights drawer 2025-06-06 08:49:22 -04:00
daniel31x13
5a028e98e3 minor improvement 2025-06-05 09:06:27 -04:00
Daniel
8ed5147762 Merge pull request #1209 from Talkabout/dev
improve preview generation for webpages in case site does not provide an icon
2025-06-05 00:52:07 +03:30
Daniel
a20c4a67a8 Merge branch 'dev' into dev 2025-06-05 00:51:52 +03:30
daniel31x13
bf8ac7d801 minor fix 2025-06-04 17:16:21 -04:00
Daniel
2971871a51 Merge pull request #1196 from Vlad1mir-D/fix/952-invalid-image-url
fix(worker): determine origin for ogImageUrl (closes #952)
2025-06-05 00:17:44 +03:30
Daniel
834714a941 Merge pull request #1182 from redrathnure/fix-file-handling
fix: "Error copying file" when no src file
2025-06-04 17:24:26 +03:30
daniel31x13
08523b2234 minor fix 2025-06-04 09:52:48 -04:00
daniel31x13
479fea3ea4 minor improvement 2025-06-03 17:22:53 -04:00
daniel31x13
a34f1d7308 bug fixes 2025-06-03 17:15:21 -04:00
Daniel
b18ce2239a Merge pull request #1210 from linkwarden/feat/improved-readable-page
Feat/improved readable page
2025-06-03 23:57:19 +03:30
daniel31x13
17f32152c3 minor fix 2025-06-03 16:23:06 -04:00
daniel31x13
47c711fea6 feat(web): finished readable dropdown 2025-06-03 15:57:32 -04:00
Isaac
4423a30a72 format 2025-06-02 15:52:08 -05:00
Isaac
cefe5e9e1b Bug fixes 2025-06-02 15:47:28 -05:00
Talkabout
d30fb4645e improve preview generation for webpages in case site does not provide an icon 2025-06-02 18:08:48 +02:00
daniel31x13
ae099dce4f minor improvement 2025-06-02 03:17:46 -04:00
daniel31x13
7c740f01d2 feat(web): sleeker alert + remove collection name confirmation requirement 2025-06-02 03:12:50 -04:00
daniel31x13
2b8964ca64 feat(web): add a text style dropdown to readable view 2025-06-02 02:46:24 -04:00
Isaac
048842efa4 format 2025-06-01 17:14:28 -05:00
Isaac
29d90db991 basic endpoint, drag and drop, and ordering on dashboard 2025-06-01 17:13:54 -05:00
Isaac
c99067ac37 Start endpoint 2025-05-31 22:46:58 -05:00
Isaac
4a66e1ec9c Dropdown, database & start endpoint 2025-05-31 17:19:02 -05:00
daniel31x13
1211b6b1ef feat(web): hide navbar in readable view 2025-05-30 12:26:57 -04:00
Isaac
fbb1ce9687 another constraint 2025-05-29 23:35:09 -05:00
Isaac
3959a42a30 database 2025-05-29 23:19:22 -05:00
Isaac
759cb15148 fix 2025-05-29 23:13:23 -05:00
Isaac
8ecd35acfe Start reordering dropdown 2025-05-29 23:10:44 -05:00
daniel31x13
db9ea8eef4 improved readable view 2025-05-28 12:00:07 -04:00
daniel31x13
b32151eb7e WIP 2025-05-28 09:36:25 -04:00
daniel31x13
d377fa6eb0 minor fix 2025-05-27 21:48:01 -04:00
daniel31x13
4b958faf7e improved tabs 2025-05-27 17:58:40 -04:00
daniel31x13
6981ad6d7a refactor(web): use the new tooltip component 2025-05-27 15:20:23 -04:00
daniel31x13
2d97f3a138 small improvement 2025-05-27 12:47:33 -04:00
daniel31x13
e668b67a3d refactor(web): use the new button component for consistency and improved styling 2025-05-27 12:44:29 -04:00
Daniel
41eb7df457 Merge pull request #1202 from linkwarden/feat/improved-ui
Feat/improved UI
2025-05-26 20:02:08 +03:30
Daniel
7725a5dd8b Merge branch 'dev' into feat/improved-ui 2025-05-26 20:01:41 +03:30
daniel31x13
37588acc0d Merge branch 'dev' of https://github.com/linkwarden/linkwarden into dev 2025-05-26 12:27:43 -04:00
daniel31x13
6ad2511fbc Merge branch 'PeterDaveHelloKitchen-zh-TW' into dev 2025-05-26 12:26:17 -04:00
daniel31x13
f3cb12c962 Merge branch 'zh-TW' of https://github.com/PeterDaveHelloKitchen/linkwarden into PeterDaveHelloKitchen-zh-TW 2025-05-26 12:25:30 -04:00
Daniel
99fa0b1d8b Merge pull request #1188 from SoCuul/fix/name-capitalization
fix(web): fix capitalization of collection names
2025-05-26 19:49:35 +03:30
Daniel
9db7ff329a Merge pull request #1201 from il516/bulk-refresh-preservations
feat(links): Add refresh preserved formats to bulk actions
2025-05-26 19:47:32 +03:30
daniel31x13
6bdf0f1c91 small change 2025-05-26 12:16:21 -04:00
Isaac
cd23f10480 format 2025-05-25 15:11:20 -05:00
Isaac
bea11edbe8 tooltip 2025-05-25 15:09:36 -05:00
Isaac
d565958e5f Fix permissions 2025-05-24 22:47:33 -05:00
daniel31x13
7222b19745 minor change 2025-05-24 17:05:32 -04:00
daniel31x13
b9be960c7e improved dropdown UI 2025-05-24 17:01:15 -04:00
Isaac
b337045ab2 Start bulk refreshing preserved formats 2025-05-23 21:23:07 -05:00
Vladimir
14db14ff07 fix(worker): determine origin for ogImageUrl (closes #952) 2025-05-24 02:20:04 +03:00
daniel31x13
6ca2774b28 replaced button component with shadcn button 2025-05-23 18:49:25 -04:00
daniel31x13
c3d702ad53 minor fix 2025-05-23 18:07:40 -04:00
daniel31x13
921d8a3718 base comment for shadcn 2025-05-23 17:42:48 -04:00
daniel31x13
2b258680b1 minor improvement to the dropdown component 2025-05-23 16:58:56 -04:00
daniel31x13
82f5a7026f feat(web): make things less rounded 2025-05-23 15:52:46 -04:00
daniel31x13
daef130728 feat(mobile): add modal component 2025-05-23 10:23:38 -04:00
daniel31x13
a181b43529 refactor(mobile): use nativewind instead of regular styles 2025-05-22 11:57:33 -04:00
Daniel
968bd04b40 Merge pull request #1194 from linkwarden/hotfix
minor fix
2025-05-22 13:31:52 +03:30
Daniel
46c20af530 fix logo 2025-05-22 13:30:00 +03:30
daniel31x13
cfb4081112 minor fix 2025-05-22 05:55:55 -04:00
daniel31x13
61ca5cf3e5 feat(mobile): add persistence 2025-05-19 20:14:50 -04:00
daniel31x13
cecbf694f5 fix(mobile): replace react-native-render-html package 2025-05-14 13:36:24 -04:00
daniel31x13
0e122c7485 feat(mobile): view readable formats 2025-05-14 09:36:55 -04:00
daniel31x13
c6713f67f4 feat(mobile): preserved formats basic viewing functionality 2025-05-12 12:02:31 -04:00
daniel31x13
dc8e763b76 feat(mobile): add search functionality 2025-05-12 06:27:10 -04:00
daniel31x13
2a52a0c79f fix(web): improved prompt output 2025-05-12 04:09:54 -04:00
SoCuul
08e1f499e1 fix(web): fix capitalization of collection names 2025-05-10 00:52:32 -07:00
Peter Dave Hello
63196e7b99 Update and improve zh-TW Traditional Chinese locale 2025-05-07 22:34:28 +08:00
Maxim Medvedev
7cae35ce8d fix: "Error copying file" when no src file 2025-05-06 18:52:06 +02:00
daniel31x13
369b3d6207 fix(web): active tabs now are adjusted properly 2025-05-06 08:51:10 -04:00
Daniel
3762d971e9 Merge pull request #1181 from linkwarden/dev
minor fix
2025-05-06 06:40:59 +03:30
daniel31x13
f3d8a3cc95 minor fix 2025-05-05 23:09:33 -04:00
Daniel
56efeecbfd Merge pull request #1180 from linkwarden/dev
v2.10.2
2025-05-06 06:08:22 +03:30
Daniel
be8aef987a Merge pull request #1174 from wnor543/dev
feat(web): add support for http/https proxies
2025-05-06 06:03:47 +03:30
daniel31x13
4b9de5cd96 add https-proxy-agent to worker as well 2025-05-05 22:33:11 -04:00
daniel31x13
59949e21ee bump version 2025-05-05 22:17:34 -04:00
Daniel
6fda674529 Merge pull request #1179 from linkwarden/dev
fix(worker): prevent sendToWayback from crashing
2025-05-06 02:49:50 +03:30
daniel31x13
4318207c39 fix(worker): prevent sendToWayback from crashing 2025-05-05 19:18:56 -04:00
Daniel
c48ad18379 Merge pull request #1178 from linkwarden/dev
Dev
2025-05-06 02:40:50 +03:30
daniel31x13
d647c60f04 fix(worker): exit monolith processes appropriately 2025-05-05 19:08:52 -04:00
wnor543
f83a9e899c feat(web): add support for http/https proxies 2025-05-05 11:01:45 +00:00
daniel31x13
b7c0cef8de feat(mobile): open saved items in webview 2025-05-04 13:40:43 -04:00
daniel31x13
14340afb99 code cleanup 2025-05-04 12:01:24 -04:00
Daniel
b903a12f8d Merge pull request #1171 from linkwarden/dev
minor fix
2025-05-03 19:14:04 +03:30
daniel31x13
c6f7c18441 minor fix 2025-05-03 11:42:36 -04:00
Daniel
6d334be82e Merge pull request #1170 from linkwarden/dev
v2.10.1
2025-05-03 18:14:02 +03:30
daniel31x13
195cb99c90 log processing queue to console 2025-05-03 09:10:39 -04:00
daniel31x13
2ebd311e0e small fix 2025-05-03 09:10:10 -04:00
Daniel
1da1f17ea3 Merge pull request #1168 from simcop2387/main
Implement more of the vercel openai sdk bits to allow for api compatible servers
2025-05-03 16:02:59 +03:30
daniel31x13
bc36513952 used the "@ai-sdk/openai-compatible" package instead 2025-05-03 08:15:13 -04:00
daniel31x13
b04ab898d7 bug fix 2025-05-02 11:31:01 -04:00
Ryan Voots
778dd764f6 Merge remote-tracking branch 'upstream/dev' 2025-05-02 09:11:50 -04:00
daniel31x13
64f8922741 bump meilisearch timeout and make it configurable 2025-05-02 00:28:21 -04:00
Ryan Voots
c8ed9ac72d Implement more of the vercel openai sdk bits to allow for api compatible proxies and servers 2025-05-01 22:15:08 -04:00
daniel31x13
6cfd26da32 bug fix 2025-05-01 05:48:50 -04:00
daniel31x13
b56cf3faa4 bug fix 2025-05-01 05:33:59 -04:00
daniel31x13
f7526df008 bug fix 2025-04-30 11:52:14 -04:00
daniel31x13
bbeba7f50f improved mobile app + fixed a bug in the worker script 2025-04-30 10:16:24 -04:00
daniel31x13
7e5fa3eacd minor improvement 2025-04-27 11:52:39 -04:00
daniel31x13
89826bd721 add loader 2025-04-27 11:21:34 -04:00
daniel31x13
fb819b6142 ui fix 2025-04-27 09:51:39 -04:00
daniel31x13
2e131403d4 add LinkListing component 2025-04-27 09:43:02 -04:00
daniel31x13
83db594fde bug fixed 2025-04-27 01:31:18 -04:00
daniel31x13
83b002d585 remove old readme 2025-04-24 06:07:36 -04:00
Daniel
7207259cdf Merge pull request #1155 from linkwarden/mobile-app
Mobile-app
2025-04-24 12:09:51 +03:30
daniel31x13
7c647ae02d bug fix 2025-04-23 16:44:13 -04:00
daniel31x13
0630ea536e bug fixed 2025-04-23 15:54:21 -04:00
daniel31x13
f2f73fc894 bug fixed 2025-04-23 10:49:11 -04:00
daniel31x13
7887f55dd0 bug fix 2025-04-21 20:04:16 -04:00
daniel31x13
5ba759bb41 base commit for mobile app 2025-04-21 19:05:49 -04:00
daniel31x13
f325be0364 remove old architecture file 2025-04-21 12:55:14 -04:00
daniel31x13
f3c8647ff2 move react-query to the @linkwarden/router package 2025-04-21 10:14:08 -04:00
daniel31x13
ceb6b8f8e7 add router directory 2025-04-21 09:28:59 -04:00
daniel31x13
94d953d449 adapt docker to the new folder structure + bug fix 2025-04-19 17:48:01 -04:00
daniel31x13
7f74fd75c9 made everything functional across the workspaces 2025-04-19 10:48:22 -04:00
daniel31x13
4151b37f9f separate prisma as a package 2025-04-14 13:26:55 -04:00
daniel31x13
1b45286aaf move files to /apps/web directory 2025-04-10 18:38:59 -04:00
Daniel
d578fdc0c4 Merge pull request #1104 from linkwarden/dev
v2.10.0
2025-04-08 00:42:32 +03:30
Daniel
2f2747cfc8 Merge pull request #1083 from sinsky/openrouter-provider
OpenRouter AI Provider
2025-04-05 16:59:46 +03:30
Daniel
f254bd85b5 Merge pull request #1109 from TheMeier/gitlab_url
feat(gitlab-auth): allow to configure GitLab instance
2025-04-04 21:51:53 +03:30
daniel31x13
a321d12307 small fix 2025-04-04 14:02:32 -04:00
daniel31x13
0e5e3ea00e finished highlight feature implementation 2025-04-04 13:39:54 -04:00
daniel31x13
6599fd7f5a minor fix 2025-04-04 11:29:46 -04:00
daniel31x13
225288c742 minor fix 2025-04-04 11:29:17 -04:00
daniel31x13
9bf77e849f minor improvement 2025-04-04 11:16:10 -04:00
daniel31x13
44ae6d0dbf WIP 2025-04-01 02:17:00 -04:00
Christoph Maser
3684204a03 feat(gitlab-auth): allow to configure GitLab instance
This change allows to configure the GitLab instance URL in the
`.env` file. This is useful for self-hosted GitLab instances.
2025-03-30 16:49:29 +02:00
daniel31x13
5d17b628cc small change 2025-03-20 23:41:24 -04:00
daniel31x13
197a5b3b74 bug fix 2025-03-20 23:25:32 -04:00
daniel31x13
278b674ea7 small change 2025-03-20 08:00:50 -04:00
daniel31x13
78e8078d6b upped the size limits for the preservations 2025-03-20 00:11:04 -04:00
daniel31x13
2f05dbad5d improvements to docker 2025-03-19 23:47:31 -04:00
daniel31x13
0987475e41 revert change 2025-03-19 11:56:42 -04:00
daniel31x13
41e34ba4ec small improvement 2025-03-19 11:52:43 -04:00
daniel31x13
dd78c1570f bug fix 2025-03-18 09:20:40 -04:00
daniel31x13
5bf90c56de bug fix 2025-03-18 08:33:24 -04:00
daniel31x13
35643faf85 bug fixed 2025-03-12 10:36:47 -04:00
Daniel
72c5a05324 Merge pull request #1093 from linkwarden/feat/text-highlighting
Feat/text highlighting
2025-03-12 17:34:49 +03:30
daniel31x13
0de7988b29 update highlight functionality 2025-03-11 20:12:30 -04:00
daniel31x13
43d5f0a205 remove highlight client side functionality 2025-03-11 19:57:26 -04:00
daniel31x13
d703ff072c add remove highlight server side 2025-03-11 09:22:47 -04:00
daniel31x13
6f80fa62d2 view highlights + bug fixed 2025-03-10 22:46:56 -04:00
daniel31x13
34b914b91f clear comments 2025-03-10 17:02:31 -04:00
daniel31x13
c6c8dab5db post highlight functionality 2025-03-09 14:42:06 -04:00
sinsky
d4bbdebe31 added openrouter ai provider 2025-03-07 02:09:36 +09:00
daniel31x13
2f5c431fa7 small improvement 2025-03-04 08:56:14 -05:00
daniel31x13
6d92ce64bd bug fix 2025-03-04 08:48:45 -05:00
daniel31x13
6c006bb748 bug fix 2025-03-04 08:42:03 -05:00
daniel31x13
d2cb7604fa improved preservation page 2025-03-04 08:29:12 -05:00
daniel31x13
dd061e9dc8 wip 2025-03-04 07:06:22 -05:00
daniel31x13
63c50d96d7 small improvement 2025-03-03 22:47:50 -05:00
daniel31x13
1360a03eb5 revert modal 2025-03-03 18:01:02 -05:00
daniel31x13
902c724f39 remove tiptap 2025-03-03 17:22:57 -05:00
daniel31x13
1677e5e0ab bug fixed 2025-03-02 11:56:40 -05:00
daniel31x13
1989510aac Merge branch 'dev' of https://github.com/linkwarden/linkwarden into dev 2025-03-02 05:55:52 -05:00
daniel31x13
ef3cf4bcfa add line to .env.sample 2025-03-02 05:55:46 -05:00
Daniel
645df6c0aa Merge pull request #1069 from weikinhuang/remote-playwright
Add PLAYWRIGHT_WS_URL to connect to a remote chrome instance
2025-03-02 14:24:56 +03:30
Daniel
dfad34c3dd Merge pull request #1042 from clemenstyp/azure-ai-provider
Azure AI provider
2025-03-02 13:46:37 +03:30
Daniel
f5abe4e1a2 Merge pull request #1067 from sur5r/linkcounttitlefix
Fix collection card link count title
2025-03-02 13:42:57 +03:30
Daniel
79e447c58d Merge pull request #1076 from linkwarden/feat/meilisearch-implementation
Feat/meilisearch implementation
2025-03-02 13:38:49 +03:30
daniel31x13
bc013e7819 implement the meilisearch for the public page 2025-02-28 06:02:41 -05:00
Wei Kin Huang
12844f2529 Add PLAYWRIGHT_WS_URL to connect to a remote chrome instance 2025-02-26 20:00:25 -05:00
daniel31x13
92a66933ce remove unused code 2025-02-26 09:51:54 -05:00
daniel31x13
bc01b6d4a9 remove filter dropdown 2025-02-26 09:18:22 -05:00
Jakob Haufe
23650bb684 Fix collection card link count title
The link icon on the collection card has a wrong title which is even
misleading if the collection is NOT shared publicly.

This was originally introduced in cf1306d2c4
and seemingly never noticed.
2025-02-25 11:21:14 +01:00
daniel31x13
140ee8c65d bug fix 2025-02-22 16:53:56 -05:00
daniel31x13
7c153d841a bug fix 2025-02-22 16:38:59 -05:00
daniel31x13
e06f52642a improvements 2025-02-22 11:18:53 -05:00
daniel31x13
874a909d1c minor improvement 2025-02-22 10:28:01 -05:00
daniel31x13
bbe702925b add negative queries 2025-02-22 10:22:42 -05:00
daniel31x13
49262d52cf bug fix 2025-02-22 07:52:23 -05:00
daniel31x13
5a468b44a1 wip 2025-02-22 07:19:48 -05:00
daniel31x13
6678f9c971 bump timeout 2025-02-16 13:22:39 -05:00
daniel31x13
b5a27968de bug fix 2025-02-16 12:43:51 -05:00
daniel31x13
4f1c6855aa bug fix 2025-02-16 12:11:43 -05:00
daniel31x13
50c7fcd012 bug fix 2025-02-16 11:23:03 -05:00
daniel31x13
de63ba523e small change 2025-02-16 11:16:13 -05:00
daniel31x13
5e2362ea62 bug fixed 2025-02-16 11:12:37 -05:00
daniel31x13
db94b01859 update take logic 2025-02-16 09:58:55 -05:00
daniel31x13
9bd821baa9 refactored search 2025-02-15 17:14:26 -05:00
daniel31x13
b69b535155 add package 2025-02-11 05:54:41 -05:00
Clemens Eyhoff
0bee36eced added azure ai provider 2025-02-11 10:55:05 +01:00
daniel31x13
d044a6cbba pass version to config route 2025-02-10 05:07:52 -05:00
Daniel
8095010835 Merge pull request #1039 from il516/worker
feat(archival): Implement archival tags and support deletion/updating of existing preservations
2025-02-09 21:03:41 +03:30
daniel31x13
bd21c3d8c3 more improved UI 2025-02-09 12:32:38 -05:00
daniel31x13
2bacf6e07d minor improvements 2025-02-09 10:40:20 -05:00
Isaac
5c73d7097f don't need this 2025-02-08 22:32:25 -06:00
Isaac
f77236d49e FIX 2025-02-08 22:29:13 -06:00
Isaac
1152571e49 format 2025-02-08 22:20:28 -06:00
Isaac
eda41d7132 Fix console warning 2025-02-08 22:20:16 -06:00
Isaac
dd58a8565a more fixes 2025-02-08 22:14:18 -06:00
Isaac
64d20df6f1 Fix tag selection & deleting new tags 2025-02-08 21:47:51 -06:00
Isaac
cd3f5f7e70 revert 2025-02-08 21:13:12 -06:00
Isaac
fca04dd0a0 fix building 2025-02-08 21:11:01 -06:00
Isaac
e024b324ed format 2025-02-08 20:57:14 -06:00
Isaac
f012eaee33 move api route & update schema 2025-02-08 20:56:36 -06:00
Isaac
794f8f07fa use existing TagSelection 2025-02-08 20:26:53 -06:00
Isaac
0a9aa774cc use existing modal for confirmation modal 2025-02-08 20:09:36 -06:00
Isaac
ebbc23f581 Make sure the user has an AI tagging method set 2025-02-07 20:39:01 -06:00
Isaac
bf7722aa2e format 2025-02-07 17:45:14 -06:00
Isaac
33637a8bca Regenerate preview when choosing to represerve 2025-02-07 17:44:26 -06:00
Isaac
80962c5df6 format & lint 2025-02-07 14:58:01 -06:00
Isaac
7fa9621de1 Merge branch 'dev' into worker 2025-02-07 14:52:30 -06:00
Isaac
d17fed9c85 Finish delete/update preservations 2025-02-07 14:45:18 -06:00
Isaac
81536e61f7 Update text & add third option 2025-02-06 21:32:53 -06:00
Isaac
c790781315 Start worker archival update/delete options 2025-02-06 21:21:18 -06:00
Isaac
e7a067b358 Verify user is admin 2025-02-06 18:06:01 -06:00
Isaac
4156126a71 add archival delete/update route 2025-02-06 18:04:52 -06:00
Isaac
95f456bbb6 Add tag archival settings to the worker 2025-02-06 17:34:49 -06:00
Isaac
102cd1a6cf Fix endpoint & useMutation callback 2025-02-06 16:49:26 -06:00
Isaac
395dc5c0cb Track changes to prevent useless requests 2025-02-06 16:20:19 -06:00
Isaac
7f0c8f4bbf finish endpoint 2025-02-05 18:48:41 -06:00
Isaac
671f27ccde fix deleting archival tags 2025-02-05 17:46:36 -06:00
Isaac
c90b53376d Archival tags post route 2025-02-05 16:47:16 -06:00
daniel31x13
13ed7b6cdc bug fixed 2025-02-05 06:49:38 -05:00
Daniel
3c71301cbc Merge pull request #1021 from stuzer05/dev
Add collection select sorting, fix inconsistent sidebar reorder
2025-02-05 15:15:40 +03:30
daniel31x13
833a871731 remove extra logging 2025-02-05 05:56:54 -05:00
daniel31x13
7b3a4e48c1 remove note modal to focus on advanced bookmarking functionality 2025-02-05 05:50:18 -05:00
Isaac
76112534f6 remove duplicate functions 2025-02-04 17:06:43 -06:00
Isaac
adafe36cd7 Merge branch 'worker' of https://github.com/il516/linkwarden into worker 2025-02-04 16:58:00 -06:00
Isaac
0123791b8a use a hook to manage state for archival tags input 2025-02-04 16:51:56 -06:00
Isaac
81f150c0ce Merge branch 'linkwarden:main' into worker 2025-02-04 01:50:12 -06:00
Isaac
9177ad6a72 complete toggling options 2025-02-03 21:53:16 -06:00
Isaac
d360f612e2 add ai tagging & fix select 2025-02-03 21:13:41 -06:00
Daniel
b0496e2e65 Merge pull request #942 from AverageHelper/avg/decode-html-entities
[Fix] Work around parser bug that mangles attribute values
2025-02-03 19:35:28 +03:30
daniel31x13
c5751386fa undo changes to yarn.lock 2025-02-03 11:05:10 -05:00
Daniel
406647d687 Merge pull request #1003 from Kur0den/dev
Fix mistranslation and add new translation in Japanese
2025-02-03 18:11:46 +03:30
Daniel
5949a0965c Merge pull request #1017 from YeeJiaWei/fix/double-overflow
fixed height causing double overflow
2025-02-03 18:01:24 +03:30
daniel31x13
c015bed8a7 small improvement 2025-02-03 09:30:52 -05:00
daniel31x13
504af53f18 bug fix 2025-02-03 08:26:25 -05:00
daniel31x13
d822db440b improved UX 2025-02-03 08:22:18 -05:00
daniel31x13
181e1009e5 fix build errors 2025-02-03 07:47:46 -05:00
Daniel
db16044949 Merge pull request #1028 from il516/more-ai-providers
feat(tags): Ability to use OpenAI/Anthropic & auto tag existing links
2025-02-03 16:09:03 +03:30
daniel31x13
1626a277e0 better UX 2025-02-03 07:38:16 -05:00
daniel31x13
616efffed2 improvements 2025-02-03 06:57:22 -05:00
daniel31x13
c9dd143d59 remove extra logging 2025-02-03 04:14:15 -05:00
Isaac
0a4d62491d Start tag archival options 2025-02-02 15:59:37 -06:00
daniel31x13
9e417b7f35 bug fixed 2025-02-02 11:30:29 -05:00
daniel31x13
81e9b27683 small change 2025-02-02 03:09:20 -05:00
Isaac
942ae4af99 format 2025-02-01 18:03:26 -06:00
Isaac
f7e9119450 Remove comment 2025-02-01 18:03:10 -06:00
Isaac
d71216a908 Add user option to archive as readable 2025-02-01 18:02:12 -06:00
Isaac
f61ce5563d Max tag name length 2025-02-01 17:10:31 -06:00
Isaac
575d91832e format 2025-02-01 17:07:56 -06:00
Isaac
15fe4575f9 Use existing tags for ai tagging 2025-02-01 17:04:54 -06:00
daniel31x13
9ba9b06ae3 remove comments 2025-02-01 06:27:14 -05:00
daniel31x13
cf16344aef add note modal 2025-02-01 06:25:39 -05:00
Isaac
29ed07c74a format 2025-01-29 17:25:52 -06:00
Isaac
493e0e2f6b Merge branch 'more-ai-providers' of https://github.com/il516/linkwarden into more-ai-providers 2025-01-29 17:04:52 -06:00
Isaac
d76d99844c remove console log 2025-01-29 17:04:28 -06:00
Isaac
533f29706e Merge branch 'linkwarden:main' into more-ai-providers 2025-01-29 15:28:14 -06:00
Isaac
88cf45c7c2 format 2025-01-29 14:12:48 -06:00
Isaac
b100129d80 use env interval 2025-01-29 14:12:10 -06:00
Isaac
a267d4ed3a format 2025-01-29 14:09:15 -06:00
Isaac
9d8d5f0fa0 auto tag existing links & use meta description 2025-01-29 14:08:34 -06:00
daniel31x13
aa6b068d92 remove unused imports 2025-01-29 04:37:08 -05:00
daniel31x13
4b230e01d3 fix typo 2025-01-29 03:01:13 -05:00
daniel31x13
d140e2109f wip 2025-01-29 02:44:13 -05:00
Isaac
d5703ba70e Fix ollama 2025-01-28 20:01:56 -06:00
Isaac
5f12046a49 Provide list of models for openai & anthropic 2025-01-28 19:26:52 -06:00
Isaac
edad55d608 use provided ollama URL 2025-01-28 19:22:00 -06:00
Isaac
fac46de09c Add OpenAI & Anthropic 2025-01-28 19:19:16 -06:00
stuzer05
007de56cd3 Commit 2025-01-27 10:45:33 +02:00
stuzer05
8efdf6d87b Commit 2025-01-27 10:34:23 +02:00
daniel31x13
69225e0642 minor improvement 2025-01-27 02:40:13 -05:00
daniel31x13
0aa23e27b3 minor fix 2025-01-27 02:16:23 -05:00
daniel31x13
3bf2daddd1 improvements 2025-01-27 02:08:12 -05:00
stuzer05
71644e6e4e Commit 2025-01-27 08:55:51 +02:00
daniel31x13
6e7f92c046 improvements 2025-01-27 00:59:57 -05:00
daniel31x13
8d4504262b small fix 2025-01-27 00:34:36 -05:00
daniel31x13
20224c835a small improvement 2025-01-27 00:25:19 -05:00
daniel31x13
c3873b030f full width expanded mode 2025-01-27 00:20:17 -05:00
daniel31x13
cf8a202afa small fix 2025-01-27 00:05:34 -05:00
daniel31x13
c79b1e6492 cleaner code 2025-01-27 00:04:33 -05:00
daniel31x13
e21e3ecaae minor change 2025-01-26 14:12:39 -05:00
daniel31x13
b84840e12c wip 2025-01-25 11:35:01 -05:00
daniel31x13
f8a130ae6e add component 2025-01-25 10:11:34 -05:00
daniel31x13
852de0d587 minor fix 2025-01-24 18:00:50 -05:00
daniel31x13
eecfb112e3 small change 2025-01-24 17:45:01 -05:00
daniel31x13
81a35655e9 add portal wrapper component 2025-01-24 17:28:29 -05:00
Yee Jia Wei
c45b44cdbc fixed height causing double overflow 2025-01-25 02:56:35 +08:00
daniel31x13
ecc48f8fe2 wip 2025-01-24 06:10:44 -05:00
daniel31x13
e1c4f85e53 small fix 2025-01-22 04:01:22 -05:00
daniel31x13
f8c96b493c wip 2025-01-22 03:49:16 -05:00
daniel31x13
63904f6d41 small fix 2025-01-19 12:37:32 -05:00
Daniel
9083d9a01b Merge pull request #1008 from linkwarden:patch
add issue template
2025-01-19 10:53:51 -05:00
Daniel
806cba8110 Merge pull request #1007 from linkwarden/patch
add issue template
2025-01-19 10:51:50 -05:00
daniel31x13
ae24358a77 add issue template 2025-01-19 10:50:44 -05:00
daniel31x13
f0dfd5568e small fix 2025-01-18 16:19:20 -05:00
daniel31x13
9d17600124 bug fix 2025-01-18 15:58:58 -05:00
daniel31x13
6a72d7894b bug fixed 2025-01-18 00:56:31 -05:00
daniel31x13
00c33d48f0 wip 2025-01-18 00:36:55 -05:00
daniel31x13
6573c683f6 support for markdown for readable content 2025-01-15 22:11:26 -05:00
Kur0den0010
5412545c6f Change translate in Japanese
Change start_journey
2025-01-16 10:54:18 +09:00
Kur0den0010
eee06e2be9 Add new translation in Japanese
Add invalid_url_guide to search_query_invalid_symbol
2025-01-16 10:52:01 +09:00
Kur0den0010
c3e8097aac Add missing translation keys in Japanese
Add invalid_url_guide to search_query_invalid_symbol
2025-01-16 10:45:16 +09:00
Kur0den0010
2d97feef17 Add missing translation in Japanese
Add from_omnivore
2025-01-16 10:43:42 +09:00
Kur0den0010
74c0a40622 Fix mistranslation in Japanese
Change created
2025-01-16 10:42:06 +09:00
daniel31x13
56741b123b minor fix 2025-01-15 19:02:06 -05:00
daniel31x13
e8c6cc45f4 minor fix 2025-01-15 18:33:22 -05:00
daniel31x13
d0c999655c improved preservation view 2025-01-15 18:32:29 -05:00
daniel31x13
05594e6507 redesigned preserved view 2025-01-15 12:10:17 -05:00
daniel31x13
b59663ea91 WIP 2025-01-15 05:56:11 -05:00
daniel31x13
8d2029a19d wip 2025-01-15 05:04:21 -05:00
daniel31x13
cee2f0a759 WIP 2025-01-15 02:57:27 -05:00
daniel31x13
032f96191e WIP 2025-01-15 02:33:39 -05:00
Daniel
e87bfc83bf Merge pull request #996 from linkwarden/dev
v2.9.3
2025-01-13 11:17:32 -05:00
daniel31x13
aa0c7d64f4 bug fixes 2025-01-13 11:12:41 -05:00
Daniel
b8a1839fb6 Merge pull request #972 from siberianspot/main
Localization and small fixes
2025-01-12 18:46:32 -05:00
Daniel
629574c6b2 Merge branch 'dev' into main 2025-01-12 18:45:51 -05:00
daniel31x13
1bb7d3cc5c fixes #981 2025-01-11 09:47:49 -05:00
daniel31x13
6b811c3e7d undo 2025-01-11 09:44:27 -05:00
daniel31x13
a941eec569 bug fix 2025-01-11 08:47:54 -05:00
Daniel
5ed79f0f5b Merge pull request #990 from linkwarden/dev
update README
2025-01-10 15:09:26 -05:00
daniel31x13
010ca8eeae update README 2025-01-10 14:59:07 -05:00
Denis Bryukhanov
5840ffc620 Merge pull request #1 from linkwarden/main
Merge with latest
2025-01-08 01:53:44 +07:00
siberian
14754a23f1 Adding missing localizations, Fix Russian localization, Correcting a duplicate value in the settings 2025-01-08 01:50:21 +07:00
Daniel
1a501b5365 Merge pull request #967 from linkwarden/dev
Dev
2025-01-07 08:28:11 -05:00
daniel31x13
3bc9bbf074 bump version 2025-01-07 08:26:37 -05:00
daniel31x13
09a52dd260 bug fixed 2025-01-07 08:26:04 -05:00
Daniel
84e99b55c9 Merge pull request #959 from linkwarden/dev
revert variable name
2025-01-06 13:32:38 -05:00
daniel31x13
22c4fbf613 revert variable name 2025-01-06 13:32:02 -05:00
Daniel
0e1b51177b Merge pull request #958 from linkwarden/dev
v2.9.1
2025-01-06 13:28:45 -05:00
daniel31x13
44499c1277 bump version 2025-01-06 13:28:25 -05:00
daniel31x13
9d986356a7 switch to react-query for fetching the config 2025-01-06 12:56:17 -05:00
daniel31x13
1d854e16aa bug fix 2025-01-06 12:39:16 -05:00
daniel31x13
02c02fc3b9 use SSR for public ENV 2025-01-06 11:32:44 -05:00
Daniel
99bdc7d55e Merge pull request #954 from linkwarden/dev
bug fixed
2025-01-06 05:44:35 -05:00
daniel31x13
62c7bbbb74 bug fixed 2025-01-06 05:43:22 -05:00
Daniel
dfb31ab1b3 Merge pull request #948 from linkwarden/dev
Linkwarden v2.9.0
2025-01-06 05:09:24 -05:00
daniel31x13
e0c0b76eb0 update README 2025-01-05 19:25:00 -05:00
daniel31x13
9bc261bc85 minor change 2025-01-02 23:16:03 -05:00
daniel31x13
b2d2e23539 fix: better green 2025-01-02 16:37:07 -05:00
daniel31x13
04c69bb05f feat: add option to open link details 2025-01-02 16:33:58 -05:00
daniel31x13
0cc1fd8407 WIP 2025-01-02 16:26:38 -05:00
daniel31x13
848e0bf50e feat: quick look at the preserved formats 2025-01-02 15:42:47 -05:00
daniel31x13
c3981c7fff feat: support for multiple themes 2025-01-02 13:48:35 -05:00
daniel31x13
ec7d6f4a6b minor improvement 2024-12-31 18:43:11 -05:00
daniel31x13
9d8b602839 feat: choose what to show in dashboard 2024-12-31 18:42:34 -05:00
daniel31x13
f0f57fb1a9 bug fix 2024-12-31 17:01:27 -05:00
daniel31x13
88820361e9 update README 2024-12-31 08:36:06 -05:00
daniel31x13
be47c78e4d small improvement to the public collection page 2024-12-30 06:05:15 -05:00
daniel31x13
fa059d1b00 bug fixed 2024-12-30 04:15:43 -05:00
daniel31x13
bcfec38adf bug fixed 2024-12-30 04:07:48 -05:00
AverageHelper
0344467cb7 feat: Use the same decoder that JSDom uses to encode 2024-12-29 01:37:22 -07:00
daniel31x13
454ed7b7eb more efficient docker-compose file 2024-12-29 02:04:28 -05:00
Daniel
51da37a22f Merge pull request #893 from zodac/dev
Adding healthcheck to docker image
2024-12-29 01:43:00 -05:00
Daniel
6edbc4f438 Merge pull request #902 from keizie/fix/import-pin
fix(import): save pinnedLinks from Linkwarden export json
2024-12-29 01:17:00 -05:00
AverageHelper
899ddafd90 fix(import-html): Work around parser bug that mangles attribute values 2024-12-28 23:11:50 -07:00
Daniel
755721f1c2 Merge pull request #920 from Zalaxx/dev
fix: HTML backup with Monolith when using Docker
2024-12-29 01:04:46 -05:00
Daniel
0b20f61913 Merge pull request #928 from il516/rss-feeds
feat(rss): Add RSS feeds for collections
2024-12-25 08:08:00 -05:00
daniel31x13
e9cf93d769 minor improvement 2024-12-25 08:07:15 -05:00
Daniel
f94d10a3d3 Merge pull request #934 from mrkhachaturov/main
russian language support
2024-12-24 09:31:34 -05:00
Daniel
6a95f6efdc Merge branch 'dev' into main 2024-12-24 09:30:19 -05:00
Daniel
150eeb9f11 Merge pull request #875 from dereulenspiegel/omnivore-import
Omnivore import
2024-12-24 09:27:48 -05:00
daniel31x13
fe77625289 finalized the implementation 2024-12-24 09:22:53 -05:00
zodac
63e7377df4 Installing curl 2024-12-24 21:48:16 +13:00
Ruben Khachaturov
458eae9a3c russian language support 2024-12-23 08:42:48 +03:00
daniel31x13
7ef2afae7f small change 2024-12-22 14:07:10 -05:00
Isaac Wise
a1d02f110d Fix RSS Subscription form & move endpoint 2024-12-19 17:16:07 -06:00
Isaac Wise
378fec06bb Format & Lint 2024-12-19 16:06:24 -06:00
Isaac Wise
e04997c8c4 Add RSS Feed button 2024-12-19 16:05:34 -06:00
Isaac Wise
aecb90b4a4 use correct protocol 2024-12-19 01:02:56 -06:00
Isaac Wise
dac6ec966c rss feeds 2024-12-19 00:44:53 -06:00
daniel31x13
582159f454 resolved conflicts 2024-12-14 10:28:46 -05:00
dereulenspiegel
cf02b7a099 Rebased to dev and ran formatting 2024-12-13 18:18:32 +01:00
dereulenspiegel
7b4d324852 Previous import mechanism is restored to previous behavior, while omnivore uses new binary import path 2024-12-13 18:17:57 +01:00
dereulenspiegel
8932b9929a Moving omnivore import to specific binary upload route 2024-12-13 18:17:56 +01:00
dereulenspiegel
6b416f23f0 Now limiting the read size for imports in a configuration compatible way to the previous implementation 2024-12-13 18:17:55 +01:00
dereulenspiegel
fb25cf5c75 Adopted other import functions to handle streams instead of text 2024-12-13 18:17:54 +01:00
dereulenspiegel
c8f33f4800 Returning 200 success to avoid stalled responses 2024-12-13 18:17:53 +01:00
dereulenspiegel
95f818959b Made the import links dropdown menu reusable to avoid code duplication 2024-12-13 18:17:52 +01:00
dereulenspiegel
a0aabde322 Fixed the unnecessary creation of multiple import collections 2024-12-13 18:17:51 +01:00
dereulenspiegel
0e3ca5b51f Made importBookmarks reusable 2024-12-13 18:17:46 +01:00
dereulenspiegel
a733cc69a3 Import should now work on testdata 2024-12-13 18:17:03 +01:00
dereulenspiegel
cbc88ebcb2 Revert "Added adm-zip dependency to be able to handle zip files for importing omnivore exports"
This reverts commit 47367c44c1.
2024-12-13 18:17:02 +01:00
dereulenspiegel
c3b78a8f82 Added adm-zip dependency to be able to handle zip files for importing omnivore exports 2024-12-13 18:17:00 +01:00
dereulenspiegel
8ad9ab7755 Added basic infrastructure to be able to have omnivore imports 2024-12-13 18:16:58 +01:00
daniel31x13
4bf220c786 minimize the usage of external dependencies 2024-12-11 09:36:42 -05:00
Daniel
ed8f2d3777 Merge pull request #894 from KittyKatt/dev
Sub-collections included in collection page and total link count
2024-12-10 07:15:35 -05:00
daniel31x13
5b87799fdd improvements 2024-12-10 07:14:17 -05:00
Daniel
97abe5de0c Merge branch 'dev' into omnivore-import 2024-12-10 06:25:01 -05:00
daniel31x13
731b259329 update version number 2024-12-09 23:48:41 -05:00
daniel31x13
63cef8e6b0 minor fix 2024-12-09 22:38:51 -05:00
Daniel
18db677c29 Merge pull request #921 from linkwarden/ai-tagging
Ai tagging
2024-12-09 22:36:20 -05:00
Daniel
6071aa617f Merge branch 'dev' into ai-tagging 2024-12-09 22:33:03 -05:00
daniel31x13
f270adbffa fully implemented ai tagging 2024-12-09 22:31:39 -05:00
daniel31x13
6259048431 remove pending format logic 2024-12-09 16:17:39 -05:00
daniel31x13
40f4a5acd9 cleaner code + add "aiTagged" field 2024-12-09 14:10:20 -05:00
Zalax
4240d37d77 Merge branch 'linkwarden:dev' into dev 2024-12-09 19:07:11 +00:00
Zalax
0abe065c0c fix: HTML backup with Monolith when using Docker
ca-certificates is needed to not have certificates error when monolith is retrieving a website's resources.

It does not happend with other backup format because the retrieving is done with a headless chrome and thus has its own certificate store
2024-12-09 20:06:06 +01:00
Daniel
346f41a12c Merge pull request #911 from AmadeusGraves/dev
Update Spanish Lang
2024-12-08 07:42:09 -05:00
Daniel
2ffbede170 Merge pull request #910 from il516/rss-feed-subscriptions
feat(rss): Add RSS Subscriptions
2024-12-08 07:41:14 -05:00
daniel31x13
c148c2b953 improvements 2024-12-08 07:37:27 -05:00
daniel31x13
a872f218fb added a base prompt for the tag generation... 2024-12-05 14:04:02 -05:00
Isaac Wise
ff1f87cb35 uncomment collection table cell 2024-12-05 02:49:37 -06:00
AmadeusGraves
94b143c91a Update common.json
Upload translation to spanish.
2024-12-05 09:48:07 +01:00
Isaac Wise
52a11040f6 Fix capitalization 2024-12-05 02:22:38 -06:00
Isaac Wise
e9c43d75fe remove console log 2024-12-05 02:04:21 -06:00
Isaac Wise
47b226cf1f format 2024-12-05 02:01:47 -06:00
Isaac Wise
266f834018 RSS form 2024-12-05 01:53:53 -06:00
Isaac Wise
c9885c0b73 format 2024-12-04 16:56:26 -06:00
Isaac
ec885a7db2 Merge branch 'linkwarden:main' into rss-feed-subscriptions 2024-12-04 16:52:52 -06:00
Isaac Wise
5665fbb412 Include collection name in table 2024-12-04 16:51:41 -06:00
Isaac Wise
9e933bd630 Update yarn lock 2024-12-04 01:55:29 -06:00
Isaac Wise
3572e101fb Table & Delete modal 2024-12-04 01:53:25 -06:00
Isaac Wise
fb06549330 Delete RSS Subscription 2024-12-04 01:18:11 -06:00
Isaac Wise
991f12566f Start RSS Subscriptions 2024-12-04 01:12:05 -06:00
dereulenspiegel
160845319f Now limiting the read size for imports in a configuration compatible way to the previous implementation 2024-12-01 15:27:16 +01:00
Daniel
bff5a7ae9a Merge pull request #903 from keizie/fix/import-url-slice
fix(import): keep url not sliced
2024-12-01 06:26:56 -05:00
Daniel
6e01c135dc increase the url limits 2024-12-01 06:25:13 -05:00
Daniel
f32e5a8d05 Merge pull request #897 from 7Adrian/7Adrian-pl-language
Added Polish "polski" language.
2024-12-01 06:02:55 -05:00
Daniel
e9003e5c45 added locale to configs 2024-12-01 05:56:38 -05:00
keizie
9065756686 fix(import): save pinnedLinks from Linkwarden export json 2024-12-01 15:25:30 +09:00
keizie
881df93c02 fix(import): keep url not sliced 2024-12-01 13:01:43 +09:00
Daniel
ed7fab0473 Merge pull request #899 from linkwarden/dev
minor fix
2024-11-30 16:13:46 -05:00
daniel31x13
38d054e143 minor fix 2024-11-30 16:12:10 -05:00
Daniel
5e89658a11 Merge pull request #898 from linkwarden/dev
Dev
2024-11-30 16:10:47 -05:00
daniel31x13
d964e02ba1 update readme 2024-11-30 16:09:31 -05:00
daniel31x13
a36eb23096 update version number 2024-11-30 15:55:36 -05:00
7Adrian
8f875d15b0 Added Polish "polski" language.
Add Polish language to public/locales/pl/common.json.
2024-11-30 17:38:48 +01:00
Daniel
9f660ff70f Merge pull request #885 from Go-rom/2.8.3-french-translation
2.8.3 french translation
2024-11-29 23:32:52 -05:00
Daniel
e6f43bbbfa Merge pull request #896 from keizie/fix-monolith-error
fix(monolith): quote url
2024-11-29 23:31:10 -05:00
keizie
1609868149 fix(monolith): quote url
when url include ampersand shell fails
```
/bin/sh: 1: amp: not found
/bin/sh: 1: -j: not found
Uncaught Monolith error...
```
2024-11-30 11:07:56 +09:00
daniel31x13
9075618e00 added ai tagging to the settings 2024-11-29 12:57:42 -05:00
Katie Bohnenkamper
179cd18ac5 Sub-collections shown on collection page 2024-11-29 00:25:50 -06:00
Katie Bohnenkamper
5a5fa9ed6c Sub-collections included in link count on tree 2024-11-29 00:24:59 -06:00
zodac
5279d94b8c Adding healthcheck to docker image 2024-11-29 13:43:37 +13:00
Gorom
5c3848e833 Update french translation 2024-11-20 17:15:59 +01:00
Gorom
27d7bbabb3 Update french translation 2024-11-20 16:56:41 +01:00
dereulenspiegel
8ff1346bf1 Adopted other import functions to handle streams instead of text 2024-11-19 12:51:21 +01:00
dereulenspiegel
71119d511e Returning 200 success to avoid stalled responses 2024-11-19 12:48:13 +01:00
dereulenspiegel
b7eb8f2c2f Made the import links dropdown menu reusable to avoid code duplication 2024-11-19 12:47:03 +01:00
dereulenspiegel
3d54cc05a4 Fixed the unnecessary creation of multiple import collections 2024-11-18 18:02:02 +01:00
dereulenspiegel
8973bdd94e Made importBookmarks reusable 2024-11-18 17:54:36 +01:00
dereulenspiegel
1af37f3619 Import should now work on testdata 2024-11-18 17:39:06 +01:00
daniel31x13
5303d63e4b add option to disable preservation 2024-11-17 16:06:05 -05:00
Daniel
05a30e1ec6 Merge pull request #867 from clemenstyp/fixed-placeholder
fixed placeholder in german translation (delete collection)
2024-11-16 09:13:21 -05:00
daniel31x13
b1a55785b5 import dates as well 2024-11-16 08:58:03 -05:00
Clemens Eyhoff
24b47e9d4b fixed an other uppercase placeholder 2024-11-16 14:49:07 +01:00
Clemens Eyhoff
34d19f9dbe fixed placeholder (was upper case) 2024-11-16 12:46:44 +01:00
dereulenspiegel
3a70e138b5 Revert "Added adm-zip dependency to be able to handle zip files for importing omnivore exports"
This reverts commit 47367c44c1.
2024-11-15 12:50:04 +01:00
dereulenspiegel
47367c44c1 Added adm-zip dependency to be able to handle zip files for importing omnivore exports 2024-11-15 12:43:17 +01:00
dereulenspiegel
e1a31481ad Added basic infrastructure to be able to have omnivore imports 2024-11-15 12:39:13 +01:00
Daniel
95dddd7da0 Merge pull request #859 from linkwarden/dev
Dev
2024-11-14 15:45:17 -05:00
daniel31x13
1a949ecdc6 bug fix 2024-11-14 15:44:31 -05:00
daniel31x13
2e6f1c207c bug fixed 2024-11-14 15:43:37 -05:00
Daniel
6aa0fa9465 Merge pull request #857 from linkwarden/dev
minor UI improvement
2024-11-14 11:01:41 -05:00
daniel31x13
8677df0340 minor UI improvement 2024-11-14 11:01:02 -05:00
Daniel
125f6ac619 Merge pull request #856 from linkwarden/dev
update version
2024-11-14 08:51:33 -05:00
daniel31x13
89ecf5c529 update version 2024-11-14 08:50:52 -05:00
Daniel
fa78d6057f Merge pull request #855 from linkwarden/dev
bug fix
2024-11-14 08:50:10 -05:00
daniel31x13
cfc28be898 bug fix 2024-11-14 08:48:18 -05:00
Daniel
c8efd4f9db Merge pull request #852 from linkwarden/dev
created check-branch.yml
2024-11-14 02:43:50 -05:00
daniel31x13
ada4e53b46 created check-branch.yml 2024-11-14 02:42:01 -05:00
Daniel
91494b0188 Merge pull request #849 from linkwarden/dev
increase staticPageGenerationTimeout
2024-11-13 22:20:17 -05:00
daniel31x13
e9fd6ec4d5 increase staticPageGenerationTimeout 2024-11-13 22:19:18 -05:00
Daniel
f08f4058dc Merge pull request #846 from linkwarden/dev
update Dockerfile
2024-11-13 09:23:59 -05:00
daniel31x13
d60200205a update Dockerfile 2024-11-13 09:23:22 -05:00
Daniel
de38eb2963 Merge pull request #845 from linkwarden/dev
revert dockerfile to working state
2024-11-13 06:14:46 -05:00
daniel31x13
f22dd4535d revert dockerfile to working state 2024-11-13 06:12:39 -05:00
Daniel
043589b301 Merge pull request #841 from linkwarden/dev
update version number
2024-11-13 00:01:50 -05:00
daniel31x13
4556827d79 update version number 2024-11-13 00:01:12 -05:00
Daniel
98ebd6d7bc Merge pull request #840 from linkwarden/dev
Dev
2024-11-12 23:59:47 -05:00
daniel31x13
0a3ca4a1d4 Merge branch 'dev' of https://github.com/linkwarden/linkwarden into dev 2024-11-12 23:58:53 -05:00
daniel31x13
106410f55a revert change 2024-11-12 23:58:50 -05:00
Daniel
1ffe1b68a9 Merge pull request #839 from linkwarden/dev
Dev
2024-11-12 23:15:52 -05:00
Daniel
91ab0e609b Merge pull request #833 from click0/Ukrainian-translation
Corrected Ukrainian translation for November 12, 2024
2024-11-12 23:13:38 -05:00
daniel31x13
cbb7a666cd bug fix 2024-11-12 23:12:20 -05:00
Daniel
e8cf14334f Merge pull request #838 from linkwarden/dev
added status badges
2024-11-12 22:50:33 -05:00
daniel31x13
019790791b added status badges 2024-11-12 22:49:38 -05:00
Daniel
e41ba2668f Merge pull request #837 from linkwarden/dev
bug fix
2024-11-12 22:18:25 -05:00
daniel31x13
66a09fdc4b bug fix 2024-11-12 22:16:42 -05:00
Daniel
e50143ca7e Merge pull request #835 from linkwarden/dev
minor change
2024-11-12 18:22:28 -05:00
daniel31x13
162b120e55 minor change 2024-11-12 18:21:58 -05:00
Daniel
b4dd47aa37 Merge pull request #834 from linkwarden/dev
updated README
2024-11-12 17:40:32 -05:00
daniel31x13
256c232a85 updated README 2024-11-12 17:39:48 -05:00
vlad11
b7ddf22662 Corrected Ukrainian translation for November 12, 2024
Signed-off-by: vlad11 <admin@support.od.ua>
2024-11-13 00:12:32 +02:00
Daniel
5f60e9833e Merge pull request #831 from linkwarden/dev
bug fix
2024-11-12 16:18:15 -05:00
daniel31x13
ceed23ff51 bug fix 2024-11-12 16:17:38 -05:00
daniel31x13
a4c83dc82f small fix 2024-11-12 08:36:40 -05:00
daniel31x13
46f81ebf25 add info to inviteModal 2024-11-10 16:42:04 -05:00
daniel31x13
0ac5009a4a minor change 2024-11-10 00:27:13 -05:00
daniel31x13
6842da4283 new feature: open all links 2024-11-09 23:59:12 -05:00
daniel31x13
78ecf3ddb5 bug fix 2024-11-09 23:33:13 -05:00
daniel31x13
e39645e135 bug fix 2024-11-09 23:11:03 -05:00
daniel31x13
836360f99d bug fixed 2024-11-09 23:07:01 -05:00
daniel31x13
9c9fd969bc minor fix 2024-11-09 15:27:15 -05:00
daniel31x13
213105942b minor change 2024-11-09 15:02:59 -05:00
daniel31x13
0b7acb35b7 minor change 2024-11-09 14:14:13 -05:00
daniel31x13
9b58ea5c98 minor change 2024-11-09 13:45:11 -05:00
daniel31x13
c85c3bb0d7 minor fix 2024-11-08 18:03:00 -05:00
daniel31x13
7ca574b76f bug fixes 2024-11-08 17:57:50 -05:00
daniel31x13
8593df4673 bug fixed 2024-11-08 17:21:20 -05:00
daniel31x13
ddc2079f4b minor fix 2024-11-08 12:25:31 -05:00
daniel31x13
0de5caffa1 minor fix 2024-11-08 05:24:18 -05:00
daniel31x13
b14e77bdf9 minor fix 2024-11-08 04:48:31 -05:00
daniel31x13
8d366ae7d8 minor fix 2024-11-07 16:54:51 -05:00
daniel31x13
a18938ba2a minor fix 2024-11-07 16:46:26 -05:00
daniel31x13
6eac8423f8 added survey 2024-11-07 11:09:36 -05:00
daniel31x13
cbf93dcf06 minor improvement 2024-11-07 07:32:06 -05:00
daniel31x13
2993347dc7 sleeker dashboard items 2024-11-07 02:09:56 -05:00
daniel31x13
cc45c8fc3e minor improvement 2024-11-07 01:17:30 -05:00
daniel31x13
d5602a09cd minor fix 2024-11-07 01:14:23 -05:00
daniel31x13
736e98ac7d improvements 2024-11-07 01:12:05 -05:00
daniel31x13
7eaff332a9 bug fixed 2024-11-07 00:20:57 -05:00
daniel31x13
7931e2d7b6 better logic when showing link icons 2024-11-07 00:19:12 -05:00
daniel31x13
ac3888f9b3 icon picker is now much more efficient 2024-11-06 23:57:20 -05:00
daniel31x13
ac8add8c5d small improvement 2024-11-06 22:53:21 -05:00
daniel31x13
a6a0f6965b bug fixed 2024-11-06 03:36:02 -05:00
Daniel
b2c5c3c6dd Merge pull request #792 from jvanbruegge/prisma-update
Update prisma to v5
2024-11-03 14:20:24 -05:00
Daniel
4555874725 Merge branch 'dev' into prisma-update 2024-11-03 14:19:28 -05:00
daniel31x13
0f5b70eda7 update prisma 2024-11-03 03:59:39 -05:00
daniel31x13
d1c3748681 minor improvement 2024-11-03 03:34:21 -05:00
Daniel
2524139113 Merge pull request #816 from linkwarden/main
Merge main to dev
2024-11-03 03:19:40 -05:00
Daniel
6c2b86fc4b Merge branch 'dev' into main 2024-11-03 03:19:29 -05:00
Daniel
d0e0526655 Merge pull request #815 from Green-Kite/dev
update german translation
2024-11-03 03:10:40 -05:00
Green-Kite
43e94ebd0b update german translation
updated german translation
2024-11-03 07:24:38 +01:00
Daniel
aeafe6e15d Merge pull request #789 from jvanbruegge/playwright-path
Allow to specify a custom playwright browser path
2024-11-02 21:59:12 -04:00
daniel31x13
5ec221d87d update .env.sample 2024-11-02 21:58:44 -04:00
Daniel
d6d6442bc4 Merge pull request #809 from Green-Kite/dev
update german translation
2024-11-02 20:57:31 -04:00
Daniel
d12d12518e Merge pull request #636 from bjoerndot/tags-in-public-collection
Tags in public collection
2024-11-02 20:55:16 -04:00
daniel31x13
02ced62832 final change 2024-11-02 20:45:31 -04:00
daniel31x13
4febe1ace5 minor changes 2024-11-02 20:43:53 -04:00
daniel31x13
2e1e94112f make tags visible on public collections 2024-11-02 18:16:38 -04:00
daniel31x13
d86bbcd940 minor fix 2024-11-02 18:07:16 -04:00
daniel31x13
eed80ca812 add migration 2024-11-02 18:01:36 -04:00
Daniel
394251c1f1 Merge branch 'dev' into tags-in-public-collection 2024-11-02 17:56:43 -04:00
Daniel
68cdde91ad Merge pull request #813 from linkwarden/feat/team-support
Feat/seats support
2024-11-02 17:52:17 -04:00
Green-Kite
1ef286a38c update german translation
German translation updated
2024-11-01 10:08:14 +01:00
daniel31x13
508844dd9d bug fixes 2024-10-30 16:47:40 -04:00
daniel31x13
fa1f9873d5 minor change 2024-10-30 13:56:07 -04:00
Oliver Schwamb
891803547e retrieve all links for collection 2024-10-30 12:10:53 +01:00
Oliver Schwamb
24d45f8e8e Merge remote-tracking branch 'upstream/dev' into tags-in-public-collection 2024-10-30 12:10:30 +01:00
daniel31x13
f95350405c rename variable 2024-10-29 18:14:35 -04:00
daniel31x13
665019dc59 finalizing team support 2024-10-29 18:08:47 -04:00
daniel31x13
b09de5a8af updated verify max link logic 2024-10-26 13:44:52 -04:00
daniel31x13
cfd33e9bd1 bug fixed 2024-10-26 10:58:27 -04:00
daniel31x13
d3d2d5069e add member onboarding 2024-10-26 09:42:21 -04:00
daniel31x13
cffc74caa4 add team invitation functionality [WIP] 2024-10-21 13:59:05 -04:00
Jan van Brügge
3cd8eadee3 Update prisma to v5 2024-10-08 16:25:36 +01:00
daniel31x13
d146ec296c bug fixed 2024-10-07 23:43:44 -04:00
Jan van Brügge
fb4aa42eef Allow to specify a custom playwright browser path 2024-10-07 15:05:48 +01:00
daniel31x13
f68582e28c bug fixed 2024-10-07 00:57:36 -04:00
daniel31x13
d042c82cb0 add subscription webhook 2024-10-06 01:59:31 -04:00
Daniel
8738dd45e9 Merge pull request #771 from click0/main
Corrected Ukrainian translation.
2024-09-19 14:13:37 -04:00
Vladyslav V. Prodan
839de18d7a Merge branch 'linkwarden:main' into main 2024-09-19 00:46:06 +03:00
vlad11
2ba0851fee Corrected Ukrainian translation.
Signed-off-by: vlad11 <admin@support.od.ua>
2024-09-19 00:36:16 +03:00
daniel31x13
d99972a335 minor fix 2024-09-18 12:10:45 -04:00
daniel31x13
e071b9eb07 minor fix 2024-09-18 11:39:31 -04:00
daniel31x13
eb00d151b7 added locale to the config file 2024-09-18 11:06:31 -04:00
Daniel
22aaa52b3e Merge pull request #770 from bennyz327/dev
feat(lang): add traditional chinese translate
2024-09-18 11:04:03 -04:00
Benny Chou
4541277b28 feat(lang): add traditional chinese translate 2024-09-18 15:21:19 +08:00
Daniel
39faece9d7 Merge pull request #769 from linkwarden/main
Merge pull request #766 from linkwarden/daniel31x13-patch-1
2024-09-17 14:08:44 -04:00
daniel31x13
a21b0760de remove unused type 2024-09-17 14:06:03 -04:00
daniel31x13
04149fe86b Merge branch 'dev' of https://github.com/linkwarden/linkwarden into dev 2024-09-17 14:03:07 -04:00
daniel31x13
ff6e71d494 add schema validation for PUT requests 2024-09-17 14:03:05 -04:00
Daniel
5b02c1cfc9 Merge pull request #765 from ochtum/dev
Translated the added IDs.
2024-09-15 13:53:00 -04:00
Daniel
1ff13e8aa0 Merge pull request #767 from linkwarden/daniel31x13-patch-1
Update .env.sample
2024-09-15 13:52:47 -04:00
Daniel
eaf4524598 Merge pull request #766 from linkwarden/daniel31x13-patch-1
Update .env.sample
2024-09-15 13:52:17 -04:00
Daniel
a276065288 Update .env.sample 2024-09-15 13:51:09 -04:00
daniel31x13
1cf7421b76 added zod for post requests 2024-09-14 16:00:19 -04:00
武田 淳一
ed4a334024 Translated the added IDs. 2024-09-14 13:24:29 +09:00
Daniel
a5b1952e0d Merge pull request #710 from arran4/patch-1
Please 'EXPOSE' port 3000
2024-09-13 01:57:56 -04:00
Daniel
01826b1634 Merge branch 'dev' into patch-1 2024-09-13 01:57:39 -04:00
daniel31x13
3b17d4ddfe bug fixed 2024-09-13 00:37:58 -04:00
Daniel
f104fa095f Merge pull request #672 from jlssmt/logging
disabled query logging as default
2024-09-12 23:36:01 -04:00
Daniel
b08e6690f3 Merge pull request #689 from stumpylog/chore/update-actions
Chore: Updates actions to their latest versions
2024-09-12 23:16:27 -04:00
Daniel
33a654d21a Merge pull request #688 from stumpylog/feature/docker-file-reduce
fix: reduce Docker image size
2024-09-12 23:06:08 -04:00
Daniel
e1262142f8 Merge pull request #764 from click0/main
Corrected Ukrainian translation for September 12, 2024.
2024-09-12 19:04:50 -04:00
vlad11
0a43279665 Corrected Ukrainian translation for September 12, 2024.
Signed-off-by: vlad11 <admin@support.od.ua>
2024-09-13 01:29:34 +03:00
daniel31x13
5491ac74a5 add nl and tr translations 2024-09-12 17:06:36 -04:00
Daniel
bbcfca4cde Merge pull request #716 from kgnfth/main
feat(translations): Add Dutch and Turkish translations
2024-09-12 17:05:19 -04:00
daniel31x13
bf9a7d4fa0 add german translation 2024-09-12 17:01:38 -04:00
Daniel
edf4e489ec Merge pull request #711 from Green-Kite/main
Add German Translation
2024-09-12 17:00:49 -04:00
daniel31x13
20c5a20851 add spanish translation 2024-09-12 17:00:26 -04:00
Daniel
6f47a20e87 Merge pull request #717 from joser93/es-patch-1
Patch for Spanish translation.
2024-09-12 16:56:31 -04:00
Daniel
384937e210 Merge pull request #714 from phampyk/main
Added Spanish translation
2024-09-12 16:54:58 -04:00
Daniel
d22d989c91 Merge pull request #724 from ochtum/main
Created Japanese Translate
2024-09-12 16:53:24 -04:00
Daniel
4e0294322f Merge branch 'dev' into main 2024-09-12 16:53:12 -04:00
daniel31x13
75d5061bdf minor fix 2024-09-12 16:52:26 -04:00
Daniel
0150a9a6e3 Merge pull request #762 from rdeavila/dev
Update pt-BR translation
2024-09-12 16:49:38 -04:00
Daniel
87b79ffbac Merge pull request #726 from CoffeeAnon/feat/set-max-workers
Add Configurable Playwright Concurrency via Environment Variable
2024-09-12 16:43:32 -04:00
Rodrigo de Avila
5a40677191 Update pt-BR translation 2024-09-12 17:42:17 -03:00
Daniel
95ce2f30a8 Merge pull request #734 from click0/main
Added Ukranian translation
2024-09-12 16:33:28 -04:00
Daniel
e6a0ecbab5 Merge branch 'dev' into main 2024-09-12 16:32:39 -04:00
daniel31x13
e4c9cf8a38 add locale to config 2024-09-12 16:30:20 -04:00
Daniel
eaca3d7453 Merge pull request #746 from rdeavila/main
Add Brazilian Portuguese (pt-BR) support
2024-09-12 16:18:06 -04:00
Rodrigo de Avila
fbe3642be4 Merge branch 'linkwarden:main' into main 2024-09-12 17:16:22 -03:00
daniel31x13
bc32abbb92 Merge branch 'main' into dev
merge main to dev
2024-09-12 16:10:35 -04:00
daniel31x13
38f731f313 minor change 2024-09-12 15:46:16 -04:00
daniel31x13
aaf3590542 members with edit permission can now refresh preservation as well + bug fix 2024-09-12 15:30:15 -04:00
daniel31x13
8bb6e32bfa urls are now editable 2024-09-12 15:03:14 -04:00
daniel31x13
7bd3872195 bug fixed + optimizations 2024-09-12 13:47:18 -04:00
daniel31x13
906779010e collection closing bug fixed 2024-09-12 12:46:38 -04:00
daniel31x13
b0f87e8659 bug fixed 2024-09-12 11:59:20 -04:00
daniel31x13
653b1bc396 bug fix 2024-09-11 02:29:50 -04:00
daniel31x13
9b1506a64e add pin to hover view + add number of pins to dashboard + bug fixes 2024-09-11 01:38:38 -04:00
daniel31x13
fb1869ca7a fix dashboard bug 2024-09-10 00:09:33 -04:00
daniel31x13
5e7835b4d5 minor improvement 2024-09-09 23:27:55 -04:00
daniel31x13
0a91c47f83 minor change 2024-09-09 23:07:22 -04:00
daniel31x13
dc9db05e75 fully implemented the custom slider for the number of columns to show 2024-09-09 23:05:57 -04:00
daniel31x13
e1149c2733 minor fix 2024-09-09 19:16:28 -04:00
daniel31x13
0591d7c134 remove unused import 2024-09-09 19:09:09 -04:00
daniel31x13
4602269dd8 add number of columns slider 2024-09-09 19:05:30 -04:00
daniel31x13
9ae6a22236 minor improvement 2024-09-09 12:18:45 -04:00
daniel31x13
442da02956 minor fix 2024-09-04 23:17:58 -04:00
daniel31x13
dfcc271343 bug fix 2024-09-04 23:02:19 -04:00
daniel31x13
43d50dfd1b minor change 2024-09-04 22:39:10 -04:00
daniel31x13
40bb3e6fae fix build error 2024-09-04 22:29:54 -04:00
Daniel
3e077fa247 Merge pull request #754 from linkwarden/feat/customizable-links
Feat/customizable links
2024-09-04 22:20:16 -04:00
daniel31x13
3de8872f26 upload preview functionality 2024-09-04 22:19:40 -04:00
daniel31x13
e9072bba51 minor improvement 2024-08-30 18:10:50 -04:00
daniel31x13
d20c915970 improved edit view 2024-08-30 17:29:15 -04:00
daniel31x13
1a378de267 minor improvement 2024-08-30 10:54:27 -04:00
daniel31x13
d594159c15 minor improvement 2024-08-30 10:47:29 -04:00
daniel31x13
aee10fa406 better edit view 2024-08-30 02:38:58 -04:00
daniel31x13
820d686c37 minor improvement 2024-08-29 18:26:15 -04:00
daniel31x13
4189062c4c bug fixed 2024-08-29 12:53:37 -04:00
Daniel
1461caf68a Merge pull request #748 from linkwarden/hotfix
bug fix
2024-08-29 12:49:48 -04:00
daniel31x13
e7c7fedf8b bug fix 2024-08-29 12:47:23 -04:00
daniel31x13
b7adbbc86f improvements 2024-08-28 20:48:35 -04:00
daniel31x13
975716937f minor improvement 2024-08-28 20:30:57 -04:00
daniel31x13
2d0e52f65b better looking detail modal 2024-08-28 20:22:11 -04:00
Rodrigo de Avila
e9afe0ef25 Add Brazilian Portuguese (pt-BR) support 2024-08-28 10:26:27 -03:00
José Roberto Sánchez
a38133d618 Improved translations based on comments from @jmiguelr 2024-08-27 11:32:15 -06:00
daniel31x13
6498ae794b custom preview initial commit 2024-08-26 21:04:52 -04:00
daniel31x13
0371695eb3 choose to show which detail in each views 2024-08-26 19:56:04 -04:00
daniel31x13
9ae9c7c81a refactored view dropdown 2024-08-26 18:47:10 -04:00
daniel31x13
642374c2e5 remove commented code 2024-08-26 16:22:59 -04:00
daniel31x13
f368c2aa81 less padding for list view 2024-08-26 16:11:02 -04:00
daniel31x13
fae9e95fa9 added custom icons for links 2024-08-24 15:50:29 -04:00
Daniel
03639adc22 Merge pull request #735 from IsaacWise06/issue-691
Add new collection drop down
2024-08-22 22:48:56 -04:00
Isaac Wise
9fe829771d Add new collection drop down 2024-08-22 17:09:14 -05:00
vlad11
ed7b268c2b Created Ukranian Translate.
Signed-off-by: vlad11 <admin@support.od.ua>
2024-08-22 03:07:25 +03:00
daniel31x13
bf1a6efd2e custom icons fully implemented for collections 2024-08-20 19:25:35 -04:00
daniel31x13
6df2e44213 added translation to icon picker component + other fixes and improvements 2024-08-20 18:11:20 -04:00
daniel31x13
ae2324ecd3 progressed icon picker component 2024-08-20 16:59:01 -04:00
daniel31x13
accbd4cbfa bug fixes 2024-08-19 23:53:43 -04:00
Daniel
5f4e0d4262 Merge pull request #731 from linkwarden/hotfix
bugs fixed
2024-08-19 23:37:30 -04:00
daniel31x13
c072fed99f bugs fixed 2024-08-19 23:36:28 -04:00
Daniel
b4a9f917b5 Merge pull request #728 from linkwarden/hotfix
hotfix
2024-08-19 19:30:26 -04:00
daniel31x13
078e5ba95f minor change 2024-08-19 19:30:01 -04:00
daniel31x13
495509c888 bug fix 2024-08-19 19:25:13 -04:00
daniel31x13
dc388ebba5 improved iconPicker component + other improvements 2024-08-19 18:14:09 -04:00
Dan Jacobsen
21578bac8d feat: add configurable max workers 2024-08-19 12:44:59 -07:00
武田 淳一
1062e07065 Created Japanese Translate 2024-08-20 00:50:07 +09:00
daniel31x13
2893d3caf2 minor improvement 2024-08-18 16:52:08 -04:00
Daniel
9f74f62330 Merge pull request #722 from linkwarden/dev
Dev
2024-08-18 16:41:48 -04:00
Daniel
c6e3147bb6 Merge pull request #678 from IsaacWise06/fixes
General Fixes
2024-08-18 16:40:48 -04:00
daniel31x13
1260e8c093 fixes 2024-08-18 16:39:43 -04:00
Daniel
5cb4bdced3 Merge pull request #721 from linkwarden/feat/customizable-links
small improvements
2024-08-18 14:47:55 -04:00
Daniel
03b4240b8b Merge pull request #720 from linkwarden/revert-719-feat/customizable-links
Revert "Feat/customizable links"
2024-08-18 14:47:29 -04:00
Daniel
9a3e82470a Revert "Feat/customizable links" 2024-08-18 14:46:52 -04:00
Daniel
ee2319996b Merge pull request #719 from linkwarden/feat/customizable-links
Feat/customizable links
2024-08-18 14:46:21 -04:00
daniel31x13
c979adfe69 small improvements 2024-08-18 14:45:40 -04:00
Isaac Wise
2b83522eaa Merge branch 'dev' into fixes 2024-08-18 13:21:02 -05:00
Daniel
8c738d4a99 Merge pull request #718 from linkwarden/feat/customizable-links
Feat/customizable links
2024-08-18 14:11:47 -04:00
Isaac Wise
63678b7f1e format 2024-08-18 13:06:36 -05:00
Isaac Wise
b73e845299 Fix building 2024-08-18 13:06:19 -05:00
Isaac Wise
898b126231 Fix merge conflicts 2024-08-18 13:03:09 -05:00
daniel31x13
17d1cb45e3 minor improvement 2024-08-18 13:49:33 -04:00
José Roberto Sánchez
0aad2d9e4b Change email and fixed some typos. Overall is a good translation and I hope is merged soon. :D 2024-08-18 11:07:13 -06:00
daniel31x13
c18a5f4162 added details drawer 2024-08-18 02:55:59 -04:00
Teal'c
df7814385a feat(translations): Add Dutch and Turkish translations
- Added Dutch (nl) translations.
- Added Turkish (tr) translations.

#216
2024-08-18 04:39:35 +03:00
Ana
d568f22e00 Upload Spanish translation 2024-08-18 00:42:33 +01:00
Green-Kite
6bd1c90417 Create German Translation
updated German translation
2024-08-17 09:57:57 +02:00
daniel31x13
a40026040c icon picker component 2024-08-16 23:00:37 -04:00
Arran Ubels
334ad9f3dc Please 'EXPOSE' port 3000
This is so I can setup up Synology correctly
2024-08-17 11:56:00 +10:00
Daniel
f944345745 Merge pull request #708 from linkwarden/dev
bump version
2024-08-16 13:45:28 -04:00
daniel31x13
6b647573f0 bump version 2024-08-16 13:44:53 -04:00
Daniel
d81493e021 Merge pull request #707 from linkwarden/dev
bug fix
2024-08-16 13:43:57 -04:00
daniel31x13
03f4523d57 bug fix 2024-08-16 13:42:55 -04:00
Daniel
c24e76adac Merge pull request #706 from linkwarden/dev
v2.7.0
2024-08-16 12:36:43 -04:00
daniel31x13
5d26617251 bug fixed 2024-08-16 12:35:04 -04:00
daniel31x13
0e47ad9920 bump version 2024-08-15 16:42:36 -04:00
daniel31x13
ca45076b6c minor fix 2024-08-15 15:37:47 -04:00
Daniel
3bf6dcad2f Merge pull request #692 from phillibl/main
Update [...nextauth].ts to allow existing SSO user sign
2024-08-15 13:45:07 -04:00
daniel31x13
23860b8511 minor fix 2024-08-15 11:00:29 -04:00
daniel31x13
8758976f8d minor fix 2024-08-15 10:30:44 -04:00
Daniel
550dbd2bf0 Merge pull request #704 from shichen437/dev
feat(lang): add chinese translate
2024-08-15 08:49:26 -04:00
shichen437
04d2b3c6b2 feat(lang): add chinese translate 2024-08-15 17:20:46 +08:00
Trenton Holmes
cc1c17363b Also install a single browser (Chromium) through Playwright 2024-08-14 19:48:16 -07:00
daniel31x13
7bd0e29538 small improvement 2024-08-14 20:07:06 -04:00
daniel31x13
5baf55694c minor improvement 2024-08-14 19:23:51 -04:00
daniel31x13
193a70c6e8 fix dropdown text wrapping in other languages 2024-08-14 19:13:19 -04:00
daniel31x13
5b430cf31e add french translation 2024-08-14 17:49:53 -04:00
Daniel
684609a1dd Merge pull request #654 from zarevskaya/patch-1
Add french translation
2024-08-14 17:43:15 -04:00
Daniel
ebb2016915 Merge pull request #671 from jlssmt/main
handle undefined
2024-08-14 17:40:28 -04:00
daniel31x13
c103b66694 Merge branch 'dev' of https://github.com/linkwarden/linkwarden into dev 2024-08-14 17:26:40 -04:00
daniel31x13
863bcc3838 bug fixed 2024-08-14 17:26:38 -04:00
Daniel
66b0aacc3f Merge pull request #660 from IsaacWise06/issue-646
fix(collections): Redirect to dashboard or login for non-public collections
2024-08-14 17:04:25 -04:00
Daniel
299498ffa6 Merge pull request #703 from linkwarden/chore/react-query-implementation
Chore/react query implementation
2024-08-14 16:45:40 -04:00
phillibl
c5602dc79f Merge pull request #1 from phillibl/phillibl-SSO-user-signin
Update [...nextauth].ts to allow existing SSO user sign
2024-08-07 05:44:19 -04:00
phillibl
0158e58d90 Update [...nextauth].ts
Fixed issue where sign in would fail for existing user if DISABLE_NEW_SSO_USERS  = true
2024-08-07 05:29:10 -04:00
Trenton Holmes
602f399119 Updates actions to their latest versions 2024-08-04 15:15:33 -07:00
Trenton Holmes
012caab606 Use multi-stage building for the monolith binary 2024-08-04 15:01:54 -07:00
jlssmt
102690fc10 handle undefined 2024-08-02 09:07:13 +02:00
Daniel
237499fd03 Merge pull request #684 from linkwarden/daniel31x13-patch-1
Update README
2024-08-01 15:55:20 -04:00
Daniel
9a287d1aef Merge pull request #683 from linkwarden/daniel31x13-patch-1
Update README.md
2024-08-01 15:54:29 -04:00
Daniel
299a2331ff Update README.md 2024-08-01 15:54:00 -04:00
Isaac Wise
a1248fe62f Fix issue with link action dropdown 2024-07-27 20:19:24 -05:00
Isaac Wise
8f7e0b8d09 Remove type assertions 2024-07-27 20:01:51 -05:00
Isaac Wise
9d91d2064b Merge branch 'linkwarden:main' into issue-646 2024-07-27 17:57:25 -05:00
Isaac Wise
d631754b50 Keep any types for selects 2024-07-27 17:45:54 -05:00
Isaac Wise
94be3a7448 format 2024-07-27 17:41:13 -05:00
Isaac Wise
4faf389a2b Fix more types and use logical ANDs 2024-07-27 17:40:07 -05:00
Isaac Wise
ff31732ba3 remove more ternaries 2024-07-27 16:17:38 -05:00
Isaac Wise
fa051c0d4d Merge branch 'linkwarden:main' into fixes 2024-07-26 16:42:51 -05:00
Isaac Wise
02cb93065f Redact all ids when exporting data 2024-07-26 16:41:19 -05:00
Daniel
15a0084fb7 Merge pull request #677 from linkwarden/dev
bump version
2024-07-26 12:01:38 -04:00
Daniel
c0abf2f411 Merge pull request #676 from linkwarden/dev
bug fixed
2024-07-26 11:55:07 -04:00
Daniel
a886437589 Merge pull request #674 from linkwarden/dev
merged the two migration scripts for v2.6.1
2024-07-25 23:44:46 -04:00
Isaac Wise
0b8a9b4310 Fix some any types 2024-07-25 18:58:52 -05:00
jlssmt
ce1aa5a0ec disabled query logging as default 2024-07-25 23:19:33 +02:00
Daniel
a82c4ef85f Merge pull request #670 from linkwarden/dev
Dev
2024-07-25 14:24:24 -04:00
Daniel
7036b46084 Merge pull request #668 from linkwarden/dev
made script more efficient
2024-07-25 14:16:16 -04:00
Daniel
2bba8198b8 Merge pull request #667 from linkwarden/dev
minor fix
2024-07-25 13:58:13 -04:00
Daniel
96a70a9689 Merge pull request #666 from linkwarden/dev
update version number
2024-07-25 13:46:59 -04:00
Daniel
288fd9df87 Merge pull request #665 from linkwarden/dev
bug fixed
2024-07-25 13:45:03 -04:00
Isaac Wise
e79b98d3b0 Replace useless ternarys with logical ANDs 2024-07-22 22:34:36 -05:00
Isaac Wise
7d43ed52a4 format 2024-07-22 17:50:24 -05:00
Isaac Wise
614653bf29 Merge branch 'linkwarden:main' into issue-646 2024-07-22 17:41:34 -05:00
Isaac Wise
1b9dafbe47 Handle 400 error code when accesing a non public collection 2024-07-22 17:39:38 -05:00
zarev
abc93f1bf9 Update common.json
Correction
2024-07-20 09:55:51 +02:00
zarev
c23964a46d Create common.json
In french, if you want it ;)
2024-07-19 22:37:46 +02:00
Daniel
a76e996fc1 Merge pull request #653 from linkwarden/dev
v2.6.0
2024-07-19 08:59:54 -04:00
Oliver Schwamb
abb73f80bd Reworked access to tags as public viewer 2024-07-10 10:22:58 +02:00
Oliver Schwamb
e8d0cce58a Added allLinksOfCollection to linksStore
Removed duplicated tags
Fixed overflow for line
added disclosure for tags in public collection
2024-07-09 13:50:08 +02:00
Oliver Schwamb
e045c18b7d Only show tags within collection 2024-07-05 10:50:32 +02:00
Oliver Schwamb
a1f48bbd79 Tags in public collection 2024-07-05 10:40:40 +02:00
625 changed files with 42007 additions and 11785 deletions

View File

@@ -1,11 +1,12 @@
NEXTAUTH_SECRET=very_sensitive_secret
NEXTAUTH_URL=http://localhost:3000/api/v1/auth
NEXTAUTH_SECRET=
# Manual installation database settings
DATABASE_URL=postgresql://user:password@localhost:5432/linkwarden
# Example: DATABASE_URL=postgresql://user:password@localhost:5432/linkwarden
DATABASE_URL=
# Docker installation database settings
POSTGRES_PASSWORD=super_secret_password
POSTGRES_PASSWORD=
# Additional Optional Settings
PAGINATION_TAKE_COUNT=
@@ -14,7 +15,6 @@ AUTOSCROLL_TIMEOUT=
NEXT_PUBLIC_DISABLE_REGISTRATION=
NEXT_PUBLIC_CREDENTIALS_ENABLED=
DISABLE_NEW_SSO_USERS=
RE_ARCHIVE_LIMIT=
MAX_LINKS_PER_USER=
ARCHIVE_TAKE_COUNT=
BROWSER_TIMEOUT=
@@ -26,13 +26,51 @@ 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=
MONOLITH_MAX_BUFFER=
MONOLITH_CUSTOM_OPTIONS=
IMPORT_LIMIT=
PLAYWRIGHT_LAUNCH_OPTIONS_EXECUTABLE_PATH=
PLAYWRIGHT_WS_URL=
MAX_WORKERS=
DISABLE_PRESERVATION=
NEXT_PUBLIC_RSS_POLLING_INTERVAL_MINUTES=
RSS_SUBSCRIPTION_LIMIT_PER_USER=
TEXT_CONTENT_LIMIT=
SEARCH_FILTER_LIMIT=
INDEX_TAKE_COUNT=
MEILI_TIMEOUT=
# AI Settings
NEXT_PUBLIC_OLLAMA_ENDPOINT_URL=
OLLAMA_MODEL=
# https://ai-sdk.dev/providers/openai-compatible-providers
OPENAI_API_KEY=
OPENAI_MODEL=
# Optional: Set a custom OpenAI base URL and name (for third-party providers)
CUSTOM_OPENAI_BASE_URL=
CUSTOM_OPENAI_NAME=
# https://sdk.vercel.ai/providers/ai-sdk-providers/azure
AZURE_API_KEY=
AZURE_RESOURCE_NAME=
AZURE_MODEL=
# https://sdk.vercel.ai/providers/ai-sdk-providers/anthropic
ANTHROPIC_API_KEY=
ANTHROPIC_MODEL=
# https://github.com/OpenRouterTeam/ai-sdk-provider
OPENROUTER_API_KEY=
OPENROUTER_MODEL=
# MeiliSearch Settings
MEILI_HOST=
MEILI_MASTER_KEY=
# AWS S3 Settings
SPACES_KEY=
@@ -216,6 +254,7 @@ NEXT_PUBLIC_GITLAB_ENABLED=
GITLAB_CUSTOM_NAME=
GITLAB_CLIENT_ID=
GITLAB_CLIENT_SECRET=
GITLAB_AUTH_URL=
# Google
NEXT_PUBLIC_GOOGLE_ENABLED=

View File

@@ -0,0 +1,13 @@
name: Installation Problem
title: Installation Problem
description: Report an issue with installation
labels: installation
body:
- type: textarea
id: feature-description
validations:
required: true
attributes:
label: For installation issues, please visit discord.linkwarden.app
description: "Invite link: https://discord.com/invite/CtuYV47nuJ"
placeholder: Please do not submit installation issues on GitHub.

18
.github/workflows/check-branch.yml vendored Normal file
View File

@@ -0,0 +1,18 @@
name: Check pull request source branch
on:
pull_request_target:
types:
- opened
- reopened
- synchronize
- edited
jobs:
check-branches:
runs-on: ubuntu-latest
steps:
- name: Check branches
run: |
if [ ${{ github.head_ref }} != "dev" ] && [ ${{ github.base_ref }} == "main" ]; then
echo "Merge requests to main branch are only allowed from dev branch. Please rebase your changes to dev branch."
exit 1
fi

42
.github/workflows/locale-action.yml vendored Normal file
View File

@@ -0,0 +1,42 @@
name: Manage i18n pull requests
on:
pull_request:
types: [opened, reopened, synchronize, ready_for_review]
permissions:
contents: write
pull-requests: write
jobs:
rewrite-author:
if: github.event.pull_request.head.ref == 'i18n'
runs-on: ubuntu-latest
steps:
- name: Checkout i18n branch
uses: actions/checkout@v4
with:
ref: ${{ github.event.pull_request.head.ref }}
fetch-depth: 0
- name: Skip if already rewritten
run: |
if [ "$(git show -s --format='%an')" = 'LinkwardenBot' ]; then
echo "Already rewritten skipping."
exit 0
fi
- name: Configure bot identity
run: |
git config user.name "LinkwardenBot"
git config user.email "bot@linkwarden.app"
- name: Amend just the PR commits
env:
BASE_SHA: ${{ github.event.pull_request.base.sha }}
run: |
git rebase --committer-date-is-author-date --exec 'git commit --amend --no-edit --allow-empty --author="LinkwardenBot <bot@linkwarden.app>"' "$BASE_SHA"
- name: Push rewritten history
run: git push --force-with-lease origin HEAD:i18n

View File

@@ -59,10 +59,10 @@ jobs:
--health-retries 5
steps:
- uses: actions/checkout@v2
- uses: actions/checkout@v4
- name: Use Node.js
uses: actions/setup-node@v3
uses: actions/setup-node@v4
with:
node-version: "18"
cache: 'yarn'
@@ -119,23 +119,19 @@ jobs:
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
yarn prisma:generate
yarn web:build
yarn prisma:deploy
- name: Start linkwarden server and worker
run: yarn start &
run: yarn concurrently:start &
- name: Run Tests
run: npx playwright test --grep ${{ matrix.test_case }}
run: yarn workspace @linkwarden/web playwright test --grep ${{ matrix.test_case }}
- uses: actions/upload-artifact@v3
- uses: actions/upload-artifact@v4
if: always()
with:
name: playwright-report

View File

@@ -1,6 +1,7 @@
name: Create and publish a container image on release
on:
workflow_dispatch:
push:
tags:
- "*"
@@ -27,7 +28,7 @@ jobs:
uses: docker/setup-buildx-action@v3
- name: Log in to the Container registry
uses: docker/login-action@v2
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
@@ -40,7 +41,7 @@ jobs:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
- name: Build and push Docker image
uses: docker/build-push-action@v3
uses: docker/build-push-action@v6
with:
context: .
push: true

32
.gitignore vendored
View File

@@ -1,5 +1,5 @@
# dependencies
/node_modules
node_modules
/.pnp
.pnp.js
@@ -7,7 +7,7 @@
/coverage
# next.js
/.next/
.next
/out/
# production
@@ -34,23 +34,21 @@ yarn-error.log*
*.tsbuildinfo
next-env.d.ts
# generated files and folders
/data
.idea
prisma/dev.db
# tests
/tests
/test-results/
/blob-report/
/playwright-report/
/playwright/.cache/
/playwright/.auth/
/apps/web/tests
/apps/web/test-results/
/apps/web/blob-report/
/apps/web/playwright-report/
/apps/web/playwright/.cache/
/apps/web/playwright/.auth/
# docker
pgdata
certificates
/test-results/
/playwright-report/
/blob-report/
/playwright/.cache/
# generated files and folders
/data
.idea
prisma/dev.db
data.ms
.turbo

View File

@@ -1,45 +0,0 @@
# Architecture
This is a summary of the architecture of Linkwarden. It's intended as a primer for collaborators to get a high-level understanding of the project.
When you start Linkwarden, there are mainly two components that run:
- The NextJS app, This is the main app and it's responsible for serving the frontend and handling the API routes.
- [The Background Worker](https://github.com/linkwarden/linkwarden/blob/main/scripts/worker.ts), This is a separate `ts-node` process that runs in the background and is responsible for archiving links.
## Main Tech Stack
- [NextJS](https://github.com/vercel/next.js)
- [TypeScript](https://github.com/microsoft/TypeScript)
- [Tailwind](https://github.com/tailwindlabs/tailwindcss)
- [DaisyUI](https://github.com/saadeghi/daisyui)
- [Prisma](https://github.com/prisma/prisma)
- [Playwright](https://github.com/microsoft/playwright)
- [Zustand](https://github.com/pmndrs/zustand)
## Folder Structure
Here's a summary of the main files and folders in the project:
```
linkwarden
├── components # React components
├── hooks # React reusable hooks
├── layouts # Layouts for pages
├── lib
│   ├── api # Server-side functions (controllers, etc.)
│   ├── client # Client-side functions
│   └── shared # Shared functions between client and server
├── pages # Pages and API routes
├── prisma # Prisma schema and migrations
├── scripts
│   ├── migration # Scripts for breaking changes
│   └── worker.ts # Background worker for archiving links
├── store # Zustand stores
├── styles # Styles
└── types # TypeScript types
```
## Versioning
We use semantic versioning for the project. You can track the changes from the [Releases](https://github.com/linkwarden/linkwarden/releases).

View File

@@ -1,4 +1,16 @@
FROM node:18.18-bullseye-slim
# Stage: monolith-builder
# Purpose: Uses the Rust image to build monolith
# Notes:
# - Fine to leave extra here, as only the resulting binary is copied out
FROM docker.io/rust:1.86-bullseye AS monolith-builder
RUN set -eux && cargo install --locked monolith
# Stage: main-app
# Purpose: Compiles the frontend and
# Notes:
# - Nothing extra should be left here. All commands should cleanup
FROM node:22.14-bullseye-slim AS main-app
ARG DEBIAN_FRONTEND=noninteractive
@@ -6,35 +18,42 @@ RUN mkdir /data
WORKDIR /data
COPY ./package.json ./yarn.lock ./playwright.config.ts ./
COPY ./apps/web/package.json ./apps/web/playwright.config.ts ./apps/web/
RUN --mount=type=cache,sharing=locked,target=/usr/local/share/.cache/yarn yarn install --network-timeout 10000000
COPY ./apps/worker/package.json ./apps/worker/
RUN apt-get update
COPY ./packages ./packages
RUN apt-get install -y \
build-essential \
curl \
libssl-dev \
pkg-config
COPY ./yarn.lock ./package.json ./
RUN apt-get update
RUN --mount=type=cache,sharing=locked,target=/usr/local/share/.cache/yarn \
set -eux && \
yarn install --network-timeout 10000000 && \
# Install curl for healthcheck, and ca-certificates to prevent monolith from failing to retrieve resources due to invalid certificates
apt-get update && \
apt-get install -yqq --no-install-recommends curl ca-certificates && \
apt-get autoremove && \
apt-get clean && \
rm -rf /var/lib/apt/lists/*
RUN curl https://sh.rustup.rs -sSf | bash -s -- -y
# Copy the compiled monolith binary from the builder stage
COPY --from=monolith-builder /usr/local/cargo/bin/monolith /usr/local/bin/monolith
ENV PATH="/root/.cargo/bin:${PATH}"
RUN cargo install monolith
RUN npx playwright install-deps && \
RUN set -eux && \
apt-get clean && \
yarn cache clean
RUN yarn playwright install
COPY . .
RUN yarn prisma generate && \
yarn build
RUN yarn prisma:generate && \
yarn web:build
CMD yarn prisma migrate deploy && yarn start
HEALTHCHECK --interval=30s \
--timeout=5s \
--start-period=10s \
--retries=3 \
CMD [ "/usr/bin/curl", "--silent", "--fail", "http://127.0.0.1:3000/" ]
EXPOSE 3000
CMD ["sh", "-c", "yarn prisma:deploy && yarn concurrently:start"]

View File

@@ -1,64 +1,47 @@
<div align="center">
<img src="./assets/logo.png" width="100px" />
<h1>Linkwarden</h1>
<h3>Bookmarks, Evolved</h3>
<a href="https://discord.com/invite/CtuYV47nuJ"><img src="https://img.shields.io/discord/1117993124669702164?logo=discord&style=flat" alt="Discord"></a>
<a href="https://twitter.com/LinkwardenHQ"><img src="https://img.shields.io/twitter/follow/linkwarden" alt="Twitter"></a>
<a href="https://twitter.com/LinkwardenHQ"><img src="https://img.shields.io/twitter/follow/linkwarden" alt="Twitter"></a> <a href="https://news.ycombinator.com/item?id=36942308"><img src="https://img.shields.io/badge/Hacker%20News-280-%23FF6600"></img></a>
<img alt="GitHub commits since latest release" src="https://img.shields.io/github/commits-since/linkwarden/linkwarden/latest/dev?style=for-the-badge&label=COMMITS%20SINCE%20LATEST%20RELEASE">
<a href="https://github.com/linkwarden/linkwarden/releases"><img alt="GitHub release" src="https://img.shields.io/github/v/release/linkwarden/linkwarden"></a>
<a href="https://crowdin.com/project/linkwarden">
<img src="https://badges.crowdin.net/linkwarden/localized.svg" alt="Crowdin" /></a>
<a href="https://opencollective.com/linkwarden"><img src="https://img.shields.io/opencollective/all/linkwarden" alt="Open Collective"></a>
</div>
<div align='center'>
[Website](https://linkwarden.app) | [Getting Started](https://docs.linkwarden.app) | [Features](https://github.com/linkwarden/linkwarden#features) | [Roadmap](https://github.com/orgs/linkwarden/projects/1) | [Support ❤](https://github.com/linkwarden/linkwarden#support-)
[« LAUNCH DEMO »](https://demo.linkwarden.app)
[Cloud](https://cloud.linkwarden.app) · [Website](https://linkwarden.app) · [Features](https://github.com/linkwarden/linkwarden#features) · [Docs](https://docs.linkwarden.app)
<img src="./assets/home.png" />
</div>
## Intro & motivation
**Linkwarden is a self-hosted, open-source collaborative bookmark manager to collect, organize and archive webpages.**
**Linkwarden is a self-hosted, open-source collaborative bookmark manager to collect, read, annotate, and fully preserve what matters, all in one place.**
The objective is to organize useful webpages and articles you find across the web in one place, and since useful webpages can go away (see the inevitability of [Link Rot](https://www.howtogeek.com/786227/what-is-link-rot-and-how-does-it-threaten-the-web/)), Linkwarden also saves a copy of each webpage as a Screenshot and PDF, ensuring accessibility even if the original content is no longer available.
The objective is to organize useful webpages and articles you find across the web in one place, and since useful webpages can go away (see the inevitability of [Link Rot](https://en.wikipedia.org/wiki/Link_rot)), Linkwarden also saves a copy of each webpage as a Screenshot and PDF, ensuring accessibility even if the original content is no longer available.
Additionally, Linkwarden is designed with collaboration in mind, sharing links with the public and/or allowing multiple users to work together seamlessly.
In addition to preservation, Linkwarden provides a user-friendly reading and annotation experience that blends the simplicity of a “read-it-later” tool with the reliability of a web archive. Whether youre highlighting key ideas, jotting down thoughts, or revisiting content long after its disappeared from the web, Linkwarden keeps your knowledge accessible and organized.
Linkwarden is also designed with collaboration in mind, enabling you to share links with the public and/or collaborate seamlessly with multiple users.
> [!TIP]
> Our official [Cloud](https://linkwarden.app/#pricing) offering provides the simplest way to begin using Linkwarden and it's the preferred choice for many due to its time-saving benefits. <br> Your subscription supports our hosting infrastructure and ongoing development. <br> Alternatively, if you prefer [self-hosting](https://docs.linkwarden.app/self-hosting/installation) Linkwarden, no problem! You'll still have access to all the premium features.
<img src="./assets/dashboard.png" />
<div align="center">
<img src="./assets/all_links.jpg" width="23%" />
<img src="./assets/list_view.jpg" width="23%" />
<img src="./assets/all_collections.jpg" width="23%" />
<img src="./assets/manage_team.jpg" width="23%" />
<img src="./assets/readable_view.jpg" width="23%" />
<img src="./assets/preserved_formats.jpg" width="23%" />
<img src="./assets/public_page.jpg" width="23%" />
<img src="./assets/light_dashboard.jpg" width="23%" />
</div>
<details>
<summary><b>A bit of a "history"</b></summary>
Linkwarden has been completely rebuilt and redesigned from ground up, so pretty much the only thing it has in common with its predecessor is the idea behind it - bookmark management.
**What happened to the old version?**
We've forked the old version from the current repository into [this repo](https://github.com/linkwarden/linkwarden-old).
</details>
> Our official [Cloud](https://linkwarden.app/#pricing) offering provides the simplest way to begin using Linkwarden and it's the preferred choice for many due to its time-saving benefits. <br> Your subscription supports our hosting infrastructure and ongoing development. <br> Alternatively, if you prefer self-hosting Linkwarden, you can do so by following our [Installation documentation](https://docs.linkwarden.app/self-hosting/installation).
## Features
- 📸 Auto capture a screenshot, PDF, single html file, and readable view of each webpage.
- 📸 Auto capture a screenshot, PDF, and single html file of each webpage.
- 📖 Reader view of the webpage, with the ability to highlight and annotate text.
- 🏛️ Send your webpage to Wayback Machine ([archive.org](https://archive.org)) for a snapshot. (Optional)
- ✨ Local AI Tagging to automatically tag your links based on their content (Optional).
- 📂 Organize links by collection, sub-collection, name, description and multiple tags.
- 👥 Collaborate on gathering links in a collection.
- 🎛️ Customize the permissions of each member.
@@ -67,14 +50,20 @@ We've forked the old version from the current repository into [this repo](https:
- 🔍 Full text search, filter and sort for easy retrieval.
- 📱 Responsive design and supports most modern browsers.
- 🌓 Dark/Light mode support.
- 🧩 Browser extension, managed by the community. [Star it here!](https://github.com/linkwarden/browser-extension)
- 🧩 Browser extension. [Star it here!](https://github.com/linkwarden/browser-extension)
- 🔄 Browser Synchronization (using [Floccus](https://floccus.org)!)
- ⬇️ Import and export your bookmarks.
- 🔐 SSO integration. (Enterprise and Self-hosted users only)
- 📦 Installable Progressive Web App (PWA).
- 🍎 iOS Shortcut to save links to Linkwarden.
- 🍎 iOS Shortcut to save Links to Linkwarden.
- 🔑 API keys.
- ✅ Bulk actions.
- ✨ And so many more features!
- 👥 User administration.
- 🌐 Support for Other Languages (i18n).
- 📁 Image and PDF Uploads.
- 🎨 Custom Icons for Links and Collections.
- 🔔 RSS Feed Subscription.
- ✨ And many more features. (Literally!)
## Like what we're doing? Give us a Star ⭐
@@ -92,25 +81,39 @@ Join and follow us in the following platforms to stay up to date about the most
## Suggestions
We _usually_ go after the [popular suggestions](https://github.com/linkwarden/linkwarden/issues?q=is%3Aissue+is%3Aopen+sort%3Areactions-%2B1-desc). Feel free to open a [new issue](https://github.com/linkwarden/linkwarden/issues/new?assignees=&labels=enhancement&projects=&template=feature_request.md&title=) to suggest one - others might be interested too! :)
We _usually_ go after the [popular suggestions](https://github.com/linkwarden/linkwarden/issues?q=is%3Aissue%20is%3Aopen%20sort%3Areactions-%2B1-desc). Feel free to open a [new issue](https://github.com/linkwarden/linkwarden/issues/new?assignees=&labels=enhancement&projects=&template=feature_request.md&title=) to suggest one - others might be interested too! :)
## Roadmap
Make sure to check out our [public roadmap](https://github.com/orgs/linkwarden/projects/1).
## Docs
## Community Projects
For information on how to get started or to set up your own instance, please visit the [documentation](https://docs.linkwarden.app).
Here are some community-maintained projects that are built around Linkwarden:
- [My Links](https://apps.apple.com/ca/app/my-links-for-linkwarden/id6504573402) - iOS and MacOS Apps, maintained by [JGeek00](https://github.com/JGeek00).
- [LinkDroid](https://fossdroid.com/a/linkdroid-for-linkwarden.html) - Android App with share sheet integration, [source code](https://github.com/Dacid99/LinkDroid-for-Linkwarden).
- [LinkGuardian](https://github.com/Elbullazul/LinkGuardian) - An Android client for Linkwarden. Built with Kotlin and Jetpack compose.
- [StarWarden](https://github.com/rtuszik/starwarden) - A browser extension to save your starred GitHub repositories to Linkwarden.
## Development
If you want to contribute, Thanks! Start by checking our [public roadmap](https://github.com/orgs/linkwarden/projects/1), there you'll see a [README for contributers](https://github.com/orgs/linkwarden/projects/1?pane=issue&itemId=34708277) for the rest of the info on how to contribute and the main tech stack.
If you want to contribute, Thanks! Start by choosing one of our [popular suggestions](https://github.com/linkwarden/linkwarden/issues?q=is%3Aissue%20is%3Aopen%20sort%3Areactions-%2B1-desc), just please stay in touch with [@daniel31x13](https://github.com/daniel31x13) before starting.
# Translations
If you want to help us translate Linkwarden to your language, please check out our [Crowdin page](https://crowdin.com/project/linkwarden) and start translating. We would love to have your help!
To start translating a new language, please create an issue so we can set it up for you. New languages will be added once they reach at least 50% translation completion.
<a href="https://crowdin.com/project/linkwarden">
<img src="https://badges.crowdin.net/linkwarden/localized.svg" alt="Crowdin" /></a>
## Security
If you found a security vulnerability, please do **not** create a public issue, instead send an email to [security@linkwarden.app](mailto:security@linkwarden.app) stating the vulnerability. Thanks!
## Support
## Support <3
Other than using our official [Cloud](https://linkwarden.app/#pricing) offering, any [donations](https://opencollective.com/linkwarden) are highly appreciated as well!

2
apps/mobile/.env.sample Normal file
View File

@@ -0,0 +1,2 @@
LINKWARDEN_URL=
EXPO_PUBLIC_SHOW_LOGS=

42
apps/mobile/.gitignore vendored Normal file
View File

@@ -0,0 +1,42 @@
# Learn more https://docs.github.com/en/get-started/getting-started-with-git/ignoring-files
# dependencies
node_modules/
# Expo
.expo/
dist/
web-build/
expo-env.d.ts
# Native
*.orig.*
*.jks
*.p8
*.p12
*.key
*.mobileprovision
# Metro
.metro-health-check*
# debug
npm-debug.*
yarn-debug.*
yarn-error.*
# macOS
.DS_Store
*.pem
# local env files
.env*.local
# typescript
*.tsbuildinfo
app-example
.env
ios/
android/

1
apps/mobile/.npmrc Normal file
View File

@@ -0,0 +1 @@
node-linker=hoisted

55
apps/mobile/app.json Normal file
View File

@@ -0,0 +1,55 @@
{
"expo": {
"name": "Linkwarden",
"slug": "linkwarden",
"version": "1.0.0",
"orientation": "portrait",
"icon": "./assets/images/icon.png",
"scheme": "linkwarden",
"userInterfaceStyle": "automatic",
"newArchEnabled": true,
"ios": {
"supportsTablet": true,
"bundleIdentifier": "com.anonymous.linkwarden"
},
"android": {
"adaptiveIcon": {
"foregroundImage": "./assets/images/adaptive-icon.png",
"backgroundColor": "#ffffff"
},
"package": "com.anonymous.linkwarden"
},
"web": {
"bundler": "metro",
"output": "static",
"favicon": "./assets/images/favicon.png"
},
"plugins": [
"expo-router",
[
"expo-splash-screen",
{
"image": "./assets/images/splash-icon.png",
"imageWidth": 200,
"resizeMode": "contain",
"backgroundColor": "#ffffff"
}
],
"expo-secure-store",
"expo-share-intent",
"./plugins/with-daynight-transparent-nav"
],
"experiments": {
"typedRoutes": true
},
"androidStatusBar": {
"backgroundColor": "#ffffff",
"barStyle": "dark-content",
"translucent": false
},
"androidNavigationBar": {
"backgroundColor": "#ffffff",
"barStyle": "dark-content"
}
}
}

View File

@@ -0,0 +1,61 @@
import { Tabs } from "expo-router";
import React from "react";
import { Platform } from "react-native";
import HapticTab from "@/components/HapticTab";
import TabBarBackground from "@/components/ui/TabBarBackground";
import { useColorScheme } from "nativewind";
import { rawTheme, ThemeName } from "@/lib/colors";
import { House, Link, Settings } from "lucide-react-native";
export default function TabLayout() {
const { colorScheme } = useColorScheme();
return (
<Tabs
screenOptions={{
tabBarBackground: TabBarBackground,
tabBarActiveTintColor: rawTheme[colorScheme as ThemeName].primary,
tabBarInactiveTintColor: rawTheme[colorScheme as ThemeName].neutral,
tabBarButton: HapticTab,
tabBarStyle: Platform.select({
ios: {
position: "absolute",
borderTopWidth: 0,
elevation: 0,
backgroundColor: rawTheme[colorScheme as ThemeName]["base-200"],
},
default: {
borderTopWidth: 0,
backgroundColor: rawTheme[colorScheme as ThemeName]["base-200"],
elevation: 0,
},
}),
}}
>
<Tabs.Screen
name="dashboard"
options={{
title: "Dashboard",
headerShown: false,
tabBarIcon: ({ color }) => <House size={26} color={color} />,
}}
/>
<Tabs.Screen
name="links"
options={{
title: "Links",
headerShown: false,
tabBarIcon: ({ color }) => <Link size={26} color={color} />,
}}
/>
<Tabs.Screen
name="settings"
options={{
title: "Settings",
headerShown: false,
tabBarIcon: ({ color }) => <Settings size={26} color={color} />,
}}
/>
</Tabs>
);
}

View File

@@ -0,0 +1,84 @@
import { useLinks } from "@linkwarden/router/links";
import { View, StyleSheet, FlatList, Platform } from "react-native";
import useAuthStore from "@/store/auth";
import LinkListing from "@/components/LinkListing";
import { useLocalSearchParams, useNavigation } from "expo-router";
import React, { useEffect, useState } from "react";
import { LinkIncludingShortenedCollectionAndTags } from "@linkwarden/types";
import { useCollections } from "@linkwarden/router/collections";
const RenderItem = React.memo(
({ item }: { item: LinkIncludingShortenedCollectionAndTags }) => {
return <LinkListing link={item} />;
}
);
export default function LinksScreen() {
const { auth } = useAuthStore();
const { search, section, collectionId } = useLocalSearchParams<{
search?: string;
section?: "pinned-links" | "recent-links" | "collection";
collectionId?: string;
}>();
const navigation = useNavigation();
const collections = useCollections(auth);
useEffect(() => {
navigation.setOptions({
headerTitle:
section === "pinned-links"
? "Pinned Links"
: section === "recent-links"
? "Recent Links"
: section === "collection"
? collections.data?.find((c) => c.id?.toString() === collectionId)
?.name || "Collection"
: "Links",
});
}, [section, navigation]);
const { links, data } = useLinks(
{
sort: 0,
searchQueryString: decodeURIComponent(search ?? ""),
collectionId: Number(collectionId),
pinnedOnly: section === "pinned-links",
},
auth
);
return (
<View
style={styles.container}
className="h-full bg-base-100"
collapsable={false}
collapsableChildren={false}
>
<FlatList
contentInsetAdjustmentBehavior="automatic"
ListHeaderComponent={() => <></>}
data={links || []}
onRefresh={() => data.refetch()}
refreshing={data.isRefetching}
initialNumToRender={4}
keyExtractor={(item) => item.id?.toString() || ""}
renderItem={({ item }) => (
<RenderItem item={item} key={item.id?.toString()} />
)}
onEndReached={() => data.fetchNextPage()}
onEndReachedThreshold={0.5}
ItemSeparatorComponent={() => <View className="bg-base-200 h-px" />}
/>
</View>
);
}
const styles = StyleSheet.create({
container: Platform.select({
ios: {
paddingBottom: 83,
},
default: {},
}),
});

View File

@@ -0,0 +1,86 @@
import { rawTheme, ThemeName } from "@/lib/colors";
import { Stack, useRouter } from "expo-router";
import { Plus } from "lucide-react-native";
import { useColorScheme } from "nativewind";
import { Platform, TouchableOpacity } from "react-native";
import { SheetManager } from "react-native-actions-sheet";
import * as DropdownMenu from "zeego/dropdown-menu";
export default function RootLayout() {
const router = useRouter();
const { colorScheme } = useColorScheme();
return (
<Stack
screenOptions={{
headerLargeTitle: true,
headerTransparent: Platform.OS === "ios" ? true : false,
headerShadowVisible: false,
headerBlurEffect:
colorScheme === "dark" ? "systemMaterialDark" : "systemMaterial",
headerTintColor: colorScheme === "dark" ? "white" : "black",
headerLargeStyle: {
backgroundColor: rawTheme[colorScheme as ThemeName]["base-100"],
},
headerStyle: {
backgroundColor:
Platform.OS === "ios"
? "transparent"
: colorScheme === "dark"
? rawTheme["dark"]["base-100"]
: "white",
},
}}
>
<Stack.Screen
name="index"
options={{
headerTitle: "Dashboard",
headerRight: () => (
<DropdownMenu.Root>
<DropdownMenu.Trigger>
<TouchableOpacity>
<Plus
size={21}
color={rawTheme[colorScheme as ThemeName].primary}
/>
</TouchableOpacity>
</DropdownMenu.Trigger>
<DropdownMenu.Content>
<DropdownMenu.Item
key="new-link"
onSelect={() => SheetManager.show("add-link-sheet")}
>
<DropdownMenu.ItemTitle>New Link</DropdownMenu.ItemTitle>
</DropdownMenu.Item>
<DropdownMenu.Item key="more-options" disabled>
<DropdownMenu.ItemTitle>
More Coming Soon!
</DropdownMenu.ItemTitle>
</DropdownMenu.Item>
</DropdownMenu.Content>
</DropdownMenu.Root>
),
}}
/>
<Stack.Screen
name="[section]"
options={{
headerTitle: "Links",
headerBackTitle: "Back",
headerSearchBarOptions: {
placeholder: "Search",
autoCapitalize: "none",
headerIconColor: colorScheme === "dark" ? "white" : "black",
onChangeText: (e) => {
router.setParams({
search: encodeURIComponent(e.nativeEvent.text),
});
},
},
}}
/>
</Stack>
);
}

View File

@@ -0,0 +1,401 @@
import {
FlatList,
Platform,
RefreshControl,
ScrollView,
StyleSheet,
Text,
TouchableOpacity,
View,
} from "react-native";
import { SafeAreaView } from "react-native-safe-area-context";
import { useDashboardData } from "@linkwarden/router/dashboardData";
import useAuthStore from "@/store/auth";
import React, { useEffect, useMemo, useState } from "react";
import { DashboardSection, DashboardSectionType } from "@prisma/client";
import { useUser } from "@linkwarden/router/user";
import { useCollections } from "@linkwarden/router/collections";
import { useTags } from "@linkwarden/router/tags";
import clsx from "clsx";
import DashboardItem from "@/components/DashboardItem";
import { LinkIncludingShortenedCollectionAndTags } from "@linkwarden/types";
import LinkListing from "@/components/LinkListing";
import { useRouter } from "expo-router";
import { rawTheme, ThemeName } from "@/lib/colors";
import { useColorScheme } from "nativewind";
import {
Clock8,
ChevronRight,
Pin,
Folder,
Hash,
Link,
} from "lucide-react-native";
export default function DashboardScreen() {
const { auth } = useAuthStore();
const {
data: { links = [], numberOfPinnedLinks, collectionLinks = {} } = {
links: [],
},
...dashboardData
} = useDashboardData(auth);
const { data: user, ...userData } = useUser(auth);
const { data: collections = [] } = useCollections(auth);
const { data: tags = [] } = useTags(auth);
const { colorScheme } = useColorScheme();
const router = useRouter();
const [dashboardSections, setDashboardSections] = useState<
DashboardSection[]
>(user?.dashboardSections || []);
const [numberOfLinks, setNumberOfLinks] = useState(0);
useEffect(() => {
setDashboardSections(user?.dashboardSections || []);
}, [user?.dashboardSections]);
useEffect(() => {
setNumberOfLinks(
collections?.reduce?.(
(accumulator, collection) =>
accumulator + (collection._count as any).links,
0
)
);
}, [collections]);
const orderedSections = useMemo(() => {
return [...dashboardSections].sort((a, b) => {
const orderA = a.order ?? Number.MAX_SAFE_INTEGER;
const orderB = b.order ?? Number.MAX_SAFE_INTEGER;
return orderA - orderB;
});
}, [dashboardSections]);
interface SectionProps {
sectionData: { type: DashboardSectionType };
collection?: any;
links?: any[];
tagsLength: number;
numberOfLinks: number;
collectionsLength: number;
numberOfPinnedLinks: number;
dashboardData: { isLoading: boolean };
collectionLinks?: any[];
}
const Section: React.FC<SectionProps> = ({
sectionData,
collection,
links = [],
tagsLength,
numberOfLinks,
collectionsLength,
numberOfPinnedLinks,
dashboardData,
collectionLinks = [],
}) => {
switch (sectionData.type) {
case DashboardSectionType.STATS:
return (
<View className="flex-col gap-4 max-w-full px-5">
<View className="flex-row gap-4">
<DashboardItem
name={numberOfLinks === 1 ? "Link" : "Links"}
value={numberOfLinks}
icon={<Link size={23} color="white" />}
color="#9c00cc"
/>
<DashboardItem
name={collectionsLength === 1 ? "Collection" : "Collections"}
value={collectionsLength}
icon={<Folder size={23} color="white" fill="white" />}
color="#0096cc"
/>
</View>
<View className="flex-row gap-4">
<DashboardItem
name={tagsLength === 1 ? "Tag" : "Tags"}
value={tagsLength}
icon={<Hash size={23} color="white" />}
color="#00cc99"
/>
<DashboardItem
name={"Pinned Links"}
value={numberOfPinnedLinks}
icon={<Pin size={23} color="white" fill="white" />}
color="#cc6d00"
/>
</View>
</View>
);
case DashboardSectionType.RECENT_LINKS:
return (
<>
<View className="flex-row justify-between items-center px-5">
<View className="flex-row gap-2 items-center">
<View className={"flex-row items-center gap-2"}>
<Clock8
size={30}
color={rawTheme[colorScheme as ThemeName].primary}
/>
<Text className="text-2xl capitalize text-base-content">
Recent Links
</Text>
</View>
</View>
<TouchableOpacity
className="flex-row items-center text-sm gap-1"
onPress={() =>
router.navigate("/(tabs)/dashboard/recent-links")
}
>
<Text className="text-primary">View All</Text>
<ChevronRight
size={15}
color={rawTheme[colorScheme as ThemeName].primary}
/>
</TouchableOpacity>
</View>
{dashboardData.isLoading ||
(links.length > 0 && !dashboardData.isLoading) ? (
<FlatList
horizontal
showsHorizontalScrollIndicator={false}
directionalLockEnabled
data={links || []}
refreshing={dashboardData.isLoading}
initialNumToRender={2}
keyExtractor={(item) => item.id?.toString() || ""}
renderItem={({ item }) => (
<RenderItem item={item} key={item.id?.toString()} />
)}
ItemSeparatorComponent={() => <View style={{ width: 10 }} />}
contentContainerStyle={{
paddingHorizontal: 20,
}}
/>
) : (
<View className="flex-col gap-2 justify-center items-center h-40 p-10 rounded-xl bg-base-200 mx-5">
<Clock8
size={40}
color={rawTheme[colorScheme as ThemeName].primary}
/>
<Text className="text-center text-xl text-neutral">
No Recent Links
</Text>
{/* <View className="text-center w-full mt-4 flex-row flex-wrap gap-4 justify-center">
<Button onPress={() => setNewLinkModal(true)} variant="accent">
<Icon name="bi-plus-lg" className="text-xl" />
<Text>{t("add_link")}</Text>
</Button>
<ImportDropdown />
</View> */}
</View>
)}
</>
);
case DashboardSectionType.PINNED_LINKS:
return (
<>
<View className="flex-row justify-between items-center px-5">
<View className="flex-row gap-2 items-center">
<View className={"flex-row items-center gap-2"}>
<Pin
size={30}
color={rawTheme[colorScheme as ThemeName].primary}
/>
<Text className="text-2xl capitalize text-base-content">
Pinned Links
</Text>
</View>
</View>
<TouchableOpacity
className="flex-row items-center text-sm gap-1"
onPress={() =>
router.navigate("/(tabs)/dashboard/pinned-links")
}
>
<Text className="text-primary">View All</Text>
<ChevronRight
size={15}
color={rawTheme[colorScheme as ThemeName].primary}
/>
</TouchableOpacity>
</View>
{dashboardData.isLoading ||
links?.some((e: any) => e.pinnedBy && e.pinnedBy[0]) ? (
<FlatList
horizontal
showsHorizontalScrollIndicator={false}
data={
links.filter((e: any) => e.pinnedBy && e.pinnedBy[0]) || []
}
// onRefresh={() => data.refetch()}
refreshing={dashboardData.isLoading}
initialNumToRender={2}
keyExtractor={(item) => item.id?.toString() || ""}
renderItem={({ item }) => (
<RenderItem item={item} key={item.id?.toString()} />
)}
ItemSeparatorComponent={() => <View style={{ width: 10 }} />}
contentContainerStyle={{
paddingHorizontal: 20,
}}
/>
) : (
<View className="flex-col gap-2 justify-center items-center h-40 p-10 rounded-xl bg-base-200 mx-5">
<Pin
size={40}
color={rawTheme[colorScheme as ThemeName].primary}
/>
<Text className="text-center text-xl text-neutral">
No Pinned Links
</Text>
</View>
)}
</>
);
case DashboardSectionType.COLLECTION:
return collection?.id ? (
<>
<View className="flex-row justify-between items-center px-5">
<View className="flex-row gap-2 items-center max-w-[60%]">
<View className={clsx("flex-row items-center gap-2")}>
<Folder
size={30}
fill={collection.color || "#0ea5e9"}
color={collection.color || "#0ea5e9"}
/>
<Text
className="text-2xl capitalize w-full text-base-content"
numberOfLines={1}
>
{collection.name}
</Text>
</View>
</View>
<TouchableOpacity
className="flex-row items-center text-sm gap-1 whitespace-nowrap"
onPress={() =>
router.navigate(
`/(tabs)/dashboard/collection?collectionId=${collection.id}`
)
}
>
<Text className="text-primary">View All</Text>
<ChevronRight
size={15}
color={rawTheme[colorScheme as ThemeName].primary}
/>
</TouchableOpacity>
</View>
{dashboardData.isLoading || collectionLinks.length > 0 ? (
<FlatList
horizontal
showsHorizontalScrollIndicator={false}
data={collectionLinks || []}
// onRefresh={() => data.refetch()}
refreshing={dashboardData.isLoading}
initialNumToRender={2}
keyExtractor={(item) => item.id?.toString() || ""}
renderItem={({ item }) => (
<RenderItem item={item} key={item.id?.toString()} />
)}
ItemSeparatorComponent={() => <View style={{ width: 10 }} />}
contentContainerStyle={{
paddingHorizontal: 20,
}}
/>
) : (
<View className="flex-col gap-2 justify-center items-center h-40 p-10 rounded-xl bg-base-200 mx-5">
<Text className="text-center text-xl text-neutral">
Empty Collection
</Text>
</View>
)}
</>
) : null;
default:
return null;
}
};
const RenderItem = React.memo(
({ item }: { item: LinkIncludingShortenedCollectionAndTags }) => {
return <LinkListing link={item} dashboard />;
}
);
return (
<SafeAreaView
style={styles.container}
collapsable={false}
collapsableChildren={false}
className="bg-base-100 h-full"
>
<ScrollView
refreshControl={
<RefreshControl
refreshing={dashboardData.isLoading || userData.isLoading}
onRefresh={() => {
dashboardData.refetch();
userData.refetch();
}}
/>
}
contentContainerStyle={{
flexDirection: "column",
gap: 15,
paddingVertical: 20,
}}
className="bg-base-100"
contentInsetAdjustmentBehavior="automatic"
>
{orderedSections.map((sectionData) => {
return (
<Section
key={sectionData.id}
sectionData={sectionData}
collection={collections.find(
(c) => c.id === sectionData.collectionId
)}
collectionLinks={
sectionData.collectionId
? collectionLinks[sectionData.collectionId]
: []
}
links={links}
tagsLength={tags.length}
numberOfLinks={numberOfLinks}
collectionsLength={collections.length}
numberOfPinnedLinks={numberOfPinnedLinks}
dashboardData={dashboardData}
/>
);
})}
</ScrollView>
</SafeAreaView>
);
}
const styles = StyleSheet.create({
container: Platform.select({
ios: {
paddingBottom: 49,
},
default: {},
}),
});

View File

@@ -0,0 +1,44 @@
import { Stack, useRouter } from "expo-router";
import { useColorScheme } from "nativewind";
import { rawTheme, ThemeName } from "@/lib/colors";
import { Platform } from "react-native";
export default function RootLayout() {
const router = useRouter();
const { colorScheme } = useColorScheme();
return (
<Stack
screenOptions={{
headerTitle: "Links",
headerLargeTitle: true,
headerTransparent: Platform.OS === "ios" ? true : false,
headerShadowVisible: false,
headerBlurEffect:
colorScheme === "dark" ? "systemMaterialDark" : "systemMaterial",
headerTintColor: colorScheme === "dark" ? "white" : "black",
headerSearchBarOptions: {
placeholder: "Search",
autoCapitalize: "none",
onChangeText: (e) => {
router.setParams({
search: encodeURIComponent(e.nativeEvent.text),
});
},
headerIconColor: colorScheme === "dark" ? "white" : "black",
},
headerLargeStyle: {
backgroundColor: rawTheme[colorScheme as ThemeName]["base-100"],
},
headerStyle: {
backgroundColor:
Platform.OS === "ios"
? "transparent"
: colorScheme === "dark"
? rawTheme["dark"]["base-100"]
: "white",
},
}}
/>
);
}

View File

@@ -0,0 +1,62 @@
import { useLinks } from "@linkwarden/router/links";
import { View, StyleSheet, FlatList, Platform } from "react-native";
import useAuthStore from "@/store/auth";
import LinkListing from "@/components/LinkListing";
import { useLocalSearchParams } from "expo-router";
import React from "react";
import { LinkIncludingShortenedCollectionAndTags } from "@linkwarden/types";
const RenderItem = React.memo(
({ item }: { item: LinkIncludingShortenedCollectionAndTags }) => {
return <LinkListing link={item} />;
}
);
export default function LinksScreen() {
const { auth } = useAuthStore();
const { search } = useLocalSearchParams<{ search?: string }>();
const { links, data } = useLinks(
{
sort: 0,
searchQueryString: decodeURIComponent(search ?? ""),
},
auth
);
return (
<View
style={styles.container}
className="h-full bg-base-100"
collapsable={false}
collapsableChildren={false}
>
<FlatList
contentInsetAdjustmentBehavior="automatic"
ListHeaderComponent={() => <></>}
data={links || []}
onRefresh={() => data.refetch()}
refreshing={data.isRefetching}
initialNumToRender={4}
keyExtractor={(item) => item.id?.toString() || ""}
renderItem={({ item }) => (
<RenderItem item={item} key={item.id?.toString()} />
)}
onEndReached={() => data.fetchNextPage()}
onEndReachedThreshold={0.5}
ItemSeparatorComponent={() => (
<View className="bg-neutral-content h-px" />
)}
/>
</View>
);
}
const styles = StyleSheet.create({
container: Platform.select({
ios: {
paddingBottom: 83,
},
default: {},
}),
});

View File

@@ -0,0 +1,33 @@
import { Stack } from "expo-router";
import { useColorScheme } from "nativewind";
import { rawTheme, ThemeName } from "@/lib/colors";
import { Platform } from "react-native";
export default function RootLayout() {
const { colorScheme } = useColorScheme();
return (
<Stack
screenOptions={{
headerTitle: "Settings",
headerLargeTitle: true,
headerTransparent: Platform.OS === "ios" ? true : false,
headerShadowVisible: false,
headerBlurEffect:
colorScheme === "dark" ? "systemMaterialDark" : "systemMaterial",
headerTintColor: colorScheme === "dark" ? "white" : "black",
headerLargeStyle: {
backgroundColor: rawTheme[colorScheme as ThemeName]["base-100"],
},
headerStyle: {
backgroundColor:
Platform.OS === "ios"
? "transparent"
: colorScheme === "dark"
? rawTheme["dark"]["base-100"]
: "white",
},
}}
/>
);
}

View File

@@ -0,0 +1,211 @@
import useAuthStore from "@/store/auth";
import { useUser } from "@linkwarden/router/user";
import {
View,
Text,
StyleSheet,
ScrollView,
TouchableOpacity,
Platform,
Alert,
} from "react-native";
import { nativeApplicationVersion } from "expo-application";
import { useColorScheme } from "nativewind";
import { rawTheme, ThemeName } from "@/lib/colors";
import { useEffect, useState } from "react";
import {
Check,
FileText,
Globe,
LogOut,
Moon,
Smartphone,
Sun,
} from "lucide-react-native";
import useDataStore from "@/store/data";
import { ArchivedFormat } from "@/types/global";
export default function SettingsScreen() {
const { signOut, auth } = useAuthStore();
const { data: user } = useUser(auth);
const { colorScheme, setColorScheme } = useColorScheme();
const { data, updateData } = useDataStore();
const [override, setOverride] = useState<"light" | "dark" | "system">(
data.theme || "system"
);
useEffect(() => {
setColorScheme(override);
updateData({ theme: override });
}, [override]);
return (
<View
style={styles.container}
collapsable={false}
collapsableChildren={false}
className="bg-base-100"
>
<ScrollView
contentContainerStyle={{
padding: 20,
}}
contentContainerClassName="flex-col gap-6"
contentInsetAdjustmentBehavior="automatic"
>
<View className="bg-base-200 rounded-xl px-4 py-3">
<Text className="font-semibold text-xl text-base-content">
Your account
</Text>
<Text className="text-neutral mt-2 mb-3">
{user?.email || "@" + user?.username}
</Text>
<View className="h-px bg-neutral-content -mr-4" />
<TouchableOpacity
className="flex-row items-center mt-3"
onPress={() =>
Alert.alert("Sign Out", "Are you sure you want to sign out?", [
{
text: "Cancel",
style: "cancel",
},
{
text: "Sign Out",
style: "destructive",
onPress: () => {
signOut();
},
},
])
}
>
<Text className="flex-1 text-base text-red-500">Sign Out</Text>
<LogOut size={18} color="red" />
</TouchableOpacity>
</View>
<View>
<Text className="mb-4 mx-4 text-neutral">Theme</Text>
<View className="bg-base-200 rounded-xl flex-col">
<TouchableOpacity
className="flex-row gap-2 items-center justify-between py-3 px-4"
onPress={() => setOverride("system")}
>
<View className="flex-row items-center gap-2">
<Smartphone
size={20}
color={rawTheme[colorScheme as ThemeName].neutral}
/>
<Text className="text-neutral">System Defaults</Text>
</View>
{override === "system" ? (
<Check
size={20}
color={rawTheme[colorScheme as ThemeName].primary}
/>
) : null}
</TouchableOpacity>
<View className="h-px bg-neutral-content ml-12" />
<TouchableOpacity
className="flex-row gap-2 items-center justify-between py-3 px-4"
onPress={() => setOverride("light")}
>
<View className="flex-row items-center gap-2">
<Sun size={20} color="orange" />
<Text className="text-orange-500 dark:text-orange-400">
Light
</Text>
</View>
{override === "light" ? (
<Check
size={20}
color={rawTheme[colorScheme as ThemeName].primary}
/>
) : null}
</TouchableOpacity>
<View className="h-px bg-neutral-content ml-12" />
<TouchableOpacity
className="flex-row gap-2 items-center justify-between py-3 px-4"
onPress={() => setOverride("dark")}
>
<View className="flex-row items-center gap-2">
<Moon size={20} color="royalblue" />
<Text className="text-blue-600 dark:text-blue-400">Dark</Text>
</View>
{override === "dark" ? (
<Check
size={20}
color={rawTheme[colorScheme as ThemeName].primary}
/>
) : null}
</TouchableOpacity>
</View>
</View>
<View>
<Text className="mb-4 mx-4 text-neutral">
Default Behavior for Opening Links
</Text>
<View className="bg-base-200 rounded-xl flex-col">
<TouchableOpacity
className="flex-row gap-2 items-center justify-between py-3 px-4"
onPress={() =>
updateData({
preferredFormat: null,
})
}
>
<View className="flex-row items-center gap-2">
<Globe
size={20}
color={rawTheme[colorScheme as ThemeName].neutral}
/>
<Text className="text-base-content">Open original content</Text>
</View>
{data.preferredFormat === null ? (
<Check
size={20}
color={rawTheme[colorScheme as ThemeName].primary}
/>
) : null}
</TouchableOpacity>
<View className="h-px bg-neutral-content ml-12" />
<TouchableOpacity
className="flex-row gap-2 items-center justify-between py-3 px-4"
onPress={() =>
updateData({
preferredFormat: ArchivedFormat.readability,
})
}
>
<View className="flex-row items-center gap-2">
<FileText
size={20}
color={rawTheme[colorScheme as ThemeName].neutral}
/>
<Text className="text-base-content">Open reader view</Text>
</View>
{data.preferredFormat === ArchivedFormat.readability ? (
<Check
size={20}
color={rawTheme[colorScheme as ThemeName].primary}
/>
) : null}
</TouchableOpacity>
</View>
</View>
<Text className="mx-auto text-sm text-neutral">
Linkwarden for {Platform.OS === "ios" ? "iOS" : "Android"}{" "}
{nativeApplicationVersion}
</Text>
</ScrollView>
</View>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
},
});

View File

@@ -0,0 +1,20 @@
import { Link, Stack } from "expo-router";
import { View, StyleSheet } from "react-native";
export default function NotFoundScreen() {
return (
<>
<Stack.Screen options={{ title: "Oops! This screen doesn't exist." }} />
<View style={styles.container}>
<Link href="/">Go to home screen</Link>
</View>
</>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
justifyContent: "center",
alignItems: "center",
},
});

186
apps/mobile/app/_layout.tsx Normal file
View File

@@ -0,0 +1,186 @@
import {
Stack,
usePathname,
useRootNavigationState,
useRouter,
} from "expo-router";
import { PersistQueryClientProvider } from "@tanstack/react-query-persist-client";
import { mmkvPersister } from "@/lib/queryPersister";
import { useState, useEffect } from "react";
import "../styles/global.css";
import { SheetProvider } from "react-native-actions-sheet";
import "@/components/ActionSheets/Sheets";
import { useColorScheme } from "nativewind";
import { lightTheme, darkTheme } from "../lib/theme";
import { Platform, View } from "react-native";
import { rawTheme, ThemeName } from "@/lib/colors";
import { useShareIntent } from "expo-share-intent";
import useDataStore from "@/store/data";
import useAuthStore from "@/store/auth";
import { QueryClient } from "@tanstack/react-query";
import * as FileSystem from "expo-file-system";
const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 1000 * 60 * 60 * 24,
refetchOnMount: true,
refetchOnWindowFocus: false,
refetchOnReconnect: false,
},
},
});
export default function RootLayout() {
const [isLoading, setIsLoading] = useState(true);
const { colorScheme } = useColorScheme();
const { hasShareIntent, shareIntent, error, resetShareIntent } =
useShareIntent();
const { updateData, setData, data } = useDataStore();
const router = useRouter();
const pathname = usePathname();
const { auth, setAuth } = useAuthStore();
const rootNavState = useRootNavigationState();
useEffect(() => {
setAuth();
setData();
}, []);
useEffect(() => {
(async () => {
if (auth.status === "unauthenticated") {
queryClient.cancelQueries();
queryClient.clear();
mmkvPersister.removeClient?.();
const CACHE_DIR =
FileSystem.documentDirectory + "archivedData/readable/";
await FileSystem.deleteAsync(CACHE_DIR, { idempotent: true });
}
})();
}, [auth.status]);
useEffect(() => {
if (!rootNavState?.key) return;
if (hasShareIntent && shareIntent.webUrl) {
updateData({
shareIntent: {
hasShareIntent: true,
url: shareIntent.webUrl || "",
},
});
resetShareIntent();
}
const needsRewrite =
((typeof pathname === "string" && pathname.startsWith("/dataUrl=")) ||
hasShareIntent) &&
pathname !== "/incoming";
if (needsRewrite) {
router.replace("/incoming");
}
if (hasShareIntent) {
resetShareIntent();
router.replace("/incoming");
}
}, [
rootNavState?.key,
hasShareIntent,
pathname,
shareIntent?.webUrl,
data.shareIntent,
]);
return (
<PersistQueryClientProvider
client={queryClient}
persistOptions={{
persister: mmkvPersister,
maxAge: Infinity,
dehydrateOptions: {
shouldDehydrateMutation: () => true,
shouldDehydrateQuery: () => true,
},
}}
onSuccess={() => {
setIsLoading(false);
queryClient.invalidateQueries();
}}
>
<View
style={[{ flex: 1 }, colorScheme === "dark" ? darkTheme : lightTheme]}
>
<SheetProvider>
{!isLoading && (
<Stack
screenOptions={{
navigationBarColor:
rawTheme[colorScheme as ThemeName]["base-200"],
headerShown: false,
contentStyle: {
backgroundColor:
rawTheme[colorScheme as ThemeName]["base-100"],
},
...Platform.select({
android: {
statusBarStyle: colorScheme === "dark" ? "light" : "dark",
statusBarBackgroundColor:
rawTheme[colorScheme as ThemeName]["base-100"],
},
}),
}}
>
{/* <Stack.Screen name="(tabs)" /> */}
<Stack.Screen
name="links/[id]"
options={{
headerShown: true,
headerBackTitle: "Back",
headerTitle: "",
headerTintColor: colorScheme === "dark" ? "white" : "black",
navigationBarColor:
rawTheme[colorScheme as ThemeName]["base-100"],
headerStyle: {
backgroundColor:
colorScheme === "dark"
? rawTheme["dark"]["base-100"]
: "white",
},
}}
/>
<Stack.Screen
name="login"
options={{
navigationBarColor:
rawTheme[colorScheme as ThemeName]["base-100"],
...Platform.select({
android: {
statusBarStyle:
colorScheme === "light" ? "light" : "dark",
statusBarBackgroundColor:
rawTheme[colorScheme as ThemeName]["primary"],
},
}),
}}
/>
<Stack.Screen
name="incoming"
options={{
navigationBarColor:
rawTheme[colorScheme as ThemeName]["base-100"],
}}
/>
<Stack.Screen name="+not-found" />
</Stack>
)}
</SheetProvider>
</View>
</PersistQueryClientProvider>
);
}

View File

@@ -0,0 +1,98 @@
import React, { useEffect } from "react";
import {
SafeAreaView,
View,
Text,
StyleSheet,
ActivityIndicator,
Alert,
} from "react-native";
import { Redirect, useRouter } from "expo-router";
import useAuthStore from "@/store/auth";
import useDataStore from "@/store/data";
import { Check } from "lucide-react-native";
import { useAddLink } from "@linkwarden/router/links";
import { rawTheme, ThemeName } from "@/lib/colors";
import { useColorScheme } from "nativewind";
export default function IncomingScreen() {
const { auth } = useAuthStore();
const router = useRouter();
const { data, updateData } = useDataStore();
const addLink = useAddLink(auth);
const { colorScheme } = useColorScheme();
useEffect(() => {
if (auth.status === "authenticated" && data.shareIntent.url)
addLink.mutate(
{ url: data.shareIntent.url },
{
onSuccess: () => {
setTimeout(() => {
updateData({
shareIntent: {
hasShareIntent: false,
url: "",
},
});
router.replace("/dashboard");
}, 1000);
},
onError: (error) => {
Alert.alert("Error", "There was an error adding the link.");
console.error("Error adding link:", error);
},
}
);
}, [auth, data.shareIntent.url]);
if (auth.status === "unauthenticated") return <Redirect href="/login" />;
return (
<SafeAreaView className="flex-1 bg-base-100">
{data?.shareIntent.url ? (
<View className="flex-1 items-center justify-center">
<Check
size={140}
className="mb-3 text-base-content"
color={rawTheme[colorScheme as ThemeName].primary}
/>
<Text className="text-2xl font-semibold text-base-content">
Link Saved!
</Text>
</View>
) : (
<View className="flex-1 items-center justify-center">
<ActivityIndicator size="large" />
<Text className="mt-3 text-base text-base-content opacity-70">
One sec {String(data?.shareIntent.url)}
</Text>
</View>
)}
</SafeAreaView>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: "#fff",
},
center: {
flex: 1,
alignItems: "center",
justifyContent: "center",
},
check: {
marginBottom: 12,
},
title: {
fontSize: 28,
fontWeight: "600",
},
subtitle: {
marginTop: 12,
fontSize: 16,
opacity: 0.7,
},
});

12
apps/mobile/app/index.tsx Normal file
View File

@@ -0,0 +1,12 @@
import useAuthStore from "@/store/auth";
import { Redirect } from "expo-router";
export default function HomeScreen() {
const { auth } = useAuthStore();
if (auth.session) {
return <Redirect href="/(tabs)/dashboard" />;
} else {
return <Redirect href="/login" />;
}
}

View File

@@ -0,0 +1,186 @@
import React, { useEffect, useMemo, useState } from "react";
import {
View,
ActivityIndicator,
Text,
ScrollView,
TouchableOpacity,
} from "react-native";
import { WebView } from "react-native-webview";
import * as FileSystem from "expo-file-system";
import NetInfo from "@react-native-community/netinfo";
import useAuthStore from "@/store/auth";
import { useLocalSearchParams, useRouter } from "expo-router";
import { useUser } from "@linkwarden/router/user";
import { generateLinkHref } from "@linkwarden/lib/generateLinkHref";
import { useWindowDimensions } from "react-native";
import RenderHtml from "@linkwarden/react-native-render-html";
import ElementNotSupported from "@/components/ElementNotSupported";
import { decode } from "html-entities";
import { useGetLink } from "@linkwarden/router/links";
import { useColorScheme } from "nativewind";
import { rawTheme, ThemeName } from "@/lib/colors";
import { CalendarDays, Link } from "lucide-react-native";
const CACHE_DIR = FileSystem.documentDirectory + "archivedData/readable/";
const htmlPath = (id: string) => `${CACHE_DIR}link_${id}.html`;
async function ensureCacheDir() {
const info = await FileSystem.getInfoAsync(CACHE_DIR);
if (!info.exists) {
await FileSystem.makeDirectoryAsync(CACHE_DIR, { intermediates: true });
}
}
export default function LinkScreen() {
const { auth } = useAuthStore();
const { id, format } = useLocalSearchParams();
const { data: user } = useUser(auth);
const [url, setUrl] = useState<string>();
const [htmlContent, setHtmlContent] = useState<string>("");
const [isLoading, setIsLoading] = useState(true);
const { width } = useWindowDimensions();
const router = useRouter();
const { colorScheme } = useColorScheme();
const { data: link } = useGetLink({ id: Number(id), auth, enabled: true });
useEffect(() => {
async function loadCacheOrFetch() {
await ensureCacheDir();
const htmlFile = htmlPath(id as string);
const [htmlInfo] = await Promise.all([FileSystem.getInfoAsync(htmlFile)]);
if (format === "3" && htmlInfo.exists) {
const rawHtml = await FileSystem.readAsStringAsync(htmlFile);
setHtmlContent(rawHtml);
setIsLoading(false);
}
const net = await NetInfo.fetch();
if (net.isConnected) {
await fetchLinkData();
}
}
if (user?.id && link?.id && !url) {
loadCacheOrFetch();
}
}, [user, link]);
async function fetchLinkData() {
if (link?.id && format === "3") {
const apiUrl = `${auth.instance}/api/v1/archives/${link.id}?format=${format}`;
setUrl(apiUrl);
try {
const response = await fetch(apiUrl, {
headers: { Authorization: `Bearer ${auth.session}` },
});
const html = (await response.json()).content;
setHtmlContent(html);
await FileSystem.writeAsStringAsync(htmlPath(id as string), html, {
encoding: FileSystem.EncodingType.UTF8,
});
} catch (e) {
console.error("Failed to fetch HTML content", e);
} finally {
setIsLoading(false);
}
} else if (link?.id && !format && user) {
setUrl(
generateLinkHref(link, { ...user, password: "" }, auth.instance, true)
);
} else if (link?.id && format) {
setUrl(`${auth.instance}/api/v1/archives/${link.id}?format=${format}`);
}
}
return (
<>
{format === "3" && htmlContent ? (
<ScrollView
className="flex-1 bg-base-100"
contentContainerClassName="p-4"
nestedScrollEnabled
>
<Text className="text-2xl font-bold mb-2.5 text-base-content">
{decode(link?.name || link?.description || link?.url || "")}
</Text>
<TouchableOpacity
className="flex-row items-center gap-1 mb-2.5 pr-5"
onPress={() => router.replace(`/links/${id}`)}
>
<Link
size={16}
color={rawTheme[colorScheme as ThemeName]["neutral"]}
/>
<Text className="text-base text-neutral flex-1" numberOfLines={1}>
{link?.url}
</Text>
</TouchableOpacity>
<View className="flex-row items-center gap-1 mb-2.5">
<CalendarDays
size={16}
color={rawTheme[colorScheme as ThemeName]["neutral"]}
/>
<Text className="text-base text-neutral">
{new Date(
(link?.importDate || link?.createdAt) as string
).toLocaleString("en-US", {
year: "numeric",
month: "short",
day: "numeric",
})}
</Text>
</View>
<View className="border-t border-neutral-content mt-2.5 mb-5" />
<RenderHtml
contentWidth={width}
source={{ html: htmlContent }}
renderers={{
table: () => (
<ElementNotSupported
onPress={() => router.replace(`/links/${id}`)}
/>
),
}}
tagsStyles={{
p: { fontSize: 18, lineHeight: 28, marginVertical: 10 },
}}
baseStyle={{
color: rawTheme[colorScheme as ThemeName]["base-content"],
}}
/>
</ScrollView>
) : url ? (
<WebView
className={isLoading ? "opacity-0" : "flex-1"}
source={{
uri: url,
headers: format ? { Authorization: `Bearer ${auth.session}` } : {},
}}
onLoadEnd={() => setIsLoading(false)}
/>
) : (
<View className="flex-1 justify-center items-center bg-base-100 p-5">
<Text className="text-base text-neutral">
No link data available. Please check your network connection or try
again later.
</Text>
</View>
)}
{isLoading && (
<View className="absolute inset-0 flex-1 justify-center items-center bg-base-100 p-5">
<ActivityIndicator size="large" />
<Text className="text-base mt-2.5 text-neutral">Loading...</Text>
</View>
)}
</>
);
}

104
apps/mobile/app/login.tsx Normal file
View File

@@ -0,0 +1,104 @@
import { Button } from "@/components/ui/Button";
import Input from "@/components/ui/Input";
import { rawTheme, ThemeName } from "@/lib/colors";
import useAuthStore from "@/store/auth";
import { Redirect } from "expo-router";
import { useColorScheme } from "nativewind";
import { useState } from "react";
import { View, Text, Dimensions, TouchableOpacity } from "react-native";
import Svg, { Path } from "react-native-svg";
export default function HomeScreen() {
const { auth, signIn } = useAuthStore();
const { colorScheme } = useColorScheme();
const [method, setMethod] = useState<"password" | "token">("password");
const [form, setForm] = useState({
user: "",
password: "",
token: "",
instance: "",
});
if (auth.status === "authenticated") {
return <Redirect href="/dashboard" />;
}
return (
<View className="flex-col justify-end h-full bg-primary">
<Text className="text-base-100 text-7xl font-bold ml-8">Login</Text>
<Svg
viewBox="0 0 1440 320"
width={Dimensions.get("screen").width}
height={100}
>
<Path
fill={rawTheme[colorScheme as ThemeName]["base-100"]}
fill-opacity="1"
d="M0,256L48,234.7C96,213,192,171,288,176C384,181,480,235,576,266.7C672,299,768,309,864,277.3C960,245,1056,171,1152,122.7C1248,75,1344,53,1392,42.7L1440,32L1440,320L1392,320C1344,320,1248,320,1152,320C1056,320,960,320,864,320C768,320,672,320,576,320C480,320,384,320,288,320C192,320,96,320,48,320L0,320Z"
/>
</Svg>
<View className="flex-col justify-end h-1/3 bg-base-100 -mt-2 pb-10 gap-4 w-full px-4">
{method === "password" ? (
<>
<Input
className="w-full text-xl p-3 leading-tight"
textAlignVertical="center"
placeholder="Email or Username"
value={form.user}
onChangeText={(text) => setForm({ ...form, user: text })}
/>
<Input
className="w-full text-xl p-3 leading-tight"
textAlignVertical="center"
placeholder="Password"
secureTextEntry
value={form.password}
onChangeText={(text) => setForm({ ...form, password: text })}
/>
</>
) : (
<Input
className="w-full text-xl p-3 leading-tight"
textAlignVertical="center"
placeholder="Access Token"
secureTextEntry
value={form.token}
onChangeText={(text) => setForm({ ...form, token: text })}
/>
)}
<TouchableOpacity
onPress={() =>
setMethod(method === "password" ? "token" : "password")
}
className="w-fit mx-auto"
>
<Text className="text-primary w-fit text-center">
{method === "password"
? "Login with Access Token instead"
: "Login with Username/Password instead"}
</Text>
</TouchableOpacity>
<Button
variant="accent"
size="lg"
onPress={() =>
signIn(
form.user,
form.password,
form.instance ? form.instance : undefined
)
}
>
<Text className="text-white">Login</Text>
</Button>
<TouchableOpacity className="w-fit mx-auto">
<Text className="text-neutral text-center w-fit">Need help?</Text>
</TouchableOpacity>
</View>
</View>
);
}

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

View File

@@ -0,0 +1,9 @@
module.exports = function (api) {
api.cache(true);
return {
presets: [
["babel-preset-expo", { jsxImportSource: "nativewind" }],
"nativewind/babel",
],
};
};

View File

@@ -0,0 +1,72 @@
import { Alert, Text, View } from "react-native";
import { useRef, useState } from "react";
import ActionSheet, { ActionSheetRef } from "react-native-actions-sheet";
import Input from "@/components/ui/Input";
import { Button } from "@/components/ui/Button";
import { useAddLink } from "@linkwarden/router/links";
import useAuthStore from "@/store/auth";
import { rawTheme, ThemeName } from "@/lib/colors";
import { useColorScheme } from "nativewind";
export default function AddLinkSheet() {
const actionSheetRef = useRef<ActionSheetRef>(null);
const { auth } = useAuthStore();
const addLink = useAddLink(auth);
const [link, setLink] = useState("");
const { colorScheme } = useColorScheme();
return (
<ActionSheet
ref={actionSheetRef}
gestureEnabled
indicatorStyle={{
backgroundColor: rawTheme[colorScheme as ThemeName]["neutral-content"],
}}
containerStyle={{
backgroundColor: rawTheme[colorScheme as ThemeName]["base-200"],
}}
>
<View className="px-8 py-5">
<Input
placeholder="e.g. https://example.com"
className="mb-4 bg-base-100"
value={link}
onChangeText={setLink}
/>
<Button
onPress={() =>
addLink.mutate(
{ url: link },
{
onSuccess: () => {
actionSheetRef.current?.hide();
setLink("");
},
onError: (error) => {
Alert.alert("Error", "There was an error adding the link.");
console.error("Error adding link:", error);
},
}
)
}
variant="accent"
className="mb-2"
>
<Text className="text-white">Save to Linkwarden</Text>
</Button>
<Button
onPress={() => {
actionSheetRef.current?.hide();
setLink("");
}}
variant="outline"
className="mb-2"
>
<Text className="text-base-content">Cancel</Text>
</Button>
</View>
</ActionSheet>
);
}

View File

@@ -0,0 +1,263 @@
import { View, Text, Alert } from "react-native";
import { useCallback, useEffect, useMemo, useState } from "react";
import ActionSheet, {
FlatList,
Route,
SheetManager,
SheetProps,
useSheetRouteParams,
useSheetRouter,
} from "react-native-actions-sheet";
import Input from "@/components/ui/Input";
import { Button } from "@/components/ui/Button";
import { useAddLink, useUpdateLink } from "@linkwarden/router/links";
import useAuthStore from "@/store/auth";
import {
CollectionIncludingMembersAndLinkCount,
LinkIncludingShortenedCollectionAndTags,
} from "@linkwarden/types";
import { useCollections } from "@linkwarden/router/collections";
import { rawTheme, ThemeName } from "@/lib/colors";
import { useColorScheme } from "nativewind";
import { Folder, ChevronRight, Check } from "lucide-react-native";
const Main = (props: SheetProps<"edit-link-sheet">) => {
const { auth } = useAuthStore();
const params = useSheetRouteParams("edit-link-sheet", "main");
const [link, setLink] = useState<
LinkIncludingShortenedCollectionAndTags | undefined
>(props.payload?.link);
const editLink = useUpdateLink(auth);
const router = useSheetRouter("edit-link-sheet");
const { colorScheme } = useColorScheme();
useEffect(() => {
if (params?.link) {
setLink(params.link);
}
}, [params?.link]);
return (
<View className="px-8 py-5">
<Input
placeholder="Name"
className="mb-4 bg-base-100"
value={link?.name || ""}
onChangeText={(text) => link?.id && setLink({ ...link, name: text })}
/>
{props.payload?.link?.url && (
<Input
placeholder="URL"
className="mb-4 bg-base-100"
value={link?.url || ""}
onChangeText={(text) => link?.id && setLink({ ...link, url: text })}
/>
)}
<Button
variant="input"
className="mb-4"
onPress={() => router?.navigate("collections", { link })}
>
<View className="flex-row items-center gap-2 w-[90%]">
<Folder
size={20}
fill={link?.collection.color || "gray"}
color={link?.collection.color || "gray"}
/>
<Text numberOfLines={1} className="w-[90%] text-base-content">
{link?.collection.name}
</Text>
</View>
<ChevronRight
size={16}
color={rawTheme[colorScheme as ThemeName]["neutral"]}
/>
</Button>
{/* <Button variant="input" className="mb-4 h-auto">
{link?.tags && link?.tags.length > 0 ? (
<View className="flex-row flex-wrap items-center gap-2 w-[90%]">
{link.tags.map((tag) => (
<View
key={tag.id}
className="bg-gray-200 rounded-md h-7 px-2 py-1"
>
<Text numberOfLines={1}>{tag.name}</Text>
</View>
))}
</View>
) : (
<Text className="text-gray-500">No tags</Text>
)}
<ChevronRight size={16} color={"gray"} />
</Button> */}
<Input
multiline
textAlignVertical="top"
placeholder="Description"
className="mb-4 h-28 bg-base-100"
value={link?.description || ""}
onChangeText={(text) =>
link?.id && setLink({ ...link, description: text })
}
/>
<Button
onPress={() =>
editLink.mutate(link as LinkIncludingShortenedCollectionAndTags, {
onSuccess: () => {
SheetManager.hide("edit-link-sheet");
},
onError: (error) => {
Alert.alert("Error", "There was an error editing the link.");
console.error("Error editing link:", error);
},
})
}
variant="accent"
className="mb-2"
>
<Text className="text-white">Save</Text>
</Button>
<Button
onPress={() => {
SheetManager.hide("edit-link-sheet");
}}
variant="outline"
className="mb-2"
>
<Text className="text-base-content">Cancel</Text>
</Button>
</View>
);
};
const Collections = () => {
const { auth } = useAuthStore();
const addLink = useAddLink(auth);
const [searchQuery, setSearchQuery] = useState("");
const router = useSheetRouter("edit-link-sheet");
const { link: currentLink } = useSheetRouteParams<
"edit-link-sheet",
"collections"
>("edit-link-sheet", "collections");
const params = useSheetRouteParams("edit-link-sheet", "collections");
const collections = useCollections(auth);
const { colorScheme } = useColorScheme();
const filteredCollections = useMemo(() => {
if (!collections.data) return [];
const q = searchQuery.trim().toLowerCase();
if (q === "") return collections.data;
return collections.data.filter((col) => col.name.toLowerCase().includes(q));
}, [collections.data, searchQuery]);
const renderItem = useCallback(
({
item: collection,
}: {
item: CollectionIncludingMembersAndLinkCount;
}) => {
const onSelect = () => {
// 1. Create a brand-new link object with the new collection
const updatedLink = {
...currentLink!,
collection,
};
// 2. Navigate back to "main", passing the updated link as payload
router?.popToTop();
router?.navigate("main", { link: updatedLink });
};
return (
<Button variant="input" className="mb-2" onPress={onSelect}>
<View className="flex-row items-center gap-2 w-[75%]">
<Folder
size={20}
fill={collection.color || "gray"}
color={collection.color || "gray"}
/>
<Text numberOfLines={1} className="w-full text-base-content">
{collection.name}
</Text>
</View>
<View className="flex-row items-center gap-2">
{params.link?.collection.id === collection.id && (
<Check
size={16}
color={rawTheme[colorScheme as ThemeName].primary}
/>
)}
<Text className="text-neutral">
{collection._count?.links ?? 0}
</Text>
</View>
</Button>
);
},
[addLink, params.link, router]
);
return (
<View className="px-8 py-5 max-h-[80vh]">
<Input
placeholder="Search collections"
className="mb-4 bg-base-100"
value={searchQuery}
onChangeText={setSearchQuery}
/>
<FlatList
data={filteredCollections}
keyExtractor={(e, i) => i.toString()}
renderItem={renderItem}
ListEmptyComponent={
<Text
style={{ textAlign: "center", marginTop: 20 }}
className="text-neutral"
>
No collections match {searchQuery}
</Text>
}
contentContainerStyle={{ paddingBottom: 20 }}
/>
</View>
);
};
const routes: Route[] = [
{
name: "main",
component: Main,
},
{
name: "collections",
component: Collections,
},
];
export default function EditLinkSheet() {
const { colorScheme } = useColorScheme();
return (
<ActionSheet
gestureEnabled
indicatorStyle={{
backgroundColor: rawTheme[colorScheme as ThemeName]["neutral-content"],
}}
enableRouterBackNavigation={true}
routes={routes}
initialRoute="main"
containerStyle={{
backgroundColor: rawTheme[colorScheme as ThemeName]["base-200"],
}}
/>
);
}

View File

@@ -0,0 +1,32 @@
import {
registerSheet,
RouteDefinition,
SheetDefinition,
} from "react-native-actions-sheet";
import AddLinkSheet from "./AddLinkSheet";
import EditLinkSheet from "./EditLinkSheet";
import { LinkIncludingShortenedCollectionAndTags } from "@linkwarden/types";
registerSheet("add-link-sheet", AddLinkSheet);
registerSheet("edit-link-sheet", EditLinkSheet);
declare module "react-native-actions-sheet" {
interface Sheets {
"add-link-sheet": SheetDefinition;
"edit-link-sheet": SheetDefinition<{
payload: {
link: LinkIncludingShortenedCollectionAndTags;
};
routes: {
main: RouteDefinition<{
link: LinkIncludingShortenedCollectionAndTags;
}>;
collections: RouteDefinition<{
link: LinkIncludingShortenedCollectionAndTags;
}>;
};
}>;
}
}
export {};

View File

@@ -0,0 +1,35 @@
import { Text, View } from "react-native";
export default function DashboardItem({
name,
value,
icon,
color,
}: {
name: string;
value: number;
icon: React.ReactNode;
color: string;
}) {
return (
<View className="flex-1 flex-col gap-2 rounded-xl bg-base-200 p-3">
<View className="flex-row justify-between">
<View
className="flex-col gap-2 rounded-full aspect-square flex justify-center items-center"
style={{
backgroundColor: color,
}}
>
{icon}
</View>
<Text
className="text-4xl text-base-content mt-0.5 text-right max-w-[75%]"
numberOfLines={1}
>
{value || 0}
</Text>
</View>
<Text className="font-semibold text-neutral">{name}</Text>
</View>
);
}

View File

@@ -0,0 +1,18 @@
import { View, Text, Button } from "react-native";
export default function ElementNotSupported({
message = "This element is currently not supported in this view.",
buttonTitle = "Open original",
onPress,
}: {
message?: string;
buttonTitle?: string;
onPress: () => void;
}) {
return (
<View className="border-y border-neutral-content my-2 py-5 flex justify-center items-center">
<Text className="text-neutral">{message}</Text>
<Button onPress={onPress} title={buttonTitle} />
</View>
);
}

View File

@@ -0,0 +1,18 @@
import { BottomTabBarButtonProps } from "@react-navigation/bottom-tabs";
import { PlatformPressable } from "@react-navigation/elements";
import * as Haptics from "expo-haptics";
export default function HapticTab(props: BottomTabBarButtonProps) {
return (
<PlatformPressable
{...props}
onPressIn={(ev) => {
if (process.env.EXPO_OS === "ios") {
// Add a soft haptic feedback when pressing down on the tabs.
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
}
props.onPressIn?.(ev);
}}
/>
);
}

View File

@@ -0,0 +1,285 @@
import { View, Text, Image, Pressable, Platform, Alert } from "react-native";
import { decode } from "html-entities";
import { LinkIncludingShortenedCollectionAndTags } from "@linkwarden/types";
import { ArchivedFormat } from "@linkwarden/types";
import {
atLeastOneFormatAvailable,
formatAvailable,
} from "@linkwarden/lib/formatStats";
import useAuthStore from "@/store/auth";
import { useRouter } from "expo-router";
import * as ContextMenu from "zeego/context-menu";
import { useDeleteLink, useUpdateLink } from "@linkwarden/router/links";
import { SheetManager } from "react-native-actions-sheet";
import * as Clipboard from "expo-clipboard";
import { cn } from "@linkwarden/lib/utils";
import { useUser } from "@linkwarden/router/user";
import { rawTheme, ThemeName } from "@/lib/colors";
import { useColorScheme } from "nativewind";
import { CalendarDays, Folder } from "lucide-react-native";
import useDataStore from "@/store/data";
type Props = {
link: LinkIncludingShortenedCollectionAndTags;
dashboard?: boolean;
};
const LinkListing = ({ link, dashboard }: Props) => {
const { auth } = useAuthStore();
const router = useRouter();
const updateLink = useUpdateLink(auth);
const { data: user } = useUser(auth);
const { colorScheme } = useColorScheme();
const { data } = useDataStore();
const deleteLink = useDeleteLink(auth);
let shortendURL;
try {
if (link.url) {
shortendURL = new URL(link.url).host.toLowerCase();
}
} catch (error) {
console.log(error);
}
return (
<ContextMenu.Root>
<ContextMenu.Trigger asChild>
<Pressable
className={cn(
"p-5 flex-row justify-between",
dashboard ? "bg-base-200" : "bg-base-100",
Platform.OS !== "android" && "active:bg-base-200/50",
dashboard && "rounded-xl"
)}
onLongPress={() => {}}
onPress={() =>
router.push(
data.preferredFormat
? `/links/${link.id}?format=${data.preferredFormat}`
: `/links/${link.id}`
)
}
android_ripple={{
color: colorScheme === "dark" ? "rgba(255,255,255,0.2)" : "#ddd",
borderless: false,
}}
>
<View
className={cn(
"flex-row justify-between",
dashboard ? "w-80" : "w-full"
)}
>
<View className="w-[65%] flex-col justify-between">
<Text
numberOfLines={2}
className="font-medium text-lg text-base-content"
>
{decode(link.name || link.description || link.url)}
</Text>
{shortendURL && (
<Text
numberOfLines={1}
className="mt-1.5 font-light text-sm text-base-content"
>
{shortendURL}
</Text>
)}
<View className="flex flex-row gap-1 items-center mt-1.5 pr-1.5 self-start rounded-md">
<Folder
size={16}
fill={link.collection.color || ""}
color={link.collection.color || ""}
/>
<Text
numberOfLines={1}
className="font-light text-xs text-base-content"
>
{link.collection.name}
</Text>
</View>
</View>
<View className="flex-col items-end">
<View className="rounded-lg overflow-hidden relative">
{formatAvailable(link, "preview") ? (
<Image
source={{
uri: `${auth.instance}/api/v1/archives/${link.id}?format=${ArchivedFormat.jpeg}&preview=true&updatedAt=${link.updatedAt}`,
headers: {
Authorization: `Bearer ${auth.session}`,
},
}}
className="rounded-md h-[60px] w-[90px] object-cover scale-105"
/>
) : (
<View className="h-[60px] w-[90px]" />
)}
</View>
<View className="flex flex-row gap-1 items-center mt-5 self-start">
<CalendarDays
size={16}
color={rawTheme[colorScheme as ThemeName]["neutral"]}
/>
<Text
numberOfLines={1}
className="font-light text-xs text-base-content"
>
{new Date(link.createdAt as string).toLocaleString("en-US", {
year: "numeric",
month: "short",
day: "numeric",
})}
</Text>
</View>
</View>
</View>
</Pressable>
</ContextMenu.Trigger>
<ContextMenu.Content avoidCollisions>
<ContextMenu.Item
key="open-link"
onSelect={() => router.push(`/links/${link.id}`)}
>
<ContextMenu.ItemTitle>Open Link</ContextMenu.ItemTitle>
</ContextMenu.Item>
{link.url && (
<ContextMenu.Item
key="copy-url"
onSelect={async () => {
await Clipboard.setStringAsync(link.url as string);
}}
>
<ContextMenu.ItemTitle>Copy URL</ContextMenu.ItemTitle>
</ContextMenu.Item>
)}
<ContextMenu.Item
key="pin-link"
onSelect={async () => {
const isAlreadyPinned =
link?.pinnedBy && link.pinnedBy[0] ? true : false;
await updateLink.mutateAsync({
...link,
pinnedBy: (isAlreadyPinned
? [{ id: undefined }]
: [{ id: user?.id }]) as any,
});
}}
>
<ContextMenu.ItemTitle>
{link.pinnedBy && link.pinnedBy[0] ? "Unpin Link" : "Pin Link"}
</ContextMenu.ItemTitle>
</ContextMenu.Item>
<ContextMenu.Item
key="edit-link"
onSelect={() => {
SheetManager.show("edit-link-sheet", {
payload: { link: link },
});
}}
>
<ContextMenu.ItemTitle>Edit Link</ContextMenu.ItemTitle>
</ContextMenu.Item>
{link.url && atLeastOneFormatAvailable(link) && (
<ContextMenu.Sub>
<ContextMenu.SubTrigger key="preserved-formats">
<ContextMenu.ItemTitle>Preserved Formats</ContextMenu.ItemTitle>
</ContextMenu.SubTrigger>
<ContextMenu.SubContent>
{formatAvailable(link, "monolith") && (
<ContextMenu.Item
key="preserved-formats-webpage"
onSelect={() =>
router.push(
`/links/${link.id}?format=${ArchivedFormat.monolith}`
)
}
>
<ContextMenu.ItemTitle>Webpage</ContextMenu.ItemTitle>
</ContextMenu.Item>
)}
{formatAvailable(link, "image") && (
<ContextMenu.Item
key="preserved-formats-screenshot"
onSelect={() =>
router.push(
`/links/${link.id}?format=${
link.image?.endsWith(".png")
? ArchivedFormat.png
: ArchivedFormat.jpeg
}`
)
}
>
<ContextMenu.ItemTitle>Screenshot</ContextMenu.ItemTitle>
</ContextMenu.Item>
)}
{formatAvailable(link, "pdf") && (
<ContextMenu.Item
key="preserved-formats-pdf"
onSelect={() =>
router.push(
`/links/${link.id}?format=${ArchivedFormat.pdf}`
)
}
>
<ContextMenu.ItemTitle>PDF</ContextMenu.ItemTitle>
</ContextMenu.Item>
)}
{formatAvailable(link, "readable") && (
<ContextMenu.Item
key="preserved-formats-readable"
onSelect={() =>
router.push(
`/links/${link.id}?format=${ArchivedFormat.readability}`
)
}
>
<ContextMenu.ItemTitle>Readable</ContextMenu.ItemTitle>
</ContextMenu.Item>
)}
</ContextMenu.SubContent>
</ContextMenu.Sub>
)}
<ContextMenu.Item
key="delete-link"
onSelect={() => {
return Alert.alert(
"Delete Link",
"Are you sure you want to delete this link? This action cannot be undone.",
[
{
text: "Cancel",
style: "cancel",
},
{
text: "Delete",
style: "destructive",
onPress: () => {
deleteLink.mutate(link.id as number);
},
},
]
);
}}
>
<ContextMenu.ItemTitle>Delete</ContextMenu.ItemTitle>
</ContextMenu.Item>
</ContextMenu.Content>
</ContextMenu.Root>
);
};
export default LinkListing;

View File

@@ -0,0 +1,17 @@
import { PropsWithChildren } from "react";
import { IconSymbol } from "../ui/IconSymbol";
import ModalBase from "../ModalBase";
import { Text } from "react-native";
type Props = PropsWithChildren<{
isVisible: boolean;
onClose: () => void;
}>;
export default function AddLink({ isVisible, onClose }: Props) {
return (
// <ModalBase isVisible={isVisible} onClose={onClose}>
<Text>Hi</Text>
// </ModalBase>
);
}

View File

@@ -0,0 +1,84 @@
import React, { forwardRef } from "react";
import {
TouchableOpacity,
Text,
View,
ActivityIndicator,
type TouchableOpacityProps,
} from "react-native";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@linkwarden/lib/utils";
const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 rounded-lg disabled:opacity-50 disabled:pointer-events-none",
{
variants: {
variant: {
default: "bg-slate-500 text-white",
primary: "bg-primary text-base-content",
accent: "bg-accent border border-violet-400 text-white",
destructive: "bg-destructive text-white",
outline: "border border-base-content",
secondary: "bg-secondary text-secondary-foreground",
input:
"bg-base-100 rounded-lg px-4 justify-between flex-row font-normal",
metal: "bg-neutral-content text-base-content border border-neutral/30",
ghost: "",
simple: "bg-base-200",
link: "text-primary underline-offset-",
},
size: {
default: "h-10 px-4 py-2",
sm: "h-8 px-2 py-1 text-xs",
lg: "h-12 px-8",
full: "w-full px-4 py-2",
icon: "h-8 w-8",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
);
export type ButtonProps = TouchableOpacityProps &
VariantProps<typeof buttonVariants> & {
isLoading?: boolean;
children: React.ReactNode;
className?: string;
};
export const Button = forwardRef<
React.ElementRef<typeof TouchableOpacity>,
ButtonProps
>(
(
{
variant = "default",
size,
className,
isLoading = false,
children,
disabled,
...props
},
ref
) => {
const combinedClasses = cn(buttonVariants({ variant, size }), className);
return (
<TouchableOpacity
ref={ref}
className={combinedClasses}
activeOpacity={0.8}
disabled={disabled || isLoading}
{...props}
>
{isLoading ? <ActivityIndicator /> : children}
</TouchableOpacity>
);
}
);
Button.displayName = "Button";

View File

@@ -0,0 +1,32 @@
import { SymbolView, SymbolViewProps, SymbolWeight } from "expo-symbols";
import { StyleProp, ViewStyle } from "react-native";
export function IconSymbol({
name,
size = 24,
color,
style,
weight = "regular",
}: {
name: SymbolViewProps["name"];
size?: number;
color: string;
style?: StyleProp<ViewStyle>;
weight?: SymbolWeight;
}) {
return (
<SymbolView
weight={weight}
tintColor={color}
resizeMode="scaleAspectFit"
name={name}
style={[
{
width: size,
height: size,
},
style,
]}
/>
);
}

View File

@@ -0,0 +1,50 @@
// This file is a fallback for using MaterialIcons on Android and web.
import MaterialIcons from "@expo/vector-icons/MaterialIcons";
import { SymbolWeight } from "expo-symbols";
import React from "react";
import { OpaqueColorValue, StyleProp, TextStyle } from "react-native";
// Add your SFSymbol to MaterialIcons mappings here.
const MAPPING = {
// See MaterialIcons here: https://icons.expo.fyi
// See SF Symbols in the SF Symbols app on Mac.
"house.fill": "home",
"paperplane.fill": "send",
"chevron.left.forwardslash.chevron.right": "code",
"chevron.right": "chevron-right",
} as Partial<
Record<
import("expo-symbols").SymbolViewProps["name"],
React.ComponentProps<typeof MaterialIcons>["name"]
>
>;
export type IconSymbolName = keyof typeof MAPPING;
/**
* An icon component that uses native SFSymbols on iOS, and MaterialIcons on Android and web. This ensures a consistent look across platforms, and optimal resource usage.
*
* Icon `name`s are based on SFSymbols and require manual mapping to MaterialIcons.
*/
export function IconSymbol({
name,
size = 24,
color,
style,
}: {
name: IconSymbolName;
size?: number;
color: string | OpaqueColorValue;
style?: StyleProp<TextStyle>;
weight?: SymbolWeight;
}) {
return (
<MaterialIcons
color={color}
size={size}
name={MAPPING[name]}
style={style}
/>
);
}

View File

@@ -0,0 +1,20 @@
import React, { forwardRef } from "react";
import { TextInput, TextInputProps } from "react-native";
import { cn } from "@linkwarden/lib/utils";
const Input = forwardRef<TextInput, TextInputProps>(
({ className, ...props }, ref) => {
return (
<TextInput
ref={ref}
className={cn(
"bg-base-200 text-base-content rounded-lg px-4 py-2",
className
)}
{...props}
/>
);
}
);
export default Input;

View File

@@ -0,0 +1,22 @@
import { useBottomTabBarHeight } from "@react-navigation/bottom-tabs";
import { BlurView } from "expo-blur";
import { StyleSheet } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
export default function BlurTabBarBackground() {
return (
<BlurView
// System chrome material automatically adapts to the system's theme
// and matches the native tab bar appearance on iOS.
tint="systemChromeMaterial"
intensity={100}
style={StyleSheet.absoluteFill}
/>
);
}
export function useBottomTabOverflow() {
const tabHeight = useBottomTabBarHeight();
const { bottom } = useSafeAreaInsets();
return tabHeight - bottom;
}

View File

@@ -0,0 +1,6 @@
// This is a shim for web and Android where the tab bar is generally opaque.
export default undefined;
export function useBottomTabOverflow() {
return 0;
}

33
apps/mobile/lib/colors.ts Normal file
View File

@@ -0,0 +1,33 @@
// lib/theme/colors.ts
export type ThemeName = "light" | "dark";
export const rawTheme = {
light: {
primary: "#0369A1",
secondary: "#0891B2",
accent: "#6D28D9",
neutral: "#6B7280",
"neutral-content": "#D1D5DB",
"base-100": "#FFFFFF",
"base-200": "#F3F4F6",
"base-content": "#0A0A0A",
info: "#A5F3FC",
success: "#22C55E",
warning: "#FACC15",
error: "#DC2626",
},
dark: {
primary: "#7DD3FC",
secondary: "#22D3EE",
accent: "#6D28D9",
neutral: "#9CA3AF",
"neutral-content": "#404040",
"base-100": "#171717",
"base-200": "#262626",
"base-content": "#FAFAFA",
info: "#009EE4",
success: "#00B17D",
warning: "#EAC700",
error: "#F1293C",
},
};

View File

@@ -0,0 +1,31 @@
import { MMKV } from "react-native-mmkv";
import { Persister } from "@tanstack/react-query-persist-client";
const storage = new MMKV({ id: "react-query" });
export const mmkvPersister: Persister = {
persistClient: async (client) => {
try {
const json = JSON.stringify(client);
storage.set("REACT_QUERY_CACHE", json);
} catch (e) {
console.error("Error persisting client:", e);
}
},
restoreClient: async () => {
try {
const json = storage.getString("REACT_QUERY_CACHE");
return json ? JSON.parse(json) : undefined;
} catch (e) {
console.error("Error restoring client:", e);
return undefined;
}
},
removeClient: async () => {
try {
storage.delete("REACT_QUERY_CACHE");
} catch (e) {
console.error("Error removing client:", e);
}
},
};

23
apps/mobile/lib/theme.ts Normal file
View File

@@ -0,0 +1,23 @@
import { vars } from "nativewind";
import { rawTheme, ThemeName } from "./colors";
const hexToRgb = (hex: string) => {
const [r, g, b] = hex
.replace(/^#/, "")
.match(/.{2}/g)!
.map((h) => parseInt(h, 16));
return `${r} ${g} ${b}`;
};
const makeVars = (scheme: ThemeName) =>
vars(
Object.fromEntries(
Object.entries(rawTheme[scheme]).map(([key, hex]) => [
`--color-${key}`,
hexToRgb(hex),
])
) as Record<string, string>
);
export const lightTheme = makeVars("light");
export const darkTheme = makeVars("dark");

View File

@@ -0,0 +1,6 @@
const { getDefaultConfig } = require("expo/metro-config");
const { withNativeWind } = require("nativewind/metro");
const config = getDefaultConfig(__dirname);
module.exports = withNativeWind(config, { input: "./styles/global.css" });

3
apps/mobile/nativewind-env.d.ts vendored Normal file
View File

@@ -0,0 +1,3 @@
/// <reference types="nativewind/types" />
// NOTE: This file should not be edited and should be committed with your source code. It is generated by NativeWind.

80
apps/mobile/package.json Normal file
View File

@@ -0,0 +1,80 @@
{
"name": "@linkwarden/mobile",
"main": "expo-router/entry",
"version": "1.0.0",
"scripts": {
"start": "expo start",
"android": "expo run:android",
"ios": "expo run:ios",
"web": "expo start --web",
"test": "jest --watchAll",
"lint": "expo lint"
},
"jest": {
"preset": "jest-expo"
},
"dependencies": {
"@expo/vector-icons": "^14.0.2",
"@linkwarden/lib": "*",
"@linkwarden/react-native-render-html": "^6.3.4",
"@linkwarden/router": "*",
"@linkwarden/types": "*",
"@react-native-async-storage/async-storage": "^2.2.0",
"@react-native-community/netinfo": "11.4.1",
"@react-native-menu/menu": "1.2.2",
"@react-navigation/bottom-tabs": "^7.0.0",
"@react-navigation/native": "^7.0.0",
"@tanstack/react-query": "^5.51.15",
"@tanstack/react-query-persist-client": "^5.51.15",
"class-variance-authority": "^0.7.1",
"expo": "~52.0.18",
"expo-application": "~6.0.2",
"expo-blur": "~14.0.1",
"expo-clipboard": "~7.0.1",
"expo-constants": "~17.0.3",
"expo-dev-client": "~5.0.6",
"expo-file-system": "~18.0.12",
"expo-font": "~13.0.1",
"expo-haptics": "~14.0.0",
"expo-linking": "~7.0.5",
"expo-router": "4.0.20",
"expo-secure-store": "~14.0.0",
"expo-share-intent": "^3.2.3",
"expo-splash-screen": "~0.29.18",
"expo-status-bar": "~2.0.0",
"expo-symbols": "~0.2.0",
"expo-system-ui": "~4.0.6",
"expo-web-browser": "~14.0.1",
"html-entities": "^2.6.0",
"lucide-react-native": "^0.536.0",
"nativewind": "^4.1.23",
"react": "18.3.1",
"react-dom": "18.3.1",
"react-native": "0.76.9",
"react-native-actions-sheet": "^0.9.7",
"react-native-gesture-handler": "~2.20.2",
"react-native-ios-context-menu": "3.1.0",
"react-native-ios-utilities": "5.1.2",
"react-native-mmkv": "^3.2.0",
"react-native-reanimated": "3.16.2",
"react-native-safe-area-context": "4.12.0",
"react-native-screens": "~4.1.0",
"react-native-svg": "^15.12.1",
"react-native-web": "~0.19.13",
"react-native-webview": "13.12.5",
"tailwindcss": "^3.4.17",
"zeego": "^3.0.6",
"zustand": "^5.0.2"
},
"devDependencies": {
"@babel/core": "^7.25.2",
"@types/jest": "^29.5.12",
"@types/react": "~18.3.12",
"@types/react-test-renderer": "^18.3.0",
"jest": "^29.2.1",
"jest-expo": "~52.0.2",
"react-test-renderer": "18.3.1",
"typescript": "^5.8.3"
},
"private": true
}

View File

@@ -0,0 +1,43 @@
const { withAndroidStyles } = require("@expo/config-plugins");
function mutateStylesXml(xml) {
const styles = xml.resources?.style ?? [];
let appTheme = styles.find((s) => s.$?.name === "AppTheme");
if (!appTheme) {
appTheme = {
$: { name: "AppTheme", parent: "Theme.AppCompat.DayNight.NoActionBar" },
item: [],
};
styles.push(appTheme);
}
appTheme.$ = appTheme.$ || {};
appTheme.$.parent = "Theme.AppCompat.DayNight.NoActionBar";
appTheme.item = appTheme.item ?? [];
appTheme.item = appTheme.item.filter(
(i) => i?.$?.name !== "android:textColor"
);
const navItem = appTheme.item.find(
(i) => i.$?.name === "android:navigationBarColor"
);
if (navItem) {
navItem._ = "@android:color/transparent";
} else {
appTheme.item.push({
$: { name: "android:navigationBarColor" },
_: "@android:color/transparent",
});
}
xml.resources.style = styles;
return xml;
}
module.exports = function withDayNightTransparentNav(config) {
return withAndroidStyles(config, (c) => {
c.modResults = mutateStylesXml(c.modResults);
return c;
});
};

97
apps/mobile/store/auth.ts Normal file
View File

@@ -0,0 +1,97 @@
import { create } from "zustand";
import * as SecureStore from "expo-secure-store";
import { router } from "expo-router";
import { MobileAuth } from "@linkwarden/types";
type AuthStore = {
auth: MobileAuth;
signIn: (username: string, password: string, instance?: string) => void;
signOut: () => void;
setAuth: () => void;
};
const useAuthStore = create<AuthStore>((set) => ({
auth: {
instance: "",
session: null,
status: "loading",
},
setAuth: async () => {
const session = await SecureStore.getItemAsync("TOKEN");
const instance = await SecureStore.getItemAsync("INSTANCE");
if (session) {
set({
auth: {
instance,
session,
status: "authenticated",
},
});
} else {
set({
auth: {
instance: "",
session: null,
status: "unauthenticated",
},
});
}
},
signIn: async (
username,
password,
instance = process.env.NODE_ENV === "production"
? "https://cloud.linkwarden.app"
: (process.env.EXPO_PUBLIC_LINKWARDEN_URL as string)
) => {
if (process.env.EXPO_PUBLIC_SHOW_LOGS === "true")
console.log("Signing into", instance);
await fetch(instance + "/api/v1/session", {
method: "POST",
body: JSON.stringify({ username, password }),
headers: {
"Content-Type": "application/json",
},
}).then(async (res) => {
if (res.ok) {
const data = await res.json();
const session = (data as any).response.token;
await SecureStore.setItemAsync("TOKEN", session);
await SecureStore.setItemAsync("INSTANCE", instance);
set({
auth: {
session,
instance,
status: "authenticated",
},
});
router.replace("/(tabs)/dashboard");
} else {
set({
auth: {
instance,
session: null,
status: "unauthenticated",
},
});
}
});
},
signOut: async () => {
await SecureStore.deleteItemAsync("TOKEN");
set({
auth: {
instance: "",
session: null,
status: "unauthenticated",
},
});
router.replace("/login");
},
}));
export default useAuthStore;

37
apps/mobile/store/data.ts Normal file
View File

@@ -0,0 +1,37 @@
import { create } from "zustand";
import { MobileData } from "@linkwarden/types";
import AsyncStorage from "@react-native-async-storage/async-storage";
import { colorScheme } from "nativewind";
type DataStore = {
data: MobileData;
updateData: (newData: Partial<MobileData>) => void;
setData: () => void;
};
const useDataStore = create<DataStore>((set, get) => ({
data: {
shareIntent: {
hasShareIntent: false,
url: "",
},
theme: "light",
preferredFormat: null,
},
setData: async () => {
const dataString = JSON.parse((await AsyncStorage.getItem("data")) || "{}");
colorScheme.set(dataString.theme || "light");
if (dataString)
set((state) => ({ data: { ...state.data, ...dataString } }));
},
updateData: async (patch) => {
const merged = { ...get().data, ...patch };
const { shareIntent, ...persistable } = merged;
await AsyncStorage.setItem("data", JSON.stringify(persistable));
set({ data: merged });
},
}));
export default useDataStore;

View File

@@ -0,0 +1,3 @@
@tailwind base;
@tailwind components;
@tailwind utilities;

View File

@@ -0,0 +1,39 @@
/** @type {import('tailwindcss').Config} */
const { rawTheme } = require("./lib/colors");
const hexToRgb = (hex) => {
const [r, g, b] = hex
.replace(/^#/, "")
.match(/.{2}/g)
.map((h) => parseInt(h, 16));
return `${r} ${g} ${b}`;
};
module.exports = {
content: ["./app/**/*.{js,jsx,ts,tsx}", "./components/**/*.{js,jsx,ts,tsx}"],
presets: [require("nativewind/preset")],
darkMode: "media",
theme: {
extend: {
colors: Object.fromEntries(
Object.keys(rawTheme.light).map((key) => [
key,
`rgb(var(--color-${key}) / <alpha-value>)`,
])
),
},
},
plugins: [
({ addBase }) => {
addBase({
":root": Object.fromEntries(
Object.entries(rawTheme.light).map(([key, hex]) => [
`--color-${key}`,
hexToRgb(hex),
])
),
});
},
],
};

18
apps/mobile/tsconfig.json Normal file
View File

@@ -0,0 +1,18 @@
{
"extends": "expo/tsconfig.base",
"compilerOptions": {
"strict": true,
"paths": {
"@/*": [
"./*"
]
}
},
"include": [
"**/*.ts",
"**/*.tsx",
".expo/types/**/*.ts",
"expo-env.d.ts",
"nativewind-env.d.ts"
]
}

View File

@@ -0,0 +1,43 @@
export enum Sort {
DateNewestFirst,
DateOldestFirst,
NameAZ,
NameZA,
DescriptionAZ,
DescriptionZA,
}
export type LinkRequestQuery = {
sort?: Sort;
cursor?: number;
collectionId?: number;
tagId?: number;
pinnedOnly?: boolean;
searchQueryString?: string;
searchByName?: boolean;
searchByUrl?: boolean;
searchByDescription?: boolean;
searchByTextContent?: boolean;
searchByTags?: boolean;
};
export type LinkIncludingShortenedCollectionAndTags = {
id: number;
name: string;
url: string;
description: string;
type: "url" | "image" | "pdf";
preview: string | null;
createdAt: string;
updatedAt: string;
collectionId: number;
tags: { id: number; name: string }[];
};
export enum ArchivedFormat {
png,
jpeg,
pdf,
readability,
monolith,
}

1
apps/mobile/types/nativewind-env.d.ts vendored Normal file
View File

@@ -0,0 +1 @@
/// <reference types="nativewind/types" />

21
apps/web/components.json Normal file
View File

@@ -0,0 +1,21 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "default",
"rsc": false,
"tsx": true,
"tailwind": {
"config": "tailwind.config.js",
"css": "styles/globals.css",
"baseColor": "neutral",
"cssVariables": true,
"prefix": ""
},
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
"ui": "@/components/ui",
"lib": "@/lib",
"hooks": "@/hooks"
},
"iconLibrary": "lucide"
}

View File

@@ -1,6 +1,7 @@
import Link from "next/link";
import React, { MouseEventHandler } from "react";
import { Trans } from "next-i18next";
import { Button } from "@/components/ui/button";
type Props = {
toggleAnnouncementBar: MouseEventHandler<HTMLButtonElement>;
@@ -12,7 +13,7 @@ export default function Announcement({ toggleAnnouncementBar }: Props) {
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>
<i className="bi-stars text-xl 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"
@@ -21,18 +22,15 @@ export default function Announcement({ toggleAnnouncementBar }: Props) {
<Link
href={`https://blog.linkwarden.app/releases/${announcementId}`}
target="_blank"
className="underline"
className="underline decoration-dotted underline-offset-4 hover:text-primary duration-100"
key={0}
/>,
]}
/>
</p>
<button
onClick={toggleAnnouncementBar}
className="btn btn-ghost btn-square btn-sm"
>
<Button variant="ghost" size="icon" onClick={toggleAnnouncementBar}>
<i className="bi-x text-xl"></i>
</button>
</Button>
</div>
</div>
);

View File

@@ -5,20 +5,29 @@ type Props = {
state: boolean;
className?: string;
onClick: ChangeEventHandler<HTMLInputElement>;
disabled?: boolean;
};
export default function Checkbox({ label, state, className, onClick }: Props) {
export default function Checkbox({
label,
state,
className,
onClick,
disabled,
}: Props) {
return (
<label
className={`label cursor-pointer flex gap-2 justify-start ${
className || ""
}`}
aria-disabled={disabled}
>
<input
type="checkbox"
checked={state}
onChange={onClick}
className="checkbox checkbox-primary"
disabled={disabled}
/>
<span className="label-text">{label}</span>
</label>

View File

@@ -31,6 +31,11 @@ function useOutsideAlerter(
useEffect(() => {
function handleClickOutside(event: MouseEvent) {
const clickedElement = event.target as HTMLElement;
if (clickedElement.closest("[data-ignore-click-away]")) {
return;
}
if (ref.current && !ref.current.contains(clickedElement)) {
const refZIndex = getZIndex(ref.current);
const clickedZIndex = getZIndex(clickedElement);

View File

@@ -1,29 +1,36 @@
import Link from "next/link";
import { CollectionIncludingMembersAndLinkCount } from "@/types/global";
import {
AccountSettings,
CollectionIncludingMembersAndLinkCount,
} from "@linkwarden/types";
import React, { useEffect, useState } from "react";
import ProfilePhoto from "./ProfilePhoto";
import usePermissions from "@/hooks/usePermissions";
import useLocalSettingsStore from "@/store/localSettings";
import getPublicUserData from "@/lib/client/getPublicUserData";
import EditCollectionModal from "./ModalContent/EditCollectionModal";
import EditCollectionSharingModal from "./ModalContent/EditCollectionSharingModal";
import DeleteCollectionModal from "./ModalContent/DeleteCollectionModal";
import { dropdownTriggerer } from "@/lib/client/utils";
import { useTranslation } from "next-i18next";
import { useUser } from "@/hooks/store/user";
import { useUser } from "@linkwarden/router/user";
import {
DropdownMenu,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
} from "@/components/ui/dropdown-menu";
import { Button } from "./ui/button";
type Props = {
export default function CollectionCard({
collection,
}: {
collection: CollectionIncludingMembersAndLinkCount;
className?: string;
};
export default function CollectionCard({ collection, className }: Props) {
}) {
const { t } = useTranslation();
const { settings } = useLocalSettingsStore();
const { data: user = {} } = useUser();
const { data: user } = useUser();
const formattedDate = new Date(collection.createdAt as string).toLocaleString(
"en-US",
t("locale"),
{
year: "numeric",
month: "short",
@@ -33,30 +40,24 @@ export default function CollectionCard({ collection, className }: Props) {
const permissions = usePermissions(collection.id as number);
const [collectionOwner, setCollectionOwner] = useState({
id: null as unknown as number,
name: "",
username: "",
image: "",
archiveAsScreenshot: undefined as unknown as boolean,
archiveAsMonolith: undefined as unknown as boolean,
archiveAsPDF: undefined as unknown as boolean,
});
const [collectionOwner, setCollectionOwner] = useState<
Partial<AccountSettings>
>({});
useEffect(() => {
const fetchOwner = async () => {
if (collection && collection.ownerId !== user.id) {
if (collection && collection.ownerId !== user?.id) {
const owner = await getPublicUserData(collection.ownerId as number);
setCollectionOwner(owner);
} else if (collection && collection.ownerId === user.id) {
} else if (collection && collection.ownerId === user?.id) {
setCollectionOwner({
id: user.id as number,
name: user.name,
username: user.username as string,
image: user.image as string,
archiveAsScreenshot: user.archiveAsScreenshot as boolean,
archiveAsMonolith: user.archiveAsMonolith as boolean,
archiveAsPDF: user.archiveAsPDF as boolean,
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,
});
}
};
@@ -71,70 +72,68 @@ export default function CollectionCard({ collection, className }: Props) {
return (
<div className="relative">
<div className="dropdown dropdown-bottom dropdown-end absolute top-3 right-3 z-20">
<div
tabIndex={0}
role="button"
onMouseDown={dropdownTriggerer}
className="btn btn-ghost btn-sm btn-square text-neutral"
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="icon"
className="absolute top-3 right-3 z-20"
>
<i title="More" className="bi-three-dots text-xl" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent
sideOffset={4}
side="bottom"
align="end"
className="z-[30]"
>
<i className="bi-three-dots text-xl" title="More"></i>
</div>
<ul className="dropdown-content z-[30] menu shadow bg-base-200 border border-neutral-content rounded-box w-52 mt-1">
{permissions === true && (
<li>
<div
role="button"
tabIndex={0}
onClick={() => {
(document?.activeElement as HTMLElement)?.blur();
setEditCollectionModal(true);
}}
>
{t("edit_collection_info")}
</div>
</li>
<DropdownMenuItem onSelect={() => setEditCollectionModal(true)}>
<i className="bi-pencil-square" />
{t("edit_collection_info")}
</DropdownMenuItem>
)}
<li>
<div
role="button"
tabIndex={0}
onClick={() => {
(document?.activeElement as HTMLElement)?.blur();
setEditCollectionSharingModal(true);
}}
>
{permissions === true
? t("share_and_collaborate")
: t("view_team")}
</div>
</li>
<li>
<div
role="button"
tabIndex={0}
onClick={() => {
(document?.activeElement as HTMLElement)?.blur();
setDeleteCollectionModal(true);
}}
>
{permissions === true
? t("delete_collection")
: t("leave_collection")}
</div>
</li>
</ul>
</div>
<DropdownMenuItem
onSelect={() => setEditCollectionSharingModal(true)}
>
<i className="bi-globe" />
{permissions === true ? t("share_and_collaborate") : t("view_team")}
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
onSelect={() => setDeleteCollectionModal(true)}
className="text-error"
>
{permissions === true ? (
<>
<i className="bi-trash" />
{t("delete_collection")}
</>
) : (
<>
<i className="bi-box-arrow-left" />
{t("leave_collection")}
</>
)}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<div
className="flex items-center absolute bottom-3 left-3 z-10 btn px-2 btn-ghost rounded-full"
className="flex items-center absolute bottom-3 left-3 z-10 px-1 py-1 rounded-full cursor-pointer hover:bg-base-content/20 transition-colors duration-200"
onClick={() => setEditCollectionSharingModal(true)}
>
{collectionOwner.id ? (
{collectionOwner.id && (
<ProfilePhoto
src={collectionOwner.image || undefined}
name={collectionOwner.name}
/>
) : undefined}
)}
{collection.members
.sort((a, b) => (a.userId as number) - (b.userId as number))
.map((e, i) => {
@@ -148,21 +147,21 @@ export default function CollectionCard({ collection, className }: Props) {
);
})
.slice(0, 3)}
{collection.members.length - 3 > 0 ? (
{collection.members.length - 3 > 0 && (
<div className={`avatar drop-shadow-md placeholder -ml-3`}>
<div className="bg-base-100 text-neutral rounded-full w-8 h-8 ring-2 ring-neutral-content">
<span>+{collection.members.length - 3}</span>
</div>
</div>
) : null}
)}
</div>
<Link
href={`/collections/${collection.id}`}
style={{
backgroundImage: `linear-gradient(45deg, ${collection.color}30 10%, ${
settings.theme === "dark" ? "oklch(var(--b2))" : "oklch(var(--b2))"
user?.theme === "dark" ? "oklch(var(--b2))" : "oklch(var(--b2))"
} 50%, ${
settings.theme === "dark" ? "oklch(var(--b2))" : "oklch(var(--b2))"
user?.theme === "dark" ? "oklch(var(--b2))" : "oklch(var(--b2))"
} 100%)`,
}}
className="card card-compact shadow-md hover:shadow-none duration-200 border border-neutral-content"
@@ -178,15 +177,15 @@ export default function CollectionCard({ collection, className }: Props) {
<div className="flex justify-end items-center">
<div className="text-right">
<div className="font-bold text-sm flex justify-end gap-1 items-center">
{collection.isPublic ? (
{collection.isPublic && (
<i
className="bi-globe2 drop-shadow text-neutral"
title="This collection is being shared publicly."
title={t("collection_publicly_shared")}
></i>
) : undefined}
)}
<i
className="bi-link-45deg text-lg text-neutral"
title="This collection is being shared publicly."
title={t("links")}
></i>
{collection._count && collection._count.links}
</div>
@@ -194,7 +193,7 @@ export default function CollectionCard({ collection, className }: Props) {
<p className="font-bold text-xs flex gap-1 items-center">
<i
className="bi-calendar3 text-neutral"
title="This collection is being shared publicly."
title={t("collection_publicly_shared")}
></i>
{formattedDate}
</p>
@@ -203,24 +202,24 @@ export default function CollectionCard({ collection, className }: Props) {
</div>
</div>
</Link>
{editCollectionModal ? (
{editCollectionModal && (
<EditCollectionModal
onClose={() => setEditCollectionModal(false)}
activeCollection={collection}
/>
) : undefined}
{editCollectionSharingModal ? (
)}
{editCollectionSharingModal && (
<EditCollectionSharingModal
onClose={() => setEditCollectionSharingModal(false)}
activeCollection={collection}
/>
) : undefined}
{deleteCollectionModal ? (
)}
{deleteCollectionModal && (
<DeleteCollectionModal
onClose={() => setDeleteCollectionModal(false)}
activeCollection={collection}
/>
) : undefined}
)}
</div>
);
}

View File

@@ -9,25 +9,34 @@ import Tree, {
TreeSourcePosition,
TreeDestinationPosition,
} from "@atlaskit/tree";
import { Collection } from "@prisma/client";
import { Collection } from "@linkwarden/prisma/client";
import Link from "next/link";
import { CollectionIncludingMembersAndLinkCount } from "@/types/global";
import { CollectionIncludingMembersAndLinkCount } from "@linkwarden/types";
import { useRouter } from "next/router";
import toast from "react-hot-toast";
import { useTranslation } from "next-i18next";
import { useCollections, useUpdateCollection } from "@/hooks/store/collections";
import { useUpdateUser, useUser } from "@/hooks/store/user";
import {
useCollections,
useUpdateCollection,
} from "@linkwarden/router/collections";
import { useUpdateUser, useUser } from "@linkwarden/router/user";
import Icon from "./Icon";
import { IconWeight } from "@phosphor-icons/react";
import Droppable from "./Droppable";
import { cn } from "@linkwarden/lib";
import { Active, useDndContext } from "@dnd-kit/core";
interface ExtendedTreeItem extends TreeItem {
data: Collection;
}
const CollectionListing = () => {
const { active: droppableActive } = useDndContext();
const { t } = useTranslation();
const updateCollection = useUpdateCollection();
const { data: collections = [], isLoading } = useCollections();
const { data: user = {} } = useUser();
const { data: user, refetch } = useUser();
const updateUser = useUpdateUser();
const router = useRouter();
@@ -36,25 +45,23 @@ const CollectionListing = () => {
const [tree, setTree] = useState<TreeData | undefined>();
const initialTree = useMemo(() => {
if (
// !tree &&
collections.length > 0
) {
if (collections.length > 0) {
return buildTreeFromCollections(
collections,
router,
user.collectionOrder
tree,
user?.collectionOrder
);
} else return undefined;
}, [collections, user, router]);
useEffect(() => {
// if (!tree)
setTree(initialTree);
}, [initialTree]);
useEffect(() => {
if (user.username) {
if (user?.username) {
// refetch();
if (
(!user.collectionOrder || user.collectionOrder.length === 0) &&
collections.length > 0
@@ -62,11 +69,7 @@ const CollectionListing = () => {
updateUser.mutate({
...user,
collectionOrder: collections
.filter(
(e) =>
e.parentId === null ||
!collections.find((i) => i.id === e.parentId)
) // Filter out collections with non-null parentId
.filter((e) => e.parentId === null)
.map((e) => e.id as number),
});
else {
@@ -100,7 +103,7 @@ const CollectionListing = () => {
}
}
}
}, [collections]);
}, [user, collections]);
const onExpand = (movedCollectionId: ItemId) => {
setTree((currentTree) =>
@@ -116,6 +119,81 @@ const CollectionListing = () => {
);
};
function reorderTreeItems(
tree: TreeData,
movedCollectionId: ItemId,
source: TreeSourcePosition,
destination: TreeDestinationPosition
) {
// Same parent reordering
if (source.parentId === destination.parentId) {
const parent = tree.items[source.parentId];
const children = [...parent.children];
// Remove from source index
children.splice(source.index, 1);
// Insert at destination index
if (destination.index !== undefined) {
children.splice(destination.index, 0, movedCollectionId);
}
parent.children = children;
return tree;
}
// Different parent move
const sourceParent = tree.items[source.parentId];
const destinationParent = tree.items[destination.parentId];
// Remove from source parent
sourceParent.children = sourceParent.children.filter(
(id) => id !== movedCollectionId
);
// Initialize children array if it doesn't exist
if (!destinationParent.children) {
destinationParent.children = [];
}
// If destination index is not specified, add to the end
const destinationIndex =
destination.index !== undefined
? destination.index
: destinationParent.children.length;
// Add to destination parent
destinationParent.children.splice(destinationIndex, 0, movedCollectionId);
// Update destination parent properties
destinationParent.hasChildren = true;
destinationParent.isExpanded = true;
// Update the moved item's parent ID
tree.items[movedCollectionId].data.parentId = destination.parentId;
return tree;
}
function flattenTreeIds(
tree: TreeData,
nodeId: ItemId = "root",
result: Array<ItemId> = []
) {
const node = tree.items[nodeId];
if (nodeId !== "root") {
result.push(node.id);
}
if (node.children && node.children.length > 0) {
node.children.forEach((childId) => {
flattenTreeIds(tree, childId, result);
});
}
return result;
}
const onDragEnd = async (
source: TreeSourcePosition,
destination: TreeDestinationPosition | undefined
@@ -142,9 +220,9 @@ const CollectionListing = () => {
);
if (
(movedCollection?.ownerId !== user.id &&
(movedCollection?.ownerId !== user?.id &&
destination.parentId !== source.parentId) ||
(destinationCollection?.ownerId !== user.id &&
(destinationCollection?.ownerId !== user?.id &&
destination.parentId !== "root")
) {
return toast.error(t("cant_change_collection_you_dont_own"));
@@ -152,7 +230,12 @@ const CollectionListing = () => {
setTree((currentTree) => moveItemOnTree(currentTree!, source, destination));
const updatedCollectionOrder = [...user.collectionOrder];
const newTree = reorderTreeItems(
tree,
movedCollectionId,
source,
destination
);
if (source.parentId !== destination.parentId) {
await updateCollection.mutateAsync(
@@ -173,42 +256,10 @@ const CollectionListing = () => {
);
}
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,
});
}
await updateUser.mutateAsync({
...user,
collectionOrder: flattenTreeIds(newTree),
});
};
if (isLoading) {
@@ -229,7 +280,9 @@ const CollectionListing = () => {
return (
<Tree
tree={tree}
renderItem={(itemProps) => renderItem({ ...itemProps }, currentPath)}
renderItem={(itemProps) =>
renderItem({ ...itemProps }, currentPath, droppableActive)
}
onExpand={onExpand}
onCollapse={onCollapse}
onDragEnd={onDragEnd}
@@ -243,52 +296,81 @@ export default CollectionListing;
const renderItem = (
{ item, onExpand, onCollapse, provided }: RenderItemParams,
currentPath: string
currentPath: string,
droppableActive: Active | null
) => {
const collection = item.data;
return (
<div ref={provided.innerRef} {...provided.draggableProps} className="mb-1">
<Droppable
id={`side-bar-collection-${collection.id}`}
data={{
name: collection.name,
id: collection.id,
ownerId: collection.ownerId,
}}
className="group"
>
<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`}
ref={provided.innerRef}
{...provided.draggableProps}
className="mb-1"
>
{Icon(item as ExtendedTreeItem, onExpand, onCollapse)}
<Link
href={`/collections/${collection.id}`}
className="w-full"
{...provided.dragHandleProps}
<div
className={cn(
currentPath === `/collections/${collection.id}`
? "bg-primary/20 is-active"
: droppableActive
? "select-none"
: "hover:bg-neutral/20",
"duration-100 flex gap-1 items-center pr-2 pl-1 rounded-md"
)}
>
<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>
{Dropdown(item as ExtendedTreeItem, onExpand, onCollapse)}
{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}
<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`}
>
{collection.icon ? (
<Icon
icon={collection.icon}
size={30}
weight={(collection.iconWeight || "regular") as IconWeight}
color={collection.color}
className="-mr-[0.15rem]"
/>
) : (
<i
className="bi-folder-fill text-xl"
style={{ color: collection.color }}
></i>
)}
<p className="truncate w-full">{collection.name}</p>
{collection.isPublic && (
<i
className="bi-globe2 text-sm text-black/50 dark:text-white/50 drop-shadow"
title="This collection is being shared publicly."
></i>
)}
<div className="drop-shadow text-neutral text-xs">
{collection._count?.links}
</div>
</div>
</div>
</Link>
</Link>
</div>
</div>
</div>
</Droppable>
);
};
const Icon = (
const Dropdown = (
item: ExtendedTreeItem,
onExpand: (id: ItemId) => void,
onCollapse: (id: ItemId) => void
@@ -311,6 +393,7 @@ const Icon = (
const buildTreeFromCollections = (
collections: CollectionIncludingMembersAndLinkCount[],
router: ReturnType<typeof useRouter>,
tree?: TreeData,
order?: number[]
): TreeData => {
if (order) {
@@ -319,19 +402,38 @@ const buildTreeFromCollections = (
});
}
function getTotalLinkCount(collectionId: number): number {
const collection = items[collectionId];
if (!collection) {
return 0;
}
let totalLinkCount = (collection.data as any)._count?.links || 0;
if (collection.hasChildren) {
collection.children.forEach((childId) => {
totalLinkCount += getTotalLinkCount(childId as number);
});
}
return totalLinkCount;
}
const items: { [key: string]: ExtendedTreeItem } = collections.reduce(
(acc: any, collection) => {
acc[collection.id as number] = {
id: collection.id,
children: [],
hasChildren: false,
isExpanded: false,
isExpanded: tree?.items[collection.id as number]?.isExpanded || false,
data: {
id: collection.id,
parentId: collection.parentId,
name: collection.name,
description: collection.description,
color: collection.color,
icon: collection.icon,
iconWeight: collection.iconWeight,
isPublic: collection.isPublic,
ownerId: collection.ownerId,
createdAt: collection.createdAt,
@@ -370,6 +472,14 @@ const buildTreeFromCollections = (
}
});
collections.forEach((collection) => {
const collectionId = collection.id;
if (items[collectionId as number] && collection.id) {
const linkCount = getTotalLinkCount(collectionId as number);
(items[collectionId as number].data as any)._count.links = linkCount;
}
});
const rootId = "root";
items[rootId] = {
id: rootId,

View File

@@ -0,0 +1,50 @@
import React, { ReactNode } from "react";
import { Button } from "@/components/ui/button";
import { useTranslation } from "next-i18next";
import Modal from "./Modal";
import { Separator } from "./ui/separator";
type Props = {
toggleModal: Function;
className?: string;
children: ReactNode;
title: string;
onConfirmed: Function;
dismissible?: boolean;
};
export default function ConfirmationModal({
toggleModal,
className,
children,
title,
onConfirmed,
}: Props) {
const { t } = useTranslation();
return (
<Modal toggleModal={toggleModal} className={className}>
<p className="text-xl font-thin">{title}</p>
<Separator className="mb-3 mt-1" />
{children}
<div className="w-full flex items-center justify-end gap-2 mt-3">
<Button
variant="ghost"
className="hover:bg-base-200"
onClick={() => toggleModal()}
>
{t("cancel")}
</Button>
<Button
variant="destructive"
onClick={async () => {
await onConfirmed();
toggleModal();
}}
>
{t("confirm")}
</Button>
</div>
</Modal>
);
}

View File

@@ -0,0 +1,60 @@
import React, { useState } from "react";
import { Button } from "./ui/button";
type Props = {
text: string;
};
const CopyButton: React.FC<Props> = ({ text }) => {
const [copied, setCopied] = useState(false);
const handleCopy = async () => {
try {
await navigator.clipboard.writeText(text);
setCopied(true);
setTimeout(() => {
setCopied(false);
}, 1000);
} catch (err) {
console.log(err);
}
};
return (
<Button variant="ghost" type="button" size="icon" onClick={handleCopy}>
{copied ? (
<svg
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
fill="currentColor"
className="h-5 w-5 text-success"
viewBox="0 0 16 16"
>
<path d="M13.854 3.646a.5.5 0 0 1 0 .708l-7 7a.5.5 0 0 1-.708 0l-3.5-3.5a.5.5 0 1 1 .708-.708L6.5 10.293l6.646-6.647a.5.5 0 0 1 .708 0" />
</svg>
) : (
<svg
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
fill="currentColor"
className="h-5 w-5"
viewBox="0 0 16 16"
>
<path
fillRule="evenodd"
d="M4 2a2 2 0 0 1 2-2h8a2 2 0 0 1 2 2v8a2
2 0 0 1-2 2H6a2 2 0 0 1-2-2zm2-1a1 1 0 0 0-1
1v8a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1V2a1 1 0
0 0-1-1zM2 5a1 1 0 0 0-1 1v8a1 1 0 0
0 1 1h8a1 1 0 0 0 1-1v-1h1v1a2 2 0 0 1-2
2H2a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h1v1z"
/>
</svg>
)}
</Button>
);
};
export default CopyButton;

View File

@@ -0,0 +1,23 @@
export default function dashboardItem({
name,
value,
icon,
}: {
name: string;
value: number;
icon: string;
}) {
return (
<div className="flex items-center justify-between w-full rounded-xl border border-neutral-content p-3 bg-gradient-to-tr from-neutral-content/70 to-50% to-base-200">
<div className="w-14 aspect-square flex justify-center items-center bg-primary/20 rounded-xl select-none">
<i className={`${icon} text-primary text-3xl drop-shadow`}></i>
</div>
<div className="ml-4 flex flex-col justify-center">
<p className="text-neutral text-xs tracking-wider text-right">{name}</p>
<p className="font-thin text-4xl text-primary mt-0.5 text-right">
{value || 0}
</p>
</div>
</div>
);
}

View File

@@ -0,0 +1,398 @@
import React, { useState, useMemo, useEffect } from "react";
import {
DropdownMenu,
DropdownMenuTrigger,
DropdownMenuContent,
} from "@/components/ui/dropdown-menu";
import { useTranslation } from "next-i18next";
import { Button } from "@/components/ui/button";
import TextInput from "./TextInput";
import { useCollections } from "@linkwarden/router/collections";
import {
DashboardSection,
DashboardSectionType,
} from "@linkwarden/prisma/client";
import { useUser } from "@linkwarden/router/user";
import { useUpdateDashboardLayout } from "@linkwarden/router/dashboardData";
import {
SortableContext,
useSortable,
verticalListSortingStrategy,
} from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
import {
DndContext,
DragEndEvent,
MouseSensor,
TouchSensor,
useSensor,
useSensors,
} from "@dnd-kit/core";
import { restrictToParentElement } from "@dnd-kit/modifiers";
import { cn } from "@linkwarden/lib";
interface DashboardSectionOption {
type: DashboardSectionType;
name: string;
collectionId?: number;
enabled: boolean;
order?: number;
}
export default function DashboardLayoutDropdown() {
const { t } = useTranslation();
const { data: user } = useUser();
const { data: collections = [] } = useCollections();
const updateDashboardLayout = useUpdateDashboardLayout();
const [searchTerm, setSearchTerm] = useState("");
const mouseSensor = useSensor(MouseSensor, {
// Require the mouse to move by 10 pixels before activating
activationConstraint: {
distance: 10,
},
});
const touchSensor = useSensor(TouchSensor, {
// Press delay of 200ms, with tolerance of 5px of movement
activationConstraint: {
delay: 200,
tolerance: 5,
},
});
const sensors = useSensors(mouseSensor, touchSensor);
const [dashboardSections, setDashboardSections] = useState<
DashboardSection[]
>(user?.dashboardSections || []);
useEffect(() => {
setDashboardSections(user?.dashboardSections || []);
}, [user?.dashboardSections]);
const getSectionOrder = (
type: DashboardSectionType,
collectionId?: number
): number | undefined => {
const section = dashboardSections.find(
(section) =>
section.type === type &&
(type === DashboardSectionType.COLLECTION
? section.collectionId === collectionId
: true)
);
return section?.order;
};
const isSectionEnabled = (
type: DashboardSectionType,
collectionId?: number
): boolean => {
return dashboardSections.some(
(section) =>
section.type === type &&
(type === DashboardSectionType.COLLECTION
? section.collectionId === collectionId
: true)
);
};
const defaultSections: DashboardSectionOption[] = useMemo(
() => [
{
type: DashboardSectionType.STATS,
name: t("dashboard_stats"),
enabled: isSectionEnabled(DashboardSectionType.STATS),
order: getSectionOrder(DashboardSectionType.STATS),
},
{
type: DashboardSectionType.RECENT_LINKS,
name: t("recent_links"),
enabled: isSectionEnabled(DashboardSectionType.RECENT_LINKS),
order: getSectionOrder(DashboardSectionType.RECENT_LINKS),
},
{
type: DashboardSectionType.PINNED_LINKS,
name: t("pinned_links"),
enabled: isSectionEnabled(DashboardSectionType.PINNED_LINKS),
order: getSectionOrder(DashboardSectionType.PINNED_LINKS),
},
],
[dashboardSections]
);
const collectionSections = useMemo(
() =>
collections.map((collection) => ({
type: DashboardSectionType.COLLECTION,
name: collection.name,
collectionId: collection.id,
enabled: isSectionEnabled(
DashboardSectionType.COLLECTION,
collection.id
),
order: getSectionOrder(DashboardSectionType.COLLECTION, collection.id),
})),
[collections, dashboardSections]
);
const allSections = useMemo(
() => [...defaultSections, ...collectionSections],
[collectionSections, defaultSections]
);
const filteredSections = useMemo(() => {
let sections = allSections;
if (searchTerm.trim()) {
sections = sections.filter((section) =>
section.name.toLowerCase().includes(searchTerm.toLowerCase())
);
}
const enabledSections = sections
.filter((section) => section.enabled)
.sort((a, b) => {
if (a.order !== undefined && b.order !== undefined) {
return a.order - b.order;
}
if (a.order !== undefined) return -1;
if (b.order !== undefined) return 1;
return 0;
});
const disabledSections = sections.filter((section) => !section.enabled);
return [...enabledSections, ...disabledSections];
}, [allSections, searchTerm]);
const getSectionId = (section: DashboardSectionOption) =>
`${section.type}-${section.collectionId ?? "default"}`;
const handleCheckboxChange = (section: DashboardSectionOption) => {
const enabledSections = allSections.filter((s) => s.enabled);
const highestOrder =
enabledSections.length > 0
? Math.max(...enabledSections.map((s) => s.order ?? 0))
: -1;
const updatedSections = allSections.map((s) => {
if (s.type === section.type && s.collectionId === section.collectionId) {
return {
...s,
enabled: !s.enabled,
order: !s.enabled ? highestOrder + 1 : undefined,
};
}
return s;
});
updateDashboardLayout.mutateAsync(updatedSections);
};
const handleReorder = (sourceId: string, destId: string) => {
if (sourceId === destId) return;
// Get only enabled sections for reordering
const enabledSections = filteredSections.filter((s) => s.enabled);
const sourceIndex = enabledSections.findIndex(
(s) => getSectionId(s) === sourceId
);
const destIndex = enabledSections.findIndex(
(s) => getSectionId(s) === destId
);
if (sourceIndex < 0 || destIndex < 0) return;
// Reorder only the enabled sections
const reorderedEnabled = [...enabledSections];
const [moved] = reorderedEnabled.splice(sourceIndex, 1);
reorderedEnabled.splice(destIndex, 0, moved);
// Assign new order values based on the reordered enabled sections
const reorderedWithNewOrders = reorderedEnabled.map((section, idx) => ({
...section,
order: idx,
}));
// Get disabled sections and combine with reordered enabled sections
const disabledSections = filteredSections.filter((s) => !s.enabled);
const updated = [...reorderedWithNewOrders, ...disabledSections];
updateDashboardLayout.mutateAsync(updated);
};
const handleDragEnd = (event: DragEndEvent) => {
const { active, over } = event;
if (!over || active.id === over.id) {
return;
}
const sourceId = active.id as string;
const destId = over.id as string;
// Only allow reordering enabled sections
const sourceSection = filteredSections.find(
(s) => getSectionId(s) === sourceId
);
const destSection = filteredSections.find(
(s) => getSectionId(s) === destId
);
if (sourceSection?.enabled && destSection?.enabled) {
handleReorder(sourceId, destId);
}
};
// Only include enabled sections in the sortable context
const sortableItems = filteredSections
.filter((section) => section.enabled)
.map(getSectionId);
return (
<DropdownMenu modal>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="sm" className="h-8">
<i className="bi-sliders2-vertical text-neutral" />
{t("edit_layout")}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent
className="min-w-72 pt-1 px-0 pb-0 select-none"
align="end"
>
<div className="flex flex-col gap-1">
<div className="flex flex-col gap-1 mx-2">
<p className="text-sm text-neutral mb-1">
{t("display_on_dashboard")}
</p>
<TextInput
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="py-0"
placeholder={t("search")}
/>
</div>
<DndContext
modifiers={[restrictToParentElement]}
onDragEnd={handleDragEnd}
sensors={sensors}
>
<SortableContext
items={sortableItems}
strategy={verticalListSortingStrategy}
>
<ul className="max-h-60 overflow-y-auto px-2 pb-2">
{filteredSections.map((section) => {
const color =
section.type === "COLLECTION"
? collections.find((c) => c.id === section.collectionId)
?.color
: undefined;
return (
<DraggableListItem
key={getSectionId(section)}
section={{ ...section, color }}
onCheckboxChange={handleCheckboxChange}
/>
);
})}
{filteredSections.length === 0 && (
<li className="text-sm py-2 text-center text-neutral">
{t("no_results_found")}
</li>
)}
</ul>
</SortableContext>
</DndContext>
</div>
</DropdownMenuContent>
</DropdownMenu>
);
}
interface DraggableListItemProps {
section: DashboardSectionOption & { color?: string };
onCheckboxChange: (section: DashboardSectionOption) => void;
}
function DraggableListItem({
section,
onCheckboxChange,
}: DraggableListItemProps) {
const sectionId = `${section.type}-${section.collectionId ?? "default"}`;
const {
attributes,
listeners,
setNodeRef,
transform,
transition,
isDragging,
} = useSortable({
id: sectionId,
disabled: !section.enabled,
});
const style = {
transform: CSS.Transform.toString(transform),
transition,
};
return (
<li
ref={setNodeRef}
style={style}
{...attributes}
{...listeners}
className={cn(
"select-none py-1 px-1 flex items-center justify-between",
section.enabled
? "cursor-grab active:cursor-grabbing"
: "cursor-default",
isDragging && "opacity-50"
)}
>
<div className="flex items-center gap-2">
<input
id={`section-${section.type}-${section.collectionId ?? "default"}`}
className="checkbox checkbox-primary"
type="checkbox"
checked={section.enabled}
onChange={() => onCheckboxChange(section)}
/>
<label
htmlFor={`section-${section.type}-${
section.collectionId ?? "default"
}`}
className={`text-sm pointer-events-none ${
section.enabled ? "opacity-100" : "opacity-50"
}`}
>
<i
className={`bi-${
section.type === "STATS"
? "bar-chart-line"
: section.type === "RECENT_LINKS"
? "clock"
: section.type === "PINNED_LINKS"
? "pin"
: "folder-fill"
} ${section.type !== "COLLECTION" ? "text-primary" : ""} mr-1`}
style={
section.type === "COLLECTION" ? { color: section.color } : {}
}
/>
{section.name}
</label>
</div>
<i
className={`bi-grip-vertical text-neutral ${
section.enabled ? "opacity-100" : "opacity-50"
}`}
/>
</li>
);
}

View File

@@ -0,0 +1,242 @@
import { LinkIncludingShortenedCollectionAndTags } from "@linkwarden/types";
import useLocalSettingsStore from "@/store/localSettings";
import {
ArchivedFormat,
CollectionIncludingMembersAndLinkCount,
} from "@linkwarden/types";
import { useEffect, useRef, useState } from "react";
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 {
atLeastOneFormatAvailable,
formatAvailable,
} from "@linkwarden/lib/formatStats";
import useOnScreen from "@/hooks/useOnScreen";
import { useCollections } from "@linkwarden/router/collections";
import { useUser } from "@linkwarden/router/user";
import { useGetLink, useLinks } from "@linkwarden/router/links";
import { useRouter } from "next/router";
import openLink from "@/lib/client/openLink";
import LinkIcon from "./LinkViews/LinkComponents/LinkIcon";
import LinkFormats from "./LinkViews/LinkComponents/LinkFormats";
import LinkTypeBadge from "./LinkViews/LinkComponents/LinkTypeBadge";
import LinkPin from "./LinkViews/LinkComponents/LinkPin";
import { Separator } from "./ui/separator";
import { useDraggable } from "@dnd-kit/core";
import { cn } from "@linkwarden/lib";
export function DashboardLinks({
links,
isLoading,
type,
}: {
links?: LinkIncludingShortenedCollectionAndTags[];
isLoading?: boolean;
type?: "collection" | "recent";
}) {
return (
<div
className={`flex gap-5 overflow-x-auto overflow-y-hidden hide-scrollbar w-full min-h-72`}
>
{isLoading ? (
<div className="flex flex-col gap-4 min-w-60 w-60">
<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>
) : (
links?.map((e, i) => <Card key={i} link={e} dashboardType={type} />)
)}
</div>
);
}
type Props = {
link: LinkIncludingShortenedCollectionAndTags;
editMode?: boolean;
dashboardType?: "collection" | "recent";
};
export function Card({ link, editMode, dashboardType }: Props) {
const { attributes, listeners, setNodeRef, isDragging } = useDraggable({
id: `${link.id}-${dashboardType}`,
data: {
linkId: link.id,
dashboardType,
},
});
const { data: collections = [] } = useCollections();
const { data: user } = useUser();
const {
settings: { show },
} = useLocalSettingsStore();
const { links } = useLinks();
const router = useRouter();
const isPublicRoute = router.pathname.startsWith("/public") ? true : false;
const { refetch } = useGetLink({ id: link.id as number, isPublicRoute });
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 [linkModal, setLinkModal] = useState(false);
useEffect(() => {
let interval: NodeJS.Timeout | null = null;
if (
isVisible &&
!link.preview?.startsWith("archives") &&
link.preview !== "unavailable"
) {
interval = setInterval(async () => {
refetch().catch((error) => {
console.error("Error refetching link:", error);
});
}, 5000);
}
return () => {
if (interval) {
clearInterval(interval);
}
};
}, [isVisible, link.preview]);
return (
<div
ref={setNodeRef}
className={cn(
isDragging ? "opacity-30" : "opacity-100",
"relative group touch-manipulation select-none"
)}
>
<div
ref={ref}
className={`min-w-60 w-60 border border-solid border-neutral-content bg-base-200 duration-100 rounded-xl relative group h-full`}
>
<div
className="rounded-xl cursor-pointer h-full w-full flex flex-col justify-between"
onClick={() =>
!editMode && openLink(link, user, () => setLinkModal(true))
}
{...listeners}
{...attributes}
>
{show.image && (
<div>
<div className={`relative rounded-t-xl h-40 overflow-hidden`}>
{formatAvailable(link, "preview") ? (
<Image
src={`/api/v1/archives/${link.id}?format=${ArchivedFormat.jpeg}&preview=true&updatedAt=${link.updatedAt}`}
width={1280}
height={720}
alt=""
className={`rounded-t-xl select-none object-cover z-10 h-40 w-full shadow opacity-80 scale-105`}
style={show.icon ? { filter: "blur(1px)" } : undefined}
draggable="false"
onError={(e) => {
const target = e.target as HTMLElement;
target.style.display = "none";
}}
/>
) : link.preview === "unavailable" ? (
<div className={`bg-gray-50 h-40 bg-opacity-80`}></div>
) : (
<div
className={`h-40 bg-opacity-80 skeleton rounded-none`}
></div>
)}
{show.icon && (
<div className="absolute top-0 left-0 right-0 bottom-0 rounded-t-xl flex items-center justify-center rounded-md">
<LinkIcon link={link} />
</div>
)}
{show.preserved_formats &&
link.type === "url" &&
atLeastOneFormatAvailable(link) && (
<div className="absolute bottom-0 right-0 m-2 bg-base-200 bg-opacity-60 px-1 rounded-md">
<LinkFormats link={link} />
</div>
)}
</div>
<Separator />
</div>
)}
<div className="flex flex-col justify-between h-full min-h-24">
<div className="p-3 flex flex-col justify-between h-full gap-2">
{show.name && (
<p className="line-clamp-2 w-full text-primary text-sm">
{unescapeString(link.name)}
</p>
)}
{show.link && <LinkTypeBadge link={link} />}
</div>
{(show.collection || show.date) && (
<div>
<Separator className="mb-1" />
<div className="flex justify-between items-center text-xs text-neutral px-3 pb-1 gap-2">
{show.collection && !isPublicRoute && (
<div className="cursor-pointer truncate">
<LinkCollection link={link} collection={collection} />
</div>
)}
{show.date && <LinkDate link={link} />}
</div>
</div>
)}
</div>
</div>
{/* Overlay on hover */}
<div className="absolute pointer-events-none top-0 left-0 right-0 bottom-0 bg-base-100 bg-opacity-0 group-hover:bg-opacity-20 group-focus-within:opacity-20 rounded-xl duration-100"></div>
<LinkActions
link={link}
collection={collection}
linkModal={linkModal}
setLinkModal={(e) => setLinkModal(e)}
className="absolute top-3 right-3 group-hover:opacity-100 group-focus-within:opacity-100 opacity-0 duration-100 text-neutral z-20"
/>
{!isPublicRoute && <LinkPin link={link} />}
</div>
</div>
);
}

View File

@@ -0,0 +1,175 @@
import {
DndContext,
DragEndEvent,
DragOverlay,
DragStartEvent,
MouseSensor,
SensorDescriptor,
SensorOptions,
TouchSensor,
useSensor,
useSensors,
} from "@dnd-kit/core";
import LinkIcon from "./LinkViews/LinkComponents/LinkIcon";
import { LinkIncludingShortenedCollectionAndTags } from "@linkwarden/types";
import toast from "react-hot-toast";
import { useUpdateLink } from "@linkwarden/router/links";
import { useTranslation } from "react-i18next";
import { restrictToWindowEdges, snapCenterToCursor } from "@dnd-kit/modifiers";
import { customCollisionDetectionAlgorithm } from "@/lib/utils";
import { useUpdateTag } from "@linkwarden/router/tags";
interface DragNDropProps {
children: React.ReactNode;
/**
* The currently active link being dragged
*/
activeLink: LinkIncludingShortenedCollectionAndTags | null;
/**
* All links available for drag and drop
*/
links: LinkIncludingShortenedCollectionAndTags[];
setActiveLink: (link: LinkIncludingShortenedCollectionAndTags | null) => void;
/**
* Override the default sensors used for drag and drop.
*/
sensors?: SensorDescriptor<SensorOptions>[];
/**
* Override onDragEnd function.
*/
onDragEnd?: (event: DragEndEvent) => void;
}
/**
* Wrapper component for drag and drop functionality.
*/
export default function DragNDrop({
children,
activeLink,
links,
setActiveLink,
sensors: sensorProp,
onDragEnd: onDragEndProp,
}: DragNDropProps) {
const { t } = useTranslation();
const updateTag = useUpdateTag();
const updateLink = useUpdateLink();
const mouseSensor = useSensor(MouseSensor, {
// Require the mouse to move by 10 pixels before activating
activationConstraint: {
distance: 10,
},
});
const touchSensor = useSensor(TouchSensor, {
// Press delay of 250ms, with tolerance of 5px of movement
activationConstraint: {
delay: 200,
tolerance: 5,
},
});
const sensors = useSensors(mouseSensor, touchSensor);
const handleDragStart = (event: DragStartEvent) => {
const draggedLink = links.find(
(link: any) => link.id === event.active.data.current?.linkId
);
setActiveLink(draggedLink || null);
};
const handleDragOverCancel = () => {
setActiveLink(null);
};
const handleDragEnd = async (event: DragEndEvent) => {
// If an onDragEnd prop is provided, use it instead of the default behavior
if (onDragEndProp) {
onDragEndProp(event);
return;
}
const { over } = event;
if (!over || !activeLink) return;
let updatedLink: LinkIncludingShortenedCollectionAndTags | null = null;
// if the link is dropped over a tag
if (over.data.current?.type === "tag") {
const isTagAlreadyExists = activeLink.tags.some(
(tag) => tag.name === over.data.current?.name
);
if (isTagAlreadyExists) {
toast.error(t("tag_already_added"));
return;
}
// to match the tags structure required to update the link
const allTags: { name: string }[] = activeLink.tags.map((tag) => ({
name: tag.name,
}));
const newTags = [...allTags, { name: over.data.current?.name as string }];
updatedLink = {
...activeLink,
tags: newTags as any,
};
} else {
const collectionId = over.data.current?.id as number;
const collectionName = over.data.current?.name as string;
const ownerId = over.data.current?.ownerId as number;
// Immediately hide the drag overlay
setActiveLink(null);
// if the link dropped over the same collection, toast
if (activeLink.collection.id === collectionId) {
toast.error(t("link_already_in_collection"));
return;
}
updatedLink = {
...activeLink,
collection: {
id: collectionId,
name: collectionName,
ownerId,
},
};
}
const load = toast.loading(t("updating"));
await updateLink.mutateAsync(updatedLink, {
onSettled: (_, error) => {
toast.dismiss(load);
if (error) {
toast.error(error.message);
} else {
toast.success(t("updated"));
}
},
});
};
return (
<DndContext
onDragStart={handleDragStart}
onDragEnd={handleDragEnd}
onDragCancel={handleDragOverCancel}
modifiers={[snapCenterToCursor]}
sensors={sensorProp ? sensorProp : sensors}
collisionDetection={customCollisionDetectionAlgorithm}
>
{!!activeLink && (
// when drag end, immediately hide the overlay
<DragOverlay
style={{
zIndex: 100,
pointerEvents: "none",
}}
>
<div className="w-fit h-fit">
<LinkIcon link={activeLink} />
</div>
</DragOverlay>
)}
{children}
</DndContext>
);
}

View File

@@ -0,0 +1,91 @@
import React, { ReactNode, useEffect } from "react";
import { Drawer as D } from "vaul";
import clsx from "clsx";
import useWindowDimensions from "@/hooks/useWindowDimensions";
type Props = {
toggleDrawer: Function;
children: ReactNode;
className?: string;
dismissible?: boolean;
direction?: "left" | "right";
};
export default function Drawer({
toggleDrawer,
className,
children,
dismissible = true,
direction,
}: Props) {
const [drawerIsOpen, setDrawerIsOpen] = React.useState(true);
const { width } = useWindowDimensions();
useEffect(() => {
if (width >= 640) {
document.body.style.overflow = "hidden";
document.body.style.position = "relative";
return () => {
document.body.style.overflow = "auto";
document.body.style.position = "";
};
}
}, []);
if (width < 640) {
return (
<D.Root
open={drawerIsOpen}
onClose={() => dismissible && setDrawerIsOpen(false)}
onAnimationEnd={(isOpen) => !isOpen && toggleDrawer()}
dismissible={dismissible}
>
<D.Portal>
<D.Overlay className="fixed inset-0 bg-black/40" />
<D.Content className="flex flex-col rounded-t-xl mt-24 fixed bottom-0 left-0 right-0 z-30 h-[90%] !select-auto focus:outline-none">
<div
className={clsx(
"p-4 bg-base-100 rounded-t-xl flex-1 border-neutral-content border-t overflow-y-auto",
className
)}
data-testid="mobile-modal-container"
>
<div data-testid="mobile-modal-slider" />
{children}
</div>
</D.Content>
</D.Portal>
</D.Root>
);
} else {
return (
<D.Root
open={drawerIsOpen}
onClose={() => dismissible && setDrawerIsOpen(false)}
onAnimationEnd={(isOpen) => !isOpen && toggleDrawer()}
dismissible={dismissible}
direction={direction || "right"}
>
<D.Portal>
<D.Overlay className="fixed inset-0 bg-black/10 z-20" />
<D.Content
className={clsx(
"bg-white flex flex-col h-full w-2/5 max-w-6xl min-w-[30rem] mt-24 fixed bottom-0 z-40 !select-auto focus:outline-none",
direction === "left" ? "left-0" : "right-0"
)}
>
<div
className={clsx(
"p-4 bg-base-100 flex-1 border-neutral-content overflow-y-auto",
direction === "left" ? "border-r" : "border-l",
className
)}
>
{children}
</div>
</D.Content>
</D.Portal>
</D.Root>
);
}
}

View File

@@ -60,47 +60,49 @@ export default function Dropdown({
}
}, [points, dropdownHeight]);
return !points || pos ? (
<ClickAwayHandler
onMount={(e) => {
setDropdownHeight(e.height);
setDropdownWidth(e.width);
}}
style={
points
? {
position: "fixed",
top: `${pos?.y}px`,
left: `${pos?.x}px`,
}
: undefined
}
onClickOutside={onClickOutside}
className={`${
className || ""
} py-1 shadow-md border border-neutral-content bg-base-200 rounded-md flex flex-col z-20`}
>
{items.map((e, i) => {
const inner = e && (
<div className="cursor-pointer rounded-md">
<div className="flex items-center gap-2 py-1 px-2 hover:bg-base-100 duration-100">
<p className="select-none">{e.name}</p>
return (
(!points || pos) && (
<ClickAwayHandler
onMount={(e) => {
setDropdownHeight(e.height);
setDropdownWidth(e.width);
}}
style={
points
? {
position: "fixed",
top: `${pos?.y}px`,
left: `${pos?.x}px`,
}
: undefined
}
onClickOutside={onClickOutside}
className={`${
className || ""
} py-1 shadow-md border border-neutral-content bg-base-200 rounded-md flex flex-col z-20`}
>
{items.map((e, i) => {
const inner = e && (
<div className="cursor-pointer rounded-md">
<div className="flex items-center gap-2 py-1 px-2 hover:bg-base-100 duration-100">
<p className="select-none">{e.name}</p>
</div>
</div>
</div>
);
);
return e && e.href ? (
<Link key={i} href={e.href}>
{inner}
</Link>
) : (
e && (
<div key={i} onClick={e.onClick}>
return e && e.href ? (
<Link key={i} href={e.href}>
{inner}
</div>
)
);
})}
</ClickAwayHandler>
) : null;
</Link>
) : (
e && (
<div key={i} onClick={e.onClick}>
{inner}
</div>
)
);
})}
</ClickAwayHandler>
)
);
}

View File

@@ -0,0 +1,50 @@
import { cn } from "@/lib/utils";
import { useDroppable } from "@dnd-kit/core";
const Droppable = ({
children,
id,
data,
className,
}: {
children: React.ReactNode;
id: string;
data?: {
/**
* Id of collection or tag to drop into.
*/
id?: string;
/**
* Name of collection or tag to drop into.
*/
name?: string;
ownerId?: string;
type?: "collection" | "tag";
};
className?: string;
}) => {
const { setNodeRef, isOver } = useDroppable({
id,
data,
});
return (
<div
ref={setNodeRef}
className={cn(
isOver &&
"bg-primary/10 outline-2 outline-dashed outline-primary rounded-lg",
className
)}
data-over={isOver ? "true" : undefined}
style={{
position: "relative",
zIndex: isOver ? 1 : "auto",
}}
>
{children}
</div>
);
};
export default Droppable;

View File

@@ -0,0 +1,104 @@
import React from "react";
import Drawer from "./Drawer";
import {
useGetLinkHighlights,
useRemoveHighlight,
} from "@linkwarden/router/highlights";
import { useRouter } from "next/router";
import clsx from "clsx";
import Link from "next/link";
import { useTranslation } from "next-i18next";
import { Button } from "./ui/button";
import { Separator } from "./ui/separator";
type Props = {
onClose: Function;
};
const HighlightDrawer = ({ onClose }: Props) => {
const router = useRouter();
const { t } = useTranslation();
const { data } = useGetLinkHighlights(Number(router.query.id));
const removeHighlight = useRemoveHighlight(Number(router.query.id));
return (
<Drawer
toggleDrawer={onClose}
className="sm:h-screen items-center relative"
direction="left"
>
<div>
<h2 className="text-lg font-semibold">{t("notes_highlights")}</h2>
<Separator className="my-5" />
{data && data.length > 0 ? (
data.map((highlight) => {
const formattedDate = new Date(highlight.createdAt).toLocaleString(
"en-US",
{
year: "numeric",
month: "short",
day: "numeric",
}
);
return (
<Link key={highlight.id} href={`#highlight-${highlight.id}`}>
<div
className={clsx(
"p-2 mb-4 border-l-2 duration-150 cursor-pointer flex flex-col gap-1 relative group",
highlight.color === "yellow"
? "border-yellow-500"
: highlight.color === "green"
? "border-green-500"
: highlight.color === "blue"
? "border-blue-500"
: "border-red-500"
)}
>
<p
className={clsx(
"w-fit px-2 rounded-md mr-10",
highlight.color === "yellow"
? "bg-yellow-500/70"
: highlight.color === "green"
? "bg-green-500/70"
: highlight.color === "blue"
? "bg-blue-500/70"
: "bg-red-500/70"
)}
>
{highlight.text}
</p>
{highlight.comment && <p>{highlight.comment}</p>}
<p
className="text-xs text-neutral"
title={String(highlight.createdAt)}
>
{formattedDate}
</p>
<Button
className="absolute top-2 right-2 text-neutral hover:text-red-500 group-hover:opacity-100 opacity-0 transition-opacity duration-150"
variant="ghost"
size="icon"
onClick={(e) => {
e.preventDefault();
removeHighlight.mutate(highlight.id);
}}
>
<i className="bi-trash" />
</Button>
</div>
</Link>
);
})
) : (
<div className="text-neutral text-sm">{t("no_notes_highlights")}</div>
)}
</div>
</Drawer>
);
};
export default HighlightDrawer;

View File

@@ -0,0 +1,18 @@
import React, { forwardRef } from "react";
import * as Icons from "@phosphor-icons/react";
type Props = {
icon: string;
} & Icons.IconProps;
const Icon = forwardRef<SVGSVGElement, Props>(({ icon, ...rest }, ref) => {
const IconComponent: any = Icons[icon as keyof typeof Icons];
if (!IconComponent) {
return null;
} else return <IconComponent ref={ref} {...rest} />;
});
Icon.displayName = "Icon";
export default Icon;

View File

@@ -0,0 +1,91 @@
import { icons } from "@/lib/client/icons";
import Fuse from "fuse.js";
import { forwardRef, useMemo } from "react";
import { FixedSizeGrid as Grid } from "react-window";
const fuse = new Fuse(icons, {
keys: [{ name: "name", weight: 4 }, "tags", "categories"],
threshold: 0.2,
useExtendedSearch: true,
});
type Props = {
query: string;
color: string;
weight: "light" | "regular" | "bold" | "fill" | "duotone" | "thin";
iconName?: string;
setIconName: Function;
};
const IconGrid = ({ query, color, weight, iconName, setIconName }: Props) => {
const filteredIcons = useMemo(() => {
if (!query) {
return icons;
}
return fuse.search(query).map((result) => result.item);
}, [query]);
const columnCount = 6;
const rowCount = Math.ceil(filteredIcons.length / columnCount);
const GUTTER_SIZE = 5;
const Cell = ({ columnIndex, rowIndex, style }: any) => {
const index = rowIndex * columnCount + columnIndex;
if (index >= filteredIcons.length) return null; // Prevent overflow
const icon = filteredIcons[index];
const IconComponent = icon.Icon;
return (
<div
style={{
...style,
left: style.left + GUTTER_SIZE,
top: style.top + GUTTER_SIZE,
width: style.width - GUTTER_SIZE,
height: style.height - GUTTER_SIZE,
}}
onClick={() => setIconName(icon.pascal_name)}
className={`cursor-pointer p-[6px] rounded-lg bg-base-100 w-full ${
icon.pascal_name === iconName
? "outline outline-1 outline-primary"
: ""
}`}
>
<IconComponent size={32} weight={weight} color={color} />
</div>
);
};
const InnerElementType = forwardRef(({ style, ...rest }: any, ref) => (
<div
ref={ref}
style={{
...style,
paddingLeft: GUTTER_SIZE,
paddingTop: GUTTER_SIZE,
}}
{...rest}
/>
));
InnerElementType.displayName = "InnerElementType";
return (
<Grid
columnCount={columnCount}
rowCount={rowCount}
columnWidth={50}
rowHeight={50}
innerElementType={InnerElementType}
width={320}
height={158}
itemData={filteredIcons}
className="hide-scrollbar ml-[4px] w-fit"
>
{Cell}
</Grid>
);
};
export default IconGrid;

View File

@@ -0,0 +1,79 @@
import React, { useState } from "react";
import { useTranslation } from "next-i18next";
import Icon from "./Icon";
import { IconWeight } from "@phosphor-icons/react";
import IconPopover from "./IconPopover";
import clsx from "clsx";
import { Button } from "./ui/button";
type Props = {
alignment?: string;
color: string;
setColor: Function;
iconName?: string;
setIconName: Function;
weight: "light" | "regular" | "bold" | "fill" | "duotone" | "thin";
setWeight: Function;
hideDefaultIcon?: boolean;
reset: Function;
className?: string;
};
const IconPicker = ({
alignment,
color,
setColor,
iconName,
setIconName,
weight,
setWeight,
hideDefaultIcon,
className,
reset,
}: Props) => {
const { t } = useTranslation();
const [iconPicker, setIconPicker] = useState(false);
return (
<div className="relative">
<Button
onClick={() => setIconPicker(!iconPicker)}
variant="ghost"
className="w-20 h-20"
size="icon"
>
{iconName ? (
<Icon
icon={iconName}
size={60}
weight={(weight || "regular") as IconWeight}
color={color}
/>
) : !iconName && hideDefaultIcon ? (
<p className="p-1">{t("set_custom_icon")}</p>
) : (
<i className="bi-folder-fill text-6xl" style={{ color: color }}></i>
)}
</Button>
{iconPicker && (
<IconPopover
alignment={alignment}
color={color}
setColor={setColor}
iconName={iconName}
setIconName={setIconName}
weight={weight}
setWeight={setWeight}
reset={reset}
onClose={() => setIconPicker(false)}
className={clsx(
className,
alignment || "lg:-translate-x-1/3 top-20 left-0"
)}
/>
)}
</div>
);
};
export default IconPicker;

View File

@@ -0,0 +1,159 @@
import React, { useState } from "react";
import ReactDOM from "react-dom";
import TextInput from "./TextInput";
import Popover from "./Popover";
import { HexColorPicker } from "react-colorful";
import { useTranslation } from "next-i18next";
import IconGrid from "./IconGrid";
import clsx from "clsx";
import { Button } from "./ui/button";
type Props = {
alignment?: string;
color: string;
setColor: Function;
iconName?: string;
setIconName: Function;
weight: "light" | "regular" | "bold" | "fill" | "duotone" | "thin";
setWeight: Function;
reset: Function;
className?: string;
onClose: Function;
top?: number;
left?: number;
};
const IconPopover = ({
alignment,
color,
setColor,
iconName,
setIconName,
weight,
setWeight,
reset,
className,
onClose,
top,
left,
}: Props) => {
const { t } = useTranslation();
const [query, setQuery] = useState("");
const content = (
<Popover
onClose={() => onClose()}
className={clsx(
className,
"fade-in bg-base-200 border border-neutral-content p-3 w-[22.5rem] rounded-lg shadow-md pointer-events-auto",
top !== undefined && left !== undefined && `z-[1000]`
)}
style={{ top: top, left: left }}
>
<div className="flex flex-col gap-3 w-full h-full">
<TextInput
className="p-2 rounded w-full h-7 text-sm"
placeholder={t("search")}
value={query}
onChange={(e) => setQuery(e.target.value)}
/>
<div className="grid grid-cols-6 gap-1 w-full overflow-y-auto h-44 border border-neutral-content bg-base-100 rounded-md p-2">
<IconGrid
query={query}
color={color}
weight={weight}
iconName={iconName}
setIconName={setIconName}
/>
</div>
<div className="flex gap-3 color-picker w-full justify-between">
<HexColorPicker
color={color}
onChange={(e) => setColor(e)}
className="border border-neutral-content rounded-lg"
/>
<div className="grid grid-cols-2 gap-3 text-sm">
<label className="flex items-center cursor-pointer">
<input
type="radio"
className="radio radio-primary mr-2"
value="regular"
checked={weight === "regular"}
onChange={() => setWeight("regular")}
/>
{t("regular")}
</label>
<label className="flex items-center cursor-pointer">
<input
type="radio"
className="radio radio-primary mr-2"
value="thin"
checked={weight === "thin"}
onChange={() => setWeight("thin")}
/>
{t("thin")}
</label>
<label className="flex items-center cursor-pointer">
<input
type="radio"
className="radio radio-primary mr-2"
value="light"
checked={weight === "light"}
onChange={() => setWeight("light")}
/>
{t("light_icon")}
</label>
<label className="flex items-center cursor-pointer">
<input
type="radio"
className="radio radio-primary mr-2"
value="bold"
checked={weight === "bold"}
onChange={() => setWeight("bold")}
/>
{t("bold")}
</label>
<label className="flex items-center cursor-pointer">
<input
type="radio"
className="radio radio-primary mr-2"
value="fill"
checked={weight === "fill"}
onChange={() => setWeight("fill")}
/>
{t("fill")}
</label>
<label className="flex items-center cursor-pointer">
<input
type="radio"
className="radio radio-primary mr-2"
value="duotone"
checked={weight === "duotone"}
onChange={() => setWeight("duotone")}
/>
{t("duotone")}
</label>
</div>
</div>
<div className="flex flex-row gap-2 justify-between items-center mt-2">
<Button size="sm" variant="ghost" onClick={() => reset()}>
<i className="bi-arrow-counterclockwise text-neutral" />
{t("reset_defaults")}
</Button>
<p className="text-neutral text-xs">{t("click_out_to_apply")}</p>
</div>
</div>
</Popover>
);
if (top !== undefined && left !== undefined) {
return ReactDOM.createPortal(content, document.body);
}
return content;
};
export default IconPopover;

View File

@@ -0,0 +1,84 @@
import React from "react";
import importBookmarks from "@/lib/client/importBookmarks";
import { MigrationFormat } from "@linkwarden/types";
import { useTranslation } from "next-i18next";
import {
DropdownMenu,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuItem,
} from "@/components/ui/dropdown-menu";
import { Button } from "./ui/button";
type Props = {};
const ImportDropdown = ({}: Props) => {
const { t } = useTranslation();
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="metal">
<i className="bi-cloud-upload text-xl"></i>
{t("import_links")}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent side="bottom" align="start">
{[
{
id: "import-linkwarden-file",
format: MigrationFormat.linkwarden,
label: t("from_linkwarden"),
},
{
id: "import-html-file",
format: MigrationFormat.htmlFile,
label: t("from_html"),
},
{
id: "import-pocket-file",
format: MigrationFormat.pocket,
label: t("from_pocket"),
},
{
id: "import-wallabag-file",
format: MigrationFormat.wallabag,
label: t("from_wallabag"),
},
{
id: "import-omnivore-file",
format: MigrationFormat.omnivore,
label: t("from_omnivore"),
},
].map((item) => (
<DropdownMenuItem
asChild
key={item.id}
onSelect={(e) => e.preventDefault()}
>
<label htmlFor={item.id} className="whitespace-nowrap w-full">
{item.label}
<input
type="file"
id={item.id}
accept={
item.id === "import-html-file"
? ".html"
: item.id === "import-omnivore-file"
? ".zip"
: item.id === "import-pocket-file"
? ".csv"
: ".json"
}
className="hidden"
onChange={(e) => importBookmarks(e, item.format)}
/>
</label>
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
);
};
export default ImportDropdown;

View File

@@ -1,10 +1,11 @@
import { useRouter } from "next/router";
import { useEffect, useState } from "react";
import { styles } from "./styles";
import { Options } from "./types";
import { Option } from "@linkwarden/types/inputSelect";
import CreatableSelect from "react-select/creatable";
import Select from "react-select";
import { useCollections } from "@/hooks/store/collections";
import { useCollections } from "@linkwarden/router/collections";
import clsx from "clsx";
type Props = {
onChange: any;
@@ -16,6 +17,9 @@ type Props = {
}
| undefined;
creatable?: boolean;
autoFocus?: boolean;
onBlur?: any;
className?: string;
};
export default function CollectionSelection({
@@ -23,12 +27,15 @@ export default function CollectionSelection({
defaultValue,
showDefaultValue = true,
creatable = true,
autoFocus,
onBlur,
className,
}: Props) {
const { data: collections = [] } = useCollections();
const router = useRouter();
const [options, setOptions] = useState<Options[]>([]);
const [options, setOptions] = useState<Option[]>([]);
const collectionId = Number(router.query.id);
@@ -43,20 +50,6 @@ export default function CollectionSelection({
};
}
useEffect(() => {
const formatedCollections = collections.map((e) => {
return {
value: e.id,
label: e.name,
ownerId: e.ownerId,
count: e._count,
parentId: e.parentId,
};
});
setOptions(formatedCollections);
}, [collections]);
const getParentNames = (parentId: number): string[] => {
const parentNames = [];
const parent = collections.find((e) => e.id === parentId);
@@ -72,24 +65,39 @@ export default function CollectionSelection({
return parentNames.reverse();
};
useEffect(() => {
const formattedCollections = collections
.map((e) => {
return {
value: e.id,
label: e.name,
parentsLabel:
((e.parentId && getParentNames(e.parentId).join(" > ") + " > ") ||
"") + e.name,
ownerId: e.ownerId,
count: e._count,
parentId: e.parentId,
};
})
.sort((a, b) => {
return a.parentsLabel.localeCompare(b.parentsLabel);
});
setOptions(formattedCollections);
}, [collections]);
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"
className="px-2 py-2 last:border-0 border-b border-neutral-content hover:bg-neutral-content duration-100 cursor-pointer"
>
<div className="flex w-full justify-between items-center">
<span>{data.label}</span>
<span className="text-sm text-neutral">{data.count?.links}</span>
</div>
<div className="text-xs text-gray-600 dark:text-gray-300">
{getParentNames(data?.parentId).length > 0 ? (
<>
{getParentNames(data.parentId).join(" > ")} {">"} {data.label}
</>
) : (
data.label
)}
{data.parentsLabel}
</div>
</div>
);
@@ -99,11 +107,13 @@ export default function CollectionSelection({
return (
<CreatableSelect
isClearable={false}
className="react-select-container"
className={clsx("react-select-container", className)}
classNamePrefix="react-select"
onChange={onChange}
options={options}
styles={styles}
autoFocus={autoFocus}
onBlur={onBlur}
defaultValue={showDefaultValue ? defaultValue : null}
components={{
Option: customOption,
@@ -115,12 +125,14 @@ export default function CollectionSelection({
return (
<Select
isClearable={false}
className="react-select-container"
className={clsx("react-select-container", className)}
classNamePrefix="react-select"
onChange={onChange}
options={options}
styles={styles}
autoFocus={autoFocus}
defaultValue={showDefaultValue ? defaultValue : null}
onBlur={onBlur}
components={{
Option: customOption,
}}

View File

@@ -0,0 +1,57 @@
import { useEffect, useState } from "react";
import CreatableSelect from "react-select/creatable";
import { styles } from "./styles";
import { ArchivalTagOption, Option } from "@linkwarden/types/inputSelect";
import { useTags } from "@linkwarden/router/tags";
import { useTranslation } from "next-i18next";
type Props = {
onChange: (e: any) => void;
options?: Option[] | ArchivalTagOption[];
isArchivalSelection?: boolean;
defaultValue?: {
value?: number;
label: string;
}[];
autoFocus?: boolean;
onBlur?: any;
};
export default function TagSelection({
onChange,
options,
isArchivalSelection,
defaultValue,
autoFocus,
onBlur,
}: Props) {
const { data: tags = [] } = useTags();
const { t } = useTranslation();
const [tagOptions, setTagOptions] = useState<Option[]>([]);
useEffect(() => {
const formatedCollections = tags.map((e: any) => {
return { value: e.id, label: e.name };
});
setTagOptions(formatedCollections);
}, [tags]);
return (
<CreatableSelect
isClearable={false}
className="react-select-container text-sm"
classNamePrefix="react-select"
onChange={onChange}
options={isArchivalSelection ? options : tagOptions}
styles={styles}
value={isArchivalSelection ? [] : undefined}
defaultValue={isArchivalSelection ? undefined : defaultValue}
placeholder={t("tag_selection_placeholder")}
isMulti
autoFocus={autoFocus}
onBlur={onBlur}
/>
);
}

View File

@@ -14,7 +14,11 @@ export const styles: StylesConfig = {
? "oklch(var(--p))"
: "oklch(var(--nc))",
},
transition: "all 50ms",
transition: "all 100ms",
}),
menu: (styles) => ({
...styles,
zIndex: 10,
}),
control: (styles, state) => ({
...styles,
@@ -46,23 +50,33 @@ export const styles: StylesConfig = {
placeholder: (styles) => ({
...styles,
borderColor: "black",
color: "oklch(var(--n))",
}),
multiValue: (styles) => {
return {
...styles,
backgroundColor: "#0ea5e9",
color: "white",
backgroundColor: "oklch(var(--b2))",
color: "oklch(var(--bc))",
display: "flex",
alignItems: "center",
gap: "0.1rem",
marginRight: "0.4rem",
};
},
multiValueLabel: (styles) => ({
...styles,
color: "white",
color: "oklch(var(--bc))",
}),
multiValueRemove: (styles) => ({
...styles,
height: "1.2rem",
width: "1.2rem",
borderRadius: "100px",
transition: "all 100ms",
color: "oklch(var(--w))",
":hover": {
color: "white",
backgroundColor: "#38bdf8",
color: "red",
backgroundColor: "oklch(var(--nc))",
},
}),
menuPortal: (base) => ({ ...base, zIndex: 9999 }),

View File

@@ -1,6 +1,7 @@
import { isPWA } from "@/lib/client/utils";
import { isPWA } from "@/lib/utils";
import React, { useState } from "react";
import { Trans } from "next-i18next";
import { Button } from "./ui/button";
type Props = {};
@@ -8,7 +9,7 @@ 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="fixed left-0 right-0 bottom-10 w-full px-5">
<div className="mx-auto w-fit p-2 flex justify-between gap-2 items-center border border-neutral-content rounded-xl bg-base-300 backdrop-blur-md bg-opacity-80 max-w-md">
<svg
xmlns="http://www.w3.org/2000/svg"
@@ -38,12 +39,9 @@ const InstallApp = (props: Props) => {
]}
/>
</p>
<button
onClick={() => setIsOpen(false)}
className="btn btn-ghost btn-square btn-sm"
>
<Button onClick={() => setIsOpen(false)} variant="ghost" size="icon">
<i className="bi-x text-xl"></i>
</button>
</Button>
</div>
</div>
) : (

View File

@@ -0,0 +1,661 @@
import React, { useEffect, useState } from "react";
import {
LinkIncludingShortenedCollectionAndTags,
ArchivedFormat,
} from "@linkwarden/types";
import Link from "next/link";
import {
atLeastOneFormatAvailable,
formatAvailable,
} from "@linkwarden/lib/formatStats";
import PreservedFormatRow from "@/components/PreserverdFormatRow";
import getPublicUserData from "@/lib/client/getPublicUserData";
import { useTranslation } from "next-i18next";
import { BeatLoader } from "react-spinners";
import { useUser } from "@linkwarden/router/user";
import { useUpdateLink, useUpdateFile } from "@linkwarden/router/links";
import LinkIcon from "./LinkViews/LinkComponents/LinkIcon";
import CopyButton from "./CopyButton";
import { useRouter } from "next/router";
import Icon from "./Icon";
import { IconWeight } from "@phosphor-icons/react";
import Image from "next/image";
import clsx from "clsx";
import toast from "react-hot-toast";
import CollectionSelection from "./InputSelect/CollectionSelection";
import TagSelection from "./InputSelect/TagSelection";
import unescapeString from "@/lib/client/unescapeString";
import IconPopover from "./IconPopover";
import TextInput from "./TextInput";
import usePermissions from "@/hooks/usePermissions";
import oklchVariableToHex from "@/lib/client/oklchVariableToHex";
import { Button } from "./ui/button";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { Separator } from "./ui/separator";
type Props = {
className?: string;
activeLink: LinkIncludingShortenedCollectionAndTags;
standalone?: boolean;
mode?: "view" | "edit";
setMode?: Function;
onUpdateArchive?: () => void;
};
export default function LinkDetails({
className,
activeLink,
standalone,
mode = "view",
setMode,
onUpdateArchive,
}: Props) {
const [link, setLink] =
useState<LinkIncludingShortenedCollectionAndTags>(activeLink);
useEffect(() => {
setLink(activeLink);
}, [activeLink]);
const permissions = usePermissions(link.collection.id as number);
const { t } = useTranslation();
const { data: user } = useUser();
const router = useRouter();
const isPublicRoute = router.pathname.startsWith("/public") ? true : false;
const [collectionOwner, setCollectionOwner] = useState({
id: null as unknown as number,
name: "",
username: "",
image: "",
archiveAsScreenshot: undefined as unknown as boolean,
archiveAsMonolith: undefined as unknown as boolean,
archiveAsPDF: undefined as unknown as boolean,
});
useEffect(() => {
const fetchOwner = async () => {
if (link.collection.ownerId !== user?.id) {
const owner = await getPublicUserData(
link.collection.ownerId as number
);
setCollectionOwner(owner);
} else if (link.collection.ownerId === user?.id) {
setCollectionOwner({
id: user?.id as number,
name: user?.name as string,
username: user?.username as string,
image: user?.image as string,
archiveAsScreenshot: user?.archiveAsScreenshot as boolean,
archiveAsMonolith: user?.archiveAsScreenshot as boolean,
archiveAsPDF: user?.archiveAsPDF as boolean,
});
}
};
fetchOwner();
}, [link.collection.ownerId]);
const isReady = () => {
return (
link &&
(collectionOwner.archiveAsScreenshot === true ? link.pdf : true) &&
(collectionOwner.archiveAsMonolith === true ? link.monolith : true) &&
(collectionOwner.archiveAsPDF === true ? link.pdf : true) &&
link.readable
);
};
const updateLink = useUpdateLink();
const updateFile = useUpdateFile();
const submit = async (e?: any) => {
e?.preventDefault();
const { updatedAt: b, ...oldLink } = activeLink;
const { updatedAt: a, ...newLink } = link;
if (JSON.stringify(oldLink) === JSON.stringify(newLink)) {
return;
}
const load = toast.loading(t("updating"));
await updateLink.mutateAsync(link, {
onSettled: (data, error) => {
toast.dismiss(load);
if (error) {
toast.error(error.message);
} else {
toast.success(t("updated"));
setMode && setMode("view");
setLink(data);
}
},
});
};
const setCollection = (e: any) => {
if (e?.__isNew__) e.value = null;
setLink({
...link,
collection: { id: e?.value, name: e?.label, ownerId: e?.ownerId },
});
};
const setTags = (e: any) => {
const tagNames = e.map((e: any) => ({ name: e.label }));
setLink({ ...link, tags: tagNames });
};
const [iconPopover, setIconPopover] = useState(false);
return (
<div className={clsx(className)} data-vaul-no-drag>
<div
className={clsx(
standalone && "sm:border sm:border-neutral-content sm:rounded-xl p-5"
)}
>
<div
className={clsx(
"overflow-hidden select-none relative group h-40 opacity-80",
standalone
? "sm:max-w-xl -mx-5 -mt-5 sm:rounded-t-xl"
: "-mx-4 -mt-4"
)}
>
{formatAvailable(link, "preview") ? (
<Image
src={`/api/v1/archives/${link.id}?format=${ArchivedFormat.jpeg}&preview=true&updatedAt=${link.updatedAt}`}
width={1280}
height={720}
alt=""
className="object-cover scale-105 object-center h-full"
style={{
filter: "blur(1px)",
}}
onError={(e) => {
const target = e.target as HTMLElement;
target.style.display = "none";
}}
/>
) : link.preview === "unavailable" ? (
<div className="bg-gray-50 duration-100 h-40"></div>
) : (
<div className="h-40 skeleton rounded-none"></div>
)}
{!standalone &&
(permissions === true || permissions?.canUpdate) &&
!isPublicRoute && (
<div className="absolute top-0 bottom-0 left-0 right-0 opacity-0 group-hover:opacity-100 duration-100 flex justify-end items-end">
<Button
className="mb-2 mr-3 opacity-50 hover:opacity-100 p-0"
size="sm"
>
<label className="cursor-pointer py-1 px-2 w-full">
{t("upload_banner")}
<input
type="file"
accept="image/jpg, image/jpeg"
onChange={async (e) => {
const file = e.target.files?.[0];
if (!file) return;
const load = toast.loading(t("updating"));
await updateFile.mutateAsync(
{
linkId: link.id as number,
file,
},
{
onSettled: (data, error) => {
toast.dismiss(load);
if (error) {
toast.error(error.message);
} else {
toast.success(t("updated"));
setLink({ updatedAt: data.updatedAt, ...link });
}
},
}
);
}}
className="hidden"
/>
</label>
</Button>
</div>
)}
</div>
{!standalone &&
(permissions === true || permissions?.canUpdate) &&
!isPublicRoute ? (
<div className="-mt-14 ml-8 relative w-fit pb-2">
<TooltipProvider>
<Tooltip>
<TooltipTrigger>
<LinkIcon
link={link}
className="hover:bg-opacity-70 duration-100 cursor-pointer"
onClick={() => setIconPopover(true)}
/>
</TooltipTrigger>
<TooltipContent side="bottom">
<p>{t("change_icon")}</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
{iconPopover && (
<IconPopover
color={link.color || oklchVariableToHex("--p")}
setColor={(color: string) => setLink({ ...link, color })}
weight={(link.iconWeight || "regular") as IconWeight}
setWeight={(iconWeight: string) =>
setLink({ ...link, iconWeight })
}
iconName={link.icon as string}
setIconName={(icon: string) => setLink({ ...link, icon })}
reset={() =>
setLink({
...link,
color: "",
icon: "",
iconWeight: "",
})
}
className="top-12"
onClose={() => {
setIconPopover(false);
submit();
}}
/>
)}
</div>
) : (
<div className="-mt-14 ml-8 relative w-fit pb-2">
<LinkIcon link={link} onClick={() => setIconPopover(true)} />
</div>
)}
<div className="sm:px-8 p-5 pb-8 pt-2">
{mode === "view" && (
<div className="text-xl mt-2 pr-7">
<p
className={clsx("relative w-fit", !link.name && "text-neutral")}
>
{unescapeString(link.name) || t("untitled")}
</p>
</div>
)}
{mode === "edit" && (
<>
<br />
<div>
<p className="text-sm mb-2 text-neutral relative w-fit flex justify-between">
{t("name")}
</p>
<TextInput
value={link.name}
onChange={(e) => setLink({ ...link, name: e.target.value })}
placeholder={t("placeholder_example_link")}
className="bg-base-200"
/>
</div>
</>
)}
{link.url && mode === "view" ? (
<>
<br />
<p className="text-sm mb-2 text-neutral">{t("link")}</p>
<div className="relative">
<div className="rounded-md p-2 bg-base-200 hide-scrollbar overflow-x-auto whitespace-nowrap flex justify-between items-center gap-2 pr-14">
<Link href={link.url} title={link.url} target="_blank">
{link.url}
</Link>
<div className="absolute right-0 px-2 bg-base-200">
<CopyButton text={link.url} />
</div>
</div>
</div>
</>
) : activeLink.url ? (
<>
<br />
<div>
<p className="text-sm mb-2 text-neutral relative w-fit flex justify-between">
{t("link")}
</p>
<TextInput
value={link.url || ""}
onChange={(e) => setLink({ ...link, url: e.target.value })}
placeholder={t("placeholder_example_link")}
className="bg-base-200"
/>
</div>
</>
) : undefined}
<br />
<div className="relative">
<p className="text-sm mb-2 text-neutral relative w-fit flex justify-between">
{t("collection")}
</p>
{mode === "view" ? (
<div className="relative">
<Link
href={
isPublicRoute
? `/public/collections/${link.collection.id}`
: `/collections/${link.collection.id}`
}
className="rounded-md p-2 bg-base-200 border border-base-200 hide-scrollbar overflow-x-auto whitespace-nowrap flex justify-between items-center gap-2 pr-14"
>
<p>{link.collection.name}</p>
<div className="absolute right-0 px-2 bg-base-200">
{link.collection.icon ? (
<Icon
icon={link.collection.icon}
size={30}
weight={
(link.collection.iconWeight ||
"regular") as IconWeight
}
color={link.collection.color}
/>
) : (
<i
className="bi-folder-fill text-xl"
style={{ color: link.collection.color }}
></i>
)}
</div>
</Link>
</div>
) : (
<CollectionSelection
onChange={setCollection}
defaultValue={
link.collection.id
? { value: link.collection.id, label: link.collection.name }
: { value: null as unknown as number, label: "Unorganized" }
}
creatable={false}
/>
)}
</div>
<br />
<div className="relative">
<p className="text-sm mb-2 text-neutral relative w-fit flex justify-between">
{t("tags")}
</p>
{mode === "view" ? (
<div className="flex gap-2 flex-wrap rounded-md p-2 bg-base-200 border border-base-200 w-full text-xs">
{link.tags && link.tags[0] ? (
link.tags.map((tag) =>
isPublicRoute ? (
<div
key={tag.id}
className="bg-base-200 p-1 hover:bg-neutral-content rounded-md duration-100"
>
{tag.name}
</div>
) : (
<Link
href={"/tags/" + tag.id}
key={tag.id}
className="bg-base-200 py-1 px-2 hover:bg-neutral-content rounded-sm duration-150"
>
{tag.name}
</Link>
)
)
) : (
<div className="text-neutral text-base">{t("no_tags")}</div>
)}
</div>
) : (
<TagSelection
onChange={setTags}
defaultValue={link.tags.map((e) => ({
label: e.name,
value: e.id,
}))}
/>
)}
</div>
<br />
<div className="relative">
<p className="text-sm mb-2 text-neutral relative w-fit flex justify-between">
{t("description")}
</p>
{mode === "view" ? (
<div className="rounded-md p-2 bg-base-200 hyphens-auto">
{link.description ? (
<p>{link.description}</p>
) : (
<p className="text-neutral">{t("no_description_provided")}</p>
)}
</div>
) : (
<textarea
value={unescapeString(link.description) as string}
onChange={(e) =>
setLink({ ...link, description: e.target.value })
}
placeholder={t("link_description_placeholder")}
className="resize-none w-full rounded-md p-2 h-32 border-neutral-content bg-base-200 focus:border-primary border-solid border outline-none duration-100"
/>
)}
</div>
{mode === "view" && (
<div>
<br />
<div className="flex gap-1 items-center mb-2">
<p
className="text-sm text-neutral"
title={t("available_formats")}
>
{link.url ? t("preserved_formats") : t("content")}
</p>
{onUpdateArchive &&
(permissions === true || permissions?.canUpdate) &&
!isPublicRoute &&
link.url && (
<TooltipProvider>
<Tooltip>
<TooltipTrigger>
<Button
variant="ghost"
size="sm"
className="text-neutral"
onClick={onUpdateArchive}
>
<i className="bi-arrow-clockwise text-sm" />
</Button>
</TooltipTrigger>
<TooltipContent side="bottom">
<p>{t("refresh_preserved_formats")}</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
</div>
<div className={`flex flex-col rounded-md p-3 bg-base-200`}>
{formatAvailable(link, "monolith") ? (
<>
<PreservedFormatRow
name={t("webpage")}
icon={"bi-filetype-html"}
format={ArchivedFormat.monolith}
link={link}
downloadable={true}
/>
<Separator className="my-3" />
</>
) : undefined}
{formatAvailable(link, "image") ? (
<>
<PreservedFormatRow
name={t("screenshot")}
icon={"bi-file-earmark-image"}
format={
link?.image?.endsWith("png")
? ArchivedFormat.png
: ArchivedFormat.jpeg
}
link={link}
downloadable={true}
/>
<Separator className="my-3" />
</>
) : undefined}
{formatAvailable(link, "pdf") ? (
<>
<PreservedFormatRow
name={t("pdf")}
icon={"bi-file-earmark-pdf"}
format={ArchivedFormat.pdf}
link={link}
downloadable={true}
/>
<Separator className="my-3" />
</>
) : undefined}
{formatAvailable(link, "readable") ? (
<>
<PreservedFormatRow
name={t("readable")}
icon={"bi-file-earmark-text"}
format={ArchivedFormat.readability}
link={link}
/>
<Separator className="my-3" />
</>
) : undefined}
{!isReady() && !atLeastOneFormatAvailable(link) ? (
<div
className={`w-full h-full flex flex-col justify-center p-10`}
>
<BeatLoader
color="oklch(var(--p))"
className="mx-auto mb-3"
size={30}
/>
<p className="text-center text-xl">
{t("preservation_in_queue")}
</p>
<p className="text-center text-lg">
{t("check_back_later")}
</p>
</div>
) : link.url &&
!isReady() &&
atLeastOneFormatAvailable(link) ? (
<div
className={`w-full h-full flex flex-col justify-center p-5`}
>
<BeatLoader
color="oklch(var(--p))"
className="mx-auto mb-3"
size={20}
/>
<p className="text-center">{t("there_are_more_formats")}</p>
<p className="text-center text-sm">
{t("check_back_later")}
</p>
</div>
) : undefined}
{link.url && (
<Link
href={`https://web.archive.org/web/${link?.url?.replace(
/(^\w+:|^)\/\//,
""
)}`}
target="_blank"
className="text-neutral mx-auto duration-100 hover:opacity-60 flex gap-2 w-1/2 justify-center items-center text-sm"
>
<p className="whitespace-nowrap">
{t("view_latest_snapshot")}
</p>
<i className="bi-box-arrow-up-right" />
</Link>
)}
</div>
</div>
)}
{mode === "view" ? (
<>
<br />
<p className="text-neutral text-xs text-center">
{t("saved")}{" "}
{new Date(link.createdAt || "").toLocaleDateString("en-US", {
month: "short",
day: "numeric",
year: "numeric",
})}{" "}
at{" "}
{new Date(link.createdAt || "").toLocaleTimeString("en-US", {
hour: "numeric",
minute: "numeric",
})}
</p>
</>
) : (
<>
<br />
<div className="flex justify-end items-center">
<Button
variant="accent"
disabled={JSON.stringify(activeLink) === JSON.stringify(link)}
onClick={submit}
>
{t("save_changes")}
</Button>
</div>
</>
)}
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,293 @@
import React, { Dispatch, SetStateAction, useEffect, useState } from "react";
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 {
LinkIncludingShortenedCollectionAndTags,
Sort,
ViewMode,
} from "@linkwarden/types";
import { useArchiveAction, useBulkDeleteLinks } from "@linkwarden/router/links";
import toast from "react-hot-toast";
import { Button } from "@/components/ui/button";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import ConfirmationModal from "./ConfirmationModal";
type Props = {
children: React.ReactNode;
t: TFunction<"translation", undefined>;
viewMode: ViewMode;
setViewMode: Dispatch<SetStateAction<ViewMode>>;
sortBy: Sort;
setSortBy: Dispatch<SetStateAction<Sort>>;
editMode?: boolean;
setEditMode?: (mode: boolean) => void;
links: LinkIncludingShortenedCollectionAndTags[];
};
const LinkListOptions = ({
children,
t,
viewMode,
setViewMode,
sortBy,
setSortBy,
editMode,
setEditMode,
links,
}: Props) => {
const { selectedLinks, setSelectedLinks } = useLinkStore();
const deleteLinksById = useBulkDeleteLinks();
const refreshPreservations = useArchiveAction();
const router = useRouter();
const [bulkDeleteLinksModal, setBulkDeleteLinksModal] = useState(false);
const [bulkEditLinksModal, setBulkEditLinksModal] = useState(false);
const [bulkRefreshPreservationsModal, setBulkRefreshPreservationsModal] =
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([]);
setEditMode?.(false);
toast.success(t("deleted"));
}
},
}
);
};
const bulkRefreshPreservations = async () => {
const load = toast.loading(t("sending_request"));
await refreshPreservations.mutateAsync(
{
linkIds: selectedLinks.map((link) => link.id as number),
},
{
onSettled: (data, error) => {
toast.dismiss(load);
if (error) {
toast.error(error.message);
} else {
setSelectedLinks([]);
setEditMode?.(false);
toast.success(t("links_being_archived"));
}
},
}
);
};
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 && (
<Button
variant="ghost"
size="icon"
onClick={() => {
setEditMode(!editMode);
setSelectedLinks([]);
}}
className={
editMode ? "bg-primary/20 hover:bg-primary/20" : ""
}
>
<i className="bi-pencil-fill text-neutral text-xl" />
</Button>
)}
<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">
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
onClick={() => setBulkRefreshPreservationsModal(true)}
disabled={selectedLinks.length === 0}
>
<i className="bi-arrow-clockwise" />
</Button>
</TooltipTrigger>
<TooltipContent>
<p>{t("refresh_preserved_formats")}</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button
onClick={() => setBulkEditLinksModal(true)}
variant="ghost"
size="icon"
disabled={
selectedLinks.length === 0 ||
!(
collectivePermissions === true ||
collectivePermissions?.canUpdate
)
}
>
<i className="bi-pencil-square" />
</Button>
</TooltipTrigger>
<TooltipContent>
<p>{t("edit")}</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button
onClick={(e) => {
e.shiftKey
? bulkDeleteLinks()
: setBulkDeleteLinksModal(true);
}}
variant="ghost"
size="icon"
disabled={
selectedLinks.length === 0 ||
!(
collectivePermissions === true ||
collectivePermissions?.canDelete
)
}
>
<i className="bi-trash text-error" />
</Button>
</TooltipTrigger>
<TooltipContent>
<p> {t("delete")}</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
</div>
)}
{bulkDeleteLinksModal && (
<BulkDeleteLinksModal
onClose={() => {
setBulkDeleteLinksModal(false);
setEditMode?.(false);
}}
/>
)}
{bulkEditLinksModal && (
<BulkEditLinksModal
onClose={() => {
setBulkEditLinksModal(false);
setEditMode?.(false);
}}
/>
)}
{bulkRefreshPreservationsModal && (
<ConfirmationModal
toggleModal={() => {
setBulkRefreshPreservationsModal(false);
}}
onConfirmed={async () => {
await bulkRefreshPreservations();
}}
title={t("refresh_preserved_formats")}
>
<p className="mb-5">
{selectedLinks.length === 1
? t("refresh_preserved_formats_confirmation_desc")
: t("refresh_multiple_preserved_formats_confirmation_desc", {
count: selectedLinks.length,
})}
</p>
</ConfirmationModal>
)}
</>
);
};
export default LinkListOptions;

View File

@@ -0,0 +1,201 @@
import { useState } from "react";
import {
CollectionIncludingMembersAndLinkCount,
LinkIncludingShortenedCollectionAndTags,
} from "@linkwarden/types";
import usePermissions from "@/hooks/usePermissions";
import DeleteLinkModal from "@/components/ModalContent/DeleteLinkModal";
import { useTranslation } from "next-i18next";
import { useDeleteLink, useGetLink } from "@linkwarden/router/links";
import toast from "react-hot-toast";
import LinkModal from "@/components/ModalContent/LinkModal";
import { useRouter } from "next/router";
import clsx from "clsx";
import usePinLink from "@/lib/client/pinLink";
import {
DropdownMenu,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
} from "@/components/ui/dropdown-menu";
import { Button } from "@/components/ui/button";
import ConfirmationModal from "@/components/ConfirmationModal";
type Props = {
link: LinkIncludingShortenedCollectionAndTags;
collection: CollectionIncludingMembersAndLinkCount;
linkModal: boolean;
className?: string;
setLinkModal: (value: boolean) => void;
ghost?: boolean;
};
export default function LinkActions({
link,
linkModal,
className,
setLinkModal,
ghost,
}: Props) {
const { t } = useTranslation();
const permissions = usePermissions(link.collection.id as number);
const router = useRouter();
const isPublicRoute = router.pathname.startsWith("/public");
const { refetch } = useGetLink({
id: link.id as number,
isPublicRoute,
});
const pinLink = usePinLink();
const [editLinkModal, setEditLinkModal] = useState(false);
const [deleteLinkModal, setDeleteLinkModal] = useState(false);
const [refreshPreservationsModal, setRefreshPreservationsModal] =
useState(false);
const deleteLink = useDeleteLink();
const updateArchive = async () => {
const load = toast.loading(t("sending_request"));
const response = await fetch(`/api/v1/links/${link?.id}/archive`, {
method: "PUT",
});
const data = await response.json();
toast.dismiss(load);
if (response.ok) {
refetch().catch((error) => {
console.error("Error fetching link:", error);
});
toast.success(t("link_being_archived"));
} else toast.error(data.response);
};
return (
<>
{isPublicRoute ? (
<Button
variant={ghost ? "ghost" : "simple"}
size="icon"
className={clsx(className, "cursor-pointer")}
onClick={() => setLinkModal(true)}
>
<i title="More" className="bi-info-circle text-xl" />
</Button>
) : (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
asChild
variant={ghost ? "ghost" : "simple"}
size="icon"
className={clsx(className, "cursor-pointer")}
onMouseDown={(e) => e.preventDefault()}
>
<i title="More" className="bi-three-dots text-xl" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent sideOffset={4} align="end">
<DropdownMenuItem onSelect={() => pinLink(link)}>
<i className="bi-pin" />
{link.pinnedBy && link.pinnedBy.length > 0
? t("unpin")
: t("pin_to_dashboard")}
</DropdownMenuItem>
<DropdownMenuItem onSelect={() => setLinkModal(true)}>
<i className="bi-info-circle" />
{t("show_link_details")}
</DropdownMenuItem>
{(permissions === true || permissions?.canUpdate) && (
<DropdownMenuItem onSelect={() => setEditLinkModal(true)}>
<i className="bi-pencil-square" />
{t("edit_link")}
</DropdownMenuItem>
)}
{(permissions === true || permissions?.canDelete) && (
<>
<DropdownMenuSeparator />
<DropdownMenuItem
className="text-error"
onClick={async (e) => {
if (e.shiftKey) {
const load = toast.loading(t("deleting"));
await deleteLink.mutateAsync(link.id as number, {
onSettled: (data, error) => {
toast.dismiss(load);
if (error) toast.error(error.message);
else toast.success(t("deleted"));
},
});
} else {
setDeleteLinkModal(true);
}
}}
>
<i className="bi-trash" />
{t("delete")}
</DropdownMenuItem>
</>
)}
</DropdownMenuContent>
</DropdownMenu>
)}
{editLinkModal && (
<LinkModal
onClose={() => setEditLinkModal(false)}
onPin={() => pinLink(link)}
onUpdateArchive={() => setRefreshPreservationsModal(true)}
onDelete={() => setDeleteLinkModal(true)}
link={link}
activeMode="edit"
/>
)}
{deleteLinkModal && (
<DeleteLinkModal
onClose={() => setDeleteLinkModal(false)}
activeLink={link}
/>
)}
{refreshPreservationsModal && (
<ConfirmationModal
toggleModal={() => {
setRefreshPreservationsModal(false);
}}
onConfirmed={async () => {
await updateArchive();
}}
title={t("refresh_preserved_formats")}
>
<p className="mb-5">
{t("refresh_preserved_formats_confirmation_desc")}
</p>
</ConfirmationModal>
)}
{linkModal && (
<LinkModal
onClose={() => setLinkModal(false)}
onPin={() => pinLink(link)}
onUpdateArchive={() => setRefreshPreservationsModal(true)}
onDelete={() => setDeleteLinkModal(true)}
link={link}
/>
)}
</>
);
}

View File

@@ -0,0 +1,278 @@
import {
ArchivedFormat,
CollectionIncludingMembersAndLinkCount,
LinkIncludingShortenedCollectionAndTags,
} from "@linkwarden/types";
import { useEffect, useMemo, useRef, useState } from "react";
import useLinkStore from "@/store/links";
import unescapeString from "@/lib/client/unescapeString";
import LinkActions from "@/components/LinkViews/LinkComponents/LinkActions";
import LinkDate from "@/components/LinkViews/LinkComponents/LinkDate";
import LinkCollection from "@/components/LinkViews/LinkComponents/LinkCollection";
import Image from "next/image";
import {
atLeastOneFormatAvailable,
formatAvailable,
} from "@linkwarden/lib/formatStats";
import LinkIcon from "./LinkIcon";
import useOnScreen from "@/hooks/useOnScreen";
import usePermissions from "@/hooks/usePermissions";
import toast from "react-hot-toast";
import LinkTypeBadge from "./LinkTypeBadge";
import { useTranslation } from "next-i18next";
import { useCollections } from "@linkwarden/router/collections";
import { useUser } from "@linkwarden/router/user";
import { useGetLink, useLinks } from "@linkwarden/router/links";
import { useRouter } from "next/router";
import useLocalSettingsStore from "@/store/localSettings";
import LinkPin from "./LinkPin";
import LinkFormats from "./LinkFormats";
import openLink from "@/lib/client/openLink";
import { Separator } from "@/components/ui/separator";
import { useDraggable } from "@dnd-kit/core";
import { cn } from "@/lib/utils";
import useMediaQuery from "@/hooks/useMediaQuery";
type Props = {
link: LinkIncludingShortenedCollectionAndTags;
columns: number;
className?: string;
editMode?: boolean;
};
export default function LinkCard({ link, columns, editMode }: Props) {
const { t } = useTranslation();
// we don't want to use the draggable feature for screen under 1023px since the sidebar is hidden
const isSmallScreen = useMediaQuery("(max-width: 1023px)");
const { attributes, listeners, setNodeRef, isDragging } = useDraggable({
id: link.id?.toString() ?? "",
data: {
linkId: link.id,
},
disabled: isSmallScreen,
});
const heightMap = {
1: "h-44",
2: "h-40",
3: "h-36",
4: "h-32",
5: "h-28",
6: "h-24",
7: "h-20",
8: "h-20",
};
const imageHeightClass = useMemo(
() => (columns ? heightMap[columns as keyof typeof heightMap] : "h-40"),
[columns]
);
const { data: collections = [] } = useCollections();
const { data: user } = useUser();
const { setSelectedLinks, selectedLinks } = useLinkStore();
const {
settings: { show },
} = useLocalSettingsStore();
const { links } = useLinks();
const router = useRouter();
const isPublicRoute = router.pathname.startsWith("/public") ? true : false;
const { refetch } = useGetLink({ id: link.id as number, isPublicRoute });
useEffect(() => {
if (!editMode) {
setSelectedLinks([]);
}
}, [editMode]);
const handleCheckboxClick = (
link: LinkIncludingShortenedCollectionAndTags
) => {
if (selectedLinks.includes(link)) {
setSelectedLinks(selectedLinks.filter((e) => e !== link));
} else {
setSelectedLinks([...selectedLinks, link]);
}
};
let shortendURL;
try {
if (link.url) {
shortendURL = new URL(link.url).host.toLowerCase();
}
} catch (error) {
console.log(error);
}
const [collection, setCollection] =
useState<CollectionIncludingMembersAndLinkCount>(
collections.find(
(e) => e.id === link.collection.id
) as CollectionIncludingMembersAndLinkCount
);
useEffect(() => {
setCollection(
collections.find(
(e) => e.id === link.collection.id
) as CollectionIncludingMembersAndLinkCount
);
}, [collections, links]);
const ref = useRef<HTMLDivElement>(null);
const isVisible = useOnScreen(ref);
const permissions = usePermissions(collection?.id as number);
const [linkModal, setLinkModal] = useState(false);
useEffect(() => {
let interval: NodeJS.Timeout | null = null;
if (
isVisible &&
!link.preview?.startsWith("archives") &&
link.preview !== "unavailable"
) {
interval = setInterval(async () => {
refetch().catch((error) => {
console.error("Error refetching link:", error);
});
}, 5000);
}
return () => {
if (interval) {
clearInterval(interval);
}
};
}, [isVisible, link.preview]);
const isLinkSelected = selectedLinks.some(
(selectedLink) => selectedLink.id === link.id
);
const selectable =
editMode &&
(permissions === true || permissions?.canCreate || permissions?.canDelete);
return (
<div
ref={setNodeRef}
className={cn(
"border border-solid border-neutral-content bg-base-200 shadow-md hover:shadow-none duration-100 rounded-xl relative group",
isLinkSelected && "border-primary bg-base-300",
isDragging ? "opacity-30" : "opacity-100",
"relative group touch-manipulation select-none"
)}
onClick={() =>
selectable
? handleCheckboxClick(link)
: editMode
? toast.error(t("link_selection_error"))
: undefined
}
>
<div ref={ref}>
<div
className="rounded-xl cursor-pointer h-full flex flex-col justify-between"
onClick={() =>
!editMode && openLink(link, user, () => setLinkModal(true))
}
{...listeners}
{...attributes}
>
{show.image && (
<div>
<div
className={`relative rounded-t-xl ${imageHeightClass} overflow-hidden`}
>
{formatAvailable(link, "preview") ? (
<Image
src={`/api/v1/archives/${link.id}?format=${ArchivedFormat.jpeg}&preview=true&updatedAt=${link.updatedAt}`}
width={1280}
height={720}
alt=""
className={`rounded-t-xl select-none object-cover z-10 ${imageHeightClass} w-full shadow opacity-80 scale-105`}
style={show.icon ? { filter: "blur(1px)" } : undefined}
draggable="false"
onError={(e) => {
const target = e.target as HTMLElement;
target.style.display = "none";
}}
/>
) : link.preview === "unavailable" ? (
<div
className={`bg-gray-50 ${imageHeightClass} bg-opacity-80`}
></div>
) : (
<div
className={`${imageHeightClass} bg-opacity-80 skeleton rounded-none`}
></div>
)}
{show.icon && (
<div className="absolute top-0 left-0 right-0 bottom-0 rounded-t-xl flex items-center justify-center rounded-md">
<LinkIcon link={link} />
</div>
)}
{show.preserved_formats &&
link.type === "url" &&
atLeastOneFormatAvailable(link) && (
<div className="absolute bottom-0 right-0 m-2 bg-base-200 bg-opacity-60 px-1 rounded-md">
<LinkFormats link={link} />
</div>
)}
</div>
<Separator />
</div>
)}
<div className="flex flex-col justify-between h-full min-h-11">
<div className="p-3 flex flex-col gap-2">
{show.name && (
<p className="truncate w-full text-primary text-sm">
{unescapeString(link.name)}
</p>
)}
{show.link && <LinkTypeBadge link={link} />}
</div>
{(show.collection || show.date) && (
<div>
<Separator className="mb-1" />
<div className="flex justify-between items-center text-xs text-neutral px-3 pb-1 gap-2">
{show.collection && !isPublicRoute && (
<div className="cursor-pointer truncate">
<LinkCollection link={link} collection={collection} />
</div>
)}
{show.date && <LinkDate link={link} />}
</div>
</div>
)}
</div>
</div>
{/* Overlay on hover */}
<div className="absolute pointer-events-none top-0 left-0 right-0 bottom-0 bg-base-100 bg-opacity-0 group-hover:bg-opacity-20 group-focus-within:opacity-20 rounded-xl duration-100"></div>
<LinkActions
link={link}
collection={collection}
linkModal={linkModal}
setLinkModal={(e) => setLinkModal(e)}
className="absolute top-3 right-3 group-hover:opacity-100 group-focus-within:opacity-100 opacity-0 duration-100 text-neutral z-20"
/>
{!isPublicRoute && <LinkPin link={link} />}
</div>
</div>
);
}

View File

@@ -0,0 +1,49 @@
import Icon from "@/components/Icon";
import {
CollectionIncludingMembersAndLinkCount,
LinkIncludingShortenedCollectionAndTags,
} from "@linkwarden/types";
import { IconWeight } from "@phosphor-icons/react";
import Link from "next/link";
import { useRouter } from "next/router";
import React from "react";
export default function LinkCollection({
link,
collection,
}: {
link: LinkIncludingShortenedCollectionAndTags;
collection: CollectionIncludingMembersAndLinkCount;
}) {
const router = useRouter();
const isPublicRoute = router.pathname.startsWith("/public") ? true : false;
return !isPublicRoute && collection?.name ? (
<>
<Link
href={`/collections/${link.collection.id}`}
onClick={(e) => {
e.stopPropagation();
}}
className="flex items-center gap-1 max-w-full w-fit hover:opacity-70 duration-100 select-none"
title={collection?.name}
>
{link.collection.icon ? (
<Icon
icon={link.collection.icon}
size={20}
weight={(link.collection.iconWeight || "regular") as IconWeight}
color={link.collection.color}
/>
) : (
<i
className="bi-folder-fill text-lg"
style={{ color: link.collection.color }}
></i>
)}
<p className="truncate">{collection?.name}</p>
</Link>
</>
) : null;
}

View File

@@ -1,4 +1,4 @@
import { LinkIncludingShortenedCollectionAndTags } from "@/types/global";
import { LinkIncludingShortenedCollectionAndTags } from "@linkwarden/types";
import React from "react";
export default function LinkDate({
@@ -16,7 +16,7 @@ export default function LinkDate({
return (
<div className="flex items-center gap-1 text-neutral min-w-fit">
<i className="bi-calendar3 text-lg"></i>
<i className="bi-calendar3 text-"></i>
<p>{formattedDate}</p>
</div>
);

View File

@@ -0,0 +1,95 @@
import { formatAvailable } from "@linkwarden/lib/formatStats";
import {
ArchivedFormat,
LinkIncludingShortenedCollectionAndTags,
} from "@linkwarden/types";
import { useTranslation } from "next-i18next";
import Link from "next/link";
import { useRouter } from "next/router";
export default function LinkFormats({
link,
}: {
link: LinkIncludingShortenedCollectionAndTags;
}) {
const { t } = useTranslation();
const router = useRouter();
let isPublic = router.pathname.startsWith("/public") ? true : undefined;
return (
<div className="flex gap-1 text-neutral">
{formatAvailable(link, "monolith") && (
<Link
href={`${isPublic ? "/public" : ""}/preserved/${link?.id}?format=${
ArchivedFormat.monolith
}`}
target="_blank"
onClick={(e) => {
e.stopPropagation();
}}
className="hover:opacity-70 duration-100"
>
<i
className="bi-filetype-html text-md leading-none"
title={t("webpage")}
></i>
</Link>
)}
{formatAvailable(link, "image") && (
<Link
href={`${isPublic ? "/public" : ""}/preserved/${link?.id}?format=${
link?.image?.endsWith("png")
? ArchivedFormat.png
: ArchivedFormat.jpeg
}`}
target="_blank"
onClick={(e) => {
e.stopPropagation();
}}
className="hover:opacity-70 duration-100"
>
<i
className="bi-file-earmark-image text-md leading-none"
title={t("image")}
></i>
</Link>
)}
{formatAvailable(link, "pdf") && (
<Link
href={`${isPublic ? "/public" : ""}/preserved/${link?.id}?format=${
ArchivedFormat.pdf
}`}
target="_blank"
onClick={(e) => {
e.stopPropagation();
}}
className="hover:opacity-70 duration-100"
>
<i
className="bi-file-earmark-pdf text-md leading-none"
title={t("pdf")}
></i>
</Link>
)}
{formatAvailable(link, "readable") && (
<Link
href={`${isPublic ? "/public" : ""}/preserved/${link?.id}?format=${
ArchivedFormat.readability
}`}
target="_blank"
onClick={(e) => {
e.stopPropagation();
}}
className="hover:opacity-70 duration-100"
>
<i
className="bi-file-earmark-text text-md leading-none"
title={t("readable")}
></i>
</Link>
)}
</div>
);
}

View File

@@ -0,0 +1,106 @@
import { LinkIncludingShortenedCollectionAndTags } from "@linkwarden/types";
import Image from "next/image";
import isValidUrl from "@/lib/shared/isValidUrl";
import React, { useState } from "react";
import Icon from "@/components/Icon";
import { IconWeight } from "@phosphor-icons/react";
import clsx from "clsx";
import oklchVariableToHex from "@/lib/client/oklchVariableToHex";
export default function LinkIcon({
link,
className,
hideBackground,
onClick,
}: {
link: LinkIncludingShortenedCollectionAndTags;
className?: string;
hideBackground?: boolean;
onClick?: Function;
}) {
let iconClasses: string = clsx(
"rounded flex item-center justify-center shadow select-none z-10 w-12 h-12",
!hideBackground &&
"rounded-md backdrop-blur-xl bg-white/30 dark:bg-black/30 bg-opacity-50 p-1",
className
);
const url =
isValidUrl(link.url || "") && link.url ? new URL(link.url) : undefined;
const [faviconLoaded, setFaviconLoaded] = useState(false);
return (
<div onClick={() => onClick && onClick()}>
{link.icon ? (
<div className={iconClasses}>
<Icon
icon={link.icon}
size={30}
weight={(link.iconWeight || "regular") as IconWeight}
color={link.color || oklchVariableToHex("--p")}
className="m-auto"
/>
</div>
) : link.type === "url" && url ? (
<>
<Image
src={`https://t2.gstatic.com/faviconV2?client=SOCIAL&type=FAVICON&fallback_opts=TYPE,SIZE,URL&url=${link.url}&size=64`}
width={64}
height={64}
alt=""
className={clsx(
iconClasses,
faviconLoaded ? "" : "absolute opacity-0"
)}
draggable="false"
onLoadingComplete={() => setFaviconLoaded(true)}
onError={() => setFaviconLoaded(false)}
/>
{!faviconLoaded && (
<LinkPlaceholderIcon
iconClasses={iconClasses}
icon="bi-link-45deg"
/>
)}
</>
) : link.type === "pdf" ? (
<LinkPlaceholderIcon
iconClasses={iconClasses}
icon="bi-file-earmark-pdf"
/>
) : link.type === "image" ? (
<LinkPlaceholderIcon
iconClasses={iconClasses}
icon="bi-file-earmark-image"
/>
) : // : link.type === "monolith" ? (
// <LinkPlaceholderIcon
// iconClasses={iconClasses + dimension}
// size={size}
// icon="bi-filetype-html"
// />
// )
undefined}
</div>
);
}
const LinkPlaceholderIcon = ({
iconClasses,
icon,
}: {
iconClasses: string;
icon: string;
}) => {
return (
<div
className={clsx(
iconClasses,
"aspect-square text-4xl text-[oklch(var(--p))]"
)}
>
<i className={`${icon} m-auto`}></i>
</div>
);
};

View File

@@ -1,7 +1,7 @@
import {
CollectionIncludingMembersAndLinkCount,
LinkIncludingShortenedCollectionAndTags,
} from "@/types/global";
} from "@linkwarden/types";
import { useEffect, useState } from "react";
import useLinkStore from "@/store/links";
import unescapeString from "@/lib/client/unescapeString";
@@ -9,36 +9,51 @@ 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 { cn, isPWA } from "@/lib/utils";
import usePermissions from "@/hooks/usePermissions";
import toast from "react-hot-toast";
import LinkTypeBadge from "./LinkTypeBadge";
import { useTranslation } from "next-i18next";
import { useCollections } from "@/hooks/store/collections";
import { useUser } from "@/hooks/store/user";
import { useLinks } from "@/hooks/store/links";
import { useCollections } from "@linkwarden/router/collections";
import { useUser } from "@linkwarden/router/user";
import { useLinks } from "@linkwarden/router/links";
import useLocalSettingsStore from "@/store/localSettings";
import LinkPin from "./LinkPin";
import { useRouter } from "next/router";
import { atLeastOneFormatAvailable } from "@linkwarden/lib/formatStats";
import LinkFormats from "./LinkFormats";
import openLink from "@/lib/client/openLink";
import { useDraggable } from "@dnd-kit/core";
import useMediaQuery from "@/hooks/useMediaQuery";
type Props = {
link: LinkIncludingShortenedCollectionAndTags;
count: number;
className?: string;
flipDropdown?: boolean;
editMode?: boolean;
};
export default function LinkCardCompact({
link,
flipDropdown,
editMode,
}: Props) {
export default function LinkCardCompact({ link, editMode }: Props) {
const { t } = useTranslation();
const isSmallScreen = useMediaQuery("(max-width: 1023px)");
const { attributes, listeners, setNodeRef, isDragging } = useDraggable({
id: link.id?.toString() ?? "",
data: {
linkId: link.id,
},
disabled: isSmallScreen,
});
const { data: collections = [] } = useCollections();
const { data: user = {} } = useUser();
const { data: user } = useUser();
const { setSelectedLinks, selectedLinks } = useLinkStore();
const {
settings: { show },
} = useLocalSettingsStore();
const { links } = useLinks();
useEffect(() => {
@@ -80,8 +95,6 @@ export default function LinkCardCompact({
const permissions = usePermissions(collection?.id as number);
const [showInfo, setShowInfo] = useState(false);
const selectedStyle = selectedLinks.some(
(selectedLink) => selectedLink.id === link.id
)
@@ -92,12 +105,23 @@ export default function LinkCardCompact({
editMode &&
(permissions === true || permissions?.canCreate || permissions?.canDelete);
const router = useRouter();
let isPublic = router.pathname.startsWith("/public") ? true : undefined;
const [linkModal, setLinkModal] = useState(false);
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`}
ref={setNodeRef}
className={cn(
"rounded-md border relative group items-center flex",
selectedStyle,
!isPWA() ? "hover:bg-base-300 px-2 py-1" : "py-1",
isDragging ? "opacity-30" : "opacity-100",
"duration-200, touch-manipulation select-none"
)}
onClick={() =>
selectable
? handleCheckboxClick(link)
@@ -106,67 +130,57 @@ export default function LinkCardCompact({
: undefined
}
>
{/* {showCheckbox &&
editMode &&
(permissions === true ||
permissions?.canCreate ||
permissions?.canDelete) && (
<input
type="checkbox"
className="checkbox checkbox-primary my-auto mr-2"
checked={selectedLinks.some(
(selectedLink) => selectedLink.id === link.id
)}
onChange={() => handleCheckboxClick(link)}
/>
)} */}
<div
className="flex items-center cursor-pointer w-full"
className="flex items-center cursor-pointer w-full min-h-12"
onClick={() =>
!editMode && window.open(generateLinkHref(link, user), "_blank")
!editMode && openLink(link, user, () => setLinkModal(true))
}
{...attributes}
{...listeners}
>
<div className="shrink-0">
<LinkIcon link={link} className="w-12 h-12 text-4xl" />
</div>
{show.icon && (
<div className="shrink-0">
<LinkIcon link={link} hideBackground />
</div>
)}
<div className="w-[calc(100%-56px)] ml-2">
<p className="line-clamp-1 mr-8 text-primary select-none">
{link.name ? (
unescapeString(link.name)
) : (
<div className="mt-2">
<LinkTypeBadge link={link} />
</div>
)}
</p>
{show.name && (
<div className="flex gap-1 mr-20">
<p className="truncate text-primary">
{unescapeString(link.name)}
</p>
{show.preserved_formats &&
link.type === "url" &&
atLeastOneFormatAvailable(link) && (
<div className="pl-1 inline-block text-lg">
<LinkFormats link={link} />
</div>
)}
</div>
)}
<div className="mt-1 flex flex-col sm:flex-row sm:items-center gap-2 text-xs text-neutral">
<div className="flex items-center gap-x-3 text-neutral flex-wrap">
{collection ? (
{show.link && <LinkTypeBadge link={link} />}
{show.collection && (
<LinkCollection link={link} collection={collection} />
) : undefined}
{link.name && <LinkTypeBadge link={link} />}
<LinkDate link={link} />
)}
{show.date && <LinkDate link={link} />}
</div>
</div>
</div>
</div>
{!isPublic && <LinkPin link={link} />}
<LinkActions
link={link}
collection={collection}
position="top-3 right-3"
flipDropdown={flipDropdown}
// toggleShowInfo={() => setShowInfo(!showInfo)}
// linkInfo={showInfo}
linkModal={linkModal}
setLinkModal={(e) => setLinkModal(e)}
className="absolute top-3 right-3 group-hover:opacity-100 group-focus-within:opacity-100 opacity-0 duration-100 text-neutral z-20"
/>
</div>
<div
className="last:hidden rounded-none"
style={{
borderTop: "1px solid var(--fallback-bc,oklch(var(--bc)/0.1))",
}}
></div>
<div className="last:hidden rounded-none my-0 mx-1 border-t border-base-300 h-[1px]"></div>
</>
);
}

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