mirror of
https://github.com/linkwarden/linkwarden.git
synced 2026-03-03 16:27:02 +00:00
Compare commits
1955 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c7ab767872 | ||
|
|
fdf48abd29 | ||
|
|
59252759f2 | ||
|
|
dd96d80d42 | ||
|
|
f8efbe95e6 | ||
|
|
cf84474921 | ||
|
|
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 | ||
|
|
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 | ||
|
|
84e99b55c9 | ||
|
|
22c4fbf613 | ||
|
|
0e1b51177b | ||
|
|
44499c1277 | ||
|
|
9d986356a7 | ||
|
|
1d854e16aa | ||
|
|
02c02fc3b9 | ||
|
|
99bdc7d55e | ||
|
|
62c7bbbb74 | ||
|
|
dfb31ab1b3 | ||
|
|
e0c0b76eb0 | ||
|
|
9bc261bc85 | ||
|
|
b2d2e23539 | ||
|
|
04c69bb05f | ||
|
|
0cc1fd8407 | ||
|
|
848e0bf50e | ||
|
|
c3981c7fff | ||
|
|
ec7d6f4a6b | ||
|
|
9d8b602839 | ||
|
|
f0f57fb1a9 | ||
|
|
88820361e9 | ||
|
|
be47c78e4d | ||
|
|
fa059d1b00 | ||
|
|
bcfec38adf | ||
|
|
0344467cb7 | ||
|
|
454ed7b7eb | ||
|
|
51da37a22f | ||
|
|
6edbc4f438 | ||
|
|
899ddafd90 | ||
|
|
755721f1c2 | ||
|
|
0b20f61913 | ||
|
|
e9cf93d769 | ||
|
|
f94d10a3d3 | ||
|
|
6a95f6efdc | ||
|
|
150eeb9f11 | ||
|
|
fe77625289 | ||
|
|
63e7377df4 | ||
|
|
458eae9a3c | ||
|
|
7ef2afae7f | ||
|
|
a1d02f110d | ||
|
|
378fec06bb | ||
|
|
e04997c8c4 | ||
|
|
aecb90b4a4 | ||
|
|
dac6ec966c | ||
|
|
582159f454 | ||
|
|
cf02b7a099 | ||
|
|
7b4d324852 | ||
|
|
8932b9929a | ||
|
|
6b416f23f0 | ||
|
|
fb25cf5c75 | ||
|
|
c8f33f4800 | ||
|
|
95f818959b | ||
|
|
a0aabde322 | ||
|
|
0e3ca5b51f | ||
|
|
a733cc69a3 | ||
|
|
cbc88ebcb2 | ||
|
|
c3b78a8f82 | ||
|
|
8ad9ab7755 | ||
|
|
4bf220c786 | ||
|
|
ed8f2d3777 | ||
|
|
5b87799fdd | ||
|
|
97abe5de0c | ||
|
|
731b259329 | ||
|
|
63cef8e6b0 | ||
|
|
18db677c29 | ||
|
|
6071aa617f | ||
|
|
f270adbffa | ||
|
|
6259048431 | ||
|
|
40f4a5acd9 | ||
|
|
4240d37d77 | ||
|
|
0abe065c0c | ||
|
|
346f41a12c | ||
|
|
2ffbede170 | ||
|
|
c148c2b953 | ||
|
|
a872f218fb | ||
|
|
ff1f87cb35 | ||
|
|
94b143c91a | ||
|
|
52a11040f6 | ||
|
|
e9c43d75fe | ||
|
|
47b226cf1f | ||
|
|
266f834018 | ||
|
|
c9885c0b73 | ||
|
|
ec885a7db2 | ||
|
|
5665fbb412 | ||
|
|
9e933bd630 | ||
|
|
3572e101fb | ||
|
|
fb06549330 | ||
|
|
991f12566f | ||
|
|
160845319f | ||
|
|
bff5a7ae9a | ||
|
|
6e01c135dc | ||
|
|
f32e5a8d05 | ||
|
|
e9003e5c45 | ||
|
|
9065756686 | ||
|
|
881df93c02 | ||
|
|
ed7fab0473 | ||
|
|
38d054e143 | ||
|
|
5e89658a11 | ||
|
|
d964e02ba1 | ||
|
|
a36eb23096 | ||
|
|
8f875d15b0 | ||
|
|
9f660ff70f | ||
|
|
e6f43bbbfa | ||
|
|
1609868149 | ||
|
|
9075618e00 | ||
|
|
179cd18ac5 | ||
|
|
5a5fa9ed6c | ||
|
|
5279d94b8c | ||
|
|
5c3848e833 | ||
|
|
27d7bbabb3 | ||
|
|
8ff1346bf1 | ||
|
|
71119d511e | ||
|
|
b7eb8f2c2f | ||
|
|
3d54cc05a4 | ||
|
|
8973bdd94e | ||
|
|
1af37f3619 | ||
|
|
5303d63e4b | ||
|
|
05a30e1ec6 | ||
|
|
b1a55785b5 | ||
|
|
24b47e9d4b | ||
|
|
34d19f9dbe | ||
|
|
3a70e138b5 | ||
|
|
47367c44c1 | ||
|
|
e1a31481ad | ||
|
|
95dddd7da0 | ||
|
|
1a949ecdc6 | ||
|
|
2e6f1c207c | ||
|
|
6aa0fa9465 | ||
|
|
8677df0340 | ||
|
|
125f6ac619 | ||
|
|
89ecf5c529 | ||
|
|
fa78d6057f | ||
|
|
cfc28be898 | ||
|
|
c8efd4f9db | ||
|
|
ada4e53b46 | ||
|
|
91494b0188 | ||
|
|
e9fd6ec4d5 | ||
|
|
f08f4058dc | ||
|
|
d60200205a | ||
|
|
de38eb2963 | ||
|
|
f22dd4535d | ||
|
|
043589b301 | ||
|
|
4556827d79 | ||
|
|
98ebd6d7bc | ||
|
|
0a3ca4a1d4 | ||
|
|
106410f55a | ||
|
|
1ffe1b68a9 | ||
|
|
91ab0e609b | ||
|
|
cbb7a666cd | ||
|
|
e8cf14334f | ||
|
|
019790791b | ||
|
|
e41ba2668f | ||
|
|
66a09fdc4b | ||
|
|
e50143ca7e | ||
|
|
162b120e55 | ||
|
|
b4dd47aa37 | ||
|
|
256c232a85 | ||
|
|
b7ddf22662 | ||
|
|
5f60e9833e | ||
|
|
ceed23ff51 | ||
|
|
a4c83dc82f | ||
|
|
46f81ebf25 | ||
|
|
0ac5009a4a | ||
|
|
6842da4283 | ||
|
|
78ecf3ddb5 | ||
|
|
e39645e135 | ||
|
|
836360f99d | ||
|
|
9c9fd969bc | ||
|
|
213105942b | ||
|
|
0b7acb35b7 | ||
|
|
9b58ea5c98 | ||
|
|
c85c3bb0d7 | ||
|
|
7ca574b76f | ||
|
|
8593df4673 | ||
|
|
ddc2079f4b | ||
|
|
0de5caffa1 | ||
|
|
b14e77bdf9 | ||
|
|
8d366ae7d8 | ||
|
|
a18938ba2a | ||
|
|
6eac8423f8 | ||
|
|
cbf93dcf06 | ||
|
|
2993347dc7 | ||
|
|
cc45c8fc3e | ||
|
|
d5602a09cd | ||
|
|
736e98ac7d | ||
|
|
7eaff332a9 | ||
|
|
7931e2d7b6 | ||
|
|
ac3888f9b3 | ||
|
|
ac8add8c5d | ||
|
|
a6a0f6965b | ||
|
|
b2c5c3c6dd | ||
|
|
4555874725 | ||
|
|
0f5b70eda7 | ||
|
|
d1c3748681 | ||
|
|
2524139113 | ||
|
|
6c2b86fc4b | ||
|
|
d0e0526655 | ||
|
|
43e94ebd0b | ||
|
|
aeafe6e15d | ||
|
|
5ec221d87d | ||
|
|
d6d6442bc4 | ||
|
|
d12d12518e | ||
|
|
02ced62832 | ||
|
|
4febe1ace5 | ||
|
|
2e1e94112f | ||
|
|
d86bbcd940 | ||
|
|
eed80ca812 | ||
|
|
394251c1f1 | ||
|
|
68cdde91ad | ||
|
|
1ef286a38c | ||
|
|
508844dd9d | ||
|
|
fa1f9873d5 | ||
|
|
891803547e | ||
|
|
24d45f8e8e | ||
|
|
f95350405c | ||
|
|
665019dc59 | ||
|
|
b09de5a8af | ||
|
|
cfd33e9bd1 | ||
|
|
d3d2d5069e | ||
|
|
cffc74caa4 | ||
|
|
3cd8eadee3 | ||
|
|
d146ec296c | ||
|
|
fb4aa42eef | ||
|
|
f68582e28c | ||
|
|
d042c82cb0 | ||
|
|
8738dd45e9 | ||
|
|
839de18d7a | ||
|
|
2ba0851fee | ||
|
|
d99972a335 | ||
|
|
e071b9eb07 | ||
|
|
eb00d151b7 | ||
|
|
22aaa52b3e | ||
|
|
4541277b28 | ||
|
|
39faece9d7 | ||
|
|
a21b0760de | ||
|
|
04149fe86b | ||
|
|
ff6e71d494 | ||
|
|
5b02c1cfc9 | ||
|
|
1ff13e8aa0 | ||
|
|
eaf4524598 | ||
|
|
a276065288 | ||
|
|
1cf7421b76 | ||
|
|
ed4a334024 | ||
|
|
a5b1952e0d | ||
|
|
01826b1634 | ||
|
|
3b17d4ddfe | ||
|
|
f104fa095f | ||
|
|
b08e6690f3 | ||
|
|
33a654d21a | ||
|
|
e1262142f8 | ||
|
|
0a43279665 | ||
|
|
5491ac74a5 | ||
|
|
bbcfca4cde | ||
|
|
bf9a7d4fa0 | ||
|
|
edf4e489ec | ||
|
|
20c5a20851 | ||
|
|
6f47a20e87 | ||
|
|
384937e210 | ||
|
|
d22d989c91 | ||
|
|
4e0294322f | ||
|
|
75d5061bdf | ||
|
|
0150a9a6e3 | ||
|
|
87b79ffbac | ||
|
|
5a40677191 | ||
|
|
95ce2f30a8 | ||
|
|
e6a0ecbab5 | ||
|
|
e4c9cf8a38 | ||
|
|
eaca3d7453 | ||
|
|
fbe3642be4 | ||
|
|
bc32abbb92 | ||
|
|
38f731f313 | ||
|
|
aaf3590542 | ||
|
|
8bb6e32bfa | ||
|
|
7bd3872195 | ||
|
|
906779010e | ||
|
|
b0f87e8659 | ||
|
|
653b1bc396 | ||
|
|
9b1506a64e | ||
|
|
fb1869ca7a | ||
|
|
5e7835b4d5 | ||
|
|
0a91c47f83 | ||
|
|
dc9db05e75 | ||
|
|
e1149c2733 | ||
|
|
0591d7c134 | ||
|
|
4602269dd8 | ||
|
|
9ae6a22236 | ||
|
|
442da02956 | ||
|
|
dfcc271343 | ||
|
|
43d50dfd1b | ||
|
|
40bb3e6fae | ||
|
|
3e077fa247 | ||
|
|
3de8872f26 | ||
|
|
e9072bba51 | ||
|
|
d20c915970 | ||
|
|
1a378de267 | ||
|
|
d594159c15 | ||
|
|
aee10fa406 | ||
|
|
820d686c37 | ||
|
|
4189062c4c | ||
|
|
1461caf68a | ||
|
|
e7c7fedf8b | ||
|
|
b7adbbc86f | ||
|
|
975716937f | ||
|
|
2d0e52f65b | ||
|
|
e9afe0ef25 | ||
|
|
a38133d618 | ||
|
|
6498ae794b | ||
|
|
0371695eb3 | ||
|
|
9ae9c7c81a | ||
|
|
642374c2e5 | ||
|
|
f368c2aa81 | ||
|
|
fae9e95fa9 | ||
|
|
03639adc22 | ||
|
|
9fe829771d | ||
|
|
ed7b268c2b | ||
|
|
bf1a6efd2e | ||
|
|
6df2e44213 | ||
|
|
ae2324ecd3 | ||
|
|
accbd4cbfa | ||
|
|
5f4e0d4262 | ||
|
|
c072fed99f | ||
|
|
b4a9f917b5 | ||
|
|
078e5ba95f | ||
|
|
495509c888 | ||
|
|
dc388ebba5 | ||
|
|
21578bac8d | ||
|
|
1062e07065 | ||
|
|
2893d3caf2 | ||
|
|
9f74f62330 | ||
|
|
c6e3147bb6 | ||
|
|
1260e8c093 | ||
|
|
5cb4bdced3 | ||
|
|
03b4240b8b | ||
|
|
9a3e82470a | ||
|
|
ee2319996b | ||
|
|
c979adfe69 | ||
|
|
2b83522eaa | ||
|
|
8c738d4a99 | ||
|
|
63678b7f1e | ||
|
|
b73e845299 | ||
|
|
898b126231 | ||
|
|
17d1cb45e3 | ||
|
|
0aad2d9e4b | ||
|
|
c18a5f4162 | ||
|
|
df7814385a | ||
|
|
d568f22e00 | ||
|
|
6bd1c90417 | ||
|
|
a40026040c | ||
|
|
334ad9f3dc | ||
|
|
f944345745 | ||
|
|
6b647573f0 | ||
|
|
d81493e021 | ||
|
|
03f4523d57 | ||
|
|
c24e76adac | ||
|
|
5d26617251 | ||
|
|
0e47ad9920 | ||
|
|
ca45076b6c | ||
|
|
3bf6dcad2f | ||
|
|
23860b8511 | ||
|
|
8758976f8d | ||
|
|
550dbd2bf0 | ||
|
|
04d2b3c6b2 | ||
|
|
cc1c17363b | ||
|
|
7bd0e29538 | ||
|
|
5baf55694c | ||
|
|
193a70c6e8 | ||
|
|
5b430cf31e | ||
|
|
684609a1dd | ||
|
|
ebb2016915 | ||
|
|
c103b66694 | ||
|
|
863bcc3838 | ||
|
|
66b0aacc3f | ||
|
|
299498ffa6 | ||
|
|
8031432995 | ||
|
|
9cc3a7206e | ||
|
|
d15d965139 | ||
|
|
bc04ea0fe8 | ||
|
|
bd34dacf21 | ||
|
|
80f366cd7b | ||
|
|
c5602dc79f | ||
|
|
0158e58d90 | ||
|
|
602f399119 | ||
|
|
012caab606 | ||
|
|
102690fc10 | ||
|
|
a73e5fa6c6 | ||
|
|
75b1ae738f | ||
|
|
8563a09a07 | ||
|
|
da8dc83b8f | ||
|
|
e889509697 | ||
|
|
237499fd03 | ||
|
|
9a287d1aef | ||
|
|
299a2331ff | ||
|
|
be5400f7cb | ||
|
|
099bc9e054 | ||
|
|
5c5dd967c4 | ||
|
|
d1ed33b532 | ||
|
|
05c5bdf63c | ||
|
|
a1248fe62f | ||
|
|
8f7e0b8d09 | ||
|
|
9d91d2064b | ||
|
|
d631754b50 | ||
|
|
94be3a7448 | ||
|
|
4faf389a2b | ||
|
|
ff31732ba3 | ||
|
|
fa051c0d4d | ||
|
|
02cb93065f | ||
|
|
15a0084fb7 | ||
|
|
cd82083e09 | ||
|
|
c0abf2f411 | ||
|
|
061e22d225 | ||
|
|
a886437589 | ||
|
|
8e6f88d29f | ||
|
|
0b8a9b4310 | ||
|
|
ce1aa5a0ec | ||
|
|
a82c4ef85f | ||
|
|
6983e41576 | ||
|
|
7e96ba63df | ||
|
|
7036b46084 | ||
|
|
af7f0fb47c | ||
|
|
2bba8198b8 | ||
|
|
9d8ae6970c | ||
|
|
96a70a9689 | ||
|
|
6cae2fb634 | ||
|
|
288fd9df87 | ||
|
|
5e6d46b6b9 | ||
|
|
e79b98d3b0 | ||
|
|
7d43ed52a4 | ||
|
|
614653bf29 | ||
|
|
1b9dafbe47 | ||
|
|
abc93f1bf9 | ||
|
|
c23964a46d | ||
|
|
a76e996fc1 | ||
|
|
2264abd384 | ||
|
|
6544e3ecbb | ||
|
|
a8ffbc87d1 | ||
|
|
92c7f40956 | ||
|
|
6c29d905d9 | ||
|
|
9b85a2b1bb | ||
|
|
cebe746ca7 | ||
|
|
5b0297bfe0 | ||
|
|
9c5226ee51 | ||
|
|
6d30912812 | ||
|
|
78111f010b | ||
|
|
abb73f80bd | ||
|
|
e8d0cce58a | ||
|
|
a2637d4526 | ||
|
|
479995366a | ||
|
|
7edd7f893b | ||
|
|
0185ec57c7 | ||
|
|
e045c18b7d | ||
|
|
a1f48bbd79 | ||
|
|
7c95761990 | ||
|
|
c67526e54c | ||
|
|
8db5307747 | ||
|
|
54beb50576 | ||
|
|
9ab01da369 | ||
|
|
78c80a5fea | ||
|
|
644b827669 | ||
|
|
d66c784d3f | ||
|
|
1e2ed6c293 | ||
|
|
576d50f467 | ||
|
|
06234e42df | ||
|
|
8a901ba0e9 | ||
|
|
39422e54df | ||
|
|
a71f42af6e | ||
|
|
5b8e1d53cc | ||
|
|
52f7cbb10b | ||
|
|
22b2734494 | ||
|
|
9fa9fe5db0 | ||
|
|
6003c6c449 | ||
|
|
239589eaed | ||
|
|
586074ef43 | ||
|
|
afd5e5f036 | ||
|
|
8082efdc67 | ||
|
|
3618ba907d | ||
|
|
c68f9d68ad | ||
|
|
359d22e61b | ||
|
|
c216a92474 | ||
|
|
e45384c855 | ||
|
|
eadc98fbbe | ||
|
|
8505667f73 | ||
|
|
71678ba9dd | ||
|
|
d261bd39ec | ||
|
|
2c87459f35 | ||
|
|
83cd9f6a06 | ||
|
|
adcc4e85ac | ||
|
|
f921ecaa96 | ||
|
|
deb6ed7ec8 | ||
|
|
17cdb7efa4 | ||
|
|
7e98de6122 | ||
|
|
5f34f03355 | ||
|
|
4344183564 | ||
|
|
bc3ec3cc54 | ||
|
|
fc97735703 | ||
|
|
8f38c82ed7 | ||
|
|
b0ea14737f | ||
|
|
75d91fbac7 | ||
|
|
bcb6aea119 | ||
|
|
cb50de96a3 | ||
|
|
fc66dac933 | ||
|
|
f310cd79ad | ||
|
|
d262041f33 | ||
|
|
a498f3a10d | ||
|
|
811628a952 | ||
|
|
0fd10396f4 | ||
|
|
329019b34e | ||
|
|
73dda21573 | ||
|
|
27061ada43 | ||
|
|
78fa417f06 | ||
|
|
f0621dac2e | ||
|
|
90efec3c6e | ||
|
|
142af9b5c0 | ||
|
|
f68ca100a1 | ||
|
|
db446d450f | ||
|
|
7442799836 | ||
|
|
341154e928 | ||
|
|
65b29830f0 | ||
|
|
74030b26c5 | ||
|
|
861f8e55f4 | ||
|
|
2dd49ff844 | ||
|
|
f1655aad15 | ||
|
|
80ad01a2d0 | ||
|
|
915d08a315 | ||
|
|
08c2ff278f | ||
|
|
58d71a863b | ||
|
|
b4ea7dcd8e | ||
|
|
6f4759d928 | ||
|
|
eb8eb74a32 | ||
|
|
30ef557f43 | ||
|
|
7fb50337d3 | ||
|
|
d66019bfea | ||
|
|
ba1e096cff | ||
|
|
9354842065 | ||
|
|
464d2f920d | ||
|
|
1c55ec8d97 | ||
|
|
d181d5db20 | ||
|
|
154d0d5fb6 | ||
|
|
ca076b1be8 | ||
|
|
4f6368fcbf | ||
|
|
2b04bcb1df | ||
|
|
1a96ca32f9 | ||
|
|
d37b25c5a2 | ||
|
|
7856e76b15 | ||
|
|
04547e1bdf | ||
|
|
f37a4b9c9e | ||
|
|
163bf6a0cc | ||
|
|
489ad14c3b | ||
|
|
7c14cf7bf1 | ||
|
|
b8d7bd57c8 | ||
|
|
ce7a94e492 | ||
|
|
cd09843b99 | ||
|
|
389db59b28 | ||
|
|
b702aa0401 | ||
|
|
9a92b4d229 | ||
|
|
8278878673 | ||
|
|
4640c1c966 | ||
|
|
49fbbe966c | ||
|
|
3610e73d3b | ||
|
|
76a5dcb90b | ||
|
|
e51fba41e7 | ||
|
|
e8edd1c9a0 | ||
|
|
f30c652676 | ||
|
|
8cf621bc62 | ||
|
|
a89274fc03 | ||
|
|
baadd6c06b | ||
|
|
4a71af8a67 | ||
|
|
ece09c6f3b | ||
|
|
189db27c5b | ||
|
|
68d8d403cf | ||
|
|
07b87be7f1 | ||
|
|
e67fef1d04 | ||
|
|
87eb2471ff | ||
|
|
58b6f7339c | ||
|
|
c659711181 | ||
|
|
5503483502 | ||
|
|
a6d018fb53 | ||
|
|
3929f32e63 | ||
|
|
c08522386b | ||
|
|
b51a876904 | ||
|
|
2e2d7baee1 | ||
|
|
2b8f7d4be2 | ||
|
|
797ddc4b73 | ||
|
|
237d301f88 | ||
|
|
6d7d364853 | ||
|
|
495af0a752 | ||
|
|
388b9d9184 | ||
|
|
ede3882a94 | ||
|
|
e5fcf18fa4 | ||
|
|
a3d3b353a1 | ||
|
|
546e216ac9 | ||
|
|
ffc037b854 | ||
|
|
5fe6a5b19a | ||
|
|
cc2d7c863d | ||
|
|
53a65774f0 | ||
|
|
5990d4ce2d | ||
|
|
ce2eb8eafb | ||
|
|
bae4cf1d4f | ||
|
|
4e20d71a41 | ||
|
|
4a0e75c6e5 | ||
|
|
cac90524ed | ||
|
|
9fce74971f | ||
|
|
3feeecdc1d | ||
|
|
bde7b9aae0 | ||
|
|
bda0dc6c87 | ||
|
|
7dd254af48 | ||
|
|
a57c3114d8 | ||
|
|
3969cc5abd | ||
|
|
252d41886a | ||
|
|
d8bab2eb24 | ||
|
|
9bfba6037e | ||
|
|
e59ab23b3d | ||
|
|
01b3b4485e | ||
|
|
8c76b0d141 | ||
|
|
d2b867c438 | ||
|
|
f26cd31694 | ||
|
|
8dcd2c67d2 | ||
|
|
750aa294d0 | ||
|
|
281b376eac | ||
|
|
837241186f | ||
|
|
51cf8172ff | ||
|
|
9c51a65f31 | ||
|
|
a451e9fa2e | ||
|
|
ba4860a910 | ||
|
|
84aeac96ce | ||
|
|
ac70c9e29c | ||
|
|
f77ef58396 | ||
|
|
4442ce8705 | ||
|
|
4ff7298a3b | ||
|
|
a8be4d8f2f | ||
|
|
f183f122e9 | ||
|
|
5164f287d4 | ||
|
|
439c562002 | ||
|
|
cc02ab3615 | ||
|
|
d2e59d48c2 | ||
|
|
dbfdb587b6 | ||
|
|
7fd9f5b806 | ||
|
|
69ac3eb01f | ||
|
|
44272540aa | ||
|
|
0dda77db1e | ||
|
|
60aa7b830e | ||
|
|
b6ad2b5900 | ||
|
|
aee1828c15 | ||
|
|
67bf6b7d75 | ||
|
|
bbc2e4c457 | ||
|
|
1f28d9d461 | ||
|
|
df1da9f1f8 | ||
|
|
b476b3ccd4 | ||
|
|
ae561ff227 | ||
|
|
d438381ebd | ||
|
|
72266d1cd5 | ||
|
|
f560422427 | ||
|
|
7b7b979b20 | ||
|
|
c3c74b8162 | ||
|
|
0e60dee47d | ||
|
|
c3f72c4be8 | ||
|
|
79bd95f650 | ||
|
|
88d73703f8 | ||
|
|
41df9d0c82 | ||
|
|
0b2e78332a | ||
|
|
558ba11db7 | ||
|
|
155c77cbc4 | ||
|
|
a3c487d074 | ||
|
|
1cff2db876 | ||
|
|
2112176d6e | ||
|
|
aef33d859e | ||
|
|
5128bd44d8 | ||
|
|
0a77ee90a7 | ||
|
|
e2c6993a6d | ||
|
|
e1c4a8575b | ||
|
|
0c531760e8 | ||
|
|
5f468cd95d | ||
|
|
63597a041f | ||
|
|
e753f1dded | ||
|
|
8ecedf7cae | ||
|
|
44daffbae6 | ||
|
|
d5f262200b | ||
|
|
ccd3fcb8c1 | ||
|
|
059fcecc5f | ||
|
|
58e2fb22c9 | ||
|
|
2ace10c058 | ||
|
|
4b8f4c4179 | ||
|
|
8f62f4dffb | ||
|
|
95dc3b31db | ||
|
|
ebdeedc2ec | ||
|
|
325c41254d | ||
|
|
fda782ec44 | ||
|
|
080be856cc | ||
|
|
e1ef638f0e | ||
|
|
582607e726 | ||
|
|
9eaa106766 | ||
|
|
e0705ece4f | ||
|
|
da0533ac36 | ||
|
|
e3d9912378 | ||
|
|
26997475fd | ||
|
|
ea31eb47ae | ||
|
|
193c66123b | ||
|
|
eba9d3c86d | ||
|
|
b51355b406 | ||
|
|
0a070deebd | ||
|
|
c78aa2da0d | ||
|
|
aef55d65a1 | ||
|
|
efddd55841 | ||
|
|
f7a53d53e2 | ||
|
|
ef08edf1fb | ||
|
|
39261de45e | ||
|
|
cc915c8a64 | ||
|
|
7d9cc1f1f0 | ||
|
|
b06cb7c379 | ||
|
|
d5bd095827 | ||
|
|
daed2d82f4 | ||
|
|
39e022f87b | ||
|
|
3600f6398a | ||
|
|
392d98f090 | ||
|
|
6252b61b89 | ||
|
|
00bfdfb926 | ||
|
|
2d0093172a | ||
|
|
34e0115a0f | ||
|
|
dba2453453 | ||
|
|
ae3cf104b7 | ||
|
|
8534572662 | ||
|
|
2901db7035 | ||
|
|
5be194235c | ||
|
|
05563134b4 | ||
|
|
39db72a201 | ||
|
|
1d14d17e7a | ||
|
|
1716e1d408 | ||
|
|
4591f8ebc7 | ||
|
|
86bcd5ef07 | ||
|
|
047e156cfb | ||
|
|
dfe9fec4b4 | ||
|
|
cf8e409bb3 | ||
|
|
3565ad3e7c | ||
|
|
f35bc7b9fd | ||
|
|
23f4142414 | ||
|
|
ee3dca92cd | ||
|
|
4e47a6bffb | ||
|
|
d4f59d7f32 | ||
|
|
d91ebb3fa2 | ||
|
|
0c78187a10 | ||
|
|
834d25a99e | ||
|
|
bc46f6f64b | ||
|
|
a67980b29d | ||
|
|
07eb242c26 | ||
|
|
7880551c4d | ||
|
|
f71acd86df | ||
|
|
98fbb5b678 | ||
|
|
0c2c837028 | ||
|
|
a5b166f41d | ||
|
|
89de1829c2 | ||
|
|
fbca98984b | ||
|
|
06ab784441 | ||
|
|
4da2310e95 | ||
|
|
a8f4072f1c | ||
|
|
93bcfc67fe | ||
|
|
ba49946974 | ||
|
|
d16b296b15 | ||
|
|
3fc61ac5ce | ||
|
|
ced51e4801 | ||
|
|
254c090605 | ||
|
|
2a83ced9d8 | ||
|
|
52d333f085 | ||
|
|
fbbb97b4cd | ||
|
|
4e29330472 | ||
|
|
44c82ff426 | ||
|
|
29e0370808 | ||
|
|
74399c1708 | ||
|
|
1dde8a6088 | ||
|
|
e872c25332 | ||
|
|
dea1e12700 | ||
|
|
055869883a | ||
|
|
a5d3926d84 | ||
|
|
eee6a807da | ||
|
|
e24ae15a73 | ||
|
|
f1dadf1546 | ||
|
|
ee6dcdcc5b | ||
|
|
e4cf682217 | ||
|
|
e4fc8948fa | ||
|
|
9cfdb714c3 | ||
|
|
ca093008b7 | ||
|
|
3dc99eff9d | ||
|
|
530e83ba34 | ||
|
|
4ae75634ca | ||
|
|
372ed248f1 | ||
|
|
8b3b7445c3 | ||
|
|
327bb10a08 | ||
|
|
f9f2a8ca64 | ||
|
|
31c56b5009 | ||
|
|
7cf59bd430 | ||
|
|
feb18b8c7a | ||
|
|
ea9f940517 | ||
|
|
76a0151e43 | ||
|
|
a78d9774a7 | ||
|
|
dfbd56acc9 | ||
|
|
8a34413482 | ||
|
|
2e5f2deee7 | ||
|
|
320cddf224 | ||
|
|
86820c402b | ||
|
|
e27fb90f14 | ||
|
|
848a33a53e | ||
|
|
98106b9f25 | ||
|
|
385bdc2343 | ||
|
|
e9b47a69c5 | ||
|
|
1511ee1def | ||
|
|
cb5e6de8b8 | ||
|
|
b7387b1e08 | ||
|
|
c0bca32462 | ||
|
|
79bf67f879 | ||
|
|
8529602252 | ||
|
|
ad895eee17 | ||
|
|
31cf3c4f01 | ||
|
|
55c43d6f9e | ||
|
|
b65787358f | ||
|
|
9e2f70d2eb | ||
|
|
71b99bb25c | ||
|
|
82452555e5 | ||
|
|
ce746f33fd | ||
|
|
7131fde897 | ||
|
|
a6e0af6b6e | ||
|
|
f961ec0109 | ||
|
|
1677e132f3 | ||
|
|
141ca8f60b | ||
|
|
fbc083c373 | ||
|
|
feda50464c | ||
|
|
5e0f38c0d5 | ||
|
|
e9acb548bf | ||
|
|
e1f036adb2 | ||
|
|
1da960a3cf | ||
|
|
8c73ce60e9 | ||
|
|
a481903b50 | ||
|
|
6632c0507b | ||
|
|
fc679d1150 | ||
|
|
d849b37f6c | ||
|
|
dc6c17f8c4 | ||
|
|
e2cf627ccd | ||
|
|
de13a109c6 | ||
|
|
15c6213840 | ||
|
|
42f9dacffd | ||
|
|
4210913277 | ||
|
|
5f095b5631 | ||
|
|
cf1306d2c4 | ||
|
|
500f7a338c | ||
|
|
e910172558 | ||
|
|
d906391ae2 | ||
|
|
ae541bf2f5 | ||
|
|
c28c73ce18 | ||
|
|
f7d8ff9881 | ||
|
|
c5b083e802 | ||
|
|
4d691e0cce | ||
|
|
f5e7e373a8 | ||
|
|
ef33f2c948 | ||
|
|
d976761280 | ||
|
|
04ede17bfd | ||
|
|
9119402dac | ||
|
|
d52afd66f3 | ||
|
|
ae87b5698e | ||
|
|
1955cca589 | ||
|
|
8df0eab2a2 | ||
|
|
e0bb7ffa08 | ||
|
|
7c35fe409f | ||
|
|
ce9b4b05d4 | ||
|
|
b246cdbc44 | ||
|
|
a2b1513dbc | ||
|
|
bcfbdf3e49 | ||
|
|
f8ad08f5ed | ||
|
|
530ec69d1c | ||
|
|
b74ff01ce6 | ||
|
|
a001f70b9d | ||
|
|
ca3eb29c48 | ||
|
|
9af695deaf | ||
|
|
650fa693bd | ||
|
|
099024518f | ||
|
|
6d227750c3 | ||
|
|
6ba2aab0ba | ||
|
|
375a55dd37 | ||
|
|
8e49ccf723 | ||
|
|
ab83d1d0c6 | ||
|
|
fc9de564b6 | ||
|
|
8786f8b5fe | ||
|
|
a6a9402425 | ||
|
|
b36dd49e16 | ||
|
|
add781451a | ||
|
|
e6979d4e75 | ||
|
|
9868ab61c9 | ||
|
|
c367992116 | ||
|
|
64d361fa23 | ||
|
|
760c0b0026 | ||
|
|
3f4b7117bd | ||
|
|
361795ed47 | ||
|
|
93e4897c0b | ||
|
|
5b44bbcf59 | ||
|
|
8ba2cecf06 | ||
|
|
9cd165f2ce | ||
|
|
ce5b1f444a | ||
|
|
4b1017f45b | ||
|
|
6f77882ffc | ||
|
|
f8811a49c0 | ||
|
|
0e6b47d068 | ||
|
|
a3106e072b | ||
|
|
1f20180a51 | ||
|
|
ee05975e10 | ||
|
|
9c65e3e215 | ||
|
|
ba72de19ef | ||
|
|
ffc927759e | ||
|
|
33be9e5d83 | ||
|
|
c447b36540 | ||
|
|
18e0b8b010 | ||
|
|
2042b94680 | ||
|
|
104c79cd99 | ||
|
|
1b1e4108ec | ||
|
|
e253485e3d | ||
|
|
e2a5f36008 | ||
|
|
17721c91b6 | ||
|
|
230110e912 | ||
|
|
4ac7110fb4 | ||
|
|
e8a91bb551 | ||
|
|
9e4502c015 | ||
|
|
a36769c521 | ||
|
|
a3c6d9b42e | ||
|
|
2c9541734a | ||
|
|
d40373032a | ||
|
|
732a5227d3 | ||
|
|
6d51b6de53 | ||
|
|
2fd21c8219 | ||
|
|
cfc308f521 | ||
|
|
5850a423f9 | ||
|
|
bef8ad976d | ||
|
|
64a1f352cf | ||
|
|
93e0fe6172 | ||
|
|
692b9b99e7 | ||
|
|
3b2b9e8279 | ||
|
|
82b743fa8d | ||
|
|
1ca6d72f82 | ||
|
|
916c69602d | ||
|
|
b1dd9d66b6 | ||
|
|
b51b08b0f4 | ||
|
|
0a398d1fd9 | ||
|
|
d53dd93bb7 | ||
|
|
3c9d171f4d | ||
|
|
af80614b3a | ||
|
|
b88fa446be | ||
|
|
a33d68c03a | ||
|
|
ba7024db83 | ||
|
|
cbd0ec6aa7 | ||
|
|
a8172a9dbe | ||
|
|
75d4fce8ec | ||
|
|
73954fe78e | ||
|
|
14f9378375 | ||
|
|
3afd5fef6e | ||
|
|
b8b6fe24bc |
@@ -1,22 +1,22 @@
|
||||
// For format details, see https://aka.ms/devcontainer.json. For config options, see the
|
||||
// README at: https://github.com/devcontainers/templates/tree/main/src/typescript-node
|
||||
{
|
||||
"name": "Node.js & TypeScript",
|
||||
// Or use a Dockerfile or Docker Compose file. More info: https://containers.dev/guide/dockerfile
|
||||
"image": "mcr.microsoft.com/devcontainers/typescript-node:1-20-bullseye",
|
||||
"name": "Node.js & TypeScript",
|
||||
// Or use a Dockerfile or Docker Compose file. More info: https://containers.dev/guide/dockerfile
|
||||
"image": "mcr.microsoft.com/devcontainers/typescript-node:1-20-bullseye",
|
||||
|
||||
// Features to add to the dev container. More info: https://containers.dev/features.
|
||||
// "features": {},
|
||||
// Features to add to the dev container. More info: https://containers.dev/features.
|
||||
// "features": {},
|
||||
|
||||
// Use 'forwardPorts' to make a list of ports inside the container available locally.
|
||||
// "forwardPorts": [],
|
||||
// Use 'forwardPorts' to make a list of ports inside the container available locally.
|
||||
// "forwardPorts": [],
|
||||
|
||||
// Use 'postCreateCommand' to run commands after the container is created.
|
||||
// "postCreateCommand": "yarn install",
|
||||
// Use 'postCreateCommand' to run commands after the container is created.
|
||||
// "postCreateCommand": "yarn install",
|
||||
|
||||
// Configure tool-specific properties.
|
||||
// "customizations": {},
|
||||
// Configure tool-specific properties.
|
||||
// "customizations": {},
|
||||
|
||||
// Uncomment to connect as root instead. More info: https://aka.ms/dev-containers-non-root.
|
||||
"remoteUser": "root"
|
||||
// Uncomment to connect as root instead. More info: https://aka.ms/dev-containers-non-root.
|
||||
"remoteUser": "root"
|
||||
}
|
||||
|
||||
@@ -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
|
||||
449
.env.sample
449
.env.sample
@@ -1,18 +1,80 @@
|
||||
NEXTAUTH_SECRET=very_sensitive_secret
|
||||
NEXTAUTH_URL=http://localhost:3000
|
||||
NEXTAUTH_URL=http://localhost:3000/api/v1/auth
|
||||
NEXTAUTH_SECRET=
|
||||
|
||||
# Manual installation database settings
|
||||
DATABASE_URL=postgresql://user:password@localhost:5432/linkwarden
|
||||
# Example: DATABASE_URL=postgresql://user:password@localhost:5432/linkwarden
|
||||
DATABASE_URL=
|
||||
|
||||
# Docker installation database settings
|
||||
POSTGRES_PASSWORD=super_secret_password
|
||||
POSTGRES_PASSWORD=
|
||||
|
||||
# Additional Optional Settings
|
||||
PAGINATION_TAKE_COUNT=
|
||||
STORAGE_FOLDER=
|
||||
AUTOSCROLL_TIMEOUT=
|
||||
NEXT_PUBLIC_DISABLE_REGISTRATION=
|
||||
RE_ARCHIVE_LIMIT=
|
||||
NEXT_PUBLIC_CREDENTIALS_ENABLED=
|
||||
DISABLE_NEW_SSO_USERS=
|
||||
MAX_LINKS_PER_USER=
|
||||
ARCHIVE_TAKE_COUNT=
|
||||
BROWSER_TIMEOUT=
|
||||
IGNORE_UNAUTHORIZED_CA=
|
||||
IGNORE_HTTPS_ERRORS=
|
||||
IGNORE_URL_SIZE_LIMIT=
|
||||
NEXT_PUBLIC_DEMO=
|
||||
NEXT_PUBLIC_DEMO_USERNAME=
|
||||
NEXT_PUBLIC_DEMO_PASSWORD=
|
||||
NEXT_PUBLIC_ADMIN=
|
||||
NEXT_PUBLIC_MAX_FILE_BUFFER=
|
||||
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=
|
||||
@@ -26,9 +88,386 @@ SPACES_FORCE_PATH_STYLE=
|
||||
NEXT_PUBLIC_EMAIL_PROVIDER=
|
||||
EMAIL_FROM=
|
||||
EMAIL_SERVER=
|
||||
BASE_URL=
|
||||
|
||||
# Proxy settings
|
||||
PROXY=
|
||||
PROXY_USERNAME=
|
||||
PROXY_PASSWORD=
|
||||
PROXY_BYPASS=
|
||||
|
||||
# PDF archive settings
|
||||
PDF_MARGIN_TOP=
|
||||
PDF_MARGIN_BOTTOM=
|
||||
|
||||
#################
|
||||
# SSO Providers #
|
||||
#################
|
||||
|
||||
# 42 School
|
||||
NEXT_PUBLIC_FORTYTWO_ENABLED=
|
||||
FORTYTWO_CUSTOM_NAME=
|
||||
FORTYTWO_CLIENT_ID=
|
||||
FORTYTWO_CLIENT_SECRET=
|
||||
|
||||
# Apple
|
||||
NEXT_PUBLIC_APPLE_ENABLED=
|
||||
APPLE_CUSTOM_NAME=
|
||||
APPLE_ID=
|
||||
APPLE_SECRET=
|
||||
|
||||
# Atlassian
|
||||
NEXT_PUBLIC_ATLASSIAN_ENABLED=
|
||||
ATLASSIAN_CUSTOM_NAME=
|
||||
ATLASSIAN_CLIENT_ID=
|
||||
ATLASSIAN_CLIENT_SECRET=
|
||||
ATLASSIAN_SCOPE=
|
||||
|
||||
# Auth0
|
||||
NEXT_PUBLIC_AUTH0_ENABLED=
|
||||
AUTH0_CUSTOM_NAME=
|
||||
AUTH0_ISSUER=
|
||||
AUTH0_CLIENT_SECRET=
|
||||
AUTH0_CLIENT_ID=
|
||||
|
||||
# Authelia
|
||||
NEXT_PUBLIC_AUTHELIA_ENABLED=
|
||||
AUTHELIA_CLIENT_ID=
|
||||
AUTHELIA_CLIENT_SECRET=
|
||||
AUTHELIA_WELLKNOWN_URL=
|
||||
|
||||
# Authentik
|
||||
NEXT_PUBLIC_AUTHENTIK_ENABLED=
|
||||
AUTHENTIK_CUSTOM_NAME=
|
||||
AUTHENTIK_ISSUER=
|
||||
AUTHENTIK_CLIENT_ID=
|
||||
AUTHENTIK_CLIENT_SECRET=
|
||||
|
||||
# Azure AD B2C
|
||||
NEXT_PUBLIC_AZURE_AD_B2C_ENABLED=
|
||||
AZURE_AD_B2C_TENANT_NAME=
|
||||
AZURE_AD_B2C_CLIENT_ID=
|
||||
AZURE_AD_B2C_CLIENT_SECRET=
|
||||
AZURE_AD_B2C_PRIMARY_USER_FLOW=
|
||||
|
||||
# Azure AD
|
||||
NEXT_PUBLIC_AZURE_AD_ENABLED=
|
||||
AZURE_AD_CLIENT_ID=
|
||||
AZURE_AD_CLIENT_SECRET=
|
||||
AZURE_AD_TENANT_ID=
|
||||
|
||||
# Battle.net
|
||||
NEXT_PUBLIC_BATTLENET_ENABLED=
|
||||
BATTLENET_CUSTOM_NAME=
|
||||
BATTLENET_CLIENT_ID=
|
||||
BATTLENET_CLIENT_SECRET=
|
||||
BATTLENET_ISSUER=
|
||||
|
||||
# Box
|
||||
NEXT_PUBLIC_BOX_ENABLED=
|
||||
BOX_CUSTOM_NAME=
|
||||
BOX_CLIENT_ID=
|
||||
BOX_CLIENT_SECRET=
|
||||
|
||||
# Bungie
|
||||
NEXT_PUBLIC_BUNGIE_ENABLED=
|
||||
BUNGIE_CUSTOM_NAME=
|
||||
BUNGIE_CLIENT_ID=
|
||||
BUNGIE_CLIENT_SECRET=
|
||||
BUNGIE_API_KEY=
|
||||
|
||||
# Cognito
|
||||
NEXT_PUBLIC_COGNITO_ENABLED=
|
||||
COGNITO_CUSTOM_NAME=
|
||||
COGNITO_CLIENT_ID=
|
||||
COGNITO_CLIENT_SECRET=
|
||||
COGNITO_ISSUER=
|
||||
|
||||
# Coinbase
|
||||
NEXT_PUBLIC_COINBASE_ENABLED=
|
||||
COINBASE_CUSTOM_NAME=
|
||||
COINBASE_CLIENT_ID=
|
||||
COINBASE_CLIENT_SECRET=
|
||||
|
||||
# Discord
|
||||
NEXT_PUBLIC_DISCORD_ENABLED=
|
||||
DISCORD_CUSTOM_NAME=
|
||||
DISCORD_CLIENT_ID=
|
||||
DISCORD_CLIENT_SECRET=
|
||||
|
||||
# Dropbox
|
||||
NEXT_PUBLIC_DROPBOX_ENABLED=
|
||||
DROPBOX_CUSTOM_NAME=
|
||||
DROPBOX_CLIENT_ID=
|
||||
DROPBOX_CLIENT_SECRET=
|
||||
|
||||
# DuendeIndentityServer6
|
||||
NEXT_PUBLIC_DUENDE_IDS6_ENABLED=
|
||||
DUENDE_IDS6_CUSTOM_NAME=
|
||||
DUENDE_IDS6_CLIENT_ID=
|
||||
DUENDE_IDS6_CLIENT_SECRET=
|
||||
DUENDE_IDS6_ISSUER=
|
||||
|
||||
# EVE Online
|
||||
NEXT_PUBLIC_EVEONLINE_ENABLED=
|
||||
EVEONLINE_CUSTOM_NAME=
|
||||
EVEONLINE_CLIENT_ID=
|
||||
EVEONLINE_CLIENT_SECRET=
|
||||
|
||||
# Facebook
|
||||
NEXT_PUBLIC_FACEBOOK_ENABLED=
|
||||
FACEBOOK_CUSTOM_NAME=
|
||||
FACEBOOK_CLIENT_ID=
|
||||
FACEBOOK_CLIENT_SECRET=
|
||||
|
||||
# FACEIT
|
||||
NEXT_PUBLIC_FACEIT_ENABLED=
|
||||
FACEIT_CUSTOM_NAME=
|
||||
FACEIT_CLIENT_ID=
|
||||
FACEIT_CLIENT_SECRET=
|
||||
|
||||
# Foursquare
|
||||
NEXT_PUBLIC_FOURSQUARE_ENABLED=
|
||||
FOURSQUARE_CUSTOM_NAME=
|
||||
FOURSQUARE_CLIENT_ID=
|
||||
FOURSQUARE_CLIENT_SECRET=
|
||||
FOURSQUARE_APIVERSION=
|
||||
|
||||
# Freshbooks
|
||||
NEXT_PUBLIC_FRESHBOOKS_ENABLED=
|
||||
FRESHBOOKS_CUSTOM_NAME=
|
||||
FRESHBOOKS_CLIENT_ID=
|
||||
FRESHBOOKS_CLIENT_SECRET=
|
||||
|
||||
# FusionAuth
|
||||
NEXT_PUBLIC_FUSIONAUTH_ENABLED=
|
||||
FUSIONAUTH_CUSTOM_NAME=
|
||||
FUSIONAUTH_CLIENT_ID=
|
||||
FUSIONAUTH_CLIENT_SECRET=
|
||||
FUSIONAUTH_ISSUER=
|
||||
FUSIONAUTH_TENANT_ID=
|
||||
|
||||
# GitHub
|
||||
NEXT_PUBLIC_GITHUB_ENABLED=
|
||||
GITHUB_CUSTOM_NAME=
|
||||
GITHUB_ID=
|
||||
GITHUB_SECRET=
|
||||
|
||||
# GitLab
|
||||
NEXT_PUBLIC_GITLAB_ENABLED=
|
||||
GITLAB_CUSTOM_NAME=
|
||||
GITLAB_CLIENT_ID=
|
||||
GITLAB_CLIENT_SECRET=
|
||||
GITLAB_AUTH_URL=
|
||||
|
||||
# Google
|
||||
NEXT_PUBLIC_GOOGLE_ENABLED=
|
||||
GOOGLE_CUSTOM_NAME=
|
||||
GOOGLE_CLIENT_ID=
|
||||
GOOGLE_CLIENT_SECRET=
|
||||
|
||||
# HubSpot
|
||||
NEXT_PUBLIC_HUBSPOT_ENABLED=
|
||||
HUBSPOT_CUSTOM_NAME=
|
||||
HUBSPOT_CLIENT_ID=
|
||||
HUBSPOT_CLIENT_SECRET=
|
||||
|
||||
# IdentityServer4
|
||||
NEXT_PUBLIC_IDS4_ENABLED=
|
||||
IDS4_CUSTOM_NAME=
|
||||
IDS4_CLIENT_ID=
|
||||
IDS4_CLIENT_SECRET=
|
||||
IDS4_ISSUER=
|
||||
|
||||
# Kakao
|
||||
NEXT_PUBLIC_KAKAO_ENABLED=
|
||||
KAKAO_CUSTOM_NAME=
|
||||
KAKAO_CLIENT_ID=
|
||||
KAKAO_CLIENT_SECRET=
|
||||
|
||||
# Keycloak
|
||||
NEXT_PUBLIC_KEYCLOAK_ENABLED=
|
||||
KEYCLOAK_CUSTOM_NAME=
|
||||
KEYCLOAK_ISSUER=
|
||||
KEYCLOAK_CLIENT_ID=
|
||||
KEYCLOAK_CLIENT_SECRET=
|
||||
|
||||
# LINE
|
||||
NEXT_PUBLIC_LINE_ENABLED=
|
||||
LINE_CUSTOM_NAME=
|
||||
LINE_CLIENT_ID=
|
||||
LINE_CLIENT_SECRET=
|
||||
|
||||
# LinkedIn
|
||||
NEXT_PUBLIC_LINKEDIN_ENABLED=
|
||||
LINKEDIN_CUSTOM_NAME=
|
||||
LINKEDIN_CLIENT_ID=
|
||||
LINKEDIN_CLIENT_SECRET=
|
||||
|
||||
# Mailchimp
|
||||
NEXT_PUBLIC_MAILCHIMP_ENABLED=
|
||||
MAILCHIMP_CUSTOM_NAME=
|
||||
MAILCHIMP_CLIENT_ID=
|
||||
MAILCHIMP_CLIENT_SECRET=
|
||||
|
||||
# Mail.ru
|
||||
NEXT_PUBLIC_MAILRU_ENABLED=
|
||||
MAILRU_CUSTOM_NAME=
|
||||
MAILRU_CLIENT_ID=
|
||||
MAILRU_CLIENT_SECRET=
|
||||
|
||||
# Naver
|
||||
NEXT_PUBLIC_NAVER_ENABLED=
|
||||
NAVER_CUSTOM_NAME=
|
||||
NAVER_CLIENT_ID=
|
||||
NAVER_CLIENT_SECRET=
|
||||
|
||||
# Netlify
|
||||
NEXT_PUBLIC_NETLIFY_ENABLED=
|
||||
NETLIFY_CUSTOM_NAME=
|
||||
NETLIFY_CLIENT_ID=
|
||||
NETLIFY_CLIENT_SECRET=
|
||||
|
||||
# Okta
|
||||
NEXT_PUBLIC_OKTA_ENABLED=
|
||||
OKTA_CUSTOM_NAME=
|
||||
OKTA_CLIENT_ID=
|
||||
OKTA_CLIENT_SECRET=
|
||||
OKTA_ISSUER=
|
||||
|
||||
# OneLogin
|
||||
NEXT_PUBLIC_ONELOGIN_ENABLED=
|
||||
ONELOGIN_CUSTOM_NAME=
|
||||
ONELOGIN_CLIENT_ID=
|
||||
ONELOGIN_CLIENT_SECRET=
|
||||
ONELOGIN_ISSUER=
|
||||
|
||||
# Osso
|
||||
NEXT_PUBLIC_OSSO_ENABLED=
|
||||
OSSO_CUSTOM_NAME=
|
||||
OSSO_CLIENT_ID=
|
||||
OSSO_CLIENT_SECRET=
|
||||
OSSO_ISSUER=
|
||||
|
||||
# osu!
|
||||
NEXT_PUBLIC_OSU_ENABLED=
|
||||
OSU_CUSTOM_NAME=
|
||||
OSU_CLIENT_ID=
|
||||
OSU_CLIENT_SECRET=
|
||||
|
||||
# Patreon
|
||||
NEXT_PUBLIC_PATREON_ENABLED=
|
||||
PATREON_CUSTOM_NAME=
|
||||
PATREON_CLIENT_ID=
|
||||
PATREON_CLIENT_SECRET=
|
||||
|
||||
# Pinterest
|
||||
NEXT_PUBLIC_PINTEREST_ENABLED=
|
||||
PINTEREST_CUSTOM_NAME=
|
||||
PINTEREST_CLIENT_ID=
|
||||
PINTEREST_CLIENT_SECRET=
|
||||
|
||||
# Pipedrive
|
||||
NEXT_PUBLIC_PIPEDRIVE_ENABLED=
|
||||
PIPEDRIVE_CUSTOM_NAME=
|
||||
PIPEDRIVE_CLIENT_ID=
|
||||
PIPEDRIVE_CLIENT_SECRET=
|
||||
|
||||
# Reddit
|
||||
NEXT_PUBLIC_REDDIT_ENABLED=
|
||||
REDDIT_CUSTOM_NAME=
|
||||
REDDIT_CLIENT_ID=
|
||||
REDDIT_CLIENT_SECRET=
|
||||
|
||||
# Salesforce
|
||||
NEXT_PUBLIC_SALESFORCE_ENABLED=
|
||||
SALESFORCE_CUSTOM_NAME=
|
||||
SALESFORCE_CLIENT_ID=
|
||||
SALESFORCE_CLIENT_SECRET=
|
||||
|
||||
# Slack
|
||||
NEXT_PUBLIC_SLACK_ENABLED=
|
||||
SLACK_CUSTOM_NAME=
|
||||
SLACK_CLIENT_ID=
|
||||
SLACK_CLIENT_SECRET=
|
||||
|
||||
# Spotify
|
||||
NEXT_PUBLIC_SPOTIFY_ENABLED=
|
||||
SPOTIFY_CUSTOM_NAME=
|
||||
SPOTIFY_CLIENT_ID=
|
||||
SPOTIFY_CLIENT_SECRET=
|
||||
|
||||
# Strava
|
||||
NEXT_PUBLIC_STRAVA_ENABLED=
|
||||
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=
|
||||
TODOIST_CLIENT_ID=
|
||||
TODOIST_CLIENT_SECRET=
|
||||
|
||||
# Twitch
|
||||
NEXT_PUBLIC_TWITCH_ENABLED=
|
||||
TWITCH_CUSTOM_NAME=
|
||||
TWITCH_CLIENT_ID=
|
||||
TWITCH_CLIENT_SECRET=
|
||||
|
||||
# United Effects
|
||||
NEXT_PUBLIC_UNITED_EFFECTS_ENABLED=
|
||||
UNITED_EFFECTS_CUSTOM_NAME=
|
||||
UNITED_EFFECTS_CLIENT_ID=
|
||||
UNITED_EFFECTS_CLIENT_SECRET=
|
||||
UNITED_EFFECTS_ISSUER=
|
||||
|
||||
# VK
|
||||
NEXT_PUBLIC_VK_ENABLED=
|
||||
VK_CUSTOM_NAME=
|
||||
VK_CLIENT_ID=
|
||||
VK_CLIENT_SECRET=
|
||||
|
||||
# Wikimedia
|
||||
NEXT_PUBLIC_WIKIMEDIA_ENABLED=
|
||||
WIKIMEDIA_CUSTOM_NAME=
|
||||
WIKIMEDIA_CLIENT_ID=
|
||||
WIKIMEDIA_CLIENT_SECRET=
|
||||
|
||||
# Wordpress.com
|
||||
NEXT_PUBLIC_WORDPRESS_ENABLED=
|
||||
WORDPRESS_CUSTOM_NAME=
|
||||
WORDPRESS_CLIENT_ID=
|
||||
WORDPRESS_CLIENT_SECRET=
|
||||
|
||||
# Yandex
|
||||
NEXT_PUBLIC_YANDEX_ENABLED=
|
||||
YANDEX_CUSTOM_NAME=
|
||||
YANDEX_CLIENT_ID=
|
||||
YANDEX_CLIENT_SECRET=
|
||||
|
||||
# Zitadel
|
||||
NEXT_PUBLIC_ZITADEL_ENABLED=
|
||||
ZITADEL_CUSTOM_NAME=
|
||||
ZITADEL_CLIENT_ID=
|
||||
ZITADEL_CLIENT_SECRET=
|
||||
ZITADEL_ISSUER=
|
||||
|
||||
# Zoho
|
||||
NEXT_PUBLIC_ZOHO_ENABLED=
|
||||
ZOHO_CUSTOM_NAME=
|
||||
ZOHO_CLIENT_ID=
|
||||
ZOHO_CLIENT_SECRET=
|
||||
|
||||
# Zoom
|
||||
NEXT_PUBLIC_ZOOM_ENABLED=
|
||||
ZOOM_CUSTOM_NAME=
|
||||
ZOOM_CLIENT_ID=
|
||||
ZOOM_CLIENT_SECRET=
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
{
|
||||
"extends": "next/core-web-vitals",
|
||||
"rules": {
|
||||
"react-hooks/exhaustive-deps": "off"
|
||||
"react-hooks/exhaustive-deps": "off",
|
||||
"@next/next/no-img-element": "off"
|
||||
}
|
||||
}
|
||||
|
||||
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.
|
||||
22
.github/SECURITY.md
vendored
22
.github/SECURITY.md
vendored
@@ -1,17 +1,19 @@
|
||||
# Security Policy
|
||||
# Security
|
||||
|
||||
## Supported Versions
|
||||
The Linkwarden team and community take security bugs in Linkwarden seriously. We appreciate your efforts to responsibly disclose your findings, and will make every effort to acknowledge your contributions.
|
||||
|
||||
| Version | Supported |
|
||||
| ------- | --------- |
|
||||
| 1.x.x | ✅ |
|
||||
# Reporting Security Issues
|
||||
|
||||
## Reporting a Vulnerability
|
||||
**Please do not report security vulnerabilities through public GitHub issues.**
|
||||
|
||||
First off, we really appreciate the time you spent!
|
||||
Instead, please use the GitHub Security Advisory ["Report a Vulnerability"](https://github.com/linkwarden/linkwarden/security/advisories/new) tab.
|
||||
|
||||
If you found a vulnerability, these are the ways you can reach us:
|
||||
You should receive a response within 24 hours. If for some reason you do not, please follow up via email to ensure we received your original message:
|
||||
[security@linkwarden.app](mailto:security@linkwarden.app)
|
||||
|
||||
Email: [security@linkwarden.app](mailto:security@linkwarden.app)
|
||||
|
||||
Or you can directly DM me via Twitter: [@daniel31x13](https://twitter.com/Daniel31X13).
|
||||
After the initial reply to your report, the security team will keep you informed of the progress towards a fix and full announcement, and may ask for additional information or guidance.
|
||||
|
||||
# Preferred Languages
|
||||
|
||||
We prefer all communications to be in English.
|
||||
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
|
||||
18
.github/workflows/check-branch.yml
vendored
Normal file
18
.github/workflows/check-branch.yml
vendored
Normal file
@@ -0,0 +1,18 @@
|
||||
name: Check pull request source branch
|
||||
on:
|
||||
pull_request_target:
|
||||
types:
|
||||
- opened
|
||||
- reopened
|
||||
- synchronize
|
||||
- edited
|
||||
jobs:
|
||||
check-branches:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check branches
|
||||
run: |
|
||||
if [ ${{ github.head_ref }} != "dev" ] && [ ${{ github.base_ref }} == "main" ]; then
|
||||
echo "Merge requests to main branch are only allowed from dev branch. Please rebase your changes to dev branch."
|
||||
exit 1
|
||||
fi
|
||||
42
.github/workflows/locale-action.yml
vendored
Normal file
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
|
||||
145
.github/workflows/playwright-tests.yml
vendored
Normal file
145
.github/workflows/playwright-tests.yml
vendored
Normal file
@@ -0,0 +1,145 @@
|
||||
name: Linkwarden Playwright Tests
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
- qacomet/**
|
||||
pull_request:
|
||||
workflow_dispatch:
|
||||
|
||||
env:
|
||||
PGHOST: localhost
|
||||
PGPORT: 5432
|
||||
PGUSER: postgres
|
||||
PGPASSWORD: password
|
||||
PGDATABASE: postgres
|
||||
|
||||
TEST_POSTGRES_USER: test_linkwarden_user
|
||||
TEST_POSTGRES_PASSWORD: password
|
||||
TEST_POSTGRES_DATABASE: test_linkwarden_db
|
||||
TEST_POSTGRES_DATABASE_TEMPLATE: test_linkwarden_db_template
|
||||
TEST_POSTGRES_HOST: localhost
|
||||
TEST_POSTGREST_PORT: 5432
|
||||
PRODUCTION_POSTGRES_DATABASE: linkwarden_db
|
||||
|
||||
NEXTAUTH_SECRET: very_sensitive_secret
|
||||
NEXTAUTH_URL: http://localhost:3000/api/v1/auth
|
||||
|
||||
# Manual installation database settings
|
||||
DATABASE_URL: postgresql://test_linkwarden_user:password@localhost:5432/test_linkwarden_db
|
||||
|
||||
# Docker installation database settings
|
||||
POSTGRES_PASSWORD: password
|
||||
|
||||
TEST_USERNAME: test-user
|
||||
TEST_PASSWORD: password
|
||||
|
||||
jobs:
|
||||
playwright-test-runner:
|
||||
strategy:
|
||||
matrix:
|
||||
test_case: ['@login']
|
||||
timeout-minutes: 20
|
||||
runs-on:
|
||||
- ubuntu-22.04
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:16-alpine
|
||||
env:
|
||||
POSTGRES_USER: postgres
|
||||
POSTGRES_PASSWORD: password
|
||||
POSTGRES_DB: postgres
|
||||
ports:
|
||||
- 5432:5432
|
||||
options: >-
|
||||
--health-cmd pg_isready
|
||||
--health-interval 10s
|
||||
--health-timeout 5s
|
||||
--health-retries 5
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Use Node.js and Enable Yarn 4
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
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"
|
||||
psql -h localhost -U postgres -d postgres -c "CREATE USER ${{ env.TEST_POSTGRES_USER }} WITH PASSWORD '${{ env.TEST_POSTGRES_PASSWORD }}';"
|
||||
psql -h localhost -U postgres -d postgres -c "CREATE DATABASE ${{ env.TEST_POSTGRES_DATABASE }} OWNER ${{ env.TEST_POSTGRES_USER }};"
|
||||
|
||||
- name: Install packages
|
||||
run: yarn install --immutable
|
||||
|
||||
- name: Cache playwright dependencies
|
||||
uses: awalsh128/cache-apt-pkgs-action@latest
|
||||
with:
|
||||
packages: |
|
||||
ffmpeg fonts-freefont-ttf fonts-ipafont-gothic fonts-tlwg-loma-otf
|
||||
fonts-unifont fonts-wqy-zenhei gstreamer1.0-libav gstreamer1.0-plugins-bad
|
||||
gstreamer1.0-plugins-base gstreamer1.0-plugins-good libaa1 libass9
|
||||
libasyncns0 libavc1394-0 libavcodec58 libavdevice58 libavfilter7
|
||||
libavformat58 libavutil56 libbluray2 libbs2b0 libcaca0 libcdio-cdda2
|
||||
libcdio-paranoia2 libcdio19 libcdparanoia0 libchromaprint1 libcodec2-1.0
|
||||
libdc1394-25 libdca0 libdecor-0-0 libdv4 libdvdnav4 libdvdread8 libegl-mesa0
|
||||
libegl1 libevdev2 libevent-2.1-7 libfaad2 libffi7 libflac8 libflite1
|
||||
libfluidsynth3 libfreeaptx0 libgles2 libgme0 libgsm1 libgssdp-1.2-0
|
||||
libgstreamer-gl1.0-0 libgstreamer-plugins-bad1.0-0
|
||||
libgstreamer-plugins-base1.0-0 libgstreamer-plugins-good1.0-0 libgupnp-1.2-1
|
||||
libgupnp-igd-1.0-4 libharfbuzz-icu0 libhyphen0 libiec61883-0
|
||||
libinstpatch-1.0-2 libjack-jackd2-0 libkate1 libldacbt-enc2 liblilv-0-0
|
||||
libltc11 libmanette-0.2-0 libmfx1 libmjpegutils-2.1-0 libmodplug1
|
||||
libmp3lame0 libmpcdec6 libmpeg2encpp-2.1-0 libmpg123-0 libmplex2-2.1-0
|
||||
libmysofa1 libnice10 libnotify4 libopenal-data libopenal1 libopengl0
|
||||
libopenh264-6 libopenmpt0 libopenni2-0 libopus0 liborc-0.4-0
|
||||
libpocketsphinx3 libpostproc55 libpulse0 libqrencode4 libraw1394-11
|
||||
librubberband2 libsamplerate0 libsbc1 libsdl2-2.0-0 libserd-0-0 libshine3
|
||||
libshout3 libsndfile1 libsndio7.0 libsord-0-0 libsoundtouch1 libsoup-3.0-0
|
||||
libsoup-3.0-common libsoxr0 libspandsp2 libspeex1 libsphinxbase3
|
||||
libsratom-0-0 libsrt1.4-gnutls libsrtp2-1 libssh-gcrypt-4 libswresample3
|
||||
libswscale5 libtag1v5 libtag1v5-vanilla libtheora0 libtwolame0 libudfread0
|
||||
libv4l-0 libv4lconvert0 libva-drm2 libva-x11-2 libva2 libvdpau1
|
||||
libvidstab1.1 libvisual-0.4-0 libvo-aacenc0 libvo-amrwbenc0 libvorbisenc2
|
||||
libvpx7 libwavpack1 libwebrtc-audio-processing1 libwildmidi2 libwoff1
|
||||
libx264-163 libxcb-shape0 libxv1 libxvidcore4 libzbar0 libzimg2
|
||||
libzvbi-common libzvbi0 libzxingcore1 ocl-icd-libopencl1 timgm6mb-soundfont
|
||||
xfonts-cyrillic xfonts-encodings xfonts-scalable xfonts-utils
|
||||
|
||||
- name: Cache playwright browsers
|
||||
id: cache-playwright
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: ~/.cache/
|
||||
key: ${{ runner.os }}-playwright-${{ hashFiles('**/yarn.lock') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-playwright-
|
||||
|
||||
- name: Setup project
|
||||
run: |
|
||||
yarn prisma:generate
|
||||
yarn web:build
|
||||
yarn prisma:deploy
|
||||
|
||||
- name: Start linkwarden server and worker
|
||||
run: yarn concurrently:start &
|
||||
|
||||
- name: Run Tests
|
||||
run: yarn workspace @linkwarden/web playwright test --grep ${{ matrix.test_case }}
|
||||
|
||||
- uses: actions/upload-artifact@v4
|
||||
if: always()
|
||||
with:
|
||||
name: playwright-report
|
||||
path: test-results
|
||||
retention-days: 30
|
||||
5
.github/workflows/release-container.yml
vendored
5
.github/workflows/release-container.yml
vendored
@@ -1,6 +1,7 @@
|
||||
name: Create and publish a container image on release
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
push:
|
||||
tags:
|
||||
- "*"
|
||||
@@ -27,7 +28,7 @@ jobs:
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Log in to the Container registry
|
||||
uses: docker/login-action@v2
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ github.actor }}
|
||||
@@ -40,7 +41,7 @@ jobs:
|
||||
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
|
||||
|
||||
- name: Build and push Docker image
|
||||
uses: docker/build-push-action@v3
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
push: true
|
||||
|
||||
34
.gitignore
vendored
34
.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,16 +35,25 @@ yarn-error.log*
|
||||
*.tsbuildinfo
|
||||
next-env.d.ts
|
||||
|
||||
# generated files and folders
|
||||
/data
|
||||
.idea
|
||||
prisma/dev.db
|
||||
|
||||
# tests
|
||||
/tests
|
||||
/test-results/
|
||||
/playwright-report/
|
||||
/playwright/.cache/
|
||||
/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
|
||||
pgdata
|
||||
certificates
|
||||
|
||||
# generated files and folders
|
||||
/data
|
||||
/data.ms
|
||||
meilisearch
|
||||
meili_data
|
||||
.idea
|
||||
prisma/dev.db
|
||||
.turbo
|
||||
|
||||
service-account-file.json
|
||||
11
.prettierignore
Normal file
11
.prettierignore
Normal file
@@ -0,0 +1,11 @@
|
||||
node_modules
|
||||
.next
|
||||
/public
|
||||
|
||||
*.lock
|
||||
*.log
|
||||
|
||||
.github
|
||||
|
||||
data
|
||||
pgdata
|
||||
4
.prettierrc.json
Normal file
4
.prettierrc.json
Normal file
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"trailingComma": "es5",
|
||||
"tabWidth": 2
|
||||
}
|
||||
6
.vscode/settings.json
vendored
Normal file
6
.vscode/settings.json
vendored
Normal file
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"tailwindCSS.experimental.classRegex": [
|
||||
["cva\\(([^)]*)\\)", "[\"'`]([^\"'`]*).*?[\"'`]"],
|
||||
["cx\\(([^)]*)\\)", "(?:'|\"|`)([^']*)(?:'|\"|`)"]
|
||||
]
|
||||
}
|
||||
1
.yarnrc.yml
Normal file
1
.yarnrc.yml
Normal file
@@ -0,0 +1 @@
|
||||
nodeLinker: node-modules
|
||||
63
Dockerfile
63
Dockerfile
@@ -1,4 +1,22 @@
|
||||
FROM node:18.18-bullseye-slim
|
||||
# Stage: monolith-builder
|
||||
# Purpose: Uses the Rust image to build monolith
|
||||
# Notes:
|
||||
# - Fine to leave extra here, as only the resulting binary is copied out
|
||||
FROM docker.io/rust:1.86-bullseye AS monolith-builder
|
||||
|
||||
RUN set -eux && cargo install --locked monolith
|
||||
|
||||
# Stage: main-app
|
||||
# Purpose: Compiles the frontend and
|
||||
# Notes:
|
||||
# - Nothing extra should be left here. All commands should cleanup
|
||||
FROM node: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
|
||||
|
||||
@@ -6,18 +24,47 @@ RUN mkdir /data
|
||||
|
||||
WORKDIR /data
|
||||
|
||||
COPY ./package.json ./yarn.lock ./playwright.config.ts ./
|
||||
RUN corepack enable
|
||||
|
||||
# Increase timeout to pass github actions arm64 build
|
||||
RUN yarn install --network-timeout 10000000
|
||||
COPY ./.yarnrc.yml ./
|
||||
|
||||
RUN npx playwright install-deps && \
|
||||
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 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 && \
|
||||
apt-get autoremove && \
|
||||
apt-get clean && \
|
||||
rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Copy the compiled monolith binary from the builder stage
|
||||
COPY --from=monolith-builder /usr/local/cargo/bin/monolith /usr/local/bin/monolith
|
||||
|
||||
RUN set -eux && \
|
||||
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
|
||||
|
||||
CMD yarn prisma migrate deploy && yarn start
|
||||
HEALTHCHECK --interval=30s \
|
||||
--timeout=5s \
|
||||
--start-period=10s \
|
||||
--retries=3 \
|
||||
CMD [ "/usr/bin/curl", "--silent", "--fail", "http://127.0.0.1:3000/" ]
|
||||
|
||||
EXPOSE 3000
|
||||
|
||||
CMD ["sh", "-c", "yarn prisma:deploy && yarn concurrently:start"]
|
||||
|
||||
161
README.md
161
README.md
@@ -1,101 +1,136 @@
|
||||
<div align="center">
|
||||
<img src="./assets/logo.png" width="100px" />
|
||||
<h1>Linkwarden</h1>
|
||||
<h3>Bookmarks, Evolved</h3>
|
||||
|
||||
<a href="https://discord.com/invite/CtuYV47nuJ"><img src="https://img.shields.io/discord/1117993124669702164?logo=discord&style=flat-square" alt="Discord"></a>
|
||||
<img alt="GitHub commits since latest release (by SemVer including pre-releases)" src="https://img.shields.io/github/commits-since/linkwarden/linkwarden/v1.1.0/dev">
|
||||
<img src="https://img.shields.io/github/languages/top/linkwarden/linkwarden?style=flat-square" alt="Top Language">
|
||||
<img src="https://img.shields.io/github/stars/linkwarden/linkwarden?style=flat-square" alt="Github Stars">
|
||||
<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://github.com/linkwarden/linkwarden/releases"><img alt="GitHub release" src="https://img.shields.io/github/v/release/linkwarden/linkwarden"></a>
|
||||
<a href="https://crowdin.com/project/linkwarden">
|
||||
<img src="https://badges.crowdin.net/linkwarden/localized.svg" alt="Crowdin" /></a>
|
||||
<a href="https://opencollective.com/linkwarden"><img src="https://img.shields.io/opencollective/all/linkwarden" alt="Open Collective"></a>
|
||||
|
||||
</div>
|
||||
|
||||
<div align='center'>
|
||||
|
||||
[Website](https://linkwarden.app) | [Getting Started](https://docs.linkwarden.app) | [Features](https://github.com/linkwarden/linkwarden#features) | [Roadmap](https://github.com/orgs/linkwarden/projects/1) | [Support ❤](https://github.com/linkwarden/linkwarden#support-)
|
||||
[« LAUNCH DEMO »](https://demo.linkwarden.app)
|
||||
|
||||
[Cloud](https://cloud.linkwarden.app) · [Website](https://linkwarden.app) · [Features](https://github.com/linkwarden/linkwarden#features) · [Docs](https://docs.linkwarden.app)
|
||||
|
||||
<img src="./assets/home.png" />
|
||||
|
||||
</div>
|
||||
|
||||
## Intro & motivation
|
||||
|
||||
**Linkwarden is a self-hosted, open-source collaborative bookmark manager to collect, organize and archive webpages.** The objective is to organize useful webpages and articles you find across the web in one place, and since useful webpages can go away (see the inevitability of [Link Rot](https://www.howtogeek.com/786227/what-is-link-rot-and-how-does-it-threaten-the-web/)), Linkwarden also saves a copy of each webpage as a Screenshot and PDF, ensuring accessibility even if the original content is no longer available.
|
||||
**Linkwarden is a self-hosted, open-source collaborative bookmark manager to collect, read, annotate, and fully preserve what matters, all in one place.**
|
||||
|
||||
Additionally, Linkwarden is designed with collaboration in mind, sharing links with the public and/or allowing multiple users to work together seamlessly.
|
||||
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.
|
||||
|
||||
In addition to preservation, Linkwarden provides a user-friendly reading and annotation experience that blends the simplicity of a “read-it-later” tool with the reliability of a web archive. Whether you’re highlighting key ideas, jotting down thoughts, or revisiting content long after it’s disappeared from the web, Linkwarden keeps your knowledge accessible and organized.
|
||||
|
||||
Linkwarden is also designed with collaboration in mind, enabling you to share links with the public and/or collaborate seamlessly with multiple users.
|
||||
|
||||
> [!TIP]
|
||||
> Our official [Cloud](https://linkwarden.app/#pricing) offering provides the simplest way to begin using Linkwarden and it's the preferred choice for many due to its time-saving benefits. <br> Your subscription supports our hosting infrastructure and ongoing development. <br> Alternatively, if you prefer [self-hosting](https://docs.linkwarden.app/self-hosting/installation) Linkwarden, no problem! You'll still have access to all the premium features.
|
||||
|
||||
<img src="./assets/dashboard.png" />
|
||||
|
||||
<div align="center">
|
||||
<img src="./assets/all_links.png" width="32%" />
|
||||
|
||||
<img src="./assets/all_collections.png" width="32%" />
|
||||
|
||||
<img src="./assets/manage_team.png" width="32%" />
|
||||
|
||||
<img src="./assets/readable_view.png" width="32%" />
|
||||
|
||||
<img src="./assets/public_page.png" width="32%" />
|
||||
|
||||
<img src="./assets/light_mode.png" width="32%" />
|
||||
</div>
|
||||
|
||||
<details>
|
||||
<summary><b>A bit of a "history"</b></summary>
|
||||
Linkwarden has been completely rebuilt and redesigned from ground up, so pretty much the only thing it has in common with its predecessor is the idea behind it - bookmark management.
|
||||
|
||||
**What happened to the old version?**
|
||||
We've forked the old version from the current repository into [this repo](https://github.com/linkwarden/linkwarden-old).
|
||||
|
||||
</details>
|
||||
> Our official [Cloud](https://linkwarden.app/#pricing) offering provides the simplest way to begin using Linkwarden and it's the preferred choice for many due to its time-saving benefits. <br> Your subscription supports our hosting infrastructure and ongoing development. <br> Alternatively, if you prefer self-hosting Linkwarden, you can do so by following our [Installation documentation](https://docs.linkwarden.app/self-hosting/installation).
|
||||
|
||||
## Features
|
||||
|
||||
- 📸 Auto capture a screenshot, PDF, and readable view of each webpage.
|
||||
- 🏛️ Send your webpage to Wayback Machine ([archive.org](https://archive.org)) for a snapshot. (Optional)
|
||||
- 📂 Organize links by collection, name, description and multiple tags.
|
||||
- 👥 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, managed by the community. [Star it here!](https://github.com/linkwarden/browser-extension)
|
||||
- ⬇️ Import your bookmarks from other browsers.
|
||||
- ⚡️ Powerful API.
|
||||
- 🔐 SSO and Keycloak integration. (Enterprise and Self-hosted users only)
|
||||
- ✅ And many more features!
|
||||
- 📸 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)!)
|
||||
- ⬆️ 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 ⭐
|
||||
|
||||

|
||||
|
||||
## We're building our Community 🌐
|
||||
|
||||
Join and follow us in the following platforms to stay up to date about the most recent features and for support:
|
||||
|
||||
<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://fosstodon.org/@linkwarden"><img src="https://img.shields.io/mastodon/follow/110748840237143200?domain=https%3A%2F%2Ffosstodon.org" alt="Mastodon"></a>
|
||||
|
||||
## Suggestions
|
||||
|
||||
We _usually_ go after the [popular suggestions](https://github.com/linkwarden/linkwarden/issues?q=is%3Aissue+is%3Aopen+sort%3Areactions-%2B1-desc). Feel free to open a [new issue](https://github.com/linkwarden/linkwarden/issues/new?assignees=&labels=enhancement&projects=&template=feature_request.md&title=) to suggest one - others might be interested too! :)
|
||||
We _usually_ go after the [popular suggestions](https://github.com/linkwarden/linkwarden/issues?q=is%3Aissue%20is%3Aopen%20sort%3Areactions-%2B1-desc). Feel free to open a [new issue](https://github.com/linkwarden/linkwarden/issues/new?assignees=&labels=enhancement&projects=&template=feature_request.md&title=) to suggest one - others might be interested too! :)
|
||||
|
||||
## Roadmap
|
||||
|
||||
Make sure to check out our [public roadmap](https://github.com/orgs/linkwarden/projects/1).
|
||||
|
||||
## Docs
|
||||
## Community Projects
|
||||
|
||||
For information on how to get started or to set up your own instance, please visit the [documentation](https://docs.linkwarden.app).
|
||||
Here are some community-maintained projects that are built around Linkwarden:
|
||||
|
||||
## Main Tech Stack
|
||||
|
||||
- NextJS
|
||||
- TypeScript
|
||||
- Tailwind
|
||||
- Prisma
|
||||
- Zustand
|
||||
- [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 to this repo.
|
||||
If you want to contribute, Thanks! Start by choosing one of our [popular suggestions](https://github.com/linkwarden/linkwarden/issues?q=is%3Aissue%20is%3Aopen%20sort%3Areactions-%2B1-desc), just please stay in touch with [@daniel31x13](https://github.com/daniel31x13) before starting.
|
||||
|
||||
# Translations
|
||||
|
||||
If you want to help us translate Linkwarden to your language, please check out our [Crowdin page](https://crowdin.com/project/linkwarden) and start translating. We would love to have your help!
|
||||
|
||||
To start translating a new language, please create an issue so we can set it up for you. New languages will be added once they reach at least 50% translation completion.
|
||||
|
||||
<a href="https://crowdin.com/project/linkwarden">
|
||||
<img src="https://badges.crowdin.net/linkwarden/localized.svg" alt="Crowdin" /></a>
|
||||
|
||||
## Security
|
||||
|
||||
If you found a security vulnerability, please do **not** create a public issue, instead send an email to [security@linkwarden.app](mailto:security@linkwarden.app) stating the vulnerability. Thanks!
|
||||
|
||||
## Support ❤
|
||||
## Support <3
|
||||
|
||||
Other than using our official [Cloud](https://linkwarden.app/#pricing) offering, any [donations](https://opencollective.com/linkwarden) are highly appreciated as well!
|
||||
|
||||
@@ -106,3 +141,9 @@ Here are the other ways to support/cheer this project:
|
||||
- Referring Linkwarden to a friend.
|
||||
|
||||
If you did any of the above, Thanksss! Otherwise thanks.
|
||||
|
||||
## Thanks to All the Contributors 💪
|
||||
|
||||
Huge thanks to these guys for spending their time helping Linkwarden grow. They rock! ⚡️
|
||||
|
||||
<img src="https://contributors-img.web.app/image?repo=linkwarden/linkwarden" alt="Contributors"/>
|
||||
|
||||
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
|
||||
106
apps/mobile/app.json
Normal file
106
apps/mobile/app.json
Normal file
@@ -0,0 +1,106 @@
|
||||
{
|
||||
"expo": {
|
||||
"name": "Linkwarden",
|
||||
"slug": "linkwarden",
|
||||
"version": "1.0.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"
|
||||
}
|
||||
],
|
||||
[
|
||||
"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";
|
||||
|
||||
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: {},
|
||||
}),
|
||||
});
|
||||
33
apps/mobile/app/(tabs)/settings/_layout.tsx
Normal file
33
apps/mobile/app/(tabs)/settings/_layout.tsx
Normal file
@@ -0,0 +1,33 @@
|
||||
import { Stack } from "expo-router";
|
||||
import { useColorScheme } from "nativewind";
|
||||
import { rawTheme, ThemeName } from "@/lib/colors";
|
||||
import { Platform } from "react-native";
|
||||
|
||||
export default function 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"],
|
||||
},
|
||||
headerStyle: {
|
||||
backgroundColor:
|
||||
Platform.OS === "ios"
|
||||
? "transparent"
|
||||
: colorScheme === "dark"
|
||||
? rawTheme["dark"]["base-100"]
|
||||
: "white",
|
||||
},
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
241
apps/mobile/app/(tabs)/settings/index.tsx
Normal file
241
apps/mobile/app/(tabs)/settings/index.tsx
Normal file
@@ -0,0 +1,241 @@
|
||||
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,
|
||||
ExternalLink,
|
||||
LogOut,
|
||||
Mail,
|
||||
Moon,
|
||||
Smartphone,
|
||||
Sun,
|
||||
} from "lucide-react-native";
|
||||
import useDataStore from "@/store/data";
|
||||
import * as Clipboard from "expo-clipboard";
|
||||
|
||||
export default function SettingsScreen() {
|
||||
const { signOut, auth } = useAuthStore();
|
||||
const { data: user } = useUser(auth);
|
||||
const { colorScheme, setColorScheme } = useColorScheme();
|
||||
const { data, updateData } = useDataStore();
|
||||
const [override, setOverride] = useState<"light" | "dark" | "system">(
|
||||
data.theme || "system"
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
setColorScheme(override);
|
||||
updateData({ theme: override });
|
||||
}, [override]);
|
||||
|
||||
return (
|
||||
<View
|
||||
style={styles.container}
|
||||
collapsable={false}
|
||||
collapsableChildren={false}
|
||||
className="bg-base-100"
|
||||
>
|
||||
<ScrollView
|
||||
contentContainerStyle={{
|
||||
padding: 20,
|
||||
}}
|
||||
contentContainerClassName="flex-col gap-6"
|
||||
contentInsetAdjustmentBehavior="automatic"
|
||||
>
|
||||
<View className="bg-base-200 rounded-xl px-4 py-3">
|
||||
<Text className="font-semibold text-xl text-base-content">
|
||||
Your account
|
||||
</Text>
|
||||
<Text className="text-neutral mt-2 mb-3">
|
||||
{user?.email || "@" + user?.username}
|
||||
</Text>
|
||||
<View className="h-px bg-neutral-content -mr-4" />
|
||||
<TouchableOpacity
|
||||
className="flex-row items-center mt-3"
|
||||
onPress={() =>
|
||||
Alert.alert("Sign Out", "Are you sure you want to sign out?", [
|
||||
{
|
||||
text: "Cancel",
|
||||
style: "cancel",
|
||||
},
|
||||
{
|
||||
text: "Sign Out",
|
||||
style: "destructive",
|
||||
onPress: () => {
|
||||
signOut();
|
||||
},
|
||||
},
|
||||
])
|
||||
}
|
||||
>
|
||||
<Text className="flex-1 text-base text-red-500">Sign Out</Text>
|
||||
<LogOut size={18} color="red" />
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
<View>
|
||||
<Text className="mb-4 mx-4 text-neutral">Theme</Text>
|
||||
<View className="bg-base-200 rounded-xl flex-col">
|
||||
<TouchableOpacity
|
||||
className="flex-row gap-2 items-center justify-between py-3 px-4"
|
||||
onPress={() => setOverride("system")}
|
||||
>
|
||||
<View className="flex-row items-center gap-2">
|
||||
<Smartphone
|
||||
size={20}
|
||||
color={rawTheme[colorScheme as ThemeName].neutral}
|
||||
/>
|
||||
<Text className="text-neutral">System Defaults</Text>
|
||||
</View>
|
||||
{override === "system" ? (
|
||||
<Check
|
||||
size={20}
|
||||
color={rawTheme[colorScheme as ThemeName].primary}
|
||||
/>
|
||||
) : null}
|
||||
</TouchableOpacity>
|
||||
<View className="h-px bg-neutral-content ml-12" />
|
||||
<TouchableOpacity
|
||||
className="flex-row gap-2 items-center justify-between py-3 px-4"
|
||||
onPress={() => setOverride("light")}
|
||||
>
|
||||
<View className="flex-row items-center gap-2">
|
||||
<Sun size={20} color="orange" />
|
||||
<Text className="text-orange-500 dark:text-orange-400">
|
||||
Light
|
||||
</Text>
|
||||
</View>
|
||||
{override === "light" ? (
|
||||
<Check
|
||||
size={20}
|
||||
color={rawTheme[colorScheme as ThemeName].primary}
|
||||
/>
|
||||
) : null}
|
||||
</TouchableOpacity>
|
||||
<View className="h-px bg-neutral-content ml-12" />
|
||||
<TouchableOpacity
|
||||
className="flex-row gap-2 items-center justify-between py-3 px-4"
|
||||
onPress={() => setOverride("dark")}
|
||||
>
|
||||
<View className="flex-row items-center gap-2">
|
||||
<Moon size={20} color="royalblue" />
|
||||
<Text className="text-blue-600 dark:text-blue-400">Dark</Text>
|
||||
</View>
|
||||
{override === "dark" ? (
|
||||
<Check
|
||||
size={20}
|
||||
color={rawTheme[colorScheme as ThemeName].primary}
|
||||
/>
|
||||
) : null}
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View>
|
||||
<Text className="mb-4 mx-4 text-neutral">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">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,
|
||||
},
|
||||
}),
|
||||
});
|
||||
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";
|
||||
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",
|
||||
},
|
||||
});
|
||||
325
apps/mobile/app/_layout.tsx
Normal file
325
apps/mobile/app/_layout.tsx
Normal file
@@ -0,0 +1,325 @@
|
||||
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";
|
||||
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);
|
||||
const deleteLink = useDeleteLink(auth);
|
||||
|
||||
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={async () => {
|
||||
const isAlreadyPinned =
|
||||
tmp.link?.pinnedBy && tmp.link.pinnedBy[0]
|
||||
? true
|
||||
: false;
|
||||
await 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: () => {
|
||||
deleteLink.mutate(
|
||||
tmp.link?.id as number,
|
||||
{
|
||||
onSuccess: async () => {
|
||||
await deleteLinkCache(
|
||||
tmp.link?.id as number
|
||||
);
|
||||
},
|
||||
}
|
||||
);
|
||||
// go back
|
||||
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>
|
||||
);
|
||||
};
|
||||
98
apps/mobile/app/incoming.tsx
Normal file
98
apps/mobile/app/incoming.tsx
Normal file
@@ -0,0 +1,98 @@
|
||||
import React, { useEffect } from "react";
|
||||
import {
|
||||
SafeAreaView,
|
||||
View,
|
||||
Text,
|
||||
StyleSheet,
|
||||
ActivityIndicator,
|
||||
Alert,
|
||||
} from "react-native";
|
||||
import { Redirect, useRouter } from "expo-router";
|
||||
import useAuthStore from "@/store/auth";
|
||||
import useDataStore from "@/store/data";
|
||||
import { Check } from "lucide-react-native";
|
||||
import { useAddLink } from "@linkwarden/router/links";
|
||||
import { rawTheme, ThemeName } from "@/lib/colors";
|
||||
import { useColorScheme } from "nativewind";
|
||||
|
||||
export default function IncomingScreen() {
|
||||
const { auth } = useAuthStore();
|
||||
const router = useRouter();
|
||||
const { data, updateData } = useDataStore();
|
||||
const addLink = useAddLink(auth);
|
||||
const { colorScheme } = useColorScheme();
|
||||
|
||||
useEffect(() => {
|
||||
if (auth.status === "authenticated" && data.shareIntent.url)
|
||||
addLink.mutate(
|
||||
{ url: data.shareIntent.url },
|
||||
{
|
||||
onSuccess: () => {
|
||||
setTimeout(() => {
|
||||
updateData({
|
||||
shareIntent: {
|
||||
hasShareIntent: false,
|
||||
url: "",
|
||||
},
|
||||
});
|
||||
router.replace("/dashboard");
|
||||
}, 1000);
|
||||
},
|
||||
onError: (error) => {
|
||||
Alert.alert("Error", "There was an error adding the link.");
|
||||
console.error("Error adding link:", error);
|
||||
},
|
||||
}
|
||||
);
|
||||
}, [auth, data.shareIntent.url]);
|
||||
|
||||
if (auth.status === "unauthenticated") return <Redirect href="/" />;
|
||||
|
||||
return (
|
||||
<SafeAreaView className="flex-1 bg-base-100">
|
||||
{data?.shareIntent.url ? (
|
||||
<View className="flex-1 items-center justify-center">
|
||||
<Check
|
||||
size={140}
|
||||
className="mb-3 text-base-content"
|
||||
color={rawTheme[colorScheme as ThemeName].primary}
|
||||
/>
|
||||
<Text className="text-2xl font-semibold text-base-content">
|
||||
Link Saved!
|
||||
</Text>
|
||||
</View>
|
||||
) : (
|
||||
<View className="flex-1 items-center justify-center">
|
||||
<ActivityIndicator size="large" />
|
||||
<Text className="mt-3 text-base text-base-content opacity-70">
|
||||
One sec… {String(data?.shareIntent.url)}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
</SafeAreaView>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
backgroundColor: "#fff",
|
||||
},
|
||||
center: {
|
||||
flex: 1,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
},
|
||||
check: {
|
||||
marginBottom: 12,
|
||||
},
|
||||
title: {
|
||||
fontSize: 28,
|
||||
fontWeight: "600",
|
||||
},
|
||||
subtitle: {
|
||||
marginTop: 12,
|
||||
fontSize: 16,
|
||||
opacity: 0.7,
|
||||
},
|
||||
});
|
||||
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";
|
||||
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: 241 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",
|
||||
],
|
||||
};
|
||||
};
|
||||
77
apps/mobile/components/ActionSheets/AddLinkSheet.tsx
Normal file
77
apps/mobile/components/ActionSheets/AddLinkSheet.tsx
Normal file
@@ -0,0 +1,77 @@
|
||||
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);
|
||||
const [link, setLink] = useState("");
|
||||
const { colorScheme } = useColorScheme();
|
||||
|
||||
const insets = useSafeAreaInsets();
|
||||
|
||||
return (
|
||||
<ActionSheet
|
||||
ref={actionSheetRef}
|
||||
gestureEnabled
|
||||
indicatorStyle={{
|
||||
backgroundColor: rawTheme[colorScheme as ThemeName]["neutral-content"],
|
||||
}}
|
||||
containerStyle={{
|
||||
backgroundColor: rawTheme[colorScheme as ThemeName]["base-200"],
|
||||
}}
|
||||
safeAreaInsets={insets}
|
||||
>
|
||||
<View className="px-8 py-5">
|
||||
<Input
|
||||
placeholder="e.g. https://example.com"
|
||||
className="mb-4 bg-base-100"
|
||||
value={link}
|
||||
onChangeText={setLink}
|
||||
/>
|
||||
|
||||
<Button
|
||||
onPress={() =>
|
||||
addLink.mutate(
|
||||
{ url: link },
|
||||
{
|
||||
onSuccess: () => {
|
||||
actionSheetRef.current?.hide();
|
||||
setLink("");
|
||||
},
|
||||
onError: (error) => {
|
||||
Alert.alert("Error", "There was an error adding the link.");
|
||||
console.error("Error adding link:", error);
|
||||
},
|
||||
}
|
||||
)
|
||||
}
|
||||
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>
|
||||
);
|
||||
}
|
||||
276
apps/mobile/components/ActionSheets/EditLinkSheet.tsx
Normal file
276
apps/mobile/components/ActionSheets/EditLinkSheet.tsx
Normal file
@@ -0,0 +1,276 @@
|
||||
import { View, Text, Alert } from "react-native";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import ActionSheet, {
|
||||
FlatList,
|
||||
Route,
|
||||
SheetManager,
|
||||
SheetProps,
|
||||
useSheetRouteParams,
|
||||
useSheetRouter,
|
||||
} from "react-native-actions-sheet";
|
||||
import Input from "@/components/ui/Input";
|
||||
import { Button } from "@/components/ui/Button";
|
||||
import { useAddLink, useUpdateLink } from "@linkwarden/router/links";
|
||||
import useAuthStore from "@/store/auth";
|
||||
import {
|
||||
CollectionIncludingMembersAndLinkCount,
|
||||
LinkIncludingShortenedCollectionAndTags,
|
||||
} from "@linkwarden/types";
|
||||
import { useCollections } from "@linkwarden/router/collections";
|
||||
import { rawTheme, ThemeName } from "@/lib/colors";
|
||||
import { useColorScheme } from "nativewind";
|
||||
import { Folder, ChevronRight, Check } from "lucide-react-native";
|
||||
import useTmpStore from "@/store/tmp";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
|
||||
const Main = (props: SheetProps<"edit-link-sheet">) => {
|
||||
const { auth } = useAuthStore();
|
||||
|
||||
const params = useSheetRouteParams("edit-link-sheet", "main");
|
||||
|
||||
const [link, setLink] = useState<
|
||||
LinkIncludingShortenedCollectionAndTags | undefined
|
||||
>(props.payload?.link);
|
||||
const editLink = useUpdateLink(auth);
|
||||
const router = useSheetRouter("edit-link-sheet");
|
||||
const { colorScheme } = useColorScheme();
|
||||
|
||||
useEffect(() => {
|
||||
if (params?.link) {
|
||||
setLink(params.link);
|
||||
}
|
||||
}, [params?.link]);
|
||||
|
||||
const { tmp, updateTmp } = useTmpStore();
|
||||
|
||||
return (
|
||||
<View className="px-8 py-5">
|
||||
<Input
|
||||
placeholder="Name"
|
||||
className="mb-4 bg-base-100"
|
||||
value={link?.name || ""}
|
||||
onChangeText={(text) => link?.id && setLink({ ...link, name: text })}
|
||||
/>
|
||||
|
||||
{props.payload?.link?.url && (
|
||||
<Input
|
||||
placeholder="URL"
|
||||
className="mb-4 bg-base-100"
|
||||
value={link?.url || ""}
|
||||
onChangeText={(text) => link?.id && setLink({ ...link, url: text })}
|
||||
/>
|
||||
)}
|
||||
|
||||
<Button
|
||||
variant="input"
|
||||
className="mb-4"
|
||||
onPress={() => router?.navigate("collections", { link })}
|
||||
>
|
||||
<View className="flex-row items-center gap-2 w-[90%]">
|
||||
<Folder
|
||||
size={20}
|
||||
fill={link?.collection.color || "gray"}
|
||||
color={link?.collection.color || "gray"}
|
||||
/>
|
||||
<Text numberOfLines={1} className="w-[90%] text-base-content">
|
||||
{link?.collection.name}
|
||||
</Text>
|
||||
</View>
|
||||
<ChevronRight
|
||||
size={16}
|
||||
color={rawTheme[colorScheme as ThemeName]["neutral"]}
|
||||
/>
|
||||
</Button>
|
||||
|
||||
{/* <Button variant="input" className="mb-4 h-auto">
|
||||
{link?.tags && link?.tags.length > 0 ? (
|
||||
<View className="flex-row flex-wrap items-center gap-2 w-[90%]">
|
||||
{link.tags.map((tag) => (
|
||||
<View
|
||||
key={tag.id}
|
||||
className="bg-gray-200 rounded-md h-7 px-2 py-1"
|
||||
>
|
||||
<Text numberOfLines={1}>{tag.name}</Text>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
) : (
|
||||
<Text className="text-gray-500">No tags</Text>
|
||||
)}
|
||||
<ChevronRight size={16} color={"gray"} />
|
||||
</Button> */}
|
||||
|
||||
<Input
|
||||
multiline
|
||||
textAlignVertical="top"
|
||||
placeholder="Description"
|
||||
className="mb-4 h-28 bg-base-100"
|
||||
value={link?.description || ""}
|
||||
onChangeText={(text) =>
|
||||
link?.id && setLink({ ...link, description: text })
|
||||
}
|
||||
/>
|
||||
|
||||
<Button
|
||||
onPress={() =>
|
||||
editLink.mutate(link as LinkIncludingShortenedCollectionAndTags, {
|
||||
onSuccess: () => {
|
||||
if (link && tmp.link)
|
||||
updateTmp({
|
||||
link,
|
||||
});
|
||||
|
||||
SheetManager.hide("edit-link-sheet");
|
||||
},
|
||||
onError: (error) => {
|
||||
Alert.alert("Error", "There was an error editing the link.");
|
||||
console.error("Error editing link:", error);
|
||||
},
|
||||
})
|
||||
}
|
||||
isLoading={editLink.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 = () => {
|
||||
// 1. Create a brand-new link object with the new collection
|
||||
const updatedLink = {
|
||||
...currentLink!,
|
||||
collection,
|
||||
};
|
||||
|
||||
// 2. Navigate back to "main", passing the updated link as payload
|
||||
router?.popToTop();
|
||||
router?.navigate("main", { link: updatedLink });
|
||||
};
|
||||
|
||||
return (
|
||||
<Button variant="input" className="mb-2" onPress={onSelect}>
|
||||
<View className="flex-row items-center gap-2 w-[75%]">
|
||||
<Folder
|
||||
size={20}
|
||||
fill={collection.color || "gray"}
|
||||
color={collection.color || "gray"}
|
||||
/>
|
||||
<Text numberOfLines={1} className="w-full text-base-content">
|
||||
{collection.name}
|
||||
</Text>
|
||||
</View>
|
||||
<View className="flex-row items-center gap-2">
|
||||
{params.link?.collection.id === collection.id && (
|
||||
<Check
|
||||
size={16}
|
||||
color={rawTheme[colorScheme as ThemeName].primary}
|
||||
/>
|
||||
)}
|
||||
<Text className="text-neutral">
|
||||
{collection._count?.links ?? 0}
|
||||
</Text>
|
||||
</View>
|
||||
</Button>
|
||||
);
|
||||
},
|
||||
[addLink, params.link, router]
|
||||
);
|
||||
|
||||
return (
|
||||
<View className="px-8 py-5 max-h-[80vh]">
|
||||
<Input
|
||||
placeholder="Search collections"
|
||||
className="mb-4 bg-base-100"
|
||||
value={searchQuery}
|
||||
onChangeText={setSearchQuery}
|
||||
/>
|
||||
|
||||
<FlatList
|
||||
data={filteredCollections}
|
||||
keyExtractor={(e, i) => i.toString()}
|
||||
renderItem={renderItem}
|
||||
ListEmptyComponent={
|
||||
<Text
|
||||
style={{ textAlign: "center", marginTop: 20 }}
|
||||
className="text-neutral"
|
||||
>
|
||||
No collections match “{searchQuery}”
|
||||
</Text>
|
||||
}
|
||||
contentContainerStyle={{ paddingBottom: 20 }}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
const routes: Route[] = [
|
||||
{
|
||||
name: "main",
|
||||
component: Main,
|
||||
},
|
||||
{
|
||||
name: "collections",
|
||||
component: Collections,
|
||||
},
|
||||
];
|
||||
|
||||
export default function EditLinkSheet() {
|
||||
const { colorScheme } = useColorScheme();
|
||||
|
||||
const insets = useSafeAreaInsets();
|
||||
|
||||
return (
|
||||
<ActionSheet
|
||||
gestureEnabled
|
||||
indicatorStyle={{
|
||||
backgroundColor: rawTheme[colorScheme as ThemeName]["neutral-content"],
|
||||
}}
|
||||
enableRouterBackNavigation={true}
|
||||
routes={routes}
|
||||
initialRoute="main"
|
||||
containerStyle={{
|
||||
backgroundColor: rawTheme[colorScheme as ThemeName]["base-200"],
|
||||
}}
|
||||
safeAreaInsets={insets}
|
||||
/>
|
||||
);
|
||||
}
|
||||
92
apps/mobile/components/ActionSheets/NewCollectionSheet.tsx
Normal file
92
apps/mobile/components/ActionSheets/NewCollectionSheet.tsx
Normal file
@@ -0,0 +1,92 @@
|
||||
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={{
|
||||
backgroundColor: rawTheme[colorScheme as ThemeName]["neutral-content"],
|
||||
}}
|
||||
containerStyle={{
|
||||
backgroundColor: rawTheme[colorScheme as ThemeName]["base-200"],
|
||||
}}
|
||||
safeAreaInsets={insets}
|
||||
>
|
||||
<View className="px-8 py-5">
|
||||
<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>
|
||||
);
|
||||
}
|
||||
38
apps/mobile/components/ActionSheets/Sheets.tsx
Normal file
38
apps/mobile/components/ActionSheets/Sheets.tsx
Normal file
@@ -0,0 +1,38 @@
|
||||
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";
|
||||
|
||||
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;
|
||||
}>;
|
||||
};
|
||||
}>;
|
||||
"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";
|
||||
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);
|
||||
|
||||
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";
|
||||
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";
|
||||
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";
|
||||
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";
|
||||
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";
|
||||
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);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
341
apps/mobile/components/LinkListing.tsx
Normal file
341
apps/mobile/components/LinkListing.tsx
Normal file
@@ -0,0 +1,341 @@
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
Image,
|
||||
Pressable,
|
||||
Platform,
|
||||
Alert,
|
||||
ActivityIndicator,
|
||||
Linking,
|
||||
} from "react-native";
|
||||
import { decode } from "html-entities";
|
||||
import { LinkIncludingShortenedCollectionAndTags } from "@linkwarden/types";
|
||||
import { ArchivedFormat } from "@linkwarden/types";
|
||||
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);
|
||||
const { data: user } = useUser(auth);
|
||||
const { colorScheme } = useColorScheme();
|
||||
const { data } = useDataStore();
|
||||
|
||||
const deleteLink = useDeleteLink(auth);
|
||||
|
||||
const [url, setUrl] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
try {
|
||||
if (link.url) {
|
||||
setUrl(new URL(link.url).host.toLowerCase());
|
||||
}
|
||||
} catch (error) {
|
||||
console.log(error);
|
||||
}
|
||||
}, [link]);
|
||||
|
||||
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 || ""}
|
||||
color={link.collection.color || ""}
|
||||
/>
|
||||
<Text
|
||||
numberOfLines={1}
|
||||
className="font-light text-xs text-base-content"
|
||||
>
|
||||
{link.collection.name}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View className="flex-col items-end">
|
||||
<View className="rounded-lg overflow-hidden relative">
|
||||
{formatAvailable(link, "preview") ? (
|
||||
<Image
|
||||
source={{
|
||||
uri: `${auth.instance}/api/v1/archives/${link.id}?format=${ArchivedFormat.jpeg}&preview=true&updatedAt=${link.updatedAt}`,
|
||||
headers: {
|
||||
Authorization: `Bearer ${auth.session}`,
|
||||
},
|
||||
}}
|
||||
className="rounded-md h-[60px] w-[90px] object-cover scale-105"
|
||||
/>
|
||||
) : !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={async () => {
|
||||
const isAlreadyPinned =
|
||||
link?.pinnedBy && link.pinnedBy[0] ? true : false;
|
||||
|
||||
await updateLink.mutateAsync({
|
||||
...link,
|
||||
pinnedBy: (isAlreadyPinned
|
||||
? [{ id: undefined }]
|
||||
: [{ id: user?.id }]) as any,
|
||||
});
|
||||
}}
|
||||
>
|
||||
<ContextMenu.ItemTitle>
|
||||
{link.pinnedBy && link.pinnedBy[0] ? "Unpin Link" : "Pin Link"}
|
||||
</ContextMenu.ItemTitle>
|
||||
</ContextMenu.Item>
|
||||
|
||||
<ContextMenu.Item
|
||||
key="edit-link"
|
||||
onSelect={() => {
|
||||
SheetManager.show("edit-link-sheet", {
|
||||
payload: { link: link },
|
||||
});
|
||||
}}
|
||||
>
|
||||
<ContextMenu.ItemTitle>Edit Link</ContextMenu.ItemTitle>
|
||||
</ContextMenu.Item>
|
||||
|
||||
{link.url && atLeastOneFormatAvailable(link) && (
|
||||
<ContextMenu.Sub>
|
||||
<ContextMenu.SubTrigger key="preserved-formats">
|
||||
<ContextMenu.ItemTitle>Preserved Formats</ContextMenu.ItemTitle>
|
||||
</ContextMenu.SubTrigger>
|
||||
<ContextMenu.SubContent>
|
||||
{formatAvailable(link, "monolith") && (
|
||||
<ContextMenu.Item
|
||||
key="preserved-formats-webpage"
|
||||
onSelect={() =>
|
||||
router.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: () => {
|
||||
deleteLink.mutate(link.id as number, {
|
||||
onSuccess: async () => {
|
||||
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";
|
||||
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";
|
||||
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;
|
||||
});
|
||||
};
|
||||
138
apps/mobile/store/auth.ts
Normal file
138
apps/mobile/store/auth.ts
Normal file
@@ -0,0 +1,138 @@
|
||||
import { create } from "zustand";
|
||||
import * as SecureStore from "expo-secure-store";
|
||||
import { router } from "expo-router";
|
||||
import { MobileAuth } from "@linkwarden/types";
|
||||
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) {
|
||||
// make a request to the API to validate the token
|
||||
await fetch(instance + "/api/v1/users/me", {
|
||||
method: "GET",
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
}).then(async (res) => {
|
||||
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");
|
||||
}
|
||||
});
|
||||
} 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;
|
||||
37
apps/mobile/store/data.ts
Normal file
37
apps/mobile/store/data.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import { create } from "zustand";
|
||||
import { MobileData } from "@linkwarden/types";
|
||||
import AsyncStorage from "@react-native-async-storage/async-storage";
|
||||
import { colorScheme } from "nativewind";
|
||||
|
||||
type DataStore = {
|
||||
data: MobileData;
|
||||
updateData: (newData: Partial<MobileData>) => void;
|
||||
setData: () => void;
|
||||
};
|
||||
|
||||
const useDataStore = create<DataStore>((set, get) => ({
|
||||
data: {
|
||||
shareIntent: {
|
||||
hasShareIntent: false,
|
||||
url: "",
|
||||
},
|
||||
theme: "system",
|
||||
preferredBrowser: "app",
|
||||
},
|
||||
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";
|
||||
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"
|
||||
}
|
||||
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>
|
||||
);
|
||||
}
|
||||
65
apps/web/components/CenteredForm.tsx
Normal file
65
apps/web/components/CenteredForm.tsx
Normal file
@@ -0,0 +1,65 @@
|
||||
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;
|
||||
children: ReactNode;
|
||||
"data-testid"?: string;
|
||||
}
|
||||
|
||||
export default function CenteredForm({
|
||||
text,
|
||||
children,
|
||||
"data-testid": dataTestId,
|
||||
}: Props) {
|
||||
const { data: user } = useUser();
|
||||
|
||||
return (
|
||||
<div
|
||||
className="absolute top-0 bottom-0 left-0 right-0 flex justify-center items-center p-5"
|
||||
data-testid={dataTestId}
|
||||
>
|
||||
<div className="m-auto flex flex-col gap-2 w-full">
|
||||
{user?.theme === "light" ? (
|
||||
<Image
|
||||
src={"/linkwarden_light.png"}
|
||||
width={640}
|
||||
height={136}
|
||||
alt="Linkwarden"
|
||||
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 && (
|
||||
<p className="text-lg max-w-[30rem] min-w-80 w-full mx-auto font-semibold px-2 text-center">
|
||||
{text}
|
||||
</p>
|
||||
)}
|
||||
{children}
|
||||
<p className="text-center text-xs text-neutral mb-5">
|
||||
<Trans
|
||||
values={{ date: new Date().getFullYear() }}
|
||||
i18nKey="all_rights_reserved"
|
||||
components={[
|
||||
<Link
|
||||
href="https://linkwarden.app"
|
||||
className="font-semibold"
|
||||
key="linkwarden-website-key"
|
||||
/>,
|
||||
]}
|
||||
/>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
35
apps/web/components/Checkbox.tsx
Normal file
35
apps/web/components/Checkbox.tsx
Normal file
@@ -0,0 +1,35 @@
|
||||
import { ChangeEventHandler } from "react";
|
||||
|
||||
type Props = {
|
||||
label: string;
|
||||
state: boolean;
|
||||
className?: string;
|
||||
onClick: ChangeEventHandler<HTMLInputElement>;
|
||||
disabled?: boolean;
|
||||
};
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -8,19 +8,43 @@ type Props = {
|
||||
onMount?: (rect: DOMRect) => void;
|
||||
};
|
||||
|
||||
function getZIndex(element: HTMLElement): number {
|
||||
let zIndex = 0;
|
||||
while (element) {
|
||||
const zIndexStyle = window
|
||||
.getComputedStyle(element)
|
||||
.getPropertyValue("z-index");
|
||||
const numericZIndex = Number(zIndexStyle);
|
||||
if (zIndexStyle !== "auto" && !isNaN(numericZIndex)) {
|
||||
zIndex = numericZIndex;
|
||||
break;
|
||||
}
|
||||
element = element.parentElement as HTMLElement;
|
||||
}
|
||||
return zIndex;
|
||||
}
|
||||
|
||||
function useOutsideAlerter(
|
||||
ref: RefObject<HTMLElement>,
|
||||
onClickOutside: Function
|
||||
) {
|
||||
useEffect(() => {
|
||||
function handleClickOutside(event: Event) {
|
||||
if (
|
||||
ref.current &&
|
||||
!ref.current.contains(event.target as HTMLInputElement)
|
||||
) {
|
||||
onClickOutside(event);
|
||||
function handleClickOutside(event: MouseEvent) {
|
||||
const clickedElement = event.target as HTMLElement;
|
||||
|
||||
if (clickedElement.closest("[data-ignore-click-away]")) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (ref.current && !ref.current.contains(clickedElement)) {
|
||||
const refZIndex = getZIndex(ref.current);
|
||||
const clickedZIndex = getZIndex(clickedElement);
|
||||
if (clickedZIndex <= refZIndex) {
|
||||
onClickOutside(event);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener("mousedown", handleClickOutside);
|
||||
return () => {
|
||||
document.removeEventListener("mousedown", handleClickOutside);
|
||||
225
apps/web/components/CollectionCard.tsx
Normal file
225
apps/web/components/CollectionCard.tsx
Normal file
@@ -0,0 +1,225 @@
|
||||
import Link from "next/link";
|
||||
import {
|
||||
AccountSettings,
|
||||
CollectionIncludingMembersAndLinkCount,
|
||||
} from "@linkwarden/types";
|
||||
import React, { useEffect, useState } from "react";
|
||||
import ProfilePhoto from "./ProfilePhoto";
|
||||
import usePermissions from "@/hooks/usePermissions";
|
||||
import getPublicUserData from "@/lib/client/getPublicUserData";
|
||||
import EditCollectionModal from "./ModalContent/EditCollectionModal";
|
||||
import EditCollectionSharingModal from "./ModalContent/EditCollectionSharingModal";
|
||||
import DeleteCollectionModal from "./ModalContent/DeleteCollectionModal";
|
||||
import { useTranslation } from "next-i18next";
|
||||
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,
|
||||
}: {
|
||||
collection: CollectionIncludingMembersAndLinkCount;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const { data: user } = useUser();
|
||||
|
||||
const formattedDate = new Date(collection.createdAt as string).toLocaleString(
|
||||
t("locale"),
|
||||
{
|
||||
year: "numeric",
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
}
|
||||
);
|
||||
|
||||
const permissions = usePermissions(collection.id as number);
|
||||
|
||||
const [collectionOwner, setCollectionOwner] = useState<
|
||||
Partial<AccountSettings>
|
||||
>({});
|
||||
|
||||
useEffect(() => {
|
||||
const fetchOwner = async () => {
|
||||
if (collection && collection.ownerId !== user?.id) {
|
||||
const owner = await getPublicUserData(collection.ownerId as number);
|
||||
setCollectionOwner(owner);
|
||||
} 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,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
fetchOwner();
|
||||
}, [collection]);
|
||||
|
||||
const [editCollectionModal, setEditCollectionModal] = useState(false);
|
||||
const [editCollectionSharingModal, setEditCollectionSharingModal] =
|
||||
useState(false);
|
||||
const [deleteCollectionModal, setDeleteCollectionModal] = useState(false);
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
<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]"
|
||||
>
|
||||
{permissions === true && (
|
||||
<DropdownMenuItem onSelect={() => setEditCollectionModal(true)}>
|
||||
<i className="bi-pencil-square" />
|
||||
{t("edit_collection_info")}
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
|
||||
<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 px-1 py-1 rounded-full cursor-pointer hover:bg-base-content/20 transition-colors duration-200"
|
||||
onClick={() => setEditCollectionSharingModal(true)}
|
||||
>
|
||||
{collectionOwner.id && (
|
||||
<ProfilePhoto
|
||||
src={collectionOwner.image || undefined}
|
||||
name={collectionOwner.name}
|
||||
/>
|
||||
)}
|
||||
{collection.members
|
||||
.sort((a, b) => (a.userId as number) - (b.userId as number))
|
||||
.map((e, i) => {
|
||||
return (
|
||||
<ProfilePhoto
|
||||
key={i}
|
||||
src={e.user.image ? e.user.image : undefined}
|
||||
name={e.user.name}
|
||||
className="-ml-3"
|
||||
/>
|
||||
);
|
||||
})
|
||||
.slice(0, 3)}
|
||||
{collection.members.length - 3 > 0 && (
|
||||
<div className={`avatar drop-shadow-md placeholder -ml-3`}>
|
||||
<div className="bg-base-100 text-neutral rounded-full w-8 h-8 ring-2 ring-neutral-content">
|
||||
<span>+{collection.members.length - 3}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<Link
|
||||
href={`/collections/${collection.id}`}
|
||||
style={{
|
||||
backgroundImage: `linear-gradient(45deg, ${collection.color}30 10%, ${
|
||||
user?.theme === "dark" ? "oklch(var(--b2))" : "oklch(var(--b2))"
|
||||
} 50%, ${
|
||||
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"
|
||||
>
|
||||
<div className="card-body flex flex-col justify-between min-h-[12rem]">
|
||||
<div className="flex justify-between">
|
||||
<p className="card-title break-words line-clamp-2 w-full">
|
||||
{collection.name}
|
||||
</p>
|
||||
<div className="w-8 h-8 ml-10"></div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end items-center">
|
||||
<div className="text-right">
|
||||
<div className="font-bold text-sm flex justify-end gap-1 items-center">
|
||||
{collection.isPublic && (
|
||||
<i
|
||||
className="bi-globe2 drop-shadow text-neutral"
|
||||
title={t("collection_publicly_shared")}
|
||||
></i>
|
||||
)}
|
||||
<i
|
||||
className="bi-link-45deg text-lg text-neutral"
|
||||
title={t("links")}
|
||||
></i>
|
||||
{collection._count && collection._count.links}
|
||||
</div>
|
||||
<div className="flex items-center justify-end gap-1 text-neutral">
|
||||
<p className="font-bold text-xs flex gap-1 items-center">
|
||||
<i
|
||||
className="bi-calendar3 text-neutral"
|
||||
title={t("collection_publicly_shared")}
|
||||
></i>
|
||||
{formattedDate}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
{editCollectionModal && (
|
||||
<EditCollectionModal
|
||||
onClose={() => setEditCollectionModal(false)}
|
||||
activeCollection={collection}
|
||||
/>
|
||||
)}
|
||||
{editCollectionSharingModal && (
|
||||
<EditCollectionSharingModal
|
||||
onClose={() => setEditCollectionSharingModal(false)}
|
||||
activeCollection={collection}
|
||||
/>
|
||||
)}
|
||||
{deleteCollectionModal && (
|
||||
<DeleteCollectionModal
|
||||
onClose={() => setDeleteCollectionModal(false)}
|
||||
activeCollection={collection}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user