Compare commits
1575 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
874e7dcee6 | ||
|
|
8297edaf71 | ||
|
|
9e4e629a1a | ||
|
|
8b86617f18 | ||
|
|
bbda35f868 | ||
|
|
df68405fef | ||
|
|
65194d948f | ||
|
|
d49297216c | ||
|
|
e3e50f8456 | ||
|
|
e90b3730ef | ||
|
|
7675a24eb6 | ||
|
|
2bf9186135 | ||
|
|
d4ea51c145 | ||
|
|
6e0e99694e | ||
|
|
9ede8d1c46 | ||
|
|
fd0425a2be | ||
|
|
2b976cadeb | ||
|
|
023c27a565 | ||
|
|
69c9988404 | ||
|
|
b1a4debb95 | ||
|
|
5663d619aa | ||
|
|
2ef9e8d274 | ||
|
|
1292018de0 | ||
|
|
039e91414e | ||
|
|
662d0f754f | ||
|
|
7fb7efbdf7 | ||
|
|
a841c80261 | ||
|
|
da4143fa13 | ||
|
|
789857b09f | ||
|
|
ed45746f52 | ||
|
|
deb51f2ccc | ||
|
|
5fec4a4c5f | ||
|
|
7b335e2fd4 | ||
|
|
60b6c69020 | ||
|
|
08ab32c4c2 | ||
|
|
ff24fe4c7c | ||
|
|
50c62fb468 | ||
|
|
201331afc3 | ||
|
|
cf3100081e | ||
|
|
860aab7495 | ||
|
|
b084c8d108 | ||
|
|
8e0a53fc49 | ||
|
|
4ea2bad083 | ||
|
|
46065d938d | ||
|
|
16389824f7 | ||
|
|
92b624ca8a | ||
|
|
1ae5111f76 | ||
|
|
d9a9a01a60 | ||
|
|
bbbb9c10a6 | ||
|
|
50cf9718a3 | ||
|
|
99a7ede82d | ||
|
|
7b1218ef1e | ||
|
|
8dab16090f | ||
|
|
6e5f362a8e | ||
|
|
96212afd27 | ||
|
|
7e02380858 | ||
|
|
2742b7fff6 | ||
|
|
dade873420 | ||
|
|
e7925e6330 | ||
|
|
f845f225cf | ||
|
|
39ba4a1c97 | ||
|
|
a491b95a02 | ||
|
|
e0c05c8e5d | ||
|
|
2f1aa12e30 | ||
|
|
4c532cf028 | ||
|
|
dc95044fbc | ||
|
|
418cb4797d | ||
|
|
c646503501 | ||
|
|
0ea0db48db | ||
|
|
bb4bb0c7d7 | ||
|
|
97781d5551 | ||
|
|
f4e48383cc | ||
|
|
aa009c366d | ||
|
|
1289dbae84 | ||
|
|
8c69dd355c | ||
|
|
fdf4fdcc87 | ||
|
|
9cd1cde571 | ||
|
|
1b4b3ca52c | ||
|
|
6a76c8b8c3 | ||
|
|
b49d35f181 | ||
|
|
5ba248eaba | ||
|
|
11aff68052 | ||
|
|
07dd10848f | ||
|
|
b2bd386e9c | ||
|
|
d09cabb8c6 | ||
|
|
818d847607 | ||
|
|
1db53e48c6 | ||
|
|
5601d150c3 | ||
|
|
a35f55cde6 | ||
|
|
3714bfaccc | ||
|
|
5541cc9fbe | ||
|
|
bdabd9db0d | ||
|
|
2762c535d6 | ||
|
|
241c465eba | ||
|
|
6c3895e60a | ||
|
|
a30bf18102 | ||
|
|
d9ccdf1caf | ||
|
|
155e7ba1aa | ||
|
|
00faf44c94 | ||
|
|
c45f832131 | ||
|
|
6f781216cd | ||
|
|
fd0e5426e5 | ||
|
|
b5d99b9661 | ||
|
|
50fcdece86 | ||
|
|
d882553644 | ||
|
|
bf71e825a4 | ||
|
|
351701d674 | ||
|
|
cb4a8df0d2 | ||
|
|
7ef865506f | ||
|
|
e4863e8881 | ||
|
|
c86a060170 | ||
|
|
6ed5637e57 | ||
|
|
929df60f09 | ||
|
|
2b51de8e5b | ||
|
|
0ba70d29bd | ||
|
|
197b3b258b | ||
|
|
850f66999c | ||
|
|
d7d3574e36 | ||
|
|
435d612cbf | ||
|
|
3d3a7c6496 | ||
|
|
fba57fe0a7 | ||
|
|
ce7933f320 | ||
|
|
8ac452afc9 | ||
|
|
a11cb3ac7a | ||
|
|
39808bbafc | ||
|
|
aee56e3dbe | ||
|
|
40f451c762 | ||
|
|
d633803ab5 | ||
|
|
d7a3b75687 | ||
|
|
df8c4056b6 | ||
|
|
06319c1eb0 | ||
|
|
b7ede8eba2 | ||
|
|
1a4517d6a3 | ||
|
|
a402c5d7d8 | ||
|
|
408809787e | ||
|
|
d7b0d572c1 | ||
|
|
b356be3e6f | ||
|
|
998385334b | ||
|
|
c6d613d81a | ||
|
|
9981d8763d | ||
|
|
b37680333c | ||
|
|
66d1eb3f1f | ||
|
|
6fe1c2a3c0 | ||
|
|
c2e453027c | ||
|
|
f16bac9b59 | ||
|
|
8cca826e70 | ||
|
|
b0165bb26a | ||
|
|
366294ab46 | ||
|
|
2988938440 | ||
|
|
e865769e30 | ||
|
|
f87be2fc03 | ||
|
|
466846d268 | ||
|
|
61b6be4090 | ||
|
|
cb779ec494 | ||
|
|
da6f2050f9 | ||
|
|
4304f84a55 | ||
|
|
8a175d8221 | ||
|
|
f1896d34e2 | ||
|
|
45d0e0ec98 | ||
|
|
38c5beec2f | ||
|
|
c4715dc3f7 | ||
|
|
6ce6b5ef0e | ||
|
|
1af3dd452c | ||
|
|
1f4ec41222 | ||
|
|
512c4cc507 | ||
|
|
d391c8f1c9 | ||
|
|
46d3e67aec | ||
|
|
d9505c4d87 | ||
|
|
42491f5778 | ||
|
|
9c897c9fb2 | ||
|
|
21b500a96e | ||
|
|
04c74b5daa | ||
|
|
3edb8a3ee2 | ||
|
|
922346bef6 | ||
|
|
82cf0e154a | ||
|
|
efe32e86c9 | ||
|
|
e208d4ae1e | ||
|
|
adf20327bd | ||
|
|
781c41b452 | ||
|
|
2b597f9b43 | ||
|
|
2e26f34135 | ||
|
|
9e59a472da | ||
|
|
970043467c | ||
|
|
3e903fc6bc | ||
|
|
95f4cffa7c | ||
|
|
6ebe0fa827 | ||
|
|
488a88fe95 | ||
|
|
d5898a0173 | ||
|
|
bdcfbc22bf | ||
|
|
53b06f41f3 | ||
|
|
872247d80f | ||
|
|
7c226f41db | ||
|
|
bb55a91a14 | ||
|
|
f140650b4e | ||
|
|
8a64a9db31 | ||
|
|
c1520652f2 | ||
|
|
90e3044249 | ||
|
|
f7786d9962 | ||
|
|
aeaaeaee0e | ||
|
|
4d0a8fd133 | ||
|
|
b1938c234c | ||
|
|
6a5052787d | ||
|
|
877fc33180 | ||
|
|
8b0b9b1a66 | ||
|
|
689c329430 | ||
|
|
52f911f303 | ||
|
|
91d0988177 | ||
|
|
4f644ba9f5 | ||
|
|
4f699d9675 | ||
|
|
a5aba6f7ae | ||
|
|
78c8711a79 | ||
|
|
8325236d0e | ||
|
|
437401e73f | ||
|
|
fa06d321d5 | ||
|
|
d1ddcb6ace | ||
|
|
6944d4dc0b | ||
|
|
c835d805b1 | ||
|
|
4a90e1f69d | ||
|
|
fcfeaa462e | ||
|
|
b16978d8fe | ||
|
|
68c62b4528 | ||
|
|
18f68aab31 | ||
|
|
8abb2770ec | ||
|
|
9156b8b6d0 | ||
|
|
2c32fa1e13 | ||
|
|
7e48afe36c | ||
|
|
cd94a3b56f | ||
|
|
22e0f1f382 | ||
|
|
e2eeba90ef | ||
|
|
662c0fc6b9 | ||
|
|
fafc0619ad | ||
|
|
3a5dc5d0ed | ||
|
|
23a696e644 | ||
|
|
72cb71a2fb | ||
|
|
9b757735b8 | ||
|
|
6b9f8f268f | ||
|
|
e5b0eb426c | ||
|
|
ea6c83ca33 | ||
|
|
763ce1e4fd | ||
|
|
e748499ed8 | ||
|
|
e430604528 | ||
|
|
5e08c81d12 | ||
|
|
84626e1ef2 | ||
|
|
191ece0bac | ||
|
|
24eaff61f2 | ||
|
|
aa5e9bfd83 | ||
|
|
a200147926 | ||
|
|
d6205b7da3 | ||
|
|
5ecf3e0fbf | ||
|
|
bb25e0ede6 | ||
|
|
f5c0e2d375 | ||
|
|
12ab5b1e7b | ||
|
|
3e6451289f | ||
|
|
09d21d88a4 | ||
|
|
2ec6d0a66a | ||
|
|
412fc52f1c | ||
|
|
b5e5989604 | ||
|
|
105ff46c01 | ||
|
|
f100f3f91a | ||
|
|
45eb436b8f | ||
|
|
bf3914e748 | ||
|
|
5df7aaf7cd | ||
|
|
f10cfd7ad0 | ||
|
|
0626e5cd7a | ||
|
|
8846472e6c | ||
|
|
c20379f376 | ||
|
|
f7ad9c9905 | ||
|
|
c2419e19fc | ||
|
|
a08ea27c2f | ||
|
|
a546ae0d53 | ||
|
|
12f8609d79 | ||
|
|
e77267e33b | ||
|
|
b86ff2a32f | ||
|
|
5939f845b3 | ||
|
|
fc8d4f1f67 | ||
|
|
3b03da1bd6 | ||
|
|
4eacd238fa | ||
|
|
bc7c50f0f3 | ||
|
|
e84a1289e3 | ||
|
|
85ebcd6407 | ||
|
|
735a1e8b11 | ||
|
|
6de817f539 | ||
|
|
08a2746921 | ||
|
|
bc28727e39 | ||
|
|
eceaf3a98d | ||
|
|
4a8939e5e5 | ||
|
|
e90b80c641 | ||
|
|
2979600cc2 | ||
|
|
a2deef7f7f | ||
|
|
b5097d4fc3 | ||
|
|
f858eed150 | ||
|
|
bbdd712b01 | ||
|
|
c0875971e9 | ||
|
|
0199ebb6c3 | ||
|
|
c5763e2f8f | ||
|
|
5338ec0c34 | ||
|
|
8b5735f521 | ||
|
|
3d1a1cd033 | ||
|
|
b1b5eeb0e0 | ||
|
|
49e37587f9 | ||
|
|
01102ae973 | ||
|
|
e7931bf360 | ||
|
|
d095e4b35a | ||
|
|
b8e254dab6 | ||
|
|
4059160d90 | ||
|
|
e0f242fe22 | ||
|
|
05453364ff | ||
|
|
c3aedd935d | ||
|
|
99a7f72448 | ||
|
|
56ae1eadbc | ||
|
|
4828c03bbf | ||
|
|
cfc07764b4 | ||
|
|
91938cc3b9 | ||
|
|
c62a84a9ea | ||
|
|
0b16b6bb86 | ||
|
|
6a8f7f0a40 | ||
|
|
42ca0967b6 | ||
|
|
deb29f0e88 | ||
|
|
714af986b0 | ||
|
|
4ff26366a5 | ||
|
|
9c628a8f53 | ||
|
|
4a40f2b8f7 | ||
|
|
9a2dda626c | ||
|
|
a9ff491da0 | ||
|
|
5c5a7d20de | ||
|
|
05ae4eb529 | ||
|
|
15f93b198c | ||
|
|
0a99dacb6b | ||
|
|
00f6c04611 | ||
|
|
d9b899b53f | ||
|
|
d96f8da8fd | ||
|
|
ababcf7850 | ||
|
|
f23bfaf694 | ||
|
|
cac05dee0b | ||
|
|
155c93d371 | ||
|
|
9a61ee7530 | ||
|
|
4bea1c5e5c | ||
|
|
9ccc26b0b0 | ||
|
|
5cd3787d6f | ||
|
|
807b1f62a1 | ||
|
|
c15db54d5a | ||
|
|
aa7b078121 | ||
|
|
99130d0181 | ||
|
|
90e2036cbe | ||
|
|
c2f3e42867 | ||
|
|
bd33369a41 | ||
|
|
4f625d8ed5 | ||
|
|
866fe56dd2 | ||
|
|
5f37dbca4c | ||
|
|
c49e617dfe | ||
|
|
e763ffd4cf | ||
|
|
20ab7dd3e1 | ||
|
|
55741c6332 | ||
|
|
42d85336a8 | ||
|
|
639b82f494 | ||
|
|
5003c176a2 | ||
|
|
10bfbbec17 | ||
|
|
3da900db7f | ||
|
|
9f421ec3b0 | ||
|
|
69fb11eee0 | ||
|
|
ffbb85df43 | ||
|
|
a4e78c4e0d | ||
|
|
274c5ae165 | ||
|
|
39c4012a1a | ||
|
|
6d4b0cbdef | ||
|
|
ea4b120a85 | ||
|
|
5c2454c331 | ||
|
|
4ff46965c4 | ||
|
|
33e3f7ea3c | ||
|
|
347fc4f2c8 | ||
|
|
2b4ff4a8a5 | ||
|
|
f7d34983e0 | ||
|
|
3271d69fcb | ||
|
|
7ea24b21f8 | ||
|
|
b2b608e8c3 | ||
|
|
e44ea5bc96 | ||
|
|
fa58b1e53f | ||
|
|
9466bc544c | ||
|
|
9e65f5726c | ||
|
|
fc2c3740a0 | ||
|
|
2095a6512b | ||
|
|
a461a72224 | ||
|
|
f9e7653901 | ||
|
|
a11e528b8d | ||
|
|
c0937aa473 | ||
|
|
fecd6451d5 | ||
|
|
fb96631351 | ||
|
|
a16450be5d | ||
|
|
02628b5886 | ||
|
|
60e37deae8 | ||
|
|
21922265e4 | ||
|
|
42c740eaff | ||
|
|
d187c23a77 | ||
|
|
9252042c99 | ||
|
|
66c5a471e3 | ||
|
|
91c48bedd3 | ||
|
|
c882ad5399 | ||
|
|
b3700dc09e | ||
|
|
b06187ddf7 | ||
|
|
cf69bb2013 | ||
|
|
81b284ad94 | ||
|
|
f838f877fa | ||
|
|
d7c6f8eb52 | ||
|
|
6f49f1fe01 | ||
|
|
e75c4554a5 | ||
|
|
58852502dc | ||
|
|
a151646850 | ||
|
|
97d290de9d | ||
|
|
438b255708 | ||
|
|
754ac166e0 | ||
|
|
0b18334236 | ||
|
|
90d2ad6b19 | ||
|
|
21fcae52b2 | ||
|
|
d72c9ba247 | ||
|
|
27f80148cb | ||
|
|
1daf57a4bd | ||
|
|
3999532e77 | ||
|
|
126a5e3bbc | ||
|
|
a1fb5871d1 | ||
|
|
4c18ebf61a | ||
|
|
8bc6a2adcc | ||
|
|
475c0673a0 | ||
|
|
f81491fb32 | ||
|
|
1f2a265c54 | ||
|
|
fbfe16e784 | ||
|
|
c6439fe020 | ||
|
|
7e605e5cda | ||
|
|
973fe56cc8 | ||
|
|
91bc7fa4b0 | ||
|
|
051fa37949 | ||
|
|
243aaac3da | ||
|
|
a8db632c4a | ||
|
|
11f5b22cb4 | ||
|
|
5967706daa | ||
|
|
9c02eba0dc | ||
|
|
e2340c2e98 | ||
|
|
a8e818f97f | ||
|
|
6f26c54b62 | ||
|
|
448feedace | ||
|
|
eefc1ee0d7 | ||
|
|
d2eac62273 | ||
|
|
ee89b34ab8 | ||
|
|
2d8584b72d | ||
|
|
e803ce13eb | ||
|
|
4e5fd18eea | ||
|
|
9ec62bc1de | ||
|
|
906acb217a | ||
|
|
6c6cc8d85b | ||
|
|
5cb09bc4c6 | ||
|
|
198d9fb17e | ||
|
|
33b87312f4 | ||
|
|
ece9b993e0 | ||
|
|
04894f118b | ||
|
|
ac7b6eeb21 | ||
|
|
4c4868a2b6 | ||
|
|
a75f726111 | ||
|
|
d34c0c8652 | ||
|
|
c0bd7d0610 | ||
|
|
155a66b913 | ||
|
|
c7216ef0a6 | ||
|
|
c692a8d8f3 | ||
|
|
54e6bc3154 | ||
|
|
2e24d32cc2 | ||
|
|
1c7e31a464 | ||
|
|
94bf8338cd | ||
|
|
152f0bd727 | ||
|
|
6ffdc7b07d | ||
|
|
fe87566668 | ||
|
|
c36dd47afd | ||
|
|
b6a9b17410 | ||
|
|
c78fdf87b8 | ||
|
|
55a1ccc849 | ||
|
|
d97f42ff2d | ||
|
|
9ab52aeaf2 | ||
|
|
a0190143fe | ||
|
|
a48135a60d | ||
|
|
09eec3235d | ||
|
|
d21e5dfee4 | ||
|
|
899a8d746a | ||
|
|
9bbfc2de3f | ||
|
|
d82bb22341 | ||
|
|
0fd55c6635 | ||
|
|
4b346dd2e1 | ||
|
|
13a0516cce | ||
|
|
5fcd7ccb58 | ||
|
|
b0aef46c99 | ||
|
|
ec50530284 | ||
|
|
cbc4ebe7b3 | ||
|
|
f5339db646 | ||
|
|
c573e70e8b | ||
|
|
16b3049839 | ||
|
|
57ff8e9d22 | ||
|
|
5c6ea23e0f | ||
|
|
5a2aa7cd4b | ||
|
|
3df53b582a | ||
|
|
eb53fc472c | ||
|
|
c4e9178efb | ||
|
|
822f41bc40 | ||
|
|
1558c0a62f | ||
|
|
3977bb2a0b | ||
|
|
26df3a1d1d | ||
|
|
a77a860e0c | ||
|
|
78b637c83b | ||
|
|
b132178228 | ||
|
|
4021389a4d | ||
|
|
089be99287 | ||
|
|
b3dd6acfe6 | ||
|
|
ec3645a1c9 | ||
|
|
1c49873da1 | ||
|
|
8818bd90e0 | ||
|
|
4fb95799f8 | ||
|
|
2ee9084b91 | ||
|
|
3f3ef27d6b | ||
|
|
e01e59b72c | ||
|
|
0a97f04257 | ||
|
|
0b3888a8ae | ||
|
|
c6601e5bbf | ||
|
|
5f7c5d25de | ||
|
|
99d80df76c | ||
|
|
22beeabb9b | ||
|
|
8e1aad655a | ||
|
|
942447c62f | ||
|
|
d8a4da7ec8 | ||
|
|
56c50eacfe | ||
|
|
eec6f7d168 | ||
|
|
b45219a595 | ||
|
|
d7858f17a1 | ||
|
|
000a99c53e | ||
|
|
e592d26f8b | ||
|
|
015a60f998 | ||
|
|
caba43bb5b | ||
|
|
056425bd8a | ||
|
|
4aca62c042 | ||
|
|
b597c655cd | ||
|
|
9b276009e2 | ||
|
|
c1dac2e064 | ||
|
|
f707993188 | ||
|
|
ea612d9d53 | ||
|
|
b44e737448 | ||
|
|
bb429afd95 | ||
|
|
475a8f8a28 | ||
|
|
c7ba5ca894 | ||
|
|
3023f0a7cc | ||
|
|
ddaefbc952 | ||
|
|
0b3a0fb3ed | ||
|
|
7f40a430fd | ||
|
|
05f5d3b25c | ||
|
|
c3ca0b18b3 | ||
|
|
696e0b1fa7 | ||
|
|
201f7dbd3e | ||
|
|
0bfd3e906c | ||
|
|
71ac2bfc45 | ||
|
|
5370db7c5e | ||
|
|
bcc30e40ba | ||
|
|
2f70f654f7 | ||
|
|
b64115dcbd | ||
|
|
c9c71d8582 | ||
|
|
689bc19296 | ||
|
|
27498ab649 | ||
|
|
678a11f998 | ||
|
|
e9ef98716f | ||
|
|
b3ce43eaf7 | ||
|
|
72083b7e87 | ||
|
|
0cc94c2033 | ||
|
|
1d6296b400 | ||
|
|
7ad5da2a9e | ||
|
|
a7665a9994 | ||
|
|
a4cd3f26e8 | ||
|
|
fcdb33b64b | ||
|
|
7fd6119bcf | ||
|
|
b4d4b2473c | ||
|
|
91f715c3c3 | ||
|
|
ea5fccfe5f | ||
|
|
86835eec73 | ||
|
|
2bccee2333 | ||
|
|
2d01b0d714 | ||
|
|
44bf37b05a | ||
|
|
cf617f0a64 | ||
|
|
eeeaffd883 | ||
|
|
d178302d34 | ||
|
|
83a5364903 | ||
|
|
aef76db664 | ||
|
|
c3b3240191 | ||
|
|
f381974955 | ||
|
|
bd16dd98c4 | ||
|
|
2fca6132a0 | ||
|
|
137eba33c9 | ||
|
|
143699c0a4 | ||
|
|
0485403fff | ||
|
|
489fcb9666 | ||
|
|
7cc3b84ebc | ||
|
|
cb254f87d4 | ||
|
|
d4db98fd64 | ||
|
|
d14a6d8311 | ||
|
|
286c115167 | ||
|
|
6038b9e052 | ||
|
|
552082a36a | ||
|
|
5cea92d96d | ||
|
|
02b7b89b94 | ||
|
|
93697cf1f5 | ||
|
|
8daaee28c3 | ||
|
|
c32f608ec5 | ||
|
|
7b09029c5b | ||
|
|
6e1c414c84 | ||
|
|
e57976be99 | ||
|
|
a37e6a3f4c | ||
|
|
2dbe4064b2 | ||
|
|
2b0c0d467a | ||
|
|
40fa4516df | ||
|
|
5201c0cd14 | ||
|
|
61039dcd7e | ||
|
|
039ff4ee41 | ||
|
|
b40349805f | ||
|
|
d709d119ac | ||
|
|
8d2b6bdc12 | ||
|
|
ff78af2d56 | ||
|
|
ada53dba3b | ||
|
|
ba2f6c0f66 | ||
|
|
268869345c | ||
|
|
4b556bd3a9 | ||
|
|
6f10d35a4c | ||
|
|
33167fcdce | ||
|
|
e9c85b0e77 | ||
|
|
e521254600 | ||
|
|
a773d98400 | ||
|
|
ae066d3cd9 | ||
|
|
b5726fc0f3 | ||
|
|
4a056a0d27 | ||
|
|
7817431bce | ||
|
|
c02d2745c3 | ||
|
|
ee610ec800 | ||
|
|
6c0d585fef | ||
|
|
29417005b0 | ||
|
|
cf87fd8340 | ||
|
|
f1b85b0dde | ||
|
|
abef73d384 | ||
|
|
535f947f88 | ||
|
|
f27e243cc4 | ||
|
|
6a699ed5f1 | ||
|
|
9c1f5efab5 | ||
|
|
6b7ce56f6b | ||
|
|
b76ee4a2d0 | ||
|
|
b444a74a44 | ||
|
|
d43820cc82 | ||
|
|
e74e8fe1c2 | ||
|
|
9eb6e8ec27 | ||
|
|
fae94d3696 | ||
|
|
68e5ed64c9 | ||
|
|
f912d3b8bd | ||
|
|
fc03d2ee91 | ||
|
|
523b2b8db4 | ||
|
|
d547e9b6d7 | ||
|
|
71efc9f854 | ||
|
|
4f289f7467 | ||
|
|
02ef8bee71 | ||
|
|
ff5c1b00d7 | ||
|
|
30264be311 | ||
|
|
8ea44ab8c7 | ||
|
|
1b8ff7ca61 | ||
|
|
f00a066c22 | ||
|
|
859cf468aa | ||
|
|
5b486a917b | ||
|
|
9ace6b70f0 | ||
|
|
447029ae70 | ||
|
|
83f26cde53 | ||
|
|
8ac52690fd | ||
|
|
6934b2bd27 | ||
|
|
6647e4fcd4 | ||
|
|
21710f55f3 | ||
|
|
27bd9a7489 | ||
|
|
630d37125c | ||
|
|
9424237534 | ||
|
|
cba3fbeb5f | ||
|
|
58778ccf43 | ||
|
|
6c61d47d78 | ||
|
|
35e02f9d98 | ||
|
|
58c1650863 | ||
|
|
9b14ffa14c | ||
|
|
96c09bf4cd | ||
|
|
737cec744a | ||
|
|
13ed92bb94 | ||
|
|
076594c78e | ||
|
|
b6b1b4ebbe | ||
|
|
4007f37492 | ||
|
|
532d671feb | ||
|
|
fed7a1ac84 | ||
|
|
ddfd170ea8 | ||
|
|
bae5c67dfa | ||
|
|
84f51603fb | ||
|
|
f73ddc03e9 | ||
|
|
a16d9877cc | ||
|
|
c24e9e083c | ||
|
|
101602c6f6 | ||
|
|
18a7bd1fd1 | ||
|
|
dfbd556bb8 | ||
|
|
040cdde8ba | ||
|
|
06373480ae | ||
|
|
5713a78f2e | ||
|
|
b9f2f17a24 | ||
|
|
9adc993472 | ||
|
|
dcd5f3d529 | ||
|
|
18e70a0e6b | ||
|
|
5ad57d1608 | ||
|
|
74eaf48ceb | ||
|
|
30bb0cb291 | ||
|
|
b50e6b93bd | ||
|
|
a0b5a1462d | ||
|
|
4910f93c94 | ||
|
|
4a52bd0cb7 | ||
|
|
b0bfb73952 | ||
|
|
69d049a69a | ||
|
|
7d75153362 | ||
|
|
748bfa31ae | ||
|
|
e7d995edbc | ||
|
|
a144fb2e48 | ||
|
|
7521013e11 | ||
|
|
c6321fc6b2 | ||
|
|
7d92d5d096 | ||
|
|
ab201d5016 | ||
|
|
efa38d5ee9 | ||
|
|
e8769d09a8 | ||
|
|
a216444825 | ||
|
|
fee3e10e6b | ||
|
|
4d71a8f3c2 | ||
|
|
fc104b0b01 | ||
|
|
3dcb351b36 | ||
|
|
600d05d08f | ||
|
|
6b6ff70ad3 | ||
|
|
891f660738 | ||
|
|
6901b9b728 | ||
|
|
c7f211a7f8 | ||
|
|
c48ea1152c | ||
|
|
f5d0eb94b4 | ||
|
|
cebeef04a0 | ||
|
|
3e77a83ca6 | ||
|
|
c872b335e7 | ||
|
|
cc1e173552 | ||
|
|
35e0567705 | ||
|
|
fb2add305e | ||
|
|
74d4c18c4c | ||
|
|
da3ce07485 | ||
|
|
c7ab179a9e | ||
|
|
6fd11fcd56 | ||
|
|
3966cf165b | ||
|
|
0b2ada5d1c | ||
|
|
4278101bbe | ||
|
|
8b43af49fc | ||
|
|
6e29e8426b | ||
|
|
af11d3c771 | ||
|
|
e5c5af4d57 | ||
|
|
3dbdf5adf2 | ||
|
|
4d7a030b70 | ||
|
|
3351262dd7 | ||
|
|
5ec4377502 | ||
|
|
9c8402c3a5 | ||
|
|
928a45e48e | ||
|
|
1d088c5eae | ||
|
|
cdcf81ab7c | ||
|
|
9f196bafe9 | ||
|
|
5c9e1406a1 | ||
|
|
0b42e00b29 | ||
|
|
88b98a138f | ||
|
|
136c37885d | ||
|
|
812988b31a | ||
|
|
191680a01b | ||
|
|
467d1a754d | ||
|
|
d1973922cd | ||
|
|
3b7689975d | ||
|
|
3386a71c5e | ||
|
|
7bb65a5e76 | ||
|
|
f3a9c8e0e2 | ||
|
|
22861ca8d0 | ||
|
|
19118ea241 | ||
|
|
4a9dc7249f | ||
|
|
5dad9c2eb8 | ||
|
|
d6b35b00b9 | ||
|
|
fda8ab500b | ||
|
|
66df421de2 | ||
|
|
33c62f08ca | ||
|
|
b660602809 | ||
|
|
6dfce2ca30 | ||
|
|
655e20e99e | ||
|
|
f2b80bdc08 | ||
|
|
10af873fa5 | ||
|
|
d87a5b14f8 | ||
|
|
b87a18b993 | ||
|
|
c4185034e4 | ||
|
|
9d64426b00 | ||
|
|
c81cc8bea4 | ||
|
|
90e680d6be | ||
|
|
04c0833111 | ||
|
|
06151eab3b | ||
|
|
3dcb8590f6 | ||
|
|
a9b313aa4a | ||
|
|
1f2e35060b | ||
|
|
a96862fffa | ||
|
|
68cb8e194d | ||
|
|
c164926c54 | ||
|
|
de7516116d | ||
|
|
fccfe5b088 | ||
|
|
23aa5fa0a3 | ||
|
|
d384c0a141 | ||
|
|
18058c2a36 | ||
|
|
71727202f3 | ||
|
|
eee0b949de | ||
|
|
3cbbb67b0c | ||
|
|
7879f66e78 | ||
|
|
c14ac37495 | ||
|
|
73a77183aa | ||
|
|
09cfa21091 | ||
|
|
c193571ece | ||
|
|
04bc92b071 | ||
|
|
94e58a449c | ||
|
|
9d044195aa | ||
|
|
caff34cc3b | ||
|
|
34c5c0b1f7 | ||
|
|
906801e13c | ||
|
|
dad4c6b866 | ||
|
|
090462022f | ||
|
|
cbf9f65fb4 | ||
|
|
5a493cd55d | ||
|
|
dfc204ef05 | ||
|
|
56c6e2d29c | ||
|
|
db03dd12a0 | ||
|
|
6c67e6363a | ||
|
|
e2888beb4c | ||
|
|
bba9166885 | ||
|
|
504e4eab3e | ||
|
|
2e475c35cc | ||
|
|
ccf18758fb | ||
|
|
68f9852790 | ||
|
|
d0150de003 | ||
|
|
e2b792335b | ||
|
|
ece38c9e59 | ||
|
|
a19b5090bf | ||
|
|
e4b3c35892 | ||
|
|
4b229a759a | ||
|
|
1e9e42ac48 | ||
|
|
245a48f66e | ||
|
|
e6d8397550 | ||
|
|
d59bd43846 | ||
|
|
c1579c83c7 | ||
|
|
4d782e60ad | ||
|
|
c702f47927 | ||
|
|
9110cfd923 | ||
|
|
e40dd14bbf | ||
|
|
90aaae9959 | ||
|
|
e81dda0fa8 | ||
|
|
f93796d036 | ||
|
|
d06359cb81 | ||
|
|
8b68fb578f | ||
|
|
cca300e419 | ||
|
|
77c3ec0bbe | ||
|
|
ed81fc576a | ||
|
|
435fcb9669 | ||
|
|
9020d95b62 | ||
|
|
84d7a501d4 | ||
|
|
e65dd49d69 | ||
|
|
a705cbe6c2 | ||
|
|
60b8af3860 | ||
|
|
9ac4187aa8 | ||
|
|
6419d29489 | ||
|
|
4684e43f42 | ||
|
|
a477c9fa6d | ||
|
|
d1be331f99 | ||
|
|
cbc792d406 | ||
|
|
0313c5c560 | ||
|
|
18aa2fcd92 | ||
|
|
10461941d7 | ||
|
|
e6050219bc | ||
|
|
81481c37fe | ||
|
|
5ea92a7d18 | ||
|
|
f40630aced | ||
|
|
81850acdfe | ||
|
|
6819d5aa8b | ||
|
|
2aef4e5d05 | ||
|
|
6d4d2c3e7e | ||
|
|
87bcaa4731 | ||
|
|
5d2378f291 | ||
|
|
253507d14b | ||
|
|
548fb7099b | ||
|
|
0dd7c777ee | ||
|
|
6812bf2388 | ||
|
|
12bcbfa9f7 | ||
|
|
b5dfd371d9 | ||
|
|
e09d7fb103 | ||
|
|
0fe3afe254 | ||
|
|
db50d50c19 | ||
|
|
691bdb1512 | ||
|
|
d50b712bca | ||
|
|
3b68e4f32b | ||
|
|
259b9a90dd | ||
|
|
f4c5fd7eb4 | ||
|
|
3cd42d03f0 | ||
|
|
3497b82e8c | ||
|
|
15a24e4e75 | ||
|
|
96837f908e | ||
|
|
4ea5ebbf9e | ||
|
|
281e015376 | ||
|
|
5825a16aff | ||
|
|
2586a8c433 | ||
|
|
9f7c9c3428 | ||
|
|
9790ba735b | ||
|
|
e3dbcac9fb | ||
|
|
1c99929429 | ||
|
|
9b2cdbbb18 | ||
|
|
928cf1220e | ||
|
|
c0557856a3 | ||
|
|
97c2cc3d15 | ||
|
|
a94ef980bb | ||
|
|
eea0c24d2b | ||
|
|
c8fded3c56 | ||
|
|
8f2ba5e186 | ||
|
|
5ce2823d0b | ||
|
|
a0c70d326f | ||
|
|
5f28fd4114 | ||
|
|
7151db0909 | ||
|
|
e82888f8f3 | ||
|
|
4fb60a6ec6 | ||
|
|
27f22f6094 | ||
|
|
7497a0151a | ||
|
|
41f133afb1 | ||
|
|
4b15ecbc1b | ||
|
|
6498130850 | ||
|
|
24bd1121af | ||
|
|
3cccf741d6 | ||
|
|
0a2d2c3f43 | ||
|
|
969da0f2a6 | ||
|
|
2061b68a2f | ||
|
|
443dea5055 | ||
|
|
a4c6365ede | ||
|
|
c9c044386e | ||
|
|
2744f8285c | ||
|
|
7bf5f20b06 | ||
|
|
b43aa84c2a | ||
|
|
dd27d88309 | ||
|
|
8dc36a72b2 | ||
|
|
d3ca301675 | ||
|
|
43e3469e63 | ||
|
|
cdc3dc6740 | ||
|
|
6fba8b61e7 | ||
|
|
b34594a1dc | ||
|
|
19964d253e | ||
|
|
165f3ed25a | ||
|
|
5058290103 | ||
|
|
358a6029a1 | ||
|
|
fa4bfa729d | ||
|
|
9c9e43cf46 | ||
|
|
b7e5bd0144 | ||
|
|
58dc6f5832 | ||
|
|
f409af1c37 | ||
|
|
9e0c94f1a4 | ||
|
|
3794d61a77 | ||
|
|
d22da54d53 | ||
|
|
8e34c44e0d | ||
|
|
b71434acf6 | ||
|
|
7e158ed9b9 | ||
|
|
2ec0d067f3 | ||
|
|
effc65b777 | ||
|
|
c48e248283 | ||
|
|
f9e9a4547c | ||
|
|
63e35aba6d | ||
|
|
8f852fb9ac | ||
|
|
bf6a13b43f | ||
|
|
12030f6ce9 | ||
|
|
07da878bba | ||
|
|
8d5c3bdec8 | ||
|
|
ce95772afa | ||
|
|
b9f27b2b00 | ||
|
|
0059cabebe | ||
|
|
326ee79c8c | ||
|
|
54cc265ee6 | ||
|
|
e38778b4d0 | ||
|
|
6152d3c14a | ||
|
|
8a172170ea | ||
|
|
64b5d64709 | ||
|
|
67d7315003 | ||
|
|
47da4a2a1a | ||
|
|
174be9c2d1 | ||
|
|
9b68539322 | ||
|
|
2a4660ffa6 | ||
|
|
dce0cf7ee4 | ||
|
|
d6c39d4aba | ||
|
|
fd7e183f40 | ||
|
|
bf78a80f29 | ||
|
|
0ff630b8bd | ||
|
|
49b9e3f278 | ||
|
|
a4cc65c6a4 | ||
|
|
0b46187ac5 | ||
|
|
14ef5af936 | ||
|
|
539d9c6d0e | ||
|
|
56bcc5ef5e | ||
|
|
d6b0324e24 | ||
|
|
ff044e2592 | ||
|
|
3c7747ab97 | ||
|
|
34d97221ed | ||
|
|
84e78d34cd | ||
|
|
ac73806aee | ||
|
|
2105e9a5c9 | ||
|
|
2a36cc4327 | ||
|
|
c3feaf9a15 | ||
|
|
d8537a98aa | ||
|
|
42a6001ba5 | ||
|
|
4d9eb35230 | ||
|
|
e4ac296a1f | ||
|
|
01b49e7864 | ||
|
|
bd0b85a8d2 | ||
|
|
3d59a4c516 | ||
|
|
08ceff0f03 | ||
|
|
d6ae88ac43 | ||
|
|
5c8f016dd6 | ||
|
|
17288017d8 | ||
|
|
1e2757b52f | ||
|
|
0dce2f057e | ||
|
|
e017c5c304 | ||
|
|
a3e828f90a | ||
|
|
74e5c24fdc | ||
|
|
76c0abaa22 | ||
|
|
a52b5fd711 | ||
|
|
ffa51406b6 | ||
|
|
0b3b267e63 | ||
|
|
fcdb9d8257 | ||
|
|
04943ca525 | ||
|
|
574d4a1223 | ||
|
|
7349814cb2 | ||
|
|
114c5eb356 | ||
|
|
191f861f6e | ||
|
|
fac1fcc3a6 | ||
|
|
d0490c5eb5 | ||
|
|
2673efa9fc | ||
|
|
d4bce7b0a1 | ||
|
|
ba4a7ce6ab | ||
|
|
58f10153ab | ||
|
|
e7b65e3f26 | ||
|
|
fe91473748 | ||
|
|
0140402ad4 | ||
|
|
f56cba59ae | ||
|
|
fed74f05fc | ||
|
|
0888f11257 | ||
|
|
7205d5bb9c | ||
|
|
17a5ef882f | ||
|
|
ea68dbc56f | ||
|
|
0cec8af074 | ||
|
|
f7d0fc5768 | ||
|
|
bcaab694c8 | ||
|
|
247a3d5ab3 | ||
|
|
8e262a1e10 | ||
|
|
f63695bdc7 | ||
|
|
b051613b62 | ||
|
|
b886379d34 | ||
|
|
2a780dd2bb | ||
|
|
9cf7b80110 | ||
|
|
8fee73f1d1 | ||
|
|
36edb9373b | ||
|
|
374c4b265a | ||
|
|
db0b685ae1 | ||
|
|
23d33b8402 | ||
|
|
8a57be3e63 | ||
|
|
823cb03f9b | ||
|
|
e96cbcb057 | ||
|
|
fa0e7bcb54 | ||
|
|
20292a7742 | ||
|
|
943bde7eed | ||
|
|
9701af0736 | ||
|
|
1456cc40e1 | ||
|
|
dc1f88c44c | ||
|
|
55c916956f | ||
|
|
51eda57618 | ||
|
|
d6a55e1ec0 | ||
|
|
b78210421c | ||
|
|
1324269f1d | ||
|
|
cda6cb5cc0 | ||
|
|
c1b8619b26 | ||
|
|
4203e25321 | ||
|
|
aa02c7b93a | ||
|
|
0ff477579b | ||
|
|
62a8e8c119 | ||
|
|
fa212e0911 | ||
|
|
c8ad902a60 | ||
|
|
f05515d7d6 | ||
|
|
95bbcce941 | ||
|
|
d6b98f1518 | ||
|
|
bd9b1b11c5 | ||
|
|
e4c4960972 | ||
|
|
2a26031261 | ||
|
|
1d6e212955 | ||
|
|
9fa3743d21 | ||
|
|
7b373c79d9 | ||
|
|
4e9266e2d5 | ||
|
|
ea957e297c | ||
|
|
9320b6beb8 | ||
|
|
1319bf4a8c | ||
|
|
78b1ec6e6a | ||
|
|
6d4cbb889d | ||
|
|
27d16265d6 | ||
|
|
9888e23cd9 | ||
|
|
eb5a6913e0 | ||
|
|
34d7cb949d | ||
|
|
3c935a0b67 | ||
|
|
982cf044ef | ||
|
|
7a21e9816c | ||
|
|
fd6701079e | ||
|
|
757cfff0e6 | ||
|
|
560277663f | ||
|
|
a10d0336c5 | ||
|
|
114ab6834c | ||
|
|
840a96255c | ||
|
|
fd857b1298 | ||
|
|
281b4512e8 | ||
|
|
ec7081c4b5 | ||
|
|
7dff44bcb4 | ||
|
|
e62c7141af | ||
|
|
dd3455d273 | ||
|
|
e9cd8317aa | ||
|
|
ac7fe91593 | ||
|
|
c349c28e12 | ||
|
|
cf302edabe | ||
|
|
20180eb890 | ||
|
|
60b5f82adb | ||
|
|
a0b07196de | ||
|
|
74907d4067 | ||
|
|
f83a7a2ef7 | ||
|
|
b8cd0b024c | ||
|
|
397718fbb4 | ||
|
|
a41ed14fea | ||
|
|
e8f0cfb4bd | ||
|
|
ff32b0e1c9 | ||
|
|
e505ed5b7f | ||
|
|
6ef5f824da | ||
|
|
7b8801f6db | ||
|
|
c8e33aa6c7 | ||
|
|
45ea215aaf | ||
|
|
8b3da58969 | ||
|
|
04981bdcef | ||
|
|
39be4fec4e | ||
|
|
f9e7958e8b | ||
|
|
3d8c0ca663 | ||
|
|
febb6b19dd | ||
|
|
96c4431534 | ||
|
|
1a8ca2242c | ||
|
|
888545e857 | ||
|
|
c5e9e60ab0 | ||
|
|
afbbd07a13 | ||
|
|
cf96a0a84e | ||
|
|
0329c7d876 | ||
|
|
0c25412f03 | ||
|
|
bbf04c4687 | ||
|
|
33b683d037 | ||
|
|
21ec54408e | ||
|
|
f0f46169e4 | ||
|
|
fa6a3494ae | ||
|
|
4c0206324d | ||
|
|
5867b51f3b | ||
|
|
c56c213da7 | ||
|
|
9d070bd33c | ||
|
|
986fd25942 | ||
|
|
bbcd79e49f | ||
|
|
4dabf47822 | ||
|
|
db258d4ecc | ||
|
|
8b237db690 | ||
|
|
416350c004 | ||
|
|
8d63377e78 | ||
|
|
377176df05 | ||
|
|
95da0078b3 | ||
|
|
6392b87afc | ||
|
|
ba04d2adfe | ||
|
|
517ce1a726 | ||
|
|
36492cbff5 | ||
|
|
4b46aa08ac | ||
|
|
1a9a80c0da | ||
|
|
32a30019a7 | ||
|
|
bb72131354 | ||
|
|
3a8d72cab4 | ||
|
|
f5f7a8e63b | ||
|
|
570c4f3a1f | ||
|
|
172164b74b | ||
|
|
49835ae234 | ||
|
|
c4f1e910f8 | ||
|
|
3a621b61c6 | ||
|
|
c28f0d6788 | ||
|
|
2db9224ffc | ||
|
|
043b1df585 | ||
|
|
0626200787 | ||
|
|
b7ee61a8df | ||
|
|
6e1cdaf50e | ||
|
|
e770f802e7 | ||
|
|
8e4cf77fcb | ||
|
|
bc3bd42ce3 | ||
|
|
f73e0ba307 | ||
|
|
5703b5e8d4 | ||
|
|
cecbb2cf72 | ||
|
|
8638e4751d | ||
|
|
3b69e3b029 | ||
|
|
dced21c8e4 | ||
|
|
dab26af294 | ||
|
|
65f118e561 | ||
|
|
67f533b9f6 | ||
|
|
93573bcdb7 | ||
|
|
2263801c55 | ||
|
|
10c34d0440 | ||
|
|
4430ef3847 | ||
|
|
8e331b908d | ||
|
|
dbc6fb58e0 | ||
|
|
db298ab684 | ||
|
|
170a6095e6 | ||
|
|
6dd1bf3281 | ||
|
|
b1500cebfd | ||
|
|
6202bdbc28 | ||
|
|
39bfb61b95 | ||
|
|
fa79524ed4 | ||
|
|
ab5b70e52b | ||
|
|
4f8cd53b83 | ||
|
|
afb6221e5e | ||
|
|
f78aedc30d | ||
|
|
80ff2c8ff7 | ||
|
|
579a77dfc9 | ||
|
|
f902d967a6 | ||
|
|
0899e0b0bf | ||
|
|
65d6f8616b | ||
|
|
5c27f0834c | ||
|
|
a5f7b56bf2 | ||
|
|
63ec92038c | ||
|
|
464ac36ddb | ||
|
|
840bc2ef7a | ||
|
|
e248504528 | ||
|
|
f4f3d9ca48 | ||
|
|
e727ee414b | ||
|
|
1e9295b386 | ||
|
|
b980cdc2c2 | ||
|
|
fbe722facd | ||
|
|
1897d8e0c0 | ||
|
|
3745a152aa | ||
|
|
a7731acb08 | ||
|
|
16dd5deed4 | ||
|
|
c9f70650a0 | ||
|
|
eaa84253df | ||
|
|
45abcd7385 | ||
|
|
8a633aa648 | ||
|
|
05e092062d | ||
|
|
e83602a05c | ||
|
|
abf8666e24 | ||
|
|
af1ccc6669 | ||
|
|
cdcbfbff68 | ||
|
|
6860940afc | ||
|
|
bfc2ee3663 | ||
|
|
b104622081 | ||
|
|
a861387bd7 | ||
|
|
b0f2260fad | ||
|
|
97f0d98ffd | ||
|
|
1ad58a029c | ||
|
|
4c27da0433 | ||
|
|
faf69b43c3 | ||
|
|
7fff561268 | ||
|
|
5e1360a65b | ||
|
|
cc92d2f546 | ||
|
|
def75a250f | ||
|
|
15cd7caf9b | ||
|
|
41a51530ef | ||
|
|
3a101941b3 | ||
|
|
0976fee4df | ||
|
|
f87da777da | ||
|
|
e1c2bf0890 | ||
|
|
b829defb30 | ||
|
|
fa8770d2a7 | ||
|
|
222c8a65af | ||
|
|
76f5b67ac4 | ||
|
|
1791d49efe | ||
|
|
64e1b5df09 | ||
|
|
e1ff077623 | ||
|
|
1361072558 | ||
|
|
5119434d21 | ||
|
|
b29540b14e | ||
|
|
e69785bb89 | ||
|
|
76465fee07 | ||
|
|
b52c459ebb | ||
|
|
1d73982545 | ||
|
|
74f6c45f36 | ||
|
|
0490b528e4 | ||
|
|
ffa1e14449 | ||
|
|
b8fe89b2f4 | ||
|
|
94b293202c | ||
|
|
7ef143a642 | ||
|
|
057f6916e9 | ||
|
|
e24e892cb3 | ||
|
|
78976b06e2 | ||
|
|
96cfcd5b2b | ||
|
|
12bda0122c | ||
|
|
4ac4e5abf2 | ||
|
|
268f0f53a8 | ||
|
|
71521f3428 | ||
|
|
6101fb2bef | ||
|
|
8f6aa0896b | ||
|
|
b8f0af5b2e | ||
|
|
32730f6c41 | ||
|
|
7caa99f8f2 | ||
|
|
4f8e2ab478 | ||
|
|
5c44f392ca | ||
|
|
174d21fd4e | ||
|
|
c2ed6d47f1 | ||
|
|
0f6f717d09 | ||
|
|
d7fb637f68 | ||
|
|
fce9086b27 | ||
|
|
97586cd2c8 | ||
|
|
b74458f0b0 | ||
|
|
7c7a0fceaf | ||
|
|
425a8880cd | ||
|
|
23fe90ec64 | ||
|
|
c01ec5d039 | ||
|
|
4f284165c2 | ||
|
|
2a62ccff11 | ||
|
|
d09cf472dd | ||
|
|
5c721ae6f5 | ||
|
|
2bb8fcdb5f | ||
|
|
6eda93098b | ||
|
|
6344f554d6 | ||
|
|
7e4c1f374c | ||
|
|
28eaab7f7d | ||
|
|
1937944f7e | ||
|
|
3b4b84fdab | ||
|
|
32325bb49c | ||
|
|
c01c1e93f9 | ||
|
|
eac096019f | ||
|
|
9f9389e846 | ||
|
|
a71317881f | ||
|
|
7092824c96 | ||
|
|
0ff998bbd7 | ||
|
|
fc318ad211 | ||
|
|
73323335cb | ||
|
|
ef57c5523d | ||
|
|
846f4a7222 | ||
|
|
05036778d6 | ||
|
|
52df661238 | ||
|
|
7957dc237e | ||
|
|
3fe419ba2f | ||
|
|
61944656b8 | ||
|
|
1cb997b66d | ||
|
|
89463808db | ||
|
|
6aca66d8cf | ||
|
|
38f8102fb3 | ||
|
|
e709499240 | ||
|
|
0b714d5e52 | ||
|
|
98e4f0c6dc | ||
|
|
d82d0af565 | ||
|
|
d8abb7039d | ||
|
|
84dc11048d | ||
|
|
bad915bbaa | ||
|
|
287dea2d36 | ||
|
|
a0b937769d | ||
|
|
6acef4a406 | ||
|
|
8b77eb9850 | ||
|
|
6f22836dcb | ||
|
|
a4347c8878 | ||
|
|
836f7eff09 | ||
|
|
c993bd472d | ||
|
|
431ab92a02 | ||
|
|
94f469a6b1 | ||
|
|
3fec1c6890 | ||
|
|
f8316911bd | ||
|
|
642d1f6be5 | ||
|
|
5a82c3a130 | ||
|
|
6a8174afac | ||
|
|
f4c86634f7 | ||
|
|
322e588a4e | ||
|
|
822dee7a13 | ||
|
|
101e179788 | ||
|
|
57abee6cf0 | ||
|
|
b615847b09 | ||
|
|
ffef87e249 | ||
|
|
ba3b8df4c9 | ||
|
|
40175d3e54 | ||
|
|
06b047cfe6 | ||
|
|
1f4d62ab47 | ||
|
|
a7b826bd4f | ||
|
|
407481faa6 | ||
|
|
305b68546c | ||
|
|
136c41c6aa | ||
|
|
587b25b18b | ||
|
|
beaa40ad65 | ||
|
|
1389a5a238 | ||
|
|
2f34ff8a9f | ||
|
|
d3626b0e7c | ||
|
|
bb4529b6f1 | ||
|
|
dd94125d52 | ||
|
|
a7149e3740 | ||
|
|
b64d041385 | ||
|
|
cc04bdfbc5 | ||
|
|
d8c772ed5e | ||
|
|
dfcc4eeebd | ||
|
|
e491841d4a | ||
|
|
ccb72837b3 | ||
|
|
6560fc9d05 | ||
|
|
14d5879735 | ||
|
|
7fa8bef3de | ||
|
|
966caae727 | ||
|
|
a14484ee03 | ||
|
|
fb9b42ab12 | ||
|
|
6974abdb95 | ||
|
|
65efdeb1df | ||
|
|
54a39ea0a9 | ||
|
|
641350cbde | ||
|
|
06ece8f5ee | ||
|
|
ca87f1c47a | ||
|
|
c38ddb5d00 | ||
|
|
1acd7c4a01 | ||
|
|
d92c2ebdf7 | ||
|
|
8f19e9408e | ||
|
|
3ecb47da5a | ||
|
|
ae03b42c6d | ||
|
|
ee4eb9bb07 | ||
|
|
a0be2e0879 | ||
|
|
a3414d7156 | ||
|
|
81a4b36c08 | ||
|
|
bf154cf83d | ||
|
|
0d1234ca4b | ||
|
|
a1c42f2709 | ||
|
|
7608921684 | ||
|
|
24f2b17416 | ||
|
|
33eb469520 | ||
|
|
90eef904f9 | ||
|
|
d1f72ee53a | ||
|
|
e0e212dfc4 | ||
|
|
ef0a03cb3b | ||
|
|
221eeddab8 | ||
|
|
1076527b62 | ||
|
|
1e13c11061 | ||
|
|
440922380d | ||
|
|
969a199a8e | ||
|
|
21b0176a49 | ||
|
|
06a996cd81 | ||
|
|
e1fc33626e | ||
|
|
b331626e8f | ||
|
|
95d4f725f9 | ||
|
|
45b54a75db | ||
|
|
e000bb05c4 | ||
|
|
d66ca05dca | ||
|
|
321260b0a5 | ||
|
|
5ef8fd18ca | ||
|
|
5b5d5cca1c | ||
|
|
51ac16a9e1 | ||
|
|
cf185c3877 | ||
|
|
71368fba62 | ||
|
|
6bae50a56a | ||
|
|
27681603cd | ||
|
|
e1be05711b | ||
|
|
7b1bb9072e | ||
|
|
a58b0a0806 | ||
|
|
e26950671c | ||
|
|
0d730128f7 | ||
|
|
174664619b | ||
|
|
f8738f10af | ||
|
|
677fb87f71 | ||
|
|
c980e5dd67 | ||
|
|
474995c8dd | ||
|
|
74ee810757 | ||
|
|
c5b56b47ae | ||
|
|
dccaca4972 | ||
|
|
92f53a0034 | ||
|
|
15eb00b1ba | ||
|
|
041b5ad2c0 | ||
|
|
4520ef4078 | ||
|
|
701a1903ba | ||
|
|
ff7458dfc1 | ||
|
|
a72e08c0c6 | ||
|
|
2bff335698 | ||
|
|
b8a256ac7d | ||
|
|
2168c0039a | ||
|
|
d225884ec3 | ||
|
|
9934c4a169 | ||
|
|
9e75f23d8f | ||
|
|
f36471bbf3 | ||
|
|
4664bef4d8 | ||
|
|
71403d4174 | ||
|
|
cb1b99815c | ||
|
|
5668efc8a8 | ||
|
|
d3223ec8b4 | ||
|
|
bfbe39993f | ||
|
|
e90747fd08 | ||
|
|
8926f9784d | ||
|
|
0ff1d58dfb | ||
|
|
8df587aaad | ||
|
|
10fdffc378 | ||
|
|
a7d7335970 | ||
|
|
bb5244c118 | ||
|
|
f20a5e92e2 | ||
|
|
5ce0428b15 | ||
|
|
a43e738365 | ||
|
|
f4a4eab32d | ||
|
|
7ffc58892a | ||
|
|
8f85637bb8 | ||
|
|
a37925396a | ||
|
|
b66749264a | ||
|
|
6dcf2aabd1 | ||
|
|
71bb33d710 | ||
|
|
365c235e1f | ||
|
|
da65e85081 | ||
|
|
7497b88c26 | ||
|
|
5d5c955451 | ||
|
|
c17cc5bd1c | ||
|
|
54e5621267 | ||
|
|
13534b5f44 | ||
|
|
43a0c7be81 | ||
|
|
8ec9705dd6 | ||
|
|
a82e6f3402 | ||
|
|
704081656e | ||
|
|
201a3ae96f | ||
|
|
2d54ec9efb | ||
|
|
c1ac273749 | ||
|
|
9bc5fdf02f | ||
|
|
8d340e0f52 | ||
|
|
8628ac9e9a | ||
|
|
737e24e7dc | ||
|
|
f5943889ec | ||
|
|
61bdd484d3 | ||
|
|
0ed901ffb6 | ||
|
|
5fe5b97130 | ||
|
|
ef79cf1748 | ||
|
|
0f00161b93 | ||
|
|
0809021c25 | ||
|
|
ad28b26e72 | ||
|
|
7827cf49d6 | ||
|
|
8ed58a8aa5 | ||
|
|
068bb1a0d8 | ||
|
|
4f1b458458 | ||
|
|
7ad9c24879 | ||
|
|
e3e476555a | ||
|
|
223c2f464e | ||
|
|
6d396e1982 | ||
|
|
60bf96411c | ||
|
|
3dd4f140e2 | ||
|
|
1131d70645 | ||
|
|
4b080510e7 | ||
|
|
37437877e1 | ||
|
|
da94880c53 | ||
|
|
68ad6d8b55 | ||
|
|
080c0b48d0 | ||
|
|
e8bfecc07d | ||
|
|
8e43a7fa00 | ||
|
|
fa45d1bfad | ||
|
|
9cdc364fde | ||
|
|
6f29af1710 | ||
|
|
e77787e2cd | ||
|
|
84159a3a2d | ||
|
|
68e531ed0c | ||
|
|
72bdf2573c | ||
|
|
00159ce1c5 | ||
|
|
d293e972f2 | ||
|
|
c618e22c52 | ||
|
|
73f2871235 | ||
|
|
bdb30a60c3 | ||
|
|
7da630ed6d | ||
|
|
8845c54d0c | ||
|
|
02f1090fe7 | ||
|
|
9bac3f424f | ||
|
|
db2264023f | ||
|
|
ec8eb4bd1f | ||
|
|
ed5596636a | ||
|
|
dd0fdfc89e | ||
|
|
d212e96664 | ||
|
|
1a720e6a29 | ||
|
|
7316a6e07d | ||
|
|
84a75db464 | ||
|
|
dde3d8e405 | ||
|
|
9e0a39981f | ||
|
|
dab9f53743 | ||
|
|
645164997d | ||
|
|
c2b53b117c | ||
|
|
6e52f60e85 | ||
|
|
1498e24037 | ||
|
|
fdacac74cc | ||
|
|
3defd982e7 | ||
|
|
f4eb9e2a09 | ||
|
|
08693e16f0 | ||
|
|
d95e1522d8 | ||
|
|
150920e0c8 | ||
|
|
074ecbf159 |
6
.dockerignore
Normal file
@@ -0,0 +1,6 @@
|
||||
# ignore everything
|
||||
*
|
||||
|
||||
# allow only what we need
|
||||
!commafeed-server/target/commafeed.jar
|
||||
!commafeed-server/config.yml.example
|
||||
2
.github/FUNDING.yml
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
github: [athou]
|
||||
custom: ['https://www.paypal.com/donate/?business=9CNQHMJG2ZJVY&no_recurring=0&item_name=CommaFeed¤cy_code=EUR']
|
||||
33
.github/ISSUE_TEMPLATE/bug_report.md
vendored
Normal file
@@ -0,0 +1,33 @@
|
||||
---
|
||||
name: Bug report
|
||||
about: Create a report to help us improve
|
||||
title: ""
|
||||
labels: ""
|
||||
assignees: ""
|
||||
---
|
||||
|
||||
**Describe the bug**
|
||||
A clear and concise description of what the bug is.
|
||||
|
||||
**To Reproduce**
|
||||
Steps to reproduce the behavior:
|
||||
|
||||
1. Go to '...'
|
||||
2. Click on '....'
|
||||
3. Scroll down to '....'
|
||||
4. See error
|
||||
|
||||
**Expected behavior**
|
||||
A clear and concise description of what you expected to happen.
|
||||
|
||||
**Screenshots**
|
||||
If applicable, add screenshots to help explain your problem.
|
||||
|
||||
**Environment (please complete the following information):**
|
||||
|
||||
- CommaFeed version (or "commafeed.com"): 3.2.1
|
||||
- Browser [e.g. chrome, firefox]:
|
||||
- Device [e.g. desktop, mobile]:
|
||||
|
||||
**Additional context**
|
||||
Add any other context about the problem here.
|
||||
20
.github/ISSUE_TEMPLATE/feature_request.md
vendored
Normal file
@@ -0,0 +1,20 @@
|
||||
---
|
||||
name: Feature request
|
||||
about: Suggest an idea for this project
|
||||
title: ''
|
||||
labels: ''
|
||||
assignees: ''
|
||||
|
||||
---
|
||||
|
||||
**Is your feature request related to a problem? Please describe.**
|
||||
A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]
|
||||
|
||||
**Describe the solution you'd like**
|
||||
A clear and concise description of what you want to happen.
|
||||
|
||||
**Describe alternatives you've considered**
|
||||
A clear and concise description of any alternative solutions or features you've considered.
|
||||
|
||||
**Additional context**
|
||||
Add any other context or screenshots about the feature request here.
|
||||
19
.github/stale.yml
vendored
Normal file
@@ -0,0 +1,19 @@
|
||||
# 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
|
||||
closeComment: false
|
||||
89
.github/workflows/build.yml
vendored
Normal file
@@ -0,0 +1,89 @@
|
||||
name: Java CI
|
||||
|
||||
on: [ push ]
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
matrix:
|
||||
java: [ "17", "21" ]
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v3
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
# Setup
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v2
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v2
|
||||
|
||||
- name: Set up Java
|
||||
uses: actions/setup-java@v3
|
||||
with:
|
||||
java-version: ${{ matrix.java }}
|
||||
distribution: "temurin"
|
||||
cache: "maven"
|
||||
|
||||
# Build
|
||||
- name: Build with Maven
|
||||
run: mvn --batch-mode --update-snapshots verify
|
||||
|
||||
- name: Upload JAR
|
||||
uses: actions/upload-artifact@v4
|
||||
if: ${{ matrix.java == '17' }}
|
||||
with:
|
||||
name: commafeed.jar
|
||||
path: commafeed-server/target/commafeed.jar
|
||||
|
||||
# Docker
|
||||
- name: Login to Container Registry
|
||||
uses: docker/login-action@v2
|
||||
if: ${{ matrix.java == '17' }}
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
- name: Docker build and push tag
|
||||
uses: docker/build-push-action@v4
|
||||
if: ${{ matrix.java == '17' && github.ref_type == 'tag' }}
|
||||
with:
|
||||
context: .
|
||||
push: true
|
||||
platforms: linux/amd64,linux/arm/v7,linux/arm64/v8
|
||||
tags: |
|
||||
athou/commafeed:latest
|
||||
athou/commafeed:${{ github.ref_name }}
|
||||
|
||||
- name: Docker build and push master
|
||||
uses: docker/build-push-action@v4
|
||||
if: ${{ matrix.java == '17' && github.ref_name == 'master' }}
|
||||
with:
|
||||
context: .
|
||||
push: true
|
||||
platforms: linux/amd64,linux/arm/v7,linux/arm64/v8
|
||||
tags: athou/commafeed:master
|
||||
|
||||
# Create GitHub release after Docker image has been published
|
||||
- name: Extract Changelog Entry
|
||||
uses: mindsers/changelog-reader-action@v2
|
||||
if: ${{ matrix.java == '17' && github.ref_type == 'tag' }}
|
||||
id: changelog_reader
|
||||
with:
|
||||
version: ${{ github.ref_name }}
|
||||
|
||||
- name: Create GitHub release
|
||||
uses: softprops/action-gh-release@v1
|
||||
if: ${{ matrix.java == '17' && github.ref_type == 'tag' }}
|
||||
with:
|
||||
name: CommaFeed ${{ github.ref_name }}
|
||||
body: ${{ steps.changelog_reader.outputs.changes }}
|
||||
draft: false
|
||||
prerelease: false
|
||||
files: |
|
||||
commafeed-server/target/commafeed.jar
|
||||
commafeed-server/config.yml.example
|
||||
32
.gitignore
vendored
@@ -1,19 +1,32 @@
|
||||
#runtime files
|
||||
commafeed.log
|
||||
derby.log
|
||||
data/
|
||||
java_pid*
|
||||
# config file
|
||||
config.yml
|
||||
|
||||
# Maven build directory
|
||||
# build directory
|
||||
target
|
||||
deployments/ROOT.war
|
||||
target-ide
|
||||
|
||||
# database files
|
||||
database
|
||||
|
||||
# log files
|
||||
log
|
||||
|
||||
# jetty sessions
|
||||
sessions
|
||||
|
||||
# node
|
||||
node
|
||||
node_modules
|
||||
|
||||
# bower
|
||||
src/main/app/lib
|
||||
|
||||
# Eclipse files
|
||||
.project
|
||||
.classpath
|
||||
.settings
|
||||
.factorypath
|
||||
/target
|
||||
.checkstyle
|
||||
|
||||
# IntelliJ Idea files
|
||||
.idea
|
||||
@@ -22,5 +35,8 @@ deployments/ROOT.war
|
||||
# Sublime
|
||||
*.sublime*
|
||||
|
||||
# VSCode
|
||||
.vscode
|
||||
|
||||
# Macs
|
||||
*.DS_Store
|
||||
|
||||
BIN
.mvn/wrapper/maven-wrapper.jar
vendored
Normal file
18
.mvn/wrapper/maven-wrapper.properties
vendored
Normal file
@@ -0,0 +1,18 @@
|
||||
# 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
|
||||
#
|
||||
# https://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing,
|
||||
# software distributed under the License is distributed on an
|
||||
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
# KIND, either express or implied. See the License for the
|
||||
# specific language governing permissions and limitations
|
||||
# under the License.
|
||||
distributionUrl=https://repo.maven.apache.org/maven2/org/apache/maven/apache-maven/3.8.3/apache-maven-3.8.3-bin.zip
|
||||
wrapperUrl=https://repo.maven.apache.org/maven2/org/apache/maven/wrapper/maven-wrapper/3.1.1/maven-wrapper-3.1.1.jar
|
||||
@@ -1,3 +0,0 @@
|
||||
For information about which action hooks are supported, consult the OpenShift documentation:
|
||||
|
||||
https://github.com/openshift/origin-server/blob/master/node/README.writing_applications.md
|
||||
@@ -1,5 +0,0 @@
|
||||
#!/bin/bash
|
||||
# This is a simple build script and will be executed on your CI system if
|
||||
# available. Otherwise it will execute while your application is stopped
|
||||
# before the deploy step. This script gets executed directly, so it
|
||||
# could be python, php, ruby, etc.
|
||||
@@ -1,5 +0,0 @@
|
||||
#!/bin/bash
|
||||
# This deploy hook gets executed after dependencies are resolved and the
|
||||
# build hook has been run but before the application has been started back
|
||||
# up again. This script gets executed directly, so it could be python, php,
|
||||
# ruby, etc.
|
||||
@@ -1,4 +0,0 @@
|
||||
#!/bin/bash
|
||||
# This is a simple post deploy hook executed after your application
|
||||
# is deployed and started. This script gets executed directly, so
|
||||
# it could be python, php, ruby, etc.
|
||||
@@ -1,14 +0,0 @@
|
||||
#!/bin/bash
|
||||
|
||||
# The pre_start_cartridge and pre_stop_cartridge hooks are *SOURCED*
|
||||
# immediately before (re)starting or stopping the specified cartridge.
|
||||
# They are able to make any desired environment variable changes as
|
||||
# well as other adjustments to the application environment.
|
||||
|
||||
# The post_start_cartridge and post_stop_cartridge hooks are executed
|
||||
# immediately after (re)starting or stopping the specified cartridge.
|
||||
|
||||
# Exercise caution when adding commands to these hooks. They can
|
||||
# prevent your application from stopping cleanly or starting at all.
|
||||
# Application start and stop is subject to different timeouts
|
||||
# throughout the system.
|
||||
@@ -1,14 +0,0 @@
|
||||
#!/bin/bash
|
||||
|
||||
# The pre_start_cartridge and pre_stop_cartridge hooks are *SOURCED*
|
||||
# immediately before (re)starting or stopping the specified cartridge.
|
||||
# They are able to make any desired environment variable changes as
|
||||
# well as other adjustments to the application environment.
|
||||
|
||||
# The post_start_cartridge and post_stop_cartridge hooks are executed
|
||||
# immediately after (re)starting or stopping the specified cartridge.
|
||||
|
||||
# Exercise caution when adding commands to these hooks. They can
|
||||
# prevent your application from stopping cleanly or starting at all.
|
||||
# Application start and stop is subject to different timeouts
|
||||
# throughout the system.
|
||||
@@ -1,5 +0,0 @@
|
||||
#!/bin/bash
|
||||
# This is a simple script and will be executed on your CI system if
|
||||
# available. Otherwise it will execute while your application is stopped
|
||||
# before the build step. This script gets executed directly, so it
|
||||
# could be python, php, ruby, etc.
|
||||
@@ -1,7 +0,0 @@
|
||||
#!/bin/bash
|
||||
# This is a simple bash script and will be sourced prior to building
|
||||
# your application. This script can be used to modify the Maven build
|
||||
# arguments for non-CI/Jenkins builds by exporting MAVEN_ARGS. The default
|
||||
# is "clean package -Popenshift -DskipTests"
|
||||
export MAVEN_ARGS="clean package -Popenshift -Pprod -DskipTests=true"
|
||||
export MAVEN_OPTS="-Xmx512m -XX:MaxPermSize=128m -Dmaven.artifact.threads=20"
|
||||
@@ -1,14 +0,0 @@
|
||||
#!/bin/bash
|
||||
|
||||
# The pre_start_cartridge and pre_stop_cartridge hooks are *SOURCED*
|
||||
# immediately before (re)starting or stopping the specified cartridge.
|
||||
# They are able to make any desired environment variable changes as
|
||||
# well as other adjustments to the application environment.
|
||||
|
||||
# The post_start_cartridge and post_stop_cartridge hooks are executed
|
||||
# immediately after (re)starting or stopping the specified cartridge.
|
||||
|
||||
# Exercise caution when adding commands to these hooks. They can
|
||||
# prevent your application from stopping cleanly or starting at all.
|
||||
# Application start and stop is subject to different timeouts
|
||||
# throughout the system.
|
||||
@@ -1,14 +0,0 @@
|
||||
#!/bin/bash
|
||||
|
||||
# The pre_start_cartridge and pre_stop_cartridge hooks are *SOURCED*
|
||||
# immediately before (re)starting or stopping the specified cartridge.
|
||||
# They are able to make any desired environment variable changes as
|
||||
# well as other adjustments to the application environment.
|
||||
|
||||
# The post_start_cartridge and post_stop_cartridge hooks are executed
|
||||
# immediately after (re)starting or stopping the specified cartridge.
|
||||
|
||||
# Exercise caution when adding commands to these hooks. They can
|
||||
# prevent your application from stopping cleanly or starting at all.
|
||||
# Application start and stop is subject to different timeouts
|
||||
# throughout the system.
|
||||
@@ -1,3 +0,0 @@
|
||||
Place your jboss-as7 modules in this directory. This directory is added to the
|
||||
module path of the jboss-as7 server associated with your application. It has the
|
||||
same structure as the jboss-as7/modules directory.
|
||||
@@ -1,517 +0,0 @@
|
||||
<?xml version='1.0' encoding='UTF-8'?>
|
||||
|
||||
<server xmlns="urn:jboss:domain:1.3">
|
||||
|
||||
<extensions>
|
||||
<extension module="org.jboss.as.clustering.infinispan" />
|
||||
<extension module="org.jboss.as.clustering.jgroups" />
|
||||
<extension module="org.jboss.as.cmp" />
|
||||
<extension module="org.jboss.as.configadmin" />
|
||||
<extension module="org.jboss.as.connector" />
|
||||
<extension module="org.jboss.as.deployment-scanner" />
|
||||
<extension module="org.jboss.as.ee" />
|
||||
<extension module="org.jboss.as.ejb3" />
|
||||
<extension module="org.jboss.as.jacorb" />
|
||||
<extension module="org.jboss.as.jaxr" />
|
||||
<extension module="org.jboss.as.jaxrs" />
|
||||
<extension module="org.jboss.as.jdr" />
|
||||
<extension module="org.jboss.as.jmx" />
|
||||
<extension module="org.jboss.as.jpa" />
|
||||
<extension module="org.jboss.as.jsr77" />
|
||||
<extension module="org.jboss.as.logging" />
|
||||
<extension module="org.jboss.as.mail" />
|
||||
<extension module="org.jboss.as.messaging" />
|
||||
<extension module="org.jboss.as.naming" />
|
||||
<extension module="org.jboss.as.osgi" />
|
||||
<extension module="org.jboss.as.pojo" />
|
||||
<extension module="org.jboss.as.remoting" />
|
||||
<extension module="org.jboss.as.sar" />
|
||||
<extension module="org.jboss.as.security" />
|
||||
<extension module="org.jboss.as.threads" />
|
||||
<extension module="org.jboss.as.transactions" />
|
||||
<extension module="org.jboss.as.web" />
|
||||
<extension module="org.jboss.as.webservices" />
|
||||
<extension module="org.jboss.as.weld" />
|
||||
</extensions>
|
||||
|
||||
<system-properties>
|
||||
<property name="org.apache.coyote.http11.Http11Protocol.COMPRESSION" value="on"/>
|
||||
</system-properties>
|
||||
|
||||
<management>
|
||||
<management-interfaces>
|
||||
<native-interface>
|
||||
<socket-binding native="management-native"/>
|
||||
</native-interface>
|
||||
<http-interface>
|
||||
<socket-binding http="management-http"/>
|
||||
</http-interface>
|
||||
</management-interfaces>
|
||||
</management>
|
||||
|
||||
<profile>
|
||||
<subsystem xmlns="urn:jboss:domain:logging:1.1">
|
||||
<!--console-handler name="CONSOLE"> <level name="INFO"/> <formatter> <pattern-formatter
|
||||
pattern="%d{HH:mm:ss,SSS} %-5p [%c] (%t) %s%E%n"/> </formatter> </console-handler -->
|
||||
<periodic-rotating-file-handler name="FILE">
|
||||
<formatter>
|
||||
<pattern-formatter
|
||||
pattern="%d{yyyy/MM/dd HH:mm:ss,SSS} %-5p [%c] (%t) %s%E%n" />
|
||||
</formatter>
|
||||
<file relative-to="jboss.server.log.dir" path="server.log" />
|
||||
<suffix value=".yyyy-MM-dd" />
|
||||
<append value="true" />
|
||||
</periodic-rotating-file-handler>
|
||||
<logger category="com.arjuna">
|
||||
<level name="WARN" />
|
||||
</logger>
|
||||
<logger category="org.apache.tomcat.util.modeler">
|
||||
<level name="WARN" />
|
||||
</logger>
|
||||
<logger category="sun.rmi">
|
||||
<level name="WARN" />
|
||||
</logger>
|
||||
<logger category="jacorb">
|
||||
<level name="WARN" />
|
||||
</logger>
|
||||
<logger category="jacorb.config">
|
||||
<level name="ERROR" />
|
||||
</logger>
|
||||
<root-logger>
|
||||
<level name="INFO" />
|
||||
<handlers>
|
||||
<!--handler name="CONSOLE"/ -->
|
||||
<handler name="FILE" />
|
||||
</handlers>
|
||||
</root-logger>
|
||||
</subsystem>
|
||||
<subsystem xmlns="urn:jboss:domain:cmp:1.0" />
|
||||
<subsystem xmlns="urn:jboss:domain:configadmin:1.0" />
|
||||
<subsystem xmlns="urn:jboss:domain:datasources:1.1">
|
||||
<datasources>
|
||||
<datasource jndi-name="java:jboss/datasources/MysqlDS"
|
||||
enabled="${mysql.enabled}" use-java-context="true" pool-name="MysqlDS">
|
||||
<connection-url>jdbc:mysql://${env.OPENSHIFT_MYSQL_DB_HOST}:${env.OPENSHIFT_MYSQL_DB_PORT}/${env.OPENSHIFT_APP_NAME}?useUnicode=true&characterEncoding=UTF-8
|
||||
</connection-url>
|
||||
<driver>mysql</driver>
|
||||
<security>
|
||||
<user-name>${env.OPENSHIFT_MYSQL_DB_USERNAME}</user-name>
|
||||
<password>${env.OPENSHIFT_MYSQL_DB_PASSWORD}</password>
|
||||
</security>
|
||||
</datasource>
|
||||
<drivers>
|
||||
<driver name="mysql" module="com.mysql.jdbc">
|
||||
<xa-datasource-class>com.mysql.jdbc.jdbc2.optional.MysqlXADataSource</xa-datasource-class>
|
||||
</driver>
|
||||
</drivers>
|
||||
</datasources>
|
||||
</subsystem>
|
||||
<subsystem xmlns="urn:jboss:domain:deployment-scanner:1.1">
|
||||
<deployment-scanner path="deployments"
|
||||
relative-to="jboss.server.base.dir" scan-interval="5000"
|
||||
deployment-timeout="300" />
|
||||
</subsystem>
|
||||
<subsystem xmlns="urn:jboss:domain:ee:1.1">
|
||||
<spec-descriptor-property-replacement>false
|
||||
</spec-descriptor-property-replacement>
|
||||
<jboss-descriptor-property-replacement>true
|
||||
</jboss-descriptor-property-replacement>
|
||||
</subsystem>
|
||||
<subsystem xmlns="urn:jboss:domain:ejb3:1.3">
|
||||
<session-bean>
|
||||
<stateless>
|
||||
<bean-instance-pool-ref pool-name="slsb-strict-max-pool" />
|
||||
</stateless>
|
||||
<stateful default-access-timeout="5000" cache-ref="simple"
|
||||
clustered-cache-ref="clustered" />
|
||||
<singleton default-access-timeout="5000" />
|
||||
</session-bean>
|
||||
<mdb>
|
||||
<resource-adapter-ref resource-adapter-name="hornetq-ra" />
|
||||
<bean-instance-pool-ref pool-name="mdb-strict-max-pool" />
|
||||
</mdb>
|
||||
<pools>
|
||||
<bean-instance-pools>
|
||||
<strict-max-pool name="slsb-strict-max-pool"
|
||||
max-pool-size="20" instance-acquisition-timeout="5"
|
||||
instance-acquisition-timeout-unit="MINUTES" />
|
||||
<strict-max-pool name="mdb-strict-max-pool"
|
||||
max-pool-size="20" instance-acquisition-timeout="5"
|
||||
instance-acquisition-timeout-unit="MINUTES" />
|
||||
</bean-instance-pools>
|
||||
</pools>
|
||||
<caches>
|
||||
<cache name="simple" aliases="NoPassivationCache" />
|
||||
<cache name="passivating" passivation-store-ref="file"
|
||||
aliases="SimpleStatefulCache" />
|
||||
<cache name="clustered" passivation-store-ref="infinispan"
|
||||
aliases="StatefulTreeCache" />
|
||||
</caches>
|
||||
<passivation-stores>
|
||||
<file-passivation-store name="file" />
|
||||
<cluster-passivation-store name="infinispan"
|
||||
cache-container="ejb" />
|
||||
</passivation-stores>
|
||||
<async thread-pool-name="default" />
|
||||
<timer-service thread-pool-name="default">
|
||||
<data-store path="timer-service-data" relative-to="jboss.server.data.dir" />
|
||||
</timer-service>
|
||||
<remote connector-ref="remoting-connector" thread-pool-name="default" />
|
||||
<thread-pools>
|
||||
<thread-pool name="default">
|
||||
<max-threads count="10" />
|
||||
<keepalive-time time="100" unit="milliseconds" />
|
||||
</thread-pool>
|
||||
</thread-pools>
|
||||
<iiop enable-by-default="false" use-qualified-name="false" />
|
||||
</subsystem>
|
||||
<subsystem xmlns="urn:jboss:domain:infinispan:1.3">
|
||||
<cache-container name="cluster" aliases="ha-partition"
|
||||
default-cache="default">
|
||||
<transport lock-timeout="60000" />
|
||||
<replicated-cache name="default" mode="SYNC"
|
||||
batching="true">
|
||||
<locking isolation="REPEATABLE_READ" />
|
||||
</replicated-cache>
|
||||
</cache-container>
|
||||
<cache-container name="web" aliases="standard-session-cache"
|
||||
default-cache="repl">
|
||||
<transport lock-timeout="60000" />
|
||||
<replicated-cache name="repl" mode="ASYNC"
|
||||
batching="true">
|
||||
<file-store />
|
||||
</replicated-cache>
|
||||
<replicated-cache name="sso" mode="SYNC" batching="true" />
|
||||
<distributed-cache name="dist" mode="ASYNC"
|
||||
batching="true" l1-lifespan="0">
|
||||
<file-store />
|
||||
</distributed-cache>
|
||||
</cache-container>
|
||||
<cache-container name="ejb" aliases="sfsb sfsb-cache"
|
||||
default-cache="repl">
|
||||
<transport lock-timeout="60000" />
|
||||
<replicated-cache name="repl" mode="ASYNC"
|
||||
batching="true">
|
||||
<eviction strategy="LRU" max-entries="10000" />
|
||||
<file-store />
|
||||
</replicated-cache>
|
||||
<!-- ~ Clustered cache used internally by EJB subsytem for managing the
|
||||
client-mapping(s) of ~ the socketbinding referenced by the EJB remoting connector -->
|
||||
<replicated-cache name="remote-connector-client-mappings"
|
||||
mode="SYNC" batching="true" />
|
||||
<distributed-cache name="dist" mode="ASYNC"
|
||||
batching="true" l1-lifespan="0">
|
||||
<eviction strategy="LRU" max-entries="10000" />
|
||||
<file-store />
|
||||
</distributed-cache>
|
||||
</cache-container>
|
||||
<cache-container name="hibernate" default-cache="local-query"
|
||||
module="org.jboss.as.jpa.hibernate:4">
|
||||
<transport lock-timeout="60000" />
|
||||
<local-cache name="local-query">
|
||||
<transaction mode="NONE" />
|
||||
<eviction strategy="LRU" max-entries="10000" />
|
||||
<expiration max-idle="100000" />
|
||||
</local-cache>
|
||||
<invalidation-cache name="entity" mode="SYNC">
|
||||
<transaction mode="NON_XA" />
|
||||
<eviction strategy="LRU" max-entries="10000" />
|
||||
<expiration max-idle="100000" />
|
||||
</invalidation-cache>
|
||||
<replicated-cache name="timestamps" mode="ASYNC">
|
||||
<transaction mode="NONE" />
|
||||
<eviction strategy="NONE" />
|
||||
</replicated-cache>
|
||||
</cache-container>
|
||||
</subsystem>
|
||||
<subsystem xmlns="urn:jboss:domain:jacorb:1.2">
|
||||
<orb>
|
||||
<initializers transactions="spec" security="on" />
|
||||
</orb>
|
||||
</subsystem>
|
||||
<subsystem xmlns="urn:jboss:domain:jaxr:1.1">
|
||||
<connection-factory jndi-name="java:jboss/jaxr/ConnectionFactory" />
|
||||
</subsystem>
|
||||
<subsystem xmlns="urn:jboss:domain:jaxrs:1.0" />
|
||||
<subsystem xmlns="urn:jboss:domain:jca:1.1">
|
||||
<archive-validation enabled="true" fail-on-error="true" fail-on-warn="false"/>
|
||||
<bean-validation enabled="true"/>
|
||||
<default-workmanager>
|
||||
<short-running-threads>
|
||||
<core-threads count="50" />
|
||||
<queue-length count="50" />
|
||||
<max-threads count="50" />
|
||||
<keepalive-time time="10" unit="seconds" />
|
||||
</short-running-threads>
|
||||
<long-running-threads>
|
||||
<core-threads count="50" />
|
||||
<queue-length count="50" />
|
||||
<max-threads count="50" />
|
||||
<keepalive-time time="10" unit="seconds" />
|
||||
</long-running-threads>
|
||||
</default-workmanager>
|
||||
<cached-connection-manager />
|
||||
</subsystem>
|
||||
<subsystem xmlns="urn:jboss:domain:jdr:1.0" />
|
||||
<subsystem xmlns="urn:jboss:domain:jgroups:1.1"
|
||||
default-stack="tcp">
|
||||
<stack name="tcp">
|
||||
<transport type="TCP" socket-binding="jgroups-tcp">
|
||||
<property name="external_addr">${env.OPENSHIFT_GEAR_DNS}</property>
|
||||
<property name="external_port">${env.OPENSHIFT_JBOSSEAP_CLUSTER_PROXY_PORT}
|
||||
</property>
|
||||
<property name="bind_port">7600</property>
|
||||
<property name="bind_addr">${env.OPENSHIFT_INTERNAL_IP}</property>
|
||||
</transport>
|
||||
<protocol type="TCPPING">
|
||||
<property name="timeout">3000</property>
|
||||
<property name="initial_hosts">${env.OPENSHIFT_JBOSSEAP_CLUSTER}</property>
|
||||
<property name="port_range">0</property>
|
||||
<property name="num_initial_members">1</property>
|
||||
</protocol>
|
||||
<protocol type="MERGE2" />
|
||||
<protocol type="FD" />
|
||||
<protocol type="VERIFY_SUSPECT" />
|
||||
<protocol type="BARRIER" />
|
||||
<protocol type="pbcast.NAKACK" />
|
||||
<protocol type="UNICAST2" />
|
||||
<protocol type="pbcast.STABLE" />
|
||||
<protocol type="AUTH">
|
||||
<property name="auth_class">org.jgroups.auth.MD5Token</property>
|
||||
<property name="token_hash">SHA</property>
|
||||
<property name="auth_value">${env.OPENSHIFT_APP_UUID}</property>
|
||||
</protocol>
|
||||
<protocol type="pbcast.GMS" />
|
||||
<protocol type="UFC" />
|
||||
<protocol type="MFC" />
|
||||
<protocol type="FRAG2" />
|
||||
<!--protocol type="pbcast.STATE_TRANSFER"/> <protocol type="pbcast.FLUSH"/ -->
|
||||
</stack>
|
||||
</subsystem>
|
||||
<subsystem xmlns="urn:jboss:domain:jmx:1.1">
|
||||
<show-model value="true" />
|
||||
<remoting-connector />
|
||||
</subsystem>
|
||||
<subsystem xmlns="urn:jboss:domain:jpa:1.0">
|
||||
<jpa default-datasource="" />
|
||||
</subsystem>
|
||||
<subsystem xmlns="urn:jboss:domain:jsr77:1.0" />
|
||||
<subsystem xmlns="urn:jboss:domain:mail:1.0">
|
||||
<mail-session jndi-name="java:jboss/mail/Default">
|
||||
<smtp-server outbound-socket-binding-ref="mail-smtp" />
|
||||
</mail-session>
|
||||
</subsystem>
|
||||
<subsystem xmlns="urn:jboss:domain:messaging:1.2">
|
||||
<hornetq-server>
|
||||
<clustered>false</clustered>
|
||||
<persistence-enabled>false</persistence-enabled>
|
||||
<security-enabled>false</security-enabled>
|
||||
<journal-file-size>102400</journal-file-size>
|
||||
<journal-min-files>2</journal-min-files>
|
||||
|
||||
<thread-pool-max-size>${messaging.thread.pool.max.size}</thread-pool-max-size>
|
||||
<scheduled-thread-pool-max-size>${messaging.scheduled.thread.pool.max.size}</scheduled-thread-pool-max-size>
|
||||
|
||||
<connectors>
|
||||
<netty-connector name="netty" socket-binding="messaging" />
|
||||
<netty-connector name="netty-throughput"
|
||||
socket-binding="messaging-throughput">
|
||||
<param key="batch-delay" value="50" />
|
||||
</netty-connector>
|
||||
<in-vm-connector name="in-vm" server-id="0" />
|
||||
</connectors>
|
||||
<acceptors>
|
||||
<netty-acceptor name="netty" socket-binding="messaging" />
|
||||
<netty-acceptor name="netty-throughput"
|
||||
socket-binding="messaging-throughput">
|
||||
<param key="batch-delay" value="50" />
|
||||
<param key="direct-deliver" value="false" />
|
||||
</netty-acceptor>
|
||||
<in-vm-acceptor name="in-vm" server-id="0" />
|
||||
</acceptors>
|
||||
<!--broadcast-groups> <broadcast-group name="bg-group1"> <socket-binding>messaging-group</socket-binding>
|
||||
<broadcast-period>5000</broadcast-period> <connector-ref>netty</connector-ref>
|
||||
</broadcast-group> </broadcast-groups> <discovery-groups> <discovery-group
|
||||
name="dg-group1"> <socket-binding>messaging-group</socket-binding> <refresh-timeout>10000</refresh-timeout>
|
||||
</discovery-group> </discovery-groups> <cluster-connections> <cluster-connection
|
||||
name="my-cluster"> <address>jms</address> <connector-ref>netty</connector-ref>
|
||||
<discovery-group-ref discovery-group-name="dg-group1"/> </cluster-connection>
|
||||
</cluster-connections -->
|
||||
<address-settings>
|
||||
<!--default for catch all -->
|
||||
<address-setting match="#">
|
||||
<dead-letter-address>jms.queue.DLQ</dead-letter-address>
|
||||
<expiry-address>jms.queue.ExpiryQueue</expiry-address>
|
||||
<redelivery-delay>0</redelivery-delay>
|
||||
<redistribution-delay>1000</redistribution-delay>
|
||||
<max-size-bytes>10485760</max-size-bytes>
|
||||
<address-full-policy>BLOCK</address-full-policy>
|
||||
<message-counter-history-day-limit>10
|
||||
</message-counter-history-day-limit>
|
||||
</address-setting>
|
||||
</address-settings>
|
||||
<jms-connection-factories>
|
||||
<connection-factory name="InVmConnectionFactory">
|
||||
<connectors>
|
||||
<connector-ref connector-name="in-vm" />
|
||||
</connectors>
|
||||
<entries>
|
||||
<entry name="java:/ConnectionFactory" />
|
||||
</entries>
|
||||
</connection-factory>
|
||||
<!--
|
||||
<connection-factory name="RemoteConnectionFactory">
|
||||
<connectors>
|
||||
<connector-ref connector-name="netty" />
|
||||
</connectors>
|
||||
<entries>
|
||||
<entry name="java:jboss/exported/jms/RemoteConnectionFactory" />
|
||||
</entries>
|
||||
</connection-factory>
|
||||
-->
|
||||
<pooled-connection-factory name="hornetq-ra">
|
||||
<transaction mode="xa" />
|
||||
<connectors>
|
||||
<connector-ref connector-name="in-vm" />
|
||||
</connectors>
|
||||
<entries>
|
||||
<entry name="java:/JmsXA" />
|
||||
</entries>
|
||||
</pooled-connection-factory>
|
||||
</jms-connection-factories>
|
||||
<jms-destinations>
|
||||
<jms-queue name="refreshQueue">
|
||||
<entry name="jms/refreshQueue"/>
|
||||
<entry name="java:jboss/exported/jms/refreshQueue"/>
|
||||
</jms-queue>
|
||||
</jms-destinations>
|
||||
</hornetq-server>
|
||||
</subsystem>
|
||||
<subsystem xmlns="urn:jboss:domain:naming:1.2">
|
||||
<remote-naming />
|
||||
</subsystem>
|
||||
<subsystem xmlns="urn:jboss:domain:osgi:1.2" activation="lazy">
|
||||
<properties>
|
||||
<!-- Specifies the beginning start level of the framework -->
|
||||
<property name="org.osgi.framework.startlevel.beginning">1</property>
|
||||
</properties>
|
||||
<capabilities>
|
||||
<!-- modules registered with the OSGi layer on startup -->
|
||||
<capability name="javax.servlet.api:v25" />
|
||||
<capability name="javax.transaction.api" />
|
||||
<!-- bundles started in startlevel 1 -->
|
||||
<capability name="org.apache.felix.log" startlevel="1" />
|
||||
<capability name="org.jboss.osgi.logging" startlevel="1" />
|
||||
<capability name="org.apache.felix.configadmin"
|
||||
startlevel="1" />
|
||||
<capability name="org.jboss.as.osgi.configadmin"
|
||||
startlevel="1" />
|
||||
</capabilities>
|
||||
</subsystem>
|
||||
<subsystem xmlns="urn:jboss:domain:pojo:1.0" />
|
||||
<subsystem xmlns="urn:jboss:domain:remoting:1.1">
|
||||
<connector name="remoting-connector" socket-binding="remoting" />
|
||||
</subsystem>
|
||||
<subsystem xmlns="urn:jboss:domain:resource-adapters:1.0" />
|
||||
<subsystem xmlns="urn:jboss:domain:sar:1.0" />
|
||||
<subsystem xmlns="urn:jboss:domain:security:1.2">
|
||||
<security-domains>
|
||||
<security-domain name="other" cache-type="default">
|
||||
<authentication>
|
||||
<login-module code="Remoting" flag="optional">
|
||||
<module-option name="password-stacking" value="useFirstPass"/>
|
||||
</login-module>
|
||||
<login-module code="RealmDirect" flag="required">
|
||||
<module-option name="password-stacking" value="useFirstPass"/>
|
||||
</login-module>
|
||||
</authentication>
|
||||
</security-domain>
|
||||
<security-domain name="jboss-web-policy" cache-type="default">
|
||||
<authorization>
|
||||
<policy-module code="Delegating" flag="required"/>
|
||||
</authorization>
|
||||
</security-domain>
|
||||
<security-domain name="jboss-ejb-policy" cache-type="default">
|
||||
<authorization>
|
||||
<policy-module code="Delegating" flag="required"/>
|
||||
</authorization>
|
||||
</security-domain>
|
||||
</security-domains>
|
||||
</subsystem>
|
||||
<subsystem xmlns="urn:jboss:domain:threads:1.1" />
|
||||
<subsystem xmlns="urn:jboss:domain:transactions:1.2">
|
||||
<core-environment>
|
||||
<process-id>
|
||||
<uuid />
|
||||
</process-id>
|
||||
</core-environment>
|
||||
<recovery-environment socket-binding="txn-recovery-environment"
|
||||
status-socket-binding="txn-status-manager" />
|
||||
<coordinator-environment default-timeout="300" />
|
||||
</subsystem>
|
||||
<subsystem xmlns="urn:jboss:domain:web:1.1"
|
||||
default-virtual-server="default-host" native="false">
|
||||
<connector name="http" protocol="HTTP/1.1" scheme="http"
|
||||
socket-binding="http" />
|
||||
<virtual-server name="default-host"
|
||||
enable-welcome-root="false">
|
||||
<alias name="localhost" />
|
||||
</virtual-server>
|
||||
</subsystem>
|
||||
<subsystem xmlns="urn:jboss:domain:webservices:1.1">
|
||||
<modify-wsdl-address>true</modify-wsdl-address>
|
||||
<wsdl-host>${env.OPENSHIFT_GEAR_DNS}</wsdl-host>
|
||||
<wsdl-port>80</wsdl-port>
|
||||
<endpoint-config name="Standard-Endpoint-Config" />
|
||||
<endpoint-config name="Recording-Endpoint-Config">
|
||||
<pre-handler-chain name="recording-handlers"
|
||||
protocol-bindings="##SOAP11_HTTP ##SOAP11_HTTP_MTOM ##SOAP12_HTTP ##SOAP12_HTTP_MTOM">
|
||||
<handler name="RecordingHandler"
|
||||
class="org.jboss.ws.common.invocation.RecordingServerHandler" />
|
||||
</pre-handler-chain>
|
||||
</endpoint-config>
|
||||
</subsystem>
|
||||
<subsystem xmlns="urn:jboss:domain:weld:1.0" />
|
||||
</profile>
|
||||
|
||||
<interfaces>
|
||||
<interface name="management">
|
||||
<loopback-address value="${env.OPENSHIFT_INTERNAL_IP}" />
|
||||
</interface>
|
||||
<interface name="public">
|
||||
<loopback-address value="${env.OPENSHIFT_INTERNAL_IP}" />
|
||||
</interface>
|
||||
<interface name="unsecure">
|
||||
<!-- Used for IIOP sockets in the standarad configuration. To secure JacORB
|
||||
you need to setup SSL -->
|
||||
<loopback-address value="${env.OPENSHIFT_INTERNAL_IP}" />
|
||||
</interface>
|
||||
</interfaces>
|
||||
|
||||
<socket-binding-group name="standard-sockets"
|
||||
default-interface="public" port-offset="0">
|
||||
<socket-binding name="management-native" interface="management"
|
||||
port="9999" />
|
||||
<socket-binding name="management-http" interface="management"
|
||||
port="9990" />
|
||||
|
||||
<socket-binding name="http" port="8080" />
|
||||
<socket-binding name="jacorb" interface="unsecure"
|
||||
port="3528" />
|
||||
<socket-binding name="jacorb-ssl" interface="unsecure"
|
||||
port="3529" />
|
||||
<socket-binding name="jgroups-tcp" port="7600" />
|
||||
<socket-binding name="messaging" port="5445" />
|
||||
<!--socket-binding name="messaging-group" multicast-address="${jboss.messaging.group.address:231.7.7.7}"
|
||||
multicast-port="${jboss.messaging.group.port:9876}"/ -->
|
||||
<socket-binding name="messaging-throughput" port="5455" />
|
||||
<socket-binding name="osgi-http" interface="management"
|
||||
port="8090" />
|
||||
<socket-binding name="remoting" port="4447" />
|
||||
<socket-binding name="txn-recovery-environment" port="4712" />
|
||||
<socket-binding name="txn-status-manager" port="4713" />
|
||||
<outbound-socket-binding name="mail-smtp">
|
||||
<remote-destination host="localhost" port="25" />
|
||||
</outbound-socket-binding>
|
||||
</socket-binding-group>
|
||||
</server>
|
||||
@@ -1,22 +0,0 @@
|
||||
Run scripts or jobs on a periodic basis
|
||||
=======================================
|
||||
Any scripts or jobs added to the minutely, hourly, daily, weekly or monthly
|
||||
directories will be run on a scheduled basis (frequency is as indicated by the
|
||||
name of the directory) using run-parts.
|
||||
|
||||
run-parts ignores any files that are hidden or dotfiles (.*) or backup
|
||||
files (*~ or *,) or named *.{rpmsave,rpmorig,rpmnew,swp,cfsaved}
|
||||
|
||||
The presence of two specially named files jobs.deny and jobs.allow controls
|
||||
how run-parts executes your scripts/jobs.
|
||||
jobs.deny ===> Prevents specific scripts or jobs from being executed.
|
||||
jobs.allow ===> Only execute the named scripts or jobs (all other/non-named
|
||||
scripts that exist in this directory are ignored).
|
||||
|
||||
The principles of jobs.deny and jobs.allow are the same as those of cron.deny
|
||||
and cron.allow and are described in detail at:
|
||||
http://docs.redhat.com/docs/en-US/Red_Hat_Enterprise_Linux/6/html/Deployment_Guide/ch-Automating_System_Tasks.html#s2-autotasks-cron-access
|
||||
|
||||
See: man crontab or above link for more details and see the the weekly/
|
||||
directory for an example.
|
||||
|
||||
0
.openshift/cron/daily/.gitignore
vendored
@@ -1 +0,0 @@
|
||||
rm -rf $OPENSHIFT_JBOSSAS_LOG_DIR\*.log.*
|
||||
0
.openshift/cron/hourly/.gitignore
vendored
0
.openshift/cron/minutely/.gitignore
vendored
0
.openshift/cron/monthly/.gitignore
vendored
@@ -1,16 +0,0 @@
|
||||
Run scripts or jobs on a weekly basis
|
||||
=====================================
|
||||
Any scripts or jobs added to this directory will be run on a scheduled basis
|
||||
(weekly) using run-parts.
|
||||
|
||||
run-parts ignores any files that are hidden or dotfiles (.*) or backup
|
||||
files (*~ or *,) or named *.{rpmsave,rpmorig,rpmnew,swp,cfsaved} and handles
|
||||
the files named jobs.deny and jobs.allow specially.
|
||||
|
||||
In this specific example, the chronograph script is the only script or job file
|
||||
executed on a weekly basis (due to white-listing it in jobs.allow). And the
|
||||
README and chrono.dat file are ignored either as a result of being black-listed
|
||||
in jobs.deny or because they are NOT white-listed in the jobs.allow file.
|
||||
|
||||
For more details, please see ../README.cron file.
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
Time And Relative D...n In Execution (Open)Shift!
|
||||
@@ -1,3 +0,0 @@
|
||||
#!/bin/bash
|
||||
|
||||
echo "`date`: `cat $(dirname \"$0\")/chrono.dat`"
|
||||
@@ -1,12 +0,0 @@
|
||||
#
|
||||
# Script or job files listed in here (one entry per line) will be
|
||||
# executed on a weekly-basis.
|
||||
#
|
||||
# Example: The chronograph script will be executed weekly but the README
|
||||
# and chrono.dat files in this directory will be ignored.
|
||||
#
|
||||
# The README file is actually ignored due to the entry in the
|
||||
# jobs.deny which is checked before jobs.allow (this file).
|
||||
#
|
||||
chronograph
|
||||
|
||||
@@ -1,7 +0,0 @@
|
||||
#
|
||||
# Any script or job files listed in here (one entry per line) will NOT be
|
||||
# executed (read as ignored by run-parts).
|
||||
#
|
||||
|
||||
README
|
||||
|
||||
@@ -1,22 +0,0 @@
|
||||
Markers
|
||||
===========
|
||||
|
||||
Adding marker files to this directory will have the following effects:
|
||||
|
||||
enable_jpda - Will enable the JPDA socket based transport on the java virtual
|
||||
machine running the JBoss AS 7 application server. This enables
|
||||
you to remotely debug code running inside the JBoss AS 7
|
||||
application server.
|
||||
|
||||
skip_maven_build - Maven build step will be skipped
|
||||
|
||||
force_clean_build - Will start the build process by removing all non
|
||||
essential Maven dependencies. Any current dependencies specified in
|
||||
your pom.xml file will then be re-downloaded.
|
||||
|
||||
hot_deploy - Will prevent a JBoss container restart during build/deployment.
|
||||
Newly build archives will be re-deployed automatically by the
|
||||
JBoss HDScanner component.
|
||||
|
||||
java7 - Will run JBoss AS7 with Java7 if present. If no marker is present then the
|
||||
baseline Java version will be used (currently Java6)
|
||||
282
CHANGELOG.md
Normal file
@@ -0,0 +1,282 @@
|
||||
# Changelog
|
||||
|
||||
## [4.1.0]
|
||||
|
||||
- it is now possible to open the sidebar on mobile by swiping to the right (#1098)
|
||||
- swiping to mark entries as read/unread changed from swipinig right to left because swiping right now opens the sidebar
|
||||
- the full hierarchy of categories are now displayed in the category dropdown (#1045)
|
||||
- added a setting `maxEntriesAgeDays` to delete old entries based on their age during database cleanup.
|
||||
The setting is disabled by default for existing installations, except for the docker image where it is enabled and set
|
||||
to 365 days
|
||||
- if user registrations are disabled on your instance which is the default behavior, users are redirected on the login
|
||||
page instead of the welcome page when not logged in (#1185)
|
||||
- the sidebar resizer is no longer shown in the middle of the screen on mobile
|
||||
- when using the system color scheme and the system is using a dark theme, feed entries no longer flicker on load
|
||||
- the demo account (if enabled) cannot register custom javascript code anymore
|
||||
- removed the usage of `toSorted` in the client because older browsers do not support it (#1183)
|
||||
- the openapi documentation is no longer cached by the browser so you always have access to the latest version
|
||||
- added a memory management section to the readme, reading it is recommended if you are running CommaFeed on a server
|
||||
with limited memory
|
||||
- fixed an issue that caused users without an email address set to be unable to edit their profile (#1184)
|
||||
|
||||
## [4.0.0]
|
||||
|
||||
- migrated from dropwizard 2 to dropwizard 4, Java 17+ is now required
|
||||
- entries that were fetched and inserted in the database but not yet shown in the UI are no longer marked as read when
|
||||
marking all entries as read
|
||||
- your custom sidebar width is now persisted in the local storage of your browser
|
||||
- there is now a third color scheme option in addition to light and dark: system (follows the system color scheme)
|
||||
- added support for youtube playlist favicons
|
||||
- custom JS code is now executed when the app is done loading instead of when the page is loaded
|
||||
- the favicon is now correctly returned for feeds that return an invalid content type
|
||||
- the feed refresh engine now uses httpclient5 with connection pooling and no longer creates a new client for each
|
||||
request, reducing CPU usage
|
||||
- updated UI library Mantine to 7.0, improving performance
|
||||
- the h2 embedded database is now compacted on shutdown to reclaim unused space
|
||||
- the admin connector on port 8084 is now disabled in config.yml.example. Disabling it in your config.yml is
|
||||
recommended (see https://github.com/Athou/commafeed/commit/929df60f09cce56020b0962ab111cd8349b271b0)
|
||||
- migrated documentation from swagger 2 to openapi 3
|
||||
- added a GET method to the fever api to indicate that the endpoint is working correctly when accessed from a browser
|
||||
- the websocket connection can now be disabled, the websocket ping interval and the tree reload interval can now be
|
||||
configured (see config.yml.example)
|
||||
- the websocket connection now works correctly when the context root of the application is not "/"
|
||||
- unstable pubsubhubbub support was removed
|
||||
|
||||
## [3.10.1]
|
||||
|
||||
- swap next and previous buttons (#1159)
|
||||
- unread count for subscriptions will now be shortened starting at 10k instead of 1k
|
||||
- increased websocket ping interval to just under a minute to reduce data and battery usage on mobile
|
||||
- only refresh subscription tree on a timer if websocket connection is unavailable
|
||||
- the Docker image now uses less memory by returning unused memory to the OS
|
||||
- add support for Java 21
|
||||
|
||||
## [3.10.0]
|
||||
|
||||
- added a Fever-compatible API that is usable with mobile clients that support the Fever API (see instructions in
|
||||
Settings -> Profile)
|
||||
- long entry titles are no longer shortened in the detailed view
|
||||
- added the "s" keyboard shortcut to star/unstar entries
|
||||
- http sessions are now stored in the database (they were stored on disk before)
|
||||
- fixed an issue that made it impossible to override the database url in a config.yml mounted in the Docker image
|
||||
|
||||
## [3.9.0]
|
||||
|
||||
- improve performance by disabling the loader when nothing is loading (most noticeable on mobile)
|
||||
- added a setting to disable the 'mark all as read' confirmation
|
||||
- added a setting to disable the custom context menu
|
||||
- if the custom context is enabled, it can still be disabled by pressing the shift key
|
||||
- the announcement feature is now working again and supports html ('announcement' configuration element in config.yml)
|
||||
- add support for MariaDB 11+
|
||||
- fix entry header shortly rendered as mobile on desktop, causing a small visual glitch
|
||||
- fix an issue that could cause a feed to not refresh correctly if the url was very long
|
||||
- database cleanup batch size is now configurable
|
||||
- css parsing errors are no longer logged to the standard output
|
||||
- fix small errors in the api documentation
|
||||
|
||||
## [3.8.1]
|
||||
|
||||
- in expanded mode, don't scroll when clicking on the body of the current entry
|
||||
- improve content cleanup task performance for instances with a very large number of feeds
|
||||
|
||||
## [3.8.0]
|
||||
|
||||
- add previous and next buttons in the toolbar
|
||||
- add a setting to always scroll selected entry to the top of the page, even if it fits entirely on screen
|
||||
- clicking on the body of an entry in expanded mode selects it and marks it as read
|
||||
- add rich text editor with autocomplete for custom css and js code in settings (desktop only)
|
||||
- dramatically improve performance while scrolling
|
||||
- fix broken welcome page mobile layout
|
||||
- format dates in user locale instead of GMT in relative date popups
|
||||
|
||||
## [3.7.0]
|
||||
|
||||
- the sidebar is now resizable
|
||||
- added the "f" keyboard shortcut to hide the sidebar
|
||||
- added tooltips to relative dates with the exact date
|
||||
- add a setting to hide commafeed from search engines (exposes a robots.txt file, enabled by default)
|
||||
- the browser extension unread count now updates when articles are marked as read/unread in the app
|
||||
- The "b" keyboard shortcut now works as expected on Chrome but requires the browser extension to be installed
|
||||
- dark mode has been disabled on the api documentation page as it was unreadable
|
||||
- improvement to the feed refresh queuing logic when "heavy load" mode is enabled
|
||||
- fix a bug that could prevent feeds and categories from being edited
|
||||
|
||||
## [3.6.0]
|
||||
|
||||
- add a button to open CommaFeed in a new tab and a button to open options when using the browser extension
|
||||
- clicking on the entry title in expanded mode now opens the link instead of doing nothing
|
||||
- add tooltips to buttons when the mobile layout is used on desktop
|
||||
- redirect the user to the welcome page if the user was deleted from the database
|
||||
- add link to api documentation on welcome page
|
||||
- the unread count is now correctly updated when using the "/next" bookmarklet while redis cache is enabled
|
||||
|
||||
## [3.5.0]
|
||||
|
||||
- add compatibility with the new version of the CommaFeed browser extension
|
||||
- disable pull-to-refresh on mobile as it messes with vertical scrolling
|
||||
- add css classes to feed entries to help with custom css rules
|
||||
- api documentation page no longer requires users to be authenticated
|
||||
- add a setting to limit the number of feeds a user can subscribe to
|
||||
- add a setting to disable strict password policy
|
||||
- add feed refresh engine metrics
|
||||
- fix redis timeouts
|
||||
|
||||
## [3.4.0]
|
||||
|
||||
- add support for arm64 docker images
|
||||
- add divider to visually separate read-only information from form on the profile settings page
|
||||
- reduce javascript bundle size by 30% by loading only the necessary translations
|
||||
- add a standalone donate page with all ways to support CommaFeed
|
||||
- fix an issue introduced in 3.1.0 that could make CommaFeed not refresh feeds as fast as before on instances with lots
|
||||
of feeds
|
||||
- fix alignment of icon with text for category tree nodes
|
||||
- fix alignment of burger button with the rest of the header on mobile
|
||||
|
||||
## [3.3.2]
|
||||
|
||||
- restore entry selection indicator (left orange border) that was lost with the mantine 6.x upgrade (3.3.0)
|
||||
- add dividers to visually separate read-only information from forms on feed and category details pages
|
||||
- reduced javascript bundle size by 10%
|
||||
|
||||
## [3.3.1]
|
||||
|
||||
- fix long feed names not being shortened to respect tree max width
|
||||
|
||||
## [3.3.0]
|
||||
|
||||
- there are now database changes, rolling back to 2.x will no longer be possible
|
||||
- restore support for user custom CSS rules
|
||||
- add support for user custom JS code that will be executed on page load
|
||||
|
||||
## [3.2.0]
|
||||
|
||||
- restore the welcome page
|
||||
- only apply hover effect for unread entries (same as commafeed v2)
|
||||
- move notifications at the bottom of the screen
|
||||
- always use https for sharing urls
|
||||
- add support for redis ACLs
|
||||
- transition to google analytics v4
|
||||
|
||||
## [3.1.0]
|
||||
|
||||
- add an even more compact layout
|
||||
- restore hover effect from commafeed 2.x
|
||||
- view mode (compact, expanded, ...) is now stored on the device so you can have a different view mode on desktop and
|
||||
mobile
|
||||
- fix for the "Illegal attempt to associate a collection with two open sessions." error
|
||||
- feed fetching workflow is now orchestrated with rxjava, removing a lot of code
|
||||
|
||||
## [3.0.1]
|
||||
|
||||
- allow env variable substitution in config.yml
|
||||
- e.g. having a custom config.yml file with `app.session.path=${SOME_ENV_VAR}` will substitute `SOME_ENV_VAR` with its
|
||||
value
|
||||
- allow env variable prefixed with `CF_` to override config.yml properties
|
||||
- e.g. setting `CF_APP_ALLOWREGISTRATIONS=true` will set `app.allowRegistrations` to `true`
|
||||
|
||||
## [3.0.0]
|
||||
|
||||
- complete overhaul of the UI
|
||||
- backend and frontend are now in separate maven modules
|
||||
- no changes to the api or the database
|
||||
- Docker images are now automatically built and available at https://hub.docker.com/r/athou/commafeed
|
||||
|
||||
## [2.6.0]
|
||||
|
||||
- add support for media content as a backup for missing content (useful for youtube feeds)
|
||||
- correctly follow http error code 308 redirects
|
||||
- fixed a bug that prevented users from deleting their account
|
||||
- fixed a bug that made commafeed store entry contents multiple times
|
||||
- fixed a bug that prevented the app to be used as an installed app on mobile devices if the context path of commafeed
|
||||
was not "/"
|
||||
- fixed a bug that prevented entries from being "marked as read older than xxx" for a feed that was just added
|
||||
- removed support for google+ and readability as those services no longer exist
|
||||
- removed support for deploying on openshift
|
||||
- removed alphabetical sorting of entries because of really poor performance (title cannot be indexed)
|
||||
- improve performance for instances with the heavy load setting enabled by preventing CommaFeed from fetching feeds from
|
||||
users that did not log in for a long time
|
||||
- various dependencies upgrades (notably dropwizard from 1.3 to 2.1)
|
||||
- add support for mariadb
|
||||
- add support for java17+ runtime
|
||||
- various security improvements
|
||||
|
||||
## [2.5.0]
|
||||
|
||||
- unread count is now displayed in a favicon badge when supported
|
||||
- the user agent string for the bot fetching feeds is now configurable
|
||||
- feed parsing performance improvements
|
||||
- support for java9+ runtime
|
||||
- can now properly start from an empty postgresql database
|
||||
|
||||
## [2.4.0]
|
||||
|
||||
- users were not able to change password or delete account
|
||||
- fix api key generation
|
||||
- feed entries can now be sorted alphabetically
|
||||
- fix facebook sharing
|
||||
- fix layout on iOS
|
||||
- postgresql driver update (fix for postgres 9.6)
|
||||
- various internationalization fixes
|
||||
- security fixes
|
||||
|
||||
## [2.3.0]
|
||||
|
||||
- dropwizard upgrade 0.9.1
|
||||
- feed enclosures are hidden if they already displayed in the content
|
||||
- fix youtube favicons
|
||||
- various internationalization fixes
|
||||
|
||||
## [2.2.0]
|
||||
|
||||
- fix youtube and instagram favicon fetching
|
||||
- mark as read filter was lost when a feed was rearranged with drag&drop
|
||||
- feed entry categories are now displayed if available
|
||||
- various performance and dependencies upgrades
|
||||
- java8 is now required
|
||||
|
||||
## [2.1.0]
|
||||
|
||||
- dropwizard upgrade to 0.8.0
|
||||
- you have to remove the "app.contextPath" setting from your yml file, you can optionally use
|
||||
server.applicationContextPath instead
|
||||
- new setting app.maxFeedCapacity for deleting old entries
|
||||
- ability to set filtering expressions for subscriptions to automatically mark new entries as read based on title,
|
||||
content, author or url.
|
||||
- ability to use !keyword or -keyword to exclude a keyword from a search query
|
||||
- facebook feeds now show user favicon instead of facebook favicon
|
||||
- new dark theme 'nightsky'
|
||||
|
||||
## [2.0.3]
|
||||
|
||||
- internet explorer ajax cache workaround
|
||||
- categories are now deletable again
|
||||
- openshift support is back
|
||||
- youtube feeds now show user favicon instead of youtube favicon
|
||||
|
||||
## [2.0.2]
|
||||
|
||||
- api using the api key is now working again
|
||||
- context path is now configurable in config.yml (see app.contextPath in config.yml.example)
|
||||
- fix login on firefox when fields are autofilled by the browser
|
||||
- fix scrolling of subscriptions list on mobile
|
||||
- user is now logged in after registration
|
||||
- fix link to documentation on home page and about page
|
||||
- fields autocomplete is disabled on the profile page
|
||||
- users are able to delete their account again
|
||||
- chinese and malaysian translation files are now correctly loaded
|
||||
- software version in user-agent when fetching feeds is no longer hardcoded
|
||||
- admin settings page is now read only, settings are configured in config.yml
|
||||
- added link to metrics on the admin settings page
|
||||
- Rome (rss library) upgrade to 1.5.0
|
||||
|
||||
## [2.0.1]
|
||||
|
||||
- the redis pool no longer throws an exception when it is unable to aquire a new connection
|
||||
|
||||
## [2.0.0]
|
||||
|
||||
- The backend has been completely rewritten using Dropwizard instead of TomEE, resulting in a lot less memory
|
||||
consumption and better overall performances.
|
||||
See the README on how to build CommaFeed from now on.
|
||||
- CommaFeed should no longer fetch the same feed multiple times in a row
|
||||
- Users can use their username or email to log in
|
||||
12
Dockerfile
Normal file
@@ -0,0 +1,12 @@
|
||||
FROM eclipse-temurin:17-jre
|
||||
|
||||
EXPOSE 8082
|
||||
|
||||
RUN mkdir -p /commafeed/data
|
||||
VOLUME /commafeed/data
|
||||
|
||||
COPY commafeed-server/config.yml.example config.yml
|
||||
COPY commafeed-server/target/commafeed.jar .
|
||||
|
||||
ENV JAVA_TOOL_OPTIONS -Djava.net.preferIPv4Stack=true -Xms20m -XX:+UseG1GC -XX:-ShrinkHeapInSteps -XX:G1PeriodicGCInterval=10000 -XX:-G1PeriodicGCInvokesConcurrent -XX:MinHeapFreeRatio=5 -XX:MaxHeapFreeRatio=10
|
||||
CMD ["java", "-jar", "commafeed.jar", "server", "config.yml"]
|
||||
257
README.md
@@ -1,132 +1,127 @@
|
||||
CommaFeed [](https://buildhive.cloudbees.com/job/Athou/job/commafeed/)
|
||||
=========
|
||||
Sources for [CommaFeed.com](http://www.commafeed.com/).
|
||||
|
||||
Google Reader inspired self-hosted RSS reader, based on JAX-RS, Wicket and AngularJS.
|
||||
|
||||
Deploy on your own server (using TomEE, a lightweight JavaEE6 container based on Tomcat) or even in the cloud for free on OpenShift.
|
||||
|
||||
[Android app](https://github.com/doomrobo/CommaFeed-Android-Reader)
|
||||
|
||||
[Chrome extension](https://github.com/Athou/commafeed-chrome)
|
||||
|
||||
[Firefox extension](https://github.com/Athou/commafeed-firefox)
|
||||
|
||||
[Opera extension](https://github.com/Athou/commafeed-opera)
|
||||
|
||||
[Safari extension](https://github.com/Athou/commafeed-safari)
|
||||
|
||||
Deployment on OpenShift
|
||||
-----------------------
|
||||
|
||||
Hosting an application on OpenShift is free.
|
||||
At the moment those instructions are not working because the application takes too long to build on OpenShift and causes a timeout.
|
||||
See [here](http://jasonwryan.com/blog/2013/05/25/greader/) for an alternative method.
|
||||
|
||||
* Create an account on [OpenShift](http://www.openshift.com/).
|
||||
* Add an application, select `JBoss Enterprise Application Platform 6.0`.
|
||||
* For the `Public URL` set the name you want (e.g. `commafeed`).
|
||||
* For the `Source Code` option, click `Change` and set this repository (`https://github.com/Athou/commafeed.git`).
|
||||
* Click `Create Application`.
|
||||
* Click `Add cartridge` and select `MySQL`.
|
||||
* Wait a couple of minutes and access your application.
|
||||
* The default user is `admin` and the password is `admin`.
|
||||
|
||||
Deployment on your own server
|
||||
-----------------------------
|
||||
|
||||
For storage, you can either use an embedded HSQLDB database or an external MySQL, PostgreSQL or SQLServer database.
|
||||
You also need Maven 3.x (and a Java 1.7+ JDK) installed in order to build the application.
|
||||
|
||||
To install maven and openjdk on Ubuntu, issue the following commands
|
||||
|
||||
sudo add-apt-repository ppa:natecarlson/maven3
|
||||
sudo apt-get update
|
||||
sudo apt-get install openjdk-7-jdk maven3
|
||||
|
||||
Not required but if you don't, use 'mvn3' instead of 'mvn' for the rest of the instructions.
|
||||
sudo ln -s /usr/bin/mvn3 /usr/bin/mvn
|
||||
|
||||
On Windows and other operating systems, just download maven 3.x from the [official site](http://maven.apache.org/), extract it somewhere and add the `bin` directory to your `PATH` environment variable.
|
||||
|
||||
Download the sources (it doesn't matter where, you can delete the directory when you're done).
|
||||
If you don't have git you can download the sources as a zip file from [here](https://github.com/Athou/commafeed/archive/master.zip)
|
||||
|
||||
git clone https://github.com/Athou/commafeed.git
|
||||
cd commafeed
|
||||
|
||||
Now build the application
|
||||
|
||||
Embedded HSQL database:
|
||||
mvn clean package tomee:build -Pprod
|
||||
|
||||
External MySQL database:
|
||||
mvn clean package tomee:build -Pprod -Pmysql
|
||||
|
||||
External PostgreSQL database:
|
||||
mvn clean package tomee:build -Pprod -Ppgsql
|
||||
|
||||
External Microsoft SQL Server database:
|
||||
mvn clean package tomee:build -Pprod -Pmssql
|
||||
|
||||
It will generate a zip file at `target/commafeed.zip` with everything you need to run the application.
|
||||
|
||||
* Create a directory somewhere (e.g. `/opt/commafeed/`) and extract the generated zip inside this directory.
|
||||
* Create a directory called `logs` (e.g. `/opt/commafeed/logs`)
|
||||
* Copy the file `conf/setenv.sh` (Linux) or `conf/setenv.bat` (Windows) to `bin/`
|
||||
* If you don't use the embedded database, create a database in your external database instance, then uncomment the `Resource` element corresponding to the database engine you use from `conf/tomee.xml` and edit the default credentials.
|
||||
* If you'd like to change the default port (8082), edit `conf/server.xml` and look for `<Connector port="8082" protocol="HTTP/1.1"`. Change the port to the value you'd like to use.
|
||||
* CommaFeed will run on the `/commafeed` context. If you'd like to change the context, go to `webapps` and rename `commafeed.war`. Use the special name `ROOT.war` to deploy to the root context.
|
||||
* To start and stop the application, use `bin/startup.sh` and `bin/shutdown.sh` on Linux (you need to `chmod +x bin/*.sh`) or `bin\startup.bat` and `bin\shutdown.bat` on Windows.
|
||||
* To update the application with a newer version, pull the latest changes and use the same command you used to build the complete TomEE package, but without the `tomee:build` part (keep `-Pprod -P<database>`).
|
||||
This will generate the file `target/commafeed.war`. Copy this file to your tomee `webapps/` directory.
|
||||
* The application is online at [http://localhost:8082/commafeed](http://localhost:8082/commafeed). Don't forget to set the public URL in the admin settings.
|
||||
* The default user is `admin` and the password is `admin`.
|
||||
|
||||
Local development
|
||||
-----------------
|
||||
|
||||
Checkout the code and use maven to build and start a local TomEE instance.
|
||||
|
||||
`mvn clean package tomee:run`
|
||||
|
||||
The application is online at [http://localhost:8082/commafeed](http://localhost:8082/commafeed). Any change to the source code will be applied immediatly.
|
||||
The default user is `admin` and the password is `admin`.
|
||||
|
||||
Translate CommaFeed into your language
|
||||
--------------------------------------
|
||||
|
||||
Files for internationalization are located [here](https://github.com/Athou/commafeed/tree/master/src/main/resources/i18n).
|
||||
|
||||
To add a new language, create a new file in that directory.
|
||||
The name of the file should be the two-letters [ISO-639-1 language code](http://en.wikipedia.org/wiki/List_of_ISO_639-1_codes).
|
||||
The language has to be referenced in the `languages.properties` file to be picked up.
|
||||
|
||||
When adding new translations, add them in en.properties then run `mvn -e groovy:execute -Pi18n`. It will parse the english file and add placeholders in the other translation files.
|
||||
|
||||
Themes
|
||||
---------------------
|
||||
|
||||
To create a theme, create a new file `src/main/webapp/sass/themes/_<theme>.scss`. Your styles should be wrapped in a `#theme-<theme>` element and use the [SCSS format](http://sass-lang.com/) which is a superset of CSS.
|
||||
|
||||
Don't forget to reference your theme in `src/main/webapp/sass/app.scss` and in `src/main/webapp/js/controllers.js` (look for `$scope.themes`).
|
||||
|
||||
See [_test.scss](https://github.com/Athou/commafeed/blob/master/src/main/webapp/sass/themes/_test.scss) for an example.
|
||||
|
||||
|
||||
Copyright and license
|
||||
---------------------
|
||||
|
||||
Copyright 2013 CommaFeed.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this work except in compliance with the License.
|
||||
You may obtain a copy of the License in the LICENSE file, or 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
|
||||
# CommaFeed
|
||||
|
||||
Google Reader inspired self-hosted RSS reader, based on Dropwizard and React/TypeScript.
|
||||
|
||||

|
||||
|
||||
## Features
|
||||
|
||||
- 4 different layouts
|
||||
- Light/Dark theme
|
||||
- Fully responsive
|
||||
- Keyboard shortcuts for almost everything
|
||||
- Support for right-to-left feeds
|
||||
- Translated in 25+ languages
|
||||
- Supports thousands of users and millions of feeds
|
||||
- OPML import/export
|
||||
- REST API and a Fever-compatible API for native mobile apps
|
||||
- [Browser extension](https://github.com/Athou/commafeed-browser-extension)
|
||||
|
||||
## Deployment
|
||||
|
||||
### Docker
|
||||
|
||||
Docker is the easiest way to get started with CommaFeed.
|
||||
|
||||
Docker images are built automatically and are available at https://hub.docker.com/r/athou/commafeed
|
||||
|
||||
### Cloud hosting
|
||||
|
||||
[PikaPods](https://www.pikapods.com) offers 1-click cloud hosting solutions starting at $1/month with a free $5
|
||||
welcome credit and officially supports CommaFeed.
|
||||
PikaPods shares 20% of the revenue back to CommaFeed.
|
||||
|
||||
[](https://www.pikapods.com/pods?run=commafeed)
|
||||
|
||||
### Download precompiled package
|
||||
|
||||
mkdir commafeed && cd commafeed
|
||||
wget https://github.com/Athou/commafeed/releases/latest/download/commafeed.jar
|
||||
wget https://github.com/Athou/commafeed/releases/latest/download/config.yml.example -O config.yml
|
||||
java -Djava.net.preferIPv4Stack=true -jar commafeed.jar server config.yml
|
||||
|
||||
The server will listen on http://localhost:8082. The default
|
||||
user is `admin` and the default password is `admin`.
|
||||
|
||||
### Build from sources
|
||||
|
||||
git clone https://github.com/Athou/commafeed.git
|
||||
cd commafeed
|
||||
./mvnw clean package
|
||||
cp commafeed-server/config.yml.example config.yml
|
||||
java -Djava.net.preferIPv4Stack=true -jar commafeed-server/target/commafeed.jar server config.yml
|
||||
|
||||
The server will listen on http://localhost:8082. The default
|
||||
user is `admin` and the default password is `admin`.
|
||||
|
||||
### Memory management
|
||||
|
||||
The Java Virtual Machine (JVM) is rather greedy by default and will not release unused memory to the
|
||||
operating system. This is because acquiring memory from the operating system is a relatively expensive operation.
|
||||
However, this can be problematic on systems with limited memory.
|
||||
|
||||
#### Hard limit
|
||||
|
||||
The JVM can be configured to use a maximum amount of memory with the `-Xmx` parameter.
|
||||
For example, to limit the JVM to 256MB of memory, use `-Xmx256m`.
|
||||
|
||||
#### Dynamic sizing
|
||||
|
||||
The JVM can be configured to release unused memory to the operating system with the following parameters:
|
||||
|
||||
-Xms20m -XX:+UseG1GC -XX:-ShrinkHeapInSteps -XX:G1PeriodicGCInterval=10000 -XX:-G1PeriodicGCInvokesConcurrent -XX:MinHeapFreeRatio=5 -XX:MaxHeapFreeRatio=10
|
||||
|
||||
This is how the Docker image is configured.
|
||||
See [here](https://docs.oracle.com/en/java/javase/17/gctuning/garbage-first-g1-garbage-collector1.html)
|
||||
and [here](https://docs.oracle.com/en/java/javase/17/gctuning/factors-affecting-garbage-collection-performance.html) for
|
||||
more
|
||||
information.
|
||||
|
||||
## Translation
|
||||
|
||||
Files for internationalization are
|
||||
located [here](https://github.com/Athou/commafeed/tree/master/commafeed-client/src/locales).
|
||||
|
||||
To add a new language:
|
||||
|
||||
- add the new locale to the `locales` array in:
|
||||
- `commafeed-client/.linguirc`
|
||||
- `commafeed-client/src/i18n.ts`
|
||||
- run `npm run i18n:extract`
|
||||
- add translations to the newly created `commafeed-client/src/locales/[locale]/messages.po` file
|
||||
|
||||
The name of the locale should be the
|
||||
two-letters [ISO-639-1 language code](http://en.wikipedia.org/wiki/List_of_ISO_639-1_codes).
|
||||
|
||||
## Local development
|
||||
|
||||
### Backend
|
||||
|
||||
- Open `commafeed-server` in your preferred Java IDE.
|
||||
- CommaFeed uses Lombok, you need the Lombok plugin for your IDE.
|
||||
- Start `CommaFeedApplication.java` in debug mode with `server config.dev.yml` as arguments
|
||||
|
||||
### Frontend
|
||||
|
||||
- Open `commafeed-client` in your preferred JavaScript IDE.
|
||||
- run `npm install`
|
||||
- run `npm run dev`
|
||||
|
||||
The frontend server is now running at http://localhost:8082 and is proxying REST requests to the backend running on
|
||||
port 8083
|
||||
|
||||
## Copyright and license
|
||||
|
||||
Copyright 2013-2023 CommaFeed.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this work except in compliance with the License.
|
||||
You may obtain a copy of the License in the LICENSE file, or 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.
|
||||
|
||||
6
SECURITY.md
Normal file
@@ -0,0 +1,6 @@
|
||||
# Security Policy
|
||||
|
||||
## 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 !
|
||||
8
commafeed-client/.eslintignore
Normal file
@@ -0,0 +1,8 @@
|
||||
dist
|
||||
node_modules
|
||||
|
||||
vite.config.ts
|
||||
|
||||
# compiled linguijs locales
|
||||
# they no longer exist but we keep this to avoid issues with people still having those files on disk
|
||||
src/locales/**/*.ts
|
||||
41
commafeed-client/.eslintrc.cjs
Normal file
@@ -0,0 +1,41 @@
|
||||
module.exports = {
|
||||
env: {
|
||||
browser: true,
|
||||
es2021: true
|
||||
},
|
||||
extends: ["standard-with-typescript", "plugin:react/recommended", "plugin:react-hooks/recommended", "plugin:prettier/recommended"],
|
||||
settings: {
|
||||
react: {
|
||||
version: "detect"
|
||||
}
|
||||
},
|
||||
overrides: [
|
||||
{
|
||||
env: {
|
||||
node: true
|
||||
},
|
||||
files: [".eslintrc.{js,cjs}"],
|
||||
parserOptions: {
|
||||
sourceType: "script"
|
||||
}
|
||||
}
|
||||
],
|
||||
parserOptions: {
|
||||
ecmaVersion: "latest",
|
||||
sourceType: "module"
|
||||
},
|
||||
plugins: ["react"],
|
||||
rules: {
|
||||
"@typescript-eslint/consistent-type-assertions": ["error", { assertionStyle: "as" }],
|
||||
"@typescript-eslint/explicit-function-return-type": "off",
|
||||
"@typescript-eslint/no-confusing-void-expression": ["error", { ignoreArrowShorthand: true }],
|
||||
"@typescript-eslint/no-floating-promises": "off",
|
||||
"@typescript-eslint/no-misused-promises": "off",
|
||||
"@typescript-eslint/prefer-nullish-coalescing": ["error", { ignoreConditionalTests: true }],
|
||||
"@typescript-eslint/strict-boolean-expressions": "off",
|
||||
"@typescript-eslint/unbound-method": "off",
|
||||
"react/no-unescaped-entities": "off",
|
||||
"react/react-in-jsx-scope": "off",
|
||||
"react-hooks/exhaustive-deps": "error"
|
||||
}
|
||||
}
|
||||
34
commafeed-client/.gitignore
vendored
Normal file
@@ -0,0 +1,34 @@
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
.DS_Store
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
|
||||
# rollup-plugin-visualizer
|
||||
/stats.html
|
||||
|
||||
# vite
|
||||
vite.config.ts.timestamp-*.mjs
|
||||
|
||||
# compiled linguijs locales
|
||||
# they no longer exist but we keep this to avoid issues with people still having those files on disk
|
||||
src/locales/**/*.ts
|
||||
52
commafeed-client/.linguirc
Normal file
@@ -0,0 +1,52 @@
|
||||
{
|
||||
"locales": [
|
||||
"ar",
|
||||
"ca",
|
||||
"cs",
|
||||
"cy",
|
||||
"da",
|
||||
"de",
|
||||
"en",
|
||||
"es",
|
||||
"fa",
|
||||
"fi",
|
||||
"fr",
|
||||
"gl",
|
||||
"hu",
|
||||
"id",
|
||||
"it",
|
||||
"ja",
|
||||
"ko",
|
||||
"ms",
|
||||
"nb",
|
||||
"nl",
|
||||
"nn",
|
||||
"pl",
|
||||
"pt",
|
||||
"ru",
|
||||
"sk",
|
||||
"sv",
|
||||
"tr",
|
||||
"zh"
|
||||
],
|
||||
"catalogs": [
|
||||
{
|
||||
"path": "src/locales/{locale}/messages",
|
||||
"include": [
|
||||
"src"
|
||||
],
|
||||
"exclude": [
|
||||
"src/locales/**"
|
||||
]
|
||||
}
|
||||
],
|
||||
"format": "po",
|
||||
"formatOptions": {
|
||||
"origins": true,
|
||||
"lineNumbers": false
|
||||
},
|
||||
"sourceLocale": "en",
|
||||
"fallbackLocales": {
|
||||
"default": "en"
|
||||
}
|
||||
}
|
||||
8
commafeed-client/.prettierrc
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"printWidth": 140,
|
||||
"semi": false,
|
||||
"tabWidth": 4,
|
||||
"arrowParens": "avoid",
|
||||
"endOfLine": "auto",
|
||||
"trailingComma": "es5"
|
||||
}
|
||||
14
commafeed-client/index.html
Normal file
@@ -0,0 +1,14 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<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" />
|
||||
<title>CommaFeed</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
9389
commafeed-client/package-lock.json
generated
Normal file
85
commafeed-client/package.json
Normal file
@@ -0,0 +1,85 @@
|
||||
{
|
||||
"name": "commafeed-client",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite --host",
|
||||
"dev:typescript": "tsc --watch",
|
||||
"build": "tsc && vite build",
|
||||
"preview": "vite preview",
|
||||
"test": "vitest",
|
||||
"test:ci": "vitest run",
|
||||
"eslint": "eslint --ext=.js,.jsx,.ts,.tsx src",
|
||||
"i18n:extract": "lingui extract --clean"
|
||||
},
|
||||
"dependencies": {
|
||||
"@emotion/react": "^11.11.3",
|
||||
"@fontsource/open-sans": "^5.0.20",
|
||||
"@lingui/core": "^4.7.0",
|
||||
"@lingui/macro": "^4.7.0",
|
||||
"@lingui/react": "^4.7.0",
|
||||
"@mantine/core": "^7.3.2",
|
||||
"@mantine/form": "^7.3.2",
|
||||
"@mantine/hooks": "^7.3.2",
|
||||
"@mantine/modals": "^7.3.2",
|
||||
"@mantine/notifications": "^7.3.2",
|
||||
"@mantine/spotlight": "^7.3.2",
|
||||
"@monaco-editor/react": "^4.6.0",
|
||||
"@reduxjs/toolkit": "^2.0.1",
|
||||
"axios": "^1.6.3",
|
||||
"dayjs": "^1.11.10",
|
||||
"escape-string-regexp": "^5.0.0",
|
||||
"interweave": "^13.1.0",
|
||||
"monaco-editor": "^0.45.0",
|
||||
"mousetrap": "^1.6.5",
|
||||
"react": "^18.2.0",
|
||||
"react-async-hook": "^4.0.0",
|
||||
"react-contexify": "^6.0.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-draggable": "^4.4.6",
|
||||
"react-ga4": "^2.1.0",
|
||||
"react-icons": "^4.12.0",
|
||||
"react-infinite-scroller": "^1.2.6",
|
||||
"react-redux": "^9.0.4",
|
||||
"react-router-dom": "^6.21.1",
|
||||
"react-swipeable": "^7.0.1",
|
||||
"redoc": "^2.1.3",
|
||||
"throttle-debounce": "^5.0.0",
|
||||
"tinycon": "^0.6.8",
|
||||
"tss-react": "^4.9.3",
|
||||
"use-local-storage": "^3.0.0",
|
||||
"websocket-heartbeat-js": "^1.1.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@lingui/cli": "^4.7.0",
|
||||
"@lingui/vite-plugin": "^4.7.0",
|
||||
"@types/mousetrap": "^1.6.15",
|
||||
"@types/react": "^18.2.46",
|
||||
"@types/react-dom": "^18.2.18",
|
||||
"@types/react-infinite-scroller": "^1.2.5",
|
||||
"@types/swagger-ui-react": "^4.18.3",
|
||||
"@types/throttle-debounce": "^5.0.2",
|
||||
"@types/tinycon": "^0.6.5",
|
||||
"@typescript-eslint/eslint-plugin": "^6.16.0",
|
||||
"@vitejs/plugin-react": "^4.2.1",
|
||||
"babel-plugin-macros": "^3.1.0",
|
||||
"eslint": "^8.56.0",
|
||||
"eslint-config-prettier": "^9.1.0",
|
||||
"eslint-config-standard-with-typescript": "^43.0.0",
|
||||
"eslint-plugin-import": "^2.29.1",
|
||||
"eslint-plugin-n": "^16.5.0",
|
||||
"eslint-plugin-prettier": "^5.1.2",
|
||||
"eslint-plugin-promise": "^6.1.1",
|
||||
"eslint-plugin-react": "^7.33.2",
|
||||
"eslint-plugin-react-hooks": "^4.6.0",
|
||||
"prettier": "^3.1.1",
|
||||
"rollup-plugin-visualizer": "^5.12.0",
|
||||
"typescript": "^5.3.3",
|
||||
"vite": "^5.0.11",
|
||||
"vite-plugin-eslint": "^1.8.1",
|
||||
"vite-tsconfig-paths": "^4.2.3",
|
||||
"vitest": "^1.1.3",
|
||||
"vitest-mock-extended": "^1.3.1"
|
||||
}
|
||||
}
|
||||
88
commafeed-client/pom.xml
Normal file
@@ -0,0 +1,88 @@
|
||||
<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>4.1.0</version>
|
||||
</parent>
|
||||
<artifactId>commafeed-client</artifactId>
|
||||
<name>CommaFeed Client</name>
|
||||
|
||||
<build>
|
||||
<plugins>
|
||||
<plugin>
|
||||
<groupId>com.github.eirslett</groupId>
|
||||
<artifactId>frontend-maven-plugin</artifactId>
|
||||
<version>1.15.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>v20.10.0</nodeVersion>
|
||||
<npmVersion>10.2.5</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/assets</outputDirectory>
|
||||
<resources>
|
||||
<resource>
|
||||
<directory>dist</directory>
|
||||
<filtering>false</filtering>
|
||||
</resource>
|
||||
</resources>
|
||||
</configuration>
|
||||
</execution>
|
||||
</executions>
|
||||
</plugin>
|
||||
</plugins>
|
||||
</build>
|
||||
</project>
|
||||
BIN
commafeed-client/public/app-icon-114.png
Normal file
|
After Width: | Height: | Size: 5.4 KiB |
BIN
commafeed-client/public/app-icon-144.png
Normal file
|
After Width: | Height: | Size: 6.3 KiB |
BIN
commafeed-client/public/app-icon-192.png
Normal file
|
After Width: | Height: | Size: 3.9 KiB |
|
Before Width: | Height: | Size: 4.2 KiB After Width: | Height: | Size: 4.3 KiB |
|
Before Width: | Height: | Size: 1.1 KiB After Width: | Height: | Size: 1.1 KiB |
34
commafeed-client/public/manifest.json
Normal file
@@ -0,0 +1,34 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/web-manifest-combined.json",
|
||||
"name": "CommaFeed",
|
||||
"scope": ".",
|
||||
"start_url": "./",
|
||||
"display": "standalone",
|
||||
"theme_color": "#f88a14",
|
||||
"icons": [
|
||||
{
|
||||
"src": "app-icon-72.png",
|
||||
"sizes": "72x72",
|
||||
"type": "image/png",
|
||||
"density": "1.5"
|
||||
},
|
||||
{
|
||||
"src": "app-icon-114.png",
|
||||
"sizes": "96x96",
|
||||
"type": "image/png",
|
||||
"density": "2.0"
|
||||
},
|
||||
{
|
||||
"src": "app-icon-144.png",
|
||||
"sizes": "144x144",
|
||||
"type": "image/png",
|
||||
"density": "3.0"
|
||||
},
|
||||
{
|
||||
"src": "app-icon-192.png",
|
||||
"sizes": "192x192",
|
||||
"type": "image/png",
|
||||
"density": "4.0"
|
||||
}
|
||||
]
|
||||
}
|
||||
226
commafeed-client/src/App.tsx
Normal file
@@ -0,0 +1,226 @@
|
||||
import { i18n } from "@lingui/core"
|
||||
import { I18nProvider } from "@lingui/react"
|
||||
import { MantineProvider } from "@mantine/core"
|
||||
import { useDidUpdate } from "@mantine/hooks"
|
||||
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 { ErrorBoundary } from "components/ErrorBoundary"
|
||||
import { Header } from "components/header/Header"
|
||||
import { Tree } from "components/sidebar/Tree"
|
||||
import { useBrowserExtension } from "hooks/useBrowserExtension"
|
||||
import { useI18n } from "i18n"
|
||||
import { AdminUsersPage } from "pages/admin/AdminUsersPage"
|
||||
import { MetricsPage } from "pages/admin/MetricsPage"
|
||||
import { AboutPage } from "pages/app/AboutPage"
|
||||
import { AddPage } from "pages/app/AddPage"
|
||||
import { CategoryDetailsPage } from "pages/app/CategoryDetailsPage"
|
||||
import { DonatePage } from "pages/app/DonatePage"
|
||||
import { FeedDetailsPage } from "pages/app/FeedDetailsPage"
|
||||
import { FeedEntriesPage } from "pages/app/FeedEntriesPage"
|
||||
import Layout from "pages/app/Layout"
|
||||
import { SettingsPage } from "pages/app/SettingsPage"
|
||||
import { TagDetailsPage } from "pages/app/TagDetailsPage"
|
||||
import { LoginPage } from "pages/auth/LoginPage"
|
||||
import { PasswordRecoveryPage } from "pages/auth/PasswordRecoveryPage"
|
||||
import { RegistrationPage } from "pages/auth/RegistrationPage"
|
||||
import { WelcomePage } from "pages/WelcomePage"
|
||||
import React, { useEffect, useRef } from "react"
|
||||
import ReactGA from "react-ga4"
|
||||
import { HashRouter, Navigate, Route, Routes, useLocation, useNavigate } from "react-router-dom"
|
||||
import Tinycon from "tinycon"
|
||||
|
||||
function Providers(props: { children: React.ReactNode }) {
|
||||
return (
|
||||
<I18nProvider i18n={i18n}>
|
||||
<MantineProvider
|
||||
defaultColorScheme="auto"
|
||||
theme={{
|
||||
primaryColor: "orange",
|
||||
fontFamily: "Open Sans",
|
||||
colors: {
|
||||
// keep using dark colors from mantine v6
|
||||
// https://v6.mantine.dev/theming/colors/#default-colors
|
||||
dark: [
|
||||
"#C1C2C5",
|
||||
"#A6A7AB",
|
||||
"#909296",
|
||||
"#5c5f66",
|
||||
"#373A40",
|
||||
"#2C2E33",
|
||||
"#25262b",
|
||||
"#1A1B1E",
|
||||
"#141517",
|
||||
"#101113",
|
||||
],
|
||||
},
|
||||
}}
|
||||
>
|
||||
<ModalsProvider>
|
||||
<Notifications position="bottom-right" zIndex={9999} />
|
||||
<ErrorBoundary>{props.children}</ErrorBoundary>
|
||||
</ModalsProvider>
|
||||
</MantineProvider>
|
||||
</I18nProvider>
|
||||
)
|
||||
}
|
||||
|
||||
// swagger-ui 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)
|
||||
|
||||
return (
|
||||
<Routes>
|
||||
<Route path="/" element={<Navigate to={`/app/category/${Constants.categories.all.id}`} replace />} />
|
||||
<Route path="welcome" element={<WelcomePage />} />
|
||||
<Route path="login" element={<LoginPage />} />
|
||||
<Route path="register" element={<RegistrationPage />} />
|
||||
<Route path="passwordRecovery" element={<PasswordRecoveryPage />} />
|
||||
<Route path="api" element={<ApiDocumentationPage />} />
|
||||
<Route path="app" element={<Layout header={<Header />} sidebar={<Tree />} sidebarVisible={sidebarVisible} />}>
|
||||
<Route path="category">
|
||||
<Route path=":id" element={<FeedEntriesPage sourceType="category" />} />
|
||||
<Route path=":id/details" element={<CategoryDetailsPage />} />
|
||||
</Route>
|
||||
<Route path="feed">
|
||||
<Route path=":id" element={<FeedEntriesPage sourceType="feed" />} />
|
||||
<Route path=":id/details" element={<FeedDetailsPage />} />
|
||||
</Route>
|
||||
<Route path="tag">
|
||||
<Route path=":id" element={<FeedEntriesPage sourceType="tag" />} />
|
||||
<Route path=":id/details" element={<TagDetailsPage />} />
|
||||
</Route>
|
||||
<Route path="add" element={<AddPage />} />
|
||||
<Route path="settings" element={<SettingsPage />} />
|
||||
<Route path="admin">
|
||||
<Route path="users" element={<AdminUsersPage />} />
|
||||
<Route path="metrics" element={<MetricsPage />} />
|
||||
</Route>
|
||||
<Route path="about" element={<AboutPage />} />
|
||||
<Route path="donate" element={<DonatePage />} />
|
||||
</Route>
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
</Routes>
|
||||
)
|
||||
}
|
||||
|
||||
function RedirectHandler() {
|
||||
const target = useAppSelector(state => state.redirect.to)
|
||||
const dispatch = useAppDispatch()
|
||||
const navigate = useNavigate()
|
||||
useEffect(() => {
|
||||
if (target) {
|
||||
// pages can subscribe to state.timestamp in order to refresh when navigating to an url matching the current page
|
||||
navigate(target, { state: { timestamp: new Date() } })
|
||||
dispatch(redirectTo(undefined))
|
||||
}
|
||||
}, [target, dispatch, navigate])
|
||||
|
||||
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 FaviconHandler() {
|
||||
const root = useAppSelector(state => state.tree.rootCategory)
|
||||
useEffect(() => {
|
||||
const unreadCount = categoryUnreadCount(root)
|
||||
if (unreadCount === 0) {
|
||||
Tinycon.reset()
|
||||
} else {
|
||||
Tinycon.setBubble(unreadCount)
|
||||
}
|
||||
}, [root])
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
function BrowserExtensionBadgeUnreadCountHandler() {
|
||||
const root = useAppSelector(state => state.tree.rootCategory)
|
||||
const { setBadgeUnreadCount } = useBrowserExtension()
|
||||
useEffect(() => {
|
||||
if (!root) return
|
||||
const unreadCount = categoryUnreadCount(root)
|
||||
setBadgeUnreadCount(unreadCount)
|
||||
}, [root, setBadgeUnreadCount])
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
function CustomJs() {
|
||||
const scriptLoaded = useRef(false)
|
||||
|
||||
// useDidUpdate is used instead of useEffect because we want to skip the first render
|
||||
// the first render is the render of react-router, the routes are actually loaded in a second render
|
||||
// we want the script to be executed when the first route is done loading
|
||||
useDidUpdate(() => {
|
||||
if (scriptLoaded.current) {
|
||||
return
|
||||
}
|
||||
|
||||
const script = document.createElement("script")
|
||||
script.src = "custom_js.js"
|
||||
script.async = true
|
||||
document.body.appendChild(script)
|
||||
|
||||
scriptLoaded.current = true
|
||||
})
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
function CustomCss() {
|
||||
useEffect(() => {
|
||||
const link = document.createElement("link")
|
||||
link.rel = "stylesheet"
|
||||
link.type = "text/css"
|
||||
link.href = "custom_css.css"
|
||||
document.head.appendChild(link)
|
||||
}, [])
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
export function App() {
|
||||
useI18n()
|
||||
const dispatch = useAppDispatch()
|
||||
|
||||
useEffect(() => {
|
||||
dispatch(reloadServerInfos())
|
||||
}, [dispatch])
|
||||
|
||||
return (
|
||||
<Providers>
|
||||
<>
|
||||
<FaviconHandler />
|
||||
<BrowserExtensionBadgeUnreadCountHandler />
|
||||
<HashRouter>
|
||||
<GoogleAnalyticsHandler />
|
||||
<RedirectHandler />
|
||||
<AppRoutes />
|
||||
<CustomJs />
|
||||
<CustomCss />
|
||||
</HashRouter>
|
||||
</>
|
||||
</Providers>
|
||||
)
|
||||
}
|
||||
7
commafeed-client/src/app/async-thunk.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import { createAsyncThunk } from "@reduxjs/toolkit"
|
||||
import { type AppDispatch, type RootState } from "app/store"
|
||||
|
||||
export const createAppAsyncThunk = createAsyncThunk.withTypes<{
|
||||
state: RootState
|
||||
dispatch: AppDispatch
|
||||
}>()
|
||||
120
commafeed-client/src/app/client.ts
Normal file
@@ -0,0 +1,120 @@
|
||||
import axios from "axios"
|
||||
import {
|
||||
type AddCategoryRequest,
|
||||
type AdminSaveUserRequest,
|
||||
type Category,
|
||||
type CategoryModificationRequest,
|
||||
type CollapseRequest,
|
||||
type Entries,
|
||||
type FeedInfo,
|
||||
type FeedInfoRequest,
|
||||
type FeedModificationRequest,
|
||||
type GetEntriesPaginatedRequest,
|
||||
type IDRequest,
|
||||
type LoginRequest,
|
||||
type MarkRequest,
|
||||
type Metrics,
|
||||
type MultipleMarkRequest,
|
||||
type PasswordResetRequest,
|
||||
type ProfileModificationRequest,
|
||||
type RegistrationRequest,
|
||||
type ServerInfo,
|
||||
type Settings,
|
||||
type StarRequest,
|
||||
type SubscribeRequest,
|
||||
type Subscription,
|
||||
type TagRequest,
|
||||
type UserModel,
|
||||
} from "./types"
|
||||
|
||||
const axiosInstance = axios.create({ baseURL: "./rest", withCredentials: true })
|
||||
axiosInstance.interceptors.response.use(
|
||||
response => response,
|
||||
error => {
|
||||
const { status, data } = error.response
|
||||
if (
|
||||
(status === 401 && data?.message === "Credentials are required to access this resource.") ||
|
||||
(status === 403 && data?.message === "You don't have the required role to access this resource.")
|
||||
) {
|
||||
window.location.hash = data?.allowRegistrations ? "/welcome" : "/login"
|
||||
}
|
||||
throw error
|
||||
}
|
||||
)
|
||||
|
||||
export const client = {
|
||||
category: {
|
||||
getRoot: async () => await axiosInstance.get<Category>("category/get"),
|
||||
modify: async (req: CategoryModificationRequest) => await axiosInstance.post("category/modify", req),
|
||||
collapse: async (req: CollapseRequest) => await axiosInstance.post("category/collapse", req),
|
||||
getEntries: async (req: GetEntriesPaginatedRequest) => await axiosInstance.get<Entries>("category/entries", { params: req }),
|
||||
markEntries: async (req: MarkRequest) => await axiosInstance.post("category/mark", req),
|
||||
add: async (req: AddCategoryRequest) => await axiosInstance.post("category/add", req),
|
||||
delete: async (req: IDRequest) => await axiosInstance.post("category/delete", req),
|
||||
},
|
||||
entry: {
|
||||
mark: async (req: MarkRequest) => await axiosInstance.post("entry/mark", req),
|
||||
markMultiple: async (req: MultipleMarkRequest) => await axiosInstance.post("entry/markMultiple", req),
|
||||
star: async (req: StarRequest) => await axiosInstance.post("entry/star", req),
|
||||
getTags: async () => await axiosInstance.get<string[]>("entry/tags"),
|
||||
tag: async (req: TagRequest) => await axiosInstance.post("entry/tag", req),
|
||||
},
|
||||
feed: {
|
||||
get: async (id: string) => await axiosInstance.get<Subscription>(`feed/get/${id}`),
|
||||
modify: async (req: FeedModificationRequest) => await axiosInstance.post("feed/modify", req),
|
||||
getEntries: async (req: GetEntriesPaginatedRequest) => await axiosInstance.get<Entries>("feed/entries", { params: req }),
|
||||
markEntries: async (req: MarkRequest) => await axiosInstance.post("feed/mark", req),
|
||||
fetchFeed: async (req: FeedInfoRequest) => await axiosInstance.post<FeedInfo>("feed/fetch", req),
|
||||
refreshAll: async () => await axiosInstance.get("feed/refreshAll"),
|
||||
subscribe: async (req: SubscribeRequest) => await axiosInstance.post<number>("feed/subscribe", req),
|
||||
unsubscribe: async (req: IDRequest) => await axiosInstance.post("feed/unsubscribe", req),
|
||||
importOpml: async (req: File) => {
|
||||
const formData = new FormData()
|
||||
formData.append("file", req)
|
||||
return await axiosInstance.post("feed/import", formData, {
|
||||
headers: {
|
||||
"Content-Type": "multipart/form-data",
|
||||
},
|
||||
})
|
||||
},
|
||||
},
|
||||
user: {
|
||||
login: async (req: LoginRequest) => await axiosInstance.post("user/login", req),
|
||||
register: async (req: RegistrationRequest) => await axiosInstance.post("user/register", req),
|
||||
passwordReset: async (req: PasswordResetRequest) => await axiosInstance.post("user/passwordReset", req),
|
||||
getSettings: async () => await axiosInstance.get<Settings>("user/settings"),
|
||||
saveSettings: async (settings: Settings) => await axiosInstance.post("user/settings", 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"),
|
||||
},
|
||||
server: {
|
||||
getServerInfos: async () => await axiosInstance.get<ServerInfo>("server/get"),
|
||||
},
|
||||
admin: {
|
||||
getAllUsers: async () => await axiosInstance.get<UserModel[]>("admin/user/getAll"),
|
||||
saveUser: async (req: AdminSaveUserRequest) => await axiosInstance.post("admin/user/save", req),
|
||||
deleteUser: async (req: IDRequest) => await axiosInstance.post("admin/user/delete", req),
|
||||
getMetrics: async () => await axiosInstance.get<Metrics>("admin/metrics"),
|
||||
},
|
||||
}
|
||||
|
||||
/**
|
||||
* transform an error object to an array of strings that can be displayed to the user
|
||||
* @param err an error object (e.g. from axios)
|
||||
* @returns an array of messages to show the user
|
||||
*/
|
||||
export const errorToStrings = (err: unknown) => {
|
||||
let strings: string[] = []
|
||||
|
||||
if (axios.isAxiosError(err)) {
|
||||
if (err.response) {
|
||||
const { data } = err.response
|
||||
if (typeof data === "string") strings.push(data)
|
||||
if (typeof data === "object" && data.message) strings.push(data.message as string)
|
||||
if (typeof data === "object" && data.errors) strings = [...strings, ...data.errors]
|
||||
}
|
||||
}
|
||||
|
||||
return strings
|
||||
}
|
||||
101
commafeed-client/src/app/constants.ts
Normal file
@@ -0,0 +1,101 @@
|
||||
import { t } from "@lingui/macro"
|
||||
import { type IconType } from "react-icons"
|
||||
import { FaAt } from "react-icons/fa"
|
||||
import { SiBuffer, SiFacebook, SiGmail, SiInstapaper, SiPocket, SiTumblr, SiTwitter } from "react-icons/si"
|
||||
import { type Category, type Entry, type SharingSettings } from "./types"
|
||||
|
||||
const categories: Record<string, Category> = {
|
||||
all: {
|
||||
id: "all",
|
||||
name: t`All`,
|
||||
expanded: false,
|
||||
children: [],
|
||||
feeds: [],
|
||||
position: 0,
|
||||
},
|
||||
starred: {
|
||||
id: "starred",
|
||||
name: t`Starred`,
|
||||
expanded: false,
|
||||
children: [],
|
||||
feeds: [],
|
||||
position: 1,
|
||||
},
|
||||
}
|
||||
|
||||
const sharing: {
|
||||
[key in keyof SharingSettings]: {
|
||||
label: string
|
||||
icon: IconType
|
||||
color: `#${string}`
|
||||
url: (url: string, description: string) => string
|
||||
}
|
||||
} = {
|
||||
email: {
|
||||
label: "Email",
|
||||
icon: FaAt,
|
||||
color: "#000000",
|
||||
url: (url, desc) => `mailto:?subject=${desc}&body=${url}`,
|
||||
},
|
||||
gmail: {
|
||||
label: "Gmail",
|
||||
icon: SiGmail,
|
||||
color: "#EA4335",
|
||||
url: (url, desc) => `https://mail.google.com/mail/?view=cm&fs=1&tf=1&source=mailto&su=${desc}&body=${url}`,
|
||||
},
|
||||
facebook: {
|
||||
label: "Facebook",
|
||||
icon: SiFacebook,
|
||||
color: "#1B74E4",
|
||||
url: url => `https://www.facebook.com/sharer/sharer.php?u=${url}`,
|
||||
},
|
||||
twitter: {
|
||||
label: "Twitter",
|
||||
icon: SiTwitter,
|
||||
color: "#1D9BF0",
|
||||
url: (url, desc) => `https://twitter.com/share?text=${desc}&url=${url}`,
|
||||
},
|
||||
tumblr: {
|
||||
label: "Tumblr",
|
||||
icon: SiTumblr,
|
||||
color: "#375672",
|
||||
url: (url, desc) => `https://www.tumblr.com/share/link?url=${url}&name=${desc}`,
|
||||
},
|
||||
pocket: {
|
||||
label: "Pocket",
|
||||
icon: SiPocket,
|
||||
color: "#EF4154",
|
||||
url: (url, desc) => `https://getpocket.com/save?url=${url}&title=${desc}`,
|
||||
},
|
||||
instapaper: {
|
||||
label: "Instapaper",
|
||||
icon: SiInstapaper,
|
||||
color: "#010101",
|
||||
url: (url, desc) => `https://www.instapaper.com/hello2?url=${url}&title=${desc}`,
|
||||
},
|
||||
buffer: {
|
||||
label: "Buffer",
|
||||
icon: SiBuffer,
|
||||
color: "#000000",
|
||||
url: (url, desc) => `https://bufferapp.com/add?url=${url}&text=${desc}`,
|
||||
},
|
||||
}
|
||||
|
||||
export const Constants = {
|
||||
categories,
|
||||
sharing,
|
||||
layout: {
|
||||
mobileBreakpoint: 992,
|
||||
mobileBreakpointName: "md",
|
||||
headerHeight: 60,
|
||||
entryMaxWidth: 650,
|
||||
isTopVisible: (div: HTMLElement) => div.getBoundingClientRect().top >= Constants.layout.headerHeight,
|
||||
isBottomVisible: (div: HTMLElement) => div.getBoundingClientRect().bottom <= window.innerHeight,
|
||||
},
|
||||
dom: {
|
||||
entryId: (entry: Entry) => `entry-id-${entry.id}`,
|
||||
entryContextMenuId: (entry: Entry) => entry.id,
|
||||
},
|
||||
browserExtensionUrl: "https://github.com/Athou/commafeed-browser-extension",
|
||||
bitcoinWalletAddress: "1dymfUxqCWpyD7a6rQSqNy4rLVDBsAr5e",
|
||||
}
|
||||
146
commafeed-client/src/app/entries/entries.test.ts
Normal file
@@ -0,0 +1,146 @@
|
||||
/* eslint-disable import/first */
|
||||
import { configureStore } from "@reduxjs/toolkit"
|
||||
import { type client } from "app/client"
|
||||
import { loadEntries, loadMoreEntries, markAllEntries, markEntry } from "app/entries/thunks"
|
||||
import { reducers, type RootState } from "app/store"
|
||||
import { type Entries, type Entry } from "app/types"
|
||||
import { type AxiosResponse } from "axios"
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest"
|
||||
import { mockReset } from "vitest-mock-extended"
|
||||
|
||||
const mockClient = await vi.hoisted(async () => {
|
||||
const mockModule = await import("vitest-mock-extended")
|
||||
return mockModule.mockDeep<typeof client>()
|
||||
})
|
||||
vi.mock("app/client", () => ({ client: mockClient }))
|
||||
|
||||
describe("entries", () => {
|
||||
beforeEach(() => {
|
||||
mockReset(mockClient)
|
||||
})
|
||||
|
||||
it("loads entries", async () => {
|
||||
mockClient.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 })
|
||||
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")
|
||||
expect(store.getState().entries.entries).toStrictEqual([])
|
||||
expect(store.getState().entries.hasMore).toBe(true)
|
||||
expect(store.getState().entries.sourceLabel).toBe("")
|
||||
expect(store.getState().entries.sourceWebsiteUrl).toBe("")
|
||||
expect(store.getState().entries.timestamp).toBeUndefined()
|
||||
|
||||
await promise
|
||||
expect(store.getState().entries.source.type).toBe("feed")
|
||||
expect(store.getState().entries.source.id).toBe("feed-id")
|
||||
expect(store.getState().entries.entries).toStrictEqual([{ id: "3" }])
|
||||
expect(store.getState().entries.hasMore).toBe(false)
|
||||
expect(store.getState().entries.sourceLabel).toBe("my-feed")
|
||||
expect(store.getState().entries.sourceWebsiteUrl).toBe("https://mysite.com/feed")
|
||||
expect(store.getState().entries.timestamp).toBe(123)
|
||||
})
|
||||
|
||||
it("loads more entries", async () => {
|
||||
mockClient.category.getEntries.mockResolvedValue({
|
||||
data: {
|
||||
entries: [{ id: "4" } 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: {
|
||||
entries: {
|
||||
source: {
|
||||
type: "category",
|
||||
id: "category-id",
|
||||
},
|
||||
sourceLabel: "",
|
||||
sourceWebsiteUrl: "",
|
||||
entries: [{ id: "3" } as Entry],
|
||||
hasMore: true,
|
||||
loading: false,
|
||||
scrollingToEntry: false,
|
||||
},
|
||||
} as RootState,
|
||||
})
|
||||
const promise = store.dispatch(loadMoreEntries())
|
||||
|
||||
await promise
|
||||
expect(store.getState().entries.entries).toStrictEqual([{ id: "3" }, { id: "4" }])
|
||||
expect(store.getState().entries.hasMore).toBe(false)
|
||||
})
|
||||
|
||||
it("marks an entry as read", async () => {
|
||||
const store = configureStore({
|
||||
reducer: reducers,
|
||||
preloadedState: {
|
||||
entries: {
|
||||
source: {
|
||||
type: "category",
|
||||
id: "category-id",
|
||||
},
|
||||
sourceLabel: "",
|
||||
sourceWebsiteUrl: "",
|
||||
entries: [{ id: "3", read: false } as Entry, { id: "4", read: false } as Entry],
|
||||
hasMore: true,
|
||||
loading: false,
|
||||
scrollingToEntry: false,
|
||||
},
|
||||
} as RootState,
|
||||
})
|
||||
|
||||
store.dispatch(markEntry({ entry: { id: "3" } as Entry, read: true }))
|
||||
expect(store.getState().entries.entries).toStrictEqual([
|
||||
{ id: "3", read: true },
|
||||
{ id: "4", read: false },
|
||||
])
|
||||
expect(mockClient.entry.mark).toHaveBeenCalledWith({ id: "3", read: true })
|
||||
})
|
||||
|
||||
it("marks all entries as read", async () => {
|
||||
const store = configureStore({
|
||||
reducer: reducers,
|
||||
preloadedState: {
|
||||
entries: {
|
||||
source: {
|
||||
type: "category",
|
||||
id: "category-id",
|
||||
},
|
||||
sourceLabel: "",
|
||||
sourceWebsiteUrl: "",
|
||||
entries: [{ id: "3", read: false } as Entry, { id: "4", read: false } as Entry],
|
||||
hasMore: true,
|
||||
loading: false,
|
||||
scrollingToEntry: false,
|
||||
},
|
||||
} as RootState,
|
||||
})
|
||||
|
||||
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 })
|
||||
})
|
||||
})
|
||||
134
commafeed-client/src/app/entries/slice.ts
Normal file
@@ -0,0 +1,134 @@
|
||||
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"
|
||||
|
||||
export interface EntrySource {
|
||||
type: EntrySourceType
|
||||
id: string
|
||||
}
|
||||
|
||||
export type ExpendableEntry = Entry & { expanded?: boolean }
|
||||
|
||||
interface EntriesState {
|
||||
/** selected source */
|
||||
source: EntrySource
|
||||
sourceLabel: string
|
||||
sourceWebsiteUrl: string
|
||||
entries: ExpendableEntry[]
|
||||
/** stores when the first batch of entries were retrieved
|
||||
*
|
||||
* this is used when marking all entries of a feed/category to only mark entries up to that timestamp as newer entries were potentially never shown
|
||||
*/
|
||||
timestamp?: number
|
||||
selectedEntryId?: string
|
||||
hasMore: boolean
|
||||
loading: boolean
|
||||
search?: string
|
||||
scrollingToEntry: boolean
|
||||
}
|
||||
|
||||
const initialState: EntriesState = {
|
||||
source: {
|
||||
type: "category",
|
||||
id: Constants.categories.all.id,
|
||||
},
|
||||
sourceLabel: "",
|
||||
sourceWebsiteUrl: "",
|
||||
entries: [],
|
||||
hasMore: true,
|
||||
loading: false,
|
||||
scrollingToEntry: false,
|
||||
}
|
||||
|
||||
export const entriesSlice = createSlice({
|
||||
name: "entries",
|
||||
initialState,
|
||||
reducers: {
|
||||
setSelectedEntry: (state, action: PayloadAction<Entry>) => {
|
||||
state.selectedEntryId = action.payload.id
|
||||
},
|
||||
setEntryExpanded: (state, action: PayloadAction<{ entry: Entry; expanded: boolean }>) => {
|
||||
state.entries
|
||||
.filter(e => e.id === action.payload.entry.id)
|
||||
.forEach(e => {
|
||||
e.expanded = action.payload.expanded
|
||||
})
|
||||
},
|
||||
setScrollingToEntry: (state, action: PayloadAction<boolean>) => {
|
||||
state.scrollingToEntry = action.payload
|
||||
},
|
||||
setSearch: (state, action: PayloadAction<string>) => {
|
||||
state.search = action.payload
|
||||
},
|
||||
},
|
||||
extraReducers: builder => {
|
||||
builder.addCase(markEntry.pending, (state, action) => {
|
||||
state.entries
|
||||
.filter(e => e.id === action.meta.arg.entry.id)
|
||||
.forEach(e => {
|
||||
e.read = action.meta.arg.read
|
||||
})
|
||||
})
|
||||
builder.addCase(markMultipleEntries.pending, (state, action) => {
|
||||
state.entries
|
||||
.filter(e => action.meta.arg.entries.some(e2 => e2.id === e.id))
|
||||
.forEach(e => {
|
||||
e.read = action.meta.arg.read
|
||||
})
|
||||
})
|
||||
builder.addCase(markAllEntries.pending, (state, action) => {
|
||||
state.entries
|
||||
.filter(e => (action.meta.arg.req.olderThan ? e.date < action.meta.arg.req.olderThan : true))
|
||||
.forEach(e => {
|
||||
e.read = true
|
||||
})
|
||||
})
|
||||
builder.addCase(starEntry.pending, (state, action) => {
|
||||
state.entries
|
||||
.filter(e => action.meta.arg.entry.id === e.id && action.meta.arg.entry.feedId === e.feedId)
|
||||
.forEach(e => {
|
||||
e.starred = action.meta.arg.starred
|
||||
})
|
||||
})
|
||||
builder.addCase(loadEntries.pending, (state, action) => {
|
||||
state.source = action.meta.arg.source
|
||||
state.entries = []
|
||||
state.timestamp = undefined
|
||||
state.sourceLabel = ""
|
||||
state.sourceWebsiteUrl = ""
|
||||
state.hasMore = true
|
||||
state.selectedEntryId = undefined
|
||||
state.loading = true
|
||||
})
|
||||
builder.addCase(loadMoreEntries.pending, state => {
|
||||
state.loading = true
|
||||
})
|
||||
builder.addCase(loadEntries.fulfilled, (state, action) => {
|
||||
state.entries = action.payload.entries
|
||||
state.timestamp = action.payload.timestamp
|
||||
state.sourceLabel = action.payload.name
|
||||
state.sourceWebsiteUrl = action.payload.feedLink
|
||||
state.hasMore = action.payload.hasMore
|
||||
state.loading = false
|
||||
})
|
||||
builder.addCase(loadMoreEntries.fulfilled, (state, action) => {
|
||||
// remove already existing entries
|
||||
const entriesToAdd = action.payload.entries.filter(e => !state.entries.some(e2 => e.id === e2.id))
|
||||
state.entries = [...state.entries, ...entriesToAdd]
|
||||
state.hasMore = action.payload.hasMore
|
||||
state.loading = false
|
||||
})
|
||||
builder.addCase(tagEntry.pending, (state, action) => {
|
||||
state.entries
|
||||
.filter(e => +e.id === action.meta.arg.entryId)
|
||||
.forEach(e => {
|
||||
e.tags = action.meta.arg.tags
|
||||
})
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
export const { setSearch } = entriesSlice.actions
|
||||
240
commafeed-client/src/app/entries/thunks.ts
Normal file
@@ -0,0 +1,240 @@
|
||||
import { createAppAsyncThunk } from "app/async-thunk"
|
||||
import { client } from "app/client"
|
||||
import { Constants } from "app/constants"
|
||||
import { entriesSlice, type EntrySource, type EntrySourceType, 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"
|
||||
|
||||
const getEndpoint = (sourceType: EntrySourceType) =>
|
||||
sourceType === "category" || sourceType === "tag" ? client.category.getEntries : client.feed.getEntries
|
||||
export const loadEntries = createAppAsyncThunk(
|
||||
"entries/load",
|
||||
async (
|
||||
arg: {
|
||||
source: EntrySource
|
||||
clearSearch: boolean
|
||||
},
|
||||
thunkApi
|
||||
) => {
|
||||
if (arg.clearSearch) thunkApi.dispatch(setSearch(""))
|
||||
|
||||
const state = thunkApi.getState()
|
||||
const endpoint = getEndpoint(arg.source.type)
|
||||
const result = await endpoint(buildGetEntriesPaginatedRequest(state, arg.source, 0))
|
||||
return result.data
|
||||
}
|
||||
)
|
||||
export const loadMoreEntries = createAppAsyncThunk("entries/loadMore", async (_, thunkApi) => {
|
||||
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
|
||||
const endpoint = getEndpoint(state.entries.source.type)
|
||||
const result = await endpoint(buildGetEntriesPaginatedRequest(state, source, offset))
|
||||
return result.data
|
||||
})
|
||||
const buildGetEntriesPaginatedRequest = (state: RootState, source: EntrySource, offset: number) => ({
|
||||
id: source.type === "tag" ? Constants.categories.all.id : source.id,
|
||||
order: state.user.settings?.readingOrder,
|
||||
readType: state.user.settings?.readingMode,
|
||||
offset,
|
||||
limit: 50,
|
||||
tag: source.type === "tag" ? source.id : undefined,
|
||||
keywords: state.entries.search,
|
||||
})
|
||||
export const reloadEntries = createAppAsyncThunk("entries/reload", async (arg, thunkApi) => {
|
||||
const state = thunkApi.getState()
|
||||
thunkApi.dispatch(loadEntries({ source: state.entries.source, clearSearch: false }))
|
||||
})
|
||||
export const search = createAppAsyncThunk("entries/search", async (arg: string, thunkApi) => {
|
||||
const state = thunkApi.getState()
|
||||
thunkApi.dispatch(setSearch(arg))
|
||||
thunkApi.dispatch(loadEntries({ source: state.entries.source, clearSearch: false }))
|
||||
})
|
||||
export const markEntry = createAppAsyncThunk(
|
||||
"entries/entry/mark",
|
||||
(arg: { entry: Entry; read: boolean }) => {
|
||||
client.entry.mark({
|
||||
id: arg.entry.id,
|
||||
read: arg.read,
|
||||
})
|
||||
},
|
||||
{
|
||||
condition: arg => arg.entry.read !== arg.read,
|
||||
}
|
||||
)
|
||||
export const markMultipleEntries = createAppAsyncThunk(
|
||||
"entries/entry/markMultiple",
|
||||
async (
|
||||
arg: {
|
||||
entries: Entry[]
|
||||
read: boolean
|
||||
},
|
||||
thunkApi
|
||||
) => {
|
||||
const requests: MarkRequest[] = arg.entries.map(e => ({
|
||||
id: e.id,
|
||||
read: arg.read,
|
||||
}))
|
||||
await client.entry.markMultiple({ requests })
|
||||
thunkApi.dispatch(reloadTree())
|
||||
}
|
||||
)
|
||||
export const markEntriesUpToEntry = createAppAsyncThunk("entries/entry/upToEntry", async (arg: Entry, thunkApi) => {
|
||||
const state = thunkApi.getState()
|
||||
const { entries } = state.entries
|
||||
|
||||
const index = entries.findIndex(e => e.id === arg.id)
|
||||
if (index === -1) return
|
||||
|
||||
thunkApi.dispatch(
|
||||
markMultipleEntries({
|
||||
entries: entries.slice(0, index + 1),
|
||||
read: true,
|
||||
})
|
||||
)
|
||||
})
|
||||
export const markAllEntries = createAppAsyncThunk(
|
||||
"entries/entry/markAll",
|
||||
async (
|
||||
arg: {
|
||||
sourceType: EntrySourceType
|
||||
req: MarkRequest
|
||||
},
|
||||
thunkApi
|
||||
) => {
|
||||
const endpoint = arg.sourceType === "category" ? client.category.markEntries : client.feed.markEntries
|
||||
await endpoint(arg.req)
|
||||
thunkApi.dispatch(reloadEntries())
|
||||
thunkApi.dispatch(reloadTree())
|
||||
}
|
||||
)
|
||||
export const starEntry = createAppAsyncThunk("entries/entry/star", (arg: { entry: Entry; starred: boolean }) => {
|
||||
client.entry.star({
|
||||
id: arg.entry.id,
|
||||
feedId: +arg.entry.feedId,
|
||||
starred: arg.starred,
|
||||
})
|
||||
})
|
||||
export const selectEntry = createAppAsyncThunk(
|
||||
"entries/entry/select",
|
||||
(
|
||||
arg: {
|
||||
entry: Entry
|
||||
expand: boolean
|
||||
markAsRead: boolean
|
||||
scrollToEntry: boolean
|
||||
},
|
||||
thunkApi
|
||||
) => {
|
||||
const state = thunkApi.getState()
|
||||
const entry = state.entries.entries.find(e => e.id === arg.entry.id)
|
||||
if (!entry) return
|
||||
|
||||
// flushSync is required because we need the newly selected entry to be expanded
|
||||
// and the previously selected entry to be collapsed to be able to scroll to the right position
|
||||
flushSync(() => {
|
||||
// mark as read if requested
|
||||
if (arg.markAsRead) {
|
||||
thunkApi.dispatch(markEntry({ entry, read: true }))
|
||||
}
|
||||
|
||||
// set entry as selected
|
||||
thunkApi.dispatch(entriesSlice.actions.setSelectedEntry(entry))
|
||||
|
||||
// expand if requested
|
||||
const previouslySelectedEntry = state.entries.entries.find(e => e.id === state.entries.selectedEntryId)
|
||||
if (previouslySelectedEntry) {
|
||||
thunkApi.dispatch(
|
||||
entriesSlice.actions.setEntryExpanded({
|
||||
entry: previouslySelectedEntry,
|
||||
expanded: false,
|
||||
})
|
||||
)
|
||||
}
|
||||
thunkApi.dispatch(entriesSlice.actions.setEntryExpanded({ entry, expanded: arg.expand }))
|
||||
})
|
||||
|
||||
if (arg.scrollToEntry) {
|
||||
const entryElement = document.getElementById(Constants.dom.entryId(entry))
|
||||
if (entryElement) {
|
||||
const alwaysScrollToEntry = state.user.settings?.alwaysScrollToEntry
|
||||
const entryEntirelyVisible = Constants.layout.isTopVisible(entryElement) && Constants.layout.isBottomVisible(entryElement)
|
||||
if (alwaysScrollToEntry || !entryEntirelyVisible) {
|
||||
const scrollSpeed = state.user.settings?.scrollSpeed
|
||||
thunkApi.dispatch(entriesSlice.actions.setScrollingToEntry(true))
|
||||
scrollToEntry(entryElement, scrollSpeed, () => thunkApi.dispatch(entriesSlice.actions.setScrollingToEntry(false)))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
const scrollToEntry = (entryElement: HTMLElement, scrollSpeed: number | undefined, onScrollEnded: () => void) => {
|
||||
scrollToWithCallback({
|
||||
options: {
|
||||
// add a small gap between the top of the content and the top of the page
|
||||
top: entryElement.offsetTop - Constants.layout.headerHeight - 3,
|
||||
behavior: scrollSpeed && scrollSpeed > 0 ? "smooth" : "auto",
|
||||
},
|
||||
onScrollEnded,
|
||||
})
|
||||
}
|
||||
|
||||
export const selectPreviousEntry = createAppAsyncThunk(
|
||||
"entries/entry/selectPrevious",
|
||||
(
|
||||
arg: {
|
||||
expand: boolean
|
||||
markAsRead: boolean
|
||||
scrollToEntry: boolean
|
||||
},
|
||||
thunkApi
|
||||
) => {
|
||||
const state = thunkApi.getState()
|
||||
const { entries } = state.entries
|
||||
const previousIndex = entries.findIndex(e => e.id === state.entries.selectedEntryId) - 1
|
||||
if (previousIndex >= 0) {
|
||||
thunkApi.dispatch(
|
||||
selectEntry({
|
||||
entry: entries[previousIndex],
|
||||
expand: arg.expand,
|
||||
markAsRead: arg.markAsRead,
|
||||
scrollToEntry: arg.scrollToEntry,
|
||||
})
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
export const selectNextEntry = createAppAsyncThunk(
|
||||
"entries/entry/selectNext",
|
||||
(
|
||||
arg: {
|
||||
expand: boolean
|
||||
markAsRead: boolean
|
||||
scrollToEntry: boolean
|
||||
},
|
||||
thunkApi
|
||||
) => {
|
||||
const state = thunkApi.getState()
|
||||
const { entries } = state.entries
|
||||
const nextIndex = entries.findIndex(e => e.id === state.entries.selectedEntryId) + 1
|
||||
if (nextIndex < entries.length) {
|
||||
thunkApi.dispatch(
|
||||
selectEntry({
|
||||
entry: entries[nextIndex],
|
||||
expand: arg.expand,
|
||||
markAsRead: arg.markAsRead,
|
||||
scrollToEntry: arg.scrollToEntry,
|
||||
})
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
export const tagEntry = createAppAsyncThunk("entries/entry/tag", async (arg: TagRequest, thunkApi) => {
|
||||
await client.entry.tag(arg)
|
||||
thunkApi.dispatch(reloadTags())
|
||||
})
|
||||
10
commafeed-client/src/app/redirect/redirect.test.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { redirectToCategory } from "app/redirect/thunks"
|
||||
import { store } from "app/store"
|
||||
import { describe, expect, it } from "vitest"
|
||||
|
||||
describe("redirects", () => {
|
||||
it("redirects to category", async () => {
|
||||
await store.dispatch(redirectToCategory("1"))
|
||||
expect(store.getState().redirect.to).toBe("/app/category/1")
|
||||
})
|
||||
})
|
||||
19
commafeed-client/src/app/redirect/slice.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import { createSlice, type PayloadAction } from "@reduxjs/toolkit"
|
||||
|
||||
interface RedirectState {
|
||||
to?: string
|
||||
}
|
||||
|
||||
const initialState: RedirectState = {}
|
||||
|
||||
export const redirectSlice = createSlice({
|
||||
name: "redirect",
|
||||
initialState,
|
||||
reducers: {
|
||||
redirectTo: (state, action: PayloadAction<string | undefined>) => {
|
||||
state.to = action.payload
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
export const { redirectTo } = redirectSlice.actions
|
||||
45
commafeed-client/src/app/redirect/thunks.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
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 redirectToPasswordRecovery = createAppAsyncThunk("redirect/passwordRecovery", (_, thunkApi) =>
|
||||
thunkApi.dispatch(redirectTo("/passwordRecovery"))
|
||||
)
|
||||
export const redirectToApiDocumentation = createAppAsyncThunk("redirect/api", (_, thunkApi) => thunkApi.dispatch(redirectTo("/api")))
|
||||
|
||||
export const redirectToSelectedSource = createAppAsyncThunk("redirect/selectedSource", (_, thunkApi) => {
|
||||
const { source } = thunkApi.getState().entries
|
||||
thunkApi.dispatch(redirectTo(`/app/${source.type}/${source.id}`))
|
||||
})
|
||||
export const redirectToCategory = createAppAsyncThunk("redirect/category", (id: string, thunkApi) =>
|
||||
thunkApi.dispatch(redirectTo(`/app/category/${id}`))
|
||||
)
|
||||
export const redirectToRootCategory = createAppAsyncThunk(
|
||||
"redirect/category/root",
|
||||
async (_, thunkApi) => await thunkApi.dispatch(redirectToCategory(Constants.categories.all.id))
|
||||
)
|
||||
export const redirectToCategoryDetails = createAppAsyncThunk("redirect/category/details", (id: string, thunkApi) =>
|
||||
thunkApi.dispatch(redirectTo(`/app/category/${id}/details`))
|
||||
)
|
||||
export const redirectToFeed = createAppAsyncThunk("redirect/feed", (id: string | number, thunkApi) =>
|
||||
thunkApi.dispatch(redirectTo(`/app/feed/${id}`))
|
||||
)
|
||||
export const redirectToFeedDetails = createAppAsyncThunk("redirect/feed/details", (id: string, thunkApi) =>
|
||||
thunkApi.dispatch(redirectTo(`/app/feed/${id}/details`))
|
||||
)
|
||||
export const redirectToTag = createAppAsyncThunk("redirect/tag", (id: string, thunkApi) => thunkApi.dispatch(redirectTo(`/app/tag/${id}`)))
|
||||
export const redirectToTagDetails = createAppAsyncThunk("redirect/tag/details", (id: string, thunkApi) =>
|
||||
thunkApi.dispatch(redirectTo(`/app/tag/${id}/details`))
|
||||
)
|
||||
export const redirectToAdd = createAppAsyncThunk("redirect/add", (_, thunkApi) => thunkApi.dispatch(redirectTo("/app/add")))
|
||||
export const redirectToSettings = createAppAsyncThunk("redirect/settings", (_, thunkApi) => thunkApi.dispatch(redirectTo("/app/settings")))
|
||||
export const redirectToAdminUsers = createAppAsyncThunk("redirect/admin/users", (_, thunkApi) =>
|
||||
thunkApi.dispatch(redirectTo("/app/admin/users"))
|
||||
)
|
||||
export const redirectToMetrics = createAppAsyncThunk("redirect/admin/metrics", (_, thunkApi) =>
|
||||
thunkApi.dispatch(redirectTo("/app/admin/metrics"))
|
||||
)
|
||||
export const redirectToDonate = createAppAsyncThunk("redirect/donate", (_, thunkApi) => thunkApi.dispatch(redirectTo("/app/donate")))
|
||||
export const redirectToAbout = createAppAsyncThunk("redirect/about", (_, thunkApi) => thunkApi.dispatch(redirectTo("/app/about")))
|
||||
29
commafeed-client/src/app/server/slice.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import { createSlice, type PayloadAction } from "@reduxjs/toolkit"
|
||||
import { reloadServerInfos } from "app/server/thunks"
|
||||
import { type ServerInfo } from "app/types"
|
||||
|
||||
interface ServerState {
|
||||
serverInfos?: ServerInfo
|
||||
webSocketConnected: boolean
|
||||
}
|
||||
|
||||
const initialState: ServerState = {
|
||||
webSocketConnected: false,
|
||||
}
|
||||
|
||||
export const serverSlice = createSlice({
|
||||
name: "server",
|
||||
initialState,
|
||||
reducers: {
|
||||
setWebSocketConnected: (state, action: PayloadAction<boolean>) => {
|
||||
state.webSocketConnected = action.payload
|
||||
},
|
||||
},
|
||||
extraReducers: builder => {
|
||||
builder.addCase(reloadServerInfos.fulfilled, (state, action) => {
|
||||
state.serverInfos = action.payload
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
export const { setWebSocketConnected } = serverSlice.actions
|
||||
4
commafeed-client/src/app/server/thunks.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
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))
|
||||
23
commafeed-client/src/app/store.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
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 { userSlice } from "app/user/slice"
|
||||
import { type TypedUseSelectorHook, useDispatch, useSelector } from "react-redux"
|
||||
|
||||
export const reducers = {
|
||||
entries: entriesSlice.reducer,
|
||||
redirect: redirectSlice.reducer,
|
||||
tree: treeSlice.reducer,
|
||||
server: serverSlice.reducer,
|
||||
user: userSlice.reducer,
|
||||
}
|
||||
|
||||
export const store = configureStore({ reducer: reducers })
|
||||
|
||||
export type RootState = ReturnType<typeof store.getState>
|
||||
export type AppDispatch = typeof store.dispatch
|
||||
|
||||
export const useAppDispatch: () => AppDispatch = useDispatch
|
||||
export const useAppSelector: TypedUseSelectorHook<RootState> = useSelector
|
||||
56
commafeed-client/src/app/tree/slice.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
import { createSlice, type PayloadAction } 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"
|
||||
|
||||
interface TreeState {
|
||||
rootCategory?: Category
|
||||
mobileMenuOpen: boolean
|
||||
sidebarVisible: boolean
|
||||
}
|
||||
|
||||
const initialState: TreeState = {
|
||||
mobileMenuOpen: false,
|
||||
sidebarVisible: true,
|
||||
}
|
||||
|
||||
export const treeSlice = createSlice({
|
||||
name: "tree",
|
||||
initialState,
|
||||
reducers: {
|
||||
setMobileMenuOpen: (state, action: PayloadAction<boolean>) => {
|
||||
state.mobileMenuOpen = action.payload
|
||||
},
|
||||
toggleSidebar: state => {
|
||||
state.sidebarVisible = !state.sidebarVisible
|
||||
},
|
||||
},
|
||||
extraReducers: builder => {
|
||||
builder.addCase(reloadTree.fulfilled, (state, action) => {
|
||||
state.rootCategory = action.payload
|
||||
})
|
||||
builder.addCase(collapseTreeCategory.pending, (state, action) => {
|
||||
if (!state.rootCategory) return
|
||||
visitCategoryTree(state.rootCategory, c => {
|
||||
if (+c.id === action.meta.arg.id) c.expanded = !action.meta.arg.collapse
|
||||
})
|
||||
})
|
||||
builder.addCase(markEntry.pending, (state, action) => {
|
||||
if (!state.rootCategory) return
|
||||
visitCategoryTree(state.rootCategory, c =>
|
||||
c.feeds
|
||||
.filter(f => f.id === +action.meta.arg.entry.feedId)
|
||||
.forEach(f => {
|
||||
f.unread = action.meta.arg.read ? f.unread - 1 : f.unread + 1
|
||||
})
|
||||
)
|
||||
})
|
||||
builder.addCase(redirectTo, state => {
|
||||
state.mobileMenuOpen = false
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
export const { setMobileMenuOpen, toggleSidebar } = treeSlice.actions
|
||||
9
commafeed-client/src/app/tree/thunks.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { createAppAsyncThunk } from "app/async-thunk"
|
||||
import { client } from "app/client"
|
||||
import type { CollapseRequest } from "app/types"
|
||||
|
||||
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)
|
||||
)
|
||||
290
commafeed-client/src/app/types.ts
Normal file
@@ -0,0 +1,290 @@
|
||||
export interface AddCategoryRequest {
|
||||
name: string
|
||||
parentId?: string
|
||||
}
|
||||
|
||||
export interface Category {
|
||||
id: string
|
||||
parentId?: string
|
||||
parentName?: string
|
||||
name: string
|
||||
children: Category[]
|
||||
feeds: Subscription[]
|
||||
expanded: boolean
|
||||
position: number
|
||||
}
|
||||
|
||||
export interface CategoryModificationRequest {
|
||||
id: number
|
||||
name?: string
|
||||
parentId?: string
|
||||
position?: number
|
||||
}
|
||||
|
||||
export interface CollapseRequest {
|
||||
id: number
|
||||
collapse: boolean
|
||||
}
|
||||
|
||||
export interface Entries {
|
||||
name: string
|
||||
message?: string
|
||||
errorCount: number
|
||||
feedLink: string
|
||||
timestamp: number
|
||||
hasMore: boolean
|
||||
offset?: number
|
||||
limit?: number
|
||||
entries: Entry[]
|
||||
ignoredReadStatus: boolean
|
||||
}
|
||||
|
||||
export interface Entry {
|
||||
id: string
|
||||
guid: string
|
||||
title: string
|
||||
content: string
|
||||
categories?: string
|
||||
rtl: boolean
|
||||
author?: string
|
||||
enclosureUrl?: string
|
||||
enclosureType?: string
|
||||
mediaDescription?: string
|
||||
mediaThumbnailUrl?: string
|
||||
mediaThumbnailWidth?: number
|
||||
mediaThumbnailHeight?: number
|
||||
date: number
|
||||
insertedDate: number
|
||||
feedId: string
|
||||
feedName: string
|
||||
feedUrl: string
|
||||
feedLink: string
|
||||
iconUrl: string
|
||||
url: string
|
||||
read: boolean
|
||||
starred: boolean
|
||||
markable: boolean
|
||||
tags: string[]
|
||||
}
|
||||
|
||||
export interface FeedInfo {
|
||||
url: string
|
||||
title: string
|
||||
}
|
||||
|
||||
export interface FeedInfoRequest {
|
||||
url: string
|
||||
}
|
||||
|
||||
export interface FeedModificationRequest {
|
||||
id: number
|
||||
name?: string
|
||||
categoryId?: string
|
||||
position?: number
|
||||
filter?: string
|
||||
}
|
||||
|
||||
export interface GetEntriesRequest {
|
||||
id: string
|
||||
readType?: ReadingMode
|
||||
newerThan?: number
|
||||
order?: ReadingOrder
|
||||
keywords?: string
|
||||
onlyIds?: boolean
|
||||
excludedSubscriptionIds?: string
|
||||
tag?: string
|
||||
}
|
||||
|
||||
export interface GetEntriesPaginatedRequest extends GetEntriesRequest {
|
||||
offset: number
|
||||
limit: number
|
||||
}
|
||||
|
||||
export interface IDRequest {
|
||||
id: number
|
||||
}
|
||||
|
||||
export interface LoginRequest {
|
||||
name: string
|
||||
password: string
|
||||
}
|
||||
|
||||
export interface MarkRequest {
|
||||
id: string
|
||||
read: boolean
|
||||
olderThan?: number
|
||||
insertedBefore?: number
|
||||
keywords?: string
|
||||
excludedSubscriptions?: number[]
|
||||
}
|
||||
|
||||
export interface MetricCounter {
|
||||
count: number
|
||||
}
|
||||
|
||||
export interface MetricGauge {
|
||||
value: number
|
||||
}
|
||||
|
||||
export interface MetricMeter {
|
||||
count: number
|
||||
m15_rate: number
|
||||
m1_rate: number
|
||||
m5_rate: number
|
||||
mean_rate: number
|
||||
units: string
|
||||
}
|
||||
|
||||
export interface MetricTimer {
|
||||
count: number
|
||||
max: number
|
||||
mean: number
|
||||
min: number
|
||||
p50: number
|
||||
p75: number
|
||||
p95: number
|
||||
p98: number
|
||||
p99: number
|
||||
p999: number
|
||||
stddev: number
|
||||
m15_rate: number
|
||||
m1_rate: number
|
||||
m5_rate: number
|
||||
mean_rate: number
|
||||
duration_units: string
|
||||
rate_units: string
|
||||
}
|
||||
|
||||
export interface Metrics {
|
||||
counters: Record<string, MetricCounter>
|
||||
gauges: Record<string, MetricGauge>
|
||||
meters: Record<string, MetricMeter>
|
||||
timers: Record<string, MetricTimer>
|
||||
}
|
||||
|
||||
export interface MultipleMarkRequest {
|
||||
requests: MarkRequest[]
|
||||
}
|
||||
|
||||
export interface PasswordResetRequest {
|
||||
email: string
|
||||
}
|
||||
|
||||
export interface ProfileModificationRequest {
|
||||
currentPassword: string
|
||||
email: string
|
||||
newPassword?: string
|
||||
newApiKey?: boolean
|
||||
}
|
||||
|
||||
export interface RegistrationRequest {
|
||||
name: string
|
||||
password: string
|
||||
email: string
|
||||
}
|
||||
|
||||
export interface ServerInfo {
|
||||
announcement?: string
|
||||
version: string
|
||||
gitCommit: string
|
||||
allowRegistrations: boolean
|
||||
googleAnalyticsCode?: string
|
||||
smtpEnabled: boolean
|
||||
demoAccountEnabled: boolean
|
||||
websocketEnabled: boolean
|
||||
websocketPingInterval: number
|
||||
treeReloadInterval: number
|
||||
}
|
||||
|
||||
export interface Settings {
|
||||
language: string
|
||||
readingMode: ReadingMode
|
||||
readingOrder: ReadingOrder
|
||||
showRead: boolean
|
||||
scrollMarks: boolean
|
||||
customCss?: string
|
||||
customJs?: string
|
||||
scrollSpeed: number
|
||||
alwaysScrollToEntry: boolean
|
||||
markAllAsReadConfirmation: boolean
|
||||
customContextMenu: boolean
|
||||
sharingSettings: SharingSettings
|
||||
}
|
||||
|
||||
export interface SharingSettings {
|
||||
email: boolean
|
||||
gmail: boolean
|
||||
facebook: boolean
|
||||
twitter: boolean
|
||||
tumblr: boolean
|
||||
pocket: boolean
|
||||
instapaper: boolean
|
||||
buffer: boolean
|
||||
}
|
||||
|
||||
export interface StarRequest {
|
||||
id: string
|
||||
feedId: number
|
||||
starred: boolean
|
||||
}
|
||||
|
||||
export interface SubscribeRequest {
|
||||
url: string
|
||||
title: string
|
||||
categoryId?: string
|
||||
}
|
||||
|
||||
export interface Subscription {
|
||||
id: number
|
||||
name: string
|
||||
message?: string
|
||||
errorCount: number
|
||||
lastRefresh?: number
|
||||
nextRefresh?: number
|
||||
feedUrl: string
|
||||
feedLink: string
|
||||
iconUrl: string
|
||||
unread: number
|
||||
categoryId?: string
|
||||
position: number
|
||||
newestItemTime?: number
|
||||
filter?: string
|
||||
}
|
||||
|
||||
export interface TagRequest {
|
||||
entryId: number
|
||||
tags: string[]
|
||||
}
|
||||
|
||||
export interface UnreadCount {
|
||||
feedId?: number
|
||||
unreadCount?: number
|
||||
newestItemTime?: number
|
||||
}
|
||||
|
||||
export interface UserModel {
|
||||
id: number
|
||||
name: string
|
||||
email?: string
|
||||
apiKey?: string
|
||||
password?: string
|
||||
enabled: boolean
|
||||
created: number
|
||||
lastLogin?: number
|
||||
admin: boolean
|
||||
}
|
||||
|
||||
export interface AdminSaveUserRequest {
|
||||
id?: number
|
||||
name: string
|
||||
email?: string
|
||||
password?: string
|
||||
enabled: boolean
|
||||
admin: boolean
|
||||
}
|
||||
|
||||
export type ReadingMode = "all" | "unread"
|
||||
|
||||
export type ReadingOrder = "asc" | "desc"
|
||||
|
||||
export type ViewMode = "title" | "cozy" | "detailed" | "expanded"
|
||||
102
commafeed-client/src/app/user/slice.ts
Normal file
@@ -0,0 +1,102 @@
|
||||
import { t } from "@lingui/macro"
|
||||
import { showNotification } from "@mantine/notifications"
|
||||
import { createSlice, isAnyOf } from "@reduxjs/toolkit"
|
||||
import { type Settings, type UserModel } from "app/types"
|
||||
import {
|
||||
changeAlwaysScrollToEntry,
|
||||
changeCustomContextMenu,
|
||||
changeLanguage,
|
||||
changeMarkAllAsReadConfirmation,
|
||||
changeReadingMode,
|
||||
changeReadingOrder,
|
||||
changeScrollMarks,
|
||||
changeScrollSpeed,
|
||||
changeSharingSetting,
|
||||
changeShowRead,
|
||||
reloadProfile,
|
||||
reloadSettings,
|
||||
reloadTags,
|
||||
} from "./thunks"
|
||||
|
||||
interface UserState {
|
||||
settings?: Settings
|
||||
profile?: UserModel
|
||||
tags?: string[]
|
||||
}
|
||||
|
||||
const initialState: UserState = {}
|
||||
|
||||
export const userSlice = createSlice({
|
||||
name: "user",
|
||||
initialState,
|
||||
reducers: {},
|
||||
extraReducers: builder => {
|
||||
builder.addCase(reloadSettings.fulfilled, (state, action) => {
|
||||
state.settings = action.payload
|
||||
})
|
||||
builder.addCase(reloadProfile.fulfilled, (state, action) => {
|
||||
state.profile = action.payload
|
||||
})
|
||||
builder.addCase(reloadTags.fulfilled, (state, action) => {
|
||||
state.tags = action.payload
|
||||
})
|
||||
builder.addCase(changeReadingMode.pending, (state, action) => {
|
||||
if (!state.settings) return
|
||||
state.settings.readingMode = action.meta.arg
|
||||
})
|
||||
builder.addCase(changeReadingOrder.pending, (state, action) => {
|
||||
if (!state.settings) return
|
||||
state.settings.readingOrder = action.meta.arg
|
||||
})
|
||||
builder.addCase(changeLanguage.pending, (state, action) => {
|
||||
if (!state.settings) return
|
||||
state.settings.language = action.meta.arg
|
||||
})
|
||||
builder.addCase(changeScrollSpeed.pending, (state, action) => {
|
||||
if (!state.settings) return
|
||||
state.settings.scrollSpeed = action.meta.arg ? 400 : 0
|
||||
})
|
||||
builder.addCase(changeShowRead.pending, (state, action) => {
|
||||
if (!state.settings) return
|
||||
state.settings.showRead = action.meta.arg
|
||||
})
|
||||
builder.addCase(changeScrollMarks.pending, (state, action) => {
|
||||
if (!state.settings) return
|
||||
state.settings.scrollMarks = action.meta.arg
|
||||
})
|
||||
builder.addCase(changeAlwaysScrollToEntry.pending, (state, action) => {
|
||||
if (!state.settings) return
|
||||
state.settings.alwaysScrollToEntry = action.meta.arg
|
||||
})
|
||||
builder.addCase(changeMarkAllAsReadConfirmation.pending, (state, action) => {
|
||||
if (!state.settings) return
|
||||
state.settings.markAllAsReadConfirmation = action.meta.arg
|
||||
})
|
||||
builder.addCase(changeCustomContextMenu.pending, (state, action) => {
|
||||
if (!state.settings) return
|
||||
state.settings.customContextMenu = action.meta.arg
|
||||
})
|
||||
builder.addCase(changeSharingSetting.pending, (state, action) => {
|
||||
if (!state.settings) return
|
||||
state.settings.sharingSettings[action.meta.arg.site] = action.meta.arg.value
|
||||
})
|
||||
builder.addMatcher(
|
||||
isAnyOf(
|
||||
changeLanguage.fulfilled,
|
||||
changeScrollSpeed.fulfilled,
|
||||
changeShowRead.fulfilled,
|
||||
changeScrollMarks.fulfilled,
|
||||
changeAlwaysScrollToEntry.fulfilled,
|
||||
changeMarkAllAsReadConfirmation.fulfilled,
|
||||
changeCustomContextMenu.fulfilled,
|
||||
changeSharingSetting.fulfilled
|
||||
),
|
||||
() => {
|
||||
showNotification({
|
||||
message: t`Settings saved.`,
|
||||
color: "green",
|
||||
})
|
||||
}
|
||||
)
|
||||
},
|
||||
})
|
||||
78
commafeed-client/src/app/user/thunks.ts
Normal file
@@ -0,0 +1,78 @@
|
||||
import { createAppAsyncThunk } from "app/async-thunk"
|
||||
import { client } from "app/client"
|
||||
import { reloadEntries } from "app/entries/thunks"
|
||||
import type { ReadingMode, ReadingOrder, SharingSettings } from "app/types"
|
||||
|
||||
export const reloadSettings = createAppAsyncThunk("settings/reload", async () => await client.user.getSettings().then(r => r.data))
|
||||
export const reloadProfile = createAppAsyncThunk("profile/reload", async () => await client.user.getProfile().then(r => r.data))
|
||||
export const reloadTags = createAppAsyncThunk("entries/tags", async () => await client.entry.getTags().then(r => r.data))
|
||||
export const changeReadingMode = createAppAsyncThunk("settings/readingMode", (readingMode: ReadingMode, thunkApi) => {
|
||||
const { settings } = thunkApi.getState().user
|
||||
if (!settings) return
|
||||
client.user.saveSettings({ ...settings, readingMode })
|
||||
thunkApi.dispatch(reloadEntries())
|
||||
})
|
||||
export const changeReadingOrder = createAppAsyncThunk("settings/readingOrder", (readingOrder: ReadingOrder, thunkApi) => {
|
||||
const { settings } = thunkApi.getState().user
|
||||
if (!settings) return
|
||||
client.user.saveSettings({ ...settings, readingOrder })
|
||||
thunkApi.dispatch(reloadEntries())
|
||||
})
|
||||
export const changeLanguage = createAppAsyncThunk("settings/language", (language: string, thunkApi) => {
|
||||
const { settings } = thunkApi.getState().user
|
||||
if (!settings) return
|
||||
client.user.saveSettings({ ...settings, language })
|
||||
})
|
||||
export const changeScrollSpeed = createAppAsyncThunk("settings/scrollSpeed", (speed: boolean, thunkApi) => {
|
||||
const { settings } = thunkApi.getState().user
|
||||
if (!settings) return
|
||||
client.user.saveSettings({ ...settings, scrollSpeed: speed ? 400 : 0 })
|
||||
})
|
||||
export const changeShowRead = createAppAsyncThunk("settings/showRead", (showRead: boolean, thunkApi) => {
|
||||
const { settings } = thunkApi.getState().user
|
||||
if (!settings) return
|
||||
client.user.saveSettings({ ...settings, showRead })
|
||||
})
|
||||
export const changeScrollMarks = createAppAsyncThunk("settings/scrollMarks", (scrollMarks: boolean, thunkApi) => {
|
||||
const { settings } = thunkApi.getState().user
|
||||
if (!settings) return
|
||||
client.user.saveSettings({ ...settings, scrollMarks })
|
||||
})
|
||||
export const changeAlwaysScrollToEntry = createAppAsyncThunk("settings/alwaysScrollToEntry", (alwaysScrollToEntry: boolean, thunkApi) => {
|
||||
const { settings } = thunkApi.getState().user
|
||||
if (!settings) return
|
||||
client.user.saveSettings({ ...settings, alwaysScrollToEntry })
|
||||
})
|
||||
export const changeMarkAllAsReadConfirmation = createAppAsyncThunk(
|
||||
"settings/markAllAsReadConfirmation",
|
||||
(markAllAsReadConfirmation: boolean, thunkApi) => {
|
||||
const { settings } = thunkApi.getState().user
|
||||
if (!settings) return
|
||||
client.user.saveSettings({ ...settings, markAllAsReadConfirmation })
|
||||
}
|
||||
)
|
||||
export const changeCustomContextMenu = createAppAsyncThunk("settings/customContextMenu", (customContextMenu: boolean, thunkApi) => {
|
||||
const { settings } = thunkApi.getState().user
|
||||
if (!settings) return
|
||||
client.user.saveSettings({ ...settings, customContextMenu })
|
||||
})
|
||||
export const changeSharingSetting = createAppAsyncThunk(
|
||||
"settings/sharingSetting",
|
||||
(
|
||||
sharingSetting: {
|
||||
site: keyof SharingSettings
|
||||
value: boolean
|
||||
},
|
||||
thunkApi
|
||||
) => {
|
||||
const { settings } = thunkApi.getState().user
|
||||
if (!settings) return
|
||||
client.user.saveSettings({
|
||||
...settings,
|
||||
sharingSettings: {
|
||||
...settings.sharingSettings,
|
||||
[sharingSetting.site]: sharingSetting.value,
|
||||
},
|
||||
})
|
||||
}
|
||||
)
|
||||
47
commafeed-client/src/app/utils.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
import { throttle } from "throttle-debounce"
|
||||
import { type Category } from "./types"
|
||||
|
||||
export function visitCategoryTree(category: Category, visitor: (category: Category) => void): void {
|
||||
visitor(category)
|
||||
category.children.forEach(child => visitCategoryTree(child, visitor))
|
||||
}
|
||||
|
||||
export function flattenCategoryTree(category: Category): Category[] {
|
||||
const categories: Category[] = []
|
||||
visitCategoryTree(category, c => categories.push(c))
|
||||
return categories
|
||||
}
|
||||
|
||||
export function categoryUnreadCount(category?: Category): number {
|
||||
if (!category) return 0
|
||||
|
||||
return flattenCategoryTree(category)
|
||||
.flatMap(c => c.feeds)
|
||||
.map(f => f.unread)
|
||||
.reduce((total, current) => total + current, 0)
|
||||
}
|
||||
|
||||
export const calculatePlaceholderSize = ({ width, height, maxWidth }: { width?: number; height?: number; maxWidth: number }) => {
|
||||
const placeholderWidth = width && Math.min(width, maxWidth)
|
||||
const placeholderHeight = height && width && width > maxWidth ? height * (maxWidth / width) : height
|
||||
return { width: placeholderWidth, height: placeholderHeight }
|
||||
}
|
||||
|
||||
export const scrollToWithCallback = ({ options, onScrollEnded }: { options: ScrollToOptions; onScrollEnded: () => void }) => {
|
||||
const offset = (options.top ?? 0).toFixed()
|
||||
|
||||
const onScroll = throttle(100, () => {
|
||||
if (window.scrollY.toFixed() === offset) {
|
||||
window.removeEventListener("scroll", onScroll)
|
||||
onScrollEnded()
|
||||
}
|
||||
})
|
||||
window.addEventListener("scroll", onScroll)
|
||||
|
||||
// scrollTo does not trigger if there's nothing to do, trigger it manually
|
||||
onScroll()
|
||||
|
||||
window.scrollTo(options)
|
||||
}
|
||||
|
||||
export const truncate = (str: string, n: number) => (str.length > n ? `${str.slice(0, n - 1)}\u2026` : str)
|
||||
10
commafeed-client/src/assets/logo.svg
Normal file
@@ -0,0 +1,10 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<svg height="512" width="512" viewBox="0 0 6.5625 6.5625" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect fill="#f88a14" rx="0.7" ry="0.7" height="6.5625" width="6.5625" />
|
||||
<path d="m1.9761,1.5289c2.9002,0,2.9002,2.9101,2.9002,2.9101" fill="none" stroke="#FFF" stroke-linecap="round"
|
||||
stroke-width="0.78125" />
|
||||
<path d="m1.9688,2.875c1.5705-0.00908,1.5705,1.5639,1.5705,1.5639" fill="none" stroke="#FFF" stroke-linecap="round"
|
||||
stroke-width="0.78125" />
|
||||
<path d="m2.6503,4.4062c0,0.23366-0.10712,0.47418-0.24663,0.6537-0.1814,0.2333-0.5705,0.5618-0.6913,0.5653,0.0402-0.0662,0.263-0.5654,0.2563-0.5654-0.36423,0-0.6595-0.29265-0.6595-0.65365s0.29527-0.65365,0.6595-0.65365,0.68159,0.29265,0.68159,0.65365z"
|
||||
fill="#FFF" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 791 B |
BIN
commafeed-client/src/assets/welcome_page_dark.png
Normal file
|
After Width: | Height: | Size: 43 KiB |
BIN
commafeed-client/src/assets/welcome_page_light.png
Normal file
|
After Width: | Height: | Size: 44 KiB |
36
commafeed-client/src/components/ActionButton.tsx
Normal file
@@ -0,0 +1,36 @@
|
||||
import { ActionIcon, Button, type ButtonVariant, Tooltip, useMantineTheme } from "@mantine/core"
|
||||
import { type ActionIconVariant } from "@mantine/core/lib/components/ActionIcon/ActionIcon"
|
||||
import { useActionButton } from "hooks/useActionButton"
|
||||
import { forwardRef, type MouseEventHandler, type ReactNode } from "react"
|
||||
|
||||
interface ActionButtonProps {
|
||||
className?: string
|
||||
icon?: ReactNode
|
||||
label: ReactNode
|
||||
onClick?: MouseEventHandler
|
||||
variant?: ActionIconVariant & ButtonVariant
|
||||
hideLabelOnDesktop?: boolean
|
||||
showLabelOnMobile?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Switches between Button with label (desktop) and ActionIcon (mobile)
|
||||
*/
|
||||
export const ActionButton = forwardRef<HTMLButtonElement, ActionButtonProps>((props: ActionButtonProps, ref) => {
|
||||
const { mobile } = useActionButton()
|
||||
const theme = useMantineTheme()
|
||||
const variant = props.variant ?? "subtle"
|
||||
const iconOnly = (mobile && !props.showLabelOnMobile) || (!mobile && props.hideLabelOnDesktop)
|
||||
return iconOnly ? (
|
||||
<Tooltip label={props.label} openDelay={500}>
|
||||
<ActionIcon ref={ref} color={theme.primaryColor} variant={variant} className={props.className} onClick={props.onClick}>
|
||||
{props.icon}
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
) : (
|
||||
<Button ref={ref} variant={variant} size="xs" className={props.className} leftSection={props.icon} onClick={props.onClick}>
|
||||
{props.label}
|
||||
</Button>
|
||||
)
|
||||
})
|
||||
ActionButton.displayName = "HeaderButton"
|
||||
47
commafeed-client/src/components/Alert.tsx
Normal file
@@ -0,0 +1,47 @@
|
||||
import { Trans } from "@lingui/macro"
|
||||
import { Alert as MantineAlert, Box } from "@mantine/core"
|
||||
import { Fragment } from "react"
|
||||
import { TbAlertCircle, TbAlertTriangle, TbCircleCheck } from "react-icons/tb"
|
||||
|
||||
type Level = "error" | "warning" | "success"
|
||||
|
||||
export interface ErrorsAlertProps {
|
||||
level?: Level
|
||||
messages: string[]
|
||||
}
|
||||
|
||||
export function Alert(props: ErrorsAlertProps) {
|
||||
let title: React.ReactNode
|
||||
let color: string
|
||||
let icon: React.ReactNode
|
||||
|
||||
const level = props.level ?? "error"
|
||||
switch (level) {
|
||||
case "error":
|
||||
title = <Trans>Error</Trans>
|
||||
color = "red"
|
||||
icon = <TbAlertCircle />
|
||||
break
|
||||
case "warning":
|
||||
title = <Trans>Warning</Trans>
|
||||
color = "orange"
|
||||
icon = <TbAlertTriangle />
|
||||
break
|
||||
case "success":
|
||||
title = <Trans>Success</Trans>
|
||||
color = "green"
|
||||
icon = <TbCircleCheck />
|
||||
break
|
||||
}
|
||||
|
||||
return (
|
||||
<MantineAlert title={title} color={color} icon={icon}>
|
||||
{props.messages.map((m, i) => (
|
||||
<Fragment key={m}>
|
||||
<Box>{m}</Box>
|
||||
{i !== props.messages.length - 1 && <br />}
|
||||
</Fragment>
|
||||
))}
|
||||
</MantineAlert>
|
||||
)
|
||||
}
|
||||
36
commafeed-client/src/components/AnnouncementDialog.tsx
Normal file
@@ -0,0 +1,36 @@
|
||||
import { Trans } from "@lingui/macro"
|
||||
import { Box, Dialog, Text } from "@mantine/core"
|
||||
import { useAppSelector } from "app/store"
|
||||
import { Content } from "components/content/Content"
|
||||
import { useAsync } from "react-async-hook"
|
||||
import useLocalStorage from "use-local-storage"
|
||||
|
||||
const sha256Hex = async (input: string | undefined) => {
|
||||
const data = new TextEncoder().encode(input)
|
||||
const buffer = await crypto.subtle.digest("SHA-256", data)
|
||||
const array = Array.from(new Uint8Array(buffer))
|
||||
return array.map(b => b.toString(16).padStart(2, "0")).join("")
|
||||
}
|
||||
|
||||
export function AnnouncementDialog() {
|
||||
const announcement = useAppSelector(state => state.server.serverInfos?.announcement)
|
||||
const announcementHash = useAsync(sha256Hex, [announcement]).result
|
||||
const [localStorageHash, setLocalStorageHash] = useLocalStorage("announcement-hash", "no-hash")
|
||||
|
||||
const opened = !!announcementHash && announcementHash !== localStorageHash
|
||||
const onClosed = () => setLocalStorageHash(announcementHash)
|
||||
|
||||
if (!announcement) return null
|
||||
return (
|
||||
<Dialog opened={opened} withCloseButton onClose={onClosed} size="xl" radius="md">
|
||||
<Box>
|
||||
<Text fw="bold">
|
||||
<Trans>Announcement</Trans>
|
||||
</Text>
|
||||
</Box>
|
||||
<Box>
|
||||
<Content content={announcement} />
|
||||
</Box>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
26
commafeed-client/src/components/ErrorBoundary.tsx
Normal file
@@ -0,0 +1,26 @@
|
||||
import { ErrorPage } from "pages/ErrorPage"
|
||||
import React, { type ReactNode } from "react"
|
||||
|
||||
interface ErrorBoundaryProps {
|
||||
children?: ReactNode
|
||||
}
|
||||
|
||||
interface ErrorBoundaryState {
|
||||
error?: Error
|
||||
}
|
||||
|
||||
export class ErrorBoundary extends React.Component<ErrorBoundaryProps, ErrorBoundaryState> {
|
||||
constructor(props: ErrorBoundaryProps) {
|
||||
super(props)
|
||||
this.state = {}
|
||||
}
|
||||
|
||||
componentDidCatch(error: Error) {
|
||||
this.setState({ error })
|
||||
}
|
||||
|
||||
render() {
|
||||
if (this.state.error) return <ErrorPage error={this.state.error} />
|
||||
return this.props.children
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
import { Box, Center, type MantineTheme, useMantineTheme } from "@mantine/core"
|
||||
import { useColorScheme } from "hooks/useColorScheme"
|
||||
import { useState } from "react"
|
||||
import { TbPhoto } from "react-icons/tb"
|
||||
import { tss } from "tss"
|
||||
|
||||
interface ImageWithPlaceholderWhileLoadingProps {
|
||||
src: string
|
||||
alt: string
|
||||
title?: string
|
||||
width?: number
|
||||
height?: number | "auto"
|
||||
placeholderWidth?: number
|
||||
placeholderHeight?: number
|
||||
placeholderBackgroundColor?: string
|
||||
placeholderIconSize?: number
|
||||
}
|
||||
|
||||
const useStyles = tss
|
||||
.withParams<{
|
||||
theme: MantineTheme
|
||||
colorScheme: "light" | "dark"
|
||||
placeholderWidth?: number
|
||||
placeholderHeight?: number
|
||||
placeholderBackgroundColor?: string
|
||||
}>()
|
||||
.create(props => ({
|
||||
placeholder: {
|
||||
width: props.placeholderWidth ?? 400,
|
||||
height: props.placeholderHeight ?? 600,
|
||||
maxWidth: "100%",
|
||||
backgroundColor:
|
||||
props.placeholderBackgroundColor ??
|
||||
(props.colorScheme === "dark" ? props.theme.colors.dark[5] : props.theme.colors.gray[1]),
|
||||
},
|
||||
}))
|
||||
|
||||
export function ImageWithPlaceholderWhileLoading({
|
||||
alt,
|
||||
height,
|
||||
placeholderBackgroundColor,
|
||||
placeholderHeight,
|
||||
placeholderIconSize,
|
||||
placeholderWidth,
|
||||
src,
|
||||
title,
|
||||
width,
|
||||
}: ImageWithPlaceholderWhileLoadingProps) {
|
||||
const theme = useMantineTheme()
|
||||
const colorScheme = useColorScheme()
|
||||
const { classes } = useStyles({
|
||||
theme,
|
||||
colorScheme,
|
||||
placeholderWidth,
|
||||
placeholderHeight,
|
||||
placeholderBackgroundColor,
|
||||
})
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
return (
|
||||
<>
|
||||
{loading && (
|
||||
<Box>
|
||||
<Center className={classes.placeholder}>
|
||||
<div>
|
||||
<TbPhoto size={placeholderIconSize ?? 48} />
|
||||
</div>
|
||||
</Center>
|
||||
</Box>
|
||||
)}
|
||||
<img
|
||||
src={src}
|
||||
alt={alt}
|
||||
title={title}
|
||||
width={width}
|
||||
height={height}
|
||||
onLoad={() => setLoading(false)}
|
||||
style={{ display: loading ? "none" : "block" }}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
222
commafeed-client/src/components/KeyboardShortcutsHelp.tsx
Normal file
@@ -0,0 +1,222 @@
|
||||
import { Trans } from "@lingui/macro"
|
||||
import { Anchor, Box, Kbd, Stack, Table } from "@mantine/core"
|
||||
import { Constants } from "app/constants"
|
||||
|
||||
export function KeyboardShortcutsHelp() {
|
||||
return (
|
||||
<Stack gap="xs">
|
||||
<Table striped highlightOnHover>
|
||||
<Table.Tbody>
|
||||
<Table.Tr>
|
||||
<Table.Td>
|
||||
<Trans>Refresh</Trans>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Kbd>R</Kbd>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
<Table.Tr>
|
||||
<Table.Td>
|
||||
<Trans>Open next entry</Trans>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Kbd>J</Kbd>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
<Table.Tr>
|
||||
<Table.Td>
|
||||
<Trans>Open previous entry</Trans>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Kbd>K</Kbd>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
<Table.Tr>
|
||||
<Table.Td>
|
||||
<Trans>Set focus on next entry without opening it</Trans>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Kbd>N</Kbd>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
<Table.Tr>
|
||||
<Table.Td>
|
||||
<Trans>Set focus on previous entry without opening it</Trans>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Kbd>P</Kbd>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
<Table.Tr>
|
||||
<Table.Td>
|
||||
<Trans>Move the page down</Trans>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Kbd>
|
||||
<Trans>Space</Trans>
|
||||
</Kbd>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
<Table.Tr>
|
||||
<Table.Td>
|
||||
<Trans>Move the page up</Trans>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Kbd>
|
||||
<Trans>Shift</Trans>
|
||||
</Kbd>
|
||||
<span> + </span>
|
||||
<Kbd>
|
||||
<Trans>Space</Trans>
|
||||
</Kbd>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
<Table.Tr>
|
||||
<Table.Td>
|
||||
<Trans>Open/close current entry</Trans>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Kbd>O</Kbd>
|
||||
<span>, </span>
|
||||
<Kbd>
|
||||
<Trans>Enter</Trans>
|
||||
</Kbd>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
<Table.Tr>
|
||||
<Table.Td>
|
||||
<Trans>Open current entry in a new tab</Trans>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Kbd>V</Kbd>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
<Table.Tr>
|
||||
<Table.Td>
|
||||
<Trans>Open current entry in a new tab in the background</Trans>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Kbd>B</Kbd>
|
||||
<span>*, </span>
|
||||
<Kbd>
|
||||
<Trans>Middle click</Trans>
|
||||
</Kbd>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
<Table.Tr>
|
||||
<Table.Td>
|
||||
<Trans>Toggle read status of current entry</Trans>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Kbd>M</Kbd>
|
||||
<span>, </span>
|
||||
<Trans>Swipe header to the left</Trans>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
<Table.Tr>
|
||||
<Table.Td>
|
||||
<Trans>Toggle starred status of current entry</Trans>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Kbd>S</Kbd>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
<Table.Tr>
|
||||
<Table.Td>
|
||||
<Trans>Mark all entries as read</Trans>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Kbd>
|
||||
<Trans>Shift</Trans>
|
||||
</Kbd>
|
||||
<span> + </span>
|
||||
<Kbd>A</Kbd>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
<Table.Tr>
|
||||
<Table.Td>
|
||||
<Trans>Go to the All view</Trans>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Kbd>G</Kbd>
|
||||
<span> </span>
|
||||
<Kbd>A</Kbd>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
<Table.Tr>
|
||||
<Table.Td>
|
||||
<Trans>Navigate to a subscription by entering its name</Trans>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Kbd>
|
||||
<Trans>Ctrl</Trans>
|
||||
</Kbd>
|
||||
<span> + </span>
|
||||
<Kbd>K</Kbd>
|
||||
<span>, </span>
|
||||
<Kbd>G</Kbd>
|
||||
<span> </span>
|
||||
<Kbd>U</Kbd>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
<Table.Tr>
|
||||
<Table.Td>
|
||||
<Trans>Show entry menu (desktop)</Trans>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Kbd>
|
||||
<Trans>Right click</Trans>
|
||||
</Kbd>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
<Table.Tr>
|
||||
<Table.Td>
|
||||
<Trans>Show native menu (desktop)</Trans>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Kbd>
|
||||
<Trans>Shift</Trans>
|
||||
</Kbd>
|
||||
<span> + </span>
|
||||
<Kbd>
|
||||
<Trans>Right click</Trans>
|
||||
</Kbd>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
<Table.Tr>
|
||||
<Table.Td>
|
||||
<Trans>Show entry menu (mobile)</Trans>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Kbd>
|
||||
<Trans>Long press</Trans>
|
||||
</Kbd>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
<Table.Tr>
|
||||
<Table.Td>
|
||||
<Trans>Toggle sidebar</Trans>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Kbd>F</Kbd>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
<Table.Tr>
|
||||
<Table.Td>
|
||||
<Trans>Show keyboard shortcut help</Trans>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Kbd>?</Kbd>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
<Box>
|
||||
<span>* </span>
|
||||
<Anchor href={Constants.browserExtensionUrl} target="_blank" rel="noreferrer">
|
||||
<Trans>Browser extension required for Chrome</Trans>
|
||||
</Anchor>
|
||||
</Box>
|
||||
</Stack>
|
||||
)
|
||||
}
|
||||
9
commafeed-client/src/components/Loader.tsx
Normal file
@@ -0,0 +1,9 @@
|
||||
import { Center, Loader as MantineLoader } from "@mantine/core"
|
||||
|
||||
export function Loader() {
|
||||
return (
|
||||
<Center>
|
||||
<MantineLoader size="lg" type="bars" />
|
||||
</Center>
|
||||
)
|
||||
}
|
||||
10
commafeed-client/src/components/Logo.tsx
Normal file
@@ -0,0 +1,10 @@
|
||||
import { Image } from "@mantine/core"
|
||||
import logo from "assets/logo.svg"
|
||||
|
||||
export interface LogoProps {
|
||||
size: number
|
||||
}
|
||||
|
||||
export function Logo(props: LogoProps) {
|
||||
return <Image src={logo} w={props.size} />
|
||||
}
|
||||
20
commafeed-client/src/components/RelativeDate.tsx
Normal file
@@ -0,0 +1,20 @@
|
||||
import { Trans } from "@lingui/macro"
|
||||
import { Tooltip } from "@mantine/core"
|
||||
import dayjs from "dayjs"
|
||||
import { useEffect, useState } from "react"
|
||||
|
||||
export function RelativeDate(props: { date: Date | number | undefined }) {
|
||||
const [now, setNow] = useState(new Date())
|
||||
useEffect(() => {
|
||||
const interval = setInterval(() => setNow(new Date()), 60 * 1000)
|
||||
return () => clearInterval(interval)
|
||||
}, [])
|
||||
|
||||
if (!props.date) return <Trans>N/A</Trans>
|
||||
const date = dayjs(props.date)
|
||||
return (
|
||||
<Tooltip label={date.toDate().toLocaleString()} openDelay={500}>
|
||||
<span>{date.from(dayjs(now))}</span>
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
54
commafeed-client/src/components/admin/UserEdit.tsx
Normal file
@@ -0,0 +1,54 @@
|
||||
import { Trans } from "@lingui/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, type UserModel } from "app/types"
|
||||
import { Alert } from "components/Alert"
|
||||
import { useAsyncCallback } from "react-async-hook"
|
||||
import { TbDeviceFloppy } from "react-icons/tb"
|
||||
|
||||
interface UserEditProps {
|
||||
user?: UserModel
|
||||
onCancel: () => void
|
||||
onSave: () => void
|
||||
}
|
||||
|
||||
export function UserEdit(props: UserEditProps) {
|
||||
const form = useForm<AdminSaveUserRequest>({
|
||||
initialValues: props.user ?? {
|
||||
name: "",
|
||||
enabled: true,
|
||||
admin: false,
|
||||
},
|
||||
})
|
||||
const saveUser = useAsyncCallback(client.admin.saveUser, { onSuccess: props.onSave })
|
||||
|
||||
return (
|
||||
<>
|
||||
{saveUser.error && (
|
||||
<Box mb="md">
|
||||
<Alert messages={errorToStrings(saveUser.error)} />
|
||||
</Box>
|
||||
)}
|
||||
|
||||
<form onSubmit={form.onSubmit(saveUser.execute)}>
|
||||
<Stack>
|
||||
<TextInput label={<Trans>Name</Trans>} {...form.getInputProps("name")} required />
|
||||
<PasswordInput label={<Trans>Password</Trans>} {...form.getInputProps("password")} required={!props.user} />
|
||||
<TextInput type="email" label={<Trans>E-mail</Trans>} {...form.getInputProps("email")} />
|
||||
<Checkbox label={<Trans>Admin</Trans>} {...form.getInputProps("admin", { type: "checkbox" })} />
|
||||
<Checkbox label={<Trans>Enabled</Trans>} {...form.getInputProps("enabled", { type: "checkbox" })} />
|
||||
|
||||
<Group justify="right">
|
||||
<Button variant="default" onClick={props.onCancel}>
|
||||
<Trans>Cancel</Trans>
|
||||
</Button>
|
||||
<Button type="submit" leftSection={<TbDeviceFloppy size={16} />} loading={saveUser.loading}>
|
||||
<Trans>Save</Trans>
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</form>
|
||||
</>
|
||||
)
|
||||
}
|
||||
36
commafeed-client/src/components/code/CodeEditor.tsx
Normal file
@@ -0,0 +1,36 @@
|
||||
import { Input, Textarea } from "@mantine/core"
|
||||
import RichCodeEditor from "components/code/RichCodeEditor"
|
||||
import { useMobile } from "hooks/useMobile"
|
||||
import { type ReactNode } from "react"
|
||||
|
||||
interface CodeEditorProps {
|
||||
description?: ReactNode
|
||||
language: "css" | "javascript"
|
||||
value: string
|
||||
onChange: (value: string | undefined) => void
|
||||
}
|
||||
|
||||
export function CodeEditor(props: CodeEditorProps) {
|
||||
const mobile = useMobile()
|
||||
|
||||
return mobile ? (
|
||||
// monaco mobile support is poor, fallback to textarea
|
||||
<Textarea
|
||||
autosize
|
||||
minRows={4}
|
||||
maxRows={15}
|
||||
description={props.description}
|
||||
styles={{
|
||||
input: {
|
||||
fontFamily: "monospace",
|
||||
},
|
||||
}}
|
||||
value={props.value}
|
||||
onChange={e => props.onChange(e.currentTarget.value)}
|
||||
/>
|
||||
) : (
|
||||
<Input.Wrapper description={props.description}>
|
||||
<RichCodeEditor height="30vh" language={props.language} value={props.value} onChange={props.onChange} />
|
||||
</Input.Wrapper>
|
||||
)
|
||||
}
|
||||
52
commafeed-client/src/components/code/RichCodeEditor.tsx
Normal file
@@ -0,0 +1,52 @@
|
||||
import { Loader } from "components/Loader"
|
||||
import { useColorScheme } from "hooks/useColorScheme"
|
||||
import { useAsync } from "react-async-hook"
|
||||
|
||||
const init = async () => {
|
||||
window.MonacoEnvironment = {
|
||||
async getWorker(_, label) {
|
||||
let worker
|
||||
if (label === "css") {
|
||||
worker = await import("monaco-editor/esm/vs/language/css/css.worker?worker")
|
||||
} else if (label === "javascript") {
|
||||
worker = await import("monaco-editor/esm/vs/language/typescript/ts.worker?worker")
|
||||
} else {
|
||||
worker = await import("monaco-editor/esm/vs/editor/editor.worker?worker")
|
||||
}
|
||||
// eslint-disable-next-line new-cap
|
||||
return new worker.default()
|
||||
},
|
||||
}
|
||||
|
||||
const monacoReact = await import("@monaco-editor/react")
|
||||
const monaco = await import("monaco-editor")
|
||||
monacoReact.loader.config({ monaco })
|
||||
return monacoReact.Editor
|
||||
}
|
||||
|
||||
interface RichCodeEditorProps {
|
||||
height: number | string
|
||||
language: "css" | "javascript"
|
||||
value: string
|
||||
onChange: (value: string | undefined) => void
|
||||
}
|
||||
|
||||
function RichCodeEditor(props: RichCodeEditorProps) {
|
||||
const colorScheme = useColorScheme()
|
||||
const editorTheme = colorScheme === "dark" ? "vs-dark" : "light"
|
||||
|
||||
const { result: Editor } = useAsync(init, [])
|
||||
if (!Editor) return <Loader />
|
||||
return (
|
||||
<Editor
|
||||
height={props.height}
|
||||
defaultLanguage={props.language}
|
||||
theme={editorTheme}
|
||||
options={{ minimap: { enabled: false } }}
|
||||
value={props.value}
|
||||
onChange={props.onChange}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export default RichCodeEditor
|
||||
11
commafeed-client/src/components/content/BasicHtmlStyles.tsx
Normal file
@@ -0,0 +1,11 @@
|
||||
import { TypographyStylesProvider } from "@mantine/core"
|
||||
import { type ReactNode } from "react"
|
||||
|
||||
/**
|
||||
* This component is used to provide basic styles to html typography elements.
|
||||
*
|
||||
* see https://mantine.dev/core/typography-styles-provider/
|
||||
*/
|
||||
export const BasicHtmlStyles = (props: { children: ReactNode }) => {
|
||||
return <TypographyStylesProvider pl={0}>{props.children}</TypographyStylesProvider>
|
||||
}
|
||||
103
commafeed-client/src/components/content/Content.tsx
Normal file
@@ -0,0 +1,103 @@
|
||||
import { Box, Mark } from "@mantine/core"
|
||||
import { Constants } from "app/constants"
|
||||
import { calculatePlaceholderSize } from "app/utils"
|
||||
import { BasicHtmlStyles } from "components/content/BasicHtmlStyles"
|
||||
import { ImageWithPlaceholderWhileLoading } from "components/ImageWithPlaceholderWhileLoading"
|
||||
import escapeStringRegexp from "escape-string-regexp"
|
||||
import { type ChildrenNode, Interweave, Matcher, type MatchResponse, type Node, type TransformCallback } from "interweave"
|
||||
import React from "react"
|
||||
import { tss } from "tss"
|
||||
|
||||
export interface ContentProps {
|
||||
content: string
|
||||
highlight?: string
|
||||
}
|
||||
|
||||
const useStyles = tss.create(() => ({
|
||||
content: {
|
||||
// break long links or long words
|
||||
overflowWrap: "anywhere",
|
||||
"& a": {
|
||||
color: "inherit",
|
||||
textDecoration: "underline",
|
||||
},
|
||||
"& iframe": {
|
||||
maxWidth: "100%",
|
||||
},
|
||||
"& pre, & code": {
|
||||
whiteSpace: "pre-wrap",
|
||||
},
|
||||
},
|
||||
}))
|
||||
|
||||
const transform: TransformCallback = node => {
|
||||
if (node.tagName === "IMG") {
|
||||
// show placeholders for loading img tags, this allows the entry to have its final height immediately
|
||||
const src = node.getAttribute("src") ?? undefined
|
||||
if (!src) return undefined
|
||||
|
||||
const alt = node.getAttribute("alt") ?? "image"
|
||||
const title = node.getAttribute("title") ?? undefined
|
||||
const nodeWidth = node.getAttribute("width")
|
||||
const nodeHeight = node.getAttribute("height")
|
||||
const width = nodeWidth ? parseInt(nodeWidth, 10) : undefined
|
||||
const height = nodeHeight ? parseInt(nodeHeight, 10) : undefined
|
||||
const placeholderSize = calculatePlaceholderSize({
|
||||
width,
|
||||
height,
|
||||
maxWidth: Constants.layout.entryMaxWidth,
|
||||
})
|
||||
|
||||
return (
|
||||
<ImageWithPlaceholderWhileLoading
|
||||
src={src}
|
||||
alt={alt}
|
||||
title={title}
|
||||
width={width}
|
||||
height="auto"
|
||||
placeholderWidth={placeholderSize.width}
|
||||
placeholderHeight={placeholderSize.height}
|
||||
/>
|
||||
)
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
class HighlightMatcher extends Matcher {
|
||||
private readonly search: string
|
||||
|
||||
constructor(search: string) {
|
||||
super("highlight")
|
||||
this.search = escapeStringRegexp(search)
|
||||
}
|
||||
|
||||
match(string: string): MatchResponse<unknown> | null {
|
||||
const pattern = this.search.split(" ").join("|")
|
||||
return this.doMatch(string, new RegExp(pattern, "i"), () => ({}))
|
||||
}
|
||||
|
||||
replaceWith(children: ChildrenNode, props: unknown): Node {
|
||||
return <Mark>{children}</Mark>
|
||||
}
|
||||
|
||||
asTag(): string {
|
||||
return "span"
|
||||
}
|
||||
}
|
||||
|
||||
// memoize component because Interweave is costly
|
||||
const Content = React.memo((props: ContentProps) => {
|
||||
const { classes } = useStyles()
|
||||
const matchers = props.highlight ? [new HighlightMatcher(props.highlight)] : []
|
||||
|
||||
return (
|
||||
<BasicHtmlStyles>
|
||||
<Box className={classes.content}>
|
||||
<Interweave content={props.content} transform={transform} matchers={matchers} />
|
||||
</Box>
|
||||
</BasicHtmlStyles>
|
||||
)
|
||||
})
|
||||
Content.displayName = "Content"
|
||||
|
||||
export { Content }
|
||||
24
commafeed-client/src/components/content/Enclosure.tsx
Normal file
@@ -0,0 +1,24 @@
|
||||
import { BasicHtmlStyles } from "components/content/BasicHtmlStyles"
|
||||
import { ImageWithPlaceholderWhileLoading } from "components/ImageWithPlaceholderWhileLoading"
|
||||
|
||||
export function Enclosure(props: { enclosureType: string; enclosureUrl: string }) {
|
||||
const hasVideo = props.enclosureType?.startsWith("video")
|
||||
const hasAudio = props.enclosureType?.startsWith("audio")
|
||||
const hasImage = props.enclosureType?.startsWith("image")
|
||||
|
||||
return (
|
||||
<BasicHtmlStyles>
|
||||
{hasVideo && (
|
||||
<video controls>
|
||||
<source src={props.enclosureUrl} type={props.enclosureType} />
|
||||
</video>
|
||||
)}
|
||||
{hasAudio && (
|
||||
<audio controls>
|
||||
<source src={props.enclosureUrl} type={props.enclosureType} />
|
||||
</audio>
|
||||
)}
|
||||
{hasImage && <ImageWithPlaceholderWhileLoading src={props.enclosureUrl} alt="enclosure" />}
|
||||
</BasicHtmlStyles>
|
||||
)
|
||||
}
|
||||
329
commafeed-client/src/components/content/FeedEntries.tsx
Normal file
@@ -0,0 +1,329 @@
|
||||
import { Trans } from "@lingui/macro"
|
||||
import { Box } from "@mantine/core"
|
||||
import { openModal } from "@mantine/modals"
|
||||
import { Constants } from "app/constants"
|
||||
import { type ExpendableEntry } from "app/entries/slice"
|
||||
import {
|
||||
loadMoreEntries,
|
||||
markAllEntries,
|
||||
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 { useViewMode } from "hooks/useViewMode"
|
||||
import { useEffect } from "react"
|
||||
import { useContextMenu } from "react-contexify"
|
||||
import InfiniteScroll from "react-infinite-scroller"
|
||||
import { throttle } from "throttle-debounce"
|
||||
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)
|
||||
const scrollMarks = useAppSelector(state => state.user.settings?.scrollMarks)
|
||||
const scrollingToEntry = useAppSelector(state => state.entries.scrollingToEntry)
|
||||
const sidebarVisible = useAppSelector(state => state.tree.sidebarVisible)
|
||||
const customContextMenu = useAppSelector(state => state.user.settings?.customContextMenu)
|
||||
const { viewMode } = useViewMode()
|
||||
const dispatch = useAppDispatch()
|
||||
const { openLinkInBackgroundTab } = useBrowserExtension()
|
||||
|
||||
const selectedEntry = entries.find(e => e.id === selectedEntryId)
|
||||
|
||||
const headerClicked = (entry: ExpendableEntry, event: React.MouseEvent) => {
|
||||
const middleClick = event.button === 1 || event.ctrlKey || event.metaKey
|
||||
if (middleClick || viewMode === "expanded") {
|
||||
dispatch(markEntry({ entry, read: true }))
|
||||
} else if (event.button === 0) {
|
||||
// main click
|
||||
// don't trigger the link
|
||||
event.preventDefault()
|
||||
|
||||
dispatch(
|
||||
selectEntry({
|
||||
entry,
|
||||
expand: !entry.expanded,
|
||||
markAsRead: !entry.expanded,
|
||||
scrollToEntry: true,
|
||||
})
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const contextMenu = useContextMenu()
|
||||
const headerRightClicked = (entry: ExpendableEntry, event: React.MouseEvent) => {
|
||||
if (event.shiftKey || !customContextMenu) return
|
||||
|
||||
event.preventDefault()
|
||||
contextMenu.show({
|
||||
id: Constants.dom.entryContextMenuId(entry),
|
||||
event,
|
||||
})
|
||||
}
|
||||
|
||||
const bodyClicked = (entry: ExpendableEntry) => {
|
||||
if (viewMode !== "expanded") return
|
||||
|
||||
// entry is already selected
|
||||
if (entry.id === selectedEntryId) return
|
||||
|
||||
dispatch(
|
||||
selectEntry({
|
||||
entry,
|
||||
expand: true,
|
||||
markAsRead: true,
|
||||
scrollToEntry: true,
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
const swipedLeft = async (entry: ExpendableEntry) => await dispatch(markEntry({ entry, read: !entry.read }))
|
||||
|
||||
// close context menu on scroll
|
||||
useEffect(() => {
|
||||
const listener = throttle(100, () => contextMenu.hideAll())
|
||||
window.addEventListener("scroll", listener)
|
||||
return () => window.removeEventListener("scroll", listener)
|
||||
}, [contextMenu])
|
||||
|
||||
useEffect(() => {
|
||||
const listener = throttle(100, () => {
|
||||
if (viewMode !== "expanded") return
|
||||
if (scrollingToEntry) return
|
||||
|
||||
const currentEntry = entries
|
||||
// use slice to get a copy of the array because reverse mutates the array in-place
|
||||
.slice()
|
||||
.reverse()
|
||||
.find(e => {
|
||||
const el = document.getElementById(Constants.dom.entryId(e))
|
||||
return el && !Constants.layout.isTopVisible(el)
|
||||
})
|
||||
if (currentEntry) {
|
||||
dispatch(
|
||||
selectEntry({
|
||||
entry: currentEntry,
|
||||
expand: false,
|
||||
markAsRead: !!scrollMarks,
|
||||
scrollToEntry: false,
|
||||
})
|
||||
)
|
||||
}
|
||||
})
|
||||
window.addEventListener("scroll", listener)
|
||||
return () => window.removeEventListener("scroll", listener)
|
||||
}, [dispatch, contextMenu, entries, viewMode, scrollMarks, scrollingToEntry])
|
||||
|
||||
useMousetrap("r", async () => await dispatch(reloadEntries()))
|
||||
useMousetrap(
|
||||
"j",
|
||||
async () =>
|
||||
await dispatch(
|
||||
selectNextEntry({
|
||||
expand: true,
|
||||
markAsRead: true,
|
||||
scrollToEntry: true,
|
||||
})
|
||||
)
|
||||
)
|
||||
useMousetrap(
|
||||
"n",
|
||||
async () =>
|
||||
await dispatch(
|
||||
selectNextEntry({
|
||||
expand: false,
|
||||
markAsRead: false,
|
||||
scrollToEntry: true,
|
||||
})
|
||||
)
|
||||
)
|
||||
useMousetrap(
|
||||
"k",
|
||||
async () =>
|
||||
await dispatch(
|
||||
selectPreviousEntry({
|
||||
expand: true,
|
||||
markAsRead: true,
|
||||
scrollToEntry: true,
|
||||
})
|
||||
)
|
||||
)
|
||||
useMousetrap(
|
||||
"p",
|
||||
async () =>
|
||||
await dispatch(
|
||||
selectPreviousEntry({
|
||||
expand: false,
|
||||
markAsRead: false,
|
||||
scrollToEntry: true,
|
||||
})
|
||||
)
|
||||
)
|
||||
useMousetrap("space", () => {
|
||||
if (selectedEntry) {
|
||||
if (selectedEntry.expanded) {
|
||||
const entryElement = document.getElementById(Constants.dom.entryId(selectedEntry))
|
||||
if (entryElement && Constants.layout.isBottomVisible(entryElement)) {
|
||||
dispatch(
|
||||
selectNextEntry({
|
||||
expand: true,
|
||||
markAsRead: true,
|
||||
scrollToEntry: true,
|
||||
})
|
||||
)
|
||||
} else {
|
||||
window.scrollTo({
|
||||
top: window.scrollY + document.documentElement.clientHeight * 0.8,
|
||||
behavior: "smooth",
|
||||
})
|
||||
}
|
||||
} else {
|
||||
dispatch(
|
||||
selectEntry({
|
||||
entry: selectedEntry,
|
||||
expand: true,
|
||||
markAsRead: true,
|
||||
scrollToEntry: true,
|
||||
})
|
||||
)
|
||||
}
|
||||
} else {
|
||||
dispatch(
|
||||
selectNextEntry({
|
||||
expand: true,
|
||||
markAsRead: true,
|
||||
scrollToEntry: true,
|
||||
})
|
||||
)
|
||||
}
|
||||
})
|
||||
useMousetrap("shift+space", () => {
|
||||
if (selectedEntry) {
|
||||
if (selectedEntry.expanded) {
|
||||
const entryElement = document.getElementById(Constants.dom.entryId(selectedEntry))
|
||||
if (entryElement && Constants.layout.isTopVisible(entryElement)) {
|
||||
dispatch(
|
||||
selectPreviousEntry({
|
||||
expand: true,
|
||||
markAsRead: true,
|
||||
scrollToEntry: true,
|
||||
})
|
||||
)
|
||||
} else {
|
||||
window.scrollTo({
|
||||
top: window.scrollY - document.documentElement.clientHeight * 0.8,
|
||||
behavior: "smooth",
|
||||
})
|
||||
}
|
||||
} else {
|
||||
dispatch(
|
||||
selectPreviousEntry({
|
||||
expand: true,
|
||||
markAsRead: true,
|
||||
scrollToEntry: true,
|
||||
})
|
||||
)
|
||||
}
|
||||
}
|
||||
})
|
||||
useMousetrap(["o", "enter"], () => {
|
||||
// toggle expanded status
|
||||
if (!selectedEntry) return
|
||||
dispatch(
|
||||
selectEntry({
|
||||
entry: selectedEntry,
|
||||
expand: !selectedEntry.expanded,
|
||||
markAsRead: !selectedEntry.expanded,
|
||||
scrollToEntry: true,
|
||||
})
|
||||
)
|
||||
})
|
||||
useMousetrap("v", () => {
|
||||
// open tab in foreground
|
||||
if (!selectedEntry) return
|
||||
window.open(selectedEntry.url, "_blank", "noreferrer")
|
||||
})
|
||||
useMousetrap("b", () => {
|
||||
if (!selectedEntry) return
|
||||
openLinkInBackgroundTab(selectedEntry.url)
|
||||
})
|
||||
useMousetrap("m", () => {
|
||||
// toggle read status
|
||||
if (!selectedEntry) return
|
||||
dispatch(markEntry({ entry: selectedEntry, read: !selectedEntry.read }))
|
||||
})
|
||||
useMousetrap("s", () => {
|
||||
// toggle starred status
|
||||
if (!selectedEntry) return
|
||||
dispatch(starEntry({ entry: selectedEntry, starred: !selectedEntry.starred }))
|
||||
})
|
||||
useMousetrap("shift+a", () => {
|
||||
// mark all entries as read
|
||||
dispatch(
|
||||
markAllEntries({
|
||||
sourceType: source.type,
|
||||
req: {
|
||||
id: source.id,
|
||||
read: true,
|
||||
olderThan: Date.now(),
|
||||
insertedBefore: entriesTimestamp,
|
||||
},
|
||||
})
|
||||
)
|
||||
})
|
||||
useMousetrap("g a", async () => await dispatch(redirectToRootCategory()))
|
||||
useMousetrap("f", () => dispatch(toggleSidebar()))
|
||||
useMousetrap("?", () =>
|
||||
openModal({
|
||||
title: <Trans>Keyboard shortcuts</Trans>,
|
||||
size: "xl",
|
||||
children: <KeyboardShortcutsHelp />,
|
||||
})
|
||||
)
|
||||
|
||||
if (!entries) return <Loader />
|
||||
return (
|
||||
<InfiniteScroll
|
||||
id="entries"
|
||||
initialLoad={false}
|
||||
loadMore={async () => await (!loading && dispatch(loadMoreEntries()))}
|
||||
hasMore={hasMore}
|
||||
loader={<Box key={0}>{loading && <Loader />}</Box>}
|
||||
>
|
||||
{entries.map(entry => (
|
||||
<div
|
||||
key={entry.id}
|
||||
ref={el => {
|
||||
if (el) el.id = Constants.dom.entryId(entry)
|
||||
}}
|
||||
>
|
||||
<FeedEntry
|
||||
entry={entry}
|
||||
expanded={!!entry.expanded || viewMode === "expanded"}
|
||||
selected={entry.id === selectedEntryId}
|
||||
showSelectionIndicator={entry.id === selectedEntryId && (!entry.expanded || viewMode === "expanded")}
|
||||
maxWidth={sidebarVisible ? Constants.layout.entryMaxWidth : undefined}
|
||||
onHeaderClick={event => headerClicked(entry, event)}
|
||||
onHeaderRightClick={event => headerRightClicked(entry, event)}
|
||||
onBodyClick={() => bodyClicked(entry)}
|
||||
onSwipedLeft={async () => await swipedLeft(entry)}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</InfiniteScroll>
|
||||
)
|
||||
}
|
||||
174
commafeed-client/src/components/content/FeedEntry.tsx
Normal file
@@ -0,0 +1,174 @@
|
||||
import { Box, Divider, type MantineRadius, type MantineSpacing, type MantineTheme, Paper, useMantineTheme } from "@mantine/core"
|
||||
import { Constants } from "app/constants"
|
||||
import { type Entry, type ViewMode } from "app/types"
|
||||
import { useColorScheme } from "hooks/useColorScheme"
|
||||
import { useViewMode } from "hooks/useViewMode"
|
||||
import React from "react"
|
||||
import { useSwipeable } from "react-swipeable"
|
||||
import { tss } from "tss"
|
||||
import { FeedEntryBody } from "./FeedEntryBody"
|
||||
import { FeedEntryCompactHeader } from "./FeedEntryCompactHeader"
|
||||
import { FeedEntryContextMenu } from "./FeedEntryContextMenu"
|
||||
import { FeedEntryFooter } from "./FeedEntryFooter"
|
||||
import { FeedEntryHeader } from "./FeedEntryHeader"
|
||||
|
||||
interface FeedEntryProps {
|
||||
entry: Entry
|
||||
expanded: boolean
|
||||
selected: boolean
|
||||
showSelectionIndicator: boolean
|
||||
maxWidth?: number
|
||||
onHeaderClick: (e: React.MouseEvent) => void
|
||||
onHeaderRightClick: (e: React.MouseEvent) => void
|
||||
onBodyClick: (e: React.MouseEvent) => void
|
||||
onSwipedLeft: () => void
|
||||
}
|
||||
|
||||
const useStyles = tss
|
||||
.withParams<{
|
||||
theme: MantineTheme
|
||||
colorScheme: "light" | "dark"
|
||||
read: boolean
|
||||
expanded: boolean
|
||||
viewMode: ViewMode
|
||||
rtl: boolean
|
||||
showSelectionIndicator: boolean
|
||||
maxWidth?: number
|
||||
}>()
|
||||
.create(({ theme, colorScheme, read, expanded, viewMode, rtl, showSelectionIndicator, maxWidth }) => {
|
||||
let backgroundColor
|
||||
if (colorScheme === "dark") {
|
||||
backgroundColor = read ? "inherit" : theme.colors.dark[5]
|
||||
} else {
|
||||
backgroundColor = read && !expanded ? theme.colors.gray[0] : "inherit"
|
||||
}
|
||||
|
||||
let marginY = 10
|
||||
if (viewMode === "title") {
|
||||
marginY = 2
|
||||
} else if (viewMode === "cozy") {
|
||||
marginY = 6
|
||||
}
|
||||
|
||||
let mobileMarginY = 6
|
||||
if (viewMode === "title") {
|
||||
mobileMarginY = 2
|
||||
} else if (viewMode === "cozy") {
|
||||
mobileMarginY = 4
|
||||
}
|
||||
|
||||
let backgroundHoverColor = backgroundColor
|
||||
if (!expanded && !read) {
|
||||
backgroundHoverColor = colorScheme === "dark" ? theme.colors.dark[6] : theme.colors.gray[1]
|
||||
}
|
||||
|
||||
let paperBorderLeftColor
|
||||
if (showSelectionIndicator) {
|
||||
const borderLeftColor = colorScheme === "dark" ? theme.colors[theme.primaryColor][4] : theme.colors[theme.primaryColor][6]
|
||||
paperBorderLeftColor = `${borderLeftColor} !important`
|
||||
}
|
||||
|
||||
return {
|
||||
paper: {
|
||||
backgroundColor,
|
||||
borderLeftColor: paperBorderLeftColor,
|
||||
marginTop: marginY,
|
||||
marginBottom: marginY,
|
||||
[`@media (max-width: ${Constants.layout.mobileBreakpoint}px)`]: {
|
||||
marginTop: mobileMarginY,
|
||||
marginBottom: mobileMarginY,
|
||||
},
|
||||
"@media (hover: hover)": {
|
||||
"&:hover": {
|
||||
backgroundColor: backgroundHoverColor,
|
||||
},
|
||||
},
|
||||
},
|
||||
headerLink: {
|
||||
color: "inherit",
|
||||
textDecoration: "none",
|
||||
},
|
||||
body: {
|
||||
direction: rtl ? "rtl" : "ltr",
|
||||
maxWidth: maxWidth ?? "100%",
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
export function FeedEntry(props: FeedEntryProps) {
|
||||
const theme = useMantineTheme()
|
||||
const colorScheme = useColorScheme()
|
||||
const { viewMode } = useViewMode()
|
||||
const { classes, cx } = useStyles({
|
||||
theme,
|
||||
colorScheme,
|
||||
read: props.entry.read,
|
||||
expanded: props.expanded,
|
||||
viewMode,
|
||||
rtl: props.entry.rtl,
|
||||
showSelectionIndicator: props.showSelectionIndicator,
|
||||
maxWidth: props.maxWidth,
|
||||
})
|
||||
|
||||
const swipeHandlers = useSwipeable({
|
||||
onSwipedLeft: props.onSwipedLeft,
|
||||
})
|
||||
|
||||
let paddingX: MantineSpacing = "xs"
|
||||
if (viewMode === "title" || viewMode === "cozy") paddingX = 6
|
||||
|
||||
let paddingY: MantineSpacing = "xs"
|
||||
if (viewMode === "title") {
|
||||
paddingY = 4
|
||||
} else if (viewMode === "cozy") {
|
||||
paddingY = 8
|
||||
}
|
||||
|
||||
let borderRadius: MantineRadius = "sm"
|
||||
if (viewMode === "title") {
|
||||
borderRadius = 0
|
||||
} else if (viewMode === "cozy") {
|
||||
borderRadius = "xs"
|
||||
}
|
||||
|
||||
const compactHeader = !props.expanded && (viewMode === "title" || viewMode === "cozy")
|
||||
return (
|
||||
<Paper
|
||||
withBorder
|
||||
radius={borderRadius}
|
||||
className={cx(classes.paper, {
|
||||
read: props.entry.read,
|
||||
unread: !props.entry.read,
|
||||
expanded: props.expanded,
|
||||
selected: props.selected,
|
||||
"show-selection-indicator": props.showSelectionIndicator,
|
||||
})}
|
||||
>
|
||||
<a
|
||||
className={classes.headerLink}
|
||||
href={props.entry.url}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
onClick={props.onHeaderClick}
|
||||
onAuxClick={props.onHeaderClick}
|
||||
onContextMenu={props.onHeaderRightClick}
|
||||
>
|
||||
<Box px={paddingX} py={paddingY} {...swipeHandlers}>
|
||||
{compactHeader && <FeedEntryCompactHeader entry={props.entry} />}
|
||||
{!compactHeader && <FeedEntryHeader entry={props.entry} expanded={props.expanded} />}
|
||||
</Box>
|
||||
</a>
|
||||
{props.expanded && (
|
||||
<Box px={paddingX} pb={paddingY} onClick={props.onBodyClick}>
|
||||
<Box className={classes.body}>
|
||||
<FeedEntryBody entry={props.entry} />
|
||||
</Box>
|
||||
<Divider variant="dashed" my={paddingY} />
|
||||
<FeedEntryFooter entry={props.entry} />
|
||||
</Box>
|
||||
)}
|
||||
|
||||
<FeedEntryContextMenu entry={props.entry} />
|
||||
</Paper>
|
||||
)
|
||||
}
|
||||
37
commafeed-client/src/components/content/FeedEntryBody.tsx
Normal file
@@ -0,0 +1,37 @@
|
||||
import { Box } from "@mantine/core"
|
||||
import { useAppSelector } from "app/store"
|
||||
import { type Entry } from "app/types"
|
||||
import { Content } from "./Content"
|
||||
import { Enclosure } from "./Enclosure"
|
||||
import { Media } from "./Media"
|
||||
|
||||
export interface FeedEntryBodyProps {
|
||||
entry: Entry
|
||||
}
|
||||
|
||||
export function FeedEntryBody(props: FeedEntryBodyProps) {
|
||||
const search = useAppSelector(state => state.entries.search)
|
||||
return (
|
||||
<Box>
|
||||
<Box>
|
||||
<Content content={props.entry.content} highlight={search} />
|
||||
</Box>
|
||||
{props.entry.enclosureType && props.entry.enclosureUrl && (
|
||||
<Box pt="md">
|
||||
<Enclosure enclosureType={props.entry.enclosureType} enclosureUrl={props.entry.enclosureUrl} />
|
||||
</Box>
|
||||
)}
|
||||
{/* show media only if we don't have content to avoid duplicate content */}
|
||||
{!props.entry.content && props.entry.mediaThumbnailUrl && (
|
||||
<Box pt="md">
|
||||
<Media
|
||||
thumbnailUrl={props.entry.mediaThumbnailUrl}
|
||||
thumbnailWidth={props.entry.mediaThumbnailWidth}
|
||||
thumbnailHeight={props.entry.mediaThumbnailHeight}
|
||||
description={props.entry.mediaDescription}
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
import { Box, Text } from "@mantine/core"
|
||||
import { type Entry } from "app/types"
|
||||
import { RelativeDate } from "components/RelativeDate"
|
||||
import { OnDesktop } from "components/responsive/OnDesktop"
|
||||
import { useColorScheme } from "hooks/useColorScheme"
|
||||
import { tss } from "tss"
|
||||
import { FeedEntryTitle } from "./FeedEntryTitle"
|
||||
import { FeedFavicon } from "./FeedFavicon"
|
||||
|
||||
export interface FeedEntryHeaderProps {
|
||||
entry: Entry
|
||||
}
|
||||
|
||||
const useStyles = tss
|
||||
.withParams<{
|
||||
colorScheme: "light" | "dark"
|
||||
read: boolean
|
||||
}>()
|
||||
.create(({ colorScheme, read }) => ({
|
||||
wrapper: {
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
columnGap: "10px",
|
||||
},
|
||||
title: {
|
||||
flexGrow: 1,
|
||||
fontWeight: colorScheme === "light" && !read ? "bold" : "inherit",
|
||||
whiteSpace: "nowrap",
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
},
|
||||
feedName: {
|
||||
width: "145px",
|
||||
minWidth: "145px",
|
||||
whiteSpace: "nowrap",
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
},
|
||||
date: {
|
||||
whiteSpace: "nowrap",
|
||||
},
|
||||
}))
|
||||
|
||||
export function FeedEntryCompactHeader(props: FeedEntryHeaderProps) {
|
||||
const colorScheme = useColorScheme()
|
||||
const { classes } = useStyles({
|
||||
colorScheme,
|
||||
read: props.entry.read,
|
||||
})
|
||||
return (
|
||||
<Box className={classes.wrapper}>
|
||||
<Box>
|
||||
<FeedFavicon url={props.entry.iconUrl} />
|
||||
</Box>
|
||||
<OnDesktop>
|
||||
<Text c="dimmed" className={classes.feedName}>
|
||||
{props.entry.feedName}
|
||||
</Text>
|
||||
</OnDesktop>
|
||||
<Box className={classes.title}>
|
||||
<FeedEntryTitle entry={props.entry} />
|
||||
</Box>
|
||||
<OnDesktop>
|
||||
<Text c="dimmed" className={classes.date}>
|
||||
<RelativeDate date={props.entry.date} />
|
||||
</Text>
|
||||
</OnDesktop>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
110
commafeed-client/src/components/content/FeedEntryContextMenu.tsx
Normal file
@@ -0,0 +1,110 @@
|
||||
import { Trans } from "@lingui/macro"
|
||||
import { Group, type MantineTheme, useMantineTheme } 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, TbEyeCheck, TbEyeOff, TbRss, TbStar, TbStarOff } from "react-icons/tb"
|
||||
import { tss } from "tss"
|
||||
|
||||
interface FeedEntryContextMenuProps {
|
||||
entry: Entry
|
||||
}
|
||||
|
||||
const iconSize = 16
|
||||
const useStyles = tss
|
||||
.withParams<{
|
||||
theme: MantineTheme
|
||||
colorScheme: "light" | "dark"
|
||||
}>()
|
||||
.create(({ theme, colorScheme }) => ({
|
||||
menu: {
|
||||
// apply mantine theme from MenuItem.styles.ts
|
||||
fontSize: theme.fontSizes.sm,
|
||||
"--contexify-item-color": `${colorScheme === "dark" ? theme.colors.dark[0] : theme.black} !important`,
|
||||
"--contexify-activeItem-color": `${colorScheme === "dark" ? theme.colors.dark[0] : theme.black} !important`,
|
||||
"--contexify-activeItem-bgColor": `${colorScheme === "dark" ? theme.colors.dark[4] : theme.colors.gray[1]} !important`,
|
||||
},
|
||||
}))
|
||||
|
||||
export function FeedEntryContextMenu(props: FeedEntryContextMenuProps) {
|
||||
const theme = useMantineTheme()
|
||||
const colorScheme = useColorScheme()
|
||||
const { classes } = useStyles({
|
||||
theme,
|
||||
colorScheme,
|
||||
})
|
||||
const sourceType = useAppSelector(state => state.entries.source.type)
|
||||
const dispatch = useAppDispatch()
|
||||
const { openLinkInBackgroundTab } = useBrowserExtension()
|
||||
|
||||
return (
|
||||
<Menu id={Constants.dom.entryContextMenuId(props.entry)} theme={colorScheme} animation={false} className={classes.menu}>
|
||||
<Item
|
||||
onClick={() => {
|
||||
window.open(props.entry.url, "_blank", "noreferrer")
|
||||
dispatch(markEntry({ entry: props.entry, read: true }))
|
||||
}}
|
||||
>
|
||||
<Group>
|
||||
<TbExternalLink size={iconSize} />
|
||||
<Trans>Open link in new tab</Trans>
|
||||
</Group>
|
||||
</Item>
|
||||
<Item
|
||||
onClick={() => {
|
||||
openLinkInBackgroundTab(props.entry.url)
|
||||
dispatch(markEntry({ entry: props.entry, read: true }))
|
||||
}}
|
||||
>
|
||||
<Group>
|
||||
<TbExternalLink size={iconSize} />
|
||||
<Trans>Open link in new background tab</Trans>
|
||||
</Group>
|
||||
</Item>
|
||||
|
||||
<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>
|
||||
<Item onClick={async () => await dispatch(markEntry({ entry: props.entry, read: !props.entry.read }))}>
|
||||
<Group>
|
||||
{props.entry.read ? <TbEyeOff size={iconSize} /> : <TbEyeCheck 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>
|
||||
<TbArrowBarToDown size={iconSize} />
|
||||
<Trans>Mark as read up to here</Trans>
|
||||
</Group>
|
||||
</Item>
|
||||
|
||||
{sourceType === "category" && (
|
||||
<>
|
||||
<Separator />
|
||||
|
||||
<Item
|
||||
onClick={() => {
|
||||
dispatch(redirectToFeed(props.entry.feedId))
|
||||
}}
|
||||
>
|
||||
<Group>
|
||||
<TbRss size={iconSize} />
|
||||
<Trans>Go to {truncate(props.entry.feedName, 30)}</Trans>
|
||||
</Group>
|
||||
</Item>
|
||||
</>
|
||||
)}
|
||||
</Menu>
|
||||
)
|
||||
}
|
||||
107
commafeed-client/src/components/content/FeedEntryFooter.tsx
Normal file
@@ -0,0 +1,107 @@
|
||||
import { t, Trans } from "@lingui/macro"
|
||||
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, TbEyeCheck, TbEyeOff, TbShare, TbStar, TbStarOff, TbTag } from "react-icons/tb"
|
||||
import { ShareButtons } from "./ShareButtons"
|
||||
|
||||
interface FeedEntryFooterProps {
|
||||
entry: Entry
|
||||
}
|
||||
|
||||
export function FeedEntryFooter(props: FeedEntryFooterProps) {
|
||||
const sharingSettings = useAppSelector(state => state.user.settings?.sharingSettings)
|
||||
const tags = useAppSelector(state => state.user.tags)
|
||||
const mobile = useMobile()
|
||||
const { spacing } = useActionButton()
|
||||
const dispatch = useAppDispatch()
|
||||
|
||||
const showSharingButtons = sharingSettings && Object.values(sharingSettings).some(v => v)
|
||||
|
||||
const readStatusButtonClicked = async () =>
|
||||
await dispatch(
|
||||
markEntry({
|
||||
entry: props.entry,
|
||||
read: !props.entry.read,
|
||||
})
|
||||
)
|
||||
const onTagsChange = async (values: string[]) =>
|
||||
await dispatch(
|
||||
tagEntry({
|
||||
entryId: +props.entry.id,
|
||||
tags: values,
|
||||
})
|
||||
)
|
||||
|
||||
return (
|
||||
<Group justify="space-between">
|
||||
<Group gap={spacing}>
|
||||
{props.entry.markable && (
|
||||
<ActionButton
|
||||
icon={props.entry.read ? <TbEyeOff size={18} /> : <TbEyeCheck size={18} />}
|
||||
label={props.entry.read ? <Trans>Keep unread</Trans> : <Trans>Mark as read</Trans>}
|
||||
onClick={readStatusButtonClicked}
|
||||
/>
|
||||
)}
|
||||
<ActionButton
|
||||
icon={props.entry.starred ? <TbStarOff size={18} /> : <TbStar size={18} />}
|
||||
label={props.entry.starred ? <Trans>Unstar</Trans> : <Trans>Star</Trans>}
|
||||
onClick={async () =>
|
||||
await dispatch(
|
||||
starEntry({
|
||||
entry: props.entry,
|
||||
starred: !props.entry.starred,
|
||||
})
|
||||
)
|
||||
}
|
||||
/>
|
||||
|
||||
{showSharingButtons && (
|
||||
<Popover withArrow withinPortal shadow="md" closeOnClickOutside={!mobile}>
|
||||
<Popover.Target>
|
||||
<ActionButton icon={<TbShare size={18} />} label={<Trans>Share</Trans>} />
|
||||
</Popover.Target>
|
||||
<Popover.Dropdown>
|
||||
<ShareButtons url={props.entry.url} description={props.entry.title} />
|
||||
</Popover.Dropdown>
|
||||
</Popover>
|
||||
)}
|
||||
|
||||
{tags && (
|
||||
<Popover withArrow shadow="md" closeOnClickOutside={!mobile}>
|
||||
<Popover.Target>
|
||||
<Indicator label={props.entry.tags.length} disabled={props.entry.tags.length === 0} inline size={16}>
|
||||
<ActionButton icon={<TbTag size={18} />} label={<Trans>Tags</Trans>} />
|
||||
</Indicator>
|
||||
</Popover.Target>
|
||||
<Popover.Dropdown>
|
||||
<TagsInput
|
||||
placeholder={t`Tags`}
|
||||
data={tags}
|
||||
value={props.entry.tags}
|
||||
onChange={onTagsChange}
|
||||
comboboxProps={{
|
||||
withinPortal: false,
|
||||
}}
|
||||
/>
|
||||
</Popover.Dropdown>
|
||||
</Popover>
|
||||
)}
|
||||
|
||||
<a href={props.entry.url} target="_blank" rel="noreferrer">
|
||||
<ActionButton icon={<TbExternalLink size={18} />} label={<Trans>Open link</Trans>} />
|
||||
</a>
|
||||
</Group>
|
||||
|
||||
<ActionButton
|
||||
icon={<TbArrowBarToDown size={18} />}
|
||||
label={<Trans>Mark as read up to here</Trans>}
|
||||
onClick={async () => await dispatch(markEntriesUpToEntry(props.entry))}
|
||||
/>
|
||||
</Group>
|
||||
)
|
||||
}
|
||||
61
commafeed-client/src/components/content/FeedEntryHeader.tsx
Normal file
@@ -0,0 +1,61 @@
|
||||
import { Box, Space, Text } from "@mantine/core"
|
||||
import { type Entry } from "app/types"
|
||||
import { RelativeDate } from "components/RelativeDate"
|
||||
import { useColorScheme } from "hooks/useColorScheme"
|
||||
import { tss } from "tss"
|
||||
import { FeedEntryTitle } from "./FeedEntryTitle"
|
||||
import { FeedFavicon } from "./FeedFavicon"
|
||||
|
||||
export interface FeedEntryHeaderProps {
|
||||
entry: Entry
|
||||
expanded: boolean
|
||||
}
|
||||
|
||||
const useStyles = tss
|
||||
.withParams<{
|
||||
colorScheme: "light" | "dark"
|
||||
read: boolean
|
||||
}>()
|
||||
.create(({ colorScheme, read }) => ({
|
||||
headerText: {
|
||||
fontWeight: colorScheme === "light" && !read ? "bold" : "inherit",
|
||||
},
|
||||
headerSubtext: {
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
fontSize: "90%",
|
||||
},
|
||||
}))
|
||||
|
||||
export function FeedEntryHeader(props: FeedEntryHeaderProps) {
|
||||
const colorScheme = useColorScheme()
|
||||
const { classes } = useStyles({
|
||||
colorScheme,
|
||||
read: props.entry.read,
|
||||
})
|
||||
return (
|
||||
<Box>
|
||||
<Box className={classes.headerText}>
|
||||
<FeedEntryTitle entry={props.entry} />
|
||||
</Box>
|
||||
<Box className={classes.headerSubtext}>
|
||||
<FeedFavicon url={props.entry.iconUrl} />
|
||||
<Space w={6} />
|
||||
<Text c="dimmed">
|
||||
{props.entry.feedName}
|
||||
<span> · </span>
|
||||
<RelativeDate date={props.entry.date} />
|
||||
</Text>
|
||||
</Box>
|
||||
{props.expanded && (
|
||||
<Box className={classes.headerSubtext}>
|
||||
<Text c="dimmed">
|
||||
{props.entry.author && <span>by {props.entry.author}</span>}
|
||||
{props.entry.author && props.entry.categories && <span> · </span>}
|
||||
{props.entry.categories && <span>{props.entry.categories}</span>}
|
||||
</Text>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
22
commafeed-client/src/components/content/FeedEntryTitle.tsx
Normal file
@@ -0,0 +1,22 @@
|
||||
import { Highlight } from "@mantine/core"
|
||||
import { useAppSelector } from "app/store"
|
||||
import { type Entry } from "app/types"
|
||||
|
||||
export interface FeedEntryTitleProps {
|
||||
entry: Entry
|
||||
}
|
||||
|
||||
export function FeedEntryTitle(props: FeedEntryTitleProps) {
|
||||
const search = useAppSelector(state => state.entries.search)
|
||||
const keywords = search?.split(" ")
|
||||
return (
|
||||
<Highlight
|
||||
inherit
|
||||
highlight={keywords ?? ""}
|
||||
// make sure ellipsis is shown when title is too long
|
||||
span
|
||||
>
|
||||
{props.entry.title}
|
||||
</Highlight>
|
||||
)
|
||||
}
|
||||
21
commafeed-client/src/components/content/FeedFavicon.tsx
Normal file
@@ -0,0 +1,21 @@
|
||||
import { ImageWithPlaceholderWhileLoading } from "components/ImageWithPlaceholderWhileLoading"
|
||||
|
||||
export interface FeedFaviconProps {
|
||||
url: string
|
||||
size?: number
|
||||
}
|
||||
|
||||
export function FeedFavicon({ url, size = 18 }: FeedFaviconProps) {
|
||||
return (
|
||||
<ImageWithPlaceholderWhileLoading
|
||||
src={url}
|
||||
alt="feed favicon"
|
||||
width={size}
|
||||
height={size}
|
||||
placeholderWidth={size}
|
||||
placeholderHeight={size}
|
||||
placeholderBackgroundColor="inherit"
|
||||
placeholderIconSize={size}
|
||||
/>
|
||||
)
|
||||
}
|
||||
40
commafeed-client/src/components/content/Media.tsx
Normal file
@@ -0,0 +1,40 @@
|
||||
import { Box } from "@mantine/core"
|
||||
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 {
|
||||
thumbnailUrl: string
|
||||
thumbnailWidth?: number
|
||||
thumbnailHeight?: number
|
||||
description?: string
|
||||
}
|
||||
|
||||
export function Media(props: MediaProps) {
|
||||
const width = props.thumbnailWidth
|
||||
const height = props.thumbnailHeight
|
||||
const placeholderSize = calculatePlaceholderSize({
|
||||
width,
|
||||
height,
|
||||
maxWidth: Constants.layout.entryMaxWidth,
|
||||
})
|
||||
return (
|
||||
<BasicHtmlStyles>
|
||||
<ImageWithPlaceholderWhileLoading
|
||||
src={props.thumbnailUrl}
|
||||
alt="media thumbnail"
|
||||
width={props.thumbnailWidth}
|
||||
height={props.thumbnailHeight}
|
||||
placeholderWidth={placeholderSize.width}
|
||||
placeholderHeight={placeholderSize.height}
|
||||
/>
|
||||
{props.description && (
|
||||
<Box pt="md">
|
||||
<Content content={props.description} />
|
||||
</Box>
|
||||
)}
|
||||
</BasicHtmlStyles>
|
||||
)
|
||||
}
|
||||