mirror of
https://github.com/linkwarden/linkwarden.git
synced 2026-03-03 18:17:02 +00:00
Compare commits
1072 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9edb450b6a | ||
|
|
4fa1f57351 | ||
|
|
f3d30085de | ||
|
|
da8761387f | ||
|
|
c9fd573b31 | ||
|
|
c99f9edd9a | ||
|
|
389a96dadc | ||
|
|
c8b1129e4f | ||
|
|
b9fd802288 | ||
|
|
549299743c | ||
|
|
21b6ab3de4 | ||
|
|
155ca17b55 | ||
|
|
686e3b44e1 | ||
|
|
f13c5e1cfc | ||
|
|
7e34d98bc4 | ||
|
|
e9c1c5217b | ||
|
|
209e0faa1b | ||
|
|
27a86c0b28 | ||
|
|
0198a9148e | ||
|
|
45dc95122a | ||
|
|
8c9cd34ec3 | ||
|
|
6b3dba3faf | ||
|
|
81ae7c64a9 | ||
|
|
d39a0ed5b2 | ||
|
|
ffc9971ce6 | ||
|
|
a8a9ad602f | ||
|
|
bc750bd588 | ||
|
|
e3de382739 | ||
|
|
57601413d4 | ||
|
|
af8a650096 | ||
|
|
b445fde85a | ||
|
|
7bbdec0f85 | ||
|
|
4743aa8144 | ||
|
|
fedd19770e | ||
|
|
6c5253121c | ||
|
|
6536b34c41 | ||
|
|
2c812e11e4 | ||
|
|
13305c06c4 | ||
|
|
3cbbeb55a4 | ||
|
|
6bb261c81a | ||
|
|
dbad316bac | ||
|
|
cc37543324 | ||
|
|
7c9307dd84 | ||
|
|
c794c0814e | ||
|
|
78d6d1c70a | ||
|
|
f79f57ccda | ||
|
|
eb8402448d | ||
|
|
350cdb485a | ||
|
|
8bd3bd3763 | ||
|
|
f2cfbf0b10 | ||
|
|
513c03dcae | ||
|
|
98b7e38139 | ||
|
|
9b5c08655a | ||
|
|
caf706e8ea | ||
|
|
d54f7da5a5 | ||
|
|
ae2e3c80db | ||
|
|
06285ce6d7 | ||
|
|
fdf48abd29 | ||
|
|
59252759f2 | ||
|
|
dd96d80d42 | ||
|
|
f8efbe95e6 | ||
|
|
b4b6edd618 | ||
|
|
d066378076 | ||
|
|
cddfc5dba6 | ||
|
|
4bdcfa0ee7 | ||
|
|
cf84474921 | ||
|
|
95e662358f | ||
|
|
eb31acbc30 | ||
|
|
7a34d836be | ||
|
|
eccf27425c | ||
|
|
3926e566b7 | ||
|
|
bca333be26 | ||
|
|
02a1e3b455 | ||
|
|
5b0c66b5e2 | ||
|
|
7c0c823c41 | ||
|
|
a8d2c55d12 | ||
|
|
37410fcf97 | ||
|
|
0ab4a2d883 | ||
|
|
756b896fe6 | ||
|
|
e3e3611b54 | ||
|
|
4bf65f8ebd | ||
|
|
6956c71aa2 | ||
|
|
e0f357513c | ||
|
|
daeb859990 | ||
|
|
f072bcd0b0 | ||
|
|
a497dc953a | ||
|
|
dcf6d72c01 | ||
|
|
d749487fb6 | ||
|
|
7079355013 | ||
|
|
46762b0d36 | ||
|
|
389e5df117 | ||
|
|
865bff2214 | ||
|
|
33553e22d5 | ||
|
|
1323787294 | ||
|
|
0a812fa72b | ||
|
|
9faf9d844e | ||
|
|
1558854f78 | ||
|
|
ab19df767c | ||
|
|
218fd504bf | ||
|
|
02158d7621 | ||
|
|
9edb42d181 | ||
|
|
8ba370bf62 | ||
|
|
a32934ee9d | ||
|
|
ff5ba2097d | ||
|
|
703f84403e | ||
|
|
f9ec18c51a | ||
|
|
3d0651d4af | ||
|
|
f30bf63c24 | ||
|
|
e2d89a56d6 | ||
|
|
40c3ccca93 | ||
|
|
1e515d5284 | ||
|
|
cb9cdc92c8 | ||
|
|
639f777b8a | ||
|
|
48b7384490 | ||
|
|
3bff1650c7 | ||
|
|
cadea5c654 | ||
|
|
e9b7c21ea0 | ||
|
|
03ca0c5e3d | ||
|
|
1ee3e01cfd | ||
|
|
e2e9395f8e | ||
|
|
f415e8c4bb | ||
|
|
6a73d7c594 | ||
|
|
24206c0953 | ||
|
|
7610f844f7 | ||
|
|
a7c1aeb876 | ||
|
|
dd4c925a56 | ||
|
|
a4c55fb455 | ||
|
|
451d17a2cb | ||
|
|
530a12a86f | ||
|
|
e908f9c534 | ||
|
|
9af731c7eb | ||
|
|
0548331937 | ||
|
|
9eb4c883ff | ||
|
|
17b578361a | ||
|
|
08a220f424 | ||
|
|
d7b6ce04e8 | ||
|
|
ece88eed5c | ||
|
|
257bdf9877 | ||
|
|
0c512345b1 | ||
|
|
9fc9e597e1 | ||
|
|
03ffc3c379 | ||
|
|
eb66d72589 | ||
|
|
3ab026aa37 | ||
|
|
bebbf5ad80 | ||
|
|
8f1612d10b | ||
|
|
26da55dfb9 | ||
|
|
ffee9d0551 | ||
|
|
fa4d9313ff | ||
|
|
0ff5092561 | ||
|
|
27997b8f4b | ||
|
|
fd676eda34 | ||
|
|
c46583131e | ||
|
|
1d8fadf18d | ||
|
|
6b988193b3 | ||
|
|
aab7c703ad | ||
|
|
98c1a1f035 | ||
|
|
f51f5f376f | ||
|
|
e0f3bc3bc2 | ||
|
|
c839e7ac4c | ||
|
|
b620bbaad7 | ||
|
|
21779df1c2 | ||
|
|
86e721c159 | ||
|
|
463883383b | ||
|
|
f13ab8500a | ||
|
|
3c7bcfe3e4 | ||
|
|
476a9d78a4 | ||
|
|
fa2d439b3e | ||
|
|
0907c3caa2 | ||
|
|
a4266b1a62 | ||
|
|
05bebd8703 | ||
|
|
2c727ccd47 | ||
|
|
1205cdce1c | ||
|
|
6ae4c37d0c | ||
|
|
4148c0f5fb | ||
|
|
f7e7fda779 | ||
|
|
fbafa3df4e | ||
|
|
7d45249e8f | ||
|
|
96472243db | ||
|
|
4ef0e6bd86 | ||
|
|
daf5dc4f22 | ||
|
|
43f8da30d3 | ||
|
|
d2e5dbd521 | ||
|
|
9c05aaf2df | ||
|
|
4f2e26c31f | ||
|
|
328e031ebd | ||
|
|
93d4e58306 | ||
|
|
2255fb3a6c | ||
|
|
d23a935cae | ||
|
|
57bde730f8 | ||
|
|
01e0587012 | ||
|
|
c9f8b233d5 | ||
|
|
2685188ba6 | ||
|
|
942672ea95 | ||
|
|
ac57cc0202 | ||
|
|
69b5919a96 | ||
|
|
09823fe776 | ||
|
|
a97cb229ff | ||
|
|
cbf3756f16 | ||
|
|
900f62487b | ||
|
|
bc6f9a55e4 | ||
|
|
e7b7a7f46a | ||
|
|
f3b23dadd1 | ||
|
|
8de57d3875 | ||
|
|
bfb6b25c28 | ||
|
|
a24786a513 | ||
|
|
6b9f181585 | ||
|
|
b21e2c6ffd | ||
|
|
96dcbcfb79 | ||
|
|
9c45f933cd | ||
|
|
427c062075 | ||
|
|
2130422c2a | ||
|
|
8133900e76 | ||
|
|
9ad4a3ee87 | ||
|
|
8c6169320a | ||
|
|
ea0916d826 | ||
|
|
14550a89e6 | ||
|
|
c1c5b3e953 | ||
|
|
bebe9671cb | ||
|
|
bcf12e25a1 | ||
|
|
4d9725f66c | ||
|
|
aa045801cc | ||
|
|
8dd54a1c26 | ||
|
|
ce76eb0b74 | ||
|
|
453dfbcfb8 | ||
|
|
0ca4f72e53 | ||
|
|
645e8dc4b2 | ||
|
|
bab44a942a | ||
|
|
9fb24a7329 | ||
|
|
3023316087 | ||
|
|
b5e8b00125 | ||
|
|
3c3d2d94fa | ||
|
|
3476d5fbf0 | ||
|
|
4ca5c5a177 | ||
|
|
feedf88b97 | ||
|
|
80b8029f50 | ||
|
|
fc328f3fd3 | ||
|
|
6a40a70e6d | ||
|
|
b336b04d71 | ||
|
|
d1e3badf21 | ||
|
|
3b916dfb71 | ||
|
|
6f0ee8eb73 | ||
|
|
20b538c1fb | ||
|
|
4541f1435b | ||
|
|
37c1100e37 | ||
|
|
3cc1be0a9d | ||
|
|
4b2c2ad66d | ||
|
|
28b755fe37 | ||
|
|
c021753585 | ||
|
|
bcb9d671c4 | ||
|
|
d2d9477493 | ||
|
|
4951f8113d | ||
|
|
67a2c62d26 | ||
|
|
8a0264081b | ||
|
|
c30ed6c784 | ||
|
|
ab0a91cf28 | ||
|
|
9b5c7909ec | ||
|
|
3aa9e33b4e | ||
|
|
7e387e504d | ||
|
|
d689b24d07 | ||
|
|
84de737d92 | ||
|
|
5f6934e7ea | ||
|
|
884e6dcf96 | ||
|
|
2f3e388d1e | ||
|
|
bbbd7a242d | ||
|
|
d91089ed48 | ||
|
|
c6bdb8ee5a | ||
|
|
d9d2e3b78f | ||
|
|
7f8d6dcd50 | ||
|
|
f4d3b8f657 | ||
|
|
0d1ed7bdd9 | ||
|
|
01fc6db008 | ||
|
|
1ad0bf14a6 | ||
|
|
af623cd8e5 | ||
|
|
f7a909f18e | ||
|
|
3c7a6b53a5 | ||
|
|
8b192bedf4 | ||
|
|
a57130f838 | ||
|
|
6ffed2568a | ||
|
|
f7a796fc2b | ||
|
|
e55bf7064e | ||
|
|
705ec3726b | ||
|
|
e343a1829b | ||
|
|
cf27bccb6e | ||
|
|
07d93c609d | ||
|
|
e6818dcbd0 | ||
|
|
98902a90ac | ||
|
|
27618029d8 | ||
|
|
9e6a22320b | ||
|
|
15ce6810c8 | ||
|
|
dc237b36f3 | ||
|
|
4348221210 | ||
|
|
cb1f42e0a2 | ||
|
|
8526442782 | ||
|
|
a75ac115de | ||
|
|
06e04fa11e | ||
|
|
3dfa53cf01 | ||
|
|
4acc639a8a | ||
|
|
07665cee7e | ||
|
|
9aefa3cf3b | ||
|
|
36be3d8772 | ||
|
|
1af9aaf11f | ||
|
|
69b86a473a | ||
|
|
22fde2d367 | ||
|
|
2b63d7e863 | ||
|
|
0aa6b0b4ae | ||
|
|
ee489534ec | ||
|
|
9f9d96edfe | ||
|
|
cf71bb8a8a | ||
|
|
9ed374c4c3 | ||
|
|
1119f80ffc | ||
|
|
0f62beb0ab | ||
|
|
395f5c01e1 | ||
|
|
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 | ||
|
|
327826d760 | ||
|
|
f3e31edb7d | ||
|
|
936f7d9614 | ||
|
|
966c271bd3 | ||
|
|
95c243df18 | ||
|
|
89efd237fe | ||
|
|
899426772b | ||
|
|
55582433c6 | ||
|
|
395f357fcb | ||
|
|
a14485c6dd | ||
|
|
2351a83c48 | ||
|
|
e761c1d17a | ||
|
|
ea01ab7b0f | ||
|
|
583bc077fb | ||
|
|
63ed780bb0 | ||
|
|
2441470849 | ||
|
|
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 | ||
|
|
be532d5455 | ||
|
|
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 | ||
|
|
0344467cb7 | ||
|
|
899ddafd90 |
@@ -5,3 +5,11 @@ pgdata
|
||||
docker-compose.yml
|
||||
Dockerfile
|
||||
README.md
|
||||
.yarn/install-state.gz
|
||||
./apps/mobile
|
||||
**/.next/cache
|
||||
**/.next/cache/**
|
||||
data
|
||||
data.ms
|
||||
.git
|
||||
meili_data
|
||||
54
.env.sample
54
.env.sample
@@ -15,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=
|
||||
@@ -27,23 +26,56 @@ 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=
|
||||
|
||||
# https://ai-sdk.dev/providers/ai-sdk-providers/perplexity
|
||||
PERPLEXITY_API_KEY=
|
||||
PERPLEXITY_MODEL=
|
||||
|
||||
# MeiliSearch Settings
|
||||
MEILI_HOST=
|
||||
MEILI_MASTER_KEY=
|
||||
|
||||
# AWS S3 Settings
|
||||
SPACES_KEY=
|
||||
SPACES_SECRET=
|
||||
@@ -99,10 +131,10 @@ AUTH0_CLIENT_SECRET=
|
||||
AUTH0_CLIENT_ID=
|
||||
|
||||
# Authelia
|
||||
NEXT_PUBLIC_AUTHELIA_ENABLED=""
|
||||
AUTHELIA_CLIENT_ID=""
|
||||
AUTHELIA_CLIENT_SECRET=""
|
||||
AUTHELIA_WELLKNOWN_URL=""
|
||||
NEXT_PUBLIC_AUTHELIA_ENABLED=
|
||||
AUTHELIA_CLIENT_ID=
|
||||
AUTHELIA_CLIENT_SECRET=
|
||||
AUTHELIA_WELLKNOWN_URL=
|
||||
|
||||
# Authentik
|
||||
NEXT_PUBLIC_AUTHENTIK_ENABLED=
|
||||
@@ -226,6 +258,7 @@ NEXT_PUBLIC_GITLAB_ENABLED=
|
||||
GITLAB_CUSTOM_NAME=
|
||||
GITLAB_CLIENT_ID=
|
||||
GITLAB_CLIENT_SECRET=
|
||||
GITLAB_AUTH_URL=
|
||||
|
||||
# Google
|
||||
NEXT_PUBLIC_GOOGLE_ENABLED=
|
||||
@@ -370,6 +403,13 @@ STRAVA_CUSTOM_NAME=
|
||||
STRAVA_CLIENT_ID=
|
||||
STRAVA_CLIENT_SECRET=
|
||||
|
||||
# Synology
|
||||
NEXT_PUBLIC_SYNOLOGY_ENABLED=
|
||||
SYNOLOGY_CUSTOM_NAME=
|
||||
SYNOLOGY_CLIENT_ID=
|
||||
SYNOLOGY_CLIENT_SECRET=
|
||||
SYNOLOGY_WELLKNOWN_URL=
|
||||
|
||||
# Todoist
|
||||
NEXT_PUBLIC_TODOIST_ENABLED=
|
||||
TODOIST_CUSTOM_NAME=
|
||||
|
||||
14
.github/FUNDING.yml
vendored
14
.github/FUNDING.yml
vendored
@@ -1,13 +1,3 @@
|
||||
# These are supported funding model platforms
|
||||
|
||||
github: # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2]
|
||||
patreon: # Replace with a single Patreon username
|
||||
open_collective: linkwarden
|
||||
ko_fi: # Replace with a single Ko-fi username
|
||||
tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel
|
||||
community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry
|
||||
liberapay: # Replace with a single Liberapay username
|
||||
issuehunt: # Replace with a single IssueHunt username
|
||||
otechie: # Replace with a single Otechie username
|
||||
lfx_crowdfunding: # Replace with a single LFX Crowdfunding project-name e.g., cloud-foundry
|
||||
custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2']
|
||||
github: daniel31x13
|
||||
buy_me_a_coffee: daniel31x13
|
||||
13
.github/ISSUE_TEMPLATE/installation-problem.yml
vendored
Normal file
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.
|
||||
46
.github/pull_request_template.md
vendored
Normal file
46
.github/pull_request_template.md
vendored
Normal file
@@ -0,0 +1,46 @@
|
||||
## What does this PR do?
|
||||
|
||||
<!-- Please include a summary of the change and which issue is fixed. Please also include relevant motivation and context. List any dependencies that are required for this change. -->
|
||||
|
||||
- Fixes #XXXX (GitHub issue number)
|
||||
|
||||
## Visual Demo
|
||||
|
||||
A visual demonstration is strongly recommended, for both the original and new change **(video / image)**.
|
||||
|
||||
#### Video Demo (if applicable):
|
||||
|
||||
- Show screen recordings of the issue or feature.
|
||||
- Demonstrate how to reproduce the issue, the behavior before and after the change.
|
||||
|
||||
#### Image Demo (if applicable):
|
||||
|
||||
- Add side-by-side screenshots of the original and updated change.
|
||||
- Highlight any significant change(s).
|
||||
|
||||
## AI Assistance (Required)
|
||||
|
||||
We allow AI-assisted development, but reviewers need transparency to assess risk, maintainability, and correctness.
|
||||
|
||||
#### AI usage level (check one)
|
||||
|
||||
- [ ] None (no AI used)
|
||||
- [ ] Light (spellcheck/rewording/comments/docs only)
|
||||
- [ ] Medium (AI suggested small code changes/snippets that I adapted)
|
||||
- [ ] Heavy (AI significantly shaped the implementation or architecture)
|
||||
|
||||
#### Which tool(s) where used?
|
||||
|
||||
- e.g., ChatGPT, Copilot, Cursor, etc.
|
||||
|
||||
## What was verified by the author?
|
||||
|
||||
<!-- Add what you personally checked to ensure correctness and safety. -->
|
||||
|
||||
- [ ] I reviewed **and** understood all AI/human generated code
|
||||
- [ ] I validated behavior locally (tests/manual verification)
|
||||
- [ ] I checked edge cases and failure modes
|
||||
|
||||
## Submission Acknowledgement
|
||||
|
||||
- [ ] I acknowledge that a decent size PR without self-review might be rejected
|
||||
42
.github/workflows/locale-action.yml
vendored
Normal file
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
|
||||
28
.github/workflows/playwright-tests.yml
vendored
28
.github/workflows/playwright-tests.yml
vendored
@@ -61,12 +61,18 @@ jobs:
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Use Node.js
|
||||
- name: Use Node.js and Enable Yarn 4
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "18"
|
||||
cache: 'yarn'
|
||||
node-version: "20"
|
||||
|
||||
- name: Enable Yarn 4
|
||||
run: |
|
||||
sudo rm -f /usr/bin/yarn /usr/bin/yarnpkg || true
|
||||
corepack enable
|
||||
corepack prepare yarn@4.12.0 --activate
|
||||
yarn --version
|
||||
|
||||
- name: Initialize PostgreSQL
|
||||
run: |
|
||||
echo "Initializing Databases"
|
||||
@@ -74,7 +80,7 @@ jobs:
|
||||
psql -h localhost -U postgres -d postgres -c "CREATE DATABASE ${{ env.TEST_POSTGRES_DATABASE }} OWNER ${{ env.TEST_POSTGRES_USER }};"
|
||||
|
||||
- name: Install packages
|
||||
run: yarn install -y
|
||||
run: yarn install --immutable
|
||||
|
||||
- name: Cache playwright dependencies
|
||||
uses: awalsh128/cache-apt-pkgs-action@latest
|
||||
@@ -119,21 +125,17 @@ 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@v4
|
||||
if: always()
|
||||
|
||||
37
.gitignore
vendored
37
.gitignore
vendored
@@ -1,13 +1,14 @@
|
||||
# dependencies
|
||||
/node_modules
|
||||
node_modules
|
||||
/.pnp
|
||||
.pnp.js
|
||||
.yarn/install-state.gz
|
||||
|
||||
# testing
|
||||
/coverage
|
||||
|
||||
# next.js
|
||||
/.next/
|
||||
.next
|
||||
/out/
|
||||
|
||||
# production
|
||||
@@ -34,23 +35,25 @@ 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
|
||||
/data.ms
|
||||
meilisearch
|
||||
meili_data
|
||||
.idea
|
||||
prisma/dev.db
|
||||
.turbo
|
||||
|
||||
service-account-file.json
|
||||
1
.yarnrc.yml
Normal file
1
.yarnrc.yml
Normal file
@@ -0,0 +1 @@
|
||||
nodeLinker: node-modules
|
||||
@@ -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).
|
||||
32
Dockerfile
32
Dockerfile
@@ -2,7 +2,7 @@
|
||||
# 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.80-bullseye AS monolith-builder
|
||||
FROM docker.io/rust:1.86-bullseye AS monolith-builder
|
||||
|
||||
RUN set -eux && cargo install --locked monolith
|
||||
|
||||
@@ -10,7 +10,13 @@ RUN set -eux && cargo install --locked monolith
|
||||
# Purpose: Compiles the frontend and
|
||||
# Notes:
|
||||
# - Nothing extra should be left here. All commands should cleanup
|
||||
FROM node:18.18-bullseye-slim AS main-app
|
||||
FROM node:20.19.6-bullseye-slim AS main-app
|
||||
|
||||
ENV YARN_HTTP_TIMEOUT=10000000
|
||||
|
||||
ENV COREPACK_ENABLE_DOWNLOAD_PROMPT=0
|
||||
|
||||
ENV PRISMA_HIDE_UPDATE_MESSAGE=1
|
||||
|
||||
ARG DEBIAN_FRONTEND=noninteractive
|
||||
|
||||
@@ -18,11 +24,21 @@ RUN mkdir /data
|
||||
|
||||
WORKDIR /data
|
||||
|
||||
COPY ./package.json ./yarn.lock ./playwright.config.ts ./
|
||||
RUN corepack enable
|
||||
|
||||
COPY ./.yarnrc.yml ./
|
||||
|
||||
COPY ./apps/web/package.json ./apps/web/playwright.config.ts ./apps/web/
|
||||
|
||||
COPY ./apps/worker/package.json ./apps/worker/
|
||||
|
||||
COPY ./packages ./packages
|
||||
|
||||
COPY ./yarn.lock ./package.json ./
|
||||
|
||||
RUN --mount=type=cache,sharing=locked,target=/usr/local/share/.cache/yarn \
|
||||
set -eux && \
|
||||
yarn install --network-timeout 10000000 && \
|
||||
yarn workspaces focus linkwarden @linkwarden/web @linkwarden/worker && \
|
||||
# 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 && \
|
||||
@@ -34,14 +50,14 @@ RUN --mount=type=cache,sharing=locked,target=/usr/local/share/.cache/yarn \
|
||||
COPY --from=monolith-builder /usr/local/cargo/bin/monolith /usr/local/bin/monolith
|
||||
|
||||
RUN set -eux && \
|
||||
npx playwright install --with-deps chromium && \
|
||||
apt-get clean && \
|
||||
yarn cache clean
|
||||
|
||||
COPY . .
|
||||
|
||||
RUN yarn prisma generate && \
|
||||
yarn build
|
||||
RUN yarn prisma:generate && \
|
||||
yarn web:build && \
|
||||
rm -rf apps/web/.next/cache
|
||||
|
||||
HEALTHCHECK --interval=30s \
|
||||
--timeout=5s \
|
||||
@@ -51,4 +67,4 @@ HEALTHCHECK --interval=30s \
|
||||
|
||||
EXPOSE 3000
|
||||
|
||||
CMD yarn prisma migrate deploy && yarn start
|
||||
CMD ["sh", "-c", "yarn prisma:deploy && yarn concurrently:start"]
|
||||
|
||||
138
README.md
138
README.md
@@ -1,12 +1,16 @@
|
||||
<div align="center">
|
||||
<img src="./assets/logo.png" width="100px" />
|
||||
<h1>Linkwarden</h1>
|
||||
<h3>Bookmark Preservation for Individuals and Teams</h3>
|
||||
<h3>Bookmarks, Evolved</h3>
|
||||
|
||||
<a href="https://trendshift.io/repositories/4006" target="_blank"><img src="https://trendshift.io/api/badge/repositories/4006" alt="linkwarden%2Flinkwarden | Trendshift" style="width: 250px; height: 55px;" width="250" height="55"/></a>
|
||||
|
||||
<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://news.ycombinator.com/item?id=36942308"><img src="https://img.shields.io/badge/Hacker%20News-280-%23FF6600"></img></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=43856801"><img src="https://img.shields.io/badge/Hacker%20News-301-%23FF6600"></img></a>
|
||||
|
||||
<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>
|
||||
@@ -15,78 +19,70 @@
|
||||
|
||||
[« LAUNCH DEMO »](https://demo.linkwarden.app)
|
||||
|
||||
[Cloud](https://cloud.linkwarden.app) · [Website](https://linkwarden.app) · [Features](https://github.com/linkwarden/linkwarden#features)
|
||||
[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 Linkwarden, you can do so by following our [Installation documentation](https://docs.linkwarden.app/self-hosting/installation).
|
||||
|
||||
<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>
|
||||
|
||||
## Features
|
||||
|
||||
- 📸 Auto capture a screenshot, PDF, single html file, and readable view of each webpage.
|
||||
- 🏛️ Send your webpage to Wayback Machine ([archive.org](https://archive.org)) for a snapshot. (Optional)
|
||||
- ✨ 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.
|
||||
- 🌐 Share your collected links and preserved formats with the world.
|
||||
- 📌 Pin your favorite links to dashboard.
|
||||
- 🔍 Full text search, filter and sort for easy retrieval.
|
||||
- 📱 Responsive design and supports most modern browsers.
|
||||
- 🌓 Dark/Light mode support.
|
||||
- 🧩 Browser extension. [Star it here!](https://github.com/linkwarden/browser-extension)
|
||||
- 📸 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
|
||||
- 🌐 Share your collected links and preserved formats with the world
|
||||
- 📱 Native iOS and android mobile apps
|
||||
- 🔍 Full text search, filter and sort for easy retrieval
|
||||
- 🌓 Dark/Light mode support
|
||||
- 🧩 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 and MacOS Apps, maintained by [JGeek00](https://github.com/JGeek00).
|
||||
- 🍎 iOS Shortcut to save Links to Linkwarden.
|
||||
- 🔑 API keys.
|
||||
- ✅ Bulk actions.
|
||||
- 👥 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!)
|
||||
- ⬆️ Upload from SingleFile (check out the [guide](https://docs.linkwarden.app/Usage/upload-from-singlefile))
|
||||
- 🔐 SSO integration (Enterprise and Self-hosted users only)
|
||||
- 🍎 iOS Shortcut to save links to Linkwarden
|
||||
- 🔑 API keys
|
||||
- ✅ Bulk actions
|
||||
- 👥 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!)
|
||||
|
||||
## Get Our Official Mobile App
|
||||
|
||||
<img src="./assets/mobile_apps.png" alt="Different screens (iPad, Pixel, and iPhone)" width="400" />
|
||||
|
||||
> [!IMPORTANT]
|
||||
> To use the app you’ll first need a Linkwarden account.
|
||||
|
||||
To create an account, you can choose between:
|
||||
|
||||
- [**Linkwarden Cloud**](https://linkwarden.app/#pricing) – instant setup, and your subscription directly supports ongoing development.
|
||||
- [**Self-hosted Linkwarden**](https://docs.linkwarden.app/self-hosting/installation) – free, but you’ll need to deploy and maintain a Linkwarden instance on a server.
|
||||
|
||||
After creating an account, download the app from your preferred store:
|
||||
|
||||
[](https://apps.apple.com/app/linkwarden/id6752550960)
|
||||
[](https://play.google.com/store/apps/details?id=app.linkwarden)
|
||||
|
||||
(To get the app as an APK outside Google Play, check out our [builds](https://github.com/linkwarden/builds) repository.)
|
||||
|
||||
## Like what we're doing? Give us a Star ⭐
|
||||
|
||||
@@ -104,19 +100,33 @@ 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).
|
||||
|
||||
## Documentation
|
||||
## 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
|
||||
|
||||
|
||||
42
apps/mobile/.easignore
Normal file
42
apps/mobile/.easignore
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/
|
||||
2
apps/mobile/.env.sample
Normal file
2
apps/mobile/.env.sample
Normal file
@@ -0,0 +1,2 @@
|
||||
LINKWARDEN_URL=
|
||||
EXPO_PUBLIC_SHOW_LOGS=
|
||||
46
apps/mobile/.gitignore
vendored
Normal file
46
apps/mobile/.gitignore
vendored
Normal file
@@ -0,0 +1,46 @@
|
||||
# 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/
|
||||
|
||||
service-account-file.json
|
||||
|
||||
.env.local
|
||||
108
apps/mobile/app.json
Normal file
108
apps/mobile/app.json
Normal file
@@ -0,0 +1,108 @@
|
||||
{
|
||||
"expo": {
|
||||
"name": "Linkwarden",
|
||||
"slug": "linkwarden",
|
||||
"version": "1.1.1",
|
||||
"orientation": "portrait",
|
||||
"icon": "./assets/images/icon.png",
|
||||
"scheme": "linkwarden",
|
||||
"userInterfaceStyle": "automatic",
|
||||
"newArchEnabled": true,
|
||||
"ios": {
|
||||
"supportsTablet": true,
|
||||
"bundleIdentifier": "app.linkwarden",
|
||||
"entitlements": {
|
||||
"com.apple.security.application-groups": ["group.app.linkwarden"]
|
||||
},
|
||||
"infoPlist": {
|
||||
"ITSAppUsesNonExemptEncryption": false,
|
||||
"NSAppTransportSecurity": {
|
||||
"NSAllowsArbitraryLoads": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"android": {
|
||||
"adaptiveIcon": {
|
||||
"foregroundImage": "./assets/images/maskable_logo.jpeg",
|
||||
"backgroundColor": "#ffffff"
|
||||
},
|
||||
"package": "app.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",
|
||||
"dark": {
|
||||
"image": "./assets/images/splash-icon.png",
|
||||
"resizeMode": "contain",
|
||||
"backgroundColor": "#171717"
|
||||
}
|
||||
}
|
||||
],
|
||||
"expo-secure-store",
|
||||
[
|
||||
"expo-share-intent",
|
||||
{
|
||||
"iosAppGroupIdentifier": "group.app.linkwarden",
|
||||
"iosActivationRules": "SUBQUERY (extensionItems, $extensionItem, SUBQUERY ($extensionItem.attachments, $attachment, (ANY $attachment.registeredTypeIdentifiers UTI-CONFORMS-TO \"public.url\" OR ANY $attachment.registeredTypeIdentifiers UTI-CONFORMS-TO \"public.text\" OR ANY $attachment.registeredTypeIdentifiers UTI-CONFORMS-TO \"public.plain-text\")).@count > 0).@count > 0",
|
||||
"androidIntentFilters": ["text/*"]
|
||||
}
|
||||
],
|
||||
[
|
||||
"expo-build-properties",
|
||||
{
|
||||
"android": {
|
||||
"enableProguardInReleaseBuilds": true,
|
||||
"extraProguardRules": "-keep public class com.horcrux.svg.** {*;}",
|
||||
"allowBackup": false,
|
||||
"compileSdkVersion": 35,
|
||||
"targetSdkVersion": 35,
|
||||
"buildToolsVersion": "35.0.0",
|
||||
"usesCleartextTraffic": true
|
||||
}
|
||||
}
|
||||
],
|
||||
[
|
||||
"react-native-edge-to-edge",
|
||||
{
|
||||
"android": {
|
||||
"parentTheme": "Default",
|
||||
"enforceNavigationBarContrast": false
|
||||
}
|
||||
}
|
||||
],
|
||||
"./plugins/with-daynight-transparent-nav"
|
||||
],
|
||||
"experiments": {
|
||||
"typedRoutes": true
|
||||
},
|
||||
"androidStatusBar": {
|
||||
"backgroundColor": "#ffffff",
|
||||
"barStyle": "dark-content",
|
||||
"translucent": false
|
||||
},
|
||||
"androidNavigationBar": {
|
||||
"backgroundColor": "#ffffff",
|
||||
"barStyle": "dark-content"
|
||||
},
|
||||
"extra": {
|
||||
"router": {
|
||||
"origin": false
|
||||
},
|
||||
"eas": {
|
||||
"projectId": "34f82639-7a25-4ebe-81c8-2db521b612cf"
|
||||
}
|
||||
},
|
||||
"owner": "linkwarden"
|
||||
}
|
||||
}
|
||||
81
apps/mobile/app/(tabs)/_layout.tsx
Normal file
81
apps/mobile/app/(tabs)/_layout.tsx
Normal file
@@ -0,0 +1,81 @@
|
||||
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 { Folder, Hash, 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"],
|
||||
paddingLeft: 5,
|
||||
paddingRight: 5,
|
||||
},
|
||||
default: {
|
||||
borderTopWidth: 0,
|
||||
backgroundColor: rawTheme[colorScheme as ThemeName]["base-200"],
|
||||
elevation: 0,
|
||||
paddingLeft: 5,
|
||||
paddingRight: 5,
|
||||
},
|
||||
}),
|
||||
}}
|
||||
>
|
||||
<Tabs.Screen
|
||||
name="dashboard"
|
||||
options={{
|
||||
title: "Dashboard",
|
||||
headerShown: false,
|
||||
tabBarIcon: ({ color }) => <House size={24} color={color} />,
|
||||
}}
|
||||
/>
|
||||
<Tabs.Screen
|
||||
name="links"
|
||||
options={{
|
||||
title: "Links",
|
||||
headerShown: false,
|
||||
tabBarIcon: ({ color }) => <Link size={24} color={color} />,
|
||||
}}
|
||||
/>
|
||||
<Tabs.Screen
|
||||
name="collections"
|
||||
options={{
|
||||
title: "Collections",
|
||||
headerShown: false,
|
||||
tabBarIcon: ({ color }) => <Folder size={24} color={color} />,
|
||||
}}
|
||||
/>
|
||||
<Tabs.Screen
|
||||
name="tags"
|
||||
options={{
|
||||
title: "Tags",
|
||||
headerShown: false,
|
||||
tabBarIcon: ({ color }) => <Hash size={24} color={color} />,
|
||||
}}
|
||||
/>
|
||||
<Tabs.Screen
|
||||
name="settings"
|
||||
options={{
|
||||
title: "Settings",
|
||||
headerShown: false,
|
||||
tabBarIcon: ({ color }) => <Settings size={24} color={color} />,
|
||||
}}
|
||||
/>
|
||||
</Tabs>
|
||||
);
|
||||
}
|
||||
62
apps/mobile/app/(tabs)/collections/[id].tsx
Normal file
62
apps/mobile/app/(tabs)/collections/[id].tsx
Normal file
@@ -0,0 +1,62 @@
|
||||
import { useLinks } from "@linkwarden/router/links";
|
||||
import { View, StyleSheet, Platform } from "react-native";
|
||||
import useAuthStore from "@/store/auth";
|
||||
import { useLocalSearchParams, useNavigation } from "expo-router";
|
||||
import React, { useEffect } from "react";
|
||||
import { useCollections } from "@linkwarden/router/collections";
|
||||
import Links from "@/components/Links";
|
||||
|
||||
export default function LinksScreen() {
|
||||
const { auth } = useAuthStore();
|
||||
const { search, id } = useLocalSearchParams<{
|
||||
search?: string;
|
||||
id: string;
|
||||
}>();
|
||||
|
||||
const { links, data } = useLinks(
|
||||
{
|
||||
sort: 0,
|
||||
searchQueryString: decodeURIComponent(search ?? ""),
|
||||
collectionId: Number(id),
|
||||
},
|
||||
auth
|
||||
);
|
||||
|
||||
const collections = useCollections(auth);
|
||||
|
||||
const navigation = useNavigation();
|
||||
|
||||
useEffect(() => {
|
||||
const activeCollection = collections.data?.filter(
|
||||
(e) => e.id === Number(id)
|
||||
)[0];
|
||||
|
||||
if (activeCollection?.name)
|
||||
navigation?.setOptions?.({
|
||||
headerTitle: activeCollection?.name,
|
||||
headerSearchBarOptions: {
|
||||
placeholder: `Search ${activeCollection.name}`,
|
||||
},
|
||||
});
|
||||
}, [navigation]);
|
||||
|
||||
return (
|
||||
<View
|
||||
style={styles.container}
|
||||
className="h-full bg-base-100"
|
||||
collapsable={false}
|
||||
collapsableChildren={false}
|
||||
>
|
||||
<Links links={links} data={data} />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: Platform.select({
|
||||
ios: {
|
||||
paddingBottom: 83,
|
||||
},
|
||||
default: {},
|
||||
}),
|
||||
});
|
||||
44
apps/mobile/app/(tabs)/collections/_layout.tsx
Normal file
44
apps/mobile/app/(tabs)/collections/_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 Layout() {
|
||||
const router = useRouter();
|
||||
const { colorScheme } = useColorScheme();
|
||||
|
||||
return (
|
||||
<Stack
|
||||
screenOptions={{
|
||||
headerTitle: "Collections",
|
||||
headerLargeTitle: true,
|
||||
headerTransparent: Platform.OS === "ios" ? true : false,
|
||||
headerShadowVisible: false,
|
||||
headerBlurEffect:
|
||||
colorScheme === "dark" ? "systemMaterialDark" : "systemMaterial",
|
||||
headerTintColor: colorScheme === "dark" ? "white" : "black",
|
||||
headerSearchBarOptions: {
|
||||
placeholder: "Search Collections",
|
||||
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",
|
||||
},
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
94
apps/mobile/app/(tabs)/collections/index.tsx
Normal file
94
apps/mobile/app/(tabs)/collections/index.tsx
Normal file
@@ -0,0 +1,94 @@
|
||||
import {
|
||||
View,
|
||||
StyleSheet,
|
||||
FlatList,
|
||||
Platform,
|
||||
Text,
|
||||
ActivityIndicator,
|
||||
} from "react-native";
|
||||
import useAuthStore from "@/store/auth";
|
||||
import CollectionListing from "@/components/CollectionListing";
|
||||
import { useLocalSearchParams } from "expo-router";
|
||||
import React, { useEffect, useState } from "react";
|
||||
import Spinner from "@/components/ui/Spinner";
|
||||
import { rawTheme, ThemeName } from "@/lib/colors";
|
||||
import { useColorScheme } from "nativewind";
|
||||
import { useCollections } from "@linkwarden/router/collections";
|
||||
import { CollectionIncludingMembersAndLinkCount } from "@linkwarden/types/global";
|
||||
|
||||
export default function CollectionsScreen() {
|
||||
const { colorScheme } = useColorScheme();
|
||||
const { auth } = useAuthStore();
|
||||
const { search } = useLocalSearchParams<{ search?: string }>();
|
||||
|
||||
const collections = useCollections(auth);
|
||||
|
||||
const [filteredCollections, setFilteredCollections] = useState<
|
||||
CollectionIncludingMembersAndLinkCount[]
|
||||
>([]);
|
||||
|
||||
useEffect(() => {
|
||||
const filter =
|
||||
collections.data?.filter((e) =>
|
||||
e.name.includes(decodeURIComponent(search || ""))
|
||||
) || [];
|
||||
|
||||
setFilteredCollections(filter);
|
||||
}, [search, collections.data]);
|
||||
|
||||
return (
|
||||
<View
|
||||
style={styles.container}
|
||||
className="h-full bg-base-100"
|
||||
collapsable={false}
|
||||
collapsableChildren={false}
|
||||
>
|
||||
{collections.isLoading ? (
|
||||
<View className="flex justify-center h-screen items-center">
|
||||
<ActivityIndicator size="large" />
|
||||
<Text className="text-base mt-2.5 text-neutral">Loading...</Text>
|
||||
</View>
|
||||
) : (
|
||||
<FlatList
|
||||
contentInsetAdjustmentBehavior="automatic"
|
||||
ListHeaderComponent={() => <></>}
|
||||
data={filteredCollections}
|
||||
refreshControl={
|
||||
<Spinner
|
||||
refreshing={collections.isRefetching}
|
||||
onRefresh={() => collections.refetch()}
|
||||
progressBackgroundColor={
|
||||
rawTheme[colorScheme as ThemeName]["base-200"]
|
||||
}
|
||||
colors={[rawTheme[colorScheme as ThemeName]["base-content"]]}
|
||||
/>
|
||||
}
|
||||
refreshing={collections.isRefetching}
|
||||
initialNumToRender={4}
|
||||
keyExtractor={(item) => item.id?.toString() || ""}
|
||||
renderItem={({ item }) => <CollectionListing collection={item} />}
|
||||
onEndReachedThreshold={0.5}
|
||||
ItemSeparatorComponent={() => (
|
||||
<View className="bg-neutral-content h-px" />
|
||||
)}
|
||||
ListEmptyComponent={
|
||||
<View className="flex justify-center py-10 items-center">
|
||||
<Text className="text-center text-xl text-neutral">
|
||||
Nothing found...
|
||||
</Text>
|
||||
</View>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: Platform.select({
|
||||
ios: {
|
||||
paddingBottom: 83,
|
||||
},
|
||||
default: {},
|
||||
}),
|
||||
});
|
||||
72
apps/mobile/app/(tabs)/dashboard/[section].tsx
Normal file
72
apps/mobile/app/(tabs)/dashboard/[section].tsx
Normal file
@@ -0,0 +1,72 @@
|
||||
import { useLinks } from "@linkwarden/router/links";
|
||||
import { View, StyleSheet, Platform } from "react-native";
|
||||
import useAuthStore from "@/store/auth";
|
||||
import { useLocalSearchParams, useNavigation } from "expo-router";
|
||||
import React, { useEffect, useMemo } from "react";
|
||||
import { useCollections } from "@linkwarden/router/collections";
|
||||
import Links from "@/components/Links";
|
||||
|
||||
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);
|
||||
|
||||
const title = useMemo(() => {
|
||||
if (section === "pinned-links") return "Pinned Links";
|
||||
if (section === "recent-links") return "Recent Links";
|
||||
|
||||
if (section === "collection") {
|
||||
return (
|
||||
collections.data?.find((c) => c.id?.toString() === collectionId)
|
||||
?.name || "Collection"
|
||||
);
|
||||
}
|
||||
|
||||
return "Links";
|
||||
}, [section, collections.data, collectionId]);
|
||||
|
||||
useEffect(() => {
|
||||
navigation.setOptions({
|
||||
headerTitle: title,
|
||||
headerSearchBarOptions: {
|
||||
placeholder: `Search ${title}`,
|
||||
},
|
||||
});
|
||||
}, [title, 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}
|
||||
>
|
||||
<Links links={links} data={data} />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: Platform.select({
|
||||
ios: {
|
||||
paddingBottom: 83,
|
||||
},
|
||||
default: {},
|
||||
}),
|
||||
});
|
||||
89
apps/mobile/app/(tabs)/dashboard/_layout.tsx
Normal file
89
apps/mobile/app/(tabs)/dashboard/_layout.tsx
Normal file
@@ -0,0 +1,89 @@
|
||||
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 Layout() {
|
||||
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="new-collection"
|
||||
onSelect={() => SheetManager.show("new-collection-sheet")}
|
||||
>
|
||||
<DropdownMenu.ItemTitle>
|
||||
New Collection
|
||||
</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>
|
||||
);
|
||||
}
|
||||
147
apps/mobile/app/(tabs)/dashboard/index.tsx
Normal file
147
apps/mobile/app/(tabs)/dashboard/index.tsx
Normal file
@@ -0,0 +1,147 @@
|
||||
import {
|
||||
ActivityIndicator,
|
||||
Platform,
|
||||
ScrollView,
|
||||
StyleSheet,
|
||||
Text,
|
||||
View,
|
||||
} from "react-native";
|
||||
import React, { useEffect, useMemo, useState } from "react";
|
||||
import { useDashboardData } from "@linkwarden/router/dashboardData";
|
||||
import useAuthStore from "@/store/auth";
|
||||
import { DashboardSection as DashboardSectionType } from "@linkwarden/prisma/client";
|
||||
import { useUser } from "@linkwarden/router/user";
|
||||
import { useCollections } from "@linkwarden/router/collections";
|
||||
import { useTags } from "@linkwarden/router/tags";
|
||||
import { rawTheme, ThemeName } from "@/lib/colors";
|
||||
import { useColorScheme } from "nativewind";
|
||||
import Spinner from "@/components/ui/Spinner";
|
||||
import DashboardSection from "@/components/DashboardSection";
|
||||
|
||||
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 = [], ...collectionsData } = useCollections(auth);
|
||||
const { data: tags = [], ...tagsData } = useTags(auth);
|
||||
|
||||
const { colorScheme } = useColorScheme();
|
||||
|
||||
const [dashboardSections, setDashboardSections] = useState<
|
||||
DashboardSectionType[]
|
||||
>(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]);
|
||||
|
||||
const [pullRefreshing, setPullRefreshing] = useState(false);
|
||||
|
||||
const onRefresh = async () => {
|
||||
setPullRefreshing(true);
|
||||
try {
|
||||
await Promise.all([
|
||||
dashboardData.refetch(),
|
||||
userData.refetch(),
|
||||
collectionsData.refetch(),
|
||||
tagsData.refetch(),
|
||||
]);
|
||||
} finally {
|
||||
setPullRefreshing(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (orderedSections.length === 0 && dashboardData.isLoading)
|
||||
return (
|
||||
<View className="flex justify-center h-screen items-center bg-base-100">
|
||||
<ActivityIndicator size="large" />
|
||||
<Text className="text-base mt-2.5 text-neutral">Loading...</Text>
|
||||
</View>
|
||||
);
|
||||
|
||||
return (
|
||||
<ScrollView
|
||||
refreshControl={
|
||||
<Spinner
|
||||
refreshing={pullRefreshing}
|
||||
onRefresh={onRefresh}
|
||||
progressBackgroundColor={
|
||||
rawTheme[colorScheme as ThemeName]["base-200"]
|
||||
}
|
||||
colors={[rawTheme[colorScheme as ThemeName]["base-content"]]}
|
||||
/>
|
||||
}
|
||||
contentContainerStyle={styles.container}
|
||||
className="bg-base-100"
|
||||
contentInsetAdjustmentBehavior="automatic"
|
||||
>
|
||||
{orderedSections.map((sectionData, i) => {
|
||||
if (!collections || !collections[0]) return null;
|
||||
|
||||
const collection = collections.find(
|
||||
(c) => c.id === sectionData.collectionId
|
||||
);
|
||||
|
||||
return (
|
||||
<DashboardSection
|
||||
key={sectionData.id}
|
||||
sectionData={sectionData}
|
||||
collection={collection}
|
||||
collectionLinks={
|
||||
sectionData.collectionId
|
||||
? collectionLinks[sectionData.collectionId]
|
||||
: []
|
||||
}
|
||||
links={links}
|
||||
tagsLength={tags.length}
|
||||
numberOfLinks={numberOfLinks}
|
||||
collectionsLength={collections.length}
|
||||
numberOfPinnedLinks={numberOfPinnedLinks}
|
||||
dashboardData={dashboardData}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</ScrollView>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: Platform.select({
|
||||
ios: {
|
||||
paddingBottom: 49,
|
||||
flexDirection: "column",
|
||||
gap: 15,
|
||||
paddingVertical: 20,
|
||||
},
|
||||
default: {
|
||||
flexDirection: "column",
|
||||
gap: 15,
|
||||
paddingVertical: 20,
|
||||
},
|
||||
}),
|
||||
});
|
||||
44
apps/mobile/app/(tabs)/links/_layout.tsx
Normal file
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 Layout() {
|
||||
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 Links",
|
||||
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",
|
||||
},
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
39
apps/mobile/app/(tabs)/links/index.tsx
Normal file
39
apps/mobile/app/(tabs)/links/index.tsx
Normal file
@@ -0,0 +1,39 @@
|
||||
import { useLinks } from "@linkwarden/router/links";
|
||||
import { View, StyleSheet, Platform } from "react-native";
|
||||
import useAuthStore from "@/store/auth";
|
||||
import { useLocalSearchParams } from "expo-router";
|
||||
import React from "react";
|
||||
import Links from "@/components/Links";
|
||||
|
||||
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}
|
||||
>
|
||||
<Links links={links} data={data} />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: Platform.select({
|
||||
ios: {
|
||||
paddingBottom: 83,
|
||||
},
|
||||
default: {},
|
||||
}),
|
||||
});
|
||||
43
apps/mobile/app/(tabs)/settings/_layout.tsx
Normal file
43
apps/mobile/app/(tabs)/settings/_layout.tsx
Normal file
@@ -0,0 +1,43 @@
|
||||
import { Stack } from "expo-router";
|
||||
import { useColorScheme } from "nativewind";
|
||||
import { rawTheme, ThemeName } from "@/lib/colors";
|
||||
import { Platform } from "react-native";
|
||||
|
||||
export default function Layout() {
|
||||
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"],
|
||||
},
|
||||
headerBackTitle: "Back",
|
||||
headerStyle: {
|
||||
backgroundColor:
|
||||
Platform.OS === "ios"
|
||||
? "transparent"
|
||||
: colorScheme === "dark"
|
||||
? rawTheme["dark"]["base-100"]
|
||||
: "white",
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Stack.Screen name="index" />
|
||||
<Stack.Screen
|
||||
name="preferredCollection"
|
||||
options={{
|
||||
headerTitle: "Preferred Collection",
|
||||
headerLargeTitle: false,
|
||||
}}
|
||||
/>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
273
apps/mobile/app/(tabs)/settings/index.tsx
Normal file
273
apps/mobile/app/(tabs)/settings/index.tsx
Normal file
@@ -0,0 +1,273 @@
|
||||
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 {
|
||||
AppWindowMac,
|
||||
Check,
|
||||
ChevronRight,
|
||||
ExternalLink,
|
||||
Folder,
|
||||
LogOut,
|
||||
Mail,
|
||||
Moon,
|
||||
Smartphone,
|
||||
Sun,
|
||||
} from "lucide-react-native";
|
||||
import useDataStore from "@/store/data";
|
||||
import * as Clipboard from "expo-clipboard";
|
||||
import { useRouter } from "expo-router";
|
||||
|
||||
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]);
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
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">Preferred Browser</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({
|
||||
preferredBrowser: "app",
|
||||
})
|
||||
}
|
||||
>
|
||||
<View className="flex-row items-center gap-2">
|
||||
<AppWindowMac
|
||||
size={20}
|
||||
color={rawTheme[colorScheme as ThemeName].neutral}
|
||||
/>
|
||||
<Text className="text-base-content">In app browser</Text>
|
||||
</View>
|
||||
{data.preferredBrowser === "app" ? (
|
||||
<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({
|
||||
preferredBrowser: "system",
|
||||
})
|
||||
}
|
||||
>
|
||||
<View className="flex-row items-center gap-2">
|
||||
<ExternalLink
|
||||
size={20}
|
||||
color={rawTheme[colorScheme as ThemeName].neutral}
|
||||
/>
|
||||
<Text className="text-base-content">
|
||||
System default browser
|
||||
</Text>
|
||||
</View>
|
||||
{data.preferredBrowser === "system" ? (
|
||||
<Check
|
||||
size={20}
|
||||
color={rawTheme[colorScheme as ThemeName].primary}
|
||||
/>
|
||||
) : null}
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View>
|
||||
<Text className="mb-4 mx-4 text-neutral">Save Shared Links To</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={() => router.navigate("/settings/preferredCollection")}
|
||||
>
|
||||
<View className="flex-row items-center gap-2">
|
||||
<Folder
|
||||
size={20}
|
||||
color={rawTheme[colorScheme as ThemeName].neutral}
|
||||
/>
|
||||
<Text className="text-base-content">Preferred collection</Text>
|
||||
</View>
|
||||
<View className="flex-row items-center gap-2">
|
||||
<Text numberOfLines={1} className="text-neutral max-w-[140px]">
|
||||
{data.preferredCollection?.name || "None"}
|
||||
</Text>
|
||||
<ChevronRight
|
||||
size={20}
|
||||
color={rawTheme[colorScheme as ThemeName].neutral}
|
||||
/>
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View>
|
||||
<Text className="mb-4 mx-4 text-neutral">Contact Us</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={async () => {
|
||||
await Clipboard.setStringAsync("support@linkwarden.app");
|
||||
Alert.alert("Copied to clipboard", "support@linkwarden.app");
|
||||
}}
|
||||
>
|
||||
<View className="flex-row items-center gap-2">
|
||||
<Mail
|
||||
size={20}
|
||||
color={rawTheme[colorScheme as ThemeName].neutral}
|
||||
/>
|
||||
<Text className="text-base-content">
|
||||
support@linkwarden.app
|
||||
</Text>
|
||||
</View>
|
||||
</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: Platform.select({
|
||||
ios: {
|
||||
flex: 1,
|
||||
paddingBottom: 83,
|
||||
},
|
||||
default: {
|
||||
flex: 1,
|
||||
},
|
||||
}),
|
||||
});
|
||||
99
apps/mobile/app/(tabs)/settings/preferredCollection.tsx
Normal file
99
apps/mobile/app/(tabs)/settings/preferredCollection.tsx
Normal file
@@ -0,0 +1,99 @@
|
||||
import { View, Text, FlatList, TouchableOpacity } from "react-native";
|
||||
import React, { useCallback, useMemo, useState } from "react";
|
||||
import useAuthStore from "@/store/auth";
|
||||
import useDataStore from "@/store/data";
|
||||
import { useCollections } from "@linkwarden/router/collections";
|
||||
import { CollectionIncludingMembersAndLinkCount } from "@linkwarden/types/global";
|
||||
import Input from "@/components/ui/Input";
|
||||
import { Folder, Check } from "lucide-react-native";
|
||||
import { useColorScheme } from "nativewind";
|
||||
import { rawTheme, ThemeName } from "@/lib/colors";
|
||||
|
||||
const PreferredCollectionScreen = () => {
|
||||
const { auth } = useAuthStore();
|
||||
const { data, updateData } = useDataStore();
|
||||
const collections = useCollections(auth);
|
||||
const { colorScheme } = useColorScheme();
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
|
||||
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 renderCollection = useCallback(
|
||||
({
|
||||
item: collection,
|
||||
}: {
|
||||
item: CollectionIncludingMembersAndLinkCount;
|
||||
}) => {
|
||||
const isSelected = data.preferredCollection?.id === collection.id;
|
||||
|
||||
return (
|
||||
<TouchableOpacity
|
||||
className="bg-base-200 rounded-lg px-4 py-3 mb-3 flex-row items-center justify-between"
|
||||
onPress={() => updateData({ preferredCollection: collection })}
|
||||
>
|
||||
<View className="flex-row items-center gap-2 w-[70%]">
|
||||
<Folder
|
||||
size={20}
|
||||
fill={collection.color || "gray"}
|
||||
color={collection.color || "gray"}
|
||||
/>
|
||||
<Text numberOfLines={1} className="text-base-content">
|
||||
{collection.name}
|
||||
</Text>
|
||||
</View>
|
||||
<View className="flex-row items-center gap-2">
|
||||
{isSelected ? (
|
||||
<Check
|
||||
size={16}
|
||||
color={rawTheme[colorScheme as ThemeName].primary}
|
||||
/>
|
||||
) : null}
|
||||
<Text className="text-neutral">
|
||||
{collection._count?.links ?? 0}
|
||||
</Text>
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
},
|
||||
[colorScheme, data.preferredCollection?.id, updateData]
|
||||
);
|
||||
|
||||
return (
|
||||
<View className="flex-1 bg-base-100">
|
||||
<FlatList
|
||||
data={filteredCollections}
|
||||
keyExtractor={(item) => item.id?.toString() || ""}
|
||||
renderItem={renderCollection}
|
||||
contentContainerStyle={{
|
||||
paddingHorizontal: 20,
|
||||
paddingTop: 20,
|
||||
paddingBottom: 20,
|
||||
}}
|
||||
contentInsetAdjustmentBehavior="automatic"
|
||||
ListHeaderComponent={
|
||||
<Input
|
||||
placeholder="Search collections"
|
||||
className="mb-4 bg-base-200 h-10"
|
||||
value={searchQuery}
|
||||
onChangeText={setSearchQuery}
|
||||
/>
|
||||
}
|
||||
ListEmptyComponent={
|
||||
<Text
|
||||
style={{ textAlign: "center", marginTop: 20 }}
|
||||
className="text-neutral"
|
||||
>
|
||||
No collections match “{searchQuery}”
|
||||
</Text>
|
||||
}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
export default PreferredCollectionScreen;
|
||||
60
apps/mobile/app/(tabs)/tags/[id].tsx
Normal file
60
apps/mobile/app/(tabs)/tags/[id].tsx
Normal file
@@ -0,0 +1,60 @@
|
||||
import { useLinks } from "@linkwarden/router/links";
|
||||
import { View, StyleSheet, Platform } from "react-native";
|
||||
import useAuthStore from "@/store/auth";
|
||||
import { useLocalSearchParams, useNavigation } from "expo-router";
|
||||
import React, { useEffect } from "react";
|
||||
import { useTags } from "@linkwarden/router/tags";
|
||||
import Links from "@/components/Links";
|
||||
|
||||
export default function LinksScreen() {
|
||||
const { auth } = useAuthStore();
|
||||
const { search, id } = useLocalSearchParams<{
|
||||
search?: string;
|
||||
id: string;
|
||||
}>();
|
||||
|
||||
const { links, data } = useLinks(
|
||||
{
|
||||
sort: 0,
|
||||
searchQueryString: decodeURIComponent(search ?? ""),
|
||||
tagId: Number(id),
|
||||
},
|
||||
auth
|
||||
);
|
||||
|
||||
const tags = useTags(auth);
|
||||
|
||||
const navigation = useNavigation();
|
||||
|
||||
useEffect(() => {
|
||||
const activeTag = tags.data?.filter((e) => e.id === Number(id))[0];
|
||||
|
||||
if (activeTag?.name)
|
||||
navigation?.setOptions?.({
|
||||
headerTitle: activeTag?.name,
|
||||
headerSearchBarOptions: {
|
||||
placeholder: `Search ${activeTag.name}`,
|
||||
},
|
||||
});
|
||||
}, [navigation]);
|
||||
|
||||
return (
|
||||
<View
|
||||
style={styles.container}
|
||||
className="h-full bg-base-100"
|
||||
collapsable={false}
|
||||
collapsableChildren={false}
|
||||
>
|
||||
<Links links={links} data={data} />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: Platform.select({
|
||||
ios: {
|
||||
paddingBottom: 83,
|
||||
},
|
||||
default: {},
|
||||
}),
|
||||
});
|
||||
44
apps/mobile/app/(tabs)/tags/_layout.tsx
Normal file
44
apps/mobile/app/(tabs)/tags/_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 Layout() {
|
||||
const router = useRouter();
|
||||
const { colorScheme } = useColorScheme();
|
||||
|
||||
return (
|
||||
<Stack
|
||||
screenOptions={{
|
||||
headerTitle: "Tags",
|
||||
headerLargeTitle: true,
|
||||
headerTransparent: Platform.OS === "ios" ? true : false,
|
||||
headerShadowVisible: false,
|
||||
headerBlurEffect:
|
||||
colorScheme === "dark" ? "systemMaterialDark" : "systemMaterial",
|
||||
headerTintColor: colorScheme === "dark" ? "white" : "black",
|
||||
headerSearchBarOptions: {
|
||||
placeholder: "Search Tags",
|
||||
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",
|
||||
},
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
92
apps/mobile/app/(tabs)/tags/index.tsx
Normal file
92
apps/mobile/app/(tabs)/tags/index.tsx
Normal file
@@ -0,0 +1,92 @@
|
||||
import {
|
||||
View,
|
||||
StyleSheet,
|
||||
FlatList,
|
||||
Platform,
|
||||
Text,
|
||||
ActivityIndicator,
|
||||
} from "react-native";
|
||||
import useAuthStore from "@/store/auth";
|
||||
import TagListing from "@/components/TagListing";
|
||||
import { useLocalSearchParams } from "expo-router";
|
||||
import React, { useEffect, useState } from "react";
|
||||
import Spinner from "@/components/ui/Spinner";
|
||||
import { rawTheme, ThemeName } from "@/lib/colors";
|
||||
import { useColorScheme } from "nativewind";
|
||||
import { TagIncludingLinkCount } from "@linkwarden/types/global";
|
||||
import { useTags } from "@linkwarden/router/tags";
|
||||
|
||||
export default function TagsScreen() {
|
||||
const { colorScheme } = useColorScheme();
|
||||
const { auth } = useAuthStore();
|
||||
const { search } = useLocalSearchParams<{ search?: string }>();
|
||||
|
||||
const tags = useTags(auth);
|
||||
|
||||
const [filteredTags, setFilteredTags] = useState<TagIncludingLinkCount[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
const filter =
|
||||
tags.data?.filter((e) =>
|
||||
e.name.includes(decodeURIComponent(search || ""))
|
||||
) || [];
|
||||
|
||||
setFilteredTags(filter);
|
||||
}, [search, tags.data]);
|
||||
|
||||
return (
|
||||
<View
|
||||
style={styles.container}
|
||||
className="h-full bg-base-100"
|
||||
collapsable={false}
|
||||
collapsableChildren={false}
|
||||
>
|
||||
{tags.isLoading ? (
|
||||
<View className="flex justify-center h-screen items-center">
|
||||
<ActivityIndicator size="large" />
|
||||
<Text className="text-base mt-2.5 text-neutral">Loading...</Text>
|
||||
</View>
|
||||
) : (
|
||||
<FlatList
|
||||
contentInsetAdjustmentBehavior="automatic"
|
||||
ListHeaderComponent={() => <></>}
|
||||
data={filteredTags}
|
||||
refreshControl={
|
||||
<Spinner
|
||||
refreshing={tags.isRefetching}
|
||||
onRefresh={() => tags.refetch()}
|
||||
progressBackgroundColor={
|
||||
rawTheme[colorScheme as ThemeName]["base-200"]
|
||||
}
|
||||
colors={[rawTheme[colorScheme as ThemeName]["base-content"]]}
|
||||
/>
|
||||
}
|
||||
refreshing={tags.isRefetching}
|
||||
initialNumToRender={4}
|
||||
keyExtractor={(item) => item.id?.toString() || ""}
|
||||
renderItem={({ item }) => <TagListing tag={item} />}
|
||||
onEndReachedThreshold={0.5}
|
||||
ItemSeparatorComponent={() => (
|
||||
<View className="bg-neutral-content h-px" />
|
||||
)}
|
||||
ListEmptyComponent={
|
||||
<View className="flex justify-center py-10 items-center">
|
||||
<Text className="text-center text-xl text-neutral">
|
||||
Nothing found...
|
||||
</Text>
|
||||
</View>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: Platform.select({
|
||||
ios: {
|
||||
paddingBottom: 83,
|
||||
},
|
||||
default: {},
|
||||
}),
|
||||
});
|
||||
20
apps/mobile/app/+not-found.tsx
Normal file
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",
|
||||
},
|
||||
});
|
||||
322
apps/mobile/app/_layout.tsx
Normal file
322
apps/mobile/app/_layout.tsx
Normal file
@@ -0,0 +1,322 @@
|
||||
import {
|
||||
router,
|
||||
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 { SheetManager, SheetProvider } from "react-native-actions-sheet";
|
||||
import "@/components/ActionSheets/Sheets";
|
||||
import { useColorScheme } from "nativewind";
|
||||
import { lightTheme, darkTheme } from "../lib/theme";
|
||||
import {
|
||||
Alert,
|
||||
Linking,
|
||||
Platform,
|
||||
Share,
|
||||
TouchableOpacity,
|
||||
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 { KeyboardProvider } from "react-native-keyboard-controller";
|
||||
import * as DropdownMenu from "zeego/dropdown-menu";
|
||||
import { Compass, Ellipsis } from "lucide-react-native";
|
||||
import { Chromium } from "@/components/ui/Icons";
|
||||
import useTmpStore from "@/store/tmp";
|
||||
import {
|
||||
LinkIncludingShortenedCollectionAndTags,
|
||||
MobileAuth,
|
||||
} from "@linkwarden/types/global";
|
||||
import { useDeleteLink, useUpdateLink } from "@linkwarden/router/links";
|
||||
import { deleteLinkCache } from "@/lib/cache";
|
||||
import { queryClient } from "@/lib/queryClient";
|
||||
import getOriginalFormat from "@linkwarden/lib/getOriginalFormat";
|
||||
import { StatusBar } from "expo-status-bar";
|
||||
|
||||
export default function RootLayout() {
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
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(() => {
|
||||
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();
|
||||
}}
|
||||
>
|
||||
<RootComponent isLoading={isLoading} auth={auth} />
|
||||
</PersistQueryClientProvider>
|
||||
);
|
||||
}
|
||||
|
||||
const RootComponent = ({
|
||||
isLoading,
|
||||
auth,
|
||||
}: {
|
||||
isLoading: boolean;
|
||||
auth: MobileAuth;
|
||||
}) => {
|
||||
const { colorScheme } = useColorScheme();
|
||||
const updateLink = useUpdateLink({ auth, Alert });
|
||||
const deleteLink = useDeleteLink({ auth, Alert });
|
||||
|
||||
const { tmp } = useTmpStore();
|
||||
|
||||
return (
|
||||
<View
|
||||
style={[{ flex: 1 }, colorScheme === "dark" ? darkTheme : lightTheme]}
|
||||
>
|
||||
<KeyboardProvider>
|
||||
<SheetProvider>
|
||||
<StatusBar
|
||||
style={colorScheme === "dark" ? "light" : "dark"}
|
||||
backgroundColor={rawTheme[colorScheme as ThemeName]["base-100"]}
|
||||
/>
|
||||
{!isLoading && (
|
||||
<Stack
|
||||
screenOptions={{
|
||||
headerShown: false,
|
||||
contentStyle: {
|
||||
backgroundColor:
|
||||
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",
|
||||
headerStyle: {
|
||||
backgroundColor:
|
||||
colorScheme === "dark"
|
||||
? rawTheme["dark"]["base-100"]
|
||||
: "white",
|
||||
},
|
||||
headerRight: () => (
|
||||
<View className="flex-row gap-5">
|
||||
<TouchableOpacity
|
||||
onPress={() => {
|
||||
if (tmp.link) {
|
||||
if (tmp.link.url) {
|
||||
return Linking.openURL(tmp.link.url);
|
||||
} else {
|
||||
const format = getOriginalFormat(tmp.link);
|
||||
|
||||
return Linking.openURL(
|
||||
format !== null
|
||||
? auth.instance +
|
||||
`/preserved/${tmp.link.id}?format=${format}`
|
||||
: tmp.link.url || ""
|
||||
);
|
||||
}
|
||||
}
|
||||
}}
|
||||
>
|
||||
{Platform.OS === "ios" ? (
|
||||
<Compass
|
||||
size={21}
|
||||
color={
|
||||
rawTheme[colorScheme as ThemeName]["base-content"]
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<Chromium
|
||||
stroke={
|
||||
rawTheme[colorScheme as ThemeName]["base-content"]
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
<DropdownMenu.Root>
|
||||
<DropdownMenu.Trigger>
|
||||
<TouchableOpacity>
|
||||
<Ellipsis
|
||||
size={21}
|
||||
color={
|
||||
rawTheme[colorScheme as ThemeName][
|
||||
"base-content"
|
||||
]
|
||||
}
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
</DropdownMenu.Trigger>
|
||||
|
||||
<DropdownMenu.Content>
|
||||
{tmp.link?.url && (
|
||||
<DropdownMenu.Item
|
||||
key="share"
|
||||
onSelect={async () => {
|
||||
await Share.share({
|
||||
...(Platform.OS === "android"
|
||||
? { message: tmp.link?.url as string }
|
||||
: { url: tmp.link?.url as string }),
|
||||
});
|
||||
}}
|
||||
>
|
||||
<DropdownMenu.ItemTitle>
|
||||
Share
|
||||
</DropdownMenu.ItemTitle>
|
||||
</DropdownMenu.Item>
|
||||
)}
|
||||
|
||||
{tmp.link && tmp.user && (
|
||||
<DropdownMenu.Item
|
||||
key="pin-link"
|
||||
onSelect={() => {
|
||||
const isAlreadyPinned =
|
||||
tmp.link?.pinnedBy && tmp.link.pinnedBy[0]
|
||||
? true
|
||||
: false;
|
||||
updateLink.mutateAsync({
|
||||
...(tmp.link as LinkIncludingShortenedCollectionAndTags),
|
||||
pinnedBy: (isAlreadyPinned
|
||||
? [{ id: undefined }]
|
||||
: [{ id: tmp.user?.id }]) as any,
|
||||
});
|
||||
}}
|
||||
>
|
||||
<DropdownMenu.ItemTitle>
|
||||
{tmp.link.pinnedBy && tmp.link.pinnedBy[0]
|
||||
? "Unpin Link"
|
||||
: "Pin Link"}
|
||||
</DropdownMenu.ItemTitle>
|
||||
</DropdownMenu.Item>
|
||||
)}
|
||||
|
||||
{tmp.link && (
|
||||
<DropdownMenu.Item
|
||||
key="edit-link"
|
||||
onSelect={() => {
|
||||
SheetManager.show("edit-link-sheet", {
|
||||
payload: {
|
||||
link: tmp.link as LinkIncludingShortenedCollectionAndTags,
|
||||
},
|
||||
});
|
||||
}}
|
||||
>
|
||||
<DropdownMenu.ItemTitle>
|
||||
Edit Link
|
||||
</DropdownMenu.ItemTitle>
|
||||
</DropdownMenu.Item>
|
||||
)}
|
||||
|
||||
{tmp.link && (
|
||||
<DropdownMenu.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: async () => {
|
||||
deleteLink.mutate(
|
||||
tmp.link?.id as number
|
||||
);
|
||||
|
||||
await deleteLinkCache(
|
||||
tmp.link?.id as number
|
||||
);
|
||||
|
||||
router.back();
|
||||
},
|
||||
},
|
||||
]
|
||||
);
|
||||
}}
|
||||
>
|
||||
<DropdownMenu.ItemTitle>
|
||||
Delete
|
||||
</DropdownMenu.ItemTitle>
|
||||
</DropdownMenu.Item>
|
||||
)}
|
||||
</DropdownMenu.Content>
|
||||
</DropdownMenu.Root>
|
||||
</View>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen name="login" />
|
||||
<Stack.Screen name="index" />
|
||||
<Stack.Screen name="incoming" />
|
||||
<Stack.Screen name="+not-found" />
|
||||
</Stack>
|
||||
)}
|
||||
</SheetProvider>
|
||||
</KeyboardProvider>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
97
apps/mobile/app/incoming.tsx
Normal file
97
apps/mobile/app/incoming.tsx
Normal file
@@ -0,0 +1,97 @@
|
||||
import React, { useEffect, useState } from "react";
|
||||
import {
|
||||
SafeAreaView,
|
||||
View,
|
||||
Text,
|
||||
ActivityIndicator,
|
||||
Alert,
|
||||
TouchableOpacity,
|
||||
} 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";
|
||||
import { SheetManager } from "react-native-actions-sheet";
|
||||
import { LinkIncludingShortenedCollectionAndTags } from "@linkwarden/types/global";
|
||||
|
||||
export default function IncomingScreen() {
|
||||
const { auth } = useAuthStore();
|
||||
const router = useRouter();
|
||||
const { data, updateData } = useDataStore();
|
||||
const addLink = useAddLink({ auth });
|
||||
const { colorScheme } = useColorScheme();
|
||||
const [showSuccess, setShowSuccess] = useState(false);
|
||||
const [link, setLink] = useState<LinkIncludingShortenedCollectionAndTags>();
|
||||
|
||||
useEffect(() => {
|
||||
if (auth.status === "authenticated" && data.shareIntent.url)
|
||||
addLink.mutate(
|
||||
{
|
||||
url: data.shareIntent.url,
|
||||
collection: { id: data.preferredCollection?.id },
|
||||
},
|
||||
{
|
||||
onSuccess: (e) => {
|
||||
setLink(e as unknown as LinkIncludingShortenedCollectionAndTags);
|
||||
setShowSuccess(true);
|
||||
setTimeout(() => {
|
||||
updateData({
|
||||
shareIntent: {
|
||||
hasShareIntent: false,
|
||||
url: "",
|
||||
},
|
||||
});
|
||||
router.replace("/dashboard");
|
||||
}, 1500);
|
||||
},
|
||||
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="/" />;
|
||||
|
||||
return (
|
||||
<SafeAreaView className="flex-1 bg-base-100">
|
||||
<View className="flex-1 items-center justify-center">
|
||||
{data?.shareIntent.url && showSuccess && link ? (
|
||||
<>
|
||||
<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>
|
||||
<TouchableOpacity
|
||||
className="w-fit mx-auto mt-5"
|
||||
onPress={() =>
|
||||
SheetManager.show("edit-link-sheet", {
|
||||
payload: {
|
||||
link: link,
|
||||
},
|
||||
})
|
||||
}
|
||||
>
|
||||
<Text className="text-neutral text-center w-fit">Edit Link</Text>
|
||||
</TouchableOpacity>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<ActivityIndicator size="large" />
|
||||
<Text className="mt-3 text-base text-base-content opacity-70">
|
||||
One sec…
|
||||
</Text>
|
||||
</>
|
||||
)}
|
||||
</View>
|
||||
</SafeAreaView>
|
||||
);
|
||||
}
|
||||
77
apps/mobile/app/index.tsx
Normal file
77
apps/mobile/app/index.tsx
Normal file
@@ -0,0 +1,77 @@
|
||||
import { Button } from "@/components/ui/Button";
|
||||
import { rawTheme, ThemeName } from "@/lib/colors";
|
||||
import useAuthStore from "@/store/auth";
|
||||
import { Redirect, router } from "expo-router";
|
||||
import { useColorScheme } from "nativewind";
|
||||
import { View, Text, Dimensions, TouchableOpacity, Image } from "react-native";
|
||||
import { SheetManager } from "react-native-actions-sheet";
|
||||
import Svg, { Path } from "react-native-svg";
|
||||
import Animated, { SlideInDown } from "react-native-reanimated";
|
||||
import { SafeAreaView } from "react-native-safe-area-context";
|
||||
|
||||
export default function HomeScreen() {
|
||||
const { auth } = useAuthStore();
|
||||
const { colorScheme } = useColorScheme();
|
||||
|
||||
if (auth.session) {
|
||||
return <Redirect href="/dashboard" />;
|
||||
}
|
||||
|
||||
return (
|
||||
<Animated.View
|
||||
entering={SlideInDown.springify().damping(100).stiffness(300)}
|
||||
className="flex-col justify-end h-full"
|
||||
>
|
||||
<View className="h-full bg-primary relative">
|
||||
<View className="my-auto">
|
||||
<Image
|
||||
source={require("@/assets/images/linkwarden.png")}
|
||||
className="w-[120px] h-[120px] mx-auto"
|
||||
/>
|
||||
<Text className="text-base-100 text-4xl font-semibold mt-7 mx-auto">
|
||||
Linkwarden
|
||||
</Text>
|
||||
</View>
|
||||
<View>
|
||||
<Text className="text-base-100 text-xl text-center font-semibold mx-4 mt-3">
|
||||
Welcome to the official mobile app for Linkwarden!
|
||||
</Text>
|
||||
|
||||
<Text className="text-base-100 text-xl text-center mx-4 mt-3">
|
||||
Expect regular improvements and new features as we continue refining
|
||||
the experience.
|
||||
</Text>
|
||||
</View>
|
||||
<Svg
|
||||
viewBox="0 0 1440 320"
|
||||
width={Dimensions.get("screen").width}
|
||||
height={Dimensions.get("screen").width * (320 / 1440) + 2}
|
||||
>
|
||||
<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>
|
||||
<SafeAreaView
|
||||
edges={["bottom"]}
|
||||
className="flex-col justify-end h-auto duration-100 pt-10 bg-base-100 -mt-2 pb-10 gap-4 w-full px-4"
|
||||
>
|
||||
<Button
|
||||
variant="accent"
|
||||
size="lg"
|
||||
onPress={() => router.navigate("/login")}
|
||||
>
|
||||
<Text className="text-white text-xl">Get Started</Text>
|
||||
</Button>
|
||||
<TouchableOpacity
|
||||
className="w-fit mx-auto"
|
||||
onPress={() => SheetManager.show("support-sheet")}
|
||||
>
|
||||
<Text className="text-neutral text-center w-fit">Need help?</Text>
|
||||
</TouchableOpacity>
|
||||
</SafeAreaView>
|
||||
</View>
|
||||
</Animated.View>
|
||||
);
|
||||
}
|
||||
111
apps/mobile/app/links/[id].tsx
Normal file
111
apps/mobile/app/links/[id].tsx
Normal file
@@ -0,0 +1,111 @@
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { View, ActivityIndicator, Text, Platform } from "react-native";
|
||||
import { WebView } from "react-native-webview";
|
||||
import useAuthStore from "@/store/auth";
|
||||
import { useLocalSearchParams } from "expo-router";
|
||||
import { useUser } from "@linkwarden/router/user";
|
||||
import { useGetLink } from "@linkwarden/router/links";
|
||||
import useTmpStore from "@/store/tmp";
|
||||
import { ArchivedFormat } from "@linkwarden/types/global";
|
||||
import ReadableFormat from "@/components/Formats/ReadableFormat";
|
||||
import ImageFormat from "@/components/Formats/ImageFormat";
|
||||
import PdfFormat from "@/components/Formats/PdfFormat";
|
||||
import WebpageFormat from "@/components/Formats/WebpageFormat";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
|
||||
export default function LinkScreen() {
|
||||
const { auth } = useAuthStore();
|
||||
const { id, format } = useLocalSearchParams();
|
||||
const { data: user } = useUser(auth);
|
||||
const [url, setUrl] = useState<string>();
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
||||
const { data: link } = useGetLink({ id: Number(id), auth, enabled: true });
|
||||
|
||||
const { updateTmp } = useTmpStore();
|
||||
|
||||
useEffect(() => {
|
||||
if (link?.id && user?.id)
|
||||
updateTmp({
|
||||
link,
|
||||
user: {
|
||||
id: user.id,
|
||||
},
|
||||
});
|
||||
|
||||
return () =>
|
||||
updateTmp({
|
||||
link: null,
|
||||
});
|
||||
}, [link, user]);
|
||||
|
||||
useEffect(() => {
|
||||
if (user?.id && link?.id && format) {
|
||||
setUrl(`${auth.instance}/api/v1/archives/${link.id}?format=${format}`);
|
||||
} else if (!url) {
|
||||
if (link?.url) {
|
||||
setUrl(link.url);
|
||||
}
|
||||
}
|
||||
}, [user, link]);
|
||||
|
||||
const insets = useSafeAreaInsets();
|
||||
|
||||
return (
|
||||
<View
|
||||
className="flex-1"
|
||||
style={{ paddingBottom: Platform.OS === "android" ? insets.bottom : 0 }}
|
||||
>
|
||||
{link?.id && Number(format) === ArchivedFormat.readability ? (
|
||||
<ReadableFormat
|
||||
link={link as any}
|
||||
setIsLoading={(state) => setIsLoading(state)}
|
||||
/>
|
||||
) : link?.id &&
|
||||
(Number(format) === ArchivedFormat.jpeg ||
|
||||
Number(format) === ArchivedFormat.png) ? (
|
||||
<ImageFormat
|
||||
link={link as any}
|
||||
setIsLoading={(state) => setIsLoading(state)}
|
||||
format={Number(format)}
|
||||
/>
|
||||
) : link?.id && Number(format) === ArchivedFormat.pdf ? (
|
||||
<PdfFormat
|
||||
link={link as any}
|
||||
setIsLoading={(state) => setIsLoading(state)}
|
||||
/>
|
||||
) : link?.id && Number(format) === ArchivedFormat.monolith ? (
|
||||
<WebpageFormat
|
||||
link={link as any}
|
||||
setIsLoading={(state) => setIsLoading(state)}
|
||||
/>
|
||||
) : url ? (
|
||||
<WebView
|
||||
className={isLoading ? "opacity-0" : "flex-1"}
|
||||
source={{
|
||||
uri: url,
|
||||
headers:
|
||||
format || link?.type !== "url"
|
||||
? { 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>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
197
apps/mobile/app/login.tsx
Normal file
197
apps/mobile/app/login.tsx
Normal file
@@ -0,0 +1,197 @@
|
||||
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 { useEffect, useState } from "react";
|
||||
import { View, Text, Dimensions, TouchableOpacity, Image } from "react-native";
|
||||
import { SheetManager } from "react-native-actions-sheet";
|
||||
import Svg, { Path } from "react-native-svg";
|
||||
import {
|
||||
KeyboardStickyView,
|
||||
KeyboardToolbar,
|
||||
} from "react-native-keyboard-controller";
|
||||
import { SafeAreaView } from "react-native-safe-area-context";
|
||||
|
||||
export default function HomeScreen() {
|
||||
const { auth, signIn } = useAuthStore();
|
||||
const { colorScheme } = useColorScheme();
|
||||
const [method, setMethod] = useState<"password" | "token">("password");
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
const [form, setForm] = useState({
|
||||
user: "",
|
||||
password: "",
|
||||
token: "",
|
||||
instance: auth.instance || "https://cloud.linkwarden.app",
|
||||
});
|
||||
|
||||
const [showInstanceField, setShowInstanceField] = useState(
|
||||
form.instance !== "https://cloud.linkwarden.app"
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
setForm((prev) => ({
|
||||
...prev,
|
||||
instance: auth.instance || "https://cloud.linkwarden.app",
|
||||
}));
|
||||
}, [auth.instance]);
|
||||
|
||||
useEffect(() => {
|
||||
setShowInstanceField(form.instance !== "https://cloud.linkwarden.app");
|
||||
}, [form.instance]);
|
||||
|
||||
useEffect(() => {
|
||||
setForm((prev) => ({
|
||||
...prev,
|
||||
token: "",
|
||||
user: "",
|
||||
password: "",
|
||||
}));
|
||||
}, [method]);
|
||||
|
||||
if (auth.status === "authenticated") {
|
||||
return <Redirect href="/dashboard" />;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<KeyboardStickyView className="flex-col justify-end h-full bg-base-100 relative">
|
||||
<View className="flex-col justify-end h-full bg-primary relative">
|
||||
<View className="my-auto">
|
||||
<Image
|
||||
source={require("@/assets/images/linkwarden.png")}
|
||||
className="w-[120px] h-[120px] mx-auto"
|
||||
/>
|
||||
</View>
|
||||
<Text className="text-base-100 text-7xl font-bold ml-8">Login</Text>
|
||||
<View>
|
||||
<Text
|
||||
className="text-base-100 text-2xl mx-8 mt-3"
|
||||
numberOfLines={1}
|
||||
>
|
||||
Login to{" "}
|
||||
{form.instance === "https://cloud.linkwarden.app"
|
||||
? "cloud.linkwarden.app"
|
||||
: form.instance}
|
||||
</Text>
|
||||
<TouchableOpacity
|
||||
onPress={() => {
|
||||
if (showInstanceField) {
|
||||
setForm({
|
||||
...form,
|
||||
instance: "https://cloud.linkwarden.app",
|
||||
});
|
||||
}
|
||||
setShowInstanceField(!showInstanceField);
|
||||
}}
|
||||
className="mx-8 mt-1 self-start"
|
||||
>
|
||||
<Text className="text-neutral-content text-sm">
|
||||
{!showInstanceField ? "Change server" : "Use official server"}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
<Svg
|
||||
viewBox="0 0 1440 320"
|
||||
width={Dimensions.get("screen").width}
|
||||
height={Dimensions.get("screen").width * (320 / 1440) + 2}
|
||||
>
|
||||
<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>
|
||||
<SafeAreaView
|
||||
edges={["bottom"]}
|
||||
className="flex-col justify-end h-auto duration-100 pt-10 bg-base-100 -mt-2 pb-10 gap-4 w-full px-4"
|
||||
>
|
||||
{showInstanceField && (
|
||||
<Input
|
||||
className="w-full text-xl p-3 leading-tight h-12"
|
||||
textAlignVertical="center"
|
||||
placeholder="Instance URL"
|
||||
selectTextOnFocus={false}
|
||||
value={form.instance}
|
||||
onChangeText={(text) => setForm({ ...form, instance: text })}
|
||||
/>
|
||||
)}
|
||||
{method === "password" ? (
|
||||
<>
|
||||
<Input
|
||||
className="w-full text-xl p-3 leading-tight h-12"
|
||||
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 h-12"
|
||||
textAlignVertical="center"
|
||||
placeholder="Password"
|
||||
secureTextEntry
|
||||
value={form.password}
|
||||
onChangeText={(text) => setForm({ ...form, password: text })}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<Input
|
||||
className="w-full text-xl p-3 leading-tight h-12"
|
||||
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"
|
||||
: "Login with Username/Password"}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
|
||||
<Button
|
||||
variant="accent"
|
||||
size="lg"
|
||||
isLoading={isLoading}
|
||||
onPress={async () => {
|
||||
if (
|
||||
((form.user && form.password) || form.token) &&
|
||||
form.instance
|
||||
) {
|
||||
setIsLoading(true);
|
||||
await signIn(
|
||||
form.user,
|
||||
form.password,
|
||||
form.instance,
|
||||
form.token
|
||||
);
|
||||
setIsLoading(false);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Text className="text-white text-xl">Login</Text>
|
||||
</Button>
|
||||
<TouchableOpacity
|
||||
className="w-fit mx-auto"
|
||||
onPress={() => SheetManager.show("support-sheet")}
|
||||
>
|
||||
<Text className="text-neutral text-center w-fit">Need help?</Text>
|
||||
</TouchableOpacity>
|
||||
</SafeAreaView>
|
||||
</View>
|
||||
</KeyboardStickyView>
|
||||
<KeyboardToolbar />
|
||||
</>
|
||||
);
|
||||
}
|
||||
BIN
apps/mobile/assets/fonts/SpaceMono-Regular.ttf
Executable file
BIN
apps/mobile/assets/fonts/SpaceMono-Regular.ttf
Executable file
Binary file not shown.
BIN
apps/mobile/assets/images/favicon.png
Normal file
BIN
apps/mobile/assets/images/favicon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.4 KiB |
BIN
apps/mobile/assets/images/icon.png
Normal file
BIN
apps/mobile/assets/images/icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 238 KiB |
BIN
apps/mobile/assets/images/linkwarden.png
Normal file
BIN
apps/mobile/assets/images/linkwarden.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 237 KiB |
BIN
apps/mobile/assets/images/maskable_logo.jpeg
Normal file
BIN
apps/mobile/assets/images/maskable_logo.jpeg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 8.7 KiB |
BIN
apps/mobile/assets/images/splash-icon.png
Normal file
BIN
apps/mobile/assets/images/splash-icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 82 B |
9
apps/mobile/babel.config.js
Normal file
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
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";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
|
||||
export default function AddLinkSheet() {
|
||||
const actionSheetRef = useRef<ActionSheetRef>(null);
|
||||
const { auth } = useAuthStore();
|
||||
const addLink = useAddLink({ auth, Alert });
|
||||
const [link, setLink] = useState("");
|
||||
const { colorScheme } = useColorScheme();
|
||||
|
||||
const insets = useSafeAreaInsets();
|
||||
|
||||
return (
|
||||
<ActionSheet
|
||||
ref={actionSheetRef}
|
||||
gestureEnabled
|
||||
indicatorStyle={{
|
||||
display: "none",
|
||||
}}
|
||||
containerStyle={{
|
||||
backgroundColor: rawTheme[colorScheme as ThemeName]["base-200"],
|
||||
}}
|
||||
safeAreaInsets={insets}
|
||||
>
|
||||
<View className="px-8 py-5">
|
||||
<Text className="font-semibold text-lg mx-auto mb-5 text-base-content">
|
||||
New Link
|
||||
</Text>
|
||||
|
||||
<Input
|
||||
placeholder="e.g. https://example.com"
|
||||
className="mb-4 bg-base-100"
|
||||
value={link}
|
||||
onChangeText={setLink}
|
||||
/>
|
||||
|
||||
<Button
|
||||
onPress={() => {
|
||||
addLink.mutate({ url: link });
|
||||
|
||||
actionSheetRef.current?.hide();
|
||||
setLink("");
|
||||
}}
|
||||
isLoading={addLink.isPending}
|
||||
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>
|
||||
);
|
||||
}
|
||||
396
apps/mobile/components/ActionSheets/EditLinkSheet.tsx
Normal file
396
apps/mobile/components/ActionSheets/EditLinkSheet.tsx
Normal file
@@ -0,0 +1,396 @@
|
||||
import { View, Text, Alert, TouchableOpacity } 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,
|
||||
TagIncludingLinkCount,
|
||||
} from "@linkwarden/types/global";
|
||||
import { useCollections } from "@linkwarden/router/collections";
|
||||
import { rawTheme, ThemeName } from "@/lib/colors";
|
||||
import { useColorScheme } from "nativewind";
|
||||
import { Folder, ChevronRight, ChevronLeft, Check } from "lucide-react-native";
|
||||
import useTmpStore from "@/store/tmp";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { useTags } from "@linkwarden/router/tags";
|
||||
|
||||
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 updateLink = useUpdateLink({ auth, Alert });
|
||||
const router = useSheetRouter("edit-link-sheet");
|
||||
const { colorScheme } = useColorScheme();
|
||||
|
||||
useEffect(() => {
|
||||
if (params?.link) {
|
||||
setLink(params.link);
|
||||
}
|
||||
}, [params?.link]);
|
||||
|
||||
const { tmp, updateTmp } = useTmpStore();
|
||||
|
||||
return (
|
||||
<View className="px-8 py-5">
|
||||
<Text className="font-semibold text-lg mx-auto mb-5 text-base-content">
|
||||
Edit Link
|
||||
</Text>
|
||||
|
||||
<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"
|
||||
onPress={() => router?.navigate("tags", { link })}
|
||||
>
|
||||
{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-neutral rounded-md h-7 px-2 py-1"
|
||||
>
|
||||
<Text numberOfLines={1} className="text-base-100">
|
||||
{tag.name}
|
||||
</Text>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
) : (
|
||||
<Text className="text-neutral">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={() => {
|
||||
updateLink.mutate(link as LinkIncludingShortenedCollectionAndTags);
|
||||
if (link && tmp.link)
|
||||
updateTmp({
|
||||
link,
|
||||
});
|
||||
SheetManager.hide("edit-link-sheet");
|
||||
}}
|
||||
isLoading={updateLink.isPending}
|
||||
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 = () => {
|
||||
const updatedLink = {
|
||||
...currentLink,
|
||||
collection,
|
||||
};
|
||||
|
||||
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="py-5 max-h-[80vh]">
|
||||
<TouchableOpacity
|
||||
className="flex-row items-center gap-1 top-6 left-8 absolute"
|
||||
onPress={() => {
|
||||
router?.popToTop();
|
||||
router?.navigate("main", { link: currentLink });
|
||||
}}
|
||||
>
|
||||
<ChevronLeft
|
||||
size={18}
|
||||
color={rawTheme[colorScheme as ThemeName]["primary"]}
|
||||
/>
|
||||
<Text className="text-primary">Back</Text>
|
||||
</TouchableOpacity>
|
||||
<Text className="font-semibold text-lg mx-auto mb-5 text-base-content">
|
||||
Collection
|
||||
</Text>
|
||||
<Input
|
||||
placeholder="Search collections"
|
||||
className="mb-4 bg-base-100 mx-8"
|
||||
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>
|
||||
}
|
||||
contentContainerClassName="px-8"
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
const Tags = () => {
|
||||
const { auth } = useAuthStore();
|
||||
const addLink = useAddLink({ auth });
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const router = useSheetRouter("edit-link-sheet");
|
||||
const params = useSheetRouteParams("edit-link-sheet", "tags");
|
||||
const tags = useTags(auth);
|
||||
const { colorScheme } = useColorScheme();
|
||||
const [updatedLink, setUpdatedLink] =
|
||||
useState<LinkIncludingShortenedCollectionAndTags>(params.link);
|
||||
|
||||
const filteredTags = useMemo(() => {
|
||||
if (!tags.data) return [];
|
||||
const q = searchQuery.trim().toLowerCase();
|
||||
if (q === "") return tags.data;
|
||||
return tags.data.filter((tag) => tag.name.toLowerCase().includes(q));
|
||||
}, [tags.data, searchQuery]);
|
||||
|
||||
const renderItem = useCallback(
|
||||
({ item: tag }: { item: TagIncludingLinkCount }) => {
|
||||
const onSelect = () => {
|
||||
const isSelected = (updatedLink?.tags || []).some(
|
||||
(t) => t.id === tag.id
|
||||
);
|
||||
const nextTags = isSelected
|
||||
? (updatedLink?.tags || []).filter((t) => t.id !== tag.id)
|
||||
: [...(updatedLink?.tags || []), tag];
|
||||
|
||||
setUpdatedLink({
|
||||
...updatedLink,
|
||||
tags: nextTags,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Button variant="input" className="mb-2" onPress={onSelect}>
|
||||
<View className="flex-row items-center gap-2 w-[75%]">
|
||||
<Text numberOfLines={1} className="w-full text-base-content">
|
||||
{tag.name}
|
||||
</Text>
|
||||
</View>
|
||||
<View className="flex-row items-center gap-2">
|
||||
{updatedLink?.tags.find((e) => e.id === tag.id) && (
|
||||
<Check
|
||||
size={16}
|
||||
color={rawTheme[colorScheme as ThemeName].primary}
|
||||
/>
|
||||
)}
|
||||
<Text className="text-neutral">{tag._count?.links ?? 0}</Text>
|
||||
</View>
|
||||
</Button>
|
||||
);
|
||||
},
|
||||
[addLink, params.link, router]
|
||||
);
|
||||
|
||||
return (
|
||||
<View className="py-5 max-h-[80vh]">
|
||||
<TouchableOpacity
|
||||
className="flex-row items-center gap-1 top-6 left-8 absolute"
|
||||
onPress={() => {
|
||||
router?.popToTop();
|
||||
router?.navigate("main", { link: updatedLink });
|
||||
}}
|
||||
>
|
||||
<ChevronLeft
|
||||
size={18}
|
||||
color={rawTheme[colorScheme as ThemeName]["primary"]}
|
||||
/>
|
||||
<Text className="text-primary">Back</Text>
|
||||
</TouchableOpacity>
|
||||
<Text className="font-semibold text-lg mx-auto mb-5 text-base-content">
|
||||
Tags
|
||||
</Text>
|
||||
<Input
|
||||
placeholder="Search tags"
|
||||
className="mb-4 bg-base-100 mx-8"
|
||||
value={searchQuery}
|
||||
onChangeText={setSearchQuery}
|
||||
/>
|
||||
|
||||
<FlatList
|
||||
data={filteredTags}
|
||||
keyExtractor={(e, i) => i.toString()}
|
||||
renderItem={renderItem}
|
||||
ListEmptyComponent={
|
||||
<Text
|
||||
style={{ textAlign: "center", marginTop: 20 }}
|
||||
className="text-neutral"
|
||||
>
|
||||
No tags match “{searchQuery}”
|
||||
</Text>
|
||||
}
|
||||
contentContainerClassName="px-8"
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
const routes: Route[] = [
|
||||
{
|
||||
name: "main",
|
||||
component: Main,
|
||||
},
|
||||
{
|
||||
name: "collections",
|
||||
component: Collections,
|
||||
},
|
||||
{
|
||||
name: "tags",
|
||||
component: Tags,
|
||||
},
|
||||
];
|
||||
|
||||
export default function EditLinkSheet() {
|
||||
const { colorScheme } = useColorScheme();
|
||||
|
||||
const insets = useSafeAreaInsets();
|
||||
|
||||
return (
|
||||
<ActionSheet
|
||||
gestureEnabled
|
||||
indicatorStyle={{
|
||||
display: "none",
|
||||
}}
|
||||
routes={routes}
|
||||
initialRoute="main"
|
||||
containerStyle={{
|
||||
backgroundColor: rawTheme[colorScheme as ThemeName]["base-200"],
|
||||
}}
|
||||
safeAreaInsets={insets}
|
||||
/>
|
||||
);
|
||||
}
|
||||
96
apps/mobile/components/ActionSheets/NewCollectionSheet.tsx
Normal file
96
apps/mobile/components/ActionSheets/NewCollectionSheet.tsx
Normal file
@@ -0,0 +1,96 @@
|
||||
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 useAuthStore from "@/store/auth";
|
||||
import { rawTheme, ThemeName } from "@/lib/colors";
|
||||
import { useColorScheme } from "nativewind";
|
||||
import { useCreateCollection } from "@linkwarden/router/collections";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
|
||||
export default function NewCollectionSheet() {
|
||||
const actionSheetRef = useRef<ActionSheetRef>(null);
|
||||
const { auth } = useAuthStore();
|
||||
const createCollection = useCreateCollection(auth);
|
||||
const [collection, setCollection] = useState({
|
||||
name: "",
|
||||
description: "",
|
||||
});
|
||||
const { colorScheme } = useColorScheme();
|
||||
|
||||
const insets = useSafeAreaInsets();
|
||||
|
||||
return (
|
||||
<ActionSheet
|
||||
ref={actionSheetRef}
|
||||
gestureEnabled
|
||||
indicatorStyle={{
|
||||
display: "none",
|
||||
}}
|
||||
containerStyle={{
|
||||
backgroundColor: rawTheme[colorScheme as ThemeName]["base-200"],
|
||||
}}
|
||||
safeAreaInsets={insets}
|
||||
>
|
||||
<View className="px-8 py-5">
|
||||
<Text className="font-semibold text-lg mx-auto mb-5 text-base-content">
|
||||
New Collection
|
||||
</Text>
|
||||
|
||||
<Input
|
||||
placeholder="Name"
|
||||
className="mb-4 bg-base-100"
|
||||
value={collection.name}
|
||||
onChangeText={(text) => setCollection({ ...collection, name: text })}
|
||||
/>
|
||||
|
||||
<Input
|
||||
placeholder="Description"
|
||||
className="mb-4 bg-base-100"
|
||||
value={collection.description}
|
||||
onChangeText={(text) =>
|
||||
setCollection({ ...collection, description: text })
|
||||
}
|
||||
/>
|
||||
|
||||
<Button
|
||||
onPress={() =>
|
||||
createCollection.mutate(
|
||||
{ name: collection.name, description: collection.description },
|
||||
{
|
||||
onSuccess: () => {
|
||||
actionSheetRef.current?.hide();
|
||||
setCollection({ name: "", description: "" });
|
||||
},
|
||||
onError: (error) => {
|
||||
Alert.alert(
|
||||
"Error",
|
||||
"There was an error creating the collection."
|
||||
);
|
||||
console.error("Error creating collection:", error);
|
||||
},
|
||||
}
|
||||
)
|
||||
}
|
||||
isLoading={createCollection.isPending}
|
||||
variant="accent"
|
||||
className="mb-2"
|
||||
>
|
||||
<Text className="text-white">Save Collection</Text>
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
onPress={() => {
|
||||
actionSheetRef.current?.hide();
|
||||
setCollection({ name: "", description: "" });
|
||||
}}
|
||||
variant="outline"
|
||||
className="mb-2"
|
||||
>
|
||||
<Text className="text-base-content">Cancel</Text>
|
||||
</Button>
|
||||
</View>
|
||||
</ActionSheet>
|
||||
);
|
||||
}
|
||||
41
apps/mobile/components/ActionSheets/Sheets.tsx
Normal file
41
apps/mobile/components/ActionSheets/Sheets.tsx
Normal file
@@ -0,0 +1,41 @@
|
||||
import {
|
||||
registerSheet,
|
||||
RouteDefinition,
|
||||
SheetDefinition,
|
||||
} from "react-native-actions-sheet";
|
||||
import SupportSheet from "./SupportSheet";
|
||||
import AddLinkSheet from "./AddLinkSheet";
|
||||
import EditLinkSheet from "./EditLinkSheet";
|
||||
import NewCollectionSheet from "./NewCollectionSheet";
|
||||
import { LinkIncludingShortenedCollectionAndTags } from "@linkwarden/types/global";
|
||||
|
||||
registerSheet("support-sheet", SupportSheet);
|
||||
registerSheet("add-link-sheet", AddLinkSheet);
|
||||
registerSheet("edit-link-sheet", EditLinkSheet);
|
||||
registerSheet("new-collection-sheet", NewCollectionSheet);
|
||||
|
||||
declare module "react-native-actions-sheet" {
|
||||
interface Sheets {
|
||||
"support-sheet": SheetDefinition;
|
||||
"add-link-sheet": SheetDefinition;
|
||||
"edit-link-sheet": SheetDefinition<{
|
||||
payload: {
|
||||
link: LinkIncludingShortenedCollectionAndTags;
|
||||
};
|
||||
routes: {
|
||||
main: RouteDefinition<{
|
||||
link: LinkIncludingShortenedCollectionAndTags;
|
||||
}>;
|
||||
collections: RouteDefinition<{
|
||||
link: LinkIncludingShortenedCollectionAndTags;
|
||||
}>;
|
||||
tags: RouteDefinition<{
|
||||
link: LinkIncludingShortenedCollectionAndTags;
|
||||
}>;
|
||||
};
|
||||
}>;
|
||||
"new-collection-sheet": SheetDefinition;
|
||||
}
|
||||
}
|
||||
|
||||
export {};
|
||||
47
apps/mobile/components/ActionSheets/SupportSheet.tsx
Normal file
47
apps/mobile/components/ActionSheets/SupportSheet.tsx
Normal file
@@ -0,0 +1,47 @@
|
||||
import { Text, View } from "react-native";
|
||||
import { useState } from "react";
|
||||
import ActionSheet from "react-native-actions-sheet";
|
||||
import { rawTheme, ThemeName } from "@/lib/colors";
|
||||
import { useColorScheme } from "nativewind";
|
||||
import * as Clipboard from "expo-clipboard";
|
||||
import { Button } from "../ui/Button";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
|
||||
export default function SupportSheet() {
|
||||
const { colorScheme } = useColorScheme();
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
async function handleEmailPress() {
|
||||
await Clipboard.setStringAsync("support@linkwarden.app");
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
}
|
||||
|
||||
const insets = useSafeAreaInsets();
|
||||
|
||||
return (
|
||||
<ActionSheet
|
||||
gestureEnabled
|
||||
indicatorStyle={{
|
||||
backgroundColor: rawTheme[colorScheme as ThemeName]["neutral-content"],
|
||||
}}
|
||||
containerStyle={{
|
||||
backgroundColor: rawTheme[colorScheme as ThemeName]["base-100"],
|
||||
}}
|
||||
safeAreaInsets={insets}
|
||||
>
|
||||
<View className="px-8 py-5 flex-col gap-4">
|
||||
<Text className="text-2xl font-bold text-base-content">Need help?</Text>
|
||||
<Text className="text-base-content">
|
||||
Whether you have a question or need assistance, feel free to reach out
|
||||
to us at support@linkwarden.app
|
||||
</Text>
|
||||
<Button onPress={handleEmailPress} variant="outline">
|
||||
<Text className="text-base-content">
|
||||
{copied ? "Copied!" : "Copy Support Email"}
|
||||
</Text>
|
||||
</Button>
|
||||
</View>
|
||||
</ActionSheet>
|
||||
);
|
||||
}
|
||||
132
apps/mobile/components/CollectionListing.tsx
Normal file
132
apps/mobile/components/CollectionListing.tsx
Normal file
@@ -0,0 +1,132 @@
|
||||
import { View, Text, Pressable, Platform, Alert } from "react-native";
|
||||
import { decode } from "html-entities";
|
||||
import { CollectionIncludingMembersAndLinkCount } from "@linkwarden/types/global";
|
||||
import useAuthStore from "@/store/auth";
|
||||
import { useRouter } from "expo-router";
|
||||
import * as ContextMenu from "zeego/context-menu";
|
||||
import { cn } from "@linkwarden/lib/utils";
|
||||
import { rawTheme, ThemeName } from "@/lib/colors";
|
||||
import { useColorScheme } from "nativewind";
|
||||
import { CalendarDays, Folder, Link } from "lucide-react-native";
|
||||
import { useDeleteCollection } from "@linkwarden/router/collections";
|
||||
|
||||
type Props = {
|
||||
collection: CollectionIncludingMembersAndLinkCount;
|
||||
};
|
||||
|
||||
const CollectionListing = ({ collection }: Props) => {
|
||||
const { auth } = useAuthStore();
|
||||
const router = useRouter();
|
||||
const { colorScheme } = useColorScheme();
|
||||
|
||||
const deleteCollection = useDeleteCollection({ auth, Alert });
|
||||
|
||||
return (
|
||||
<ContextMenu.Root>
|
||||
<ContextMenu.Trigger asChild>
|
||||
<Pressable
|
||||
className={cn(
|
||||
"p-5 flex-row justify-between",
|
||||
"bg-base-100",
|
||||
Platform.OS !== "android" && "active:bg-base-200/50"
|
||||
)}
|
||||
onLongPress={() => {}}
|
||||
onPress={() => router.navigate(`/collections/${collection.id}`)}
|
||||
android_ripple={{
|
||||
color: colorScheme === "dark" ? "rgba(255,255,255,0.2)" : "#ddd",
|
||||
borderless: false,
|
||||
}}
|
||||
>
|
||||
<View className="w-full">
|
||||
<View className="w-[90%] flex-col justify-between gap-3">
|
||||
<View className="flex flex-row gap-2 items-center pr-1.5 self-start rounded-md">
|
||||
<Folder
|
||||
size={16}
|
||||
fill={collection.color || ""}
|
||||
color={collection.color || ""}
|
||||
/>
|
||||
<Text
|
||||
numberOfLines={2}
|
||||
className="font-medium text-lg text-base-content"
|
||||
>
|
||||
{decode(collection.name)}
|
||||
</Text>
|
||||
</View>
|
||||
{collection.description && (
|
||||
<Text
|
||||
numberOfLines={2}
|
||||
className="font-light text-sm text-base-content"
|
||||
>
|
||||
{decode(collection.description)}
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
|
||||
<View className="flex-row gap-3">
|
||||
<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(collection.createdAt as string).toLocaleString(
|
||||
"en-US",
|
||||
{
|
||||
year: "numeric",
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
}
|
||||
)}
|
||||
</Text>
|
||||
</View>
|
||||
<View className="flex flex-row gap-1 items-center mt-5 self-start">
|
||||
<Link
|
||||
size={16}
|
||||
color={rawTheme[colorScheme as ThemeName]["neutral"]}
|
||||
/>
|
||||
<Text
|
||||
numberOfLines={1}
|
||||
className="font-light text-xs text-base-content"
|
||||
>
|
||||
{collection._count?.links}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
</Pressable>
|
||||
</ContextMenu.Trigger>
|
||||
|
||||
<ContextMenu.Content avoidCollisions>
|
||||
<ContextMenu.Item
|
||||
key="delete-collection"
|
||||
onSelect={() => {
|
||||
return Alert.alert(
|
||||
"Delete Collection",
|
||||
"Are you sure you want to delete this collection? This action cannot be undone.",
|
||||
[
|
||||
{
|
||||
text: "Cancel",
|
||||
style: "cancel",
|
||||
},
|
||||
{
|
||||
text: "Delete",
|
||||
style: "destructive",
|
||||
onPress: () => {
|
||||
deleteCollection.mutate(collection.id as number);
|
||||
},
|
||||
},
|
||||
]
|
||||
);
|
||||
}}
|
||||
>
|
||||
<ContextMenu.ItemTitle>Delete</ContextMenu.ItemTitle>
|
||||
</ContextMenu.Item>
|
||||
</ContextMenu.Content>
|
||||
</ContextMenu.Root>
|
||||
);
|
||||
};
|
||||
|
||||
export default CollectionListing;
|
||||
35
apps/mobile/components/DashboardItem.tsx
Normal file
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>
|
||||
);
|
||||
}
|
||||
346
apps/mobile/components/DashboardSection.tsx
Normal file
346
apps/mobile/components/DashboardSection.tsx
Normal file
@@ -0,0 +1,346 @@
|
||||
import {
|
||||
FlatList,
|
||||
Text,
|
||||
TouchableOpacity,
|
||||
View,
|
||||
ViewToken,
|
||||
} from "react-native";
|
||||
import React from "react";
|
||||
import clsx from "clsx";
|
||||
import DashboardItem from "@/components/DashboardItem";
|
||||
import { rawTheme, ThemeName } from "@/lib/colors";
|
||||
import {
|
||||
Clock8,
|
||||
ChevronRight,
|
||||
Pin,
|
||||
Folder,
|
||||
Hash,
|
||||
Link,
|
||||
} from "lucide-react-native";
|
||||
import { LinkIncludingShortenedCollectionAndTags } from "@linkwarden/types/global";
|
||||
import LinkListing from "@/components/LinkListing";
|
||||
import { useColorScheme } from "nativewind";
|
||||
import { useRouter } from "expo-router";
|
||||
|
||||
// Don't use prisma client's DashboardSectionType, it'll crash in production (React Native)
|
||||
type DashboardSectionType =
|
||||
| "STATS"
|
||||
| "RECENT_LINKS"
|
||||
| "PINNED_LINKS"
|
||||
| "COLLECTION";
|
||||
|
||||
type DashboardSectionProps = {
|
||||
sectionData: { type: DashboardSectionType };
|
||||
collection?: any;
|
||||
links?: any[];
|
||||
tagsLength: number;
|
||||
numberOfLinks: number;
|
||||
collectionsLength: number;
|
||||
numberOfPinnedLinks: number;
|
||||
dashboardData: {
|
||||
isLoading: boolean;
|
||||
refetch: Function;
|
||||
isRefetching: boolean;
|
||||
};
|
||||
collectionLinks?: any[];
|
||||
};
|
||||
|
||||
const DashboardSection: React.FC<DashboardSectionProps> = ({
|
||||
sectionData,
|
||||
collection,
|
||||
links = [],
|
||||
tagsLength,
|
||||
numberOfLinks,
|
||||
collectionsLength,
|
||||
numberOfPinnedLinks,
|
||||
dashboardData,
|
||||
collectionLinks = [],
|
||||
}) => {
|
||||
const { colorScheme } = useColorScheme();
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
switch (sectionData.type) {
|
||||
case "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 "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,
|
||||
}}
|
||||
onViewableItemsChanged={({
|
||||
viewableItems,
|
||||
}: {
|
||||
viewableItems: ViewToken[];
|
||||
}) => {
|
||||
const links = viewableItems.map(
|
||||
(e) => e.item
|
||||
) as LinkIncludingShortenedCollectionAndTags[];
|
||||
|
||||
if (
|
||||
!dashboardData.isRefetching &&
|
||||
links.some((e) => e.id && !e.preview)
|
||||
) {
|
||||
dashboardData.refetch();
|
||||
}
|
||||
}}
|
||||
viewabilityConfig={{ itemVisiblePercentThreshold: 50 }}
|
||||
/>
|
||||
) : (
|
||||
<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 "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]) || []}
|
||||
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,
|
||||
}}
|
||||
onViewableItemsChanged={({
|
||||
viewableItems,
|
||||
}: {
|
||||
viewableItems: ViewToken[];
|
||||
}) => {
|
||||
const links = viewableItems.map(
|
||||
(e) => e.item
|
||||
) as LinkIncludingShortenedCollectionAndTags[];
|
||||
|
||||
if (
|
||||
!dashboardData.isRefetching &&
|
||||
links.some((e) => e.id && !e.preview)
|
||||
) {
|
||||
dashboardData.refetch();
|
||||
}
|
||||
}}
|
||||
viewabilityConfig={{ itemVisiblePercentThreshold: 50 }}
|
||||
/>
|
||||
) : (
|
||||
<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 "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 || []}
|
||||
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,
|
||||
}}
|
||||
onViewableItemsChanged={({
|
||||
viewableItems,
|
||||
}: {
|
||||
viewableItems: ViewToken[];
|
||||
}) => {
|
||||
const links = viewableItems.map(
|
||||
(e) => e.item
|
||||
) as LinkIncludingShortenedCollectionAndTags[];
|
||||
|
||||
if (
|
||||
!dashboardData.isRefetching &&
|
||||
links.some((e) => e.id && !e.preview)
|
||||
) {
|
||||
dashboardData.refetch();
|
||||
}
|
||||
}}
|
||||
viewabilityConfig={{ itemVisiblePercentThreshold: 50 }}
|
||||
/>
|
||||
) : (
|
||||
<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;
|
||||
}
|
||||
};
|
||||
|
||||
export default DashboardSection;
|
||||
|
||||
const RenderItem = React.memo(
|
||||
({ item }: { item: LinkIncludingShortenedCollectionAndTags }) => {
|
||||
return <LinkListing link={item} dashboard />;
|
||||
}
|
||||
);
|
||||
18
apps/mobile/components/ElementNotSupported.tsx
Normal file
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>
|
||||
);
|
||||
}
|
||||
136
apps/mobile/components/Formats/ImageFormat.tsx
Normal file
136
apps/mobile/components/Formats/ImageFormat.tsx
Normal file
@@ -0,0 +1,136 @@
|
||||
import React, { useEffect, useState } from "react";
|
||||
import * as FileSystem from "expo-file-system";
|
||||
import NetInfo from "@react-native-community/netinfo";
|
||||
import useAuthStore from "@/store/auth";
|
||||
import { ArchivedFormat } from "@linkwarden/types/global";
|
||||
import { Link as LinkType } from "@linkwarden/prisma/client";
|
||||
import WebView from "react-native-webview";
|
||||
import { Image, Platform, ScrollView } from "react-native";
|
||||
|
||||
type Props = {
|
||||
link: LinkType;
|
||||
setIsLoading: (state: boolean) => void;
|
||||
format: ArchivedFormat.png | ArchivedFormat.jpeg;
|
||||
};
|
||||
|
||||
export default function ImageFormat({ link, setIsLoading, format }: Props) {
|
||||
const FORMAT = format;
|
||||
|
||||
const extension = format === ArchivedFormat.png ? "png" : "jpeg";
|
||||
|
||||
const { auth } = useAuthStore();
|
||||
const [content, setContent] = useState<string>("");
|
||||
const [dimension, setDimension] = useState<{
|
||||
width: number;
|
||||
height: number;
|
||||
}>();
|
||||
|
||||
useEffect(() => {
|
||||
if (content)
|
||||
Image.getSize(content, (width, height) => {
|
||||
setDimension({ width, height });
|
||||
});
|
||||
}, [content]);
|
||||
|
||||
useEffect(() => {
|
||||
async function loadCacheOrFetch() {
|
||||
const filePath =
|
||||
FileSystem.documentDirectory +
|
||||
`archivedData/${extension}/link_${link.id}.${extension}`;
|
||||
|
||||
await FileSystem.makeDirectoryAsync(
|
||||
filePath.substring(0, filePath.lastIndexOf("/")),
|
||||
{
|
||||
intermediates: true,
|
||||
}
|
||||
).catch(() => {});
|
||||
|
||||
const [info] = await Promise.all([FileSystem.getInfoAsync(filePath)]);
|
||||
|
||||
if (info.exists) {
|
||||
setContent(filePath);
|
||||
}
|
||||
|
||||
const net = await NetInfo.fetch();
|
||||
|
||||
if (net.isConnected) {
|
||||
const apiUrl = `${auth.instance}/api/v1/archives/${link.id}?format=${FORMAT}`;
|
||||
|
||||
try {
|
||||
const result = await FileSystem.downloadAsync(apiUrl, filePath, {
|
||||
headers: { Authorization: `Bearer ${auth.session}` },
|
||||
});
|
||||
|
||||
setContent(result.uri);
|
||||
} catch (e) {
|
||||
console.error("Failed to fetch content", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
loadCacheOrFetch();
|
||||
}, [link]);
|
||||
|
||||
if (Platform.OS === "ios")
|
||||
return (
|
||||
content &&
|
||||
dimension && (
|
||||
<ScrollView maximumZoomScale={10}>
|
||||
<Image
|
||||
source={{ uri: content }}
|
||||
onLoadEnd={() => setIsLoading(false)}
|
||||
style={{
|
||||
width: "100%",
|
||||
height: "auto",
|
||||
aspectRatio: dimension.width / dimension.height,
|
||||
}}
|
||||
resizeMode="contain"
|
||||
/>
|
||||
</ScrollView>
|
||||
)
|
||||
);
|
||||
else
|
||||
return (
|
||||
content && (
|
||||
<WebView
|
||||
style={{
|
||||
flex: 1,
|
||||
}}
|
||||
source={{
|
||||
baseUrl: content,
|
||||
html: `
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<style>
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
img {
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<img src="${content}" />
|
||||
</body>
|
||||
</html>
|
||||
`,
|
||||
}}
|
||||
scalesPageToFit
|
||||
originWhitelist={["*"]}
|
||||
mixedContentMode="always"
|
||||
javaScriptEnabled={true}
|
||||
allowFileAccess={true}
|
||||
allowFileAccessFromFileURLs={true}
|
||||
allowUniversalAccessFromFileURLs={true}
|
||||
onLoadEnd={() => setIsLoading(false)}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
72
apps/mobile/components/Formats/PdfFormat.tsx
Normal file
72
apps/mobile/components/Formats/PdfFormat.tsx
Normal file
@@ -0,0 +1,72 @@
|
||||
import React, { useEffect, useState } from "react";
|
||||
import * as FileSystem from "expo-file-system";
|
||||
import NetInfo from "@react-native-community/netinfo";
|
||||
import useAuthStore from "@/store/auth";
|
||||
import { ArchivedFormat } from "@linkwarden/types/global";
|
||||
import { Link as LinkType } from "@linkwarden/prisma/client";
|
||||
import Pdf from "react-native-pdf";
|
||||
|
||||
type Props = {
|
||||
link: LinkType;
|
||||
setIsLoading: (state: boolean) => void;
|
||||
};
|
||||
|
||||
export default function PdfFormat({ link, setIsLoading }: Props) {
|
||||
const FORMAT = ArchivedFormat.pdf;
|
||||
|
||||
const { auth } = useAuthStore();
|
||||
const [content, setContent] = useState<string>("");
|
||||
|
||||
useEffect(() => {
|
||||
async function loadCacheOrFetch() {
|
||||
const filePath =
|
||||
FileSystem.documentDirectory + `archivedData/pdf/link_${link.id}.pdf`;
|
||||
|
||||
await FileSystem.makeDirectoryAsync(
|
||||
filePath.substring(0, filePath.lastIndexOf("/")),
|
||||
{
|
||||
intermediates: true,
|
||||
}
|
||||
).catch(() => {});
|
||||
|
||||
const [info] = await Promise.all([FileSystem.getInfoAsync(filePath)]);
|
||||
|
||||
if (info.exists) {
|
||||
setContent(filePath);
|
||||
}
|
||||
|
||||
const net = await NetInfo.fetch();
|
||||
|
||||
if (net.isConnected) {
|
||||
const apiUrl = `${auth.instance}/api/v1/archives/${link.id}?format=${FORMAT}`;
|
||||
|
||||
try {
|
||||
const result = await FileSystem.downloadAsync(apiUrl, filePath, {
|
||||
headers: { Authorization: `Bearer ${auth.session}` },
|
||||
});
|
||||
|
||||
setContent(result.uri);
|
||||
} catch (e) {
|
||||
console.error("Failed to fetch content", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
loadCacheOrFetch();
|
||||
}, [link]);
|
||||
|
||||
return (
|
||||
content && (
|
||||
<Pdf
|
||||
style={{
|
||||
flex: 1,
|
||||
}}
|
||||
source={{ uri: content }}
|
||||
onLoadComplete={() => setIsLoading(false)}
|
||||
onPressLink={(uri) => {
|
||||
console.log(`Link pressed: ${uri}`);
|
||||
}}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
139
apps/mobile/components/Formats/ReadableFormat.tsx
Normal file
139
apps/mobile/components/Formats/ReadableFormat.tsx
Normal file
@@ -0,0 +1,139 @@
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { View, Text, ScrollView, TouchableOpacity } from "react-native";
|
||||
import * as FileSystem from "expo-file-system";
|
||||
import NetInfo from "@react-native-community/netinfo";
|
||||
import useAuthStore from "@/store/auth";
|
||||
import { useRouter } from "expo-router";
|
||||
import { useWindowDimensions } from "react-native";
|
||||
import RenderHtml from "@linkwarden/react-native-render-html";
|
||||
import ElementNotSupported from "@/components/ElementNotSupported";
|
||||
import { decode } from "html-entities";
|
||||
import { useColorScheme } from "nativewind";
|
||||
import { rawTheme, ThemeName } from "@/lib/colors";
|
||||
import { CalendarDays, Link } from "lucide-react-native";
|
||||
import { ArchivedFormat } from "@linkwarden/types/global";
|
||||
import { Link as LinkType } from "@linkwarden/prisma/client";
|
||||
|
||||
type Props = {
|
||||
link: LinkType;
|
||||
setIsLoading: (state: boolean) => void;
|
||||
};
|
||||
|
||||
export default function ReadableFormat({ link, setIsLoading }: Props) {
|
||||
const FORMAT = ArchivedFormat.readability;
|
||||
|
||||
const { auth } = useAuthStore();
|
||||
const [content, setContent] = useState<string>("");
|
||||
const { width } = useWindowDimensions();
|
||||
const router = useRouter();
|
||||
const { colorScheme } = useColorScheme();
|
||||
|
||||
useEffect(() => {
|
||||
async function loadCacheOrFetch() {
|
||||
const filePath =
|
||||
FileSystem.documentDirectory +
|
||||
`archivedData/readable/link_${link.id}.html`;
|
||||
|
||||
await FileSystem.makeDirectoryAsync(
|
||||
filePath.substring(0, filePath.lastIndexOf("/")),
|
||||
{
|
||||
intermediates: true,
|
||||
}
|
||||
).catch(() => {});
|
||||
|
||||
const [info] = await Promise.all([FileSystem.getInfoAsync(filePath)]);
|
||||
|
||||
if (info.exists) {
|
||||
const rawContent = await FileSystem.readAsStringAsync(filePath);
|
||||
setContent(rawContent);
|
||||
}
|
||||
|
||||
const net = await NetInfo.fetch();
|
||||
|
||||
if (net.isConnected) {
|
||||
const apiUrl = `${auth.instance}/api/v1/archives/${link.id}?format=${FORMAT}`;
|
||||
|
||||
try {
|
||||
const response = await fetch(apiUrl, {
|
||||
headers: { Authorization: `Bearer ${auth.session}` },
|
||||
});
|
||||
|
||||
const data = (await response.json()).content;
|
||||
setContent(data);
|
||||
await FileSystem.writeAsStringAsync(filePath, data, {
|
||||
encoding: FileSystem.EncodingType.UTF8,
|
||||
});
|
||||
} catch (e) {
|
||||
console.error("Failed to fetch content", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
loadCacheOrFetch();
|
||||
}, [link]);
|
||||
|
||||
return (
|
||||
content && (
|
||||
<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/${link.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).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: content }}
|
||||
renderers={{
|
||||
table: () => (
|
||||
<ElementNotSupported
|
||||
onPress={() => router.replace(`/links/${link.id}`)}
|
||||
/>
|
||||
),
|
||||
}}
|
||||
onHTMLLoaded={() => setIsLoading(false)}
|
||||
tagsStyles={{
|
||||
p: { fontSize: 18, lineHeight: 28, marginVertical: 10 },
|
||||
}}
|
||||
baseStyle={{
|
||||
color: rawTheme[colorScheme as ThemeName]["base-content"],
|
||||
}}
|
||||
/>
|
||||
</ScrollView>
|
||||
)
|
||||
);
|
||||
}
|
||||
80
apps/mobile/components/Formats/WebpageFormat.tsx
Normal file
80
apps/mobile/components/Formats/WebpageFormat.tsx
Normal file
@@ -0,0 +1,80 @@
|
||||
import React, { useEffect, useState } from "react";
|
||||
import * as FileSystem from "expo-file-system";
|
||||
import NetInfo from "@react-native-community/netinfo";
|
||||
import useAuthStore from "@/store/auth";
|
||||
import { ArchivedFormat } from "@linkwarden/types/global";
|
||||
import { Link as LinkType } from "@linkwarden/prisma/client";
|
||||
import WebView from "react-native-webview";
|
||||
|
||||
type Props = {
|
||||
link: LinkType;
|
||||
setIsLoading: (state: boolean) => void;
|
||||
};
|
||||
|
||||
export default function WebpageFormat({ link, setIsLoading }: Props) {
|
||||
const FORMAT = ArchivedFormat.monolith;
|
||||
|
||||
const { auth } = useAuthStore();
|
||||
const [content, setContent] = useState<string>("");
|
||||
|
||||
useEffect(() => {
|
||||
async function loadCacheOrFetch() {
|
||||
const filePath =
|
||||
FileSystem.documentDirectory +
|
||||
`archivedData/webpage/link_${link.id}.html`;
|
||||
|
||||
await FileSystem.makeDirectoryAsync(
|
||||
filePath.substring(0, filePath.lastIndexOf("/")),
|
||||
{
|
||||
intermediates: true,
|
||||
}
|
||||
).catch(() => {});
|
||||
|
||||
const [info] = await Promise.all([FileSystem.getInfoAsync(filePath)]);
|
||||
|
||||
if (info.exists) {
|
||||
setContent(filePath);
|
||||
}
|
||||
|
||||
const net = await NetInfo.fetch();
|
||||
|
||||
if (net.isConnected) {
|
||||
const apiUrl = `${auth.instance}/api/v1/archives/${link.id}?format=${FORMAT}`;
|
||||
|
||||
try {
|
||||
const result = await FileSystem.downloadAsync(apiUrl, filePath, {
|
||||
headers: { Authorization: `Bearer ${auth.session}` },
|
||||
});
|
||||
|
||||
setContent(result.uri);
|
||||
} catch (e) {
|
||||
console.error("Failed to fetch content", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
loadCacheOrFetch();
|
||||
}, [link]);
|
||||
|
||||
return (
|
||||
content && (
|
||||
<WebView
|
||||
style={{
|
||||
flex: 1,
|
||||
}}
|
||||
source={{
|
||||
uri: content,
|
||||
baseUrl: FileSystem.documentDirectory,
|
||||
}}
|
||||
scalesPageToFit
|
||||
originWhitelist={["*"]}
|
||||
mixedContentMode="always"
|
||||
javaScriptEnabled={true}
|
||||
allowFileAccess={true}
|
||||
allowFileAccessFromFileURLs={true}
|
||||
allowUniversalAccessFromFileURLs={true}
|
||||
onLoadEnd={() => setIsLoading(false)}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
18
apps/mobile/components/HapticTab.tsx
Normal file
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);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
339
apps/mobile/components/LinkListing.tsx
Normal file
339
apps/mobile/components/LinkListing.tsx
Normal file
@@ -0,0 +1,339 @@
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
Image,
|
||||
Pressable,
|
||||
Platform,
|
||||
Alert,
|
||||
ActivityIndicator,
|
||||
Linking,
|
||||
} from "react-native";
|
||||
import { decode } from "html-entities";
|
||||
import { LinkIncludingShortenedCollectionAndTags } from "@linkwarden/types/global";
|
||||
import { ArchivedFormat } from "@linkwarden/types/global";
|
||||
import getFormatBasedOnPreference from "@linkwarden/lib/getFormatBasedOnPreference";
|
||||
import getOriginalFormat from "@linkwarden/lib/getOriginalFormat";
|
||||
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";
|
||||
import { useEffect, useState } from "react";
|
||||
import { deleteLinkCache } from "@/lib/cache";
|
||||
|
||||
type Props = {
|
||||
link: LinkIncludingShortenedCollectionAndTags;
|
||||
dashboard?: boolean;
|
||||
};
|
||||
|
||||
const LinkListing = ({ link, dashboard }: Props) => {
|
||||
const { auth } = useAuthStore();
|
||||
const router = useRouter();
|
||||
const updateLink = useUpdateLink({ auth, Alert });
|
||||
const { data: user } = useUser(auth);
|
||||
const { colorScheme } = useColorScheme();
|
||||
const { data } = useDataStore();
|
||||
|
||||
const deleteLink = useDeleteLink({ auth, Alert });
|
||||
|
||||
const [url, setUrl] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
try {
|
||||
if (link.url) {
|
||||
setUrl(new URL(link.url).host.toLowerCase());
|
||||
}
|
||||
} catch (error) {
|
||||
console.log(error);
|
||||
}
|
||||
}, [link.url]);
|
||||
|
||||
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={() => {
|
||||
if (user) {
|
||||
const format = getFormatBasedOnPreference({
|
||||
link,
|
||||
preference: user.linksRouteTo,
|
||||
});
|
||||
|
||||
data.preferredBrowser === "app"
|
||||
? router.navigate(
|
||||
format !== null
|
||||
? `/links/${link.id}?format=${format}`
|
||||
: `/links/${link.id}`
|
||||
)
|
||||
: Linking.openURL(
|
||||
format !== null
|
||||
? auth.instance +
|
||||
`/preserved/${link?.id}?format=${format}`
|
||||
: (link.url as string)
|
||||
);
|
||||
}
|
||||
}}
|
||||
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>
|
||||
|
||||
{url && (
|
||||
<Text
|
||||
numberOfLines={1}
|
||||
className="mt-1.5 font-light text-sm text-base-content"
|
||||
>
|
||||
{url}
|
||||
</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 || "#0ea5e9"}
|
||||
color={link.collection.color || "#0ea5e9"}
|
||||
/>
|
||||
<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"
|
||||
/>
|
||||
) : !link.preview ? (
|
||||
<ActivityIndicator
|
||||
size="small"
|
||||
className="h-[60px] w-[90px]"
|
||||
/>
|
||||
) : (
|
||||
<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-original"
|
||||
onSelect={() => {
|
||||
if (link) {
|
||||
const format = getOriginalFormat(link);
|
||||
|
||||
data.preferredBrowser === "app"
|
||||
? router.navigate(
|
||||
format !== null
|
||||
? `/links/${link.id}?format=${format}`
|
||||
: `/links/${link.id}`
|
||||
)
|
||||
: Linking.openURL(
|
||||
format !== null
|
||||
? auth.instance +
|
||||
`/preserved/${link?.id}?format=${format}`
|
||||
: (link.url as string)
|
||||
);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<ContextMenu.ItemTitle>Open Original</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={() => {
|
||||
const isAlreadyPinned =
|
||||
link?.pinnedBy && link.pinnedBy[0] ? true : false;
|
||||
|
||||
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.navigate(
|
||||
`/links/${link.id}?format=${ArchivedFormat.monolith}`
|
||||
)
|
||||
}
|
||||
>
|
||||
<ContextMenu.ItemTitle>Webpage</ContextMenu.ItemTitle>
|
||||
</ContextMenu.Item>
|
||||
)}
|
||||
{formatAvailable(link, "image") && (
|
||||
<ContextMenu.Item
|
||||
key="preserved-formats-screenshot"
|
||||
onSelect={() =>
|
||||
router.navigate(
|
||||
`/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.navigate(
|
||||
`/links/${link.id}?format=${ArchivedFormat.pdf}`
|
||||
)
|
||||
}
|
||||
>
|
||||
<ContextMenu.ItemTitle>PDF</ContextMenu.ItemTitle>
|
||||
</ContextMenu.Item>
|
||||
)}
|
||||
{formatAvailable(link, "readable") && (
|
||||
<ContextMenu.Item
|
||||
key="preserved-formats-readable"
|
||||
onSelect={() =>
|
||||
router.navigate(
|
||||
`/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: async () => {
|
||||
deleteLink.mutate(link.id as number);
|
||||
|
||||
await deleteLinkCache(link.id as number);
|
||||
},
|
||||
},
|
||||
]
|
||||
);
|
||||
}}
|
||||
>
|
||||
<ContextMenu.ItemTitle>Delete</ContextMenu.ItemTitle>
|
||||
</ContextMenu.Item>
|
||||
</ContextMenu.Content>
|
||||
</ContextMenu.Root>
|
||||
);
|
||||
};
|
||||
|
||||
export default LinkListing;
|
||||
87
apps/mobile/components/Links.tsx
Normal file
87
apps/mobile/components/Links.tsx
Normal file
@@ -0,0 +1,87 @@
|
||||
import {
|
||||
View,
|
||||
FlatList,
|
||||
Text,
|
||||
ActivityIndicator,
|
||||
ViewToken,
|
||||
} from "react-native";
|
||||
import LinkListing from "@/components/LinkListing";
|
||||
import React, { useState } from "react";
|
||||
import { LinkIncludingShortenedCollectionAndTags } from "@linkwarden/types/global";
|
||||
import Spinner from "@/components/ui/Spinner";
|
||||
import { rawTheme, ThemeName } from "@/lib/colors";
|
||||
import { useColorScheme } from "nativewind";
|
||||
|
||||
const RenderItem = React.memo(
|
||||
({ item }: { item: LinkIncludingShortenedCollectionAndTags }) => {
|
||||
return <LinkListing link={item} />;
|
||||
}
|
||||
);
|
||||
|
||||
type Props = {
|
||||
links: LinkIncludingShortenedCollectionAndTags[];
|
||||
data: any;
|
||||
};
|
||||
|
||||
export default function Links({ links, data }: Props) {
|
||||
const { colorScheme } = useColorScheme();
|
||||
const [promptedRefetch, setPromptedRefetch] = useState(false);
|
||||
|
||||
return data.isLoading ? (
|
||||
<View className="flex justify-center h-screen items-center">
|
||||
<ActivityIndicator size="large" />
|
||||
<Text className="text-base mt-2.5 text-neutral">Loading...</Text>
|
||||
</View>
|
||||
) : (
|
||||
<FlatList
|
||||
contentInsetAdjustmentBehavior="automatic"
|
||||
ListHeaderComponent={() => <></>}
|
||||
data={links || []}
|
||||
refreshControl={
|
||||
<Spinner
|
||||
refreshing={data.isRefetching && promptedRefetch}
|
||||
onRefresh={async () => {
|
||||
setPromptedRefetch(true);
|
||||
await data.refetch();
|
||||
setPromptedRefetch(false);
|
||||
}}
|
||||
progressBackgroundColor={
|
||||
rawTheme[colorScheme as ThemeName]["base-200"]
|
||||
}
|
||||
colors={[rawTheme[colorScheme as ThemeName]["base-content"]]}
|
||||
/>
|
||||
}
|
||||
refreshing={data.isRefetching && promptedRefetch}
|
||||
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" />
|
||||
)}
|
||||
ListEmptyComponent={
|
||||
<View className="flex justify-center py-10 items-center">
|
||||
<Text className="text-center text-xl text-neutral">
|
||||
Nothing found...
|
||||
</Text>
|
||||
</View>
|
||||
}
|
||||
onViewableItemsChanged={({
|
||||
viewableItems,
|
||||
}: {
|
||||
viewableItems: ViewToken[];
|
||||
}) => {
|
||||
const links = viewableItems.map(
|
||||
(e) => e.item
|
||||
) as LinkIncludingShortenedCollectionAndTags[];
|
||||
|
||||
if (!data.isRefetching && links.some((e) => e.id && !e.preview))
|
||||
data.refetch();
|
||||
}}
|
||||
viewabilityConfig={{ itemVisiblePercentThreshold: 50 }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
120
apps/mobile/components/TagListing.tsx
Normal file
120
apps/mobile/components/TagListing.tsx
Normal file
@@ -0,0 +1,120 @@
|
||||
import { View, Text, Pressable, Platform, Alert } from "react-native";
|
||||
import { decode } from "html-entities";
|
||||
import { TagIncludingLinkCount } from "@linkwarden/types/global";
|
||||
import useAuthStore from "@/store/auth";
|
||||
import { useRouter } from "expo-router";
|
||||
import * as ContextMenu from "zeego/context-menu";
|
||||
import { cn } from "@linkwarden/lib/utils";
|
||||
import { rawTheme, ThemeName } from "@/lib/colors";
|
||||
import { useColorScheme } from "nativewind";
|
||||
import { CalendarDays, Hash, Link } from "lucide-react-native";
|
||||
import { useRemoveTag } from "@linkwarden/router/tags";
|
||||
|
||||
type Props = {
|
||||
tag: TagIncludingLinkCount;
|
||||
};
|
||||
|
||||
const TagListing = ({ tag }: Props) => {
|
||||
const { auth } = useAuthStore();
|
||||
const router = useRouter();
|
||||
const { colorScheme } = useColorScheme();
|
||||
|
||||
const deleteCollection = useRemoveTag(auth);
|
||||
|
||||
return (
|
||||
<ContextMenu.Root>
|
||||
<ContextMenu.Trigger asChild>
|
||||
<Pressable
|
||||
className={cn(
|
||||
"p-5 flex-row justify-between",
|
||||
"bg-base-100",
|
||||
Platform.OS !== "android" && "active:bg-base-200/50"
|
||||
)}
|
||||
onLongPress={() => {}}
|
||||
onPress={() => router.navigate(`/tags/${tag.id}`)}
|
||||
android_ripple={{
|
||||
color: colorScheme === "dark" ? "rgba(255,255,255,0.2)" : "#ddd",
|
||||
borderless: false,
|
||||
}}
|
||||
>
|
||||
<View className="w-full">
|
||||
<View className="w-[90%] flex-col justify-between gap-3">
|
||||
<View className="flex flex-row gap-2 items-center pr-1.5 self-start rounded-md">
|
||||
<Hash
|
||||
size={16}
|
||||
color={rawTheme[colorScheme as ThemeName]["primary"]}
|
||||
/>
|
||||
<Text
|
||||
numberOfLines={2}
|
||||
className="font-medium text-lg text-base-content"
|
||||
>
|
||||
{decode(tag.name)}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View className="flex-row gap-3">
|
||||
<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(tag.createdAt).toLocaleString("en-US", {
|
||||
year: "numeric",
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
})}
|
||||
</Text>
|
||||
</View>
|
||||
<View className="flex flex-row gap-1 items-center mt-5 self-start">
|
||||
<Link
|
||||
size={16}
|
||||
color={rawTheme[colorScheme as ThemeName]["neutral"]}
|
||||
/>
|
||||
<Text
|
||||
numberOfLines={1}
|
||||
className="font-light text-xs text-base-content"
|
||||
>
|
||||
{tag._count?.links}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
</Pressable>
|
||||
</ContextMenu.Trigger>
|
||||
|
||||
<ContextMenu.Content avoidCollisions>
|
||||
<ContextMenu.Item
|
||||
key="delete-tag"
|
||||
onSelect={() => {
|
||||
return Alert.alert(
|
||||
"Delete Tag",
|
||||
"Are you sure you want to delete this Tag? This action cannot be undone.",
|
||||
[
|
||||
{
|
||||
text: "Cancel",
|
||||
style: "cancel",
|
||||
},
|
||||
{
|
||||
text: "Delete",
|
||||
style: "destructive",
|
||||
onPress: () => {
|
||||
deleteCollection.mutate(tag.id as number);
|
||||
},
|
||||
},
|
||||
]
|
||||
);
|
||||
}}
|
||||
>
|
||||
<ContextMenu.ItemTitle>Delete</ContextMenu.ItemTitle>
|
||||
</ContextMenu.Item>
|
||||
</ContextMenu.Content>
|
||||
</ContextMenu.Root>
|
||||
);
|
||||
};
|
||||
|
||||
export default TagListing;
|
||||
84
apps/mobile/components/ui/Button.tsx
Normal file
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
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
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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
21
apps/mobile/components/ui/Icons.tsx
Normal file
21
apps/mobile/components/ui/Icons.tsx
Normal file
@@ -0,0 +1,21 @@
|
||||
import React from "react";
|
||||
import Svg, { Path, Circle, SvgProps } from "react-native-svg";
|
||||
|
||||
export const Chromium = (props: SvgProps) => (
|
||||
<Svg
|
||||
width={21}
|
||||
height={21}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
strokeWidth={2}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
{...props}
|
||||
>
|
||||
<Path d="M10.88 21.94 15.46 14" />
|
||||
<Path d="M21.17 8H12" />
|
||||
<Path d="M3.95 6.06 8.54 14" />
|
||||
<Circle cx={12} cy={12} r={10} />
|
||||
<Circle cx={12} cy={12} r={4} />
|
||||
</Svg>
|
||||
);
|
||||
20
apps/mobile/components/ui/Input.tsx
Normal file
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;
|
||||
10
apps/mobile/components/ui/Spinner.tsx
Normal file
10
apps/mobile/components/ui/Spinner.tsx
Normal file
@@ -0,0 +1,10 @@
|
||||
import React, { forwardRef } from "react";
|
||||
import { RefreshControl, RefreshControlProps } from "react-native";
|
||||
|
||||
const Spinner = forwardRef<RefreshControl, RefreshControlProps>(
|
||||
(props, ref) => {
|
||||
return <RefreshControl ref={ref} {...props} />;
|
||||
}
|
||||
);
|
||||
|
||||
export default Spinner;
|
||||
22
apps/mobile/components/ui/TabBarBackground.ios.tsx
Normal file
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
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;
|
||||
}
|
||||
41
apps/mobile/eas.json
Normal file
41
apps/mobile/eas.json
Normal file
@@ -0,0 +1,41 @@
|
||||
{
|
||||
"cli": {
|
||||
"version": ">= 16.20.1",
|
||||
"appVersionSource": "remote"
|
||||
},
|
||||
"build": {
|
||||
"preview": {
|
||||
"android": {
|
||||
"buildType": "apk"
|
||||
}
|
||||
},
|
||||
"development": {
|
||||
"developmentClient": true,
|
||||
"distribution": "internal"
|
||||
},
|
||||
"simulator": {
|
||||
"extends": "development",
|
||||
"ios": {
|
||||
"simulator": true
|
||||
}
|
||||
},
|
||||
"production": {
|
||||
"corepack": true,
|
||||
"distribution": "store",
|
||||
"autoIncrement": true,
|
||||
"channel": "production"
|
||||
}
|
||||
},
|
||||
"submit": {
|
||||
"production": {
|
||||
"ios": {
|
||||
"ascAppId": "6752550960"
|
||||
},
|
||||
"android": {
|
||||
"serviceAccountKeyPath": "./service-account-file.json",
|
||||
"track": "internal",
|
||||
"releaseStatus": "draft"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
33
apps/mobile/lib/cache.ts
Normal file
33
apps/mobile/lib/cache.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import * as FileSystem from "expo-file-system";
|
||||
|
||||
export const clearCache = async () => {
|
||||
await Promise.all([
|
||||
FileSystem.deleteAsync(FileSystem.documentDirectory + "archivedData", {
|
||||
idempotent: true,
|
||||
}),
|
||||
FileSystem.deleteAsync(FileSystem.documentDirectory + "mmkv", {
|
||||
idempotent: true,
|
||||
}),
|
||||
]);
|
||||
};
|
||||
|
||||
export const deleteLinkCache = async (linkId: number) => {
|
||||
const readablePath =
|
||||
FileSystem.documentDirectory + `archivedData/readable/link_${linkId}.html`;
|
||||
const webpagePath =
|
||||
FileSystem.documentDirectory + `archivedData/webpage/link_${linkId}.html`;
|
||||
const jpegPath =
|
||||
FileSystem.documentDirectory + `archivedData/jpeg/link_${linkId}.jpeg`;
|
||||
const pngPath =
|
||||
FileSystem.documentDirectory + `archivedData/png/link_${linkId}.png`;
|
||||
const pdfPath =
|
||||
FileSystem.documentDirectory + `archivedData/pdf/link_${linkId}.pdf`;
|
||||
|
||||
await Promise.all([
|
||||
FileSystem.deleteAsync(readablePath, { idempotent: true }),
|
||||
FileSystem.deleteAsync(webpagePath, { idempotent: true }),
|
||||
FileSystem.deleteAsync(jpegPath, { idempotent: true }),
|
||||
FileSystem.deleteAsync(pngPath, { idempotent: true }),
|
||||
FileSystem.deleteAsync(pdfPath, { idempotent: true }),
|
||||
]);
|
||||
};
|
||||
33
apps/mobile/lib/colors.ts
Normal file
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",
|
||||
},
|
||||
};
|
||||
14
apps/mobile/lib/queryClient.ts
Normal file
14
apps/mobile/lib/queryClient.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { QueryClient } from "@tanstack/react-query";
|
||||
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
staleTime: 1000 * 60 * 60 * 24,
|
||||
refetchOnMount: true,
|
||||
refetchOnWindowFocus: false,
|
||||
refetchOnReconnect: false,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export { queryClient };
|
||||
31
apps/mobile/lib/queryPersister.ts
Normal file
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
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
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
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.
|
||||
87
apps/mobile/package.json
Normal file
87
apps/mobile/package.json
Normal file
@@ -0,0 +1,87 @@
|
||||
{
|
||||
"name": "@linkwarden/mobile",
|
||||
"main": "expo-router/entry",
|
||||
"version": "0.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/prisma": "*",
|
||||
"@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-build-properties": "~0.13.3",
|
||||
"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-updates": "~0.27.4",
|
||||
"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-blob-util": "^0.23.2",
|
||||
"react-native-edge-to-edge": "^1.7.0",
|
||||
"react-native-gesture-handler": "~2.20.2",
|
||||
"react-native-ios-context-menu": "3.1.3",
|
||||
"react-native-ios-utilities": "5.1.7",
|
||||
"react-native-keyboard-controller": "^1.19.0",
|
||||
"react-native-mmkv": "^3.2.0",
|
||||
"react-native-pdf": "^7.0.3",
|
||||
"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.1",
|
||||
"@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
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;
|
||||
});
|
||||
};
|
||||
157
apps/mobile/store/auth.ts
Normal file
157
apps/mobile/store/auth.ts
Normal file
@@ -0,0 +1,157 @@
|
||||
import { create } from "zustand";
|
||||
import * as SecureStore from "expo-secure-store";
|
||||
import { router } from "expo-router";
|
||||
import { MobileAuth } from "@linkwarden/types/global";
|
||||
import { Alert } from "react-native";
|
||||
import { queryClient } from "@/lib/queryClient";
|
||||
import { mmkvPersister } from "@/lib/queryPersister";
|
||||
import { clearCache } from "@/lib/cache";
|
||||
|
||||
type AuthStore = {
|
||||
auth: MobileAuth;
|
||||
signIn: (
|
||||
username: string,
|
||||
password: string,
|
||||
instance: string,
|
||||
token?: string
|
||||
) => Promise<void>;
|
||||
signOut: () => Promise<void>;
|
||||
setAuth: () => Promise<void>;
|
||||
};
|
||||
|
||||
const useAuthStore = create<AuthStore>((set) => ({
|
||||
auth: {
|
||||
instance: "",
|
||||
session: null,
|
||||
status: "loading" as const,
|
||||
},
|
||||
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: instance || "https://cloud.linkwarden.app",
|
||||
session: null,
|
||||
status: "unauthenticated",
|
||||
},
|
||||
});
|
||||
}
|
||||
},
|
||||
signIn: async (username, password, instance, token) => {
|
||||
if (process.env.EXPO_PUBLIC_SHOW_LOGS === "true")
|
||||
console.log("Signing into", instance);
|
||||
|
||||
if (token) {
|
||||
try {
|
||||
// make a request to the API to validate the token
|
||||
const res = await Promise.race([
|
||||
fetch(instance + "/api/v1/users/me", {
|
||||
method: "GET",
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
}),
|
||||
new Promise<Response>((_, reject) =>
|
||||
setTimeout(() => reject(new Error("TIMEOUT")), 30000)
|
||||
),
|
||||
]);
|
||||
|
||||
if (res.ok) {
|
||||
await SecureStore.setItemAsync("INSTANCE", instance);
|
||||
await SecureStore.setItemAsync("TOKEN", token);
|
||||
set({
|
||||
auth: {
|
||||
session: token,
|
||||
instance,
|
||||
status: "authenticated",
|
||||
},
|
||||
});
|
||||
router.replace("/(tabs)/dashboard");
|
||||
} else {
|
||||
Alert.alert("Error", "Invalid token");
|
||||
}
|
||||
} catch (err: any) {
|
||||
if (err?.message === "TIMEOUT") {
|
||||
Alert.alert(
|
||||
"Request timed out",
|
||||
"Unable to reach the server in time. Please check your network configuration and try again."
|
||||
);
|
||||
} else {
|
||||
Alert.alert(
|
||||
"Network error",
|
||||
"Could not connect to the server. Please check your network configuration and try again."
|
||||
);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
try {
|
||||
const res = await Promise.race([
|
||||
fetch(`${instance}/api/v1/session`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ username, password }),
|
||||
headers: { "Content-Type": "application/json" },
|
||||
}),
|
||||
new Promise<Response>((_, reject) =>
|
||||
setTimeout(() => reject(new Error("TIMEOUT")), 30000)
|
||||
),
|
||||
]);
|
||||
|
||||
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 {
|
||||
Alert.alert("Error", "Invalid credentials");
|
||||
}
|
||||
} catch (err: any) {
|
||||
if (err?.message === "TIMEOUT") {
|
||||
Alert.alert(
|
||||
"Request timed out",
|
||||
"Unable to reach the server in time. Please check your network configuration and try again."
|
||||
);
|
||||
} else {
|
||||
Alert.alert(
|
||||
"Network error",
|
||||
"Could not connect to the server. Please check your network configuration and try again."
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
signOut: async () => {
|
||||
await SecureStore.deleteItemAsync("TOKEN");
|
||||
await SecureStore.deleteItemAsync("INSTANCE");
|
||||
|
||||
queryClient.cancelQueries();
|
||||
queryClient.clear();
|
||||
mmkvPersister.removeClient?.();
|
||||
|
||||
await clearCache();
|
||||
|
||||
set({
|
||||
auth: {
|
||||
instance: "",
|
||||
session: null,
|
||||
status: "unauthenticated",
|
||||
},
|
||||
});
|
||||
|
||||
router.replace("/");
|
||||
},
|
||||
}));
|
||||
|
||||
export default useAuthStore;
|
||||
38
apps/mobile/store/data.ts
Normal file
38
apps/mobile/store/data.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import { create } from "zustand";
|
||||
import { MobileData } from "@linkwarden/types/global";
|
||||
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: "system",
|
||||
preferredBrowser: "app",
|
||||
preferredCollection: null,
|
||||
},
|
||||
setData: async () => {
|
||||
const dataString = JSON.parse((await AsyncStorage.getItem("data")) || "{}");
|
||||
|
||||
colorScheme.set(dataString.theme || "system");
|
||||
|
||||
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;
|
||||
26
apps/mobile/store/tmp.ts
Normal file
26
apps/mobile/store/tmp.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import { create } from "zustand";
|
||||
import { LinkIncludingShortenedCollectionAndTags } from "@linkwarden/types/global";
|
||||
import { User } from "@linkwarden/prisma/client";
|
||||
|
||||
type Tmp = {
|
||||
link: LinkIncludingShortenedCollectionAndTags | null;
|
||||
user: Pick<User, "id"> | null;
|
||||
};
|
||||
|
||||
type TmpStore = {
|
||||
tmp: Tmp;
|
||||
updateTmp: (newData: Partial<Tmp>) => void;
|
||||
};
|
||||
|
||||
const useTmpStore = create<TmpStore>((set, get) => ({
|
||||
tmp: {
|
||||
link: null,
|
||||
user: null,
|
||||
},
|
||||
updateTmp: async (patch) => {
|
||||
const merged = { ...get().tmp, ...patch };
|
||||
set({ tmp: merged });
|
||||
},
|
||||
}));
|
||||
|
||||
export default useTmpStore;
|
||||
3
apps/mobile/styles/global.css
Normal file
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
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
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
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 = 0,
|
||||
jpeg = 1,
|
||||
pdf = 2,
|
||||
readability = 3,
|
||||
monolith = 4,
|
||||
}
|
||||
1
apps/mobile/types/nativewind-env.d.ts
vendored
Normal file
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
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"
|
||||
}
|
||||
125
apps/web/components/AdminSidebar.tsx
Normal file
125
apps/web/components/AdminSidebar.tsx
Normal file
@@ -0,0 +1,125 @@
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/router";
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { useTranslation } from "next-i18next";
|
||||
import { useUser } from "@linkwarden/router/user";
|
||||
import Image from "next/image";
|
||||
|
||||
export default function AdminSidebar({ className }: { className?: string }) {
|
||||
const { t } = useTranslation();
|
||||
const LINKWARDEN_VERSION = process.env.version;
|
||||
|
||||
const { data: user } = useUser();
|
||||
|
||||
const router = useRouter();
|
||||
const [active, setActive] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
setActive(router.asPath);
|
||||
}, [router]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`bg-base-200 h-screen w-80 overflow-y-auto border-solid border border-base-200 border-r-neutral-content p-2 z-20 flex flex-col gap-5 justify-between ${
|
||||
className || ""
|
||||
}`}
|
||||
>
|
||||
<div className="flex flex-col gap-1">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
{user?.theme === "light" ? (
|
||||
<Image
|
||||
src={"/linkwarden_light.png"}
|
||||
width={640}
|
||||
height={136}
|
||||
alt="Linkwarden"
|
||||
className="h-9 w-auto cursor-pointer"
|
||||
onClick={() => router.push("/dashboard")}
|
||||
priority
|
||||
/>
|
||||
) : (
|
||||
<Image
|
||||
src={"/linkwarden_dark.png"}
|
||||
width={640}
|
||||
height={136}
|
||||
alt="Linkwarden"
|
||||
className="h-9 w-auto cursor-pointer"
|
||||
onClick={() => router.push("/dashboard")}
|
||||
priority
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<Link href="/admin/user-administration">
|
||||
<div
|
||||
className={`${
|
||||
active === "/admin/user-administration"
|
||||
? "bg-primary/20"
|
||||
: "hover:bg-neutral/20"
|
||||
} duration-200 cursor-pointer flex items-center gap-2 rounded-lg px-3 py-1`}
|
||||
>
|
||||
<i className="bi-people text-primary text-xl drop-shadow"></i>
|
||||
<p className="truncate w-full font-semibold text-sm">
|
||||
{t("user_administration")}
|
||||
</p>
|
||||
</div>
|
||||
</Link>
|
||||
|
||||
<Link href="/admin/background-jobs">
|
||||
<div
|
||||
className={`${
|
||||
active === "/admin/background-jobs"
|
||||
? "bg-primary/20"
|
||||
: "hover:bg-neutral/20"
|
||||
} duration-200 cursor-pointer flex items-center gap-2 rounded-lg px-3 py-1`}
|
||||
>
|
||||
<i className="bi-gear-wide-connected text-primary text-xl drop-shadow"></i>
|
||||
<p className="truncate w-full font-semibold text-sm">
|
||||
{t("background_jobs")}
|
||||
</p>
|
||||
</div>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1">
|
||||
<Link
|
||||
href={`https://github.com/linkwarden/linkwarden/releases`}
|
||||
target="_blank"
|
||||
className="text-neutral text-sm ml-2 hover:opacity-50 duration-100"
|
||||
>
|
||||
{t("linkwarden_version", { version: LINKWARDEN_VERSION })}
|
||||
</Link>
|
||||
<Link href="https://docs.linkwarden.app" target="_blank">
|
||||
<div
|
||||
className={`hover:bg-neutral/20 duration-200 cursor-pointer flex items-center gap-2 rounded-lg px-3 py-1`}
|
||||
>
|
||||
<i className="bi-question-circle text-primary text-xl drop-shadow"></i>
|
||||
<p className="truncate w-full font-semibold text-sm">{t("help")}</p>
|
||||
</div>
|
||||
</Link>
|
||||
<Link href="https://github.com/linkwarden/linkwarden" target="_blank">
|
||||
<div
|
||||
className={`hover:bg-neutral/20 duration-200 cursor-pointer flex items-center gap-2 rounded-lg px-3 py-1`}
|
||||
>
|
||||
<i className="bi-github text-primary text-xl drop-shadow"></i>
|
||||
<p className="truncate w-full font-semibold text-sm">{t("github")}</p>
|
||||
</div>
|
||||
</Link>
|
||||
<Link href="https://twitter.com/LinkwardenHQ" target="_blank">
|
||||
<div
|
||||
className={`hover:bg-neutral/20 duration-200 cursor-pointer flex items-center gap-2 rounded-lg px-3 py-1`}
|
||||
>
|
||||
<i className="bi-twitter-x text-primary text-xl drop-shadow"></i>
|
||||
<p className="truncate w-full font-semibold text-sm">{t("twitter")}</p>
|
||||
</div>
|
||||
</Link>
|
||||
<Link href="https://fosstodon.org/@linkwarden" target="_blank">
|
||||
<div
|
||||
className={`hover:bg-neutral/20 duration-200 cursor-pointer flex items-center gap-2 rounded-lg px-3 py-1`}
|
||||
>
|
||||
<i className="bi-mastodon text-primary text-xl drop-shadow"></i>
|
||||
<p className="truncate w-full font-semibold text-sm">{t("mastodon")}</p>
|
||||
</div>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
52
apps/web/components/Announcement.tsx
Normal file
52
apps/web/components/Announcement.tsx
Normal file
@@ -0,0 +1,52 @@
|
||||
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>;
|
||||
};
|
||||
|
||||
export default function Announcement({ toggleAnnouncementBar }: Props) {
|
||||
const announcementId = localStorage.getItem("announcementId");
|
||||
const announcementMessage = localStorage.getItem("announcementMessage");
|
||||
|
||||
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-xl text-yellow-600 dark:text-yellow-500"></i>
|
||||
<p className="w-4/5 text-center text-sm sm:text-base">
|
||||
{announcementId ? (
|
||||
<Trans
|
||||
i18nKey="new_version_announcement"
|
||||
values={{ version: announcementId }}
|
||||
components={[
|
||||
<Link
|
||||
href={`https://linkwarden.app/blog/releases/${announcementId}`}
|
||||
target="_blank"
|
||||
className="underline decoration-dotted underline-offset-4 hover:text-primary duration-100"
|
||||
key={0}
|
||||
/>,
|
||||
]}
|
||||
/>
|
||||
) : announcementMessage ? (
|
||||
<Trans
|
||||
i18nKey={announcementMessage}
|
||||
components={[
|
||||
<Link
|
||||
href={`https://linkwarden.app/blog`}
|
||||
target="_blank"
|
||||
className="underline decoration-dotted underline-offset-4 hover:text-primary duration-100"
|
||||
key={0}
|
||||
/>,
|
||||
]}
|
||||
/>
|
||||
) : undefined}
|
||||
</p>
|
||||
<Button variant="ghost" size="icon" onClick={toggleAnnouncementBar}>
|
||||
<i className="bi-x text-xl"></i>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,8 +1,8 @@
|
||||
import useLocalSettingsStore from "@/store/localSettings";
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import React, { ReactNode } from "react";
|
||||
import { Trans } from "next-i18next";
|
||||
import { useUser } from "@linkwarden/router/user";
|
||||
|
||||
interface Props {
|
||||
text?: string;
|
||||
@@ -15,7 +15,7 @@ export default function CenteredForm({
|
||||
children,
|
||||
"data-testid": dataTestId,
|
||||
}: Props) {
|
||||
const { settings } = useLocalSettingsStore();
|
||||
const { data: user } = useUser();
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -23,15 +23,21 @@ export default function CenteredForm({
|
||||
data-testid={dataTestId}
|
||||
>
|
||||
<div className="m-auto flex flex-col gap-2 w-full">
|
||||
{settings.theme && (
|
||||
{user?.theme === "light" ? (
|
||||
<Image
|
||||
src={`/linkwarden_${
|
||||
settings.theme === "dark" ? "dark" : "light"
|
||||
}.png`}
|
||||
src={"/linkwarden_light.png"}
|
||||
width={640}
|
||||
height={136}
|
||||
alt="Linkwarden"
|
||||
className="h-12 w-fit mx-auto"
|
||||
className="h-12 w-auto mx-auto"
|
||||
/>
|
||||
) : (
|
||||
<Image
|
||||
src={"/linkwarden_dark.png"}
|
||||
width={640}
|
||||
height={136}
|
||||
alt="Linkwarden"
|
||||
className="h-12 w-auto mx-auto"
|
||||
/>
|
||||
)}
|
||||
{text && (
|
||||
@@ -45,7 +51,11 @@ export default function CenteredForm({
|
||||
values={{ date: new Date().getFullYear() }}
|
||||
i18nKey="all_rights_reserved"
|
||||
components={[
|
||||
<Link href="https://linkwarden.app" className="font-semibold" />,
|
||||
<Link
|
||||
href="https://linkwarden.app"
|
||||
className="font-semibold"
|
||||
key="linkwarden-website-key"
|
||||
/>,
|
||||
]}
|
||||
/>
|
||||
</p>
|
||||
@@ -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);
|
||||
@@ -2,18 +2,24 @@ import Link from "next/link";
|
||||
import {
|
||||
AccountSettings,
|
||||
CollectionIncludingMembersAndLinkCount,
|
||||
} from "@/types/global";
|
||||
} from "@linkwarden/types/global";
|
||||
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";
|
||||
|
||||
export default function CollectionCard({
|
||||
collection,
|
||||
@@ -21,11 +27,10 @@ export default function CollectionCard({
|
||||
collection: CollectionIncludingMembersAndLinkCount;
|
||||
}) {
|
||||
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",
|
||||
@@ -41,18 +46,18 @@ export default function CollectionCard({
|
||||
|
||||
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,
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -67,65 +72,60 @@ export default function CollectionCard({
|
||||
|
||||
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 text-neutral" />
|
||||
</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 mt-1">
|
||||
{permissions === true && (
|
||||
<li>
|
||||
<div
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={() => {
|
||||
(document?.activeElement as HTMLElement)?.blur();
|
||||
setEditCollectionModal(true);
|
||||
}}
|
||||
className="whitespace-nowrap"
|
||||
>
|
||||
{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);
|
||||
}}
|
||||
className="whitespace-nowrap"
|
||||
>
|
||||
{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);
|
||||
}}
|
||||
className="whitespace-nowrap"
|
||||
>
|
||||
{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 && (
|
||||
@@ -159,9 +159,9 @@ export default function CollectionCard({
|
||||
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"
|
||||
@@ -180,12 +180,12 @@ export default function CollectionCard({
|
||||
{collection.isPublic && (
|
||||
<i
|
||||
className="bi-globe2 drop-shadow text-neutral"
|
||||
title="This collection is being shared publicly."
|
||||
title={t("collection_publicly_shared")}
|
||||
></i>
|
||||
)}
|
||||
<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>
|
||||
@@ -193,7 +193,7 @@ export default function CollectionCard({
|
||||
<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>
|
||||
@@ -9,31 +9,37 @@ 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/global";
|
||||
import { useRouter } from "next/router";
|
||||
import toast from "react-hot-toast";
|
||||
import { useTranslation } from "next-i18next";
|
||||
import { useCollections, useUpdateCollection } from "@/hooks/store/collections";
|
||||
import { useUpdateUser, useUser } from "@/hooks/store/user";
|
||||
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/utils";
|
||||
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 = {}, refetch } = useUser();
|
||||
const { data: user } = useUser();
|
||||
const updateUser = useUpdateUser();
|
||||
|
||||
const router = useRouter();
|
||||
const currentPath = router.asPath;
|
||||
|
||||
const [tree, setTree] = useState<TreeData | undefined>();
|
||||
|
||||
@@ -43,18 +49,18 @@ const CollectionListing = () => {
|
||||
collections,
|
||||
router,
|
||||
tree,
|
||||
user.collectionOrder
|
||||
user?.collectionOrder
|
||||
);
|
||||
} else return undefined;
|
||||
}, [collections, user, router]);
|
||||
}, [collections, user]);
|
||||
|
||||
useEffect(() => {
|
||||
setTree(initialTree);
|
||||
}, [initialTree]);
|
||||
|
||||
useEffect(() => {
|
||||
if (user.username) {
|
||||
refetch();
|
||||
if (user?.username) {
|
||||
// refetch();
|
||||
if (
|
||||
(!user.collectionOrder || user.collectionOrder.length === 0) &&
|
||||
collections.length > 0
|
||||
@@ -112,6 +118,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
|
||||
@@ -138,9 +219,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"));
|
||||
@@ -148,7 +229,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(
|
||||
@@ -169,42 +255,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) {
|
||||
@@ -225,7 +279,9 @@ const CollectionListing = () => {
|
||||
return (
|
||||
<Tree
|
||||
tree={tree}
|
||||
renderItem={(itemProps) => renderItem({ ...itemProps }, currentPath)}
|
||||
renderItem={(itemProps) =>
|
||||
renderItem({ ...itemProps }, router.asPath, droppableActive)
|
||||
}
|
||||
onExpand={onExpand}
|
||||
onCollapse={onCollapse}
|
||||
onDragEnd={onDragEnd}
|
||||
@@ -239,59 +295,77 @@ 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"
|
||||
>
|
||||
{Dropdown(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`}
|
||||
{Dropdown(item as ExtendedTreeItem, onExpand, onCollapse)}
|
||||
|
||||
<Link
|
||||
href={`/collections/${collection.id}`}
|
||||
className="w-full"
|
||||
{...provided.dragHandleProps}
|
||||
>
|
||||
{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-2xl"
|
||||
style={{ color: collection.color }}
|
||||
></i>
|
||||
)}
|
||||
<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>
|
||||
<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}
|
||||
{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>
|
||||
);
|
||||
};
|
||||
|
||||
50
apps/web/components/ConfirmationModal.tsx
Normal file
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
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;
|
||||
@@ -8,7 +8,7 @@ export default function dashboardItem({
|
||||
icon: string;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex items-center justify-between w-full rounded-2xl border border-neutral-content p-3 bg-gradient-to-tr from-neutral-content/70 to-50% to-base-200">
|
||||
<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>
|
||||
411
apps/web/components/DashboardLayoutDropdown.tsx
Normal file
411
apps/web/components/DashboardLayoutDropdown.tsx
Normal file
@@ -0,0 +1,411 @@
|
||||
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/utils";
|
||||
import toast from "react-hot-toast";
|
||||
|
||||
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, {
|
||||
onSettled: (data, error) => {
|
||||
if (error) {
|
||||
toast.error(error.message);
|
||||
}
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
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, {
|
||||
onSettled: (data, error) => {
|
||||
if (error) {
|
||||
toast.error(error.message);
|
||||
}
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
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-xs font-bold text-neutral mb-1">
|
||||
{t("display_on_dashboard")}
|
||||
</p>
|
||||
|
||||
<TextInput
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
className="py-0 bg-base-100"
|
||||
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>
|
||||
);
|
||||
}
|
||||
239
apps/web/components/DashboardLinks.tsx
Normal file
239
apps/web/components/DashboardLinks.tsx
Normal file
@@ -0,0 +1,239 @@
|
||||
import { LinkIncludingShortenedCollectionAndTags } from "@linkwarden/types/global";
|
||||
import useLocalSettingsStore from "@/store/localSettings";
|
||||
import {
|
||||
ArchivedFormat,
|
||||
CollectionIncludingMembersAndLinkCount,
|
||||
} from "@linkwarden/types/global";
|
||||
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 } 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/utils";
|
||||
import { useTranslation } from "next-i18next";
|
||||
|
||||
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-fit`}
|
||||
>
|
||||
{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 { t } = useTranslation();
|
||||
|
||||
const { attributes, listeners, setNodeRef, isDragging } = useDraggable({
|
||||
id: `${link.id}-${dashboardType}`,
|
||||
data: {
|
||||
linkId: link.id,
|
||||
link,
|
||||
dashboardType,
|
||||
},
|
||||
});
|
||||
const { data: collections = [] } = useCollections();
|
||||
|
||||
const { data: user } = useUser();
|
||||
|
||||
const {
|
||||
settings: { show },
|
||||
} = useLocalSettingsStore();
|
||||
|
||||
const router = useRouter();
|
||||
const isPublicRoute = router.pathname.startsWith("/public") ? true : false;
|
||||
|
||||
const { refetch } = useGetLink({ id: link.id as number, isPublicRoute });
|
||||
|
||||
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, link]);
|
||||
|
||||
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";
|
||||
}}
|
||||
unoptimized
|
||||
/>
|
||||
) : 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-11">
|
||||
<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}
|
||||
isPublicRoute={false}
|
||||
/>
|
||||
</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}
|
||||
t={t}
|
||||
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>
|
||||
);
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user