forked from Archives/Athou_commafeed
Compare commits
2068 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 3cefc0f176 | |||
| 1cb346866a | |||
| 5cc8c736e7 | |||
| eb5614a03b | |||
|
|
fc76d7e609 | ||
|
|
1b24bf33ed | ||
|
|
58ff378735 | ||
|
|
1cee04a233 | ||
|
|
ac11a0efb8 | ||
|
|
f2ea1e3f7a | ||
|
|
153970c146 | ||
|
|
c21287e642 | ||
|
|
284942a82e | ||
|
|
e796916e73 | ||
| e2a1630adc | |||
|
|
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 | ||
|
|
54da4e6839 | ||
|
|
3a6b4c588c | ||
|
|
48071b9fd1 | ||
|
|
f519aa039f | ||
|
|
dc3e5476a1 | ||
|
|
903035ecfc | ||
|
|
13ad57da10 | ||
|
|
44bc24c22a | ||
|
|
97f90405fc | ||
|
|
0fc2a0b022 | ||
|
|
89eb641704 | ||
|
|
c53da9f631 | ||
|
|
998868e63a | ||
|
|
93f22d2351 | ||
|
|
c3782bd7d2 | ||
|
|
f330349397 | ||
|
|
99c973c8c2 | ||
|
|
469420b5bf | ||
|
|
bde556d41f | ||
|
|
bf6c2d7beb | ||
|
|
fa62ca21e0 | ||
|
|
7dcf76da84 | ||
|
|
3dc80fa762 | ||
|
|
dbce12492b | ||
|
|
85f5eaffec | ||
|
|
106276351e | ||
|
|
961fb6a464 | ||
|
|
ac3d9ef57f | ||
|
|
3478ee4815 | ||
|
|
3dc02d7ba1 | ||
|
|
c886f8b83c | ||
|
|
4a2154d0b3 | ||
|
|
ba530d5019 | ||
|
|
85b6209c52 | ||
|
|
7ff86a5e31 | ||
|
|
8edd6a1e2d | ||
|
|
6e65ed49e9 | ||
|
|
711b01abfa | ||
|
|
c7014ca2a1 | ||
|
|
a3984cd959 | ||
|
|
8d85b1bcba | ||
|
|
c451eee406 | ||
|
|
8f42135996 | ||
|
|
2c26aeed17 | ||
|
|
e2c4aa998b | ||
|
|
c9e3b7f349 | ||
|
|
ebb4e52ba7 | ||
|
|
1ddfdfb12e | ||
|
|
81f16aea62 | ||
|
|
429ec193c8 | ||
|
|
732b714448 | ||
|
|
82e0405ad9 | ||
|
|
9ef002fcd1 | ||
|
|
ec938e416c | ||
|
|
37cf711cbc | ||
|
|
de441e4ff7 | ||
|
|
46251526b6 | ||
|
|
67eeea0b06 | ||
|
|
b49ccc4cd9 | ||
|
|
8586a8b57b | ||
|
|
d9f63786a8 | ||
|
|
8f0c8b68b9 | ||
|
|
15e574c5c4 | ||
|
|
fe532242b4 | ||
|
|
fb48ff0858 | ||
|
|
8d850639d7 | ||
|
|
ee73195915 | ||
|
|
72d9dad61b | ||
|
|
fde8dab8cd | ||
|
|
dae5efa787 | ||
|
|
3c067140fd | ||
|
|
4ccbe81e87 | ||
|
|
3d5d93bb72 | ||
|
|
4138b6eb9b | ||
|
|
9c39c95a9b | ||
|
|
32b2bf99a4 | ||
|
|
cf459876af | ||
|
|
6698bd74b5 | ||
|
|
c81d06e5f3 | ||
|
|
b12a78dc84 | ||
|
|
b076587e44 | ||
|
|
bb12f16bea | ||
|
|
e80caadd12 | ||
|
|
846d93f2b2 | ||
|
|
0ed6f6ef9c | ||
|
|
15992dcb80 | ||
|
|
1a5c399b54 | ||
|
|
5e92f9ffb8 | ||
|
|
71164d1b69 | ||
|
|
6947670fe6 | ||
|
|
30810e37b9 | ||
|
|
b17b2767b0 | ||
|
|
d37cf5bbcf | ||
|
|
045baba705 | ||
|
|
3623dc8e1d | ||
|
|
2610c37067 | ||
|
|
286b69a646 | ||
|
|
9673f27090 | ||
|
|
0722599f6d | ||
|
|
1df40d8370 | ||
|
|
457e4ec69e | ||
|
|
647310a45f | ||
|
|
e85c92f216 | ||
|
|
d93b0dbfd4 | ||
|
|
b4e61ef547 | ||
|
|
71dffbba46 | ||
|
|
2c0b0c4e3b | ||
|
|
d868e58e1e | ||
|
|
90eb2095bf | ||
|
|
62d3ed16e6 | ||
|
|
74f7c48818 | ||
|
|
23fe9c29ed | ||
|
|
8f7be8278a | ||
|
|
49118b6ea0 | ||
|
|
d97bd04ae2 | ||
|
|
8d11309b64 | ||
|
|
68c24e4cb8 | ||
|
|
4e43e0235f | ||
|
|
62b79a9625 | ||
|
|
cb0706808c | ||
|
|
ffd5704b1e | ||
|
|
3987077e5a | ||
|
|
2e01a76784 | ||
|
|
8254093f5f | ||
|
|
0b06526756 | ||
|
|
06731ae76d | ||
|
|
9a59453792 | ||
|
|
c195a52c89 | ||
|
|
3d7924f953 | ||
|
|
f29efd7fae | ||
|
|
157bff3c83 | ||
|
|
5c17bbc36d | ||
|
|
c85e72e70c | ||
|
|
01150f67e1 | ||
|
|
75aca7aa6f | ||
|
|
affde7e43c | ||
|
|
b9b1b53235 | ||
|
|
708ebb8abc | ||
|
|
83e763df0a | ||
|
|
0ff812c1ea | ||
|
|
3e9dd6d8e2 | ||
|
|
23af73e847 | ||
|
|
e79e4719fd | ||
|
|
23fef98432 | ||
|
|
22478252e7 | ||
|
|
76b1f3cd35 | ||
|
|
420d73ec6a | ||
|
|
e0211cfa0c | ||
|
|
25a92c651c | ||
|
|
0781205c69 | ||
|
|
5102dd5e30 | ||
|
|
6ccfc3fd67 | ||
|
|
2791ed91ab | ||
|
|
f40c198233 | ||
|
|
003dc63121 | ||
|
|
f8ef1e2a99 | ||
|
|
14c7078940 | ||
|
|
074836d3e8 | ||
|
|
0cdbc144b3 | ||
|
|
dc63ec24c0 | ||
|
|
6d4c6c36a5 | ||
|
|
464af5f4d9 | ||
|
|
aa94a46a3d | ||
|
|
8542197dc3 | ||
|
|
64d77eaef4 | ||
|
|
675ef8794c | ||
|
|
4bcdbeb516 | ||
|
|
a9f37739fb | ||
|
|
5ab0fc19da | ||
|
|
7b232425f3 | ||
|
|
c0e7668140 | ||
|
|
ae3f059257 | ||
|
|
d44c7c1e95 | ||
|
|
6cd9d134cf | ||
|
|
6f21ba8afc | ||
|
|
b2fe13c117 | ||
|
|
03ece7a262 | ||
|
|
697fde2d0e | ||
|
|
7f0f85b356 | ||
|
|
a7d41debfe | ||
|
|
57bf758108 | ||
|
|
b37d933047 | ||
|
|
80ffef4555 | ||
|
|
af5a0002aa | ||
|
|
cd24e412e3 | ||
|
|
a073d843ab | ||
|
|
8ccb59ed18 | ||
|
|
e6dc7d2d0d | ||
|
|
f7b21ca3f6 | ||
|
|
df3a1bcdb6 | ||
|
|
5bec494a7c | ||
|
|
d8eef4dd9f | ||
|
|
d80138caf3 | ||
|
|
d26c103aa5 | ||
|
|
249231f57e | ||
|
|
7a838cddad | ||
|
|
477f2cd6db | ||
|
|
9915f05f73 | ||
|
|
0a16bb2fba | ||
|
|
3d4faf2406 | ||
|
|
63de6fe833 | ||
|
|
9c6219a58a | ||
|
|
3e664d4287 | ||
|
|
4c4ffd84f3 | ||
|
|
f555f0e392 | ||
|
|
124166738b | ||
|
|
8b32dcc576 | ||
|
|
105651215a | ||
|
|
d9c6cbd072 | ||
|
|
b4c52e06fe | ||
|
|
2565dfe528 | ||
|
|
b5036c9148 | ||
|
|
e2c8ddb8f7 | ||
|
|
85fbd284fa | ||
|
|
559fb69a64 | ||
|
|
054c76716a | ||
|
|
ba17c9350f | ||
|
|
781015eea4 | ||
|
|
13e5c0e8d2 | ||
|
|
4d88a30848 | ||
|
|
19c03de9e4 | ||
|
|
e3169c9f2d | ||
|
|
e90fb0b56f | ||
|
|
69607a5122 | ||
|
|
21eb8e6d9f | ||
|
|
a28a6b9dc4 | ||
|
|
a9cadbafeb | ||
|
|
d491af5a8d | ||
|
|
39c7934fb8 | ||
|
|
76eba8cc63 | ||
|
|
7549ff2491 | ||
|
|
66cd18bc4b | ||
|
|
44d7a2c340 | ||
|
|
d6ee63a01f | ||
|
|
a495c9cacd | ||
|
|
530185d15c | ||
|
|
8dd28c25a7 | ||
|
|
50884b236c | ||
|
|
da9fe09e58 | ||
|
|
3c24c9aa7a | ||
|
|
9d10a4b46f | ||
|
|
d56ed3bd06 | ||
|
|
41c1c429d0 | ||
|
|
1a3d890b40 | ||
|
|
e3ec9b2ccd | ||
|
|
f69878a242 | ||
|
|
ea766706fb | ||
|
|
306507af80 | ||
|
|
67084783b2 | ||
|
|
7cbb75f717 | ||
|
|
1c335492d5 | ||
|
|
de92e74c8e | ||
|
|
9cbbd30618 | ||
|
|
f14f1493c4 | ||
|
|
e68c8fdbe1 | ||
|
|
e094972aa2 | ||
|
|
ff831c6d2b | ||
|
|
9957cda11a | ||
|
|
6809822000 | ||
|
|
8048b1a9aa | ||
|
|
8557bd018a | ||
|
|
183d5fd162 | ||
|
|
411f86fbeb | ||
|
|
5493046f25 | ||
|
|
42015015a5 | ||
|
|
f3d2808f7d | ||
|
|
906c353a7f | ||
|
|
93dea83cd3 | ||
|
|
1fc76ce1ad | ||
|
|
a337b01bc7 | ||
|
|
689e5c0004 | ||
|
|
d5a3c81c85 | ||
|
|
8230fde5d2 | ||
|
|
b35513ea84 | ||
|
|
42a7785ca1 | ||
|
|
ea5ee4f04f | ||
|
|
3e14b12d4f | ||
|
|
78cc30f828 | ||
|
|
6091c84e60 | ||
|
|
6ea95ad254 | ||
|
|
7f888d926e | ||
|
|
5e4e02474f | ||
|
|
bff8611b42 | ||
|
|
f674048af3 | ||
|
|
0265c24cf9 | ||
|
|
f8c3a229ec | ||
|
|
c424f40420 | ||
|
|
b77666cfe5 | ||
|
|
193d1604d9 | ||
|
|
4efc6296b5 | ||
|
|
f753a4bdda | ||
|
|
afaaaf9657 | ||
|
|
4d46896bf0 | ||
|
|
2ad28c927f | ||
|
|
b9680a66ef | ||
|
|
4f687d5857 | ||
|
|
9cca026834 | ||
|
|
058a9cd192 | ||
|
|
57d2ede86e | ||
|
|
e3abea4ec5 | ||
|
|
b831f1f35c | ||
|
|
74bce1308c | ||
|
|
98cfa6d2c8 | ||
|
|
99a02a2186 | ||
|
|
3431a813b1 | ||
|
|
e9e0e8d32b | ||
|
|
2d14409d35 | ||
|
|
a8200e5c58 | ||
|
|
79a8df8b06 | ||
|
|
061a5f0262 | ||
|
|
821bdb3b0f | ||
|
|
606dfa9299 | ||
|
|
131357c616 | ||
|
|
f6d3493bad | ||
|
|
0c6104e25b | ||
|
|
d73735a35d | ||
|
|
e725d2d6b6 | ||
|
|
f0e1279d68 | ||
|
|
c74c74d2c4 | ||
|
|
aa70cf5dcd | ||
|
|
1055259627 | ||
|
|
302d37b6ef | ||
|
|
8532a73d94 | ||
|
|
ffafb272cb | ||
|
|
22e0171a34 | ||
|
|
2b410f040c | ||
|
|
259e8ad4e5 | ||
|
|
21244dd9f5 | ||
|
|
bc6206180d | ||
|
|
6e22d21358 | ||
|
|
95bdb4e700 | ||
|
|
9b7dbc68ab | ||
|
|
dc86c9b0db | ||
|
|
cb92ed753f | ||
|
|
10a085e24e | ||
|
|
3400a39edf | ||
|
|
21efffa345 | ||
|
|
e2e80ba7e5 | ||
|
|
d988dba66e | ||
|
|
403201fbff | ||
|
|
3cc93b51bb | ||
|
|
6a7d83bb45 | ||
|
|
19c8db8b31 | ||
|
|
0d75688ec8 | ||
|
|
e01dcb2f5b | ||
|
|
57757e2c14 | ||
|
|
779cd2fcfe | ||
|
|
94919f22e4 | ||
|
|
d5cf690703 | ||
|
|
191574dace | ||
|
|
ee7c6792c9 | ||
|
|
e2962dc2eb | ||
|
|
8c335cb8fd | ||
|
|
461c18a591 | ||
|
|
363837ab26 | ||
|
|
a184485421 | ||
|
|
f992c3f1a6 | ||
|
|
3219a9e313 | ||
|
|
4717c31058 | ||
|
|
693844828b | ||
|
|
ef4b479638 | ||
|
|
8eefb1bcfb | ||
|
|
ada9a5039b | ||
|
|
cca2d49cc3 | ||
|
|
f4a43e9950 | ||
|
|
9a89b39b62 | ||
|
|
2dba844b6c | ||
|
|
3101dc91de | ||
|
|
83cacd97f3 | ||
|
|
aa179c21f8 | ||
|
|
31cf4d8bb2 | ||
|
|
19bcc2c0da | ||
|
|
ca803ff7ce | ||
|
|
0e26d975aa | ||
|
|
86a3cb67f2 | ||
|
|
6297463445 | ||
|
|
1a3a3076b1 | ||
|
|
7fb9cfeaf1 | ||
|
|
5c7dbe6304 | ||
|
|
c41fd9216a | ||
|
|
91a9ad79f0 | ||
|
|
906458dc96 | ||
|
|
2f4fddf539 | ||
|
|
a8157143b9 | ||
|
|
92576e28e9 | ||
|
|
a6e34a2960 | ||
|
|
306cf7aab7 | ||
|
|
f3b806686d | ||
|
|
6634cfb991 | ||
|
|
9930bb68b2 | ||
|
|
37722131e5 | ||
|
|
5f2d213419 | ||
|
|
e119941762 | ||
|
|
ba496c1395 | ||
|
|
9c3fc84542 | ||
|
|
b017ce936a | ||
|
|
d696b0581b | ||
|
|
e4b2880f2b | ||
|
|
e071049969 | ||
|
|
5e1f592951 | ||
|
|
231f82da28 | ||
|
|
9a28bc7334 | ||
|
|
00907e92ff | ||
|
|
5669798881 | ||
|
|
f3a62a5f75 | ||
|
|
3b20ed222c | ||
|
|
1f40f3f59c | ||
|
|
a8d890524f | ||
|
|
b635799e80 | ||
|
|
50ea66620d | ||
|
|
46581d0e22 | ||
|
|
a3562867a6 | ||
|
|
c0117ada93 | ||
|
|
a3dcb2c03e | ||
|
|
8f8aaa5a1d | ||
|
|
85482422fd | ||
|
|
643c969faf | ||
|
|
85f9469d6d | ||
|
|
0df0d50695 | ||
|
|
5e9256c197 | ||
|
|
b67e1a92f5 | ||
|
|
d250e4bc26 | ||
|
|
dcf1f41f2d | ||
|
|
3df6ba1457 | ||
|
|
b89928f6c6 | ||
|
|
2e014484e3 | ||
|
|
3b2b18fd2e | ||
|
|
ebf1e592ff | ||
|
|
88404b91d8 | ||
|
|
9cbb60313c | ||
|
|
b95d417f5e | ||
|
|
994f1fb121 | ||
|
|
e533c1fa4b | ||
|
|
d0d946ffe9 | ||
|
|
e3bcc824c7 | ||
|
|
357e4e207f | ||
|
|
2aee961600 | ||
|
|
3aa1987319 | ||
|
|
ae15f61fc2 | ||
|
|
e58f92a812 | ||
|
|
46383924b1 | ||
|
|
071920e864 | ||
|
|
012238e6a9 | ||
|
|
a565566c50 | ||
|
|
550804c666 | ||
|
|
f7a4a33f5e | ||
|
|
f1b19ebae3 | ||
|
|
4049fa2e17 | ||
|
|
28808cf4f5 | ||
|
|
870b46cf9d | ||
|
|
9c20dea99c | ||
|
|
63c7679067 | ||
|
|
764c1a6430 | ||
|
|
bb6578bdd0 | ||
|
|
748c8531ad | ||
|
|
a734fe68d2 | ||
|
|
cc5ebc55a4 | ||
|
|
aa396c1e1c | ||
|
|
fbf87ff291 | ||
|
|
e9f3ffddf4 | ||
|
|
695518d68b | ||
|
|
5d96c1e12b | ||
|
|
3a72a1cc04 | ||
|
|
54f5714108 | ||
|
|
04811c7eca | ||
|
|
6856736ddb | ||
|
|
db6c43993a | ||
|
|
508a22576a | ||
|
|
8fb012b3a1 | ||
|
|
133781d314 | ||
|
|
50cb12896e | ||
|
|
79a4315941 | ||
|
|
33a2f76521 | ||
|
|
d4041a1d88 | ||
|
|
09f2f56446 | ||
|
|
a0c3eda506 | ||
|
|
84de3199cc | ||
|
|
a7e8309d63 | ||
|
|
7e74d2f6f4 | ||
|
|
dc25d53dc0 | ||
|
|
ac1a927836 | ||
|
|
b50b69adb2 | ||
|
|
b112e912af | ||
|
|
ece55727d3 | ||
|
|
181dd24b57 | ||
|
|
10008ca0e8 | ||
|
|
134c4621a8 | ||
|
|
51f15bf487 | ||
|
|
49ae2c88ad | ||
|
|
43a628fc55 | ||
|
|
7f71f95f7c | ||
|
|
e8d5eab419 | ||
|
|
de3a6b1f20 | ||
|
|
849742e19a | ||
|
|
b6392b114c | ||
|
|
4db0c775ff | ||
|
|
ff9374f1ed | ||
|
|
ea86c9bb1f | ||
|
|
e6dd088abe | ||
|
|
c039d8f3a4 | ||
|
|
bffa6329fd | ||
|
|
b88e5d2847 | ||
|
|
0fc4fcd406 | ||
|
|
f04ca21394 | ||
|
|
a82fca130f | ||
|
|
70d494798c | ||
|
|
cf02bf221b | ||
|
|
9eb03d7455 | ||
|
|
12c8fdeec2 | ||
|
|
851babfe2a | ||
|
|
859490806b | ||
|
|
2c828b50da | ||
|
|
ede7834cb8 | ||
|
|
3627ee369d | ||
|
|
c4c41d1494 | ||
|
|
c577e77f8f | ||
|
|
9218f19832 | ||
|
|
ecbc2133a4 | ||
|
|
e38ca66c51 | ||
|
|
2395a2670e | ||
|
|
e7748d787f | ||
|
|
012ce71134 | ||
|
|
1b1a3f49c1 | ||
|
|
5b77860189 | ||
|
|
b333e8d90a | ||
|
|
ab6457ef3f | ||
|
|
5c69daec08 | ||
|
|
1bfa3ebb8e | ||
|
|
2694fea211 | ||
|
|
720eddeb66 | ||
|
|
ab334a7bc6 | ||
|
|
214dfe580a | ||
|
|
4ef53eab3a | ||
|
|
2f51547f0d | ||
|
|
da910ac336 | ||
|
|
643954f7c9 | ||
|
|
63061482d0 | ||
|
|
86d4f5a670 | ||
|
|
815093f1c6 | ||
|
|
47d39831d3 | ||
|
|
c18ed829aa | ||
|
|
e757e61b79 | ||
|
|
d612d83874 | ||
|
|
e170dfe60b | ||
|
|
69cd90edd8 | ||
|
|
f506f722c2 | ||
|
|
857736adad | ||
|
|
a92df774bd | ||
|
|
f2c6734c79 | ||
|
|
77b6cf75a5 | ||
|
|
3b56496196 | ||
|
|
aabbf0a5d1 | ||
|
|
9a43fd434f | ||
|
|
21ce9db4b0 | ||
|
|
044694487d | ||
|
|
3af8485326 | ||
|
|
f7adef0648 | ||
|
|
dc16e43154 | ||
|
|
78a5267198 | ||
|
|
04af355e0c | ||
|
|
89405009ec | ||
|
|
6b0aa32da2 | ||
|
|
aaf237d111 | ||
|
|
1fd48a0a40 | ||
|
|
09e0a51b46 | ||
|
|
cc32f8ad16 | ||
|
|
2f6ddf0e70 | ||
|
|
c3973755da | ||
|
|
42537a65b9 | ||
|
|
906c92e54f | ||
|
|
cc69968d78 | ||
|
|
dcde2083ec | ||
|
|
7469784059 | ||
|
|
c13a693456 | ||
|
|
e3c482d664 | ||
|
|
1fd33a5585 | ||
|
|
0742778e6a | ||
|
|
152479c888 | ||
|
|
a297f8c0c8 | ||
|
|
92aeee0572 | ||
|
|
050756517e | ||
|
|
0bb46f291a | ||
|
|
1eecabf105 | ||
|
|
da1bd8d32e | ||
|
|
124983a396 | ||
|
|
43613688da | ||
|
|
43aa69cd18 | ||
|
|
780b7666c5 | ||
|
|
70b4534e14 | ||
|
|
24666fd7fc | ||
|
|
de80aa6bb3 | ||
|
|
6c7e2ea847 | ||
|
|
6ea318acd3 | ||
|
|
2f4ee7cff8 | ||
|
|
9d9d758fa6 | ||
|
|
a071b7c265 | ||
|
|
3a57b68fa3 | ||
|
|
f2f36baf1b | ||
|
|
1aaf9e747a | ||
|
|
92611772a9 | ||
|
|
fb159dc46b | ||
|
|
78ea0873f2 | ||
|
|
faabc01dbc | ||
|
|
5acfe9e92a | ||
|
|
4388a8b6ce | ||
|
|
7414bd15b0 | ||
|
|
52d6021f3c | ||
|
|
f7acc27fcb | ||
|
|
175a293327 | ||
|
|
21dd6519b0 | ||
|
|
72f55c34b7 | ||
|
|
1b1d3c791b | ||
|
|
159c2c01a7 | ||
|
|
272f5b42f9 | ||
|
|
2395d0782e | ||
|
|
da81830e43 | ||
|
|
63a602cf8a | ||
|
|
0244b5c3e3 | ||
|
|
9592e86fa9 | ||
|
|
e6840bb50c | ||
|
|
b6890378a1 | ||
|
|
ba72ed0b93 | ||
|
|
e2fb576858 | ||
|
|
608b099b4d | ||
|
|
c2e0c81f7e | ||
|
|
7071d01a59 | ||
|
|
30cd2b9b53 | ||
|
|
abc498b09c | ||
|
|
31081e1089 | ||
|
|
4a16b8d072 | ||
|
|
9c04095292 | ||
|
|
643f98d59e | ||
|
|
f4da19183e | ||
|
|
de40f253b5 | ||
|
|
f6543e407a | ||
|
|
4d462a8e9e | ||
|
|
018ee1f3e6 | ||
|
|
752268fed1 | ||
|
|
8fe9a6cc3a | ||
|
|
b17a17ba10 | ||
|
|
b3545b60ea | ||
|
|
e6da3f693d | ||
|
|
4ab09da434 | ||
|
|
5e8daf29bf | ||
|
|
024a1067bb | ||
|
|
c427da72b9 | ||
|
|
346fb6b1ea | ||
|
|
1b658c76a3 | ||
|
|
1593ed62ba | ||
|
|
085eddd4b0 | ||
|
|
0db77ad2c0 | ||
|
|
6f8bcb6c6a | ||
|
|
4196dee896 | ||
|
|
6d49e0f0df | ||
|
|
d99f572989 | ||
|
|
fa197c33f1 | ||
|
|
1ce39a419e | ||
|
|
f0e3ac8fcb | ||
|
|
30947cea05 | ||
|
|
9134f36d3b | ||
|
|
dc526316a0 | ||
|
|
6593174668 | ||
|
|
0891c41abc | ||
|
|
6ecb6254aa | ||
|
|
84bd9eeeff | ||
|
|
2549c4d47b | ||
|
|
8750aa3dd6 | ||
|
|
262094a736 | ||
|
|
035201f917 | ||
|
|
ae9cbc5214 | ||
|
|
78d5bf129a | ||
|
|
1f02ddd163 | ||
|
|
eff1e8cc7b | ||
|
|
dc8475b59a | ||
|
|
921968662d | ||
|
|
4d83173dbd | ||
|
|
f13368cb96 | ||
|
|
ec7e97e1de | ||
|
|
d4c9bd1dd7 | ||
|
|
6bff657d4d | ||
|
|
613d286be1 | ||
|
|
fd48108f8b | ||
|
|
c3cbd18df9 | ||
|
|
6685057dae | ||
|
|
0dec0e3788 | ||
|
|
1a73dd4004 | ||
|
|
eae80a6450 | ||
|
|
21a32ce0eb | ||
|
|
325533c5d9 | ||
|
|
7d819022f6 | ||
|
|
dba944874b | ||
|
|
ce9c12ec92 | ||
|
|
22dfc5774f | ||
|
|
d59091ab2b | ||
|
|
f69146a6bf | ||
|
|
43cdf3db3b | ||
|
|
280a354228 | ||
|
|
573b0431f9 | ||
|
|
9878b60e97 | ||
|
|
964033c2a7 | ||
|
|
d2e45aca91 | ||
|
|
daa99a2efc | ||
|
|
e986e9999a | ||
|
|
98d302cb94 | ||
|
|
bf11c4a7e4 | ||
|
|
e1cab952f8 | ||
|
|
bc28d4de27 | ||
|
|
bb901564e3 | ||
|
|
93acc9ded1 | ||
|
|
9b1c6a371e | ||
|
|
82bf8cd807 | ||
|
|
c2f2780c3f | ||
|
|
08f71d1f6f | ||
|
|
f498088beb | ||
|
|
347b41cf35 | ||
|
|
61ae90ad28 | ||
|
|
9a42fbafb2 | ||
|
|
938f9e9434 | ||
|
|
9004e453c2 | ||
|
|
7d33542691 | ||
|
|
c99348862c | ||
|
|
ac86db3966 | ||
|
|
e368810731 | ||
|
|
edae2f5a61 | ||
|
|
ab17c6f44e | ||
|
|
59dbae4f66 | ||
|
|
d7956292df | ||
|
|
1075497559 | ||
|
|
2d99fa03d3 | ||
|
|
72b64b6f0d | ||
|
|
a2096d3622 | ||
|
|
c81f9fb7b1 | ||
|
|
cc7e9e21fb | ||
|
|
803d537e51 | ||
|
|
9a83e5b6ef | ||
|
|
4323da9007 | ||
|
|
30b9b24be4 | ||
|
|
b191b00003 | ||
|
|
7e5cdcba34 | ||
|
|
45b30ad333 | ||
|
|
7ca087b0a6 | ||
|
|
188e4594fd | ||
|
|
2da80ce7d8 | ||
|
|
d5820f9aa5 | ||
|
|
b1a0aae0a5 | ||
|
|
cdd4d4b063 | ||
|
|
21f675e80b | ||
|
|
380724d73e | ||
|
|
2d26c5dee3 | ||
|
|
29bcc5ccf5 | ||
|
|
91497ab45a | ||
|
|
be77968570 | ||
|
|
a42dacc48d |
@@ -1,6 +1 @@
|
|||||||
# ignore everything
|
commafeed-client
|
||||||
*
|
|
||||||
|
|
||||||
# allow only what we need
|
|
||||||
!commafeed-server/target/commafeed.jar
|
|
||||||
!commafeed-server/config.yml.example
|
|
||||||
3
.gitattributes
vendored
Normal file
3
.gitattributes
vendored
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
* text eol=lf
|
||||||
|
*.cmd text eol=crlf
|
||||||
|
*.png binary
|
||||||
7
.github/FUNDING.yml
vendored
7
.github/FUNDING.yml
vendored
@@ -1,2 +1,5 @@
|
|||||||
github: [athou]
|
github: [ athou ]
|
||||||
custom: ['https://www.paypal.com/donate/?business=9CNQHMJG2ZJVY&no_recurring=0&item_name=CommaFeed¤cy_code=EUR']
|
custom: [
|
||||||
|
'https://github.com/sponsors/Athou',
|
||||||
|
'https://www.paypal.com/donate/?business=9CNQHMJG2ZJVY&no_recurring=0&item_name=CommaFeed¤cy_code=EUR'
|
||||||
|
]
|
||||||
|
|||||||
3
.github/ISSUE_TEMPLATE/bug_report.md
vendored
3
.github/ISSUE_TEMPLATE/bug_report.md
vendored
@@ -25,7 +25,8 @@ If applicable, add screenshots to help explain your problem.
|
|||||||
|
|
||||||
**Environment (please complete the following information):**
|
**Environment (please complete the following information):**
|
||||||
|
|
||||||
- CommaFeed version (or "commafeed.com"): 3.2.1
|
- commafeed.com or self-hosted:
|
||||||
|
- If self-hosted, CommaFeed version [e.g. 5.1.0 (a3dcb2c)]:
|
||||||
- Browser [e.g. chrome, firefox]:
|
- Browser [e.g. chrome, firefox]:
|
||||||
- Device [e.g. desktop, mobile]:
|
- Device [e.g. desktop, mobile]:
|
||||||
|
|
||||||
|
|||||||
1
.github/stale.yml
vendored
1
.github/stale.yml
vendored
@@ -7,6 +7,7 @@ exemptLabels:
|
|||||||
- pinned
|
- pinned
|
||||||
- security
|
- security
|
||||||
- enhancement
|
- enhancement
|
||||||
|
- feature-request
|
||||||
- bug
|
- bug
|
||||||
# Label to use when marking an issue as stale
|
# Label to use when marking an issue as stale
|
||||||
staleLabel: wontfix
|
staleLabel: wontfix
|
||||||
|
|||||||
97
.github/workflows/build.yml
vendored
97
.github/workflows/build.yml
vendored
@@ -1,97 +0,0 @@
|
|||||||
name: Java CI
|
|
||||||
|
|
||||||
on: [ push ]
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
build:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
strategy:
|
|
||||||
matrix:
|
|
||||||
java: [ "17", "21" ]
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- name: Checkout
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
with:
|
|
||||||
fetch-depth: 0
|
|
||||||
|
|
||||||
# Setup
|
|
||||||
- name: Set up QEMU
|
|
||||||
uses: docker/setup-qemu-action@v3
|
|
||||||
|
|
||||||
- name: Set up Docker Buildx
|
|
||||||
uses: docker/setup-buildx-action@v3
|
|
||||||
|
|
||||||
- name: Set up Java
|
|
||||||
uses: actions/setup-java@v4
|
|
||||||
with:
|
|
||||||
java-version: ${{ matrix.java }}
|
|
||||||
distribution: "temurin"
|
|
||||||
cache: "maven"
|
|
||||||
|
|
||||||
# Build
|
|
||||||
- name: Build with Maven
|
|
||||||
run: mvn --batch-mode --update-snapshots verify
|
|
||||||
|
|
||||||
- name: Upload JAR
|
|
||||||
uses: actions/upload-artifact@v4
|
|
||||||
if: ${{ matrix.java == '17' }}
|
|
||||||
with:
|
|
||||||
name: commafeed.jar
|
|
||||||
path: commafeed-server/target/commafeed.jar
|
|
||||||
|
|
||||||
- name: Upload Playwright artifacts
|
|
||||||
if: failure()
|
|
||||||
uses: actions/upload-artifact@v4
|
|
||||||
with:
|
|
||||||
name: playwright-artifacts
|
|
||||||
path: |
|
|
||||||
**/target/playwright-artifacts/
|
|
||||||
|
|
||||||
# Docker
|
|
||||||
- name: Login to Container Registry
|
|
||||||
uses: docker/login-action@v3
|
|
||||||
if: ${{ matrix.java == '17' && (github.ref_type == 'tag' || github.ref_name == 'master') }}
|
|
||||||
with:
|
|
||||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
|
||||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
|
||||||
|
|
||||||
- name: Docker build and push tag
|
|
||||||
uses: docker/build-push-action@v5
|
|
||||||
if: ${{ matrix.java == '17' && github.ref_type == 'tag' }}
|
|
||||||
with:
|
|
||||||
context: .
|
|
||||||
push: true
|
|
||||||
platforms: linux/amd64,linux/arm64/v8
|
|
||||||
tags: |
|
|
||||||
athou/commafeed:latest
|
|
||||||
athou/commafeed:${{ github.ref_name }}
|
|
||||||
|
|
||||||
- name: Docker build and push master
|
|
||||||
uses: docker/build-push-action@v5
|
|
||||||
if: ${{ matrix.java == '17' && github.ref_name == 'master' }}
|
|
||||||
with:
|
|
||||||
context: .
|
|
||||||
push: true
|
|
||||||
platforms: linux/amd64,linux/arm64/v8
|
|
||||||
tags: athou/commafeed:master
|
|
||||||
|
|
||||||
# Create GitHub release after Docker image has been published
|
|
||||||
- name: Extract Changelog Entry
|
|
||||||
uses: mindsers/changelog-reader-action@v2
|
|
||||||
if: ${{ matrix.java == '17' && github.ref_type == 'tag' }}
|
|
||||||
id: changelog_reader
|
|
||||||
with:
|
|
||||||
version: ${{ github.ref_name }}
|
|
||||||
|
|
||||||
- name: Create GitHub release
|
|
||||||
uses: softprops/action-gh-release@v2
|
|
||||||
if: ${{ matrix.java == '17' && github.ref_type == 'tag' }}
|
|
||||||
with:
|
|
||||||
name: CommaFeed ${{ github.ref_name }}
|
|
||||||
body: ${{ steps.changelog_reader.outputs.changes }}
|
|
||||||
draft: false
|
|
||||||
prerelease: false
|
|
||||||
files: |
|
|
||||||
commafeed-server/target/commafeed.jar
|
|
||||||
commafeed-server/config.yml.example
|
|
||||||
278
.github/workflows/ci.yml
vendored
Normal file
278
.github/workflows/ci.yml
vendored
Normal file
@@ -0,0 +1,278 @@
|
|||||||
|
name: ci
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
pull_request:
|
||||||
|
|
||||||
|
env:
|
||||||
|
JAVA_VERSION: 25
|
||||||
|
DOCKER_BUILD_SUMMARY: false
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
if: github.event_name != 'pull_request' || github.actor != 'renovate[bot]' # renovate already triggers the build on pushes
|
||||||
|
|
||||||
|
strategy:
|
||||||
|
matrix:
|
||||||
|
os: [ "ubuntu-latest", "ubuntu-22.04-arm", "windows-latest" ]
|
||||||
|
database: [ "h2", "postgresql", "mysql", "mariadb" ]
|
||||||
|
|
||||||
|
runs-on: ${{ matrix.os }}
|
||||||
|
steps:
|
||||||
|
# Checkout
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
|
||||||
|
# Setup
|
||||||
|
- name: Set up GraalVM
|
||||||
|
uses: graalvm/setup-graalvm@03e8abf916fd0e281b2efe7b2da3378bb0a1d085 # v1
|
||||||
|
with:
|
||||||
|
java-version: ${{ env.JAVA_VERSION }}
|
||||||
|
distribution: "graalvm"
|
||||||
|
cache: "maven"
|
||||||
|
|
||||||
|
- name: Install Playwright dependencies
|
||||||
|
run: sudo apt-get install -y libgbm1
|
||||||
|
if: matrix.os != 'windows-latest'
|
||||||
|
|
||||||
|
# Build & Test
|
||||||
|
- name: Build with Maven
|
||||||
|
run: mvn --batch-mode --no-transfer-progress install -Pnative -P${{ matrix.database }} -DskipTests=${{ matrix.os == 'windows-latest' && matrix.database != 'h2' }}
|
||||||
|
|
||||||
|
# Build pages
|
||||||
|
- name: Create pages directory structure
|
||||||
|
run: mkdir -p target/pages/documentation/custom-css
|
||||||
|
|
||||||
|
- name: Convert readme file to html
|
||||||
|
uses: jaywcjlove/markdown-to-html-cli@cff9330af4ca8048b197a76d9eb1db189c2a7cee # v5.0.4
|
||||||
|
with:
|
||||||
|
source: README.md
|
||||||
|
output: target/pages/index.html
|
||||||
|
|
||||||
|
- name: Convert config documentation to html
|
||||||
|
uses: jaywcjlove/markdown-to-html-cli@cff9330af4ca8048b197a76d9eb1db189c2a7cee # v5.0.4
|
||||||
|
with:
|
||||||
|
source: commafeed-server/target/quarkus-generated-doc/config/commafeed-server.md
|
||||||
|
output: target/pages/documentation/index.html
|
||||||
|
|
||||||
|
- name: Convert custom css documentation to html
|
||||||
|
uses: jaywcjlove/markdown-to-html-cli@cff9330af4ca8048b197a76d9eb1db189c2a7cee # v5.0.4
|
||||||
|
with:
|
||||||
|
source: documentation/CUSTOMCSS.md
|
||||||
|
output: target/pages/documentation/custom-css/index.html
|
||||||
|
|
||||||
|
# Upload artifacts
|
||||||
|
- name: Upload cross-platform app
|
||||||
|
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7
|
||||||
|
if: matrix.os == 'ubuntu-latest' # we only need to upload the cross-platform artifact once per database
|
||||||
|
with:
|
||||||
|
name: commafeed-${{ matrix.database }}-jvm
|
||||||
|
path: commafeed-server/target/commafeed-*.zip
|
||||||
|
|
||||||
|
- name: Upload native executable
|
||||||
|
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7
|
||||||
|
with:
|
||||||
|
name: commafeed-${{ matrix.database }}-${{ runner.os }}-${{ runner.arch }}
|
||||||
|
path: commafeed-server/target/commafeed-*-runner*
|
||||||
|
|
||||||
|
- name: Upload pages
|
||||||
|
uses: actions/upload-pages-artifact@7b1f4a764d45c48632c6b24a0339c27f5614fb0b # v4
|
||||||
|
if: matrix.os == 'ubuntu-latest' && matrix.database == 'h2' # we only need to upload the pages once
|
||||||
|
with:
|
||||||
|
path: target/pages
|
||||||
|
|
||||||
|
docker:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
needs: build
|
||||||
|
env:
|
||||||
|
DOCKERHUB_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||||
|
|
||||||
|
strategy:
|
||||||
|
matrix:
|
||||||
|
database: [ "h2", "postgresql", "mysql", "mariadb" ]
|
||||||
|
|
||||||
|
steps:
|
||||||
|
# Checkout
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
|
||||||
|
# Setup
|
||||||
|
- name: Set up QEMU
|
||||||
|
uses: docker/setup-qemu-action@ce360397dd3f832beb865e1373c09c0e9f86d70a # v4
|
||||||
|
|
||||||
|
- name: Set up Docker Buildx
|
||||||
|
uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4
|
||||||
|
|
||||||
|
- name: Install required packages
|
||||||
|
run: sudo apt-get install -y rename unzip
|
||||||
|
|
||||||
|
# Prepare artifacts
|
||||||
|
- name: Download artifacts
|
||||||
|
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8
|
||||||
|
with:
|
||||||
|
pattern: commafeed-${{ matrix.database }}-*
|
||||||
|
path: ./artifacts
|
||||||
|
merge-multiple: true
|
||||||
|
|
||||||
|
- name: Set the exec flag on the native executables
|
||||||
|
run: chmod +x artifacts/*-runner
|
||||||
|
|
||||||
|
- name: Rename native executables to match buildx TARGETARCH
|
||||||
|
run: |
|
||||||
|
rename 's/x86_64/amd64/g' artifacts/*
|
||||||
|
rename 's/aarch_64/arm64/g' artifacts/*
|
||||||
|
|
||||||
|
- name: Unzip jvm package
|
||||||
|
run: |
|
||||||
|
unzip artifacts/*-jvm.zip -d artifacts/extracted-jvm-package
|
||||||
|
rename 's/commafeed-.*/quarkus-app/g' artifacts/extracted-jvm-package/*
|
||||||
|
|
||||||
|
# Docker
|
||||||
|
- name: Login to Container Registry
|
||||||
|
uses: docker/login-action@b45d80f862d83dbcd57f89517bcf500b2ab88fb2 # v4
|
||||||
|
if: ${{ env.DOCKERHUB_USERNAME != '' }}
|
||||||
|
with:
|
||||||
|
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||||
|
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||||
|
|
||||||
|
## build but don't push for PRs and renovate
|
||||||
|
- name: Docker build - native
|
||||||
|
uses: docker/build-push-action@d08e5c354a6adb9ed34480a06d141179aa583294 # v7
|
||||||
|
with:
|
||||||
|
context: .
|
||||||
|
file: commafeed-server/src/main/docker/Dockerfile.native
|
||||||
|
push: false
|
||||||
|
platforms: linux/amd64,linux/arm64/v8
|
||||||
|
|
||||||
|
- name: Docker build - jvm
|
||||||
|
uses: docker/build-push-action@d08e5c354a6adb9ed34480a06d141179aa583294 # v7
|
||||||
|
with:
|
||||||
|
context: .
|
||||||
|
file: commafeed-server/src/main/docker/Dockerfile.jvm
|
||||||
|
push: false
|
||||||
|
platforms: linux/amd64,linux/arm64/v8
|
||||||
|
|
||||||
|
## build and push tag
|
||||||
|
- name: Docker build and push tag - native
|
||||||
|
uses: docker/build-push-action@d08e5c354a6adb9ed34480a06d141179aa583294 # v7
|
||||||
|
if: ${{ github.ref_type == 'tag' }}
|
||||||
|
with:
|
||||||
|
context: .
|
||||||
|
file: commafeed-server/src/main/docker/Dockerfile.native
|
||||||
|
push: ${{ env.DOCKERHUB_USERNAME != '' }}
|
||||||
|
platforms: linux/amd64,linux/arm64/v8
|
||||||
|
tags: |
|
||||||
|
athou/commafeed:latest-${{ matrix.database }}
|
||||||
|
athou/commafeed:${{ github.ref_name }}-${{ matrix.database }}
|
||||||
|
|
||||||
|
- name: Docker build and push tag - jvm
|
||||||
|
uses: docker/build-push-action@d08e5c354a6adb9ed34480a06d141179aa583294 # v7
|
||||||
|
if: ${{ github.ref_type == 'tag' }}
|
||||||
|
with:
|
||||||
|
context: .
|
||||||
|
file: commafeed-server/src/main/docker/Dockerfile.jvm
|
||||||
|
push: ${{ env.DOCKERHUB_USERNAME != '' }}
|
||||||
|
platforms: linux/amd64,linux/arm64/v8
|
||||||
|
tags: |
|
||||||
|
athou/commafeed:latest-${{ matrix.database }}-jvm
|
||||||
|
athou/commafeed:${{ github.ref_name }}-${{ matrix.database }}-jvm
|
||||||
|
|
||||||
|
## build and push master
|
||||||
|
- name: Docker build and push master - native
|
||||||
|
uses: docker/build-push-action@d08e5c354a6adb9ed34480a06d141179aa583294 # v7
|
||||||
|
if: ${{ github.ref_name == 'master' }}
|
||||||
|
with:
|
||||||
|
context: .
|
||||||
|
file: commafeed-server/src/main/docker/Dockerfile.native
|
||||||
|
push: ${{ env.DOCKERHUB_USERNAME != '' }}
|
||||||
|
platforms: linux/amd64,linux/arm64/v8
|
||||||
|
tags: athou/commafeed:master-${{ matrix.database }}
|
||||||
|
|
||||||
|
- name: Docker build and push master - jvm
|
||||||
|
uses: docker/build-push-action@d08e5c354a6adb9ed34480a06d141179aa583294 # v7
|
||||||
|
if: ${{ github.ref_name == 'master' }}
|
||||||
|
with:
|
||||||
|
context: .
|
||||||
|
file: commafeed-server/src/main/docker/Dockerfile.jvm
|
||||||
|
push: ${{ env.DOCKERHUB_USERNAME != '' }}
|
||||||
|
platforms: linux/amd64,linux/arm64/v8
|
||||||
|
tags: athou/commafeed:master-${{ matrix.database }}-jvm
|
||||||
|
|
||||||
|
release:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
needs:
|
||||||
|
- build
|
||||||
|
- docker
|
||||||
|
permissions:
|
||||||
|
contents: write
|
||||||
|
if: github.ref_type == 'tag'
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
|
||||||
|
- name: Download artifacts
|
||||||
|
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8
|
||||||
|
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@32aa5b4c155d76c94e4ec883a223c947b2f02656 # v2
|
||||||
|
id: changelog_reader
|
||||||
|
with:
|
||||||
|
version: ${{ github.ref_name }}
|
||||||
|
|
||||||
|
- name: Create GitHub release
|
||||||
|
uses: ncipollo/release-action@339a81892b84b4eeb0f6e744e4574d79d0d9b8dd # v1
|
||||||
|
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@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
|
||||||
|
- name: Update Docker Hub Description
|
||||||
|
uses: peter-evans/dockerhub-description@1b9a80c056b620d92cedb9d9b5a223409c68ddfa # v5
|
||||||
|
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@d6db90164ac5ed86f2b6aed7e0febac5b3c0c03e # v4
|
||||||
|
id: deployment
|
||||||
19
.mvn/wrapper/maven-wrapper.properties
vendored
19
.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
|
|
||||||
# distributed with this work for additional information
|
|
||||||
# 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
|
distributionType=only-script
|
||||||
distributionUrl=https://repo.maven.apache.org/maven2/org/apache/maven/apache-maven/3.9.7/apache-maven-3.9.7-bin.zip
|
distributionUrl=https://repo.maven.apache.org/maven2/org/apache/maven/apache-maven/3.9.14/apache-maven-3.9.14-bin.zip
|
||||||
|
|||||||
214
CHANGELOG.md
214
CHANGELOG.md
@@ -1,5 +1,219 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## [7.0.0]
|
||||||
|
|
||||||
|
- Replaced the JEXL filter expression for marking feed entries as read automatically with a user-friendly visual query builder. Expressions are now evaluated with Common Expression Language, which is safer than JEXL and sanboxed by default.
|
||||||
|
- Added a per-feed setting for sending push notifications to ntfy, Gotify or Pushover when new feed entries are discovered (#1610)
|
||||||
|
- Added a per-feed setting for marking entries as read after a number of days (#2041)
|
||||||
|
- The default value of `commafeed.http-client.block-local-addresses` is now false, allowing users to subscribe to feeds only available on their local network. This may be a security risk (SSRF) if your instance is accessible by untrusted users, so you may want to set it to true if you host a public instance of CommaFeed with user registeration enabled.
|
||||||
|
- When `commafeed.http-client.block-local-addresses` is enabled, SSRF is now also mitigated by blocking public websites redirecting to local ones.
|
||||||
|
|
||||||
|
## [6.2.0]
|
||||||
|
|
||||||
|
- Starred entries are no longer deleted after a certain amount of time, they are now kept indefinitely. The new `commafeed.database.cleanup.keep-starred-entries` setting can be disabled to restore the previous behavior if you want to keep deleting starred entries during normal entries cleanup (#1581)
|
||||||
|
|
||||||
|
## [6.1.1]
|
||||||
|
|
||||||
|
- Fix old starred entries not loading if they were marked as read (#2031)
|
||||||
|
|
||||||
|
## [6.1.0]
|
||||||
|
|
||||||
|
- When clicking on the password reset link, a random password is no longer generated automatically. The user is now redirected to a page where they can set their own password (#2023)
|
||||||
|
- Use browser preferred language instead of English when using CommaFeed for the first time (#2018)
|
||||||
|
- The profile menu is now closed when scrolling the page (#2019)
|
||||||
|
- The "disable pull to refresh" feature is now disabled by default (#2030)
|
||||||
|
|
||||||
|
## [6.0.0]
|
||||||
|
|
||||||
|
- When booting CommaFeed for the first time, the default "admin" account is no longer created automatically. A setup wizard will guide you through the creation of an admin account
|
||||||
|
- Default password complexity requirements have been lowered for local network deployments, where strict password rules are often unnecessary. The `commafeed.users.strict-password-policy` setting has been replaced by `commafeed.users.minimum-password-length` with a default value of `4` (#1916)
|
||||||
|
- Email addresses are no longer required when creating users and when they update their profile. The `commafeed.users.email-address-required` setting has been added to restore the previous behavior (#1914)
|
||||||
|
- Java 25+ is now required to build and run CommaFeed
|
||||||
|
|
||||||
|
## [5.12.1]
|
||||||
|
|
||||||
|
- The favicon is now crispier (#1978)
|
||||||
|
- The ReadKit iOS app now works via the Fever API (#1602)
|
||||||
|
|
||||||
|
## [5.12.0]
|
||||||
|
|
||||||
|
- Added a setting to disable the "disable pull to refresh" feature because it messes with some browsers (#1168)
|
||||||
|
- Emojis in feeds are now correctly displayed (#1955)
|
||||||
|
- Don't show "Star/Unstar" in the context menu if the entry is too old to be starred (#1935)
|
||||||
|
- Invalid relative urls in feeds no longer prevent those feeds from being parsed (#1939)
|
||||||
|
- Fix an issue that could prevent large feeds from being parsed when using Java 24+ (#1961)
|
||||||
|
- Enforce user password validation when created in the admin view (#1937)
|
||||||
|
- The process in the docker native image is now called "commafeed" instead of "application"
|
||||||
|
|
||||||
|
## [5.11.1]
|
||||||
|
|
||||||
|
- The search limit of 3 characters has been removed (#1887)
|
||||||
|
- Fix an issue that caused feed filtering expressions to be incorrectly converted to lowercase when saving them (#1899)
|
||||||
|
|
||||||
|
## [5.11.0]
|
||||||
|
|
||||||
|
- Add an option to navigate to the next unread category/feed when marking all entries as read (#1807)
|
||||||
|
- Google Analytics support has been removed
|
||||||
|
|
||||||
|
## [5.10.0]
|
||||||
|
|
||||||
|
- Add an indicator next to each feed's unread count in the tree to show when new entries are discovered while the app is open (#1762)
|
||||||
|
- Feeds with uppercase HTTP:// or HTTPS:// URLs are now correctly handled again
|
||||||
|
- The aarch64 native executable now also works on the Raspberry Pi 5 (#1795)
|
||||||
|
- Improve general performance of the UI by reducing the number of re-renders, especially when a lot of entries are displayed (#1087)
|
||||||
|
|
||||||
|
## [5.9.0]
|
||||||
|
|
||||||
|
- A lot of CSS classes have been added to the elements of the application to ease custom CSS rules (#1757)
|
||||||
|
- Added a link in the README to the [documentation](https://athou.github.io/commafeed/documentation/custom-css/) of the new CSS classes
|
||||||
|
- Static resources are now cached for much longer (#1782)
|
||||||
|
|
||||||
|
## [5.8.0]
|
||||||
|
|
||||||
|
- A color picker is now available on the settings page to change the orange accent of the application (#1598)
|
||||||
|
- A font size slider is now available to change the size of the text of feed entries (#1462)
|
||||||
|
- The "mark all as read" confirmation setting now also applies to the "shift+a" keyboard shortcut (#1744)
|
||||||
|
- CommaFeed wil try to match the language of the browser before defaulting to english (#1767)
|
||||||
|
- The default value for the number of entries to keep above the selected entry when scrolling is now 1 instead of 0 to match what other feed readers do
|
||||||
|
|
||||||
|
## [5.7.0]
|
||||||
|
|
||||||
|
- Add Shift+J/Shift+K keyboard shortcuts to navigate to the next/previous feed or category with unread entries (#1558)
|
||||||
|
- Add the referrer "no-referrer" meta to index.html (#1724)
|
||||||
|
- Load custom JS code when the app is done loading (#1724)
|
||||||
|
- Correctly handle feeds that return an unmodified Last-Modified header but a different ETag header (#1746)
|
||||||
|
- Restore gzip compression of responses that was accidentaly disabled since 5.0.0
|
||||||
|
- Fix tooltips not showing up in mobile view
|
||||||
|
- Fix the bookmarklet generator on the About page
|
||||||
|
|
||||||
|
## [5.6.1]
|
||||||
|
|
||||||
|
- Restore support for iframes in feed entries (#1688)
|
||||||
|
- There is now a package available for Arch Linux thanks to @dcelasun (#1691)
|
||||||
|
|
||||||
|
## [5.6.0]
|
||||||
|
|
||||||
|
- To better respect the bandwidth of feed owners, the default value of `commafeed.feed-refresh.interval-empirical` is now true. This means feeds no longer refresh exactly every 5 minutes (the default value of `commafeed.feed-refresh.interval`) but between 5 minutes and 4 hours (the default value of the new `commafeed.feed-refresh.max-interval` setting). The interval is calculated based on feed activity, so highly active feeds refresh more often (#1677)
|
||||||
|
- Many previously hardcoded values used in feed refresh interval calculation are now exposed as settings (#1677)
|
||||||
|
- Access to local addresses is now blocked to mitigate server-side request forgery (SSRF) attacks, which could potentially expose internal resources. You might want to disable the new `commafeed.http-client.block-local-addresses` setting if you subscribe to feeds only available on your local network and you trust all your users
|
||||||
|
- If a feed responds with a "429 - Too many requests" response, a backoff mechanism is triggered when the response does not contain a "Retry-After" header
|
||||||
|
|
||||||
|
## [5.5.0]
|
||||||
|
|
||||||
|
- CommaFeed now honors the Retry-After response header and will not try to refresh a feed sooner than the value of this header
|
||||||
|
- Audio enclosures (e.g. podcasts) now fill available entry width
|
||||||
|
- Fix an issue with some labels not correctly internationalized
|
||||||
|
|
||||||
|
## [5.4.0]
|
||||||
|
|
||||||
|
- An arm64 native executable is now available for download on the releases page
|
||||||
|
- The native executable Docker image now supports arm64
|
||||||
|
- Fixed an issue with feeds that declared an invalid DOCTYPE (#1260)
|
||||||
|
|
||||||
|
## [5.3.6]
|
||||||
|
|
||||||
|
- Ignore invalid Cache-Control header values (#1619)
|
||||||
|
|
||||||
|
## [5.3.5]
|
||||||
|
|
||||||
|
- Fixed an issue with the aspect ratio of images of some feeds (#1595)
|
||||||
|
- CommaFeed now honors the Cache-Control response header and will not try to refresh a feed sooner than its max-age property (#1615)
|
||||||
|
- Added support for compilation with JDK 23+. If you're building CommaFeed from sources with a JDK 17 or 21, you may need to update it to the most recent patch version to support `-proc:full` (#1618)
|
||||||
|
|
||||||
|
## [5.3.4]
|
||||||
|
|
||||||
|
- Added support for Internationalized Domain Names (#1588)
|
||||||
|
|
||||||
|
## [5.3.3]
|
||||||
|
|
||||||
|
- Removed image bottom margins (#1587)
|
||||||
|
|
||||||
|
## [5.3.2]
|
||||||
|
|
||||||
|
- Fixed an issue that could cause some images from not being rendered correctly (#1587)
|
||||||
|
|
||||||
|
## [5.3.1]
|
||||||
|
|
||||||
|
- Fixed an issue that could cause some HTTP feeds to return a 400 error (#1572)
|
||||||
|
|
||||||
|
## [5.3.0]
|
||||||
|
|
||||||
|
- Added a setting to set a cooldown on the "fetch all my feeds" action, disabled by default (#1556)
|
||||||
|
- Fixed an issue that could cause entries to not correctly load when using the "next" header button (#1557)
|
||||||
|
|
||||||
|
## [5.2.0]
|
||||||
|
|
||||||
|
- Added an option to keep a number of entries above the selected entry when scrolling
|
||||||
|
- Added a cache to the HTTP client to reduce the number of requests made to feeds when subscribing (#1431)
|
||||||
|
- Feeds are no longer refreshed between the moment its last user unsubscribes and the moment the feed is cleaned up (every hour)
|
||||||
|
- Fixed an issue that could cause entries to not correctly load when using keyboard navigation (#1557)
|
||||||
|
|
||||||
|
## [5.1.1]
|
||||||
|
|
||||||
|
- Fixed database migration issue when upgrading from 5.0.0 to 5.1.0 on MariaDB (#1544)
|
||||||
|
- When feeds without unread entries are hidden from the tree, the feed is displayed in the tree until another one is selected (#1543)
|
||||||
|
|
||||||
|
## [5.1.0]
|
||||||
|
|
||||||
|
- Added a setting for showing/hiding unread count in the browser's tab title/favicon (#1518)
|
||||||
|
- Fixed an issue that could prevent the app from starting on some systems (#1532)
|
||||||
|
- Added a cache busting filter for the webapp index.html and openapi documentation to make sure they are always up to date
|
||||||
|
- Reduced database cleanup log verbosity
|
||||||
|
|
||||||
|
## [5.0.2]
|
||||||
|
|
||||||
|
- Fix favicon fetching for Youtube channels in native mode when Google auth key is set
|
||||||
|
- Fix an error that appears in the logs when fetching some favicons
|
||||||
|
|
||||||
|
## [5.0.1]
|
||||||
|
|
||||||
|
- Configure native compilation to support older CPU architectures (#1524)
|
||||||
|
|
||||||
|
## [5.0.0]
|
||||||
|
|
||||||
|
CommaFeed is now powered by Quarkus instead of Dropwizard. Read the rationale behind this change in
|
||||||
|
the [announcement](https://github.com/Athou/commafeed/discussions/1517).
|
||||||
|
The gist of it is that CommaFeed can now be compiled to a native binary, resulting in blazing fast startup times (around
|
||||||
|
0.3s) and very low memory footprint (< 50M).
|
||||||
|
|
||||||
|
- CommaFeed now has a different package for each supported database.
|
||||||
|
- If you are deploying CommaFeed with a precompiled package, please
|
||||||
|
read [this section of the README](https://github.com/Athou/commafeed/tree/master?tab=readme-ov-file#download-a-precompiled-package).
|
||||||
|
- If you are building CommaFeed from sources, please
|
||||||
|
read [this section of the README](https://github.com/Athou/commafeed/tree/master?tab=readme-ov-file#build-from-sources).
|
||||||
|
- If you are using the Docker image, please read the instructions on
|
||||||
|
the [Docker Hub page](https://hub.docker.com/r/athou/commafeed).
|
||||||
|
- Due to the switch to Quarkus, the way CommaFeed is configured is very different (the `config.yml` file is gone).
|
||||||
|
Please
|
||||||
|
read [this section of the README](https://github.com/Athou/commafeed/tree/master?tab=readme-ov-file#configuration).
|
||||||
|
Note that a lot of configuration elements have been removed or renamed and are now nested/grouped by feature.
|
||||||
|
- Added a setting to prevent parsing large feeds to avoid out of memory errors. The default is 5MB.
|
||||||
|
- Use a different icon for filtering unread entries and marking an entry as read (#1506)
|
||||||
|
- Added various HTML attributes to ease custom JS/CSS customization (#1507)
|
||||||
|
- The Redis cache has been removed. There have been multiple enhancements to the feed refresh engine and it is no longer
|
||||||
|
needed, even for instances with a large number of feeds.
|
||||||
|
- The H2 migration tool that automatically upgrades H2 databases from format 2 to 3 has been removed. If you're using
|
||||||
|
the H2 embedded database, please upgrade to at least version 4.3.0 before upgrading to CommaFeed 5.0.0.
|
||||||
|
|
||||||
|
## [4.6.0]
|
||||||
|
|
||||||
|
- switched from Temurin to OpenJ9 as the JVM used in the Docker image, resulting in memory usage reduction by up to 50%
|
||||||
|
- fix an issue that could cause old entries to reappear if they were updated by their author (#1486)
|
||||||
|
- show all entries regardless of their read status when searching with keywords, even if the ui is configured to show
|
||||||
|
unread entries only
|
||||||
|
|
||||||
|
## [4.5.0]
|
||||||
|
|
||||||
|
- significantly reduce the time needed to retrieve entries or mark them as read, especially when there are a lot of
|
||||||
|
entries (#1452)
|
||||||
|
- fix a race condition where a feed could be refreshed before it was created in the database
|
||||||
|
- fix an issue that could cause the websocket notification to contain the wrong number of unread entries when using
|
||||||
|
mysql/mariadb
|
||||||
|
- fix an error when trying to mark all starred entries as read
|
||||||
|
- remove the `onlyIds` parameter from REST endpoints since retrieving all the entries is now just as fast
|
||||||
|
- remove support for microsoft sqlserver because it's not covered with integration tests (please open an issue if you'd
|
||||||
|
like it back)
|
||||||
|
|
||||||
## [4.4.1]
|
## [4.4.1]
|
||||||
|
|
||||||
- fix vertical scrolling issues with Safari (#1168)
|
- fix vertical scrolling issues with Safari (#1168)
|
||||||
|
|||||||
12
Dockerfile
12
Dockerfile
@@ -1,12 +0,0 @@
|
|||||||
FROM eclipse-temurin:21.0.3_9-jre
|
|
||||||
|
|
||||||
EXPOSE 8082
|
|
||||||
|
|
||||||
RUN mkdir -p /commafeed/data
|
|
||||||
VOLUME /commafeed/data
|
|
||||||
|
|
||||||
COPY commafeed-server/config.yml.example config.yml
|
|
||||||
COPY commafeed-server/target/commafeed.jar .
|
|
||||||
|
|
||||||
ENV JAVA_TOOL_OPTIONS -Djava.net.preferIPv4Stack=true -Xms20m -XX:+UseG1GC -XX:-ShrinkHeapInSteps -XX:G1PeriodicGCInterval=10000 -XX:-G1PeriodicGCInvokesConcurrent -XX:MinHeapFreeRatio=5 -XX:MaxHeapFreeRatio=10
|
|
||||||
CMD ["java", "-jar", "commafeed.jar", "server", "config.yml"]
|
|
||||||
22
README-fork.md
Normal file
22
README-fork.md
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
# `garrettmills/commafeed`
|
||||||
|
|
||||||
|
This is my personal fork of `Athou/commafeed` with some tweaks:
|
||||||
|
|
||||||
|
- "Infrequent" tab - like "All" but limits to blogs w/ an average post interval greater than a user-configurable number of days
|
||||||
|
- User preference to disable the swipe-to-open-menu gesture on mobile
|
||||||
|
|
||||||
|
## Building
|
||||||
|
|
||||||
|
Use `gmfork-build-docker.sh` to build the JVM Docker image for `linux/amd64`:
|
||||||
|
|
||||||
|
You can use the `DB_VARIANT` env var to change which DB the image builds with. By default, it builds the `postgresql` variant.
|
||||||
|
|
||||||
|
```sh
|
||||||
|
DOCKER_REGISTRY=myregistry.example.com DB_VARIANT=h2 ./gmfork-build-docker.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
To run locally:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
docker run -p 8082:8082 $DOCKER_REGISTRY/commafeed-fork:latest
|
||||||
|
```
|
||||||
134
README.md
134
README.md
@@ -1,6 +1,6 @@
|
|||||||
# CommaFeed
|
# CommaFeed
|
||||||
|
|
||||||
Google Reader inspired self-hosted RSS reader, based on Dropwizard and React/TypeScript.
|
Google Reader inspired self-hosted RSS reader, based on Quarkus and React/TypeScript.
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
@@ -8,20 +8,37 @@ Google Reader inspired self-hosted RSS reader, based on Dropwizard and React/Typ
|
|||||||
|
|
||||||
- 4 different layouts
|
- 4 different layouts
|
||||||
- Light/Dark theme
|
- Light/Dark theme
|
||||||
- Fully responsive
|
- Fully responsive, works great on both mobile and desktop
|
||||||
- Keyboard shortcuts for almost everything
|
- Keyboard shortcuts for almost everything
|
||||||
- Support for right-to-left feeds
|
- Support for right-to-left feeds
|
||||||
- Translated in 25+ languages
|
- Translated in 25+ languages
|
||||||
- Supports thousands of users and millions of feeds
|
- Supports thousands of users and millions of feeds
|
||||||
- OPML import/export
|
- OPML import/export
|
||||||
- REST API and a Fever-compatible API for native mobile apps
|
- REST API
|
||||||
|
- Fever-compatible API for native mobile apps
|
||||||
|
- 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
|
||||||
|
- Supports 4 databases
|
||||||
|
- H2 (embedded database)
|
||||||
|
- PostgreSQL
|
||||||
|
- MySQL
|
||||||
|
- 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
|
||||||
|
|
||||||
@@ -33,50 +50,115 @@ PikaPods shares 20% of the revenue back to CommaFeed.
|
|||||||
|
|
||||||
[](https://www.pikapods.com/pods?run=commafeed)
|
[](https://www.pikapods.com/pods?run=commafeed)
|
||||||
|
|
||||||
### Download precompiled package
|
### Download a precompiled package
|
||||||
|
|
||||||
mkdir commafeed && cd commafeed
|
Go to the [release page](https://github.com/Athou/commafeed/releases) and download the latest version for your operating
|
||||||
wget https://github.com/Athou/commafeed/releases/latest/download/commafeed.jar
|
system and database of choice.
|
||||||
wget https://github.com/Athou/commafeed/releases/latest/download/config.yml.example -O config.yml
|
|
||||||
java -Djava.net.preferIPv4Stack=true -jar commafeed.jar server config.yml
|
|
||||||
|
|
||||||
The server will listen on http://localhost:8082. The default
|
There are two types of packages:
|
||||||
user is `admin` and the default password is `admin`.
|
|
||||||
|
- The `linux-x86_64`, `linux-aarch_64` and `windows-x86_64` packages are compiled natively and contain an executable that can be run
|
||||||
|
directly.
|
||||||
|
- The `jvm` package is a zip file containing all `.jar` files required to run the application. This package works on all
|
||||||
|
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
|
||||||
|
memory usage.
|
||||||
|
|
||||||
### Build from sources
|
### Build from sources
|
||||||
|
|
||||||
git clone https://github.com/Athou/commafeed.git
|
./mvnw clean package [-P<database> [-Pnative]] [-DskipTests]
|
||||||
cd commafeed
|
|
||||||
./mvnw clean package
|
|
||||||
cp commafeed-server/config.yml.example config.yml
|
|
||||||
java -Djava.net.preferIPv4Stack=true -jar commafeed-server/target/commafeed.jar server config.yml
|
|
||||||
|
|
||||||
The server will listen on http://localhost:8082. The default
|
- `<database>` can be one of `h2`, `postgresql`, `mysql` or `mariadb`. The default is `h2`.
|
||||||
user is `admin` and the default password is `admin`.
|
- `-Pnative` compiles the application to native code. This requires GraalVM to be installed (`GRAALVM_HOME` environment
|
||||||
|
variable pointing to a GraalVM installation).
|
||||||
|
- `-DskipTests` to speed up the build process by skipping tests.
|
||||||
|
|
||||||
|
When the build is complete:
|
||||||
|
|
||||||
|
- a zip containing all jars required to run the application is located at
|
||||||
|
`commafeed-server/target/commafeed-<version>-<database>-jvm.zip`. Extract it and run the application with
|
||||||
|
`java -jar quarkus-run.jar`
|
||||||
|
- if you used the native profile, the executable is located at
|
||||||
|
`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
|
||||||
|
|
||||||
|
CommaFeed doesn't require any configuration to run with its embedded database (H2). The database file will be stored in
|
||||||
|
the `data` directory of the current directory.
|
||||||
|
|
||||||
|
To use a different database, you will need to configure the following properties:
|
||||||
|
|
||||||
|
- `quarkus.datasource.jdbc.url`
|
||||||
|
- e.g. for H2: `jdbc:h2:./data/db;DEFRAG_ALWAYS=TRUE`
|
||||||
|
- e.g. for PostgreSQL: `jdbc:postgresql://localhost:5432/commafeed`
|
||||||
|
- e.g. for MySQL:
|
||||||
|
`jdbc:mysql://localhost/commafeed?autoReconnect=true&failOverReadOnly=false&maxReconnects=20&rewriteBatchedStatements=true&timezone=UTC`
|
||||||
|
- e.g. for MariaDB:
|
||||||
|
`jdbc:mariadb://localhost/commafeed?autoReconnect=true&failOverReadOnly=false&maxReconnects=20&rewriteBatchedStatements=true&timezone=UTC`
|
||||||
|
- `quarkus.datasource.username`
|
||||||
|
- `quarkus.datasource.password`
|
||||||
|
|
||||||
|
There are multiple ways to configure CommaFeed:
|
||||||
|
|
||||||
|
- a `config/application.properties` [properties](https://en.wikipedia.org/wiki/.properties) file relative to the working
|
||||||
|
directory (keys in kebab-case)
|
||||||
|
- Command line arguments each prefixed with `-D` (keys in kebab-case)
|
||||||
|
- Environment variables (keys in UPPER_CASE)
|
||||||
|
- a `.env` file in the working directory (keys in UPPER_CASE)
|
||||||
|
|
||||||
|
When in doubt, the properties file is recommended because CommaFeed will be able to warn about invalid properties and typos.
|
||||||
|
|
||||||
|
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,
|
||||||
|
meaning that you will have to log back in after each restart of the application. To prevent this, you can set the
|
||||||
|
`quarkus.http.auth.session.encryption-key` property to a fixed value (min. 16 characters).
|
||||||
|
All other Quarkus settings can be found [here](https://quarkus.io/guides/all-config).
|
||||||
|
|
||||||
|
When started, the server will listen on http://localhost:8082.
|
||||||
|
|
||||||
|
### Updates
|
||||||
|
|
||||||
|
When CommaFeed is up and running, you can subscribe to [this feed](https://github.com/Athou/commafeed/releases.atom) to be notified of new releases.
|
||||||
|
|
||||||
### Memory management
|
### Memory management
|
||||||
|
|
||||||
The Java Virtual Machine (JVM) is rather greedy by default and will not release unused memory to the
|
The Java Virtual Machine (JVM) is rather greedy by default and will not release unused memory to the
|
||||||
operating system. This is because acquiring memory from the operating system is a relatively expensive operation.
|
operating system. This is because acquiring memory from the operating system is a relatively expensive operation.
|
||||||
However, this can be problematic on systems with limited memory.
|
This can be problematic on systems with limited memory.
|
||||||
|
|
||||||
#### Hard limit
|
#### Hard limit (`native` and `jvm` packages)
|
||||||
|
|
||||||
The JVM can be configured to use a maximum amount of memory with the `-Xmx` parameter.
|
The JVM can be configured to use a maximum amount of memory with the `-Xmx` parameter.
|
||||||
For example, to limit the JVM to 256MB of memory, use `-Xmx256m`.
|
For example, to limit the JVM to 256MB of memory, use `-Xmx256m`.
|
||||||
|
|
||||||
#### Dynamic sizing
|
#### Dynamic sizing (`jvm` package)
|
||||||
|
|
||||||
The JVM can be configured to release unused memory to the operating system with the following parameters:
|
In addition to the previous setting, the JVM can be configured to release unused memory to the operating system with the
|
||||||
|
following parameters:
|
||||||
|
|
||||||
-Xms20m -XX:+UseG1GC -XX:-ShrinkHeapInSteps -XX:G1PeriodicGCInterval=10000 -XX:-G1PeriodicGCInvokesConcurrent -XX:MinHeapFreeRatio=5 -XX:MaxHeapFreeRatio=10
|
-Xms20m -XX:+UseG1GC -XX:+UseStringDeduplication -XX:-ShrinkHeapInSteps -XX:G1PeriodicGCInterval=10000 -XX:-G1PeriodicGCInvokesConcurrent -XX:MinHeapFreeRatio=5 -XX:MaxHeapFreeRatio=10
|
||||||
|
|
||||||
This is how the Docker image is configured.
|
|
||||||
See [here](https://docs.oracle.com/en/java/javase/17/gctuning/garbage-first-g1-garbage-collector1.html)
|
See [here](https://docs.oracle.com/en/java/javase/17/gctuning/garbage-first-g1-garbage-collector1.html)
|
||||||
and [here](https://docs.oracle.com/en/java/javase/17/gctuning/factors-affecting-garbage-collection-performance.html) for
|
and [here](https://docs.oracle.com/en/java/javase/17/gctuning/factors-affecting-garbage-collection-performance.html) for
|
||||||
more
|
more
|
||||||
information.
|
information.
|
||||||
|
|
||||||
|
#### OpenJ9 (`jvm` package)
|
||||||
|
|
||||||
|
The [OpenJ9](https://eclipse.dev/openj9/) JVM is a more memory-efficient alternative to the HotSpot JVM, at the cost of
|
||||||
|
slightly slower throughput.
|
||||||
|
|
||||||
|
IBM provides precompiled binaries for OpenJ9
|
||||||
|
named [Semeru](https://developer.ibm.com/languages/java/semeru-runtimes/downloads/).
|
||||||
|
This is the JVM used in
|
||||||
|
the [Docker image](https://github.com/Athou/commafeed/blob/master/commafeed-server/src/main/docker/Dockerfile.jvm).
|
||||||
|
|
||||||
## Translation
|
## Translation
|
||||||
|
|
||||||
Files for internationalization are
|
Files for internationalization are
|
||||||
@@ -99,7 +181,7 @@ two-letters [ISO-639-1 language code](http://en.wikipedia.org/wiki/List_of_ISO_6
|
|||||||
|
|
||||||
- Open `commafeed-server` in your preferred Java IDE.
|
- Open `commafeed-server` in your preferred Java IDE.
|
||||||
- CommaFeed uses Lombok, you need the Lombok plugin for your IDE.
|
- CommaFeed uses Lombok, you need the Lombok plugin for your IDE.
|
||||||
- Start `CommaFeedApplication.java` in debug mode with `server config.dev.yml` as arguments
|
- run `./mvnw quarkus:dev`
|
||||||
|
|
||||||
### Frontend
|
### Frontend
|
||||||
|
|
||||||
|
|||||||
@@ -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/.husky/pre-commit
Normal file
1
commafeed-client/.husky/pre-commit
Normal file
@@ -0,0 +1 @@
|
|||||||
|
cd commafeed-client && npx lint-staged
|
||||||
@@ -1,52 +0,0 @@
|
|||||||
{
|
|
||||||
"locales": [
|
|
||||||
"ar",
|
|
||||||
"ca",
|
|
||||||
"cs",
|
|
||||||
"cy",
|
|
||||||
"da",
|
|
||||||
"de",
|
|
||||||
"en",
|
|
||||||
"es",
|
|
||||||
"fa",
|
|
||||||
"fi",
|
|
||||||
"fr",
|
|
||||||
"gl",
|
|
||||||
"hu",
|
|
||||||
"id",
|
|
||||||
"it",
|
|
||||||
"ja",
|
|
||||||
"ko",
|
|
||||||
"ms",
|
|
||||||
"nb",
|
|
||||||
"nl",
|
|
||||||
"nn",
|
|
||||||
"pl",
|
|
||||||
"pt",
|
|
||||||
"ru",
|
|
||||||
"sk",
|
|
||||||
"sv",
|
|
||||||
"tr",
|
|
||||||
"zh"
|
|
||||||
],
|
|
||||||
"catalogs": [
|
|
||||||
{
|
|
||||||
"path": "src/locales/{locale}/messages",
|
|
||||||
"include": [
|
|
||||||
"src"
|
|
||||||
],
|
|
||||||
"exclude": [
|
|
||||||
"src/locales/**"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"format": "po",
|
|
||||||
"formatOptions": {
|
|
||||||
"origins": true,
|
|
||||||
"lineNumbers": false
|
|
||||||
},
|
|
||||||
"sourceLocale": "en",
|
|
||||||
"fallbackLocales": {
|
|
||||||
"default": "en"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://biomejs.dev/schemas/1.8.1/schema.json",
|
"$schema": "https://biomejs.dev/schemas/2.4.7/schema.json",
|
||||||
"formatter": {
|
"formatter": {
|
||||||
"indentStyle": "space",
|
"indentStyle": "space",
|
||||||
"indentWidth": 4,
|
"indentWidth": 4,
|
||||||
@@ -14,6 +14,6 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"files": {
|
"files": {
|
||||||
"ignore": ["dist", "node_modules", "target", "target-ide"]
|
"includes": ["**", "!**/dist", "!**/node_modules", "!**/target", "!**/target-ide"]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,14 @@
|
|||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<link rel="icon" type="image/x-icon" href="favicon.ico" />
|
<link rel="icon" type="image/svg+xml" href="favicon.svg" />
|
||||||
<link rel="manifest" href="manifest.json" />
|
<link rel="icon" type="image/x-icon" href="favicon.ico" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
|
<link rel="manifest" href="manifest.json" />
|
||||||
<title>CommaFeed</title>
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
|
||||||
|
<meta name="referrer" content="no-referrer" />
|
||||||
|
|
||||||
|
<title>CommaFeed</title>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
|
|||||||
52
commafeed-client/lingui.config.ts
Normal file
52
commafeed-client/lingui.config.ts
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
import type { LinguiConfig } from "@lingui/conf"
|
||||||
|
|
||||||
|
const config: LinguiConfig = {
|
||||||
|
locales: [
|
||||||
|
"ar",
|
||||||
|
"ca",
|
||||||
|
"cs",
|
||||||
|
"cy",
|
||||||
|
"da",
|
||||||
|
"de",
|
||||||
|
"en",
|
||||||
|
"es",
|
||||||
|
"fa",
|
||||||
|
"fi",
|
||||||
|
"fr",
|
||||||
|
"gl",
|
||||||
|
"hu",
|
||||||
|
"id",
|
||||||
|
"it",
|
||||||
|
"ja",
|
||||||
|
"ko",
|
||||||
|
"ms",
|
||||||
|
"nb",
|
||||||
|
"nl",
|
||||||
|
"nn",
|
||||||
|
"pl",
|
||||||
|
"pt",
|
||||||
|
"ru",
|
||||||
|
"sk",
|
||||||
|
"sv",
|
||||||
|
"tr",
|
||||||
|
"zh",
|
||||||
|
],
|
||||||
|
catalogs: [
|
||||||
|
{
|
||||||
|
path: "src/locales/{locale}/messages",
|
||||||
|
include: ["src"],
|
||||||
|
exclude: ["src/locales/**"],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
format: "po",
|
||||||
|
formatOptions: {
|
||||||
|
origins: true,
|
||||||
|
lineNumbers: false,
|
||||||
|
},
|
||||||
|
sourceLocale: "en",
|
||||||
|
fallbackLocales: {
|
||||||
|
default: "en",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
export default config
|
||||||
3
commafeed-client/lint-staged.config.js
Normal file
3
commafeed-client/lint-staged.config.js
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
export default {
|
||||||
|
"src/**/*.{js,jsx,ts,tsx}": () => ["npm run i18n:extract", "git diff --exit-code commafeed-client/src/locales/en/messages.po"],
|
||||||
|
}
|
||||||
9298
commafeed-client/package-lock.json
generated
9298
commafeed-client/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -4,76 +4,83 @@
|
|||||||
"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",
|
||||||
|
"prepare": "cd .. && husky ./commafeed-client/.husky"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@emotion/react": "^11.11.4",
|
"@emotion/react": "^11.14.0",
|
||||||
"@fontsource/open-sans": "^5.0.28",
|
"@fontsource/open-sans": "^5.2.7",
|
||||||
"@lingui/core": "^4.11.1",
|
"@lingui/core": "^5.9.3",
|
||||||
"@lingui/macro": "^4.11.1",
|
"@lingui/react": "^5.9.3",
|
||||||
"@lingui/react": "^4.11.1",
|
"@mantine/core": "^8.3.16",
|
||||||
"@mantine/core": "^7.10.2",
|
"@mantine/form": "^8.3.16",
|
||||||
"@mantine/form": "^7.10.2",
|
"@mantine/hooks": "^8.3.16",
|
||||||
"@mantine/hooks": "^7.10.2",
|
"@mantine/modals": "^8.3.16",
|
||||||
"@mantine/modals": "^7.10.2",
|
"@mantine/notifications": "^8.3.16",
|
||||||
"@mantine/notifications": "^7.10.2",
|
"@mantine/spotlight": "^8.3.16",
|
||||||
"@mantine/spotlight": "^7.10.2",
|
"@monaco-editor/react": "^4.7.0",
|
||||||
"@monaco-editor/react": "^4.6.0",
|
"@react-querybuilder/mantine": "^8.14.0",
|
||||||
"@reduxjs/toolkit": "^2.2.5",
|
"@reduxjs/toolkit": "^2.11.2",
|
||||||
"axios": "^1.7.2",
|
"@rolldown/plugin-babel": "^0.2.2",
|
||||||
"dayjs": "^1.11.11",
|
"axios": "^1.13.6",
|
||||||
|
"dayjs": "^1.11.20",
|
||||||
"escape-string-regexp": "^5.0.0",
|
"escape-string-regexp": "^5.0.0",
|
||||||
"interweave": "^13.1.0",
|
"interweave": "^13.1.1",
|
||||||
"monaco-editor": "^0.49.0",
|
"monaco-editor": "^0.55.1",
|
||||||
"mousetrap": "^1.6.5",
|
"mousetrap": "^1.6.5",
|
||||||
"react": "^18.3.1",
|
"react": "^19.2.4",
|
||||||
"react-async-hook": "^4.0.0",
|
"react-async-hook": "^4.0.0",
|
||||||
"react-contexify": "^6.0.0",
|
"react-contexify": "^6.0.0",
|
||||||
"react-device-detect": "^2.2.3",
|
"react-dom": "^19.2.4",
|
||||||
"react-dom": "^18.3.1",
|
"react-draggable": "^4.5.0",
|
||||||
"react-draggable": "^4.4.6",
|
"react-icons": "^5.6.0",
|
||||||
"react-ga4": "^2.1.0",
|
|
||||||
"react-helmet": "^6.1.0",
|
|
||||||
"react-icons": "^5.2.1",
|
|
||||||
"react-infinite-scroller": "^1.2.6",
|
"react-infinite-scroller": "^1.2.6",
|
||||||
"react-redux": "^9.1.2",
|
"react-querybuilder": "^8.14.0",
|
||||||
"react-router-dom": "^6.23.1",
|
"react-redux": "^9.2.0",
|
||||||
"react-swipeable": "^7.0.1",
|
"react-router-dom": "^7.13.1",
|
||||||
"redoc": "^2.1.5",
|
"react-swipeable": "^7.0.2",
|
||||||
"throttle-debounce": "^5.0.0",
|
"style-to-object": "^1.0.14",
|
||||||
|
"throttle-debounce": "^5.0.2",
|
||||||
"tinycon": "^0.6.8",
|
"tinycon": "^0.6.8",
|
||||||
"tss-react": "^4.9.10",
|
"tss-react": "^4.9.20",
|
||||||
"use-local-storage": "^3.0.0",
|
|
||||||
"vite-plugin-biome": "^1.0.10",
|
|
||||||
"websocket-heartbeat-js": "^1.1.3"
|
"websocket-heartbeat-js": "^1.1.3"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@biomejs/biome": "^1.8.1",
|
"@biomejs/biome": "^2.4.7",
|
||||||
"@lingui/cli": "^4.11.1",
|
"@lingui/babel-plugin-lingui-macro": "^5.9.3",
|
||||||
"@lingui/vite-plugin": "^4.11.1",
|
"@lingui/cli": "^5.9.3",
|
||||||
|
"@lingui/vite-plugin": "^5.9.3",
|
||||||
|
"@testing-library/jest-dom": "^6.9.1",
|
||||||
|
"@testing-library/react": "^16.3.2",
|
||||||
|
"@testing-library/user-event": "^14.6.1",
|
||||||
"@types/mousetrap": "^1.6.15",
|
"@types/mousetrap": "^1.6.15",
|
||||||
"@types/react": "^18.3.3",
|
"@types/react": "^19.2.14",
|
||||||
"@types/react-dom": "^18.3.0",
|
"@types/react-dom": "^19.2.3",
|
||||||
"@types/react-helmet": "^6.1.11",
|
|
||||||
"@types/react-infinite-scroller": "^1.2.5",
|
"@types/react-infinite-scroller": "^1.2.5",
|
||||||
"@types/swagger-ui-react": "^4.18.3",
|
|
||||||
"@types/throttle-debounce": "^5.0.2",
|
"@types/throttle-debounce": "^5.0.2",
|
||||||
"@types/tinycon": "^0.6.5",
|
"@types/tinycon": "^0.6.7",
|
||||||
"@vitejs/plugin-react": "^4.3.1",
|
"@vitejs/plugin-react": "^6.0.1",
|
||||||
"babel-plugin-macros": "^3.1.0",
|
"babel-plugin-react-compiler": "1.0.0",
|
||||||
"rollup-plugin-visualizer": "^5.12.0",
|
"husky": "^9.1.7",
|
||||||
"typescript": "^5.4.5",
|
"jsdom": "^29.0.0",
|
||||||
"vite": "^5.3.1",
|
"lint-staged": "^16.4.0",
|
||||||
"vite-tsconfig-paths": "^4.3.2",
|
"typescript": "^5.9.3",
|
||||||
"vitest": "^1.6.0",
|
"vite": "^8.0.0",
|
||||||
"vitest-mock-extended": "^1.3.1"
|
"vite-plugin-checker": "^0.12.0",
|
||||||
|
"vitest": "^4.1.0",
|
||||||
|
"yaml": "^2.8.2"
|
||||||
|
},
|
||||||
|
"overrides": {
|
||||||
|
"react-infinite-scroller": {
|
||||||
|
"react": "^19.2.4"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,21 +1,29 @@
|
|||||||
<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">
|
<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">
|
||||||
|
|
||||||
<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>4.4.1</version>
|
<version>7.0.0</version>
|
||||||
</parent>
|
</parent>
|
||||||
<artifactId>commafeed-client</artifactId>
|
<artifactId>commafeed-client</artifactId>
|
||||||
<name>CommaFeed Client</name>
|
<name>CommaFeed Client</name>
|
||||||
|
|
||||||
|
<properties>
|
||||||
|
<!-- renovate: datasource=node-version depName=node -->
|
||||||
|
<node.version>v24.14.0</node.version>
|
||||||
|
<!-- renovate: datasource=npm depName=npm -->
|
||||||
|
<npm.version>11.11.1</npm.version>
|
||||||
|
</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.0</version>
|
<version>2.0.0</version>
|
||||||
<?m2e ignore?>
|
<?m2e ignore?>
|
||||||
<executions>
|
<executions>
|
||||||
<execution>
|
<execution>
|
||||||
@@ -25,8 +33,8 @@
|
|||||||
</goals>
|
</goals>
|
||||||
<phase>compile</phase>
|
<phase>compile</phase>
|
||||||
<configuration>
|
<configuration>
|
||||||
<nodeVersion>v20.10.0</nodeVersion>
|
<nodeVersion>${node.version}</nodeVersion>
|
||||||
<npmVersion>10.2.5</npmVersion>
|
<npmVersion>${npm.version}</npmVersion>
|
||||||
</configuration>
|
</configuration>
|
||||||
</execution>
|
</execution>
|
||||||
<execution>
|
<execution>
|
||||||
@@ -47,6 +55,7 @@
|
|||||||
<phase>compile</phase>
|
<phase>compile</phase>
|
||||||
<configuration>
|
<configuration>
|
||||||
<arguments>run test:ci</arguments>
|
<arguments>run test:ci</arguments>
|
||||||
|
<skip>${skipTests}</skip>
|
||||||
</configuration>
|
</configuration>
|
||||||
</execution>
|
</execution>
|
||||||
<execution>
|
<execution>
|
||||||
@@ -63,7 +72,7 @@
|
|||||||
</plugin>
|
</plugin>
|
||||||
<plugin>
|
<plugin>
|
||||||
<artifactId>maven-resources-plugin</artifactId>
|
<artifactId>maven-resources-plugin</artifactId>
|
||||||
<version>3.3.1</version>
|
<version>3.5.0</version>
|
||||||
<executions>
|
<executions>
|
||||||
<execution>
|
<execution>
|
||||||
<id>copy web interface to resources</id>
|
<id>copy web interface to resources</id>
|
||||||
@@ -72,7 +81,7 @@
|
|||||||
<goal>copy-resources</goal>
|
<goal>copy-resources</goal>
|
||||||
</goals>
|
</goals>
|
||||||
<configuration>
|
<configuration>
|
||||||
<outputDirectory>${project.build.directory}/classes/assets</outputDirectory>
|
<outputDirectory>${project.build.directory}/classes/META-INF/resources</outputDirectory>
|
||||||
<resources>
|
<resources>
|
||||||
<resource>
|
<resource>
|
||||||
<directory>dist</directory>
|
<directory>dist</directory>
|
||||||
@@ -85,4 +94,49 @@
|
|||||||
</plugin>
|
</plugin>
|
||||||
</plugins>
|
</plugins>
|
||||||
</build>
|
</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.3</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>
|
||||||
62
commafeed-client/public/favicon.svg
Normal file
62
commafeed-client/public/favicon.svg
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
<?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"
|
||||||
|
sodipodi:docname="favicon.svg"
|
||||||
|
inkscape:version="1.4.2 (ebf0e940d0, 2025-05-08)"
|
||||||
|
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
|
||||||
|
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
xmlns:svg="http://www.w3.org/2000/svg">
|
||||||
|
<defs
|
||||||
|
id="defs3" />
|
||||||
|
<sodipodi:namedview
|
||||||
|
id="namedview3"
|
||||||
|
pagecolor="#ffffff"
|
||||||
|
bordercolor="#000000"
|
||||||
|
borderopacity="0.25"
|
||||||
|
inkscape:showpageshadow="2"
|
||||||
|
inkscape:pageopacity="0.0"
|
||||||
|
inkscape:pagecheckerboard="0"
|
||||||
|
inkscape:deskcolor="#d1d1d1"
|
||||||
|
inkscape:zoom="1.21875"
|
||||||
|
inkscape:cx="207.17949"
|
||||||
|
inkscape:cy="187.07692"
|
||||||
|
inkscape:window-width="1440"
|
||||||
|
inkscape:window-height="855"
|
||||||
|
inkscape:window-x="0"
|
||||||
|
inkscape:window-y="0"
|
||||||
|
inkscape:window-maximized="1"
|
||||||
|
inkscape:current-layer="svg3" />
|
||||||
|
<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.9 KiB |
@@ -3,46 +3,53 @@ import { I18nProvider } from "@lingui/react"
|
|||||||
import { MantineProvider } from "@mantine/core"
|
import { MantineProvider } 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, useState } 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 { categoryUnreadCount } from "app/utils"
|
|
||||||
import { DisablePullToRefresh } from "components/DisablePullToRefresh"
|
|
||||||
import { ErrorBoundary } from "components/ErrorBoundary"
|
|
||||||
import { Header } from "components/header/Header"
|
|
||||||
import { Tree } from "components/sidebar/Tree"
|
|
||||||
import { useBrowserExtension } from "hooks/useBrowserExtension"
|
|
||||||
import { useI18n } from "i18n"
|
|
||||||
import { WelcomePage } from "pages/WelcomePage"
|
|
||||||
import { AdminUsersPage } from "pages/admin/AdminUsersPage"
|
|
||||||
import { MetricsPage } from "pages/admin/MetricsPage"
|
|
||||||
import { AboutPage } from "pages/app/AboutPage"
|
|
||||||
import { AddPage } from "pages/app/AddPage"
|
|
||||||
import { CategoryDetailsPage } from "pages/app/CategoryDetailsPage"
|
|
||||||
import { DonatePage } from "pages/app/DonatePage"
|
|
||||||
import { FeedDetailsPage } from "pages/app/FeedDetailsPage"
|
|
||||||
import { FeedEntriesPage } from "pages/app/FeedEntriesPage"
|
|
||||||
import Layout from "pages/app/Layout"
|
|
||||||
import { SettingsPage } from "pages/app/SettingsPage"
|
|
||||||
import { TagDetailsPage } from "pages/app/TagDetailsPage"
|
|
||||||
import { LoginPage } from "pages/auth/LoginPage"
|
|
||||||
import { PasswordRecoveryPage } from "pages/auth/PasswordRecoveryPage"
|
|
||||||
import { RegistrationPage } from "pages/auth/RegistrationPage"
|
|
||||||
import React, { useEffect } from "react"
|
|
||||||
import { isSafari } from "react-device-detect"
|
|
||||||
import ReactGA from "react-ga4"
|
|
||||||
import { Helmet } from "react-helmet"
|
|
||||||
import { HashRouter, Navigate, Route, Routes, useLocation, useNavigate } from "react-router-dom"
|
|
||||||
import Tinycon from "tinycon"
|
import Tinycon from "tinycon"
|
||||||
|
import { Constants } from "@/app/constants"
|
||||||
|
import { redirectTo } from "@/app/redirect/slice"
|
||||||
|
import { redirectToInitialSetup } from "@/app/redirect/thunks"
|
||||||
|
import { reloadServerInfos } from "@/app/server/thunks"
|
||||||
|
import { useAppDispatch, useAppSelector } from "@/app/store"
|
||||||
|
import { categoryUnreadCount } from "@/app/utils"
|
||||||
|
import { DisablePullToRefresh } from "@/components/DisablePullToRefresh"
|
||||||
|
import { ErrorBoundary } from "@/components/ErrorBoundary"
|
||||||
|
import { Header } from "@/components/header/Header"
|
||||||
|
import { Tree } from "@/components/sidebar/Tree"
|
||||||
|
import { useAppLoading } from "@/hooks/useAppLoading"
|
||||||
|
import { useBrowserExtension } from "@/hooks/useBrowserExtension"
|
||||||
|
import { useI18n } from "@/i18n"
|
||||||
|
import { AdminUsersPage } from "@/pages/admin/AdminUsersPage"
|
||||||
|
import { MetricsPage } from "@/pages/admin/MetricsPage"
|
||||||
|
import { AboutPage } from "@/pages/app/AboutPage"
|
||||||
|
import { AddPage } from "@/pages/app/AddPage"
|
||||||
|
import { CategoryDetailsPage } from "@/pages/app/CategoryDetailsPage"
|
||||||
|
import { DonatePage } from "@/pages/app/DonatePage"
|
||||||
|
import { FeedDetailsPage } from "@/pages/app/FeedDetailsPage"
|
||||||
|
import { FeedEntriesPage } from "@/pages/app/FeedEntriesPage"
|
||||||
|
import Layout from "@/pages/app/Layout"
|
||||||
|
import { SettingsPage } from "@/pages/app/SettingsPage"
|
||||||
|
import { TagDetailsPage } from "@/pages/app/TagDetailsPage"
|
||||||
|
import { InitialSetupPage } from "@/pages/auth/InitialSetupPage"
|
||||||
|
import { LoginPage } from "@/pages/auth/LoginPage"
|
||||||
|
import { PasswordRecoveryPage } from "@/pages/auth/PasswordRecoveryPage"
|
||||||
|
import { PasswordResetPage } from "@/pages/auth/PasswordResetPage"
|
||||||
|
import { RegistrationPage } from "@/pages/auth/RegistrationPage"
|
||||||
|
import { WelcomePage } from "@/pages/WelcomePage"
|
||||||
|
|
||||||
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"
|
||||||
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
|
||||||
@@ -71,9 +78,6 @@ function Providers(props: { children: React.ReactNode }) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// swagger-ui 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)
|
||||||
|
|
||||||
@@ -81,10 +85,11 @@ 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" />} />
|
||||||
@@ -112,6 +117,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()
|
||||||
@@ -127,31 +144,26 @@ function RedirectHandler() {
|
|||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
function GoogleAnalyticsHandler() {
|
function UnreadCountTitleHandler({
|
||||||
const location = useLocation()
|
enabled,
|
||||||
const googleAnalyticsCode = useAppSelector(state => state.server.serverInfos?.googleAnalyticsCode)
|
}: Readonly<{
|
||||||
|
enabled?: boolean
|
||||||
useEffect(() => {
|
}>) {
|
||||||
if (googleAnalyticsCode) ReactGA.initialize(googleAnalyticsCode)
|
const root = useAppSelector(state => state.tree.rootCategory)
|
||||||
}, [googleAnalyticsCode])
|
const unreadCount = categoryUnreadCount(root)
|
||||||
|
return <title>{enabled && unreadCount > 0 ? `(${unreadCount}) CommaFeed` : "CommaFeed"}</title>
|
||||||
useEffect(() => {
|
|
||||||
if (ReactGA.isInitialized) ReactGA.send({ hitType: "pageview", page: location.pathname })
|
|
||||||
}, [location])
|
|
||||||
|
|
||||||
return null
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function FaviconHandler() {
|
function UnreadCountFaviconHandler({ enabled }: { enabled?: boolean }) {
|
||||||
const root = useAppSelector(state => state.tree.rootCategory)
|
const root = useAppSelector(state => state.tree.rootCategory)
|
||||||
|
const unreadCount = categoryUnreadCount(root)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const unreadCount = categoryUnreadCount(root)
|
if (enabled && unreadCount > 0) {
|
||||||
if (unreadCount === 0) {
|
|
||||||
Tinycon.reset()
|
|
||||||
} else {
|
|
||||||
Tinycon.setBubble(unreadCount)
|
Tinycon.setBubble(unreadCount)
|
||||||
|
} else {
|
||||||
|
Tinycon.reset()
|
||||||
}
|
}
|
||||||
}, [root])
|
}, [unreadCount, enabled])
|
||||||
|
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
@@ -168,17 +180,47 @@ function BrowserExtensionBadgeUnreadCountHandler() {
|
|||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
function CustomCode() {
|
function CustomJsHandler() {
|
||||||
return (
|
const [scriptLoaded, setScriptLoaded] = useState(false)
|
||||||
<Helmet>
|
const { loading } = useAppLoading()
|
||||||
<link rel="stylesheet" type="text/css" href="custom_css.css" />
|
|
||||||
<script type="text/javascript" src="custom_js.js" />
|
useEffect(() => {
|
||||||
</Helmet>
|
if (scriptLoaded || loading) {
|
||||||
)
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const script = document.createElement("script")
|
||||||
|
script.src = "custom_js.js"
|
||||||
|
script.async = true
|
||||||
|
document.body.appendChild(script)
|
||||||
|
|
||||||
|
setScriptLoaded(true)
|
||||||
|
|
||||||
|
return () => script.remove()
|
||||||
|
}, [scriptLoaded, loading])
|
||||||
|
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
function CustomCssHandler() {
|
||||||
|
useEffect(() => {
|
||||||
|
const link = document.createElement("link")
|
||||||
|
link.rel = "stylesheet"
|
||||||
|
link.type = "text/css"
|
||||||
|
link.href = "custom_css.css"
|
||||||
|
document.head.appendChild(link)
|
||||||
|
|
||||||
|
return () => link.remove()
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
export function App() {
|
export function App() {
|
||||||
useI18n()
|
useI18n()
|
||||||
|
const unreadCountTitle = useAppSelector(state => state.user.settings?.unreadCountTitle)
|
||||||
|
const unreadCountFavicon = useAppSelector(state => state.user.settings?.unreadCountFavicon)
|
||||||
|
const disablePullToRefresh = useAppSelector(state => state.user.settings?.disablePullToRefresh)
|
||||||
const dispatch = useAppDispatch()
|
const dispatch = useAppDispatch()
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -187,21 +229,18 @@ export function App() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Providers>
|
<Providers>
|
||||||
<>
|
<UnreadCountTitleHandler enabled={unreadCountTitle} />
|
||||||
<FaviconHandler />
|
<UnreadCountFaviconHandler enabled={unreadCountFavicon} />
|
||||||
<BrowserExtensionBadgeUnreadCountHandler />
|
<BrowserExtensionBadgeUnreadCountHandler />
|
||||||
<HashRouter>
|
<CustomJsHandler />
|
||||||
<GoogleAnalyticsHandler />
|
<CustomCssHandler />
|
||||||
<RedirectHandler />
|
<DisablePullToRefresh enabled={disablePullToRefresh} />
|
||||||
<AppRoutes />
|
|
||||||
<CustomCode />
|
<HashRouter>
|
||||||
{/* disable pull-to-refresh as it messes with vertical scrolling
|
<InitialSetupHandler />
|
||||||
safari behaves weirdly when overscroll-behavior is set to none so we disable it only for other browsers
|
<RedirectHandler />
|
||||||
https://github.com/Athou/commafeed/issues/1168
|
<AppRoutes />
|
||||||
*/}
|
</HashRouter>
|
||||||
{!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
|
||||||
|
|||||||
@@ -12,12 +12,15 @@ import type {
|
|||||||
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,
|
||||||
@@ -32,16 +35,17 @@ 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 = {
|
||||||
@@ -81,11 +85,25 @@ export const client = {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
user: {
|
user: {
|
||||||
login: async (req: LoginRequest) => await axiosInstance.post("user/login", req),
|
login: async (req: LoginRequest) => {
|
||||||
|
const formData = new URLSearchParams()
|
||||||
|
formData.append("j_username", req.name)
|
||||||
|
formData.append("j_password", req.password)
|
||||||
|
return await axiosInstance.post("j_security_check", formData, {
|
||||||
|
baseURL: ".",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/x-www-form-urlencoded",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
},
|
||||||
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"),
|
||||||
@@ -95,7 +113,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"),
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,13 +1,11 @@
|
|||||||
import { t } from "@lingui/macro"
|
|
||||||
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, SiTwitter } from "react-icons/si"
|
import { SiBuffer, SiFacebook, SiGmail, SiInstapaper, SiPocket, SiTumblr, SiX } from "react-icons/si"
|
||||||
import type { Category, Entry, SharingSettings } from "./types"
|
import type { Category, Entry, SharingSettings } from "./types"
|
||||||
|
|
||||||
const categories: Record<string, Category> = {
|
const categories: Record<string, Omit<Category, "name">> = {
|
||||||
all: {
|
all: {
|
||||||
id: "all",
|
id: "all",
|
||||||
name: t`All`,
|
|
||||||
expanded: false,
|
expanded: false,
|
||||||
children: [],
|
children: [],
|
||||||
feeds: [],
|
feeds: [],
|
||||||
@@ -15,12 +13,18 @@ const categories: Record<string, Category> = {
|
|||||||
},
|
},
|
||||||
starred: {
|
starred: {
|
||||||
id: "starred",
|
id: "starred",
|
||||||
name: t`Starred`,
|
|
||||||
expanded: false,
|
expanded: false,
|
||||||
children: [],
|
children: [],
|
||||||
feeds: [],
|
feeds: [],
|
||||||
position: 1,
|
position: 1,
|
||||||
},
|
},
|
||||||
|
infrequent: {
|
||||||
|
id: "infrequent",
|
||||||
|
expanded: false,
|
||||||
|
children: [],
|
||||||
|
feeds: [],
|
||||||
|
position: 2,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
const sharing: {
|
const sharing: {
|
||||||
@@ -50,10 +54,10 @@ const sharing: {
|
|||||||
url: url => `https://www.facebook.com/sharer/sharer.php?u=${url}`,
|
url: url => `https://www.facebook.com/sharer/sharer.php?u=${url}`,
|
||||||
},
|
},
|
||||||
twitter: {
|
twitter: {
|
||||||
label: "Twitter",
|
label: "X",
|
||||||
icon: SiTwitter,
|
icon: SiX,
|
||||||
color: "#1D9BF0",
|
color: "#000000",
|
||||||
url: (url, desc) => `https://twitter.com/share?text=${desc}&url=${url}`,
|
url: (url, desc) => `https://x.com/share?text=${desc}&url=${url}`,
|
||||||
},
|
},
|
||||||
tumblr: {
|
tumblr: {
|
||||||
label: "Tumblr",
|
label: "Tumblr",
|
||||||
@@ -90,23 +94,26 @@ 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,
|
||||||
},
|
},
|
||||||
|
infrequentThresholdDaysDefault: 7,
|
||||||
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 { 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.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.mockResolvedValue({
|
vi.mocked(client.category.getEntries).mockResolvedValue({
|
||||||
data: {
|
data: {
|
||||||
entries: [{ id: "4" } as Entry],
|
entries: [{ id: "4" } as Entry],
|
||||||
hasMore: false,
|
hasMore: false,
|
||||||
@@ -113,7 +113,7 @@ 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("marks all entries as read", () => {
|
it("marks all entries as read", () => {
|
||||||
@@ -135,11 +135,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,7 @@
|
|||||||
import { type PayloadAction, createSlice } from "@reduxjs/toolkit"
|
import { createSlice, 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 { loadEntries, loadMoreEntries, markAllEntries, markEntry, markMultipleEntries, starEntry, tagEntry } from "@/app/entries/thunks"
|
||||||
import type { Entry } from "app/types"
|
import type { Entry } from "@/app/types"
|
||||||
|
|
||||||
export type EntrySourceType = "category" | "feed" | "tag"
|
export type EntrySourceType = "category" | "feed" | "tag"
|
||||||
|
|
||||||
@@ -28,6 +28,7 @@ interface EntriesState {
|
|||||||
loading: boolean
|
loading: boolean
|
||||||
search?: string
|
search?: string
|
||||||
scrollingToEntry: boolean
|
scrollingToEntry: boolean
|
||||||
|
markAllAsReadConfirmationDialogOpen: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
const initialState: EntriesState = {
|
const initialState: EntriesState = {
|
||||||
@@ -41,6 +42,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,6 +63,9 @@ 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) => {
|
||||||
@@ -119,4 +124,4 @@ export const entriesSlice = createSlice({
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
export const { setSearch } = entriesSlice.actions
|
export const { setSearch, setMarkAllAsReadConfirmationDialogOpen } = entriesSlice.actions
|
||||||
|
|||||||
@@ -1,16 +1,23 @@
|
|||||||
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
|
||||||
|
|
||||||
export const loadEntries = createAppAsyncThunk(
|
export const loadEntries = createAppAsyncThunk(
|
||||||
"entries/load",
|
"entries/load",
|
||||||
async (
|
async (
|
||||||
@@ -28,33 +35,40 @@ export const loadEntries = createAppAsyncThunk(
|
|||||||
return result.data
|
return result.data
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
export const loadMoreEntries = createAppAsyncThunk("entries/loadMore", async (_, thunkApi) => {
|
export const loadMoreEntries = createAppAsyncThunk("entries/loadMore", async (_, thunkApi) => {
|
||||||
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" || (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
|
||||||
})
|
})
|
||||||
|
|
||||||
const buildGetEntriesPaginatedRequest = (state: RootState, source: EntrySource, offset: number) => ({
|
const buildGetEntriesPaginatedRequest = (state: RootState, source: EntrySource, offset: number) => ({
|
||||||
id: source.type === "tag" ? Constants.categories.all.id : source.id,
|
id: source.type === "tag" ? Constants.categories.all.id : source.id,
|
||||||
order: state.user.settings?.readingOrder,
|
order: state.user.settings?.readingOrder,
|
||||||
readType: state.user.settings?.readingMode,
|
readType: state.entries.search ? "all" : state.user.settings?.readingMode,
|
||||||
offset,
|
offset,
|
||||||
limit: 50,
|
limit: 50,
|
||||||
tag: source.type === "tag" ? source.id : undefined,
|
tag: source.type === "tag" ? source.id : undefined,
|
||||||
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 }))
|
||||||
})
|
})
|
||||||
|
|
||||||
export const search = createAppAsyncThunk("entries/search", (arg: string, thunkApi) => {
|
export const search = createAppAsyncThunk("entries/search", (arg: string, thunkApi) => {
|
||||||
const state = thunkApi.getState()
|
const state = thunkApi.getState()
|
||||||
thunkApi.dispatch(setSearch(arg))
|
thunkApi.dispatch(setSearch(arg))
|
||||||
thunkApi.dispatch(loadEntries({ source: state.entries.source, clearSearch: false }))
|
thunkApi.dispatch(loadEntries({ source: state.entries.source, clearSearch: false }))
|
||||||
})
|
})
|
||||||
|
|
||||||
export const markEntry = createAppAsyncThunk(
|
export const markEntry = createAppAsyncThunk(
|
||||||
"entries/entry/mark",
|
"entries/entry/mark",
|
||||||
(arg: { entry: Entry; read: boolean }) => {
|
(arg: { entry: Entry; read: boolean }) => {
|
||||||
@@ -67,6 +81,7 @@ export const markEntry = createAppAsyncThunk(
|
|||||||
condition: arg => arg.entry.markable && arg.entry.read !== arg.read,
|
condition: arg => arg.entry.markable && arg.entry.read !== arg.read,
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
export const markMultipleEntries = createAppAsyncThunk(
|
export const markMultipleEntries = createAppAsyncThunk(
|
||||||
"entries/entry/markMultiple",
|
"entries/entry/markMultiple",
|
||||||
async (
|
async (
|
||||||
@@ -84,6 +99,7 @@ export const markMultipleEntries = createAppAsyncThunk(
|
|||||||
thunkApi.dispatch(reloadTree())
|
thunkApi.dispatch(reloadTree())
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
export const markEntriesUpToEntry = createAppAsyncThunk("entries/entry/upToEntry", (arg: Entry, thunkApi) => {
|
export const markEntriesUpToEntry = createAppAsyncThunk("entries/entry/upToEntry", (arg: Entry, thunkApi) => {
|
||||||
const state = thunkApi.getState()
|
const state = thunkApi.getState()
|
||||||
const { entries } = state.entries
|
const { entries } = state.entries
|
||||||
@@ -98,6 +114,7 @@ export const markEntriesUpToEntry = createAppAsyncThunk("entries/entry/upToEntry
|
|||||||
})
|
})
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
export const markAllEntries = createAppAsyncThunk(
|
export const markAllEntries = createAppAsyncThunk(
|
||||||
"entries/entry/markAll",
|
"entries/entry/markAll",
|
||||||
async (
|
async (
|
||||||
@@ -113,6 +130,37 @@ export const markAllEntries = createAppAsyncThunk(
|
|||||||
thunkApi.dispatch(reloadTree())
|
thunkApi.dispatch(reloadTree())
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
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 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,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
)
|
||||||
|
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 }) => {
|
||||||
@@ -126,6 +174,7 @@ export const starEntry = createAppAsyncThunk(
|
|||||||
condition: arg => arg.entry.markable && arg.entry.starred !== arg.starred,
|
condition: arg => arg.entry.markable && arg.entry.starred !== arg.starred,
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
export const selectEntry = createAppAsyncThunk(
|
export const selectEntry = createAppAsyncThunk(
|
||||||
"entries/entry/select",
|
"entries/entry/select",
|
||||||
(
|
(
|
||||||
@@ -166,22 +215,35 @@ export const selectEntry = createAppAsyncThunk(
|
|||||||
})
|
})
|
||||||
|
|
||||||
if (arg.scrollToEntry) {
|
if (arg.scrollToEntry) {
|
||||||
|
const viewMode = state.user.localSettings.viewMode
|
||||||
|
|
||||||
|
const entryIndex = state.entries.entries.indexOf(entry)
|
||||||
|
const entriesToKeepOnTopWhenScrolling =
|
||||||
|
viewMode === "expanded" ? 0 : Math.min(state.user.settings?.entriesToKeepOnTopWhenScrolling ?? 0, entryIndex)
|
||||||
|
const entryToScrollTo = state.entries.entries[entryIndex - entriesToKeepOnTopWhenScrolling]
|
||||||
|
|
||||||
const entryElement = document.getElementById(Constants.dom.entryId(entry))
|
const entryElement = document.getElementById(Constants.dom.entryId(entry))
|
||||||
if (entryElement) {
|
const entryElementToScrollTo = document.getElementById(Constants.dom.entryId(entryToScrollTo))
|
||||||
|
if (entryElement && entryElementToScrollTo) {
|
||||||
const scrollMode = state.user.settings?.scrollMode
|
const scrollMode = state.user.settings?.scrollMode
|
||||||
const entryEntirelyVisible = Constants.layout.isTopVisible(entryElement) && Constants.layout.isBottomVisible(entryElement)
|
const entryEntirelyVisible =
|
||||||
|
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
|
||||||
thunkApi.dispatch(entriesSlice.actions.setScrollingToEntry(true))
|
thunkApi.dispatch(entriesSlice.actions.setScrollingToEntry(true))
|
||||||
scrollToEntry(entryElement, scrollSpeed, () => thunkApi.dispatch(entriesSlice.actions.setScrollingToEntry(false)))
|
scrollToEntry(entryElementToScrollTo, margin, scrollSpeed, () =>
|
||||||
|
thunkApi.dispatch(entriesSlice.actions.setScrollingToEntry(false))
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
const scrollToEntry = (entryElement: HTMLElement, scrollSpeed: number | undefined, onScrollEnded: () => void) => {
|
|
||||||
const header = document.getElementById(Constants.dom.headerId)?.getBoundingClientRect()
|
const scrollToEntry = (entryElement: HTMLElement, margin: number, scrollSpeed: number | undefined, onScrollEnded: () => void) => {
|
||||||
const offset = (header?.bottom ?? 0) + 3
|
const header = document.getElementsByTagName("header").item(0)?.getBoundingClientRect()
|
||||||
|
const offset = (header?.bottom ?? 0) + margin
|
||||||
scrollToWithCallback({
|
scrollToWithCallback({
|
||||||
options: {
|
options: {
|
||||||
top: entryElement.offsetTop - offset,
|
top: entryElement.offsetTop - offset,
|
||||||
@@ -216,9 +278,10 @@ export const selectPreviousEntry = createAppAsyncThunk(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
export const selectNextEntry = createAppAsyncThunk(
|
export const selectNextEntry = createAppAsyncThunk(
|
||||||
"entries/entry/selectNext",
|
"entries/entry/selectNext",
|
||||||
(
|
async (
|
||||||
arg: {
|
arg: {
|
||||||
expand: boolean
|
expand: boolean
|
||||||
markAsRead: boolean
|
markAsRead: boolean
|
||||||
@@ -227,12 +290,20 @@ export const selectNextEntry = createAppAsyncThunk(
|
|||||||
thunkApi
|
thunkApi
|
||||||
) => {
|
) => {
|
||||||
const state = thunkApi.getState()
|
const state = thunkApi.getState()
|
||||||
const { entries } = state.entries
|
const { entries, hasMore, loading } = state.entries
|
||||||
const nextIndex = entries.findIndex(e => e.id === state.entries.selectedEntryId) + 1
|
const nextIndex = entries.findIndex(e => e.id === state.entries.selectedEntryId) + 1
|
||||||
if (nextIndex < entries.length) {
|
|
||||||
|
// load more entries if needed
|
||||||
|
// this can happen if the last entry is too large to fit on the screen and the infinite loader doesn't trigger
|
||||||
|
if (nextIndex >= entries.length && hasMore && !loading) {
|
||||||
|
await thunkApi.dispatch(loadMoreEntries())
|
||||||
|
}
|
||||||
|
|
||||||
|
const entriesAfterLoading = thunkApi.getState().entries.entries
|
||||||
|
if (nextIndex < entriesAfterLoading.length) {
|
||||||
thunkApi.dispatch(
|
thunkApi.dispatch(
|
||||||
selectEntry({
|
selectEntry({
|
||||||
entry: entries[nextIndex],
|
entry: entriesAfterLoading[nextIndex],
|
||||||
expand: arg.expand,
|
expand: arg.expand,
|
||||||
markAsRead: arg.markAsRead,
|
markAsRead: arg.markAsRead,
|
||||||
scrollToEntry: arg.scrollToEntry,
|
scrollToEntry: arg.scrollToEntry,
|
||||||
@@ -241,6 +312,7 @@ export const selectNextEntry = createAppAsyncThunk(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
export const tagEntry = createAppAsyncThunk("entries/entry/tag", async (arg: TagRequest, thunkApi) => {
|
export const tagEntry = createAppAsyncThunk("entries/entry/tag", async (arg: TagRequest, thunkApi) => {
|
||||||
await client.entry.tag(arg)
|
await client.entry.tag(arg)
|
||||||
thunkApi.dispatch(reloadTags())
|
thunkApi.dispatch(reloadTags())
|
||||||
|
|||||||
@@ -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,45 +1,61 @@
|
|||||||
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 redirectToPasswordRecovery = createAppAsyncThunk("redirect/passwordRecovery", (_, thunkApi) =>
|
|
||||||
thunkApi.dispatch(redirectTo("/passwordRecovery"))
|
export const redirectToInitialSetup = createAppAsyncThunk("redirect/initialSetup", (_, thunkApi) => thunkApi.dispatch(redirectTo("/setup")))
|
||||||
)
|
|
||||||
export const redirectToApiDocumentation = createAppAsyncThunk("redirect/api", (_, thunkApi) => thunkApi.dispatch(redirectTo("/api")))
|
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
|
||||||
thunkApi.dispatch(redirectTo(`/app/${source.type}/${source.id}`))
|
thunkApi.dispatch(redirectTo(`/app/${source.type}/${source.id}`))
|
||||||
})
|
})
|
||||||
|
|
||||||
export const redirectToCategory = createAppAsyncThunk("redirect/category", (id: string, thunkApi) =>
|
export const redirectToCategory = createAppAsyncThunk("redirect/category", (id: string, thunkApi) =>
|
||||||
thunkApi.dispatch(redirectTo(`/app/category/${id}`))
|
thunkApi.dispatch(redirectTo(`/app/category/${id}`))
|
||||||
)
|
)
|
||||||
|
|
||||||
export const redirectToRootCategory = createAppAsyncThunk(
|
export const redirectToRootCategory = createAppAsyncThunk(
|
||||||
"redirect/category/root",
|
"redirect/category/root",
|
||||||
async (_, thunkApi) => await thunkApi.dispatch(redirectToCategory(Constants.categories.all.id))
|
async (_, thunkApi) => await thunkApi.dispatch(redirectToCategory(Constants.categories.all.id))
|
||||||
)
|
)
|
||||||
|
|
||||||
export const redirectToCategoryDetails = createAppAsyncThunk("redirect/category/details", (id: string, thunkApi) =>
|
export const redirectToCategoryDetails = createAppAsyncThunk("redirect/category/details", (id: string, thunkApi) =>
|
||||||
thunkApi.dispatch(redirectTo(`/app/category/${id}/details`))
|
thunkApi.dispatch(redirectTo(`/app/category/${id}/details`))
|
||||||
)
|
)
|
||||||
|
|
||||||
export const redirectToFeed = createAppAsyncThunk("redirect/feed", (id: string | number, thunkApi) =>
|
export const redirectToFeed = createAppAsyncThunk("redirect/feed", (id: string | number, thunkApi) =>
|
||||||
thunkApi.dispatch(redirectTo(`/app/feed/${id}`))
|
thunkApi.dispatch(redirectTo(`/app/feed/${id}`))
|
||||||
)
|
)
|
||||||
|
|
||||||
export const redirectToFeedDetails = createAppAsyncThunk("redirect/feed/details", (id: string, thunkApi) =>
|
export const redirectToFeedDetails = createAppAsyncThunk("redirect/feed/details", (id: string, thunkApi) =>
|
||||||
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"))
|
||||||
)
|
)
|
||||||
|
|
||||||
export const redirectToMetrics = createAppAsyncThunk("redirect/admin/metrics", (_, thunkApi) =>
|
export const redirectToMetrics = createAppAsyncThunk("redirect/admin/metrics", (_, thunkApi) =>
|
||||||
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,4 @@
|
|||||||
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,10 +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 { userSlice } from "app/user/slice"
|
import { treeSlice } from "@/app/tree/slice"
|
||||||
import { type TypedUseSelectorHook, useDispatch, useSelector } from "react-redux"
|
import type { LocalSettings } from "@/app/types"
|
||||||
|
import { initialLocalSettings, userSlice } from "@/app/user/slice"
|
||||||
|
|
||||||
export const reducers = {
|
export const reducers = {
|
||||||
entries: entriesSlice.reducer,
|
entries: entriesSlice.reducer,
|
||||||
@@ -14,10 +15,30 @@ export const reducers = {
|
|||||||
user: userSlice.reducer,
|
user: userSlice.reducer,
|
||||||
}
|
}
|
||||||
|
|
||||||
export const store = configureStore({ reducer: reducers })
|
const loadLocalSettings = (): LocalSettings => {
|
||||||
|
const json = localStorage.getItem("commafeed-local-settings")
|
||||||
|
return {
|
||||||
|
...initialLocalSettings,
|
||||||
|
...(json ? JSON.parse(json) : {}),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const store = configureStore({
|
||||||
|
reducer: reducers,
|
||||||
|
preloadedState: {
|
||||||
|
user: {
|
||||||
|
localSettings: loadLocalSettings(),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
store.subscribe(() => {
|
||||||
|
const localSettings = store.getState().user.localSettings
|
||||||
|
localStorage.setItem("commafeed-local-settings", JSON.stringify(localSettings))
|
||||||
|
})
|
||||||
|
|
||||||
export type RootState = ReturnType<typeof store.getState>
|
export type RootState = ReturnType<typeof store.getState>
|
||||||
export type AppDispatch = typeof store.dispatch
|
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,9 +1,84 @@
|
|||||||
import { createAppAsyncThunk } from "app/async-thunk"
|
import { createAppAsyncThunk } from "@/app/async-thunk"
|
||||||
import { client } from "app/client"
|
import { client } from "@/app/client"
|
||||||
import type { CollapseRequest } from "app/types"
|
import { Constants } from "@/app/constants"
|
||||||
|
import { redirectToCategory, redirectToFeed } from "@/app/redirect/thunks"
|
||||||
|
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(
|
||||||
|
"tree/new-feed-entries-discovered",
|
||||||
|
async ({ feedId, amount }: { feedId: number; amount: number }, thunkApi) => {
|
||||||
|
const root = thunkApi.getState().tree.rootCategory
|
||||||
|
if (!root) return
|
||||||
|
|
||||||
|
const feed = flattenCategoryTree(root)
|
||||||
|
.flatMap(c => c.feeds)
|
||||||
|
.some(f => f.id === feedId)
|
||||||
|
if (!feed) {
|
||||||
|
// feed not found in the tree, reload the tree completely
|
||||||
|
thunkApi.dispatch(reloadTree())
|
||||||
|
} else {
|
||||||
|
thunkApi.dispatch(
|
||||||
|
incrementUnreadCount({
|
||||||
|
feedId,
|
||||||
|
amount,
|
||||||
|
})
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
)
|
)
|
||||||
|
|||||||
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,10 @@ export interface Subscription {
|
|||||||
position: number
|
position: number
|
||||||
newestItemTime?: number
|
newestItemTime?: number
|
||||||
filter?: string
|
filter?: string
|
||||||
|
filterLegacy?: string
|
||||||
|
pushNotificationsEnabled: boolean
|
||||||
|
autoMarkAsReadAfterDays?: number
|
||||||
|
averageEntryIntervalMs?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Category {
|
export interface Category {
|
||||||
@@ -109,6 +113,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 {
|
||||||
@@ -117,7 +123,6 @@ export interface GetEntriesRequest {
|
|||||||
newerThan?: number
|
newerThan?: number
|
||||||
order?: ReadingOrder
|
order?: ReadingOrder
|
||||||
keywords?: string
|
keywords?: string
|
||||||
onlyIds?: boolean
|
|
||||||
excludedSubscriptionIds?: string
|
excludedSubscriptionIds?: string
|
||||||
tag?: string
|
tag?: string
|
||||||
}
|
}
|
||||||
@@ -197,6 +202,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
|
||||||
@@ -210,17 +221,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
|
||||||
|
initialSetupRequired: boolean
|
||||||
|
minimumPasswordLength: number
|
||||||
|
pushNotificationsEnabled: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SharingSettings {
|
export interface SharingSettings {
|
||||||
@@ -234,8 +255,18 @@ export interface SharingSettings {
|
|||||||
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
|
||||||
@@ -244,12 +275,28 @@ export interface Settings {
|
|||||||
customJs?: string
|
customJs?: string
|
||||||
scrollSpeed: number
|
scrollSpeed: number
|
||||||
scrollMode: ScrollMode
|
scrollMode: ScrollMode
|
||||||
|
entriesToKeepOnTopWhenScrolling: number
|
||||||
starIconDisplayMode: IconDisplayMode
|
starIconDisplayMode: IconDisplayMode
|
||||||
externalLinkIconDisplayMode: IconDisplayMode
|
externalLinkIconDisplayMode: IconDisplayMode
|
||||||
markAllAsReadConfirmation: boolean
|
markAllAsReadConfirmation: boolean
|
||||||
|
markAllAsReadNavigateToNextUnread: boolean
|
||||||
customContextMenu: boolean
|
customContextMenu: boolean
|
||||||
mobileFooter: boolean
|
mobileFooter: boolean
|
||||||
|
unreadCountTitle: boolean
|
||||||
|
unreadCountFavicon: boolean
|
||||||
|
disablePullToRefresh: boolean
|
||||||
|
disableMobileSwipe: boolean
|
||||||
|
infrequentThresholdDays: number
|
||||||
|
primaryColor?: string
|
||||||
sharingSettings: SharingSettings
|
sharingSettings: SharingSettings
|
||||||
|
pushNotificationSettings: PushNotificationSettings
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LocalSettings {
|
||||||
|
viewMode: ViewMode
|
||||||
|
sidebarWidth: number
|
||||||
|
announcementHash: string
|
||||||
|
fontSizePercentage: number
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface StarRequest {
|
export interface StarRequest {
|
||||||
@@ -279,6 +326,7 @@ export interface UserModel {
|
|||||||
created: number
|
created: number
|
||||||
lastLogin?: number
|
lastLogin?: number
|
||||||
admin: boolean
|
admin: boolean
|
||||||
|
lastForceRefresh?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AdminSaveUserRequest {
|
export interface AdminSaveUserRequest {
|
||||||
|
|||||||
@@ -1,13 +1,20 @@
|
|||||||
import { t } from "@lingui/macro"
|
import { t } from "@lingui/core/macro"
|
||||||
import { showNotification } from "@mantine/notifications"
|
import { showNotification } from "@mantine/notifications"
|
||||||
import { createSlice, isAnyOf } from "@reduxjs/toolkit"
|
import { createSlice, isAnyOf, type PayloadAction } from "@reduxjs/toolkit"
|
||||||
import type { Settings, UserModel } from "app/types"
|
import type { LocalSettings, Settings, UserModel, ViewMode } from "@/app/types"
|
||||||
import {
|
import {
|
||||||
changeCustomContextMenu,
|
changeCustomContextMenu,
|
||||||
|
changeDisableMobileSwipe,
|
||||||
|
changeDisablePullToRefresh,
|
||||||
|
changeEntriesToKeepOnTopWhenScrolling,
|
||||||
changeExternalLinkIconDisplayMode,
|
changeExternalLinkIconDisplayMode,
|
||||||
|
changeInfrequentThresholdDays,
|
||||||
changeLanguage,
|
changeLanguage,
|
||||||
changeMarkAllAsReadConfirmation,
|
changeMarkAllAsReadConfirmation,
|
||||||
|
changeMarkAllAsReadNavigateToUnread,
|
||||||
changeMobileFooter,
|
changeMobileFooter,
|
||||||
|
changePrimaryColor,
|
||||||
|
changePushNotificationSettings,
|
||||||
changeReadingMode,
|
changeReadingMode,
|
||||||
changeReadingOrder,
|
changeReadingOrder,
|
||||||
changeScrollMarks,
|
changeScrollMarks,
|
||||||
@@ -16,6 +23,8 @@ import {
|
|||||||
changeSharingSetting,
|
changeSharingSetting,
|
||||||
changeShowRead,
|
changeShowRead,
|
||||||
changeStarIconDisplayMode,
|
changeStarIconDisplayMode,
|
||||||
|
changeUnreadCountFavicon,
|
||||||
|
changeUnreadCountTitle,
|
||||||
reloadProfile,
|
reloadProfile,
|
||||||
reloadSettings,
|
reloadSettings,
|
||||||
reloadTags,
|
reloadTags,
|
||||||
@@ -23,16 +32,39 @@ import {
|
|||||||
|
|
||||||
interface UserState {
|
interface UserState {
|
||||||
settings?: Settings
|
settings?: Settings
|
||||||
|
localSettings: LocalSettings
|
||||||
profile?: UserModel
|
profile?: UserModel
|
||||||
tags?: string[]
|
tags?: string[]
|
||||||
}
|
}
|
||||||
|
|
||||||
const initialState: UserState = {}
|
export const initialLocalSettings: LocalSettings = {
|
||||||
|
viewMode: "detailed",
|
||||||
|
sidebarWidth: 360,
|
||||||
|
announcementHash: "no-hash",
|
||||||
|
fontSizePercentage: 100,
|
||||||
|
}
|
||||||
|
|
||||||
|
const initialState: UserState = {
|
||||||
|
localSettings: initialLocalSettings,
|
||||||
|
}
|
||||||
|
|
||||||
export const userSlice = createSlice({
|
export const userSlice = createSlice({
|
||||||
name: "user",
|
name: "user",
|
||||||
initialState,
|
initialState,
|
||||||
reducers: {},
|
reducers: {
|
||||||
|
setViewMode: (state, action: PayloadAction<ViewMode>) => {
|
||||||
|
state.localSettings.viewMode = action.payload
|
||||||
|
},
|
||||||
|
setFontSizePercentage: (state, action: PayloadAction<number>) => {
|
||||||
|
state.localSettings.fontSizePercentage = action.payload
|
||||||
|
},
|
||||||
|
setSidebarWidth: (state, action: PayloadAction<number>) => {
|
||||||
|
state.localSettings.sidebarWidth = action.payload
|
||||||
|
},
|
||||||
|
setAnnouncementHash: (state, action: PayloadAction<string>) => {
|
||||||
|
state.localSettings.announcementHash = action.payload
|
||||||
|
},
|
||||||
|
},
|
||||||
extraReducers: builder => {
|
extraReducers: builder => {
|
||||||
builder.addCase(reloadSettings.fulfilled, (state, action) => {
|
builder.addCase(reloadSettings.fulfilled, (state, action) => {
|
||||||
state.settings = action.payload
|
state.settings = action.payload
|
||||||
@@ -71,6 +103,10 @@ export const userSlice = createSlice({
|
|||||||
if (!state.settings) return
|
if (!state.settings) return
|
||||||
state.settings.scrollMode = action.meta.arg
|
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) => {
|
builder.addCase(changeStarIconDisplayMode.pending, (state, action) => {
|
||||||
if (!state.settings) return
|
if (!state.settings) return
|
||||||
state.settings.starIconDisplayMode = action.meta.arg
|
state.settings.starIconDisplayMode = action.meta.arg
|
||||||
@@ -83,6 +119,10 @@ export const userSlice = createSlice({
|
|||||||
if (!state.settings) return
|
if (!state.settings) return
|
||||||
state.settings.markAllAsReadConfirmation = action.meta.arg
|
state.settings.markAllAsReadConfirmation = action.meta.arg
|
||||||
})
|
})
|
||||||
|
builder.addCase(changeMarkAllAsReadNavigateToUnread.pending, (state, action) => {
|
||||||
|
if (!state.settings) return
|
||||||
|
state.settings.markAllAsReadNavigateToNextUnread = action.meta.arg
|
||||||
|
})
|
||||||
builder.addCase(changeCustomContextMenu.pending, (state, action) => {
|
builder.addCase(changeCustomContextMenu.pending, (state, action) => {
|
||||||
if (!state.settings) return
|
if (!state.settings) return
|
||||||
state.settings.customContextMenu = action.meta.arg
|
state.settings.customContextMenu = action.meta.arg
|
||||||
@@ -91,10 +131,39 @@ export const userSlice = createSlice({
|
|||||||
if (!state.settings) return
|
if (!state.settings) return
|
||||||
state.settings.mobileFooter = action.meta.arg
|
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(changeDisablePullToRefresh.pending, (state, action) => {
|
||||||
|
if (!state.settings) return
|
||||||
|
state.settings.disablePullToRefresh = action.meta.arg
|
||||||
|
})
|
||||||
|
builder.addCase(changeDisableMobileSwipe.pending, (state, action) => {
|
||||||
|
if (!state.settings) return
|
||||||
|
state.settings.disableMobileSwipe = action.meta.arg
|
||||||
|
})
|
||||||
|
builder.addCase(changeInfrequentThresholdDays.pending, (state, action) => {
|
||||||
|
if (!state.settings) return
|
||||||
|
state.settings.infrequentThresholdDays = action.meta.arg
|
||||||
|
})
|
||||||
|
builder.addCase(changePrimaryColor.pending, (state, action) => {
|
||||||
|
if (!state.settings) return
|
||||||
|
state.settings.primaryColor = action.meta.arg
|
||||||
|
})
|
||||||
builder.addCase(changeSharingSetting.pending, (state, action) => {
|
builder.addCase(changeSharingSetting.pending, (state, action) => {
|
||||||
if (!state.settings) return
|
if (!state.settings) return
|
||||||
state.settings.sharingSettings[action.meta.arg.site] = action.meta.arg.value
|
state.settings.sharingSettings[action.meta.arg.site] = action.meta.arg.value
|
||||||
})
|
})
|
||||||
|
builder.addCase(changePushNotificationSettings.pending, (state, action) => {
|
||||||
|
if (!state.settings) return
|
||||||
|
state.settings.pushNotificationSettings = action.meta.arg
|
||||||
|
})
|
||||||
|
|
||||||
builder.addMatcher(
|
builder.addMatcher(
|
||||||
isAnyOf(
|
isAnyOf(
|
||||||
changeLanguage.fulfilled,
|
changeLanguage.fulfilled,
|
||||||
@@ -102,12 +171,21 @@ export const userSlice = createSlice({
|
|||||||
changeShowRead.fulfilled,
|
changeShowRead.fulfilled,
|
||||||
changeScrollMarks.fulfilled,
|
changeScrollMarks.fulfilled,
|
||||||
changeScrollMode.fulfilled,
|
changeScrollMode.fulfilled,
|
||||||
|
changeEntriesToKeepOnTopWhenScrolling.fulfilled,
|
||||||
changeStarIconDisplayMode.fulfilled,
|
changeStarIconDisplayMode.fulfilled,
|
||||||
changeExternalLinkIconDisplayMode.fulfilled,
|
changeExternalLinkIconDisplayMode.fulfilled,
|
||||||
changeMarkAllAsReadConfirmation.fulfilled,
|
changeMarkAllAsReadConfirmation.fulfilled,
|
||||||
|
changeMarkAllAsReadNavigateToUnread.fulfilled,
|
||||||
changeCustomContextMenu.fulfilled,
|
changeCustomContextMenu.fulfilled,
|
||||||
changeMobileFooter.fulfilled,
|
changeMobileFooter.fulfilled,
|
||||||
changeSharingSetting.fulfilled
|
changeUnreadCountTitle.fulfilled,
|
||||||
|
changeUnreadCountFavicon.fulfilled,
|
||||||
|
changeDisablePullToRefresh.fulfilled,
|
||||||
|
changeDisableMobileSwipe.fulfilled,
|
||||||
|
changeInfrequentThresholdDays.fulfilled,
|
||||||
|
changePrimaryColor.fulfilled,
|
||||||
|
changeSharingSetting.fulfilled,
|
||||||
|
changePushNotificationSettings.fulfilled
|
||||||
),
|
),
|
||||||
() => {
|
() => {
|
||||||
showNotification({
|
showNotification({
|
||||||
@@ -118,3 +196,5 @@ export const userSlice = createSlice({
|
|||||||
)
|
)
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
export const { setViewMode, setSidebarWidth, setAnnouncementHash, setFontSizePercentage } = userSlice.actions
|
||||||
|
|||||||
@@ -1,48 +1,67 @@
|
|||||||
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 { reloadEntries } from "@/app/entries/thunks"
|
||||||
import type { IconDisplayMode, ReadingMode, ReadingOrder, ScrollMode, SharingSettings } from "app/types"
|
import type { IconDisplayMode, PushNotificationSettings, 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", async () => await client.user.getSettings().then(r => r.data))
|
||||||
|
|
||||||
export const reloadProfile = createAppAsyncThunk("profile/reload", async () => await client.user.getProfile().then(r => r.data))
|
export const reloadProfile = createAppAsyncThunk("profile/reload", async () => await client.user.getProfile().then(r => r.data))
|
||||||
|
|
||||||
export const reloadTags = createAppAsyncThunk("entries/tags", async () => await client.entry.getTags().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) => {
|
export const changeReadingMode = createAppAsyncThunk("settings/readingMode", (readingMode: ReadingMode, thunkApi) => {
|
||||||
const { settings } = thunkApi.getState().user
|
const { settings } = thunkApi.getState().user
|
||||||
if (!settings) return
|
if (!settings) return
|
||||||
client.user.saveSettings({ ...settings, readingMode })
|
client.user.saveSettings({ ...settings, readingMode })
|
||||||
thunkApi.dispatch(reloadEntries())
|
thunkApi.dispatch(reloadEntries())
|
||||||
})
|
})
|
||||||
|
|
||||||
export const changeReadingOrder = createAppAsyncThunk("settings/readingOrder", (readingOrder: ReadingOrder, thunkApi) => {
|
export const changeReadingOrder = createAppAsyncThunk("settings/readingOrder", (readingOrder: ReadingOrder, thunkApi) => {
|
||||||
const { settings } = thunkApi.getState().user
|
const { settings } = thunkApi.getState().user
|
||||||
if (!settings) return
|
if (!settings) return
|
||||||
client.user.saveSettings({ ...settings, readingOrder })
|
client.user.saveSettings({ ...settings, readingOrder })
|
||||||
thunkApi.dispatch(reloadEntries())
|
thunkApi.dispatch(reloadEntries())
|
||||||
})
|
})
|
||||||
|
|
||||||
export const changeLanguage = createAppAsyncThunk("settings/language", (language: string, thunkApi) => {
|
export const changeLanguage = createAppAsyncThunk("settings/language", (language: string, thunkApi) => {
|
||||||
const { settings } = thunkApi.getState().user
|
const { settings } = thunkApi.getState().user
|
||||||
if (!settings) return
|
if (!settings) return
|
||||||
client.user.saveSettings({ ...settings, language })
|
client.user.saveSettings({ ...settings, language })
|
||||||
})
|
})
|
||||||
|
|
||||||
export const changeScrollSpeed = createAppAsyncThunk("settings/scrollSpeed", (speed: boolean, thunkApi) => {
|
export const changeScrollSpeed = createAppAsyncThunk("settings/scrollSpeed", (speed: boolean, thunkApi) => {
|
||||||
const { settings } = thunkApi.getState().user
|
const { settings } = thunkApi.getState().user
|
||||||
if (!settings) return
|
if (!settings) return
|
||||||
client.user.saveSettings({ ...settings, scrollSpeed: speed ? 400 : 0 })
|
client.user.saveSettings({ ...settings, scrollSpeed: speed ? 400 : 0 })
|
||||||
})
|
})
|
||||||
|
|
||||||
export const changeShowRead = createAppAsyncThunk("settings/showRead", (showRead: boolean, thunkApi) => {
|
export const changeShowRead = createAppAsyncThunk("settings/showRead", (showRead: boolean, thunkApi) => {
|
||||||
const { settings } = thunkApi.getState().user
|
const { settings } = thunkApi.getState().user
|
||||||
if (!settings) return
|
if (!settings) return
|
||||||
client.user.saveSettings({ ...settings, showRead })
|
client.user.saveSettings({ ...settings, showRead })
|
||||||
})
|
})
|
||||||
|
|
||||||
export const changeScrollMarks = createAppAsyncThunk("settings/scrollMarks", (scrollMarks: boolean, thunkApi) => {
|
export const changeScrollMarks = createAppAsyncThunk("settings/scrollMarks", (scrollMarks: boolean, thunkApi) => {
|
||||||
const { settings } = thunkApi.getState().user
|
const { settings } = thunkApi.getState().user
|
||||||
if (!settings) return
|
if (!settings) return
|
||||||
client.user.saveSettings({ ...settings, scrollMarks })
|
client.user.saveSettings({ ...settings, scrollMarks })
|
||||||
})
|
})
|
||||||
|
|
||||||
export const changeScrollMode = createAppAsyncThunk("settings/scrollMode", (scrollMode: ScrollMode, thunkApi) => {
|
export const changeScrollMode = createAppAsyncThunk("settings/scrollMode", (scrollMode: ScrollMode, thunkApi) => {
|
||||||
const { settings } = thunkApi.getState().user
|
const { settings } = thunkApi.getState().user
|
||||||
if (!settings) return
|
if (!settings) return
|
||||||
client.user.saveSettings({ ...settings, scrollMode })
|
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 changeStarIconDisplayMode = createAppAsyncThunk(
|
||||||
"settings/starIconDisplayMode",
|
"settings/starIconDisplayMode",
|
||||||
(starIconDisplayMode: IconDisplayMode, thunkApi) => {
|
(starIconDisplayMode: IconDisplayMode, thunkApi) => {
|
||||||
@@ -51,6 +70,7 @@ export const changeStarIconDisplayMode = createAppAsyncThunk(
|
|||||||
client.user.saveSettings({ ...settings, starIconDisplayMode })
|
client.user.saveSettings({ ...settings, starIconDisplayMode })
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
export const changeExternalLinkIconDisplayMode = createAppAsyncThunk(
|
export const changeExternalLinkIconDisplayMode = createAppAsyncThunk(
|
||||||
"settings/externalLinkIconDisplayMode",
|
"settings/externalLinkIconDisplayMode",
|
||||||
(externalLinkIconDisplayMode: IconDisplayMode, thunkApi) => {
|
(externalLinkIconDisplayMode: IconDisplayMode, thunkApi) => {
|
||||||
@@ -59,6 +79,7 @@ export const changeExternalLinkIconDisplayMode = createAppAsyncThunk(
|
|||||||
client.user.saveSettings({ ...settings, externalLinkIconDisplayMode })
|
client.user.saveSettings({ ...settings, externalLinkIconDisplayMode })
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
export const changeMarkAllAsReadConfirmation = createAppAsyncThunk(
|
export const changeMarkAllAsReadConfirmation = createAppAsyncThunk(
|
||||||
"settings/markAllAsReadConfirmation",
|
"settings/markAllAsReadConfirmation",
|
||||||
(markAllAsReadConfirmation: boolean, thunkApi) => {
|
(markAllAsReadConfirmation: boolean, thunkApi) => {
|
||||||
@@ -67,16 +88,61 @@ export const changeMarkAllAsReadConfirmation = createAppAsyncThunk(
|
|||||||
client.user.saveSettings({ ...settings, markAllAsReadConfirmation })
|
client.user.saveSettings({ ...settings, markAllAsReadConfirmation })
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
export const changeMarkAllAsReadNavigateToUnread = createAppAsyncThunk(
|
||||||
|
"settings/markAllAsReadNavigateToUnread",
|
||||||
|
(markAllAsReadNavigateToNextUnread: boolean, thunkApi) => {
|
||||||
|
const { settings } = thunkApi.getState().user
|
||||||
|
if (!settings) return
|
||||||
|
client.user.saveSettings({ ...settings, markAllAsReadNavigateToNextUnread })
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
export const changeCustomContextMenu = createAppAsyncThunk("settings/customContextMenu", (customContextMenu: boolean, thunkApi) => {
|
export const changeCustomContextMenu = createAppAsyncThunk("settings/customContextMenu", (customContextMenu: boolean, thunkApi) => {
|
||||||
const { settings } = thunkApi.getState().user
|
const { settings } = thunkApi.getState().user
|
||||||
if (!settings) return
|
if (!settings) return
|
||||||
client.user.saveSettings({ ...settings, customContextMenu })
|
client.user.saveSettings({ ...settings, customContextMenu })
|
||||||
})
|
})
|
||||||
|
|
||||||
export const changeMobileFooter = createAppAsyncThunk("settings/mobileFooter", (mobileFooter: boolean, thunkApi) => {
|
export const changeMobileFooter = createAppAsyncThunk("settings/mobileFooter", (mobileFooter: boolean, thunkApi) => {
|
||||||
const { settings } = thunkApi.getState().user
|
const { settings } = thunkApi.getState().user
|
||||||
if (!settings) return
|
if (!settings) return
|
||||||
client.user.saveSettings({ ...settings, mobileFooter })
|
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 changeDisablePullToRefresh = createAppAsyncThunk(
|
||||||
|
"settings/disablePullToRefresh",
|
||||||
|
(disablePullToRefresh: boolean, thunkApi) => {
|
||||||
|
const { settings } = thunkApi.getState().user
|
||||||
|
if (!settings) return
|
||||||
|
client.user.saveSettings({ ...settings, disablePullToRefresh })
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
export const changeDisableMobileSwipe = createAppAsyncThunk("settings/disableMobileSwipe", (disableMobileSwipe: boolean, thunkApi) => {
|
||||||
|
const { settings } = thunkApi.getState().user
|
||||||
|
if (!settings) return
|
||||||
|
client.user.saveSettings({ ...settings, disableMobileSwipe })
|
||||||
|
})
|
||||||
|
|
||||||
|
export const changePrimaryColor = createAppAsyncThunk("settings/primaryColor", (primaryColor: string, thunkApi) => {
|
||||||
|
const { settings } = thunkApi.getState().user
|
||||||
|
if (!settings) return
|
||||||
|
client.user.saveSettings({ ...settings, primaryColor })
|
||||||
|
})
|
||||||
|
|
||||||
export const changeSharingSetting = createAppAsyncThunk(
|
export const changeSharingSetting = createAppAsyncThunk(
|
||||||
"settings/sharingSetting",
|
"settings/sharingSetting",
|
||||||
(
|
(
|
||||||
@@ -97,3 +163,24 @@ export const changeSharingSetting = createAppAsyncThunk(
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
export const changeInfrequentThresholdDays = createAppAsyncThunk(
|
||||||
|
"settings/infrequentThresholdDays",
|
||||||
|
(infrequentThresholdDays: number, thunkApi) => {
|
||||||
|
const { settings } = thunkApi.getState().user
|
||||||
|
if (!settings) return
|
||||||
|
client.user.saveSettings({ ...settings, infrequentThresholdDays })
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
export const changePushNotificationSettings = createAppAsyncThunk(
|
||||||
|
"settings/pushNotificationSettings",
|
||||||
|
(pushNotificationSettings: PushNotificationSettings, thunkApi) => {
|
||||||
|
const { settings } = thunkApi.getState().user
|
||||||
|
if (!settings) return
|
||||||
|
client.user.saveSettings({
|
||||||
|
...settings,
|
||||||
|
pushNotificationSettings,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|||||||
@@ -1,28 +1,50 @@
|
|||||||
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, maxFrequencyThresholdMs?: number): number {
|
||||||
if (!category) return 0
|
if (!category) return 0
|
||||||
|
|
||||||
return flattenCategoryTree(category)
|
return flattenCategoryTree(category)
|
||||||
.flatMap(c => c.feeds)
|
.flatMap(c => c.feeds)
|
||||||
|
.filter(f => !maxFrequencyThresholdMs || (f.averageEntryIntervalMs && f.averageEntryIntervalMs >= maxFrequencyThresholdMs))
|
||||||
.map(f => f.unread)
|
.map(f => f.unread)
|
||||||
.reduce((total, current) => total + current, 0)
|
.reduce((total, current) => total + current, 0)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function categoryHasNewEntries(category?: TreeCategory, maxFrequencyThresholdMs?: number): boolean {
|
||||||
|
if (!category) return false
|
||||||
|
|
||||||
|
return flattenCategoryTree(category)
|
||||||
|
.flatMap(c => c.feeds)
|
||||||
|
.filter(f => !maxFrequencyThresholdMs || (f.averageEntryIntervalMs && f.averageEntryIntervalMs >= maxFrequencyThresholdMs))
|
||||||
|
.some(f => f.hasNewEntries)
|
||||||
|
}
|
||||||
|
|
||||||
export const calculatePlaceholderSize = ({ width, height, maxWidth }: { width?: number; height?: number; maxWidth: number }) => {
|
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
|
||||||
|
|||||||
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,13 +1,15 @@
|
|||||||
import { ActionIcon, Button, type ButtonVariant, Tooltip, useMantineTheme } from "@mantine/core"
|
import type { MessageDescriptor } from "@lingui/core"
|
||||||
|
import { useLingui } from "@lingui/react"
|
||||||
|
import { ActionIcon, Box, Button, type ButtonVariant, Tooltip, useMantineTheme } from "@mantine/core"
|
||||||
import type { ActionIconVariant } from "@mantine/core/lib/components/ActionIcon/ActionIcon"
|
import type { ActionIconVariant } from "@mantine/core/lib/components/ActionIcon/ActionIcon"
|
||||||
import { Constants } from "app/constants"
|
import { forwardRef, type MouseEventHandler, type ReactNode } from "react"
|
||||||
import { useActionButton } from "hooks/useActionButton"
|
import { Constants } from "@/app/constants"
|
||||||
import { type MouseEventHandler, type ReactNode, forwardRef } from "react"
|
import { useActionButton } from "@/hooks/useActionButton"
|
||||||
|
|
||||||
interface ActionButtonProps {
|
interface ActionButtonProps {
|
||||||
|
icon: ReactNode
|
||||||
className?: string
|
className?: string
|
||||||
icon?: ReactNode
|
label?: string | MessageDescriptor
|
||||||
label: ReactNode
|
|
||||||
onClick?: MouseEventHandler
|
onClick?: MouseEventHandler
|
||||||
variant?: ActionIconVariant & ButtonVariant
|
variant?: ActionIconVariant & ButtonVariant
|
||||||
hideLabelOnDesktop?: boolean
|
hideLabelOnDesktop?: boolean
|
||||||
@@ -17,21 +19,44 @@ interface ActionButtonProps {
|
|||||||
/**
|
/**
|
||||||
* 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 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)
|
const iconOnly = (mobile && !props.showLabelOnMobile) || (!mobile && props.hideLabelOnDesktop)
|
||||||
return iconOnly ? (
|
|
||||||
<Tooltip label={props.label} openDelay={Constants.tooltip.delay}>
|
return (
|
||||||
<ActionIcon ref={ref} color={theme.primaryColor} variant={variant} className={props.className} onClick={props.onClick}>
|
<Box ref={ref} className="cf-action-button">
|
||||||
{props.icon}
|
{iconOnly && (
|
||||||
</ActionIcon>
|
<Tooltip label={label} openDelay={Constants.tooltip.delay}>
|
||||||
</Tooltip>
|
<ActionIcon
|
||||||
) : (
|
color={theme.primaryColor}
|
||||||
<Button ref={ref} variant={variant} size="xs" className={props.className} leftSection={props.icon} onClick={props.onClick}>
|
variant={variant}
|
||||||
{props.label}
|
className={props.className}
|
||||||
</Button>
|
onClick={props.onClick}
|
||||||
|
aria-label={label}
|
||||||
|
>
|
||||||
|
{props.icon}
|
||||||
|
</ActionIcon>
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
|
{!iconOnly && (
|
||||||
|
<Button
|
||||||
|
variant={variant}
|
||||||
|
size="xs"
|
||||||
|
className={props.className}
|
||||||
|
leftSection={props.icon}
|
||||||
|
onClick={props.onClick}
|
||||||
|
aria-label={label}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
ActionButton.displayName = "HeaderButton"
|
ActionButton.displayName = "HeaderButton"
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Trans } from "@lingui/macro"
|
import { Trans } from "@lingui/react/macro"
|
||||||
import { Box, Alert as MantineAlert } from "@mantine/core"
|
import { Box, Alert as MantineAlert } from "@mantine/core"
|
||||||
import { Fragment } from "react"
|
import { Fragment } from "react"
|
||||||
import { TbAlertCircle, TbAlertTriangle, TbCircleCheck } from "react-icons/tb"
|
import { TbAlertCircle, TbAlertTriangle, TbCircleCheck } from "react-icons/tb"
|
||||||
@@ -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/macro"
|
import { Trans } from "@lingui/react/macro"
|
||||||
import { Box, Dialog, Text } from "@mantine/core"
|
import { Box, Dialog, Text } from "@mantine/core"
|
||||||
import { useAppSelector } from "app/store"
|
|
||||||
import { Content } from "components/content/Content"
|
|
||||||
import { useAsync } from "react-async-hook"
|
import { useAsync } from "react-async-hook"
|
||||||
import useLocalStorage from "use-local-storage"
|
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)
|
||||||
@@ -15,10 +15,11 @@ const sha256Hex = async (input: string | undefined) => {
|
|||||||
export function AnnouncementDialog() {
|
export function AnnouncementDialog() {
|
||||||
const announcement = useAppSelector(state => state.server.serverInfos?.announcement)
|
const announcement = useAppSelector(state => state.server.serverInfos?.announcement)
|
||||||
const announcementHash = useAsync(sha256Hex, [announcement]).result
|
const announcementHash = useAsync(sha256Hex, [announcement]).result
|
||||||
const [localStorageHash, setLocalStorageHash] = useLocalStorage("announcement-hash", "no-hash")
|
const existingAnnouncementHash = useAppSelector(state => state.user.localSettings.announcementHash)
|
||||||
|
const dispatch = useAppDispatch()
|
||||||
|
|
||||||
const opened = !!announcementHash && announcementHash !== localStorageHash
|
const opened = !!announcementHash && announcementHash !== existingAnnouncementHash
|
||||||
const onClosed = () => setLocalStorageHash(announcementHash)
|
const onClosed = () => announcementHash && dispatch(setAnnouncementHash(announcementHash))
|
||||||
|
|
||||||
if (!announcement) return null
|
if (!announcement) return null
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -1,15 +1,3 @@
|
|||||||
import { Helmet } from "react-helmet"
|
export const DisablePullToRefresh = ({ enabled }: { enabled: boolean | undefined }) => {
|
||||||
|
return enabled ? <style>{`html, body { overscroll-behavior: none; }`}</style> : null
|
||||||
export const DisablePullToRefresh = () => {
|
|
||||||
return (
|
|
||||||
<Helmet>
|
|
||||||
<style type="text/css">
|
|
||||||
{`
|
|
||||||
html, body {
|
|
||||||
overscroll-behavior: none;
|
|
||||||
}
|
|
||||||
`}
|
|
||||||
</style>
|
|
||||||
</Helmet>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -9,6 +9,7 @@ interface ImageWithPlaceholderWhileLoadingProps {
|
|||||||
title?: string
|
title?: string
|
||||||
width?: number
|
width?: number
|
||||||
height?: number | "auto"
|
height?: number | "auto"
|
||||||
|
style?: React.CSSProperties
|
||||||
placeholderWidth?: number
|
placeholderWidth?: number
|
||||||
placeholderHeight?: number
|
placeholderHeight?: number
|
||||||
placeholderBackgroundColor?: string
|
placeholderBackgroundColor?: string
|
||||||
@@ -42,7 +43,8 @@ export function ImageWithPlaceholderWhileLoading({
|
|||||||
src,
|
src,
|
||||||
title,
|
title,
|
||||||
width,
|
width,
|
||||||
}: ImageWithPlaceholderWhileLoadingProps) {
|
style,
|
||||||
|
}: Readonly<ImageWithPlaceholderWhileLoadingProps>) {
|
||||||
const { classes } = useStyles({
|
const { classes } = useStyles({
|
||||||
placeholderWidth,
|
placeholderWidth,
|
||||||
placeholderHeight,
|
placeholderHeight,
|
||||||
@@ -68,7 +70,11 @@ export function ImageWithPlaceholderWhileLoading({
|
|||||||
width={width}
|
width={width}
|
||||||
height={height}
|
height={height}
|
||||||
onLoad={() => setLoading(false)}
|
onLoad={() => setLoading(false)}
|
||||||
style={{ display: loading ? "none" : "block" }}
|
style={{
|
||||||
|
...style,
|
||||||
|
display: loading ? "none" : (style?.display ?? "initial"),
|
||||||
|
height: style?.width ? "auto" : style?.height,
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { Trans } from "@lingui/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>
|
||||||
@@ -150,9 +170,7 @@ export function KeyboardShortcutsHelp() {
|
|||||||
<Trans>Navigate to a subscription by entering its name</Trans>
|
<Trans>Navigate to a subscription by entering its name</Trans>
|
||||||
</Table.Td>
|
</Table.Td>
|
||||||
<Table.Td>
|
<Table.Td>
|
||||||
<Kbd>
|
<Kbd>{isMacOS ? <Trans>Cmd</Trans> : <Trans>Ctrl</Trans>}</Kbd>
|
||||||
<Trans>{isMacOS ? "Cmd" : "Ctrl"}</Trans>
|
|
||||||
</Kbd>
|
|
||||||
<span> + </span>
|
<span> + </span>
|
||||||
<Kbd>K</Kbd>
|
<Kbd>K</Kbd>
|
||||||
<span>, </span>
|
<span>, </span>
|
||||||
|
|||||||
@@ -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,83 @@
|
|||||||
|
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 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,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
|
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,19 @@
|
|||||||
|
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,15 +1,15 @@
|
|||||||
import { Trans } from "@lingui/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 { useEffect, useState } from "react"
|
import { Constants } from "@/app/constants"
|
||||||
|
import { useNow } from "@/hooks/useNow"
|
||||||
|
|
||||||
export function RelativeDate(props: { date: Date | number | undefined }) {
|
export function RelativeDate(
|
||||||
const [now, setNow] = useState(new Date())
|
props: Readonly<{
|
||||||
useEffect(() => {
|
date: Date | number | undefined
|
||||||
const interval = setInterval(() => setNow(new Date()), 60 * 1000)
|
}>
|
||||||
return () => clearInterval(interval)
|
) {
|
||||||
}, [])
|
const now = useNow(60 * 1000)
|
||||||
|
|
||||||
if (!props.date) return <Trans>N/A</Trans>
|
if (!props.date) return <Trans>N/A</Trans>
|
||||||
const date = dayjs(props.date)
|
const date = dayjs(props.date)
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
import { Trans } from "@lingui/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: "",
|
||||||
|
|||||||
@@ -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,6 +1,6 @@
|
|||||||
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 = {
|
||||||
@@ -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,11 +1,24 @@
|
|||||||
import { TypographyStylesProvider } from "@mantine/core"
|
import { Typography } from "@mantine/core"
|
||||||
import type { ReactNode } from "react"
|
import type { ReactNode } from "react"
|
||||||
|
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.
|
||||||
*
|
*
|
||||||
* see https://mantine.dev/core/typography-styles-provider/
|
* see https://mantine.dev/core/typography-styles-provider/
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
const useStyles = tss.create(() => ({
|
||||||
|
// override mantine default typography styles
|
||||||
|
content: {
|
||||||
|
paddingLeft: 0,
|
||||||
|
"& img": {
|
||||||
|
marginBottom: 0,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
|
||||||
export const BasicHtmlStyles = (props: { children: ReactNode }) => {
|
export const BasicHtmlStyles = (props: { children: ReactNode }) => {
|
||||||
return <TypographyStylesProvider pl={0}>{props.children}</TypographyStylesProvider>
|
const { classes } = useStyles()
|
||||||
|
return <Typography className={classes.content}>{props.children}</Typography>
|
||||||
}
|
}
|
||||||
|
|||||||
26
commafeed-client/src/components/content/Content.test.tsx
Normal file
26
commafeed-client/src/components/content/Content.test.tsx
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
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,12 +1,13 @@
|
|||||||
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 { tss } from "tss"
|
import styleToObject from "style-to-object"
|
||||||
|
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
|
||||||
@@ -42,6 +43,7 @@ const transform: TransformCallback = node => {
|
|||||||
const nodeHeight = node.getAttribute("height")
|
const nodeHeight = node.getAttribute("height")
|
||||||
const width = nodeWidth ? Number.parseInt(nodeWidth, 10) : undefined
|
const width = nodeWidth ? Number.parseInt(nodeWidth, 10) : undefined
|
||||||
const height = nodeHeight ? Number.parseInt(nodeHeight, 10) : undefined
|
const height = nodeHeight ? Number.parseInt(nodeHeight, 10) : undefined
|
||||||
|
const style = styleToObject(node.getAttribute("style") ?? "") ?? undefined
|
||||||
const placeholderSize = calculatePlaceholderSize({
|
const placeholderSize = calculatePlaceholderSize({
|
||||||
width,
|
width,
|
||||||
height,
|
height,
|
||||||
@@ -55,6 +57,7 @@ const transform: TransformCallback = node => {
|
|||||||
title={title}
|
title={title}
|
||||||
width={width}
|
width={width}
|
||||||
height="auto"
|
height="auto"
|
||||||
|
style={style}
|
||||||
placeholderWidth={placeholderSize.width}
|
placeholderWidth={placeholderSize.width}
|
||||||
placeholderHeight={placeholderSize.height}
|
placeholderHeight={placeholderSize.height}
|
||||||
/>
|
/>
|
||||||
@@ -64,20 +67,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 {
|
||||||
@@ -85,6 +87,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()
|
||||||
@@ -93,7 +98,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")
|
||||||
@@ -19,7 +21,7 @@ export function Enclosure(props: {
|
|||||||
)}
|
)}
|
||||||
{hasAudio && (
|
{hasAudio && (
|
||||||
// biome-ignore lint/a11y/useMediaCaption: we don't have any captions for audio
|
// biome-ignore lint/a11y/useMediaCaption: we don't have any captions for audio
|
||||||
<audio controls>
|
<audio controls style={{ width: "100%" }}>
|
||||||
<source src={props.enclosureUrl} type={props.enclosureType} />
|
<source src={props.enclosureUrl} type={props.enclosureType} />
|
||||||
</audio>
|
</audio>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,36 +1,34 @@
|
|||||||
import { Trans } from "@lingui/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 { useViewMode } from "hooks/useViewMode"
|
import { useMousetrap } from "@/hooks/useMousetrap"
|
||||||
import { useEffect } from "react"
|
|
||||||
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)
|
||||||
@@ -38,7 +36,7 @@ export function FeedEntries() {
|
|||||||
const scrollingToEntry = useAppSelector(state => state.entries.scrollingToEntry)
|
const scrollingToEntry = useAppSelector(state => state.entries.scrollingToEntry)
|
||||||
const sidebarVisible = useAppSelector(state => state.tree.sidebarVisible)
|
const sidebarVisible = useAppSelector(state => state.tree.sidebarVisible)
|
||||||
const customContextMenu = useAppSelector(state => state.user.settings?.customContextMenu)
|
const customContextMenu = useAppSelector(state => state.user.settings?.customContextMenu)
|
||||||
const { viewMode } = useViewMode()
|
const viewMode = useAppSelector(state => state.user.localSettings.viewMode)
|
||||||
const dispatch = useAppDispatch()
|
const dispatch = useAppDispatch()
|
||||||
const { openLinkInBackgroundTab } = useBrowserExtension()
|
const { openLinkInBackgroundTab } = useBrowserExtension()
|
||||||
|
|
||||||
@@ -173,6 +171,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) {
|
||||||
@@ -273,17 +273,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()))
|
||||||
@@ -297,32 +287,25 @@ 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 => (
|
||||||
<div
|
<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}
|
||||||
>
|
showSelectionIndicator={entry.id === selectedEntryId && (!entry.expanded || viewMode === "expanded")}
|
||||||
<FeedEntry
|
maxWidth={sidebarVisible ? Constants.layout.entryMaxWidth : undefined}
|
||||||
entry={entry}
|
onHeaderClick={event => headerClicked(entry, event)}
|
||||||
expanded={!!entry.expanded || viewMode === "expanded"}
|
onHeaderRightClick={event => headerRightClicked(entry, event)}
|
||||||
selected={entry.id === selectedEntryId}
|
onBodyClick={() => bodyClicked(entry)}
|
||||||
showSelectionIndicator={entry.id === selectedEntryId && (!entry.expanded || viewMode === "expanded")}
|
onSwipedLeft={async () => await swipedLeft(entry)}
|
||||||
maxWidth={sidebarVisible ? Constants.layout.entryMaxWidth : undefined}
|
/>
|
||||||
onHeaderClick={event => headerClicked(entry, event)}
|
|
||||||
onHeaderRightClick={event => headerRightClicked(entry, event)}
|
|
||||||
onBodyClick={() => bodyClicked(entry)}
|
|
||||||
onSwipedLeft={async () => await swipedLeft(entry)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
))}
|
))}
|
||||||
</InfiniteScroll>
|
</InfiniteScroll>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,14 +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 { useViewMode } from "hooks/useViewMode"
|
|
||||||
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"
|
||||||
@@ -33,8 +32,9 @@ 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(({ theme, colorScheme, read, expanded, viewMode, rtl, showSelectionIndicator, maxWidth, fontSizePercentage }) => {
|
||||||
let backgroundColor: string
|
let backgroundColor: string
|
||||||
if (colorScheme === "dark") {
|
if (colorScheme === "dark") {
|
||||||
backgroundColor = read ? "inherit" : theme.colors.dark[5]
|
backgroundColor = read ? "inherit" : theme.colors.dark[5]
|
||||||
@@ -84,18 +84,21 @@ const useStyles = tss
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
headerLink: {
|
headerLink: {
|
||||||
|
fontSize: `${fontSizePercentage}%`,
|
||||||
color: "inherit",
|
color: "inherit",
|
||||||
textDecoration: "none",
|
textDecoration: "none",
|
||||||
},
|
},
|
||||||
body: {
|
body: {
|
||||||
|
fontSize: `${fontSizePercentage}%`,
|
||||||
direction: rtl ? "rtl" : "ltr",
|
direction: rtl ? "rtl" : "ltr",
|
||||||
maxWidth: maxWidth ?? "100%",
|
maxWidth: maxWidth ?? "100%",
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
export function FeedEntry(props: FeedEntryProps) {
|
export function FeedEntry(props: Readonly<FeedEntryProps>) {
|
||||||
const { viewMode } = useViewMode()
|
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,
|
||||||
@@ -103,6 +106,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)
|
||||||
@@ -138,6 +142,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, {
|
||||||
@@ -177,10 +185,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/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, TbEyeCheck, TbEyeOff, 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)
|
||||||
@@ -61,19 +61,21 @@ 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 onClick={async () => await dispatch(starEntry({ entry: props.entry, starred: !props.entry.starred }))}>
|
||||||
{props.entry.read ? <TbEyeOff size={iconSize} /> : <TbEyeCheck size={iconSize} />}
|
<Group>
|
||||||
{props.entry.read ? <Trans>Keep unread</Trans> : <Trans>Mark as read</Trans>}
|
{props.entry.starred ? <TbStarOff size={iconSize} /> : <TbStar size={iconSize} />}
|
||||||
</Group>
|
{props.entry.starred ? <Trans>Unstar</Trans> : <Trans>Star</Trans>}
|
||||||
</Item>
|
</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,23 +1,25 @@
|
|||||||
import { Trans, t } from "@lingui/macro"
|
import { msg } from "@lingui/core/macro"
|
||||||
|
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 { TbArrowBarToDown, TbExternalLink, TbMail, TbMailOpened, TbShare, TbStar, TbStarOff, TbTag } from "react-icons/tb"
|
||||||
import { useAppDispatch, useAppSelector } from "app/store"
|
import { markEntriesUpToEntry, markEntry, starEntry, tagEntry } from "@/app/entries/thunks"
|
||||||
import type { Entry } from "app/types"
|
import { useAppDispatch, useAppSelector } from "@/app/store"
|
||||||
import { ActionButton } from "components/ActionButton"
|
import type { Entry } from "@/app/types"
|
||||||
import { useActionButton } from "hooks/useActionButton"
|
import { ActionButton } from "@/components/ActionButton"
|
||||||
import { useMobile } from "hooks/useMobile"
|
import { useActionButton } from "@/hooks/useActionButton"
|
||||||
import { TbArrowBarToDown, TbExternalLink, TbEyeCheck, TbEyeOff, TbShare, TbStar, TbStarOff, TbTag } from "react-icons/tb"
|
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()
|
||||||
const dispatch = useAppDispatch()
|
const dispatch = useAppDispatch()
|
||||||
|
const { _ } = useLingui()
|
||||||
|
|
||||||
const readStatusButtonClicked = async () =>
|
const readStatusButtonClicked = async () =>
|
||||||
await dispatch(
|
await dispatch(
|
||||||
@@ -35,18 +37,18 @@ 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
|
||||||
icon={props.entry.read ? <TbEyeOff size={18} /> : <TbEyeCheck size={18} />}
|
icon={props.entry.read ? <TbMail size={18} /> : <TbMailOpened size={18} />}
|
||||||
label={props.entry.read ? <Trans>Keep unread</Trans> : <Trans>Mark as read</Trans>}
|
label={props.entry.read ? msg`Keep unread` : msg`Mark as read`}
|
||||||
onClick={readStatusButtonClicked}
|
onClick={readStatusButtonClicked}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<ActionButton
|
<ActionButton
|
||||||
icon={props.entry.starred ? <TbStarOff size={18} /> : <TbStar size={18} />}
|
icon={props.entry.starred ? <TbStarOff size={18} /> : <TbStar size={18} />}
|
||||||
label={props.entry.starred ? <Trans>Unstar</Trans> : <Trans>Star</Trans>}
|
label={props.entry.starred ? msg`Unstar` : msg`Star`}
|
||||||
onClick={async () =>
|
onClick={async () =>
|
||||||
await dispatch(
|
await dispatch(
|
||||||
starEntry({
|
starEntry({
|
||||||
@@ -59,7 +61,7 @@ export function FeedEntryFooter(props: FeedEntryFooterProps) {
|
|||||||
|
|
||||||
<Popover withArrow withinPortal shadow="md" closeOnClickOutside={!mobile}>
|
<Popover withArrow withinPortal shadow="md" closeOnClickOutside={!mobile}>
|
||||||
<Popover.Target>
|
<Popover.Target>
|
||||||
<ActionButton icon={<TbShare size={18} />} label={<Trans>Share</Trans>} />
|
<ActionButton icon={<TbShare size={18} />} label={msg`Share`} />
|
||||||
</Popover.Target>
|
</Popover.Target>
|
||||||
<Popover.Dropdown>
|
<Popover.Dropdown>
|
||||||
<ShareButtons url={props.entry.url} description={props.entry.title} />
|
<ShareButtons url={props.entry.url} description={props.entry.title} />
|
||||||
@@ -70,12 +72,12 @@ export function FeedEntryFooter(props: FeedEntryFooterProps) {
|
|||||||
<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={<Trans>Tags</Trans>} />
|
<ActionButton icon={<TbTag size={18} />} label={msg`Tags`} />
|
||||||
</Indicator>
|
</Indicator>
|
||||||
</Popover.Target>
|
</Popover.Target>
|
||||||
<Popover.Dropdown>
|
<Popover.Dropdown>
|
||||||
<TagsInput
|
<TagsInput
|
||||||
placeholder={t`Tags`}
|
placeholder={_(msg`Tags`)}
|
||||||
data={tags}
|
data={tags}
|
||||||
value={props.entry.tags}
|
value={props.entry.tags}
|
||||||
onChange={onTagsChange}
|
onChange={onTagsChange}
|
||||||
@@ -88,13 +90,13 @@ export function FeedEntryFooter(props: FeedEntryFooterProps) {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<a href={props.entry.url} target="_blank" rel="noreferrer">
|
<a href={props.entry.url} target="_blank" rel="noreferrer">
|
||||||
<ActionButton icon={<TbExternalLink size={18} />} label={<Trans>Open link</Trans>} />
|
<ActionButton icon={<TbExternalLink size={18} />} label={msg`Open link`} />
|
||||||
</a>
|
</a>
|
||||||
</Group>
|
</Group>
|
||||||
|
|
||||||
<ActionButton
|
<ActionButton
|
||||||
icon={<TbArrowBarToDown size={18} />}
|
icon={<TbArrowBarToDown size={18} />}
|
||||||
label={<Trans>Mark as read up to here</Trans>}
|
label={msg`Mark as read up to here`}
|
||||||
onClick={async () => await dispatch(markEntriesUpToEntry(props.entry))}
|
onClick={async () => await dispatch(markEntriesUpToEntry(props.entry))}
|
||||||
/>
|
/>
|
||||||
</Group>
|
</Group>
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
import { ImageWithPlaceholderWhileLoading } from "components/ImageWithPlaceholderWhileLoading"
|
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>) {
|
||||||
return (
|
return (
|
||||||
<ImageWithPlaceholderWhileLoading
|
<ImageWithPlaceholderWhileLoading
|
||||||
src={url}
|
src={url}
|
||||||
|
|||||||
@@ -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/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,7 +97,12 @@ 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)
|
||||||
|
|||||||
@@ -1,18 +1,21 @@
|
|||||||
import { Trans, t } from "@lingui/macro"
|
import { msg } from "@lingui/core/macro"
|
||||||
|
import { useLingui } from "@lingui/react"
|
||||||
|
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() {
|
||||||
const dispatch = useAppDispatch()
|
const dispatch = useAppDispatch()
|
||||||
|
const { _ } = useLingui()
|
||||||
|
|
||||||
const form = useForm<AddCategoryRequest>()
|
const form = useForm<AddCategoryRequest>()
|
||||||
|
|
||||||
@@ -33,7 +36,7 @@ export function AddCategory() {
|
|||||||
|
|
||||||
<form onSubmit={form.onSubmit(addCategory.execute)}>
|
<form onSubmit={form.onSubmit(addCategory.execute)}>
|
||||||
<Stack>
|
<Stack>
|
||||||
<TextInput label={<Trans>Category</Trans>} placeholder={t`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,10 +1,11 @@
|
|||||||
import { t } from "@lingui/macro"
|
import { msg } from "@lingui/core/macro"
|
||||||
|
import { useLingui } from "@lingui/react"
|
||||||
import { Select, type SelectProps } from "@mantine/core"
|
import { Select, type SelectProps } from "@mantine/core"
|
||||||
import type { ComboboxItem } from "@mantine/core/lib/components/Combobox/Combobox.types"
|
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
|
||||||
@@ -13,6 +14,8 @@ type CategorySelectProps = Partial<SelectProps> & {
|
|||||||
|
|
||||||
export function CategorySelect(props: CategorySelectProps) {
|
export function CategorySelect(props: CategorySelectProps) {
|
||||||
const rootCategory = useAppSelector(state => state.tree.rootCategory)
|
const rootCategory = useAppSelector(state => state.tree.rootCategory)
|
||||||
|
const { _ } = useLingui()
|
||||||
|
|
||||||
const categories = rootCategory && flattenCategoryTree(rootCategory)
|
const categories = rootCategory && flattenCategoryTree(rootCategory)
|
||||||
const categoriesById = categories?.reduce((map, c) => {
|
const categoriesById = categories?.reduce((map, c) => {
|
||||||
map.set(c.id, c)
|
map.set(c.id, c)
|
||||||
@@ -43,7 +46,7 @@ export function CategorySelect(props: CategorySelectProps) {
|
|||||||
.sort((c1, c2) => c1.label.localeCompare(c2.label))
|
.sort((c1, c2) => c1.label.localeCompare(c2.label))
|
||||||
if (props.withAll) {
|
if (props.withAll) {
|
||||||
selectData?.unshift({
|
selectData?.unshift({
|
||||||
label: t`All`,
|
label: _(msg`All`),
|
||||||
value: Constants.categories.all.id,
|
value: Constants.categories.all.id,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,20 +1,23 @@
|
|||||||
import { Trans, t } from "@lingui/macro"
|
import { msg } from "@lingui/core/macro"
|
||||||
|
import { useLingui } from "@lingui/react"
|
||||||
|
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()
|
||||||
|
const { _ } = useLingui()
|
||||||
|
|
||||||
const form = useForm<{ file: File }>({
|
const form = useForm<{ file: File }>({
|
||||||
validate: {
|
validate: {
|
||||||
file: isNotEmpty(t`OPML file is required`),
|
file: isNotEmpty(_(msg`OPML file is required`)),
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -38,7 +41,7 @@ export function ImportOpml() {
|
|||||||
<FileInput
|
<FileInput
|
||||||
label={<Trans>OPML file</Trans>}
|
label={<Trans>OPML file</Trans>}
|
||||||
leftSection={<TbFileImport />}
|
leftSection={<TbFileImport />}
|
||||||
placeholder={t`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 file by exporting your
|
||||||
|
|||||||
@@ -1,16 +1,16 @@
|
|||||||
import { Trans } from "@lingui/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() {
|
||||||
@@ -40,8 +40,7 @@ export function Subscribe() {
|
|||||||
})
|
})
|
||||||
const subscribe = useAsyncCallback(client.feed.subscribe, {
|
const subscribe = useAsyncCallback(client.feed.subscribe, {
|
||||||
onSuccess: sub => {
|
onSuccess: sub => {
|
||||||
dispatch(reloadTree())
|
dispatch(reloadTree()).then(() => dispatch(redirectToFeed(sub.data)))
|
||||||
dispatch(redirectToFeed(sub.data))
|
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,118 @@
|
|||||||
|
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,12 +1,12 @@
|
|||||||
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"
|
||||||
|
|
||||||
export interface FeedEntryTitleProps {
|
export interface FeedEntryTitleProps {
|
||||||
entry: Entry
|
entry: Entry
|
||||||
}
|
}
|
||||||
|
|
||||||
export function FeedEntryTitle(props: FeedEntryTitleProps) {
|
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(" ")
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -1,12 +1,16 @@
|
|||||||
import { Trans } from "@lingui/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/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()
|
||||||
|
|||||||
@@ -1,18 +1,12 @@
|
|||||||
import { Trans, t } from "@lingui/macro"
|
import { msg } from "@lingui/core/macro"
|
||||||
|
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 { 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 { useEffect } from "react"
|
||||||
import {
|
import {
|
||||||
TbArrowDown,
|
TbArrowDown,
|
||||||
TbArrowUp,
|
TbArrowUp,
|
||||||
|
TbChecks,
|
||||||
TbExternalLink,
|
TbExternalLink,
|
||||||
TbEye,
|
TbEye,
|
||||||
TbEyeOff,
|
TbEyeOff,
|
||||||
@@ -23,7 +17,14 @@ 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 { 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 { ProfileMenu } from "./ProfileMenu"
|
import { ProfileMenu } from "./ProfileMenu"
|
||||||
|
|
||||||
function HeaderDivider() {
|
function HeaderDivider() {
|
||||||
@@ -41,11 +42,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} className="cf-toolbar">
|
||||||
|
{props.children}
|
||||||
|
</Group>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -57,12 +61,9 @@ export function Header() {
|
|||||||
const searchFromStore = useAppSelector(state => state.entries.search)
|
const searchFromStore = useAppSelector(state => state.entries.search)
|
||||||
const { isBrowserExtensionPopup, openSettingsPage, openAppInNewTab } = useBrowserExtension()
|
const { isBrowserExtensionPopup, openSettingsPage, openAppInNewTab } = useBrowserExtension()
|
||||||
const dispatch = useAppDispatch()
|
const dispatch = useAppDispatch()
|
||||||
|
const { _ } = useLingui()
|
||||||
|
|
||||||
const searchForm = useForm<{ search: string }>({
|
const searchForm = useForm<{ search: string }>()
|
||||||
validate: {
|
|
||||||
search: value => (value.length > 0 && value.length < 3 ? t`Search requires at least 3 characters` : null),
|
|
||||||
},
|
|
||||||
})
|
|
||||||
const { setValues } = searchForm
|
const { setValues } = searchForm
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -73,11 +74,11 @@ export function Header() {
|
|||||||
|
|
||||||
if (!settings) return <Loader />
|
if (!settings) return <Loader />
|
||||||
return (
|
return (
|
||||||
<Center>
|
<Center className="cf-toolbar-wrapper">
|
||||||
<HeaderToolbar>
|
<HeaderToolbar>
|
||||||
<ActionButton
|
<ActionButton
|
||||||
icon={<TbArrowUp size={iconSize} />}
|
icon={<TbArrowUp size={iconSize} />}
|
||||||
label={<Trans>Previous</Trans>}
|
label={msg`Previous`}
|
||||||
onClick={async () =>
|
onClick={async () =>
|
||||||
await dispatch(
|
await dispatch(
|
||||||
selectPreviousEntry({
|
selectPreviousEntry({
|
||||||
@@ -90,7 +91,7 @@ export function Header() {
|
|||||||
/>
|
/>
|
||||||
<ActionButton
|
<ActionButton
|
||||||
icon={<TbArrowDown size={iconSize} />}
|
icon={<TbArrowDown size={iconSize} />}
|
||||||
label={<Trans>Next</Trans>}
|
label={msg`Next`}
|
||||||
onClick={async () =>
|
onClick={async () =>
|
||||||
await dispatch(
|
await dispatch(
|
||||||
selectNextEntry({
|
selectNextEntry({
|
||||||
@@ -106,34 +107,38 @@ export function Header() {
|
|||||||
|
|
||||||
<ActionButton
|
<ActionButton
|
||||||
icon={<TbRefresh size={iconSize} />}
|
icon={<TbRefresh size={iconSize} />}
|
||||||
label={<Trans>Refresh</Trans>}
|
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" ? <Trans>All</Trans> : <Trans>Unread</Trans>}
|
label={settings.readingMode === "all" ? msg`All` : msg`Unread`}
|
||||||
onClick={async () => await dispatch(changeReadingMode(settings.readingMode === "all" ? "unread" : "all"))}
|
onClick={async () => await dispatch(changeReadingMode(settings.readingMode === "all" ? "unread" : "all"))}
|
||||||
/>
|
/>
|
||||||
<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" ? <Trans>Asc</Trans> : <Trans>Desc</Trans>}
|
label={settings.readingOrder === "asc" ? msg`Asc` : msg`Desc`}
|
||||||
onClick={async () => await dispatch(changeReadingOrder(settings.readingOrder === "asc" ? "desc" : "asc"))}
|
onClick={async () => await dispatch(changeReadingOrder(settings.readingOrder === "asc" ? "desc" : "asc"))}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Popover>
|
<Popover>
|
||||||
<Popover.Target>
|
<Popover.Target>
|
||||||
<Indicator disabled={!searchFromStore}>
|
<Indicator disabled={!searchFromStore}>
|
||||||
<ActionButton icon={<TbSearch size={iconSize} />} label={<Trans>Search</Trans>} />
|
<ActionButton icon={<TbSearch size={iconSize} />} label={msg`Search`} />
|
||||||
</Indicator>
|
</Indicator>
|
||||||
</Popover.Target>
|
</Popover.Target>
|
||||||
<Popover.Dropdown>
|
<Popover.Dropdown>
|
||||||
<form onSubmit={searchForm.onSubmit(async values => await dispatch(search(values.search)))}>
|
<form onSubmit={searchForm.onSubmit(async values => await dispatch(search(values.search)))}>
|
||||||
<TextInput
|
<TextInput
|
||||||
placeholder={t`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("")))} />}
|
||||||
@@ -153,12 +158,12 @@ export function Header() {
|
|||||||
|
|
||||||
<ActionButton
|
<ActionButton
|
||||||
icon={<TbSettings size={iconSize} />}
|
icon={<TbSettings size={iconSize} />}
|
||||||
label={<Trans>Extension options</Trans>}
|
label={msg`Extension options`}
|
||||||
onClick={() => openSettingsPage()}
|
onClick={() => openSettingsPage()}
|
||||||
/>
|
/>
|
||||||
<ActionButton
|
<ActionButton
|
||||||
icon={<TbExternalLink size={iconSize} />}
|
icon={<TbExternalLink size={iconSize} />}
|
||||||
label={<Trans>Open CommaFeed</Trans>}
|
label={msg`Open CommaFeed`}
|
||||||
onClick={() => openAppInNewTab()}
|
onClick={() => openAppInNewTab()}
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -1,97 +0,0 @@
|
|||||||
import { Trans } from "@lingui/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={<Trans>Mark all as read</Trans>} onClick={buttonClicked} />
|
|
||||||
</>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Trans } from "@lingui/macro"
|
import { Trans } from "@lingui/react/macro"
|
||||||
import {
|
import {
|
||||||
Box,
|
Box,
|
||||||
Divider,
|
Divider,
|
||||||
@@ -7,15 +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 dayjs from "dayjs"
|
||||||
import { redirectToAbout, redirectToAdminUsers, redirectToDonate, redirectToMetrics, redirectToSettings } from "app/redirect/thunks"
|
import { type ReactNode, useEffect, useState } from "react"
|
||||||
import { useAppDispatch, useAppSelector } from "app/store"
|
|
||||||
import type { ViewMode } from "app/types"
|
|
||||||
import { useViewMode } from "hooks/useViewMode"
|
|
||||||
import { type ReactNode, useState } from "react"
|
|
||||||
import {
|
import {
|
||||||
TbChartLine,
|
TbChartLine,
|
||||||
TbHeartFilled,
|
TbHeartFilled,
|
||||||
@@ -32,6 +29,14 @@ 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
|
||||||
@@ -90,14 +95,30 @@ const viewModeData: ViewModeControlItem[] = [
|
|||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
export function ProfileMenu(props: ProfileMenuProps) {
|
export function ProfileMenu(props: Readonly<ProfileMenuProps>) {
|
||||||
const [opened, setOpened] = useState(false)
|
const [opened, setOpened] = useState(false)
|
||||||
const { viewMode, setViewMode } = useViewMode()
|
|
||||||
|
// 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 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 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()
|
||||||
|
|
||||||
|
const nextAvailableForceRefresh = profile?.lastForceRefresh
|
||||||
|
? profile.lastForceRefresh + (forceRefreshCooldownDuration ?? 0)
|
||||||
|
: now.getTime()
|
||||||
|
const forceRefreshEnabled = nextAvailableForceRefresh <= now.getTime()
|
||||||
|
|
||||||
const logout = () => {
|
const logout = () => {
|
||||||
window.location.href = "logout"
|
window.location.href = "logout"
|
||||||
}
|
}
|
||||||
@@ -118,18 +139,32 @@ export function ProfileMenu(props: ProfileMenuProps) {
|
|||||||
</Menu.Item>
|
</Menu.Item>
|
||||||
<Menu.Item
|
<Menu.Item
|
||||||
leftSection={<TbWorldDownload size={iconSize} />}
|
leftSection={<TbWorldDownload size={iconSize} />}
|
||||||
onClick={async () =>
|
disabled={!forceRefreshEnabled}
|
||||||
await client.feed.refreshAll().then(() => {
|
onClick={async () => {
|
||||||
|
setOpened(false)
|
||||||
|
|
||||||
|
try {
|
||||||
|
await client.feed.refreshAll()
|
||||||
|
|
||||||
|
// reload profile to update last force refresh timestamp
|
||||||
|
await dispatch(reloadProfile())
|
||||||
|
|
||||||
showNotification({
|
showNotification({
|
||||||
message: <Trans>Your feeds have been queued for refresh.</Trans>,
|
message: <Trans>Your feeds have been queued for refresh.</Trans>,
|
||||||
color: "green",
|
color: "green",
|
||||||
autoClose: 1000,
|
autoClose: 1000,
|
||||||
})
|
})
|
||||||
setOpened(false)
|
} catch {
|
||||||
})
|
showNotification({
|
||||||
}
|
message: <Trans>Force fetching feeds is not yet available.</Trans>,
|
||||||
|
color: "red",
|
||||||
|
autoClose: 2000,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<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>}
|
||||||
</Menu.Item>
|
</Menu.Item>
|
||||||
|
|
||||||
<Divider />
|
<Divider />
|
||||||
@@ -156,10 +191,26 @@ export function ProfileMenu(props: ProfileMenuProps) {
|
|||||||
orientation="vertical"
|
orientation="vertical"
|
||||||
data={viewModeData}
|
data={viewModeData}
|
||||||
value={viewMode}
|
value={viewMode}
|
||||||
onChange={e => setViewMode(e as ViewMode)}
|
onChange={e => dispatch(setViewMode(e as ViewMode))}
|
||||||
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,9 +1,10 @@
|
|||||||
import type { MetricGauge } from "app/types"
|
import { NumberFormatter } from "@mantine/core"
|
||||||
|
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 <span>{props.gauge.value}</span>
|
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/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")}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1,24 +1,34 @@
|
|||||||
import { Trans, t } from "@lingui/macro"
|
import { msg } from "@lingui/core/macro"
|
||||||
import { Divider, Group, Radio, Select, SimpleGrid, Stack, Switch } from "@mantine/core"
|
import { useLingui } from "@lingui/react"
|
||||||
|
import { Trans } from "@lingui/react/macro"
|
||||||
|
import { Box, Divider, Group, NumberInput, Radio, Select, type SelectProps, SimpleGrid, Stack, Switch } from "@mantine/core"
|
||||||
import type { ComboboxData } from "@mantine/core/lib/components/Combobox/Combobox.types"
|
import type { ComboboxData } from "@mantine/core/lib/components/Combobox/Combobox.types"
|
||||||
import { Constants } from "app/constants"
|
import type { ReactNode } from "react"
|
||||||
import { useAppDispatch, useAppSelector } from "app/store"
|
import { Constants } from "@/app/constants"
|
||||||
import type { IconDisplayMode, ScrollMode, SharingSettings } from "app/types"
|
import { useAppDispatch, useAppSelector } from "@/app/store"
|
||||||
|
import type { IconDisplayMode, ScrollMode, SharingSettings } from "@/app/types"
|
||||||
import {
|
import {
|
||||||
changeCustomContextMenu,
|
changeCustomContextMenu,
|
||||||
|
changeDisableMobileSwipe,
|
||||||
|
changeDisablePullToRefresh,
|
||||||
|
changeEntriesToKeepOnTopWhenScrolling,
|
||||||
changeExternalLinkIconDisplayMode,
|
changeExternalLinkIconDisplayMode,
|
||||||
|
changeInfrequentThresholdDays,
|
||||||
changeLanguage,
|
changeLanguage,
|
||||||
changeMarkAllAsReadConfirmation,
|
changeMarkAllAsReadConfirmation,
|
||||||
|
changeMarkAllAsReadNavigateToUnread,
|
||||||
changeMobileFooter,
|
changeMobileFooter,
|
||||||
|
changePrimaryColor,
|
||||||
changeScrollMarks,
|
changeScrollMarks,
|
||||||
changeScrollMode,
|
changeScrollMode,
|
||||||
changeScrollSpeed,
|
changeScrollSpeed,
|
||||||
changeSharingSetting,
|
changeSharingSetting,
|
||||||
changeShowRead,
|
changeShowRead,
|
||||||
changeStarIconDisplayMode,
|
changeStarIconDisplayMode,
|
||||||
} from "app/user/thunks"
|
changeUnreadCountFavicon,
|
||||||
import { locales } from "i18n"
|
changeUnreadCountTitle,
|
||||||
import type { ReactNode } from "react"
|
} from "@/app/user/thunks"
|
||||||
|
import { locales } from "@/i18n"
|
||||||
|
|
||||||
export function DisplaySettings() {
|
export function DisplaySettings() {
|
||||||
const language = useAppSelector(state => state.user.settings?.language)
|
const language = useAppSelector(state => state.user.settings?.language)
|
||||||
@@ -26,12 +36,21 @@ export function DisplaySettings() {
|
|||||||
const showRead = useAppSelector(state => state.user.settings?.showRead)
|
const showRead = useAppSelector(state => state.user.settings?.showRead)
|
||||||
const scrollMarks = useAppSelector(state => state.user.settings?.scrollMarks)
|
const scrollMarks = useAppSelector(state => state.user.settings?.scrollMarks)
|
||||||
const scrollMode = useAppSelector(state => state.user.settings?.scrollMode)
|
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 starIconDisplayMode = useAppSelector(state => state.user.settings?.starIconDisplayMode)
|
||||||
const externalLinkIconDisplayMode = useAppSelector(state => state.user.settings?.externalLinkIconDisplayMode)
|
const externalLinkIconDisplayMode = useAppSelector(state => state.user.settings?.externalLinkIconDisplayMode)
|
||||||
const markAllAsReadConfirmation = useAppSelector(state => state.user.settings?.markAllAsReadConfirmation)
|
const markAllAsReadConfirmation = useAppSelector(state => state.user.settings?.markAllAsReadConfirmation)
|
||||||
|
const markAllAsReadNavigateToNextUnread = useAppSelector(state => state.user.settings?.markAllAsReadNavigateToNextUnread)
|
||||||
const customContextMenu = useAppSelector(state => state.user.settings?.customContextMenu)
|
const customContextMenu = useAppSelector(state => state.user.settings?.customContextMenu)
|
||||||
const mobileFooter = useAppSelector(state => state.user.settings?.mobileFooter)
|
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 disablePullToRefresh = useAppSelector(state => state.user.settings?.disablePullToRefresh)
|
||||||
|
const disableMobileSwipe = useAppSelector(state => state.user.settings?.disableMobileSwipe)
|
||||||
|
const infrequentThresholdDays = useAppSelector(state => state.user.settings?.infrequentThresholdDays)
|
||||||
const sharingSettings = useAppSelector(state => state.user.settings?.sharingSettings)
|
const sharingSettings = useAppSelector(state => state.user.settings?.sharingSettings)
|
||||||
|
const primaryColor = useAppSelector(state => state.user.settings?.primaryColor) || Constants.theme.defaultPrimaryColor
|
||||||
|
const { _ } = useLingui()
|
||||||
const dispatch = useAppDispatch()
|
const dispatch = useAppDispatch()
|
||||||
|
|
||||||
const scrollModeOptions: Record<ScrollMode, ReactNode> = {
|
const scrollModeOptions: Record<ScrollMode, ReactNode> = {
|
||||||
@@ -43,26 +62,51 @@ export function DisplaySettings() {
|
|||||||
const displayModeData: ComboboxData = [
|
const displayModeData: ComboboxData = [
|
||||||
{
|
{
|
||||||
value: "always",
|
value: "always",
|
||||||
label: t`Always`,
|
label: _(msg`Always`),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
value: "on_desktop",
|
value: "on_desktop",
|
||||||
label: t`On desktop`,
|
label: _(msg`On desktop`),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
value: "on_mobile",
|
value: "on_mobile",
|
||||||
label: t`On mobile`,
|
label: _(msg`On mobile`),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
value: "never",
|
value: "never",
|
||||||
label: t`Never`,
|
label: _(msg`Never`),
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
|
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>
|
||||||
|
)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Stack>
|
<Stack>
|
||||||
|
<Divider label={<Trans>Display</Trans>} labelPosition="center" />
|
||||||
|
|
||||||
<Select
|
<Select
|
||||||
description={<Trans>Language</Trans>}
|
label={<Trans>Language</Trans>}
|
||||||
value={language}
|
value={language}
|
||||||
data={locales.map(l => ({
|
data={locales.map(l => ({
|
||||||
value: l.key,
|
value: l.key,
|
||||||
@@ -71,6 +115,14 @@ export function DisplaySettings() {
|
|||||||
onChange={async s => await (s && dispatch(changeLanguage(s)))}
|
onChange={async s => await (s && dispatch(changeLanguage(s)))}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<Select
|
||||||
|
label={<Trans>Primary color</Trans>}
|
||||||
|
data={colorData}
|
||||||
|
value={primaryColor}
|
||||||
|
onChange={async value => value && (await dispatch(changePrimaryColor(value)))}
|
||||||
|
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={showRead}
|
||||||
@@ -83,36 +135,41 @@ export function DisplaySettings() {
|
|||||||
onChange={async e => await dispatch(changeMarkAllAsReadConfirmation(e.currentTarget.checked))}
|
onChange={async e => await dispatch(changeMarkAllAsReadConfirmation(e.currentTarget.checked))}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<Switch
|
||||||
|
label={<Trans>Navigate to the next category/feed with unread entries when marking all entries as read</Trans>}
|
||||||
|
checked={markAllAsReadNavigateToNextUnread}
|
||||||
|
onChange={async e => await dispatch(changeMarkAllAsReadNavigateToUnread(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={mobileFooter}
|
||||||
onChange={async e => await dispatch(changeMobileFooter(e.currentTarget.checked))}
|
onChange={async e => await dispatch(changeMobileFooter(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
|
<Switch
|
||||||
label={<Trans>Show CommaFeed's own context menu on right click</Trans>}
|
label={<Trans>On mobile, disable swipe gesture to open the menu</Trans>}
|
||||||
checked={customContextMenu}
|
checked={disableMobileSwipe}
|
||||||
onChange={async e => await dispatch(changeCustomContextMenu(e.currentTarget.checked))}
|
onChange={async e => await dispatch(changeDisableMobileSwipe(e.currentTarget.checked))}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<NumberInput
|
||||||
|
label={<Trans>Infrequent posts threshold (days)</Trans>}
|
||||||
|
description={<Trans>Feeds posting less often than this (on average) will appear in the Infrequent view</Trans>}
|
||||||
|
min={1}
|
||||||
|
value={infrequentThresholdDays}
|
||||||
|
onChange={async value => await dispatch(changeInfrequentThresholdDays(+value))}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<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={disablePullToRefresh}
|
||||||
|
onChange={async e => await dispatch(changeDisablePullToRefresh(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={scrollMode}
|
||||||
@@ -125,6 +182,14 @@ export function DisplaySettings() {
|
|||||||
</Group>
|
</Group>
|
||||||
</Radio.Group>
|
</Radio.Group>
|
||||||
|
|
||||||
|
<NumberInput
|
||||||
|
label={<Trans>Entries to keep above the selected entry when scrolling</Trans>}
|
||||||
|
description={<Trans>Only applies to compact, cozy and detailed modes</Trans>}
|
||||||
|
min={0}
|
||||||
|
value={entriesToKeepOnTop}
|
||||||
|
onChange={async value => await dispatch(changeEntriesToKeepOnTopWhenScrolling(+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={scrollSpeed ? scrollSpeed > 0 : false}
|
||||||
@@ -137,6 +202,42 @@ export function DisplaySettings() {
|
|||||||
onChange={async e => await dispatch(changeScrollMarks(e.currentTarget.checked))}
|
onChange={async e => await dispatch(changeScrollMarks(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
|
||||||
|
label={<Trans>Show star icon</Trans>}
|
||||||
|
value={starIconDisplayMode}
|
||||||
|
data={displayModeData}
|
||||||
|
onChange={async s => await dispatch(changeStarIconDisplayMode(s as IconDisplayMode))}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Select
|
||||||
|
label={<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>Sharing sites</Trans>} labelPosition="center" />
|
<Divider label={<Trans>Sharing sites</Trans>} labelPosition="center" />
|
||||||
|
|
||||||
<SimpleGrid cols={2}>
|
<SimpleGrid cols={2}>
|
||||||
|
|||||||
@@ -1,16 +1,18 @@
|
|||||||
import { Trans, t } from "@lingui/macro"
|
import { useLingui } from "@lingui/react"
|
||||||
|
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
|
||||||
@@ -18,12 +20,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 validationRules = useValidationRules()
|
||||||
|
|
||||||
const form = useForm<FormData>({
|
const form = useForm<FormData>({
|
||||||
validate: {
|
validate: {
|
||||||
newPasswordConfirmation: (value, values) => (value !== values.newPassword ? t`Passwords do not match` : null),
|
newPassword: validationRules.password,
|
||||||
|
newPasswordConfirmation: (value, values) => validationRules.passwordConfirmation(value, values.newPassword),
|
||||||
},
|
},
|
||||||
|
validateInputOnChange: true,
|
||||||
})
|
})
|
||||||
const { setValues } = form
|
const { setValues } = form
|
||||||
|
|
||||||
@@ -49,7 +56,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(() => {
|
||||||
@@ -129,7 +138,12 @@ 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>}
|
||||||
|
|||||||
@@ -0,0 +1,124 @@
|
|||||||
|
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 { changePushNotificationSettings } 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(changePushNotificationSettings(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/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, TbClock, TbInbox, TbStar, TbTag } from "react-icons/tb"
|
||||||
|
import { Constants } from "@/app/constants"
|
||||||
import {
|
import {
|
||||||
redirectToCategory,
|
redirectToCategory,
|
||||||
redirectToCategoryDetails,
|
redirectToCategoryDetails,
|
||||||
@@ -8,20 +10,20 @@ 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"
|
||||||
|
|
||||||
const allIcon = <TbInbox size={16} />
|
const allIcon = <TbInbox size={16} />
|
||||||
const starredIcon = <TbStar size={16} />
|
const starredIcon = <TbStar size={16} />
|
||||||
|
const infrequentIcon = <TbClock size={16} />
|
||||||
const tagIcon = <TbTag size={16} />
|
const tagIcon = <TbTag size={16} />
|
||||||
const expandedIcon = <TbChevronDown size={16} />
|
const expandedIcon = <TbChevronDown size={16} />
|
||||||
const collapsedIcon = <TbChevronRight size={16} />
|
const collapsedIcon = <TbChevronRight size={16} />
|
||||||
@@ -33,8 +35,27 @@ export function Tree() {
|
|||||||
const source = useAppSelector(state => state.entries.source)
|
const source = useAppSelector(state => state.entries.source)
|
||||||
const tags = useAppSelector(state => state.user.tags)
|
const tags = useAppSelector(state => state.user.tags)
|
||||||
const showRead = useAppSelector(state => state.user.settings?.showRead)
|
const showRead = useAppSelector(state => state.user.settings?.showRead)
|
||||||
|
const infrequentThresholdDays = useAppSelector(
|
||||||
|
state => state.user.settings?.infrequentThresholdDays ?? Constants.infrequentThresholdDaysDefault
|
||||||
|
)
|
||||||
|
const infrequentThresholdMs = infrequentThresholdDays * 24 * 3600 * 1000
|
||||||
const dispatch = useAppDispatch()
|
const dispatch = useAppDispatch()
|
||||||
|
|
||||||
|
const isFeedDisplayed = (feed: Subscription) => {
|
||||||
|
const isCurrentFeed = source.type === "feed" && source.id === String(feed.id)
|
||||||
|
return isCurrentFeed || feed.unread > 0 || showRead
|
||||||
|
}
|
||||||
|
|
||||||
|
const isCategoryDisplayed = (category: Category): boolean => {
|
||||||
|
const isCurrentCategory = source.type === "category" && source.id === category.id
|
||||||
|
return (
|
||||||
|
isCurrentCategory ||
|
||||||
|
showRead ||
|
||||||
|
category.children.some(c => isCategoryDisplayed(c)) ||
|
||||||
|
category.feeds.some(f => isFeedDisplayed(f))
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
const feedClicked = (e: React.MouseEvent, id: string) => {
|
const feedClicked = (e: React.MouseEvent, id: string) => {
|
||||||
if (e.detail === 2) {
|
if (e.detail === 2) {
|
||||||
dispatch(redirectToFeedDetails(id))
|
dispatch(redirectToFeedDetails(id))
|
||||||
@@ -70,45 +91,70 @@ export function Tree() {
|
|||||||
const allCategoryNode = () => (
|
const allCategoryNode = () => (
|
||||||
<TreeNode
|
<TreeNode
|
||||||
id={Constants.categories.all.id}
|
id={Constants.categories.all.id}
|
||||||
|
type="category"
|
||||||
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}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
const starredCategoryNode = () => (
|
const starredCategoryNode = () => (
|
||||||
<TreeNode
|
<TreeNode
|
||||||
id={Constants.categories.starred.id}
|
id={Constants.categories.starred.id}
|
||||||
|
type="category"
|
||||||
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}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
const infrequentCategoryNode = () => (
|
||||||
|
<TreeNode
|
||||||
|
id={Constants.categories.infrequent.id}
|
||||||
|
type="category"
|
||||||
|
name={<Trans>Infrequent</Trans>}
|
||||||
|
icon={infrequentIcon}
|
||||||
|
unread={categoryUnreadCount(root, infrequentThresholdMs)}
|
||||||
|
hasNewEntries={categoryHasNewEntries(root, infrequentThresholdMs)}
|
||||||
|
selected={source.type === "category" && source.id === Constants.categories.infrequent.id}
|
||||||
|
expanded={false}
|
||||||
|
level={0}
|
||||||
|
hasError={false}
|
||||||
|
hasWarning={false}
|
||||||
onClick={categoryClicked}
|
onClick={categoryClicked}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
|
|
||||||
const categoryNode = (category: Category, level = 0) => {
|
const categoryNode = (category: Category, level = 0) => {
|
||||||
const unreadCount = categoryUnreadCount(category)
|
if (!isCategoryDisplayed(category)) return null
|
||||||
if (unreadCount === 0 && !showRead) 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}
|
||||||
|
type="category"
|
||||||
name={category.name}
|
name={category.name}
|
||||||
icon={category.expanded ? expandedIcon : collapsedIcon}
|
icon={category.expanded ? expandedIcon : collapsedIcon}
|
||||||
unread={unreadCount}
|
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}
|
||||||
@@ -116,18 +162,21 @@ export function Tree() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const feedNode = (feed: Subscription, level = 0) => {
|
const feedNode = (feed: TreeSubscription, level = 0) => {
|
||||||
if (feed.unread === 0 && !showRead) return null
|
if (!isFeedDisplayed(feed)) return null
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<TreeNode
|
<TreeNode
|
||||||
id={String(feed.id)}
|
id={String(feed.id)}
|
||||||
|
type="feed"
|
||||||
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}
|
||||||
/>
|
/>
|
||||||
@@ -137,12 +186,15 @@ export function Tree() {
|
|||||||
const tagNode = (tag: string) => (
|
const tagNode = (tag: string) => (
|
||||||
<TreeNode
|
<TreeNode
|
||||||
id={tag}
|
id={tag}
|
||||||
|
type="tag"
|
||||||
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}
|
||||||
/>
|
/>
|
||||||
@@ -163,9 +215,10 @@ export function Tree() {
|
|||||||
<OnDesktop>
|
<OnDesktop>
|
||||||
<TreeSearch feeds={feeds} />
|
<TreeSearch feeds={feeds} />
|
||||||
</OnDesktop>
|
</OnDesktop>
|
||||||
<Box>
|
<Box className="cf-tree">
|
||||||
{allCategoryNode()}
|
{allCategoryNode()}
|
||||||
{starredCategoryNode()}
|
{starredCategoryNode()}
|
||||||
|
{infrequentCategoryNode()}
|
||||||
{root.children.map(c => recursiveCategoryNode(c))}
|
{root.children.map(c => recursiveCategoryNode(c))}
|
||||||
{root.feeds.map(f => feedNode(f))}
|
{root.feeds.map(f => feedNode(f))}
|
||||||
{tags?.map(tag => tagNode(tag))}
|
{tags?.map(tag => tagNode(tag))}
|
||||||
|
|||||||
@@ -1,11 +1,13 @@
|
|||||||
import { Box, Center } from "@mantine/core"
|
import { Box, Center } from "@mantine/core"
|
||||||
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 {
|
||||||
id: string
|
id: string
|
||||||
|
type: EntrySourceType
|
||||||
name: React.ReactNode
|
name: React.ReactNode
|
||||||
icon: React.ReactNode
|
icon: React.ReactNode
|
||||||
unread: number
|
unread: number
|
||||||
@@ -13,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
|
||||||
}
|
}
|
||||||
@@ -21,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 {
|
||||||
@@ -56,21 +63,30 @@ 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 py={1} pl={props.level * 20} className={classes.node} onClick={(e: React.MouseEvent) => props.onClick(e, props.id)}>
|
<Box
|
||||||
<Box mr={6} onClick={(e: React.MouseEvent) => props.onIconClick?.(e, props.id)}>
|
py={1}
|
||||||
|
pl={props.level * 20}
|
||||||
|
className={`${classes.node} cf-treenode cf-treenode-${props.type}`}
|
||||||
|
onClick={(e: React.MouseEvent) => props.onClick(e, props.id)}
|
||||||
|
data-id={props.id}
|
||||||
|
data-type={props.type}
|
||||||
|
data-unread-count={props.unread}
|
||||||
|
>
|
||||||
|
<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,21 +1,23 @@
|
|||||||
import { Trans, t } from "@lingui/macro"
|
import { msg } from "@lingui/core/macro"
|
||||||
import { Box, Center, Kbd, TextInput } from "@mantine/core"
|
import { useLingui } from "@lingui/react"
|
||||||
import { useOs } from "@mantine/hooks"
|
import { Trans } from "@lingui/react/macro"
|
||||||
|
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 isMacOS = useOs() === "macos"
|
const { _ } = useLingui()
|
||||||
|
|
||||||
const actions: SpotlightActionData[] = props.feeds
|
const actions: SpotlightActionData[] = props.feeds
|
||||||
.map(f => ({
|
.map(f => ({
|
||||||
id: `${f.id}`,
|
id: `${f.id}`,
|
||||||
@@ -26,24 +28,16 @@ export function TreeSearch(props: TreeSearchProps) {
|
|||||||
.sort((f1, f2) => f1.label.localeCompare(f2.label))
|
.sort((f1, f2) => f1.label.localeCompare(f2.label))
|
||||||
|
|
||||||
const searchIcon = <TbSearch size={18} />
|
const searchIcon = <TbSearch size={18} />
|
||||||
const rightSection = (
|
|
||||||
<Center style={{ cursor: "pointer" }} onClick={() => spotlight.open()}>
|
|
||||||
<Kbd>{isMacOS ? "Cmd" : "Ctrl"}</Kbd>
|
|
||||||
<Box mx={5}>+</Box>
|
|
||||||
<Kbd>K</Kbd>
|
|
||||||
</Center>
|
|
||||||
)
|
|
||||||
|
|
||||||
// additional keyboard shortcut used by commafeed v1
|
// additional keyboard shortcut used by commafeed v1
|
||||||
useMousetrap("g u", () => spotlight.open())
|
useMousetrap("g u", () => spotlight.open())
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<Box className="cf-treesearch">
|
||||||
<TextInput
|
<TextInput
|
||||||
placeholder={t`Search`}
|
placeholder={_(msg`Search`)}
|
||||||
leftSection={searchIcon}
|
leftSection={searchIcon}
|
||||||
rightSectionWidth={100}
|
rightSectionWidth={100}
|
||||||
rightSection={rightSection}
|
|
||||||
styles={{
|
styles={{
|
||||||
input: {
|
input: {
|
||||||
cursor: "pointer",
|
cursor: "pointer",
|
||||||
@@ -60,10 +54,10 @@ export function TreeSearch(props: TreeSearchProps) {
|
|||||||
shortcut="mod+k"
|
shortcut="mod+k"
|
||||||
searchProps={{
|
searchProps={{
|
||||||
leftSection: searchIcon,
|
leftSection: searchIcon,
|
||||||
placeholder: t`Search`,
|
placeholder: _(msg`Search`),
|
||||||
}}
|
}}
|
||||||
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">
|
<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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,5 +1,6 @@
|
|||||||
import { t } from "@lingui/macro"
|
import { msg } from "@lingui/core/macro"
|
||||||
import { useAppSelector } from "app/store"
|
import { useLingui } from "@lingui/react"
|
||||||
|
import { useAppSelector } from "@/app/store"
|
||||||
|
|
||||||
interface Step {
|
interface Step {
|
||||||
label: string
|
label: string
|
||||||
@@ -7,27 +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 steps: Step[] = [
|
const steps: Step[] = [
|
||||||
{
|
{
|
||||||
label: t`Loading settings...`,
|
label: _(msg`Loading settings...`),
|
||||||
done: !!settings,
|
done: settingsLoaded,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: t`Loading profile...`,
|
label: _(msg`Loading profile...`),
|
||||||
done: !!profile,
|
done: profileLoaded,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: t`Loading subscriptions...`,
|
label: _(msg`Loading subscriptions...`),
|
||||||
done: !!rootCategory,
|
done: rootCategoryLoaded,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: t`Loading tags...`,
|
label: _(msg`Loading tags...`),
|
||||||
done: !!tags,
|
done: tagsLoaded,
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user