Compare commits
1179 Commits
chore/reac
...
v2.12.2
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9aefa3cf3b | ||
|
|
36be3d8772 | ||
|
|
1af9aaf11f | ||
|
|
69b86a473a | ||
|
|
22fde2d367 | ||
|
|
2b63d7e863 | ||
|
|
0aa6b0b4ae | ||
|
|
ee489534ec | ||
|
|
9f9d96edfe | ||
|
|
cf71bb8a8a | ||
|
|
9ed374c4c3 | ||
|
|
1119f80ffc | ||
|
|
0f62beb0ab | ||
|
|
803f344680 | ||
|
|
5f243d4fa2 | ||
|
|
dcd6b1be48 | ||
|
|
fed53fd187 | ||
|
|
e30bcaefe3 | ||
|
|
a81111dfe5 | ||
|
|
ce99562e16 | ||
|
|
c849e0aeda | ||
|
|
6f87e02ebc | ||
|
|
59ef7baeeb | ||
|
|
47068d3352 | ||
|
|
9153958811 | ||
|
|
3adbb10be8 | ||
|
|
5f3bfa4bda | ||
|
|
e5a278bb6a | ||
|
|
4bc3eb4bb5 | ||
|
|
20fbc1552d | ||
|
|
7cf3812e2c | ||
|
|
1ce72e6a03 | ||
|
|
711477743f | ||
|
|
1c55ac571a | ||
|
|
5128efc05c | ||
|
|
2b6f48ad83 | ||
|
|
435a44dd47 | ||
|
|
244f78a686 | ||
|
|
fbb9f7af23 | ||
|
|
c08da0f990 | ||
|
|
495d3111a1 | ||
|
|
c8f63cedc1 | ||
|
|
221930f88d | ||
|
|
58812811d0 | ||
|
|
f2b521d34b | ||
|
|
56b6791621 | ||
|
|
bd26f90738 | ||
|
|
63782f1627 | ||
|
|
078e38c7d8 | ||
|
|
11806563c8 | ||
|
|
6da64e6535 | ||
|
|
577f8600ae | ||
|
|
e6c2bc860f | ||
|
|
c54fbbc985 | ||
|
|
c7d38733ce | ||
|
|
794c2f2657 | ||
|
|
d73f236b36 | ||
|
|
658434afaf | ||
|
|
a191861748 | ||
|
|
eb3f1eeb5b | ||
|
|
c911f132f6 | ||
|
|
89122ccd5c | ||
|
|
ac8dacd570 | ||
|
|
0b942cbb29 | ||
|
|
f8cfe8e556 | ||
|
|
2be7f314bb | ||
|
|
f083b45b78 | ||
|
|
0e7d2ef716 | ||
|
|
581e27e8c6 | ||
|
|
c20f6d8015 | ||
|
|
91dafd40f1 | ||
|
|
d0cf951731 | ||
|
|
c75685c435 | ||
|
|
3bc8bb676e | ||
|
|
63f77b19a9 | ||
|
|
8521f4f1e9 | ||
|
|
a866de6931 | ||
|
|
d661c5c609 | ||
|
|
7b12a82a67 | ||
|
|
374c64ff0f | ||
|
|
7558068f7a | ||
|
|
c03557c080 | ||
|
|
f15b8df6f3 | ||
|
|
91e337ea8c | ||
|
|
65f0075a6f | ||
|
|
a8928e9de4 | ||
|
|
fb7e2286a7 | ||
|
|
f057434c3b | ||
|
|
c7c4959659 | ||
|
|
c6ed7bb8e8 | ||
|
|
7351b5265d | ||
|
|
fcc4b89249 | ||
|
|
04ea06e319 | ||
|
|
57347f2b90 | ||
|
|
35ae395447 | ||
|
|
553680d9eb | ||
|
|
1048989598 | ||
|
|
b5f96f5ed3 | ||
|
|
e22ed9bb3d | ||
|
|
51ee97c7ef | ||
|
|
c25bc658f4 | ||
|
|
3b8a6051e9 | ||
|
|
b8a6c77150 | ||
|
|
a9937d218c | ||
|
|
d3302daa3a | ||
|
|
dac2b4c30a | ||
|
|
28e3e23f8a | ||
|
|
80375843af | ||
|
|
232098191a | ||
|
|
b59ad36192 | ||
|
|
ebdc8b9db2 | ||
|
|
7a76de5726 | ||
|
|
a0ceca443c | ||
|
|
21cb65edbd | ||
|
|
c58f8f3086 | ||
|
|
04419aa9e0 | ||
|
|
e916ea53fc | ||
|
|
20fde711d1 | ||
|
|
652931e5be | ||
|
|
88d4e0203f | ||
|
|
87ae5da6f3 | ||
|
|
6a9a897529 | ||
|
|
92a1c4a5f0 | ||
|
|
1ca2b4e534 | ||
|
|
139f99d050 | ||
|
|
c5bfd20833 | ||
|
|
26ab9584ca | ||
|
|
76056d6b1a | ||
|
|
bc129af07a | ||
|
|
16a83496bc | ||
|
|
e270a6b957 | ||
|
|
ae6dbf7745 | ||
|
|
ee8a6634f0 | ||
|
|
a7f8de0a2e | ||
|
|
1f369401df | ||
|
|
a4375e3b57 | ||
|
|
532f6fb1f1 | ||
|
|
1e1bd8f333 | ||
|
|
c999259102 | ||
|
|
13883e0cbc | ||
|
|
5acfdc2f89 | ||
|
|
8e1767d7d0 | ||
|
|
477f534a17 | ||
|
|
032359a357 | ||
|
|
87c66b81e0 | ||
|
|
8eb3b5ce5d | ||
|
|
ee13d3551d | ||
|
|
52a4441b6f | ||
|
|
04e73942a6 | ||
|
|
6234278c9f | ||
|
|
e39d0b1bbd | ||
|
|
b703eb5654 | ||
|
|
0b296dc557 | ||
|
|
3ba2beb769 | ||
|
|
4b477f82bf | ||
|
|
30ef7863d5 | ||
|
|
b78aa39f79 | ||
|
|
787e4c49a4 | ||
|
|
6e60031e40 | ||
|
|
23148b22d7 | ||
|
|
07945f946d | ||
|
|
702508498e | ||
|
|
289818716c | ||
|
|
75419df40b | ||
|
|
586ef5ed58 | ||
|
|
ee53a170ed | ||
|
|
ea48e797e3 | ||
|
|
b294abd65b | ||
|
|
f7fcbfe635 | ||
|
|
2c01013eb3 | ||
|
|
a36af7d673 | ||
|
|
f4c8030a1b | ||
|
|
0a735cd2f6 | ||
|
|
d870ffecd1 | ||
|
|
360eadb08d | ||
|
|
35eec60ac9 | ||
|
|
dae7d2be3b | ||
|
|
e0abe1df39 | ||
|
|
4ae03168bb | ||
|
|
9a6429b85b | ||
|
|
20997ba8bc | ||
|
|
4fe2ef7134 | ||
|
|
63b6f3e66a | ||
|
|
5c07687e1a | ||
|
|
019bc783a4 | ||
|
|
3dfa12dbbf | ||
|
|
20eecd682e | ||
|
|
de3ca46ef0 | ||
|
|
a77c5c20cd | ||
|
|
822415c695 | ||
|
|
3de6e9965a | ||
|
|
8555d26d99 | ||
|
|
c8c8fb7875 | ||
|
|
64ae49a3a4 | ||
|
|
1bcbf2011d | ||
|
|
d43c4e3e95 | ||
|
|
6253635e78 | ||
|
|
b30f554fa7 | ||
|
|
f80432eda1 | ||
|
|
267a14c535 | ||
|
|
e24e338e67 | ||
|
|
0299f7bd4b | ||
|
|
db4b570577 | ||
|
|
8b5b1ae17e | ||
|
|
566927f6ff | ||
|
|
82d37e00e2 | ||
|
|
9be8b540d0 | ||
|
|
6c01456c76 | ||
|
|
dd6cde5b93 | ||
|
|
a984f8eca2 | ||
|
|
9fe910e205 | ||
|
|
c76ac2f4c7 | ||
|
|
71b1ff7da0 | ||
|
|
2bb1393eca | ||
|
|
44f094f473 | ||
|
|
ef98de61ee | ||
|
|
1897e9c8ce | ||
|
|
659910a76d | ||
|
|
f3e31edb7d | ||
|
|
936f7d9614 | ||
|
|
966c271bd3 | ||
|
|
95c243df18 | ||
|
|
89efd237fe | ||
|
|
899426772b | ||
|
|
55582433c6 | ||
|
|
395f357fcb | ||
|
|
a14485c6dd | ||
|
|
2351a83c48 | ||
|
|
e761c1d17a | ||
|
|
ea01ab7b0f | ||
|
|
583bc077fb | ||
|
|
63ed780bb0 | ||
|
|
5032bc3d13 | ||
|
|
7cec177ef0 | ||
|
|
cb104a3a64 | ||
|
|
3fe98f9346 | ||
|
|
22a311297a | ||
|
|
c9da55034c | ||
|
|
6d7f69929f | ||
|
|
77dd17f051 | ||
|
|
3e015fa76c | ||
|
|
c56259cb72 | ||
|
|
a05fbd7141 | ||
|
|
e00852f5df | ||
|
|
3067b6e2d3 | ||
|
|
a49a57c6ea | ||
|
|
32efa56f77 | ||
|
|
a8c6ef6fa0 | ||
|
|
21fadfe389 | ||
|
|
1e80fb33c4 | ||
|
|
edf72aa042 | ||
|
|
74d5dfb404 | ||
|
|
86971dcead | ||
|
|
9b0c3bf405 | ||
|
|
3ce30ec0f6 | ||
|
|
e08684328a | ||
|
|
fe06ecc3f6 | ||
|
|
42d877e2b5 | ||
|
|
94cba5394f | ||
|
|
70999b83c5 | ||
|
|
d89aa4a4e7 | ||
|
|
c933970cce | ||
|
|
8f10930127 | ||
|
|
30c1a064e0 | ||
|
|
d207c89d8e | ||
|
|
4712dc9b26 | ||
|
|
1203f62ab3 | ||
|
|
c1df0db729 | ||
|
|
ee5814a0e4 | ||
|
|
126d98d1b8 | ||
|
|
ebbe68dc9c | ||
|
|
5a763bdce0 | ||
|
|
3b36cf09f4 | ||
|
|
fb017a7655 | ||
|
|
b462531da8 | ||
|
|
acbefe4b6e | ||
|
|
e5296dd5c9 | ||
|
|
a8295f94b8 | ||
|
|
8607c066b8 | ||
|
|
63bc36d5ef | ||
|
|
74a671d165 | ||
|
|
b95c4b2d97 | ||
|
|
9a4f74e4a9 | ||
|
|
e43fd9359b | ||
|
|
be011955b5 | ||
|
|
5660093f1a | ||
|
|
a7b13e27f9 | ||
|
|
f25edb1302 | ||
|
|
592254daba | ||
|
|
395a7a58aa | ||
|
|
7fcd73b61b | ||
|
|
b091e133a7 | ||
|
|
5c8e339864 | ||
|
|
35808ef03f | ||
|
|
1fc7f436a1 | ||
|
|
2e15d43e4b | ||
|
|
8b25a54b26 | ||
|
|
6c4f694f17 | ||
|
|
a6370dd24b | ||
|
|
873f51379d | ||
|
|
701824dd18 | ||
|
|
e39876790b | ||
|
|
db7135abdf | ||
|
|
0b89ab3e35 | ||
|
|
707ec84ee2 | ||
|
|
ba03f99666 | ||
|
|
6cbf9c7153 | ||
|
|
b230c12f61 | ||
|
|
f3cd1845b7 | ||
|
|
580c87880b | ||
|
|
52a10aba1e | ||
|
|
5dcf73570e | ||
|
|
5cffa0bd4f | ||
|
|
6a8bb8b6e8 | ||
|
|
3e3a368131 | ||
|
|
6b7fca78e1 | ||
|
|
d356fc9fd5 | ||
|
|
f198cc5923 | ||
|
|
4e50ead03e | ||
|
|
c716a986a3 | ||
|
|
fcf9548552 | ||
|
|
918d0d46b2 | ||
|
|
a57ec28f3d | ||
|
|
fc4ba6a1fc | ||
|
|
b05c96360f | ||
|
|
37f13af8aa | ||
|
|
1e24580010 | ||
|
|
69c713bf50 | ||
|
|
e9853bc6f5 | ||
|
|
dbb055699a | ||
|
|
da3d4e2c0a | ||
|
|
27d88c2218 | ||
|
|
b59ccfffa1 | ||
|
|
4bc0476e38 | ||
|
|
eba81441ea | ||
|
|
4bd91f0b95 | ||
|
|
134757648d | ||
|
|
86a5d0f965 | ||
|
|
8a838f382a | ||
|
|
dd2361d1cf | ||
|
|
a251e5e526 | ||
|
|
1e8a0fd1c9 | ||
|
|
d4cc066c76 | ||
|
|
a1f2f3484b | ||
|
|
1339b5d625 | ||
|
|
40abb89602 | ||
|
|
0a8d48a07a | ||
|
|
b3530225f4 | ||
|
|
78726a2f04 | ||
|
|
c44f044505 | ||
|
|
8508360dc7 | ||
|
|
c16b1c621c | ||
|
|
e098fe1c39 | ||
|
|
7f8c856f6c | ||
|
|
8b1a88f326 | ||
|
|
9ced58eae8 | ||
|
|
3b2e5089d4 | ||
|
|
3822475fa4 | ||
|
|
929e23850b | ||
|
|
3645c6f0ff | ||
|
|
1ad44a81a3 | ||
|
|
d4a808e808 | ||
|
|
3953bb000c | ||
|
|
2be900a3f0 | ||
|
|
92d335ddbe | ||
|
|
f5633089f9 | ||
|
|
1b6f8f1fc8 | ||
|
|
5cd0fbdf6f | ||
|
|
05a20d9279 | ||
|
|
91a5c548d8 | ||
|
|
95f3ae382a | ||
|
|
6e3354ff0b | ||
|
|
9d92e0103c | ||
|
|
a57fc3f1c2 | ||
|
|
cc3a719611 | ||
|
|
16107c8369 | ||
|
|
43f6314ce2 | ||
|
|
5884687abc | ||
|
|
835fe3de17 | ||
|
|
f8839dbdd2 | ||
|
|
b0987581c0 | ||
|
|
1a02ba64a8 | ||
|
|
0ed87d7ffc | ||
|
|
ddd47d74e3 | ||
|
|
213b0aafee | ||
|
|
396e41a232 | ||
|
|
e04c256bd3 | ||
|
|
2023d6984f | ||
|
|
43ebd72aca | ||
|
|
572fa267d0 | ||
|
|
846a233639 | ||
|
|
a6e3ae1de5 | ||
|
|
c0159e5a27 | ||
|
|
ee1785ca6e | ||
|
|
7bf4db9b25 | ||
|
|
803a97b7e0 | ||
|
|
87715b8f62 | ||
|
|
276a2a30c8 | ||
|
|
bbad712e0d | ||
|
|
32f481f65b | ||
|
|
b7e260e180 | ||
|
|
df89364c0d | ||
|
|
77731dcf8a | ||
|
|
292fadaa1e | ||
|
|
fc801f98a9 | ||
|
|
a005e05d72 | ||
|
|
6124062a85 | ||
|
|
1a69058c7a | ||
|
|
4580f8cd26 | ||
|
|
63050ae7c2 | ||
|
|
82f1bd943e | ||
|
|
6a8ce9614b | ||
|
|
60bb97e4ca | ||
|
|
c9f2799618 | ||
|
|
96b508c41a | ||
|
|
a9206ca6c9 | ||
|
|
441e97e4d9 | ||
|
|
a918d2c960 | ||
|
|
c9860c535b | ||
|
|
c2a660fd50 | ||
|
|
1790638012 | ||
|
|
c9c3941688 | ||
|
|
9f376a633c | ||
|
|
fa0c08a1b2 | ||
|
|
4e0d7f5d8e | ||
|
|
59b6b7228e | ||
|
|
352389958c | ||
|
|
6c75f3ee58 | ||
|
|
792d03f906 | ||
|
|
82f4921038 | ||
|
|
ff497b5f66 | ||
|
|
e012c8953c | ||
|
|
06d8cf3cb8 | ||
|
|
7ce78fc314 | ||
|
|
de5a43cea4 | ||
|
|
6e47cc6897 | ||
|
|
62b1bcbc59 | ||
|
|
8718b1acfe | ||
|
|
f36d36dec7 | ||
|
|
8b6ded9179 | ||
|
|
4333d2686c | ||
|
|
78c1f6246f | ||
|
|
941bc04fc8 | ||
|
|
cfa36fc8da | ||
|
|
9388073cbf | ||
|
|
90eeba5191 | ||
|
|
95846c1d09 | ||
|
|
31b8092472 | ||
|
|
2a62c1ee1a | ||
|
|
5a028e98e3 | ||
|
|
8ed5147762 | ||
|
|
a20c4a67a8 | ||
|
|
bf8ac7d801 | ||
|
|
2971871a51 | ||
|
|
834714a941 | ||
|
|
08523b2234 | ||
|
|
479fea3ea4 | ||
|
|
a34f1d7308 | ||
|
|
b18ce2239a | ||
|
|
17f32152c3 | ||
|
|
47c711fea6 | ||
|
|
4423a30a72 | ||
|
|
cefe5e9e1b | ||
|
|
d30fb4645e | ||
|
|
ae099dce4f | ||
|
|
7c740f01d2 | ||
|
|
2b8964ca64 | ||
|
|
048842efa4 | ||
|
|
29d90db991 | ||
|
|
c99067ac37 | ||
|
|
4a66e1ec9c | ||
|
|
1211b6b1ef | ||
|
|
fbb1ce9687 | ||
|
|
3959a42a30 | ||
|
|
759cb15148 | ||
|
|
8ecd35acfe | ||
|
|
db9ea8eef4 | ||
|
|
b32151eb7e | ||
|
|
d377fa6eb0 | ||
|
|
4b958faf7e | ||
|
|
6981ad6d7a | ||
|
|
2d97f3a138 | ||
|
|
e668b67a3d | ||
|
|
41eb7df457 | ||
|
|
7725a5dd8b | ||
|
|
37588acc0d | ||
|
|
6ad2511fbc | ||
|
|
f3cb12c962 | ||
|
|
99fa0b1d8b | ||
|
|
9db7ff329a | ||
|
|
6bdf0f1c91 | ||
|
|
cd23f10480 | ||
|
|
bea11edbe8 | ||
|
|
d565958e5f | ||
|
|
7222b19745 | ||
|
|
b9be960c7e | ||
|
|
b337045ab2 | ||
|
|
14db14ff07 | ||
|
|
6ca2774b28 | ||
|
|
c3d702ad53 | ||
|
|
921d8a3718 | ||
|
|
2b258680b1 | ||
|
|
82f5a7026f | ||
|
|
daef130728 | ||
|
|
a181b43529 | ||
|
|
968bd04b40 | ||
|
|
46c20af530 | ||
|
|
cfb4081112 | ||
|
|
61ca5cf3e5 | ||
|
|
cecbf694f5 | ||
|
|
0e122c7485 | ||
|
|
c6713f67f4 | ||
|
|
dc8e763b76 | ||
|
|
2a52a0c79f | ||
|
|
08e1f499e1 | ||
|
|
63196e7b99 | ||
|
|
7cae35ce8d | ||
|
|
369b3d6207 | ||
|
|
3762d971e9 | ||
|
|
f3d8a3cc95 | ||
|
|
56efeecbfd | ||
|
|
be8aef987a | ||
|
|
4b9de5cd96 | ||
|
|
59949e21ee | ||
|
|
6fda674529 | ||
|
|
4318207c39 | ||
|
|
c48ad18379 | ||
|
|
d647c60f04 | ||
|
|
f83a9e899c | ||
|
|
b7c0cef8de | ||
|
|
14340afb99 | ||
|
|
b903a12f8d | ||
|
|
c6f7c18441 | ||
|
|
6d334be82e | ||
|
|
195cb99c90 | ||
|
|
2ebd311e0e | ||
|
|
1da1f17ea3 | ||
|
|
bc36513952 | ||
|
|
b04ab898d7 | ||
|
|
778dd764f6 | ||
|
|
64f8922741 | ||
|
|
c8ed9ac72d | ||
|
|
6cfd26da32 | ||
|
|
b56cf3faa4 | ||
|
|
f7526df008 | ||
|
|
bbeba7f50f | ||
|
|
7e5fa3eacd | ||
|
|
89826bd721 | ||
|
|
fb819b6142 | ||
|
|
2e131403d4 | ||
|
|
83db594fde | ||
|
|
83b002d585 | ||
|
|
7207259cdf | ||
|
|
7c647ae02d | ||
|
|
0630ea536e | ||
|
|
f2f73fc894 | ||
|
|
7887f55dd0 | ||
|
|
5ba759bb41 | ||
|
|
f325be0364 | ||
|
|
f3c8647ff2 | ||
|
|
ceb6b8f8e7 | ||
|
|
94d953d449 | ||
|
|
7f74fd75c9 | ||
|
|
4151b37f9f | ||
|
|
1b45286aaf | ||
|
|
d578fdc0c4 | ||
|
|
2f2747cfc8 | ||
|
|
f254bd85b5 | ||
|
|
a321d12307 | ||
|
|
0e5e3ea00e | ||
|
|
6599fd7f5a | ||
|
|
225288c742 | ||
|
|
9bf77e849f | ||
|
|
44ae6d0dbf | ||
|
|
3684204a03 | ||
|
|
5d17b628cc | ||
|
|
197a5b3b74 | ||
|
|
278b674ea7 | ||
|
|
78e8078d6b | ||
|
|
2f05dbad5d | ||
|
|
0987475e41 | ||
|
|
41e34ba4ec | ||
|
|
dd78c1570f | ||
|
|
5bf90c56de | ||
|
|
35643faf85 | ||
|
|
72c5a05324 | ||
|
|
0de7988b29 | ||
|
|
43d5f0a205 | ||
|
|
d703ff072c | ||
|
|
6f80fa62d2 | ||
|
|
34b914b91f | ||
|
|
c6c8dab5db | ||
|
|
d4bbdebe31 | ||
|
|
2f5c431fa7 | ||
|
|
6d92ce64bd | ||
|
|
6c006bb748 | ||
|
|
d2cb7604fa | ||
|
|
dd061e9dc8 | ||
|
|
63c50d96d7 | ||
|
|
1360a03eb5 | ||
|
|
902c724f39 | ||
|
|
1677e5e0ab | ||
|
|
1989510aac | ||
|
|
ef3cf4bcfa | ||
|
|
645df6c0aa | ||
|
|
dfad34c3dd | ||
|
|
f5abe4e1a2 | ||
|
|
79e447c58d | ||
|
|
bc013e7819 | ||
|
|
12844f2529 | ||
|
|
92a66933ce | ||
|
|
bc01b6d4a9 | ||
|
|
23650bb684 | ||
|
|
140ee8c65d | ||
|
|
7c153d841a | ||
|
|
e06f52642a | ||
|
|
874a909d1c | ||
|
|
bbe702925b | ||
|
|
49262d52cf | ||
|
|
5a468b44a1 | ||
|
|
6678f9c971 | ||
|
|
b5a27968de | ||
|
|
4f1c6855aa | ||
|
|
50c7fcd012 | ||
|
|
de63ba523e | ||
|
|
5e2362ea62 | ||
|
|
db94b01859 | ||
|
|
9bd821baa9 | ||
|
|
b69b535155 | ||
|
|
0bee36eced | ||
|
|
d044a6cbba | ||
|
|
8095010835 | ||
|
|
bd21c3d8c3 | ||
|
|
2bacf6e07d | ||
|
|
5c73d7097f | ||
|
|
f77236d49e | ||
|
|
1152571e49 | ||
|
|
eda41d7132 | ||
|
|
dd58a8565a | ||
|
|
64d20df6f1 | ||
|
|
cd3f5f7e70 | ||
|
|
fca04dd0a0 | ||
|
|
e024b324ed | ||
|
|
f012eaee33 | ||
|
|
794f8f07fa | ||
|
|
0a9aa774cc | ||
|
|
ebbc23f581 | ||
|
|
bf7722aa2e | ||
|
|
33637a8bca | ||
|
|
80962c5df6 | ||
|
|
7fa9621de1 | ||
|
|
d17fed9c85 | ||
|
|
81536e61f7 | ||
|
|
c790781315 | ||
|
|
e7a067b358 | ||
|
|
4156126a71 | ||
|
|
95f456bbb6 | ||
|
|
102cd1a6cf | ||
|
|
395dc5c0cb | ||
|
|
7f0c8f4bbf | ||
|
|
671f27ccde | ||
|
|
c90b53376d | ||
|
|
13ed7b6cdc | ||
|
|
3c71301cbc | ||
|
|
833a871731 | ||
|
|
7b3a4e48c1 | ||
|
|
76112534f6 | ||
|
|
adafe36cd7 | ||
|
|
0123791b8a | ||
|
|
81f150c0ce | ||
|
|
9177ad6a72 | ||
|
|
d360f612e2 | ||
|
|
b0496e2e65 | ||
|
|
c5751386fa | ||
|
|
406647d687 | ||
|
|
5949a0965c | ||
|
|
c015bed8a7 | ||
|
|
504af53f18 | ||
|
|
d822db440b | ||
|
|
181e1009e5 | ||
|
|
db16044949 | ||
|
|
1626a277e0 | ||
|
|
616efffed2 | ||
|
|
c9dd143d59 | ||
|
|
0a4d62491d | ||
|
|
9e417b7f35 | ||
|
|
81e9b27683 | ||
|
|
942ae4af99 | ||
|
|
f7e9119450 | ||
|
|
d71216a908 | ||
|
|
f61ce5563d | ||
|
|
575d91832e | ||
|
|
15fe4575f9 | ||
|
|
9ba9b06ae3 | ||
|
|
cf16344aef | ||
|
|
29ed07c74a | ||
|
|
493e0e2f6b | ||
|
|
d76d99844c | ||
|
|
533f29706e | ||
|
|
88cf45c7c2 | ||
|
|
b100129d80 | ||
|
|
a267d4ed3a | ||
|
|
9d8d5f0fa0 | ||
|
|
aa6b068d92 | ||
|
|
4b230e01d3 | ||
|
|
d140e2109f | ||
|
|
d5703ba70e | ||
|
|
5f12046a49 | ||
|
|
edad55d608 | ||
|
|
fac46de09c | ||
|
|
007de56cd3 | ||
|
|
8efdf6d87b | ||
|
|
69225e0642 | ||
|
|
0aa23e27b3 | ||
|
|
3bf2daddd1 | ||
|
|
71644e6e4e | ||
|
|
6e7f92c046 | ||
|
|
8d4504262b | ||
|
|
20224c835a | ||
|
|
c3873b030f | ||
|
|
cf8a202afa | ||
|
|
c79b1e6492 | ||
|
|
e21e3ecaae | ||
|
|
b84840e12c | ||
|
|
f8a130ae6e | ||
|
|
852de0d587 | ||
|
|
eecfb112e3 | ||
|
|
81a35655e9 | ||
|
|
c45b44cdbc | ||
|
|
ecc48f8fe2 | ||
|
|
e1c4f85e53 | ||
|
|
f8c96b493c | ||
|
|
63904f6d41 | ||
|
|
9083d9a01b | ||
|
|
806cba8110 | ||
|
|
ae24358a77 | ||
|
|
f0dfd5568e | ||
|
|
9d17600124 | ||
|
|
6a72d7894b | ||
|
|
00c33d48f0 | ||
|
|
6573c683f6 | ||
|
|
5412545c6f | ||
|
|
eee06e2be9 | ||
|
|
c3e8097aac | ||
|
|
2d97feef17 | ||
|
|
74c0a40622 | ||
|
|
56741b123b | ||
|
|
e8c6cc45f4 | ||
|
|
d0c999655c | ||
|
|
05594e6507 | ||
|
|
b59663ea91 | ||
|
|
8d2029a19d | ||
|
|
cee2f0a759 | ||
|
|
032f96191e | ||
|
|
e87bfc83bf | ||
|
|
aa0c7d64f4 | ||
|
|
b8a1839fb6 | ||
|
|
629574c6b2 | ||
|
|
1bb7d3cc5c | ||
|
|
6b811c3e7d | ||
|
|
a941eec569 | ||
|
|
5ed79f0f5b | ||
|
|
010ca8eeae | ||
|
|
5840ffc620 | ||
|
|
14754a23f1 | ||
|
|
1a501b5365 | ||
|
|
3bc9bbf074 | ||
|
|
09a52dd260 | ||
|
|
84e99b55c9 | ||
|
|
22c4fbf613 | ||
|
|
0e1b51177b | ||
|
|
44499c1277 | ||
|
|
9d986356a7 | ||
|
|
1d854e16aa | ||
|
|
02c02fc3b9 | ||
|
|
99bdc7d55e | ||
|
|
62c7bbbb74 | ||
|
|
dfb31ab1b3 | ||
|
|
e0c0b76eb0 | ||
|
|
9bc261bc85 | ||
|
|
b2d2e23539 | ||
|
|
04c69bb05f | ||
|
|
0cc1fd8407 | ||
|
|
848e0bf50e | ||
|
|
c3981c7fff | ||
|
|
ec7d6f4a6b | ||
|
|
9d8b602839 | ||
|
|
f0f57fb1a9 | ||
|
|
88820361e9 | ||
|
|
be47c78e4d | ||
|
|
fa059d1b00 | ||
|
|
bcfec38adf | ||
|
|
0344467cb7 | ||
|
|
454ed7b7eb | ||
|
|
51da37a22f | ||
|
|
6edbc4f438 | ||
|
|
899ddafd90 | ||
|
|
755721f1c2 | ||
|
|
0b20f61913 | ||
|
|
e9cf93d769 | ||
|
|
f94d10a3d3 | ||
|
|
6a95f6efdc | ||
|
|
150eeb9f11 | ||
|
|
fe77625289 | ||
|
|
63e7377df4 | ||
|
|
458eae9a3c | ||
|
|
7ef2afae7f | ||
|
|
a1d02f110d | ||
|
|
378fec06bb | ||
|
|
e04997c8c4 | ||
|
|
aecb90b4a4 | ||
|
|
dac6ec966c | ||
|
|
582159f454 | ||
|
|
cf02b7a099 | ||
|
|
7b4d324852 | ||
|
|
8932b9929a | ||
|
|
6b416f23f0 | ||
|
|
fb25cf5c75 | ||
|
|
c8f33f4800 | ||
|
|
95f818959b | ||
|
|
a0aabde322 | ||
|
|
0e3ca5b51f | ||
|
|
a733cc69a3 | ||
|
|
cbc88ebcb2 | ||
|
|
c3b78a8f82 | ||
|
|
8ad9ab7755 | ||
|
|
4bf220c786 | ||
|
|
ed8f2d3777 | ||
|
|
5b87799fdd | ||
|
|
97abe5de0c | ||
|
|
731b259329 | ||
|
|
63cef8e6b0 | ||
|
|
18db677c29 | ||
|
|
6071aa617f | ||
|
|
f270adbffa | ||
|
|
6259048431 | ||
|
|
40f4a5acd9 | ||
|
|
4240d37d77 | ||
|
|
0abe065c0c | ||
|
|
346f41a12c | ||
|
|
2ffbede170 | ||
|
|
c148c2b953 | ||
|
|
a872f218fb | ||
|
|
ff1f87cb35 | ||
|
|
94b143c91a | ||
|
|
52a11040f6 | ||
|
|
e9c43d75fe | ||
|
|
47b226cf1f | ||
|
|
266f834018 | ||
|
|
c9885c0b73 | ||
|
|
ec885a7db2 | ||
|
|
5665fbb412 | ||
|
|
9e933bd630 | ||
|
|
3572e101fb | ||
|
|
fb06549330 | ||
|
|
991f12566f | ||
|
|
160845319f | ||
|
|
bff5a7ae9a | ||
|
|
6e01c135dc | ||
|
|
f32e5a8d05 | ||
|
|
e9003e5c45 | ||
|
|
9065756686 | ||
|
|
881df93c02 | ||
|
|
ed7fab0473 | ||
|
|
38d054e143 | ||
|
|
5e89658a11 | ||
|
|
d964e02ba1 | ||
|
|
a36eb23096 | ||
|
|
8f875d15b0 | ||
|
|
9f660ff70f | ||
|
|
e6f43bbbfa | ||
|
|
1609868149 | ||
|
|
9075618e00 | ||
|
|
179cd18ac5 | ||
|
|
5a5fa9ed6c | ||
|
|
5279d94b8c | ||
|
|
5c3848e833 | ||
|
|
27d7bbabb3 | ||
|
|
8ff1346bf1 | ||
|
|
71119d511e | ||
|
|
b7eb8f2c2f | ||
|
|
3d54cc05a4 | ||
|
|
8973bdd94e | ||
|
|
1af37f3619 | ||
|
|
5303d63e4b | ||
|
|
05a30e1ec6 | ||
|
|
b1a55785b5 | ||
|
|
24b47e9d4b | ||
|
|
34d19f9dbe | ||
|
|
3a70e138b5 | ||
|
|
47367c44c1 | ||
|
|
e1a31481ad | ||
|
|
95dddd7da0 | ||
|
|
1a949ecdc6 | ||
|
|
2e6f1c207c | ||
|
|
6aa0fa9465 | ||
|
|
8677df0340 | ||
|
|
125f6ac619 | ||
|
|
89ecf5c529 | ||
|
|
fa78d6057f | ||
|
|
cfc28be898 | ||
|
|
c8efd4f9db | ||
|
|
ada4e53b46 | ||
|
|
91494b0188 | ||
|
|
e9fd6ec4d5 | ||
|
|
f08f4058dc | ||
|
|
d60200205a | ||
|
|
de38eb2963 | ||
|
|
f22dd4535d | ||
|
|
043589b301 | ||
|
|
4556827d79 | ||
|
|
98ebd6d7bc | ||
|
|
0a3ca4a1d4 | ||
|
|
106410f55a | ||
|
|
1ffe1b68a9 | ||
|
|
91ab0e609b | ||
|
|
cbb7a666cd | ||
|
|
e8cf14334f | ||
|
|
019790791b | ||
|
|
e41ba2668f | ||
|
|
66a09fdc4b | ||
|
|
e50143ca7e | ||
|
|
162b120e55 | ||
|
|
b4dd47aa37 | ||
|
|
256c232a85 | ||
|
|
b7ddf22662 | ||
|
|
5f60e9833e | ||
|
|
ceed23ff51 | ||
|
|
a4c83dc82f | ||
|
|
46f81ebf25 | ||
|
|
0ac5009a4a | ||
|
|
6842da4283 | ||
|
|
78ecf3ddb5 | ||
|
|
e39645e135 | ||
|
|
836360f99d | ||
|
|
9c9fd969bc | ||
|
|
213105942b | ||
|
|
0b7acb35b7 | ||
|
|
9b58ea5c98 | ||
|
|
c85c3bb0d7 | ||
|
|
7ca574b76f | ||
|
|
8593df4673 | ||
|
|
ddc2079f4b | ||
|
|
0de5caffa1 | ||
|
|
b14e77bdf9 | ||
|
|
8d366ae7d8 | ||
|
|
a18938ba2a | ||
|
|
6eac8423f8 | ||
|
|
cbf93dcf06 | ||
|
|
2993347dc7 | ||
|
|
cc45c8fc3e | ||
|
|
d5602a09cd | ||
|
|
736e98ac7d | ||
|
|
7eaff332a9 | ||
|
|
7931e2d7b6 | ||
|
|
ac3888f9b3 | ||
|
|
ac8add8c5d | ||
|
|
a6a0f6965b | ||
|
|
b2c5c3c6dd | ||
|
|
4555874725 | ||
|
|
0f5b70eda7 | ||
|
|
d1c3748681 | ||
|
|
2524139113 | ||
|
|
6c2b86fc4b | ||
|
|
d0e0526655 | ||
|
|
43e94ebd0b | ||
|
|
aeafe6e15d | ||
|
|
5ec221d87d | ||
|
|
d6d6442bc4 | ||
|
|
d12d12518e | ||
|
|
02ced62832 | ||
|
|
4febe1ace5 | ||
|
|
2e1e94112f | ||
|
|
d86bbcd940 | ||
|
|
eed80ca812 | ||
|
|
394251c1f1 | ||
|
|
68cdde91ad | ||
|
|
1ef286a38c | ||
|
|
508844dd9d | ||
|
|
fa1f9873d5 | ||
|
|
891803547e | ||
|
|
24d45f8e8e | ||
|
|
f95350405c | ||
|
|
665019dc59 | ||
|
|
b09de5a8af | ||
|
|
cfd33e9bd1 | ||
|
|
d3d2d5069e | ||
|
|
cffc74caa4 | ||
|
|
3cd8eadee3 | ||
|
|
d146ec296c | ||
|
|
fb4aa42eef | ||
|
|
f68582e28c | ||
|
|
d042c82cb0 | ||
|
|
8738dd45e9 | ||
|
|
839de18d7a | ||
|
|
2ba0851fee | ||
|
|
d99972a335 | ||
|
|
e071b9eb07 | ||
|
|
eb00d151b7 | ||
|
|
22aaa52b3e | ||
|
|
4541277b28 | ||
|
|
39faece9d7 | ||
|
|
a21b0760de | ||
|
|
04149fe86b | ||
|
|
ff6e71d494 | ||
|
|
5b02c1cfc9 | ||
|
|
1ff13e8aa0 | ||
|
|
eaf4524598 | ||
|
|
a276065288 | ||
|
|
1cf7421b76 | ||
|
|
ed4a334024 | ||
|
|
a5b1952e0d | ||
|
|
01826b1634 | ||
|
|
3b17d4ddfe | ||
|
|
f104fa095f | ||
|
|
b08e6690f3 | ||
|
|
33a654d21a | ||
|
|
e1262142f8 | ||
|
|
0a43279665 | ||
|
|
5491ac74a5 | ||
|
|
bbcfca4cde | ||
|
|
bf9a7d4fa0 | ||
|
|
edf4e489ec | ||
|
|
20c5a20851 | ||
|
|
6f47a20e87 | ||
|
|
384937e210 | ||
|
|
d22d989c91 | ||
|
|
4e0294322f | ||
|
|
75d5061bdf | ||
|
|
0150a9a6e3 | ||
|
|
87b79ffbac | ||
|
|
5a40677191 | ||
|
|
95ce2f30a8 | ||
|
|
e6a0ecbab5 | ||
|
|
e4c9cf8a38 | ||
|
|
eaca3d7453 | ||
|
|
fbe3642be4 | ||
|
|
bc32abbb92 | ||
|
|
38f731f313 | ||
|
|
aaf3590542 | ||
|
|
8bb6e32bfa | ||
|
|
7bd3872195 | ||
|
|
906779010e | ||
|
|
b0f87e8659 | ||
|
|
653b1bc396 | ||
|
|
9b1506a64e | ||
|
|
fb1869ca7a | ||
|
|
5e7835b4d5 | ||
|
|
0a91c47f83 | ||
|
|
dc9db05e75 | ||
|
|
e1149c2733 | ||
|
|
0591d7c134 | ||
|
|
4602269dd8 | ||
|
|
9ae6a22236 | ||
|
|
442da02956 | ||
|
|
dfcc271343 | ||
|
|
43d50dfd1b | ||
|
|
40bb3e6fae | ||
|
|
3e077fa247 | ||
|
|
3de8872f26 | ||
|
|
e9072bba51 | ||
|
|
d20c915970 | ||
|
|
1a378de267 | ||
|
|
d594159c15 | ||
|
|
aee10fa406 | ||
|
|
820d686c37 | ||
|
|
4189062c4c | ||
|
|
1461caf68a | ||
|
|
e7c7fedf8b | ||
|
|
b7adbbc86f | ||
|
|
975716937f | ||
|
|
2d0e52f65b | ||
|
|
e9afe0ef25 | ||
|
|
a38133d618 | ||
|
|
6498ae794b | ||
|
|
0371695eb3 | ||
|
|
9ae9c7c81a | ||
|
|
642374c2e5 | ||
|
|
f368c2aa81 | ||
|
|
fae9e95fa9 | ||
|
|
03639adc22 | ||
|
|
9fe829771d | ||
|
|
ed7b268c2b | ||
|
|
bf1a6efd2e | ||
|
|
6df2e44213 | ||
|
|
ae2324ecd3 | ||
|
|
accbd4cbfa | ||
|
|
5f4e0d4262 | ||
|
|
c072fed99f | ||
|
|
b4a9f917b5 | ||
|
|
078e5ba95f | ||
|
|
495509c888 | ||
|
|
dc388ebba5 | ||
|
|
21578bac8d | ||
|
|
1062e07065 | ||
|
|
2893d3caf2 | ||
|
|
9f74f62330 | ||
|
|
c6e3147bb6 | ||
|
|
1260e8c093 | ||
|
|
5cb4bdced3 | ||
|
|
03b4240b8b | ||
|
|
9a3e82470a | ||
|
|
ee2319996b | ||
|
|
c979adfe69 | ||
|
|
2b83522eaa | ||
|
|
8c738d4a99 | ||
|
|
63678b7f1e | ||
|
|
b73e845299 | ||
|
|
898b126231 | ||
|
|
17d1cb45e3 | ||
|
|
0aad2d9e4b | ||
|
|
c18a5f4162 | ||
|
|
df7814385a | ||
|
|
d568f22e00 | ||
|
|
6bd1c90417 | ||
|
|
a40026040c | ||
|
|
334ad9f3dc | ||
|
|
f944345745 | ||
|
|
6b647573f0 | ||
|
|
d81493e021 | ||
|
|
03f4523d57 | ||
|
|
c24e76adac | ||
|
|
5d26617251 | ||
|
|
0e47ad9920 | ||
|
|
ca45076b6c | ||
|
|
3bf6dcad2f | ||
|
|
23860b8511 | ||
|
|
8758976f8d | ||
|
|
550dbd2bf0 | ||
|
|
04d2b3c6b2 | ||
|
|
cc1c17363b | ||
|
|
7bd0e29538 | ||
|
|
5baf55694c | ||
|
|
193a70c6e8 | ||
|
|
5b430cf31e | ||
|
|
684609a1dd | ||
|
|
ebb2016915 | ||
|
|
c103b66694 | ||
|
|
863bcc3838 | ||
|
|
66b0aacc3f | ||
|
|
299498ffa6 | ||
|
|
c5602dc79f | ||
|
|
0158e58d90 | ||
|
|
602f399119 | ||
|
|
012caab606 | ||
|
|
102690fc10 | ||
|
|
237499fd03 | ||
|
|
9a287d1aef | ||
|
|
299a2331ff | ||
|
|
a1248fe62f | ||
|
|
8f7e0b8d09 | ||
|
|
9d91d2064b | ||
|
|
d631754b50 | ||
|
|
94be3a7448 | ||
|
|
4faf389a2b | ||
|
|
ff31732ba3 | ||
|
|
fa051c0d4d | ||
|
|
02cb93065f | ||
|
|
15a0084fb7 | ||
|
|
c0abf2f411 | ||
|
|
a886437589 | ||
|
|
0b8a9b4310 | ||
|
|
ce1aa5a0ec | ||
|
|
a82c4ef85f | ||
|
|
7036b46084 | ||
|
|
2bba8198b8 | ||
|
|
96a70a9689 | ||
|
|
288fd9df87 | ||
|
|
e79b98d3b0 | ||
|
|
7d43ed52a4 | ||
|
|
614653bf29 | ||
|
|
1b9dafbe47 | ||
|
|
abc93f1bf9 | ||
|
|
c23964a46d | ||
|
|
a76e996fc1 | ||
|
|
abb73f80bd | ||
|
|
e8d0cce58a | ||
|
|
e045c18b7d | ||
|
|
a1f48bbd79 |
51
.env.sample
@@ -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=
|
||||
|
||||
13
.github/ISSUE_TEMPLATE/installation-problem.yml
vendored
Normal 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
@@ -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
@@ -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
|
||||
20
.github/workflows/playwright-tests.yml
vendored
@@ -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
|
||||
|
||||
5
.github/workflows/release-container.yml
vendored
@@ -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
@@ -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
|
||||
@@ -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).
|
||||
61
Dockerfile
@@ -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"]
|
||||
|
||||
93
README.md
@@ -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 you’re highlighting key ideas, jotting down thoughts, or revisiting content long after it’s 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
@@ -0,0 +1,2 @@
|
||||
LINKWARDEN_URL=
|
||||
EXPO_PUBLIC_SHOW_LOGS=
|
||||
42
apps/mobile/.gitignore
vendored
Normal 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
@@ -0,0 +1 @@
|
||||
node-linker=hoisted
|
||||
55
apps/mobile/app.json
Normal 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"
|
||||
}
|
||||
}
|
||||
}
|
||||
61
apps/mobile/app/(tabs)/_layout.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
84
apps/mobile/app/(tabs)/dashboard/[section].tsx
Normal 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: {},
|
||||
}),
|
||||
});
|
||||
86
apps/mobile/app/(tabs)/dashboard/_layout.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
401
apps/mobile/app/(tabs)/dashboard/index.tsx
Normal 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: {},
|
||||
}),
|
||||
});
|
||||
44
apps/mobile/app/(tabs)/links/_layout.tsx
Normal 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",
|
||||
},
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
62
apps/mobile/app/(tabs)/links/index.tsx
Normal 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: {},
|
||||
}),
|
||||
});
|
||||
33
apps/mobile/app/(tabs)/settings/_layout.tsx
Normal 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",
|
||||
},
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
211
apps/mobile/app/(tabs)/settings/index.tsx
Normal 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,
|
||||
},
|
||||
});
|
||||
20
apps/mobile/app/+not-found.tsx
Normal 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
@@ -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>
|
||||
);
|
||||
}
|
||||
98
apps/mobile/app/incoming.tsx
Normal 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
@@ -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" />;
|
||||
}
|
||||
}
|
||||
186
apps/mobile/app/links/[id].tsx
Normal 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
@@ -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>
|
||||
);
|
||||
}
|
||||
BIN
apps/mobile/assets/fonts/SpaceMono-Regular.ttf
Executable file
BIN
apps/mobile/assets/images/adaptive-icon.png
Normal file
|
After Width: | Height: | Size: 17 KiB |
BIN
apps/mobile/assets/images/favicon.png
Normal file
|
After Width: | Height: | Size: 1.4 KiB |
BIN
apps/mobile/assets/images/icon.png
Normal file
|
After Width: | Height: | Size: 22 KiB |
BIN
apps/mobile/assets/images/partial-react-logo.png
Normal file
|
After Width: | Height: | Size: 5.0 KiB |
BIN
apps/mobile/assets/images/react-logo.png
Normal file
|
After Width: | Height: | Size: 6.2 KiB |
BIN
apps/mobile/assets/images/react-logo@2x.png
Normal file
|
After Width: | Height: | Size: 14 KiB |
BIN
apps/mobile/assets/images/react-logo@3x.png
Normal file
|
After Width: | Height: | Size: 21 KiB |
BIN
apps/mobile/assets/images/splash-icon.png
Normal file
|
After Width: | Height: | Size: 17 KiB |
9
apps/mobile/babel.config.js
Normal file
@@ -0,0 +1,9 @@
|
||||
module.exports = function (api) {
|
||||
api.cache(true);
|
||||
return {
|
||||
presets: [
|
||||
["babel-preset-expo", { jsxImportSource: "nativewind" }],
|
||||
"nativewind/babel",
|
||||
],
|
||||
};
|
||||
};
|
||||
72
apps/mobile/components/ActionSheets/AddLinkSheet.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
263
apps/mobile/components/ActionSheets/EditLinkSheet.tsx
Normal 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"],
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
32
apps/mobile/components/ActionSheets/Sheets.tsx
Normal 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 {};
|
||||
35
apps/mobile/components/DashboardItem.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
18
apps/mobile/components/ElementNotSupported.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
18
apps/mobile/components/HapticTab.tsx
Normal 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);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
285
apps/mobile/components/LinkListing.tsx
Normal 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;
|
||||
17
apps/mobile/components/Modals/AddLink.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
84
apps/mobile/components/ui/Button.tsx
Normal 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";
|
||||
32
apps/mobile/components/ui/IconSymbol.ios.tsx
Normal 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,
|
||||
]}
|
||||
/>
|
||||
);
|
||||
}
|
||||
50
apps/mobile/components/ui/IconSymbol.tsx
Normal 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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
20
apps/mobile/components/ui/Input.tsx
Normal 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;
|
||||
22
apps/mobile/components/ui/TabBarBackground.ios.tsx
Normal 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;
|
||||
}
|
||||
6
apps/mobile/components/ui/TabBarBackground.tsx
Normal 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
@@ -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",
|
||||
},
|
||||
};
|
||||
31
apps/mobile/lib/queryPersister.ts
Normal 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
@@ -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");
|
||||
6
apps/mobile/metro.config.js
Normal 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
@@ -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
@@ -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
|
||||
}
|
||||
43
apps/mobile/plugins/with-daynight-transparent-nav.js
Normal 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
@@ -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
@@ -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;
|
||||
3
apps/mobile/styles/global.css
Normal file
@@ -0,0 +1,3 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
39
apps/mobile/tailwind.config.js
Normal 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
@@ -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"
|
||||
]
|
||||
}
|
||||
43
apps/mobile/types/global.ts
Normal 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
@@ -0,0 +1 @@
|
||||
/// <reference types="nativewind/types" />
|
||||
21
apps/web/components.json
Normal 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"
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
@@ -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>
|
||||
@@ -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);
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
50
apps/web/components/ConfirmationModal.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
60
apps/web/components/CopyButton.tsx
Normal 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;
|
||||
23
apps/web/components/DashboardItem.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
398
apps/web/components/DashboardLayoutDropdown.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
242
apps/web/components/DashboardLinks.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
175
apps/web/components/DragNDrop.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
91
apps/web/components/Drawer.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
);
|
||||
}
|
||||
50
apps/web/components/Droppable.tsx
Normal 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;
|
||||
104
apps/web/components/HighlightDrawer.tsx
Normal 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;
|
||||
18
apps/web/components/Icon.tsx
Normal file
@@ -0,0 +1,18 @@
|
||||
import React, { forwardRef } from "react";
|
||||
import * as Icons from "@phosphor-icons/react";
|
||||
|
||||
type Props = {
|
||||
icon: string;
|
||||
} & Icons.IconProps;
|
||||
|
||||
const Icon = forwardRef<SVGSVGElement, Props>(({ icon, ...rest }, ref) => {
|
||||
const IconComponent: any = Icons[icon as keyof typeof Icons];
|
||||
|
||||
if (!IconComponent) {
|
||||
return null;
|
||||
} else return <IconComponent ref={ref} {...rest} />;
|
||||
});
|
||||
|
||||
Icon.displayName = "Icon";
|
||||
|
||||
export default Icon;
|
||||
91
apps/web/components/IconGrid.tsx
Normal 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;
|
||||
79
apps/web/components/IconPicker.tsx
Normal 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;
|
||||
159
apps/web/components/IconPopover.tsx
Normal 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;
|
||||
84
apps/web/components/ImportDropdown.tsx
Normal 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;
|
||||
@@ -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,
|
||||
}}
|
||||
57
apps/web/components/InputSelect/TagSelection.tsx
Normal 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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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 }),
|
||||
@@ -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>
|
||||
) : (
|
||||
661
apps/web/components/LinkDetails.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
293
apps/web/components/LinkListOptions.tsx
Normal 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;
|
||||
201
apps/web/components/LinkViews/LinkComponents/LinkActions.tsx
Normal 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}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
278
apps/web/components/LinkViews/LinkComponents/LinkCard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
95
apps/web/components/LinkViews/LinkComponents/LinkFormats.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
106
apps/web/components/LinkViews/LinkComponents/LinkIcon.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||