mirror of
https://github.com/linkwarden/linkwarden.git
synced 2026-03-03 18:17:02 +00:00
Compare commits
2131 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9edb450b6a | ||
|
|
4fa1f57351 | ||
|
|
f3d30085de | ||
|
|
da8761387f | ||
|
|
c9fd573b31 | ||
|
|
c99f9edd9a | ||
|
|
389a96dadc | ||
|
|
c8b1129e4f | ||
|
|
b9fd802288 | ||
|
|
549299743c | ||
|
|
21b6ab3de4 | ||
|
|
155ca17b55 | ||
|
|
686e3b44e1 | ||
|
|
f13c5e1cfc | ||
|
|
7e34d98bc4 | ||
|
|
e9c1c5217b | ||
|
|
209e0faa1b | ||
|
|
27a86c0b28 | ||
|
|
0198a9148e | ||
|
|
45dc95122a | ||
|
|
8c9cd34ec3 | ||
|
|
6b3dba3faf | ||
|
|
81ae7c64a9 | ||
|
|
d39a0ed5b2 | ||
|
|
ffc9971ce6 | ||
|
|
a8a9ad602f | ||
|
|
bc750bd588 | ||
|
|
e3de382739 | ||
|
|
57601413d4 | ||
|
|
af8a650096 | ||
|
|
b445fde85a | ||
|
|
7bbdec0f85 | ||
|
|
4743aa8144 | ||
|
|
fedd19770e | ||
|
|
6c5253121c | ||
|
|
6536b34c41 | ||
|
|
2c812e11e4 | ||
|
|
13305c06c4 | ||
|
|
3cbbeb55a4 | ||
|
|
6bb261c81a | ||
|
|
dbad316bac | ||
|
|
cc37543324 | ||
|
|
7c9307dd84 | ||
|
|
c794c0814e | ||
|
|
78d6d1c70a | ||
|
|
f79f57ccda | ||
|
|
eb8402448d | ||
|
|
350cdb485a | ||
|
|
8bd3bd3763 | ||
|
|
f2cfbf0b10 | ||
|
|
513c03dcae | ||
|
|
98b7e38139 | ||
|
|
9b5c08655a | ||
|
|
caf706e8ea | ||
|
|
d54f7da5a5 | ||
|
|
ae2e3c80db | ||
|
|
06285ce6d7 | ||
|
|
fdf48abd29 | ||
|
|
59252759f2 | ||
|
|
dd96d80d42 | ||
|
|
f8efbe95e6 | ||
|
|
b4b6edd618 | ||
|
|
d066378076 | ||
|
|
cddfc5dba6 | ||
|
|
4bdcfa0ee7 | ||
|
|
cf84474921 | ||
|
|
95e662358f | ||
|
|
eb31acbc30 | ||
|
|
7a34d836be | ||
|
|
eccf27425c | ||
|
|
3926e566b7 | ||
|
|
bca333be26 | ||
|
|
02a1e3b455 | ||
|
|
5b0c66b5e2 | ||
|
|
7c0c823c41 | ||
|
|
a8d2c55d12 | ||
|
|
37410fcf97 | ||
|
|
0ab4a2d883 | ||
|
|
756b896fe6 | ||
|
|
e3e3611b54 | ||
|
|
4bf65f8ebd | ||
|
|
6956c71aa2 | ||
|
|
e0f357513c | ||
|
|
daeb859990 | ||
|
|
f072bcd0b0 | ||
|
|
a497dc953a | ||
|
|
dcf6d72c01 | ||
|
|
d749487fb6 | ||
|
|
7079355013 | ||
|
|
46762b0d36 | ||
|
|
389e5df117 | ||
|
|
865bff2214 | ||
|
|
33553e22d5 | ||
|
|
1323787294 | ||
|
|
0a812fa72b | ||
|
|
9faf9d844e | ||
|
|
1558854f78 | ||
|
|
ab19df767c | ||
|
|
218fd504bf | ||
|
|
02158d7621 | ||
|
|
9edb42d181 | ||
|
|
8ba370bf62 | ||
|
|
a32934ee9d | ||
|
|
ff5ba2097d | ||
|
|
703f84403e | ||
|
|
f9ec18c51a | ||
|
|
3d0651d4af | ||
|
|
f30bf63c24 | ||
|
|
e2d89a56d6 | ||
|
|
40c3ccca93 | ||
|
|
1e515d5284 | ||
|
|
cb9cdc92c8 | ||
|
|
639f777b8a | ||
|
|
48b7384490 | ||
|
|
3bff1650c7 | ||
|
|
cadea5c654 | ||
|
|
e9b7c21ea0 | ||
|
|
03ca0c5e3d | ||
|
|
1ee3e01cfd | ||
|
|
e2e9395f8e | ||
|
|
f415e8c4bb | ||
|
|
6a73d7c594 | ||
|
|
24206c0953 | ||
|
|
7610f844f7 | ||
|
|
a7c1aeb876 | ||
|
|
dd4c925a56 | ||
|
|
a4c55fb455 | ||
|
|
451d17a2cb | ||
|
|
530a12a86f | ||
|
|
e908f9c534 | ||
|
|
9af731c7eb | ||
|
|
0548331937 | ||
|
|
9eb4c883ff | ||
|
|
17b578361a | ||
|
|
08a220f424 | ||
|
|
d7b6ce04e8 | ||
|
|
ece88eed5c | ||
|
|
257bdf9877 | ||
|
|
0c512345b1 | ||
|
|
9fc9e597e1 | ||
|
|
03ffc3c379 | ||
|
|
eb66d72589 | ||
|
|
3ab026aa37 | ||
|
|
bebbf5ad80 | ||
|
|
8f1612d10b | ||
|
|
26da55dfb9 | ||
|
|
ffee9d0551 | ||
|
|
fa4d9313ff | ||
|
|
0ff5092561 | ||
|
|
27997b8f4b | ||
|
|
fd676eda34 | ||
|
|
c46583131e | ||
|
|
1d8fadf18d | ||
|
|
6b988193b3 | ||
|
|
aab7c703ad | ||
|
|
98c1a1f035 | ||
|
|
f51f5f376f | ||
|
|
e0f3bc3bc2 | ||
|
|
c839e7ac4c | ||
|
|
b620bbaad7 | ||
|
|
21779df1c2 | ||
|
|
86e721c159 | ||
|
|
463883383b | ||
|
|
f13ab8500a | ||
|
|
3c7bcfe3e4 | ||
|
|
476a9d78a4 | ||
|
|
fa2d439b3e | ||
|
|
0907c3caa2 | ||
|
|
a4266b1a62 | ||
|
|
05bebd8703 | ||
|
|
2c727ccd47 | ||
|
|
1205cdce1c | ||
|
|
6ae4c37d0c | ||
|
|
4148c0f5fb | ||
|
|
f7e7fda779 | ||
|
|
fbafa3df4e | ||
|
|
7d45249e8f | ||
|
|
96472243db | ||
|
|
4ef0e6bd86 | ||
|
|
daf5dc4f22 | ||
|
|
43f8da30d3 | ||
|
|
d2e5dbd521 | ||
|
|
9c05aaf2df | ||
|
|
4f2e26c31f | ||
|
|
328e031ebd | ||
|
|
93d4e58306 | ||
|
|
2255fb3a6c | ||
|
|
d23a935cae | ||
|
|
57bde730f8 | ||
|
|
01e0587012 | ||
|
|
c9f8b233d5 | ||
|
|
2685188ba6 | ||
|
|
942672ea95 | ||
|
|
ac57cc0202 | ||
|
|
69b5919a96 | ||
|
|
09823fe776 | ||
|
|
a97cb229ff | ||
|
|
cbf3756f16 | ||
|
|
900f62487b | ||
|
|
bc6f9a55e4 | ||
|
|
e7b7a7f46a | ||
|
|
f3b23dadd1 | ||
|
|
8de57d3875 | ||
|
|
bfb6b25c28 | ||
|
|
a24786a513 | ||
|
|
6b9f181585 | ||
|
|
b21e2c6ffd | ||
|
|
96dcbcfb79 | ||
|
|
9c45f933cd | ||
|
|
427c062075 | ||
|
|
2130422c2a | ||
|
|
8133900e76 | ||
|
|
9ad4a3ee87 | ||
|
|
8c6169320a | ||
|
|
ea0916d826 | ||
|
|
14550a89e6 | ||
|
|
c1c5b3e953 | ||
|
|
bebe9671cb | ||
|
|
bcf12e25a1 | ||
|
|
4d9725f66c | ||
|
|
aa045801cc | ||
|
|
8dd54a1c26 | ||
|
|
ce76eb0b74 | ||
|
|
453dfbcfb8 | ||
|
|
0ca4f72e53 | ||
|
|
645e8dc4b2 | ||
|
|
bab44a942a | ||
|
|
9fb24a7329 | ||
|
|
3023316087 | ||
|
|
b5e8b00125 | ||
|
|
3c3d2d94fa | ||
|
|
3476d5fbf0 | ||
|
|
4ca5c5a177 | ||
|
|
feedf88b97 | ||
|
|
80b8029f50 | ||
|
|
fc328f3fd3 | ||
|
|
6a40a70e6d | ||
|
|
b336b04d71 | ||
|
|
d1e3badf21 | ||
|
|
3b916dfb71 | ||
|
|
6f0ee8eb73 | ||
|
|
20b538c1fb | ||
|
|
4541f1435b | ||
|
|
37c1100e37 | ||
|
|
3cc1be0a9d | ||
|
|
4b2c2ad66d | ||
|
|
28b755fe37 | ||
|
|
c021753585 | ||
|
|
bcb9d671c4 | ||
|
|
d2d9477493 | ||
|
|
4951f8113d | ||
|
|
67a2c62d26 | ||
|
|
8a0264081b | ||
|
|
c30ed6c784 | ||
|
|
ab0a91cf28 | ||
|
|
9b5c7909ec | ||
|
|
3aa9e33b4e | ||
|
|
7e387e504d | ||
|
|
d689b24d07 | ||
|
|
84de737d92 | ||
|
|
5f6934e7ea | ||
|
|
884e6dcf96 | ||
|
|
2f3e388d1e | ||
|
|
bbbd7a242d | ||
|
|
d91089ed48 | ||
|
|
c6bdb8ee5a | ||
|
|
d9d2e3b78f | ||
|
|
7f8d6dcd50 | ||
|
|
f4d3b8f657 | ||
|
|
0d1ed7bdd9 | ||
|
|
01fc6db008 | ||
|
|
1ad0bf14a6 | ||
|
|
af623cd8e5 | ||
|
|
f7a909f18e | ||
|
|
3c7a6b53a5 | ||
|
|
8b192bedf4 | ||
|
|
a57130f838 | ||
|
|
6ffed2568a | ||
|
|
f7a796fc2b | ||
|
|
e55bf7064e | ||
|
|
705ec3726b | ||
|
|
e343a1829b | ||
|
|
cf27bccb6e | ||
|
|
07d93c609d | ||
|
|
e6818dcbd0 | ||
|
|
98902a90ac | ||
|
|
27618029d8 | ||
|
|
9e6a22320b | ||
|
|
15ce6810c8 | ||
|
|
dc237b36f3 | ||
|
|
4348221210 | ||
|
|
cb1f42e0a2 | ||
|
|
8526442782 | ||
|
|
a75ac115de | ||
|
|
06e04fa11e | ||
|
|
3dfa53cf01 | ||
|
|
4acc639a8a | ||
|
|
07665cee7e | ||
|
|
9aefa3cf3b | ||
|
|
36be3d8772 | ||
|
|
1af9aaf11f | ||
|
|
69b86a473a | ||
|
|
22fde2d367 | ||
|
|
2b63d7e863 | ||
|
|
0aa6b0b4ae | ||
|
|
ee489534ec | ||
|
|
9f9d96edfe | ||
|
|
cf71bb8a8a | ||
|
|
9ed374c4c3 | ||
|
|
1119f80ffc | ||
|
|
0f62beb0ab | ||
|
|
395f5c01e1 | ||
|
|
803f344680 | ||
|
|
5f243d4fa2 | ||
|
|
dcd6b1be48 | ||
|
|
fed53fd187 | ||
|
|
e30bcaefe3 | ||
|
|
a81111dfe5 | ||
|
|
ce99562e16 | ||
|
|
c849e0aeda | ||
|
|
6f87e02ebc | ||
|
|
59ef7baeeb | ||
|
|
47068d3352 | ||
|
|
9153958811 | ||
|
|
3adbb10be8 | ||
|
|
5f3bfa4bda | ||
|
|
e5a278bb6a | ||
|
|
4bc3eb4bb5 | ||
|
|
20fbc1552d | ||
|
|
7cf3812e2c | ||
|
|
1ce72e6a03 | ||
|
|
711477743f | ||
|
|
1c55ac571a | ||
|
|
5128efc05c | ||
|
|
2b6f48ad83 | ||
|
|
435a44dd47 | ||
|
|
244f78a686 | ||
|
|
fbb9f7af23 | ||
|
|
c08da0f990 | ||
|
|
495d3111a1 | ||
|
|
c8f63cedc1 | ||
|
|
221930f88d | ||
|
|
58812811d0 | ||
|
|
f2b521d34b | ||
|
|
56b6791621 | ||
|
|
bd26f90738 | ||
|
|
63782f1627 | ||
|
|
078e38c7d8 | ||
|
|
11806563c8 | ||
|
|
6da64e6535 | ||
|
|
577f8600ae | ||
|
|
e6c2bc860f | ||
|
|
c54fbbc985 | ||
|
|
c7d38733ce | ||
|
|
794c2f2657 | ||
|
|
d73f236b36 | ||
|
|
658434afaf | ||
|
|
a191861748 | ||
|
|
eb3f1eeb5b | ||
|
|
c911f132f6 | ||
|
|
89122ccd5c | ||
|
|
ac8dacd570 | ||
|
|
0b942cbb29 | ||
|
|
f8cfe8e556 | ||
|
|
2be7f314bb | ||
|
|
f083b45b78 | ||
|
|
0e7d2ef716 | ||
|
|
581e27e8c6 | ||
|
|
c20f6d8015 | ||
|
|
91dafd40f1 | ||
|
|
d0cf951731 | ||
|
|
c75685c435 | ||
|
|
3bc8bb676e | ||
|
|
63f77b19a9 | ||
|
|
8521f4f1e9 | ||
|
|
a866de6931 | ||
|
|
d661c5c609 | ||
|
|
7b12a82a67 | ||
|
|
374c64ff0f | ||
|
|
7558068f7a | ||
|
|
c03557c080 | ||
|
|
f15b8df6f3 | ||
|
|
91e337ea8c | ||
|
|
65f0075a6f | ||
|
|
a8928e9de4 | ||
|
|
fb7e2286a7 | ||
|
|
f057434c3b | ||
|
|
c7c4959659 | ||
|
|
c6ed7bb8e8 | ||
|
|
7351b5265d | ||
|
|
fcc4b89249 | ||
|
|
04ea06e319 | ||
|
|
57347f2b90 | ||
|
|
35ae395447 | ||
|
|
553680d9eb | ||
|
|
1048989598 | ||
|
|
b5f96f5ed3 | ||
|
|
e22ed9bb3d | ||
|
|
51ee97c7ef | ||
|
|
c25bc658f4 | ||
|
|
3b8a6051e9 | ||
|
|
b8a6c77150 | ||
|
|
a9937d218c | ||
|
|
d3302daa3a | ||
|
|
dac2b4c30a | ||
|
|
28e3e23f8a | ||
|
|
80375843af | ||
|
|
232098191a | ||
|
|
b59ad36192 | ||
|
|
ebdc8b9db2 | ||
|
|
7a76de5726 | ||
|
|
a0ceca443c | ||
|
|
21cb65edbd | ||
|
|
c58f8f3086 | ||
|
|
04419aa9e0 | ||
|
|
e916ea53fc | ||
|
|
20fde711d1 | ||
|
|
652931e5be | ||
|
|
88d4e0203f | ||
|
|
87ae5da6f3 | ||
|
|
6a9a897529 | ||
|
|
92a1c4a5f0 | ||
|
|
1ca2b4e534 | ||
|
|
139f99d050 | ||
|
|
c5bfd20833 | ||
|
|
26ab9584ca | ||
|
|
76056d6b1a | ||
|
|
bc129af07a | ||
|
|
16a83496bc | ||
|
|
e270a6b957 | ||
|
|
ae6dbf7745 | ||
|
|
ee8a6634f0 | ||
|
|
a7f8de0a2e | ||
|
|
1f369401df | ||
|
|
a4375e3b57 | ||
|
|
532f6fb1f1 | ||
|
|
1e1bd8f333 | ||
|
|
c999259102 | ||
|
|
13883e0cbc | ||
|
|
5acfdc2f89 | ||
|
|
8e1767d7d0 | ||
|
|
477f534a17 | ||
|
|
032359a357 | ||
|
|
87c66b81e0 | ||
|
|
8eb3b5ce5d | ||
|
|
ee13d3551d | ||
|
|
52a4441b6f | ||
|
|
04e73942a6 | ||
|
|
6234278c9f | ||
|
|
e39d0b1bbd | ||
|
|
b703eb5654 | ||
|
|
0b296dc557 | ||
|
|
3ba2beb769 | ||
|
|
4b477f82bf | ||
|
|
30ef7863d5 | ||
|
|
b78aa39f79 | ||
|
|
787e4c49a4 | ||
|
|
6e60031e40 | ||
|
|
23148b22d7 | ||
|
|
07945f946d | ||
|
|
702508498e | ||
|
|
289818716c | ||
|
|
75419df40b | ||
|
|
586ef5ed58 | ||
|
|
ee53a170ed | ||
|
|
ea48e797e3 | ||
|
|
b294abd65b | ||
|
|
f7fcbfe635 | ||
|
|
2c01013eb3 | ||
|
|
a36af7d673 | ||
|
|
f4c8030a1b | ||
|
|
0a735cd2f6 | ||
|
|
d870ffecd1 | ||
|
|
360eadb08d | ||
|
|
35eec60ac9 | ||
|
|
dae7d2be3b | ||
|
|
e0abe1df39 | ||
|
|
4ae03168bb | ||
|
|
9a6429b85b | ||
|
|
20997ba8bc | ||
|
|
4fe2ef7134 | ||
|
|
63b6f3e66a | ||
|
|
5c07687e1a | ||
|
|
019bc783a4 | ||
|
|
3dfa12dbbf | ||
|
|
20eecd682e | ||
|
|
de3ca46ef0 | ||
|
|
a77c5c20cd | ||
|
|
822415c695 | ||
|
|
3de6e9965a | ||
|
|
8555d26d99 | ||
|
|
c8c8fb7875 | ||
|
|
64ae49a3a4 | ||
|
|
1bcbf2011d | ||
|
|
d43c4e3e95 | ||
|
|
6253635e78 | ||
|
|
b30f554fa7 | ||
|
|
f80432eda1 | ||
|
|
267a14c535 | ||
|
|
e24e338e67 | ||
|
|
0299f7bd4b | ||
|
|
db4b570577 | ||
|
|
8b5b1ae17e | ||
|
|
566927f6ff | ||
|
|
82d37e00e2 | ||
|
|
9be8b540d0 | ||
|
|
6c01456c76 | ||
|
|
dd6cde5b93 | ||
|
|
a984f8eca2 | ||
|
|
9fe910e205 | ||
|
|
c76ac2f4c7 | ||
|
|
71b1ff7da0 | ||
|
|
2bb1393eca | ||
|
|
44f094f473 | ||
|
|
ef98de61ee | ||
|
|
1897e9c8ce | ||
|
|
659910a76d | ||
|
|
327826d760 | ||
|
|
f3e31edb7d | ||
|
|
936f7d9614 | ||
|
|
966c271bd3 | ||
|
|
95c243df18 | ||
|
|
89efd237fe | ||
|
|
899426772b | ||
|
|
55582433c6 | ||
|
|
395f357fcb | ||
|
|
a14485c6dd | ||
|
|
2351a83c48 | ||
|
|
e761c1d17a | ||
|
|
ea01ab7b0f | ||
|
|
583bc077fb | ||
|
|
63ed780bb0 | ||
|
|
2441470849 | ||
|
|
5032bc3d13 | ||
|
|
7cec177ef0 | ||
|
|
cb104a3a64 | ||
|
|
3fe98f9346 | ||
|
|
22a311297a | ||
|
|
c9da55034c | ||
|
|
6d7f69929f | ||
|
|
77dd17f051 | ||
|
|
3e015fa76c | ||
|
|
c56259cb72 | ||
|
|
a05fbd7141 | ||
|
|
e00852f5df | ||
|
|
3067b6e2d3 | ||
|
|
a49a57c6ea | ||
|
|
32efa56f77 | ||
|
|
a8c6ef6fa0 | ||
|
|
21fadfe389 | ||
|
|
1e80fb33c4 | ||
|
|
edf72aa042 | ||
|
|
74d5dfb404 | ||
|
|
86971dcead | ||
|
|
9b0c3bf405 | ||
|
|
3ce30ec0f6 | ||
|
|
e08684328a | ||
|
|
fe06ecc3f6 | ||
|
|
42d877e2b5 | ||
|
|
94cba5394f | ||
|
|
70999b83c5 | ||
|
|
d89aa4a4e7 | ||
|
|
c933970cce | ||
|
|
8f10930127 | ||
|
|
30c1a064e0 | ||
|
|
d207c89d8e | ||
|
|
4712dc9b26 | ||
|
|
1203f62ab3 | ||
|
|
c1df0db729 | ||
|
|
ee5814a0e4 | ||
|
|
126d98d1b8 | ||
|
|
ebbe68dc9c | ||
|
|
5a763bdce0 | ||
|
|
3b36cf09f4 | ||
|
|
fb017a7655 | ||
|
|
b462531da8 | ||
|
|
acbefe4b6e | ||
|
|
e5296dd5c9 | ||
|
|
a8295f94b8 | ||
|
|
8607c066b8 | ||
|
|
63bc36d5ef | ||
|
|
74a671d165 | ||
|
|
b95c4b2d97 | ||
|
|
9a4f74e4a9 | ||
|
|
e43fd9359b | ||
|
|
be011955b5 | ||
|
|
5660093f1a | ||
|
|
a7b13e27f9 | ||
|
|
f25edb1302 | ||
|
|
592254daba | ||
|
|
395a7a58aa | ||
|
|
7fcd73b61b | ||
|
|
b091e133a7 | ||
|
|
5c8e339864 | ||
|
|
35808ef03f | ||
|
|
1fc7f436a1 | ||
|
|
2e15d43e4b | ||
|
|
8b25a54b26 | ||
|
|
6c4f694f17 | ||
|
|
a6370dd24b | ||
|
|
873f51379d | ||
|
|
701824dd18 | ||
|
|
e39876790b | ||
|
|
db7135abdf | ||
|
|
0b89ab3e35 | ||
|
|
707ec84ee2 | ||
|
|
ba03f99666 | ||
|
|
6cbf9c7153 | ||
|
|
b230c12f61 | ||
|
|
f3cd1845b7 | ||
|
|
580c87880b | ||
|
|
52a10aba1e | ||
|
|
5dcf73570e | ||
|
|
5cffa0bd4f | ||
|
|
6a8bb8b6e8 | ||
|
|
3e3a368131 | ||
|
|
6b7fca78e1 | ||
|
|
d356fc9fd5 | ||
|
|
f198cc5923 | ||
|
|
4e50ead03e | ||
|
|
c716a986a3 | ||
|
|
fcf9548552 | ||
|
|
918d0d46b2 | ||
|
|
a57ec28f3d | ||
|
|
fc4ba6a1fc | ||
|
|
b05c96360f | ||
|
|
37f13af8aa | ||
|
|
1e24580010 | ||
|
|
69c713bf50 | ||
|
|
e9853bc6f5 | ||
|
|
dbb055699a | ||
|
|
da3d4e2c0a | ||
|
|
27d88c2218 | ||
|
|
b59ccfffa1 | ||
|
|
4bc0476e38 | ||
|
|
eba81441ea | ||
|
|
4bd91f0b95 | ||
|
|
134757648d | ||
|
|
86a5d0f965 | ||
|
|
8a838f382a | ||
|
|
dd2361d1cf | ||
|
|
a251e5e526 | ||
|
|
1e8a0fd1c9 | ||
|
|
d4cc066c76 | ||
|
|
a1f2f3484b | ||
|
|
1339b5d625 | ||
|
|
40abb89602 | ||
|
|
0a8d48a07a | ||
|
|
b3530225f4 | ||
|
|
78726a2f04 | ||
|
|
c44f044505 | ||
|
|
8508360dc7 | ||
|
|
c16b1c621c | ||
|
|
e098fe1c39 | ||
|
|
7f8c856f6c | ||
|
|
8b1a88f326 | ||
|
|
9ced58eae8 | ||
|
|
3b2e5089d4 | ||
|
|
3822475fa4 | ||
|
|
929e23850b | ||
|
|
3645c6f0ff | ||
|
|
1ad44a81a3 | ||
|
|
d4a808e808 | ||
|
|
3953bb000c | ||
|
|
2be900a3f0 | ||
|
|
92d335ddbe | ||
|
|
f5633089f9 | ||
|
|
1b6f8f1fc8 | ||
|
|
5cd0fbdf6f | ||
|
|
05a20d9279 | ||
|
|
91a5c548d8 | ||
|
|
95f3ae382a | ||
|
|
6e3354ff0b | ||
|
|
9d92e0103c | ||
|
|
a57fc3f1c2 | ||
|
|
cc3a719611 | ||
|
|
16107c8369 | ||
|
|
43f6314ce2 | ||
|
|
5884687abc | ||
|
|
835fe3de17 | ||
|
|
f8839dbdd2 | ||
|
|
b0987581c0 | ||
|
|
1a02ba64a8 | ||
|
|
0ed87d7ffc | ||
|
|
ddd47d74e3 | ||
|
|
213b0aafee | ||
|
|
396e41a232 | ||
|
|
e04c256bd3 | ||
|
|
2023d6984f | ||
|
|
43ebd72aca | ||
|
|
572fa267d0 | ||
|
|
846a233639 | ||
|
|
a6e3ae1de5 | ||
|
|
c0159e5a27 | ||
|
|
ee1785ca6e | ||
|
|
7bf4db9b25 | ||
|
|
803a97b7e0 | ||
|
|
87715b8f62 | ||
|
|
276a2a30c8 | ||
|
|
bbad712e0d | ||
|
|
32f481f65b | ||
|
|
b7e260e180 | ||
|
|
df89364c0d | ||
|
|
77731dcf8a | ||
|
|
292fadaa1e | ||
|
|
fc801f98a9 | ||
|
|
a005e05d72 | ||
|
|
6124062a85 | ||
|
|
1a69058c7a | ||
|
|
4580f8cd26 | ||
|
|
63050ae7c2 | ||
|
|
82f1bd943e | ||
|
|
6a8ce9614b | ||
|
|
60bb97e4ca | ||
|
|
c9f2799618 | ||
|
|
96b508c41a | ||
|
|
a9206ca6c9 | ||
|
|
441e97e4d9 | ||
|
|
a918d2c960 | ||
|
|
c9860c535b | ||
|
|
c2a660fd50 | ||
|
|
1790638012 | ||
|
|
c9c3941688 | ||
|
|
9f376a633c | ||
|
|
fa0c08a1b2 | ||
|
|
4e0d7f5d8e | ||
|
|
59b6b7228e | ||
|
|
352389958c | ||
|
|
6c75f3ee58 | ||
|
|
792d03f906 | ||
|
|
82f4921038 | ||
|
|
ff497b5f66 | ||
|
|
e012c8953c | ||
|
|
06d8cf3cb8 | ||
|
|
7ce78fc314 | ||
|
|
de5a43cea4 | ||
|
|
6e47cc6897 | ||
|
|
62b1bcbc59 | ||
|
|
be532d5455 | ||
|
|
8718b1acfe | ||
|
|
f36d36dec7 | ||
|
|
8b6ded9179 | ||
|
|
4333d2686c | ||
|
|
78c1f6246f | ||
|
|
941bc04fc8 | ||
|
|
cfa36fc8da | ||
|
|
9388073cbf | ||
|
|
90eeba5191 | ||
|
|
95846c1d09 | ||
|
|
31b8092472 | ||
|
|
2a62c1ee1a | ||
|
|
5a028e98e3 | ||
|
|
8ed5147762 | ||
|
|
a20c4a67a8 | ||
|
|
bf8ac7d801 | ||
|
|
2971871a51 | ||
|
|
834714a941 | ||
|
|
08523b2234 | ||
|
|
479fea3ea4 | ||
|
|
a34f1d7308 | ||
|
|
b18ce2239a | ||
|
|
17f32152c3 | ||
|
|
47c711fea6 | ||
|
|
4423a30a72 | ||
|
|
cefe5e9e1b | ||
|
|
d30fb4645e | ||
|
|
ae099dce4f | ||
|
|
7c740f01d2 | ||
|
|
2b8964ca64 | ||
|
|
048842efa4 | ||
|
|
29d90db991 | ||
|
|
c99067ac37 | ||
|
|
4a66e1ec9c | ||
|
|
1211b6b1ef | ||
|
|
fbb1ce9687 | ||
|
|
3959a42a30 | ||
|
|
759cb15148 | ||
|
|
8ecd35acfe | ||
|
|
db9ea8eef4 | ||
|
|
b32151eb7e | ||
|
|
d377fa6eb0 | ||
|
|
4b958faf7e | ||
|
|
6981ad6d7a | ||
|
|
2d97f3a138 | ||
|
|
e668b67a3d | ||
|
|
41eb7df457 | ||
|
|
7725a5dd8b | ||
|
|
37588acc0d | ||
|
|
6ad2511fbc | ||
|
|
f3cb12c962 | ||
|
|
99fa0b1d8b | ||
|
|
9db7ff329a | ||
|
|
6bdf0f1c91 | ||
|
|
cd23f10480 | ||
|
|
bea11edbe8 | ||
|
|
d565958e5f | ||
|
|
7222b19745 | ||
|
|
b9be960c7e | ||
|
|
b337045ab2 | ||
|
|
14db14ff07 | ||
|
|
6ca2774b28 | ||
|
|
c3d702ad53 | ||
|
|
921d8a3718 | ||
|
|
2b258680b1 | ||
|
|
82f5a7026f | ||
|
|
daef130728 | ||
|
|
a181b43529 | ||
|
|
968bd04b40 | ||
|
|
46c20af530 | ||
|
|
cfb4081112 | ||
|
|
61ca5cf3e5 | ||
|
|
cecbf694f5 | ||
|
|
0e122c7485 | ||
|
|
c6713f67f4 | ||
|
|
dc8e763b76 | ||
|
|
2a52a0c79f | ||
|
|
08e1f499e1 | ||
|
|
63196e7b99 | ||
|
|
7cae35ce8d | ||
|
|
369b3d6207 | ||
|
|
3762d971e9 | ||
|
|
f3d8a3cc95 | ||
|
|
56efeecbfd | ||
|
|
be8aef987a | ||
|
|
4b9de5cd96 | ||
|
|
59949e21ee | ||
|
|
6fda674529 | ||
|
|
4318207c39 | ||
|
|
c48ad18379 | ||
|
|
d647c60f04 | ||
|
|
f83a9e899c | ||
|
|
b7c0cef8de | ||
|
|
14340afb99 | ||
|
|
b903a12f8d | ||
|
|
c6f7c18441 | ||
|
|
6d334be82e | ||
|
|
195cb99c90 | ||
|
|
2ebd311e0e | ||
|
|
1da1f17ea3 | ||
|
|
bc36513952 | ||
|
|
b04ab898d7 | ||
|
|
778dd764f6 | ||
|
|
64f8922741 | ||
|
|
c8ed9ac72d | ||
|
|
6cfd26da32 | ||
|
|
b56cf3faa4 | ||
|
|
f7526df008 | ||
|
|
bbeba7f50f | ||
|
|
7e5fa3eacd | ||
|
|
89826bd721 | ||
|
|
fb819b6142 | ||
|
|
2e131403d4 | ||
|
|
83db594fde | ||
|
|
83b002d585 | ||
|
|
7207259cdf | ||
|
|
7c647ae02d | ||
|
|
0630ea536e | ||
|
|
f2f73fc894 | ||
|
|
7887f55dd0 | ||
|
|
5ba759bb41 | ||
|
|
f325be0364 | ||
|
|
f3c8647ff2 | ||
|
|
ceb6b8f8e7 | ||
|
|
94d953d449 | ||
|
|
7f74fd75c9 | ||
|
|
4151b37f9f | ||
|
|
1b45286aaf | ||
|
|
d578fdc0c4 | ||
|
|
2f2747cfc8 | ||
|
|
f254bd85b5 | ||
|
|
a321d12307 | ||
|
|
0e5e3ea00e | ||
|
|
6599fd7f5a | ||
|
|
225288c742 | ||
|
|
9bf77e849f | ||
|
|
44ae6d0dbf | ||
|
|
3684204a03 | ||
|
|
5d17b628cc | ||
|
|
197a5b3b74 | ||
|
|
278b674ea7 | ||
|
|
78e8078d6b | ||
|
|
2f05dbad5d | ||
|
|
0987475e41 | ||
|
|
41e34ba4ec | ||
|
|
dd78c1570f | ||
|
|
5bf90c56de | ||
|
|
35643faf85 | ||
|
|
72c5a05324 | ||
|
|
0de7988b29 | ||
|
|
43d5f0a205 | ||
|
|
d703ff072c | ||
|
|
6f80fa62d2 | ||
|
|
34b914b91f | ||
|
|
c6c8dab5db | ||
|
|
d4bbdebe31 | ||
|
|
2f5c431fa7 | ||
|
|
6d92ce64bd | ||
|
|
6c006bb748 | ||
|
|
d2cb7604fa | ||
|
|
dd061e9dc8 | ||
|
|
63c50d96d7 | ||
|
|
1360a03eb5 | ||
|
|
902c724f39 | ||
|
|
1677e5e0ab | ||
|
|
1989510aac | ||
|
|
ef3cf4bcfa | ||
|
|
645df6c0aa | ||
|
|
dfad34c3dd | ||
|
|
f5abe4e1a2 | ||
|
|
79e447c58d | ||
|
|
bc013e7819 | ||
|
|
12844f2529 | ||
|
|
92a66933ce | ||
|
|
bc01b6d4a9 | ||
|
|
23650bb684 | ||
|
|
140ee8c65d | ||
|
|
7c153d841a | ||
|
|
e06f52642a | ||
|
|
874a909d1c | ||
|
|
bbe702925b | ||
|
|
49262d52cf | ||
|
|
5a468b44a1 | ||
|
|
6678f9c971 | ||
|
|
b5a27968de | ||
|
|
4f1c6855aa | ||
|
|
50c7fcd012 | ||
|
|
de63ba523e | ||
|
|
5e2362ea62 | ||
|
|
db94b01859 | ||
|
|
9bd821baa9 | ||
|
|
b69b535155 | ||
|
|
0bee36eced | ||
|
|
d044a6cbba | ||
|
|
8095010835 | ||
|
|
bd21c3d8c3 | ||
|
|
2bacf6e07d | ||
|
|
5c73d7097f | ||
|
|
f77236d49e | ||
|
|
1152571e49 | ||
|
|
eda41d7132 | ||
|
|
dd58a8565a | ||
|
|
64d20df6f1 | ||
|
|
cd3f5f7e70 | ||
|
|
fca04dd0a0 | ||
|
|
e024b324ed | ||
|
|
f012eaee33 | ||
|
|
794f8f07fa | ||
|
|
0a9aa774cc | ||
|
|
ebbc23f581 | ||
|
|
bf7722aa2e | ||
|
|
33637a8bca | ||
|
|
80962c5df6 | ||
|
|
7fa9621de1 | ||
|
|
d17fed9c85 | ||
|
|
81536e61f7 | ||
|
|
c790781315 | ||
|
|
e7a067b358 | ||
|
|
4156126a71 | ||
|
|
95f456bbb6 | ||
|
|
102cd1a6cf | ||
|
|
395dc5c0cb | ||
|
|
7f0c8f4bbf | ||
|
|
671f27ccde | ||
|
|
c90b53376d | ||
|
|
13ed7b6cdc | ||
|
|
3c71301cbc | ||
|
|
833a871731 | ||
|
|
7b3a4e48c1 | ||
|
|
76112534f6 | ||
|
|
adafe36cd7 | ||
|
|
0123791b8a | ||
|
|
81f150c0ce | ||
|
|
9177ad6a72 | ||
|
|
d360f612e2 | ||
|
|
b0496e2e65 | ||
|
|
c5751386fa | ||
|
|
406647d687 | ||
|
|
5949a0965c | ||
|
|
c015bed8a7 | ||
|
|
504af53f18 | ||
|
|
d822db440b | ||
|
|
181e1009e5 | ||
|
|
db16044949 | ||
|
|
1626a277e0 | ||
|
|
616efffed2 | ||
|
|
c9dd143d59 | ||
|
|
0a4d62491d | ||
|
|
9e417b7f35 | ||
|
|
81e9b27683 | ||
|
|
942ae4af99 | ||
|
|
f7e9119450 | ||
|
|
d71216a908 | ||
|
|
f61ce5563d | ||
|
|
575d91832e | ||
|
|
15fe4575f9 | ||
|
|
9ba9b06ae3 | ||
|
|
cf16344aef | ||
|
|
29ed07c74a | ||
|
|
493e0e2f6b | ||
|
|
d76d99844c | ||
|
|
533f29706e | ||
|
|
88cf45c7c2 | ||
|
|
b100129d80 | ||
|
|
a267d4ed3a | ||
|
|
9d8d5f0fa0 | ||
|
|
aa6b068d92 | ||
|
|
4b230e01d3 | ||
|
|
d140e2109f | ||
|
|
d5703ba70e | ||
|
|
5f12046a49 | ||
|
|
edad55d608 | ||
|
|
fac46de09c | ||
|
|
007de56cd3 | ||
|
|
8efdf6d87b | ||
|
|
69225e0642 | ||
|
|
0aa23e27b3 | ||
|
|
3bf2daddd1 | ||
|
|
71644e6e4e | ||
|
|
6e7f92c046 | ||
|
|
8d4504262b | ||
|
|
20224c835a | ||
|
|
c3873b030f | ||
|
|
cf8a202afa | ||
|
|
c79b1e6492 | ||
|
|
e21e3ecaae | ||
|
|
b84840e12c | ||
|
|
f8a130ae6e | ||
|
|
852de0d587 | ||
|
|
eecfb112e3 | ||
|
|
81a35655e9 | ||
|
|
c45b44cdbc | ||
|
|
ecc48f8fe2 | ||
|
|
e1c4f85e53 | ||
|
|
f8c96b493c | ||
|
|
63904f6d41 | ||
|
|
9083d9a01b | ||
|
|
806cba8110 | ||
|
|
ae24358a77 | ||
|
|
f0dfd5568e | ||
|
|
9d17600124 | ||
|
|
6a72d7894b | ||
|
|
00c33d48f0 | ||
|
|
6573c683f6 | ||
|
|
5412545c6f | ||
|
|
eee06e2be9 | ||
|
|
c3e8097aac | ||
|
|
2d97feef17 | ||
|
|
74c0a40622 | ||
|
|
56741b123b | ||
|
|
e8c6cc45f4 | ||
|
|
d0c999655c | ||
|
|
05594e6507 | ||
|
|
b59663ea91 | ||
|
|
8d2029a19d | ||
|
|
cee2f0a759 | ||
|
|
032f96191e | ||
|
|
e87bfc83bf | ||
|
|
aa0c7d64f4 | ||
|
|
b8a1839fb6 | ||
|
|
629574c6b2 | ||
|
|
1bb7d3cc5c | ||
|
|
6b811c3e7d | ||
|
|
a941eec569 | ||
|
|
5ed79f0f5b | ||
|
|
010ca8eeae | ||
|
|
5840ffc620 | ||
|
|
14754a23f1 | ||
|
|
1a501b5365 | ||
|
|
3bc9bbf074 | ||
|
|
09a52dd260 | ||
|
|
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 | ||
|
|
0f40578ca9 | ||
|
|
676c7c3a5d | ||
|
|
544585afd9 | ||
|
|
87196b1190 | ||
|
|
94d1bbbfba | ||
|
|
cbd0ec6aa7 | ||
|
|
f78eefbb3b | ||
|
|
a8172a9dbe | ||
|
|
75d4fce8ec | ||
|
|
73954fe78e | ||
|
|
14f9378375 | ||
|
|
3afd5fef6e | ||
|
|
b8b6fe24bc | ||
|
|
828e8eae2e | ||
|
|
1b53fb139d | ||
|
|
c5d9f2c127 | ||
|
|
f25b83bc09 | ||
|
|
9a15ca9684 | ||
|
|
557494747d | ||
|
|
5968bc6c9c | ||
|
|
cf7b18e012 | ||
|
|
9ad277c784 | ||
|
|
9f181fb15e | ||
|
|
0c6911aaf0 | ||
|
|
bd16136946 | ||
|
|
9eee3eea1d | ||
|
|
0579395e93 | ||
|
|
988d647521 | ||
|
|
e628b3a6d5 | ||
|
|
c73f13a9b0 | ||
|
|
9a28552af5 | ||
|
|
9938d21499 | ||
|
|
eb78fb71d9 | ||
|
|
e2fdb11a67 | ||
|
|
e2f9439d40 | ||
|
|
30c9c86e22 | ||
|
|
614d92f050 | ||
|
|
b50ec09727 | ||
|
|
01602bafec | ||
|
|
d972ec2dab | ||
|
|
021f7c9481 | ||
|
|
59815f47d8 | ||
|
|
b868318548 | ||
|
|
09ee81bf11 | ||
|
|
31663faa5a | ||
|
|
88a8c21aa4 | ||
|
|
cd8081e610 | ||
|
|
1e0aaed833 | ||
|
|
34eec78ba4 | ||
|
|
11c834c61b | ||
|
|
aefcd6d311 | ||
|
|
dd09fd9026 | ||
|
|
b19d6694ec | ||
|
|
49b1ea4875 | ||
|
|
ea82fb5825 | ||
|
|
e3b32fd791 | ||
|
|
3dfbccaf23 | ||
|
|
836dc10c2b | ||
|
|
946eed3773 | ||
|
|
359507c014 | ||
|
|
518b94b1f4 | ||
|
|
f21033bd7a | ||
|
|
fbc1d4b113 | ||
|
|
dba62d7028 | ||
|
|
3aafc0960c | ||
|
|
a2f03ff468 | ||
|
|
c300da172b | ||
|
|
2f4af7f3d9 | ||
|
|
cb5b1751c0 | ||
|
|
6f5245cbc4 | ||
|
|
9bee9b8ae4 | ||
|
|
7bdef522c1 | ||
|
|
c8edc3844b | ||
|
|
b5a28f68ad | ||
|
|
ae1889e757 | ||
|
|
b458fad567 | ||
|
|
b1b0d98eb2 | ||
|
|
b1c6a3faf1 | ||
|
|
56a281ae3d | ||
|
|
ccafc997fc | ||
|
|
417c16d08b | ||
|
|
dbeefecec6 | ||
|
|
35665ce292 | ||
|
|
fb61812356 | ||
|
|
ed91c4267b | ||
|
|
c9c62b615b | ||
|
|
de20fb7bc1 | ||
|
|
16024f40be | ||
|
|
2856e23a4a | ||
|
|
db47a2a142 | ||
|
|
ac795cdbdc | ||
|
|
9b6038201c | ||
|
|
9486d699c9 | ||
|
|
cdcfabec0b | ||
|
|
f9eedadb9f | ||
|
|
c08e7d4580 | ||
|
|
ea86737835 | ||
|
|
788fc56caf | ||
|
|
966136dab6 | ||
|
|
4454e615b6 | ||
|
|
91748ac5d2 | ||
|
|
2be2a83c62 | ||
|
|
c38c5b2cc5 | ||
|
|
cb8c2d5f10 | ||
|
|
8fdc503f55 | ||
|
|
97d8c35d2a | ||
|
|
4ffbc4e2f6 | ||
|
|
4252b79586 | ||
|
|
ee4554ae95 | ||
|
|
697b139493 | ||
|
|
a4ea023c51 | ||
|
|
bcae97a296 | ||
|
|
565ee92d20 | ||
|
|
ec4bfa6ba9 | ||
|
|
68f0f03d0d | ||
|
|
86cfdd508a | ||
|
|
ed24685aaf | ||
|
|
84d4153b5c | ||
|
|
b3295e136d |
@@ -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
|
||||
468
.env.sample
468
.env.sample
@@ -1,33 +1,473 @@
|
||||
NEXTAUTH_SECRET=very_sensitive_secret
|
||||
DATABASE_URL=postgresql://user:password@localhost:5432/linkwarden
|
||||
NEXTAUTH_URL=http://localhost:3000
|
||||
NEXTAUTH_URL=http://localhost:3000/api/v1/auth
|
||||
NEXTAUTH_SECRET=
|
||||
|
||||
# Manual installation database settings
|
||||
# Example: DATABASE_URL=postgresql://user:password@localhost:5432/linkwarden
|
||||
DATABASE_URL=
|
||||
|
||||
# Docker installation database settings
|
||||
POSTGRES_PASSWORD=
|
||||
|
||||
# Additional Optional Settings
|
||||
|
||||
PAGINATION_TAKE_COUNT=
|
||||
STORAGE_FOLDER=
|
||||
AUTOSCROLL_TIMEOUT=
|
||||
NEXT_PUBLIC_DISABLE_REGISTRATION=
|
||||
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=
|
||||
SPACES_SECRET=
|
||||
SPACES_ENDPOINT=
|
||||
SPACES_BUCKET_NAME=
|
||||
SPACES_REGION=
|
||||
SPACES_FORCE_PATH_STYLE=
|
||||
|
||||
# SMTP Settings
|
||||
NEXT_PUBLIC_EMAIL_PROVIDER=
|
||||
EMAIL_FROM=
|
||||
EMAIL_SERVER=
|
||||
BASE_URL=
|
||||
|
||||
# Stripe settings (You don't need these, it's for the cloud instance payments)
|
||||
NEXT_PUBLIC_STRIPE_IS_ACTIVE=
|
||||
STRIPE_SECRET_KEY=
|
||||
MONTHLY_PRICE_ID=
|
||||
YEARLY_PRICE_ID=
|
||||
NEXT_PUBLIC_TRIAL_PERIOD_DAYS=
|
||||
NEXT_PUBLIC_STRIPE_BILLING_PORTAL_URL=
|
||||
BASE_URL=http://localhost:3000
|
||||
# Proxy settings
|
||||
PROXY=
|
||||
PROXY_USERNAME=
|
||||
PROXY_PASSWORD=
|
||||
PROXY_BYPASS=
|
||||
|
||||
# Docker postgres settings
|
||||
POSTGRES_PASSWORD=
|
||||
# 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
|
||||
20
.github/ISSUE_TEMPLATE/ask-a-question.md
vendored
Normal file
20
.github/ISSUE_TEMPLATE/ask-a-question.md
vendored
Normal file
@@ -0,0 +1,20 @@
|
||||
---
|
||||
name: Ask a Question
|
||||
about: Ask about a particular topic
|
||||
title: ''
|
||||
labels: question
|
||||
assignees: ''
|
||||
|
||||
---
|
||||
|
||||
**Is your question related to a problem or code? Please describe.**
|
||||
A clear and concise description of what the problem or code is. Ex. I'm confused about how [...] works, or I'm facing an issue when [...]
|
||||
|
||||
**Describe what you've tried to solve this question**
|
||||
Explain what steps or research you've already taken to try and understand or solve this.
|
||||
|
||||
**Include any code or screenshots (if applicable)**
|
||||
Add any code snippets, error messages, or screenshots that might help others understand your question better.
|
||||
|
||||
**Additional context**
|
||||
Include any additional context or details that might help get a clearer understanding of your question.
|
||||
@@ -1,8 +1,8 @@
|
||||
---
|
||||
name: Bug report
|
||||
name: Bug Report
|
||||
about: Create a report to help us improve
|
||||
title: ''
|
||||
labels: ''
|
||||
labels: bug
|
||||
assignees: ''
|
||||
|
||||
---
|
||||
@@ -1,8 +1,8 @@
|
||||
---
|
||||
name: Feature request
|
||||
name: Feature Request
|
||||
about: Suggest an idea for this project
|
||||
title: ''
|
||||
labels: ''
|
||||
labels: enhancement
|
||||
assignees: ''
|
||||
|
||||
---
|
||||
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"]
|
||||
|
||||
155
README.md
155
README.md
@@ -1,94 +1,138 @@
|
||||
<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://trendshift.io/repositories/4006" target="_blank"><img src="https://trendshift.io/api/badge/repositories/4006" alt="linkwarden%2Flinkwarden | Trendshift" style="width: 250px; height: 55px;" width="250" height="55"/></a>
|
||||
|
||||
<a href="https://discord.com/invite/CtuYV47nuJ"><img src="https://img.shields.io/discord/1117993124669702164?logo=discord&style=flat" alt="Discord"></a>
|
||||
<a href="https://twitter.com/LinkwardenHQ"><img src="https://img.shields.io/twitter/follow/linkwarden" alt="Twitter"></a> <a href="https://news.ycombinator.com/item?id=43856801"><img src="https://img.shields.io/badge/Hacker%20News-301-%23FF6600"></img></a>
|
||||
|
||||
<a href="https://github.com/linkwarden/linkwarden/releases"><img alt="GitHub release" src="https://img.shields.io/github/v/release/linkwarden/linkwarden"></a>
|
||||
<a href="https://crowdin.com/project/linkwarden">
|
||||
<img src="https://badges.crowdin.net/linkwarden/localized.svg" alt="Crowdin" /></a>
|
||||
<a href="https://opencollective.com/linkwarden"><img src="https://img.shields.io/opencollective/all/linkwarden" alt="Open Collective"></a>
|
||||
|
||||
</div>
|
||||
|
||||
<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) | [Screenshots](https://github.com/linkwarden/linkwarden#screenshots) | [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.
|
||||
|
||||
<img src="./assets/showcase_image.png" />
|
||||
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.
|
||||
|
||||
> **Note**
|
||||
> 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.
|
||||
Linkwarden is also designed with collaboration in mind, enabling you to share links with the public and/or collaborate seamlessly with multiple users.
|
||||
|
||||
<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 highly recommend that you don't use the old version because it is no longer maintained and has far fewer features. However, if you still want to check it out, we've forked the old version from the current repository into [this repo](https://github.com/linkwarden/linkwarden-old).
|
||||
|
||||
</details>
|
||||
> [!TIP]
|
||||
> Our official [Cloud](https://linkwarden.app/#pricing) offering provides the simplest way to begin using Linkwarden and it's the preferred choice for many due to its time-saving benefits. <br> Your subscription supports our hosting infrastructure and ongoing development. <br> Alternatively, if you prefer self-hosting Linkwarden, you can do so by following our [Installation documentation](https://docs.linkwarden.app/self-hosting/installation).
|
||||
|
||||
## Features
|
||||
|
||||
- 📸 Auto capture a screenshot and a PDF of each link.
|
||||
- 🏛️ Send your webpage to Wayback Machine archive.org for a snapshot.
|
||||
- 📂 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 with the world.
|
||||
- 📌 Pin your favorite links to dashboard.
|
||||
- 🔍 Search, filter and sort by link details.
|
||||
- 📱 Responsive design and supports most browsers.
|
||||
- 🌓 Dark/Light mode support.
|
||||
- 🧩 Browser extension, managed by the community [check it out!](https://github.com/linkwarden/browser-extension)
|
||||
- ⬇️ Import your bookmarks from other browsers.
|
||||
- 📸 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!
|
||||
|
||||
## Screenshots
|
||||
|
||||
<div align="center">
|
||||
<img src="./assets/collections.png" height="150" />
|
||||
|
||||
<img src="./assets/collaborators.png" height="150" />
|
||||
|
||||
<img src="./assets/link_details.png" height="150" />
|
||||
</div>
|
||||
|
||||
## 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!
|
||||
|
||||
@@ -96,7 +140,12 @@ Here are the other ways to support/cheer this project:
|
||||
|
||||
- Starring this repository.
|
||||
- Joining us on [Discord](https://discord.com/invite/CtuYV47nuJ).
|
||||
- Following @daniel31x13 on [Mastodon](https://mastodon.social/@daniel31x13), [Twitter](https://twitter.com/daniel31x13) and [GitHub](https://github.com/daniel31x13).
|
||||
- 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
|
||||
108
apps/mobile/app.json
Normal file
108
apps/mobile/app.json
Normal file
@@ -0,0 +1,108 @@
|
||||
{
|
||||
"expo": {
|
||||
"name": "Linkwarden",
|
||||
"slug": "linkwarden",
|
||||
"version": "1.1.1",
|
||||
"orientation": "portrait",
|
||||
"icon": "./assets/images/icon.png",
|
||||
"scheme": "linkwarden",
|
||||
"userInterfaceStyle": "automatic",
|
||||
"newArchEnabled": true,
|
||||
"ios": {
|
||||
"supportsTablet": true,
|
||||
"bundleIdentifier": "app.linkwarden",
|
||||
"entitlements": {
|
||||
"com.apple.security.application-groups": ["group.app.linkwarden"]
|
||||
},
|
||||
"infoPlist": {
|
||||
"ITSAppUsesNonExemptEncryption": false,
|
||||
"NSAppTransportSecurity": {
|
||||
"NSAllowsArbitraryLoads": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"android": {
|
||||
"adaptiveIcon": {
|
||||
"foregroundImage": "./assets/images/maskable_logo.jpeg",
|
||||
"backgroundColor": "#ffffff"
|
||||
},
|
||||
"package": "app.linkwarden"
|
||||
},
|
||||
"web": {
|
||||
"bundler": "metro",
|
||||
"output": "static",
|
||||
"favicon": "./assets/images/favicon.png"
|
||||
},
|
||||
"plugins": [
|
||||
"expo-router",
|
||||
[
|
||||
"expo-splash-screen",
|
||||
{
|
||||
"image": "./assets/images/splash-icon.png",
|
||||
"imageWidth": 200,
|
||||
"resizeMode": "contain",
|
||||
"backgroundColor": "#FFFFFF",
|
||||
"dark": {
|
||||
"image": "./assets/images/splash-icon.png",
|
||||
"resizeMode": "contain",
|
||||
"backgroundColor": "#171717"
|
||||
}
|
||||
}
|
||||
],
|
||||
"expo-secure-store",
|
||||
[
|
||||
"expo-share-intent",
|
||||
{
|
||||
"iosAppGroupIdentifier": "group.app.linkwarden",
|
||||
"iosActivationRules": "SUBQUERY (extensionItems, $extensionItem, SUBQUERY ($extensionItem.attachments, $attachment, (ANY $attachment.registeredTypeIdentifiers UTI-CONFORMS-TO \"public.url\" OR ANY $attachment.registeredTypeIdentifiers UTI-CONFORMS-TO \"public.text\" OR ANY $attachment.registeredTypeIdentifiers UTI-CONFORMS-TO \"public.plain-text\")).@count > 0).@count > 0",
|
||||
"androidIntentFilters": ["text/*"]
|
||||
}
|
||||
],
|
||||
[
|
||||
"expo-build-properties",
|
||||
{
|
||||
"android": {
|
||||
"enableProguardInReleaseBuilds": true,
|
||||
"extraProguardRules": "-keep public class com.horcrux.svg.** {*;}",
|
||||
"allowBackup": false,
|
||||
"compileSdkVersion": 35,
|
||||
"targetSdkVersion": 35,
|
||||
"buildToolsVersion": "35.0.0",
|
||||
"usesCleartextTraffic": true
|
||||
}
|
||||
}
|
||||
],
|
||||
[
|
||||
"react-native-edge-to-edge",
|
||||
{
|
||||
"android": {
|
||||
"parentTheme": "Default",
|
||||
"enforceNavigationBarContrast": false
|
||||
}
|
||||
}
|
||||
],
|
||||
"./plugins/with-daynight-transparent-nav"
|
||||
],
|
||||
"experiments": {
|
||||
"typedRoutes": true
|
||||
},
|
||||
"androidStatusBar": {
|
||||
"backgroundColor": "#ffffff",
|
||||
"barStyle": "dark-content",
|
||||
"translucent": false
|
||||
},
|
||||
"androidNavigationBar": {
|
||||
"backgroundColor": "#ffffff",
|
||||
"barStyle": "dark-content"
|
||||
},
|
||||
"extra": {
|
||||
"router": {
|
||||
"origin": false
|
||||
},
|
||||
"eas": {
|
||||
"projectId": "34f82639-7a25-4ebe-81c8-2db521b612cf"
|
||||
}
|
||||
},
|
||||
"owner": "linkwarden"
|
||||
}
|
||||
}
|
||||
81
apps/mobile/app/(tabs)/_layout.tsx
Normal file
81
apps/mobile/app/(tabs)/_layout.tsx
Normal file
@@ -0,0 +1,81 @@
|
||||
import { Tabs } from "expo-router";
|
||||
import React from "react";
|
||||
import { Platform } from "react-native";
|
||||
import HapticTab from "@/components/HapticTab";
|
||||
import TabBarBackground from "@/components/ui/TabBarBackground";
|
||||
import { useColorScheme } from "nativewind";
|
||||
import { rawTheme, ThemeName } from "@/lib/colors";
|
||||
import { Folder, Hash, House, Link, Settings } from "lucide-react-native";
|
||||
|
||||
export default function TabLayout() {
|
||||
const { colorScheme } = useColorScheme();
|
||||
|
||||
return (
|
||||
<Tabs
|
||||
screenOptions={{
|
||||
tabBarBackground: TabBarBackground,
|
||||
tabBarActiveTintColor: rawTheme[colorScheme as ThemeName].primary,
|
||||
tabBarInactiveTintColor: rawTheme[colorScheme as ThemeName].neutral,
|
||||
tabBarButton: HapticTab,
|
||||
tabBarStyle: Platform.select({
|
||||
ios: {
|
||||
position: "absolute",
|
||||
borderTopWidth: 0,
|
||||
elevation: 0,
|
||||
backgroundColor: rawTheme[colorScheme as ThemeName]["base-200"],
|
||||
paddingLeft: 5,
|
||||
paddingRight: 5,
|
||||
},
|
||||
default: {
|
||||
borderTopWidth: 0,
|
||||
backgroundColor: rawTheme[colorScheme as ThemeName]["base-200"],
|
||||
elevation: 0,
|
||||
paddingLeft: 5,
|
||||
paddingRight: 5,
|
||||
},
|
||||
}),
|
||||
}}
|
||||
>
|
||||
<Tabs.Screen
|
||||
name="dashboard"
|
||||
options={{
|
||||
title: "Dashboard",
|
||||
headerShown: false,
|
||||
tabBarIcon: ({ color }) => <House size={24} color={color} />,
|
||||
}}
|
||||
/>
|
||||
<Tabs.Screen
|
||||
name="links"
|
||||
options={{
|
||||
title: "Links",
|
||||
headerShown: false,
|
||||
tabBarIcon: ({ color }) => <Link size={24} color={color} />,
|
||||
}}
|
||||
/>
|
||||
<Tabs.Screen
|
||||
name="collections"
|
||||
options={{
|
||||
title: "Collections",
|
||||
headerShown: false,
|
||||
tabBarIcon: ({ color }) => <Folder size={24} color={color} />,
|
||||
}}
|
||||
/>
|
||||
<Tabs.Screen
|
||||
name="tags"
|
||||
options={{
|
||||
title: "Tags",
|
||||
headerShown: false,
|
||||
tabBarIcon: ({ color }) => <Hash size={24} color={color} />,
|
||||
}}
|
||||
/>
|
||||
<Tabs.Screen
|
||||
name="settings"
|
||||
options={{
|
||||
title: "Settings",
|
||||
headerShown: false,
|
||||
tabBarIcon: ({ color }) => <Settings size={24} color={color} />,
|
||||
}}
|
||||
/>
|
||||
</Tabs>
|
||||
);
|
||||
}
|
||||
62
apps/mobile/app/(tabs)/collections/[id].tsx
Normal file
62
apps/mobile/app/(tabs)/collections/[id].tsx
Normal file
@@ -0,0 +1,62 @@
|
||||
import { useLinks } from "@linkwarden/router/links";
|
||||
import { View, StyleSheet, Platform } from "react-native";
|
||||
import useAuthStore from "@/store/auth";
|
||||
import { useLocalSearchParams, useNavigation } from "expo-router";
|
||||
import React, { useEffect } from "react";
|
||||
import { useCollections } from "@linkwarden/router/collections";
|
||||
import Links from "@/components/Links";
|
||||
|
||||
export default function LinksScreen() {
|
||||
const { auth } = useAuthStore();
|
||||
const { search, id } = useLocalSearchParams<{
|
||||
search?: string;
|
||||
id: string;
|
||||
}>();
|
||||
|
||||
const { links, data } = useLinks(
|
||||
{
|
||||
sort: 0,
|
||||
searchQueryString: decodeURIComponent(search ?? ""),
|
||||
collectionId: Number(id),
|
||||
},
|
||||
auth
|
||||
);
|
||||
|
||||
const collections = useCollections(auth);
|
||||
|
||||
const navigation = useNavigation();
|
||||
|
||||
useEffect(() => {
|
||||
const activeCollection = collections.data?.filter(
|
||||
(e) => e.id === Number(id)
|
||||
)[0];
|
||||
|
||||
if (activeCollection?.name)
|
||||
navigation?.setOptions?.({
|
||||
headerTitle: activeCollection?.name,
|
||||
headerSearchBarOptions: {
|
||||
placeholder: `Search ${activeCollection.name}`,
|
||||
},
|
||||
});
|
||||
}, [navigation]);
|
||||
|
||||
return (
|
||||
<View
|
||||
style={styles.container}
|
||||
className="h-full bg-base-100"
|
||||
collapsable={false}
|
||||
collapsableChildren={false}
|
||||
>
|
||||
<Links links={links} data={data} />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: Platform.select({
|
||||
ios: {
|
||||
paddingBottom: 83,
|
||||
},
|
||||
default: {},
|
||||
}),
|
||||
});
|
||||
44
apps/mobile/app/(tabs)/collections/_layout.tsx
Normal file
44
apps/mobile/app/(tabs)/collections/_layout.tsx
Normal file
@@ -0,0 +1,44 @@
|
||||
import { Stack, useRouter } from "expo-router";
|
||||
import { useColorScheme } from "nativewind";
|
||||
import { rawTheme, ThemeName } from "@/lib/colors";
|
||||
import { Platform } from "react-native";
|
||||
|
||||
export default function Layout() {
|
||||
const router = useRouter();
|
||||
const { colorScheme } = useColorScheme();
|
||||
|
||||
return (
|
||||
<Stack
|
||||
screenOptions={{
|
||||
headerTitle: "Collections",
|
||||
headerLargeTitle: true,
|
||||
headerTransparent: Platform.OS === "ios" ? true : false,
|
||||
headerShadowVisible: false,
|
||||
headerBlurEffect:
|
||||
colorScheme === "dark" ? "systemMaterialDark" : "systemMaterial",
|
||||
headerTintColor: colorScheme === "dark" ? "white" : "black",
|
||||
headerSearchBarOptions: {
|
||||
placeholder: "Search Collections",
|
||||
autoCapitalize: "none",
|
||||
onChangeText: (e) => {
|
||||
router.setParams({
|
||||
search: encodeURIComponent(e.nativeEvent.text),
|
||||
});
|
||||
},
|
||||
headerIconColor: colorScheme === "dark" ? "white" : "black",
|
||||
},
|
||||
headerLargeStyle: {
|
||||
backgroundColor: rawTheme[colorScheme as ThemeName]["base-100"],
|
||||
},
|
||||
headerStyle: {
|
||||
backgroundColor:
|
||||
Platform.OS === "ios"
|
||||
? "transparent"
|
||||
: colorScheme === "dark"
|
||||
? rawTheme["dark"]["base-100"]
|
||||
: "white",
|
||||
},
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
94
apps/mobile/app/(tabs)/collections/index.tsx
Normal file
94
apps/mobile/app/(tabs)/collections/index.tsx
Normal file
@@ -0,0 +1,94 @@
|
||||
import {
|
||||
View,
|
||||
StyleSheet,
|
||||
FlatList,
|
||||
Platform,
|
||||
Text,
|
||||
ActivityIndicator,
|
||||
} from "react-native";
|
||||
import useAuthStore from "@/store/auth";
|
||||
import CollectionListing from "@/components/CollectionListing";
|
||||
import { useLocalSearchParams } from "expo-router";
|
||||
import React, { useEffect, useState } from "react";
|
||||
import Spinner from "@/components/ui/Spinner";
|
||||
import { rawTheme, ThemeName } from "@/lib/colors";
|
||||
import { useColorScheme } from "nativewind";
|
||||
import { useCollections } from "@linkwarden/router/collections";
|
||||
import { CollectionIncludingMembersAndLinkCount } from "@linkwarden/types/global";
|
||||
|
||||
export default function CollectionsScreen() {
|
||||
const { colorScheme } = useColorScheme();
|
||||
const { auth } = useAuthStore();
|
||||
const { search } = useLocalSearchParams<{ search?: string }>();
|
||||
|
||||
const collections = useCollections(auth);
|
||||
|
||||
const [filteredCollections, setFilteredCollections] = useState<
|
||||
CollectionIncludingMembersAndLinkCount[]
|
||||
>([]);
|
||||
|
||||
useEffect(() => {
|
||||
const filter =
|
||||
collections.data?.filter((e) =>
|
||||
e.name.includes(decodeURIComponent(search || ""))
|
||||
) || [];
|
||||
|
||||
setFilteredCollections(filter);
|
||||
}, [search, collections.data]);
|
||||
|
||||
return (
|
||||
<View
|
||||
style={styles.container}
|
||||
className="h-full bg-base-100"
|
||||
collapsable={false}
|
||||
collapsableChildren={false}
|
||||
>
|
||||
{collections.isLoading ? (
|
||||
<View className="flex justify-center h-screen items-center">
|
||||
<ActivityIndicator size="large" />
|
||||
<Text className="text-base mt-2.5 text-neutral">Loading...</Text>
|
||||
</View>
|
||||
) : (
|
||||
<FlatList
|
||||
contentInsetAdjustmentBehavior="automatic"
|
||||
ListHeaderComponent={() => <></>}
|
||||
data={filteredCollections}
|
||||
refreshControl={
|
||||
<Spinner
|
||||
refreshing={collections.isRefetching}
|
||||
onRefresh={() => collections.refetch()}
|
||||
progressBackgroundColor={
|
||||
rawTheme[colorScheme as ThemeName]["base-200"]
|
||||
}
|
||||
colors={[rawTheme[colorScheme as ThemeName]["base-content"]]}
|
||||
/>
|
||||
}
|
||||
refreshing={collections.isRefetching}
|
||||
initialNumToRender={4}
|
||||
keyExtractor={(item) => item.id?.toString() || ""}
|
||||
renderItem={({ item }) => <CollectionListing collection={item} />}
|
||||
onEndReachedThreshold={0.5}
|
||||
ItemSeparatorComponent={() => (
|
||||
<View className="bg-neutral-content h-px" />
|
||||
)}
|
||||
ListEmptyComponent={
|
||||
<View className="flex justify-center py-10 items-center">
|
||||
<Text className="text-center text-xl text-neutral">
|
||||
Nothing found...
|
||||
</Text>
|
||||
</View>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: Platform.select({
|
||||
ios: {
|
||||
paddingBottom: 83,
|
||||
},
|
||||
default: {},
|
||||
}),
|
||||
});
|
||||
72
apps/mobile/app/(tabs)/dashboard/[section].tsx
Normal file
72
apps/mobile/app/(tabs)/dashboard/[section].tsx
Normal file
@@ -0,0 +1,72 @@
|
||||
import { useLinks } from "@linkwarden/router/links";
|
||||
import { View, StyleSheet, Platform } from "react-native";
|
||||
import useAuthStore from "@/store/auth";
|
||||
import { useLocalSearchParams, useNavigation } from "expo-router";
|
||||
import React, { useEffect, useMemo } from "react";
|
||||
import { useCollections } from "@linkwarden/router/collections";
|
||||
import Links from "@/components/Links";
|
||||
|
||||
export default function LinksScreen() {
|
||||
const { auth } = useAuthStore();
|
||||
const { search, section, collectionId } = useLocalSearchParams<{
|
||||
search?: string;
|
||||
section?: "pinned-links" | "recent-links" | "collection";
|
||||
collectionId?: string;
|
||||
}>();
|
||||
|
||||
const navigation = useNavigation();
|
||||
const collections = useCollections(auth);
|
||||
|
||||
const title = useMemo(() => {
|
||||
if (section === "pinned-links") return "Pinned Links";
|
||||
if (section === "recent-links") return "Recent Links";
|
||||
|
||||
if (section === "collection") {
|
||||
return (
|
||||
collections.data?.find((c) => c.id?.toString() === collectionId)
|
||||
?.name || "Collection"
|
||||
);
|
||||
}
|
||||
|
||||
return "Links";
|
||||
}, [section, collections.data, collectionId]);
|
||||
|
||||
useEffect(() => {
|
||||
navigation.setOptions({
|
||||
headerTitle: title,
|
||||
headerSearchBarOptions: {
|
||||
placeholder: `Search ${title}`,
|
||||
},
|
||||
});
|
||||
}, [title, navigation]);
|
||||
|
||||
const { links, data } = useLinks(
|
||||
{
|
||||
sort: 0,
|
||||
searchQueryString: decodeURIComponent(search ?? ""),
|
||||
collectionId: Number(collectionId),
|
||||
pinnedOnly: section === "pinned-links",
|
||||
},
|
||||
auth
|
||||
);
|
||||
|
||||
return (
|
||||
<View
|
||||
style={styles.container}
|
||||
className="h-full bg-base-100"
|
||||
collapsable={false}
|
||||
collapsableChildren={false}
|
||||
>
|
||||
<Links links={links} data={data} />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: Platform.select({
|
||||
ios: {
|
||||
paddingBottom: 83,
|
||||
},
|
||||
default: {},
|
||||
}),
|
||||
});
|
||||
89
apps/mobile/app/(tabs)/dashboard/_layout.tsx
Normal file
89
apps/mobile/app/(tabs)/dashboard/_layout.tsx
Normal file
@@ -0,0 +1,89 @@
|
||||
import { rawTheme, ThemeName } from "@/lib/colors";
|
||||
import { Stack, useRouter } from "expo-router";
|
||||
import { Plus } from "lucide-react-native";
|
||||
import { useColorScheme } from "nativewind";
|
||||
import { Platform, TouchableOpacity } from "react-native";
|
||||
import { SheetManager } from "react-native-actions-sheet";
|
||||
import * as DropdownMenu from "zeego/dropdown-menu";
|
||||
|
||||
export default function Layout() {
|
||||
const router = useRouter();
|
||||
const { colorScheme } = useColorScheme();
|
||||
|
||||
return (
|
||||
<Stack
|
||||
screenOptions={{
|
||||
headerLargeTitle: true,
|
||||
headerTransparent: Platform.OS === "ios" ? true : false,
|
||||
headerShadowVisible: false,
|
||||
headerBlurEffect:
|
||||
colorScheme === "dark" ? "systemMaterialDark" : "systemMaterial",
|
||||
headerTintColor: colorScheme === "dark" ? "white" : "black",
|
||||
headerLargeStyle: {
|
||||
backgroundColor: rawTheme[colorScheme as ThemeName]["base-100"],
|
||||
},
|
||||
headerStyle: {
|
||||
backgroundColor:
|
||||
Platform.OS === "ios"
|
||||
? "transparent"
|
||||
: colorScheme === "dark"
|
||||
? rawTheme["dark"]["base-100"]
|
||||
: "white",
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Stack.Screen
|
||||
name="index"
|
||||
options={{
|
||||
headerTitle: "Dashboard",
|
||||
headerRight: () => (
|
||||
<DropdownMenu.Root>
|
||||
<DropdownMenu.Trigger>
|
||||
<TouchableOpacity>
|
||||
<Plus
|
||||
size={21}
|
||||
color={rawTheme[colorScheme as ThemeName].primary}
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
</DropdownMenu.Trigger>
|
||||
|
||||
<DropdownMenu.Content>
|
||||
<DropdownMenu.Item
|
||||
key="new-link"
|
||||
onSelect={() => SheetManager.show("add-link-sheet")}
|
||||
>
|
||||
<DropdownMenu.ItemTitle>New Link</DropdownMenu.ItemTitle>
|
||||
</DropdownMenu.Item>
|
||||
<DropdownMenu.Item
|
||||
key="new-collection"
|
||||
onSelect={() => SheetManager.show("new-collection-sheet")}
|
||||
>
|
||||
<DropdownMenu.ItemTitle>
|
||||
New Collection
|
||||
</DropdownMenu.ItemTitle>
|
||||
</DropdownMenu.Item>
|
||||
</DropdownMenu.Content>
|
||||
</DropdownMenu.Root>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="[section]"
|
||||
options={{
|
||||
headerTitle: "Links",
|
||||
headerBackTitle: "Back",
|
||||
headerSearchBarOptions: {
|
||||
placeholder: "Search",
|
||||
autoCapitalize: "none",
|
||||
headerIconColor: colorScheme === "dark" ? "white" : "black",
|
||||
onChangeText: (e) => {
|
||||
router.setParams({
|
||||
search: encodeURIComponent(e.nativeEvent.text),
|
||||
});
|
||||
},
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
147
apps/mobile/app/(tabs)/dashboard/index.tsx
Normal file
147
apps/mobile/app/(tabs)/dashboard/index.tsx
Normal file
@@ -0,0 +1,147 @@
|
||||
import {
|
||||
ActivityIndicator,
|
||||
Platform,
|
||||
ScrollView,
|
||||
StyleSheet,
|
||||
Text,
|
||||
View,
|
||||
} from "react-native";
|
||||
import React, { useEffect, useMemo, useState } from "react";
|
||||
import { useDashboardData } from "@linkwarden/router/dashboardData";
|
||||
import useAuthStore from "@/store/auth";
|
||||
import { DashboardSection as DashboardSectionType } from "@linkwarden/prisma/client";
|
||||
import { useUser } from "@linkwarden/router/user";
|
||||
import { useCollections } from "@linkwarden/router/collections";
|
||||
import { useTags } from "@linkwarden/router/tags";
|
||||
import { rawTheme, ThemeName } from "@/lib/colors";
|
||||
import { useColorScheme } from "nativewind";
|
||||
import Spinner from "@/components/ui/Spinner";
|
||||
import DashboardSection from "@/components/DashboardSection";
|
||||
|
||||
export default function DashboardScreen() {
|
||||
const { auth } = useAuthStore();
|
||||
const {
|
||||
data: { links = [], numberOfPinnedLinks, collectionLinks = {} } = {
|
||||
links: [],
|
||||
},
|
||||
...dashboardData
|
||||
} = useDashboardData(auth);
|
||||
const { data: user, ...userData } = useUser(auth);
|
||||
const { data: collections = [], ...collectionsData } = useCollections(auth);
|
||||
const { data: tags = [], ...tagsData } = useTags(auth);
|
||||
|
||||
const { colorScheme } = useColorScheme();
|
||||
|
||||
const [dashboardSections, setDashboardSections] = useState<
|
||||
DashboardSectionType[]
|
||||
>(user?.dashboardSections || []);
|
||||
|
||||
const [numberOfLinks, setNumberOfLinks] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
setDashboardSections(user?.dashboardSections || []);
|
||||
}, [user?.dashboardSections]);
|
||||
|
||||
useEffect(() => {
|
||||
setNumberOfLinks(
|
||||
collections?.reduce?.(
|
||||
(accumulator, collection) =>
|
||||
accumulator + (collection._count as any).links,
|
||||
0
|
||||
)
|
||||
);
|
||||
}, [collections]);
|
||||
|
||||
const orderedSections = useMemo(() => {
|
||||
return [...dashboardSections].sort((a, b) => {
|
||||
const orderA = a.order ?? Number.MAX_SAFE_INTEGER;
|
||||
const orderB = b.order ?? Number.MAX_SAFE_INTEGER;
|
||||
return orderA - orderB;
|
||||
});
|
||||
}, [dashboardSections]);
|
||||
|
||||
const [pullRefreshing, setPullRefreshing] = useState(false);
|
||||
|
||||
const onRefresh = async () => {
|
||||
setPullRefreshing(true);
|
||||
try {
|
||||
await Promise.all([
|
||||
dashboardData.refetch(),
|
||||
userData.refetch(),
|
||||
collectionsData.refetch(),
|
||||
tagsData.refetch(),
|
||||
]);
|
||||
} finally {
|
||||
setPullRefreshing(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (orderedSections.length === 0 && dashboardData.isLoading)
|
||||
return (
|
||||
<View className="flex justify-center h-screen items-center bg-base-100">
|
||||
<ActivityIndicator size="large" />
|
||||
<Text className="text-base mt-2.5 text-neutral">Loading...</Text>
|
||||
</View>
|
||||
);
|
||||
|
||||
return (
|
||||
<ScrollView
|
||||
refreshControl={
|
||||
<Spinner
|
||||
refreshing={pullRefreshing}
|
||||
onRefresh={onRefresh}
|
||||
progressBackgroundColor={
|
||||
rawTheme[colorScheme as ThemeName]["base-200"]
|
||||
}
|
||||
colors={[rawTheme[colorScheme as ThemeName]["base-content"]]}
|
||||
/>
|
||||
}
|
||||
contentContainerStyle={styles.container}
|
||||
className="bg-base-100"
|
||||
contentInsetAdjustmentBehavior="automatic"
|
||||
>
|
||||
{orderedSections.map((sectionData, i) => {
|
||||
if (!collections || !collections[0]) return null;
|
||||
|
||||
const collection = collections.find(
|
||||
(c) => c.id === sectionData.collectionId
|
||||
);
|
||||
|
||||
return (
|
||||
<DashboardSection
|
||||
key={sectionData.id}
|
||||
sectionData={sectionData}
|
||||
collection={collection}
|
||||
collectionLinks={
|
||||
sectionData.collectionId
|
||||
? collectionLinks[sectionData.collectionId]
|
||||
: []
|
||||
}
|
||||
links={links}
|
||||
tagsLength={tags.length}
|
||||
numberOfLinks={numberOfLinks}
|
||||
collectionsLength={collections.length}
|
||||
numberOfPinnedLinks={numberOfPinnedLinks}
|
||||
dashboardData={dashboardData}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</ScrollView>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: Platform.select({
|
||||
ios: {
|
||||
paddingBottom: 49,
|
||||
flexDirection: "column",
|
||||
gap: 15,
|
||||
paddingVertical: 20,
|
||||
},
|
||||
default: {
|
||||
flexDirection: "column",
|
||||
gap: 15,
|
||||
paddingVertical: 20,
|
||||
},
|
||||
}),
|
||||
});
|
||||
44
apps/mobile/app/(tabs)/links/_layout.tsx
Normal file
44
apps/mobile/app/(tabs)/links/_layout.tsx
Normal file
@@ -0,0 +1,44 @@
|
||||
import { Stack, useRouter } from "expo-router";
|
||||
import { useColorScheme } from "nativewind";
|
||||
import { rawTheme, ThemeName } from "@/lib/colors";
|
||||
import { Platform } from "react-native";
|
||||
|
||||
export default function Layout() {
|
||||
const router = useRouter();
|
||||
const { colorScheme } = useColorScheme();
|
||||
|
||||
return (
|
||||
<Stack
|
||||
screenOptions={{
|
||||
headerTitle: "Links",
|
||||
headerLargeTitle: true,
|
||||
headerTransparent: Platform.OS === "ios" ? true : false,
|
||||
headerShadowVisible: false,
|
||||
headerBlurEffect:
|
||||
colorScheme === "dark" ? "systemMaterialDark" : "systemMaterial",
|
||||
headerTintColor: colorScheme === "dark" ? "white" : "black",
|
||||
headerSearchBarOptions: {
|
||||
placeholder: "Search Links",
|
||||
autoCapitalize: "none",
|
||||
onChangeText: (e) => {
|
||||
router.setParams({
|
||||
search: encodeURIComponent(e.nativeEvent.text),
|
||||
});
|
||||
},
|
||||
headerIconColor: colorScheme === "dark" ? "white" : "black",
|
||||
},
|
||||
headerLargeStyle: {
|
||||
backgroundColor: rawTheme[colorScheme as ThemeName]["base-100"],
|
||||
},
|
||||
headerStyle: {
|
||||
backgroundColor:
|
||||
Platform.OS === "ios"
|
||||
? "transparent"
|
||||
: colorScheme === "dark"
|
||||
? rawTheme["dark"]["base-100"]
|
||||
: "white",
|
||||
},
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
39
apps/mobile/app/(tabs)/links/index.tsx
Normal file
39
apps/mobile/app/(tabs)/links/index.tsx
Normal file
@@ -0,0 +1,39 @@
|
||||
import { useLinks } from "@linkwarden/router/links";
|
||||
import { View, StyleSheet, Platform } from "react-native";
|
||||
import useAuthStore from "@/store/auth";
|
||||
import { useLocalSearchParams } from "expo-router";
|
||||
import React from "react";
|
||||
import Links from "@/components/Links";
|
||||
|
||||
export default function LinksScreen() {
|
||||
const { auth } = useAuthStore();
|
||||
const { search } = useLocalSearchParams<{ search?: string }>();
|
||||
|
||||
const { links, data } = useLinks(
|
||||
{
|
||||
sort: 0,
|
||||
searchQueryString: decodeURIComponent(search ?? ""),
|
||||
},
|
||||
auth
|
||||
);
|
||||
|
||||
return (
|
||||
<View
|
||||
style={styles.container}
|
||||
className="h-full bg-base-100"
|
||||
collapsable={false}
|
||||
collapsableChildren={false}
|
||||
>
|
||||
<Links links={links} data={data} />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: Platform.select({
|
||||
ios: {
|
||||
paddingBottom: 83,
|
||||
},
|
||||
default: {},
|
||||
}),
|
||||
});
|
||||
43
apps/mobile/app/(tabs)/settings/_layout.tsx
Normal file
43
apps/mobile/app/(tabs)/settings/_layout.tsx
Normal file
@@ -0,0 +1,43 @@
|
||||
import { Stack } from "expo-router";
|
||||
import { useColorScheme } from "nativewind";
|
||||
import { rawTheme, ThemeName } from "@/lib/colors";
|
||||
import { Platform } from "react-native";
|
||||
|
||||
export default function Layout() {
|
||||
const { colorScheme } = useColorScheme();
|
||||
|
||||
return (
|
||||
<Stack
|
||||
screenOptions={{
|
||||
headerTitle: "Settings",
|
||||
headerLargeTitle: true,
|
||||
headerTransparent: Platform.OS === "ios" ? true : false,
|
||||
headerShadowVisible: false,
|
||||
headerBlurEffect:
|
||||
colorScheme === "dark" ? "systemMaterialDark" : "systemMaterial",
|
||||
headerTintColor: colorScheme === "dark" ? "white" : "black",
|
||||
headerLargeStyle: {
|
||||
backgroundColor: rawTheme[colorScheme as ThemeName]["base-100"],
|
||||
},
|
||||
headerBackTitle: "Back",
|
||||
headerStyle: {
|
||||
backgroundColor:
|
||||
Platform.OS === "ios"
|
||||
? "transparent"
|
||||
: colorScheme === "dark"
|
||||
? rawTheme["dark"]["base-100"]
|
||||
: "white",
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Stack.Screen name="index" />
|
||||
<Stack.Screen
|
||||
name="preferredCollection"
|
||||
options={{
|
||||
headerTitle: "Preferred Collection",
|
||||
headerLargeTitle: false,
|
||||
}}
|
||||
/>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
273
apps/mobile/app/(tabs)/settings/index.tsx
Normal file
273
apps/mobile/app/(tabs)/settings/index.tsx
Normal file
@@ -0,0 +1,273 @@
|
||||
import useAuthStore from "@/store/auth";
|
||||
import { useUser } from "@linkwarden/router/user";
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
StyleSheet,
|
||||
ScrollView,
|
||||
TouchableOpacity,
|
||||
Platform,
|
||||
Alert,
|
||||
} from "react-native";
|
||||
import { nativeApplicationVersion } from "expo-application";
|
||||
import { useColorScheme } from "nativewind";
|
||||
import { rawTheme, ThemeName } from "@/lib/colors";
|
||||
import { useEffect, useState } from "react";
|
||||
import {
|
||||
AppWindowMac,
|
||||
Check,
|
||||
ChevronRight,
|
||||
ExternalLink,
|
||||
Folder,
|
||||
LogOut,
|
||||
Mail,
|
||||
Moon,
|
||||
Smartphone,
|
||||
Sun,
|
||||
} from "lucide-react-native";
|
||||
import useDataStore from "@/store/data";
|
||||
import * as Clipboard from "expo-clipboard";
|
||||
import { useRouter } from "expo-router";
|
||||
|
||||
export default function SettingsScreen() {
|
||||
const { signOut, auth } = useAuthStore();
|
||||
const { data: user } = useUser(auth);
|
||||
const { colorScheme, setColorScheme } = useColorScheme();
|
||||
const { data, updateData } = useDataStore();
|
||||
const [override, setOverride] = useState<"light" | "dark" | "system">(
|
||||
data.theme || "system"
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
setColorScheme(override);
|
||||
updateData({ theme: override });
|
||||
}, [override]);
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
return (
|
||||
<View
|
||||
style={styles.container}
|
||||
collapsable={false}
|
||||
collapsableChildren={false}
|
||||
className="bg-base-100"
|
||||
>
|
||||
<ScrollView
|
||||
contentContainerStyle={{
|
||||
padding: 20,
|
||||
}}
|
||||
contentContainerClassName="flex-col gap-6"
|
||||
contentInsetAdjustmentBehavior="automatic"
|
||||
>
|
||||
<View className="bg-base-200 rounded-xl px-4 py-3">
|
||||
<Text className="font-semibold text-xl text-base-content">
|
||||
Your account
|
||||
</Text>
|
||||
<Text className="text-neutral mt-2 mb-3">
|
||||
{user?.email || "@" + user?.username}
|
||||
</Text>
|
||||
<View className="h-px bg-neutral-content -mr-4" />
|
||||
<TouchableOpacity
|
||||
className="flex-row items-center mt-3"
|
||||
onPress={() =>
|
||||
Alert.alert("Sign Out", "Are you sure you want to sign out?", [
|
||||
{
|
||||
text: "Cancel",
|
||||
style: "cancel",
|
||||
},
|
||||
{
|
||||
text: "Sign Out",
|
||||
style: "destructive",
|
||||
onPress: () => {
|
||||
signOut();
|
||||
},
|
||||
},
|
||||
])
|
||||
}
|
||||
>
|
||||
<Text className="flex-1 text-base text-red-500">Sign Out</Text>
|
||||
<LogOut size={18} color="red" />
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
<View>
|
||||
<Text className="mb-4 mx-4 text-neutral">Theme</Text>
|
||||
<View className="bg-base-200 rounded-xl flex-col">
|
||||
<TouchableOpacity
|
||||
className="flex-row gap-2 items-center justify-between py-3 px-4"
|
||||
onPress={() => setOverride("system")}
|
||||
>
|
||||
<View className="flex-row items-center gap-2">
|
||||
<Smartphone
|
||||
size={20}
|
||||
color={rawTheme[colorScheme as ThemeName].neutral}
|
||||
/>
|
||||
<Text className="text-neutral">System Defaults</Text>
|
||||
</View>
|
||||
{override === "system" ? (
|
||||
<Check
|
||||
size={20}
|
||||
color={rawTheme[colorScheme as ThemeName].primary}
|
||||
/>
|
||||
) : null}
|
||||
</TouchableOpacity>
|
||||
<View className="h-px bg-neutral-content ml-12" />
|
||||
<TouchableOpacity
|
||||
className="flex-row gap-2 items-center justify-between py-3 px-4"
|
||||
onPress={() => setOverride("light")}
|
||||
>
|
||||
<View className="flex-row items-center gap-2">
|
||||
<Sun size={20} color="orange" />
|
||||
<Text className="text-orange-500 dark:text-orange-400">
|
||||
Light
|
||||
</Text>
|
||||
</View>
|
||||
{override === "light" ? (
|
||||
<Check
|
||||
size={20}
|
||||
color={rawTheme[colorScheme as ThemeName].primary}
|
||||
/>
|
||||
) : null}
|
||||
</TouchableOpacity>
|
||||
<View className="h-px bg-neutral-content ml-12" />
|
||||
<TouchableOpacity
|
||||
className="flex-row gap-2 items-center justify-between py-3 px-4"
|
||||
onPress={() => setOverride("dark")}
|
||||
>
|
||||
<View className="flex-row items-center gap-2">
|
||||
<Moon size={20} color="royalblue" />
|
||||
<Text className="text-blue-600 dark:text-blue-400">Dark</Text>
|
||||
</View>
|
||||
{override === "dark" ? (
|
||||
<Check
|
||||
size={20}
|
||||
color={rawTheme[colorScheme as ThemeName].primary}
|
||||
/>
|
||||
) : null}
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View>
|
||||
<Text className="mb-4 mx-4 text-neutral">Preferred Browser</Text>
|
||||
<View className="bg-base-200 rounded-xl flex-col">
|
||||
<TouchableOpacity
|
||||
className="flex-row gap-2 items-center justify-between py-3 px-4"
|
||||
onPress={() =>
|
||||
updateData({
|
||||
preferredBrowser: "app",
|
||||
})
|
||||
}
|
||||
>
|
||||
<View className="flex-row items-center gap-2">
|
||||
<AppWindowMac
|
||||
size={20}
|
||||
color={rawTheme[colorScheme as ThemeName].neutral}
|
||||
/>
|
||||
<Text className="text-base-content">In app browser</Text>
|
||||
</View>
|
||||
{data.preferredBrowser === "app" ? (
|
||||
<Check
|
||||
size={20}
|
||||
color={rawTheme[colorScheme as ThemeName].primary}
|
||||
/>
|
||||
) : null}
|
||||
</TouchableOpacity>
|
||||
<View className="h-px bg-neutral-content ml-12" />
|
||||
<TouchableOpacity
|
||||
className="flex-row gap-2 items-center justify-between py-3 px-4"
|
||||
onPress={() =>
|
||||
updateData({
|
||||
preferredBrowser: "system",
|
||||
})
|
||||
}
|
||||
>
|
||||
<View className="flex-row items-center gap-2">
|
||||
<ExternalLink
|
||||
size={20}
|
||||
color={rawTheme[colorScheme as ThemeName].neutral}
|
||||
/>
|
||||
<Text className="text-base-content">
|
||||
System default browser
|
||||
</Text>
|
||||
</View>
|
||||
{data.preferredBrowser === "system" ? (
|
||||
<Check
|
||||
size={20}
|
||||
color={rawTheme[colorScheme as ThemeName].primary}
|
||||
/>
|
||||
) : null}
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View>
|
||||
<Text className="mb-4 mx-4 text-neutral">Save Shared Links To</Text>
|
||||
<View className="bg-base-200 rounded-xl flex-col">
|
||||
<TouchableOpacity
|
||||
className="flex-row gap-2 items-center justify-between py-3 px-4"
|
||||
onPress={() => router.navigate("/settings/preferredCollection")}
|
||||
>
|
||||
<View className="flex-row items-center gap-2">
|
||||
<Folder
|
||||
size={20}
|
||||
color={rawTheme[colorScheme as ThemeName].neutral}
|
||||
/>
|
||||
<Text className="text-base-content">Preferred collection</Text>
|
||||
</View>
|
||||
<View className="flex-row items-center gap-2">
|
||||
<Text numberOfLines={1} className="text-neutral max-w-[140px]">
|
||||
{data.preferredCollection?.name || "None"}
|
||||
</Text>
|
||||
<ChevronRight
|
||||
size={20}
|
||||
color={rawTheme[colorScheme as ThemeName].neutral}
|
||||
/>
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View>
|
||||
<Text className="mb-4 mx-4 text-neutral">Contact Us</Text>
|
||||
<View className="bg-base-200 rounded-xl flex-col">
|
||||
<TouchableOpacity
|
||||
className="flex-row gap-2 items-center justify-between py-3 px-4"
|
||||
onPress={async () => {
|
||||
await Clipboard.setStringAsync("support@linkwarden.app");
|
||||
Alert.alert("Copied to clipboard", "support@linkwarden.app");
|
||||
}}
|
||||
>
|
||||
<View className="flex-row items-center gap-2">
|
||||
<Mail
|
||||
size={20}
|
||||
color={rawTheme[colorScheme as ThemeName].neutral}
|
||||
/>
|
||||
<Text className="text-base-content">
|
||||
support@linkwarden.app
|
||||
</Text>
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<Text className="mx-auto text-sm text-neutral">
|
||||
Linkwarden for {Platform.OS === "ios" ? "iOS" : "Android"}{" "}
|
||||
{nativeApplicationVersion}
|
||||
</Text>
|
||||
</ScrollView>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: Platform.select({
|
||||
ios: {
|
||||
flex: 1,
|
||||
paddingBottom: 83,
|
||||
},
|
||||
default: {
|
||||
flex: 1,
|
||||
},
|
||||
}),
|
||||
});
|
||||
99
apps/mobile/app/(tabs)/settings/preferredCollection.tsx
Normal file
99
apps/mobile/app/(tabs)/settings/preferredCollection.tsx
Normal file
@@ -0,0 +1,99 @@
|
||||
import { View, Text, FlatList, TouchableOpacity } from "react-native";
|
||||
import React, { useCallback, useMemo, useState } from "react";
|
||||
import useAuthStore from "@/store/auth";
|
||||
import useDataStore from "@/store/data";
|
||||
import { useCollections } from "@linkwarden/router/collections";
|
||||
import { CollectionIncludingMembersAndLinkCount } from "@linkwarden/types/global";
|
||||
import Input from "@/components/ui/Input";
|
||||
import { Folder, Check } from "lucide-react-native";
|
||||
import { useColorScheme } from "nativewind";
|
||||
import { rawTheme, ThemeName } from "@/lib/colors";
|
||||
|
||||
const PreferredCollectionScreen = () => {
|
||||
const { auth } = useAuthStore();
|
||||
const { data, updateData } = useDataStore();
|
||||
const collections = useCollections(auth);
|
||||
const { colorScheme } = useColorScheme();
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
|
||||
const filteredCollections = useMemo(() => {
|
||||
if (!collections.data) return [];
|
||||
const q = searchQuery.trim().toLowerCase();
|
||||
if (q === "") return collections.data;
|
||||
return collections.data.filter((col) => col.name.toLowerCase().includes(q));
|
||||
}, [collections.data, searchQuery]);
|
||||
|
||||
const renderCollection = useCallback(
|
||||
({
|
||||
item: collection,
|
||||
}: {
|
||||
item: CollectionIncludingMembersAndLinkCount;
|
||||
}) => {
|
||||
const isSelected = data.preferredCollection?.id === collection.id;
|
||||
|
||||
return (
|
||||
<TouchableOpacity
|
||||
className="bg-base-200 rounded-lg px-4 py-3 mb-3 flex-row items-center justify-between"
|
||||
onPress={() => updateData({ preferredCollection: collection })}
|
||||
>
|
||||
<View className="flex-row items-center gap-2 w-[70%]">
|
||||
<Folder
|
||||
size={20}
|
||||
fill={collection.color || "gray"}
|
||||
color={collection.color || "gray"}
|
||||
/>
|
||||
<Text numberOfLines={1} className="text-base-content">
|
||||
{collection.name}
|
||||
</Text>
|
||||
</View>
|
||||
<View className="flex-row items-center gap-2">
|
||||
{isSelected ? (
|
||||
<Check
|
||||
size={16}
|
||||
color={rawTheme[colorScheme as ThemeName].primary}
|
||||
/>
|
||||
) : null}
|
||||
<Text className="text-neutral">
|
||||
{collection._count?.links ?? 0}
|
||||
</Text>
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
},
|
||||
[colorScheme, data.preferredCollection?.id, updateData]
|
||||
);
|
||||
|
||||
return (
|
||||
<View className="flex-1 bg-base-100">
|
||||
<FlatList
|
||||
data={filteredCollections}
|
||||
keyExtractor={(item) => item.id?.toString() || ""}
|
||||
renderItem={renderCollection}
|
||||
contentContainerStyle={{
|
||||
paddingHorizontal: 20,
|
||||
paddingTop: 20,
|
||||
paddingBottom: 20,
|
||||
}}
|
||||
contentInsetAdjustmentBehavior="automatic"
|
||||
ListHeaderComponent={
|
||||
<Input
|
||||
placeholder="Search collections"
|
||||
className="mb-4 bg-base-200 h-10"
|
||||
value={searchQuery}
|
||||
onChangeText={setSearchQuery}
|
||||
/>
|
||||
}
|
||||
ListEmptyComponent={
|
||||
<Text
|
||||
style={{ textAlign: "center", marginTop: 20 }}
|
||||
className="text-neutral"
|
||||
>
|
||||
No collections match “{searchQuery}”
|
||||
</Text>
|
||||
}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
export default PreferredCollectionScreen;
|
||||
60
apps/mobile/app/(tabs)/tags/[id].tsx
Normal file
60
apps/mobile/app/(tabs)/tags/[id].tsx
Normal file
@@ -0,0 +1,60 @@
|
||||
import { useLinks } from "@linkwarden/router/links";
|
||||
import { View, StyleSheet, Platform } from "react-native";
|
||||
import useAuthStore from "@/store/auth";
|
||||
import { useLocalSearchParams, useNavigation } from "expo-router";
|
||||
import React, { useEffect } from "react";
|
||||
import { useTags } from "@linkwarden/router/tags";
|
||||
import Links from "@/components/Links";
|
||||
|
||||
export default function LinksScreen() {
|
||||
const { auth } = useAuthStore();
|
||||
const { search, id } = useLocalSearchParams<{
|
||||
search?: string;
|
||||
id: string;
|
||||
}>();
|
||||
|
||||
const { links, data } = useLinks(
|
||||
{
|
||||
sort: 0,
|
||||
searchQueryString: decodeURIComponent(search ?? ""),
|
||||
tagId: Number(id),
|
||||
},
|
||||
auth
|
||||
);
|
||||
|
||||
const tags = useTags(auth);
|
||||
|
||||
const navigation = useNavigation();
|
||||
|
||||
useEffect(() => {
|
||||
const activeTag = tags.data?.filter((e) => e.id === Number(id))[0];
|
||||
|
||||
if (activeTag?.name)
|
||||
navigation?.setOptions?.({
|
||||
headerTitle: activeTag?.name,
|
||||
headerSearchBarOptions: {
|
||||
placeholder: `Search ${activeTag.name}`,
|
||||
},
|
||||
});
|
||||
}, [navigation]);
|
||||
|
||||
return (
|
||||
<View
|
||||
style={styles.container}
|
||||
className="h-full bg-base-100"
|
||||
collapsable={false}
|
||||
collapsableChildren={false}
|
||||
>
|
||||
<Links links={links} data={data} />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: Platform.select({
|
||||
ios: {
|
||||
paddingBottom: 83,
|
||||
},
|
||||
default: {},
|
||||
}),
|
||||
});
|
||||
44
apps/mobile/app/(tabs)/tags/_layout.tsx
Normal file
44
apps/mobile/app/(tabs)/tags/_layout.tsx
Normal file
@@ -0,0 +1,44 @@
|
||||
import { Stack, useRouter } from "expo-router";
|
||||
import { useColorScheme } from "nativewind";
|
||||
import { rawTheme, ThemeName } from "@/lib/colors";
|
||||
import { Platform } from "react-native";
|
||||
|
||||
export default function Layout() {
|
||||
const router = useRouter();
|
||||
const { colorScheme } = useColorScheme();
|
||||
|
||||
return (
|
||||
<Stack
|
||||
screenOptions={{
|
||||
headerTitle: "Tags",
|
||||
headerLargeTitle: true,
|
||||
headerTransparent: Platform.OS === "ios" ? true : false,
|
||||
headerShadowVisible: false,
|
||||
headerBlurEffect:
|
||||
colorScheme === "dark" ? "systemMaterialDark" : "systemMaterial",
|
||||
headerTintColor: colorScheme === "dark" ? "white" : "black",
|
||||
headerSearchBarOptions: {
|
||||
placeholder: "Search Tags",
|
||||
autoCapitalize: "none",
|
||||
onChangeText: (e) => {
|
||||
router.setParams({
|
||||
search: encodeURIComponent(e.nativeEvent.text),
|
||||
});
|
||||
},
|
||||
headerIconColor: colorScheme === "dark" ? "white" : "black",
|
||||
},
|
||||
headerLargeStyle: {
|
||||
backgroundColor: rawTheme[colorScheme as ThemeName]["base-100"],
|
||||
},
|
||||
headerStyle: {
|
||||
backgroundColor:
|
||||
Platform.OS === "ios"
|
||||
? "transparent"
|
||||
: colorScheme === "dark"
|
||||
? rawTheme["dark"]["base-100"]
|
||||
: "white",
|
||||
},
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
92
apps/mobile/app/(tabs)/tags/index.tsx
Normal file
92
apps/mobile/app/(tabs)/tags/index.tsx
Normal file
@@ -0,0 +1,92 @@
|
||||
import {
|
||||
View,
|
||||
StyleSheet,
|
||||
FlatList,
|
||||
Platform,
|
||||
Text,
|
||||
ActivityIndicator,
|
||||
} from "react-native";
|
||||
import useAuthStore from "@/store/auth";
|
||||
import TagListing from "@/components/TagListing";
|
||||
import { useLocalSearchParams } from "expo-router";
|
||||
import React, { useEffect, useState } from "react";
|
||||
import Spinner from "@/components/ui/Spinner";
|
||||
import { rawTheme, ThemeName } from "@/lib/colors";
|
||||
import { useColorScheme } from "nativewind";
|
||||
import { TagIncludingLinkCount } from "@linkwarden/types/global";
|
||||
import { useTags } from "@linkwarden/router/tags";
|
||||
|
||||
export default function TagsScreen() {
|
||||
const { colorScheme } = useColorScheme();
|
||||
const { auth } = useAuthStore();
|
||||
const { search } = useLocalSearchParams<{ search?: string }>();
|
||||
|
||||
const tags = useTags(auth);
|
||||
|
||||
const [filteredTags, setFilteredTags] = useState<TagIncludingLinkCount[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
const filter =
|
||||
tags.data?.filter((e) =>
|
||||
e.name.includes(decodeURIComponent(search || ""))
|
||||
) || [];
|
||||
|
||||
setFilteredTags(filter);
|
||||
}, [search, tags.data]);
|
||||
|
||||
return (
|
||||
<View
|
||||
style={styles.container}
|
||||
className="h-full bg-base-100"
|
||||
collapsable={false}
|
||||
collapsableChildren={false}
|
||||
>
|
||||
{tags.isLoading ? (
|
||||
<View className="flex justify-center h-screen items-center">
|
||||
<ActivityIndicator size="large" />
|
||||
<Text className="text-base mt-2.5 text-neutral">Loading...</Text>
|
||||
</View>
|
||||
) : (
|
||||
<FlatList
|
||||
contentInsetAdjustmentBehavior="automatic"
|
||||
ListHeaderComponent={() => <></>}
|
||||
data={filteredTags}
|
||||
refreshControl={
|
||||
<Spinner
|
||||
refreshing={tags.isRefetching}
|
||||
onRefresh={() => tags.refetch()}
|
||||
progressBackgroundColor={
|
||||
rawTheme[colorScheme as ThemeName]["base-200"]
|
||||
}
|
||||
colors={[rawTheme[colorScheme as ThemeName]["base-content"]]}
|
||||
/>
|
||||
}
|
||||
refreshing={tags.isRefetching}
|
||||
initialNumToRender={4}
|
||||
keyExtractor={(item) => item.id?.toString() || ""}
|
||||
renderItem={({ item }) => <TagListing tag={item} />}
|
||||
onEndReachedThreshold={0.5}
|
||||
ItemSeparatorComponent={() => (
|
||||
<View className="bg-neutral-content h-px" />
|
||||
)}
|
||||
ListEmptyComponent={
|
||||
<View className="flex justify-center py-10 items-center">
|
||||
<Text className="text-center text-xl text-neutral">
|
||||
Nothing found...
|
||||
</Text>
|
||||
</View>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: Platform.select({
|
||||
ios: {
|
||||
paddingBottom: 83,
|
||||
},
|
||||
default: {},
|
||||
}),
|
||||
});
|
||||
20
apps/mobile/app/+not-found.tsx
Normal file
20
apps/mobile/app/+not-found.tsx
Normal file
@@ -0,0 +1,20 @@
|
||||
import { Link, Stack } from "expo-router";
|
||||
import { View, StyleSheet } from "react-native";
|
||||
|
||||
export default function NotFoundScreen() {
|
||||
return (
|
||||
<>
|
||||
<Stack.Screen options={{ title: "Oops! This screen doesn't exist." }} />
|
||||
<View style={styles.container}>
|
||||
<Link href="/">Go to home screen</Link>
|
||||
</View>
|
||||
</>
|
||||
);
|
||||
}
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
},
|
||||
});
|
||||
322
apps/mobile/app/_layout.tsx
Normal file
322
apps/mobile/app/_layout.tsx
Normal file
@@ -0,0 +1,322 @@
|
||||
import {
|
||||
router,
|
||||
Stack,
|
||||
usePathname,
|
||||
useRootNavigationState,
|
||||
useRouter,
|
||||
} from "expo-router";
|
||||
import { PersistQueryClientProvider } from "@tanstack/react-query-persist-client";
|
||||
import { mmkvPersister } from "@/lib/queryPersister";
|
||||
import { useState, useEffect } from "react";
|
||||
import "../styles/global.css";
|
||||
import { SheetManager, SheetProvider } from "react-native-actions-sheet";
|
||||
import "@/components/ActionSheets/Sheets";
|
||||
import { useColorScheme } from "nativewind";
|
||||
import { lightTheme, darkTheme } from "../lib/theme";
|
||||
import {
|
||||
Alert,
|
||||
Linking,
|
||||
Platform,
|
||||
Share,
|
||||
TouchableOpacity,
|
||||
View,
|
||||
} from "react-native";
|
||||
import { rawTheme, ThemeName } from "@/lib/colors";
|
||||
import { useShareIntent } from "expo-share-intent";
|
||||
import useDataStore from "@/store/data";
|
||||
import useAuthStore from "@/store/auth";
|
||||
import { KeyboardProvider } from "react-native-keyboard-controller";
|
||||
import * as DropdownMenu from "zeego/dropdown-menu";
|
||||
import { Compass, Ellipsis } from "lucide-react-native";
|
||||
import { Chromium } from "@/components/ui/Icons";
|
||||
import useTmpStore from "@/store/tmp";
|
||||
import {
|
||||
LinkIncludingShortenedCollectionAndTags,
|
||||
MobileAuth,
|
||||
} from "@linkwarden/types/global";
|
||||
import { useDeleteLink, useUpdateLink } from "@linkwarden/router/links";
|
||||
import { deleteLinkCache } from "@/lib/cache";
|
||||
import { queryClient } from "@/lib/queryClient";
|
||||
import getOriginalFormat from "@linkwarden/lib/getOriginalFormat";
|
||||
import { StatusBar } from "expo-status-bar";
|
||||
|
||||
export default function RootLayout() {
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const { hasShareIntent, shareIntent, error, resetShareIntent } =
|
||||
useShareIntent();
|
||||
const { updateData, setData, data } = useDataStore();
|
||||
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
|
||||
const { auth, setAuth } = useAuthStore();
|
||||
const rootNavState = useRootNavigationState();
|
||||
|
||||
useEffect(() => {
|
||||
setAuth();
|
||||
setData();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!rootNavState?.key) return;
|
||||
|
||||
if (hasShareIntent && shareIntent.webUrl) {
|
||||
updateData({
|
||||
shareIntent: {
|
||||
hasShareIntent: true,
|
||||
url: shareIntent.webUrl || "",
|
||||
},
|
||||
});
|
||||
|
||||
resetShareIntent();
|
||||
}
|
||||
|
||||
const needsRewrite =
|
||||
((typeof pathname === "string" && pathname.startsWith("/dataUrl=")) ||
|
||||
hasShareIntent) &&
|
||||
pathname !== "/incoming";
|
||||
|
||||
if (needsRewrite) {
|
||||
router.replace("/incoming");
|
||||
}
|
||||
if (hasShareIntent) {
|
||||
resetShareIntent();
|
||||
router.replace("/incoming");
|
||||
}
|
||||
}, [
|
||||
rootNavState?.key,
|
||||
hasShareIntent,
|
||||
pathname,
|
||||
shareIntent?.webUrl,
|
||||
data.shareIntent,
|
||||
]);
|
||||
|
||||
return (
|
||||
<PersistQueryClientProvider
|
||||
client={queryClient}
|
||||
persistOptions={{
|
||||
persister: mmkvPersister,
|
||||
maxAge: Infinity,
|
||||
dehydrateOptions: {
|
||||
shouldDehydrateMutation: () => true,
|
||||
shouldDehydrateQuery: () => true,
|
||||
},
|
||||
}}
|
||||
onSuccess={() => {
|
||||
setIsLoading(false);
|
||||
queryClient.invalidateQueries();
|
||||
}}
|
||||
>
|
||||
<RootComponent isLoading={isLoading} auth={auth} />
|
||||
</PersistQueryClientProvider>
|
||||
);
|
||||
}
|
||||
|
||||
const RootComponent = ({
|
||||
isLoading,
|
||||
auth,
|
||||
}: {
|
||||
isLoading: boolean;
|
||||
auth: MobileAuth;
|
||||
}) => {
|
||||
const { colorScheme } = useColorScheme();
|
||||
const updateLink = useUpdateLink({ auth, Alert });
|
||||
const deleteLink = useDeleteLink({ auth, Alert });
|
||||
|
||||
const { tmp } = useTmpStore();
|
||||
|
||||
return (
|
||||
<View
|
||||
style={[{ flex: 1 }, colorScheme === "dark" ? darkTheme : lightTheme]}
|
||||
>
|
||||
<KeyboardProvider>
|
||||
<SheetProvider>
|
||||
<StatusBar
|
||||
style={colorScheme === "dark" ? "light" : "dark"}
|
||||
backgroundColor={rawTheme[colorScheme as ThemeName]["base-100"]}
|
||||
/>
|
||||
{!isLoading && (
|
||||
<Stack
|
||||
screenOptions={{
|
||||
headerShown: false,
|
||||
contentStyle: {
|
||||
backgroundColor:
|
||||
rawTheme[colorScheme as ThemeName]["base-100"],
|
||||
},
|
||||
}}
|
||||
>
|
||||
{/* <Stack.Screen name="(tabs)" /> */}
|
||||
<Stack.Screen
|
||||
name="links/[id]"
|
||||
options={{
|
||||
headerShown: true,
|
||||
headerBackTitle: "Back",
|
||||
headerTitle: "",
|
||||
headerTintColor: colorScheme === "dark" ? "white" : "black",
|
||||
headerStyle: {
|
||||
backgroundColor:
|
||||
colorScheme === "dark"
|
||||
? rawTheme["dark"]["base-100"]
|
||||
: "white",
|
||||
},
|
||||
headerRight: () => (
|
||||
<View className="flex-row gap-5">
|
||||
<TouchableOpacity
|
||||
onPress={() => {
|
||||
if (tmp.link) {
|
||||
if (tmp.link.url) {
|
||||
return Linking.openURL(tmp.link.url);
|
||||
} else {
|
||||
const format = getOriginalFormat(tmp.link);
|
||||
|
||||
return Linking.openURL(
|
||||
format !== null
|
||||
? auth.instance +
|
||||
`/preserved/${tmp.link.id}?format=${format}`
|
||||
: tmp.link.url || ""
|
||||
);
|
||||
}
|
||||
}
|
||||
}}
|
||||
>
|
||||
{Platform.OS === "ios" ? (
|
||||
<Compass
|
||||
size={21}
|
||||
color={
|
||||
rawTheme[colorScheme as ThemeName]["base-content"]
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<Chromium
|
||||
stroke={
|
||||
rawTheme[colorScheme as ThemeName]["base-content"]
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
<DropdownMenu.Root>
|
||||
<DropdownMenu.Trigger>
|
||||
<TouchableOpacity>
|
||||
<Ellipsis
|
||||
size={21}
|
||||
color={
|
||||
rawTheme[colorScheme as ThemeName][
|
||||
"base-content"
|
||||
]
|
||||
}
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
</DropdownMenu.Trigger>
|
||||
|
||||
<DropdownMenu.Content>
|
||||
{tmp.link?.url && (
|
||||
<DropdownMenu.Item
|
||||
key="share"
|
||||
onSelect={async () => {
|
||||
await Share.share({
|
||||
...(Platform.OS === "android"
|
||||
? { message: tmp.link?.url as string }
|
||||
: { url: tmp.link?.url as string }),
|
||||
});
|
||||
}}
|
||||
>
|
||||
<DropdownMenu.ItemTitle>
|
||||
Share
|
||||
</DropdownMenu.ItemTitle>
|
||||
</DropdownMenu.Item>
|
||||
)}
|
||||
|
||||
{tmp.link && tmp.user && (
|
||||
<DropdownMenu.Item
|
||||
key="pin-link"
|
||||
onSelect={() => {
|
||||
const isAlreadyPinned =
|
||||
tmp.link?.pinnedBy && tmp.link.pinnedBy[0]
|
||||
? true
|
||||
: false;
|
||||
updateLink.mutateAsync({
|
||||
...(tmp.link as LinkIncludingShortenedCollectionAndTags),
|
||||
pinnedBy: (isAlreadyPinned
|
||||
? [{ id: undefined }]
|
||||
: [{ id: tmp.user?.id }]) as any,
|
||||
});
|
||||
}}
|
||||
>
|
||||
<DropdownMenu.ItemTitle>
|
||||
{tmp.link.pinnedBy && tmp.link.pinnedBy[0]
|
||||
? "Unpin Link"
|
||||
: "Pin Link"}
|
||||
</DropdownMenu.ItemTitle>
|
||||
</DropdownMenu.Item>
|
||||
)}
|
||||
|
||||
{tmp.link && (
|
||||
<DropdownMenu.Item
|
||||
key="edit-link"
|
||||
onSelect={() => {
|
||||
SheetManager.show("edit-link-sheet", {
|
||||
payload: {
|
||||
link: tmp.link as LinkIncludingShortenedCollectionAndTags,
|
||||
},
|
||||
});
|
||||
}}
|
||||
>
|
||||
<DropdownMenu.ItemTitle>
|
||||
Edit Link
|
||||
</DropdownMenu.ItemTitle>
|
||||
</DropdownMenu.Item>
|
||||
)}
|
||||
|
||||
{tmp.link && (
|
||||
<DropdownMenu.Item
|
||||
key="delete-link"
|
||||
onSelect={() => {
|
||||
return Alert.alert(
|
||||
"Delete Link",
|
||||
"Are you sure you want to delete this link? This action cannot be undone.",
|
||||
[
|
||||
{
|
||||
text: "Cancel",
|
||||
style: "cancel",
|
||||
},
|
||||
{
|
||||
text: "Delete",
|
||||
style: "destructive",
|
||||
onPress: async () => {
|
||||
deleteLink.mutate(
|
||||
tmp.link?.id as number
|
||||
);
|
||||
|
||||
await deleteLinkCache(
|
||||
tmp.link?.id as number
|
||||
);
|
||||
|
||||
router.back();
|
||||
},
|
||||
},
|
||||
]
|
||||
);
|
||||
}}
|
||||
>
|
||||
<DropdownMenu.ItemTitle>
|
||||
Delete
|
||||
</DropdownMenu.ItemTitle>
|
||||
</DropdownMenu.Item>
|
||||
)}
|
||||
</DropdownMenu.Content>
|
||||
</DropdownMenu.Root>
|
||||
</View>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen name="login" />
|
||||
<Stack.Screen name="index" />
|
||||
<Stack.Screen name="incoming" />
|
||||
<Stack.Screen name="+not-found" />
|
||||
</Stack>
|
||||
)}
|
||||
</SheetProvider>
|
||||
</KeyboardProvider>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
97
apps/mobile/app/incoming.tsx
Normal file
97
apps/mobile/app/incoming.tsx
Normal file
@@ -0,0 +1,97 @@
|
||||
import React, { useEffect, useState } from "react";
|
||||
import {
|
||||
SafeAreaView,
|
||||
View,
|
||||
Text,
|
||||
ActivityIndicator,
|
||||
Alert,
|
||||
TouchableOpacity,
|
||||
} from "react-native";
|
||||
import { Redirect, useRouter } from "expo-router";
|
||||
import useAuthStore from "@/store/auth";
|
||||
import useDataStore from "@/store/data";
|
||||
import { Check } from "lucide-react-native";
|
||||
import { useAddLink } from "@linkwarden/router/links";
|
||||
import { rawTheme, ThemeName } from "@/lib/colors";
|
||||
import { useColorScheme } from "nativewind";
|
||||
import { SheetManager } from "react-native-actions-sheet";
|
||||
import { LinkIncludingShortenedCollectionAndTags } from "@linkwarden/types/global";
|
||||
|
||||
export default function IncomingScreen() {
|
||||
const { auth } = useAuthStore();
|
||||
const router = useRouter();
|
||||
const { data, updateData } = useDataStore();
|
||||
const addLink = useAddLink({ auth });
|
||||
const { colorScheme } = useColorScheme();
|
||||
const [showSuccess, setShowSuccess] = useState(false);
|
||||
const [link, setLink] = useState<LinkIncludingShortenedCollectionAndTags>();
|
||||
|
||||
useEffect(() => {
|
||||
if (auth.status === "authenticated" && data.shareIntent.url)
|
||||
addLink.mutate(
|
||||
{
|
||||
url: data.shareIntent.url,
|
||||
collection: { id: data.preferredCollection?.id },
|
||||
},
|
||||
{
|
||||
onSuccess: (e) => {
|
||||
setLink(e as unknown as LinkIncludingShortenedCollectionAndTags);
|
||||
setShowSuccess(true);
|
||||
setTimeout(() => {
|
||||
updateData({
|
||||
shareIntent: {
|
||||
hasShareIntent: false,
|
||||
url: "",
|
||||
},
|
||||
});
|
||||
router.replace("/dashboard");
|
||||
}, 1500);
|
||||
},
|
||||
onError: (error) => {
|
||||
Alert.alert("Error", "There was an error adding the link.");
|
||||
console.error("Error adding link:", error);
|
||||
},
|
||||
}
|
||||
);
|
||||
}, [auth, data.shareIntent.url]);
|
||||
|
||||
if (auth.status === "unauthenticated") return <Redirect href="/" />;
|
||||
|
||||
return (
|
||||
<SafeAreaView className="flex-1 bg-base-100">
|
||||
<View className="flex-1 items-center justify-center">
|
||||
{data?.shareIntent.url && showSuccess && link ? (
|
||||
<>
|
||||
<Check
|
||||
size={140}
|
||||
className="mb-3 text-base-content"
|
||||
color={rawTheme[colorScheme as ThemeName].primary}
|
||||
/>
|
||||
<Text className="text-2xl font-semibold text-base-content">
|
||||
Link Saved!
|
||||
</Text>
|
||||
<TouchableOpacity
|
||||
className="w-fit mx-auto mt-5"
|
||||
onPress={() =>
|
||||
SheetManager.show("edit-link-sheet", {
|
||||
payload: {
|
||||
link: link,
|
||||
},
|
||||
})
|
||||
}
|
||||
>
|
||||
<Text className="text-neutral text-center w-fit">Edit Link</Text>
|
||||
</TouchableOpacity>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<ActivityIndicator size="large" />
|
||||
<Text className="mt-3 text-base text-base-content opacity-70">
|
||||
One sec…
|
||||
</Text>
|
||||
</>
|
||||
)}
|
||||
</View>
|
||||
</SafeAreaView>
|
||||
);
|
||||
}
|
||||
77
apps/mobile/app/index.tsx
Normal file
77
apps/mobile/app/index.tsx
Normal file
@@ -0,0 +1,77 @@
|
||||
import { Button } from "@/components/ui/Button";
|
||||
import { rawTheme, ThemeName } from "@/lib/colors";
|
||||
import useAuthStore from "@/store/auth";
|
||||
import { Redirect, router } from "expo-router";
|
||||
import { useColorScheme } from "nativewind";
|
||||
import { View, Text, Dimensions, TouchableOpacity, Image } from "react-native";
|
||||
import { SheetManager } from "react-native-actions-sheet";
|
||||
import Svg, { Path } from "react-native-svg";
|
||||
import Animated, { SlideInDown } from "react-native-reanimated";
|
||||
import { SafeAreaView } from "react-native-safe-area-context";
|
||||
|
||||
export default function HomeScreen() {
|
||||
const { auth } = useAuthStore();
|
||||
const { colorScheme } = useColorScheme();
|
||||
|
||||
if (auth.session) {
|
||||
return <Redirect href="/dashboard" />;
|
||||
}
|
||||
|
||||
return (
|
||||
<Animated.View
|
||||
entering={SlideInDown.springify().damping(100).stiffness(300)}
|
||||
className="flex-col justify-end h-full"
|
||||
>
|
||||
<View className="h-full bg-primary relative">
|
||||
<View className="my-auto">
|
||||
<Image
|
||||
source={require("@/assets/images/linkwarden.png")}
|
||||
className="w-[120px] h-[120px] mx-auto"
|
||||
/>
|
||||
<Text className="text-base-100 text-4xl font-semibold mt-7 mx-auto">
|
||||
Linkwarden
|
||||
</Text>
|
||||
</View>
|
||||
<View>
|
||||
<Text className="text-base-100 text-xl text-center font-semibold mx-4 mt-3">
|
||||
Welcome to the official mobile app for Linkwarden!
|
||||
</Text>
|
||||
|
||||
<Text className="text-base-100 text-xl text-center mx-4 mt-3">
|
||||
Expect regular improvements and new features as we continue refining
|
||||
the experience.
|
||||
</Text>
|
||||
</View>
|
||||
<Svg
|
||||
viewBox="0 0 1440 320"
|
||||
width={Dimensions.get("screen").width}
|
||||
height={Dimensions.get("screen").width * (320 / 1440) + 2}
|
||||
>
|
||||
<Path
|
||||
fill={rawTheme[colorScheme as ThemeName]["base-100"]}
|
||||
fill-opacity="1"
|
||||
d="M0,256L48,234.7C96,213,192,171,288,176C384,181,480,235,576,266.7C672,299,768,309,864,277.3C960,245,1056,171,1152,122.7C1248,75,1344,53,1392,42.7L1440,32L1440,320L1392,320C1344,320,1248,320,1152,320C1056,320,960,320,864,320C768,320,672,320,576,320C480,320,384,320,288,320C192,320,96,320,48,320L0,320Z"
|
||||
/>
|
||||
</Svg>
|
||||
<SafeAreaView
|
||||
edges={["bottom"]}
|
||||
className="flex-col justify-end h-auto duration-100 pt-10 bg-base-100 -mt-2 pb-10 gap-4 w-full px-4"
|
||||
>
|
||||
<Button
|
||||
variant="accent"
|
||||
size="lg"
|
||||
onPress={() => router.navigate("/login")}
|
||||
>
|
||||
<Text className="text-white text-xl">Get Started</Text>
|
||||
</Button>
|
||||
<TouchableOpacity
|
||||
className="w-fit mx-auto"
|
||||
onPress={() => SheetManager.show("support-sheet")}
|
||||
>
|
||||
<Text className="text-neutral text-center w-fit">Need help?</Text>
|
||||
</TouchableOpacity>
|
||||
</SafeAreaView>
|
||||
</View>
|
||||
</Animated.View>
|
||||
);
|
||||
}
|
||||
111
apps/mobile/app/links/[id].tsx
Normal file
111
apps/mobile/app/links/[id].tsx
Normal file
@@ -0,0 +1,111 @@
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { View, ActivityIndicator, Text, Platform } from "react-native";
|
||||
import { WebView } from "react-native-webview";
|
||||
import useAuthStore from "@/store/auth";
|
||||
import { useLocalSearchParams } from "expo-router";
|
||||
import { useUser } from "@linkwarden/router/user";
|
||||
import { useGetLink } from "@linkwarden/router/links";
|
||||
import useTmpStore from "@/store/tmp";
|
||||
import { ArchivedFormat } from "@linkwarden/types/global";
|
||||
import ReadableFormat from "@/components/Formats/ReadableFormat";
|
||||
import ImageFormat from "@/components/Formats/ImageFormat";
|
||||
import PdfFormat from "@/components/Formats/PdfFormat";
|
||||
import WebpageFormat from "@/components/Formats/WebpageFormat";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
|
||||
export default function LinkScreen() {
|
||||
const { auth } = useAuthStore();
|
||||
const { id, format } = useLocalSearchParams();
|
||||
const { data: user } = useUser(auth);
|
||||
const [url, setUrl] = useState<string>();
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
||||
const { data: link } = useGetLink({ id: Number(id), auth, enabled: true });
|
||||
|
||||
const { updateTmp } = useTmpStore();
|
||||
|
||||
useEffect(() => {
|
||||
if (link?.id && user?.id)
|
||||
updateTmp({
|
||||
link,
|
||||
user: {
|
||||
id: user.id,
|
||||
},
|
||||
});
|
||||
|
||||
return () =>
|
||||
updateTmp({
|
||||
link: null,
|
||||
});
|
||||
}, [link, user]);
|
||||
|
||||
useEffect(() => {
|
||||
if (user?.id && link?.id && format) {
|
||||
setUrl(`${auth.instance}/api/v1/archives/${link.id}?format=${format}`);
|
||||
} else if (!url) {
|
||||
if (link?.url) {
|
||||
setUrl(link.url);
|
||||
}
|
||||
}
|
||||
}, [user, link]);
|
||||
|
||||
const insets = useSafeAreaInsets();
|
||||
|
||||
return (
|
||||
<View
|
||||
className="flex-1"
|
||||
style={{ paddingBottom: Platform.OS === "android" ? insets.bottom : 0 }}
|
||||
>
|
||||
{link?.id && Number(format) === ArchivedFormat.readability ? (
|
||||
<ReadableFormat
|
||||
link={link as any}
|
||||
setIsLoading={(state) => setIsLoading(state)}
|
||||
/>
|
||||
) : link?.id &&
|
||||
(Number(format) === ArchivedFormat.jpeg ||
|
||||
Number(format) === ArchivedFormat.png) ? (
|
||||
<ImageFormat
|
||||
link={link as any}
|
||||
setIsLoading={(state) => setIsLoading(state)}
|
||||
format={Number(format)}
|
||||
/>
|
||||
) : link?.id && Number(format) === ArchivedFormat.pdf ? (
|
||||
<PdfFormat
|
||||
link={link as any}
|
||||
setIsLoading={(state) => setIsLoading(state)}
|
||||
/>
|
||||
) : link?.id && Number(format) === ArchivedFormat.monolith ? (
|
||||
<WebpageFormat
|
||||
link={link as any}
|
||||
setIsLoading={(state) => setIsLoading(state)}
|
||||
/>
|
||||
) : url ? (
|
||||
<WebView
|
||||
className={isLoading ? "opacity-0" : "flex-1"}
|
||||
source={{
|
||||
uri: url,
|
||||
headers:
|
||||
format || link?.type !== "url"
|
||||
? { Authorization: `Bearer ${auth.session}` }
|
||||
: {},
|
||||
}}
|
||||
onLoadEnd={() => setIsLoading(false)}
|
||||
/>
|
||||
) : (
|
||||
<View className="flex-1 justify-center items-center bg-base-100 p-5">
|
||||
<Text className="text-base text-neutral">
|
||||
No link data available. Please check your network connection or try
|
||||
again later.
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{isLoading && (
|
||||
<View className="absolute inset-0 flex-1 justify-center items-center bg-base-100 p-5">
|
||||
<ActivityIndicator size="large" />
|
||||
<Text className="text-base mt-2.5 text-neutral">Loading...</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
197
apps/mobile/app/login.tsx
Normal file
197
apps/mobile/app/login.tsx
Normal file
@@ -0,0 +1,197 @@
|
||||
import { Button } from "@/components/ui/Button";
|
||||
import Input from "@/components/ui/Input";
|
||||
import { rawTheme, ThemeName } from "@/lib/colors";
|
||||
import useAuthStore from "@/store/auth";
|
||||
import { Redirect } from "expo-router";
|
||||
import { useColorScheme } from "nativewind";
|
||||
import { useEffect, useState } from "react";
|
||||
import { View, Text, Dimensions, TouchableOpacity, Image } from "react-native";
|
||||
import { SheetManager } from "react-native-actions-sheet";
|
||||
import Svg, { Path } from "react-native-svg";
|
||||
import {
|
||||
KeyboardStickyView,
|
||||
KeyboardToolbar,
|
||||
} from "react-native-keyboard-controller";
|
||||
import { SafeAreaView } from "react-native-safe-area-context";
|
||||
|
||||
export default function HomeScreen() {
|
||||
const { auth, signIn } = useAuthStore();
|
||||
const { colorScheme } = useColorScheme();
|
||||
const [method, setMethod] = useState<"password" | "token">("password");
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
const [form, setForm] = useState({
|
||||
user: "",
|
||||
password: "",
|
||||
token: "",
|
||||
instance: auth.instance || "https://cloud.linkwarden.app",
|
||||
});
|
||||
|
||||
const [showInstanceField, setShowInstanceField] = useState(
|
||||
form.instance !== "https://cloud.linkwarden.app"
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
setForm((prev) => ({
|
||||
...prev,
|
||||
instance: auth.instance || "https://cloud.linkwarden.app",
|
||||
}));
|
||||
}, [auth.instance]);
|
||||
|
||||
useEffect(() => {
|
||||
setShowInstanceField(form.instance !== "https://cloud.linkwarden.app");
|
||||
}, [form.instance]);
|
||||
|
||||
useEffect(() => {
|
||||
setForm((prev) => ({
|
||||
...prev,
|
||||
token: "",
|
||||
user: "",
|
||||
password: "",
|
||||
}));
|
||||
}, [method]);
|
||||
|
||||
if (auth.status === "authenticated") {
|
||||
return <Redirect href="/dashboard" />;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<KeyboardStickyView className="flex-col justify-end h-full bg-base-100 relative">
|
||||
<View className="flex-col justify-end h-full bg-primary relative">
|
||||
<View className="my-auto">
|
||||
<Image
|
||||
source={require("@/assets/images/linkwarden.png")}
|
||||
className="w-[120px] h-[120px] mx-auto"
|
||||
/>
|
||||
</View>
|
||||
<Text className="text-base-100 text-7xl font-bold ml-8">Login</Text>
|
||||
<View>
|
||||
<Text
|
||||
className="text-base-100 text-2xl mx-8 mt-3"
|
||||
numberOfLines={1}
|
||||
>
|
||||
Login to{" "}
|
||||
{form.instance === "https://cloud.linkwarden.app"
|
||||
? "cloud.linkwarden.app"
|
||||
: form.instance}
|
||||
</Text>
|
||||
<TouchableOpacity
|
||||
onPress={() => {
|
||||
if (showInstanceField) {
|
||||
setForm({
|
||||
...form,
|
||||
instance: "https://cloud.linkwarden.app",
|
||||
});
|
||||
}
|
||||
setShowInstanceField(!showInstanceField);
|
||||
}}
|
||||
className="mx-8 mt-1 self-start"
|
||||
>
|
||||
<Text className="text-neutral-content text-sm">
|
||||
{!showInstanceField ? "Change server" : "Use official server"}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
<Svg
|
||||
viewBox="0 0 1440 320"
|
||||
width={Dimensions.get("screen").width}
|
||||
height={Dimensions.get("screen").width * (320 / 1440) + 2}
|
||||
>
|
||||
<Path
|
||||
fill={rawTheme[colorScheme as ThemeName]["base-100"]}
|
||||
fill-opacity="1"
|
||||
d="M0,256L48,234.7C96,213,192,171,288,176C384,181,480,235,576,266.7C672,299,768,309,864,277.3C960,245,1056,171,1152,122.7C1248,75,1344,53,1392,42.7L1440,32L1440,320L1392,320C1344,320,1248,320,1152,320C1056,320,960,320,864,320C768,320,672,320,576,320C480,320,384,320,288,320C192,320,96,320,48,320L0,320Z"
|
||||
/>
|
||||
</Svg>
|
||||
<SafeAreaView
|
||||
edges={["bottom"]}
|
||||
className="flex-col justify-end h-auto duration-100 pt-10 bg-base-100 -mt-2 pb-10 gap-4 w-full px-4"
|
||||
>
|
||||
{showInstanceField && (
|
||||
<Input
|
||||
className="w-full text-xl p-3 leading-tight h-12"
|
||||
textAlignVertical="center"
|
||||
placeholder="Instance URL"
|
||||
selectTextOnFocus={false}
|
||||
value={form.instance}
|
||||
onChangeText={(text) => setForm({ ...form, instance: text })}
|
||||
/>
|
||||
)}
|
||||
{method === "password" ? (
|
||||
<>
|
||||
<Input
|
||||
className="w-full text-xl p-3 leading-tight h-12"
|
||||
textAlignVertical="center"
|
||||
placeholder="Email or Username"
|
||||
value={form.user}
|
||||
onChangeText={(text) => setForm({ ...form, user: text })}
|
||||
/>
|
||||
<Input
|
||||
className="w-full text-xl p-3 leading-tight h-12"
|
||||
textAlignVertical="center"
|
||||
placeholder="Password"
|
||||
secureTextEntry
|
||||
value={form.password}
|
||||
onChangeText={(text) => setForm({ ...form, password: text })}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<Input
|
||||
className="w-full text-xl p-3 leading-tight h-12"
|
||||
textAlignVertical="center"
|
||||
placeholder="Access Token"
|
||||
secureTextEntry
|
||||
value={form.token}
|
||||
onChangeText={(text) => setForm({ ...form, token: text })}
|
||||
/>
|
||||
)}
|
||||
|
||||
<TouchableOpacity
|
||||
onPress={() =>
|
||||
setMethod(method === "password" ? "token" : "password")
|
||||
}
|
||||
className="w-fit mx-auto"
|
||||
>
|
||||
<Text className="text-primary w-fit text-center">
|
||||
{method === "password"
|
||||
? "Login with Access Token"
|
||||
: "Login with Username/Password"}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
|
||||
<Button
|
||||
variant="accent"
|
||||
size="lg"
|
||||
isLoading={isLoading}
|
||||
onPress={async () => {
|
||||
if (
|
||||
((form.user && form.password) || form.token) &&
|
||||
form.instance
|
||||
) {
|
||||
setIsLoading(true);
|
||||
await signIn(
|
||||
form.user,
|
||||
form.password,
|
||||
form.instance,
|
||||
form.token
|
||||
);
|
||||
setIsLoading(false);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Text className="text-white text-xl">Login</Text>
|
||||
</Button>
|
||||
<TouchableOpacity
|
||||
className="w-fit mx-auto"
|
||||
onPress={() => SheetManager.show("support-sheet")}
|
||||
>
|
||||
<Text className="text-neutral text-center w-fit">Need help?</Text>
|
||||
</TouchableOpacity>
|
||||
</SafeAreaView>
|
||||
</View>
|
||||
</KeyboardStickyView>
|
||||
<KeyboardToolbar />
|
||||
</>
|
||||
);
|
||||
}
|
||||
BIN
apps/mobile/assets/fonts/SpaceMono-Regular.ttf
Executable file
BIN
apps/mobile/assets/fonts/SpaceMono-Regular.ttf
Executable file
Binary file not shown.
BIN
apps/mobile/assets/images/favicon.png
Normal file
BIN
apps/mobile/assets/images/favicon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.4 KiB |
BIN
apps/mobile/assets/images/icon.png
Normal file
BIN
apps/mobile/assets/images/icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 238 KiB |
BIN
apps/mobile/assets/images/linkwarden.png
Normal file
BIN
apps/mobile/assets/images/linkwarden.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 237 KiB |
BIN
apps/mobile/assets/images/maskable_logo.jpeg
Normal file
BIN
apps/mobile/assets/images/maskable_logo.jpeg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 8.7 KiB |
BIN
apps/mobile/assets/images/splash-icon.png
Normal file
BIN
apps/mobile/assets/images/splash-icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 82 B |
9
apps/mobile/babel.config.js
Normal file
9
apps/mobile/babel.config.js
Normal file
@@ -0,0 +1,9 @@
|
||||
module.exports = function (api) {
|
||||
api.cache(true);
|
||||
return {
|
||||
presets: [
|
||||
["babel-preset-expo", { jsxImportSource: "nativewind" }],
|
||||
"nativewind/babel",
|
||||
],
|
||||
};
|
||||
};
|
||||
72
apps/mobile/components/ActionSheets/AddLinkSheet.tsx
Normal file
72
apps/mobile/components/ActionSheets/AddLinkSheet.tsx
Normal file
@@ -0,0 +1,72 @@
|
||||
import { Alert, Text, View } from "react-native";
|
||||
import { useRef, useState } from "react";
|
||||
import ActionSheet, { ActionSheetRef } from "react-native-actions-sheet";
|
||||
import Input from "@/components/ui/Input";
|
||||
import { Button } from "@/components/ui/Button";
|
||||
import { useAddLink } from "@linkwarden/router/links";
|
||||
import useAuthStore from "@/store/auth";
|
||||
import { rawTheme, ThemeName } from "@/lib/colors";
|
||||
import { useColorScheme } from "nativewind";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
|
||||
export default function AddLinkSheet() {
|
||||
const actionSheetRef = useRef<ActionSheetRef>(null);
|
||||
const { auth } = useAuthStore();
|
||||
const addLink = useAddLink({ auth, Alert });
|
||||
const [link, setLink] = useState("");
|
||||
const { colorScheme } = useColorScheme();
|
||||
|
||||
const insets = useSafeAreaInsets();
|
||||
|
||||
return (
|
||||
<ActionSheet
|
||||
ref={actionSheetRef}
|
||||
gestureEnabled
|
||||
indicatorStyle={{
|
||||
display: "none",
|
||||
}}
|
||||
containerStyle={{
|
||||
backgroundColor: rawTheme[colorScheme as ThemeName]["base-200"],
|
||||
}}
|
||||
safeAreaInsets={insets}
|
||||
>
|
||||
<View className="px-8 py-5">
|
||||
<Text className="font-semibold text-lg mx-auto mb-5 text-base-content">
|
||||
New Link
|
||||
</Text>
|
||||
|
||||
<Input
|
||||
placeholder="e.g. https://example.com"
|
||||
className="mb-4 bg-base-100"
|
||||
value={link}
|
||||
onChangeText={setLink}
|
||||
/>
|
||||
|
||||
<Button
|
||||
onPress={() => {
|
||||
addLink.mutate({ url: link });
|
||||
|
||||
actionSheetRef.current?.hide();
|
||||
setLink("");
|
||||
}}
|
||||
isLoading={addLink.isPending}
|
||||
variant="accent"
|
||||
className="mb-2"
|
||||
>
|
||||
<Text className="text-white">Save to Linkwarden</Text>
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
onPress={() => {
|
||||
actionSheetRef.current?.hide();
|
||||
setLink("");
|
||||
}}
|
||||
variant="outline"
|
||||
className="mb-2"
|
||||
>
|
||||
<Text className="text-base-content">Cancel</Text>
|
||||
</Button>
|
||||
</View>
|
||||
</ActionSheet>
|
||||
);
|
||||
}
|
||||
396
apps/mobile/components/ActionSheets/EditLinkSheet.tsx
Normal file
396
apps/mobile/components/ActionSheets/EditLinkSheet.tsx
Normal file
@@ -0,0 +1,396 @@
|
||||
import { View, Text, Alert, TouchableOpacity } from "react-native";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import ActionSheet, {
|
||||
FlatList,
|
||||
Route,
|
||||
SheetManager,
|
||||
SheetProps,
|
||||
useSheetRouteParams,
|
||||
useSheetRouter,
|
||||
} from "react-native-actions-sheet";
|
||||
import Input from "@/components/ui/Input";
|
||||
import { Button } from "@/components/ui/Button";
|
||||
import { useAddLink, useUpdateLink } from "@linkwarden/router/links";
|
||||
import useAuthStore from "@/store/auth";
|
||||
import {
|
||||
CollectionIncludingMembersAndLinkCount,
|
||||
LinkIncludingShortenedCollectionAndTags,
|
||||
TagIncludingLinkCount,
|
||||
} from "@linkwarden/types/global";
|
||||
import { useCollections } from "@linkwarden/router/collections";
|
||||
import { rawTheme, ThemeName } from "@/lib/colors";
|
||||
import { useColorScheme } from "nativewind";
|
||||
import { Folder, ChevronRight, ChevronLeft, Check } from "lucide-react-native";
|
||||
import useTmpStore from "@/store/tmp";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { useTags } from "@linkwarden/router/tags";
|
||||
|
||||
const Main = (props: SheetProps<"edit-link-sheet">) => {
|
||||
const { auth } = useAuthStore();
|
||||
|
||||
const params = useSheetRouteParams("edit-link-sheet", "main");
|
||||
|
||||
const [link, setLink] = useState<
|
||||
LinkIncludingShortenedCollectionAndTags | undefined
|
||||
>(props.payload?.link);
|
||||
const updateLink = useUpdateLink({ auth, Alert });
|
||||
const router = useSheetRouter("edit-link-sheet");
|
||||
const { colorScheme } = useColorScheme();
|
||||
|
||||
useEffect(() => {
|
||||
if (params?.link) {
|
||||
setLink(params.link);
|
||||
}
|
||||
}, [params?.link]);
|
||||
|
||||
const { tmp, updateTmp } = useTmpStore();
|
||||
|
||||
return (
|
||||
<View className="px-8 py-5">
|
||||
<Text className="font-semibold text-lg mx-auto mb-5 text-base-content">
|
||||
Edit Link
|
||||
</Text>
|
||||
|
||||
<Input
|
||||
placeholder="Name"
|
||||
className="mb-4 bg-base-100"
|
||||
value={link?.name || ""}
|
||||
onChangeText={(text) => link?.id && setLink({ ...link, name: text })}
|
||||
/>
|
||||
|
||||
{props.payload?.link?.url && (
|
||||
<Input
|
||||
placeholder="URL"
|
||||
className="mb-4 bg-base-100"
|
||||
value={link?.url || ""}
|
||||
onChangeText={(text) => link?.id && setLink({ ...link, url: text })}
|
||||
/>
|
||||
)}
|
||||
|
||||
<Button
|
||||
variant="input"
|
||||
className="mb-4"
|
||||
onPress={() => router?.navigate("collections", { link })}
|
||||
>
|
||||
<View className="flex-row items-center gap-2 w-[90%]">
|
||||
<Folder
|
||||
size={20}
|
||||
fill={link?.collection.color || "gray"}
|
||||
color={link?.collection.color || "gray"}
|
||||
/>
|
||||
<Text numberOfLines={1} className="w-[90%] text-base-content">
|
||||
{link?.collection.name}
|
||||
</Text>
|
||||
</View>
|
||||
<ChevronRight
|
||||
size={16}
|
||||
color={rawTheme[colorScheme as ThemeName]["neutral"]}
|
||||
/>
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="input"
|
||||
className="mb-4 h-auto"
|
||||
onPress={() => router?.navigate("tags", { link })}
|
||||
>
|
||||
{link?.tags && link?.tags.length > 0 ? (
|
||||
<View className="flex-row flex-wrap items-center gap-2 w-[90%]">
|
||||
{link.tags.map((tag) => (
|
||||
<View
|
||||
key={tag.id}
|
||||
className="bg-neutral rounded-md h-7 px-2 py-1"
|
||||
>
|
||||
<Text numberOfLines={1} className="text-base-100">
|
||||
{tag.name}
|
||||
</Text>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
) : (
|
||||
<Text className="text-neutral">No tags</Text>
|
||||
)}
|
||||
<ChevronRight size={16} color={"gray"} />
|
||||
</Button>
|
||||
|
||||
<Input
|
||||
multiline
|
||||
textAlignVertical="top"
|
||||
placeholder="Description"
|
||||
className="mb-4 h-28 bg-base-100"
|
||||
value={link?.description || ""}
|
||||
onChangeText={(text) =>
|
||||
link?.id && setLink({ ...link, description: text })
|
||||
}
|
||||
/>
|
||||
|
||||
<Button
|
||||
onPress={() => {
|
||||
updateLink.mutate(link as LinkIncludingShortenedCollectionAndTags);
|
||||
if (link && tmp.link)
|
||||
updateTmp({
|
||||
link,
|
||||
});
|
||||
SheetManager.hide("edit-link-sheet");
|
||||
}}
|
||||
isLoading={updateLink.isPending}
|
||||
variant="accent"
|
||||
className="mb-2"
|
||||
>
|
||||
<Text className="text-white">Save</Text>
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
onPress={() => {
|
||||
SheetManager.hide("edit-link-sheet");
|
||||
}}
|
||||
variant="outline"
|
||||
className="mb-2"
|
||||
>
|
||||
<Text className="text-base-content">Cancel</Text>
|
||||
</Button>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
const Collections = () => {
|
||||
const { auth } = useAuthStore();
|
||||
const addLink = useAddLink({ auth });
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const router = useSheetRouter("edit-link-sheet");
|
||||
const { link: currentLink } = useSheetRouteParams<
|
||||
"edit-link-sheet",
|
||||
"collections"
|
||||
>("edit-link-sheet", "collections");
|
||||
const params = useSheetRouteParams("edit-link-sheet", "collections");
|
||||
const collections = useCollections(auth);
|
||||
const { colorScheme } = useColorScheme();
|
||||
|
||||
const filteredCollections = useMemo(() => {
|
||||
if (!collections.data) return [];
|
||||
const q = searchQuery.trim().toLowerCase();
|
||||
if (q === "") return collections.data;
|
||||
return collections.data.filter((col) => col.name.toLowerCase().includes(q));
|
||||
}, [collections.data, searchQuery]);
|
||||
|
||||
const renderItem = useCallback(
|
||||
({
|
||||
item: collection,
|
||||
}: {
|
||||
item: CollectionIncludingMembersAndLinkCount;
|
||||
}) => {
|
||||
const onSelect = () => {
|
||||
const updatedLink = {
|
||||
...currentLink,
|
||||
collection,
|
||||
};
|
||||
|
||||
router?.popToTop();
|
||||
router?.navigate("main", { link: updatedLink });
|
||||
};
|
||||
|
||||
return (
|
||||
<Button variant="input" className="mb-2" onPress={onSelect}>
|
||||
<View className="flex-row items-center gap-2 w-[75%]">
|
||||
<Folder
|
||||
size={20}
|
||||
fill={collection.color || "gray"}
|
||||
color={collection.color || "gray"}
|
||||
/>
|
||||
<Text numberOfLines={1} className="w-full text-base-content">
|
||||
{collection.name}
|
||||
</Text>
|
||||
</View>
|
||||
<View className="flex-row items-center gap-2">
|
||||
{params.link?.collection.id === collection.id && (
|
||||
<Check
|
||||
size={16}
|
||||
color={rawTheme[colorScheme as ThemeName].primary}
|
||||
/>
|
||||
)}
|
||||
<Text className="text-neutral">
|
||||
{collection._count?.links ?? 0}
|
||||
</Text>
|
||||
</View>
|
||||
</Button>
|
||||
);
|
||||
},
|
||||
[addLink, params.link, router]
|
||||
);
|
||||
|
||||
return (
|
||||
<View className="py-5 max-h-[80vh]">
|
||||
<TouchableOpacity
|
||||
className="flex-row items-center gap-1 top-6 left-8 absolute"
|
||||
onPress={() => {
|
||||
router?.popToTop();
|
||||
router?.navigate("main", { link: currentLink });
|
||||
}}
|
||||
>
|
||||
<ChevronLeft
|
||||
size={18}
|
||||
color={rawTheme[colorScheme as ThemeName]["primary"]}
|
||||
/>
|
||||
<Text className="text-primary">Back</Text>
|
||||
</TouchableOpacity>
|
||||
<Text className="font-semibold text-lg mx-auto mb-5 text-base-content">
|
||||
Collection
|
||||
</Text>
|
||||
<Input
|
||||
placeholder="Search collections"
|
||||
className="mb-4 bg-base-100 mx-8"
|
||||
value={searchQuery}
|
||||
onChangeText={setSearchQuery}
|
||||
/>
|
||||
|
||||
<FlatList
|
||||
data={[...filteredCollections]}
|
||||
keyExtractor={(e, i) => i.toString()}
|
||||
renderItem={renderItem}
|
||||
ListEmptyComponent={
|
||||
<Text
|
||||
style={{ textAlign: "center", marginTop: 20 }}
|
||||
className="text-neutral"
|
||||
>
|
||||
No collections match “{searchQuery}”
|
||||
</Text>
|
||||
}
|
||||
contentContainerClassName="px-8"
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
const Tags = () => {
|
||||
const { auth } = useAuthStore();
|
||||
const addLink = useAddLink({ auth });
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const router = useSheetRouter("edit-link-sheet");
|
||||
const params = useSheetRouteParams("edit-link-sheet", "tags");
|
||||
const tags = useTags(auth);
|
||||
const { colorScheme } = useColorScheme();
|
||||
const [updatedLink, setUpdatedLink] =
|
||||
useState<LinkIncludingShortenedCollectionAndTags>(params.link);
|
||||
|
||||
const filteredTags = useMemo(() => {
|
||||
if (!tags.data) return [];
|
||||
const q = searchQuery.trim().toLowerCase();
|
||||
if (q === "") return tags.data;
|
||||
return tags.data.filter((tag) => tag.name.toLowerCase().includes(q));
|
||||
}, [tags.data, searchQuery]);
|
||||
|
||||
const renderItem = useCallback(
|
||||
({ item: tag }: { item: TagIncludingLinkCount }) => {
|
||||
const onSelect = () => {
|
||||
const isSelected = (updatedLink?.tags || []).some(
|
||||
(t) => t.id === tag.id
|
||||
);
|
||||
const nextTags = isSelected
|
||||
? (updatedLink?.tags || []).filter((t) => t.id !== tag.id)
|
||||
: [...(updatedLink?.tags || []), tag];
|
||||
|
||||
setUpdatedLink({
|
||||
...updatedLink,
|
||||
tags: nextTags,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Button variant="input" className="mb-2" onPress={onSelect}>
|
||||
<View className="flex-row items-center gap-2 w-[75%]">
|
||||
<Text numberOfLines={1} className="w-full text-base-content">
|
||||
{tag.name}
|
||||
</Text>
|
||||
</View>
|
||||
<View className="flex-row items-center gap-2">
|
||||
{updatedLink?.tags.find((e) => e.id === tag.id) && (
|
||||
<Check
|
||||
size={16}
|
||||
color={rawTheme[colorScheme as ThemeName].primary}
|
||||
/>
|
||||
)}
|
||||
<Text className="text-neutral">{tag._count?.links ?? 0}</Text>
|
||||
</View>
|
||||
</Button>
|
||||
);
|
||||
},
|
||||
[addLink, params.link, router]
|
||||
);
|
||||
|
||||
return (
|
||||
<View className="py-5 max-h-[80vh]">
|
||||
<TouchableOpacity
|
||||
className="flex-row items-center gap-1 top-6 left-8 absolute"
|
||||
onPress={() => {
|
||||
router?.popToTop();
|
||||
router?.navigate("main", { link: updatedLink });
|
||||
}}
|
||||
>
|
||||
<ChevronLeft
|
||||
size={18}
|
||||
color={rawTheme[colorScheme as ThemeName]["primary"]}
|
||||
/>
|
||||
<Text className="text-primary">Back</Text>
|
||||
</TouchableOpacity>
|
||||
<Text className="font-semibold text-lg mx-auto mb-5 text-base-content">
|
||||
Tags
|
||||
</Text>
|
||||
<Input
|
||||
placeholder="Search tags"
|
||||
className="mb-4 bg-base-100 mx-8"
|
||||
value={searchQuery}
|
||||
onChangeText={setSearchQuery}
|
||||
/>
|
||||
|
||||
<FlatList
|
||||
data={filteredTags}
|
||||
keyExtractor={(e, i) => i.toString()}
|
||||
renderItem={renderItem}
|
||||
ListEmptyComponent={
|
||||
<Text
|
||||
style={{ textAlign: "center", marginTop: 20 }}
|
||||
className="text-neutral"
|
||||
>
|
||||
No tags match “{searchQuery}”
|
||||
</Text>
|
||||
}
|
||||
contentContainerClassName="px-8"
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
const routes: Route[] = [
|
||||
{
|
||||
name: "main",
|
||||
component: Main,
|
||||
},
|
||||
{
|
||||
name: "collections",
|
||||
component: Collections,
|
||||
},
|
||||
{
|
||||
name: "tags",
|
||||
component: Tags,
|
||||
},
|
||||
];
|
||||
|
||||
export default function EditLinkSheet() {
|
||||
const { colorScheme } = useColorScheme();
|
||||
|
||||
const insets = useSafeAreaInsets();
|
||||
|
||||
return (
|
||||
<ActionSheet
|
||||
gestureEnabled
|
||||
indicatorStyle={{
|
||||
display: "none",
|
||||
}}
|
||||
routes={routes}
|
||||
initialRoute="main"
|
||||
containerStyle={{
|
||||
backgroundColor: rawTheme[colorScheme as ThemeName]["base-200"],
|
||||
}}
|
||||
safeAreaInsets={insets}
|
||||
/>
|
||||
);
|
||||
}
|
||||
96
apps/mobile/components/ActionSheets/NewCollectionSheet.tsx
Normal file
96
apps/mobile/components/ActionSheets/NewCollectionSheet.tsx
Normal file
@@ -0,0 +1,96 @@
|
||||
import { Alert, Text, View } from "react-native";
|
||||
import { useRef, useState } from "react";
|
||||
import ActionSheet, { ActionSheetRef } from "react-native-actions-sheet";
|
||||
import Input from "@/components/ui/Input";
|
||||
import { Button } from "@/components/ui/Button";
|
||||
import useAuthStore from "@/store/auth";
|
||||
import { rawTheme, ThemeName } from "@/lib/colors";
|
||||
import { useColorScheme } from "nativewind";
|
||||
import { useCreateCollection } from "@linkwarden/router/collections";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
|
||||
export default function NewCollectionSheet() {
|
||||
const actionSheetRef = useRef<ActionSheetRef>(null);
|
||||
const { auth } = useAuthStore();
|
||||
const createCollection = useCreateCollection(auth);
|
||||
const [collection, setCollection] = useState({
|
||||
name: "",
|
||||
description: "",
|
||||
});
|
||||
const { colorScheme } = useColorScheme();
|
||||
|
||||
const insets = useSafeAreaInsets();
|
||||
|
||||
return (
|
||||
<ActionSheet
|
||||
ref={actionSheetRef}
|
||||
gestureEnabled
|
||||
indicatorStyle={{
|
||||
display: "none",
|
||||
}}
|
||||
containerStyle={{
|
||||
backgroundColor: rawTheme[colorScheme as ThemeName]["base-200"],
|
||||
}}
|
||||
safeAreaInsets={insets}
|
||||
>
|
||||
<View className="px-8 py-5">
|
||||
<Text className="font-semibold text-lg mx-auto mb-5 text-base-content">
|
||||
New Collection
|
||||
</Text>
|
||||
|
||||
<Input
|
||||
placeholder="Name"
|
||||
className="mb-4 bg-base-100"
|
||||
value={collection.name}
|
||||
onChangeText={(text) => setCollection({ ...collection, name: text })}
|
||||
/>
|
||||
|
||||
<Input
|
||||
placeholder="Description"
|
||||
className="mb-4 bg-base-100"
|
||||
value={collection.description}
|
||||
onChangeText={(text) =>
|
||||
setCollection({ ...collection, description: text })
|
||||
}
|
||||
/>
|
||||
|
||||
<Button
|
||||
onPress={() =>
|
||||
createCollection.mutate(
|
||||
{ name: collection.name, description: collection.description },
|
||||
{
|
||||
onSuccess: () => {
|
||||
actionSheetRef.current?.hide();
|
||||
setCollection({ name: "", description: "" });
|
||||
},
|
||||
onError: (error) => {
|
||||
Alert.alert(
|
||||
"Error",
|
||||
"There was an error creating the collection."
|
||||
);
|
||||
console.error("Error creating collection:", error);
|
||||
},
|
||||
}
|
||||
)
|
||||
}
|
||||
isLoading={createCollection.isPending}
|
||||
variant="accent"
|
||||
className="mb-2"
|
||||
>
|
||||
<Text className="text-white">Save Collection</Text>
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
onPress={() => {
|
||||
actionSheetRef.current?.hide();
|
||||
setCollection({ name: "", description: "" });
|
||||
}}
|
||||
variant="outline"
|
||||
className="mb-2"
|
||||
>
|
||||
<Text className="text-base-content">Cancel</Text>
|
||||
</Button>
|
||||
</View>
|
||||
</ActionSheet>
|
||||
);
|
||||
}
|
||||
41
apps/mobile/components/ActionSheets/Sheets.tsx
Normal file
41
apps/mobile/components/ActionSheets/Sheets.tsx
Normal file
@@ -0,0 +1,41 @@
|
||||
import {
|
||||
registerSheet,
|
||||
RouteDefinition,
|
||||
SheetDefinition,
|
||||
} from "react-native-actions-sheet";
|
||||
import SupportSheet from "./SupportSheet";
|
||||
import AddLinkSheet from "./AddLinkSheet";
|
||||
import EditLinkSheet from "./EditLinkSheet";
|
||||
import NewCollectionSheet from "./NewCollectionSheet";
|
||||
import { LinkIncludingShortenedCollectionAndTags } from "@linkwarden/types/global";
|
||||
|
||||
registerSheet("support-sheet", SupportSheet);
|
||||
registerSheet("add-link-sheet", AddLinkSheet);
|
||||
registerSheet("edit-link-sheet", EditLinkSheet);
|
||||
registerSheet("new-collection-sheet", NewCollectionSheet);
|
||||
|
||||
declare module "react-native-actions-sheet" {
|
||||
interface Sheets {
|
||||
"support-sheet": SheetDefinition;
|
||||
"add-link-sheet": SheetDefinition;
|
||||
"edit-link-sheet": SheetDefinition<{
|
||||
payload: {
|
||||
link: LinkIncludingShortenedCollectionAndTags;
|
||||
};
|
||||
routes: {
|
||||
main: RouteDefinition<{
|
||||
link: LinkIncludingShortenedCollectionAndTags;
|
||||
}>;
|
||||
collections: RouteDefinition<{
|
||||
link: LinkIncludingShortenedCollectionAndTags;
|
||||
}>;
|
||||
tags: RouteDefinition<{
|
||||
link: LinkIncludingShortenedCollectionAndTags;
|
||||
}>;
|
||||
};
|
||||
}>;
|
||||
"new-collection-sheet": SheetDefinition;
|
||||
}
|
||||
}
|
||||
|
||||
export {};
|
||||
47
apps/mobile/components/ActionSheets/SupportSheet.tsx
Normal file
47
apps/mobile/components/ActionSheets/SupportSheet.tsx
Normal file
@@ -0,0 +1,47 @@
|
||||
import { Text, View } from "react-native";
|
||||
import { useState } from "react";
|
||||
import ActionSheet from "react-native-actions-sheet";
|
||||
import { rawTheme, ThemeName } from "@/lib/colors";
|
||||
import { useColorScheme } from "nativewind";
|
||||
import * as Clipboard from "expo-clipboard";
|
||||
import { Button } from "../ui/Button";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
|
||||
export default function SupportSheet() {
|
||||
const { colorScheme } = useColorScheme();
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
async function handleEmailPress() {
|
||||
await Clipboard.setStringAsync("support@linkwarden.app");
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
}
|
||||
|
||||
const insets = useSafeAreaInsets();
|
||||
|
||||
return (
|
||||
<ActionSheet
|
||||
gestureEnabled
|
||||
indicatorStyle={{
|
||||
backgroundColor: rawTheme[colorScheme as ThemeName]["neutral-content"],
|
||||
}}
|
||||
containerStyle={{
|
||||
backgroundColor: rawTheme[colorScheme as ThemeName]["base-100"],
|
||||
}}
|
||||
safeAreaInsets={insets}
|
||||
>
|
||||
<View className="px-8 py-5 flex-col gap-4">
|
||||
<Text className="text-2xl font-bold text-base-content">Need help?</Text>
|
||||
<Text className="text-base-content">
|
||||
Whether you have a question or need assistance, feel free to reach out
|
||||
to us at support@linkwarden.app
|
||||
</Text>
|
||||
<Button onPress={handleEmailPress} variant="outline">
|
||||
<Text className="text-base-content">
|
||||
{copied ? "Copied!" : "Copy Support Email"}
|
||||
</Text>
|
||||
</Button>
|
||||
</View>
|
||||
</ActionSheet>
|
||||
);
|
||||
}
|
||||
132
apps/mobile/components/CollectionListing.tsx
Normal file
132
apps/mobile/components/CollectionListing.tsx
Normal file
@@ -0,0 +1,132 @@
|
||||
import { View, Text, Pressable, Platform, Alert } from "react-native";
|
||||
import { decode } from "html-entities";
|
||||
import { CollectionIncludingMembersAndLinkCount } from "@linkwarden/types/global";
|
||||
import useAuthStore from "@/store/auth";
|
||||
import { useRouter } from "expo-router";
|
||||
import * as ContextMenu from "zeego/context-menu";
|
||||
import { cn } from "@linkwarden/lib/utils";
|
||||
import { rawTheme, ThemeName } from "@/lib/colors";
|
||||
import { useColorScheme } from "nativewind";
|
||||
import { CalendarDays, Folder, Link } from "lucide-react-native";
|
||||
import { useDeleteCollection } from "@linkwarden/router/collections";
|
||||
|
||||
type Props = {
|
||||
collection: CollectionIncludingMembersAndLinkCount;
|
||||
};
|
||||
|
||||
const CollectionListing = ({ collection }: Props) => {
|
||||
const { auth } = useAuthStore();
|
||||
const router = useRouter();
|
||||
const { colorScheme } = useColorScheme();
|
||||
|
||||
const deleteCollection = useDeleteCollection({ auth, Alert });
|
||||
|
||||
return (
|
||||
<ContextMenu.Root>
|
||||
<ContextMenu.Trigger asChild>
|
||||
<Pressable
|
||||
className={cn(
|
||||
"p-5 flex-row justify-between",
|
||||
"bg-base-100",
|
||||
Platform.OS !== "android" && "active:bg-base-200/50"
|
||||
)}
|
||||
onLongPress={() => {}}
|
||||
onPress={() => router.navigate(`/collections/${collection.id}`)}
|
||||
android_ripple={{
|
||||
color: colorScheme === "dark" ? "rgba(255,255,255,0.2)" : "#ddd",
|
||||
borderless: false,
|
||||
}}
|
||||
>
|
||||
<View className="w-full">
|
||||
<View className="w-[90%] flex-col justify-between gap-3">
|
||||
<View className="flex flex-row gap-2 items-center pr-1.5 self-start rounded-md">
|
||||
<Folder
|
||||
size={16}
|
||||
fill={collection.color || ""}
|
||||
color={collection.color || ""}
|
||||
/>
|
||||
<Text
|
||||
numberOfLines={2}
|
||||
className="font-medium text-lg text-base-content"
|
||||
>
|
||||
{decode(collection.name)}
|
||||
</Text>
|
||||
</View>
|
||||
{collection.description && (
|
||||
<Text
|
||||
numberOfLines={2}
|
||||
className="font-light text-sm text-base-content"
|
||||
>
|
||||
{decode(collection.description)}
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
|
||||
<View className="flex-row gap-3">
|
||||
<View className="flex flex-row gap-1 items-center mt-5 self-start">
|
||||
<CalendarDays
|
||||
size={16}
|
||||
color={rawTheme[colorScheme as ThemeName]["neutral"]}
|
||||
/>
|
||||
<Text
|
||||
numberOfLines={1}
|
||||
className="font-light text-xs text-base-content"
|
||||
>
|
||||
{new Date(collection.createdAt as string).toLocaleString(
|
||||
"en-US",
|
||||
{
|
||||
year: "numeric",
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
}
|
||||
)}
|
||||
</Text>
|
||||
</View>
|
||||
<View className="flex flex-row gap-1 items-center mt-5 self-start">
|
||||
<Link
|
||||
size={16}
|
||||
color={rawTheme[colorScheme as ThemeName]["neutral"]}
|
||||
/>
|
||||
<Text
|
||||
numberOfLines={1}
|
||||
className="font-light text-xs text-base-content"
|
||||
>
|
||||
{collection._count?.links}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
</Pressable>
|
||||
</ContextMenu.Trigger>
|
||||
|
||||
<ContextMenu.Content avoidCollisions>
|
||||
<ContextMenu.Item
|
||||
key="delete-collection"
|
||||
onSelect={() => {
|
||||
return Alert.alert(
|
||||
"Delete Collection",
|
||||
"Are you sure you want to delete this collection? This action cannot be undone.",
|
||||
[
|
||||
{
|
||||
text: "Cancel",
|
||||
style: "cancel",
|
||||
},
|
||||
{
|
||||
text: "Delete",
|
||||
style: "destructive",
|
||||
onPress: () => {
|
||||
deleteCollection.mutate(collection.id as number);
|
||||
},
|
||||
},
|
||||
]
|
||||
);
|
||||
}}
|
||||
>
|
||||
<ContextMenu.ItemTitle>Delete</ContextMenu.ItemTitle>
|
||||
</ContextMenu.Item>
|
||||
</ContextMenu.Content>
|
||||
</ContextMenu.Root>
|
||||
);
|
||||
};
|
||||
|
||||
export default CollectionListing;
|
||||
35
apps/mobile/components/DashboardItem.tsx
Normal file
35
apps/mobile/components/DashboardItem.tsx
Normal file
@@ -0,0 +1,35 @@
|
||||
import { Text, View } from "react-native";
|
||||
|
||||
export default function DashboardItem({
|
||||
name,
|
||||
value,
|
||||
icon,
|
||||
color,
|
||||
}: {
|
||||
name: string;
|
||||
value: number;
|
||||
icon: React.ReactNode;
|
||||
color: string;
|
||||
}) {
|
||||
return (
|
||||
<View className="flex-1 flex-col gap-2 rounded-xl bg-base-200 p-3">
|
||||
<View className="flex-row justify-between">
|
||||
<View
|
||||
className="flex-col gap-2 rounded-full aspect-square flex justify-center items-center"
|
||||
style={{
|
||||
backgroundColor: color,
|
||||
}}
|
||||
>
|
||||
{icon}
|
||||
</View>
|
||||
<Text
|
||||
className="text-4xl text-base-content mt-0.5 text-right max-w-[75%]"
|
||||
numberOfLines={1}
|
||||
>
|
||||
{value || 0}
|
||||
</Text>
|
||||
</View>
|
||||
<Text className="font-semibold text-neutral">{name}</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
346
apps/mobile/components/DashboardSection.tsx
Normal file
346
apps/mobile/components/DashboardSection.tsx
Normal file
@@ -0,0 +1,346 @@
|
||||
import {
|
||||
FlatList,
|
||||
Text,
|
||||
TouchableOpacity,
|
||||
View,
|
||||
ViewToken,
|
||||
} from "react-native";
|
||||
import React from "react";
|
||||
import clsx from "clsx";
|
||||
import DashboardItem from "@/components/DashboardItem";
|
||||
import { rawTheme, ThemeName } from "@/lib/colors";
|
||||
import {
|
||||
Clock8,
|
||||
ChevronRight,
|
||||
Pin,
|
||||
Folder,
|
||||
Hash,
|
||||
Link,
|
||||
} from "lucide-react-native";
|
||||
import { LinkIncludingShortenedCollectionAndTags } from "@linkwarden/types/global";
|
||||
import LinkListing from "@/components/LinkListing";
|
||||
import { useColorScheme } from "nativewind";
|
||||
import { useRouter } from "expo-router";
|
||||
|
||||
// Don't use prisma client's DashboardSectionType, it'll crash in production (React Native)
|
||||
type DashboardSectionType =
|
||||
| "STATS"
|
||||
| "RECENT_LINKS"
|
||||
| "PINNED_LINKS"
|
||||
| "COLLECTION";
|
||||
|
||||
type DashboardSectionProps = {
|
||||
sectionData: { type: DashboardSectionType };
|
||||
collection?: any;
|
||||
links?: any[];
|
||||
tagsLength: number;
|
||||
numberOfLinks: number;
|
||||
collectionsLength: number;
|
||||
numberOfPinnedLinks: number;
|
||||
dashboardData: {
|
||||
isLoading: boolean;
|
||||
refetch: Function;
|
||||
isRefetching: boolean;
|
||||
};
|
||||
collectionLinks?: any[];
|
||||
};
|
||||
|
||||
const DashboardSection: React.FC<DashboardSectionProps> = ({
|
||||
sectionData,
|
||||
collection,
|
||||
links = [],
|
||||
tagsLength,
|
||||
numberOfLinks,
|
||||
collectionsLength,
|
||||
numberOfPinnedLinks,
|
||||
dashboardData,
|
||||
collectionLinks = [],
|
||||
}) => {
|
||||
const { colorScheme } = useColorScheme();
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
switch (sectionData.type) {
|
||||
case "STATS":
|
||||
return (
|
||||
<View className="flex-col gap-4 max-w-full px-5">
|
||||
<View className="flex-row gap-4">
|
||||
<DashboardItem
|
||||
name={numberOfLinks === 1 ? "Link" : "Links"}
|
||||
value={numberOfLinks}
|
||||
icon={<Link size={23} color="white" />}
|
||||
color="#9c00cc"
|
||||
/>
|
||||
<DashboardItem
|
||||
name={collectionsLength === 1 ? "Collection" : "Collections"}
|
||||
value={collectionsLength}
|
||||
icon={<Folder size={23} color="white" fill="white" />}
|
||||
color="#0096cc"
|
||||
/>
|
||||
</View>
|
||||
<View className="flex-row gap-4">
|
||||
<DashboardItem
|
||||
name={tagsLength === 1 ? "Tag" : "Tags"}
|
||||
value={tagsLength}
|
||||
icon={<Hash size={23} color="white" />}
|
||||
color="#00cc99"
|
||||
/>
|
||||
<DashboardItem
|
||||
name={"Pinned Links"}
|
||||
value={numberOfPinnedLinks}
|
||||
icon={<Pin size={23} color="white" fill="white" />}
|
||||
color="#cc6d00"
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
|
||||
case "RECENT_LINKS":
|
||||
return (
|
||||
<>
|
||||
<View className="flex-row justify-between items-center px-5">
|
||||
<View className="flex-row gap-2 items-center">
|
||||
<View className={"flex-row items-center gap-2"}>
|
||||
<Clock8
|
||||
size={30}
|
||||
color={rawTheme[colorScheme as ThemeName].primary}
|
||||
/>
|
||||
<Text className="text-2xl capitalize text-base-content">
|
||||
Recent Links
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
<TouchableOpacity
|
||||
className="flex-row items-center text-sm gap-1"
|
||||
onPress={() => router.navigate("/(tabs)/dashboard/recent-links")}
|
||||
>
|
||||
<Text className="text-primary">View All</Text>
|
||||
<ChevronRight
|
||||
size={15}
|
||||
color={rawTheme[colorScheme as ThemeName].primary}
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
{dashboardData.isLoading ||
|
||||
(links.length > 0 && !dashboardData.isLoading) ? (
|
||||
<FlatList
|
||||
horizontal
|
||||
showsHorizontalScrollIndicator={false}
|
||||
directionalLockEnabled
|
||||
data={links || []}
|
||||
refreshing={dashboardData.isLoading}
|
||||
initialNumToRender={2}
|
||||
keyExtractor={(item) => item.id?.toString() || ""}
|
||||
renderItem={({ item }) => (
|
||||
<RenderItem item={item} key={item.id?.toString()} />
|
||||
)}
|
||||
ItemSeparatorComponent={() => <View style={{ width: 10 }} />}
|
||||
contentContainerStyle={{
|
||||
paddingHorizontal: 20,
|
||||
}}
|
||||
onViewableItemsChanged={({
|
||||
viewableItems,
|
||||
}: {
|
||||
viewableItems: ViewToken[];
|
||||
}) => {
|
||||
const links = viewableItems.map(
|
||||
(e) => e.item
|
||||
) as LinkIncludingShortenedCollectionAndTags[];
|
||||
|
||||
if (
|
||||
!dashboardData.isRefetching &&
|
||||
links.some((e) => e.id && !e.preview)
|
||||
) {
|
||||
dashboardData.refetch();
|
||||
}
|
||||
}}
|
||||
viewabilityConfig={{ itemVisiblePercentThreshold: 50 }}
|
||||
/>
|
||||
) : (
|
||||
<View className="flex-col gap-2 justify-center items-center h-40 p-10 rounded-xl bg-base-200 mx-5">
|
||||
<Clock8
|
||||
size={40}
|
||||
color={rawTheme[colorScheme as ThemeName].primary}
|
||||
/>
|
||||
<Text className="text-center text-xl text-neutral">
|
||||
No Recent Links
|
||||
</Text>
|
||||
|
||||
{/* <View className="text-center w-full mt-4 flex-row flex-wrap gap-4 justify-center">
|
||||
<Button onPress={() => setNewLinkModal(true)} variant="accent">
|
||||
<Icon name="bi-plus-lg" className="text-xl" />
|
||||
<Text>{t("add_link")}</Text>
|
||||
</Button>
|
||||
<ImportDropdown />
|
||||
</View> */}
|
||||
</View>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
case "PINNED_LINKS":
|
||||
return (
|
||||
<>
|
||||
<View className="flex-row justify-between items-center px-5">
|
||||
<View className="flex-row gap-2 items-center">
|
||||
<View className={"flex-row items-center gap-2"}>
|
||||
<Pin
|
||||
size={30}
|
||||
color={rawTheme[colorScheme as ThemeName].primary}
|
||||
/>
|
||||
<Text className="text-2xl capitalize text-base-content">
|
||||
Pinned Links
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
<TouchableOpacity
|
||||
className="flex-row items-center text-sm gap-1"
|
||||
onPress={() => router.navigate("/(tabs)/dashboard/pinned-links")}
|
||||
>
|
||||
<Text className="text-primary">View All</Text>
|
||||
<ChevronRight
|
||||
size={15}
|
||||
color={rawTheme[colorScheme as ThemeName].primary}
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
{dashboardData.isLoading ||
|
||||
links?.some((e: any) => e.pinnedBy && e.pinnedBy[0]) ? (
|
||||
<FlatList
|
||||
horizontal
|
||||
showsHorizontalScrollIndicator={false}
|
||||
data={links.filter((e: any) => e.pinnedBy && e.pinnedBy[0]) || []}
|
||||
refreshing={dashboardData.isLoading}
|
||||
initialNumToRender={2}
|
||||
keyExtractor={(item) => item.id?.toString() || ""}
|
||||
renderItem={({ item }) => (
|
||||
<RenderItem item={item} key={item.id?.toString()} />
|
||||
)}
|
||||
ItemSeparatorComponent={() => <View style={{ width: 10 }} />}
|
||||
contentContainerStyle={{
|
||||
paddingHorizontal: 20,
|
||||
}}
|
||||
onViewableItemsChanged={({
|
||||
viewableItems,
|
||||
}: {
|
||||
viewableItems: ViewToken[];
|
||||
}) => {
|
||||
const links = viewableItems.map(
|
||||
(e) => e.item
|
||||
) as LinkIncludingShortenedCollectionAndTags[];
|
||||
|
||||
if (
|
||||
!dashboardData.isRefetching &&
|
||||
links.some((e) => e.id && !e.preview)
|
||||
) {
|
||||
dashboardData.refetch();
|
||||
}
|
||||
}}
|
||||
viewabilityConfig={{ itemVisiblePercentThreshold: 50 }}
|
||||
/>
|
||||
) : (
|
||||
<View className="flex-col gap-2 justify-center items-center h-40 p-10 rounded-xl bg-base-200 mx-5">
|
||||
<Pin
|
||||
size={40}
|
||||
color={rawTheme[colorScheme as ThemeName].primary}
|
||||
/>
|
||||
<Text className="text-center text-xl text-neutral">
|
||||
No Pinned Links
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
case "COLLECTION":
|
||||
return collection?.id ? (
|
||||
<>
|
||||
<View className="flex-row justify-between items-center px-5">
|
||||
<View className="flex-row gap-2 items-center max-w-[60%]">
|
||||
<View className={clsx("flex-row items-center gap-2")}>
|
||||
<Folder
|
||||
size={30}
|
||||
fill={collection.color || "#0ea5e9"}
|
||||
color={collection.color || "#0ea5e9"}
|
||||
/>
|
||||
<Text
|
||||
className="text-2xl capitalize w-full text-base-content"
|
||||
numberOfLines={1}
|
||||
>
|
||||
{collection.name}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
<TouchableOpacity
|
||||
className="flex-row items-center text-sm gap-1 whitespace-nowrap"
|
||||
onPress={() =>
|
||||
router.navigate(
|
||||
`/(tabs)/dashboard/collection?collectionId=${collection.id}`
|
||||
)
|
||||
}
|
||||
>
|
||||
<Text className="text-primary">View All</Text>
|
||||
<ChevronRight
|
||||
size={15}
|
||||
color={rawTheme[colorScheme as ThemeName].primary}
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
{dashboardData.isLoading || collectionLinks.length > 0 ? (
|
||||
<FlatList
|
||||
horizontal
|
||||
showsHorizontalScrollIndicator={false}
|
||||
data={collectionLinks || []}
|
||||
refreshing={dashboardData.isLoading}
|
||||
initialNumToRender={2}
|
||||
keyExtractor={(item) => item.id?.toString() || ""}
|
||||
renderItem={({ item }) => (
|
||||
<RenderItem item={item} key={item.id?.toString()} />
|
||||
)}
|
||||
ItemSeparatorComponent={() => <View style={{ width: 10 }} />}
|
||||
contentContainerStyle={{
|
||||
paddingHorizontal: 20,
|
||||
}}
|
||||
onViewableItemsChanged={({
|
||||
viewableItems,
|
||||
}: {
|
||||
viewableItems: ViewToken[];
|
||||
}) => {
|
||||
const links = viewableItems.map(
|
||||
(e) => e.item
|
||||
) as LinkIncludingShortenedCollectionAndTags[];
|
||||
|
||||
if (
|
||||
!dashboardData.isRefetching &&
|
||||
links.some((e) => e.id && !e.preview)
|
||||
) {
|
||||
dashboardData.refetch();
|
||||
}
|
||||
}}
|
||||
viewabilityConfig={{ itemVisiblePercentThreshold: 50 }}
|
||||
/>
|
||||
) : (
|
||||
<View className="flex-col gap-2 justify-center items-center h-40 p-10 rounded-xl bg-base-200 mx-5">
|
||||
<Text className="text-center text-xl text-neutral">
|
||||
Empty Collection
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
</>
|
||||
) : null;
|
||||
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
export default DashboardSection;
|
||||
|
||||
const RenderItem = React.memo(
|
||||
({ item }: { item: LinkIncludingShortenedCollectionAndTags }) => {
|
||||
return <LinkListing link={item} dashboard />;
|
||||
}
|
||||
);
|
||||
18
apps/mobile/components/ElementNotSupported.tsx
Normal file
18
apps/mobile/components/ElementNotSupported.tsx
Normal file
@@ -0,0 +1,18 @@
|
||||
import { View, Text, Button } from "react-native";
|
||||
|
||||
export default function ElementNotSupported({
|
||||
message = "This element is currently not supported in this view.",
|
||||
buttonTitle = "Open original",
|
||||
onPress,
|
||||
}: {
|
||||
message?: string;
|
||||
buttonTitle?: string;
|
||||
onPress: () => void;
|
||||
}) {
|
||||
return (
|
||||
<View className="border-y border-neutral-content my-2 py-5 flex justify-center items-center">
|
||||
<Text className="text-neutral">{message}</Text>
|
||||
<Button onPress={onPress} title={buttonTitle} />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
136
apps/mobile/components/Formats/ImageFormat.tsx
Normal file
136
apps/mobile/components/Formats/ImageFormat.tsx
Normal file
@@ -0,0 +1,136 @@
|
||||
import React, { useEffect, useState } from "react";
|
||||
import * as FileSystem from "expo-file-system";
|
||||
import NetInfo from "@react-native-community/netinfo";
|
||||
import useAuthStore from "@/store/auth";
|
||||
import { ArchivedFormat } from "@linkwarden/types/global";
|
||||
import { Link as LinkType } from "@linkwarden/prisma/client";
|
||||
import WebView from "react-native-webview";
|
||||
import { Image, Platform, ScrollView } from "react-native";
|
||||
|
||||
type Props = {
|
||||
link: LinkType;
|
||||
setIsLoading: (state: boolean) => void;
|
||||
format: ArchivedFormat.png | ArchivedFormat.jpeg;
|
||||
};
|
||||
|
||||
export default function ImageFormat({ link, setIsLoading, format }: Props) {
|
||||
const FORMAT = format;
|
||||
|
||||
const extension = format === ArchivedFormat.png ? "png" : "jpeg";
|
||||
|
||||
const { auth } = useAuthStore();
|
||||
const [content, setContent] = useState<string>("");
|
||||
const [dimension, setDimension] = useState<{
|
||||
width: number;
|
||||
height: number;
|
||||
}>();
|
||||
|
||||
useEffect(() => {
|
||||
if (content)
|
||||
Image.getSize(content, (width, height) => {
|
||||
setDimension({ width, height });
|
||||
});
|
||||
}, [content]);
|
||||
|
||||
useEffect(() => {
|
||||
async function loadCacheOrFetch() {
|
||||
const filePath =
|
||||
FileSystem.documentDirectory +
|
||||
`archivedData/${extension}/link_${link.id}.${extension}`;
|
||||
|
||||
await FileSystem.makeDirectoryAsync(
|
||||
filePath.substring(0, filePath.lastIndexOf("/")),
|
||||
{
|
||||
intermediates: true,
|
||||
}
|
||||
).catch(() => {});
|
||||
|
||||
const [info] = await Promise.all([FileSystem.getInfoAsync(filePath)]);
|
||||
|
||||
if (info.exists) {
|
||||
setContent(filePath);
|
||||
}
|
||||
|
||||
const net = await NetInfo.fetch();
|
||||
|
||||
if (net.isConnected) {
|
||||
const apiUrl = `${auth.instance}/api/v1/archives/${link.id}?format=${FORMAT}`;
|
||||
|
||||
try {
|
||||
const result = await FileSystem.downloadAsync(apiUrl, filePath, {
|
||||
headers: { Authorization: `Bearer ${auth.session}` },
|
||||
});
|
||||
|
||||
setContent(result.uri);
|
||||
} catch (e) {
|
||||
console.error("Failed to fetch content", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
loadCacheOrFetch();
|
||||
}, [link]);
|
||||
|
||||
if (Platform.OS === "ios")
|
||||
return (
|
||||
content &&
|
||||
dimension && (
|
||||
<ScrollView maximumZoomScale={10}>
|
||||
<Image
|
||||
source={{ uri: content }}
|
||||
onLoadEnd={() => setIsLoading(false)}
|
||||
style={{
|
||||
width: "100%",
|
||||
height: "auto",
|
||||
aspectRatio: dimension.width / dimension.height,
|
||||
}}
|
||||
resizeMode="contain"
|
||||
/>
|
||||
</ScrollView>
|
||||
)
|
||||
);
|
||||
else
|
||||
return (
|
||||
content && (
|
||||
<WebView
|
||||
style={{
|
||||
flex: 1,
|
||||
}}
|
||||
source={{
|
||||
baseUrl: content,
|
||||
html: `
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<style>
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
img {
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<img src="${content}" />
|
||||
</body>
|
||||
</html>
|
||||
`,
|
||||
}}
|
||||
scalesPageToFit
|
||||
originWhitelist={["*"]}
|
||||
mixedContentMode="always"
|
||||
javaScriptEnabled={true}
|
||||
allowFileAccess={true}
|
||||
allowFileAccessFromFileURLs={true}
|
||||
allowUniversalAccessFromFileURLs={true}
|
||||
onLoadEnd={() => setIsLoading(false)}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
72
apps/mobile/components/Formats/PdfFormat.tsx
Normal file
72
apps/mobile/components/Formats/PdfFormat.tsx
Normal file
@@ -0,0 +1,72 @@
|
||||
import React, { useEffect, useState } from "react";
|
||||
import * as FileSystem from "expo-file-system";
|
||||
import NetInfo from "@react-native-community/netinfo";
|
||||
import useAuthStore from "@/store/auth";
|
||||
import { ArchivedFormat } from "@linkwarden/types/global";
|
||||
import { Link as LinkType } from "@linkwarden/prisma/client";
|
||||
import Pdf from "react-native-pdf";
|
||||
|
||||
type Props = {
|
||||
link: LinkType;
|
||||
setIsLoading: (state: boolean) => void;
|
||||
};
|
||||
|
||||
export default function PdfFormat({ link, setIsLoading }: Props) {
|
||||
const FORMAT = ArchivedFormat.pdf;
|
||||
|
||||
const { auth } = useAuthStore();
|
||||
const [content, setContent] = useState<string>("");
|
||||
|
||||
useEffect(() => {
|
||||
async function loadCacheOrFetch() {
|
||||
const filePath =
|
||||
FileSystem.documentDirectory + `archivedData/pdf/link_${link.id}.pdf`;
|
||||
|
||||
await FileSystem.makeDirectoryAsync(
|
||||
filePath.substring(0, filePath.lastIndexOf("/")),
|
||||
{
|
||||
intermediates: true,
|
||||
}
|
||||
).catch(() => {});
|
||||
|
||||
const [info] = await Promise.all([FileSystem.getInfoAsync(filePath)]);
|
||||
|
||||
if (info.exists) {
|
||||
setContent(filePath);
|
||||
}
|
||||
|
||||
const net = await NetInfo.fetch();
|
||||
|
||||
if (net.isConnected) {
|
||||
const apiUrl = `${auth.instance}/api/v1/archives/${link.id}?format=${FORMAT}`;
|
||||
|
||||
try {
|
||||
const result = await FileSystem.downloadAsync(apiUrl, filePath, {
|
||||
headers: { Authorization: `Bearer ${auth.session}` },
|
||||
});
|
||||
|
||||
setContent(result.uri);
|
||||
} catch (e) {
|
||||
console.error("Failed to fetch content", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
loadCacheOrFetch();
|
||||
}, [link]);
|
||||
|
||||
return (
|
||||
content && (
|
||||
<Pdf
|
||||
style={{
|
||||
flex: 1,
|
||||
}}
|
||||
source={{ uri: content }}
|
||||
onLoadComplete={() => setIsLoading(false)}
|
||||
onPressLink={(uri) => {
|
||||
console.log(`Link pressed: ${uri}`);
|
||||
}}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
139
apps/mobile/components/Formats/ReadableFormat.tsx
Normal file
139
apps/mobile/components/Formats/ReadableFormat.tsx
Normal file
@@ -0,0 +1,139 @@
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { View, Text, ScrollView, TouchableOpacity } from "react-native";
|
||||
import * as FileSystem from "expo-file-system";
|
||||
import NetInfo from "@react-native-community/netinfo";
|
||||
import useAuthStore from "@/store/auth";
|
||||
import { useRouter } from "expo-router";
|
||||
import { useWindowDimensions } from "react-native";
|
||||
import RenderHtml from "@linkwarden/react-native-render-html";
|
||||
import ElementNotSupported from "@/components/ElementNotSupported";
|
||||
import { decode } from "html-entities";
|
||||
import { useColorScheme } from "nativewind";
|
||||
import { rawTheme, ThemeName } from "@/lib/colors";
|
||||
import { CalendarDays, Link } from "lucide-react-native";
|
||||
import { ArchivedFormat } from "@linkwarden/types/global";
|
||||
import { Link as LinkType } from "@linkwarden/prisma/client";
|
||||
|
||||
type Props = {
|
||||
link: LinkType;
|
||||
setIsLoading: (state: boolean) => void;
|
||||
};
|
||||
|
||||
export default function ReadableFormat({ link, setIsLoading }: Props) {
|
||||
const FORMAT = ArchivedFormat.readability;
|
||||
|
||||
const { auth } = useAuthStore();
|
||||
const [content, setContent] = useState<string>("");
|
||||
const { width } = useWindowDimensions();
|
||||
const router = useRouter();
|
||||
const { colorScheme } = useColorScheme();
|
||||
|
||||
useEffect(() => {
|
||||
async function loadCacheOrFetch() {
|
||||
const filePath =
|
||||
FileSystem.documentDirectory +
|
||||
`archivedData/readable/link_${link.id}.html`;
|
||||
|
||||
await FileSystem.makeDirectoryAsync(
|
||||
filePath.substring(0, filePath.lastIndexOf("/")),
|
||||
{
|
||||
intermediates: true,
|
||||
}
|
||||
).catch(() => {});
|
||||
|
||||
const [info] = await Promise.all([FileSystem.getInfoAsync(filePath)]);
|
||||
|
||||
if (info.exists) {
|
||||
const rawContent = await FileSystem.readAsStringAsync(filePath);
|
||||
setContent(rawContent);
|
||||
}
|
||||
|
||||
const net = await NetInfo.fetch();
|
||||
|
||||
if (net.isConnected) {
|
||||
const apiUrl = `${auth.instance}/api/v1/archives/${link.id}?format=${FORMAT}`;
|
||||
|
||||
try {
|
||||
const response = await fetch(apiUrl, {
|
||||
headers: { Authorization: `Bearer ${auth.session}` },
|
||||
});
|
||||
|
||||
const data = (await response.json()).content;
|
||||
setContent(data);
|
||||
await FileSystem.writeAsStringAsync(filePath, data, {
|
||||
encoding: FileSystem.EncodingType.UTF8,
|
||||
});
|
||||
} catch (e) {
|
||||
console.error("Failed to fetch content", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
loadCacheOrFetch();
|
||||
}, [link]);
|
||||
|
||||
return (
|
||||
content && (
|
||||
<ScrollView
|
||||
className="flex-1 bg-base-100"
|
||||
contentContainerClassName="p-4"
|
||||
nestedScrollEnabled
|
||||
>
|
||||
<Text className="text-2xl font-bold mb-2.5 text-base-content">
|
||||
{decode(link.name || link.description || link.url || "")}
|
||||
</Text>
|
||||
|
||||
<TouchableOpacity
|
||||
className="flex-row items-center gap-1 mb-2.5 pr-5"
|
||||
onPress={() => router.replace(`/links/${link.id}`)}
|
||||
>
|
||||
<Link
|
||||
size={16}
|
||||
color={rawTheme[colorScheme as ThemeName]["neutral"]}
|
||||
/>
|
||||
<Text className="text-base text-neutral flex-1" numberOfLines={1}>
|
||||
{link.url}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
|
||||
<View className="flex-row items-center gap-1 mb-2.5">
|
||||
<CalendarDays
|
||||
size={16}
|
||||
color={rawTheme[colorScheme as ThemeName]["neutral"]}
|
||||
/>
|
||||
<Text className="text-base text-neutral">
|
||||
{new Date(link?.importDate || link.createdAt).toLocaleString(
|
||||
"en-US",
|
||||
{
|
||||
year: "numeric",
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
}
|
||||
)}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<View className="border-t border-neutral-content mt-2.5 mb-5" />
|
||||
|
||||
<RenderHtml
|
||||
contentWidth={width}
|
||||
source={{ html: content }}
|
||||
renderers={{
|
||||
table: () => (
|
||||
<ElementNotSupported
|
||||
onPress={() => router.replace(`/links/${link.id}`)}
|
||||
/>
|
||||
),
|
||||
}}
|
||||
onHTMLLoaded={() => setIsLoading(false)}
|
||||
tagsStyles={{
|
||||
p: { fontSize: 18, lineHeight: 28, marginVertical: 10 },
|
||||
}}
|
||||
baseStyle={{
|
||||
color: rawTheme[colorScheme as ThemeName]["base-content"],
|
||||
}}
|
||||
/>
|
||||
</ScrollView>
|
||||
)
|
||||
);
|
||||
}
|
||||
80
apps/mobile/components/Formats/WebpageFormat.tsx
Normal file
80
apps/mobile/components/Formats/WebpageFormat.tsx
Normal file
@@ -0,0 +1,80 @@
|
||||
import React, { useEffect, useState } from "react";
|
||||
import * as FileSystem from "expo-file-system";
|
||||
import NetInfo from "@react-native-community/netinfo";
|
||||
import useAuthStore from "@/store/auth";
|
||||
import { ArchivedFormat } from "@linkwarden/types/global";
|
||||
import { Link as LinkType } from "@linkwarden/prisma/client";
|
||||
import WebView from "react-native-webview";
|
||||
|
||||
type Props = {
|
||||
link: LinkType;
|
||||
setIsLoading: (state: boolean) => void;
|
||||
};
|
||||
|
||||
export default function WebpageFormat({ link, setIsLoading }: Props) {
|
||||
const FORMAT = ArchivedFormat.monolith;
|
||||
|
||||
const { auth } = useAuthStore();
|
||||
const [content, setContent] = useState<string>("");
|
||||
|
||||
useEffect(() => {
|
||||
async function loadCacheOrFetch() {
|
||||
const filePath =
|
||||
FileSystem.documentDirectory +
|
||||
`archivedData/webpage/link_${link.id}.html`;
|
||||
|
||||
await FileSystem.makeDirectoryAsync(
|
||||
filePath.substring(0, filePath.lastIndexOf("/")),
|
||||
{
|
||||
intermediates: true,
|
||||
}
|
||||
).catch(() => {});
|
||||
|
||||
const [info] = await Promise.all([FileSystem.getInfoAsync(filePath)]);
|
||||
|
||||
if (info.exists) {
|
||||
setContent(filePath);
|
||||
}
|
||||
|
||||
const net = await NetInfo.fetch();
|
||||
|
||||
if (net.isConnected) {
|
||||
const apiUrl = `${auth.instance}/api/v1/archives/${link.id}?format=${FORMAT}`;
|
||||
|
||||
try {
|
||||
const result = await FileSystem.downloadAsync(apiUrl, filePath, {
|
||||
headers: { Authorization: `Bearer ${auth.session}` },
|
||||
});
|
||||
|
||||
setContent(result.uri);
|
||||
} catch (e) {
|
||||
console.error("Failed to fetch content", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
loadCacheOrFetch();
|
||||
}, [link]);
|
||||
|
||||
return (
|
||||
content && (
|
||||
<WebView
|
||||
style={{
|
||||
flex: 1,
|
||||
}}
|
||||
source={{
|
||||
uri: content,
|
||||
baseUrl: FileSystem.documentDirectory,
|
||||
}}
|
||||
scalesPageToFit
|
||||
originWhitelist={["*"]}
|
||||
mixedContentMode="always"
|
||||
javaScriptEnabled={true}
|
||||
allowFileAccess={true}
|
||||
allowFileAccessFromFileURLs={true}
|
||||
allowUniversalAccessFromFileURLs={true}
|
||||
onLoadEnd={() => setIsLoading(false)}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
18
apps/mobile/components/HapticTab.tsx
Normal file
18
apps/mobile/components/HapticTab.tsx
Normal file
@@ -0,0 +1,18 @@
|
||||
import { BottomTabBarButtonProps } from "@react-navigation/bottom-tabs";
|
||||
import { PlatformPressable } from "@react-navigation/elements";
|
||||
import * as Haptics from "expo-haptics";
|
||||
|
||||
export default function HapticTab(props: BottomTabBarButtonProps) {
|
||||
return (
|
||||
<PlatformPressable
|
||||
{...props}
|
||||
onPressIn={(ev) => {
|
||||
if (process.env.EXPO_OS === "ios") {
|
||||
// Add a soft haptic feedback when pressing down on the tabs.
|
||||
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
|
||||
}
|
||||
props.onPressIn?.(ev);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
339
apps/mobile/components/LinkListing.tsx
Normal file
339
apps/mobile/components/LinkListing.tsx
Normal file
@@ -0,0 +1,339 @@
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
Image,
|
||||
Pressable,
|
||||
Platform,
|
||||
Alert,
|
||||
ActivityIndicator,
|
||||
Linking,
|
||||
} from "react-native";
|
||||
import { decode } from "html-entities";
|
||||
import { LinkIncludingShortenedCollectionAndTags } from "@linkwarden/types/global";
|
||||
import { ArchivedFormat } from "@linkwarden/types/global";
|
||||
import getFormatBasedOnPreference from "@linkwarden/lib/getFormatBasedOnPreference";
|
||||
import getOriginalFormat from "@linkwarden/lib/getOriginalFormat";
|
||||
import {
|
||||
atLeastOneFormatAvailable,
|
||||
formatAvailable,
|
||||
} from "@linkwarden/lib/formatStats";
|
||||
import useAuthStore from "@/store/auth";
|
||||
import { useRouter } from "expo-router";
|
||||
import * as ContextMenu from "zeego/context-menu";
|
||||
import { useDeleteLink, useUpdateLink } from "@linkwarden/router/links";
|
||||
import { SheetManager } from "react-native-actions-sheet";
|
||||
import * as Clipboard from "expo-clipboard";
|
||||
import { cn } from "@linkwarden/lib/utils";
|
||||
import { useUser } from "@linkwarden/router/user";
|
||||
import { rawTheme, ThemeName } from "@/lib/colors";
|
||||
import { useColorScheme } from "nativewind";
|
||||
import { CalendarDays, Folder } from "lucide-react-native";
|
||||
import useDataStore from "@/store/data";
|
||||
import { useEffect, useState } from "react";
|
||||
import { deleteLinkCache } from "@/lib/cache";
|
||||
|
||||
type Props = {
|
||||
link: LinkIncludingShortenedCollectionAndTags;
|
||||
dashboard?: boolean;
|
||||
};
|
||||
|
||||
const LinkListing = ({ link, dashboard }: Props) => {
|
||||
const { auth } = useAuthStore();
|
||||
const router = useRouter();
|
||||
const updateLink = useUpdateLink({ auth, Alert });
|
||||
const { data: user } = useUser(auth);
|
||||
const { colorScheme } = useColorScheme();
|
||||
const { data } = useDataStore();
|
||||
|
||||
const deleteLink = useDeleteLink({ auth, Alert });
|
||||
|
||||
const [url, setUrl] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
try {
|
||||
if (link.url) {
|
||||
setUrl(new URL(link.url).host.toLowerCase());
|
||||
}
|
||||
} catch (error) {
|
||||
console.log(error);
|
||||
}
|
||||
}, [link.url]);
|
||||
|
||||
return (
|
||||
<ContextMenu.Root>
|
||||
<ContextMenu.Trigger asChild>
|
||||
<Pressable
|
||||
className={cn(
|
||||
"p-5 flex-row justify-between",
|
||||
dashboard ? "bg-base-200" : "bg-base-100",
|
||||
Platform.OS !== "android" && "active:bg-base-200/50",
|
||||
dashboard && "rounded-xl"
|
||||
)}
|
||||
onLongPress={() => {}}
|
||||
onPress={() => {
|
||||
if (user) {
|
||||
const format = getFormatBasedOnPreference({
|
||||
link,
|
||||
preference: user.linksRouteTo,
|
||||
});
|
||||
|
||||
data.preferredBrowser === "app"
|
||||
? router.navigate(
|
||||
format !== null
|
||||
? `/links/${link.id}?format=${format}`
|
||||
: `/links/${link.id}`
|
||||
)
|
||||
: Linking.openURL(
|
||||
format !== null
|
||||
? auth.instance +
|
||||
`/preserved/${link?.id}?format=${format}`
|
||||
: (link.url as string)
|
||||
);
|
||||
}
|
||||
}}
|
||||
android_ripple={{
|
||||
color: colorScheme === "dark" ? "rgba(255,255,255,0.2)" : "#ddd",
|
||||
borderless: false,
|
||||
}}
|
||||
>
|
||||
<View
|
||||
className={cn(
|
||||
"flex-row justify-between",
|
||||
dashboard ? "w-80" : "w-full"
|
||||
)}
|
||||
>
|
||||
<View className="w-[65%] flex-col justify-between">
|
||||
<Text
|
||||
numberOfLines={2}
|
||||
className="font-medium text-lg text-base-content"
|
||||
>
|
||||
{decode(link.name || link.description || link.url)}
|
||||
</Text>
|
||||
|
||||
{url && (
|
||||
<Text
|
||||
numberOfLines={1}
|
||||
className="mt-1.5 font-light text-sm text-base-content"
|
||||
>
|
||||
{url}
|
||||
</Text>
|
||||
)}
|
||||
|
||||
<View className="flex flex-row gap-1 items-center mt-1.5 pr-1.5 self-start rounded-md">
|
||||
<Folder
|
||||
size={16}
|
||||
fill={link.collection.color || "#0ea5e9"}
|
||||
color={link.collection.color || "#0ea5e9"}
|
||||
/>
|
||||
<Text
|
||||
numberOfLines={1}
|
||||
className="font-light text-xs text-base-content"
|
||||
>
|
||||
{link.collection.name}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View className="flex-col items-end">
|
||||
<View className="rounded-lg overflow-hidden relative">
|
||||
{formatAvailable(link, "preview") ? (
|
||||
<Image
|
||||
source={{
|
||||
uri: `${auth.instance}/api/v1/archives/${link.id}?format=${ArchivedFormat.jpeg}&preview=true&updatedAt=${link.updatedAt}`,
|
||||
headers: {
|
||||
Authorization: `Bearer ${auth.session}`,
|
||||
},
|
||||
}}
|
||||
className="rounded-md h-[60px] w-[90px] object-cover scale-105"
|
||||
/>
|
||||
) : !link.preview ? (
|
||||
<ActivityIndicator
|
||||
size="small"
|
||||
className="h-[60px] w-[90px]"
|
||||
/>
|
||||
) : (
|
||||
<View className="h-[60px] w-[90px]" />
|
||||
)}
|
||||
</View>
|
||||
<View className="flex flex-row gap-1 items-center mt-5 self-start">
|
||||
<CalendarDays
|
||||
size={16}
|
||||
color={rawTheme[colorScheme as ThemeName]["neutral"]}
|
||||
/>
|
||||
<Text
|
||||
numberOfLines={1}
|
||||
className="font-light text-xs text-base-content"
|
||||
>
|
||||
{new Date(link.createdAt as string).toLocaleString("en-US", {
|
||||
year: "numeric",
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
})}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
</Pressable>
|
||||
</ContextMenu.Trigger>
|
||||
|
||||
<ContextMenu.Content avoidCollisions>
|
||||
<ContextMenu.Item
|
||||
key="open-original"
|
||||
onSelect={() => {
|
||||
if (link) {
|
||||
const format = getOriginalFormat(link);
|
||||
|
||||
data.preferredBrowser === "app"
|
||||
? router.navigate(
|
||||
format !== null
|
||||
? `/links/${link.id}?format=${format}`
|
||||
: `/links/${link.id}`
|
||||
)
|
||||
: Linking.openURL(
|
||||
format !== null
|
||||
? auth.instance +
|
||||
`/preserved/${link?.id}?format=${format}`
|
||||
: (link.url as string)
|
||||
);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<ContextMenu.ItemTitle>Open Original</ContextMenu.ItemTitle>
|
||||
</ContextMenu.Item>
|
||||
{link?.url && (
|
||||
<>
|
||||
<ContextMenu.Item
|
||||
key="copy-url"
|
||||
onSelect={async () => {
|
||||
await Clipboard.setStringAsync(link.url as string);
|
||||
}}
|
||||
>
|
||||
<ContextMenu.ItemTitle>Copy URL</ContextMenu.ItemTitle>
|
||||
</ContextMenu.Item>
|
||||
</>
|
||||
)}
|
||||
|
||||
<ContextMenu.Item
|
||||
key="pin-link"
|
||||
onSelect={() => {
|
||||
const isAlreadyPinned =
|
||||
link?.pinnedBy && link.pinnedBy[0] ? true : false;
|
||||
|
||||
updateLink.mutateAsync({
|
||||
...link,
|
||||
pinnedBy: (isAlreadyPinned
|
||||
? [{ id: undefined }]
|
||||
: [{ id: user?.id }]) as any,
|
||||
});
|
||||
}}
|
||||
>
|
||||
<ContextMenu.ItemTitle>
|
||||
{link.pinnedBy && link.pinnedBy[0] ? "Unpin Link" : "Pin Link"}
|
||||
</ContextMenu.ItemTitle>
|
||||
</ContextMenu.Item>
|
||||
|
||||
<ContextMenu.Item
|
||||
key="edit-link"
|
||||
onSelect={() => {
|
||||
SheetManager.show("edit-link-sheet", {
|
||||
payload: { link: link },
|
||||
});
|
||||
}}
|
||||
>
|
||||
<ContextMenu.ItemTitle>Edit Link</ContextMenu.ItemTitle>
|
||||
</ContextMenu.Item>
|
||||
|
||||
{link.url && atLeastOneFormatAvailable(link) && (
|
||||
<ContextMenu.Sub>
|
||||
<ContextMenu.SubTrigger key="preserved-formats">
|
||||
<ContextMenu.ItemTitle>Preserved Formats</ContextMenu.ItemTitle>
|
||||
</ContextMenu.SubTrigger>
|
||||
<ContextMenu.SubContent>
|
||||
{formatAvailable(link, "monolith") && (
|
||||
<ContextMenu.Item
|
||||
key="preserved-formats-webpage"
|
||||
onSelect={() =>
|
||||
router.navigate(
|
||||
`/links/${link.id}?format=${ArchivedFormat.monolith}`
|
||||
)
|
||||
}
|
||||
>
|
||||
<ContextMenu.ItemTitle>Webpage</ContextMenu.ItemTitle>
|
||||
</ContextMenu.Item>
|
||||
)}
|
||||
{formatAvailable(link, "image") && (
|
||||
<ContextMenu.Item
|
||||
key="preserved-formats-screenshot"
|
||||
onSelect={() =>
|
||||
router.navigate(
|
||||
`/links/${link.id}?format=${
|
||||
link.image?.endsWith(".png")
|
||||
? ArchivedFormat.png
|
||||
: ArchivedFormat.jpeg
|
||||
}`
|
||||
)
|
||||
}
|
||||
>
|
||||
<ContextMenu.ItemTitle>Screenshot</ContextMenu.ItemTitle>
|
||||
</ContextMenu.Item>
|
||||
)}
|
||||
{formatAvailable(link, "pdf") && (
|
||||
<ContextMenu.Item
|
||||
key="preserved-formats-pdf"
|
||||
onSelect={() =>
|
||||
router.navigate(
|
||||
`/links/${link.id}?format=${ArchivedFormat.pdf}`
|
||||
)
|
||||
}
|
||||
>
|
||||
<ContextMenu.ItemTitle>PDF</ContextMenu.ItemTitle>
|
||||
</ContextMenu.Item>
|
||||
)}
|
||||
{formatAvailable(link, "readable") && (
|
||||
<ContextMenu.Item
|
||||
key="preserved-formats-readable"
|
||||
onSelect={() =>
|
||||
router.navigate(
|
||||
`/links/${link.id}?format=${ArchivedFormat.readability}`
|
||||
)
|
||||
}
|
||||
>
|
||||
<ContextMenu.ItemTitle>Readable</ContextMenu.ItemTitle>
|
||||
</ContextMenu.Item>
|
||||
)}
|
||||
</ContextMenu.SubContent>
|
||||
</ContextMenu.Sub>
|
||||
)}
|
||||
|
||||
<ContextMenu.Item
|
||||
key="delete-link"
|
||||
onSelect={() => {
|
||||
return Alert.alert(
|
||||
"Delete Link",
|
||||
"Are you sure you want to delete this link? This action cannot be undone.",
|
||||
[
|
||||
{
|
||||
text: "Cancel",
|
||||
style: "cancel",
|
||||
},
|
||||
{
|
||||
text: "Delete",
|
||||
style: "destructive",
|
||||
onPress: async () => {
|
||||
deleteLink.mutate(link.id as number);
|
||||
|
||||
await deleteLinkCache(link.id as number);
|
||||
},
|
||||
},
|
||||
]
|
||||
);
|
||||
}}
|
||||
>
|
||||
<ContextMenu.ItemTitle>Delete</ContextMenu.ItemTitle>
|
||||
</ContextMenu.Item>
|
||||
</ContextMenu.Content>
|
||||
</ContextMenu.Root>
|
||||
);
|
||||
};
|
||||
|
||||
export default LinkListing;
|
||||
87
apps/mobile/components/Links.tsx
Normal file
87
apps/mobile/components/Links.tsx
Normal file
@@ -0,0 +1,87 @@
|
||||
import {
|
||||
View,
|
||||
FlatList,
|
||||
Text,
|
||||
ActivityIndicator,
|
||||
ViewToken,
|
||||
} from "react-native";
|
||||
import LinkListing from "@/components/LinkListing";
|
||||
import React, { useState } from "react";
|
||||
import { LinkIncludingShortenedCollectionAndTags } from "@linkwarden/types/global";
|
||||
import Spinner from "@/components/ui/Spinner";
|
||||
import { rawTheme, ThemeName } from "@/lib/colors";
|
||||
import { useColorScheme } from "nativewind";
|
||||
|
||||
const RenderItem = React.memo(
|
||||
({ item }: { item: LinkIncludingShortenedCollectionAndTags }) => {
|
||||
return <LinkListing link={item} />;
|
||||
}
|
||||
);
|
||||
|
||||
type Props = {
|
||||
links: LinkIncludingShortenedCollectionAndTags[];
|
||||
data: any;
|
||||
};
|
||||
|
||||
export default function Links({ links, data }: Props) {
|
||||
const { colorScheme } = useColorScheme();
|
||||
const [promptedRefetch, setPromptedRefetch] = useState(false);
|
||||
|
||||
return data.isLoading ? (
|
||||
<View className="flex justify-center h-screen items-center">
|
||||
<ActivityIndicator size="large" />
|
||||
<Text className="text-base mt-2.5 text-neutral">Loading...</Text>
|
||||
</View>
|
||||
) : (
|
||||
<FlatList
|
||||
contentInsetAdjustmentBehavior="automatic"
|
||||
ListHeaderComponent={() => <></>}
|
||||
data={links || []}
|
||||
refreshControl={
|
||||
<Spinner
|
||||
refreshing={data.isRefetching && promptedRefetch}
|
||||
onRefresh={async () => {
|
||||
setPromptedRefetch(true);
|
||||
await data.refetch();
|
||||
setPromptedRefetch(false);
|
||||
}}
|
||||
progressBackgroundColor={
|
||||
rawTheme[colorScheme as ThemeName]["base-200"]
|
||||
}
|
||||
colors={[rawTheme[colorScheme as ThemeName]["base-content"]]}
|
||||
/>
|
||||
}
|
||||
refreshing={data.isRefetching && promptedRefetch}
|
||||
initialNumToRender={4}
|
||||
keyExtractor={(item) => item.id?.toString() || ""}
|
||||
renderItem={({ item }) => (
|
||||
<RenderItem item={item} key={item.id?.toString()} />
|
||||
)}
|
||||
onEndReached={() => data.fetchNextPage()}
|
||||
onEndReachedThreshold={0.5}
|
||||
ItemSeparatorComponent={() => (
|
||||
<View className="bg-neutral-content h-px" />
|
||||
)}
|
||||
ListEmptyComponent={
|
||||
<View className="flex justify-center py-10 items-center">
|
||||
<Text className="text-center text-xl text-neutral">
|
||||
Nothing found...
|
||||
</Text>
|
||||
</View>
|
||||
}
|
||||
onViewableItemsChanged={({
|
||||
viewableItems,
|
||||
}: {
|
||||
viewableItems: ViewToken[];
|
||||
}) => {
|
||||
const links = viewableItems.map(
|
||||
(e) => e.item
|
||||
) as LinkIncludingShortenedCollectionAndTags[];
|
||||
|
||||
if (!data.isRefetching && links.some((e) => e.id && !e.preview))
|
||||
data.refetch();
|
||||
}}
|
||||
viewabilityConfig={{ itemVisiblePercentThreshold: 50 }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
120
apps/mobile/components/TagListing.tsx
Normal file
120
apps/mobile/components/TagListing.tsx
Normal file
@@ -0,0 +1,120 @@
|
||||
import { View, Text, Pressable, Platform, Alert } from "react-native";
|
||||
import { decode } from "html-entities";
|
||||
import { TagIncludingLinkCount } from "@linkwarden/types/global";
|
||||
import useAuthStore from "@/store/auth";
|
||||
import { useRouter } from "expo-router";
|
||||
import * as ContextMenu from "zeego/context-menu";
|
||||
import { cn } from "@linkwarden/lib/utils";
|
||||
import { rawTheme, ThemeName } from "@/lib/colors";
|
||||
import { useColorScheme } from "nativewind";
|
||||
import { CalendarDays, Hash, Link } from "lucide-react-native";
|
||||
import { useRemoveTag } from "@linkwarden/router/tags";
|
||||
|
||||
type Props = {
|
||||
tag: TagIncludingLinkCount;
|
||||
};
|
||||
|
||||
const TagListing = ({ tag }: Props) => {
|
||||
const { auth } = useAuthStore();
|
||||
const router = useRouter();
|
||||
const { colorScheme } = useColorScheme();
|
||||
|
||||
const deleteCollection = useRemoveTag(auth);
|
||||
|
||||
return (
|
||||
<ContextMenu.Root>
|
||||
<ContextMenu.Trigger asChild>
|
||||
<Pressable
|
||||
className={cn(
|
||||
"p-5 flex-row justify-between",
|
||||
"bg-base-100",
|
||||
Platform.OS !== "android" && "active:bg-base-200/50"
|
||||
)}
|
||||
onLongPress={() => {}}
|
||||
onPress={() => router.navigate(`/tags/${tag.id}`)}
|
||||
android_ripple={{
|
||||
color: colorScheme === "dark" ? "rgba(255,255,255,0.2)" : "#ddd",
|
||||
borderless: false,
|
||||
}}
|
||||
>
|
||||
<View className="w-full">
|
||||
<View className="w-[90%] flex-col justify-between gap-3">
|
||||
<View className="flex flex-row gap-2 items-center pr-1.5 self-start rounded-md">
|
||||
<Hash
|
||||
size={16}
|
||||
color={rawTheme[colorScheme as ThemeName]["primary"]}
|
||||
/>
|
||||
<Text
|
||||
numberOfLines={2}
|
||||
className="font-medium text-lg text-base-content"
|
||||
>
|
||||
{decode(tag.name)}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View className="flex-row gap-3">
|
||||
<View className="flex flex-row gap-1 items-center mt-5 self-start">
|
||||
<CalendarDays
|
||||
size={16}
|
||||
color={rawTheme[colorScheme as ThemeName]["neutral"]}
|
||||
/>
|
||||
<Text
|
||||
numberOfLines={1}
|
||||
className="font-light text-xs text-base-content"
|
||||
>
|
||||
{new Date(tag.createdAt).toLocaleString("en-US", {
|
||||
year: "numeric",
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
})}
|
||||
</Text>
|
||||
</View>
|
||||
<View className="flex flex-row gap-1 items-center mt-5 self-start">
|
||||
<Link
|
||||
size={16}
|
||||
color={rawTheme[colorScheme as ThemeName]["neutral"]}
|
||||
/>
|
||||
<Text
|
||||
numberOfLines={1}
|
||||
className="font-light text-xs text-base-content"
|
||||
>
|
||||
{tag._count?.links}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
</Pressable>
|
||||
</ContextMenu.Trigger>
|
||||
|
||||
<ContextMenu.Content avoidCollisions>
|
||||
<ContextMenu.Item
|
||||
key="delete-tag"
|
||||
onSelect={() => {
|
||||
return Alert.alert(
|
||||
"Delete Tag",
|
||||
"Are you sure you want to delete this Tag? This action cannot be undone.",
|
||||
[
|
||||
{
|
||||
text: "Cancel",
|
||||
style: "cancel",
|
||||
},
|
||||
{
|
||||
text: "Delete",
|
||||
style: "destructive",
|
||||
onPress: () => {
|
||||
deleteCollection.mutate(tag.id as number);
|
||||
},
|
||||
},
|
||||
]
|
||||
);
|
||||
}}
|
||||
>
|
||||
<ContextMenu.ItemTitle>Delete</ContextMenu.ItemTitle>
|
||||
</ContextMenu.Item>
|
||||
</ContextMenu.Content>
|
||||
</ContextMenu.Root>
|
||||
);
|
||||
};
|
||||
|
||||
export default TagListing;
|
||||
84
apps/mobile/components/ui/Button.tsx
Normal file
84
apps/mobile/components/ui/Button.tsx
Normal file
@@ -0,0 +1,84 @@
|
||||
import React, { forwardRef } from "react";
|
||||
import {
|
||||
TouchableOpacity,
|
||||
Text,
|
||||
View,
|
||||
ActivityIndicator,
|
||||
type TouchableOpacityProps,
|
||||
} from "react-native";
|
||||
import { cva, type VariantProps } from "class-variance-authority";
|
||||
import { cn } from "@linkwarden/lib/utils";
|
||||
|
||||
const buttonVariants = cva(
|
||||
"inline-flex items-center justify-center gap-2 rounded-lg disabled:opacity-50 disabled:pointer-events-none",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-slate-500 text-white",
|
||||
primary: "bg-primary text-base-content",
|
||||
accent: "bg-accent border border-violet-400 text-white",
|
||||
destructive: "bg-destructive text-white",
|
||||
outline: "border border-base-content",
|
||||
secondary: "bg-secondary text-secondary-foreground",
|
||||
input:
|
||||
"bg-base-100 rounded-lg px-4 justify-between flex-row font-normal",
|
||||
metal: "bg-neutral-content text-base-content border border-neutral/30",
|
||||
ghost: "",
|
||||
simple: "bg-base-200",
|
||||
link: "text-primary underline-offset-",
|
||||
},
|
||||
size: {
|
||||
default: "h-10 px-4 py-2",
|
||||
sm: "h-8 px-2 py-1 text-xs",
|
||||
lg: "h-12 px-8",
|
||||
full: "w-full px-4 py-2",
|
||||
icon: "h-8 w-8",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
size: "default",
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
export type ButtonProps = TouchableOpacityProps &
|
||||
VariantProps<typeof buttonVariants> & {
|
||||
isLoading?: boolean;
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export const Button = forwardRef<
|
||||
React.ElementRef<typeof TouchableOpacity>,
|
||||
ButtonProps
|
||||
>(
|
||||
(
|
||||
{
|
||||
variant = "default",
|
||||
size,
|
||||
className,
|
||||
isLoading = false,
|
||||
children,
|
||||
disabled,
|
||||
...props
|
||||
},
|
||||
ref
|
||||
) => {
|
||||
const combinedClasses = cn(buttonVariants({ variant, size }), className);
|
||||
|
||||
return (
|
||||
<TouchableOpacity
|
||||
ref={ref}
|
||||
className={combinedClasses}
|
||||
activeOpacity={0.8}
|
||||
disabled={disabled || isLoading}
|
||||
{...props}
|
||||
>
|
||||
{isLoading ? <ActivityIndicator /> : children}
|
||||
</TouchableOpacity>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
Button.displayName = "Button";
|
||||
32
apps/mobile/components/ui/IconSymbol.ios.tsx
Normal file
32
apps/mobile/components/ui/IconSymbol.ios.tsx
Normal file
@@ -0,0 +1,32 @@
|
||||
import { SymbolView, SymbolViewProps, SymbolWeight } from "expo-symbols";
|
||||
import { StyleProp, ViewStyle } from "react-native";
|
||||
|
||||
export function IconSymbol({
|
||||
name,
|
||||
size = 24,
|
||||
color,
|
||||
style,
|
||||
weight = "regular",
|
||||
}: {
|
||||
name: SymbolViewProps["name"];
|
||||
size?: number;
|
||||
color: string;
|
||||
style?: StyleProp<ViewStyle>;
|
||||
weight?: SymbolWeight;
|
||||
}) {
|
||||
return (
|
||||
<SymbolView
|
||||
weight={weight}
|
||||
tintColor={color}
|
||||
resizeMode="scaleAspectFit"
|
||||
name={name}
|
||||
style={[
|
||||
{
|
||||
width: size,
|
||||
height: size,
|
||||
},
|
||||
style,
|
||||
]}
|
||||
/>
|
||||
);
|
||||
}
|
||||
50
apps/mobile/components/ui/IconSymbol.tsx
Normal file
50
apps/mobile/components/ui/IconSymbol.tsx
Normal file
@@ -0,0 +1,50 @@
|
||||
// This file is a fallback for using MaterialIcons on Android and web.
|
||||
|
||||
import MaterialIcons from "@expo/vector-icons/MaterialIcons";
|
||||
import { SymbolWeight } from "expo-symbols";
|
||||
import React from "react";
|
||||
import { OpaqueColorValue, StyleProp, TextStyle } from "react-native";
|
||||
|
||||
// Add your SFSymbol to MaterialIcons mappings here.
|
||||
const MAPPING = {
|
||||
// See MaterialIcons here: https://icons.expo.fyi
|
||||
// See SF Symbols in the SF Symbols app on Mac.
|
||||
"house.fill": "home",
|
||||
"paperplane.fill": "send",
|
||||
"chevron.left.forwardslash.chevron.right": "code",
|
||||
"chevron.right": "chevron-right",
|
||||
} as Partial<
|
||||
Record<
|
||||
import("expo-symbols").SymbolViewProps["name"],
|
||||
React.ComponentProps<typeof MaterialIcons>["name"]
|
||||
>
|
||||
>;
|
||||
|
||||
export type IconSymbolName = keyof typeof MAPPING;
|
||||
|
||||
/**
|
||||
* An icon component that uses native SFSymbols on iOS, and MaterialIcons on Android and web. This ensures a consistent look across platforms, and optimal resource usage.
|
||||
*
|
||||
* Icon `name`s are based on SFSymbols and require manual mapping to MaterialIcons.
|
||||
*/
|
||||
export function IconSymbol({
|
||||
name,
|
||||
size = 24,
|
||||
color,
|
||||
style,
|
||||
}: {
|
||||
name: IconSymbolName;
|
||||
size?: number;
|
||||
color: string | OpaqueColorValue;
|
||||
style?: StyleProp<TextStyle>;
|
||||
weight?: SymbolWeight;
|
||||
}) {
|
||||
return (
|
||||
<MaterialIcons
|
||||
color={color}
|
||||
size={size}
|
||||
name={MAPPING[name]}
|
||||
style={style}
|
||||
/>
|
||||
);
|
||||
}
|
||||
21
apps/mobile/components/ui/Icons.tsx
Normal file
21
apps/mobile/components/ui/Icons.tsx
Normal file
@@ -0,0 +1,21 @@
|
||||
import React from "react";
|
||||
import Svg, { Path, Circle, SvgProps } from "react-native-svg";
|
||||
|
||||
export const Chromium = (props: SvgProps) => (
|
||||
<Svg
|
||||
width={21}
|
||||
height={21}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
strokeWidth={2}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
{...props}
|
||||
>
|
||||
<Path d="M10.88 21.94 15.46 14" />
|
||||
<Path d="M21.17 8H12" />
|
||||
<Path d="M3.95 6.06 8.54 14" />
|
||||
<Circle cx={12} cy={12} r={10} />
|
||||
<Circle cx={12} cy={12} r={4} />
|
||||
</Svg>
|
||||
);
|
||||
20
apps/mobile/components/ui/Input.tsx
Normal file
20
apps/mobile/components/ui/Input.tsx
Normal file
@@ -0,0 +1,20 @@
|
||||
import React, { forwardRef } from "react";
|
||||
import { TextInput, TextInputProps } from "react-native";
|
||||
import { cn } from "@linkwarden/lib/utils";
|
||||
|
||||
const Input = forwardRef<TextInput, TextInputProps>(
|
||||
({ className, ...props }, ref) => {
|
||||
return (
|
||||
<TextInput
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"bg-base-200 text-base-content rounded-lg px-4 py-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
export default Input;
|
||||
10
apps/mobile/components/ui/Spinner.tsx
Normal file
10
apps/mobile/components/ui/Spinner.tsx
Normal file
@@ -0,0 +1,10 @@
|
||||
import React, { forwardRef } from "react";
|
||||
import { RefreshControl, RefreshControlProps } from "react-native";
|
||||
|
||||
const Spinner = forwardRef<RefreshControl, RefreshControlProps>(
|
||||
(props, ref) => {
|
||||
return <RefreshControl ref={ref} {...props} />;
|
||||
}
|
||||
);
|
||||
|
||||
export default Spinner;
|
||||
22
apps/mobile/components/ui/TabBarBackground.ios.tsx
Normal file
22
apps/mobile/components/ui/TabBarBackground.ios.tsx
Normal file
@@ -0,0 +1,22 @@
|
||||
import { useBottomTabBarHeight } from "@react-navigation/bottom-tabs";
|
||||
import { BlurView } from "expo-blur";
|
||||
import { StyleSheet } from "react-native";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
|
||||
export default function BlurTabBarBackground() {
|
||||
return (
|
||||
<BlurView
|
||||
// System chrome material automatically adapts to the system's theme
|
||||
// and matches the native tab bar appearance on iOS.
|
||||
tint="systemChromeMaterial"
|
||||
intensity={100}
|
||||
style={StyleSheet.absoluteFill}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function useBottomTabOverflow() {
|
||||
const tabHeight = useBottomTabBarHeight();
|
||||
const { bottom } = useSafeAreaInsets();
|
||||
return tabHeight - bottom;
|
||||
}
|
||||
6
apps/mobile/components/ui/TabBarBackground.tsx
Normal file
6
apps/mobile/components/ui/TabBarBackground.tsx
Normal file
@@ -0,0 +1,6 @@
|
||||
// This is a shim for web and Android where the tab bar is generally opaque.
|
||||
export default undefined;
|
||||
|
||||
export function useBottomTabOverflow() {
|
||||
return 0;
|
||||
}
|
||||
41
apps/mobile/eas.json
Normal file
41
apps/mobile/eas.json
Normal file
@@ -0,0 +1,41 @@
|
||||
{
|
||||
"cli": {
|
||||
"version": ">= 16.20.1",
|
||||
"appVersionSource": "remote"
|
||||
},
|
||||
"build": {
|
||||
"preview": {
|
||||
"android": {
|
||||
"buildType": "apk"
|
||||
}
|
||||
},
|
||||
"development": {
|
||||
"developmentClient": true,
|
||||
"distribution": "internal"
|
||||
},
|
||||
"simulator": {
|
||||
"extends": "development",
|
||||
"ios": {
|
||||
"simulator": true
|
||||
}
|
||||
},
|
||||
"production": {
|
||||
"corepack": true,
|
||||
"distribution": "store",
|
||||
"autoIncrement": true,
|
||||
"channel": "production"
|
||||
}
|
||||
},
|
||||
"submit": {
|
||||
"production": {
|
||||
"ios": {
|
||||
"ascAppId": "6752550960"
|
||||
},
|
||||
"android": {
|
||||
"serviceAccountKeyPath": "./service-account-file.json",
|
||||
"track": "internal",
|
||||
"releaseStatus": "draft"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
33
apps/mobile/lib/cache.ts
Normal file
33
apps/mobile/lib/cache.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import * as FileSystem from "expo-file-system";
|
||||
|
||||
export const clearCache = async () => {
|
||||
await Promise.all([
|
||||
FileSystem.deleteAsync(FileSystem.documentDirectory + "archivedData", {
|
||||
idempotent: true,
|
||||
}),
|
||||
FileSystem.deleteAsync(FileSystem.documentDirectory + "mmkv", {
|
||||
idempotent: true,
|
||||
}),
|
||||
]);
|
||||
};
|
||||
|
||||
export const deleteLinkCache = async (linkId: number) => {
|
||||
const readablePath =
|
||||
FileSystem.documentDirectory + `archivedData/readable/link_${linkId}.html`;
|
||||
const webpagePath =
|
||||
FileSystem.documentDirectory + `archivedData/webpage/link_${linkId}.html`;
|
||||
const jpegPath =
|
||||
FileSystem.documentDirectory + `archivedData/jpeg/link_${linkId}.jpeg`;
|
||||
const pngPath =
|
||||
FileSystem.documentDirectory + `archivedData/png/link_${linkId}.png`;
|
||||
const pdfPath =
|
||||
FileSystem.documentDirectory + `archivedData/pdf/link_${linkId}.pdf`;
|
||||
|
||||
await Promise.all([
|
||||
FileSystem.deleteAsync(readablePath, { idempotent: true }),
|
||||
FileSystem.deleteAsync(webpagePath, { idempotent: true }),
|
||||
FileSystem.deleteAsync(jpegPath, { idempotent: true }),
|
||||
FileSystem.deleteAsync(pngPath, { idempotent: true }),
|
||||
FileSystem.deleteAsync(pdfPath, { idempotent: true }),
|
||||
]);
|
||||
};
|
||||
33
apps/mobile/lib/colors.ts
Normal file
33
apps/mobile/lib/colors.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
// lib/theme/colors.ts
|
||||
export type ThemeName = "light" | "dark";
|
||||
|
||||
export const rawTheme = {
|
||||
light: {
|
||||
primary: "#0369A1",
|
||||
secondary: "#0891B2",
|
||||
accent: "#6D28D9",
|
||||
neutral: "#6B7280",
|
||||
"neutral-content": "#D1D5DB",
|
||||
"base-100": "#FFFFFF",
|
||||
"base-200": "#F3F4F6",
|
||||
"base-content": "#0A0A0A",
|
||||
info: "#A5F3FC",
|
||||
success: "#22C55E",
|
||||
warning: "#FACC15",
|
||||
error: "#DC2626",
|
||||
},
|
||||
dark: {
|
||||
primary: "#7DD3FC",
|
||||
secondary: "#22D3EE",
|
||||
accent: "#6D28D9",
|
||||
neutral: "#9CA3AF",
|
||||
"neutral-content": "#404040",
|
||||
"base-100": "#171717",
|
||||
"base-200": "#262626",
|
||||
"base-content": "#FAFAFA",
|
||||
info: "#009EE4",
|
||||
success: "#00B17D",
|
||||
warning: "#EAC700",
|
||||
error: "#F1293C",
|
||||
},
|
||||
};
|
||||
14
apps/mobile/lib/queryClient.ts
Normal file
14
apps/mobile/lib/queryClient.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { QueryClient } from "@tanstack/react-query";
|
||||
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
staleTime: 1000 * 60 * 60 * 24,
|
||||
refetchOnMount: true,
|
||||
refetchOnWindowFocus: false,
|
||||
refetchOnReconnect: false,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export { queryClient };
|
||||
31
apps/mobile/lib/queryPersister.ts
Normal file
31
apps/mobile/lib/queryPersister.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import { MMKV } from "react-native-mmkv";
|
||||
import { Persister } from "@tanstack/react-query-persist-client";
|
||||
|
||||
const storage = new MMKV({ id: "react-query" });
|
||||
|
||||
export const mmkvPersister: Persister = {
|
||||
persistClient: async (client) => {
|
||||
try {
|
||||
const json = JSON.stringify(client);
|
||||
storage.set("REACT_QUERY_CACHE", json);
|
||||
} catch (e) {
|
||||
console.error("Error persisting client:", e);
|
||||
}
|
||||
},
|
||||
restoreClient: async () => {
|
||||
try {
|
||||
const json = storage.getString("REACT_QUERY_CACHE");
|
||||
return json ? JSON.parse(json) : undefined;
|
||||
} catch (e) {
|
||||
console.error("Error restoring client:", e);
|
||||
return undefined;
|
||||
}
|
||||
},
|
||||
removeClient: async () => {
|
||||
try {
|
||||
storage.delete("REACT_QUERY_CACHE");
|
||||
} catch (e) {
|
||||
console.error("Error removing client:", e);
|
||||
}
|
||||
},
|
||||
};
|
||||
23
apps/mobile/lib/theme.ts
Normal file
23
apps/mobile/lib/theme.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import { vars } from "nativewind";
|
||||
import { rawTheme, ThemeName } from "./colors";
|
||||
|
||||
const hexToRgb = (hex: string) => {
|
||||
const [r, g, b] = hex
|
||||
.replace(/^#/, "")
|
||||
.match(/.{2}/g)!
|
||||
.map((h) => parseInt(h, 16));
|
||||
return `${r} ${g} ${b}`;
|
||||
};
|
||||
|
||||
const makeVars = (scheme: ThemeName) =>
|
||||
vars(
|
||||
Object.fromEntries(
|
||||
Object.entries(rawTheme[scheme]).map(([key, hex]) => [
|
||||
`--color-${key}`,
|
||||
hexToRgb(hex),
|
||||
])
|
||||
) as Record<string, string>
|
||||
);
|
||||
|
||||
export const lightTheme = makeVars("light");
|
||||
export const darkTheme = makeVars("dark");
|
||||
6
apps/mobile/metro.config.js
Normal file
6
apps/mobile/metro.config.js
Normal file
@@ -0,0 +1,6 @@
|
||||
const { getDefaultConfig } = require("expo/metro-config");
|
||||
const { withNativeWind } = require("nativewind/metro");
|
||||
|
||||
const config = getDefaultConfig(__dirname);
|
||||
|
||||
module.exports = withNativeWind(config, { input: "./styles/global.css" });
|
||||
3
apps/mobile/nativewind-env.d.ts
vendored
Normal file
3
apps/mobile/nativewind-env.d.ts
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
/// <reference types="nativewind/types" />
|
||||
|
||||
// NOTE: This file should not be edited and should be committed with your source code. It is generated by NativeWind.
|
||||
87
apps/mobile/package.json
Normal file
87
apps/mobile/package.json
Normal file
@@ -0,0 +1,87 @@
|
||||
{
|
||||
"name": "@linkwarden/mobile",
|
||||
"main": "expo-router/entry",
|
||||
"version": "0.0.0",
|
||||
"scripts": {
|
||||
"start": "expo start",
|
||||
"android": "expo run:android",
|
||||
"ios": "expo run:ios",
|
||||
"web": "expo start --web",
|
||||
"test": "jest --watchAll",
|
||||
"lint": "expo lint"
|
||||
},
|
||||
"jest": {
|
||||
"preset": "jest-expo"
|
||||
},
|
||||
"dependencies": {
|
||||
"@expo/vector-icons": "^14.0.2",
|
||||
"@linkwarden/lib": "*",
|
||||
"@linkwarden/prisma": "*",
|
||||
"@linkwarden/react-native-render-html": "^6.3.4",
|
||||
"@linkwarden/router": "*",
|
||||
"@linkwarden/types": "*",
|
||||
"@react-native-async-storage/async-storage": "^2.2.0",
|
||||
"@react-native-community/netinfo": "11.4.1",
|
||||
"@react-native-menu/menu": "1.2.2",
|
||||
"@react-navigation/bottom-tabs": "^7.0.0",
|
||||
"@react-navigation/native": "^7.0.0",
|
||||
"@tanstack/react-query": "^5.51.15",
|
||||
"@tanstack/react-query-persist-client": "^5.51.15",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"expo": "~52.0.18",
|
||||
"expo-application": "~6.0.2",
|
||||
"expo-blur": "~14.0.1",
|
||||
"expo-build-properties": "~0.13.3",
|
||||
"expo-clipboard": "~7.0.1",
|
||||
"expo-constants": "~17.0.3",
|
||||
"expo-dev-client": "~5.0.6",
|
||||
"expo-file-system": "~18.0.12",
|
||||
"expo-font": "~13.0.1",
|
||||
"expo-haptics": "~14.0.0",
|
||||
"expo-linking": "~7.0.5",
|
||||
"expo-router": "4.0.20",
|
||||
"expo-secure-store": "~14.0.0",
|
||||
"expo-share-intent": "^3.2.3",
|
||||
"expo-splash-screen": "~0.29.18",
|
||||
"expo-status-bar": "~2.0.0",
|
||||
"expo-symbols": "~0.2.0",
|
||||
"expo-system-ui": "~4.0.6",
|
||||
"expo-updates": "~0.27.4",
|
||||
"expo-web-browser": "~14.0.1",
|
||||
"html-entities": "^2.6.0",
|
||||
"lucide-react-native": "^0.536.0",
|
||||
"nativewind": "^4.1.23",
|
||||
"react": "18.3.1",
|
||||
"react-dom": "18.3.1",
|
||||
"react-native": "0.76.9",
|
||||
"react-native-actions-sheet": "^0.9.7",
|
||||
"react-native-blob-util": "^0.23.2",
|
||||
"react-native-edge-to-edge": "^1.7.0",
|
||||
"react-native-gesture-handler": "~2.20.2",
|
||||
"react-native-ios-context-menu": "3.1.3",
|
||||
"react-native-ios-utilities": "5.1.7",
|
||||
"react-native-keyboard-controller": "^1.19.0",
|
||||
"react-native-mmkv": "^3.2.0",
|
||||
"react-native-pdf": "^7.0.3",
|
||||
"react-native-reanimated": "3.16.2",
|
||||
"react-native-safe-area-context": "4.12.0",
|
||||
"react-native-screens": "~4.1.0",
|
||||
"react-native-svg": "^15.12.1",
|
||||
"react-native-web": "~0.19.13",
|
||||
"react-native-webview": "13.12.5",
|
||||
"tailwindcss": "^3.4.17",
|
||||
"zeego": "^3.0.6",
|
||||
"zustand": "^5.0.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/core": "^7.25.2",
|
||||
"@types/jest": "^29.5.12",
|
||||
"@types/react": "18.3.1",
|
||||
"@types/react-test-renderer": "^18.3.0",
|
||||
"jest": "^29.2.1",
|
||||
"jest-expo": "~52.0.2",
|
||||
"react-test-renderer": "18.3.1",
|
||||
"typescript": "^5.8.3"
|
||||
},
|
||||
"private": true
|
||||
}
|
||||
43
apps/mobile/plugins/with-daynight-transparent-nav.js
Normal file
43
apps/mobile/plugins/with-daynight-transparent-nav.js
Normal file
@@ -0,0 +1,43 @@
|
||||
const { withAndroidStyles } = require("@expo/config-plugins");
|
||||
function mutateStylesXml(xml) {
|
||||
const styles = xml.resources?.style ?? [];
|
||||
let appTheme = styles.find((s) => s.$?.name === "AppTheme");
|
||||
if (!appTheme) {
|
||||
appTheme = {
|
||||
$: { name: "AppTheme", parent: "Theme.AppCompat.DayNight.NoActionBar" },
|
||||
item: [],
|
||||
};
|
||||
styles.push(appTheme);
|
||||
}
|
||||
|
||||
appTheme.$ = appTheme.$ || {};
|
||||
appTheme.$.parent = "Theme.AppCompat.DayNight.NoActionBar";
|
||||
|
||||
appTheme.item = appTheme.item ?? [];
|
||||
|
||||
appTheme.item = appTheme.item.filter(
|
||||
(i) => i?.$?.name !== "android:textColor"
|
||||
);
|
||||
|
||||
const navItem = appTheme.item.find(
|
||||
(i) => i.$?.name === "android:navigationBarColor"
|
||||
);
|
||||
if (navItem) {
|
||||
navItem._ = "@android:color/transparent";
|
||||
} else {
|
||||
appTheme.item.push({
|
||||
$: { name: "android:navigationBarColor" },
|
||||
_: "@android:color/transparent",
|
||||
});
|
||||
}
|
||||
|
||||
xml.resources.style = styles;
|
||||
return xml;
|
||||
}
|
||||
|
||||
module.exports = function withDayNightTransparentNav(config) {
|
||||
return withAndroidStyles(config, (c) => {
|
||||
c.modResults = mutateStylesXml(c.modResults);
|
||||
return c;
|
||||
});
|
||||
};
|
||||
157
apps/mobile/store/auth.ts
Normal file
157
apps/mobile/store/auth.ts
Normal file
@@ -0,0 +1,157 @@
|
||||
import { create } from "zustand";
|
||||
import * as SecureStore from "expo-secure-store";
|
||||
import { router } from "expo-router";
|
||||
import { MobileAuth } from "@linkwarden/types/global";
|
||||
import { Alert } from "react-native";
|
||||
import { queryClient } from "@/lib/queryClient";
|
||||
import { mmkvPersister } from "@/lib/queryPersister";
|
||||
import { clearCache } from "@/lib/cache";
|
||||
|
||||
type AuthStore = {
|
||||
auth: MobileAuth;
|
||||
signIn: (
|
||||
username: string,
|
||||
password: string,
|
||||
instance: string,
|
||||
token?: string
|
||||
) => Promise<void>;
|
||||
signOut: () => Promise<void>;
|
||||
setAuth: () => Promise<void>;
|
||||
};
|
||||
|
||||
const useAuthStore = create<AuthStore>((set) => ({
|
||||
auth: {
|
||||
instance: "",
|
||||
session: null,
|
||||
status: "loading" as const,
|
||||
},
|
||||
setAuth: async () => {
|
||||
const session = await SecureStore.getItemAsync("TOKEN");
|
||||
const instance = await SecureStore.getItemAsync("INSTANCE");
|
||||
|
||||
if (session) {
|
||||
set({
|
||||
auth: {
|
||||
instance,
|
||||
session,
|
||||
status: "authenticated",
|
||||
},
|
||||
});
|
||||
} else {
|
||||
set({
|
||||
auth: {
|
||||
instance: instance || "https://cloud.linkwarden.app",
|
||||
session: null,
|
||||
status: "unauthenticated",
|
||||
},
|
||||
});
|
||||
}
|
||||
},
|
||||
signIn: async (username, password, instance, token) => {
|
||||
if (process.env.EXPO_PUBLIC_SHOW_LOGS === "true")
|
||||
console.log("Signing into", instance);
|
||||
|
||||
if (token) {
|
||||
try {
|
||||
// make a request to the API to validate the token
|
||||
const res = await Promise.race([
|
||||
fetch(instance + "/api/v1/users/me", {
|
||||
method: "GET",
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
}),
|
||||
new Promise<Response>((_, reject) =>
|
||||
setTimeout(() => reject(new Error("TIMEOUT")), 30000)
|
||||
),
|
||||
]);
|
||||
|
||||
if (res.ok) {
|
||||
await SecureStore.setItemAsync("INSTANCE", instance);
|
||||
await SecureStore.setItemAsync("TOKEN", token);
|
||||
set({
|
||||
auth: {
|
||||
session: token,
|
||||
instance,
|
||||
status: "authenticated",
|
||||
},
|
||||
});
|
||||
router.replace("/(tabs)/dashboard");
|
||||
} else {
|
||||
Alert.alert("Error", "Invalid token");
|
||||
}
|
||||
} catch (err: any) {
|
||||
if (err?.message === "TIMEOUT") {
|
||||
Alert.alert(
|
||||
"Request timed out",
|
||||
"Unable to reach the server in time. Please check your network configuration and try again."
|
||||
);
|
||||
} else {
|
||||
Alert.alert(
|
||||
"Network error",
|
||||
"Could not connect to the server. Please check your network configuration and try again."
|
||||
);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
try {
|
||||
const res = await Promise.race([
|
||||
fetch(`${instance}/api/v1/session`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ username, password }),
|
||||
headers: { "Content-Type": "application/json" },
|
||||
}),
|
||||
new Promise<Response>((_, reject) =>
|
||||
setTimeout(() => reject(new Error("TIMEOUT")), 30000)
|
||||
),
|
||||
]);
|
||||
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
const session = (data as any).response.token;
|
||||
|
||||
await SecureStore.setItemAsync("TOKEN", session);
|
||||
await SecureStore.setItemAsync("INSTANCE", instance);
|
||||
set({ auth: { session, instance, status: "authenticated" } });
|
||||
router.replace("/(tabs)/dashboard");
|
||||
} else {
|
||||
Alert.alert("Error", "Invalid credentials");
|
||||
}
|
||||
} catch (err: any) {
|
||||
if (err?.message === "TIMEOUT") {
|
||||
Alert.alert(
|
||||
"Request timed out",
|
||||
"Unable to reach the server in time. Please check your network configuration and try again."
|
||||
);
|
||||
} else {
|
||||
Alert.alert(
|
||||
"Network error",
|
||||
"Could not connect to the server. Please check your network configuration and try again."
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
signOut: async () => {
|
||||
await SecureStore.deleteItemAsync("TOKEN");
|
||||
await SecureStore.deleteItemAsync("INSTANCE");
|
||||
|
||||
queryClient.cancelQueries();
|
||||
queryClient.clear();
|
||||
mmkvPersister.removeClient?.();
|
||||
|
||||
await clearCache();
|
||||
|
||||
set({
|
||||
auth: {
|
||||
instance: "",
|
||||
session: null,
|
||||
status: "unauthenticated",
|
||||
},
|
||||
});
|
||||
|
||||
router.replace("/");
|
||||
},
|
||||
}));
|
||||
|
||||
export default useAuthStore;
|
||||
38
apps/mobile/store/data.ts
Normal file
38
apps/mobile/store/data.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import { create } from "zustand";
|
||||
import { MobileData } from "@linkwarden/types/global";
|
||||
import AsyncStorage from "@react-native-async-storage/async-storage";
|
||||
import { colorScheme } from "nativewind";
|
||||
|
||||
type DataStore = {
|
||||
data: MobileData;
|
||||
updateData: (newData: Partial<MobileData>) => void;
|
||||
setData: () => void;
|
||||
};
|
||||
|
||||
const useDataStore = create<DataStore>((set, get) => ({
|
||||
data: {
|
||||
shareIntent: {
|
||||
hasShareIntent: false,
|
||||
url: "",
|
||||
},
|
||||
theme: "system",
|
||||
preferredBrowser: "app",
|
||||
preferredCollection: null,
|
||||
},
|
||||
setData: async () => {
|
||||
const dataString = JSON.parse((await AsyncStorage.getItem("data")) || "{}");
|
||||
|
||||
colorScheme.set(dataString.theme || "system");
|
||||
|
||||
if (dataString)
|
||||
set((state) => ({ data: { ...state.data, ...dataString } }));
|
||||
},
|
||||
updateData: async (patch) => {
|
||||
const merged = { ...get().data, ...patch };
|
||||
const { shareIntent, ...persistable } = merged;
|
||||
await AsyncStorage.setItem("data", JSON.stringify(persistable));
|
||||
set({ data: merged });
|
||||
},
|
||||
}));
|
||||
|
||||
export default useDataStore;
|
||||
26
apps/mobile/store/tmp.ts
Normal file
26
apps/mobile/store/tmp.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import { create } from "zustand";
|
||||
import { LinkIncludingShortenedCollectionAndTags } from "@linkwarden/types/global";
|
||||
import { User } from "@linkwarden/prisma/client";
|
||||
|
||||
type Tmp = {
|
||||
link: LinkIncludingShortenedCollectionAndTags | null;
|
||||
user: Pick<User, "id"> | null;
|
||||
};
|
||||
|
||||
type TmpStore = {
|
||||
tmp: Tmp;
|
||||
updateTmp: (newData: Partial<Tmp>) => void;
|
||||
};
|
||||
|
||||
const useTmpStore = create<TmpStore>((set, get) => ({
|
||||
tmp: {
|
||||
link: null,
|
||||
user: null,
|
||||
},
|
||||
updateTmp: async (patch) => {
|
||||
const merged = { ...get().tmp, ...patch };
|
||||
set({ tmp: merged });
|
||||
},
|
||||
}));
|
||||
|
||||
export default useTmpStore;
|
||||
3
apps/mobile/styles/global.css
Normal file
3
apps/mobile/styles/global.css
Normal file
@@ -0,0 +1,3 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
39
apps/mobile/tailwind.config.js
Normal file
39
apps/mobile/tailwind.config.js
Normal file
@@ -0,0 +1,39 @@
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
|
||||
const { rawTheme } = require("./lib/colors");
|
||||
|
||||
const hexToRgb = (hex) => {
|
||||
const [r, g, b] = hex
|
||||
.replace(/^#/, "")
|
||||
.match(/.{2}/g)
|
||||
.map((h) => parseInt(h, 16));
|
||||
return `${r} ${g} ${b}`;
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
content: ["./app/**/*.{js,jsx,ts,tsx}", "./components/**/*.{js,jsx,ts,tsx}"],
|
||||
presets: [require("nativewind/preset")],
|
||||
darkMode: "media",
|
||||
theme: {
|
||||
extend: {
|
||||
colors: Object.fromEntries(
|
||||
Object.keys(rawTheme.light).map((key) => [
|
||||
key,
|
||||
`rgb(var(--color-${key}) / <alpha-value>)`,
|
||||
])
|
||||
),
|
||||
},
|
||||
},
|
||||
plugins: [
|
||||
({ addBase }) => {
|
||||
addBase({
|
||||
":root": Object.fromEntries(
|
||||
Object.entries(rawTheme.light).map(([key, hex]) => [
|
||||
`--color-${key}`,
|
||||
hexToRgb(hex),
|
||||
])
|
||||
),
|
||||
});
|
||||
},
|
||||
],
|
||||
};
|
||||
18
apps/mobile/tsconfig.json
Normal file
18
apps/mobile/tsconfig.json
Normal file
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"extends": "expo/tsconfig.base",
|
||||
"compilerOptions": {
|
||||
"strict": true,
|
||||
"paths": {
|
||||
"@/*": [
|
||||
"./*"
|
||||
]
|
||||
}
|
||||
},
|
||||
"include": [
|
||||
"**/*.ts",
|
||||
"**/*.tsx",
|
||||
".expo/types/**/*.ts",
|
||||
"expo-env.d.ts",
|
||||
"nativewind-env.d.ts"
|
||||
]
|
||||
}
|
||||
43
apps/mobile/types/global.ts
Normal file
43
apps/mobile/types/global.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
export enum Sort {
|
||||
DateNewestFirst,
|
||||
DateOldestFirst,
|
||||
NameAZ,
|
||||
NameZA,
|
||||
DescriptionAZ,
|
||||
DescriptionZA,
|
||||
}
|
||||
|
||||
export type LinkRequestQuery = {
|
||||
sort?: Sort;
|
||||
cursor?: number;
|
||||
collectionId?: number;
|
||||
tagId?: number;
|
||||
pinnedOnly?: boolean;
|
||||
searchQueryString?: string;
|
||||
searchByName?: boolean;
|
||||
searchByUrl?: boolean;
|
||||
searchByDescription?: boolean;
|
||||
searchByTextContent?: boolean;
|
||||
searchByTags?: boolean;
|
||||
};
|
||||
|
||||
export type LinkIncludingShortenedCollectionAndTags = {
|
||||
id: number;
|
||||
name: string;
|
||||
url: string;
|
||||
description: string;
|
||||
type: "url" | "image" | "pdf";
|
||||
preview: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
collectionId: number;
|
||||
tags: { id: number; name: string }[];
|
||||
};
|
||||
|
||||
export enum ArchivedFormat {
|
||||
png = 0,
|
||||
jpeg = 1,
|
||||
pdf = 2,
|
||||
readability = 3,
|
||||
monolith = 4,
|
||||
}
|
||||
1
apps/mobile/types/nativewind-env.d.ts
vendored
Normal file
1
apps/mobile/types/nativewind-env.d.ts
vendored
Normal file
@@ -0,0 +1 @@
|
||||
/// <reference types="nativewind/types" />
|
||||
21
apps/web/components.json
Normal file
21
apps/web/components.json
Normal file
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"$schema": "https://ui.shadcn.com/schema.json",
|
||||
"style": "default",
|
||||
"rsc": false,
|
||||
"tsx": true,
|
||||
"tailwind": {
|
||||
"config": "tailwind.config.js",
|
||||
"css": "styles/globals.css",
|
||||
"baseColor": "neutral",
|
||||
"cssVariables": true,
|
||||
"prefix": ""
|
||||
},
|
||||
"aliases": {
|
||||
"components": "@/components",
|
||||
"utils": "@/lib/utils",
|
||||
"ui": "@/components/ui",
|
||||
"lib": "@/lib",
|
||||
"hooks": "@/hooks"
|
||||
},
|
||||
"iconLibrary": "lucide"
|
||||
}
|
||||
125
apps/web/components/AdminSidebar.tsx
Normal file
125
apps/web/components/AdminSidebar.tsx
Normal file
@@ -0,0 +1,125 @@
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/router";
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { useTranslation } from "next-i18next";
|
||||
import { useUser } from "@linkwarden/router/user";
|
||||
import Image from "next/image";
|
||||
|
||||
export default function AdminSidebar({ className }: { className?: string }) {
|
||||
const { t } = useTranslation();
|
||||
const LINKWARDEN_VERSION = process.env.version;
|
||||
|
||||
const { data: user } = useUser();
|
||||
|
||||
const router = useRouter();
|
||||
const [active, setActive] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
setActive(router.asPath);
|
||||
}, [router]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`bg-base-200 h-screen w-80 overflow-y-auto border-solid border border-base-200 border-r-neutral-content p-2 z-20 flex flex-col gap-5 justify-between ${
|
||||
className || ""
|
||||
}`}
|
||||
>
|
||||
<div className="flex flex-col gap-1">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
{user?.theme === "light" ? (
|
||||
<Image
|
||||
src={"/linkwarden_light.png"}
|
||||
width={640}
|
||||
height={136}
|
||||
alt="Linkwarden"
|
||||
className="h-9 w-auto cursor-pointer"
|
||||
onClick={() => router.push("/dashboard")}
|
||||
priority
|
||||
/>
|
||||
) : (
|
||||
<Image
|
||||
src={"/linkwarden_dark.png"}
|
||||
width={640}
|
||||
height={136}
|
||||
alt="Linkwarden"
|
||||
className="h-9 w-auto cursor-pointer"
|
||||
onClick={() => router.push("/dashboard")}
|
||||
priority
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<Link href="/admin/user-administration">
|
||||
<div
|
||||
className={`${
|
||||
active === "/admin/user-administration"
|
||||
? "bg-primary/20"
|
||||
: "hover:bg-neutral/20"
|
||||
} duration-200 cursor-pointer flex items-center gap-2 rounded-lg px-3 py-1`}
|
||||
>
|
||||
<i className="bi-people text-primary text-xl drop-shadow"></i>
|
||||
<p className="truncate w-full font-semibold text-sm">
|
||||
{t("user_administration")}
|
||||
</p>
|
||||
</div>
|
||||
</Link>
|
||||
|
||||
<Link href="/admin/background-jobs">
|
||||
<div
|
||||
className={`${
|
||||
active === "/admin/background-jobs"
|
||||
? "bg-primary/20"
|
||||
: "hover:bg-neutral/20"
|
||||
} duration-200 cursor-pointer flex items-center gap-2 rounded-lg px-3 py-1`}
|
||||
>
|
||||
<i className="bi-gear-wide-connected text-primary text-xl drop-shadow"></i>
|
||||
<p className="truncate w-full font-semibold text-sm">
|
||||
{t("background_jobs")}
|
||||
</p>
|
||||
</div>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1">
|
||||
<Link
|
||||
href={`https://github.com/linkwarden/linkwarden/releases`}
|
||||
target="_blank"
|
||||
className="text-neutral text-sm ml-2 hover:opacity-50 duration-100"
|
||||
>
|
||||
{t("linkwarden_version", { version: LINKWARDEN_VERSION })}
|
||||
</Link>
|
||||
<Link href="https://docs.linkwarden.app" target="_blank">
|
||||
<div
|
||||
className={`hover:bg-neutral/20 duration-200 cursor-pointer flex items-center gap-2 rounded-lg px-3 py-1`}
|
||||
>
|
||||
<i className="bi-question-circle text-primary text-xl drop-shadow"></i>
|
||||
<p className="truncate w-full font-semibold text-sm">{t("help")}</p>
|
||||
</div>
|
||||
</Link>
|
||||
<Link href="https://github.com/linkwarden/linkwarden" target="_blank">
|
||||
<div
|
||||
className={`hover:bg-neutral/20 duration-200 cursor-pointer flex items-center gap-2 rounded-lg px-3 py-1`}
|
||||
>
|
||||
<i className="bi-github text-primary text-xl drop-shadow"></i>
|
||||
<p className="truncate w-full font-semibold text-sm">{t("github")}</p>
|
||||
</div>
|
||||
</Link>
|
||||
<Link href="https://twitter.com/LinkwardenHQ" target="_blank">
|
||||
<div
|
||||
className={`hover:bg-neutral/20 duration-200 cursor-pointer flex items-center gap-2 rounded-lg px-3 py-1`}
|
||||
>
|
||||
<i className="bi-twitter-x text-primary text-xl drop-shadow"></i>
|
||||
<p className="truncate w-full font-semibold text-sm">{t("twitter")}</p>
|
||||
</div>
|
||||
</Link>
|
||||
<Link href="https://fosstodon.org/@linkwarden" target="_blank">
|
||||
<div
|
||||
className={`hover:bg-neutral/20 duration-200 cursor-pointer flex items-center gap-2 rounded-lg px-3 py-1`}
|
||||
>
|
||||
<i className="bi-mastodon text-primary text-xl drop-shadow"></i>
|
||||
<p className="truncate w-full font-semibold text-sm">{t("mastodon")}</p>
|
||||
</div>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user