Compare commits
877 Commits
feature/em
...
dev
Author | SHA1 | Date | |
---|---|---|---|
a95c78fd60 | |||
4e050198c5 | |||
36810d688c | |||
abc1f184a1 | |||
244c91fb02 | |||
6bd919f16b | |||
![]() |
04878bec0b | ||
b791de3d4e | |||
6f26e09bef | |||
![]() |
55e2b34466 | ||
217c027044 | |||
8106bae0c2 | |||
ae1a93469b | |||
67e8c80d9a | |||
abdc419aa8 | |||
![]() |
32ebcff5fe | ||
![]() |
b91a1be989 | ||
21b3903062 | |||
fab8a5ed53 | |||
845490d5db | |||
30de1ddb3e | |||
90cd3988a1 | |||
258b579d05 | |||
76dea4341d | |||
595e2b8a4b | |||
b393810f7a | |||
90057a2d0e | |||
7aa01d6152 | |||
962140e755 | |||
0c61445293 | |||
29dbd67c27 | |||
a144d7051b | |||
c165742fbf | |||
bbab1e4cb8 | |||
dc2b6a5ab1 | |||
91d8fdf746 | |||
db8b53f9bd | |||
aa11c8d8b8 | |||
6dc25260bb | |||
a805493b27 | |||
996153bbc2 | |||
c26f960d9b | |||
ef7ceb5d33 | |||
22575cc7fa | |||
a52ee5a90f | |||
2fef053029 | |||
5e2b4a7ae6 | |||
6e3871cd5a | |||
a451a6bf4e | |||
317d4a004c | |||
29d1661993 | |||
7288725480 | |||
c959a2bba4 | |||
d7a5a188ff | |||
b3b8e51d2d | |||
343b71defd | |||
![]() |
545728ac84 | ||
fe76bb46e6 | |||
1f0e91d2b9 | |||
![]() |
76366a01d9 | ||
d496760fdb | |||
3da600dacc | |||
f9ed111d8e | |||
392581f50d | |||
c40a357815 | |||
6ca29a351f | |||
d6c6545726 | |||
1db4224827 | |||
98f03c2296 | |||
![]() |
9491f7c504 | ||
![]() |
fac7451fc4 | ||
64224720f5 | |||
d1ff340e0e | |||
92ec0da0bc | |||
d3cd027910 | |||
![]() |
146f41e167 | ||
08cbfa1d57 | |||
8345d858ed | |||
5e73863679 | |||
ff541b4456 | |||
d210587b74 | |||
64658f5175 | |||
b201163ab9 | |||
11c94da20d | |||
63e159822a | |||
d5c5eaf57b | |||
6a48b53216 | |||
20e7588019 | |||
80dbe410ab | |||
d00084183d | |||
bf09277822 | |||
35febd9ac4 | |||
35cae9415d | |||
74725df0ff | |||
5eded9f143 | |||
afa69fc86f | |||
6e0a830168 | |||
433a74a58a | |||
53299fc183 | |||
ebed7f38c3 | |||
199f845610 | |||
2fad5b8db9 | |||
344f716d1d | |||
ba55780246 | |||
512c65aeef | |||
ad4bda3c24 | |||
![]() |
7d895aa343 | ||
![]() |
49001bb63c | ||
![]() |
bef13a9bde | ||
09243925b4 | |||
![]() |
1da37e7a52 | ||
479a4ea852 | |||
![]() |
e4a1679052 | ||
8824fbab2f | |||
6ec271fe7c | |||
260b95f692 | |||
1ec368eae7 | |||
e03971193e | |||
6e11e19f0a | |||
d003df96f2 | |||
dd02c46174 | |||
9319c8d526 | |||
7a2043f223 | |||
a2999e3851 | |||
23e9ca9838 | |||
b868c7282d | |||
124ae3dece | |||
a4e5d8f332 | |||
7fa17cee3c | |||
6bfaa3fb51 | |||
30ff30d099 | |||
7714977391 | |||
e176544e36 | |||
33a81d8ee7 | |||
7c614c66d9 | |||
cd436dd34d | |||
0698900f8d | |||
090295327f | |||
f808bd2394 | |||
22f44ee0ec | |||
7ca7acc487 | |||
3d2125d99f | |||
e695c7847f | |||
95198e9791 | |||
71772ae0d6 | |||
28f3d6619d | |||
![]() |
5bf8340f13 | ||
![]() |
1c2b53cfe8 | ||
![]() |
0748aa342c | ||
e360cdf1ba | |||
17750a7630 | |||
b80a497e4d | |||
ae3473312a | |||
a186dc4d3f | |||
e6e19d74cd | |||
6a105b24fc | |||
50d98deadf | |||
582a80d34d | |||
c6ea1eb8b7 | |||
e797e8ca96 | |||
e07ace8741 | |||
b53f83947c | |||
a39190e2b1 | |||
e875212ae7 | |||
ca629e8c26 | |||
8db9f4c93f | |||
ae4ec7b6b7 | |||
6db49c2296 | |||
a5bb138fc7 | |||
142c5bfb41 | |||
81ed60a52f | |||
418572af88 | |||
![]() |
4902096e48 | ||
5846d24c10 | |||
4cb888262d | |||
1e4138e40e | |||
219e3e2325 | |||
3e25997d2a | |||
38f6cbbdca | |||
15233db9a5 | |||
87dd66ad4b | |||
0de087d999 | |||
42d8e7598c | |||
b5e25e0d6d | |||
1f58385b95 | |||
9733eff104 | |||
607d986293 | |||
![]() |
e6aa3a881f | ||
b61b19a119 | |||
01e7dec615 | |||
1a9529d9fc | |||
85e0a92b31 | |||
751157b421 | |||
0061b68257 | |||
87d08dcb75 | |||
1d540f28b7 | |||
cad695dc59 | |||
77d8ca352a | |||
a8d778b2e4 | |||
8f9dea9bfb | |||
1eb9c57f0d | |||
7573c6334c | |||
ab05a9e539 | |||
8c5ef2fd54 | |||
8fbc85615c | |||
82904bd1da | |||
19bae3b2dd | |||
c79b0451cb | |||
8cce8d897e | |||
4fe2768329 | |||
f6f012449d | |||
789a7497a3 | |||
2b7a825bc5 | |||
4efec31fec | |||
03787196a9 | |||
35c5a0ebcf | |||
7c9c155f5b | |||
2d7fbc42a8 | |||
95612eb7b8 | |||
24e594138f | |||
17d2600142 | |||
fde2335a02 | |||
ef1408327f | |||
645e65751b | |||
f916e9f9ae | |||
e5a3788f71 | |||
98461b8d09 | |||
0192acb8a4 | |||
b7e775eeea | |||
25d217389b | |||
e68741efa1 | |||
3041ee2fd6 | |||
41f989024c | |||
c2b56ed745 | |||
7623f719a5 | |||
65798a7f60 | |||
27653a4db2 | |||
![]() |
d9a0badedd | ||
![]() |
b677cb3493 | ||
![]() |
59885486eb | ||
edbd7ec3b2 | |||
481e4292b5 | |||
6602f48693 | |||
d255f6f0b1 | |||
bfc78d9df3 | |||
d4deef9bb6 | |||
ec1aacc010 | |||
878f0036ab | |||
0c000b8b6e | |||
7e3499fbb3 | |||
e3ac3cc406 | |||
b204204a31 | |||
327cd514d6 | |||
0abb85ee28 | |||
![]() |
c6816b9271 | ||
![]() |
3bfa4adf68 | ||
![]() |
32519d5ee9 | ||
![]() |
9686619243 | ||
e7bcb4c6d4 | |||
f4f4e80816 | |||
d64f68579c | |||
e627bae06c | |||
fa79a0cd5d | |||
47622f996b | |||
c623356893 | |||
90691aa650 | |||
6d3629592a | |||
d8e9dd62ce | |||
6ba51ad83e | |||
2d89f62864 | |||
6104079f09 | |||
0323c913b3 | |||
35f39da99e | |||
2d5e9877ee | |||
de29d435ec | |||
6fc2d107e9 | |||
28c66a564a | |||
a5d15f2808 | |||
587c2a961a | |||
7a499ae2e5 | |||
7c2d97053b | |||
546d1bd743 | |||
a767ce7fd1 | |||
bff5de0c8e | |||
22f45d8bd9 | |||
3433ba0aac | |||
6354bb8d47 | |||
cc60bb99ce | |||
545c25d305 | |||
b5c63fbbb0 | |||
e83fd35b85 | |||
![]() |
35e282fb18 | ||
![]() |
a7e1a1763c | ||
![]() |
7383433b58 | ||
![]() |
7d921cee2f | ||
874654f0eb | |||
8b773e5fed | |||
e5950417ea | |||
![]() |
82e8d0d00e | ||
![]() |
1cac091a1c | ||
![]() |
fd4233f907 | ||
![]() |
d321cb21b7 | ||
![]() |
105b286b37 | ||
![]() |
49d6f17779 | ||
![]() |
bd4aa9c0ac | ||
![]() |
272d9caa67 | ||
![]() |
f7294c96d1 | ||
![]() |
b36983b583 | ||
![]() |
0e5cc90648 | ||
![]() |
24f4a22110 | ||
![]() |
9106f45f53 | ||
3e8efdcae5 | |||
0a997bdcae | |||
f7b39687da | |||
d8144c4a4d | |||
b909f83411 | |||
a2514735f9 | |||
0f8744dc5d | |||
c9a8c1aa8e | |||
3fa235cd16 | |||
020768f5b1 | |||
5711b26644 | |||
e1275b76ea | |||
e4390d83e6 | |||
d2771ac2a4 | |||
c8ab5ef4f6 | |||
2f2c4af161 | |||
f55fad3d76 | |||
c1d6b4498a | |||
b855e1c11f | |||
a8d7a28297 | |||
deeb7c58cf | |||
4e631f5f91 | |||
a616f97fe4 | |||
ed145ad447 | |||
c0c379a918 | |||
6837bd3d48 | |||
305bd23f9f | |||
ae207a02f2 | |||
fcc7d19f59 | |||
2bc87b5e99 | |||
e38fce68c6 | |||
a5eaeab5cd | |||
4c7839aaff | |||
c6ae893403 | |||
1d38c12509 | |||
81a85f7da7 | |||
0a193d340a | |||
35ab9abe06 | |||
9eb30da07c | |||
d002a494e8 | |||
71e38d233a | |||
1f326c7611 | |||
876e342f4c | |||
7ceb9db77f | |||
e697f041ff | |||
2f73152829 | |||
f3681a2b1b | |||
bc52dcc653 | |||
bbff52019b | |||
7591c3d40f | |||
b2bf8335ca | |||
4043b83aca | |||
8d39c74242 | |||
![]() |
c1bd614845 | ||
![]() |
d5e95eb7bd | ||
![]() |
94520bc57b | ||
![]() |
6fcd0105de | ||
![]() |
250947a8d1 | ||
3e214d0352 | |||
4901a761b3 | |||
9c530c4180 | |||
![]() |
86c091f366 | ||
914f9a6a7b | |||
8b5b5a609f | |||
95232f8358 | |||
7fc67c07a4 | |||
![]() |
ab61c1e35a | ||
2a3fe3b89e | |||
![]() |
1ce6c9cd63 | ||
![]() |
e40df167d8 | ||
1a2d197e0c | |||
9ba7bfada8 | |||
0e8e8f5d20 | |||
fa54d89d53 | |||
fd76d98d96 | |||
f288cc3388 | |||
2952e15591 | |||
8a36b21e6e | |||
2cbd54d440 | |||
fc8d48436a | |||
c8ebb699e2 | |||
89a55f7d15 | |||
f1b705633c | |||
121a71873b | |||
9dd9bc7cab | |||
85e21b0fc3 | |||
79d8835c89 | |||
d338510454 | |||
c4be770375 | |||
e286aee46c | |||
4e71a2e748 | |||
3ec7420eb8 | |||
d249cb050d | |||
cada98f135 | |||
35cb6c1a44 | |||
6b0fa86c21 | |||
48a8e88b15 | |||
823ff181c6 | |||
2ff29b9e0f | |||
603ebbb4a5 | |||
409e64ddaf | |||
2a80dce98a | |||
b9705ab8ba | |||
e6a4db2eb5 | |||
4fe0d8e841 | |||
83656fedb5 | |||
aa0fc4ad95 | |||
fc25522516 | |||
7ba6bb2f97 | |||
3f8f3492c8 | |||
309b07c596 | |||
65d7645374 | |||
3319bfe973 | |||
8caef5b1f0 | |||
4e7cfa9241 | |||
ac9fd8d313 | |||
5f194c7a3b | |||
b7ce071ee9 | |||
9a42086f08 | |||
![]() |
ba6d29ef59 | ||
b53aa85337 | |||
144db5c0e8 | |||
36ca02dc1b | |||
e327238678 | |||
7b546ecbc8 | |||
46b80a3182 | |||
068fadd19b | |||
0b01f41edc | |||
79f94876f0 | |||
be4d16b1a5 | |||
27d3496423 | |||
2e6e1abad7 | |||
0631ecf8c9 | |||
417c57e338 | |||
8397facc5e | |||
5bde0bc7d0 | |||
bec333f7c3 | |||
652d0b647a | |||
a25d50d99b | |||
0a5e5eca95 | |||
58ec520c3e | |||
![]() |
fe2db946b4 | ||
9b76a52430 | |||
8f330ab914 | |||
319136474e | |||
7e2f2d5192 | |||
38899ad8cb | |||
59eaf3837d | |||
e4f7675606 | |||
22f0c9052d | |||
f22d10a535 | |||
135e0d215f | |||
4a55271a79 | |||
18a23ee0f8 | |||
60664581bd | |||
95c4d777b2 | |||
c8517c85c6 | |||
18bd07291d | |||
0b06e41670 | |||
af806590fb | |||
2e82eec82c | |||
74f7469c7d | |||
0ecbf07ef6 | |||
40b2c80b54 | |||
![]() |
6c99aa1adf | ||
de7afc2691 | |||
![]() |
8adff4629f | ||
f14ba82a29 | |||
![]() |
2c5ba6edab | ||
![]() |
0fa881dd6e | ||
412360f87f | |||
39d555c490 | |||
![]() |
e16f20db81 | ||
c61ad86234 | |||
3f96850948 | |||
150903ecbd | |||
aa8d5973ee | |||
ed2b4ebfbf | |||
![]() |
cb5c78790b | ||
0bb4465e87 | |||
f98bfa2d39 | |||
6a83aa6f87 | |||
91d6e0d41b | |||
65b0667a26 | |||
ae90118045 | |||
e7f17c3cc9 | |||
909c9a2983 | |||
07f2770a98 | |||
abec4043a3 | |||
50387738f8 | |||
5bbd29ddd2 | |||
49ff3ccbe3 | |||
c261c8cad0 | |||
![]() |
a9f732d1a4 | ||
399deaec86 | |||
7d69c55963 | |||
0664b5c933 | |||
79749bd95e | |||
2334c6b58c | |||
bc6b35c374 | |||
90c47a0177 | |||
9542fd0209 | |||
be7e31dbd2 | |||
e53181ef2d | |||
6a789d4a0e | |||
![]() |
aebff60263 | ||
9ec36168ae | |||
1e33d18c54 | |||
ab7dda6c14 | |||
53d3e2d836 | |||
7d24fe5598 | |||
![]() |
30672fc1af | ||
7d490c91aa | |||
2fa5a98933 | |||
605dae2127 | |||
7ffb568661 | |||
08533f4d1f | |||
![]() |
138403dfc3 | ||
67004a889f | |||
0b8ee33ed5 | |||
c52e79faf0 | |||
9c1ed4a04b | |||
ec0c2cf136 | |||
f992cf9377 | |||
f6043ad223 | |||
e1a69d97c2 | |||
8df9e3c356 | |||
78210d558f | |||
a03c26dd5a | |||
1f3b52258d | |||
56b292c817 | |||
a885686ae4 | |||
06ce5266e2 | |||
a75401b802 | |||
![]() |
73b42dbf09 | ||
1aa1dd3648 | |||
8ea55b3632 | |||
b7f19353f3 | |||
ce86896c1d | |||
52ef17dc6d | |||
75c415aece | |||
e6888d2549 | |||
8fc86b9bd9 | |||
417af2fc20 | |||
7d515c4fe2 | |||
![]() |
94a9eb4fff | ||
502dcfae6d | |||
dcbeb55ac9 | |||
3ce53728ea | |||
6e8b6043d7 | |||
3fadd719b2 | |||
![]() |
764ad4ea08 | ||
d8f61e5b66 | |||
500e8132dd | |||
![]() |
a024080661 | ||
1c02d3f29b | |||
75c7ef00f6 | |||
![]() |
8f09d6fc54 | ||
fe695f427f | |||
82e2abf8e3 | |||
![]() |
afd9bba005 | ||
![]() |
1c61692802 | ||
8e69c3979e | |||
83379c53ae | |||
56f46c18dd | |||
3f8d495076 | |||
28022607e3 | |||
1b1f3441dd | |||
0cbd3aedba | |||
9a475b8d0c | |||
eb17b5185f | |||
7d17d63b5d | |||
26b7afae66 | |||
be9a4ff275 | |||
39b15320b7 | |||
fa78483a38 | |||
629c2ad3de | |||
3161c2b2ec | |||
dee6cfbd34 | |||
848d791e42 | |||
193015a912 | |||
9277d652e9 | |||
21c216d19c | |||
ce670d0ff7 | |||
7cfe63b790 | |||
fbeceb820d | |||
7af4e91792 | |||
9f6e2c7893 | |||
b36e39edf0 | |||
b1cd3d917b | |||
b4d6171cea | |||
df90953138 | |||
27a9662143 | |||
39dd78e25d | |||
95fb194a96 | |||
3835618f3a | |||
439be16fa1 | |||
6aa84c17be | |||
ad2f6be1ae | |||
7dc2efca66 | |||
e0ed344218 | |||
b7939dead0 | |||
bd0fdeeb1f | |||
3bb0044f6e | |||
f0b2ef0ef9 | |||
a89a9bb1f4 | |||
571e475445 | |||
![]() |
004baba591 | ||
![]() |
3539ab500e | ||
e6ff073e29 | |||
2500206a9d | |||
7896950ec8 | |||
3075626d57 | |||
f77f0f08eb | |||
18b7b22270 | |||
4b1d21b15e | |||
284c728b61 | |||
e29188726c | |||
f02abc3ba0 | |||
702865b0e6 | |||
98e0bb1078 | |||
![]() |
ecd038a306 | ||
04978ebc7c | |||
a4d6466392 | |||
11b932c30e | |||
12ca2b9a97 | |||
c1a7291401 | |||
e1484e0aa9 | |||
867acd4b90 | |||
3eebc0b7ed | |||
c907e6ffa5 | |||
4c787fe49c | |||
ece5f2505f | |||
![]() |
b3155c4535 | ||
![]() |
ac39230271 | ||
7c9ecd1e3a | |||
![]() |
b9f7d01339 | ||
![]() |
1c94638ce8 | ||
84fb665f9d | |||
23326b10f7 | |||
e38d3b39b7 | |||
3174c9d83c | |||
![]() |
5334291878 | ||
![]() |
9028618067 | ||
![]() |
c1d1f05edf | ||
![]() |
4a7a052d67 | ||
![]() |
036443d7ee | ||
![]() |
fc056f14b8 | ||
![]() |
59561ec26b | ||
![]() |
437e666b3c | ||
![]() |
c9f494d2c1 | ||
![]() |
36fff73af6 | ||
![]() |
cca858dadf | ||
6030e665eb | |||
![]() |
16e3e75381 | ||
36970c03c4 | |||
0f28fe891a | |||
69fc0ffd07 | |||
6fbe9603fe | |||
f2012bca18 | |||
f7c33c167a | |||
bd4fedc6c7 | |||
![]() |
bb7d289ff7 | ||
5ddbb0dd1b | |||
6e48e64497 | |||
![]() |
e3a2aaf73a | ||
![]() |
78cde31943 | ||
ec58279d5c | |||
51cd05ba64 | |||
0eb72b9534 | |||
36a3606557 | |||
fe41845271 | |||
e1cfa376c5 | |||
e47650fe84 | |||
d6121efeb1 | |||
306d197d74 | |||
8b95ae2f85 | |||
ec70a078ad | |||
![]() |
66ed666b90 | ||
44b9c98ffc | |||
![]() |
499341baea | ||
455006f627 | |||
cf0214563d | |||
aeb42de908 | |||
79961b7f47 | |||
adae4c0144 | |||
6d12b01d56 | |||
58c4d6eae7 | |||
6851c3af6a | |||
69340e4b87 | |||
![]() |
c3495ed0b3 | ||
![]() |
b752357224 | ||
![]() |
b9591d7364 | ||
![]() |
d55be2505d | ||
![]() |
e93cb76a78 | ||
24cd1c54a8 | |||
6d30964a8b | |||
![]() |
9d5ddcfccc | ||
![]() |
d5aa083a2f | ||
![]() |
70bc237e2c | ||
![]() |
5e5693332c | ||
![]() |
ce0c2c0f0a | ||
![]() |
bbd8ef798c | ||
![]() |
75d929efda | ||
![]() |
0b88357f7c | ||
a95686d12b | |||
![]() |
78dd43a497 | ||
ddaed0557d | |||
![]() |
b84e7f43f7 | ||
e3c00cc6cd | |||
db830308e9 | |||
0c078a7bc1 | |||
![]() |
c80b2f044a | ||
![]() |
4c655509df | ||
![]() |
d4ce74b491 | ||
![]() |
97b1ec4386 | ||
5a7e416700 | |||
e33faa049e | |||
d36603a57c | |||
5f939839fb | |||
d9b3d18e95 | |||
![]() |
a88d200109 | ||
![]() |
546d3d2659 | ||
![]() |
96685507ea | ||
![]() |
084bd29d2b | ||
![]() |
6812ecd187 | ||
![]() |
896c180dd1 | ||
![]() |
4fdd025e44 | ||
![]() |
8d78ba2c62 | ||
![]() |
dd4065036f | ||
![]() |
00a0436835 | ||
![]() |
d202845aab | ||
![]() |
edf4400627 | ||
![]() |
5c4d605724 | ||
![]() |
b44008b229 | ||
![]() |
75caee909e | ||
36120abda5 | |||
![]() |
96d6e6bd0c | ||
![]() |
80ffc564a9 | ||
3b033be2b6 | |||
4e2152b8b0 | |||
cc951c305b | |||
![]() |
ae589e39fa | ||
b31d0deed4 | |||
![]() |
248d06decd | ||
![]() |
2280a776b3 | ||
![]() |
bf9f0d9c7b | ||
db7825fab8 | |||
0b905eb635 | |||
171458a83a | |||
a59ee6260c | |||
![]() |
1d64d97f9f | ||
![]() |
aed9952b61 | ||
![]() |
41e40ada9b | ||
d7f00cd962 | |||
af0c7fa712 | |||
56a83dc8ba | |||
e252ce464b | |||
3e2d7416b7 | |||
4f6169f16d | |||
626624ddb4 | |||
![]() |
4a1ad2b5af | ||
![]() |
45e8f2ba02 | ||
![]() |
eb03dc1d05 | ||
136ecda3b1 | |||
57a9fe42a8 | |||
e2c98ded5e | |||
5b97ea3746 | |||
ce66874089 | |||
449154bd1b | |||
dc719120b2 | |||
2d7fd38d82 | |||
4ed15f405e | |||
f8bf3d86a0 | |||
9b7079def5 | |||
50e8093fee | |||
0160dec607 | |||
![]() |
780c3571e4 | ||
ca66517d6a | |||
dbec93aee1 | |||
ca41467f68 | |||
372012badf | |||
ed23e1f7a8 | |||
1e27546b3e | |||
a207aeeb44 | |||
![]() |
4196bb0f1e | ||
![]() |
bc1ea82127 | ||
![]() |
e0503f593f | ||
96f72f00ee | |||
f0bddfe461 | |||
1397cc9b84 | |||
7f5553316c | |||
![]() |
b43ba41f9e | ||
![]() |
312f0f5cc9 | ||
6bee204280 | |||
bbb5ad435a | |||
![]() |
9a7c973bb2 | ||
780f59f517 | |||
d0be8ffb6a | |||
72610d10b5 | |||
423af46377 | |||
deebe79f55 | |||
a87efec9dc | |||
abc2d01485 | |||
![]() |
72cb6325d9 | ||
![]() |
c9d79088d8 | ||
9e513b2430 | |||
1a3d7a9520 | |||
147417d172 | |||
![]() |
b1b3f8b435 | ||
![]() |
8b066104e6 | ||
![]() |
0d1aaebfa5 | ||
![]() |
1d8ffe64b5 | ||
![]() |
6e1e46fca2 | ||
![]() |
80e338a60d | ||
![]() |
e34aa5e70b | ||
![]() |
507cfebd48 | ||
55ebc1c634 | |||
bfc7ed15d8 | |||
96c52ae2b4 | |||
132418d539 | |||
5092a98fba | |||
f5295d2c3d | |||
![]() |
73e1f575f8 | ||
![]() |
fe9fd37d9d | ||
c2035b801a | |||
3f7679710f | |||
560739627a | |||
0dd2736dd5 | |||
3a6faa65a8 | |||
e32e3d31ea | |||
002ffe64fc | |||
6fa6076f9f | |||
20e4e985f5 | |||
748bd206d1 | |||
b34da48e8c | |||
a6a825d623 | |||
855953d888 | |||
bfdaeb475b | |||
1deff46de8 | |||
e2a829f44f | |||
6451118e90 | |||
e5ce4585fd | |||
cb2781bad8 | |||
4d0291bd9f | |||
b0b7cf424d | |||
dfb2b17116 | |||
dab1eff314 | |||
54ef10307e | |||
94c5e5d51d | |||
1655fb1c25 | |||
f7f14328cf | |||
189f0beace | |||
0eeef56369 | |||
507a685019 | |||
8957167dfa | |||
21cf2b6185 | |||
7ccf3f8ce1 | |||
09a907d123 | |||
e336754226 | |||
82c6841523 | |||
1aef9cc952 | |||
90a70b4097 | |||
09e6ab009f | |||
d4c47f3ec7 | |||
4bc3a27254 |
|
@ -18,10 +18,10 @@ jobs:
|
||||||
run: npm install --global --save-exact @biomejs/biome
|
run: npm install --global --save-exact @biomejs/biome
|
||||||
|
|
||||||
- name: Lint with Biome
|
- name: Lint with Biome
|
||||||
run: npx biome ci .
|
run: npx @biomejs/biome ci
|
||||||
|
|
||||||
- name: Lint styles
|
- name: Lint styles
|
||||||
run: npm run lint:styles
|
run: npx stylelint **/*.{scss,css}
|
||||||
|
|
||||||
- name: Check types
|
- name: Check types
|
||||||
run: npm run typecheck
|
run: npm run typecheck
|
||||||
|
@ -29,6 +29,15 @@ jobs:
|
||||||
- name: Test production build
|
- name: Test production build
|
||||||
run: npm run build
|
run: npm run build
|
||||||
|
|
||||||
|
- name: Install Playwright
|
||||||
|
run: npx playwright install --with-deps
|
||||||
|
|
||||||
|
- name: Run Playwright tests
|
||||||
|
run: npm run e2e
|
||||||
|
env:
|
||||||
|
BASE_URL: ${{ github.event.deployment_status.target_url }}
|
||||||
|
DEBUG: pw:api
|
||||||
|
|
||||||
email-templates:
|
email-templates:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
name: Update templates on Mailgun
|
name: Update templates on Mailgun
|
||||||
|
|
64
.github/workflows/node-ci.yml
vendored
|
@ -1,42 +1,58 @@
|
||||||
name: "deploy"
|
name: "CI and E2E Tests"
|
||||||
|
|
||||||
on: [push]
|
on:
|
||||||
|
push:
|
||||||
|
deployment_status:
|
||||||
|
types: [success]
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
test:
|
ci:
|
||||||
|
if: github.event_name == 'push'
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
- uses: actions/setup-node@v4
|
- uses: actions/setup-node@v4
|
||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
|
run: npm i
|
||||||
|
- name: Install CI checks
|
||||||
run: npm ci
|
run: npm ci
|
||||||
|
|
||||||
- name: Check types
|
- name: Check types
|
||||||
run: npm run typecheck
|
run: npm run typecheck
|
||||||
|
|
||||||
- name: Lint with Biome
|
- name: Lint with Biome
|
||||||
run: npx biome ci .
|
run: npx @biomejs/biome check src/.
|
||||||
|
|
||||||
- name: Lint styles
|
- name: Lint styles
|
||||||
run: npm run lint:styles
|
run: npx stylelint **/*.{scss,css}
|
||||||
|
|
||||||
- name: Test production build
|
- name: Test production build
|
||||||
run: npm run build
|
run: npm run build
|
||||||
|
|
||||||
e2e:
|
e2e_tests:
|
||||||
timeout-minutes: 60
|
needs: ci
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
if: github.event.deployment_status.state == 'success'
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- name: Debug event info
|
||||||
- uses: actions/setup-node@v4
|
run: |
|
||||||
|
echo "Event Name: ${{ github.event_name }}"
|
||||||
- name: Install dependencies
|
echo "Deployment Status: ${{ github.event.deployment_status.state }}"
|
||||||
run: npm ci
|
- uses: actions/checkout@v4
|
||||||
- name: Install Playwright
|
- uses: actions/setup-node@v4
|
||||||
run: npx playwright install --with-deps
|
with:
|
||||||
- name: Run Playwright tests
|
node-version: '18'
|
||||||
run: npx playwright test
|
- name: Install dependencies
|
||||||
env:
|
run: npm install
|
||||||
BASE_URL: ${{ github.event.deployment_status.target_url }}
|
- name: Wait for deployment to be live
|
||||||
|
run: |
|
||||||
|
echo "Waiting for Vercel deployment to be live..."
|
||||||
|
until curl -sSf https://testing.discours.io > /dev/null; do
|
||||||
|
printf '.'
|
||||||
|
sleep 10
|
||||||
|
done
|
||||||
|
- name: Install Playwright and dependencies
|
||||||
|
run: npm run e2e:install
|
||||||
|
- name: Run Playwright tests
|
||||||
|
run: npm run e2e:tests:ci
|
||||||
|
env:
|
||||||
|
BASE_URL: https://testing.discours.io
|
||||||
|
continue-on-error: true
|
||||||
|
- name: Report test result if failed
|
||||||
|
if: failure()
|
||||||
|
run: echo "E2E tests failed. Please review the logs."
|
||||||
|
|
11
.gitignore
vendored
|
@ -1,8 +1,9 @@
|
||||||
|
.devcontainer
|
||||||
|
.pnpm-store
|
||||||
dist/
|
dist/
|
||||||
node_modules/
|
node_modules/
|
||||||
npm-debug.log*
|
npm-debug.log*
|
||||||
pnpm-debug.log*
|
pnpm-debug.log*
|
||||||
.vscode
|
|
||||||
.env
|
.env
|
||||||
.env.production
|
.env.production
|
||||||
.DS_Store
|
.DS_Store
|
||||||
|
@ -22,3 +23,11 @@ bun.lockb
|
||||||
/blob-report/
|
/blob-report/
|
||||||
/playwright/.cache/
|
/playwright/.cache/
|
||||||
/plawright-report/
|
/plawright-report/
|
||||||
|
target
|
||||||
|
.github/dependabot.yml
|
||||||
|
.output
|
||||||
|
.vinxi
|
||||||
|
*.pem
|
||||||
|
edge.*
|
||||||
|
.vscode/settings.json
|
||||||
|
storybook-static
|
||||||
|
|
|
@ -1,5 +0,0 @@
|
||||||
{
|
|
||||||
"*.{js,ts,cjs,mjs,d.mts,jsx,tsx,json,jsonc}": [
|
|
||||||
"npx @biomejs/biome check ./src && tsc"
|
|
||||||
]
|
|
||||||
}
|
|
49
.storybook/main.ts
Normal file
|
@ -0,0 +1,49 @@
|
||||||
|
import type { FrameworkOptions, StorybookConfig } from 'storybook-solidjs-vite'
|
||||||
|
|
||||||
|
const config: StorybookConfig = {
|
||||||
|
stories: ['../src/**/*.stories.@(js|jsx|ts|tsx|mdx)'],
|
||||||
|
addons: [
|
||||||
|
'@storybook/addon-links',
|
||||||
|
'@storybook/addon-essentials',
|
||||||
|
'@storybook/addon-interactions',
|
||||||
|
'@storybook/addon-a11y',
|
||||||
|
'@storybook/addon-themes',
|
||||||
|
'storybook-addon-sass-postcss'
|
||||||
|
],
|
||||||
|
framework: {
|
||||||
|
name: 'storybook-solidjs-vite',
|
||||||
|
options: {
|
||||||
|
builder: {
|
||||||
|
viteConfigPath: './vite.config.ts'
|
||||||
|
}
|
||||||
|
} as FrameworkOptions
|
||||||
|
},
|
||||||
|
docs: {
|
||||||
|
autodocs: 'tag'
|
||||||
|
},
|
||||||
|
viteFinal: (config) => {
|
||||||
|
if (config.build) {
|
||||||
|
config.build.sourcemap = true
|
||||||
|
config.build.minify = process.env.NODE_ENV === 'production'
|
||||||
|
}
|
||||||
|
if (config.css) {
|
||||||
|
config.css.preprocessorOptions = {
|
||||||
|
scss: {
|
||||||
|
silenceDeprecations: ['mixed-decls'],
|
||||||
|
additionalData: '@import "~/styles/imports";\n',
|
||||||
|
includePaths: ['./public', './src/styles', './node_modules']
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return config
|
||||||
|
},
|
||||||
|
previewHead: (head) => `
|
||||||
|
${head}
|
||||||
|
<style>
|
||||||
|
body {
|
||||||
|
transition: none !important;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
`
|
||||||
|
}
|
||||||
|
export default config
|
34
.storybook/preview.ts
Normal file
|
@ -0,0 +1,34 @@
|
||||||
|
import { withThemeByClassName } from '@storybook/addon-themes'
|
||||||
|
import '../src/styles/app.scss'
|
||||||
|
|
||||||
|
const preview = {
|
||||||
|
parameters: {
|
||||||
|
themes: {
|
||||||
|
default: 'light',
|
||||||
|
list: [
|
||||||
|
{ name: 'light', class: '', color: '#f8fafc' },
|
||||||
|
{ name: 'dark', class: 'dark', color: '#0f172a' }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
actions: { argTypesRegex: '^on[A-Z].*' },
|
||||||
|
controls: {
|
||||||
|
matchers: {
|
||||||
|
color: /(background|color)$/i,
|
||||||
|
date: /Date$/
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default preview
|
||||||
|
|
||||||
|
export const decorators = [
|
||||||
|
withThemeByClassName({
|
||||||
|
themes: {
|
||||||
|
light: '',
|
||||||
|
dark: 'dark'
|
||||||
|
},
|
||||||
|
defaultTheme: 'light',
|
||||||
|
parentSelector: 'body'
|
||||||
|
})
|
||||||
|
]
|
23
.storybook/test-runner.ts
Normal file
|
@ -0,0 +1,23 @@
|
||||||
|
import type { Page } from '@playwright/test'
|
||||||
|
import type { TestRunnerConfig } from '@storybook/test-runner'
|
||||||
|
import { checkA11y, injectAxe } from 'axe-playwright'
|
||||||
|
|
||||||
|
/*
|
||||||
|
* See https://storybook.js.org/docs/react/writing-tests/test-runner#test-hook-api-experimental
|
||||||
|
* to learn more about the test-runner hooks API.
|
||||||
|
*/
|
||||||
|
const a11yConfig = {
|
||||||
|
async preRender(page: Page) {
|
||||||
|
await injectAxe(page)
|
||||||
|
},
|
||||||
|
async postRender(page: Page) {
|
||||||
|
await checkA11y(page, '#storybook-root', {
|
||||||
|
detailedReport: true,
|
||||||
|
detailedReportOptions: {
|
||||||
|
html: true
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
} as TestRunnerConfig
|
||||||
|
|
||||||
|
module.exports = a11yConfig
|
|
@ -1,2 +1,6 @@
|
||||||
.vercel/
|
node_modules
|
||||||
dist/
|
dist/
|
||||||
|
storybook-static
|
||||||
|
.output
|
||||||
|
.vinxi
|
||||||
|
.vercel
|
||||||
|
|
|
@ -1,34 +1,73 @@
|
||||||
{
|
{
|
||||||
"extends": ["stylelint-config-standard-scss"],
|
"defaultSeverity": "warning",
|
||||||
|
"extends": ["stylelint-config-standard-scss", "stylelint-config-recommended"],
|
||||||
"plugins": ["stylelint-order", "stylelint-scss"],
|
"plugins": ["stylelint-order", "stylelint-scss"],
|
||||||
"rules": {
|
"rules": {
|
||||||
"keyframes-name-pattern": null,
|
"annotation-no-unknown": [
|
||||||
"declaration-block-no-redundant-longhand-properties": null,
|
|
||||||
"selector-class-pattern": null,
|
|
||||||
"no-descending-specificity": null,
|
|
||||||
"scss/function-no-unknown": null,
|
|
||||||
"scss/no-global-function-names": null,
|
|
||||||
"function-url-quotes": null,
|
|
||||||
"font-family-no-missing-generic-family-keyword": null,
|
|
||||||
"order/order": ["custom-properties", "declarations"],
|
|
||||||
"scss/dollar-variable-pattern": [
|
|
||||||
"^[a-z][a-zA-Z]+$",
|
|
||||||
{
|
|
||||||
"ignore": "global"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"selector-pseudo-class-no-unknown": [
|
|
||||||
true,
|
true,
|
||||||
{
|
{
|
||||||
"ignorePseudoClasses": ["global", "export"]
|
"ignoreAnnotations": ["default"]
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
|
"at-rule-no-unknown": null,
|
||||||
|
"declaration-block-no-redundant-longhand-properties": null,
|
||||||
|
"font-family-no-missing-generic-family-keyword": null,
|
||||||
|
"function-no-unknown": [
|
||||||
|
true,
|
||||||
|
{
|
||||||
|
"ignoreFunctions": ["divide", "transparentize"]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"function-url-quotes": null,
|
||||||
|
"keyframes-name-pattern": null,
|
||||||
|
"no-descending-specificity": null,
|
||||||
|
"order/order": [
|
||||||
|
{
|
||||||
|
"type": "at-rule",
|
||||||
|
"name": "include"
|
||||||
|
},
|
||||||
|
"custom-properties",
|
||||||
|
"declarations",
|
||||||
|
"rules"
|
||||||
|
],
|
||||||
"property-no-vendor-prefix": [
|
"property-no-vendor-prefix": [
|
||||||
true,
|
true,
|
||||||
{
|
{
|
||||||
"ignoreProperties": ["box-decoration-break"]
|
"ignoreProperties": ["box-decoration-break"]
|
||||||
}
|
}
|
||||||
|
],
|
||||||
|
"scss/at-function-pattern": null,
|
||||||
|
"scss/at-mixin-pattern": null,
|
||||||
|
"scss/dollar-variable-colon-space-after": "always-single-line",
|
||||||
|
"scss/dollar-variable-colon-space-before": "never",
|
||||||
|
"scss/dollar-variable-pattern": [
|
||||||
|
"^[a-z][a-zA-Z]+$",
|
||||||
|
{
|
||||||
|
"ignore": "global"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"scss/double-slash-comment-empty-line-before": [
|
||||||
|
"always",
|
||||||
|
{
|
||||||
|
"except": ["first-nested"],
|
||||||
|
"ignore": ["between-comments", "stylelint-commands"]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"scss/double-slash-comment-whitespace-inside": "always",
|
||||||
|
"scss/function-no-unknown": null,
|
||||||
|
"scss/no-duplicate-dollar-variables": null,
|
||||||
|
"scss/no-duplicate-mixins": null,
|
||||||
|
"scss/no-global-function-names": null,
|
||||||
|
"scss/operator-no-newline-after": null,
|
||||||
|
"scss/operator-no-newline-before": null,
|
||||||
|
"scss/operator-no-unspaced": null,
|
||||||
|
"scss/percent-placeholder-pattern": null,
|
||||||
|
"selector-class-pattern": null,
|
||||||
|
"selector-pseudo-class-no-unknown": [
|
||||||
|
true,
|
||||||
|
{
|
||||||
|
"ignorePseudoClasses": ["global", "export"]
|
||||||
|
}
|
||||||
]
|
]
|
||||||
},
|
}
|
||||||
"defaultSeverity": "warning"
|
|
||||||
}
|
}
|
||||||
|
|
3
.vscode/extension.json
vendored
Normal file
|
@ -0,0 +1,3 @@
|
||||||
|
{
|
||||||
|
"recommendations": ["biomejs.biome", "stylelint.vscode-stylelint", "wayou.vscode-todo-highlight"]
|
||||||
|
}
|
5
.vscode/settings.json
vendored
Normal file
|
@ -0,0 +1,5 @@
|
||||||
|
{
|
||||||
|
"editor.codeActionsOnSave": {
|
||||||
|
"source.organizeImports.biome": "always"
|
||||||
|
}
|
||||||
|
}
|
57
README.en.md
Normal file
|
@ -0,0 +1,57 @@
|
||||||
|
## Development setup recommendations
|
||||||
|
|
||||||
|
### How to start
|
||||||
|
|
||||||
|
Use `bun i`, `npm i`, `pnpm i` or `yarn` to install packages.
|
||||||
|
|
||||||
|
### Config of variables
|
||||||
|
|
||||||
|
- Use `.env` file to setup your own development environment
|
||||||
|
- Env vars with prefix `PUBLIC_` are widely used in `/src/utils/config.ts`
|
||||||
|
|
||||||
|
### Useful commands
|
||||||
|
|
||||||
|
run checks, fix styles, imports, formatting and autofixable linting errors:
|
||||||
|
```
|
||||||
|
bun run typecheck
|
||||||
|
bun run fix
|
||||||
|
```
|
||||||
|
|
||||||
|
## End-to-End (E2E) Tests
|
||||||
|
|
||||||
|
This directory contains end-to-end tests. These tests are written using [Playwright](https://playwright.dev/)
|
||||||
|
|
||||||
|
### Structure
|
||||||
|
|
||||||
|
- `/tests/*`: This directory contains the test files.
|
||||||
|
- `/playwright.config.ts`: This is the configuration file for Playwright.
|
||||||
|
|
||||||
|
### Getting Started
|
||||||
|
|
||||||
|
Follow these steps:
|
||||||
|
|
||||||
|
1. **Install dependencies**: Run `npm run e2e:install` to install the necessary dependencies for running the tests.
|
||||||
|
|
||||||
|
2. **Run the tests**: After using `npm run e2e:tests`.
|
||||||
|
|
||||||
|
### Additional Information
|
||||||
|
|
||||||
|
If workers is no needed use:
|
||||||
|
- `npx playwright test --project=webkit --workers 4`
|
||||||
|
|
||||||
|
For more information on how to write tests using Playwright - [Playwright documentation](https://playwright.dev/docs/intro).
|
||||||
|
|
||||||
|
### 🚀 Tests in CI Mode
|
||||||
|
|
||||||
|
Tests are executed within a GitHub workflow. We organize our tests into two main directories:
|
||||||
|
|
||||||
|
- `tests`: Contains tests that do not require authentication.
|
||||||
|
- `tests-with-auth`: Houses tests that interact with authenticated parts of the application.
|
||||||
|
|
||||||
|
🔧 **Configuration:**
|
||||||
|
|
||||||
|
Playwright is configured to utilize the `BASE_URL` environment variable. Ensure this is properly set in your CI configuration to point to the correct environment.
|
||||||
|
|
||||||
|
📝 **Note:**
|
||||||
|
|
||||||
|
After pages have been adjusted to work with authentication, all tests should be moved to the `tests` directory to streamline the testing process.
|
77
README.md
|
@ -1,30 +1,57 @@
|
||||||
## How to start
|
[English](README.en.md)
|
||||||
|
|
||||||
|
## Рекомендации по настройке разработки
|
||||||
|
|
||||||
|
### Как начать
|
||||||
|
|
||||||
|
Используйте `bun i`, `npm i`, `pnpm i` или `yarn`, чтобы установить пакеты.
|
||||||
|
|
||||||
|
### Настройка переменных
|
||||||
|
|
||||||
|
- Используйте файл `.env` для настройки переменных собственной среды разработки.
|
||||||
|
- Переменные окружения с префиксом `PUBLIC_` широко используются в `/src/utils/config.ts`.
|
||||||
|
|
||||||
|
### Полезные команды
|
||||||
|
|
||||||
|
Запуск проверки соответствия типов и автоматически исправить ошибки стилей, порядок импорта, форматирование:
|
||||||
|
|
||||||
```
|
```
|
||||||
npm install
|
bun run typecheck
|
||||||
npm start
|
bun run fix
|
||||||
```
|
```
|
||||||
|
|
||||||
## Useful commands
|
## End-to-End (E2E) тесты
|
||||||
run checks
|
|
||||||
```
|
|
||||||
npm run check
|
|
||||||
```
|
|
||||||
type checking with watch
|
|
||||||
```
|
|
||||||
npm run typecheck:watch
|
|
||||||
```
|
|
||||||
fix styles, imports, formatting and autofixable linting errors:
|
|
||||||
```
|
|
||||||
npm run fix
|
|
||||||
```
|
|
||||||
## Code generation
|
|
||||||
|
|
||||||
generate new SolidJS component:
|
End-to-end тесты написаны с использованием [Playwright](https://playwright.dev/).
|
||||||
```
|
|
||||||
npm run hygen component new NewComponentName
|
|
||||||
```
|
|
||||||
|
|
||||||
generate new SolidJS context:
|
### Структура
|
||||||
```
|
|
||||||
npm run hygen context new NewContextName
|
- `/tests/*`: содержит файлы тестов
|
||||||
```
|
- `/playwright.config.ts`: конфиг для Playwright
|
||||||
|
|
||||||
|
### Начало работы
|
||||||
|
|
||||||
|
Следуйте этим шагам:
|
||||||
|
|
||||||
|
1. **Установите зависимости**: Запустите `npm run e2e:install`, чтобы установить необходимые зависимости для выполнения тестов.
|
||||||
|
|
||||||
|
2. **Запустите тесты**: После установки зависимостей используйте `npm run e2e:tests`.
|
||||||
|
|
||||||
|
### Дополнительная информация
|
||||||
|
|
||||||
|
Для параллельного исполнения:
|
||||||
|
- `npx playwright test --project=webkit --workers 4`
|
||||||
|
|
||||||
|
Для получения дополнительной информации о написании тестов с использованием Playwright - [Документация Playwright](https://playwright.dev/docs/intro).
|
||||||
|
|
||||||
|
### 🚀 Тесты в режиме CI
|
||||||
|
|
||||||
|
Тесты выполняются в рамках GitHub workflow из папки `tests`
|
||||||
|
|
||||||
|
🔧 **Конфигурация:**
|
||||||
|
|
||||||
|
Playwright настроен на использование переменной окружения `BASE_URL`. Убедитесь, что она правильно установлена в вашей конфигурации CI для указания на правильную среду.
|
||||||
|
|
||||||
|
📝 **Примечание:**
|
||||||
|
|
||||||
|
После того как страницы были настроены для работы с аутентификацией, все тесты должны быть перемещены в директорию `tests` для упрощения процесса тестирования.
|
||||||
|
|
|
@ -1,32 +0,0 @@
|
||||||
import { renderPage } from 'vike/server'
|
|
||||||
|
|
||||||
export const config = {
|
|
||||||
runtime: 'edge',
|
|
||||||
}
|
|
||||||
export default async function handler(request) {
|
|
||||||
const { url, cookies } = request
|
|
||||||
|
|
||||||
const pageContext = await renderPage({ urlOriginal: url, cookies })
|
|
||||||
|
|
||||||
const { httpResponse, errorWhileRendering, is404 } = pageContext
|
|
||||||
|
|
||||||
if (errorWhileRendering && !is404) {
|
|
||||||
console.error(errorWhileRendering)
|
|
||||||
return new Response('', { status: 500 })
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!httpResponse) {
|
|
||||||
return new Response()
|
|
||||||
}
|
|
||||||
|
|
||||||
const { body, statusCode, headers: headersArray } = httpResponse
|
|
||||||
|
|
||||||
const headers = headersArray.reduce((acc, [name, value]) => {
|
|
||||||
acc[name] = value
|
|
||||||
return acc
|
|
||||||
}, {})
|
|
||||||
|
|
||||||
headers['Cache-Control'] = 's-maxage=1, stale-while-revalidate'
|
|
||||||
|
|
||||||
return new Response(body, { status: statusCode, headers })
|
|
||||||
}
|
|
|
@ -1,10 +1,8 @@
|
||||||
const formData = require('form-data')
|
import FormData from 'form-data'
|
||||||
const Mailgun = require('mailgun.js')
|
import Mailgun from 'mailgun.js'
|
||||||
|
|
||||||
const mailgun = new Mailgun(formData)
|
const mailgun = new Mailgun(FormData)
|
||||||
|
const mg = mailgun.client({ username: 'discoursio', key: process.env.MAILGUN_API_KEY })
|
||||||
const { MAILGUN_API_KEY, MAILGUN_DOMAIN } = process.env
|
|
||||||
const mg = mailgun.client({ username: 'discoursio', key: MAILGUN_API_KEY })
|
|
||||||
|
|
||||||
export default async function handler(req, res) {
|
export default async function handler(req, res) {
|
||||||
const { contact, subject, message } = req.body
|
const { contact, subject, message } = req.body
|
||||||
|
@ -15,11 +13,11 @@ export default async function handler(req, res) {
|
||||||
from: 'Discours Feedback Robot <robot@discours.io>',
|
from: 'Discours Feedback Robot <robot@discours.io>',
|
||||||
to: 'welcome@discours.io',
|
to: 'welcome@discours.io',
|
||||||
subject,
|
subject,
|
||||||
text,
|
text
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await mg.messages.create(MAILGUN_DOMAIN, data)
|
const response = await mg.messages.create('discours.io', data)
|
||||||
console.log('Email sent successfully!', response)
|
console.log('Email sent successfully!', response)
|
||||||
res.status(200).json({ result: 'great success' })
|
res.status(200).json({ result: 'great success' })
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|
|
@ -1,10 +1,8 @@
|
||||||
const formData = require('form-data')
|
import FormData from 'form-data'
|
||||||
const Mailgun = require('mailgun.js')
|
import Mailgun from 'mailgun.js'
|
||||||
|
|
||||||
const mailgun = new Mailgun(formData)
|
const mailgun = new Mailgun(FormData)
|
||||||
|
const mg = mailgun.client({ username: 'discoursio', key: process.env.MAILGUN_API_KEY })
|
||||||
const { MAILGUN_API_KEY } = process.env
|
|
||||||
const mg = mailgun.client({ username: 'discoursio', key: MAILGUN_API_KEY })
|
|
||||||
|
|
||||||
export default async (req, res) => {
|
export default async (req, res) => {
|
||||||
const { email } = req.body
|
const { email } = req.body
|
||||||
|
@ -13,18 +11,18 @@ export default async (req, res) => {
|
||||||
const response = await mg.lists.members.createMember('newsletter@discours.io', {
|
const response = await mg.lists.members.createMember('newsletter@discours.io', {
|
||||||
address: email,
|
address: email,
|
||||||
subscribed: true,
|
subscribed: true,
|
||||||
upsert: 'yes',
|
upsert: 'yes'
|
||||||
})
|
})
|
||||||
|
|
||||||
return res.status(200).json({
|
return res.status(200).json({
|
||||||
success: true,
|
success: true,
|
||||||
message: 'Email was added to newsletter list',
|
message: 'Email was added to newsletter list',
|
||||||
response: JSON.stringify(response),
|
response: JSON.stringify(response)
|
||||||
})
|
})
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return res.status(400).json({
|
return res.status(400).json({
|
||||||
success: false,
|
success: false,
|
||||||
message: error.message,
|
message: error.message
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
23
app.config.ts
Normal file
|
@ -0,0 +1,23 @@
|
||||||
|
import { SolidStartInlineConfig, defineConfig } from '@solidjs/start/config'
|
||||||
|
import viteConfig, { isDev } from './vite.config'
|
||||||
|
|
||||||
|
const isVercel = Boolean(process.env.VERCEL)
|
||||||
|
const isNetlify = Boolean(process.env.NETLIFY)
|
||||||
|
const isBun = Boolean(process.env.BUN)
|
||||||
|
|
||||||
|
const preset = isNetlify ? 'netlify' : isVercel ? 'vercel_edge' : isBun ? 'bun' : 'node'
|
||||||
|
console.info(`[app.config] solid-start preset {> ${preset} <}`)
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
nitro: {
|
||||||
|
timing: true
|
||||||
|
},
|
||||||
|
ssr: true,
|
||||||
|
server: {
|
||||||
|
preset,
|
||||||
|
port: 3000,
|
||||||
|
https: true
|
||||||
|
},
|
||||||
|
devOverlay: isDev,
|
||||||
|
vite: viteConfig
|
||||||
|
} as SolidStartInlineConfig)
|
39
biome.json
|
@ -1,16 +1,18 @@
|
||||||
{
|
{
|
||||||
"$schema": "https://biomejs.dev/schemas/1.5.3/schema.json",
|
"$schema": "https://biomejs.dev/schemas/1.9.3/schema.json",
|
||||||
"files": {
|
"files": {
|
||||||
"include": ["*.tsx", "*.ts", "*.js", "*.json"],
|
"include": ["*.tsx", "*.ts", "*.js", "*.json"],
|
||||||
"ignore": ["./dist", "./node_modules", ".husky", "docs", "gen", "*.d.ts"]
|
"ignore": ["./dist", "./node_modules", ".husky", "docs", "gen", "*.gen.ts", "*.d.ts"]
|
||||||
},
|
},
|
||||||
"vcs": {
|
"vcs": {
|
||||||
"defaultBranch": "dev",
|
"defaultBranch": "dev",
|
||||||
"useIgnoreFile": true
|
"useIgnoreFile": true,
|
||||||
|
"enabled": true,
|
||||||
|
"clientKind": "git"
|
||||||
},
|
},
|
||||||
"organizeImports": {
|
"organizeImports": {
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
"ignore": ["./api", "./gen"]
|
"ignore": ["./gen"]
|
||||||
},
|
},
|
||||||
"formatter": {
|
"formatter": {
|
||||||
"indentStyle": "space",
|
"indentStyle": "space",
|
||||||
|
@ -22,10 +24,10 @@
|
||||||
"formatter": {
|
"formatter": {
|
||||||
"semicolons": "asNeeded",
|
"semicolons": "asNeeded",
|
||||||
"quoteStyle": "single",
|
"quoteStyle": "single",
|
||||||
"trailingComma": "all",
|
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
"jsxQuoteStyle": "double",
|
"jsxQuoteStyle": "double",
|
||||||
"arrowParentheses": "always"
|
"arrowParentheses": "always",
|
||||||
|
"trailingCommas": "none"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"linter": {
|
"linter": {
|
||||||
|
@ -36,10 +38,13 @@
|
||||||
"complexity": {
|
"complexity": {
|
||||||
"noForEach": "off",
|
"noForEach": "off",
|
||||||
"useOptionalChain": "warn",
|
"useOptionalChain": "warn",
|
||||||
"useLiteralKeys": "off"
|
"useLiteralKeys": "off",
|
||||||
|
"noExcessiveCognitiveComplexity": "off"
|
||||||
},
|
},
|
||||||
"correctness": {
|
"correctness": {
|
||||||
"useHookAtTopLevel": "off"
|
"useHookAtTopLevel": "off",
|
||||||
|
"useImportExtensions": "off",
|
||||||
|
"noUndeclaredDependencies": "off"
|
||||||
},
|
},
|
||||||
"a11y": {
|
"a11y": {
|
||||||
"useHeadingContent": "off",
|
"useHeadingContent": "off",
|
||||||
|
@ -51,20 +56,28 @@
|
||||||
"useAltText": "off",
|
"useAltText": "off",
|
||||||
"useButtonType": "off",
|
"useButtonType": "off",
|
||||||
"noRedundantAlt": "off",
|
"noRedundantAlt": "off",
|
||||||
"noSvgWithoutTitle": "off"
|
"noSvgWithoutTitle": "off",
|
||||||
|
"noLabelWithoutControl": "off"
|
||||||
},
|
},
|
||||||
"nursery": {
|
"nursery": {
|
||||||
"useImportRestrictions": "off",
|
"useImportRestrictions": "off"
|
||||||
"useImportType": "off",
|
},
|
||||||
"useFilenamingConvention": "off"
|
"performance": {
|
||||||
|
"noBarrelFile": "off"
|
||||||
},
|
},
|
||||||
"style": {
|
"style": {
|
||||||
|
"noNonNullAssertion": "off",
|
||||||
|
"noNamespaceImport": "warn",
|
||||||
"useBlockStatements": "off",
|
"useBlockStatements": "off",
|
||||||
"noImplicitBoolean": "off",
|
"noImplicitBoolean": "off",
|
||||||
"useNamingConvention": "off",
|
"useNamingConvention": "off",
|
||||||
"noDefaultExport": "off"
|
"useImportType": "off",
|
||||||
|
"noDefaultExport": "off",
|
||||||
|
"useFilenamingConvention": "off",
|
||||||
|
"useExplicitLengthCheck": "off"
|
||||||
},
|
},
|
||||||
"suspicious": {
|
"suspicious": {
|
||||||
|
"noConsole": "off",
|
||||||
"noConsoleLog": "off",
|
"noConsoleLog": "off",
|
||||||
"noAssignInExpressions": "off"
|
"noAssignInExpressions": "off"
|
||||||
}
|
}
|
||||||
|
|
30
codegen.yml
|
@ -25,33 +25,3 @@ generates:
|
||||||
useTypeImports: true
|
useTypeImports: true
|
||||||
outputPath: './src/graphql/types/core.gen.ts'
|
outputPath: './src/graphql/types/core.gen.ts'
|
||||||
# namingConvention: change-case#CamelCase # for generated types
|
# namingConvention: change-case#CamelCase # for generated types
|
||||||
|
|
||||||
# Generate types for notifier
|
|
||||||
src/graphql/schema/notifier.gen.ts:
|
|
||||||
schema: 'https://notifier.discours.io'
|
|
||||||
plugins:
|
|
||||||
- 'typescript'
|
|
||||||
- 'typescript-operations'
|
|
||||||
- 'typescript-urql'
|
|
||||||
config:
|
|
||||||
skipTypename: true
|
|
||||||
useTypeImports: true
|
|
||||||
outputPath: './src/graphql/types/notifier.gen.ts'
|
|
||||||
# namingConvention: change-case#CamelCase # for generated types
|
|
||||||
|
|
||||||
# internal types for auth
|
|
||||||
# src/graphql/schema/auth.gen.ts:
|
|
||||||
# schema: 'https://auth.discours.io/graphql'
|
|
||||||
# plugins:
|
|
||||||
# - 'typescript'
|
|
||||||
# - 'typescript-operations'
|
|
||||||
# - 'typescript-urql'
|
|
||||||
# config:
|
|
||||||
# skipTypename: true
|
|
||||||
# useTypeImports: true
|
|
||||||
# outputPath: './src/graphql/types/auth.gen.ts'
|
|
||||||
# namingConvention: change-case#CamelCase # for generated types
|
|
||||||
|
|
||||||
hooks:
|
|
||||||
afterAllFileWrite:
|
|
||||||
- prettier --ignore-path .gitignore --write --plugin-search-dir=. src/graphql/schema/*.gen.ts
|
|
||||||
|
|
|
@ -1,66 +0,0 @@
|
||||||
@startuml
|
|
||||||
actor User
|
|
||||||
participant Browser
|
|
||||||
participant Vercel
|
|
||||||
participant article.page.server.ts
|
|
||||||
participant Solid
|
|
||||||
participant Store
|
|
||||||
|
|
||||||
User -> Browser: discours.io
|
|
||||||
activate Browser
|
|
||||||
Browser -> Vercel: GET <slug>
|
|
||||||
activate Vercel
|
|
||||||
Vercel -> article.page.server.ts: render
|
|
||||||
activate article.page.server.ts
|
|
||||||
article.page.server.ts -> apiClient: getArticle({ slug })
|
|
||||||
activate apiClient
|
|
||||||
apiClient -> DB: query: articleBySlug
|
|
||||||
activate DB
|
|
||||||
DB --> apiClient: response
|
|
||||||
deactivate DB
|
|
||||||
apiClient --> article.page.server.ts: article data
|
|
||||||
deactivate apiClient
|
|
||||||
article.page.server.ts -> Solid: render <ArticlePage article={article} />
|
|
||||||
activate Solid
|
|
||||||
Solid -> Store: useCurrentArticleStore(article)
|
|
||||||
activate Store
|
|
||||||
Store -> Store: create store with initial data (server)
|
|
||||||
Store --> Solid: currentArticle
|
|
||||||
deactivate Store
|
|
||||||
Solid -> Solid: render component
|
|
||||||
Solid --> article.page.server.ts: rendered component
|
|
||||||
deactivate Solid
|
|
||||||
article.page.server.ts --> Vercel: rendered page
|
|
||||||
Vercel -> Vercel: save rendered page to CDN
|
|
||||||
deactivate article.page.server.ts
|
|
||||||
Vercel --> Browser: rendered page
|
|
||||||
deactivate Vercel
|
|
||||||
Browser --> User: rendered page
|
|
||||||
deactivate Browser
|
|
||||||
Browser -> Browser: load client scripts
|
|
||||||
Browser -> Solid: render <ArticlePage article={article} />
|
|
||||||
Solid -> Store: useCurrentArticleStore(article)
|
|
||||||
activate Store
|
|
||||||
Store -> Store: create store with initial data (client)
|
|
||||||
Store --> Solid: currentArticle
|
|
||||||
deactivate Store
|
|
||||||
Solid -> Solid: render component (no changes)
|
|
||||||
Solid -> Solid: onMount
|
|
||||||
Solid -> Store: loadArticleComments
|
|
||||||
activate Store
|
|
||||||
Store -> apiClient: getArticleComments
|
|
||||||
activate apiClient
|
|
||||||
apiClient -> DB: query: getReactions
|
|
||||||
activate DB
|
|
||||||
DB --> apiClient: response
|
|
||||||
deactivate DB
|
|
||||||
apiClient --> Store: comments data
|
|
||||||
deactivate apiClient
|
|
||||||
Store -> Store: update store
|
|
||||||
Store --> Solid: store updated
|
|
||||||
deactivate Store
|
|
||||||
Solid -> Solid: render comments
|
|
||||||
Solid --> Browser: rendered comments
|
|
||||||
Browser --> User: comments
|
|
||||||
@enduml
|
|
||||||
|
|
|
@ -1,40 +0,0 @@
|
||||||
@startuml
|
|
||||||
actor User
|
|
||||||
participant Browser
|
|
||||||
participant Server
|
|
||||||
|
|
||||||
User -> Browser: discours.io
|
|
||||||
activate Browser
|
|
||||||
Browser -> Server: GET\nquery { lng }\ncookies { lng }
|
|
||||||
opt lng in query
|
|
||||||
Server -> Server: lng = lng from query
|
|
||||||
else no lng in query
|
|
||||||
opt lng in cookies
|
|
||||||
Server -> Server: lng = lng from cookies
|
|
||||||
else no lng in cookies
|
|
||||||
Server -> Server: lng = 'ru'
|
|
||||||
end opt
|
|
||||||
end opt
|
|
||||||
note right
|
|
||||||
_dafault.page.server.ts render
|
|
||||||
end note
|
|
||||||
|
|
||||||
opt i18next is not initialized
|
|
||||||
Server -> Server: initialize i18next with lng
|
|
||||||
else i18next not initialized
|
|
||||||
Server -> Server: change i18next language to lng
|
|
||||||
end opt
|
|
||||||
note right
|
|
||||||
all resources loaded synchronously
|
|
||||||
end note
|
|
||||||
Server --> Browser: pageContext { lng }
|
|
||||||
Browser -> Browser: init client side i18next with http backend
|
|
||||||
activate Browser
|
|
||||||
Browser -> Server: get translations for current language
|
|
||||||
Server --> Browser: translations JSON
|
|
||||||
deactivate Browser
|
|
||||||
Browser -> Browser: render page
|
|
||||||
Browser --> User: rendered page
|
|
||||||
deactivate Browser
|
|
||||||
@enduml
|
|
||||||
|
|
|
@ -1,24 +0,0 @@
|
||||||
@startuml
|
|
||||||
actor User
|
|
||||||
participant Browser
|
|
||||||
participant Server
|
|
||||||
|
|
||||||
User -> Browser: discours.io
|
|
||||||
activate Browser
|
|
||||||
Browser -> Server: GET
|
|
||||||
activate Server
|
|
||||||
Server -> Server: resolve route
|
|
||||||
note right
|
|
||||||
based on routes from
|
|
||||||
*.page.route.ts files
|
|
||||||
end note
|
|
||||||
Server -> Server: some.page.server.ts onBeforeRender
|
|
||||||
Server -> Server: _default.page.server.tsx render
|
|
||||||
Server --> Browser: pageContent
|
|
||||||
deactivate Server
|
|
||||||
Browser -> Browser: _default.page.client.tsx render(pageContext)
|
|
||||||
|
|
||||||
Browser --> User: rendered page
|
|
||||||
deactivate Browser
|
|
||||||
@enduml
|
|
||||||
|
|
|
@ -1,18 +0,0 @@
|
||||||
---
|
|
||||||
to: src/components/<%= h.changeCase.pascal(name) %>/<%= h.changeCase.pascal(name) %>.tsx
|
|
||||||
---
|
|
||||||
|
|
||||||
import { clsx } from 'clsx'
|
|
||||||
import styles from './<%= h.changeCase.pascal(name) %>.module.scss'
|
|
||||||
|
|
||||||
type Props = {
|
|
||||||
class?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export const <%= h.changeCase.pascal(name) %> = (props: Props) => {
|
|
||||||
return (
|
|
||||||
<div class={clsx(styles.<%= h.changeCase.pascal(name) %>, props.class)}>
|
|
||||||
<%= h.changeCase.pascal(name) %>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
|
@ -1,4 +0,0 @@
|
||||||
---
|
|
||||||
to: src/components/<%= h.changeCase.pascal(name) %>/index.ts
|
|
||||||
---
|
|
||||||
export { <%= h.changeCase.pascal(name) %> } from './<%= h.changeCase.pascal(name) %>'
|
|
|
@ -1,7 +0,0 @@
|
||||||
---
|
|
||||||
to: src/components/<%= h.changeCase.pascal(name) %>/<%= h.changeCase.pascal(name) %>.module.scss
|
|
||||||
---
|
|
||||||
|
|
||||||
.<%= h.changeCase.pascal(name) %> {
|
|
||||||
display: block;
|
|
||||||
}
|
|
|
@ -1,24 +0,0 @@
|
||||||
---
|
|
||||||
to: src/context/<%= h.changeCase.camel(name) %>.tsx
|
|
||||||
---
|
|
||||||
import type { Accessor, JSX } from 'solid-js'
|
|
||||||
import { createContext, createSignal, useContext } from 'solid-js'
|
|
||||||
|
|
||||||
type <%= h.changeCase.pascal(name) %>ContextType = {
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
const <%= h.changeCase.pascal(name) %>Context = createContext<<%= h.changeCase.pascal(name) %>ContextType>()
|
|
||||||
|
|
||||||
export function use<%= h.changeCase.pascal(name) %>() {
|
|
||||||
return useContext(<%= h.changeCase.pascal(name) %>Context)
|
|
||||||
}
|
|
||||||
|
|
||||||
export const <%= h.changeCase.pascal(name) %>Provider = (props: { children: JSX.Element }) => {
|
|
||||||
const actions = {
|
|
||||||
}
|
|
||||||
|
|
||||||
const value: <%= h.changeCase.pascal(name) %>ContextType = { ...actions }
|
|
||||||
|
|
||||||
return <<%= h.changeCase.pascal(name) %>Context.Provider value={value}>{props.children}</<%= h.changeCase.pascal(name) %>Context.Provider>
|
|
||||||
}
|
|
|
@ -1,5 +0,0 @@
|
||||||
---
|
|
||||||
message: |
|
|
||||||
hygen {bold generator new} --name [NAME] --action [ACTION]
|
|
||||||
hygen {bold generator with-prompt} --name [NAME] --action [ACTION]
|
|
||||||
---
|
|
|
@ -1,16 +0,0 @@
|
||||||
---
|
|
||||||
to: gen/<%= name %>/<%= action || 'new' %>/hello.ejs.t
|
|
||||||
---
|
|
||||||
---
|
|
||||||
to: app/hello.js
|
|
||||||
---
|
|
||||||
const hello = ```
|
|
||||||
Hello!
|
|
||||||
This is your first hygen template.
|
|
||||||
|
|
||||||
Learn what it can do here:
|
|
||||||
|
|
||||||
https://github.com/jondot/hygen
|
|
||||||
```
|
|
||||||
|
|
||||||
console.log(hello)
|
|
|
@ -1,16 +0,0 @@
|
||||||
---
|
|
||||||
to: gen/<%= name %>/<%= action || 'new' %>/hello.ejs.t
|
|
||||||
---
|
|
||||||
---
|
|
||||||
to: app/hello.js
|
|
||||||
---
|
|
||||||
const hello = ```
|
|
||||||
Hello!
|
|
||||||
This is your first prompt based hygen template.
|
|
||||||
|
|
||||||
Learn what it can do here:
|
|
||||||
|
|
||||||
https://github.com/jondot/hygen
|
|
||||||
```
|
|
||||||
|
|
||||||
console.log(hello)
|
|
|
@ -1,14 +0,0 @@
|
||||||
---
|
|
||||||
to: gen/<%= name %>/<%= action || 'new' %>/prompt.js
|
|
||||||
---
|
|
||||||
|
|
||||||
// see types of prompts:
|
|
||||||
// https://github.com/enquirer/enquirer/tree/master/examples
|
|
||||||
//
|
|
||||||
module.exports = [
|
|
||||||
{
|
|
||||||
type: 'input',
|
|
||||||
name: 'message',
|
|
||||||
message: "What's your message?"
|
|
||||||
}
|
|
||||||
]
|
|
|
@ -1,4 +0,0 @@
|
||||||
---
|
|
||||||
setup: <%= name %>
|
|
||||||
force: true # this is because mostly, people init into existing folders is safe
|
|
||||||
---
|
|
21717
package-lock.json
generated
257
package.json
|
@ -1,142 +1,151 @@
|
||||||
{
|
{
|
||||||
"name": "discoursio-webapp",
|
"name": "discoursio-webapp",
|
||||||
"version": "0.9.2",
|
|
||||||
"private": true,
|
"private": true,
|
||||||
"license": "MIT",
|
"version": "0.9.6",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "vite build",
|
"dev": "vinxi dev",
|
||||||
"check": "npm run lint && npm run typecheck",
|
"build": "vinxi build",
|
||||||
|
"start": "vinxi start",
|
||||||
"codegen": "graphql-codegen",
|
"codegen": "graphql-codegen",
|
||||||
"deploy": "graphql-codegen && npm run typecheck && vite build && vercel",
|
"e2e": "E2E=1 npm run e2e:tests",
|
||||||
"dev": "vite",
|
"e2e:tests": "npx playwright test --project=webkit",
|
||||||
"e2e": "npx playwright test --project=chromium",
|
"e2e:tests:ci": "CI=true npx playwright test --project=webkit",
|
||||||
"fix": "npm run check:code:fix && stylelint **/*.{scss,css} --fix",
|
"e2e:install": "npx playwright install webkit && npx playwright install-deps ",
|
||||||
|
"fix": "npx @biomejs/biome check . --fix && stylelint **/*.{scss,css} --fix",
|
||||||
"format": "npx @biomejs/biome format src/. --write",
|
"format": "npx @biomejs/biome format src/. --write",
|
||||||
"hygen": "HYGEN_TMPLS=gen hygen",
|
|
||||||
"postinstall": "npm run codegen && npx patch-package",
|
"postinstall": "npm run codegen && npx patch-package",
|
||||||
"check:code": "npx @biomejs/biome check src --log-kind=compact --verbose",
|
|
||||||
"check:code:fix": "npx @biomejs/biome check src --log-kind=compact --verbose --apply-unsafe",
|
|
||||||
"lint": "npm run lint:code && stylelint **/*.{scss,css}",
|
|
||||||
"lint:code": "npx @biomejs/biome lint src --log-kind=compact --verbose",
|
|
||||||
"lint:code:fix": "npx @biomejs/biome lint src --apply-unsafe --log-kind=compact --verbose",
|
|
||||||
"lint:styles": "stylelint **/*.{scss,css}",
|
|
||||||
"lint:styles:fix": "stylelint **/*.{scss,css} --fix",
|
|
||||||
"preview": "vite preview",
|
|
||||||
"start": "vite",
|
|
||||||
"typecheck": "tsc --noEmit",
|
"typecheck": "tsc --noEmit",
|
||||||
"typecheck:watch": "tsc --noEmit --watch"
|
"storybook": "storybook dev -p 6006",
|
||||||
},
|
"storybook:test": "test-storybook",
|
||||||
"dependencies": {
|
"build-storybook": "storybook build"
|
||||||
"form-data": "4.0.0",
|
|
||||||
"mailgun.js": "10.1.0"
|
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@authorizerdev/authorizer-js": "2.0.0",
|
"@authorizerdev/authorizer-js": "^2.0.3",
|
||||||
"@babel/core": "7.23.3",
|
"@biomejs/biome": "^1.9.3",
|
||||||
"@biomejs/biome": "^1.5.3",
|
"@graphql-codegen/cli": "^5.0.2",
|
||||||
"@graphql-codegen/cli": "^5.0.0",
|
"@graphql-codegen/typescript": "^4.0.9",
|
||||||
"@graphql-codegen/typescript": "^4.0.1",
|
"@graphql-codegen/typescript-operations": "^4.2.3",
|
||||||
"@graphql-codegen/typescript-operations": "^4.0.1",
|
|
||||||
"@graphql-codegen/typescript-urql": "^4.0.0",
|
"@graphql-codegen/typescript-urql": "^4.0.0",
|
||||||
"@graphql-tools/url-loader": "8.0.1",
|
"@hocuspocus/provider": "^2.13.6",
|
||||||
"@hocuspocus/provider": "2.11.0",
|
"@playwright/test": "^1.47.2",
|
||||||
"@microsoft/fetch-event-source": "^2.0.1",
|
"@popperjs/core": "^2.11.8",
|
||||||
"@nanostores/router": "0.13.0",
|
"@solid-primitives/media": "^2.2.9",
|
||||||
"@nanostores/solid": "0.4.2",
|
"@solid-primitives/memo": "^1.3.9",
|
||||||
"@playwright/test": "1.41.2",
|
"@solid-primitives/pagination": "^0.3.0",
|
||||||
"@popperjs/core": "2.11.8",
|
"@solid-primitives/script-loader": "^2.2.0",
|
||||||
"@sentry/browser": "7.99.0",
|
"@solid-primitives/share": "^2.0.6",
|
||||||
"@solid-primitives/media": "2.2.3",
|
"@solid-primitives/storage": "^4.2.1",
|
||||||
"@solid-primitives/memo": "1.2.4",
|
"@solid-primitives/upload": "^0.0.117",
|
||||||
"@solid-primitives/pagination": "0.2.10",
|
"@solidjs/meta": "^0.29.4",
|
||||||
"@solid-primitives/share": "2.0.4",
|
"@solidjs/router": "^0.14.7",
|
||||||
"@solid-primitives/storage": "1.3.9",
|
"@solidjs/start": "^1.0.8",
|
||||||
"@solid-primitives/upload": "0.0.110",
|
"@storybook/addon-a11y": "^8.3.4",
|
||||||
"@solidjs/meta": "0.29.1",
|
"@storybook/addon-actions": "^8.3.4",
|
||||||
"@thisbeyond/solid-select": "0.14.0",
|
"@storybook/addon-controls": "^8.3.4",
|
||||||
"@tiptap/core": "2.2.3",
|
"@storybook/addon-essentials": "^8.3.4",
|
||||||
"@tiptap/extension-blockquote": "2.2.3",
|
"@storybook/addon-interactions": "^8.3.4",
|
||||||
"@tiptap/extension-bold": "2.2.3",
|
"@storybook/addon-links": "^8.3.4",
|
||||||
"@tiptap/extension-bubble-menu": "2.2.3",
|
"@storybook/addon-themes": "^8.3.4",
|
||||||
"@tiptap/extension-bullet-list": "2.2.3",
|
"@storybook/addon-viewport": "^8.3.4",
|
||||||
"@tiptap/extension-character-count": "2.2.3",
|
"@storybook/builder-vite": "^8.3.4",
|
||||||
"@tiptap/extension-collaboration": "2.2.3",
|
"@storybook/docs-tools": "^8.3.4",
|
||||||
"@tiptap/extension-collaboration-cursor": "2.2.3",
|
"@storybook/test": "^8.3.4",
|
||||||
"@tiptap/extension-document": "2.2.3",
|
"@storybook/test-runner": "^0.19.1",
|
||||||
"@tiptap/extension-dropcursor": "2.2.3",
|
"@tiptap/core": "^2.8.0",
|
||||||
"@tiptap/extension-floating-menu": "2.2.3",
|
"@tiptap/extension-blockquote": "^2.8.0",
|
||||||
"@tiptap/extension-focus": "2.2.3",
|
"@tiptap/extension-bold": "^2.8.0",
|
||||||
"@tiptap/extension-gapcursor": "2.2.3",
|
"@tiptap/extension-bubble-menu": "^2.8.0",
|
||||||
"@tiptap/extension-hard-break": "2.2.3",
|
"@tiptap/extension-bullet-list": "^2.8.0",
|
||||||
"@tiptap/extension-heading": "2.2.3",
|
"@tiptap/extension-character-count": "^2.8.0",
|
||||||
"@tiptap/extension-highlight": "2.2.3",
|
"@tiptap/extension-collaboration": "^2.8.0",
|
||||||
"@tiptap/extension-history": "2.2.3",
|
"@tiptap/extension-collaboration-cursor": "^2.8.0",
|
||||||
"@tiptap/extension-horizontal-rule": "2.2.3",
|
"@tiptap/extension-document": "^2.8.0",
|
||||||
"@tiptap/extension-image": "2.2.3",
|
"@tiptap/extension-dropcursor": "^2.8.0",
|
||||||
"@tiptap/extension-italic": "2.2.3",
|
"@tiptap/extension-floating-menu": "^2.8.0",
|
||||||
"@tiptap/extension-link": "2.2.3",
|
"@tiptap/extension-focus": "^2.8.0",
|
||||||
"@tiptap/extension-list-item": "2.2.3",
|
"@tiptap/extension-gapcursor": "^2.8.0",
|
||||||
"@tiptap/extension-ordered-list": "2.2.3",
|
"@tiptap/extension-hard-break": "^2.8.0",
|
||||||
"@tiptap/extension-paragraph": "2.2.3",
|
"@tiptap/extension-heading": "^2.8.0",
|
||||||
"@tiptap/extension-placeholder": "2.2.3",
|
"@tiptap/extension-highlight": "^2.8.0",
|
||||||
"@tiptap/extension-strike": "2.2.3",
|
"@tiptap/extension-history": "^2.8.0",
|
||||||
"@tiptap/extension-text": "2.2.3",
|
"@tiptap/extension-horizontal-rule": "^2.8.0",
|
||||||
"@tiptap/extension-underline": "2.2.3",
|
"@tiptap/extension-image": "^2.8.0",
|
||||||
"@tiptap/extension-youtube": "2.2.3",
|
"@tiptap/extension-italic": "^2.8.0",
|
||||||
"@types/js-cookie": "3.0.6",
|
"@tiptap/extension-link": "^2.8.0",
|
||||||
"@types/node": "^20.11.0",
|
"@tiptap/extension-list-item": "^2.8.0",
|
||||||
"@urql/core": "4.2.3",
|
"@tiptap/extension-ordered-list": "^2.8.0",
|
||||||
"@urql/devtools": "^2.0.3",
|
"@tiptap/extension-paragraph": "^2.8.0",
|
||||||
"babel-preset-solid": "1.8.4",
|
"@tiptap/extension-placeholder": "^2.8.0",
|
||||||
"bootstrap": "5.3.2",
|
"@tiptap/extension-strike": "^2.8.0",
|
||||||
"clsx": "2.0.0",
|
"@tiptap/extension-text": "^2.8.0",
|
||||||
"cropperjs": "1.6.1",
|
"@tiptap/extension-underline": "^2.8.0",
|
||||||
"cross-env": "7.0.3",
|
"@tiptap/extension-youtube": "^2.8.0",
|
||||||
"fast-deep-equal": "3.1.3",
|
"@tiptap/starter-kit": "^2.8.0",
|
||||||
"ga-gtag": "1.2.0",
|
"@types/cookie": "^0.6.0",
|
||||||
"graphql": "16.8.1",
|
"@types/cookie-signature": "^1.1.2",
|
||||||
"graphql-tag": "2.12.6",
|
"@types/node": "^22.7.4",
|
||||||
"hygen": "6.2.11",
|
"@types/throttle-debounce": "^5.0.2",
|
||||||
"i18next": "22.4.15",
|
"@urql/core": "^5.0.6",
|
||||||
"i18next-http-backend": "2.2.0",
|
"axe-playwright": "^2.0.3",
|
||||||
"i18next-icu": "2.3.0",
|
"bootstrap": "^5.3.3",
|
||||||
"intl-messageformat": "10.5.3",
|
"clsx": "^2.1.1",
|
||||||
"javascript-time-ago": "2.5.9",
|
"cookie": "^0.6.0",
|
||||||
"js-cookie": "3.0.5",
|
"cookie-signature": "^1.2.1",
|
||||||
"lint-staged": "15.1.0",
|
"cropperjs": "^1.6.2",
|
||||||
"loglevel": "1.8.1",
|
"extended-eventsource": "^1.6.4",
|
||||||
"loglevel-plugin-prefix": "0.8.4",
|
"fast-deep-equal": "^3.1.3",
|
||||||
"nanostores": "0.9.5",
|
"graphql": "^16.9.0",
|
||||||
|
"i18next": "^23.15.1",
|
||||||
|
"i18next-http-backend": "^2.6.1",
|
||||||
|
"i18next-icu": "^2.3.0",
|
||||||
|
"intl-messageformat": "^10.5.14",
|
||||||
|
"javascript-time-ago": "^2.5.11",
|
||||||
"patch-package": "^8.0.0",
|
"patch-package": "^8.0.0",
|
||||||
"prosemirror-history": "1.3.2",
|
"prosemirror-history": "^1.4.1",
|
||||||
"prosemirror-trailing-node": "2.0.7",
|
"prosemirror-trailing-node": "^2.0.9",
|
||||||
"prosemirror-view": "1.32.7",
|
"prosemirror-view": "^1.34.3",
|
||||||
"rollup": "4.11.0",
|
"rollup-plugin-visualizer": "^5.12.0",
|
||||||
"sass": "1.69.5",
|
"sass": "1.77.6",
|
||||||
"solid-js": "1.8.15",
|
"solid-js": "^1.9.1",
|
||||||
"solid-popper": "0.3.0",
|
"solid-popper": "^0.3.0",
|
||||||
"solid-tiptap": "0.7.0",
|
"solid-tiptap": "0.7.0",
|
||||||
"solid-transition-group": "0.2.3",
|
"solid-transition-group": "^0.2.3",
|
||||||
"stylelint": "^16.0.0",
|
"storybook": "^8.3.4",
|
||||||
"stylelint-config-standard-scss": "^13.0.0",
|
"storybook-addon-sass-postcss": "^0.3.2",
|
||||||
"stylelint-order": "^6.0.3",
|
"storybook-solidjs": "^1.0.0-beta.2",
|
||||||
"stylelint-scss": "^6.1.0",
|
"storybook-solidjs-vite": "^1.0.0-beta.2",
|
||||||
"swiper": "11.0.5",
|
"stylelint": "^16.9.0",
|
||||||
"throttle-debounce": "5.0.0",
|
"stylelint-config-recommended": "^14.0.1",
|
||||||
"typescript": "5.2.2",
|
"stylelint-config-standard-scss": "^13.1.0",
|
||||||
"typograf": "7.3.0",
|
"stylelint-order": "^6.0.4",
|
||||||
"uniqolor": "1.1.0",
|
"stylelint-scss": "^6.7.0",
|
||||||
"vike": "0.4.148",
|
"swiper": "^11.1.14",
|
||||||
"vite": "5.1.2",
|
"throttle-debounce": "^5.0.2",
|
||||||
"vite-plugin-mkcert": "^1.17.3",
|
"tslib": "^2.7.0",
|
||||||
"vite-plugin-sass-dts": "^1.3.17",
|
"typescript": "^5.6.2",
|
||||||
"vite-plugin-solid": "2.10.1",
|
"typograf": "^7.4.1",
|
||||||
"y-prosemirror": "1.2.2",
|
"uniqolor": "^1.1.1",
|
||||||
"yjs": "13.6.12"
|
"vinxi": "^0.4.3",
|
||||||
|
"vite-plugin-mkcert": "^1.17.6",
|
||||||
|
"vite-plugin-node-polyfills": "^0.22.0",
|
||||||
|
"vite-plugin-sass-dts": "^1.3.29",
|
||||||
|
"y-prosemirror": "1.2.12",
|
||||||
|
"yjs": "13.6.19"
|
||||||
},
|
},
|
||||||
"overrides": {
|
"overrides": {
|
||||||
"y-prosemirror": "1.2.2",
|
"sass": "1.77.6",
|
||||||
"yjs": "13.6.12"
|
"vite": "5.3.5",
|
||||||
|
"yjs": "13.6.19",
|
||||||
|
"y-prosemirror": "1.2.12"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 20"
|
||||||
|
},
|
||||||
|
"trustedDependencies": ["@biomejs/biome", "@swc/core", "esbuild", "protobufjs"],
|
||||||
|
"dependencies": {
|
||||||
|
"form-data": "^4.0.0",
|
||||||
|
"idb": "^8.0.0",
|
||||||
|
"mailgun.js": "^10.2.3"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -10,44 +10,40 @@ import { defineConfig, devices } from '@playwright/test'
|
||||||
* See https://playwright.dev/docs/test-configuration.
|
* See https://playwright.dev/docs/test-configuration.
|
||||||
*/
|
*/
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
|
/* Directory to search for tests */
|
||||||
testDir: './tests',
|
testDir: './tests',
|
||||||
/* Run tests in files in parallel */
|
/* Run tests in files in parallel */
|
||||||
fullyParallel: true,
|
fullyParallel: false,
|
||||||
/* Fail the build on CI if you accidentally left test.only in the source code. */
|
/* Fail the build on CI if you accidentally left test.only in the source code. */
|
||||||
forbidOnly: !!process.env.CI,
|
forbidOnly: !!process.env.CI,
|
||||||
/* Retry on CI only */
|
/* Retry on CI only */
|
||||||
retries: process.env.CI ? 2 : 0,
|
retries: 0,
|
||||||
/* Opt out of parallel tests on CI. */
|
/* Opt out of parallel tests on CI. */
|
||||||
workers: process.env.CI ? 1 : undefined,
|
workers: process.env.CI ? 1 : undefined,
|
||||||
/* Reporter to use. See https://playwright.dev/docs/test-reporters */
|
/* Reporter to use. See https://playwright.dev/docs/test-reporters */
|
||||||
reporter: 'html',
|
reporter: 'list',
|
||||||
|
/* Timeout for each test */
|
||||||
|
timeout: 40000,
|
||||||
/* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */
|
/* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */
|
||||||
use: {
|
use: {
|
||||||
/* Base URL to use in actions like `await page.goto('/')`. */
|
/* Base URL to use in actions like `await page.goto('/')`. */
|
||||||
// baseURL: 'http://127.0.0.1:3000',
|
baseURL: process.env.BASE_URL || 'https://localhost:3000',
|
||||||
|
/* Headless */
|
||||||
|
headless: true,
|
||||||
|
/* Ignode SSL certificates */
|
||||||
|
ignoreHTTPSErrors: true,
|
||||||
/* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */
|
/* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */
|
||||||
trace: 'on-first-retry',
|
trace: 'on-first-retry'
|
||||||
},
|
},
|
||||||
|
|
||||||
/* Configure projects for major browsers */
|
/* Configure projects for major browsers */
|
||||||
projects: [
|
projects: [
|
||||||
{
|
|
||||||
name: 'chromium',
|
|
||||||
use: { ...devices['Desktop Chrome'] },
|
|
||||||
},
|
|
||||||
|
|
||||||
{
|
|
||||||
name: 'firefox',
|
|
||||||
use: { ...devices['Desktop Firefox'] },
|
|
||||||
},
|
|
||||||
|
|
||||||
{
|
{
|
||||||
name: 'webkit',
|
name: 'webkit',
|
||||||
use: { ...devices['Desktop Safari'] },
|
use: { ...devices['Desktop Safari'] }
|
||||||
},
|
}
|
||||||
|
|
||||||
/* Test against mobile viewports. */
|
/* Test against many viewports.
|
||||||
// {
|
// {
|
||||||
// name: 'Mobile Chrome',
|
// name: 'Mobile Chrome',
|
||||||
// use: { ...devices['Pixel 5'] },
|
// use: { ...devices['Pixel 5'] },
|
||||||
|
@ -68,10 +64,15 @@ export default defineConfig({
|
||||||
// },
|
// },
|
||||||
],
|
],
|
||||||
|
|
||||||
/* Run your local dev server before starting the tests */
|
/* Run local dev server before starting the tests */
|
||||||
// webServer: {
|
/* If process env CI is set to false */
|
||||||
// command: 'npm run start',
|
webServer: process.env.CI
|
||||||
// url: 'http://127.0.0.1:3000',
|
? undefined
|
||||||
// reuseExistingServer: !process.env.CI,
|
: {
|
||||||
// },
|
command: 'npm run dev',
|
||||||
|
url: 'http://localhost:3000',
|
||||||
|
ignoreHTTPSErrors: true,
|
||||||
|
reuseExistingServer: !process.env.CI,
|
||||||
|
timeout: 5 * 60 * 1000
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|
5
public/icons/arrow-right-2.svg
Normal file
|
@ -0,0 +1,5 @@
|
||||||
|
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M19.125 12.75H4.5C4.08854 12.75 3.75 12.4115 3.75 12C3.75 11.5885 4.08854 11.25 4.5 11.25H19.125C19.5365 11.25 19.875 11.5885 19.875 12C19.875 12.4115 19.5365 12.75 19.125 12.75Z" fill="currentColor"/>
|
||||||
|
<path
|
||||||
|
d="M14.0678 18.3593C13.8803 18.3593 13.6928 18.2916 13.547 18.151C13.2501 17.8593 13.2397 17.3853 13.5314 17.0885L18.4584 11.9999L13.5314 6.91137C13.2397 6.6145 13.2501 6.14054 13.547 5.84887C13.8439 5.56241 14.3178 5.57283 14.6043 5.8697L20.0366 11.4791C20.3178 11.7707 20.3178 12.2291 20.0366 12.5207L14.6043 18.1301C14.4584 18.2864 14.2657 18.3593 14.0678 18.3593Z" fill="currentColor"/>
|
||||||
|
</svg>
|
After Width: | Height: | Size: 713 B |
Before Width: | Height: | Size: 714 B After Width: | Height: | Size: 714 B |
Before Width: | Height: | Size: 350 B After Width: | Height: | Size: 350 B |
Before Width: | Height: | Size: 290 B After Width: | Height: | Size: 290 B |
|
@ -1,11 +0,0 @@
|
||||||
<svg
|
|
||||||
width="13" height="16"
|
|
||||||
viewBox="0 0 13 16"
|
|
||||||
fill="none"
|
|
||||||
version="1.1"
|
|
||||||
xmlns="http://www.w3.org/2000/svg">
|
|
||||||
<path
|
|
||||||
d="M 10.1573,7.43667 C 11.2197,6.70286 11.9645,5.49809 11.9645,4.38095 11.9645,1.90571 10.0478,0 7.58352,0 H 0.738281 V 15.3333 H 8.44876 c 2.28904,0 4.06334,-1.8619 4.06334,-4.1509 0,-1.66478 -0.9419,-3.08859 -2.3548,-3.74573 z M 4.02344,2.73828 h 3.28571 c 0.90905,0 1.64286,0.73381 1.64286,1.64286 0,0.90905 -0.73381,1.64286 -1.64286,1.64286 H 4.02344 Z M 4.01629,9.3405869 h 3.87946 c 0.9090501,0 1.6428601,0.7338101 1.6428601,1.6428601 0,0.90905 -0.73381,1.64286 -1.6428601,1.64286 H 4.01629 Z"
|
|
||||||
fill="currentColor"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
Before Width: | Height: | Size: 677 B |
4
public/icons/expert.svg
Normal file
|
@ -0,0 +1,4 @@
|
||||||
|
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path
|
||||||
|
d="M11.9967 4.51318C11.5931 4.51318 11.1868 4.59652 10.8118 4.75798L7.9056 6.01058L9.83268 6.81266L11.4056 6.13558C11.5931 6.05225 11.7962 6.01058 11.9993 6.01058C12.2025 6.01058 12.4056 6.05225 12.5931 6.13558L20.5801 9.57829C20.6504 9.60693 20.6947 9.67464 20.6947 9.75016C20.6947 9.82568 20.6504 9.89339 20.5801 9.92204L12.5931 13.3647C12.2181 13.5262 11.7806 13.5262 11.4056 13.3647L3.41862 9.92204C3.34831 9.89339 3.30404 9.82568 3.30404 9.75016C3.30404 9.67464 3.34831 9.60693 3.41862 9.57829L6.47591 8.26058L11.7103 10.4429C11.804 10.4819 11.903 10.5002 11.9993 10.5002C12.291 10.5002 12.5723 10.3283 12.6921 10.0392C12.8509 9.65641 12.6712 9.21631 12.2884 9.05746L8.39258 7.43506L8.39518 7.43246L6.4681 6.63037L2.42643 8.37516C1.87435 8.60954 1.51758 9.1512 1.51758 9.75016C1.51758 10.3491 1.87435 10.8908 2.42643 11.1252L4.87435 12.1825V18.5679C4.64779 18.7371 4.49935 19.008 4.49935 19.3127V20.8127C4.49935 21.3309 4.91862 21.7502 5.43685 21.7502H5.81185C6.33008 21.7502 6.74935 21.3309 6.74935 20.8127V19.3127C6.74935 19.008 6.60091 18.7371 6.37435 18.5679V17.1512C7.42904 17.909 9.2181 18.7502 11.9993 18.7502C15.5384 18.7502 17.4889 17.3856 18.3353 16.5705C18.8379 16.0887 19.1243 15.4064 19.1243 14.6955V12.1825L21.5723 11.1252C22.1243 10.8908 22.4811 10.3491 22.4811 9.75016C22.4811 9.1512 22.1243 8.60954 21.5723 8.37516L13.1868 4.75798C12.8092 4.59652 12.403 4.51318 11.9967 4.51318ZM6.37435 12.8283L10.8118 14.7424C11.1895 14.9064 11.5931 14.9845 11.9993 14.9845C12.4056 14.9845 12.8092 14.9064 13.1868 14.7424L17.6243 12.8283V14.6955C17.6243 15.0002 17.5046 15.2892 17.2962 15.4897C16.6113 16.146 15.015 17.2502 11.9993 17.2502C8.98372 17.2502 7.38737 16.146 6.70247 15.4897C6.49414 15.2892 6.37435 15.0002 6.37435 14.6955V12.8283Z" fill="currentColor"/>
|
||||||
|
</svg>
|
After Width: | Height: | Size: 1.8 KiB |
|
@ -1,3 +1,3 @@
|
||||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
<path d="M8.25 4.125C7.14583 4.125 6.13281 4.6901 5.60937 5.60156C5.40365 5.95573 5.16406 6.53385 5.03125 6.91927C4.91146 7.2474 3.07813 11.349 1.95313 13.8568L1.95833 13.8594C1.66667 14.4349 1.5 15.0755 1.5 15.75C1.5 18.2318 3.6875 20.25 6.375 20.25C9.0625 20.25 11.25 18.2318 11.25 15.75V14.3724C11.4505 14.3099 11.7109 14.25 12 14.25C12.2891 14.25 12.5495 14.3099 12.75 14.3724V15.75C12.75 18.2318 14.9375 20.25 17.625 20.25C20.3125 20.25 22.5 18.2318 22.5 15.75C22.5 15.0755 22.3333 14.4349 22.0417 13.8594L22.0469 13.8568C20.9219 11.349 19.0885 7.2474 18.9688 6.92448C18.8359 6.53646 18.5964 5.95833 18.3906 5.60417C17.8672 4.6901 16.8542 4.125 15.75 4.125C14.1354 4.125 12.8177 5.32813 12.7552 6.82813C12.526 6.78125 12.2734 6.75 12 6.75C11.7266 6.75 11.474 6.78125 11.2448 6.82813C11.1823 5.32813 9.86458 4.125 8.25 4.125ZM8.25 5.625C9.07813 5.625 9.75 6.21354 9.75 6.9375V12.5104C8.8724 11.7318 7.6849 11.25 6.375 11.25C5.75781 11.25 5.16927 11.362 4.625 11.5547C5.48177 9.64063 6.36458 7.65365 6.45052 7.40885C6.57292 7.04688 6.77604 6.58333 6.90885 6.35156C7.16667 5.90365 7.67969 5.625 8.25 5.625ZM15.75 5.625C16.3203 5.625 16.8333 5.90365 17.0911 6.35156C17.224 6.58333 17.4271 7.04948 17.5495 7.40885C17.6354 7.65365 18.5182 9.64063 19.3724 11.5547C18.8307 11.362 18.2422 11.25 17.625 11.25C16.3151 11.25 15.1276 11.7318 14.25 12.5104V6.9375C14.25 6.21354 14.9219 5.625 15.75 5.625ZM12 8.25C12.2891 8.25 12.5495 8.3099 12.75 8.3724V9.82552C12.5208 9.78125 12.2708 9.75 12 9.75C11.7292 9.75 11.4792 9.78125 11.25 9.82552V8.3724C11.4505 8.3099 11.7109 8.25 12 8.25ZM12 11.25C12.2891 11.25 12.5495 11.3099 12.75 11.3724V12.8255C12.5208 12.7812 12.2708 12.75 12 12.75C11.7292 12.75 11.4792 12.7812 11.25 12.8255V11.3724C11.4505 11.3099 11.7109 11.25 12 11.25ZM6.375 12.75C8.23698 12.75 9.75 14.0964 9.75 15.75C9.75 17.4036 8.23698 18.75 6.375 18.75C4.51302 18.75 3 17.4036 3 15.75C3 14.0964 4.51302 12.75 6.375 12.75ZM17.625 12.75C19.487 12.75 21 14.0964 21 15.75C21 17.4036 19.487 18.75 17.625 18.75C15.763 18.75 14.25 17.4036 14.25 15.75C14.25 14.0964 15.763 12.75 17.625 12.75Z" fill="#141414"/>
|
<path d="M8.625 4.5C7.59115 4.5 6.75 5.34115 6.75 6.375V8.25H5.625C4.59115 8.25 3.75 9.09115 3.75 10.125V17.25C3.75 18.4896 4.76042 19.5 6 19.5H18C19.2396 19.5 20.25 18.4896 20.25 17.25V6.375C20.25 5.34115 19.4089 4.5 18.375 4.5H8.625ZM8.625 6H18.375C18.5807 6 18.75 6.16927 18.75 6.375V17.25C18.75 17.6641 18.4141 18 18 18H8.1224C8.20313 17.7656 8.25 17.513 8.25 17.25V6.375C8.25 6.16927 8.41927 6 8.625 6ZM10.125 7.5C9.71094 7.5 9.375 7.83594 9.375 8.25C9.375 8.66406 9.71094 9 10.125 9H16.875C17.2891 9 17.625 8.66406 17.625 8.25C17.625 7.83594 17.2891 7.5 16.875 7.5H10.125ZM5.625 9.75H6.75V17.25C6.75 17.6641 6.41406 18 6 18C5.58594 18 5.25 17.6641 5.25 17.25V10.125C5.25 9.91927 5.41927 9.75 5.625 9.75ZM10.125 10.125C9.71094 10.125 9.375 10.4609 9.375 10.875C9.375 11.2891 9.71094 11.625 10.125 11.625H16.875C17.2891 11.625 17.625 11.2891 17.625 10.875C17.625 10.4609 17.2891 10.125 16.875 10.125H10.125ZM10.125 12.75C9.71094 12.75 9.375 13.0859 9.375 13.5V16.125C9.375 16.5391 9.71094 16.875 10.125 16.875H12.375C12.7891 16.875 13.125 16.5391 13.125 16.125V13.5C13.125 13.0859 12.7891 12.75 12.375 12.75H10.125ZM15 12.75C14.5859 12.75 14.25 13.0859 14.25 13.5C14.25 13.9141 14.5859 14.25 15 14.25H16.875C17.2891 14.25 17.625 13.9141 17.625 13.5C17.625 13.0859 17.2891 12.75 16.875 12.75H15ZM15 15.375C14.5859 15.375 14.25 15.7109 14.25 16.125C14.25 16.5391 14.5859 16.875 15 16.875H16.875C17.2891 16.875 17.625 16.5391 17.625 16.125C17.625 15.7109 17.2891 15.375 16.875 15.375H15Z" fill="black"/>
|
||||||
</svg>
|
</svg>
|
||||||
|
|
Before Width: | Height: | Size: 2.2 KiB After Width: | Height: | Size: 1.6 KiB |
Before Width: | Height: | Size: 245 B After Width: | Height: | Size: 245 B |
4
public/icons/logout.svg
Normal file
|
@ -0,0 +1,4 @@
|
||||||
|
<svg width="25" height="25" viewBox="0 0 25 25" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path
|
||||||
|
d="M16.1785 3.05371C15.1421 3.05371 14.3035 3.89486 14.3035 4.92871C14.3035 5.96256 15.1421 6.80371 16.1785 6.80371C17.215 6.80371 18.0535 5.96256 18.0535 4.92871C18.0535 3.89486 17.215 3.05371 16.1785 3.05371ZM14.6785 7.55371C14.4051 7.55371 14.1473 7.61621 13.9129 7.72038L10.9181 9.12923C10.7723 9.19694 10.6577 9.31413 10.5926 9.45736L9.12124 12.7308C9.07957 12.8089 9.05353 12.8975 9.05353 12.9912C9.05353 13.3011 9.30613 13.5537 9.61603 13.5537C9.8478 13.5537 10.0483 13.4131 10.1343 13.2126V13.21L11.702 10.5303L12.4858 10.249L11.7462 12.6761L11.7541 12.6787C11.7098 12.8376 11.6785 13.0042 11.6785 13.1787C11.6785 13.8923 12.0822 14.5068 12.6707 14.8245L12.6655 14.8298L15.5848 16.9626L16.5874 20.5225H16.59C16.6837 20.8298 16.965 21.0537 17.3035 21.0537C17.7176 21.0537 18.0535 20.7178 18.0535 20.3037C18.0535 20.2282 18.0379 20.1553 18.0171 20.085H18.0197L17.2671 16.3454C17.2436 16.223 17.1968 16.1058 17.1317 15.999L15.4806 13.2881L16.4806 9.99902L16.4572 9.99121C16.5145 9.81152 16.5535 9.62663 16.5535 9.42871C16.5535 8.39486 15.715 7.55371 14.6785 7.55371ZM17.1681 10.5355L16.603 12.0771L17.0353 12.4001C17.0718 12.4261 17.1108 12.4469 17.1525 12.4626L19.8869 13.5042C19.8973 13.5094 19.9103 13.512 19.9207 13.5173L19.9363 13.5225C19.9936 13.5407 20.0535 13.5537 20.116 13.5537C20.4259 13.5537 20.6785 13.3011 20.6785 12.9912C20.6785 12.7699 20.5483 12.5771 20.3608 12.486L17.9233 11.21L17.1681 10.5355ZM8.91551 13.9313C8.69676 13.9105 8.47801 14.0225 8.36863 14.2282L7.43895 15.9886L6.11343 15.2829C5.74884 15.0876 5.29572 15.2256 5.1004 15.5928L3.33738 18.9053C3.14468 19.2673 3.2853 19.723 3.64988 19.9183L4.48582 20.3636C4.47801 20.1058 4.5379 19.8454 4.66551 19.611C4.92593 19.1188 5.43374 18.8115 5.99103 18.8115C6.23322 18.8115 6.47801 18.874 6.69155 18.9886C6.80353 19.0485 6.9077 19.1188 6.99884 19.21L7.98843 17.348C7.99103 17.3454 7.99103 17.3454 7.99363 17.3428L8.2254 16.9027L8.43113 16.5173V16.5146L9.36343 14.7542C9.50926 14.4782 9.40249 14.1396 9.12905 13.9938C9.06134 13.9574 8.98843 13.9365 8.91551 13.9313ZM11.8608 15.3532L11.4988 17.0225L9.93113 19.8844L9.89988 19.9417C9.83999 20.0485 9.80353 20.1709 9.80353 20.3037C9.80353 20.7178 10.1395 21.0537 10.5535 21.0537C10.8244 21.0537 11.0613 20.9079 11.1916 20.6917L13.7332 16.7334L11.8608 15.3532ZM6.05613 19.5641C5.76447 19.5407 5.4728 19.6865 5.32697 19.96C5.13165 20.3271 5.27228 20.7803 5.63686 20.9756C6.00145 21.1683 6.45718 21.0303 6.65249 20.6657C6.8452 20.2985 6.70718 19.8454 6.33999 19.6501C6.24884 19.6006 6.15249 19.5745 6.05613 19.5641Z" fill="currentColor"/>
|
||||||
|
</svg>
|
After Width: | Height: | Size: 2.6 KiB |
4
public/icons/profile.svg
Normal file
|
@ -0,0 +1,4 @@
|
||||||
|
<svg width="25" height="25" viewBox="0 0 25 25" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path
|
||||||
|
d="M12.4285 3.05347C11.392 3.05347 10.5535 3.89461 10.5535 4.92847C10.5535 5.96232 11.392 6.80347 12.4285 6.80347C13.4649 6.80347 14.3035 5.96232 14.3035 4.92847C14.3035 3.89461 13.4649 3.05347 12.4285 3.05347ZM12.4285 7.55347C10.3113 7.55347 9.05347 9.05347 9.05347 10.1785V14.6785C9.05347 15.0925 9.3894 15.4285 9.80347 15.4285H10.1785V21.7852C10.1785 22.2097 10.5222 22.5535 10.9467 22.5535C11.3582 22.5535 11.6941 22.2332 11.7149 21.8243L12.017 15.4285H12.8399L13.142 21.8243C13.1628 22.2332 13.4988 22.5535 13.9102 22.5535C14.3347 22.5535 14.6785 22.2097 14.6785 21.7852V15.4285H15.0535C15.4675 15.4285 15.8035 15.0925 15.8035 14.6785V10.1785C15.8035 9.05347 14.5457 7.55347 12.4285 7.55347Z" fill="currentColor"/>
|
||||||
|
</svg>
|
After Width: | Height: | Size: 831 B |
3
public/icons/toggle-arrow.svg
Normal file
|
@ -0,0 +1,3 @@
|
||||||
|
<svg width="18" height="10" viewBox="0 0 18 10" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M17.1042 9.90633C16.9063 9.90633 16.7136 9.82821 16.5626 9.67716L8.98965 1.91675L1.43236 9.66154C1.1459 9.95841 0.671944 9.96362 0.375069 9.67716C0.0781948 9.3855 0.0729868 8.91154 0.359444 8.61467L8.45319 0.322998C8.73965 0.0313314 9.24486 0.0313314 9.53132 0.322998L17.6407 8.63029C17.9272 8.92716 17.9219 9.40112 17.6251 9.69279C17.4792 9.83342 17.2917 9.90633 17.1042 9.90633Z" fill="#9FA1A7"/>
|
||||||
|
</svg>
|
After Width: | Height: | Size: 511 B |
|
@ -1,527 +0,0 @@
|
||||||
{
|
|
||||||
"A guide to horizontal editorial: how an open journal works": "A guide to horizontal editorial: how an open journal works",
|
|
||||||
"About the project": "About the project",
|
|
||||||
"About": "About",
|
|
||||||
"Add a few topics so that the reader knows what your content is about and can find it on pages of topics that interest them. Topics can be swapped, the first topic becomes the title": "Add a few topics so that the reader knows what your content is about and can find it on pages of topics that interest them. Topics can be swapped, the first topic becomes the title",
|
|
||||||
"Add a link or click plus to embed media": "Add a link or click plus to embed media",
|
|
||||||
"Add an embed widget": "Add an embed widget",
|
|
||||||
"Add another image": "Add another image",
|
|
||||||
"Add audio": "Add audio",
|
|
||||||
"Add blockquote": "Add blockquote",
|
|
||||||
"Add comment": "Comment",
|
|
||||||
"Add cover": "Add cover",
|
|
||||||
"Add image": "Add image",
|
|
||||||
"Add images": "Add images",
|
|
||||||
"Add intro": "Add intro",
|
|
||||||
"Add link": "Add link",
|
|
||||||
"Add rule": "Add rule",
|
|
||||||
"Add signature": "Add signature",
|
|
||||||
"Add subtitle": "Add subtitle",
|
|
||||||
"Add url": "Add url",
|
|
||||||
"Add": "Add",
|
|
||||||
"Address on Discours": "Address on Discours",
|
|
||||||
"Album name": "Название aльбома",
|
|
||||||
"Alignment center": "Alignment center",
|
|
||||||
"Alignment left": "Alignment left",
|
|
||||||
"Alignment right": "Alignment right",
|
|
||||||
"All articles": "All articles",
|
|
||||||
"All authors": "All authors",
|
|
||||||
"All posts": "All posts",
|
|
||||||
"All topics": "All topics",
|
|
||||||
"All": "All",
|
|
||||||
"Almost done! Check your email.": "Almost done! Just checking your email.",
|
|
||||||
"Are you sure you want to delete this comment?": "Are you sure you want to delete this comment?",
|
|
||||||
"Are you sure you want to delete this draft?": "Are you sure you want to delete this draft?",
|
|
||||||
"Are you sure you want to to proceed the action?": "Are you sure you want to to proceed the action?",
|
|
||||||
"Art": "Art",
|
|
||||||
"Artist": "Artist",
|
|
||||||
"Artworks": "Artworks",
|
|
||||||
"Audio": "Audio",
|
|
||||||
"Author": "Author",
|
|
||||||
"Authors": "Authors",
|
|
||||||
"Autotypograph": "Autotypograph",
|
|
||||||
"Back to editor": "Back to editor",
|
|
||||||
"Back to main page": "Back to main page",
|
|
||||||
"Back": "Back",
|
|
||||||
"Be the first to rate": "Be the first to rate",
|
|
||||||
"Become an author": "Become an author",
|
|
||||||
"Bold": "Bold",
|
|
||||||
"Bookmarked": "Saved",
|
|
||||||
"Bookmarks": "Bookmarks",
|
|
||||||
"Bullet list": "Bullet list",
|
|
||||||
"By alphabet": "By alphabet",
|
|
||||||
"By authors": "By authors",
|
|
||||||
"By name": "By name",
|
|
||||||
"By popularity": "By popularity",
|
|
||||||
"By rating": "By popularity",
|
|
||||||
"By relevance": "By relevance",
|
|
||||||
"By shouts": "By publications",
|
|
||||||
"By signing up you agree with our": "By signing up you agree with our",
|
|
||||||
"By time": "By time",
|
|
||||||
"By title": "By title",
|
|
||||||
"By updates": "By updates",
|
|
||||||
"By views": "By views",
|
|
||||||
"Can make any changes, accept or reject suggestions, and share access with others": "Can make any changes, accept or reject suggestions, and share access with others",
|
|
||||||
"Can offer edits and comments, but cannot edit the post or share access with others": "Can offer edits and comments, but cannot edit the post or share access with others",
|
|
||||||
"Can write and edit text directly, and accept or reject suggestions from others": "Can write and edit text directly, and accept or reject suggestions from others",
|
|
||||||
"Cancel changes": "Cancel changes",
|
|
||||||
"Cancel": "Cancel",
|
|
||||||
"Change password": "Change password",
|
|
||||||
"Characters": "Знаков",
|
|
||||||
"Chat Title": "Chat Title",
|
|
||||||
"Choose a post type": "Choose a post type",
|
|
||||||
"Choose a title image for the article. You can immediately see how the publication card will look like.": "Choose a title image for the article. You can immediately see how the publication card will look like.",
|
|
||||||
"Choose who you want to write to": "Choose who you want to write to",
|
|
||||||
"Close": "Close",
|
|
||||||
"Co-author": "Co-author",
|
|
||||||
"Collaborate": "Help Edit",
|
|
||||||
"Collaborators": "Collaborators",
|
|
||||||
"Collections": "Collections",
|
|
||||||
"Come up with a subtitle for your story": "Come up with a subtitle for your story",
|
|
||||||
"Come up with a title for your story": "Come up with a title for your story",
|
|
||||||
"Coming soon": "Coming soon",
|
|
||||||
"Comment successfully deleted": "Comment successfully deleted",
|
|
||||||
"Commentator": "Commentator",
|
|
||||||
"Comments": "Comments",
|
|
||||||
"Communities": "Communities",
|
|
||||||
"Community Discussion Rules": "Community Discussion Rules",
|
|
||||||
"Community Principles": "Community Principles",
|
|
||||||
"Community values and rules of engagement for the open editorial team": "Community values and rules of engagement for the open editorial team",
|
|
||||||
"Confirm": "Confirm",
|
|
||||||
"Contribute to free samizdat. Support Discours - an independent non-profit publication that works only for you. Become a pillar of the open newsroom": "Contribute to free samizdat. Support Discours - an independent non-profit publication that works only for you. Become a pillar of the open newsroom",
|
|
||||||
"Cooperate": "Cooperate",
|
|
||||||
"Copy link": "Copy link",
|
|
||||||
"Copy": "Copy",
|
|
||||||
"Corrections history": "Corrections history",
|
|
||||||
"Create Chat": "Create Chat",
|
|
||||||
"Create Group": "Create a group",
|
|
||||||
"Create account": "Create an account",
|
|
||||||
"Create an account to add to your bookmarks": "Create an account to add to your bookmarks",
|
|
||||||
"Create an account to participate in discussions": "Create an account to participate in discussions",
|
|
||||||
"Create an account to publish articles": "Create an account to publish articles",
|
|
||||||
"Create an account to subscribe to new publications": "Create an account to subscribe to new publications",
|
|
||||||
"Create an account to subscribe": "Create an account to subscribe",
|
|
||||||
"Create an account to vote": "Create an account to vote",
|
|
||||||
"Create gallery": "Create gallery",
|
|
||||||
"Create post": "Create post",
|
|
||||||
"Create video": "Create video",
|
|
||||||
"Crop image": "Crop image",
|
|
||||||
"Culture": "Culture",
|
|
||||||
"Date of Birth": "Date of Birth",
|
|
||||||
"Decline": "Decline",
|
|
||||||
"Delete cover": "Delete cover",
|
|
||||||
"Delete userpic": "Delete userpic",
|
|
||||||
"Delete": "Delete",
|
|
||||||
"Description": "Description",
|
|
||||||
"Discours Manifest": "Discours Manifest",
|
|
||||||
"Discours Partners": "Discours Partners",
|
|
||||||
"Discours is an intellectual environment, a web space and tools that allows authors to collaborate with readers and come together to co-create publications and media projects": "Discours is an intellectual environment, a web space and tools that allows authors to collaborate with readers and come together to co-create publications and media projects.<br/><em>We are convinced that one voice is good, but many is better. We create the most amazing stories together</em>",
|
|
||||||
"Discours is created with our common effort": "Discours exists because of our common effort",
|
|
||||||
"Discours – an open magazine about culture, science and society": "Discours – an open magazine about culture, science and society",
|
|
||||||
"Discours": "Discours",
|
|
||||||
"Discussing": "Discussing",
|
|
||||||
"Discussion rules": "Discussion rules",
|
|
||||||
"Discussions": "Discussions",
|
|
||||||
"Do you really want to reset all changes?": "Do you really want to reset all changes?",
|
|
||||||
"Dogma": "Dogma",
|
|
||||||
"Draft successfully deleted": "Draft successfully deleted",
|
|
||||||
"Drafts": "Drafts",
|
|
||||||
"Drag the image to this area": "Drag the image to this area",
|
|
||||||
"Each image must be no larger than 5 MB.": "Each image must be no larger than 5 MB.",
|
|
||||||
"Edit profile": "Edit profile",
|
|
||||||
"Edit": "Edit",
|
|
||||||
"Editing": "Editing",
|
|
||||||
"Editor": "Editor",
|
|
||||||
"Email": "Mail",
|
|
||||||
"Enter URL address": "Enter URL address",
|
|
||||||
"Enter a new password": "Enter a new password",
|
|
||||||
"Enter footnote text": "Enter footnote text",
|
|
||||||
"Enter image description": "Enter image description",
|
|
||||||
"Enter image title": "Enter image title",
|
|
||||||
"Enter text": "Enter text",
|
|
||||||
"Enter the code or click the link from email to confirm": "Enter the code from the email or follow the link in the email to confirm registration",
|
|
||||||
"Enter your new password": "Enter your new password",
|
|
||||||
"Enter": "Enter",
|
|
||||||
"Error": "Error",
|
|
||||||
"Please give us your email address": "Please provide us your email address to get the password reset link",
|
|
||||||
"Experience": "Experience",
|
|
||||||
"FAQ": "Tips and suggestions",
|
|
||||||
"Favorite topics": "Favorite topics",
|
|
||||||
"Favorite": "Favorites",
|
|
||||||
"Feed settings": "Feed settings",
|
|
||||||
"Feed": "Feed",
|
|
||||||
"Feedback": "Feedback",
|
|
||||||
"Fill email": "Fill email",
|
|
||||||
"Fixed": "Fixed",
|
|
||||||
"Follow the topic": "Follow the topic",
|
|
||||||
"Follow": "Follow",
|
|
||||||
"Followers": "Followers",
|
|
||||||
"Following": "Following",
|
|
||||||
"Forward": "Forward",
|
|
||||||
"Full name": "First and last name",
|
|
||||||
"Gallery name": "Gallery name",
|
|
||||||
"Gallery": "Gallery",
|
|
||||||
"Get to know the most intelligent people of our time, edit and discuss the articles, share your expertise, rate and decide what to publish in the magazine": "Get to know the most intelligent people of our time, edit and discuss the articles, share your expertise, rate and decide what to publish in the magazine",
|
|
||||||
"Go to main page": "Go to main page",
|
|
||||||
"Group Chat": "Group Chat",
|
|
||||||
"Groups": "Groups",
|
|
||||||
"Header 1": "Header 1",
|
|
||||||
"Header 2": "Header 2",
|
|
||||||
"Header 3": "Header 3",
|
|
||||||
"Headers": "Headers",
|
|
||||||
"Help to edit": "Help to edit",
|
|
||||||
"Help": "Помощь",
|
|
||||||
"Here you can customize your profile the way you want.": "Here you can customize your profile the way you want.",
|
|
||||||
"Here you can manage all your Discours subscriptions": "Here you can manage all your Discours subscriptions",
|
|
||||||
"Here you can upload your photo": "Here you can upload your photo",
|
|
||||||
"Hide table of contents": "Hide table of contents",
|
|
||||||
"Highlight": "Highlight",
|
|
||||||
"Hooray! Welcome!": "Hooray! Welcome!",
|
|
||||||
"Horizontal collaborative journalistic platform": "Horizontal collaborative journalism platform",
|
|
||||||
"Hot topics": "Hot topics",
|
|
||||||
"Hotkeys": "Горячие клавиши",
|
|
||||||
"How Discours works": "How Discours works",
|
|
||||||
"How can I help/skills": "How can I help/skills",
|
|
||||||
"How it works": "How it works",
|
|
||||||
"How to help": "How to help?",
|
|
||||||
"How to write a good article": "Как написать хорошую статью",
|
|
||||||
"How to write an article": "How to write an article",
|
|
||||||
"Hundreds of people from different countries and cities share their knowledge and art on the Discours. Join us!": "Hundreds of people from different countries and cities share their knowledge and art on the Discours. Join us!",
|
|
||||||
"I have an account": "I have an account!",
|
|
||||||
"I have no account yet": "I don't have an account yet",
|
|
||||||
"I know the password": "I know the password",
|
|
||||||
"Image format not supported": "Image format not supported",
|
|
||||||
"In bookmarks, you can save favorite discussions and materials that you want to return to": "In bookmarks, you can save favorite discussions and materials that you want to return to",
|
|
||||||
"Inbox": "Inbox",
|
|
||||||
"Incut": "Incut",
|
|
||||||
"Independant magazine with an open horizontal cooperation about culture, science and society": "Independant magazine with an open horizontal cooperation about culture, science and society",
|
|
||||||
"Independent media project about culture, science, art and society with horizontal editing": "Independent media project about culture, science, art and society with horizontal editing",
|
|
||||||
"Insert footnote": "Insert footnote",
|
|
||||||
"Insert video link": "Insert video link",
|
|
||||||
"Interview": "Interview",
|
|
||||||
"Introduce": "Introduction",
|
|
||||||
"Invalid email": "Check if your email is correct",
|
|
||||||
"Invalid image URL": "Invalid image URL",
|
|
||||||
"Invalid url format": "Invalid url format",
|
|
||||||
"Invite co-authors": "Invite co-authors",
|
|
||||||
"Invite collaborators": "Invite collaborators",
|
|
||||||
"Invite to collab": "Invite to Collab",
|
|
||||||
"Invite": "Invite",
|
|
||||||
"It does not look like url": "It doesn't look like a link",
|
|
||||||
"Italic": "Italic",
|
|
||||||
"Join our maillist": "To receive the best postings, just enter your email",
|
|
||||||
"Join the community": "Join the community",
|
|
||||||
"Join the global community of authors!": "Join the global community of authors from all over the world!",
|
|
||||||
"Join": "Join",
|
|
||||||
"Just start typing...": "Just start typing...",
|
|
||||||
"Knowledge base": "Knowledge base",
|
|
||||||
"Language": "Language",
|
|
||||||
"Last rev.": "Посл. изм.",
|
|
||||||
"Let's log in": "Let's log in",
|
|
||||||
"Link copied to clipboard": "Link copied to clipboard",
|
|
||||||
"Link copied": "Link copied",
|
|
||||||
"Link sent, check your email": "Link sent, check your email",
|
|
||||||
"List of authors of the open editorial community": "List of authors of the open editorial community",
|
|
||||||
"Lists": "Lists",
|
|
||||||
"Literature": "Literature",
|
|
||||||
"Load more": "Show more",
|
|
||||||
"Loading": "Loading",
|
|
||||||
"Logout": "Logout",
|
|
||||||
"Looks like you forgot to upload the video": "Looks like you forgot to upload the video",
|
|
||||||
"Manifest of samizdat: principles and mission of an open magazine with a horizontal editorial board": "Manifest of samizdat: principles and mission of an open magazine with a horizontal editorial board",
|
|
||||||
"Manifesto": "Manifesto",
|
|
||||||
"Many files, choose only one": "Many files, choose only one",
|
|
||||||
"Mark as read": "Mark as read",
|
|
||||||
"Material card": "Material card",
|
|
||||||
"Message": "Message",
|
|
||||||
"More": "More",
|
|
||||||
"Most commented": "Commented",
|
|
||||||
"Most read": "Readable",
|
|
||||||
"Move down": "Move down",
|
|
||||||
"Move up": "Move up",
|
|
||||||
"Music": "Music",
|
|
||||||
"My feed": "My feed",
|
|
||||||
"My subscriptions": "Subscriptions",
|
|
||||||
"Name": "Name",
|
|
||||||
"New literary work": "New literary work",
|
|
||||||
"New only": "New only",
|
|
||||||
"New password": "New password",
|
|
||||||
"New stories every day and even more!": "New stories and more are waiting for you every day!",
|
|
||||||
"Newsletter": "Newsletter",
|
|
||||||
"Night mode": "Night mode",
|
|
||||||
"No notifications yet": "No notifications yet",
|
|
||||||
"Nothing here yet": "There's nothing here yet",
|
|
||||||
"Nothing is here": "There is nothing here",
|
|
||||||
"Notifications": "Notifications",
|
|
||||||
"Now you can enter a new password, it must contain at least 8 characters and not be the same as the previous password": "Now you can enter a new password, it must contain at least 8 characters and not be the same as the previous password",
|
|
||||||
"Or paste a link to an image": "Or paste a link to an image",
|
|
||||||
"Ordered list": "Ordered list",
|
|
||||||
"Our regular contributor": "Our regular contributor",
|
|
||||||
"Paragraphs": "Абзацев",
|
|
||||||
"Participate in the Discours: share information, join the editorial team": "Участвуйте в Дискурсе: делитесь информацией, присоединяйтесь к редакции",
|
|
||||||
"Participating": "Participating",
|
|
||||||
"Participation": "Participation",
|
|
||||||
"Partners": "Partners",
|
|
||||||
"Password again": "Password again",
|
|
||||||
"Password should be at least 8 characters": "Password should be at least 8 characters",
|
|
||||||
"Password should contain at least one number": "Password should contain at least one number",
|
|
||||||
"Password should contain at least one special character: !@#$%^&*": "Password should contain at least one special character: !@#$%^&*",
|
|
||||||
"Password updated!": "Password updated!",
|
|
||||||
"Password": "Password",
|
|
||||||
"Passwords are not equal": "Passwords are not equal",
|
|
||||||
"Paste Embed code": "Paste Embed code",
|
|
||||||
"Personal": "Personal",
|
|
||||||
"Pin": "Pin",
|
|
||||||
"Platform Guide": "Platform Guide",
|
|
||||||
"Please check your email address": "Please check your email address",
|
|
||||||
"Please confirm your email to finish": "Confirm your email and the action will complete",
|
|
||||||
"Please enter a name to sign your comments and publication": "Please enter a name to sign your comments and publication",
|
|
||||||
"Please enter email": "Please enter your email",
|
|
||||||
"Please enter password again": "Please enter password again",
|
|
||||||
"Please enter password": "Please enter a password",
|
|
||||||
"Please, confirm email": "Please confirm email",
|
|
||||||
"Please, set the main topic first": "Please, set the main topic first",
|
|
||||||
"Podcasts": "Podcasts",
|
|
||||||
"Poetry": "Poetry",
|
|
||||||
"Popular authors": "Popular authors",
|
|
||||||
"Popular": "Popular",
|
|
||||||
"Principles": "Community principles",
|
|
||||||
"Professional principles that the open editorial team follows in its work": "Professional principles that the open editorial team follows in its work",
|
|
||||||
"Profile settings": "Profile settings",
|
|
||||||
"Profile": "Profile",
|
|
||||||
"Publications": "Publications",
|
|
||||||
"PublicationsWithCount": "{count, plural, =0 {no publications} one {{count} publication} other {{count} publications}}",
|
|
||||||
"FollowersWithCount": "{count, plural, =0 {no followers} one {{count} follower} other {{count} followers}}",
|
|
||||||
"Publish Album": "Publish Album",
|
|
||||||
"Publish Settings": "Publish Settings",
|
|
||||||
"Published": "Published",
|
|
||||||
"Punchline": "Punchline",
|
|
||||||
"Quit": "Quit",
|
|
||||||
"Quote": "Quote",
|
|
||||||
"Quotes": "Quotes",
|
|
||||||
"Reason uknown": "Reason unknown",
|
|
||||||
"Recent": "Fresh",
|
|
||||||
"Registered since {date}": "Registered since {date}",
|
|
||||||
"Remove link": "Remove link",
|
|
||||||
"Reply": "Reply",
|
|
||||||
"Report": "Complain",
|
|
||||||
"Reports": "Reports",
|
|
||||||
"Required": "Required",
|
|
||||||
"Resend code": "Send confirmation",
|
|
||||||
"Rules of the journal Discours": "Rules of the journal Discours",
|
|
||||||
"Save draft": "Save draft",
|
|
||||||
"Save settings": "Save settings",
|
|
||||||
"Saving...": "Saving...",
|
|
||||||
"Scroll up": "Scroll up",
|
|
||||||
"Search author": "Search author",
|
|
||||||
"Search topic": "Search topic",
|
|
||||||
"Search": "Search",
|
|
||||||
"Sections": "Sections",
|
|
||||||
"Security": "Security",
|
|
||||||
"Select": "Select",
|
|
||||||
"Self-publishing exists thanks to the help of wonderful people from all over the world. Thank you!": "Samizdat exists thanks to the help of wonderful people from all over the world. Thank you!",
|
|
||||||
"Send link again": "Send link again",
|
|
||||||
"Send": "Send",
|
|
||||||
"Set the new password": "Set the new password",
|
|
||||||
"Settings": "Settings",
|
|
||||||
"Share publication": "Share publication",
|
|
||||||
"Share": "Share",
|
|
||||||
"Show lyrics": "Show lyrics",
|
|
||||||
"Show more": "Show more",
|
|
||||||
"Show table of contents": "Show table of contents",
|
|
||||||
"Show": "Show",
|
|
||||||
"Site search": "Site search",
|
|
||||||
"Slug": "Slug",
|
|
||||||
"Social networks": "Social networks",
|
|
||||||
"Society": "Society",
|
|
||||||
"Some new comments to your publication": "{commentsCount, plural, one {New comment} other {{commentsCount} comments}} to your publication",
|
|
||||||
"Some new replies to your comment": "{commentsCount, plural, one {New reply} other {{commentsCount} replays}} to your publication",
|
|
||||||
"Something went wrong, check email and password": "Something went wrong. Check your email and password",
|
|
||||||
"Something went wrong, please try again": "Something went wrong, please try again",
|
|
||||||
"Song lyrics": "Song lyrics...",
|
|
||||||
"Song title": "Song title",
|
|
||||||
"Soon": "Скоро",
|
|
||||||
"Sorry, this address is already taken, please choose another one.": "Sorry, this address is already taken, please choose another one",
|
|
||||||
"Special Projects": "Special Projects",
|
|
||||||
"Special projects": "Special projects",
|
|
||||||
"Specify the source and the name of the author": "Specify the source and the name of the author",
|
|
||||||
"Start conversation": "Start a conversation",
|
|
||||||
"Start dialog": "Start dialog",
|
|
||||||
"Subsccriptions": "Subscriptions",
|
|
||||||
"Subscribe to the best publications newsletter": "Subscribe to the best publications newsletter",
|
|
||||||
"Subscribe us": "Subscribe us",
|
|
||||||
"Subscribe what you like to tune your personal feed": "Subscribe to topics that interest you to customize your personal feed and get instant updates on new posts and discussions",
|
|
||||||
"Subscribe who you like to tune your personal feed": "Subscribe to authors you're interested in to customize your personal feed and get instant updates on new posts and discussions",
|
|
||||||
"Subscribe": "Subscribe",
|
|
||||||
"SubscriberWithCount": "{count, plural, =0 {no followers} one {{count} follower} other {{count} followers}}",
|
|
||||||
"Subscription": "Subscription",
|
|
||||||
"SubscriptionWithCount": "{count, plural, =0 {no subscriptions} one {{count} subscription} other {{count} subscriptions}}",
|
|
||||||
"Subscriptions": "Subscriptions",
|
|
||||||
"Substrate": "Substrate",
|
|
||||||
"Success": "Success",
|
|
||||||
"Successfully authorized": "Authorization successful",
|
|
||||||
"Suggest an idea": "Suggest an idea",
|
|
||||||
"Support Discours": "Support Discours",
|
|
||||||
"Support the project": "Support the project",
|
|
||||||
"Support us": "Support us",
|
|
||||||
"Terms of use": "Site rules",
|
|
||||||
"Text checking": "Text checking",
|
|
||||||
"Thank you!": "Thank you!",
|
|
||||||
"Thank you": "Thank you",
|
|
||||||
"The address is already taken": "The address is already taken",
|
|
||||||
"The most interesting publications on the topic": "The most interesting publications on the topic {topicName}",
|
|
||||||
"Thematic table of contents of the magazine. Here you can find all the topics that community authors have written about.": "Thematic table of contents of the magazine. Here you can find all the topics that community authors have written about.",
|
|
||||||
"Thematic table of contents of the magazine. Here you can find all the topics that the community authors wrote about": "Thematic table of contents of the magazine. Here you can find all the topics that the community authors wrote about",
|
|
||||||
"Themes and plots": "Themes and plots",
|
|
||||||
"Please, set the article title": "Please, set the article title",
|
|
||||||
"Theory": "Theory",
|
|
||||||
"There are unsaved changes in your profile settings. Are you sure you want to leave the page without saving?": "There are unsaved changes in your profile settings. Are you sure you want to leave the page without saving?",
|
|
||||||
"There are unsaved changes in your publishing settings. Are you sure you want to leave the page without saving?": "There are unsaved changes in your publishing settings. Are you sure you want to leave the page without saving?",
|
|
||||||
"This comment has not yet been rated": "This comment has not yet been rated",
|
|
||||||
"This email is": "This email is",
|
|
||||||
"This email is not verified": "This email is not verified",
|
|
||||||
"This email is verified": "This email is verified",
|
|
||||||
"This email is registered": "This email is registered",
|
|
||||||
"This functionality is currently not available, we would like to work on this issue. Use the download link.": "This functionality is currently not available, we would like to work on this issue. Use the download link.",
|
|
||||||
"This month": "This month",
|
|
||||||
"This post has not been rated yet": "This post has not been rated yet",
|
|
||||||
"This way we ll realize that you re a real person and ll take your vote into account. And you ll see how others voted": "This way we ll realize that you re a real person and ll take your vote into account. And you ll see how others voted",
|
|
||||||
"This way you ll be able to subscribe to authors, interesting topics and customize your feed": "This way you ll be able to subscribe to authors, interesting topics and customize your feed",
|
|
||||||
"This week": "This week",
|
|
||||||
"This year": "This year",
|
|
||||||
"To find publications, art, comments, authors and topics of interest to you, just start typing your query": "To find publications, art, comments, authors and topics of interest to you, just start typing your query",
|
|
||||||
"To leave a comment please": "To leave a comment please",
|
|
||||||
"To write a comment, you must": "To write a comment, you must",
|
|
||||||
"Top authors": "Authors rating",
|
|
||||||
"Top commented": "Most commented",
|
|
||||||
"Top discussed": "Top discussed",
|
|
||||||
"Top month articles": "Top of the month",
|
|
||||||
"Top rated": "Popular",
|
|
||||||
"Top recent": "Most recent",
|
|
||||||
"Top topics": "Interesting topics",
|
|
||||||
"Top viewed": "Most viewed",
|
|
||||||
"Topic is supported by": "Topic is supported by",
|
|
||||||
"Topics which supported by author": "Topics which supported by author",
|
|
||||||
"Topics": "Topics",
|
|
||||||
"Try to find another way": "Try to find another way",
|
|
||||||
"Unfollow the topic": "Unfollow the topic",
|
|
||||||
"Unfollow": "Unfollow",
|
|
||||||
"Unnamed draft": "Unnamed draft",
|
|
||||||
"Upload error": "Upload error",
|
|
||||||
"Upload userpic": "Upload userpic",
|
|
||||||
"Upload video": "Upload video",
|
|
||||||
"Upload": "Upload",
|
|
||||||
"Uploading image": "Uploading image",
|
|
||||||
"Username": "Username",
|
|
||||||
"Userpic": "Userpic",
|
|
||||||
"Users": "Users",
|
|
||||||
"Video format not supported": "Video format not supported",
|
|
||||||
"Video": "Video",
|
|
||||||
"Views": "Views",
|
|
||||||
"We are working on collaborative editing of articles and in the near future you will have an amazing opportunity - to create together with your colleagues": "We are working on collaborative editing of articles and in the near future you will have an amazing opportunity - to create together with your colleagues",
|
|
||||||
"We can't find you, check email or": "We can't find you, check email or",
|
|
||||||
"We couldn't find anything for your request": "We couldn’t find anything for your request",
|
|
||||||
"We know you, please try to login": "This email address is already registered, please try to login",
|
|
||||||
"We've sent you a message with a link to enter our website.": "We've sent you an email with a link to your email. Follow the link in the email to enter our website.",
|
|
||||||
"Welcome to Discours to add to your bookmarks": "Welcome to Discours to add to your bookmarks",
|
|
||||||
"Welcome to Discours to participate in discussions": "Welcome to Discours to participate in discussions",
|
|
||||||
"Welcome to Discours to publish articles": "Welcome to Discours to publish articles",
|
|
||||||
"Welcome to Discours to subscribe to new publications": "Welcome to Discours to subscribe to new publications",
|
|
||||||
"Welcome to Discours to subscribe": "Welcome to Discours to subscribe",
|
|
||||||
"Welcome to Discours to vote": "Welcome to Discours to vote",
|
|
||||||
"Welcome to Discours": "Welcome to Discours",
|
|
||||||
"Where": "From",
|
|
||||||
"Why you can earn a hole in your karma and how to receive rays of gratitude for your contribution to discussions in samizdat communities": "Why you can earn a hole in your karma and how to receive rays of gratitude for your contribution to discussions in samizdat communities",
|
|
||||||
"Words": "Слов",
|
|
||||||
"Work with us": "Cooperate with Discours",
|
|
||||||
"Write a comment...": "Write a comment...",
|
|
||||||
"Write a short introduction": "Write a short introduction",
|
|
||||||
"Write about the topic": "Write about the topic",
|
|
||||||
"Write an article": "Write an article",
|
|
||||||
"Write comment": "Write comment",
|
|
||||||
"Write good articles, comment\nand it won't be so empty here": "Write good articles, comment\nand it won't be so empty here",
|
|
||||||
"Write message": "Write a message",
|
|
||||||
"Write to us": "Write to us",
|
|
||||||
"You can": "You can",
|
|
||||||
"Write your colleagues name or email": "Write your colleague's name or email",
|
|
||||||
"You can download multiple tracks at once in .mp3, .wav or .flac formats": "You can download multiple tracks at once in .mp3, .wav or .flac formats",
|
|
||||||
"You can now login using your new password": "Теперь вы можете входить с помощью нового пароля",
|
|
||||||
"You were successfully authorized": "You were successfully authorized",
|
|
||||||
"You ll be able to participate in discussions, rate others' comments and learn about new responses": "You ll be able to participate in discussions, rate others' comments and learn about new responses",
|
|
||||||
"You've confirmed email": "You've confirmed email",
|
|
||||||
"You've reached a non-existed page": "You've reached a non-existed page",
|
|
||||||
"Your email": "Your email",
|
|
||||||
"Your name will appear on your profile page and as your signature in publications, comments and responses.": "Your name will appear on your profile page and as your signature in publications, comments and responses",
|
|
||||||
"actions": "actions",
|
|
||||||
"add link": "add link",
|
|
||||||
"all topics": "all topics",
|
|
||||||
"and some more authors": "{restUsersCount, plural, =0 {} one { and one more user} other { and more {restUsersCount} users}}",
|
|
||||||
"article": "article",
|
|
||||||
"author": "author",
|
|
||||||
"authors": "authors",
|
|
||||||
"authorsWithCount": "{count} {count, plural, one {author} other {authors}}",
|
|
||||||
"back to menu": "back to menu",
|
|
||||||
"bold": "bold",
|
|
||||||
"bookmarks": "bookmarks",
|
|
||||||
"cancel": "cancel",
|
|
||||||
"collections": "collections",
|
|
||||||
"community": "community",
|
|
||||||
"contents": "contents",
|
|
||||||
"delimiter": "delimiter",
|
|
||||||
"discussion": "Discours",
|
|
||||||
"dogma keywords": "Discours.io, dogma, editorial principles, code of ethics, journalism, community",
|
|
||||||
"drafts": "drafts",
|
|
||||||
"earlier": "earlier",
|
|
||||||
"email not confirmed": "email not confirmed",
|
|
||||||
"enter": "enter",
|
|
||||||
"feed": "feed",
|
|
||||||
"follower": "follower",
|
|
||||||
"followersWithCount": "{count} {count, plural, one {follower} other {followers}}",
|
|
||||||
"from": "from",
|
|
||||||
"header 1": "header 1",
|
|
||||||
"header 2": "header 2",
|
|
||||||
"header 3": "header 3",
|
|
||||||
"images": "images",
|
|
||||||
"invalid password": "invalid password",
|
|
||||||
"italic": "italic",
|
|
||||||
"journal": "journal",
|
|
||||||
"jpg, .png, max. 10 mb.": "jpg, .png, макс. 10 мб.",
|
|
||||||
"keywords": "Discours.io, Discours magazine, Discours, culture, science, art, society, independent journalism, literature, music, cinema, video, photography",
|
|
||||||
"literature": "literature",
|
|
||||||
"marker list": "marker list",
|
|
||||||
"min. 1400×1400 pix": "мин. 1400×1400 пикс.",
|
|
||||||
"music": "music",
|
|
||||||
"my feed": "my ribbon",
|
|
||||||
"not verified": "not verified",
|
|
||||||
"number list": "number list",
|
|
||||||
"or sign in with social networks": "or sign in with social networks",
|
|
||||||
"personal data usage and email notifications": "to process personal data and receive email notifications",
|
|
||||||
"post": "post",
|
|
||||||
"principles keywords": "Discours.io, communities, values, editorial rules, polyphony, creation",
|
|
||||||
"register": "register",
|
|
||||||
"registered": "registered",
|
|
||||||
"repeat": "repeat",
|
|
||||||
"resend confirmation link": "resend confirmation link",
|
|
||||||
"shout": "post",
|
|
||||||
"shoutsWithCount": "{count} {count, plural, one {post} other {posts}}",
|
|
||||||
"sign up or sign in": "sign up or sign in",
|
|
||||||
"slug is used by another user": "Slug is already taken by another user",
|
|
||||||
"subscriber": "subscriber",
|
|
||||||
"subscriber_rp": "subscriber",
|
|
||||||
"subscribers": "subscribers",
|
|
||||||
"subscribing...": "subscribing...",
|
|
||||||
"subscription": "subscription",
|
|
||||||
"subscription_rp": "subscription",
|
|
||||||
"subscriptions": "subscriptions",
|
|
||||||
"terms of use keywords": "Discours.io, site rules, terms of use",
|
|
||||||
"terms of use": "terms of use",
|
|
||||||
"today": "today",
|
|
||||||
"topicKeywords": "{topic}, Discours.io, articles, journalism, research",
|
|
||||||
"topics": "topics",
|
|
||||||
"user already exist": "user already exists",
|
|
||||||
"verified": "verified",
|
|
||||||
"video": "video",
|
|
||||||
"view": "view",
|
|
||||||
"viewsWithCount": "{count} {count, plural, one {view} other {views}}",
|
|
||||||
"yesterday": "yesterday"
|
|
||||||
}
|
|
BIN
public/placeholder-discussions.webp
Normal file
After Width: | Height: | Size: 379 KiB |
BIN
public/placeholder-experts.webp
Normal file
After Width: | Height: | Size: 157 KiB |
BIN
public/placeholder-feed.webp
Normal file
After Width: | Height: | Size: 220 KiB |
BIN
public/placeholder-join.webp
Normal file
After Width: | Height: | Size: 192 KiB |
|
@ -1,2 +1,2 @@
|
||||||
User-agent: *
|
User-agent: *
|
||||||
Allow: /
|
Disallow: /
|
||||||
|
|
51
src/app.tsx
Normal file
|
@ -0,0 +1,51 @@
|
||||||
|
import { Meta, MetaProvider } from '@solidjs/meta'
|
||||||
|
import { Router } from '@solidjs/router'
|
||||||
|
import { FileRoutes } from '@solidjs/start/router'
|
||||||
|
import { type JSX, Suspense } from 'solid-js'
|
||||||
|
|
||||||
|
import { AuthToken } from '@authorizerdev/authorizer-js'
|
||||||
|
import { Loading } from './components/_shared/Loading'
|
||||||
|
import { AuthorsProvider } from './context/authors'
|
||||||
|
import { EditorProvider } from './context/editor'
|
||||||
|
import { FeedProvider } from './context/feed'
|
||||||
|
import { LocalizeProvider } from './context/localize'
|
||||||
|
import { SessionProvider } from './context/session'
|
||||||
|
import { TopicsProvider } from './context/topics'
|
||||||
|
import { UIProvider } from './context/ui'
|
||||||
|
|
||||||
|
import '~/styles/app.scss'
|
||||||
|
|
||||||
|
export const Providers = (props: { children?: JSX.Element }) => {
|
||||||
|
const sessionStateChanged = (payload: AuthToken) => {
|
||||||
|
console.debug(payload)
|
||||||
|
// TODO: maybe load subs here
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<LocalizeProvider>
|
||||||
|
<SessionProvider onStateChangeCallback={sessionStateChanged}>
|
||||||
|
<TopicsProvider>
|
||||||
|
<FeedProvider>
|
||||||
|
<MetaProvider>
|
||||||
|
<Meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<UIProvider>
|
||||||
|
<EditorProvider>
|
||||||
|
<AuthorsProvider>
|
||||||
|
<Suspense fallback={<Loading />}>{props.children}</Suspense>
|
||||||
|
</AuthorsProvider>
|
||||||
|
</EditorProvider>
|
||||||
|
</UIProvider>
|
||||||
|
</MetaProvider>
|
||||||
|
</FeedProvider>
|
||||||
|
</TopicsProvider>
|
||||||
|
</SessionProvider>
|
||||||
|
</LocalizeProvider>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const App = () => (
|
||||||
|
<Router root={Providers}>
|
||||||
|
<FileRoutes />
|
||||||
|
</Router>
|
||||||
|
)
|
||||||
|
|
||||||
|
export default App
|
|
@ -1,143 +0,0 @@
|
||||||
import type { PageProps, RootSearchParams } from '../pages/types'
|
|
||||||
|
|
||||||
import { Meta, MetaProvider } from '@solidjs/meta'
|
|
||||||
import { Component, createEffect, createMemo } from 'solid-js'
|
|
||||||
import { Dynamic } from 'solid-js/web'
|
|
||||||
|
|
||||||
import { ConfirmProvider } from '../context/confirm'
|
|
||||||
import { ConnectProvider } from '../context/connect'
|
|
||||||
import { EditorProvider } from '../context/editor'
|
|
||||||
import { FollowingProvider } from '../context/following'
|
|
||||||
import { InboxProvider } from '../context/inbox'
|
|
||||||
import { LocalizeProvider } from '../context/localize'
|
|
||||||
import { MediaQueryProvider } from '../context/mediaQuery'
|
|
||||||
import { NotificationsProvider } from '../context/notifications'
|
|
||||||
import { SessionProvider } from '../context/session'
|
|
||||||
import { SnackbarProvider } from '../context/snackbar'
|
|
||||||
import { DiscussionRulesPage } from '../pages/about/discussionRules.page'
|
|
||||||
import { DogmaPage } from '../pages/about/dogma.page'
|
|
||||||
import { GuidePage } from '../pages/about/guide.page'
|
|
||||||
import { HelpPage } from '../pages/about/help.page'
|
|
||||||
import { ManifestPage } from '../pages/about/manifest.page'
|
|
||||||
import { PartnersPage } from '../pages/about/partners.page'
|
|
||||||
import { PrinciplesPage } from '../pages/about/principles.page'
|
|
||||||
import { ProjectsPage } from '../pages/about/projects.page'
|
|
||||||
import { TermsOfUsePage } from '../pages/about/termsOfUse.page'
|
|
||||||
import { ThanksPage } from '../pages/about/thanks.page'
|
|
||||||
import { AllAuthorsPage } from '../pages/allAuthors.page'
|
|
||||||
import { AllTopicsPage } from '../pages/allTopics.page'
|
|
||||||
import { ArticlePage } from '../pages/article.page'
|
|
||||||
import { AuthorPage } from '../pages/author.page'
|
|
||||||
import { ConnectPage } from '../pages/connect.page'
|
|
||||||
import { CreatePage } from '../pages/create.page'
|
|
||||||
import { DraftsPage } from '../pages/drafts.page'
|
|
||||||
import { EditPage } from '../pages/edit.page'
|
|
||||||
import { ExpoPage } from '../pages/expo/expo.page'
|
|
||||||
import { FeedPage } from '../pages/feed.page'
|
|
||||||
import { FourOuFourPage } from '../pages/fourOuFour.page'
|
|
||||||
import { InboxPage } from '../pages/inbox.page'
|
|
||||||
import { HomePage } from '../pages/index.page'
|
|
||||||
import { ProfileSecurityPage } from '../pages/profile/profileSecurity.page'
|
|
||||||
import { ProfileSettingsPage } from '../pages/profile/profileSettings.page'
|
|
||||||
import { ProfileSubscriptionsPage } from '../pages/profile/profileSubscriptions.page'
|
|
||||||
import { SearchPage } from '../pages/search.page'
|
|
||||||
import { TopicPage } from '../pages/topic.page'
|
|
||||||
import { ROUTES, useRouter } from '../stores/router'
|
|
||||||
import { MODALS, hideModal, showModal } from '../stores/ui'
|
|
||||||
|
|
||||||
// TODO: lazy load
|
|
||||||
// const SomePage = lazy(() => import('./Pages/SomePage'))
|
|
||||||
|
|
||||||
const pagesMap: Record<keyof typeof ROUTES, Component<PageProps>> = {
|
|
||||||
author: AuthorPage,
|
|
||||||
authorComments: AuthorPage,
|
|
||||||
authorAbout: AuthorPage,
|
|
||||||
inbox: InboxPage,
|
|
||||||
expo: ExpoPage,
|
|
||||||
connect: ConnectPage,
|
|
||||||
create: CreatePage,
|
|
||||||
edit: EditPage,
|
|
||||||
editSettings: EditPage,
|
|
||||||
drafts: DraftsPage,
|
|
||||||
home: HomePage,
|
|
||||||
topics: AllTopicsPage,
|
|
||||||
topic: TopicPage,
|
|
||||||
authors: AllAuthorsPage,
|
|
||||||
feed: FeedPage,
|
|
||||||
feedMy: FeedPage,
|
|
||||||
feedNotifications: FeedPage,
|
|
||||||
feedBookmarks: FeedPage,
|
|
||||||
feedCollaborations: FeedPage,
|
|
||||||
feedDiscussions: FeedPage,
|
|
||||||
article: ArticlePage,
|
|
||||||
search: SearchPage,
|
|
||||||
discussionRules: DiscussionRulesPage,
|
|
||||||
dogma: DogmaPage,
|
|
||||||
guide: GuidePage,
|
|
||||||
help: HelpPage,
|
|
||||||
manifest: ManifestPage,
|
|
||||||
projects: ProjectsPage,
|
|
||||||
partners: PartnersPage,
|
|
||||||
principles: PrinciplesPage,
|
|
||||||
termsOfUse: TermsOfUsePage,
|
|
||||||
thanks: ThanksPage,
|
|
||||||
profileSettings: ProfileSettingsPage,
|
|
||||||
profileSecurity: ProfileSecurityPage,
|
|
||||||
profileSubscriptions: ProfileSubscriptionsPage,
|
|
||||||
fourOuFour: FourOuFourPage,
|
|
||||||
}
|
|
||||||
|
|
||||||
type Props = PageProps & { is404: boolean }
|
|
||||||
|
|
||||||
export const App = (props: Props) => {
|
|
||||||
const { page, searchParams } = useRouter<RootSearchParams>()
|
|
||||||
const is404 = createMemo(() => props.is404)
|
|
||||||
|
|
||||||
createEffect(() => {
|
|
||||||
if (!searchParams().m) {
|
|
||||||
hideModal()
|
|
||||||
}
|
|
||||||
|
|
||||||
const modal = MODALS[searchParams().m]
|
|
||||||
if (modal) {
|
|
||||||
showModal(modal)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
const pageComponent = createMemo(() => {
|
|
||||||
const result = pagesMap[page()?.route || 'home']
|
|
||||||
|
|
||||||
if (is404() || !result || page()?.path === '/404') {
|
|
||||||
return FourOuFourPage
|
|
||||||
}
|
|
||||||
|
|
||||||
return result
|
|
||||||
})
|
|
||||||
|
|
||||||
return (
|
|
||||||
<MetaProvider>
|
|
||||||
<Meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
||||||
<LocalizeProvider>
|
|
||||||
<MediaQueryProvider>
|
|
||||||
<SnackbarProvider>
|
|
||||||
<ConfirmProvider>
|
|
||||||
<SessionProvider onStateChangeCallback={console.log}>
|
|
||||||
<FollowingProvider>
|
|
||||||
<ConnectProvider>
|
|
||||||
<NotificationsProvider>
|
|
||||||
<EditorProvider>
|
|
||||||
<InboxProvider>
|
|
||||||
<Dynamic component={pageComponent()} {...props} />
|
|
||||||
</InboxProvider>
|
|
||||||
</EditorProvider>
|
|
||||||
</NotificationsProvider>
|
|
||||||
</ConnectProvider>
|
|
||||||
</FollowingProvider>
|
|
||||||
</SessionProvider>
|
|
||||||
</ConfirmProvider>
|
|
||||||
</SnackbarProvider>
|
|
||||||
</MediaQueryProvider>
|
|
||||||
</LocalizeProvider>
|
|
||||||
</MetaProvider>
|
|
||||||
)
|
|
||||||
}
|
|
|
@ -22,10 +22,21 @@ img {
|
||||||
.articleContent {
|
.articleContent {
|
||||||
img:not([data-disable-lightbox='true']) {
|
img:not([data-disable-lightbox='true']) {
|
||||||
cursor: zoom-in;
|
cursor: zoom-in;
|
||||||
|
width: 100%;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.shoutBody {
|
.shoutBody {
|
||||||
|
@include media-breakpoint-up(sm) {
|
||||||
|
:global(.width-30) {
|
||||||
|
width: 30%;
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.width-50) {
|
||||||
|
width: 50%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
font-size: 1.6rem;
|
font-size: 1.6rem;
|
||||||
line-height: 1.6;
|
line-height: 1.6;
|
||||||
|
|
||||||
|
@ -64,6 +75,16 @@ img {
|
||||||
|
|
||||||
blockquote[data-type='quote'],
|
blockquote[data-type='quote'],
|
||||||
ta-quotation {
|
ta-quotation {
|
||||||
|
@include media-breakpoint-up(sm) {
|
||||||
|
&[data-float='left'] {
|
||||||
|
margin-right: 1.5em;
|
||||||
|
}
|
||||||
|
|
||||||
|
&[data-float='right'] {
|
||||||
|
margin-left: 1.5em;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
border: solid #000;
|
border: solid #000;
|
||||||
border-width: 0 0 0 2px;
|
border-width: 0 0 0 2px;
|
||||||
clear: both;
|
clear: both;
|
||||||
|
@ -77,21 +98,11 @@ img {
|
||||||
&[data-float='right'] {
|
&[data-float='right'] {
|
||||||
@include font-size(2.2rem);
|
@include font-size(2.2rem);
|
||||||
|
|
||||||
line-height: 1.4;
|
|
||||||
|
|
||||||
@include media-breakpoint-up(sm) {
|
@include media-breakpoint-up(sm) {
|
||||||
clear: none;
|
clear: none;
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
@include media-breakpoint-up(sm) {
|
line-height: 1.4;
|
||||||
&[data-float='left'] {
|
|
||||||
margin-right: 1.5em;
|
|
||||||
}
|
|
||||||
|
|
||||||
&[data-float='right'] {
|
|
||||||
margin-left: 1.5em;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
&::before {
|
&::before {
|
||||||
|
@ -105,17 +116,17 @@ img {
|
||||||
ta-border-sub {
|
ta-border-sub {
|
||||||
@include font-size(1.4rem);
|
@include font-size(1.4rem);
|
||||||
|
|
||||||
|
@include media-breakpoint-up(md) {
|
||||||
|
margin: 3.2rem -8.3333%;
|
||||||
|
padding: 3.2rem 8.3333%;
|
||||||
|
}
|
||||||
|
|
||||||
background: #f1f2f3;
|
background: #f1f2f3;
|
||||||
clear: both;
|
clear: both;
|
||||||
display: block;
|
display: block;
|
||||||
margin: 3.2rem 0;
|
margin: 3.2rem 0;
|
||||||
padding: 3.2rem;
|
padding: 3.2rem;
|
||||||
|
|
||||||
@include media-breakpoint-up(md) {
|
|
||||||
margin: 3.2rem -8.3333%;
|
|
||||||
padding: 3.2rem 8.3333%;
|
|
||||||
}
|
|
||||||
|
|
||||||
p:last-child {
|
p:last-child {
|
||||||
margin-bottom: 0;
|
margin-bottom: 0;
|
||||||
}
|
}
|
||||||
|
@ -193,16 +204,6 @@ img {
|
||||||
margin: 0 8.3333% 1.5em 0;
|
margin: 0 8.3333% 1.5em 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
@include media-breakpoint-up(sm) {
|
|
||||||
:global(.width-30) {
|
|
||||||
width: 30%;
|
|
||||||
}
|
|
||||||
|
|
||||||
:global(.width-50) {
|
|
||||||
width: 50%;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
:global(.img-align-left.width-50) {
|
:global(.img-align-left.width-50) {
|
||||||
@include media-breakpoint-up(xl) {
|
@include media-breakpoint-up(xl) {
|
||||||
margin-left: -16.6666%;
|
margin-left: -16.6666%;
|
||||||
|
@ -312,20 +313,24 @@ img {
|
||||||
}
|
}
|
||||||
|
|
||||||
.shoutStats {
|
.shoutStats {
|
||||||
|
@include media-breakpoint-down(lg) {
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
border-top: 4px solid #000;
|
border-top: 4px solid #000;
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: flex-start;
|
justify-content: flex-start;
|
||||||
padding: 3rem 0 0;
|
padding: 3rem 0 0;
|
||||||
position: relative;
|
position: relative;
|
||||||
|
|
||||||
@include media-breakpoint-down(lg) {
|
|
||||||
flex-wrap: wrap;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.shoutStatsItem {
|
.shoutStatsItem {
|
||||||
@include font-size(1.5rem);
|
@include font-size(1.5rem);
|
||||||
|
|
||||||
|
@include media-breakpoint-up(xl) {
|
||||||
|
margin-right: 3.2rem;
|
||||||
|
}
|
||||||
|
|
||||||
align-items: center;
|
align-items: center;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
display: flex;
|
display: flex;
|
||||||
|
@ -333,10 +338,6 @@ img {
|
||||||
vertical-align: baseline;
|
vertical-align: baseline;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
|
|
||||||
@include media-breakpoint-up(xl) {
|
|
||||||
margin-right: 3.2rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.icon {
|
.icon {
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
margin-right: 0.2em;
|
margin-right: 0.2em;
|
||||||
|
@ -378,11 +379,11 @@ img {
|
||||||
}
|
}
|
||||||
|
|
||||||
.shoutStatsItemBookmarks {
|
.shoutStatsItemBookmarks {
|
||||||
margin-left: auto;
|
|
||||||
|
|
||||||
@include media-breakpoint-up(lg) {
|
@include media-breakpoint-up(lg) {
|
||||||
margin-left: 0;
|
margin-left: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
margin-left: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.shoutStatsItemInner {
|
.shoutStatsItemInner {
|
||||||
|
@ -407,6 +408,15 @@ img {
|
||||||
}
|
}
|
||||||
|
|
||||||
.shoutStatsItemAdditionalData {
|
.shoutStatsItemAdditionalData {
|
||||||
|
@include media-breakpoint-down(lg) {
|
||||||
|
flex: 1 100%;
|
||||||
|
order: 9;
|
||||||
|
|
||||||
|
.shoutStatsItemAdditionalDataItem {
|
||||||
|
margin-left: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
color: rgb(0 0 0 / 40%);
|
color: rgb(0 0 0 / 40%);
|
||||||
cursor: default;
|
cursor: default;
|
||||||
font-weight: normal;
|
font-weight: normal;
|
||||||
|
@ -417,24 +427,9 @@ img {
|
||||||
opacity: 0.4;
|
opacity: 0.4;
|
||||||
height: 2rem;
|
height: 2rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
@include media-breakpoint-down(lg) {
|
|
||||||
flex: 1 100%;
|
|
||||||
order: 9;
|
|
||||||
|
|
||||||
.shoutStatsItemAdditionalDataItem {
|
|
||||||
margin-left: 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.shoutStatsItemViews {
|
.shoutStatsItemViews {
|
||||||
color: rgb(0 0 0 / 40%);
|
|
||||||
cursor: default;
|
|
||||||
font-weight: normal;
|
|
||||||
margin-left: auto;
|
|
||||||
white-space: nowrap;
|
|
||||||
|
|
||||||
@include media-breakpoint-down(lg) {
|
@include media-breakpoint-down(lg) {
|
||||||
bottom: 0;
|
bottom: 0;
|
||||||
flex: 1 40%;
|
flex: 1 40%;
|
||||||
|
@ -448,6 +443,12 @@ img {
|
||||||
display: none !important;
|
display: none !important;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
color: rgb(0 0 0 / 40%);
|
||||||
|
cursor: default;
|
||||||
|
font-weight: normal;
|
||||||
|
margin-left: auto;
|
||||||
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.shoutStatsItemLabel {
|
.shoutStatsItemLabel {
|
||||||
|
@ -456,11 +457,11 @@ img {
|
||||||
}
|
}
|
||||||
|
|
||||||
.commentsTextLabel {
|
.commentsTextLabel {
|
||||||
display: none;
|
|
||||||
|
|
||||||
@include media-breakpoint-up(sm) {
|
@include media-breakpoint-up(sm) {
|
||||||
display: block;
|
display: block;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.shoutStatsItemCount {
|
.shoutStatsItemCount {
|
||||||
|
@ -470,6 +471,12 @@ img {
|
||||||
}
|
}
|
||||||
|
|
||||||
.shoutStatsItemAdditionalDataItem {
|
.shoutStatsItemAdditionalDataItem {
|
||||||
|
@include media-breakpoint-down(sm) {
|
||||||
|
&:first-child {
|
||||||
|
margin-left: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
font-weight: normal;
|
font-weight: normal;
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
|
|
||||||
|
@ -477,12 +484,6 @@ img {
|
||||||
margin-right: 0;
|
margin-right: 0;
|
||||||
margin-bottom: 0;
|
margin-bottom: 0;
|
||||||
cursor: default;
|
cursor: default;
|
||||||
|
|
||||||
@include media-breakpoint-down(sm) {
|
|
||||||
&:first-child {
|
|
||||||
margin-left: 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.topicsList {
|
.topicsList {
|
||||||
|
|
|
@ -36,7 +36,7 @@
|
||||||
width: 200px;
|
width: 200px;
|
||||||
height: 200px;
|
height: 200px;
|
||||||
transition: all 0.2s ease-in-out;
|
transition: all 0.2s ease-in-out;
|
||||||
background: var(--placeholder-color-semi) url('/icons/create-music.svg') no-repeat 50% 50%;
|
background: var(--placeholder-color-semi) url('/icons/create-audio.svg') no-repeat 50% 50%;
|
||||||
|
|
||||||
.image {
|
.image {
|
||||||
object-fit: cover;
|
object-fit: cover;
|
||||||
|
|
|
@ -1,11 +1,11 @@
|
||||||
import { clsx } from 'clsx'
|
import { clsx } from 'clsx'
|
||||||
import { Show, createSignal } from 'solid-js'
|
import { Show, createSignal } from 'solid-js'
|
||||||
|
|
||||||
import { Topic } from '../../../graphql/schema/core.gen'
|
import { Icon } from '~/components/_shared/Icon'
|
||||||
import { MediaItem } from '../../../pages/types'
|
import { Image } from '~/components/_shared/Image'
|
||||||
|
import { Topic } from '~/graphql/schema/core.gen'
|
||||||
|
import { MediaItem } from '~/types/mediaitem'
|
||||||
import { CardTopic } from '../../Feed/CardTopic'
|
import { CardTopic } from '../../Feed/CardTopic'
|
||||||
import { Icon } from '../../_shared/Icon'
|
|
||||||
import { Image } from '../../_shared/Image'
|
|
||||||
|
|
||||||
import styles from './AudioHeader.module.scss'
|
import styles from './AudioHeader.module.scss'
|
||||||
|
|
||||||
|
@ -30,19 +30,19 @@ export const AudioHeader = (props: Props) => {
|
||||||
</div>
|
</div>
|
||||||
<div class={styles.albumInfo}>
|
<div class={styles.albumInfo}>
|
||||||
<Show when={props.topic}>
|
<Show when={props.topic}>
|
||||||
<CardTopic title={props.topic.title} slug={props.topic.slug} />
|
<CardTopic title={props.topic.title || ''} slug={props.topic.slug} />
|
||||||
</Show>
|
</Show>
|
||||||
<h1>{props.title}</h1>
|
<h1>{props.title}</h1>
|
||||||
<Show when={props.artistData}>
|
<Show when={props.artistData}>
|
||||||
<div class={styles.artistData}>
|
<div class={styles.artistData}>
|
||||||
<Show when={props.artistData?.artist}>
|
<Show when={props.artistData?.artist}>
|
||||||
<div class={styles.item}>{props.artistData.artist}</div>
|
<div class={styles.item}>{props.artistData?.artist || ''}</div>
|
||||||
</Show>
|
</Show>
|
||||||
<Show when={props.artistData?.date}>
|
<Show when={props.artistData?.date}>
|
||||||
<div class={styles.item}>{props.artistData.date}</div>
|
<div class={styles.item}>{props.artistData?.date || ''}</div>
|
||||||
</Show>
|
</Show>
|
||||||
<Show when={props.artistData?.genre}>
|
<Show when={props.artistData?.genre}>
|
||||||
<div class={styles.item}>{props.artistData.genre}</div>
|
<div class={styles.item}>{props.artistData?.genre || ''}</div>
|
||||||
</Show>
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
</Show>
|
</Show>
|
||||||
|
|
|
@ -3,27 +3,32 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
.playerHeader {
|
.playerHeader {
|
||||||
width: 100%;
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
|
|
||||||
@include media-breakpoint-down(sm) {
|
@include media-breakpoint-down(sm) {
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
width: 100%;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
}
|
}
|
||||||
|
|
||||||
.playerTitle {
|
.playerTitle {
|
||||||
|
@include media-breakpoint-down(sm) {
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
max-width: 50%;
|
max-width: 50%;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
|
|
||||||
@include media-breakpoint-down(sm) {
|
|
||||||
max-width: 100%;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.playerControls {
|
.playerControls {
|
||||||
|
@include media-breakpoint-down(sm) {
|
||||||
|
margin-top: 20px;
|
||||||
|
margin-left: 0;
|
||||||
|
}
|
||||||
|
|
||||||
display: flex;
|
display: flex;
|
||||||
min-width: 160px;
|
min-width: 160px;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
@ -42,11 +47,6 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@include media-breakpoint-down(sm) {
|
|
||||||
margin-top: 20px;
|
|
||||||
margin-left: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.playButton {
|
.playButton {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
import { Show, createEffect, createMemo, createSignal, on, onMount } from 'solid-js'
|
import { Show, createEffect, createMemo, createSignal, on, onMount } from 'solid-js'
|
||||||
|
|
||||||
import { MediaItem } from '../../../pages/types'
|
import { MediaItem } from '~/types/mediaitem'
|
||||||
|
|
||||||
import { PlayerHeader } from './PlayerHeader'
|
import { PlayerHeader } from './PlayerHeader'
|
||||||
import { PlayerPlaylist } from './PlayerPlaylist'
|
import { PlayerPlaylist } from './PlayerPlaylist'
|
||||||
|
@ -12,18 +12,22 @@ type Props = {
|
||||||
articleSlug?: string
|
articleSlug?: string
|
||||||
body?: string
|
body?: string
|
||||||
editorMode?: boolean
|
editorMode?: boolean
|
||||||
onMediaItemFieldChange?: (index: number, field: keyof MediaItem, value: string) => void
|
onMediaItemFieldChange?: (
|
||||||
onChangeMediaIndex?: (direction: 'up' | 'down', index) => void
|
index: number,
|
||||||
|
field: keyof MediaItem | string | number | symbol,
|
||||||
|
value: string
|
||||||
|
) => void
|
||||||
|
onChangeMediaIndex?: (direction: 'up' | 'down', index: number) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
const getFormattedTime = (point: number) => new Date(point * 1000).toISOString().slice(14, -5)
|
const getFormattedTime = (point: number) => new Date(point * 1000).toISOString().slice(14, -5)
|
||||||
|
|
||||||
export const AudioPlayer = (props: Props) => {
|
export const AudioPlayer = (props: Props) => {
|
||||||
const audioRef: { current: HTMLAudioElement } = { current: null }
|
let audioRef: HTMLAudioElement | undefined
|
||||||
const gainNodeRef: { current: GainNode } = { current: null }
|
let gainNodeRef: GainNode | undefined
|
||||||
const progressRef: { current: HTMLDivElement } = { current: null }
|
let progressRef: HTMLDivElement | undefined
|
||||||
const audioContextRef: { current: AudioContext } = { current: null }
|
let audioContextRef: AudioContext | undefined
|
||||||
const mouseDownRef: { current: boolean } = { current: false }
|
let mouseDownRef: boolean | undefined
|
||||||
|
|
||||||
const [currentTrackDuration, setCurrentTrackDuration] = createSignal(0)
|
const [currentTrackDuration, setCurrentTrackDuration] = createSignal(0)
|
||||||
const [currentTime, setCurrentTime] = createSignal(0)
|
const [currentTime, setCurrentTime] = createSignal(0)
|
||||||
|
@ -31,34 +35,25 @@ export const AudioPlayer = (props: Props) => {
|
||||||
const [isPlaying, setIsPlaying] = createSignal(false)
|
const [isPlaying, setIsPlaying] = createSignal(false)
|
||||||
|
|
||||||
const currentTack = createMemo(() => props.media[currentTrackIndex()])
|
const currentTack = createMemo(() => props.media[currentTrackIndex()])
|
||||||
|
createEffect(on(currentTrackIndex, () => setCurrentTrackDuration(0), { defer: true }))
|
||||||
createEffect(
|
|
||||||
on(
|
|
||||||
() => currentTrackIndex(),
|
|
||||||
() => {
|
|
||||||
setCurrentTrackDuration(0)
|
|
||||||
},
|
|
||||||
{ defer: true },
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
const handlePlayMedia = async (trackIndex: number) => {
|
const handlePlayMedia = async (trackIndex: number) => {
|
||||||
setIsPlaying(!isPlaying() || trackIndex !== currentTrackIndex())
|
setIsPlaying(!isPlaying() || trackIndex !== currentTrackIndex())
|
||||||
setCurrentTrackIndex(trackIndex)
|
setCurrentTrackIndex(trackIndex)
|
||||||
|
|
||||||
if (audioContextRef.current.state === 'suspended') {
|
if (audioContextRef?.state === 'suspended') {
|
||||||
await audioContextRef.current.resume()
|
await audioContextRef?.resume()
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isPlaying()) {
|
if (isPlaying()) {
|
||||||
await audioRef.current.play()
|
await audioRef?.play()
|
||||||
} else {
|
} else {
|
||||||
audioRef.current.pause()
|
audioRef?.pause()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleVolumeChange = (volume: number) => {
|
const handleVolumeChange = (volume: number) => {
|
||||||
gainNodeRef.current.gain.value = volume
|
if (gainNodeRef) gainNodeRef.gain.value = volume
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleAudioEnd = () => {
|
const handleAudioEnd = () => {
|
||||||
|
@ -67,21 +62,22 @@ export const AudioPlayer = (props: Props) => {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
audioRef.current.currentTime = 0
|
if (audioRef) audioRef.currentTime = 0
|
||||||
setIsPlaying(false)
|
setIsPlaying(false)
|
||||||
setCurrentTrackIndex(0)
|
setCurrentTrackIndex(0)
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleAudioTimeUpdate = () => {
|
const handleAudioTimeUpdate = () => {
|
||||||
setCurrentTime(audioRef.current.currentTime)
|
setCurrentTime(audioRef?.currentTime || 0)
|
||||||
}
|
}
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
audioContextRef.current = new AudioContext()
|
audioContextRef = new AudioContext()
|
||||||
gainNodeRef.current = audioContextRef.current.createGain()
|
gainNodeRef = audioContextRef.createGain()
|
||||||
|
if (audioRef) {
|
||||||
const track = audioContextRef.current.createMediaElementSource(audioRef.current)
|
const track = audioContextRef?.createMediaElementSource(audioRef)
|
||||||
track.connect(gainNodeRef.current).connect(audioContextRef.current.destination)
|
track.connect(gainNodeRef).connect(audioContextRef?.destination)
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
const playPrevTrack = () => {
|
const playPrevTrack = () => {
|
||||||
|
@ -102,13 +98,18 @@ export const AudioPlayer = (props: Props) => {
|
||||||
setCurrentTrackIndex(newCurrentTrackIndex)
|
setCurrentTrackIndex(newCurrentTrackIndex)
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleMediaItemFieldChange = (index: number, field: keyof MediaItem, value) => {
|
const handleMediaItemFieldChange = (
|
||||||
props.onMediaItemFieldChange(index, field, value)
|
index: number,
|
||||||
|
field: keyof MediaItem | string | number | symbol,
|
||||||
|
value: string
|
||||||
|
) => {
|
||||||
|
props.onMediaItemFieldChange?.(index, field, value)
|
||||||
}
|
}
|
||||||
|
|
||||||
const scrub = (event) => {
|
const scrub = (event: MouseEvent | undefined) => {
|
||||||
audioRef.current.currentTime =
|
if (progressRef && audioRef) {
|
||||||
(event.offsetX / progressRef.current.offsetWidth) * currentTrackDuration()
|
audioRef.currentTime = (event?.offsetX || 0 / progressRef.offsetWidth) * currentTrackDuration()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
@ -125,16 +126,16 @@ export const AudioPlayer = (props: Props) => {
|
||||||
<div class={styles.timeline}>
|
<div class={styles.timeline}>
|
||||||
<div
|
<div
|
||||||
class={styles.progress}
|
class={styles.progress}
|
||||||
ref={(el) => (progressRef.current = el)}
|
ref={(el) => (progressRef = el)}
|
||||||
onClick={(e) => scrub(e)}
|
onClick={scrub}
|
||||||
onMouseMove={(e) => mouseDownRef.current && scrub(e)}
|
onMouseMove={(e) => mouseDownRef && scrub(e)}
|
||||||
onMouseDown={() => (mouseDownRef.current = true)}
|
onMouseDown={() => (mouseDownRef = true)}
|
||||||
onMouseUp={() => (mouseDownRef.current = false)}
|
onMouseUp={() => (mouseDownRef = false)}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
class={styles.progressFilled}
|
class={styles.progressFilled}
|
||||||
style={{
|
style={{
|
||||||
width: `${(currentTime() / currentTrackDuration()) * 100 || 0}%`,
|
width: `${(currentTime() / currentTrackDuration()) * 100 || 0}%`
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
@ -145,13 +146,13 @@ export const AudioPlayer = (props: Props) => {
|
||||||
</Show>
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
<audio
|
<audio
|
||||||
ref={(el) => (audioRef.current = el)}
|
ref={(el) => (audioRef = el)}
|
||||||
onTimeUpdate={handleAudioTimeUpdate}
|
onTimeUpdate={handleAudioTimeUpdate}
|
||||||
src={currentTack().url.replace('images.discours.io', 'cdn.discours.io')}
|
src={currentTack().url.replace('images.discours.io', 'cdn.discours.io')}
|
||||||
onCanPlay={() => {
|
onCanPlay={() => {
|
||||||
// start to play the next track on src change
|
// start to play the next track on src change
|
||||||
if (isPlaying()) {
|
if (isPlaying() && audioRef) {
|
||||||
audioRef.current.play()
|
audioRef.play()
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
onLoadedMetadata={({ currentTarget }) => setCurrentTrackDuration(currentTarget.duration)}
|
onLoadedMetadata={({ currentTarget }) => setCurrentTrackDuration(currentTarget.duration)}
|
||||||
|
@ -162,7 +163,7 @@ export const AudioPlayer = (props: Props) => {
|
||||||
<PlayerPlaylist
|
<PlayerPlaylist
|
||||||
editorMode={props.editorMode}
|
editorMode={props.editorMode}
|
||||||
onPlayMedia={handlePlayMedia}
|
onPlayMedia={handlePlayMedia}
|
||||||
onChangeMediaIndex={(direction, index) => props.onChangeMediaIndex(direction, index)}
|
onChangeMediaIndex={(direction, index) => props.onChangeMediaIndex?.(direction, index)}
|
||||||
isPlaying={isPlaying()}
|
isPlaying={isPlaying()}
|
||||||
media={props.media}
|
media={props.media}
|
||||||
currentTrackIndex={currentTrackIndex()}
|
currentTrackIndex={currentTrackIndex()}
|
||||||
|
|
|
@ -1,10 +1,9 @@
|
||||||
import { clsx } from 'clsx'
|
import { clsx } from 'clsx'
|
||||||
import { Show, createSignal } from 'solid-js'
|
import { Show, createSignal } from 'solid-js'
|
||||||
|
import { Icon } from '~/components/_shared/Icon'
|
||||||
|
import { useOutsideClickHandler } from '~/lib/useOutsideClickHandler'
|
||||||
|
|
||||||
import { MediaItem } from '../../../pages/types'
|
import { MediaItem } from '~/types/mediaitem'
|
||||||
import { useOutsideClickHandler } from '../../../utils/useOutsideClickHandler'
|
|
||||||
import { Icon } from '../../_shared/Icon'
|
|
||||||
|
|
||||||
import styles from './AudioPlayer.module.scss'
|
import styles from './AudioPlayer.module.scss'
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
|
@ -17,10 +16,7 @@ type Props = {
|
||||||
}
|
}
|
||||||
|
|
||||||
export const PlayerHeader = (props: Props) => {
|
export const PlayerHeader = (props: Props) => {
|
||||||
const volumeContainerRef: { current: HTMLDivElement } = {
|
let volumeContainerRef: HTMLDivElement | undefined
|
||||||
current: null,
|
|
||||||
}
|
|
||||||
|
|
||||||
const [isVolumeBarOpened, setIsVolumeBarOpened] = createSignal(false)
|
const [isVolumeBarOpened, setIsVolumeBarOpened] = createSignal(false)
|
||||||
|
|
||||||
const toggleVolumeBar = () => {
|
const toggleVolumeBar = () => {
|
||||||
|
@ -30,7 +26,7 @@ export const PlayerHeader = (props: Props) => {
|
||||||
useOutsideClickHandler({
|
useOutsideClickHandler({
|
||||||
containerRef: volumeContainerRef,
|
containerRef: volumeContainerRef,
|
||||||
predicate: () => isVolumeBarOpened(),
|
predicate: () => isVolumeBarOpened(),
|
||||||
handler: () => toggleVolumeBar(),
|
handler: () => toggleVolumeBar()
|
||||||
})
|
})
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
@ -42,7 +38,7 @@ export const PlayerHeader = (props: Props) => {
|
||||||
onClick={props.onPlayMedia}
|
onClick={props.onPlayMedia}
|
||||||
class={clsx(
|
class={clsx(
|
||||||
styles.playButton,
|
styles.playButton,
|
||||||
props.isPlaying ? styles.playButtonInvertPause : styles.playButtonInvertPlay,
|
props.isPlaying ? styles.playButtonInvertPause : styles.playButtonInvertPlay
|
||||||
)}
|
)}
|
||||||
aria-label="Play"
|
aria-label="Play"
|
||||||
data-playing="false"
|
data-playing="false"
|
||||||
|
@ -65,7 +61,7 @@ export const PlayerHeader = (props: Props) => {
|
||||||
>
|
>
|
||||||
<Icon name="player-arrow" />
|
<Icon name="player-arrow" />
|
||||||
</button>
|
</button>
|
||||||
<div ref={(el) => (volumeContainerRef.current = el)} class={styles.volumeContainer}>
|
<div ref={(el) => (volumeContainerRef = el)} class={styles.volumeContainer}>
|
||||||
<Show when={isVolumeBarOpened()}>
|
<Show when={isVolumeBarOpened()}>
|
||||||
<input
|
<input
|
||||||
type="range"
|
type="range"
|
||||||
|
@ -78,7 +74,7 @@ export const PlayerHeader = (props: Props) => {
|
||||||
onChange={({ target }) => props.onVolumeChange(Number(target.value))}
|
onChange={({ target }) => props.onVolumeChange(Number(target.value))}
|
||||||
/>
|
/>
|
||||||
</Show>
|
</Show>
|
||||||
<button onClick={toggleVolumeBar} class={styles.volumeButton} role="button" aria-label="Volume">
|
<button onClick={toggleVolumeBar} class={styles.volumeButton} aria-label="Volume">
|
||||||
<Icon name="volume" />
|
<Icon name="volume" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -1,17 +1,16 @@
|
||||||
import { gtag } from 'ga-gtag'
|
|
||||||
import { For, Show, createSignal, lazy } from 'solid-js'
|
import { For, Show, createSignal, lazy } from 'solid-js'
|
||||||
|
|
||||||
import { useLocalize } from '../../../context/localize'
|
import { Icon } from '~/components/_shared/Icon'
|
||||||
import { MediaItem } from '../../../pages/types'
|
import { Popover } from '~/components/_shared/Popover'
|
||||||
import { getDescription } from '../../../utils/meta'
|
import { useLocalize } from '~/context/localize'
|
||||||
import { Icon } from '../../_shared/Icon'
|
import { MediaItem } from '~/types/mediaitem'
|
||||||
import { Popover } from '../../_shared/Popover'
|
import { descFromBody } from '~/utils/meta'
|
||||||
import { SharePopup, getShareUrl } from '../SharePopup'
|
import { SharePopup, getShareUrl } from '../SharePopup'
|
||||||
|
|
||||||
import styles from './AudioPlayer.module.scss'
|
import styles from './AudioPlayer.module.scss'
|
||||||
|
|
||||||
const SimplifiedEditor = lazy(() => import('../../Editor/SimplifiedEditor'))
|
const MicroEditor = lazy(() => import('../../Editor/MicroEditor'))
|
||||||
const GrowingTextarea = lazy(() => import('../../_shared/GrowingTextarea/GrowingTextarea'))
|
const GrowingTextarea = lazy(() => import('~/components/_shared/GrowingTextarea/GrowingTextarea'))
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
media: MediaItem[]
|
media: MediaItem[]
|
||||||
|
@ -22,29 +21,30 @@ type Props = {
|
||||||
body?: string
|
body?: string
|
||||||
editorMode?: boolean
|
editorMode?: boolean
|
||||||
onMediaItemFieldChange?: (index: number, field: keyof MediaItem, value: string) => void
|
onMediaItemFieldChange?: (index: number, field: keyof MediaItem, value: string) => void
|
||||||
onChangeMediaIndex?: (direction: 'up' | 'down', index) => void
|
onChangeMediaIndex?: (direction: 'up' | 'down', index: number) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
const getMediaTitle = (itm: MediaItem, idx: number) => `${idx}. ${itm.artist} - ${itm.title}`
|
const _getMediaTitle = (itm: MediaItem, idx: number) => `${idx}. ${itm.artist} - ${itm.title}`
|
||||||
|
|
||||||
export const PlayerPlaylist = (props: Props) => {
|
export const PlayerPlaylist = (props: Props) => {
|
||||||
const { t } = useLocalize()
|
const { t } = useLocalize()
|
||||||
const [activeEditIndex, setActiveEditIndex] = createSignal(-1)
|
const [activeEditIndex, setActiveEditIndex] = createSignal(-1)
|
||||||
|
|
||||||
const toggleDropDown = (index) => {
|
const toggleDropDown = (index: number) => {
|
||||||
setActiveEditIndex(activeEditIndex() === index ? -1 : index)
|
setActiveEditIndex(activeEditIndex() === index ? -1 : index)
|
||||||
}
|
}
|
||||||
const handleMediaItemFieldChange = (field: keyof MediaItem, value: string) => {
|
const handleMediaItemFieldChange = (field: keyof MediaItem, value: string) => {
|
||||||
props.onMediaItemFieldChange(activeEditIndex(), field, value)
|
props.onMediaItemFieldChange?.(activeEditIndex(), field, value)
|
||||||
}
|
}
|
||||||
|
|
||||||
const play = (index: number) => {
|
const play = (index: number) => {
|
||||||
const mi = props.media[index]
|
props.onPlayMedia(index)
|
||||||
gtag('event', 'select_item', {
|
//const mi = props.media[index]
|
||||||
item_list_id: props.articleSlug,
|
//gtag('event', 'select_item', {
|
||||||
item_list_name: getMediaTitle(mi, index),
|
//item_list_id: props.articleSlug,
|
||||||
items: props.media.map((it, ix) => getMediaTitle(it, ix)),
|
//item_list_name: getMediaTitle(mi, index),
|
||||||
})
|
//items: props.media.map((it, ix) => getMediaTitle(it, ix)),
|
||||||
|
//})
|
||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
<ul class={styles.playlist}>
|
<ul class={styles.playlist}>
|
||||||
|
@ -89,26 +89,26 @@ export const PlayerPlaylist = (props: Props) => {
|
||||||
<div class={styles.actions}>
|
<div class={styles.actions}>
|
||||||
<Show when={props.editorMode}>
|
<Show when={props.editorMode}>
|
||||||
<Popover content={t('Move up')}>
|
<Popover content={t('Move up')}>
|
||||||
{(triggerRef: (el) => void) => (
|
{(triggerRef: (el: HTMLElement) => void) => (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
ref={triggerRef}
|
ref={triggerRef}
|
||||||
class={styles.action}
|
class={styles.action}
|
||||||
disabled={index() === 0}
|
disabled={index() === 0}
|
||||||
onClick={() => props.onChangeMediaIndex('up', index())}
|
onClick={() => props.onChangeMediaIndex?.('up', index())}
|
||||||
>
|
>
|
||||||
<Icon name="up-button" />
|
<Icon name="up-button" />
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</Popover>
|
</Popover>
|
||||||
<Popover content={t('Move down')}>
|
<Popover content={t('Move down')}>
|
||||||
{(triggerRef: (el) => void) => (
|
{(triggerRef: (el: HTMLElement) => void) => (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
ref={triggerRef}
|
ref={triggerRef}
|
||||||
class={styles.action}
|
class={styles.action}
|
||||||
disabled={index() === props.media.length - 1}
|
disabled={index() === props.media.length - 1}
|
||||||
onClick={() => props.onChangeMediaIndex('down', index())}
|
onClick={() => props.onChangeMediaIndex?.('down', index())}
|
||||||
>
|
>
|
||||||
<Icon name="up-button" class={styles.moveIconDown} />
|
<Icon name="up-button" class={styles.moveIconDown} />
|
||||||
</button>
|
</button>
|
||||||
|
@ -117,7 +117,7 @@ export const PlayerPlaylist = (props: Props) => {
|
||||||
</Show>
|
</Show>
|
||||||
<Show when={(mi.lyrics || mi.body) && !props.editorMode}>
|
<Show when={(mi.lyrics || mi.body) && !props.editorMode}>
|
||||||
<Popover content={t('Show lyrics')}>
|
<Popover content={t('Show lyrics')}>
|
||||||
{(triggerRef: (el) => void) => (
|
{(triggerRef: (el: HTMLElement) => void) => (
|
||||||
<button ref={triggerRef} type="button" onClick={() => toggleDropDown(index())}>
|
<button ref={triggerRef} type="button" onClick={() => toggleDropDown(index())}>
|
||||||
<Icon name="list" />
|
<Icon name="list" />
|
||||||
</button>
|
</button>
|
||||||
|
@ -125,7 +125,7 @@ export const PlayerPlaylist = (props: Props) => {
|
||||||
</Popover>
|
</Popover>
|
||||||
</Show>
|
</Show>
|
||||||
<Popover content={props.editorMode ? t('Edit') : t('Share')}>
|
<Popover content={props.editorMode ? t('Edit') : t('Share')}>
|
||||||
{(triggerRef: (el) => void) => (
|
{(triggerRef: (el: HTMLElement) => void) => (
|
||||||
<div ref={triggerRef}>
|
<div ref={triggerRef}>
|
||||||
<Show
|
<Show
|
||||||
when={!props.editorMode}
|
when={!props.editorMode}
|
||||||
|
@ -137,8 +137,8 @@ export const PlayerPlaylist = (props: Props) => {
|
||||||
>
|
>
|
||||||
<SharePopup
|
<SharePopup
|
||||||
title={mi.title}
|
title={mi.title}
|
||||||
description={getDescription(props.body)}
|
description={descFromBody(props.body || '')}
|
||||||
imageUrl={mi.pic}
|
imageUrl={mi.pic || ''}
|
||||||
shareUrl={getShareUrl({ pathname: `/${props.articleSlug}` })}
|
shareUrl={getShareUrl({ pathname: `/${props.articleSlug}` })}
|
||||||
trigger={
|
trigger={
|
||||||
<div>
|
<div>
|
||||||
|
@ -171,11 +171,10 @@ export const PlayerPlaylist = (props: Props) => {
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<div class={styles.descriptionBlock}>
|
<div class={styles.descriptionBlock}>
|
||||||
<SimplifiedEditor
|
<MicroEditor
|
||||||
initialContent={mi.body}
|
content={mi.body}
|
||||||
placeholder={`${t('Description')}...`}
|
placeholder={`${t('Description')}...`}
|
||||||
smallHeight={true}
|
onChange={(value: string) => handleMediaItemFieldChange('body', value)}
|
||||||
onChange={(value) => handleMediaItemFieldChange('body', value)}
|
|
||||||
/>
|
/>
|
||||||
<GrowingTextarea
|
<GrowingTextarea
|
||||||
allowEnterKey={true}
|
allowEnterKey={true}
|
||||||
|
|
|
@ -1,14 +1,14 @@
|
||||||
.comment {
|
.comment {
|
||||||
|
@include media-breakpoint-down(sm) {
|
||||||
|
padding-right: 0;
|
||||||
|
}
|
||||||
|
|
||||||
margin: 0 0 0.5em;
|
margin: 0 0 0.5em;
|
||||||
padding: 0 1rem;
|
padding: 0 1rem;
|
||||||
transition: background-color 0.3s;
|
transition: background-color 0.3s;
|
||||||
position: relative;
|
position: relative;
|
||||||
list-style: none;
|
list-style: none;
|
||||||
|
|
||||||
@include media-breakpoint-down(sm) {
|
|
||||||
padding-right: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
&.isNew {
|
&.isNew {
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
background: rgb(38 56 217 / 5%);
|
background: rgb(38 56 217 / 5%);
|
||||||
|
@ -179,6 +179,10 @@
|
||||||
@include font-size(1.2rem);
|
@include font-size(1.2rem);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.commentAuthor {
|
||||||
|
margin-right: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
.articleAuthor {
|
.articleAuthor {
|
||||||
@include font-size(1.2rem);
|
@include font-size(1.2rem);
|
||||||
|
|
||||||
|
@ -189,9 +193,6 @@
|
||||||
.articleLink {
|
.articleLink {
|
||||||
@include font-size(1.2rem);
|
@include font-size(1.2rem);
|
||||||
|
|
||||||
flex: 0 0 50%;
|
|
||||||
margin-right: 2em;
|
|
||||||
|
|
||||||
@include media-breakpoint-down(md) {
|
@include media-breakpoint-down(md) {
|
||||||
margin: 0.3em 0 0.5em;
|
margin: 0.3em 0 0.5em;
|
||||||
}
|
}
|
||||||
|
@ -204,20 +205,25 @@
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
flex: 0 0 50%;
|
||||||
|
margin-right: 2em;
|
||||||
}
|
}
|
||||||
|
|
||||||
.articleLinkIcon {
|
.articleLinkIcon {
|
||||||
|
@include media-breakpoint-up(md) {
|
||||||
|
margin-left: 1em;
|
||||||
|
}
|
||||||
|
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
margin-right: 1em;
|
margin-right: 1em;
|
||||||
vertical-align: middle;
|
vertical-align: middle;
|
||||||
width: 1em;
|
width: 1em;
|
||||||
|
|
||||||
@include media-breakpoint-up(md) {
|
|
||||||
margin-left: 1em;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.commentDates {
|
.commentDates {
|
||||||
|
@include font-size(1.2rem);
|
||||||
|
|
||||||
flex: 1;
|
flex: 1;
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 1rem;
|
gap: 1rem;
|
||||||
|
@ -227,8 +233,6 @@
|
||||||
margin: 0 1em 4px 0;
|
margin: 0 1em 4px 0;
|
||||||
color: rgb(0 0 0 / 30%);
|
color: rgb(0 0 0 / 30%);
|
||||||
|
|
||||||
@include font-size(1.2rem);
|
|
||||||
|
|
||||||
.date {
|
.date {
|
||||||
.icon {
|
.icon {
|
||||||
line-height: 1;
|
line-height: 1;
|
||||||
|
@ -242,13 +246,13 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
.commentDetails {
|
.commentDetails {
|
||||||
padding: 1rem 0.2rem 0;
|
|
||||||
margin-bottom: 1.2rem;
|
|
||||||
|
|
||||||
@include media-breakpoint-up(md) {
|
@include media-breakpoint-up(md) {
|
||||||
align-items: center;
|
align-items: center;
|
||||||
display: flex;
|
display: flex;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
padding: 1rem 0.2rem 0;
|
||||||
|
margin-bottom: 1.2rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.compactUserpic {
|
.compactUserpic {
|
||||||
|
|
|
@ -1,24 +1,27 @@
|
||||||
import { getPagePath } from '@nanostores/router'
|
import { A } from '@solidjs/router'
|
||||||
import { clsx } from 'clsx'
|
import { clsx } from 'clsx'
|
||||||
import { For, Show, Suspense, createMemo, createSignal, lazy } from 'solid-js'
|
import { For, Show, Suspense, createMemo, createSignal, lazy } from 'solid-js'
|
||||||
|
import { Icon } from '~/components/_shared/Icon'
|
||||||
import { useConfirm } from '../../../context/confirm'
|
import { ShowIfAuthenticated } from '~/components/_shared/ShowIfAuthenticated'
|
||||||
import { useLocalize } from '../../../context/localize'
|
import { useLocalize } from '~/context/localize'
|
||||||
import { useReactions } from '../../../context/reactions'
|
import { useReactions } from '~/context/reactions'
|
||||||
import { useSession } from '../../../context/session'
|
import { useSession } from '~/context/session'
|
||||||
import { useSnackbar } from '../../../context/snackbar'
|
import { useSnackbar, useUI } from '~/context/ui'
|
||||||
import { Author, Reaction, ReactionKind } from '../../../graphql/schema/core.gen'
|
import deleteReactionMutation from '~/graphql/mutation/core/reaction-destroy'
|
||||||
import { router } from '../../../stores/router'
|
import {
|
||||||
|
Author,
|
||||||
|
MutationCreate_ReactionArgs,
|
||||||
|
MutationUpdate_ReactionArgs,
|
||||||
|
Reaction,
|
||||||
|
ReactionKind
|
||||||
|
} from '~/graphql/schema/core.gen'
|
||||||
import { AuthorLink } from '../../Author/AuthorLink'
|
import { AuthorLink } from '../../Author/AuthorLink'
|
||||||
import { Userpic } from '../../Author/Userpic'
|
import { Userpic } from '../../Author/Userpic'
|
||||||
import { Icon } from '../../_shared/Icon'
|
|
||||||
import { ShowIfAuthenticated } from '../../_shared/ShowIfAuthenticated'
|
|
||||||
import { CommentDate } from '../CommentDate'
|
import { CommentDate } from '../CommentDate'
|
||||||
import { CommentRatingControl } from '../CommentRatingControl'
|
import { CommentRatingControl } from '../CommentRatingControl'
|
||||||
|
|
||||||
import styles from './Comment.module.scss'
|
import styles from './Comment.module.scss'
|
||||||
|
|
||||||
const SimplifiedEditor = lazy(() => import('../../Editor/SimplifiedEditor'))
|
const MiniEditor = lazy(() => import('../../Editor/MiniEditor'))
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
comment: Reaction
|
comment: Reaction
|
||||||
|
@ -30,6 +33,7 @@ type Props = {
|
||||||
showArticleLink?: boolean
|
showArticleLink?: boolean
|
||||||
clickedReply?: (id: number) => void
|
clickedReply?: (id: number) => void
|
||||||
clickedReplyId?: number
|
clickedReplyId?: number
|
||||||
|
onDelete?: (id: number) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
export const Comment = (props: Props) => {
|
export const Comment = (props: Props) => {
|
||||||
|
@ -37,73 +41,93 @@ export const Comment = (props: Props) => {
|
||||||
const [isReplyVisible, setIsReplyVisible] = createSignal(false)
|
const [isReplyVisible, setIsReplyVisible] = createSignal(false)
|
||||||
const [loading, setLoading] = createSignal(false)
|
const [loading, setLoading] = createSignal(false)
|
||||||
const [editMode, setEditMode] = createSignal(false)
|
const [editMode, setEditMode] = createSignal(false)
|
||||||
const [clearEditor, setClearEditor] = createSignal(false)
|
const [editedBody, setEditedBody] = createSignal<string>()
|
||||||
const { author, session } = useSession()
|
const { session, client } = useSession()
|
||||||
const { createReaction, deleteReaction, updateReaction } = useReactions()
|
const author = createMemo<Author>(() => session()?.user?.app_data?.profile as Author)
|
||||||
const { showConfirm } = useConfirm()
|
const { createShoutReaction, updateShoutReaction } = useReactions()
|
||||||
|
const { showConfirm } = useUI()
|
||||||
const { showSnackbar } = useSnackbar()
|
const { showSnackbar } = useSnackbar()
|
||||||
|
|
||||||
const canEdit = createMemo(
|
const canEdit = createMemo(
|
||||||
() =>
|
() =>
|
||||||
Boolean(author()?.id) &&
|
Boolean(author()?.id) &&
|
||||||
(props.comment?.created_by?.id === author().id || session()?.user?.roles.includes('editor')),
|
(props.comment?.created_by?.slug === author()?.slug || session()?.user?.roles?.includes('editor'))
|
||||||
)
|
)
|
||||||
|
|
||||||
const comment = createMemo(() => props.comment)
|
const body = createMemo(() => (editedBody() ? editedBody()?.trim() : props.comment.body?.trim() || ''))
|
||||||
const body = createMemo(() => (comment().body || '').trim())
|
|
||||||
|
|
||||||
const remove = async () => {
|
const remove = async () => {
|
||||||
if (comment()?.id) {
|
if (props.comment?.id) {
|
||||||
try {
|
try {
|
||||||
const isConfirmed = await showConfirm({
|
const isConfirmed = await showConfirm({
|
||||||
confirmBody: t('Are you sure you want to delete this comment?'),
|
confirmBody: t('Are you sure you want to delete this comment?'),
|
||||||
confirmButtonLabel: t('Delete'),
|
confirmButtonLabel: t('Delete'),
|
||||||
confirmButtonVariant: 'danger',
|
confirmButtonVariant: 'danger',
|
||||||
declineButtonVariant: 'primary',
|
declineButtonVariant: 'primary'
|
||||||
})
|
})
|
||||||
|
|
||||||
if (isConfirmed) {
|
if (isConfirmed) {
|
||||||
await deleteReaction(comment().id)
|
const resp = await client()
|
||||||
|
?.mutation(deleteReactionMutation, { id: props.comment.id })
|
||||||
|
.toPromise()
|
||||||
|
const result = resp?.data?.delete_reaction
|
||||||
|
const { error } = result
|
||||||
|
const notificationType = error ? 'error' : 'success'
|
||||||
|
const notificationMessage = error
|
||||||
|
? t('Failed to delete comment')
|
||||||
|
: t('Comment successfully deleted')
|
||||||
|
await showSnackbar({
|
||||||
|
type: notificationType,
|
||||||
|
body: notificationMessage,
|
||||||
|
duration: 3
|
||||||
|
})
|
||||||
|
|
||||||
await showSnackbar({ body: t('Comment successfully deleted') })
|
if (!error && props.onDelete) {
|
||||||
|
props.onDelete(props.comment.id)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
await showSnackbar({ body: 'error' })
|
||||||
console.error('[deleteReaction]', error)
|
console.error('[deleteReaction]', error)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleCreate = async (value) => {
|
const handleCreate = async (value: string) => {
|
||||||
try {
|
try {
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
await createReaction({
|
await createShoutReaction({
|
||||||
kind: ReactionKind.Comment,
|
reaction: {
|
||||||
reply_to: props.comment.id,
|
kind: ReactionKind.Comment,
|
||||||
body: value,
|
reply_to: props.comment.id,
|
||||||
shout: props.comment.shout.id,
|
body: value,
|
||||||
})
|
shout: props.comment.shout.id
|
||||||
setClearEditor(true)
|
}
|
||||||
|
} as MutationCreate_ReactionArgs)
|
||||||
setIsReplyVisible(false)
|
setIsReplyVisible(false)
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('[handleCreate reaction]:', error)
|
console.error('[handleCreate reaction]:', error)
|
||||||
}
|
}
|
||||||
setClearEditor(false)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const toggleEditMode = () => {
|
const toggleEditMode = () => {
|
||||||
setEditMode((oldEditMode) => !oldEditMode)
|
setEditMode((oldEditMode) => !oldEditMode)
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleUpdate = async (value) => {
|
const handleUpdate = async (value: string) => {
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
try {
|
try {
|
||||||
await updateReaction({
|
const reaction = await updateShoutReaction({
|
||||||
id: props.comment.id,
|
reaction: {
|
||||||
kind: ReactionKind.Comment,
|
id: props.comment.id || 0,
|
||||||
body: value,
|
kind: ReactionKind.Comment,
|
||||||
shout: props.comment.shout.id,
|
body: value,
|
||||||
})
|
shout: props.comment.shout.id
|
||||||
|
}
|
||||||
|
} as MutationUpdate_ReactionArgs)
|
||||||
|
if (reaction) {
|
||||||
|
setEditedBody(value)
|
||||||
|
}
|
||||||
setEditMode(false)
|
setEditMode(false)
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
@ -113,8 +137,11 @@ export const Comment = (props: Props) => {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<li
|
<li
|
||||||
id={`comment_${comment().id}`}
|
id={`comment_${props.comment.id}`}
|
||||||
class={clsx(styles.comment, props.class, { [styles.isNew]: comment()?.created_at > props.lastSeen })}
|
class={clsx(styles.comment, props.class, {
|
||||||
|
[styles.isNew]:
|
||||||
|
(props.lastSeen || Date.now()) > (props.comment.updated_at || props.comment.created_at)
|
||||||
|
})}
|
||||||
>
|
>
|
||||||
<Show when={!!body()}>
|
<Show when={!!body()}>
|
||||||
<div class={styles.commentContent}>
|
<div class={styles.commentContent}>
|
||||||
|
@ -123,21 +150,21 @@ export const Comment = (props: Props) => {
|
||||||
fallback={
|
fallback={
|
||||||
<div>
|
<div>
|
||||||
<Userpic
|
<Userpic
|
||||||
name={comment().created_by.name}
|
name={props.comment.created_by.name || ''}
|
||||||
userpic={comment().created_by.pic}
|
userpic={props.comment.created_by.pic || ''}
|
||||||
class={clsx({
|
class={clsx({
|
||||||
[styles.compactUserpic]: props.compact,
|
[styles.compactUserpic]: props.compact
|
||||||
})}
|
})}
|
||||||
/>
|
/>
|
||||||
<small>
|
<small>
|
||||||
<a href={`#comment_${comment()?.id}`}>{comment()?.shout.title || ''}</a>
|
<a href={`#comment_${props.comment?.id}`}>{props.comment?.shout.title || ''}</a>
|
||||||
</small>
|
</small>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<div class={styles.commentDetails}>
|
<div class={styles.commentDetails}>
|
||||||
<div class={styles.commentAuthor}>
|
<div class={styles.commentAuthor}>
|
||||||
<AuthorLink author={comment()?.created_by as Author} />
|
<AuthorLink author={props.comment?.created_by as Author} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Show when={props.isArticleAuthor}>
|
<Show when={props.isArticleAuthor}>
|
||||||
|
@ -147,32 +174,23 @@ export const Comment = (props: Props) => {
|
||||||
<Show when={props.showArticleLink}>
|
<Show when={props.showArticleLink}>
|
||||||
<div class={styles.articleLink}>
|
<div class={styles.articleLink}>
|
||||||
<Icon name="arrow-right" class={styles.articleLinkIcon} />
|
<Icon name="arrow-right" class={styles.articleLinkIcon} />
|
||||||
<a
|
<A href={`${props.comment.shout.slug}?commentId=${props.comment.id}`}>
|
||||||
href={`${getPagePath(router, 'article', { slug: comment().shout.slug })}?commentId=${
|
{props.comment.shout.title}
|
||||||
comment().id
|
</A>
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{comment().shout.title}
|
|
||||||
</a>
|
|
||||||
</div>
|
</div>
|
||||||
</Show>
|
</Show>
|
||||||
<CommentDate showOnHover={true} comment={comment()} isShort={true} />
|
<CommentDate showOnHover={true} comment={props.comment} isShort={true} />
|
||||||
<CommentRatingControl comment={comment()} />
|
<CommentRatingControl comment={props.comment} />
|
||||||
</div>
|
</div>
|
||||||
</Show>
|
</Show>
|
||||||
<div class={styles.commentBody}>
|
<div class={styles.commentBody}>
|
||||||
<Show when={editMode()} fallback={<div innerHTML={body()} />}>
|
<Show when={editMode()} fallback={<div innerHTML={body()} />}>
|
||||||
<Suspense fallback={<p>{t('Loading')}</p>}>
|
<Suspense fallback={<p>{t('Loading')}</p>}>
|
||||||
<SimplifiedEditor
|
<MiniEditor
|
||||||
initialContent={comment().body}
|
content={editedBody() || props.comment.body || ''}
|
||||||
submitButtonText={t('Save')}
|
|
||||||
quoteEnabled={true}
|
|
||||||
imageEnabled={true}
|
|
||||||
placeholder={t('Write a comment...')}
|
placeholder={t('Write a comment...')}
|
||||||
onSubmit={(value) => handleUpdate(value)}
|
onSubmit={(value) => handleUpdate(value)}
|
||||||
submitByCtrlEnter={true}
|
|
||||||
onCancel={() => setEditMode(false)}
|
onCancel={() => setEditMode(false)}
|
||||||
setClear={clearEditor()}
|
|
||||||
/>
|
/>
|
||||||
</Suspense>
|
</Suspense>
|
||||||
</Show>
|
</Show>
|
||||||
|
@ -185,7 +203,7 @@ export const Comment = (props: Props) => {
|
||||||
disabled={loading()}
|
disabled={loading()}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setIsReplyVisible(!isReplyVisible())
|
setIsReplyVisible(!isReplyVisible())
|
||||||
props.clickedReply(props.comment.id)
|
props.clickedReply?.(props.comment.id)
|
||||||
}}
|
}}
|
||||||
class={clsx(styles.commentControl, styles.commentControlReply)}
|
class={clsx(styles.commentControl, styles.commentControlReply)}
|
||||||
>
|
>
|
||||||
|
@ -226,18 +244,15 @@ export const Comment = (props: Props) => {
|
||||||
{/* class={clsx(styles.commentControl, styles.commentControlComplain)}*/}
|
{/* class={clsx(styles.commentControl, styles.commentControlComplain)}*/}
|
||||||
{/* onClick={() => showModal('reportComment')}*/}
|
{/* onClick={() => showModal('reportComment')}*/}
|
||||||
{/*>*/}
|
{/*>*/}
|
||||||
{/* {t('Report')}*/}
|
{/* {t('Complain')}*/}
|
||||||
{/*</button>*/}
|
{/*</button>*/}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Show when={isReplyVisible() && props.clickedReplyId === props.comment.id}>
|
<Show when={isReplyVisible() && props.clickedReplyId === props.comment.id}>
|
||||||
<Suspense fallback={<p>{t('Loading')}</p>}>
|
<Suspense fallback={<p>{t('Loading')}</p>}>
|
||||||
<SimplifiedEditor
|
<MiniEditor
|
||||||
quoteEnabled={true}
|
|
||||||
imageEnabled={true}
|
|
||||||
placeholder={t('Write a comment...')}
|
placeholder={t('Write a comment...')}
|
||||||
onSubmit={(value) => handleCreate(value)}
|
onSubmit={(value) => handleCreate(value)}
|
||||||
submitByCtrlEnter={true}
|
|
||||||
/>
|
/>
|
||||||
</Suspense>
|
</Suspense>
|
||||||
</Show>
|
</Show>
|
||||||
|
@ -246,7 +261,7 @@ export const Comment = (props: Props) => {
|
||||||
</Show>
|
</Show>
|
||||||
<Show when={props.sortedComments}>
|
<Show when={props.sortedComments}>
|
||||||
<ul>
|
<ul>
|
||||||
<For each={props.sortedComments.filter((r) => r.reply_to === props.comment.id)}>
|
<For each={props.sortedComments?.filter((r) => r.reply_to === props.comment.id)}>
|
||||||
{(c) => (
|
{(c) => (
|
||||||
<Comment
|
<Comment
|
||||||
sortedComments={props.sortedComments}
|
sortedComments={props.sortedComments}
|
||||||
|
|
|
@ -2,29 +2,17 @@
|
||||||
@include font-size(1.2rem);
|
@include font-size(1.2rem);
|
||||||
|
|
||||||
color: var(--secondary-color);
|
color: var(--secondary-color);
|
||||||
align-items: center;
|
|
||||||
align-self: center;
|
|
||||||
display: flex;
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
flex-direction: column;
|
||||||
flex: 1;
|
flex: 1;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
font-size: 1.2rem;
|
font-size: 1.2rem;
|
||||||
justify-content: flex-start;
|
|
||||||
margin: 0 1rem;
|
|
||||||
height: 1.6rem;
|
|
||||||
|
|
||||||
.date {
|
.date {
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
margin-right: 1rem;
|
margin-right: 1rem;
|
||||||
position: relative;
|
position: relative;
|
||||||
|
|
||||||
.icon {
|
|
||||||
line-height: 1;
|
|
||||||
width: 1rem;
|
|
||||||
display: inline-block;
|
|
||||||
opacity: 0.6;
|
|
||||||
margin-right: 0.5rem;
|
|
||||||
vertical-align: middle;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
&.showOnHover {
|
&.showOnHover {
|
||||||
|
|
|
@ -1,10 +1,8 @@
|
||||||
import type { Reaction } from '../../../graphql/schema/core.gen'
|
import type { Reaction } from '~/graphql/schema/core.gen'
|
||||||
|
|
||||||
import { clsx } from 'clsx'
|
import { clsx } from 'clsx'
|
||||||
import { Show } from 'solid-js'
|
|
||||||
|
|
||||||
import { useLocalize } from '../../../context/localize'
|
import { useLocalize } from '~/context/localize'
|
||||||
import { Icon } from '../../_shared/Icon'
|
|
||||||
|
|
||||||
import styles from './CommentDate.module.scss'
|
import styles from './CommentDate.module.scss'
|
||||||
|
|
||||||
|
@ -16,7 +14,7 @@ type Props = {
|
||||||
}
|
}
|
||||||
|
|
||||||
export const CommentDate = (props: Props) => {
|
export const CommentDate = (props: Props) => {
|
||||||
const { t, formatDate } = useLocalize()
|
const { formatDate } = useLocalize()
|
||||||
|
|
||||||
const formattedDate = (date: number) => {
|
const formattedDate = (date: number) => {
|
||||||
const formatDateOptions: Intl.DateTimeFormatOptions = props.isShort
|
const formatDateOptions: Intl.DateTimeFormatOptions = props.isShort
|
||||||
|
@ -30,18 +28,10 @@ export const CommentDate = (props: Props) => {
|
||||||
<div
|
<div
|
||||||
class={clsx(styles.commentDates, {
|
class={clsx(styles.commentDates, {
|
||||||
[styles.commentDatesLastInRow]: props.isLastInRow,
|
[styles.commentDatesLastInRow]: props.isLastInRow,
|
||||||
[styles.showOnHover]: props.showOnHover,
|
[styles.showOnHover]: props.showOnHover
|
||||||
})}
|
})}
|
||||||
>
|
>
|
||||||
<time class={styles.date}>{formattedDate(props.comment.created_at * 1000)}</time>
|
<time class={styles.date}>{formattedDate(props.comment.created_at * 1000)}</time>
|
||||||
<Show when={props.comment.updated_at}>
|
|
||||||
<time class={styles.date}>
|
|
||||||
<Icon name="edit" class={styles.icon} />
|
|
||||||
<span class={styles.text}>
|
|
||||||
{t('Edited')} {formattedDate(props.comment.updated_at * 1000)}
|
|
||||||
</span>
|
|
||||||
</time>
|
|
||||||
</Show>
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,12 +1,12 @@
|
||||||
import { clsx } from 'clsx'
|
import { clsx } from 'clsx'
|
||||||
import { createMemo } from 'solid-js'
|
import { createMemo } from 'solid-js'
|
||||||
|
|
||||||
import { useLocalize } from '../../context/localize'
|
import { useFeed } from '~/context/feed'
|
||||||
import { useReactions } from '../../context/reactions'
|
import { useLocalize } from '~/context/localize'
|
||||||
import { useSession } from '../../context/session'
|
import { useReactions } from '~/context/reactions'
|
||||||
import { useSnackbar } from '../../context/snackbar'
|
import { useSession } from '~/context/session'
|
||||||
import { Reaction, ReactionKind } from '../../graphql/schema/core.gen'
|
import { useSnackbar } from '~/context/ui'
|
||||||
import { loadShout } from '../../stores/zine/articles'
|
import { Reaction, ReactionKind } from '~/graphql/schema/core.gen'
|
||||||
import { Popup } from '../_shared/Popup'
|
import { Popup } from '../_shared/Popup'
|
||||||
import { VotersList } from '../_shared/VotersList'
|
import { VotersList } from '../_shared/VotersList'
|
||||||
|
|
||||||
|
@ -18,40 +18,42 @@ type Props = {
|
||||||
|
|
||||||
export const CommentRatingControl = (props: Props) => {
|
export const CommentRatingControl = (props: Props) => {
|
||||||
const { t } = useLocalize()
|
const { t } = useLocalize()
|
||||||
const { author } = useSession()
|
const { loadShout } = useFeed()
|
||||||
|
const { session } = useSession()
|
||||||
|
const uid = createMemo<number>(() => session()?.user?.app_data?.profile?.id || 0)
|
||||||
const { showSnackbar } = useSnackbar()
|
const { showSnackbar } = useSnackbar()
|
||||||
const { reactionEntities, createReaction, deleteReaction, loadReactionsBy } = useReactions()
|
const { reactionEntities, createShoutReaction, deleteShoutReaction, loadReactionsBy } = useReactions()
|
||||||
|
|
||||||
const checkReaction = (reactionKind: ReactionKind) =>
|
const checkReaction = (reactionKind: ReactionKind) =>
|
||||||
Object.values(reactionEntities).some(
|
Object.values(reactionEntities).some(
|
||||||
(r) =>
|
(r) =>
|
||||||
r.kind === reactionKind &&
|
r.kind === reactionKind &&
|
||||||
r.created_by.slug === author()?.slug &&
|
r.created_by.id === uid() &&
|
||||||
r.shout.id === props.comment.shout.id &&
|
r.shout.id === props.comment.shout.id &&
|
||||||
r.reply_to === props.comment.id,
|
r.reply_to === props.comment.id
|
||||||
)
|
)
|
||||||
const isUpvoted = createMemo(() => checkReaction(ReactionKind.Like))
|
const isUpvoted = createMemo(() => checkReaction(ReactionKind.Like))
|
||||||
const isDownvoted = createMemo(() => checkReaction(ReactionKind.Dislike))
|
const isDownvoted = createMemo(() => checkReaction(ReactionKind.Dislike))
|
||||||
const canVote = createMemo(() => author()?.slug !== props.comment.created_by.slug)
|
const canVote = createMemo(() => uid() !== props.comment.created_by.id)
|
||||||
|
|
||||||
const commentRatingReactions = createMemo(() =>
|
const commentRatingReactions = createMemo(() =>
|
||||||
Object.values(reactionEntities).filter(
|
Object.values(reactionEntities).filter(
|
||||||
(r) =>
|
(r) =>
|
||||||
[ReactionKind.Like, ReactionKind.Dislike].includes(r.kind) &&
|
[ReactionKind.Like, ReactionKind.Dislike].includes(r.kind) &&
|
||||||
r.shout.id === props.comment.shout.id &&
|
r.shout.id === props.comment.shout.id &&
|
||||||
r.reply_to === props.comment.id,
|
r.reply_to === props.comment.id
|
||||||
),
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
const deleteCommentReaction = async (reactionKind: ReactionKind) => {
|
const deleteCommentReaction = async (reactionKind: ReactionKind) => {
|
||||||
const reactionToDelete = Object.values(reactionEntities).find(
|
const reactionToDelete = Object.values(reactionEntities).find(
|
||||||
(r) =>
|
(r) =>
|
||||||
r.kind === reactionKind &&
|
r.kind === reactionKind &&
|
||||||
r.created_by.slug === author()?.slug &&
|
r.created_by.id === uid() &&
|
||||||
r.shout.id === props.comment.shout.id &&
|
r.shout.id === props.comment.shout.id &&
|
||||||
r.reply_to === props.comment.id,
|
r.reply_to === props.comment.id
|
||||||
)
|
)
|
||||||
return deleteReaction(reactionToDelete.id)
|
if (reactionToDelete) return deleteShoutReaction(reactionToDelete.id)
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleRatingChange = async (isUpvote: boolean) => {
|
const handleRatingChange = async (isUpvote: boolean) => {
|
||||||
|
@ -61,10 +63,12 @@ export const CommentRatingControl = (props: Props) => {
|
||||||
} else if (isDownvoted()) {
|
} else if (isDownvoted()) {
|
||||||
await deleteCommentReaction(ReactionKind.Dislike)
|
await deleteCommentReaction(ReactionKind.Dislike)
|
||||||
} else {
|
} else {
|
||||||
await createReaction({
|
await createShoutReaction({
|
||||||
kind: isUpvote ? ReactionKind.Like : ReactionKind.Dislike,
|
reaction: {
|
||||||
shout: props.comment.shout.id,
|
kind: isUpvote ? ReactionKind.Like : ReactionKind.Dislike,
|
||||||
reply_to: props.comment.id,
|
shout: props.comment.shout.id,
|
||||||
|
reply_to: props.comment.id
|
||||||
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
|
@ -73,29 +77,28 @@ export const CommentRatingControl = (props: Props) => {
|
||||||
|
|
||||||
await loadShout(props.comment.shout.slug)
|
await loadShout(props.comment.shout.slug)
|
||||||
await loadReactionsBy({
|
await loadReactionsBy({
|
||||||
by: { shout: props.comment.shout.slug },
|
by: { shout: props.comment.shout.slug }
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div class={styles.commentRating}>
|
<div class={styles.commentRating}>
|
||||||
<button
|
<button
|
||||||
role="button"
|
disabled={!(canVote() && uid())}
|
||||||
disabled={!(canVote() && author())}
|
|
||||||
onClick={() => handleRatingChange(true)}
|
onClick={() => handleRatingChange(true)}
|
||||||
class={clsx(styles.commentRatingControl, styles.commentRatingControlUp, {
|
class={clsx(styles.commentRatingControl, styles.commentRatingControlUp, {
|
||||||
[styles.voted]: isUpvoted(),
|
[styles.voted]: isUpvoted()
|
||||||
})}
|
})}
|
||||||
/>
|
/>
|
||||||
<Popup
|
<Popup
|
||||||
trigger={
|
trigger={
|
||||||
<div
|
<div
|
||||||
class={clsx(styles.commentRatingValue, {
|
class={clsx(styles.commentRatingValue, {
|
||||||
[styles.commentRatingPositive]: props.comment.stat.rating > 0,
|
[styles.commentRatingPositive]: (props.comment?.stat?.rating || 0) > 0,
|
||||||
[styles.commentRatingNegative]: props.comment.stat.rating < 0,
|
[styles.commentRatingNegative]: (props.comment?.stat?.rating || 0) < 0
|
||||||
})}
|
})}
|
||||||
>
|
>
|
||||||
{props.comment.stat.rating || 0}
|
{props.comment?.stat?.rating || 0}
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
variant="tiny"
|
variant="tiny"
|
||||||
|
@ -106,11 +109,10 @@ export const CommentRatingControl = (props: Props) => {
|
||||||
/>
|
/>
|
||||||
</Popup>
|
</Popup>
|
||||||
<button
|
<button
|
||||||
role="button"
|
disabled={!(canVote() && uid())}
|
||||||
disabled={!(canVote() && author())}
|
|
||||||
onClick={() => handleRatingChange(false)}
|
onClick={() => handleRatingChange(false)}
|
||||||
class={clsx(styles.commentRatingControl, styles.commentRatingControlDown, {
|
class={clsx(styles.commentRatingControl, styles.commentRatingControlDown, {
|
||||||
[styles.voted]: isDownvoted(),
|
[styles.voted]: isDownvoted()
|
||||||
})}
|
})}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -1,40 +1,20 @@
|
||||||
import { clsx } from 'clsx'
|
import { clsx } from 'clsx'
|
||||||
import { For, Show, createMemo, createSignal, lazy, onMount } from 'solid-js'
|
import { For, Show, createMemo, createSignal, lazy, onMount } from 'solid-js'
|
||||||
|
|
||||||
import { useLocalize } from '../../context/localize'
|
import { useFeed } from '~/context/feed'
|
||||||
import { useReactions } from '../../context/reactions'
|
import { useLocalize } from '~/context/localize'
|
||||||
import { useSession } from '../../context/session'
|
import { useReactions } from '~/context/reactions'
|
||||||
import { Author, Reaction, ReactionKind } from '../../graphql/schema/core.gen'
|
import { useSession } from '~/context/session'
|
||||||
import { byCreated } from '../../utils/sortby'
|
import { Author, Reaction, ReactionKind, ReactionSort } from '~/graphql/schema/core.gen'
|
||||||
|
import { SortFunction } from '~/types/common'
|
||||||
|
import { byCreated, byStat } from '~/utils/sort'
|
||||||
import { Button } from '../_shared/Button'
|
import { Button } from '../_shared/Button'
|
||||||
|
import { Loading } from '../_shared/Loading'
|
||||||
import { ShowIfAuthenticated } from '../_shared/ShowIfAuthenticated'
|
import { ShowIfAuthenticated } from '../_shared/ShowIfAuthenticated'
|
||||||
|
import styles from './Article.module.scss'
|
||||||
import { Comment } from './Comment'
|
import { Comment } from './Comment'
|
||||||
|
|
||||||
import styles from './Article.module.scss'
|
const MiniEditor = lazy(() => import('../Editor/MiniEditor'))
|
||||||
|
|
||||||
const SimplifiedEditor = lazy(() => import('../Editor/SimplifiedEditor'))
|
|
||||||
|
|
||||||
type CommentsOrder = 'createdAt' | 'rating' | 'newOnly'
|
|
||||||
|
|
||||||
const sortCommentsByRating = (a: Reaction, b: Reaction): -1 | 0 | 1 => {
|
|
||||||
if (a.reply_to && b.reply_to) {
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
|
|
||||||
const x = a.stat?.rating || 0
|
|
||||||
const y = b.stat?.rating || 0
|
|
||||||
|
|
||||||
if (x > y) {
|
|
||||||
return 1
|
|
||||||
}
|
|
||||||
|
|
||||||
if (x < y) {
|
|
||||||
return -1
|
|
||||||
}
|
|
||||||
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
articleAuthors: Author[]
|
articleAuthors: Author[]
|
||||||
|
@ -43,63 +23,70 @@ type Props = {
|
||||||
}
|
}
|
||||||
|
|
||||||
export const CommentsTree = (props: Props) => {
|
export const CommentsTree = (props: Props) => {
|
||||||
const { author } = useSession()
|
const { session } = useSession()
|
||||||
const { t } = useLocalize()
|
const { t } = useLocalize()
|
||||||
const [commentsOrder, setCommentsOrder] = createSignal<CommentsOrder>('createdAt')
|
const [commentsOrder, setCommentsOrder] = createSignal<ReactionSort>(ReactionSort.Newest)
|
||||||
|
const [onlyNew, setOnlyNew] = createSignal(false)
|
||||||
const [newReactions, setNewReactions] = createSignal<Reaction[]>([])
|
const [newReactions, setNewReactions] = createSignal<Reaction[]>([])
|
||||||
const [clearEditor, setClearEditor] = createSignal(false)
|
|
||||||
const [clickedReplyId, setClickedReplyId] = createSignal<number>()
|
const [clickedReplyId, setClickedReplyId] = createSignal<number>()
|
||||||
const { reactionEntities, createReaction } = useReactions()
|
const { reactionEntities, createShoutReaction, loadReactionsBy } = useReactions()
|
||||||
|
|
||||||
const comments = createMemo(() =>
|
const comments = createMemo(() =>
|
||||||
Object.values(reactionEntities).filter((reaction) => reaction.kind === 'COMMENT'),
|
Object.values(reactionEntities()).filter((reaction) => reaction.kind === 'COMMENT')
|
||||||
)
|
)
|
||||||
|
|
||||||
const sortedComments = createMemo(() => {
|
const sortedComments = createMemo(() => {
|
||||||
let newSortedComments = [...comments()]
|
let newSortedComments = [...comments()]
|
||||||
newSortedComments = newSortedComments.sort(byCreated)
|
newSortedComments = newSortedComments.sort(byCreated)
|
||||||
|
|
||||||
if (commentsOrder() === 'newOnly') {
|
if (onlyNew()) {
|
||||||
return newReactions().reverse()
|
return newReactions().sort(byCreated).reverse()
|
||||||
}
|
}
|
||||||
|
|
||||||
if (commentsOrder() === 'rating') {
|
if (commentsOrder() === ReactionSort.Like) {
|
||||||
newSortedComments = newSortedComments.sort(sortCommentsByRating)
|
newSortedComments = newSortedComments.sort(byStat('rating') as SortFunction<Reaction>)
|
||||||
}
|
}
|
||||||
return newSortedComments
|
return newSortedComments
|
||||||
})
|
})
|
||||||
|
const { seen } = useFeed()
|
||||||
const dateFromLocalStorage = Number.parseInt(localStorage.getItem(`${props.shoutSlug}`))
|
const shoutLastSeen = createMemo(() => seen()[props.shoutSlug] ?? 0)
|
||||||
const currentDate = new Date()
|
|
||||||
const setCookie = () => localStorage.setItem(`${props.shoutSlug}`, `${currentDate}`)
|
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
if (!dateFromLocalStorage) {
|
const currentDate = new Date()
|
||||||
|
const setCookie = () => localStorage?.setItem(`${props.shoutSlug}`, `${currentDate}`)
|
||||||
|
if (!shoutLastSeen()) {
|
||||||
setCookie()
|
setCookie()
|
||||||
} else if (currentDate.getTime() > dateFromLocalStorage) {
|
} else if (currentDate.getTime() > shoutLastSeen()) {
|
||||||
const newComments = comments().filter((c) => {
|
const newComments = comments().filter((c) => {
|
||||||
if (c.reply_to || c.created_by.slug === author()?.slug) {
|
if (
|
||||||
|
(session()?.user?.app_data?.profile?.id && c.reply_to) ||
|
||||||
|
c.created_by.id === session()?.user?.app_data?.profile?.id
|
||||||
|
) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
const created = c.created_at
|
return (c.updated_at || c.created_at) > shoutLastSeen()
|
||||||
return created > dateFromLocalStorage
|
|
||||||
})
|
})
|
||||||
setNewReactions(newComments)
|
setNewReactions(newComments)
|
||||||
setCookie()
|
setCookie()
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
const handleSubmitComment = async (value) => {
|
|
||||||
|
const [posting, setPosting] = createSignal(false)
|
||||||
|
const handleSubmitComment = async (value: string) => {
|
||||||
|
setPosting(true)
|
||||||
try {
|
try {
|
||||||
await createReaction({
|
await createShoutReaction({
|
||||||
kind: ReactionKind.Comment,
|
reaction: {
|
||||||
body: value,
|
kind: ReactionKind.Comment,
|
||||||
shout: props.shoutId,
|
body: value,
|
||||||
|
shout: props.shoutId
|
||||||
|
}
|
||||||
})
|
})
|
||||||
setClearEditor(true)
|
await loadReactionsBy({ by: { shout: props.shoutSlug } })
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('[handleCreate reaction]:', error)
|
console.error('[handleCreate reaction]:', error)
|
||||||
}
|
}
|
||||||
setClearEditor(false)
|
setPosting(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
@ -108,37 +95,31 @@ export const CommentsTree = (props: Props) => {
|
||||||
<h2 class={styles.commentsHeader}>
|
<h2 class={styles.commentsHeader}>
|
||||||
{t('Comments')} {comments().length.toString() || ''}
|
{t('Comments')} {comments().length.toString() || ''}
|
||||||
<Show when={newReactions().length > 0}>
|
<Show when={newReactions().length > 0}>
|
||||||
<span class={styles.newReactions}> +{newReactions().length}</span>
|
<span class={styles.newReactions}>{` +${newReactions().length}`}</span>
|
||||||
</Show>
|
</Show>
|
||||||
</h2>
|
</h2>
|
||||||
<Show when={comments().length > 0}>
|
<Show when={comments().length > 0}>
|
||||||
<ul class={clsx(styles.commentsViewSwitcher, 'view-switcher')}>
|
<ul class={clsx(styles.commentsViewSwitcher, 'view-switcher')}>
|
||||||
<Show when={newReactions().length > 0}>
|
<Show when={newReactions().length > 0}>
|
||||||
<li classList={{ 'view-switcher__item--selected': commentsOrder() === 'newOnly' }}>
|
<li classList={{ 'view-switcher__item--selected': onlyNew() }}>
|
||||||
<Button
|
<Button variant="light" value={t('New only')} onClick={() => setOnlyNew(!onlyNew())} />
|
||||||
variant="light"
|
|
||||||
value={t('New only')}
|
|
||||||
onClick={() => {
|
|
||||||
setCommentsOrder('newOnly')
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</li>
|
</li>
|
||||||
</Show>
|
</Show>
|
||||||
<li classList={{ 'view-switcher__item--selected': commentsOrder() === 'createdAt' }}>
|
<li classList={{ 'view-switcher__item--selected': commentsOrder() === ReactionSort.Newest }}>
|
||||||
<Button
|
<Button
|
||||||
variant="light"
|
variant="light"
|
||||||
value={t('By time')}
|
value={t('By time')}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setCommentsOrder('createdAt')
|
setCommentsOrder(ReactionSort.Newest)
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</li>
|
</li>
|
||||||
<li classList={{ 'view-switcher__item--selected': commentsOrder() === 'rating' }}>
|
<li classList={{ 'view-switcher__item--selected': commentsOrder() === ReactionSort.Like }}>
|
||||||
<Button
|
<Button
|
||||||
variant="light"
|
variant="light"
|
||||||
value={t('By rating')}
|
value={t('By rating')}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setCommentsOrder('rating')
|
setCommentsOrder(ReactionSort.Like)
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</li>
|
</li>
|
||||||
|
@ -150,13 +131,11 @@ export const CommentsTree = (props: Props) => {
|
||||||
{(reaction) => (
|
{(reaction) => (
|
||||||
<Comment
|
<Comment
|
||||||
sortedComments={sortedComments()}
|
sortedComments={sortedComments()}
|
||||||
isArticleAuthor={Boolean(
|
isArticleAuthor={Boolean(props.articleAuthors.some((a) => a?.id === reaction.created_by.id))}
|
||||||
props.articleAuthors.some((a) => a?.slug === reaction.created_by.slug),
|
|
||||||
)}
|
|
||||||
comment={reaction}
|
comment={reaction}
|
||||||
clickedReply={(id) => setClickedReplyId(id)}
|
clickedReply={(id) => setClickedReplyId(id)}
|
||||||
clickedReplyId={clickedReplyId()}
|
clickedReplyId={clickedReplyId()}
|
||||||
lastSeen={dateFromLocalStorage}
|
lastSeen={shoutLastSeen()}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</For>
|
</For>
|
||||||
|
@ -168,22 +147,17 @@ export const CommentsTree = (props: Props) => {
|
||||||
<a href="?m=auth&mode=register" class={styles.link}>
|
<a href="?m=auth&mode=register" class={styles.link}>
|
||||||
{t('sign up')}
|
{t('sign up')}
|
||||||
</a>{' '}
|
</a>{' '}
|
||||||
{t('or')}
|
{t('or')}{' '}
|
||||||
<a href="?m=auth&mode=login" class={styles.link}>
|
<a href="?m=auth&mode=login" class={styles.link}>
|
||||||
{t('sign in')}
|
{t('sign in')}
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<SimplifiedEditor
|
<MiniEditor placeholder={t('Write a comment...')} onSubmit={handleSubmitComment} />
|
||||||
quoteEnabled={true}
|
<Show when={posting()}>
|
||||||
imageEnabled={true}
|
<Loading />
|
||||||
autoFocus={false}
|
</Show>
|
||||||
submitByCtrlEnter={true}
|
|
||||||
placeholder={t('Write a comment...')}
|
|
||||||
onSubmit={(value) => handleSubmitComment(value)}
|
|
||||||
setClear={clearEditor()}
|
|
||||||
/>
|
|
||||||
</ShowIfAuthenticated>
|
</ShowIfAuthenticated>
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
|
|
|
@ -26,7 +26,7 @@ const coverImages = [
|
||||||
CoverImage9,
|
CoverImage9,
|
||||||
CoverImage10,
|
CoverImage10,
|
||||||
CoverImage11,
|
CoverImage11,
|
||||||
CoverImage12,
|
CoverImage12
|
||||||
]
|
]
|
||||||
|
|
||||||
let counter = 0
|
let counter = 0
|
||||||
|
|
|
@ -1,49 +1,44 @@
|
||||||
import type { Author, Shout, Topic } from '../../graphql/schema/core.gen'
|
import { AuthToken } from '@authorizerdev/authorizer-js'
|
||||||
|
|
||||||
import { getPagePath } from '@nanostores/router'
|
|
||||||
import { createPopper } from '@popperjs/core'
|
import { createPopper } from '@popperjs/core'
|
||||||
import { Link, Meta } from '@solidjs/meta'
|
import { Link } from '@solidjs/meta'
|
||||||
|
import { A, useSearchParams } from '@solidjs/router'
|
||||||
import { clsx } from 'clsx'
|
import { clsx } from 'clsx'
|
||||||
import { install } from 'ga-gtag'
|
|
||||||
import { For, Show, createEffect, createMemo, createSignal, on, onCleanup, onMount } from 'solid-js'
|
import { For, Show, createEffect, createMemo, createSignal, on, onCleanup, onMount } from 'solid-js'
|
||||||
import { isServer } from 'solid-js/web'
|
import { isServer } from 'solid-js/web'
|
||||||
|
import { useFeed } from '~/context/feed'
|
||||||
import { useLocalize } from '../../context/localize'
|
import { useLocalize } from '~/context/localize'
|
||||||
import { useReactions } from '../../context/reactions'
|
import { useReactions } from '~/context/reactions'
|
||||||
import { useSession } from '../../context/session'
|
import { useSession } from '~/context/session'
|
||||||
import { MediaItem } from '../../pages/types'
|
import { DEFAULT_HEADER_OFFSET, useUI } from '~/context/ui'
|
||||||
import { DEFAULT_HEADER_OFFSET, router, useRouter } from '../../stores/router'
|
import type { Author, Maybe, Shout, Topic } from '~/graphql/schema/core.gen'
|
||||||
import { showModal } from '../../stores/ui'
|
import { processPrepositions } from '~/intl/prepositions'
|
||||||
import { capitalize } from '../../utils/capitalize'
|
import { isCyrillic } from '~/intl/translate'
|
||||||
import { getImageUrl, getOpenGraphImageUrl } from '../../utils/getImageUrl'
|
import { getImageUrl } from '~/lib/getThumbUrl'
|
||||||
import { getDescription, getKeywords } from '../../utils/meta'
|
import { MediaItem } from '~/types/mediaitem'
|
||||||
import { isCyrillic } from '../../utils/translate'
|
import { capitalize } from '~/utils/capitalize'
|
||||||
import { AuthorBadge } from '../Author/AuthorBadge'
|
import { AuthorBadge } from '../Author/AuthorBadge'
|
||||||
import { CardTopic } from '../Feed/CardTopic'
|
import { CardTopic } from '../Feed/CardTopic'
|
||||||
import { FeedArticlePopup } from '../Feed/FeedArticlePopup'
|
import { FeedArticlePopup } from '../Feed/FeedArticlePopup'
|
||||||
import { Modal } from '../Nav/Modal'
|
import stylesHeader from '../HeaderNav/Header.module.scss'
|
||||||
import { TableOfContents } from '../TableOfContents'
|
|
||||||
import { Icon } from '../_shared/Icon'
|
import { Icon } from '../_shared/Icon'
|
||||||
import { Image } from '../_shared/Image'
|
import { Image } from '../_shared/Image'
|
||||||
import { InviteMembers } from '../_shared/InviteMembers'
|
import { InviteMembers } from '../_shared/InviteMembers'
|
||||||
import { Lightbox } from '../_shared/Lightbox'
|
import { Lightbox } from '../_shared/Lightbox'
|
||||||
|
import { Modal } from '../_shared/Modal'
|
||||||
import { Popover } from '../_shared/Popover'
|
import { Popover } from '../_shared/Popover'
|
||||||
import { ShareModal } from '../_shared/ShareModal'
|
import { ShareModal } from '../_shared/ShareModal'
|
||||||
import { ImageSwiper } from '../_shared/SolidSwiper'
|
import { ImageSwiper } from '../_shared/SolidSwiper'
|
||||||
|
import { TableOfContents } from '../_shared/TableOfContents'
|
||||||
import { VideoPlayer } from '../_shared/VideoPlayer'
|
import { VideoPlayer } from '../_shared/VideoPlayer'
|
||||||
|
import styles from './Article.module.scss'
|
||||||
import { AudioHeader } from './AudioHeader'
|
import { AudioHeader } from './AudioHeader'
|
||||||
import { AudioPlayer } from './AudioPlayer'
|
import { AudioPlayer } from './AudioPlayer'
|
||||||
import { CommentsTree } from './CommentsTree'
|
import { CommentsTree } from './CommentsTree'
|
||||||
import { SharePopup, getShareUrl } from './SharePopup'
|
import { SharePopup, getShareUrl } from './SharePopup'
|
||||||
import { ShoutRatingControl } from './ShoutRatingControl'
|
import { ShoutRatingControl } from './ShoutRatingControl'
|
||||||
|
|
||||||
import stylesHeader from '../Nav/Header/Header.module.scss'
|
|
||||||
import styles from './Article.module.scss'
|
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
article: Shout
|
article: Shout
|
||||||
scrollToComments?: boolean
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type IframeSize = {
|
type IframeSize = {
|
||||||
|
@ -52,71 +47,105 @@ type IframeSize = {
|
||||||
}
|
}
|
||||||
|
|
||||||
export type ArticlePageSearchParams = {
|
export type ArticlePageSearchParams = {
|
||||||
scrollTo: 'comments'
|
commentId?: string
|
||||||
commentId: string
|
slide?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
const scrollTo = (el: HTMLElement) => {
|
const scrollTo = (el: HTMLElement) => {
|
||||||
const { top } = el.getBoundingClientRect()
|
const { top } = el.getBoundingClientRect()
|
||||||
window.scrollTo({
|
|
||||||
top: top - DEFAULT_HEADER_OFFSET,
|
window?.scrollTo({
|
||||||
|
top: top + window.scrollY - DEFAULT_HEADER_OFFSET,
|
||||||
left: 0,
|
left: 0,
|
||||||
behavior: 'smooth',
|
behavior: 'smooth'
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const imgSrcRegExp = /<img[^>]+src\s*=\s*["']([^"']+)["']/gi
|
const imgSrcRegExp = /<img[^>]+src\s*=\s*["']([^"']+)["']/gi
|
||||||
|
export const COMMENTS_PER_PAGE = 30
|
||||||
|
const VOTES_PER_PAGE = 50
|
||||||
|
|
||||||
export const FullArticle = (props: Props) => {
|
export const FullArticle = (props: Props) => {
|
||||||
const { searchParams, changeSearchParams } = useRouter<ArticlePageSearchParams>()
|
const [searchParams] = useSearchParams<ArticlePageSearchParams>()
|
||||||
|
const { showModal } = useUI()
|
||||||
const { loadReactionsBy } = useReactions()
|
const { loadReactionsBy } = useReactions()
|
||||||
const [selectedImage, setSelectedImage] = createSignal('')
|
const [selectedImage, setSelectedImage] = createSignal('')
|
||||||
const [isReactionsLoaded, setIsReactionsLoaded] = createSignal(false)
|
const [isReactionsLoaded, setIsReactionsLoaded] = createSignal(false)
|
||||||
const [isActionPopupActive, setIsActionPopupActive] = createSignal(false)
|
const [isActionPopupActive, setIsActionPopupActive] = createSignal(false)
|
||||||
const { t, formatDate, lang } = useLocalize()
|
const { t, formatDate, lang } = useLocalize()
|
||||||
const { author, session, isAuthenticated, requireAuthentication } = useSession()
|
const { session, requireAuthentication } = useSession()
|
||||||
|
const { addSeen } = useFeed()
|
||||||
|
const formattedDate = createMemo(() => formatDate(new Date((props.article.published_at || 0) * 1000)))
|
||||||
|
|
||||||
const formattedDate = createMemo(() => formatDate(new Date(props.article.published_at * 1000)))
|
const [pages, setPages] = createSignal<Record<string, number>>({})
|
||||||
|
createEffect(
|
||||||
|
on(
|
||||||
|
pages,
|
||||||
|
(p: Record<string, number>) => {
|
||||||
|
console.debug('content paginated')
|
||||||
|
loadReactionsBy({
|
||||||
|
by: { shout: props.article.slug, comment: true },
|
||||||
|
limit: COMMENTS_PER_PAGE,
|
||||||
|
offset: COMMENTS_PER_PAGE * p.comments || 0
|
||||||
|
})
|
||||||
|
loadReactionsBy({
|
||||||
|
by: { shout: props.article.slug, rating: true },
|
||||||
|
limit: VOTES_PER_PAGE,
|
||||||
|
offset: VOTES_PER_PAGE * p.rating || 0
|
||||||
|
})
|
||||||
|
setIsReactionsLoaded(true)
|
||||||
|
console.debug('reactions paginated')
|
||||||
|
},
|
||||||
|
{ defer: true }
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
const canEdit = createMemo(
|
const [canEdit, setCanEdit] = createSignal<boolean>(false)
|
||||||
() =>
|
createEffect(
|
||||||
Boolean(author()?.id) &&
|
on(
|
||||||
(props.article?.authors?.some((a) => Boolean(a) && a?.id === author().id) ||
|
() => session(),
|
||||||
props.article?.created_by?.id === author().id ||
|
(s?: AuthToken) => {
|
||||||
session()?.user?.roles.includes('editor')),
|
const profile = s?.user?.app_data?.profile
|
||||||
|
if (!profile) return
|
||||||
|
const isEditor = s?.user?.roles?.includes('editor')
|
||||||
|
const isCreator = props.article.created_by?.id === profile.id
|
||||||
|
const fit = (a: Maybe<Author>) => a?.id === profile.id || isCreator || isEditor
|
||||||
|
setCanEdit((_: boolean) => Boolean(props.article.authors?.some(fit)))
|
||||||
|
}
|
||||||
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
const mainTopic = createMemo(() => {
|
const mainTopic = createMemo(() => {
|
||||||
const mainTopicSlug = props.article.topics.length > 0 ? props.article.main_topic : null
|
const mainTopicSlug = (props.article.topics?.length || 0) > 0 ? props.article.main_topic : null
|
||||||
const mt = props.article.topics.find((tpc: Topic) => tpc.slug === mainTopicSlug)
|
const mt = props.article.topics?.find((tpc: Maybe<Topic>) => tpc?.slug === mainTopicSlug)
|
||||||
if (mt) {
|
if (mt) {
|
||||||
mt.title = lang() === 'en' ? capitalize(mt.slug.replace(/-/, ' ')) : mt.title
|
mt.title = lang() === 'en' ? capitalize(mt.slug.replaceAll('-', ' ')) : mt.title
|
||||||
return mt
|
return mt
|
||||||
}
|
}
|
||||||
return props.article.topics[0]
|
return props.article.topics?.[0]
|
||||||
})
|
})
|
||||||
|
|
||||||
const handleBookmarkButtonClick = (ev) => {
|
const handleBookmarkButtonClick = (ev: MouseEvent | undefined) => {
|
||||||
requireAuthentication(() => {
|
requireAuthentication(() => {
|
||||||
// TODO: implement bookmark clicked
|
// TODO: implement bookmark clicked
|
||||||
ev.preventDefault()
|
ev?.preventDefault()
|
||||||
}, 'bookmark')
|
}, 'bookmark')
|
||||||
}
|
}
|
||||||
|
|
||||||
const body = createMemo(() => {
|
const body = createMemo(() => {
|
||||||
if (props.article.layout === 'literature') {
|
if (props.article.layout === 'literature') {
|
||||||
try {
|
try {
|
||||||
if (props.article?.media) {
|
if (props.article.media) {
|
||||||
const media = JSON.parse(props.article.media)
|
const media = JSON.parse(props.article.media)
|
||||||
if (media.length > 0) {
|
if (media.length > 0) {
|
||||||
return media[0].body
|
return processPrepositions(media[0].body)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(error)
|
console.error(error)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return props.article.body
|
return processPrepositions(props.article.body) || ''
|
||||||
})
|
})
|
||||||
|
|
||||||
const imageUrls = createMemo(() => {
|
const imageUrls = createMemo(() => {
|
||||||
|
@ -126,10 +155,11 @@ export const FullArticle = (props: Props) => {
|
||||||
|
|
||||||
if (isServer) {
|
if (isServer) {
|
||||||
const result: string[] = []
|
const result: string[] = []
|
||||||
let match: RegExpMatchArray
|
let match: RegExpMatchArray | null
|
||||||
|
|
||||||
while ((match = imgSrcRegExp.exec(body())) !== null) {
|
while ((match = imgSrcRegExp.exec(body())) !== null) {
|
||||||
result.push(match[1])
|
if (match) result.push(match[1])
|
||||||
|
else break
|
||||||
}
|
}
|
||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
@ -139,45 +169,25 @@ export const FullArticle = (props: Props) => {
|
||||||
return Array.from(imageElements).map((img) => img.src)
|
return Array.from(imageElements).map((img) => img.src)
|
||||||
})
|
})
|
||||||
|
|
||||||
const media = createMemo<MediaItem[]>(() => {
|
const media = createMemo<MediaItem[]>(() => JSON.parse(props.article.media || '[]'))
|
||||||
try {
|
|
||||||
return JSON.parse(props.article.media)
|
|
||||||
} catch {
|
|
||||||
return []
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
const commentsRef: {
|
|
||||||
current: HTMLDivElement
|
|
||||||
} = { current: null }
|
|
||||||
|
|
||||||
|
let commentsRef: HTMLDivElement | undefined
|
||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
if (props.scrollToComments) {
|
if (searchParams?.commentId && isReactionsLoaded()) {
|
||||||
scrollTo(commentsRef.current)
|
console.debug('comment id is in link, scroll to')
|
||||||
}
|
const scrollToElement =
|
||||||
})
|
document.querySelector<HTMLElement>(`[id='comment_${searchParams?.commentId}']`) ||
|
||||||
|
commentsRef ||
|
||||||
|
document.body
|
||||||
|
|
||||||
createEffect(() => {
|
if (scrollToElement) {
|
||||||
if (searchParams()?.scrollTo === 'comments' && commentsRef.current) {
|
requestAnimationFrame(() => scrollTo(scrollToElement))
|
||||||
requestAnimationFrame(() => scrollTo(commentsRef.current))
|
|
||||||
changeSearchParams({ scrollTo: null })
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
createEffect(() => {
|
|
||||||
if (searchParams().commentId && isReactionsLoaded()) {
|
|
||||||
const commentElement = document.querySelector<HTMLElement>(
|
|
||||||
`[id='comment_${searchParams().commentId}']`,
|
|
||||||
)
|
|
||||||
|
|
||||||
if (commentElement) {
|
|
||||||
requestAnimationFrame(() => scrollTo(commentElement))
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
const clickHandlers = []
|
const clickHandlers: { element: HTMLElement; handler: () => void }[] = []
|
||||||
const documentClickHandlers = []
|
const documentClickHandlers: ((e: MouseEvent) => void)[] = []
|
||||||
|
|
||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
if (!body()) {
|
if (!body()) {
|
||||||
|
@ -185,7 +195,7 @@ export const FullArticle = (props: Props) => {
|
||||||
}
|
}
|
||||||
|
|
||||||
const tooltipElements: NodeListOf<HTMLElement> = document.querySelectorAll(
|
const tooltipElements: NodeListOf<HTMLElement> = document.querySelectorAll(
|
||||||
'[data-toggle="tooltip"], footnote',
|
'[data-toggle="tooltip"], footnote'
|
||||||
)
|
)
|
||||||
if (!tooltipElements) {
|
if (!tooltipElements) {
|
||||||
return
|
return
|
||||||
|
@ -195,7 +205,7 @@ export const FullArticle = (props: Props) => {
|
||||||
tooltip.classList.add(styles.tooltip)
|
tooltip.classList.add(styles.tooltip)
|
||||||
const tooltipContent = document.createElement('div')
|
const tooltipContent = document.createElement('div')
|
||||||
tooltipContent.classList.add(styles.tooltipContent)
|
tooltipContent.classList.add(styles.tooltipContent)
|
||||||
tooltipContent.innerHTML = element.dataset.originalTitle || element.dataset.value
|
tooltipContent.innerHTML = element.dataset.originalTitle || element.dataset.value || ''
|
||||||
|
|
||||||
tooltip.append(tooltipContent)
|
tooltip.append(tooltipContent)
|
||||||
|
|
||||||
|
@ -210,19 +220,19 @@ export const FullArticle = (props: Props) => {
|
||||||
modifiers: [
|
modifiers: [
|
||||||
{
|
{
|
||||||
name: 'eventListeners',
|
name: 'eventListeners',
|
||||||
options: { scroll: false },
|
options: { scroll: false }
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'offset',
|
name: 'offset',
|
||||||
options: {
|
options: {
|
||||||
offset: [0, 8],
|
offset: [0, 8]
|
||||||
},
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'flip',
|
name: 'flip',
|
||||||
options: { fallbackPlacements: ['top'] },
|
options: { fallbackPlacements: ['top'] }
|
||||||
},
|
}
|
||||||
],
|
]
|
||||||
})
|
})
|
||||||
|
|
||||||
tooltip.style.visibility = 'hidden'
|
tooltip.style.visibility = 'hidden'
|
||||||
|
@ -239,7 +249,7 @@ export const FullArticle = (props: Props) => {
|
||||||
popperInstance.update()
|
popperInstance.update()
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleDocumentClick = (e) => {
|
const handleDocumentClick = (e: MouseEvent) => {
|
||||||
if (isTooltipVisible && e.target !== element && e.target !== tooltip) {
|
if (isTooltipVisible && e.target !== element && e.target !== tooltip) {
|
||||||
tooltip.style.visibility = 'hidden'
|
tooltip.style.visibility = 'hidden'
|
||||||
isTooltipVisible = false
|
isTooltipVisible = false
|
||||||
|
@ -263,14 +273,15 @@ export const FullArticle = (props: Props) => {
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
const openLightbox = (image) => {
|
const openLightbox = (image: string) => {
|
||||||
setSelectedImage(image)
|
setSelectedImage(image)
|
||||||
}
|
}
|
||||||
const handleLightboxClose = () => {
|
const handleLightboxClose = () => {
|
||||||
setSelectedImage()
|
setSelectedImage('')
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleArticleBodyClick = (event) => {
|
// biome-ignore lint/suspicious/noExplicitAny: FIXME: typing
|
||||||
|
const handleArticleBodyClick = (event: any) => {
|
||||||
if (event.target.tagName === 'IMG' && !event.target.dataset.disableLightbox) {
|
if (event.target.tagName === 'IMG' && !event.target.dataset.disableLightbox) {
|
||||||
const src = event.target.src
|
const src = event.target.src
|
||||||
openLightbox(getImageUrl(src))
|
openLightbox(getImageUrl(src))
|
||||||
|
@ -278,12 +289,13 @@ export const FullArticle = (props: Props) => {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check iframes size
|
// Check iframes size
|
||||||
const articleContainer: { current: HTMLElement } = { current: null }
|
let articleContainer: HTMLElement | undefined
|
||||||
const updateIframeSizes = () => {
|
const updateIframeSizes = () => {
|
||||||
if (!(articleContainer?.current && props.article.body)) return
|
if (!window) return
|
||||||
const iframes = articleContainer?.current?.querySelectorAll('iframe')
|
if (!(articleContainer && props.article.body)) return
|
||||||
|
const iframes = articleContainer?.querySelectorAll('iframe')
|
||||||
if (!iframes) return
|
if (!iframes) return
|
||||||
const containerWidth = articleContainer.current?.offsetWidth
|
const containerWidth = articleContainer?.offsetWidth
|
||||||
iframes.forEach((iframe) => {
|
iframes.forEach((iframe) => {
|
||||||
const style = window.getComputedStyle(iframe)
|
const style = window.getComputedStyle(iframe)
|
||||||
const originalWidth = iframe.getAttribute('width') || style.width.replace('px', '')
|
const originalWidth = iframe.getAttribute('width') || style.width.replace('px', '')
|
||||||
|
@ -302,58 +314,26 @@ export const FullArticle = (props: Props) => {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
createEffect(
|
onMount(() => {
|
||||||
on(
|
console.debug(props.article)
|
||||||
() => props.article,
|
setPages((_) => ({ comments: 0, rating: 0 }))
|
||||||
() => {
|
addSeen(props.article.slug)
|
||||||
updateIframeSizes()
|
|
||||||
},
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
onMount(async () => {
|
|
||||||
install('G-LQ4B87H8C2')
|
|
||||||
await loadReactionsBy({ by: { shout: props.article.slug } })
|
|
||||||
setIsReactionsLoaded(true)
|
|
||||||
document.title = props.article.title
|
document.title = props.article.title
|
||||||
|
updateIframeSizes()
|
||||||
window?.addEventListener('resize', updateIframeSizes)
|
window?.addEventListener('resize', updateIframeSizes)
|
||||||
|
|
||||||
onCleanup(() => window.removeEventListener('resize', updateIframeSizes))
|
onCleanup(() => window.removeEventListener('resize', updateIframeSizes))
|
||||||
})
|
})
|
||||||
|
|
||||||
const cover = props.article.cover ?? 'production/image/logo_image.png'
|
const shareUrl = createMemo(() => getShareUrl({ pathname: `/${props.article.slug || ''}` }))
|
||||||
const ogImage = getOpenGraphImageUrl(cover, {
|
const getAuthorName = (a: Author) =>
|
||||||
title: props.article.title,
|
lang() === 'en' && isCyrillic(a.name || '') ? capitalize(a.slug.replaceAll('-', ' ')) : a.name
|
||||||
topic: mainTopic()?.title || '',
|
|
||||||
author: props.article?.authors[0]?.name || '',
|
|
||||||
width: 1200,
|
|
||||||
})
|
|
||||||
|
|
||||||
const description = getDescription(props.article.description || body())
|
|
||||||
const ogTitle = props.article.title
|
|
||||||
const keywords = getKeywords(props.article)
|
|
||||||
const shareUrl = getShareUrl({ pathname: `/${props.article.slug}` })
|
|
||||||
const getAuthorName = (a: Author) => {
|
|
||||||
return lang() === 'en' && isCyrillic(a.name) ? capitalize(a.slug.replace(/-/, ' ')) : a.name
|
|
||||||
}
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Meta name="descprition" content={description} />
|
|
||||||
<Meta name="keywords" content={keywords} />
|
|
||||||
<Meta name="og:type" content="article" />
|
|
||||||
<Meta name="og:title" content={ogTitle} />
|
|
||||||
<Meta name="og:image" content={ogImage} />
|
|
||||||
<Meta name="og:description" content={description} />
|
|
||||||
<Meta name="twitter:card" content="summary_large_image" />
|
|
||||||
<Meta name="twitter:title" content={ogTitle} />
|
|
||||||
<Meta name="twitter:description" content={description} />
|
|
||||||
<Meta name="twitter:image" content={ogImage} />
|
|
||||||
|
|
||||||
<For each={imageUrls()}>{(imageUrl) => <Link rel="preload" as="image" href={imageUrl} />}</For>
|
<For each={imageUrls()}>{(imageUrl) => <Link rel="preload" as="image" href={imageUrl} />}</For>
|
||||||
<div class="wide-container">
|
<div class="wide-container">
|
||||||
<div class="row position-relative">
|
<div class="row position-relative">
|
||||||
<article
|
<article
|
||||||
ref={(el) => (articleContainer.current = el)}
|
ref={(el) => (articleContainer = el)}
|
||||||
class={clsx('col-md-16 col-lg-14 col-xl-12 offset-md-5', styles.articleContent)}
|
class={clsx('col-md-16 col-lg-14 col-xl-12 offset-md-5', styles.articleContent)}
|
||||||
onClick={handleArticleBodyClick}
|
onClick={handleArticleBodyClick}
|
||||||
>
|
>
|
||||||
|
@ -361,20 +341,20 @@ export const FullArticle = (props: Props) => {
|
||||||
<Show when={props.article.layout !== 'audio'}>
|
<Show when={props.article.layout !== 'audio'}>
|
||||||
<div class={styles.shoutHeader}>
|
<div class={styles.shoutHeader}>
|
||||||
<Show when={mainTopic()}>
|
<Show when={mainTopic()}>
|
||||||
<CardTopic title={mainTopic().title} slug={mainTopic().slug} />
|
<CardTopic title={mainTopic()?.title || ''} slug={mainTopic()?.slug || ''} />
|
||||||
</Show>
|
</Show>
|
||||||
|
|
||||||
<h1>{props.article.title}</h1>
|
<h1>{props.article.title || ''}</h1>
|
||||||
<Show when={props.article.subtitle}>
|
<Show when={props.article.subtitle}>
|
||||||
<h4>{props.article.subtitle}</h4>
|
<h4>{processPrepositions(props.article.subtitle || '')}</h4>
|
||||||
</Show>
|
</Show>
|
||||||
|
|
||||||
<div class={styles.shoutAuthor}>
|
<div class={styles.shoutAuthor}>
|
||||||
<For each={props.article.authors}>
|
<For each={props.article.authors}>
|
||||||
{(a: Author, index) => (
|
{(a: Maybe<Author>, index: () => number) => (
|
||||||
<>
|
<>
|
||||||
<Show when={index() > 0}>, </Show>
|
<Show when={index() > 0}>, </Show>
|
||||||
<a href={getPagePath(router, 'author', { slug: a.slug })}>{getAuthorName(a)}</a>
|
<A href={`/@${a?.slug}`}>{a && getAuthorName(a)}</A>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</For>
|
</For>
|
||||||
|
@ -387,25 +367,29 @@ export const FullArticle = (props: Props) => {
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<figure class="img-align-column">
|
<figure class="img-align-column">
|
||||||
<Image width={800} alt={props.article.cover_caption} src={props.article.cover} />
|
<Image
|
||||||
<figcaption innerHTML={props.article.cover_caption} />
|
width={800}
|
||||||
|
alt={props.article.cover_caption || ''}
|
||||||
|
src={props.article.cover || ''}
|
||||||
|
/>
|
||||||
|
<figcaption innerHTML={props.article.cover_caption || ''} />
|
||||||
</figure>
|
</figure>
|
||||||
</Show>
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
</Show>
|
</Show>
|
||||||
<Show when={props.article.lead}>
|
<Show when={props.article.lead}>
|
||||||
<section class={styles.lead} innerHTML={props.article.lead} />
|
<section class={styles.lead} innerHTML={processPrepositions(props.article.lead || '')} />
|
||||||
</Show>
|
</Show>
|
||||||
<Show when={props.article.layout === 'audio'}>
|
<Show when={props.article.layout === 'audio'}>
|
||||||
<AudioHeader
|
<AudioHeader
|
||||||
title={props.article.title}
|
title={props.article.title || ''}
|
||||||
cover={props.article.cover}
|
cover={props.article.cover || ''}
|
||||||
artistData={media()?.[0]}
|
artistData={media()?.[0]}
|
||||||
topic={mainTopic()}
|
topic={mainTopic() as Topic}
|
||||||
/>
|
/>
|
||||||
<Show when={media().length > 0}>
|
<Show when={media().length > 0}>
|
||||||
<div class="media-items">
|
<div class="media-items">
|
||||||
<AudioPlayer media={media()} articleSlug={props.article.slug} body={body()} />
|
<AudioPlayer media={media()} articleSlug={props.article.slug || ''} body={body()} />
|
||||||
</div>
|
</div>
|
||||||
</Show>
|
</Show>
|
||||||
</Show>
|
</Show>
|
||||||
|
@ -463,11 +447,11 @@ export const FullArticle = (props: Props) => {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Popover content={t('Comment')} disabled={isActionPopupActive()}>
|
<Popover content={t('Comment')} disabled={isActionPopupActive()}>
|
||||||
{(triggerRef: (el) => void) => (
|
{(triggerRef: (el: HTMLElement) => void) => (
|
||||||
<div
|
<div
|
||||||
class={clsx(styles.shoutStatsItem)}
|
class={clsx(styles.shoutStatsItem)}
|
||||||
ref={triggerRef}
|
ref={triggerRef}
|
||||||
onClick={() => scrollTo(commentsRef.current)}
|
onClick={() => commentsRef && scrollTo(commentsRef)}
|
||||||
>
|
>
|
||||||
<Icon name="comment" class={styles.icon} />
|
<Icon name="comment" class={styles.icon} />
|
||||||
<Icon name="comment-hover" class={clsx(styles.icon, styles.iconHover)} />
|
<Icon name="comment-hover" class={clsx(styles.icon, styles.iconHover)} />
|
||||||
|
@ -483,7 +467,7 @@ export const FullArticle = (props: Props) => {
|
||||||
|
|
||||||
<Show when={props.article.stat?.viewed}>
|
<Show when={props.article.stat?.viewed}>
|
||||||
<div class={clsx(styles.shoutStatsItem, styles.shoutStatsItemViews)}>
|
<div class={clsx(styles.shoutStatsItem, styles.shoutStatsItemViews)}>
|
||||||
{t('viewsWithCount', { count: props.article.stat?.viewed })}
|
{t('some views', { count: props.article.stat?.viewed || 0 })}
|
||||||
</div>
|
</div>
|
||||||
</Show>
|
</Show>
|
||||||
|
|
||||||
|
@ -494,7 +478,7 @@ export const FullArticle = (props: Props) => {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Popover content={t('Add to bookmarks')} disabled={isActionPopupActive()}>
|
<Popover content={t('Add to bookmarks')} disabled={isActionPopupActive()}>
|
||||||
{(triggerRef: (el) => void) => (
|
{(triggerRef: (el: HTMLElement) => void) => (
|
||||||
<div
|
<div
|
||||||
class={clsx(styles.shoutStatsItem, styles.shoutStatsItemBookmarks)}
|
class={clsx(styles.shoutStatsItem, styles.shoutStatsItemBookmarks)}
|
||||||
ref={triggerRef}
|
ref={triggerRef}
|
||||||
|
@ -509,13 +493,13 @@ export const FullArticle = (props: Props) => {
|
||||||
</Popover>
|
</Popover>
|
||||||
|
|
||||||
<Popover content={t('Share')} disabled={isActionPopupActive()}>
|
<Popover content={t('Share')} disabled={isActionPopupActive()}>
|
||||||
{(triggerRef: (el) => void) => (
|
{(triggerRef: (el: HTMLElement) => void) => (
|
||||||
<div class={styles.shoutStatsItem} ref={triggerRef}>
|
<div class={styles.shoutStatsItem} ref={triggerRef}>
|
||||||
<SharePopup
|
<SharePopup
|
||||||
title={props.article.title}
|
title={props.article.title}
|
||||||
description={description}
|
description={props.article.description || body() || media()[0]?.body}
|
||||||
imageUrl={props.article.cover}
|
imageUrl={props.article.cover || ''}
|
||||||
shareUrl={shareUrl}
|
shareUrl={shareUrl()}
|
||||||
containerCssClass={stylesHeader.control}
|
containerCssClass={stylesHeader.control}
|
||||||
onVisibilityChange={(isVisible) => setIsActionPopupActive(isVisible)}
|
onVisibilityChange={(isVisible) => setIsActionPopupActive(isVisible)}
|
||||||
trigger={
|
trigger={
|
||||||
|
@ -531,22 +515,19 @@ export const FullArticle = (props: Props) => {
|
||||||
|
|
||||||
<Show when={canEdit()}>
|
<Show when={canEdit()}>
|
||||||
<Popover content={t('Edit')}>
|
<Popover content={t('Edit')}>
|
||||||
{(triggerRef: (el) => void) => (
|
{(triggerRef: (el: HTMLElement) => void) => (
|
||||||
<div class={styles.shoutStatsItem} ref={triggerRef}>
|
<div class={styles.shoutStatsItem} ref={triggerRef}>
|
||||||
<a
|
<A href={`/edit/${props.article.id}`} class={styles.shoutStatsItemInner}>
|
||||||
href={getPagePath(router, 'edit', { shoutId: props.article.id.toString() })}
|
|
||||||
class={styles.shoutStatsItemInner}
|
|
||||||
>
|
|
||||||
<Icon name="pencil-outline" class={styles.icon} />
|
<Icon name="pencil-outline" class={styles.icon} />
|
||||||
<Icon name="pencil-outline-hover" class={clsx(styles.icon, styles.iconHover)} />
|
<Icon name="pencil-outline-hover" class={clsx(styles.icon, styles.iconHover)} />
|
||||||
</a>
|
</A>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</Popover>
|
</Popover>
|
||||||
</Show>
|
</Show>
|
||||||
|
|
||||||
<FeedArticlePopup
|
<FeedArticlePopup
|
||||||
canEdit={canEdit()}
|
canEdit={Boolean(canEdit())}
|
||||||
containerCssClass={clsx(stylesHeader.control, styles.articlePopupOpener)}
|
containerCssClass={clsx(stylesHeader.control, styles.articlePopupOpener)}
|
||||||
onShareClick={() => showModal('share')}
|
onShareClick={() => showModal('share')}
|
||||||
onInviteClick={() => showModal('inviteMembers')}
|
onInviteClick={() => showModal('inviteMembers')}
|
||||||
|
@ -560,7 +541,7 @@ export const FullArticle = (props: Props) => {
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Show when={isAuthenticated() && !canEdit()}>
|
<Show when={session()?.access_token && !canEdit()}>
|
||||||
<div class={styles.help}>
|
<div class={styles.help}>
|
||||||
<button class="button">{t('Cooperate')}</button>
|
<button class="button">{t('Cooperate')}</button>
|
||||||
</div>
|
</div>
|
||||||
|
@ -571,14 +552,14 @@ export const FullArticle = (props: Props) => {
|
||||||
</div>
|
</div>
|
||||||
</Show>
|
</Show>
|
||||||
|
|
||||||
<Show when={props.article.topics.length}>
|
<Show when={props.article.topics?.length}>
|
||||||
<div class={styles.topicsList}>
|
<div class={styles.topicsList}>
|
||||||
<For each={props.article.topics}>
|
<For each={props.article.topics || []}>
|
||||||
{(topic) => (
|
{(topic) => (
|
||||||
<div class={styles.shoutTopic}>
|
<div class={styles.shoutTopic}>
|
||||||
<a href={getPagePath(router, 'topic', { slug: topic.slug })}>
|
<A href={`/topic/${topic?.slug || ''}`}>
|
||||||
{lang() === 'en' ? capitalize(topic.slug) : topic.title}
|
{lang() === 'en' ? capitalize(topic?.slug || '') : topic?.title || ''}
|
||||||
</a>
|
</A>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</For>
|
</For>
|
||||||
|
@ -586,23 +567,23 @@ export const FullArticle = (props: Props) => {
|
||||||
</Show>
|
</Show>
|
||||||
|
|
||||||
<div class={styles.shoutAuthorsList}>
|
<div class={styles.shoutAuthorsList}>
|
||||||
<Show when={props.article.authors.length > 1}>
|
<Show when={(props.article.authors?.length || 0) > 1}>
|
||||||
<h4>{t('Authors')}</h4>
|
<h4>{t('Authors')}</h4>
|
||||||
</Show>
|
</Show>
|
||||||
<For each={props.article.authors}>
|
<For each={props.article.authors}>
|
||||||
{(a: Author) => (
|
{(a: Maybe<Author>) => (
|
||||||
<div class="col-xl-12">
|
<div class="col-xl-12">
|
||||||
<AuthorBadge iconButtons={true} showMessageButton={true} author={a} />
|
<AuthorBadge iconButtons={true} showMessageButton={true} author={a as Author} />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</For>
|
</For>
|
||||||
</div>
|
</div>
|
||||||
<div id="comments" ref={(el) => (commentsRef.current = el)}>
|
<div id="comments" ref={(el) => (commentsRef = el)}>
|
||||||
<Show when={isReactionsLoaded()}>
|
<Show when={isReactionsLoaded()}>
|
||||||
<CommentsTree
|
<CommentsTree
|
||||||
shoutId={props.article.id}
|
shoutId={props.article.id}
|
||||||
shoutSlug={props.article.slug}
|
shoutSlug={props.article.slug}
|
||||||
articleAuthors={props.article.authors}
|
articleAuthors={props.article.authors as Author[]}
|
||||||
/>
|
/>
|
||||||
</Show>
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
|
@ -617,9 +598,9 @@ export const FullArticle = (props: Props) => {
|
||||||
</Modal>
|
</Modal>
|
||||||
<ShareModal
|
<ShareModal
|
||||||
title={props.article.title}
|
title={props.article.title}
|
||||||
description={description}
|
description={props.article.description || body() || media()[0]?.body}
|
||||||
imageUrl={props.article.cover}
|
imageUrl={props.article.cover || ''}
|
||||||
shareUrl={shareUrl}
|
shareUrl={shareUrl()}
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
|
|
|
@ -28,7 +28,7 @@ export const SharePopup = (props: SharePopupProps) => {
|
||||||
})
|
})
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Popup {...props} variant="bordered" onVisibilityChange={(value) => setIsVisible(value)}>
|
<Popup {...props} onVisibilityChange={(value) => setIsVisible(value)}>
|
||||||
<ShareLinks
|
<ShareLinks
|
||||||
variant="inPopup"
|
variant="inPopup"
|
||||||
title={props.title}
|
title={props.title}
|
||||||
|
|
|
@ -1,11 +1,11 @@
|
||||||
import { clsx } from 'clsx'
|
import { clsx } from 'clsx'
|
||||||
import { Show, createMemo, createSignal } from 'solid-js'
|
import { Show, createMemo, createSignal } from 'solid-js'
|
||||||
|
import { useFeed } from '~/context/feed'
|
||||||
import { useLocalize } from '../../context/localize'
|
import { useLocalize } from '~/context/localize'
|
||||||
import { useReactions } from '../../context/reactions'
|
import { useReactions } from '~/context/reactions'
|
||||||
import { useSession } from '../../context/session'
|
import { useSession } from '~/context/session'
|
||||||
import { ReactionKind, Shout } from '../../graphql/schema/core.gen'
|
import type { Author } from '~/graphql/schema/core.gen'
|
||||||
import { loadShout } from '../../stores/zine/articles'
|
import { ReactionKind, Shout } from '~/graphql/schema/core.gen'
|
||||||
import { Icon } from '../_shared/Icon'
|
import { Icon } from '../_shared/Icon'
|
||||||
import { Popup } from '../_shared/Popup'
|
import { Popup } from '../_shared/Popup'
|
||||||
import { VotersList } from '../_shared/VotersList'
|
import { VotersList } from '../_shared/VotersList'
|
||||||
|
@ -19,56 +19,60 @@ interface ShoutRatingControlProps {
|
||||||
|
|
||||||
export const ShoutRatingControl = (props: ShoutRatingControlProps) => {
|
export const ShoutRatingControl = (props: ShoutRatingControlProps) => {
|
||||||
const { t } = useLocalize()
|
const { t } = useLocalize()
|
||||||
const { author, requireAuthentication } = useSession()
|
const { loadShout } = useFeed()
|
||||||
const { reactionEntities, createReaction, deleteReaction, loadReactionsBy } = useReactions()
|
const { requireAuthentication, session } = useSession()
|
||||||
|
const author = createMemo<Author>(() => session()?.user?.app_data?.profile as Author)
|
||||||
|
const { reactionEntities, createShoutReaction, deleteShoutReaction, loadReactionsBy } = useReactions()
|
||||||
const [isLoading, setIsLoading] = createSignal(false)
|
const [isLoading, setIsLoading] = createSignal(false)
|
||||||
|
|
||||||
const checkReaction = (reactionKind: ReactionKind) =>
|
const checkReaction = (reactionKind: ReactionKind) =>
|
||||||
Object.values(reactionEntities).some(
|
Object.values(reactionEntities()).some(
|
||||||
(r) =>
|
(r) =>
|
||||||
r.kind === reactionKind &&
|
r.kind === reactionKind &&
|
||||||
r.created_by.id === author()?.id &&
|
r.created_by.id === author()?.id &&
|
||||||
r.shout.id === props.shout.id &&
|
r.shout.id === props.shout.id &&
|
||||||
!r.reply_to,
|
!r.reply_to
|
||||||
)
|
)
|
||||||
|
|
||||||
const isUpvoted = createMemo(() => checkReaction(ReactionKind.Like))
|
const isUpvoted = createMemo(() => checkReaction(ReactionKind.Like))
|
||||||
const isDownvoted = createMemo(() => checkReaction(ReactionKind.Dislike))
|
const isDownvoted = createMemo(() => checkReaction(ReactionKind.Dislike))
|
||||||
|
|
||||||
const shoutRatingReactions = createMemo(() =>
|
const shoutRatingReactions = createMemo(() =>
|
||||||
Object.values(reactionEntities).filter(
|
Object.values(reactionEntities()).filter(
|
||||||
(r) => ['LIKE', 'DISLIKE'].includes(r.kind) && r.shout.id === props.shout.id && !r.reply_to,
|
(r) => ['LIKE', 'DISLIKE'].includes(r.kind) && r.shout.id === props.shout.id && !r.reply_to
|
||||||
),
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
const deleteShoutReaction = async (reactionKind: ReactionKind) => {
|
const removeReaction = async (reactionKind: ReactionKind) => {
|
||||||
const reactionToDelete = Object.values(reactionEntities).find(
|
const reactionToDelete = Object.values(reactionEntities).find(
|
||||||
(r) =>
|
(r) =>
|
||||||
r.kind === reactionKind &&
|
r.kind === reactionKind &&
|
||||||
r.created_by.id === author()?.id &&
|
r.created_by.id === author()?.id &&
|
||||||
r.shout.id === props.shout.id &&
|
r.shout.id === props.shout.id &&
|
||||||
!r.reply_to,
|
!r.reply_to
|
||||||
)
|
)
|
||||||
return deleteReaction(reactionToDelete.id)
|
if (reactionToDelete) return deleteShoutReaction(reactionToDelete.id)
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleRatingChange = (isUpvote: boolean) => {
|
const handleRatingChange = (isUpvote: boolean) => {
|
||||||
requireAuthentication(async () => {
|
requireAuthentication(async () => {
|
||||||
setIsLoading(true)
|
setIsLoading(true)
|
||||||
if (isUpvoted()) {
|
if (isUpvoted()) {
|
||||||
await deleteShoutReaction(ReactionKind.Like)
|
await removeReaction(ReactionKind.Like)
|
||||||
} else if (isDownvoted()) {
|
} else if (isDownvoted()) {
|
||||||
await deleteShoutReaction(ReactionKind.Dislike)
|
await removeReaction(ReactionKind.Dislike)
|
||||||
} else {
|
} else {
|
||||||
await createReaction({
|
await createShoutReaction({
|
||||||
kind: isUpvote ? ReactionKind.Like : ReactionKind.Dislike,
|
reaction: {
|
||||||
shout: props.shout.id,
|
kind: isUpvote ? ReactionKind.Like : ReactionKind.Dislike,
|
||||||
|
shout: props.shout.id
|
||||||
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
loadShout(props.shout.slug)
|
loadShout(props.shout.slug)
|
||||||
loadReactionsBy({
|
loadReactionsBy({
|
||||||
by: { shout: props.shout.slug },
|
by: { shout: props.shout.slug }
|
||||||
})
|
})
|
||||||
|
|
||||||
setIsLoading(false)
|
setIsLoading(false)
|
||||||
|
@ -83,7 +87,10 @@ export const ShoutRatingControl = (props: ShoutRatingControlProps) => {
|
||||||
</Show>
|
</Show>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<Popup trigger={<span class={styles.ratingValue}>{props.shout.stat.rating}</span>} variant="tiny">
|
<Popup
|
||||||
|
trigger={<span class={styles.ratingValue}>{props.shout.stat?.rating || 0}</span>}
|
||||||
|
variant="tiny"
|
||||||
|
>
|
||||||
<VotersList
|
<VotersList
|
||||||
reactions={shoutRatingReactions()}
|
reactions={shoutRatingReactions()}
|
||||||
fallbackMessage={t('This post has not been rated yet')}
|
fallbackMessage={t('This post has not been rated yet')}
|
||||||
|
|
|
@ -1,10 +1,7 @@
|
||||||
import { JSX, Show, createEffect } from 'solid-js'
|
import { useSearchParams } from '@solidjs/router'
|
||||||
|
import { JSX, Show, createEffect, createMemo, on } from 'solid-js'
|
||||||
import { useSession } from '../../context/session'
|
import { useSession } from '~/context/session'
|
||||||
import { RootSearchParams } from '../../pages/types'
|
import { useUI } from '~/context/ui'
|
||||||
import { useRouter } from '../../stores/router'
|
|
||||||
import { hideModal } from '../../stores/ui'
|
|
||||||
import { AuthModalSearchParams } from '../Nav/AuthModal/types'
|
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
children: JSX.Element
|
children: JSX.Element
|
||||||
|
@ -12,30 +9,32 @@ type Props = {
|
||||||
}
|
}
|
||||||
|
|
||||||
export const AuthGuard = (props: Props) => {
|
export const AuthGuard = (props: Props) => {
|
||||||
const { isAuthenticated, isSessionLoaded } = useSession()
|
const { session } = useSession()
|
||||||
const { changeSearchParams } = useRouter<RootSearchParams & AuthModalSearchParams>()
|
const author = createMemo<number>(() => session()?.user?.app_data?.profile?.id || 0)
|
||||||
|
const [, changeSearchParams] = useSearchParams()
|
||||||
|
const { hideModal } = useUI()
|
||||||
|
|
||||||
createEffect(() => {
|
createEffect(
|
||||||
if (props.disabled) {
|
on(
|
||||||
return
|
[() => props.disabled, author],
|
||||||
}
|
([disabled, a]) => {
|
||||||
if (isSessionLoaded()) {
|
if (disabled || !a) return
|
||||||
if (isAuthenticated()) {
|
if (a) {
|
||||||
hideModal()
|
console.debug('[AuthGuard] profile is loaded')
|
||||||
} else {
|
hideModal()
|
||||||
changeSearchParams(
|
} else {
|
||||||
{
|
changeSearchParams(
|
||||||
source: 'authguard',
|
{
|
||||||
m: 'auth',
|
source: 'authguard',
|
||||||
},
|
m: 'auth'
|
||||||
true,
|
},
|
||||||
)
|
{ replace: true }
|
||||||
}
|
)
|
||||||
} else {
|
}
|
||||||
// await loadSession()
|
},
|
||||||
console.warn('session is not loaded')
|
{ defer: true }
|
||||||
}
|
)
|
||||||
})
|
)
|
||||||
|
|
||||||
return <Show when={(isSessionLoaded() && isAuthenticated()) || props.disabled}>{props.children}</Show>
|
return <Show when={author() || props.disabled}>{props.children}</Show>
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,13 +1,13 @@
|
||||||
.view {
|
.view {
|
||||||
background: #fff;
|
|
||||||
min-height: 550px;
|
|
||||||
position: relative;
|
|
||||||
justify-content: center;
|
|
||||||
|
|
||||||
@include media-breakpoint-up(md) {
|
@include media-breakpoint-up(md) {
|
||||||
min-height: 600px;
|
min-height: 600px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
background: var(--background-color);
|
||||||
|
min-height: 550px;
|
||||||
|
position: relative;
|
||||||
|
justify-content: center;
|
||||||
|
|
||||||
input {
|
input {
|
||||||
font-size: 1.7rem;
|
font-size: 1.7rem;
|
||||||
}
|
}
|
||||||
|
@ -41,6 +41,10 @@
|
||||||
.authImage {
|
.authImage {
|
||||||
@include font-size(1.5rem);
|
@include font-size(1.5rem);
|
||||||
|
|
||||||
|
@include media-breakpoint-down(sm) {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
background: var(--background-color-invert)
|
background: var(--background-color-invert)
|
||||||
url('https://images.discours.io/unsafe/1600x/production/image/auth-page.jpg') center no-repeat;
|
url('https://images.discours.io/unsafe/1600x/production/image/auth-page.jpg') center no-repeat;
|
||||||
background-size: cover;
|
background-size: cover;
|
||||||
|
@ -49,10 +53,6 @@
|
||||||
padding: 3em;
|
padding: 3em;
|
||||||
position: relative;
|
position: relative;
|
||||||
|
|
||||||
@include media-breakpoint-down(sm) {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
h2 {
|
h2 {
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
}
|
}
|
||||||
|
@ -118,13 +118,13 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
.auth {
|
.auth {
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
padding: $container-padding-x;
|
|
||||||
|
|
||||||
@include media-breakpoint-up(lg) {
|
@include media-breakpoint-up(lg) {
|
||||||
padding: 4rem !important;
|
padding: 4rem !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
padding: $container-padding-x;
|
||||||
}
|
}
|
||||||
|
|
||||||
.submitButton {
|
.submitButton {
|
||||||
|
@ -154,17 +154,6 @@
|
||||||
margin-bottom: 1em;
|
margin-bottom: 1em;
|
||||||
}
|
}
|
||||||
|
|
||||||
.authInfo {
|
|
||||||
font-weight: 400;
|
|
||||||
font-size: smaller;
|
|
||||||
margin-top: -2em;
|
|
||||||
position: absolute;
|
|
||||||
|
|
||||||
.warn {
|
|
||||||
color: #a00;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.authForm {
|
.authForm {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex: 1;
|
flex: 1;
|
||||||
|
@ -221,3 +210,7 @@
|
||||||
line-height: 24px;
|
line-height: 24px;
|
||||||
margin-bottom: 52px;
|
margin-bottom: 52px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.submitError {
|
||||||
|
margin: -1rem 0 -2rem;
|
||||||
|
}
|
|
@ -1,9 +1,6 @@
|
||||||
|
import { useSearchParams } from '@solidjs/router'
|
||||||
import { Show } from 'solid-js'
|
import { Show } from 'solid-js'
|
||||||
|
import { useLocalize } from '~/context/localize'
|
||||||
import { useLocalize } from '../../../../context/localize'
|
|
||||||
import { useRouter } from '../../../../stores/router'
|
|
||||||
import { AuthModalSearchParams } from '../types'
|
|
||||||
|
|
||||||
import styles from './AuthModalHeader.module.scss'
|
import styles from './AuthModalHeader.module.scss'
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
|
@ -12,65 +9,64 @@ type Props = {
|
||||||
|
|
||||||
export const AuthModalHeader = (props: Props) => {
|
export const AuthModalHeader = (props: Props) => {
|
||||||
const { t } = useLocalize()
|
const { t } = useLocalize()
|
||||||
const { searchParams } = useRouter<AuthModalSearchParams>()
|
const [searchParams] = useSearchParams<{ source: string }>()
|
||||||
const { source } = searchParams()
|
|
||||||
|
|
||||||
const generateModalTextsFromSource = (
|
const generateModalTextsFromSource = (
|
||||||
modalType: 'login' | 'register',
|
modalType: 'login' | 'register'
|
||||||
): { title: string; description: string } => {
|
): { title: string; description: string } => {
|
||||||
const title = modalType === 'login' ? 'Welcome to Discours' : 'Create account'
|
const title = modalType === 'login' ? 'Welcome to Discours' : 'Sign up'
|
||||||
|
|
||||||
switch (source) {
|
switch (searchParams?.source) {
|
||||||
case 'create': {
|
case 'create': {
|
||||||
return {
|
return {
|
||||||
title: t(`${title} to publish articles`),
|
title: t(`${title} to publish articles`),
|
||||||
description: '',
|
description: ''
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
case 'bookmark': {
|
case 'bookmark': {
|
||||||
return {
|
return {
|
||||||
title: t(`${title} to add to your bookmarks`),
|
title: t(`${title} to add to your bookmarks`),
|
||||||
description: t(
|
description: t(
|
||||||
'In bookmarks, you can save favorite discussions and materials that you want to return to',
|
'In bookmarks, you can save favorite discussions and materials that you want to return to'
|
||||||
),
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
case 'discussions': {
|
case 'discussions': {
|
||||||
return {
|
return {
|
||||||
title: t(`${title} to participate in discussions`),
|
title: t(`${title} to participate in discussions`),
|
||||||
description: t(
|
description: t(
|
||||||
"You ll be able to participate in discussions, rate others' comments and learn about new responses",
|
"You ll be able to participate in discussions, rate others' comments and learn about new responses"
|
||||||
),
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
case 'follow': {
|
case 'follow': {
|
||||||
return {
|
return {
|
||||||
title: t(`${title} to subscribe`),
|
title: t(`${title} to subscribe`),
|
||||||
description: t(
|
description: t(
|
||||||
'This way you ll be able to subscribe to authors, interesting topics and customize your feed',
|
'This way you ll be able to subscribe to authors, interesting topics and customize your feed'
|
||||||
),
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
case 'subscribe': {
|
case 'subscribe': {
|
||||||
return {
|
return {
|
||||||
title: t(`${title} to subscribe to new publications`),
|
title: t(`${title} to subscribe to new publications`),
|
||||||
description: t(
|
description: t(
|
||||||
'This way you ll be able to subscribe to authors, interesting topics and customize your feed',
|
'This way you ll be able to subscribe to authors, interesting topics and customize your feed'
|
||||||
),
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
case 'vote': {
|
case 'vote': {
|
||||||
return {
|
return {
|
||||||
title: t(`${title} to vote`),
|
title: t(`${title} to vote`),
|
||||||
description: t(
|
description: t(
|
||||||
'This way we ll realize that you re a real person and ll take your vote into account. And you ll see how others voted',
|
'This way we ll realize that you re a real person and ll take your vote into account. And you ll see how others voted'
|
||||||
),
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
default: {
|
default: {
|
||||||
return {
|
return {
|
||||||
title: t(title),
|
title: t(title),
|
||||||
description: '',
|
description: ''
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
|
@ -1,15 +1,12 @@
|
||||||
import type { AuthModalSearchParams } from './types'
|
|
||||||
|
|
||||||
import { clsx } from 'clsx'
|
import { clsx } from 'clsx'
|
||||||
import { JSX, Show, createSignal } from 'solid-js'
|
import { JSX, Show, createSignal } from 'solid-js'
|
||||||
|
|
||||||
import { useLocalize } from '../../../context/localize'
|
import { useLocalize } from '~/context/localize'
|
||||||
import { useSession } from '../../../context/session'
|
import { useSession } from '~/context/session'
|
||||||
import { useRouter } from '../../../stores/router'
|
import { useUI } from '~/context/ui'
|
||||||
import { hideModal } from '../../../stores/ui'
|
|
||||||
|
|
||||||
import { PasswordField } from './PasswordField'
|
import { PasswordField } from './PasswordField'
|
||||||
|
|
||||||
|
import { useSearchParams } from '@solidjs/router'
|
||||||
import styles from './AuthModal.module.scss'
|
import styles from './AuthModal.module.scss'
|
||||||
|
|
||||||
type FormFields = {
|
type FormFields = {
|
||||||
|
@ -19,26 +16,26 @@ type FormFields = {
|
||||||
type ValidationErrors = Partial<Record<keyof FormFields, string | JSX.Element>>
|
type ValidationErrors = Partial<Record<keyof FormFields, string | JSX.Element>>
|
||||||
|
|
||||||
export const ChangePasswordForm = () => {
|
export const ChangePasswordForm = () => {
|
||||||
const { searchParams, changeSearchParams } = useRouter<AuthModalSearchParams>()
|
const [searchParams, changeSearchParams] = useSearchParams<{ token?: string }>()
|
||||||
|
const { hideModal } = useUI()
|
||||||
const { t } = useLocalize()
|
const { t } = useLocalize()
|
||||||
const { changePassword } = useSession()
|
const { changePassword } = useSession()
|
||||||
const [isSubmitting, setIsSubmitting] = createSignal(false)
|
const [isSubmitting, setIsSubmitting] = createSignal(false)
|
||||||
const [validationErrors, setValidationErrors] = createSignal<ValidationErrors>({})
|
const [validationErrors, setValidationErrors] = createSignal<ValidationErrors>({})
|
||||||
const [newPassword, setNewPassword] = createSignal<string>()
|
const [newPassword, setNewPassword] = createSignal<string>('')
|
||||||
const [passwordError, setPasswordError] = createSignal<string>()
|
const [passwordError, setPasswordError] = createSignal<string>('')
|
||||||
const [isSuccess, setIsSuccess] = createSignal(false)
|
const [isSuccess, setIsSuccess] = createSignal(false)
|
||||||
const authFormRef: { current: HTMLFormElement } = { current: null }
|
let authFormRef: HTMLFormElement | undefined
|
||||||
|
|
||||||
const handleSubmit = async (event: Event) => {
|
const handleSubmit = async (event: Event) => {
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
setIsSubmitting(true)
|
setIsSubmitting(true)
|
||||||
if (newPassword()) {
|
if (!newPassword()) return
|
||||||
await changePassword(newPassword(), searchParams()?.token)
|
if (searchParams?.token) changePassword(newPassword(), searchParams.token)
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
setIsSubmitting(false)
|
setIsSubmitting(false)
|
||||||
setIsSuccess(true)
|
setIsSuccess(true)
|
||||||
}, 1000)
|
}, 1000)
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const handlePasswordInput = (value: string) => {
|
const handlePasswordInput = (value: string) => {
|
||||||
|
@ -56,15 +53,10 @@ export const ChangePasswordForm = () => {
|
||||||
<form
|
<form
|
||||||
onSubmit={handleSubmit}
|
onSubmit={handleSubmit}
|
||||||
class={clsx(styles.authForm, styles.authFormForgetPassword)}
|
class={clsx(styles.authForm, styles.authFormForgetPassword)}
|
||||||
ref={(el) => (authFormRef.current = el)}
|
ref={(el) => (authFormRef = el)}
|
||||||
>
|
>
|
||||||
<div>
|
<div>
|
||||||
<h4>{t('Enter a new password')}</h4>
|
<h4>{t('Enter a new password')}</h4>
|
||||||
<div class={styles.authSubtitle}>
|
|
||||||
{t(
|
|
||||||
'Now you can enter a new password, it must contain at least 8 characters and not be the same as the previous password',
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<Show when={validationErrors()}>
|
<Show when={validationErrors()}>
|
||||||
<div>{validationErrors().password}</div>
|
<div>{validationErrors().password}</div>
|
||||||
</Show>
|
</Show>
|
||||||
|
@ -83,7 +75,7 @@ export const ChangePasswordForm = () => {
|
||||||
class={styles.authLink}
|
class={styles.authLink}
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
changeSearchParams({
|
changeSearchParams({
|
||||||
mode: 'login',
|
mode: 'login'
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
>
|
>
|
|
@ -1,35 +1,42 @@
|
||||||
import { clsx } from 'clsx'
|
import { clsx } from 'clsx'
|
||||||
import { Show, createEffect, createSignal } from 'solid-js'
|
import { Show, createEffect, createSignal } from 'solid-js'
|
||||||
|
|
||||||
import { useLocalize } from '../../../context/localize'
|
import { useLocalize } from '~/context/localize'
|
||||||
import { useSession } from '../../../context/session'
|
import { useSession } from '~/context/session'
|
||||||
import { useRouter } from '../../../stores/router'
|
import { useUI } from '~/context/ui'
|
||||||
import { hideModal } from '../../../stores/ui'
|
|
||||||
|
|
||||||
import { email, setEmail } from './sharedLogic'
|
import { email, setEmail } from './sharedLogic'
|
||||||
|
|
||||||
|
import { useSearchParams } from '@solidjs/router'
|
||||||
import styles from './AuthModal.module.scss'
|
import styles from './AuthModal.module.scss'
|
||||||
|
|
||||||
|
export type ConfirmEmailSearchParams = {
|
||||||
|
access_token?: string
|
||||||
|
token?: string
|
||||||
|
}
|
||||||
|
|
||||||
export const EmailConfirm = () => {
|
export const EmailConfirm = () => {
|
||||||
const { t } = useLocalize()
|
const { t } = useLocalize()
|
||||||
const { changeSearchParams } = useRouter()
|
const { hideModal } = useUI()
|
||||||
|
const [, changeSearchParams] = useSearchParams<ConfirmEmailSearchParams>()
|
||||||
const { session, authError } = useSession()
|
const { session, authError } = useSession()
|
||||||
const [emailConfirmed, setEmailConfirmed] = createSignal(false)
|
const [emailConfirmed, setEmailConfirmed] = createSignal(false)
|
||||||
|
|
||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
const e = session()?.user?.email
|
const email = session()?.user?.email
|
||||||
const v = session()?.user?.email_verified
|
const isVerified = session()?.user?.email_verified
|
||||||
if (e) {
|
|
||||||
setEmail(e.toLowerCase())
|
if (email) {
|
||||||
if (v) setEmailConfirmed(v)
|
setEmail(email.toLowerCase())
|
||||||
|
if (isVerified) setEmailConfirmed(isVerified)
|
||||||
if (authError()) {
|
if (authError()) {
|
||||||
changeSearchParams({}, true)
|
changeSearchParams({}, { replace: true })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
})
|
|
||||||
|
|
||||||
createEffect(() => {
|
if (authError()) {
|
||||||
if (authError()) console.debug('[AuthModal.EmailConfirm] auth error:', authError())
|
console.debug('[AuthModal.EmailConfirm] auth error:', authError())
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
return (
|
return (
|
207
src/components/AuthModal/LoginForm.tsx
Normal file
|
@ -0,0 +1,207 @@
|
||||||
|
import { clsx } from 'clsx'
|
||||||
|
import { JSX, Show, createSignal } from 'solid-js'
|
||||||
|
|
||||||
|
import { useLocalize } from '~/context/localize'
|
||||||
|
import { useSession } from '~/context/session'
|
||||||
|
import { useSnackbar, useUI } from '~/context/ui'
|
||||||
|
import { validateEmail } from '~/utils/validate'
|
||||||
|
|
||||||
|
import { AuthModalHeader } from './AuthModalHeader'
|
||||||
|
import { PasswordField } from './PasswordField'
|
||||||
|
import { SocialProviders } from './SocialProviders'
|
||||||
|
import { email, setEmail } from './sharedLogic'
|
||||||
|
|
||||||
|
import { useSearchParams } from '@solidjs/router'
|
||||||
|
import styles from './AuthModal.module.scss'
|
||||||
|
|
||||||
|
type FormFields = {
|
||||||
|
email: string
|
||||||
|
password: string
|
||||||
|
}
|
||||||
|
|
||||||
|
type ValidationErrors = Partial<Record<keyof FormFields, string>>
|
||||||
|
|
||||||
|
export const LoginForm = () => {
|
||||||
|
const { hideModal } = useUI()
|
||||||
|
const [, setSearchParams] = useSearchParams()
|
||||||
|
const { t } = useLocalize()
|
||||||
|
const [submitError, setSubmitError] = createSignal<string | JSX.Element>()
|
||||||
|
const [isSubmitting, setIsSubmitting] = createSignal(false)
|
||||||
|
const [password, setPassword] = createSignal('')
|
||||||
|
const [validationErrors, setValidationErrors] = createSignal<ValidationErrors>({})
|
||||||
|
// FIXME: use signal or remove
|
||||||
|
const [_isLinkSent, setIsLinkSent] = createSignal(false)
|
||||||
|
let authFormRef: HTMLFormElement
|
||||||
|
const { showSnackbar } = useSnackbar()
|
||||||
|
const { signIn, authError } = useSession()
|
||||||
|
|
||||||
|
const handleEmailInput = (newEmail: string) => {
|
||||||
|
setValidationErrors(({ email: _notNeeded, ...rest }) => rest)
|
||||||
|
setEmail(newEmail.toLowerCase())
|
||||||
|
}
|
||||||
|
|
||||||
|
const handlePasswordInput = (newPassword: string) => {
|
||||||
|
setValidationErrors(({ password: _notNeeded, ...rest }) => rest)
|
||||||
|
setPassword(newPassword)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSendLinkAgainClick = (event: Event) => {
|
||||||
|
event.preventDefault()
|
||||||
|
|
||||||
|
setIsLinkSent(true)
|
||||||
|
setSubmitError()
|
||||||
|
setSearchParams({ mode: 'send-confirm-email' })
|
||||||
|
}
|
||||||
|
|
||||||
|
const preSendValidate = async (value: string, type: 'email' | 'password'): Promise<boolean> => {
|
||||||
|
if (type === 'email') {
|
||||||
|
if (value === '' || !validateEmail(value)) {
|
||||||
|
setValidationErrors((prev) => ({
|
||||||
|
...prev,
|
||||||
|
email: t('Invalid email')
|
||||||
|
}))
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
} else if (type === 'password') {
|
||||||
|
if (value === '') {
|
||||||
|
setValidationErrors((prev) => ({
|
||||||
|
...prev,
|
||||||
|
password: t('Please enter password')
|
||||||
|
}))
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
const handleSubmit = async (event: Event) => {
|
||||||
|
event.preventDefault()
|
||||||
|
|
||||||
|
await preSendValidate(email(), 'email')
|
||||||
|
await preSendValidate(password(), 'password')
|
||||||
|
|
||||||
|
setIsLinkSent(false)
|
||||||
|
setSubmitError()
|
||||||
|
|
||||||
|
if (Object.keys(validationErrors()).length > 0) {
|
||||||
|
authFormRef
|
||||||
|
.querySelector<HTMLInputElement>(`input[name="${Object.keys(validationErrors())[0]}"]`)
|
||||||
|
?.focus()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsSubmitting(true)
|
||||||
|
|
||||||
|
try {
|
||||||
|
const success = await signIn({ email: email(), password: password() })
|
||||||
|
if (!success) {
|
||||||
|
switch (authError()) {
|
||||||
|
case 'user has not signed up email & password':
|
||||||
|
case 'bad user credentials': {
|
||||||
|
setValidationErrors((prev) => ({
|
||||||
|
...prev,
|
||||||
|
password: t('Something went wrong, check email and password')
|
||||||
|
}))
|
||||||
|
break
|
||||||
|
}
|
||||||
|
case 'user not found': {
|
||||||
|
setValidationErrors((prev) => ({ ...prev, email: t('User was not found') }))
|
||||||
|
break
|
||||||
|
}
|
||||||
|
case 'email not verified': {
|
||||||
|
setValidationErrors((prev) => ({ ...prev, email: t('This email is not verified') }))
|
||||||
|
break
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
setSubmitError(
|
||||||
|
<div class={styles.info}>
|
||||||
|
{t('Error', authError())}
|
||||||
|
{'. '}
|
||||||
|
<span class={'link'} onClick={handleSendLinkAgainClick}>
|
||||||
|
{t('Send link again')}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
hideModal()
|
||||||
|
showSnackbar({ body: t('Welcome!') })
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error)
|
||||||
|
setSubmitError(authError())
|
||||||
|
} finally {
|
||||||
|
setIsSubmitting(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form onSubmit={handleSubmit} class={styles.authForm} ref={(el) => (authFormRef = el)}>
|
||||||
|
<div>
|
||||||
|
<AuthModalHeader modalType="login" />
|
||||||
|
<div
|
||||||
|
class={clsx('pretty-form__item', {
|
||||||
|
'pretty-form__item--error': validationErrors().email
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
id="email"
|
||||||
|
name="email"
|
||||||
|
autocomplete="email"
|
||||||
|
type="email"
|
||||||
|
value={email()}
|
||||||
|
placeholder={t('Email')}
|
||||||
|
onInput={(event) => handleEmailInput(event.currentTarget.value)}
|
||||||
|
/>
|
||||||
|
<label for="email">{t('Email')}</label>
|
||||||
|
<Show when={validationErrors().email}>
|
||||||
|
<div class={styles.validationError}>{validationErrors().email}</div>
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<PasswordField
|
||||||
|
variant={'login'}
|
||||||
|
setError={validationErrors().password}
|
||||||
|
onBlur={(value) => handlePasswordInput(value)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Show when={submitError()}>
|
||||||
|
<div class={clsx('form-message--error', styles.validationError)}>{submitError()}</div>
|
||||||
|
</Show>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<button class={clsx('button', styles.submitButton)} disabled={isSubmitting()} type="submit">
|
||||||
|
{isSubmitting() ? '...' : t('Enter')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class={styles.authActions}>
|
||||||
|
<span
|
||||||
|
class="link"
|
||||||
|
onClick={() =>
|
||||||
|
setSearchParams({
|
||||||
|
mode: 'send-reset-link'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{t('Forgot password?')}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<SocialProviders />
|
||||||
|
|
||||||
|
<div class={styles.authControl}>
|
||||||
|
<span
|
||||||
|
class={styles.authLink}
|
||||||
|
onClick={() =>
|
||||||
|
setSearchParams({
|
||||||
|
mode: 'register'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{t('I have no account yet')}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
)
|
||||||
|
}
|
|
@ -26,16 +26,16 @@
|
||||||
line-height: 16px;
|
line-height: 16px;
|
||||||
margin-top: 0.3em;
|
margin-top: 0.3em;
|
||||||
|
|
||||||
|
/* Red/500 */
|
||||||
|
color: orange;
|
||||||
|
|
||||||
&.registerPassword {
|
&.registerPassword {
|
||||||
margin-bottom: -32px;
|
margin-bottom: -32px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Red/500 */
|
|
||||||
color: #d00820;
|
|
||||||
|
|
||||||
a {
|
a {
|
||||||
color: #d00820;
|
color: orange;
|
||||||
border-color: #d00820;
|
border-color: orange;
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
color: var(--default-color-invert);
|
color: var(--default-color-invert);
|
|
@ -1,9 +1,7 @@
|
||||||
import { clsx } from 'clsx'
|
import { clsx } from 'clsx'
|
||||||
import { Show, createEffect, createSignal, on } from 'solid-js'
|
import { Show, createEffect, createSignal, on } from 'solid-js'
|
||||||
|
import { useLocalize } from '~/context/localize'
|
||||||
import { useLocalize } from '../../../../context/localize'
|
import { Icon } from '../../_shared/Icon'
|
||||||
import { Icon } from '../../../_shared/Icon'
|
|
||||||
|
|
||||||
import styles from './PasswordField.module.scss'
|
import styles from './PasswordField.module.scss'
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
|
@ -11,21 +9,26 @@ type Props = {
|
||||||
disabled?: boolean
|
disabled?: boolean
|
||||||
placeholder?: string
|
placeholder?: string
|
||||||
errorMessage?: (error: string) => void
|
errorMessage?: (error: string) => void
|
||||||
onInput: (value: string) => void
|
setError?: string
|
||||||
|
onInput?: (value: string) => void
|
||||||
|
onBlur?: (value: string) => void
|
||||||
variant?: 'login' | 'registration'
|
variant?: 'login' | 'registration'
|
||||||
disableAutocomplete?: boolean
|
disableAutocomplete?: boolean
|
||||||
|
noValidate?: boolean
|
||||||
|
onFocus?: () => void
|
||||||
|
value?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const minLength = 8
|
||||||
|
const hasNumber = /\d/
|
||||||
|
const hasSpecial = /[!#$%&*@^]/
|
||||||
|
|
||||||
export const PasswordField = (props: Props) => {
|
export const PasswordField = (props: Props) => {
|
||||||
const { t } = useLocalize()
|
const { t } = useLocalize()
|
||||||
const [showPassword, setShowPassword] = createSignal(false)
|
const [showPassword, setShowPassword] = createSignal(false)
|
||||||
const [error, setError] = createSignal<string>()
|
const [error, setError] = createSignal<string>()
|
||||||
|
|
||||||
const validatePassword = (passwordToCheck) => {
|
const validatePassword = (passwordToCheck: string) => {
|
||||||
const minLength = 8
|
|
||||||
const hasNumber = /\d/
|
|
||||||
const hasSpecial = /[!#$%&*@^]/
|
|
||||||
|
|
||||||
if (passwordToCheck.length < minLength) {
|
if (passwordToCheck.length < minLength) {
|
||||||
return t('Password should be at least 8 characters')
|
return t('Password should be at least 8 characters')
|
||||||
}
|
}
|
||||||
|
@ -35,45 +38,45 @@ export const PasswordField = (props: Props) => {
|
||||||
if (!hasSpecial.test(passwordToCheck)) {
|
if (!hasSpecial.test(passwordToCheck)) {
|
||||||
return t('Password should contain at least one special character: !@#$%^&*')
|
return t('Password should contain at least one special character: !@#$%^&*')
|
||||||
}
|
}
|
||||||
|
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleInputChange = (value) => {
|
const handleInputBlur = (value: string) => {
|
||||||
props.onInput(value)
|
if (props.variant === 'login' && props.onBlur) {
|
||||||
const errorValue = validatePassword(value)
|
props.onBlur(value)
|
||||||
if (errorValue) {
|
return
|
||||||
setError(errorValue)
|
}
|
||||||
} else {
|
if (value.length < 1) {
|
||||||
setError()
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
props.onInput?.(value)
|
||||||
|
if (!props.noValidate) {
|
||||||
|
const errorValue = validatePassword(value)
|
||||||
|
if (errorValue) {
|
||||||
|
setError(errorValue)
|
||||||
|
} else {
|
||||||
|
setError()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
createEffect(
|
createEffect(on(error, (er) => er && props.errorMessage?.(er), { defer: true }))
|
||||||
on(
|
createEffect(() => setError(props.setError))
|
||||||
() => error(),
|
|
||||||
() => {
|
|
||||||
props.errorMessage?.(error())
|
|
||||||
},
|
|
||||||
{ defer: true },
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div class={clsx(styles.PassportField, props.class)}>
|
<div class={clsx(styles.PassportField, props.class)}>
|
||||||
<div
|
<div class="pretty-form__item">
|
||||||
class={clsx('pretty-form__item', {
|
|
||||||
'pretty-form__item--error': error() && props.variant !== 'login',
|
|
||||||
})}
|
|
||||||
>
|
|
||||||
<input
|
<input
|
||||||
id="password"
|
id="password"
|
||||||
name="password"
|
name="password"
|
||||||
disabled={props.disabled}
|
disabled={props.disabled}
|
||||||
|
onFocus={props.onFocus}
|
||||||
|
value={props.value ? props.value : ''}
|
||||||
autocomplete={props.disableAutocomplete ? 'one-time-code' : 'current-password'}
|
autocomplete={props.disableAutocomplete ? 'one-time-code' : 'current-password'}
|
||||||
type={showPassword() ? 'text' : 'password'}
|
type={showPassword() ? 'text' : 'password'}
|
||||||
placeholder={props.placeholder || t('Password')}
|
placeholder={props.placeholder || t('Password')}
|
||||||
onInput={(event) => handleInputChange(event.currentTarget.value)}
|
onBlur={(event) => handleInputBlur(event.currentTarget.value)}
|
||||||
/>
|
/>
|
||||||
<label for="password">{t('Password')}</label>
|
<label for="password">{t('Password')}</label>
|
||||||
<button
|
<button
|
||||||
|
@ -83,8 +86,14 @@ export const PasswordField = (props: Props) => {
|
||||||
>
|
>
|
||||||
<Icon class={styles.passwordToggleIcon} name={showPassword() ? 'eye-off' : 'eye'} />
|
<Icon class={styles.passwordToggleIcon} name={showPassword() ? 'eye-off' : 'eye'} />
|
||||||
</button>
|
</button>
|
||||||
<Show when={error() && props.variant !== 'login'}>
|
<Show when={error()}>
|
||||||
<div class={clsx(styles.registerPassword, styles.validationError)}>{error()}</div>
|
<div
|
||||||
|
class={clsx(styles.registerPassword, styles.validationError, {
|
||||||
|
'form-message--error': props.setError
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
{error()}
|
||||||
|
</div>
|
||||||
</Show>
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
|
@ -1,21 +1,17 @@
|
||||||
|
import { clsx } from 'clsx'
|
||||||
import type { JSX } from 'solid-js'
|
import type { JSX } from 'solid-js'
|
||||||
import { Show, createMemo, createSignal } from 'solid-js'
|
import { Show, createMemo, createSignal } from 'solid-js'
|
||||||
import type { AuthModalSearchParams } from './types'
|
|
||||||
|
|
||||||
import { clsx } from 'clsx'
|
|
||||||
|
|
||||||
import { useLocalize } from '../../../context/localize'
|
|
||||||
import { useSession } from '../../../context/session'
|
|
||||||
import { useRouter } from '../../../stores/router'
|
|
||||||
import { hideModal } from '../../../stores/ui'
|
|
||||||
import { validateEmail } from '../../../utils/validateEmail'
|
|
||||||
|
|
||||||
|
import { useSearchParams } from '@solidjs/router'
|
||||||
|
import { useLocalize } from '~/context/localize'
|
||||||
|
import { useSession } from '~/context/session'
|
||||||
|
import { useUI } from '~/context/ui'
|
||||||
|
import { validateEmail } from '~/utils/validate'
|
||||||
import { AuthModalHeader } from './AuthModalHeader'
|
import { AuthModalHeader } from './AuthModalHeader'
|
||||||
import { PasswordField } from './PasswordField'
|
import { PasswordField } from './PasswordField'
|
||||||
import { SocialProviders } from './SocialProviders'
|
import { SocialProviders } from './SocialProviders'
|
||||||
import { email, setEmail } from './sharedLogic'
|
import { email, setEmail } from './sharedLogic'
|
||||||
|
|
||||||
import { GenericResponse } from '@authorizerdev/authorizer-js'
|
|
||||||
import styles from './AuthModal.module.scss'
|
import styles from './AuthModal.module.scss'
|
||||||
|
|
||||||
type EmailStatus = 'not verified' | 'verified' | 'registered' | ''
|
type EmailStatus = 'not verified' | 'verified' | 'registered' | ''
|
||||||
|
@ -28,15 +24,13 @@ type FormFields = {
|
||||||
|
|
||||||
type ValidationErrors = Partial<Record<keyof FormFields, string | JSX.Element>>
|
type ValidationErrors = Partial<Record<keyof FormFields, string | JSX.Element>>
|
||||||
|
|
||||||
const handleEmailInput = (newEmail: string) => {
|
|
||||||
setEmail(newEmail.toLowerCase())
|
|
||||||
}
|
|
||||||
|
|
||||||
export const RegisterForm = () => {
|
export const RegisterForm = () => {
|
||||||
const { changeSearchParams } = useRouter<AuthModalSearchParams>()
|
const [, changeSearchParams] = useSearchParams()
|
||||||
|
const { hideModal } = useUI()
|
||||||
const { t } = useLocalize()
|
const { t } = useLocalize()
|
||||||
const { signUp, isRegistered, resendVerifyEmail } = useSession()
|
const { signUp, isRegistered, resendVerifyEmail } = useSession()
|
||||||
const [submitError, setSubmitError] = createSignal('')
|
// FIXME: use submit error data or remove signal
|
||||||
|
const [_submitError, setSubmitError] = createSignal('')
|
||||||
const [fullName, setFullName] = createSignal('')
|
const [fullName, setFullName] = createSignal('')
|
||||||
const [password, setPassword] = createSignal('')
|
const [password, setPassword] = createSignal('')
|
||||||
const [isSubmitting, setIsSubmitting] = createSignal(false)
|
const [isSubmitting, setIsSubmitting] = createSignal(false)
|
||||||
|
@ -45,7 +39,7 @@ export const RegisterForm = () => {
|
||||||
const [passwordError, setPasswordError] = createSignal<string>()
|
const [passwordError, setPasswordError] = createSignal<string>()
|
||||||
const [emailStatus, setEmailStatus] = createSignal<string>('')
|
const [emailStatus, setEmailStatus] = createSignal<string>('')
|
||||||
|
|
||||||
const authFormRef: { current: HTMLFormElement } = { current: null }
|
let authFormRef: HTMLFormElement
|
||||||
|
|
||||||
const handleNameInput = (newName: string) => {
|
const handleNameInput = (newName: string) => {
|
||||||
setFullName(newName)
|
setFullName(newName)
|
||||||
|
@ -53,7 +47,6 @@ export const RegisterForm = () => {
|
||||||
|
|
||||||
const handleSubmit = async (event: Event) => {
|
const handleSubmit = async (event: Event) => {
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
|
|
||||||
if (passwordError()) {
|
if (passwordError()) {
|
||||||
setValidationErrors((errors) => ({ ...errors, password: passwordError() }))
|
setValidationErrors((errors) => ({ ...errors, password: passwordError() }))
|
||||||
} else {
|
} else {
|
||||||
|
@ -85,11 +78,10 @@ export const RegisterForm = () => {
|
||||||
|
|
||||||
const isValid = createMemo(() => Object.keys(newValidationErrors).length === 0)
|
const isValid = createMemo(() => Object.keys(newValidationErrors).length === 0)
|
||||||
|
|
||||||
if (!isValid()) {
|
if (!isValid() && authFormRef) {
|
||||||
authFormRef.current
|
authFormRef
|
||||||
.querySelector<HTMLInputElement>(`input[name="${Object.keys(newValidationErrors)[0]}"]`)
|
.querySelector<HTMLInputElement>(`input[name="${Object.keys(newValidationErrors)[0]}"]`)
|
||||||
.focus()
|
?.focus()
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
setIsSubmitting(true)
|
setIsSubmitting(true)
|
||||||
|
@ -99,11 +91,10 @@ export const RegisterForm = () => {
|
||||||
email: cleanEmail,
|
email: cleanEmail,
|
||||||
password: password(),
|
password: password(),
|
||||||
confirm_password: password(),
|
confirm_password: password(),
|
||||||
redirect_uri: window.location.origin,
|
redirect_uri: window?.location?.origin || ''
|
||||||
}
|
}
|
||||||
const { errors } = await signUp(opts)
|
const success = await signUp(opts)
|
||||||
if (errors) return
|
setIsSuccess(success)
|
||||||
setIsSuccess(true)
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(error)
|
console.error(error)
|
||||||
} finally {
|
} finally {
|
||||||
|
@ -111,17 +102,18 @@ export const RegisterForm = () => {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleResendLink = async (_ev) => {
|
// biome-ignore lint/suspicious/noExplicitAny: <explanation>
|
||||||
const response: GenericResponse = await resendVerifyEmail({
|
const handleResendLink = async (_ev: any) => {
|
||||||
|
const success: boolean = await resendVerifyEmail({
|
||||||
email: email(),
|
email: email(),
|
||||||
identifier: 'basic_signup',
|
identifier: 'basic_signup'
|
||||||
})
|
})
|
||||||
setIsSuccess(response?.message === 'Verification email has been sent. Please check your inbox')
|
setIsSuccess(success)
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleCheckEmailStatus = (status: EmailStatus | string) => {
|
const handleCheckEmailStatus = (status: EmailStatus | string) => {
|
||||||
switch (status) {
|
switch (status) {
|
||||||
case 'not verified':
|
case 'not verified': {
|
||||||
setValidationErrors((prev) => ({
|
setValidationErrors((prev) => ({
|
||||||
...prev,
|
...prev,
|
||||||
email: (
|
email: (
|
||||||
|
@ -131,38 +123,43 @@ export const RegisterForm = () => {
|
||||||
{t('resend confirmation link')}
|
{t('resend confirmation link')}
|
||||||
</span>
|
</span>
|
||||||
</>
|
</>
|
||||||
),
|
)
|
||||||
}))
|
}))
|
||||||
break
|
break
|
||||||
|
}
|
||||||
case 'verified':
|
case 'verified': {
|
||||||
setValidationErrors((prev) => ({
|
setValidationErrors((_prev) => ({
|
||||||
email: (
|
email: (
|
||||||
<>
|
<>
|
||||||
{t('This email is verified')}. {t('You can')}
|
{t('This email is registered')}. {t('try')}
|
||||||
|
{', '}
|
||||||
<span class="link" onClick={() => changeSearchParams({ mode: 'login' })}>
|
<span class="link" onClick={() => changeSearchParams({ mode: 'login' })}>
|
||||||
{t('enter')}
|
{t('Enter').toLocaleLowerCase()}
|
||||||
</span>
|
</span>
|
||||||
</>
|
</>
|
||||||
),
|
)
|
||||||
}))
|
}))
|
||||||
break
|
break
|
||||||
case 'registered':
|
}
|
||||||
|
case 'registered': {
|
||||||
setValidationErrors((prev) => ({
|
setValidationErrors((prev) => ({
|
||||||
...prev,
|
...prev,
|
||||||
email: (
|
email: (
|
||||||
<>
|
<>
|
||||||
{t('This email is registered')}. {t('You can')}{' '}
|
{t('This email is registered')}
|
||||||
|
{'. '}
|
||||||
<span class="link" onClick={() => changeSearchParams({ mode: 'send-reset-link' })}>
|
<span class="link" onClick={() => changeSearchParams({ mode: 'send-reset-link' })}>
|
||||||
{t('Set the new password').toLocaleLowerCase()}
|
{t('Set the new password')}
|
||||||
</span>
|
</span>
|
||||||
</>
|
</>
|
||||||
),
|
)
|
||||||
}))
|
}))
|
||||||
break
|
break
|
||||||
default:
|
}
|
||||||
|
default: {
|
||||||
console.info('[RegisterForm] email is not registered')
|
console.info('[RegisterForm] email is not registered')
|
||||||
break
|
break
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -174,20 +171,21 @@ export const RegisterForm = () => {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handleEmailInput = (newEmail: string) => {
|
||||||
|
setEmailStatus('')
|
||||||
|
setValidationErrors({})
|
||||||
|
setEmail(newEmail.toLowerCase())
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Show when={!isSuccess()}>
|
<Show when={!isSuccess()}>
|
||||||
<form onSubmit={handleSubmit} class={styles.authForm} ref={(el) => (authFormRef.current = el)}>
|
<form onSubmit={handleSubmit} class={styles.authForm} ref={(el) => (authFormRef = el)}>
|
||||||
<div>
|
<div>
|
||||||
<AuthModalHeader modalType="register" />
|
<AuthModalHeader modalType="register" />
|
||||||
<Show when={submitError()}>
|
|
||||||
<div class={styles.authInfo}>
|
|
||||||
<div class={styles.warn}>{submitError()}</div>
|
|
||||||
</div>
|
|
||||||
</Show>
|
|
||||||
<div
|
<div
|
||||||
class={clsx('pretty-form__item', {
|
class={clsx('pretty-form__item', {
|
||||||
'pretty-form__item--error': validationErrors().fullName,
|
'pretty-form__item--error': validationErrors().fullName
|
||||||
})}
|
})}
|
||||||
>
|
>
|
||||||
<input
|
<input
|
||||||
|
@ -196,7 +194,7 @@ export const RegisterForm = () => {
|
||||||
disabled={Boolean(emailStatus())}
|
disabled={Boolean(emailStatus())}
|
||||||
placeholder={t('Full name')}
|
placeholder={t('Full name')}
|
||||||
autocomplete="one-time-code"
|
autocomplete="one-time-code"
|
||||||
onInput={(event) => handleNameInput(event.currentTarget.value)}
|
onChange={(event) => handleNameInput(event.currentTarget.value)}
|
||||||
/>
|
/>
|
||||||
<label for="name">{t('Full name')}</label>
|
<label for="name">{t('Full name')}</label>
|
||||||
<Show when={validationErrors().fullName && !emailStatus()}>
|
<Show when={validationErrors().fullName && !emailStatus()}>
|
||||||
|
@ -206,7 +204,7 @@ export const RegisterForm = () => {
|
||||||
|
|
||||||
<div
|
<div
|
||||||
class={clsx('pretty-form__item', {
|
class={clsx('pretty-form__item', {
|
||||||
'pretty-form__item--error': validationErrors().email && !emailStatus(),
|
'pretty-form__item--error': validationErrors().email && !emailStatus()
|
||||||
})}
|
})}
|
||||||
>
|
>
|
||||||
<input
|
<input
|
||||||
|
@ -219,16 +217,18 @@ export const RegisterForm = () => {
|
||||||
onBlur={handleEmailBlur}
|
onBlur={handleEmailBlur}
|
||||||
/>
|
/>
|
||||||
<label for="email">{t('Email')}</label>
|
<label for="email">{t('Email')}</label>
|
||||||
<div class={clsx(styles.validationError, { info: Boolean(emailStatus()) })}>
|
<Show when={validationErrors().email || emailStatus()}>
|
||||||
{validationErrors().email}
|
<div class={clsx(styles.validationError, { info: Boolean(emailStatus()) })}>
|
||||||
</div>
|
{validationErrors().email}
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<PasswordField
|
<PasswordField
|
||||||
disableAutocomplete={true}
|
disableAutocomplete={true}
|
||||||
disabled={Boolean(emailStatus())}
|
disabled={Boolean(emailStatus())}
|
||||||
errorMessage={(err) => setPasswordError(err)}
|
errorMessage={(err) => !emailStatus() && setPasswordError(err)}
|
||||||
onInput={(value) => setPassword(value)}
|
onInput={(value) => setPassword(emailStatus() ? '' : value)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
|
@ -250,7 +250,7 @@ export const RegisterForm = () => {
|
||||||
class={styles.authLink}
|
class={styles.authLink}
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
changeSearchParams({
|
changeSearchParams({
|
||||||
mode: 'login',
|
mode: 'login'
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
|
@ -261,12 +261,14 @@ export const RegisterForm = () => {
|
||||||
</form>
|
</form>
|
||||||
</Show>
|
</Show>
|
||||||
<Show when={isSuccess()}>
|
<Show when={isSuccess()}>
|
||||||
<div class={styles.title}>{t('Almost done! Check your email.')}</div>
|
<div style={{ 'justify-content': 'center' }}>
|
||||||
<div class={styles.text}>{t("We've sent you a message with a link to enter our website.")}</div>
|
<div class={styles.title}>{t('Almost done! Check your email.')}</div>
|
||||||
<div>
|
<div class={styles.text}>{t("We've sent you a message with a link to enter our website.")}</div>
|
||||||
<button class={clsx('button', styles.submitButton)} onClick={() => hideModal()}>
|
<div>
|
||||||
{t('Back to main page')}
|
<button class={clsx('button', styles.submitButton)} onClick={() => hideModal()}>
|
||||||
</button>
|
{t('Back to main page')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Show>
|
</Show>
|
||||||
</>
|
</>
|
26
src/components/AuthModal/SendEmailConfirm.tsx
Normal file
|
@ -0,0 +1,26 @@
|
||||||
|
import { clsx } from 'clsx'
|
||||||
|
|
||||||
|
import { useLocalize } from '~/context/localize'
|
||||||
|
import { useUI } from '~/context/ui'
|
||||||
|
|
||||||
|
import styles from './AuthModal.module.scss'
|
||||||
|
|
||||||
|
export const SendEmailConfirm = () => {
|
||||||
|
const { hideModal } = useUI()
|
||||||
|
const { t } = useLocalize()
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
'align-items': 'center',
|
||||||
|
'justify-content': 'center'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div class={styles.text}>{t('Link sent, check your email')}</div>
|
||||||
|
<div>
|
||||||
|
<button class={clsx('button', styles.submitButton)} onClick={() => hideModal()}>
|
||||||
|
{t('Go to main page')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
|
@ -1,15 +1,12 @@
|
||||||
import type { AuthModalSearchParams } from './types'
|
|
||||||
|
|
||||||
import { clsx } from 'clsx'
|
import { clsx } from 'clsx'
|
||||||
import { JSX, Show, createSignal } from 'solid-js'
|
import { JSX, Show, createSignal, onMount } from 'solid-js'
|
||||||
|
|
||||||
import { useLocalize } from '../../../context/localize'
|
|
||||||
import { useSession } from '../../../context/session'
|
|
||||||
import { useRouter } from '../../../stores/router'
|
|
||||||
import { validateEmail } from '../../../utils/validateEmail'
|
|
||||||
|
|
||||||
|
import { useLocalize } from '~/context/localize'
|
||||||
|
import { useSession } from '~/context/session'
|
||||||
|
import { validateEmail } from '~/utils/validate'
|
||||||
import { email, setEmail } from './sharedLogic'
|
import { email, setEmail } from './sharedLogic'
|
||||||
|
|
||||||
|
import { useSearchParams } from '@solidjs/router'
|
||||||
import styles from './AuthModal.module.scss'
|
import styles from './AuthModal.module.scss'
|
||||||
|
|
||||||
type FormFields = {
|
type FormFields = {
|
||||||
|
@ -19,7 +16,7 @@ type FormFields = {
|
||||||
type ValidationErrors = Partial<Record<keyof FormFields, string | JSX.Element>>
|
type ValidationErrors = Partial<Record<keyof FormFields, string | JSX.Element>>
|
||||||
|
|
||||||
export const SendResetLinkForm = () => {
|
export const SendResetLinkForm = () => {
|
||||||
const { changeSearchParams } = useRouter<AuthModalSearchParams>()
|
const [, changeSearchParams] = useSearchParams()
|
||||||
const { t } = useLocalize()
|
const { t } = useLocalize()
|
||||||
const handleEmailInput = (newEmail: string) => {
|
const handleEmailInput = (newEmail: string) => {
|
||||||
setValidationErrors(({ email: _notNeeded, ...rest }) => rest)
|
setValidationErrors(({ email: _notNeeded, ...rest }) => rest)
|
||||||
|
@ -29,7 +26,7 @@ export const SendResetLinkForm = () => {
|
||||||
const [isSubmitting, setIsSubmitting] = createSignal(false)
|
const [isSubmitting, setIsSubmitting] = createSignal(false)
|
||||||
const [validationErrors, setValidationErrors] = createSignal<ValidationErrors>({})
|
const [validationErrors, setValidationErrors] = createSignal<ValidationErrors>({})
|
||||||
const [isUserNotFound, setIsUserNotFound] = createSignal(false)
|
const [isUserNotFound, setIsUserNotFound] = createSignal(false)
|
||||||
const authFormRef: { current: HTMLFormElement } = { current: null }
|
let authFormRef: HTMLFormElement
|
||||||
const [message, setMessage] = createSignal<string>('')
|
const [message, setMessage] = createSignal<string>('')
|
||||||
|
|
||||||
const handleSubmit = async (event: Event) => {
|
const handleSubmit = async (event: Event) => {
|
||||||
|
@ -47,24 +44,25 @@ export const SendResetLinkForm = () => {
|
||||||
const isValid = Object.keys(newValidationErrors).length === 0
|
const isValid = Object.keys(newValidationErrors).length === 0
|
||||||
|
|
||||||
if (!isValid) {
|
if (!isValid) {
|
||||||
authFormRef.current
|
authFormRef
|
||||||
.querySelector<HTMLInputElement>(`input[name="${Object.keys(newValidationErrors)[0]}"]`)
|
?.querySelector<HTMLInputElement>(`input[name="${Object.keys(newValidationErrors)[0]}"]`)
|
||||||
.focus()
|
?.focus()
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
setIsSubmitting(true)
|
setIsSubmitting(true)
|
||||||
try {
|
try {
|
||||||
const { data, errors } = await forgotPassword({
|
const result = await forgotPassword({
|
||||||
email: email(),
|
email: email(),
|
||||||
redirect_uri: window.location.origin,
|
redirect_uri: window?.location?.origin || ''
|
||||||
})
|
})
|
||||||
console.debug('[SendResetLinkForm] authorizer response:', data)
|
if (result) {
|
||||||
if (errors?.some((error) => error.message.includes('bad user credentials'))) {
|
setMessage(result || '')
|
||||||
setIsUserNotFound(true)
|
} else {
|
||||||
|
console.warn('[SendResetLinkForm] forgot password mutation failed')
|
||||||
|
setIsUserNotFound(false)
|
||||||
}
|
}
|
||||||
if (data.message) setMessage(data.message)
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(error)
|
console.error(error)
|
||||||
} finally {
|
} finally {
|
||||||
|
@ -72,18 +70,28 @@ export const SendResetLinkForm = () => {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
if (email()) {
|
||||||
|
console.info('[SendResetLinkForm] email detected')
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<form
|
<form
|
||||||
onSubmit={handleSubmit}
|
onSubmit={handleSubmit}
|
||||||
class={clsx(styles.authForm, styles.authFormForgetPassword)}
|
class={clsx(styles.authForm, styles.authFormForgetPassword)}
|
||||||
ref={(el) => (authFormRef.current = el)}
|
ref={(el) => (authFormRef = el)}
|
||||||
>
|
>
|
||||||
<div>
|
<div>
|
||||||
<h4>{t('Set the new password')}</h4>
|
<h4>{t('Forgot password?')}</h4>
|
||||||
<div class={styles.authSubtitle}>{t(message()) || t('Please give us your email address')}</div>
|
<Show when={!message()}>
|
||||||
|
<div class={styles.authSubtitle}>
|
||||||
|
{t("It's OK. Just enter your email to receive a link to change your password")}
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
<div
|
<div
|
||||||
class={clsx('pretty-form__item', {
|
class={clsx('pretty-form__item', {
|
||||||
'pretty-form__item--error': validationErrors().email,
|
'pretty-form__item--error': validationErrors().email
|
||||||
})}
|
})}
|
||||||
>
|
>
|
||||||
<input
|
<input
|
||||||
|
@ -94,9 +102,8 @@ export const SendResetLinkForm = () => {
|
||||||
type="email"
|
type="email"
|
||||||
value={email()}
|
value={email()}
|
||||||
placeholder={t('Email')}
|
placeholder={t('Email')}
|
||||||
onInput={(event) => handleEmailInput(event.currentTarget.value)}
|
onChange={(event) => handleEmailInput(event.currentTarget.value)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<label for="email">{t('Email')}</label>
|
<label for="email">{t('Email')}</label>
|
||||||
<Show when={isUserNotFound()}>
|
<Show when={isUserNotFound()}>
|
||||||
<div class={styles.validationError}>
|
<div class={styles.validationError}>
|
||||||
|
@ -105,7 +112,7 @@ export const SendResetLinkForm = () => {
|
||||||
class={'link'}
|
class={'link'}
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
changeSearchParams({
|
changeSearchParams({
|
||||||
mode: 'login',
|
mode: 'register'
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
|
@ -117,28 +124,31 @@ export const SendResetLinkForm = () => {
|
||||||
<div class={styles.validationError}>{validationErrors().email}</div>
|
<div class={styles.validationError}>{validationErrors().email}</div>
|
||||||
</Show>
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
|
<Show when={!message()} fallback={<div class={styles.authSubtitle}>{t(message())}</div>}>
|
||||||
<div style={{ 'margin-top': '5rem' }}>
|
<>
|
||||||
<button
|
<div style={{ 'margin-top': '5rem' }}>
|
||||||
class={clsx('button', styles.submitButton)}
|
<button
|
||||||
disabled={isSubmitting() || Boolean(message())}
|
class={clsx('button', styles.submitButton)}
|
||||||
type="submit"
|
disabled={isSubmitting() || Boolean(message())}
|
||||||
>
|
type="submit"
|
||||||
{isSubmitting() ? '...' : t('Send')}
|
>
|
||||||
</button>
|
{isSubmitting() ? '...' : t('Restore password')}
|
||||||
</div>
|
</button>
|
||||||
<div class={styles.authControl}>
|
</div>
|
||||||
<span
|
<div class={styles.authControl}>
|
||||||
class={styles.authLink}
|
<span
|
||||||
onClick={() =>
|
class={styles.authLink}
|
||||||
changeSearchParams({
|
onClick={() =>
|
||||||
mode: 'login',
|
changeSearchParams({
|
||||||
})
|
mode: 'login'
|
||||||
}
|
})
|
||||||
>
|
}
|
||||||
{t('I know the password')}
|
>
|
||||||
</span>
|
{t('I know the password')}
|
||||||
</div>
|
</span>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
)
|
)
|
|
@ -1,9 +1,7 @@
|
||||||
import { For } from 'solid-js'
|
import { For } from 'solid-js'
|
||||||
|
import { useLocalize } from '~/context/localize'
|
||||||
import { useLocalize } from '../../../context/localize'
|
import { useSession } from '~/context/session'
|
||||||
import { useSession } from '../../../context/session'
|
|
||||||
import { Icon } from '../../_shared/Icon'
|
import { Icon } from '../../_shared/Icon'
|
||||||
|
|
||||||
import styles from './SocialProviders.module.scss'
|
import styles from './SocialProviders.module.scss'
|
||||||
|
|
||||||
export const PROVIDERS = ['facebook', 'google', 'github'] // 'vk' | 'telegram'
|
export const PROVIDERS = ['facebook', 'google', 'github'] // 'vk' | 'telegram'
|
||||||
|
@ -18,7 +16,11 @@ export const SocialProviders = () => {
|
||||||
<div class={styles.social}>
|
<div class={styles.social}>
|
||||||
<For each={PROVIDERS}>
|
<For each={PROVIDERS}>
|
||||||
{(provider) => (
|
{(provider) => (
|
||||||
<button class={styles[provider]} onClick={(_e) => oauth(provider)}>
|
<button
|
||||||
|
type="button"
|
||||||
|
class={styles[provider as keyof typeof styles]}
|
||||||
|
onClick={(_e) => oauth(provider)}
|
||||||
|
>
|
||||||
<Icon name={provider} />
|
<Icon name={provider} />
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
1
src/components/AuthModal/SocialProviders/index.ts
Normal file
|
@ -0,0 +1 @@
|
||||||
|
export { SocialProviders } from './SocialProviders'
|
|
@ -1,55 +1,69 @@
|
||||||
import type { AuthModalMode, AuthModalSearchParams } from './types'
|
|
||||||
|
|
||||||
import { clsx } from 'clsx'
|
import { clsx } from 'clsx'
|
||||||
import { Component, Show, createEffect, createMemo } from 'solid-js'
|
import { Component, Show, createEffect, createMemo } from 'solid-js'
|
||||||
import { Dynamic } from 'solid-js/web'
|
import { Dynamic } from 'solid-js/web'
|
||||||
|
|
||||||
import { useLocalize } from '../../../context/localize'
|
import { useLocalize } from '~/context/localize'
|
||||||
import { useRouter } from '../../../stores/router'
|
import { ModalSource, useUI } from '~/context/ui'
|
||||||
import { hideModal } from '../../../stores/ui'
|
import { isMobile } from '~/lib/mediaQuery'
|
||||||
import { isMobile } from '../../../utils/media-query'
|
|
||||||
|
|
||||||
import { ChangePasswordForm } from './ChangePasswordForm'
|
import { ChangePasswordForm } from './ChangePasswordForm'
|
||||||
import { EmailConfirm } from './EmailConfirm'
|
import { EmailConfirm } from './EmailConfirm'
|
||||||
import { LoginForm } from './LoginForm'
|
import { LoginForm } from './LoginForm'
|
||||||
import { RegisterForm } from './RegisterForm'
|
import { RegisterForm } from './RegisterForm'
|
||||||
|
import { SendEmailConfirm } from './SendEmailConfirm'
|
||||||
import { SendResetLinkForm } from './SendResetLinkForm'
|
import { SendResetLinkForm } from './SendResetLinkForm'
|
||||||
|
|
||||||
|
import { useSearchParams } from '@solidjs/router'
|
||||||
import styles from './AuthModal.module.scss'
|
import styles from './AuthModal.module.scss'
|
||||||
|
|
||||||
|
export type AuthModalMode =
|
||||||
|
| 'login'
|
||||||
|
| 'register'
|
||||||
|
| 'confirm-email'
|
||||||
|
| 'send-confirm-email'
|
||||||
|
| 'send-reset-link'
|
||||||
|
| 'change-password'
|
||||||
|
|
||||||
|
export type AuthModalSearchParams = {
|
||||||
|
mode: AuthModalMode
|
||||||
|
source?: ModalSource
|
||||||
|
token?: string
|
||||||
|
}
|
||||||
|
|
||||||
const AUTH_MODAL_MODES: Record<AuthModalMode, Component> = {
|
const AUTH_MODAL_MODES: Record<AuthModalMode, Component> = {
|
||||||
login: LoginForm,
|
login: LoginForm,
|
||||||
register: RegisterForm,
|
register: RegisterForm,
|
||||||
'send-reset-link': SendResetLinkForm,
|
'send-reset-link': SendResetLinkForm,
|
||||||
'confirm-email': EmailConfirm,
|
'confirm-email': EmailConfirm,
|
||||||
'change-password': ChangePasswordForm,
|
'send-confirm-email': SendEmailConfirm,
|
||||||
|
'change-password': ChangePasswordForm
|
||||||
}
|
}
|
||||||
|
|
||||||
export const AuthModal = () => {
|
export const AuthModal = () => {
|
||||||
const rootRef: { current: HTMLDivElement } = { current: null }
|
let rootRef: HTMLDivElement | null
|
||||||
const { t } = useLocalize()
|
const { t } = useLocalize()
|
||||||
const { searchParams } = useRouter<AuthModalSearchParams>()
|
const [searchParams] = useSearchParams<AuthModalSearchParams>()
|
||||||
const { source } = searchParams()
|
const { hideModal } = useUI()
|
||||||
|
const mode = createMemo(() => {
|
||||||
const mode = createMemo<AuthModalMode>(() => {
|
return (
|
||||||
return AUTH_MODAL_MODES[searchParams().mode] ? searchParams().mode : 'login'
|
AUTH_MODAL_MODES[searchParams?.mode as AuthModalMode] ? searchParams?.mode : 'login'
|
||||||
|
) as AuthModalMode
|
||||||
})
|
})
|
||||||
|
|
||||||
createEffect((oldMode) => {
|
createEffect((oldMode) => {
|
||||||
if (oldMode !== mode() && !isMobile()) {
|
if (oldMode !== mode() && !isMobile()) {
|
||||||
rootRef.current?.querySelector('input')?.focus()
|
rootRef?.querySelector('input')?.focus()
|
||||||
}
|
}
|
||||||
}, null)
|
}, null)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
ref={(el) => (rootRef.current = el)}
|
ref={(el) => (rootRef = el)}
|
||||||
class={clsx(styles.view, {
|
class={clsx(styles.view, {
|
||||||
row: !source,
|
row: !searchParams?.source,
|
||||||
[styles.signUp]: mode() === 'register' || mode() === 'confirm-email',
|
[styles.signUp]: mode() === 'register' || mode() === 'confirm-email'
|
||||||
})}
|
})}
|
||||||
>
|
>
|
||||||
<Show when={!source}>
|
<Show when={!searchParams?.source}>
|
||||||
<div class={clsx('col-md-12 d-none d-md-flex', styles.authImage)}>
|
<div class={clsx('col-md-12 d-none d-md-flex', styles.authImage)}>
|
||||||
<div
|
<div
|
||||||
class={styles.authImageText}
|
class={styles.authImageText}
|
||||||
|
@ -59,33 +73,32 @@ export const AuthModal = () => {
|
||||||
<h4>{t('Join the global community of authors!')}</h4>
|
<h4>{t('Join the global community of authors!')}</h4>
|
||||||
<p class={styles.authBenefits}>
|
<p class={styles.authBenefits}>
|
||||||
{t(
|
{t(
|
||||||
'Get to know the most intelligent people of our time, edit and discuss the articles, share your expertise, rate and decide what to publish in the magazine',
|
'Get to know the most intelligent people of our time, edit and discuss the articles, share your expertise, rate and decide what to publish in the magazine'
|
||||||
)}
|
)}
|
||||||
.
|
. {t('New stories and more are waiting for you every day!')}
|
||||||
{t('New stories every day and even more!')}
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<p class={styles.disclaimer}>
|
<p class={styles.disclaimer}>
|
||||||
{t('By signing up you agree with our')}{' '}
|
{t('By signing up you agree with our')}{' '}
|
||||||
<a
|
<a
|
||||||
href="/about/terms-of-use"
|
href="/terms"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
hideModal()
|
hideModal()
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{t('terms of use')}
|
{t('terms of use')}
|
||||||
</a>
|
</a>
|
||||||
, {t('personal data usage and email notifications')}.
|
, {t('to process personal data and receive email notifications')}.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>{' '}
|
</div>{' '}
|
||||||
</Show>
|
</Show>
|
||||||
<div
|
<div
|
||||||
class={clsx(styles.auth, {
|
class={clsx(styles.auth, {
|
||||||
'col-md-12': !source,
|
'col-md-12': !searchParams?.source
|
||||||
})}
|
})}
|
||||||
>
|
>
|
||||||
<Dynamic component={AUTH_MODAL_MODES[mode()]} />
|
<Dynamic component={AUTH_MODAL_MODES[mode() as AuthModalMode]} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
|
@ -1,4 +1,8 @@
|
||||||
.AuthorBadge {
|
.AuthorBadge {
|
||||||
|
@include media-breakpoint-down(md) {
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
align-items: flex-start;
|
align-items: flex-start;
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 1rem;
|
gap: 1rem;
|
||||||
|
@ -12,34 +16,30 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@include media-breakpoint-down(md) {
|
|
||||||
text-align: left;
|
|
||||||
}
|
|
||||||
|
|
||||||
.basicInfo {
|
.basicInfo {
|
||||||
|
@include media-breakpoint-down(sm) {
|
||||||
|
flex: 0 100%;
|
||||||
|
}
|
||||||
|
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: row;
|
flex-direction: row;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
flex: 0 calc(100% - 5.2rem);
|
flex: 0 calc(100% - 5.2rem);
|
||||||
gap: 1rem;
|
gap: 1rem;
|
||||||
|
|
||||||
@include media-breakpoint-down(sm) {
|
|
||||||
flex: 0 100%;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.info {
|
.info {
|
||||||
@include font-size(1.4rem);
|
@include font-size(1.4rem);
|
||||||
|
|
||||||
|
@include media-breakpoint-up(sm) {
|
||||||
|
flex: 1 100%;
|
||||||
|
}
|
||||||
|
|
||||||
border: none;
|
border: none;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
line-height: 1.3;
|
line-height: 1.3;
|
||||||
|
|
||||||
@include media-breakpoint-up(sm) {
|
|
||||||
flex: 1 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
background: unset;
|
background: unset;
|
||||||
}
|
}
|
||||||
|
@ -60,20 +60,16 @@
|
||||||
.bio {
|
.bio {
|
||||||
@include font-size(1.2rem);
|
@include font-size(1.2rem);
|
||||||
|
|
||||||
|
color: var(--black-400);
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: row;
|
flex-direction: row;
|
||||||
gap: 1rem;
|
|
||||||
color: var(--black-400);
|
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
|
gap: 1rem;
|
||||||
|
max-width: 100%;
|
||||||
|
word-break: break-word;
|
||||||
}
|
}
|
||||||
|
|
||||||
.actions {
|
.actions {
|
||||||
flex: 0 20%;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: row;
|
|
||||||
margin-left: 5.2rem;
|
|
||||||
gap: 1rem;
|
|
||||||
|
|
||||||
@include media-breakpoint-down(sm) {
|
@include media-breakpoint-down(sm) {
|
||||||
margin-left: 0;
|
margin-left: 0;
|
||||||
}
|
}
|
||||||
|
@ -88,6 +84,12 @@
|
||||||
padding-left: 1rem;
|
padding-left: 1rem;
|
||||||
text-align: right;
|
text-align: right;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
flex: 0 20%;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
margin-left: 5.2rem;
|
||||||
|
gap: 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.actionButton {
|
.actionButton {
|
||||||
|
@ -115,8 +117,4 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.actionButtonLabelHovered {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,87 +1,80 @@
|
||||||
import { openPage } from '@nanostores/router'
|
import { useNavigate } from '@solidjs/router'
|
||||||
import { clsx } from 'clsx'
|
import { clsx } from 'clsx'
|
||||||
import { Match, Show, Switch, createEffect, createMemo, createSignal, on } from 'solid-js'
|
import { Match, Show, Switch, createEffect, createMemo, createSignal, on } from 'solid-js'
|
||||||
|
import { Button } from '~/components/_shared/Button'
|
||||||
import { useFollowing } from '../../../context/following'
|
import { CheckButton } from '~/components/_shared/CheckButton'
|
||||||
import { useLocalize } from '../../../context/localize'
|
import { ConditionalWrapper } from '~/components/_shared/ConditionalWrapper'
|
||||||
import { useMediaQuery } from '../../../context/mediaQuery'
|
import { FollowingButton } from '~/components/_shared/FollowingButton'
|
||||||
import { useSession } from '../../../context/session'
|
import { Icon } from '~/components/_shared/Icon'
|
||||||
import { Author, FollowingEntity } from '../../../graphql/schema/core.gen'
|
import { useFollowing } from '~/context/following'
|
||||||
import { router, useRouter } from '../../../stores/router'
|
import { useLocalize } from '~/context/localize'
|
||||||
import { translit } from '../../../utils/ru2en'
|
import { useSession } from '~/context/session'
|
||||||
import { isCyrillic } from '../../../utils/translate'
|
import { Author, FollowingEntity } from '~/graphql/schema/core.gen'
|
||||||
import { Button } from '../../_shared/Button'
|
import { isCyrillic } from '~/intl/translate'
|
||||||
import { CheckButton } from '../../_shared/CheckButton'
|
import { translit } from '~/intl/translit'
|
||||||
import { ConditionalWrapper } from '../../_shared/ConditionalWrapper'
|
import { mediaMatches } from '~/lib/mediaQuery'
|
||||||
import { Icon } from '../../_shared/Icon'
|
|
||||||
import { Userpic } from '../Userpic'
|
import { Userpic } from '../Userpic'
|
||||||
|
|
||||||
import { FollowedInfo } from '../../../pages/types'
|
|
||||||
import stylesButton from '../../_shared/Button/Button.module.scss'
|
|
||||||
import styles from './AuthorBadge.module.scss'
|
import styles from './AuthorBadge.module.scss'
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
author: Author
|
author: Author
|
||||||
minimizeSubscribeButton?: boolean
|
minimize?: boolean
|
||||||
showMessageButton?: boolean
|
showMessageButton?: boolean
|
||||||
iconButtons?: boolean
|
iconButtons?: boolean
|
||||||
nameOnly?: boolean
|
nameOnly?: boolean
|
||||||
inviteView?: boolean
|
inviteView?: boolean
|
||||||
onInvite?: (id: number) => void
|
onInvite?: (id: number) => void
|
||||||
selected?: boolean
|
selected?: boolean
|
||||||
isFollowed?: FollowedInfo
|
subscriptionsMode?: boolean
|
||||||
}
|
}
|
||||||
export const AuthorBadge = (props: Props) => {
|
export const AuthorBadge = (props: Props) => {
|
||||||
const { mediaMatches } = useMediaQuery()
|
const { session, requireAuthentication } = useSession()
|
||||||
const { author, requireAuthentication } = useSession()
|
const author = createMemo<Author>(() => session()?.user?.app_data?.profile as Author)
|
||||||
|
const { follow, unfollow, follows, following } = useFollowing()
|
||||||
const [isMobileView, setIsMobileView] = createSignal(false)
|
const [isMobileView, setIsMobileView] = createSignal(false)
|
||||||
const [isFollowed, setIsFollowed] = createSignal<boolean>()
|
const [isFollowed, setIsFollowed] = createSignal<boolean>(
|
||||||
|
Boolean(follows?.authors?.some((authorEntity) => Boolean(authorEntity.id === props.author?.id)))
|
||||||
|
)
|
||||||
|
createEffect(() => setIsMobileView(!mediaMatches.sm))
|
||||||
|
createEffect(
|
||||||
|
on(
|
||||||
|
[() => follows?.authors, () => props.author, following],
|
||||||
|
([followingAuthors, currentAuthor, _]) => {
|
||||||
|
setIsFollowed(
|
||||||
|
Boolean(followingAuthors?.some((followedAuthor) => followedAuthor.id === currentAuthor?.id))
|
||||||
|
)
|
||||||
|
},
|
||||||
|
{ defer: true }
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
createEffect(() => {
|
const navigate = useNavigate()
|
||||||
setIsMobileView(!mediaMatches.sm)
|
|
||||||
})
|
|
||||||
|
|
||||||
const { setFollowing } = useFollowing()
|
|
||||||
const { changeSearchParams } = useRouter()
|
|
||||||
const { t, formatDate, lang } = useLocalize()
|
const { t, formatDate, lang } = useLocalize()
|
||||||
|
|
||||||
const initChat = () => {
|
const initChat = () => {
|
||||||
// eslint-disable-next-line solid/reactivity
|
// eslint-disable-next-line solid/reactivity
|
||||||
requireAuthentication(() => {
|
requireAuthentication(() => {
|
||||||
openPage(router, 'inbox')
|
props.author?.id && navigate(`/inbox/${props.author?.id}`, { replace: true })
|
||||||
changeSearchParams({
|
|
||||||
initChat: props.author.id.toString(),
|
|
||||||
})
|
|
||||||
}, 'discussions')
|
}, 'discussions')
|
||||||
}
|
}
|
||||||
|
|
||||||
const name = createMemo(() => {
|
const name = createMemo(() => {
|
||||||
if (lang() !== 'ru' && isCyrillic(props.author.name)) {
|
if (lang() !== 'ru' && isCyrillic(props.author.name || '')) {
|
||||||
if (props.author.name === 'Дискурс') {
|
if (props.author.name === 'Дискурс') {
|
||||||
return 'Discours'
|
return 'Discours'
|
||||||
}
|
}
|
||||||
|
|
||||||
return translit(props.author.name)
|
return translit(props.author.name || '')
|
||||||
}
|
}
|
||||||
|
|
||||||
return props.author.name
|
return props.author.name
|
||||||
})
|
})
|
||||||
|
|
||||||
createEffect(
|
|
||||||
on(
|
|
||||||
() => props.isFollowed,
|
|
||||||
() => {
|
|
||||||
setIsFollowed(props.isFollowed?.value)
|
|
||||||
},
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
const handleFollowClick = () => {
|
const handleFollowClick = () => {
|
||||||
const value = !isFollowed()
|
requireAuthentication(async () => {
|
||||||
requireAuthentication(() => {
|
const handle = isFollowed() ? unfollow : follow
|
||||||
setIsFollowed(value)
|
await handle(FollowingEntity.Author, props.author.slug)
|
||||||
setFollowing(FollowingEntity.Author, props.author.slug, value)
|
}, 'follow')
|
||||||
}, 'subscribe')
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
@ -90,14 +83,14 @@ export const AuthorBadge = (props: Props) => {
|
||||||
<Userpic
|
<Userpic
|
||||||
hasLink={true}
|
hasLink={true}
|
||||||
size={isMobileView() ? 'M' : 'L'}
|
size={isMobileView() ? 'M' : 'L'}
|
||||||
name={name()}
|
name={name() || ''}
|
||||||
userpic={props.author.pic}
|
userpic={props.author.pic || ''}
|
||||||
slug={props.author.slug}
|
slug={props.author.slug}
|
||||||
/>
|
/>
|
||||||
<ConditionalWrapper
|
<ConditionalWrapper
|
||||||
condition={!props.inviteView}
|
condition={!props.inviteView}
|
||||||
wrapper={(children) => (
|
wrapper={(children) => (
|
||||||
<a href={`/author/${props.author.slug}`} class={styles.info}>
|
<a href={`/@${props.author.slug}`} class={styles.info}>
|
||||||
{children}
|
{children}
|
||||||
</a>
|
</a>
|
||||||
)}
|
)}
|
||||||
|
@ -110,22 +103,25 @@ export const AuthorBadge = (props: Props) => {
|
||||||
fallback={
|
fallback={
|
||||||
<div class={styles.bio}>
|
<div class={styles.bio}>
|
||||||
{t('Registered since {date}', {
|
{t('Registered since {date}', {
|
||||||
date: formatDate(new Date(props.author.created_at * 1000)),
|
date: formatDate(new Date((props.author.created_at || 0) * 1000))
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<Match when={props.author.bio}>
|
<Match when={props.author.bio}>
|
||||||
<div class={clsx('text-truncate', styles.bio)} innerHTML={props.author.bio} />
|
<div class={clsx('text-truncate', styles.bio)} innerHTML={props.author.bio || ''} />
|
||||||
</Match>
|
</Match>
|
||||||
</Switch>
|
</Switch>
|
||||||
<Show when={props.author?.stat}>
|
<Show when={props.author?.stat && !props.subscriptionsMode}>
|
||||||
<div class={styles.bio}>
|
<div class={styles.bio}>
|
||||||
<Show when={props.author?.stat.shouts > 0}>
|
<Show when={(props.author?.stat?.shouts || 0) > 0}>
|
||||||
<div>{t('PublicationsWithCount', { count: props.author.stat?.shouts ?? 0 })}</div>
|
<div>{t('some posts', { count: props.author.stat?.shouts ?? 0 })}</div>
|
||||||
</Show>
|
</Show>
|
||||||
<Show when={props.author?.stat.followers > 0}>
|
<Show when={(props.author?.stat?.comments || 0) > 0}>
|
||||||
<div>{t('FollowersWithCount', { count: props.author.stat?.followers ?? 0 })}</div>
|
<div>{t('some comments', { count: props.author.stat?.comments ?? 0 })}</div>
|
||||||
|
</Show>
|
||||||
|
<Show when={(props.author?.stat?.followers || 0) > 0}>
|
||||||
|
<div>{t('some followers', { count: props.author.stat?.followers ?? 0 })}</div>
|
||||||
</Show>
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
</Show>
|
</Show>
|
||||||
|
@ -134,55 +130,11 @@ export const AuthorBadge = (props: Props) => {
|
||||||
</div>
|
</div>
|
||||||
<Show when={props.author.slug !== author()?.slug && !props.nameOnly}>
|
<Show when={props.author.slug !== author()?.slug && !props.nameOnly}>
|
||||||
<div class={styles.actions}>
|
<div class={styles.actions}>
|
||||||
<Show
|
<FollowingButton
|
||||||
when={!props.minimizeSubscribeButton}
|
action={handleFollowClick}
|
||||||
fallback={<CheckButton text={t('Follow')} checked={isFollowed()} onClick={handleFollowClick} />}
|
isFollowed={isFollowed()}
|
||||||
>
|
actionMessageType={following()?.slug === props.author.slug ? following()?.type : undefined}
|
||||||
<Show
|
/>
|
||||||
when={isFollowed()}
|
|
||||||
fallback={
|
|
||||||
<Button
|
|
||||||
variant={props.iconButtons ? 'secondary' : 'bordered'}
|
|
||||||
size="S"
|
|
||||||
value={
|
|
||||||
<Show when={props.iconButtons} fallback={t('Subscribe')}>
|
|
||||||
<Icon name="author-subscribe" class={stylesButton.icon} />
|
|
||||||
</Show>
|
|
||||||
}
|
|
||||||
onClick={handleFollowClick}
|
|
||||||
isSubscribeButton={true}
|
|
||||||
class={clsx(styles.actionButton, {
|
|
||||||
[styles.iconed]: props.iconButtons,
|
|
||||||
[stylesButton.subscribed]: isFollowed(),
|
|
||||||
})}
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<Button
|
|
||||||
variant={props.iconButtons ? 'secondary' : 'bordered'}
|
|
||||||
size="S"
|
|
||||||
value={
|
|
||||||
<Show
|
|
||||||
when={props.iconButtons}
|
|
||||||
fallback={
|
|
||||||
<>
|
|
||||||
<span class={styles.actionButtonLabel}>{t('Following')}</span>
|
|
||||||
<span class={styles.actionButtonLabelHovered}>{t('Unfollow')}</span>
|
|
||||||
</>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<Icon name="author-unsubscribe" class={stylesButton.icon} />
|
|
||||||
</Show>
|
|
||||||
}
|
|
||||||
onClick={handleFollowClick}
|
|
||||||
isSubscribeButton={true}
|
|
||||||
class={clsx(styles.actionButton, {
|
|
||||||
[styles.iconed]: props.iconButtons,
|
|
||||||
[stylesButton.subscribed]: isFollowed(),
|
|
||||||
})}
|
|
||||||
/>
|
|
||||||
</Show>
|
|
||||||
</Show>
|
|
||||||
<Show when={props.showMessageButton}>
|
<Show when={props.showMessageButton}>
|
||||||
<Button
|
<Button
|
||||||
variant={props.iconButtons ? 'secondary' : 'bordered'}
|
variant={props.iconButtons ? 'secondary' : 'bordered'}
|
||||||
|
@ -197,8 +149,8 @@ export const AuthorBadge = (props: Props) => {
|
||||||
<Show when={props.inviteView}>
|
<Show when={props.inviteView}>
|
||||||
<CheckButton
|
<CheckButton
|
||||||
text={t('Invite')}
|
text={t('Invite')}
|
||||||
checked={props.selected}
|
checked={Boolean(props.selected)}
|
||||||
onClick={() => props.onInvite(props.author.id)}
|
onClick={() => props.onInvite?.(props.author.id)}
|
||||||
/>
|
/>
|
||||||
</Show>
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -1,4 +1,16 @@
|
||||||
.author {
|
.author {
|
||||||
|
@include media-breakpoint-down(md) {
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
@include media-breakpoint-up(md) {
|
||||||
|
margin-bottom: 2.4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
@include media-breakpoint-down(lg) {
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
flex-flow: row nowrap;
|
flex-flow: row nowrap;
|
||||||
|
@ -8,19 +20,11 @@
|
||||||
margin-bottom: 0;
|
margin-bottom: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
@include media-breakpoint-down(md) {
|
|
||||||
justify-content: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
@include media-breakpoint-up(md) {
|
|
||||||
margin-bottom: 2.4rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.authorName {
|
.authorName {
|
||||||
@include font-size(4rem);
|
@include font-size(4rem);
|
||||||
|
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
margin-bottom: 0.2em;
|
margin-bottom: 1.2rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.authorAbout {
|
.authorAbout {
|
||||||
|
@ -32,15 +36,15 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
.authorActions {
|
.authorActions {
|
||||||
|
@include media-breakpoint-down(md) {
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
margin: 2rem -0.8rem 0 0;
|
margin: 2rem -0.8rem 0 0;
|
||||||
padding-left: 0;
|
padding-left: 0;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: row;
|
flex-direction: row;
|
||||||
gap: 1rem;
|
gap: 1rem;
|
||||||
|
|
||||||
@include media-breakpoint-down(md) {
|
|
||||||
justify-content: center;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.authorActionsLabel {
|
.authorActionsLabel {
|
||||||
|
@ -50,27 +54,24 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
.authorActionsLabelMobile {
|
.authorActionsLabelMobile {
|
||||||
display: none;
|
|
||||||
|
|
||||||
@include media-breakpoint-down(sm) {
|
@include media-breakpoint-down(sm) {
|
||||||
display: block;
|
display: block;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.authorDetails {
|
.authorDetails {
|
||||||
display: block;
|
|
||||||
margin-bottom: 0;
|
|
||||||
|
|
||||||
@include media-breakpoint-down(md) {
|
@include media-breakpoint-down(md) {
|
||||||
flex: 1 100%;
|
flex: 1 100%;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.listWrapper & {
|
.listWrapper & {
|
||||||
align-items: flex-start;
|
|
||||||
margin-bottom: 2rem;
|
|
||||||
|
|
||||||
@include media-breakpoint-down(sm) {
|
@include media-breakpoint-down(sm) {
|
||||||
margin-bottom: 3rem;
|
margin-bottom: 3rem;
|
||||||
}
|
}
|
||||||
|
@ -79,6 +80,9 @@
|
||||||
margin-bottom: 3rem;
|
margin-bottom: 3rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
align-items: flex-start;
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
|
||||||
.circlewrap {
|
.circlewrap {
|
||||||
margin-top: 1rem;
|
margin-top: 1rem;
|
||||||
}
|
}
|
||||||
|
@ -88,10 +92,6 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@include media-breakpoint-down(lg) {
|
|
||||||
flex-wrap: wrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.buttonWriteMessage {
|
.buttonWriteMessage {
|
||||||
border-radius: 0.8rem;
|
border-radius: 0.8rem;
|
||||||
padding-bottom: 0.6rem;
|
padding-bottom: 0.6rem;
|
||||||
|
@ -100,8 +100,6 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
.authorDetails {
|
.authorDetails {
|
||||||
flex: 0 0 auto;
|
|
||||||
|
|
||||||
@include media-breakpoint-up(sm) {
|
@include media-breakpoint-up(sm) {
|
||||||
align-items: center;
|
align-items: center;
|
||||||
display: flex;
|
display: flex;
|
||||||
|
@ -118,12 +116,11 @@
|
||||||
flex-wrap: nowrap;
|
flex-wrap: nowrap;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
flex: 0 0 auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.authorDetailsWrapper {
|
.authorDetailsWrapper {
|
||||||
flex: 1 0;
|
|
||||||
position: relative;
|
|
||||||
|
|
||||||
@include media-breakpoint-up(sm) {
|
@include media-breakpoint-up(sm) {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
}
|
}
|
||||||
|
@ -139,6 +136,9 @@
|
||||||
@include media-breakpoint-up(md) {
|
@include media-breakpoint-up(md) {
|
||||||
padding-right: 1.2rem;
|
padding-right: 1.2rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
flex: 1 0;
|
||||||
|
position: relative;
|
||||||
}
|
}
|
||||||
|
|
||||||
.authorName {
|
.authorName {
|
||||||
|
@ -160,6 +160,15 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
.authorSubscribeSocial {
|
.authorSubscribeSocial {
|
||||||
|
@include media-breakpoint-down(sm) {
|
||||||
|
flex: 1 100%;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
@include media-breakpoint-down(md) {
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
align-items: center;
|
align-items: center;
|
||||||
display: flex;
|
display: flex;
|
||||||
margin: 0.5rem 0 2rem -0.4rem;
|
margin: 0.5rem 0 2rem -0.4rem;
|
||||||
|
@ -175,7 +184,7 @@
|
||||||
width: 24px;
|
width: 24px;
|
||||||
|
|
||||||
&::before {
|
&::before {
|
||||||
background-image: url(/icons/user-link-default.svg);
|
background-image: url('/icons/user-link-default.svg');
|
||||||
background-repeat: no-repeat;
|
background-repeat: no-repeat;
|
||||||
background-position: 50% 50%;
|
background-position: 50% 50%;
|
||||||
background-size: contain;
|
background-size: contain;
|
||||||
|
@ -209,7 +218,7 @@
|
||||||
|
|
||||||
&[href*='facebook.com/'] {
|
&[href*='facebook.com/'] {
|
||||||
&::before {
|
&::before {
|
||||||
background-image: url(/icons/user-link-facebook.svg);
|
background-image: url('/icons/user-link-facebook.svg');
|
||||||
}
|
}
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
|
@ -221,7 +230,7 @@
|
||||||
|
|
||||||
&[href*='twitter.com/'] {
|
&[href*='twitter.com/'] {
|
||||||
&::before {
|
&::before {
|
||||||
background-image: url(/icons/user-link-twitter.svg);
|
background-image: url('/icons/user-link-twitter.svg');
|
||||||
}
|
}
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
|
@ -234,7 +243,7 @@
|
||||||
&[href*='telegram.com/'],
|
&[href*='telegram.com/'],
|
||||||
&[href*='t.me/'] {
|
&[href*='t.me/'] {
|
||||||
&::before {
|
&::before {
|
||||||
background-image: url(/icons/user-link-telegram.svg);
|
background-image: url('/icons/user-link-telegram.svg');
|
||||||
}
|
}
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
|
@ -247,7 +256,7 @@
|
||||||
&[href*='vk.cc/'],
|
&[href*='vk.cc/'],
|
||||||
&[href*='vk.com/'] {
|
&[href*='vk.com/'] {
|
||||||
&::before {
|
&::before {
|
||||||
background-image: url(/icons/user-link-vk.svg);
|
background-image: url('/icons/user-link-vk.svg');
|
||||||
}
|
}
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
|
@ -259,7 +268,7 @@
|
||||||
|
|
||||||
&[href*='tumblr.com/'] {
|
&[href*='tumblr.com/'] {
|
||||||
&::before {
|
&::before {
|
||||||
background-image: url(/icons/user-link-tumblr.svg);
|
background-image: url('/icons/user-link-tumblr.svg');
|
||||||
}
|
}
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
|
@ -271,7 +280,7 @@
|
||||||
|
|
||||||
&[href*='instagram.com/'] {
|
&[href*='instagram.com/'] {
|
||||||
&::before {
|
&::before {
|
||||||
background-image: url(/icons/user-link-instagram.svg);
|
background-image: url('/icons/user-link-instagram.svg');
|
||||||
}
|
}
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
|
@ -283,7 +292,7 @@
|
||||||
|
|
||||||
&[href*='behance.net/'] {
|
&[href*='behance.net/'] {
|
||||||
&::before {
|
&::before {
|
||||||
background-image: url(/icons/user-link-behance.svg);
|
background-image: url('/icons/user-link-behance.svg');
|
||||||
}
|
}
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
|
@ -295,7 +304,7 @@
|
||||||
|
|
||||||
&[href*='dribbble.com/'] {
|
&[href*='dribbble.com/'] {
|
||||||
&::before {
|
&::before {
|
||||||
background-image: url(/icons/user-link-dribbble.svg);
|
background-image: url('/icons/user-link-dribbble.svg');
|
||||||
}
|
}
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
|
@ -307,7 +316,7 @@
|
||||||
|
|
||||||
&[href*='github.com/'] {
|
&[href*='github.com/'] {
|
||||||
&::before {
|
&::before {
|
||||||
background-image: url(/icons/user-link-github.svg);
|
background-image: url('/icons/user-link-github.svg');
|
||||||
}
|
}
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
|
@ -319,7 +328,7 @@
|
||||||
|
|
||||||
&[href*='linkedin.com/'] {
|
&[href*='linkedin.com/'] {
|
||||||
&::before {
|
&::before {
|
||||||
background-image: url(/icons/user-link-linkedin.svg);
|
background-image: url('/icons/user-link-linkedin.svg');
|
||||||
}
|
}
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
|
@ -331,7 +340,7 @@
|
||||||
|
|
||||||
&[href*='medium.com/'] {
|
&[href*='medium.com/'] {
|
||||||
&::before {
|
&::before {
|
||||||
background-image: url(/icons/user-link-medium.svg);
|
background-image: url('/icons/user-link-medium.svg');
|
||||||
}
|
}
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
|
@ -343,7 +352,7 @@
|
||||||
|
|
||||||
&[href*='ok.ru/'] {
|
&[href*='ok.ru/'] {
|
||||||
&::before {
|
&::before {
|
||||||
background-image: url(/icons/user-link-ok.svg);
|
background-image: url('/icons/user-link-ok.svg');
|
||||||
}
|
}
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
|
@ -355,7 +364,7 @@
|
||||||
|
|
||||||
&[href*='pinterest.com/'] {
|
&[href*='pinterest.com/'] {
|
||||||
&::before {
|
&::before {
|
||||||
background-image: url(/icons/user-link-pinterest.svg);
|
background-image: url('/icons/user-link-pinterest.svg');
|
||||||
}
|
}
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
|
@ -367,7 +376,7 @@
|
||||||
|
|
||||||
&[href*='reddit.com/'] {
|
&[href*='reddit.com/'] {
|
||||||
&::before {
|
&::before {
|
||||||
background-image: url(/icons/user-link-reddit.svg);
|
background-image: url('/icons/user-link-reddit.svg');
|
||||||
}
|
}
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
|
@ -379,7 +388,7 @@
|
||||||
|
|
||||||
&[href*='tiktok.com/'] {
|
&[href*='tiktok.com/'] {
|
||||||
&::before {
|
&::before {
|
||||||
background-image: url(/icons/user-link-tiktok.svg);
|
background-image: url('/icons/user-link-tiktok.svg');
|
||||||
}
|
}
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
|
@ -392,7 +401,7 @@
|
||||||
&[href*='youtube.com/'],
|
&[href*='youtube.com/'],
|
||||||
&[href*='youtu.be/'] {
|
&[href*='youtu.be/'] {
|
||||||
&::before {
|
&::before {
|
||||||
background-image: url(/icons/user-link-youtube.svg);
|
background-image: url('/icons/user-link-youtube.svg');
|
||||||
}
|
}
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
|
@ -404,7 +413,7 @@
|
||||||
|
|
||||||
&[href*='dzen.ru/'] {
|
&[href*='dzen.ru/'] {
|
||||||
&::before {
|
&::before {
|
||||||
background-image: url(/icons/user-link-dzen.svg);
|
background-image: url('/icons/user-link-dzen.svg');
|
||||||
}
|
}
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
|
@ -415,78 +424,24 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@include media-breakpoint-down(sm) {
|
|
||||||
flex: 1 100%;
|
|
||||||
justify-content: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
@include media-breakpoint-down(md) {
|
|
||||||
justify-content: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
a:link {
|
a:link {
|
||||||
border: none;
|
border: none;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.subscribersContainer {
|
|
||||||
display: flex;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
font-size: 1.4rem;
|
|
||||||
margin-top: 1.5rem;
|
|
||||||
|
|
||||||
@include media-breakpoint-down(md) {
|
|
||||||
justify-content: center;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.subscribers {
|
|
||||||
align-items: center;
|
|
||||||
cursor: pointer;
|
|
||||||
display: inline-flex;
|
|
||||||
margin: 0 2% 1rem;
|
|
||||||
vertical-align: top;
|
|
||||||
border-bottom: unset !important;
|
|
||||||
|
|
||||||
&:first-child {
|
|
||||||
margin-left: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
&:last-child {
|
|
||||||
margin-right: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.subscribersItem {
|
|
||||||
position: relative;
|
|
||||||
|
|
||||||
&:nth-child(1) {
|
|
||||||
z-index: 2;
|
|
||||||
}
|
|
||||||
|
|
||||||
&:nth-child(2) {
|
|
||||||
z-index: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
&:not(:last-child) {
|
|
||||||
margin-right: -4px;
|
|
||||||
box-shadow: 0 0 0 1px var(--background-color);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.subscribersCounter {
|
|
||||||
font-weight: 500;
|
|
||||||
margin-left: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
background: none !important;
|
|
||||||
|
|
||||||
.subscribersCounter {
|
|
||||||
background: var(--background-color-invert);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.listWrapper {
|
.listWrapper {
|
||||||
max-height: 70vh;
|
max-height: 70vh;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.subscribersContainer {
|
||||||
|
@include media-breakpoint-down(md) {
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
font-size: 1.4rem;
|
||||||
|
gap: 1rem;
|
||||||
|
margin-top: 0;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
|
@ -1,97 +1,88 @@
|
||||||
import type { Author, Community } from '../../../graphql/schema/core.gen'
|
import { redirect, useNavigate } from '@solidjs/router'
|
||||||
|
|
||||||
import { openPage, redirectPage } from '@nanostores/router'
|
|
||||||
import { clsx } from 'clsx'
|
import { clsx } from 'clsx'
|
||||||
import { For, Show, createEffect, createMemo, createSignal, onMount } from 'solid-js'
|
import { For, Show, createEffect, createMemo, createSignal, on } from 'solid-js'
|
||||||
|
import { Button } from '~/components/_shared/Button'
|
||||||
import { useFollowing } from '../../../context/following'
|
import stylesButton from '~/components/_shared/Button/Button.module.scss'
|
||||||
import { useLocalize } from '../../../context/localize'
|
import { FollowingCounters } from '~/components/_shared/FollowingCounters/FollowingCounters'
|
||||||
import { useSession } from '../../../context/session'
|
import { ShowOnlyOnClient } from '~/components/_shared/ShowOnlyOnClient'
|
||||||
import { FollowingEntity, Topic } from '../../../graphql/schema/core.gen'
|
import { FollowsFilter, useFollowing } from '~/context/following'
|
||||||
import { SubscriptionFilter } from '../../../pages/types'
|
import { useLocalize } from '~/context/localize'
|
||||||
import { router, useRouter } from '../../../stores/router'
|
import { useSession } from '~/context/session'
|
||||||
import { isAuthor } from '../../../utils/isAuthor'
|
import type { Author, Community } from '~/graphql/schema/core.gen'
|
||||||
import { translit } from '../../../utils/ru2en'
|
import { FollowingEntity, Topic } from '~/graphql/schema/core.gen'
|
||||||
import { isCyrillic } from '../../../utils/translate'
|
import { isCyrillic } from '~/intl/translate'
|
||||||
|
import { translit } from '~/intl/translit'
|
||||||
import { SharePopup, getShareUrl } from '../../Article/SharePopup'
|
import { SharePopup, getShareUrl } from '../../Article/SharePopup'
|
||||||
import { Modal } from '../../Nav/Modal'
|
|
||||||
import { TopicBadge } from '../../Topic/TopicBadge'
|
import { TopicBadge } from '../../Topic/TopicBadge'
|
||||||
import { Button } from '../../_shared/Button'
|
import { Modal } from '../../_shared/Modal'
|
||||||
import { ShowOnlyOnClient } from '../../_shared/ShowOnlyOnClient'
|
|
||||||
import { AuthorBadge } from '../AuthorBadge'
|
import { AuthorBadge } from '../AuthorBadge'
|
||||||
import { Userpic } from '../Userpic'
|
import { Userpic } from '../Userpic'
|
||||||
|
|
||||||
import stylesButton from '../../_shared/Button/Button.module.scss'
|
|
||||||
import styles from './AuthorCard.module.scss'
|
import styles from './AuthorCard.module.scss'
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
author: Author
|
author: Author
|
||||||
followers?: Author[]
|
followers?: Author[]
|
||||||
following?: Array<Author | Topic>
|
flatFollows?: Array<Author | Topic>
|
||||||
}
|
}
|
||||||
|
|
||||||
export const AuthorCard = (props: Props) => {
|
export const AuthorCard = (props: Props) => {
|
||||||
const { t, lang } = useLocalize()
|
const { t, lang } = useLocalize()
|
||||||
const { author, isSessionLoaded, requireAuthentication } = useSession()
|
const navigate = useNavigate()
|
||||||
|
const { session, isSessionLoaded, requireAuthentication } = useSession()
|
||||||
|
const author = createMemo<Author>(() => session()?.user?.app_data?.profile as Author)
|
||||||
const [authorSubs, setAuthorSubs] = createSignal<Array<Author | Topic | Community>>([])
|
const [authorSubs, setAuthorSubs] = createSignal<Array<Author | Topic | Community>>([])
|
||||||
const [subscriptionFilter, setSubscriptionFilter] = createSignal<SubscriptionFilter>('all')
|
const [followsFilter, setFollowsFilter] = createSignal<FollowsFilter>('all')
|
||||||
const [isFollowed, setIsFollowed] = createSignal<boolean>()
|
const [isFollowed, setIsFollowed] = createSignal<boolean>()
|
||||||
const isProfileOwner = createMemo(() => author()?.slug === props.author.slug)
|
const isProfileOwner = createMemo(() => author()?.slug === props.author.slug)
|
||||||
const { setFollowing, isOwnerSubscribed } = useFollowing()
|
const { follow, unfollow, follows, following } = useFollowing() // viewer's followings
|
||||||
|
|
||||||
onMount(() => {
|
|
||||||
setAuthorSubs(props.following)
|
|
||||||
})
|
|
||||||
|
|
||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
setIsFollowed(isOwnerSubscribed(props.author?.id))
|
if (!(follows && props.author)) return
|
||||||
|
const followed = follows?.authors?.some((authorEntity) => authorEntity.id === props.author?.id)
|
||||||
|
setIsFollowed(followed)
|
||||||
})
|
})
|
||||||
|
|
||||||
const name = createMemo(() => {
|
const name = createMemo(() => {
|
||||||
if (lang() !== 'ru' && isCyrillic(props.author.name)) {
|
if (lang() !== 'ru' && isCyrillic(props.author?.name || '')) {
|
||||||
if (props.author.name === 'Дискурс') {
|
if (props.author.name === 'Дискурс') {
|
||||||
return 'Discours'
|
return 'Discours'
|
||||||
}
|
}
|
||||||
return translit(props.author.name)
|
return translit(props.author?.name || '')
|
||||||
}
|
}
|
||||||
return props.author.name
|
return props.author.name
|
||||||
})
|
})
|
||||||
|
|
||||||
// TODO: reimplement AuthorCard
|
|
||||||
const { changeSearchParams } = useRouter()
|
|
||||||
const initChat = () => {
|
const initChat = () => {
|
||||||
// eslint-disable-next-line solid/reactivity
|
// eslint-disable-next-line solid/reactivity
|
||||||
requireAuthentication(() => {
|
requireAuthentication(() => {
|
||||||
openPage(router, 'inbox')
|
props.author?.id && navigate(`/inbox/${props.author?.id}`, { replace: true })
|
||||||
changeSearchParams({
|
|
||||||
initChat: props.author.id.toString(),
|
|
||||||
})
|
|
||||||
}, 'discussions')
|
}, 'discussions')
|
||||||
}
|
}
|
||||||
|
|
||||||
createEffect(() => {
|
createEffect(
|
||||||
if (props.following) {
|
on(followsFilter, (f = 'all') => {
|
||||||
if (subscriptionFilter() === 'authors') {
|
const subs =
|
||||||
setAuthorSubs(props.following.filter((s) => 'name' in s))
|
f !== 'all'
|
||||||
} else if (subscriptionFilter() === 'topics') {
|
? follows[f as keyof typeof follows]
|
||||||
setAuthorSubs(props.following.filter((s) => 'title' in s))
|
: [...(follows.topics || []), ...(follows.authors || [])]
|
||||||
} else if (subscriptionFilter() === 'communities') {
|
setAuthorSubs(subs || [])
|
||||||
setAuthorSubs(props.following.filter((s) => 'title' in s))
|
})
|
||||||
} else {
|
)
|
||||||
setAuthorSubs(props.following)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
const handleFollowClick = () => {
|
const handleFollowClick = () => {
|
||||||
const value = !isFollowed()
|
|
||||||
requireAuthentication(() => {
|
requireAuthentication(() => {
|
||||||
setIsFollowed(value)
|
isFollowed()
|
||||||
setFollowing(FollowingEntity.Author, props.author.slug, value)
|
? unfollow(FollowingEntity.Author, props.author.slug)
|
||||||
}, 'subscribe')
|
: follow(FollowingEntity.Author, props.author.slug)
|
||||||
|
}, 'follow')
|
||||||
}
|
}
|
||||||
|
|
||||||
const followButtonText = createMemo(() => {
|
const followButtonText = createMemo(() => {
|
||||||
if (isOwnerSubscribed(props.author?.id)) {
|
if (following()?.slug === props.author.slug) {
|
||||||
|
return following()?.type === 'follow' ? t('Following...') : t('Unfollowing...')
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isFollowed()) {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<span class={stylesButton.buttonSubscribeLabel}>{t('Following')}</span>
|
<span class={stylesButton.buttonSubscribeLabel}>{t('Following')}</span>
|
||||||
|
@ -102,13 +93,82 @@ export const AuthorCard = (props: Props) => {
|
||||||
return t('Follow')
|
return t('Follow')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const FollowersModalView = () => (
|
||||||
|
<>
|
||||||
|
<h2>{t('Followers')}</h2>
|
||||||
|
<div class={styles.listWrapper}>
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-24">
|
||||||
|
<For each={props.followers}>{(follower: Author) => <AuthorBadge author={follower} />}</For>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
|
||||||
|
const FollowingModalView = () => (
|
||||||
|
<>
|
||||||
|
<h2>{t('Subscriptions')}</h2>
|
||||||
|
<ul class="view-switcher">
|
||||||
|
<li
|
||||||
|
class={clsx({
|
||||||
|
'view-switcher__item--selected': followsFilter() === 'all'
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<button type="button" onClick={() => setFollowsFilter('all')}>
|
||||||
|
{t('All')}
|
||||||
|
</button>
|
||||||
|
<span class="view-switcher__counter">{props.flatFollows?.length}</span>
|
||||||
|
</li>
|
||||||
|
<li
|
||||||
|
class={clsx({
|
||||||
|
'view-switcher__item--selected': followsFilter() === 'authors'
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<button type="button" onClick={() => setFollowsFilter('authors')}>
|
||||||
|
{t('Authors')}
|
||||||
|
</button>
|
||||||
|
<span class="view-switcher__counter">{props.flatFollows?.filter((s) => 'name' in s).length}</span>
|
||||||
|
</li>
|
||||||
|
<li
|
||||||
|
class={clsx({
|
||||||
|
'view-switcher__item--selected': followsFilter() === 'topics'
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<button type="button" onClick={() => setFollowsFilter('topics')}>
|
||||||
|
{t('Topics')}
|
||||||
|
</button>
|
||||||
|
<span class="view-switcher__counter">
|
||||||
|
{props.flatFollows?.filter((s) => 'title' in s).length}
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
<br />
|
||||||
|
<div class={styles.listWrapper}>
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-24">
|
||||||
|
<For each={authorSubs()}>
|
||||||
|
{(subscription) =>
|
||||||
|
'name' in subscription ? (
|
||||||
|
<AuthorBadge author={subscription as Author} subscriptionsMode={true} />
|
||||||
|
) : (
|
||||||
|
<TopicBadge topic={subscription as Topic} subscriptionsMode={true} />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
</For>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div class={clsx(styles.author, 'row')}>
|
<div class={clsx(styles.author, 'row')}>
|
||||||
<div class="col-md-5">
|
<div class="col-md-5">
|
||||||
<Userpic
|
<Userpic
|
||||||
size={'XL'}
|
size={'XL'}
|
||||||
name={props.author.name}
|
name={props.author.name || ''}
|
||||||
userpic={props.author.pic}
|
userpic={props.author.pic || ''}
|
||||||
slug={props.author.slug}
|
slug={props.author.slug}
|
||||||
class={styles.circlewrap}
|
class={styles.circlewrap}
|
||||||
/>
|
/>
|
||||||
|
@ -117,62 +177,16 @@ export const AuthorCard = (props: Props) => {
|
||||||
<div class={styles.authorDetailsWrapper}>
|
<div class={styles.authorDetailsWrapper}>
|
||||||
<div class={styles.authorName}>{name()}</div>
|
<div class={styles.authorName}>{name()}</div>
|
||||||
<Show when={props.author.bio}>
|
<Show when={props.author.bio}>
|
||||||
<div class={styles.authorAbout} innerHTML={props.author.bio} />
|
<div class={styles.authorAbout} innerHTML={props.author.bio || ''} />
|
||||||
</Show>
|
</Show>
|
||||||
<Show
|
<Show when={(props.followers || [])?.length > 0 || (props.flatFollows || []).length > 0}>
|
||||||
when={
|
|
||||||
(props.followers && props.followers.length > 0) ||
|
|
||||||
(props.following && props.following.length > 0)
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<div class={styles.subscribersContainer}>
|
<div class={styles.subscribersContainer}>
|
||||||
<Show when={props.followers && props.followers.length > 0}>
|
<FollowingCounters
|
||||||
<a href="?m=followers" class={styles.subscribers}>
|
followers={props.followers}
|
||||||
<For each={props.followers.slice(0, 3)}>
|
followersAmount={props.author?.stat?.followers || 0}
|
||||||
{(f) => (
|
following={props.flatFollows}
|
||||||
<Userpic size={'XS'} name={f.name} userpic={f.pic} class={styles.subscribersItem} />
|
followingAmount={props.flatFollows?.length || 0}
|
||||||
)}
|
/>
|
||||||
</For>
|
|
||||||
<div class={styles.subscribersCounter}>
|
|
||||||
{t('SubscriberWithCount', { count: props.followers.length ?? 0 })}
|
|
||||||
</div>
|
|
||||||
</a>
|
|
||||||
</Show>
|
|
||||||
|
|
||||||
<Show when={props.following && props.following.length > 0}>
|
|
||||||
<a href="?m=following" class={styles.subscribers}>
|
|
||||||
<For each={props.following.slice(0, 3)}>
|
|
||||||
{(f) => {
|
|
||||||
if ('name' in f) {
|
|
||||||
return (
|
|
||||||
<Userpic
|
|
||||||
size={'XS'}
|
|
||||||
name={f.name}
|
|
||||||
userpic={f.pic}
|
|
||||||
class={styles.subscribersItem}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
if ('title' in f) {
|
|
||||||
return (
|
|
||||||
<Userpic
|
|
||||||
size={'XS'}
|
|
||||||
name={f.title}
|
|
||||||
userpic={f.pic}
|
|
||||||
class={styles.subscribersItem}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return null
|
|
||||||
}}
|
|
||||||
</For>
|
|
||||||
<div class={styles.subscribersCounter}>
|
|
||||||
{t('SubscriptionWithCount', { count: props?.following.length ?? 0 })}
|
|
||||||
</div>
|
|
||||||
</a>
|
|
||||||
</Show>
|
|
||||||
</div>
|
</div>
|
||||||
</Show>
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
|
@ -181,15 +195,15 @@ export const AuthorCard = (props: Props) => {
|
||||||
<Show when={props.author.links && props.author.links.length > 0}>
|
<Show when={props.author.links && props.author.links.length > 0}>
|
||||||
<div class={styles.authorSubscribeSocial}>
|
<div class={styles.authorSubscribeSocial}>
|
||||||
<For each={props.author.links}>
|
<For each={props.author.links}>
|
||||||
{(link) => (
|
{(link: string | null) => (
|
||||||
<a
|
<a
|
||||||
class={styles.socialLink}
|
class={styles.socialLink}
|
||||||
href={link.startsWith('http') ? link : `https://${link}`}
|
href={link?.startsWith('http') ? link : `https://${link}`}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="nofollow noopener noreferrer"
|
rel="nofollow noopener noreferrer"
|
||||||
>
|
>
|
||||||
<span class={styles.authorSubscribeSocialLabel}>
|
<span class={styles.authorSubscribeSocialLabel}>
|
||||||
{link.startsWith('http') ? link : `https://${link}`}
|
{link?.startsWith('http') ? link : `https://${link}`}
|
||||||
</span>
|
</span>
|
||||||
</a>
|
</a>
|
||||||
)}
|
)}
|
||||||
|
@ -200,13 +214,14 @@ export const AuthorCard = (props: Props) => {
|
||||||
when={isProfileOwner()}
|
when={isProfileOwner()}
|
||||||
fallback={
|
fallback={
|
||||||
<div class={styles.authorActions}>
|
<div class={styles.authorActions}>
|
||||||
<Show when={authorSubs().length}>
|
<Show when={authorSubs()?.length}>
|
||||||
<Button
|
<Button
|
||||||
onClick={handleFollowClick}
|
onClick={handleFollowClick}
|
||||||
|
disabled={Boolean(following())}
|
||||||
value={followButtonText()}
|
value={followButtonText()}
|
||||||
isSubscribeButton={true}
|
isSubscribeButton={true}
|
||||||
class={clsx({
|
class={clsx({
|
||||||
[stylesButton.subscribed]: isFollowed(),
|
[stylesButton.followed]: isFollowed()
|
||||||
})}
|
})}
|
||||||
/>
|
/>
|
||||||
</Show>
|
</Show>
|
||||||
|
@ -222,7 +237,7 @@ export const AuthorCard = (props: Props) => {
|
||||||
<div class={styles.authorActions}>
|
<div class={styles.authorActions}>
|
||||||
<Button
|
<Button
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
onClick={() => redirectPage(router, 'profileSettings')}
|
onClick={() => redirect('/settings')}
|
||||||
value={
|
value={
|
||||||
<>
|
<>
|
||||||
<span class={styles.authorActionsLabel}>{t('Edit profile')}</span>
|
<span class={styles.authorActionsLabel}>{t('Edit profile')}</span>
|
||||||
|
@ -231,10 +246,12 @@ export const AuthorCard = (props: Props) => {
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<SharePopup
|
<SharePopup
|
||||||
title={props.author.name}
|
title={props.author.name || ''}
|
||||||
description={props.author.bio}
|
description={props.author.bio || ''}
|
||||||
imageUrl={props.author.pic}
|
imageUrl={props.author.pic || ''}
|
||||||
shareUrl={getShareUrl({ pathname: `/author/${props.author.slug}` })}
|
shareUrl={getShareUrl({
|
||||||
|
pathname: `/@${props.author.slug}`
|
||||||
|
})}
|
||||||
trigger={<Button variant="secondary" value={t('Share')} />}
|
trigger={<Button variant="secondary" value={t('Share')} />}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
@ -243,85 +260,12 @@ export const AuthorCard = (props: Props) => {
|
||||||
</ShowOnlyOnClient>
|
</ShowOnlyOnClient>
|
||||||
<Show when={props.followers}>
|
<Show when={props.followers}>
|
||||||
<Modal variant="medium" isResponsive={true} name="followers" maxHeight>
|
<Modal variant="medium" isResponsive={true} name="followers" maxHeight>
|
||||||
<>
|
<FollowersModalView />
|
||||||
<h2>{t('Followers')}</h2>
|
|
||||||
<div class={styles.listWrapper}>
|
|
||||||
<div class="row">
|
|
||||||
<div class="col-24">
|
|
||||||
<For each={props.followers}>
|
|
||||||
{(follower: Author) => (
|
|
||||||
<AuthorBadge
|
|
||||||
author={follower}
|
|
||||||
isFollowed={{
|
|
||||||
loaded: Boolean(authorSubs()),
|
|
||||||
value: isOwnerSubscribed(follower.id),
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</For>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
</Modal>
|
</Modal>
|
||||||
</Show>
|
</Show>
|
||||||
<Show when={props.following}>
|
<Show when={props.flatFollows}>
|
||||||
<Modal variant="medium" isResponsive={true} name="following" maxHeight>
|
<Modal variant="medium" isResponsive={true} name="following" maxHeight>
|
||||||
<>
|
<FollowingModalView />
|
||||||
<h2>{t('Subscriptions')}</h2>
|
|
||||||
<ul class="view-switcher">
|
|
||||||
<li class={clsx({ 'view-switcher__item--selected': subscriptionFilter() === 'all' })}>
|
|
||||||
<button type="button" onClick={() => setSubscriptionFilter('all')}>
|
|
||||||
{t('All')}
|
|
||||||
</button>
|
|
||||||
<span class="view-switcher__counter">{props.following.length}</span>
|
|
||||||
</li>
|
|
||||||
<li class={clsx({ 'view-switcher__item--selected': subscriptionFilter() === 'authors' })}>
|
|
||||||
<button type="button" onClick={() => setSubscriptionFilter('authors')}>
|
|
||||||
{t('Authors')}
|
|
||||||
</button>
|
|
||||||
<span class="view-switcher__counter">
|
|
||||||
{props.following.filter((s) => 'name' in s).length}
|
|
||||||
</span>
|
|
||||||
</li>
|
|
||||||
<li class={clsx({ 'view-switcher__item--selected': subscriptionFilter() === 'topics' })}>
|
|
||||||
<button type="button" onClick={() => setSubscriptionFilter('topics')}>
|
|
||||||
{t('Topics')}
|
|
||||||
</button>
|
|
||||||
<span class="view-switcher__counter">
|
|
||||||
{props.following.filter((s) => 'title' in s).length}
|
|
||||||
</span>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
<br />
|
|
||||||
<div class={styles.listWrapper}>
|
|
||||||
<div class="row">
|
|
||||||
<div class="col-24">
|
|
||||||
<For each={authorSubs()}>
|
|
||||||
{(subscription) =>
|
|
||||||
isAuthor(subscription) ? (
|
|
||||||
<AuthorBadge
|
|
||||||
isFollowed={{
|
|
||||||
loaded: Boolean(authorSubs()),
|
|
||||||
value: isOwnerSubscribed(subscription.id),
|
|
||||||
}}
|
|
||||||
author={subscription}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<TopicBadge
|
|
||||||
isFollowed={{
|
|
||||||
loaded: Boolean(authorSubs()),
|
|
||||||
value: isOwnerSubscribed(subscription.id),
|
|
||||||
}}
|
|
||||||
topic={subscription}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
</For>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
</Modal>
|
</Modal>
|
||||||
</Show>
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -1,14 +1,14 @@
|
||||||
import { clsx } from 'clsx'
|
import { clsx } from 'clsx'
|
||||||
import { createMemo } from 'solid-js'
|
import { createMemo } from 'solid-js'
|
||||||
|
|
||||||
import { useLocalize } from '../../../context/localize'
|
import { useLocalize } from '~/context/localize'
|
||||||
import { Author } from '../../../graphql/schema/core.gen'
|
import { Author } from '~/graphql/schema/core.gen'
|
||||||
import { capitalize } from '../../../utils/capitalize'
|
import { isCyrillic } from '~/intl/translate'
|
||||||
import { translit } from '../../../utils/ru2en'
|
import { translit } from '~/intl/translit'
|
||||||
import { isCyrillic } from '../../../utils/translate'
|
import { capitalize } from '~/utils/capitalize'
|
||||||
import { Userpic } from '../Userpic'
|
import { Userpic } from '../Userpic'
|
||||||
|
|
||||||
import styles from './AhtorLink.module.scss'
|
import styles from './AuthorLink.module.scss'
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
author: Author
|
author: Author
|
||||||
|
@ -20,18 +20,18 @@ type Props = {
|
||||||
export const AuthorLink = (props: Props) => {
|
export const AuthorLink = (props: Props) => {
|
||||||
const { lang } = useLocalize()
|
const { lang } = useLocalize()
|
||||||
const name = createMemo(() => {
|
const name = createMemo(() => {
|
||||||
return lang() === 'en' && isCyrillic(props.author.name)
|
return lang() === 'en' && isCyrillic(props.author.name || '')
|
||||||
? translit(capitalize(props.author.name))
|
? translit(capitalize(props.author.name || ''))
|
||||||
: props.author.name
|
: props.author.name
|
||||||
})
|
})
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
class={clsx(styles.AuthorLink, props.class, styles[props.size ?? 'M'], {
|
class={clsx(styles.AuthorLink, props.class, styles[(props.size ?? 'M') as keyof Props['size']], {
|
||||||
[styles.authorLinkFloorImportant]: props.isFloorImportant,
|
[styles.authorLinkFloorImportant]: props.isFloorImportant
|
||||||
})}
|
})}
|
||||||
>
|
>
|
||||||
<a class={styles.link} href={`/author/${props.author.slug}`}>
|
<a class={styles.link} href={`/@${props.author.slug}`}>
|
||||||
<Userpic size={props.size ?? 'M'} name={name()} userpic={props.author.pic} />
|
<Userpic size={props.size ?? 'M'} name={name() || ''} userpic={props.author.pic || ''} />
|
||||||
<div class={styles.name}>{name()}</div>
|
<div class={styles.name}>{name()}</div>
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -1,10 +1,9 @@
|
||||||
import type { Author } from '../../graphql/schema/core.gen'
|
import type { Author } from '~/graphql/schema/core.gen'
|
||||||
|
|
||||||
import { clsx } from 'clsx'
|
import { clsx } from 'clsx'
|
||||||
import { Show, createSignal } from 'solid-js'
|
import { Show, createSignal } from 'solid-js'
|
||||||
|
import { useSession } from '~/context/session'
|
||||||
import { apiClient } from '../../graphql/client/core'
|
import rateAuthorMutation from '~/graphql/mutation/core/author-rate'
|
||||||
|
|
||||||
import styles from './AuthorRatingControl.module.scss'
|
import styles from './AuthorRatingControl.module.scss'
|
||||||
|
|
||||||
interface AuthorRatingControlProps {
|
interface AuthorRatingControlProps {
|
||||||
|
@ -15,22 +14,29 @@ interface AuthorRatingControlProps {
|
||||||
export const AuthorRatingControl = (props: AuthorRatingControlProps) => {
|
export const AuthorRatingControl = (props: AuthorRatingControlProps) => {
|
||||||
const isUpvoted = false
|
const isUpvoted = false
|
||||||
const isDownvoted = false
|
const isDownvoted = false
|
||||||
|
|
||||||
|
const { client } = useSession()
|
||||||
|
|
||||||
// eslint-disable-next-line unicorn/consistent-function-scoping
|
// eslint-disable-next-line unicorn/consistent-function-scoping
|
||||||
const handleRatingChange = async (isUpvote: boolean) => {
|
const handleRatingChange = async (isUpvote: boolean) => {
|
||||||
console.log('handleRatingChange', { isUpvote })
|
console.log('handleRatingChange', { isUpvote })
|
||||||
if (props.author?.slug) {
|
if (props.author?.slug) {
|
||||||
const value = isUpvote ? 1 : -1
|
const value = isUpvote ? 1 : -1
|
||||||
await apiClient.rateAuthor({ rated_slug: props.author?.slug, value })
|
const _resp = await client()
|
||||||
setRating((r) => r + value)
|
?.mutation(rateAuthorMutation, {
|
||||||
|
rated_slug: props.author?.slug,
|
||||||
|
value
|
||||||
|
})
|
||||||
|
.toPromise()
|
||||||
|
setRating((r) => (r || 0) + value)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const [rating, setRating] = createSignal(props.author?.stat?.rating)
|
const [rating, setRating] = createSignal(props.author?.stat?.rating)
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
class={clsx(styles.rating, props.class, {
|
class={clsx(styles.rating, props.class, {
|
||||||
[styles.isUpvoted]: isUpvoted,
|
[styles.isUpvoted]: isUpvoted,
|
||||||
[styles.isDownvoted]: isDownvoted,
|
[styles.isDownvoted]: isDownvoted
|
||||||
})}
|
})}
|
||||||
>
|
>
|
||||||
<button
|
<button
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
import type { Author } from '../../graphql/schema/core.gen'
|
import type { Author } from '~/graphql/schema/core.gen'
|
||||||
|
|
||||||
import { clsx } from 'clsx'
|
import { clsx } from 'clsx'
|
||||||
import { createMemo } from 'solid-js'
|
import { createMemo } from 'solid-js'
|
||||||
|
@ -11,12 +11,12 @@ interface AuthorShoutsRating {
|
||||||
}
|
}
|
||||||
|
|
||||||
export const AuthorShoutsRating = (props: AuthorShoutsRating) => {
|
export const AuthorShoutsRating = (props: AuthorShoutsRating) => {
|
||||||
const isUpvoted = createMemo(() => props.author?.stat?.rating_shouts > 0)
|
const isUpvoted = createMemo(() => (props.author?.stat?.rating_shouts || 0) > 0)
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
class={clsx(styles.rating, props.class, {
|
class={clsx(styles.rating, props.class, {
|
||||||
[styles.isUpvoted]: isUpvoted(),
|
[styles.isUpvoted]: isUpvoted(),
|
||||||
[styles.isDownvoted]: !isUpvoted(),
|
[styles.isDownvoted]: !isUpvoted()
|
||||||
})}
|
})}
|
||||||
>
|
>
|
||||||
<span class={styles.ratingValue}>{props.author?.stat?.rating_shouts}</span>
|
<span class={styles.ratingValue}>{props.author?.stat?.rating_shouts}</span>
|
||||||
|
|
|
@ -86,17 +86,17 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
&.XL {
|
&.XL {
|
||||||
|
@include media-breakpoint-up(md) {
|
||||||
|
margin: 0;
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
aspect-ratio: 1/1;
|
aspect-ratio: 1/1;
|
||||||
margin: 0 auto 1rem;
|
margin: 0 auto 1rem;
|
||||||
max-width: 168px;
|
max-width: 168px;
|
||||||
height: auto;
|
height: auto;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
|
||||||
@include media-breakpoint-up(md) {
|
|
||||||
margin: 0;
|
|
||||||
max-width: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.letters {
|
.letters {
|
||||||
align-items: center;
|
align-items: center;
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|
|
@ -1,9 +1,9 @@
|
||||||
import { clsx } from 'clsx'
|
import { clsx } from 'clsx'
|
||||||
import { Show, createMemo } from 'solid-js'
|
import { Show, createMemo } from 'solid-js'
|
||||||
|
|
||||||
import { ConditionalWrapper } from '../../_shared/ConditionalWrapper'
|
import { ConditionalWrapper } from '~/components/_shared/ConditionalWrapper'
|
||||||
import { Image } from '../../_shared/Image'
|
import { Image } from '~/components/_shared/Image'
|
||||||
import { Loading } from '../../_shared/Loading'
|
import { Loading } from '~/components/_shared/Loading'
|
||||||
|
|
||||||
import styles from './Userpic.module.scss'
|
import styles from './Userpic.module.scss'
|
||||||
|
|
||||||
|
@ -22,7 +22,7 @@ export const Userpic = (props: Props) => {
|
||||||
const letters = () => {
|
const letters = () => {
|
||||||
if (!props.name) return
|
if (!props.name) return
|
||||||
const names = props.name ? props.name.split(' ') : []
|
const names = props.name ? props.name.split(' ') : []
|
||||||
return `${names[0][0 ?? names[0][0]]}.${names.length > 1 ? `${names[1][0]}.` : ''}`
|
return `${names[0][0] ? names[0][0] : ''}.${names.length > 1 ? `${names[1][0]}.` : ''}`
|
||||||
}
|
}
|
||||||
|
|
||||||
const avatarSize = createMemo(() => {
|
const avatarSize = createMemo(() => {
|
||||||
|
@ -48,14 +48,14 @@ export const Userpic = (props: Props) => {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
class={clsx(styles.Userpic, props.class, styles[props.size ?? 'M'], {
|
class={clsx(styles.Userpic, props.class, styles[props.size ?? 'M'], {
|
||||||
cursorPointer: props.onClick,
|
cursorPointer: props.onClick
|
||||||
})}
|
})}
|
||||||
onClick={props.onClick}
|
onClick={props.onClick}
|
||||||
>
|
>
|
||||||
<Show when={!props.loading} fallback={<Loading />}>
|
<Show when={!props.loading} fallback={<Loading />}>
|
||||||
<ConditionalWrapper
|
<ConditionalWrapper
|
||||||
condition={props.hasLink}
|
condition={Boolean(props.hasLink)}
|
||||||
wrapper={(children) => <a href={`/author/${props.slug}`}>{children}</a>}
|
wrapper={(children) => <a href={`/@${props.slug}`}>{children}</a>}
|
||||||
>
|
>
|
||||||
<Show keyed={true} when={props.userpic} fallback={<div class={styles.letters}>{letters()}</div>}>
|
<Show keyed={true} when={props.userpic} fallback={<div class={styles.letters}>{letters()}</div>}>
|
||||||
<Image src={props.userpic} width={avatarSize()} height={avatarSize()} alt={props.name} />
|
<Image src={props.userpic} width={avatarSize()} height={avatarSize()} alt={props.name} />
|
||||||
|
|
|
@ -1,26 +0,0 @@
|
||||||
.AuthorsList {
|
|
||||||
.action {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
min-height: 8rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.loading {
|
|
||||||
@include font-size(1.4rem);
|
|
||||||
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
gap: 1rem;
|
|
||||||
width: 100%;
|
|
||||||
flex-direction: row;
|
|
||||||
opacity: 0.5;
|
|
||||||
|
|
||||||
.icon {
|
|
||||||
position: relative;
|
|
||||||
width: 18px;
|
|
||||||
height: 18px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|