mirror of
https://github.com/Athou/commafeed.git
synced 2026-11-06 04:16:54 +00:00
Compare commits
2148 Commits
5.6.0
...
renovate/l
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2c52bc3a65 | ||
|
|
e68268aa3f | ||
|
|
5dd870c9a9 | ||
|
|
c9ed8f9bfd | ||
|
|
e38c45e4d6 | ||
|
|
6c54ae1e1e | ||
|
|
ef4fde24e3 | ||
|
|
f0fd987f4b | ||
|
|
d691095746 | ||
|
|
ae707a2602 | ||
|
|
69cf674a02 | ||
|
|
29987a460d | ||
|
|
fc8cd9c0a2 | ||
|
|
44c2893fff | ||
|
|
2e66fd4702 | ||
|
|
01ac8d6ebd | ||
|
|
ddff408afe | ||
|
|
0c280c4dc4 | ||
|
|
ca4642f7c9 | ||
|
|
93edb47dca | ||
|
|
801680e373 | ||
|
|
5704e1e1fa | ||
|
|
a2a6720cd1 | ||
|
|
4f8cddeb54 | ||
|
|
90d0846bad | ||
|
|
698b2f4dfd | ||
|
|
0053c54ae4 | ||
|
|
16b6077496 | ||
|
|
edb4d5e178 | ||
|
|
4b091fb90b | ||
|
|
30c049d10a | ||
|
|
fa8a447787 | ||
|
|
20ec5d3eb9 | ||
|
|
796dc88c33 | ||
|
|
f979f5426f | ||
|
|
8f9be28002 | ||
|
|
89845681be | ||
|
|
8fdf63dd73 | ||
|
|
8f07454270 | ||
|
|
946d82d8ee | ||
|
|
f8d625b651 | ||
|
|
eefdd40a02 | ||
|
|
08f2889f1d | ||
|
|
45453fcf4f | ||
|
|
ef7bab4841 | ||
|
|
ac277f9ca5 | ||
|
|
44dab0287b | ||
|
|
88b75ec0cb | ||
|
|
cbb45ce68c | ||
|
|
b0c4dbf533 | ||
|
|
1891b63a0e | ||
|
|
c420fec8ee | ||
|
|
2ee2a5dfbe | ||
|
|
a5b98f9ea2 | ||
|
|
baedab2960 | ||
|
|
004e18bbb8 | ||
|
|
72e364afe9 | ||
|
|
86ff2dfcb2 | ||
|
|
69ce73012c | ||
|
|
f3bd1a2e11 | ||
|
|
a819283661 | ||
|
|
8255c1e929 | ||
|
|
eb7990f8dc | ||
|
|
982476b2d0 | ||
|
|
3ec653c3b7 | ||
|
|
5dc5b3795e | ||
|
|
f5f77a80a3 | ||
|
|
1d1099492e | ||
|
|
ccc2564929 | ||
|
|
084574ffd4 | ||
|
|
6a6978c491 | ||
|
|
0399375288 | ||
|
|
394346fbc1 | ||
|
|
0feec109bd | ||
|
|
fdb5c9b536 | ||
|
|
f7ad8aed60 | ||
|
|
4621d7e79f | ||
|
|
b64a49cfb0 | ||
|
|
aaddfbaf40 | ||
|
|
994da5e1ee | ||
|
|
811e282e28 | ||
|
|
892fcf8168 | ||
|
|
032ee9243f | ||
|
|
30cdf34d56 | ||
|
|
fd52caa3fd | ||
|
|
9788712808 | ||
|
|
af9d086f05 | ||
|
|
fc7190868e | ||
|
|
d3c1892c0b | ||
|
|
1fb4cdf3ca | ||
|
|
7928183036 | ||
|
|
ffa276df9e | ||
|
|
9f05e66e9d | ||
|
|
8ba90ded85 | ||
|
|
4e5e2f06d3 | ||
|
|
72396254d2 | ||
|
|
f9313006ba | ||
|
|
c894d701ff | ||
|
|
e9d0466018 | ||
|
|
28b4f61f20 | ||
|
|
ab6b3b225d | ||
|
|
ffb4a43e38 | ||
|
|
0653e2dc97 | ||
|
|
912e03d2ce | ||
|
|
a8df765cda | ||
|
|
b865b3ff84 | ||
|
|
69c1e8a5b8 | ||
|
|
c1522f1005 | ||
|
|
8144d24b06 | ||
|
|
4c2e2efbb5 | ||
|
|
ad452c9a7a | ||
|
|
a43da1304d | ||
|
|
c2da59a92f | ||
|
|
a89166d1c4 | ||
|
|
45039593bf | ||
|
|
d56c35cdfb | ||
|
|
73836c6800 | ||
|
|
f9a0440db8 | ||
|
|
6e7a8c1ef6 | ||
|
|
44147cc062 | ||
|
|
352b756e22 | ||
|
|
a5d9eb757f | ||
|
|
a19a73d675 | ||
|
|
4a57f87284 | ||
|
|
b523fe4c58 | ||
|
|
c208450abc | ||
|
|
b73ab2a781 | ||
|
|
ec679cc23c | ||
|
|
1dbbcc4d21 | ||
|
|
1573b61584 | ||
|
|
1ac8cc7674 | ||
|
|
793db4e9b6 | ||
|
|
12863e557a | ||
|
|
d999fc1e06 | ||
|
|
6c288d160c | ||
|
|
57d5dd8b20 | ||
|
|
0a8c0e0c7f | ||
|
|
f72bd4cc5c | ||
|
|
26cce1faac | ||
|
|
3101b4f423 | ||
|
|
e8e30faf3e | ||
|
|
3f6158fc47 | ||
|
|
3465645b70 | ||
|
|
2dd397757d | ||
|
|
5496419bb4 | ||
|
|
f1b950c38e | ||
|
|
4a3e97ad52 | ||
|
|
9fe17996b4 | ||
|
|
c7a2f87148 | ||
|
|
1125dd3fe7 | ||
|
|
adf48aee44 | ||
|
|
534d8a333e | ||
|
|
03fc91c19c | ||
|
|
d2429dada2 | ||
|
|
0fae323647 | ||
|
|
fae58d56be | ||
|
|
fc56d5e7cb | ||
|
|
04c85acae0 | ||
|
|
af2fd9bc09 | ||
|
|
d4ce2a213f | ||
|
|
7d9ba805e4 | ||
|
|
4c315ca3b8 | ||
|
|
bcf812ef7a | ||
|
|
fd603aa76f | ||
|
|
e34c08c803 | ||
|
|
9252ef1531 | ||
|
|
63153ca195 | ||
|
|
a8c2f69251 | ||
|
|
91eaef05e1 | ||
|
|
4822642fd7 | ||
|
|
a712d8f900 | ||
|
|
86e507f93e | ||
|
|
86eba276c5 | ||
|
|
4f18a98a4a | ||
|
|
547eff9f06 | ||
|
|
5bd1225d3f | ||
|
|
309a0aa94c | ||
|
|
f8286a6334 | ||
|
|
916086d60e | ||
|
|
4fa0eb0a87 | ||
|
|
295eb57922 | ||
|
|
2179cf57b6 | ||
|
|
cca0f2d5db | ||
|
|
d3c3ffb88d | ||
|
|
10c7196f9f | ||
|
|
254def6152 | ||
|
|
09bae2565c | ||
|
|
5572918750 | ||
|
|
4e91db09a6 | ||
|
|
bbaee20097 | ||
|
|
7554e2b03e | ||
|
|
3baac1138f | ||
|
|
8815cef66c | ||
|
|
afa836c6df | ||
|
|
bf513508e4 | ||
|
|
a0423f301d | ||
|
|
302fa8f4dd | ||
|
|
4a75a22a6c | ||
|
|
c7d5cf1cc2 | ||
|
|
e031ca3ef4 | ||
|
|
8e0e926a68 | ||
|
|
d4ed634c4e | ||
|
|
18582f1a2d | ||
|
|
3688b128e4 | ||
|
|
4718b75f89 | ||
|
|
aa7f8f79a5 | ||
|
|
c794a6aaf8 | ||
|
|
0786a45995 | ||
|
|
66fde65550 | ||
|
|
caf56aca94 | ||
|
|
06581692e6 | ||
|
|
ca2fbd9fdd | ||
|
|
81ce6c9950 | ||
|
|
31b67f2217 | ||
|
|
0ee76d39b2 | ||
|
|
6a3f4816d5 | ||
|
|
1365754d62 | ||
|
|
ac4c98bead | ||
|
|
74306719ca | ||
|
|
cc028dd37f | ||
|
|
43184fff6d | ||
|
|
d41f94f254 | ||
|
|
fb741a4e54 | ||
|
|
4319dfd0fe | ||
|
|
a06a0e893c | ||
|
|
8517092a88 | ||
|
|
52e79dfaa7 | ||
|
|
7fb19fd032 | ||
|
|
6344657940 | ||
|
|
3f01a99b66 | ||
|
|
69d7350e89 | ||
|
|
9ee502f437 | ||
|
|
b8b876535b | ||
|
|
816edefe25 | ||
|
|
c523ff608e | ||
|
|
663f14c439 | ||
|
|
20c6ef6702 | ||
|
|
91241f045f | ||
|
|
82e5c54cdc | ||
|
|
4b17102e5a | ||
|
|
bc58324a56 | ||
|
|
b43bcf244a | ||
|
|
befe1a0bdc | ||
|
|
1aae5433f3 | ||
|
|
afeb12686b | ||
|
|
75f4b94b26 | ||
|
|
4a867904ad | ||
|
|
249b161243 | ||
|
|
a709a3268a | ||
|
|
50b8a06f63 | ||
|
|
29e6338b41 | ||
|
|
82e9fd7f2e | ||
|
|
8eb41eec53 | ||
|
|
07f96019b1 | ||
|
|
1a0d4cb8cd | ||
|
|
5ae34047ee | ||
|
|
260181ec7c | ||
|
|
21bc826eda | ||
|
|
e1d9125a5c | ||
|
|
8d1b836f59 | ||
|
|
bd50399483 | ||
|
|
d641d25392 | ||
|
|
a8c9f41fce | ||
|
|
2ff9d84584 | ||
|
|
11cd61cd44 | ||
|
|
cf43988fae | ||
|
|
4ef27e3027 | ||
|
|
5ffabb387b | ||
|
|
d78a45b7e5 | ||
|
|
bd5a9c382d | ||
|
|
e238e829ad | ||
|
|
689c20d367 | ||
|
|
43588df2b4 | ||
|
|
d2aaddea33 | ||
|
|
de233f8ae5 | ||
|
|
3179e74dc7 | ||
|
|
6f5e9f5d49 | ||
|
|
8a43fdf200 | ||
|
|
69051e5bbb | ||
|
|
c648e7e0ec | ||
|
|
c91cf376ad | ||
|
|
f4e07597d0 | ||
|
|
64b64d3340 | ||
|
|
57ea87871e | ||
|
|
6154d80bb9 | ||
|
|
ad85745f85 | ||
|
|
41fc77322c | ||
|
|
1b83c26417 | ||
|
|
10ea5f0b93 | ||
|
|
032684de68 | ||
|
|
3efec5ce58 | ||
|
|
4ed32964d9 | ||
|
|
05c1d0cc6e | ||
|
|
3c54924971 | ||
|
|
e57dfc0e3f | ||
|
|
eff8bb16a0 | ||
|
|
87e681b71e | ||
|
|
ac73ef4ea7 | ||
|
|
20945e7443 | ||
|
|
b7b1278111 | ||
|
|
6f9378404f | ||
|
|
79787aca6e | ||
|
|
2d90ccf856 | ||
|
|
7f77cf0268 | ||
|
|
f3c390e0e0 | ||
|
|
1fdc471197 | ||
|
|
ecf5d9e50e | ||
|
|
97a49e8faa | ||
|
|
f5a020cdc2 | ||
|
|
1f0507ee65 | ||
|
|
393c90f334 | ||
|
|
03ecc2f49b | ||
|
|
17743adce6 | ||
|
|
126dde103a | ||
|
|
14a25581b4 | ||
|
|
c7c95efb3a | ||
|
|
4c1293d357 | ||
|
|
76a92ea44c | ||
|
|
ba454322db | ||
|
|
a3b9f471e1 | ||
|
|
d58ddc888e | ||
|
|
fe9837c45c | ||
|
|
332d126bd7 | ||
|
|
0c66bf2695 | ||
|
|
4d6d07c98b | ||
|
|
8f7d4bfa10 | ||
|
|
7b3f39001f | ||
|
|
b9630809a0 | ||
|
|
ce3244da5e | ||
|
|
4067297d2a | ||
|
|
03c11fa181 | ||
|
|
3676e78eb9 | ||
|
|
1e08c4486f | ||
|
|
fd8bb60daa | ||
|
|
557d56ffc5 | ||
|
|
30f76a2349 | ||
|
|
cf0fa5e22e | ||
|
|
f16becbd82 | ||
|
|
4855450ddc | ||
|
|
0733aedce5 | ||
|
|
399ad14d9b | ||
|
|
7881ea3c5c | ||
|
|
7e9de622da | ||
|
|
c6655078f2 | ||
|
|
b86cef9499 | ||
|
|
5375ab6e97 | ||
|
|
75920a6473 | ||
|
|
f474e76866 | ||
|
|
d0ae27c4ce | ||
|
|
ab191dded6 | ||
|
|
950b571a9f | ||
|
|
7a153ca69a | ||
|
|
effd8946de | ||
|
|
ae3ad3745c | ||
|
|
2e1e5257b4 | ||
|
|
bc6e767953 | ||
|
|
b9eceaae73 | ||
|
|
8ad9ba7d74 | ||
|
|
10bb4b7936 | ||
|
|
ab3127cef9 | ||
|
|
5d64e2f21e | ||
|
|
e404c9ce3b | ||
|
|
0c5a71650c | ||
|
|
8ce9eb8537 | ||
|
|
1ce0e4af97 | ||
|
|
44ad42a8a5 | ||
|
|
f213726c40 | ||
|
|
972d424846 | ||
|
|
6049263137 | ||
|
|
ae2dc7f066 | ||
|
|
503d129f22 | ||
|
|
543ec3bbb1 | ||
|
|
bed45127c4 | ||
|
|
71f10b0a62 | ||
|
|
cb235289c4 | ||
|
|
0b07c65320 | ||
|
|
868f65d7c9 | ||
|
|
07715383f4 | ||
|
|
29d10debdb | ||
|
|
cd640da960 | ||
|
|
56ecb4a2f4 | ||
|
|
00161fd549 | ||
|
|
a602ce17cd | ||
|
|
672588d08b | ||
|
|
5fa2579275 | ||
|
|
09bc317797 | ||
|
|
440c1ada50 | ||
|
|
911a5ca099 | ||
|
|
65017f6b76 | ||
|
|
64fb5ef3a2 | ||
|
|
dda389a50f | ||
|
|
419ad4fb6a | ||
|
|
ebae46f06b | ||
|
|
ac25accb31 | ||
|
|
3c7eb90e50 | ||
|
|
4b3973f3fa | ||
|
|
6ddbe40c3c | ||
|
|
d5164750e4 | ||
|
|
822fbd8add | ||
|
|
3a341d0707 | ||
|
|
23884fcbda | ||
|
|
3b16674f98 | ||
|
|
d5eb3e1bb1 | ||
|
|
8d47b38fd4 | ||
|
|
04fb3282fa | ||
|
|
bcce130b82 | ||
|
|
e5d336f6ba | ||
|
|
2d741f6e1e | ||
|
|
2968dff118 | ||
|
|
0c244ec154 | ||
|
|
34cd337992 | ||
|
|
1703cb7b93 | ||
|
|
dc89faa4a1 | ||
|
|
3eccfe8ccc | ||
|
|
6e34f6831c | ||
|
|
b7ebcd6a23 | ||
|
|
8b7efe056d | ||
|
|
87cb392516 | ||
|
|
aa11cb48f2 | ||
|
|
36a29a393c | ||
|
|
99f1b45866 | ||
|
|
9e8d529af3 | ||
|
|
bb80641b40 | ||
|
|
a2ed16c6fd | ||
|
|
4f10f57837 | ||
|
|
28f40cd6fe | ||
|
|
89a84a0c83 | ||
|
|
ebcda50a34 | ||
|
|
8640e62b77 | ||
|
|
d0418eea89 | ||
|
|
41ba067daf | ||
|
|
53346997b6 | ||
|
|
87a632761e | ||
|
|
998ebc43a3 | ||
|
|
945e312c41 | ||
|
|
963a562100 | ||
|
|
4be961b68b | ||
|
|
02a9140c13 | ||
|
|
5ea60ce6d6 | ||
|
|
e9e7be0fc6 | ||
|
|
60c30f2683 | ||
|
|
a17e69d37d | ||
|
|
157f75d33e | ||
|
|
30a14c73ff | ||
|
|
27cece3afb | ||
|
|
8124ecb51f | ||
|
|
e67c4c8670 | ||
|
|
fcc6798d63 | ||
|
|
d313ebfc69 | ||
|
|
6edb8f00f6 | ||
|
|
98a261d457 | ||
|
|
e97a69f475 | ||
|
|
e9dc284b26 | ||
|
|
856c52c64b | ||
|
|
386a4a32ef | ||
|
|
dbdb40ff01 | ||
|
|
1bd9b71215 | ||
|
|
f4dc279ec9 | ||
|
|
ca8438623d | ||
|
|
b81aac71f5 | ||
|
|
a4e0d28fa2 | ||
|
|
56d4e4bbb1 | ||
|
|
af1d10529b | ||
|
|
a5f84a3f6d | ||
|
|
69dfbc81cc | ||
|
|
34408ccdb0 | ||
|
|
b0522a6e5d | ||
|
|
2a559a07c0 | ||
|
|
bdf2a6a915 | ||
|
|
e814f75872 | ||
|
|
c0e0791854 | ||
|
|
17dbdb66bf | ||
|
|
6e3e9add36 | ||
|
|
5b25011853 | ||
|
|
804b24b2d6 | ||
|
|
de98b0f7c2 | ||
|
|
709de70fa9 | ||
|
|
1b7bc11f9d | ||
|
|
bbcaacf3dd | ||
|
|
e816b1262c | ||
|
|
fc019333dc | ||
|
|
56b0ddbc34 | ||
|
|
bb74d27d52 | ||
|
|
7b1d19ca71 | ||
|
|
af961678bd | ||
|
|
1579242644 | ||
|
|
2a45f5c596 | ||
|
|
c4560c49b0 | ||
|
|
2b7f75d9ef | ||
|
|
5f68bcbd46 | ||
|
|
c93c072259 | ||
|
|
516344a993 | ||
|
|
4d42fe5a0b | ||
|
|
47bf132fff | ||
|
|
04a9d48505 | ||
|
|
3716ddcf15 | ||
|
|
6c3ab40cb6 | ||
|
|
b7270974e5 | ||
|
|
9bfc53e77a | ||
|
|
f34fac8d17 | ||
|
|
555c11649e | ||
|
|
c308c5d606 | ||
|
|
54e9615994 | ||
|
|
b0b3b323fd | ||
|
|
f740a0b8ba | ||
|
|
e154675e72 | ||
|
|
2b3d3261de | ||
|
|
fab525ca56 | ||
|
|
1c273d1c1a | ||
|
|
90d684367c | ||
|
|
1ffc544ab0 | ||
|
|
cde0cceee1 | ||
|
|
1604fbc55c | ||
|
|
6f586e8866 | ||
|
|
c53455ad61 | ||
|
|
77b3c609f3 | ||
|
|
3b84598d99 | ||
|
|
6f536c0981 | ||
|
|
1f881ea252 | ||
|
|
b14df1b3cb | ||
|
|
e13763baa3 | ||
|
|
502ef67a71 | ||
|
|
05cab626df | ||
|
|
c7d54af7ad | ||
|
|
ad195fcd80 | ||
|
|
b87c68d598 | ||
|
|
33af37ed2e | ||
|
|
c4113fc92b | ||
|
|
ed40025d26 | ||
|
|
752008a551 | ||
|
|
a8bf970dca | ||
|
|
5b94bcdfdd | ||
|
|
a66a0e9485 | ||
|
|
80755e15b8 | ||
|
|
8cede0c6e0 | ||
|
|
9556f0f7d2 | ||
|
|
1b80f3060c | ||
|
|
00811f0be3 | ||
|
|
0fdfc2c59c | ||
|
|
8d25562a76 | ||
|
|
1d0d9d4be6 | ||
|
|
9ed4559625 | ||
|
|
2a53e8f15d | ||
|
|
15fbd18f31 | ||
|
|
2c248a7ca1 | ||
|
|
94d8fdb4ab | ||
|
|
3596439ed4 | ||
|
|
bc732a52ce | ||
|
|
ef096899a2 | ||
|
|
bf4bcf740e | ||
|
|
679281ac26 | ||
|
|
bde3d1408b | ||
|
|
3621a96d3f | ||
|
|
6583ad114f | ||
|
|
f4e42165bf | ||
|
|
00d7be735a | ||
|
|
1388fba8e4 | ||
|
|
c43644861f | ||
|
|
831ca599b8 | ||
|
|
43fbd03292 | ||
|
|
1efd04f6c5 | ||
|
|
e89ccfd74b | ||
|
|
622486160c | ||
|
|
6d172cae9e | ||
|
|
91b60fb147 | ||
|
|
3f9b7f48d5 | ||
|
|
3599fdec65 | ||
|
|
00e86974b3 | ||
|
|
2aa897eb80 | ||
|
|
ce9706b860 | ||
|
|
e140dfe556 | ||
|
|
4a1b845ac2 | ||
|
|
573f8af57e | ||
|
|
33445c93e8 | ||
|
|
f7fcb659f7 | ||
|
|
b0aa723c2f | ||
|
|
48dca41738 | ||
|
|
873d6f487f | ||
|
|
c8d73b129e | ||
|
|
c63e9e1cd2 | ||
|
|
def2ad84bb | ||
|
|
52510e9817 | ||
|
|
69a95488ce | ||
|
|
636841f647 | ||
|
|
1ea521236c | ||
|
|
6966efeda5 | ||
|
|
66ad0c3548 | ||
|
|
e061ccbf79 | ||
|
|
30cc6a111d | ||
|
|
8416ce5124 | ||
|
|
0cacfe391b | ||
|
|
c0eaab48cd | ||
|
|
c696bc3545 | ||
|
|
72505954a9 | ||
|
|
49159dba5c | ||
|
|
41e2d89abc | ||
|
|
6e7de97d93 | ||
|
|
2f20e6b498 | ||
|
|
ecf9158fd7 | ||
|
|
dc7e7321f3 | ||
|
|
0f087ad2a0 | ||
|
|
ff19a6e9eb | ||
|
|
8cec0c0779 | ||
|
|
314384f8e7 | ||
|
|
6c75ad95ad | ||
|
|
76e3b3caff | ||
|
|
c8f71480c9 | ||
|
|
9172a0cf9c | ||
|
|
eb476c6023 | ||
|
|
da4f3bd38e | ||
|
|
c65d628a36 | ||
|
|
589b8c3afc | ||
|
|
2684051da0 | ||
|
|
200db68d05 | ||
|
|
f6d8d32a01 | ||
|
|
72d5af5bb0 | ||
|
|
aabbc54cbb | ||
|
|
09b523bf56 | ||
|
|
5dc7f522bf | ||
|
|
fd58a42824 | ||
|
|
0a6d1c85ed | ||
|
|
13f84a260c | ||
|
|
4132ad9a97 | ||
|
|
1faba92ecc | ||
|
|
b93fc86663 | ||
|
|
7123036e8d | ||
|
|
cce4ea4dc6 | ||
|
|
b8f7df1555 | ||
|
|
ba8cc0477c | ||
|
|
010026bc88 | ||
|
|
a1ba34caf2 | ||
|
|
1214380bee | ||
|
|
03eb09c68c | ||
|
|
055d503862 | ||
|
|
5755ff762b | ||
|
|
88e24787e8 | ||
|
|
ed32d07ac4 | ||
|
|
3a917fcebe | ||
|
|
3535068ca8 | ||
|
|
4c39d4b96b | ||
|
|
8e864c6793 | ||
|
|
aeffecced3 | ||
|
|
4be42dc1e2 | ||
|
|
bdf6c62c29 | ||
|
|
a4592e5199 | ||
|
|
a5268f082d | ||
|
|
b556e72922 | ||
|
|
3602d2533a | ||
|
|
681f2c766a | ||
|
|
6e0a136cd1 | ||
|
|
9e9945a1cc | ||
|
|
3368cead8b | ||
|
|
a7b5126b4a | ||
|
|
a9ef1a03dc | ||
|
|
bc0ec8dbfa | ||
|
|
7bef009922 | ||
|
|
9a467bc27b | ||
|
|
e3934c9403 | ||
|
|
af337aa4d0 | ||
|
|
313d3a01ec | ||
|
|
feb9876cf5 | ||
|
|
9c3a5cb820 | ||
|
|
e219f781bf | ||
|
|
21ecd6ae05 | ||
|
|
2449902c3d | ||
|
|
c1a55037ba | ||
|
|
646a8122c0 | ||
|
|
edc4e6c508 | ||
|
|
5ddeb7b156 | ||
|
|
306624e55f | ||
|
|
6a9ca65cbc | ||
|
|
7db0026a0c | ||
|
|
7358705248 | ||
|
|
0b9ee2eec9 | ||
|
|
42eaf7fc39 | ||
|
|
6783cac9b4 | ||
|
|
628106ce88 | ||
|
|
1ce69b31a7 | ||
|
|
912ac11842 | ||
|
|
5f2fad560c | ||
|
|
b300f364a7 | ||
|
|
049ec41d13 | ||
|
|
f0d17bc84e | ||
|
|
cc442d7409 | ||
|
|
80bac26f21 | ||
|
|
015c39e6f4 | ||
|
|
e0ec3a4ebd | ||
|
|
a022de5cc3 | ||
|
|
a7b5e1e18c | ||
|
|
b8695df1c8 | ||
|
|
5e4382d1d2 | ||
|
|
ce983f4906 | ||
|
|
4a8be29616 | ||
|
|
1622eb642a | ||
|
|
422d992484 | ||
|
|
1065af1233 | ||
|
|
dde39d83be | ||
|
|
94fe7617bf | ||
|
|
e781d85b7c | ||
|
|
c30c9ca380 | ||
|
|
c395916af9 | ||
|
|
1a4037e360 | ||
|
|
f207bfaddd | ||
|
|
3dd298c0c8 | ||
|
|
8e2e4da7c3 | ||
|
|
418e3c109c | ||
|
|
cfda63a609 | ||
|
|
ba68276bfb | ||
|
|
97c700024a | ||
|
|
d4f67d726c | ||
|
|
ea87fa3e5a | ||
|
|
d12d247b3e | ||
|
|
c4a23915bd | ||
|
|
8645c57d13 | ||
|
|
b170e0bc17 | ||
|
|
4199f514f9 | ||
|
|
c96efb3bb4 | ||
|
|
d5571e0196 | ||
|
|
9f7f87b4ae | ||
|
|
4b03fce85d | ||
|
|
4b28d1c635 | ||
|
|
a5e803177f | ||
|
|
9462f284a4 | ||
|
|
4b0f6bd61b | ||
|
|
d6c85214ef | ||
|
|
de770cf801 | ||
|
|
dae0f740c2 | ||
|
|
9452c3e74c | ||
|
|
f826f1d543 | ||
|
|
fd13c80e5a | ||
|
|
75871348d5 | ||
|
|
0140e88595 | ||
|
|
e620b1254b | ||
|
|
77cfcf6c58 | ||
|
|
04e9df3d0a | ||
|
|
a953134089 | ||
|
|
88b9c0d783 | ||
|
|
ff7e0ba95b | ||
|
|
53dfbcaf00 | ||
|
|
d8fca22c00 | ||
|
|
4fb5963bd9 | ||
|
|
862d81f446 | ||
|
|
8ae7943b94 | ||
|
|
46e947d779 | ||
|
|
7780d6020f | ||
|
|
e5803b46f2 | ||
|
|
d776090298 | ||
|
|
f2f3111d49 | ||
|
|
dff1b957cd | ||
|
|
b1ec50c3cb | ||
|
|
85aa188287 | ||
|
|
e7d6526da9 | ||
|
|
d67c6fcc00 | ||
|
|
d9bf084300 | ||
|
|
da3977b0e2 | ||
|
|
7df930bccc | ||
|
|
11f73a1a53 | ||
|
|
2bbcd55e48 | ||
|
|
43890b8d5c | ||
|
|
b6b8e3741b | ||
|
|
c39f00c336 | ||
|
|
e48eaacfef | ||
|
|
6d97691388 | ||
|
|
d64cc996bc | ||
|
|
617d66c9b9 | ||
|
|
0fc50d2105 | ||
|
|
2bb9f28569 | ||
|
|
c1580b8359 | ||
|
|
dde235825d | ||
|
|
eac13b4025 | ||
|
|
59cc9c2706 | ||
|
|
40e20a0d33 | ||
|
|
a42679acaa | ||
|
|
fd2e8fcdb8 | ||
|
|
ebd5a15152 | ||
|
|
6cdd110304 | ||
|
|
3181cda2e0 | ||
|
|
2659c7fc02 | ||
|
|
5747ae3429 | ||
|
|
8d60be0e3f | ||
|
|
7e3f801a5a | ||
|
|
687ef53c65 | ||
|
|
a1fe9e727e | ||
|
|
6d625c88d3 | ||
|
|
c314e2de6b | ||
|
|
bd08b95390 | ||
|
|
aed0cca7e1 | ||
|
|
7d7761fb8d | ||
|
|
6d2fb5acc2 | ||
|
|
ec35d846cf | ||
|
|
1d23907652 | ||
|
|
efc6c10ace | ||
|
|
47a8ad5244 | ||
|
|
5d879c4619 | ||
|
|
6e0dd3d064 | ||
|
|
4657a403d0 | ||
|
|
bf09a07fe2 | ||
|
|
927802ca88 | ||
|
|
3d5bc9ef0e | ||
|
|
706ad7e44d | ||
|
|
88206f521b | ||
|
|
af786b4c11 | ||
|
|
d4c749c997 | ||
|
|
b5aa25cd0b | ||
|
|
ae49fb6dad | ||
|
|
3e24d17951 | ||
|
|
22030e6907 | ||
|
|
39fdcb9af3 | ||
|
|
866998f98b | ||
|
|
0fa71f76e8 | ||
|
|
317f655128 | ||
|
|
a871426f31 | ||
|
|
2caf9ff38a | ||
|
|
1de3a57d50 | ||
|
|
bed1355d63 | ||
|
|
189f9b4f26 | ||
|
|
931b5a22e6 | ||
|
|
4c9e0a8b74 | ||
|
|
589472314b | ||
|
|
f11657fdee | ||
|
|
e4ec081357 | ||
|
|
9e55527a2b | ||
|
|
cca70ffcec | ||
|
|
2a6a717ef2 | ||
|
|
11043554c2 | ||
|
|
cdbef468d7 | ||
|
|
4705b75580 | ||
|
|
245ccf889e | ||
|
|
2ad0a3d622 | ||
|
|
e775f774b5 | ||
|
|
ad2aec44e5 | ||
|
|
11d3effc28 | ||
|
|
2ef12e2868 | ||
|
|
ed8c5ce1ea | ||
|
|
33e507efe5 | ||
|
|
c425f5cf6a | ||
|
|
fc76d7e609 | ||
|
|
1b24bf33ed | ||
|
|
16036897cb | ||
|
|
58ff378735 | ||
|
|
1cee04a233 | ||
|
|
ac11a0efb8 | ||
|
|
f2ea1e3f7a | ||
|
|
76dbbdfbf9 | ||
|
|
153970c146 | ||
|
|
c21287e642 | ||
|
|
284942a82e | ||
|
|
e796916e73 | ||
|
|
004db1762c | ||
|
|
8e05ba8820 | ||
|
|
f4f386b5e5 | ||
|
|
ecbf8bec23 | ||
|
|
5d8c09ccda | ||
|
|
47c5c3d8a0 | ||
|
|
3ddce16d5b | ||
|
|
acefdc44d9 | ||
|
|
d05c5b9d7f | ||
|
|
5d1237d1f4 | ||
|
|
6bac8631ac | ||
|
|
2c803327ad | ||
|
|
451ae5bc51 | ||
|
|
f41ce8c878 | ||
|
|
177c54c813 | ||
|
|
3765e32fd4 | ||
|
|
aab7a16d18 | ||
|
|
f2463af63c | ||
|
|
212c2c3b56 | ||
|
|
3ab00b0cdd | ||
|
|
fad85e9299 | ||
|
|
3cd5e203e2 | ||
|
|
7b081fa870 | ||
|
|
5ce22051d4 | ||
|
|
1e59489fa5 | ||
|
|
1e691b1255 | ||
|
|
61e1bef63f | ||
|
|
46dbb78fbf | ||
|
|
99890707b3 | ||
|
|
f54c841fdc | ||
|
|
e2a6009ee9 | ||
|
|
a34dd15040 | ||
|
|
24c934003d | ||
|
|
5300e1a245 | ||
|
|
90381c670b | ||
|
|
23edea93db | ||
|
|
a51712e363 | ||
|
|
4310e979e1 | ||
|
|
f690b76d87 | ||
|
|
ac29594b67 | ||
|
|
93f535bb87 | ||
|
|
076eb3cf42 | ||
|
|
7b2e0fffbd | ||
|
|
8eaab0dbc3 | ||
|
|
eaa5bc896e | ||
|
|
42d1db5fc3 | ||
|
|
78c017ddaf | ||
|
|
231551d743 | ||
|
|
d0984eaba7 | ||
|
|
15854a72d1 | ||
|
|
7fd2bf0eda | ||
|
|
6a10a2167e | ||
|
|
1ba99d255c | ||
|
|
ff1c2947b6 | ||
|
|
a690d2e0db | ||
|
|
a3df327396 | ||
|
|
ede0016d8e | ||
|
|
c19b091795 | ||
|
|
9e62c8b9f3 | ||
|
|
9f30dc181c | ||
|
|
37fe44f860 | ||
|
|
68aaab8467 | ||
|
|
b951ed1fcd | ||
|
|
9bd9dc568a | ||
|
|
29e4356fee | ||
|
|
0ed31eaa99 | ||
|
|
1e9869b217 | ||
|
|
78cfc2c827 | ||
|
|
a6e5a0d125 | ||
|
|
ea13aecd27 | ||
|
|
d838e8f28f | ||
|
|
c9a7b9e17c | ||
|
|
8fe2d0bc0e | ||
|
|
71e2f1e1e6 | ||
|
|
1ce9d1b9b2 | ||
|
|
b3d6ae467f | ||
|
|
da8d720dc4 | ||
|
|
824c38f8ce | ||
|
|
b0579a70d8 | ||
|
|
f195eb6d0d | ||
|
|
f9fe2d0976 | ||
|
|
2aeadc8f67 | ||
|
|
ee3eaa1166 | ||
|
|
5dce143756 | ||
|
|
33a0568895 | ||
|
|
721d728906 | ||
|
|
c55cbaf373 | ||
|
|
3fd5cfdecd | ||
|
|
f87d3359c2 | ||
|
|
37f27849bd | ||
|
|
6fe326f052 | ||
|
|
de7e4e9c69 | ||
|
|
6861fe303b | ||
|
|
ba3214bf10 | ||
|
|
bcce77495a | ||
|
|
a165d2b27e | ||
|
|
e63714cab0 | ||
|
|
390c21dd91 | ||
|
|
9defea9c8e | ||
|
|
5906173de7 | ||
|
|
a81de48cfb | ||
|
|
2be61e8b1c | ||
|
|
4d7fc9f354 | ||
|
|
e11fd7b3a1 | ||
|
|
cc0348b113 | ||
|
|
77bb948bf2 | ||
|
|
8e2adcbce4 | ||
|
|
bb141203a8 | ||
|
|
579c22df65 | ||
|
|
694e8291de | ||
|
|
367259fd31 | ||
|
|
e54151d2eb | ||
|
|
ca2c687f26 | ||
|
|
d444a7080d | ||
|
|
08bfcded7f | ||
|
|
1c9e4f978b | ||
|
|
a5f1fba6ee | ||
|
|
88f1cf1913 | ||
|
|
1fb69d2861 | ||
|
|
3aeff66522 | ||
|
|
114cbe0ec5 | ||
|
|
bfe94222b7 | ||
|
|
30061abc54 | ||
|
|
126e07489b | ||
|
|
bd2577c089 | ||
|
|
5039c61e98 | ||
|
|
5f34ab3d7e | ||
|
|
fee47427cf | ||
|
|
15c8289a01 | ||
|
|
141a863079 | ||
|
|
6fa8d4be34 | ||
|
|
984e8a44d5 | ||
|
|
bdb296bce2 | ||
|
|
955a9084c3 | ||
|
|
70f486b0eb | ||
|
|
0bc383c6a8 | ||
|
|
0bb2b36585 | ||
|
|
9e3a24753a | ||
|
|
f2c400799e | ||
|
|
25a8c8a7e3 | ||
|
|
8f95d89fc6 | ||
|
|
39b0cdb9d5 | ||
|
|
42e06b848e | ||
|
|
7c3a13b1c4 | ||
|
|
151248fce2 | ||
|
|
6e8d6fe063 | ||
|
|
ca2da5e631 | ||
|
|
6cd3b70201 | ||
|
|
2dcfba75b5 | ||
|
|
44a51b03d3 | ||
|
|
6ee9e9831e | ||
|
|
68c717cee8 | ||
|
|
b15fc02c34 | ||
|
|
033ebfb497 | ||
|
|
4cceaa7650 | ||
|
|
5df47f1396 | ||
|
|
903f35c01b | ||
|
|
6a34f94277 | ||
|
|
dcc143eb7d | ||
|
|
fb47bf27e8 | ||
|
|
dcf969ff2e | ||
|
|
32c1318355 | ||
|
|
8ca6b89da4 | ||
|
|
b46c3a15f3 | ||
|
|
cbc5e014f7 | ||
|
|
8925b248e4 | ||
|
|
cc6aa2bbc5 | ||
|
|
1989aaf8b4 | ||
|
|
c90c91b748 | ||
|
|
bca23db213 | ||
|
|
c9a92d2043 | ||
|
|
c48e06fa46 | ||
|
|
5529eced91 | ||
|
|
2a0d935471 | ||
|
|
6c68fda572 | ||
|
|
861c1fc3dc | ||
|
|
5971bb4255 | ||
|
|
76ba360631 | ||
|
|
89d3ff3c90 | ||
|
|
34024a913d | ||
|
|
a858380121 | ||
|
|
e1dc870005 | ||
|
|
2fc1cac869 | ||
|
|
f627ff4958 | ||
|
|
5ff8e51948 | ||
|
|
538f25c6bb | ||
|
|
65100ba279 | ||
|
|
79fd470bbf | ||
|
|
866d74665b | ||
|
|
29da74f038 | ||
|
|
3c8ac35a46 | ||
|
|
afe957ba59 | ||
|
|
7e50e99351 | ||
|
|
62ce462cc8 | ||
|
|
108cb06f43 | ||
|
|
95a38675bc | ||
|
|
714681bc50 | ||
|
|
0f8d91d997 | ||
|
|
562297a82f | ||
|
|
b108bf06e5 | ||
|
|
3c819066fd | ||
|
|
5f30cb7e2e | ||
|
|
5a95b95801 | ||
|
|
eb573fdc8b | ||
|
|
238ea54e98 | ||
|
|
e4dfc47fb8 | ||
|
|
a1491c779a | ||
|
|
dabd7552be | ||
|
|
0a4c56af1f | ||
|
|
c3dae5b92c | ||
|
|
2c3105b526 | ||
|
|
20f5081ac8 | ||
|
|
3091eb9d14 | ||
|
|
5bdda42239 | ||
|
|
7eda8b7662 | ||
|
|
fc94ce5d2b | ||
|
|
e5d7161ab7 | ||
|
|
f725cb7fa4 | ||
|
|
830e689fe8 | ||
|
|
2832e8c638 | ||
|
|
d711cbab49 | ||
|
|
2e8fd737af | ||
|
|
a080ede15b | ||
|
|
ab3d41508f | ||
|
|
1ab4a5e925 | ||
|
|
543ce08be6 | ||
|
|
21829056ba | ||
|
|
1af59c87d0 | ||
|
|
799e6c082c | ||
|
|
09635cf0fd | ||
|
|
1dfbd30471 | ||
|
|
48e0a77d1f | ||
|
|
7ae8594c00 | ||
|
|
19663b0f38 | ||
|
|
4bcb9adb83 | ||
|
|
f7505298d7 | ||
|
|
df722ffa8b | ||
|
|
2a852fe08d | ||
|
|
540f796200 | ||
|
|
b726ac84fe | ||
|
|
61ac2bb6a3 | ||
|
|
5d702b3992 | ||
|
|
3bf4a004d4 | ||
|
|
7ac5876d2d | ||
|
|
0f18c612af | ||
|
|
03f4a3c478 | ||
|
|
7069343cf4 | ||
|
|
7fae79f2c5 | ||
|
|
66b714ed39 | ||
|
|
d371ebe354 | ||
|
|
9093d0d5e5 | ||
|
|
1139df0637 | ||
|
|
c1810de316 | ||
|
|
863ced57f8 | ||
|
|
2147aeb4ae | ||
|
|
a810b4fc9a | ||
|
|
abcbb61b4c | ||
|
|
83332223ef | ||
|
|
fd8d981ea0 | ||
|
|
03e3ade09d | ||
|
|
68305f2e00 | ||
|
|
b7d6b06242 | ||
|
|
9098050c5a | ||
|
|
0147ec0a6a | ||
|
|
c6b71605d0 | ||
|
|
64009c82e9 | ||
|
|
5b24cb370f | ||
|
|
2d261cd97b | ||
|
|
9455d91b3d | ||
|
|
cb645c56b4 | ||
|
|
1a6b91dee5 | ||
|
|
8d2edad488 | ||
|
|
522e26b1fa | ||
|
|
259b22c255 | ||
|
|
b61cf82b46 | ||
|
|
4f06f7424c | ||
|
|
d2d65437f8 | ||
|
|
3ae0f7558e | ||
|
|
604801686d | ||
|
|
554d4190ff | ||
|
|
1d71390349 | ||
|
|
fe24c6d682 | ||
|
|
4359d91a23 | ||
|
|
ae42eac7fd | ||
|
|
37a8888a32 | ||
|
|
2d7e065d39 | ||
|
|
35cf640691 | ||
|
|
b308fbe0ad | ||
|
|
d5e2b51b6d | ||
|
|
9b7844542d | ||
|
|
9f6fac0d58 | ||
|
|
f6011dc3f2 | ||
|
|
fdb7fa21f6 | ||
|
|
29bbe41e51 | ||
|
|
004ada8204 | ||
|
|
9a2894944c | ||
|
|
dfcff5029b | ||
|
|
853fc600dd | ||
|
|
a546b21755 | ||
|
|
e40c4e3779 | ||
|
|
60cbf6cff3 | ||
|
|
6d3f4b98d7 | ||
|
|
4812a2b401 | ||
|
|
5f99376d58 | ||
|
|
3e76c142c3 | ||
|
|
28f23a73af | ||
|
|
68b94fed8e | ||
|
|
657b02727c | ||
|
|
7d7a10073c | ||
|
|
9d5f0c791c | ||
|
|
212493e4ff | ||
|
|
9fc8e9c6d7 | ||
|
|
f69ddb71a0 | ||
|
|
290beec0c5 | ||
|
|
dcb2f6f8cd | ||
|
|
d200845906 | ||
|
|
a52e02695d | ||
|
|
febd7c3063 | ||
|
|
d5ae0b99f0 | ||
|
|
8b10c608fc | ||
|
|
0d49b91cc6 | ||
|
|
2af4b83e09 | ||
|
|
cde3ca3d9e | ||
|
|
429798190a | ||
|
|
583db4c70f | ||
|
|
3ec35eec91 | ||
|
|
65bfbfc7fd | ||
|
|
1b93701df2 | ||
|
|
d6debc55f5 | ||
|
|
87fd9ae686 | ||
|
|
3225a3b337 | ||
|
|
4eb98a6c31 | ||
|
|
38a6e2fc98 | ||
|
|
c171cf1487 | ||
|
|
b64a0f1d55 | ||
|
|
3ab124b2db | ||
|
|
d710f3995f | ||
|
|
f53c209082 | ||
|
|
9997be3462 | ||
|
|
c3b06e375c | ||
|
|
1476c5a932 | ||
|
|
8e1c9b9703 | ||
|
|
27c89f7cc7 | ||
|
|
9210198766 | ||
|
|
ce6fa0bf8f | ||
|
|
cd6629b424 | ||
|
|
f25a62ad71 | ||
|
|
cec3c872b6 | ||
|
|
e666e71281 | ||
|
|
3d0c303d41 | ||
|
|
d70a97cf77 | ||
|
|
c67c433258 | ||
|
|
0da6bd5ab6 | ||
|
|
e5cdb1580e | ||
|
|
2c10292073 | ||
|
|
30036a456e | ||
|
|
6349ae9e2b | ||
|
|
8d746669c3 | ||
|
|
0081abc9a7 | ||
|
|
a2f9ac05fe | ||
|
|
6c1f24bad7 | ||
|
|
77cd01e91f | ||
|
|
5487aac81d | ||
|
|
8a6257dc63 | ||
|
|
8146c69ebf | ||
|
|
78ece1abf2 | ||
|
|
baab35c4c5 | ||
|
|
357f9d46f9 | ||
|
|
4eb26302a7 | ||
|
|
a2071d9527 | ||
|
|
65c32c52ff | ||
|
|
fa4353f47d | ||
|
|
46fea1a3e5 | ||
|
|
497cf111d1 | ||
|
|
b1f2fd26e3 | ||
|
|
ae60d4a60f | ||
|
|
ae78e4691d | ||
|
|
9c058cf6d6 | ||
|
|
1ac9af23c5 | ||
|
|
f783bb660e | ||
|
|
e5c271ca1c | ||
|
|
f927247955 | ||
|
|
087e38bec8 | ||
|
|
bab3c8e6b0 | ||
|
|
54ac5d9e27 | ||
|
|
36519d9053 | ||
|
|
ccce4c622d | ||
|
|
4cbf677e55 | ||
|
|
1dbac44a93 | ||
|
|
7e1cfb5cd2 | ||
|
|
df9fb956fa | ||
|
|
16dc383f2b | ||
|
|
0dd7c4851b | ||
|
|
fce4e75eef | ||
|
|
16b578a76d | ||
|
|
483db9881e | ||
|
|
a4053c6084 | ||
|
|
e4f4b46047 | ||
|
|
36f77d5408 | ||
|
|
b3533771dc | ||
|
|
45372cba92 | ||
|
|
dd7fb5bb0d | ||
|
|
41bdc19a22 | ||
|
|
8b7f22021a | ||
|
|
f0160e4d2b | ||
|
|
39d727f98f | ||
|
|
13cc8ac70d | ||
|
|
eb2a219ec8 | ||
|
|
4a59565b20 | ||
|
|
4b7fa96308 | ||
|
|
1ebc8a1e7b | ||
|
|
df2a9aae20 | ||
|
|
dd8287c9d7 | ||
|
|
22fcb08dad | ||
|
|
8c2cf181bd | ||
|
|
69adae36b6 | ||
|
|
8ab700dfa9 | ||
|
|
0177529b45 | ||
|
|
4c6ae3364e | ||
|
|
6df8511a6d | ||
|
|
6fa39517f8 | ||
|
|
c69ce39424 | ||
|
|
a47f6736ac | ||
|
|
79bd7cfff3 | ||
|
|
bc02f23f0f | ||
|
|
715dffb6c8 | ||
|
|
702b3eb971 | ||
|
|
17f62bf491 | ||
|
|
28471302ee | ||
|
|
d8bfdd5d3b | ||
|
|
a36e68e9c3 | ||
|
|
343aed16fb | ||
|
|
142d873c8b | ||
|
|
a94b3e05d3 | ||
|
|
26a79d58f0 | ||
|
|
7c5e68e47d | ||
|
|
ba68627060 | ||
|
|
5bb6a7d4d4 | ||
|
|
76f7999046 | ||
|
|
547693df4f | ||
|
|
0206f8211a | ||
|
|
e061f2e259 | ||
|
|
560ccff04a | ||
|
|
2f0a84557b | ||
|
|
3ae7318ded | ||
|
|
6b7d66e833 | ||
|
|
ec8e594a5c | ||
|
|
858041772e | ||
|
|
b355c04d87 | ||
|
|
4918eaf752 | ||
|
|
80706f006d | ||
|
|
8a7fec1207 | ||
|
|
22a5b6e85e | ||
|
|
a51c533712 | ||
|
|
1f74674a11 | ||
|
|
2eada58ce5 | ||
|
|
31e74bd4a8 | ||
|
|
903f73ee78 | ||
|
|
b21198b239 | ||
|
|
e20ff09457 | ||
|
|
674393eabc | ||
|
|
d78a131713 | ||
|
|
e3816bf05b | ||
|
|
37fe1c60cc | ||
|
|
e705a0d32b | ||
|
|
eb658a644b | ||
|
|
cb905bfc8c | ||
|
|
d0accf6a84 | ||
|
|
55e6f89fc1 | ||
|
|
60695a0ffc | ||
|
|
8a8e4655cd | ||
|
|
2f4b390be1 | ||
|
|
31146cc713 | ||
|
|
9e020ff268 | ||
|
|
7e825192d0 | ||
|
|
8871ae894f | ||
|
|
2808f4b1a2 | ||
|
|
0324c22061 | ||
|
|
57227f9544 | ||
|
|
59c5131f1a | ||
|
|
ccbc07d7d8 | ||
|
|
a0247f0036 | ||
|
|
0979c2767b | ||
|
|
9a9613bba3 | ||
|
|
6451f5f3b7 | ||
|
|
4a4430ce9b | ||
|
|
a38d3dcf72 | ||
|
|
60e1e0d037 | ||
|
|
8071b85b3d | ||
|
|
c867bfb846 | ||
|
|
24b32ab69b | ||
|
|
b1fc65262f | ||
|
|
5af3fea74c | ||
|
|
dde38985e4 | ||
|
|
3f0084fa1c | ||
|
|
8936d4fdce | ||
|
|
4c47b7d838 | ||
|
|
093a9cb8e4 | ||
|
|
f27b3f8933 | ||
|
|
74a9e48e55 | ||
|
|
bafef26ffc | ||
|
|
f8e66170bf | ||
|
|
00bf99fe5a | ||
|
|
05dd66177f | ||
|
|
d5a9e6401e | ||
|
|
660ba67433 | ||
|
|
7ad948065b | ||
|
|
40fcb85c93 | ||
|
|
dcddb80f7b | ||
|
|
8e349aea19 | ||
|
|
3d72725ae0 | ||
|
|
270cb340f5 | ||
|
|
42b5462889 | ||
|
|
b98ab8d011 | ||
|
|
b4264a8ba3 | ||
|
|
a395246d1e | ||
|
|
4b7a2afd07 | ||
|
|
7f49ff20cf | ||
|
|
4e9995e610 | ||
|
|
9f61442cec | ||
|
|
9339847d09 | ||
|
|
39e57cb3ef | ||
|
|
f3a574d05c | ||
|
|
297c76006a | ||
|
|
62d025d827 | ||
|
|
999799ea68 | ||
|
|
331f68253e | ||
|
|
70d3c7a4be | ||
|
|
b3c75a0286 | ||
|
|
9946120304 | ||
|
|
7030a67389 | ||
|
|
eda5ef6965 | ||
|
|
0324479fda | ||
|
|
aeafecb88d | ||
|
|
fde7fbe21a | ||
|
|
7116efc490 | ||
|
|
1ac6058200 | ||
|
|
32b80b64f4 | ||
|
|
9e348767dc | ||
|
|
bce72e1152 | ||
|
|
64aba75be2 | ||
|
|
ca65e13f9a | ||
|
|
54797607c6 | ||
|
|
e174254a95 | ||
|
|
4378e24b49 | ||
|
|
35d276ea98 | ||
|
|
678c89d9c0 | ||
|
|
0a42223de0 | ||
|
|
54d3f3b007 | ||
|
|
3ee58ee464 | ||
|
|
3b5ff016fe | ||
|
|
8a8e786f5e | ||
|
|
2a15f68ffb | ||
|
|
9387e014c1 | ||
|
|
1ef37fcaff | ||
|
|
c5906a481f | ||
|
|
ac0bc916a1 | ||
|
|
5bbe76d56e | ||
|
|
1e6195d74c | ||
|
|
85acea7e64 | ||
|
|
0e4ff99602 | ||
|
|
575d2a0940 | ||
|
|
c548462eef | ||
|
|
3b4cc66b24 | ||
|
|
6d7273f822 | ||
|
|
65014d330a | ||
|
|
d9e3cf0190 | ||
|
|
2d8ee54d28 | ||
|
|
98c3bb780d | ||
|
|
7247c10615 | ||
|
|
0787284d80 | ||
|
|
1c73bffc95 | ||
|
|
6f79815933 | ||
|
|
bb108d594a | ||
|
|
f7716c8834 | ||
|
|
5ba076b1dd | ||
|
|
7861b5a414 | ||
|
|
f36a5988d8 | ||
|
|
8b57240db3 | ||
|
|
7b52efd2d1 | ||
|
|
4901b838e2 | ||
|
|
2313a60f32 | ||
|
|
c38e958588 | ||
|
|
43b1e14f41 | ||
|
|
1e23b3c355 | ||
|
|
85e1556148 | ||
|
|
b65f333a89 | ||
|
|
3dbcbb8280 | ||
|
|
06e464854a | ||
|
|
f7a944a78a | ||
|
|
7f53531489 | ||
|
|
8386c2889f | ||
|
|
13d2332984 | ||
|
|
ce496c205a | ||
|
|
66547661b5 | ||
|
|
8568a29461 | ||
|
|
5d42229aec | ||
|
|
ad8c928cf1 | ||
|
|
cc90883342 | ||
|
|
a4071da5de | ||
|
|
c65dbf978b | ||
|
|
c4ea804fee | ||
|
|
f71720c809 | ||
|
|
03ba601491 | ||
|
|
bdee3fc1b5 | ||
|
|
2e472fa90d | ||
|
|
aad7e896f2 | ||
|
|
2478fc2967 | ||
|
|
2db96c968d | ||
|
|
9bc1a69ace | ||
|
|
cca74e9e54 | ||
|
|
8185411071 | ||
|
|
c89addab2e | ||
|
|
6c617bf9e7 | ||
|
|
5847e340bf | ||
|
|
5a5fd8f425 | ||
|
|
d6283e326d | ||
|
|
c63deb70dd | ||
|
|
c071781099 | ||
|
|
0820b4b70a | ||
|
|
ac42d11251 | ||
|
|
324248ff1e | ||
|
|
f32e83d43b | ||
|
|
3820aaed21 | ||
|
|
a45ef79c6f | ||
|
|
9b9266a6c9 | ||
|
|
06e22030c3 | ||
|
|
ca146c977b | ||
|
|
6a96a3617f | ||
|
|
6dd6e05e0c | ||
|
|
1fb33d51d3 | ||
|
|
4841f2d7f6 | ||
|
|
ad388ae056 | ||
|
|
a80769fae3 | ||
|
|
b34c6f4c34 | ||
|
|
d6d084fbd1 | ||
|
|
1fca44c0da | ||
|
|
8bf1d0b776 | ||
|
|
484412514f | ||
|
|
6987449a7e | ||
|
|
18dac92fc1 | ||
|
|
54774fcfe5 | ||
|
|
b431229273 | ||
|
|
658dde158e | ||
|
|
ced3ada6fc | ||
|
|
0db236639b | ||
|
|
036ce7f94f | ||
|
|
68c887ffe0 | ||
|
|
e96da49d0a | ||
|
|
794684bc4e | ||
|
|
dd944c5293 | ||
|
|
be878454a9 | ||
|
|
e567f81046 | ||
|
|
6164ca5f91 | ||
|
|
655332e3fd | ||
|
|
7e300fea87 | ||
|
|
cea3e0aba8 | ||
|
|
459e270561 | ||
|
|
cba660e785 | ||
|
|
758301a39d | ||
|
|
a8d0bae16e | ||
|
|
583cc39849 | ||
|
|
3585bd3d2d | ||
|
|
3a895b6418 | ||
|
|
bb67733723 | ||
|
|
f380fd553f | ||
|
|
d22ef12adf | ||
|
|
eaec088348 | ||
|
|
fa1d0b9151 | ||
|
|
c0a418b8b1 | ||
|
|
1a4f633a28 | ||
|
|
c92ae40db6 | ||
|
|
0b42bea600 | ||
|
|
d8565cb3d3 | ||
|
|
f68798c10e | ||
|
|
a2ab927433 | ||
|
|
c7eae71c56 | ||
|
|
c3784c2606 | ||
|
|
60fe263b53 | ||
|
|
aaa0cfd0c8 | ||
|
|
a209b2774a | ||
|
|
84d67b6166 | ||
|
|
a7a215e6c7 | ||
|
|
8686fe4e97 | ||
|
|
afe2e8f95b | ||
|
|
f580226c27 | ||
|
|
e93db46e0a | ||
|
|
daea4b7f84 | ||
|
|
eb942b07b1 | ||
|
|
804ca38db7 | ||
|
|
7278c0beae | ||
|
|
096e3a0f5f | ||
|
|
5090c15f20 | ||
|
|
cb7e74fc21 | ||
|
|
ff90041ed4 | ||
|
|
f8fbe1844a | ||
|
|
1902172a04 | ||
|
|
2df384b847 | ||
|
|
65bb35b4de | ||
|
|
97516100f5 | ||
|
|
009ec7a59b | ||
|
|
02890c2b69 | ||
|
|
0f690bf00e | ||
|
|
cfe427b34c | ||
|
|
a44c76cdc3 | ||
|
|
730bde3d0d | ||
|
|
aa006fe22a | ||
|
|
ca77090ecd | ||
|
|
5619d1a4c5 | ||
|
|
b7c80c397d | ||
|
|
d1e7cd2f85 | ||
|
|
7da7aeb796 | ||
|
|
26b46166aa | ||
|
|
6d5eb51a5d | ||
|
|
917b6b318f | ||
|
|
bfd95687b8 | ||
|
|
4198ee1af1 | ||
|
|
e9b1280ae6 | ||
|
|
3c42831db0 | ||
|
|
b8482006b9 | ||
|
|
53f0c33c1d | ||
|
|
563516901e | ||
|
|
73b40fd8b7 | ||
|
|
08224a8486 | ||
|
|
993f3d3aa8 | ||
|
|
3a975de136 | ||
|
|
48b5195798 | ||
|
|
8eb34c7539 | ||
|
|
d75d7a9209 | ||
|
|
ddf851f1eb | ||
|
|
889dd00c23 | ||
|
|
c5ea2a1aa1 | ||
|
|
1489aff78e | ||
|
|
640296d42f | ||
|
|
3b12b2a5f6 | ||
|
|
d5c41a5167 | ||
|
|
58bf86d25d | ||
|
|
d73034d6d9 | ||
|
|
151a613dcc | ||
|
|
4bb741a42f | ||
|
|
cc9c8d3db3 | ||
|
|
c3d4831550 | ||
|
|
31e385fbfb | ||
|
|
a8c47d717c | ||
|
|
9a25157d3f | ||
|
|
9176e0f7b7 | ||
|
|
ff7aa890a6 | ||
|
|
03312c1592 | ||
|
|
9d1ec2c636 | ||
|
|
c49c31a44e | ||
|
|
947c1f562f | ||
|
|
2d1dbb6988 | ||
|
|
622e46ff67 | ||
|
|
4ff45a65c3 | ||
|
|
a62676061b | ||
|
|
11d77d2265 | ||
|
|
1e7d44b250 | ||
|
|
ffd86c6d8c | ||
|
|
a566c9460d | ||
|
|
24edae3d58 | ||
|
|
97876344c4 | ||
|
|
95dbeb9a47 | ||
|
|
3fc64859b1 | ||
|
|
896fe3b5b2 | ||
|
|
85404781a3 | ||
|
|
efe2abc86e | ||
|
|
b70b7a0b40 | ||
|
|
865c80f87b | ||
|
|
23a91aab12 | ||
|
|
085a3cbb50 | ||
|
|
fb9d875c31 | ||
|
|
5ee15c6f68 | ||
|
|
9853205849 | ||
|
|
2c9ce7e8fc | ||
|
|
9753ae60e2 | ||
|
|
bd66f1e682 | ||
|
|
ed6a45c119 | ||
|
|
8f53ce27fc | ||
|
|
f7ae2e6689 | ||
|
|
c6cc47192c | ||
|
|
1c447fe369 | ||
|
|
6b5c92db48 | ||
|
|
427e020d27 | ||
|
|
18084995b2 | ||
|
|
f894fdf564 | ||
|
|
0b0a964a90 | ||
|
|
d6df979d0d | ||
|
|
c366c37afe | ||
|
|
20cbd239b2 | ||
|
|
a9c7595ee7 | ||
|
|
3f09e3ca64 | ||
|
|
ed42db7a0d | ||
|
|
c85daeb46e | ||
|
|
3f2b93f1f8 | ||
|
|
78d2e66c56 | ||
|
|
0f2de651ff | ||
|
|
2eb7c7237e | ||
|
|
3b8f62ff11 | ||
|
|
f8bf9370de | ||
|
|
30cd0ec089 | ||
|
|
e984be9289 | ||
|
|
8069787754 | ||
|
|
343e442dff | ||
|
|
313ccdeae9 | ||
|
|
fdec8ebfd3 | ||
|
|
efddd86263 | ||
|
|
7d18bde40b | ||
|
|
7fee410be4 | ||
|
|
ebc2516a53 | ||
|
|
ade4d1d782 | ||
|
|
07f7a288d2 | ||
|
|
380ed16caf | ||
|
|
db654a10d1 | ||
|
|
2cf84d35cd | ||
|
|
a4eac86913 | ||
|
|
5168be45a8 | ||
|
|
163ab43da3 | ||
|
|
e5fa517270 | ||
|
|
b8b8ea5ce2 | ||
|
|
991b147af5 | ||
|
|
ecff62d0fa | ||
|
|
cdec4c0879 | ||
|
|
e8085ac4cf | ||
|
|
327062112b | ||
|
|
6dfc23c33a | ||
|
|
a601b0ab35 | ||
|
|
a48c8ca87a | ||
|
|
b59e64a3d1 | ||
|
|
5fc62dd06d | ||
|
|
d81a0cae91 | ||
|
|
50e31c6b69 | ||
|
|
92d3d88127 | ||
|
|
517fdb2095 | ||
|
|
d16ebb02b4 | ||
|
|
a5c64c8b7b | ||
|
|
5287a93484 | ||
|
|
06e84d9032 | ||
|
|
3a63dd032a | ||
|
|
889e227523 | ||
|
|
57d895daf5 | ||
|
|
a744394faa | ||
|
|
64e3c25bad | ||
|
|
75f85e1fb2 | ||
|
|
00bd4cab37 | ||
|
|
a9527f59a9 | ||
|
|
77661930f0 | ||
|
|
80a09bd9a0 | ||
|
|
6eb7cfbdc2 | ||
|
|
fb186530aa | ||
|
|
6c121ccb90 | ||
|
|
c08ad3b365 | ||
|
|
1668bc88ad | ||
|
|
3a43f62460 | ||
|
|
bfba5179d1 | ||
|
|
78bf7856dc | ||
|
|
e0c708f677 | ||
|
|
794d6824e8 | ||
|
|
15573a7bee | ||
|
|
31c61a79c6 | ||
|
|
87ca427094 | ||
|
|
99bdc904e0 | ||
|
|
2fdee68feb | ||
|
|
7be014f83e | ||
|
|
5668fe0a33 | ||
|
|
32c07efe19 | ||
|
|
21b23d0f79 | ||
|
|
793d0dd13f | ||
|
|
14e8ff4c1b | ||
|
|
416ab06997 | ||
|
|
493cd60dae | ||
|
|
e0948e1e9e | ||
|
|
5776b8c044 | ||
|
|
38ab4105d8 | ||
|
|
5ed9dadcc2 | ||
|
|
357d7e2381 | ||
|
|
8cfaab3e9f | ||
|
|
fef2404357 | ||
|
|
1aa1bce8c8 | ||
|
|
124b2761f6 | ||
|
|
066ca1af7c | ||
|
|
c20520879b | ||
|
|
4fa5b2b856 | ||
|
|
5c1b1fad76 | ||
|
|
c18d248c06 | ||
|
|
d46ee7f673 | ||
|
|
f2c0d99bd9 | ||
|
|
60ee0b9185 | ||
|
|
4b3e660ae7 | ||
|
|
0b42392bfc | ||
|
|
a94d7ce235 | ||
|
|
72aec432ed | ||
|
|
0e5db8d604 | ||
|
|
dc45fb4b84 | ||
|
|
6503d38fe3 | ||
|
|
32c89d9a11 | ||
|
|
f279465750 | ||
|
|
58ec1b022a | ||
|
|
612199429e | ||
|
|
e5482f9051 | ||
|
|
05df14fda2 | ||
|
|
29898ba1ba | ||
|
|
93d1cec503 | ||
|
|
9884f44122 | ||
|
|
d400456685 | ||
|
|
c39069cafd | ||
|
|
5fb0edc318 | ||
|
|
21a6b2d780 | ||
|
|
40c9063a54 | ||
|
|
59b0103ed5 | ||
|
|
f4730e9338 | ||
|
|
b7b520ca3c | ||
|
|
21d44e6a55 | ||
|
|
607886f0f0 | ||
|
|
7cd3c68256 | ||
|
|
6e37c1bd86 | ||
|
|
5db1a0748f | ||
|
|
a7584df4f4 | ||
|
|
4421197403 | ||
|
|
15b59467fb | ||
|
|
c95ff0a2ce | ||
|
|
7eff9df025 | ||
|
|
2f05e53e14 | ||
|
|
6089fe4036 | ||
|
|
10d9af0d86 | ||
|
|
c119d5062a | ||
|
|
324609ee60 | ||
|
|
a0a65f2b45 | ||
|
|
45e5ca704c | ||
|
|
f361be0c72 | ||
|
|
1611dc5703 | ||
|
|
04faad84a4 | ||
|
|
19c42e5838 | ||
|
|
4918b69d0a | ||
|
|
c7cec464aa | ||
|
|
91857c4d73 | ||
|
|
fc6f9f4258 | ||
|
|
34f9f9374a | ||
|
|
0ae4c1621f | ||
|
|
c393f5c045 | ||
|
|
1624290dc1 | ||
|
|
c6491990ac | ||
|
|
15dea17923 | ||
|
|
689d5ac7b2 | ||
|
|
2142e20e7d | ||
|
|
dc23126570 | ||
|
|
55856f9060 | ||
|
|
c756ce5fc8 | ||
|
|
0546f25d55 | ||
|
|
7b33717333 | ||
|
|
6ea6d16e58 | ||
|
|
a9b65c83aa | ||
|
|
a497802b50 | ||
|
|
42b0428b9a | ||
|
|
931c553e1d | ||
|
|
f3c0b92a3c | ||
|
|
970cabf241 | ||
|
|
e321ecde5d | ||
|
|
32ac326a77 | ||
|
|
134dcd4466 | ||
|
|
26a44353d4 | ||
|
|
55acb3ef28 | ||
|
|
0e96307726 | ||
|
|
0199a36238 | ||
|
|
3f2f6e83fa | ||
|
|
4fa780cac2 | ||
|
|
edb0f655b0 | ||
|
|
651ada7073 | ||
|
|
efb5d49d04 | ||
|
|
f78cc18b06 | ||
|
|
8acffa11e5 | ||
|
|
f4246807ff | ||
|
|
abf6e7131b | ||
|
|
b2688520cc | ||
|
|
fad0aea108 | ||
|
|
0b63773c83 | ||
|
|
3ef28009ac | ||
|
|
8979e2b191 | ||
|
|
d6910aa1e8 | ||
|
|
afc56c6053 | ||
|
|
1bd504cbfb | ||
|
|
2c089ddb5e | ||
|
|
0b5245643a | ||
|
|
ae35d43f7f | ||
|
|
fe55682c9f | ||
|
|
0d3e6f17e2 | ||
|
|
d5659c4278 | ||
|
|
69b87b9026 | ||
|
|
168bcd3a37 | ||
|
|
e3b6be0cd0 | ||
|
|
eeceda0ca8 | ||
|
|
aa903039c8 | ||
|
|
73d81d0cdb | ||
|
|
01fe539af6 | ||
|
|
c08063ca57 | ||
|
|
60d4af2890 | ||
|
|
6378f074a8 | ||
|
|
5082ec86fd | ||
|
|
6cff5bb099 | ||
|
|
d54562d56f | ||
|
|
2b45a8fae5 | ||
|
|
8654df8994 | ||
|
|
4d5145c17e | ||
|
|
850921bca9 | ||
|
|
1dc6470419 | ||
|
|
b5c197f499 | ||
|
|
d417655a86 | ||
|
|
ebca9f8290 | ||
|
|
53b1f89b30 | ||
|
|
6885191877 | ||
|
|
e69d9fe8b8 | ||
|
|
d6a1f1ae15 | ||
|
|
a7813f4442 | ||
|
|
1e4664987a | ||
|
|
7a819f5d58 | ||
|
|
45ef56e9da | ||
|
|
f43c7aa5d0 | ||
|
|
8d88711e59 | ||
|
|
280c0b60e9 | ||
|
|
605f8f6615 | ||
|
|
0e88b4de1b | ||
|
|
b02aa923d7 | ||
|
|
680c927e1d | ||
|
|
4b2e65abdc | ||
|
|
e501bf6b05 | ||
|
|
80bf2582bd | ||
|
|
586da4424d | ||
|
|
8e0e8c2407 | ||
|
|
40461ac883 | ||
|
|
9288a7e66e | ||
|
|
275db4ec72 | ||
|
|
67b2f8968d | ||
|
|
45ce35dfdb | ||
|
|
52aa9ab2fe | ||
|
|
d24725bd55 | ||
|
|
f368a67dec | ||
|
|
564d1744e1 | ||
|
|
d091ecfa5f | ||
|
|
7d23165e14 | ||
|
|
a9ca3278c6 | ||
|
|
e8734710ca | ||
|
|
f8a0e20df9 | ||
|
|
de90e4de54 | ||
|
|
03cb27f69a | ||
|
|
f86f1dd770 | ||
|
|
ec21ffc571 | ||
|
|
c73c9c74ba | ||
|
|
fd3c264d0c | ||
|
|
3983ba6cd0 | ||
|
|
dc0b5bdd11 | ||
|
|
5b3728186e | ||
|
|
8b0936b678 | ||
|
|
b99e81a389 | ||
|
|
0b8fb0f9a7 | ||
|
|
7d5bbe0130 | ||
|
|
c6637c6814 | ||
|
|
e302a011bb | ||
|
|
25f8bdaa28 | ||
|
|
e7d1018cbc | ||
|
|
e81fa69a03 | ||
|
|
176e76ad2d | ||
|
|
0925a91089 | ||
|
|
c04d0b147c | ||
|
|
a349eff1a3 | ||
|
|
2ddeda9a27 | ||
|
|
00b0fe921f | ||
|
|
bcc6cdf4b1 | ||
|
|
ba46cc3cd6 | ||
|
|
ed1bf609b8 | ||
|
|
13262b678d | ||
|
|
cf9321de23 | ||
|
|
ac7a78bdc0 | ||
|
|
640d3f1f6e | ||
|
|
d716a8081c | ||
|
|
1cec4e68b1 | ||
|
|
9d16299c5b | ||
|
|
cb92b1969c | ||
|
|
55a62d393d | ||
|
|
027b2252db | ||
|
|
ccfb88ddcc | ||
|
|
dde0736fcf | ||
|
|
6498bb5ee6 | ||
|
|
d7ca2db330 | ||
|
|
99b32795a5 | ||
|
|
840f670d5d | ||
|
|
e3c0a4c665 | ||
|
|
915506527a | ||
|
|
7541251344 | ||
|
|
40c9b42b24 | ||
|
|
dfab678070 | ||
|
|
3d371d5942 | ||
|
|
9bfbaa8ded | ||
|
|
be0fc95c45 | ||
|
|
dcb6113eb7 | ||
|
|
55491651f6 | ||
|
|
c1d471ebdc | ||
|
|
069e675f19 | ||
|
|
d34f719a4f | ||
|
|
4eb932a3f0 | ||
|
|
1ab27f2626 | ||
|
|
7d3ce7e602 | ||
|
|
cf530b2c60 | ||
|
|
d9bcd7f592 | ||
|
|
8b854b5cda | ||
|
|
f7b6677bb1 | ||
|
|
0cc5e8f5b8 | ||
|
|
8d153e3b2b | ||
|
|
d15428971c | ||
|
|
f34c2aa437 | ||
|
|
9b3ff5f81f | ||
|
|
e1f6937802 | ||
|
|
0c0834b30f | ||
|
|
5ad4b97205 | ||
|
|
c4ec249bc4 | ||
|
|
cf8d3965d5 | ||
|
|
3903fd9374 | ||
|
|
77d59dabe8 | ||
|
|
56ca737297 | ||
|
|
9edb539be3 | ||
|
|
31a773d200 | ||
|
|
61355eabf7 | ||
|
|
569874e51f | ||
|
|
00d47901fc | ||
|
|
d8b4ef55ce | ||
|
|
da41a4cab9 | ||
|
|
8a90ef0471 | ||
|
|
b4ab32a578 | ||
|
|
03aa53abc8 | ||
|
|
2ae5c0cd8e | ||
|
|
cacc632443 | ||
|
|
28f865ccfa | ||
|
|
a4c949e8b3 | ||
|
|
6098994397 | ||
|
|
5763ca30d6 | ||
|
|
7d039d1001 | ||
|
|
7fe74af906 | ||
|
|
80b72aa30b | ||
|
|
3ba0d241f9 | ||
|
|
67428aa0c7 | ||
|
|
b9a0256031 | ||
|
|
f3c2296636 | ||
|
|
b6e8f21975 | ||
|
|
284f80045f | ||
|
|
f589477aa8 | ||
|
|
29cb296d09 | ||
|
|
86caa1450a | ||
|
|
9dd4b9e67f | ||
|
|
e2e654f05b | ||
|
|
72dbc62b41 | ||
|
|
0a21014668 | ||
|
|
b6d9d2a26c | ||
|
|
25c3a7748c | ||
|
|
b2bcfdd6eb | ||
|
|
2a978db406 | ||
|
|
9e40d0d066 | ||
|
|
c912650d59 | ||
|
|
464ebcb471 | ||
|
|
463e0e59d7 | ||
|
|
b4e5d8ef20 | ||
|
|
126905aeb3 | ||
|
|
1af10d3364 | ||
|
|
6ad854c019 | ||
|
|
b30117aa4d | ||
|
|
5a66482d1e | ||
|
|
2628ec49bb | ||
|
|
f3d15cf173 | ||
|
|
bbcf55ce57 | ||
|
|
72fc3716e7 | ||
|
|
81a6cfaa88 | ||
|
|
aed5165ef3 | ||
|
|
eaf2933726 | ||
|
|
39da4d9d36 | ||
|
|
e5ebd7ff39 | ||
|
|
b6ae3e4e1e | ||
|
|
32d1488352 | ||
|
|
b08d0a388f | ||
|
|
7fe004a696 | ||
|
|
f620d033b0 | ||
|
|
ba071ba71f | ||
|
|
6f3197302d | ||
|
|
131a8ebf68 | ||
|
|
8b24c125c2 | ||
|
|
52293376ec | ||
|
|
f8ac59af6a | ||
|
|
5c791e2305 | ||
|
|
6641bc0631 | ||
|
|
da690aa750 | ||
|
|
fb7f041454 | ||
|
|
ec4554c76e | ||
|
|
068e85fe6e | ||
|
|
ba926c674e | ||
|
|
836f8f14c0 | ||
|
|
eeecac96e1 | ||
|
|
ecc62f222a | ||
|
|
9022f93811 | ||
|
|
e7225d35b2 | ||
|
|
454fc03038 | ||
|
|
9c0674fd83 | ||
|
|
7a20482ddf | ||
|
|
32ad47ba16 | ||
|
|
fc562cce0f | ||
|
|
b029b251db | ||
|
|
e3e28e727f | ||
|
|
50cb728db7 | ||
|
|
c654ba4d1b | ||
|
|
846e29b15e | ||
|
|
f2b4062d73 | ||
|
|
9051e6a6db | ||
|
|
b733129043 | ||
|
|
d46b571444 | ||
|
|
7d744b4ce0 | ||
|
|
801dda912c | ||
|
|
a20005409a | ||
|
|
6f1411d075 | ||
|
|
1aa263a6c0 | ||
|
|
9d511ac7dd | ||
|
|
122e98cc76 | ||
|
|
e445e5ea39 | ||
|
|
5b9212015b | ||
|
|
293292f341 | ||
|
|
57d8a4dbb1 | ||
|
|
e104f531f9 | ||
|
|
bf1361926f | ||
|
|
cc4f4d9eb4 | ||
|
|
706bad26f1 | ||
|
|
4ecefe6491 | ||
|
|
937e7353ce | ||
|
|
1dcf76fc0a | ||
|
|
9d794dcad7 | ||
|
|
d11b666755 | ||
|
|
7a444e4861 | ||
|
|
5992795579 | ||
|
|
4441d76a7f | ||
|
|
c1305b56e3 | ||
|
|
cc0440c029 | ||
|
|
f65591c170 | ||
|
|
9a32dce9d1 | ||
|
|
789bd3edae | ||
|
|
256cd426d9 | ||
|
|
58af2da105 | ||
|
|
e0de397273 | ||
|
|
75cc3cf29c | ||
|
|
af60758e2a | ||
|
|
01180e95a2 | ||
|
|
fa683ef7e1 | ||
|
|
462d17a429 | ||
|
|
17f71a40d4 | ||
|
|
de91a3a05a | ||
|
|
ead587ee88 | ||
|
|
62b3e6fb3a | ||
|
|
037ff15045 | ||
|
|
ed35b06934 | ||
|
|
3cfb1a13a7 | ||
|
|
d04745d859 | ||
|
|
58b18f36c5 | ||
|
|
7282d18d8f | ||
|
|
8e58fa22b4 | ||
|
|
58d6eb2c5a | ||
|
|
2f7c7498e2 | ||
|
|
bcf8dcd551 | ||
|
|
511f0a60bb | ||
|
|
72db0d815f | ||
|
|
280d0b7fdd | ||
|
|
42e4575cb7 | ||
|
|
28a4bb403a | ||
|
|
cca3c907db | ||
|
|
1a5b932742 | ||
|
|
a1d3f3008a | ||
|
|
902f2efbd2 | ||
|
|
2e534af146 | ||
|
|
23ca30c3c2 | ||
|
|
517eedad00 | ||
|
|
216ea1fb42 | ||
|
|
640d1a0ce3 | ||
|
|
bba7425b5f | ||
|
|
7a1a49bfb4 | ||
|
|
e451e6698c | ||
|
|
9af3f21404 | ||
|
|
7b14a9c0c2 | ||
|
|
0b65cc9510 | ||
|
|
7879ab9b61 | ||
|
|
e6bebcafb3 | ||
|
|
3b465cebb7 | ||
|
|
aeb211be06 | ||
|
|
ad992aea7b | ||
|
|
d848f72a0b | ||
|
|
0db087908d | ||
|
|
42138d04d6 | ||
|
|
4522a9d0d5 | ||
|
|
7440fcad0e | ||
|
|
fc51c1882f | ||
|
|
e24498b31f | ||
|
|
60fdc79563 | ||
|
|
6729ebc6ea | ||
|
|
c8ff216ce5 | ||
|
|
98c4150cfe | ||
|
|
128332d710 | ||
|
|
eabcb519a4 | ||
|
|
5e14cead3d | ||
|
|
b601f938ff | ||
|
|
4acfda32d0 |
3
.gitattributes
vendored
Normal file
3
.gitattributes
vendored
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
* text eol=lf
|
||||||
|
*.cmd text eol=crlf
|
||||||
|
*.png binary
|
||||||
37
.github/stale.yml
vendored
37
.github/stale.yml
vendored
@@ -1,19 +1,20 @@
|
|||||||
# Number of days of inactivity before an issue becomes stale
|
# Number of days of inactivity before an issue becomes stale
|
||||||
daysUntilStale: 60
|
daysUntilStale: 60
|
||||||
# Number of days of inactivity before a stale issue is closed
|
# Number of days of inactivity before a stale issue is closed
|
||||||
daysUntilClose: 7
|
daysUntilClose: 7
|
||||||
# Issues with these labels will never be considered stale
|
# Issues with these labels will never be considered stale
|
||||||
exemptLabels:
|
exemptLabels:
|
||||||
- pinned
|
- pinned
|
||||||
- security
|
- security
|
||||||
- enhancement
|
- enhancement
|
||||||
- bug
|
- feature-request
|
||||||
# Label to use when marking an issue as stale
|
- bug
|
||||||
staleLabel: wontfix
|
# Label to use when marking an issue as stale
|
||||||
# Comment to post when marking an issue as stale. Set to `false` to disable
|
staleLabel: wontfix
|
||||||
markComment: >
|
# Comment to post when marking an issue as stale. Set to `false` to disable
|
||||||
This issue has been automatically marked as stale because it has not had
|
markComment: >
|
||||||
recent activity. It will be closed if no further activity occurs. Thank you
|
This issue has been automatically marked as stale because it has not had
|
||||||
for your contributions.
|
recent activity. It will be closed if no further activity occurs. Thank you
|
||||||
# Comment to post when closing a stale issue. Set to `false` to disable
|
for your contributions.
|
||||||
|
# Comment to post when closing a stale issue. Set to `false` to disable
|
||||||
closeComment: false
|
closeComment: false
|
||||||
511
.github/workflows/ci.yml
vendored
511
.github/workflows/ci.yml
vendored
@@ -1,227 +1,284 @@
|
|||||||
name: ci
|
name: ci
|
||||||
permissions:
|
permissions:
|
||||||
contents: read
|
contents: read
|
||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
pull_request:
|
pull_request:
|
||||||
|
|
||||||
env:
|
env:
|
||||||
JAVA_VERSION: 21
|
JAVA_VERSION: 25
|
||||||
DOCKER_BUILD_SUMMARY: false
|
DOCKER_BUILD_SUMMARY: false
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build:
|
build:
|
||||||
if: github.event_name != 'pull_request' || github.actor != 'renovate[bot]' # renovate already triggers the build on pushes
|
if: github.event_name != 'pull_request' || github.actor != 'renovate[bot]' # renovate already triggers the build on pushes
|
||||||
|
|
||||||
strategy:
|
strategy:
|
||||||
matrix:
|
matrix:
|
||||||
os: [ "ubuntu-latest", "ubuntu-22.04-arm", "windows-latest" ]
|
os: [ "ubuntu-24.04", "ubuntu-22.04-arm", "windows-latest" ]
|
||||||
database: [ "h2", "postgresql", "mysql", "mariadb" ]
|
database: [ "h2", "postgresql", "mysql", "mariadb" ]
|
||||||
|
|
||||||
runs-on: ${{ matrix.os }}
|
runs-on: ${{ matrix.os }}
|
||||||
steps:
|
steps:
|
||||||
# Checkout
|
# Checkout
|
||||||
- name: Configure git to checkout as-is
|
- name: Checkout
|
||||||
run: git config --global core.autocrlf false
|
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||||
|
with:
|
||||||
- name: Checkout
|
fetch-depth: 0
|
||||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
|
|
||||||
with:
|
# Setup
|
||||||
fetch-depth: 0
|
- name: Set up GraalVM
|
||||||
|
uses: graalvm/setup-graalvm@f70bc8032f90a104502824a54bcb94b6f01715bc # v1.6.8
|
||||||
# Setup
|
with:
|
||||||
- name: Set up GraalVM
|
java-version: ${{ env.JAVA_VERSION }}
|
||||||
uses: graalvm/setup-graalvm@b0cb26a8da53cb3e97cdc0c827d8e3071240e730 # v1
|
distribution: "mandrel"
|
||||||
with:
|
cache: "maven"
|
||||||
java-version: ${{ env.JAVA_VERSION }}
|
|
||||||
distribution: "graalvm"
|
- name: Set up NodeJS cache
|
||||||
cache: "maven"
|
uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0
|
||||||
|
with:
|
||||||
- name: Install Playwright dependencies
|
cache: "npm"
|
||||||
run: sudo apt-get install -y libgbm1
|
cache-dependency-path: 'commafeed-client/package-lock.json'
|
||||||
if: matrix.os != 'windows-latest'
|
|
||||||
|
- name: Install Playwright dependencies
|
||||||
# Build & Test
|
run: sudo apt-get install -y libgbm1
|
||||||
- name: Build with Maven
|
if: matrix.os != 'windows-latest'
|
||||||
run: mvn --batch-mode --no-transfer-progress install -Pnative -P${{ matrix.database }} -DskipTests=${{ matrix.os == 'windows-latest' && matrix.database != 'h2' }}
|
|
||||||
|
# Build & Test
|
||||||
# Upload artifacts
|
- name: Build with Maven
|
||||||
- name: Upload cross-platform app
|
run: mvn --batch-mode --no-transfer-progress install -Pnative -P${{ matrix.database }} -DskipITs=${{ matrix.os == 'windows-latest' && matrix.database != 'h2' }}
|
||||||
uses: actions/upload-artifact@65c4c4a1ddee5b72f698fdd19549f0f0fb45cf08 # v4
|
|
||||||
if: matrix.os == 'ubuntu-latest' # we only need to upload the cross-platform artifact once per database
|
# Build pages
|
||||||
with:
|
- name: Create pages directory structure
|
||||||
name: commafeed-${{ matrix.database }}-jvm
|
run: mkdir -p target/pages/documentation/custom-css
|
||||||
path: commafeed-server/target/commafeed-*.zip
|
|
||||||
|
- name: Convert readme file to html
|
||||||
- name: Upload native executable
|
uses: jaywcjlove/markdown-to-html-cli@cff9330af4ca8048b197a76d9eb1db189c2a7cee # v5.0.4
|
||||||
uses: actions/upload-artifact@65c4c4a1ddee5b72f698fdd19549f0f0fb45cf08 # v4
|
with:
|
||||||
with:
|
source: README.md
|
||||||
name: commafeed-${{ matrix.database }}-${{ runner.os }}-${{ runner.arch }}
|
output: target/pages/index.html
|
||||||
path: commafeed-server/target/commafeed-*-runner*
|
|
||||||
|
- name: Convert config documentation to html
|
||||||
docker:
|
uses: jaywcjlove/markdown-to-html-cli@cff9330af4ca8048b197a76d9eb1db189c2a7cee # v5.0.4
|
||||||
runs-on: ubuntu-latest
|
with:
|
||||||
needs: build
|
source: commafeed-server/target/quarkus-generated-doc/config/commafeed-server.md
|
||||||
env:
|
output: target/pages/documentation/index.html
|
||||||
DOCKERHUB_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }}
|
|
||||||
|
- name: Convert custom css documentation to html
|
||||||
strategy:
|
uses: jaywcjlove/markdown-to-html-cli@cff9330af4ca8048b197a76d9eb1db189c2a7cee # v5.0.4
|
||||||
matrix:
|
with:
|
||||||
database: [ "h2", "postgresql", "mysql", "mariadb" ]
|
source: documentation/CUSTOMCSS.md
|
||||||
|
output: target/pages/documentation/custom-css/index.html
|
||||||
steps:
|
|
||||||
# Checkout
|
# Upload artifacts
|
||||||
- name: Checkout
|
- name: Upload cross-platform app
|
||||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
|
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
|
||||||
with:
|
if: matrix.os == 'ubuntu-24.04' # we only need to upload the cross-platform artifact once per database
|
||||||
fetch-depth: 0
|
with:
|
||||||
|
name: commafeed-${{ matrix.database }}-jvm
|
||||||
# Setup
|
path: commafeed-server/target/commafeed-*.zip
|
||||||
- name: Set up QEMU
|
|
||||||
uses: docker/setup-qemu-action@4574d27a4764455b42196d70a065bc6853246a25 # v3
|
- name: Upload native executable
|
||||||
|
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
|
||||||
- name: Set up Docker Buildx
|
with:
|
||||||
uses: docker/setup-buildx-action@f7ce87c1d6bead3e36075b2ce75da1f6cc28aaca # v3
|
name: commafeed-${{ matrix.database }}-${{ runner.os }}-${{ runner.arch }}
|
||||||
|
path: commafeed-server/target/commafeed-*-runner*
|
||||||
- name: Install required packages
|
|
||||||
run: sudo apt-get install -y rename unzip
|
- name: Upload pages
|
||||||
|
uses: actions/upload-pages-artifact@fc324d3547104276b827a68afc52ff2a11cc49c9 # v5.0.0
|
||||||
# Prepare artifacts
|
if: matrix.os == 'ubuntu-24.04' && matrix.database == 'h2' # we only need to upload the pages once
|
||||||
- name: Download artifacts
|
with:
|
||||||
uses: actions/download-artifact@fa0a91b85d4f404e444e00e005971372dc801d16 # v4
|
path: target/pages
|
||||||
with:
|
|
||||||
pattern: commafeed-${{ matrix.database }}-*
|
docker:
|
||||||
path: ./artifacts
|
runs-on: ubuntu-latest
|
||||||
merge-multiple: true
|
needs: build
|
||||||
|
env:
|
||||||
- name: Set the exec flag on the native executables
|
DOCKERHUB_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||||
run: chmod +x artifacts/*-runner
|
|
||||||
|
strategy:
|
||||||
- name: Rename native executables to match buildx TARGETARCH
|
matrix:
|
||||||
run: |
|
database: [ "h2", "postgresql", "mysql", "mariadb" ]
|
||||||
rename 's/x86_64/amd64/g' artifacts/*
|
|
||||||
rename 's/aarch_64/arm64/g' artifacts/*
|
steps:
|
||||||
|
# Checkout
|
||||||
- name: Unzip jvm package
|
- name: Checkout
|
||||||
run: |
|
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||||
unzip artifacts/*-jvm.zip -d artifacts/extracted-jvm-package
|
with:
|
||||||
rename 's/commafeed-.*/quarkus-app/g' artifacts/extracted-jvm-package/*
|
fetch-depth: 0
|
||||||
|
|
||||||
# Docker
|
# Setup
|
||||||
- name: Login to Container Registry
|
- name: Set up QEMU
|
||||||
uses: docker/login-action@9780b0c442fbb1117ed29e0efdff1e18412f7567 # v3
|
uses: docker/setup-qemu-action@99012661954931238ded8c8b007157a8430204e1 # v4.4.0
|
||||||
if: ${{ env.DOCKERHUB_USERNAME != '' }}
|
|
||||||
with:
|
- name: Set up Docker Buildx
|
||||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4.4.1
|
||||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
|
||||||
|
- name: Install required packages
|
||||||
## build but don't push for PRs and renovate
|
run: sudo apt-get install -y rename unzip
|
||||||
- name: Docker build - native
|
|
||||||
uses: docker/build-push-action@ca877d9245402d1537745e0e356eab47c3520991 # v6
|
# Prepare artifacts
|
||||||
with:
|
- name: Download artifacts
|
||||||
context: .
|
uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
|
||||||
file: commafeed-server/src/main/docker/Dockerfile.native
|
with:
|
||||||
push: false
|
pattern: commafeed-${{ matrix.database }}-*
|
||||||
platforms: linux/amd64,linux/arm64/v8
|
path: ./artifacts
|
||||||
|
merge-multiple: true
|
||||||
- name: Docker build - jvm
|
|
||||||
uses: docker/build-push-action@ca877d9245402d1537745e0e356eab47c3520991 # v6
|
- name: Set the exec flag on the native executables
|
||||||
with:
|
run: chmod +x artifacts/*-runner
|
||||||
context: .
|
|
||||||
file: commafeed-server/src/main/docker/Dockerfile.jvm
|
- name: Rename native executables to match buildx TARGETARCH
|
||||||
push: false
|
run: |
|
||||||
platforms: linux/amd64,linux/arm64/v8
|
rename 's/x86_64/amd64/g' artifacts/*
|
||||||
|
rename 's/aarch_64/arm64/g' artifacts/*
|
||||||
## build and push tag
|
|
||||||
- name: Docker build and push tag - native
|
- name: Unzip jvm package
|
||||||
uses: docker/build-push-action@ca877d9245402d1537745e0e356eab47c3520991 # v6
|
run: |
|
||||||
if: ${{ github.ref_type == 'tag' }}
|
unzip artifacts/*-jvm.zip -d artifacts/extracted-jvm-package
|
||||||
with:
|
rename 's/commafeed-.*/quarkus-app/g' artifacts/extracted-jvm-package/*
|
||||||
context: .
|
|
||||||
file: commafeed-server/src/main/docker/Dockerfile.native
|
# Docker
|
||||||
push: ${{ env.DOCKERHUB_USERNAME != '' }}
|
- name: Login to Container Registry
|
||||||
platforms: linux/amd64,linux/arm64/v8
|
uses: docker/login-action@81c6f93d4bc991e510b342a00b419b3bb4efc5c6 # v4.7.0
|
||||||
tags: |
|
if: ${{ env.DOCKERHUB_USERNAME != '' }}
|
||||||
athou/commafeed:latest-${{ matrix.database }}
|
with:
|
||||||
athou/commafeed:${{ github.ref_name }}-${{ matrix.database }}
|
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||||
|
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||||
- name: Docker build and push tag - jvm
|
|
||||||
uses: docker/build-push-action@ca877d9245402d1537745e0e356eab47c3520991 # v6
|
## build but don't push for PRs and renovate
|
||||||
if: ${{ github.ref_type == 'tag' }}
|
- name: Docker build - native
|
||||||
with:
|
uses: docker/build-push-action@c3c9e263c25d99ce0380d002d59b67737d91b0dc # v7.4.0
|
||||||
context: .
|
with:
|
||||||
file: commafeed-server/src/main/docker/Dockerfile.jvm
|
context: .
|
||||||
push: ${{ env.DOCKERHUB_USERNAME != '' }}
|
file: commafeed-server/src/main/docker/Dockerfile.native
|
||||||
platforms: linux/amd64,linux/arm64/v8
|
push: false
|
||||||
tags: |
|
platforms: linux/amd64,linux/arm64/v8
|
||||||
athou/commafeed:latest-${{ matrix.database }}-jvm
|
|
||||||
athou/commafeed:${{ github.ref_name }}-${{ matrix.database }}-jvm
|
- name: Docker build - jvm
|
||||||
|
uses: docker/build-push-action@c3c9e263c25d99ce0380d002d59b67737d91b0dc # v7.4.0
|
||||||
## build and push master
|
with:
|
||||||
- name: Docker build and push master - native
|
context: .
|
||||||
uses: docker/build-push-action@ca877d9245402d1537745e0e356eab47c3520991 # v6
|
file: commafeed-server/src/main/docker/Dockerfile.jvm
|
||||||
if: ${{ github.ref_name == 'master' }}
|
push: false
|
||||||
with:
|
platforms: linux/amd64,linux/arm64/v8
|
||||||
context: .
|
|
||||||
file: commafeed-server/src/main/docker/Dockerfile.native
|
## build and push tag
|
||||||
push: ${{ env.DOCKERHUB_USERNAME != '' }}
|
- name: Docker build and push tag - native
|
||||||
platforms: linux/amd64,linux/arm64/v8
|
uses: docker/build-push-action@c3c9e263c25d99ce0380d002d59b67737d91b0dc # v7.4.0
|
||||||
tags: athou/commafeed:master-${{ matrix.database }}
|
if: ${{ github.ref_type == 'tag' }}
|
||||||
|
with:
|
||||||
- name: Docker build and push master - jvm
|
context: .
|
||||||
uses: docker/build-push-action@ca877d9245402d1537745e0e356eab47c3520991 # v6
|
file: commafeed-server/src/main/docker/Dockerfile.native
|
||||||
if: ${{ github.ref_name == 'master' }}
|
push: ${{ env.DOCKERHUB_USERNAME != '' }}
|
||||||
with:
|
platforms: linux/amd64,linux/arm64/v8
|
||||||
context: .
|
tags: |
|
||||||
file: commafeed-server/src/main/docker/Dockerfile.jvm
|
athou/commafeed:latest-${{ matrix.database }}
|
||||||
push: ${{ env.DOCKERHUB_USERNAME != '' }}
|
athou/commafeed:${{ github.ref_name }}-${{ matrix.database }}
|
||||||
platforms: linux/amd64,linux/arm64/v8
|
|
||||||
tags: athou/commafeed:master-${{ matrix.database }}-jvm
|
- name: Docker build and push tag - jvm
|
||||||
|
uses: docker/build-push-action@c3c9e263c25d99ce0380d002d59b67737d91b0dc # v7.4.0
|
||||||
release:
|
if: ${{ github.ref_type == 'tag' }}
|
||||||
runs-on: ubuntu-latest
|
with:
|
||||||
needs:
|
context: .
|
||||||
- build
|
file: commafeed-server/src/main/docker/Dockerfile.jvm
|
||||||
- docker
|
push: ${{ env.DOCKERHUB_USERNAME != '' }}
|
||||||
permissions:
|
platforms: linux/amd64,linux/arm64/v8
|
||||||
contents: write
|
tags: |
|
||||||
if: github.ref_type == 'tag'
|
athou/commafeed:latest-${{ matrix.database }}-jvm
|
||||||
|
athou/commafeed:${{ github.ref_name }}-${{ matrix.database }}-jvm
|
||||||
steps:
|
|
||||||
- name: Checkout
|
## build and push master
|
||||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
|
- name: Docker build and push master - native
|
||||||
with:
|
uses: docker/build-push-action@c3c9e263c25d99ce0380d002d59b67737d91b0dc # v7.4.0
|
||||||
fetch-depth: 0
|
if: ${{ github.ref_name == 'master' }}
|
||||||
|
with:
|
||||||
- name: Download artifacts
|
context: .
|
||||||
uses: actions/download-artifact@fa0a91b85d4f404e444e00e005971372dc801d16 # v4
|
file: commafeed-server/src/main/docker/Dockerfile.native
|
||||||
with:
|
push: ${{ env.DOCKERHUB_USERNAME != '' }}
|
||||||
pattern: commafeed-*
|
platforms: linux/amd64,linux/arm64/v8
|
||||||
path: ./artifacts
|
tags: athou/commafeed:master-${{ matrix.database }}
|
||||||
merge-multiple: true
|
|
||||||
|
- name: Docker build and push master - jvm
|
||||||
- name: Set the exec flag on the native executables
|
uses: docker/build-push-action@c3c9e263c25d99ce0380d002d59b67737d91b0dc # v7.4.0
|
||||||
run: chmod +x artifacts/*-runner
|
if: ${{ github.ref_name == 'master' }}
|
||||||
|
with:
|
||||||
- name: Extract Changelog Entry
|
context: .
|
||||||
uses: mindsers/changelog-reader-action@32aa5b4c155d76c94e4ec883a223c947b2f02656 # v2
|
file: commafeed-server/src/main/docker/Dockerfile.jvm
|
||||||
id: changelog_reader
|
push: ${{ env.DOCKERHUB_USERNAME != '' }}
|
||||||
with:
|
platforms: linux/amd64,linux/arm64/v8
|
||||||
version: ${{ github.ref_name }}
|
tags: athou/commafeed:master-${{ matrix.database }}-jvm
|
||||||
|
|
||||||
- name: Create GitHub release
|
release:
|
||||||
uses: ncipollo/release-action@cdcc88a9acf3ca41c16c37bb7d21b9ad48560d87 # v1
|
runs-on: ubuntu-latest
|
||||||
with:
|
needs:
|
||||||
name: CommaFeed ${{ github.ref_name }}
|
- build
|
||||||
body: ${{ steps.changelog_reader.outputs.changes }}
|
- docker
|
||||||
artifacts: ./artifacts/*
|
permissions:
|
||||||
|
contents: write
|
||||||
- name: Update Docker Hub Description
|
if: github.ref_type == 'tag'
|
||||||
uses: peter-evans/dockerhub-description@e98e4d1628a5f3be2be7c231e50981aee98723ae # v4
|
|
||||||
with:
|
steps:
|
||||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
- name: Checkout
|
||||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||||
repository: athou/commafeed
|
with:
|
||||||
short-description: ${{ github.event.repository.description }}
|
fetch-depth: 0
|
||||||
readme-filepath: commafeed-server/src/main/docker/README.md
|
|
||||||
|
- name: Download artifacts
|
||||||
|
uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
|
||||||
|
with:
|
||||||
|
pattern: commafeed-*
|
||||||
|
path: ./artifacts
|
||||||
|
merge-multiple: true
|
||||||
|
|
||||||
|
- name: Set the exec flag on the native executables
|
||||||
|
run: chmod +x artifacts/*-runner
|
||||||
|
|
||||||
|
- name: Extract Changelog Entry
|
||||||
|
uses: mindsers/changelog-reader-action@1faaf50aa09d5793d9a100819973df801febfb31 # v2.4.0
|
||||||
|
id: changelog_reader
|
||||||
|
with:
|
||||||
|
version: ${{ github.ref_name }}
|
||||||
|
|
||||||
|
- name: Create GitHub release
|
||||||
|
uses: ncipollo/release-action@339a81892b84b4eeb0f6e744e4574d79d0d9b8dd # v1.21.0
|
||||||
|
with:
|
||||||
|
name: CommaFeed ${{ github.ref_name }}
|
||||||
|
body: ${{ steps.changelog_reader.outputs.changes }}
|
||||||
|
artifacts: ./artifacts/*
|
||||||
|
|
||||||
|
|
||||||
|
update-dockerhub-description:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
needs: release
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
|
||||||
|
- name: Update Docker Hub Description
|
||||||
|
uses: peter-evans/dockerhub-description@1b9a80c056b620d92cedb9d9b5a223409c68ddfa # v5.0.0
|
||||||
|
with:
|
||||||
|
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||||
|
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||||
|
repository: athou/commafeed
|
||||||
|
short-description: ${{ github.event.repository.description }}
|
||||||
|
readme-filepath: commafeed-server/src/main/docker/README.md
|
||||||
|
|
||||||
|
|
||||||
|
deploy-pages:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
needs: release
|
||||||
|
permissions:
|
||||||
|
pages: write
|
||||||
|
id-token: write
|
||||||
|
environment:
|
||||||
|
name: github-pages
|
||||||
|
url: ${{ steps.deployment.outputs.page_url }}
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- uses: actions/deploy-pages@368f82528645a54fb793d4d04e342629a3f51346 # v5.0.1
|
||||||
|
id: deployment
|
||||||
|
|||||||
21
.mvn/wrapper/maven-wrapper.properties
vendored
21
.mvn/wrapper/maven-wrapper.properties
vendored
@@ -1,18 +1,3 @@
|
|||||||
# Licensed to the Apache Software Foundation (ASF) under one
|
wrapperVersion=3.3.4
|
||||||
# or more contributor license agreements. See the NOTICE file
|
distributionType=only-script
|
||||||
# distributed with this work for additional information
|
distributionUrl=https://repo.maven.apache.org/maven2/org/apache/maven/apache-maven/3.10.0/apache-maven-3.10.0-bin.zip
|
||||||
# regarding copyright ownership. The ASF licenses this file
|
|
||||||
# to you under the Apache License, Version 2.0 (the
|
|
||||||
# "License"); you may not use this file except in compliance
|
|
||||||
# with the License. You may obtain a copy of the License at
|
|
||||||
#
|
|
||||||
# http://www.apache.org/licenses/LICENSE-2.0
|
|
||||||
#
|
|
||||||
# Unless required by applicable law or agreed to in writing,
|
|
||||||
# software distributed under the License is distributed on an
|
|
||||||
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
|
||||||
# KIND, either express or implied. See the License for the
|
|
||||||
# specific language governing permissions and limitations
|
|
||||||
# under the License.
|
|
||||||
distributionType=only-script
|
|
||||||
distributionUrl=https://repo.maven.apache.org/maven2/org/apache/maven/apache-maven/3.9.9/apache-maven-3.9.9-bin.zip
|
|
||||||
|
|||||||
1100
CHANGELOG.md
1100
CHANGELOG.md
File diff suppressed because it is too large
Load Diff
60
LICENSE
60
LICENSE
@@ -1,31 +1,31 @@
|
|||||||
Apache License, Version 2.0 Apache License Version 2.0, January 2004 http://www.apache.org/licenses/
|
Apache License, Version 2.0 Apache License Version 2.0, January 2004 http://www.apache.org/licenses/
|
||||||
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
||||||
1. Definitions.
|
1. Definitions.
|
||||||
"License" shall mean the terms and conditions for use, reproduction, and distribution as defined by Sections 1 through 9 of this document.
|
"License" shall mean the terms and conditions for use, reproduction, and distribution as defined by Sections 1 through 9 of this document.
|
||||||
"Licensor" shall mean the copyright owner or entity authorized by the copyright owner that is granting the License.
|
"Licensor" shall mean the copyright owner or entity authorized by the copyright owner that is granting the License.
|
||||||
"Legal Entity" shall mean the union of the acting entity and all other entities that control, are controlled by, or are under common control with that entity. For the purposes of this definition, "control" means (i) the power, direct or indirect, to cause the direction or management of such entity, whether by contract or otherwise, or (ii) ownership of fifty percent (50%) or more of the outstanding shares, or (iii) beneficial ownership of such entity.
|
"Legal Entity" shall mean the union of the acting entity and all other entities that control, are controlled by, or are under common control with that entity. For the purposes of this definition, "control" means (i) the power, direct or indirect, to cause the direction or management of such entity, whether by contract or otherwise, or (ii) ownership of fifty percent (50%) or more of the outstanding shares, or (iii) beneficial ownership of such entity.
|
||||||
"You" (or "Your") shall mean an individual or Legal Entity exercising permissions granted by this License.
|
"You" (or "Your") shall mean an individual or Legal Entity exercising permissions granted by this License.
|
||||||
"Source" form shall mean the preferred form for making modifications, including but not limited to software source code, documentation source, and configuration files.
|
"Source" form shall mean the preferred form for making modifications, including but not limited to software source code, documentation source, and configuration files.
|
||||||
"Object" form shall mean any form resulting from mechanical transformation or translation of a Source form, including but not limited to compiled object code, generated documentation, and conversions to other media types.
|
"Object" form shall mean any form resulting from mechanical transformation or translation of a Source form, including but not limited to compiled object code, generated documentation, and conversions to other media types.
|
||||||
"Work" shall mean the work of authorship, whether in Source or Object form, made available under the License, as indicated by a copyright notice that is included in or attached to the work (an example is provided in the Appendix below).
|
"Work" shall mean the work of authorship, whether in Source or Object form, made available under the License, as indicated by a copyright notice that is included in or attached to the work (an example is provided in the Appendix below).
|
||||||
"Derivative Works" shall mean any work, whether in Source or Object form, that is based on (or derived from) the Work and for which the editorial revisions, annotations, elaborations, or other modifications represent, as a whole, an original work of authorship. For the purposes of this License, Derivative Works shall not include works that remain separable from, or merely link (or bind by name) to the interfaces of, the Work and Derivative Works thereof.
|
"Derivative Works" shall mean any work, whether in Source or Object form, that is based on (or derived from) the Work and for which the editorial revisions, annotations, elaborations, or other modifications represent, as a whole, an original work of authorship. For the purposes of this License, Derivative Works shall not include works that remain separable from, or merely link (or bind by name) to the interfaces of, the Work and Derivative Works thereof.
|
||||||
"Contribution" shall mean any work of authorship, including the original version of the Work and any modifications or additions to that Work or Derivative Works thereof, that is intentionally submitted to Licensor for inclusion in the Work by the copyright owner or by an individual or Legal Entity authorized to submit on behalf of the copyright owner. For the purposes of this definition, "submitted" means any form of electronic, verbal, or written communication sent to the Licensor or its representatives, including but not limited to communication on electronic mailing lists, source code control systems, and issue tracking systems that are managed by, or on behalf of, the Licensor for the purpose of discussing and improving the Work, but excluding communication that is conspicuously marked or otherwise designated in writing by the copyright owner as "Not a Contribution."
|
"Contribution" shall mean any work of authorship, including the original version of the Work and any modifications or additions to that Work or Derivative Works thereof, that is intentionally submitted to Licensor for inclusion in the Work by the copyright owner or by an individual or Legal Entity authorized to submit on behalf of the copyright owner. For the purposes of this definition, "submitted" means any form of electronic, verbal, or written communication sent to the Licensor or its representatives, including but not limited to communication on electronic mailing lists, source code control systems, and issue tracking systems that are managed by, or on behalf of, the Licensor for the purpose of discussing and improving the Work, but excluding communication that is conspicuously marked or otherwise designated in writing by the copyright owner as "Not a Contribution."
|
||||||
"Contributor" shall mean Licensor and any individual or Legal Entity on behalf of whom a Contribution has been received by Licensor and subsequently incorporated within the Work.
|
"Contributor" shall mean Licensor and any individual or Legal Entity on behalf of whom a Contribution has been received by Licensor and subsequently incorporated within the Work.
|
||||||
2. Grant of Copyright License.
|
2. Grant of Copyright License.
|
||||||
Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable copyright license to reproduce, prepare Derivative Works of, publicly display, publicly perform, sublicense, and distribute the Work and such Derivative Works in Source or Object form.
|
Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable copyright license to reproduce, prepare Derivative Works of, publicly display, publicly perform, sublicense, and distribute the Work and such Derivative Works in Source or Object form.
|
||||||
3. Grant of Patent License.
|
3. Grant of Patent License.
|
||||||
Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable (except as stated in this section) patent license to make, have made, use, offer to sell, sell, import, and otherwise transfer the Work, where such license applies only to those patent claims licensable by such Contributor that are necessarily infringed by their Contribution(s) alone or by combination of their Contribution(s) with the Work to which such Contribution(s) was submitted. If You institute patent litigation against any entity (including a cross-claim or counterclaim in a lawsuit) alleging that the Work or a Contribution incorporated within the Work constitutes direct or contributory patent infringement, then any patent licenses granted to You under this License for that Work shall terminate as of the date such litigation is filed.
|
Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable (except as stated in this section) patent license to make, have made, use, offer to sell, sell, import, and otherwise transfer the Work, where such license applies only to those patent claims licensable by such Contributor that are necessarily infringed by their Contribution(s) alone or by combination of their Contribution(s) with the Work to which such Contribution(s) was submitted. If You institute patent litigation against any entity (including a cross-claim or counterclaim in a lawsuit) alleging that the Work or a Contribution incorporated within the Work constitutes direct or contributory patent infringement, then any patent licenses granted to You under this License for that Work shall terminate as of the date such litigation is filed.
|
||||||
4. Redistribution.
|
4. Redistribution.
|
||||||
You may reproduce and distribute copies of the Work or Derivative Works thereof in any medium, with or without modifications, and in Source or Object form, provided that You meet the following conditions:
|
You may reproduce and distribute copies of the Work or Derivative Works thereof in any medium, with or without modifications, and in Source or Object form, provided that You meet the following conditions:
|
||||||
You must give any other recipients of the Work or Derivative Works a copy of this License; and You must cause any modified files to carry prominent notices stating that You changed the files; and You must retain, in the Source form of any Derivative Works that You distribute, all copyright, patent, trademark, and attribution notices from the Source form of the Work, excluding those notices that do not pertain to any part of the Derivative Works; and If the Work includes a "NOTICE" text file as part of its distribution, then any Derivative Works that You distribute must include a readable copy of the attribution notices contained within such NOTICE file, excluding those notices that do not pertain to any part of the Derivative Works, in at least one of the following places: within a NOTICE text file distributed as part of the Derivative Works; within the Source form or documentation, if provided along with the Derivative Works; or, within a display generated by the Derivative Works, if and wherever such third-party notices normally appear. The contents of the NOTICE file are for informational purposes only and do not modify the License. You may add Your own attribution notices within Derivative Works that You distribute, alongside or as an addendum to the NOTICE text from the Work, provided that such additional attribution notices cannot be construed as modifying the License. You may add Your own copyright statement to Your modifications and may provide additional or different license terms and conditions for use, reproduction, or distribution of Your modifications, or for any such Derivative Works as a whole, provided Your use, reproduction, and distribution of the Work otherwise complies with the conditions stated in this License.
|
You must give any other recipients of the Work or Derivative Works a copy of this License; and You must cause any modified files to carry prominent notices stating that You changed the files; and You must retain, in the Source form of any Derivative Works that You distribute, all copyright, patent, trademark, and attribution notices from the Source form of the Work, excluding those notices that do not pertain to any part of the Derivative Works; and If the Work includes a "NOTICE" text file as part of its distribution, then any Derivative Works that You distribute must include a readable copy of the attribution notices contained within such NOTICE file, excluding those notices that do not pertain to any part of the Derivative Works, in at least one of the following places: within a NOTICE text file distributed as part of the Derivative Works; within the Source form or documentation, if provided along with the Derivative Works; or, within a display generated by the Derivative Works, if and wherever such third-party notices normally appear. The contents of the NOTICE file are for informational purposes only and do not modify the License. You may add Your own attribution notices within Derivative Works that You distribute, alongside or as an addendum to the NOTICE text from the Work, provided that such additional attribution notices cannot be construed as modifying the License. You may add Your own copyright statement to Your modifications and may provide additional or different license terms and conditions for use, reproduction, or distribution of Your modifications, or for any such Derivative Works as a whole, provided Your use, reproduction, and distribution of the Work otherwise complies with the conditions stated in this License.
|
||||||
5. Submission of Contributions.
|
5. Submission of Contributions.
|
||||||
Unless You explicitly state otherwise, any Contribution intentionally submitted for inclusion in the Work by You to the Licensor shall be under the terms and conditions of this License, without any additional terms or conditions. Notwithstanding the above, nothing herein shall supersede or modify the terms of any separate license agreement you may have executed with Licensor regarding such Contributions.
|
Unless You explicitly state otherwise, any Contribution intentionally submitted for inclusion in the Work by You to the Licensor shall be under the terms and conditions of this License, without any additional terms or conditions. Notwithstanding the above, nothing herein shall supersede or modify the terms of any separate license agreement you may have executed with Licensor regarding such Contributions.
|
||||||
6. Trademarks.
|
6. Trademarks.
|
||||||
This License does not grant permission to use the trade names, trademarks, service marks, or product names of the Licensor, except as required for reasonable and customary use in describing the origin of the Work and reproducing the content of the NOTICE file.
|
This License does not grant permission to use the trade names, trademarks, service marks, or product names of the Licensor, except as required for reasonable and customary use in describing the origin of the Work and reproducing the content of the NOTICE file.
|
||||||
7. Disclaimer of Warranty.
|
7. Disclaimer of Warranty.
|
||||||
Unless required by applicable law or agreed to in writing, Licensor provides the Work (and each Contributor provides its Contributions) on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied, including, without limitation, any warranties or conditions of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A PARTICULAR PURPOSE. You are solely responsible for determining the appropriateness of using or redistributing the Work and assume any risks associated with Your exercise of permissions under this License.
|
Unless required by applicable law or agreed to in writing, Licensor provides the Work (and each Contributor provides its Contributions) on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied, including, without limitation, any warranties or conditions of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A PARTICULAR PURPOSE. You are solely responsible for determining the appropriateness of using or redistributing the Work and assume any risks associated with Your exercise of permissions under this License.
|
||||||
8. Limitation of Liability.
|
8. Limitation of Liability.
|
||||||
In no event and under no legal theory, whether in tort (including negligence), contract, or otherwise, unless required by applicable law (such as deliberate and grossly negligent acts) or agreed to in writing, shall any Contributor be liable to You for damages, including any direct, indirect, special, incidental, or consequential damages of any character arising as a result of this License or out of the use or inability to use the Work (including but not limited to damages for loss of goodwill, work stoppage, computer failure or malfunction, or any and all other commercial damages or losses), even if such Contributor has been advised of the possibility of such damages.
|
In no event and under no legal theory, whether in tort (including negligence), contract, or otherwise, unless required by applicable law (such as deliberate and grossly negligent acts) or agreed to in writing, shall any Contributor be liable to You for damages, including any direct, indirect, special, incidental, or consequential damages of any character arising as a result of this License or out of the use or inability to use the Work (including but not limited to damages for loss of goodwill, work stoppage, computer failure or malfunction, or any and all other commercial damages or losses), even if such Contributor has been advised of the possibility of such damages.
|
||||||
9. Accepting Warranty or Additional Liability.
|
9. Accepting Warranty or Additional Liability.
|
||||||
While redistributing the Work or Derivative Works thereof, You may choose to offer, and charge a fee for, acceptance of support, warranty, indemnity, or other liability obligations and/or rights consistent with this License. However, in accepting such obligations, You may act only on Your own behalf and on Your sole responsibility, not on behalf of any other Contributor, and only if You agree to indemnify, defend, and hold each Contributor harmless for any liability incurred by, or claims asserted against, such Contributor by reason of your accepting any such warranty or additional liability.
|
While redistributing the Work or Derivative Works thereof, You may choose to offer, and charge a fee for, acceptance of support, warranty, indemnity, or other liability obligations and/or rights consistent with this License. However, in accepting such obligations, You may act only on Your own behalf and on Your sole responsibility, not on behalf of any other Contributor, and only if You agree to indemnify, defend, and hold each Contributor harmless for any liability incurred by, or claims asserted against, such Contributor by reason of your accepting any such warranty or additional liability.
|
||||||
END OF TERMS AND CONDITIONS
|
END OF TERMS AND CONDITIONS
|
||||||
56
README.md
56
README.md
@@ -15,8 +15,10 @@ Google Reader inspired self-hosted RSS reader, based on Quarkus and React/TypeSc
|
|||||||
- Supports thousands of users and millions of feeds
|
- Supports thousands of users and millions of feeds
|
||||||
- OPML import/export
|
- OPML import/export
|
||||||
- REST API
|
- REST API
|
||||||
- Fever-compatible API for native mobile apps
|
- Fever and Google Reader API for native mobile apps
|
||||||
- Can automatically mark articles as read based on user-defined rules
|
- Can automatically mark articles as read based on user-defined rules
|
||||||
|
- Push notifications when new articles are published
|
||||||
|
- Highly customizable with [custom CSS](https://athou.github.io/commafeed/documentation/custom-css) and JavaScript
|
||||||
- [Browser extension](https://github.com/Athou/commafeed-browser-extension)
|
- [Browser extension](https://github.com/Athou/commafeed-browser-extension)
|
||||||
- Compiles to native code for blazing fast startup and low memory usage
|
- Compiles to native code for blazing fast startup and low memory usage
|
||||||
- Supports 4 databases
|
- Supports 4 databases
|
||||||
@@ -25,11 +27,18 @@ Google Reader inspired self-hosted RSS reader, based on Quarkus and React/TypeSc
|
|||||||
- MySQL
|
- MySQL
|
||||||
- MariaDB
|
- MariaDB
|
||||||
|
|
||||||
## Deployment
|
## Usage
|
||||||
|
|
||||||
|
### Public instance
|
||||||
|
|
||||||
|
A free public instance is available at https://www.commafeed.com.
|
||||||
|
|
||||||
|
It has no ads, no tracking, and your data is never exploited or sold to third parties. The service is funded entirely through donations.
|
||||||
|
However, this public instance does have a few limitations compared to self-hosted setups, outlined [here](https://github.com/Athou/commafeed/discussions/1567).
|
||||||
|
|
||||||
### Docker
|
### Docker
|
||||||
|
|
||||||
Docker is the easiest way to get started with CommaFeed.
|
Docker is the easiest way to get started with self-hosted CommaFeed.
|
||||||
|
|
||||||
Docker images are built automatically and are available at https://hub.docker.com/r/athou/commafeed
|
Docker images are built automatically and are available at https://hub.docker.com/r/athou/commafeed
|
||||||
|
|
||||||
@@ -48,21 +57,21 @@ system and database of choice.
|
|||||||
|
|
||||||
There are two types of packages:
|
There are two types of packages:
|
||||||
|
|
||||||
- The `linux-x86_64` and `windows-x86_64` packages are compiled natively and contain an executable that can be run
|
- The `linux-x86_64`, `linux-aarch_64` and `windows-x86_64` packages are compiled natively and contain an executable that can be run
|
||||||
directly.
|
directly.
|
||||||
- The `jvm` package is a zip file containing all `.jar` files required to run the application. This package works on all
|
- The `jvm` package is a zip file containing all `.jar` files required to run the application. This package works on all
|
||||||
platforms and is started with `java -jar quarkus-run.jar`.
|
platforms but requires a JRE and is started with `java -jar quarkus-run.jar`.
|
||||||
|
|
||||||
If available for your operating system, the native package is recommended because it has a faster startup time and lower
|
If available for your operating system, the native package is recommended because it has a faster startup time and lower
|
||||||
memory usage.
|
memory usage.
|
||||||
|
|
||||||
### Build from sources
|
### Build from sources
|
||||||
|
|
||||||
./mvnw clean package [-P<database>] [-Pnative] [-DskipTests]
|
./mvnw clean package [-P<database> [-Pnative]] [-DskipTests]
|
||||||
|
|
||||||
- `<database>` can be one of `h2`, `postgresql`, `mysql` or `mariadb`. The default is `h2`.
|
- `<database>` can be one of `h2`, `postgresql`, `mysql` or `mariadb`. The default is `h2`.
|
||||||
- `-Pnative` compiles the application to native code. This requires GraalVM to be installed (`GRAALVM_HOME` environment
|
- `-Pnative` compiles the application to native code. This requires either GraalVM to be installed (`GRAALVM_HOME` environment
|
||||||
variable pointing to a GraalVM installation).
|
variable pointing to a GraalVM installation) or a container environment to be available (docker/podman/...).
|
||||||
- `-DskipTests` to speed up the build process by skipping tests.
|
- `-DskipTests` to speed up the build process by skipping tests.
|
||||||
|
|
||||||
When the build is complete:
|
When the build is complete:
|
||||||
@@ -73,6 +82,10 @@ When the build is complete:
|
|||||||
- if you used the native profile, the executable is located at
|
- if you used the native profile, the executable is located at
|
||||||
`commafeed-server/target/commafeed-<version>-<database>-<platform>-<arch>-runner[.exe]`
|
`commafeed-server/target/commafeed-<version>-<database>-<platform>-<arch>-runner[.exe]`
|
||||||
|
|
||||||
|
### Distribution packages
|
||||||
|
|
||||||
|
- Arch Linux users can use [the CommaFeed package on AUR](https://aur.archlinux.org/pkgbase/commafeed), which builds native binaries with GraalVM for all supported databases.
|
||||||
|
|
||||||
## Configuration
|
## Configuration
|
||||||
|
|
||||||
CommaFeed doesn't require any configuration to run with its embedded database (H2). The database file will be stored in
|
CommaFeed doesn't require any configuration to run with its embedded database (H2). The database file will be stored in
|
||||||
@@ -98,9 +111,9 @@ There are multiple ways to configure CommaFeed:
|
|||||||
- Environment variables (keys in UPPER_CASE)
|
- Environment variables (keys in UPPER_CASE)
|
||||||
- a `.env` file in the working directory (keys in UPPER_CASE)
|
- a `.env` file in the working directory (keys in UPPER_CASE)
|
||||||
|
|
||||||
The properties file is recommended because CommaFeed will be able to warn about invalid properties and typos.
|
When in doubt, the properties file is recommended because CommaFeed will be able to warn about invalid properties and typos.
|
||||||
|
|
||||||
All [CommaFeed settings](commafeed-server/doc/commafeed.md) are optional and have sensible default values.
|
All [CommaFeed settings](https://athou.github.io/commafeed/documentation) are optional and have sensible default values.
|
||||||
|
|
||||||
When logging in, credentials are stored in an encrypted cookie. The encryption key is randomly generated at startup,
|
When logging in, credentials are stored in an encrypted cookie. The encryption key is randomly generated at startup,
|
||||||
meaning that you will have to log back in after each restart of the application. To prevent this, you can set the
|
meaning that you will have to log back in after each restart of the application. To prevent this, you can set the
|
||||||
@@ -108,7 +121,6 @@ meaning that you will have to log back in after each restart of the application.
|
|||||||
All other Quarkus settings can be found [here](https://quarkus.io/guides/all-config).
|
All other Quarkus settings can be found [here](https://quarkus.io/guides/all-config).
|
||||||
|
|
||||||
When started, the server will listen on http://localhost:8082.
|
When started, the server will listen on http://localhost:8082.
|
||||||
The default user is `admin` and the default password is `admin`.
|
|
||||||
|
|
||||||
### Updates
|
### Updates
|
||||||
|
|
||||||
@@ -147,6 +159,28 @@ named [Semeru](https://developer.ibm.com/languages/java/semeru-runtimes/download
|
|||||||
This is the JVM used in
|
This is the JVM used in
|
||||||
the [Docker image](https://github.com/Athou/commafeed/blob/master/commafeed-server/src/main/docker/Dockerfile.jvm).
|
the [Docker image](https://github.com/Athou/commafeed/blob/master/commafeed-server/src/main/docker/Dockerfile.jvm).
|
||||||
|
|
||||||
|
## FAQ
|
||||||
|
|
||||||
|
### Getting "Access to local address blocked" when adding a feed
|
||||||
|
|
||||||
|
CommaFeed blocks access to local resources by default to prevent [SSRF](https://en.wikipedia.org/wiki/Server-side_request_forgery) attacks.
|
||||||
|
If you want to subscribe to feeds that are only available on your local network, you can disable this security measure by setting the `commafeed.http-client.block-local-addresses` variable to `false`.
|
||||||
|
Do this only if you trust all users of your CommaFeed instance not to access private resources.
|
||||||
|
|
||||||
|
### Listen on a single network interface
|
||||||
|
|
||||||
|
By default, CommaFeed listens on all interfaces. You can restrict it by setting `quarkus.http.host`.
|
||||||
|
|
||||||
|
Note that if you set it to a local name like `127.0.0.1` host validation is enabled automatically. This prevents
|
||||||
|
access if you're using a reverse proxy like Nginx. To fix, add your actual hostname to `allowed-hosts`:
|
||||||
|
|
||||||
|
```
|
||||||
|
quarkus.http.host=127.0.0.1
|
||||||
|
quarkus.http.proxy.proxy-address-forwarding=true
|
||||||
|
quarkus.http.proxy.allow-forwarded=true
|
||||||
|
quarkus.http.host-validation.allowed-hosts=commafeed.example.com
|
||||||
|
```
|
||||||
|
|
||||||
## Translation
|
## Translation
|
||||||
|
|
||||||
Files for internationalization are
|
Files for internationalization are
|
||||||
|
|||||||
@@ -2,5 +2,4 @@
|
|||||||
|
|
||||||
## Reporting a Vulnerability
|
## Reporting a Vulnerability
|
||||||
|
|
||||||
If you found a vulnerability that you deem too sensitive to disclose publicly in a Github issue, please send an email at jeremiepanzer at gmail dot com.
|
If you found a vulnerability that you deem too sensitive to disclose publicly in a Github issue, please create a private security advisory here: https://github.com/Athou/commafeed/security/advisories
|
||||||
Thanks !
|
|
||||||
|
|||||||
3
commafeed-client/.gitignore
vendored
3
commafeed-client/.gitignore
vendored
@@ -23,9 +23,6 @@ dist-ssr
|
|||||||
*.sln
|
*.sln
|
||||||
*.sw?
|
*.sw?
|
||||||
|
|
||||||
# rollup-plugin-visualizer
|
|
||||||
/stats.html
|
|
||||||
|
|
||||||
# vite
|
# vite
|
||||||
vite.config.ts.timestamp-*.mjs
|
vite.config.ts.timestamp-*.mjs
|
||||||
|
|
||||||
|
|||||||
1
commafeed-client/.npmrc
Normal file
1
commafeed-client/.npmrc
Normal file
@@ -0,0 +1 @@
|
|||||||
|
loglevel=warn
|
||||||
@@ -1,10 +1,10 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://biomejs.dev/schemas/1.9.4/schema.json",
|
"$schema": "https://biomejs.dev/schemas/2.6.4/schema.json",
|
||||||
"formatter": {
|
"formatter": {
|
||||||
"indentStyle": "space",
|
"indentStyle": "space",
|
||||||
"indentWidth": 4,
|
"indentWidth": 4,
|
||||||
"lineEnding": "lf",
|
"lineEnding": "lf",
|
||||||
"lineWidth": 140
|
"lineWidth": 120
|
||||||
},
|
},
|
||||||
"javascript": {
|
"javascript": {
|
||||||
"formatter": {
|
"formatter": {
|
||||||
@@ -14,6 +14,6 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"files": {
|
"files": {
|
||||||
"ignore": ["dist", "node_modules", "target", "target-ide"]
|
"includes": ["**", "!**/dist", "!**/node_modules", "!**/target", "!**/target-ide"]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,12 +2,11 @@
|
|||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
|
<link rel="icon" type="image/svg+xml" href="favicon.svg" />
|
||||||
<link rel="icon" type="image/x-icon" href="favicon.ico" />
|
<link rel="icon" type="image/x-icon" href="favicon.ico" />
|
||||||
<link rel="manifest" href="manifest.json" />
|
<link rel="manifest" href="manifest.json" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
|
||||||
|
<meta name="referrer" content="no-referrer" />
|
||||||
<link rel="stylesheet" type="text/css" href="custom_css.css" />
|
|
||||||
<script type="text/javascript" src="custom_js.js"></script>
|
|
||||||
|
|
||||||
<title>CommaFeed</title>
|
<title>CommaFeed</title>
|
||||||
</head>
|
</head>
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import type { LinguiConfig } from "@lingui/conf"
|
import type { LinguiConfig } from "@lingui/conf"
|
||||||
|
import { formatter } from "@lingui/format-po"
|
||||||
|
|
||||||
const config: LinguiConfig = {
|
const config: LinguiConfig = {
|
||||||
locales: [
|
locales: [
|
||||||
@@ -38,11 +39,9 @@ const config: LinguiConfig = {
|
|||||||
exclude: ["src/locales/**"],
|
exclude: ["src/locales/**"],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
format: "po",
|
format: formatter({
|
||||||
formatOptions: {
|
|
||||||
origins: true,
|
|
||||||
lineNumbers: false,
|
lineNumbers: false,
|
||||||
},
|
}),
|
||||||
sourceLocale: "en",
|
sourceLocale: "en",
|
||||||
fallbackLocales: {
|
fallbackLocales: {
|
||||||
default: "en",
|
default: "en",
|
||||||
|
|||||||
8044
commafeed-client/package-lock.json
generated
8044
commafeed-client/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -4,79 +4,80 @@
|
|||||||
"version": "0.0.0",
|
"version": "0.0.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite --host",
|
"dev": "vite",
|
||||||
"dev:typescript": "tsc --watch",
|
"dev:host": "vite --host",
|
||||||
"build": "tsc && vite build",
|
"build": "tsc && vite build",
|
||||||
"preview": "vite preview",
|
"preview": "vite preview",
|
||||||
"test": "vitest",
|
"test": "vitest",
|
||||||
"test:ci": "vitest run",
|
"test:ci": "vitest run",
|
||||||
"lint": "biome check ./src",
|
"lint": "biome check",
|
||||||
"lint:fix": "biome check --write ./src",
|
"lint:fix": "biome check --write",
|
||||||
"i18n:extract": "lingui extract --clean"
|
"i18n:extract": "lingui extract --clean"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@emotion/react": "^11.14.0",
|
"@emotion/react": "11.14.0",
|
||||||
"@fontsource/open-sans": "^5.1.1",
|
"@fontsource/open-sans": "5.3.0",
|
||||||
"@mantine/core": "^7.16.3",
|
"@lingui/core": "6.12.0",
|
||||||
"@mantine/form": "^7.16.3",
|
"@lingui/react": "6.12.0",
|
||||||
"@mantine/hooks": "^7.16.3",
|
"@mantine/core": "9.7.4",
|
||||||
"@mantine/modals": "^7.16.3",
|
"@mantine/dates": "9.7.4",
|
||||||
"@mantine/notifications": "^7.16.3",
|
"@mantine/form": "9.7.4",
|
||||||
"@mantine/spotlight": "^7.16.3",
|
"@mantine/hooks": "9.7.4",
|
||||||
"@lingui/core": "^5.2.0",
|
"@mantine/modals": "9.7.4",
|
||||||
"@lingui/react": "^5.2.0",
|
"@mantine/notifications": "9.7.4",
|
||||||
"@monaco-editor/react": "^4.7.0",
|
"@mantine/spotlight": "9.7.4",
|
||||||
"@reduxjs/toolkit": "^2.5.1",
|
"@monaco-editor/react": "4.7.0",
|
||||||
"axios": "^1.7.9",
|
"@react-querybuilder/mantine": "8.26.0",
|
||||||
"dayjs": "^1.11.13",
|
"@reduxjs/toolkit": "2.13.0",
|
||||||
"escape-string-regexp": "^5.0.0",
|
"@rolldown/plugin-babel": "0.2.4",
|
||||||
"interweave": "^13.1.1",
|
"axios": "1.21.0",
|
||||||
"monaco-editor": "^0.52.2",
|
"dayjs": "1.11.23",
|
||||||
"mousetrap": "^1.6.5",
|
"escape-string-regexp": "5.0.0",
|
||||||
"react": "^19.0.0",
|
"interweave": "13.1.1",
|
||||||
"react-async-hook": "^4.0.0",
|
"monaco-editor": "0.57.0",
|
||||||
"react-contexify": "^6.0.0",
|
"mousetrap": "1.6.5",
|
||||||
"react-device-detect": "^2.2.3",
|
"react": "19.3.1",
|
||||||
"react-dom": "^19.0.0",
|
"react-async-hook": "4.0.0",
|
||||||
"react-draggable": "^4.4.6",
|
"react-contexify": "6.0.0",
|
||||||
"react-ga4": "^2.1.0",
|
"react-dom": "19.3.1",
|
||||||
"react-icons": "^5.4.0",
|
"react-draggable": "4.7.2",
|
||||||
"react-infinite-scroller": "^1.2.6",
|
"react-icons": "5.7.0",
|
||||||
"react-redux": "^9.2.0",
|
"react-infinite-scroller": "1.2.6",
|
||||||
"react-router-dom": "^7.1.5",
|
"react-querybuilder": "8.26.0",
|
||||||
"react-swipeable": "^7.0.2",
|
"react-redux": "9.3.1",
|
||||||
"redoc": "^2.4.0",
|
"react-router-dom": "7.18.5",
|
||||||
"style-to-object": "^1.0.8",
|
"react-swipeable": "7.0.2",
|
||||||
"throttle-debounce": "^5.0.2",
|
"style-to-object": "2.0.2",
|
||||||
"tinycon": "^0.6.8",
|
"throttle-debounce": "5.0.2",
|
||||||
"tss-react": "^4.9.15",
|
"tinycon": "0.6.8",
|
||||||
"websocket-heartbeat-js": "^1.1.3"
|
"tss-react": "4.9.21",
|
||||||
|
"websocket-heartbeat-js": "1.1.3"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@biomejs/biome": "^1.9.4",
|
"@biomejs/biome": "2.6.4",
|
||||||
"@lingui/babel-plugin-lingui-macro": "^5.2.0",
|
"@lingui/cli": "6.12.0",
|
||||||
"@lingui/cli": "^5.2.0",
|
"@lingui/vite-plugin": "6.12.0",
|
||||||
"@lingui/vite-plugin": "^5.2.0",
|
"@testing-library/jest-dom": "7.0.1",
|
||||||
"@types/mousetrap": "^1.6.15",
|
"@testing-library/react": "16.3.3",
|
||||||
"@types/react": "^19.0.8",
|
"@testing-library/user-event": "14.6.8",
|
||||||
"@types/react-dom": "^19.0.3",
|
"@types/mousetrap": "1.6.15",
|
||||||
"@types/react-infinite-scroller": "^1.2.5",
|
"@types/react": "19.3.0",
|
||||||
"@types/throttle-debounce": "^5.0.2",
|
"@types/react-dom": "19.3.0",
|
||||||
"@types/tinycon": "^0.6.7",
|
"@types/react-infinite-scroller": "1.2.5",
|
||||||
"@vitejs/plugin-react": "^4.3.4",
|
"@types/throttle-debounce": "5.0.2",
|
||||||
"babel-plugin-macros": "^3.1.0",
|
"@types/tinycon": "0.6.7",
|
||||||
"jsdom": "^26.0.0",
|
"@vitejs/plugin-react": "6.2.1",
|
||||||
"rollup-plugin-visualizer": "^5.14.0",
|
"babel-plugin-react-compiler": "^1.0.0",
|
||||||
"typescript": "^5.7.3",
|
"jsdom": "30.1.3",
|
||||||
"vite": "^6.1.0",
|
"typescript": "7.0.2",
|
||||||
"vite-plugin-checker": "^0.8.0",
|
"vite": "8.3.5",
|
||||||
"vite-tsconfig-paths": "^5.1.4",
|
"vite-plugin-checker": "0.14.5",
|
||||||
"vitest": "^3.0.5",
|
"vitest": "5.0.5",
|
||||||
"vitest-mock-extended": "^2.0.2"
|
"yaml": "2.9.1"
|
||||||
},
|
},
|
||||||
"overrides": {
|
"overrides": {
|
||||||
"react-infinite-scroller": {
|
"react-infinite-scroller": {
|
||||||
"react": "^19.0.0"
|
"react": "19.3.1"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,96 +1,142 @@
|
|||||||
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||||
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
|
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
|
||||||
|
|
||||||
<modelVersion>4.0.0</modelVersion>
|
<modelVersion>4.0.0</modelVersion>
|
||||||
|
|
||||||
<parent>
|
<parent>
|
||||||
<groupId>com.commafeed</groupId>
|
<groupId>com.commafeed</groupId>
|
||||||
<artifactId>commafeed</artifactId>
|
<artifactId>commafeed</artifactId>
|
||||||
<version>5.6.0</version>
|
<version>7.4.1</version>
|
||||||
</parent>
|
</parent>
|
||||||
<artifactId>commafeed-client</artifactId>
|
<artifactId>commafeed-client</artifactId>
|
||||||
<name>CommaFeed Client</name>
|
<name>CommaFeed Client</name>
|
||||||
|
|
||||||
<properties>
|
<properties>
|
||||||
<!-- renovate: datasource=node-version depName=node -->
|
<!-- renovate: datasource=node-version depName=node -->
|
||||||
<node.version>v22.14.0</node.version>
|
<node.version>v26.13.0</node.version>
|
||||||
<!-- renovate: datasource=npm depName=npm -->
|
<!-- renovate: datasource=npm depName=npm -->
|
||||||
<npm.version>11.1.0</npm.version>
|
<npm.version>12.2.1</npm.version>
|
||||||
</properties>
|
</properties>
|
||||||
|
|
||||||
<build>
|
<build>
|
||||||
<plugins>
|
<plugins>
|
||||||
<plugin>
|
<plugin>
|
||||||
<groupId>com.github.eirslett</groupId>
|
<groupId>com.github.eirslett</groupId>
|
||||||
<artifactId>frontend-maven-plugin</artifactId>
|
<artifactId>frontend-maven-plugin</artifactId>
|
||||||
<version>1.15.1</version>
|
<version>2.0.2</version>
|
||||||
<?m2e ignore?>
|
<?m2e ignore?>
|
||||||
<executions>
|
<executions>
|
||||||
<execution>
|
<execution>
|
||||||
<id>install node and npm</id>
|
<id>install node and npm</id>
|
||||||
<goals>
|
<goals>
|
||||||
<goal>install-node-and-npm</goal>
|
<goal>install-node-and-npm</goal>
|
||||||
</goals>
|
</goals>
|
||||||
<phase>compile</phase>
|
<phase>compile</phase>
|
||||||
<configuration>
|
<configuration>
|
||||||
<nodeVersion>${node.version}</nodeVersion>
|
<nodeVersion>${node.version}</nodeVersion>
|
||||||
<npmVersion>${npm.version}</npmVersion>
|
<npmVersion>${npm.version}</npmVersion>
|
||||||
</configuration>
|
</configuration>
|
||||||
</execution>
|
</execution>
|
||||||
<execution>
|
<execution>
|
||||||
<id>npm install</id>
|
<id>npm install</id>
|
||||||
<goals>
|
<goals>
|
||||||
<goal>npm</goal>
|
<goal>npm</goal>
|
||||||
</goals>
|
</goals>
|
||||||
<phase>compile</phase>
|
<phase>compile</phase>
|
||||||
<configuration>
|
<configuration>
|
||||||
<arguments>ci</arguments>
|
<arguments>ci</arguments>
|
||||||
</configuration>
|
</configuration>
|
||||||
</execution>
|
</execution>
|
||||||
<execution>
|
<execution>
|
||||||
<id>npm run test</id>
|
<id>npm run test</id>
|
||||||
<goals>
|
<goals>
|
||||||
<goal>npm</goal>
|
<goal>npm</goal>
|
||||||
</goals>
|
</goals>
|
||||||
<phase>compile</phase>
|
<phase>compile</phase>
|
||||||
<configuration>
|
<configuration>
|
||||||
<arguments>run test:ci</arguments>
|
<arguments>run test:ci</arguments>
|
||||||
</configuration>
|
<skip>${skipTests}</skip>
|
||||||
</execution>
|
</configuration>
|
||||||
<execution>
|
</execution>
|
||||||
<id>npm run build</id>
|
<execution>
|
||||||
<goals>
|
<id>npm run build</id>
|
||||||
<goal>npm</goal>
|
<goals>
|
||||||
</goals>
|
<goal>npm</goal>
|
||||||
<phase>compile</phase>
|
</goals>
|
||||||
<configuration>
|
<phase>compile</phase>
|
||||||
<arguments>run build</arguments>
|
<configuration>
|
||||||
</configuration>
|
<arguments>run build</arguments>
|
||||||
</execution>
|
</configuration>
|
||||||
</executions>
|
</execution>
|
||||||
</plugin>
|
</executions>
|
||||||
<plugin>
|
</plugin>
|
||||||
<artifactId>maven-resources-plugin</artifactId>
|
<plugin>
|
||||||
<version>3.3.1</version>
|
<artifactId>maven-resources-plugin</artifactId>
|
||||||
<executions>
|
<version>3.5.0</version>
|
||||||
<execution>
|
<executions>
|
||||||
<id>copy web interface to resources</id>
|
<execution>
|
||||||
<phase>prepare-package</phase>
|
<id>copy web interface to resources</id>
|
||||||
<goals>
|
<phase>prepare-package</phase>
|
||||||
<goal>copy-resources</goal>
|
<goals>
|
||||||
</goals>
|
<goal>copy-resources</goal>
|
||||||
<configuration>
|
</goals>
|
||||||
<outputDirectory>${project.build.directory}/classes/META-INF/resources</outputDirectory>
|
<configuration>
|
||||||
<resources>
|
<outputDirectory>${project.build.directory}/classes/META-INF/resources</outputDirectory>
|
||||||
<resource>
|
<resources>
|
||||||
<directory>dist</directory>
|
<resource>
|
||||||
<filtering>false</filtering>
|
<directory>dist</directory>
|
||||||
</resource>
|
<filtering>false</filtering>
|
||||||
</resources>
|
</resource>
|
||||||
</configuration>
|
</resources>
|
||||||
</execution>
|
</configuration>
|
||||||
</executions>
|
</execution>
|
||||||
</plugin>
|
</executions>
|
||||||
</plugins>
|
</plugin>
|
||||||
</build>
|
</plugins>
|
||||||
|
</build>
|
||||||
|
|
||||||
|
<profiles>
|
||||||
|
<!-- This profile is used to kill the Biome process on Windows -->
|
||||||
|
<!-- npm ci can fail if Biome is running (e.g., in the IDE) because it locks some files -->
|
||||||
|
<profile>
|
||||||
|
<id>kill-biome</id>
|
||||||
|
<activation>
|
||||||
|
<os>
|
||||||
|
<family>Windows</family>
|
||||||
|
</os>
|
||||||
|
</activation>
|
||||||
|
<build>
|
||||||
|
<plugins>
|
||||||
|
<plugin>
|
||||||
|
<groupId>org.codehaus.mojo</groupId>
|
||||||
|
<artifactId>exec-maven-plugin</artifactId>
|
||||||
|
<version>3.6.4</version>
|
||||||
|
<executions>
|
||||||
|
<execution>
|
||||||
|
<id>kill-biome</id>
|
||||||
|
<phase>initialize</phase>
|
||||||
|
<goals>
|
||||||
|
<goal>exec</goal>
|
||||||
|
</goals>
|
||||||
|
<configuration>
|
||||||
|
<executable>taskkill</executable>
|
||||||
|
<arguments>
|
||||||
|
<argument>/F</argument>
|
||||||
|
<argument>/IM</argument>
|
||||||
|
<argument>biome.exe</argument>
|
||||||
|
</arguments>
|
||||||
|
<successCodes>
|
||||||
|
<successCode>0</successCode>
|
||||||
|
<!-- taskkill returns 128 if the process is not found, which is fine in this case -->
|
||||||
|
<successCode>128</successCode>
|
||||||
|
</successCodes>
|
||||||
|
</configuration>
|
||||||
|
</execution>
|
||||||
|
</executions>
|
||||||
|
</plugin>
|
||||||
|
</plugins>
|
||||||
|
</build>
|
||||||
|
</profile>
|
||||||
|
</profiles>
|
||||||
|
|
||||||
</project>
|
</project>
|
||||||
40
commafeed-client/public/favicon.svg
Normal file
40
commafeed-client/public/favicon.svg
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||||
|
<svg
|
||||||
|
height="393.84613"
|
||||||
|
width="393.84613"
|
||||||
|
viewBox="0 0 5.0480766 5.0480766"
|
||||||
|
version="1.1"
|
||||||
|
id="svg3"
|
||||||
|
xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<title>CommaFeed favicon</title>
|
||||||
|
<defs
|
||||||
|
id="defs3" />
|
||||||
|
<rect
|
||||||
|
fill="#f88a14"
|
||||||
|
rx="0.53846151"
|
||||||
|
ry="0.53846151"
|
||||||
|
height="5.0480766"
|
||||||
|
width="5.0480766"
|
||||||
|
id="rect1"
|
||||||
|
x="0"
|
||||||
|
y="0"
|
||||||
|
style="stroke-width:0.769231" />
|
||||||
|
<path
|
||||||
|
d="m 1.3450904,0.64548657 c 2.9002,0 2.9002,2.91010003 2.9002,2.91010003"
|
||||||
|
fill="none"
|
||||||
|
stroke="#ffffff"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-width="0.78125"
|
||||||
|
id="path1" />
|
||||||
|
<path
|
||||||
|
d="m 1.3377904,1.9915866 c 1.5705,-0.00908 1.5705,1.5639 1.5705,1.5639"
|
||||||
|
fill="none"
|
||||||
|
stroke="#ffffff"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-width="0.78125"
|
||||||
|
id="path2" />
|
||||||
|
<path
|
||||||
|
d="m 2.0192904,3.5227866 c 0,0.23366 -0.10712,0.47418 -0.24663,0.6537 -0.1814,0.2333 -0.5705,0.5618 -0.6913,0.5653 0.0402,-0.0662 0.263,-0.5654 0.2563,-0.5654 -0.36423004,0 -0.65950004,-0.29265 -0.65950004,-0.65365 0,-0.361 0.29527,-0.65365 0.65950004,-0.65365 0.36423,0 0.68159,0.29265 0.68159,0.65365 z"
|
||||||
|
fill="#ffffff"
|
||||||
|
id="path3" />
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.1 KiB |
@@ -1,47 +1,53 @@
|
|||||||
import { i18n } from "@lingui/core"
|
import { i18n } from "@lingui/core"
|
||||||
import { I18nProvider } from "@lingui/react"
|
import { I18nProvider } from "@lingui/react"
|
||||||
import { MantineProvider } from "@mantine/core"
|
import { MantineProvider, v8CssVariablesResolver } from "@mantine/core"
|
||||||
import { ModalsProvider } from "@mantine/modals"
|
import { ModalsProvider } from "@mantine/modals"
|
||||||
import { Notifications } from "@mantine/notifications"
|
import { Notifications } from "@mantine/notifications"
|
||||||
import { Constants } from "app/constants"
|
import type React from "react"
|
||||||
import { redirectTo } from "app/redirect/slice"
|
import { useEffect } from "react"
|
||||||
import { reloadServerInfos } from "app/server/thunks"
|
import { HashRouter, Navigate, Route, Routes, useNavigate } from "react-router-dom"
|
||||||
import { useAppDispatch, useAppSelector } from "app/store"
|
import { Constants } from "@/app/constants"
|
||||||
import { categoryUnreadCount } from "app/utils"
|
import { redirectTo } from "@/app/redirect/slice"
|
||||||
import { DisablePullToRefresh } from "components/DisablePullToRefresh"
|
import { redirectToInitialSetup } from "@/app/redirect/thunks"
|
||||||
import { ErrorBoundary } from "components/ErrorBoundary"
|
import { reloadServerInfos } from "@/app/server/thunks"
|
||||||
import { Header } from "components/header/Header"
|
import { useAppDispatch, useAppSelector } from "@/app/store"
|
||||||
import { Tree } from "components/sidebar/Tree"
|
import { ErrorBoundary } from "@/components/ErrorBoundary"
|
||||||
import { useBrowserExtension } from "hooks/useBrowserExtension"
|
import { Header } from "@/components/header/Header"
|
||||||
import { useI18n } from "i18n"
|
import { Tree } from "@/components/sidebar/Tree"
|
||||||
import { WelcomePage } from "pages/WelcomePage"
|
import { useI18n } from "@/i18n"
|
||||||
import { AdminUsersPage } from "pages/admin/AdminUsersPage"
|
import { AdminUsersPage } from "@/pages/admin/AdminUsersPage"
|
||||||
import { MetricsPage } from "pages/admin/MetricsPage"
|
import { MetricsPage } from "@/pages/admin/MetricsPage"
|
||||||
import { AboutPage } from "pages/app/AboutPage"
|
import { AboutPage } from "@/pages/app/AboutPage"
|
||||||
import { AddPage } from "pages/app/AddPage"
|
import { AddPage } from "@/pages/app/AddPage"
|
||||||
import { CategoryDetailsPage } from "pages/app/CategoryDetailsPage"
|
import { CategoryDetailsPage } from "@/pages/app/CategoryDetailsPage"
|
||||||
import { DonatePage } from "pages/app/DonatePage"
|
import { DonatePage } from "@/pages/app/DonatePage"
|
||||||
import { FeedDetailsPage } from "pages/app/FeedDetailsPage"
|
import { FeedDetailsPage } from "@/pages/app/FeedDetailsPage"
|
||||||
import { FeedEntriesPage } from "pages/app/FeedEntriesPage"
|
import { FeedEntriesPage } from "@/pages/app/FeedEntriesPage"
|
||||||
import Layout from "pages/app/Layout"
|
import Layout from "@/pages/app/Layout"
|
||||||
import { SettingsPage } from "pages/app/SettingsPage"
|
import { SettingsPage } from "@/pages/app/SettingsPage"
|
||||||
import { TagDetailsPage } from "pages/app/TagDetailsPage"
|
import { TagDetailsPage } from "@/pages/app/TagDetailsPage"
|
||||||
import { LoginPage } from "pages/auth/LoginPage"
|
import { InitialSetupPage } from "@/pages/auth/InitialSetupPage"
|
||||||
import { PasswordRecoveryPage } from "pages/auth/PasswordRecoveryPage"
|
import { LoginPage } from "@/pages/auth/LoginPage"
|
||||||
import { RegistrationPage } from "pages/auth/RegistrationPage"
|
import { PasswordRecoveryPage } from "@/pages/auth/PasswordRecoveryPage"
|
||||||
import React, { useEffect } from "react"
|
import { PasswordResetPage } from "@/pages/auth/PasswordResetPage"
|
||||||
import { isSafari } from "react-device-detect"
|
import { RegistrationPage } from "@/pages/auth/RegistrationPage"
|
||||||
import ReactGA from "react-ga4"
|
import { WelcomePage } from "@/pages/WelcomePage"
|
||||||
import { HashRouter, Navigate, Route, Routes, useLocation, useNavigate } from "react-router-dom"
|
|
||||||
import Tinycon from "tinycon"
|
|
||||||
|
|
||||||
function Providers(props: { children: React.ReactNode }) {
|
function Providers(
|
||||||
|
props: Readonly<{
|
||||||
|
children: React.ReactNode
|
||||||
|
}>
|
||||||
|
) {
|
||||||
|
const primaryColor =
|
||||||
|
useAppSelector(state => state.user.settings?.primaryColor) || Constants.theme.defaultPrimaryColor
|
||||||
return (
|
return (
|
||||||
<I18nProvider i18n={i18n}>
|
<I18nProvider i18n={i18n}>
|
||||||
<MantineProvider
|
<MantineProvider
|
||||||
defaultColorScheme="auto"
|
defaultColorScheme="auto"
|
||||||
|
// keep using css variables from mantine v8
|
||||||
|
cssVariablesResolver={v8CssVariablesResolver}
|
||||||
theme={{
|
theme={{
|
||||||
primaryColor: "orange",
|
primaryColor: primaryColor,
|
||||||
fontFamily: "Open Sans",
|
fontFamily: "Open Sans",
|
||||||
colors: {
|
colors: {
|
||||||
// keep using dark colors from mantine v6
|
// keep using dark colors from mantine v6
|
||||||
@@ -70,9 +76,6 @@ function Providers(props: { children: React.ReactNode }) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// api documentation page is very large, load only on-demand
|
|
||||||
const ApiDocumentationPage = React.lazy(async () => await import("pages/app/ApiDocumentationPage"))
|
|
||||||
|
|
||||||
function AppRoutes() {
|
function AppRoutes() {
|
||||||
const sidebarVisible = useAppSelector(state => state.tree.sidebarVisible)
|
const sidebarVisible = useAppSelector(state => state.tree.sidebarVisible)
|
||||||
|
|
||||||
@@ -80,11 +83,15 @@ function AppRoutes() {
|
|||||||
<Routes>
|
<Routes>
|
||||||
<Route path="/" element={<Navigate to={`/app/category/${Constants.categories.all.id}`} replace />} />
|
<Route path="/" element={<Navigate to={`/app/category/${Constants.categories.all.id}`} replace />} />
|
||||||
<Route path="welcome" element={<WelcomePage />} />
|
<Route path="welcome" element={<WelcomePage />} />
|
||||||
|
<Route path="setup" element={<InitialSetupPage />} />
|
||||||
<Route path="login" element={<LoginPage />} />
|
<Route path="login" element={<LoginPage />} />
|
||||||
<Route path="register" element={<RegistrationPage />} />
|
<Route path="register" element={<RegistrationPage />} />
|
||||||
<Route path="passwordRecovery" element={<PasswordRecoveryPage />} />
|
<Route path="passwordRecovery" element={<PasswordRecoveryPage />} />
|
||||||
<Route path="api" element={<ApiDocumentationPage />} />
|
<Route path="passwordReset" element={<PasswordResetPage />} />
|
||||||
<Route path="app" element={<Layout header={<Header />} sidebar={<Tree />} sidebarVisible={sidebarVisible} />}>
|
<Route
|
||||||
|
path="app"
|
||||||
|
element={<Layout header={<Header />} sidebar={<Tree />} sidebarVisible={sidebarVisible} />}
|
||||||
|
>
|
||||||
<Route path="category">
|
<Route path="category">
|
||||||
<Route path=":id" element={<FeedEntriesPage sourceType="category" />} />
|
<Route path=":id" element={<FeedEntriesPage sourceType="category" />} />
|
||||||
<Route path=":id/details" element={<CategoryDetailsPage />} />
|
<Route path=":id/details" element={<CategoryDetailsPage />} />
|
||||||
@@ -111,6 +118,18 @@ function AppRoutes() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function InitialSetupHandler() {
|
||||||
|
const serverInfos = useAppSelector(state => state.server.serverInfos)
|
||||||
|
const dispatch = useAppDispatch()
|
||||||
|
useEffect(() => {
|
||||||
|
if (serverInfos?.initialSetupRequired) {
|
||||||
|
dispatch(redirectToInitialSetup())
|
||||||
|
}
|
||||||
|
}, [serverInfos, dispatch])
|
||||||
|
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
function RedirectHandler() {
|
function RedirectHandler() {
|
||||||
const target = useAppSelector(state => state.redirect.to)
|
const target = useAppSelector(state => state.redirect.to)
|
||||||
const dispatch = useAppDispatch()
|
const dispatch = useAppDispatch()
|
||||||
@@ -126,79 +145,21 @@ function RedirectHandler() {
|
|||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
function GoogleAnalyticsHandler() {
|
|
||||||
const location = useLocation()
|
|
||||||
const googleAnalyticsCode = useAppSelector(state => state.server.serverInfos?.googleAnalyticsCode)
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (googleAnalyticsCode) ReactGA.initialize(googleAnalyticsCode)
|
|
||||||
}, [googleAnalyticsCode])
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (ReactGA.isInitialized) ReactGA.send({ hitType: "pageview", page: location.pathname })
|
|
||||||
}, [location])
|
|
||||||
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
function UnreadCountTitleHandler({ unreadCount, enabled }: { unreadCount: number; enabled?: boolean }) {
|
|
||||||
return <title>{enabled && unreadCount > 0 ? `(${unreadCount}) CommaFeed` : "CommaFeed"}</title>
|
|
||||||
}
|
|
||||||
|
|
||||||
function UnreadCountFaviconHandler({ unreadCount, enabled }: { unreadCount: number; enabled?: boolean }) {
|
|
||||||
useEffect(() => {
|
|
||||||
if (enabled && unreadCount > 0) {
|
|
||||||
Tinycon.setBubble(unreadCount)
|
|
||||||
} else {
|
|
||||||
Tinycon.reset()
|
|
||||||
}
|
|
||||||
}, [unreadCount, enabled])
|
|
||||||
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
function BrowserExtensionBadgeUnreadCountHandler() {
|
|
||||||
const root = useAppSelector(state => state.tree.rootCategory)
|
|
||||||
const { setBadgeUnreadCount } = useBrowserExtension()
|
|
||||||
useEffect(() => {
|
|
||||||
if (!root) return
|
|
||||||
const unreadCount = categoryUnreadCount(root)
|
|
||||||
setBadgeUnreadCount(unreadCount)
|
|
||||||
}, [root, setBadgeUnreadCount])
|
|
||||||
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
export function App() {
|
export function App() {
|
||||||
useI18n()
|
useI18n()
|
||||||
const root = useAppSelector(state => state.tree.rootCategory)
|
|
||||||
const unreadCountTitle = useAppSelector(state => state.user.settings?.unreadCountTitle)
|
|
||||||
const unreadCountFavicon = useAppSelector(state => state.user.settings?.unreadCountFavicon)
|
|
||||||
const dispatch = useAppDispatch()
|
const dispatch = useAppDispatch()
|
||||||
|
|
||||||
const unreadCount = categoryUnreadCount(root)
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
dispatch(reloadServerInfos())
|
dispatch(reloadServerInfos())
|
||||||
}, [dispatch])
|
}, [dispatch])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Providers>
|
<Providers>
|
||||||
<>
|
<HashRouter>
|
||||||
<UnreadCountTitleHandler unreadCount={unreadCount} enabled={unreadCountTitle} />
|
<InitialSetupHandler />
|
||||||
<UnreadCountFaviconHandler unreadCount={unreadCount} enabled={unreadCountFavicon} />
|
<RedirectHandler />
|
||||||
<BrowserExtensionBadgeUnreadCountHandler />
|
<AppRoutes />
|
||||||
<HashRouter>
|
</HashRouter>
|
||||||
<GoogleAnalyticsHandler />
|
|
||||||
<RedirectHandler />
|
|
||||||
<AppRoutes />
|
|
||||||
{/* disable pull-to-refresh as it messes with vertical scrolling
|
|
||||||
safari behaves weirdly when overscroll-behavior is set to none so we disable it only for other browsers
|
|
||||||
https://github.com/Athou/commafeed/issues/1168
|
|
||||||
*/}
|
|
||||||
{!isSafari && <DisablePullToRefresh />}
|
|
||||||
</HashRouter>
|
|
||||||
</>
|
|
||||||
</Providers>
|
</Providers>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { createAsyncThunk } from "@reduxjs/toolkit"
|
import { createAsyncThunk } from "@reduxjs/toolkit"
|
||||||
import type { AppDispatch, RootState } from "app/store"
|
import type { AppDispatch, RootState } from "@/app/store"
|
||||||
|
|
||||||
export const createAppAsyncThunk = createAsyncThunk.withTypes<{
|
export const createAppAsyncThunk = createAsyncThunk.withTypes<{
|
||||||
state: RootState
|
state: RootState
|
||||||
|
|||||||
36
commafeed-client/src/app/client.test.ts
Normal file
36
commafeed-client/src/app/client.test.ts
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
import { i18n } from "@lingui/core"
|
||||||
|
import type { AxiosError } from "axios"
|
||||||
|
import { afterEach, describe, expect, it, vi } from "vitest"
|
||||||
|
import { errorToStrings } from "./client"
|
||||||
|
|
||||||
|
const axiosError = (status: number, data: unknown) =>
|
||||||
|
({
|
||||||
|
isAxiosError: true,
|
||||||
|
response: { status, data },
|
||||||
|
}) as AxiosError
|
||||||
|
|
||||||
|
describe("errorToStrings", () => {
|
||||||
|
afterEach(() => vi.restoreAllMocks())
|
||||||
|
|
||||||
|
it("translates known application error types", () => {
|
||||||
|
vi.spyOn(i18n, "_").mockReturnValue("Translated authentication error")
|
||||||
|
const error = axiosError(401, {
|
||||||
|
type: "WRONG_USERNAME_OR_PASSWORD",
|
||||||
|
message: "wrong username or password",
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(errorToStrings(error)).toEqual(["Translated authentication error"])
|
||||||
|
})
|
||||||
|
|
||||||
|
it("preserves backend messages for unexpected errors", () => {
|
||||||
|
const error = axiosError(500, { message: "unexpected error" })
|
||||||
|
|
||||||
|
expect(errorToStrings(error)).toEqual(["unexpected error"])
|
||||||
|
})
|
||||||
|
|
||||||
|
it("preserves backend messages for unknown application error types", () => {
|
||||||
|
const error = axiosError(400, { type: "UNKNOWN_ERROR", message: "unknown error" })
|
||||||
|
|
||||||
|
expect(errorToStrings(error)).toEqual(["unknown error"])
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import { i18n, type MessageDescriptor } from "@lingui/core"
|
||||||
|
import { msg } from "@lingui/core/macro"
|
||||||
import axios, { type AxiosError } from "axios"
|
import axios, { type AxiosError } from "axios"
|
||||||
import type {
|
import type {
|
||||||
AddCategoryRequest,
|
AddCategoryRequest,
|
||||||
@@ -6,18 +8,23 @@ import type {
|
|||||||
Category,
|
Category,
|
||||||
CategoryModificationRequest,
|
CategoryModificationRequest,
|
||||||
CollapseRequest,
|
CollapseRequest,
|
||||||
|
CommaFeedApplicationError,
|
||||||
|
CommaFeedExceptionType,
|
||||||
Entries,
|
Entries,
|
||||||
FeedInfo,
|
FeedInfo,
|
||||||
FeedInfoRequest,
|
FeedInfoRequest,
|
||||||
FeedModificationRequest,
|
FeedModificationRequest,
|
||||||
GetEntriesPaginatedRequest,
|
GetEntriesPaginatedRequest,
|
||||||
IDRequest,
|
IDRequest,
|
||||||
|
InitialSetupRequest,
|
||||||
LoginRequest,
|
LoginRequest,
|
||||||
MarkRequest,
|
MarkRequest,
|
||||||
Metrics,
|
Metrics,
|
||||||
MultipleMarkRequest,
|
MultipleMarkRequest,
|
||||||
|
PasswordResetConfirmationRequest,
|
||||||
PasswordResetRequest,
|
PasswordResetRequest,
|
||||||
ProfileModificationRequest,
|
ProfileModificationRequest,
|
||||||
|
PushNotificationSettings,
|
||||||
RegistrationRequest,
|
RegistrationRequest,
|
||||||
ServerInfo,
|
ServerInfo,
|
||||||
Settings,
|
Settings,
|
||||||
@@ -28,20 +35,25 @@ import type {
|
|||||||
UserModel,
|
UserModel,
|
||||||
} from "./types"
|
} from "./types"
|
||||||
|
|
||||||
|
const applicationErrorMessages = {
|
||||||
|
WRONG_USERNAME_OR_PASSWORD: msg`Wrong username or password`,
|
||||||
|
} satisfies Record<CommaFeedExceptionType, MessageDescriptor>
|
||||||
|
|
||||||
const axiosInstance = axios.create({ baseURL: "./rest", withCredentials: true })
|
const axiosInstance = axios.create({ baseURL: "./rest", withCredentials: true })
|
||||||
axiosInstance.interceptors.response.use(
|
axiosInstance.interceptors.response.use(
|
||||||
response => response,
|
response => response,
|
||||||
error => {
|
error => {
|
||||||
if (isAuthenticationError(error)) {
|
if (isAuthenticationError(error) && window.location.hash !== "#/login") {
|
||||||
const data = error.response?.data
|
const data = error.response?.data
|
||||||
window.location.hash = data?.allowRegistrations ? "/welcome" : "/login"
|
window.location.hash = data?.allowRegistrations ? "/welcome" : "/login"
|
||||||
|
window.location.reload()
|
||||||
}
|
}
|
||||||
throw error
|
throw error
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
function isAuthenticationError(error: unknown): error is AxiosError<AuthenticationError> {
|
function isAuthenticationError(error: unknown): error is AxiosError<AuthenticationError> {
|
||||||
return axios.isAxiosError(error) && !!error.response && [401, 403].includes(error.response.status)
|
return axios.isAxiosError(error) && error.response?.status === 401
|
||||||
}
|
}
|
||||||
|
|
||||||
export const client = {
|
export const client = {
|
||||||
@@ -49,7 +61,8 @@ export const client = {
|
|||||||
getRoot: async () => await axiosInstance.get<Category>("category/get"),
|
getRoot: async () => await axiosInstance.get<Category>("category/get"),
|
||||||
modify: async (req: CategoryModificationRequest) => await axiosInstance.post("category/modify", req),
|
modify: async (req: CategoryModificationRequest) => await axiosInstance.post("category/modify", req),
|
||||||
collapse: async (req: CollapseRequest) => await axiosInstance.post("category/collapse", req),
|
collapse: async (req: CollapseRequest) => await axiosInstance.post("category/collapse", req),
|
||||||
getEntries: async (req: GetEntriesPaginatedRequest) => await axiosInstance.get<Entries>("category/entries", { params: req }),
|
getEntries: async (req: GetEntriesPaginatedRequest) =>
|
||||||
|
await axiosInstance.get<Entries>("category/entries", { params: req }),
|
||||||
markEntries: async (req: MarkRequest) => await axiosInstance.post("category/mark", req),
|
markEntries: async (req: MarkRequest) => await axiosInstance.post("category/mark", req),
|
||||||
add: async (req: AddCategoryRequest) => await axiosInstance.post("category/add", req),
|
add: async (req: AddCategoryRequest) => await axiosInstance.post("category/add", req),
|
||||||
delete: async (req: IDRequest) => await axiosInstance.post("category/delete", req),
|
delete: async (req: IDRequest) => await axiosInstance.post("category/delete", req),
|
||||||
@@ -64,7 +77,8 @@ export const client = {
|
|||||||
feed: {
|
feed: {
|
||||||
get: async (id: string) => await axiosInstance.get<Subscription>(`feed/get/${id}`),
|
get: async (id: string) => await axiosInstance.get<Subscription>(`feed/get/${id}`),
|
||||||
modify: async (req: FeedModificationRequest) => await axiosInstance.post("feed/modify", req),
|
modify: async (req: FeedModificationRequest) => await axiosInstance.post("feed/modify", req),
|
||||||
getEntries: async (req: GetEntriesPaginatedRequest) => await axiosInstance.get<Entries>("feed/entries", { params: req }),
|
getEntries: async (req: GetEntriesPaginatedRequest) =>
|
||||||
|
await axiosInstance.get<Entries>("feed/entries", { params: req }),
|
||||||
markEntries: async (req: MarkRequest) => await axiosInstance.post("feed/mark", req),
|
markEntries: async (req: MarkRequest) => await axiosInstance.post("feed/mark", req),
|
||||||
fetchFeed: async (req: FeedInfoRequest) => await axiosInstance.post<FeedInfo>("feed/fetch", req),
|
fetchFeed: async (req: FeedInfoRequest) => await axiosInstance.post<FeedInfo>("feed/fetch", req),
|
||||||
refreshAll: async () => await axiosInstance.get("feed/refreshAll"),
|
refreshAll: async () => await axiosInstance.get("feed/refreshAll"),
|
||||||
@@ -93,9 +107,14 @@ export const client = {
|
|||||||
})
|
})
|
||||||
},
|
},
|
||||||
register: async (req: RegistrationRequest) => await axiosInstance.post("user/register", req),
|
register: async (req: RegistrationRequest) => await axiosInstance.post("user/register", req),
|
||||||
|
initialSetup: async (req: InitialSetupRequest) => await axiosInstance.post("user/initialSetup", req),
|
||||||
passwordReset: async (req: PasswordResetRequest) => await axiosInstance.post("user/passwordReset", req),
|
passwordReset: async (req: PasswordResetRequest) => await axiosInstance.post("user/passwordReset", req),
|
||||||
|
passwordResetCallback: async (req: PasswordResetConfirmationRequest) =>
|
||||||
|
await axiosInstance.post("user/passwordResetCallback", req),
|
||||||
getSettings: async () => await axiosInstance.get<Settings>("user/settings"),
|
getSettings: async () => await axiosInstance.get<Settings>("user/settings"),
|
||||||
saveSettings: async (settings: Settings) => await axiosInstance.post("user/settings", settings),
|
saveSettings: async (settings: Settings) => await axiosInstance.post("user/settings", settings),
|
||||||
|
sendTestPushNotification: async (settings: PushNotificationSettings) =>
|
||||||
|
await axiosInstance.post("user/pushNotificationTest", settings),
|
||||||
getProfile: async () => await axiosInstance.get<UserModel>("user/profile"),
|
getProfile: async () => await axiosInstance.get<UserModel>("user/profile"),
|
||||||
saveProfile: async (req: ProfileModificationRequest) => await axiosInstance.post("user/profile", req),
|
saveProfile: async (req: ProfileModificationRequest) => await axiosInstance.post("user/profile", req),
|
||||||
deleteProfile: async () => await axiosInstance.post("user/profile/deleteAccount"),
|
deleteProfile: async () => await axiosInstance.post("user/profile/deleteAccount"),
|
||||||
@@ -105,7 +124,7 @@ export const client = {
|
|||||||
},
|
},
|
||||||
admin: {
|
admin: {
|
||||||
getAllUsers: async () => await axiosInstance.get<UserModel[]>("admin/user/getAll"),
|
getAllUsers: async () => await axiosInstance.get<UserModel[]>("admin/user/getAll"),
|
||||||
saveUser: async (req: AdminSaveUserRequest) => await axiosInstance.post("admin/user/save", req),
|
saveUser: async (req: AdminSaveUserRequest) => await axiosInstance.post<number>("admin/user/save", req),
|
||||||
deleteUser: async (req: IDRequest) => await axiosInstance.post("admin/user/delete", req),
|
deleteUser: async (req: IDRequest) => await axiosInstance.post("admin/user/delete", req),
|
||||||
getMetrics: async () => await axiosInstance.get<Metrics>("admin/metrics"),
|
getMetrics: async () => await axiosInstance.get<Metrics>("admin/metrics"),
|
||||||
},
|
},
|
||||||
@@ -120,18 +139,33 @@ export const errorToStrings = (err: unknown) => {
|
|||||||
let strings: string[] = []
|
let strings: string[] = []
|
||||||
|
|
||||||
if (axios.isAxiosError(err) && err.response) {
|
if (axios.isAxiosError(err) && err.response) {
|
||||||
if (typeof err.response.data === "string") strings.push(err.response.data)
|
if (isCommaFeedApplicationError(err)) {
|
||||||
if (isMessageError(err)) strings.push(err.response.data.message)
|
strings.push(i18n._(applicationErrorMessages[err.response.data.type]))
|
||||||
if (isMessageArrayError(err)) strings = [...strings, ...err.response.data.errors]
|
} else {
|
||||||
|
if (typeof err.response.data === "string") strings.push(err.response.data)
|
||||||
|
if (isMessageError(err)) strings.push(err.response.data.message)
|
||||||
|
if (isMessageArrayError(err)) strings = [...strings, ...err.response.data.errors]
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return strings
|
return strings
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isCommaFeedApplicationError(err: AxiosError): err is AxiosError<CommaFeedApplicationError> {
|
||||||
|
const data = err.response?.data
|
||||||
|
if (!data || typeof data !== "object" || !("type" in data)) return false
|
||||||
|
const type = data.type
|
||||||
|
return typeof type === "string" && Object.hasOwn(applicationErrorMessages, type)
|
||||||
|
}
|
||||||
|
|
||||||
function isMessageError(err: AxiosError): err is AxiosError<{ message: string }> {
|
function isMessageError(err: AxiosError): err is AxiosError<{ message: string }> {
|
||||||
return !!err.response && !!err.response.data && typeof err.response.data === "object" && "message" in err.response.data
|
return (
|
||||||
|
!!err.response && !!err.response.data && typeof err.response.data === "object" && "message" in err.response.data
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function isMessageArrayError(err: AxiosError): err is AxiosError<{ errors: string[] }> {
|
function isMessageArrayError(err: AxiosError): err is AxiosError<{ errors: string[] }> {
|
||||||
return !!err.response && !!err.response.data && typeof err.response.data === "object" && "errors" in err.response.data
|
return (
|
||||||
|
!!err.response && !!err.response.data && typeof err.response.data === "object" && "errors" in err.response.data
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import type { IconType } from "react-icons"
|
import type { IconType } from "react-icons"
|
||||||
import { FaAt } from "react-icons/fa"
|
import { FaAt } from "react-icons/fa"
|
||||||
import { SiBuffer, SiFacebook, SiGmail, SiInstapaper, SiPocket, SiTumblr, SiX } from "react-icons/si"
|
import { SiBuffer, SiFacebook, SiGmail, SiInstapaper, SiTumblr, SiX } from "react-icons/si"
|
||||||
import type { Category, Entry, SharingSettings } from "./types"
|
import type { Category, Entry, SharingSettings } from "./types"
|
||||||
|
|
||||||
const categories: Record<string, Omit<Category, "name">> = {
|
const categories: Record<string, Omit<Category, "name">> = {
|
||||||
@@ -58,12 +58,6 @@ const sharing: {
|
|||||||
color: "#375672",
|
color: "#375672",
|
||||||
url: (url, desc) => `https://www.tumblr.com/share/link?url=${url}&name=${desc}`,
|
url: (url, desc) => `https://www.tumblr.com/share/link?url=${url}&name=${desc}`,
|
||||||
},
|
},
|
||||||
pocket: {
|
|
||||||
label: "Pocket",
|
|
||||||
icon: SiPocket,
|
|
||||||
color: "#EF4154",
|
|
||||||
url: (url, desc) => `https://getpocket.com/save?url=${url}&title=${desc}`,
|
|
||||||
},
|
|
||||||
instapaper: {
|
instapaper: {
|
||||||
label: "Instapaper",
|
label: "Instapaper",
|
||||||
icon: SiInstapaper,
|
icon: SiInstapaper,
|
||||||
@@ -87,23 +81,25 @@ export const Constants = {
|
|||||||
headerHeight: 60,
|
headerHeight: 60,
|
||||||
entryMaxWidth: 650,
|
entryMaxWidth: 650,
|
||||||
isTopVisible: (div: HTMLElement) => {
|
isTopVisible: (div: HTMLElement) => {
|
||||||
const header = document.getElementById(Constants.dom.headerId)?.getBoundingClientRect()
|
const header = document.getElementsByTagName("header").item(0)?.getBoundingClientRect()
|
||||||
return div.getBoundingClientRect().top >= (header?.bottom ?? 0)
|
return div.getBoundingClientRect().top >= (header?.bottom ?? 0)
|
||||||
},
|
},
|
||||||
isBottomVisible: (div: HTMLElement) => {
|
isBottomVisible: (div: HTMLElement) => {
|
||||||
const footer = document.getElementById(Constants.dom.footerId)?.getBoundingClientRect()
|
const footer = document.getElementsByTagName("footer").item(0)?.getBoundingClientRect()
|
||||||
return div.getBoundingClientRect().bottom <= (footer?.top ?? window.innerHeight)
|
return div.getBoundingClientRect().bottom <= (footer?.top ?? window.innerHeight)
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
dom: {
|
dom: {
|
||||||
headerId: "header",
|
|
||||||
footerId: "footer",
|
|
||||||
entryId: (entry: Entry) => `entry-id-${entry.id}`,
|
entryId: (entry: Entry) => `entry-id-${entry.id}`,
|
||||||
entryContextMenuId: (entry: Entry) => entry.id,
|
entryContextMenuId: (entry: Entry) => entry.id,
|
||||||
},
|
},
|
||||||
|
theme: {
|
||||||
|
defaultPrimaryColor: "orange",
|
||||||
|
},
|
||||||
tooltip: {
|
tooltip: {
|
||||||
delay: 500,
|
delay: 500,
|
||||||
},
|
},
|
||||||
browserExtensionUrl: "https://github.com/Athou/commafeed-browser-extension",
|
browserExtensionUrl: "https://github.com/Athou/commafeed-browser-extension",
|
||||||
|
customCssDocumentationUrl: "https://athou.github.io/commafeed/documentation/custom-css",
|
||||||
bitcoinWalletAddress: "1dymfUxqCWpyD7a6rQSqNy4rLVDBsAr5e",
|
bitcoinWalletAddress: "1dymfUxqCWpyD7a6rQSqNy4rLVDBsAr5e",
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,25 +1,20 @@
|
|||||||
import { configureStore } from "@reduxjs/toolkit"
|
import { configureStore } from "@reduxjs/toolkit"
|
||||||
import type { client } from "app/client"
|
|
||||||
import { loadEntries, loadMoreEntries, markAllEntries, markEntry } from "app/entries/thunks"
|
|
||||||
import { type RootState, reducers } from "app/store"
|
|
||||||
import type { Entries, Entry } from "app/types"
|
|
||||||
import type { AxiosResponse } from "axios"
|
import type { AxiosResponse } from "axios"
|
||||||
import { beforeEach, describe, expect, it, vi } from "vitest"
|
import { beforeEach, describe, expect, it, vi } from "vitest"
|
||||||
import { any, mockReset } from "vitest-mock-extended"
|
import { client } from "@/app/client"
|
||||||
|
import { loadEntries, loadMoreEntries, markAllEntries, markEntry } from "@/app/entries/thunks"
|
||||||
|
import { type RootState, reducers } from "@/app/store"
|
||||||
|
import type { Entries, Entry } from "@/app/types"
|
||||||
|
|
||||||
const mockClient = await vi.hoisted(async () => {
|
vi.mock(import("@/app/client"))
|
||||||
const mockModule = await import("vitest-mock-extended")
|
|
||||||
return mockModule.mockDeep<typeof client>()
|
|
||||||
})
|
|
||||||
vi.mock("app/client", () => ({ client: mockClient }))
|
|
||||||
|
|
||||||
describe("entries", () => {
|
describe("entries", () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
mockReset(mockClient)
|
vi.resetAllMocks()
|
||||||
})
|
})
|
||||||
|
|
||||||
it("loads entries", async () => {
|
it("loads entries", async () => {
|
||||||
mockClient.feed.getEntries.calledWith(any()).mockResolvedValue({
|
vi.mocked(client.feed.getEntries).mockResolvedValue({
|
||||||
data: {
|
data: {
|
||||||
entries: [{ id: "3" } as Entry],
|
entries: [{ id: "3" } as Entry],
|
||||||
hasMore: false,
|
hasMore: false,
|
||||||
@@ -32,7 +27,12 @@ describe("entries", () => {
|
|||||||
} as AxiosResponse<Entries>)
|
} as AxiosResponse<Entries>)
|
||||||
|
|
||||||
const store = configureStore({ reducer: reducers })
|
const store = configureStore({ reducer: reducers })
|
||||||
const promise = store.dispatch(loadEntries({ source: { type: "feed", id: "feed-id" }, clearSearch: true }))
|
const promise = store.dispatch(
|
||||||
|
loadEntries({
|
||||||
|
source: { type: "feed", id: "feed-id" },
|
||||||
|
clearSearch: true,
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
expect(store.getState().entries.source.type).toBe("feed")
|
expect(store.getState().entries.source.type).toBe("feed")
|
||||||
expect(store.getState().entries.source.id).toBe("feed-id")
|
expect(store.getState().entries.source.id).toBe("feed-id")
|
||||||
@@ -53,7 +53,7 @@ describe("entries", () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
it("loads more entries", async () => {
|
it("loads more entries", async () => {
|
||||||
mockClient.category.getEntries.calledWith(any()).mockResolvedValue({
|
vi.mocked(client.category.getEntries).mockResolvedValue({
|
||||||
data: {
|
data: {
|
||||||
entries: [{ id: "4" } as Entry],
|
entries: [{ id: "4" } as Entry],
|
||||||
hasMore: false,
|
hasMore: false,
|
||||||
@@ -89,6 +89,188 @@ describe("entries", () => {
|
|||||||
expect(store.getState().entries.hasMore).toBe(false)
|
expect(store.getState().entries.hasMore).toBe(false)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it("stops loading after an initial-load failure and allows a retry", async () => {
|
||||||
|
const response = {
|
||||||
|
data: {
|
||||||
|
entries: [{ id: "3" } as Entry],
|
||||||
|
hasMore: true,
|
||||||
|
name: "my-feed",
|
||||||
|
feedLink: "https://mysite.com/feed",
|
||||||
|
timestamp: 123,
|
||||||
|
},
|
||||||
|
} as AxiosResponse<Entries>
|
||||||
|
vi.mocked(client.feed.getEntries)
|
||||||
|
.mockRejectedValueOnce(new Error("Network error"))
|
||||||
|
.mockResolvedValueOnce(response)
|
||||||
|
|
||||||
|
const store = configureStore({ reducer: reducers })
|
||||||
|
const arg = { source: { type: "feed" as const, id: "feed-id" }, clearSearch: true }
|
||||||
|
const request = store.dispatch(loadEntries(arg))
|
||||||
|
|
||||||
|
expect(store.getState().entries.loading).toBe(true)
|
||||||
|
expect(loadEntries.rejected.match(await request)).toBe(true)
|
||||||
|
expect(store.getState().entries.loading).toBe(false)
|
||||||
|
expect(store.getState().entries.entries).toStrictEqual([])
|
||||||
|
expect(store.getState().entries.hasMore).toBe(true)
|
||||||
|
|
||||||
|
expect(loadEntries.fulfilled.match(await store.dispatch(loadEntries(arg)))).toBe(true)
|
||||||
|
expect(store.getState().entries.entries).toStrictEqual(response.data.entries)
|
||||||
|
expect(store.getState().entries.loading).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("preserves entries after a pagination failure and allows a retry", async () => {
|
||||||
|
const response = {
|
||||||
|
data: {
|
||||||
|
entries: [{ id: "3", read: false } as Entry],
|
||||||
|
hasMore: true,
|
||||||
|
name: "my-feed",
|
||||||
|
feedLink: "https://mysite.com/feed",
|
||||||
|
timestamp: 123,
|
||||||
|
},
|
||||||
|
} as AxiosResponse<Entries>
|
||||||
|
vi.mocked(client.feed.getEntries)
|
||||||
|
.mockResolvedValueOnce(response)
|
||||||
|
.mockRejectedValueOnce(new Error("Network error"))
|
||||||
|
.mockResolvedValueOnce({
|
||||||
|
data: { ...response.data, entries: [{ id: "4" } as Entry], hasMore: false },
|
||||||
|
} as AxiosResponse<Entries>)
|
||||||
|
|
||||||
|
const store = configureStore({ reducer: reducers })
|
||||||
|
await store.dispatch(loadEntries({ source: { type: "feed", id: "feed-id" }, clearSearch: true }))
|
||||||
|
const previousState = store.getState().entries
|
||||||
|
const request = store.dispatch(loadMoreEntries())
|
||||||
|
|
||||||
|
expect(store.getState().entries.loading).toBe(true)
|
||||||
|
expect(loadMoreEntries.rejected.match(await request)).toBe(true)
|
||||||
|
expect(store.getState().entries.loading).toBe(false)
|
||||||
|
expect(store.getState().entries.entries).toStrictEqual(previousState.entries)
|
||||||
|
expect(store.getState().entries.hasMore).toBe(true)
|
||||||
|
expect(store.getState().entries.timestamp).toBe(123)
|
||||||
|
expect(store.getState().entries.sourceLabel).toBe("my-feed")
|
||||||
|
expect(store.getState().entries.sourceWebsiteUrl).toBe("https://mysite.com/feed")
|
||||||
|
|
||||||
|
expect(loadMoreEntries.fulfilled.match(await store.dispatch(loadMoreEntries()))).toBe(true)
|
||||||
|
expect(store.getState().entries.entries).toStrictEqual([{ id: "3", read: false }, { id: "4" }])
|
||||||
|
expect(store.getState().entries.hasMore).toBe(false)
|
||||||
|
expect(store.getState().entries.loading).toBe(false)
|
||||||
|
expect(vi.mocked(client.feed.getEntries).mock.calls[1][0]).toEqual(
|
||||||
|
vi.mocked(client.feed.getEntries).mock.calls[2][0]
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
describe.each(["initial load", "pagination"] as const)("overlapping %s requests", olderRequestType => {
|
||||||
|
it.each(["initial load", "pagination"] as const)(
|
||||||
|
"ignores an older failure while a newer %s is pending",
|
||||||
|
async newerRequestType => {
|
||||||
|
let rejectOlder!: (reason: Error) => void
|
||||||
|
vi.mocked(client.category.getEntries).mockReturnValueOnce(
|
||||||
|
new Promise<AxiosResponse<Entries>>((_, reject) => {
|
||||||
|
rejectOlder = reject
|
||||||
|
})
|
||||||
|
)
|
||||||
|
vi.mocked(client.category.getEntries).mockReturnValueOnce(new Promise<AxiosResponse<Entries>>(() => {}))
|
||||||
|
|
||||||
|
const store = configureStore({ reducer: reducers })
|
||||||
|
const arg = { source: { type: "category" as const, id: "all" }, clearSearch: true }
|
||||||
|
const older =
|
||||||
|
olderRequestType === "initial load"
|
||||||
|
? store.dispatch(loadEntries(arg))
|
||||||
|
: store.dispatch(loadMoreEntries())
|
||||||
|
const newer =
|
||||||
|
newerRequestType === "initial load"
|
||||||
|
? store.dispatch(loadEntries(arg))
|
||||||
|
: store.dispatch(loadMoreEntries())
|
||||||
|
|
||||||
|
rejectOlder(new Error("Older request failed"))
|
||||||
|
await older
|
||||||
|
expect(store.getState().entries.loading).toBe(true)
|
||||||
|
|
||||||
|
newer.abort()
|
||||||
|
await newer
|
||||||
|
expect(store.getState().entries.loading).toBe(false)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
it("does not clear a newer request's loading state when an older request succeeds", async () => {
|
||||||
|
let resolveOlder!: (response: AxiosResponse<Entries>) => void
|
||||||
|
vi.mocked(client.category.getEntries).mockReturnValueOnce(
|
||||||
|
new Promise<AxiosResponse<Entries>>(resolve => {
|
||||||
|
resolveOlder = resolve
|
||||||
|
})
|
||||||
|
)
|
||||||
|
vi.mocked(client.category.getEntries).mockReturnValueOnce(new Promise<AxiosResponse<Entries>>(() => {}))
|
||||||
|
|
||||||
|
const store = configureStore({ reducer: reducers })
|
||||||
|
const arg = { source: { type: "category" as const, id: "all" }, clearSearch: true }
|
||||||
|
const older =
|
||||||
|
olderRequestType === "initial load"
|
||||||
|
? store.dispatch(loadEntries(arg))
|
||||||
|
: store.dispatch(loadMoreEntries())
|
||||||
|
const newer = store.dispatch(loadEntries(arg))
|
||||||
|
|
||||||
|
resolveOlder({
|
||||||
|
data: {
|
||||||
|
entries: [] as Entry[],
|
||||||
|
hasMore: true,
|
||||||
|
name: "old-category",
|
||||||
|
feedLink: "",
|
||||||
|
timestamp: 123,
|
||||||
|
errorCount: 0,
|
||||||
|
ignoredReadStatus: false,
|
||||||
|
},
|
||||||
|
} as AxiosResponse<Entries>)
|
||||||
|
await older
|
||||||
|
expect(store.getState().entries.loading).toBe(true)
|
||||||
|
|
||||||
|
newer.abort()
|
||||||
|
await newer
|
||||||
|
expect(store.getState().entries.loading).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("ignores an older failure after the newer request succeeds", async () => {
|
||||||
|
let rejectOlder!: (reason: Error) => void
|
||||||
|
vi.mocked(client.category.getEntries).mockReturnValueOnce(
|
||||||
|
new Promise<AxiosResponse<Entries>>((_, reject) => {
|
||||||
|
rejectOlder = reject
|
||||||
|
})
|
||||||
|
)
|
||||||
|
vi.mocked(client.category.getEntries).mockResolvedValueOnce({
|
||||||
|
data: { entries: [{ id: "4" } as Entry], hasMore: false, name: "new-category", timestamp: 456 },
|
||||||
|
} as AxiosResponse<Entries>)
|
||||||
|
|
||||||
|
const store = configureStore({ reducer: reducers })
|
||||||
|
const arg = { source: { type: "category" as const, id: "all" }, clearSearch: true }
|
||||||
|
const older =
|
||||||
|
olderRequestType === "initial load"
|
||||||
|
? store.dispatch(loadEntries(arg))
|
||||||
|
: store.dispatch(loadMoreEntries())
|
||||||
|
await store.dispatch(loadEntries(arg))
|
||||||
|
const completedState = store.getState().entries
|
||||||
|
|
||||||
|
rejectOlder(new Error("Older request failed"))
|
||||||
|
await older
|
||||||
|
expect(store.getState().entries).toStrictEqual(completedState)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it("does not append entries already present when loading more entries", async () => {
|
||||||
|
vi.mocked(client.category.getEntries)
|
||||||
|
.mockResolvedValueOnce({
|
||||||
|
data: { entries: [{ id: "3" } as Entry], hasMore: true },
|
||||||
|
} as AxiosResponse<Entries>)
|
||||||
|
.mockResolvedValueOnce({
|
||||||
|
data: { entries: [{ id: "3" } as Entry, { id: "4" } as Entry], hasMore: false },
|
||||||
|
} as AxiosResponse<Entries>)
|
||||||
|
|
||||||
|
const store = configureStore({ reducer: reducers })
|
||||||
|
await store.dispatch(loadEntries({ source: { type: "category", id: "all" }, clearSearch: true }))
|
||||||
|
await store.dispatch(loadMoreEntries())
|
||||||
|
|
||||||
|
expect(store.getState().entries.entries).toStrictEqual([{ id: "3" }, { id: "4" }])
|
||||||
|
expect(store.getState().entries.loading).toBe(false)
|
||||||
|
expect(store.getState().entries.hasMore).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
it("marks an entry as read", () => {
|
it("marks an entry as read", () => {
|
||||||
const store = configureStore({
|
const store = configureStore({
|
||||||
reducer: reducers,
|
reducer: reducers,
|
||||||
@@ -113,7 +295,31 @@ describe("entries", () => {
|
|||||||
{ id: "3", read: true },
|
{ id: "3", read: true },
|
||||||
{ id: "4", read: false },
|
{ id: "4", read: false },
|
||||||
])
|
])
|
||||||
expect(mockClient.entry.mark).toHaveBeenCalledWith({ id: "3", read: true })
|
expect(client.entry.mark).toHaveBeenCalledWith({ id: "3", read: true })
|
||||||
|
})
|
||||||
|
|
||||||
|
it("flags an entry marked as unread by the user until entries are reloaded", async () => {
|
||||||
|
vi.mocked(client.feed.getEntries).mockResolvedValue({
|
||||||
|
data: {
|
||||||
|
entries: [{ id: "3", read: true, markable: true } as Entry],
|
||||||
|
hasMore: false,
|
||||||
|
name: "my-feed",
|
||||||
|
errorCount: 0,
|
||||||
|
feedLink: "https://mysite.com/feed",
|
||||||
|
timestamp: 123,
|
||||||
|
ignoredReadStatus: false,
|
||||||
|
},
|
||||||
|
} as AxiosResponse<Entries>)
|
||||||
|
|
||||||
|
const store = configureStore({ reducer: reducers })
|
||||||
|
await store.dispatch(loadEntries({ source: { type: "feed", id: "feed-id" }, clearSearch: true }))
|
||||||
|
|
||||||
|
store.dispatch(markEntry({ entry: store.getState().entries.entries[0], read: false }))
|
||||||
|
expect(store.getState().entries.entries[0].keepUnread).toBe(true)
|
||||||
|
|
||||||
|
// reloading the entry list resets the flag
|
||||||
|
await store.dispatch(loadEntries({ source: { type: "feed", id: "feed-id" }, clearSearch: false }))
|
||||||
|
expect(store.getState().entries.entries[0].keepUnread).toBeUndefined()
|
||||||
})
|
})
|
||||||
|
|
||||||
it("marks all entries as read", () => {
|
it("marks all entries as read", () => {
|
||||||
@@ -135,11 +341,19 @@ describe("entries", () => {
|
|||||||
} as RootState,
|
} as RootState,
|
||||||
})
|
})
|
||||||
|
|
||||||
store.dispatch(markAllEntries({ sourceType: "category", req: { id: "all", read: true } }))
|
store.dispatch(
|
||||||
|
markAllEntries({
|
||||||
|
sourceType: "category",
|
||||||
|
req: { id: "all", read: true },
|
||||||
|
})
|
||||||
|
)
|
||||||
expect(store.getState().entries.entries).toStrictEqual([
|
expect(store.getState().entries.entries).toStrictEqual([
|
||||||
{ id: "3", read: true },
|
{ id: "3", read: true },
|
||||||
{ id: "4", read: true },
|
{ id: "4", read: true },
|
||||||
])
|
])
|
||||||
expect(mockClient.category.markEntries).toHaveBeenCalledWith({ id: "all", read: true })
|
expect(client.category.markEntries).toHaveBeenCalledWith({
|
||||||
|
id: "all",
|
||||||
|
read: true,
|
||||||
|
})
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,7 +1,15 @@
|
|||||||
import { type PayloadAction, createSlice } from "@reduxjs/toolkit"
|
import { createSlice, isAnyOf, type PayloadAction } from "@reduxjs/toolkit"
|
||||||
import { Constants } from "app/constants"
|
import { Constants } from "@/app/constants"
|
||||||
import { loadEntries, loadMoreEntries, markAllEntries, markEntry, markMultipleEntries, starEntry, tagEntry } from "app/entries/thunks"
|
import {
|
||||||
import type { Entry } from "app/types"
|
loadEntries,
|
||||||
|
loadMoreEntries,
|
||||||
|
markAllEntries,
|
||||||
|
markEntry,
|
||||||
|
markMultipleEntries,
|
||||||
|
starEntry,
|
||||||
|
tagEntry,
|
||||||
|
} from "@/app/entries/thunks"
|
||||||
|
import type { Entry } from "@/app/types"
|
||||||
|
|
||||||
export type EntrySourceType = "category" | "feed" | "tag"
|
export type EntrySourceType = "category" | "feed" | "tag"
|
||||||
|
|
||||||
@@ -10,7 +18,11 @@ export interface EntrySource {
|
|||||||
id: string
|
id: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export type ExpendableEntry = Entry & { expanded?: boolean }
|
export type ExpendableEntry = Entry & {
|
||||||
|
expanded?: boolean
|
||||||
|
/** set when the user explicitly marks the entry as unread, prevents it from being marked as read again by scrolling in expanded mode until the entry list is reloaded */
|
||||||
|
keepUnread?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
interface EntriesState {
|
interface EntriesState {
|
||||||
/** selected source */
|
/** selected source */
|
||||||
@@ -26,8 +38,11 @@ interface EntriesState {
|
|||||||
selectedEntryId?: string
|
selectedEntryId?: string
|
||||||
hasMore: boolean
|
hasMore: boolean
|
||||||
loading: boolean
|
loading: boolean
|
||||||
|
/** identifies the request that owns the loading state */
|
||||||
|
currentRequestId?: string
|
||||||
search?: string
|
search?: string
|
||||||
scrollingToEntry: boolean
|
scrollingToEntry: boolean
|
||||||
|
markAllAsReadConfirmationDialogOpen: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
const initialState: EntriesState = {
|
const initialState: EntriesState = {
|
||||||
@@ -41,6 +56,7 @@ const initialState: EntriesState = {
|
|||||||
hasMore: true,
|
hasMore: true,
|
||||||
loading: false,
|
loading: false,
|
||||||
scrollingToEntry: false,
|
scrollingToEntry: false,
|
||||||
|
markAllAsReadConfirmationDialogOpen: false,
|
||||||
}
|
}
|
||||||
|
|
||||||
export const entriesSlice = createSlice({
|
export const entriesSlice = createSlice({
|
||||||
@@ -61,25 +77,34 @@ export const entriesSlice = createSlice({
|
|||||||
setSearch: (state, action: PayloadAction<string>) => {
|
setSearch: (state, action: PayloadAction<string>) => {
|
||||||
state.search = action.payload
|
state.search = action.payload
|
||||||
},
|
},
|
||||||
|
setMarkAllAsReadConfirmationDialogOpen: (state, action: PayloadAction<boolean>) => {
|
||||||
|
state.markAllAsReadConfirmationDialogOpen = action.payload
|
||||||
|
},
|
||||||
},
|
},
|
||||||
extraReducers: builder => {
|
extraReducers: builder => {
|
||||||
builder.addCase(markEntry.pending, (state, action) => {
|
builder.addCase(markEntry.pending, (state, action) => {
|
||||||
for (const e of state.entries.filter(e => e.id === action.meta.arg.entry.id)) {
|
for (const e of state.entries.filter(e => e.id === action.meta.arg.entry.id)) {
|
||||||
e.read = action.meta.arg.read
|
e.read = action.meta.arg.read
|
||||||
|
if (!action.meta.arg.read) e.keepUnread = true
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
builder.addCase(markMultipleEntries.pending, (state, action) => {
|
builder.addCase(markMultipleEntries.pending, (state, action) => {
|
||||||
for (const e of state.entries.filter(e => action.meta.arg.entries.some(e2 => e2.id === e.id))) {
|
for (const e of state.entries.filter(e => action.meta.arg.entries.some(e2 => e2.id === e.id))) {
|
||||||
e.read = action.meta.arg.read
|
e.read = action.meta.arg.read
|
||||||
|
if (!action.meta.arg.read) e.keepUnread = true
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
builder.addCase(markAllEntries.pending, (state, action) => {
|
builder.addCase(markAllEntries.pending, (state, action) => {
|
||||||
for (const e of state.entries.filter(e => (action.meta.arg.req.olderThan ? e.date < action.meta.arg.req.olderThan : true))) {
|
for (const e of state.entries.filter(e =>
|
||||||
|
action.meta.arg.req.olderThan ? e.date < action.meta.arg.req.olderThan : true
|
||||||
|
)) {
|
||||||
e.read = true
|
e.read = true
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
builder.addCase(starEntry.pending, (state, action) => {
|
builder.addCase(starEntry.pending, (state, action) => {
|
||||||
for (const e of state.entries.filter(e => action.meta.arg.entry.id === e.id && action.meta.arg.entry.feedId === e.feedId)) {
|
for (const e of state.entries.filter(
|
||||||
|
e => action.meta.arg.entry.id === e.id && action.meta.arg.entry.feedId === e.feedId
|
||||||
|
)) {
|
||||||
e.starred = action.meta.arg.starred
|
e.starred = action.meta.arg.starred
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -92,9 +117,11 @@ export const entriesSlice = createSlice({
|
|||||||
state.hasMore = true
|
state.hasMore = true
|
||||||
state.selectedEntryId = undefined
|
state.selectedEntryId = undefined
|
||||||
state.loading = true
|
state.loading = true
|
||||||
|
state.currentRequestId = action.meta.requestId
|
||||||
})
|
})
|
||||||
builder.addCase(loadMoreEntries.pending, state => {
|
builder.addCase(loadMoreEntries.pending, (state, action) => {
|
||||||
state.loading = true
|
state.loading = true
|
||||||
|
state.currentRequestId = action.meta.requestId
|
||||||
})
|
})
|
||||||
builder.addCase(loadEntries.fulfilled, (state, action) => {
|
builder.addCase(loadEntries.fulfilled, (state, action) => {
|
||||||
state.entries = action.payload.entries
|
state.entries = action.payload.entries
|
||||||
@@ -102,21 +129,25 @@ export const entriesSlice = createSlice({
|
|||||||
state.sourceLabel = action.payload.name
|
state.sourceLabel = action.payload.name
|
||||||
state.sourceWebsiteUrl = action.payload.feedLink
|
state.sourceWebsiteUrl = action.payload.feedLink
|
||||||
state.hasMore = action.payload.hasMore
|
state.hasMore = action.payload.hasMore
|
||||||
state.loading = false
|
|
||||||
})
|
})
|
||||||
builder.addCase(loadMoreEntries.fulfilled, (state, action) => {
|
builder.addCase(loadMoreEntries.fulfilled, (state, action) => {
|
||||||
// remove already existing entries
|
// remove already existing entries
|
||||||
const entriesToAdd = action.payload.entries.filter(e => !state.entries.some(e2 => e.id === e2.id))
|
const entriesToAdd = action.payload.entries.filter(e => !state.entries.some(e2 => e.id === e2.id))
|
||||||
state.entries = [...state.entries, ...entriesToAdd]
|
state.entries = [...state.entries, ...entriesToAdd]
|
||||||
state.hasMore = action.payload.hasMore
|
state.hasMore = action.payload.hasMore
|
||||||
state.loading = false
|
|
||||||
})
|
})
|
||||||
builder.addCase(tagEntry.pending, (state, action) => {
|
builder.addCase(tagEntry.pending, (state, action) => {
|
||||||
for (const e of state.entries.filter(e => +e.id === action.meta.arg.entryId)) {
|
for (const e of state.entries.filter(e => +e.id === action.meta.arg.entryId)) {
|
||||||
e.tags = action.meta.arg.tags
|
e.tags = action.meta.arg.tags
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
builder.addMatcher(isAnyOf(loadEntries.settled, loadMoreEntries.settled), (state, action) => {
|
||||||
|
if (state.currentRequestId === action.meta.requestId) {
|
||||||
|
state.loading = false
|
||||||
|
state.currentRequestId = undefined
|
||||||
|
}
|
||||||
|
})
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
export const { setSearch } = entriesSlice.actions
|
export const { setSearch, setMarkAllAsReadConfirmationDialogOpen } = entriesSlice.actions
|
||||||
|
|||||||
@@ -1,13 +1,19 @@
|
|||||||
import { createAppAsyncThunk } from "app/async-thunk"
|
|
||||||
import { client } from "app/client"
|
|
||||||
import { Constants } from "app/constants"
|
|
||||||
import { type EntrySource, type EntrySourceType, entriesSlice, setSearch } from "app/entries/slice"
|
|
||||||
import type { RootState } from "app/store"
|
|
||||||
import { reloadTree } from "app/tree/thunks"
|
|
||||||
import type { Entry, MarkRequest, TagRequest } from "app/types"
|
|
||||||
import { reloadTags } from "app/user/thunks"
|
|
||||||
import { scrollToWithCallback } from "app/utils"
|
|
||||||
import { flushSync } from "react-dom"
|
import { flushSync } from "react-dom"
|
||||||
|
import { createAppAsyncThunk } from "@/app/async-thunk"
|
||||||
|
import { client } from "@/app/client"
|
||||||
|
import { Constants } from "@/app/constants"
|
||||||
|
import {
|
||||||
|
type EntrySource,
|
||||||
|
type EntrySourceType,
|
||||||
|
entriesSlice,
|
||||||
|
setMarkAllAsReadConfirmationDialogOpen,
|
||||||
|
setSearch,
|
||||||
|
} from "@/app/entries/slice"
|
||||||
|
import type { RootState } from "@/app/store"
|
||||||
|
import { reloadTree, selectNextUnreadTreeItem } from "@/app/tree/thunks"
|
||||||
|
import type { Entry, MarkRequest, TagRequest } from "@/app/types"
|
||||||
|
import { reloadTags } from "@/app/user/thunks"
|
||||||
|
import { scrollToWithCallback } from "@/app/utils"
|
||||||
|
|
||||||
const getEndpoint = (sourceType: EntrySourceType) =>
|
const getEndpoint = (sourceType: EntrySourceType) =>
|
||||||
sourceType === "category" || sourceType === "tag" ? client.category.getEntries : client.feed.getEntries
|
sourceType === "category" || sourceType === "tag" ? client.category.getEntries : client.feed.getEntries
|
||||||
@@ -34,7 +40,11 @@ export const loadMoreEntries = createAppAsyncThunk("entries/loadMore", async (_,
|
|||||||
const state = thunkApi.getState()
|
const state = thunkApi.getState()
|
||||||
const { source } = state.entries
|
const { source } = state.entries
|
||||||
const offset =
|
const offset =
|
||||||
state.user.settings?.readingMode === "all" ? state.entries.entries.length : state.entries.entries.filter(e => !e.read).length
|
state.user.settings?.readingMode === "all" ||
|
||||||
|
state.entries.search ||
|
||||||
|
(source.type === "category" && source.id === "starred")
|
||||||
|
? state.entries.entries.length
|
||||||
|
: state.entries.entries.filter(e => !e.read).length
|
||||||
const endpoint = getEndpoint(state.entries.source.type)
|
const endpoint = getEndpoint(state.entries.source.type)
|
||||||
const result = await endpoint(buildGetEntriesPaginatedRequest(state, source, offset))
|
const result = await endpoint(buildGetEntriesPaginatedRequest(state, source, offset))
|
||||||
return result.data
|
return result.data
|
||||||
@@ -50,7 +60,7 @@ const buildGetEntriesPaginatedRequest = (state: RootState, source: EntrySource,
|
|||||||
keywords: state.entries.search,
|
keywords: state.entries.search,
|
||||||
})
|
})
|
||||||
|
|
||||||
export const reloadEntries = createAppAsyncThunk("entries/reload", (arg, thunkApi) => {
|
export const reloadEntries = createAppAsyncThunk("entries/reload", (_, thunkApi) => {
|
||||||
const state = thunkApi.getState()
|
const state = thunkApi.getState()
|
||||||
thunkApi.dispatch(loadEntries({ source: state.entries.source, clearSearch: false }))
|
thunkApi.dispatch(loadEntries({ source: state.entries.source, clearSearch: false }))
|
||||||
})
|
})
|
||||||
@@ -123,12 +133,43 @@ export const markAllEntries = createAppAsyncThunk(
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
export const markAllAsReadWithConfirmationIfRequired = createAppAsyncThunk(
|
||||||
|
"entries/entry/markAllAsReadWithConfirmationIfRequired",
|
||||||
|
async (_, thunkApi) => {
|
||||||
|
const state = thunkApi.getState()
|
||||||
|
const source = state.entries.source
|
||||||
|
const entriesTimestamp = state.entries.timestamp ?? Date.now()
|
||||||
|
const search = state.entries.search
|
||||||
|
const markAllAsReadConfirmation = state.user.settings?.markAllAsReadConfirmation
|
||||||
|
const markAllAsReadNavigateToNextUnread = state.user.settings?.markAllAsReadNavigateToNextUnread
|
||||||
|
|
||||||
|
if (markAllAsReadConfirmation) {
|
||||||
|
thunkApi.dispatch(setMarkAllAsReadConfirmationDialogOpen(true))
|
||||||
|
} else {
|
||||||
|
await thunkApi.dispatch(
|
||||||
|
markAllEntries({
|
||||||
|
sourceType: source.type,
|
||||||
|
req: {
|
||||||
|
id: source.id,
|
||||||
|
read: true,
|
||||||
|
olderThan: Date.now(),
|
||||||
|
insertedBefore: entriesTimestamp,
|
||||||
|
keywords: search,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
)
|
||||||
|
const isAllCategorySelected = source.type === "category" && source.id === Constants.categories.all.id
|
||||||
|
if (markAllAsReadNavigateToNextUnread && !isAllCategorySelected)
|
||||||
|
await thunkApi.dispatch(selectNextUnreadTreeItem({ direction: "forward" }))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
export const starEntry = createAppAsyncThunk(
|
export const starEntry = createAppAsyncThunk(
|
||||||
"entries/entry/star",
|
"entries/entry/star",
|
||||||
(arg: { entry: Entry; starred: boolean }) => {
|
(arg: { entry: Entry; starred: boolean }) => {
|
||||||
client.entry.star({
|
client.entry.star({
|
||||||
id: arg.entry.id,
|
id: arg.entry.id,
|
||||||
feedId: +arg.entry.feedId,
|
|
||||||
starred: arg.starred,
|
starred: arg.starred,
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
@@ -181,7 +222,9 @@ export const selectEntry = createAppAsyncThunk(
|
|||||||
|
|
||||||
const entryIndex = state.entries.entries.indexOf(entry)
|
const entryIndex = state.entries.entries.indexOf(entry)
|
||||||
const entriesToKeepOnTopWhenScrolling =
|
const entriesToKeepOnTopWhenScrolling =
|
||||||
viewMode === "expanded" ? 0 : Math.min(state.user.settings?.entriesToKeepOnTopWhenScrolling ?? 0, entryIndex)
|
viewMode === "expanded"
|
||||||
|
? 0
|
||||||
|
: Math.min(state.user.settings?.entriesToKeepOnTopWhenScrolling ?? 0, entryIndex)
|
||||||
const entryToScrollTo = state.entries.entries[entryIndex - entriesToKeepOnTopWhenScrolling]
|
const entryToScrollTo = state.entries.entries[entryIndex - entriesToKeepOnTopWhenScrolling]
|
||||||
|
|
||||||
const entryElement = document.getElementById(Constants.dom.entryId(entry))
|
const entryElement = document.getElementById(Constants.dom.entryId(entry))
|
||||||
@@ -189,7 +232,8 @@ export const selectEntry = createAppAsyncThunk(
|
|||||||
if (entryElement && entryElementToScrollTo) {
|
if (entryElement && entryElementToScrollTo) {
|
||||||
const scrollMode = state.user.settings?.scrollMode
|
const scrollMode = state.user.settings?.scrollMode
|
||||||
const entryEntirelyVisible =
|
const entryEntirelyVisible =
|
||||||
Constants.layout.isTopVisible(entryElementToScrollTo) && Constants.layout.isBottomVisible(entryElement)
|
Constants.layout.isTopVisible(entryElementToScrollTo) &&
|
||||||
|
Constants.layout.isBottomVisible(entryElement)
|
||||||
if (scrollMode === "always" || (scrollMode === "if_needed" && !entryEntirelyVisible)) {
|
if (scrollMode === "always" || (scrollMode === "if_needed" && !entryEntirelyVisible)) {
|
||||||
const scrollSpeed = state.user.settings?.scrollSpeed
|
const scrollSpeed = state.user.settings?.scrollSpeed
|
||||||
const margin = viewMode === "detailed" ? 8 : 3
|
const margin = viewMode === "detailed" ? 8 : 3
|
||||||
@@ -203,8 +247,13 @@ export const selectEntry = createAppAsyncThunk(
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
const scrollToEntry = (entryElement: HTMLElement, margin: number, scrollSpeed: number | undefined, onScrollEnded: () => void) => {
|
const scrollToEntry = (
|
||||||
const header = document.getElementById(Constants.dom.headerId)?.getBoundingClientRect()
|
entryElement: HTMLElement,
|
||||||
|
margin: number,
|
||||||
|
scrollSpeed: number | undefined,
|
||||||
|
onScrollEnded: () => void
|
||||||
|
) => {
|
||||||
|
const header = document.getElementsByTagName("header").item(0)?.getBoundingClientRect()
|
||||||
const offset = (header?.bottom ?? 0) + margin
|
const offset = (header?.bottom ?? 0) + margin
|
||||||
scrollToWithCallback({
|
scrollToWithCallback({
|
||||||
options: {
|
options: {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { redirectToCategory } from "app/redirect/thunks"
|
|
||||||
import { store } from "app/store"
|
|
||||||
import { describe, expect, it } from "vitest"
|
import { describe, expect, it } from "vitest"
|
||||||
|
import { redirectToCategory } from "@/app/redirect/thunks"
|
||||||
|
import { store } from "@/app/store"
|
||||||
|
|
||||||
describe("redirects", () => {
|
describe("redirects", () => {
|
||||||
it("redirects to category", async () => {
|
it("redirects to category", async () => {
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { type PayloadAction, createSlice } from "@reduxjs/toolkit"
|
import { createSlice, type PayloadAction } from "@reduxjs/toolkit"
|
||||||
|
|
||||||
interface RedirectState {
|
interface RedirectState {
|
||||||
to?: string
|
to?: string
|
||||||
|
|||||||
@@ -1,12 +1,22 @@
|
|||||||
import { createAppAsyncThunk } from "app/async-thunk"
|
import { createAppAsyncThunk } from "@/app/async-thunk"
|
||||||
import { Constants } from "app/constants"
|
import { Constants } from "@/app/constants"
|
||||||
import { redirectTo } from "app/redirect/slice"
|
import { redirectTo } from "@/app/redirect/slice"
|
||||||
|
|
||||||
export const redirectToLogin = createAppAsyncThunk("redirect/login", (_, thunkApi) => thunkApi.dispatch(redirectTo("/login")))
|
export const redirectToLogin = createAppAsyncThunk("redirect/login", (_, thunkApi) =>
|
||||||
|
thunkApi.dispatch(redirectTo("/login"))
|
||||||
|
)
|
||||||
|
|
||||||
export const redirectToRegistration = createAppAsyncThunk("redirect/register", (_, thunkApi) => thunkApi.dispatch(redirectTo("/register")))
|
export const redirectToRegistration = createAppAsyncThunk("redirect/register", (_, thunkApi) =>
|
||||||
|
thunkApi.dispatch(redirectTo("/register"))
|
||||||
|
)
|
||||||
|
|
||||||
export const redirectToApiDocumentation = createAppAsyncThunk("redirect/api", (_, thunkApi) => thunkApi.dispatch(redirectTo("/api")))
|
export const redirectToInitialSetup = createAppAsyncThunk("redirect/initialSetup", (_, thunkApi) =>
|
||||||
|
thunkApi.dispatch(redirectTo("/setup"))
|
||||||
|
)
|
||||||
|
|
||||||
|
export const redirectToApiDocumentation = createAppAsyncThunk("redirect/api", () => {
|
||||||
|
window.location.href = "api-documentation/"
|
||||||
|
})
|
||||||
|
|
||||||
export const redirectToSelectedSource = createAppAsyncThunk("redirect/selectedSource", (_, thunkApi) => {
|
export const redirectToSelectedSource = createAppAsyncThunk("redirect/selectedSource", (_, thunkApi) => {
|
||||||
const { source } = thunkApi.getState().entries
|
const { source } = thunkApi.getState().entries
|
||||||
@@ -34,15 +44,21 @@ export const redirectToFeedDetails = createAppAsyncThunk("redirect/feed/details"
|
|||||||
thunkApi.dispatch(redirectTo(`/app/feed/${id}/details`))
|
thunkApi.dispatch(redirectTo(`/app/feed/${id}/details`))
|
||||||
)
|
)
|
||||||
|
|
||||||
export const redirectToTag = createAppAsyncThunk("redirect/tag", (id: string, thunkApi) => thunkApi.dispatch(redirectTo(`/app/tag/${id}`)))
|
export const redirectToTag = createAppAsyncThunk("redirect/tag", (id: string, thunkApi) =>
|
||||||
|
thunkApi.dispatch(redirectTo(`/app/tag/${id}`))
|
||||||
|
)
|
||||||
|
|
||||||
export const redirectToTagDetails = createAppAsyncThunk("redirect/tag/details", (id: string, thunkApi) =>
|
export const redirectToTagDetails = createAppAsyncThunk("redirect/tag/details", (id: string, thunkApi) =>
|
||||||
thunkApi.dispatch(redirectTo(`/app/tag/${id}/details`))
|
thunkApi.dispatch(redirectTo(`/app/tag/${id}/details`))
|
||||||
)
|
)
|
||||||
|
|
||||||
export const redirectToAdd = createAppAsyncThunk("redirect/add", (_, thunkApi) => thunkApi.dispatch(redirectTo("/app/add")))
|
export const redirectToAdd = createAppAsyncThunk("redirect/add", (_, thunkApi) =>
|
||||||
|
thunkApi.dispatch(redirectTo("/app/add"))
|
||||||
|
)
|
||||||
|
|
||||||
export const redirectToSettings = createAppAsyncThunk("redirect/settings", (_, thunkApi) => thunkApi.dispatch(redirectTo("/app/settings")))
|
export const redirectToSettings = createAppAsyncThunk("redirect/settings", (_, thunkApi) =>
|
||||||
|
thunkApi.dispatch(redirectTo("/app/settings"))
|
||||||
|
)
|
||||||
|
|
||||||
export const redirectToAdminUsers = createAppAsyncThunk("redirect/admin/users", (_, thunkApi) =>
|
export const redirectToAdminUsers = createAppAsyncThunk("redirect/admin/users", (_, thunkApi) =>
|
||||||
thunkApi.dispatch(redirectTo("/app/admin/users"))
|
thunkApi.dispatch(redirectTo("/app/admin/users"))
|
||||||
@@ -52,6 +68,10 @@ export const redirectToMetrics = createAppAsyncThunk("redirect/admin/metrics", (
|
|||||||
thunkApi.dispatch(redirectTo("/app/admin/metrics"))
|
thunkApi.dispatch(redirectTo("/app/admin/metrics"))
|
||||||
)
|
)
|
||||||
|
|
||||||
export const redirectToDonate = createAppAsyncThunk("redirect/donate", (_, thunkApi) => thunkApi.dispatch(redirectTo("/app/donate")))
|
export const redirectToDonate = createAppAsyncThunk("redirect/donate", (_, thunkApi) =>
|
||||||
|
thunkApi.dispatch(redirectTo("/app/donate"))
|
||||||
|
)
|
||||||
|
|
||||||
export const redirectToAbout = createAppAsyncThunk("redirect/about", (_, thunkApi) => thunkApi.dispatch(redirectTo("/app/about")))
|
export const redirectToAbout = createAppAsyncThunk("redirect/about", (_, thunkApi) =>
|
||||||
|
thunkApi.dispatch(redirectTo("/app/about"))
|
||||||
|
)
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { type PayloadAction, createSlice } from "@reduxjs/toolkit"
|
import { createSlice, type PayloadAction } from "@reduxjs/toolkit"
|
||||||
import { reloadServerInfos } from "app/server/thunks"
|
import { reloadServerInfos } from "@/app/server/thunks"
|
||||||
import type { ServerInfo } from "app/types"
|
import type { ServerInfo } from "@/app/types"
|
||||||
|
|
||||||
interface ServerState {
|
interface ServerState {
|
||||||
serverInfos?: ServerInfo
|
serverInfos?: ServerInfo
|
||||||
|
|||||||
@@ -1,4 +1,7 @@
|
|||||||
import { createAppAsyncThunk } from "app/async-thunk"
|
import { createAppAsyncThunk } from "@/app/async-thunk"
|
||||||
import { client } from "app/client"
|
import { client } from "@/app/client"
|
||||||
|
|
||||||
export const reloadServerInfos = createAppAsyncThunk("server/infos", async () => await client.server.getServerInfos().then(r => r.data))
|
export const reloadServerInfos = createAppAsyncThunk(
|
||||||
|
"server/infos",
|
||||||
|
async () => await client.server.getServerInfos().then(r => r.data)
|
||||||
|
)
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
import { configureStore } from "@reduxjs/toolkit"
|
import { configureStore } from "@reduxjs/toolkit"
|
||||||
import { entriesSlice } from "app/entries/slice"
|
import { shallowEqual, type TypedUseSelectorHook, useDispatch, useSelector } from "react-redux"
|
||||||
import { redirectSlice } from "app/redirect/slice"
|
import { entriesSlice } from "@/app/entries/slice"
|
||||||
import { serverSlice } from "app/server/slice"
|
import { redirectSlice } from "@/app/redirect/slice"
|
||||||
import { treeSlice } from "app/tree/slice"
|
import { serverSlice } from "@/app/server/slice"
|
||||||
import type { LocalSettings } from "app/types"
|
import { treeSlice } from "@/app/tree/slice"
|
||||||
import { initialLocalSettings, userSlice } from "app/user/slice"
|
import type { LocalSettings } from "@/app/types"
|
||||||
import { type TypedUseSelectorHook, useDispatch, useSelector } from "react-redux"
|
import { initialLocalSettings, userSlice } from "@/app/user/slice"
|
||||||
|
|
||||||
export const reducers = {
|
export const reducers = {
|
||||||
entries: entriesSlice.reducer,
|
entries: entriesSlice.reducer,
|
||||||
@@ -17,19 +17,9 @@ export const reducers = {
|
|||||||
|
|
||||||
const loadLocalSettings = (): LocalSettings => {
|
const loadLocalSettings = (): LocalSettings => {
|
||||||
const json = localStorage.getItem("commafeed-local-settings")
|
const json = localStorage.getItem("commafeed-local-settings")
|
||||||
if (json) {
|
|
||||||
return JSON.parse(json)
|
|
||||||
}
|
|
||||||
|
|
||||||
// load old settings
|
|
||||||
const viewMode = localStorage.getItem("view-mode")
|
|
||||||
const sidebarWidth = localStorage.getItem("sidebar-width")
|
|
||||||
const announcementHash = localStorage.getItem("announcement-hash")
|
|
||||||
return {
|
return {
|
||||||
...initialLocalSettings,
|
...initialLocalSettings,
|
||||||
viewMode: viewMode ? JSON.parse(viewMode) : initialLocalSettings.viewMode,
|
...(json ? JSON.parse(json) : {}),
|
||||||
sidebarWidth: sidebarWidth ? JSON.parse(sidebarWidth) : initialLocalSettings.sidebarWidth,
|
|
||||||
announcementHash: announcementHash ? JSON.parse(announcementHash) : initialLocalSettings.announcementHash,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -51,3 +41,5 @@ export type AppDispatch = typeof store.dispatch
|
|||||||
|
|
||||||
export const useAppDispatch: () => AppDispatch = useDispatch
|
export const useAppDispatch: () => AppDispatch = useDispatch
|
||||||
export const useAppSelector: TypedUseSelectorHook<RootState> = useSelector
|
export const useAppSelector: TypedUseSelectorHook<RootState> = useSelector
|
||||||
|
export const useShallowEqualAppSelector: TypedUseSelectorHook<RootState> = selector =>
|
||||||
|
useSelector(selector, shallowEqual)
|
||||||
|
|||||||
@@ -1,12 +1,22 @@
|
|||||||
import { type PayloadAction, createSlice } from "@reduxjs/toolkit"
|
import { createSlice, type PayloadAction } from "@reduxjs/toolkit"
|
||||||
import { markEntry } from "app/entries/thunks"
|
import { loadEntries, markEntry } from "@/app/entries/thunks"
|
||||||
import { redirectTo } from "app/redirect/slice"
|
import { redirectTo } from "@/app/redirect/slice"
|
||||||
import { collapseTreeCategory, reloadTree } from "app/tree/thunks"
|
import { collapseTreeCategory, reloadTree } from "@/app/tree/thunks"
|
||||||
import type { Category } from "app/types"
|
import type { Category, Subscription } from "@/app/types"
|
||||||
import { visitCategoryTree } from "app/utils"
|
import { flattenCategoryTree, visitCategoryTree } from "@/app/utils"
|
||||||
|
|
||||||
|
export interface TreeSubscription extends Subscription {
|
||||||
|
// client-side only flag
|
||||||
|
hasNewEntries?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TreeCategory extends Category {
|
||||||
|
feeds: TreeSubscription[]
|
||||||
|
children: TreeCategory[]
|
||||||
|
}
|
||||||
|
|
||||||
interface TreeState {
|
interface TreeState {
|
||||||
rootCategory?: Category
|
rootCategory?: TreeCategory
|
||||||
mobileMenuOpen: boolean
|
mobileMenuOpen: boolean
|
||||||
sidebarVisible: boolean
|
sidebarVisible: boolean
|
||||||
}
|
}
|
||||||
@@ -37,12 +47,27 @@ export const treeSlice = createSlice({
|
|||||||
visitCategoryTree(state.rootCategory, c => {
|
visitCategoryTree(state.rootCategory, c => {
|
||||||
for (const f of c.feeds.filter(f => f.id === action.payload.feedId)) {
|
for (const f of c.feeds.filter(f => f.id === action.payload.feedId)) {
|
||||||
f.unread += action.payload.amount
|
f.unread += action.payload.amount
|
||||||
|
f.hasNewEntries = true
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
extraReducers: builder => {
|
extraReducers: builder => {
|
||||||
builder.addCase(reloadTree.fulfilled, (state, action) => {
|
builder.addCase(reloadTree.fulfilled, (state, action) => {
|
||||||
|
// set hasNewEntries to true if new unread > previous unread
|
||||||
|
if (state.rootCategory) {
|
||||||
|
const oldFeeds = flattenCategoryTree(state.rootCategory).flatMap(c => c.feeds)
|
||||||
|
const oldFeedsById = new Map(oldFeeds.map(f => [f.id, f]))
|
||||||
|
|
||||||
|
const newFeeds = flattenCategoryTree(action.payload).flatMap(c => c.feeds)
|
||||||
|
for (const newFeed of newFeeds) {
|
||||||
|
const oldFeed = oldFeedsById.get(newFeed.id)
|
||||||
|
if (oldFeed && newFeed.unread > oldFeed.unread) {
|
||||||
|
newFeed.hasNewEntries = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
state.rootCategory = action.payload
|
state.rootCategory = action.payload
|
||||||
})
|
})
|
||||||
builder.addCase(collapseTreeCategory.pending, (state, action) => {
|
builder.addCase(collapseTreeCategory.pending, (state, action) => {
|
||||||
@@ -59,6 +84,25 @@ export const treeSlice = createSlice({
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
builder.addCase(loadEntries.fulfilled, (state, action) => {
|
||||||
|
if (!state.rootCategory) return
|
||||||
|
|
||||||
|
const { source } = action.meta.arg
|
||||||
|
if (source.type === "category") {
|
||||||
|
visitCategoryTree(state.rootCategory, c => {
|
||||||
|
if (c.id === source.id) {
|
||||||
|
for (const f of flattenCategoryTree(c).flatMap(c => c.feeds)) {
|
||||||
|
f.hasNewEntries = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
} else if (source.type === "feed") {
|
||||||
|
const feeds = flattenCategoryTree(state.rootCategory).flatMap(c => c.feeds)
|
||||||
|
for (const f of feeds.filter(f => f.id === +source.id)) {
|
||||||
|
f.hasNewEntries = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
builder.addCase(redirectTo, state => {
|
builder.addCase(redirectTo, state => {
|
||||||
state.mobileMenuOpen = false
|
state.mobileMenuOpen = false
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,14 +1,66 @@
|
|||||||
import { createAppAsyncThunk } from "app/async-thunk"
|
import { createAppAsyncThunk } from "@/app/async-thunk"
|
||||||
import { client } from "app/client"
|
import { client } from "@/app/client"
|
||||||
import { incrementUnreadCount } from "app/tree/slice"
|
import { Constants } from "@/app/constants"
|
||||||
import type { CollapseRequest } from "app/types"
|
import { redirectToCategory, redirectToFeed } from "@/app/redirect/thunks"
|
||||||
import { flattenCategoryTree } from "app/utils"
|
import { incrementUnreadCount } from "@/app/tree/slice"
|
||||||
|
import type { CollapseRequest, Subscription } from "@/app/types"
|
||||||
|
import { flattenCategoryTree, visitCategoryTree } from "@/app/utils"
|
||||||
|
|
||||||
export const reloadTree = createAppAsyncThunk("tree/reload", async () => await client.category.getRoot().then(r => r.data))
|
export const reloadTree = createAppAsyncThunk(
|
||||||
|
"tree/reload",
|
||||||
|
async () => await client.category.getRoot().then(r => r.data)
|
||||||
|
)
|
||||||
|
|
||||||
export const collapseTreeCategory = createAppAsyncThunk(
|
export const collapseTreeCategory = createAppAsyncThunk(
|
||||||
"tree/category/collapse",
|
"tree/category/collapse",
|
||||||
async (req: CollapseRequest) => await client.category.collapse(req)
|
async (req: CollapseRequest) => await client.category.collapse(req).then(r => r.data)
|
||||||
|
)
|
||||||
|
|
||||||
|
export const selectNextUnreadTreeItem = createAppAsyncThunk(
|
||||||
|
"tree/selectNextUnreadItem",
|
||||||
|
(
|
||||||
|
arg: {
|
||||||
|
direction: "forward" | "backward"
|
||||||
|
},
|
||||||
|
thunkApi
|
||||||
|
) => {
|
||||||
|
const state = thunkApi.getState()
|
||||||
|
const root = state.tree.rootCategory
|
||||||
|
if (!root) return
|
||||||
|
|
||||||
|
const { source } = state.entries
|
||||||
|
if (source.type === "category") {
|
||||||
|
const categories = flattenCategoryTree(root)
|
||||||
|
if (arg.direction === "backward") categories.reverse()
|
||||||
|
|
||||||
|
const index = categories.findIndex(c => c.id === source.id)
|
||||||
|
if (index === -1) return
|
||||||
|
|
||||||
|
for (let i = index + 1; i < categories.length; i++) {
|
||||||
|
const c = categories[i]
|
||||||
|
if (c.feeds.some(f => f.unread > 0)) {
|
||||||
|
return thunkApi.dispatch(redirectToCategory(String(c.id)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (source.type === "feed") {
|
||||||
|
const feeds: Subscription[] = []
|
||||||
|
visitCategoryTree(root, c => feeds.push(...c.feeds), { childrenFirst: true })
|
||||||
|
if (arg.direction === "backward") feeds.reverse()
|
||||||
|
|
||||||
|
const index = feeds.findIndex(f => f.id === +source.id)
|
||||||
|
if (index === -1) return
|
||||||
|
|
||||||
|
for (let i = index + 1; i < feeds.length; i++) {
|
||||||
|
const f = feeds[i]
|
||||||
|
if (f.unread > 0) {
|
||||||
|
return thunkApi.dispatch(redirectToFeed(String(f.id)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// redirect to 'all' if no unread categories or feeds found or if we reached the end of the list
|
||||||
|
thunkApi.dispatch(redirectToCategory(Constants.categories.all.id))
|
||||||
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
export const newFeedEntriesDiscovered = createAppAsyncThunk(
|
export const newFeedEntriesDiscovered = createAppAsyncThunk(
|
||||||
|
|||||||
173
commafeed-client/src/app/tree/tree.test.ts
Normal file
173
commafeed-client/src/app/tree/tree.test.ts
Normal file
@@ -0,0 +1,173 @@
|
|||||||
|
import { configureStore } from "@reduxjs/toolkit"
|
||||||
|
import type { AxiosResponse } from "axios"
|
||||||
|
import { beforeEach, describe, expect, it, vi } from "vitest"
|
||||||
|
import { client } from "@/app/client"
|
||||||
|
import { loadEntries } from "@/app/entries/thunks"
|
||||||
|
import { type RootState, reducers } from "@/app/store"
|
||||||
|
import { newFeedEntriesDiscovered, selectNextUnreadTreeItem } from "@/app/tree/thunks"
|
||||||
|
import type { Category, Entries, Entry, Subscription } from "@/app/types"
|
||||||
|
|
||||||
|
vi.mock(import("@/app/client"))
|
||||||
|
|
||||||
|
const createCategory = (id: string): Category => ({
|
||||||
|
id,
|
||||||
|
name: id,
|
||||||
|
children: [],
|
||||||
|
feeds: [],
|
||||||
|
expanded: true,
|
||||||
|
position: 0,
|
||||||
|
})
|
||||||
|
|
||||||
|
const createFeed = (id: number, unread: number): Subscription => ({
|
||||||
|
id,
|
||||||
|
name: String(id),
|
||||||
|
unread,
|
||||||
|
errorCount: 0,
|
||||||
|
position: 0,
|
||||||
|
feedUrl: "",
|
||||||
|
feedLink: "",
|
||||||
|
iconUrl: "",
|
||||||
|
pushNotificationsEnabled: true,
|
||||||
|
})
|
||||||
|
|
||||||
|
const root = createCategory("root")
|
||||||
|
|
||||||
|
const catA = createCategory("catA")
|
||||||
|
catA.feeds.push(createFeed(1, 0), createFeed(2, 0), createFeed(3, 1))
|
||||||
|
|
||||||
|
const catB = createCategory("catB")
|
||||||
|
|
||||||
|
const catC = createCategory("catC")
|
||||||
|
catC.feeds.push(createFeed(4, 1))
|
||||||
|
|
||||||
|
root.children.push(catA, catB, catC)
|
||||||
|
|
||||||
|
describe("selectNextUnreadTreeItem", () => {
|
||||||
|
it("selects the next unread category", async () => {
|
||||||
|
const store = configureStore({
|
||||||
|
reducer: reducers,
|
||||||
|
preloadedState: {
|
||||||
|
tree: {
|
||||||
|
rootCategory: root,
|
||||||
|
},
|
||||||
|
entries: {
|
||||||
|
source: {
|
||||||
|
type: "category",
|
||||||
|
id: "catA",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
} as RootState,
|
||||||
|
})
|
||||||
|
|
||||||
|
await store.dispatch(selectNextUnreadTreeItem({ direction: "forward" }))
|
||||||
|
expect(store.getState().redirect.to).toBe("/app/category/catC")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("selects the previous unread category", async () => {
|
||||||
|
const store = configureStore({
|
||||||
|
reducer: reducers,
|
||||||
|
preloadedState: {
|
||||||
|
tree: {
|
||||||
|
rootCategory: root,
|
||||||
|
},
|
||||||
|
entries: {
|
||||||
|
source: {
|
||||||
|
type: "category",
|
||||||
|
id: "catC",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
} as RootState,
|
||||||
|
})
|
||||||
|
|
||||||
|
await store.dispatch(selectNextUnreadTreeItem({ direction: "backward" }))
|
||||||
|
expect(store.getState().redirect.to).toBe("/app/category/catA")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("selects the next unread feed", async () => {
|
||||||
|
const store = configureStore({
|
||||||
|
reducer: reducers,
|
||||||
|
preloadedState: {
|
||||||
|
tree: {
|
||||||
|
rootCategory: root,
|
||||||
|
},
|
||||||
|
entries: {
|
||||||
|
source: {
|
||||||
|
type: "feed",
|
||||||
|
id: "1",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
} as RootState,
|
||||||
|
})
|
||||||
|
|
||||||
|
await store.dispatch(selectNextUnreadTreeItem({ direction: "forward" }))
|
||||||
|
expect(store.getState().redirect.to).toBe("/app/feed/3")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("selects the previous unread feed", async () => {
|
||||||
|
const store = configureStore({
|
||||||
|
reducer: reducers,
|
||||||
|
preloadedState: {
|
||||||
|
tree: {
|
||||||
|
rootCategory: root,
|
||||||
|
},
|
||||||
|
entries: {
|
||||||
|
source: {
|
||||||
|
type: "feed",
|
||||||
|
id: "4",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
} as RootState,
|
||||||
|
})
|
||||||
|
|
||||||
|
await store.dispatch(selectNextUnreadTreeItem({ direction: "backward" }))
|
||||||
|
expect(store.getState().redirect.to).toBe("/app/feed/3")
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("hasNewEntries", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.resetAllMocks()
|
||||||
|
})
|
||||||
|
|
||||||
|
it("sets and clear flag for a feed", async () => {
|
||||||
|
vi.mocked(client.feed.getEntries).mockResolvedValue({
|
||||||
|
data: {
|
||||||
|
entries: [{ id: "3" } as Entry],
|
||||||
|
hasMore: false,
|
||||||
|
name: "my-feed",
|
||||||
|
errorCount: 3,
|
||||||
|
feedLink: "https://mysite.com/feed",
|
||||||
|
timestamp: 123,
|
||||||
|
ignoredReadStatus: false,
|
||||||
|
},
|
||||||
|
} as AxiosResponse<Entries>)
|
||||||
|
|
||||||
|
const store = configureStore({
|
||||||
|
reducer: reducers,
|
||||||
|
preloadedState: {
|
||||||
|
tree: {
|
||||||
|
rootCategory: root,
|
||||||
|
},
|
||||||
|
entries: {
|
||||||
|
source: {
|
||||||
|
type: "feed",
|
||||||
|
id: "1",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
} as RootState,
|
||||||
|
})
|
||||||
|
|
||||||
|
// initial state
|
||||||
|
expect(store.getState().tree.rootCategory?.children[0].feeds[0].unread).toBe(0)
|
||||||
|
expect(store.getState().tree.rootCategory?.children[0].feeds[0].hasNewEntries).toBeFalsy()
|
||||||
|
|
||||||
|
// increments unread count and sets hasNewEntries to true
|
||||||
|
await store.dispatch(newFeedEntriesDiscovered({ feedId: 1, amount: 3 }))
|
||||||
|
expect(store.getState().tree.rootCategory?.children[0].feeds[0].unread).toBe(3)
|
||||||
|
expect(store.getState().tree.rootCategory?.children[0].feeds[0].hasNewEntries).toBe(true)
|
||||||
|
|
||||||
|
// reload entries and sets hasNewEntries to false
|
||||||
|
await store.dispatch(loadEntries({ source: { type: "feed", id: "1" }, clearSearch: true }))
|
||||||
|
expect(store.getState().tree.rootCategory?.children[0].feeds[0].hasNewEntries).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -28,6 +28,9 @@ export interface Subscription {
|
|||||||
position: number
|
position: number
|
||||||
newestItemTime?: number
|
newestItemTime?: number
|
||||||
filter?: string
|
filter?: string
|
||||||
|
filterLegacy?: string
|
||||||
|
pushNotificationsEnabled: boolean
|
||||||
|
autoMarkAsReadAfterDays?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Category {
|
export interface Category {
|
||||||
@@ -109,6 +112,8 @@ export interface FeedModificationRequest {
|
|||||||
categoryId?: string
|
categoryId?: string
|
||||||
position?: number
|
position?: number
|
||||||
filter?: string
|
filter?: string
|
||||||
|
pushNotificationsEnabled: boolean
|
||||||
|
autoMarkAsReadAfterDays?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface GetEntriesRequest {
|
export interface GetEntriesRequest {
|
||||||
@@ -196,6 +201,12 @@ export interface PasswordResetRequest {
|
|||||||
email: string
|
email: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface PasswordResetConfirmationRequest {
|
||||||
|
email: string
|
||||||
|
token: string
|
||||||
|
password: string
|
||||||
|
}
|
||||||
|
|
||||||
export interface ProfileModificationRequest {
|
export interface ProfileModificationRequest {
|
||||||
currentPassword: string
|
currentPassword: string
|
||||||
email: string
|
email: string
|
||||||
@@ -209,18 +220,27 @@ export interface RegistrationRequest {
|
|||||||
email: string
|
email: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface InitialSetupRequest {
|
||||||
|
name: string
|
||||||
|
password: string
|
||||||
|
email?: string
|
||||||
|
}
|
||||||
|
|
||||||
export interface ServerInfo {
|
export interface ServerInfo {
|
||||||
announcement?: string
|
announcement?: string
|
||||||
version: string
|
version: string
|
||||||
gitCommit: string
|
gitCommit: string
|
||||||
allowRegistrations: boolean
|
allowRegistrations: boolean
|
||||||
googleAnalyticsCode?: string
|
emailAddressRequired: boolean
|
||||||
smtpEnabled: boolean
|
smtpEnabled: boolean
|
||||||
demoAccountEnabled: boolean
|
demoAccountEnabled: boolean
|
||||||
websocketEnabled: boolean
|
websocketEnabled: boolean
|
||||||
websocketPingInterval: number
|
websocketPingInterval: number
|
||||||
treeReloadInterval: number
|
treeReloadInterval: number
|
||||||
forceRefreshCooldownDuration: number
|
forceRefreshCooldownDuration: number
|
||||||
|
initialSetupRequired: boolean
|
||||||
|
minimumPasswordLength: number
|
||||||
|
pushNotificationsEnabled: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SharingSettings {
|
export interface SharingSettings {
|
||||||
@@ -229,13 +249,22 @@ export interface SharingSettings {
|
|||||||
facebook: boolean
|
facebook: boolean
|
||||||
twitter: boolean
|
twitter: boolean
|
||||||
tumblr: boolean
|
tumblr: boolean
|
||||||
pocket: boolean
|
|
||||||
instapaper: boolean
|
instapaper: boolean
|
||||||
buffer: boolean
|
buffer: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type PushNotificationType = "ntfy" | "gotify" | "pushover"
|
||||||
|
|
||||||
|
export interface PushNotificationSettings {
|
||||||
|
type?: PushNotificationType
|
||||||
|
serverUrl?: string
|
||||||
|
userId?: string
|
||||||
|
userSecret?: string
|
||||||
|
topic?: string
|
||||||
|
}
|
||||||
|
|
||||||
export interface Settings {
|
export interface Settings {
|
||||||
language: string
|
language?: string
|
||||||
readingMode: ReadingMode
|
readingMode: ReadingMode
|
||||||
readingOrder: ReadingOrder
|
readingOrder: ReadingOrder
|
||||||
showRead: boolean
|
showRead: boolean
|
||||||
@@ -248,22 +277,26 @@ export interface Settings {
|
|||||||
starIconDisplayMode: IconDisplayMode
|
starIconDisplayMode: IconDisplayMode
|
||||||
externalLinkIconDisplayMode: IconDisplayMode
|
externalLinkIconDisplayMode: IconDisplayMode
|
||||||
markAllAsReadConfirmation: boolean
|
markAllAsReadConfirmation: boolean
|
||||||
|
markAllAsReadNavigateToNextUnread: boolean
|
||||||
customContextMenu: boolean
|
customContextMenu: boolean
|
||||||
mobileFooter: boolean
|
mobileFooter: boolean
|
||||||
unreadCountTitle: boolean
|
unreadCountTitle: boolean
|
||||||
unreadCountFavicon: boolean
|
unreadCountFavicon: boolean
|
||||||
|
disablePullToRefresh: boolean
|
||||||
|
primaryColor?: string
|
||||||
sharingSettings: SharingSettings
|
sharingSettings: SharingSettings
|
||||||
|
pushNotificationSettings: PushNotificationSettings
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface LocalSettings {
|
export interface LocalSettings {
|
||||||
viewMode: ViewMode
|
viewMode: ViewMode
|
||||||
sidebarWidth: number
|
sidebarWidth: number
|
||||||
announcementHash: string
|
announcementHash: string
|
||||||
|
fontSizePercentage: number
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface StarRequest {
|
export interface StarRequest {
|
||||||
id: string
|
id: string
|
||||||
feedId: number
|
|
||||||
starred: boolean
|
starred: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -304,3 +337,10 @@ export interface AuthenticationError {
|
|||||||
message: string
|
message: string
|
||||||
allowRegistrations: boolean
|
allowRegistrations: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type CommaFeedExceptionType = "WRONG_USERNAME_OR_PASSWORD"
|
||||||
|
|
||||||
|
export interface CommaFeedApplicationError {
|
||||||
|
type: CommaFeedExceptionType
|
||||||
|
message: string
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,28 +1,8 @@
|
|||||||
import { t } from "@lingui/core/macro"
|
import { t } from "@lingui/core/macro"
|
||||||
import { showNotification } from "@mantine/notifications"
|
import { showNotification } from "@mantine/notifications"
|
||||||
import { type PayloadAction, createSlice, isAnyOf } from "@reduxjs/toolkit"
|
import { createSlice, type PayloadAction } from "@reduxjs/toolkit"
|
||||||
import type { LocalSettings, Settings, UserModel, ViewMode } from "app/types"
|
import type { LocalSettings, Settings, UserModel, ViewMode } from "@/app/types"
|
||||||
import {
|
import { changeSettings, reloadProfile, reloadSettings, reloadTags } from "./thunks"
|
||||||
changeCustomContextMenu,
|
|
||||||
changeEntriesToKeepOnTopWhenScrolling,
|
|
||||||
changeExternalLinkIconDisplayMode,
|
|
||||||
changeLanguage,
|
|
||||||
changeMarkAllAsReadConfirmation,
|
|
||||||
changeMobileFooter,
|
|
||||||
changeReadingMode,
|
|
||||||
changeReadingOrder,
|
|
||||||
changeScrollMarks,
|
|
||||||
changeScrollMode,
|
|
||||||
changeScrollSpeed,
|
|
||||||
changeSharingSetting,
|
|
||||||
changeShowRead,
|
|
||||||
changeStarIconDisplayMode,
|
|
||||||
changeUnreadCountFavicon,
|
|
||||||
changeUnreadCountTitle,
|
|
||||||
reloadProfile,
|
|
||||||
reloadSettings,
|
|
||||||
reloadTags,
|
|
||||||
} from "./thunks"
|
|
||||||
|
|
||||||
interface UserState {
|
interface UserState {
|
||||||
settings?: Settings
|
settings?: Settings
|
||||||
@@ -35,6 +15,7 @@ export const initialLocalSettings: LocalSettings = {
|
|||||||
viewMode: "detailed",
|
viewMode: "detailed",
|
||||||
sidebarWidth: 360,
|
sidebarWidth: 360,
|
||||||
announcementHash: "no-hash",
|
announcementHash: "no-hash",
|
||||||
|
fontSizePercentage: 100,
|
||||||
}
|
}
|
||||||
|
|
||||||
const initialState: UserState = {
|
const initialState: UserState = {
|
||||||
@@ -48,6 +29,9 @@ export const userSlice = createSlice({
|
|||||||
setViewMode: (state, action: PayloadAction<ViewMode>) => {
|
setViewMode: (state, action: PayloadAction<ViewMode>) => {
|
||||||
state.localSettings.viewMode = action.payload
|
state.localSettings.viewMode = action.payload
|
||||||
},
|
},
|
||||||
|
setFontSizePercentage: (state, action: PayloadAction<number>) => {
|
||||||
|
state.localSettings.fontSizePercentage = action.payload
|
||||||
|
},
|
||||||
setSidebarWidth: (state, action: PayloadAction<number>) => {
|
setSidebarWidth: (state, action: PayloadAction<number>) => {
|
||||||
state.localSettings.sidebarWidth = action.payload
|
state.localSettings.sidebarWidth = action.payload
|
||||||
},
|
},
|
||||||
@@ -65,95 +49,17 @@ export const userSlice = createSlice({
|
|||||||
builder.addCase(reloadTags.fulfilled, (state, action) => {
|
builder.addCase(reloadTags.fulfilled, (state, action) => {
|
||||||
state.tags = action.payload
|
state.tags = action.payload
|
||||||
})
|
})
|
||||||
builder.addCase(changeReadingMode.pending, (state, action) => {
|
builder.addCase(changeSettings.pending, (state, action) => {
|
||||||
if (!state.settings) return
|
if (!state.settings) return
|
||||||
state.settings.readingMode = action.meta.arg
|
state.settings = { ...state.settings, ...action.meta.arg }
|
||||||
})
|
})
|
||||||
builder.addCase(changeReadingOrder.pending, (state, action) => {
|
builder.addCase(changeSettings.fulfilled, () => {
|
||||||
if (!state.settings) return
|
showNotification({
|
||||||
state.settings.readingOrder = action.meta.arg
|
message: t`Settings saved.`,
|
||||||
|
color: "green",
|
||||||
|
})
|
||||||
})
|
})
|
||||||
builder.addCase(changeLanguage.pending, (state, action) => {
|
|
||||||
if (!state.settings) return
|
|
||||||
state.settings.language = action.meta.arg
|
|
||||||
})
|
|
||||||
builder.addCase(changeScrollSpeed.pending, (state, action) => {
|
|
||||||
if (!state.settings) return
|
|
||||||
state.settings.scrollSpeed = action.meta.arg ? 400 : 0
|
|
||||||
})
|
|
||||||
builder.addCase(changeShowRead.pending, (state, action) => {
|
|
||||||
if (!state.settings) return
|
|
||||||
state.settings.showRead = action.meta.arg
|
|
||||||
})
|
|
||||||
builder.addCase(changeScrollMarks.pending, (state, action) => {
|
|
||||||
if (!state.settings) return
|
|
||||||
state.settings.scrollMarks = action.meta.arg
|
|
||||||
})
|
|
||||||
builder.addCase(changeScrollMode.pending, (state, action) => {
|
|
||||||
if (!state.settings) return
|
|
||||||
state.settings.scrollMode = action.meta.arg
|
|
||||||
})
|
|
||||||
builder.addCase(changeEntriesToKeepOnTopWhenScrolling.pending, (state, action) => {
|
|
||||||
if (!state.settings) return
|
|
||||||
state.settings.entriesToKeepOnTopWhenScrolling = action.meta.arg
|
|
||||||
})
|
|
||||||
builder.addCase(changeStarIconDisplayMode.pending, (state, action) => {
|
|
||||||
if (!state.settings) return
|
|
||||||
state.settings.starIconDisplayMode = action.meta.arg
|
|
||||||
})
|
|
||||||
builder.addCase(changeExternalLinkIconDisplayMode.pending, (state, action) => {
|
|
||||||
if (!state.settings) return
|
|
||||||
state.settings.externalLinkIconDisplayMode = action.meta.arg
|
|
||||||
})
|
|
||||||
builder.addCase(changeMarkAllAsReadConfirmation.pending, (state, action) => {
|
|
||||||
if (!state.settings) return
|
|
||||||
state.settings.markAllAsReadConfirmation = action.meta.arg
|
|
||||||
})
|
|
||||||
builder.addCase(changeCustomContextMenu.pending, (state, action) => {
|
|
||||||
if (!state.settings) return
|
|
||||||
state.settings.customContextMenu = action.meta.arg
|
|
||||||
})
|
|
||||||
builder.addCase(changeMobileFooter.pending, (state, action) => {
|
|
||||||
if (!state.settings) return
|
|
||||||
state.settings.mobileFooter = action.meta.arg
|
|
||||||
})
|
|
||||||
builder.addCase(changeUnreadCountTitle.pending, (state, action) => {
|
|
||||||
if (!state.settings) return
|
|
||||||
state.settings.unreadCountTitle = action.meta.arg
|
|
||||||
})
|
|
||||||
builder.addCase(changeUnreadCountFavicon.pending, (state, action) => {
|
|
||||||
if (!state.settings) return
|
|
||||||
state.settings.unreadCountFavicon = action.meta.arg
|
|
||||||
})
|
|
||||||
builder.addCase(changeSharingSetting.pending, (state, action) => {
|
|
||||||
if (!state.settings) return
|
|
||||||
state.settings.sharingSettings[action.meta.arg.site] = action.meta.arg.value
|
|
||||||
})
|
|
||||||
builder.addMatcher(
|
|
||||||
isAnyOf(
|
|
||||||
changeLanguage.fulfilled,
|
|
||||||
changeScrollSpeed.fulfilled,
|
|
||||||
changeShowRead.fulfilled,
|
|
||||||
changeScrollMarks.fulfilled,
|
|
||||||
changeScrollMode.fulfilled,
|
|
||||||
changeEntriesToKeepOnTopWhenScrolling.fulfilled,
|
|
||||||
changeStarIconDisplayMode.fulfilled,
|
|
||||||
changeExternalLinkIconDisplayMode.fulfilled,
|
|
||||||
changeMarkAllAsReadConfirmation.fulfilled,
|
|
||||||
changeCustomContextMenu.fulfilled,
|
|
||||||
changeMobileFooter.fulfilled,
|
|
||||||
changeUnreadCountTitle.fulfilled,
|
|
||||||
changeUnreadCountFavicon.fulfilled,
|
|
||||||
changeSharingSetting.fulfilled
|
|
||||||
),
|
|
||||||
() => {
|
|
||||||
showNotification({
|
|
||||||
message: t`Settings saved.`,
|
|
||||||
color: "green",
|
|
||||||
})
|
|
||||||
}
|
|
||||||
)
|
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
export const { setViewMode, setSidebarWidth, setAnnouncementHash } = userSlice.actions
|
export const { setViewMode, setSidebarWidth, setAnnouncementHash, setFontSizePercentage } = userSlice.actions
|
||||||
|
|||||||
@@ -1,135 +1,27 @@
|
|||||||
import { createAppAsyncThunk } from "app/async-thunk"
|
import { createAppAsyncThunk } from "@/app/async-thunk"
|
||||||
import { client } from "app/client"
|
import { client } from "@/app/client"
|
||||||
import { reloadEntries } from "app/entries/thunks"
|
import type { Settings } from "@/app/types"
|
||||||
import type { IconDisplayMode, ReadingMode, ReadingOrder, ScrollMode, SharingSettings } from "app/types"
|
|
||||||
|
|
||||||
export const reloadSettings = createAppAsyncThunk("settings/reload", async () => await client.user.getSettings().then(r => r.data))
|
export const reloadSettings = createAppAsyncThunk(
|
||||||
|
"settings/reload",
|
||||||
export const reloadProfile = createAppAsyncThunk("profile/reload", async () => await client.user.getProfile().then(r => r.data))
|
async () => await client.user.getSettings().then(r => r.data)
|
||||||
|
|
||||||
export const reloadTags = createAppAsyncThunk("entries/tags", async () => await client.entry.getTags().then(r => r.data))
|
|
||||||
|
|
||||||
export const changeReadingMode = createAppAsyncThunk("settings/readingMode", (readingMode: ReadingMode, thunkApi) => {
|
|
||||||
const { settings } = thunkApi.getState().user
|
|
||||||
if (!settings) return
|
|
||||||
client.user.saveSettings({ ...settings, readingMode })
|
|
||||||
thunkApi.dispatch(reloadEntries())
|
|
||||||
})
|
|
||||||
|
|
||||||
export const changeReadingOrder = createAppAsyncThunk("settings/readingOrder", (readingOrder: ReadingOrder, thunkApi) => {
|
|
||||||
const { settings } = thunkApi.getState().user
|
|
||||||
if (!settings) return
|
|
||||||
client.user.saveSettings({ ...settings, readingOrder })
|
|
||||||
thunkApi.dispatch(reloadEntries())
|
|
||||||
})
|
|
||||||
|
|
||||||
export const changeLanguage = createAppAsyncThunk("settings/language", (language: string, thunkApi) => {
|
|
||||||
const { settings } = thunkApi.getState().user
|
|
||||||
if (!settings) return
|
|
||||||
client.user.saveSettings({ ...settings, language })
|
|
||||||
})
|
|
||||||
|
|
||||||
export const changeScrollSpeed = createAppAsyncThunk("settings/scrollSpeed", (speed: boolean, thunkApi) => {
|
|
||||||
const { settings } = thunkApi.getState().user
|
|
||||||
if (!settings) return
|
|
||||||
client.user.saveSettings({ ...settings, scrollSpeed: speed ? 400 : 0 })
|
|
||||||
})
|
|
||||||
|
|
||||||
export const changeShowRead = createAppAsyncThunk("settings/showRead", (showRead: boolean, thunkApi) => {
|
|
||||||
const { settings } = thunkApi.getState().user
|
|
||||||
if (!settings) return
|
|
||||||
client.user.saveSettings({ ...settings, showRead })
|
|
||||||
})
|
|
||||||
|
|
||||||
export const changeScrollMarks = createAppAsyncThunk("settings/scrollMarks", (scrollMarks: boolean, thunkApi) => {
|
|
||||||
const { settings } = thunkApi.getState().user
|
|
||||||
if (!settings) return
|
|
||||||
client.user.saveSettings({ ...settings, scrollMarks })
|
|
||||||
})
|
|
||||||
|
|
||||||
export const changeScrollMode = createAppAsyncThunk("settings/scrollMode", (scrollMode: ScrollMode, thunkApi) => {
|
|
||||||
const { settings } = thunkApi.getState().user
|
|
||||||
if (!settings) return
|
|
||||||
client.user.saveSettings({ ...settings, scrollMode })
|
|
||||||
})
|
|
||||||
|
|
||||||
export const changeEntriesToKeepOnTopWhenScrolling = createAppAsyncThunk(
|
|
||||||
"settings/entriesToKeepOnTopWhenScrolling",
|
|
||||||
(entriesToKeepOnTopWhenScrolling: number, thunkApi) => {
|
|
||||||
const { settings } = thunkApi.getState().user
|
|
||||||
if (!settings) return
|
|
||||||
client.user.saveSettings({ ...settings, entriesToKeepOnTopWhenScrolling })
|
|
||||||
}
|
|
||||||
)
|
)
|
||||||
|
|
||||||
export const changeStarIconDisplayMode = createAppAsyncThunk(
|
export const reloadProfile = createAppAsyncThunk(
|
||||||
"settings/starIconDisplayMode",
|
"profile/reload",
|
||||||
(starIconDisplayMode: IconDisplayMode, thunkApi) => {
|
async () => await client.user.getProfile().then(r => r.data)
|
||||||
const { settings } = thunkApi.getState().user
|
|
||||||
if (!settings) return
|
|
||||||
client.user.saveSettings({ ...settings, starIconDisplayMode })
|
|
||||||
}
|
|
||||||
)
|
)
|
||||||
|
|
||||||
export const changeExternalLinkIconDisplayMode = createAppAsyncThunk(
|
export const reloadTags = createAppAsyncThunk(
|
||||||
"settings/externalLinkIconDisplayMode",
|
"entries/tags",
|
||||||
(externalLinkIconDisplayMode: IconDisplayMode, thunkApi) => {
|
async () => await client.entry.getTags().then(r => r.data)
|
||||||
const { settings } = thunkApi.getState().user
|
|
||||||
if (!settings) return
|
|
||||||
client.user.saveSettings({ ...settings, externalLinkIconDisplayMode })
|
|
||||||
}
|
|
||||||
)
|
)
|
||||||
|
|
||||||
export const changeMarkAllAsReadConfirmation = createAppAsyncThunk(
|
export const changeSettings = createAppAsyncThunk(
|
||||||
"settings/markAllAsReadConfirmation",
|
"settings/change",
|
||||||
(markAllAsReadConfirmation: boolean, thunkApi) => {
|
async (newSettings: Partial<Settings>, thunkApi) => {
|
||||||
const { settings } = thunkApi.getState().user
|
const { settings } = thunkApi.getState().user
|
||||||
if (!settings) return
|
if (!settings) return
|
||||||
client.user.saveSettings({ ...settings, markAllAsReadConfirmation })
|
await client.user.saveSettings({ ...settings, ...newSettings })
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
export const changeCustomContextMenu = createAppAsyncThunk("settings/customContextMenu", (customContextMenu: boolean, thunkApi) => {
|
|
||||||
const { settings } = thunkApi.getState().user
|
|
||||||
if (!settings) return
|
|
||||||
client.user.saveSettings({ ...settings, customContextMenu })
|
|
||||||
})
|
|
||||||
|
|
||||||
export const changeMobileFooter = createAppAsyncThunk("settings/mobileFooter", (mobileFooter: boolean, thunkApi) => {
|
|
||||||
const { settings } = thunkApi.getState().user
|
|
||||||
if (!settings) return
|
|
||||||
client.user.saveSettings({ ...settings, mobileFooter })
|
|
||||||
})
|
|
||||||
|
|
||||||
export const changeUnreadCountTitle = createAppAsyncThunk("settings/unreadCountTitle", (unreadCountTitle: boolean, thunkApi) => {
|
|
||||||
const { settings } = thunkApi.getState().user
|
|
||||||
if (!settings) return
|
|
||||||
client.user.saveSettings({ ...settings, unreadCountTitle })
|
|
||||||
})
|
|
||||||
|
|
||||||
export const changeUnreadCountFavicon = createAppAsyncThunk("settings/unreadCountFavicon", (unreadCountFavicon: boolean, thunkApi) => {
|
|
||||||
const { settings } = thunkApi.getState().user
|
|
||||||
if (!settings) return
|
|
||||||
client.user.saveSettings({ ...settings, unreadCountFavicon })
|
|
||||||
})
|
|
||||||
|
|
||||||
export const changeSharingSetting = createAppAsyncThunk(
|
|
||||||
"settings/sharingSetting",
|
|
||||||
(
|
|
||||||
sharingSetting: {
|
|
||||||
site: keyof SharingSettings
|
|
||||||
value: boolean
|
|
||||||
},
|
|
||||||
thunkApi
|
|
||||||
) => {
|
|
||||||
const { settings } = thunkApi.getState().user
|
|
||||||
if (!settings) return
|
|
||||||
client.user.saveSettings({
|
|
||||||
...settings,
|
|
||||||
sharingSettings: {
|
|
||||||
...settings.sharingSettings,
|
|
||||||
[sharingSetting.site]: sharingSetting.value,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,20 +1,32 @@
|
|||||||
import { throttle } from "throttle-debounce"
|
import { throttle } from "throttle-debounce"
|
||||||
|
import type { TreeCategory } from "@/app/tree/slice"
|
||||||
import type { Category } from "./types"
|
import type { Category } from "./types"
|
||||||
|
|
||||||
export function visitCategoryTree(category: Category, visitor: (category: Category) => void): void {
|
export function visitCategoryTree(
|
||||||
visitor(category)
|
category: TreeCategory,
|
||||||
for (const child of category.children) {
|
visitor: (category: TreeCategory) => void,
|
||||||
visitCategoryTree(child, visitor)
|
options?: {
|
||||||
|
childrenFirst?: boolean
|
||||||
}
|
}
|
||||||
|
): void {
|
||||||
|
const childrenFirst = options?.childrenFirst
|
||||||
|
|
||||||
|
if (!childrenFirst) visitor(category)
|
||||||
|
|
||||||
|
for (const child of category.children) {
|
||||||
|
visitCategoryTree(child, visitor, options)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (childrenFirst) visitor(category)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function flattenCategoryTree(category: Category): Category[] {
|
export function flattenCategoryTree(category: TreeCategory): TreeCategory[] {
|
||||||
const categories: Category[] = []
|
const categories: Category[] = []
|
||||||
visitCategoryTree(category, c => categories.push(c))
|
visitCategoryTree(category, c => categories.push(c))
|
||||||
return categories
|
return categories
|
||||||
}
|
}
|
||||||
|
|
||||||
export function categoryUnreadCount(category?: Category): number {
|
export function categoryUnreadCount(category?: TreeCategory): number {
|
||||||
if (!category) return 0
|
if (!category) return 0
|
||||||
|
|
||||||
return flattenCategoryTree(category)
|
return flattenCategoryTree(category)
|
||||||
@@ -23,13 +35,35 @@ export function categoryUnreadCount(category?: Category): number {
|
|||||||
.reduce((total, current) => total + current, 0)
|
.reduce((total, current) => total + current, 0)
|
||||||
}
|
}
|
||||||
|
|
||||||
export const calculatePlaceholderSize = ({ width, height, maxWidth }: { width?: number; height?: number; maxWidth: number }) => {
|
export function categoryHasNewEntries(category?: TreeCategory): boolean {
|
||||||
|
if (!category) return false
|
||||||
|
|
||||||
|
return flattenCategoryTree(category)
|
||||||
|
.flatMap(c => c.feeds)
|
||||||
|
.some(f => f.hasNewEntries)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const calculatePlaceholderSize = ({
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
maxWidth,
|
||||||
|
}: {
|
||||||
|
width?: number
|
||||||
|
height?: number
|
||||||
|
maxWidth: number
|
||||||
|
}) => {
|
||||||
const placeholderWidth = width && Math.min(width, maxWidth)
|
const placeholderWidth = width && Math.min(width, maxWidth)
|
||||||
const placeholderHeight = height && width && width > maxWidth ? height * (maxWidth / width) : height
|
const placeholderHeight = height && width && width > maxWidth ? height * (maxWidth / width) : height
|
||||||
return { width: placeholderWidth, height: placeholderHeight }
|
return { width: placeholderWidth, height: placeholderHeight }
|
||||||
}
|
}
|
||||||
|
|
||||||
export const scrollToWithCallback = ({ options, onScrollEnded }: { options: ScrollToOptions; onScrollEnded: () => void }) => {
|
export const scrollToWithCallback = ({
|
||||||
|
options,
|
||||||
|
onScrollEnded,
|
||||||
|
}: {
|
||||||
|
options: ScrollToOptions
|
||||||
|
onScrollEnded: () => void
|
||||||
|
}) => {
|
||||||
const offset = (options.top ?? 0).toFixed()
|
const offset = (options.top ?? 0).toFixed()
|
||||||
|
|
||||||
const onScroll = throttle(100, () => {
|
const onScroll = throttle(100, () => {
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||||
<svg height="512" width="512" viewBox="0 0 6.5625 6.5625" xmlns="http://www.w3.org/2000/svg">
|
<svg height="512" width="512" viewBox="0 0 6.5625 6.5625" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<title>CommaFeed logo</title>
|
||||||
<rect fill="#f88a14" rx="0.7" ry="0.7" height="6.5625" width="6.5625" />
|
<rect fill="#f88a14" rx="0.7" ry="0.7" height="6.5625" width="6.5625" />
|
||||||
<path d="m1.9761,1.5289c2.9002,0,2.9002,2.9101,2.9002,2.9101" fill="none" stroke="#FFF" stroke-linecap="round"
|
<path d="m1.9761,1.5289c2.9002,0,2.9002,2.9101,2.9002,2.9101" fill="none" stroke="#FFF" stroke-linecap="round"
|
||||||
stroke-width="0.78125" />
|
stroke-width="0.78125" />
|
||||||
<path d="m1.9688,2.875c1.5705-0.00908,1.5705,1.5639,1.5705,1.5639" fill="none" stroke="#FFF" stroke-linecap="round"
|
<path d="m1.9688,2.875c1.5705-0.00908,1.5705,1.5639,1.5705,1.5639" fill="none" stroke="#FFF" stroke-linecap="round"
|
||||||
stroke-width="0.78125" />
|
stroke-width="0.78125" />
|
||||||
<path d="m2.6503,4.4062c0,0.23366-0.10712,0.47418-0.24663,0.6537-0.1814,0.2333-0.5705,0.5618-0.6913,0.5653,0.0402-0.0662,0.263-0.5654,0.2563-0.5654-0.36423,0-0.6595-0.29265-0.6595-0.65365s0.29527-0.65365,0.6595-0.65365,0.68159,0.29265,0.68159,0.65365z"
|
<path d="m2.6503,4.4062c0,0.23366-0.10712,0.47418-0.24663,0.6537-0.1814,0.2333-0.5705,0.5618-0.6913,0.5653,0.0402-0.0662,0.263-0.5654,0.2563-0.5654-0.36423,0-0.6595-0.29265-0.6595-0.65365s0.29527-0.65365,0.6595-0.65365,0.68159,0.29265,0.68159,0.65365z"
|
||||||
fill="#FFF" />
|
fill="#FFF" />
|
||||||
</svg>
|
</svg>
|
||||||
|
Before Width: | Height: | Size: 791 B After Width: | Height: | Size: 831 B |
53
commafeed-client/src/components/ActionButton.test.tsx
Normal file
53
commafeed-client/src/components/ActionButton.test.tsx
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
import type { I18nContext } from "@lingui/react"
|
||||||
|
import { MantineProvider } from "@mantine/core"
|
||||||
|
import { fireEvent, render, screen, waitFor } from "@testing-library/react"
|
||||||
|
import { describe, expect, it, vi } from "vitest"
|
||||||
|
import { useActionButton } from "@/hooks/useActionButton"
|
||||||
|
import { ActionButton } from "./ActionButton"
|
||||||
|
|
||||||
|
vi.mock(import("@lingui/react"), () => ({
|
||||||
|
useLingui: vi.fn().mockReturnValue({
|
||||||
|
_: msg => msg,
|
||||||
|
} as I18nContext),
|
||||||
|
}))
|
||||||
|
vi.mock(import("@/hooks/useActionButton"))
|
||||||
|
|
||||||
|
const label = "Test Label"
|
||||||
|
const icon = "Test Icon"
|
||||||
|
describe("ActionButton", () => {
|
||||||
|
it("renders Button with label on desktop", () => {
|
||||||
|
vi.mocked(useActionButton).mockReturnValue({ mobile: false, spacing: 0 })
|
||||||
|
|
||||||
|
render(<ActionButton label={label} icon={icon} />, {
|
||||||
|
wrapper: MantineProvider,
|
||||||
|
})
|
||||||
|
expect(screen.getByText(label)).toBeInTheDocument()
|
||||||
|
expect(screen.getByText(icon)).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it("renders ActionIcon with tooltip on mobile", async () => {
|
||||||
|
vi.mocked(useActionButton).mockReturnValue({ mobile: true, spacing: 0 })
|
||||||
|
|
||||||
|
render(<ActionButton label={label} icon={icon} />, {
|
||||||
|
wrapper: MantineProvider,
|
||||||
|
})
|
||||||
|
expect(screen.queryByText(label)).not.toBeInTheDocument()
|
||||||
|
expect(screen.getByText(icon)).toBeInTheDocument()
|
||||||
|
|
||||||
|
fireEvent.mouseEnter(screen.getByRole("button"))
|
||||||
|
const tooltip = await waitFor(() => screen.getByRole("tooltip"))
|
||||||
|
expect(tooltip).toContainHTML(label)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("calls onClick handler when clicked", () => {
|
||||||
|
vi.mocked(useActionButton).mockReturnValue({ mobile: false, spacing: 0 })
|
||||||
|
const clickListener = vi.fn()
|
||||||
|
|
||||||
|
render(<ActionButton label={label} icon={icon} onClick={clickListener} />, {
|
||||||
|
wrapper: MantineProvider,
|
||||||
|
})
|
||||||
|
fireEvent.click(screen.getByRole("button"))
|
||||||
|
|
||||||
|
expect(clickListener).toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -1,57 +1,72 @@
|
|||||||
import type { MessageDescriptor } from "@lingui/core"
|
import type { MessageDescriptor } from "@lingui/core"
|
||||||
import { useLingui } from "@lingui/react"
|
import { useLingui } from "@lingui/react"
|
||||||
import { ActionIcon, Button, type ButtonVariant, Tooltip, useMantineTheme } from "@mantine/core"
|
import {
|
||||||
import type { ActionIconVariant } from "@mantine/core/lib/components/ActionIcon/ActionIcon"
|
ActionIcon,
|
||||||
import { Constants } from "app/constants"
|
type ActionIconVariant,
|
||||||
import { useActionButton } from "hooks/useActionButton"
|
Box,
|
||||||
import { type MouseEventHandler, type ReactNode, forwardRef } from "react"
|
Button,
|
||||||
|
type ButtonVariant,
|
||||||
|
Tooltip,
|
||||||
|
useMantineTheme,
|
||||||
|
} from "@mantine/core"
|
||||||
|
import { forwardRef, type MouseEventHandler, type ReactNode } from "react"
|
||||||
|
import { Constants } from "@/app/constants"
|
||||||
|
import { useActionButton } from "@/hooks/useActionButton"
|
||||||
|
|
||||||
|
export type Mode = "auto" | "mobile" | "desktop"
|
||||||
|
|
||||||
interface ActionButtonProps {
|
interface ActionButtonProps {
|
||||||
|
icon: ReactNode
|
||||||
className?: string
|
className?: string
|
||||||
icon?: ReactNode
|
|
||||||
label?: string | MessageDescriptor
|
label?: string | MessageDescriptor
|
||||||
onClick?: MouseEventHandler
|
onClick?: MouseEventHandler
|
||||||
variant?: ActionIconVariant & ButtonVariant
|
variant?: ActionIconVariant & ButtonVariant
|
||||||
hideLabelOnDesktop?: boolean
|
mode?: Mode
|
||||||
showLabelOnMobile?: boolean
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Switches between Button with label (desktop) and ActionIcon (mobile)
|
* Switches between Button with label (desktop) and ActionIcon (mobile)
|
||||||
*/
|
*/
|
||||||
export const ActionButton = forwardRef<HTMLButtonElement, ActionButtonProps>((props: ActionButtonProps, ref) => {
|
export const ActionButton = forwardRef<HTMLDivElement, ActionButtonProps>((props: ActionButtonProps, ref) => {
|
||||||
const { mobile } = useActionButton()
|
const { mobile } = useActionButton()
|
||||||
const theme = useMantineTheme()
|
const theme = useMantineTheme()
|
||||||
const { _ } = useLingui()
|
const { _ } = useLingui()
|
||||||
|
|
||||||
const label = typeof props.label === "string" ? props.label : props.label && _(props.label)
|
const label = typeof props.label === "string" ? props.label : props.label && _(props.label)
|
||||||
const variant = props.variant ?? "subtle"
|
const variant = props.variant ?? "subtle"
|
||||||
const iconOnly = (mobile && !props.showLabelOnMobile) || (!mobile && props.hideLabelOnDesktop)
|
|
||||||
return iconOnly ? (
|
const mode: Mode = props.mode ?? "auto"
|
||||||
<Tooltip label={label} openDelay={Constants.tooltip.delay}>
|
const iconOnly = mode === "mobile" || (mode === "auto" && mobile)
|
||||||
<ActionIcon
|
|
||||||
ref={ref}
|
return (
|
||||||
color={theme.primaryColor}
|
<Box ref={ref} className="cf-action-button">
|
||||||
variant={variant}
|
{iconOnly && (
|
||||||
className={props.className}
|
<Tooltip label={label} openDelay={Constants.tooltip.delay}>
|
||||||
onClick={props.onClick}
|
<ActionIcon
|
||||||
aria-label={label}
|
color={theme.primaryColor}
|
||||||
>
|
variant={variant}
|
||||||
{props.icon}
|
className={props.className}
|
||||||
</ActionIcon>
|
onClick={props.onClick}
|
||||||
</Tooltip>
|
aria-label={label}
|
||||||
) : (
|
>
|
||||||
<Button
|
{props.icon}
|
||||||
ref={ref}
|
</ActionIcon>
|
||||||
variant={variant}
|
</Tooltip>
|
||||||
size="xs"
|
)}
|
||||||
className={props.className}
|
{!iconOnly && (
|
||||||
leftSection={props.icon}
|
<Button
|
||||||
onClick={props.onClick}
|
variant={variant}
|
||||||
aria-label={label}
|
size="xs"
|
||||||
>
|
className={props.className}
|
||||||
{label}
|
leftSection={props.icon}
|
||||||
</Button>
|
onClick={props.onClick}
|
||||||
|
aria-label={label}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
ActionButton.displayName = "HeaderButton"
|
ActionButton.displayName = "HeaderButton"
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ export interface ErrorsAlertProps {
|
|||||||
messages: string[]
|
messages: string[]
|
||||||
}
|
}
|
||||||
|
|
||||||
export function Alert(props: ErrorsAlertProps) {
|
export function Alert(props: Readonly<ErrorsAlertProps>) {
|
||||||
let title: React.ReactNode
|
let title: React.ReactNode
|
||||||
let color: string
|
let color: string
|
||||||
let icon: React.ReactNode
|
let icon: React.ReactNode
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import { Trans } from "@lingui/react/macro"
|
import { Trans } from "@lingui/react/macro"
|
||||||
import { Box, Dialog, Text } from "@mantine/core"
|
import { Box, Dialog, Text } from "@mantine/core"
|
||||||
import { useAppDispatch, useAppSelector } from "app/store"
|
|
||||||
import { setAnnouncementHash } from "app/user/slice"
|
|
||||||
import { Content } from "components/content/Content"
|
|
||||||
import { useAsync } from "react-async-hook"
|
import { useAsync } from "react-async-hook"
|
||||||
|
import { useAppDispatch, useAppSelector } from "@/app/store"
|
||||||
|
import { setAnnouncementHash } from "@/app/user/slice"
|
||||||
|
import { Content } from "@/components/content/Content"
|
||||||
|
|
||||||
const sha256Hex = async (input: string | undefined) => {
|
const sha256Hex = async (input: string | undefined) => {
|
||||||
const data = new TextEncoder().encode(input)
|
const data = new TextEncoder().encode(input)
|
||||||
|
|||||||
@@ -1,4 +0,0 @@
|
|||||||
html,
|
|
||||||
body {
|
|
||||||
overscroll-behavior: none;
|
|
||||||
}
|
|
||||||
@@ -1,4 +1,3 @@
|
|||||||
export const DisablePullToRefresh = () => {
|
export const DisablePullToRefresh = ({ enabled }: { enabled: boolean | undefined }) => {
|
||||||
import("./DisablePullToRefresh.css")
|
return enabled ? <style>{`html, body { overscroll-behavior: none; }`}</style> : null
|
||||||
return <></>
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { ErrorPage } from "pages/ErrorPage"
|
|
||||||
import React, { type ReactNode } from "react"
|
import React, { type ReactNode } from "react"
|
||||||
|
import { ErrorPage } from "@/pages/ErrorPage"
|
||||||
|
|
||||||
interface ErrorBoundaryProps {
|
interface ErrorBoundaryProps {
|
||||||
children?: ReactNode
|
children?: ReactNode
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { Box, Center } from "@mantine/core"
|
import { Box, Center } from "@mantine/core"
|
||||||
import { useState } from "react"
|
import { useState } from "react"
|
||||||
import { TbPhoto } from "react-icons/tb"
|
import { TbPhoto } from "react-icons/tb"
|
||||||
import { tss } from "tss"
|
import { tss } from "@/tss"
|
||||||
|
|
||||||
interface ImageWithPlaceholderWhileLoadingProps {
|
interface ImageWithPlaceholderWhileLoadingProps {
|
||||||
src: string
|
src: string
|
||||||
@@ -14,6 +14,7 @@ interface ImageWithPlaceholderWhileLoadingProps {
|
|||||||
placeholderHeight?: number
|
placeholderHeight?: number
|
||||||
placeholderBackgroundColor?: string
|
placeholderBackgroundColor?: string
|
||||||
placeholderIconSize?: number
|
placeholderIconSize?: number
|
||||||
|
onError?: (e: React.SyntheticEvent<HTMLImageElement, Event>) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
const useStyles = tss
|
const useStyles = tss
|
||||||
@@ -44,7 +45,8 @@ export function ImageWithPlaceholderWhileLoading({
|
|||||||
title,
|
title,
|
||||||
width,
|
width,
|
||||||
style,
|
style,
|
||||||
}: ImageWithPlaceholderWhileLoadingProps) {
|
onError,
|
||||||
|
}: Readonly<ImageWithPlaceholderWhileLoadingProps>) {
|
||||||
const { classes } = useStyles({
|
const { classes } = useStyles({
|
||||||
placeholderWidth,
|
placeholderWidth,
|
||||||
placeholderHeight,
|
placeholderHeight,
|
||||||
@@ -70,6 +72,7 @@ export function ImageWithPlaceholderWhileLoading({
|
|||||||
width={width}
|
width={width}
|
||||||
height={height}
|
height={height}
|
||||||
onLoad={() => setLoading(false)}
|
onLoad={() => setLoading(false)}
|
||||||
|
onError={onError}
|
||||||
style={{
|
style={{
|
||||||
...style,
|
...style,
|
||||||
display: loading ? "none" : (style?.display ?? "initial"),
|
display: loading ? "none" : (style?.display ?? "initial"),
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { Trans } from "@lingui/react/macro"
|
import { Trans } from "@lingui/react/macro"
|
||||||
import { Anchor, Box, Kbd, Stack, Table } from "@mantine/core"
|
import { Anchor, Box, Kbd, Stack, Table } from "@mantine/core"
|
||||||
import { useOs } from "@mantine/hooks"
|
import { useOs } from "@mantine/hooks"
|
||||||
import { Constants } from "app/constants"
|
import { Constants } from "@/app/constants"
|
||||||
|
|
||||||
export function KeyboardShortcutsHelp() {
|
export function KeyboardShortcutsHelp() {
|
||||||
const isMacOS = useOs() === "macos"
|
const isMacOS = useOs() === "macos"
|
||||||
@@ -33,6 +33,26 @@ export function KeyboardShortcutsHelp() {
|
|||||||
<Kbd>K</Kbd>
|
<Kbd>K</Kbd>
|
||||||
</Table.Td>
|
</Table.Td>
|
||||||
</Table.Tr>
|
</Table.Tr>
|
||||||
|
<Table.Tr>
|
||||||
|
<Table.Td>
|
||||||
|
<Trans>Select next unread feed/category</Trans>
|
||||||
|
</Table.Td>
|
||||||
|
<Table.Td>
|
||||||
|
<Kbd>Shift</Kbd>
|
||||||
|
<span> + </span>
|
||||||
|
<Kbd>J</Kbd>
|
||||||
|
</Table.Td>
|
||||||
|
</Table.Tr>
|
||||||
|
<Table.Tr>
|
||||||
|
<Table.Td>
|
||||||
|
<Trans>Select previous unread feed/category</Trans>
|
||||||
|
</Table.Td>
|
||||||
|
<Table.Td>
|
||||||
|
<Kbd>Shift</Kbd>
|
||||||
|
<span> + </span>
|
||||||
|
<Kbd>K</Kbd>
|
||||||
|
</Table.Td>
|
||||||
|
</Table.Tr>
|
||||||
<Table.Tr>
|
<Table.Tr>
|
||||||
<Table.Td>
|
<Table.Td>
|
||||||
<Trans>Set focus on next entry without opening it</Trans>
|
<Trans>Set focus on next entry without opening it</Trans>
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
import { Image } from "@mantine/core"
|
import { Image } from "@mantine/core"
|
||||||
import logo from "assets/logo.svg"
|
import logo from "@/assets/logo.svg"
|
||||||
|
|
||||||
export interface LogoProps {
|
export interface LogoProps {
|
||||||
size: number
|
size: number
|
||||||
}
|
}
|
||||||
|
|
||||||
export function Logo(props: LogoProps) {
|
export function Logo(props: Readonly<LogoProps>) {
|
||||||
return <Image src={logo} w={props.size} />
|
return <Image src={logo} w={props.size} />
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,89 @@
|
|||||||
|
import { Trans } from "@lingui/react/macro"
|
||||||
|
import { Button, Code, Group, Modal, Slider, Stack, Text } from "@mantine/core"
|
||||||
|
import { useState } from "react"
|
||||||
|
import { Constants } from "@/app/constants"
|
||||||
|
import { setMarkAllAsReadConfirmationDialogOpen } from "@/app/entries/slice"
|
||||||
|
import { markAllEntries } from "@/app/entries/thunks"
|
||||||
|
import { useAppDispatch, useAppSelector } from "@/app/store"
|
||||||
|
import { selectNextUnreadTreeItem } from "@/app/tree/thunks"
|
||||||
|
|
||||||
|
export function MarkAllAsReadConfirmationDialog() {
|
||||||
|
const [threshold, setThreshold] = useState(0)
|
||||||
|
const open = useAppSelector(state => state.entries.markAllAsReadConfirmationDialogOpen)
|
||||||
|
const source = useAppSelector(state => state.entries.source)
|
||||||
|
const sourceLabel = useAppSelector(state => state.entries.sourceLabel)
|
||||||
|
const entriesTimestamp = useAppSelector(state => state.entries.timestamp) ?? Date.now()
|
||||||
|
const search = useAppSelector(state => state.entries.search)
|
||||||
|
const markAllAsReadNavigateToNextUnread = useAppSelector(
|
||||||
|
state => state.user.settings?.markAllAsReadNavigateToNextUnread
|
||||||
|
)
|
||||||
|
const dispatch = useAppDispatch()
|
||||||
|
|
||||||
|
const onConfirm = async () => {
|
||||||
|
dispatch(setMarkAllAsReadConfirmationDialogOpen(false))
|
||||||
|
await dispatch(
|
||||||
|
markAllEntries({
|
||||||
|
sourceType: source.type,
|
||||||
|
req: {
|
||||||
|
id: source.id,
|
||||||
|
read: true,
|
||||||
|
olderThan: Date.now() - threshold * 24 * 60 * 60 * 1000,
|
||||||
|
insertedBefore: entriesTimestamp,
|
||||||
|
keywords: search,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
|
const isAllCategorySelected = source.type === "category" && source.id === Constants.categories.all.id
|
||||||
|
if (markAllAsReadNavigateToNextUnread && !isAllCategorySelected)
|
||||||
|
await dispatch(selectNextUnreadTreeItem({ direction: "forward" }))
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
opened={open}
|
||||||
|
onClose={() => dispatch(setMarkAllAsReadConfirmationDialogOpen(false))}
|
||||||
|
title={<Trans>Mark all entries as read</Trans>}
|
||||||
|
>
|
||||||
|
<Stack>
|
||||||
|
<Text size="sm">
|
||||||
|
{threshold === 0 && (
|
||||||
|
<Trans>
|
||||||
|
Are you sure you want to mark all entries of <Code>{sourceLabel}</Code> as read?
|
||||||
|
</Trans>
|
||||||
|
)}
|
||||||
|
{threshold > 0 && (
|
||||||
|
<Trans>
|
||||||
|
Are you sure you want to mark entries older than {threshold} days of{" "}
|
||||||
|
<Code>{sourceLabel}</Code> as read?
|
||||||
|
</Trans>
|
||||||
|
)}
|
||||||
|
</Text>
|
||||||
|
<Slider
|
||||||
|
py="xl"
|
||||||
|
min={0}
|
||||||
|
max={28}
|
||||||
|
marks={[
|
||||||
|
{ value: 0, label: "0" },
|
||||||
|
{ value: 7, label: "7" },
|
||||||
|
{ value: 14, label: "14" },
|
||||||
|
{ value: 21, label: "21" },
|
||||||
|
{ value: 28, label: "28" },
|
||||||
|
]}
|
||||||
|
value={threshold}
|
||||||
|
onChange={setThreshold}
|
||||||
|
data-autofocus
|
||||||
|
onKeyDown={e => e.key === "Enter" && onConfirm()}
|
||||||
|
/>
|
||||||
|
<Group justify="flex-end">
|
||||||
|
<Button variant="default" onClick={() => dispatch(setMarkAllAsReadConfirmationDialogOpen(false))}>
|
||||||
|
<Trans>Cancel</Trans>
|
||||||
|
</Button>
|
||||||
|
<Button color="red" onClick={onConfirm}>
|
||||||
|
<Trans>Confirm</Trans>
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
|
</Stack>
|
||||||
|
</Modal>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import { Trans } from "@lingui/react/macro"
|
||||||
|
import { Checkbox, type CheckboxProps } from "@mantine/core"
|
||||||
|
import type { ReactNode } from "react"
|
||||||
|
import { useAppSelector } from "@/app/store"
|
||||||
|
|
||||||
|
export const ReceivePushNotificationsChechbox = (props: CheckboxProps) => {
|
||||||
|
const pushNotificationsEnabled = useAppSelector(state => state.server.serverInfos?.pushNotificationsEnabled)
|
||||||
|
const pushNotificationsConfigured = useAppSelector(state => !!state.user.settings?.pushNotificationSettings.type)
|
||||||
|
|
||||||
|
const disabled = !pushNotificationsEnabled || !pushNotificationsConfigured
|
||||||
|
let description: ReactNode = ""
|
||||||
|
if (!pushNotificationsEnabled) {
|
||||||
|
description = <Trans>Push notifications are not enabled on this CommaFeed instance.</Trans>
|
||||||
|
} else if (!pushNotificationsConfigured) {
|
||||||
|
description = <Trans>Push notifications are not configured in your user settings.</Trans>
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Checkbox
|
||||||
|
label={<Trans>Receive push notifications</Trans>}
|
||||||
|
disabled={disabled}
|
||||||
|
description={description}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,10 +1,14 @@
|
|||||||
import { Trans } from "@lingui/react/macro"
|
import { Trans } from "@lingui/react/macro"
|
||||||
import { Tooltip } from "@mantine/core"
|
import { Tooltip } from "@mantine/core"
|
||||||
import { Constants } from "app/constants"
|
|
||||||
import dayjs from "dayjs"
|
import dayjs from "dayjs"
|
||||||
import { useNow } from "hooks/useNow"
|
import { Constants } from "@/app/constants"
|
||||||
|
import { useNow } from "@/hooks/useNow"
|
||||||
|
|
||||||
export function RelativeDate(props: { date: Date | number | undefined }) {
|
export function RelativeDate(
|
||||||
|
props: Readonly<{
|
||||||
|
date: Date | number | undefined
|
||||||
|
}>
|
||||||
|
) {
|
||||||
const now = useNow(60 * 1000)
|
const now = useNow(60 * 1000)
|
||||||
|
|
||||||
if (!props.date) return <Trans>N/A</Trans>
|
if (!props.date) return <Trans>N/A</Trans>
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
import { Trans } from "@lingui/react/macro"
|
import { Trans } from "@lingui/react/macro"
|
||||||
import { Box, Button, Checkbox, Group, PasswordInput, Stack, TextInput } from "@mantine/core"
|
import { Box, Button, Checkbox, Group, PasswordInput, Stack, TextInput } from "@mantine/core"
|
||||||
import { useForm } from "@mantine/form"
|
import { useForm } from "@mantine/form"
|
||||||
import { client, errorToStrings } from "app/client"
|
|
||||||
import type { AdminSaveUserRequest, UserModel } from "app/types"
|
|
||||||
import { Alert } from "components/Alert"
|
|
||||||
import { useAsyncCallback } from "react-async-hook"
|
import { useAsyncCallback } from "react-async-hook"
|
||||||
import { TbDeviceFloppy } from "react-icons/tb"
|
import { TbDeviceFloppy } from "react-icons/tb"
|
||||||
|
import { client, errorToStrings } from "@/app/client"
|
||||||
|
import type { AdminSaveUserRequest, UserModel } from "@/app/types"
|
||||||
|
import { Alert } from "@/components/Alert"
|
||||||
|
|
||||||
interface UserEditProps {
|
interface UserEditProps {
|
||||||
user?: UserModel
|
user?: UserModel
|
||||||
@@ -13,7 +13,7 @@ interface UserEditProps {
|
|||||||
onSave: () => void
|
onSave: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
export function UserEdit(props: UserEditProps) {
|
export function UserEdit(props: Readonly<UserEditProps>) {
|
||||||
const form = useForm<AdminSaveUserRequest>({
|
const form = useForm<AdminSaveUserRequest>({
|
||||||
initialValues: props.user ?? {
|
initialValues: props.user ?? {
|
||||||
name: "",
|
name: "",
|
||||||
@@ -34,7 +34,11 @@ export function UserEdit(props: UserEditProps) {
|
|||||||
<form onSubmit={form.onSubmit(saveUser.execute)}>
|
<form onSubmit={form.onSubmit(saveUser.execute)}>
|
||||||
<Stack>
|
<Stack>
|
||||||
<TextInput label={<Trans>Name</Trans>} {...form.getInputProps("name")} required />
|
<TextInput label={<Trans>Name</Trans>} {...form.getInputProps("name")} required />
|
||||||
<PasswordInput label={<Trans>Password</Trans>} {...form.getInputProps("password")} required={!props.user} />
|
<PasswordInput
|
||||||
|
label={<Trans>Password</Trans>}
|
||||||
|
{...form.getInputProps("password")}
|
||||||
|
required={!props.user}
|
||||||
|
/>
|
||||||
<TextInput type="email" label={<Trans>E-mail</Trans>} {...form.getInputProps("email")} />
|
<TextInput type="email" label={<Trans>E-mail</Trans>} {...form.getInputProps("email")} />
|
||||||
<Checkbox label={<Trans>Admin</Trans>} {...form.getInputProps("admin", { type: "checkbox" })} />
|
<Checkbox label={<Trans>Admin</Trans>} {...form.getInputProps("admin", { type: "checkbox" })} />
|
||||||
<Checkbox label={<Trans>Enabled</Trans>} {...form.getInputProps("enabled", { type: "checkbox" })} />
|
<Checkbox label={<Trans>Enabled</Trans>} {...form.getInputProps("enabled", { type: "checkbox" })} />
|
||||||
|
|||||||
@@ -1,16 +1,17 @@
|
|||||||
import { Input, Textarea } from "@mantine/core"
|
import { Input, Textarea } from "@mantine/core"
|
||||||
import RichCodeEditor from "components/code/RichCodeEditor"
|
|
||||||
import { useMobile } from "hooks/useMobile"
|
|
||||||
import type { ReactNode } from "react"
|
import type { ReactNode } from "react"
|
||||||
|
import RichCodeEditor from "@/components/code/RichCodeEditor"
|
||||||
|
import { useMobile } from "@/hooks/useMobile"
|
||||||
|
|
||||||
interface CodeEditorProps {
|
interface CodeEditorProps {
|
||||||
|
label?: ReactNode
|
||||||
description?: ReactNode
|
description?: ReactNode
|
||||||
language: "css" | "javascript"
|
language: "css" | "javascript"
|
||||||
value?: string
|
value?: string
|
||||||
onChange: (value: string | undefined) => void
|
onChange: (value: string | undefined) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
export function CodeEditor(props: CodeEditorProps) {
|
export function CodeEditor(props: Readonly<CodeEditorProps>) {
|
||||||
const mobile = useMobile()
|
const mobile = useMobile()
|
||||||
|
|
||||||
return mobile ? (
|
return mobile ? (
|
||||||
@@ -19,6 +20,7 @@ export function CodeEditor(props: CodeEditorProps) {
|
|||||||
autosize
|
autosize
|
||||||
minRows={4}
|
minRows={4}
|
||||||
maxRows={15}
|
maxRows={15}
|
||||||
|
label={props.label}
|
||||||
description={props.description}
|
description={props.description}
|
||||||
styles={{
|
styles={{
|
||||||
input: {
|
input: {
|
||||||
@@ -29,7 +31,7 @@ export function CodeEditor(props: CodeEditorProps) {
|
|||||||
onChange={e => props.onChange(e.currentTarget.value)}
|
onChange={e => props.onChange(e.currentTarget.value)}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<Input.Wrapper description={props.description}>
|
<Input.Wrapper label={props.label} description={props.description}>
|
||||||
<RichCodeEditor height="30vh" language={props.language} value={props.value} onChange={props.onChange} />
|
<RichCodeEditor height="30vh" language={props.language} value={props.value} onChange={props.onChange} />
|
||||||
</Input.Wrapper>
|
</Input.Wrapper>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,17 +1,17 @@
|
|||||||
import { Loader } from "components/Loader"
|
|
||||||
import { useColorScheme } from "hooks/useColorScheme"
|
|
||||||
import { useAsync } from "react-async-hook"
|
import { useAsync } from "react-async-hook"
|
||||||
|
import { Loader } from "@/components/Loader"
|
||||||
|
import { useColorScheme } from "@/hooks/useColorScheme"
|
||||||
|
|
||||||
const init = async () => {
|
const init = async () => {
|
||||||
window.MonacoEnvironment = {
|
window.MonacoEnvironment = {
|
||||||
async getWorker(_, label) {
|
async getWorker(_, label) {
|
||||||
let worker: typeof import("*?worker")
|
let worker: typeof import("*?worker")
|
||||||
if (label === "css") {
|
if (label === "css") {
|
||||||
worker = await import("monaco-editor/esm/vs/language/css/css.worker?worker")
|
worker = await import("monaco-editor/language/css/css.worker?worker")
|
||||||
} else if (label === "javascript") {
|
} else if (label === "javascript") {
|
||||||
worker = await import("monaco-editor/esm/vs/language/typescript/ts.worker?worker")
|
worker = await import("monaco-editor/language/typescript/ts.worker?worker")
|
||||||
} else {
|
} else {
|
||||||
worker = await import("monaco-editor/esm/vs/editor/editor.worker?worker")
|
worker = await import("monaco-editor/editor/editor.worker?worker")
|
||||||
}
|
}
|
||||||
return new worker.default()
|
return new worker.default()
|
||||||
},
|
},
|
||||||
@@ -30,7 +30,7 @@ interface RichCodeEditorProps {
|
|||||||
onChange: (value: string | undefined) => void
|
onChange: (value: string | undefined) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
function RichCodeEditor(props: RichCodeEditorProps) {
|
function RichCodeEditor(props: Readonly<RichCodeEditorProps>) {
|
||||||
const colorScheme = useColorScheme()
|
const colorScheme = useColorScheme()
|
||||||
const editorTheme = colorScheme === "dark" ? "vs-dark" : "light"
|
const editorTheme = colorScheme === "dark" ? "vs-dark" : "light"
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { TypographyStylesProvider } from "@mantine/core"
|
import { Typography } from "@mantine/core"
|
||||||
import type { ReactNode } from "react"
|
import type { ReactNode } from "react"
|
||||||
import { tss } from "tss"
|
import { tss } from "@/tss"
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* This component is used to provide basic styles to html typography elements.
|
* This component is used to provide basic styles to html typography elements.
|
||||||
@@ -20,5 +20,5 @@ const useStyles = tss.create(() => ({
|
|||||||
|
|
||||||
export const BasicHtmlStyles = (props: { children: ReactNode }) => {
|
export const BasicHtmlStyles = (props: { children: ReactNode }) => {
|
||||||
const { classes } = useStyles()
|
const { classes } = useStyles()
|
||||||
return <TypographyStylesProvider className={classes.content}>{props.children}</TypographyStylesProvider>
|
return <Typography className={classes.content}>{props.children}</Typography>
|
||||||
}
|
}
|
||||||
|
|||||||
28
commafeed-client/src/components/content/Content.test.tsx
Normal file
28
commafeed-client/src/components/content/Content.test.tsx
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
import { MantineProvider } from "@mantine/core"
|
||||||
|
import { render } from "@testing-library/react"
|
||||||
|
import { describe, expect, it } from "vitest"
|
||||||
|
import { Content } from "@/components/content/Content"
|
||||||
|
|
||||||
|
describe("Content component", () => {
|
||||||
|
it("renders basic content", () => {
|
||||||
|
const { container } = render(<Content content="<p>Hello World</p>" />, { wrapper: MantineProvider })
|
||||||
|
expect(container.querySelector("p")).toHaveTextContent("Hello World")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("renders highlighted text when highlight prop is provided", () => {
|
||||||
|
const { container } = render(<Content content="Hello World" highlight="World" />, { wrapper: MantineProvider })
|
||||||
|
expect(container.querySelector("mark")).toHaveTextContent("World")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("renders iframe tag when included in content", () => {
|
||||||
|
const { container } = render(<Content content='<iframe src="https://example.com"></iframe>' />, {
|
||||||
|
wrapper: MantineProvider,
|
||||||
|
})
|
||||||
|
expect(container.querySelector("iframe")).toHaveAttribute("src", "https://example.com")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("does not render unsupported tags", () => {
|
||||||
|
const { container } = render(<Content content='<script>alert("test")</script>' />, { wrapper: MantineProvider })
|
||||||
|
expect(container.querySelector("script")).toBeNull()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -1,13 +1,21 @@
|
|||||||
import { Box, Mark } from "@mantine/core"
|
import { Box, Mark } from "@mantine/core"
|
||||||
import { Constants } from "app/constants"
|
|
||||||
import { calculatePlaceholderSize } from "app/utils"
|
|
||||||
import { ImageWithPlaceholderWhileLoading } from "components/ImageWithPlaceholderWhileLoading"
|
|
||||||
import { BasicHtmlStyles } from "components/content/BasicHtmlStyles"
|
|
||||||
import escapeStringRegexp from "escape-string-regexp"
|
import escapeStringRegexp from "escape-string-regexp"
|
||||||
import { type ChildrenNode, Interweave, type MatchResponse, Matcher, type Node, type TransformCallback } from "interweave"
|
import {
|
||||||
|
ALLOWED_TAG_LIST,
|
||||||
|
type ChildrenNode,
|
||||||
|
Interweave,
|
||||||
|
Matcher,
|
||||||
|
type MatchResponse,
|
||||||
|
type Node,
|
||||||
|
type TransformCallback,
|
||||||
|
} from "interweave"
|
||||||
import React from "react"
|
import React from "react"
|
||||||
import styleToObject from "style-to-object"
|
import styleToObject from "style-to-object"
|
||||||
import { tss } from "tss"
|
import { Constants } from "@/app/constants"
|
||||||
|
import { calculatePlaceholderSize } from "@/app/utils"
|
||||||
|
import { BasicHtmlStyles } from "@/components/content/BasicHtmlStyles"
|
||||||
|
import { ImageWithPlaceholderWhileLoading } from "@/components/ImageWithPlaceholderWhileLoading"
|
||||||
|
import { tss } from "@/tss"
|
||||||
|
|
||||||
export interface ContentProps {
|
export interface ContentProps {
|
||||||
content: string
|
content: string
|
||||||
@@ -67,20 +75,19 @@ const transform: TransformCallback = node => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class HighlightMatcher extends Matcher {
|
class HighlightMatcher extends Matcher {
|
||||||
private readonly search: string
|
private readonly regexp: RegExp
|
||||||
|
|
||||||
constructor(search: string) {
|
constructor(search: string) {
|
||||||
super("highlight")
|
super("highlight")
|
||||||
this.search = escapeStringRegexp(search)
|
this.regexp = new RegExp(escapeStringRegexp(search).split(" ").join("|"), "i")
|
||||||
}
|
}
|
||||||
|
|
||||||
match(string: string): MatchResponse<unknown> | null {
|
match(string: string): MatchResponse<unknown> | null {
|
||||||
const pattern = this.search.split(" ").join("|")
|
return this.doMatch(string, this.regexp, () => ({}))
|
||||||
return this.doMatch(string, new RegExp(pattern, "i"), () => ({}))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
replaceWith(children: ChildrenNode): Node {
|
replaceWith(children: ChildrenNode): Node {
|
||||||
return <Mark>{children}</Mark>
|
return <Mark key={0}>{children}</Mark>
|
||||||
}
|
}
|
||||||
|
|
||||||
asTag(): string {
|
asTag(): string {
|
||||||
@@ -88,6 +95,9 @@ class HighlightMatcher extends Matcher {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// allow iframe tag
|
||||||
|
const allowList = [...ALLOWED_TAG_LIST, "iframe"]
|
||||||
|
|
||||||
// memoize component because Interweave is costly
|
// memoize component because Interweave is costly
|
||||||
const Content = React.memo((props: ContentProps) => {
|
const Content = React.memo((props: ContentProps) => {
|
||||||
const { classes } = useStyles()
|
const { classes } = useStyles()
|
||||||
@@ -96,7 +106,7 @@ const Content = React.memo((props: ContentProps) => {
|
|||||||
return (
|
return (
|
||||||
<BasicHtmlStyles>
|
<BasicHtmlStyles>
|
||||||
<Box className={classes.content}>
|
<Box className={classes.content}>
|
||||||
<Interweave content={props.content} transform={transform} matchers={matchers} />
|
<Interweave content={props.content} transform={transform} matchers={matchers} allowList={allowList} />
|
||||||
</Box>
|
</Box>
|
||||||
</BasicHtmlStyles>
|
</BasicHtmlStyles>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
import { ImageWithPlaceholderWhileLoading } from "components/ImageWithPlaceholderWhileLoading"
|
import { BasicHtmlStyles } from "@/components/content/BasicHtmlStyles"
|
||||||
import { BasicHtmlStyles } from "components/content/BasicHtmlStyles"
|
import { ImageWithPlaceholderWhileLoading } from "@/components/ImageWithPlaceholderWhileLoading"
|
||||||
|
|
||||||
export function Enclosure(props: {
|
export function Enclosure(
|
||||||
enclosureType: string
|
props: Readonly<{
|
||||||
enclosureUrl: string
|
enclosureType: string
|
||||||
}) {
|
enclosureUrl: string
|
||||||
|
}>
|
||||||
|
) {
|
||||||
const hasVideo = props.enclosureType.startsWith("video")
|
const hasVideo = props.enclosureType.startsWith("video")
|
||||||
const hasAudio = props.enclosureType.startsWith("audio")
|
const hasAudio = props.enclosureType.startsWith("audio")
|
||||||
const hasImage = props.enclosureType.startsWith("image")
|
const hasImage = props.enclosureType.startsWith("image")
|
||||||
|
|||||||
@@ -1,35 +1,34 @@
|
|||||||
import { Trans } from "@lingui/react/macro"
|
import { Trans } from "@lingui/react/macro"
|
||||||
import { Box } from "@mantine/core"
|
import { Box } from "@mantine/core"
|
||||||
import { openModal } from "@mantine/modals"
|
import { openModal } from "@mantine/modals"
|
||||||
import { Constants } from "app/constants"
|
import { useEffect } from "react"
|
||||||
import type { ExpendableEntry } from "app/entries/slice"
|
import { useContextMenu } from "react-contexify"
|
||||||
|
import InfiniteScroll from "react-infinite-scroller"
|
||||||
|
import { throttle } from "throttle-debounce"
|
||||||
|
import { Constants } from "@/app/constants"
|
||||||
|
import type { ExpendableEntry } from "@/app/entries/slice"
|
||||||
import {
|
import {
|
||||||
loadMoreEntries,
|
loadMoreEntries,
|
||||||
markAllEntries,
|
markAllAsReadWithConfirmationIfRequired,
|
||||||
markEntry,
|
markEntry,
|
||||||
reloadEntries,
|
reloadEntries,
|
||||||
selectEntry,
|
selectEntry,
|
||||||
selectNextEntry,
|
selectNextEntry,
|
||||||
selectPreviousEntry,
|
selectPreviousEntry,
|
||||||
starEntry,
|
starEntry,
|
||||||
} from "app/entries/thunks"
|
} from "@/app/entries/thunks"
|
||||||
import { redirectToRootCategory } from "app/redirect/thunks"
|
import { redirectToRootCategory } from "@/app/redirect/thunks"
|
||||||
import { useAppDispatch, useAppSelector } from "app/store"
|
import { useAppDispatch, useAppSelector } from "@/app/store"
|
||||||
import { toggleSidebar } from "app/tree/slice"
|
import { toggleSidebar } from "@/app/tree/slice"
|
||||||
import { KeyboardShortcutsHelp } from "components/KeyboardShortcutsHelp"
|
import { selectNextUnreadTreeItem } from "@/app/tree/thunks"
|
||||||
import { Loader } from "components/Loader"
|
import { KeyboardShortcutsHelp } from "@/components/KeyboardShortcutsHelp"
|
||||||
import { useBrowserExtension } from "hooks/useBrowserExtension"
|
import { Loader } from "@/components/Loader"
|
||||||
import { useMousetrap } from "hooks/useMousetrap"
|
import { useBrowserExtension } from "@/hooks/useBrowserExtension"
|
||||||
import { useEffect } from "react"
|
import { useMousetrap } from "@/hooks/useMousetrap"
|
||||||
import { useContextMenu } from "react-contexify"
|
|
||||||
import InfiniteScroll from "react-infinite-scroller"
|
|
||||||
import { throttle } from "throttle-debounce"
|
|
||||||
import { FeedEntry } from "./FeedEntry"
|
import { FeedEntry } from "./FeedEntry"
|
||||||
|
|
||||||
export function FeedEntries() {
|
export function FeedEntries() {
|
||||||
const source = useAppSelector(state => state.entries.source)
|
|
||||||
const entries = useAppSelector(state => state.entries.entries)
|
const entries = useAppSelector(state => state.entries.entries)
|
||||||
const entriesTimestamp = useAppSelector(state => state.entries.timestamp)
|
|
||||||
const selectedEntryId = useAppSelector(state => state.entries.selectedEntryId)
|
const selectedEntryId = useAppSelector(state => state.entries.selectedEntryId)
|
||||||
const hasMore = useAppSelector(state => state.entries.hasMore)
|
const hasMore = useAppSelector(state => state.entries.hasMore)
|
||||||
const loading = useAppSelector(state => state.entries.loading)
|
const loading = useAppSelector(state => state.entries.loading)
|
||||||
@@ -112,12 +111,13 @@ export function FeedEntries() {
|
|||||||
const el = document.getElementById(Constants.dom.entryId(e))
|
const el = document.getElementById(Constants.dom.entryId(e))
|
||||||
return el && !Constants.layout.isTopVisible(el)
|
return el && !Constants.layout.isTopVisible(el)
|
||||||
})
|
})
|
||||||
if (currentEntry) {
|
if (currentEntry && currentEntry.id !== selectedEntryId) {
|
||||||
dispatch(
|
dispatch(
|
||||||
selectEntry({
|
selectEntry({
|
||||||
entry: currentEntry,
|
entry: currentEntry,
|
||||||
expand: false,
|
expand: false,
|
||||||
markAsRead: !!scrollMarks,
|
// don't mark as read entries that the user explicitly marked as unread
|
||||||
|
markAsRead: !!scrollMarks && !currentEntry.keepUnread,
|
||||||
scrollToEntry: false,
|
scrollToEntry: false,
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
@@ -125,7 +125,7 @@ export function FeedEntries() {
|
|||||||
})
|
})
|
||||||
window.addEventListener("scroll", listener)
|
window.addEventListener("scroll", listener)
|
||||||
return () => window.removeEventListener("scroll", listener)
|
return () => window.removeEventListener("scroll", listener)
|
||||||
}, [dispatch, entries, viewMode, scrollMarks, scrollingToEntry])
|
}, [dispatch, entries, viewMode, scrollMarks, scrollingToEntry, selectedEntryId])
|
||||||
|
|
||||||
useMousetrap("r", async () => await dispatch(reloadEntries()))
|
useMousetrap("r", async () => await dispatch(reloadEntries()))
|
||||||
useMousetrap(
|
useMousetrap(
|
||||||
@@ -172,6 +172,8 @@ export function FeedEntries() {
|
|||||||
})
|
})
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
useMousetrap("shift+j", async () => await dispatch(selectNextUnreadTreeItem({ direction: "forward" })))
|
||||||
|
useMousetrap("shift+k", async () => await dispatch(selectNextUnreadTreeItem({ direction: "backward" })))
|
||||||
useMousetrap("space", () => {
|
useMousetrap("space", () => {
|
||||||
if (selectedEntry) {
|
if (selectedEntry) {
|
||||||
if (selectedEntry.expanded) {
|
if (selectedEntry.expanded) {
|
||||||
@@ -272,17 +274,7 @@ export function FeedEntries() {
|
|||||||
})
|
})
|
||||||
useMousetrap("shift+a", () => {
|
useMousetrap("shift+a", () => {
|
||||||
// mark all entries as read
|
// mark all entries as read
|
||||||
dispatch(
|
dispatch(markAllAsReadWithConfirmationIfRequired())
|
||||||
markAllEntries({
|
|
||||||
sourceType: source.type,
|
|
||||||
req: {
|
|
||||||
id: source.id,
|
|
||||||
read: true,
|
|
||||||
olderThan: Date.now(),
|
|
||||||
insertedBefore: entriesTimestamp,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
)
|
|
||||||
})
|
})
|
||||||
useMousetrap("g a", async () => await dispatch(redirectToRootCategory()))
|
useMousetrap("g a", async () => await dispatch(redirectToRootCategory()))
|
||||||
useMousetrap("f", () => dispatch(toggleSidebar()))
|
useMousetrap("f", () => dispatch(toggleSidebar()))
|
||||||
@@ -296,33 +288,27 @@ export function FeedEntries() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<InfiniteScroll
|
<InfiniteScroll
|
||||||
id="entries"
|
className={`cf-entries cf-view-mode-${viewMode}`}
|
||||||
className={`view-mode-${viewMode}`}
|
|
||||||
initialLoad={false}
|
initialLoad={false}
|
||||||
loadMore={async () => await (!loading && dispatch(loadMoreEntries()))}
|
loadMore={async () => await (!loading && dispatch(loadMoreEntries()))}
|
||||||
hasMore={hasMore}
|
hasMore={hasMore}
|
||||||
loader={<Box key={0}>{loading && <Loader />}</Box>}
|
loader={<Box key={0}>{loading && <Loader />}</Box>}
|
||||||
>
|
>
|
||||||
{entries.map(entry => (
|
{entries.map(entry => (
|
||||||
<article
|
<FeedEntry
|
||||||
key={entry.id}
|
key={entry.id}
|
||||||
ref={el => {
|
entry={entry}
|
||||||
if (el) el.id = Constants.dom.entryId(entry)
|
expanded={!!entry.expanded || viewMode === "expanded"}
|
||||||
}}
|
selected={entry.id === selectedEntryId}
|
||||||
data-id={entry.id}
|
showSelectionIndicator={
|
||||||
>
|
entry.id === selectedEntryId && (!entry.expanded || viewMode === "expanded")
|
||||||
<FeedEntry
|
}
|
||||||
entry={entry}
|
maxWidth={sidebarVisible ? Constants.layout.entryMaxWidth : undefined}
|
||||||
expanded={!!entry.expanded || viewMode === "expanded"}
|
onHeaderClick={event => headerClicked(entry, event)}
|
||||||
selected={entry.id === selectedEntryId}
|
onHeaderRightClick={event => headerRightClicked(entry, event)}
|
||||||
showSelectionIndicator={entry.id === selectedEntryId && (!entry.expanded || viewMode === "expanded")}
|
onBodyClick={() => bodyClicked(entry)}
|
||||||
maxWidth={sidebarVisible ? Constants.layout.entryMaxWidth : undefined}
|
onSwipedLeft={async () => await swipedLeft(entry)}
|
||||||
onHeaderClick={event => headerClicked(entry, event)}
|
/>
|
||||||
onHeaderRightClick={event => headerRightClicked(entry, event)}
|
|
||||||
onBodyClick={() => bodyClicked(entry)}
|
|
||||||
onSwipedLeft={async () => await swipedLeft(entry)}
|
|
||||||
/>
|
|
||||||
</article>
|
|
||||||
))}
|
))}
|
||||||
</InfiniteScroll>
|
</InfiniteScroll>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,13 +1,13 @@
|
|||||||
import { Box, Divider, type MantineRadius, type MantineSpacing, Paper } from "@mantine/core"
|
import { Box, Divider, type MantineRadius, type MantineSpacing, Paper } from "@mantine/core"
|
||||||
import { Constants } from "app/constants"
|
|
||||||
import { useAppSelector } from "app/store"
|
|
||||||
import type { Entry, ViewMode } from "app/types"
|
|
||||||
import { FeedEntryCompactHeader } from "components/content/header/FeedEntryCompactHeader"
|
|
||||||
import { FeedEntryHeader } from "components/content/header/FeedEntryHeader"
|
|
||||||
import { useMobile } from "hooks/useMobile"
|
|
||||||
import type React from "react"
|
import type React from "react"
|
||||||
import { useSwipeable } from "react-swipeable"
|
import { useSwipeable } from "react-swipeable"
|
||||||
import { tss } from "tss"
|
import { Constants } from "@/app/constants"
|
||||||
|
import { useAppSelector } from "@/app/store"
|
||||||
|
import type { Entry, ViewMode } from "@/app/types"
|
||||||
|
import { FeedEntryCompactHeader } from "@/components/content/header/FeedEntryCompactHeader"
|
||||||
|
import { FeedEntryHeader } from "@/components/content/header/FeedEntryHeader"
|
||||||
|
import { useMobile } from "@/hooks/useMobile"
|
||||||
|
import { tss } from "@/tss"
|
||||||
import { FeedEntryBody } from "./FeedEntryBody"
|
import { FeedEntryBody } from "./FeedEntryBody"
|
||||||
import { FeedEntryContextMenu } from "./FeedEntryContextMenu"
|
import { FeedEntryContextMenu } from "./FeedEntryContextMenu"
|
||||||
import { FeedEntryFooter } from "./FeedEntryFooter"
|
import { FeedEntryFooter } from "./FeedEntryFooter"
|
||||||
@@ -32,69 +32,86 @@ const useStyles = tss
|
|||||||
rtl: boolean
|
rtl: boolean
|
||||||
showSelectionIndicator: boolean
|
showSelectionIndicator: boolean
|
||||||
maxWidth?: number
|
maxWidth?: number
|
||||||
|
fontSizePercentage: number
|
||||||
}>()
|
}>()
|
||||||
.create(({ theme, colorScheme, read, expanded, viewMode, rtl, showSelectionIndicator, maxWidth }) => {
|
.create(
|
||||||
let backgroundColor: string
|
({
|
||||||
if (colorScheme === "dark") {
|
theme,
|
||||||
backgroundColor = read ? "inherit" : theme.colors.dark[5]
|
colorScheme,
|
||||||
} else {
|
read,
|
||||||
backgroundColor = read && !expanded ? theme.colors.gray[0] : "inherit"
|
expanded,
|
||||||
}
|
viewMode,
|
||||||
|
rtl,
|
||||||
|
showSelectionIndicator,
|
||||||
|
maxWidth,
|
||||||
|
fontSizePercentage,
|
||||||
|
}) => {
|
||||||
|
let backgroundColor: string
|
||||||
|
if (colorScheme === "dark") {
|
||||||
|
backgroundColor = read ? "inherit" : theme.colors.dark[5]
|
||||||
|
} else {
|
||||||
|
backgroundColor = read && !expanded ? theme.colors.gray[0] : "inherit"
|
||||||
|
}
|
||||||
|
|
||||||
let marginY = 10
|
let marginY = 10
|
||||||
if (viewMode === "title") {
|
if (viewMode === "title") {
|
||||||
marginY = 2
|
marginY = 2
|
||||||
} else if (viewMode === "cozy") {
|
} else if (viewMode === "cozy") {
|
||||||
marginY = 6
|
marginY = 6
|
||||||
}
|
}
|
||||||
|
|
||||||
let mobileMarginY = 6
|
let mobileMarginY = 6
|
||||||
if (viewMode === "title") {
|
if (viewMode === "title") {
|
||||||
mobileMarginY = 2
|
mobileMarginY = 2
|
||||||
} else if (viewMode === "cozy") {
|
} else if (viewMode === "cozy") {
|
||||||
mobileMarginY = 4
|
mobileMarginY = 4
|
||||||
}
|
}
|
||||||
|
|
||||||
let backgroundHoverColor = backgroundColor
|
let backgroundHoverColor = backgroundColor
|
||||||
if (!expanded && !read) {
|
if (!expanded && !read) {
|
||||||
backgroundHoverColor = colorScheme === "dark" ? theme.colors.dark[6] : theme.colors.gray[1]
|
backgroundHoverColor = colorScheme === "dark" ? theme.colors.dark[6] : theme.colors.gray[1]
|
||||||
}
|
}
|
||||||
|
|
||||||
let paperBorderLeftColor = ""
|
let paperBorderLeftColor = ""
|
||||||
if (showSelectionIndicator) {
|
if (showSelectionIndicator) {
|
||||||
const borderLeftColor = colorScheme === "dark" ? theme.colors[theme.primaryColor][4] : theme.colors[theme.primaryColor][6]
|
const borderLeftColor =
|
||||||
paperBorderLeftColor = `${borderLeftColor} !important`
|
colorScheme === "dark" ? theme.colors[theme.primaryColor][4] : theme.colors[theme.primaryColor][6]
|
||||||
}
|
paperBorderLeftColor = `${borderLeftColor} !important`
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
paper: {
|
paper: {
|
||||||
backgroundColor,
|
backgroundColor,
|
||||||
borderLeftColor: paperBorderLeftColor,
|
borderLeftColor: paperBorderLeftColor,
|
||||||
marginTop: marginY,
|
marginTop: marginY,
|
||||||
marginBottom: marginY,
|
marginBottom: marginY,
|
||||||
[`@media (max-width: ${Constants.layout.mobileBreakpoint}px)`]: {
|
[`@media (max-width: ${Constants.layout.mobileBreakpoint}px)`]: {
|
||||||
marginTop: mobileMarginY,
|
marginTop: mobileMarginY,
|
||||||
marginBottom: mobileMarginY,
|
marginBottom: mobileMarginY,
|
||||||
},
|
},
|
||||||
"@media (hover: hover)": {
|
"@media (hover: hover)": {
|
||||||
"&:hover": {
|
"&:hover": {
|
||||||
backgroundColor: backgroundHoverColor,
|
backgroundColor: backgroundHoverColor,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
headerLink: {
|
||||||
headerLink: {
|
fontSize: `${fontSizePercentage}%`,
|
||||||
color: "inherit",
|
color: "inherit",
|
||||||
textDecoration: "none",
|
textDecoration: "none",
|
||||||
},
|
},
|
||||||
body: {
|
body: {
|
||||||
direction: rtl ? "rtl" : "ltr",
|
fontSize: `${fontSizePercentage}%`,
|
||||||
maxWidth: maxWidth ?? "100%",
|
direction: rtl ? "rtl" : "ltr",
|
||||||
},
|
maxWidth: maxWidth ?? "100%",
|
||||||
|
},
|
||||||
|
}
|
||||||
}
|
}
|
||||||
})
|
)
|
||||||
|
|
||||||
export function FeedEntry(props: FeedEntryProps) {
|
export function FeedEntry(props: Readonly<FeedEntryProps>) {
|
||||||
const viewMode = useAppSelector(state => state.user.localSettings.viewMode)
|
const viewMode = useAppSelector(state => state.user.localSettings.viewMode)
|
||||||
|
const fontSizePercentage = useAppSelector(state => state.user.localSettings.fontSizePercentage)
|
||||||
const { classes, cx } = useStyles({
|
const { classes, cx } = useStyles({
|
||||||
read: props.entry.read,
|
read: props.entry.read,
|
||||||
expanded: props.expanded,
|
expanded: props.expanded,
|
||||||
@@ -102,6 +119,7 @@ export function FeedEntry(props: FeedEntryProps) {
|
|||||||
rtl: props.entry.rtl,
|
rtl: props.entry.rtl,
|
||||||
showSelectionIndicator: props.showSelectionIndicator,
|
showSelectionIndicator: props.showSelectionIndicator,
|
||||||
maxWidth: props.maxWidth,
|
maxWidth: props.maxWidth,
|
||||||
|
fontSizePercentage,
|
||||||
})
|
})
|
||||||
|
|
||||||
const externalLinkDisplayMode = useAppSelector(state => state.user.settings?.externalLinkIconDisplayMode)
|
const externalLinkDisplayMode = useAppSelector(state => state.user.settings?.externalLinkIconDisplayMode)
|
||||||
@@ -111,7 +129,9 @@ export function FeedEntry(props: FeedEntryProps) {
|
|||||||
const showExternalLinkIcon =
|
const showExternalLinkIcon =
|
||||||
externalLinkDisplayMode && ["always", mobile ? "on_mobile" : "on_desktop"].includes(externalLinkDisplayMode)
|
externalLinkDisplayMode && ["always", mobile ? "on_mobile" : "on_desktop"].includes(externalLinkDisplayMode)
|
||||||
const showStarIcon =
|
const showStarIcon =
|
||||||
props.entry.markable && starIconDisplayMode && ["always", mobile ? "on_mobile" : "on_desktop"].includes(starIconDisplayMode)
|
props.entry.markable &&
|
||||||
|
starIconDisplayMode &&
|
||||||
|
["always", mobile ? "on_mobile" : "on_desktop"].includes(starIconDisplayMode)
|
||||||
|
|
||||||
const swipeHandlers = useSwipeable({
|
const swipeHandlers = useSwipeable({
|
||||||
onSwipedLeft: props.onSwipedLeft,
|
onSwipedLeft: props.onSwipedLeft,
|
||||||
@@ -137,6 +157,10 @@ export function FeedEntry(props: FeedEntryProps) {
|
|||||||
const compactHeader = !props.expanded && (viewMode === "title" || viewMode === "cozy")
|
const compactHeader = !props.expanded && (viewMode === "title" || viewMode === "cozy")
|
||||||
return (
|
return (
|
||||||
<Paper
|
<Paper
|
||||||
|
component="article"
|
||||||
|
id={Constants.dom.entryId(props.entry)}
|
||||||
|
data-id={props.entry.id}
|
||||||
|
data-feed-id={props.entry.feedId}
|
||||||
withBorder
|
withBorder
|
||||||
radius={borderRadius}
|
radius={borderRadius}
|
||||||
className={cx(classes.paper, {
|
className={cx(classes.paper, {
|
||||||
@@ -176,10 +200,10 @@ export function FeedEntry(props: FeedEntryProps) {
|
|||||||
</a>
|
</a>
|
||||||
{props.expanded && (
|
{props.expanded && (
|
||||||
<Box px={paddingX} pb={paddingY} onClick={props.onBodyClick}>
|
<Box px={paddingX} pb={paddingY} onClick={props.onBodyClick}>
|
||||||
<Box className={classes.body}>
|
<Box className={`${classes.body} cf-content`}>
|
||||||
<FeedEntryBody entry={props.entry} />
|
<FeedEntryBody entry={props.entry} />
|
||||||
</Box>
|
</Box>
|
||||||
<Divider variant="dashed" my={paddingY} />
|
<Divider variant="dashed" my={paddingY} className="cf-footer-divider" />
|
||||||
<FeedEntryFooter entry={props.entry} />
|
<FeedEntryFooter entry={props.entry} />
|
||||||
</Box>
|
</Box>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { Box } from "@mantine/core"
|
import { Box } from "@mantine/core"
|
||||||
import { useAppSelector } from "app/store"
|
import { useAppSelector } from "@/app/store"
|
||||||
import type { Entry } from "app/types"
|
import type { Entry } from "@/app/types"
|
||||||
import { Content } from "./Content"
|
import { Content } from "./Content"
|
||||||
import { Enclosure } from "./Enclosure"
|
import { Enclosure } from "./Enclosure"
|
||||||
import { Media } from "./Media"
|
import { Media } from "./Media"
|
||||||
@@ -9,7 +9,7 @@ export interface FeedEntryBodyProps {
|
|||||||
entry: Entry
|
entry: Entry
|
||||||
}
|
}
|
||||||
|
|
||||||
export function FeedEntryBody(props: FeedEntryBodyProps) {
|
export function FeedEntryBody(props: Readonly<FeedEntryBodyProps>) {
|
||||||
const search = useAppSelector(state => state.entries.search)
|
const search = useAppSelector(state => state.entries.search)
|
||||||
return (
|
return (
|
||||||
<Box>
|
<Box>
|
||||||
|
|||||||
@@ -1,16 +1,16 @@
|
|||||||
import { Trans } from "@lingui/react/macro"
|
import { Trans } from "@lingui/react/macro"
|
||||||
import { Group } from "@mantine/core"
|
import { Group } from "@mantine/core"
|
||||||
import { Constants } from "app/constants"
|
|
||||||
import { markEntriesUpToEntry, markEntry, starEntry } from "app/entries/thunks"
|
|
||||||
import { redirectToFeed } from "app/redirect/thunks"
|
|
||||||
import { useAppDispatch, useAppSelector } from "app/store"
|
|
||||||
import type { Entry } from "app/types"
|
|
||||||
import { truncate } from "app/utils"
|
|
||||||
import { useBrowserExtension } from "hooks/useBrowserExtension"
|
|
||||||
import { useColorScheme } from "hooks/useColorScheme"
|
|
||||||
import { Item, Menu, Separator } from "react-contexify"
|
import { Item, Menu, Separator } from "react-contexify"
|
||||||
import { TbArrowBarToDown, TbExternalLink, TbMail, TbMailOpened, TbRss, TbStar, TbStarOff } from "react-icons/tb"
|
import { TbArrowBarToDown, TbExternalLink, TbMail, TbMailOpened, TbRss, TbStar, TbStarOff } from "react-icons/tb"
|
||||||
import { tss } from "tss"
|
import { Constants } from "@/app/constants"
|
||||||
|
import { markEntriesUpToEntry, markEntry, starEntry } from "@/app/entries/thunks"
|
||||||
|
import { redirectToFeed } from "@/app/redirect/thunks"
|
||||||
|
import { useAppDispatch, useAppSelector } from "@/app/store"
|
||||||
|
import type { Entry } from "@/app/types"
|
||||||
|
import { truncate } from "@/app/utils"
|
||||||
|
import { useBrowserExtension } from "@/hooks/useBrowserExtension"
|
||||||
|
import { useColorScheme } from "@/hooks/useColorScheme"
|
||||||
|
import { tss } from "@/tss"
|
||||||
|
|
||||||
interface FeedEntryContextMenuProps {
|
interface FeedEntryContextMenuProps {
|
||||||
entry: Entry
|
entry: Entry
|
||||||
@@ -27,7 +27,7 @@ const useStyles = tss.create(({ theme, colorScheme }) => ({
|
|||||||
},
|
},
|
||||||
}))
|
}))
|
||||||
|
|
||||||
export function FeedEntryContextMenu(props: FeedEntryContextMenuProps) {
|
export function FeedEntryContextMenu(props: Readonly<FeedEntryContextMenuProps>) {
|
||||||
const colorScheme = useColorScheme()
|
const colorScheme = useColorScheme()
|
||||||
const { classes } = useStyles()
|
const { classes } = useStyles()
|
||||||
const sourceType = useAppSelector(state => state.entries.source.type)
|
const sourceType = useAppSelector(state => state.entries.source.type)
|
||||||
@@ -35,7 +35,12 @@ export function FeedEntryContextMenu(props: FeedEntryContextMenuProps) {
|
|||||||
const { openLinkInBackgroundTab } = useBrowserExtension()
|
const { openLinkInBackgroundTab } = useBrowserExtension()
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Menu id={Constants.dom.entryContextMenuId(props.entry)} theme={colorScheme} animation={false} className={classes.menu}>
|
<Menu
|
||||||
|
id={Constants.dom.entryContextMenuId(props.entry)}
|
||||||
|
theme={colorScheme}
|
||||||
|
animation={false}
|
||||||
|
className={classes.menu}
|
||||||
|
>
|
||||||
<Item
|
<Item
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
window.open(props.entry.url, "_blank", "noreferrer")
|
window.open(props.entry.url, "_blank", "noreferrer")
|
||||||
@@ -61,19 +66,27 @@ export function FeedEntryContextMenu(props: FeedEntryContextMenuProps) {
|
|||||||
|
|
||||||
<Separator />
|
<Separator />
|
||||||
|
|
||||||
<Item onClick={async () => await dispatch(starEntry({ entry: props.entry, starred: !props.entry.starred }))}>
|
|
||||||
<Group>
|
|
||||||
{props.entry.starred ? <TbStarOff size={iconSize} /> : <TbStar size={iconSize} />}
|
|
||||||
{props.entry.starred ? <Trans>Unstar</Trans> : <Trans>Star</Trans>}
|
|
||||||
</Group>
|
|
||||||
</Item>
|
|
||||||
{props.entry.markable && (
|
{props.entry.markable && (
|
||||||
<Item onClick={async () => await dispatch(markEntry({ entry: props.entry, read: !props.entry.read }))}>
|
<>
|
||||||
<Group>
|
<Item
|
||||||
{props.entry.read ? <TbMail size={iconSize} /> : <TbMailOpened size={iconSize} />}
|
onClick={async () =>
|
||||||
{props.entry.read ? <Trans>Keep unread</Trans> : <Trans>Mark as read</Trans>}
|
await dispatch(starEntry({ entry: props.entry, starred: !props.entry.starred }))
|
||||||
</Group>
|
}
|
||||||
</Item>
|
>
|
||||||
|
<Group>
|
||||||
|
{props.entry.starred ? <TbStarOff size={iconSize} /> : <TbStar size={iconSize} />}
|
||||||
|
{props.entry.starred ? <Trans>Unstar</Trans> : <Trans>Star</Trans>}
|
||||||
|
</Group>
|
||||||
|
</Item>
|
||||||
|
<Item
|
||||||
|
onClick={async () => await dispatch(markEntry({ entry: props.entry, read: !props.entry.read }))}
|
||||||
|
>
|
||||||
|
<Group>
|
||||||
|
{props.entry.read ? <TbMail size={iconSize} /> : <TbMailOpened size={iconSize} />}
|
||||||
|
{props.entry.read ? <Trans>Keep unread</Trans> : <Trans>Mark as read</Trans>}
|
||||||
|
</Group>
|
||||||
|
</Item>
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
<Item onClick={async () => await dispatch(markEntriesUpToEntry(props.entry))}>
|
<Item onClick={async () => await dispatch(markEntriesUpToEntry(props.entry))}>
|
||||||
<Group>
|
<Group>
|
||||||
|
|||||||
@@ -1,20 +1,29 @@
|
|||||||
import { msg } from "@lingui/core/macro"
|
import { msg } from "@lingui/core/macro"
|
||||||
import { useLingui } from "@lingui/react"
|
import { useLingui } from "@lingui/react"
|
||||||
import { Group, Indicator, Popover, TagsInput } from "@mantine/core"
|
import { Group, Indicator, Popover, TagsInput } from "@mantine/core"
|
||||||
import { markEntriesUpToEntry, markEntry, starEntry, tagEntry } from "app/entries/thunks"
|
import {
|
||||||
import { useAppDispatch, useAppSelector } from "app/store"
|
TbArrowBarToDown,
|
||||||
import type { Entry } from "app/types"
|
TbExternalLink,
|
||||||
import { ActionButton } from "components/ActionButton"
|
TbMail,
|
||||||
import { useActionButton } from "hooks/useActionButton"
|
TbMailOpened,
|
||||||
import { useMobile } from "hooks/useMobile"
|
TbShare,
|
||||||
import { TbArrowBarToDown, TbExternalLink, TbMail, TbMailOpened, TbShare, TbStar, TbStarOff, TbTag } from "react-icons/tb"
|
TbStar,
|
||||||
|
TbStarOff,
|
||||||
|
TbTag,
|
||||||
|
} from "react-icons/tb"
|
||||||
|
import { markEntriesUpToEntry, markEntry, starEntry, tagEntry } from "@/app/entries/thunks"
|
||||||
|
import { useAppDispatch, useAppSelector } from "@/app/store"
|
||||||
|
import type { Entry } from "@/app/types"
|
||||||
|
import { ActionButton } from "@/components/ActionButton"
|
||||||
|
import { useActionButton } from "@/hooks/useActionButton"
|
||||||
|
import { useMobile } from "@/hooks/useMobile"
|
||||||
import { ShareButtons } from "./ShareButtons"
|
import { ShareButtons } from "./ShareButtons"
|
||||||
|
|
||||||
interface FeedEntryFooterProps {
|
interface FeedEntryFooterProps {
|
||||||
entry: Entry
|
entry: Entry
|
||||||
}
|
}
|
||||||
|
|
||||||
export function FeedEntryFooter(props: FeedEntryFooterProps) {
|
export function FeedEntryFooter(props: Readonly<FeedEntryFooterProps>) {
|
||||||
const tags = useAppSelector(state => state.user.tags)
|
const tags = useAppSelector(state => state.user.tags)
|
||||||
const mobile = useMobile()
|
const mobile = useMobile()
|
||||||
const { spacing } = useActionButton()
|
const { spacing } = useActionButton()
|
||||||
@@ -37,7 +46,7 @@ export function FeedEntryFooter(props: FeedEntryFooterProps) {
|
|||||||
)
|
)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Group justify="space-between">
|
<Group justify="space-between" className="cf-footer">
|
||||||
<Group gap={spacing}>
|
<Group gap={spacing}>
|
||||||
{props.entry.markable && (
|
{props.entry.markable && (
|
||||||
<ActionButton
|
<ActionButton
|
||||||
@@ -71,7 +80,12 @@ export function FeedEntryFooter(props: FeedEntryFooterProps) {
|
|||||||
{tags && (
|
{tags && (
|
||||||
<Popover withArrow shadow="md" closeOnClickOutside={!mobile}>
|
<Popover withArrow shadow="md" closeOnClickOutside={!mobile}>
|
||||||
<Popover.Target>
|
<Popover.Target>
|
||||||
<Indicator label={props.entry.tags.length} disabled={props.entry.tags.length === 0} inline size={16}>
|
<Indicator
|
||||||
|
label={props.entry.tags.length}
|
||||||
|
disabled={props.entry.tags.length === 0}
|
||||||
|
inline
|
||||||
|
size={16}
|
||||||
|
>
|
||||||
<ActionButton icon={<TbTag size={18} />} label={msg`Tags`} />
|
<ActionButton icon={<TbTag size={18} />} label={msg`Tags`} />
|
||||||
</Indicator>
|
</Indicator>
|
||||||
</Popover.Target>
|
</Popover.Target>
|
||||||
|
|||||||
@@ -1,14 +1,30 @@
|
|||||||
import { ImageWithPlaceholderWhileLoading } from "components/ImageWithPlaceholderWhileLoading"
|
import { useTimeout } from "@mantine/hooks"
|
||||||
|
import { useState } from "react"
|
||||||
|
import { ImageWithPlaceholderWhileLoading } from "@/components/ImageWithPlaceholderWhileLoading"
|
||||||
|
|
||||||
export interface FeedFaviconProps {
|
export interface FeedFaviconProps {
|
||||||
url: string
|
url: string
|
||||||
size?: number
|
size?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
export function FeedFavicon({ url, size = 18 }: FeedFaviconProps) {
|
export function FeedFavicon({ url, size = 18 }: Readonly<FeedFaviconProps>) {
|
||||||
|
// the backend always returns a favicon except when the feed has never been fetched
|
||||||
|
// this can happen when the user subscribes to a feed, the feed is added to the tree but the feed has not been fetched yet
|
||||||
|
// in this case we retry every second up to 3 times until the feed is fetched and the favicon is available
|
||||||
|
const [timestamp, setTimestamp] = useState(0)
|
||||||
|
const [retryCount, setRetryCount] = useState(0)
|
||||||
|
const { start: retry } = useTimeout(() => setTimestamp(Date.now()), 1000)
|
||||||
|
|
||||||
|
const urlWithTimestamp = url + (timestamp === 0 ? "" : `?t=${timestamp}`)
|
||||||
|
const handleError = () => {
|
||||||
|
if (retryCount < 3) {
|
||||||
|
setRetryCount(c => c + 1)
|
||||||
|
retry()
|
||||||
|
}
|
||||||
|
}
|
||||||
return (
|
return (
|
||||||
<ImageWithPlaceholderWhileLoading
|
<ImageWithPlaceholderWhileLoading
|
||||||
src={url}
|
src={urlWithTimestamp}
|
||||||
alt="feed favicon"
|
alt="feed favicon"
|
||||||
width={size}
|
width={size}
|
||||||
height={size}
|
height={size}
|
||||||
@@ -16,6 +32,7 @@ export function FeedFavicon({ url, size = 18 }: FeedFaviconProps) {
|
|||||||
placeholderHeight={size}
|
placeholderHeight={size}
|
||||||
placeholderBackgroundColor="inherit"
|
placeholderBackgroundColor="inherit"
|
||||||
placeholderIconSize={size}
|
placeholderIconSize={size}
|
||||||
|
onError={handleError}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import { Box } from "@mantine/core"
|
import { Box } from "@mantine/core"
|
||||||
import { Constants } from "app/constants"
|
import { Constants } from "@/app/constants"
|
||||||
import { calculatePlaceholderSize } from "app/utils"
|
import { calculatePlaceholderSize } from "@/app/utils"
|
||||||
import { ImageWithPlaceholderWhileLoading } from "components/ImageWithPlaceholderWhileLoading"
|
import { BasicHtmlStyles } from "@/components/content/BasicHtmlStyles"
|
||||||
import { BasicHtmlStyles } from "components/content/BasicHtmlStyles"
|
import { ImageWithPlaceholderWhileLoading } from "@/components/ImageWithPlaceholderWhileLoading"
|
||||||
import { Content } from "./Content"
|
import { Content } from "./Content"
|
||||||
|
|
||||||
export interface MediaProps {
|
export interface MediaProps {
|
||||||
@@ -12,7 +12,7 @@ export interface MediaProps {
|
|||||||
description?: string
|
description?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export function Media(props: MediaProps) {
|
export function Media(props: Readonly<MediaProps>) {
|
||||||
const width = props.thumbnailWidth
|
const width = props.thumbnailWidth
|
||||||
const height = props.thumbnailHeight
|
const height = props.thumbnailHeight
|
||||||
const placeholderSize = calculatePlaceholderSize({
|
const placeholderSize = calculatePlaceholderSize({
|
||||||
|
|||||||
@@ -1,13 +1,13 @@
|
|||||||
import { Trans } from "@lingui/react/macro"
|
import { Trans } from "@lingui/react/macro"
|
||||||
import { ActionIcon, Box, CopyButton, Divider, SimpleGrid } from "@mantine/core"
|
import { ActionIcon, Box, CopyButton, Divider, SimpleGrid } from "@mantine/core"
|
||||||
import { Constants } from "app/constants"
|
|
||||||
import { useAppSelector } from "app/store"
|
|
||||||
import type { SharingSettings } from "app/types"
|
|
||||||
import { useBrowserExtension } from "hooks/useBrowserExtension"
|
|
||||||
import { useMobile } from "hooks/useMobile"
|
|
||||||
import type { IconType } from "react-icons"
|
import type { IconType } from "react-icons"
|
||||||
import { TbCheck, TbCopy, TbDeviceDesktopShare, TbDeviceMobileShare } from "react-icons/tb"
|
import { TbCheck, TbCopy, TbDeviceDesktopShare, TbDeviceMobileShare } from "react-icons/tb"
|
||||||
import { tss } from "tss"
|
import { Constants } from "@/app/constants"
|
||||||
|
import { useAppSelector } from "@/app/store"
|
||||||
|
import type { SharingSettings } from "@/app/types"
|
||||||
|
import { useBrowserExtension } from "@/hooks/useBrowserExtension"
|
||||||
|
import { useMobile } from "@/hooks/useMobile"
|
||||||
|
import { tss } from "@/tss"
|
||||||
|
|
||||||
type Color = `#${string}`
|
type Color = `#${string}`
|
||||||
|
|
||||||
@@ -22,7 +22,15 @@ const useStyles = tss
|
|||||||
},
|
},
|
||||||
}))
|
}))
|
||||||
|
|
||||||
function ShareButton({ icon, color, onClick }: { icon: IconType; color: Color; onClick: () => void }) {
|
function ShareButton({
|
||||||
|
icon,
|
||||||
|
color,
|
||||||
|
onClick,
|
||||||
|
}: Readonly<{
|
||||||
|
icon: IconType
|
||||||
|
color: Color
|
||||||
|
onClick: () => void
|
||||||
|
}>) {
|
||||||
const { classes } = useStyles({
|
const { classes } = useStyles({
|
||||||
color,
|
color,
|
||||||
})
|
})
|
||||||
@@ -36,7 +44,15 @@ function ShareButton({ icon, color, onClick }: { icon: IconType; color: Color; o
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function SiteShareButton({ url, icon, color }: { icon: IconType; color: Color; url: string }) {
|
function SiteShareButton({
|
||||||
|
url,
|
||||||
|
icon,
|
||||||
|
color,
|
||||||
|
}: Readonly<{
|
||||||
|
icon: IconType
|
||||||
|
color: Color
|
||||||
|
url: string
|
||||||
|
}>) {
|
||||||
const onClick = () => {
|
const onClick = () => {
|
||||||
window.open(url, "", "menubar=no,toolbar=no,resizable=yes,scrollbars=yes,width=800,height=600")
|
window.open(url, "", "menubar=no,toolbar=no,resizable=yes,scrollbars=yes,width=800,height=600")
|
||||||
}
|
}
|
||||||
@@ -44,7 +60,11 @@ function SiteShareButton({ url, icon, color }: { icon: IconType; color: Color; u
|
|||||||
return <ShareButton icon={icon} color={color} onClick={onClick} />
|
return <ShareButton icon={icon} color={color} onClick={onClick} />
|
||||||
}
|
}
|
||||||
|
|
||||||
function CopyUrlButton({ url }: { url: string }) {
|
function CopyUrlButton({
|
||||||
|
url,
|
||||||
|
}: Readonly<{
|
||||||
|
url: string
|
||||||
|
}>) {
|
||||||
return (
|
return (
|
||||||
<CopyButton value={url}>
|
<CopyButton value={url}>
|
||||||
{({ copied, copy }) => <ShareButton icon={copied ? TbCheck : TbCopy} color="#000" onClick={copy} />}
|
{({ copied, copy }) => <ShareButton icon={copied ? TbCheck : TbCopy} color="#000" onClick={copy} />}
|
||||||
@@ -52,7 +72,13 @@ function CopyUrlButton({ url }: { url: string }) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function BrowserNativeShareButton({ url, description }: { url: string; description: string }) {
|
function BrowserNativeShareButton({
|
||||||
|
url,
|
||||||
|
description,
|
||||||
|
}: Readonly<{
|
||||||
|
url: string
|
||||||
|
description: string
|
||||||
|
}>) {
|
||||||
const mobile = useMobile()
|
const mobile = useMobile()
|
||||||
const { isBrowserExtensionPopup } = useBrowserExtension()
|
const { isBrowserExtensionPopup } = useBrowserExtension()
|
||||||
const onClick = () => {
|
const onClick = () => {
|
||||||
@@ -71,9 +97,16 @@ function BrowserNativeShareButton({ url, description }: { url: string; descripti
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ShareButtons(props: { url: string; description: string }) {
|
export function ShareButtons(
|
||||||
|
props: Readonly<{
|
||||||
|
url: string
|
||||||
|
description: string
|
||||||
|
}>
|
||||||
|
) {
|
||||||
const sharingSettings = useAppSelector(state => state.user.settings?.sharingSettings)
|
const sharingSettings = useAppSelector(state => state.user.settings?.sharingSettings)
|
||||||
const enabledSharingSites = (Object.keys(Constants.sharing) as Array<keyof SharingSettings>).filter(site => sharingSettings?.[site])
|
const enabledSharingSites = (Object.keys(Constants.sharing) as Array<keyof SharingSettings>).filter(
|
||||||
|
site => sharingSettings?.[site]
|
||||||
|
)
|
||||||
const url = encodeURIComponent(props.url)
|
const url = encodeURIComponent(props.url)
|
||||||
const desc = encodeURIComponent(props.description)
|
const desc = encodeURIComponent(props.description)
|
||||||
const clipboardAvailable = typeof navigator.clipboard !== "undefined"
|
const clipboardAvailable = typeof navigator.clipboard !== "undefined"
|
||||||
@@ -88,7 +121,9 @@ export function ShareButtons(props: { url: string; description: string }) {
|
|||||||
{showNativeSection && (
|
{showNativeSection && (
|
||||||
<SimpleGrid cols={4}>
|
<SimpleGrid cols={4}>
|
||||||
{clipboardAvailable && <CopyUrlButton url={props.url} />}
|
{clipboardAvailable && <CopyUrlButton url={props.url} />}
|
||||||
{nativeSharingAvailable && <BrowserNativeShareButton url={props.url} description={props.description} />}
|
{nativeSharingAvailable && (
|
||||||
|
<BrowserNativeShareButton url={props.url} description={props.description} />
|
||||||
|
)}
|
||||||
</SimpleGrid>
|
</SimpleGrid>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -3,14 +3,14 @@ import { useLingui } from "@lingui/react"
|
|||||||
import { Trans } from "@lingui/react/macro"
|
import { Trans } from "@lingui/react/macro"
|
||||||
import { Box, Button, Group, Stack, TextInput } from "@mantine/core"
|
import { Box, Button, Group, Stack, TextInput } from "@mantine/core"
|
||||||
import { useForm } from "@mantine/form"
|
import { useForm } from "@mantine/form"
|
||||||
import { client, errorToStrings } from "app/client"
|
|
||||||
import { redirectToSelectedSource } from "app/redirect/thunks"
|
|
||||||
import { useAppDispatch } from "app/store"
|
|
||||||
import { reloadTree } from "app/tree/thunks"
|
|
||||||
import type { AddCategoryRequest } from "app/types"
|
|
||||||
import { Alert } from "components/Alert"
|
|
||||||
import { useAsyncCallback } from "react-async-hook"
|
import { useAsyncCallback } from "react-async-hook"
|
||||||
import { TbFolderPlus } from "react-icons/tb"
|
import { TbFolderPlus } from "react-icons/tb"
|
||||||
|
import { client, errorToStrings } from "@/app/client"
|
||||||
|
import { redirectToSelectedSource } from "@/app/redirect/thunks"
|
||||||
|
import { useAppDispatch } from "@/app/store"
|
||||||
|
import { reloadTree } from "@/app/tree/thunks"
|
||||||
|
import type { AddCategoryRequest } from "@/app/types"
|
||||||
|
import { Alert } from "@/components/Alert"
|
||||||
import { CategorySelect } from "./CategorySelect"
|
import { CategorySelect } from "./CategorySelect"
|
||||||
|
|
||||||
export function AddCategory() {
|
export function AddCategory() {
|
||||||
@@ -36,7 +36,12 @@ export function AddCategory() {
|
|||||||
|
|
||||||
<form onSubmit={form.onSubmit(addCategory.execute)}>
|
<form onSubmit={form.onSubmit(addCategory.execute)}>
|
||||||
<Stack>
|
<Stack>
|
||||||
<TextInput label={<Trans>Category</Trans>} placeholder={_(msg`Category`)} {...form.getInputProps("name")} required />
|
<TextInput
|
||||||
|
label={<Trans>Category</Trans>}
|
||||||
|
placeholder={_(msg`Category`)}
|
||||||
|
{...form.getInputProps("name")}
|
||||||
|
required
|
||||||
|
/>
|
||||||
<CategorySelect label={<Trans>Parent</Trans>} {...form.getInputProps("parentId")} clearable />
|
<CategorySelect label={<Trans>Parent</Trans>} {...form.getInputProps("parentId")} clearable />
|
||||||
<Group justify="center">
|
<Group justify="center">
|
||||||
<Button variant="default" onClick={async () => await dispatch(redirectToSelectedSource())}>
|
<Button variant="default" onClick={async () => await dispatch(redirectToSelectedSource())}>
|
||||||
|
|||||||
@@ -1,11 +1,10 @@
|
|||||||
import { msg } from "@lingui/core/macro"
|
import { msg } from "@lingui/core/macro"
|
||||||
import { useLingui } from "@lingui/react"
|
import { useLingui } from "@lingui/react"
|
||||||
import { Select, type SelectProps } from "@mantine/core"
|
import { type ComboboxItem, Select, type SelectProps } from "@mantine/core"
|
||||||
import type { ComboboxItem } from "@mantine/core/lib/components/Combobox/Combobox.types"
|
import { Constants } from "@/app/constants"
|
||||||
import { Constants } from "app/constants"
|
import { useAppSelector } from "@/app/store"
|
||||||
import { useAppSelector } from "app/store"
|
import type { Category } from "@/app/types"
|
||||||
import type { Category } from "app/types"
|
import { flattenCategoryTree } from "@/app/utils"
|
||||||
import { flattenCategoryTree } from "app/utils"
|
|
||||||
|
|
||||||
type CategorySelectProps = Partial<SelectProps> & {
|
type CategorySelectProps = Partial<SelectProps> & {
|
||||||
withAll?: boolean
|
withAll?: boolean
|
||||||
|
|||||||
@@ -3,13 +3,13 @@ import { useLingui } from "@lingui/react"
|
|||||||
import { Trans } from "@lingui/react/macro"
|
import { Trans } from "@lingui/react/macro"
|
||||||
import { Box, Button, FileInput, Group, Stack } from "@mantine/core"
|
import { Box, Button, FileInput, Group, Stack } from "@mantine/core"
|
||||||
import { isNotEmpty, useForm } from "@mantine/form"
|
import { isNotEmpty, useForm } from "@mantine/form"
|
||||||
import { client, errorToStrings } from "app/client"
|
|
||||||
import { redirectToSelectedSource } from "app/redirect/thunks"
|
|
||||||
import { useAppDispatch } from "app/store"
|
|
||||||
import { reloadTree } from "app/tree/thunks"
|
|
||||||
import { Alert } from "components/Alert"
|
|
||||||
import { useAsyncCallback } from "react-async-hook"
|
import { useAsyncCallback } from "react-async-hook"
|
||||||
import { TbFileImport } from "react-icons/tb"
|
import { TbFileImport } from "react-icons/tb"
|
||||||
|
import { client, errorToStrings } from "@/app/client"
|
||||||
|
import { redirectToSelectedSource } from "@/app/redirect/thunks"
|
||||||
|
import { useAppDispatch } from "@/app/store"
|
||||||
|
import { reloadTree } from "@/app/tree/thunks"
|
||||||
|
import { Alert } from "@/components/Alert"
|
||||||
|
|
||||||
export function ImportOpml() {
|
export function ImportOpml() {
|
||||||
const dispatch = useAppDispatch()
|
const dispatch = useAppDispatch()
|
||||||
@@ -44,8 +44,8 @@ export function ImportOpml() {
|
|||||||
placeholder={_(msg`OPML file`)}
|
placeholder={_(msg`OPML file`)}
|
||||||
description={
|
description={
|
||||||
<Trans>
|
<Trans>
|
||||||
An opml file is an XML file containing feed URLs and categories. You can get an OPML file by exporting your
|
An opml file is an XML file containing feed URLs and categories. You can get an OPML
|
||||||
data from other feed reading services.
|
file by exporting your data from other feed reading services.
|
||||||
</Trans>
|
</Trans>
|
||||||
}
|
}
|
||||||
{...form.getInputProps("file")}
|
{...form.getInputProps("file")}
|
||||||
|
|||||||
@@ -1,16 +1,16 @@
|
|||||||
import { Trans } from "@lingui/react/macro"
|
import { Trans } from "@lingui/react/macro"
|
||||||
import { Box, Button, Group, Stack, Stepper, TextInput } from "@mantine/core"
|
import { Box, Button, Group, Stack, Stepper, TextInput } from "@mantine/core"
|
||||||
import { useForm } from "@mantine/form"
|
import { useForm } from "@mantine/form"
|
||||||
import { client, errorToStrings } from "app/client"
|
|
||||||
import { Constants } from "app/constants"
|
|
||||||
import { redirectToFeed, redirectToSelectedSource } from "app/redirect/thunks"
|
|
||||||
import { useAppDispatch } from "app/store"
|
|
||||||
import { reloadTree } from "app/tree/thunks"
|
|
||||||
import type { FeedInfoRequest, SubscribeRequest } from "app/types"
|
|
||||||
import { Alert } from "components/Alert"
|
|
||||||
import { useState } from "react"
|
import { useState } from "react"
|
||||||
import { useAsyncCallback } from "react-async-hook"
|
import { useAsyncCallback } from "react-async-hook"
|
||||||
import { TbRss } from "react-icons/tb"
|
import { TbRss } from "react-icons/tb"
|
||||||
|
import { client, errorToStrings } from "@/app/client"
|
||||||
|
import { Constants } from "@/app/constants"
|
||||||
|
import { redirectToFeed, redirectToSelectedSource } from "@/app/redirect/thunks"
|
||||||
|
import { useAppDispatch } from "@/app/store"
|
||||||
|
import { reloadTree } from "@/app/tree/thunks"
|
||||||
|
import type { FeedInfoRequest, SubscribeRequest } from "@/app/types"
|
||||||
|
import { Alert } from "@/components/Alert"
|
||||||
import { CategorySelect } from "./CategorySelect"
|
import { CategorySelect } from "./CategorySelect"
|
||||||
|
|
||||||
export function Subscribe() {
|
export function Subscribe() {
|
||||||
@@ -39,9 +39,8 @@ export function Subscribe() {
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
const subscribe = useAsyncCallback(client.feed.subscribe, {
|
const subscribe = useAsyncCallback(client.feed.subscribe, {
|
||||||
onSuccess: async sub => {
|
onSuccess: sub => {
|
||||||
await dispatch(reloadTree())
|
dispatch(reloadTree()).then(() => dispatch(redirectToFeed(sub.data)))
|
||||||
dispatch(redirectToFeed(sub.data))
|
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -86,8 +85,8 @@ export function Subscribe() {
|
|||||||
placeholder="https://www.mysite.com/rss"
|
placeholder="https://www.mysite.com/rss"
|
||||||
description={
|
description={
|
||||||
<Trans>
|
<Trans>
|
||||||
The URL for the feed you want to subscribe to. You can also use the website's url directly and CommaFeed
|
The URL for the feed you want to subscribe to. You can also use the website's url
|
||||||
will try to find the feed in the page.
|
directly and CommaFeed will try to find the feed in the page.
|
||||||
</Trans>
|
</Trans>
|
||||||
}
|
}
|
||||||
required
|
required
|
||||||
@@ -102,8 +101,17 @@ export function Subscribe() {
|
|||||||
>
|
>
|
||||||
<Stack>
|
<Stack>
|
||||||
<TextInput label={<Trans>Feed URL</Trans>} {...step1Form.getInputProps("url")} disabled />
|
<TextInput label={<Trans>Feed URL</Trans>} {...step1Form.getInputProps("url")} disabled />
|
||||||
<TextInput label={<Trans>Feed name</Trans>} {...step1Form.getInputProps("title")} required autoFocus />
|
<TextInput
|
||||||
<CategorySelect label={<Trans>Category</Trans>} {...step1Form.getInputProps("categoryId")} clearable />
|
label={<Trans>Feed name</Trans>}
|
||||||
|
{...step1Form.getInputProps("title")}
|
||||||
|
required
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
<CategorySelect
|
||||||
|
label={<Trans>Category</Trans>}
|
||||||
|
{...step1Form.getInputProps("categoryId")}
|
||||||
|
clearable
|
||||||
|
/>
|
||||||
</Stack>
|
</Stack>
|
||||||
</Stepper.Step>
|
</Stepper.Step>
|
||||||
</Stepper>
|
</Stepper>
|
||||||
@@ -118,7 +126,11 @@ export function Subscribe() {
|
|||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
{activeStep === 1 && (
|
{activeStep === 1 && (
|
||||||
<Button type="submit" leftSection={<TbRss size={16} />} loading={fetchFeed.loading || subscribe.loading}>
|
<Button
|
||||||
|
type="submit"
|
||||||
|
leftSection={<TbRss size={16} />}
|
||||||
|
loading={fetchFeed.loading || subscribe.loading}
|
||||||
|
>
|
||||||
<Trans>Subscribe</Trans>
|
<Trans>Subscribe</Trans>
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -0,0 +1,127 @@
|
|||||||
|
import { Stack } from "@mantine/core"
|
||||||
|
import { MantineValueSelector, QueryBuilderMantine } from "@react-querybuilder/mantine"
|
||||||
|
import {
|
||||||
|
type CombinatorSelectorProps,
|
||||||
|
defaultOperators,
|
||||||
|
defaultRuleProcessorCEL,
|
||||||
|
type Field,
|
||||||
|
type FormatQueryOptions,
|
||||||
|
formatQuery,
|
||||||
|
QueryBuilder,
|
||||||
|
type RuleGroupType,
|
||||||
|
} from "react-querybuilder"
|
||||||
|
import { isCELIdentifier, isCELMember, isCELStringLiteral, parseCEL } from "react-querybuilder/parseCEL"
|
||||||
|
import "react-querybuilder/dist/query-builder.css"
|
||||||
|
|
||||||
|
const fields: Field[] = [
|
||||||
|
{ name: "title", label: "Title" },
|
||||||
|
{ name: "content", label: "Content" },
|
||||||
|
{ name: "url", label: "URL" },
|
||||||
|
{ name: "author", label: "Author" },
|
||||||
|
{ name: "categories", label: "Categories" },
|
||||||
|
{ name: "titleLower", label: "Title (lower case)" },
|
||||||
|
{ name: "contentLower", label: "Content (lower case)" },
|
||||||
|
{ name: "urlLower", label: "URL (lower case)" },
|
||||||
|
{ name: "authorLower", label: "Author (lower case)" },
|
||||||
|
{ name: "categoriesLower", label: "Categories (lower case)" },
|
||||||
|
]
|
||||||
|
|
||||||
|
const textOperators = new Set([
|
||||||
|
"=",
|
||||||
|
"!=",
|
||||||
|
"contains",
|
||||||
|
"beginsWith",
|
||||||
|
"endsWith",
|
||||||
|
"doesNotContain",
|
||||||
|
"doesNotBeginWith",
|
||||||
|
"doesNotEndWith",
|
||||||
|
])
|
||||||
|
|
||||||
|
function toCelString(query: RuleGroupType): string {
|
||||||
|
if (query.rules.length === 0) {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
const celFormatOptions: FormatQueryOptions = {
|
||||||
|
format: "cel",
|
||||||
|
ruleProcessor: (rule, options, meta) => {
|
||||||
|
if (rule.operator === "matches") {
|
||||||
|
const escapedValue = String(rule.value).replaceAll("\\", "\\\\").replaceAll('"', String.raw`\"`)
|
||||||
|
return `${rule.field}.matches("${escapedValue}")`
|
||||||
|
}
|
||||||
|
|
||||||
|
return defaultRuleProcessorCEL(rule, options, meta)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
return formatQuery(query, celFormatOptions)
|
||||||
|
}
|
||||||
|
|
||||||
|
function fromCelString(celString: string): RuleGroupType {
|
||||||
|
return parseCEL(celString ?? "", {
|
||||||
|
customExpressionHandler: expr => {
|
||||||
|
if (
|
||||||
|
isCELMember(expr) &&
|
||||||
|
expr.right?.value === "matches" &&
|
||||||
|
expr.left &&
|
||||||
|
isCELIdentifier(expr.left) &&
|
||||||
|
expr.list &&
|
||||||
|
isCELStringLiteral(expr.list.value[0])
|
||||||
|
) {
|
||||||
|
return {
|
||||||
|
field: expr.left.value,
|
||||||
|
operator: "matches",
|
||||||
|
value: JSON.parse(expr.list.value[0].value),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const getOperators = () => {
|
||||||
|
const filteredDefault = defaultOperators.filter(op => textOperators.has(op.name))
|
||||||
|
return [
|
||||||
|
...filteredDefault,
|
||||||
|
{
|
||||||
|
name: "matches",
|
||||||
|
label: "matches pattern",
|
||||||
|
},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
function CombinatorSelector(props: Readonly<CombinatorSelectorProps>) {
|
||||||
|
if (props.rules.length === 0) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
return <MantineValueSelector {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
interface FilteringExpressionEditorProps {
|
||||||
|
initialValue: string | undefined
|
||||||
|
onChange: (value: string) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function FilteringExpressionEditor({ initialValue, onChange }: Readonly<FilteringExpressionEditorProps>) {
|
||||||
|
const handleQueryChange = (newQuery: RuleGroupType) => {
|
||||||
|
onChange(toCelString(newQuery))
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Stack gap="sm">
|
||||||
|
<QueryBuilderMantine>
|
||||||
|
<QueryBuilder
|
||||||
|
fields={fields}
|
||||||
|
defaultQuery={fromCelString(initialValue ?? "")}
|
||||||
|
onQueryChange={handleQueryChange}
|
||||||
|
getOperators={getOperators}
|
||||||
|
addRuleToNewGroups
|
||||||
|
resetOnFieldChange={false}
|
||||||
|
controlClassnames={{ queryBuilder: "queryBuilder-branches" }}
|
||||||
|
controlElements={{ combinatorSelector: CombinatorSelector }}
|
||||||
|
/>
|
||||||
|
</QueryBuilderMantine>
|
||||||
|
</Stack>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,11 +1,11 @@
|
|||||||
import { Box, Text } from "@mantine/core"
|
import { Box } from "@mantine/core"
|
||||||
import type { Entry } from "app/types"
|
import type { Entry } from "@/app/types"
|
||||||
import { RelativeDate } from "components/RelativeDate"
|
import { FeedFavicon } from "@/components/content/FeedFavicon"
|
||||||
import { FeedFavicon } from "components/content/FeedFavicon"
|
import { OpenExternalLink } from "@/components/content/header/OpenExternalLink"
|
||||||
import { OpenExternalLink } from "components/content/header/OpenExternalLink"
|
import { Star } from "@/components/content/header/Star"
|
||||||
import { Star } from "components/content/header/Star"
|
import { RelativeDate } from "@/components/RelativeDate"
|
||||||
import { OnDesktop } from "components/responsive/OnDesktop"
|
import { OnDesktop } from "@/components/responsive/OnDesktop"
|
||||||
import { tss } from "tss"
|
import { tss } from "@/tss"
|
||||||
import { FeedEntryTitle } from "./FeedEntryTitle"
|
import { FeedEntryTitle } from "./FeedEntryTitle"
|
||||||
|
|
||||||
export interface FeedEntryHeaderProps {
|
export interface FeedEntryHeaderProps {
|
||||||
@@ -43,7 +43,7 @@ const useStyles = tss
|
|||||||
},
|
},
|
||||||
}))
|
}))
|
||||||
|
|
||||||
export function FeedEntryCompactHeader(props: FeedEntryHeaderProps) {
|
export function FeedEntryCompactHeader(props: Readonly<FeedEntryHeaderProps>) {
|
||||||
const { classes } = useStyles({
|
const { classes } = useStyles({
|
||||||
read: props.entry.read,
|
read: props.entry.read,
|
||||||
})
|
})
|
||||||
@@ -54,17 +54,17 @@ export function FeedEntryCompactHeader(props: FeedEntryHeaderProps) {
|
|||||||
<FeedFavicon url={props.entry.iconUrl} />
|
<FeedFavicon url={props.entry.iconUrl} />
|
||||||
</Box>
|
</Box>
|
||||||
<OnDesktop>
|
<OnDesktop>
|
||||||
<Text c="dimmed" className={classes.feedName}>
|
<Box c="dimmed" className={classes.feedName}>
|
||||||
{props.entry.feedName}
|
{props.entry.feedName}
|
||||||
</Text>
|
</Box>
|
||||||
</OnDesktop>
|
</OnDesktop>
|
||||||
<Box className={classes.title}>
|
<Box className={classes.title}>
|
||||||
<FeedEntryTitle entry={props.entry} />
|
<FeedEntryTitle entry={props.entry} />
|
||||||
</Box>
|
</Box>
|
||||||
<OnDesktop>
|
<OnDesktop>
|
||||||
<Text c="dimmed" className={classes.date}>
|
<Box c="dimmed" className={classes.date}>
|
||||||
<RelativeDate date={props.entry.date} />
|
<RelativeDate date={props.entry.date} />
|
||||||
</Text>
|
</Box>
|
||||||
</OnDesktop>
|
</OnDesktop>
|
||||||
{props.showExternalLinkIcon && <OpenExternalLink entry={props.entry} />}
|
{props.showExternalLinkIcon && <OpenExternalLink entry={props.entry} />}
|
||||||
</Box>
|
</Box>
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
import { Box, Flex, Space, Text } from "@mantine/core"
|
import { Box, Flex, Space } from "@mantine/core"
|
||||||
import type { Entry } from "app/types"
|
import type { Entry } from "@/app/types"
|
||||||
import { RelativeDate } from "components/RelativeDate"
|
import { FeedFavicon } from "@/components/content/FeedFavicon"
|
||||||
import { FeedFavicon } from "components/content/FeedFavicon"
|
import { OpenExternalLink } from "@/components/content/header/OpenExternalLink"
|
||||||
import { OpenExternalLink } from "components/content/header/OpenExternalLink"
|
import { Star } from "@/components/content/header/Star"
|
||||||
import { Star } from "components/content/header/Star"
|
import { RelativeDate } from "@/components/RelativeDate"
|
||||||
import { tss } from "tss"
|
import { tss } from "@/tss"
|
||||||
import { FeedEntryTitle } from "./FeedEntryTitle"
|
import { FeedEntryTitle } from "./FeedEntryTitle"
|
||||||
|
|
||||||
export interface FeedEntryHeaderProps {
|
export interface FeedEntryHeaderProps {
|
||||||
@@ -22,18 +22,15 @@ const useStyles = tss
|
|||||||
main: {
|
main: {
|
||||||
fontWeight: colorScheme === "light" && !read ? "bold" : "inherit",
|
fontWeight: colorScheme === "light" && !read ? "bold" : "inherit",
|
||||||
},
|
},
|
||||||
details: {
|
|
||||||
fontSize: "90%",
|
|
||||||
},
|
|
||||||
}))
|
}))
|
||||||
|
|
||||||
export function FeedEntryHeader(props: FeedEntryHeaderProps) {
|
export function FeedEntryHeader(props: Readonly<FeedEntryHeaderProps>) {
|
||||||
const { classes } = useStyles({
|
const { classes } = useStyles({
|
||||||
read: props.entry.read,
|
read: props.entry.read,
|
||||||
})
|
})
|
||||||
return (
|
return (
|
||||||
<Box>
|
<Box className="cf-header">
|
||||||
<Flex align="flex-start" justify="space-between">
|
<Flex align="flex-start" justify="space-between" className="cf-header-title">
|
||||||
<Flex align="flex-start" className={classes.main}>
|
<Flex align="flex-start" className={classes.main}>
|
||||||
{props.showStarIcon && (
|
{props.showStarIcon && (
|
||||||
<Box ml={-5}>
|
<Box ml={-5}>
|
||||||
@@ -44,22 +41,20 @@ export function FeedEntryHeader(props: FeedEntryHeaderProps) {
|
|||||||
</Flex>
|
</Flex>
|
||||||
{props.showExternalLinkIcon && <OpenExternalLink entry={props.entry} />}
|
{props.showExternalLinkIcon && <OpenExternalLink entry={props.entry} />}
|
||||||
</Flex>
|
</Flex>
|
||||||
<Flex align="center" className={classes.details}>
|
<Flex align="center" className="cf-header-subtitle">
|
||||||
<FeedFavicon url={props.entry.iconUrl} />
|
<FeedFavicon url={props.entry.iconUrl} />
|
||||||
<Space w={6} />
|
<Space w={6} />
|
||||||
<Text c="dimmed">
|
<Box c="dimmed">
|
||||||
{props.entry.feedName}
|
{props.entry.feedName}
|
||||||
<span> · </span>
|
<span> · </span>
|
||||||
<RelativeDate date={props.entry.date} />
|
<RelativeDate date={props.entry.date} />
|
||||||
</Text>
|
</Box>
|
||||||
</Flex>
|
</Flex>
|
||||||
{props.expanded && (
|
{props.expanded && (
|
||||||
<Box className={classes.details}>
|
<Box className="cf-header-details">
|
||||||
<Text c="dimmed">
|
{props.entry.author && <span>by {props.entry.author}</span>}
|
||||||
{props.entry.author && <span>by {props.entry.author}</span>}
|
{props.entry.author && props.entry.categories && <span> · </span>}
|
||||||
{props.entry.author && props.entry.categories && <span> · </span>}
|
{props.entry.categories && <span>{props.entry.categories}</span>}
|
||||||
{props.entry.categories && <span>{props.entry.categories}</span>}
|
|
||||||
</Text>
|
|
||||||
</Box>
|
</Box>
|
||||||
)}
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
|
|||||||
@@ -1,20 +1,30 @@
|
|||||||
import { Highlight } from "@mantine/core"
|
import { Highlight } from "@mantine/core"
|
||||||
import { useAppSelector } from "app/store"
|
import { useAppSelector } from "@/app/store"
|
||||||
import type { Entry } from "app/types"
|
import type { Entry } from "@/app/types"
|
||||||
|
import { tss } from "@/tss"
|
||||||
|
|
||||||
export interface FeedEntryTitleProps {
|
export interface FeedEntryTitleProps {
|
||||||
entry: Entry
|
entry: Entry
|
||||||
}
|
}
|
||||||
|
|
||||||
export function FeedEntryTitle(props: FeedEntryTitleProps) {
|
const useStyles = tss.create(() => ({
|
||||||
|
content: {
|
||||||
|
textWrap: "inherit",
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
|
||||||
|
export function FeedEntryTitle(props: Readonly<FeedEntryTitleProps>) {
|
||||||
const search = useAppSelector(state => state.entries.search)
|
const search = useAppSelector(state => state.entries.search)
|
||||||
const keywords = search?.split(" ")
|
const keywords = search?.split(" ")
|
||||||
|
|
||||||
|
const { classes } = useStyles()
|
||||||
return (
|
return (
|
||||||
<Highlight
|
<Highlight
|
||||||
inherit
|
inherit
|
||||||
highlight={keywords ?? ""}
|
highlight={keywords ?? ""}
|
||||||
// make sure ellipsis is shown when title is too long
|
// make sure ellipsis is shown when title is too long
|
||||||
span
|
span
|
||||||
|
className={classes.content}
|
||||||
>
|
>
|
||||||
{props.entry.title}
|
{props.entry.title}
|
||||||
</Highlight>
|
</Highlight>
|
||||||
|
|||||||
@@ -1,12 +1,16 @@
|
|||||||
import { Trans } from "@lingui/react/macro"
|
import { Trans } from "@lingui/react/macro"
|
||||||
import { ActionIcon, Anchor, Tooltip } from "@mantine/core"
|
import { ActionIcon, Anchor, Tooltip } from "@mantine/core"
|
||||||
import { Constants } from "app/constants"
|
|
||||||
import { markEntry } from "app/entries/thunks"
|
|
||||||
import { useAppDispatch } from "app/store"
|
|
||||||
import type { Entry } from "app/types"
|
|
||||||
import { TbExternalLink } from "react-icons/tb"
|
import { TbExternalLink } from "react-icons/tb"
|
||||||
|
import { Constants } from "@/app/constants"
|
||||||
|
import { markEntry } from "@/app/entries/thunks"
|
||||||
|
import { useAppDispatch } from "@/app/store"
|
||||||
|
import type { Entry } from "@/app/types"
|
||||||
|
|
||||||
export function OpenExternalLink(props: { entry: Entry }) {
|
export function OpenExternalLink(
|
||||||
|
props: Readonly<{
|
||||||
|
entry: Entry
|
||||||
|
}>
|
||||||
|
) {
|
||||||
const dispatch = useAppDispatch()
|
const dispatch = useAppDispatch()
|
||||||
const onClick = (e: React.MouseEvent) => {
|
const onClick = (e: React.MouseEvent) => {
|
||||||
e.stopPropagation()
|
e.stopPropagation()
|
||||||
|
|||||||
@@ -1,12 +1,16 @@
|
|||||||
import { Trans } from "@lingui/react/macro"
|
import { Trans } from "@lingui/react/macro"
|
||||||
import { ActionIcon, Tooltip } from "@mantine/core"
|
import { ActionIcon, Tooltip } from "@mantine/core"
|
||||||
import { Constants } from "app/constants"
|
|
||||||
import { starEntry } from "app/entries/thunks"
|
|
||||||
import { useAppDispatch } from "app/store"
|
|
||||||
import type { Entry } from "app/types"
|
|
||||||
import { TbStar, TbStarFilled } from "react-icons/tb"
|
import { TbStar, TbStarFilled } from "react-icons/tb"
|
||||||
|
import { Constants } from "@/app/constants"
|
||||||
|
import { starEntry } from "@/app/entries/thunks"
|
||||||
|
import { useAppDispatch } from "@/app/store"
|
||||||
|
import type { Entry } from "@/app/types"
|
||||||
|
|
||||||
export function Star(props: { entry: Entry }) {
|
export function Star(
|
||||||
|
props: Readonly<{
|
||||||
|
entry: Entry
|
||||||
|
}>
|
||||||
|
) {
|
||||||
const dispatch = useAppDispatch()
|
const dispatch = useAppDispatch()
|
||||||
const onClick = (e: React.MouseEvent) => {
|
const onClick = (e: React.MouseEvent) => {
|
||||||
e.stopPropagation()
|
e.stopPropagation()
|
||||||
@@ -20,7 +24,10 @@ export function Star(props: { entry: Entry }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Tooltip label={props.entry.starred ? <Trans>Unstar</Trans> : <Trans>Star</Trans>} openDelay={Constants.tooltip.delay}>
|
<Tooltip
|
||||||
|
label={props.entry.starred ? <Trans>Unstar</Trans> : <Trans>Star</Trans>}
|
||||||
|
openDelay={Constants.tooltip.delay}
|
||||||
|
>
|
||||||
<ActionIcon variant="transparent" onClick={onClick}>
|
<ActionIcon variant="transparent" onClick={onClick}>
|
||||||
{props.entry.starred ? <TbStarFilled size={18} /> : <TbStar size={18} />}
|
{props.entry.starred ? <TbStarFilled size={18} /> : <TbStar size={18} />}
|
||||||
</ActionIcon>
|
</ActionIcon>
|
||||||
|
|||||||
@@ -2,18 +2,11 @@ import { msg } from "@lingui/core/macro"
|
|||||||
import { useLingui } from "@lingui/react"
|
import { useLingui } from "@lingui/react"
|
||||||
import { Box, Center, CloseButton, Divider, Group, Indicator, Popover, TextInput } from "@mantine/core"
|
import { Box, Center, CloseButton, Divider, Group, Indicator, Popover, TextInput } from "@mantine/core"
|
||||||
import { useForm } from "@mantine/form"
|
import { useForm } from "@mantine/form"
|
||||||
import { reloadEntries, search, selectNextEntry, selectPreviousEntry } from "app/entries/thunks"
|
import { type ReactNode, useEffect } from "react"
|
||||||
import { useAppDispatch, useAppSelector } from "app/store"
|
|
||||||
import { changeReadingMode, changeReadingOrder } from "app/user/thunks"
|
|
||||||
import { ActionButton } from "components/ActionButton"
|
|
||||||
import { Loader } from "components/Loader"
|
|
||||||
import { useActionButton } from "hooks/useActionButton"
|
|
||||||
import { useBrowserExtension } from "hooks/useBrowserExtension"
|
|
||||||
import { useMobile } from "hooks/useMobile"
|
|
||||||
import { useEffect } from "react"
|
|
||||||
import {
|
import {
|
||||||
TbArrowDown,
|
TbArrowDown,
|
||||||
TbArrowUp,
|
TbArrowUp,
|
||||||
|
TbChecks,
|
||||||
TbExternalLink,
|
TbExternalLink,
|
||||||
TbEye,
|
TbEye,
|
||||||
TbEyeOff,
|
TbEyeOff,
|
||||||
@@ -24,14 +17,27 @@ import {
|
|||||||
TbSortDescending,
|
TbSortDescending,
|
||||||
TbUser,
|
TbUser,
|
||||||
} from "react-icons/tb"
|
} from "react-icons/tb"
|
||||||
import { MarkAllAsReadButton } from "./MarkAllAsReadButton"
|
import {
|
||||||
|
markAllAsReadWithConfirmationIfRequired,
|
||||||
|
reloadEntries,
|
||||||
|
search,
|
||||||
|
selectNextEntry,
|
||||||
|
selectPreviousEntry,
|
||||||
|
} from "@/app/entries/thunks"
|
||||||
|
import { useAppDispatch, useAppSelector } from "@/app/store"
|
||||||
|
import { changeSettings } from "@/app/user/thunks"
|
||||||
|
import { ActionButton } from "@/components/ActionButton"
|
||||||
|
import { Loader } from "@/components/Loader"
|
||||||
|
import { useActionButton } from "@/hooks/useActionButton"
|
||||||
|
import { useBrowserExtension } from "@/hooks/useBrowserExtension"
|
||||||
|
import { useMobile } from "@/hooks/useMobile"
|
||||||
import { ProfileMenu } from "./ProfileMenu"
|
import { ProfileMenu } from "./ProfileMenu"
|
||||||
|
|
||||||
function HeaderDivider() {
|
function HeaderDivider() {
|
||||||
return <Divider orientation="vertical" />
|
return <Divider orientation="vertical" />
|
||||||
}
|
}
|
||||||
|
|
||||||
function HeaderToolbar(props: { children: React.ReactNode }) {
|
function HeaderToolbar(props: { children: ReactNode }) {
|
||||||
const { spacing } = useActionButton()
|
const { spacing } = useActionButton()
|
||||||
const mobile = useMobile("480px")
|
const mobile = useMobile("480px")
|
||||||
return mobile ? (
|
return mobile ? (
|
||||||
@@ -42,11 +48,14 @@ function HeaderToolbar(props: { children: React.ReactNode }) {
|
|||||||
display: "flex",
|
display: "flex",
|
||||||
justifyContent: "space-between",
|
justifyContent: "space-between",
|
||||||
}}
|
}}
|
||||||
|
className="cf-toolbar"
|
||||||
>
|
>
|
||||||
{props.children}
|
{props.children}
|
||||||
</Box>
|
</Box>
|
||||||
) : (
|
) : (
|
||||||
<Group gap={spacing}>{props.children}</Group>
|
<Group gap={spacing} wrap="nowrap" className="cf-toolbar">
|
||||||
|
{props.children}
|
||||||
|
</Group>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -60,11 +69,7 @@ export function Header() {
|
|||||||
const dispatch = useAppDispatch()
|
const dispatch = useAppDispatch()
|
||||||
const { _ } = useLingui()
|
const { _ } = useLingui()
|
||||||
|
|
||||||
const searchForm = useForm<{ search: string }>({
|
const searchForm = useForm<{ search: string }>()
|
||||||
validate: {
|
|
||||||
search: value => (value.length > 0 && value.length < 3 ? _(msg`Search requires at least 3 characters`) : null),
|
|
||||||
},
|
|
||||||
})
|
|
||||||
const { setValues } = searchForm
|
const { setValues } = searchForm
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -74,8 +79,27 @@ export function Header() {
|
|||||||
}, [setValues, searchFromStore])
|
}, [setValues, searchFromStore])
|
||||||
|
|
||||||
if (!settings) return <Loader />
|
if (!settings) return <Loader />
|
||||||
|
|
||||||
|
const toggleReadingMode = async () => {
|
||||||
|
await dispatch(
|
||||||
|
changeSettings({
|
||||||
|
readingMode: settings.readingMode === "all" ? "unread" : "all",
|
||||||
|
})
|
||||||
|
)
|
||||||
|
dispatch(reloadEntries())
|
||||||
|
}
|
||||||
|
|
||||||
|
const toggleReadingOrder = async () => {
|
||||||
|
await dispatch(
|
||||||
|
changeSettings({
|
||||||
|
readingOrder: settings.readingOrder === "asc" ? "desc" : "asc",
|
||||||
|
})
|
||||||
|
)
|
||||||
|
dispatch(reloadEntries())
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Center>
|
<Center className="cf-toolbar-wrapper">
|
||||||
<HeaderToolbar>
|
<HeaderToolbar>
|
||||||
<ActionButton
|
<ActionButton
|
||||||
icon={<TbArrowUp size={iconSize} />}
|
icon={<TbArrowUp size={iconSize} />}
|
||||||
@@ -111,19 +135,29 @@ export function Header() {
|
|||||||
label={msg`Refresh`}
|
label={msg`Refresh`}
|
||||||
onClick={async () => await dispatch(reloadEntries())}
|
onClick={async () => await dispatch(reloadEntries())}
|
||||||
/>
|
/>
|
||||||
<MarkAllAsReadButton iconSize={iconSize} />
|
<ActionButton
|
||||||
|
icon={<TbChecks size={iconSize} />}
|
||||||
|
label={msg`Mark all as read`}
|
||||||
|
onClick={() => dispatch(markAllAsReadWithConfirmationIfRequired())}
|
||||||
|
/>
|
||||||
|
|
||||||
<HeaderDivider />
|
<HeaderDivider />
|
||||||
|
|
||||||
<ActionButton
|
<ActionButton
|
||||||
icon={settings.readingMode === "all" ? <TbEye size={iconSize} /> : <TbEyeOff size={iconSize} />}
|
icon={settings.readingMode === "all" ? <TbEye size={iconSize} /> : <TbEyeOff size={iconSize} />}
|
||||||
label={settings.readingMode === "all" ? msg`All` : msg`Unread`}
|
label={settings.readingMode === "all" ? msg`All` : msg`Unread`}
|
||||||
onClick={async () => await dispatch(changeReadingMode(settings.readingMode === "all" ? "unread" : "all"))}
|
onClick={toggleReadingMode}
|
||||||
/>
|
/>
|
||||||
<ActionButton
|
<ActionButton
|
||||||
icon={settings.readingOrder === "asc" ? <TbSortAscending size={iconSize} /> : <TbSortDescending size={iconSize} />}
|
icon={
|
||||||
|
settings.readingOrder === "asc" ? (
|
||||||
|
<TbSortAscending size={iconSize} />
|
||||||
|
) : (
|
||||||
|
<TbSortDescending size={iconSize} />
|
||||||
|
)
|
||||||
|
}
|
||||||
label={settings.readingOrder === "asc" ? msg`Asc` : msg`Desc`}
|
label={settings.readingOrder === "asc" ? msg`Asc` : msg`Desc`}
|
||||||
onClick={async () => await dispatch(changeReadingOrder(settings.readingOrder === "asc" ? "desc" : "asc"))}
|
onClick={toggleReadingOrder}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Popover>
|
<Popover>
|
||||||
@@ -138,7 +172,11 @@ export function Header() {
|
|||||||
placeholder={_(msg`Search`)}
|
placeholder={_(msg`Search`)}
|
||||||
{...searchForm.getInputProps("search")}
|
{...searchForm.getInputProps("search")}
|
||||||
leftSection={<TbSearch size={iconSize} />}
|
leftSection={<TbSearch size={iconSize} />}
|
||||||
rightSection={<CloseButton onClick={async () => await (searchFromStore && dispatch(search("")))} />}
|
rightSection={
|
||||||
|
<CloseButton
|
||||||
|
onClick={async () => await (searchFromStore && dispatch(search("")))}
|
||||||
|
/>
|
||||||
|
}
|
||||||
autoFocus
|
autoFocus
|
||||||
/>
|
/>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
@@ -1,98 +0,0 @@
|
|||||||
import { msg } from "@lingui/core/macro"
|
|
||||||
import { Trans } from "@lingui/react/macro"
|
|
||||||
|
|
||||||
import { Button, Code, Group, Modal, Slider, Stack, Text } from "@mantine/core"
|
|
||||||
import { markAllEntries } from "app/entries/thunks"
|
|
||||||
import { useAppDispatch, useAppSelector } from "app/store"
|
|
||||||
import { ActionButton } from "components/ActionButton"
|
|
||||||
import { useState } from "react"
|
|
||||||
import { TbChecks } from "react-icons/tb"
|
|
||||||
|
|
||||||
export function MarkAllAsReadButton(props: { iconSize: number }) {
|
|
||||||
const [opened, setOpened] = useState(false)
|
|
||||||
const [threshold, setThreshold] = useState(0)
|
|
||||||
const source = useAppSelector(state => state.entries.source)
|
|
||||||
const sourceLabel = useAppSelector(state => state.entries.sourceLabel)
|
|
||||||
const entriesTimestamp = useAppSelector(state => state.entries.timestamp) ?? Date.now()
|
|
||||||
const markAllAsReadConfirmation = useAppSelector(state => state.user.settings?.markAllAsReadConfirmation)
|
|
||||||
const dispatch = useAppDispatch()
|
|
||||||
|
|
||||||
const buttonClicked = () => {
|
|
||||||
if (markAllAsReadConfirmation) {
|
|
||||||
setThreshold(0)
|
|
||||||
setOpened(true)
|
|
||||||
} else {
|
|
||||||
dispatch(
|
|
||||||
markAllEntries({
|
|
||||||
sourceType: source.type,
|
|
||||||
req: {
|
|
||||||
id: source.id,
|
|
||||||
read: true,
|
|
||||||
olderThan: Date.now(),
|
|
||||||
insertedBefore: entriesTimestamp,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<Modal opened={opened} onClose={() => setOpened(false)} title={<Trans>Mark all entries as read</Trans>}>
|
|
||||||
<Stack>
|
|
||||||
<Text size="sm">
|
|
||||||
{threshold === 0 && (
|
|
||||||
<Trans>
|
|
||||||
Are you sure you want to mark all entries of <Code>{sourceLabel}</Code> as read?
|
|
||||||
</Trans>
|
|
||||||
)}
|
|
||||||
{threshold > 0 && (
|
|
||||||
<Trans>
|
|
||||||
Are you sure you want to mark entries older than {threshold} days of <Code>{sourceLabel}</Code> as read?
|
|
||||||
</Trans>
|
|
||||||
)}
|
|
||||||
</Text>
|
|
||||||
<Slider
|
|
||||||
py="xl"
|
|
||||||
min={0}
|
|
||||||
max={28}
|
|
||||||
marks={[
|
|
||||||
{ value: 0, label: "0" },
|
|
||||||
{ value: 7, label: "7" },
|
|
||||||
{ value: 14, label: "14" },
|
|
||||||
{ value: 21, label: "21" },
|
|
||||||
{ value: 28, label: "28" },
|
|
||||||
]}
|
|
||||||
value={threshold}
|
|
||||||
onChange={setThreshold}
|
|
||||||
/>
|
|
||||||
<Group justify="flex-end">
|
|
||||||
<Button variant="default" onClick={() => setOpened(false)}>
|
|
||||||
<Trans>Cancel</Trans>
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
color="red"
|
|
||||||
onClick={() => {
|
|
||||||
setOpened(false)
|
|
||||||
dispatch(
|
|
||||||
markAllEntries({
|
|
||||||
sourceType: source.type,
|
|
||||||
req: {
|
|
||||||
id: source.id,
|
|
||||||
read: true,
|
|
||||||
olderThan: Date.now() - threshold * 24 * 60 * 60 * 1000,
|
|
||||||
insertedBefore: entriesTimestamp,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
)
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Trans>Confirm</Trans>
|
|
||||||
</Button>
|
|
||||||
</Group>
|
|
||||||
</Stack>
|
|
||||||
</Modal>
|
|
||||||
<ActionButton icon={<TbChecks size={props.iconSize} />} label={msg`Mark all as read`} onClick={buttonClicked} />
|
|
||||||
</>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -7,18 +7,12 @@ import {
|
|||||||
Menu,
|
Menu,
|
||||||
SegmentedControl,
|
SegmentedControl,
|
||||||
type SegmentedControlItem,
|
type SegmentedControlItem,
|
||||||
|
Slider,
|
||||||
useMantineColorScheme,
|
useMantineColorScheme,
|
||||||
} from "@mantine/core"
|
} from "@mantine/core"
|
||||||
import { showNotification } from "@mantine/notifications"
|
import { showNotification } from "@mantine/notifications"
|
||||||
import { client } from "app/client"
|
|
||||||
import { redirectToAbout, redirectToAdminUsers, redirectToDonate, redirectToMetrics, redirectToSettings } from "app/redirect/thunks"
|
|
||||||
import { useAppDispatch, useAppSelector } from "app/store"
|
|
||||||
import type { ViewMode } from "app/types"
|
|
||||||
import { setViewMode } from "app/user/slice"
|
|
||||||
import { reloadProfile } from "app/user/thunks"
|
|
||||||
import dayjs from "dayjs"
|
import dayjs from "dayjs"
|
||||||
import { useNow } from "hooks/useNow"
|
import { type ReactNode, useEffect, useState } from "react"
|
||||||
import { type ReactNode, useState } from "react"
|
|
||||||
import {
|
import {
|
||||||
TbChartLine,
|
TbChartLine,
|
||||||
TbHeartFilled,
|
TbHeartFilled,
|
||||||
@@ -35,6 +29,20 @@ import {
|
|||||||
TbUsers,
|
TbUsers,
|
||||||
TbWorldDownload,
|
TbWorldDownload,
|
||||||
} from "react-icons/tb"
|
} from "react-icons/tb"
|
||||||
|
import { throttle } from "throttle-debounce"
|
||||||
|
import { client } from "@/app/client"
|
||||||
|
import {
|
||||||
|
redirectToAbout,
|
||||||
|
redirectToAdminUsers,
|
||||||
|
redirectToDonate,
|
||||||
|
redirectToMetrics,
|
||||||
|
redirectToSettings,
|
||||||
|
} from "@/app/redirect/thunks"
|
||||||
|
import { useAppDispatch, useAppSelector } from "@/app/store"
|
||||||
|
import type { ViewMode } from "@/app/types"
|
||||||
|
import { setFontSizePercentage, setViewMode } from "@/app/user/slice"
|
||||||
|
import { reloadProfile } from "@/app/user/thunks"
|
||||||
|
import { useNow } from "@/hooks/useNow"
|
||||||
|
|
||||||
interface ProfileMenuProps {
|
interface ProfileMenuProps {
|
||||||
control: React.ReactElement
|
control: React.ReactElement
|
||||||
@@ -93,13 +101,22 @@ const viewModeData: ViewModeControlItem[] = [
|
|||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
export function ProfileMenu(props: ProfileMenuProps) {
|
export function ProfileMenu(props: Readonly<ProfileMenuProps>) {
|
||||||
const [opened, setOpened] = useState(false)
|
const [opened, setOpened] = useState(false)
|
||||||
|
|
||||||
|
// close profile menu on scroll
|
||||||
|
useEffect(() => {
|
||||||
|
const listener = throttle(100, () => setOpened(false))
|
||||||
|
window.addEventListener("scroll", listener)
|
||||||
|
return () => window.removeEventListener("scroll", listener)
|
||||||
|
}, [])
|
||||||
|
|
||||||
const now = useNow()
|
const now = useNow()
|
||||||
const profile = useAppSelector(state => state.user.profile)
|
const profile = useAppSelector(state => state.user.profile)
|
||||||
const admin = useAppSelector(state => state.user.profile?.admin)
|
const admin = useAppSelector(state => state.user.profile?.admin)
|
||||||
const viewMode = useAppSelector(state => state.user.localSettings.viewMode)
|
const viewMode = useAppSelector(state => state.user.localSettings.viewMode)
|
||||||
const forceRefreshCooldownDuration = useAppSelector(state => state.server.serverInfos?.forceRefreshCooldownDuration)
|
const forceRefreshCooldownDuration = useAppSelector(state => state.server.serverInfos?.forceRefreshCooldownDuration)
|
||||||
|
const fontSizePercentage = useAppSelector(state => state.user.localSettings.fontSizePercentage)
|
||||||
const dispatch = useAppDispatch()
|
const dispatch = useAppDispatch()
|
||||||
const { colorScheme, setColorScheme } = useMantineColorScheme()
|
const { colorScheme, setColorScheme } = useMantineColorScheme()
|
||||||
|
|
||||||
@@ -113,7 +130,13 @@ export function ProfileMenu(props: ProfileMenuProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Menu position="bottom-end" closeOnItemClick={false} opened={opened} onChange={setOpened}>
|
<Menu
|
||||||
|
position="bottom-end"
|
||||||
|
closeOnItemClick={false}
|
||||||
|
opened={opened}
|
||||||
|
onChange={setOpened}
|
||||||
|
preventPositionChangeWhenVisible={false}
|
||||||
|
>
|
||||||
<Menu.Target>{props.control}</Menu.Target>
|
<Menu.Target>{props.control}</Menu.Target>
|
||||||
<Menu.Dropdown>
|
<Menu.Dropdown>
|
||||||
{profile && <Menu.Label>{profile.name}</Menu.Label>}
|
{profile && <Menu.Label>{profile.name}</Menu.Label>}
|
||||||
@@ -143,7 +166,7 @@ export function ProfileMenu(props: ProfileMenuProps) {
|
|||||||
color: "green",
|
color: "green",
|
||||||
autoClose: 1000,
|
autoClose: 1000,
|
||||||
})
|
})
|
||||||
} catch (error) {
|
} catch {
|
||||||
showNotification({
|
showNotification({
|
||||||
message: <Trans>Force fetching feeds is not yet available.</Trans>,
|
message: <Trans>Force fetching feeds is not yet available.</Trans>,
|
||||||
color: "red",
|
color: "red",
|
||||||
@@ -153,7 +176,9 @@ export function ProfileMenu(props: ProfileMenuProps) {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Trans>Fetch all my feeds now</Trans>
|
<Trans>Fetch all my feeds now</Trans>
|
||||||
{!forceRefreshEnabled && <span> ({dayjs.duration(nextAvailableForceRefresh - now.getTime()).format("HH:mm:ss")})</span>}
|
{!forceRefreshEnabled && (
|
||||||
|
<span> ({dayjs.duration(nextAvailableForceRefresh - now.getTime()).format("HH:mm:ss")})</span>
|
||||||
|
)}
|
||||||
</Menu.Item>
|
</Menu.Item>
|
||||||
|
|
||||||
<Divider />
|
<Divider />
|
||||||
@@ -184,6 +209,22 @@ export function ProfileMenu(props: ProfileMenuProps) {
|
|||||||
mb="xs"
|
mb="xs"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<Divider />
|
||||||
|
|
||||||
|
<Menu.Label>
|
||||||
|
<Trans>Font size</Trans>
|
||||||
|
</Menu.Label>
|
||||||
|
<Slider
|
||||||
|
min={50}
|
||||||
|
max={150}
|
||||||
|
step={5}
|
||||||
|
marks={[{ value: 100 }]}
|
||||||
|
label={v => `${v}%`}
|
||||||
|
mb="xs"
|
||||||
|
value={fontSizePercentage}
|
||||||
|
onChange={value => dispatch(setFontSizePercentage(value))}
|
||||||
|
/>
|
||||||
|
|
||||||
{admin && (
|
{admin && (
|
||||||
<>
|
<>
|
||||||
<Divider />
|
<Divider />
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
import { NumberFormatter } from "@mantine/core"
|
import { NumberFormatter } from "@mantine/core"
|
||||||
import type { MetricGauge } from "app/types"
|
import type { MetricGauge } from "@/app/types"
|
||||||
|
|
||||||
interface MeterProps {
|
interface GaugeProps {
|
||||||
gauge: MetricGauge
|
gauge: MetricGauge
|
||||||
}
|
}
|
||||||
|
|
||||||
export function Gauge(props: MeterProps) {
|
export function Gauge(props: Readonly<GaugeProps>) {
|
||||||
return <NumberFormatter value={props.gauge.value} thousandSeparator />
|
return <NumberFormatter value={props.gauge.value} thousandSeparator />
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
import { Box } from "@mantine/core"
|
import { Box } from "@mantine/core"
|
||||||
import type { MetricMeter } from "app/types"
|
import type { MetricMeter } from "@/app/types"
|
||||||
|
|
||||||
interface MeterProps {
|
interface MeterProps {
|
||||||
meter: MetricMeter
|
meter: MetricMeter
|
||||||
}
|
}
|
||||||
|
|
||||||
export function Meter(props: MeterProps) {
|
export function Meter(props: Readonly<MeterProps>) {
|
||||||
return (
|
return (
|
||||||
<Box>
|
<Box>
|
||||||
<Box>Mean: {props.meter.mean_rate.toFixed(2)}</Box>
|
<Box>Mean: {props.meter.mean_rate.toFixed(2)}</Box>
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ interface MetricAccordionItemProps {
|
|||||||
children: React.ReactNode
|
children: React.ReactNode
|
||||||
}
|
}
|
||||||
|
|
||||||
export function MetricAccordionItem({ metricKey, name, headerValue, children }: MetricAccordionItemProps) {
|
export function MetricAccordionItem({ metricKey, name, headerValue, children }: Readonly<MetricAccordionItemProps>) {
|
||||||
return (
|
return (
|
||||||
<Accordion.Item value={metricKey} key={metricKey}>
|
<Accordion.Item value={metricKey} key={metricKey}>
|
||||||
<Accordion.Control>
|
<Accordion.Control>
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
import { Box } from "@mantine/core"
|
import { Box } from "@mantine/core"
|
||||||
import type { MetricTimer } from "app/types"
|
import type { MetricTimer } from "@/app/types"
|
||||||
|
|
||||||
interface MetricTimerProps {
|
interface MetricTimerProps {
|
||||||
timer: MetricTimer
|
timer: MetricTimer
|
||||||
}
|
}
|
||||||
|
|
||||||
export function Timer(props: MetricTimerProps) {
|
export function Timer(props: Readonly<MetricTimerProps>) {
|
||||||
return (
|
return (
|
||||||
<Box>
|
<Box>
|
||||||
<Box>Mean: {props.timer.mean_rate.toFixed(2)}</Box>
|
<Box>Mean: {props.timer.mean_rate.toFixed(2)}</Box>
|
||||||
|
|||||||
@@ -1,8 +1,12 @@
|
|||||||
import { Box } from "@mantine/core"
|
import { Box } from "@mantine/core"
|
||||||
import { useMobile } from "hooks/useMobile"
|
|
||||||
import type React from "react"
|
import type React from "react"
|
||||||
|
import { useMobile } from "@/hooks/useMobile"
|
||||||
|
|
||||||
export function OnDesktop(props: { children: React.ReactNode }) {
|
export function OnDesktop(
|
||||||
|
props: Readonly<{
|
||||||
|
children: React.ReactNode
|
||||||
|
}>
|
||||||
|
) {
|
||||||
const mobile = useMobile()
|
const mobile = useMobile()
|
||||||
return <Box>{!mobile && props.children}</Box>
|
return <Box>{!mobile && props.children}</Box>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,12 @@
|
|||||||
import { Box } from "@mantine/core"
|
import { Box } from "@mantine/core"
|
||||||
import { useMobile } from "hooks/useMobile"
|
|
||||||
import type React from "react"
|
import type React from "react"
|
||||||
|
import { useMobile } from "@/hooks/useMobile"
|
||||||
|
|
||||||
export function OnMobile(props: { children: React.ReactNode }) {
|
export function OnMobile(
|
||||||
|
props: Readonly<{
|
||||||
|
children: React.ReactNode
|
||||||
|
}>
|
||||||
|
) {
|
||||||
const mobile = useMobile()
|
const mobile = useMobile()
|
||||||
return <Box>{mobile && props.children}</Box>
|
return <Box>{mobile && props.children}</Box>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,14 +1,15 @@
|
|||||||
import { Trans } from "@lingui/react/macro"
|
import { Trans } from "@lingui/react/macro"
|
||||||
import { Box, Button, Group, Stack } from "@mantine/core"
|
import { Anchor, Box, Button, Group, Stack } from "@mantine/core"
|
||||||
import { useForm } from "@mantine/form"
|
import { useForm } from "@mantine/form"
|
||||||
import { client, errorToStrings } from "app/client"
|
|
||||||
import { redirectToSelectedSource } from "app/redirect/thunks"
|
|
||||||
import { useAppDispatch, useAppSelector } from "app/store"
|
|
||||||
import { Alert } from "components/Alert"
|
|
||||||
import { CodeEditor } from "components/code/CodeEditor"
|
|
||||||
import { useEffect } from "react"
|
import { useEffect } from "react"
|
||||||
import { useAsyncCallback } from "react-async-hook"
|
import { useAsyncCallback } from "react-async-hook"
|
||||||
import { TbDeviceFloppy } from "react-icons/tb"
|
import { TbDeviceFloppy } from "react-icons/tb"
|
||||||
|
import { client, errorToStrings } from "@/app/client"
|
||||||
|
import { Constants } from "@/app/constants"
|
||||||
|
import { redirectToSelectedSource } from "@/app/redirect/thunks"
|
||||||
|
import { useAppDispatch, useAppSelector } from "@/app/store"
|
||||||
|
import { Alert } from "@/components/Alert"
|
||||||
|
import { CodeEditor } from "@/components/code/CodeEditor"
|
||||||
|
|
||||||
interface FormData {
|
interface FormData {
|
||||||
customCss: string
|
customCss: string
|
||||||
@@ -57,13 +58,23 @@ export function CustomCodeSettings() {
|
|||||||
<form onSubmit={form.onSubmit(saveCustomCode.execute)}>
|
<form onSubmit={form.onSubmit(saveCustomCode.execute)}>
|
||||||
<Stack>
|
<Stack>
|
||||||
<CodeEditor
|
<CodeEditor
|
||||||
description={<Trans>Custom CSS rules that will be applied</Trans>}
|
label={<Trans>Custom CSS rules that will be applied</Trans>}
|
||||||
|
description={
|
||||||
|
<Anchor
|
||||||
|
href={Constants.customCssDocumentationUrl}
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
style={{ fontSize: "inherit" }}
|
||||||
|
>
|
||||||
|
<Trans>Link to the documentation</Trans>
|
||||||
|
</Anchor>
|
||||||
|
}
|
||||||
language="css"
|
language="css"
|
||||||
{...form.getInputProps("customCss")}
|
{...form.getInputProps("customCss")}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<CodeEditor
|
<CodeEditor
|
||||||
description={<Trans>Custom JS code that will be executed on page load</Trans>}
|
label={<Trans>Custom JS code that will be executed on page load</Trans>}
|
||||||
language="javascript"
|
language="javascript"
|
||||||
{...form.getInputProps("customJs")}
|
{...form.getInputProps("customJs")}
|
||||||
/>
|
/>
|
||||||
@@ -72,7 +83,11 @@ export function CustomCodeSettings() {
|
|||||||
<Button variant="default" onClick={async () => await dispatch(redirectToSelectedSource())}>
|
<Button variant="default" onClick={async () => await dispatch(redirectToSelectedSource())}>
|
||||||
<Trans>Cancel</Trans>
|
<Trans>Cancel</Trans>
|
||||||
</Button>
|
</Button>
|
||||||
<Button type="submit" leftSection={<TbDeviceFloppy size={16} />} loading={saveCustomCode.loading}>
|
<Button
|
||||||
|
type="submit"
|
||||||
|
leftSection={<TbDeviceFloppy size={16} />}
|
||||||
|
loading={saveCustomCode.loading}
|
||||||
|
>
|
||||||
<Trans>Save</Trans>
|
<Trans>Save</Trans>
|
||||||
</Button>
|
</Button>
|
||||||
</Group>
|
</Group>
|
||||||
|
|||||||
@@ -1,47 +1,31 @@
|
|||||||
import { msg } from "@lingui/core/macro"
|
import { msg } from "@lingui/core/macro"
|
||||||
import { useLingui } from "@lingui/react"
|
import { useLingui } from "@lingui/react"
|
||||||
import { Trans } from "@lingui/react/macro"
|
import { Trans } from "@lingui/react/macro"
|
||||||
import { Divider, Group, NumberInput, Radio, Select, SimpleGrid, Stack, Switch } from "@mantine/core"
|
|
||||||
import type { ComboboxData } from "@mantine/core/lib/components/Combobox/Combobox.types"
|
|
||||||
import { Constants } from "app/constants"
|
|
||||||
import { useAppDispatch, useAppSelector } from "app/store"
|
|
||||||
import type { IconDisplayMode, ScrollMode, SharingSettings } from "app/types"
|
|
||||||
import {
|
import {
|
||||||
changeCustomContextMenu,
|
Box,
|
||||||
changeEntriesToKeepOnTopWhenScrolling,
|
type ComboboxData,
|
||||||
changeExternalLinkIconDisplayMode,
|
Divider,
|
||||||
changeLanguage,
|
Group,
|
||||||
changeMarkAllAsReadConfirmation,
|
Loader,
|
||||||
changeMobileFooter,
|
NumberInput,
|
||||||
changeScrollMarks,
|
Radio,
|
||||||
changeScrollMode,
|
Select,
|
||||||
changeScrollSpeed,
|
type SelectProps,
|
||||||
changeSharingSetting,
|
SimpleGrid,
|
||||||
changeShowRead,
|
Stack,
|
||||||
changeStarIconDisplayMode,
|
Switch,
|
||||||
changeUnreadCountFavicon,
|
} from "@mantine/core"
|
||||||
changeUnreadCountTitle,
|
|
||||||
} from "app/user/thunks"
|
|
||||||
import { locales } from "i18n"
|
|
||||||
import type { ReactNode } from "react"
|
import type { ReactNode } from "react"
|
||||||
|
import { Constants } from "@/app/constants"
|
||||||
|
import { useAppDispatch, useAppSelector } from "@/app/store"
|
||||||
|
import type { IconDisplayMode, ScrollMode, SharingSettings } from "@/app/types"
|
||||||
|
import { changeSettings } from "@/app/user/thunks"
|
||||||
|
import { locales } from "@/i18n"
|
||||||
|
|
||||||
export function DisplaySettings() {
|
export function DisplaySettings() {
|
||||||
const language = useAppSelector(state => state.user.settings?.language)
|
const settings = useAppSelector(state => state.user.settings)
|
||||||
const scrollSpeed = useAppSelector(state => state.user.settings?.scrollSpeed)
|
|
||||||
const showRead = useAppSelector(state => state.user.settings?.showRead)
|
|
||||||
const scrollMarks = useAppSelector(state => state.user.settings?.scrollMarks)
|
|
||||||
const scrollMode = useAppSelector(state => state.user.settings?.scrollMode)
|
|
||||||
const entriesToKeepOnTop = useAppSelector(state => state.user.settings?.entriesToKeepOnTopWhenScrolling)
|
|
||||||
const starIconDisplayMode = useAppSelector(state => state.user.settings?.starIconDisplayMode)
|
|
||||||
const externalLinkIconDisplayMode = useAppSelector(state => state.user.settings?.externalLinkIconDisplayMode)
|
|
||||||
const markAllAsReadConfirmation = useAppSelector(state => state.user.settings?.markAllAsReadConfirmation)
|
|
||||||
const customContextMenu = useAppSelector(state => state.user.settings?.customContextMenu)
|
|
||||||
const mobileFooter = useAppSelector(state => state.user.settings?.mobileFooter)
|
|
||||||
const unreadCountTitle = useAppSelector(state => state.user.settings?.unreadCountTitle)
|
|
||||||
const unreadCountFavicon = useAppSelector(state => state.user.settings?.unreadCountFavicon)
|
|
||||||
const sharingSettings = useAppSelector(state => state.user.settings?.sharingSettings)
|
|
||||||
const dispatch = useAppDispatch()
|
|
||||||
const { _ } = useLingui()
|
const { _ } = useLingui()
|
||||||
|
const dispatch = useAppDispatch()
|
||||||
|
|
||||||
const scrollModeOptions: Record<ScrollMode, ReactNode> = {
|
const scrollModeOptions: Record<ScrollMode, ReactNode> = {
|
||||||
always: <Trans>Always</Trans>,
|
always: <Trans>Always</Trans>,
|
||||||
@@ -68,78 +52,93 @@ export function DisplaySettings() {
|
|||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
|
const colorData: ComboboxData = [
|
||||||
|
{ value: "dark", label: _(msg`Dark`) },
|
||||||
|
{ value: "gray", label: _(msg`Gray`) },
|
||||||
|
{ value: "red", label: _(msg`Red`) },
|
||||||
|
{ value: "pink", label: _(msg`Pink`) },
|
||||||
|
{ value: "grape", label: _(msg`Grape`) },
|
||||||
|
{ value: "violet", label: _(msg`Violet`) },
|
||||||
|
{ value: "indigo", label: _(msg`Indigo`) },
|
||||||
|
{ value: "blue", label: _(msg`Blue`) },
|
||||||
|
{ value: "cyan", label: _(msg`Cyan`) },
|
||||||
|
{ value: "green", label: _(msg`Green`) },
|
||||||
|
{ value: "lime", label: _(msg`Lime`) },
|
||||||
|
{ value: "yellow", label: _(msg`Yellow`) },
|
||||||
|
{ value: "orange", label: _(msg`Orange`) },
|
||||||
|
{ value: "teal", label: _(msg`Teal`) },
|
||||||
|
].sort((a, b) => a.label.localeCompare(b.label))
|
||||||
|
const colorRenderer: SelectProps["renderOption"] = ({ option }) => (
|
||||||
|
<Group>
|
||||||
|
<Box h={18} w={18} bg={option.value} />
|
||||||
|
<Box>{option.label}</Box>
|
||||||
|
</Group>
|
||||||
|
)
|
||||||
|
|
||||||
|
if (!settings) return <Loader />
|
||||||
return (
|
return (
|
||||||
<Stack>
|
<Stack>
|
||||||
|
<Divider label={<Trans>Display</Trans>} labelPosition="center" />
|
||||||
|
|
||||||
<Select
|
<Select
|
||||||
description={<Trans>Language</Trans>}
|
label={<Trans>Language</Trans>}
|
||||||
value={language}
|
value={settings.language}
|
||||||
data={locales.map(l => ({
|
data={locales.map(l => ({
|
||||||
value: l.key,
|
value: l.key,
|
||||||
label: l.label,
|
label: l.label,
|
||||||
}))}
|
}))}
|
||||||
onChange={async s => await (s && dispatch(changeLanguage(s)))}
|
onChange={language => language && dispatch(changeSettings({ language }))}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Select
|
||||||
|
label={<Trans>Primary color</Trans>}
|
||||||
|
data={colorData}
|
||||||
|
value={settings.primaryColor}
|
||||||
|
onChange={primaryColor => primaryColor && dispatch(changeSettings({ primaryColor }))}
|
||||||
|
renderOption={colorRenderer}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Switch
|
<Switch
|
||||||
label={<Trans>Show feeds and categories with no unread entries</Trans>}
|
label={<Trans>Show feeds and categories with no unread entries</Trans>}
|
||||||
checked={showRead}
|
checked={settings.showRead}
|
||||||
onChange={async e => await dispatch(changeShowRead(e.currentTarget.checked))}
|
onChange={e => dispatch(changeSettings({ showRead: e.currentTarget.checked }))}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Switch
|
<Switch
|
||||||
label={<Trans>Show confirmation when marking all entries as read</Trans>}
|
label={<Trans>Show confirmation when marking all entries as read</Trans>}
|
||||||
checked={markAllAsReadConfirmation}
|
checked={settings.markAllAsReadConfirmation}
|
||||||
onChange={async e => await dispatch(changeMarkAllAsReadConfirmation(e.currentTarget.checked))}
|
onChange={e => dispatch(changeSettings({ markAllAsReadConfirmation: e.currentTarget.checked }))}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Switch
|
||||||
|
label={
|
||||||
|
<Trans>
|
||||||
|
Navigate to the next category/feed with unread entries when marking all entries as read
|
||||||
|
</Trans>
|
||||||
|
}
|
||||||
|
checked={settings.markAllAsReadNavigateToNextUnread}
|
||||||
|
onChange={e => dispatch(changeSettings({ markAllAsReadNavigateToNextUnread: e.currentTarget.checked }))}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Switch
|
<Switch
|
||||||
label={<Trans>On mobile, show action buttons at the bottom of the screen</Trans>}
|
label={<Trans>On mobile, show action buttons at the bottom of the screen</Trans>}
|
||||||
checked={mobileFooter}
|
checked={settings.mobileFooter}
|
||||||
onChange={async e => await dispatch(changeMobileFooter(e.currentTarget.checked))}
|
onChange={e => dispatch(changeSettings({ mobileFooter: e.currentTarget.checked }))}
|
||||||
/>
|
|
||||||
|
|
||||||
<Divider label={<Trans>Browser tab</Trans>} labelPosition="center" />
|
|
||||||
|
|
||||||
<Switch
|
|
||||||
label={<Trans>Show unread count in tab title</Trans>}
|
|
||||||
checked={unreadCountTitle}
|
|
||||||
onChange={async e => await dispatch(changeUnreadCountTitle(e.currentTarget.checked))}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Switch
|
|
||||||
label={<Trans>Show unread count in tab favicon</Trans>}
|
|
||||||
checked={unreadCountFavicon}
|
|
||||||
onChange={async e => await dispatch(changeUnreadCountFavicon(e.currentTarget.checked))}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Divider label={<Trans>Entry headers</Trans>} labelPosition="center" />
|
|
||||||
|
|
||||||
<Select
|
|
||||||
description={<Trans>Show star icon</Trans>}
|
|
||||||
value={starIconDisplayMode}
|
|
||||||
data={displayModeData}
|
|
||||||
onChange={async s => await dispatch(changeStarIconDisplayMode(s as IconDisplayMode))}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Select
|
|
||||||
description={<Trans>Show external link icon</Trans>}
|
|
||||||
value={externalLinkIconDisplayMode}
|
|
||||||
data={displayModeData}
|
|
||||||
onChange={async s => await dispatch(changeExternalLinkIconDisplayMode(s as IconDisplayMode))}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Switch
|
|
||||||
label={<Trans>Show CommaFeed's own context menu on right click</Trans>}
|
|
||||||
checked={customContextMenu}
|
|
||||||
onChange={async e => await dispatch(changeCustomContextMenu(e.currentTarget.checked))}
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Divider label={<Trans>Scrolling</Trans>} labelPosition="center" />
|
<Divider label={<Trans>Scrolling</Trans>} labelPosition="center" />
|
||||||
|
|
||||||
|
<Switch
|
||||||
|
label={<Trans>Disable "Pull to refresh" browser behavior</Trans>}
|
||||||
|
description={<Trans>This setting can cause scrolling issues on some browsers (e.g. Safari)</Trans>}
|
||||||
|
checked={settings.disablePullToRefresh}
|
||||||
|
onChange={e => dispatch(changeSettings({ disablePullToRefresh: e.currentTarget.checked }))}
|
||||||
|
/>
|
||||||
|
|
||||||
<Radio.Group
|
<Radio.Group
|
||||||
label={<Trans>Scroll selected entry to the top of the page</Trans>}
|
label={<Trans>Scroll selected entry to the top of the page</Trans>}
|
||||||
value={scrollMode}
|
value={settings.scrollMode}
|
||||||
onChange={async value => await dispatch(changeScrollMode(value as ScrollMode))}
|
onChange={value => dispatch(changeSettings({ scrollMode: value }))}
|
||||||
>
|
>
|
||||||
<Group mt="xs">
|
<Group mt="xs">
|
||||||
{Object.entries(scrollModeOptions).map(e => (
|
{Object.entries(scrollModeOptions).map(e => (
|
||||||
@@ -152,20 +151,56 @@ export function DisplaySettings() {
|
|||||||
label={<Trans>Entries to keep above the selected entry when scrolling</Trans>}
|
label={<Trans>Entries to keep above the selected entry when scrolling</Trans>}
|
||||||
description={<Trans>Only applies to compact, cozy and detailed modes</Trans>}
|
description={<Trans>Only applies to compact, cozy and detailed modes</Trans>}
|
||||||
min={0}
|
min={0}
|
||||||
value={entriesToKeepOnTop}
|
value={settings.entriesToKeepOnTopWhenScrolling}
|
||||||
onChange={async value => await dispatch(changeEntriesToKeepOnTopWhenScrolling(+value))}
|
onChange={value => dispatch(changeSettings({ entriesToKeepOnTopWhenScrolling: +value }))}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Switch
|
<Switch
|
||||||
label={<Trans>Scroll smoothly when navigating between entries</Trans>}
|
label={<Trans>Scroll smoothly when navigating between entries</Trans>}
|
||||||
checked={scrollSpeed ? scrollSpeed > 0 : false}
|
checked={settings.scrollSpeed ? settings.scrollSpeed > 0 : false}
|
||||||
onChange={async e => await dispatch(changeScrollSpeed(e.currentTarget.checked))}
|
onChange={e => dispatch(changeSettings({ scrollSpeed: e.currentTarget.checked ? 400 : 0 }))}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Switch
|
<Switch
|
||||||
label={<Trans>In expanded view, scrolling through entries mark them as read</Trans>}
|
label={<Trans>In expanded view, scrolling through entries mark them as read</Trans>}
|
||||||
checked={scrollMarks}
|
checked={settings.scrollMarks}
|
||||||
onChange={async e => await dispatch(changeScrollMarks(e.currentTarget.checked))}
|
onChange={e => dispatch(changeSettings({ scrollMarks: e.currentTarget.checked }))}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Divider label={<Trans>Browser tab</Trans>} labelPosition="center" />
|
||||||
|
|
||||||
|
<Switch
|
||||||
|
label={<Trans>Show unread count in tab title</Trans>}
|
||||||
|
checked={settings.unreadCountTitle}
|
||||||
|
onChange={e => dispatch(changeSettings({ unreadCountTitle: e.currentTarget.checked }))}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Switch
|
||||||
|
label={<Trans>Show unread count in tab favicon</Trans>}
|
||||||
|
checked={settings.unreadCountFavicon}
|
||||||
|
onChange={e => dispatch(changeSettings({ unreadCountFavicon: e.currentTarget.checked }))}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Divider label={<Trans>Entry headers</Trans>} labelPosition="center" />
|
||||||
|
|
||||||
|
<Select
|
||||||
|
label={<Trans>Show star icon</Trans>}
|
||||||
|
value={settings.starIconDisplayMode}
|
||||||
|
data={displayModeData}
|
||||||
|
onChange={value => dispatch(changeSettings({ starIconDisplayMode: value as IconDisplayMode }))}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Select
|
||||||
|
label={<Trans>Show external link icon</Trans>}
|
||||||
|
value={settings.externalLinkIconDisplayMode}
|
||||||
|
data={displayModeData}
|
||||||
|
onChange={value => dispatch(changeSettings({ externalLinkIconDisplayMode: value as IconDisplayMode }))}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Switch
|
||||||
|
label={<Trans>Show CommaFeed's own context menu on right click</Trans>}
|
||||||
|
checked={settings.customContextMenu}
|
||||||
|
onChange={e => dispatch(changeSettings({ customContextMenu: e.currentTarget.checked }))}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Divider label={<Trans>Sharing sites</Trans>} labelPosition="center" />
|
<Divider label={<Trans>Sharing sites</Trans>} labelPosition="center" />
|
||||||
@@ -175,12 +210,14 @@ export function DisplaySettings() {
|
|||||||
<Switch
|
<Switch
|
||||||
key={site}
|
key={site}
|
||||||
label={Constants.sharing[site].label}
|
label={Constants.sharing[site].label}
|
||||||
checked={sharingSettings?.[site]}
|
checked={settings.sharingSettings[site]}
|
||||||
onChange={async e =>
|
onChange={e =>
|
||||||
await dispatch(
|
dispatch(
|
||||||
changeSharingSetting({
|
changeSettings({
|
||||||
site,
|
sharingSettings: {
|
||||||
value: e.currentTarget.checked,
|
...settings.sharingSettings,
|
||||||
|
[site]: e.currentTarget.checked,
|
||||||
|
},
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,18 +1,30 @@
|
|||||||
import { msg } from "@lingui/core/macro"
|
|
||||||
import { useLingui } from "@lingui/react"
|
import { useLingui } from "@lingui/react"
|
||||||
import { Trans } from "@lingui/react/macro"
|
import { Trans } from "@lingui/react/macro"
|
||||||
import { Anchor, Box, Button, Checkbox, Divider, Group, Input, PasswordInput, Stack, Text, TextInput } from "@mantine/core"
|
import {
|
||||||
|
Anchor,
|
||||||
|
Box,
|
||||||
|
Button,
|
||||||
|
Checkbox,
|
||||||
|
Divider,
|
||||||
|
Group,
|
||||||
|
Input,
|
||||||
|
PasswordInput,
|
||||||
|
Stack,
|
||||||
|
Text,
|
||||||
|
TextInput,
|
||||||
|
} from "@mantine/core"
|
||||||
import { useForm } from "@mantine/form"
|
import { useForm } from "@mantine/form"
|
||||||
import { openConfirmModal } from "@mantine/modals"
|
import { openConfirmModal } from "@mantine/modals"
|
||||||
import { client, errorToStrings } from "app/client"
|
|
||||||
import { redirectToLogin, redirectToSelectedSource } from "app/redirect/thunks"
|
|
||||||
import { useAppDispatch, useAppSelector } from "app/store"
|
|
||||||
import type { ProfileModificationRequest } from "app/types"
|
|
||||||
import { reloadProfile } from "app/user/thunks"
|
|
||||||
import { Alert } from "components/Alert"
|
|
||||||
import { useEffect } from "react"
|
import { useEffect } from "react"
|
||||||
import { useAsyncCallback } from "react-async-hook"
|
import { useAsyncCallback } from "react-async-hook"
|
||||||
import { TbDeviceFloppy, TbTrash } from "react-icons/tb"
|
import { TbDeviceFloppy, TbTrash } from "react-icons/tb"
|
||||||
|
import { client, errorToStrings } from "@/app/client"
|
||||||
|
import { redirectToLogin, redirectToSelectedSource } from "@/app/redirect/thunks"
|
||||||
|
import { useAppDispatch, useAppSelector } from "@/app/store"
|
||||||
|
import type { ProfileModificationRequest } from "@/app/types"
|
||||||
|
import { reloadProfile } from "@/app/user/thunks"
|
||||||
|
import { Alert } from "@/components/Alert"
|
||||||
|
import { useValidationRules } from "@/hooks/useValidationRules"
|
||||||
|
|
||||||
interface FormData extends ProfileModificationRequest {
|
interface FormData extends ProfileModificationRequest {
|
||||||
newPasswordConfirmation?: string
|
newPasswordConfirmation?: string
|
||||||
@@ -20,13 +32,17 @@ interface FormData extends ProfileModificationRequest {
|
|||||||
|
|
||||||
export function ProfileSettings() {
|
export function ProfileSettings() {
|
||||||
const profile = useAppSelector(state => state.user.profile)
|
const profile = useAppSelector(state => state.user.profile)
|
||||||
|
const serverInfos = useAppSelector(state => state.server.serverInfos)
|
||||||
const dispatch = useAppDispatch()
|
const dispatch = useAppDispatch()
|
||||||
const { _ } = useLingui()
|
const { _ } = useLingui()
|
||||||
|
const validationRules = useValidationRules()
|
||||||
|
|
||||||
const form = useForm<FormData>({
|
const form = useForm<FormData>({
|
||||||
validate: {
|
validate: {
|
||||||
newPasswordConfirmation: (value, values) => (value !== values.newPassword ? _(msg`Passwords do not match`) : null),
|
newPassword: validationRules.password,
|
||||||
|
newPasswordConfirmation: (value, values) => validationRules.passwordConfirmation(value, values.newPassword),
|
||||||
},
|
},
|
||||||
|
validateInputOnChange: true,
|
||||||
})
|
})
|
||||||
const { setValues } = form
|
const { setValues } = form
|
||||||
|
|
||||||
@@ -52,7 +68,9 @@ export function ProfileSettings() {
|
|||||||
),
|
),
|
||||||
labels: { confirm: <Trans>Confirm</Trans>, cancel: <Trans>Cancel</Trans> },
|
labels: { confirm: <Trans>Confirm</Trans>, cancel: <Trans>Cancel</Trans> },
|
||||||
confirmProps: { color: "red" },
|
confirmProps: { color: "red" },
|
||||||
onConfirm: async () => await deleteProfile.execute(),
|
onConfirm: () => {
|
||||||
|
deleteProfile.execute()
|
||||||
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -85,8 +103,9 @@ export function ProfileSettings() {
|
|||||||
label={<Trans>API key</Trans>}
|
label={<Trans>API key</Trans>}
|
||||||
description={
|
description={
|
||||||
<Trans>
|
<Trans>
|
||||||
This is your API key. It can be used for some read-only API operations and grants access to the Fever API.
|
This is your API key. It can be used for some read-only API operations and grants access
|
||||||
Use the form at the bottom of the page to generate a new API key
|
to the Fever API and the Google Reader API. Use the form at the bottom of the page to
|
||||||
|
generate a new API key
|
||||||
</Trans>
|
</Trans>
|
||||||
}
|
}
|
||||||
readOnly
|
readOnly
|
||||||
@@ -97,7 +116,8 @@ export function ProfileSettings() {
|
|||||||
label={<Trans>OPML export</Trans>}
|
label={<Trans>OPML export</Trans>}
|
||||||
description={
|
description={
|
||||||
<Trans>
|
<Trans>
|
||||||
Export your subscriptions and categories as an OPML file that can be imported in other feed reading services
|
Export your subscriptions and categories as an OPML file that can be imported in other
|
||||||
|
feed reading services
|
||||||
</Trans>
|
</Trans>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
@@ -112,8 +132,8 @@ export function ProfileSettings() {
|
|||||||
label={<Trans>Fever API</Trans>}
|
label={<Trans>Fever API</Trans>}
|
||||||
description={
|
description={
|
||||||
<Trans>
|
<Trans>
|
||||||
CommaFeed is compatible with the Fever API. Use the following URL in your Fever-compatible mobile client.
|
CommaFeed is compatible with the Fever API. Use the following URL in your
|
||||||
Login with your username and your <u>API key</u>.
|
Fever-compatible mobile client. Login with your username and your <u>API key</u>.
|
||||||
</Trans>
|
</Trans>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
@@ -124,6 +144,22 @@ export function ProfileSettings() {
|
|||||||
</Box>
|
</Box>
|
||||||
</Input.Wrapper>
|
</Input.Wrapper>
|
||||||
|
|
||||||
|
<Input.Wrapper
|
||||||
|
label={<Trans>Google Reader API</Trans>}
|
||||||
|
description={
|
||||||
|
<Trans>
|
||||||
|
CommaFeed is compatible with the Google Reader API. Use the following URL in your Google
|
||||||
|
Reader-compatible mobile client. Login with your username and your <u>API key</u>.
|
||||||
|
</Trans>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Box>
|
||||||
|
<Anchor href="rest/googlereader" target="_blank">
|
||||||
|
<Trans>Google Reader API URL</Trans>
|
||||||
|
</Anchor>
|
||||||
|
</Box>
|
||||||
|
</Input.Wrapper>
|
||||||
|
|
||||||
<Divider />
|
<Divider />
|
||||||
|
|
||||||
<PasswordInput
|
<PasswordInput
|
||||||
@@ -132,14 +168,25 @@ export function ProfileSettings() {
|
|||||||
required
|
required
|
||||||
{...form.getInputProps("currentPassword")}
|
{...form.getInputProps("currentPassword")}
|
||||||
/>
|
/>
|
||||||
<TextInput type="email" label={<Trans>E-mail</Trans>} {...form.getInputProps("email")} required />
|
<TextInput
|
||||||
|
type="email"
|
||||||
|
label={<Trans>E-mail</Trans>}
|
||||||
|
{...form.getInputProps("email")}
|
||||||
|
required={serverInfos?.emailAddressRequired}
|
||||||
|
/>
|
||||||
<PasswordInput
|
<PasswordInput
|
||||||
label={<Trans>New password</Trans>}
|
label={<Trans>New password</Trans>}
|
||||||
description={<Trans>Changing password will generate a new API key</Trans>}
|
description={<Trans>Changing password will generate a new API key</Trans>}
|
||||||
{...form.getInputProps("newPassword")}
|
{...form.getInputProps("newPassword")}
|
||||||
/>
|
/>
|
||||||
<PasswordInput label={<Trans>Confirm password</Trans>} {...form.getInputProps("newPasswordConfirmation")} />
|
<PasswordInput
|
||||||
<Checkbox label={<Trans>Generate new API key</Trans>} {...form.getInputProps("newApiKey", { type: "checkbox" })} />
|
label={<Trans>Confirm password</Trans>}
|
||||||
|
{...form.getInputProps("newPasswordConfirmation")}
|
||||||
|
/>
|
||||||
|
<Checkbox
|
||||||
|
label={<Trans>Generate new API key</Trans>}
|
||||||
|
{...form.getInputProps("newApiKey", { type: "checkbox" })}
|
||||||
|
/>
|
||||||
|
|
||||||
<Group>
|
<Group>
|
||||||
<Button variant="default" onClick={async () => await dispatch(redirectToSelectedSource())}>
|
<Button variant="default" onClick={async () => await dispatch(redirectToSelectedSource())}>
|
||||||
|
|||||||
@@ -0,0 +1,129 @@
|
|||||||
|
import { msg } from "@lingui/core/macro"
|
||||||
|
import { useLingui } from "@lingui/react"
|
||||||
|
import { Trans } from "@lingui/react/macro"
|
||||||
|
import { Button, Divider, Group, Select, Stack, TextInput } from "@mantine/core"
|
||||||
|
import { useForm } from "@mantine/form"
|
||||||
|
import { useEffect } from "react"
|
||||||
|
import { useAsyncCallback } from "react-async-hook"
|
||||||
|
import { TbDeviceFloppy, TbSend } from "react-icons/tb"
|
||||||
|
import { client, errorToStrings } from "@/app/client"
|
||||||
|
import { redirectToSelectedSource } from "@/app/redirect/thunks"
|
||||||
|
import { useAppDispatch, useAppSelector } from "@/app/store"
|
||||||
|
import type { PushNotificationSettings as PushNotificationSettingsModel } from "@/app/types"
|
||||||
|
import { changeSettings } from "@/app/user/thunks"
|
||||||
|
import { Alert } from "@/components/Alert"
|
||||||
|
|
||||||
|
export function PushNotificationSettings() {
|
||||||
|
const notificationSettings = useAppSelector(state => state.user.settings?.pushNotificationSettings)
|
||||||
|
const pushNotificationsEnabled = useAppSelector(state => state.server.serverInfos?.pushNotificationsEnabled)
|
||||||
|
const { _ } = useLingui()
|
||||||
|
const dispatch = useAppDispatch()
|
||||||
|
|
||||||
|
const form = useForm<PushNotificationSettingsModel>()
|
||||||
|
useEffect(() => {
|
||||||
|
if (notificationSettings) form.initialize(notificationSettings)
|
||||||
|
}, [form.initialize, notificationSettings])
|
||||||
|
|
||||||
|
const handleSubmit = (values: PushNotificationSettingsModel) => {
|
||||||
|
dispatch(changeSettings({ pushNotificationSettings: values }))
|
||||||
|
}
|
||||||
|
|
||||||
|
const sendTestPushNotification = useAsyncCallback(client.user.sendTestPushNotification)
|
||||||
|
|
||||||
|
const typeInputProps = form.getInputProps("type")
|
||||||
|
|
||||||
|
if (!pushNotificationsEnabled) {
|
||||||
|
return <Trans>Push notifications are not enabled on this CommaFeed instance.</Trans>
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<form onSubmit={form.onSubmit(handleSubmit)}>
|
||||||
|
<Stack>
|
||||||
|
{sendTestPushNotification.status === "success" && (
|
||||||
|
<Alert level="success" messages={[_(msg`Test notification sent successfully.`)]} />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{sendTestPushNotification.status === "error" && (
|
||||||
|
<Alert level="error" messages={errorToStrings(sendTestPushNotification.error)} />
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Select
|
||||||
|
label={<Trans>Push notification service</Trans>}
|
||||||
|
description={
|
||||||
|
<Trans>
|
||||||
|
Receive push notifications when new feed entries are discovered. Enable "Receive push
|
||||||
|
notifications" in the settings of each feed for which you want to receive notifications.
|
||||||
|
</Trans>
|
||||||
|
}
|
||||||
|
data={[
|
||||||
|
{ value: "ntfy", label: "ntfy" },
|
||||||
|
{ value: "gotify", label: "Gotify" },
|
||||||
|
{ value: "pushover", label: "Pushover" },
|
||||||
|
]}
|
||||||
|
clearable
|
||||||
|
{...typeInputProps}
|
||||||
|
onChange={value => {
|
||||||
|
typeInputProps.onChange(value)
|
||||||
|
form.setFieldValue("serverUrl", "")
|
||||||
|
form.setFieldValue("topic", "")
|
||||||
|
form.setFieldValue("userSecret", "")
|
||||||
|
form.setFieldValue("userId", "")
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{form.values.type === "ntfy" && (
|
||||||
|
<>
|
||||||
|
<TextInput
|
||||||
|
label={<Trans>Server URL</Trans>}
|
||||||
|
placeholder="https://ntfy.sh"
|
||||||
|
required
|
||||||
|
{...form.getInputProps("serverUrl")}
|
||||||
|
/>
|
||||||
|
<TextInput
|
||||||
|
label={<Trans>Topic</Trans>}
|
||||||
|
placeholder="commafeed"
|
||||||
|
required
|
||||||
|
{...form.getInputProps("topic")}
|
||||||
|
/>
|
||||||
|
<TextInput label={<Trans>Access token</Trans>} {...form.getInputProps("userSecret")} />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{form.values.type === "gotify" && (
|
||||||
|
<>
|
||||||
|
<TextInput
|
||||||
|
label={<Trans>Server URL</Trans>}
|
||||||
|
placeholder="https://gotify.example.com"
|
||||||
|
required
|
||||||
|
{...form.getInputProps("serverUrl")}
|
||||||
|
/>
|
||||||
|
<TextInput label={<Trans>App token</Trans>} required {...form.getInputProps("userSecret")} />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{form.values.type === "pushover" && (
|
||||||
|
<>
|
||||||
|
<TextInput label={<Trans>User key</Trans>} required {...form.getInputProps("userId")} />
|
||||||
|
<TextInput label={<Trans>API token</Trans>} required {...form.getInputProps("userSecret")} />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Group>
|
||||||
|
<Button variant="default" onClick={async () => await dispatch(redirectToSelectedSource())}>
|
||||||
|
<Trans>Cancel</Trans>
|
||||||
|
</Button>
|
||||||
|
<Button type="submit" leftSection={<TbDeviceFloppy size={16} />}>
|
||||||
|
<Trans>Save</Trans>
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Divider orientation="vertical" />
|
||||||
|
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
leftSection={<TbSend size={16} />}
|
||||||
|
onClick={() => sendTestPushNotification.execute(form.values)}
|
||||||
|
loading={sendTestPushNotification.loading}
|
||||||
|
>
|
||||||
|
<Trans>Test</Trans>
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
|
</Stack>
|
||||||
|
</form>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,6 +1,8 @@
|
|||||||
import { Trans } from "@lingui/react/macro"
|
import { Trans } from "@lingui/react/macro"
|
||||||
import { Box, Stack } from "@mantine/core"
|
import { Box, Stack } from "@mantine/core"
|
||||||
import { Constants } from "app/constants"
|
import React from "react"
|
||||||
|
import { TbChevronDown, TbChevronRight, TbInbox, TbStar, TbTag } from "react-icons/tb"
|
||||||
|
import { Constants } from "@/app/constants"
|
||||||
import {
|
import {
|
||||||
redirectToCategory,
|
redirectToCategory,
|
||||||
redirectToCategoryDetails,
|
redirectToCategoryDetails,
|
||||||
@@ -8,15 +10,14 @@ import {
|
|||||||
redirectToFeedDetails,
|
redirectToFeedDetails,
|
||||||
redirectToTag,
|
redirectToTag,
|
||||||
redirectToTagDetails,
|
redirectToTagDetails,
|
||||||
} from "app/redirect/thunks"
|
} from "@/app/redirect/thunks"
|
||||||
import { useAppDispatch, useAppSelector } from "app/store"
|
import { useAppDispatch, useAppSelector } from "@/app/store"
|
||||||
import { collapseTreeCategory } from "app/tree/thunks"
|
import type { TreeSubscription } from "@/app/tree/slice"
|
||||||
import type { Category, Subscription } from "app/types"
|
import { collapseTreeCategory } from "@/app/tree/thunks"
|
||||||
import { categoryUnreadCount, flattenCategoryTree } from "app/utils"
|
import type { Category, Subscription } from "@/app/types"
|
||||||
import { Loader } from "components/Loader"
|
import { categoryHasNewEntries, categoryUnreadCount, flattenCategoryTree } from "@/app/utils"
|
||||||
import { OnDesktop } from "components/responsive/OnDesktop"
|
import { Loader } from "@/components/Loader"
|
||||||
import React from "react"
|
import { OnDesktop } from "@/components/responsive/OnDesktop"
|
||||||
import { TbChevronDown, TbChevronRight, TbInbox, TbStar, TbTag } from "react-icons/tb"
|
|
||||||
import { TreeNode } from "./TreeNode"
|
import { TreeNode } from "./TreeNode"
|
||||||
import { TreeSearch } from "./TreeSearch"
|
import { TreeSearch } from "./TreeSearch"
|
||||||
|
|
||||||
@@ -89,10 +90,12 @@ export function Tree() {
|
|||||||
name={<Trans>All</Trans>}
|
name={<Trans>All</Trans>}
|
||||||
icon={allIcon}
|
icon={allIcon}
|
||||||
unread={categoryUnreadCount(root)}
|
unread={categoryUnreadCount(root)}
|
||||||
|
hasNewEntries={categoryHasNewEntries(root)}
|
||||||
selected={source.type === "category" && source.id === Constants.categories.all.id}
|
selected={source.type === "category" && source.id === Constants.categories.all.id}
|
||||||
expanded={false}
|
expanded={false}
|
||||||
level={0}
|
level={0}
|
||||||
hasError={false}
|
hasError={false}
|
||||||
|
hasWarning={false}
|
||||||
onClick={categoryClicked}
|
onClick={categoryClicked}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
@@ -103,10 +106,12 @@ export function Tree() {
|
|||||||
name={<Trans>Starred</Trans>}
|
name={<Trans>Starred</Trans>}
|
||||||
icon={starredIcon}
|
icon={starredIcon}
|
||||||
unread={0}
|
unread={0}
|
||||||
|
hasNewEntries={false}
|
||||||
selected={source.type === "category" && source.id === Constants.categories.starred.id}
|
selected={source.type === "category" && source.id === Constants.categories.starred.id}
|
||||||
expanded={false}
|
expanded={false}
|
||||||
level={0}
|
level={0}
|
||||||
hasError={false}
|
hasError={false}
|
||||||
|
hasWarning={false}
|
||||||
onClick={categoryClicked}
|
onClick={categoryClicked}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
@@ -114,7 +119,11 @@ export function Tree() {
|
|||||||
const categoryNode = (category: Category, level = 0) => {
|
const categoryNode = (category: Category, level = 0) => {
|
||||||
if (!isCategoryDisplayed(category)) return null
|
if (!isCategoryDisplayed(category)) return null
|
||||||
|
|
||||||
const hasError = !category.expanded && flattenCategoryTree(category).some(c => c.feeds.some(f => f.errorCount > errorThreshold))
|
const hasError =
|
||||||
|
!category.expanded &&
|
||||||
|
flattenCategoryTree(category).some(c => c.feeds.some(f => f.errorCount > errorThreshold))
|
||||||
|
const hasWarning =
|
||||||
|
!category.expanded && flattenCategoryTree(category).some(c => c.feeds.some(f => !!f.filterLegacy))
|
||||||
return (
|
return (
|
||||||
<TreeNode
|
<TreeNode
|
||||||
id={category.id}
|
id={category.id}
|
||||||
@@ -122,10 +131,12 @@ export function Tree() {
|
|||||||
name={category.name}
|
name={category.name}
|
||||||
icon={category.expanded ? expandedIcon : collapsedIcon}
|
icon={category.expanded ? expandedIcon : collapsedIcon}
|
||||||
unread={categoryUnreadCount(category)}
|
unread={categoryUnreadCount(category)}
|
||||||
|
hasNewEntries={categoryHasNewEntries(category)}
|
||||||
selected={source.type === "category" && source.id === category.id}
|
selected={source.type === "category" && source.id === category.id}
|
||||||
expanded={category.expanded}
|
expanded={category.expanded}
|
||||||
level={level}
|
level={level}
|
||||||
hasError={hasError}
|
hasError={hasError}
|
||||||
|
hasWarning={hasWarning}
|
||||||
onClick={categoryClicked}
|
onClick={categoryClicked}
|
||||||
onIconClick={e => categoryIconClicked(e, category)}
|
onIconClick={e => categoryIconClicked(e, category)}
|
||||||
key={category.id}
|
key={category.id}
|
||||||
@@ -133,7 +144,7 @@ export function Tree() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const feedNode = (feed: Subscription, level = 0) => {
|
const feedNode = (feed: TreeSubscription, level = 0) => {
|
||||||
if (!isFeedDisplayed(feed)) return null
|
if (!isFeedDisplayed(feed)) return null
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -143,9 +154,11 @@ export function Tree() {
|
|||||||
name={feed.name}
|
name={feed.name}
|
||||||
icon={feed.iconUrl}
|
icon={feed.iconUrl}
|
||||||
unread={feed.unread}
|
unread={feed.unread}
|
||||||
|
hasNewEntries={!!feed.hasNewEntries}
|
||||||
selected={source.type === "feed" && source.id === String(feed.id)}
|
selected={source.type === "feed" && source.id === String(feed.id)}
|
||||||
level={level}
|
level={level}
|
||||||
hasError={feed.errorCount > errorThreshold}
|
hasError={feed.errorCount > errorThreshold}
|
||||||
|
hasWarning={!!feed.filterLegacy}
|
||||||
onClick={feedClicked}
|
onClick={feedClicked}
|
||||||
key={feed.id}
|
key={feed.id}
|
||||||
/>
|
/>
|
||||||
@@ -159,9 +172,11 @@ export function Tree() {
|
|||||||
name={tag}
|
name={tag}
|
||||||
icon={tagIcon}
|
icon={tagIcon}
|
||||||
unread={0}
|
unread={0}
|
||||||
|
hasNewEntries={false}
|
||||||
selected={source.type === "tag" && source.id === tag}
|
selected={source.type === "tag" && source.id === tag}
|
||||||
level={0}
|
level={0}
|
||||||
hasError={false}
|
hasError={false}
|
||||||
|
hasWarning={false}
|
||||||
onClick={tagClicked}
|
onClick={tagClicked}
|
||||||
key={tag}
|
key={tag}
|
||||||
/>
|
/>
|
||||||
@@ -182,7 +197,7 @@ export function Tree() {
|
|||||||
<OnDesktop>
|
<OnDesktop>
|
||||||
<TreeSearch feeds={feeds} />
|
<TreeSearch feeds={feeds} />
|
||||||
</OnDesktop>
|
</OnDesktop>
|
||||||
<Box>
|
<Box className="cf-tree">
|
||||||
{allCategoryNode()}
|
{allCategoryNode()}
|
||||||
{starredCategoryNode()}
|
{starredCategoryNode()}
|
||||||
{root.children.map(c => recursiveCategoryNode(c))}
|
{root.children.map(c => recursiveCategoryNode(c))}
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import { Box, Center } from "@mantine/core"
|
import { Box, Center } from "@mantine/core"
|
||||||
import type { EntrySourceType } from "app/entries/slice"
|
|
||||||
import { FeedFavicon } from "components/content/FeedFavicon"
|
|
||||||
import type React from "react"
|
import type React from "react"
|
||||||
import { tss } from "tss"
|
import type { EntrySourceType } from "@/app/entries/slice"
|
||||||
|
import { FeedFavicon } from "@/components/content/FeedFavicon"
|
||||||
|
import { tss } from "@/tss"
|
||||||
import { UnreadCount } from "./UnreadCount"
|
import { UnreadCount } from "./UnreadCount"
|
||||||
|
|
||||||
interface TreeNodeProps {
|
interface TreeNodeProps {
|
||||||
@@ -15,6 +15,8 @@ interface TreeNodeProps {
|
|||||||
expanded?: boolean
|
expanded?: boolean
|
||||||
level: number
|
level: number
|
||||||
hasError: boolean
|
hasError: boolean
|
||||||
|
hasWarning: boolean
|
||||||
|
hasNewEntries: boolean
|
||||||
onClick: (e: React.MouseEvent, id: string) => void
|
onClick: (e: React.MouseEvent, id: string) => void
|
||||||
onIconClick?: (e: React.MouseEvent, id: string) => void
|
onIconClick?: (e: React.MouseEvent, id: string) => void
|
||||||
}
|
}
|
||||||
@@ -23,15 +25,18 @@ const useStyles = tss
|
|||||||
.withParams<{
|
.withParams<{
|
||||||
selected: boolean
|
selected: boolean
|
||||||
hasError: boolean
|
hasError: boolean
|
||||||
|
hasWarning: boolean
|
||||||
hasUnread: boolean
|
hasUnread: boolean
|
||||||
}>()
|
}>()
|
||||||
.create(({ theme, colorScheme, selected, hasError, hasUnread }) => {
|
.create(({ theme, colorScheme, selected, hasError, hasWarning, hasUnread }) => {
|
||||||
let backgroundColor = "inherit"
|
let backgroundColor = "inherit"
|
||||||
if (selected) backgroundColor = colorScheme === "dark" ? theme.colors.dark[4] : theme.colors.gray[1]
|
if (selected) backgroundColor = colorScheme === "dark" ? theme.colors.dark[4] : theme.colors.gray[1]
|
||||||
|
|
||||||
let color: string
|
let color: string
|
||||||
if (hasError) {
|
if (hasError) {
|
||||||
color = theme.colors.red[6]
|
color = theme.colors.red[6]
|
||||||
|
} else if (hasWarning) {
|
||||||
|
color = theme.colors.yellow[6]
|
||||||
} else if (colorScheme === "dark") {
|
} else if (colorScheme === "dark") {
|
||||||
color = hasUnread ? theme.colors.dark[0] : theme.colors.dark[3]
|
color = hasUnread ? theme.colors.dark[0] : theme.colors.dark[3]
|
||||||
} else {
|
} else {
|
||||||
@@ -58,29 +63,34 @@ const useStyles = tss
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
export function TreeNode(props: TreeNodeProps) {
|
export function TreeNode(props: Readonly<TreeNodeProps>) {
|
||||||
const { classes } = useStyles({
|
const { classes } = useStyles({
|
||||||
selected: props.selected,
|
selected: props.selected,
|
||||||
hasError: props.hasError,
|
hasError: props.hasError,
|
||||||
|
hasWarning: props.hasWarning,
|
||||||
hasUnread: props.unread > 0,
|
hasUnread: props.unread > 0,
|
||||||
})
|
})
|
||||||
return (
|
return (
|
||||||
<Box
|
<Box
|
||||||
py={1}
|
py={1}
|
||||||
pl={props.level * 20}
|
pl={props.level * 20}
|
||||||
className={classes.node}
|
className={`${classes.node} cf-treenode cf-treenode-${props.type}`}
|
||||||
onClick={(e: React.MouseEvent) => props.onClick(e, props.id)}
|
onClick={(e: React.MouseEvent) => props.onClick(e, props.id)}
|
||||||
data-id={props.id}
|
data-id={props.id}
|
||||||
data-type={props.type}
|
data-type={props.type}
|
||||||
data-unread-count={props.unread}
|
data-unread-count={props.unread}
|
||||||
>
|
>
|
||||||
<Box mr={6} onClick={(e: React.MouseEvent) => props.onIconClick?.(e, props.id)}>
|
<Box
|
||||||
|
mr={6}
|
||||||
|
onClick={(e: React.MouseEvent) => props.onIconClick?.(e, props.id)}
|
||||||
|
className="cf-treenode-icon"
|
||||||
|
>
|
||||||
<Center>{typeof props.icon === "string" ? <FeedFavicon url={props.icon} /> : props.icon}</Center>
|
<Center>{typeof props.icon === "string" ? <FeedFavicon url={props.icon} /> : props.icon}</Center>
|
||||||
</Box>
|
</Box>
|
||||||
<Box className={classes.nodeText}>{props.name}</Box>
|
<Box className={classes.nodeText}>{props.name}</Box>
|
||||||
{!props.expanded && (
|
{!props.expanded && (
|
||||||
<Box>
|
<Box className="cf-treenode-unread-count">
|
||||||
<UnreadCount unreadCount={props.unread} />
|
<UnreadCount unreadCount={props.unread} showIndicator={props.hasNewEntries} />
|
||||||
</Box>
|
</Box>
|
||||||
)}
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
|
|||||||
@@ -1,20 +1,20 @@
|
|||||||
import { msg } from "@lingui/core/macro"
|
import { msg } from "@lingui/core/macro"
|
||||||
import { useLingui } from "@lingui/react"
|
import { useLingui } from "@lingui/react"
|
||||||
import { Trans } from "@lingui/react/macro"
|
import { Trans } from "@lingui/react/macro"
|
||||||
import { TextInput } from "@mantine/core"
|
import { Box, TextInput } from "@mantine/core"
|
||||||
import { Spotlight, type SpotlightActionData, spotlight } from "@mantine/spotlight"
|
import { Spotlight, type SpotlightActionData, spotlight } from "@mantine/spotlight"
|
||||||
import { redirectToFeed } from "app/redirect/thunks"
|
|
||||||
import { useAppDispatch } from "app/store"
|
|
||||||
import type { Subscription } from "app/types"
|
|
||||||
import { FeedFavicon } from "components/content/FeedFavicon"
|
|
||||||
import { useMousetrap } from "hooks/useMousetrap"
|
|
||||||
import { TbSearch } from "react-icons/tb"
|
import { TbSearch } from "react-icons/tb"
|
||||||
|
import { redirectToFeed } from "@/app/redirect/thunks"
|
||||||
|
import { useAppDispatch } from "@/app/store"
|
||||||
|
import type { Subscription } from "@/app/types"
|
||||||
|
import { FeedFavicon } from "@/components/content/FeedFavicon"
|
||||||
|
import { useMousetrap } from "@/hooks/useMousetrap"
|
||||||
|
|
||||||
export interface TreeSearchProps {
|
export interface TreeSearchProps {
|
||||||
feeds: Subscription[]
|
feeds: Subscription[]
|
||||||
}
|
}
|
||||||
|
|
||||||
export function TreeSearch(props: TreeSearchProps) {
|
export function TreeSearch(props: Readonly<TreeSearchProps>) {
|
||||||
const dispatch = useAppDispatch()
|
const dispatch = useAppDispatch()
|
||||||
const { _ } = useLingui()
|
const { _ } = useLingui()
|
||||||
|
|
||||||
@@ -33,7 +33,7 @@ export function TreeSearch(props: TreeSearchProps) {
|
|||||||
useMousetrap("g u", () => spotlight.open())
|
useMousetrap("g u", () => spotlight.open())
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<Box className="cf-treesearch">
|
||||||
<TextInput
|
<TextInput
|
||||||
placeholder={_(msg`Search`)}
|
placeholder={_(msg`Search`)}
|
||||||
leftSection={searchIcon}
|
leftSection={searchIcon}
|
||||||
@@ -58,6 +58,6 @@ export function TreeSearch(props: TreeSearchProps) {
|
|||||||
}}
|
}}
|
||||||
nothingFound={<Trans>Nothing found</Trans>}
|
nothingFound={<Trans>Nothing found</Trans>}
|
||||||
/>
|
/>
|
||||||
</>
|
</Box>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,16 +1,25 @@
|
|||||||
import { Badge, Tooltip } from "@mantine/core"
|
import { Badge, Indicator, Tooltip } from "@mantine/core"
|
||||||
import { Constants } from "app/constants"
|
import { Constants } from "@/app/constants"
|
||||||
import { tss } from "tss"
|
import { tss } from "@/tss"
|
||||||
|
|
||||||
const useStyles = tss.create(() => ({
|
const useStyles = tss.create(() => ({
|
||||||
badge: {
|
badge: {
|
||||||
width: "3.2rem",
|
width: "3.2rem",
|
||||||
// for some reason, mantine Badge has "cursor: 'default'"
|
// for some reason, mantine Badge has "cursor: 'default'"
|
||||||
cursor: "pointer",
|
cursor: "inherit",
|
||||||
|
},
|
||||||
|
indicator: {
|
||||||
|
// ensure the indicator is not shown above the app header
|
||||||
|
zIndex: 0,
|
||||||
},
|
},
|
||||||
}))
|
}))
|
||||||
|
|
||||||
export function UnreadCount(props: { unreadCount: number }) {
|
export function UnreadCount(
|
||||||
|
props: Readonly<{
|
||||||
|
unreadCount: number
|
||||||
|
showIndicator: boolean
|
||||||
|
}>
|
||||||
|
) {
|
||||||
const { classes } = useStyles()
|
const { classes } = useStyles()
|
||||||
|
|
||||||
if (props.unreadCount <= 0) return null
|
if (props.unreadCount <= 0) return null
|
||||||
@@ -18,9 +27,19 @@ export function UnreadCount(props: { unreadCount: number }) {
|
|||||||
const count = props.unreadCount >= 10000 ? "10k+" : props.unreadCount
|
const count = props.unreadCount >= 10000 ? "10k+" : props.unreadCount
|
||||||
return (
|
return (
|
||||||
<Tooltip label={props.unreadCount} disabled={props.unreadCount === count} openDelay={Constants.tooltip.delay}>
|
<Tooltip label={props.unreadCount} disabled={props.unreadCount === count} openDelay={Constants.tooltip.delay}>
|
||||||
<Badge className={classes.badge} variant="light" fullWidth>
|
<Indicator
|
||||||
{count}
|
disabled={!props.showIndicator}
|
||||||
</Badge>
|
size={4}
|
||||||
|
offset={10}
|
||||||
|
position="middle-start"
|
||||||
|
classNames={{
|
||||||
|
indicator: classes.indicator,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Badge className={`${classes.badge} cf-badge`} variant="light" fullWidth>
|
||||||
|
{count}
|
||||||
|
</Badge>
|
||||||
|
</Indicator>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
3
commafeed-client/src/globals.d.ts
vendored
Normal file
3
commafeed-client/src/globals.d.ts
vendored
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
// required because of noUncheckedSideEffectImports
|
||||||
|
// https://www.typescriptlang.org/tsconfig/#noUncheckedSideEffectImports
|
||||||
|
declare module "@fontsource/open-sans"
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useMantineTheme } from "@mantine/core"
|
import { useMantineTheme } from "@mantine/core"
|
||||||
import { useMobile } from "hooks/useMobile"
|
import { useMobile } from "@/hooks/useMobile"
|
||||||
|
|
||||||
export const useActionButton = () => {
|
export const useActionButton = () => {
|
||||||
const theme = useMantineTheme()
|
const theme = useMantineTheme()
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { msg } from "@lingui/core/macro"
|
import { msg } from "@lingui/core/macro"
|
||||||
import { useLingui } from "@lingui/react"
|
import { useLingui } from "@lingui/react"
|
||||||
import { useAppSelector } from "app/store"
|
import { useAppSelector } from "@/app/store"
|
||||||
|
|
||||||
interface Step {
|
interface Step {
|
||||||
label: string
|
label: string
|
||||||
@@ -8,28 +8,28 @@ interface Step {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const useAppLoading = () => {
|
export const useAppLoading = () => {
|
||||||
const profile = useAppSelector(state => state.user.profile)
|
const profileLoaded = useAppSelector(state => !!state.user.profile)
|
||||||
const settings = useAppSelector(state => state.user.settings)
|
const settingsLoaded = useAppSelector(state => !!state.user.settings)
|
||||||
const rootCategory = useAppSelector(state => state.tree.rootCategory)
|
const rootCategoryLoaded = useAppSelector(state => !!state.tree.rootCategory)
|
||||||
const tags = useAppSelector(state => state.user.tags)
|
const tagsLoaded = useAppSelector(state => !!state.user.tags)
|
||||||
const { _ } = useLingui()
|
const { _ } = useLingui()
|
||||||
|
|
||||||
const steps: Step[] = [
|
const steps: Step[] = [
|
||||||
{
|
{
|
||||||
label: _(msg`Loading settings...`),
|
label: _(msg`Loading settings...`),
|
||||||
done: !!settings,
|
done: settingsLoaded,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: _(msg`Loading profile...`),
|
label: _(msg`Loading profile...`),
|
||||||
done: !!profile,
|
done: profileLoaded,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: _(msg`Loading subscriptions...`),
|
label: _(msg`Loading subscriptions...`),
|
||||||
done: !!rootCategory,
|
done: rootCategoryLoaded,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: _(msg`Loading tags...`),
|
label: _(msg`Loading tags...`),
|
||||||
done: !!tags,
|
done: tagsLoaded,
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useMediaQuery } from "@mantine/hooks"
|
import { useMediaQuery } from "@mantine/hooks"
|
||||||
import { Constants } from "app/constants"
|
import { Constants } from "@/app/constants"
|
||||||
|
|
||||||
export const useMobile = (breakpoint: string | number = Constants.layout.mobileBreakpoint) => {
|
export const useMobile = (breakpoint: string | number = Constants.layout.mobileBreakpoint) => {
|
||||||
const bp = typeof breakpoint === "number" ? `${breakpoint}px` : breakpoint
|
const bp = typeof breakpoint === "number" ? `${breakpoint}px` : breakpoint
|
||||||
|
|||||||
17
commafeed-client/src/hooks/useValidationRules.ts
Normal file
17
commafeed-client/src/hooks/useValidationRules.ts
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
import { msg } from "@lingui/core/macro"
|
||||||
|
import { useLingui } from "@lingui/react"
|
||||||
|
import { useAppSelector } from "@/app/store"
|
||||||
|
|
||||||
|
export function useValidationRules() {
|
||||||
|
const minimumPasswordLength = useAppSelector(state => state.server.serverInfos?.minimumPasswordLength)
|
||||||
|
const { _ } = useLingui()
|
||||||
|
|
||||||
|
return {
|
||||||
|
password: (value: string | undefined) =>
|
||||||
|
value && minimumPasswordLength && value.length < minimumPasswordLength
|
||||||
|
? _(msg`Password must be at least ${minimumPasswordLength} characters`)
|
||||||
|
: null,
|
||||||
|
passwordConfirmation: (newPasswordConfirmation: string | undefined, newPassword: string | undefined) =>
|
||||||
|
newPasswordConfirmation && newPasswordConfirmation !== newPassword ? _(msg`Passwords do not match`) : null,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,8 +1,8 @@
|
|||||||
import { setWebSocketConnected } from "app/server/slice"
|
|
||||||
import { type AppDispatch, useAppDispatch, useAppSelector } from "app/store"
|
|
||||||
import { newFeedEntriesDiscovered } from "app/tree/thunks"
|
|
||||||
import { useEffect } from "react"
|
import { useEffect } from "react"
|
||||||
import WebsocketHeartbeatJs from "websocket-heartbeat-js"
|
import WebsocketHeartbeatJs from "websocket-heartbeat-js"
|
||||||
|
import { setWebSocketConnected } from "@/app/server/slice"
|
||||||
|
import { type AppDispatch, useAppDispatch, useAppSelector } from "@/app/store"
|
||||||
|
import { newFeedEntriesDiscovered } from "@/app/tree/thunks"
|
||||||
|
|
||||||
const handleMessage = (dispatch: AppDispatch, message: string) => {
|
const handleMessage = (dispatch: AppDispatch, message: string) => {
|
||||||
const parts = message.split(":")
|
const parts = message.split(":")
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user