mirror of
https://github.com/Athou/commafeed.git
synced 2026-03-21 21:37:29 +00:00
Compare commits
1315 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 |
3
.gitattributes
vendored
Normal file
3
.gitattributes
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
* text eol=lf
|
||||
*.cmd text eol=crlf
|
||||
*.png binary
|
||||
37
.github/stale.yml
vendored
37
.github/stale.yml
vendored
@@ -1,19 +1,20 @@
|
||||
# Number of days of inactivity before an issue becomes stale
|
||||
daysUntilStale: 60
|
||||
# Number of days of inactivity before a stale issue is closed
|
||||
daysUntilClose: 7
|
||||
# Issues with these labels will never be considered stale
|
||||
exemptLabels:
|
||||
- pinned
|
||||
- security
|
||||
- enhancement
|
||||
- bug
|
||||
# Label to use when marking an issue as stale
|
||||
staleLabel: wontfix
|
||||
# Comment to post when marking an issue as stale. Set to `false` to disable
|
||||
markComment: >
|
||||
This issue has been automatically marked as stale because it has not had
|
||||
recent activity. It will be closed if no further activity occurs. Thank you
|
||||
for your contributions.
|
||||
# Comment to post when closing a stale issue. Set to `false` to disable
|
||||
# Number of days of inactivity before an issue becomes stale
|
||||
daysUntilStale: 60
|
||||
# Number of days of inactivity before a stale issue is closed
|
||||
daysUntilClose: 7
|
||||
# Issues with these labels will never be considered stale
|
||||
exemptLabels:
|
||||
- pinned
|
||||
- security
|
||||
- enhancement
|
||||
- feature-request
|
||||
- bug
|
||||
# Label to use when marking an issue as stale
|
||||
staleLabel: wontfix
|
||||
# Comment to post when marking an issue as stale. Set to `false` to disable
|
||||
markComment: >
|
||||
This issue has been automatically marked as stale because it has not had
|
||||
recent activity. It will be closed if no further activity occurs. Thank you
|
||||
for your contributions.
|
||||
# Comment to post when closing a stale issue. Set to `false` to disable
|
||||
closeComment: false
|
||||
505
.github/workflows/ci.yml
vendored
505
.github/workflows/ci.yml
vendored
@@ -1,227 +1,278 @@
|
||||
name: ci
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
on:
|
||||
push:
|
||||
pull_request:
|
||||
|
||||
env:
|
||||
JAVA_VERSION: 21
|
||||
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: Configure git to checkout as-is
|
||||
run: git config --global core.autocrlf false
|
||||
|
||||
- name: Checkout
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
# Setup
|
||||
- name: Set up GraalVM
|
||||
uses: graalvm/setup-graalvm@b0cb26a8da53cb3e97cdc0c827d8e3071240e730 # 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' }}
|
||||
|
||||
# Upload artifacts
|
||||
- name: Upload cross-platform app
|
||||
uses: actions/upload-artifact@65c4c4a1ddee5b72f698fdd19549f0f0fb45cf08 # v4
|
||||
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@65c4c4a1ddee5b72f698fdd19549f0f0fb45cf08 # v4
|
||||
with:
|
||||
name: commafeed-${{ matrix.database }}-${{ runner.os }}-${{ runner.arch }}
|
||||
path: commafeed-server/target/commafeed-*-runner*
|
||||
|
||||
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@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
# Setup
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@4574d27a4764455b42196d70a065bc6853246a25 # v3
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@f7ce87c1d6bead3e36075b2ce75da1f6cc28aaca # v3
|
||||
|
||||
- name: Install required packages
|
||||
run: sudo apt-get install -y rename unzip
|
||||
|
||||
# Prepare artifacts
|
||||
- name: Download artifacts
|
||||
uses: actions/download-artifact@fa0a91b85d4f404e444e00e005971372dc801d16 # v4
|
||||
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@9780b0c442fbb1117ed29e0efdff1e18412f7567 # v3
|
||||
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@ca877d9245402d1537745e0e356eab47c3520991 # v6
|
||||
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@ca877d9245402d1537745e0e356eab47c3520991 # v6
|
||||
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@ca877d9245402d1537745e0e356eab47c3520991 # v6
|
||||
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@ca877d9245402d1537745e0e356eab47c3520991 # v6
|
||||
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@ca877d9245402d1537745e0e356eab47c3520991 # v6
|
||||
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@ca877d9245402d1537745e0e356eab47c3520991 # v6
|
||||
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@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Download artifacts
|
||||
uses: actions/download-artifact@fa0a91b85d4f404e444e00e005971372dc801d16 # v4
|
||||
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@cdcc88a9acf3ca41c16c37bb7d21b9ad48560d87 # v1
|
||||
with:
|
||||
name: CommaFeed ${{ github.ref_name }}
|
||||
body: ${{ steps.changelog_reader.outputs.changes }}
|
||||
artifacts: ./artifacts/*
|
||||
|
||||
- name: Update Docker Hub Description
|
||||
uses: peter-evans/dockerhub-description@e98e4d1628a5f3be2be7c231e50981aee98723ae # v4
|
||||
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
|
||||
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
|
||||
|
||||
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
|
||||
# or more contributor license agreements. See the NOTICE file
|
||||
# distributed with this work for additional information
|
||||
# regarding copyright ownership. The ASF licenses this file
|
||||
# to you under the Apache License, Version 2.0 (the
|
||||
# "License"); you may not use this file except in compliance
|
||||
# with the License. You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing,
|
||||
# software distributed under the License is distributed on an
|
||||
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
# KIND, either express or implied. See the License for the
|
||||
# specific language governing permissions and limitations
|
||||
# under the License.
|
||||
distributionType=only-script
|
||||
distributionUrl=https://repo.maven.apache.org/maven2/org/apache/maven/apache-maven/3.9.9/apache-maven-3.9.9-bin.zip
|
||||
wrapperVersion=3.3.4
|
||||
distributionType=only-script
|
||||
distributionUrl=https://repo.maven.apache.org/maven2/org/apache/maven/apache-maven/3.9.14/apache-maven-3.9.14-bin.zip
|
||||
|
||||
1013
CHANGELOG.md
1013
CHANGELOG.md
File diff suppressed because it is too large
Load Diff
60
LICENSE
60
LICENSE
@@ -1,31 +1,31 @@
|
||||
Apache License, Version 2.0 Apache License Version 2.0, January 2004 http://www.apache.org/licenses/
|
||||
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
||||
1. Definitions.
|
||||
"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.
|
||||
"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.
|
||||
"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.
|
||||
"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.
|
||||
"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.
|
||||
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.
|
||||
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.
|
||||
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 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.
|
||||
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.
|
||||
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.
|
||||
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.
|
||||
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.
|
||||
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.
|
||||
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
|
||||
1. Definitions.
|
||||
"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.
|
||||
"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.
|
||||
"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.
|
||||
"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.
|
||||
"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.
|
||||
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.
|
||||
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.
|
||||
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 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.
|
||||
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.
|
||||
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.
|
||||
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.
|
||||
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.
|
||||
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
|
||||
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
|
||||
```
|
||||
28
README.md
28
README.md
@@ -17,6 +17,8 @@ Google Reader inspired self-hosted RSS reader, based on Quarkus and React/TypeSc
|
||||
- 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)
|
||||
- Compiles to native code for blazing fast startup and low memory usage
|
||||
- Supports 4 databases
|
||||
@@ -25,11 +27,18 @@ Google Reader inspired self-hosted RSS reader, based on Quarkus and React/TypeSc
|
||||
- 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 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
|
||||
|
||||
@@ -48,17 +57,17 @@ system and database of choice.
|
||||
|
||||
There are two types of packages:
|
||||
|
||||
- The `linux-x86_64` and `windows-x86_64` packages are compiled natively and contain an executable that can be run
|
||||
- The `linux-x86_64`, `linux-aarch_64` and `windows-x86_64` packages are compiled natively and contain an executable that can be run
|
||||
directly.
|
||||
- The `jvm` package is a zip file containing all `.jar` files required to run the application. This package works on all
|
||||
platforms and is started with `java -jar quarkus-run.jar`.
|
||||
platforms but requires a JRE and is started with `java -jar quarkus-run.jar`.
|
||||
|
||||
If available for your operating system, the native package is recommended because it has a faster startup time and lower
|
||||
memory usage.
|
||||
|
||||
### Build from sources
|
||||
|
||||
./mvnw clean package [-P<database>] [-Pnative] [-DskipTests]
|
||||
./mvnw clean package [-P<database> [-Pnative]] [-DskipTests]
|
||||
|
||||
- `<database>` can be one of `h2`, `postgresql`, `mysql` or `mariadb`. The default is `h2`.
|
||||
- `-Pnative` compiles the application to native code. This requires GraalVM to be installed (`GRAALVM_HOME` environment
|
||||
@@ -73,6 +82,10 @@ When the build is complete:
|
||||
- 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
|
||||
@@ -98,9 +111,9 @@ There are multiple ways to configure CommaFeed:
|
||||
- Environment variables (keys in UPPER_CASE)
|
||||
- a `.env` file in the working directory (keys in UPPER_CASE)
|
||||
|
||||
The properties file is recommended because CommaFeed will be able to warn about invalid properties and typos.
|
||||
When in doubt, the properties file is recommended because CommaFeed will be able to warn about invalid properties and typos.
|
||||
|
||||
All [CommaFeed settings](commafeed-server/doc/commafeed.md) are optional and have sensible default values.
|
||||
All [CommaFeed settings](https://athou.github.io/commafeed/documentation) are optional and have sensible default values.
|
||||
|
||||
When logging in, credentials are stored in an encrypted cookie. The encryption key is randomly generated at startup,
|
||||
meaning that you will have to log back in after each restart of the application. To prevent this, you can set the
|
||||
@@ -108,7 +121,6 @@ meaning that you will have to log back in after each restart of the application.
|
||||
All other Quarkus settings can be found [here](https://quarkus.io/guides/all-config).
|
||||
|
||||
When started, the server will listen on http://localhost:8082.
|
||||
The default user is `admin` and the default password is `admin`.
|
||||
|
||||
### Updates
|
||||
|
||||
|
||||
@@ -2,5 +2,4 @@
|
||||
|
||||
## 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.
|
||||
Thanks !
|
||||
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
|
||||
|
||||
3
commafeed-client/.gitignore
vendored
3
commafeed-client/.gitignore
vendored
@@ -23,9 +23,6 @@ dist-ssr
|
||||
*.sln
|
||||
*.sw?
|
||||
|
||||
# rollup-plugin-visualizer
|
||||
/stats.html
|
||||
|
||||
# vite
|
||||
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,5 +1,5 @@
|
||||
{
|
||||
"$schema": "https://biomejs.dev/schemas/1.9.4/schema.json",
|
||||
"$schema": "https://biomejs.dev/schemas/2.4.7/schema.json",
|
||||
"formatter": {
|
||||
"indentStyle": "space",
|
||||
"indentWidth": 4,
|
||||
@@ -14,6 +14,6 @@
|
||||
}
|
||||
},
|
||||
"files": {
|
||||
"ignore": ["dist", "node_modules", "target", "target-ide"]
|
||||
"includes": ["**", "!**/dist", "!**/node_modules", "!**/target", "!**/target-ide"]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,12 +2,11 @@
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="favicon.svg" />
|
||||
<link rel="icon" type="image/x-icon" href="favicon.ico" />
|
||||
<link rel="manifest" href="manifest.json" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
|
||||
|
||||
<link rel="stylesheet" type="text/css" href="custom_css.css" />
|
||||
<script type="text/javascript" src="custom_js.js"></script>
|
||||
<meta name="referrer" content="no-referrer" />
|
||||
|
||||
<title>CommaFeed</title>
|
||||
</head>
|
||||
|
||||
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"],
|
||||
}
|
||||
7162
commafeed-client/package-lock.json
generated
7162
commafeed-client/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -4,79 +4,83 @@
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite --host",
|
||||
"dev:typescript": "tsc --watch",
|
||||
"dev": "vite",
|
||||
"dev:host": "vite --host",
|
||||
"build": "tsc && vite build",
|
||||
"preview": "vite preview",
|
||||
"test": "vitest",
|
||||
"test:ci": "vitest run",
|
||||
"lint": "biome check ./src",
|
||||
"lint:fix": "biome check --write ./src",
|
||||
"i18n:extract": "lingui extract --clean"
|
||||
"lint": "biome check",
|
||||
"lint:fix": "biome check --write",
|
||||
"i18n:extract": "lingui extract --clean",
|
||||
"prepare": "cd .. && husky ./commafeed-client/.husky"
|
||||
},
|
||||
"dependencies": {
|
||||
"@emotion/react": "^11.14.0",
|
||||
"@fontsource/open-sans": "^5.1.1",
|
||||
"@mantine/core": "^7.16.3",
|
||||
"@mantine/form": "^7.16.3",
|
||||
"@mantine/hooks": "^7.16.3",
|
||||
"@mantine/modals": "^7.16.3",
|
||||
"@mantine/notifications": "^7.16.3",
|
||||
"@mantine/spotlight": "^7.16.3",
|
||||
"@lingui/core": "^5.2.0",
|
||||
"@lingui/react": "^5.2.0",
|
||||
"@fontsource/open-sans": "^5.2.7",
|
||||
"@lingui/core": "^5.9.3",
|
||||
"@lingui/react": "^5.9.3",
|
||||
"@mantine/core": "^8.3.16",
|
||||
"@mantine/form": "^8.3.16",
|
||||
"@mantine/hooks": "^8.3.16",
|
||||
"@mantine/modals": "^8.3.16",
|
||||
"@mantine/notifications": "^8.3.16",
|
||||
"@mantine/spotlight": "^8.3.16",
|
||||
"@monaco-editor/react": "^4.7.0",
|
||||
"@reduxjs/toolkit": "^2.5.1",
|
||||
"axios": "^1.7.9",
|
||||
"dayjs": "^1.11.13",
|
||||
"@react-querybuilder/mantine": "^8.14.0",
|
||||
"@reduxjs/toolkit": "^2.11.2",
|
||||
"@rolldown/plugin-babel": "^0.2.2",
|
||||
"axios": "^1.13.6",
|
||||
"dayjs": "^1.11.20",
|
||||
"escape-string-regexp": "^5.0.0",
|
||||
"interweave": "^13.1.1",
|
||||
"monaco-editor": "^0.52.2",
|
||||
"monaco-editor": "^0.55.1",
|
||||
"mousetrap": "^1.6.5",
|
||||
"react": "^19.0.0",
|
||||
"react": "^19.2.4",
|
||||
"react-async-hook": "^4.0.0",
|
||||
"react-contexify": "^6.0.0",
|
||||
"react-device-detect": "^2.2.3",
|
||||
"react-dom": "^19.0.0",
|
||||
"react-draggable": "^4.4.6",
|
||||
"react-ga4": "^2.1.0",
|
||||
"react-icons": "^5.4.0",
|
||||
"react-dom": "^19.2.4",
|
||||
"react-draggable": "^4.5.0",
|
||||
"react-icons": "^5.6.0",
|
||||
"react-infinite-scroller": "^1.2.6",
|
||||
"react-querybuilder": "^8.14.0",
|
||||
"react-redux": "^9.2.0",
|
||||
"react-router-dom": "^7.1.5",
|
||||
"react-router-dom": "^7.13.1",
|
||||
"react-swipeable": "^7.0.2",
|
||||
"redoc": "^2.4.0",
|
||||
"style-to-object": "^1.0.8",
|
||||
"style-to-object": "^1.0.14",
|
||||
"throttle-debounce": "^5.0.2",
|
||||
"tinycon": "^0.6.8",
|
||||
"tss-react": "^4.9.15",
|
||||
"tss-react": "^4.9.20",
|
||||
"websocket-heartbeat-js": "^1.1.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@biomejs/biome": "^1.9.4",
|
||||
"@lingui/babel-plugin-lingui-macro": "^5.2.0",
|
||||
"@lingui/cli": "^5.2.0",
|
||||
"@lingui/vite-plugin": "^5.2.0",
|
||||
"@biomejs/biome": "^2.4.7",
|
||||
"@lingui/babel-plugin-lingui-macro": "^5.9.3",
|
||||
"@lingui/cli": "^5.9.3",
|
||||
"@lingui/vite-plugin": "^5.9.3",
|
||||
"@testing-library/jest-dom": "^6.9.1",
|
||||
"@testing-library/react": "^16.3.2",
|
||||
"@testing-library/user-event": "^14.6.1",
|
||||
"@types/mousetrap": "^1.6.15",
|
||||
"@types/react": "^19.0.8",
|
||||
"@types/react-dom": "^19.0.3",
|
||||
"@types/react": "^19.2.14",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@types/react-infinite-scroller": "^1.2.5",
|
||||
"@types/throttle-debounce": "^5.0.2",
|
||||
"@types/tinycon": "^0.6.7",
|
||||
"@vitejs/plugin-react": "^4.3.4",
|
||||
"babel-plugin-macros": "^3.1.0",
|
||||
"jsdom": "^26.0.0",
|
||||
"rollup-plugin-visualizer": "^5.14.0",
|
||||
"typescript": "^5.7.3",
|
||||
"vite": "^6.1.0",
|
||||
"vite-plugin-checker": "^0.8.0",
|
||||
"vite-tsconfig-paths": "^5.1.4",
|
||||
"vitest": "^3.0.5",
|
||||
"vitest-mock-extended": "^2.0.2"
|
||||
"@vitejs/plugin-react": "^6.0.1",
|
||||
"babel-plugin-react-compiler": "1.0.0",
|
||||
"husky": "^9.1.7",
|
||||
"jsdom": "^29.0.0",
|
||||
"lint-staged": "^16.4.0",
|
||||
"typescript": "^5.9.3",
|
||||
"vite": "^8.0.0",
|
||||
"vite-plugin-checker": "^0.12.0",
|
||||
"vitest": "^4.1.0",
|
||||
"yaml": "^2.8.2"
|
||||
},
|
||||
"overrides": {
|
||||
"react-infinite-scroller": {
|
||||
"react": "^19.0.0"
|
||||
"react": "^19.2.4"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,96 +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">
|
||||
|
||||
<modelVersion>4.0.0</modelVersion>
|
||||
|
||||
<parent>
|
||||
<groupId>com.commafeed</groupId>
|
||||
<artifactId>commafeed</artifactId>
|
||||
<version>5.6.0</version>
|
||||
</parent>
|
||||
<artifactId>commafeed-client</artifactId>
|
||||
<name>CommaFeed Client</name>
|
||||
|
||||
<properties>
|
||||
<!-- renovate: datasource=node-version depName=node -->
|
||||
<node.version>v22.14.0</node.version>
|
||||
<!-- renovate: datasource=npm depName=npm -->
|
||||
<npm.version>11.1.0</npm.version>
|
||||
</properties>
|
||||
|
||||
<build>
|
||||
<plugins>
|
||||
<plugin>
|
||||
<groupId>com.github.eirslett</groupId>
|
||||
<artifactId>frontend-maven-plugin</artifactId>
|
||||
<version>1.15.1</version>
|
||||
<?m2e ignore?>
|
||||
<executions>
|
||||
<execution>
|
||||
<id>install node and npm</id>
|
||||
<goals>
|
||||
<goal>install-node-and-npm</goal>
|
||||
</goals>
|
||||
<phase>compile</phase>
|
||||
<configuration>
|
||||
<nodeVersion>${node.version}</nodeVersion>
|
||||
<npmVersion>${npm.version}</npmVersion>
|
||||
</configuration>
|
||||
</execution>
|
||||
<execution>
|
||||
<id>npm install</id>
|
||||
<goals>
|
||||
<goal>npm</goal>
|
||||
</goals>
|
||||
<phase>compile</phase>
|
||||
<configuration>
|
||||
<arguments>ci</arguments>
|
||||
</configuration>
|
||||
</execution>
|
||||
<execution>
|
||||
<id>npm run test</id>
|
||||
<goals>
|
||||
<goal>npm</goal>
|
||||
</goals>
|
||||
<phase>compile</phase>
|
||||
<configuration>
|
||||
<arguments>run test:ci</arguments>
|
||||
</configuration>
|
||||
</execution>
|
||||
<execution>
|
||||
<id>npm run build</id>
|
||||
<goals>
|
||||
<goal>npm</goal>
|
||||
</goals>
|
||||
<phase>compile</phase>
|
||||
<configuration>
|
||||
<arguments>run build</arguments>
|
||||
</configuration>
|
||||
</execution>
|
||||
</executions>
|
||||
</plugin>
|
||||
<plugin>
|
||||
<artifactId>maven-resources-plugin</artifactId>
|
||||
<version>3.3.1</version>
|
||||
<executions>
|
||||
<execution>
|
||||
<id>copy web interface to resources</id>
|
||||
<phase>prepare-package</phase>
|
||||
<goals>
|
||||
<goal>copy-resources</goal>
|
||||
</goals>
|
||||
<configuration>
|
||||
<outputDirectory>${project.build.directory}/classes/META-INF/resources</outputDirectory>
|
||||
<resources>
|
||||
<resource>
|
||||
<directory>dist</directory>
|
||||
<filtering>false</filtering>
|
||||
</resource>
|
||||
</resources>
|
||||
</configuration>
|
||||
</execution>
|
||||
</executions>
|
||||
</plugin>
|
||||
</plugins>
|
||||
</build>
|
||||
<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>
|
||||
|
||||
<parent>
|
||||
<groupId>com.commafeed</groupId>
|
||||
<artifactId>commafeed</artifactId>
|
||||
<version>7.0.0</version>
|
||||
</parent>
|
||||
<artifactId>commafeed-client</artifactId>
|
||||
<name>CommaFeed Client</name>
|
||||
|
||||
<properties>
|
||||
<!-- renovate: datasource=node-version depName=node -->
|
||||
<node.version>v24.14.0</node.version>
|
||||
<!-- renovate: datasource=npm depName=npm -->
|
||||
<npm.version>11.11.1</npm.version>
|
||||
</properties>
|
||||
|
||||
<build>
|
||||
<plugins>
|
||||
<plugin>
|
||||
<groupId>com.github.eirslett</groupId>
|
||||
<artifactId>frontend-maven-plugin</artifactId>
|
||||
<version>2.0.0</version>
|
||||
<?m2e ignore?>
|
||||
<executions>
|
||||
<execution>
|
||||
<id>install node and npm</id>
|
||||
<goals>
|
||||
<goal>install-node-and-npm</goal>
|
||||
</goals>
|
||||
<phase>compile</phase>
|
||||
<configuration>
|
||||
<nodeVersion>${node.version}</nodeVersion>
|
||||
<npmVersion>${npm.version}</npmVersion>
|
||||
</configuration>
|
||||
</execution>
|
||||
<execution>
|
||||
<id>npm install</id>
|
||||
<goals>
|
||||
<goal>npm</goal>
|
||||
</goals>
|
||||
<phase>compile</phase>
|
||||
<configuration>
|
||||
<arguments>ci</arguments>
|
||||
</configuration>
|
||||
</execution>
|
||||
<execution>
|
||||
<id>npm run test</id>
|
||||
<goals>
|
||||
<goal>npm</goal>
|
||||
</goals>
|
||||
<phase>compile</phase>
|
||||
<configuration>
|
||||
<arguments>run test:ci</arguments>
|
||||
<skip>${skipTests}</skip>
|
||||
</configuration>
|
||||
</execution>
|
||||
<execution>
|
||||
<id>npm run build</id>
|
||||
<goals>
|
||||
<goal>npm</goal>
|
||||
</goals>
|
||||
<phase>compile</phase>
|
||||
<configuration>
|
||||
<arguments>run build</arguments>
|
||||
</configuration>
|
||||
</execution>
|
||||
</executions>
|
||||
</plugin>
|
||||
<plugin>
|
||||
<artifactId>maven-resources-plugin</artifactId>
|
||||
<version>3.5.0</version>
|
||||
<executions>
|
||||
<execution>
|
||||
<id>copy web interface to resources</id>
|
||||
<phase>prepare-package</phase>
|
||||
<goals>
|
||||
<goal>copy-resources</goal>
|
||||
</goals>
|
||||
<configuration>
|
||||
<outputDirectory>${project.build.directory}/classes/META-INF/resources</outputDirectory>
|
||||
<resources>
|
||||
<resource>
|
||||
<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>
|
||||
62
commafeed-client/public/favicon.svg
Normal file
62
commafeed-client/public/favicon.svg
Normal file
@@ -0,0 +1,62 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<svg
|
||||
height="393.84613"
|
||||
width="393.84613"
|
||||
viewBox="0 0 5.0480766 5.0480766"
|
||||
version="1.1"
|
||||
id="svg3"
|
||||
sodipodi:docname="favicon.svg"
|
||||
inkscape:version="1.4.2 (ebf0e940d0, 2025-05-08)"
|
||||
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
|
||||
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
xmlns:svg="http://www.w3.org/2000/svg">
|
||||
<defs
|
||||
id="defs3" />
|
||||
<sodipodi:namedview
|
||||
id="namedview3"
|
||||
pagecolor="#ffffff"
|
||||
bordercolor="#000000"
|
||||
borderopacity="0.25"
|
||||
inkscape:showpageshadow="2"
|
||||
inkscape:pageopacity="0.0"
|
||||
inkscape:pagecheckerboard="0"
|
||||
inkscape:deskcolor="#d1d1d1"
|
||||
inkscape:zoom="1.21875"
|
||||
inkscape:cx="207.17949"
|
||||
inkscape:cy="187.07692"
|
||||
inkscape:window-width="1440"
|
||||
inkscape:window-height="855"
|
||||
inkscape:window-x="0"
|
||||
inkscape:window-y="0"
|
||||
inkscape:window-maximized="1"
|
||||
inkscape:current-layer="svg3" />
|
||||
<rect
|
||||
fill="#f88a14"
|
||||
rx="0.53846151"
|
||||
ry="0.53846151"
|
||||
height="5.0480766"
|
||||
width="5.0480766"
|
||||
id="rect1"
|
||||
x="0"
|
||||
y="0"
|
||||
style="stroke-width:0.769231" />
|
||||
<path
|
||||
d="m 1.3450904,0.64548657 c 2.9002,0 2.9002,2.91010003 2.9002,2.91010003"
|
||||
fill="none"
|
||||
stroke="#ffffff"
|
||||
stroke-linecap="round"
|
||||
stroke-width="0.78125"
|
||||
id="path1" />
|
||||
<path
|
||||
d="m 1.3377904,1.9915866 c 1.5705,-0.00908 1.5705,1.5639 1.5705,1.5639"
|
||||
fill="none"
|
||||
stroke="#ffffff"
|
||||
stroke-linecap="round"
|
||||
stroke-width="0.78125"
|
||||
id="path2" />
|
||||
<path
|
||||
d="m 2.0192904,3.5227866 c 0,0.23366 -0.10712,0.47418 -0.24663,0.6537 -0.1814,0.2333 -0.5705,0.5618 -0.6913,0.5653 0.0402,-0.0662 0.263,-0.5654 0.2563,-0.5654 -0.36423004,0 -0.65950004,-0.29265 -0.65950004,-0.65365 0,-0.361 0.29527,-0.65365 0.65950004,-0.65365 0.36423,0 0.68159,0.29265 0.68159,0.65365 z"
|
||||
fill="#ffffff"
|
||||
id="path3" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.9 KiB |
@@ -3,45 +3,53 @@ import { I18nProvider } from "@lingui/react"
|
||||
import { MantineProvider } from "@mantine/core"
|
||||
import { ModalsProvider } from "@mantine/modals"
|
||||
import { Notifications } from "@mantine/notifications"
|
||||
import { Constants } from "app/constants"
|
||||
import { redirectTo } from "app/redirect/slice"
|
||||
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 { useBrowserExtension } from "hooks/useBrowserExtension"
|
||||
import { useI18n } from "i18n"
|
||||
import { WelcomePage } from "pages/WelcomePage"
|
||||
import { AdminUsersPage } from "pages/admin/AdminUsersPage"
|
||||
import { MetricsPage } from "pages/admin/MetricsPage"
|
||||
import { AboutPage } from "pages/app/AboutPage"
|
||||
import { AddPage } from "pages/app/AddPage"
|
||||
import { CategoryDetailsPage } from "pages/app/CategoryDetailsPage"
|
||||
import { DonatePage } from "pages/app/DonatePage"
|
||||
import { FeedDetailsPage } from "pages/app/FeedDetailsPage"
|
||||
import { FeedEntriesPage } from "pages/app/FeedEntriesPage"
|
||||
import Layout from "pages/app/Layout"
|
||||
import { SettingsPage } from "pages/app/SettingsPage"
|
||||
import { TagDetailsPage } from "pages/app/TagDetailsPage"
|
||||
import { LoginPage } from "pages/auth/LoginPage"
|
||||
import { PasswordRecoveryPage } from "pages/auth/PasswordRecoveryPage"
|
||||
import { RegistrationPage } from "pages/auth/RegistrationPage"
|
||||
import React, { useEffect } from "react"
|
||||
import { isSafari } from "react-device-detect"
|
||||
import ReactGA from "react-ga4"
|
||||
import { HashRouter, Navigate, Route, Routes, useLocation, useNavigate } from "react-router-dom"
|
||||
import type React from "react"
|
||||
import { useEffect, useState } from "react"
|
||||
import { HashRouter, Navigate, Route, Routes, useNavigate } from "react-router-dom"
|
||||
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 (
|
||||
<I18nProvider i18n={i18n}>
|
||||
<MantineProvider
|
||||
defaultColorScheme="auto"
|
||||
theme={{
|
||||
primaryColor: "orange",
|
||||
primaryColor: primaryColor,
|
||||
fontFamily: "Open Sans",
|
||||
colors: {
|
||||
// keep using dark colors from mantine v6
|
||||
@@ -70,9 +78,6 @@ function Providers(props: { children: React.ReactNode }) {
|
||||
)
|
||||
}
|
||||
|
||||
// api documentation page is very large, load only on-demand
|
||||
const ApiDocumentationPage = React.lazy(async () => await import("pages/app/ApiDocumentationPage"))
|
||||
|
||||
function AppRoutes() {
|
||||
const sidebarVisible = useAppSelector(state => state.tree.sidebarVisible)
|
||||
|
||||
@@ -80,10 +85,11 @@ function AppRoutes() {
|
||||
<Routes>
|
||||
<Route path="/" element={<Navigate to={`/app/category/${Constants.categories.all.id}`} replace />} />
|
||||
<Route path="welcome" element={<WelcomePage />} />
|
||||
<Route path="setup" element={<InitialSetupPage />} />
|
||||
<Route path="login" element={<LoginPage />} />
|
||||
<Route path="register" element={<RegistrationPage />} />
|
||||
<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="category">
|
||||
<Route path=":id" element={<FeedEntriesPage sourceType="category" />} />
|
||||
@@ -111,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() {
|
||||
const target = useAppSelector(state => state.redirect.to)
|
||||
const dispatch = useAppDispatch()
|
||||
@@ -126,26 +144,19 @@ function RedirectHandler() {
|
||||
return null
|
||||
}
|
||||
|
||||
function GoogleAnalyticsHandler() {
|
||||
const location = useLocation()
|
||||
const googleAnalyticsCode = useAppSelector(state => state.server.serverInfos?.googleAnalyticsCode)
|
||||
|
||||
useEffect(() => {
|
||||
if (googleAnalyticsCode) ReactGA.initialize(googleAnalyticsCode)
|
||||
}, [googleAnalyticsCode])
|
||||
|
||||
useEffect(() => {
|
||||
if (ReactGA.isInitialized) ReactGA.send({ hitType: "pageview", page: location.pathname })
|
||||
}, [location])
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
function UnreadCountTitleHandler({ unreadCount, enabled }: { unreadCount: number; enabled?: boolean }) {
|
||||
function UnreadCountTitleHandler({
|
||||
enabled,
|
||||
}: Readonly<{
|
||||
enabled?: boolean
|
||||
}>) {
|
||||
const root = useAppSelector(state => state.tree.rootCategory)
|
||||
const unreadCount = categoryUnreadCount(root)
|
||||
return <title>{enabled && unreadCount > 0 ? `(${unreadCount}) CommaFeed` : "CommaFeed"}</title>
|
||||
}
|
||||
|
||||
function UnreadCountFaviconHandler({ unreadCount, enabled }: { unreadCount: number; enabled?: boolean }) {
|
||||
function UnreadCountFaviconHandler({ enabled }: { enabled?: boolean }) {
|
||||
const root = useAppSelector(state => state.tree.rootCategory)
|
||||
const unreadCount = categoryUnreadCount(root)
|
||||
useEffect(() => {
|
||||
if (enabled && unreadCount > 0) {
|
||||
Tinycon.setBubble(unreadCount)
|
||||
@@ -169,36 +180,67 @@ function BrowserExtensionBadgeUnreadCountHandler() {
|
||||
return null
|
||||
}
|
||||
|
||||
function CustomJsHandler() {
|
||||
const [scriptLoaded, setScriptLoaded] = useState(false)
|
||||
const { loading } = useAppLoading()
|
||||
|
||||
useEffect(() => {
|
||||
if (scriptLoaded || loading) {
|
||||
return
|
||||
}
|
||||
|
||||
const script = document.createElement("script")
|
||||
script.src = "custom_js.js"
|
||||
script.async = true
|
||||
document.body.appendChild(script)
|
||||
|
||||
setScriptLoaded(true)
|
||||
|
||||
return () => script.remove()
|
||||
}, [scriptLoaded, loading])
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
function CustomCssHandler() {
|
||||
useEffect(() => {
|
||||
const link = document.createElement("link")
|
||||
link.rel = "stylesheet"
|
||||
link.type = "text/css"
|
||||
link.href = "custom_css.css"
|
||||
document.head.appendChild(link)
|
||||
|
||||
return () => link.remove()
|
||||
}, [])
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
export function App() {
|
||||
useI18n()
|
||||
const root = useAppSelector(state => state.tree.rootCategory)
|
||||
const unreadCountTitle = useAppSelector(state => state.user.settings?.unreadCountTitle)
|
||||
const unreadCountFavicon = useAppSelector(state => state.user.settings?.unreadCountFavicon)
|
||||
const disablePullToRefresh = useAppSelector(state => state.user.settings?.disablePullToRefresh)
|
||||
const dispatch = useAppDispatch()
|
||||
|
||||
const unreadCount = categoryUnreadCount(root)
|
||||
|
||||
useEffect(() => {
|
||||
dispatch(reloadServerInfos())
|
||||
}, [dispatch])
|
||||
|
||||
return (
|
||||
<Providers>
|
||||
<>
|
||||
<UnreadCountTitleHandler unreadCount={unreadCount} enabled={unreadCountTitle} />
|
||||
<UnreadCountFaviconHandler unreadCount={unreadCount} enabled={unreadCountFavicon} />
|
||||
<BrowserExtensionBadgeUnreadCountHandler />
|
||||
<HashRouter>
|
||||
<GoogleAnalyticsHandler />
|
||||
<RedirectHandler />
|
||||
<AppRoutes />
|
||||
{/* disable pull-to-refresh as it messes with vertical scrolling
|
||||
safari behaves weirdly when overscroll-behavior is set to none so we disable it only for other browsers
|
||||
https://github.com/Athou/commafeed/issues/1168
|
||||
*/}
|
||||
{!isSafari && <DisablePullToRefresh />}
|
||||
</HashRouter>
|
||||
</>
|
||||
<UnreadCountTitleHandler enabled={unreadCountTitle} />
|
||||
<UnreadCountFaviconHandler enabled={unreadCountFavicon} />
|
||||
<BrowserExtensionBadgeUnreadCountHandler />
|
||||
<CustomJsHandler />
|
||||
<CustomCssHandler />
|
||||
<DisablePullToRefresh enabled={disablePullToRefresh} />
|
||||
|
||||
<HashRouter>
|
||||
<InitialSetupHandler />
|
||||
<RedirectHandler />
|
||||
<AppRoutes />
|
||||
</HashRouter>
|
||||
</Providers>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { createAsyncThunk } from "@reduxjs/toolkit"
|
||||
import type { AppDispatch, RootState } from "app/store"
|
||||
import type { AppDispatch, RootState } from "@/app/store"
|
||||
|
||||
export const createAppAsyncThunk = createAsyncThunk.withTypes<{
|
||||
state: RootState
|
||||
|
||||
@@ -12,12 +12,15 @@ import type {
|
||||
FeedModificationRequest,
|
||||
GetEntriesPaginatedRequest,
|
||||
IDRequest,
|
||||
InitialSetupRequest,
|
||||
LoginRequest,
|
||||
MarkRequest,
|
||||
Metrics,
|
||||
MultipleMarkRequest,
|
||||
PasswordResetConfirmationRequest,
|
||||
PasswordResetRequest,
|
||||
ProfileModificationRequest,
|
||||
PushNotificationSettings,
|
||||
RegistrationRequest,
|
||||
ServerInfo,
|
||||
Settings,
|
||||
@@ -32,16 +35,17 @@ const axiosInstance = axios.create({ baseURL: "./rest", withCredentials: true })
|
||||
axiosInstance.interceptors.response.use(
|
||||
response => response,
|
||||
error => {
|
||||
if (isAuthenticationError(error)) {
|
||||
if (isAuthenticationError(error) && window.location.hash !== "#/login") {
|
||||
const data = error.response?.data
|
||||
window.location.hash = data?.allowRegistrations ? "/welcome" : "/login"
|
||||
window.location.reload()
|
||||
}
|
||||
throw error
|
||||
}
|
||||
)
|
||||
|
||||
function isAuthenticationError(error: unknown): error is AxiosError<AuthenticationError> {
|
||||
return axios.isAxiosError(error) && !!error.response && [401, 403].includes(error.response.status)
|
||||
return axios.isAxiosError(error) && error.response?.status === 401
|
||||
}
|
||||
|
||||
export const client = {
|
||||
@@ -93,9 +97,13 @@ export const client = {
|
||||
})
|
||||
},
|
||||
register: async (req: RegistrationRequest) => await axiosInstance.post("user/register", req),
|
||||
initialSetup: async (req: InitialSetupRequest) => await axiosInstance.post("user/initialSetup", req),
|
||||
passwordReset: async (req: PasswordResetRequest) => await axiosInstance.post("user/passwordReset", req),
|
||||
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),
|
||||
sendTestPushNotification: async (settings: PushNotificationSettings) =>
|
||||
await axiosInstance.post("user/pushNotificationTest", settings),
|
||||
getProfile: async () => await axiosInstance.get<UserModel>("user/profile"),
|
||||
saveProfile: async (req: ProfileModificationRequest) => await axiosInstance.post("user/profile", req),
|
||||
deleteProfile: async () => await axiosInstance.post("user/profile/deleteAccount"),
|
||||
@@ -105,7 +113,7 @@ export const client = {
|
||||
},
|
||||
admin: {
|
||||
getAllUsers: async () => await axiosInstance.get<UserModel[]>("admin/user/getAll"),
|
||||
saveUser: async (req: AdminSaveUserRequest) => await axiosInstance.post("admin/user/save", req),
|
||||
saveUser: async (req: AdminSaveUserRequest) => await axiosInstance.post<number>("admin/user/save", req),
|
||||
deleteUser: async (req: IDRequest) => await axiosInstance.post("admin/user/delete", req),
|
||||
getMetrics: async () => await axiosInstance.get<Metrics>("admin/metrics"),
|
||||
},
|
||||
|
||||
@@ -18,6 +18,13 @@ const categories: Record<string, Omit<Category, "name">> = {
|
||||
feeds: [],
|
||||
position: 1,
|
||||
},
|
||||
infrequent: {
|
||||
id: "infrequent",
|
||||
expanded: false,
|
||||
children: [],
|
||||
feeds: [],
|
||||
position: 2,
|
||||
},
|
||||
}
|
||||
|
||||
const sharing: {
|
||||
@@ -87,23 +94,26 @@ export const Constants = {
|
||||
headerHeight: 60,
|
||||
entryMaxWidth: 650,
|
||||
isTopVisible: (div: HTMLElement) => {
|
||||
const header = document.getElementById(Constants.dom.headerId)?.getBoundingClientRect()
|
||||
const header = document.getElementsByTagName("header").item(0)?.getBoundingClientRect()
|
||||
return div.getBoundingClientRect().top >= (header?.bottom ?? 0)
|
||||
},
|
||||
isBottomVisible: (div: HTMLElement) => {
|
||||
const footer = document.getElementById(Constants.dom.footerId)?.getBoundingClientRect()
|
||||
const footer = document.getElementsByTagName("footer").item(0)?.getBoundingClientRect()
|
||||
return div.getBoundingClientRect().bottom <= (footer?.top ?? window.innerHeight)
|
||||
},
|
||||
},
|
||||
dom: {
|
||||
headerId: "header",
|
||||
footerId: "footer",
|
||||
entryId: (entry: Entry) => `entry-id-${entry.id}`,
|
||||
entryContextMenuId: (entry: Entry) => entry.id,
|
||||
},
|
||||
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,25 +1,20 @@
|
||||
import { configureStore } from "@reduxjs/toolkit"
|
||||
import type { client } from "app/client"
|
||||
import { loadEntries, loadMoreEntries, markAllEntries, markEntry } from "app/entries/thunks"
|
||||
import { type RootState, reducers } from "app/store"
|
||||
import type { Entries, Entry } from "app/types"
|
||||
import type { AxiosResponse } from "axios"
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest"
|
||||
import { any, mockReset } from "vitest-mock-extended"
|
||||
import { client } from "@/app/client"
|
||||
import { loadEntries, loadMoreEntries, markAllEntries, markEntry } from "@/app/entries/thunks"
|
||||
import { type RootState, reducers } from "@/app/store"
|
||||
import type { Entries, Entry } from "@/app/types"
|
||||
|
||||
const mockClient = await vi.hoisted(async () => {
|
||||
const mockModule = await import("vitest-mock-extended")
|
||||
return mockModule.mockDeep<typeof client>()
|
||||
})
|
||||
vi.mock("app/client", () => ({ client: mockClient }))
|
||||
vi.mock(import("@/app/client"))
|
||||
|
||||
describe("entries", () => {
|
||||
beforeEach(() => {
|
||||
mockReset(mockClient)
|
||||
vi.resetAllMocks()
|
||||
})
|
||||
|
||||
it("loads entries", async () => {
|
||||
mockClient.feed.getEntries.calledWith(any()).mockResolvedValue({
|
||||
vi.mocked(client.feed.getEntries).mockResolvedValue({
|
||||
data: {
|
||||
entries: [{ id: "3" } as Entry],
|
||||
hasMore: false,
|
||||
@@ -32,7 +27,12 @@ describe("entries", () => {
|
||||
} as AxiosResponse<Entries>)
|
||||
|
||||
const store = configureStore({ reducer: reducers })
|
||||
const promise = store.dispatch(loadEntries({ source: { type: "feed", id: "feed-id" }, clearSearch: true }))
|
||||
const promise = store.dispatch(
|
||||
loadEntries({
|
||||
source: { type: "feed", id: "feed-id" },
|
||||
clearSearch: true,
|
||||
})
|
||||
)
|
||||
|
||||
expect(store.getState().entries.source.type).toBe("feed")
|
||||
expect(store.getState().entries.source.id).toBe("feed-id")
|
||||
@@ -53,7 +53,7 @@ describe("entries", () => {
|
||||
})
|
||||
|
||||
it("loads more entries", async () => {
|
||||
mockClient.category.getEntries.calledWith(any()).mockResolvedValue({
|
||||
vi.mocked(client.category.getEntries).mockResolvedValue({
|
||||
data: {
|
||||
entries: [{ id: "4" } as Entry],
|
||||
hasMore: false,
|
||||
@@ -113,7 +113,7 @@ describe("entries", () => {
|
||||
{ id: "3", read: true },
|
||||
{ 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", () => {
|
||||
@@ -135,11 +135,19 @@ describe("entries", () => {
|
||||
} as RootState,
|
||||
})
|
||||
|
||||
store.dispatch(markAllEntries({ sourceType: "category", req: { id: "all", read: true } }))
|
||||
store.dispatch(
|
||||
markAllEntries({
|
||||
sourceType: "category",
|
||||
req: { id: "all", read: true },
|
||||
})
|
||||
)
|
||||
expect(store.getState().entries.entries).toStrictEqual([
|
||||
{ id: "3", read: true },
|
||||
{ id: "4", read: true },
|
||||
])
|
||||
expect(mockClient.category.markEntries).toHaveBeenCalledWith({ id: "all", read: true })
|
||||
expect(client.category.markEntries).toHaveBeenCalledWith({
|
||||
id: "all",
|
||||
read: true,
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { type PayloadAction, createSlice } from "@reduxjs/toolkit"
|
||||
import { Constants } from "app/constants"
|
||||
import { loadEntries, loadMoreEntries, markAllEntries, markEntry, markMultipleEntries, starEntry, tagEntry } from "app/entries/thunks"
|
||||
import type { Entry } from "app/types"
|
||||
import { createSlice, type PayloadAction } from "@reduxjs/toolkit"
|
||||
import { Constants } from "@/app/constants"
|
||||
import { loadEntries, loadMoreEntries, markAllEntries, markEntry, markMultipleEntries, starEntry, tagEntry } from "@/app/entries/thunks"
|
||||
import type { Entry } from "@/app/types"
|
||||
|
||||
export type EntrySourceType = "category" | "feed" | "tag"
|
||||
|
||||
@@ -28,6 +28,7 @@ interface EntriesState {
|
||||
loading: boolean
|
||||
search?: string
|
||||
scrollingToEntry: boolean
|
||||
markAllAsReadConfirmationDialogOpen: boolean
|
||||
}
|
||||
|
||||
const initialState: EntriesState = {
|
||||
@@ -41,6 +42,7 @@ const initialState: EntriesState = {
|
||||
hasMore: true,
|
||||
loading: false,
|
||||
scrollingToEntry: false,
|
||||
markAllAsReadConfirmationDialogOpen: false,
|
||||
}
|
||||
|
||||
export const entriesSlice = createSlice({
|
||||
@@ -61,6 +63,9 @@ export const entriesSlice = createSlice({
|
||||
setSearch: (state, action: PayloadAction<string>) => {
|
||||
state.search = action.payload
|
||||
},
|
||||
setMarkAllAsReadConfirmationDialogOpen: (state, action: PayloadAction<boolean>) => {
|
||||
state.markAllAsReadConfirmationDialogOpen = action.payload
|
||||
},
|
||||
},
|
||||
extraReducers: builder => {
|
||||
builder.addCase(markEntry.pending, (state, action) => {
|
||||
@@ -119,4 +124,4 @@ export const entriesSlice = createSlice({
|
||||
},
|
||||
})
|
||||
|
||||
export const { setSearch } = entriesSlice.actions
|
||||
export const { setSearch, setMarkAllAsReadConfirmationDialogOpen } = entriesSlice.actions
|
||||
|
||||
@@ -1,13 +1,19 @@
|
||||
import { createAppAsyncThunk } from "app/async-thunk"
|
||||
import { client } from "app/client"
|
||||
import { Constants } from "app/constants"
|
||||
import { type EntrySource, type EntrySourceType, entriesSlice, setSearch } from "app/entries/slice"
|
||||
import type { RootState } from "app/store"
|
||||
import { reloadTree } from "app/tree/thunks"
|
||||
import type { Entry, MarkRequest, TagRequest } from "app/types"
|
||||
import { reloadTags } from "app/user/thunks"
|
||||
import { scrollToWithCallback } from "app/utils"
|
||||
import { flushSync } from "react-dom"
|
||||
import { createAppAsyncThunk } from "@/app/async-thunk"
|
||||
import { client } from "@/app/client"
|
||||
import { Constants } from "@/app/constants"
|
||||
import {
|
||||
type EntrySource,
|
||||
type EntrySourceType,
|
||||
entriesSlice,
|
||||
setMarkAllAsReadConfirmationDialogOpen,
|
||||
setSearch,
|
||||
} from "@/app/entries/slice"
|
||||
import type { RootState } from "@/app/store"
|
||||
import { reloadTree, selectNextUnreadTreeItem } from "@/app/tree/thunks"
|
||||
import type { Entry, MarkRequest, TagRequest } from "@/app/types"
|
||||
import { reloadTags } from "@/app/user/thunks"
|
||||
import { scrollToWithCallback } from "@/app/utils"
|
||||
|
||||
const getEndpoint = (sourceType: EntrySourceType) =>
|
||||
sourceType === "category" || sourceType === "tag" ? client.category.getEntries : client.feed.getEntries
|
||||
@@ -34,7 +40,9 @@ export const loadMoreEntries = createAppAsyncThunk("entries/loadMore", async (_,
|
||||
const state = thunkApi.getState()
|
||||
const { source } = state.entries
|
||||
const offset =
|
||||
state.user.settings?.readingMode === "all" ? state.entries.entries.length : state.entries.entries.filter(e => !e.read).length
|
||||
state.user.settings?.readingMode === "all" || (source.type === "category" && source.id === "starred")
|
||||
? state.entries.entries.length
|
||||
: state.entries.entries.filter(e => !e.read).length
|
||||
const endpoint = getEndpoint(state.entries.source.type)
|
||||
const result = await endpoint(buildGetEntriesPaginatedRequest(state, source, offset))
|
||||
return result.data
|
||||
@@ -50,7 +58,7 @@ const buildGetEntriesPaginatedRequest = (state: RootState, source: EntrySource,
|
||||
keywords: state.entries.search,
|
||||
})
|
||||
|
||||
export const reloadEntries = createAppAsyncThunk("entries/reload", (arg, thunkApi) => {
|
||||
export const reloadEntries = createAppAsyncThunk("entries/reload", (_, thunkApi) => {
|
||||
const state = thunkApi.getState()
|
||||
thunkApi.dispatch(loadEntries({ source: state.entries.source, clearSearch: false }))
|
||||
})
|
||||
@@ -123,6 +131,36 @@ export const markAllEntries = createAppAsyncThunk(
|
||||
}
|
||||
)
|
||||
|
||||
export const markAllAsReadWithConfirmationIfRequired = createAppAsyncThunk(
|
||||
"entries/entry/markAllAsReadWithConfirmationIfRequired",
|
||||
async (_, thunkApi) => {
|
||||
const state = thunkApi.getState()
|
||||
const source = state.entries.source
|
||||
const entriesTimestamp = state.entries.timestamp ?? Date.now()
|
||||
const markAllAsReadConfirmation = state.user.settings?.markAllAsReadConfirmation
|
||||
const markAllAsReadNavigateToNextUnread = state.user.settings?.markAllAsReadNavigateToNextUnread
|
||||
|
||||
if (markAllAsReadConfirmation) {
|
||||
thunkApi.dispatch(setMarkAllAsReadConfirmationDialogOpen(true))
|
||||
} else {
|
||||
await thunkApi.dispatch(
|
||||
markAllEntries({
|
||||
sourceType: source.type,
|
||||
req: {
|
||||
id: source.id,
|
||||
read: true,
|
||||
olderThan: Date.now(),
|
||||
insertedBefore: entriesTimestamp,
|
||||
},
|
||||
})
|
||||
)
|
||||
const isAllCategorySelected = source.type === "category" && source.id === Constants.categories.all.id
|
||||
if (markAllAsReadNavigateToNextUnread && !isAllCategorySelected)
|
||||
await thunkApi.dispatch(selectNextUnreadTreeItem({ direction: "forward" }))
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
export const starEntry = createAppAsyncThunk(
|
||||
"entries/entry/star",
|
||||
(arg: { entry: Entry; starred: boolean }) => {
|
||||
@@ -204,7 +242,7 @@ export const selectEntry = createAppAsyncThunk(
|
||||
)
|
||||
|
||||
const scrollToEntry = (entryElement: HTMLElement, margin: number, scrollSpeed: number | undefined, onScrollEnded: () => void) => {
|
||||
const header = document.getElementById(Constants.dom.headerId)?.getBoundingClientRect()
|
||||
const header = document.getElementsByTagName("header").item(0)?.getBoundingClientRect()
|
||||
const offset = (header?.bottom ?? 0) + margin
|
||||
scrollToWithCallback({
|
||||
options: {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { redirectToCategory } from "app/redirect/thunks"
|
||||
import { store } from "app/store"
|
||||
import { describe, expect, it } from "vitest"
|
||||
import { redirectToCategory } from "@/app/redirect/thunks"
|
||||
import { store } from "@/app/store"
|
||||
|
||||
describe("redirects", () => {
|
||||
it("redirects to category", async () => {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { type PayloadAction, createSlice } from "@reduxjs/toolkit"
|
||||
import { createSlice, type PayloadAction } from "@reduxjs/toolkit"
|
||||
|
||||
interface RedirectState {
|
||||
to?: string
|
||||
|
||||
@@ -1,12 +1,16 @@
|
||||
import { createAppAsyncThunk } from "app/async-thunk"
|
||||
import { Constants } from "app/constants"
|
||||
import { redirectTo } from "app/redirect/slice"
|
||||
import { createAppAsyncThunk } from "@/app/async-thunk"
|
||||
import { Constants } from "@/app/constants"
|
||||
import { redirectTo } from "@/app/redirect/slice"
|
||||
|
||||
export const redirectToLogin = createAppAsyncThunk("redirect/login", (_, thunkApi) => thunkApi.dispatch(redirectTo("/login")))
|
||||
|
||||
export const redirectToRegistration = createAppAsyncThunk("redirect/register", (_, thunkApi) => thunkApi.dispatch(redirectTo("/register")))
|
||||
|
||||
export const redirectToApiDocumentation = createAppAsyncThunk("redirect/api", (_, thunkApi) => thunkApi.dispatch(redirectTo("/api")))
|
||||
export const redirectToInitialSetup = createAppAsyncThunk("redirect/initialSetup", (_, thunkApi) => thunkApi.dispatch(redirectTo("/setup")))
|
||||
|
||||
export const redirectToApiDocumentation = createAppAsyncThunk("redirect/api", () => {
|
||||
window.location.href = "api-documentation/"
|
||||
})
|
||||
|
||||
export const redirectToSelectedSource = createAppAsyncThunk("redirect/selectedSource", (_, thunkApi) => {
|
||||
const { source } = thunkApi.getState().entries
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { type PayloadAction, createSlice } from "@reduxjs/toolkit"
|
||||
import { reloadServerInfos } from "app/server/thunks"
|
||||
import type { ServerInfo } from "app/types"
|
||||
import { createSlice, type PayloadAction } from "@reduxjs/toolkit"
|
||||
import { reloadServerInfos } from "@/app/server/thunks"
|
||||
import type { ServerInfo } from "@/app/types"
|
||||
|
||||
interface ServerState {
|
||||
serverInfos?: ServerInfo
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { createAppAsyncThunk } from "app/async-thunk"
|
||||
import { client } from "app/client"
|
||||
import { createAppAsyncThunk } from "@/app/async-thunk"
|
||||
import { client } from "@/app/client"
|
||||
|
||||
export const reloadServerInfos = createAppAsyncThunk("server/infos", async () => await client.server.getServerInfos().then(r => r.data))
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { configureStore } from "@reduxjs/toolkit"
|
||||
import { entriesSlice } from "app/entries/slice"
|
||||
import { redirectSlice } from "app/redirect/slice"
|
||||
import { serverSlice } from "app/server/slice"
|
||||
import { treeSlice } from "app/tree/slice"
|
||||
import type { LocalSettings } from "app/types"
|
||||
import { initialLocalSettings, userSlice } from "app/user/slice"
|
||||
import { type TypedUseSelectorHook, useDispatch, useSelector } from "react-redux"
|
||||
import { shallowEqual, type TypedUseSelectorHook, useDispatch, useSelector } from "react-redux"
|
||||
import { entriesSlice } from "@/app/entries/slice"
|
||||
import { redirectSlice } from "@/app/redirect/slice"
|
||||
import { serverSlice } from "@/app/server/slice"
|
||||
import { treeSlice } from "@/app/tree/slice"
|
||||
import type { LocalSettings } from "@/app/types"
|
||||
import { initialLocalSettings, userSlice } from "@/app/user/slice"
|
||||
|
||||
export const reducers = {
|
||||
entries: entriesSlice.reducer,
|
||||
@@ -17,19 +17,9 @@ export const reducers = {
|
||||
|
||||
const loadLocalSettings = (): LocalSettings => {
|
||||
const json = localStorage.getItem("commafeed-local-settings")
|
||||
if (json) {
|
||||
return JSON.parse(json)
|
||||
}
|
||||
|
||||
// load old settings
|
||||
const viewMode = localStorage.getItem("view-mode")
|
||||
const sidebarWidth = localStorage.getItem("sidebar-width")
|
||||
const announcementHash = localStorage.getItem("announcement-hash")
|
||||
return {
|
||||
...initialLocalSettings,
|
||||
viewMode: viewMode ? JSON.parse(viewMode) : initialLocalSettings.viewMode,
|
||||
sidebarWidth: sidebarWidth ? JSON.parse(sidebarWidth) : initialLocalSettings.sidebarWidth,
|
||||
announcementHash: announcementHash ? JSON.parse(announcementHash) : initialLocalSettings.announcementHash,
|
||||
...(json ? JSON.parse(json) : {}),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -51,3 +41,4 @@ 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,12 +1,22 @@
|
||||
import { type PayloadAction, createSlice } from "@reduxjs/toolkit"
|
||||
import { markEntry } from "app/entries/thunks"
|
||||
import { redirectTo } from "app/redirect/slice"
|
||||
import { collapseTreeCategory, reloadTree } from "app/tree/thunks"
|
||||
import type { Category } from "app/types"
|
||||
import { visitCategoryTree } from "app/utils"
|
||||
import { createSlice, type PayloadAction } from "@reduxjs/toolkit"
|
||||
import { loadEntries, markEntry } from "@/app/entries/thunks"
|
||||
import { redirectTo } from "@/app/redirect/slice"
|
||||
import { collapseTreeCategory, reloadTree } from "@/app/tree/thunks"
|
||||
import type { Category, Subscription } from "@/app/types"
|
||||
import { flattenCategoryTree, visitCategoryTree } from "@/app/utils"
|
||||
|
||||
export interface TreeSubscription extends Subscription {
|
||||
// client-side only flag
|
||||
hasNewEntries?: boolean
|
||||
}
|
||||
|
||||
export interface TreeCategory extends Category {
|
||||
feeds: TreeSubscription[]
|
||||
children: TreeCategory[]
|
||||
}
|
||||
|
||||
interface TreeState {
|
||||
rootCategory?: Category
|
||||
rootCategory?: TreeCategory
|
||||
mobileMenuOpen: boolean
|
||||
sidebarVisible: boolean
|
||||
}
|
||||
@@ -37,12 +47,27 @@ export const treeSlice = createSlice({
|
||||
visitCategoryTree(state.rootCategory, c => {
|
||||
for (const f of c.feeds.filter(f => f.id === action.payload.feedId)) {
|
||||
f.unread += action.payload.amount
|
||||
f.hasNewEntries = true
|
||||
}
|
||||
})
|
||||
},
|
||||
},
|
||||
extraReducers: builder => {
|
||||
builder.addCase(reloadTree.fulfilled, (state, action) => {
|
||||
// set hasNewEntries to true if new unread > previous unread
|
||||
if (state.rootCategory) {
|
||||
const oldFeeds = flattenCategoryTree(state.rootCategory).flatMap(c => c.feeds)
|
||||
const oldFeedsById = new Map(oldFeeds.map(f => [f.id, f]))
|
||||
|
||||
const newFeeds = flattenCategoryTree(action.payload).flatMap(c => c.feeds)
|
||||
for (const newFeed of newFeeds) {
|
||||
const oldFeed = oldFeedsById.get(newFeed.id)
|
||||
if (oldFeed && newFeed.unread > oldFeed.unread) {
|
||||
newFeed.hasNewEntries = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
state.rootCategory = action.payload
|
||||
})
|
||||
builder.addCase(collapseTreeCategory.pending, (state, action) => {
|
||||
@@ -59,6 +84,25 @@ export const treeSlice = createSlice({
|
||||
}
|
||||
})
|
||||
})
|
||||
builder.addCase(loadEntries.fulfilled, (state, action) => {
|
||||
if (!state.rootCategory) return
|
||||
|
||||
const { source } = action.meta.arg
|
||||
if (source.type === "category") {
|
||||
visitCategoryTree(state.rootCategory, c => {
|
||||
if (c.id === source.id) {
|
||||
for (const f of flattenCategoryTree(c).flatMap(c => c.feeds)) {
|
||||
f.hasNewEntries = false
|
||||
}
|
||||
}
|
||||
})
|
||||
} else if (source.type === "feed") {
|
||||
const feeds = flattenCategoryTree(state.rootCategory).flatMap(c => c.feeds)
|
||||
for (const f of feeds.filter(f => f.id === +source.id)) {
|
||||
f.hasNewEntries = false
|
||||
}
|
||||
}
|
||||
})
|
||||
builder.addCase(redirectTo, state => {
|
||||
state.mobileMenuOpen = false
|
||||
})
|
||||
|
||||
@@ -1,14 +1,63 @@
|
||||
import { createAppAsyncThunk } from "app/async-thunk"
|
||||
import { client } from "app/client"
|
||||
import { incrementUnreadCount } from "app/tree/slice"
|
||||
import type { CollapseRequest } from "app/types"
|
||||
import { flattenCategoryTree } from "app/utils"
|
||||
import { createAppAsyncThunk } from "@/app/async-thunk"
|
||||
import { client } from "@/app/client"
|
||||
import { Constants } from "@/app/constants"
|
||||
import { redirectToCategory, redirectToFeed } from "@/app/redirect/thunks"
|
||||
import { incrementUnreadCount } from "@/app/tree/slice"
|
||||
import type { CollapseRequest, Subscription } from "@/app/types"
|
||||
import { flattenCategoryTree, visitCategoryTree } from "@/app/utils"
|
||||
|
||||
export const reloadTree = createAppAsyncThunk("tree/reload", async () => await client.category.getRoot().then(r => r.data))
|
||||
|
||||
export const collapseTreeCategory = createAppAsyncThunk(
|
||||
"tree/category/collapse",
|
||||
async (req: CollapseRequest) => await client.category.collapse(req)
|
||||
async (req: CollapseRequest) => await client.category.collapse(req).then(r => r.data)
|
||||
)
|
||||
|
||||
export const selectNextUnreadTreeItem = createAppAsyncThunk(
|
||||
"tree/selectNextUnreadItem",
|
||||
(
|
||||
arg: {
|
||||
direction: "forward" | "backward"
|
||||
},
|
||||
thunkApi
|
||||
) => {
|
||||
const state = thunkApi.getState()
|
||||
const root = state.tree.rootCategory
|
||||
if (!root) return
|
||||
|
||||
const { source } = state.entries
|
||||
if (source.type === "category") {
|
||||
const categories = flattenCategoryTree(root)
|
||||
if (arg.direction === "backward") categories.reverse()
|
||||
|
||||
const index = categories.findIndex(c => c.id === source.id)
|
||||
if (index === -1) return
|
||||
|
||||
for (let i = index + 1; i < categories.length; i++) {
|
||||
const c = categories[i]
|
||||
if (c.feeds.some(f => f.unread > 0)) {
|
||||
return thunkApi.dispatch(redirectToCategory(String(c.id)))
|
||||
}
|
||||
}
|
||||
} else if (source.type === "feed") {
|
||||
const feeds: Subscription[] = []
|
||||
visitCategoryTree(root, c => feeds.push(...c.feeds), { childrenFirst: true })
|
||||
if (arg.direction === "backward") feeds.reverse()
|
||||
|
||||
const index = feeds.findIndex(f => f.id === +source.id)
|
||||
if (index === -1) return
|
||||
|
||||
for (let i = index + 1; i < feeds.length; i++) {
|
||||
const f = feeds[i]
|
||||
if (f.unread > 0) {
|
||||
return thunkApi.dispatch(redirectToFeed(String(f.id)))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// redirect to 'all' if no unread categories or feeds found or if we reached the end of the list
|
||||
thunkApi.dispatch(redirectToCategory(Constants.categories.all.id))
|
||||
}
|
||||
)
|
||||
|
||||
export const newFeedEntriesDiscovered = createAppAsyncThunk(
|
||||
|
||||
173
commafeed-client/src/app/tree/tree.test.ts
Normal file
173
commafeed-client/src/app/tree/tree.test.ts
Normal file
@@ -0,0 +1,173 @@
|
||||
import { configureStore } from "@reduxjs/toolkit"
|
||||
import type { AxiosResponse } from "axios"
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest"
|
||||
import { client } from "@/app/client"
|
||||
import { loadEntries } from "@/app/entries/thunks"
|
||||
import { type RootState, reducers } from "@/app/store"
|
||||
import { newFeedEntriesDiscovered, selectNextUnreadTreeItem } from "@/app/tree/thunks"
|
||||
import type { Category, Entries, Entry, Subscription } from "@/app/types"
|
||||
|
||||
vi.mock(import("@/app/client"))
|
||||
|
||||
const createCategory = (id: string): Category => ({
|
||||
id,
|
||||
name: id,
|
||||
children: [],
|
||||
feeds: [],
|
||||
expanded: true,
|
||||
position: 0,
|
||||
})
|
||||
|
||||
const createFeed = (id: number, unread: number): Subscription => ({
|
||||
id,
|
||||
name: String(id),
|
||||
unread,
|
||||
errorCount: 0,
|
||||
position: 0,
|
||||
feedUrl: "",
|
||||
feedLink: "",
|
||||
iconUrl: "",
|
||||
pushNotificationsEnabled: true,
|
||||
})
|
||||
|
||||
const root = createCategory("root")
|
||||
|
||||
const catA = createCategory("catA")
|
||||
catA.feeds.push(createFeed(1, 0), createFeed(2, 0), createFeed(3, 1))
|
||||
|
||||
const catB = createCategory("catB")
|
||||
|
||||
const catC = createCategory("catC")
|
||||
catC.feeds.push(createFeed(4, 1))
|
||||
|
||||
root.children.push(catA, catB, catC)
|
||||
|
||||
describe("selectNextUnreadTreeItem", () => {
|
||||
it("selects the next unread category", async () => {
|
||||
const store = configureStore({
|
||||
reducer: reducers,
|
||||
preloadedState: {
|
||||
tree: {
|
||||
rootCategory: root,
|
||||
},
|
||||
entries: {
|
||||
source: {
|
||||
type: "category",
|
||||
id: "catA",
|
||||
},
|
||||
},
|
||||
} as RootState,
|
||||
})
|
||||
|
||||
await store.dispatch(selectNextUnreadTreeItem({ direction: "forward" }))
|
||||
expect(store.getState().redirect.to).toBe("/app/category/catC")
|
||||
})
|
||||
|
||||
it("selects the previous unread category", async () => {
|
||||
const store = configureStore({
|
||||
reducer: reducers,
|
||||
preloadedState: {
|
||||
tree: {
|
||||
rootCategory: root,
|
||||
},
|
||||
entries: {
|
||||
source: {
|
||||
type: "category",
|
||||
id: "catC",
|
||||
},
|
||||
},
|
||||
} as RootState,
|
||||
})
|
||||
|
||||
await store.dispatch(selectNextUnreadTreeItem({ direction: "backward" }))
|
||||
expect(store.getState().redirect.to).toBe("/app/category/catA")
|
||||
})
|
||||
|
||||
it("selects the next unread feed", async () => {
|
||||
const store = configureStore({
|
||||
reducer: reducers,
|
||||
preloadedState: {
|
||||
tree: {
|
||||
rootCategory: root,
|
||||
},
|
||||
entries: {
|
||||
source: {
|
||||
type: "feed",
|
||||
id: "1",
|
||||
},
|
||||
},
|
||||
} as RootState,
|
||||
})
|
||||
|
||||
await store.dispatch(selectNextUnreadTreeItem({ direction: "forward" }))
|
||||
expect(store.getState().redirect.to).toBe("/app/feed/3")
|
||||
})
|
||||
|
||||
it("selects the previous unread feed", async () => {
|
||||
const store = configureStore({
|
||||
reducer: reducers,
|
||||
preloadedState: {
|
||||
tree: {
|
||||
rootCategory: root,
|
||||
},
|
||||
entries: {
|
||||
source: {
|
||||
type: "feed",
|
||||
id: "4",
|
||||
},
|
||||
},
|
||||
} as RootState,
|
||||
})
|
||||
|
||||
await store.dispatch(selectNextUnreadTreeItem({ direction: "backward" }))
|
||||
expect(store.getState().redirect.to).toBe("/app/feed/3")
|
||||
})
|
||||
})
|
||||
|
||||
describe("hasNewEntries", () => {
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks()
|
||||
})
|
||||
|
||||
it("sets and clear flag for a feed", async () => {
|
||||
vi.mocked(client.feed.getEntries).mockResolvedValue({
|
||||
data: {
|
||||
entries: [{ id: "3" } as Entry],
|
||||
hasMore: false,
|
||||
name: "my-feed",
|
||||
errorCount: 3,
|
||||
feedLink: "https://mysite.com/feed",
|
||||
timestamp: 123,
|
||||
ignoredReadStatus: false,
|
||||
},
|
||||
} as AxiosResponse<Entries>)
|
||||
|
||||
const store = configureStore({
|
||||
reducer: reducers,
|
||||
preloadedState: {
|
||||
tree: {
|
||||
rootCategory: root,
|
||||
},
|
||||
entries: {
|
||||
source: {
|
||||
type: "feed",
|
||||
id: "1",
|
||||
},
|
||||
},
|
||||
} as RootState,
|
||||
})
|
||||
|
||||
// initial state
|
||||
expect(store.getState().tree.rootCategory?.children[0].feeds[0].unread).toBe(0)
|
||||
expect(store.getState().tree.rootCategory?.children[0].feeds[0].hasNewEntries).toBeFalsy()
|
||||
|
||||
// increments unread count and sets hasNewEntries to true
|
||||
await store.dispatch(newFeedEntriesDiscovered({ feedId: 1, amount: 3 }))
|
||||
expect(store.getState().tree.rootCategory?.children[0].feeds[0].unread).toBe(3)
|
||||
expect(store.getState().tree.rootCategory?.children[0].feeds[0].hasNewEntries).toBe(true)
|
||||
|
||||
// reload entries and sets hasNewEntries to false
|
||||
await store.dispatch(loadEntries({ source: { type: "feed", id: "1" }, clearSearch: true }))
|
||||
expect(store.getState().tree.rootCategory?.children[0].feeds[0].hasNewEntries).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -28,6 +28,10 @@ export interface Subscription {
|
||||
position: number
|
||||
newestItemTime?: number
|
||||
filter?: string
|
||||
filterLegacy?: string
|
||||
pushNotificationsEnabled: boolean
|
||||
autoMarkAsReadAfterDays?: number
|
||||
averageEntryIntervalMs?: number
|
||||
}
|
||||
|
||||
export interface Category {
|
||||
@@ -109,6 +113,8 @@ export interface FeedModificationRequest {
|
||||
categoryId?: string
|
||||
position?: number
|
||||
filter?: string
|
||||
pushNotificationsEnabled: boolean
|
||||
autoMarkAsReadAfterDays?: number
|
||||
}
|
||||
|
||||
export interface GetEntriesRequest {
|
||||
@@ -196,6 +202,12 @@ export interface PasswordResetRequest {
|
||||
email: string
|
||||
}
|
||||
|
||||
export interface PasswordResetConfirmationRequest {
|
||||
email: string
|
||||
token: string
|
||||
password: string
|
||||
}
|
||||
|
||||
export interface ProfileModificationRequest {
|
||||
currentPassword: string
|
||||
email: string
|
||||
@@ -209,18 +221,27 @@ export interface RegistrationRequest {
|
||||
email: string
|
||||
}
|
||||
|
||||
export interface InitialSetupRequest {
|
||||
name: string
|
||||
password: string
|
||||
email?: string
|
||||
}
|
||||
|
||||
export interface ServerInfo {
|
||||
announcement?: string
|
||||
version: string
|
||||
gitCommit: string
|
||||
allowRegistrations: boolean
|
||||
googleAnalyticsCode?: string
|
||||
emailAddressRequired: boolean
|
||||
smtpEnabled: boolean
|
||||
demoAccountEnabled: boolean
|
||||
websocketEnabled: boolean
|
||||
websocketPingInterval: number
|
||||
treeReloadInterval: number
|
||||
forceRefreshCooldownDuration: number
|
||||
initialSetupRequired: boolean
|
||||
minimumPasswordLength: number
|
||||
pushNotificationsEnabled: boolean
|
||||
}
|
||||
|
||||
export interface SharingSettings {
|
||||
@@ -234,8 +255,18 @@ export interface SharingSettings {
|
||||
buffer: boolean
|
||||
}
|
||||
|
||||
export type PushNotificationType = "ntfy" | "gotify" | "pushover"
|
||||
|
||||
export interface PushNotificationSettings {
|
||||
type?: PushNotificationType
|
||||
serverUrl?: string
|
||||
userId?: string
|
||||
userSecret?: string
|
||||
topic?: string
|
||||
}
|
||||
|
||||
export interface Settings {
|
||||
language: string
|
||||
language?: string
|
||||
readingMode: ReadingMode
|
||||
readingOrder: ReadingOrder
|
||||
showRead: boolean
|
||||
@@ -248,17 +279,24 @@ export interface Settings {
|
||||
starIconDisplayMode: IconDisplayMode
|
||||
externalLinkIconDisplayMode: IconDisplayMode
|
||||
markAllAsReadConfirmation: boolean
|
||||
markAllAsReadNavigateToNextUnread: boolean
|
||||
customContextMenu: boolean
|
||||
mobileFooter: boolean
|
||||
unreadCountTitle: boolean
|
||||
unreadCountFavicon: boolean
|
||||
disablePullToRefresh: boolean
|
||||
disableMobileSwipe: boolean
|
||||
infrequentThresholdDays: number
|
||||
primaryColor?: string
|
||||
sharingSettings: SharingSettings
|
||||
pushNotificationSettings: PushNotificationSettings
|
||||
}
|
||||
|
||||
export interface LocalSettings {
|
||||
viewMode: ViewMode
|
||||
sidebarWidth: number
|
||||
announcementHash: string
|
||||
fontSizePercentage: number
|
||||
}
|
||||
|
||||
export interface StarRequest {
|
||||
|
||||
@@ -1,14 +1,20 @@
|
||||
import { t } from "@lingui/core/macro"
|
||||
import { showNotification } from "@mantine/notifications"
|
||||
import { type PayloadAction, createSlice, isAnyOf } from "@reduxjs/toolkit"
|
||||
import type { LocalSettings, Settings, UserModel, ViewMode } from "app/types"
|
||||
import { createSlice, isAnyOf, type PayloadAction } from "@reduxjs/toolkit"
|
||||
import type { LocalSettings, Settings, UserModel, ViewMode } from "@/app/types"
|
||||
import {
|
||||
changeCustomContextMenu,
|
||||
changeDisableMobileSwipe,
|
||||
changeDisablePullToRefresh,
|
||||
changeEntriesToKeepOnTopWhenScrolling,
|
||||
changeExternalLinkIconDisplayMode,
|
||||
changeInfrequentThresholdDays,
|
||||
changeLanguage,
|
||||
changeMarkAllAsReadConfirmation,
|
||||
changeMarkAllAsReadNavigateToUnread,
|
||||
changeMobileFooter,
|
||||
changePrimaryColor,
|
||||
changePushNotificationSettings,
|
||||
changeReadingMode,
|
||||
changeReadingOrder,
|
||||
changeScrollMarks,
|
||||
@@ -35,6 +41,7 @@ export const initialLocalSettings: LocalSettings = {
|
||||
viewMode: "detailed",
|
||||
sidebarWidth: 360,
|
||||
announcementHash: "no-hash",
|
||||
fontSizePercentage: 100,
|
||||
}
|
||||
|
||||
const initialState: UserState = {
|
||||
@@ -48,6 +55,9 @@ export const userSlice = createSlice({
|
||||
setViewMode: (state, action: PayloadAction<ViewMode>) => {
|
||||
state.localSettings.viewMode = action.payload
|
||||
},
|
||||
setFontSizePercentage: (state, action: PayloadAction<number>) => {
|
||||
state.localSettings.fontSizePercentage = action.payload
|
||||
},
|
||||
setSidebarWidth: (state, action: PayloadAction<number>) => {
|
||||
state.localSettings.sidebarWidth = action.payload
|
||||
},
|
||||
@@ -109,6 +119,10 @@ export const userSlice = createSlice({
|
||||
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
|
||||
@@ -125,10 +139,31 @@ export const userSlice = createSlice({
|
||||
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,
|
||||
@@ -140,11 +175,17 @@ export const userSlice = createSlice({
|
||||
changeStarIconDisplayMode.fulfilled,
|
||||
changeExternalLinkIconDisplayMode.fulfilled,
|
||||
changeMarkAllAsReadConfirmation.fulfilled,
|
||||
changeMarkAllAsReadNavigateToUnread.fulfilled,
|
||||
changeCustomContextMenu.fulfilled,
|
||||
changeMobileFooter.fulfilled,
|
||||
changeUnreadCountTitle.fulfilled,
|
||||
changeUnreadCountFavicon.fulfilled,
|
||||
changeSharingSetting.fulfilled
|
||||
changeDisablePullToRefresh.fulfilled,
|
||||
changeDisableMobileSwipe.fulfilled,
|
||||
changeInfrequentThresholdDays.fulfilled,
|
||||
changePrimaryColor.fulfilled,
|
||||
changeSharingSetting.fulfilled,
|
||||
changePushNotificationSettings.fulfilled
|
||||
),
|
||||
() => {
|
||||
showNotification({
|
||||
@@ -156,4 +197,4 @@ export const userSlice = createSlice({
|
||||
},
|
||||
})
|
||||
|
||||
export const { setViewMode, setSidebarWidth, setAnnouncementHash } = userSlice.actions
|
||||
export const { setViewMode, setSidebarWidth, setAnnouncementHash, setFontSizePercentage } = userSlice.actions
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { createAppAsyncThunk } from "app/async-thunk"
|
||||
import { client } from "app/client"
|
||||
import { reloadEntries } from "app/entries/thunks"
|
||||
import type { IconDisplayMode, ReadingMode, ReadingOrder, ScrollMode, SharingSettings } from "app/types"
|
||||
import { createAppAsyncThunk } from "@/app/async-thunk"
|
||||
import { client } from "@/app/client"
|
||||
import { reloadEntries } from "@/app/entries/thunks"
|
||||
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))
|
||||
|
||||
@@ -89,6 +89,15 @@ export const changeMarkAllAsReadConfirmation = createAppAsyncThunk(
|
||||
}
|
||||
)
|
||||
|
||||
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
|
||||
@@ -113,6 +122,27 @@ export const changeUnreadCountFavicon = createAppAsyncThunk("settings/unreadCoun
|
||||
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",
|
||||
(
|
||||
@@ -133,3 +163,24 @@ export const changeSharingSetting = createAppAsyncThunk(
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
export const changeInfrequentThresholdDays = createAppAsyncThunk(
|
||||
"settings/infrequentThresholdDays",
|
||||
(infrequentThresholdDays: number, thunkApi) => {
|
||||
const { settings } = thunkApi.getState().user
|
||||
if (!settings) return
|
||||
client.user.saveSettings({ ...settings, infrequentThresholdDays })
|
||||
}
|
||||
)
|
||||
|
||||
export const changePushNotificationSettings = createAppAsyncThunk(
|
||||
"settings/pushNotificationSettings",
|
||||
(pushNotificationSettings: PushNotificationSettings, thunkApi) => {
|
||||
const { settings } = thunkApi.getState().user
|
||||
if (!settings) return
|
||||
client.user.saveSettings({
|
||||
...settings,
|
||||
pushNotificationSettings,
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
@@ -1,28 +1,50 @@
|
||||
import { throttle } from "throttle-debounce"
|
||||
import type { TreeCategory } from "@/app/tree/slice"
|
||||
import type { Category } from "./types"
|
||||
|
||||
export function visitCategoryTree(category: Category, visitor: (category: Category) => void): void {
|
||||
visitor(category)
|
||||
for (const child of category.children) {
|
||||
visitCategoryTree(child, visitor)
|
||||
export function visitCategoryTree(
|
||||
category: TreeCategory,
|
||||
visitor: (category: TreeCategory) => void,
|
||||
options?: {
|
||||
childrenFirst?: boolean
|
||||
}
|
||||
): void {
|
||||
const childrenFirst = options?.childrenFirst
|
||||
|
||||
if (!childrenFirst) visitor(category)
|
||||
|
||||
for (const child of category.children) {
|
||||
visitCategoryTree(child, visitor, options)
|
||||
}
|
||||
|
||||
if (childrenFirst) visitor(category)
|
||||
}
|
||||
|
||||
export function flattenCategoryTree(category: Category): Category[] {
|
||||
export function flattenCategoryTree(category: TreeCategory): TreeCategory[] {
|
||||
const categories: Category[] = []
|
||||
visitCategoryTree(category, c => categories.push(c))
|
||||
return categories
|
||||
}
|
||||
|
||||
export function categoryUnreadCount(category?: Category): number {
|
||||
export function categoryUnreadCount(category?: TreeCategory, maxFrequencyThresholdMs?: number): number {
|
||||
if (!category) return 0
|
||||
|
||||
return flattenCategoryTree(category)
|
||||
.flatMap(c => c.feeds)
|
||||
.filter(f => !maxFrequencyThresholdMs || (f.averageEntryIntervalMs && f.averageEntryIntervalMs >= maxFrequencyThresholdMs))
|
||||
.map(f => f.unread)
|
||||
.reduce((total, current) => total + current, 0)
|
||||
}
|
||||
|
||||
export function categoryHasNewEntries(category?: TreeCategory, maxFrequencyThresholdMs?: number): boolean {
|
||||
if (!category) return false
|
||||
|
||||
return flattenCategoryTree(category)
|
||||
.flatMap(c => c.feeds)
|
||||
.filter(f => !maxFrequencyThresholdMs || (f.averageEntryIntervalMs && f.averageEntryIntervalMs >= maxFrequencyThresholdMs))
|
||||
.some(f => f.hasNewEntries)
|
||||
}
|
||||
|
||||
export const calculatePlaceholderSize = ({ width, height, maxWidth }: { width?: number; height?: number; maxWidth: number }) => {
|
||||
const placeholderWidth = width && Math.min(width, maxWidth)
|
||||
const placeholderHeight = height && width && width > maxWidth ? height * (maxWidth / width) : height
|
||||
|
||||
53
commafeed-client/src/components/ActionButton.test.tsx
Normal file
53
commafeed-client/src/components/ActionButton.test.tsx
Normal file
@@ -0,0 +1,53 @@
|
||||
import type { I18nContext } from "@lingui/react"
|
||||
import { MantineProvider } from "@mantine/core"
|
||||
import { fireEvent, render, screen, waitFor } from "@testing-library/react"
|
||||
import { describe, expect, it, vi } from "vitest"
|
||||
import { useActionButton } from "@/hooks/useActionButton"
|
||||
import { ActionButton } from "./ActionButton"
|
||||
|
||||
vi.mock(import("@lingui/react"), () => ({
|
||||
useLingui: vi.fn().mockReturnValue({
|
||||
_: msg => msg,
|
||||
} as I18nContext),
|
||||
}))
|
||||
vi.mock(import("@/hooks/useActionButton"))
|
||||
|
||||
const label = "Test Label"
|
||||
const icon = "Test Icon"
|
||||
describe("ActionButton", () => {
|
||||
it("renders Button with label on desktop", () => {
|
||||
vi.mocked(useActionButton).mockReturnValue({ mobile: false, spacing: 0 })
|
||||
|
||||
render(<ActionButton label={label} icon={icon} />, {
|
||||
wrapper: MantineProvider,
|
||||
})
|
||||
expect(screen.getByText(label)).toBeInTheDocument()
|
||||
expect(screen.getByText(icon)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it("renders ActionIcon with tooltip on mobile", async () => {
|
||||
vi.mocked(useActionButton).mockReturnValue({ mobile: true, spacing: 0 })
|
||||
|
||||
render(<ActionButton label={label} icon={icon} />, {
|
||||
wrapper: MantineProvider,
|
||||
})
|
||||
expect(screen.queryByText(label)).not.toBeInTheDocument()
|
||||
expect(screen.getByText(icon)).toBeInTheDocument()
|
||||
|
||||
fireEvent.mouseEnter(screen.getByRole("button"))
|
||||
const tooltip = await waitFor(() => screen.getByRole("tooltip"))
|
||||
expect(tooltip).toContainHTML(label)
|
||||
})
|
||||
|
||||
it("calls onClick handler when clicked", () => {
|
||||
vi.mocked(useActionButton).mockReturnValue({ mobile: false, spacing: 0 })
|
||||
const clickListener = vi.fn()
|
||||
|
||||
render(<ActionButton label={label} icon={icon} onClick={clickListener} />, {
|
||||
wrapper: MantineProvider,
|
||||
})
|
||||
fireEvent.click(screen.getByRole("button"))
|
||||
|
||||
expect(clickListener).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -1,14 +1,14 @@
|
||||
import type { MessageDescriptor } from "@lingui/core"
|
||||
import { useLingui } from "@lingui/react"
|
||||
import { ActionIcon, Button, type ButtonVariant, Tooltip, useMantineTheme } from "@mantine/core"
|
||||
import { ActionIcon, Box, Button, type ButtonVariant, Tooltip, useMantineTheme } from "@mantine/core"
|
||||
import type { ActionIconVariant } from "@mantine/core/lib/components/ActionIcon/ActionIcon"
|
||||
import { Constants } from "app/constants"
|
||||
import { useActionButton } from "hooks/useActionButton"
|
||||
import { type MouseEventHandler, type ReactNode, forwardRef } from "react"
|
||||
import { forwardRef, type MouseEventHandler, type ReactNode } from "react"
|
||||
import { Constants } from "@/app/constants"
|
||||
import { useActionButton } from "@/hooks/useActionButton"
|
||||
|
||||
interface ActionButtonProps {
|
||||
icon: ReactNode
|
||||
className?: string
|
||||
icon?: ReactNode
|
||||
label?: string | MessageDescriptor
|
||||
onClick?: MouseEventHandler
|
||||
variant?: ActionIconVariant & ButtonVariant
|
||||
@@ -19,7 +19,7 @@ interface ActionButtonProps {
|
||||
/**
|
||||
* Switches between Button with label (desktop) and ActionIcon (mobile)
|
||||
*/
|
||||
export const ActionButton = forwardRef<HTMLButtonElement, ActionButtonProps>((props: ActionButtonProps, ref) => {
|
||||
export const ActionButton = forwardRef<HTMLDivElement, ActionButtonProps>((props: ActionButtonProps, ref) => {
|
||||
const { mobile } = useActionButton()
|
||||
const theme = useMantineTheme()
|
||||
const { _ } = useLingui()
|
||||
@@ -27,31 +27,36 @@ export const ActionButton = forwardRef<HTMLButtonElement, ActionButtonProps>((pr
|
||||
const label = typeof props.label === "string" ? props.label : props.label && _(props.label)
|
||||
const variant = props.variant ?? "subtle"
|
||||
const iconOnly = (mobile && !props.showLabelOnMobile) || (!mobile && props.hideLabelOnDesktop)
|
||||
return iconOnly ? (
|
||||
<Tooltip label={label} openDelay={Constants.tooltip.delay}>
|
||||
<ActionIcon
|
||||
ref={ref}
|
||||
color={theme.primaryColor}
|
||||
variant={variant}
|
||||
className={props.className}
|
||||
onClick={props.onClick}
|
||||
aria-label={label}
|
||||
>
|
||||
{props.icon}
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
) : (
|
||||
<Button
|
||||
ref={ref}
|
||||
variant={variant}
|
||||
size="xs"
|
||||
className={props.className}
|
||||
leftSection={props.icon}
|
||||
onClick={props.onClick}
|
||||
aria-label={label}
|
||||
>
|
||||
{label}
|
||||
</Button>
|
||||
|
||||
return (
|
||||
<Box ref={ref} className="cf-action-button">
|
||||
{iconOnly && (
|
||||
<Tooltip label={label} openDelay={Constants.tooltip.delay}>
|
||||
<ActionIcon
|
||||
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"
|
||||
|
||||
@@ -10,7 +10,7 @@ export interface ErrorsAlertProps {
|
||||
messages: string[]
|
||||
}
|
||||
|
||||
export function Alert(props: ErrorsAlertProps) {
|
||||
export function Alert(props: Readonly<ErrorsAlertProps>) {
|
||||
let title: React.ReactNode
|
||||
let color: string
|
||||
let icon: React.ReactNode
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { Trans } from "@lingui/react/macro"
|
||||
import { Box, Dialog, Text } from "@mantine/core"
|
||||
import { useAppDispatch, useAppSelector } from "app/store"
|
||||
import { setAnnouncementHash } from "app/user/slice"
|
||||
import { Content } from "components/content/Content"
|
||||
import { useAsync } from "react-async-hook"
|
||||
import { useAppDispatch, useAppSelector } from "@/app/store"
|
||||
import { setAnnouncementHash } from "@/app/user/slice"
|
||||
import { Content } from "@/components/content/Content"
|
||||
|
||||
const sha256Hex = async (input: string | undefined) => {
|
||||
const data = new TextEncoder().encode(input)
|
||||
|
||||
@@ -1,4 +0,0 @@
|
||||
html,
|
||||
body {
|
||||
overscroll-behavior: none;
|
||||
}
|
||||
@@ -1,4 +1,3 @@
|
||||
export const DisablePullToRefresh = () => {
|
||||
import("./DisablePullToRefresh.css")
|
||||
return <></>
|
||||
export const DisablePullToRefresh = ({ enabled }: { enabled: boolean | undefined }) => {
|
||||
return enabled ? <style>{`html, body { overscroll-behavior: none; }`}</style> : null
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { ErrorPage } from "pages/ErrorPage"
|
||||
import React, { type ReactNode } from "react"
|
||||
import { ErrorPage } from "@/pages/ErrorPage"
|
||||
|
||||
interface ErrorBoundaryProps {
|
||||
children?: ReactNode
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Box, Center } from "@mantine/core"
|
||||
import { useState } from "react"
|
||||
import { TbPhoto } from "react-icons/tb"
|
||||
import { tss } from "tss"
|
||||
import { tss } from "@/tss"
|
||||
|
||||
interface ImageWithPlaceholderWhileLoadingProps {
|
||||
src: string
|
||||
@@ -44,7 +44,7 @@ export function ImageWithPlaceholderWhileLoading({
|
||||
title,
|
||||
width,
|
||||
style,
|
||||
}: ImageWithPlaceholderWhileLoadingProps) {
|
||||
}: Readonly<ImageWithPlaceholderWhileLoadingProps>) {
|
||||
const { classes } = useStyles({
|
||||
placeholderWidth,
|
||||
placeholderHeight,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Trans } from "@lingui/react/macro"
|
||||
import { Anchor, Box, Kbd, Stack, Table } from "@mantine/core"
|
||||
import { useOs } from "@mantine/hooks"
|
||||
import { Constants } from "app/constants"
|
||||
import { Constants } from "@/app/constants"
|
||||
|
||||
export function KeyboardShortcutsHelp() {
|
||||
const isMacOS = useOs() === "macos"
|
||||
@@ -33,6 +33,26 @@ export function KeyboardShortcutsHelp() {
|
||||
<Kbd>K</Kbd>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
<Table.Tr>
|
||||
<Table.Td>
|
||||
<Trans>Select next unread feed/category</Trans>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Kbd>Shift</Kbd>
|
||||
<span> + </span>
|
||||
<Kbd>J</Kbd>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
<Table.Tr>
|
||||
<Table.Td>
|
||||
<Trans>Select previous unread feed/category</Trans>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Kbd>Shift</Kbd>
|
||||
<span> + </span>
|
||||
<Kbd>K</Kbd>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
<Table.Tr>
|
||||
<Table.Td>
|
||||
<Trans>Set focus on next entry without opening it</Trans>
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { Image } from "@mantine/core"
|
||||
import logo from "assets/logo.svg"
|
||||
import logo from "@/assets/logo.svg"
|
||||
|
||||
export interface LogoProps {
|
||||
size: number
|
||||
}
|
||||
|
||||
export function Logo(props: LogoProps) {
|
||||
export function Logo(props: Readonly<LogoProps>) {
|
||||
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,10 +1,14 @@
|
||||
import { Trans } from "@lingui/react/macro"
|
||||
import { Tooltip } from "@mantine/core"
|
||||
import { Constants } from "app/constants"
|
||||
import dayjs from "dayjs"
|
||||
import { useNow } from "hooks/useNow"
|
||||
import { Constants } from "@/app/constants"
|
||||
import { useNow } from "@/hooks/useNow"
|
||||
|
||||
export function RelativeDate(props: { date: Date | number | undefined }) {
|
||||
export function RelativeDate(
|
||||
props: Readonly<{
|
||||
date: Date | number | undefined
|
||||
}>
|
||||
) {
|
||||
const now = useNow(60 * 1000)
|
||||
|
||||
if (!props.date) return <Trans>N/A</Trans>
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { Trans } from "@lingui/react/macro"
|
||||
import { Box, Button, Checkbox, Group, PasswordInput, Stack, TextInput } from "@mantine/core"
|
||||
import { useForm } from "@mantine/form"
|
||||
import { client, errorToStrings } from "app/client"
|
||||
import type { AdminSaveUserRequest, UserModel } from "app/types"
|
||||
import { Alert } from "components/Alert"
|
||||
import { useAsyncCallback } from "react-async-hook"
|
||||
import { TbDeviceFloppy } from "react-icons/tb"
|
||||
import { client, errorToStrings } from "@/app/client"
|
||||
import type { AdminSaveUserRequest, UserModel } from "@/app/types"
|
||||
import { Alert } from "@/components/Alert"
|
||||
|
||||
interface UserEditProps {
|
||||
user?: UserModel
|
||||
@@ -13,7 +13,7 @@ interface UserEditProps {
|
||||
onSave: () => void
|
||||
}
|
||||
|
||||
export function UserEdit(props: UserEditProps) {
|
||||
export function UserEdit(props: Readonly<UserEditProps>) {
|
||||
const form = useForm<AdminSaveUserRequest>({
|
||||
initialValues: props.user ?? {
|
||||
name: "",
|
||||
|
||||
@@ -1,16 +1,17 @@
|
||||
import { Input, Textarea } from "@mantine/core"
|
||||
import RichCodeEditor from "components/code/RichCodeEditor"
|
||||
import { useMobile } from "hooks/useMobile"
|
||||
import type { ReactNode } from "react"
|
||||
import RichCodeEditor from "@/components/code/RichCodeEditor"
|
||||
import { useMobile } from "@/hooks/useMobile"
|
||||
|
||||
interface CodeEditorProps {
|
||||
label?: ReactNode
|
||||
description?: ReactNode
|
||||
language: "css" | "javascript"
|
||||
value?: string
|
||||
onChange: (value: string | undefined) => void
|
||||
}
|
||||
|
||||
export function CodeEditor(props: CodeEditorProps) {
|
||||
export function CodeEditor(props: Readonly<CodeEditorProps>) {
|
||||
const mobile = useMobile()
|
||||
|
||||
return mobile ? (
|
||||
@@ -19,6 +20,7 @@ export function CodeEditor(props: CodeEditorProps) {
|
||||
autosize
|
||||
minRows={4}
|
||||
maxRows={15}
|
||||
label={props.label}
|
||||
description={props.description}
|
||||
styles={{
|
||||
input: {
|
||||
@@ -29,7 +31,7 @@ export function CodeEditor(props: CodeEditorProps) {
|
||||
onChange={e => props.onChange(e.currentTarget.value)}
|
||||
/>
|
||||
) : (
|
||||
<Input.Wrapper description={props.description}>
|
||||
<Input.Wrapper label={props.label} description={props.description}>
|
||||
<RichCodeEditor height="30vh" language={props.language} value={props.value} onChange={props.onChange} />
|
||||
</Input.Wrapper>
|
||||
)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Loader } from "components/Loader"
|
||||
import { useColorScheme } from "hooks/useColorScheme"
|
||||
import { useAsync } from "react-async-hook"
|
||||
import { Loader } from "@/components/Loader"
|
||||
import { useColorScheme } from "@/hooks/useColorScheme"
|
||||
|
||||
const init = async () => {
|
||||
window.MonacoEnvironment = {
|
||||
@@ -30,7 +30,7 @@ interface RichCodeEditorProps {
|
||||
onChange: (value: string | undefined) => void
|
||||
}
|
||||
|
||||
function RichCodeEditor(props: RichCodeEditorProps) {
|
||||
function RichCodeEditor(props: Readonly<RichCodeEditorProps>) {
|
||||
const colorScheme = useColorScheme()
|
||||
const editorTheme = colorScheme === "dark" ? "vs-dark" : "light"
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { TypographyStylesProvider } from "@mantine/core"
|
||||
import { Typography } from "@mantine/core"
|
||||
import type { ReactNode } from "react"
|
||||
import { tss } from "tss"
|
||||
import { tss } from "@/tss"
|
||||
|
||||
/**
|
||||
* This component is used to provide basic styles to html typography elements.
|
||||
@@ -20,5 +20,5 @@ const useStyles = tss.create(() => ({
|
||||
|
||||
export const BasicHtmlStyles = (props: { children: ReactNode }) => {
|
||||
const { classes } = useStyles()
|
||||
return <TypographyStylesProvider className={classes.content}>{props.children}</TypographyStylesProvider>
|
||||
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,13 +1,13 @@
|
||||
import { Box, Mark } from "@mantine/core"
|
||||
import { Constants } from "app/constants"
|
||||
import { calculatePlaceholderSize } from "app/utils"
|
||||
import { ImageWithPlaceholderWhileLoading } from "components/ImageWithPlaceholderWhileLoading"
|
||||
import { BasicHtmlStyles } from "components/content/BasicHtmlStyles"
|
||||
import escapeStringRegexp from "escape-string-regexp"
|
||||
import { type ChildrenNode, Interweave, type MatchResponse, Matcher, type Node, type TransformCallback } from "interweave"
|
||||
import { ALLOWED_TAG_LIST, type ChildrenNode, Interweave, Matcher, type MatchResponse, type Node, type TransformCallback } from "interweave"
|
||||
import React from "react"
|
||||
import styleToObject from "style-to-object"
|
||||
import { tss } from "tss"
|
||||
import { Constants } from "@/app/constants"
|
||||
import { calculatePlaceholderSize } from "@/app/utils"
|
||||
import { BasicHtmlStyles } from "@/components/content/BasicHtmlStyles"
|
||||
import { ImageWithPlaceholderWhileLoading } from "@/components/ImageWithPlaceholderWhileLoading"
|
||||
import { tss } from "@/tss"
|
||||
|
||||
export interface ContentProps {
|
||||
content: string
|
||||
@@ -67,20 +67,19 @@ const transform: TransformCallback = node => {
|
||||
}
|
||||
|
||||
class HighlightMatcher extends Matcher {
|
||||
private readonly search: string
|
||||
private readonly regexp: RegExp
|
||||
|
||||
constructor(search: string) {
|
||||
super("highlight")
|
||||
this.search = escapeStringRegexp(search)
|
||||
this.regexp = new RegExp(escapeStringRegexp(search).split(" ").join("|"), "i")
|
||||
}
|
||||
|
||||
match(string: string): MatchResponse<unknown> | null {
|
||||
const pattern = this.search.split(" ").join("|")
|
||||
return this.doMatch(string, new RegExp(pattern, "i"), () => ({}))
|
||||
return this.doMatch(string, this.regexp, () => ({}))
|
||||
}
|
||||
|
||||
replaceWith(children: ChildrenNode): Node {
|
||||
return <Mark>{children}</Mark>
|
||||
return <Mark key={0}>{children}</Mark>
|
||||
}
|
||||
|
||||
asTag(): string {
|
||||
@@ -88,6 +87,9 @@ class HighlightMatcher extends Matcher {
|
||||
}
|
||||
}
|
||||
|
||||
// allow iframe tag
|
||||
const allowList = [...ALLOWED_TAG_LIST, "iframe"]
|
||||
|
||||
// memoize component because Interweave is costly
|
||||
const Content = React.memo((props: ContentProps) => {
|
||||
const { classes } = useStyles()
|
||||
@@ -96,7 +98,7 @@ const Content = React.memo((props: ContentProps) => {
|
||||
return (
|
||||
<BasicHtmlStyles>
|
||||
<Box className={classes.content}>
|
||||
<Interweave content={props.content} transform={transform} matchers={matchers} />
|
||||
<Interweave content={props.content} transform={transform} matchers={matchers} allowList={allowList} />
|
||||
</Box>
|
||||
</BasicHtmlStyles>
|
||||
)
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import { ImageWithPlaceholderWhileLoading } from "components/ImageWithPlaceholderWhileLoading"
|
||||
import { BasicHtmlStyles } from "components/content/BasicHtmlStyles"
|
||||
import { BasicHtmlStyles } from "@/components/content/BasicHtmlStyles"
|
||||
import { ImageWithPlaceholderWhileLoading } from "@/components/ImageWithPlaceholderWhileLoading"
|
||||
|
||||
export function Enclosure(props: {
|
||||
enclosureType: string
|
||||
enclosureUrl: string
|
||||
}) {
|
||||
export function Enclosure(
|
||||
props: Readonly<{
|
||||
enclosureType: string
|
||||
enclosureUrl: string
|
||||
}>
|
||||
) {
|
||||
const hasVideo = props.enclosureType.startsWith("video")
|
||||
const hasAudio = props.enclosureType.startsWith("audio")
|
||||
const hasImage = props.enclosureType.startsWith("image")
|
||||
|
||||
@@ -1,35 +1,34 @@
|
||||
import { Trans } from "@lingui/react/macro"
|
||||
import { Box } from "@mantine/core"
|
||||
import { openModal } from "@mantine/modals"
|
||||
import { Constants } from "app/constants"
|
||||
import type { ExpendableEntry } from "app/entries/slice"
|
||||
import { useEffect } from "react"
|
||||
import { useContextMenu } from "react-contexify"
|
||||
import InfiniteScroll from "react-infinite-scroller"
|
||||
import { throttle } from "throttle-debounce"
|
||||
import { Constants } from "@/app/constants"
|
||||
import type { ExpendableEntry } from "@/app/entries/slice"
|
||||
import {
|
||||
loadMoreEntries,
|
||||
markAllEntries,
|
||||
markAllAsReadWithConfirmationIfRequired,
|
||||
markEntry,
|
||||
reloadEntries,
|
||||
selectEntry,
|
||||
selectNextEntry,
|
||||
selectPreviousEntry,
|
||||
starEntry,
|
||||
} from "app/entries/thunks"
|
||||
import { redirectToRootCategory } from "app/redirect/thunks"
|
||||
import { useAppDispatch, useAppSelector } from "app/store"
|
||||
import { toggleSidebar } from "app/tree/slice"
|
||||
import { KeyboardShortcutsHelp } from "components/KeyboardShortcutsHelp"
|
||||
import { Loader } from "components/Loader"
|
||||
import { useBrowserExtension } from "hooks/useBrowserExtension"
|
||||
import { useMousetrap } from "hooks/useMousetrap"
|
||||
import { useEffect } from "react"
|
||||
import { useContextMenu } from "react-contexify"
|
||||
import InfiniteScroll from "react-infinite-scroller"
|
||||
import { throttle } from "throttle-debounce"
|
||||
} from "@/app/entries/thunks"
|
||||
import { redirectToRootCategory } from "@/app/redirect/thunks"
|
||||
import { useAppDispatch, useAppSelector } from "@/app/store"
|
||||
import { toggleSidebar } from "@/app/tree/slice"
|
||||
import { selectNextUnreadTreeItem } from "@/app/tree/thunks"
|
||||
import { KeyboardShortcutsHelp } from "@/components/KeyboardShortcutsHelp"
|
||||
import { Loader } from "@/components/Loader"
|
||||
import { useBrowserExtension } from "@/hooks/useBrowserExtension"
|
||||
import { useMousetrap } from "@/hooks/useMousetrap"
|
||||
import { FeedEntry } from "./FeedEntry"
|
||||
|
||||
export function FeedEntries() {
|
||||
const source = useAppSelector(state => state.entries.source)
|
||||
const entries = useAppSelector(state => state.entries.entries)
|
||||
const entriesTimestamp = useAppSelector(state => state.entries.timestamp)
|
||||
const selectedEntryId = useAppSelector(state => state.entries.selectedEntryId)
|
||||
const hasMore = useAppSelector(state => state.entries.hasMore)
|
||||
const loading = useAppSelector(state => state.entries.loading)
|
||||
@@ -172,6 +171,8 @@ export function FeedEntries() {
|
||||
})
|
||||
)
|
||||
)
|
||||
useMousetrap("shift+j", async () => await dispatch(selectNextUnreadTreeItem({ direction: "forward" })))
|
||||
useMousetrap("shift+k", async () => await dispatch(selectNextUnreadTreeItem({ direction: "backward" })))
|
||||
useMousetrap("space", () => {
|
||||
if (selectedEntry) {
|
||||
if (selectedEntry.expanded) {
|
||||
@@ -272,17 +273,7 @@ export function FeedEntries() {
|
||||
})
|
||||
useMousetrap("shift+a", () => {
|
||||
// mark all entries as read
|
||||
dispatch(
|
||||
markAllEntries({
|
||||
sourceType: source.type,
|
||||
req: {
|
||||
id: source.id,
|
||||
read: true,
|
||||
olderThan: Date.now(),
|
||||
insertedBefore: entriesTimestamp,
|
||||
},
|
||||
})
|
||||
)
|
||||
dispatch(markAllAsReadWithConfirmationIfRequired())
|
||||
})
|
||||
useMousetrap("g a", async () => await dispatch(redirectToRootCategory()))
|
||||
useMousetrap("f", () => dispatch(toggleSidebar()))
|
||||
@@ -296,33 +287,25 @@ export function FeedEntries() {
|
||||
|
||||
return (
|
||||
<InfiniteScroll
|
||||
id="entries"
|
||||
className={`view-mode-${viewMode}`}
|
||||
className={`cf-entries cf-view-mode-${viewMode}`}
|
||||
initialLoad={false}
|
||||
loadMore={async () => await (!loading && dispatch(loadMoreEntries()))}
|
||||
hasMore={hasMore}
|
||||
loader={<Box key={0}>{loading && <Loader />}</Box>}
|
||||
>
|
||||
{entries.map(entry => (
|
||||
<article
|
||||
<FeedEntry
|
||||
key={entry.id}
|
||||
ref={el => {
|
||||
if (el) el.id = Constants.dom.entryId(entry)
|
||||
}}
|
||||
data-id={entry.id}
|
||||
>
|
||||
<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)}
|
||||
/>
|
||||
</article>
|
||||
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)}
|
||||
/>
|
||||
))}
|
||||
</InfiniteScroll>
|
||||
)
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
import { Box, Divider, type MantineRadius, type MantineSpacing, Paper } from "@mantine/core"
|
||||
import { Constants } from "app/constants"
|
||||
import { useAppSelector } from "app/store"
|
||||
import type { Entry, ViewMode } from "app/types"
|
||||
import { FeedEntryCompactHeader } from "components/content/header/FeedEntryCompactHeader"
|
||||
import { FeedEntryHeader } from "components/content/header/FeedEntryHeader"
|
||||
import { useMobile } from "hooks/useMobile"
|
||||
import type React from "react"
|
||||
import { useSwipeable } from "react-swipeable"
|
||||
import { tss } from "tss"
|
||||
import { Constants } from "@/app/constants"
|
||||
import { useAppSelector } from "@/app/store"
|
||||
import type { Entry, ViewMode } from "@/app/types"
|
||||
import { FeedEntryCompactHeader } from "@/components/content/header/FeedEntryCompactHeader"
|
||||
import { FeedEntryHeader } from "@/components/content/header/FeedEntryHeader"
|
||||
import { useMobile } from "@/hooks/useMobile"
|
||||
import { tss } from "@/tss"
|
||||
import { FeedEntryBody } from "./FeedEntryBody"
|
||||
import { FeedEntryContextMenu } from "./FeedEntryContextMenu"
|
||||
import { FeedEntryFooter } from "./FeedEntryFooter"
|
||||
@@ -32,8 +32,9 @@ const useStyles = tss
|
||||
rtl: boolean
|
||||
showSelectionIndicator: boolean
|
||||
maxWidth?: number
|
||||
fontSizePercentage: number
|
||||
}>()
|
||||
.create(({ theme, colorScheme, read, expanded, viewMode, rtl, showSelectionIndicator, maxWidth }) => {
|
||||
.create(({ theme, colorScheme, read, expanded, viewMode, rtl, showSelectionIndicator, maxWidth, fontSizePercentage }) => {
|
||||
let backgroundColor: string
|
||||
if (colorScheme === "dark") {
|
||||
backgroundColor = read ? "inherit" : theme.colors.dark[5]
|
||||
@@ -83,18 +84,21 @@ const useStyles = tss
|
||||
},
|
||||
},
|
||||
headerLink: {
|
||||
fontSize: `${fontSizePercentage}%`,
|
||||
color: "inherit",
|
||||
textDecoration: "none",
|
||||
},
|
||||
body: {
|
||||
fontSize: `${fontSizePercentage}%`,
|
||||
direction: rtl ? "rtl" : "ltr",
|
||||
maxWidth: maxWidth ?? "100%",
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
export function FeedEntry(props: FeedEntryProps) {
|
||||
export function FeedEntry(props: Readonly<FeedEntryProps>) {
|
||||
const viewMode = useAppSelector(state => state.user.localSettings.viewMode)
|
||||
const fontSizePercentage = useAppSelector(state => state.user.localSettings.fontSizePercentage)
|
||||
const { classes, cx } = useStyles({
|
||||
read: props.entry.read,
|
||||
expanded: props.expanded,
|
||||
@@ -102,6 +106,7 @@ export function FeedEntry(props: FeedEntryProps) {
|
||||
rtl: props.entry.rtl,
|
||||
showSelectionIndicator: props.showSelectionIndicator,
|
||||
maxWidth: props.maxWidth,
|
||||
fontSizePercentage,
|
||||
})
|
||||
|
||||
const externalLinkDisplayMode = useAppSelector(state => state.user.settings?.externalLinkIconDisplayMode)
|
||||
@@ -137,6 +142,10 @@ export function FeedEntry(props: FeedEntryProps) {
|
||||
const compactHeader = !props.expanded && (viewMode === "title" || viewMode === "cozy")
|
||||
return (
|
||||
<Paper
|
||||
component="article"
|
||||
id={Constants.dom.entryId(props.entry)}
|
||||
data-id={props.entry.id}
|
||||
data-feed-id={props.entry.feedId}
|
||||
withBorder
|
||||
radius={borderRadius}
|
||||
className={cx(classes.paper, {
|
||||
@@ -176,10 +185,10 @@ export function FeedEntry(props: FeedEntryProps) {
|
||||
</a>
|
||||
{props.expanded && (
|
||||
<Box px={paddingX} pb={paddingY} onClick={props.onBodyClick}>
|
||||
<Box className={classes.body}>
|
||||
<Box className={`${classes.body} cf-content`}>
|
||||
<FeedEntryBody entry={props.entry} />
|
||||
</Box>
|
||||
<Divider variant="dashed" my={paddingY} />
|
||||
<Divider variant="dashed" my={paddingY} className="cf-footer-divider" />
|
||||
<FeedEntryFooter entry={props.entry} />
|
||||
</Box>
|
||||
)}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Box } from "@mantine/core"
|
||||
import { useAppSelector } from "app/store"
|
||||
import type { Entry } from "app/types"
|
||||
import { useAppSelector } from "@/app/store"
|
||||
import type { Entry } from "@/app/types"
|
||||
import { Content } from "./Content"
|
||||
import { Enclosure } from "./Enclosure"
|
||||
import { Media } from "./Media"
|
||||
@@ -9,7 +9,7 @@ export interface FeedEntryBodyProps {
|
||||
entry: Entry
|
||||
}
|
||||
|
||||
export function FeedEntryBody(props: FeedEntryBodyProps) {
|
||||
export function FeedEntryBody(props: Readonly<FeedEntryBodyProps>) {
|
||||
const search = useAppSelector(state => state.entries.search)
|
||||
return (
|
||||
<Box>
|
||||
|
||||
@@ -1,16 +1,16 @@
|
||||
import { Trans } from "@lingui/react/macro"
|
||||
import { Group } from "@mantine/core"
|
||||
import { Constants } from "app/constants"
|
||||
import { markEntriesUpToEntry, markEntry, starEntry } from "app/entries/thunks"
|
||||
import { redirectToFeed } from "app/redirect/thunks"
|
||||
import { useAppDispatch, useAppSelector } from "app/store"
|
||||
import type { Entry } from "app/types"
|
||||
import { truncate } from "app/utils"
|
||||
import { useBrowserExtension } from "hooks/useBrowserExtension"
|
||||
import { useColorScheme } from "hooks/useColorScheme"
|
||||
import { Item, Menu, Separator } from "react-contexify"
|
||||
import { TbArrowBarToDown, TbExternalLink, TbMail, TbMailOpened, TbRss, TbStar, TbStarOff } from "react-icons/tb"
|
||||
import { tss } from "tss"
|
||||
import { Constants } from "@/app/constants"
|
||||
import { markEntriesUpToEntry, markEntry, starEntry } from "@/app/entries/thunks"
|
||||
import { redirectToFeed } from "@/app/redirect/thunks"
|
||||
import { useAppDispatch, useAppSelector } from "@/app/store"
|
||||
import type { Entry } from "@/app/types"
|
||||
import { truncate } from "@/app/utils"
|
||||
import { useBrowserExtension } from "@/hooks/useBrowserExtension"
|
||||
import { useColorScheme } from "@/hooks/useColorScheme"
|
||||
import { tss } from "@/tss"
|
||||
|
||||
interface FeedEntryContextMenuProps {
|
||||
entry: Entry
|
||||
@@ -27,7 +27,7 @@ const useStyles = tss.create(({ theme, colorScheme }) => ({
|
||||
},
|
||||
}))
|
||||
|
||||
export function FeedEntryContextMenu(props: FeedEntryContextMenuProps) {
|
||||
export function FeedEntryContextMenu(props: Readonly<FeedEntryContextMenuProps>) {
|
||||
const colorScheme = useColorScheme()
|
||||
const { classes } = useStyles()
|
||||
const sourceType = useAppSelector(state => state.entries.source.type)
|
||||
@@ -61,19 +61,21 @@ export function FeedEntryContextMenu(props: FeedEntryContextMenuProps) {
|
||||
|
||||
<Separator />
|
||||
|
||||
<Item onClick={async () => await dispatch(starEntry({ entry: props.entry, starred: !props.entry.starred }))}>
|
||||
<Group>
|
||||
{props.entry.starred ? <TbStarOff size={iconSize} /> : <TbStar size={iconSize} />}
|
||||
{props.entry.starred ? <Trans>Unstar</Trans> : <Trans>Star</Trans>}
|
||||
</Group>
|
||||
</Item>
|
||||
{props.entry.markable && (
|
||||
<Item onClick={async () => await dispatch(markEntry({ entry: props.entry, read: !props.entry.read }))}>
|
||||
<Group>
|
||||
{props.entry.read ? <TbMail size={iconSize} /> : <TbMailOpened size={iconSize} />}
|
||||
{props.entry.read ? <Trans>Keep unread</Trans> : <Trans>Mark as read</Trans>}
|
||||
</Group>
|
||||
</Item>
|
||||
<>
|
||||
<Item onClick={async () => await dispatch(starEntry({ entry: props.entry, starred: !props.entry.starred }))}>
|
||||
<Group>
|
||||
{props.entry.starred ? <TbStarOff size={iconSize} /> : <TbStar size={iconSize} />}
|
||||
{props.entry.starred ? <Trans>Unstar</Trans> : <Trans>Star</Trans>}
|
||||
</Group>
|
||||
</Item>
|
||||
<Item onClick={async () => await dispatch(markEntry({ entry: props.entry, read: !props.entry.read }))}>
|
||||
<Group>
|
||||
{props.entry.read ? <TbMail size={iconSize} /> : <TbMailOpened size={iconSize} />}
|
||||
{props.entry.read ? <Trans>Keep unread</Trans> : <Trans>Mark as read</Trans>}
|
||||
</Group>
|
||||
</Item>
|
||||
</>
|
||||
)}
|
||||
<Item onClick={async () => await dispatch(markEntriesUpToEntry(props.entry))}>
|
||||
<Group>
|
||||
|
||||
@@ -1,20 +1,20 @@
|
||||
import { msg } from "@lingui/core/macro"
|
||||
import { useLingui } from "@lingui/react"
|
||||
import { Group, Indicator, Popover, TagsInput } from "@mantine/core"
|
||||
import { markEntriesUpToEntry, markEntry, starEntry, tagEntry } from "app/entries/thunks"
|
||||
import { useAppDispatch, useAppSelector } from "app/store"
|
||||
import type { Entry } from "app/types"
|
||||
import { ActionButton } from "components/ActionButton"
|
||||
import { useActionButton } from "hooks/useActionButton"
|
||||
import { useMobile } from "hooks/useMobile"
|
||||
import { TbArrowBarToDown, TbExternalLink, TbMail, TbMailOpened, TbShare, TbStar, TbStarOff, TbTag } from "react-icons/tb"
|
||||
import { markEntriesUpToEntry, markEntry, starEntry, tagEntry } from "@/app/entries/thunks"
|
||||
import { useAppDispatch, useAppSelector } from "@/app/store"
|
||||
import type { Entry } from "@/app/types"
|
||||
import { ActionButton } from "@/components/ActionButton"
|
||||
import { useActionButton } from "@/hooks/useActionButton"
|
||||
import { useMobile } from "@/hooks/useMobile"
|
||||
import { ShareButtons } from "./ShareButtons"
|
||||
|
||||
interface FeedEntryFooterProps {
|
||||
entry: Entry
|
||||
}
|
||||
|
||||
export function FeedEntryFooter(props: FeedEntryFooterProps) {
|
||||
export function FeedEntryFooter(props: Readonly<FeedEntryFooterProps>) {
|
||||
const tags = useAppSelector(state => state.user.tags)
|
||||
const mobile = useMobile()
|
||||
const { spacing } = useActionButton()
|
||||
@@ -37,7 +37,7 @@ export function FeedEntryFooter(props: FeedEntryFooterProps) {
|
||||
)
|
||||
|
||||
return (
|
||||
<Group justify="space-between">
|
||||
<Group justify="space-between" className="cf-footer">
|
||||
<Group gap={spacing}>
|
||||
{props.entry.markable && (
|
||||
<ActionButton
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { ImageWithPlaceholderWhileLoading } from "components/ImageWithPlaceholderWhileLoading"
|
||||
import { ImageWithPlaceholderWhileLoading } from "@/components/ImageWithPlaceholderWhileLoading"
|
||||
|
||||
export interface FeedFaviconProps {
|
||||
url: string
|
||||
size?: number
|
||||
}
|
||||
|
||||
export function FeedFavicon({ url, size = 18 }: FeedFaviconProps) {
|
||||
export function FeedFavicon({ url, size = 18 }: Readonly<FeedFaviconProps>) {
|
||||
return (
|
||||
<ImageWithPlaceholderWhileLoading
|
||||
src={url}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { Box } from "@mantine/core"
|
||||
import { Constants } from "app/constants"
|
||||
import { calculatePlaceholderSize } from "app/utils"
|
||||
import { ImageWithPlaceholderWhileLoading } from "components/ImageWithPlaceholderWhileLoading"
|
||||
import { BasicHtmlStyles } from "components/content/BasicHtmlStyles"
|
||||
import { Constants } from "@/app/constants"
|
||||
import { calculatePlaceholderSize } from "@/app/utils"
|
||||
import { BasicHtmlStyles } from "@/components/content/BasicHtmlStyles"
|
||||
import { ImageWithPlaceholderWhileLoading } from "@/components/ImageWithPlaceholderWhileLoading"
|
||||
import { Content } from "./Content"
|
||||
|
||||
export interface MediaProps {
|
||||
@@ -12,7 +12,7 @@ export interface MediaProps {
|
||||
description?: string
|
||||
}
|
||||
|
||||
export function Media(props: MediaProps) {
|
||||
export function Media(props: Readonly<MediaProps>) {
|
||||
const width = props.thumbnailWidth
|
||||
const height = props.thumbnailHeight
|
||||
const placeholderSize = calculatePlaceholderSize({
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
import { Trans } from "@lingui/react/macro"
|
||||
import { ActionIcon, Box, CopyButton, Divider, SimpleGrid } from "@mantine/core"
|
||||
import { Constants } from "app/constants"
|
||||
import { useAppSelector } from "app/store"
|
||||
import type { SharingSettings } from "app/types"
|
||||
import { useBrowserExtension } from "hooks/useBrowserExtension"
|
||||
import { useMobile } from "hooks/useMobile"
|
||||
import type { IconType } from "react-icons"
|
||||
import { TbCheck, TbCopy, TbDeviceDesktopShare, TbDeviceMobileShare } from "react-icons/tb"
|
||||
import { tss } from "tss"
|
||||
import { Constants } from "@/app/constants"
|
||||
import { useAppSelector } from "@/app/store"
|
||||
import type { SharingSettings } from "@/app/types"
|
||||
import { useBrowserExtension } from "@/hooks/useBrowserExtension"
|
||||
import { useMobile } from "@/hooks/useMobile"
|
||||
import { tss } from "@/tss"
|
||||
|
||||
type Color = `#${string}`
|
||||
|
||||
@@ -22,7 +22,15 @@ const useStyles = tss
|
||||
},
|
||||
}))
|
||||
|
||||
function ShareButton({ icon, color, onClick }: { icon: IconType; color: Color; onClick: () => void }) {
|
||||
function ShareButton({
|
||||
icon,
|
||||
color,
|
||||
onClick,
|
||||
}: Readonly<{
|
||||
icon: IconType
|
||||
color: Color
|
||||
onClick: () => void
|
||||
}>) {
|
||||
const { classes } = useStyles({
|
||||
color,
|
||||
})
|
||||
@@ -36,7 +44,15 @@ function ShareButton({ icon, color, onClick }: { icon: IconType; color: Color; o
|
||||
)
|
||||
}
|
||||
|
||||
function SiteShareButton({ url, icon, color }: { icon: IconType; color: Color; url: string }) {
|
||||
function SiteShareButton({
|
||||
url,
|
||||
icon,
|
||||
color,
|
||||
}: Readonly<{
|
||||
icon: IconType
|
||||
color: Color
|
||||
url: string
|
||||
}>) {
|
||||
const onClick = () => {
|
||||
window.open(url, "", "menubar=no,toolbar=no,resizable=yes,scrollbars=yes,width=800,height=600")
|
||||
}
|
||||
@@ -44,7 +60,11 @@ function SiteShareButton({ url, icon, color }: { icon: IconType; color: Color; u
|
||||
return <ShareButton icon={icon} color={color} onClick={onClick} />
|
||||
}
|
||||
|
||||
function CopyUrlButton({ url }: { url: string }) {
|
||||
function CopyUrlButton({
|
||||
url,
|
||||
}: Readonly<{
|
||||
url: string
|
||||
}>) {
|
||||
return (
|
||||
<CopyButton value={url}>
|
||||
{({ copied, copy }) => <ShareButton icon={copied ? TbCheck : TbCopy} color="#000" onClick={copy} />}
|
||||
@@ -52,7 +72,13 @@ function CopyUrlButton({ url }: { url: string }) {
|
||||
)
|
||||
}
|
||||
|
||||
function BrowserNativeShareButton({ url, description }: { url: string; description: string }) {
|
||||
function BrowserNativeShareButton({
|
||||
url,
|
||||
description,
|
||||
}: Readonly<{
|
||||
url: string
|
||||
description: string
|
||||
}>) {
|
||||
const mobile = useMobile()
|
||||
const { isBrowserExtensionPopup } = useBrowserExtension()
|
||||
const onClick = () => {
|
||||
@@ -71,7 +97,12 @@ function BrowserNativeShareButton({ url, description }: { url: string; descripti
|
||||
)
|
||||
}
|
||||
|
||||
export function ShareButtons(props: { url: string; description: string }) {
|
||||
export function ShareButtons(
|
||||
props: Readonly<{
|
||||
url: string
|
||||
description: string
|
||||
}>
|
||||
) {
|
||||
const sharingSettings = useAppSelector(state => state.user.settings?.sharingSettings)
|
||||
const enabledSharingSites = (Object.keys(Constants.sharing) as Array<keyof SharingSettings>).filter(site => sharingSettings?.[site])
|
||||
const url = encodeURIComponent(props.url)
|
||||
|
||||
@@ -3,14 +3,14 @@ import { useLingui } from "@lingui/react"
|
||||
import { Trans } from "@lingui/react/macro"
|
||||
import { Box, Button, Group, Stack, TextInput } from "@mantine/core"
|
||||
import { useForm } from "@mantine/form"
|
||||
import { client, errorToStrings } from "app/client"
|
||||
import { redirectToSelectedSource } from "app/redirect/thunks"
|
||||
import { useAppDispatch } from "app/store"
|
||||
import { reloadTree } from "app/tree/thunks"
|
||||
import type { AddCategoryRequest } from "app/types"
|
||||
import { Alert } from "components/Alert"
|
||||
import { useAsyncCallback } from "react-async-hook"
|
||||
import { TbFolderPlus } from "react-icons/tb"
|
||||
import { client, errorToStrings } from "@/app/client"
|
||||
import { redirectToSelectedSource } from "@/app/redirect/thunks"
|
||||
import { useAppDispatch } from "@/app/store"
|
||||
import { reloadTree } from "@/app/tree/thunks"
|
||||
import type { AddCategoryRequest } from "@/app/types"
|
||||
import { Alert } from "@/components/Alert"
|
||||
import { CategorySelect } from "./CategorySelect"
|
||||
|
||||
export function AddCategory() {
|
||||
|
||||
@@ -2,10 +2,10 @@ import { msg } from "@lingui/core/macro"
|
||||
import { useLingui } from "@lingui/react"
|
||||
import { Select, type SelectProps } from "@mantine/core"
|
||||
import type { ComboboxItem } from "@mantine/core/lib/components/Combobox/Combobox.types"
|
||||
import { Constants } from "app/constants"
|
||||
import { useAppSelector } from "app/store"
|
||||
import type { Category } from "app/types"
|
||||
import { flattenCategoryTree } from "app/utils"
|
||||
import { Constants } from "@/app/constants"
|
||||
import { useAppSelector } from "@/app/store"
|
||||
import type { Category } from "@/app/types"
|
||||
import { flattenCategoryTree } from "@/app/utils"
|
||||
|
||||
type CategorySelectProps = Partial<SelectProps> & {
|
||||
withAll?: boolean
|
||||
|
||||
@@ -3,13 +3,13 @@ import { useLingui } from "@lingui/react"
|
||||
import { Trans } from "@lingui/react/macro"
|
||||
import { Box, Button, FileInput, Group, Stack } from "@mantine/core"
|
||||
import { isNotEmpty, useForm } from "@mantine/form"
|
||||
import { client, errorToStrings } from "app/client"
|
||||
import { redirectToSelectedSource } from "app/redirect/thunks"
|
||||
import { useAppDispatch } from "app/store"
|
||||
import { reloadTree } from "app/tree/thunks"
|
||||
import { Alert } from "components/Alert"
|
||||
import { useAsyncCallback } from "react-async-hook"
|
||||
import { TbFileImport } from "react-icons/tb"
|
||||
import { client, errorToStrings } from "@/app/client"
|
||||
import { redirectToSelectedSource } from "@/app/redirect/thunks"
|
||||
import { useAppDispatch } from "@/app/store"
|
||||
import { reloadTree } from "@/app/tree/thunks"
|
||||
import { Alert } from "@/components/Alert"
|
||||
|
||||
export function ImportOpml() {
|
||||
const dispatch = useAppDispatch()
|
||||
|
||||
@@ -1,16 +1,16 @@
|
||||
import { Trans } from "@lingui/react/macro"
|
||||
import { Box, Button, Group, Stack, Stepper, TextInput } from "@mantine/core"
|
||||
import { useForm } from "@mantine/form"
|
||||
import { client, errorToStrings } from "app/client"
|
||||
import { Constants } from "app/constants"
|
||||
import { redirectToFeed, redirectToSelectedSource } from "app/redirect/thunks"
|
||||
import { useAppDispatch } from "app/store"
|
||||
import { reloadTree } from "app/tree/thunks"
|
||||
import type { FeedInfoRequest, SubscribeRequest } from "app/types"
|
||||
import { Alert } from "components/Alert"
|
||||
import { useState } from "react"
|
||||
import { useAsyncCallback } from "react-async-hook"
|
||||
import { TbRss } from "react-icons/tb"
|
||||
import { client, errorToStrings } from "@/app/client"
|
||||
import { Constants } from "@/app/constants"
|
||||
import { redirectToFeed, redirectToSelectedSource } from "@/app/redirect/thunks"
|
||||
import { useAppDispatch } from "@/app/store"
|
||||
import { reloadTree } from "@/app/tree/thunks"
|
||||
import type { FeedInfoRequest, SubscribeRequest } from "@/app/types"
|
||||
import { Alert } from "@/components/Alert"
|
||||
import { CategorySelect } from "./CategorySelect"
|
||||
|
||||
export function Subscribe() {
|
||||
@@ -39,9 +39,8 @@ export function Subscribe() {
|
||||
},
|
||||
})
|
||||
const subscribe = useAsyncCallback(client.feed.subscribe, {
|
||||
onSuccess: async sub => {
|
||||
await dispatch(reloadTree())
|
||||
dispatch(redirectToFeed(sub.data))
|
||||
onSuccess: sub => {
|
||||
dispatch(reloadTree()).then(() => dispatch(redirectToFeed(sub.data)))
|
||||
},
|
||||
})
|
||||
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
import { Stack } from "@mantine/core"
|
||||
import { MantineValueSelector, QueryBuilderMantine } from "@react-querybuilder/mantine"
|
||||
import {
|
||||
type CombinatorSelectorProps,
|
||||
defaultOperators,
|
||||
defaultRuleProcessorCEL,
|
||||
type Field,
|
||||
type FormatQueryOptions,
|
||||
formatQuery,
|
||||
QueryBuilder,
|
||||
type RuleGroupType,
|
||||
} from "react-querybuilder"
|
||||
import { isCELIdentifier, isCELMember, isCELStringLiteral, parseCEL } from "react-querybuilder/parseCEL"
|
||||
import "react-querybuilder/dist/query-builder.css"
|
||||
|
||||
const fields: Field[] = [
|
||||
{ name: "title", label: "Title" },
|
||||
{ name: "content", label: "Content" },
|
||||
{ name: "url", label: "URL" },
|
||||
{ name: "author", label: "Author" },
|
||||
{ name: "categories", label: "Categories" },
|
||||
{ name: "titleLower", label: "Title (lower case)" },
|
||||
{ name: "contentLower", label: "Content (lower case)" },
|
||||
{ name: "urlLower", label: "URL (lower case)" },
|
||||
{ name: "authorLower", label: "Author (lower case)" },
|
||||
{ name: "categoriesLower", label: "Categories (lower case)" },
|
||||
]
|
||||
|
||||
const textOperators = new Set(["=", "!=", "contains", "beginsWith", "endsWith", "doesNotContain", "doesNotBeginWith", "doesNotEndWith"])
|
||||
|
||||
function toCelString(query: RuleGroupType): string {
|
||||
if (query.rules.length === 0) {
|
||||
return ""
|
||||
}
|
||||
|
||||
const celFormatOptions: FormatQueryOptions = {
|
||||
format: "cel",
|
||||
ruleProcessor: (rule, options, meta) => {
|
||||
if (rule.operator === "matches") {
|
||||
const escapedValue = String(rule.value).replaceAll("\\", "\\\\").replaceAll('"', String.raw`\"`)
|
||||
return `${rule.field}.matches("${escapedValue}")`
|
||||
}
|
||||
|
||||
return defaultRuleProcessorCEL(rule, options, meta)
|
||||
},
|
||||
}
|
||||
|
||||
return formatQuery(query, celFormatOptions)
|
||||
}
|
||||
|
||||
function fromCelString(celString: string): RuleGroupType {
|
||||
return parseCEL(celString ?? "", {
|
||||
customExpressionHandler: expr => {
|
||||
if (
|
||||
isCELMember(expr) &&
|
||||
expr.right?.value === "matches" &&
|
||||
expr.left &&
|
||||
isCELIdentifier(expr.left) &&
|
||||
expr.list &&
|
||||
isCELStringLiteral(expr.list.value[0])
|
||||
) {
|
||||
return {
|
||||
field: expr.left.value,
|
||||
operator: "matches",
|
||||
value: JSON.parse(expr.list.value[0].value),
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
const getOperators = () => {
|
||||
const filteredDefault = defaultOperators.filter(op => textOperators.has(op.name))
|
||||
return [
|
||||
...filteredDefault,
|
||||
{
|
||||
name: "matches",
|
||||
label: "matches pattern",
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
function CombinatorSelector(props: Readonly<CombinatorSelectorProps>) {
|
||||
if (props.rules.length === 0) {
|
||||
return null
|
||||
}
|
||||
return <MantineValueSelector {...props} />
|
||||
}
|
||||
|
||||
interface FilteringExpressionEditorProps {
|
||||
initialValue: string | undefined
|
||||
onChange: (value: string) => void
|
||||
}
|
||||
|
||||
export function FilteringExpressionEditor({ initialValue, onChange }: Readonly<FilteringExpressionEditorProps>) {
|
||||
const handleQueryChange = (newQuery: RuleGroupType) => {
|
||||
onChange(toCelString(newQuery))
|
||||
}
|
||||
|
||||
return (
|
||||
<Stack gap="sm">
|
||||
<QueryBuilderMantine>
|
||||
<QueryBuilder
|
||||
fields={fields}
|
||||
defaultQuery={fromCelString(initialValue ?? "")}
|
||||
onQueryChange={handleQueryChange}
|
||||
getOperators={getOperators}
|
||||
addRuleToNewGroups
|
||||
resetOnFieldChange={false}
|
||||
controlClassnames={{ queryBuilder: "queryBuilder-branches" }}
|
||||
controlElements={{ combinatorSelector: CombinatorSelector }}
|
||||
/>
|
||||
</QueryBuilderMantine>
|
||||
</Stack>
|
||||
)
|
||||
}
|
||||
@@ -1,11 +1,11 @@
|
||||
import { Box, Text } from "@mantine/core"
|
||||
import type { Entry } from "app/types"
|
||||
import { RelativeDate } from "components/RelativeDate"
|
||||
import { FeedFavicon } from "components/content/FeedFavicon"
|
||||
import { OpenExternalLink } from "components/content/header/OpenExternalLink"
|
||||
import { Star } from "components/content/header/Star"
|
||||
import { OnDesktop } from "components/responsive/OnDesktop"
|
||||
import { tss } from "tss"
|
||||
import { Box } 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 { OnDesktop } from "@/components/responsive/OnDesktop"
|
||||
import { tss } from "@/tss"
|
||||
import { FeedEntryTitle } from "./FeedEntryTitle"
|
||||
|
||||
export interface FeedEntryHeaderProps {
|
||||
@@ -43,7 +43,7 @@ const useStyles = tss
|
||||
},
|
||||
}))
|
||||
|
||||
export function FeedEntryCompactHeader(props: FeedEntryHeaderProps) {
|
||||
export function FeedEntryCompactHeader(props: Readonly<FeedEntryHeaderProps>) {
|
||||
const { classes } = useStyles({
|
||||
read: props.entry.read,
|
||||
})
|
||||
@@ -54,17 +54,17 @@ export function FeedEntryCompactHeader(props: FeedEntryHeaderProps) {
|
||||
<FeedFavicon url={props.entry.iconUrl} />
|
||||
</Box>
|
||||
<OnDesktop>
|
||||
<Text c="dimmed" className={classes.feedName}>
|
||||
<Box c="dimmed" className={classes.feedName}>
|
||||
{props.entry.feedName}
|
||||
</Text>
|
||||
</Box>
|
||||
</OnDesktop>
|
||||
<Box className={classes.title}>
|
||||
<FeedEntryTitle entry={props.entry} />
|
||||
</Box>
|
||||
<OnDesktop>
|
||||
<Text c="dimmed" className={classes.date}>
|
||||
<Box c="dimmed" className={classes.date}>
|
||||
<RelativeDate date={props.entry.date} />
|
||||
</Text>
|
||||
</Box>
|
||||
</OnDesktop>
|
||||
{props.showExternalLinkIcon && <OpenExternalLink entry={props.entry} />}
|
||||
</Box>
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { Box, Flex, Space, Text } from "@mantine/core"
|
||||
import type { Entry } from "app/types"
|
||||
import { RelativeDate } from "components/RelativeDate"
|
||||
import { FeedFavicon } from "components/content/FeedFavicon"
|
||||
import { OpenExternalLink } from "components/content/header/OpenExternalLink"
|
||||
import { Star } from "components/content/header/Star"
|
||||
import { tss } from "tss"
|
||||
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 {
|
||||
@@ -22,18 +22,15 @@ const useStyles = tss
|
||||
main: {
|
||||
fontWeight: colorScheme === "light" && !read ? "bold" : "inherit",
|
||||
},
|
||||
details: {
|
||||
fontSize: "90%",
|
||||
},
|
||||
}))
|
||||
|
||||
export function FeedEntryHeader(props: FeedEntryHeaderProps) {
|
||||
export function FeedEntryHeader(props: Readonly<FeedEntryHeaderProps>) {
|
||||
const { classes } = useStyles({
|
||||
read: props.entry.read,
|
||||
})
|
||||
return (
|
||||
<Box>
|
||||
<Flex align="flex-start" justify="space-between">
|
||||
<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}>
|
||||
@@ -44,22 +41,20 @@ export function FeedEntryHeader(props: FeedEntryHeaderProps) {
|
||||
</Flex>
|
||||
{props.showExternalLinkIcon && <OpenExternalLink entry={props.entry} />}
|
||||
</Flex>
|
||||
<Flex align="center" className={classes.details}>
|
||||
<Flex align="center" className="cf-header-subtitle">
|
||||
<FeedFavicon url={props.entry.iconUrl} />
|
||||
<Space w={6} />
|
||||
<Text c="dimmed">
|
||||
<Box c="dimmed">
|
||||
{props.entry.feedName}
|
||||
<span> · </span>
|
||||
<RelativeDate date={props.entry.date} />
|
||||
</Text>
|
||||
</Box>
|
||||
</Flex>
|
||||
{props.expanded && (
|
||||
<Box className={classes.details}>
|
||||
<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 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,12 +1,12 @@
|
||||
import { Highlight } from "@mantine/core"
|
||||
import { useAppSelector } from "app/store"
|
||||
import type { Entry } from "app/types"
|
||||
import { useAppSelector } from "@/app/store"
|
||||
import type { Entry } from "@/app/types"
|
||||
|
||||
export interface FeedEntryTitleProps {
|
||||
entry: Entry
|
||||
}
|
||||
|
||||
export function FeedEntryTitle(props: FeedEntryTitleProps) {
|
||||
export function FeedEntryTitle(props: Readonly<FeedEntryTitleProps>) {
|
||||
const search = useAppSelector(state => state.entries.search)
|
||||
const keywords = search?.split(" ")
|
||||
return (
|
||||
|
||||
@@ -1,12 +1,16 @@
|
||||
import { Trans } from "@lingui/react/macro"
|
||||
import { ActionIcon, Anchor, Tooltip } from "@mantine/core"
|
||||
import { Constants } from "app/constants"
|
||||
import { markEntry } from "app/entries/thunks"
|
||||
import { useAppDispatch } from "app/store"
|
||||
import type { Entry } from "app/types"
|
||||
import { TbExternalLink } from "react-icons/tb"
|
||||
import { Constants } from "@/app/constants"
|
||||
import { markEntry } from "@/app/entries/thunks"
|
||||
import { useAppDispatch } from "@/app/store"
|
||||
import type { Entry } from "@/app/types"
|
||||
|
||||
export function OpenExternalLink(props: { entry: Entry }) {
|
||||
export function OpenExternalLink(
|
||||
props: Readonly<{
|
||||
entry: Entry
|
||||
}>
|
||||
) {
|
||||
const dispatch = useAppDispatch()
|
||||
const onClick = (e: React.MouseEvent) => {
|
||||
e.stopPropagation()
|
||||
|
||||
@@ -1,12 +1,16 @@
|
||||
import { Trans } from "@lingui/react/macro"
|
||||
import { ActionIcon, Tooltip } from "@mantine/core"
|
||||
import { Constants } from "app/constants"
|
||||
import { starEntry } from "app/entries/thunks"
|
||||
import { useAppDispatch } from "app/store"
|
||||
import type { Entry } from "app/types"
|
||||
import { TbStar, TbStarFilled } from "react-icons/tb"
|
||||
import { Constants } from "@/app/constants"
|
||||
import { starEntry } from "@/app/entries/thunks"
|
||||
import { useAppDispatch } from "@/app/store"
|
||||
import type { Entry } from "@/app/types"
|
||||
|
||||
export function Star(props: { entry: Entry }) {
|
||||
export function Star(
|
||||
props: Readonly<{
|
||||
entry: Entry
|
||||
}>
|
||||
) {
|
||||
const dispatch = useAppDispatch()
|
||||
const onClick = (e: React.MouseEvent) => {
|
||||
e.stopPropagation()
|
||||
|
||||
@@ -2,18 +2,11 @@ import { msg } from "@lingui/core/macro"
|
||||
import { useLingui } from "@lingui/react"
|
||||
import { Box, Center, CloseButton, Divider, Group, Indicator, Popover, TextInput } from "@mantine/core"
|
||||
import { useForm } from "@mantine/form"
|
||||
import { reloadEntries, search, selectNextEntry, selectPreviousEntry } from "app/entries/thunks"
|
||||
import { useAppDispatch, useAppSelector } from "app/store"
|
||||
import { changeReadingMode, changeReadingOrder } from "app/user/thunks"
|
||||
import { ActionButton } from "components/ActionButton"
|
||||
import { Loader } from "components/Loader"
|
||||
import { useActionButton } from "hooks/useActionButton"
|
||||
import { useBrowserExtension } from "hooks/useBrowserExtension"
|
||||
import { useMobile } from "hooks/useMobile"
|
||||
import { useEffect } from "react"
|
||||
import {
|
||||
TbArrowDown,
|
||||
TbArrowUp,
|
||||
TbChecks,
|
||||
TbExternalLink,
|
||||
TbEye,
|
||||
TbEyeOff,
|
||||
@@ -24,7 +17,14 @@ import {
|
||||
TbSortDescending,
|
||||
TbUser,
|
||||
} from "react-icons/tb"
|
||||
import { MarkAllAsReadButton } from "./MarkAllAsReadButton"
|
||||
import { markAllAsReadWithConfirmationIfRequired, reloadEntries, search, selectNextEntry, selectPreviousEntry } from "@/app/entries/thunks"
|
||||
import { useAppDispatch, useAppSelector } from "@/app/store"
|
||||
import { changeReadingMode, changeReadingOrder } from "@/app/user/thunks"
|
||||
import { ActionButton } from "@/components/ActionButton"
|
||||
import { Loader } from "@/components/Loader"
|
||||
import { useActionButton } from "@/hooks/useActionButton"
|
||||
import { useBrowserExtension } from "@/hooks/useBrowserExtension"
|
||||
import { useMobile } from "@/hooks/useMobile"
|
||||
import { ProfileMenu } from "./ProfileMenu"
|
||||
|
||||
function HeaderDivider() {
|
||||
@@ -42,11 +42,14 @@ function HeaderToolbar(props: { children: React.ReactNode }) {
|
||||
display: "flex",
|
||||
justifyContent: "space-between",
|
||||
}}
|
||||
className="cf-toolbar"
|
||||
>
|
||||
{props.children}
|
||||
</Box>
|
||||
) : (
|
||||
<Group gap={spacing}>{props.children}</Group>
|
||||
<Group gap={spacing} className="cf-toolbar">
|
||||
{props.children}
|
||||
</Group>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -60,11 +63,7 @@ export function Header() {
|
||||
const dispatch = useAppDispatch()
|
||||
const { _ } = useLingui()
|
||||
|
||||
const searchForm = useForm<{ search: string }>({
|
||||
validate: {
|
||||
search: value => (value.length > 0 && value.length < 3 ? _(msg`Search requires at least 3 characters`) : null),
|
||||
},
|
||||
})
|
||||
const searchForm = useForm<{ search: string }>()
|
||||
const { setValues } = searchForm
|
||||
|
||||
useEffect(() => {
|
||||
@@ -75,7 +74,7 @@ export function Header() {
|
||||
|
||||
if (!settings) return <Loader />
|
||||
return (
|
||||
<Center>
|
||||
<Center className="cf-toolbar-wrapper">
|
||||
<HeaderToolbar>
|
||||
<ActionButton
|
||||
icon={<TbArrowUp size={iconSize} />}
|
||||
@@ -111,7 +110,11 @@ export function Header() {
|
||||
label={msg`Refresh`}
|
||||
onClick={async () => await dispatch(reloadEntries())}
|
||||
/>
|
||||
<MarkAllAsReadButton iconSize={iconSize} />
|
||||
<ActionButton
|
||||
icon={<TbChecks size={iconSize} />}
|
||||
label={msg`Mark all as read`}
|
||||
onClick={() => dispatch(markAllAsReadWithConfirmationIfRequired())}
|
||||
/>
|
||||
|
||||
<HeaderDivider />
|
||||
|
||||
|
||||
@@ -1,98 +0,0 @@
|
||||
import { msg } from "@lingui/core/macro"
|
||||
import { Trans } from "@lingui/react/macro"
|
||||
|
||||
import { Button, Code, Group, Modal, Slider, Stack, Text } from "@mantine/core"
|
||||
import { markAllEntries } from "app/entries/thunks"
|
||||
import { useAppDispatch, useAppSelector } from "app/store"
|
||||
import { ActionButton } from "components/ActionButton"
|
||||
import { useState } from "react"
|
||||
import { TbChecks } from "react-icons/tb"
|
||||
|
||||
export function MarkAllAsReadButton(props: { iconSize: number }) {
|
||||
const [opened, setOpened] = useState(false)
|
||||
const [threshold, setThreshold] = useState(0)
|
||||
const source = useAppSelector(state => state.entries.source)
|
||||
const sourceLabel = useAppSelector(state => state.entries.sourceLabel)
|
||||
const entriesTimestamp = useAppSelector(state => state.entries.timestamp) ?? Date.now()
|
||||
const markAllAsReadConfirmation = useAppSelector(state => state.user.settings?.markAllAsReadConfirmation)
|
||||
const dispatch = useAppDispatch()
|
||||
|
||||
const buttonClicked = () => {
|
||||
if (markAllAsReadConfirmation) {
|
||||
setThreshold(0)
|
||||
setOpened(true)
|
||||
} else {
|
||||
dispatch(
|
||||
markAllEntries({
|
||||
sourceType: source.type,
|
||||
req: {
|
||||
id: source.id,
|
||||
read: true,
|
||||
olderThan: Date.now(),
|
||||
insertedBefore: entriesTimestamp,
|
||||
},
|
||||
})
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Modal opened={opened} onClose={() => setOpened(false)} title={<Trans>Mark all entries as read</Trans>}>
|
||||
<Stack>
|
||||
<Text size="sm">
|
||||
{threshold === 0 && (
|
||||
<Trans>
|
||||
Are you sure you want to mark all entries of <Code>{sourceLabel}</Code> as read?
|
||||
</Trans>
|
||||
)}
|
||||
{threshold > 0 && (
|
||||
<Trans>
|
||||
Are you sure you want to mark entries older than {threshold} days of <Code>{sourceLabel}</Code> as read?
|
||||
</Trans>
|
||||
)}
|
||||
</Text>
|
||||
<Slider
|
||||
py="xl"
|
||||
min={0}
|
||||
max={28}
|
||||
marks={[
|
||||
{ value: 0, label: "0" },
|
||||
{ value: 7, label: "7" },
|
||||
{ value: 14, label: "14" },
|
||||
{ value: 21, label: "21" },
|
||||
{ value: 28, label: "28" },
|
||||
]}
|
||||
value={threshold}
|
||||
onChange={setThreshold}
|
||||
/>
|
||||
<Group justify="flex-end">
|
||||
<Button variant="default" onClick={() => setOpened(false)}>
|
||||
<Trans>Cancel</Trans>
|
||||
</Button>
|
||||
<Button
|
||||
color="red"
|
||||
onClick={() => {
|
||||
setOpened(false)
|
||||
dispatch(
|
||||
markAllEntries({
|
||||
sourceType: source.type,
|
||||
req: {
|
||||
id: source.id,
|
||||
read: true,
|
||||
olderThan: Date.now() - threshold * 24 * 60 * 60 * 1000,
|
||||
insertedBefore: entriesTimestamp,
|
||||
},
|
||||
})
|
||||
)
|
||||
}}
|
||||
>
|
||||
<Trans>Confirm</Trans>
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Modal>
|
||||
<ActionButton icon={<TbChecks size={props.iconSize} />} label={msg`Mark all as read`} onClick={buttonClicked} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -7,18 +7,12 @@ import {
|
||||
Menu,
|
||||
SegmentedControl,
|
||||
type SegmentedControlItem,
|
||||
Slider,
|
||||
useMantineColorScheme,
|
||||
} from "@mantine/core"
|
||||
import { showNotification } from "@mantine/notifications"
|
||||
import { client } from "app/client"
|
||||
import { redirectToAbout, redirectToAdminUsers, redirectToDonate, redirectToMetrics, redirectToSettings } from "app/redirect/thunks"
|
||||
import { useAppDispatch, useAppSelector } from "app/store"
|
||||
import type { ViewMode } from "app/types"
|
||||
import { setViewMode } from "app/user/slice"
|
||||
import { reloadProfile } from "app/user/thunks"
|
||||
import dayjs from "dayjs"
|
||||
import { useNow } from "hooks/useNow"
|
||||
import { type ReactNode, useState } from "react"
|
||||
import { type ReactNode, useEffect, useState } from "react"
|
||||
import {
|
||||
TbChartLine,
|
||||
TbHeartFilled,
|
||||
@@ -35,6 +29,14 @@ import {
|
||||
TbUsers,
|
||||
TbWorldDownload,
|
||||
} from "react-icons/tb"
|
||||
import { throttle } from "throttle-debounce"
|
||||
import { client } from "@/app/client"
|
||||
import { redirectToAbout, redirectToAdminUsers, redirectToDonate, redirectToMetrics, redirectToSettings } from "@/app/redirect/thunks"
|
||||
import { useAppDispatch, useAppSelector } from "@/app/store"
|
||||
import type { ViewMode } from "@/app/types"
|
||||
import { setFontSizePercentage, setViewMode } from "@/app/user/slice"
|
||||
import { reloadProfile } from "@/app/user/thunks"
|
||||
import { useNow } from "@/hooks/useNow"
|
||||
|
||||
interface ProfileMenuProps {
|
||||
control: React.ReactElement
|
||||
@@ -93,13 +95,22 @@ const viewModeData: ViewModeControlItem[] = [
|
||||
},
|
||||
]
|
||||
|
||||
export function ProfileMenu(props: ProfileMenuProps) {
|
||||
export function ProfileMenu(props: Readonly<ProfileMenuProps>) {
|
||||
const [opened, setOpened] = useState(false)
|
||||
|
||||
// close profile menu on scroll
|
||||
useEffect(() => {
|
||||
const listener = throttle(100, () => setOpened(false))
|
||||
window.addEventListener("scroll", listener)
|
||||
return () => window.removeEventListener("scroll", listener)
|
||||
}, [])
|
||||
|
||||
const now = useNow()
|
||||
const profile = useAppSelector(state => state.user.profile)
|
||||
const admin = useAppSelector(state => state.user.profile?.admin)
|
||||
const viewMode = useAppSelector(state => state.user.localSettings.viewMode)
|
||||
const forceRefreshCooldownDuration = useAppSelector(state => state.server.serverInfos?.forceRefreshCooldownDuration)
|
||||
const fontSizePercentage = useAppSelector(state => state.user.localSettings.fontSizePercentage)
|
||||
const dispatch = useAppDispatch()
|
||||
const { colorScheme, setColorScheme } = useMantineColorScheme()
|
||||
|
||||
@@ -143,7 +154,7 @@ export function ProfileMenu(props: ProfileMenuProps) {
|
||||
color: "green",
|
||||
autoClose: 1000,
|
||||
})
|
||||
} catch (error) {
|
||||
} catch {
|
||||
showNotification({
|
||||
message: <Trans>Force fetching feeds is not yet available.</Trans>,
|
||||
color: "red",
|
||||
@@ -184,6 +195,22 @@ export function ProfileMenu(props: ProfileMenuProps) {
|
||||
mb="xs"
|
||||
/>
|
||||
|
||||
<Divider />
|
||||
|
||||
<Menu.Label>
|
||||
<Trans>Font size</Trans>
|
||||
</Menu.Label>
|
||||
<Slider
|
||||
min={50}
|
||||
max={150}
|
||||
step={5}
|
||||
marks={[{ value: 100 }]}
|
||||
label={v => `${v}%`}
|
||||
mb="xs"
|
||||
value={fontSizePercentage}
|
||||
onChange={value => dispatch(setFontSizePercentage(value))}
|
||||
/>
|
||||
|
||||
{admin && (
|
||||
<>
|
||||
<Divider />
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { NumberFormatter } from "@mantine/core"
|
||||
import type { MetricGauge } from "app/types"
|
||||
import type { MetricGauge } from "@/app/types"
|
||||
|
||||
interface MeterProps {
|
||||
interface GaugeProps {
|
||||
gauge: MetricGauge
|
||||
}
|
||||
|
||||
export function Gauge(props: MeterProps) {
|
||||
export function Gauge(props: Readonly<GaugeProps>) {
|
||||
return <NumberFormatter value={props.gauge.value} thousandSeparator />
|
||||
}
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { Box } from "@mantine/core"
|
||||
import type { MetricMeter } from "app/types"
|
||||
import type { MetricMeter } from "@/app/types"
|
||||
|
||||
interface MeterProps {
|
||||
meter: MetricMeter
|
||||
}
|
||||
|
||||
export function Meter(props: MeterProps) {
|
||||
export function Meter(props: Readonly<MeterProps>) {
|
||||
return (
|
||||
<Box>
|
||||
<Box>Mean: {props.meter.mean_rate.toFixed(2)}</Box>
|
||||
|
||||
@@ -7,7 +7,7 @@ interface MetricAccordionItemProps {
|
||||
children: React.ReactNode
|
||||
}
|
||||
|
||||
export function MetricAccordionItem({ metricKey, name, headerValue, children }: MetricAccordionItemProps) {
|
||||
export function MetricAccordionItem({ metricKey, name, headerValue, children }: Readonly<MetricAccordionItemProps>) {
|
||||
return (
|
||||
<Accordion.Item value={metricKey} key={metricKey}>
|
||||
<Accordion.Control>
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { Box } from "@mantine/core"
|
||||
import type { MetricTimer } from "app/types"
|
||||
import type { MetricTimer } from "@/app/types"
|
||||
|
||||
interface MetricTimerProps {
|
||||
timer: MetricTimer
|
||||
}
|
||||
|
||||
export function Timer(props: MetricTimerProps) {
|
||||
export function Timer(props: Readonly<MetricTimerProps>) {
|
||||
return (
|
||||
<Box>
|
||||
<Box>Mean: {props.timer.mean_rate.toFixed(2)}</Box>
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
import { Box } from "@mantine/core"
|
||||
import { useMobile } from "hooks/useMobile"
|
||||
import type React from "react"
|
||||
import { useMobile } from "@/hooks/useMobile"
|
||||
|
||||
export function OnDesktop(props: { children: React.ReactNode }) {
|
||||
export function OnDesktop(
|
||||
props: Readonly<{
|
||||
children: React.ReactNode
|
||||
}>
|
||||
) {
|
||||
const mobile = useMobile()
|
||||
return <Box>{!mobile && props.children}</Box>
|
||||
}
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
import { Box } from "@mantine/core"
|
||||
import { useMobile } from "hooks/useMobile"
|
||||
import type React from "react"
|
||||
import { useMobile } from "@/hooks/useMobile"
|
||||
|
||||
export function OnMobile(props: { children: React.ReactNode }) {
|
||||
export function OnMobile(
|
||||
props: Readonly<{
|
||||
children: React.ReactNode
|
||||
}>
|
||||
) {
|
||||
const mobile = useMobile()
|
||||
return <Box>{mobile && props.children}</Box>
|
||||
}
|
||||
|
||||
@@ -1,14 +1,15 @@
|
||||
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 { 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 { useAsyncCallback } from "react-async-hook"
|
||||
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 {
|
||||
customCss: string
|
||||
@@ -57,13 +58,23 @@ export function CustomCodeSettings() {
|
||||
<form onSubmit={form.onSubmit(saveCustomCode.execute)}>
|
||||
<Stack>
|
||||
<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"
|
||||
{...form.getInputProps("customCss")}
|
||||
/>
|
||||
|
||||
<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"
|
||||
{...form.getInputProps("customJs")}
|
||||
/>
|
||||
|
||||
@@ -1,18 +1,24 @@
|
||||
import { msg } from "@lingui/core/macro"
|
||||
import { useLingui } from "@lingui/react"
|
||||
import { Trans } from "@lingui/react/macro"
|
||||
import { Divider, Group, NumberInput, Radio, Select, SimpleGrid, Stack, Switch } from "@mantine/core"
|
||||
import { Box, Divider, Group, NumberInput, Radio, Select, type SelectProps, SimpleGrid, Stack, Switch } from "@mantine/core"
|
||||
import type { ComboboxData } from "@mantine/core/lib/components/Combobox/Combobox.types"
|
||||
import { Constants } from "app/constants"
|
||||
import { useAppDispatch, useAppSelector } from "app/store"
|
||||
import type { IconDisplayMode, ScrollMode, SharingSettings } from "app/types"
|
||||
import type { ReactNode } from "react"
|
||||
import { Constants } from "@/app/constants"
|
||||
import { useAppDispatch, useAppSelector } from "@/app/store"
|
||||
import type { IconDisplayMode, ScrollMode, SharingSettings } from "@/app/types"
|
||||
import {
|
||||
changeCustomContextMenu,
|
||||
changeDisableMobileSwipe,
|
||||
changeDisablePullToRefresh,
|
||||
changeEntriesToKeepOnTopWhenScrolling,
|
||||
changeExternalLinkIconDisplayMode,
|
||||
changeInfrequentThresholdDays,
|
||||
changeLanguage,
|
||||
changeMarkAllAsReadConfirmation,
|
||||
changeMarkAllAsReadNavigateToUnread,
|
||||
changeMobileFooter,
|
||||
changePrimaryColor,
|
||||
changeScrollMarks,
|
||||
changeScrollMode,
|
||||
changeScrollSpeed,
|
||||
@@ -21,9 +27,8 @@ import {
|
||||
changeStarIconDisplayMode,
|
||||
changeUnreadCountFavicon,
|
||||
changeUnreadCountTitle,
|
||||
} from "app/user/thunks"
|
||||
import { locales } from "i18n"
|
||||
import type { ReactNode } from "react"
|
||||
} from "@/app/user/thunks"
|
||||
import { locales } from "@/i18n"
|
||||
|
||||
export function DisplaySettings() {
|
||||
const language = useAppSelector(state => state.user.settings?.language)
|
||||
@@ -35,13 +40,18 @@ export function DisplaySettings() {
|
||||
const starIconDisplayMode = useAppSelector(state => state.user.settings?.starIconDisplayMode)
|
||||
const externalLinkIconDisplayMode = useAppSelector(state => state.user.settings?.externalLinkIconDisplayMode)
|
||||
const markAllAsReadConfirmation = useAppSelector(state => state.user.settings?.markAllAsReadConfirmation)
|
||||
const markAllAsReadNavigateToNextUnread = useAppSelector(state => state.user.settings?.markAllAsReadNavigateToNextUnread)
|
||||
const customContextMenu = useAppSelector(state => state.user.settings?.customContextMenu)
|
||||
const mobileFooter = useAppSelector(state => state.user.settings?.mobileFooter)
|
||||
const unreadCountTitle = useAppSelector(state => state.user.settings?.unreadCountTitle)
|
||||
const unreadCountFavicon = useAppSelector(state => state.user.settings?.unreadCountFavicon)
|
||||
const disablePullToRefresh = useAppSelector(state => state.user.settings?.disablePullToRefresh)
|
||||
const disableMobileSwipe = useAppSelector(state => state.user.settings?.disableMobileSwipe)
|
||||
const infrequentThresholdDays = useAppSelector(state => state.user.settings?.infrequentThresholdDays)
|
||||
const sharingSettings = useAppSelector(state => state.user.settings?.sharingSettings)
|
||||
const dispatch = useAppDispatch()
|
||||
const primaryColor = useAppSelector(state => state.user.settings?.primaryColor) || Constants.theme.defaultPrimaryColor
|
||||
const { _ } = useLingui()
|
||||
const dispatch = useAppDispatch()
|
||||
|
||||
const scrollModeOptions: Record<ScrollMode, ReactNode> = {
|
||||
always: <Trans>Always</Trans>,
|
||||
@@ -68,10 +78,35 @@ export function DisplaySettings() {
|
||||
},
|
||||
]
|
||||
|
||||
const colorData: ComboboxData = [
|
||||
{ value: "dark", label: _(msg`Dark`) },
|
||||
{ value: "gray", label: _(msg`Gray`) },
|
||||
{ value: "red", label: _(msg`Red`) },
|
||||
{ value: "pink", label: _(msg`Pink`) },
|
||||
{ value: "grape", label: _(msg`Grape`) },
|
||||
{ value: "violet", label: _(msg`Violet`) },
|
||||
{ value: "indigo", label: _(msg`Indigo`) },
|
||||
{ value: "blue", label: _(msg`Blue`) },
|
||||
{ value: "cyan", label: _(msg`Cyan`) },
|
||||
{ value: "green", label: _(msg`Green`) },
|
||||
{ value: "lime", label: _(msg`Lime`) },
|
||||
{ value: "yellow", label: _(msg`Yellow`) },
|
||||
{ value: "orange", label: _(msg`Orange`) },
|
||||
{ value: "teal", label: _(msg`Teal`) },
|
||||
].sort((a, b) => a.label.localeCompare(b.label))
|
||||
const colorRenderer: SelectProps["renderOption"] = ({ option }) => (
|
||||
<Group>
|
||||
<Box h={18} w={18} bg={option.value} />
|
||||
<Box>{option.label}</Box>
|
||||
</Group>
|
||||
)
|
||||
|
||||
return (
|
||||
<Stack>
|
||||
<Divider label={<Trans>Display</Trans>} labelPosition="center" />
|
||||
|
||||
<Select
|
||||
description={<Trans>Language</Trans>}
|
||||
label={<Trans>Language</Trans>}
|
||||
value={language}
|
||||
data={locales.map(l => ({
|
||||
value: l.key,
|
||||
@@ -80,6 +115,14 @@ export function DisplaySettings() {
|
||||
onChange={async s => await (s && dispatch(changeLanguage(s)))}
|
||||
/>
|
||||
|
||||
<Select
|
||||
label={<Trans>Primary color</Trans>}
|
||||
data={colorData}
|
||||
value={primaryColor}
|
||||
onChange={async value => value && (await dispatch(changePrimaryColor(value)))}
|
||||
renderOption={colorRenderer}
|
||||
/>
|
||||
|
||||
<Switch
|
||||
label={<Trans>Show feeds and categories with no unread entries</Trans>}
|
||||
checked={showRead}
|
||||
@@ -92,50 +135,41 @@ export function DisplaySettings() {
|
||||
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))}
|
||||
/>
|
||||
|
||||
<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))}
|
||||
label={<Trans>On mobile, disable swipe gesture to open the menu</Trans>}
|
||||
checked={disableMobileSwipe}
|
||||
onChange={async e => await dispatch(changeDisableMobileSwipe(e.currentTarget.checked))}
|
||||
/>
|
||||
|
||||
<Switch
|
||||
label={<Trans>Show unread count in tab favicon</Trans>}
|
||||
checked={unreadCountFavicon}
|
||||
onChange={async e => await dispatch(changeUnreadCountFavicon(e.currentTarget.checked))}
|
||||
/>
|
||||
|
||||
<Divider label={<Trans>Entry headers</Trans>} labelPosition="center" />
|
||||
|
||||
<Select
|
||||
description={<Trans>Show star icon</Trans>}
|
||||
value={starIconDisplayMode}
|
||||
data={displayModeData}
|
||||
onChange={async s => await dispatch(changeStarIconDisplayMode(s as IconDisplayMode))}
|
||||
/>
|
||||
|
||||
<Select
|
||||
description={<Trans>Show external link icon</Trans>}
|
||||
value={externalLinkIconDisplayMode}
|
||||
data={displayModeData}
|
||||
onChange={async s => await dispatch(changeExternalLinkIconDisplayMode(s as IconDisplayMode))}
|
||||
/>
|
||||
|
||||
<Switch
|
||||
label={<Trans>Show CommaFeed's own context menu on right click</Trans>}
|
||||
checked={customContextMenu}
|
||||
onChange={async e => await dispatch(changeCustomContextMenu(e.currentTarget.checked))}
|
||||
<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}
|
||||
@@ -168,6 +202,42 @@ export function DisplaySettings() {
|
||||
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}>
|
||||
|
||||
@@ -1,18 +1,18 @@
|
||||
import { msg } from "@lingui/core/macro"
|
||||
import { useLingui } from "@lingui/react"
|
||||
import { Trans } from "@lingui/react/macro"
|
||||
import { Anchor, Box, Button, Checkbox, Divider, Group, Input, PasswordInput, Stack, Text, TextInput } from "@mantine/core"
|
||||
import { useForm } from "@mantine/form"
|
||||
import { openConfirmModal } from "@mantine/modals"
|
||||
import { client, errorToStrings } from "app/client"
|
||||
import { redirectToLogin, redirectToSelectedSource } from "app/redirect/thunks"
|
||||
import { useAppDispatch, useAppSelector } from "app/store"
|
||||
import type { ProfileModificationRequest } from "app/types"
|
||||
import { reloadProfile } from "app/user/thunks"
|
||||
import { Alert } from "components/Alert"
|
||||
import { useEffect } from "react"
|
||||
import { useAsyncCallback } from "react-async-hook"
|
||||
import { TbDeviceFloppy, TbTrash } from "react-icons/tb"
|
||||
import { client, errorToStrings } from "@/app/client"
|
||||
import { redirectToLogin, redirectToSelectedSource } from "@/app/redirect/thunks"
|
||||
import { useAppDispatch, useAppSelector } from "@/app/store"
|
||||
import type { ProfileModificationRequest } from "@/app/types"
|
||||
import { reloadProfile } from "@/app/user/thunks"
|
||||
import { Alert } from "@/components/Alert"
|
||||
import { useValidationRules } from "@/hooks/useValidationRules"
|
||||
|
||||
interface FormData extends ProfileModificationRequest {
|
||||
newPasswordConfirmation?: string
|
||||
@@ -20,13 +20,17 @@ interface FormData extends ProfileModificationRequest {
|
||||
|
||||
export function ProfileSettings() {
|
||||
const profile = useAppSelector(state => state.user.profile)
|
||||
const serverInfos = useAppSelector(state => state.server.serverInfos)
|
||||
const dispatch = useAppDispatch()
|
||||
const { _ } = useLingui()
|
||||
const validationRules = useValidationRules()
|
||||
|
||||
const form = useForm<FormData>({
|
||||
validate: {
|
||||
newPasswordConfirmation: (value, values) => (value !== values.newPassword ? _(msg`Passwords do not match`) : null),
|
||||
newPassword: validationRules.password,
|
||||
newPasswordConfirmation: (value, values) => validationRules.passwordConfirmation(value, values.newPassword),
|
||||
},
|
||||
validateInputOnChange: true,
|
||||
})
|
||||
const { setValues } = form
|
||||
|
||||
@@ -52,7 +56,9 @@ export function ProfileSettings() {
|
||||
),
|
||||
labels: { confirm: <Trans>Confirm</Trans>, cancel: <Trans>Cancel</Trans> },
|
||||
confirmProps: { color: "red" },
|
||||
onConfirm: async () => await deleteProfile.execute(),
|
||||
onConfirm: () => {
|
||||
deleteProfile.execute()
|
||||
},
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
@@ -132,7 +138,12 @@ export function ProfileSettings() {
|
||||
required
|
||||
{...form.getInputProps("currentPassword")}
|
||||
/>
|
||||
<TextInput type="email" label={<Trans>E-mail</Trans>} {...form.getInputProps("email")} required />
|
||||
<TextInput
|
||||
type="email"
|
||||
label={<Trans>E-mail</Trans>}
|
||||
{...form.getInputProps("email")}
|
||||
required={serverInfos?.emailAddressRequired}
|
||||
/>
|
||||
<PasswordInput
|
||||
label={<Trans>New password</Trans>}
|
||||
description={<Trans>Changing password will generate a new API key</Trans>}
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
import { msg } from "@lingui/core/macro"
|
||||
import { useLingui } from "@lingui/react"
|
||||
import { Trans } from "@lingui/react/macro"
|
||||
import { Button, Divider, Group, Select, Stack, TextInput } from "@mantine/core"
|
||||
import { useForm } from "@mantine/form"
|
||||
import { useEffect } from "react"
|
||||
import { useAsyncCallback } from "react-async-hook"
|
||||
import { TbDeviceFloppy, TbSend } from "react-icons/tb"
|
||||
import { client, errorToStrings } from "@/app/client"
|
||||
import { redirectToSelectedSource } from "@/app/redirect/thunks"
|
||||
import { useAppDispatch, useAppSelector } from "@/app/store"
|
||||
import type { PushNotificationSettings as PushNotificationSettingsModel } from "@/app/types"
|
||||
import { changePushNotificationSettings } from "@/app/user/thunks"
|
||||
import { Alert } from "@/components/Alert"
|
||||
|
||||
export function PushNotificationSettings() {
|
||||
const notificationSettings = useAppSelector(state => state.user.settings?.pushNotificationSettings)
|
||||
const pushNotificationsEnabled = useAppSelector(state => state.server.serverInfos?.pushNotificationsEnabled)
|
||||
const { _ } = useLingui()
|
||||
const dispatch = useAppDispatch()
|
||||
|
||||
const form = useForm<PushNotificationSettingsModel>()
|
||||
useEffect(() => {
|
||||
if (notificationSettings) form.initialize(notificationSettings)
|
||||
}, [form.initialize, notificationSettings])
|
||||
|
||||
const handleSubmit = (values: PushNotificationSettingsModel) => {
|
||||
dispatch(changePushNotificationSettings(values))
|
||||
}
|
||||
|
||||
const sendTestPushNotification = useAsyncCallback(client.user.sendTestPushNotification)
|
||||
|
||||
const typeInputProps = form.getInputProps("type")
|
||||
|
||||
if (!pushNotificationsEnabled) {
|
||||
return <Trans>Push notifications are not enabled on this CommaFeed instance.</Trans>
|
||||
}
|
||||
return (
|
||||
<form onSubmit={form.onSubmit(handleSubmit)}>
|
||||
<Stack>
|
||||
{sendTestPushNotification.status === "success" && (
|
||||
<Alert level="success" messages={[_(msg`Test notification sent successfully.`)]} />
|
||||
)}
|
||||
|
||||
{sendTestPushNotification.status === "error" && (
|
||||
<Alert level="error" messages={errorToStrings(sendTestPushNotification.error)} />
|
||||
)}
|
||||
|
||||
<Select
|
||||
label={<Trans>Push notification service</Trans>}
|
||||
description={
|
||||
<Trans>
|
||||
Receive push notifications when new feed entries are discovered. Enable "Receive push notifications" in the
|
||||
settings of each feed for which you want to receive notifications.
|
||||
</Trans>
|
||||
}
|
||||
data={[
|
||||
{ value: "ntfy", label: "ntfy" },
|
||||
{ value: "gotify", label: "Gotify" },
|
||||
{ value: "pushover", label: "Pushover" },
|
||||
]}
|
||||
clearable
|
||||
{...typeInputProps}
|
||||
onChange={value => {
|
||||
typeInputProps.onChange(value)
|
||||
form.setFieldValue("serverUrl", "")
|
||||
form.setFieldValue("topic", "")
|
||||
form.setFieldValue("userSecret", "")
|
||||
form.setFieldValue("userId", "")
|
||||
}}
|
||||
/>
|
||||
{form.values.type === "ntfy" && (
|
||||
<>
|
||||
<TextInput
|
||||
label={<Trans>Server URL</Trans>}
|
||||
placeholder="https://ntfy.sh"
|
||||
required
|
||||
{...form.getInputProps("serverUrl")}
|
||||
/>
|
||||
<TextInput label={<Trans>Topic</Trans>} placeholder="commafeed" required {...form.getInputProps("topic")} />
|
||||
<TextInput label={<Trans>Access token</Trans>} {...form.getInputProps("userSecret")} />
|
||||
</>
|
||||
)}
|
||||
{form.values.type === "gotify" && (
|
||||
<>
|
||||
<TextInput
|
||||
label={<Trans>Server URL</Trans>}
|
||||
placeholder="https://gotify.example.com"
|
||||
required
|
||||
{...form.getInputProps("serverUrl")}
|
||||
/>
|
||||
<TextInput label={<Trans>App token</Trans>} required {...form.getInputProps("userSecret")} />
|
||||
</>
|
||||
)}
|
||||
{form.values.type === "pushover" && (
|
||||
<>
|
||||
<TextInput label={<Trans>User key</Trans>} required {...form.getInputProps("userId")} />
|
||||
<TextInput label={<Trans>API token</Trans>} required {...form.getInputProps("userSecret")} />
|
||||
</>
|
||||
)}
|
||||
|
||||
<Group>
|
||||
<Button variant="default" onClick={async () => await dispatch(redirectToSelectedSource())}>
|
||||
<Trans>Cancel</Trans>
|
||||
</Button>
|
||||
<Button type="submit" leftSection={<TbDeviceFloppy size={16} />}>
|
||||
<Trans>Save</Trans>
|
||||
</Button>
|
||||
|
||||
<Divider orientation="vertical" />
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
leftSection={<TbSend size={16} />}
|
||||
onClick={() => sendTestPushNotification.execute(form.values)}
|
||||
loading={sendTestPushNotification.loading}
|
||||
>
|
||||
<Trans>Test</Trans>
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
@@ -1,6 +1,8 @@
|
||||
import { Trans } from "@lingui/react/macro"
|
||||
import { Box, Stack } from "@mantine/core"
|
||||
import { Constants } from "app/constants"
|
||||
import React from "react"
|
||||
import { TbChevronDown, TbChevronRight, TbClock, TbInbox, TbStar, TbTag } from "react-icons/tb"
|
||||
import { Constants } from "@/app/constants"
|
||||
import {
|
||||
redirectToCategory,
|
||||
redirectToCategoryDetails,
|
||||
@@ -8,20 +10,20 @@ import {
|
||||
redirectToFeedDetails,
|
||||
redirectToTag,
|
||||
redirectToTagDetails,
|
||||
} from "app/redirect/thunks"
|
||||
import { useAppDispatch, useAppSelector } from "app/store"
|
||||
import { collapseTreeCategory } from "app/tree/thunks"
|
||||
import type { Category, Subscription } from "app/types"
|
||||
import { categoryUnreadCount, flattenCategoryTree } from "app/utils"
|
||||
import { Loader } from "components/Loader"
|
||||
import { OnDesktop } from "components/responsive/OnDesktop"
|
||||
import React from "react"
|
||||
import { TbChevronDown, TbChevronRight, TbInbox, TbStar, TbTag } from "react-icons/tb"
|
||||
} from "@/app/redirect/thunks"
|
||||
import { useAppDispatch, useAppSelector } from "@/app/store"
|
||||
import type { TreeSubscription } from "@/app/tree/slice"
|
||||
import { collapseTreeCategory } from "@/app/tree/thunks"
|
||||
import type { Category, Subscription } from "@/app/types"
|
||||
import { categoryHasNewEntries, categoryUnreadCount, flattenCategoryTree } from "@/app/utils"
|
||||
import { Loader } from "@/components/Loader"
|
||||
import { OnDesktop } from "@/components/responsive/OnDesktop"
|
||||
import { TreeNode } from "./TreeNode"
|
||||
import { TreeSearch } from "./TreeSearch"
|
||||
|
||||
const allIcon = <TbInbox size={16} />
|
||||
const starredIcon = <TbStar size={16} />
|
||||
const infrequentIcon = <TbClock size={16} />
|
||||
const tagIcon = <TbTag size={16} />
|
||||
const expandedIcon = <TbChevronDown size={16} />
|
||||
const collapsedIcon = <TbChevronRight size={16} />
|
||||
@@ -33,6 +35,10 @@ export function Tree() {
|
||||
const source = useAppSelector(state => state.entries.source)
|
||||
const tags = useAppSelector(state => state.user.tags)
|
||||
const showRead = useAppSelector(state => state.user.settings?.showRead)
|
||||
const infrequentThresholdDays = useAppSelector(
|
||||
state => state.user.settings?.infrequentThresholdDays ?? Constants.infrequentThresholdDaysDefault
|
||||
)
|
||||
const infrequentThresholdMs = infrequentThresholdDays * 24 * 3600 * 1000
|
||||
const dispatch = useAppDispatch()
|
||||
|
||||
const isFeedDisplayed = (feed: Subscription) => {
|
||||
@@ -89,10 +95,12 @@ export function Tree() {
|
||||
name={<Trans>All</Trans>}
|
||||
icon={allIcon}
|
||||
unread={categoryUnreadCount(root)}
|
||||
hasNewEntries={categoryHasNewEntries(root)}
|
||||
selected={source.type === "category" && source.id === Constants.categories.all.id}
|
||||
expanded={false}
|
||||
level={0}
|
||||
hasError={false}
|
||||
hasWarning={false}
|
||||
onClick={categoryClicked}
|
||||
/>
|
||||
)
|
||||
@@ -103,10 +111,28 @@ export function Tree() {
|
||||
name={<Trans>Starred</Trans>}
|
||||
icon={starredIcon}
|
||||
unread={0}
|
||||
hasNewEntries={false}
|
||||
selected={source.type === "category" && source.id === Constants.categories.starred.id}
|
||||
expanded={false}
|
||||
level={0}
|
||||
hasError={false}
|
||||
hasWarning={false}
|
||||
onClick={categoryClicked}
|
||||
/>
|
||||
)
|
||||
const infrequentCategoryNode = () => (
|
||||
<TreeNode
|
||||
id={Constants.categories.infrequent.id}
|
||||
type="category"
|
||||
name={<Trans>Infrequent</Trans>}
|
||||
icon={infrequentIcon}
|
||||
unread={categoryUnreadCount(root, infrequentThresholdMs)}
|
||||
hasNewEntries={categoryHasNewEntries(root, infrequentThresholdMs)}
|
||||
selected={source.type === "category" && source.id === Constants.categories.infrequent.id}
|
||||
expanded={false}
|
||||
level={0}
|
||||
hasError={false}
|
||||
hasWarning={false}
|
||||
onClick={categoryClicked}
|
||||
/>
|
||||
)
|
||||
@@ -115,6 +141,7 @@ export function Tree() {
|
||||
if (!isCategoryDisplayed(category)) return null
|
||||
|
||||
const hasError = !category.expanded && flattenCategoryTree(category).some(c => c.feeds.some(f => f.errorCount > errorThreshold))
|
||||
const hasWarning = !category.expanded && flattenCategoryTree(category).some(c => c.feeds.some(f => !!f.filterLegacy))
|
||||
return (
|
||||
<TreeNode
|
||||
id={category.id}
|
||||
@@ -122,10 +149,12 @@ export function Tree() {
|
||||
name={category.name}
|
||||
icon={category.expanded ? expandedIcon : collapsedIcon}
|
||||
unread={categoryUnreadCount(category)}
|
||||
hasNewEntries={categoryHasNewEntries(category)}
|
||||
selected={source.type === "category" && source.id === category.id}
|
||||
expanded={category.expanded}
|
||||
level={level}
|
||||
hasError={hasError}
|
||||
hasWarning={hasWarning}
|
||||
onClick={categoryClicked}
|
||||
onIconClick={e => categoryIconClicked(e, category)}
|
||||
key={category.id}
|
||||
@@ -133,7 +162,7 @@ export function Tree() {
|
||||
)
|
||||
}
|
||||
|
||||
const feedNode = (feed: Subscription, level = 0) => {
|
||||
const feedNode = (feed: TreeSubscription, level = 0) => {
|
||||
if (!isFeedDisplayed(feed)) return null
|
||||
|
||||
return (
|
||||
@@ -143,9 +172,11 @@ export function Tree() {
|
||||
name={feed.name}
|
||||
icon={feed.iconUrl}
|
||||
unread={feed.unread}
|
||||
hasNewEntries={!!feed.hasNewEntries}
|
||||
selected={source.type === "feed" && source.id === String(feed.id)}
|
||||
level={level}
|
||||
hasError={feed.errorCount > errorThreshold}
|
||||
hasWarning={!!feed.filterLegacy}
|
||||
onClick={feedClicked}
|
||||
key={feed.id}
|
||||
/>
|
||||
@@ -159,9 +190,11 @@ export function Tree() {
|
||||
name={tag}
|
||||
icon={tagIcon}
|
||||
unread={0}
|
||||
hasNewEntries={false}
|
||||
selected={source.type === "tag" && source.id === tag}
|
||||
level={0}
|
||||
hasError={false}
|
||||
hasWarning={false}
|
||||
onClick={tagClicked}
|
||||
key={tag}
|
||||
/>
|
||||
@@ -182,9 +215,10 @@ export function Tree() {
|
||||
<OnDesktop>
|
||||
<TreeSearch feeds={feeds} />
|
||||
</OnDesktop>
|
||||
<Box>
|
||||
<Box className="cf-tree">
|
||||
{allCategoryNode()}
|
||||
{starredCategoryNode()}
|
||||
{infrequentCategoryNode()}
|
||||
{root.children.map(c => recursiveCategoryNode(c))}
|
||||
{root.feeds.map(f => feedNode(f))}
|
||||
{tags?.map(tag => tagNode(tag))}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { Box, Center } from "@mantine/core"
|
||||
import type { EntrySourceType } from "app/entries/slice"
|
||||
import { FeedFavicon } from "components/content/FeedFavicon"
|
||||
import type React from "react"
|
||||
import { tss } from "tss"
|
||||
import type { EntrySourceType } from "@/app/entries/slice"
|
||||
import { FeedFavicon } from "@/components/content/FeedFavicon"
|
||||
import { tss } from "@/tss"
|
||||
import { UnreadCount } from "./UnreadCount"
|
||||
|
||||
interface TreeNodeProps {
|
||||
@@ -15,6 +15,8 @@ interface TreeNodeProps {
|
||||
expanded?: boolean
|
||||
level: number
|
||||
hasError: boolean
|
||||
hasWarning: boolean
|
||||
hasNewEntries: boolean
|
||||
onClick: (e: React.MouseEvent, id: string) => void
|
||||
onIconClick?: (e: React.MouseEvent, id: string) => void
|
||||
}
|
||||
@@ -23,15 +25,18 @@ const useStyles = tss
|
||||
.withParams<{
|
||||
selected: boolean
|
||||
hasError: boolean
|
||||
hasWarning: boolean
|
||||
hasUnread: boolean
|
||||
}>()
|
||||
.create(({ theme, colorScheme, selected, hasError, hasUnread }) => {
|
||||
.create(({ theme, colorScheme, selected, hasError, hasWarning, hasUnread }) => {
|
||||
let backgroundColor = "inherit"
|
||||
if (selected) backgroundColor = colorScheme === "dark" ? theme.colors.dark[4] : theme.colors.gray[1]
|
||||
|
||||
let color: string
|
||||
if (hasError) {
|
||||
color = theme.colors.red[6]
|
||||
} else if (hasWarning) {
|
||||
color = theme.colors.yellow[6]
|
||||
} else if (colorScheme === "dark") {
|
||||
color = hasUnread ? theme.colors.dark[0] : theme.colors.dark[3]
|
||||
} else {
|
||||
@@ -58,29 +63,30 @@ const useStyles = tss
|
||||
}
|
||||
})
|
||||
|
||||
export function TreeNode(props: TreeNodeProps) {
|
||||
export function TreeNode(props: Readonly<TreeNodeProps>) {
|
||||
const { classes } = useStyles({
|
||||
selected: props.selected,
|
||||
hasError: props.hasError,
|
||||
hasWarning: props.hasWarning,
|
||||
hasUnread: props.unread > 0,
|
||||
})
|
||||
return (
|
||||
<Box
|
||||
py={1}
|
||||
pl={props.level * 20}
|
||||
className={classes.node}
|
||||
className={`${classes.node} cf-treenode cf-treenode-${props.type}`}
|
||||
onClick={(e: React.MouseEvent) => props.onClick(e, props.id)}
|
||||
data-id={props.id}
|
||||
data-type={props.type}
|
||||
data-unread-count={props.unread}
|
||||
>
|
||||
<Box mr={6} onClick={(e: React.MouseEvent) => props.onIconClick?.(e, props.id)}>
|
||||
<Box mr={6} onClick={(e: React.MouseEvent) => props.onIconClick?.(e, props.id)} className="cf-treenode-icon">
|
||||
<Center>{typeof props.icon === "string" ? <FeedFavicon url={props.icon} /> : props.icon}</Center>
|
||||
</Box>
|
||||
<Box className={classes.nodeText}>{props.name}</Box>
|
||||
{!props.expanded && (
|
||||
<Box>
|
||||
<UnreadCount unreadCount={props.unread} />
|
||||
<Box className="cf-treenode-unread-count">
|
||||
<UnreadCount unreadCount={props.unread} showIndicator={props.hasNewEntries} />
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
@@ -1,20 +1,20 @@
|
||||
import { msg } from "@lingui/core/macro"
|
||||
import { useLingui } from "@lingui/react"
|
||||
import { Trans } from "@lingui/react/macro"
|
||||
import { TextInput } from "@mantine/core"
|
||||
import { Box, TextInput } from "@mantine/core"
|
||||
import { Spotlight, type SpotlightActionData, spotlight } from "@mantine/spotlight"
|
||||
import { redirectToFeed } from "app/redirect/thunks"
|
||||
import { useAppDispatch } from "app/store"
|
||||
import type { Subscription } from "app/types"
|
||||
import { FeedFavicon } from "components/content/FeedFavicon"
|
||||
import { useMousetrap } from "hooks/useMousetrap"
|
||||
import { TbSearch } from "react-icons/tb"
|
||||
import { redirectToFeed } from "@/app/redirect/thunks"
|
||||
import { useAppDispatch } from "@/app/store"
|
||||
import type { Subscription } from "@/app/types"
|
||||
import { FeedFavicon } from "@/components/content/FeedFavicon"
|
||||
import { useMousetrap } from "@/hooks/useMousetrap"
|
||||
|
||||
export interface TreeSearchProps {
|
||||
feeds: Subscription[]
|
||||
}
|
||||
|
||||
export function TreeSearch(props: TreeSearchProps) {
|
||||
export function TreeSearch(props: Readonly<TreeSearchProps>) {
|
||||
const dispatch = useAppDispatch()
|
||||
const { _ } = useLingui()
|
||||
|
||||
@@ -33,7 +33,7 @@ export function TreeSearch(props: TreeSearchProps) {
|
||||
useMousetrap("g u", () => spotlight.open())
|
||||
|
||||
return (
|
||||
<>
|
||||
<Box className="cf-treesearch">
|
||||
<TextInput
|
||||
placeholder={_(msg`Search`)}
|
||||
leftSection={searchIcon}
|
||||
@@ -58,6 +58,6 @@ export function TreeSearch(props: TreeSearchProps) {
|
||||
}}
|
||||
nothingFound={<Trans>Nothing found</Trans>}
|
||||
/>
|
||||
</>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,16 +1,25 @@
|
||||
import { Badge, Tooltip } from "@mantine/core"
|
||||
import { Constants } from "app/constants"
|
||||
import { tss } from "tss"
|
||||
import { Badge, Indicator, Tooltip } from "@mantine/core"
|
||||
import { Constants } from "@/app/constants"
|
||||
import { tss } from "@/tss"
|
||||
|
||||
const useStyles = tss.create(() => ({
|
||||
badge: {
|
||||
width: "3.2rem",
|
||||
// for some reason, mantine Badge has "cursor: 'default'"
|
||||
cursor: "pointer",
|
||||
cursor: "inherit",
|
||||
},
|
||||
indicator: {
|
||||
// ensure the indicator is not shown above the app header
|
||||
zIndex: 0,
|
||||
},
|
||||
}))
|
||||
|
||||
export function UnreadCount(props: { unreadCount: number }) {
|
||||
export function UnreadCount(
|
||||
props: Readonly<{
|
||||
unreadCount: number
|
||||
showIndicator: boolean
|
||||
}>
|
||||
) {
|
||||
const { classes } = useStyles()
|
||||
|
||||
if (props.unreadCount <= 0) return null
|
||||
@@ -18,9 +27,19 @@ export function UnreadCount(props: { unreadCount: number }) {
|
||||
const count = props.unreadCount >= 10000 ? "10k+" : props.unreadCount
|
||||
return (
|
||||
<Tooltip label={props.unreadCount} disabled={props.unreadCount === count} openDelay={Constants.tooltip.delay}>
|
||||
<Badge className={classes.badge} variant="light" fullWidth>
|
||||
{count}
|
||||
</Badge>
|
||||
<Indicator
|
||||
disabled={!props.showIndicator}
|
||||
size={4}
|
||||
offset={10}
|
||||
position="middle-start"
|
||||
classNames={{
|
||||
indicator: classes.indicator,
|
||||
}}
|
||||
>
|
||||
<Badge className={`${classes.badge} cf-badge`} variant="light" fullWidth>
|
||||
{count}
|
||||
</Badge>
|
||||
</Indicator>
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useMantineTheme } from "@mantine/core"
|
||||
import { useMobile } from "hooks/useMobile"
|
||||
import { useMobile } from "@/hooks/useMobile"
|
||||
|
||||
export const useActionButton = () => {
|
||||
const theme = useMantineTheme()
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { msg } from "@lingui/core/macro"
|
||||
import { useLingui } from "@lingui/react"
|
||||
import { useAppSelector } from "app/store"
|
||||
import { useAppSelector } from "@/app/store"
|
||||
|
||||
interface Step {
|
||||
label: string
|
||||
@@ -8,28 +8,28 @@ interface Step {
|
||||
}
|
||||
|
||||
export const useAppLoading = () => {
|
||||
const profile = useAppSelector(state => state.user.profile)
|
||||
const settings = useAppSelector(state => state.user.settings)
|
||||
const rootCategory = useAppSelector(state => state.tree.rootCategory)
|
||||
const tags = useAppSelector(state => state.user.tags)
|
||||
const profileLoaded = useAppSelector(state => !!state.user.profile)
|
||||
const settingsLoaded = useAppSelector(state => !!state.user.settings)
|
||||
const rootCategoryLoaded = useAppSelector(state => !!state.tree.rootCategory)
|
||||
const tagsLoaded = useAppSelector(state => !!state.user.tags)
|
||||
const { _ } = useLingui()
|
||||
|
||||
const steps: Step[] = [
|
||||
{
|
||||
label: _(msg`Loading settings...`),
|
||||
done: !!settings,
|
||||
done: settingsLoaded,
|
||||
},
|
||||
{
|
||||
label: _(msg`Loading profile...`),
|
||||
done: !!profile,
|
||||
done: profileLoaded,
|
||||
},
|
||||
{
|
||||
label: _(msg`Loading subscriptions...`),
|
||||
done: !!rootCategory,
|
||||
done: rootCategoryLoaded,
|
||||
},
|
||||
{
|
||||
label: _(msg`Loading tags...`),
|
||||
done: !!tags,
|
||||
done: tagsLoaded,
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useMediaQuery } from "@mantine/hooks"
|
||||
import { Constants } from "app/constants"
|
||||
import { Constants } from "@/app/constants"
|
||||
|
||||
export const useMobile = (breakpoint: string | number = Constants.layout.mobileBreakpoint) => {
|
||||
const bp = typeof breakpoint === "number" ? `${breakpoint}px` : breakpoint
|
||||
|
||||
17
commafeed-client/src/hooks/useValidationRules.ts
Normal file
17
commafeed-client/src/hooks/useValidationRules.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import { msg } from "@lingui/core/macro"
|
||||
import { useLingui } from "@lingui/react"
|
||||
import { useAppSelector } from "@/app/store"
|
||||
|
||||
export function useValidationRules() {
|
||||
const minimumPasswordLength = useAppSelector(state => state.server.serverInfos?.minimumPasswordLength)
|
||||
const { _ } = useLingui()
|
||||
|
||||
return {
|
||||
password: (value: string | undefined) =>
|
||||
value && minimumPasswordLength && value.length < minimumPasswordLength
|
||||
? _(msg`Password must be at least ${minimumPasswordLength} characters`)
|
||||
: null,
|
||||
passwordConfirmation: (newPasswordConfirmation: string | undefined, newPassword: string | undefined) =>
|
||||
newPasswordConfirmation && newPasswordConfirmation !== newPassword ? _(msg`Passwords do not match`) : null,
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,8 @@
|
||||
import { setWebSocketConnected } from "app/server/slice"
|
||||
import { type AppDispatch, useAppDispatch, useAppSelector } from "app/store"
|
||||
import { newFeedEntriesDiscovered } from "app/tree/thunks"
|
||||
import { useEffect } from "react"
|
||||
import WebsocketHeartbeatJs from "websocket-heartbeat-js"
|
||||
import { setWebSocketConnected } from "@/app/server/slice"
|
||||
import { type AppDispatch, useAppDispatch, useAppSelector } from "@/app/store"
|
||||
import { newFeedEntriesDiscovered } from "@/app/tree/thunks"
|
||||
|
||||
const handleMessage = (dispatch: AppDispatch, message: string) => {
|
||||
const parts = message.split(":")
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { type Messages, i18n } from "@lingui/core"
|
||||
import { useAppSelector } from "app/store"
|
||||
import { i18n, type Messages } from "@lingui/core"
|
||||
import dayjs from "dayjs"
|
||||
import { useEffect } from "react"
|
||||
import { useAppSelector } from "@/app/store"
|
||||
|
||||
interface Locale {
|
||||
key: string
|
||||
@@ -12,34 +12,146 @@ interface Locale {
|
||||
// add an object to the array to add a new locale
|
||||
// don't forget to also add it to the 'locales' array in lingui.config.ts
|
||||
export const locales: Locale[] = [
|
||||
{ key: "ar", label: "العربية", dayjsImportFn: async () => await import("dayjs/locale/ar") },
|
||||
{ key: "ca", label: "Català", dayjsImportFn: async () => await import("dayjs/locale/ca") },
|
||||
{ key: "cs", label: "Čeština", dayjsImportFn: async () => await import("dayjs/locale/cs") },
|
||||
{ key: "cy", label: "Cymraeg", dayjsImportFn: async () => await import("dayjs/locale/cy") },
|
||||
{ key: "da", label: "Danish", dayjsImportFn: async () => await import("dayjs/locale/da") },
|
||||
{ key: "de", label: "Deutsch", dayjsImportFn: async () => await import("dayjs/locale/de") },
|
||||
{ key: "en", label: "English", dayjsImportFn: async () => await import("dayjs/locale/en") },
|
||||
{ key: "es", label: "Español", dayjsImportFn: async () => await import("dayjs/locale/es") },
|
||||
{ key: "fa", label: "فارسی", dayjsImportFn: async () => await import("dayjs/locale/fa") },
|
||||
{ key: "fi", label: "Suomi", dayjsImportFn: async () => await import("dayjs/locale/fi") },
|
||||
{ key: "fr", label: "Français", dayjsImportFn: async () => await import("dayjs/locale/fr") },
|
||||
{ key: "gl", label: "Galician", dayjsImportFn: async () => await import("dayjs/locale/gl") },
|
||||
{ key: "hu", label: "Magyar", dayjsImportFn: async () => await import("dayjs/locale/hu") },
|
||||
{ key: "id", label: "Indonesian", dayjsImportFn: async () => await import("dayjs/locale/id") },
|
||||
{ key: "it", label: "Italiano", dayjsImportFn: async () => await import("dayjs/locale/it") },
|
||||
{ key: "ja", label: "日本語", dayjsImportFn: async () => await import("dayjs/locale/ja") },
|
||||
{ key: "ko", label: "한국어", dayjsImportFn: async () => await import("dayjs/locale/ko") },
|
||||
{ key: "ms", label: "Bahasa Malaysian", dayjsImportFn: async () => await import("dayjs/locale/ms") },
|
||||
{ key: "nb", label: "Norsk (bokmål)", dayjsImportFn: async () => await import("dayjs/locale/nb") },
|
||||
{ key: "nl", label: "Nederlands", dayjsImportFn: async () => await import("dayjs/locale/nl") },
|
||||
{ key: "nn", label: "Norsk (nynorsk)", dayjsImportFn: async () => await import("dayjs/locale/nn") },
|
||||
{ key: "pl", label: "Polski", dayjsImportFn: async () => await import("dayjs/locale/pl") },
|
||||
{ key: "pt", label: "Português", dayjsImportFn: async () => await import("dayjs/locale/pt") },
|
||||
{ key: "ru", label: "Русский", dayjsImportFn: async () => await import("dayjs/locale/ru") },
|
||||
{ key: "sk", label: "Slovenčina", dayjsImportFn: async () => await import("dayjs/locale/sk") },
|
||||
{ key: "sv", label: "Svenska", dayjsImportFn: async () => await import("dayjs/locale/sv") },
|
||||
{ key: "tr", label: "Türkçe", dayjsImportFn: async () => await import("dayjs/locale/tr") },
|
||||
{ key: "zh", label: "简体中文", dayjsImportFn: async () => await import("dayjs/locale/zh") },
|
||||
{
|
||||
key: "ar",
|
||||
label: "العربية",
|
||||
dayjsImportFn: async () => await import("dayjs/locale/ar"),
|
||||
},
|
||||
{
|
||||
key: "ca",
|
||||
label: "Català",
|
||||
dayjsImportFn: async () => await import("dayjs/locale/ca"),
|
||||
},
|
||||
{
|
||||
key: "cs",
|
||||
label: "Čeština",
|
||||
dayjsImportFn: async () => await import("dayjs/locale/cs"),
|
||||
},
|
||||
{
|
||||
key: "cy",
|
||||
label: "Cymraeg",
|
||||
dayjsImportFn: async () => await import("dayjs/locale/cy"),
|
||||
},
|
||||
{
|
||||
key: "da",
|
||||
label: "Danish",
|
||||
dayjsImportFn: async () => await import("dayjs/locale/da"),
|
||||
},
|
||||
{
|
||||
key: "de",
|
||||
label: "Deutsch",
|
||||
dayjsImportFn: async () => await import("dayjs/locale/de"),
|
||||
},
|
||||
{
|
||||
key: "en",
|
||||
label: "English",
|
||||
dayjsImportFn: async () => await import("dayjs/locale/en"),
|
||||
},
|
||||
{
|
||||
key: "es",
|
||||
label: "Español",
|
||||
dayjsImportFn: async () => await import("dayjs/locale/es"),
|
||||
},
|
||||
{
|
||||
key: "fa",
|
||||
label: "فارسی",
|
||||
dayjsImportFn: async () => await import("dayjs/locale/fa"),
|
||||
},
|
||||
{
|
||||
key: "fi",
|
||||
label: "Suomi",
|
||||
dayjsImportFn: async () => await import("dayjs/locale/fi"),
|
||||
},
|
||||
{
|
||||
key: "fr",
|
||||
label: "Français",
|
||||
dayjsImportFn: async () => await import("dayjs/locale/fr"),
|
||||
},
|
||||
{
|
||||
key: "gl",
|
||||
label: "Galician",
|
||||
dayjsImportFn: async () => await import("dayjs/locale/gl"),
|
||||
},
|
||||
{
|
||||
key: "hu",
|
||||
label: "Magyar",
|
||||
dayjsImportFn: async () => await import("dayjs/locale/hu"),
|
||||
},
|
||||
{
|
||||
key: "id",
|
||||
label: "Indonesian",
|
||||
dayjsImportFn: async () => await import("dayjs/locale/id"),
|
||||
},
|
||||
{
|
||||
key: "it",
|
||||
label: "Italiano",
|
||||
dayjsImportFn: async () => await import("dayjs/locale/it"),
|
||||
},
|
||||
{
|
||||
key: "ja",
|
||||
label: "日本語",
|
||||
dayjsImportFn: async () => await import("dayjs/locale/ja"),
|
||||
},
|
||||
{
|
||||
key: "ko",
|
||||
label: "한국어",
|
||||
dayjsImportFn: async () => await import("dayjs/locale/ko"),
|
||||
},
|
||||
{
|
||||
key: "ms",
|
||||
label: "Bahasa Malaysian",
|
||||
dayjsImportFn: async () => await import("dayjs/locale/ms"),
|
||||
},
|
||||
{
|
||||
key: "nb",
|
||||
label: "Norsk (bokmål)",
|
||||
dayjsImportFn: async () => await import("dayjs/locale/nb"),
|
||||
},
|
||||
{
|
||||
key: "nl",
|
||||
label: "Nederlands",
|
||||
dayjsImportFn: async () => await import("dayjs/locale/nl"),
|
||||
},
|
||||
{
|
||||
key: "nn",
|
||||
label: "Norsk (nynorsk)",
|
||||
dayjsImportFn: async () => await import("dayjs/locale/nn"),
|
||||
},
|
||||
{
|
||||
key: "pl",
|
||||
label: "Polski",
|
||||
dayjsImportFn: async () => await import("dayjs/locale/pl"),
|
||||
},
|
||||
{
|
||||
key: "pt",
|
||||
label: "Português",
|
||||
dayjsImportFn: async () => await import("dayjs/locale/pt"),
|
||||
},
|
||||
{
|
||||
key: "ru",
|
||||
label: "Русский",
|
||||
dayjsImportFn: async () => await import("dayjs/locale/ru"),
|
||||
},
|
||||
{
|
||||
key: "sk",
|
||||
label: "Slovenčina",
|
||||
dayjsImportFn: async () => await import("dayjs/locale/sk"),
|
||||
},
|
||||
{
|
||||
key: "sv",
|
||||
label: "Svenska",
|
||||
dayjsImportFn: async () => await import("dayjs/locale/sv"),
|
||||
},
|
||||
{
|
||||
key: "tr",
|
||||
label: "Türkçe",
|
||||
dayjsImportFn: async () => await import("dayjs/locale/tr"),
|
||||
},
|
||||
{
|
||||
key: "zh",
|
||||
label: "简体中文",
|
||||
dayjsImportFn: async () => await import("dayjs/locale/zh"),
|
||||
},
|
||||
]
|
||||
|
||||
function activateLocale(locale: string) {
|
||||
@@ -57,8 +169,12 @@ function activateLocale(locale: string) {
|
||||
}
|
||||
|
||||
export const useI18n = () => {
|
||||
const locale = useAppSelector(state => state.user.settings?.language)
|
||||
const locale =
|
||||
useAppSelector(state => state.user.settings?.language) ??
|
||||
navigator.languages.map(l => l.split("-")[0]).find(l => locales.some(locale => locale.key === l)) ??
|
||||
"en"
|
||||
|
||||
useEffect(() => {
|
||||
activateLocale(locale ?? "en")
|
||||
activateLocale(locale)
|
||||
}, [locale])
|
||||
}
|
||||
|
||||
@@ -15,11 +15,7 @@ msgstr ""
|
||||
|
||||
#: src/pages/app/AboutPage.tsx
|
||||
msgid "<0>CommaFeed is an open-source project. Sources are hosted on </0><1>GitHub</1>."
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/app/FeedDetailsPage.tsx
|
||||
msgid "<0>Complete syntax is available </0><1>here</1>."
|
||||
msgstr ""
|
||||
msgstr "<0>CommaFeed هو مشروع مفتوح المصدر. يتم استضافة المصادر على </0><1>GitHub</1>."
|
||||
|
||||
#: src/pages/auth/RegistrationPage.tsx
|
||||
msgid "<0>Have an account?</0><1>Log in!</1>"
|
||||
@@ -27,7 +23,7 @@ msgstr "<0> هل لديك حساب؟ </0> <1> تسجيل الدخول! </ 1>"
|
||||
|
||||
#: src/pages/app/DonatePage.tsx
|
||||
msgid "<0>Hey,</0><1>I'm Jérémie from Belgium and I've been working on CommaFeed in my free time for over 10 years now. Thanks for taking an interest in helping me continue supporting CommaFeed.</1>"
|
||||
msgstr ""
|
||||
msgstr "<0>مرحبًا،</0><1>أنا جيريمي من بلجيكا وأنا أعمل على CommaFeed في وقت فراغي منذ أكثر من 10 سنوات. شكرًا لاهتمامك بمساعدتي في الاستمرار في دعم CommaFeed.</1>"
|
||||
|
||||
#: src/pages/auth/LoginPage.tsx
|
||||
msgid "<0>Need an account?</0><1>Sign up!</1>"
|
||||
@@ -38,6 +34,10 @@ msgstr "<0> هل تحتاج إلى حساب؟ </0> <1> اشترك! </ 1>"
|
||||
msgid "About"
|
||||
msgstr "حول"
|
||||
|
||||
#: src/components/settings/PushNotificationSettings.tsx
|
||||
msgid "Access token"
|
||||
msgstr "رمز الوصول"
|
||||
|
||||
#: src/pages/admin/AdminUsersPage.tsx
|
||||
msgid "Actions"
|
||||
msgstr "الإجراءات"
|
||||
@@ -60,17 +60,23 @@ msgstr "إضافة مستخدم"
|
||||
msgid "Admin"
|
||||
msgstr "إداري"
|
||||
|
||||
#: src/app/constants.ts
|
||||
#: src/pages/auth/InitialSetupPage.tsx
|
||||
#: src/pages/auth/InitialSetupPage.tsx
|
||||
msgid "Admin user name"
|
||||
msgstr "اسم مستخدم المسؤول"
|
||||
|
||||
#: src/components/content/add/CategorySelect.tsx
|
||||
#: src/components/header/Header.tsx
|
||||
#: src/components/sidebar/Tree.tsx
|
||||
#: src/pages/app/CategoryDetailsPage.tsx
|
||||
#: src/pages/app/FeedEntriesPage.tsx
|
||||
msgid "All"
|
||||
msgstr "الكل"
|
||||
|
||||
#: src/components/settings/DisplaySettings.tsx
|
||||
#: src/components/settings/DisplaySettings.tsx
|
||||
msgid "Always"
|
||||
msgstr ""
|
||||
msgstr "دائمًا"
|
||||
|
||||
#: src/pages/auth/PasswordRecoveryPage.tsx
|
||||
msgid "An email has been sent if this address was registered. Check your inbox."
|
||||
@@ -86,12 +92,20 @@ msgstr "تحليل التغذية"
|
||||
|
||||
#: src/components/AnnouncementDialog.tsx
|
||||
msgid "Announcement"
|
||||
msgstr ""
|
||||
msgstr "إعلان"
|
||||
|
||||
#: src/components/settings/ProfileSettings.tsx
|
||||
msgid "API key"
|
||||
msgstr "مفتاح API"
|
||||
|
||||
#: src/components/settings/PushNotificationSettings.tsx
|
||||
msgid "API token"
|
||||
msgstr "رمز API"
|
||||
|
||||
#: src/components/settings/PushNotificationSettings.tsx
|
||||
msgid "App token"
|
||||
msgstr "رمز التطبيق"
|
||||
|
||||
#: src/pages/app/CategoryDetailsPage.tsx
|
||||
msgid "Are you sure you want to delete category <0>{categoryName}</0>?"
|
||||
msgstr "هل أنت متأكد أنك تريد حذف الفئة <0> {categoryName} </0>؟"
|
||||
@@ -104,11 +118,11 @@ msgstr "هل أنت متأكد أنك تريد حذف المستخدم <0> {user
|
||||
msgid "Are you sure you want to delete your account? There's no turning back!"
|
||||
msgstr "هل أنت متأكد أنك تريد حذف حسابك؟ "
|
||||
|
||||
#: src/components/header/MarkAllAsReadButton.tsx
|
||||
#: src/components/MarkAllAsReadConfirmationDialog.tsx
|
||||
msgid "Are you sure you want to mark all entries of <0>{sourceLabel}</0> as read?"
|
||||
msgstr "هل أنت متأكد أنك تريد تعليم كافة إدخالات <0> {sourceLabel} </0> كمقروءة؟"
|
||||
|
||||
#: src/components/header/MarkAllAsReadButton.tsx
|
||||
#: src/components/MarkAllAsReadConfirmationDialog.tsx
|
||||
msgid "Are you sure you want to mark entries older than {threshold} days of <0>{sourceLabel}</0> as read?"
|
||||
msgstr "هل أنت متأكد أنك تريد وضع علامة على الإدخالات الأقدم من {عتبة} يوم من <0> {sourceLabel} </0> كمقروءة؟"
|
||||
|
||||
@@ -121,36 +135,46 @@ msgid "Asc"
|
||||
msgstr "تصاعدي"
|
||||
|
||||
#: src/pages/app/FeedDetailsPage.tsx
|
||||
msgid "Available variables are 'title', 'content', 'url' 'author' and 'categories' and their content is converted to lower case to ease string comparison."
|
||||
msgstr "المتغيرات المتاحة هي \"العنوان\" و \"المحتوى\" و \"url\" و \"المؤلف\" و \"الفئات\" ويتم تحويل محتواها إلى أحرف صغيرة لتسهيل مقارنة السلسلة."
|
||||
msgid "Auto-mark as read"
|
||||
msgstr "وضع علامة مقروء تلقائيًا"
|
||||
|
||||
#: src/components/content/add/Subscribe.tsx
|
||||
msgid "Back"
|
||||
msgstr "العودة"
|
||||
|
||||
#: src/pages/auth/PasswordRecoveryPage.tsx
|
||||
#: src/pages/auth/PasswordResetPage.tsx
|
||||
msgid "Back to log in"
|
||||
msgstr "العودة لتسجيل الدخول"
|
||||
|
||||
#: src/components/KeyboardShortcutsHelp.tsx
|
||||
msgid "Browser extension required for Chrome"
|
||||
msgstr ""
|
||||
#: src/components/settings/DisplaySettings.tsx
|
||||
msgid "Blue"
|
||||
msgstr "أزرق"
|
||||
|
||||
#: src/pages/app/AboutPage.tsx
|
||||
msgid "Browser extention"
|
||||
msgstr ""
|
||||
msgid "Browser extension"
|
||||
msgstr "إضافة المتصفح"
|
||||
|
||||
#: src/components/KeyboardShortcutsHelp.tsx
|
||||
msgid "Browser extension required for Chrome"
|
||||
msgstr "إضافة المتصفح مطلوبة لـ Chrome"
|
||||
|
||||
#: src/components/settings/DisplaySettings.tsx
|
||||
msgid "Browser tab"
|
||||
msgstr ""
|
||||
msgstr "علامة تبويب المتصفح"
|
||||
|
||||
#: src/pages/app/FeedDetailsPage.tsx
|
||||
msgid "Build a filter expression to indicate what you want to read. Entries that don't match will be marked as read automatically."
|
||||
msgstr "قم ببناء تعبير تصفية لتحديد ما تريد قراءته. سيتم وضع علامة مقروء على الإدخالات التي لا تتطابق تلقائيًا."
|
||||
|
||||
#: src/components/admin/UserEdit.tsx
|
||||
#: src/components/content/add/AddCategory.tsx
|
||||
#: src/components/content/add/ImportOpml.tsx
|
||||
#: src/components/header/MarkAllAsReadButton.tsx
|
||||
#: src/components/MarkAllAsReadConfirmationDialog.tsx
|
||||
#: src/components/settings/CustomCodeSettings.tsx
|
||||
#: src/components/settings/ProfileSettings.tsx
|
||||
#: src/components/settings/ProfileSettings.tsx
|
||||
#: src/components/settings/PushNotificationSettings.tsx
|
||||
#: src/pages/admin/AdminUsersPage.tsx
|
||||
#: src/pages/app/CategoryDetailsPage.tsx
|
||||
#: src/pages/app/CategoryDetailsPage.tsx
|
||||
@@ -178,19 +202,19 @@ msgstr "تأكد من عمل الخلاصة"
|
||||
|
||||
#: src/pages/app/Layout.tsx
|
||||
msgid "Close menu"
|
||||
msgstr ""
|
||||
msgstr "إغلاق القائمة"
|
||||
|
||||
#: src/components/KeyboardShortcutsHelp.tsx
|
||||
msgid "Cmd"
|
||||
msgstr ""
|
||||
msgstr "Cmd"
|
||||
|
||||
#: src/pages/app/AboutPage.tsx
|
||||
msgid "CommaFeed browser extension version {browserExtensionVersion}."
|
||||
msgstr ""
|
||||
msgstr "إصدار إضافة متصفح CommaFeed هو {browserExtensionVersion}."
|
||||
|
||||
#: src/components/settings/ProfileSettings.tsx
|
||||
msgid "CommaFeed is compatible with the Fever API. Use the following URL in your Fever-compatible mobile client. Login with your username and your <0>API key</0>."
|
||||
msgstr ""
|
||||
msgstr "CommaFeed متوافق مع Fever API. استخدم عنوان URL التالي في تطبيق الهاتف المتوافق مع Fever. قم بتسجيل الدخول باستخدام اسم المستخدم و <0>مفتاح API</0> الخاص بك."
|
||||
|
||||
#: src/pages/app/AboutPage.tsx
|
||||
msgid "CommaFeed next unread item"
|
||||
@@ -198,13 +222,13 @@ msgstr "CommaFeed التالي العنصر غير المقروء"
|
||||
|
||||
#: src/pages/app/AboutPage.tsx
|
||||
msgid "CommaFeed version {version} ({revision})."
|
||||
msgstr ""
|
||||
msgstr "إصدار CommaFeed هو {version} ({revision})."
|
||||
|
||||
#: src/components/header/ProfileMenu.tsx
|
||||
msgid "Compact"
|
||||
msgstr "مضغوط"
|
||||
|
||||
#: src/components/header/MarkAllAsReadButton.tsx
|
||||
#: src/components/MarkAllAsReadConfirmationDialog.tsx
|
||||
#: src/components/settings/ProfileSettings.tsx
|
||||
#: src/pages/admin/AdminUsersPage.tsx
|
||||
#: src/pages/app/CategoryDetailsPage.tsx
|
||||
@@ -216,10 +240,19 @@ msgstr "تأكيد"
|
||||
msgid "Confirm password"
|
||||
msgstr "تأكيد كلمة المرور"
|
||||
|
||||
#: src/pages/auth/PasswordResetPage.tsx
|
||||
#: src/pages/auth/PasswordResetPage.tsx
|
||||
msgid "Confirm Password"
|
||||
msgstr "تأكيد كلمة المرور"
|
||||
|
||||
#: src/components/header/ProfileMenu.tsx
|
||||
msgid "Cozy"
|
||||
msgstr "دافئ"
|
||||
|
||||
#: src/pages/auth/InitialSetupPage.tsx
|
||||
msgid "Create Admin Account"
|
||||
msgstr "إنشاء حساب مسؤول"
|
||||
|
||||
#: src/components/KeyboardShortcutsHelp.tsx
|
||||
msgid "Ctrl"
|
||||
msgstr "السيطرة"
|
||||
@@ -230,24 +263,33 @@ msgstr "كلمة المرور الحالية"
|
||||
|
||||
#: src/pages/app/SettingsPage.tsx
|
||||
msgid "Custom code"
|
||||
msgstr ""
|
||||
msgstr "كود مخصص"
|
||||
|
||||
#: src/components/settings/CustomCodeSettings.tsx
|
||||
msgid "Custom CSS rules that will be applied"
|
||||
msgstr ""
|
||||
msgstr "قواعد CSS المخصصة التي سيتم تطبيقها"
|
||||
|
||||
#: src/components/settings/CustomCodeSettings.tsx
|
||||
msgid "Custom JS code that will be executed on page load"
|
||||
msgstr ""
|
||||
msgstr "كود JS المخصص الذي سيتم تنفيذه عند تحميل الصفحة"
|
||||
|
||||
#: src/components/settings/DisplaySettings.tsx
|
||||
msgid "Cyan"
|
||||
msgstr "سيان"
|
||||
|
||||
#: src/components/header/ProfileMenu.tsx
|
||||
#: src/components/settings/DisplaySettings.tsx
|
||||
msgid "Dark"
|
||||
msgstr ""
|
||||
msgstr "داكن"
|
||||
|
||||
#: src/pages/admin/AdminUsersPage.tsx
|
||||
msgid "Date created"
|
||||
msgstr "تاريخ الإنشاء"
|
||||
|
||||
#: src/pages/app/FeedDetailsPage.tsx
|
||||
msgid "days"
|
||||
msgstr "أيام"
|
||||
|
||||
#: src/pages/app/CategoryDetailsPage.tsx
|
||||
msgid "Delete"
|
||||
msgstr "حذف"
|
||||
@@ -271,9 +313,14 @@ msgstr "تنازلي"
|
||||
|
||||
#: src/components/header/ProfileMenu.tsx
|
||||
msgid "Detailed"
|
||||
msgstr ""
|
||||
msgstr "مفصل"
|
||||
|
||||
#: src/components/settings/DisplaySettings.tsx
|
||||
msgid "Disable \"Pull to refresh\" browser behavior"
|
||||
msgstr "تعطيل سلوك المتصفح \"اسحب للتحديث\""
|
||||
|
||||
#: src/components/header/ProfileMenu.tsx
|
||||
#: src/components/settings/DisplaySettings.tsx
|
||||
#: src/pages/app/SettingsPage.tsx
|
||||
msgid "Display"
|
||||
msgstr "عرض"
|
||||
@@ -281,7 +328,7 @@ msgstr "عرض"
|
||||
#: src/components/header/ProfileMenu.tsx
|
||||
#: src/pages/app/DonatePage.tsx
|
||||
msgid "Donate"
|
||||
msgstr ""
|
||||
msgstr "تبرع"
|
||||
|
||||
#: src/components/settings/ProfileSettings.tsx
|
||||
msgid "Download"
|
||||
@@ -294,6 +341,8 @@ msgstr "اسحب الرابط إلى شريط الإشارات"
|
||||
#: src/components/admin/UserEdit.tsx
|
||||
#: src/components/settings/ProfileSettings.tsx
|
||||
#: src/pages/admin/AdminUsersPage.tsx
|
||||
#: src/pages/auth/InitialSetupPage.tsx
|
||||
#: src/pages/auth/InitialSetupPage.tsx
|
||||
#: src/pages/auth/PasswordRecoveryPage.tsx
|
||||
#: src/pages/auth/PasswordRecoveryPage.tsx
|
||||
msgid "E-mail"
|
||||
@@ -323,20 +372,16 @@ msgstr "أدخل كلمة المرور الحالية لتغيير إعدادا
|
||||
|
||||
#: src/components/settings/DisplaySettings.tsx
|
||||
msgid "Entries to keep above the selected entry when scrolling"
|
||||
msgstr ""
|
||||
msgstr "عدد الإدخالات التي يجب الاحتفاظ بها فوق الإدخال المحدد عند التمرير"
|
||||
|
||||
#: src/components/settings/DisplaySettings.tsx
|
||||
msgid "Entry headers"
|
||||
msgstr ""
|
||||
msgstr "عناوين الإدخالات"
|
||||
|
||||
#: src/components/Alert.tsx
|
||||
msgid "Error"
|
||||
msgstr "خطأ"
|
||||
|
||||
#: src/pages/app/FeedDetailsPage.tsx
|
||||
msgid "Example: {example}."
|
||||
msgstr "مثال: {مثال}."
|
||||
|
||||
#: src/components/header/ProfileMenu.tsx
|
||||
msgid "Expanded"
|
||||
msgstr "موسع"
|
||||
@@ -348,7 +393,7 @@ msgstr "قم بتصدير اشتراكاتك وفئاتك كملف OPML يمكن
|
||||
#: src/components/header/Header.tsx
|
||||
#: src/pages/WelcomePage.tsx
|
||||
msgid "Extension options"
|
||||
msgstr ""
|
||||
msgstr "خيارات الإضافة"
|
||||
|
||||
#: src/components/content/add/Subscribe.tsx
|
||||
msgid "Feed name"
|
||||
@@ -362,23 +407,27 @@ msgstr "موجز URL"
|
||||
|
||||
#: src/components/header/ProfileMenu.tsx
|
||||
msgid "Fetch all my feeds now"
|
||||
msgstr ""
|
||||
msgstr "تحديث جميع الخلاصات الآن"
|
||||
|
||||
#: src/components/settings/ProfileSettings.tsx
|
||||
msgid "Fever API"
|
||||
msgstr ""
|
||||
msgstr "Fever API"
|
||||
|
||||
#: src/components/settings/ProfileSettings.tsx
|
||||
msgid "Fever API URL"
|
||||
msgstr ""
|
||||
msgstr "رابط Fever API"
|
||||
|
||||
#: src/pages/app/FeedDetailsPage.tsx
|
||||
msgid "Filtering expression"
|
||||
msgstr "تصفية التعبير"
|
||||
|
||||
#: src/components/header/ProfileMenu.tsx
|
||||
msgid "Font size"
|
||||
msgstr "حجم الخط"
|
||||
|
||||
#: src/components/header/ProfileMenu.tsx
|
||||
msgid "Force fetching feeds is not yet available."
|
||||
msgstr ""
|
||||
msgstr "التحديث الإجباري للخلاصات غير متاح بعد."
|
||||
|
||||
#: src/pages/auth/LoginPage.tsx
|
||||
msgid "Forgot password?"
|
||||
@@ -400,9 +449,10 @@ msgstr "إنشاء مفتاح API جديد"
|
||||
msgid "Generated feed url"
|
||||
msgstr "رابط الخلاصة المولدة"
|
||||
|
||||
#. placeholder {0}: truncate(props.entry.feedName, 30)
|
||||
#: src/components/content/FeedEntryContextMenu.tsx
|
||||
msgid "Go to {0}"
|
||||
msgstr ""
|
||||
msgstr "الذهاب إلى {0}"
|
||||
|
||||
#: src/components/KeyboardShortcutsHelp.tsx
|
||||
msgid "Go to the All view"
|
||||
@@ -416,17 +466,25 @@ msgstr "انتقل إلى وثائق API."
|
||||
msgid "Goodies"
|
||||
msgstr "الأشياء الجيدة"
|
||||
|
||||
#: src/components/settings/DisplaySettings.tsx
|
||||
msgid "Grape"
|
||||
msgstr "عنبي"
|
||||
|
||||
#: src/components/settings/DisplaySettings.tsx
|
||||
msgid "Gray"
|
||||
msgstr "رمادي"
|
||||
|
||||
#: src/components/settings/DisplaySettings.tsx
|
||||
msgid "Green"
|
||||
msgstr "أخضر"
|
||||
|
||||
#: src/pages/admin/AdminUsersPage.tsx
|
||||
msgid "Id"
|
||||
msgstr "المرجع نفسه"
|
||||
|
||||
#: src/pages/app/FeedDetailsPage.tsx
|
||||
msgid "If not empty, an expression evaluating to 'true' or 'false'. If false, new entries for this feed will be marked as read automatically."
|
||||
msgstr "إذا لم يكن فارغًا ، فسيتم تقييم التعبير إلى \"صواب\" أو \"خطأ\". "
|
||||
|
||||
#: src/components/settings/DisplaySettings.tsx
|
||||
msgid "If the entry doesn't entirely fit on the screen"
|
||||
msgstr ""
|
||||
msgstr "إذا كان الإدخال لا يتناسب تمامًا مع الشاشة"
|
||||
|
||||
#: src/pages/app/AboutPage.tsx
|
||||
msgid "If you encounter an issue, please report it on the issues page of the GitHub project."
|
||||
@@ -440,6 +498,18 @@ msgstr "استيراد"
|
||||
msgid "In expanded view, scrolling through entries mark them as read"
|
||||
msgstr "في العرض الموسع ، التمرير عبر الإدخالات وضع علامة عليها كمقروءة"
|
||||
|
||||
#: src/components/settings/DisplaySettings.tsx
|
||||
msgid "Indigo"
|
||||
msgstr "نيلي"
|
||||
|
||||
#: src/pages/auth/InitialSetupPage.tsx
|
||||
msgid "Initial Setup"
|
||||
msgstr "الإعداد الأولي"
|
||||
|
||||
#: src/pages/auth/PasswordResetPage.tsx
|
||||
msgid "Invalid password reset link. Please request a new one."
|
||||
msgstr "رابط إعادة تعيين كلمة المرور غير صالح. يرجى طلب رابط جديد."
|
||||
|
||||
#: src/components/content/FeedEntryContextMenu.tsx
|
||||
#: src/components/content/FeedEntryFooter.tsx
|
||||
msgid "Keep unread"
|
||||
@@ -468,7 +538,11 @@ msgstr "آخر رسالة تحديث"
|
||||
|
||||
#: src/components/header/ProfileMenu.tsx
|
||||
msgid "Light"
|
||||
msgstr ""
|
||||
msgstr "فاتح"
|
||||
|
||||
#: src/components/settings/DisplaySettings.tsx
|
||||
msgid "Lime"
|
||||
msgstr "ليموني"
|
||||
|
||||
#: src/pages/app/CategoryDetailsPage.tsx
|
||||
#: src/pages/app/FeedDetailsPage.tsx
|
||||
@@ -476,6 +550,10 @@ msgstr ""
|
||||
msgid "Link"
|
||||
msgstr "رابط"
|
||||
|
||||
#: src/components/settings/CustomCodeSettings.tsx
|
||||
msgid "Link to the documentation"
|
||||
msgstr "رابط الوثائق"
|
||||
|
||||
#: src/hooks/useAppLoading.ts
|
||||
msgid "Loading profile..."
|
||||
msgstr "تحميل ملف التعريف ..."
|
||||
@@ -504,19 +582,19 @@ msgstr "تسجيل الخروج"
|
||||
|
||||
#: src/components/KeyboardShortcutsHelp.tsx
|
||||
msgid "Long press"
|
||||
msgstr ""
|
||||
msgstr "ضغط مطول"
|
||||
|
||||
#: src/components/header/ProfileMenu.tsx
|
||||
#: src/pages/admin/AdminUsersPage.tsx
|
||||
msgid "Manage users"
|
||||
msgstr "إدارة المستخدمين"
|
||||
|
||||
#: src/components/header/MarkAllAsReadButton.tsx
|
||||
#: src/components/header/Header.tsx
|
||||
msgid "Mark all as read"
|
||||
msgstr "تعليم الكل كمقروء"
|
||||
|
||||
#: src/components/header/MarkAllAsReadButton.tsx
|
||||
#: src/components/KeyboardShortcutsHelp.tsx
|
||||
#: src/components/MarkAllAsReadConfirmationDialog.tsx
|
||||
msgid "Mark all entries as read"
|
||||
msgstr "تعليم كافة الإدخالات كمقروءة"
|
||||
|
||||
@@ -530,13 +608,17 @@ msgstr "وضع علامة كمقروء"
|
||||
msgid "Mark as read up to here"
|
||||
msgstr "وضع علامة كمقروءة حتى هنا"
|
||||
|
||||
#: src/pages/app/FeedDetailsPage.tsx
|
||||
msgid "Mark entries in this feed as read after this number of days. Leave empty to disable."
|
||||
msgstr "ضع علامة مقروء على الإدخالات في هذه الخلاصة بعد هذا العدد من الأيام. اتركه فارغًا للتعطيل."
|
||||
|
||||
#: src/components/header/ProfileMenu.tsx
|
||||
msgid "Metrics"
|
||||
msgstr "المقاييس"
|
||||
|
||||
#: src/components/KeyboardShortcutsHelp.tsx
|
||||
msgid "Middle click"
|
||||
msgstr ""
|
||||
msgstr "نقرة بالزر الأوسط"
|
||||
|
||||
#: src/components/KeyboardShortcutsHelp.tsx
|
||||
msgid "Move the page down"
|
||||
@@ -562,15 +644,24 @@ msgstr "الاسم"
|
||||
msgid "Navigate to a subscription by entering its name"
|
||||
msgstr "انتقل إلى اشتراك بإدخال اسمه"
|
||||
|
||||
#: src/components/settings/DisplaySettings.tsx
|
||||
msgid "Navigate to the next category/feed with unread entries when marking all entries as read"
|
||||
msgstr "انتقل إلى الفئة/الخلاصة التالية التي تحتوي على إدخالات غير مقروءة عند تمييز الكل كمقروء"
|
||||
|
||||
#: src/components/settings/DisplaySettings.tsx
|
||||
#: src/components/settings/DisplaySettings.tsx
|
||||
msgid "Never"
|
||||
msgstr ""
|
||||
msgstr "أبدا"
|
||||
|
||||
#: src/components/settings/ProfileSettings.tsx
|
||||
msgid "New password"
|
||||
msgstr "كلمة مرور جديدة"
|
||||
|
||||
#: src/pages/auth/PasswordResetPage.tsx
|
||||
#: src/pages/auth/PasswordResetPage.tsx
|
||||
msgid "New Password"
|
||||
msgstr "كلمة مرور جديدة"
|
||||
|
||||
#: src/pages/app/AboutPage.tsx
|
||||
msgid "Newest first"
|
||||
msgstr "الأحدث أولاً"
|
||||
@@ -594,7 +685,7 @@ msgstr "لا مزيد من الإدخالات"
|
||||
|
||||
#: src/components/content/ShareButtons.tsx
|
||||
msgid "No sharing options available."
|
||||
msgstr ""
|
||||
msgstr "لا توجد خيارات مشاركة متاحة."
|
||||
|
||||
#: src/components/sidebar/TreeSearch.tsx
|
||||
msgid "Nothing found"
|
||||
@@ -606,27 +697,27 @@ msgstr "الأقدم أولا"
|
||||
|
||||
#: src/components/settings/DisplaySettings.tsx
|
||||
msgid "On desktop"
|
||||
msgstr ""
|
||||
msgstr "على سطح المكتب"
|
||||
|
||||
#: src/components/settings/DisplaySettings.tsx
|
||||
msgid "On mobile"
|
||||
msgstr ""
|
||||
msgstr "على الهاتف"
|
||||
|
||||
#: src/components/settings/DisplaySettings.tsx
|
||||
msgid "On mobile, show action buttons at the bottom of the screen"
|
||||
msgstr ""
|
||||
msgstr "على الهاتف، أظهر أزرار الإجراءات في أسفل الشاشة"
|
||||
|
||||
#: src/components/settings/DisplaySettings.tsx
|
||||
msgid "Only applies to compact, cozy and detailed modes"
|
||||
msgstr ""
|
||||
msgstr "ينطبق فقط على الأوضاع المضغوطة، والدافئة، والمفصلة"
|
||||
|
||||
#: src/pages/ErrorPage.tsx
|
||||
msgid "Oops!"
|
||||
msgstr "اوووه!"
|
||||
msgstr "عذراً!"
|
||||
|
||||
#: src/components/header/Header.tsx
|
||||
msgid "Open CommaFeed"
|
||||
msgstr ""
|
||||
msgstr "افتح CommaFeed"
|
||||
|
||||
#: src/components/KeyboardShortcutsHelp.tsx
|
||||
msgid "Open current entry in a new tab"
|
||||
@@ -643,15 +734,15 @@ msgstr "افتح الرابط"
|
||||
|
||||
#: src/components/content/FeedEntryContextMenu.tsx
|
||||
msgid "Open link in new background tab"
|
||||
msgstr ""
|
||||
msgstr "افتح الرابط في علامة تبويب خلفية جديدة"
|
||||
|
||||
#: src/components/content/FeedEntryContextMenu.tsx
|
||||
msgid "Open link in new tab"
|
||||
msgstr ""
|
||||
msgstr "افتح الرابط في علامة تبويب جديدة"
|
||||
|
||||
#: src/pages/app/Layout.tsx
|
||||
msgid "Open menu"
|
||||
msgstr ""
|
||||
msgstr "افتح القائمة"
|
||||
|
||||
#: src/components/KeyboardShortcutsHelp.tsx
|
||||
msgid "Open next entry"
|
||||
@@ -667,7 +758,7 @@ msgstr "فتح / إغلاق الإدخال الحالي"
|
||||
|
||||
#: src/pages/app/AddPage.tsx
|
||||
msgid "OPML"
|
||||
msgstr ""
|
||||
msgstr "OPML"
|
||||
|
||||
#: src/components/settings/ProfileSettings.tsx
|
||||
msgid "OPML export"
|
||||
@@ -680,11 +771,15 @@ msgstr "ملف OPML"
|
||||
|
||||
#: src/components/content/add/ImportOpml.tsx
|
||||
msgid "OPML file is required"
|
||||
msgstr ""
|
||||
msgstr "ملف OPML مطلوب"
|
||||
|
||||
#: src/components/settings/DisplaySettings.tsx
|
||||
msgid "Orange"
|
||||
msgstr "برتقالي"
|
||||
|
||||
#: src/pages/app/AboutPage.tsx
|
||||
msgid "Order"
|
||||
msgstr "طلب"
|
||||
msgstr "ترتيب"
|
||||
|
||||
#: src/components/content/add/AddCategory.tsx
|
||||
msgid "Parent"
|
||||
@@ -695,6 +790,8 @@ msgid "Parent Category"
|
||||
msgstr "الفئة الأصل"
|
||||
|
||||
#: src/components/admin/UserEdit.tsx
|
||||
#: src/pages/auth/InitialSetupPage.tsx
|
||||
#: src/pages/auth/InitialSetupPage.tsx
|
||||
#: src/pages/auth/LoginPage.tsx
|
||||
#: src/pages/auth/LoginPage.tsx
|
||||
#: src/pages/auth/RegistrationPage.tsx
|
||||
@@ -702,31 +799,72 @@ msgstr "الفئة الأصل"
|
||||
msgid "Password"
|
||||
msgstr "كلمة المرور"
|
||||
|
||||
#: src/hooks/useValidationRules.ts
|
||||
msgid "Password must be at least {minimumPasswordLength} characters"
|
||||
msgstr "يجب أن تكون كلمة المرور على الأقل {minimumPasswordLength} أحرف"
|
||||
|
||||
#: src/pages/auth/PasswordRecoveryPage.tsx
|
||||
msgid "Password Recovery"
|
||||
msgstr "استعادة كلمة المرور"
|
||||
|
||||
#: src/components/settings/ProfileSettings.tsx
|
||||
#: src/hooks/useValidationRules.ts
|
||||
msgid "Passwords do not match"
|
||||
msgstr "كلمات المرور غير متطابقة"
|
||||
|
||||
#: src/components/settings/DisplaySettings.tsx
|
||||
msgid "Pink"
|
||||
msgstr "وردي"
|
||||
|
||||
#: src/pages/app/CategoryDetailsPage.tsx
|
||||
#: src/pages/app/FeedDetailsPage.tsx
|
||||
msgid "Position"
|
||||
msgstr "المنـصب"
|
||||
msgstr "المنصب"
|
||||
|
||||
#: src/components/header/Header.tsx
|
||||
msgid "Previous"
|
||||
msgstr ""
|
||||
msgstr "السابق"
|
||||
|
||||
#: src/components/settings/DisplaySettings.tsx
|
||||
msgid "Primary color"
|
||||
msgstr "اللون الأساسي"
|
||||
|
||||
#: src/pages/app/SettingsPage.tsx
|
||||
msgid "Profile"
|
||||
msgstr "الملف الشخصي"
|
||||
|
||||
#: src/components/settings/PushNotificationSettings.tsx
|
||||
msgid "Push notification service"
|
||||
msgstr "خدمة إشعارات الدفع"
|
||||
|
||||
#: src/pages/app/SettingsPage.tsx
|
||||
msgid "Push notifications"
|
||||
msgstr "إشعارات الدفع"
|
||||
|
||||
#: src/components/ReceivePushNotificationsChechbox.tsx
|
||||
msgid "Push notifications are not configured in your user settings."
|
||||
msgstr "لم يتم تكوين إشعارات الدفع في إعدادات المستخدم الخاصة بك."
|
||||
|
||||
#: src/components/ReceivePushNotificationsChechbox.tsx
|
||||
#: src/components/settings/PushNotificationSettings.tsx
|
||||
msgid "Push notifications are not enabled on this CommaFeed instance."
|
||||
msgstr "إشعارات الدفع غير مفعلة في مثيل CommaFeed هذا."
|
||||
|
||||
#: src/components/ReceivePushNotificationsChechbox.tsx
|
||||
msgid "Receive push notifications"
|
||||
msgstr "تلقي إشعارات الدفع"
|
||||
|
||||
#: src/components/settings/PushNotificationSettings.tsx
|
||||
msgid "Receive push notifications when new feed entries are discovered. Enable \"Receive push notifications\" in the settings of each feed for which you want to receive notifications."
|
||||
msgstr "تلقي إشعارات الدفع عند اكتشاف إدخالات خلاصة جديدة. قم بتمكين \"تلقي إشعارات الدفع\" في إعدادات كل خلاصة تريد تلقي إشعارات لها."
|
||||
|
||||
#: src/pages/auth/PasswordRecoveryPage.tsx
|
||||
msgid "Recover password"
|
||||
msgstr "استعادة كلمة السر"
|
||||
|
||||
#: src/components/settings/DisplaySettings.tsx
|
||||
msgid "Red"
|
||||
msgstr "أحمر"
|
||||
|
||||
#: src/components/header/Header.tsx
|
||||
#: src/components/KeyboardShortcutsHelp.tsx
|
||||
msgid "Refresh"
|
||||
@@ -736,18 +874,24 @@ msgstr "تحديث"
|
||||
msgid "Registrations are closed on this CommaFeed instance"
|
||||
msgstr "تم إغلاق التسجيلات في مثيل CommaFeed هذا"
|
||||
|
||||
#: src/pages/auth/PasswordResetPage.tsx
|
||||
#: src/pages/auth/PasswordResetPage.tsx
|
||||
msgid "Reset Password"
|
||||
msgstr "إعادة تعيين كلمة المرور"
|
||||
|
||||
#: src/pages/app/AboutPage.tsx
|
||||
msgid "REST API"
|
||||
msgstr ""
|
||||
msgstr "REST API"
|
||||
|
||||
#: src/components/KeyboardShortcutsHelp.tsx
|
||||
#: src/components/KeyboardShortcutsHelp.tsx
|
||||
msgid "Right click"
|
||||
msgstr ""
|
||||
msgstr "نقرة بالزر الأيمن"
|
||||
|
||||
#: src/components/admin/UserEdit.tsx
|
||||
#: src/components/settings/CustomCodeSettings.tsx
|
||||
#: src/components/settings/ProfileSettings.tsx
|
||||
#: src/components/settings/PushNotificationSettings.tsx
|
||||
#: src/pages/app/CategoryDetailsPage.tsx
|
||||
#: src/pages/app/FeedDetailsPage.tsx
|
||||
msgid "Save"
|
||||
@@ -755,7 +899,7 @@ msgstr "حفظ"
|
||||
|
||||
#: src/components/settings/DisplaySettings.tsx
|
||||
msgid "Scroll selected entry to the top of the page"
|
||||
msgstr ""
|
||||
msgstr "تمرير الإدخال المحدد إلى أعلى الصفحة"
|
||||
|
||||
#: src/components/settings/DisplaySettings.tsx
|
||||
msgid "Scroll smoothly when navigating between entries"
|
||||
@@ -763,7 +907,7 @@ msgstr "قم بالتمرير بسلاسة عند التنقل بين الإدخ
|
||||
|
||||
#: src/components/settings/DisplaySettings.tsx
|
||||
msgid "Scrolling"
|
||||
msgstr ""
|
||||
msgstr "التمرير"
|
||||
|
||||
#: src/components/header/Header.tsx
|
||||
#: src/components/header/Header.tsx
|
||||
@@ -772,9 +916,18 @@ msgstr ""
|
||||
msgid "Search"
|
||||
msgstr "بحث"
|
||||
|
||||
#: src/components/header/Header.tsx
|
||||
msgid "Search requires at least 3 characters"
|
||||
msgstr "يتطلب البحث 3 أحرف على الأقل"
|
||||
#: src/components/KeyboardShortcutsHelp.tsx
|
||||
msgid "Select next unread feed/category"
|
||||
msgstr "تحديد الخلاصة/الفئة التالية غير المقروءة"
|
||||
|
||||
#: src/components/KeyboardShortcutsHelp.tsx
|
||||
msgid "Select previous unread feed/category"
|
||||
msgstr "تحديد الخلاصة/الفئة السابقة غير المقروءة"
|
||||
|
||||
#: src/components/settings/PushNotificationSettings.tsx
|
||||
#: src/components/settings/PushNotificationSettings.tsx
|
||||
msgid "Server URL"
|
||||
msgstr "رابط الخادم"
|
||||
|
||||
#: src/components/KeyboardShortcutsHelp.tsx
|
||||
msgid "Set focus on next entry without opening it"
|
||||
@@ -808,23 +961,23 @@ msgstr "الحلقة"
|
||||
|
||||
#: src/components/settings/DisplaySettings.tsx
|
||||
msgid "Show CommaFeed's own context menu on right click"
|
||||
msgstr ""
|
||||
msgstr "إظهار قائمة السياق الخاصة بـ CommaFeed عند النقر بزر الماوس الأيمن"
|
||||
|
||||
#: src/components/settings/DisplaySettings.tsx
|
||||
msgid "Show confirmation when marking all entries as read"
|
||||
msgstr ""
|
||||
msgstr "إظهار تأكيد عند وضع علامة مقروء على كافة الإدخالات"
|
||||
|
||||
#: src/components/KeyboardShortcutsHelp.tsx
|
||||
msgid "Show entry menu (desktop)"
|
||||
msgstr ""
|
||||
msgstr "إظهار قائمة الإدخال (سطح المكتب)"
|
||||
|
||||
#: src/components/KeyboardShortcutsHelp.tsx
|
||||
msgid "Show entry menu (mobile)"
|
||||
msgstr ""
|
||||
msgstr "إظهار قائمة الإدخال (الهاتف)"
|
||||
|
||||
#: src/components/settings/DisplaySettings.tsx
|
||||
msgid "Show external link icon"
|
||||
msgstr ""
|
||||
msgstr "إظهار أيقونة الرابط الخارجي"
|
||||
|
||||
#: src/components/settings/DisplaySettings.tsx
|
||||
msgid "Show feeds and categories with no unread entries"
|
||||
@@ -836,19 +989,19 @@ msgstr "إظهار تعليمات اختصار لوحة المفاتيح"
|
||||
|
||||
#: src/components/KeyboardShortcutsHelp.tsx
|
||||
msgid "Show native menu (desktop)"
|
||||
msgstr ""
|
||||
msgstr "إظهار القائمة الأصلية (سطح المكتب)"
|
||||
|
||||
#: src/components/settings/DisplaySettings.tsx
|
||||
msgid "Show star icon"
|
||||
msgstr ""
|
||||
msgstr "إظهار أيقونة النجمة"
|
||||
|
||||
#: src/components/settings/DisplaySettings.tsx
|
||||
msgid "Show unread count in tab favicon"
|
||||
msgstr ""
|
||||
msgstr "إظهار عدد غير المقروء في أيقونة التبويب"
|
||||
|
||||
#: src/components/settings/DisplaySettings.tsx
|
||||
msgid "Show unread count in tab title"
|
||||
msgstr ""
|
||||
msgstr "إظهار عدد غير المقروء في عنوان التبويب"
|
||||
|
||||
#: src/pages/auth/RegistrationPage.tsx
|
||||
#: src/pages/auth/RegistrationPage.tsx
|
||||
@@ -871,8 +1024,9 @@ msgstr "فضاء"
|
||||
msgid "Star"
|
||||
msgstr "النجم"
|
||||
|
||||
#: src/app/constants.ts
|
||||
#: src/components/sidebar/Tree.tsx
|
||||
#: src/pages/app/CategoryDetailsPage.tsx
|
||||
#: src/pages/app/FeedEntriesPage.tsx
|
||||
msgid "Starred"
|
||||
msgstr "مميز بنجمة"
|
||||
|
||||
@@ -896,7 +1050,7 @@ msgstr "النجاح"
|
||||
|
||||
#: src/components/KeyboardShortcutsHelp.tsx
|
||||
msgid "Swipe header to the left"
|
||||
msgstr ""
|
||||
msgstr "اسحب الرأس لليسار"
|
||||
|
||||
#: src/pages/WelcomePage.tsx
|
||||
msgid "Switch to dark theme"
|
||||
@@ -908,13 +1062,25 @@ msgstr "قم بالتبديل إلى النسق الفاتح"
|
||||
|
||||
#: src/components/header/ProfileMenu.tsx
|
||||
msgid "System"
|
||||
msgstr ""
|
||||
msgstr "النظام"
|
||||
|
||||
#: src/components/content/FeedEntryFooter.tsx
|
||||
#: src/components/content/FeedEntryFooter.tsx
|
||||
msgid "Tags"
|
||||
msgstr "الكلمات"
|
||||
|
||||
#: src/components/settings/DisplaySettings.tsx
|
||||
msgid "Teal"
|
||||
msgstr "أزرق مخضر"
|
||||
|
||||
#: src/components/settings/PushNotificationSettings.tsx
|
||||
msgid "Test"
|
||||
msgstr "اختبار"
|
||||
|
||||
#: src/components/settings/PushNotificationSettings.tsx
|
||||
msgid "Test notification sent successfully."
|
||||
msgstr "تم إرسال إشعار الاختبار بنجاح."
|
||||
|
||||
#: src/components/content/add/Subscribe.tsx
|
||||
msgid "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."
|
||||
msgstr "عنوان URL للتغذية التي تريد الاشتراك فيها. "
|
||||
@@ -923,9 +1089,18 @@ msgstr "عنوان URL للتغذية التي تريد الاشتراك فيه
|
||||
msgid "Theme"
|
||||
msgstr "الموضوع"
|
||||
|
||||
#. placeholder {0}: feed.filterLegacy
|
||||
#: src/pages/app/FeedDetailsPage.tsx
|
||||
msgid "This feed has a legacy filter that cannot be edited and is not applied. Please recreate the filter using the new expression editor. The legacy filter expression was: <0>{0}</0>"
|
||||
msgstr "تحتوي هذه الخلاصة على فلتر قديم لا يمكن تحريره ولا يتم تطبيقه. يرجى إعادة إنشاء الفلتر باستخدام محرر التعبيرات الجديد. كان تعبير الفلتر القديم هو: <0>{0}</0>"
|
||||
|
||||
#: src/components/settings/ProfileSettings.tsx
|
||||
msgid "This is your API key. It can be used for some read-only API operations and grants access to the Fever API. Use the form at the bottom of the page to generate a new API key"
|
||||
msgstr ""
|
||||
msgstr "هذا هو مفتاح API الخاص بك. يمكن استخدامه لبعض عمليات API للقراءة فقط ويمنح الوصول إلى Fever API. استخدم النموذج الموجود في أسفل الصفحة لإنشاء مفتاح API جديد"
|
||||
|
||||
#: src/components/settings/DisplaySettings.tsx
|
||||
msgid "This setting can cause scrolling issues on some browsers (e.g. Safari)"
|
||||
msgstr "هذا الإعداد يمكن أن يسبب مشاكل في التمرير في بعض المتصفحات (مثل Safari)"
|
||||
|
||||
#: src/components/KeyboardShortcutsHelp.tsx
|
||||
msgid "Toggle read status of current entry"
|
||||
@@ -933,19 +1108,23 @@ msgstr "تبديل قراءة حالة الإدخال الحالي"
|
||||
|
||||
#: src/components/KeyboardShortcutsHelp.tsx
|
||||
msgid "Toggle sidebar"
|
||||
msgstr ""
|
||||
msgstr "تبديل الشريط الجانبي"
|
||||
|
||||
#: src/components/KeyboardShortcutsHelp.tsx
|
||||
msgid "Toggle starred status of current entry"
|
||||
msgstr ""
|
||||
msgstr "تبديل الحالة المميزة بنجمة للإدخال الحالي"
|
||||
|
||||
#: src/components/settings/PushNotificationSettings.tsx
|
||||
msgid "Topic"
|
||||
msgstr "موضوع"
|
||||
|
||||
#: src/pages/auth/LoginPage.tsx
|
||||
msgid "Try out CommaFeed with the demo account: demo/demo"
|
||||
msgstr "جرب CommaFeed باستخدام الحساب التجريبي: تجريبي / تجريبي"
|
||||
msgstr "جرب CommaFeed باستخدام الحساب التجريبي: demo/demo"
|
||||
|
||||
#: src/pages/WelcomePage.tsx
|
||||
msgid "Try the demo!"
|
||||
msgstr ""
|
||||
msgstr "جرب النسخة التجريبية!"
|
||||
|
||||
#: src/components/header/Header.tsx
|
||||
msgid "Unread"
|
||||
@@ -962,6 +1141,10 @@ msgstr "إلغاء النجم"
|
||||
msgid "Unsubscribe"
|
||||
msgstr "إلغاء الاشتراك"
|
||||
|
||||
#: src/components/settings/PushNotificationSettings.tsx
|
||||
msgid "User key"
|
||||
msgstr "مفتاح المستخدم"
|
||||
|
||||
#: src/components/settings/ProfileSettings.tsx
|
||||
msgid "User name"
|
||||
msgstr "اسم المستخدم"
|
||||
@@ -971,6 +1154,10 @@ msgstr "اسم المستخدم"
|
||||
msgid "User Name or E-mail"
|
||||
msgstr "اسم المستخدم أو البريد الإلكتروني"
|
||||
|
||||
#: src/components/settings/DisplaySettings.tsx
|
||||
msgid "Violet"
|
||||
msgstr "بنفسجي"
|
||||
|
||||
#: src/components/Alert.tsx
|
||||
msgid "Warning"
|
||||
msgstr "تحذير"
|
||||
@@ -979,10 +1166,22 @@ msgstr "تحذير"
|
||||
msgid "Website"
|
||||
msgstr "موقع الكتروني"
|
||||
|
||||
#: src/pages/auth/InitialSetupPage.tsx
|
||||
msgid "Welcome! This appears to be the first time you're running CommaFeed. Please create an administrator account to get started."
|
||||
msgstr "مرحباً! يبدو أن هذه هي المرة الأولى التي تقوم فيها بتشغيل CommaFeed. يرجى إنشاء حساب مسؤول للبدء."
|
||||
|
||||
#: src/components/settings/DisplaySettings.tsx
|
||||
msgid "Yellow"
|
||||
msgstr "أصفر"
|
||||
|
||||
#: src/pages/app/FeedEntriesPage.tsx
|
||||
msgid "You don't have any subscriptions yet. Why not try adding one by clicking on the + sign at the top of the page?"
|
||||
msgstr "ليس لديك أي اشتراكات حتى الآن. "
|
||||
msgstr "ليس لديك أي اشتراكات حتى الآن. لمَ لا تحاول إضافة واحد بالنقر فوق علامة + في أعلى الصفحة؟"
|
||||
|
||||
#: src/components/header/ProfileMenu.tsx
|
||||
msgid "Your feeds have been queued for refresh."
|
||||
msgstr ""
|
||||
msgstr "تم وضع خلاصاتك في قائمة الانتظار للتحديث."
|
||||
|
||||
#: src/pages/auth/PasswordResetPage.tsx
|
||||
msgid "Your password has been changed. You can now log in with your new password."
|
||||
msgstr "تم تغيير كلمة المرور الخاصة بك. يمكنك الآن تسجيل الدخول باستخدام كلمة المرور الجديدة."
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user