forked from Archives/Athou_commafeed
Compare commits
2312 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 | ||
|
|
cd06055246 | ||
|
|
62c1f25ffc | ||
|
|
415dc15d6c | ||
|
|
1d0c87c679 | ||
|
|
e51c486a04 | ||
|
|
73808c1a70 | ||
|
|
fbcc2ecd0f | ||
|
|
3997606774 | ||
|
|
b988b599d5 | ||
|
|
3e2ff2959d | ||
|
|
5714a63d27 | ||
|
|
12b18d1e04 | ||
|
|
232141cb56 | ||
|
|
c4334e5e6e | ||
|
|
ddf78f880b | ||
|
|
b3651f3fba | ||
|
|
24943b868c | ||
|
|
ef71a691ef | ||
|
|
01593d94eb | ||
|
|
b793cc66d1 | ||
|
|
3810dedf47 | ||
|
|
9115797dee | ||
|
|
232658b934 | ||
|
|
f99fe57695 | ||
|
|
ec89d41112 | ||
|
|
f6d26a77cc | ||
|
|
860852cc12 | ||
|
|
d06d76401c | ||
|
|
f5b04a783e | ||
|
|
964e470951 | ||
|
|
612f8722dd | ||
|
|
e118dc9b7f | ||
|
|
6e42cdaf2d | ||
|
|
5198792ca5 | ||
|
|
10a71213f3 | ||
|
|
a5d0979d9f | ||
|
|
d84225ab1c | ||
|
|
cd86947e64 | ||
|
|
f6b3114a91 | ||
|
|
cd50b6b058 | ||
|
|
b0c7ef18db | ||
|
|
24171faf86 | ||
|
|
941f14dd41 | ||
|
|
d46ef787db | ||
|
|
ec7447a38c | ||
|
|
2a3fc3ae15 | ||
|
|
ef25582bcb | ||
|
|
55bbb2542d | ||
|
|
8e94ac74a8 | ||
|
|
90ecb9253c | ||
|
|
6721842d98 | ||
|
|
8b487ec414 | ||
|
|
d6382861c3 | ||
|
|
2cdea99a69 | ||
|
|
b1ae1c8afd | ||
|
|
c09cd0c717 | ||
|
|
f50e0ae272 | ||
|
|
b99b91a2a8 | ||
|
|
d9759de6f1 | ||
|
|
cf2b7f9e4f | ||
|
|
ee880c06ed | ||
|
|
bc2e13ef22 | ||
|
|
39ecfe2782 | ||
|
|
3295d82f69 | ||
|
|
1cd27a59e2 | ||
|
|
e1602edff1 | ||
|
|
ef8e61d6fc | ||
|
|
0057030442 | ||
|
|
6fabe46d6e | ||
|
|
37c58f2755 | ||
|
|
bb982c3caf | ||
|
|
7e4c3737a8 | ||
|
|
23596b5ac6 | ||
|
|
2fdeb7acd8 | ||
|
|
c62cac478c | ||
|
|
e9026e0371 | ||
|
|
7446d906ae | ||
|
|
62ad09ac93 | ||
|
|
01d1f920a8 | ||
|
|
057810470c | ||
|
|
5a6d6be8e5 | ||
|
|
c6c813a4ee | ||
|
|
ad5787a38b | ||
|
|
387ceabf30 | ||
|
|
ffe6962c36 | ||
|
|
6d599fc77d | ||
|
|
9fcff1342c | ||
|
|
f7dbc2e9aa | ||
|
|
468f2e4c76 | ||
|
|
883c9c79aa | ||
|
|
f171d05088 | ||
|
|
f85745fe40 | ||
|
|
5ad93bb3ba | ||
|
|
d80ed9d4dd | ||
|
|
69b5f5418a | ||
|
|
06aa37659c | ||
|
|
d5c98de839 | ||
|
|
920975059c | ||
|
|
7c6e4c3356 | ||
|
|
143971da5e | ||
|
|
8976e9c01a | ||
|
|
20c6355efd | ||
|
|
f86f38ef7a | ||
|
|
24311df551 | ||
|
|
d02aa78def | ||
|
|
b131020f46 | ||
|
|
4ab82782b0 | ||
|
|
6f9ebd5d78 | ||
|
|
7ebbf26369 | ||
|
|
dbc93f9928 | ||
|
|
ad6ebd7e4d | ||
|
|
ab86247c8c | ||
|
|
884516be28 | ||
|
|
c236b1adda | ||
|
|
222117dafe | ||
|
|
38cd27df57 | ||
|
|
5e07e74bb2 | ||
|
|
fe779e361f | ||
|
|
1a51799497 | ||
|
|
6ea926cdb0 | ||
|
|
439d61946a | ||
|
|
426c8d7dfb | ||
|
|
f1b51e8342 | ||
|
|
9de19e9f2d | ||
|
|
fc2eac7f2c | ||
|
|
bc6fc01c3f | ||
|
|
85ae70f278 | ||
|
|
e415d1d945 | ||
|
|
acb06c3405 | ||
|
|
a137ecb293 | ||
|
|
b4c1aea7c4 | ||
|
|
a0d86ce94a | ||
|
|
6ce0e2f151 | ||
|
|
628f7aca90 | ||
|
|
a4a7d53670 | ||
|
|
e76e7879cd | ||
|
|
7a00e743eb | ||
|
|
6e0e692ae8 | ||
|
|
321b3d4819 | ||
|
|
211708255e | ||
|
|
dcc32cb539 | ||
|
|
c9367afd9d | ||
|
|
af724fbb87 | ||
|
|
9d052f2f59 | ||
|
|
e9a9334c03 | ||
|
|
80c9adcf0f | ||
|
|
13c402d9d0 | ||
|
|
5d5dc67a46 | ||
|
|
f2330d8346 | ||
|
|
ee061f3362 | ||
|
|
e071cb457f | ||
|
|
7972dec827 | ||
|
|
41c0200270 | ||
|
|
9e5fa5472a | ||
|
|
0ebab27588 | ||
|
|
0d081bc47e | ||
|
|
92853a164a | ||
|
|
5d75885352 | ||
|
|
83b8886846 | ||
|
|
f3869f92dc | ||
|
|
b1c1f2adc4 | ||
|
|
fd8c6c5531 | ||
|
|
b37346ad20 | ||
|
|
1b2e2e6915 | ||
|
|
f483d569f0 | ||
|
|
b9bbcf1e60 | ||
|
|
3097272179 | ||
|
|
d0b92774bc | ||
|
|
c82a142c96 | ||
|
|
5b43d416fc | ||
|
|
40e1c70fca | ||
|
|
d610f980c7 | ||
|
|
68c8ce1ef3 | ||
|
|
218a602c0b | ||
|
|
cad65e953e | ||
|
|
39bc9713e4 | ||
|
|
812da21b6f | ||
|
|
a756783604 | ||
|
|
16199c5b54 | ||
|
|
3964977a0a | ||
|
|
f5b4d037ef | ||
|
|
5929581fee | ||
|
|
92d0d6af47 | ||
|
|
413253e4a9 | ||
|
|
9c98e7eca1 | ||
|
|
d3d1aba834 | ||
|
|
6dd3ce2e72 | ||
|
|
398648ac91 | ||
|
|
28ef9ccfd2 | ||
|
|
acab5295cc | ||
|
|
3d73435446 | ||
|
|
eab08d2197 | ||
|
|
d13b96edd1 | ||
|
|
624aa9cb23 | ||
|
|
e76ee6dc9b | ||
|
|
9bf7dbe893 | ||
|
|
0610080d2a | ||
|
|
19d91cf07f | ||
|
|
d9b9b8c3da | ||
|
|
0889dc145c | ||
|
|
113a8d49f0 | ||
|
|
f60a968fb1 | ||
|
|
02b060178b | ||
|
|
b1f3afd494 | ||
|
|
35acac7b93 | ||
|
|
9334e7b7a8 | ||
|
|
f424314b0d | ||
|
|
c4a9025160 | ||
|
|
85a134ef53 | ||
|
|
db1fe0fe91 | ||
|
|
30a45fc329 | ||
|
|
ce90fc356c | ||
|
|
4f50e34b21 | ||
|
|
8ccf148eaa | ||
|
|
fa343bda20 | ||
|
|
a66f8d7065 | ||
|
|
819003e43c | ||
|
|
27800296fb | ||
|
|
b869ef072a | ||
|
|
03dfee468c | ||
|
|
d3275074bb | ||
|
|
cc0965f69c | ||
|
|
8e2fa3e153 | ||
|
|
df10bd7351 | ||
|
|
080289ca4e | ||
|
|
28b821f085 | ||
|
|
700f3ec029 | ||
|
|
d7b3ed0baa | ||
|
|
f1711014e5 | ||
|
|
29f2270443 | ||
|
|
39ee4e771c | ||
|
|
6e4d2d57fa | ||
|
|
7cd850a2e8 | ||
|
|
3280023823 | ||
|
|
3646a9610e | ||
|
|
ab639b3ee6 | ||
|
|
c85ba3fa75 | ||
|
|
193c2aecfb | ||
|
|
336de875ca | ||
|
|
eb5012f67e | ||
|
|
5c764b9b25 | ||
|
|
5da94a7ed0 | ||
|
|
dfb3006c47 | ||
|
|
e626f36c0a |
@@ -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]:
|
||||||
|
|
||||||
|
|||||||
24
.github/dependabot.yml
vendored
24
.github/dependabot.yml
vendored
@@ -1,24 +0,0 @@
|
|||||||
version: 2
|
|
||||||
updates:
|
|
||||||
- package-ecosystem: "maven"
|
|
||||||
directory: "/"
|
|
||||||
schedule:
|
|
||||||
interval: "weekly"
|
|
||||||
open-pull-requests-limit: 10
|
|
||||||
- package-ecosystem: "npm"
|
|
||||||
directory: "/commafeed-client"
|
|
||||||
schedule:
|
|
||||||
interval: "weekly"
|
|
||||||
open-pull-requests-limit: 10
|
|
||||||
groups:
|
|
||||||
mantine:
|
|
||||||
patterns:
|
|
||||||
- "@mantine/*"
|
|
||||||
lingui:
|
|
||||||
patterns:
|
|
||||||
- "@lingui/*"
|
|
||||||
- package-ecosystem: "github-actions"
|
|
||||||
directory: "/"
|
|
||||||
schedule:
|
|
||||||
interval: "weekly"
|
|
||||||
open-pull-requests-limit: 10
|
|
||||||
37
.github/stale.yml
vendored
37
.github/stale.yml
vendored
@@ -1,19 +1,20 @@
|
|||||||
# Number of days of inactivity before an issue becomes stale
|
# Number of days of inactivity before an issue becomes stale
|
||||||
daysUntilStale: 60
|
daysUntilStale: 60
|
||||||
# Number of days of inactivity before a stale issue is closed
|
# Number of days of inactivity before a stale issue is closed
|
||||||
daysUntilClose: 7
|
daysUntilClose: 7
|
||||||
# Issues with these labels will never be considered stale
|
# Issues with these labels will never be considered stale
|
||||||
exemptLabels:
|
exemptLabels:
|
||||||
- pinned
|
- pinned
|
||||||
- security
|
- security
|
||||||
- enhancement
|
- enhancement
|
||||||
- bug
|
- feature-request
|
||||||
# Label to use when marking an issue as stale
|
- bug
|
||||||
staleLabel: wontfix
|
# Label to use when marking an issue as stale
|
||||||
# Comment to post when marking an issue as stale. Set to `false` to disable
|
staleLabel: wontfix
|
||||||
markComment: >
|
# Comment to post when marking an issue as stale. Set to `false` to disable
|
||||||
This issue has been automatically marked as stale because it has not had
|
markComment: >
|
||||||
recent activity. It will be closed if no further activity occurs. Thank you
|
This issue has been automatically marked as stale because it has not had
|
||||||
for your contributions.
|
recent activity. It will be closed if no further activity occurs. Thank you
|
||||||
# Comment to post when closing a stale issue. Set to `false` to disable
|
for your contributions.
|
||||||
|
# Comment to post when closing a stale issue. Set to `false` to disable
|
||||||
closeComment: false
|
closeComment: false
|
||||||
89
.github/workflows/build.yml
vendored
89
.github/workflows/build.yml
vendored
@@ -1,89 +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
|
|
||||||
|
|
||||||
# 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/arm/v7,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/arm/v7,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@v1
|
|
||||||
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
|
||||||
BIN
.mvn/wrapper/maven-wrapper.jar
vendored
BIN
.mvn/wrapper/maven-wrapper.jar
vendored
Binary file not shown.
21
.mvn/wrapper/maven-wrapper.properties
vendored
21
.mvn/wrapper/maven-wrapper.properties
vendored
@@ -1,18 +1,3 @@
|
|||||||
# Licensed to the Apache Software Foundation (ASF) under one
|
wrapperVersion=3.3.4
|
||||||
# or more contributor license agreements. See the NOTICE file
|
distributionType=only-script
|
||||||
# distributed with this work for additional information
|
distributionUrl=https://repo.maven.apache.org/maven2/org/apache/maven/apache-maven/3.9.14/apache-maven-3.9.14-bin.zip
|
||||||
# regarding copyright ownership. The ASF licenses this file
|
|
||||||
# to you under the Apache License, Version 2.0 (the
|
|
||||||
# "License"); you may not use this file except in compliance
|
|
||||||
# with the License. You may obtain a copy of the License at
|
|
||||||
#
|
|
||||||
# https://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.
|
|
||||||
distributionUrl=https://repo.maven.apache.org/maven2/org/apache/maven/apache-maven/3.8.3/apache-maven-3.8.3-bin.zip
|
|
||||||
wrapperUrl=https://repo.maven.apache.org/maven2/org/apache/maven/wrapper/maven-wrapper/3.1.1/maven-wrapper-3.1.1.jar
|
|
||||||
|
|||||||
867
CHANGELOG.md
867
CHANGELOG.md
@@ -1,315 +1,552 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
## [4.3.2]
|
## [7.0.0]
|
||||||
|
|
||||||
- added support for unix sockets (#1278)
|
- 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)
|
||||||
## [4.3.1]
|
- 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.
|
||||||
- fix an issue that prevents new feeds from being added when mysql/mariadb is used as the database and the database
|
- When `commafeed.http-client.block-local-addresses` is enabled, SSRF is now also mitigated by blocking public websites redirecting to local ones.
|
||||||
timezone is not UTC (#1239)
|
|
||||||
- videos in enclosures can no longer have a width larger than the page (#1240)
|
## [6.2.0]
|
||||||
|
|
||||||
## [4.3.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)
|
||||||
|
|
||||||
- h2 (the embedded database) has been upgraded to 2.2.224
|
## [6.1.1]
|
||||||
- this version uses a different file format than 2.1.x, the first time you start CommaFeed with this version, the
|
|
||||||
database will be automatically converted to the new format
|
- Fix old starred entries not loading if they were marked as read (#2031)
|
||||||
- add a setting to completely disable scrolling to selected entry (#1157)
|
|
||||||
- add a css class reflecting the current view mode to ease custom css rules (#1232)
|
## [6.1.0]
|
||||||
- fix an issue that prevents new feeds from being added when mysql/mariadb is used as the database (#1239)
|
|
||||||
|
- 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)
|
||||||
## [4.2.1]
|
- 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)
|
||||||
- fix an issue that caused the tree to show an incorrect unread count after a websocket notification because entries
|
- The "disable pull to refresh" feature is now disabled by default (#2030)
|
||||||
that were already marked as read by a filtering expression were not ignored (#1191)
|
|
||||||
|
## [6.0.0]
|
||||||
## [4.2.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
|
||||||
- add a setting to display the action buttons in the footer instead of in the header on mobile (#1121)
|
- 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)
|
||||||
- the websocket notification now contains everything needed to update the UI, the client no longer needs to make an API
|
- 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)
|
||||||
call to get the latest data when receiving the notification
|
- Java 25+ is now required to build and run CommaFeed
|
||||||
- add a workaround to the Fever API for the Unread iOS app (#1188)
|
|
||||||
- fix an issue that caused dates to be saved incorrectly if the database server and the application server were in
|
## [5.12.1]
|
||||||
different timezones (#1187)
|
|
||||||
|
- The favicon is now crispier (#1978)
|
||||||
## [4.1.0]
|
- The ReadKit iOS app now works via the Fever API (#1602)
|
||||||
|
|
||||||
- it is now possible to open the sidebar on mobile by swiping to the right (#1098)
|
## [5.12.0]
|
||||||
- swiping to mark entries as read/unread changed from swiping right to left because swiping right now opens the sidebar
|
|
||||||
- the full hierarchy of categories are now displayed in the category dropdown (#1045)
|
- Added a setting to disable the "disable pull to refresh" feature because it messes with some browsers (#1168)
|
||||||
- added a setting `maxEntriesAgeDays` to delete old entries based on their age during database cleanup.
|
- Emojis in feeds are now correctly displayed (#1955)
|
||||||
The setting is disabled by default for existing installations, except for the docker image where it is enabled and set
|
- Don't show "Star/Unstar" in the context menu if the entry is too old to be starred (#1935)
|
||||||
to 365 days
|
- Invalid relative urls in feeds no longer prevent those feeds from being parsed (#1939)
|
||||||
- if user registrations are disabled on your instance which is the default behavior, users are redirected on the login
|
- Fix an issue that could prevent large feeds from being parsed when using Java 24+ (#1961)
|
||||||
page instead of the welcome page when not logged in (#1185)
|
- Enforce user password validation when created in the admin view (#1937)
|
||||||
- the sidebar resizer is no longer shown in the middle of the screen on mobile
|
- The process in the docker native image is now called "commafeed" instead of "application"
|
||||||
- when using the system color scheme and the system is using a dark theme, feed entries no longer flicker on load
|
|
||||||
- the demo account (if enabled) cannot register custom javascript code anymore
|
## [5.11.1]
|
||||||
- removed the usage of `toSorted` in the client because older browsers do not support it (#1183)
|
|
||||||
- the openapi documentation is no longer cached by the browser so you always have access to the latest version
|
- The search limit of 3 characters has been removed (#1887)
|
||||||
- added a memory management section to the readme, reading it is recommended if you are running CommaFeed on a server
|
- Fix an issue that caused feed filtering expressions to be incorrectly converted to lowercase when saving them (#1899)
|
||||||
with limited memory
|
|
||||||
- fixed an issue that caused users without an email address set to be unable to edit their profile (#1184)
|
## [5.11.0]
|
||||||
|
|
||||||
## [4.0.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
|
||||||
- migrated from dropwizard 2 to dropwizard 4, Java 17+ is now required
|
|
||||||
- entries that were fetched and inserted in the database but not yet shown in the UI are no longer marked as read when
|
## [5.10.0]
|
||||||
marking all entries as read
|
|
||||||
- your custom sidebar width is now persisted in the local storage of your browser
|
- 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)
|
||||||
- there is now a third color scheme option in addition to light and dark: system (follows the system color scheme)
|
- Feeds with uppercase HTTP:// or HTTPS:// URLs are now correctly handled again
|
||||||
- added support for youtube playlist favicons
|
- The aarch64 native executable now also works on the Raspberry Pi 5 (#1795)
|
||||||
- custom JS code is now executed when the app is done loading instead of when the page is loaded
|
- Improve general performance of the UI by reducing the number of re-renders, especially when a lot of entries are displayed (#1087)
|
||||||
- the favicon is now correctly returned for feeds that return an invalid content type
|
|
||||||
- the feed refresh engine now uses httpclient5 with connection pooling and no longer creates a new client for each
|
## [5.9.0]
|
||||||
request, reducing CPU usage
|
|
||||||
- updated UI library Mantine to 7.0, improving performance
|
- A lot of CSS classes have been added to the elements of the application to ease custom CSS rules (#1757)
|
||||||
- the h2 embedded database is now compacted on shutdown to reclaim unused space
|
- Added a link in the README to the [documentation](https://athou.github.io/commafeed/documentation/custom-css/) of the new CSS classes
|
||||||
- the admin connector on port 8084 is now disabled in config.yml.example. Disabling it in your config.yml is
|
- Static resources are now cached for much longer (#1782)
|
||||||
recommended (see https://github.com/Athou/commafeed/commit/929df60f09cce56020b0962ab111cd8349b271b0)
|
|
||||||
- migrated documentation from swagger 2 to openapi 3
|
## [5.8.0]
|
||||||
- added a GET method to the fever api to indicate that the endpoint is working correctly when accessed from a browser
|
|
||||||
- the websocket connection can now be disabled, the websocket ping interval and the tree reload interval can now be
|
- A color picker is now available on the settings page to change the orange accent of the application (#1598)
|
||||||
configured (see config.yml.example)
|
- A font size slider is now available to change the size of the text of feed entries (#1462)
|
||||||
- the websocket connection now works correctly when the context root of the application is not "/"
|
- The "mark all as read" confirmation setting now also applies to the "shift+a" keyboard shortcut (#1744)
|
||||||
- unstable pubsubhubbub support was removed
|
- 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
|
||||||
## [3.10.1]
|
|
||||||
|
## [5.7.0]
|
||||||
- swap next and previous buttons (#1159)
|
|
||||||
- unread count for subscriptions will now be shortened starting at 10k instead of 1k
|
- Add Shift+J/Shift+K keyboard shortcuts to navigate to the next/previous feed or category with unread entries (#1558)
|
||||||
- increased websocket ping interval to just under a minute to reduce data and battery usage on mobile
|
- Add the referrer "no-referrer" meta to index.html (#1724)
|
||||||
- only refresh subscription tree on a timer if websocket connection is unavailable
|
- Load custom JS code when the app is done loading (#1724)
|
||||||
- the Docker image now uses less memory by returning unused memory to the OS
|
- Correctly handle feeds that return an unmodified Last-Modified header but a different ETag header (#1746)
|
||||||
- add support for Java 21
|
- Restore gzip compression of responses that was accidentaly disabled since 5.0.0
|
||||||
|
- Fix tooltips not showing up in mobile view
|
||||||
## [3.10.0]
|
- Fix the bookmarklet generator on the About page
|
||||||
|
|
||||||
- added a Fever-compatible API that is usable with mobile clients that support the Fever API (see instructions in
|
## [5.6.1]
|
||||||
Settings -> Profile)
|
|
||||||
- long entry titles are no longer shortened in the detailed view
|
- Restore support for iframes in feed entries (#1688)
|
||||||
- added the "s" keyboard shortcut to star/unstar entries
|
- There is now a package available for Arch Linux thanks to @dcelasun (#1691)
|
||||||
- http sessions are now stored in the database (they were stored on disk before)
|
|
||||||
- fixed an issue that made it impossible to override the database url in a config.yml mounted in the Docker image
|
## [5.6.0]
|
||||||
|
|
||||||
## [3.9.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)
|
||||||
- improve performance by disabling the loader when nothing is loading (most noticeable on mobile)
|
- 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
|
||||||
- added a setting to disable the 'mark all as read' confirmation
|
- 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
|
||||||
- added a setting to disable the custom context menu
|
|
||||||
- if the custom context is enabled, it can still be disabled by pressing the shift key
|
## [5.5.0]
|
||||||
- the announcement feature is now working again and supports html ('announcement' configuration element in config.yml)
|
|
||||||
- add support for MariaDB 11+
|
- CommaFeed now honors the Retry-After response header and will not try to refresh a feed sooner than the value of this header
|
||||||
- fix entry header shortly rendered as mobile on desktop, causing a small visual glitch
|
- Audio enclosures (e.g. podcasts) now fill available entry width
|
||||||
- fix an issue that could cause a feed to not refresh correctly if the url was very long
|
- Fix an issue with some labels not correctly internationalized
|
||||||
- database cleanup batch size is now configurable
|
|
||||||
- css parsing errors are no longer logged to the standard output
|
## [5.4.0]
|
||||||
- fix small errors in the api documentation
|
|
||||||
|
- An arm64 native executable is now available for download on the releases page
|
||||||
## [3.8.1]
|
- The native executable Docker image now supports arm64
|
||||||
|
- Fixed an issue with feeds that declared an invalid DOCTYPE (#1260)
|
||||||
- in expanded mode, don't scroll when clicking on the body of the current entry
|
|
||||||
- improve content cleanup task performance for instances with a very large number of feeds
|
## [5.3.6]
|
||||||
|
|
||||||
## [3.8.0]
|
- Ignore invalid Cache-Control header values (#1619)
|
||||||
|
|
||||||
- add previous and next buttons in the toolbar
|
## [5.3.5]
|
||||||
- add a setting to always scroll selected entry to the top of the page, even if it fits entirely on screen
|
|
||||||
- clicking on the body of an entry in expanded mode selects it and marks it as read
|
- Fixed an issue with the aspect ratio of images of some feeds (#1595)
|
||||||
- add rich text editor with autocomplete for custom css and js code in settings (desktop only)
|
- CommaFeed now honors the Cache-Control response header and will not try to refresh a feed sooner than its max-age property (#1615)
|
||||||
- dramatically improve performance while scrolling
|
- 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)
|
||||||
- fix broken welcome page mobile layout
|
|
||||||
- format dates in user locale instead of GMT in relative date popups
|
## [5.3.4]
|
||||||
|
|
||||||
## [3.7.0]
|
- Added support for Internationalized Domain Names (#1588)
|
||||||
|
|
||||||
- the sidebar is now resizable
|
## [5.3.3]
|
||||||
- added the "f" keyboard shortcut to hide the sidebar
|
|
||||||
- added tooltips to relative dates with the exact date
|
- Removed image bottom margins (#1587)
|
||||||
- add a setting to hide commafeed from search engines (exposes a robots.txt file, enabled by default)
|
|
||||||
- the browser extension unread count now updates when articles are marked as read/unread in the app
|
## [5.3.2]
|
||||||
- The "b" keyboard shortcut now works as expected on Chrome but requires the browser extension to be installed
|
|
||||||
- dark mode has been disabled on the api documentation page as it was unreadable
|
- Fixed an issue that could cause some images from not being rendered correctly (#1587)
|
||||||
- improvement to the feed refresh queuing logic when "heavy load" mode is enabled
|
|
||||||
- fix a bug that could prevent feeds and categories from being edited
|
## [5.3.1]
|
||||||
|
|
||||||
## [3.6.0]
|
- Fixed an issue that could cause some HTTP feeds to return a 400 error (#1572)
|
||||||
|
|
||||||
- add a button to open CommaFeed in a new tab and a button to open options when using the browser extension
|
## [5.3.0]
|
||||||
- clicking on the entry title in expanded mode now opens the link instead of doing nothing
|
|
||||||
- add tooltips to buttons when the mobile layout is used on desktop
|
- Added a setting to set a cooldown on the "fetch all my feeds" action, disabled by default (#1556)
|
||||||
- redirect the user to the welcome page if the user was deleted from the database
|
- Fixed an issue that could cause entries to not correctly load when using the "next" header button (#1557)
|
||||||
- add link to api documentation on welcome page
|
|
||||||
- the unread count is now correctly updated when using the "/next" bookmarklet while redis cache is enabled
|
## [5.2.0]
|
||||||
|
|
||||||
## [3.5.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)
|
||||||
- add compatibility with the new version of the CommaFeed browser extension
|
- Feeds are no longer refreshed between the moment its last user unsubscribes and the moment the feed is cleaned up (every hour)
|
||||||
- disable pull-to-refresh on mobile as it messes with vertical scrolling
|
- Fixed an issue that could cause entries to not correctly load when using keyboard navigation (#1557)
|
||||||
- add css classes to feed entries to help with custom css rules
|
|
||||||
- api documentation page no longer requires users to be authenticated
|
## [5.1.1]
|
||||||
- add a setting to limit the number of feeds a user can subscribe to
|
|
||||||
- add a setting to disable strict password policy
|
- Fixed database migration issue when upgrading from 5.0.0 to 5.1.0 on MariaDB (#1544)
|
||||||
- add feed refresh engine metrics
|
- When feeds without unread entries are hidden from the tree, the feed is displayed in the tree until another one is selected (#1543)
|
||||||
- fix redis timeouts
|
|
||||||
|
## [5.1.0]
|
||||||
## [3.4.0]
|
|
||||||
|
- Added a setting for showing/hiding unread count in the browser's tab title/favicon (#1518)
|
||||||
- add support for arm64 docker images
|
- Fixed an issue that could prevent the app from starting on some systems (#1532)
|
||||||
- add divider to visually separate read-only information from form on the profile settings page
|
- Added a cache busting filter for the webapp index.html and openapi documentation to make sure they are always up to date
|
||||||
- reduce javascript bundle size by 30% by loading only the necessary translations
|
- Reduced database cleanup log verbosity
|
||||||
- add a standalone donate page with all ways to support CommaFeed
|
|
||||||
- fix an issue introduced in 3.1.0 that could make CommaFeed not refresh feeds as fast as before on instances with lots
|
## [5.0.2]
|
||||||
of feeds
|
|
||||||
- fix alignment of icon with text for category tree nodes
|
- Fix favicon fetching for Youtube channels in native mode when Google auth key is set
|
||||||
- fix alignment of burger button with the rest of the header on mobile
|
- Fix an error that appears in the logs when fetching some favicons
|
||||||
|
|
||||||
## [3.3.2]
|
## [5.0.1]
|
||||||
|
|
||||||
- restore entry selection indicator (left orange border) that was lost with the mantine 6.x upgrade (3.3.0)
|
- Configure native compilation to support older CPU architectures (#1524)
|
||||||
- add dividers to visually separate read-only information from forms on feed and category details pages
|
|
||||||
- reduced javascript bundle size by 10%
|
## [5.0.0]
|
||||||
|
|
||||||
## [3.3.1]
|
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).
|
||||||
- fix long feed names not being shortened to respect tree max width
|
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).
|
||||||
## [3.3.0]
|
|
||||||
|
- CommaFeed now has a different package for each supported database.
|
||||||
- there are now database changes, rolling back to 2.x will no longer be possible
|
- If you are deploying CommaFeed with a precompiled package, please
|
||||||
- restore support for user custom CSS rules
|
read [this section of the README](https://github.com/Athou/commafeed/tree/master?tab=readme-ov-file#download-a-precompiled-package).
|
||||||
- add support for user custom JS code that will be executed on page load
|
- 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).
|
||||||
## [3.2.0]
|
- If you are using the Docker image, please read the instructions on
|
||||||
|
the [Docker Hub page](https://hub.docker.com/r/athou/commafeed).
|
||||||
- restore the welcome page
|
- Due to the switch to Quarkus, the way CommaFeed is configured is very different (the `config.yml` file is gone).
|
||||||
- only apply hover effect for unread entries (same as commafeed v2)
|
Please
|
||||||
- move notifications at the bottom of the screen
|
read [this section of the README](https://github.com/Athou/commafeed/tree/master?tab=readme-ov-file#configuration).
|
||||||
- always use https for sharing urls
|
Note that a lot of configuration elements have been removed or renamed and are now nested/grouped by feature.
|
||||||
- add support for redis ACLs
|
- Added a setting to prevent parsing large feeds to avoid out of memory errors. The default is 5MB.
|
||||||
- transition to google analytics v4
|
- 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)
|
||||||
## [3.1.0]
|
- 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.
|
||||||
- add an even more compact layout
|
- The H2 migration tool that automatically upgrades H2 databases from format 2 to 3 has been removed. If you're using
|
||||||
- restore hover effect from commafeed 2.x
|
the H2 embedded database, please upgrade to at least version 4.3.0 before upgrading to CommaFeed 5.0.0.
|
||||||
- view mode (compact, expanded, ...) is now stored on the device so you can have a different view mode on desktop and
|
|
||||||
mobile
|
## [4.6.0]
|
||||||
- fix for the "Illegal attempt to associate a collection with two open sessions." error
|
|
||||||
- feed fetching workflow is now orchestrated with rxjava, removing a lot of code
|
- 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)
|
||||||
## [3.0.1]
|
- show all entries regardless of their read status when searching with keywords, even if the ui is configured to show
|
||||||
|
unread entries only
|
||||||
- allow env variable substitution in config.yml
|
|
||||||
- e.g. having a custom config.yml file with `app.session.path=${SOME_ENV_VAR}` will substitute `SOME_ENV_VAR` with its
|
## [4.5.0]
|
||||||
value
|
|
||||||
- allow env variable prefixed with `CF_` to override config.yml properties
|
- significantly reduce the time needed to retrieve entries or mark them as read, especially when there are a lot of
|
||||||
- e.g. setting `CF_APP_ALLOWREGISTRATIONS=true` will set `app.allowRegistrations` to `true`
|
entries (#1452)
|
||||||
|
- fix a race condition where a feed could be refreshed before it was created in the database
|
||||||
## [3.0.0]
|
- fix an issue that could cause the websocket notification to contain the wrong number of unread entries when using
|
||||||
|
mysql/mariadb
|
||||||
- complete overhaul of the UI
|
- fix an error when trying to mark all starred entries as read
|
||||||
- backend and frontend are now in separate maven modules
|
- remove the `onlyIds` parameter from REST endpoints since retrieving all the entries is now just as fast
|
||||||
- no changes to the api or the database
|
- remove support for microsoft sqlserver because it's not covered with integration tests (please open an issue if you'd
|
||||||
- Docker images are now automatically built and available at https://hub.docker.com/r/athou/commafeed
|
like it back)
|
||||||
|
|
||||||
## [2.6.0]
|
## [4.4.1]
|
||||||
|
|
||||||
- add support for media content as a backup for missing content (useful for youtube feeds)
|
- fix vertical scrolling issues with Safari (#1168)
|
||||||
- correctly follow http error code 308 redirects
|
- the default value for new users for the "star entry" button and the "open in new tab" button in the entry headers is
|
||||||
- fixed a bug that prevented users from deleting their account
|
now "on desktop" instead of "always"
|
||||||
- fixed a bug that made commafeed store entry contents multiple times
|
- the "keyboard shortcuts" help page now shows "Cmd" instead of "Ctrl" on macOS (#1389)
|
||||||
- fixed a bug that prevented the app to be used as an installed app on mobile devices if the context path of commafeed
|
- remove a superfluous feed fetch when subscribing to a feed (#1431)
|
||||||
was not "/"
|
- the Docker image now uses Java 21
|
||||||
- fixed a bug that prevented entries from being "marked as read older than xxx" for a feed that was just added
|
|
||||||
- removed support for google+ and readability as those services no longer exist
|
## [4.4.0]
|
||||||
- removed support for deploying on openshift
|
|
||||||
- removed alphabetical sorting of entries because of really poor performance (title cannot be indexed)
|
- add support for sharing using the browser native capabilities if available (#1255)
|
||||||
- improve performance for instances with the heavy load setting enabled by preventing CommaFeed from fetching feeds from
|
- add a button in the entry headers to star an entry (#1025)
|
||||||
users that did not log in for a long time
|
- add a button in the entry headers to open links in a new tab (#1333)
|
||||||
- various dependencies upgrades (notably dropwizard from 1.3 to 2.1)
|
- add two options in the settings to toggle those buttons
|
||||||
- add support for mariadb
|
- accept .opml file extension when importing and export with the .opml extension
|
||||||
- add support for java17+ runtime
|
- the "mark as read" option is no longer shown in the context menu for entries that are too old to be marked as read (
|
||||||
- various security improvements
|
older than `keepStatusDays`) (#1303)
|
||||||
|
|
||||||
## [2.5.0]
|
## [4.3.3]
|
||||||
|
|
||||||
- unread count is now displayed in a favicon badge when supported
|
- fix OPML import (#1279)
|
||||||
- the user agent string for the bot fetching feeds is now configurable
|
|
||||||
- feed parsing performance improvements
|
## [4.3.2]
|
||||||
- support for java9+ runtime
|
|
||||||
- can now properly start from an empty postgresql database
|
- added support for unix sockets (#1278)
|
||||||
|
|
||||||
## [2.4.0]
|
## [4.3.1]
|
||||||
|
|
||||||
- users were not able to change password or delete account
|
- fix an issue that prevents new feeds from being added when mysql/mariadb is used as the database and the database
|
||||||
- fix api key generation
|
timezone is not UTC (#1239)
|
||||||
- feed entries can now be sorted alphabetically
|
- videos in enclosures can no longer have a width larger than the page (#1240)
|
||||||
- fix facebook sharing
|
|
||||||
- fix layout on iOS
|
## [4.3.0]
|
||||||
- postgresql driver update (fix for postgres 9.6)
|
|
||||||
- various internationalization fixes
|
- h2 (the embedded database) has been upgraded to 2.2.224
|
||||||
- security fixes
|
- this version uses a different file format than 2.1.x, the first time you start CommaFeed with this version, the
|
||||||
|
database will be automatically converted to the new format
|
||||||
## [2.3.0]
|
- add a setting to completely disable scrolling to selected entry (#1157)
|
||||||
|
- add a css class reflecting the current view mode to ease custom css rules (#1232)
|
||||||
- dropwizard upgrade 0.9.1
|
- fix an issue that prevents new feeds from being added when mysql/mariadb is used as the database (#1239)
|
||||||
- feed enclosures are hidden if they already displayed in the content
|
|
||||||
- fix youtube favicons
|
## [4.2.1]
|
||||||
- various internationalization fixes
|
|
||||||
|
- fix an issue that caused the tree to show an incorrect unread count after a websocket notification because entries
|
||||||
## [2.2.0]
|
that were already marked as read by a filtering expression were not ignored (#1191)
|
||||||
|
|
||||||
- fix youtube and instagram favicon fetching
|
## [4.2.0]
|
||||||
- mark as read filter was lost when a feed was rearranged with drag&drop
|
|
||||||
- feed entry categories are now displayed if available
|
- add a setting to display the action buttons in the footer instead of in the header on mobile (#1121)
|
||||||
- various performance and dependencies upgrades
|
- the websocket notification now contains everything needed to update the UI, the client no longer needs to make an API
|
||||||
- java8 is now required
|
call to get the latest data when receiving the notification
|
||||||
|
- add a workaround to the Fever API for the Unread iOS app (#1188)
|
||||||
## [2.1.0]
|
- fix an issue that caused dates to be saved incorrectly if the database server and the application server were in
|
||||||
|
different timezones (#1187)
|
||||||
- dropwizard upgrade to 0.8.0
|
|
||||||
- you have to remove the "app.contextPath" setting from your yml file, you can optionally use
|
## [4.1.0]
|
||||||
server.applicationContextPath instead
|
|
||||||
- new setting app.maxFeedCapacity for deleting old entries
|
- it is now possible to open the sidebar on mobile by swiping to the right (#1098)
|
||||||
- ability to set filtering expressions for subscriptions to automatically mark new entries as read based on title,
|
- swiping to mark entries as read/unread changed from swiping right to left because swiping right now opens the sidebar
|
||||||
content, author or url.
|
- the full hierarchy of categories are now displayed in the category dropdown (#1045)
|
||||||
- ability to use !keyword or -keyword to exclude a keyword from a search query
|
- added a setting `maxEntriesAgeDays` to delete old entries based on their age during database cleanup.
|
||||||
- facebook feeds now show user favicon instead of facebook favicon
|
The setting is disabled by default for existing installations, except for the docker image where it is enabled and set
|
||||||
- new dark theme 'nightsky'
|
to 365 days
|
||||||
|
- if user registrations are disabled on your instance which is the default behavior, users are redirected on the login
|
||||||
## [2.0.3]
|
page instead of the welcome page when not logged in (#1185)
|
||||||
|
- the sidebar resizer is no longer shown in the middle of the screen on mobile
|
||||||
- internet explorer ajax cache workaround
|
- when using the system color scheme and the system is using a dark theme, feed entries no longer flicker on load
|
||||||
- categories are now deletable again
|
- the demo account (if enabled) cannot register custom javascript code anymore
|
||||||
- openshift support is back
|
- removed the usage of `toSorted` in the client because older browsers do not support it (#1183)
|
||||||
- youtube feeds now show user favicon instead of youtube favicon
|
- the openapi documentation is no longer cached by the browser so you always have access to the latest version
|
||||||
|
- added a memory management section to the readme, reading it is recommended if you are running CommaFeed on a server
|
||||||
## [2.0.2]
|
with limited memory
|
||||||
|
- fixed an issue that caused users without an email address set to be unable to edit their profile (#1184)
|
||||||
- api using the api key is now working again
|
|
||||||
- context path is now configurable in config.yml (see app.contextPath in config.yml.example)
|
## [4.0.0]
|
||||||
- fix login on firefox when fields are autofilled by the browser
|
|
||||||
- fix scrolling of subscriptions list on mobile
|
- migrated from dropwizard 2 to dropwizard 4, Java 17+ is now required
|
||||||
- user is now logged in after registration
|
- entries that were fetched and inserted in the database but not yet shown in the UI are no longer marked as read when
|
||||||
- fix link to documentation on home page and about page
|
marking all entries as read
|
||||||
- fields autocomplete is disabled on the profile page
|
- your custom sidebar width is now persisted in the local storage of your browser
|
||||||
- users are able to delete their account again
|
- there is now a third color scheme option in addition to light and dark: system (follows the system color scheme)
|
||||||
- chinese and malaysian translation files are now correctly loaded
|
- added support for youtube playlist favicons
|
||||||
- software version in user-agent when fetching feeds is no longer hardcoded
|
- custom JS code is now executed when the app is done loading instead of when the page is loaded
|
||||||
- admin settings page is now read only, settings are configured in config.yml
|
- the favicon is now correctly returned for feeds that return an invalid content type
|
||||||
- added link to metrics on the admin settings page
|
- the feed refresh engine now uses httpclient5 with connection pooling and no longer creates a new client for each
|
||||||
- Rome (rss library) upgrade to 1.5.0
|
request, reducing CPU usage
|
||||||
|
- updated UI library Mantine to 7.0, improving performance
|
||||||
## [2.0.1]
|
- the h2 embedded database is now compacted on shutdown to reclaim unused space
|
||||||
|
- the admin connector on port 8084 is now disabled in config.yml.example. Disabling it in your config.yml is
|
||||||
- the redis pool no longer throws an exception when it is unable to aquire a new connection
|
recommended (see https://github.com/Athou/commafeed/commit/929df60f09cce56020b0962ab111cd8349b271b0)
|
||||||
|
- migrated documentation from swagger 2 to openapi 3
|
||||||
## [2.0.0]
|
- added a GET method to the fever api to indicate that the endpoint is working correctly when accessed from a browser
|
||||||
|
- the websocket connection can now be disabled, the websocket ping interval and the tree reload interval can now be
|
||||||
- The backend has been completely rewritten using Dropwizard instead of TomEE, resulting in a lot less memory
|
configured (see config.yml.example)
|
||||||
consumption and better overall performances.
|
- the websocket connection now works correctly when the context root of the application is not "/"
|
||||||
See the README on how to build CommaFeed from now on.
|
- unstable pubsubhubbub support was removed
|
||||||
- CommaFeed should no longer fetch the same feed multiple times in a row
|
|
||||||
- Users can use their username or email to log in
|
## [3.10.1]
|
||||||
|
|
||||||
|
- swap next and previous buttons (#1159)
|
||||||
|
- unread count for subscriptions will now be shortened starting at 10k instead of 1k
|
||||||
|
- increased websocket ping interval to just under a minute to reduce data and battery usage on mobile
|
||||||
|
- only refresh subscription tree on a timer if websocket connection is unavailable
|
||||||
|
- the Docker image now uses less memory by returning unused memory to the OS
|
||||||
|
- add support for Java 21
|
||||||
|
|
||||||
|
## [3.10.0]
|
||||||
|
|
||||||
|
- added a Fever-compatible API that is usable with mobile clients that support the Fever API (see instructions in
|
||||||
|
Settings -> Profile)
|
||||||
|
- long entry titles are no longer shortened in the detailed view
|
||||||
|
- added the "s" keyboard shortcut to star/unstar entries
|
||||||
|
- http sessions are now stored in the database (they were stored on disk before)
|
||||||
|
- fixed an issue that made it impossible to override the database url in a config.yml mounted in the Docker image
|
||||||
|
|
||||||
|
## [3.9.0]
|
||||||
|
|
||||||
|
- improve performance by disabling the loader when nothing is loading (most noticeable on mobile)
|
||||||
|
- added a setting to disable the 'mark all as read' confirmation
|
||||||
|
- added a setting to disable the custom context menu
|
||||||
|
- if the custom context is enabled, it can still be disabled by pressing the shift key
|
||||||
|
- the announcement feature is now working again and supports html ('announcement' configuration element in config.yml)
|
||||||
|
- add support for MariaDB 11+
|
||||||
|
- fix entry header shortly rendered as mobile on desktop, causing a small visual glitch
|
||||||
|
- fix an issue that could cause a feed to not refresh correctly if the url was very long
|
||||||
|
- database cleanup batch size is now configurable
|
||||||
|
- css parsing errors are no longer logged to the standard output
|
||||||
|
- fix small errors in the api documentation
|
||||||
|
|
||||||
|
## [3.8.1]
|
||||||
|
|
||||||
|
- in expanded mode, don't scroll when clicking on the body of the current entry
|
||||||
|
- improve content cleanup task performance for instances with a very large number of feeds
|
||||||
|
|
||||||
|
## [3.8.0]
|
||||||
|
|
||||||
|
- add previous and next buttons in the toolbar
|
||||||
|
- add a setting to always scroll selected entry to the top of the page, even if it fits entirely on screen
|
||||||
|
- clicking on the body of an entry in expanded mode selects it and marks it as read
|
||||||
|
- add rich text editor with autocomplete for custom css and js code in settings (desktop only)
|
||||||
|
- dramatically improve performance while scrolling
|
||||||
|
- fix broken welcome page mobile layout
|
||||||
|
- format dates in user locale instead of GMT in relative date popups
|
||||||
|
|
||||||
|
## [3.7.0]
|
||||||
|
|
||||||
|
- the sidebar is now resizable
|
||||||
|
- added the "f" keyboard shortcut to hide the sidebar
|
||||||
|
- added tooltips to relative dates with the exact date
|
||||||
|
- add a setting to hide commafeed from search engines (exposes a robots.txt file, enabled by default)
|
||||||
|
- the browser extension unread count now updates when articles are marked as read/unread in the app
|
||||||
|
- The "b" keyboard shortcut now works as expected on Chrome but requires the browser extension to be installed
|
||||||
|
- dark mode has been disabled on the api documentation page as it was unreadable
|
||||||
|
- improvement to the feed refresh queuing logic when "heavy load" mode is enabled
|
||||||
|
- fix a bug that could prevent feeds and categories from being edited
|
||||||
|
|
||||||
|
## [3.6.0]
|
||||||
|
|
||||||
|
- add a button to open CommaFeed in a new tab and a button to open options when using the browser extension
|
||||||
|
- clicking on the entry title in expanded mode now opens the link instead of doing nothing
|
||||||
|
- add tooltips to buttons when the mobile layout is used on desktop
|
||||||
|
- redirect the user to the welcome page if the user was deleted from the database
|
||||||
|
- add link to api documentation on welcome page
|
||||||
|
- the unread count is now correctly updated when using the "/next" bookmarklet while redis cache is enabled
|
||||||
|
|
||||||
|
## [3.5.0]
|
||||||
|
|
||||||
|
- add compatibility with the new version of the CommaFeed browser extension
|
||||||
|
- disable pull-to-refresh on mobile as it messes with vertical scrolling
|
||||||
|
- add css classes to feed entries to help with custom css rules
|
||||||
|
- api documentation page no longer requires users to be authenticated
|
||||||
|
- add a setting to limit the number of feeds a user can subscribe to
|
||||||
|
- add a setting to disable strict password policy
|
||||||
|
- add feed refresh engine metrics
|
||||||
|
- fix redis timeouts
|
||||||
|
|
||||||
|
## [3.4.0]
|
||||||
|
|
||||||
|
- add support for arm64 docker images
|
||||||
|
- add divider to visually separate read-only information from form on the profile settings page
|
||||||
|
- reduce javascript bundle size by 30% by loading only the necessary translations
|
||||||
|
- add a standalone donate page with all ways to support CommaFeed
|
||||||
|
- fix an issue introduced in 3.1.0 that could make CommaFeed not refresh feeds as fast as before on instances with lots
|
||||||
|
of feeds
|
||||||
|
- fix alignment of icon with text for category tree nodes
|
||||||
|
- fix alignment of burger button with the rest of the header on mobile
|
||||||
|
|
||||||
|
## [3.3.2]
|
||||||
|
|
||||||
|
- restore entry selection indicator (left orange border) that was lost with the mantine 6.x upgrade (3.3.0)
|
||||||
|
- add dividers to visually separate read-only information from forms on feed and category details pages
|
||||||
|
- reduced javascript bundle size by 10%
|
||||||
|
|
||||||
|
## [3.3.1]
|
||||||
|
|
||||||
|
- fix long feed names not being shortened to respect tree max width
|
||||||
|
|
||||||
|
## [3.3.0]
|
||||||
|
|
||||||
|
- there are now database changes, rolling back to 2.x will no longer be possible
|
||||||
|
- restore support for user custom CSS rules
|
||||||
|
- add support for user custom JS code that will be executed on page load
|
||||||
|
|
||||||
|
## [3.2.0]
|
||||||
|
|
||||||
|
- restore the welcome page
|
||||||
|
- only apply hover effect for unread entries (same as commafeed v2)
|
||||||
|
- move notifications at the bottom of the screen
|
||||||
|
- always use https for sharing urls
|
||||||
|
- add support for redis ACLs
|
||||||
|
- transition to google analytics v4
|
||||||
|
|
||||||
|
## [3.1.0]
|
||||||
|
|
||||||
|
- add an even more compact layout
|
||||||
|
- restore hover effect from commafeed 2.x
|
||||||
|
- view mode (compact, expanded, ...) is now stored on the device so you can have a different view mode on desktop and
|
||||||
|
mobile
|
||||||
|
- fix for the "Illegal attempt to associate a collection with two open sessions." error
|
||||||
|
- feed fetching workflow is now orchestrated with rxjava, removing a lot of code
|
||||||
|
|
||||||
|
## [3.0.1]
|
||||||
|
|
||||||
|
- allow env variable substitution in config.yml
|
||||||
|
- e.g. having a custom config.yml file with `app.session.path=${SOME_ENV_VAR}` will substitute `SOME_ENV_VAR` with its
|
||||||
|
value
|
||||||
|
- allow env variable prefixed with `CF_` to override config.yml properties
|
||||||
|
- e.g. setting `CF_APP_ALLOWREGISTRATIONS=true` will set `app.allowRegistrations` to `true`
|
||||||
|
|
||||||
|
## [3.0.0]
|
||||||
|
|
||||||
|
- complete overhaul of the UI
|
||||||
|
- backend and frontend are now in separate maven modules
|
||||||
|
- no changes to the api or the database
|
||||||
|
- Docker images are now automatically built and available at https://hub.docker.com/r/athou/commafeed
|
||||||
|
|
||||||
|
## [2.6.0]
|
||||||
|
|
||||||
|
- add support for media content as a backup for missing content (useful for youtube feeds)
|
||||||
|
- correctly follow http error code 308 redirects
|
||||||
|
- fixed a bug that prevented users from deleting their account
|
||||||
|
- fixed a bug that made commafeed store entry contents multiple times
|
||||||
|
- fixed a bug that prevented the app to be used as an installed app on mobile devices if the context path of commafeed
|
||||||
|
was not "/"
|
||||||
|
- fixed a bug that prevented entries from being "marked as read older than xxx" for a feed that was just added
|
||||||
|
- removed support for google+ and readability as those services no longer exist
|
||||||
|
- removed support for deploying on openshift
|
||||||
|
- removed alphabetical sorting of entries because of really poor performance (title cannot be indexed)
|
||||||
|
- improve performance for instances with the heavy load setting enabled by preventing CommaFeed from fetching feeds from
|
||||||
|
users that did not log in for a long time
|
||||||
|
- various dependencies upgrades (notably dropwizard from 1.3 to 2.1)
|
||||||
|
- add support for mariadb
|
||||||
|
- add support for java17+ runtime
|
||||||
|
- various security improvements
|
||||||
|
|
||||||
|
## [2.5.0]
|
||||||
|
|
||||||
|
- unread count is now displayed in a favicon badge when supported
|
||||||
|
- the user agent string for the bot fetching feeds is now configurable
|
||||||
|
- feed parsing performance improvements
|
||||||
|
- support for java9+ runtime
|
||||||
|
- can now properly start from an empty postgresql database
|
||||||
|
|
||||||
|
## [2.4.0]
|
||||||
|
|
||||||
|
- users were not able to change password or delete account
|
||||||
|
- fix api key generation
|
||||||
|
- feed entries can now be sorted alphabetically
|
||||||
|
- fix facebook sharing
|
||||||
|
- fix layout on iOS
|
||||||
|
- postgresql driver update (fix for postgres 9.6)
|
||||||
|
- various internationalization fixes
|
||||||
|
- security fixes
|
||||||
|
|
||||||
|
## [2.3.0]
|
||||||
|
|
||||||
|
- dropwizard upgrade 0.9.1
|
||||||
|
- feed enclosures are hidden if they already displayed in the content
|
||||||
|
- fix youtube favicons
|
||||||
|
- various internationalization fixes
|
||||||
|
|
||||||
|
## [2.2.0]
|
||||||
|
|
||||||
|
- fix youtube and instagram favicon fetching
|
||||||
|
- mark as read filter was lost when a feed was rearranged with drag&drop
|
||||||
|
- feed entry categories are now displayed if available
|
||||||
|
- various performance and dependencies upgrades
|
||||||
|
- java8 is now required
|
||||||
|
|
||||||
|
## [2.1.0]
|
||||||
|
|
||||||
|
- dropwizard upgrade to 0.8.0
|
||||||
|
- you have to remove the "app.contextPath" setting from your yml file, you can optionally use
|
||||||
|
server.applicationContextPath instead
|
||||||
|
- new setting app.maxFeedCapacity for deleting old entries
|
||||||
|
- ability to set filtering expressions for subscriptions to automatically mark new entries as read based on title,
|
||||||
|
content, author or url.
|
||||||
|
- ability to use !keyword or -keyword to exclude a keyword from a search query
|
||||||
|
- facebook feeds now show user favicon instead of facebook favicon
|
||||||
|
- new dark theme 'nightsky'
|
||||||
|
|
||||||
|
## [2.0.3]
|
||||||
|
|
||||||
|
- internet explorer ajax cache workaround
|
||||||
|
- categories are now deletable again
|
||||||
|
- openshift support is back
|
||||||
|
- youtube feeds now show user favicon instead of youtube favicon
|
||||||
|
|
||||||
|
## [2.0.2]
|
||||||
|
|
||||||
|
- api using the api key is now working again
|
||||||
|
- context path is now configurable in config.yml (see app.contextPath in config.yml.example)
|
||||||
|
- fix login on firefox when fields are autofilled by the browser
|
||||||
|
- fix scrolling of subscriptions list on mobile
|
||||||
|
- user is now logged in after registration
|
||||||
|
- fix link to documentation on home page and about page
|
||||||
|
- fields autocomplete is disabled on the profile page
|
||||||
|
- users are able to delete their account again
|
||||||
|
- chinese and malaysian translation files are now correctly loaded
|
||||||
|
- software version in user-agent when fetching feeds is no longer hardcoded
|
||||||
|
- admin settings page is now read only, settings are configured in config.yml
|
||||||
|
- added link to metrics on the admin settings page
|
||||||
|
- Rome (rss library) upgrade to 1.5.0
|
||||||
|
|
||||||
|
## [2.0.1]
|
||||||
|
|
||||||
|
- the redis pool no longer throws an exception when it is unable to aquire a new connection
|
||||||
|
|
||||||
|
## [2.0.0]
|
||||||
|
|
||||||
|
- The backend has been completely rewritten using Dropwizard instead of TomEE, resulting in a lot less memory
|
||||||
|
consumption and better overall performances.
|
||||||
|
See the README on how to build CommaFeed from now on.
|
||||||
|
- CommaFeed should no longer fetch the same feed multiple times in a row
|
||||||
|
- Users can use their username or email to log in
|
||||||
|
|||||||
12
Dockerfile
12
Dockerfile
@@ -1,12 +0,0 @@
|
|||||||
FROM eclipse-temurin:17-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"]
|
|
||||||
60
LICENSE
60
LICENSE
@@ -1,31 +1,31 @@
|
|||||||
Apache License, Version 2.0 Apache License Version 2.0, January 2004 http://www.apache.org/licenses/
|
Apache License, Version 2.0 Apache License Version 2.0, January 2004 http://www.apache.org/licenses/
|
||||||
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
||||||
1. Definitions.
|
1. Definitions.
|
||||||
"License" shall mean the terms and conditions for use, reproduction, and distribution as defined by Sections 1 through 9 of this document.
|
"License" shall mean the terms and conditions for use, reproduction, and distribution as defined by Sections 1 through 9 of this document.
|
||||||
"Licensor" shall mean the copyright owner or entity authorized by the copyright owner that is granting the License.
|
"Licensor" shall mean the copyright owner or entity authorized by the copyright owner that is granting the License.
|
||||||
"Legal Entity" shall mean the union of the acting entity and all other entities that control, are controlled by, or are under common control with that entity. For the purposes of this definition, "control" means (i) the power, direct or indirect, to cause the direction or management of such entity, whether by contract or otherwise, or (ii) ownership of fifty percent (50%) or more of the outstanding shares, or (iii) beneficial ownership of such entity.
|
"Legal Entity" shall mean the union of the acting entity and all other entities that control, are controlled by, or are under common control with that entity. For the purposes of this definition, "control" means (i) the power, direct or indirect, to cause the direction or management of such entity, whether by contract or otherwise, or (ii) ownership of fifty percent (50%) or more of the outstanding shares, or (iii) beneficial ownership of such entity.
|
||||||
"You" (or "Your") shall mean an individual or Legal Entity exercising permissions granted by this License.
|
"You" (or "Your") shall mean an individual or Legal Entity exercising permissions granted by this License.
|
||||||
"Source" form shall mean the preferred form for making modifications, including but not limited to software source code, documentation source, and configuration files.
|
"Source" form shall mean the preferred form for making modifications, including but not limited to software source code, documentation source, and configuration files.
|
||||||
"Object" form shall mean any form resulting from mechanical transformation or translation of a Source form, including but not limited to compiled object code, generated documentation, and conversions to other media types.
|
"Object" form shall mean any form resulting from mechanical transformation or translation of a Source form, including but not limited to compiled object code, generated documentation, and conversions to other media types.
|
||||||
"Work" shall mean the work of authorship, whether in Source or Object form, made available under the License, as indicated by a copyright notice that is included in or attached to the work (an example is provided in the Appendix below).
|
"Work" shall mean the work of authorship, whether in Source or Object form, made available under the License, as indicated by a copyright notice that is included in or attached to the work (an example is provided in the Appendix below).
|
||||||
"Derivative Works" shall mean any work, whether in Source or Object form, that is based on (or derived from) the Work and for which the editorial revisions, annotations, elaborations, or other modifications represent, as a whole, an original work of authorship. For the purposes of this License, Derivative Works shall not include works that remain separable from, or merely link (or bind by name) to the interfaces of, the Work and Derivative Works thereof.
|
"Derivative Works" shall mean any work, whether in Source or Object form, that is based on (or derived from) the Work and for which the editorial revisions, annotations, elaborations, or other modifications represent, as a whole, an original work of authorship. For the purposes of this License, Derivative Works shall not include works that remain separable from, or merely link (or bind by name) to the interfaces of, the Work and Derivative Works thereof.
|
||||||
"Contribution" shall mean any work of authorship, including the original version of the Work and any modifications or additions to that Work or Derivative Works thereof, that is intentionally submitted to Licensor for inclusion in the Work by the copyright owner or by an individual or Legal Entity authorized to submit on behalf of the copyright owner. For the purposes of this definition, "submitted" means any form of electronic, verbal, or written communication sent to the Licensor or its representatives, including but not limited to communication on electronic mailing lists, source code control systems, and issue tracking systems that are managed by, or on behalf of, the Licensor for the purpose of discussing and improving the Work, but excluding communication that is conspicuously marked or otherwise designated in writing by the copyright owner as "Not a Contribution."
|
"Contribution" shall mean any work of authorship, including the original version of the Work and any modifications or additions to that Work or Derivative Works thereof, that is intentionally submitted to Licensor for inclusion in the Work by the copyright owner or by an individual or Legal Entity authorized to submit on behalf of the copyright owner. For the purposes of this definition, "submitted" means any form of electronic, verbal, or written communication sent to the Licensor or its representatives, including but not limited to communication on electronic mailing lists, source code control systems, and issue tracking systems that are managed by, or on behalf of, the Licensor for the purpose of discussing and improving the Work, but excluding communication that is conspicuously marked or otherwise designated in writing by the copyright owner as "Not a Contribution."
|
||||||
"Contributor" shall mean Licensor and any individual or Legal Entity on behalf of whom a Contribution has been received by Licensor and subsequently incorporated within the Work.
|
"Contributor" shall mean Licensor and any individual or Legal Entity on behalf of whom a Contribution has been received by Licensor and subsequently incorporated within the Work.
|
||||||
2. Grant of Copyright License.
|
2. Grant of Copyright License.
|
||||||
Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable copyright license to reproduce, prepare Derivative Works of, publicly display, publicly perform, sublicense, and distribute the Work and such Derivative Works in Source or Object form.
|
Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable copyright license to reproduce, prepare Derivative Works of, publicly display, publicly perform, sublicense, and distribute the Work and such Derivative Works in Source or Object form.
|
||||||
3. Grant of Patent License.
|
3. Grant of Patent License.
|
||||||
Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable (except as stated in this section) patent license to make, have made, use, offer to sell, sell, import, and otherwise transfer the Work, where such license applies only to those patent claims licensable by such Contributor that are necessarily infringed by their Contribution(s) alone or by combination of their Contribution(s) with the Work to which such Contribution(s) was submitted. If You institute patent litigation against any entity (including a cross-claim or counterclaim in a lawsuit) alleging that the Work or a Contribution incorporated within the Work constitutes direct or contributory patent infringement, then any patent licenses granted to You under this License for that Work shall terminate as of the date such litigation is filed.
|
Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable (except as stated in this section) patent license to make, have made, use, offer to sell, sell, import, and otherwise transfer the Work, where such license applies only to those patent claims licensable by such Contributor that are necessarily infringed by their Contribution(s) alone or by combination of their Contribution(s) with the Work to which such Contribution(s) was submitted. If You institute patent litigation against any entity (including a cross-claim or counterclaim in a lawsuit) alleging that the Work or a Contribution incorporated within the Work constitutes direct or contributory patent infringement, then any patent licenses granted to You under this License for that Work shall terminate as of the date such litigation is filed.
|
||||||
4. Redistribution.
|
4. Redistribution.
|
||||||
You may reproduce and distribute copies of the Work or Derivative Works thereof in any medium, with or without modifications, and in Source or Object form, provided that You meet the following conditions:
|
You may reproduce and distribute copies of the Work or Derivative Works thereof in any medium, with or without modifications, and in Source or Object form, provided that You meet the following conditions:
|
||||||
You must give any other recipients of the Work or Derivative Works a copy of this License; and You must cause any modified files to carry prominent notices stating that You changed the files; and You must retain, in the Source form of any Derivative Works that You distribute, all copyright, patent, trademark, and attribution notices from the Source form of the Work, excluding those notices that do not pertain to any part of the Derivative Works; and If the Work includes a "NOTICE" text file as part of its distribution, then any Derivative Works that You distribute must include a readable copy of the attribution notices contained within such NOTICE file, excluding those notices that do not pertain to any part of the Derivative Works, in at least one of the following places: within a NOTICE text file distributed as part of the Derivative Works; within the Source form or documentation, if provided along with the Derivative Works; or, within a display generated by the Derivative Works, if and wherever such third-party notices normally appear. The contents of the NOTICE file are for informational purposes only and do not modify the License. You may add Your own attribution notices within Derivative Works that You distribute, alongside or as an addendum to the NOTICE text from the Work, provided that such additional attribution notices cannot be construed as modifying the License. You may add Your own copyright statement to Your modifications and may provide additional or different license terms and conditions for use, reproduction, or distribution of Your modifications, or for any such Derivative Works as a whole, provided Your use, reproduction, and distribution of the Work otherwise complies with the conditions stated in this License.
|
You must give any other recipients of the Work or Derivative Works a copy of this License; and You must cause any modified files to carry prominent notices stating that You changed the files; and You must retain, in the Source form of any Derivative Works that You distribute, all copyright, patent, trademark, and attribution notices from the Source form of the Work, excluding those notices that do not pertain to any part of the Derivative Works; and If the Work includes a "NOTICE" text file as part of its distribution, then any Derivative Works that You distribute must include a readable copy of the attribution notices contained within such NOTICE file, excluding those notices that do not pertain to any part of the Derivative Works, in at least one of the following places: within a NOTICE text file distributed as part of the Derivative Works; within the Source form or documentation, if provided along with the Derivative Works; or, within a display generated by the Derivative Works, if and wherever such third-party notices normally appear. The contents of the NOTICE file are for informational purposes only and do not modify the License. You may add Your own attribution notices within Derivative Works that You distribute, alongside or as an addendum to the NOTICE text from the Work, provided that such additional attribution notices cannot be construed as modifying the License. You may add Your own copyright statement to Your modifications and may provide additional or different license terms and conditions for use, reproduction, or distribution of Your modifications, or for any such Derivative Works as a whole, provided Your use, reproduction, and distribution of the Work otherwise complies with the conditions stated in this License.
|
||||||
5. Submission of Contributions.
|
5. Submission of Contributions.
|
||||||
Unless You explicitly state otherwise, any Contribution intentionally submitted for inclusion in the Work by You to the Licensor shall be under the terms and conditions of this License, without any additional terms or conditions. Notwithstanding the above, nothing herein shall supersede or modify the terms of any separate license agreement you may have executed with Licensor regarding such Contributions.
|
Unless You explicitly state otherwise, any Contribution intentionally submitted for inclusion in the Work by You to the Licensor shall be under the terms and conditions of this License, without any additional terms or conditions. Notwithstanding the above, nothing herein shall supersede or modify the terms of any separate license agreement you may have executed with Licensor regarding such Contributions.
|
||||||
6. Trademarks.
|
6. Trademarks.
|
||||||
This License does not grant permission to use the trade names, trademarks, service marks, or product names of the Licensor, except as required for reasonable and customary use in describing the origin of the Work and reproducing the content of the NOTICE file.
|
This License does not grant permission to use the trade names, trademarks, service marks, or product names of the Licensor, except as required for reasonable and customary use in describing the origin of the Work and reproducing the content of the NOTICE file.
|
||||||
7. Disclaimer of Warranty.
|
7. Disclaimer of Warranty.
|
||||||
Unless required by applicable law or agreed to in writing, Licensor provides the Work (and each Contributor provides its Contributions) on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied, including, without limitation, any warranties or conditions of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A PARTICULAR PURPOSE. You are solely responsible for determining the appropriateness of using or redistributing the Work and assume any risks associated with Your exercise of permissions under this License.
|
Unless required by applicable law or agreed to in writing, Licensor provides the Work (and each Contributor provides its Contributions) on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied, including, without limitation, any warranties or conditions of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A PARTICULAR PURPOSE. You are solely responsible for determining the appropriateness of using or redistributing the Work and assume any risks associated with Your exercise of permissions under this License.
|
||||||
8. Limitation of Liability.
|
8. Limitation of Liability.
|
||||||
In no event and under no legal theory, whether in tort (including negligence), contract, or otherwise, unless required by applicable law (such as deliberate and grossly negligent acts) or agreed to in writing, shall any Contributor be liable to You for damages, including any direct, indirect, special, incidental, or consequential damages of any character arising as a result of this License or out of the use or inability to use the Work (including but not limited to damages for loss of goodwill, work stoppage, computer failure or malfunction, or any and all other commercial damages or losses), even if such Contributor has been advised of the possibility of such damages.
|
In no event and under no legal theory, whether in tort (including negligence), contract, or otherwise, unless required by applicable law (such as deliberate and grossly negligent acts) or agreed to in writing, shall any Contributor be liable to You for damages, including any direct, indirect, special, incidental, or consequential damages of any character arising as a result of this License or out of the use or inability to use the Work (including but not limited to damages for loss of goodwill, work stoppage, computer failure or malfunction, or any and all other commercial damages or losses), even if such Contributor has been advised of the possibility of such damages.
|
||||||
9. Accepting Warranty or Additional Liability.
|
9. Accepting Warranty or Additional Liability.
|
||||||
While redistributing the Work or Derivative Works thereof, You may choose to offer, and charge a fee for, acceptance of support, warranty, indemnity, or other liability obligations and/or rights consistent with this License. However, in accepting such obligations, You may act only on Your own behalf and on Your sole responsibility, not on behalf of any other Contributor, and only if You agree to indemnify, defend, and hold each Contributor harmless for any liability incurred by, or claims asserted against, such Contributor by reason of your accepting any such warranty or additional liability.
|
While redistributing the Work or Derivative Works thereof, You may choose to offer, and charge a fee for, acceptance of support, warranty, indemnity, or other liability obligations and/or rights consistent with this License. However, in accepting such obligations, You may act only on Your own behalf and on Your sole responsibility, not on behalf of any other Contributor, and only if You agree to indemnify, defend, and hold each Contributor harmless for any liability incurred by, or claims asserted against, such Contributor by reason of your accepting any such warranty or additional liability.
|
||||||
END OF TERMS AND CONDITIONS
|
END OF TERMS AND CONDITIONS
|
||||||
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 !
|
|
||||||
|
|||||||
@@ -1,8 +0,0 @@
|
|||||||
dist
|
|
||||||
node_modules
|
|
||||||
|
|
||||||
vite.config.ts
|
|
||||||
|
|
||||||
# compiled linguijs locales
|
|
||||||
# they no longer exist but we keep this to avoid issues with people still having those files on disk
|
|
||||||
src/locales/**/*.ts
|
|
||||||
@@ -1,47 +0,0 @@
|
|||||||
module.exports = {
|
|
||||||
env: {
|
|
||||||
browser: true,
|
|
||||||
es2021: true,
|
|
||||||
},
|
|
||||||
extends: [
|
|
||||||
"eslint:recommended",
|
|
||||||
"standard",
|
|
||||||
"plugin:@typescript-eslint/strict-type-checked",
|
|
||||||
"plugin:@typescript-eslint/stylistic-type-checked",
|
|
||||||
"plugin:react/recommended",
|
|
||||||
"plugin:react-hooks/recommended",
|
|
||||||
"plugin:prettier/recommended",
|
|
||||||
],
|
|
||||||
settings: {
|
|
||||||
react: {
|
|
||||||
version: "detect",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
overrides: [
|
|
||||||
{
|
|
||||||
env: {
|
|
||||||
node: true,
|
|
||||||
},
|
|
||||||
files: [".eslintrc.{js,cjs}"],
|
|
||||||
parserOptions: {
|
|
||||||
sourceType: "script",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
parserOptions: {
|
|
||||||
project: true,
|
|
||||||
ecmaVersion: "latest",
|
|
||||||
sourceType: "module",
|
|
||||||
},
|
|
||||||
plugins: ["react"],
|
|
||||||
rules: {
|
|
||||||
"@typescript-eslint/consistent-type-assertions": ["error", { assertionStyle: "as" }],
|
|
||||||
"@typescript-eslint/no-confusing-void-expression": ["error", { ignoreArrowShorthand: true }],
|
|
||||||
"@typescript-eslint/no-floating-promises": "off",
|
|
||||||
"@typescript-eslint/no-misused-promises": "off",
|
|
||||||
"@typescript-eslint/prefer-nullish-coalescing": ["error", { ignoreConditionalTests: true }],
|
|
||||||
"react/no-unescaped-entities": "off",
|
|
||||||
"react/react-in-jsx-scope": "off",
|
|
||||||
"react-hooks/exhaustive-deps": "error",
|
|
||||||
},
|
|
||||||
}
|
|
||||||
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,8 +0,0 @@
|
|||||||
{
|
|
||||||
"printWidth": 140,
|
|
||||||
"semi": false,
|
|
||||||
"tabWidth": 4,
|
|
||||||
"arrowParens": "avoid",
|
|
||||||
"endOfLine": "auto",
|
|
||||||
"trailingComma": "es5"
|
|
||||||
}
|
|
||||||
19
commafeed-client/biome.json
Normal file
19
commafeed-client/biome.json
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://biomejs.dev/schemas/2.4.7/schema.json",
|
||||||
|
"formatter": {
|
||||||
|
"indentStyle": "space",
|
||||||
|
"indentWidth": 4,
|
||||||
|
"lineEnding": "lf",
|
||||||
|
"lineWidth": 140
|
||||||
|
},
|
||||||
|
"javascript": {
|
||||||
|
"formatter": {
|
||||||
|
"trailingCommas": "es5",
|
||||||
|
"semicolons": "asNeeded",
|
||||||
|
"arrowParentheses": "asNeeded"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"files": {
|
||||||
|
"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"],
|
||||||
|
}
|
||||||
16809
commafeed-client/package-lock.json
generated
16809
commafeed-client/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -1,82 +1,86 @@
|
|||||||
{
|
{
|
||||||
"name": "commafeed-client",
|
"name": "commafeed-client",
|
||||||
"private": true,
|
"private": true,
|
||||||
"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",
|
||||||
"eslint": "eslint --ext=.js,.jsx,.ts,.tsx src",
|
"lint": "biome check",
|
||||||
"i18n:extract": "lingui extract --clean"
|
"lint:fix": "biome check --write",
|
||||||
},
|
"i18n:extract": "lingui extract --clean",
|
||||||
"dependencies": {
|
"prepare": "cd .. && husky ./commafeed-client/.husky"
|
||||||
"@emotion/react": "^11.11.4",
|
},
|
||||||
"@fontsource/open-sans": "^5.0.25",
|
"dependencies": {
|
||||||
"@lingui/core": "^4.7.1",
|
"@emotion/react": "^11.14.0",
|
||||||
"@lingui/macro": "^4.7.1",
|
"@fontsource/open-sans": "^5.2.7",
|
||||||
"@lingui/react": "^4.7.1",
|
"@lingui/core": "^5.9.3",
|
||||||
"@mantine/core": "^7.6.1",
|
"@lingui/react": "^5.9.3",
|
||||||
"@mantine/form": "^7.6.1",
|
"@mantine/core": "^8.3.16",
|
||||||
"@mantine/hooks": "^7.6.1",
|
"@mantine/form": "^8.3.16",
|
||||||
"@mantine/modals": "^7.6.1",
|
"@mantine/hooks": "^8.3.16",
|
||||||
"@mantine/notifications": "^7.6.1",
|
"@mantine/modals": "^8.3.16",
|
||||||
"@mantine/spotlight": "^7.6.1",
|
"@mantine/notifications": "^8.3.16",
|
||||||
"@monaco-editor/react": "^4.6.0",
|
"@mantine/spotlight": "^8.3.16",
|
||||||
"@reduxjs/toolkit": "^2.2.1",
|
"@monaco-editor/react": "^4.7.0",
|
||||||
"axios": "^1.6.7",
|
"@react-querybuilder/mantine": "^8.14.0",
|
||||||
"dayjs": "^1.11.10",
|
"@reduxjs/toolkit": "^2.11.2",
|
||||||
"escape-string-regexp": "^5.0.0",
|
"@rolldown/plugin-babel": "^0.2.2",
|
||||||
"interweave": "^13.1.0",
|
"axios": "^1.13.6",
|
||||||
"monaco-editor": "^0.46.0",
|
"dayjs": "^1.11.20",
|
||||||
"mousetrap": "^1.6.5",
|
"escape-string-regexp": "^5.0.0",
|
||||||
"react": "^18.2.0",
|
"interweave": "^13.1.1",
|
||||||
"react-async-hook": "^4.0.0",
|
"monaco-editor": "^0.55.1",
|
||||||
"react-contexify": "^6.0.0",
|
"mousetrap": "^1.6.5",
|
||||||
"react-dom": "^18.2.0",
|
"react": "^19.2.4",
|
||||||
"react-draggable": "^4.4.6",
|
"react-async-hook": "^4.0.0",
|
||||||
"react-ga4": "^2.1.0",
|
"react-contexify": "^6.0.0",
|
||||||
"react-icons": "^5.0.1",
|
"react-dom": "^19.2.4",
|
||||||
"react-infinite-scroller": "^1.2.6",
|
"react-draggable": "^4.5.0",
|
||||||
"react-redux": "^9.1.0",
|
"react-icons": "^5.6.0",
|
||||||
"react-router-dom": "^6.22.2",
|
"react-infinite-scroller": "^1.2.6",
|
||||||
"react-swipeable": "^7.0.1",
|
"react-querybuilder": "^8.14.0",
|
||||||
"redoc": "^2.1.3",
|
"react-redux": "^9.2.0",
|
||||||
"throttle-debounce": "^5.0.0",
|
"react-router-dom": "^7.13.1",
|
||||||
"tinycon": "^0.6.8",
|
"react-swipeable": "^7.0.2",
|
||||||
"tss-react": "^4.9.4",
|
"style-to-object": "^1.0.14",
|
||||||
"use-local-storage": "^3.0.0",
|
"throttle-debounce": "^5.0.2",
|
||||||
"websocket-heartbeat-js": "^1.1.3"
|
"tinycon": "^0.6.8",
|
||||||
},
|
"tss-react": "^4.9.20",
|
||||||
"devDependencies": {
|
"websocket-heartbeat-js": "^1.1.3"
|
||||||
"@lingui/cli": "^4.7.1",
|
},
|
||||||
"@lingui/vite-plugin": "^4.7.1",
|
"devDependencies": {
|
||||||
"@types/mousetrap": "^1.6.15",
|
"@biomejs/biome": "^2.4.7",
|
||||||
"@types/react": "^18.2.61",
|
"@lingui/babel-plugin-lingui-macro": "^5.9.3",
|
||||||
"@types/react-dom": "^18.2.19",
|
"@lingui/cli": "^5.9.3",
|
||||||
"@types/react-infinite-scroller": "^1.2.5",
|
"@lingui/vite-plugin": "^5.9.3",
|
||||||
"@types/swagger-ui-react": "^4.18.3",
|
"@testing-library/jest-dom": "^6.9.1",
|
||||||
"@types/throttle-debounce": "^5.0.2",
|
"@testing-library/react": "^16.3.2",
|
||||||
"@types/tinycon": "^0.6.5",
|
"@testing-library/user-event": "^14.6.1",
|
||||||
"@typescript-eslint/eslint-plugin": "^7.1.0",
|
"@types/mousetrap": "^1.6.15",
|
||||||
"@vitejs/plugin-react": "^4.2.1",
|
"@types/react": "^19.2.14",
|
||||||
"babel-plugin-macros": "^3.1.0",
|
"@types/react-dom": "^19.2.3",
|
||||||
"eslint": "^8.57.0",
|
"@types/react-infinite-scroller": "^1.2.5",
|
||||||
"eslint-config-prettier": "^9.1.0",
|
"@types/throttle-debounce": "^5.0.2",
|
||||||
"eslint-config-standard": "^17.1.0",
|
"@types/tinycon": "^0.6.7",
|
||||||
"eslint-plugin-prettier": "^5.1.3",
|
"@vitejs/plugin-react": "^6.0.1",
|
||||||
"eslint-plugin-react": "^7.34.0",
|
"babel-plugin-react-compiler": "1.0.0",
|
||||||
"eslint-plugin-react-hooks": "^4.6.0",
|
"husky": "^9.1.7",
|
||||||
"prettier": "^3.2.5",
|
"jsdom": "^29.0.0",
|
||||||
"rollup-plugin-visualizer": "^5.12.0",
|
"lint-staged": "^16.4.0",
|
||||||
"typescript": "^5.3.3",
|
"typescript": "^5.9.3",
|
||||||
"vite": "^5.1.4",
|
"vite": "^8.0.0",
|
||||||
"vite-plugin-eslint": "^1.8.1",
|
"vite-plugin-checker": "^0.12.0",
|
||||||
"vite-tsconfig-paths": "^4.3.1",
|
"vitest": "^4.1.0",
|
||||||
"vitest": "^1.3.1",
|
"yaml": "^2.8.2"
|
||||||
"vitest-mock-extended": "^1.3.1"
|
},
|
||||||
}
|
"overrides": {
|
||||||
|
"react-infinite-scroller": {
|
||||||
|
"react": "^19.2.4"
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,88 +1,142 @@
|
|||||||
<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>
|
|
||||||
<groupId>com.commafeed</groupId>
|
<parent>
|
||||||
<artifactId>commafeed</artifactId>
|
<groupId>com.commafeed</groupId>
|
||||||
<version>4.3.2</version>
|
<artifactId>commafeed</artifactId>
|
||||||
</parent>
|
<version>7.0.0</version>
|
||||||
<artifactId>commafeed-client</artifactId>
|
</parent>
|
||||||
<name>CommaFeed Client</name>
|
<artifactId>commafeed-client</artifactId>
|
||||||
|
<name>CommaFeed Client</name>
|
||||||
<build>
|
|
||||||
<plugins>
|
<properties>
|
||||||
<plugin>
|
<!-- renovate: datasource=node-version depName=node -->
|
||||||
<groupId>com.github.eirslett</groupId>
|
<node.version>v24.14.0</node.version>
|
||||||
<artifactId>frontend-maven-plugin</artifactId>
|
<!-- renovate: datasource=npm depName=npm -->
|
||||||
<version>1.15.0</version>
|
<npm.version>11.11.1</npm.version>
|
||||||
<?m2e ignore?>
|
</properties>
|
||||||
<executions>
|
|
||||||
<execution>
|
<build>
|
||||||
<id>install node and npm</id>
|
<plugins>
|
||||||
<goals>
|
<plugin>
|
||||||
<goal>install-node-and-npm</goal>
|
<groupId>com.github.eirslett</groupId>
|
||||||
</goals>
|
<artifactId>frontend-maven-plugin</artifactId>
|
||||||
<phase>compile</phase>
|
<version>2.0.0</version>
|
||||||
<configuration>
|
<?m2e ignore?>
|
||||||
<nodeVersion>v20.10.0</nodeVersion>
|
<executions>
|
||||||
<npmVersion>10.2.5</npmVersion>
|
<execution>
|
||||||
</configuration>
|
<id>install node and npm</id>
|
||||||
</execution>
|
<goals>
|
||||||
<execution>
|
<goal>install-node-and-npm</goal>
|
||||||
<id>npm install</id>
|
</goals>
|
||||||
<goals>
|
<phase>compile</phase>
|
||||||
<goal>npm</goal>
|
<configuration>
|
||||||
</goals>
|
<nodeVersion>${node.version}</nodeVersion>
|
||||||
<phase>compile</phase>
|
<npmVersion>${npm.version}</npmVersion>
|
||||||
<configuration>
|
</configuration>
|
||||||
<arguments>ci</arguments>
|
</execution>
|
||||||
</configuration>
|
<execution>
|
||||||
</execution>
|
<id>npm install</id>
|
||||||
<execution>
|
<goals>
|
||||||
<id>npm run test</id>
|
<goal>npm</goal>
|
||||||
<goals>
|
</goals>
|
||||||
<goal>npm</goal>
|
<phase>compile</phase>
|
||||||
</goals>
|
<configuration>
|
||||||
<phase>compile</phase>
|
<arguments>ci</arguments>
|
||||||
<configuration>
|
</configuration>
|
||||||
<arguments>run test:ci</arguments>
|
</execution>
|
||||||
</configuration>
|
<execution>
|
||||||
</execution>
|
<id>npm run test</id>
|
||||||
<execution>
|
<goals>
|
||||||
<id>npm run build</id>
|
<goal>npm</goal>
|
||||||
<goals>
|
</goals>
|
||||||
<goal>npm</goal>
|
<phase>compile</phase>
|
||||||
</goals>
|
<configuration>
|
||||||
<phase>compile</phase>
|
<arguments>run test:ci</arguments>
|
||||||
<configuration>
|
<skip>${skipTests}</skip>
|
||||||
<arguments>run build</arguments>
|
</configuration>
|
||||||
</configuration>
|
</execution>
|
||||||
</execution>
|
<execution>
|
||||||
</executions>
|
<id>npm run build</id>
|
||||||
</plugin>
|
<goals>
|
||||||
<plugin>
|
<goal>npm</goal>
|
||||||
<artifactId>maven-resources-plugin</artifactId>
|
</goals>
|
||||||
<version>3.3.1</version>
|
<phase>compile</phase>
|
||||||
<executions>
|
<configuration>
|
||||||
<execution>
|
<arguments>run build</arguments>
|
||||||
<id>copy web interface to resources</id>
|
</configuration>
|
||||||
<phase>prepare-package</phase>
|
</execution>
|
||||||
<goals>
|
</executions>
|
||||||
<goal>copy-resources</goal>
|
</plugin>
|
||||||
</goals>
|
<plugin>
|
||||||
<configuration>
|
<artifactId>maven-resources-plugin</artifactId>
|
||||||
<outputDirectory>${project.build.directory}/classes/assets</outputDirectory>
|
<version>3.5.0</version>
|
||||||
<resources>
|
<executions>
|
||||||
<resource>
|
<execution>
|
||||||
<directory>dist</directory>
|
<id>copy web interface to resources</id>
|
||||||
<filtering>false</filtering>
|
<phase>prepare-package</phase>
|
||||||
</resource>
|
<goals>
|
||||||
</resources>
|
<goal>copy-resources</goal>
|
||||||
</configuration>
|
</goals>
|
||||||
</execution>
|
<configuration>
|
||||||
</executions>
|
<outputDirectory>${project.build.directory}/classes/META-INF/resources</outputDirectory>
|
||||||
</plugin>
|
<resources>
|
||||||
</plugins>
|
<resource>
|
||||||
</build>
|
<directory>dist</directory>
|
||||||
|
<filtering>false</filtering>
|
||||||
|
</resource>
|
||||||
|
</resources>
|
||||||
|
</configuration>
|
||||||
|
</execution>
|
||||||
|
</executions>
|
||||||
|
</plugin>
|
||||||
|
</plugins>
|
||||||
|
</build>
|
||||||
|
|
||||||
|
<profiles>
|
||||||
|
<!-- This profile is used to kill the Biome process on Windows -->
|
||||||
|
<!-- npm ci can fail if Biome is running (e.g., in the IDE) because it locks some files -->
|
||||||
|
<profile>
|
||||||
|
<id>kill-biome</id>
|
||||||
|
<activation>
|
||||||
|
<os>
|
||||||
|
<family>Windows</family>
|
||||||
|
</os>
|
||||||
|
</activation>
|
||||||
|
<build>
|
||||||
|
<plugins>
|
||||||
|
<plugin>
|
||||||
|
<groupId>org.codehaus.mojo</groupId>
|
||||||
|
<artifactId>exec-maven-plugin</artifactId>
|
||||||
|
<version>3.6.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 |
@@ -1,46 +1,55 @@
|
|||||||
import { i18n } from "@lingui/core"
|
import { i18n } from "@lingui/core"
|
||||||
import { I18nProvider } from "@lingui/react"
|
import { I18nProvider } from "@lingui/react"
|
||||||
import { MantineProvider } from "@mantine/core"
|
import { MantineProvider } from "@mantine/core"
|
||||||
import { useDidUpdate } from "@mantine/hooks"
|
|
||||||
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 { 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 { 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 { WelcomePage } from "pages/WelcomePage"
|
|
||||||
import React, { useEffect, useRef } from "react"
|
|
||||||
import ReactGA from "react-ga4"
|
|
||||||
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
|
||||||
@@ -69,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)
|
||||||
|
|
||||||
@@ -79,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" />} />
|
||||||
@@ -110,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()
|
||||||
@@ -125,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
|
||||||
}
|
}
|
||||||
@@ -166,14 +180,12 @@ function BrowserExtensionBadgeUnreadCountHandler() {
|
|||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
function CustomJs() {
|
function CustomJsHandler() {
|
||||||
const scriptLoaded = useRef(false)
|
const [scriptLoaded, setScriptLoaded] = useState(false)
|
||||||
|
const { loading } = useAppLoading()
|
||||||
|
|
||||||
// useDidUpdate is used instead of useEffect because we want to skip the first render
|
useEffect(() => {
|
||||||
// the first render is the render of react-router, the routes are actually loaded in a second render
|
if (scriptLoaded || loading) {
|
||||||
// we want the script to be executed when the first route is done loading
|
|
||||||
useDidUpdate(() => {
|
|
||||||
if (scriptLoaded.current) {
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -182,19 +194,23 @@ function CustomJs() {
|
|||||||
script.async = true
|
script.async = true
|
||||||
document.body.appendChild(script)
|
document.body.appendChild(script)
|
||||||
|
|
||||||
scriptLoaded.current = true
|
setScriptLoaded(true)
|
||||||
})
|
|
||||||
|
return () => script.remove()
|
||||||
|
}, [scriptLoaded, loading])
|
||||||
|
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
function CustomCss() {
|
function CustomCssHandler() {
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const link = document.createElement("link")
|
const link = document.createElement("link")
|
||||||
link.rel = "stylesheet"
|
link.rel = "stylesheet"
|
||||||
link.type = "text/css"
|
link.type = "text/css"
|
||||||
link.href = "custom_css.css"
|
link.href = "custom_css.css"
|
||||||
document.head.appendChild(link)
|
document.head.appendChild(link)
|
||||||
|
|
||||||
|
return () => link.remove()
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
return null
|
return null
|
||||||
@@ -202,6 +218,9 @@ function CustomCss() {
|
|||||||
|
|
||||||
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(() => {
|
||||||
@@ -210,17 +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 />
|
|
||||||
<CustomJs />
|
<HashRouter>
|
||||||
<CustomCss />
|
<InitialSetupHandler />
|
||||||
</HashRouter>
|
<RedirectHandler />
|
||||||
</>
|
<AppRoutes />
|
||||||
|
</HashRouter>
|
||||||
</Providers>
|
</Providers>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { createAsyncThunk } from "@reduxjs/toolkit"
|
import { createAsyncThunk } from "@reduxjs/toolkit"
|
||||||
import { type AppDispatch, type 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
|
||||||
dispatch: AppDispatch
|
dispatch: AppDispatch
|
||||||
}>()
|
}>()
|
||||||
|
|||||||
@@ -1,127 +1,145 @@
|
|||||||
import axios, { AxiosError } from "axios"
|
import axios, { type AxiosError } from "axios"
|
||||||
import {
|
import type {
|
||||||
type AddCategoryRequest,
|
AddCategoryRequest,
|
||||||
type AdminSaveUserRequest,
|
AdminSaveUserRequest,
|
||||||
AuthenticationError,
|
AuthenticationError,
|
||||||
type Category,
|
Category,
|
||||||
type CategoryModificationRequest,
|
CategoryModificationRequest,
|
||||||
type CollapseRequest,
|
CollapseRequest,
|
||||||
type Entries,
|
Entries,
|
||||||
type FeedInfo,
|
FeedInfo,
|
||||||
type FeedInfoRequest,
|
FeedInfoRequest,
|
||||||
type FeedModificationRequest,
|
FeedModificationRequest,
|
||||||
type GetEntriesPaginatedRequest,
|
GetEntriesPaginatedRequest,
|
||||||
type IDRequest,
|
IDRequest,
|
||||||
type LoginRequest,
|
InitialSetupRequest,
|
||||||
type MarkRequest,
|
LoginRequest,
|
||||||
type Metrics,
|
MarkRequest,
|
||||||
type MultipleMarkRequest,
|
Metrics,
|
||||||
type PasswordResetRequest,
|
MultipleMarkRequest,
|
||||||
type ProfileModificationRequest,
|
PasswordResetConfirmationRequest,
|
||||||
type RegistrationRequest,
|
PasswordResetRequest,
|
||||||
type ServerInfo,
|
ProfileModificationRequest,
|
||||||
type Settings,
|
PushNotificationSettings,
|
||||||
type StarRequest,
|
RegistrationRequest,
|
||||||
type SubscribeRequest,
|
ServerInfo,
|
||||||
type Subscription,
|
Settings,
|
||||||
type TagRequest,
|
StarRequest,
|
||||||
type UserModel,
|
SubscribeRequest,
|
||||||
} from "./types"
|
Subscription,
|
||||||
|
TagRequest,
|
||||||
const axiosInstance = axios.create({ baseURL: "./rest", withCredentials: true })
|
UserModel,
|
||||||
axiosInstance.interceptors.response.use(
|
} from "./types"
|
||||||
response => response,
|
|
||||||
error => {
|
const axiosInstance = axios.create({ baseURL: "./rest", withCredentials: true })
|
||||||
if (isAuthenticationError(error)) {
|
axiosInstance.interceptors.response.use(
|
||||||
const data = error.response?.data
|
response => response,
|
||||||
window.location.hash = data?.allowRegistrations ? "/welcome" : "/login"
|
error => {
|
||||||
}
|
if (isAuthenticationError(error) && window.location.hash !== "#/login") {
|
||||||
throw error
|
const data = error.response?.data
|
||||||
}
|
window.location.hash = data?.allowRegistrations ? "/welcome" : "/login"
|
||||||
)
|
window.location.reload()
|
||||||
|
}
|
||||||
function isAuthenticationError(error: unknown): error is AxiosError<AuthenticationError> {
|
throw error
|
||||||
return axios.isAxiosError(error) && !!error.response && [401, 403].includes(error.response.status)
|
}
|
||||||
}
|
)
|
||||||
|
|
||||||
export const client = {
|
function isAuthenticationError(error: unknown): error is AxiosError<AuthenticationError> {
|
||||||
category: {
|
return axios.isAxiosError(error) && error.response?.status === 401
|
||||||
getRoot: async () => await axiosInstance.get<Category>("category/get"),
|
}
|
||||||
modify: async (req: CategoryModificationRequest) => await axiosInstance.post("category/modify", req),
|
|
||||||
collapse: async (req: CollapseRequest) => await axiosInstance.post("category/collapse", req),
|
export const client = {
|
||||||
getEntries: async (req: GetEntriesPaginatedRequest) => await axiosInstance.get<Entries>("category/entries", { params: req }),
|
category: {
|
||||||
markEntries: async (req: MarkRequest) => await axiosInstance.post("category/mark", req),
|
getRoot: async () => await axiosInstance.get<Category>("category/get"),
|
||||||
add: async (req: AddCategoryRequest) => await axiosInstance.post("category/add", req),
|
modify: async (req: CategoryModificationRequest) => await axiosInstance.post("category/modify", req),
|
||||||
delete: async (req: IDRequest) => await axiosInstance.post("category/delete", req),
|
collapse: async (req: CollapseRequest) => await axiosInstance.post("category/collapse", req),
|
||||||
},
|
getEntries: async (req: GetEntriesPaginatedRequest) => await axiosInstance.get<Entries>("category/entries", { params: req }),
|
||||||
entry: {
|
markEntries: async (req: MarkRequest) => await axiosInstance.post("category/mark", req),
|
||||||
mark: async (req: MarkRequest) => await axiosInstance.post("entry/mark", req),
|
add: async (req: AddCategoryRequest) => await axiosInstance.post("category/add", req),
|
||||||
markMultiple: async (req: MultipleMarkRequest) => await axiosInstance.post("entry/markMultiple", req),
|
delete: async (req: IDRequest) => await axiosInstance.post("category/delete", req),
|
||||||
star: async (req: StarRequest) => await axiosInstance.post("entry/star", req),
|
},
|
||||||
getTags: async () => await axiosInstance.get<string[]>("entry/tags"),
|
entry: {
|
||||||
tag: async (req: TagRequest) => await axiosInstance.post("entry/tag", req),
|
mark: async (req: MarkRequest) => await axiosInstance.post("entry/mark", req),
|
||||||
},
|
markMultiple: async (req: MultipleMarkRequest) => await axiosInstance.post("entry/markMultiple", req),
|
||||||
feed: {
|
star: async (req: StarRequest) => await axiosInstance.post("entry/star", req),
|
||||||
get: async (id: string) => await axiosInstance.get<Subscription>(`feed/get/${id}`),
|
getTags: async () => await axiosInstance.get<string[]>("entry/tags"),
|
||||||
modify: async (req: FeedModificationRequest) => await axiosInstance.post("feed/modify", req),
|
tag: async (req: TagRequest) => await axiosInstance.post("entry/tag", req),
|
||||||
getEntries: async (req: GetEntriesPaginatedRequest) => await axiosInstance.get<Entries>("feed/entries", { params: req }),
|
},
|
||||||
markEntries: async (req: MarkRequest) => await axiosInstance.post("feed/mark", req),
|
feed: {
|
||||||
fetchFeed: async (req: FeedInfoRequest) => await axiosInstance.post<FeedInfo>("feed/fetch", req),
|
get: async (id: string) => await axiosInstance.get<Subscription>(`feed/get/${id}`),
|
||||||
refreshAll: async () => await axiosInstance.get("feed/refreshAll"),
|
modify: async (req: FeedModificationRequest) => await axiosInstance.post("feed/modify", req),
|
||||||
subscribe: async (req: SubscribeRequest) => await axiosInstance.post<number>("feed/subscribe", req),
|
getEntries: async (req: GetEntriesPaginatedRequest) => await axiosInstance.get<Entries>("feed/entries", { params: req }),
|
||||||
unsubscribe: async (req: IDRequest) => await axiosInstance.post("feed/unsubscribe", req),
|
markEntries: async (req: MarkRequest) => await axiosInstance.post("feed/mark", req),
|
||||||
importOpml: async (req: File) => {
|
fetchFeed: async (req: FeedInfoRequest) => await axiosInstance.post<FeedInfo>("feed/fetch", req),
|
||||||
const formData = new FormData()
|
refreshAll: async () => await axiosInstance.get("feed/refreshAll"),
|
||||||
formData.append("file", req)
|
subscribe: async (req: SubscribeRequest) => await axiosInstance.post<number>("feed/subscribe", req),
|
||||||
return await axiosInstance.post("feed/import", formData, {
|
unsubscribe: async (req: IDRequest) => await axiosInstance.post("feed/unsubscribe", req),
|
||||||
headers: {
|
importOpml: async (req: File) => {
|
||||||
"Content-Type": "multipart/form-data",
|
const formData = new FormData()
|
||||||
},
|
formData.append("file", req)
|
||||||
})
|
return await axiosInstance.post("feed/import", formData, {
|
||||||
},
|
headers: {
|
||||||
},
|
"Content-Type": "multipart/form-data",
|
||||||
user: {
|
},
|
||||||
login: async (req: LoginRequest) => await axiosInstance.post("user/login", req),
|
})
|
||||||
register: async (req: RegistrationRequest) => await axiosInstance.post("user/register", req),
|
},
|
||||||
passwordReset: async (req: PasswordResetRequest) => await axiosInstance.post("user/passwordReset", req),
|
},
|
||||||
getSettings: async () => await axiosInstance.get<Settings>("user/settings"),
|
user: {
|
||||||
saveSettings: async (settings: Settings) => await axiosInstance.post("user/settings", settings),
|
login: async (req: LoginRequest) => {
|
||||||
getProfile: async () => await axiosInstance.get<UserModel>("user/profile"),
|
const formData = new URLSearchParams()
|
||||||
saveProfile: async (req: ProfileModificationRequest) => await axiosInstance.post("user/profile", req),
|
formData.append("j_username", req.name)
|
||||||
deleteProfile: async () => await axiosInstance.post("user/profile/deleteAccount"),
|
formData.append("j_password", req.password)
|
||||||
},
|
return await axiosInstance.post("j_security_check", formData, {
|
||||||
server: {
|
baseURL: ".",
|
||||||
getServerInfos: async () => await axiosInstance.get<ServerInfo>("server/get"),
|
headers: {
|
||||||
},
|
"Content-Type": "application/x-www-form-urlencoded",
|
||||||
admin: {
|
},
|
||||||
getAllUsers: async () => await axiosInstance.get<UserModel[]>("admin/user/getAll"),
|
})
|
||||||
saveUser: async (req: AdminSaveUserRequest) => await axiosInstance.post("admin/user/save", req),
|
},
|
||||||
deleteUser: async (req: IDRequest) => await axiosInstance.post("admin/user/delete", req),
|
register: async (req: RegistrationRequest) => await axiosInstance.post("user/register", req),
|
||||||
getMetrics: async () => await axiosInstance.get<Metrics>("admin/metrics"),
|
initialSetup: async (req: InitialSetupRequest) => await axiosInstance.post("user/initialSetup", 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"),
|
||||||
/**
|
saveSettings: async (settings: Settings) => await axiosInstance.post("user/settings", settings),
|
||||||
* transform an error object to an array of strings that can be displayed to the user
|
sendTestPushNotification: async (settings: PushNotificationSettings) =>
|
||||||
* @param err an error object (e.g. from axios)
|
await axiosInstance.post("user/pushNotificationTest", settings),
|
||||||
* @returns an array of messages to show the user
|
getProfile: async () => await axiosInstance.get<UserModel>("user/profile"),
|
||||||
*/
|
saveProfile: async (req: ProfileModificationRequest) => await axiosInstance.post("user/profile", req),
|
||||||
export const errorToStrings = (err: unknown) => {
|
deleteProfile: async () => await axiosInstance.post("user/profile/deleteAccount"),
|
||||||
let strings: string[] = []
|
},
|
||||||
|
server: {
|
||||||
if (axios.isAxiosError(err) && err.response) {
|
getServerInfos: async () => await axiosInstance.get<ServerInfo>("server/get"),
|
||||||
if (typeof err.response.data === "string") strings.push(err.response.data)
|
},
|
||||||
if (isMessageError(err)) strings.push(err.response.data.message)
|
admin: {
|
||||||
if (isMessageArrayError(err)) strings = [...strings, ...err.response.data.errors]
|
getAllUsers: async () => await axiosInstance.get<UserModel[]>("admin/user/getAll"),
|
||||||
}
|
saveUser: async (req: AdminSaveUserRequest) => await axiosInstance.post<number>("admin/user/save", req),
|
||||||
|
deleteUser: async (req: IDRequest) => await axiosInstance.post("admin/user/delete", req),
|
||||||
return strings
|
getMetrics: async () => await axiosInstance.get<Metrics>("admin/metrics"),
|
||||||
}
|
},
|
||||||
|
}
|
||||||
function isMessageError(err: AxiosError): err is AxiosError<{ message: string }> {
|
|
||||||
return !!err.response && !!err.response.data && typeof err.response.data === "object" && "message" in err.response.data
|
/**
|
||||||
}
|
* transform an error object to an array of strings that can be displayed to the user
|
||||||
|
* @param err an error object (e.g. from axios)
|
||||||
function isMessageArrayError(err: AxiosError): err is AxiosError<{ errors: string[] }> {
|
* @returns an array of messages to show the user
|
||||||
return !!err.response && !!err.response.data && typeof err.response.data === "object" && "errors" in err.response.data
|
*/
|
||||||
}
|
export const errorToStrings = (err: unknown) => {
|
||||||
|
let strings: string[] = []
|
||||||
|
|
||||||
|
if (axios.isAxiosError(err) && err.response) {
|
||||||
|
if (typeof err.response.data === "string") strings.push(err.response.data)
|
||||||
|
if (isMessageError(err)) strings.push(err.response.data.message)
|
||||||
|
if (isMessageArrayError(err)) strings = [...strings, ...err.response.data.errors]
|
||||||
|
}
|
||||||
|
|
||||||
|
return strings
|
||||||
|
}
|
||||||
|
|
||||||
|
function isMessageError(err: AxiosError): err is AxiosError<{ message: string }> {
|
||||||
|
return !!err.response && !!err.response.data && typeof err.response.data === "object" && "message" in err.response.data
|
||||||
|
}
|
||||||
|
|
||||||
|
function isMessageArrayError(err: AxiosError): err is AxiosError<{ errors: string[] }> {
|
||||||
|
return !!err.response && !!err.response.data && typeof err.response.data === "object" && "errors" in err.response.data
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,109 +1,119 @@
|
|||||||
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, SiX } from "react-icons/si"
|
||||||
import { SiBuffer, SiFacebook, SiGmail, SiInstapaper, SiPocket, SiTumblr, SiTwitter } from "react-icons/si"
|
import type { Category, Entry, SharingSettings } from "./types"
|
||||||
import { type Category, type Entry, type SharingSettings } from "./types"
|
|
||||||
|
const categories: Record<string, Omit<Category, "name">> = {
|
||||||
const categories: Record<string, Category> = {
|
all: {
|
||||||
all: {
|
id: "all",
|
||||||
id: "all",
|
expanded: false,
|
||||||
name: t`All`,
|
children: [],
|
||||||
expanded: false,
|
feeds: [],
|
||||||
children: [],
|
position: 0,
|
||||||
feeds: [],
|
},
|
||||||
position: 0,
|
starred: {
|
||||||
},
|
id: "starred",
|
||||||
starred: {
|
expanded: false,
|
||||||
id: "starred",
|
children: [],
|
||||||
name: t`Starred`,
|
feeds: [],
|
||||||
expanded: false,
|
position: 1,
|
||||||
children: [],
|
},
|
||||||
feeds: [],
|
infrequent: {
|
||||||
position: 1,
|
id: "infrequent",
|
||||||
},
|
expanded: false,
|
||||||
}
|
children: [],
|
||||||
|
feeds: [],
|
||||||
const sharing: {
|
position: 2,
|
||||||
[key in keyof SharingSettings]: {
|
},
|
||||||
label: string
|
}
|
||||||
icon: IconType
|
|
||||||
color: `#${string}`
|
const sharing: {
|
||||||
url: (url: string, description: string) => string
|
[key in keyof SharingSettings]: {
|
||||||
}
|
label: string
|
||||||
} = {
|
icon: IconType
|
||||||
email: {
|
color: `#${string}`
|
||||||
label: "Email",
|
url: (url: string, description: string) => string
|
||||||
icon: FaAt,
|
}
|
||||||
color: "#000000",
|
} = {
|
||||||
url: (url, desc) => `mailto:?subject=${desc}&body=${url}`,
|
email: {
|
||||||
},
|
label: "Email",
|
||||||
gmail: {
|
icon: FaAt,
|
||||||
label: "Gmail",
|
color: "#000000",
|
||||||
icon: SiGmail,
|
url: (url, desc) => `mailto:?subject=${desc}&body=${url}`,
|
||||||
color: "#EA4335",
|
},
|
||||||
url: (url, desc) => `https://mail.google.com/mail/?view=cm&fs=1&tf=1&source=mailto&su=${desc}&body=${url}`,
|
gmail: {
|
||||||
},
|
label: "Gmail",
|
||||||
facebook: {
|
icon: SiGmail,
|
||||||
label: "Facebook",
|
color: "#EA4335",
|
||||||
icon: SiFacebook,
|
url: (url, desc) => `https://mail.google.com/mail/?view=cm&fs=1&tf=1&source=mailto&su=${desc}&body=${url}`,
|
||||||
color: "#1B74E4",
|
},
|
||||||
url: url => `https://www.facebook.com/sharer/sharer.php?u=${url}`,
|
facebook: {
|
||||||
},
|
label: "Facebook",
|
||||||
twitter: {
|
icon: SiFacebook,
|
||||||
label: "Twitter",
|
color: "#1B74E4",
|
||||||
icon: SiTwitter,
|
url: url => `https://www.facebook.com/sharer/sharer.php?u=${url}`,
|
||||||
color: "#1D9BF0",
|
},
|
||||||
url: (url, desc) => `https://twitter.com/share?text=${desc}&url=${url}`,
|
twitter: {
|
||||||
},
|
label: "X",
|
||||||
tumblr: {
|
icon: SiX,
|
||||||
label: "Tumblr",
|
color: "#000000",
|
||||||
icon: SiTumblr,
|
url: (url, desc) => `https://x.com/share?text=${desc}&url=${url}`,
|
||||||
color: "#375672",
|
},
|
||||||
url: (url, desc) => `https://www.tumblr.com/share/link?url=${url}&name=${desc}`,
|
tumblr: {
|
||||||
},
|
label: "Tumblr",
|
||||||
pocket: {
|
icon: SiTumblr,
|
||||||
label: "Pocket",
|
color: "#375672",
|
||||||
icon: SiPocket,
|
url: (url, desc) => `https://www.tumblr.com/share/link?url=${url}&name=${desc}`,
|
||||||
color: "#EF4154",
|
},
|
||||||
url: (url, desc) => `https://getpocket.com/save?url=${url}&title=${desc}`,
|
pocket: {
|
||||||
},
|
label: "Pocket",
|
||||||
instapaper: {
|
icon: SiPocket,
|
||||||
label: "Instapaper",
|
color: "#EF4154",
|
||||||
icon: SiInstapaper,
|
url: (url, desc) => `https://getpocket.com/save?url=${url}&title=${desc}`,
|
||||||
color: "#010101",
|
},
|
||||||
url: (url, desc) => `https://www.instapaper.com/hello2?url=${url}&title=${desc}`,
|
instapaper: {
|
||||||
},
|
label: "Instapaper",
|
||||||
buffer: {
|
icon: SiInstapaper,
|
||||||
label: "Buffer",
|
color: "#010101",
|
||||||
icon: SiBuffer,
|
url: (url, desc) => `https://www.instapaper.com/hello2?url=${url}&title=${desc}`,
|
||||||
color: "#000000",
|
},
|
||||||
url: (url, desc) => `https://bufferapp.com/add?url=${url}&text=${desc}`,
|
buffer: {
|
||||||
},
|
label: "Buffer",
|
||||||
}
|
icon: SiBuffer,
|
||||||
|
color: "#000000",
|
||||||
export const Constants = {
|
url: (url, desc) => `https://bufferapp.com/add?url=${url}&text=${desc}`,
|
||||||
categories,
|
},
|
||||||
sharing,
|
}
|
||||||
layout: {
|
|
||||||
mobileBreakpoint: 992,
|
export const Constants = {
|
||||||
mobileBreakpointName: "md",
|
categories,
|
||||||
headerHeight: 60,
|
sharing,
|
||||||
entryMaxWidth: 650,
|
layout: {
|
||||||
isTopVisible: (div: HTMLElement) => {
|
mobileBreakpoint: 992,
|
||||||
const header = document.getElementById(Constants.dom.headerId)?.getBoundingClientRect()
|
mobileBreakpointName: "md",
|
||||||
return div.getBoundingClientRect().top >= (header?.bottom ?? 0)
|
headerHeight: 60,
|
||||||
},
|
entryMaxWidth: 650,
|
||||||
isBottomVisible: (div: HTMLElement) => {
|
isTopVisible: (div: HTMLElement) => {
|
||||||
const footer = document.getElementById(Constants.dom.footerId)?.getBoundingClientRect()
|
const header = document.getElementsByTagName("header").item(0)?.getBoundingClientRect()
|
||||||
return div.getBoundingClientRect().bottom <= (footer?.top ?? window.innerHeight)
|
return div.getBoundingClientRect().top >= (header?.bottom ?? 0)
|
||||||
},
|
},
|
||||||
},
|
isBottomVisible: (div: HTMLElement) => {
|
||||||
dom: {
|
const footer = document.getElementsByTagName("footer").item(0)?.getBoundingClientRect()
|
||||||
headerId: "header",
|
return div.getBoundingClientRect().bottom <= (footer?.top ?? window.innerHeight)
|
||||||
footerId: "footer",
|
},
|
||||||
entryId: (entry: Entry) => `entry-id-${entry.id}`,
|
},
|
||||||
entryContextMenuId: (entry: Entry) => entry.id,
|
dom: {
|
||||||
},
|
entryId: (entry: Entry) => `entry-id-${entry.id}`,
|
||||||
browserExtensionUrl: "https://github.com/Athou/commafeed-browser-extension",
|
entryContextMenuId: (entry: Entry) => entry.id,
|
||||||
bitcoinWalletAddress: "1dymfUxqCWpyD7a6rQSqNy4rLVDBsAr5e",
|
},
|
||||||
}
|
theme: {
|
||||||
|
defaultPrimaryColor: "orange",
|
||||||
|
},
|
||||||
|
tooltip: {
|
||||||
|
delay: 500,
|
||||||
|
},
|
||||||
|
infrequentThresholdDaysDefault: 7,
|
||||||
|
browserExtensionUrl: "https://github.com/Athou/commafeed-browser-extension",
|
||||||
|
customCssDocumentationUrl: "https://athou.github.io/commafeed/documentation/custom-css",
|
||||||
|
bitcoinWalletAddress: "1dymfUxqCWpyD7a6rQSqNy4rLVDBsAr5e",
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,145 +1,153 @@
|
|||||||
import { configureStore } from "@reduxjs/toolkit"
|
import { configureStore } from "@reduxjs/toolkit"
|
||||||
import { type client } from "app/client"
|
import type { AxiosResponse } from "axios"
|
||||||
import { loadEntries, loadMoreEntries, markAllEntries, markEntry } from "app/entries/thunks"
|
import { beforeEach, describe, expect, it, vi } from "vitest"
|
||||||
import { reducers, type RootState } from "app/store"
|
import { client } from "@/app/client"
|
||||||
import { type Entries, type Entry } from "app/types"
|
import { loadEntries, loadMoreEntries, markAllEntries, markEntry } from "@/app/entries/thunks"
|
||||||
import { type AxiosResponse } from "axios"
|
import { type RootState, reducers } from "@/app/store"
|
||||||
import { beforeEach, describe, expect, it, vi } from "vitest"
|
import type { Entries, Entry } from "@/app/types"
|
||||||
import { mockReset } from "vitest-mock-extended"
|
|
||||||
|
vi.mock(import("@/app/client"))
|
||||||
const mockClient = await vi.hoisted(async () => {
|
|
||||||
const mockModule = await import("vitest-mock-extended")
|
describe("entries", () => {
|
||||||
return mockModule.mockDeep<typeof client>()
|
beforeEach(() => {
|
||||||
})
|
vi.resetAllMocks()
|
||||||
vi.mock("app/client", () => ({ client: mockClient }))
|
})
|
||||||
|
|
||||||
describe("entries", () => {
|
it("loads entries", async () => {
|
||||||
beforeEach(() => {
|
vi.mocked(client.feed.getEntries).mockResolvedValue({
|
||||||
mockReset(mockClient)
|
data: {
|
||||||
})
|
entries: [{ id: "3" } as Entry],
|
||||||
|
hasMore: false,
|
||||||
it("loads entries", async () => {
|
name: "my-feed",
|
||||||
mockClient.feed.getEntries.mockResolvedValue({
|
errorCount: 3,
|
||||||
data: {
|
feedLink: "https://mysite.com/feed",
|
||||||
entries: [{ id: "3" } as Entry],
|
timestamp: 123,
|
||||||
hasMore: false,
|
ignoredReadStatus: false,
|
||||||
name: "my-feed",
|
},
|
||||||
errorCount: 3,
|
} as AxiosResponse<Entries>)
|
||||||
feedLink: "https://mysite.com/feed",
|
|
||||||
timestamp: 123,
|
const store = configureStore({ reducer: reducers })
|
||||||
ignoredReadStatus: false,
|
const promise = store.dispatch(
|
||||||
},
|
loadEntries({
|
||||||
} as AxiosResponse<Entries>)
|
source: { type: "feed", id: "feed-id" },
|
||||||
|
clearSearch: true,
|
||||||
const store = configureStore({ reducer: reducers })
|
})
|
||||||
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")
|
||||||
expect(store.getState().entries.entries).toStrictEqual([])
|
expect(store.getState().entries.entries).toStrictEqual([])
|
||||||
expect(store.getState().entries.hasMore).toBe(true)
|
expect(store.getState().entries.hasMore).toBe(true)
|
||||||
expect(store.getState().entries.sourceLabel).toBe("")
|
expect(store.getState().entries.sourceLabel).toBe("")
|
||||||
expect(store.getState().entries.sourceWebsiteUrl).toBe("")
|
expect(store.getState().entries.sourceWebsiteUrl).toBe("")
|
||||||
expect(store.getState().entries.timestamp).toBeUndefined()
|
expect(store.getState().entries.timestamp).toBeUndefined()
|
||||||
|
|
||||||
await promise
|
await promise
|
||||||
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")
|
||||||
expect(store.getState().entries.entries).toStrictEqual([{ id: "3" }])
|
expect(store.getState().entries.entries).toStrictEqual([{ id: "3" }])
|
||||||
expect(store.getState().entries.hasMore).toBe(false)
|
expect(store.getState().entries.hasMore).toBe(false)
|
||||||
expect(store.getState().entries.sourceLabel).toBe("my-feed")
|
expect(store.getState().entries.sourceLabel).toBe("my-feed")
|
||||||
expect(store.getState().entries.sourceWebsiteUrl).toBe("https://mysite.com/feed")
|
expect(store.getState().entries.sourceWebsiteUrl).toBe("https://mysite.com/feed")
|
||||||
expect(store.getState().entries.timestamp).toBe(123)
|
expect(store.getState().entries.timestamp).toBe(123)
|
||||||
})
|
})
|
||||||
|
|
||||||
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,
|
||||||
name: "my-feed",
|
name: "my-feed",
|
||||||
errorCount: 3,
|
errorCount: 3,
|
||||||
feedLink: "https://mysite.com/feed",
|
feedLink: "https://mysite.com/feed",
|
||||||
timestamp: 123,
|
timestamp: 123,
|
||||||
ignoredReadStatus: false,
|
ignoredReadStatus: false,
|
||||||
},
|
},
|
||||||
} as AxiosResponse<Entries>)
|
} as AxiosResponse<Entries>)
|
||||||
|
|
||||||
const store = configureStore({
|
const store = configureStore({
|
||||||
reducer: reducers,
|
reducer: reducers,
|
||||||
preloadedState: {
|
preloadedState: {
|
||||||
entries: {
|
entries: {
|
||||||
source: {
|
source: {
|
||||||
type: "category",
|
type: "category",
|
||||||
id: "category-id",
|
id: "category-id",
|
||||||
},
|
},
|
||||||
sourceLabel: "",
|
sourceLabel: "",
|
||||||
sourceWebsiteUrl: "",
|
sourceWebsiteUrl: "",
|
||||||
entries: [{ id: "3" } as Entry],
|
entries: [{ id: "3" } as Entry],
|
||||||
hasMore: true,
|
hasMore: true,
|
||||||
loading: false,
|
loading: false,
|
||||||
scrollingToEntry: false,
|
scrollingToEntry: false,
|
||||||
},
|
},
|
||||||
} as RootState,
|
} as RootState,
|
||||||
})
|
})
|
||||||
const promise = store.dispatch(loadMoreEntries())
|
const promise = store.dispatch(loadMoreEntries())
|
||||||
|
|
||||||
await promise
|
await promise
|
||||||
expect(store.getState().entries.entries).toStrictEqual([{ id: "3" }, { id: "4" }])
|
expect(store.getState().entries.entries).toStrictEqual([{ id: "3" }, { id: "4" }])
|
||||||
expect(store.getState().entries.hasMore).toBe(false)
|
expect(store.getState().entries.hasMore).toBe(false)
|
||||||
})
|
})
|
||||||
|
|
||||||
it("marks an entry as read", () => {
|
it("marks an entry as read", () => {
|
||||||
const store = configureStore({
|
const store = configureStore({
|
||||||
reducer: reducers,
|
reducer: reducers,
|
||||||
preloadedState: {
|
preloadedState: {
|
||||||
entries: {
|
entries: {
|
||||||
source: {
|
source: {
|
||||||
type: "category",
|
type: "category",
|
||||||
id: "category-id",
|
id: "category-id",
|
||||||
},
|
},
|
||||||
sourceLabel: "",
|
sourceLabel: "",
|
||||||
sourceWebsiteUrl: "",
|
sourceWebsiteUrl: "",
|
||||||
entries: [{ id: "3", read: false } as Entry, { id: "4", read: false } as Entry],
|
entries: [{ id: "3", read: false } as Entry, { id: "4", read: false } as Entry],
|
||||||
hasMore: true,
|
hasMore: true,
|
||||||
loading: false,
|
loading: false,
|
||||||
scrollingToEntry: false,
|
scrollingToEntry: false,
|
||||||
},
|
},
|
||||||
} as RootState,
|
} as RootState,
|
||||||
})
|
})
|
||||||
|
|
||||||
store.dispatch(markEntry({ entry: { id: "3" } as Entry, read: true }))
|
store.dispatch(markEntry({ entry: { id: "3" } as Entry, 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: 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", () => {
|
||||||
const store = configureStore({
|
const store = configureStore({
|
||||||
reducer: reducers,
|
reducer: reducers,
|
||||||
preloadedState: {
|
preloadedState: {
|
||||||
entries: {
|
entries: {
|
||||||
source: {
|
source: {
|
||||||
type: "category",
|
type: "category",
|
||||||
id: "category-id",
|
id: "category-id",
|
||||||
},
|
},
|
||||||
sourceLabel: "",
|
sourceLabel: "",
|
||||||
sourceWebsiteUrl: "",
|
sourceWebsiteUrl: "",
|
||||||
entries: [{ id: "3", read: false } as Entry, { id: "4", read: false } as Entry],
|
entries: [{ id: "3", read: false } as Entry, { id: "4", read: false } as Entry],
|
||||||
hasMore: true,
|
hasMore: true,
|
||||||
loading: false,
|
loading: false,
|
||||||
scrollingToEntry: false,
|
scrollingToEntry: false,
|
||||||
},
|
},
|
||||||
} as RootState,
|
} as RootState,
|
||||||
})
|
})
|
||||||
|
|
||||||
store.dispatch(markAllEntries({ sourceType: "category", req: { id: "all", read: true } }))
|
store.dispatch(
|
||||||
expect(store.getState().entries.entries).toStrictEqual([
|
markAllEntries({
|
||||||
{ id: "3", read: true },
|
sourceType: "category",
|
||||||
{ id: "4", read: true },
|
req: { id: "all", read: true },
|
||||||
])
|
})
|
||||||
expect(mockClient.category.markEntries).toHaveBeenCalledWith({ id: "all", read: true })
|
)
|
||||||
})
|
expect(store.getState().entries.entries).toStrictEqual([
|
||||||
})
|
{ id: "3", read: true },
|
||||||
|
{ id: "4", read: true },
|
||||||
|
])
|
||||||
|
expect(client.category.markEntries).toHaveBeenCalledWith({
|
||||||
|
id: "all",
|
||||||
|
read: true,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|||||||
@@ -1,134 +1,127 @@
|
|||||||
import { createSlice, type PayloadAction } 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"
|
||||||
|
|
||||||
export interface EntrySource {
|
export interface EntrySource {
|
||||||
type: EntrySourceType
|
type: EntrySourceType
|
||||||
id: string
|
id: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export type ExpendableEntry = Entry & { expanded?: boolean }
|
export type ExpendableEntry = Entry & { expanded?: boolean }
|
||||||
|
|
||||||
interface EntriesState {
|
interface EntriesState {
|
||||||
/** selected source */
|
/** selected source */
|
||||||
source: EntrySource
|
source: EntrySource
|
||||||
sourceLabel: string
|
sourceLabel: string
|
||||||
sourceWebsiteUrl: string
|
sourceWebsiteUrl: string
|
||||||
entries: ExpendableEntry[]
|
entries: ExpendableEntry[]
|
||||||
/** stores when the first batch of entries were retrieved
|
/** stores when the first batch of entries were retrieved
|
||||||
*
|
*
|
||||||
* this is used when marking all entries of a feed/category to only mark entries up to that timestamp as newer entries were potentially never shown
|
* this is used when marking all entries of a feed/category to only mark entries up to that timestamp as newer entries were potentially never shown
|
||||||
*/
|
*/
|
||||||
timestamp?: number
|
timestamp?: number
|
||||||
selectedEntryId?: string
|
selectedEntryId?: string
|
||||||
hasMore: boolean
|
hasMore: boolean
|
||||||
loading: boolean
|
loading: boolean
|
||||||
search?: string
|
search?: string
|
||||||
scrollingToEntry: boolean
|
scrollingToEntry: boolean
|
||||||
}
|
markAllAsReadConfirmationDialogOpen: boolean
|
||||||
|
}
|
||||||
const initialState: EntriesState = {
|
|
||||||
source: {
|
const initialState: EntriesState = {
|
||||||
type: "category",
|
source: {
|
||||||
id: Constants.categories.all.id,
|
type: "category",
|
||||||
},
|
id: Constants.categories.all.id,
|
||||||
sourceLabel: "",
|
},
|
||||||
sourceWebsiteUrl: "",
|
sourceLabel: "",
|
||||||
entries: [],
|
sourceWebsiteUrl: "",
|
||||||
hasMore: true,
|
entries: [],
|
||||||
loading: false,
|
hasMore: true,
|
||||||
scrollingToEntry: false,
|
loading: false,
|
||||||
}
|
scrollingToEntry: false,
|
||||||
|
markAllAsReadConfirmationDialogOpen: false,
|
||||||
export const entriesSlice = createSlice({
|
}
|
||||||
name: "entries",
|
|
||||||
initialState,
|
export const entriesSlice = createSlice({
|
||||||
reducers: {
|
name: "entries",
|
||||||
setSelectedEntry: (state, action: PayloadAction<Entry>) => {
|
initialState,
|
||||||
state.selectedEntryId = action.payload.id
|
reducers: {
|
||||||
},
|
setSelectedEntry: (state, action: PayloadAction<Entry>) => {
|
||||||
setEntryExpanded: (state, action: PayloadAction<{ entry: Entry; expanded: boolean }>) => {
|
state.selectedEntryId = action.payload.id
|
||||||
state.entries
|
},
|
||||||
.filter(e => e.id === action.payload.entry.id)
|
setEntryExpanded: (state, action: PayloadAction<{ entry: Entry; expanded: boolean }>) => {
|
||||||
.forEach(e => {
|
for (const e of state.entries.filter(e => e.id === action.payload.entry.id)) {
|
||||||
e.expanded = action.payload.expanded
|
e.expanded = action.payload.expanded
|
||||||
})
|
}
|
||||||
},
|
},
|
||||||
setScrollingToEntry: (state, action: PayloadAction<boolean>) => {
|
setScrollingToEntry: (state, action: PayloadAction<boolean>) => {
|
||||||
state.scrollingToEntry = action.payload
|
state.scrollingToEntry = action.payload
|
||||||
},
|
},
|
||||||
setSearch: (state, action: PayloadAction<string>) => {
|
setSearch: (state, action: PayloadAction<string>) => {
|
||||||
state.search = action.payload
|
state.search = action.payload
|
||||||
},
|
},
|
||||||
},
|
setMarkAllAsReadConfirmationDialogOpen: (state, action: PayloadAction<boolean>) => {
|
||||||
extraReducers: builder => {
|
state.markAllAsReadConfirmationDialogOpen = action.payload
|
||||||
builder.addCase(markEntry.pending, (state, action) => {
|
},
|
||||||
state.entries
|
},
|
||||||
.filter(e => e.id === action.meta.arg.entry.id)
|
extraReducers: builder => {
|
||||||
.forEach(e => {
|
builder.addCase(markEntry.pending, (state, action) => {
|
||||||
e.read = action.meta.arg.read
|
for (const e of state.entries.filter(e => e.id === action.meta.arg.entry.id)) {
|
||||||
})
|
e.read = action.meta.arg.read
|
||||||
})
|
}
|
||||||
builder.addCase(markMultipleEntries.pending, (state, action) => {
|
})
|
||||||
state.entries
|
builder.addCase(markMultipleEntries.pending, (state, action) => {
|
||||||
.filter(e => action.meta.arg.entries.some(e2 => e2.id === e.id))
|
for (const e of state.entries.filter(e => action.meta.arg.entries.some(e2 => e2.id === e.id))) {
|
||||||
.forEach(e => {
|
e.read = action.meta.arg.read
|
||||||
e.read = action.meta.arg.read
|
}
|
||||||
})
|
})
|
||||||
})
|
builder.addCase(markAllEntries.pending, (state, action) => {
|
||||||
builder.addCase(markAllEntries.pending, (state, action) => {
|
for (const e of state.entries.filter(e => (action.meta.arg.req.olderThan ? e.date < action.meta.arg.req.olderThan : true))) {
|
||||||
state.entries
|
e.read = true
|
||||||
.filter(e => (action.meta.arg.req.olderThan ? e.date < action.meta.arg.req.olderThan : true))
|
}
|
||||||
.forEach(e => {
|
})
|
||||||
e.read = true
|
builder.addCase(starEntry.pending, (state, action) => {
|
||||||
})
|
for (const e of state.entries.filter(e => action.meta.arg.entry.id === e.id && action.meta.arg.entry.feedId === e.feedId)) {
|
||||||
})
|
e.starred = action.meta.arg.starred
|
||||||
builder.addCase(starEntry.pending, (state, action) => {
|
}
|
||||||
state.entries
|
})
|
||||||
.filter(e => action.meta.arg.entry.id === e.id && action.meta.arg.entry.feedId === e.feedId)
|
builder.addCase(loadEntries.pending, (state, action) => {
|
||||||
.forEach(e => {
|
state.source = action.meta.arg.source
|
||||||
e.starred = action.meta.arg.starred
|
state.entries = []
|
||||||
})
|
state.timestamp = undefined
|
||||||
})
|
state.sourceLabel = ""
|
||||||
builder.addCase(loadEntries.pending, (state, action) => {
|
state.sourceWebsiteUrl = ""
|
||||||
state.source = action.meta.arg.source
|
state.hasMore = true
|
||||||
state.entries = []
|
state.selectedEntryId = undefined
|
||||||
state.timestamp = undefined
|
state.loading = true
|
||||||
state.sourceLabel = ""
|
})
|
||||||
state.sourceWebsiteUrl = ""
|
builder.addCase(loadMoreEntries.pending, state => {
|
||||||
state.hasMore = true
|
state.loading = true
|
||||||
state.selectedEntryId = undefined
|
})
|
||||||
state.loading = true
|
builder.addCase(loadEntries.fulfilled, (state, action) => {
|
||||||
})
|
state.entries = action.payload.entries
|
||||||
builder.addCase(loadMoreEntries.pending, state => {
|
state.timestamp = action.payload.timestamp
|
||||||
state.loading = true
|
state.sourceLabel = action.payload.name
|
||||||
})
|
state.sourceWebsiteUrl = action.payload.feedLink
|
||||||
builder.addCase(loadEntries.fulfilled, (state, action) => {
|
state.hasMore = action.payload.hasMore
|
||||||
state.entries = action.payload.entries
|
state.loading = false
|
||||||
state.timestamp = action.payload.timestamp
|
})
|
||||||
state.sourceLabel = action.payload.name
|
builder.addCase(loadMoreEntries.fulfilled, (state, action) => {
|
||||||
state.sourceWebsiteUrl = action.payload.feedLink
|
// remove already existing entries
|
||||||
state.hasMore = action.payload.hasMore
|
const entriesToAdd = action.payload.entries.filter(e => !state.entries.some(e2 => e.id === e2.id))
|
||||||
state.loading = false
|
state.entries = [...state.entries, ...entriesToAdd]
|
||||||
})
|
state.hasMore = action.payload.hasMore
|
||||||
builder.addCase(loadMoreEntries.fulfilled, (state, action) => {
|
state.loading = false
|
||||||
// remove already existing entries
|
})
|
||||||
const entriesToAdd = action.payload.entries.filter(e => !state.entries.some(e2 => e.id === e2.id))
|
builder.addCase(tagEntry.pending, (state, action) => {
|
||||||
state.entries = [...state.entries, ...entriesToAdd]
|
for (const e of state.entries.filter(e => +e.id === action.meta.arg.entryId)) {
|
||||||
state.hasMore = action.payload.hasMore
|
e.tags = action.meta.arg.tags
|
||||||
state.loading = false
|
}
|
||||||
})
|
})
|
||||||
builder.addCase(tagEntry.pending, (state, action) => {
|
},
|
||||||
state.entries
|
})
|
||||||
.filter(e => +e.id === action.meta.arg.entryId)
|
|
||||||
.forEach(e => {
|
export const { setSearch, setMarkAllAsReadConfirmationDialogOpen } = entriesSlice.actions
|
||||||
e.tags = action.meta.arg.tags
|
|
||||||
})
|
|
||||||
})
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
export const { setSearch } = entriesSlice.actions
|
|
||||||
|
|||||||
@@ -1,241 +1,319 @@
|
|||||||
import { createAppAsyncThunk } from "app/async-thunk"
|
import { flushSync } from "react-dom"
|
||||||
import { client } from "app/client"
|
import { createAppAsyncThunk } from "@/app/async-thunk"
|
||||||
import { Constants } from "app/constants"
|
import { client } from "@/app/client"
|
||||||
import { entriesSlice, type EntrySource, type EntrySourceType, setSearch } from "app/entries/slice"
|
import { Constants } from "@/app/constants"
|
||||||
import type { RootState } from "app/store"
|
import {
|
||||||
import { reloadTree } from "app/tree/thunks"
|
type EntrySource,
|
||||||
import type { Entry, MarkRequest, TagRequest } from "app/types"
|
type EntrySourceType,
|
||||||
import { reloadTags } from "app/user/thunks"
|
entriesSlice,
|
||||||
import { scrollToWithCallback } from "app/utils"
|
setMarkAllAsReadConfirmationDialogOpen,
|
||||||
import { flushSync } from "react-dom"
|
setSearch,
|
||||||
|
} from "@/app/entries/slice"
|
||||||
const getEndpoint = (sourceType: EntrySourceType) =>
|
import type { RootState } from "@/app/store"
|
||||||
sourceType === "category" || sourceType === "tag" ? client.category.getEntries : client.feed.getEntries
|
import { reloadTree, selectNextUnreadTreeItem } from "@/app/tree/thunks"
|
||||||
export const loadEntries = createAppAsyncThunk(
|
import type { Entry, MarkRequest, TagRequest } from "@/app/types"
|
||||||
"entries/load",
|
import { reloadTags } from "@/app/user/thunks"
|
||||||
async (
|
import { scrollToWithCallback } from "@/app/utils"
|
||||||
arg: {
|
|
||||||
source: EntrySource
|
const getEndpoint = (sourceType: EntrySourceType) =>
|
||||||
clearSearch: boolean
|
sourceType === "category" || sourceType === "tag" ? client.category.getEntries : client.feed.getEntries
|
||||||
},
|
|
||||||
thunkApi
|
export const loadEntries = createAppAsyncThunk(
|
||||||
) => {
|
"entries/load",
|
||||||
if (arg.clearSearch) thunkApi.dispatch(setSearch(""))
|
async (
|
||||||
|
arg: {
|
||||||
const state = thunkApi.getState()
|
source: EntrySource
|
||||||
const endpoint = getEndpoint(arg.source.type)
|
clearSearch: boolean
|
||||||
const result = await endpoint(buildGetEntriesPaginatedRequest(state, arg.source, 0))
|
},
|
||||||
return result.data
|
thunkApi
|
||||||
}
|
) => {
|
||||||
)
|
if (arg.clearSearch) thunkApi.dispatch(setSearch(""))
|
||||||
export const loadMoreEntries = createAppAsyncThunk("entries/loadMore", async (_, thunkApi) => {
|
|
||||||
const state = thunkApi.getState()
|
const state = thunkApi.getState()
|
||||||
const { source } = state.entries
|
const endpoint = getEndpoint(arg.source.type)
|
||||||
const offset =
|
const result = await endpoint(buildGetEntriesPaginatedRequest(state, arg.source, 0))
|
||||||
state.user.settings?.readingMode === "all" ? state.entries.entries.length : state.entries.entries.filter(e => !e.read).length
|
return result.data
|
||||||
const endpoint = getEndpoint(state.entries.source.type)
|
}
|
||||||
const result = await endpoint(buildGetEntriesPaginatedRequest(state, source, offset))
|
)
|
||||||
return result.data
|
|
||||||
})
|
export const loadMoreEntries = createAppAsyncThunk("entries/loadMore", async (_, thunkApi) => {
|
||||||
const buildGetEntriesPaginatedRequest = (state: RootState, source: EntrySource, offset: number) => ({
|
const state = thunkApi.getState()
|
||||||
id: source.type === "tag" ? Constants.categories.all.id : source.id,
|
const { source } = state.entries
|
||||||
order: state.user.settings?.readingOrder,
|
const offset =
|
||||||
readType: state.user.settings?.readingMode,
|
state.user.settings?.readingMode === "all" || (source.type === "category" && source.id === "starred")
|
||||||
offset,
|
? state.entries.entries.length
|
||||||
limit: 50,
|
: state.entries.entries.filter(e => !e.read).length
|
||||||
tag: source.type === "tag" ? source.id : undefined,
|
const endpoint = getEndpoint(state.entries.source.type)
|
||||||
keywords: state.entries.search,
|
const result = await endpoint(buildGetEntriesPaginatedRequest(state, source, offset))
|
||||||
})
|
return result.data
|
||||||
export const reloadEntries = createAppAsyncThunk("entries/reload", (arg, thunkApi) => {
|
})
|
||||||
const state = thunkApi.getState()
|
|
||||||
thunkApi.dispatch(loadEntries({ source: state.entries.source, clearSearch: false }))
|
const buildGetEntriesPaginatedRequest = (state: RootState, source: EntrySource, offset: number) => ({
|
||||||
})
|
id: source.type === "tag" ? Constants.categories.all.id : source.id,
|
||||||
export const search = createAppAsyncThunk("entries/search", (arg: string, thunkApi) => {
|
order: state.user.settings?.readingOrder,
|
||||||
const state = thunkApi.getState()
|
readType: state.entries.search ? "all" : state.user.settings?.readingMode,
|
||||||
thunkApi.dispatch(setSearch(arg))
|
offset,
|
||||||
thunkApi.dispatch(loadEntries({ source: state.entries.source, clearSearch: false }))
|
limit: 50,
|
||||||
})
|
tag: source.type === "tag" ? source.id : undefined,
|
||||||
export const markEntry = createAppAsyncThunk(
|
keywords: state.entries.search,
|
||||||
"entries/entry/mark",
|
})
|
||||||
(arg: { entry: Entry; read: boolean }) => {
|
|
||||||
client.entry.mark({
|
export const reloadEntries = createAppAsyncThunk("entries/reload", (_, thunkApi) => {
|
||||||
id: arg.entry.id,
|
const state = thunkApi.getState()
|
||||||
read: arg.read,
|
thunkApi.dispatch(loadEntries({ source: state.entries.source, clearSearch: false }))
|
||||||
})
|
})
|
||||||
},
|
|
||||||
{
|
export const search = createAppAsyncThunk("entries/search", (arg: string, thunkApi) => {
|
||||||
condition: arg => arg.entry.read !== arg.read,
|
const state = thunkApi.getState()
|
||||||
}
|
thunkApi.dispatch(setSearch(arg))
|
||||||
)
|
thunkApi.dispatch(loadEntries({ source: state.entries.source, clearSearch: false }))
|
||||||
export const markMultipleEntries = createAppAsyncThunk(
|
})
|
||||||
"entries/entry/markMultiple",
|
|
||||||
async (
|
export const markEntry = createAppAsyncThunk(
|
||||||
arg: {
|
"entries/entry/mark",
|
||||||
entries: Entry[]
|
(arg: { entry: Entry; read: boolean }) => {
|
||||||
read: boolean
|
client.entry.mark({
|
||||||
},
|
id: arg.entry.id,
|
||||||
thunkApi
|
read: arg.read,
|
||||||
) => {
|
})
|
||||||
const requests: MarkRequest[] = arg.entries.map(e => ({
|
},
|
||||||
id: e.id,
|
{
|
||||||
read: arg.read,
|
condition: arg => arg.entry.markable && arg.entry.read !== arg.read,
|
||||||
}))
|
}
|
||||||
await client.entry.markMultiple({ requests })
|
)
|
||||||
thunkApi.dispatch(reloadTree())
|
|
||||||
}
|
export const markMultipleEntries = createAppAsyncThunk(
|
||||||
)
|
"entries/entry/markMultiple",
|
||||||
export const markEntriesUpToEntry = createAppAsyncThunk("entries/entry/upToEntry", (arg: Entry, thunkApi) => {
|
async (
|
||||||
const state = thunkApi.getState()
|
arg: {
|
||||||
const { entries } = state.entries
|
entries: Entry[]
|
||||||
|
read: boolean
|
||||||
const index = entries.findIndex(e => e.id === arg.id)
|
},
|
||||||
if (index === -1) return
|
thunkApi
|
||||||
|
) => {
|
||||||
thunkApi.dispatch(
|
const requests: MarkRequest[] = arg.entries.map(e => ({
|
||||||
markMultipleEntries({
|
id: e.id,
|
||||||
entries: entries.slice(0, index + 1),
|
read: arg.read,
|
||||||
read: true,
|
}))
|
||||||
})
|
await client.entry.markMultiple({ requests })
|
||||||
)
|
thunkApi.dispatch(reloadTree())
|
||||||
})
|
}
|
||||||
export const markAllEntries = createAppAsyncThunk(
|
)
|
||||||
"entries/entry/markAll",
|
|
||||||
async (
|
export const markEntriesUpToEntry = createAppAsyncThunk("entries/entry/upToEntry", (arg: Entry, thunkApi) => {
|
||||||
arg: {
|
const state = thunkApi.getState()
|
||||||
sourceType: EntrySourceType
|
const { entries } = state.entries
|
||||||
req: MarkRequest
|
|
||||||
},
|
const index = entries.findIndex(e => e.id === arg.id)
|
||||||
thunkApi
|
if (index === -1) return
|
||||||
) => {
|
|
||||||
const endpoint = arg.sourceType === "category" ? client.category.markEntries : client.feed.markEntries
|
thunkApi.dispatch(
|
||||||
await endpoint(arg.req)
|
markMultipleEntries({
|
||||||
thunkApi.dispatch(reloadEntries())
|
entries: entries.slice(0, index + 1),
|
||||||
thunkApi.dispatch(reloadTree())
|
read: true,
|
||||||
}
|
})
|
||||||
)
|
)
|
||||||
export const starEntry = createAppAsyncThunk("entries/entry/star", (arg: { entry: Entry; starred: boolean }) => {
|
})
|
||||||
client.entry.star({
|
|
||||||
id: arg.entry.id,
|
export const markAllEntries = createAppAsyncThunk(
|
||||||
feedId: +arg.entry.feedId,
|
"entries/entry/markAll",
|
||||||
starred: arg.starred,
|
async (
|
||||||
})
|
arg: {
|
||||||
})
|
sourceType: EntrySourceType
|
||||||
export const selectEntry = createAppAsyncThunk(
|
req: MarkRequest
|
||||||
"entries/entry/select",
|
},
|
||||||
(
|
thunkApi
|
||||||
arg: {
|
) => {
|
||||||
entry: Entry
|
const endpoint = arg.sourceType === "category" ? client.category.markEntries : client.feed.markEntries
|
||||||
expand: boolean
|
await endpoint(arg.req)
|
||||||
markAsRead: boolean
|
thunkApi.dispatch(reloadEntries())
|
||||||
scrollToEntry: boolean
|
thunkApi.dispatch(reloadTree())
|
||||||
},
|
}
|
||||||
thunkApi
|
)
|
||||||
) => {
|
|
||||||
const state = thunkApi.getState()
|
export const markAllAsReadWithConfirmationIfRequired = createAppAsyncThunk(
|
||||||
const entry = state.entries.entries.find(e => e.id === arg.entry.id)
|
"entries/entry/markAllAsReadWithConfirmationIfRequired",
|
||||||
if (!entry) return
|
async (_, thunkApi) => {
|
||||||
|
const state = thunkApi.getState()
|
||||||
// flushSync is required because we need the newly selected entry to be expanded
|
const source = state.entries.source
|
||||||
// and the previously selected entry to be collapsed to be able to scroll to the right position
|
const entriesTimestamp = state.entries.timestamp ?? Date.now()
|
||||||
flushSync(() => {
|
const markAllAsReadConfirmation = state.user.settings?.markAllAsReadConfirmation
|
||||||
// mark as read if requested
|
const markAllAsReadNavigateToNextUnread = state.user.settings?.markAllAsReadNavigateToNextUnread
|
||||||
if (arg.markAsRead) {
|
|
||||||
thunkApi.dispatch(markEntry({ entry, read: true }))
|
if (markAllAsReadConfirmation) {
|
||||||
}
|
thunkApi.dispatch(setMarkAllAsReadConfirmationDialogOpen(true))
|
||||||
|
} else {
|
||||||
// set entry as selected
|
await thunkApi.dispatch(
|
||||||
thunkApi.dispatch(entriesSlice.actions.setSelectedEntry(entry))
|
markAllEntries({
|
||||||
|
sourceType: source.type,
|
||||||
// expand if requested
|
req: {
|
||||||
const previouslySelectedEntry = state.entries.entries.find(e => e.id === state.entries.selectedEntryId)
|
id: source.id,
|
||||||
if (previouslySelectedEntry) {
|
read: true,
|
||||||
thunkApi.dispatch(
|
olderThan: Date.now(),
|
||||||
entriesSlice.actions.setEntryExpanded({
|
insertedBefore: entriesTimestamp,
|
||||||
entry: previouslySelectedEntry,
|
},
|
||||||
expanded: false,
|
})
|
||||||
})
|
)
|
||||||
)
|
const isAllCategorySelected = source.type === "category" && source.id === Constants.categories.all.id
|
||||||
}
|
if (markAllAsReadNavigateToNextUnread && !isAllCategorySelected)
|
||||||
thunkApi.dispatch(entriesSlice.actions.setEntryExpanded({ entry, expanded: arg.expand }))
|
await thunkApi.dispatch(selectNextUnreadTreeItem({ direction: "forward" }))
|
||||||
})
|
}
|
||||||
|
}
|
||||||
if (arg.scrollToEntry) {
|
)
|
||||||
const entryElement = document.getElementById(Constants.dom.entryId(entry))
|
|
||||||
if (entryElement) {
|
export const starEntry = createAppAsyncThunk(
|
||||||
const scrollMode = state.user.settings?.scrollMode
|
"entries/entry/star",
|
||||||
const entryEntirelyVisible = Constants.layout.isTopVisible(entryElement) && Constants.layout.isBottomVisible(entryElement)
|
(arg: { entry: Entry; starred: boolean }) => {
|
||||||
if (scrollMode === "always" || (scrollMode === "if_needed" && !entryEntirelyVisible)) {
|
client.entry.star({
|
||||||
const scrollSpeed = state.user.settings?.scrollSpeed
|
id: arg.entry.id,
|
||||||
thunkApi.dispatch(entriesSlice.actions.setScrollingToEntry(true))
|
feedId: +arg.entry.feedId,
|
||||||
scrollToEntry(entryElement, scrollSpeed, () => thunkApi.dispatch(entriesSlice.actions.setScrollingToEntry(false)))
|
starred: arg.starred,
|
||||||
}
|
})
|
||||||
}
|
},
|
||||||
}
|
{
|
||||||
}
|
condition: arg => arg.entry.markable && arg.entry.starred !== arg.starred,
|
||||||
)
|
}
|
||||||
const scrollToEntry = (entryElement: HTMLElement, scrollSpeed: number | undefined, onScrollEnded: () => void) => {
|
)
|
||||||
const header = document.getElementById(Constants.dom.headerId)?.getBoundingClientRect()
|
|
||||||
const offset = (header?.bottom ?? 0) + 3
|
export const selectEntry = createAppAsyncThunk(
|
||||||
scrollToWithCallback({
|
"entries/entry/select",
|
||||||
options: {
|
(
|
||||||
top: entryElement.offsetTop - offset,
|
arg: {
|
||||||
behavior: scrollSpeed && scrollSpeed > 0 ? "smooth" : "auto",
|
entry: Entry
|
||||||
},
|
expand: boolean
|
||||||
onScrollEnded,
|
markAsRead: boolean
|
||||||
})
|
scrollToEntry: boolean
|
||||||
}
|
},
|
||||||
|
thunkApi
|
||||||
export const selectPreviousEntry = createAppAsyncThunk(
|
) => {
|
||||||
"entries/entry/selectPrevious",
|
const state = thunkApi.getState()
|
||||||
(
|
const entry = state.entries.entries.find(e => e.id === arg.entry.id)
|
||||||
arg: {
|
if (!entry) return
|
||||||
expand: boolean
|
|
||||||
markAsRead: boolean
|
// flushSync is required because we need the newly selected entry to be expanded
|
||||||
scrollToEntry: boolean
|
// and the previously selected entry to be collapsed to be able to scroll to the right position
|
||||||
},
|
flushSync(() => {
|
||||||
thunkApi
|
// mark as read if requested
|
||||||
) => {
|
if (arg.markAsRead) {
|
||||||
const state = thunkApi.getState()
|
thunkApi.dispatch(markEntry({ entry, read: true }))
|
||||||
const { entries } = state.entries
|
}
|
||||||
const previousIndex = entries.findIndex(e => e.id === state.entries.selectedEntryId) - 1
|
|
||||||
if (previousIndex >= 0) {
|
// set entry as selected
|
||||||
thunkApi.dispatch(
|
thunkApi.dispatch(entriesSlice.actions.setSelectedEntry(entry))
|
||||||
selectEntry({
|
|
||||||
entry: entries[previousIndex],
|
// expand if requested
|
||||||
expand: arg.expand,
|
const previouslySelectedEntry = state.entries.entries.find(e => e.id === state.entries.selectedEntryId)
|
||||||
markAsRead: arg.markAsRead,
|
if (previouslySelectedEntry) {
|
||||||
scrollToEntry: arg.scrollToEntry,
|
thunkApi.dispatch(
|
||||||
})
|
entriesSlice.actions.setEntryExpanded({
|
||||||
)
|
entry: previouslySelectedEntry,
|
||||||
}
|
expanded: false,
|
||||||
}
|
})
|
||||||
)
|
)
|
||||||
export const selectNextEntry = createAppAsyncThunk(
|
}
|
||||||
"entries/entry/selectNext",
|
thunkApi.dispatch(entriesSlice.actions.setEntryExpanded({ entry, expanded: arg.expand }))
|
||||||
(
|
})
|
||||||
arg: {
|
|
||||||
expand: boolean
|
if (arg.scrollToEntry) {
|
||||||
markAsRead: boolean
|
const viewMode = state.user.localSettings.viewMode
|
||||||
scrollToEntry: boolean
|
|
||||||
},
|
const entryIndex = state.entries.entries.indexOf(entry)
|
||||||
thunkApi
|
const entriesToKeepOnTopWhenScrolling =
|
||||||
) => {
|
viewMode === "expanded" ? 0 : Math.min(state.user.settings?.entriesToKeepOnTopWhenScrolling ?? 0, entryIndex)
|
||||||
const state = thunkApi.getState()
|
const entryToScrollTo = state.entries.entries[entryIndex - entriesToKeepOnTopWhenScrolling]
|
||||||
const { entries } = state.entries
|
|
||||||
const nextIndex = entries.findIndex(e => e.id === state.entries.selectedEntryId) + 1
|
const entryElement = document.getElementById(Constants.dom.entryId(entry))
|
||||||
if (nextIndex < entries.length) {
|
const entryElementToScrollTo = document.getElementById(Constants.dom.entryId(entryToScrollTo))
|
||||||
thunkApi.dispatch(
|
if (entryElement && entryElementToScrollTo) {
|
||||||
selectEntry({
|
const scrollMode = state.user.settings?.scrollMode
|
||||||
entry: entries[nextIndex],
|
const entryEntirelyVisible =
|
||||||
expand: arg.expand,
|
Constants.layout.isTopVisible(entryElementToScrollTo) && Constants.layout.isBottomVisible(entryElement)
|
||||||
markAsRead: arg.markAsRead,
|
if (scrollMode === "always" || (scrollMode === "if_needed" && !entryEntirelyVisible)) {
|
||||||
scrollToEntry: arg.scrollToEntry,
|
const scrollSpeed = state.user.settings?.scrollSpeed
|
||||||
})
|
const margin = viewMode === "detailed" ? 8 : 3
|
||||||
)
|
thunkApi.dispatch(entriesSlice.actions.setScrollingToEntry(true))
|
||||||
}
|
scrollToEntry(entryElementToScrollTo, margin, scrollSpeed, () =>
|
||||||
}
|
thunkApi.dispatch(entriesSlice.actions.setScrollingToEntry(false))
|
||||||
)
|
)
|
||||||
export const tagEntry = createAppAsyncThunk("entries/entry/tag", async (arg: TagRequest, thunkApi) => {
|
}
|
||||||
await client.entry.tag(arg)
|
}
|
||||||
thunkApi.dispatch(reloadTags())
|
}
|
||||||
})
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
const scrollToEntry = (entryElement: HTMLElement, margin: number, scrollSpeed: number | undefined, onScrollEnded: () => void) => {
|
||||||
|
const header = document.getElementsByTagName("header").item(0)?.getBoundingClientRect()
|
||||||
|
const offset = (header?.bottom ?? 0) + margin
|
||||||
|
scrollToWithCallback({
|
||||||
|
options: {
|
||||||
|
top: entryElement.offsetTop - offset,
|
||||||
|
behavior: scrollSpeed && scrollSpeed > 0 ? "smooth" : "auto",
|
||||||
|
},
|
||||||
|
onScrollEnded,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export const selectPreviousEntry = createAppAsyncThunk(
|
||||||
|
"entries/entry/selectPrevious",
|
||||||
|
(
|
||||||
|
arg: {
|
||||||
|
expand: boolean
|
||||||
|
markAsRead: boolean
|
||||||
|
scrollToEntry: boolean
|
||||||
|
},
|
||||||
|
thunkApi
|
||||||
|
) => {
|
||||||
|
const state = thunkApi.getState()
|
||||||
|
const { entries } = state.entries
|
||||||
|
const previousIndex = entries.findIndex(e => e.id === state.entries.selectedEntryId) - 1
|
||||||
|
if (previousIndex >= 0) {
|
||||||
|
thunkApi.dispatch(
|
||||||
|
selectEntry({
|
||||||
|
entry: entries[previousIndex],
|
||||||
|
expand: arg.expand,
|
||||||
|
markAsRead: arg.markAsRead,
|
||||||
|
scrollToEntry: arg.scrollToEntry,
|
||||||
|
})
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
export const selectNextEntry = createAppAsyncThunk(
|
||||||
|
"entries/entry/selectNext",
|
||||||
|
async (
|
||||||
|
arg: {
|
||||||
|
expand: boolean
|
||||||
|
markAsRead: boolean
|
||||||
|
scrollToEntry: boolean
|
||||||
|
},
|
||||||
|
thunkApi
|
||||||
|
) => {
|
||||||
|
const state = thunkApi.getState()
|
||||||
|
const { entries, hasMore, loading } = state.entries
|
||||||
|
const nextIndex = entries.findIndex(e => e.id === state.entries.selectedEntryId) + 1
|
||||||
|
|
||||||
|
// 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(
|
||||||
|
selectEntry({
|
||||||
|
entry: entriesAfterLoading[nextIndex],
|
||||||
|
expand: arg.expand,
|
||||||
|
markAsRead: arg.markAsRead,
|
||||||
|
scrollToEntry: arg.scrollToEntry,
|
||||||
|
})
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
export const tagEntry = createAppAsyncThunk("entries/entry/tag", async (arg: TagRequest, thunkApi) => {
|
||||||
|
await client.entry.tag(arg)
|
||||||
|
thunkApi.dispatch(reloadTags())
|
||||||
|
})
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
import { redirectToCategory } from "app/redirect/thunks"
|
import { describe, expect, it } from "vitest"
|
||||||
import { store } from "app/store"
|
import { redirectToCategory } from "@/app/redirect/thunks"
|
||||||
import { describe, expect, it } from "vitest"
|
import { store } from "@/app/store"
|
||||||
|
|
||||||
describe("redirects", () => {
|
describe("redirects", () => {
|
||||||
it("redirects to category", async () => {
|
it("redirects to category", async () => {
|
||||||
await store.dispatch(redirectToCategory("1"))
|
await store.dispatch(redirectToCategory("1"))
|
||||||
expect(store.getState().redirect.to).toBe("/app/category/1")
|
expect(store.getState().redirect.to).toBe("/app/category/1")
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,19 +1,19 @@
|
|||||||
import { createSlice, type PayloadAction } from "@reduxjs/toolkit"
|
import { createSlice, type PayloadAction } from "@reduxjs/toolkit"
|
||||||
|
|
||||||
interface RedirectState {
|
interface RedirectState {
|
||||||
to?: string
|
to?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
const initialState: RedirectState = {}
|
const initialState: RedirectState = {}
|
||||||
|
|
||||||
export const redirectSlice = createSlice({
|
export const redirectSlice = createSlice({
|
||||||
name: "redirect",
|
name: "redirect",
|
||||||
initialState,
|
initialState,
|
||||||
reducers: {
|
reducers: {
|
||||||
redirectTo: (state, action: PayloadAction<string | undefined>) => {
|
redirectTo: (state, action: PayloadAction<string | undefined>) => {
|
||||||
state.to = action.payload
|
state.to = action.payload
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
export const { redirectTo } = redirectSlice.actions
|
export const { redirectTo } = redirectSlice.actions
|
||||||
|
|||||||
@@ -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 redirectToPasswordRecovery = createAppAsyncThunk("redirect/passwordRecovery", (_, thunkApi) =>
|
export const redirectToRegistration = createAppAsyncThunk("redirect/register", (_, thunkApi) => thunkApi.dispatch(redirectTo("/register")))
|
||||||
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", () => {
|
||||||
export const redirectToSelectedSource = createAppAsyncThunk("redirect/selectedSource", (_, thunkApi) => {
|
window.location.href = "api-documentation/"
|
||||||
const { source } = thunkApi.getState().entries
|
})
|
||||||
thunkApi.dispatch(redirectTo(`/app/${source.type}/${source.id}`))
|
|
||||||
})
|
export const redirectToSelectedSource = createAppAsyncThunk("redirect/selectedSource", (_, thunkApi) => {
|
||||||
export const redirectToCategory = createAppAsyncThunk("redirect/category", (id: string, thunkApi) =>
|
const { source } = thunkApi.getState().entries
|
||||||
thunkApi.dispatch(redirectTo(`/app/category/${id}`))
|
thunkApi.dispatch(redirectTo(`/app/${source.type}/${source.id}`))
|
||||||
)
|
})
|
||||||
export const redirectToRootCategory = createAppAsyncThunk(
|
|
||||||
"redirect/category/root",
|
export const redirectToCategory = createAppAsyncThunk("redirect/category", (id: string, thunkApi) =>
|
||||||
async (_, thunkApi) => await thunkApi.dispatch(redirectToCategory(Constants.categories.all.id))
|
thunkApi.dispatch(redirectTo(`/app/category/${id}`))
|
||||||
)
|
)
|
||||||
export const redirectToCategoryDetails = createAppAsyncThunk("redirect/category/details", (id: string, thunkApi) =>
|
|
||||||
thunkApi.dispatch(redirectTo(`/app/category/${id}/details`))
|
export const redirectToRootCategory = createAppAsyncThunk(
|
||||||
)
|
"redirect/category/root",
|
||||||
export const redirectToFeed = createAppAsyncThunk("redirect/feed", (id: string | number, thunkApi) =>
|
async (_, thunkApi) => await thunkApi.dispatch(redirectToCategory(Constants.categories.all.id))
|
||||||
thunkApi.dispatch(redirectTo(`/app/feed/${id}`))
|
)
|
||||||
)
|
|
||||||
export const redirectToFeedDetails = createAppAsyncThunk("redirect/feed/details", (id: string, thunkApi) =>
|
export const redirectToCategoryDetails = createAppAsyncThunk("redirect/category/details", (id: string, thunkApi) =>
|
||||||
thunkApi.dispatch(redirectTo(`/app/feed/${id}/details`))
|
thunkApi.dispatch(redirectTo(`/app/category/${id}/details`))
|
||||||
)
|
)
|
||||||
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 redirectToFeed = createAppAsyncThunk("redirect/feed", (id: string | number, thunkApi) =>
|
||||||
thunkApi.dispatch(redirectTo(`/app/tag/${id}/details`))
|
thunkApi.dispatch(redirectTo(`/app/feed/${id}`))
|
||||||
)
|
)
|
||||||
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 redirectToFeedDetails = createAppAsyncThunk("redirect/feed/details", (id: string, thunkApi) =>
|
||||||
export const redirectToAdminUsers = createAppAsyncThunk("redirect/admin/users", (_, thunkApi) =>
|
thunkApi.dispatch(redirectTo(`/app/feed/${id}/details`))
|
||||||
thunkApi.dispatch(redirectTo("/app/admin/users"))
|
)
|
||||||
)
|
|
||||||
export const redirectToMetrics = createAppAsyncThunk("redirect/admin/metrics", (_, thunkApi) =>
|
export const redirectToTag = createAppAsyncThunk("redirect/tag", (id: string, thunkApi) => thunkApi.dispatch(redirectTo(`/app/tag/${id}`)))
|
||||||
thunkApi.dispatch(redirectTo("/app/admin/metrics"))
|
|
||||||
)
|
export const redirectToTagDetails = createAppAsyncThunk("redirect/tag/details", (id: string, thunkApi) =>
|
||||||
export const redirectToDonate = createAppAsyncThunk("redirect/donate", (_, thunkApi) => thunkApi.dispatch(redirectTo("/app/donate")))
|
thunkApi.dispatch(redirectTo(`/app/tag/${id}/details`))
|
||||||
export const redirectToAbout = createAppAsyncThunk("redirect/about", (_, thunkApi) => thunkApi.dispatch(redirectTo("/app/about")))
|
)
|
||||||
|
|
||||||
|
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 redirectToAdminUsers = createAppAsyncThunk("redirect/admin/users", (_, thunkApi) =>
|
||||||
|
thunkApi.dispatch(redirectTo("/app/admin/users"))
|
||||||
|
)
|
||||||
|
|
||||||
|
export const redirectToMetrics = createAppAsyncThunk("redirect/admin/metrics", (_, thunkApi) =>
|
||||||
|
thunkApi.dispatch(redirectTo("/app/admin/metrics"))
|
||||||
|
)
|
||||||
|
|
||||||
|
export const redirectToDonate = createAppAsyncThunk("redirect/donate", (_, thunkApi) => thunkApi.dispatch(redirectTo("/app/donate")))
|
||||||
|
|
||||||
|
export const redirectToAbout = createAppAsyncThunk("redirect/about", (_, thunkApi) => thunkApi.dispatch(redirectTo("/app/about")))
|
||||||
|
|||||||
@@ -1,29 +1,29 @@
|
|||||||
import { createSlice, type PayloadAction } 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
|
||||||
webSocketConnected: boolean
|
webSocketConnected: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
const initialState: ServerState = {
|
const initialState: ServerState = {
|
||||||
webSocketConnected: false,
|
webSocketConnected: false,
|
||||||
}
|
}
|
||||||
|
|
||||||
export const serverSlice = createSlice({
|
export const serverSlice = createSlice({
|
||||||
name: "server",
|
name: "server",
|
||||||
initialState,
|
initialState,
|
||||||
reducers: {
|
reducers: {
|
||||||
setWebSocketConnected: (state, action: PayloadAction<boolean>) => {
|
setWebSocketConnected: (state, action: PayloadAction<boolean>) => {
|
||||||
state.webSocketConnected = action.payload
|
state.webSocketConnected = action.payload
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
extraReducers: builder => {
|
extraReducers: builder => {
|
||||||
builder.addCase(reloadServerInfos.fulfilled, (state, action) => {
|
builder.addCase(reloadServerInfos.fulfilled, (state, action) => {
|
||||||
state.serverInfos = action.payload
|
state.serverInfos = action.payload
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
export const { setWebSocketConnected } = serverSlice.actions
|
export const { setWebSocketConnected } = serverSlice.actions
|
||||||
|
|||||||
@@ -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,23 +1,44 @@
|
|||||||
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 = {
|
|
||||||
entries: entriesSlice.reducer,
|
export const reducers = {
|
||||||
redirect: redirectSlice.reducer,
|
entries: entriesSlice.reducer,
|
||||||
tree: treeSlice.reducer,
|
redirect: redirectSlice.reducer,
|
||||||
server: serverSlice.reducer,
|
tree: treeSlice.reducer,
|
||||||
user: userSlice.reducer,
|
server: serverSlice.reducer,
|
||||||
}
|
user: userSlice.reducer,
|
||||||
|
}
|
||||||
export const store = configureStore({ reducer: reducers })
|
|
||||||
|
const loadLocalSettings = (): LocalSettings => {
|
||||||
export type RootState = ReturnType<typeof store.getState>
|
const json = localStorage.getItem("commafeed-local-settings")
|
||||||
export type AppDispatch = typeof store.dispatch
|
return {
|
||||||
|
...initialLocalSettings,
|
||||||
export const useAppDispatch: () => AppDispatch = useDispatch
|
...(json ? JSON.parse(json) : {}),
|
||||||
export const useAppSelector: TypedUseSelectorHook<RootState> = useSelector
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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 AppDispatch = typeof store.dispatch
|
||||||
|
|
||||||
|
export const useAppDispatch: () => AppDispatch = useDispatch
|
||||||
|
export const useAppSelector: TypedUseSelectorHook<RootState> = useSelector
|
||||||
|
export const useShallowEqualAppSelector: TypedUseSelectorHook<RootState> = selector => useSelector(selector, shallowEqual)
|
||||||
|
|||||||
@@ -1,72 +1,112 @@
|
|||||||
import { createSlice, type PayloadAction } 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"
|
||||||
|
|
||||||
interface TreeState {
|
export interface TreeSubscription extends Subscription {
|
||||||
rootCategory?: Category
|
// client-side only flag
|
||||||
mobileMenuOpen: boolean
|
hasNewEntries?: boolean
|
||||||
sidebarVisible: boolean
|
}
|
||||||
}
|
|
||||||
|
export interface TreeCategory extends Category {
|
||||||
const initialState: TreeState = {
|
feeds: TreeSubscription[]
|
||||||
mobileMenuOpen: false,
|
children: TreeCategory[]
|
||||||
sidebarVisible: true,
|
}
|
||||||
}
|
|
||||||
|
interface TreeState {
|
||||||
export const treeSlice = createSlice({
|
rootCategory?: TreeCategory
|
||||||
name: "tree",
|
mobileMenuOpen: boolean
|
||||||
initialState,
|
sidebarVisible: boolean
|
||||||
reducers: {
|
}
|
||||||
setMobileMenuOpen: (state, action: PayloadAction<boolean>) => {
|
|
||||||
state.mobileMenuOpen = action.payload
|
const initialState: TreeState = {
|
||||||
},
|
mobileMenuOpen: false,
|
||||||
toggleSidebar: state => {
|
sidebarVisible: true,
|
||||||
state.sidebarVisible = !state.sidebarVisible
|
}
|
||||||
},
|
|
||||||
incrementUnreadCount: (
|
export const treeSlice = createSlice({
|
||||||
state,
|
name: "tree",
|
||||||
action: PayloadAction<{
|
initialState,
|
||||||
feedId: number
|
reducers: {
|
||||||
amount: number
|
setMobileMenuOpen: (state, action: PayloadAction<boolean>) => {
|
||||||
}>
|
state.mobileMenuOpen = action.payload
|
||||||
) => {
|
},
|
||||||
if (!state.rootCategory) return
|
toggleSidebar: state => {
|
||||||
visitCategoryTree(state.rootCategory, c =>
|
state.sidebarVisible = !state.sidebarVisible
|
||||||
c.feeds
|
},
|
||||||
.filter(f => f.id === action.payload.feedId)
|
incrementUnreadCount: (
|
||||||
.forEach(f => {
|
state,
|
||||||
f.unread += action.payload.amount
|
action: PayloadAction<{
|
||||||
})
|
feedId: number
|
||||||
)
|
amount: number
|
||||||
},
|
}>
|
||||||
},
|
) => {
|
||||||
extraReducers: builder => {
|
if (!state.rootCategory) return
|
||||||
builder.addCase(reloadTree.fulfilled, (state, action) => {
|
visitCategoryTree(state.rootCategory, c => {
|
||||||
state.rootCategory = action.payload
|
for (const f of c.feeds.filter(f => f.id === action.payload.feedId)) {
|
||||||
})
|
f.unread += action.payload.amount
|
||||||
builder.addCase(collapseTreeCategory.pending, (state, action) => {
|
f.hasNewEntries = true
|
||||||
if (!state.rootCategory) return
|
}
|
||||||
visitCategoryTree(state.rootCategory, c => {
|
})
|
||||||
if (+c.id === action.meta.arg.id) c.expanded = !action.meta.arg.collapse
|
},
|
||||||
})
|
},
|
||||||
})
|
extraReducers: builder => {
|
||||||
builder.addCase(markEntry.pending, (state, action) => {
|
builder.addCase(reloadTree.fulfilled, (state, action) => {
|
||||||
if (!state.rootCategory) return
|
// set hasNewEntries to true if new unread > previous unread
|
||||||
visitCategoryTree(state.rootCategory, c =>
|
if (state.rootCategory) {
|
||||||
c.feeds
|
const oldFeeds = flattenCategoryTree(state.rootCategory).flatMap(c => c.feeds)
|
||||||
.filter(f => f.id === +action.meta.arg.entry.feedId)
|
const oldFeedsById = new Map(oldFeeds.map(f => [f.id, f]))
|
||||||
.forEach(f => {
|
|
||||||
f.unread = action.meta.arg.read ? f.unread - 1 : f.unread + 1
|
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) {
|
||||||
builder.addCase(redirectTo, state => {
|
newFeed.hasNewEntries = true
|
||||||
state.mobileMenuOpen = false
|
}
|
||||||
})
|
}
|
||||||
},
|
}
|
||||||
})
|
|
||||||
|
state.rootCategory = action.payload
|
||||||
export const { setMobileMenuOpen, toggleSidebar, incrementUnreadCount } = treeSlice.actions
|
})
|
||||||
|
builder.addCase(collapseTreeCategory.pending, (state, action) => {
|
||||||
|
if (!state.rootCategory) return
|
||||||
|
visitCategoryTree(state.rootCategory, c => {
|
||||||
|
if (+c.id === action.meta.arg.id) c.expanded = !action.meta.arg.collapse
|
||||||
|
})
|
||||||
|
})
|
||||||
|
builder.addCase(markEntry.pending, (state, action) => {
|
||||||
|
if (!state.rootCategory) return
|
||||||
|
visitCategoryTree(state.rootCategory, c => {
|
||||||
|
for (const f of c.feeds.filter(f => f.id === +action.meta.arg.entry.feedId)) {
|
||||||
|
f.unread = action.meta.arg.read ? f.unread - 1 : f.unread + 1
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
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 => {
|
||||||
|
state.mobileMenuOpen = false
|
||||||
|
})
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
export const { setMobileMenuOpen, toggleSidebar, incrementUnreadCount } = treeSlice.actions
|
||||||
|
|||||||
@@ -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"
|
||||||
export const reloadTree = createAppAsyncThunk("tree/reload", async () => await client.category.getRoot().then(r => r.data))
|
import { incrementUnreadCount } from "@/app/tree/slice"
|
||||||
export const collapseTreeCategory = createAppAsyncThunk(
|
import type { CollapseRequest, Subscription } from "@/app/types"
|
||||||
"tree/category/collapse",
|
import { flattenCategoryTree, visitCategoryTree } from "@/app/utils"
|
||||||
async (req: CollapseRequest) => await client.category.collapse(req)
|
|
||||||
)
|
export const reloadTree = createAppAsyncThunk("tree/reload", async () => await client.category.getRoot().then(r => r.data))
|
||||||
|
|
||||||
|
export const collapseTreeCategory = createAppAsyncThunk(
|
||||||
|
"tree/category/collapse",
|
||||||
|
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)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -1,292 +1,344 @@
|
|||||||
export type ReadingMode = "all" | "unread"
|
export type ReadingMode = "all" | "unread"
|
||||||
|
|
||||||
export type ReadingOrder = "asc" | "desc"
|
export type ReadingOrder = "asc" | "desc"
|
||||||
|
|
||||||
export type ViewMode = "title" | "cozy" | "detailed" | "expanded"
|
export type ViewMode = "title" | "cozy" | "detailed" | "expanded"
|
||||||
|
|
||||||
export type ScrollMode = "always" | "never" | "if_needed"
|
export type ScrollMode = "always" | "never" | "if_needed"
|
||||||
|
|
||||||
export interface AddCategoryRequest {
|
export type IconDisplayMode = "always" | "never" | "on_desktop" | "on_mobile"
|
||||||
name: string
|
|
||||||
parentId?: string
|
export interface AddCategoryRequest {
|
||||||
}
|
name: string
|
||||||
|
parentId?: string
|
||||||
export interface Subscription {
|
}
|
||||||
id: number
|
|
||||||
name: string
|
export interface Subscription {
|
||||||
message?: string
|
id: number
|
||||||
errorCount: number
|
name: string
|
||||||
lastRefresh?: number
|
message?: string
|
||||||
nextRefresh?: number
|
errorCount: number
|
||||||
feedUrl: string
|
lastRefresh?: number
|
||||||
feedLink: string
|
nextRefresh?: number
|
||||||
iconUrl: string
|
feedUrl: string
|
||||||
unread: number
|
feedLink: string
|
||||||
categoryId?: string
|
iconUrl: string
|
||||||
position: number
|
unread: number
|
||||||
newestItemTime?: number
|
categoryId?: string
|
||||||
filter?: string
|
position: number
|
||||||
}
|
newestItemTime?: number
|
||||||
|
filter?: string
|
||||||
export interface Category {
|
filterLegacy?: string
|
||||||
id: string
|
pushNotificationsEnabled: boolean
|
||||||
parentId?: string
|
autoMarkAsReadAfterDays?: number
|
||||||
parentName?: string
|
averageEntryIntervalMs?: number
|
||||||
name: string
|
}
|
||||||
children: Category[]
|
|
||||||
feeds: Subscription[]
|
export interface Category {
|
||||||
expanded: boolean
|
id: string
|
||||||
position: number
|
parentId?: string
|
||||||
}
|
parentName?: string
|
||||||
|
name: string
|
||||||
export interface CategoryModificationRequest {
|
children: Category[]
|
||||||
id: number
|
feeds: Subscription[]
|
||||||
name?: string
|
expanded: boolean
|
||||||
parentId?: string
|
position: number
|
||||||
position?: number
|
}
|
||||||
}
|
|
||||||
|
export interface CategoryModificationRequest {
|
||||||
export interface CollapseRequest {
|
id: number
|
||||||
id: number
|
name?: string
|
||||||
collapse: boolean
|
parentId?: string
|
||||||
}
|
position?: number
|
||||||
|
}
|
||||||
export interface Entry {
|
|
||||||
id: string
|
export interface CollapseRequest {
|
||||||
guid: string
|
id: number
|
||||||
title: string
|
collapse: boolean
|
||||||
content: string
|
}
|
||||||
categories?: string
|
|
||||||
rtl: boolean
|
export interface Entry {
|
||||||
author?: string
|
id: string
|
||||||
enclosureUrl?: string
|
guid: string
|
||||||
enclosureType?: string
|
title: string
|
||||||
mediaDescription?: string
|
content: string
|
||||||
mediaThumbnailUrl?: string
|
categories?: string
|
||||||
mediaThumbnailWidth?: number
|
rtl: boolean
|
||||||
mediaThumbnailHeight?: number
|
author?: string
|
||||||
date: number
|
enclosureUrl?: string
|
||||||
insertedDate: number
|
enclosureType?: string
|
||||||
feedId: string
|
mediaDescription?: string
|
||||||
feedName: string
|
mediaThumbnailUrl?: string
|
||||||
feedUrl: string
|
mediaThumbnailWidth?: number
|
||||||
feedLink: string
|
mediaThumbnailHeight?: number
|
||||||
iconUrl: string
|
date: number
|
||||||
url: string
|
insertedDate: number
|
||||||
read: boolean
|
feedId: string
|
||||||
starred: boolean
|
feedName: string
|
||||||
markable: boolean
|
feedUrl: string
|
||||||
tags: string[]
|
feedLink: string
|
||||||
}
|
iconUrl: string
|
||||||
|
url: string
|
||||||
export interface Entries {
|
read: boolean
|
||||||
name: string
|
starred: boolean
|
||||||
message?: string
|
markable: boolean
|
||||||
errorCount: number
|
tags: string[]
|
||||||
feedLink: string
|
}
|
||||||
timestamp: number
|
|
||||||
hasMore: boolean
|
export interface Entries {
|
||||||
offset?: number
|
name: string
|
||||||
limit?: number
|
message?: string
|
||||||
entries: Entry[]
|
errorCount: number
|
||||||
ignoredReadStatus: boolean
|
feedLink: string
|
||||||
}
|
timestamp: number
|
||||||
|
hasMore: boolean
|
||||||
export interface FeedInfo {
|
offset?: number
|
||||||
url: string
|
limit?: number
|
||||||
title: string
|
entries: Entry[]
|
||||||
}
|
ignoredReadStatus: boolean
|
||||||
|
}
|
||||||
export interface FeedInfoRequest {
|
|
||||||
url: string
|
export interface FeedInfo {
|
||||||
}
|
url: string
|
||||||
|
title: string
|
||||||
export interface FeedModificationRequest {
|
}
|
||||||
id: number
|
|
||||||
name?: string
|
export interface FeedInfoRequest {
|
||||||
categoryId?: string
|
url: string
|
||||||
position?: number
|
}
|
||||||
filter?: string
|
|
||||||
}
|
export interface FeedModificationRequest {
|
||||||
|
id: number
|
||||||
export interface GetEntriesRequest {
|
name?: string
|
||||||
id: string
|
categoryId?: string
|
||||||
readType?: ReadingMode
|
position?: number
|
||||||
newerThan?: number
|
filter?: string
|
||||||
order?: ReadingOrder
|
pushNotificationsEnabled: boolean
|
||||||
keywords?: string
|
autoMarkAsReadAfterDays?: number
|
||||||
onlyIds?: boolean
|
}
|
||||||
excludedSubscriptionIds?: string
|
|
||||||
tag?: string
|
export interface GetEntriesRequest {
|
||||||
}
|
id: string
|
||||||
|
readType?: ReadingMode
|
||||||
export interface GetEntriesPaginatedRequest extends GetEntriesRequest {
|
newerThan?: number
|
||||||
offset: number
|
order?: ReadingOrder
|
||||||
limit: number
|
keywords?: string
|
||||||
}
|
excludedSubscriptionIds?: string
|
||||||
|
tag?: string
|
||||||
export interface IDRequest {
|
}
|
||||||
id: number
|
|
||||||
}
|
export interface GetEntriesPaginatedRequest extends GetEntriesRequest {
|
||||||
|
offset: number
|
||||||
export interface LoginRequest {
|
limit: number
|
||||||
name: string
|
}
|
||||||
password: string
|
|
||||||
}
|
export interface IDRequest {
|
||||||
|
id: number
|
||||||
export interface MarkRequest {
|
}
|
||||||
id: string
|
|
||||||
read: boolean
|
export interface LoginRequest {
|
||||||
olderThan?: number
|
name: string
|
||||||
insertedBefore?: number
|
password: string
|
||||||
keywords?: string
|
}
|
||||||
excludedSubscriptions?: number[]
|
|
||||||
}
|
export interface MarkRequest {
|
||||||
|
id: string
|
||||||
export interface MetricCounter {
|
read: boolean
|
||||||
count: number
|
olderThan?: number
|
||||||
}
|
insertedBefore?: number
|
||||||
|
keywords?: string
|
||||||
export interface MetricGauge {
|
excludedSubscriptions?: number[]
|
||||||
value: number
|
}
|
||||||
}
|
|
||||||
|
export interface MetricCounter {
|
||||||
export interface MetricMeter {
|
count: number
|
||||||
count: number
|
}
|
||||||
m15_rate: number
|
|
||||||
m1_rate: number
|
export interface MetricGauge {
|
||||||
m5_rate: number
|
value: number
|
||||||
mean_rate: number
|
}
|
||||||
units: string
|
|
||||||
}
|
export interface MetricMeter {
|
||||||
|
count: number
|
||||||
export interface MetricTimer {
|
m15_rate: number
|
||||||
count: number
|
m1_rate: number
|
||||||
max: number
|
m5_rate: number
|
||||||
mean: number
|
mean_rate: number
|
||||||
min: number
|
units: string
|
||||||
p50: number
|
}
|
||||||
p75: number
|
|
||||||
p95: number
|
export interface MetricTimer {
|
||||||
p98: number
|
count: number
|
||||||
p99: number
|
max: number
|
||||||
p999: number
|
mean: number
|
||||||
stddev: number
|
min: number
|
||||||
m15_rate: number
|
p50: number
|
||||||
m1_rate: number
|
p75: number
|
||||||
m5_rate: number
|
p95: number
|
||||||
mean_rate: number
|
p98: number
|
||||||
duration_units: string
|
p99: number
|
||||||
rate_units: string
|
p999: number
|
||||||
}
|
stddev: number
|
||||||
|
m15_rate: number
|
||||||
export interface Metrics {
|
m1_rate: number
|
||||||
counters: Record<string, MetricCounter>
|
m5_rate: number
|
||||||
gauges: Record<string, MetricGauge>
|
mean_rate: number
|
||||||
meters: Record<string, MetricMeter>
|
duration_units: string
|
||||||
timers: Record<string, MetricTimer>
|
rate_units: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface MultipleMarkRequest {
|
export interface Metrics {
|
||||||
requests: MarkRequest[]
|
counters: Record<string, MetricCounter>
|
||||||
}
|
gauges: Record<string, MetricGauge>
|
||||||
|
meters: Record<string, MetricMeter>
|
||||||
export interface PasswordResetRequest {
|
timers: Record<string, MetricTimer>
|
||||||
email: string
|
}
|
||||||
}
|
|
||||||
|
export interface MultipleMarkRequest {
|
||||||
export interface ProfileModificationRequest {
|
requests: MarkRequest[]
|
||||||
currentPassword: string
|
}
|
||||||
email: string
|
|
||||||
newPassword?: string
|
export interface PasswordResetRequest {
|
||||||
newApiKey?: boolean
|
email: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface RegistrationRequest {
|
export interface PasswordResetConfirmationRequest {
|
||||||
name: string
|
email: string
|
||||||
password: string
|
token: string
|
||||||
email: string
|
password: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ServerInfo {
|
export interface ProfileModificationRequest {
|
||||||
announcement?: string
|
currentPassword: string
|
||||||
version: string
|
email: string
|
||||||
gitCommit: string
|
newPassword?: string
|
||||||
allowRegistrations: boolean
|
newApiKey?: boolean
|
||||||
googleAnalyticsCode?: string
|
}
|
||||||
smtpEnabled: boolean
|
|
||||||
demoAccountEnabled: boolean
|
export interface RegistrationRequest {
|
||||||
websocketEnabled: boolean
|
name: string
|
||||||
websocketPingInterval: number
|
password: string
|
||||||
treeReloadInterval: number
|
email: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SharingSettings {
|
export interface InitialSetupRequest {
|
||||||
email: boolean
|
name: string
|
||||||
gmail: boolean
|
password: string
|
||||||
facebook: boolean
|
email?: string
|
||||||
twitter: boolean
|
}
|
||||||
tumblr: boolean
|
|
||||||
pocket: boolean
|
export interface ServerInfo {
|
||||||
instapaper: boolean
|
announcement?: string
|
||||||
buffer: boolean
|
version: string
|
||||||
}
|
gitCommit: string
|
||||||
|
allowRegistrations: boolean
|
||||||
export interface Settings {
|
emailAddressRequired: boolean
|
||||||
language: string
|
smtpEnabled: boolean
|
||||||
readingMode: ReadingMode
|
demoAccountEnabled: boolean
|
||||||
readingOrder: ReadingOrder
|
websocketEnabled: boolean
|
||||||
showRead: boolean
|
websocketPingInterval: number
|
||||||
scrollMarks: boolean
|
treeReloadInterval: number
|
||||||
customCss?: string
|
forceRefreshCooldownDuration: number
|
||||||
customJs?: string
|
initialSetupRequired: boolean
|
||||||
scrollSpeed: number
|
minimumPasswordLength: number
|
||||||
scrollMode: ScrollMode
|
pushNotificationsEnabled: boolean
|
||||||
markAllAsReadConfirmation: boolean
|
}
|
||||||
customContextMenu: boolean
|
|
||||||
mobileFooter: boolean
|
export interface SharingSettings {
|
||||||
sharingSettings: SharingSettings
|
email: boolean
|
||||||
}
|
gmail: boolean
|
||||||
|
facebook: boolean
|
||||||
export interface StarRequest {
|
twitter: boolean
|
||||||
id: string
|
tumblr: boolean
|
||||||
feedId: number
|
pocket: boolean
|
||||||
starred: boolean
|
instapaper: boolean
|
||||||
}
|
buffer: boolean
|
||||||
|
}
|
||||||
export interface SubscribeRequest {
|
|
||||||
url: string
|
export type PushNotificationType = "ntfy" | "gotify" | "pushover"
|
||||||
title: string
|
|
||||||
categoryId?: string
|
export interface PushNotificationSettings {
|
||||||
}
|
type?: PushNotificationType
|
||||||
|
serverUrl?: string
|
||||||
export interface TagRequest {
|
userId?: string
|
||||||
entryId: number
|
userSecret?: string
|
||||||
tags: string[]
|
topic?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface UserModel {
|
export interface Settings {
|
||||||
id: number
|
language?: string
|
||||||
name: string
|
readingMode: ReadingMode
|
||||||
email?: string
|
readingOrder: ReadingOrder
|
||||||
apiKey?: string
|
showRead: boolean
|
||||||
password?: string
|
scrollMarks: boolean
|
||||||
enabled: boolean
|
customCss?: string
|
||||||
created: number
|
customJs?: string
|
||||||
lastLogin?: number
|
scrollSpeed: number
|
||||||
admin: boolean
|
scrollMode: ScrollMode
|
||||||
}
|
entriesToKeepOnTopWhenScrolling: number
|
||||||
|
starIconDisplayMode: IconDisplayMode
|
||||||
export interface AdminSaveUserRequest {
|
externalLinkIconDisplayMode: IconDisplayMode
|
||||||
id?: number
|
markAllAsReadConfirmation: boolean
|
||||||
name: string
|
markAllAsReadNavigateToNextUnread: boolean
|
||||||
email?: string
|
customContextMenu: boolean
|
||||||
password?: string
|
mobileFooter: boolean
|
||||||
enabled: boolean
|
unreadCountTitle: boolean
|
||||||
admin: boolean
|
unreadCountFavicon: boolean
|
||||||
}
|
disablePullToRefresh: boolean
|
||||||
|
disableMobileSwipe: boolean
|
||||||
export interface AuthenticationError {
|
infrequentThresholdDays: number
|
||||||
message: string
|
primaryColor?: string
|
||||||
allowRegistrations: boolean
|
sharingSettings: SharingSettings
|
||||||
}
|
pushNotificationSettings: PushNotificationSettings
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LocalSettings {
|
||||||
|
viewMode: ViewMode
|
||||||
|
sidebarWidth: number
|
||||||
|
announcementHash: string
|
||||||
|
fontSizePercentage: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface StarRequest {
|
||||||
|
id: string
|
||||||
|
feedId: number
|
||||||
|
starred: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SubscribeRequest {
|
||||||
|
url: string
|
||||||
|
title: string
|
||||||
|
categoryId?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TagRequest {
|
||||||
|
entryId: number
|
||||||
|
tags: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UserModel {
|
||||||
|
id: number
|
||||||
|
name: string
|
||||||
|
email?: string
|
||||||
|
apiKey?: string
|
||||||
|
password?: string
|
||||||
|
enabled: boolean
|
||||||
|
created: number
|
||||||
|
lastLogin?: number
|
||||||
|
admin: boolean
|
||||||
|
lastForceRefresh?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AdminSaveUserRequest {
|
||||||
|
id?: number
|
||||||
|
name: string
|
||||||
|
email?: string
|
||||||
|
password?: string
|
||||||
|
enabled: boolean
|
||||||
|
admin: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AuthenticationError {
|
||||||
|
message: string
|
||||||
|
allowRegistrations: boolean
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,108 +1,200 @@
|
|||||||
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, type UserModel } from "app/types"
|
import type { LocalSettings, Settings, UserModel, ViewMode } from "@/app/types"
|
||||||
import {
|
import {
|
||||||
changeCustomContextMenu,
|
changeCustomContextMenu,
|
||||||
changeLanguage,
|
changeDisableMobileSwipe,
|
||||||
changeMarkAllAsReadConfirmation,
|
changeDisablePullToRefresh,
|
||||||
changeMobileFooter,
|
changeEntriesToKeepOnTopWhenScrolling,
|
||||||
changeReadingMode,
|
changeExternalLinkIconDisplayMode,
|
||||||
changeReadingOrder,
|
changeInfrequentThresholdDays,
|
||||||
changeScrollMarks,
|
changeLanguage,
|
||||||
changeScrollMode,
|
changeMarkAllAsReadConfirmation,
|
||||||
changeScrollSpeed,
|
changeMarkAllAsReadNavigateToUnread,
|
||||||
changeSharingSetting,
|
changeMobileFooter,
|
||||||
changeShowRead,
|
changePrimaryColor,
|
||||||
reloadProfile,
|
changePushNotificationSettings,
|
||||||
reloadSettings,
|
changeReadingMode,
|
||||||
reloadTags,
|
changeReadingOrder,
|
||||||
} from "./thunks"
|
changeScrollMarks,
|
||||||
|
changeScrollMode,
|
||||||
interface UserState {
|
changeScrollSpeed,
|
||||||
settings?: Settings
|
changeSharingSetting,
|
||||||
profile?: UserModel
|
changeShowRead,
|
||||||
tags?: string[]
|
changeStarIconDisplayMode,
|
||||||
}
|
changeUnreadCountFavicon,
|
||||||
|
changeUnreadCountTitle,
|
||||||
const initialState: UserState = {}
|
reloadProfile,
|
||||||
|
reloadSettings,
|
||||||
export const userSlice = createSlice({
|
reloadTags,
|
||||||
name: "user",
|
} from "./thunks"
|
||||||
initialState,
|
|
||||||
reducers: {},
|
interface UserState {
|
||||||
extraReducers: builder => {
|
settings?: Settings
|
||||||
builder.addCase(reloadSettings.fulfilled, (state, action) => {
|
localSettings: LocalSettings
|
||||||
state.settings = action.payload
|
profile?: UserModel
|
||||||
})
|
tags?: string[]
|
||||||
builder.addCase(reloadProfile.fulfilled, (state, action) => {
|
}
|
||||||
state.profile = action.payload
|
|
||||||
})
|
export const initialLocalSettings: LocalSettings = {
|
||||||
builder.addCase(reloadTags.fulfilled, (state, action) => {
|
viewMode: "detailed",
|
||||||
state.tags = action.payload
|
sidebarWidth: 360,
|
||||||
})
|
announcementHash: "no-hash",
|
||||||
builder.addCase(changeReadingMode.pending, (state, action) => {
|
fontSizePercentage: 100,
|
||||||
if (!state.settings) return
|
}
|
||||||
state.settings.readingMode = action.meta.arg
|
|
||||||
})
|
const initialState: UserState = {
|
||||||
builder.addCase(changeReadingOrder.pending, (state, action) => {
|
localSettings: initialLocalSettings,
|
||||||
if (!state.settings) return
|
}
|
||||||
state.settings.readingOrder = action.meta.arg
|
|
||||||
})
|
export const userSlice = createSlice({
|
||||||
builder.addCase(changeLanguage.pending, (state, action) => {
|
name: "user",
|
||||||
if (!state.settings) return
|
initialState,
|
||||||
state.settings.language = action.meta.arg
|
reducers: {
|
||||||
})
|
setViewMode: (state, action: PayloadAction<ViewMode>) => {
|
||||||
builder.addCase(changeScrollSpeed.pending, (state, action) => {
|
state.localSettings.viewMode = action.payload
|
||||||
if (!state.settings) return
|
},
|
||||||
state.settings.scrollSpeed = action.meta.arg ? 400 : 0
|
setFontSizePercentage: (state, action: PayloadAction<number>) => {
|
||||||
})
|
state.localSettings.fontSizePercentage = action.payload
|
||||||
builder.addCase(changeShowRead.pending, (state, action) => {
|
},
|
||||||
if (!state.settings) return
|
setSidebarWidth: (state, action: PayloadAction<number>) => {
|
||||||
state.settings.showRead = action.meta.arg
|
state.localSettings.sidebarWidth = action.payload
|
||||||
})
|
},
|
||||||
builder.addCase(changeScrollMarks.pending, (state, action) => {
|
setAnnouncementHash: (state, action: PayloadAction<string>) => {
|
||||||
if (!state.settings) return
|
state.localSettings.announcementHash = action.payload
|
||||||
state.settings.scrollMarks = action.meta.arg
|
},
|
||||||
})
|
},
|
||||||
builder.addCase(changeScrollMode.pending, (state, action) => {
|
extraReducers: builder => {
|
||||||
if (!state.settings) return
|
builder.addCase(reloadSettings.fulfilled, (state, action) => {
|
||||||
state.settings.scrollMode = action.meta.arg
|
state.settings = action.payload
|
||||||
})
|
})
|
||||||
builder.addCase(changeMarkAllAsReadConfirmation.pending, (state, action) => {
|
builder.addCase(reloadProfile.fulfilled, (state, action) => {
|
||||||
if (!state.settings) return
|
state.profile = action.payload
|
||||||
state.settings.markAllAsReadConfirmation = action.meta.arg
|
})
|
||||||
})
|
builder.addCase(reloadTags.fulfilled, (state, action) => {
|
||||||
builder.addCase(changeCustomContextMenu.pending, (state, action) => {
|
state.tags = action.payload
|
||||||
if (!state.settings) return
|
})
|
||||||
state.settings.customContextMenu = action.meta.arg
|
builder.addCase(changeReadingMode.pending, (state, action) => {
|
||||||
})
|
if (!state.settings) return
|
||||||
builder.addCase(changeMobileFooter.pending, (state, action) => {
|
state.settings.readingMode = action.meta.arg
|
||||||
if (!state.settings) return
|
})
|
||||||
state.settings.mobileFooter = action.meta.arg
|
builder.addCase(changeReadingOrder.pending, (state, action) => {
|
||||||
})
|
if (!state.settings) return
|
||||||
builder.addCase(changeSharingSetting.pending, (state, action) => {
|
state.settings.readingOrder = action.meta.arg
|
||||||
if (!state.settings) return
|
})
|
||||||
state.settings.sharingSettings[action.meta.arg.site] = action.meta.arg.value
|
builder.addCase(changeLanguage.pending, (state, action) => {
|
||||||
})
|
if (!state.settings) return
|
||||||
builder.addMatcher(
|
state.settings.language = action.meta.arg
|
||||||
isAnyOf(
|
})
|
||||||
changeLanguage.fulfilled,
|
builder.addCase(changeScrollSpeed.pending, (state, action) => {
|
||||||
changeScrollSpeed.fulfilled,
|
if (!state.settings) return
|
||||||
changeShowRead.fulfilled,
|
state.settings.scrollSpeed = action.meta.arg ? 400 : 0
|
||||||
changeScrollMarks.fulfilled,
|
})
|
||||||
changeScrollMode.fulfilled,
|
builder.addCase(changeShowRead.pending, (state, action) => {
|
||||||
changeMarkAllAsReadConfirmation.fulfilled,
|
if (!state.settings) return
|
||||||
changeCustomContextMenu.fulfilled,
|
state.settings.showRead = action.meta.arg
|
||||||
changeMobileFooter.fulfilled,
|
})
|
||||||
changeSharingSetting.fulfilled
|
builder.addCase(changeScrollMarks.pending, (state, action) => {
|
||||||
),
|
if (!state.settings) return
|
||||||
() => {
|
state.settings.scrollMarks = action.meta.arg
|
||||||
showNotification({
|
})
|
||||||
message: t`Settings saved.`,
|
builder.addCase(changeScrollMode.pending, (state, action) => {
|
||||||
color: "green",
|
if (!state.settings) return
|
||||||
})
|
state.settings.scrollMode = action.meta.arg
|
||||||
}
|
})
|
||||||
)
|
builder.addCase(changeEntriesToKeepOnTopWhenScrolling.pending, (state, action) => {
|
||||||
},
|
if (!state.settings) return
|
||||||
})
|
state.settings.entriesToKeepOnTopWhenScrolling = action.meta.arg
|
||||||
|
})
|
||||||
|
builder.addCase(changeStarIconDisplayMode.pending, (state, action) => {
|
||||||
|
if (!state.settings) return
|
||||||
|
state.settings.starIconDisplayMode = action.meta.arg
|
||||||
|
})
|
||||||
|
builder.addCase(changeExternalLinkIconDisplayMode.pending, (state, action) => {
|
||||||
|
if (!state.settings) return
|
||||||
|
state.settings.externalLinkIconDisplayMode = action.meta.arg
|
||||||
|
})
|
||||||
|
builder.addCase(changeMarkAllAsReadConfirmation.pending, (state, action) => {
|
||||||
|
if (!state.settings) return
|
||||||
|
state.settings.markAllAsReadConfirmation = action.meta.arg
|
||||||
|
})
|
||||||
|
builder.addCase(changeMarkAllAsReadNavigateToUnread.pending, (state, action) => {
|
||||||
|
if (!state.settings) return
|
||||||
|
state.settings.markAllAsReadNavigateToNextUnread = action.meta.arg
|
||||||
|
})
|
||||||
|
builder.addCase(changeCustomContextMenu.pending, (state, action) => {
|
||||||
|
if (!state.settings) return
|
||||||
|
state.settings.customContextMenu = action.meta.arg
|
||||||
|
})
|
||||||
|
builder.addCase(changeMobileFooter.pending, (state, action) => {
|
||||||
|
if (!state.settings) return
|
||||||
|
state.settings.mobileFooter = action.meta.arg
|
||||||
|
})
|
||||||
|
builder.addCase(changeUnreadCountTitle.pending, (state, action) => {
|
||||||
|
if (!state.settings) return
|
||||||
|
state.settings.unreadCountTitle = action.meta.arg
|
||||||
|
})
|
||||||
|
builder.addCase(changeUnreadCountFavicon.pending, (state, action) => {
|
||||||
|
if (!state.settings) return
|
||||||
|
state.settings.unreadCountFavicon = action.meta.arg
|
||||||
|
})
|
||||||
|
builder.addCase(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) => {
|
||||||
|
if (!state.settings) return
|
||||||
|
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(
|
||||||
|
isAnyOf(
|
||||||
|
changeLanguage.fulfilled,
|
||||||
|
changeScrollSpeed.fulfilled,
|
||||||
|
changeShowRead.fulfilled,
|
||||||
|
changeScrollMarks.fulfilled,
|
||||||
|
changeScrollMode.fulfilled,
|
||||||
|
changeEntriesToKeepOnTopWhenScrolling.fulfilled,
|
||||||
|
changeStarIconDisplayMode.fulfilled,
|
||||||
|
changeExternalLinkIconDisplayMode.fulfilled,
|
||||||
|
changeMarkAllAsReadConfirmation.fulfilled,
|
||||||
|
changeMarkAllAsReadNavigateToUnread.fulfilled,
|
||||||
|
changeCustomContextMenu.fulfilled,
|
||||||
|
changeMobileFooter.fulfilled,
|
||||||
|
changeUnreadCountTitle.fulfilled,
|
||||||
|
changeUnreadCountFavicon.fulfilled,
|
||||||
|
changeDisablePullToRefresh.fulfilled,
|
||||||
|
changeDisableMobileSwipe.fulfilled,
|
||||||
|
changeInfrequentThresholdDays.fulfilled,
|
||||||
|
changePrimaryColor.fulfilled,
|
||||||
|
changeSharingSetting.fulfilled,
|
||||||
|
changePushNotificationSettings.fulfilled
|
||||||
|
),
|
||||||
|
() => {
|
||||||
|
showNotification({
|
||||||
|
message: t`Settings saved.`,
|
||||||
|
color: "green",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
export const { setViewMode, setSidebarWidth, setAnnouncementHash, setFontSizePercentage } = userSlice.actions
|
||||||
|
|||||||
@@ -1,83 +1,186 @@
|
|||||||
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 { 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 reloadTags = createAppAsyncThunk("entries/tags", async () => await client.entry.getTags().then(r => r.data))
|
export const reloadProfile = createAppAsyncThunk("profile/reload", async () => await client.user.getProfile().then(r => r.data))
|
||||||
export const changeReadingMode = createAppAsyncThunk("settings/readingMode", (readingMode: ReadingMode, thunkApi) => {
|
|
||||||
const { settings } = thunkApi.getState().user
|
export const reloadTags = createAppAsyncThunk("entries/tags", async () => await client.entry.getTags().then(r => r.data))
|
||||||
if (!settings) return
|
|
||||||
client.user.saveSettings({ ...settings, readingMode })
|
export const changeReadingMode = createAppAsyncThunk("settings/readingMode", (readingMode: ReadingMode, thunkApi) => {
|
||||||
thunkApi.dispatch(reloadEntries())
|
const { settings } = thunkApi.getState().user
|
||||||
})
|
if (!settings) return
|
||||||
export const changeReadingOrder = createAppAsyncThunk("settings/readingOrder", (readingOrder: ReadingOrder, thunkApi) => {
|
client.user.saveSettings({ ...settings, readingMode })
|
||||||
const { settings } = thunkApi.getState().user
|
thunkApi.dispatch(reloadEntries())
|
||||||
if (!settings) return
|
})
|
||||||
client.user.saveSettings({ ...settings, readingOrder })
|
|
||||||
thunkApi.dispatch(reloadEntries())
|
export const changeReadingOrder = createAppAsyncThunk("settings/readingOrder", (readingOrder: ReadingOrder, thunkApi) => {
|
||||||
})
|
const { settings } = thunkApi.getState().user
|
||||||
export const changeLanguage = createAppAsyncThunk("settings/language", (language: string, thunkApi) => {
|
if (!settings) return
|
||||||
const { settings } = thunkApi.getState().user
|
client.user.saveSettings({ ...settings, readingOrder })
|
||||||
if (!settings) return
|
thunkApi.dispatch(reloadEntries())
|
||||||
client.user.saveSettings({ ...settings, language })
|
})
|
||||||
})
|
|
||||||
export const changeScrollSpeed = createAppAsyncThunk("settings/scrollSpeed", (speed: boolean, 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, scrollSpeed: speed ? 400 : 0 })
|
client.user.saveSettings({ ...settings, language })
|
||||||
})
|
})
|
||||||
export const changeShowRead = createAppAsyncThunk("settings/showRead", (showRead: boolean, thunkApi) => {
|
|
||||||
const { settings } = thunkApi.getState().user
|
export const changeScrollSpeed = createAppAsyncThunk("settings/scrollSpeed", (speed: boolean, thunkApi) => {
|
||||||
if (!settings) return
|
const { settings } = thunkApi.getState().user
|
||||||
client.user.saveSettings({ ...settings, showRead })
|
if (!settings) return
|
||||||
})
|
client.user.saveSettings({ ...settings, scrollSpeed: speed ? 400 : 0 })
|
||||||
export const changeScrollMarks = createAppAsyncThunk("settings/scrollMarks", (scrollMarks: boolean, thunkApi) => {
|
})
|
||||||
const { settings } = thunkApi.getState().user
|
|
||||||
if (!settings) return
|
export const changeShowRead = createAppAsyncThunk("settings/showRead", (showRead: boolean, thunkApi) => {
|
||||||
client.user.saveSettings({ ...settings, scrollMarks })
|
const { settings } = thunkApi.getState().user
|
||||||
})
|
if (!settings) return
|
||||||
export const changeScrollMode = createAppAsyncThunk("settings/scrollMode", (scrollMode: ScrollMode, thunkApi) => {
|
client.user.saveSettings({ ...settings, showRead })
|
||||||
const { settings } = thunkApi.getState().user
|
})
|
||||||
if (!settings) return
|
|
||||||
client.user.saveSettings({ ...settings, scrollMode })
|
export const changeScrollMarks = createAppAsyncThunk("settings/scrollMarks", (scrollMarks: boolean, thunkApi) => {
|
||||||
})
|
const { settings } = thunkApi.getState().user
|
||||||
export const changeMarkAllAsReadConfirmation = createAppAsyncThunk(
|
if (!settings) return
|
||||||
"settings/markAllAsReadConfirmation",
|
client.user.saveSettings({ ...settings, scrollMarks })
|
||||||
(markAllAsReadConfirmation: boolean, thunkApi) => {
|
})
|
||||||
const { settings } = thunkApi.getState().user
|
|
||||||
if (!settings) return
|
export const changeScrollMode = createAppAsyncThunk("settings/scrollMode", (scrollMode: ScrollMode, thunkApi) => {
|
||||||
client.user.saveSettings({ ...settings, markAllAsReadConfirmation })
|
const { settings } = thunkApi.getState().user
|
||||||
}
|
if (!settings) return
|
||||||
)
|
client.user.saveSettings({ ...settings, scrollMode })
|
||||||
export const changeCustomContextMenu = createAppAsyncThunk("settings/customContextMenu", (customContextMenu: boolean, thunkApi) => {
|
})
|
||||||
const { settings } = thunkApi.getState().user
|
|
||||||
if (!settings) return
|
export const changeEntriesToKeepOnTopWhenScrolling = createAppAsyncThunk(
|
||||||
client.user.saveSettings({ ...settings, customContextMenu })
|
"settings/entriesToKeepOnTopWhenScrolling",
|
||||||
})
|
(entriesToKeepOnTopWhenScrolling: number, 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, entriesToKeepOnTopWhenScrolling })
|
||||||
client.user.saveSettings({ ...settings, mobileFooter })
|
}
|
||||||
})
|
)
|
||||||
export const changeSharingSetting = createAppAsyncThunk(
|
|
||||||
"settings/sharingSetting",
|
export const changeStarIconDisplayMode = createAppAsyncThunk(
|
||||||
(
|
"settings/starIconDisplayMode",
|
||||||
sharingSetting: {
|
(starIconDisplayMode: IconDisplayMode, thunkApi) => {
|
||||||
site: keyof SharingSettings
|
const { settings } = thunkApi.getState().user
|
||||||
value: boolean
|
if (!settings) return
|
||||||
},
|
client.user.saveSettings({ ...settings, starIconDisplayMode })
|
||||||
thunkApi
|
}
|
||||||
) => {
|
)
|
||||||
const { settings } = thunkApi.getState().user
|
|
||||||
if (!settings) return
|
export const changeExternalLinkIconDisplayMode = createAppAsyncThunk(
|
||||||
client.user.saveSettings({
|
"settings/externalLinkIconDisplayMode",
|
||||||
...settings,
|
(externalLinkIconDisplayMode: IconDisplayMode, thunkApi) => {
|
||||||
sharingSettings: {
|
const { settings } = thunkApi.getState().user
|
||||||
...settings.sharingSettings,
|
if (!settings) return
|
||||||
[sharingSetting.site]: sharingSetting.value,
|
client.user.saveSettings({ ...settings, externalLinkIconDisplayMode })
|
||||||
},
|
}
|
||||||
})
|
)
|
||||||
}
|
|
||||||
)
|
export const changeMarkAllAsReadConfirmation = createAppAsyncThunk(
|
||||||
|
"settings/markAllAsReadConfirmation",
|
||||||
|
(markAllAsReadConfirmation: boolean, thunkApi) => {
|
||||||
|
const { settings } = thunkApi.getState().user
|
||||||
|
if (!settings) return
|
||||||
|
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) => {
|
||||||
|
const { settings } = thunkApi.getState().user
|
||||||
|
if (!settings) return
|
||||||
|
client.user.saveSettings({ ...settings, customContextMenu })
|
||||||
|
})
|
||||||
|
|
||||||
|
export const changeMobileFooter = createAppAsyncThunk("settings/mobileFooter", (mobileFooter: boolean, thunkApi) => {
|
||||||
|
const { settings } = thunkApi.getState().user
|
||||||
|
if (!settings) return
|
||||||
|
client.user.saveSettings({ ...settings, mobileFooter })
|
||||||
|
})
|
||||||
|
|
||||||
|
export const changeUnreadCountTitle = createAppAsyncThunk("settings/unreadCountTitle", (unreadCountTitle: boolean, thunkApi) => {
|
||||||
|
const { settings } = thunkApi.getState().user
|
||||||
|
if (!settings) return
|
||||||
|
client.user.saveSettings({ ...settings, unreadCountTitle })
|
||||||
|
})
|
||||||
|
|
||||||
|
export const changeUnreadCountFavicon = createAppAsyncThunk("settings/unreadCountFavicon", (unreadCountFavicon: boolean, thunkApi) => {
|
||||||
|
const { settings } = thunkApi.getState().user
|
||||||
|
if (!settings) return
|
||||||
|
client.user.saveSettings({ ...settings, unreadCountFavicon })
|
||||||
|
})
|
||||||
|
|
||||||
|
export const 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(
|
||||||
|
"settings/sharingSetting",
|
||||||
|
(
|
||||||
|
sharingSetting: {
|
||||||
|
site: keyof SharingSettings
|
||||||
|
value: boolean
|
||||||
|
},
|
||||||
|
thunkApi
|
||||||
|
) => {
|
||||||
|
const { settings } = thunkApi.getState().user
|
||||||
|
if (!settings) return
|
||||||
|
client.user.saveSettings({
|
||||||
|
...settings,
|
||||||
|
sharingSettings: {
|
||||||
|
...settings.sharingSettings,
|
||||||
|
[sharingSetting.site]: sharingSetting.value,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
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,47 +1,71 @@
|
|||||||
import { throttle } from "throttle-debounce"
|
import { throttle } from "throttle-debounce"
|
||||||
import { type Category } from "./types"
|
import type { TreeCategory } from "@/app/tree/slice"
|
||||||
|
import type { Category } from "./types"
|
||||||
export function visitCategoryTree(category: Category, visitor: (category: Category) => void): void {
|
|
||||||
visitor(category)
|
export function visitCategoryTree(
|
||||||
category.children.forEach(child => visitCategoryTree(child, visitor))
|
category: TreeCategory,
|
||||||
}
|
visitor: (category: TreeCategory) => void,
|
||||||
|
options?: {
|
||||||
export function flattenCategoryTree(category: Category): Category[] {
|
childrenFirst?: boolean
|
||||||
const categories: Category[] = []
|
}
|
||||||
visitCategoryTree(category, c => categories.push(c))
|
): void {
|
||||||
return categories
|
const childrenFirst = options?.childrenFirst
|
||||||
}
|
|
||||||
|
if (!childrenFirst) visitor(category)
|
||||||
export function categoryUnreadCount(category?: Category): number {
|
|
||||||
if (!category) return 0
|
for (const child of category.children) {
|
||||||
|
visitCategoryTree(child, visitor, options)
|
||||||
return flattenCategoryTree(category)
|
}
|
||||||
.flatMap(c => c.feeds)
|
|
||||||
.map(f => f.unread)
|
if (childrenFirst) visitor(category)
|
||||||
.reduce((total, current) => total + current, 0)
|
}
|
||||||
}
|
|
||||||
|
export function flattenCategoryTree(category: TreeCategory): TreeCategory[] {
|
||||||
export const calculatePlaceholderSize = ({ width, height, maxWidth }: { width?: number; height?: number; maxWidth: number }) => {
|
const categories: Category[] = []
|
||||||
const placeholderWidth = width && Math.min(width, maxWidth)
|
visitCategoryTree(category, c => categories.push(c))
|
||||||
const placeholderHeight = height && width && width > maxWidth ? height * (maxWidth / width) : height
|
return categories
|
||||||
return { width: placeholderWidth, height: placeholderHeight }
|
}
|
||||||
}
|
|
||||||
|
export function categoryUnreadCount(category?: TreeCategory, maxFrequencyThresholdMs?: number): number {
|
||||||
export const scrollToWithCallback = ({ options, onScrollEnded }: { options: ScrollToOptions; onScrollEnded: () => void }) => {
|
if (!category) return 0
|
||||||
const offset = (options.top ?? 0).toFixed()
|
|
||||||
|
return flattenCategoryTree(category)
|
||||||
const onScroll = throttle(100, () => {
|
.flatMap(c => c.feeds)
|
||||||
if (window.scrollY.toFixed() === offset) {
|
.filter(f => !maxFrequencyThresholdMs || (f.averageEntryIntervalMs && f.averageEntryIntervalMs >= maxFrequencyThresholdMs))
|
||||||
window.removeEventListener("scroll", onScroll)
|
.map(f => f.unread)
|
||||||
onScrollEnded()
|
.reduce((total, current) => total + current, 0)
|
||||||
}
|
}
|
||||||
})
|
|
||||||
window.addEventListener("scroll", onScroll)
|
export function categoryHasNewEntries(category?: TreeCategory, maxFrequencyThresholdMs?: number): boolean {
|
||||||
|
if (!category) return false
|
||||||
// scrollTo does not trigger if there's nothing to do, trigger it manually
|
|
||||||
onScroll()
|
return flattenCategoryTree(category)
|
||||||
|
.flatMap(c => c.feeds)
|
||||||
window.scrollTo(options)
|
.filter(f => !maxFrequencyThresholdMs || (f.averageEntryIntervalMs && f.averageEntryIntervalMs >= maxFrequencyThresholdMs))
|
||||||
}
|
.some(f => f.hasNewEntries)
|
||||||
|
}
|
||||||
export const truncate = (str: string, n: number) => (str.length > n ? `${str.slice(0, n - 1)}\u2026` : str)
|
|
||||||
|
export const calculatePlaceholderSize = ({ width, height, maxWidth }: { width?: number; height?: number; maxWidth: number }) => {
|
||||||
|
const placeholderWidth = width && Math.min(width, maxWidth)
|
||||||
|
const placeholderHeight = height && width && width > maxWidth ? height * (maxWidth / width) : height
|
||||||
|
return { width: placeholderWidth, height: placeholderHeight }
|
||||||
|
}
|
||||||
|
|
||||||
|
export const scrollToWithCallback = ({ options, onScrollEnded }: { options: ScrollToOptions; onScrollEnded: () => void }) => {
|
||||||
|
const offset = (options.top ?? 0).toFixed()
|
||||||
|
|
||||||
|
const onScroll = throttle(100, () => {
|
||||||
|
if (window.scrollY.toFixed() === offset) {
|
||||||
|
window.removeEventListener("scroll", onScroll)
|
||||||
|
onScrollEnded()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
window.addEventListener("scroll", onScroll)
|
||||||
|
|
||||||
|
// scrollTo does not trigger if there's nothing to do, trigger it manually
|
||||||
|
onScroll()
|
||||||
|
|
||||||
|
window.scrollTo(options)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const truncate = (str: string, n: number) => (str.length > n ? `${str.slice(0, n - 1)}\u2026` : str)
|
||||||
|
|||||||
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,36 +1,62 @@
|
|||||||
import { ActionIcon, Button, type ButtonVariant, Tooltip, useMantineTheme } from "@mantine/core"
|
import type { MessageDescriptor } from "@lingui/core"
|
||||||
import { type ActionIconVariant } from "@mantine/core/lib/components/ActionIcon/ActionIcon"
|
import { useLingui } from "@lingui/react"
|
||||||
import { useActionButton } from "hooks/useActionButton"
|
import { ActionIcon, Box, Button, type ButtonVariant, Tooltip, useMantineTheme } from "@mantine/core"
|
||||||
import { forwardRef, type MouseEventHandler, type ReactNode } from "react"
|
import type { ActionIconVariant } from "@mantine/core/lib/components/ActionIcon/ActionIcon"
|
||||||
|
import { forwardRef, type MouseEventHandler, type ReactNode } from "react"
|
||||||
interface ActionButtonProps {
|
import { Constants } from "@/app/constants"
|
||||||
className?: string
|
import { useActionButton } from "@/hooks/useActionButton"
|
||||||
icon?: ReactNode
|
|
||||||
label: ReactNode
|
interface ActionButtonProps {
|
||||||
onClick?: MouseEventHandler
|
icon: ReactNode
|
||||||
variant?: ActionIconVariant & ButtonVariant
|
className?: string
|
||||||
hideLabelOnDesktop?: boolean
|
label?: string | MessageDescriptor
|
||||||
showLabelOnMobile?: boolean
|
onClick?: MouseEventHandler
|
||||||
}
|
variant?: ActionIconVariant & ButtonVariant
|
||||||
|
hideLabelOnDesktop?: boolean
|
||||||
/**
|
showLabelOnMobile?: boolean
|
||||||
* Switches between Button with label (desktop) and ActionIcon (mobile)
|
}
|
||||||
*/
|
|
||||||
export const ActionButton = forwardRef<HTMLButtonElement, ActionButtonProps>((props: ActionButtonProps, ref) => {
|
/**
|
||||||
const { mobile } = useActionButton()
|
* Switches between Button with label (desktop) and ActionIcon (mobile)
|
||||||
const theme = useMantineTheme()
|
*/
|
||||||
const variant = props.variant ?? "subtle"
|
export const ActionButton = forwardRef<HTMLDivElement, ActionButtonProps>((props: ActionButtonProps, ref) => {
|
||||||
const iconOnly = (mobile && !props.showLabelOnMobile) || (!mobile && props.hideLabelOnDesktop)
|
const { mobile } = useActionButton()
|
||||||
return iconOnly ? (
|
const theme = useMantineTheme()
|
||||||
<Tooltip label={props.label} openDelay={500}>
|
const { _ } = useLingui()
|
||||||
<ActionIcon ref={ref} color={theme.primaryColor} variant={variant} className={props.className} onClick={props.onClick}>
|
|
||||||
{props.icon}
|
const label = typeof props.label === "string" ? props.label : props.label && _(props.label)
|
||||||
</ActionIcon>
|
const variant = props.variant ?? "subtle"
|
||||||
</Tooltip>
|
const iconOnly = (mobile && !props.showLabelOnMobile) || (!mobile && props.hideLabelOnDesktop)
|
||||||
) : (
|
|
||||||
<Button ref={ref} variant={variant} size="xs" className={props.className} leftSection={props.icon} onClick={props.onClick}>
|
return (
|
||||||
{props.label}
|
<Box ref={ref} className="cf-action-button">
|
||||||
</Button>
|
{iconOnly && (
|
||||||
)
|
<Tooltip label={label} openDelay={Constants.tooltip.delay}>
|
||||||
})
|
<ActionIcon
|
||||||
ActionButton.displayName = "HeaderButton"
|
color={theme.primaryColor}
|
||||||
|
variant={variant}
|
||||||
|
className={props.className}
|
||||||
|
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"
|
||||||
|
|||||||
@@ -1,47 +1,47 @@
|
|||||||
import { Trans } from "@lingui/macro"
|
import { Trans } from "@lingui/react/macro"
|
||||||
import { Alert as MantineAlert, Box } 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"
|
||||||
|
|
||||||
type Level = "error" | "warning" | "success"
|
type Level = "error" | "warning" | "success"
|
||||||
|
|
||||||
export interface ErrorsAlertProps {
|
export interface ErrorsAlertProps {
|
||||||
level?: Level
|
level?: Level
|
||||||
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
|
||||||
|
|
||||||
const level = props.level ?? "error"
|
const level = props.level ?? "error"
|
||||||
switch (level) {
|
switch (level) {
|
||||||
case "error":
|
case "error":
|
||||||
title = <Trans>Error</Trans>
|
title = <Trans>Error</Trans>
|
||||||
color = "red"
|
color = "red"
|
||||||
icon = <TbAlertCircle />
|
icon = <TbAlertCircle />
|
||||||
break
|
break
|
||||||
case "warning":
|
case "warning":
|
||||||
title = <Trans>Warning</Trans>
|
title = <Trans>Warning</Trans>
|
||||||
color = "orange"
|
color = "orange"
|
||||||
icon = <TbAlertTriangle />
|
icon = <TbAlertTriangle />
|
||||||
break
|
break
|
||||||
case "success":
|
case "success":
|
||||||
title = <Trans>Success</Trans>
|
title = <Trans>Success</Trans>
|
||||||
color = "green"
|
color = "green"
|
||||||
icon = <TbCircleCheck />
|
icon = <TbCircleCheck />
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<MantineAlert title={title} color={color} icon={icon}>
|
<MantineAlert title={title} color={color} icon={icon}>
|
||||||
{props.messages.map((m, i) => (
|
{props.messages.map((m, i) => (
|
||||||
<Fragment key={m}>
|
<Fragment key={m}>
|
||||||
<Box>{m}</Box>
|
<Box>{m}</Box>
|
||||||
{i !== props.messages.length - 1 && <br />}
|
{i !== props.messages.length - 1 && <br />}
|
||||||
</Fragment>
|
</Fragment>
|
||||||
))}
|
))}
|
||||||
</MantineAlert>
|
</MantineAlert>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 (
|
||||||
|
|||||||
3
commafeed-client/src/components/DisablePullToRefresh.tsx
Normal file
3
commafeed-client/src/components/DisablePullToRefresh.tsx
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
export const DisablePullToRefresh = ({ enabled }: { enabled: boolean | undefined }) => {
|
||||||
|
return enabled ? <style>{`html, body { overscroll-behavior: none; }`}</style> : null
|
||||||
|
}
|
||||||
@@ -1,26 +1,26 @@
|
|||||||
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
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ErrorBoundaryState {
|
interface ErrorBoundaryState {
|
||||||
error?: Error
|
error?: Error
|
||||||
}
|
}
|
||||||
|
|
||||||
export class ErrorBoundary extends React.Component<ErrorBoundaryProps, ErrorBoundaryState> {
|
export class ErrorBoundary extends React.Component<ErrorBoundaryProps, ErrorBoundaryState> {
|
||||||
constructor(props: ErrorBoundaryProps) {
|
constructor(props: ErrorBoundaryProps) {
|
||||||
super(props)
|
super(props)
|
||||||
this.state = {}
|
this.state = {}
|
||||||
}
|
}
|
||||||
|
|
||||||
componentDidCatch(error: Error) {
|
componentDidCatch(error: Error) {
|
||||||
this.setState({ error })
|
this.setState({ error })
|
||||||
}
|
}
|
||||||
|
|
||||||
render() {
|
render() {
|
||||||
if (this.state.error) return <ErrorPage error={this.state.error} />
|
if (this.state.error) return <ErrorPage error={this.state.error} />
|
||||||
return this.props.children
|
return this.props.children
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,75 +1,81 @@
|
|||||||
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
|
||||||
alt: string
|
alt: string
|
||||||
title?: string
|
title?: string
|
||||||
width?: number
|
width?: number
|
||||||
height?: number | "auto"
|
height?: number | "auto"
|
||||||
placeholderWidth?: number
|
style?: React.CSSProperties
|
||||||
placeholderHeight?: number
|
placeholderWidth?: number
|
||||||
placeholderBackgroundColor?: string
|
placeholderHeight?: number
|
||||||
placeholderIconSize?: number
|
placeholderBackgroundColor?: string
|
||||||
}
|
placeholderIconSize?: number
|
||||||
|
}
|
||||||
const useStyles = tss
|
|
||||||
.withParams<{
|
const useStyles = tss
|
||||||
placeholderWidth?: number
|
.withParams<{
|
||||||
placeholderHeight?: number
|
placeholderWidth?: number
|
||||||
placeholderBackgroundColor?: string
|
placeholderHeight?: number
|
||||||
}>()
|
placeholderBackgroundColor?: string
|
||||||
.create(props => ({
|
}>()
|
||||||
placeholder: {
|
.create(props => ({
|
||||||
width: props.placeholderWidth ?? 400,
|
placeholder: {
|
||||||
height: props.placeholderHeight ?? 600,
|
width: props.placeholderWidth ?? 400,
|
||||||
maxWidth: "100%",
|
height: props.placeholderHeight ?? 600,
|
||||||
backgroundColor:
|
maxWidth: "100%",
|
||||||
props.placeholderBackgroundColor ??
|
backgroundColor:
|
||||||
(props.colorScheme === "dark" ? props.theme.colors.dark[5] : props.theme.colors.gray[1]),
|
props.placeholderBackgroundColor ??
|
||||||
},
|
(props.colorScheme === "dark" ? props.theme.colors.dark[5] : props.theme.colors.gray[1]),
|
||||||
}))
|
},
|
||||||
|
}))
|
||||||
export function ImageWithPlaceholderWhileLoading({
|
|
||||||
alt,
|
export function ImageWithPlaceholderWhileLoading({
|
||||||
height,
|
alt,
|
||||||
placeholderBackgroundColor,
|
height,
|
||||||
placeholderHeight,
|
placeholderBackgroundColor,
|
||||||
placeholderIconSize,
|
placeholderHeight,
|
||||||
placeholderWidth,
|
placeholderIconSize,
|
||||||
src,
|
placeholderWidth,
|
||||||
title,
|
src,
|
||||||
width,
|
title,
|
||||||
}: ImageWithPlaceholderWhileLoadingProps) {
|
width,
|
||||||
const { classes } = useStyles({
|
style,
|
||||||
placeholderWidth,
|
}: Readonly<ImageWithPlaceholderWhileLoadingProps>) {
|
||||||
placeholderHeight,
|
const { classes } = useStyles({
|
||||||
placeholderBackgroundColor,
|
placeholderWidth,
|
||||||
})
|
placeholderHeight,
|
||||||
const [loading, setLoading] = useState(true)
|
placeholderBackgroundColor,
|
||||||
|
})
|
||||||
return (
|
const [loading, setLoading] = useState(true)
|
||||||
<>
|
|
||||||
{loading && (
|
return (
|
||||||
<Box>
|
<>
|
||||||
<Center className={classes.placeholder}>
|
{loading && (
|
||||||
<div>
|
<Box>
|
||||||
<TbPhoto size={placeholderIconSize ?? 48} />
|
<Center className={classes.placeholder}>
|
||||||
</div>
|
<div>
|
||||||
</Center>
|
<TbPhoto size={placeholderIconSize ?? 48} />
|
||||||
</Box>
|
</div>
|
||||||
)}
|
</Center>
|
||||||
<img
|
</Box>
|
||||||
src={src}
|
)}
|
||||||
alt={alt}
|
<img
|
||||||
title={title}
|
src={src}
|
||||||
width={width}
|
alt={alt}
|
||||||
height={height}
|
title={title}
|
||||||
onLoad={() => setLoading(false)}
|
width={width}
|
||||||
style={{ display: loading ? "none" : "block" }}
|
height={height}
|
||||||
/>
|
onLoad={() => setLoading(false)}
|
||||||
</>
|
style={{
|
||||||
)
|
...style,
|
||||||
}
|
display: loading ? "none" : (style?.display ?? "initial"),
|
||||||
|
height: style?.width ? "auto" : style?.height,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,222 +1,242 @@
|
|||||||
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 { Constants } from "app/constants"
|
import { useOs } from "@mantine/hooks"
|
||||||
|
import { Constants } from "@/app/constants"
|
||||||
export function KeyboardShortcutsHelp() {
|
|
||||||
return (
|
export function KeyboardShortcutsHelp() {
|
||||||
<Stack gap="xs">
|
const isMacOS = useOs() === "macos"
|
||||||
<Table striped highlightOnHover>
|
return (
|
||||||
<Table.Tbody>
|
<Stack gap="xs">
|
||||||
<Table.Tr>
|
<Table striped highlightOnHover>
|
||||||
<Table.Td>
|
<Table.Tbody>
|
||||||
<Trans>Refresh</Trans>
|
<Table.Tr>
|
||||||
</Table.Td>
|
<Table.Td>
|
||||||
<Table.Td>
|
<Trans>Refresh</Trans>
|
||||||
<Kbd>R</Kbd>
|
</Table.Td>
|
||||||
</Table.Td>
|
<Table.Td>
|
||||||
</Table.Tr>
|
<Kbd>R</Kbd>
|
||||||
<Table.Tr>
|
</Table.Td>
|
||||||
<Table.Td>
|
</Table.Tr>
|
||||||
<Trans>Open next entry</Trans>
|
<Table.Tr>
|
||||||
</Table.Td>
|
<Table.Td>
|
||||||
<Table.Td>
|
<Trans>Open next entry</Trans>
|
||||||
<Kbd>J</Kbd>
|
</Table.Td>
|
||||||
</Table.Td>
|
<Table.Td>
|
||||||
</Table.Tr>
|
<Kbd>J</Kbd>
|
||||||
<Table.Tr>
|
</Table.Td>
|
||||||
<Table.Td>
|
</Table.Tr>
|
||||||
<Trans>Open previous entry</Trans>
|
<Table.Tr>
|
||||||
</Table.Td>
|
<Table.Td>
|
||||||
<Table.Td>
|
<Trans>Open previous entry</Trans>
|
||||||
<Kbd>K</Kbd>
|
</Table.Td>
|
||||||
</Table.Td>
|
<Table.Td>
|
||||||
</Table.Tr>
|
<Kbd>K</Kbd>
|
||||||
<Table.Tr>
|
</Table.Td>
|
||||||
<Table.Td>
|
</Table.Tr>
|
||||||
<Trans>Set focus on next entry without opening it</Trans>
|
<Table.Tr>
|
||||||
</Table.Td>
|
<Table.Td>
|
||||||
<Table.Td>
|
<Trans>Select next unread feed/category</Trans>
|
||||||
<Kbd>N</Kbd>
|
</Table.Td>
|
||||||
</Table.Td>
|
<Table.Td>
|
||||||
</Table.Tr>
|
<Kbd>Shift</Kbd>
|
||||||
<Table.Tr>
|
<span> + </span>
|
||||||
<Table.Td>
|
<Kbd>J</Kbd>
|
||||||
<Trans>Set focus on previous entry without opening it</Trans>
|
</Table.Td>
|
||||||
</Table.Td>
|
</Table.Tr>
|
||||||
<Table.Td>
|
<Table.Tr>
|
||||||
<Kbd>P</Kbd>
|
<Table.Td>
|
||||||
</Table.Td>
|
<Trans>Select previous unread feed/category</Trans>
|
||||||
</Table.Tr>
|
</Table.Td>
|
||||||
<Table.Tr>
|
<Table.Td>
|
||||||
<Table.Td>
|
<Kbd>Shift</Kbd>
|
||||||
<Trans>Move the page down</Trans>
|
<span> + </span>
|
||||||
</Table.Td>
|
<Kbd>K</Kbd>
|
||||||
<Table.Td>
|
</Table.Td>
|
||||||
<Kbd>
|
</Table.Tr>
|
||||||
<Trans>Space</Trans>
|
<Table.Tr>
|
||||||
</Kbd>
|
<Table.Td>
|
||||||
</Table.Td>
|
<Trans>Set focus on next entry without opening it</Trans>
|
||||||
</Table.Tr>
|
</Table.Td>
|
||||||
<Table.Tr>
|
<Table.Td>
|
||||||
<Table.Td>
|
<Kbd>N</Kbd>
|
||||||
<Trans>Move the page up</Trans>
|
</Table.Td>
|
||||||
</Table.Td>
|
</Table.Tr>
|
||||||
<Table.Td>
|
<Table.Tr>
|
||||||
<Kbd>
|
<Table.Td>
|
||||||
<Trans>Shift</Trans>
|
<Trans>Set focus on previous entry without opening it</Trans>
|
||||||
</Kbd>
|
</Table.Td>
|
||||||
<span> + </span>
|
<Table.Td>
|
||||||
<Kbd>
|
<Kbd>P</Kbd>
|
||||||
<Trans>Space</Trans>
|
</Table.Td>
|
||||||
</Kbd>
|
</Table.Tr>
|
||||||
</Table.Td>
|
<Table.Tr>
|
||||||
</Table.Tr>
|
<Table.Td>
|
||||||
<Table.Tr>
|
<Trans>Move the page down</Trans>
|
||||||
<Table.Td>
|
</Table.Td>
|
||||||
<Trans>Open/close current entry</Trans>
|
<Table.Td>
|
||||||
</Table.Td>
|
<Kbd>
|
||||||
<Table.Td>
|
<Trans>Space</Trans>
|
||||||
<Kbd>O</Kbd>
|
</Kbd>
|
||||||
<span>, </span>
|
</Table.Td>
|
||||||
<Kbd>
|
</Table.Tr>
|
||||||
<Trans>Enter</Trans>
|
<Table.Tr>
|
||||||
</Kbd>
|
<Table.Td>
|
||||||
</Table.Td>
|
<Trans>Move the page up</Trans>
|
||||||
</Table.Tr>
|
</Table.Td>
|
||||||
<Table.Tr>
|
<Table.Td>
|
||||||
<Table.Td>
|
<Kbd>
|
||||||
<Trans>Open current entry in a new tab</Trans>
|
<Trans>Shift</Trans>
|
||||||
</Table.Td>
|
</Kbd>
|
||||||
<Table.Td>
|
<span> + </span>
|
||||||
<Kbd>V</Kbd>
|
<Kbd>
|
||||||
</Table.Td>
|
<Trans>Space</Trans>
|
||||||
</Table.Tr>
|
</Kbd>
|
||||||
<Table.Tr>
|
</Table.Td>
|
||||||
<Table.Td>
|
</Table.Tr>
|
||||||
<Trans>Open current entry in a new tab in the background</Trans>
|
<Table.Tr>
|
||||||
</Table.Td>
|
<Table.Td>
|
||||||
<Table.Td>
|
<Trans>Open/close current entry</Trans>
|
||||||
<Kbd>B</Kbd>
|
</Table.Td>
|
||||||
<span>*, </span>
|
<Table.Td>
|
||||||
<Kbd>
|
<Kbd>O</Kbd>
|
||||||
<Trans>Middle click</Trans>
|
<span>, </span>
|
||||||
</Kbd>
|
<Kbd>
|
||||||
</Table.Td>
|
<Trans>Enter</Trans>
|
||||||
</Table.Tr>
|
</Kbd>
|
||||||
<Table.Tr>
|
</Table.Td>
|
||||||
<Table.Td>
|
</Table.Tr>
|
||||||
<Trans>Toggle read status of current entry</Trans>
|
<Table.Tr>
|
||||||
</Table.Td>
|
<Table.Td>
|
||||||
<Table.Td>
|
<Trans>Open current entry in a new tab</Trans>
|
||||||
<Kbd>M</Kbd>
|
</Table.Td>
|
||||||
<span>, </span>
|
<Table.Td>
|
||||||
<Trans>Swipe header to the left</Trans>
|
<Kbd>V</Kbd>
|
||||||
</Table.Td>
|
</Table.Td>
|
||||||
</Table.Tr>
|
</Table.Tr>
|
||||||
<Table.Tr>
|
<Table.Tr>
|
||||||
<Table.Td>
|
<Table.Td>
|
||||||
<Trans>Toggle starred status of current entry</Trans>
|
<Trans>Open current entry in a new tab in the background</Trans>
|
||||||
</Table.Td>
|
</Table.Td>
|
||||||
<Table.Td>
|
<Table.Td>
|
||||||
<Kbd>S</Kbd>
|
<Kbd>B</Kbd>
|
||||||
</Table.Td>
|
<span>*, </span>
|
||||||
</Table.Tr>
|
<Kbd>
|
||||||
<Table.Tr>
|
<Trans>Middle click</Trans>
|
||||||
<Table.Td>
|
</Kbd>
|
||||||
<Trans>Mark all entries as read</Trans>
|
</Table.Td>
|
||||||
</Table.Td>
|
</Table.Tr>
|
||||||
<Table.Td>
|
<Table.Tr>
|
||||||
<Kbd>
|
<Table.Td>
|
||||||
<Trans>Shift</Trans>
|
<Trans>Toggle read status of current entry</Trans>
|
||||||
</Kbd>
|
</Table.Td>
|
||||||
<span> + </span>
|
<Table.Td>
|
||||||
<Kbd>A</Kbd>
|
<Kbd>M</Kbd>
|
||||||
</Table.Td>
|
<span>, </span>
|
||||||
</Table.Tr>
|
<Trans>Swipe header to the left</Trans>
|
||||||
<Table.Tr>
|
</Table.Td>
|
||||||
<Table.Td>
|
</Table.Tr>
|
||||||
<Trans>Go to the All view</Trans>
|
<Table.Tr>
|
||||||
</Table.Td>
|
<Table.Td>
|
||||||
<Table.Td>
|
<Trans>Toggle starred status of current entry</Trans>
|
||||||
<Kbd>G</Kbd>
|
</Table.Td>
|
||||||
<span> </span>
|
<Table.Td>
|
||||||
<Kbd>A</Kbd>
|
<Kbd>S</Kbd>
|
||||||
</Table.Td>
|
</Table.Td>
|
||||||
</Table.Tr>
|
</Table.Tr>
|
||||||
<Table.Tr>
|
<Table.Tr>
|
||||||
<Table.Td>
|
<Table.Td>
|
||||||
<Trans>Navigate to a subscription by entering its name</Trans>
|
<Trans>Mark all entries as read</Trans>
|
||||||
</Table.Td>
|
</Table.Td>
|
||||||
<Table.Td>
|
<Table.Td>
|
||||||
<Kbd>
|
<Kbd>
|
||||||
<Trans>Ctrl</Trans>
|
<Trans>Shift</Trans>
|
||||||
</Kbd>
|
</Kbd>
|
||||||
<span> + </span>
|
<span> + </span>
|
||||||
<Kbd>K</Kbd>
|
<Kbd>A</Kbd>
|
||||||
<span>, </span>
|
</Table.Td>
|
||||||
<Kbd>G</Kbd>
|
</Table.Tr>
|
||||||
<span> </span>
|
<Table.Tr>
|
||||||
<Kbd>U</Kbd>
|
<Table.Td>
|
||||||
</Table.Td>
|
<Trans>Go to the All view</Trans>
|
||||||
</Table.Tr>
|
</Table.Td>
|
||||||
<Table.Tr>
|
<Table.Td>
|
||||||
<Table.Td>
|
<Kbd>G</Kbd>
|
||||||
<Trans>Show entry menu (desktop)</Trans>
|
<span> </span>
|
||||||
</Table.Td>
|
<Kbd>A</Kbd>
|
||||||
<Table.Td>
|
</Table.Td>
|
||||||
<Kbd>
|
</Table.Tr>
|
||||||
<Trans>Right click</Trans>
|
<Table.Tr>
|
||||||
</Kbd>
|
<Table.Td>
|
||||||
</Table.Td>
|
<Trans>Navigate to a subscription by entering its name</Trans>
|
||||||
</Table.Tr>
|
</Table.Td>
|
||||||
<Table.Tr>
|
<Table.Td>
|
||||||
<Table.Td>
|
<Kbd>{isMacOS ? <Trans>Cmd</Trans> : <Trans>Ctrl</Trans>}</Kbd>
|
||||||
<Trans>Show native menu (desktop)</Trans>
|
<span> + </span>
|
||||||
</Table.Td>
|
<Kbd>K</Kbd>
|
||||||
<Table.Td>
|
<span>, </span>
|
||||||
<Kbd>
|
<Kbd>G</Kbd>
|
||||||
<Trans>Shift</Trans>
|
<span> </span>
|
||||||
</Kbd>
|
<Kbd>U</Kbd>
|
||||||
<span> + </span>
|
</Table.Td>
|
||||||
<Kbd>
|
</Table.Tr>
|
||||||
<Trans>Right click</Trans>
|
<Table.Tr>
|
||||||
</Kbd>
|
<Table.Td>
|
||||||
</Table.Td>
|
<Trans>Show entry menu (desktop)</Trans>
|
||||||
</Table.Tr>
|
</Table.Td>
|
||||||
<Table.Tr>
|
<Table.Td>
|
||||||
<Table.Td>
|
<Kbd>
|
||||||
<Trans>Show entry menu (mobile)</Trans>
|
<Trans>Right click</Trans>
|
||||||
</Table.Td>
|
</Kbd>
|
||||||
<Table.Td>
|
</Table.Td>
|
||||||
<Kbd>
|
</Table.Tr>
|
||||||
<Trans>Long press</Trans>
|
<Table.Tr>
|
||||||
</Kbd>
|
<Table.Td>
|
||||||
</Table.Td>
|
<Trans>Show native menu (desktop)</Trans>
|
||||||
</Table.Tr>
|
</Table.Td>
|
||||||
<Table.Tr>
|
<Table.Td>
|
||||||
<Table.Td>
|
<Kbd>
|
||||||
<Trans>Toggle sidebar</Trans>
|
<Trans>Shift</Trans>
|
||||||
</Table.Td>
|
</Kbd>
|
||||||
<Table.Td>
|
<span> + </span>
|
||||||
<Kbd>F</Kbd>
|
<Kbd>
|
||||||
</Table.Td>
|
<Trans>Right click</Trans>
|
||||||
</Table.Tr>
|
</Kbd>
|
||||||
<Table.Tr>
|
</Table.Td>
|
||||||
<Table.Td>
|
</Table.Tr>
|
||||||
<Trans>Show keyboard shortcut help</Trans>
|
<Table.Tr>
|
||||||
</Table.Td>
|
<Table.Td>
|
||||||
<Table.Td>
|
<Trans>Show entry menu (mobile)</Trans>
|
||||||
<Kbd>?</Kbd>
|
</Table.Td>
|
||||||
</Table.Td>
|
<Table.Td>
|
||||||
</Table.Tr>
|
<Kbd>
|
||||||
</Table.Tbody>
|
<Trans>Long press</Trans>
|
||||||
</Table>
|
</Kbd>
|
||||||
<Box>
|
</Table.Td>
|
||||||
<span>* </span>
|
</Table.Tr>
|
||||||
<Anchor href={Constants.browserExtensionUrl} target="_blank" rel="noreferrer">
|
<Table.Tr>
|
||||||
<Trans>Browser extension required for Chrome</Trans>
|
<Table.Td>
|
||||||
</Anchor>
|
<Trans>Toggle sidebar</Trans>
|
||||||
</Box>
|
</Table.Td>
|
||||||
</Stack>
|
<Table.Td>
|
||||||
)
|
<Kbd>F</Kbd>
|
||||||
}
|
</Table.Td>
|
||||||
|
</Table.Tr>
|
||||||
|
<Table.Tr>
|
||||||
|
<Table.Td>
|
||||||
|
<Trans>Show keyboard shortcut help</Trans>
|
||||||
|
</Table.Td>
|
||||||
|
<Table.Td>
|
||||||
|
<Kbd>?</Kbd>
|
||||||
|
</Table.Td>
|
||||||
|
</Table.Tr>
|
||||||
|
</Table.Tbody>
|
||||||
|
</Table>
|
||||||
|
<Box>
|
||||||
|
<span>* </span>
|
||||||
|
<Anchor href={Constants.browserExtensionUrl} target="_blank" rel="noreferrer">
|
||||||
|
<Trans>Browser extension required for Chrome</Trans>
|
||||||
|
</Anchor>
|
||||||
|
</Box>
|
||||||
|
</Stack>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import { Center, Loader as MantineLoader } from "@mantine/core"
|
import { Center, Loader as MantineLoader } from "@mantine/core"
|
||||||
|
|
||||||
export function Loader() {
|
export function Loader() {
|
||||||
return (
|
return (
|
||||||
<Center>
|
<Center>
|
||||||
<MantineLoader size="lg" type="bars" />
|
<MantineLoader size="lg" type="bars" />
|
||||||
</Center>
|
</Center>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,20 +1,21 @@
|
|||||||
import { Trans } from "@lingui/macro"
|
import { Trans } from "@lingui/react/macro"
|
||||||
import { Tooltip } from "@mantine/core"
|
import { Tooltip } from "@mantine/core"
|
||||||
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 }) {
|
|
||||||
const [now, setNow] = useState(new Date())
|
export function RelativeDate(
|
||||||
useEffect(() => {
|
props: Readonly<{
|
||||||
const interval = setInterval(() => setNow(new Date()), 60 * 1000)
|
date: Date | number | undefined
|
||||||
return () => clearInterval(interval)
|
}>
|
||||||
}, [])
|
) {
|
||||||
|
const now = useNow(60 * 1000)
|
||||||
if (!props.date) return <Trans>N/A</Trans>
|
|
||||||
const date = dayjs(props.date)
|
if (!props.date) return <Trans>N/A</Trans>
|
||||||
return (
|
const date = dayjs(props.date)
|
||||||
<Tooltip label={date.toDate().toLocaleString()} openDelay={500}>
|
return (
|
||||||
<span>{date.from(dayjs(now))}</span>
|
<Tooltip label={date.toDate().toLocaleString()} openDelay={Constants.tooltip.delay}>
|
||||||
</Tooltip>
|
<span>{date.from(dayjs(now))}</span>
|
||||||
)
|
</Tooltip>
|
||||||
}
|
)
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,54 +1,54 @@
|
|||||||
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 { useAsyncCallback } from "react-async-hook"
|
||||||
import { type AdminSaveUserRequest, type UserModel } from "app/types"
|
import { TbDeviceFloppy } from "react-icons/tb"
|
||||||
import { Alert } from "components/Alert"
|
import { client, errorToStrings } from "@/app/client"
|
||||||
import { useAsyncCallback } from "react-async-hook"
|
import type { AdminSaveUserRequest, UserModel } from "@/app/types"
|
||||||
import { TbDeviceFloppy } from "react-icons/tb"
|
import { Alert } from "@/components/Alert"
|
||||||
|
|
||||||
interface UserEditProps {
|
interface UserEditProps {
|
||||||
user?: UserModel
|
user?: UserModel
|
||||||
onCancel: () => void
|
onCancel: () => void
|
||||||
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: "",
|
||||||
enabled: true,
|
enabled: true,
|
||||||
admin: false,
|
admin: false,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
const saveUser = useAsyncCallback(client.admin.saveUser, { onSuccess: props.onSave })
|
const saveUser = useAsyncCallback(client.admin.saveUser, { onSuccess: props.onSave })
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{saveUser.error && (
|
{saveUser.error && (
|
||||||
<Box mb="md">
|
<Box mb="md">
|
||||||
<Alert messages={errorToStrings(saveUser.error)} />
|
<Alert messages={errorToStrings(saveUser.error)} />
|
||||||
</Box>
|
</Box>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<form onSubmit={form.onSubmit(saveUser.execute)}>
|
<form onSubmit={form.onSubmit(saveUser.execute)}>
|
||||||
<Stack>
|
<Stack>
|
||||||
<TextInput label={<Trans>Name</Trans>} {...form.getInputProps("name")} required />
|
<TextInput label={<Trans>Name</Trans>} {...form.getInputProps("name")} required />
|
||||||
<PasswordInput label={<Trans>Password</Trans>} {...form.getInputProps("password")} required={!props.user} />
|
<PasswordInput label={<Trans>Password</Trans>} {...form.getInputProps("password")} required={!props.user} />
|
||||||
<TextInput type="email" label={<Trans>E-mail</Trans>} {...form.getInputProps("email")} />
|
<TextInput type="email" label={<Trans>E-mail</Trans>} {...form.getInputProps("email")} />
|
||||||
<Checkbox label={<Trans>Admin</Trans>} {...form.getInputProps("admin", { type: "checkbox" })} />
|
<Checkbox label={<Trans>Admin</Trans>} {...form.getInputProps("admin", { type: "checkbox" })} />
|
||||||
<Checkbox label={<Trans>Enabled</Trans>} {...form.getInputProps("enabled", { type: "checkbox" })} />
|
<Checkbox label={<Trans>Enabled</Trans>} {...form.getInputProps("enabled", { type: "checkbox" })} />
|
||||||
|
|
||||||
<Group justify="right">
|
<Group justify="right">
|
||||||
<Button variant="default" onClick={props.onCancel}>
|
<Button variant="default" onClick={props.onCancel}>
|
||||||
<Trans>Cancel</Trans>
|
<Trans>Cancel</Trans>
|
||||||
</Button>
|
</Button>
|
||||||
<Button type="submit" leftSection={<TbDeviceFloppy size={16} />} loading={saveUser.loading}>
|
<Button type="submit" leftSection={<TbDeviceFloppy size={16} />} loading={saveUser.loading}>
|
||||||
<Trans>Save</Trans>
|
<Trans>Save</Trans>
|
||||||
</Button>
|
</Button>
|
||||||
</Group>
|
</Group>
|
||||||
</Stack>
|
</Stack>
|
||||||
</form>
|
</form>
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,36 +1,38 @@
|
|||||||
import { Input, Textarea } from "@mantine/core"
|
import { Input, Textarea } from "@mantine/core"
|
||||||
import RichCodeEditor from "components/code/RichCodeEditor"
|
import type { ReactNode } from "react"
|
||||||
import { useMobile } from "hooks/useMobile"
|
import RichCodeEditor from "@/components/code/RichCodeEditor"
|
||||||
import { type ReactNode } from "react"
|
import { useMobile } from "@/hooks/useMobile"
|
||||||
|
|
||||||
interface CodeEditorProps {
|
interface CodeEditorProps {
|
||||||
description?: ReactNode
|
label?: ReactNode
|
||||||
language: "css" | "javascript"
|
description?: ReactNode
|
||||||
value?: string
|
language: "css" | "javascript"
|
||||||
onChange: (value: string | undefined) => void
|
value?: string
|
||||||
}
|
onChange: (value: string | undefined) => void
|
||||||
|
}
|
||||||
export function CodeEditor(props: CodeEditorProps) {
|
|
||||||
const mobile = useMobile()
|
export function CodeEditor(props: Readonly<CodeEditorProps>) {
|
||||||
|
const mobile = useMobile()
|
||||||
return mobile ? (
|
|
||||||
// monaco mobile support is poor, fallback to textarea
|
return mobile ? (
|
||||||
<Textarea
|
// monaco mobile support is poor, fallback to textarea
|
||||||
autosize
|
<Textarea
|
||||||
minRows={4}
|
autosize
|
||||||
maxRows={15}
|
minRows={4}
|
||||||
description={props.description}
|
maxRows={15}
|
||||||
styles={{
|
label={props.label}
|
||||||
input: {
|
description={props.description}
|
||||||
fontFamily: "monospace",
|
styles={{
|
||||||
},
|
input: {
|
||||||
}}
|
fontFamily: "monospace",
|
||||||
value={props.value}
|
},
|
||||||
onChange={e => props.onChange(e.currentTarget.value)}
|
}}
|
||||||
/>
|
value={props.value}
|
||||||
) : (
|
onChange={e => props.onChange(e.currentTarget.value)}
|
||||||
<Input.Wrapper description={props.description}>
|
/>
|
||||||
<RichCodeEditor height="30vh" language={props.language} value={props.value} onChange={props.onChange} />
|
) : (
|
||||||
</Input.Wrapper>
|
<Input.Wrapper label={props.label} description={props.description}>
|
||||||
)
|
<RichCodeEditor height="30vh" language={props.language} value={props.value} onChange={props.onChange} />
|
||||||
}
|
</Input.Wrapper>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,52 +1,51 @@
|
|||||||
import { Loader } from "components/Loader"
|
import { useAsync } from "react-async-hook"
|
||||||
import { useColorScheme } from "hooks/useColorScheme"
|
import { Loader } from "@/components/Loader"
|
||||||
import { useAsync } from "react-async-hook"
|
import { useColorScheme } from "@/hooks/useColorScheme"
|
||||||
|
|
||||||
const init = async () => {
|
const init = async () => {
|
||||||
window.MonacoEnvironment = {
|
window.MonacoEnvironment = {
|
||||||
async getWorker(_, label) {
|
async getWorker(_, label) {
|
||||||
let worker
|
let worker: typeof import("*?worker")
|
||||||
if (label === "css") {
|
if (label === "css") {
|
||||||
worker = await import("monaco-editor/esm/vs/language/css/css.worker?worker")
|
worker = await import("monaco-editor/esm/vs/language/css/css.worker?worker")
|
||||||
} else if (label === "javascript") {
|
} else if (label === "javascript") {
|
||||||
worker = await import("monaco-editor/esm/vs/language/typescript/ts.worker?worker")
|
worker = await import("monaco-editor/esm/vs/language/typescript/ts.worker?worker")
|
||||||
} else {
|
} else {
|
||||||
worker = await import("monaco-editor/esm/vs/editor/editor.worker?worker")
|
worker = await import("monaco-editor/esm/vs/editor/editor.worker?worker")
|
||||||
}
|
}
|
||||||
// eslint-disable-next-line new-cap
|
return new worker.default()
|
||||||
return new worker.default()
|
},
|
||||||
},
|
}
|
||||||
}
|
|
||||||
|
const monacoReact = await import("@monaco-editor/react")
|
||||||
const monacoReact = await import("@monaco-editor/react")
|
const monaco = await import("monaco-editor")
|
||||||
const monaco = await import("monaco-editor")
|
monacoReact.loader.config({ monaco })
|
||||||
monacoReact.loader.config({ monaco })
|
return monacoReact.Editor
|
||||||
return monacoReact.Editor
|
}
|
||||||
}
|
|
||||||
|
interface RichCodeEditorProps {
|
||||||
interface RichCodeEditorProps {
|
height: number | string
|
||||||
height: number | string
|
language: "css" | "javascript"
|
||||||
language: "css" | "javascript"
|
value?: string
|
||||||
value?: string
|
onChange: (value: string | undefined) => void
|
||||||
onChange: (value: string | undefined) => void
|
}
|
||||||
}
|
|
||||||
|
function RichCodeEditor(props: Readonly<RichCodeEditorProps>) {
|
||||||
function RichCodeEditor(props: RichCodeEditorProps) {
|
const colorScheme = useColorScheme()
|
||||||
const colorScheme = useColorScheme()
|
const editorTheme = colorScheme === "dark" ? "vs-dark" : "light"
|
||||||
const editorTheme = colorScheme === "dark" ? "vs-dark" : "light"
|
|
||||||
|
const { result: Editor } = useAsync(init, [])
|
||||||
const { result: Editor } = useAsync(init, [])
|
if (!Editor) return <Loader />
|
||||||
if (!Editor) return <Loader />
|
return (
|
||||||
return (
|
<Editor
|
||||||
<Editor
|
height={props.height}
|
||||||
height={props.height}
|
defaultLanguage={props.language}
|
||||||
defaultLanguage={props.language}
|
theme={editorTheme}
|
||||||
theme={editorTheme}
|
options={{ minimap: { enabled: false } }}
|
||||||
options={{ minimap: { enabled: false } }}
|
value={props.value}
|
||||||
value={props.value}
|
onChange={props.onChange}
|
||||||
onChange={props.onChange}
|
/>
|
||||||
/>
|
)
|
||||||
)
|
}
|
||||||
}
|
|
||||||
|
export default RichCodeEditor
|
||||||
export default RichCodeEditor
|
|
||||||
|
|||||||
@@ -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/
|
||||||
export const BasicHtmlStyles = (props: { children: ReactNode }) => {
|
*/
|
||||||
return <TypographyStylesProvider pl={0}>{props.children}</TypographyStylesProvider>
|
|
||||||
}
|
const useStyles = tss.create(() => ({
|
||||||
|
// override mantine default typography styles
|
||||||
|
content: {
|
||||||
|
paddingLeft: 0,
|
||||||
|
"& img": {
|
||||||
|
marginBottom: 0,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
|
||||||
|
export const BasicHtmlStyles = (props: { children: ReactNode }) => {
|
||||||
|
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,103 +1,108 @@
|
|||||||
import { Box, Mark } from "@mantine/core"
|
import { Box, Mark } from "@mantine/core"
|
||||||
import { Constants } from "app/constants"
|
import escapeStringRegexp from "escape-string-regexp"
|
||||||
import { calculatePlaceholderSize } from "app/utils"
|
import { ALLOWED_TAG_LIST, type ChildrenNode, Interweave, Matcher, type MatchResponse, type Node, type TransformCallback } from "interweave"
|
||||||
import { BasicHtmlStyles } from "components/content/BasicHtmlStyles"
|
import React from "react"
|
||||||
import { ImageWithPlaceholderWhileLoading } from "components/ImageWithPlaceholderWhileLoading"
|
import styleToObject from "style-to-object"
|
||||||
import escapeStringRegexp from "escape-string-regexp"
|
import { Constants } from "@/app/constants"
|
||||||
import { type ChildrenNode, Interweave, Matcher, type MatchResponse, type Node, type TransformCallback } from "interweave"
|
import { calculatePlaceholderSize } from "@/app/utils"
|
||||||
import React from "react"
|
import { BasicHtmlStyles } from "@/components/content/BasicHtmlStyles"
|
||||||
import { tss } from "tss"
|
import { ImageWithPlaceholderWhileLoading } from "@/components/ImageWithPlaceholderWhileLoading"
|
||||||
|
import { tss } from "@/tss"
|
||||||
export interface ContentProps {
|
|
||||||
content: string
|
export interface ContentProps {
|
||||||
highlight?: string
|
content: string
|
||||||
}
|
highlight?: string
|
||||||
|
}
|
||||||
const useStyles = tss.create(() => ({
|
|
||||||
content: {
|
const useStyles = tss.create(() => ({
|
||||||
// break long links or long words
|
content: {
|
||||||
overflowWrap: "anywhere",
|
// break long links or long words
|
||||||
"& a": {
|
overflowWrap: "anywhere",
|
||||||
color: "inherit",
|
"& a": {
|
||||||
textDecoration: "underline",
|
color: "inherit",
|
||||||
},
|
textDecoration: "underline",
|
||||||
"& iframe": {
|
},
|
||||||
maxWidth: "100%",
|
"& iframe": {
|
||||||
},
|
maxWidth: "100%",
|
||||||
"& pre, & code": {
|
},
|
||||||
whiteSpace: "pre-wrap",
|
"& pre, & code": {
|
||||||
},
|
whiteSpace: "pre-wrap",
|
||||||
},
|
},
|
||||||
}))
|
},
|
||||||
|
}))
|
||||||
const transform: TransformCallback = node => {
|
|
||||||
if (node.tagName === "IMG") {
|
const transform: TransformCallback = node => {
|
||||||
// show placeholders for loading img tags, this allows the entry to have its final height immediately
|
if (node.tagName === "IMG") {
|
||||||
const src = node.getAttribute("src") ?? undefined
|
// show placeholders for loading img tags, this allows the entry to have its final height immediately
|
||||||
if (!src) return undefined
|
const src = node.getAttribute("src") ?? undefined
|
||||||
|
if (!src) return undefined
|
||||||
const alt = node.getAttribute("alt") ?? "image"
|
|
||||||
const title = node.getAttribute("title") ?? undefined
|
const alt = node.getAttribute("alt") ?? "image"
|
||||||
const nodeWidth = node.getAttribute("width")
|
const title = node.getAttribute("title") ?? undefined
|
||||||
const nodeHeight = node.getAttribute("height")
|
const nodeWidth = node.getAttribute("width")
|
||||||
const width = nodeWidth ? parseInt(nodeWidth, 10) : undefined
|
const nodeHeight = node.getAttribute("height")
|
||||||
const height = nodeHeight ? parseInt(nodeHeight, 10) : undefined
|
const width = nodeWidth ? Number.parseInt(nodeWidth, 10) : undefined
|
||||||
const placeholderSize = calculatePlaceholderSize({
|
const height = nodeHeight ? Number.parseInt(nodeHeight, 10) : undefined
|
||||||
width,
|
const style = styleToObject(node.getAttribute("style") ?? "") ?? undefined
|
||||||
height,
|
const placeholderSize = calculatePlaceholderSize({
|
||||||
maxWidth: Constants.layout.entryMaxWidth,
|
width,
|
||||||
})
|
height,
|
||||||
|
maxWidth: Constants.layout.entryMaxWidth,
|
||||||
return (
|
})
|
||||||
<ImageWithPlaceholderWhileLoading
|
|
||||||
src={src}
|
return (
|
||||||
alt={alt}
|
<ImageWithPlaceholderWhileLoading
|
||||||
title={title}
|
src={src}
|
||||||
width={width}
|
alt={alt}
|
||||||
height="auto"
|
title={title}
|
||||||
placeholderWidth={placeholderSize.width}
|
width={width}
|
||||||
placeholderHeight={placeholderSize.height}
|
height="auto"
|
||||||
/>
|
style={style}
|
||||||
)
|
placeholderWidth={placeholderSize.width}
|
||||||
}
|
placeholderHeight={placeholderSize.height}
|
||||||
return undefined
|
/>
|
||||||
}
|
)
|
||||||
|
}
|
||||||
class HighlightMatcher extends Matcher {
|
return undefined
|
||||||
private readonly search: string
|
}
|
||||||
|
|
||||||
constructor(search: string) {
|
class HighlightMatcher extends Matcher {
|
||||||
super("highlight")
|
private readonly regexp: RegExp
|
||||||
this.search = escapeStringRegexp(search)
|
|
||||||
}
|
constructor(search: string) {
|
||||||
|
super("highlight")
|
||||||
match(string: string): MatchResponse<unknown> | null {
|
this.regexp = new RegExp(escapeStringRegexp(search).split(" ").join("|"), "i")
|
||||||
const pattern = this.search.split(" ").join("|")
|
}
|
||||||
return this.doMatch(string, new RegExp(pattern, "i"), () => ({}))
|
|
||||||
}
|
match(string: string): MatchResponse<unknown> | null {
|
||||||
|
return this.doMatch(string, this.regexp, () => ({}))
|
||||||
replaceWith(children: ChildrenNode): Node {
|
}
|
||||||
return <Mark>{children}</Mark>
|
|
||||||
}
|
replaceWith(children: ChildrenNode): Node {
|
||||||
|
return <Mark key={0}>{children}</Mark>
|
||||||
asTag(): string {
|
}
|
||||||
return "span"
|
|
||||||
}
|
asTag(): string {
|
||||||
}
|
return "span"
|
||||||
|
}
|
||||||
// memoize component because Interweave is costly
|
}
|
||||||
const Content = React.memo((props: ContentProps) => {
|
|
||||||
const { classes } = useStyles()
|
// allow iframe tag
|
||||||
const matchers = props.highlight ? [new HighlightMatcher(props.highlight)] : []
|
const allowList = [...ALLOWED_TAG_LIST, "iframe"]
|
||||||
|
|
||||||
return (
|
// memoize component because Interweave is costly
|
||||||
<BasicHtmlStyles>
|
const Content = React.memo((props: ContentProps) => {
|
||||||
<Box className={classes.content}>
|
const { classes } = useStyles()
|
||||||
<Interweave content={props.content} transform={transform} matchers={matchers} />
|
const matchers = props.highlight ? [new HighlightMatcher(props.highlight)] : []
|
||||||
</Box>
|
|
||||||
</BasicHtmlStyles>
|
return (
|
||||||
)
|
<BasicHtmlStyles>
|
||||||
})
|
<Box className={classes.content}>
|
||||||
Content.displayName = "Content"
|
<Interweave content={props.content} transform={transform} matchers={matchers} allowList={allowList} />
|
||||||
|
</Box>
|
||||||
export { Content }
|
</BasicHtmlStyles>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
Content.displayName = "Content"
|
||||||
|
|
||||||
|
export { Content }
|
||||||
|
|||||||
@@ -1,24 +1,31 @@
|
|||||||
import { BasicHtmlStyles } from "components/content/BasicHtmlStyles"
|
import { BasicHtmlStyles } from "@/components/content/BasicHtmlStyles"
|
||||||
import { ImageWithPlaceholderWhileLoading } from "components/ImageWithPlaceholderWhileLoading"
|
import { ImageWithPlaceholderWhileLoading } from "@/components/ImageWithPlaceholderWhileLoading"
|
||||||
|
|
||||||
export function Enclosure(props: { enclosureType: string; enclosureUrl: string }) {
|
export function Enclosure(
|
||||||
const hasVideo = props.enclosureType.startsWith("video")
|
props: Readonly<{
|
||||||
const hasAudio = props.enclosureType.startsWith("audio")
|
enclosureType: string
|
||||||
const hasImage = props.enclosureType.startsWith("image")
|
enclosureUrl: string
|
||||||
|
}>
|
||||||
return (
|
) {
|
||||||
<BasicHtmlStyles>
|
const hasVideo = props.enclosureType.startsWith("video")
|
||||||
{hasVideo && (
|
const hasAudio = props.enclosureType.startsWith("audio")
|
||||||
<video controls width="100%">
|
const hasImage = props.enclosureType.startsWith("image")
|
||||||
<source src={props.enclosureUrl} type={props.enclosureType} />
|
|
||||||
</video>
|
return (
|
||||||
)}
|
<BasicHtmlStyles>
|
||||||
{hasAudio && (
|
{hasVideo && (
|
||||||
<audio controls>
|
// biome-ignore lint/a11y/useMediaCaption: we don't have any captions for videos
|
||||||
<source src={props.enclosureUrl} type={props.enclosureType} />
|
<video controls width="100%">
|
||||||
</audio>
|
<source src={props.enclosureUrl} type={props.enclosureType} />
|
||||||
)}
|
</video>
|
||||||
{hasImage && <ImageWithPlaceholderWhileLoading src={props.enclosureUrl} alt="enclosure" />}
|
)}
|
||||||
</BasicHtmlStyles>
|
{hasAudio && (
|
||||||
)
|
// biome-ignore lint/a11y/useMediaCaption: we don't have any captions for audio
|
||||||
}
|
<audio controls style={{ width: "100%" }}>
|
||||||
|
<source src={props.enclosureUrl} type={props.enclosureType} />
|
||||||
|
</audio>
|
||||||
|
)}
|
||||||
|
{hasImage && <ImageWithPlaceholderWhileLoading src={props.enclosureUrl} alt="enclosure" />}
|
||||||
|
</BasicHtmlStyles>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,329 +1,312 @@
|
|||||||
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 {
|
import InfiniteScroll from "react-infinite-scroller"
|
||||||
loadMoreEntries,
|
import { throttle } from "throttle-debounce"
|
||||||
markAllEntries,
|
import { Constants } from "@/app/constants"
|
||||||
markEntry,
|
import type { ExpendableEntry } from "@/app/entries/slice"
|
||||||
reloadEntries,
|
import {
|
||||||
selectEntry,
|
loadMoreEntries,
|
||||||
selectNextEntry,
|
markAllAsReadWithConfirmationIfRequired,
|
||||||
selectPreviousEntry,
|
markEntry,
|
||||||
starEntry,
|
reloadEntries,
|
||||||
} from "app/entries/thunks"
|
selectEntry,
|
||||||
import { redirectToRootCategory } from "app/redirect/thunks"
|
selectNextEntry,
|
||||||
import { useAppDispatch, useAppSelector } from "app/store"
|
selectPreviousEntry,
|
||||||
import { toggleSidebar } from "app/tree/slice"
|
starEntry,
|
||||||
import { KeyboardShortcutsHelp } from "components/KeyboardShortcutsHelp"
|
} from "@/app/entries/thunks"
|
||||||
import { Loader } from "components/Loader"
|
import { redirectToRootCategory } from "@/app/redirect/thunks"
|
||||||
import { useBrowserExtension } from "hooks/useBrowserExtension"
|
import { useAppDispatch, useAppSelector } from "@/app/store"
|
||||||
import { useMousetrap } from "hooks/useMousetrap"
|
import { toggleSidebar } from "@/app/tree/slice"
|
||||||
import { useViewMode } from "hooks/useViewMode"
|
import { selectNextUnreadTreeItem } from "@/app/tree/thunks"
|
||||||
import { useEffect } from "react"
|
import { KeyboardShortcutsHelp } from "@/components/KeyboardShortcutsHelp"
|
||||||
import { useContextMenu } from "react-contexify"
|
import { Loader } from "@/components/Loader"
|
||||||
import InfiniteScroll from "react-infinite-scroller"
|
import { useBrowserExtension } from "@/hooks/useBrowserExtension"
|
||||||
import { throttle } from "throttle-debounce"
|
import { useMousetrap } from "@/hooks/useMousetrap"
|
||||||
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 selectedEntryId = useAppSelector(state => state.entries.selectedEntryId)
|
||||||
const entriesTimestamp = useAppSelector(state => state.entries.timestamp)
|
const hasMore = useAppSelector(state => state.entries.hasMore)
|
||||||
const selectedEntryId = useAppSelector(state => state.entries.selectedEntryId)
|
const loading = useAppSelector(state => state.entries.loading)
|
||||||
const hasMore = useAppSelector(state => state.entries.hasMore)
|
const scrollMarks = useAppSelector(state => state.user.settings?.scrollMarks)
|
||||||
const loading = useAppSelector(state => state.entries.loading)
|
const scrollingToEntry = useAppSelector(state => state.entries.scrollingToEntry)
|
||||||
const scrollMarks = useAppSelector(state => state.user.settings?.scrollMarks)
|
const sidebarVisible = useAppSelector(state => state.tree.sidebarVisible)
|
||||||
const scrollingToEntry = useAppSelector(state => state.entries.scrollingToEntry)
|
const customContextMenu = useAppSelector(state => state.user.settings?.customContextMenu)
|
||||||
const sidebarVisible = useAppSelector(state => state.tree.sidebarVisible)
|
const viewMode = useAppSelector(state => state.user.localSettings.viewMode)
|
||||||
const customContextMenu = useAppSelector(state => state.user.settings?.customContextMenu)
|
const dispatch = useAppDispatch()
|
||||||
const { viewMode } = useViewMode()
|
const { openLinkInBackgroundTab } = useBrowserExtension()
|
||||||
const dispatch = useAppDispatch()
|
|
||||||
const { openLinkInBackgroundTab } = useBrowserExtension()
|
const selectedEntry = entries.find(e => e.id === selectedEntryId)
|
||||||
|
|
||||||
const selectedEntry = entries.find(e => e.id === selectedEntryId)
|
const headerClicked = (entry: ExpendableEntry, event: React.MouseEvent) => {
|
||||||
|
const middleClick = event.button === 1 || event.ctrlKey || event.metaKey
|
||||||
const headerClicked = (entry: ExpendableEntry, event: React.MouseEvent) => {
|
if (middleClick || viewMode === "expanded") {
|
||||||
const middleClick = event.button === 1 || event.ctrlKey || event.metaKey
|
dispatch(markEntry({ entry, read: true }))
|
||||||
if (middleClick || viewMode === "expanded") {
|
} else if (event.button === 0) {
|
||||||
dispatch(markEntry({ entry, read: true }))
|
// main click
|
||||||
} else if (event.button === 0) {
|
// don't trigger the link
|
||||||
// main click
|
event.preventDefault()
|
||||||
// don't trigger the link
|
|
||||||
event.preventDefault()
|
dispatch(
|
||||||
|
selectEntry({
|
||||||
dispatch(
|
entry,
|
||||||
selectEntry({
|
expand: !entry.expanded,
|
||||||
entry,
|
markAsRead: !entry.expanded,
|
||||||
expand: !entry.expanded,
|
scrollToEntry: true,
|
||||||
markAsRead: !entry.expanded,
|
})
|
||||||
scrollToEntry: true,
|
)
|
||||||
})
|
}
|
||||||
)
|
}
|
||||||
}
|
|
||||||
}
|
const contextMenu = useContextMenu()
|
||||||
|
const headerRightClicked = (entry: ExpendableEntry, event: React.MouseEvent) => {
|
||||||
const contextMenu = useContextMenu()
|
if (event.shiftKey || !customContextMenu) return
|
||||||
const headerRightClicked = (entry: ExpendableEntry, event: React.MouseEvent) => {
|
|
||||||
if (event.shiftKey || !customContextMenu) return
|
event.preventDefault()
|
||||||
|
contextMenu.show({
|
||||||
event.preventDefault()
|
id: Constants.dom.entryContextMenuId(entry),
|
||||||
contextMenu.show({
|
event,
|
||||||
id: Constants.dom.entryContextMenuId(entry),
|
})
|
||||||
event,
|
}
|
||||||
})
|
|
||||||
}
|
const bodyClicked = (entry: ExpendableEntry) => {
|
||||||
|
if (viewMode !== "expanded") return
|
||||||
const bodyClicked = (entry: ExpendableEntry) => {
|
|
||||||
if (viewMode !== "expanded") return
|
// entry is already selected
|
||||||
|
if (entry.id === selectedEntryId) return
|
||||||
// entry is already selected
|
|
||||||
if (entry.id === selectedEntryId) return
|
dispatch(
|
||||||
|
selectEntry({
|
||||||
dispatch(
|
entry,
|
||||||
selectEntry({
|
expand: true,
|
||||||
entry,
|
markAsRead: true,
|
||||||
expand: true,
|
scrollToEntry: true,
|
||||||
markAsRead: true,
|
})
|
||||||
scrollToEntry: true,
|
)
|
||||||
})
|
}
|
||||||
)
|
|
||||||
}
|
const swipedLeft = async (entry: ExpendableEntry) => await dispatch(markEntry({ entry, read: !entry.read }))
|
||||||
|
|
||||||
const swipedLeft = async (entry: ExpendableEntry) => await dispatch(markEntry({ entry, read: !entry.read }))
|
// close context menu on scroll
|
||||||
|
useEffect(() => {
|
||||||
// close context menu on scroll
|
const listener = throttle(100, () => contextMenu.hideAll())
|
||||||
useEffect(() => {
|
window.addEventListener("scroll", listener)
|
||||||
const listener = throttle(100, () => contextMenu.hideAll())
|
return () => window.removeEventListener("scroll", listener)
|
||||||
window.addEventListener("scroll", listener)
|
}, [contextMenu])
|
||||||
return () => window.removeEventListener("scroll", listener)
|
|
||||||
}, [contextMenu])
|
useEffect(() => {
|
||||||
|
const listener = throttle(100, () => {
|
||||||
useEffect(() => {
|
if (viewMode !== "expanded") return
|
||||||
const listener = throttle(100, () => {
|
if (scrollingToEntry) return
|
||||||
if (viewMode !== "expanded") return
|
|
||||||
if (scrollingToEntry) return
|
const currentEntry = entries
|
||||||
|
// use slice to get a copy of the array because reverse mutates the array in-place
|
||||||
const currentEntry = entries
|
.slice()
|
||||||
// use slice to get a copy of the array because reverse mutates the array in-place
|
.reverse()
|
||||||
.slice()
|
.find(e => {
|
||||||
.reverse()
|
const el = document.getElementById(Constants.dom.entryId(e))
|
||||||
.find(e => {
|
return el && !Constants.layout.isTopVisible(el)
|
||||||
const el = document.getElementById(Constants.dom.entryId(e))
|
})
|
||||||
return el && !Constants.layout.isTopVisible(el)
|
if (currentEntry) {
|
||||||
})
|
dispatch(
|
||||||
if (currentEntry) {
|
selectEntry({
|
||||||
dispatch(
|
entry: currentEntry,
|
||||||
selectEntry({
|
expand: false,
|
||||||
entry: currentEntry,
|
markAsRead: !!scrollMarks,
|
||||||
expand: false,
|
scrollToEntry: false,
|
||||||
markAsRead: !!scrollMarks,
|
})
|
||||||
scrollToEntry: false,
|
)
|
||||||
})
|
}
|
||||||
)
|
})
|
||||||
}
|
window.addEventListener("scroll", listener)
|
||||||
})
|
return () => window.removeEventListener("scroll", listener)
|
||||||
window.addEventListener("scroll", listener)
|
}, [dispatch, entries, viewMode, scrollMarks, scrollingToEntry])
|
||||||
return () => window.removeEventListener("scroll", listener)
|
|
||||||
}, [dispatch, contextMenu, entries, viewMode, scrollMarks, scrollingToEntry])
|
useMousetrap("r", async () => await dispatch(reloadEntries()))
|
||||||
|
useMousetrap(
|
||||||
useMousetrap("r", async () => await dispatch(reloadEntries()))
|
"j",
|
||||||
useMousetrap(
|
async () =>
|
||||||
"j",
|
await dispatch(
|
||||||
async () =>
|
selectNextEntry({
|
||||||
await dispatch(
|
expand: true,
|
||||||
selectNextEntry({
|
markAsRead: true,
|
||||||
expand: true,
|
scrollToEntry: true,
|
||||||
markAsRead: true,
|
})
|
||||||
scrollToEntry: true,
|
)
|
||||||
})
|
)
|
||||||
)
|
useMousetrap(
|
||||||
)
|
"n",
|
||||||
useMousetrap(
|
async () =>
|
||||||
"n",
|
await dispatch(
|
||||||
async () =>
|
selectNextEntry({
|
||||||
await dispatch(
|
expand: false,
|
||||||
selectNextEntry({
|
markAsRead: false,
|
||||||
expand: false,
|
scrollToEntry: true,
|
||||||
markAsRead: false,
|
})
|
||||||
scrollToEntry: true,
|
)
|
||||||
})
|
)
|
||||||
)
|
useMousetrap(
|
||||||
)
|
"k",
|
||||||
useMousetrap(
|
async () =>
|
||||||
"k",
|
await dispatch(
|
||||||
async () =>
|
selectPreviousEntry({
|
||||||
await dispatch(
|
expand: true,
|
||||||
selectPreviousEntry({
|
markAsRead: true,
|
||||||
expand: true,
|
scrollToEntry: true,
|
||||||
markAsRead: true,
|
})
|
||||||
scrollToEntry: true,
|
)
|
||||||
})
|
)
|
||||||
)
|
useMousetrap(
|
||||||
)
|
"p",
|
||||||
useMousetrap(
|
async () =>
|
||||||
"p",
|
await dispatch(
|
||||||
async () =>
|
selectPreviousEntry({
|
||||||
await dispatch(
|
expand: false,
|
||||||
selectPreviousEntry({
|
markAsRead: false,
|
||||||
expand: false,
|
scrollToEntry: true,
|
||||||
markAsRead: false,
|
})
|
||||||
scrollToEntry: true,
|
)
|
||||||
})
|
)
|
||||||
)
|
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) {
|
||||||
const entryElement = document.getElementById(Constants.dom.entryId(selectedEntry))
|
const entryElement = document.getElementById(Constants.dom.entryId(selectedEntry))
|
||||||
if (entryElement && Constants.layout.isBottomVisible(entryElement)) {
|
if (entryElement && Constants.layout.isBottomVisible(entryElement)) {
|
||||||
dispatch(
|
dispatch(
|
||||||
selectNextEntry({
|
selectNextEntry({
|
||||||
expand: true,
|
expand: true,
|
||||||
markAsRead: true,
|
markAsRead: true,
|
||||||
scrollToEntry: true,
|
scrollToEntry: true,
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
window.scrollTo({
|
window.scrollTo({
|
||||||
top: window.scrollY + document.documentElement.clientHeight * 0.8,
|
top: window.scrollY + document.documentElement.clientHeight * 0.8,
|
||||||
behavior: "smooth",
|
behavior: "smooth",
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
dispatch(
|
dispatch(
|
||||||
selectEntry({
|
selectEntry({
|
||||||
entry: selectedEntry,
|
entry: selectedEntry,
|
||||||
expand: true,
|
expand: true,
|
||||||
markAsRead: true,
|
markAsRead: true,
|
||||||
scrollToEntry: true,
|
scrollToEntry: true,
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
dispatch(
|
dispatch(
|
||||||
selectNextEntry({
|
selectNextEntry({
|
||||||
expand: true,
|
expand: true,
|
||||||
markAsRead: true,
|
markAsRead: true,
|
||||||
scrollToEntry: true,
|
scrollToEntry: true,
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
useMousetrap("shift+space", () => {
|
useMousetrap("shift+space", () => {
|
||||||
if (selectedEntry) {
|
if (selectedEntry) {
|
||||||
if (selectedEntry.expanded) {
|
if (selectedEntry.expanded) {
|
||||||
const entryElement = document.getElementById(Constants.dom.entryId(selectedEntry))
|
const entryElement = document.getElementById(Constants.dom.entryId(selectedEntry))
|
||||||
if (entryElement && Constants.layout.isTopVisible(entryElement)) {
|
if (entryElement && Constants.layout.isTopVisible(entryElement)) {
|
||||||
dispatch(
|
dispatch(
|
||||||
selectPreviousEntry({
|
selectPreviousEntry({
|
||||||
expand: true,
|
expand: true,
|
||||||
markAsRead: true,
|
markAsRead: true,
|
||||||
scrollToEntry: true,
|
scrollToEntry: true,
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
window.scrollTo({
|
window.scrollTo({
|
||||||
top: window.scrollY - document.documentElement.clientHeight * 0.8,
|
top: window.scrollY - document.documentElement.clientHeight * 0.8,
|
||||||
behavior: "smooth",
|
behavior: "smooth",
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
dispatch(
|
dispatch(
|
||||||
selectPreviousEntry({
|
selectPreviousEntry({
|
||||||
expand: true,
|
expand: true,
|
||||||
markAsRead: true,
|
markAsRead: true,
|
||||||
scrollToEntry: true,
|
scrollToEntry: true,
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
useMousetrap(["o", "enter"], () => {
|
useMousetrap(["o", "enter"], () => {
|
||||||
// toggle expanded status
|
// toggle expanded status
|
||||||
if (!selectedEntry) return
|
if (!selectedEntry) return
|
||||||
dispatch(
|
dispatch(
|
||||||
selectEntry({
|
selectEntry({
|
||||||
entry: selectedEntry,
|
entry: selectedEntry,
|
||||||
expand: !selectedEntry.expanded,
|
expand: !selectedEntry.expanded,
|
||||||
markAsRead: !selectedEntry.expanded,
|
markAsRead: !selectedEntry.expanded,
|
||||||
scrollToEntry: true,
|
scrollToEntry: true,
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
useMousetrap("v", () => {
|
useMousetrap("v", () => {
|
||||||
// open tab in foreground
|
// open tab in foreground
|
||||||
if (!selectedEntry) return
|
if (!selectedEntry) return
|
||||||
window.open(selectedEntry.url, "_blank", "noreferrer")
|
window.open(selectedEntry.url, "_blank", "noreferrer")
|
||||||
})
|
})
|
||||||
useMousetrap("b", () => {
|
useMousetrap("b", () => {
|
||||||
if (!selectedEntry) return
|
if (!selectedEntry) return
|
||||||
openLinkInBackgroundTab(selectedEntry.url)
|
openLinkInBackgroundTab(selectedEntry.url)
|
||||||
})
|
})
|
||||||
useMousetrap("m", () => {
|
useMousetrap("m", () => {
|
||||||
// toggle read status
|
// toggle read status
|
||||||
if (!selectedEntry) return
|
if (!selectedEntry) return
|
||||||
dispatch(markEntry({ entry: selectedEntry, read: !selectedEntry.read }))
|
dispatch(markEntry({ entry: selectedEntry, read: !selectedEntry.read }))
|
||||||
})
|
})
|
||||||
useMousetrap("s", () => {
|
useMousetrap("s", () => {
|
||||||
// toggle starred status
|
// toggle starred status
|
||||||
if (!selectedEntry) return
|
if (!selectedEntry) return
|
||||||
dispatch(starEntry({ entry: selectedEntry, starred: !selectedEntry.starred }))
|
dispatch(starEntry({ entry: selectedEntry, starred: !selectedEntry.starred }))
|
||||||
})
|
})
|
||||||
useMousetrap("shift+a", () => {
|
useMousetrap("shift+a", () => {
|
||||||
// mark all entries as read
|
// mark all entries as read
|
||||||
dispatch(
|
dispatch(markAllAsReadWithConfirmationIfRequired())
|
||||||
markAllEntries({
|
})
|
||||||
sourceType: source.type,
|
useMousetrap("g a", async () => await dispatch(redirectToRootCategory()))
|
||||||
req: {
|
useMousetrap("f", () => dispatch(toggleSidebar()))
|
||||||
id: source.id,
|
useMousetrap("?", () =>
|
||||||
read: true,
|
openModal({
|
||||||
olderThan: Date.now(),
|
title: <Trans>Keyboard shortcuts</Trans>,
|
||||||
insertedBefore: entriesTimestamp,
|
size: "xl",
|
||||||
},
|
children: <KeyboardShortcutsHelp />,
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
})
|
|
||||||
useMousetrap("g a", async () => await dispatch(redirectToRootCategory()))
|
return (
|
||||||
useMousetrap("f", () => dispatch(toggleSidebar()))
|
<InfiniteScroll
|
||||||
useMousetrap("?", () =>
|
className={`cf-entries cf-view-mode-${viewMode}`}
|
||||||
openModal({
|
initialLoad={false}
|
||||||
title: <Trans>Keyboard shortcuts</Trans>,
|
loadMore={async () => await (!loading && dispatch(loadMoreEntries()))}
|
||||||
size: "xl",
|
hasMore={hasMore}
|
||||||
children: <KeyboardShortcutsHelp />,
|
loader={<Box key={0}>{loading && <Loader />}</Box>}
|
||||||
})
|
>
|
||||||
)
|
{entries.map(entry => (
|
||||||
|
<FeedEntry
|
||||||
return (
|
key={entry.id}
|
||||||
<InfiniteScroll
|
entry={entry}
|
||||||
id="entries"
|
expanded={!!entry.expanded || viewMode === "expanded"}
|
||||||
className={`view-mode-${viewMode}`}
|
selected={entry.id === selectedEntryId}
|
||||||
initialLoad={false}
|
showSelectionIndicator={entry.id === selectedEntryId && (!entry.expanded || viewMode === "expanded")}
|
||||||
loadMore={async () => await (!loading && dispatch(loadMoreEntries()))}
|
maxWidth={sidebarVisible ? Constants.layout.entryMaxWidth : undefined}
|
||||||
hasMore={hasMore}
|
onHeaderClick={event => headerClicked(entry, event)}
|
||||||
loader={<Box key={0}>{loading && <Loader />}</Box>}
|
onHeaderRightClick={event => headerRightClicked(entry, event)}
|
||||||
>
|
onBodyClick={() => bodyClicked(entry)}
|
||||||
{entries.map(entry => (
|
onSwipedLeft={async () => await swipedLeft(entry)}
|
||||||
<div
|
/>
|
||||||
key={entry.id}
|
))}
|
||||||
ref={el => {
|
</InfiniteScroll>
|
||||||
if (el) el.id = Constants.dom.entryId(entry)
|
)
|
||||||
}}
|
}
|
||||||
>
|
|
||||||
<FeedEntry
|
|
||||||
entry={entry}
|
|
||||||
expanded={!!entry.expanded || viewMode === "expanded"}
|
|
||||||
selected={entry.id === selectedEntryId}
|
|
||||||
showSelectionIndicator={entry.id === selectedEntryId && (!entry.expanded || viewMode === "expanded")}
|
|
||||||
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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,167 +1,199 @@
|
|||||||
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 type React from "react"
|
||||||
import { type Entry, type ViewMode } from "app/types"
|
import { useSwipeable } from "react-swipeable"
|
||||||
import { useViewMode } from "hooks/useViewMode"
|
import { Constants } from "@/app/constants"
|
||||||
import React from "react"
|
import { useAppSelector } from "@/app/store"
|
||||||
import { useSwipeable } from "react-swipeable"
|
import type { Entry, ViewMode } from "@/app/types"
|
||||||
import { tss } from "tss"
|
import { FeedEntryCompactHeader } from "@/components/content/header/FeedEntryCompactHeader"
|
||||||
import { FeedEntryBody } from "./FeedEntryBody"
|
import { FeedEntryHeader } from "@/components/content/header/FeedEntryHeader"
|
||||||
import { FeedEntryCompactHeader } from "./FeedEntryCompactHeader"
|
import { useMobile } from "@/hooks/useMobile"
|
||||||
import { FeedEntryContextMenu } from "./FeedEntryContextMenu"
|
import { tss } from "@/tss"
|
||||||
import { FeedEntryFooter } from "./FeedEntryFooter"
|
import { FeedEntryBody } from "./FeedEntryBody"
|
||||||
import { FeedEntryHeader } from "./FeedEntryHeader"
|
import { FeedEntryContextMenu } from "./FeedEntryContextMenu"
|
||||||
|
import { FeedEntryFooter } from "./FeedEntryFooter"
|
||||||
interface FeedEntryProps {
|
|
||||||
entry: Entry
|
interface FeedEntryProps {
|
||||||
expanded: boolean
|
entry: Entry
|
||||||
selected: boolean
|
expanded: boolean
|
||||||
showSelectionIndicator: boolean
|
selected: boolean
|
||||||
maxWidth?: number
|
showSelectionIndicator: boolean
|
||||||
onHeaderClick: (e: React.MouseEvent) => void
|
maxWidth?: number
|
||||||
onHeaderRightClick: (e: React.MouseEvent) => void
|
onHeaderClick: (e: React.MouseEvent) => void
|
||||||
onBodyClick: (e: React.MouseEvent) => void
|
onHeaderRightClick: (e: React.MouseEvent) => void
|
||||||
onSwipedLeft: () => void
|
onBodyClick: (e: React.MouseEvent) => void
|
||||||
}
|
onSwipedLeft: () => void
|
||||||
|
}
|
||||||
const useStyles = tss
|
|
||||||
.withParams<{
|
const useStyles = tss
|
||||||
read: boolean
|
.withParams<{
|
||||||
expanded: boolean
|
read: boolean
|
||||||
viewMode: ViewMode
|
expanded: boolean
|
||||||
rtl: boolean
|
viewMode: ViewMode
|
||||||
showSelectionIndicator: boolean
|
rtl: boolean
|
||||||
maxWidth?: number
|
showSelectionIndicator: boolean
|
||||||
}>()
|
maxWidth?: number
|
||||||
.create(({ theme, colorScheme, read, expanded, viewMode, rtl, showSelectionIndicator, maxWidth }) => {
|
fontSizePercentage: number
|
||||||
let backgroundColor
|
}>()
|
||||||
if (colorScheme === "dark") {
|
.create(({ theme, colorScheme, read, expanded, viewMode, rtl, showSelectionIndicator, maxWidth, fontSizePercentage }) => {
|
||||||
backgroundColor = read ? "inherit" : theme.colors.dark[5]
|
let backgroundColor: string
|
||||||
} else {
|
if (colorScheme === "dark") {
|
||||||
backgroundColor = read && !expanded ? theme.colors.gray[0] : "inherit"
|
backgroundColor = read ? "inherit" : theme.colors.dark[5]
|
||||||
}
|
} else {
|
||||||
|
backgroundColor = read && !expanded ? theme.colors.gray[0] : "inherit"
|
||||||
let marginY = 10
|
}
|
||||||
if (viewMode === "title") {
|
|
||||||
marginY = 2
|
let marginY = 10
|
||||||
} else if (viewMode === "cozy") {
|
if (viewMode === "title") {
|
||||||
marginY = 6
|
marginY = 2
|
||||||
}
|
} else if (viewMode === "cozy") {
|
||||||
|
marginY = 6
|
||||||
let mobileMarginY = 6
|
}
|
||||||
if (viewMode === "title") {
|
|
||||||
mobileMarginY = 2
|
let mobileMarginY = 6
|
||||||
} else if (viewMode === "cozy") {
|
if (viewMode === "title") {
|
||||||
mobileMarginY = 4
|
mobileMarginY = 2
|
||||||
}
|
} else if (viewMode === "cozy") {
|
||||||
|
mobileMarginY = 4
|
||||||
let backgroundHoverColor = backgroundColor
|
}
|
||||||
if (!expanded && !read) {
|
|
||||||
backgroundHoverColor = colorScheme === "dark" ? theme.colors.dark[6] : theme.colors.gray[1]
|
let backgroundHoverColor = backgroundColor
|
||||||
}
|
if (!expanded && !read) {
|
||||||
|
backgroundHoverColor = colorScheme === "dark" ? theme.colors.dark[6] : theme.colors.gray[1]
|
||||||
let paperBorderLeftColor
|
}
|
||||||
if (showSelectionIndicator) {
|
|
||||||
const borderLeftColor = colorScheme === "dark" ? theme.colors[theme.primaryColor][4] : theme.colors[theme.primaryColor][6]
|
let paperBorderLeftColor = ""
|
||||||
paperBorderLeftColor = `${borderLeftColor} !important`
|
if (showSelectionIndicator) {
|
||||||
}
|
const borderLeftColor = colorScheme === "dark" ? theme.colors[theme.primaryColor][4] : theme.colors[theme.primaryColor][6]
|
||||||
|
paperBorderLeftColor = `${borderLeftColor} !important`
|
||||||
return {
|
}
|
||||||
paper: {
|
|
||||||
backgroundColor,
|
return {
|
||||||
borderLeftColor: paperBorderLeftColor,
|
paper: {
|
||||||
marginTop: marginY,
|
backgroundColor,
|
||||||
marginBottom: marginY,
|
borderLeftColor: paperBorderLeftColor,
|
||||||
[`@media (max-width: ${Constants.layout.mobileBreakpoint}px)`]: {
|
marginTop: marginY,
|
||||||
marginTop: mobileMarginY,
|
marginBottom: marginY,
|
||||||
marginBottom: mobileMarginY,
|
[`@media (max-width: ${Constants.layout.mobileBreakpoint}px)`]: {
|
||||||
},
|
marginTop: mobileMarginY,
|
||||||
"@media (hover: hover)": {
|
marginBottom: mobileMarginY,
|
||||||
"&:hover": {
|
},
|
||||||
backgroundColor: backgroundHoverColor,
|
"@media (hover: hover)": {
|
||||||
},
|
"&:hover": {
|
||||||
},
|
backgroundColor: backgroundHoverColor,
|
||||||
},
|
},
|
||||||
headerLink: {
|
},
|
||||||
color: "inherit",
|
},
|
||||||
textDecoration: "none",
|
headerLink: {
|
||||||
},
|
fontSize: `${fontSizePercentage}%`,
|
||||||
body: {
|
color: "inherit",
|
||||||
direction: rtl ? "rtl" : "ltr",
|
textDecoration: "none",
|
||||||
maxWidth: maxWidth ?? "100%",
|
},
|
||||||
},
|
body: {
|
||||||
}
|
fontSize: `${fontSizePercentage}%`,
|
||||||
})
|
direction: rtl ? "rtl" : "ltr",
|
||||||
|
maxWidth: maxWidth ?? "100%",
|
||||||
export function FeedEntry(props: FeedEntryProps) {
|
},
|
||||||
const { viewMode } = useViewMode()
|
}
|
||||||
const { classes, cx } = useStyles({
|
})
|
||||||
read: props.entry.read,
|
|
||||||
expanded: props.expanded,
|
export function FeedEntry(props: Readonly<FeedEntryProps>) {
|
||||||
viewMode,
|
const viewMode = useAppSelector(state => state.user.localSettings.viewMode)
|
||||||
rtl: props.entry.rtl,
|
const fontSizePercentage = useAppSelector(state => state.user.localSettings.fontSizePercentage)
|
||||||
showSelectionIndicator: props.showSelectionIndicator,
|
const { classes, cx } = useStyles({
|
||||||
maxWidth: props.maxWidth,
|
read: props.entry.read,
|
||||||
})
|
expanded: props.expanded,
|
||||||
|
viewMode,
|
||||||
const swipeHandlers = useSwipeable({
|
rtl: props.entry.rtl,
|
||||||
onSwipedLeft: props.onSwipedLeft,
|
showSelectionIndicator: props.showSelectionIndicator,
|
||||||
})
|
maxWidth: props.maxWidth,
|
||||||
|
fontSizePercentage,
|
||||||
let paddingX: MantineSpacing = "xs"
|
})
|
||||||
if (viewMode === "title" || viewMode === "cozy") paddingX = 6
|
|
||||||
|
const externalLinkDisplayMode = useAppSelector(state => state.user.settings?.externalLinkIconDisplayMode)
|
||||||
let paddingY: MantineSpacing = "xs"
|
const starIconDisplayMode = useAppSelector(state => state.user.settings?.starIconDisplayMode)
|
||||||
if (viewMode === "title") {
|
const mobile = useMobile()
|
||||||
paddingY = 4
|
|
||||||
} else if (viewMode === "cozy") {
|
const showExternalLinkIcon =
|
||||||
paddingY = 8
|
externalLinkDisplayMode && ["always", mobile ? "on_mobile" : "on_desktop"].includes(externalLinkDisplayMode)
|
||||||
}
|
const showStarIcon =
|
||||||
|
props.entry.markable && starIconDisplayMode && ["always", mobile ? "on_mobile" : "on_desktop"].includes(starIconDisplayMode)
|
||||||
let borderRadius: MantineRadius = "sm"
|
|
||||||
if (viewMode === "title") {
|
const swipeHandlers = useSwipeable({
|
||||||
borderRadius = 0
|
onSwipedLeft: props.onSwipedLeft,
|
||||||
} else if (viewMode === "cozy") {
|
})
|
||||||
borderRadius = "xs"
|
|
||||||
}
|
let paddingX: MantineSpacing = "xs"
|
||||||
|
if (viewMode === "title" || viewMode === "cozy") paddingX = 6
|
||||||
const compactHeader = !props.expanded && (viewMode === "title" || viewMode === "cozy")
|
|
||||||
return (
|
let paddingY: MantineSpacing = "xs"
|
||||||
<Paper
|
if (viewMode === "title") {
|
||||||
withBorder
|
paddingY = 4
|
||||||
radius={borderRadius}
|
} else if (viewMode === "cozy") {
|
||||||
className={cx(classes.paper, {
|
paddingY = 8
|
||||||
read: props.entry.read,
|
}
|
||||||
unread: !props.entry.read,
|
|
||||||
expanded: props.expanded,
|
let borderRadius: MantineRadius = "sm"
|
||||||
selected: props.selected,
|
if (viewMode === "title") {
|
||||||
"show-selection-indicator": props.showSelectionIndicator,
|
borderRadius = 0
|
||||||
})}
|
} else if (viewMode === "cozy") {
|
||||||
>
|
borderRadius = "xs"
|
||||||
<a
|
}
|
||||||
className={classes.headerLink}
|
|
||||||
href={props.entry.url}
|
const compactHeader = !props.expanded && (viewMode === "title" || viewMode === "cozy")
|
||||||
target="_blank"
|
return (
|
||||||
rel="noreferrer"
|
<Paper
|
||||||
onClick={props.onHeaderClick}
|
component="article"
|
||||||
onAuxClick={props.onHeaderClick}
|
id={Constants.dom.entryId(props.entry)}
|
||||||
onContextMenu={props.onHeaderRightClick}
|
data-id={props.entry.id}
|
||||||
>
|
data-feed-id={props.entry.feedId}
|
||||||
<Box px={paddingX} py={paddingY} {...swipeHandlers}>
|
withBorder
|
||||||
{compactHeader && <FeedEntryCompactHeader entry={props.entry} />}
|
radius={borderRadius}
|
||||||
{!compactHeader && <FeedEntryHeader entry={props.entry} expanded={props.expanded} />}
|
className={cx(classes.paper, {
|
||||||
</Box>
|
read: props.entry.read,
|
||||||
</a>
|
unread: !props.entry.read,
|
||||||
{props.expanded && (
|
expanded: props.expanded,
|
||||||
<Box px={paddingX} pb={paddingY} onClick={props.onBodyClick}>
|
selected: props.selected,
|
||||||
<Box className={classes.body}>
|
"show-selection-indicator": props.showSelectionIndicator,
|
||||||
<FeedEntryBody entry={props.entry} />
|
})}
|
||||||
</Box>
|
>
|
||||||
<Divider variant="dashed" my={paddingY} />
|
<a
|
||||||
<FeedEntryFooter entry={props.entry} />
|
className={classes.headerLink}
|
||||||
</Box>
|
href={props.entry.url}
|
||||||
)}
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
<FeedEntryContextMenu entry={props.entry} />
|
onClick={props.onHeaderClick}
|
||||||
</Paper>
|
onAuxClick={props.onHeaderClick}
|
||||||
)
|
onContextMenu={props.onHeaderRightClick}
|
||||||
}
|
>
|
||||||
|
<Box px={paddingX} py={paddingY} {...swipeHandlers}>
|
||||||
|
{compactHeader && (
|
||||||
|
<FeedEntryCompactHeader
|
||||||
|
entry={props.entry}
|
||||||
|
showStarIcon={showStarIcon}
|
||||||
|
showExternalLinkIcon={showExternalLinkIcon}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{!compactHeader && (
|
||||||
|
<FeedEntryHeader
|
||||||
|
entry={props.entry}
|
||||||
|
expanded={props.expanded}
|
||||||
|
showStarIcon={showStarIcon}
|
||||||
|
showExternalLinkIcon={showExternalLinkIcon}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
</a>
|
||||||
|
{props.expanded && (
|
||||||
|
<Box px={paddingX} pb={paddingY} onClick={props.onBodyClick}>
|
||||||
|
<Box className={`${classes.body} cf-content`}>
|
||||||
|
<FeedEntryBody entry={props.entry} />
|
||||||
|
</Box>
|
||||||
|
<Divider variant="dashed" my={paddingY} className="cf-footer-divider" />
|
||||||
|
<FeedEntryFooter entry={props.entry} />
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<FeedEntryContextMenu entry={props.entry} />
|
||||||
|
</Paper>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,37 +1,37 @@
|
|||||||
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"
|
||||||
|
|
||||||
export interface FeedEntryBodyProps {
|
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>
|
||||||
<Box>
|
<Box>
|
||||||
<Content content={props.entry.content} highlight={search} />
|
<Content content={props.entry.content} highlight={search} />
|
||||||
</Box>
|
</Box>
|
||||||
{props.entry.enclosureType && props.entry.enclosureUrl && (
|
{props.entry.enclosureType && props.entry.enclosureUrl && (
|
||||||
<Box pt="md">
|
<Box pt="md">
|
||||||
<Enclosure enclosureType={props.entry.enclosureType} enclosureUrl={props.entry.enclosureUrl} />
|
<Enclosure enclosureType={props.entry.enclosureType} enclosureUrl={props.entry.enclosureUrl} />
|
||||||
</Box>
|
</Box>
|
||||||
)}
|
)}
|
||||||
{/* show media only if we don't have content to avoid duplicate content */}
|
{/* show media only if we don't have content to avoid duplicate content */}
|
||||||
{!props.entry.content && props.entry.mediaThumbnailUrl && (
|
{!props.entry.content && props.entry.mediaThumbnailUrl && (
|
||||||
<Box pt="md">
|
<Box pt="md">
|
||||||
<Media
|
<Media
|
||||||
thumbnailUrl={props.entry.mediaThumbnailUrl}
|
thumbnailUrl={props.entry.mediaThumbnailUrl}
|
||||||
thumbnailWidth={props.entry.mediaThumbnailWidth}
|
thumbnailWidth={props.entry.mediaThumbnailWidth}
|
||||||
thumbnailHeight={props.entry.mediaThumbnailHeight}
|
thumbnailHeight={props.entry.mediaThumbnailHeight}
|
||||||
description={props.entry.mediaDescription}
|
description={props.entry.mediaDescription}
|
||||||
/>
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
)}
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,101 +1,105 @@
|
|||||||
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 { Item, Menu, Separator } from "react-contexify"
|
||||||
import { markEntriesUpToEntry, markEntry, starEntry } from "app/entries/thunks"
|
import { TbArrowBarToDown, TbExternalLink, TbMail, TbMailOpened, TbRss, TbStar, TbStarOff } from "react-icons/tb"
|
||||||
import { redirectToFeed } from "app/redirect/thunks"
|
import { Constants } from "@/app/constants"
|
||||||
import { useAppDispatch, useAppSelector } from "app/store"
|
import { markEntriesUpToEntry, markEntry, starEntry } from "@/app/entries/thunks"
|
||||||
import { type Entry } from "app/types"
|
import { redirectToFeed } from "@/app/redirect/thunks"
|
||||||
import { truncate } from "app/utils"
|
import { useAppDispatch, useAppSelector } from "@/app/store"
|
||||||
import { useBrowserExtension } from "hooks/useBrowserExtension"
|
import type { Entry } from "@/app/types"
|
||||||
import { useColorScheme } from "hooks/useColorScheme"
|
import { truncate } from "@/app/utils"
|
||||||
import { Item, Menu, Separator } from "react-contexify"
|
import { useBrowserExtension } from "@/hooks/useBrowserExtension"
|
||||||
import { TbArrowBarToDown, TbExternalLink, TbEyeCheck, TbEyeOff, TbRss, TbStar, TbStarOff } from "react-icons/tb"
|
import { useColorScheme } from "@/hooks/useColorScheme"
|
||||||
import { tss } from "tss"
|
import { tss } from "@/tss"
|
||||||
|
|
||||||
interface FeedEntryContextMenuProps {
|
interface FeedEntryContextMenuProps {
|
||||||
entry: Entry
|
entry: Entry
|
||||||
}
|
}
|
||||||
|
|
||||||
const iconSize = 16
|
const iconSize = 16
|
||||||
const useStyles = tss.create(({ theme, colorScheme }) => ({
|
const useStyles = tss.create(({ theme, colorScheme }) => ({
|
||||||
menu: {
|
menu: {
|
||||||
// apply mantine theme from MenuItem.styles.ts
|
// apply mantine theme from MenuItem.styles.ts
|
||||||
fontSize: theme.fontSizes.sm,
|
fontSize: theme.fontSizes.sm,
|
||||||
"--contexify-item-color": `${colorScheme === "dark" ? theme.colors.dark[0] : theme.black} !important`,
|
"--contexify-item-color": `${colorScheme === "dark" ? theme.colors.dark[0] : theme.black} !important`,
|
||||||
"--contexify-activeItem-color": `${colorScheme === "dark" ? theme.colors.dark[0] : theme.black} !important`,
|
"--contexify-activeItem-color": `${colorScheme === "dark" ? theme.colors.dark[0] : theme.black} !important`,
|
||||||
"--contexify-activeItem-bgColor": `${colorScheme === "dark" ? theme.colors.dark[4] : theme.colors.gray[1]} !important`,
|
"--contexify-activeItem-bgColor": `${colorScheme === "dark" ? theme.colors.dark[4] : theme.colors.gray[1]} !important`,
|
||||||
},
|
},
|
||||||
}))
|
}))
|
||||||
|
|
||||||
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)
|
||||||
const dispatch = useAppDispatch()
|
const dispatch = useAppDispatch()
|
||||||
const { openLinkInBackgroundTab } = useBrowserExtension()
|
const { openLinkInBackgroundTab } = useBrowserExtension()
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Menu id={Constants.dom.entryContextMenuId(props.entry)} theme={colorScheme} animation={false} className={classes.menu}>
|
<Menu id={Constants.dom.entryContextMenuId(props.entry)} theme={colorScheme} animation={false} className={classes.menu}>
|
||||||
<Item
|
<Item
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
window.open(props.entry.url, "_blank", "noreferrer")
|
window.open(props.entry.url, "_blank", "noreferrer")
|
||||||
dispatch(markEntry({ entry: props.entry, read: true }))
|
dispatch(markEntry({ entry: props.entry, read: true }))
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Group>
|
<Group>
|
||||||
<TbExternalLink size={iconSize} />
|
<TbExternalLink size={iconSize} />
|
||||||
<Trans>Open link in new tab</Trans>
|
<Trans>Open link in new tab</Trans>
|
||||||
</Group>
|
</Group>
|
||||||
</Item>
|
</Item>
|
||||||
<Item
|
<Item
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
openLinkInBackgroundTab(props.entry.url)
|
openLinkInBackgroundTab(props.entry.url)
|
||||||
dispatch(markEntry({ entry: props.entry, read: true }))
|
dispatch(markEntry({ entry: props.entry, read: true }))
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Group>
|
<Group>
|
||||||
<TbExternalLink size={iconSize} />
|
<TbExternalLink size={iconSize} />
|
||||||
<Trans>Open link in new background tab</Trans>
|
<Trans>Open link in new background tab</Trans>
|
||||||
</Group>
|
</Group>
|
||||||
</Item>
|
</Item>
|
||||||
|
|
||||||
<Separator />
|
<Separator />
|
||||||
|
|
||||||
<Item onClick={async () => await dispatch(starEntry({ entry: props.entry, starred: !props.entry.starred }))}>
|
{props.entry.markable && (
|
||||||
<Group>
|
<>
|
||||||
{props.entry.starred ? <TbStarOff size={iconSize} /> : <TbStar size={iconSize} />}
|
<Item onClick={async () => await dispatch(starEntry({ entry: props.entry, starred: !props.entry.starred }))}>
|
||||||
{props.entry.starred ? <Trans>Unstar</Trans> : <Trans>Star</Trans>}
|
<Group>
|
||||||
</Group>
|
{props.entry.starred ? <TbStarOff size={iconSize} /> : <TbStar size={iconSize} />}
|
||||||
</Item>
|
{props.entry.starred ? <Trans>Unstar</Trans> : <Trans>Star</Trans>}
|
||||||
<Item onClick={async () => await dispatch(markEntry({ entry: props.entry, read: !props.entry.read }))}>
|
</Group>
|
||||||
<Group>
|
</Item>
|
||||||
{props.entry.read ? <TbEyeOff size={iconSize} /> : <TbEyeCheck size={iconSize} />}
|
<Item onClick={async () => await dispatch(markEntry({ entry: props.entry, read: !props.entry.read }))}>
|
||||||
{props.entry.read ? <Trans>Keep unread</Trans> : <Trans>Mark as read</Trans>}
|
<Group>
|
||||||
</Group>
|
{props.entry.read ? <TbMail size={iconSize} /> : <TbMailOpened size={iconSize} />}
|
||||||
</Item>
|
{props.entry.read ? <Trans>Keep unread</Trans> : <Trans>Mark as read</Trans>}
|
||||||
<Item onClick={async () => await dispatch(markEntriesUpToEntry(props.entry))}>
|
</Group>
|
||||||
<Group>
|
</Item>
|
||||||
<TbArrowBarToDown size={iconSize} />
|
</>
|
||||||
<Trans>Mark as read up to here</Trans>
|
)}
|
||||||
</Group>
|
<Item onClick={async () => await dispatch(markEntriesUpToEntry(props.entry))}>
|
||||||
</Item>
|
<Group>
|
||||||
|
<TbArrowBarToDown size={iconSize} />
|
||||||
{sourceType === "category" && (
|
<Trans>Mark as read up to here</Trans>
|
||||||
<>
|
</Group>
|
||||||
<Separator />
|
</Item>
|
||||||
|
|
||||||
<Item
|
{sourceType === "category" && (
|
||||||
onClick={() => {
|
<>
|
||||||
dispatch(redirectToFeed(props.entry.feedId))
|
<Separator />
|
||||||
}}
|
|
||||||
>
|
<Item
|
||||||
<Group>
|
onClick={() => {
|
||||||
<TbRss size={iconSize} />
|
dispatch(redirectToFeed(props.entry.feedId))
|
||||||
<Trans>Go to {truncate(props.entry.feedName, 30)}</Trans>
|
}}
|
||||||
</Group>
|
>
|
||||||
</Item>
|
<Group>
|
||||||
</>
|
<TbRss size={iconSize} />
|
||||||
)}
|
<Trans>Go to {truncate(props.entry.feedName, 30)}</Trans>
|
||||||
</Menu>
|
</Group>
|
||||||
)
|
</Item>
|
||||||
}
|
</>
|
||||||
|
)}
|
||||||
|
</Menu>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,107 +1,104 @@
|
|||||||
import { t, Trans } from "@lingui/macro"
|
import { msg } from "@lingui/core/macro"
|
||||||
import { Group, Indicator, Popover, TagsInput } from "@mantine/core"
|
import { useLingui } from "@lingui/react"
|
||||||
import { markEntriesUpToEntry, markEntry, starEntry, tagEntry } from "app/entries/thunks"
|
import { Group, Indicator, Popover, TagsInput } from "@mantine/core"
|
||||||
import { useAppDispatch, useAppSelector } from "app/store"
|
import { TbArrowBarToDown, TbExternalLink, TbMail, TbMailOpened, TbShare, TbStar, TbStarOff, TbTag } from "react-icons/tb"
|
||||||
import { type Entry } from "app/types"
|
import { markEntriesUpToEntry, markEntry, starEntry, tagEntry } from "@/app/entries/thunks"
|
||||||
import { ActionButton } from "components/ActionButton"
|
import { useAppDispatch, useAppSelector } from "@/app/store"
|
||||||
import { useActionButton } from "hooks/useActionButton"
|
import type { Entry } from "@/app/types"
|
||||||
import { useMobile } from "hooks/useMobile"
|
import { ActionButton } from "@/components/ActionButton"
|
||||||
import { TbArrowBarToDown, TbExternalLink, TbEyeCheck, TbEyeOff, TbShare, TbStar, TbStarOff, TbTag } from "react-icons/tb"
|
import { useActionButton } from "@/hooks/useActionButton"
|
||||||
import { ShareButtons } from "./ShareButtons"
|
import { useMobile } from "@/hooks/useMobile"
|
||||||
|
import { ShareButtons } from "./ShareButtons"
|
||||||
interface FeedEntryFooterProps {
|
|
||||||
entry: Entry
|
interface FeedEntryFooterProps {
|
||||||
}
|
entry: Entry
|
||||||
|
}
|
||||||
export function FeedEntryFooter(props: FeedEntryFooterProps) {
|
|
||||||
const sharingSettings = useAppSelector(state => state.user.settings?.sharingSettings)
|
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 showSharingButtons = sharingSettings && Object.values(sharingSettings).some(v => v)
|
|
||||||
|
const readStatusButtonClicked = async () =>
|
||||||
const readStatusButtonClicked = async () =>
|
await dispatch(
|
||||||
await dispatch(
|
markEntry({
|
||||||
markEntry({
|
entry: props.entry,
|
||||||
entry: props.entry,
|
read: !props.entry.read,
|
||||||
read: !props.entry.read,
|
})
|
||||||
})
|
)
|
||||||
)
|
const onTagsChange = async (values: string[]) =>
|
||||||
const onTagsChange = async (values: string[]) =>
|
await dispatch(
|
||||||
await dispatch(
|
tagEntry({
|
||||||
tagEntry({
|
entryId: +props.entry.id,
|
||||||
entryId: +props.entry.id,
|
tags: values,
|
||||||
tags: values,
|
})
|
||||||
})
|
)
|
||||||
)
|
|
||||||
|
return (
|
||||||
return (
|
<Group justify="space-between" className="cf-footer">
|
||||||
<Group justify="space-between">
|
<Group gap={spacing}>
|
||||||
<Group gap={spacing}>
|
{props.entry.markable && (
|
||||||
{props.entry.markable && (
|
<ActionButton
|
||||||
<ActionButton
|
icon={props.entry.read ? <TbMail size={18} /> : <TbMailOpened size={18} />}
|
||||||
icon={props.entry.read ? <TbEyeOff size={18} /> : <TbEyeCheck size={18} />}
|
label={props.entry.read ? msg`Keep unread` : msg`Mark as read`}
|
||||||
label={props.entry.read ? <Trans>Keep unread</Trans> : <Trans>Mark as read</Trans>}
|
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 ? msg`Unstar` : msg`Star`}
|
||||||
label={props.entry.starred ? <Trans>Unstar</Trans> : <Trans>Star</Trans>}
|
onClick={async () =>
|
||||||
onClick={async () =>
|
await dispatch(
|
||||||
await dispatch(
|
starEntry({
|
||||||
starEntry({
|
entry: props.entry,
|
||||||
entry: props.entry,
|
starred: !props.entry.starred,
|
||||||
starred: !props.entry.starred,
|
})
|
||||||
})
|
)
|
||||||
)
|
}
|
||||||
}
|
/>
|
||||||
/>
|
|
||||||
|
<Popover withArrow withinPortal shadow="md" closeOnClickOutside={!mobile}>
|
||||||
{showSharingButtons && (
|
<Popover.Target>
|
||||||
<Popover withArrow withinPortal shadow="md" closeOnClickOutside={!mobile}>
|
<ActionButton icon={<TbShare size={18} />} label={msg`Share`} />
|
||||||
<Popover.Target>
|
</Popover.Target>
|
||||||
<ActionButton icon={<TbShare size={18} />} label={<Trans>Share</Trans>} />
|
<Popover.Dropdown>
|
||||||
</Popover.Target>
|
<ShareButtons url={props.entry.url} description={props.entry.title} />
|
||||||
<Popover.Dropdown>
|
</Popover.Dropdown>
|
||||||
<ShareButtons url={props.entry.url} description={props.entry.title} />
|
</Popover>
|
||||||
</Popover.Dropdown>
|
|
||||||
</Popover>
|
{tags && (
|
||||||
)}
|
<Popover withArrow shadow="md" closeOnClickOutside={!mobile}>
|
||||||
|
<Popover.Target>
|
||||||
{tags && (
|
<Indicator label={props.entry.tags.length} disabled={props.entry.tags.length === 0} inline size={16}>
|
||||||
<Popover withArrow shadow="md" closeOnClickOutside={!mobile}>
|
<ActionButton icon={<TbTag size={18} />} label={msg`Tags`} />
|
||||||
<Popover.Target>
|
</Indicator>
|
||||||
<Indicator label={props.entry.tags.length} disabled={props.entry.tags.length === 0} inline size={16}>
|
</Popover.Target>
|
||||||
<ActionButton icon={<TbTag size={18} />} label={<Trans>Tags</Trans>} />
|
<Popover.Dropdown>
|
||||||
</Indicator>
|
<TagsInput
|
||||||
</Popover.Target>
|
placeholder={_(msg`Tags`)}
|
||||||
<Popover.Dropdown>
|
data={tags}
|
||||||
<TagsInput
|
value={props.entry.tags}
|
||||||
placeholder={t`Tags`}
|
onChange={onTagsChange}
|
||||||
data={tags}
|
comboboxProps={{
|
||||||
value={props.entry.tags}
|
withinPortal: false,
|
||||||
onChange={onTagsChange}
|
}}
|
||||||
comboboxProps={{
|
/>
|
||||||
withinPortal: false,
|
</Popover.Dropdown>
|
||||||
}}
|
</Popover>
|
||||||
/>
|
)}
|
||||||
</Popover.Dropdown>
|
|
||||||
</Popover>
|
<a href={props.entry.url} target="_blank" rel="noreferrer">
|
||||||
)}
|
<ActionButton icon={<TbExternalLink size={18} />} label={msg`Open link`} />
|
||||||
|
</a>
|
||||||
<a href={props.entry.url} target="_blank" rel="noreferrer">
|
</Group>
|
||||||
<ActionButton icon={<TbExternalLink size={18} />} label={<Trans>Open link</Trans>} />
|
|
||||||
</a>
|
<ActionButton
|
||||||
</Group>
|
icon={<TbArrowBarToDown size={18} />}
|
||||||
|
label={msg`Mark as read up to here`}
|
||||||
<ActionButton
|
onClick={async () => await dispatch(markEntriesUpToEntry(props.entry))}
|
||||||
icon={<TbArrowBarToDown size={18} />}
|
/>
|
||||||
label={<Trans>Mark as read up to here</Trans>}
|
</Group>
|
||||||
onClick={async () => await dispatch(markEntriesUpToEntry(props.entry))}
|
)
|
||||||
/>
|
}
|
||||||
</Group>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,57 +0,0 @@
|
|||||||
import { Box, Space, Text } from "@mantine/core"
|
|
||||||
import { type Entry } from "app/types"
|
|
||||||
import { RelativeDate } from "components/RelativeDate"
|
|
||||||
import { tss } from "tss"
|
|
||||||
import { FeedEntryTitle } from "./FeedEntryTitle"
|
|
||||||
import { FeedFavicon } from "./FeedFavicon"
|
|
||||||
|
|
||||||
export interface FeedEntryHeaderProps {
|
|
||||||
entry: Entry
|
|
||||||
expanded: boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
const useStyles = tss
|
|
||||||
.withParams<{
|
|
||||||
read: boolean
|
|
||||||
}>()
|
|
||||||
.create(({ colorScheme, read }) => ({
|
|
||||||
headerText: {
|
|
||||||
fontWeight: colorScheme === "light" && !read ? "bold" : "inherit",
|
|
||||||
},
|
|
||||||
headerSubtext: {
|
|
||||||
display: "flex",
|
|
||||||
alignItems: "center",
|
|
||||||
fontSize: "90%",
|
|
||||||
},
|
|
||||||
}))
|
|
||||||
|
|
||||||
export function FeedEntryHeader(props: FeedEntryHeaderProps) {
|
|
||||||
const { classes } = useStyles({
|
|
||||||
read: props.entry.read,
|
|
||||||
})
|
|
||||||
return (
|
|
||||||
<Box>
|
|
||||||
<Box className={classes.headerText}>
|
|
||||||
<FeedEntryTitle entry={props.entry} />
|
|
||||||
</Box>
|
|
||||||
<Box className={classes.headerSubtext}>
|
|
||||||
<FeedFavicon url={props.entry.iconUrl} />
|
|
||||||
<Space w={6} />
|
|
||||||
<Text c="dimmed">
|
|
||||||
{props.entry.feedName}
|
|
||||||
<span> · </span>
|
|
||||||
<RelativeDate date={props.entry.date} />
|
|
||||||
</Text>
|
|
||||||
</Box>
|
|
||||||
{props.expanded && (
|
|
||||||
<Box className={classes.headerSubtext}>
|
|
||||||
<Text c="dimmed">
|
|
||||||
{props.entry.author && <span>by {props.entry.author}</span>}
|
|
||||||
{props.entry.author && props.entry.categories && <span> · </span>}
|
|
||||||
{props.entry.categories && <span>{props.entry.categories}</span>}
|
|
||||||
</Text>
|
|
||||||
</Box>
|
|
||||||
)}
|
|
||||||
</Box>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,21 +1,21 @@
|
|||||||
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}
|
||||||
alt="feed favicon"
|
alt="feed favicon"
|
||||||
width={size}
|
width={size}
|
||||||
height={size}
|
height={size}
|
||||||
placeholderWidth={size}
|
placeholderWidth={size}
|
||||||
placeholderHeight={size}
|
placeholderHeight={size}
|
||||||
placeholderBackgroundColor="inherit"
|
placeholderBackgroundColor="inherit"
|
||||||
placeholderIconSize={size}
|
placeholderIconSize={size}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,40 +1,40 @@
|
|||||||
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 { BasicHtmlStyles } from "components/content/BasicHtmlStyles"
|
import { BasicHtmlStyles } from "@/components/content/BasicHtmlStyles"
|
||||||
import { ImageWithPlaceholderWhileLoading } from "components/ImageWithPlaceholderWhileLoading"
|
import { ImageWithPlaceholderWhileLoading } from "@/components/ImageWithPlaceholderWhileLoading"
|
||||||
import { Content } from "./Content"
|
import { Content } from "./Content"
|
||||||
|
|
||||||
export interface MediaProps {
|
export interface MediaProps {
|
||||||
thumbnailUrl: string
|
thumbnailUrl: string
|
||||||
thumbnailWidth?: number
|
thumbnailWidth?: number
|
||||||
thumbnailHeight?: number
|
thumbnailHeight?: number
|
||||||
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({
|
||||||
width,
|
width,
|
||||||
height,
|
height,
|
||||||
maxWidth: Constants.layout.entryMaxWidth,
|
maxWidth: Constants.layout.entryMaxWidth,
|
||||||
})
|
})
|
||||||
return (
|
return (
|
||||||
<BasicHtmlStyles>
|
<BasicHtmlStyles>
|
||||||
<ImageWithPlaceholderWhileLoading
|
<ImageWithPlaceholderWhileLoading
|
||||||
src={props.thumbnailUrl}
|
src={props.thumbnailUrl}
|
||||||
alt="media thumbnail"
|
alt="media thumbnail"
|
||||||
width={props.thumbnailWidth}
|
width={props.thumbnailWidth}
|
||||||
height={props.thumbnailHeight}
|
height={props.thumbnailHeight}
|
||||||
placeholderWidth={placeholderSize.width}
|
placeholderWidth={placeholderSize.width}
|
||||||
placeholderHeight={placeholderSize.height}
|
placeholderHeight={placeholderSize.height}
|
||||||
/>
|
/>
|
||||||
{props.description && (
|
{props.description && (
|
||||||
<Box pt="md">
|
<Box pt="md">
|
||||||
<Content content={props.description} />
|
<Content content={props.description} />
|
||||||
</Box>
|
</Box>
|
||||||
)}
|
)}
|
||||||
</BasicHtmlStyles>
|
</BasicHtmlStyles>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,62 +1,144 @@
|
|||||||
import { ActionIcon, Box, SimpleGrid } from "@mantine/core"
|
import { Trans } from "@lingui/react/macro"
|
||||||
import { Constants } from "app/constants"
|
import { ActionIcon, Box, CopyButton, Divider, SimpleGrid } from "@mantine/core"
|
||||||
import { useAppSelector } from "app/store"
|
import type { IconType } from "react-icons"
|
||||||
import { type SharingSettings } from "app/types"
|
import { TbCheck, TbCopy, TbDeviceDesktopShare, TbDeviceMobileShare } from "react-icons/tb"
|
||||||
import { type IconType } from "react-icons"
|
import { Constants } from "@/app/constants"
|
||||||
import { tss } from "tss"
|
import { useAppSelector } from "@/app/store"
|
||||||
|
import type { SharingSettings } from "@/app/types"
|
||||||
type Color = `#${string}`
|
import { useBrowserExtension } from "@/hooks/useBrowserExtension"
|
||||||
|
import { useMobile } from "@/hooks/useMobile"
|
||||||
const useStyles = tss
|
import { tss } from "@/tss"
|
||||||
.withParams<{
|
|
||||||
color: Color
|
type Color = `#${string}`
|
||||||
}>()
|
|
||||||
.create(({ theme, colorScheme, color }) => ({
|
const useStyles = tss
|
||||||
socialIcon: {
|
.withParams<{
|
||||||
color,
|
color: Color
|
||||||
backgroundColor: colorScheme === "dark" ? theme.colors.gray[2] : "white",
|
}>()
|
||||||
borderRadius: "50%",
|
.create(({ theme, colorScheme, color }) => ({
|
||||||
},
|
icon: {
|
||||||
}))
|
color,
|
||||||
|
backgroundColor: colorScheme === "dark" ? theme.colors.gray[2] : "white",
|
||||||
function ShareButton({ url, icon, color }: { url: string; icon: IconType; color: Color }) {
|
},
|
||||||
const { classes } = useStyles({
|
}))
|
||||||
color,
|
|
||||||
})
|
function ShareButton({
|
||||||
|
icon,
|
||||||
const onClick = (e: React.MouseEvent) => {
|
color,
|
||||||
e.preventDefault()
|
onClick,
|
||||||
window.open(url, "", "menubar=no,toolbar=no,resizable=yes,scrollbars=yes,width=800,height=600")
|
}: Readonly<{
|
||||||
}
|
icon: IconType
|
||||||
|
color: Color
|
||||||
return (
|
onClick: () => void
|
||||||
<ActionIcon variant="transparent">
|
}>) {
|
||||||
<a href={url} target="_blank" rel="noreferrer" onClick={onClick}>
|
const { classes } = useStyles({
|
||||||
<Box p={6} className={classes.socialIcon}>
|
color,
|
||||||
{icon({ size: 18 })}
|
})
|
||||||
</Box>
|
|
||||||
</a>
|
return (
|
||||||
</ActionIcon>
|
<ActionIcon variant="transparent" radius="xl" size={32}>
|
||||||
)
|
<Box p={6} className={classes.icon} onClick={onClick}>
|
||||||
}
|
{icon({ size: 18 })}
|
||||||
|
</Box>
|
||||||
export function ShareButtons(props: { url: string; description: string }) {
|
</ActionIcon>
|
||||||
const sharingSettings = useAppSelector(state => state.user.settings?.sharingSettings)
|
)
|
||||||
const url = encodeURIComponent(props.url)
|
}
|
||||||
const desc = encodeURIComponent(props.description)
|
|
||||||
|
function SiteShareButton({
|
||||||
return (
|
url,
|
||||||
<SimpleGrid cols={4}>
|
icon,
|
||||||
{(Object.keys(Constants.sharing) as (keyof SharingSettings)[])
|
color,
|
||||||
.filter(site => sharingSettings?.[site])
|
}: Readonly<{
|
||||||
.map(site => (
|
icon: IconType
|
||||||
<ShareButton
|
color: Color
|
||||||
key={site}
|
url: string
|
||||||
icon={Constants.sharing[site].icon}
|
}>) {
|
||||||
color={Constants.sharing[site].color}
|
const onClick = () => {
|
||||||
url={Constants.sharing[site].url(url, desc)}
|
window.open(url, "", "menubar=no,toolbar=no,resizable=yes,scrollbars=yes,width=800,height=600")
|
||||||
/>
|
}
|
||||||
))}
|
|
||||||
</SimpleGrid>
|
return <ShareButton icon={icon} color={color} onClick={onClick} />
|
||||||
)
|
}
|
||||||
}
|
|
||||||
|
function CopyUrlButton({
|
||||||
|
url,
|
||||||
|
}: Readonly<{
|
||||||
|
url: string
|
||||||
|
}>) {
|
||||||
|
return (
|
||||||
|
<CopyButton value={url}>
|
||||||
|
{({ copied, copy }) => <ShareButton icon={copied ? TbCheck : TbCopy} color="#000" onClick={copy} />}
|
||||||
|
</CopyButton>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function BrowserNativeShareButton({
|
||||||
|
url,
|
||||||
|
description,
|
||||||
|
}: Readonly<{
|
||||||
|
url: string
|
||||||
|
description: string
|
||||||
|
}>) {
|
||||||
|
const mobile = useMobile()
|
||||||
|
const { isBrowserExtensionPopup } = useBrowserExtension()
|
||||||
|
const onClick = () => {
|
||||||
|
navigator.share({
|
||||||
|
title: description,
|
||||||
|
url,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ShareButton
|
||||||
|
icon={mobile && !isBrowserExtensionPopup ? TbDeviceMobileShare : TbDeviceDesktopShare}
|
||||||
|
color="#000"
|
||||||
|
onClick={onClick}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ShareButtons(
|
||||||
|
props: Readonly<{
|
||||||
|
url: string
|
||||||
|
description: string
|
||||||
|
}>
|
||||||
|
) {
|
||||||
|
const sharingSettings = useAppSelector(state => state.user.settings?.sharingSettings)
|
||||||
|
const enabledSharingSites = (Object.keys(Constants.sharing) as Array<keyof SharingSettings>).filter(site => sharingSettings?.[site])
|
||||||
|
const url = encodeURIComponent(props.url)
|
||||||
|
const desc = encodeURIComponent(props.description)
|
||||||
|
const clipboardAvailable = typeof navigator.clipboard !== "undefined"
|
||||||
|
const nativeSharingAvailable = typeof navigator.share !== "undefined"
|
||||||
|
const showNativeSection = clipboardAvailable || nativeSharingAvailable
|
||||||
|
const showSharingSites = enabledSharingSites.length > 0
|
||||||
|
const showDivider = showNativeSection && showSharingSites
|
||||||
|
const showNoSharingOptionsAvailable = !showNativeSection && !showSharingSites
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{showNativeSection && (
|
||||||
|
<SimpleGrid cols={4}>
|
||||||
|
{clipboardAvailable && <CopyUrlButton url={props.url} />}
|
||||||
|
{nativeSharingAvailable && <BrowserNativeShareButton url={props.url} description={props.description} />}
|
||||||
|
</SimpleGrid>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{showDivider && <Divider my="xs" />}
|
||||||
|
|
||||||
|
{showSharingSites && (
|
||||||
|
<SimpleGrid cols={4}>
|
||||||
|
{enabledSharingSites.map(site => (
|
||||||
|
<SiteShareButton
|
||||||
|
key={site}
|
||||||
|
icon={Constants.sharing[site].icon}
|
||||||
|
color={Constants.sharing[site].color}
|
||||||
|
url={Constants.sharing[site].url(url, desc)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</SimpleGrid>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{showNoSharingOptionsAvailable && <Trans>No sharing options available.</Trans>}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,50 +1,53 @@
|
|||||||
import { t, Trans } from "@lingui/macro"
|
import { msg } from "@lingui/core/macro"
|
||||||
import { Box, Button, Group, Stack, TextInput } from "@mantine/core"
|
import { useLingui } from "@lingui/react"
|
||||||
import { useForm } from "@mantine/form"
|
import { Trans } from "@lingui/react/macro"
|
||||||
import { client, errorToStrings } from "app/client"
|
import { Box, Button, Group, Stack, TextInput } from "@mantine/core"
|
||||||
import { redirectToSelectedSource } from "app/redirect/thunks"
|
import { useForm } from "@mantine/form"
|
||||||
import { useAppDispatch } from "app/store"
|
import { useAsyncCallback } from "react-async-hook"
|
||||||
import { reloadTree } from "app/tree/thunks"
|
import { TbFolderPlus } from "react-icons/tb"
|
||||||
import { type AddCategoryRequest } from "app/types"
|
import { client, errorToStrings } from "@/app/client"
|
||||||
import { Alert } from "components/Alert"
|
import { redirectToSelectedSource } from "@/app/redirect/thunks"
|
||||||
import { useAsyncCallback } from "react-async-hook"
|
import { useAppDispatch } from "@/app/store"
|
||||||
import { TbFolderPlus } from "react-icons/tb"
|
import { reloadTree } from "@/app/tree/thunks"
|
||||||
import { CategorySelect } from "./CategorySelect"
|
import type { AddCategoryRequest } from "@/app/types"
|
||||||
|
import { Alert } from "@/components/Alert"
|
||||||
export function AddCategory() {
|
import { CategorySelect } from "./CategorySelect"
|
||||||
const dispatch = useAppDispatch()
|
|
||||||
|
export function AddCategory() {
|
||||||
const form = useForm<AddCategoryRequest>()
|
const dispatch = useAppDispatch()
|
||||||
|
const { _ } = useLingui()
|
||||||
const addCategory = useAsyncCallback(client.category.add, {
|
|
||||||
onSuccess: () => {
|
const form = useForm<AddCategoryRequest>()
|
||||||
dispatch(reloadTree())
|
|
||||||
dispatch(redirectToSelectedSource())
|
const addCategory = useAsyncCallback(client.category.add, {
|
||||||
},
|
onSuccess: () => {
|
||||||
})
|
dispatch(reloadTree())
|
||||||
|
dispatch(redirectToSelectedSource())
|
||||||
return (
|
},
|
||||||
<>
|
})
|
||||||
{addCategory.error && (
|
|
||||||
<Box mb="md">
|
return (
|
||||||
<Alert messages={errorToStrings(addCategory.error)} />
|
<>
|
||||||
</Box>
|
{addCategory.error && (
|
||||||
)}
|
<Box mb="md">
|
||||||
|
<Alert messages={errorToStrings(addCategory.error)} />
|
||||||
<form onSubmit={form.onSubmit(addCategory.execute)}>
|
</Box>
|
||||||
<Stack>
|
)}
|
||||||
<TextInput label={<Trans>Category</Trans>} placeholder={t`Category`} {...form.getInputProps("name")} required />
|
|
||||||
<CategorySelect label={<Trans>Parent</Trans>} {...form.getInputProps("parentId")} clearable />
|
<form onSubmit={form.onSubmit(addCategory.execute)}>
|
||||||
<Group justify="center">
|
<Stack>
|
||||||
<Button variant="default" onClick={async () => await dispatch(redirectToSelectedSource())}>
|
<TextInput label={<Trans>Category</Trans>} placeholder={_(msg`Category`)} {...form.getInputProps("name")} required />
|
||||||
<Trans>Cancel</Trans>
|
<CategorySelect label={<Trans>Parent</Trans>} {...form.getInputProps("parentId")} clearable />
|
||||||
</Button>
|
<Group justify="center">
|
||||||
<Button type="submit" leftSection={<TbFolderPlus size={16} />} loading={addCategory.loading}>
|
<Button variant="default" onClick={async () => await dispatch(redirectToSelectedSource())}>
|
||||||
<Trans>Add</Trans>
|
<Trans>Cancel</Trans>
|
||||||
</Button>
|
</Button>
|
||||||
</Group>
|
<Button type="submit" leftSection={<TbFolderPlus size={16} />} loading={addCategory.loading}>
|
||||||
</Stack>
|
<Trans>Add</Trans>
|
||||||
</form>
|
</Button>
|
||||||
</>
|
</Group>
|
||||||
)
|
</Stack>
|
||||||
}
|
</form>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,51 +1,55 @@
|
|||||||
import { t } from "@lingui/macro"
|
import { msg } from "@lingui/core/macro"
|
||||||
import { Select, type SelectProps } from "@mantine/core"
|
import { useLingui } from "@lingui/react"
|
||||||
import { type ComboboxItem } from "@mantine/core/lib/components/Combobox/Combobox.types"
|
import { Select, type SelectProps } from "@mantine/core"
|
||||||
import { Constants } from "app/constants"
|
import type { ComboboxItem } from "@mantine/core/lib/components/Combobox/Combobox.types"
|
||||||
import { useAppSelector } from "app/store"
|
import { Constants } from "@/app/constants"
|
||||||
import { type Category } from "app/types"
|
import { useAppSelector } from "@/app/store"
|
||||||
import { flattenCategoryTree } from "app/utils"
|
import type { Category } from "@/app/types"
|
||||||
|
import { flattenCategoryTree } from "@/app/utils"
|
||||||
type CategorySelectProps = Partial<SelectProps> & {
|
|
||||||
withAll?: boolean
|
type CategorySelectProps = Partial<SelectProps> & {
|
||||||
withoutCategoryIds?: string[]
|
withAll?: boolean
|
||||||
}
|
withoutCategoryIds?: string[]
|
||||||
|
}
|
||||||
export function CategorySelect(props: CategorySelectProps) {
|
|
||||||
const rootCategory = useAppSelector(state => state.tree.rootCategory)
|
export function CategorySelect(props: CategorySelectProps) {
|
||||||
const categories = rootCategory && flattenCategoryTree(rootCategory)
|
const rootCategory = useAppSelector(state => state.tree.rootCategory)
|
||||||
const categoriesById = categories?.reduce((map, c) => {
|
const { _ } = useLingui()
|
||||||
map.set(c.id, c)
|
|
||||||
return map
|
const categories = rootCategory && flattenCategoryTree(rootCategory)
|
||||||
}, new Map<string, Category>())
|
const categoriesById = categories?.reduce((map, c) => {
|
||||||
const categoryLabel = (cat: Category) => {
|
map.set(c.id, c)
|
||||||
let label = cat.name
|
return map
|
||||||
|
}, new Map<string, Category>())
|
||||||
while (cat.parentId) {
|
const categoryLabel = (category: Category) => {
|
||||||
const parent = categoriesById?.get(cat.parentId)
|
let cat = category
|
||||||
if (!parent) {
|
let label = cat.name
|
||||||
break
|
|
||||||
}
|
while (cat.parentId) {
|
||||||
label = `${parent.name} → ${label}`
|
const parent = categoriesById?.get(cat.parentId)
|
||||||
cat = parent
|
if (!parent) {
|
||||||
}
|
break
|
||||||
|
}
|
||||||
return label
|
label = `${parent.name} → ${label}`
|
||||||
}
|
cat = parent
|
||||||
const selectData: ComboboxItem[] | undefined = categories
|
}
|
||||||
?.filter(c => c.id !== Constants.categories.all.id)
|
|
||||||
.filter(c => !props.withoutCategoryIds?.includes(c.id))
|
return label
|
||||||
.map(c => ({
|
}
|
||||||
label: categoryLabel(c),
|
const selectData: ComboboxItem[] | undefined = categories
|
||||||
value: c.id,
|
?.filter(c => c.id !== Constants.categories.all.id)
|
||||||
}))
|
.filter(c => !props.withoutCategoryIds?.includes(c.id))
|
||||||
.sort((c1, c2) => c1.label.localeCompare(c2.label))
|
.map(c => ({
|
||||||
if (props.withAll) {
|
label: categoryLabel(c),
|
||||||
selectData?.unshift({
|
value: c.id,
|
||||||
label: t`All`,
|
}))
|
||||||
value: Constants.categories.all.id,
|
.sort((c1, c2) => c1.label.localeCompare(c2.label))
|
||||||
})
|
if (props.withAll) {
|
||||||
}
|
selectData?.unshift({
|
||||||
|
label: _(msg`All`),
|
||||||
return <Select {...props} data={selectData ?? []} disabled={!selectData} />
|
value: Constants.categories.all.id,
|
||||||
}
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return <Select {...props} data={selectData ?? []} disabled={!selectData} />
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,65 +1,67 @@
|
|||||||
import { t, Trans } from "@lingui/macro"
|
import { msg } from "@lingui/core/macro"
|
||||||
import { Box, Button, FileInput, Group, Stack } from "@mantine/core"
|
import { useLingui } from "@lingui/react"
|
||||||
import { useForm } from "@mantine/form"
|
import { Trans } from "@lingui/react/macro"
|
||||||
import { client, errorToStrings } from "app/client"
|
import { Box, Button, FileInput, Group, Stack } from "@mantine/core"
|
||||||
import { redirectToSelectedSource } from "app/redirect/thunks"
|
import { isNotEmpty, useForm } from "@mantine/form"
|
||||||
import { useAppDispatch } from "app/store"
|
import { useAsyncCallback } from "react-async-hook"
|
||||||
import { reloadTree } from "app/tree/thunks"
|
import { TbFileImport } from "react-icons/tb"
|
||||||
import { Alert } from "components/Alert"
|
import { client, errorToStrings } from "@/app/client"
|
||||||
import { useAsyncCallback } from "react-async-hook"
|
import { redirectToSelectedSource } from "@/app/redirect/thunks"
|
||||||
import { TbFileImport } from "react-icons/tb"
|
import { useAppDispatch } from "@/app/store"
|
||||||
|
import { reloadTree } from "@/app/tree/thunks"
|
||||||
export function ImportOpml() {
|
import { Alert } from "@/components/Alert"
|
||||||
const dispatch = useAppDispatch()
|
|
||||||
|
export function ImportOpml() {
|
||||||
const form = useForm<{ file: File }>({
|
const dispatch = useAppDispatch()
|
||||||
validate: {
|
const { _ } = useLingui()
|
||||||
file: () => t`file is required`,
|
|
||||||
},
|
const form = useForm<{ file: File }>({
|
||||||
})
|
validate: {
|
||||||
|
file: isNotEmpty(_(msg`OPML file is required`)),
|
||||||
const importOpml = useAsyncCallback(client.feed.importOpml, {
|
},
|
||||||
onSuccess: () => {
|
})
|
||||||
dispatch(reloadTree())
|
|
||||||
dispatch(redirectToSelectedSource())
|
const importOpml = useAsyncCallback(client.feed.importOpml, {
|
||||||
},
|
onSuccess: () => {
|
||||||
})
|
dispatch(reloadTree())
|
||||||
|
dispatch(redirectToSelectedSource())
|
||||||
return (
|
},
|
||||||
<>
|
})
|
||||||
{importOpml.error && (
|
|
||||||
<Box mb="md">
|
return (
|
||||||
<Alert messages={errorToStrings(importOpml.error)} />
|
<>
|
||||||
</Box>
|
{importOpml.error && (
|
||||||
)}
|
<Box mb="md">
|
||||||
|
<Alert messages={errorToStrings(importOpml.error)} />
|
||||||
<form onSubmit={form.onSubmit(async v => await importOpml.execute(v.file))}>
|
</Box>
|
||||||
<Stack>
|
)}
|
||||||
<FileInput
|
|
||||||
label={<Trans>OPML file</Trans>}
|
<form onSubmit={form.onSubmit(async v => await importOpml.execute(v.file))}>
|
||||||
leftSection={<TbFileImport />}
|
<Stack>
|
||||||
// https://github.com/mantinedev/mantine/issues/5401
|
<FileInput
|
||||||
{...{ placeholder: t`OPML file` }}
|
label={<Trans>OPML file</Trans>}
|
||||||
description={
|
leftSection={<TbFileImport />}
|
||||||
<Trans>
|
placeholder={_(msg`OPML file`)}
|
||||||
An opml file is an XML file containing feed URLs and categories. You can get an OPML file by exporting your
|
description={
|
||||||
data from other feed reading services.
|
<Trans>
|
||||||
</Trans>
|
An opml file is an XML file containing feed URLs and categories. You can get an OPML file by exporting your
|
||||||
}
|
data from other feed reading services.
|
||||||
{...form.getInputProps("file")}
|
</Trans>
|
||||||
required
|
}
|
||||||
accept="application/xml"
|
{...form.getInputProps("file")}
|
||||||
/>
|
required
|
||||||
<Group justify="center">
|
accept=".xml,.opml"
|
||||||
<Button variant="default" onClick={async () => await dispatch(redirectToSelectedSource())}>
|
/>
|
||||||
<Trans>Cancel</Trans>
|
<Group justify="center">
|
||||||
</Button>
|
<Button variant="default" onClick={async () => await dispatch(redirectToSelectedSource())}>
|
||||||
<Button type="submit" leftSection={<TbFileImport size={16} />} loading={importOpml.loading}>
|
<Trans>Cancel</Trans>
|
||||||
<Trans>Import</Trans>
|
</Button>
|
||||||
</Button>
|
<Button type="submit" leftSection={<TbFileImport size={16} />} loading={importOpml.loading}>
|
||||||
</Group>
|
<Trans>Import</Trans>
|
||||||
</Stack>
|
</Button>
|
||||||
</form>
|
</Group>
|
||||||
</>
|
</Stack>
|
||||||
)
|
</form>
|
||||||
}
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,129 +1,128 @@
|
|||||||
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 { useState } from "react"
|
||||||
import { Constants } from "app/constants"
|
import { useAsyncCallback } from "react-async-hook"
|
||||||
import { redirectToFeed, redirectToSelectedSource } from "app/redirect/thunks"
|
import { TbRss } from "react-icons/tb"
|
||||||
import { useAppDispatch } from "app/store"
|
import { client, errorToStrings } from "@/app/client"
|
||||||
import { reloadTree } from "app/tree/thunks"
|
import { Constants } from "@/app/constants"
|
||||||
import { type FeedInfoRequest, type SubscribeRequest } from "app/types"
|
import { redirectToFeed, redirectToSelectedSource } from "@/app/redirect/thunks"
|
||||||
import { Alert } from "components/Alert"
|
import { useAppDispatch } from "@/app/store"
|
||||||
import { useState } from "react"
|
import { reloadTree } from "@/app/tree/thunks"
|
||||||
import { useAsyncCallback } from "react-async-hook"
|
import type { FeedInfoRequest, SubscribeRequest } from "@/app/types"
|
||||||
import { TbRss } from "react-icons/tb"
|
import { Alert } from "@/components/Alert"
|
||||||
import { CategorySelect } from "./CategorySelect"
|
import { CategorySelect } from "./CategorySelect"
|
||||||
|
|
||||||
export function Subscribe() {
|
export function Subscribe() {
|
||||||
const [activeStep, setActiveStep] = useState(0)
|
const [activeStep, setActiveStep] = useState(0)
|
||||||
const dispatch = useAppDispatch()
|
const dispatch = useAppDispatch()
|
||||||
|
|
||||||
const step0Form = useForm<FeedInfoRequest>({
|
const step0Form = useForm<FeedInfoRequest>({
|
||||||
initialValues: {
|
initialValues: {
|
||||||
url: "",
|
url: "",
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
const step1Form = useForm<SubscribeRequest>({
|
const step1Form = useForm<SubscribeRequest>({
|
||||||
initialValues: {
|
initialValues: {
|
||||||
url: "",
|
url: "",
|
||||||
title: "",
|
title: "",
|
||||||
categoryId: Constants.categories.all.id,
|
categoryId: Constants.categories.all.id,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
const fetchFeed = useAsyncCallback(client.feed.fetchFeed, {
|
const fetchFeed = useAsyncCallback(client.feed.fetchFeed, {
|
||||||
onSuccess: ({ data }) => {
|
onSuccess: ({ data }) => {
|
||||||
step1Form.setFieldValue("url", data.url)
|
step1Form.setFieldValue("url", data.url)
|
||||||
step1Form.setFieldValue("title", data.title)
|
step1Form.setFieldValue("title", data.title)
|
||||||
setActiveStep(step => step + 1)
|
setActiveStep(step => step + 1)
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
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))
|
},
|
||||||
},
|
})
|
||||||
})
|
|
||||||
|
const previousStep = () => {
|
||||||
const previousStep = () => {
|
if (activeStep === 0) {
|
||||||
if (activeStep === 0) {
|
dispatch(redirectToSelectedSource())
|
||||||
dispatch(redirectToSelectedSource())
|
} else {
|
||||||
} else {
|
setActiveStep(activeStep - 1)
|
||||||
setActiveStep(activeStep - 1)
|
}
|
||||||
}
|
}
|
||||||
}
|
const nextStep = (e: React.FormEvent<HTMLFormElement>) => {
|
||||||
const nextStep = (e: React.FormEvent<HTMLFormElement>) => {
|
if (activeStep === 0) {
|
||||||
if (activeStep === 0) {
|
step0Form.onSubmit(fetchFeed.execute)(e)
|
||||||
step0Form.onSubmit(fetchFeed.execute)(e)
|
} else if (activeStep === 1) {
|
||||||
} else if (activeStep === 1) {
|
step1Form.onSubmit(subscribe.execute)(e)
|
||||||
step1Form.onSubmit(subscribe.execute)(e)
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
return (
|
||||||
return (
|
<>
|
||||||
<>
|
{fetchFeed.error && (
|
||||||
{fetchFeed.error && (
|
<Box mb="md">
|
||||||
<Box mb="md">
|
<Alert messages={errorToStrings(fetchFeed.error)} />
|
||||||
<Alert messages={errorToStrings(fetchFeed.error)} />
|
</Box>
|
||||||
</Box>
|
)}
|
||||||
)}
|
|
||||||
|
{subscribe.error && (
|
||||||
{subscribe.error && (
|
<Box mb="md">
|
||||||
<Box mb="md">
|
<Alert messages={errorToStrings(subscribe.error)} />
|
||||||
<Alert messages={errorToStrings(subscribe.error)} />
|
</Box>
|
||||||
</Box>
|
)}
|
||||||
)}
|
|
||||||
|
<form onSubmit={nextStep}>
|
||||||
<form onSubmit={nextStep}>
|
<Stepper active={activeStep} onStepClick={setActiveStep}>
|
||||||
<Stepper active={activeStep} onStepClick={setActiveStep}>
|
<Stepper.Step
|
||||||
<Stepper.Step
|
label={<Trans>Analyze feed</Trans>}
|
||||||
label={<Trans>Analyze feed</Trans>}
|
description={<Trans>Check that the feed is working</Trans>}
|
||||||
description={<Trans>Check that the feed is working</Trans>}
|
allowStepSelect={activeStep === 1}
|
||||||
allowStepSelect={activeStep === 1}
|
>
|
||||||
>
|
<TextInput
|
||||||
<TextInput
|
label={<Trans>Feed URL</Trans>}
|
||||||
label={<Trans>Feed URL</Trans>}
|
placeholder="https://www.mysite.com/rss"
|
||||||
placeholder="https://www.mysite.com/rss"
|
description={
|
||||||
description={
|
<Trans>
|
||||||
<Trans>
|
The URL for the feed you want to subscribe to. You can also use the website's url directly and CommaFeed
|
||||||
The URL for the feed you want to subscribe to. You can also use the website's url directly and CommaFeed
|
will try to find the feed in the page.
|
||||||
will try to find the feed in the page.
|
</Trans>
|
||||||
</Trans>
|
}
|
||||||
}
|
required
|
||||||
required
|
autoFocus
|
||||||
autoFocus
|
{...step0Form.getInputProps("url")}
|
||||||
{...step0Form.getInputProps("url")}
|
/>
|
||||||
/>
|
</Stepper.Step>
|
||||||
</Stepper.Step>
|
<Stepper.Step
|
||||||
<Stepper.Step
|
label={<Trans>Subscribe</Trans>}
|
||||||
label={<Trans>Subscribe</Trans>}
|
description={<Trans>Subscribe to the feed</Trans>}
|
||||||
description={<Trans>Subscribe to the feed</Trans>}
|
allowStepSelect={false}
|
||||||
allowStepSelect={false}
|
>
|
||||||
>
|
<Stack>
|
||||||
<Stack>
|
<TextInput label={<Trans>Feed URL</Trans>} {...step1Form.getInputProps("url")} disabled />
|
||||||
<TextInput label={<Trans>Feed URL</Trans>} {...step1Form.getInputProps("url")} disabled />
|
<TextInput label={<Trans>Feed name</Trans>} {...step1Form.getInputProps("title")} required autoFocus />
|
||||||
<TextInput label={<Trans>Feed name</Trans>} {...step1Form.getInputProps("title")} required autoFocus />
|
<CategorySelect label={<Trans>Category</Trans>} {...step1Form.getInputProps("categoryId")} clearable />
|
||||||
<CategorySelect label={<Trans>Category</Trans>} {...step1Form.getInputProps("categoryId")} clearable />
|
</Stack>
|
||||||
</Stack>
|
</Stepper.Step>
|
||||||
</Stepper.Step>
|
</Stepper>
|
||||||
</Stepper>
|
|
||||||
|
<Group justify="center" mt="xl">
|
||||||
<Group justify="center" mt="xl">
|
<Button variant="default" onClick={previousStep}>
|
||||||
<Button variant="default" onClick={previousStep}>
|
<Trans>Back</Trans>
|
||||||
<Trans>Back</Trans>
|
</Button>
|
||||||
</Button>
|
{activeStep === 0 && (
|
||||||
{activeStep === 0 && (
|
<Button type="submit" loading={fetchFeed.loading}>
|
||||||
<Button type="submit" loading={fetchFeed.loading}>
|
<Trans>Next</Trans>
|
||||||
<Trans>Next</Trans>
|
</Button>
|
||||||
</Button>
|
)}
|
||||||
)}
|
{activeStep === 1 && (
|
||||||
{activeStep === 1 && (
|
<Button type="submit" leftSection={<TbRss size={16} />} loading={fetchFeed.loading || subscribe.loading}>
|
||||||
<Button type="submit" leftSection={<TbRss size={16} />} loading={fetchFeed.loading || subscribe.loading}>
|
<Trans>Subscribe</Trans>
|
||||||
<Trans>Subscribe</Trans>
|
</Button>
|
||||||
</Button>
|
)}
|
||||||
)}
|
</Group>
|
||||||
</Group>
|
</form>
|
||||||
</form>
|
</>
|
||||||
</>
|
)
|
||||||
)
|
}
|
||||||
}
|
|
||||||
|
|||||||
@@ -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,66 +1,72 @@
|
|||||||
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 { OnDesktop } from "components/responsive/OnDesktop"
|
import { OpenExternalLink } from "@/components/content/header/OpenExternalLink"
|
||||||
import { tss } from "tss"
|
import { Star } from "@/components/content/header/Star"
|
||||||
import { FeedEntryTitle } from "./FeedEntryTitle"
|
import { RelativeDate } from "@/components/RelativeDate"
|
||||||
import { FeedFavicon } from "./FeedFavicon"
|
import { OnDesktop } from "@/components/responsive/OnDesktop"
|
||||||
|
import { tss } from "@/tss"
|
||||||
export interface FeedEntryHeaderProps {
|
import { FeedEntryTitle } from "./FeedEntryTitle"
|
||||||
entry: Entry
|
|
||||||
}
|
export interface FeedEntryHeaderProps {
|
||||||
|
entry: Entry
|
||||||
const useStyles = tss
|
showStarIcon?: boolean
|
||||||
.withParams<{
|
showExternalLinkIcon?: boolean
|
||||||
read: boolean
|
}
|
||||||
}>()
|
|
||||||
.create(({ colorScheme, read }) => ({
|
const useStyles = tss
|
||||||
wrapper: {
|
.withParams<{
|
||||||
display: "flex",
|
read: boolean
|
||||||
alignItems: "center",
|
}>()
|
||||||
columnGap: "10px",
|
.create(({ colorScheme, read }) => ({
|
||||||
},
|
wrapper: {
|
||||||
title: {
|
display: "flex",
|
||||||
flexGrow: 1,
|
alignItems: "center",
|
||||||
fontWeight: colorScheme === "light" && !read ? "bold" : "inherit",
|
columnGap: "10px",
|
||||||
whiteSpace: "nowrap",
|
},
|
||||||
overflow: "hidden",
|
title: {
|
||||||
textOverflow: "ellipsis",
|
flexGrow: 1,
|
||||||
},
|
fontWeight: colorScheme === "light" && !read ? "bold" : "inherit",
|
||||||
feedName: {
|
whiteSpace: "nowrap",
|
||||||
width: "145px",
|
overflow: "hidden",
|
||||||
minWidth: "145px",
|
textOverflow: "ellipsis",
|
||||||
whiteSpace: "nowrap",
|
},
|
||||||
overflow: "hidden",
|
feedName: {
|
||||||
textOverflow: "ellipsis",
|
width: "145px",
|
||||||
},
|
minWidth: "145px",
|
||||||
date: {
|
whiteSpace: "nowrap",
|
||||||
whiteSpace: "nowrap",
|
overflow: "hidden",
|
||||||
},
|
textOverflow: "ellipsis",
|
||||||
}))
|
},
|
||||||
|
date: {
|
||||||
export function FeedEntryCompactHeader(props: FeedEntryHeaderProps) {
|
whiteSpace: "nowrap",
|
||||||
const { classes } = useStyles({
|
},
|
||||||
read: props.entry.read,
|
}))
|
||||||
})
|
|
||||||
return (
|
export function FeedEntryCompactHeader(props: Readonly<FeedEntryHeaderProps>) {
|
||||||
<Box className={classes.wrapper}>
|
const { classes } = useStyles({
|
||||||
<Box>
|
read: props.entry.read,
|
||||||
<FeedFavicon url={props.entry.iconUrl} />
|
})
|
||||||
</Box>
|
return (
|
||||||
<OnDesktop>
|
<Box className={classes.wrapper}>
|
||||||
<Text c="dimmed" className={classes.feedName}>
|
{props.showStarIcon && <Star entry={props.entry} />}
|
||||||
{props.entry.feedName}
|
<Box>
|
||||||
</Text>
|
<FeedFavicon url={props.entry.iconUrl} />
|
||||||
</OnDesktop>
|
</Box>
|
||||||
<Box className={classes.title}>
|
<OnDesktop>
|
||||||
<FeedEntryTitle entry={props.entry} />
|
<Box c="dimmed" className={classes.feedName}>
|
||||||
</Box>
|
{props.entry.feedName}
|
||||||
<OnDesktop>
|
</Box>
|
||||||
<Text c="dimmed" className={classes.date}>
|
</OnDesktop>
|
||||||
<RelativeDate date={props.entry.date} />
|
<Box className={classes.title}>
|
||||||
</Text>
|
<FeedEntryTitle entry={props.entry} />
|
||||||
</OnDesktop>
|
</Box>
|
||||||
</Box>
|
<OnDesktop>
|
||||||
)
|
<Box c="dimmed" className={classes.date}>
|
||||||
}
|
<RelativeDate date={props.entry.date} />
|
||||||
|
</Box>
|
||||||
|
</OnDesktop>
|
||||||
|
{props.showExternalLinkIcon && <OpenExternalLink entry={props.entry} />}
|
||||||
|
</Box>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
import { Box, Flex, Space } from "@mantine/core"
|
||||||
|
import type { Entry } from "@/app/types"
|
||||||
|
import { FeedFavicon } from "@/components/content/FeedFavicon"
|
||||||
|
import { OpenExternalLink } from "@/components/content/header/OpenExternalLink"
|
||||||
|
import { Star } from "@/components/content/header/Star"
|
||||||
|
import { RelativeDate } from "@/components/RelativeDate"
|
||||||
|
import { tss } from "@/tss"
|
||||||
|
import { FeedEntryTitle } from "./FeedEntryTitle"
|
||||||
|
|
||||||
|
export interface FeedEntryHeaderProps {
|
||||||
|
entry: Entry
|
||||||
|
expanded: boolean
|
||||||
|
showStarIcon?: boolean
|
||||||
|
showExternalLinkIcon?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
const useStyles = tss
|
||||||
|
.withParams<{
|
||||||
|
read: boolean
|
||||||
|
}>()
|
||||||
|
.create(({ colorScheme, read }) => ({
|
||||||
|
main: {
|
||||||
|
fontWeight: colorScheme === "light" && !read ? "bold" : "inherit",
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
|
||||||
|
export function FeedEntryHeader(props: Readonly<FeedEntryHeaderProps>) {
|
||||||
|
const { classes } = useStyles({
|
||||||
|
read: props.entry.read,
|
||||||
|
})
|
||||||
|
return (
|
||||||
|
<Box className="cf-header">
|
||||||
|
<Flex align="flex-start" justify="space-between" className="cf-header-title">
|
||||||
|
<Flex align="flex-start" className={classes.main}>
|
||||||
|
{props.showStarIcon && (
|
||||||
|
<Box ml={-5}>
|
||||||
|
<Star entry={props.entry} />
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
<FeedEntryTitle entry={props.entry} />
|
||||||
|
</Flex>
|
||||||
|
{props.showExternalLinkIcon && <OpenExternalLink entry={props.entry} />}
|
||||||
|
</Flex>
|
||||||
|
<Flex align="center" className="cf-header-subtitle">
|
||||||
|
<FeedFavicon url={props.entry.iconUrl} />
|
||||||
|
<Space w={6} />
|
||||||
|
<Box c="dimmed">
|
||||||
|
{props.entry.feedName}
|
||||||
|
<span> · </span>
|
||||||
|
<RelativeDate date={props.entry.date} />
|
||||||
|
</Box>
|
||||||
|
</Flex>
|
||||||
|
{props.expanded && (
|
||||||
|
<Box className="cf-header-details">
|
||||||
|
{props.entry.author && <span>by {props.entry.author}</span>}
|
||||||
|
{props.entry.author && props.entry.categories && <span> · </span>}
|
||||||
|
{props.entry.categories && <span>{props.entry.categories}</span>}
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,22 +1,22 @@
|
|||||||
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 (
|
||||||
<Highlight
|
<Highlight
|
||||||
inherit
|
inherit
|
||||||
highlight={keywords ?? ""}
|
highlight={keywords ?? ""}
|
||||||
// make sure ellipsis is shown when title is too long
|
// make sure ellipsis is shown when title is too long
|
||||||
span
|
span
|
||||||
>
|
>
|
||||||
{props.entry.title}
|
{props.entry.title}
|
||||||
</Highlight>
|
</Highlight>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import { Trans } from "@lingui/react/macro"
|
||||||
|
import { ActionIcon, Anchor, Tooltip } from "@mantine/core"
|
||||||
|
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: Readonly<{
|
||||||
|
entry: Entry
|
||||||
|
}>
|
||||||
|
) {
|
||||||
|
const dispatch = useAppDispatch()
|
||||||
|
const onClick = (e: React.MouseEvent) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
dispatch(
|
||||||
|
markEntry({
|
||||||
|
entry: props.entry,
|
||||||
|
read: true,
|
||||||
|
})
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Anchor href={props.entry.url} target="_blank" rel="noreferrer" onClick={onClick}>
|
||||||
|
<Tooltip label={<Trans>Open link</Trans>} openDelay={Constants.tooltip.delay}>
|
||||||
|
<ActionIcon variant="transparent" c="dimmed">
|
||||||
|
<TbExternalLink size={18} />
|
||||||
|
</ActionIcon>
|
||||||
|
</Tooltip>
|
||||||
|
</Anchor>
|
||||||
|
)
|
||||||
|
}
|
||||||
33
commafeed-client/src/components/content/header/Star.tsx
Normal file
33
commafeed-client/src/components/content/header/Star.tsx
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
import { Trans } from "@lingui/react/macro"
|
||||||
|
import { ActionIcon, Tooltip } from "@mantine/core"
|
||||||
|
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: Readonly<{
|
||||||
|
entry: Entry
|
||||||
|
}>
|
||||||
|
) {
|
||||||
|
const dispatch = useAppDispatch()
|
||||||
|
const onClick = (e: React.MouseEvent) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
e.preventDefault()
|
||||||
|
dispatch(
|
||||||
|
starEntry({
|
||||||
|
entry: props.entry,
|
||||||
|
starred: !props.entry.starred,
|
||||||
|
})
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Tooltip label={props.entry.starred ? <Trans>Unstar</Trans> : <Trans>Star</Trans>} openDelay={Constants.tooltip.delay}>
|
||||||
|
<ActionIcon variant="transparent" onClick={onClick}>
|
||||||
|
{props.entry.starred ? <TbStarFilled size={18} /> : <TbStar size={18} />}
|
||||||
|
</ActionIcon>
|
||||||
|
</Tooltip>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,169 +1,174 @@
|
|||||||
import { t, Trans } from "@lingui/macro"
|
import { msg } from "@lingui/core/macro"
|
||||||
import { Box, Center, CloseButton, Divider, Group, Indicator, Popover, TextInput } from "@mantine/core"
|
import { useLingui } from "@lingui/react"
|
||||||
import { useForm } from "@mantine/form"
|
import { Box, Center, CloseButton, Divider, Group, Indicator, Popover, TextInput } from "@mantine/core"
|
||||||
import { reloadEntries, search, selectNextEntry, selectPreviousEntry } from "app/entries/thunks"
|
import { useForm } from "@mantine/form"
|
||||||
import { useAppDispatch, useAppSelector } from "app/store"
|
import { useEffect } from "react"
|
||||||
import { changeReadingMode, changeReadingOrder } from "app/user/thunks"
|
import {
|
||||||
import { ActionButton } from "components/ActionButton"
|
TbArrowDown,
|
||||||
import { Loader } from "components/Loader"
|
TbArrowUp,
|
||||||
import { useActionButton } from "hooks/useActionButton"
|
TbChecks,
|
||||||
import { useBrowserExtension } from "hooks/useBrowserExtension"
|
TbExternalLink,
|
||||||
import { useMobile } from "hooks/useMobile"
|
TbEye,
|
||||||
import { useEffect } from "react"
|
TbEyeOff,
|
||||||
import {
|
TbRefresh,
|
||||||
TbArrowDown,
|
TbSearch,
|
||||||
TbArrowUp,
|
TbSettings,
|
||||||
TbExternalLink,
|
TbSortAscending,
|
||||||
TbEye,
|
TbSortDescending,
|
||||||
TbEyeOff,
|
TbUser,
|
||||||
TbRefresh,
|
} from "react-icons/tb"
|
||||||
TbSearch,
|
import { markAllAsReadWithConfirmationIfRequired, reloadEntries, search, selectNextEntry, selectPreviousEntry } from "@/app/entries/thunks"
|
||||||
TbSettings,
|
import { useAppDispatch, useAppSelector } from "@/app/store"
|
||||||
TbSortAscending,
|
import { changeReadingMode, changeReadingOrder } from "@/app/user/thunks"
|
||||||
TbSortDescending,
|
import { ActionButton } from "@/components/ActionButton"
|
||||||
TbUser,
|
import { Loader } from "@/components/Loader"
|
||||||
} from "react-icons/tb"
|
import { useActionButton } from "@/hooks/useActionButton"
|
||||||
import { MarkAllAsReadButton } from "./MarkAllAsReadButton"
|
import { useBrowserExtension } from "@/hooks/useBrowserExtension"
|
||||||
import { ProfileMenu } from "./ProfileMenu"
|
import { useMobile } from "@/hooks/useMobile"
|
||||||
|
import { ProfileMenu } from "./ProfileMenu"
|
||||||
function HeaderDivider() {
|
|
||||||
return <Divider orientation="vertical" />
|
function HeaderDivider() {
|
||||||
}
|
return <Divider orientation="vertical" />
|
||||||
|
}
|
||||||
function HeaderToolbar(props: { children: React.ReactNode }) {
|
|
||||||
const { spacing } = useActionButton()
|
function HeaderToolbar(props: { children: React.ReactNode }) {
|
||||||
const mobile = useMobile("480px")
|
const { spacing } = useActionButton()
|
||||||
return mobile ? (
|
const mobile = useMobile("480px")
|
||||||
// on mobile use all available width
|
return mobile ? (
|
||||||
<Box
|
// on mobile use all available width
|
||||||
style={{
|
<Box
|
||||||
width: "100%",
|
style={{
|
||||||
display: "flex",
|
width: "100%",
|
||||||
justifyContent: "space-between",
|
display: "flex",
|
||||||
}}
|
justifyContent: "space-between",
|
||||||
>
|
}}
|
||||||
{props.children}
|
className="cf-toolbar"
|
||||||
</Box>
|
>
|
||||||
) : (
|
{props.children}
|
||||||
<Group gap={spacing}>{props.children}</Group>
|
</Box>
|
||||||
)
|
) : (
|
||||||
}
|
<Group gap={spacing} className="cf-toolbar">
|
||||||
|
{props.children}
|
||||||
const iconSize = 18
|
</Group>
|
||||||
|
)
|
||||||
export function Header() {
|
}
|
||||||
const settings = useAppSelector(state => state.user.settings)
|
|
||||||
const profile = useAppSelector(state => state.user.profile)
|
const iconSize = 18
|
||||||
const searchFromStore = useAppSelector(state => state.entries.search)
|
|
||||||
const { isBrowserExtensionPopup, openSettingsPage, openAppInNewTab } = useBrowserExtension()
|
export function Header() {
|
||||||
const dispatch = useAppDispatch()
|
const settings = useAppSelector(state => state.user.settings)
|
||||||
|
const profile = useAppSelector(state => state.user.profile)
|
||||||
const searchForm = useForm<{ search: string }>({
|
const searchFromStore = useAppSelector(state => state.entries.search)
|
||||||
validate: {
|
const { isBrowserExtensionPopup, openSettingsPage, openAppInNewTab } = useBrowserExtension()
|
||||||
search: value => (value.length > 0 && value.length < 3 ? t`Search requires at least 3 characters` : null),
|
const dispatch = useAppDispatch()
|
||||||
},
|
const { _ } = useLingui()
|
||||||
})
|
|
||||||
const { setValues } = searchForm
|
const searchForm = useForm<{ search: string }>()
|
||||||
|
const { setValues } = searchForm
|
||||||
useEffect(() => {
|
|
||||||
setValues({
|
useEffect(() => {
|
||||||
search: searchFromStore,
|
setValues({
|
||||||
})
|
search: searchFromStore,
|
||||||
}, [setValues, searchFromStore])
|
})
|
||||||
|
}, [setValues, searchFromStore])
|
||||||
if (!settings) return <Loader />
|
|
||||||
return (
|
if (!settings) return <Loader />
|
||||||
<Center>
|
return (
|
||||||
<HeaderToolbar>
|
<Center className="cf-toolbar-wrapper">
|
||||||
<ActionButton
|
<HeaderToolbar>
|
||||||
icon={<TbArrowUp size={iconSize} />}
|
<ActionButton
|
||||||
label={<Trans>Previous</Trans>}
|
icon={<TbArrowUp size={iconSize} />}
|
||||||
onClick={async () =>
|
label={msg`Previous`}
|
||||||
await dispatch(
|
onClick={async () =>
|
||||||
selectPreviousEntry({
|
await dispatch(
|
||||||
expand: true,
|
selectPreviousEntry({
|
||||||
markAsRead: true,
|
expand: true,
|
||||||
scrollToEntry: true,
|
markAsRead: true,
|
||||||
})
|
scrollToEntry: true,
|
||||||
)
|
})
|
||||||
}
|
)
|
||||||
/>
|
}
|
||||||
<ActionButton
|
/>
|
||||||
icon={<TbArrowDown size={iconSize} />}
|
<ActionButton
|
||||||
label={<Trans>Next</Trans>}
|
icon={<TbArrowDown size={iconSize} />}
|
||||||
onClick={async () =>
|
label={msg`Next`}
|
||||||
await dispatch(
|
onClick={async () =>
|
||||||
selectNextEntry({
|
await dispatch(
|
||||||
expand: true,
|
selectNextEntry({
|
||||||
markAsRead: true,
|
expand: true,
|
||||||
scrollToEntry: true,
|
markAsRead: true,
|
||||||
})
|
scrollToEntry: true,
|
||||||
)
|
})
|
||||||
}
|
)
|
||||||
/>
|
}
|
||||||
|
/>
|
||||||
<HeaderDivider />
|
|
||||||
|
<HeaderDivider />
|
||||||
<ActionButton
|
|
||||||
icon={<TbRefresh size={iconSize} />}
|
<ActionButton
|
||||||
label={<Trans>Refresh</Trans>}
|
icon={<TbRefresh size={iconSize} />}
|
||||||
onClick={async () => await dispatch(reloadEntries())}
|
label={msg`Refresh`}
|
||||||
/>
|
onClick={async () => await dispatch(reloadEntries())}
|
||||||
<MarkAllAsReadButton iconSize={iconSize} />
|
/>
|
||||||
|
<ActionButton
|
||||||
<HeaderDivider />
|
icon={<TbChecks size={iconSize} />}
|
||||||
|
label={msg`Mark all as read`}
|
||||||
<ActionButton
|
onClick={() => dispatch(markAllAsReadWithConfirmationIfRequired())}
|
||||||
icon={settings.readingMode === "all" ? <TbEye size={iconSize} /> : <TbEyeOff size={iconSize} />}
|
/>
|
||||||
label={settings.readingMode === "all" ? <Trans>All</Trans> : <Trans>Unread</Trans>}
|
|
||||||
onClick={async () => await dispatch(changeReadingMode(settings.readingMode === "all" ? "unread" : "all"))}
|
<HeaderDivider />
|
||||||
/>
|
|
||||||
<ActionButton
|
<ActionButton
|
||||||
icon={settings.readingOrder === "asc" ? <TbSortAscending size={iconSize} /> : <TbSortDescending size={iconSize} />}
|
icon={settings.readingMode === "all" ? <TbEye size={iconSize} /> : <TbEyeOff size={iconSize} />}
|
||||||
label={settings.readingOrder === "asc" ? <Trans>Asc</Trans> : <Trans>Desc</Trans>}
|
label={settings.readingMode === "all" ? msg`All` : msg`Unread`}
|
||||||
onClick={async () => await dispatch(changeReadingOrder(settings.readingOrder === "asc" ? "desc" : "asc"))}
|
onClick={async () => await dispatch(changeReadingMode(settings.readingMode === "all" ? "unread" : "all"))}
|
||||||
/>
|
/>
|
||||||
|
<ActionButton
|
||||||
<Popover>
|
icon={settings.readingOrder === "asc" ? <TbSortAscending size={iconSize} /> : <TbSortDescending size={iconSize} />}
|
||||||
<Popover.Target>
|
label={settings.readingOrder === "asc" ? msg`Asc` : msg`Desc`}
|
||||||
<Indicator disabled={!searchFromStore}>
|
onClick={async () => await dispatch(changeReadingOrder(settings.readingOrder === "asc" ? "desc" : "asc"))}
|
||||||
<ActionButton icon={<TbSearch size={iconSize} />} label={<Trans>Search</Trans>} />
|
/>
|
||||||
</Indicator>
|
|
||||||
</Popover.Target>
|
<Popover>
|
||||||
<Popover.Dropdown>
|
<Popover.Target>
|
||||||
<form onSubmit={searchForm.onSubmit(async values => await dispatch(search(values.search)))}>
|
<Indicator disabled={!searchFromStore}>
|
||||||
<TextInput
|
<ActionButton icon={<TbSearch size={iconSize} />} label={msg`Search`} />
|
||||||
placeholder={t`Search`}
|
</Indicator>
|
||||||
{...searchForm.getInputProps("search")}
|
</Popover.Target>
|
||||||
leftSection={<TbSearch size={iconSize} />}
|
<Popover.Dropdown>
|
||||||
rightSection={<CloseButton onClick={async () => await (searchFromStore && dispatch(search("")))} />}
|
<form onSubmit={searchForm.onSubmit(async values => await dispatch(search(values.search)))}>
|
||||||
autoFocus
|
<TextInput
|
||||||
/>
|
placeholder={_(msg`Search`)}
|
||||||
</form>
|
{...searchForm.getInputProps("search")}
|
||||||
</Popover.Dropdown>
|
leftSection={<TbSearch size={iconSize} />}
|
||||||
</Popover>
|
rightSection={<CloseButton onClick={async () => await (searchFromStore && dispatch(search("")))} />}
|
||||||
|
autoFocus
|
||||||
<HeaderDivider />
|
/>
|
||||||
|
</form>
|
||||||
<ProfileMenu control={<ActionButton icon={<TbUser size={iconSize} />} label={profile?.name} />} />
|
</Popover.Dropdown>
|
||||||
|
</Popover>
|
||||||
{isBrowserExtensionPopup && (
|
|
||||||
<>
|
<HeaderDivider />
|
||||||
<HeaderDivider />
|
|
||||||
|
<ProfileMenu control={<ActionButton icon={<TbUser size={iconSize} />} label={profile?.name} />} />
|
||||||
<ActionButton
|
|
||||||
icon={<TbSettings size={iconSize} />}
|
{isBrowserExtensionPopup && (
|
||||||
label={<Trans>Extension options</Trans>}
|
<>
|
||||||
onClick={() => openSettingsPage()}
|
<HeaderDivider />
|
||||||
/>
|
|
||||||
<ActionButton
|
<ActionButton
|
||||||
icon={<TbExternalLink size={iconSize} />}
|
icon={<TbSettings size={iconSize} />}
|
||||||
label={<Trans>Open CommaFeed</Trans>}
|
label={msg`Extension options`}
|
||||||
onClick={() => openAppInNewTab()}
|
onClick={() => openSettingsPage()}
|
||||||
/>
|
/>
|
||||||
</>
|
<ActionButton
|
||||||
)}
|
icon={<TbExternalLink size={iconSize} />}
|
||||||
</HeaderToolbar>
|
label={msg`Open CommaFeed`}
|
||||||
</Center>
|
onClick={() => openAppInNewTab()}
|
||||||
)
|
/>
|
||||||
}
|
</>
|
||||||
|
)}
|
||||||
|
</HeaderToolbar>
|
||||||
|
</Center>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|||||||
@@ -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,217 +1,268 @@
|
|||||||
import { Trans } from "@lingui/macro"
|
import { Trans } from "@lingui/react/macro"
|
||||||
import {
|
import {
|
||||||
Box,
|
Box,
|
||||||
Divider,
|
Divider,
|
||||||
Group,
|
Group,
|
||||||
type MantineColorScheme,
|
type MantineColorScheme,
|
||||||
Menu,
|
Menu,
|
||||||
SegmentedControl,
|
SegmentedControl,
|
||||||
type SegmentedControlItem,
|
type SegmentedControlItem,
|
||||||
useMantineColorScheme,
|
Slider,
|
||||||
} from "@mantine/core"
|
useMantineColorScheme,
|
||||||
import { showNotification } from "@mantine/notifications"
|
} from "@mantine/core"
|
||||||
import { client } from "app/client"
|
import { showNotification } from "@mantine/notifications"
|
||||||
import { redirectToAbout, redirectToAdminUsers, redirectToDonate, redirectToMetrics, redirectToSettings } from "app/redirect/thunks"
|
import dayjs from "dayjs"
|
||||||
import { useAppDispatch, useAppSelector } from "app/store"
|
import { type ReactNode, useEffect, useState } from "react"
|
||||||
import { type ViewMode } from "app/types"
|
import {
|
||||||
import { useViewMode } from "hooks/useViewMode"
|
TbChartLine,
|
||||||
import { type ReactNode, useState } from "react"
|
TbHeartFilled,
|
||||||
import {
|
TbHelp,
|
||||||
TbChartLine,
|
TbLayoutList,
|
||||||
TbHeartFilled,
|
TbList,
|
||||||
TbHelp,
|
TbListDetails,
|
||||||
TbLayoutList,
|
TbMoon,
|
||||||
TbList,
|
TbNotes,
|
||||||
TbListDetails,
|
TbPower,
|
||||||
TbMoon,
|
TbSettings,
|
||||||
TbNotes,
|
TbSun,
|
||||||
TbPower,
|
TbSunMoon,
|
||||||
TbSettings,
|
TbUsers,
|
||||||
TbSun,
|
TbWorldDownload,
|
||||||
TbSunMoon,
|
} from "react-icons/tb"
|
||||||
TbUsers,
|
import { throttle } from "throttle-debounce"
|
||||||
TbWorldDownload,
|
import { client } from "@/app/client"
|
||||||
} from "react-icons/tb"
|
import { redirectToAbout, redirectToAdminUsers, redirectToDonate, redirectToMetrics, redirectToSettings } from "@/app/redirect/thunks"
|
||||||
|
import { useAppDispatch, useAppSelector } from "@/app/store"
|
||||||
interface ProfileMenuProps {
|
import type { ViewMode } from "@/app/types"
|
||||||
control: React.ReactElement
|
import { setFontSizePercentage, setViewMode } from "@/app/user/slice"
|
||||||
}
|
import { reloadProfile } from "@/app/user/thunks"
|
||||||
|
import { useNow } from "@/hooks/useNow"
|
||||||
const ProfileMenuControlItem = ({ icon, label }: { icon: ReactNode; label: ReactNode }) => {
|
|
||||||
return (
|
interface ProfileMenuProps {
|
||||||
<Group>
|
control: React.ReactElement
|
||||||
{icon}
|
}
|
||||||
<Box ml={6}>{label}</Box>
|
|
||||||
</Group>
|
const ProfileMenuControlItem = ({ icon, label }: { icon: ReactNode; label: ReactNode }) => {
|
||||||
)
|
return (
|
||||||
}
|
<Group>
|
||||||
|
{icon}
|
||||||
const iconSize = 16
|
<Box ml={6}>{label}</Box>
|
||||||
|
</Group>
|
||||||
interface ColorSchemeControlItem extends SegmentedControlItem {
|
)
|
||||||
value: MantineColorScheme
|
}
|
||||||
}
|
|
||||||
|
const iconSize = 16
|
||||||
const colorSchemeData: ColorSchemeControlItem[] = [
|
|
||||||
{
|
interface ColorSchemeControlItem extends SegmentedControlItem {
|
||||||
value: "light",
|
value: MantineColorScheme
|
||||||
label: <ProfileMenuControlItem icon={<TbSun size={iconSize} />} label={<Trans>Light</Trans>} />,
|
}
|
||||||
},
|
|
||||||
{
|
const colorSchemeData: ColorSchemeControlItem[] = [
|
||||||
value: "dark",
|
{
|
||||||
label: <ProfileMenuControlItem icon={<TbMoon size={iconSize} />} label={<Trans>Dark</Trans>} />,
|
value: "light",
|
||||||
},
|
label: <ProfileMenuControlItem icon={<TbSun size={iconSize} />} label={<Trans>Light</Trans>} />,
|
||||||
{
|
},
|
||||||
value: "auto",
|
{
|
||||||
label: <ProfileMenuControlItem icon={<TbSunMoon size={iconSize} />} label={<Trans>System</Trans>} />,
|
value: "dark",
|
||||||
},
|
label: <ProfileMenuControlItem icon={<TbMoon size={iconSize} />} label={<Trans>Dark</Trans>} />,
|
||||||
]
|
},
|
||||||
|
{
|
||||||
interface ViewModeControlItem extends SegmentedControlItem {
|
value: "auto",
|
||||||
value: ViewMode
|
label: <ProfileMenuControlItem icon={<TbSunMoon size={iconSize} />} label={<Trans>System</Trans>} />,
|
||||||
}
|
},
|
||||||
|
]
|
||||||
const viewModeData: ViewModeControlItem[] = [
|
|
||||||
{
|
interface ViewModeControlItem extends SegmentedControlItem {
|
||||||
value: "title",
|
value: ViewMode
|
||||||
label: <ProfileMenuControlItem icon={<TbList size={iconSize} />} label={<Trans>Compact</Trans>} />,
|
}
|
||||||
},
|
|
||||||
{
|
const viewModeData: ViewModeControlItem[] = [
|
||||||
value: "cozy",
|
{
|
||||||
label: <ProfileMenuControlItem icon={<TbLayoutList size={iconSize} />} label={<Trans>Cozy</Trans>} />,
|
value: "title",
|
||||||
},
|
label: <ProfileMenuControlItem icon={<TbList size={iconSize} />} label={<Trans>Compact</Trans>} />,
|
||||||
{
|
},
|
||||||
value: "detailed",
|
{
|
||||||
label: <ProfileMenuControlItem icon={<TbListDetails size={iconSize} />} label={<Trans>Detailed</Trans>} />,
|
value: "cozy",
|
||||||
},
|
label: <ProfileMenuControlItem icon={<TbLayoutList size={iconSize} />} label={<Trans>Cozy</Trans>} />,
|
||||||
{
|
},
|
||||||
value: "expanded",
|
{
|
||||||
label: <ProfileMenuControlItem icon={<TbNotes size={iconSize} />} label={<Trans>Expanded</Trans>} />,
|
value: "detailed",
|
||||||
},
|
label: <ProfileMenuControlItem icon={<TbListDetails size={iconSize} />} label={<Trans>Detailed</Trans>} />,
|
||||||
]
|
},
|
||||||
|
{
|
||||||
export function ProfileMenu(props: ProfileMenuProps) {
|
value: "expanded",
|
||||||
const [opened, setOpened] = useState(false)
|
label: <ProfileMenuControlItem icon={<TbNotes size={iconSize} />} label={<Trans>Expanded</Trans>} />,
|
||||||
const { viewMode, setViewMode } = useViewMode()
|
},
|
||||||
const profile = useAppSelector(state => state.user.profile)
|
]
|
||||||
const admin = useAppSelector(state => state.user.profile?.admin)
|
|
||||||
const dispatch = useAppDispatch()
|
export function ProfileMenu(props: Readonly<ProfileMenuProps>) {
|
||||||
const { colorScheme, setColorScheme } = useMantineColorScheme()
|
const [opened, setOpened] = useState(false)
|
||||||
|
|
||||||
const logout = () => {
|
// close profile menu on scroll
|
||||||
window.location.href = "logout"
|
useEffect(() => {
|
||||||
}
|
const listener = throttle(100, () => setOpened(false))
|
||||||
|
window.addEventListener("scroll", listener)
|
||||||
return (
|
return () => window.removeEventListener("scroll", listener)
|
||||||
<Menu position="bottom-end" closeOnItemClick={false} opened={opened} onChange={setOpened}>
|
}, [])
|
||||||
<Menu.Target>{props.control}</Menu.Target>
|
|
||||||
<Menu.Dropdown>
|
const now = useNow()
|
||||||
{profile && <Menu.Label>{profile.name}</Menu.Label>}
|
const profile = useAppSelector(state => state.user.profile)
|
||||||
<Menu.Item
|
const admin = useAppSelector(state => state.user.profile?.admin)
|
||||||
leftSection={<TbSettings size={iconSize} />}
|
const viewMode = useAppSelector(state => state.user.localSettings.viewMode)
|
||||||
onClick={() => {
|
const forceRefreshCooldownDuration = useAppSelector(state => state.server.serverInfos?.forceRefreshCooldownDuration)
|
||||||
dispatch(redirectToSettings())
|
const fontSizePercentage = useAppSelector(state => state.user.localSettings.fontSizePercentage)
|
||||||
setOpened(false)
|
const dispatch = useAppDispatch()
|
||||||
}}
|
const { colorScheme, setColorScheme } = useMantineColorScheme()
|
||||||
>
|
|
||||||
<Trans>Settings</Trans>
|
const nextAvailableForceRefresh = profile?.lastForceRefresh
|
||||||
</Menu.Item>
|
? profile.lastForceRefresh + (forceRefreshCooldownDuration ?? 0)
|
||||||
<Menu.Item
|
: now.getTime()
|
||||||
leftSection={<TbWorldDownload size={iconSize} />}
|
const forceRefreshEnabled = nextAvailableForceRefresh <= now.getTime()
|
||||||
onClick={async () =>
|
|
||||||
await client.feed.refreshAll().then(() => {
|
const logout = () => {
|
||||||
showNotification({
|
window.location.href = "logout"
|
||||||
message: <Trans>Your feeds have been queued for refresh.</Trans>,
|
}
|
||||||
color: "green",
|
|
||||||
autoClose: 1000,
|
return (
|
||||||
})
|
<Menu position="bottom-end" closeOnItemClick={false} opened={opened} onChange={setOpened}>
|
||||||
setOpened(false)
|
<Menu.Target>{props.control}</Menu.Target>
|
||||||
})
|
<Menu.Dropdown>
|
||||||
}
|
{profile && <Menu.Label>{profile.name}</Menu.Label>}
|
||||||
>
|
<Menu.Item
|
||||||
<Trans>Fetch all my feeds now</Trans>
|
leftSection={<TbSettings size={iconSize} />}
|
||||||
</Menu.Item>
|
onClick={() => {
|
||||||
|
dispatch(redirectToSettings())
|
||||||
<Divider />
|
setOpened(false)
|
||||||
|
}}
|
||||||
<Menu.Label>
|
>
|
||||||
<Trans>Theme</Trans>
|
<Trans>Settings</Trans>
|
||||||
</Menu.Label>
|
</Menu.Item>
|
||||||
<SegmentedControl
|
<Menu.Item
|
||||||
fullWidth
|
leftSection={<TbWorldDownload size={iconSize} />}
|
||||||
orientation="vertical"
|
disabled={!forceRefreshEnabled}
|
||||||
data={colorSchemeData}
|
onClick={async () => {
|
||||||
value={colorScheme}
|
setOpened(false)
|
||||||
onChange={e => setColorScheme(e as MantineColorScheme)}
|
|
||||||
mb="xs"
|
try {
|
||||||
/>
|
await client.feed.refreshAll()
|
||||||
|
|
||||||
<Divider />
|
// reload profile to update last force refresh timestamp
|
||||||
|
await dispatch(reloadProfile())
|
||||||
<Menu.Label>
|
|
||||||
<Trans>Display</Trans>
|
showNotification({
|
||||||
</Menu.Label>
|
message: <Trans>Your feeds have been queued for refresh.</Trans>,
|
||||||
<SegmentedControl
|
color: "green",
|
||||||
fullWidth
|
autoClose: 1000,
|
||||||
orientation="vertical"
|
})
|
||||||
data={viewModeData}
|
} catch {
|
||||||
value={viewMode}
|
showNotification({
|
||||||
onChange={e => setViewMode(e as ViewMode)}
|
message: <Trans>Force fetching feeds is not yet available.</Trans>,
|
||||||
mb="xs"
|
color: "red",
|
||||||
/>
|
autoClose: 2000,
|
||||||
|
})
|
||||||
{admin && (
|
}
|
||||||
<>
|
}}
|
||||||
<Divider />
|
>
|
||||||
<Menu.Label>
|
<Trans>Fetch all my feeds now</Trans>
|
||||||
<Trans>Admin</Trans>
|
{!forceRefreshEnabled && <span> ({dayjs.duration(nextAvailableForceRefresh - now.getTime()).format("HH:mm:ss")})</span>}
|
||||||
</Menu.Label>
|
</Menu.Item>
|
||||||
<Menu.Item
|
|
||||||
leftSection={<TbUsers size={iconSize} />}
|
<Divider />
|
||||||
onClick={() => {
|
|
||||||
dispatch(redirectToAdminUsers())
|
<Menu.Label>
|
||||||
setOpened(false)
|
<Trans>Theme</Trans>
|
||||||
}}
|
</Menu.Label>
|
||||||
>
|
<SegmentedControl
|
||||||
<Trans>Manage users</Trans>
|
fullWidth
|
||||||
</Menu.Item>
|
orientation="vertical"
|
||||||
<Menu.Item
|
data={colorSchemeData}
|
||||||
leftSection={<TbChartLine size={iconSize} />}
|
value={colorScheme}
|
||||||
onClick={() => {
|
onChange={e => setColorScheme(e as MantineColorScheme)}
|
||||||
dispatch(redirectToMetrics())
|
mb="xs"
|
||||||
setOpened(false)
|
/>
|
||||||
}}
|
|
||||||
>
|
<Divider />
|
||||||
<Trans>Metrics</Trans>
|
|
||||||
</Menu.Item>
|
<Menu.Label>
|
||||||
</>
|
<Trans>Display</Trans>
|
||||||
)}
|
</Menu.Label>
|
||||||
|
<SegmentedControl
|
||||||
<Divider />
|
fullWidth
|
||||||
|
orientation="vertical"
|
||||||
<Menu.Item
|
data={viewModeData}
|
||||||
leftSection={<TbHeartFilled size={iconSize} color="red" />}
|
value={viewMode}
|
||||||
onClick={() => {
|
onChange={e => dispatch(setViewMode(e as ViewMode))}
|
||||||
dispatch(redirectToDonate())
|
mb="xs"
|
||||||
setOpened(false)
|
/>
|
||||||
}}
|
|
||||||
>
|
<Divider />
|
||||||
<Trans>Donate</Trans>
|
|
||||||
</Menu.Item>
|
<Menu.Label>
|
||||||
|
<Trans>Font size</Trans>
|
||||||
<Menu.Item
|
</Menu.Label>
|
||||||
leftSection={<TbHelp size={iconSize} />}
|
<Slider
|
||||||
onClick={() => {
|
min={50}
|
||||||
dispatch(redirectToAbout())
|
max={150}
|
||||||
setOpened(false)
|
step={5}
|
||||||
}}
|
marks={[{ value: 100 }]}
|
||||||
>
|
label={v => `${v}%`}
|
||||||
<Trans>About</Trans>
|
mb="xs"
|
||||||
</Menu.Item>
|
value={fontSizePercentage}
|
||||||
<Menu.Item leftSection={<TbPower size={iconSize} />} onClick={logout}>
|
onChange={value => dispatch(setFontSizePercentage(value))}
|
||||||
<Trans>Logout</Trans>
|
/>
|
||||||
</Menu.Item>
|
|
||||||
</Menu.Dropdown>
|
{admin && (
|
||||||
</Menu>
|
<>
|
||||||
)
|
<Divider />
|
||||||
}
|
<Menu.Label>
|
||||||
|
<Trans>Admin</Trans>
|
||||||
|
</Menu.Label>
|
||||||
|
<Menu.Item
|
||||||
|
leftSection={<TbUsers size={iconSize} />}
|
||||||
|
onClick={() => {
|
||||||
|
dispatch(redirectToAdminUsers())
|
||||||
|
setOpened(false)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Trans>Manage users</Trans>
|
||||||
|
</Menu.Item>
|
||||||
|
<Menu.Item
|
||||||
|
leftSection={<TbChartLine size={iconSize} />}
|
||||||
|
onClick={() => {
|
||||||
|
dispatch(redirectToMetrics())
|
||||||
|
setOpened(false)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Trans>Metrics</Trans>
|
||||||
|
</Menu.Item>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Divider />
|
||||||
|
|
||||||
|
<Menu.Item
|
||||||
|
leftSection={<TbHeartFilled size={iconSize} color="red" />}
|
||||||
|
onClick={() => {
|
||||||
|
dispatch(redirectToDonate())
|
||||||
|
setOpened(false)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Trans>Donate</Trans>
|
||||||
|
</Menu.Item>
|
||||||
|
|
||||||
|
<Menu.Item
|
||||||
|
leftSection={<TbHelp size={iconSize} />}
|
||||||
|
onClick={() => {
|
||||||
|
dispatch(redirectToAbout())
|
||||||
|
setOpened(false)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Trans>About</Trans>
|
||||||
|
</Menu.Item>
|
||||||
|
<Menu.Item leftSection={<TbPower size={iconSize} />} onClick={logout}>
|
||||||
|
<Trans>Logout</Trans>
|
||||||
|
</Menu.Item>
|
||||||
|
</Menu.Dropdown>
|
||||||
|
</Menu>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
import { type MetricGauge } from "app/types"
|
import { NumberFormatter } from "@mantine/core"
|
||||||
|
import type { MetricGauge } from "@/app/types"
|
||||||
interface MeterProps {
|
|
||||||
gauge: MetricGauge
|
interface GaugeProps {
|
||||||
}
|
gauge: MetricGauge
|
||||||
|
}
|
||||||
export function Gauge(props: MeterProps) {
|
|
||||||
return <span>{props.gauge.value}</span>
|
export function Gauge(props: Readonly<GaugeProps>) {
|
||||||
}
|
return <NumberFormatter value={props.gauge.value} thousandSeparator />
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,19 +1,19 @@
|
|||||||
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>
|
||||||
<Box>Last minute: {props.meter.m1_rate.toFixed(2)}</Box>
|
<Box>Last minute: {props.meter.m1_rate.toFixed(2)}</Box>
|
||||||
<Box>Last 5 minutes: {props.meter.m5_rate.toFixed(2)}</Box>
|
<Box>Last 5 minutes: {props.meter.m5_rate.toFixed(2)}</Box>
|
||||||
<Box>Last 15 minutes: {props.meter.m15_rate.toFixed(2)}</Box>
|
<Box>Last 15 minutes: {props.meter.m15_rate.toFixed(2)}</Box>
|
||||||
<Box>Units: {props.meter.units}</Box>
|
<Box>Units: {props.meter.units}</Box>
|
||||||
<Box>Total: {props.meter.count}</Box>
|
<Box>Total: {props.meter.count}</Box>
|
||||||
</Box>
|
</Box>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,22 +1,22 @@
|
|||||||
import { Accordion, Box, Group } from "@mantine/core"
|
import { Accordion, Box, Group } from "@mantine/core"
|
||||||
|
|
||||||
interface MetricAccordionItemProps {
|
interface MetricAccordionItemProps {
|
||||||
metricKey: string
|
metricKey: string
|
||||||
name: string
|
name: string
|
||||||
headerValue: number
|
headerValue: number
|
||||||
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>
|
||||||
<Group justify="space-between">
|
<Group justify="space-between">
|
||||||
<Box>{name}</Box>
|
<Box>{name}</Box>
|
||||||
<Box>{headerValue}</Box>
|
<Box>{headerValue}</Box>
|
||||||
</Group>
|
</Group>
|
||||||
</Accordion.Control>
|
</Accordion.Control>
|
||||||
<Accordion.Panel>{children}</Accordion.Panel>
|
<Accordion.Panel>{children}</Accordion.Panel>
|
||||||
</Accordion.Item>
|
</Accordion.Item>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,19 +1,19 @@
|
|||||||
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>
|
||||||
<Box>Last minute: {props.timer.m1_rate.toFixed(2)}</Box>
|
<Box>Last minute: {props.timer.m1_rate.toFixed(2)}</Box>
|
||||||
<Box>Last 5 minutes: {props.timer.m5_rate.toFixed(2)}</Box>
|
<Box>Last 5 minutes: {props.timer.m5_rate.toFixed(2)}</Box>
|
||||||
<Box>Last 15 minutes: {props.timer.m15_rate.toFixed(2)}</Box>
|
<Box>Last 15 minutes: {props.timer.m15_rate.toFixed(2)}</Box>
|
||||||
<Box>Units: {props.timer.rate_units}</Box>
|
<Box>Units: {props.timer.rate_units}</Box>
|
||||||
<Box>Total: {props.timer.count}</Box>
|
<Box>Total: {props.timer.count}</Box>
|
||||||
</Box>
|
</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 React from "react"
|
import { useMobile } from "@/hooks/useMobile"
|
||||||
|
|
||||||
export function OnDesktop(props: { children: React.ReactNode }) {
|
export function OnDesktop(
|
||||||
const mobile = useMobile()
|
props: Readonly<{
|
||||||
return <Box>{!mobile && props.children}</Box>
|
children: React.ReactNode
|
||||||
}
|
}>
|
||||||
|
) {
|
||||||
|
const mobile = useMobile()
|
||||||
|
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 React from "react"
|
import { useMobile } from "@/hooks/useMobile"
|
||||||
|
|
||||||
export function OnMobile(props: { children: React.ReactNode }) {
|
export function OnMobile(
|
||||||
const mobile = useMobile()
|
props: Readonly<{
|
||||||
return <Box>{mobile && props.children}</Box>
|
children: React.ReactNode
|
||||||
}
|
}>
|
||||||
|
) {
|
||||||
|
const mobile = useMobile()
|
||||||
|
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,121 +1,262 @@
|
|||||||
import { Trans } 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 { Constants } from "app/constants"
|
import { Trans } from "@lingui/react/macro"
|
||||||
import { useAppDispatch, useAppSelector } from "app/store"
|
import { Box, Divider, Group, NumberInput, Radio, Select, type SelectProps, SimpleGrid, Stack, Switch } from "@mantine/core"
|
||||||
import { type ScrollMode, type SharingSettings } from "app/types"
|
import type { ComboboxData } from "@mantine/core/lib/components/Combobox/Combobox.types"
|
||||||
import {
|
import type { ReactNode } from "react"
|
||||||
changeCustomContextMenu,
|
import { Constants } from "@/app/constants"
|
||||||
changeLanguage,
|
import { useAppDispatch, useAppSelector } from "@/app/store"
|
||||||
changeMarkAllAsReadConfirmation,
|
import type { IconDisplayMode, ScrollMode, SharingSettings } from "@/app/types"
|
||||||
changeMobileFooter,
|
import {
|
||||||
changeScrollMarks,
|
changeCustomContextMenu,
|
||||||
changeScrollMode,
|
changeDisableMobileSwipe,
|
||||||
changeScrollSpeed,
|
changeDisablePullToRefresh,
|
||||||
changeSharingSetting,
|
changeEntriesToKeepOnTopWhenScrolling,
|
||||||
changeShowRead,
|
changeExternalLinkIconDisplayMode,
|
||||||
} from "app/user/thunks"
|
changeInfrequentThresholdDays,
|
||||||
import { locales } from "i18n"
|
changeLanguage,
|
||||||
import { type ReactNode } from "react"
|
changeMarkAllAsReadConfirmation,
|
||||||
|
changeMarkAllAsReadNavigateToUnread,
|
||||||
export function DisplaySettings() {
|
changeMobileFooter,
|
||||||
const language = useAppSelector(state => state.user.settings?.language)
|
changePrimaryColor,
|
||||||
const scrollSpeed = useAppSelector(state => state.user.settings?.scrollSpeed)
|
changeScrollMarks,
|
||||||
const showRead = useAppSelector(state => state.user.settings?.showRead)
|
changeScrollMode,
|
||||||
const scrollMarks = useAppSelector(state => state.user.settings?.scrollMarks)
|
changeScrollSpeed,
|
||||||
const scrollMode = useAppSelector(state => state.user.settings?.scrollMode)
|
changeSharingSetting,
|
||||||
const markAllAsReadConfirmation = useAppSelector(state => state.user.settings?.markAllAsReadConfirmation)
|
changeShowRead,
|
||||||
const customContextMenu = useAppSelector(state => state.user.settings?.customContextMenu)
|
changeStarIconDisplayMode,
|
||||||
const mobileFooter = useAppSelector(state => state.user.settings?.mobileFooter)
|
changeUnreadCountFavicon,
|
||||||
const sharingSettings = useAppSelector(state => state.user.settings?.sharingSettings)
|
changeUnreadCountTitle,
|
||||||
const dispatch = useAppDispatch()
|
} from "@/app/user/thunks"
|
||||||
|
import { locales } from "@/i18n"
|
||||||
const scrollModeOptions: Record<ScrollMode, ReactNode> = {
|
|
||||||
always: <Trans>Always</Trans>,
|
export function DisplaySettings() {
|
||||||
never: <Trans>Never</Trans>,
|
const language = useAppSelector(state => state.user.settings?.language)
|
||||||
if_needed: <Trans>If the entry doesn't entirely fit on the screen</Trans>,
|
const scrollSpeed = useAppSelector(state => state.user.settings?.scrollSpeed)
|
||||||
}
|
const showRead = useAppSelector(state => state.user.settings?.showRead)
|
||||||
|
const scrollMarks = useAppSelector(state => state.user.settings?.scrollMarks)
|
||||||
return (
|
const scrollMode = useAppSelector(state => state.user.settings?.scrollMode)
|
||||||
<Stack>
|
const entriesToKeepOnTop = useAppSelector(state => state.user.settings?.entriesToKeepOnTopWhenScrolling)
|
||||||
<Select
|
const starIconDisplayMode = useAppSelector(state => state.user.settings?.starIconDisplayMode)
|
||||||
description={<Trans>Language</Trans>}
|
const externalLinkIconDisplayMode = useAppSelector(state => state.user.settings?.externalLinkIconDisplayMode)
|
||||||
value={language}
|
const markAllAsReadConfirmation = useAppSelector(state => state.user.settings?.markAllAsReadConfirmation)
|
||||||
data={locales.map(l => ({
|
const markAllAsReadNavigateToNextUnread = useAppSelector(state => state.user.settings?.markAllAsReadNavigateToNextUnread)
|
||||||
value: l.key,
|
const customContextMenu = useAppSelector(state => state.user.settings?.customContextMenu)
|
||||||
label: l.label,
|
const mobileFooter = useAppSelector(state => state.user.settings?.mobileFooter)
|
||||||
}))}
|
const unreadCountTitle = useAppSelector(state => state.user.settings?.unreadCountTitle)
|
||||||
onChange={async s => await (s && dispatch(changeLanguage(s)))}
|
const unreadCountFavicon = useAppSelector(state => state.user.settings?.unreadCountFavicon)
|
||||||
/>
|
const disablePullToRefresh = useAppSelector(state => state.user.settings?.disablePullToRefresh)
|
||||||
|
const disableMobileSwipe = useAppSelector(state => state.user.settings?.disableMobileSwipe)
|
||||||
<Switch
|
const infrequentThresholdDays = useAppSelector(state => state.user.settings?.infrequentThresholdDays)
|
||||||
label={<Trans>Show feeds and categories with no unread entries</Trans>}
|
const sharingSettings = useAppSelector(state => state.user.settings?.sharingSettings)
|
||||||
checked={showRead}
|
const primaryColor = useAppSelector(state => state.user.settings?.primaryColor) || Constants.theme.defaultPrimaryColor
|
||||||
onChange={async e => await dispatch(changeShowRead(e.currentTarget.checked))}
|
const { _ } = useLingui()
|
||||||
/>
|
const dispatch = useAppDispatch()
|
||||||
|
|
||||||
<Switch
|
const scrollModeOptions: Record<ScrollMode, ReactNode> = {
|
||||||
label={<Trans>Show confirmation when marking all entries as read</Trans>}
|
always: <Trans>Always</Trans>,
|
||||||
checked={markAllAsReadConfirmation}
|
never: <Trans>Never</Trans>,
|
||||||
onChange={async e => await dispatch(changeMarkAllAsReadConfirmation(e.currentTarget.checked))}
|
if_needed: <Trans>If the entry doesn't entirely fit on the screen</Trans>,
|
||||||
/>
|
}
|
||||||
|
|
||||||
<Switch
|
const displayModeData: ComboboxData = [
|
||||||
label={<Trans>Show CommaFeed's own context menu on right click</Trans>}
|
{
|
||||||
checked={customContextMenu}
|
value: "always",
|
||||||
onChange={async e => await dispatch(changeCustomContextMenu(e.currentTarget.checked))}
|
label: _(msg`Always`),
|
||||||
/>
|
},
|
||||||
|
{
|
||||||
<Switch
|
value: "on_desktop",
|
||||||
label={<Trans>On mobile, show action buttons at the bottom of the screen</Trans>}
|
label: _(msg`On desktop`),
|
||||||
checked={mobileFooter}
|
},
|
||||||
onChange={async e => await dispatch(changeMobileFooter(e.currentTarget.checked))}
|
{
|
||||||
/>
|
value: "on_mobile",
|
||||||
|
label: _(msg`On mobile`),
|
||||||
<Divider label={<Trans>Scrolling</Trans>} labelPosition="center" />
|
},
|
||||||
|
{
|
||||||
<Radio.Group
|
value: "never",
|
||||||
label={<Trans>Scroll selected entry to the top of the page</Trans>}
|
label: _(msg`Never`),
|
||||||
value={scrollMode}
|
},
|
||||||
onChange={async value => await dispatch(changeScrollMode(value as ScrollMode))}
|
]
|
||||||
>
|
|
||||||
<Group mt="xs">
|
const colorData: ComboboxData = [
|
||||||
{Object.entries(scrollModeOptions).map(e => (
|
{ value: "dark", label: _(msg`Dark`) },
|
||||||
<Radio key={e[0]} value={e[0]} label={e[1]} />
|
{ value: "gray", label: _(msg`Gray`) },
|
||||||
))}
|
{ value: "red", label: _(msg`Red`) },
|
||||||
</Group>
|
{ value: "pink", label: _(msg`Pink`) },
|
||||||
</Radio.Group>
|
{ value: "grape", label: _(msg`Grape`) },
|
||||||
|
{ value: "violet", label: _(msg`Violet`) },
|
||||||
<Switch
|
{ value: "indigo", label: _(msg`Indigo`) },
|
||||||
label={<Trans>Scroll smoothly when navigating between entries</Trans>}
|
{ value: "blue", label: _(msg`Blue`) },
|
||||||
checked={scrollSpeed ? scrollSpeed > 0 : false}
|
{ value: "cyan", label: _(msg`Cyan`) },
|
||||||
onChange={async e => await dispatch(changeScrollSpeed(e.currentTarget.checked))}
|
{ value: "green", label: _(msg`Green`) },
|
||||||
/>
|
{ value: "lime", label: _(msg`Lime`) },
|
||||||
|
{ value: "yellow", label: _(msg`Yellow`) },
|
||||||
<Switch
|
{ value: "orange", label: _(msg`Orange`) },
|
||||||
label={<Trans>In expanded view, scrolling through entries mark them as read</Trans>}
|
{ value: "teal", label: _(msg`Teal`) },
|
||||||
checked={scrollMarks}
|
].sort((a, b) => a.label.localeCompare(b.label))
|
||||||
onChange={async e => await dispatch(changeScrollMarks(e.currentTarget.checked))}
|
const colorRenderer: SelectProps["renderOption"] = ({ option }) => (
|
||||||
/>
|
<Group>
|
||||||
|
<Box h={18} w={18} bg={option.value} />
|
||||||
<Divider label={<Trans>Sharing sites</Trans>} labelPosition="center" />
|
<Box>{option.label}</Box>
|
||||||
|
</Group>
|
||||||
<SimpleGrid cols={2}>
|
)
|
||||||
{(Object.keys(Constants.sharing) as (keyof SharingSettings)[]).map(site => (
|
|
||||||
<Switch
|
return (
|
||||||
key={site}
|
<Stack>
|
||||||
label={Constants.sharing[site].label}
|
<Divider label={<Trans>Display</Trans>} labelPosition="center" />
|
||||||
checked={sharingSettings?.[site]}
|
|
||||||
onChange={async e =>
|
<Select
|
||||||
await dispatch(
|
label={<Trans>Language</Trans>}
|
||||||
changeSharingSetting({
|
value={language}
|
||||||
site,
|
data={locales.map(l => ({
|
||||||
value: e.currentTarget.checked,
|
value: l.key,
|
||||||
})
|
label: l.label,
|
||||||
)
|
}))}
|
||||||
}
|
onChange={async s => await (s && dispatch(changeLanguage(s)))}
|
||||||
/>
|
/>
|
||||||
))}
|
|
||||||
</SimpleGrid>
|
<Select
|
||||||
</Stack>
|
label={<Trans>Primary color</Trans>}
|
||||||
)
|
data={colorData}
|
||||||
}
|
value={primaryColor}
|
||||||
|
onChange={async value => value && (await dispatch(changePrimaryColor(value)))}
|
||||||
|
renderOption={colorRenderer}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Switch
|
||||||
|
label={<Trans>Show feeds and categories with no unread entries</Trans>}
|
||||||
|
checked={showRead}
|
||||||
|
onChange={async e => await dispatch(changeShowRead(e.currentTarget.checked))}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Switch
|
||||||
|
label={<Trans>Show confirmation when marking all entries as read</Trans>}
|
||||||
|
checked={markAllAsReadConfirmation}
|
||||||
|
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
|
||||||
|
label={<Trans>On mobile, show action buttons at the bottom of the screen</Trans>}
|
||||||
|
checked={mobileFooter}
|
||||||
|
onChange={async e => await dispatch(changeMobileFooter(e.currentTarget.checked))}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Switch
|
||||||
|
label={<Trans>On mobile, disable swipe gesture to open the menu</Trans>}
|
||||||
|
checked={disableMobileSwipe}
|
||||||
|
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" />
|
||||||
|
|
||||||
|
<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
|
||||||
|
label={<Trans>Scroll selected entry to the top of the page</Trans>}
|
||||||
|
value={scrollMode}
|
||||||
|
onChange={async value => await dispatch(changeScrollMode(value as ScrollMode))}
|
||||||
|
>
|
||||||
|
<Group mt="xs">
|
||||||
|
{Object.entries(scrollModeOptions).map(e => (
|
||||||
|
<Radio key={e[0]} value={e[0]} label={e[1]} />
|
||||||
|
))}
|
||||||
|
</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
|
||||||
|
label={<Trans>Scroll smoothly when navigating between entries</Trans>}
|
||||||
|
checked={scrollSpeed ? scrollSpeed > 0 : false}
|
||||||
|
onChange={async e => await dispatch(changeScrollSpeed(e.currentTarget.checked))}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Switch
|
||||||
|
label={<Trans>In expanded view, scrolling through entries mark them as read</Trans>}
|
||||||
|
checked={scrollMarks}
|
||||||
|
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" />
|
||||||
|
|
||||||
|
<SimpleGrid cols={2}>
|
||||||
|
{(Object.keys(Constants.sharing) as Array<keyof SharingSettings>).map(site => (
|
||||||
|
<Switch
|
||||||
|
key={site}
|
||||||
|
label={Constants.sharing[site].label}
|
||||||
|
checked={sharingSettings?.[site]}
|
||||||
|
onChange={async e =>
|
||||||
|
await dispatch(
|
||||||
|
changeSharingSetting({
|
||||||
|
site,
|
||||||
|
value: e.currentTarget.checked,
|
||||||
|
})
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</SimpleGrid>
|
||||||
|
</Stack>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user