mirror of
https://github.com/wassname/talk.git
synced 2026-09-14 11:36:51 +08:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e14389c492 | ||
|
|
6566505fc1 | ||
|
|
a8d629d680 | ||
|
|
b150668d86 | ||
|
|
39536046c9 | ||
|
|
e735fb800a | ||
|
|
66ef4e38a8 | ||
|
|
423095360e | ||
|
|
2456fe5145 | ||
|
|
761d0aaa3a | ||
|
|
c1e3aec181 | ||
|
|
498eb63e1a | ||
|
|
532f2d92ca | ||
|
|
58af8ed003 | ||
|
|
b50808d365 | ||
|
|
3e2634be76 | ||
|
|
4cc3426af4 | ||
|
|
471607403c | ||
|
|
d73a6a87a4 | ||
|
|
da732ece14 | ||
|
|
2dda81f257 | ||
|
|
e3b9799d4a | ||
|
|
7ae81fd172 | ||
|
|
953c749bd2 | ||
|
|
9d13a95a7c | ||
|
|
27bd1404b3 | ||
|
|
bb0b3f461a | ||
|
|
6becb88b56 | ||
|
|
096bd12df6 | ||
|
|
2acd099f3f | ||
|
|
dd09ddf968 | ||
|
|
80706d2fdf | ||
|
|
b29614256b | ||
|
|
07a956e582 | ||
|
|
e7c4acde4d | ||
|
|
ca107afb9b | ||
|
|
d7a6bf9e1f | ||
|
|
9149b737f5 | ||
|
|
f76befd6ac | ||
|
|
9000778d8f | ||
|
|
e24a0caa20 | ||
|
|
17813e23a9 | ||
|
|
0563b93d39 | ||
|
|
df53b101ab | ||
|
|
6f17a84097 | ||
|
|
09513c0e83 | ||
|
|
301c8e829f | ||
|
|
afcdacff0d | ||
|
|
a8396481f9 | ||
|
|
4cccf09a87 | ||
|
|
3e4dc9c8b2 | ||
|
|
a00befafcb | ||
|
|
31dda920c6 | ||
|
|
9f168e627a | ||
|
|
2506442453 | ||
|
|
6eb4725dd9 | ||
|
|
5d1bc5fb5a | ||
|
|
62ee81e5a9 | ||
|
|
5a09beb727 | ||
|
|
2d374fcdab | ||
|
|
54f0705f0f | ||
|
|
c92e336c7f | ||
|
|
98a34892ff | ||
|
|
f6a6f443a9 | ||
|
|
bf387af586 | ||
|
|
051978a1ee | ||
|
|
761afeaf4f | ||
|
|
25f366c5f1 | ||
|
|
7eca9db63c | ||
|
|
dd3435fffe | ||
|
|
a35af36ee1 | ||
|
|
41d0610985 | ||
|
|
3bfac591b3 | ||
|
|
5370a6f81a | ||
|
|
8502b43974 | ||
|
|
e8f711ff9c | ||
|
|
a903ceb643 | ||
|
|
4765973a58 | ||
|
|
83f2f7b86b | ||
|
|
f925e6a8ea | ||
|
|
a267397f06 | ||
|
|
84dcf3b3b6 | ||
|
|
1e81ec9f4e | ||
|
|
902cd8300d | ||
|
|
e1383293d5 | ||
|
|
2b4e16edfa | ||
|
|
3166692d4e | ||
|
|
465de98318 | ||
|
|
e5512a0e54 | ||
|
|
1b2d84f38d | ||
|
|
e3e85c5f3b | ||
|
|
fa597ca04d | ||
|
|
1bda098045 | ||
|
|
24f052024d | ||
|
|
82811b8fae | ||
|
|
88214a008c | ||
|
|
c771e72ac3 | ||
|
|
a59296db14 | ||
|
|
7f11efb648 | ||
|
|
4b2cd1731b | ||
|
|
29b134af87 | ||
|
|
06d426b72b | ||
|
|
c53aa1d93b | ||
|
|
1dcdd4a9c9 | ||
|
|
8e14c1986d | ||
|
|
b0e81d97ec | ||
|
|
c8870f6097 | ||
|
|
a482d6c4bf | ||
|
|
3c28557736 | ||
|
|
501e65f729 | ||
|
|
d54f3103f9 | ||
|
|
ca1d9a04f6 | ||
|
|
b6e9e3dcf5 | ||
|
|
5467d980df | ||
|
|
04c0d42278 | ||
|
|
57d11c63d2 | ||
|
|
ccb5228a9a | ||
|
|
3be8ae39c4 | ||
|
|
9ab2653054 | ||
|
|
fa152bda3f | ||
|
|
27819d0d6a | ||
|
|
b75aed8bd8 | ||
|
|
9eefc0299f | ||
|
|
6c141cbce0 | ||
|
|
56fe8ef08e | ||
|
|
93636aabac | ||
|
|
80c60c560a | ||
|
|
45b419139e | ||
|
|
f91607cff9 | ||
|
|
da1a931a40 | ||
|
|
b3b36a245a | ||
|
|
b26c170a52 | ||
|
|
8a3309da92 | ||
|
|
a37e103e87 | ||
|
|
1796efd3ba | ||
|
|
0778b7832f | ||
|
|
675f823849 | ||
|
|
57d4f9d757 | ||
|
|
efb529d7bd | ||
|
|
6eda495663 | ||
|
|
a33daea5a3 | ||
|
|
68c764fd43 | ||
|
|
1d1b1b3391 | ||
|
|
5b16df44ed | ||
|
|
ed105f876e | ||
|
|
0747219c63 | ||
|
|
e59534bf46 | ||
|
|
1cad1d36d5 | ||
|
|
88843486f1 | ||
|
|
79dbe7dcda | ||
|
|
904bda86db | ||
|
|
a9104b749c | ||
|
|
417edc81ca | ||
|
|
d7469d648d | ||
|
|
0f074dc034 | ||
|
|
d3f81353a6 | ||
|
|
e640bc2cc0 | ||
|
|
6bc610d2c6 | ||
|
|
fbf543727b | ||
|
|
bab2acc557 | ||
|
|
52a1ebe82a | ||
|
|
b1d90dfd87 | ||
|
|
00c2305ff7 | ||
|
|
50e3e733c3 | ||
|
|
63982aa241 | ||
|
|
0bef413a3a | ||
|
|
714c251c77 | ||
|
|
d4651d4a2f | ||
|
|
06422d7074 | ||
|
|
e95372373c | ||
|
|
b76f3bbf91 | ||
|
|
a07b98dcc4 | ||
|
|
85ea41725a | ||
|
|
f524f539da | ||
|
|
b497dc2286 | ||
|
|
c3fe9fb43e | ||
|
|
023ba74c8f | ||
|
|
41a1eeda26 | ||
|
|
bbb82b6a77 | ||
|
|
5e2c1a9d4f | ||
|
|
daec809d87 | ||
|
|
ce2213549b | ||
|
|
d2c9b4b7a7 | ||
|
|
53e3147290 | ||
|
|
fe1aaba9ed | ||
|
|
330c37ef86 | ||
|
|
03a198a867 | ||
|
|
6b4ea14ce2 | ||
|
|
f51c857e8b | ||
|
|
d987b33d99 | ||
|
|
d1749e0f35 | ||
|
|
b85950140b | ||
|
|
f07900b294 | ||
|
|
d4109c8fca | ||
|
|
edc3671839 | ||
|
|
7d617ca611 | ||
|
|
86d0e59147 | ||
|
|
183d2e1ea0 | ||
|
|
0234112ad0 | ||
|
|
586d3c4c8a | ||
|
|
51cbf79f30 | ||
|
|
1d70ae0dec | ||
|
|
8953182329 | ||
|
|
f9f32b1ef6 | ||
|
|
1b788b98c6 | ||
|
|
51be47b62e | ||
|
|
b30ace74b4 | ||
|
|
5a2e4b708b | ||
|
|
1af25bb2a7 | ||
|
|
37ba14f3b5 | ||
|
|
519298b980 | ||
|
|
b16bdafc1a | ||
|
|
c756b22302 | ||
|
|
732ef9b8bd | ||
|
|
72156a01b8 | ||
|
|
0403c46c6c | ||
|
|
594b87208a | ||
|
|
03b73d8d6d | ||
|
|
12acbeee45 | ||
|
|
5b2f52a283 | ||
|
|
e494cbd7e8 | ||
|
|
e178b2e82a | ||
|
|
ba51b0742f | ||
|
|
5a0af59c45 | ||
|
|
b30a5d2184 | ||
|
|
e2ebac7176 | ||
|
|
4e73d7fc23 | ||
|
|
a2855a7343 | ||
|
|
90ed1f1056 | ||
|
|
af49b0f6fb | ||
|
|
184e63e4e3 | ||
|
|
de54433c5c | ||
|
|
4fedb7f31e | ||
|
|
d4e68de349 | ||
|
|
b962d672f4 | ||
|
|
e2e40ca2b3 | ||
|
|
4db2d63ee2 | ||
|
|
11ef12c14c | ||
|
|
894fec9fb6 | ||
|
|
a5e66e61f3 | ||
|
|
81a408a0b1 | ||
|
|
bc14b908eb | ||
|
|
498eb5eb91 | ||
|
|
5c4daa72cb | ||
|
|
c80f406b21 | ||
|
|
c637be364a | ||
|
|
7325525716 | ||
|
|
71fcf9cd3f | ||
|
|
2c2541e7be | ||
|
|
27ea409e31 | ||
|
|
9320ece35b | ||
|
|
05fcc61c9c | ||
|
|
52099efed9 | ||
|
|
b4ee765437 | ||
|
|
fded4f0a91 | ||
|
|
c202042cda | ||
|
|
832d1f6763 | ||
|
|
1272dabb4b | ||
|
|
229ddb11f2 | ||
|
|
7db3da5fdd | ||
|
|
3cd2b28bc2 | ||
|
|
10937cf717 | ||
|
|
647ae2fca0 | ||
|
|
646f6040ac | ||
|
|
91e94e8400 | ||
|
|
3be4fea8ad | ||
|
|
1c26e64c71 | ||
|
|
a825d29b60 | ||
|
|
cd7d683fe2 | ||
|
|
81e050214d | ||
|
|
f2341ca9d3 | ||
|
|
d3184d9afb | ||
|
|
e6087dfa25 | ||
|
|
e234fa7e0b | ||
|
|
25103630b5 | ||
|
|
fb9bedc57b | ||
|
|
ddeb28d729 | ||
|
|
d4d1f088cd | ||
|
|
ddc26f0cec | ||
|
|
0d141e7472 | ||
|
|
85dabae355 | ||
|
|
a3f99f76e2 | ||
|
|
2352a21826 | ||
|
|
075e62581e | ||
|
|
0c777cbad4 | ||
|
|
dc35425afd | ||
|
|
fab0c7e74d | ||
|
|
6a6574239f | ||
|
|
fc880de9bf | ||
|
|
f014338083 | ||
|
|
1176a94bec | ||
|
|
a4d62f78f2 | ||
|
|
604a3a42b9 | ||
|
|
5b609ceb86 | ||
|
|
3effd11ad2 | ||
|
|
4e1891c068 | ||
|
|
d08a9fb7b2 | ||
|
|
fd40135f1b | ||
|
|
a3e1bd972c | ||
|
|
f8d83ffbde | ||
|
|
3fc7fbd01e | ||
|
|
f188df162c | ||
|
|
fcd431dc21 | ||
|
|
ca2c55f643 | ||
|
|
9d3960fd7b | ||
|
|
5cdfc7bd1b | ||
|
|
cd3d08c6f9 | ||
|
|
cdc81c4fc5 | ||
|
|
f47638ae07 | ||
|
|
c837e89b38 | ||
|
|
526f0145f1 | ||
|
|
ba450a0166 | ||
|
|
0b9956a842 | ||
|
|
e239926866 | ||
|
|
586537084b | ||
|
|
3a953c7efe | ||
|
|
77f090fd3c | ||
|
|
652170735b | ||
|
|
16f93a0a19 | ||
|
|
f19e0134db | ||
|
|
ecb72b036d | ||
|
|
80ea100fc5 | ||
|
|
8c8a8eb3b8 | ||
|
|
d93dfe55af | ||
|
|
b22895b908 | ||
|
|
fe6d6f611d | ||
|
|
93ee5bfc4a | ||
|
|
3a145e9927 | ||
|
|
8d4f87a95d | ||
|
|
36ba9bd615 | ||
|
|
7bef103217 | ||
|
|
1901592780 | ||
|
|
cb78e8d563 | ||
|
|
adae7aadf3 | ||
|
|
3815267919 | ||
|
|
ff3c49c7bd | ||
|
|
e91ff306e9 | ||
|
|
c8bb59be5b | ||
|
|
bc14a9a758 | ||
|
|
a0c03605b9 | ||
|
|
3441ec36c6 | ||
|
|
5614b9781e | ||
|
|
f9cfdd07a5 | ||
|
|
c784de6e95 | ||
|
|
8664a16bfc | ||
|
|
5ae078ff6e | ||
|
|
35919740fd | ||
|
|
5caade2409 | ||
|
|
838e2a66e3 | ||
|
|
2dacb72341 | ||
|
|
fb8450c7ff | ||
|
|
d5abe78a3d | ||
|
|
6e92f8c3d6 | ||
|
|
1e8a363205 | ||
|
|
d92c2d9744 | ||
|
|
bbdd9f22c1 | ||
|
|
f488b0e02d | ||
|
|
160fb6e4d4 | ||
|
|
8f374852ac | ||
|
|
841d6add45 | ||
|
|
b9c918eb88 | ||
|
|
5c365b1a79 | ||
|
|
f1010462ec | ||
|
|
c4b934a542 | ||
|
|
a6f7cf258b | ||
|
|
fb60b088b3 | ||
|
|
e376e19c2c | ||
|
|
f7cddad659 | ||
|
|
80241025e5 | ||
|
|
b5c9c1c731 | ||
|
|
7da141ed29 | ||
|
|
d74d2e9e87 | ||
|
|
c272b88340 | ||
|
|
5a5c04f557 | ||
|
|
98c3d11815 | ||
|
|
a53db77c21 | ||
|
|
a0cf467f1b | ||
|
|
b8b66bc50c | ||
|
|
2df980b560 | ||
|
|
feb0860ba1 | ||
|
|
bb798b8b02 | ||
|
|
03c240054a | ||
|
|
79a2a2144c | ||
|
|
d53dc09aed | ||
|
|
ce002e366d | ||
|
|
7fe6002219 | ||
|
|
3273749fbb | ||
|
|
22e32b1869 | ||
|
|
04669350a1 | ||
|
|
b0045fd8a6 | ||
|
|
bd86eef441 | ||
|
|
65aa5e7e92 | ||
|
|
b4a03a5133 | ||
|
|
ad8d92a8ea | ||
|
|
97c3ebc3e0 | ||
|
|
5831a6f927 | ||
|
|
d703cc2168 | ||
|
|
d8ab3b8fba | ||
|
|
445eb97d76 | ||
|
|
e3da45daeb | ||
|
|
13114e7530 | ||
|
|
4df09ffbd2 | ||
|
|
0c360e62a6 | ||
|
|
7e907e18f2 | ||
|
|
5dca1611bc | ||
|
|
b2430eaf60 | ||
|
|
ee0bb50dd5 | ||
|
|
6e118c93aa | ||
|
|
22c0ed686f | ||
|
|
265860bccb | ||
|
|
064ccfbbb3 | ||
|
|
990685f17a | ||
|
|
a8e4ce466d | ||
|
|
e4722249b6 | ||
|
|
3a87bdc282 | ||
|
|
7beb28e077 | ||
|
|
6b582fdb2c | ||
|
|
f45429f510 | ||
|
|
4a9c996cba | ||
|
|
f4177f318f | ||
|
|
723dee3394 | ||
|
|
3d301b7e70 | ||
|
|
c9b12fc5d6 | ||
|
|
f4d672ddcf | ||
|
|
ec956165a3 | ||
|
|
81ae482524 | ||
|
|
00297d1a1a | ||
|
|
7866d1f8cd | ||
|
|
f7c43a7853 | ||
|
|
e1650aa721 | ||
|
|
d1cf6fd19b | ||
|
|
6a4c168882 | ||
|
|
37975574ea | ||
|
|
17eefeea15 | ||
|
|
26ce3c635d | ||
|
|
37d688f99d | ||
|
|
bc6c77176e | ||
|
|
e24b953f51 | ||
|
|
653e67c9c4 | ||
|
|
6c01a37f6a | ||
|
|
caa04de09d | ||
|
|
12070309d6 | ||
|
|
6a4315d608 | ||
|
|
6c318b0502 | ||
|
|
097934dee5 | ||
|
|
282c7dc4f5 | ||
|
|
37eae51641 | ||
|
|
a377f61bdd | ||
|
|
80122dfdce | ||
|
|
f5d873ba57 | ||
|
|
20bdf648d7 | ||
|
|
55dc229b32 | ||
|
|
e6e8ad5ca6 | ||
|
|
5d185e7aca | ||
|
|
a13b1c95d6 | ||
|
|
f44c1d6f0a | ||
|
|
4a6b09ac96 | ||
|
|
64d2aa7f42 | ||
|
|
ae3b8a6474 | ||
|
|
7babb14bab | ||
|
|
890da0400f | ||
|
|
3ca263bcfb | ||
|
|
ec1dfefc5d | ||
|
|
3d83c246f7 | ||
|
|
e63e1f3428 | ||
|
|
56ddc49386 | ||
|
|
d72b661c93 | ||
|
|
0af41a5b6b | ||
|
|
864413b5d6 | ||
|
|
91cd2e9e86 | ||
|
|
e6ef4b615f | ||
|
|
6fd2a927f1 | ||
|
|
76f55b4524 | ||
|
|
5b926b9b85 | ||
|
|
e8d8c7af5b | ||
|
|
976c821715 | ||
|
|
0881201465 | ||
|
|
235214642e | ||
|
|
5d2ed3f585 | ||
|
|
f83def83bd | ||
|
|
b75557912f | ||
|
|
f9363e86d0 | ||
|
|
887fe08819 | ||
|
|
2e1dd35de3 | ||
|
|
019e2a4fa9 | ||
|
|
e803593fc7 | ||
|
|
f5dc5ed8b7 | ||
|
|
744f2085da | ||
|
|
e4839468e3 | ||
|
|
b316d626f5 | ||
|
|
f7725f4143 | ||
|
|
7a227dff91 | ||
|
|
1070dfd5ae | ||
|
|
805b5cb441 | ||
|
|
c42fea5889 | ||
|
|
8fe8f6c690 | ||
|
|
177a4d8f2e | ||
|
|
0105b1d4ac | ||
|
|
61d24bad60 | ||
|
|
e5976576c1 | ||
|
|
04cd7312a6 | ||
|
|
b674ff45c0 | ||
|
|
5e521b832c | ||
|
|
1da61784b4 | ||
|
|
f0bb9db892 | ||
|
|
9d15a0448a | ||
|
|
f09d9d0a8b | ||
|
|
801d18639b | ||
|
|
fcf22ef4f9 | ||
|
|
2e6c7f7b09 | ||
|
|
d829d2541f | ||
|
|
ebc928c6f2 | ||
|
|
55a8b4984a | ||
|
|
1778c19f6a | ||
|
|
087b1a7bd7 | ||
|
|
0096de6e79 | ||
|
|
88d718e7c9 | ||
|
|
c8c160a551 | ||
|
|
5d1fd61425 | ||
|
|
9fbde9c023 | ||
|
|
744fce4563 | ||
|
|
c071484357 | ||
|
|
0a80205095 | ||
|
|
fbc5a6660a | ||
|
|
71647d5233 | ||
|
|
e7587d9a41 | ||
|
|
02ece80227 | ||
|
|
74d78b76a5 | ||
|
|
d4c9bdb6fd | ||
|
|
b8ff2b6763 | ||
|
|
fa985acd14 | ||
|
|
f06edec6a2 | ||
|
|
b6d8a90084 | ||
|
|
3d08d1f54f | ||
|
|
b35336d1ed | ||
|
|
f42b8f324a | ||
|
|
30583cd93f | ||
|
|
2086bd7b72 | ||
|
|
fe62c1a739 | ||
|
|
06331d3c07 | ||
|
|
2ad27ef6f9 | ||
|
|
3cbe8bb7ac | ||
|
|
963fc1f091 | ||
|
|
2e34f726eb | ||
|
|
58c4d5ceb5 | ||
|
|
dc89b22abb | ||
|
|
afb246b173 | ||
|
|
48cc82b039 | ||
|
|
88169526c3 | ||
|
|
4a54c571ed | ||
|
|
853981e21f | ||
|
|
74bef18098 | ||
|
|
64ed10845b | ||
|
|
0b73f18eb3 | ||
|
|
1a065ffa51 | ||
|
|
c4256fd27b | ||
|
|
aa83aea849 | ||
|
|
31abe57cb2 | ||
|
|
000f2c64bb | ||
|
|
34ca36822f | ||
|
|
0035037583 | ||
|
|
693e92a627 | ||
|
|
b4abb9724e | ||
|
|
9764631a37 | ||
|
|
e320a9dd20 | ||
|
|
1f1b1d43ae | ||
|
|
29e69a66bb | ||
|
|
9598c55466 | ||
|
|
ec0dd34d40 | ||
|
|
ad9684acd9 | ||
|
|
91402a3e8b | ||
|
|
edf9ced454 | ||
|
|
7c132600f0 | ||
|
|
b3eb3e3c43 | ||
|
|
d19d2ebe48 | ||
|
|
30b6f8d112 | ||
|
|
14483a39a0 | ||
|
|
c1d3cdce77 | ||
|
|
cebc5f2f45 | ||
|
|
b1848c28e4 | ||
|
|
e993330467 | ||
|
|
e3b183c565 | ||
|
|
373f119ac2 | ||
|
|
eb469df573 | ||
|
|
26b7d48f1f | ||
|
|
79b366f257 | ||
|
|
b6d917a22b | ||
|
|
622afcc80f | ||
|
|
a09c208f58 | ||
|
|
2698045e82 | ||
|
|
5a2d8c5201 | ||
|
|
9616c50d49 | ||
|
|
66b0babdde | ||
|
|
9435b627b1 | ||
|
|
da7349343e | ||
|
|
62ae297887 | ||
|
|
d2acffc19c | ||
|
|
054baa0530 | ||
|
|
fb791a9df9 | ||
|
|
ecfb365c24 | ||
|
|
fa300b1c24 | ||
|
|
b0acdff34d | ||
|
|
6f44b9cba0 | ||
|
|
7c3f566202 | ||
|
|
9edf99730d | ||
|
|
050e98f157 | ||
|
|
5566856542 | ||
|
|
53dd34f406 | ||
|
|
9d7b727f08 | ||
|
|
603854c0be | ||
|
|
ce73118f53 | ||
|
|
2ffecbeeff | ||
|
|
0a5a23c1f3 | ||
|
|
ee9674cd23 | ||
|
|
9dc57c82d1 | ||
|
|
5750758cf1 | ||
|
|
88ed6ffe76 | ||
|
|
aa1f06d1ee | ||
|
|
cda768d694 | ||
|
|
33ec0798e9 | ||
|
|
eeaefd9656 | ||
|
|
3a59a785f8 | ||
|
|
5e9f8eb624 | ||
|
|
d921a59858 | ||
|
|
74ced3b2fd | ||
|
|
6e9757ae91 | ||
|
|
8b8bef4ea7 | ||
|
|
4b33199f2f | ||
|
|
368606befe | ||
|
|
71f219c0c4 | ||
|
|
0e27984518 | ||
|
|
98b3d299e5 | ||
|
|
d977c33cf5 | ||
|
|
a7e9c0c776 | ||
|
|
0994023864 | ||
|
|
8cd929bb1b | ||
|
|
840e0d0ef6 | ||
|
|
90c58885aa | ||
|
|
0d7b482dc1 | ||
|
|
c1fea3151c | ||
|
|
a409fcbab7 | ||
|
|
4e8306bcc9 | ||
|
|
c4e1c1ae44 | ||
|
|
58e50b4242 | ||
|
|
7fd075a7a6 | ||
|
|
75bcf29154 | ||
|
|
33d274386a | ||
|
|
365f60c428 | ||
|
|
f375ff48c9 | ||
|
|
765edb48b9 | ||
|
|
9fbd1d3a77 | ||
|
|
9fd184f638 | ||
|
|
c3f238b0b2 | ||
|
|
fa4c6e8ec5 | ||
|
|
6282c39f3a | ||
|
|
9e22c100f0 | ||
|
|
01f905b79d | ||
|
|
adf4729d89 | ||
|
|
44058b1718 | ||
|
|
da761468b6 | ||
|
|
a8227bd4ad | ||
|
|
d1621ad424 | ||
|
|
b2d9688c89 | ||
|
|
f023d1de08 | ||
|
|
6563f29ccf | ||
|
|
fbd0bf52fe | ||
|
|
0ec2c11cf7 | ||
|
|
32d23214f1 | ||
|
|
0dcc912fd0 | ||
|
|
dfc073ab10 | ||
|
|
d724351fb6 | ||
|
|
9c40ee1245 | ||
|
|
450d74848e | ||
|
|
39bf092ebb | ||
|
|
b98b71189f | ||
|
|
f813a77194 | ||
|
|
715faf321f | ||
|
|
2615db8575 | ||
|
|
ae29d65c75 | ||
|
|
d89f41a041 | ||
|
|
3c540b9daa | ||
|
|
8f95e29315 | ||
|
|
f0f692fc26 | ||
|
|
d14fa33077 | ||
|
|
c16f3b8fbe | ||
|
|
7a5aa0a80c | ||
|
|
cc740a4a5e | ||
|
|
0f15c21cc9 | ||
|
|
89c1fd19b7 | ||
|
|
07b2f9c2e5 | ||
|
|
f286263f1f | ||
|
|
fec18b50d1 | ||
|
|
1db1908778 | ||
|
|
54e5a18546 | ||
|
|
a7094efda6 | ||
|
|
c207a59c18 | ||
|
|
9f4c30f466 | ||
|
|
8bad6f7a73 | ||
|
|
e9846c654c | ||
|
|
fa53327571 | ||
|
|
672d1ee19e | ||
|
|
840c91274d | ||
|
|
03a5acc736 | ||
|
|
d683b09b1d | ||
|
|
70a1525058 | ||
|
|
b172e30e5b | ||
|
|
2d414a5ab7 | ||
|
|
d34d511ce6 | ||
|
|
a40971e376 | ||
|
|
44f3f99b47 | ||
|
|
d3c215a18d | ||
|
|
bbfd017998 | ||
|
|
58e7abbe0c | ||
|
|
89a188255f | ||
|
|
5f63af8140 | ||
|
|
347fe8bf2b | ||
|
|
e36ecdfb33 | ||
|
|
07e24496da | ||
|
|
f07026d6dd | ||
|
|
5b30fd89c8 | ||
|
|
52ed18a9b8 | ||
|
|
f8e55ddc8b | ||
|
|
074630e628 | ||
|
|
7140723570 | ||
|
|
b4ca90a05d | ||
|
|
b59512d2b8 | ||
|
|
f9bc623764 | ||
|
|
810cc002c9 | ||
|
|
c03f85d4b6 | ||
|
|
17849898ae | ||
|
|
14fd5e4451 | ||
|
|
8588cab228 | ||
|
|
af71059948 | ||
|
|
dc866437ff | ||
|
|
b5afd9cbeb | ||
|
|
3640a93a2f | ||
|
|
1b50cb06d4 | ||
|
|
6d826e9115 | ||
|
|
5fff644e7d | ||
|
|
249a8f78dc | ||
|
|
5408036eb3 | ||
|
|
04e3f12c87 | ||
|
|
d5137ba1c1 | ||
|
|
e41acffcf0 | ||
|
|
5a22e99282 | ||
|
|
77b52a2454 | ||
|
|
05180394c7 | ||
|
|
4f013c50e6 | ||
|
|
da0ca97483 | ||
|
|
53a403cddb | ||
|
|
06812edcd2 | ||
|
|
1accc54238 | ||
|
|
05c055c3d2 | ||
|
|
06ab33343f | ||
|
|
3cc120cd25 | ||
|
|
dc63cc6bce | ||
|
|
0131dd6b13 | ||
|
|
44a06aef46 | ||
|
|
f0cc82cb6d | ||
|
|
8632cd9b67 | ||
|
|
5f58ca6b5d | ||
|
|
cae101627f | ||
|
|
e69262c665 | ||
|
|
7295d78523 | ||
|
|
041241d6b0 | ||
|
|
9876e04454 | ||
|
|
712c53389b | ||
|
|
9e5dbfd9cf | ||
|
|
39a4d031c7 | ||
|
|
7c95781072 | ||
|
|
f56e30c1a1 | ||
|
|
c90230c115 | ||
|
|
ad0f530fd3 | ||
|
|
281a49dc74 | ||
|
|
1cc048d46e | ||
|
|
20f90572f5 | ||
|
|
536a2165cc | ||
|
|
1027b19a49 | ||
|
|
5136fce4a3 | ||
|
|
f3f5397459 | ||
|
|
0eefd3364b | ||
|
|
361be84562 | ||
|
|
3e0d56b942 | ||
|
|
31b7dac48a | ||
|
|
ce01389809 | ||
|
|
6e89734e12 | ||
|
|
44b41b031e | ||
|
|
87b350e3a9 | ||
|
|
1432cfba4a | ||
|
|
c375d11e32 | ||
|
|
b66efb7f2a | ||
|
|
f1272338cd | ||
|
|
3285a5ec29 | ||
|
|
acf3ad5e28 | ||
|
|
15e7ef6368 | ||
|
|
873ec09f62 | ||
|
|
3d65231db5 | ||
|
|
b957e75d68 | ||
|
|
4a4affad70 | ||
|
|
7101d99187 | ||
|
|
61bc87bafc | ||
|
|
18b35e38a8 | ||
|
|
6ca7e92171 | ||
|
|
09e3889793 | ||
|
|
2a1ea01827 | ||
|
|
10aaa037dc | ||
|
|
30c7539cde | ||
|
|
e8dc319a9a | ||
|
|
98a6ba5f1f | ||
|
|
5f215a95f9 | ||
|
|
9d7810f48c | ||
|
|
7a21879628 | ||
|
|
2e4256bf4a | ||
|
|
11f18c23cb | ||
|
|
3a510168d3 | ||
|
|
8519d07c67 | ||
|
|
d9147da8fc | ||
|
|
e87a471eb9 | ||
|
|
6cf4011cde | ||
|
|
ca369dcb4a | ||
|
|
a4e6638217 | ||
|
|
f76177c1db | ||
|
|
7283feab6b | ||
|
|
f957e3c8ce | ||
|
|
8fa7561e20 | ||
|
|
01d9fc313e | ||
|
|
296b941ee8 | ||
|
|
8601562418 | ||
|
|
a2704e3099 | ||
|
|
586ce941e5 | ||
|
|
ec3a114e65 | ||
|
|
9809403701 | ||
|
|
b6eaf210fe | ||
|
|
be293a03de | ||
|
|
4f4ecd8dfb | ||
|
|
b8fc2c9cc7 | ||
|
|
7bc848b801 | ||
|
|
226af72ac9 | ||
|
|
1acc92a1f2 | ||
|
|
0f24ab6b28 | ||
|
|
976f0762b3 | ||
|
|
ec020df488 | ||
|
|
d5a82f04c2 | ||
|
|
bba597f086 | ||
|
|
abd2332745 | ||
|
|
9e651a9292 | ||
|
|
a3bb8e2f41 | ||
|
|
01db900c40 | ||
|
|
4dbc73f9ab | ||
|
|
6a61f47ddf | ||
|
|
712e436f58 | ||
|
|
72cd78da8c | ||
|
|
565e34e09d | ||
|
|
8a1eedb0b2 | ||
|
|
bf04034085 | ||
|
|
1e445efaec | ||
|
|
466cb289c3 | ||
|
|
4c5b85a2ae | ||
|
|
e510f99754 | ||
|
|
4eb7c72ae2 | ||
|
|
cabe546ecf | ||
|
|
be72ebf2a1 | ||
|
|
368b431893 | ||
|
|
a468fe4c28 | ||
|
|
d9ea5977cd | ||
|
|
890ab810f3 | ||
|
|
7e7e8891b4 | ||
|
|
ac6fba2286 | ||
|
|
9ba0176b3c | ||
|
|
7a239c02ab | ||
|
|
216b21251d | ||
|
|
ab825512b9 | ||
|
|
c1e3580ef7 | ||
|
|
0a064c0e11 | ||
|
|
ce46b4dbe0 | ||
|
|
864b08135a | ||
|
|
3fa0460f34 | ||
|
|
7d5be04be3 | ||
|
|
f792aeccd2 | ||
|
|
12f6bb1ffe | ||
|
|
57f9fd8474 | ||
|
|
298e1e8d73 | ||
|
|
5f7d48a7f9 | ||
|
|
3718efce1e | ||
|
|
ccb9fe3165 | ||
|
|
7b9dbb5afd | ||
|
|
2755cc61e4 | ||
|
|
a7680ae5d9 | ||
|
|
b517adab56 | ||
|
|
0fd6f4b44e | ||
|
|
5c5a7f5ba5 | ||
|
|
dd7bdb8a26 | ||
|
|
800382132d | ||
|
|
e2a032d4a5 | ||
|
|
7363fcc5dc | ||
|
|
d4dc381aca | ||
|
|
6e54ba7f4c | ||
|
|
950340d5e7 | ||
|
|
43846651dd | ||
|
|
a1def6f950 | ||
|
|
b7380978f5 | ||
|
|
5e4564c7ea | ||
|
|
f5374dc914 | ||
|
|
0dde122e9b | ||
|
|
fd2b01d5ed | ||
|
|
759e74a88d | ||
|
|
5c6df6d481 | ||
|
|
a02ad69568 | ||
|
|
111e83a1e6 | ||
|
|
036ed8ac78 | ||
|
|
f786daaec9 | ||
|
|
9a608fd02e | ||
|
|
1ca6736146 | ||
|
|
349f8003a7 | ||
|
|
c86148c0f1 | ||
|
|
b0e4b79a6a | ||
|
|
da37bb32f1 | ||
|
|
0219f1916e | ||
|
|
ef40cf37fe | ||
|
|
d35875145c | ||
|
|
8e5e75ed6f | ||
|
|
6892ef85f8 | ||
|
|
8b5b9e856b | ||
|
|
ac66a7bb0e | ||
|
|
6052cac1d3 | ||
|
|
93c5e336b2 | ||
|
|
8e6aa23d32 | ||
|
|
8d992f7809 | ||
|
|
168248f975 | ||
|
|
964c1cb3d2 | ||
|
|
9766215231 | ||
|
|
3b1731e809 | ||
|
|
1538690cad | ||
|
|
ca9ef44202 | ||
|
|
3dfa4969e5 | ||
|
|
92a8fc5a93 | ||
|
|
018447f00e | ||
|
|
b36109f0a1 | ||
|
|
ec3e9d1cd4 | ||
|
|
54d0096dd1 | ||
|
|
6cca99e75e | ||
|
|
92b51ef1fe | ||
|
|
39222759f9 | ||
|
|
075fe7f6b4 | ||
|
|
54bad908b3 | ||
|
|
7f86c936ad | ||
|
|
50112d3166 | ||
|
|
7562edc168 | ||
|
|
29250f2bbc | ||
|
|
9c9597ae8c | ||
|
|
16a4daaeb3 | ||
|
|
e16bc8f7ad | ||
|
|
f49ca9f89a | ||
|
|
dff8413404 | ||
|
|
20505acfbe | ||
|
|
845d274b9b | ||
|
|
4172066c94 | ||
|
|
dd032521c0 | ||
|
|
d8d30e512b | ||
|
|
b75408b067 | ||
|
|
670567b8b3 | ||
|
|
eb98a41bd5 | ||
|
|
7b8131d4b0 | ||
|
|
4a76e43309 | ||
|
|
70c86bf8f5 | ||
|
|
e76d6e4694 | ||
|
|
369ed3fc29 | ||
|
|
fc042779c2 |
@@ -1 +1,3 @@
|
||||
node_modules
|
||||
test
|
||||
.git
|
||||
|
||||
+1
-1
@@ -17,7 +17,7 @@
|
||||
"no-template-curly-in-string": [1],
|
||||
"no-unsafe-negation": [1],
|
||||
"array-callback-return": [1],
|
||||
"eqeqeq": [2],
|
||||
"eqeqeq": [2, "smart"],
|
||||
"no-eval": [2],
|
||||
"no-global-assign": [2],
|
||||
"no-implied-eval": [2],
|
||||
|
||||
+3
-2
@@ -3,11 +3,12 @@ npm-debug.log*
|
||||
dist
|
||||
!dist/coral-admin
|
||||
dist/coral-admin/bundle.js
|
||||
tests/e2e/reports
|
||||
test/e2e/reports
|
||||
.DS_Store
|
||||
*.iml
|
||||
*.swp
|
||||
dump.rdb
|
||||
.env
|
||||
gaba.cfg
|
||||
*.cfg
|
||||
.idea/
|
||||
coverage/
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"verbose": true,
|
||||
"ignore": ["test/*", "client/*", "dist/*"],
|
||||
"ext": "js,json,graphql"
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
language: node_js
|
||||
node_js:
|
||||
- 4
|
||||
+64
-83
@@ -1,95 +1,76 @@
|
||||
# Contribution Guide
|
||||
# Contributor's Guide
|
||||
|
||||
We're very excited that you're interested in contributing to Talk! There is much to do. Before you begin, please review this document to get a sense of the practices and philosophies that hold this project together.
|
||||
Welcome! We are very excited that you are interested in contributing to Talk.
|
||||
|
||||
This document is a companion to help you approach contributing. If it does not do so, please [let us know how we can improve it](https://github.com/coralproject/talk/issues)!
|
||||
|
||||
By contributing to this project you agree to the [Code of Conduct](https://coralproject.net/code-of-conduct.html).
|
||||
|
||||
## Product Roadmap
|
||||
|
||||
You can view what the Coral Team is working on next here https://www.pivotaltracker.com/n/projects/1863625.
|
||||
|
||||
You can view product ideas and our longer term roadmap here https://trello.com/b/ILND751a/talk.
|
||||
|
||||
|
||||
## Doing the Work
|
||||
## Contribute to the documentation
|
||||
Clear docs are a prerequisite for a successful open source project. We value non-code and code contributions equally.
|
||||
|
||||
We are here to make it as seamless as possible to contribute to Talk. The following lists are meant to make it straightforward to perform the mechanics of working on the project so you can focus your energy toward writing and reviewing content.
|
||||
We are looking for _documentarians_ to:
|
||||
|
||||
* make clarity, grammar and completeness updates,
|
||||
* create new / missing sections, and
|
||||
* take the lead in making sections, or the over all structure better.
|
||||
|
||||
### But how?
|
||||
|
||||
* Our public docs site can be updated [here](https://github.com/coralproject/docs).
|
||||
* [Let us know](https://github.com/coralproject/talk/wiki/Contact-Us) if you'd like permission to update our wiki.
|
||||
* Update any of our .md docs files by following [this guide](https://github.com/coralproject/talk/wiki/Forking,-Branching-and-Merging).
|
||||
* Our API docs need to be kept honest. [Update them here](https://github.com/coralproject/talk/blob/master/docs/swagger.yaml).
|
||||
|
||||
## Integrate into your environment
|
||||
|
||||
First, [set up a dev environment](https://github.com/coralproject/talk/blob/master/INSTALL.md). Please let us know how this goes!
|
||||
|
||||
Talk is designed to integrate into existing environments in a variety of ways:
|
||||
|
||||
* [Auth integrations](https://github.com/coralproject/talk/wiki/Security#authentication-strategies)
|
||||
* [Push assets into Talk](https://github.com/coralproject/talk/blob/master/routes/api/assets/index.js)
|
||||
* Monitoring Hooks (coming in 2017)
|
||||
|
||||
If you're considering deploying Talk, [please let us know](https://github.com/coralproject/talk/wiki/Contact-Us)! We are quite literally doing this for you and want to help you succeed any way we can.
|
||||
|
||||
If you are writing custom integration code in your fork of Talk, please consider keeping it generic and filing a Pull Request to contribute it back to the project! See our [forking and merging guidelines](https://github.com/coralproject/talk/wiki/Forking,-Branching-and-Merging) for more info.
|
||||
|
||||
## Write some code
|
||||
|
||||
First, [set up a dev environment](https://github.com/coralproject/talk/blob/master/INSTALL.md). Please let us know how this goes!
|
||||
|
||||
### Build a New Feature / Plugin
|
||||
|
||||
Talk is beginning life as a Commenting Platform, but is architected to support many varieties of community engagement.
|
||||
|
||||
Please [contact us](https://github.com/coralproject/talk/wiki/Contact-Us) early and often if you'd like to help. We would love to hear your ideas for features and plugins and help you find a way to productively engage the project.
|
||||
|
||||
To get an idea of where the Coral Team is going, see:
|
||||
|
||||
* our [product/design Trello board](https://trello.com/b/ILND751a/talk),
|
||||
* our [current stories](https://www.pivotaltracker.com/n/projects/1863625), and
|
||||
* our [issues](https://github.com/coralproject/talk/issues).
|
||||
|
||||
|
||||
### Code Reviews
|
||||
Examples:
|
||||
|
||||
One of the most valuable aspects of working in software. It is something that should challenge the reviewer and author alike. It is a way of focusing knowledge, experience and opinions for the benefit of the project and the participants.
|
||||
|
||||
Code reviews are a collaboration to make _the work_ as good as it can be. Code reviews are not a good venue for providing direct instruction to _the author._ Focus on positive, incremental improvements that can be made on the work at hand.
|
||||
|
||||
Please take your time when writing and reviewing code. Here are some fundamental questions to open up a reviewing headspace.
|
||||
|
||||
**Is the code clear, efficient and a pleasure to read?**
|
||||
|
||||
Somewhere at the intersection of good variable names, well laid out file structures, consistent formatting and appropriate comments lies beautiful code. Code is language spoken to at least two very distinct audiences, the computer that interprets it and the developer who encounters it. Both should be at the front of your mind when reviewing code.
|
||||
|
||||
Thinking like a computer, you could ask:
|
||||
|
||||
* Is the code using memory efficiently?
|
||||
* Is data being moved around unnecessarily?
|
||||
* Are multiple network requests being made where fewer would do?
|
||||
* Is there excess processing happening in a synchronous flow that may disrupt user experience?
|
||||
* Are there large libraries included for small gains?
|
||||
|
||||
Then, returning to your human roots... Is the code readable?
|
||||
|
||||
* Can I understand what is happening here (and maybe even why) by simply opening up the file, starting at the top and reading downward?
|
||||
* Do comments convey clear, full thoughts in a narrative language that provides background for the code choices?
|
||||
* Are the files separated logically such that each one contains a clear concept of code?
|
||||
* [Add An Emoji Button to Comments](https://github.com/coralproject/talk/wiki/Add-An-Emoji-Button-to-Comments)
|
||||
|
||||
|
||||
**Is the API documentation up to date? Are all client calls written against the docs?**
|
||||
### Work on the Core
|
||||
|
||||
We use [swagger](https://github.com/coralproject/talk/blob/master/swagger.yaml) to track our API documentation.
|
||||
There is always more work to be done to make an application more stable, scaleable and secure.
|
||||
|
||||
* If APIs are created or updated, is the swagger.yml file up to date? There's nothing more frustrating than trying to develop against docs that are out of date or wrong. We need to be meticulous here as it's the little differences that can cause the most frustration and tricky bugs.
|
||||
* If client code calls APIs, are they written against the swagger.yml file? Are all return codes handled?
|
||||
If you see issues in the code or have ideas on how we may improve Talk, please consider:
|
||||
|
||||
**Is there sufficient test coverage?**
|
||||
|
||||
Our tests folder is set up to mirror the code folders: [https://github.com/coralproject/talk/tree/master/tests](https://github.com/coralproject/talk/tree/master/tests)
|
||||
|
||||
* Can you a sense of the logic behind the code by reading the tests?
|
||||
* Can you see both what should happen and what should _never, ever_ be allowed to happen?
|
||||
* Are there future cases that are guarded against via the creation of unit tests (aka, making sure things are typed, specifically checking for all values that will be used, etc...)?
|
||||
|
||||
|
||||
### Forking, Branching and Merging
|
||||
|
||||
Talk follows the _master as tip_ repo structure. `master` is the bleeding edge. It should be _as stable as possible_ but may suffer instabilities, generally during times that fundamental architectural elements are added.
|
||||
|
||||
Releases are _tagged_ off the master branch.
|
||||
|
||||
Contributions to Talk follow this process. There are a lot of steps, but mechanically following these steps will standardize communication, help stop errors and let you focus on your contribution.
|
||||
|
||||
* At the outset of a piece of work, a branch or fork is made from master.
|
||||
* The work is done in that fork.
|
||||
* As soon as the work has taken shape, a PR is created for discussion. (If the PR is created for review before it's ready to merge, please make that clear in the description/title.)
|
||||
* At least one other contributor to the project must review all code (see Code Reviews below.)
|
||||
* If there are merge conflicts with master, merge master into the branch.
|
||||
* Ensure that [circleci](https://circleci.com/) passes all tests for your branch. (If you have forked and do not have circleci set up, you and the reviewer should independently ensure that all the of Continuous Integration steps pass before merging.)
|
||||
* If merge conflicts exist with `master`, merge `master` into your branch and re-run CI before merging into master.
|
||||
* Merge to master, but _you're not quite done yet!_
|
||||
* Deploy master to staging (or have a core member do so.)
|
||||
* Ensure that all your changes are working on staging.
|
||||
* Have your reviewer verify the same.
|
||||
* ... aaaand the work is delivered!
|
||||
|
||||
|
||||
## Continuous Integration
|
||||
|
||||
We use circleci to run our ci: [https://circleci.com/gh/coralproject/talk](https://circleci.com/gh/coralproject/talk)
|
||||
|
||||
Our pipeline will _test_, _lint_, and _build_ all pushes to the repo.
|
||||
|
||||
Any branch not passing CI will not be merged into master.
|
||||
|
||||
If you're working in a fork, please run each of the steps locally before submitting a PR.
|
||||
|
||||
|
||||
## Coding Style
|
||||
|
||||
### API Design
|
||||
|
||||
When building APIs, we follow these principles:
|
||||
|
||||
* Follow [RESTful](https://en.wikipedia.org/wiki/Representational_state_transfer) principles for basic operations.
|
||||
* Avoid routing yourself into a corner, for example, by putting a variable other than an object's id directly after an object.
|
||||
* Put non-required, flexible variables into query params, required/identity based values in request params.
|
||||
* [contributing a fix](https://github.com/coralproject/talk/wiki/Forking,-Branching-and-Merging),
|
||||
* [filing an issue](https://github.com/coralproject/talk/issues), or
|
||||
* or otherwise [letting us know](https://github.com/coralproject/talk/wiki/Contact-Us).
|
||||
|
||||
+6
-3
@@ -1,5 +1,8 @@
|
||||
FROM node:7
|
||||
|
||||
# Install yarn
|
||||
RUN npm install -g yarn
|
||||
|
||||
# Create app directory
|
||||
RUN mkdir -p /usr/src/app
|
||||
WORKDIR /usr/src/app
|
||||
@@ -11,10 +14,10 @@ ENV TALK_PORT 5000
|
||||
EXPOSE 5000
|
||||
|
||||
# Install app dependencies
|
||||
COPY package.json /usr/src/app/
|
||||
RUN npm install --production
|
||||
COPY package.json yarn.lock /usr/src/app/
|
||||
RUN yarn install --production
|
||||
|
||||
# Bundle app source
|
||||
COPY . /usr/src/app
|
||||
|
||||
CMD [ "npm", "start" ]
|
||||
CMD [ "yarn", "start" ]
|
||||
|
||||
+94
@@ -0,0 +1,94 @@
|
||||
# Installation
|
||||
|
||||
## Requirements
|
||||
|
||||
### System
|
||||
|
||||
- Any flavour of Linux, OSX or Windows
|
||||
- 1GB memory (minimum)
|
||||
- 5GB storage (minimum)
|
||||
|
||||
## Installation From Source
|
||||
|
||||
### Requirements
|
||||
|
||||
There are some runtime requirements for running Talk from source:
|
||||
|
||||
- [Node](https://nodejs.org/) v7 or later
|
||||
- [MongoDB](https://www.mongodb.com/) v3.4 or later
|
||||
- [Redis](https://redis.io/) v3.2 or later
|
||||
- [Yarn](https://yarnpkg.com/) v0.19.1 or later
|
||||
|
||||
_Please be sure to check the versions of these requirements. Insufficient versions of these may lead to unexpected errors!_
|
||||
|
||||
### Installing
|
||||
|
||||
```bash
|
||||
# Download the tarball containing the repository
|
||||
curl -L https://github.com/coralproject/talk/tarball/master -o coralproject-talk.tar.gz
|
||||
|
||||
# Untar that file and change to that directory
|
||||
tar xpf coralproject-talk.tar.gz
|
||||
mv coralproject-talk-* coralproject-talk
|
||||
cd coralproject-talk
|
||||
|
||||
# Install package dependancies
|
||||
yarn
|
||||
|
||||
# Build static files
|
||||
yarn build
|
||||
```
|
||||
|
||||
### Running
|
||||
|
||||
Refer to the `README.md` file for required configuration variables to add to the
|
||||
environment.
|
||||
|
||||
You can start the server after configuring the server using the command:
|
||||
|
||||
```bash
|
||||
yarn start
|
||||
```
|
||||
|
||||
You can see other scripts we've made available by consulting the `package.json`
|
||||
file under the `scripts` key including:
|
||||
|
||||
- `yarn test` run unit tests
|
||||
- `yarn e2e` run end to end tests
|
||||
- `yarn build-watch` watch for changes to client files and build static assets
|
||||
- `yarn dev-start` watch for changes to server files and reload the server
|
||||
|
||||
## Installation From Docker Hub
|
||||
|
||||
### Requirements
|
||||
|
||||
There are some runtime requirements for running Talk for Docker:
|
||||
|
||||
- [MongoDB](https://www.mongodb.com/) v3.2 or later
|
||||
- [Redis](https://redis.io/) v3.2 or later
|
||||
- [Docker](https://www.docker.com/) v1.13.0 or later
|
||||
- [Docker Compose](https://docs.docker.com/compose/) v1.10.0 or later
|
||||
|
||||
_Please be sure to check the versions of these requirements. Insufficient versions of these may lead to unexpected errors!_
|
||||
|
||||
### Installing
|
||||
|
||||
```bash
|
||||
# Create a directory for talk
|
||||
mkdir coralproject-talk
|
||||
cd coralproject-talk
|
||||
|
||||
# Download the docker-compose.yml file from the repository
|
||||
curl -LO https://raw.githubusercontent.com/coralproject/talk/master/docker-compose.yml
|
||||
```
|
||||
|
||||
At this stage, you should refer to the `README.md` file for required
|
||||
configuration variables to add to the environment key for the `talk` service
|
||||
listed in the `docker-compose.yml` file.
|
||||
|
||||
### Running
|
||||
|
||||
```bash
|
||||
# Start the services using compose
|
||||
docker-compose up -d
|
||||
```
|
||||
@@ -1,38 +1,46 @@
|
||||
# Talk [](https://circleci.com/gh/coralproject/talk)
|
||||
|
||||
A commenting platform from [The Coral Project](https://coralproject.net).
|
||||
A commenting platform from [The Coral Project](https://coralproject.net). Talk enters a closed beta in March 2017, but you can download the code for our alpha here. [Read more about Talk here.](https://coralproject.net/products/talk.html)
|
||||
|
||||
## Contributing to Talk
|
||||
|
||||
### Product Roadmap
|
||||
|
||||
You can view what the Coral Team is working on next here https://www.pivotaltracker.com/n/projects/1863625.
|
||||
|
||||
You can view product ideas and our longer term roadmap here https://trello.com/b/ILND751a/talk.
|
||||
See our [Contribution Guide](https://github.com/coralproject/talk/blob/master/CONTRIBUTING.md)!
|
||||
|
||||
## Usage
|
||||
|
||||
### Installation
|
||||
|
||||
To set up a development environment or build from source, see [INSTALL.md](https://github.com/coralproject/talk/blob/master/INSTALL.md).
|
||||
|
||||
To launch a Talk server of your own from your browser without any need to muck about in a terminal or think about engineering concepts, stay tuned. We will launch [our installer](https://github.com/coralproject/talk-install) shortly!
|
||||
|
||||
|
||||
### Configuration
|
||||
|
||||
The Talk application requires specific configuration options to be available
|
||||
inside the environment in order to run, those variables are listed here:
|
||||
The Talk application looks for the following configuration values either as environment variables:
|
||||
|
||||
- `TALK_MONGO_URL` (*required*) - the database connection string for the MongoDB database.
|
||||
- `TALK_REDIS_URL` (*required*) - the database connection string for the Redis database.
|
||||
- `TALK_SESSION_SECRET` (*required*) - a random string which will be used to
|
||||
secure cookies.
|
||||
- `TALK_FACEBOOK_APP_ID` (*required*) - the Facebook app id for your Facebook
|
||||
Login enabled app.
|
||||
- `TALK_FACEBOOK_APP_SECRET` (*required*) - the Facebook app secret for your
|
||||
Facebook Login enabled app.
|
||||
- `TALK_ROOT_URL` (*required*) - root url of the installed application externally
|
||||
available in the format: `<scheme>://<host>` without the path.
|
||||
- `TALK_SMTP_EMAIL` (*required*) - the address to send emails from using the
|
||||
|
||||
- `TALK_FACEBOOK_APP_ID` (*required for login via fb*) - the Facebook app id for your Facebook
|
||||
Login enabled app.
|
||||
- `TALK_FACEBOOK_APP_SECRET` (*required for login via fb*) - the Facebook app secret for your
|
||||
Facebook Login enabled app.
|
||||
|
||||
- `TALK_SMTP_EMAIL` (*required for email*) - the address to send emails from using the
|
||||
SMTP provider.
|
||||
- `TALK_SMTP_USERNAME` (*required*) - username of the SMTP provider you are using.
|
||||
- `TALK_SMTP_PASSWORD` (*required*) - password for the SMTP provider you are using.
|
||||
- `TALK_SMTP_HOST` (*required*) - SMTP host url with format `smtp.domain.com`.
|
||||
- `TALK_SMTP_PORT` (*required*) - SMTP port.
|
||||
- `TALK_SMTP_USERNAME` (*required for email*) - username of the SMTP provider you are using.
|
||||
- `TALK_SMTP_PASSWORD` (*required for email*) - password for the SMTP provider you are using.
|
||||
- `TALK_SMTP_HOST` (*required for email*) - SMTP host url with format `smtp.domain.com`.
|
||||
- `TALK_SMTP_PORT` (*required for email*) - SMTP port.
|
||||
- `TALK_INSTALL_LOCK` (_optional for dynamic setup_) - Defaults to `FALSE`. When `TRUE`, disables the dynamic setup endpoint.
|
||||
|
||||
Refer to the wiki page on [Configuration Loading](https://github.com/coralproject/talk/wiki/Configuration-Loading) for
|
||||
alternative methods of loading configuration during development.
|
||||
|
||||
### License
|
||||
|
||||
|
||||
@@ -5,8 +5,13 @@ const path = require('path');
|
||||
const helmet = require('helmet');
|
||||
const passport = require('./services/passport');
|
||||
const session = require('express-session');
|
||||
const enabled = require('debug').enabled;
|
||||
const RedisStore = require('connect-redis')(session);
|
||||
const redis = require('./services/redis');
|
||||
const csrf = require('csurf');
|
||||
const errors = require('./errors');
|
||||
const graph = require('./graph');
|
||||
const apollo = require('graphql-server-express');
|
||||
|
||||
const app = express();
|
||||
|
||||
@@ -42,10 +47,11 @@ const session_opts = {
|
||||
rolling: true,
|
||||
saveUninitialized: false,
|
||||
resave: false,
|
||||
unset: 'destroy',
|
||||
name: 'talk.sid',
|
||||
cookie: {
|
||||
secure: false,
|
||||
maxAge: 18000000, // 30 minutes for expiry.
|
||||
maxAge: 36000000, // 1 hour for expiry.
|
||||
},
|
||||
store: new RedisStore({
|
||||
ttl: 1800,
|
||||
@@ -73,6 +79,51 @@ app.use(session(session_opts));
|
||||
app.use(passport.initialize());
|
||||
app.use(passport.session());
|
||||
|
||||
//==============================================================================
|
||||
// GraphQL Router
|
||||
//==============================================================================
|
||||
|
||||
// GraphQL endpoint.
|
||||
app.use('/api/v1/graph/ql', apollo.graphqlExpress(graph.createGraphOptions));
|
||||
|
||||
// Only include the graphiql tool if we aren't in production mode.
|
||||
if (app.get('env') !== 'production') {
|
||||
|
||||
// Interactive graphiql interface.
|
||||
app.use('/api/v1/graph/iql', apollo.graphiqlExpress({
|
||||
endpointURL: '/api/v1/graph/ql'
|
||||
}));
|
||||
|
||||
// GraphQL documention.
|
||||
app.get('/admin/docs', (req, res) => {
|
||||
res.render('admin/docs');
|
||||
});
|
||||
|
||||
}
|
||||
|
||||
//==============================================================================
|
||||
// CSRF MIDDLEWARE
|
||||
//==============================================================================
|
||||
|
||||
if (process.env.TEST_MODE === 'unit') {
|
||||
|
||||
// Add this fake test token in the event we are in unit test mode, and don't
|
||||
// include the CSRF protection.
|
||||
app.locals.csrfToken = 'UNIT_TESTS';
|
||||
|
||||
} else {
|
||||
|
||||
// Setup route middlewares for CSRF protection.
|
||||
// Default ignore methods are GET, HEAD, OPTIONS
|
||||
app.use(csrf({}));
|
||||
app.use((req, res, next) => {
|
||||
res.locals.csrfToken = req.csrfToken();
|
||||
|
||||
next();
|
||||
});
|
||||
|
||||
}
|
||||
|
||||
//==============================================================================
|
||||
// ROUTES
|
||||
//==============================================================================
|
||||
@@ -83,40 +134,47 @@ app.use('/', require('./routes'));
|
||||
// ERROR HANDLING
|
||||
//==============================================================================
|
||||
|
||||
const ErrNotFound = new Error('Not Found');
|
||||
ErrNotFound.status = 404;
|
||||
|
||||
// Catch 404 and forward to error handler.
|
||||
app.use((req, res, next) => {
|
||||
next(ErrNotFound);
|
||||
next(errors.ErrNotFound);
|
||||
});
|
||||
|
||||
// General error handler. Respond with the message and error if we have it while
|
||||
// returning a status code that makes sense.
|
||||
app.use('/api', (err, req, res, next) => {
|
||||
if (err !== ErrNotFound) {
|
||||
if (app.get('env') !== 'test') {
|
||||
if (err !== errors.ErrNotFound) {
|
||||
if (app.get('env') !== 'test' || enabled('talk:errors')) {
|
||||
console.error(err);
|
||||
}
|
||||
}
|
||||
|
||||
res.status(err.status || 500);
|
||||
res.json({
|
||||
message: err.message,
|
||||
error: app.get('env') === 'development' ? err : {}
|
||||
});
|
||||
if (err instanceof errors.APIError) {
|
||||
res.status(err.status).json({
|
||||
message: err.message,
|
||||
error: err
|
||||
});
|
||||
} else {
|
||||
res.status(500).json({});
|
||||
}
|
||||
});
|
||||
|
||||
app.use('/', (err, req, res, next) => {
|
||||
if (err !== ErrNotFound) {
|
||||
if (err !== errors.ErrNotFound) {
|
||||
console.error(err);
|
||||
}
|
||||
|
||||
res.status(err.status || 500);
|
||||
res.render('error', {
|
||||
message: err.message,
|
||||
error: app.get('env') === 'development' ? err : {}
|
||||
});
|
||||
if (err instanceof errors.APIError) {
|
||||
res.status(err.status);
|
||||
res.render('error', {
|
||||
message: err.message,
|
||||
error: app.get('env') === 'development' ? err : {}
|
||||
});
|
||||
} else {
|
||||
res.render('error', {
|
||||
message: err.message,
|
||||
error: app.get('env') === 'development' ? err : {}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = app;
|
||||
|
||||
@@ -29,5 +29,5 @@
|
||||
"as": "POSTMARK"
|
||||
}],
|
||||
"image": "heroku/nodejs",
|
||||
"success_url": "/admin/setup"
|
||||
"success_url": "/admin/install"
|
||||
}
|
||||
|
||||
@@ -1,31 +1,28 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
// Perform rewrites to the runtime environment variables based on the contents
|
||||
// of the process.env.REWRITE_ENV if it exists. This is done here as it is the
|
||||
// entrypoint for the entire application.
|
||||
require('env-rewrite').rewrite();
|
||||
|
||||
/**
|
||||
* Module dependencies.
|
||||
*/
|
||||
|
||||
const program = require('commander');
|
||||
const pkg = require('../package.json');
|
||||
|
||||
//==============================================================================
|
||||
// Setting up the program command line arguments.
|
||||
//==============================================================================
|
||||
// const util = require('./util');
|
||||
const program = require('./commander');
|
||||
|
||||
program
|
||||
.version(pkg.version)
|
||||
.command('serve', 'serve the application')
|
||||
.command('assets', 'interact with assets')
|
||||
.command('settings', 'work with the application settings')
|
||||
.command('setup', 'setup the application')
|
||||
.command('jobs', 'work with the job queues')
|
||||
.command('users', 'work with the application auth')
|
||||
.parse(process.argv);
|
||||
|
||||
// If there is no command listed, output help.
|
||||
if (!process.argv.slice(2).length) {
|
||||
program.outputHelp();
|
||||
}
|
||||
/**
|
||||
* When this provess exists, check to see if we have a running command, if we do
|
||||
* check to see if it is still running. If it is, then kill it with a SIGINT
|
||||
* signal. This is for the use case where we want to kill the process that is
|
||||
* labled with the PID written out by the parent process.
|
||||
*/
|
||||
process.once('exit', () => {
|
||||
if ((program.runningCommand.killed === false) && (program.runningCommand.exitCode === null)) {
|
||||
program.runningCommand.kill('SIGINT');
|
||||
}
|
||||
});
|
||||
|
||||
+5
-9
@@ -4,14 +4,13 @@
|
||||
* Module dependencies.
|
||||
*/
|
||||
|
||||
const program = require('commander');
|
||||
const pkg = require('../package.json');
|
||||
const program = require('./commander');
|
||||
const parseDuration = require('parse-duration');
|
||||
const Table = require('cli-table');
|
||||
const Asset = require('../models/asset');
|
||||
const AssetModel = require('../models/asset');
|
||||
const mongoose = require('../services/mongoose');
|
||||
const scraper = require('../services/scraper');
|
||||
const util = require('../util');
|
||||
const util = require('./util');
|
||||
|
||||
// Register the shutdown criteria.
|
||||
util.onshutdown([
|
||||
@@ -22,7 +21,7 @@ util.onshutdown([
|
||||
* Lists all the assets registered in the database.
|
||||
*/
|
||||
function listAssets() {
|
||||
Asset
|
||||
AssetModel
|
||||
.find({})
|
||||
.sort({'created_at': 1})
|
||||
.then((asset) => {
|
||||
@@ -56,7 +55,7 @@ function refreshAssets(ageString) {
|
||||
const ageMs = parseDuration(ageString);
|
||||
const age = new Date(now - ageMs);
|
||||
|
||||
Asset.find({
|
||||
AssetModel.find({
|
||||
$or: [
|
||||
{
|
||||
scraped: {
|
||||
@@ -86,9 +85,6 @@ function refreshAssets(ageString) {
|
||||
// Setting up the program command line arguments.
|
||||
//==============================================================================
|
||||
|
||||
program
|
||||
.version(pkg.version);
|
||||
|
||||
program
|
||||
.command('list')
|
||||
.description('list all the assets in the database')
|
||||
|
||||
+8
-4
@@ -4,9 +4,10 @@
|
||||
* Module dependencies.
|
||||
*/
|
||||
|
||||
const program = require('commander');
|
||||
const program = require('./commander');
|
||||
const scraper = require('../services/scraper');
|
||||
const util = require('../util');
|
||||
const mailer = require('../services/mailer');
|
||||
const util = require('./util');
|
||||
const mongoose = require('../services/mongoose');
|
||||
const kue = require('../services/kue');
|
||||
|
||||
@@ -19,13 +20,16 @@ util.onshutdown([
|
||||
*/
|
||||
function processJobs() {
|
||||
|
||||
// Start the processor.
|
||||
// Start the scraper processor.
|
||||
scraper.process();
|
||||
|
||||
// Start the mail processor.
|
||||
mailer.process();
|
||||
|
||||
// The scraper only needs to shutdown when the scraper has actually been
|
||||
// started.
|
||||
util.onshutdown([
|
||||
() => scraper.shutdown()
|
||||
() => kue.Task.shutdown()
|
||||
]);
|
||||
}
|
||||
|
||||
|
||||
+17
-25
@@ -1,18 +1,19 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
const app = require('../app');
|
||||
const debug = require('debug')('talk:server');
|
||||
const program = require('./commander');
|
||||
const http = require('http');
|
||||
const init = require('../init');
|
||||
const scraper = require('../services/scraper');
|
||||
const mailer = require('../services/mailer');
|
||||
const kue = require('../services/kue');
|
||||
const mongoose = require('../services/mongoose');
|
||||
const util = require('../util');
|
||||
const util = require('./util');
|
||||
|
||||
/**
|
||||
* Get port from environment and store in Express.
|
||||
*/
|
||||
|
||||
const port = normalizePort(process.env.TALK_PORT || (process.env.NODE_ENV === 'test' ? '3011' : '3000'));
|
||||
const port = normalizePort(process.env.TALK_PORT || '3000');
|
||||
|
||||
app.set('port', port);
|
||||
|
||||
@@ -77,34 +78,22 @@ function onListening() {
|
||||
let bind = typeof addr === 'string'
|
||||
? `pipe ${ addr}`
|
||||
: `port ${ addr.port}`;
|
||||
debug(`Listening on ${ bind}`);
|
||||
console.log(`Listening on ${ bind}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Start the app.
|
||||
*/
|
||||
function startApp() {
|
||||
init().then(() => {
|
||||
|
||||
/**
|
||||
* Listen on provided port, on all network interfaces.
|
||||
*/
|
||||
server.listen(port);
|
||||
server.on('error', onError);
|
||||
server.on('listening', onListening);
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error(err);
|
||||
util.shutdown(1);
|
||||
});
|
||||
/**
|
||||
* Listen on provided port, on all network interfaces.
|
||||
*/
|
||||
server.listen(port);
|
||||
server.on('error', onError);
|
||||
server.on('listening', onListening);
|
||||
}
|
||||
|
||||
/**
|
||||
* Module dependencies.
|
||||
*/
|
||||
|
||||
const program = require('commander');
|
||||
|
||||
//==============================================================================
|
||||
// Setting up the program command line arguments.
|
||||
//==============================================================================
|
||||
@@ -119,15 +108,18 @@ startApp();
|
||||
// Enable job processing on the thread if enabled.
|
||||
if (program.jobs) {
|
||||
|
||||
// Start the processor.
|
||||
// Start the scraper processor.
|
||||
scraper.process();
|
||||
|
||||
// Start the mail processor.
|
||||
mailer.process();
|
||||
}
|
||||
|
||||
// Define a safe shutdown function to call in the event we need to shutdown
|
||||
// because the node hooks are below which will interrupt the shutdown process.
|
||||
// Shutdown the mongoose connection, the app server, and the scraper.
|
||||
util.onshutdown([
|
||||
() => program.jobs ? scraper.shutdown() : null,
|
||||
() => program.jobs ? kue.Task.shutdown() : null,
|
||||
() => mongoose.disconnect(),
|
||||
() => server.close()
|
||||
]);
|
||||
|
||||
@@ -1,45 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
/**
|
||||
* Module dependencies.
|
||||
*/
|
||||
|
||||
const program = require('commander');
|
||||
const mongoose = require('../services/mongoose');
|
||||
const Setting = require('../models/setting');
|
||||
const util = require('../util');
|
||||
|
||||
// Register the shutdown criteria.
|
||||
util.onshutdown([
|
||||
() => mongoose.disconnect()
|
||||
]);
|
||||
|
||||
//==============================================================================
|
||||
// Setting up the program command line arguments.
|
||||
//==============================================================================
|
||||
|
||||
program
|
||||
.command('init')
|
||||
.description('initilizes the talk settings')
|
||||
.action(() => {
|
||||
const defaults = {id: '1', moderation: 'pre'};
|
||||
|
||||
Setting
|
||||
.update({id: '1'}, {$setOnInsert: defaults}, {upsert: true})
|
||||
.then(() => {
|
||||
console.log('Created settings object.');
|
||||
util.shutdown();
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error(`failed to create the settings object ${JSON.stringify(err)}`);
|
||||
util.shutdown(1);
|
||||
});
|
||||
});
|
||||
|
||||
program.parse(process.argv);
|
||||
|
||||
// If there is no command listed, output help.
|
||||
if (!process.argv.slice(2).length) {
|
||||
program.outputHelp();
|
||||
util.shutdown();
|
||||
}
|
||||
Executable
+197
@@ -0,0 +1,197 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
/**
|
||||
* Module dependencies.
|
||||
*/
|
||||
|
||||
const program = require('./commander');
|
||||
const inquirer = require('inquirer');
|
||||
const mongoose = require('../services/mongoose');
|
||||
const SettingModel = require('../models/setting');
|
||||
const SettingsService = require('../services/settings');
|
||||
const SetupService = require('../services/setup');
|
||||
const UsersService = require('../services/users');
|
||||
const util = require('./util');
|
||||
const errors = require('../errors');
|
||||
|
||||
// Register the shutdown criteria.
|
||||
util.onshutdown([
|
||||
() => mongoose.disconnect()
|
||||
]);
|
||||
|
||||
//==============================================================================
|
||||
// Setting up the program command line arguments.
|
||||
//==============================================================================
|
||||
|
||||
program
|
||||
.description('runs the setup wizard to setup the application')
|
||||
.option('--defaults', 'apply defaults for config instead of prompting')
|
||||
.parse(process.argv);
|
||||
|
||||
//==============================================================================
|
||||
// Setup the application
|
||||
//==============================================================================
|
||||
|
||||
const performSetup = () => {
|
||||
|
||||
if (program.defaults) {
|
||||
return SettingsService
|
||||
.init()
|
||||
.then(() => {
|
||||
console.log('Settings created.');
|
||||
console.log('\nTalk is now installed!');
|
||||
util.shutdown();
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error(err);
|
||||
util.shutdown(1);
|
||||
});
|
||||
}
|
||||
|
||||
// Get the current settings, we are expecing an error here.
|
||||
return SettingsService
|
||||
.retrieve()
|
||||
.then(() => {
|
||||
|
||||
// We should NOT have gotten a settings object, this means that the
|
||||
// application is already setup. Error out here.
|
||||
throw errors.ErrSettingsInit;
|
||||
|
||||
})
|
||||
.catch((err) => {
|
||||
|
||||
// If the error is `not init`, then we're good, otherwise, it's something
|
||||
// else.
|
||||
if (err !== errors.ErrSettingsNotInit) {
|
||||
throw err;
|
||||
}
|
||||
|
||||
})
|
||||
.then(() => {
|
||||
|
||||
// Create the base settings model.
|
||||
let settings = new SettingModel();
|
||||
|
||||
console.log('We\'ll ask you some questions in order to setup your installation of Talk.\n');
|
||||
|
||||
return inquirer.prompt([
|
||||
{
|
||||
type: 'input',
|
||||
name: 'organizationName',
|
||||
message: 'Organization Name',
|
||||
default: settings.organizationName,
|
||||
validate: (input) => {
|
||||
if (input && input.length > 0) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return 'Organization Name is required.';
|
||||
}
|
||||
},
|
||||
{
|
||||
type: 'list',
|
||||
choices: SettingModel.MODERATION_OPTIONS,
|
||||
name: 'moderation',
|
||||
default: settings.moderation,
|
||||
message: 'Select a moderation mode'
|
||||
},
|
||||
{
|
||||
type: 'confirm',
|
||||
name: 'requireEmailConfirmation',
|
||||
default: settings.requireEmailConfirmation,
|
||||
message: 'Should emails always be confirmed'
|
||||
}
|
||||
])
|
||||
.then((answers) => {
|
||||
|
||||
// Update the settings that were changed.
|
||||
Object.keys(answers).forEach((key) => {
|
||||
if (answers[key] !== undefined) {
|
||||
settings[key] = answers[key];
|
||||
}
|
||||
});
|
||||
|
||||
console.log('\nWe\'ll ask you some questions about your first admin user.\n');
|
||||
|
||||
return inquirer.prompt([
|
||||
{
|
||||
type: 'input',
|
||||
name: 'username',
|
||||
message: 'Username',
|
||||
filter: (username) => {
|
||||
return UsersService
|
||||
.isValidUsername(username, false)
|
||||
.catch((err) => {
|
||||
throw err.message;
|
||||
});
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'email',
|
||||
message: 'Email',
|
||||
format: 'email',
|
||||
validate: (value) => {
|
||||
if (value && value.length >= 3) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return 'Email is required';
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'password',
|
||||
message: 'Password',
|
||||
type: 'password',
|
||||
filter: (password) => {
|
||||
return UsersService
|
||||
.isValidPassword(password)
|
||||
.catch((err) => {
|
||||
throw err.message;
|
||||
});
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'confirmPassword',
|
||||
message: 'Confirm Password',
|
||||
type: 'password',
|
||||
filter: (confirmPassword) => {
|
||||
return UsersService
|
||||
.isValidPassword(confirmPassword)
|
||||
.catch((err) => {
|
||||
throw err.message;
|
||||
});
|
||||
}
|
||||
},
|
||||
]);
|
||||
})
|
||||
.then((user) => {
|
||||
|
||||
if (user.password !== user.confirmPassword) {
|
||||
return Promise.reject(new Error('Passwords do not match'));
|
||||
}
|
||||
|
||||
return SetupService.setup({
|
||||
settings: settings.toObject(),
|
||||
user: {
|
||||
email: user.email,
|
||||
username: user.username,
|
||||
password: user.password
|
||||
}
|
||||
});
|
||||
});
|
||||
})
|
||||
.then(({user}) => {
|
||||
console.log('Settings created.');
|
||||
console.log(`User ${user.id} created.`);
|
||||
console.log('\nTalk is now installed!');
|
||||
console.log('\nWe recommend adding TALK_INSTALL_LOCK=TRUE to your environment to turn off the dynamic setup.');
|
||||
util.shutdown();
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error(err);
|
||||
util.shutdown(1);
|
||||
});
|
||||
};
|
||||
|
||||
// Start tthe setup process.
|
||||
performSetup();
|
||||
+155
-116
@@ -4,101 +4,139 @@
|
||||
* Module dependencies.
|
||||
*/
|
||||
|
||||
const program = require('commander');
|
||||
const pkg = require('../package.json');
|
||||
const prompt = require('prompt');
|
||||
const User = require('../models/user');
|
||||
const program = require('./commander');
|
||||
const inquirer = require('inquirer');
|
||||
const UsersService = require('../services/users');
|
||||
const UserModel = require('../models/user');
|
||||
const mongoose = require('../services/mongoose');
|
||||
const util = require('../util');
|
||||
const util = require('./util');
|
||||
const Table = require('cli-table');
|
||||
|
||||
const validateRequired = (msg = 'Field is required', len = 1) => (input) => {
|
||||
if (input && input.length >= len) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return msg;
|
||||
};
|
||||
|
||||
// Regeister the shutdown criteria.
|
||||
util.onshutdown([
|
||||
() => mongoose.disconnect()
|
||||
]);
|
||||
|
||||
function getUserCreateAnswers(options) {
|
||||
if (options.flag_mode) {
|
||||
|
||||
let user = {
|
||||
email: options.email,
|
||||
password: options.password,
|
||||
confirmPassword: options.password,
|
||||
username: options.name,
|
||||
roles: []
|
||||
};
|
||||
|
||||
if (options.role && UserModel.USER_ROLES.indexOf(options.role) > -1) {
|
||||
user.roles = [options.role];
|
||||
}
|
||||
|
||||
return Promise.resolve(user);
|
||||
}
|
||||
|
||||
return inquirer.prompt([
|
||||
{
|
||||
name: 'email',
|
||||
message: 'Email',
|
||||
format: 'email',
|
||||
validate: validateRequired('Email is required')
|
||||
},
|
||||
{
|
||||
name: 'password',
|
||||
message: 'Password',
|
||||
type: 'password',
|
||||
filter: (password) => {
|
||||
return UsersService
|
||||
.isValidPassword(password)
|
||||
.catch((err) => {
|
||||
throw err.message;
|
||||
});
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'confirmPassword',
|
||||
message: 'Confirm Password',
|
||||
type: 'password',
|
||||
filter: (confirmPassword) => {
|
||||
return UsersService
|
||||
.isValidPassword(confirmPassword)
|
||||
.catch((err) => {
|
||||
throw err.message;
|
||||
});
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'username',
|
||||
message: 'Username',
|
||||
filter: (username) => {
|
||||
return UsersService
|
||||
.isValidUsername(username)
|
||||
.catch((err) => {
|
||||
throw err.message;
|
||||
});
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'roles',
|
||||
message: 'User Role',
|
||||
type: 'checkbox',
|
||||
choices: UserModel.USER_ROLES
|
||||
}
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Prompts for input and registers a user based on those.
|
||||
*/
|
||||
function createUser(options) {
|
||||
return new Promise((resolve, reject) => {
|
||||
|
||||
if (options.flag_mode) {
|
||||
return resolve({
|
||||
email: options.email,
|
||||
password: options.password,
|
||||
displayName: options.name,
|
||||
role: options.role
|
||||
});
|
||||
}
|
||||
|
||||
prompt.start();
|
||||
|
||||
prompt.get([
|
||||
{
|
||||
name: 'email',
|
||||
description: 'Email',
|
||||
format: 'email',
|
||||
required: true
|
||||
},
|
||||
{
|
||||
name: 'password',
|
||||
description: 'Password',
|
||||
hidden: true,
|
||||
required: true
|
||||
},
|
||||
{
|
||||
name: 'confirmPassword',
|
||||
description: 'Confirm Password',
|
||||
hidden: true,
|
||||
required: true
|
||||
},
|
||||
{
|
||||
name: 'displayName',
|
||||
description: 'Display Name',
|
||||
required: true
|
||||
},
|
||||
{
|
||||
name: 'role',
|
||||
description: 'User Role',
|
||||
required: false
|
||||
}
|
||||
], (err, result) => {
|
||||
if (err) {
|
||||
return reject(err);
|
||||
getUserCreateAnswers(options)
|
||||
.then((answers) => {
|
||||
if (answers.password !== answers.confirmPassword) {
|
||||
return Promise.reject(new Error('Passwords do not match'));
|
||||
}
|
||||
|
||||
if (result.password !== result.confirmPassword) {
|
||||
return reject(new Error('Passwords do not match'));
|
||||
}
|
||||
return answers;
|
||||
})
|
||||
.then((answers) => {
|
||||
return UsersService
|
||||
.createLocalUser(answers.email.trim(), answers.password.trim(), answers.username.trim())
|
||||
.then((user) => {
|
||||
console.log(`Created user ${user.id}.`);
|
||||
|
||||
resolve(result);
|
||||
});
|
||||
})
|
||||
.then((result) => {
|
||||
return User.createLocalUser(result.email.trim(), result.password.trim(), result.displayName.trim())
|
||||
.then((user) => {
|
||||
console.log(`Created user ${user.id}.`);
|
||||
|
||||
return User
|
||||
.addRoleToUser(user.id, result.role.trim())
|
||||
.then(() => {
|
||||
console.log(`Added the admin ${result.role.trim()} to User ${user.id}.`);
|
||||
util.shutdown();
|
||||
if (answers.roles.length > 0) {
|
||||
return Promise.all(answers.roles.map((role) => {
|
||||
return UsersService
|
||||
.addRoleToUser(user.id, role)
|
||||
.then(() => {
|
||||
console.log(`Added the role ${role} to User ${user.id}.`);
|
||||
});
|
||||
}));
|
||||
}
|
||||
});
|
||||
})
|
||||
.then(() => {
|
||||
util.shutdown();
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error(err);
|
||||
util.shutdown();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes a user.
|
||||
*/
|
||||
function deleteUser(userID) {
|
||||
User
|
||||
UserModel
|
||||
.findOneAndRemove({
|
||||
id: userID
|
||||
})
|
||||
@@ -116,44 +154,34 @@ function deleteUser(userID) {
|
||||
* Changes the password for a user.
|
||||
*/
|
||||
function passwd(userID) {
|
||||
prompt.start();
|
||||
|
||||
prompt.get([
|
||||
inquirer.prompt([
|
||||
{
|
||||
name: 'password',
|
||||
description: 'Password',
|
||||
hidden: true,
|
||||
required: true
|
||||
message: 'Password',
|
||||
type: 'password',
|
||||
validate: validateRequired('Password is required')
|
||||
},
|
||||
{
|
||||
name: 'confirmPassword',
|
||||
description: 'Confirm Password',
|
||||
hidden: true,
|
||||
required: true
|
||||
message: 'Confirm Password',
|
||||
type: 'password',
|
||||
validate: validateRequired('Confirm Password is required')
|
||||
}
|
||||
], (err, result) => {
|
||||
if (err) {
|
||||
console.error(err);
|
||||
util.shutdown();
|
||||
return;
|
||||
])
|
||||
.then((answers) => {
|
||||
if (answers.password !== answers.confirmPassword) {
|
||||
return Promise.reject(new Error('Password mismatch'));
|
||||
}
|
||||
|
||||
if (result.password !== result.confirmPassword) {
|
||||
console.error(new Error('Password mismatch'));
|
||||
util.shutdown(1);
|
||||
return;
|
||||
}
|
||||
|
||||
User
|
||||
.changePassword(userID, result.password)
|
||||
.then(() => {
|
||||
console.log('Password changed.');
|
||||
util.shutdown();
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error(err);
|
||||
util.shutdown(1);
|
||||
});
|
||||
return UsersService.changePassword(userID, answers.password);
|
||||
})
|
||||
.then(() => {
|
||||
console.log('Password changed.');
|
||||
util.shutdown();
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error(err);
|
||||
util.shutdown(1);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -164,7 +192,7 @@ function updateUser(userID, options) {
|
||||
const updates = [];
|
||||
|
||||
if (options.email && typeof options.email === 'string' && options.email.length > 0) {
|
||||
let q = User.update({
|
||||
let q = UserModel.update({
|
||||
'id': userID,
|
||||
'profiles.provider': 'local'
|
||||
}, {
|
||||
@@ -177,11 +205,11 @@ function updateUser(userID, options) {
|
||||
}
|
||||
|
||||
if (options.name && typeof options.name === 'string' && options.name.length > 0) {
|
||||
let q = User.update({
|
||||
let q = UserModel.update({
|
||||
'id': userID
|
||||
}, {
|
||||
$set: {
|
||||
displayName: options.name
|
||||
username: options.name
|
||||
}
|
||||
});
|
||||
|
||||
@@ -204,13 +232,13 @@ function updateUser(userID, options) {
|
||||
* Lists all the users registered in the database.
|
||||
*/
|
||||
function listUsers() {
|
||||
User
|
||||
UsersService
|
||||
.all()
|
||||
.then((users) => {
|
||||
let table = new Table({
|
||||
head: [
|
||||
'ID',
|
||||
'Display Name',
|
||||
'Username',
|
||||
'Profiles',
|
||||
'Roles',
|
||||
'Status',
|
||||
@@ -221,7 +249,7 @@ function listUsers() {
|
||||
users.forEach((user) => {
|
||||
table.push([
|
||||
user.id,
|
||||
user.displayName,
|
||||
user.username,
|
||||
user.profiles.map((p) => p.provider).join(', '),
|
||||
user.roles.join(', '),
|
||||
user.status,
|
||||
@@ -244,7 +272,7 @@ function listUsers() {
|
||||
* @param {String} srcUserID id of the user to which is the source of the merge
|
||||
*/
|
||||
function mergeUsers(dstUserID, srcUserID) {
|
||||
User
|
||||
UsersService
|
||||
.mergeUsers(dstUserID, srcUserID)
|
||||
.then(() => {
|
||||
console.log(`User ${srcUserID} was merged into user ${dstUserID}.`);
|
||||
@@ -262,7 +290,14 @@ function mergeUsers(dstUserID, srcUserID) {
|
||||
* @param {String} role the role to add
|
||||
*/
|
||||
function addRole(userID, role) {
|
||||
User
|
||||
|
||||
if (UserModel.USER_ROLES.indexOf(role) === -1) {
|
||||
console.error(`Role '${role}' is not supported. Supported roles are ${UserModel.USER_ROLES.join(', ')}.`);
|
||||
util.shutdown(1);
|
||||
return;
|
||||
}
|
||||
|
||||
UsersService
|
||||
.addRoleToUser(userID, role)
|
||||
.then(() => {
|
||||
console.log(`Added the ${role} role to User ${userID}.`);
|
||||
@@ -280,7 +315,14 @@ function addRole(userID, role) {
|
||||
* @param {String} role the role to remove
|
||||
*/
|
||||
function removeRole(userID, role) {
|
||||
User
|
||||
|
||||
if (UserModel.USER_ROLES.indexOf(role) === -1) {
|
||||
console.error(`Role '${role}' is not supported. Supported roles are ${UserModel.USER_ROLES.join(', ')}.`);
|
||||
util.shutdown(1);
|
||||
return;
|
||||
}
|
||||
|
||||
UsersService
|
||||
.removeRoleFromUser(userID, role)
|
||||
.then(() => {
|
||||
console.log(`Removed the ${role} role from User ${userID}.`);
|
||||
@@ -297,8 +339,8 @@ function removeRole(userID, role) {
|
||||
* @param {String} userID id of the user to ban
|
||||
*/
|
||||
function ban(userID) {
|
||||
User
|
||||
.setStatus(userID, 'banned', '')
|
||||
UsersService
|
||||
.setStatus(userID, 'BANNED')
|
||||
.then(() => {
|
||||
console.log(`Banned the User ${userID}.`);
|
||||
util.shutdown();
|
||||
@@ -314,8 +356,8 @@ function ban(userID) {
|
||||
* @param {String} userUD id of the user to remove the role from
|
||||
*/
|
||||
function unban(userID) {
|
||||
User
|
||||
.setStatus(userID, 'active', '')
|
||||
UsersService
|
||||
.setStatus(userID, 'ACTIVE')
|
||||
.then(() => {
|
||||
console.log(`Unban the User ${userID}.`);
|
||||
util.shutdown();
|
||||
@@ -331,7 +373,7 @@ function unban(userID) {
|
||||
* @param {String} userID the ID of a user to disable
|
||||
*/
|
||||
function disableUser(userID) {
|
||||
User
|
||||
UsersService
|
||||
.disableUser(userID)
|
||||
.then(() => {
|
||||
console.log(`User ${userID} was disabled.`);
|
||||
@@ -348,7 +390,7 @@ function disableUser(userID) {
|
||||
* @param {String} userID the ID of a user to enable
|
||||
*/
|
||||
function enableUser(userID) {
|
||||
User
|
||||
UsersService
|
||||
.enableUser(userID)
|
||||
.then(() => {
|
||||
console.log(`User ${userID} was enabled.`);
|
||||
@@ -364,9 +406,6 @@ function enableUser(userID) {
|
||||
// Setting up the program command line arguments.
|
||||
//==============================================================================
|
||||
|
||||
program
|
||||
.version(pkg.version);
|
||||
|
||||
program
|
||||
.command('create')
|
||||
.option('--email [email]', 'Email to use')
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
const pkg = require('../package.json');
|
||||
const dotenv = require('dotenv');
|
||||
const fs = require('fs');
|
||||
const program = require('commander');
|
||||
|
||||
// Perform rewrites to the runtime environment variables based on the contents
|
||||
// of the process.env.REWRITE_ENV if it exists. This is done here as it is the
|
||||
// entrypoint for the entire application.
|
||||
require('env-rewrite').rewrite();
|
||||
|
||||
//==============================================================================
|
||||
// Setting up the program command line arguments.
|
||||
//==============================================================================
|
||||
|
||||
const parseArgs = require('minimist')(process.argv.slice(2), {
|
||||
alias: {
|
||||
'c': 'config'
|
||||
},
|
||||
string: [
|
||||
'config',
|
||||
'pid'
|
||||
],
|
||||
default: {
|
||||
'config': null,
|
||||
'pid': null
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* If the config flag is present, then we have to load the configuration from
|
||||
* the file specified. We will then load those values into the environment.
|
||||
*/
|
||||
if (parseArgs.config) {
|
||||
let envConfig = dotenv.parse(fs.readFileSync(parseArgs.config, {encoding: 'utf8'}));
|
||||
|
||||
Object.keys(envConfig).forEach((k) => {
|
||||
process.env[k] = envConfig[k];
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* If the pid flag is present, then we have to create a pid file at the location
|
||||
* specified.
|
||||
*/
|
||||
if (parseArgs.pid) {
|
||||
const util = require('./util');
|
||||
|
||||
console.log('Wrote PID');
|
||||
|
||||
util.pid(parseArgs.pid);
|
||||
}
|
||||
|
||||
module.exports = program
|
||||
.version(pkg.version)
|
||||
.option('-c, --config [path]', 'Specify the configuration file to load')
|
||||
.option('--pid [path]', 'Specify a path to output the current PID to');
|
||||
+94
@@ -0,0 +1,94 @@
|
||||
const debug = require('debug')('talk:util');
|
||||
const fs = require('fs');
|
||||
|
||||
const util = module.exports = {};
|
||||
|
||||
/**
|
||||
* Stores an array of functions that should be executed in the event that the
|
||||
* application needs to shutdown.
|
||||
* @type {Array}
|
||||
*/
|
||||
util.toshutdown = [];
|
||||
|
||||
/**
|
||||
* Calls all the shutdown functions and then ends the process.
|
||||
* @param {Number} [defaultCode=0] default return code upon sucesfull shutdown.
|
||||
*/
|
||||
util.shutdown = (defaultCode = 0, signal = null) => {
|
||||
|
||||
if (signal) {
|
||||
debug(`Reached ${signal} signal`);
|
||||
}
|
||||
|
||||
debug(`${util.toshutdown.length} jobs now being called`);
|
||||
|
||||
Promise
|
||||
.all(util.toshutdown.map((func) => func ? func(signal) : null).filter((func) => func))
|
||||
.then(() => {
|
||||
debug('Shutdown complete, now exiting');
|
||||
process.exit(defaultCode);
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error(err);
|
||||
|
||||
process.exit(1);
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Waits until an event is triggered by the node runtime and elevates a series
|
||||
* of jobs to be ran in the event we need to shutdown.
|
||||
* @param {Array} jobs Array of promise capable shutdown functions that are
|
||||
* executed.
|
||||
*/
|
||||
util.onshutdown = (jobs) => {
|
||||
|
||||
debug(`${jobs.length} jobs registered to be called during shutdown`);
|
||||
|
||||
// Add the new jobs to shutdown to the object reference.
|
||||
util.toshutdown = util.toshutdown.concat(jobs);
|
||||
};
|
||||
|
||||
/**
|
||||
* Register a PID file to be maintained for the lifespan of the process.
|
||||
* @param {String} path path to the PID file to create
|
||||
*/
|
||||
util.pid = (path) => {
|
||||
if (!/\//.test(path)) {
|
||||
if (!/\.pid/.test(path)) {
|
||||
path += '.pid';
|
||||
}
|
||||
path = `/tmp/${path}`;
|
||||
}
|
||||
|
||||
const pid = `${process.pid.toString()}\n`;
|
||||
|
||||
fs.writeFile(path, pid, (err) => {
|
||||
if (err) {
|
||||
console.error(`Can't write PID file: ${err}`);
|
||||
throw err;
|
||||
}
|
||||
|
||||
// Add the cleanup for the fs onto the shutdown.
|
||||
util.onshutdown([
|
||||
() => new Promise((resolve, reject) => {
|
||||
|
||||
// Remove the pid file.
|
||||
fs.unlink(path, (err) => {
|
||||
if (err) {
|
||||
return reject(err);
|
||||
}
|
||||
|
||||
return resolve();
|
||||
});
|
||||
})
|
||||
]);
|
||||
});
|
||||
};
|
||||
|
||||
// Attach to the SIGTERM + SIGINT handles to ensure a clean shutdown in the
|
||||
// event that we have an external event. SIGUSR2 is called when the app is asked
|
||||
// to be 'killed', same procedure here.
|
||||
process.on('SIGTERM', () => util.shutdown(0, 'SIGTERM'));
|
||||
process.on('SIGINT', () => util.shutdown(0, 'SIGINT'));
|
||||
process.once('SIGUSR2', () => util.shutdown(0, 'SIGUSR2'));
|
||||
+37
-4
@@ -3,16 +3,49 @@ machine:
|
||||
version: 7
|
||||
services:
|
||||
- docker
|
||||
- redis
|
||||
environment:
|
||||
PATH: "${PATH}:${HOME}/${CIRCLE_PROJECT_REPONAME}/node_modules/.bin"
|
||||
NODE_ENV: "test"
|
||||
|
||||
dependencies:
|
||||
override:
|
||||
# TODO: use the following to add in support for MongoDB 3.4.
|
||||
# # Upgrade the database version to 3.4.
|
||||
# - sudo apt-get purge mongodb-org*
|
||||
# - sudo apt-key adv --keyserver hkp://keyserver.ubuntu.com:80 --recv 0C49F3730359A14518585931BC711F9BA15703C6
|
||||
# - echo "deb [ arch=amd64 ] http://repo.mongodb.org/apt/ubuntu precise/mongodb-org/3.4 multiverse" | sudo tee /etc/apt/sources.list.d/mongodb-org-3.4.list
|
||||
# - sudo apt-get update
|
||||
# - sudo apt-get install -y mongodb-org
|
||||
# - sudo service mongod restart
|
||||
|
||||
# Install node dependencies.
|
||||
- yarn
|
||||
cache_directories:
|
||||
- ~/.cache/yarn
|
||||
post:
|
||||
# Build the static assets.
|
||||
- yarn build
|
||||
# Lint the project here, before tests are ran.
|
||||
- yarn lint
|
||||
|
||||
database:
|
||||
post:
|
||||
# Initialize the settings in the database, this will create indicies for the
|
||||
# database.
|
||||
- ./bin/cli setup --defaults
|
||||
- sleep 2
|
||||
|
||||
test:
|
||||
override:
|
||||
- MOCHA_FILE=$CIRCLE_TEST_REPORTS/junit/test-results.xml ./node_modules/.bin/mocha tests --reporter mocha-junit-reporter
|
||||
- npm run lint
|
||||
- npm run build
|
||||
# Run the tests using the junit reporter.
|
||||
- MOCHA_FILE=$CIRCLE_TEST_REPORTS/junit/test-results.xml MOCHA_REPORTER=mocha-junit-reporter yarn test
|
||||
# Run the e2e test suite.
|
||||
# - E2E_REPORT_PATH=$CIRCLE_TEST_REPORTS/e2e yarn e2e
|
||||
|
||||
deployment:
|
||||
release:
|
||||
tag: /[0-9]+(\.[0-9]+)*/
|
||||
tag: /v[0-9]+(\.[0-9]+)*/
|
||||
commands:
|
||||
- bash ./scripts/deploy.sh
|
||||
|
||||
|
||||
@@ -5,19 +5,19 @@ This app handles moderation for Talk (and maybe more later on)
|
||||
|
||||
## Installation
|
||||
|
||||
$ npm install
|
||||
$ yarn install
|
||||
$ cp config.sample.json config.json
|
||||
|
||||
Then change `config.json` to adjust it to your project
|
||||
|
||||
## Building for production
|
||||
|
||||
$ npm run build
|
||||
$ yarn build
|
||||
|
||||
The public folder has everything you need for deployment. You can just copy that folder to your favorite static web server
|
||||
|
||||
## Development
|
||||
|
||||
$ npm start
|
||||
$ yarn start
|
||||
|
||||
A development server will be running at http://localhost:4132
|
||||
|
||||
@@ -1,21 +1,43 @@
|
||||
import React from 'react';
|
||||
import {Router, Route, IndexRoute, browserHistory} from 'react-router';
|
||||
import {Router, Route, IndexRoute, IndexRedirect, browserHistory} from 'react-router';
|
||||
|
||||
import ModerationQueue from 'containers/ModerationQueue/ModerationQueue';
|
||||
import CommentStream from 'containers/CommentStream/CommentStream';
|
||||
import Configure from 'containers/Configure/Configure';
|
||||
import Streams from 'containers/Streams/Streams';
|
||||
import CommunityContainer from 'containers/Community/CommunityContainer';
|
||||
import Configure from 'containers/Configure/Configure';
|
||||
import LayoutContainer from 'containers/LayoutContainer';
|
||||
import InstallContainer from 'containers/Install/InstallContainer';
|
||||
import CommunityContainer from 'containers/Community/CommunityContainer';
|
||||
|
||||
import ModerationLayout from 'containers/ModerationQueue/ModerationLayout';
|
||||
import ModerationContainer from 'containers/ModerationQueue/ModerationContainer';
|
||||
import Dashboard from 'containers/Dashboard/Dashboard';
|
||||
|
||||
const routes = (
|
||||
<Route path='/admin' component={LayoutContainer}>
|
||||
<IndexRoute component={ModerationQueue} />
|
||||
<Route path='embed' component={CommentStream} />
|
||||
<Route path='community' component={CommunityContainer} />
|
||||
<Route path='configure' component={Configure} />
|
||||
<Route path='streams' component={Streams} />
|
||||
</Route>
|
||||
<div>
|
||||
<Route exact path="/admin/install" component={InstallContainer}/>
|
||||
<Route path='/admin' component={LayoutContainer}>
|
||||
<IndexRoute component={ModerationContainer} />
|
||||
<Route path='community' component={CommunityContainer} />
|
||||
<Route path='configure' component={Configure} />
|
||||
<Route path='streams' component={Streams} />
|
||||
<Route path='dashboard' component={Dashboard} />
|
||||
|
||||
{/* Moderation Routes */}
|
||||
|
||||
<Route path='moderate' component={ModerationLayout}>
|
||||
<Route path='premod' components={ModerationContainer}>
|
||||
<Route path=':id' components={ModerationContainer} />
|
||||
</Route>
|
||||
<Route path='rejected' components={ModerationContainer}>
|
||||
<Route path=':id' components={ModerationContainer} />
|
||||
</Route>
|
||||
<Route path='flagged' components={ModerationContainer}>
|
||||
<Route path=':id' components={ModerationContainer} />
|
||||
</Route>
|
||||
<Route path=':id' components={ModerationContainer} />
|
||||
<IndexRedirect to='premod' />
|
||||
</Route>
|
||||
</Route>
|
||||
</div>
|
||||
);
|
||||
|
||||
const AppRouter = () => <Router history={browserHistory} routes={routes} />;
|
||||
|
||||
@@ -4,8 +4,10 @@ import {
|
||||
FETCH_ASSETS_FAILURE,
|
||||
UPDATE_ASSET_STATE_REQUEST,
|
||||
UPDATE_ASSET_STATE_SUCCESS,
|
||||
UPDATE_ASSET_STATE_FAILURE
|
||||
UPDATE_ASSET_STATE_FAILURE,
|
||||
UPDATE_ASSETS
|
||||
} from '../constants/assets';
|
||||
|
||||
import coralApi from '../../../coral-framework/helpers/response';
|
||||
|
||||
/**
|
||||
@@ -34,3 +36,7 @@ export const updateAssetState = (id, closedAt) => (dispatch) => {
|
||||
dispatch({type: UPDATE_ASSET_STATE_SUCCESS}))
|
||||
.catch(error => dispatch({type: UPDATE_ASSET_STATE_FAILURE, error}));
|
||||
};
|
||||
|
||||
export const updateAssets = assets => dispatch => {
|
||||
dispatch({type: UPDATE_ASSETS, assets});
|
||||
};
|
||||
|
||||
@@ -9,12 +9,15 @@ const checkLoginFailure = error => ({type: actions.CHECK_LOGIN_FAILURE, error});
|
||||
|
||||
export const checkLogin = () => dispatch => {
|
||||
dispatch(checkLoginRequest());
|
||||
coralApi('/auth')
|
||||
.then(user => {
|
||||
const isAdmin = !!user.roles.filter(i => i === 'admin').length;
|
||||
dispatch(checkLoginSuccess(user, isAdmin));
|
||||
return coralApi('/auth')
|
||||
.then(result => {
|
||||
const isAdmin = !!result.user.roles.filter(i => i === 'ADMIN').length;
|
||||
dispatch(checkLoginSuccess(result.user, isAdmin));
|
||||
})
|
||||
.catch(error => dispatch(checkLoginFailure(error)));
|
||||
.catch(error => {
|
||||
console.error(error);
|
||||
dispatch(checkLoginFailure(`${error.translation_key}`));
|
||||
});
|
||||
};
|
||||
|
||||
// LogOut Actions
|
||||
|
||||
@@ -1,71 +0,0 @@
|
||||
import coralApi from '../../../coral-framework/helpers/response';
|
||||
import * as commentTypes from '../constants/comments';
|
||||
import * as actionTypes from '../constants/actions';
|
||||
|
||||
// Get comments to fill each of the three lists on the mod queue
|
||||
export const fetchModerationQueueComments = () => {
|
||||
return dispatch => {
|
||||
dispatch({type: commentTypes.COMMENTS_MODERATION_QUEUE_FETCH_REQUEST});
|
||||
return Promise.all([
|
||||
coralApi('/queue/comments/pending'),
|
||||
coralApi('/comments?status=rejected'),
|
||||
coralApi('/comments?action_type=flag')
|
||||
])
|
||||
.then(([pending, rejected, flagged]) => {
|
||||
|
||||
/* Combine seperate calls into a single object */
|
||||
flagged.comments.forEach(comment => comment.flagged = true);
|
||||
return {
|
||||
comments: [...pending.comments, ...rejected.comments, ...flagged.comments],
|
||||
users: [...pending.users, ...rejected.users, ...flagged.users],
|
||||
actions: [...pending.actions, ...rejected.actions, ...flagged.actions]
|
||||
};
|
||||
})
|
||||
.then(({comments, users, actions}) => {
|
||||
|
||||
/* Post comments and users to redux store. Actions will be posted when they are needed. */
|
||||
dispatch({type: commentTypes.USERS_MODERATION_QUEUE_FETCH_SUCCESS, users});
|
||||
dispatch({type: commentTypes.COMMENTS_MODERATION_QUEUE_FETCH_SUCCESS, comments});
|
||||
dispatch({type: actionTypes.ACTIONS_MODERATION_QUEUE_FETCH_SUCCESS, actions});
|
||||
|
||||
});
|
||||
};
|
||||
};
|
||||
|
||||
// Create a new comment
|
||||
export const createComment = (name, body) => {
|
||||
return dispatch => {
|
||||
const comment = {body, name};
|
||||
return coralApi('/comments', {method: 'POST', comment})
|
||||
.then(res => dispatch({type: commentTypes.COMMENT_CREATE_SUCCESS, comment: res}))
|
||||
.catch(error => dispatch({type: commentTypes.COMMENT_CREATE_FAILED, error}));
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Action disptacher related to comments
|
||||
*/
|
||||
|
||||
// Update a comment. Now to update a comment we need to send back the whole object
|
||||
export const updateStatus = (status, comment) => {
|
||||
return dispatch => {
|
||||
dispatch({type: commentTypes.COMMENT_STATUS_UPDATE_REQUEST, id: comment.id, status});
|
||||
return coralApi(`/comments/${comment.id}/status`, {method: 'PUT', body: {status}})
|
||||
.then(res => dispatch({type: commentTypes.COMMENT_STATUS_UPDATE_SUCCESS, res}))
|
||||
.catch(error => dispatch({type: commentTypes.COMMENT_STATUS_UPDATE_FAILURE, error}));
|
||||
};
|
||||
};
|
||||
|
||||
export const flagComment = id => (dispatch, getState) => {
|
||||
dispatch({type: commentTypes.COMMENT_FLAG, id});
|
||||
dispatch({type: 'COMMENT_UPDATE', comment: getState().comments.get('byId').get(id)});
|
||||
};
|
||||
|
||||
// Dialog Actions
|
||||
export const showBanUserDialog = (userId, userName, commentId) => {
|
||||
return {type: commentTypes.SHOW_BANUSER_DIALOG, userId, userName, commentId};
|
||||
};
|
||||
|
||||
export const hideBanUserDialog = (showDialog) => {
|
||||
return {type: commentTypes.HIDE_BANUSER_DIALOG, showDialog};
|
||||
};
|
||||
@@ -41,14 +41,15 @@ export const newPage = () => ({
|
||||
type: COMMENTERS_NEW_PAGE
|
||||
});
|
||||
|
||||
export const setRole = (id, role) => dispatch => {
|
||||
export const setRole = (id, role) => (dispatch) => {
|
||||
|
||||
return coralApi(`/users/${id}/role`, {method: 'POST', body: {role}})
|
||||
.then(() => {
|
||||
return dispatch({type: SET_ROLE, id, role});
|
||||
});
|
||||
};
|
||||
|
||||
export const setCommenterStatus = (id, status) => dispatch => {
|
||||
export const setCommenterStatus = (id, status) => (dispatch) => {
|
||||
return coralApi(`/users/${id}/status`, {method: 'POST', body: {status}})
|
||||
.then(() => {
|
||||
return dispatch({type: SET_COMMENTER_STATUS, id, status});
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
import coralApi from 'coral-framework/helpers/response';
|
||||
import * as actions from '../constants/install';
|
||||
import validate from 'coral-framework/helpers/validate';
|
||||
import errorMsj from 'coral-framework/helpers/error';
|
||||
|
||||
export const nextStep = () => ({type: actions.NEXT_STEP});
|
||||
export const previousStep = () => ({type: actions.PREVIOUS_STEP});
|
||||
export const goToStep = step => ({type: actions.GO_TO_STEP, step});
|
||||
|
||||
const installRequest = () => ({type: actions.INSTALL_REQUEST});
|
||||
const installSuccess = () => ({type: actions.INSTALL_SUCCESS});
|
||||
const installFailure = error => ({type: actions.INSTALL_FAILURE, error});
|
||||
|
||||
const addError = (name, error) => ({type: actions.ADD_ERROR, name, error});
|
||||
const hasError = error => ({type: actions.HAS_ERROR, error});
|
||||
const clearErrors = () => ({type: actions.CLEAR_ERRORS});
|
||||
|
||||
const validation = (formData, dispatch, next) => {
|
||||
if (!(formData != null)) {
|
||||
return dispatch(hasError());
|
||||
}
|
||||
|
||||
// Required Validation
|
||||
const empty = Object.keys(formData).filter(name => {
|
||||
const cond = !formData[name].length;
|
||||
|
||||
if (cond) {
|
||||
|
||||
// Adding Error
|
||||
dispatch(addError(name, 'This field is required.'));
|
||||
} else {
|
||||
dispatch(addError(name, ''));
|
||||
}
|
||||
|
||||
return cond;
|
||||
});
|
||||
|
||||
if (empty.length) {
|
||||
return dispatch(hasError());
|
||||
}
|
||||
|
||||
// RegExp Validation
|
||||
const validation = Object.keys(formData).filter(name => {
|
||||
const cond = !validate[name](formData[name]);
|
||||
if (cond) {
|
||||
|
||||
// Adding Error
|
||||
dispatch(addError(name, errorMsj[name]));
|
||||
} else {
|
||||
dispatch(addError(name, ''));
|
||||
}
|
||||
|
||||
return cond;
|
||||
});
|
||||
|
||||
if (validation.length) {
|
||||
return dispatch(hasError());
|
||||
}
|
||||
|
||||
dispatch(clearErrors());
|
||||
next();
|
||||
};
|
||||
|
||||
export const submitSettings = () => (dispatch, getState) => {
|
||||
const settingsFormData = getState().install.toJS().data.settings;
|
||||
validation(settingsFormData, dispatch, function() {
|
||||
dispatch(nextStep());
|
||||
});
|
||||
};
|
||||
|
||||
export const submitUser = () => (dispatch, getState) => {
|
||||
const userFormData = getState().install.toJS().data.user;
|
||||
validation(userFormData, dispatch, function() {
|
||||
const data = getState().install.toJS().data;
|
||||
dispatch(installRequest());
|
||||
coralApi('/setup', {method: 'POST', body: data})
|
||||
.then(result => {
|
||||
console.log(result);
|
||||
dispatch(installSuccess());
|
||||
dispatch(nextStep());
|
||||
})
|
||||
.catch(error => {
|
||||
console.error(error);
|
||||
dispatch(installFailure(`${error.translation_key}`));
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
export const updateSettingsFormData = (name, value) => ({type: actions.UPDATE_FORMDATA_SETTINGS, name, value});
|
||||
export const updateUserFormData = (name, value) => ({type: actions.UPDATE_FORMDATA_USER, name, value});
|
||||
|
||||
const checkInstallRequest = () => ({type: actions.CHECK_INSTALL_REQUEST});
|
||||
const checkInstallSuccess = installed => ({type: actions.CHECK_INSTALL_SUCCESS, installed});
|
||||
const checkInstallFailure = error => ({type: actions.CHECK_INSTALL_FAILURE, error});
|
||||
|
||||
export const checkInstall = next => dispatch => {
|
||||
dispatch(checkInstallRequest());
|
||||
coralApi('/setup')
|
||||
.then(({installed}) => {
|
||||
dispatch(checkInstallSuccess(installed));
|
||||
if (installed) {
|
||||
next();
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
console.error(error);
|
||||
dispatch(checkInstallFailure(`${error.translation_key}`));
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,8 @@
|
||||
import * as actions from 'constants/moderation';
|
||||
|
||||
export const toggleModal = open => ({type: actions.TOGGLE_MODAL, open});
|
||||
export const singleView = () => ({type: actions.SINGLE_VIEW});
|
||||
|
||||
// Ban User Dialog
|
||||
export const showBanUserDialog = (user, commentId) => ({type: actions.SHOW_BANUSER_DIALOG, user, commentId});
|
||||
export const hideBanUserDialog = (showDialog) => ({type: actions.HIDE_BANUSER_DIALOG, showDialog});
|
||||
@@ -11,6 +11,7 @@ export const SAVE_SETTINGS_SUCCESS = 'SAVE_SETTINGS_SUCCESS';
|
||||
export const SAVE_SETTINGS_FAILED = 'SAVE_SETTINGS_FAILED';
|
||||
|
||||
export const WORDLIST_UPDATED = 'WORDLIST_UPDATED';
|
||||
export const DOMAINLIST_UPDATED = 'DOMAINLIST_UPDATED';
|
||||
|
||||
export const fetchSettings = () => dispatch => {
|
||||
dispatch({type: SETTINGS_LOADING});
|
||||
@@ -33,8 +34,12 @@ export const updateWordlist = (listName, list) => {
|
||||
return {type: WORDLIST_UPDATED, listName, list};
|
||||
};
|
||||
|
||||
export const updateDomainlist = (listName, list) => {
|
||||
return {type: DOMAINLIST_UPDATED, listName, list};
|
||||
};
|
||||
|
||||
export const saveSettingsToServer = () => (dispatch, getState) => {
|
||||
let settings = getState().settings.toJS().settings;
|
||||
let settings = getState().settings.toJS();
|
||||
if (settings.charCount) {
|
||||
settings.charCount = parseInt(settings.charCount);
|
||||
}
|
||||
|
||||
@@ -1,15 +1,31 @@
|
||||
import coralApi from '../../../coral-framework/helpers/response';
|
||||
import * as actions from '../constants/user';
|
||||
import * as userTypes from '../constants/users';
|
||||
|
||||
/**
|
||||
* Action disptacher related to users
|
||||
*/
|
||||
// change status of a user
|
||||
export const userStatusUpdate = (status, userId, commentId) => {
|
||||
return dispatch => {
|
||||
dispatch({type: actions.UPDATE_STATUS_REQUEST});
|
||||
return (dispatch) => {
|
||||
dispatch({type: userTypes.UPDATE_STATUS_REQUEST});
|
||||
return coralApi(`/users/${userId}/status`, {method: 'POST', body: {status: status, comment_id: commentId}})
|
||||
.then(res => dispatch({type: actions.UPDATE_STATUS_SUCCESS, res}))
|
||||
.catch(error => dispatch({type: actions.UPDATE_STATUS_FAILURE, error}));
|
||||
.then(res => dispatch({type: userTypes.UPDATE_STATUS_SUCCESS, res}))
|
||||
.catch(error => dispatch({type: userTypes.UPDATE_STATUS_FAILURE, error}));
|
||||
};
|
||||
};
|
||||
|
||||
// change status of a user
|
||||
export const sendNotificationEmail = (userId, subject, body) => {
|
||||
return (dispatch) => {
|
||||
return coralApi(`/users/${userId}/email`, {method: 'POST', body: {subject, body}})
|
||||
.catch(error => dispatch({type: userTypes.USER_EMAIL_FAILURE, error}));
|
||||
};
|
||||
};
|
||||
|
||||
// let a user edit their username
|
||||
export const enableUsernameEdit = (userId) => {
|
||||
return (dispatch) => {
|
||||
return coralApi(`/users/${userId}/username-enable`, {method: 'POST'})
|
||||
.catch(error => dispatch({type: userTypes.USERNAME_ENABLE_FAILURE, error}));
|
||||
};
|
||||
};
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
import React from 'react';
|
||||
import styles from './ModerationList.css';
|
||||
import BanUserButton from './BanUserButton';
|
||||
import {FabButton} from 'coral-ui';
|
||||
import {menuActionsMap} from '../containers/ModerationQueue/helpers/moderationQueueActionsMap';
|
||||
|
||||
const ActionButton = ({type = '', user, ...props}) => {
|
||||
if (type === 'BAN') {
|
||||
return <BanUserButton user={user} onClick={() => props.showBanUserDialog(props.user, props.id)} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<FabButton
|
||||
className={`${type.toLowerCase()} ${styles.actionButton}`}
|
||||
cStyle={type.toLowerCase()}
|
||||
icon={menuActionsMap[type].icon}
|
||||
onClick={type === 'APPROVE' ? props.acceptComment : props.rejectComment}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default ActionButton;
|
||||
@@ -0,0 +1,10 @@
|
||||
.banButton {
|
||||
width: 114px;
|
||||
letter-spacing: 1px;
|
||||
|
||||
i {
|
||||
vertical-align: middle;
|
||||
margin-right: 10px;
|
||||
font-size: 14px;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import React, {PropTypes} from 'react';
|
||||
import {Button, Icon} from 'coral-ui';
|
||||
import styles from './BanUserButton.css';
|
||||
|
||||
import I18n from 'coral-framework/modules/i18n/i18n';
|
||||
import translations from 'coral-admin/src/translations.json';
|
||||
const lang = new I18n(translations);
|
||||
|
||||
const BanUserButton = ({user, ...props}) => (
|
||||
<div className={styles.ban}>
|
||||
<Button cStyle='darkGrey'
|
||||
className={`ban ${styles.banButton}`}
|
||||
disabled={user.status === 'BANNED' ? 'disabled' : ''}
|
||||
onClick={props.onClick}
|
||||
raised>
|
||||
<Icon name='not_interested' />
|
||||
{lang.t('comment.ban_user')}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
|
||||
BanUserButton.propTypes = {
|
||||
onClick: PropTypes.func.isRequired
|
||||
};
|
||||
|
||||
export default BanUserButton;
|
||||
@@ -1,30 +1,38 @@
|
||||
.dialog {
|
||||
border: none;
|
||||
box-shadow: 0 9px 46px 8px rgba(0, 0, 0, 0.14), 0 11px 15px -7px rgba(0, 0, 0, 0.12), 0 24px 38px 3px rgba(0, 0, 0, 0.2);
|
||||
width: 280px;
|
||||
top: 10px;
|
||||
border: none;
|
||||
box-shadow: 0 9px 46px 8px rgba(0, 0, 0, 0.14), 0 11px 15px -7px rgba(0, 0, 0, 0.12), 0 24px 38px 3px rgba(0, 0, 0, 0.2);
|
||||
width: 500px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
height: 184px;
|
||||
padding: 20px;
|
||||
|
||||
h2 {
|
||||
color: black;
|
||||
font-size: 1.76em;
|
||||
font-weight: 500;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
h3 {
|
||||
color: black;
|
||||
font-size: 1.4em;
|
||||
font-weight: 500;
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.header {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.header h1, .separator h1{
|
||||
text-align: center;
|
||||
font-size: 1.2em;
|
||||
}
|
||||
|
||||
.formField {
|
||||
.textField {
|
||||
margin-top: 15px;
|
||||
}
|
||||
|
||||
.formField label {
|
||||
.textField label {
|
||||
font-size: 1.08em;
|
||||
font-weight: bold;
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
|
||||
.formField input {
|
||||
.textField input {
|
||||
width: 100%;
|
||||
display: block;
|
||||
border: none;
|
||||
@@ -143,5 +151,14 @@ input.error{
|
||||
}
|
||||
|
||||
.cancel {
|
||||
margin: 10px 0;
|
||||
margin-right: 10px;
|
||||
width: 47%;
|
||||
}
|
||||
|
||||
.ban {
|
||||
width: 47%;
|
||||
}
|
||||
|
||||
.buttons {
|
||||
margin: 20px 0;
|
||||
}
|
||||
|
||||
@@ -1,45 +1,46 @@
|
||||
import React from 'react';
|
||||
|
||||
import React, {PropTypes} from 'react';
|
||||
import {Dialog} from 'coral-ui';
|
||||
import Button from 'coral-ui/components/Button';
|
||||
|
||||
import styles from './BanUserDialog.css';
|
||||
|
||||
import Button from 'coral-ui/components/Button';
|
||||
|
||||
import I18n from 'coral-framework/modules/i18n/i18n';
|
||||
import translations from '../translations';
|
||||
const lang = new I18n(translations);
|
||||
|
||||
const BanUserDialog = ({open, handleClose, onClickBanUser, user = {}}) => {
|
||||
const {userName = '', userId = '', commentId = ''} = user;
|
||||
|
||||
return (
|
||||
<Dialog className={styles.dialog} open={open} onClose={() => handleClose()} onCancel={() => handleClose()} title={lang.t('bandialog.ban_user')}>
|
||||
<span className={styles.close} onClick={() => handleClose()}>×</span>
|
||||
<div>
|
||||
<div className={styles.header}>
|
||||
<h3>
|
||||
{lang.t('bandialog.ban_user')}
|
||||
</h3>
|
||||
</div>
|
||||
<div className={styles.separator}>
|
||||
<h4>
|
||||
{lang.t('bandialog.are_you_sure', userName)}
|
||||
</h4>
|
||||
<i>
|
||||
{lang.t('bandialog.note')}
|
||||
</i>
|
||||
</div>
|
||||
<div className={styles.buttons}>
|
||||
<Button cStyle="cancel" className={styles.cancel} onClick={() => handleClose()} full>
|
||||
{lang.t('bandialog.cancel')}
|
||||
</Button>
|
||||
<Button cStyle="black" onClick={() => onClickBanUser(userId, commentId)} full>
|
||||
{lang.t('bandialog.yes_ban_user')}
|
||||
</Button>
|
||||
</div>
|
||||
const BanUserDialog = ({open, handleClose, handleBanUser, user}) => (
|
||||
<Dialog
|
||||
className={styles.dialog}
|
||||
id="banuserDialog"
|
||||
open={open}
|
||||
onClose={handleClose}
|
||||
onCancel={handleClose}
|
||||
title={lang.t('bandialog.ban_user')}>
|
||||
<span className={styles.close} onClick={handleClose}>×</span>
|
||||
<div>
|
||||
<div className={styles.header}>
|
||||
<h2>{lang.t('bandialog.ban_user')}</h2>
|
||||
</div>
|
||||
<div className={styles.separator}>
|
||||
<h3>{lang.t('bandialog.are_you_sure', user.name)}</h3>
|
||||
<i>{lang.t('bandialog.note')}</i>
|
||||
</div>
|
||||
<div className={styles.buttons}>
|
||||
<Button cStyle="cancel" className={styles.cancel} onClick={handleClose} raised>
|
||||
{lang.t('bandialog.cancel')}
|
||||
</Button>
|
||||
<Button cStyle="black" className={styles.ban} onClick={() => handleBanUser({userId: user.id})} raised>
|
||||
{lang.t('bandialog.yes_ban_user')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Dialog>
|
||||
);
|
||||
);
|
||||
|
||||
BanUserDialog.propTypes = {
|
||||
handleBanUser: PropTypes.func.isRequired,
|
||||
handleClose: PropTypes.func.isRequired,
|
||||
user: PropTypes.object.isRequired,
|
||||
};
|
||||
|
||||
export default BanUserDialog;
|
||||
|
||||
@@ -2,40 +2,48 @@ import React from 'react';
|
||||
import timeago from 'timeago.js';
|
||||
import Linkify from 'react-linkify';
|
||||
|
||||
import styles from './CommentList.css';
|
||||
import styles from './ModerationList.css';
|
||||
|
||||
import I18n from 'coral-framework/modules/i18n/i18n';
|
||||
import translations from '../translations.json';
|
||||
|
||||
import {Icon} from 'react-mdl';
|
||||
import Highlighter from 'react-highlight-words';
|
||||
import {FabButton, Button} from 'coral-ui';
|
||||
import {Icon} from 'coral-ui';
|
||||
import ActionButton from './ActionButton';
|
||||
|
||||
const linkify = new Linkify();
|
||||
|
||||
// Render a single comment for the list
|
||||
export default props => {
|
||||
const Comment = props => {
|
||||
const {comment, author} = props;
|
||||
let authorStatus = author.status;
|
||||
const links = linkify.getMatches(comment.body);
|
||||
|
||||
return (
|
||||
<li tabIndex={props.index} className={`${styles.listItem} ${props.isActive && !props.hideActive ? styles.activeItem : ''}`}>
|
||||
<li tabIndex={props.index} className={`mdl-card mdl-shadow--2dp ${styles.listItem} ${props.isActive && !props.hideActive ? styles.activeItem : ''}`}>
|
||||
<div className={styles.itemHeader}>
|
||||
<div className={styles.author}>
|
||||
<i className={`material-icons ${styles.avatar}`}>person</i>
|
||||
<span>{author.displayName || lang.t('comment.anon')}</span>
|
||||
<span>{author.username || lang.t('comment.anon')}</span>
|
||||
<span className={styles.created}>{timeago().format(comment.createdAt || (Date.now() - props.index * 60 * 1000), lang.getLocale().replace('-', '_'))}</span>
|
||||
{comment.flagged ? <p className={styles.flagged}>{lang.t('comment.flagged')}</p> : null}
|
||||
</div>
|
||||
<div>
|
||||
<div className={styles.sideActions}>
|
||||
{links ?
|
||||
<span className={styles.hasLinks}><Icon name='error_outline'/> Contains Link</span> : null}
|
||||
<div className={`actions ${styles.actions}`}>
|
||||
{props.modActions.map((action, i) => getActionButton(action, i, props))}
|
||||
{props.modActions.map(
|
||||
(action, i) =>
|
||||
<ActionButton
|
||||
option={action}
|
||||
key={i}
|
||||
type='COMMENTS'
|
||||
comment={comment}
|
||||
user={author}
|
||||
menuOptionsMap={props.menuOptionsMap}
|
||||
onClickAction={props.onClickAction}
|
||||
onClickShowBanDialog={props.onClickShowBanDialog}/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
{authorStatus === 'banned' ?
|
||||
<span className={styles.banned}><Icon name='error_outline'/> {lang.t('comment.banned_user')}</span> : null}
|
||||
</div>
|
||||
@@ -53,39 +61,7 @@ export default props => {
|
||||
);
|
||||
};
|
||||
|
||||
// Get the button of the action performed over a comment if any
|
||||
const getActionButton = (action, i, props) => {
|
||||
const {comment, author} = props;
|
||||
const status = comment.status;
|
||||
const flagged = comment.flagged;
|
||||
const banned = (author.status === 'banned');
|
||||
|
||||
if (action === 'flag' && (status || flagged === true)) {
|
||||
return null;
|
||||
}
|
||||
if (action === 'ban') {
|
||||
return (
|
||||
<Button
|
||||
className='ban'
|
||||
cStyle='black'
|
||||
disabled={banned ? 'disabled' : ''}
|
||||
onClick={() => props.onClickShowBanDialog(author.id, author.displayName, comment.id)}
|
||||
key={i}
|
||||
>
|
||||
{lang.t('comment.ban_user')}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<FabButton
|
||||
className={`${action} ${styles.actionButton}`}
|
||||
cStyle={action}
|
||||
icon={props.actionsMap[action].icon}
|
||||
key={i}
|
||||
onClick={() => props.onClickAction(props.actionsMap[action].status, comment)}
|
||||
/>
|
||||
);
|
||||
};
|
||||
export default Comment;
|
||||
|
||||
const linkStyles = {
|
||||
backgroundColor: 'rgb(255, 219, 135)',
|
||||
|
||||
@@ -1,168 +0,0 @@
|
||||
import React, {PropTypes} from 'react';
|
||||
import styles from './CommentList.css';
|
||||
import key from 'keymaster';
|
||||
import Hammer from 'hammerjs';
|
||||
import Comment from 'components/Comment';
|
||||
|
||||
// Each action has different meaning and configuration
|
||||
const modActions = {
|
||||
'reject': {status: 'rejected', icon: 'close', key: 'r'},
|
||||
'approve': {status: 'accepted', icon: 'done', key: 't'},
|
||||
'flag': {status: 'flagged', icon: 'flag', filter: 'Untouched'},
|
||||
'ban': {status: 'banned', icon: 'not interested'}
|
||||
};
|
||||
|
||||
// Renders a comment list and allow performing actions
|
||||
export default class CommentList extends React.Component {
|
||||
static propTypes = {
|
||||
isActive: PropTypes.bool,
|
||||
singleView: PropTypes.bool,
|
||||
commentIds: PropTypes.arrayOf(PropTypes.string).isRequired,
|
||||
comments: PropTypes.object.isRequired,
|
||||
users: PropTypes.object.isRequired,
|
||||
onClickAction: PropTypes.func,
|
||||
modActions: PropTypes.arrayOf(PropTypes.string),
|
||||
loading: PropTypes.bool,
|
||||
|
||||
// list of actions (flags, etc) associated with the comments
|
||||
actions: PropTypes.shape({
|
||||
ids: PropTypes.arrayOf(PropTypes.string)
|
||||
}),
|
||||
suspectWords: PropTypes.arrayOf(PropTypes.string)
|
||||
}
|
||||
|
||||
constructor (props) {
|
||||
super(props);
|
||||
|
||||
this.state = {active: null};
|
||||
this.onClickAction = this.onClickAction.bind(this);
|
||||
this.onClickShowBanDialog = this.onClickShowBanDialog.bind(this);
|
||||
}
|
||||
|
||||
// remove key handlers before leaving
|
||||
componentWillUnmount () {
|
||||
this.unbindKeyHandlers();
|
||||
}
|
||||
|
||||
// add key handlers and gestures
|
||||
componentDidMount () {
|
||||
this.bindKeyHandlers();
|
||||
|
||||
// this.bindGestures() // need to check whether we're on a mobile device or this throws an Error
|
||||
}
|
||||
|
||||
// If entering to singleview and no active, active is the first eleement
|
||||
componentWillReceiveProps (nextProps) {
|
||||
if (nextProps.singleView && !this.state.active) {
|
||||
this.setState({active: nextProps.commentIds[0]});
|
||||
}
|
||||
}
|
||||
|
||||
// Add swipe to approve or reject
|
||||
bindGestures () {
|
||||
const {modActions} = this.props;
|
||||
this._hammer = new Hammer(this.base);
|
||||
this._hammer.get('swipe').set({direction: Hammer.DIRECTION_HORIZONTAL});
|
||||
|
||||
if (modActions.indexOf('reject') !== -1) {
|
||||
this._hammer.on('swipeleft', () => this.props.singleView && this.actionKeyHandler('Rejected'));
|
||||
}
|
||||
if (modActions.indexOf('approve') !== -1) {
|
||||
this._hammer.on('swiperight', () => this.props.singleView && this.actionKeyHandler('Approved'));
|
||||
}
|
||||
}
|
||||
|
||||
// Add key handlers. Each action has one and added j/k for moving around
|
||||
bindKeyHandlers () {
|
||||
this.props.modActions.filter(action => modActions[action].key).forEach(action => {
|
||||
key(modActions[action].key, 'commentList', () => this.props.isActive && this.actionKeyHandler(modActions[action].status));
|
||||
});
|
||||
key('j', 'commentList', () => this.props.isActive && this.moveKeyHandler('down'));
|
||||
key('k', 'commentList', () => this.props.isActive && this.moveKeyHandler('up'));
|
||||
key.setScope('commentList');
|
||||
}
|
||||
|
||||
// Perform an action using the keys only if the comment is active
|
||||
actionKeyHandler (action) {
|
||||
if (this.props.isActive && this.state.active) {
|
||||
this.onClickAction(action, this.state.active);
|
||||
}
|
||||
}
|
||||
|
||||
// move around with j/k
|
||||
moveKeyHandler (direction) {
|
||||
if (!this.props.isActive) {
|
||||
return;
|
||||
}
|
||||
|
||||
const {commentIds} = this.props;
|
||||
const {active} = this.state;
|
||||
|
||||
// check boundaries
|
||||
if (active === null || !commentIds.length) {
|
||||
this.setState({active: commentIds[0]});
|
||||
} else if (direction === 'up' && active !== commentIds[0]) {
|
||||
this.setState({active: commentIds[commentIds.indexOf(active) - 1]});
|
||||
} else if (direction === 'down' && active !== commentIds[commentIds.length - 1]) {
|
||||
this.setState({active: commentIds[commentIds.indexOf(active) + 1]});
|
||||
}
|
||||
|
||||
// scroll to the position
|
||||
const index = Math.max(commentIds.indexOf(this.state.active), 0);
|
||||
this.base.childNodes[index] && this.base.childNodes[index].focus();
|
||||
}
|
||||
|
||||
unbindKeyHandlers () {
|
||||
key.deleteScope('commentList');
|
||||
}
|
||||
|
||||
// If we are performing an action over a comment (aka removing from the list) we need to select a new active.
|
||||
// TODO: In the future this can be improved and look at the actual state to
|
||||
// resolve since the content of the list could change externally. For now it works as expected
|
||||
onClickAction (action, id, author_id) {
|
||||
|
||||
// activate the next comment
|
||||
if (id === this.state.active) {
|
||||
const {commentIds} = this.props;
|
||||
if (commentIds[commentIds.length - 1] === this.state.active) {
|
||||
this.setState({active: commentIds[commentIds.length - 2]});
|
||||
} else {
|
||||
this.setState({active: commentIds[Math.min(commentIds.indexOf(this.state.active) + 1, commentIds.length - 1)]});
|
||||
}
|
||||
}
|
||||
this.props.onClickAction(action, id, author_id);
|
||||
}
|
||||
|
||||
onClickShowBanDialog(userId, userName, commentId) {
|
||||
this.props.onClickShowBanDialog(userId, userName, commentId);
|
||||
}
|
||||
|
||||
render () {
|
||||
const {singleView, commentIds, comments, users, hideActive, key, suspectWords} = this.props;
|
||||
const {active} = this.state;
|
||||
|
||||
return (
|
||||
<ul
|
||||
className={`${styles.list} ${singleView ? styles.singleView : ''}`} {...key}
|
||||
id='commentList'
|
||||
>
|
||||
{commentIds.map((commentId, index) => {
|
||||
const comment = comments[commentId];
|
||||
const author = users[comment.author_id];
|
||||
return <Comment
|
||||
suspectWords={suspectWords}
|
||||
comment={comment}
|
||||
author={author}
|
||||
key={index}
|
||||
index={index}
|
||||
onClickAction={this.onClickAction}
|
||||
onClickShowBanDialog={this.onClickShowBanDialog}
|
||||
modActions={this.props.modActions}
|
||||
actionsMap={modActions}
|
||||
isActive={commentId === active}
|
||||
hideActive={hideActive} />;
|
||||
})}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import React, {PropTypes} from 'react';
|
||||
import {Card} from 'coral-ui';
|
||||
|
||||
const EmptyCard = props => (
|
||||
<Card style={{textAlign: 'center', maxWidth: 400, margin: '0 auto'}}>
|
||||
{props.children}
|
||||
</Card>
|
||||
);
|
||||
|
||||
EmptyCard.propTypes = {
|
||||
children: PropTypes.node.isRequired
|
||||
};
|
||||
|
||||
export default EmptyCard;
|
||||
@@ -0,0 +1,34 @@
|
||||
.heading {
|
||||
margin: 0;
|
||||
font-size: 1.5rem;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.widgetTable {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
box-shadow: 0px 0px 5px 0px rgba(0,0,0,0.2);
|
||||
}
|
||||
|
||||
.widgetTable thead th {
|
||||
border-bottom: 1px solid #f47e6b;
|
||||
padding: 10px;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.widgetTable tbody tr {
|
||||
border-bottom: 1px solid lightgrey;
|
||||
}
|
||||
|
||||
.widgetTable tbody tr:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.widgetTable tbody td {
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.lede {
|
||||
font-size: 0.9em;
|
||||
color: grey;
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
import React, {PropTypes} from 'react';
|
||||
import {Link} from 'react-router';
|
||||
import styles from './FlagWidget.css';
|
||||
import I18n from 'coral-framework/modules/i18n/i18n';
|
||||
import translations from 'coral-admin/src/translations';
|
||||
|
||||
const lang = new I18n(translations);
|
||||
|
||||
const FlagWidget = ({assets}) => {
|
||||
|
||||
return (
|
||||
<table className={styles.widgetTable}>
|
||||
<thead className={styles.widgetHead}>
|
||||
<tr>
|
||||
<th></th>{/* empty on purpose */}
|
||||
<th>{lang.t('streams.article')}</th>
|
||||
<th>{lang.t('modqueue.flagged')}</th>
|
||||
<th>{lang.t('modqueue.likes')}</th>
|
||||
<th>{lang.t('dashboard.comment_count')}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{
|
||||
assets.length
|
||||
? assets.map((asset, index) => {
|
||||
const flagSummary = asset.action_summaries.find(s => s.__typename === 'FlagAssetActionSummary');
|
||||
const likeSummary = asset.action_summaries.find(s => s.__typename === 'LikeAssetActionSummary');
|
||||
return (
|
||||
<tr key={asset.id}>
|
||||
<td>{index + 1}.</td>
|
||||
<td>
|
||||
<Link to={`/admin/moderate/flagged/${asset.id}`}>{asset.title}</Link>
|
||||
<p className={styles.lede}>{asset.author} - Published: {new Date(asset.created_at).toLocaleDateString()}</p>
|
||||
</td>
|
||||
<td>{flagSummary ? flagSummary.actionCount : 0}</td>
|
||||
<td>{likeSummary ? likeSummary.actionCount : 0}</td>
|
||||
<td>{asset.commentCount}</td>
|
||||
</tr>
|
||||
);
|
||||
})
|
||||
: <tr><td colSpan="3">{lang.t('dashboard.no_flags')}</td></tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
);
|
||||
};
|
||||
|
||||
FlagWidget.propTypes = {
|
||||
assets: PropTypes.arrayOf(
|
||||
PropTypes.shape({
|
||||
id: PropTypes.string,
|
||||
title: PropTypes.string,
|
||||
url: PropTypes.string,
|
||||
commentCount: PropTypes.number,
|
||||
action_summaries: PropTypes.arrayOf(
|
||||
PropTypes.shape({
|
||||
__typename: PropTypes.string.isRequired,
|
||||
actionCount: PropTypes.number.isRequired,
|
||||
actionableItemCount: PropTypes.number.isRequired
|
||||
})
|
||||
)
|
||||
})
|
||||
).isRequired
|
||||
};
|
||||
|
||||
export default FlagWidget;
|
||||
@@ -19,7 +19,6 @@
|
||||
|
||||
.inner {
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
background-color: #fff;
|
||||
box-shadow: 2px 2px 5px 0px rgba(153,153,153,1);
|
||||
padding: 20px;
|
||||
@@ -37,7 +36,6 @@
|
||||
@media (--big-viewport) {
|
||||
.inner {
|
||||
width: 600px;
|
||||
height: 50vh;
|
||||
border-radius: 4px;
|
||||
}
|
||||
}
|
||||
|
||||
+55
-15
@@ -36,19 +36,40 @@
|
||||
|
||||
.listItem {
|
||||
border-bottom: 1px solid #e0e0e0;
|
||||
padding: 16px;
|
||||
font-size: 16px;
|
||||
width: 100%;
|
||||
max-width: 660px;
|
||||
min-width: 400px;
|
||||
margin: 0 auto;
|
||||
padding: 16px 14px;
|
||||
position: relative;
|
||||
transition: box-shadow 200ms;
|
||||
|
||||
|
||||
&:hover {
|
||||
box-shadow: 0 3px 6px rgba(0,0,0,0.16), 0 3px 6px rgba(0,0,0,0.23);
|
||||
}
|
||||
|
||||
&:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.sideActions {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
height: 100%;
|
||||
top: 0;
|
||||
padding: 40px 18px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.itemHeader {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
|
||||
.author {
|
||||
min-width: 230px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
@@ -71,25 +92,15 @@
|
||||
|
||||
.created {
|
||||
color: #666;
|
||||
font-size: 10px;
|
||||
margin-left: 10px;
|
||||
}
|
||||
|
||||
.actionButton {
|
||||
transform: scale(.7);
|
||||
margin: 0;
|
||||
font-size: 13px;
|
||||
margin-left: 40px;
|
||||
}
|
||||
|
||||
.body {
|
||||
margin-top: 20px;
|
||||
flex: 1;
|
||||
font-size: 1em;
|
||||
color: rgba(0,0,0,.54);
|
||||
}
|
||||
|
||||
.actions {
|
||||
margin-left: 10px;
|
||||
display: flex;
|
||||
font-size: 0.88em;
|
||||
color: black;
|
||||
}
|
||||
|
||||
.flagged {
|
||||
@@ -97,6 +108,12 @@
|
||||
padding-top: 15px;
|
||||
padding-left: 10px;
|
||||
}
|
||||
|
||||
.flagCount{
|
||||
font-size: 12px;
|
||||
color: #d32f2f;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
.empty {
|
||||
@@ -143,3 +160,26 @@
|
||||
margin-right: 5px;
|
||||
}
|
||||
}
|
||||
|
||||
.ban {
|
||||
display: block;
|
||||
text-align: center;
|
||||
margin-top: 5px;
|
||||
}
|
||||
|
||||
.banButton {
|
||||
width: 114px;
|
||||
letter-spacing: 1px;
|
||||
|
||||
i {
|
||||
vertical-align: middle;
|
||||
margin-right: 10px;
|
||||
font-size: 14px;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
.actionButton {
|
||||
transform: scale(.8);
|
||||
margin: 0;
|
||||
}
|
||||
@@ -0,0 +1,227 @@
|
||||
import React, {PropTypes} from 'react';
|
||||
import styles from './ModerationList.css';
|
||||
import key from 'keymaster';
|
||||
import Hammer from 'hammerjs';
|
||||
import Comment from './Comment';
|
||||
import User from './User';
|
||||
import SuspendUserModal from './SuspendUserModal';
|
||||
|
||||
// Each action has different meaning and configuration
|
||||
const menuOptionsMap = {
|
||||
'reject': {status: 'REJECTED', icon: 'close', key: 'r'},
|
||||
'approve': {status: 'ACCEPTED', icon: 'done', key: 't'},
|
||||
'flag': {status: 'FLAGGED', icon: 'flag', filter: 'Untouched'},
|
||||
'ban': {status: 'BANNED', icon: 'not interested'}
|
||||
};
|
||||
|
||||
// Renders a comment list and allow performing actions
|
||||
export default class ModerationList extends React.Component {
|
||||
static propTypes = {
|
||||
isActive: PropTypes.bool,
|
||||
singleView: PropTypes.bool,
|
||||
commentIds: PropTypes.arrayOf(PropTypes.string),
|
||||
actionIds: PropTypes.arrayOf(PropTypes.string),
|
||||
comments: PropTypes.object,
|
||||
users: PropTypes.object.isRequired,
|
||||
actions: PropTypes.object,
|
||||
userStatusUpdate: PropTypes.func.isRequired,
|
||||
suspendUser: PropTypes.func.isRequired,
|
||||
|
||||
// list of actions (flags, etc) associated with the comments
|
||||
modActions: PropTypes.arrayOf(PropTypes.string).isRequired,
|
||||
loading: PropTypes.bool,
|
||||
|
||||
suspectWords: PropTypes.arrayOf(PropTypes.string).isRequired
|
||||
}
|
||||
|
||||
state = {active: null, suspendUserModal: null, email: null};
|
||||
|
||||
// remove key handlers before leaving
|
||||
componentWillUnmount () {
|
||||
this.unbindKeyHandlers();
|
||||
}
|
||||
|
||||
// add key handlers and gestures
|
||||
componentDidMount () {
|
||||
this.bindKeyHandlers();
|
||||
|
||||
// this.bindGestures() // need to check whether we're on a mobile device or this throws an Error
|
||||
}
|
||||
|
||||
// If entering to singleview and no active, active is the first eleement
|
||||
componentWillReceiveProps (nextProps) {
|
||||
if (nextProps.singleView && !this.state.active) {
|
||||
this.setState({active: nextProps.commentIds[0]});
|
||||
}
|
||||
}
|
||||
|
||||
// Add swipe to approve or reject
|
||||
bindGestures () {
|
||||
const {modActions} = this.props;
|
||||
this._hammer = new Hammer(this.base);
|
||||
this._hammer.get('swipe').set({direction: Hammer.DIRECTION_HORIZONTAL});
|
||||
|
||||
if (modActions.indexOf('reject') !== -1) {
|
||||
this._hammer.on('swipeleft', () => this.props.singleView && this.actionKeyHandler('Rejected'));
|
||||
}
|
||||
if (modActions.indexOf('approve') !== -1) {
|
||||
this._hammer.on('swiperight', () => this.props.singleView && this.actionKeyHandler('Approved'));
|
||||
}
|
||||
}
|
||||
|
||||
// Add key handlers. Each action has one and added j/k for moving around
|
||||
bindKeyHandlers () {
|
||||
const {modActions, isActive} = this.props;
|
||||
modActions.filter(action => menuOptionsMap[action].key).forEach(action => {
|
||||
key(menuOptionsMap[action].key, 'moderationList', () => isActive && this.actionKeyHandler(menuOptionsMap[action].status));
|
||||
});
|
||||
key('j', 'moderationList', () => isActive && this.moveKeyHandler('down'));
|
||||
key('k', 'moderationList', () => isActive && this.moveKeyHandler('up'));
|
||||
key.setScope('moderationList');
|
||||
}
|
||||
|
||||
// Perform an action using the keys only if the comment is active
|
||||
actionKeyHandler (action) {
|
||||
if (this.props.isActive && this.state.active) {
|
||||
this.onClickAction(action, this.state.active);
|
||||
}
|
||||
}
|
||||
|
||||
// move around with j/k
|
||||
moveKeyHandler (direction) {
|
||||
if (!this.props.isActive) {
|
||||
return;
|
||||
}
|
||||
|
||||
const {commentIds} = this.props;
|
||||
const {active} = this.state;
|
||||
|
||||
// check boundaries
|
||||
if (active === null || !commentIds.length) {
|
||||
this.setState({active: commentIds[0]});
|
||||
} else if (direction === 'up' && active !== commentIds[0]) {
|
||||
this.setState({active: commentIds[commentIds.indexOf(active) - 1]});
|
||||
} else if (direction === 'down' && active !== commentIds[commentIds.length - 1]) {
|
||||
this.setState({active: commentIds[commentIds.indexOf(active) + 1]});
|
||||
}
|
||||
|
||||
// scroll to the position
|
||||
const index = Math.max(commentIds.indexOf(this.state.active), 0);
|
||||
this.base.childNodes[index] && this.base.childNodes[index].focus();
|
||||
}
|
||||
|
||||
unbindKeyHandlers () {
|
||||
key.deleteScope('moderationList');
|
||||
}
|
||||
|
||||
// If we are performing an action over a comment (aka removing from the list) we need to select a new active.
|
||||
// TODO: In the future this can be improved and look at the actual state to
|
||||
// resolve since the content of the list could change externally. For now it works as expected
|
||||
onClickAction = (menuOption, id, action) => {
|
||||
|
||||
// activate the next comment
|
||||
if (id === this.state.active) {
|
||||
const moderationIds = this.getModerationIds();
|
||||
if (moderationIds[moderationIds.length - 1] === this.state.active) {
|
||||
this.setState({active: moderationIds[moderationIds.length - 2]});
|
||||
} else {
|
||||
this.setState({active: moderationIds[Math.min(moderationIds.indexOf(this.state.active) + 1, moderationIds.length - 1)]});
|
||||
}
|
||||
}
|
||||
|
||||
// Update the status right away if this is a comment
|
||||
if (action.item_type === 'COMMENTS') {
|
||||
this.props.updateCommentStatus(menuOption, id);
|
||||
} else if (action.item_type === 'USERS') {
|
||||
|
||||
// If a user bio or name is rejected, bring up a dialog before suspending them.
|
||||
if (menuOption === 'REJECTED') {
|
||||
this.setState({suspendUserModal: action});
|
||||
} else if (menuOption === 'ACCEPTED') {
|
||||
this.props.userStatusUpdate('APPROVED', action.item_id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
onClickShowBanDialog = (userId, userName, commentId) => {
|
||||
this.props.onClickShowBanDialog(userId, userName, commentId);
|
||||
}
|
||||
|
||||
mapModItems = (itemId, index) => {
|
||||
|
||||
const {comments = {}, users, actions = {}, modActions, suspectWords, hideActive} = this.props;
|
||||
const {active} = this.state;
|
||||
|
||||
// Because ids are unique, the id will either appear as an action or as a comment.
|
||||
|
||||
const item = comments[itemId] || actions[itemId];
|
||||
let modItem;
|
||||
|
||||
if (item.body) {
|
||||
|
||||
// If the item is a comment...
|
||||
const author = users[item.author_id];
|
||||
modItem = <Comment
|
||||
suspectWords={suspectWords}
|
||||
comment={item}
|
||||
author={author}
|
||||
key={index}
|
||||
index={index}
|
||||
onClickAction={this.onClickAction}
|
||||
onClickShowBanDialog={this.onClickShowBanDialog}
|
||||
modActions={modActions}
|
||||
menuOptionsMap={menuOptionsMap}
|
||||
isActive={itemId === active}
|
||||
hideActive={hideActive} />;
|
||||
} else {
|
||||
|
||||
// If the item is an action...
|
||||
const user = users[item.item_id];
|
||||
modItem = user && <User
|
||||
suspectWords={suspectWords}
|
||||
action={item}
|
||||
user={user}
|
||||
key={index}
|
||||
index={index}
|
||||
onClickAction={this.onClickAction}
|
||||
onClickShowBanDialog={this.onClickShowBanDialog}
|
||||
modActions={modActions}
|
||||
menuOptionsMap={menuOptionsMap}
|
||||
isActive={itemId === active}
|
||||
hideActive={hideActive} />;
|
||||
}
|
||||
return modItem;
|
||||
}
|
||||
|
||||
getModerationIds = () => {
|
||||
const {commentIds = [], actionIds = [], comments, actions} = this.props;
|
||||
if (comments && actions) {
|
||||
return [ ...commentIds, ...actionIds ].sort((a, b) => {
|
||||
const itemA = comments[a] || actions[a];
|
||||
const itemB = comments[b] || actions[b];
|
||||
return itemB.updated_at - itemA.updated_at;
|
||||
});
|
||||
} else {
|
||||
return comments ? commentIds : actionIds;
|
||||
}
|
||||
}
|
||||
|
||||
render () {
|
||||
const {singleView, key, suspendUser} = this.props;
|
||||
|
||||
// Combine moderations and actions into a single stream and sort by most recently updated.
|
||||
const moderationIds = this.getModerationIds();
|
||||
|
||||
return (
|
||||
<ul
|
||||
className={`${styles.list} ${singleView ? styles.singleView : ''}`} {...key}
|
||||
id='moderationList'>
|
||||
{moderationIds.map(this.mapModItems)}
|
||||
<SuspendUserModal
|
||||
action = {this.state.suspendUserModal}
|
||||
onClose={() => this.setState({suspendUserModal:null})}
|
||||
suspendUser={suspendUser} />
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
|
||||
.modalButtons {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
margin-top: 50px;
|
||||
}
|
||||
|
||||
.emailInput {
|
||||
border-radius: 3px;
|
||||
width: 100%;
|
||||
padding: 10px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.writeContainer {
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.emailContainer {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.emailMessage {
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: 20px;
|
||||
font-weight: 800;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
import I18n from 'coral-framework/modules/i18n/i18n';
|
||||
import translations from '../translations.json';
|
||||
import React, {Component, PropTypes} from 'react';
|
||||
import Modal from 'components/Modal';
|
||||
import styles from './SuspendUserModal.css';
|
||||
import {Button} from 'coral-ui';
|
||||
|
||||
const stages = [
|
||||
{
|
||||
title: 'suspenduser.title_0',
|
||||
description: 'suspenduser.description_0',
|
||||
options: {
|
||||
'j': 'suspenduser.no_cancel',
|
||||
'k': 'suspenduser.yes_suspend'
|
||||
}
|
||||
},
|
||||
{
|
||||
title: 'suspenduser.title_1',
|
||||
description: 'suspenduser.description_1',
|
||||
options: {
|
||||
'j': 'bandialog.cancel',
|
||||
'k': 'suspenduser.send'
|
||||
}
|
||||
}
|
||||
];
|
||||
|
||||
class SuspendUserModal extends Component {
|
||||
|
||||
state = {email: '', stage: 0}
|
||||
|
||||
static propTypes = {
|
||||
stage: PropTypes.number,
|
||||
actionType: PropTypes.string,
|
||||
onClose: PropTypes.func.isRequired,
|
||||
suspendUser: PropTypes.func.isRequired
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
const about = lang.t('suspenduser.username');
|
||||
this.setState({email: lang.t('suspenduser.email', about)});
|
||||
}
|
||||
|
||||
/*
|
||||
* When an admin clicks to suspend a user a dialog is shown, this function
|
||||
* handles the possible actions for that dialog.
|
||||
*/
|
||||
onActionClick = (stage, menuOption) => () => {
|
||||
const {suspendUser, action} = this.props;
|
||||
const {stage, email} = this.state;
|
||||
const cancel = this.props.onClose;
|
||||
const next = () => this.setState({stage: stage + 1});
|
||||
const suspend = () => suspendUser(action.item_id, lang.t('suspenduser.email_subject'), email)
|
||||
.then(this.props.onClose);
|
||||
const suspendModalActions = [
|
||||
[ cancel, next ],
|
||||
[ cancel, suspend ]
|
||||
];
|
||||
return suspendModalActions[stage][menuOption]();
|
||||
}
|
||||
|
||||
onEmailChange = (e) => this.setState({email: e.target.value})
|
||||
|
||||
render () {
|
||||
const {action, onClose} = this.props;
|
||||
|
||||
if (!action) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const {stage} = this.state;
|
||||
const actionType = action.actionType;
|
||||
const about = actionType === 'flag_bio' ? lang.t('suspenduser.bio') : lang.t('suspenduser.username');
|
||||
return <Modal open={true} onClose={onClose}>
|
||||
<div className={styles.title}>{lang.t(stages[stage].title, about)}</div>
|
||||
<div className={styles.container}>
|
||||
<div className={styles.description}>
|
||||
{lang.t(stages[stage].description, about)}
|
||||
</div>
|
||||
{
|
||||
stage === 1 &&
|
||||
<div className={styles.writeContainer}>
|
||||
<div className={styles.emailMessage}>{lang.t('suspenduser.write_message')}</div>
|
||||
<div className={styles.emailContainer}>
|
||||
<textarea
|
||||
rows={5}
|
||||
className={styles.emailInput}
|
||||
value={this.state.email}
|
||||
onChange={this.onEmailChange}/>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
<div className={styles.modalButtons}>
|
||||
{Object.keys(stages[stage].options).map((key, i) => (
|
||||
<Button key={i} onClick={this.onActionClick(stage, i)}>
|
||||
{lang.t(stages[stage].options[key], about)}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</Modal>;
|
||||
}
|
||||
}
|
||||
|
||||
export default SuspendUserModal;
|
||||
|
||||
const lang = new I18n(translations);
|
||||
@@ -0,0 +1,50 @@
|
||||
import React from 'react';
|
||||
import styles from './ModerationList.css';
|
||||
|
||||
import I18n from 'coral-framework/modules/i18n/i18n';
|
||||
import translations from '../translations.json';
|
||||
|
||||
import {Icon} from 'react-mdl';
|
||||
import ActionButton from './ActionButton';
|
||||
|
||||
// Render a single comment for the list
|
||||
const User = props => {
|
||||
const {action, user} = props;
|
||||
let userStatus = user.status;
|
||||
|
||||
// Do not display unless the user status is 'pending' or 'banned'.
|
||||
// This means that they have already been reviewed and approved.
|
||||
return (userStatus === 'PENDING' || userStatus === 'BANNED') &&
|
||||
<li tabIndex={props.index} className={`mdl-card mdl-shadow--2dp ${styles.listItem} ${props.isActive && !props.hideActive ? styles.activeItem : ''}`}>
|
||||
<div className={styles.itemHeader}>
|
||||
<div className={styles.author}>
|
||||
<span>{user.username}</span>
|
||||
</div>
|
||||
<div className={styles.sideActions}>
|
||||
<div className={`actions ${styles.actions}`}>
|
||||
{props.modActions.map(
|
||||
(action, i) =>
|
||||
<ActionButton
|
||||
type={action.toUpperCase()}
|
||||
key={i}
|
||||
user={user}
|
||||
menuOptionsMap={props.menuOptionsMap}
|
||||
onClickAction={props.onClickAction}
|
||||
onClickShowBanDialog={props.onClickShowBanDialog}/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
{userStatus === 'banned' ?
|
||||
<span className={styles.banned}><Icon name='error_outline'/> {lang.t('comment.banned_user')}</span> : null}
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles.flagCount}>
|
||||
{`${action.count} ${action.action_type === 'flag_bio' ? lang.t('user.bio_flags') : lang.t('user.username_flags')}`}
|
||||
</div>
|
||||
</li>;
|
||||
};
|
||||
|
||||
export default User;
|
||||
|
||||
const lang = new I18n(translations);
|
||||
@@ -1,40 +1,43 @@
|
||||
.header {
|
||||
background: #505050;
|
||||
background-color: transparent;
|
||||
box-shadow: none;
|
||||
min-height: 58px;
|
||||
}
|
||||
|
||||
.header > div {
|
||||
position: relative;
|
||||
padding: 0;
|
||||
width: 1170px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.active {
|
||||
background: #232323;
|
||||
background-color: #696969;
|
||||
position: relative;
|
||||
padding: 0;
|
||||
min-width: 1280px;
|
||||
margin: 0 auto;
|
||||
box-shadow: 0 2px 2px 0 rgba(0,0,0,.14), 0 3px 1px -2px rgba(0,0,0,.2), 0 1px 5px 0 rgba(0,0,0,.12);
|
||||
height: 58px;
|
||||
}
|
||||
|
||||
.rightPanel {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
width: 170px;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
width: 170px;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.rightPanel ul {
|
||||
list-style: none;
|
||||
line-height: 38px;
|
||||
margin-right: 20px;
|
||||
}
|
||||
|
||||
.rightPanel li {
|
||||
display: inline-block;
|
||||
float: right;
|
||||
margin-left: 15px;
|
||||
font-size: 15px;
|
||||
font-weight: 500;
|
||||
line-height: 33px;
|
||||
}
|
||||
|
||||
.rightPanel .settings {
|
||||
vertical-align: middle;
|
||||
border-radius: 3px;
|
||||
border: solid 1px #9e9e9e;
|
||||
line-height: 10px;
|
||||
line-height: 0;
|
||||
}
|
||||
|
||||
.rightPanel .settings > div {
|
||||
@@ -45,3 +48,25 @@
|
||||
background: rgba(158, 158, 158, 0.69);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.navLink {
|
||||
padding: 0 20px;
|
||||
font-size: 15px;
|
||||
font-weight: 500;
|
||||
background-color: transparent;
|
||||
transition: background-color 200ms;
|
||||
|
||||
&.active {
|
||||
background-color: #232323;
|
||||
}
|
||||
}
|
||||
|
||||
.nav {
|
||||
overflow: hidden;
|
||||
height: 58px !important;
|
||||
}
|
||||
|
||||
.nav .navLink {
|
||||
padding: 0 20px;
|
||||
letter-spacing: 0.4px;
|
||||
}
|
||||
|
||||
@@ -6,34 +6,61 @@ import I18n from 'coral-framework/modules/i18n/i18n';
|
||||
import translations from '../../translations.json';
|
||||
import {Logo} from './Logo';
|
||||
|
||||
export default ({handleLogout}) => (
|
||||
export default ({handleLogout, restricted = false}) => (
|
||||
<Header className={styles.header}>
|
||||
<Logo />
|
||||
<Navigation>
|
||||
<IndexLink className={styles.navLink} to="/admin"
|
||||
activeClassName={styles.active}>{lang.t('configure.moderate')}</IndexLink>
|
||||
<Link className={styles.navLink} to="/admin/community"
|
||||
activeClassName={styles.active}>{lang.t('configure.community')}</Link>
|
||||
<Link className={styles.navLink} to="/admin/configure"
|
||||
activeClassName={styles.active}>{lang.t('configure.configure')}</Link>
|
||||
<Link className={styles.navLink} to="/admin/streams"
|
||||
activeClassName={styles.active}>{lang.t('configure.streams')}</Link>
|
||||
</Navigation>
|
||||
<div className={styles.rightPanel}>
|
||||
<ul>
|
||||
<li className={styles.settings}>
|
||||
<div>
|
||||
<IconButton name="settings" id="menu-settings"/>
|
||||
<Menu target="menu-settings" align="right">
|
||||
<MenuItem onClick={handleLogout}>Sign Out</MenuItem>
|
||||
</Menu>
|
||||
</div>
|
||||
</li>
|
||||
<li>
|
||||
{`v${process.env.VERSION}`}
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
{
|
||||
!restricted ?
|
||||
<div>
|
||||
<Navigation className={styles.nav}>
|
||||
<IndexLink
|
||||
className={styles.navLink}
|
||||
to="/admin/dashboard"
|
||||
activeClassName={styles.active}>
|
||||
{lang.t('configure.dashboard')}
|
||||
</IndexLink>
|
||||
<Link
|
||||
className={styles.navLink}
|
||||
to="/admin/moderate"
|
||||
activeClassName={styles.active}>
|
||||
{lang.t('configure.moderate')}
|
||||
</Link>
|
||||
<Link className={styles.navLink}
|
||||
to="/admin/streams"
|
||||
activeClassName={styles.active}>
|
||||
{lang.t('configure.streams')}
|
||||
</Link>
|
||||
<Link className={styles.navLink}
|
||||
to="/admin/community"
|
||||
activeClassName={styles.active}>
|
||||
{lang.t('configure.community')}
|
||||
</Link>
|
||||
<Link
|
||||
className={styles.navLink}
|
||||
to="/admin/configure"
|
||||
activeClassName={styles.active}>
|
||||
{lang.t('configure.configure')}
|
||||
</Link>
|
||||
</Navigation>
|
||||
<div className={styles.rightPanel}>
|
||||
<ul>
|
||||
<li className={styles.settings}>
|
||||
<div>
|
||||
<IconButton name="settings" id="menu-settings"/>
|
||||
<Menu target="menu-settings" align="right">
|
||||
<MenuItem onClick={handleLogout}>Sign Out</MenuItem>
|
||||
</Menu>
|
||||
</div>
|
||||
</li>
|
||||
<li>
|
||||
{`v${process.env.VERSION}`}
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
:
|
||||
null
|
||||
}
|
||||
</Header>
|
||||
);
|
||||
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
.layout {
|
||||
max-width: 1170px;
|
||||
max-width: 1280px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
overflow: hidden;
|
||||
background-color: #FAFAFA;
|
||||
}
|
||||
|
||||
@@ -1,21 +1,28 @@
|
||||
.logo h1 {
|
||||
color: #272727;
|
||||
font-size: 20px;
|
||||
margin: 0;
|
||||
line-height: 60px;
|
||||
padding: 0 20px;
|
||||
color: #272727;
|
||||
font-size: 20px;
|
||||
margin: 0;
|
||||
line-height: 60px;
|
||||
padding-left: 13px;
|
||||
margin-top: -4px;
|
||||
}
|
||||
|
||||
.logo span {
|
||||
display: inline-block;
|
||||
margin-left: 10px;
|
||||
font-size: 18px;
|
||||
vertical-align: middle;
|
||||
display: inline-block;
|
||||
margin-left: 10px;
|
||||
font-size: 18px;
|
||||
vertical-align: middle;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.logo {
|
||||
background: #E5E5E5;
|
||||
height: 100%;
|
||||
background: #E5E5E5;
|
||||
height: 100%;
|
||||
width: 128px;
|
||||
}
|
||||
|
||||
|
||||
.base {
|
||||
stroke: #E5E5E5;
|
||||
height: 35px;
|
||||
width: 35px;
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@ import {CoralLogo} from 'coral-ui';
|
||||
export const Logo = () => (
|
||||
<div className={styles.logo}>
|
||||
<h1>
|
||||
<CoralLogo stroke="#E5E5E5" />
|
||||
<CoralLogo className={styles.base} />
|
||||
<span>Talk</span>
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
export const FETCH_ASSETS_REQUEST = 'FETCH_ASSETS_REQUEST';
|
||||
export const FETCH_ASSETS_SUCCESS = 'FETCH_ASSETS_SUCCESS';
|
||||
export const FETCH_ASSETS_FAILURE = 'FETCH_ASSETS_FAILURE';
|
||||
|
||||
export const UPDATE_ASSET_STATE_REQUEST = 'UPDATE_ASSET_STATE_REQUEST';
|
||||
export const UPDATE_ASSET_STATE_SUCCESS = 'UPDATE_ASSET_STATE_SUCCESS';
|
||||
export const UPDATE_ASSET_STATE_FAILURE = 'UPDATE_ASSET_STATE_FAILURE';
|
||||
|
||||
export const UPDATE_ASSETS = 'UPDATE_ASSETS';
|
||||
|
||||
@@ -2,6 +2,8 @@ export const CHECK_LOGIN_REQUEST = 'CHECK_LOGIN_REQUEST';
|
||||
export const CHECK_LOGIN_SUCCESS = 'CHECK_LOGIN_SUCCESS';
|
||||
export const CHECK_LOGIN_FAILURE = 'CHECK_LOGIN_FAILURE';
|
||||
|
||||
export const CHECK_CSRF_TOKEN = 'CHECK_CSRF_TOKEN';
|
||||
|
||||
export const LOGOUT_REQUEST = 'LOGOUT_REQUEST';
|
||||
export const LOGOUT_SUCCESS = 'LOGOUT_SUCCESS';
|
||||
export const LOGOUT_FAILURE = 'LOGOUT_FAILURE';
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
export const NEXT_STEP = 'NEXT_STEP';
|
||||
export const GO_TO_STEP = 'GO_TO_STEP';
|
||||
export const PREVIOUS_STEP = 'PREVIOUS_STEP';
|
||||
export const ADD_ERROR = 'ADD_ERROR';
|
||||
export const HAS_ERROR = 'HAS_ERROR';
|
||||
export const SHOW_ERRORS = 'SHOW_ERRORS';
|
||||
export const CLEAR_ERRORS = 'CLEAR_ERRORS';
|
||||
export const INSTALL_REQUEST = 'INSTALL_REQUEST';
|
||||
export const INSTALL_SUCCESS = 'INSTALL_SUCCESS';
|
||||
export const INSTALL_FAILURE = 'INSTALL_FAILURE';
|
||||
export const UPDATE_FORMDATA_USER = 'UPDATE_FORMDATA_USER';
|
||||
export const UPDATE_FORMDATA_SETTINGS = 'UPDATE_FORMDATA_SETTINGS';
|
||||
|
||||
export const CHECK_INSTALL_REQUEST = 'CHECK_INSTALL_REQUEST';
|
||||
export const CHECK_INSTALL_SUCCESS = 'CHECK_INSTALL_SUCCESS';
|
||||
export const CHECK_INSTALL_FAILURE = 'CHECK_INSTALL_FAILURE';
|
||||
@@ -0,0 +1,4 @@
|
||||
export const TOGGLE_MODAL = 'TOGGLE_MODAL';
|
||||
export const SINGLE_VIEW = 'SINGLE_VIEW';
|
||||
export const SHOW_BANUSER_DIALOG = 'SHOW_BANUSER_DIALOG';
|
||||
export const HIDE_BANUSER_DIALOG = 'HIDE_BANUSER_DIALOG';
|
||||
@@ -1,3 +0,0 @@
|
||||
export const UPDATE_STATUS_REQUEST = 'UPDATE_STATUS_REQUEST';
|
||||
export const UPDATE_STATUS_SUCCESS = 'UPDATE_STATUS_SUCCESS';
|
||||
export const UPDATE_STATUS_FAILURE = 'UPDATE_STATUS_FAILURE';
|
||||
@@ -0,0 +1,6 @@
|
||||
export const UPDATE_STATUS_REQUEST = 'UPDATE_STATUS_REQUEST';
|
||||
export const UPDATE_STATUS_SUCCESS = 'UPDATE_STATUS_SUCCESS';
|
||||
export const UPDATE_STATUS_FAILURE = 'UPDATE_STATUS_FAILURE';
|
||||
export const USER_EMAIL_FAILURE = 'USER_EMAIL_FAILURE';
|
||||
export const USERNAME_ENABLE_FAILURE = 'USERNAME_ENABLE_FAILURE';
|
||||
export const USERS_MODERATION_QUEUE_FETCH_SUCCESS = 'USERS_MODERATION_QUEUE_FETCH_SUCCESS';
|
||||
@@ -1,13 +0,0 @@
|
||||
|
||||
@custom-media --big-viewport (min-width: 780px);
|
||||
|
||||
.container {
|
||||
max-width: 860px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
@media (--big-viewport) {
|
||||
.tab {
|
||||
flex: none;
|
||||
}
|
||||
}
|
||||
@@ -1,66 +0,0 @@
|
||||
import React from 'react';
|
||||
import styles from './CommentStream.css';
|
||||
import {Snackbar} from 'react-mdl';
|
||||
import {connect} from 'react-redux';
|
||||
import {createComment, flagComment} from 'actions/comments';
|
||||
import CommentList from 'components/CommentList';
|
||||
import CommentBox from 'components/CommentBox';
|
||||
|
||||
/**
|
||||
* Renders a comment stream using a CommentList component
|
||||
* and adds a box for adding a new comment
|
||||
*/
|
||||
|
||||
class CommentStream extends React.Component {
|
||||
constructor (props) {
|
||||
super(props);
|
||||
this.state = {snackbar: false, snackbarMsg: ''};
|
||||
this.onSubmit = this.onSubmit.bind(this);
|
||||
this.onClickAction = this.onClickAction.bind(this);
|
||||
}
|
||||
|
||||
// Fetch the comments before mounting
|
||||
componentWillMount () {
|
||||
this.props.dispatch({type: 'COMMENT_STREAM_FETCH'});
|
||||
}
|
||||
|
||||
// Submit the new comment
|
||||
onSubmit (comment) {
|
||||
this.props.dispatch(createComment(comment.name, comment.body));
|
||||
}
|
||||
|
||||
// The only action for now is flagging
|
||||
onClickAction (action, id) {
|
||||
if (action === 'flag') {
|
||||
this.props.dispatch(flagComment(id));
|
||||
clearTimeout(this._snackTimeout);
|
||||
this.setState({snackbar: true, snackbarMsg: 'Thank you for reporting this comment. Our moderation team has been notified and will review it shortly.'});
|
||||
this._snackTimeout = setTimeout(() => this.setState({snackbar: false}), 30000);
|
||||
}
|
||||
}
|
||||
|
||||
// Render the comment box along with the CommentList
|
||||
render ({comments, users}, {snackbar, snackbarMsg}) {
|
||||
return (
|
||||
<div className={styles.container}>
|
||||
<CommentBox onSubmit={this.onSubmit} />
|
||||
<CommentList isActive hideActive
|
||||
singleView={false}
|
||||
commentIds={comments.ids}
|
||||
comments={comments.byId}
|
||||
users={users.byId}
|
||||
onClickAction={this.onClickAction}
|
||||
actions={['flag']}
|
||||
loading={comments.loading} />
|
||||
<Snackbar active={snackbar}>{snackbarMsg}</Snackbar>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const mapStateToProps = state => ({
|
||||
comments: state.comments.toJS(),
|
||||
users: state.users.toJS()
|
||||
});
|
||||
|
||||
export default connect(mapStateToProps)(CommentStream);
|
||||
@@ -1,17 +1,96 @@
|
||||
.roleButton {
|
||||
display: block;
|
||||
.container {
|
||||
padding: 10px;
|
||||
display: flex;
|
||||
padding-bottom: 200px;
|
||||
}
|
||||
|
||||
.searchInput {
|
||||
display: block;
|
||||
padding-left: 40px;
|
||||
width: auto;
|
||||
.leftColumn {
|
||||
padding: 42px 56px;
|
||||
width: 234px;
|
||||
}
|
||||
|
||||
.mainContent {
|
||||
width: calc(100% - 300px);
|
||||
padding: 34px 14px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.roleButton {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.searchBox {
|
||||
background: white;
|
||||
width: 100%;
|
||||
padding: 9px;
|
||||
border: 1px solid #ccc;
|
||||
border-radius: 2px;
|
||||
display: flex;
|
||||
background: white;
|
||||
box-sizing: border-box;
|
||||
height: 40px;
|
||||
|
||||
i {
|
||||
color: #A1A1A1
|
||||
}
|
||||
|
||||
input {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border: none;
|
||||
font-size: 16px;
|
||||
padding: 0 2px 0 15px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
}
|
||||
|
||||
.email {
|
||||
display: block;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.dataTable {
|
||||
width: 100%;
|
||||
border-left: none;
|
||||
border-right: none;
|
||||
|
||||
th {
|
||||
font-size: 1.1em;
|
||||
}
|
||||
|
||||
th:nth-child(2), th:nth-child(3) {
|
||||
width: 100px;
|
||||
}
|
||||
}
|
||||
|
||||
.selectField {
|
||||
position: relative;
|
||||
width: 150px;
|
||||
height: 36px;
|
||||
background: #2c2c2c;
|
||||
padding: 10px 15px;
|
||||
box-sizing: border-box;
|
||||
color: white;
|
||||
border-radius: 2px;
|
||||
box-shadow: 0 2px 2px 0 rgba(0,0,0,.14), 0 3px 1px -2px rgba(0,0,0,.2), 0 1px 5px 0 rgba(0,0,0,.12);
|
||||
|
||||
> div {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
i {
|
||||
position: absolute;
|
||||
top: 7px;
|
||||
right: 7px;
|
||||
}
|
||||
|
||||
input {
|
||||
padding: 0;
|
||||
font-size: 13px;
|
||||
letter-spacing: 0.7px;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
cursor: pointer;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,20 +1,19 @@
|
||||
import React from 'react';
|
||||
import I18n from 'coral-framework/modules/i18n/i18n';
|
||||
import translations from '../../translations.json';
|
||||
import {Grid, Cell} from 'react-mdl';
|
||||
import translations from 'coral-admin/src/translations.json';
|
||||
|
||||
import styles from './Community.css';
|
||||
import Table from './Table';
|
||||
import Loading from './Loading';
|
||||
import NoResults from './NoResults';
|
||||
import Pager from 'coral-ui/components/Pager';
|
||||
import {Pager} from 'coral-ui';
|
||||
import EmptyCard from '../../components/EmptyCard';
|
||||
|
||||
const lang = new I18n(translations);
|
||||
|
||||
const tableHeaders = [
|
||||
{
|
||||
title: lang.t('community.username_and_email'),
|
||||
field: 'displayName'
|
||||
field: 'username'
|
||||
},
|
||||
{
|
||||
title: lang.t('community.account_creation_date'),
|
||||
@@ -33,17 +32,17 @@ const tableHeaders = [
|
||||
const Community = ({isFetching, commenters, ...props}) => {
|
||||
const hasResults = !isFetching && !!commenters.length;
|
||||
return (
|
||||
<Grid>
|
||||
<Cell col={2}>
|
||||
<div className={styles.container}>
|
||||
<div className={styles.leftColumn}>
|
||||
<form action="">
|
||||
<div className={`mdl-textfield ${styles.searchBox}`}>
|
||||
<label className="mdl-button mdl-js-button mdl-button--icon" htmlFor="commenters-search">
|
||||
<div className={`${styles.searchBox}`}>
|
||||
<label htmlFor="commenters-search">
|
||||
<i className="material-icons">search</i>
|
||||
</label>
|
||||
<div className="">
|
||||
<input
|
||||
id="commenters-search"
|
||||
className={`mdl-textfield__input ${styles.searchInput}`}
|
||||
className={`${styles.searchInput}`}
|
||||
type="text"
|
||||
value={props.searchValue}
|
||||
onKeyDown={props.onKeyDownHandler}
|
||||
@@ -52,24 +51,25 @@ const Community = ({isFetching, commenters, ...props}) => {
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</Cell>
|
||||
<Cell col={6}>
|
||||
</div>
|
||||
<div className={styles.mainContent}>
|
||||
{ isFetching && <Loading /> }
|
||||
{ !hasResults && <NoResults /> }
|
||||
{ hasResults &&
|
||||
<Table
|
||||
headers={tableHeaders}
|
||||
data={commenters}
|
||||
onHeaderClickHandler={props.onHeaderClickHandler}
|
||||
/>
|
||||
{
|
||||
hasResults
|
||||
? <Table
|
||||
headers={tableHeaders}
|
||||
data={commenters}
|
||||
onHeaderClickHandler={props.onHeaderClickHandler}
|
||||
/>
|
||||
: <EmptyCard>{lang.t('community.no-results')}</EmptyCard>
|
||||
}
|
||||
<Pager
|
||||
totalPages={props.totalPages}
|
||||
page={props.page}
|
||||
onNewPageHandler={props.onNewPageHandler}
|
||||
/>
|
||||
</Cell>
|
||||
</Grid>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -1,9 +0,0 @@
|
||||
import React from 'react';
|
||||
|
||||
const NoResults = () => (
|
||||
<div>
|
||||
No users found with that user name or email address
|
||||
</div>
|
||||
);
|
||||
|
||||
export default NoResults;
|
||||
@@ -44,7 +44,7 @@ class Table extends Component {
|
||||
{commenters.map((row, i)=> (
|
||||
<tr key={i}>
|
||||
<td className="mdl-data-table__cell--non-numeric">
|
||||
{row.displayName}
|
||||
{row.username}
|
||||
<span className={styles.email}>{row.profiles.map(({id}) => id)}</span>
|
||||
</td>
|
||||
<td className="mdl-data-table__cell--non-numeric">
|
||||
@@ -52,19 +52,21 @@ class Table extends Component {
|
||||
</td>
|
||||
<td className="mdl-data-table__cell--non-numeric">
|
||||
<SelectField label={'Select me'} value={row.status || ''}
|
||||
className={styles.selectField}
|
||||
label={lang.t('community.status')}
|
||||
onChange={status => this.onCommenterStatusChange(row.id, status)}>
|
||||
<Option value={'active'}>{lang.t('community.active')}</Option>
|
||||
<Option value={'banned'}>{lang.t('community.banned')}</Option>
|
||||
<Option value={'ACTIVE'}>{lang.t('community.active')}</Option>
|
||||
<Option value={'BANNED'}>{lang.t('community.banned')}</Option>
|
||||
</SelectField>
|
||||
</td>
|
||||
<td className="mdl-data-table__cell--non-numeric">
|
||||
<SelectField label={'Select me'} value={row.roles[0] || ''}
|
||||
className={styles.selectField}
|
||||
label={lang.t('community.role')}
|
||||
onChange={role => this.onRoleChange(row.id, role)}>
|
||||
<Option value={''}>.</Option>
|
||||
<Option value={'moderator'}>{lang.t('community.moderator')}</Option>
|
||||
<Option value={'admin'}>{lang.t('community.admin')}</Option>
|
||||
<Option value={'MODERATOR'}>{lang.t('community.moderator')}</Option>
|
||||
<Option value={'ADMIN'}>{lang.t('community.admin')}</Option>
|
||||
</SelectField>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
@@ -3,15 +3,8 @@ import {SelectField, Option} from 'react-mdl-selectfield';
|
||||
import I18n from 'coral-framework/modules/i18n/i18n';
|
||||
import translations from '../../translations.json';
|
||||
import styles from './Configure.css';
|
||||
import {
|
||||
List,
|
||||
ListItem,
|
||||
ListItemContent,
|
||||
ListItemAction,
|
||||
Textfield,
|
||||
Checkbox,
|
||||
Icon
|
||||
} from 'react-mdl';
|
||||
import {Textfield, Checkbox} from 'react-mdl';
|
||||
import {Card, Icon, Spinner} from 'coral-ui';
|
||||
|
||||
const TIMESTAMPS = {
|
||||
weeks: 60 * 60 * 24 * 7,
|
||||
@@ -35,10 +28,14 @@ const updateCharCount = (updateSettings, settingsError) => (event) => {
|
||||
};
|
||||
|
||||
const updateModeration = (updateSettings, mod) => () => {
|
||||
const moderation = mod === 'pre' ? 'post' : 'pre';
|
||||
const moderation = mod === 'PRE' ? 'POST' : 'PRE';
|
||||
updateSettings({moderation});
|
||||
};
|
||||
|
||||
const updateEmailConfirmation = (updateSettings, verify) => () => {
|
||||
updateSettings({requireEmailConfirmation: !verify});
|
||||
};
|
||||
|
||||
const updateInfoBoxEnable = (updateSettings, infoBox) => () => {
|
||||
const infoBoxEnable = !infoBox;
|
||||
updateSettings({infoBoxEnable});
|
||||
@@ -54,6 +51,12 @@ const updateClosedMessage = (updateSettings) => (event) => {
|
||||
updateSettings({closedMessage});
|
||||
};
|
||||
|
||||
const updateCustomCssUrl = (updateSettings) => (event) => {
|
||||
console.log('updateCustomCssUrl', event.target.value);
|
||||
const customCssUrl = event.target.value;
|
||||
updateSettings({customCssUrl});
|
||||
};
|
||||
|
||||
// If we are changing the measure we need to recalculate using the old amount
|
||||
// Same thing if we are just changing the amount
|
||||
const updateClosedTimeout = (updateSettings, ts, isMeasure) => (event) => {
|
||||
@@ -71,111 +74,138 @@ const updateClosedTimeout = (updateSettings, ts, isMeasure) => (event) => {
|
||||
|
||||
const CommentSettings = ({fetchingSettings, title, updateSettings, settingsError, settings, errors}) => {
|
||||
if (fetchingSettings) {
|
||||
|
||||
/* maybe a spinner here at some point */
|
||||
return <p>Loading settings...</p>;
|
||||
return <Card shadow="4"><Spinner/>Loading settings...</Card>;
|
||||
}
|
||||
|
||||
// just putting this here for shorthand below
|
||||
const on = styles.enabledSetting;
|
||||
const off = styles.disabledSetting;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className={styles.commentSettingsSection}>
|
||||
<h3>{title}</h3>
|
||||
<List>
|
||||
<ListItem className={`${styles.configSetting} ${settings.moderation === 'pre' ? styles.enabledSetting : styles.disabledSetting}`}>
|
||||
<ListItemAction>
|
||||
<Checkbox
|
||||
onChange={updateModeration(updateSettings, settings.moderation)}
|
||||
checked={settings.moderation === 'pre'} />
|
||||
</ListItemAction>
|
||||
<ListItemContent>
|
||||
<Card className={`${styles.configSetting} ${settings.moderation === 'PRE' ? on : off}`}>
|
||||
<div className={styles.action}>
|
||||
<Checkbox
|
||||
onChange={updateModeration(updateSettings, settings.moderation)}
|
||||
checked={settings.moderation === 'PRE'} />
|
||||
</div>
|
||||
<div className={styles.content}>
|
||||
<div className={styles.settingsHeader}>{lang.t('configure.enable-pre-moderation')}</div>
|
||||
<p className={settings.moderation === 'pre' ? '' : styles.disabledSettingText}>
|
||||
<p className={settings.moderation === 'PRE' ? '' : styles.disabledSettingText}>
|
||||
{lang.t('configure.enable-pre-moderation-text')}
|
||||
</p>
|
||||
</ListItemContent>
|
||||
</ListItem>
|
||||
<ListItem className={`${styles.configSetting} ${settings.charCountEnable ? styles.enabledSetting : styles.disabledSetting}`}>
|
||||
<ListItemAction>
|
||||
<Checkbox
|
||||
onChange={updateCharCountEnable(updateSettings, settings.charCountEnable)}
|
||||
checked={settings.charCountEnable} />
|
||||
</ListItemAction>
|
||||
<ListItemContent>
|
||||
<div className={styles.settingsHeader}>{lang.t('configure.comment-count-header')}</div>
|
||||
<p className={settings.charCountEnable ? '' : styles.disabledSettingText}>
|
||||
<span>{lang.t('configure.comment-count-text-pre')}</span>
|
||||
<input type='text'
|
||||
className={`${styles.charCountTexfield} ${settings.charCountEnable && styles.charCountTexfieldEnabled}`}
|
||||
htmlFor='charCount'
|
||||
onChange={updateCharCount(updateSettings, settingsError)}
|
||||
value={settings.charCount}/>
|
||||
<span>{lang.t('configure.comment-count-text-post')}</span>
|
||||
{
|
||||
errors.charCount &&
|
||||
<span className={styles.settingsError}>
|
||||
<br/>
|
||||
<Icon name="error_outline"/>
|
||||
{lang.t('configure.comment-count-error')}
|
||||
</span>
|
||||
}
|
||||
</p>
|
||||
</ListItemContent>
|
||||
</ListItem>
|
||||
<ListItem threeLine className={`${styles.configSettingInfoBox} ${settings.infoBoxEnable ? styles.enabledSetting : styles.disabledSetting}`}>
|
||||
<ListItemAction>
|
||||
<Checkbox
|
||||
onChange={updateInfoBoxEnable(updateSettings, settings.infoBoxEnable)}
|
||||
checked={settings.infoBoxEnable} />
|
||||
</ListItemAction>
|
||||
<ListItemContent>
|
||||
{lang.t('configure.include-comment-stream')}
|
||||
<p>
|
||||
{lang.t('configure.include-comment-stream-desc')}
|
||||
</p>
|
||||
</ListItemContent>
|
||||
</ListItem>
|
||||
<ListItem className={`${styles.configSettingInfoBox} ${settings.infoBoxEnable ? null : styles.hidden}`} >
|
||||
<ListItemContent>
|
||||
<Textfield
|
||||
onChange={updateInfoBoxContent(updateSettings)}
|
||||
value={settings.infoBoxContent}
|
||||
label={lang.t('configure.include-text')}
|
||||
rows={3}/>
|
||||
</ListItemContent>
|
||||
</ListItem>
|
||||
<ListItem className={styles.configSettingInfoBox}>
|
||||
<ListItemContent>
|
||||
{lang.t('configure.close-after')}
|
||||
<br />
|
||||
<Textfield
|
||||
type='number'
|
||||
pattern='[0-9]+'
|
||||
style={{width: 50}}
|
||||
onChange={updateClosedTimeout(updateSettings, settings.closedTimeout)}
|
||||
value={getTimeoutAmount(settings.closedTimeout)}
|
||||
label={lang.t('configure.closed-comments-label')} />
|
||||
<div className={styles.configTimeoutSelect}>
|
||||
<SelectField
|
||||
label="comments closed time window"
|
||||
value={getTimeoutMeasure(settings.closedTimeout)}
|
||||
onChange={updateClosedTimeout(updateSettings, settings.closedTimeout, true)}>
|
||||
<Option value={'hours'}>{lang.t('configure.hours')}</Option>
|
||||
<Option value={'days'}>{lang.t('configure.days')}</Option>
|
||||
<Option value={'weeks'}>{lang.t('configure.weeks')}</Option>
|
||||
</SelectField>
|
||||
</div>
|
||||
</Card>
|
||||
<Card className={`${styles.configSetting} ${settings.requireEmailConfirmation ? on : off}`}>
|
||||
<div className={styles.action}>
|
||||
<Checkbox
|
||||
onChange={updateEmailConfirmation(updateSettings, settings.requireEmailConfirmation)}
|
||||
checked={settings.requireEmailConfirmation} />
|
||||
</div>
|
||||
<div className={styles.content}>
|
||||
<div className={styles.settingsHeader}>{lang.t('configure.require-email-verification')}</div>
|
||||
<p className={settings.requireEmailConfirmation ? '' : styles.disabledSettingText}>
|
||||
{lang.t('configure.require-email-verification-text')}
|
||||
</p>
|
||||
</div>
|
||||
</Card>
|
||||
<Card className={`${styles.configSetting} ${settings.charCountEnable ? on : off}`}>
|
||||
<div className={styles.action}>
|
||||
<Checkbox
|
||||
onChange={updateCharCountEnable(updateSettings, settings.charCountEnable)}
|
||||
checked={settings.charCountEnable} />
|
||||
</div>
|
||||
<div className={styles.content}>
|
||||
<div className={styles.settingsHeader}>{lang.t('configure.comment-count-header')}</div>
|
||||
<p className={settings.charCountEnable ? '' : styles.disabledSettingText}>
|
||||
<span>{lang.t('configure.comment-count-text-pre')}</span>
|
||||
<input type='text'
|
||||
className={`${styles.charCountTexfield} ${settings.charCountEnable && styles.charCountTexfieldEnabled}`}
|
||||
htmlFor='charCount'
|
||||
onChange={updateCharCount(updateSettings, settingsError)}
|
||||
value={settings.charCount}/>
|
||||
<span>{lang.t('configure.comment-count-text-post')}</span>
|
||||
{
|
||||
errors.charCount &&
|
||||
<span className={styles.settingsError}>
|
||||
<br/>
|
||||
<Icon name="error_outline"/>
|
||||
{lang.t('configure.comment-count-error')}
|
||||
</span>
|
||||
}
|
||||
</p>
|
||||
</div>
|
||||
</Card>
|
||||
<Card className={`${styles.configSettingInfoBox} ${settings.infoBoxEnable ? on : off}`}>
|
||||
<div className={styles.action}>
|
||||
<Checkbox
|
||||
onChange={updateInfoBoxEnable(updateSettings, settings.infoBoxEnable)}
|
||||
checked={settings.infoBoxEnable} />
|
||||
</div>
|
||||
<div className={styles.content}>
|
||||
{lang.t('configure.include-comment-stream')}
|
||||
<p>
|
||||
{lang.t('configure.include-comment-stream-desc')}
|
||||
</p>
|
||||
<div className={`${styles.configSettingInfoBox} ${settings.infoBoxEnable ? null : styles.hidden}`} >
|
||||
<div className={styles.content}>
|
||||
<Textfield
|
||||
onChange={updateInfoBoxContent(updateSettings)}
|
||||
value={settings.infoBoxContent}
|
||||
label={lang.t('configure.include-text')}
|
||||
rows={3}/>
|
||||
</div>
|
||||
</ListItemContent>
|
||||
</ListItem>
|
||||
<ListItem className={styles.configSettingInfoBox}>
|
||||
<ListItemContent>
|
||||
{lang.t('configure.closed-comments-desc')}
|
||||
<Textfield
|
||||
onChange={updateClosedMessage(updateSettings)}
|
||||
value={settings.closedMessage}
|
||||
label={lang.t('configure.closed-comments-label')}
|
||||
rows={3}/>
|
||||
</ListItemContent>
|
||||
</ListItem>
|
||||
</List>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
<Card className={styles.configSettingInfoBox}>
|
||||
<div className={styles.content}>
|
||||
{lang.t('configure.closed-comments-desc')}
|
||||
<div>
|
||||
<Textfield
|
||||
onChange={updateClosedMessage(updateSettings)}
|
||||
value={settings.closedMessage}
|
||||
label={lang.t('configure.closed-comments-label')}
|
||||
rows={3}/>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
<Card className={styles.configSettingInfoBox}>
|
||||
<div className={styles.content}>
|
||||
{lang.t('configure.close-after')}
|
||||
<br />
|
||||
<Textfield
|
||||
type='number'
|
||||
pattern='[0-9]+'
|
||||
style={{width: 50}}
|
||||
onChange={updateClosedTimeout(updateSettings, settings.closedTimeout)}
|
||||
value={getTimeoutAmount(settings.closedTimeout)}
|
||||
label={lang.t('configure.closed-comments-label')} />
|
||||
<div className={styles.configTimeoutSelect}>
|
||||
<SelectField
|
||||
label="comments closed time window"
|
||||
value={getTimeoutMeasure(settings.closedTimeout)}
|
||||
onChange={updateClosedTimeout(updateSettings, settings.closedTimeout, true)}>
|
||||
<Option value={'hours'}>{lang.t('configure.hours')}</Option>
|
||||
<Option value={'days'}>{lang.t('configure.days')}</Option>
|
||||
<Option value={'weeks'}>{lang.t('configure.weeks')}</Option>
|
||||
</SelectField>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
<Card className={styles.configSettingInfoBox}>
|
||||
<div className={styles.content}>
|
||||
{lang.t('configure.custom-css-url')}
|
||||
<p>{lang.t('configure.custom-css-url-desc')}</p>
|
||||
<br />
|
||||
<Textfield
|
||||
style={{width: '100%'}}
|
||||
label={lang.t('configure.custom-css-url')}
|
||||
value={settings.customCssUrl}
|
||||
onChange={updateCustomCssUrl(updateSettings)} />
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,26 +1,29 @@
|
||||
.container {
|
||||
padding: 10px;
|
||||
display: flex;
|
||||
|
||||
h3 {
|
||||
color: black;
|
||||
font-size: 1.76em;
|
||||
font-weight: 500;
|
||||
}
|
||||
}
|
||||
|
||||
.leftColumn {
|
||||
width: 300px;
|
||||
padding: 42px 56px;
|
||||
width: 234px;
|
||||
}
|
||||
|
||||
.mainContent {
|
||||
width: calc(70% - 300px)
|
||||
}
|
||||
|
||||
.settingOption {
|
||||
cursor: pointer;
|
||||
width: calc(100% - 300px);
|
||||
padding: 10px 14px;
|
||||
box-sizing: border-box;
|
||||
max-width: 718px;
|
||||
}
|
||||
|
||||
.configSetting {
|
||||
border: 1px solid #ccc;
|
||||
border-radius: 4px;
|
||||
height: 95px;
|
||||
margin-bottom: 10px;
|
||||
margin-bottom: 20px;
|
||||
align-items: flex-start;
|
||||
min-height: 100px;
|
||||
}
|
||||
|
||||
.settingsError {
|
||||
@@ -42,13 +45,13 @@
|
||||
}
|
||||
|
||||
.configSettingInfoBox {
|
||||
border: 1px solid #ccc;
|
||||
border-radius: 4px;
|
||||
margin-bottom: 10px;
|
||||
min-height: 100px;
|
||||
margin-bottom: 20px;
|
||||
cursor: pointer;
|
||||
width: auto;
|
||||
height: auto;
|
||||
text-align: left;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.configSettingInfoBox p {
|
||||
@@ -77,7 +80,7 @@
|
||||
}
|
||||
|
||||
.charCountTexfieldEnabled {
|
||||
border-color: #4caf50;
|
||||
border-color: #00796b;
|
||||
}
|
||||
|
||||
.charCountTexfield:focus {
|
||||
@@ -85,11 +88,12 @@
|
||||
}
|
||||
|
||||
.changedSave {
|
||||
background-color:#4caf50;
|
||||
background-color: #00796B;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.copiedText {
|
||||
color: #008000;
|
||||
color: #00796b;
|
||||
float: right;
|
||||
padding: 12px;
|
||||
font-size: 14px;
|
||||
@@ -97,6 +101,7 @@
|
||||
|
||||
.copyButton {
|
||||
float: right;
|
||||
width: 200px;
|
||||
}
|
||||
|
||||
.embedInput {
|
||||
@@ -113,8 +118,12 @@
|
||||
}
|
||||
|
||||
#bannedWordlist, #suspectWordlist {
|
||||
width: 100%;
|
||||
padding: 10px;
|
||||
|
||||
input {
|
||||
width: 100%;
|
||||
padding: 10px;
|
||||
}
|
||||
}
|
||||
|
||||
.wordlistHeader {
|
||||
@@ -124,7 +133,7 @@
|
||||
}
|
||||
|
||||
.enabledSetting {
|
||||
border-left-color: #4caf50;
|
||||
border-left-color: #00796b;
|
||||
border-left-style: solid;
|
||||
border-left-width: 7px;
|
||||
}
|
||||
@@ -136,3 +145,23 @@
|
||||
.hidden {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.saveBox {
|
||||
margin-top: 38px;
|
||||
}
|
||||
|
||||
.commentSettingsSection {
|
||||
padding-bottom: 200px;
|
||||
.action {
|
||||
display: inline-block;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.content {
|
||||
display: inline-block;
|
||||
padding-left: 30px;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,27 +1,24 @@
|
||||
import React from 'react';
|
||||
import React, {Component} from 'react';
|
||||
import {connect} from 'react-redux';
|
||||
import {
|
||||
fetchSettings,
|
||||
updateSettings,
|
||||
saveSettingsToServer,
|
||||
updateWordlist,
|
||||
updateDomainlist
|
||||
} from '../../actions/settings';
|
||||
import {
|
||||
List,
|
||||
ListItem,
|
||||
ListItemContent,
|
||||
Button,
|
||||
Icon
|
||||
} from 'react-mdl';
|
||||
|
||||
import {Button, List, Item} from 'coral-ui';
|
||||
import styles from './Configure.css';
|
||||
import I18n from 'coral-framework/modules/i18n/i18n';
|
||||
import translations from '../../translations.json';
|
||||
import EmbedLink from './EmbedLink';
|
||||
import CommentSettings from './CommentSettings';
|
||||
import Wordlist from './Wordlist';
|
||||
import Domainlist from './Domainlist';
|
||||
import has from 'lodash/has';
|
||||
|
||||
class Configure extends React.Component {
|
||||
class Configure extends Component {
|
||||
constructor (props) {
|
||||
super(props);
|
||||
|
||||
@@ -30,6 +27,8 @@ class Configure extends React.Component {
|
||||
changed: false,
|
||||
errors: {}
|
||||
};
|
||||
|
||||
this.changeSection = this.changeSection.bind(this);
|
||||
}
|
||||
|
||||
componentWillMount = () => {
|
||||
@@ -41,7 +40,7 @@ class Configure extends React.Component {
|
||||
this.setState({changed: false});
|
||||
}
|
||||
|
||||
changeSection = (activeSection) => () => {
|
||||
changeSection(activeSection) {
|
||||
this.setState({activeSection});
|
||||
}
|
||||
|
||||
@@ -50,6 +49,11 @@ class Configure extends React.Component {
|
||||
this.props.dispatch(updateWordlist(listName, list));
|
||||
}
|
||||
|
||||
onChangeDomainlist = (listName, list) => {
|
||||
this.setState({changed: true});
|
||||
this.props.dispatch(updateDomainlist(listName, list));
|
||||
}
|
||||
|
||||
onSettingUpdate = (setting) => {
|
||||
this.setState({changed: true});
|
||||
this.props.dispatch(updateSettings(setting));
|
||||
@@ -76,7 +80,14 @@ class Configure extends React.Component {
|
||||
errors={this.state.errors}
|
||||
settingsError={this.onSettingError}/>;
|
||||
case 'embed':
|
||||
return <EmbedLink title={pageTitle} />;
|
||||
return has(this, 'props.settings.domains.whitelist')
|
||||
? <div>
|
||||
<Domainlist
|
||||
domains={this.props.settings.domains.whitelist}
|
||||
onChangeDomainlist={this.onChangeDomainlist}/>
|
||||
<EmbedLink title={pageTitle} />
|
||||
</div>
|
||||
: <EmbedLink title={pageTitle} />;
|
||||
case 'wordlist':
|
||||
return has(this, 'props.settings.wordlist')
|
||||
? <Wordlist
|
||||
@@ -99,7 +110,8 @@ class Configure extends React.Component {
|
||||
}
|
||||
|
||||
render () {
|
||||
const section = this.getSection(this.state.activeSection);
|
||||
const {activeSection} = this.state;
|
||||
const section = this.getSection(activeSection);
|
||||
|
||||
const showSave = Object.keys(this.state.errors).reduce(
|
||||
(bool, error) => this.state.errors[error] ? false : bool, this.state.changed);
|
||||
@@ -107,37 +119,40 @@ class Configure extends React.Component {
|
||||
return (
|
||||
<div className={styles.container}>
|
||||
<div className={styles.leftColumn}>
|
||||
<List>
|
||||
<ListItem className={styles.settingOption}>
|
||||
<ListItemContent
|
||||
onClick={this.changeSection('comments')}
|
||||
icon='settings'>{lang.t('configure.comment-settings')}</ListItemContent>
|
||||
</ListItem>
|
||||
<ListItem className={styles.settingOption}>
|
||||
<ListItemContent
|
||||
onClick={this.changeSection('embed')}
|
||||
icon='code'>{lang.t('configure.embed-comment-stream')}</ListItemContent>
|
||||
</ListItem>
|
||||
<ListItem className={styles.settingOption}>
|
||||
<ListItemContent
|
||||
onClick={this.changeSection('wordlist')}
|
||||
icon='settings'>{lang.t('configure.wordlist')}</ListItemContent>
|
||||
</ListItem>
|
||||
<List onChange={this.changeSection} activeItem={activeSection}>
|
||||
<Item itemId='comments' icon="settings">
|
||||
{lang.t('configure.comment-settings')}
|
||||
</Item>
|
||||
<Item itemId='embed' icon='code'>
|
||||
{lang.t('configure.embed-comment-stream')}
|
||||
</Item>
|
||||
<Item itemId='wordlist' icon='settings'>
|
||||
{lang.t('configure.wordlist')}
|
||||
</Item>
|
||||
</List>
|
||||
<div className={styles.saveBox}>
|
||||
{
|
||||
showSave ?
|
||||
<Button
|
||||
raised
|
||||
onClick={this.saveSettings}
|
||||
className={styles.changedSave}>
|
||||
<Icon name='check' /> {lang.t('configure.save-changes')}
|
||||
</Button>
|
||||
: <Button
|
||||
raised
|
||||
disabled>
|
||||
{lang.t('configure.save-changes')}
|
||||
</Button>
|
||||
<Button
|
||||
raised
|
||||
onClick={this.saveSettings}
|
||||
className={styles.changedSave}
|
||||
icon='check'
|
||||
full
|
||||
>
|
||||
{lang.t('configure.save-changes')}
|
||||
</Button>
|
||||
:
|
||||
<Button
|
||||
raised
|
||||
disabled
|
||||
icon='check'
|
||||
full
|
||||
>
|
||||
{lang.t('configure.save-changes')}
|
||||
</Button>
|
||||
}
|
||||
</div>
|
||||
|
||||
</div>
|
||||
<div className={styles.mainContent}>
|
||||
@@ -150,7 +165,9 @@ class Configure extends React.Component {
|
||||
}
|
||||
}
|
||||
|
||||
const mapStateToProps = state => state.settings.toJS();
|
||||
const mapStateToProps = state => ({
|
||||
settings: state.settings.toJS()
|
||||
});
|
||||
export default connect(mapStateToProps)(Configure);
|
||||
|
||||
const lang = new I18n(translations);
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
import React from 'react';
|
||||
import I18n from 'coral-framework/modules/i18n/i18n';
|
||||
import translations from '../../translations.json';
|
||||
import TagsInput from 'react-tagsinput';
|
||||
import styles from './Configure.css';
|
||||
import {Card} from 'coral-ui';
|
||||
|
||||
const Domainlist = ({domains, onChangeDomainlist}) => (
|
||||
<div>
|
||||
<h3>{lang.t('configure.domain-list-title')}</h3>
|
||||
<Card id={styles.domainlist}>
|
||||
<p className={styles.domainlistDesc}>{lang.t('configure.domain-list-text')}</p>
|
||||
<TagsInput
|
||||
value={domains}
|
||||
inputProps={{placeholder: 'URL'}}
|
||||
addOnPaste={true}
|
||||
pasteSplit={data => data.split(',').map(d => d.trim())}
|
||||
onChange={tags => onChangeDomainlist('whitelist', tags)}
|
||||
/>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
|
||||
export default Domainlist;
|
||||
|
||||
const lang = new I18n(translations);
|
||||
@@ -2,11 +2,7 @@ import React, {Component} from 'react';
|
||||
import I18n from 'coral-framework/modules/i18n/i18n';
|
||||
import translations from '../../translations.json';
|
||||
import styles from './Configure.css';
|
||||
import {
|
||||
List,
|
||||
ListItem,
|
||||
Button
|
||||
} from 'react-mdl';
|
||||
import {Button, Card} from 'coral-ui';
|
||||
|
||||
class EmbedLink extends Component {
|
||||
|
||||
@@ -29,21 +25,37 @@ class EmbedLink extends Component {
|
||||
}
|
||||
|
||||
render () {
|
||||
const embedText = `<div id='coralStreamEmbed'></div><script type='text/javascript' src='${window.location.protocol}//pym.nprapps.org/pym.v1.min.js'></script><script>var pymParent = new pym.Parent('coralStreamEmbed', '${window.location.protocol}//${window.location.host}/embed/stream', {title: 'Comments'});</script>`;
|
||||
|
||||
const location = window.location;
|
||||
const talkBaseUrl = [
|
||||
location.protocol,
|
||||
'//',
|
||||
location.hostname,
|
||||
location.port ? (`:${ window.location.port}`) : ''
|
||||
].join('');
|
||||
const coralJsUrl = [talkBaseUrl, '/embed.js'].join('');
|
||||
const nonce = String(Math.random()).slice(2);
|
||||
const streamElementId = `coral_talk_${nonce}`;
|
||||
const embedText = `
|
||||
<div id="${streamElementId}"></div>
|
||||
<script src="${coralJsUrl}" async onload="
|
||||
Coral.Talk.render(document.getElementById('${streamElementId}'), {
|
||||
talk: '${talkBaseUrl}'
|
||||
});
|
||||
"></script>
|
||||
`.trim();
|
||||
return (
|
||||
<div>
|
||||
<h3>{this.props.title}</h3>
|
||||
<List>
|
||||
<ListItem className={styles.configSettingEmbed}>
|
||||
<div>
|
||||
<Card shadow="2">
|
||||
<p>{lang.t('configure.copy-and-paste')}</p>
|
||||
<textarea rows={5} type='text' className={styles.embedInput} value={embedText} readOnly={true}/>
|
||||
<Button raised colored className={styles.copyButton} onClick={this.copyToClipBoard}>
|
||||
<Button raised className={styles.copyButton} onClick={this.copyToClipBoard} cStyle="black">
|
||||
{lang.t('embedlink.copy')}
|
||||
</Button>
|
||||
<div className={styles.copiedText}>{this.state.copied && 'Copied!'}</div>
|
||||
</ListItem>
|
||||
</List>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,15 +2,13 @@ import React from 'react';
|
||||
import I18n from 'coral-framework/modules/i18n/i18n';
|
||||
import translations from '../../translations.json';
|
||||
import TagsInput from 'react-tagsinput';
|
||||
|
||||
import styles from './Configure.css';
|
||||
|
||||
import {Card} from 'react-mdl';
|
||||
import {Card} from 'coral-ui';
|
||||
|
||||
const Wordlist = ({suspectWords, bannedWords, onChangeWordlist}) => (
|
||||
<div>
|
||||
<h3>{lang.t('configure.banned-words-title')}</h3>
|
||||
<Card id={styles.bannedWordlist} shadow={2}>
|
||||
<Card id={styles.bannedWordlist}>
|
||||
<p className={styles.wordlistHeader}>{lang.t('configure.banned-word-header')}</p>
|
||||
<p className={styles.wordlistDesc}>{lang.t('configure.banned-word-text')}</p>
|
||||
<TagsInput
|
||||
@@ -18,10 +16,11 @@ const Wordlist = ({suspectWords, bannedWords, onChangeWordlist}) => (
|
||||
inputProps={{placeholder: 'word or phrase'}}
|
||||
addOnPaste={true}
|
||||
pasteSplit={data => data.split(',').map(d => d.trim())}
|
||||
onChange={tags => onChangeWordlist('banned', tags)} />
|
||||
onChange={tags => onChangeWordlist('banned', tags)}
|
||||
/>
|
||||
</Card>
|
||||
<h3>{lang.t('configure.suspect-words-title')}</h3>
|
||||
<Card id={styles.suspectWordlist} shadow={2}>
|
||||
<Card id={styles.suspectWordlist}>
|
||||
<p className={styles.wordlistHeader}>{lang.t('configure.suspect-word-header')}</p>
|
||||
<p className={styles.wordlistDesc}>{lang.t('configure.suspect-word-text')}</p>
|
||||
<TagsInput
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
.Dashboard {
|
||||
display: flex;
|
||||
padding: 5px;
|
||||
}
|
||||
|
||||
.widget {
|
||||
margin-top: 10px;
|
||||
flex: 1;
|
||||
box-shadow: 0px 0px 5px 0px rgba(0,0,0,0.2);
|
||||
margin-right: 10px;
|
||||
padding: 15px;
|
||||
|
||||
}
|
||||
|
||||
.widget:last-child {
|
||||
margin-right: 0;
|
||||
}
|
||||
|
||||
.heading {
|
||||
margin: 0;
|
||||
font-size: 1.5rem;
|
||||
font-weight: bold;
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import React from 'react';
|
||||
import {compose} from 'react-apollo';
|
||||
import {mostFlags} from 'coral-admin/src/graphql/queries';
|
||||
import {Spinner} from 'coral-ui';
|
||||
import styles from './Dashboard.css';
|
||||
import FlagWidget from '../../components/FlagWidget';
|
||||
|
||||
class Dashboard extends React.Component {
|
||||
render () {
|
||||
|
||||
const {data} = this.props;
|
||||
const {metrics: assets} = data;
|
||||
|
||||
if (data.loading) {
|
||||
return <Spinner />;
|
||||
}
|
||||
|
||||
if (data.error) {
|
||||
return <code><pre>{data.error}</pre></code>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.Dashboard}>
|
||||
<div className={styles.widget}>
|
||||
<h2 className={styles.heading}>Top Ten Articles with the most flagged comments</h2>
|
||||
<FlagWidget assets={assets} />
|
||||
</div>
|
||||
<div className={styles.widget}>
|
||||
<h2 className={styles.heading}>Top ten comments with the most likes</h2>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default compose(
|
||||
mostFlags
|
||||
)(Dashboard);
|
||||
@@ -0,0 +1,93 @@
|
||||
import React, {Component} from 'react';
|
||||
import {connect} from 'react-redux';
|
||||
import styles from './style.css';
|
||||
import {Wizard, WizardNav} from 'coral-ui';
|
||||
import {Layout} from '../../components/ui/Layout';
|
||||
|
||||
import {
|
||||
nextStep,
|
||||
previousStep,
|
||||
goToStep,
|
||||
updateUserFormData,
|
||||
updateSettingsFormData,
|
||||
submitSettings,
|
||||
submitUser,
|
||||
checkInstall
|
||||
} from '../../actions/install';
|
||||
|
||||
import InitialStep from './components/Steps/InitialStep';
|
||||
import AddOrganizationName from './components/Steps/AddOrganizationName';
|
||||
import CreateYourAccount from './components/Steps/CreateYourAccount';
|
||||
import FinalStep from './components/Steps/FinalStep';
|
||||
|
||||
class InstallContainer extends Component {
|
||||
componentDidMount() {
|
||||
const {checkInstall} = this.props;
|
||||
checkInstall(() => {
|
||||
this.context.router.push('/admin');
|
||||
});
|
||||
}
|
||||
|
||||
render() {
|
||||
const {install} = this.props;
|
||||
|
||||
return (
|
||||
<Layout restricted={true}>
|
||||
<div className={styles.Install}>
|
||||
{
|
||||
!install.alreadyInstalled ? (
|
||||
<div>
|
||||
<h2>Welcome to the Coral Project</h2>
|
||||
{ install.step !== 0 ? <WizardNav items={install.navItems} currentStep={install.step} icon='check'/> : null }
|
||||
<Wizard currentStep={install.step} {...this.props}>
|
||||
<InitialStep/>
|
||||
<AddOrganizationName/>
|
||||
<CreateYourAccount/>
|
||||
<FinalStep/>
|
||||
</Wizard>
|
||||
</div>
|
||||
) : (
|
||||
<div>Talk is already installed</div>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
InstallContainer.contextTypes = {
|
||||
router: React.PropTypes.object
|
||||
};
|
||||
|
||||
const mapStateToProps = state => ({
|
||||
install: state.install.toJS()
|
||||
});
|
||||
|
||||
const mapDispatchToProps = dispatch => ({
|
||||
checkInstall: next => dispatch(checkInstall(next)),
|
||||
nextStep: () => dispatch(nextStep()),
|
||||
previousStep: () => dispatch(previousStep()),
|
||||
goToStep: step => dispatch(goToStep(step)),
|
||||
handleSettingsChange: e => {
|
||||
const {name, value} = e.currentTarget;
|
||||
dispatch(updateSettingsFormData(name, value));
|
||||
},
|
||||
handleUserChange: e => {
|
||||
const {name, value} = e.currentTarget;
|
||||
dispatch(updateUserFormData(name, value));
|
||||
},
|
||||
handleSettingsSubmit: e => {
|
||||
e.preventDefault();
|
||||
dispatch(submitSettings());
|
||||
},
|
||||
handleUserSubmit: e => {
|
||||
e.preventDefault();
|
||||
dispatch(submitUser());
|
||||
}
|
||||
});
|
||||
|
||||
export default connect(
|
||||
mapStateToProps,
|
||||
mapDispatchToProps
|
||||
)(InstallContainer);
|
||||
@@ -0,0 +1,30 @@
|
||||
import React from 'react';
|
||||
import styles from './style.css';
|
||||
import {TextField, Button} from 'coral-ui';
|
||||
|
||||
const AddOrganizationName = props => {
|
||||
const {handleSettingsChange, handleSettingsSubmit, install} = props;
|
||||
return (
|
||||
<div className={styles.step}>
|
||||
<p>
|
||||
Please tell us the name of your organization. This will appear in emails when
|
||||
inviting new team members
|
||||
</p>
|
||||
<div className={styles.form}>
|
||||
<form onSubmit={handleSettingsSubmit}>
|
||||
<TextField
|
||||
className={styles.TextField}
|
||||
id="organizationName"
|
||||
type="text"
|
||||
label='Organization name'
|
||||
onChange={handleSettingsChange}
|
||||
showErrors={install.showErrors}
|
||||
errorMsg={install.errors.organizationName} />
|
||||
<Button type="submit" cStyle='black' full>Save</Button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AddOrganizationName;
|
||||
@@ -0,0 +1,65 @@
|
||||
import React from 'react';
|
||||
import styles from './style.css';
|
||||
import {TextField, Button, Spinner} from 'coral-ui';
|
||||
|
||||
const InitialStep = props => {
|
||||
const {handleUserChange, handleUserSubmit, install} = props;
|
||||
return (
|
||||
<div className={styles.step}>
|
||||
<div className={styles.form}>
|
||||
<form onSubmit={handleUserSubmit}>
|
||||
<TextField
|
||||
className={styles.textField}
|
||||
id="email"
|
||||
type="email"
|
||||
label='Email address'
|
||||
onChange={handleUserChange}
|
||||
showErrors={install.showErrors}
|
||||
errorMsg={install.errors.email}
|
||||
noValidate
|
||||
/>
|
||||
|
||||
<TextField
|
||||
className={styles.textField}
|
||||
id="username"
|
||||
type="text"
|
||||
label='Username'
|
||||
onChange={handleUserChange}
|
||||
showErrors={install.showErrors}
|
||||
errorMsg={install.errors.username}
|
||||
/>
|
||||
|
||||
<TextField
|
||||
className={styles.textField}
|
||||
id="password"
|
||||
type="password"
|
||||
label='Password'
|
||||
onChange={handleUserChange}
|
||||
showErrors={install.showErrors}
|
||||
errorMsg={install.errors.password}
|
||||
/>
|
||||
|
||||
<TextField
|
||||
className={styles.textField}
|
||||
id="confirmPassword"
|
||||
type="password"
|
||||
label='Confirm Password'
|
||||
onChange={handleUserChange}
|
||||
showErrors={install.showErrors}
|
||||
errorMsg={install.errors.confirmPassword}
|
||||
/>
|
||||
|
||||
{
|
||||
!props.install.isLoading ?
|
||||
<Button cStyle='black' type="submit" full>Save</Button>
|
||||
:
|
||||
<Spinner />
|
||||
}
|
||||
{props.install.installRequest === 'FAILURE' && <div className={styles.error}>Error: {props.install.installRequestError}</div>}
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default InitialStep;
|
||||
@@ -0,0 +1,20 @@
|
||||
import React from 'react';
|
||||
import styles from './style.css';
|
||||
import {Button} from 'coral-ui';
|
||||
import {Link} from 'react-router';
|
||||
|
||||
const InitialStep = () => {
|
||||
return (
|
||||
<div className={`${styles.step} ${styles.finalStep}`}>
|
||||
<p>
|
||||
Thanks for installing Talk! We sent an email to verify your email
|
||||
address. While you finish setting the account, you can start engaging
|
||||
with your readers now.
|
||||
</p>
|
||||
<Button raised><Link to='/admin'>Launch Talk</Link></Button>
|
||||
<Button cStyle='black' raised><a href="http://coralproject.net">Close this Installer</a></Button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default InitialStep;
|
||||
@@ -0,0 +1,19 @@
|
||||
import React from 'react';
|
||||
import styles from './style.css';
|
||||
import {Button} from 'coral-ui';
|
||||
|
||||
const InitialStep = props => {
|
||||
const {nextStep} = props;
|
||||
return (
|
||||
<div className={styles.step}>
|
||||
<p>
|
||||
The remainder of the Talk installation will take about ten minutes.
|
||||
Once you complete the following two steps, you will have a free
|
||||
installation and provision of Mongo and Redis.
|
||||
</p>
|
||||
<Button cStyle='green' onClick={nextStep} raised>Get Started</Button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default InitialStep;
|
||||
@@ -0,0 +1,44 @@
|
||||
import React from 'react';
|
||||
import styles from './style.css';
|
||||
import {Button, Select, Option, TextField} from 'coral-ui';
|
||||
|
||||
const InviteTeamMembers = props => {
|
||||
const {nextStep} = props;
|
||||
return (
|
||||
<div className={styles.step}>
|
||||
<h3>Invite Team Members </h3>
|
||||
<p>
|
||||
Once registered, new team members will receive an email to Create
|
||||
their password.
|
||||
</p>
|
||||
<div className={styles.form}>
|
||||
<form>
|
||||
|
||||
<TextField
|
||||
className={styles.textField}
|
||||
id="email"
|
||||
type="email"
|
||||
label='Email address' required/>
|
||||
|
||||
<TextField
|
||||
className={styles.textField}
|
||||
id="username"
|
||||
type="text"
|
||||
label='Username' required/>
|
||||
|
||||
<div className={styles.textField}>
|
||||
<label htmlFor='role'>Assing a role</label>
|
||||
<Select id='role' label='Select Role'>
|
||||
<Option>Admin</Option>
|
||||
<Option>Moderator</Option>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<Button cStyle='black' onClick={nextStep} icon='arrow_forward' full>Invite team member</Button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default InviteTeamMembers;
|
||||
@@ -0,0 +1,77 @@
|
||||
.step {
|
||||
padding: 20px 0;
|
||||
|
||||
h3 {
|
||||
max-width: 450px;
|
||||
margin: 0 auto;
|
||||
text-align: left;
|
||||
font-size: 1.4em;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
p {
|
||||
max-width: 450px;
|
||||
margin: 0 auto 30px;
|
||||
font-size: 1.1em;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
> button {
|
||||
min-width: 145px;
|
||||
a {
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
}
|
||||
}
|
||||
|
||||
.form {
|
||||
max-width: 300px;
|
||||
margin: 30px auto;
|
||||
text-align: left;
|
||||
|
||||
form > button {
|
||||
margin: 30px 0;
|
||||
}
|
||||
|
||||
.textField {
|
||||
text-align: left;
|
||||
|
||||
label {
|
||||
text-align: left;
|
||||
display: block;
|
||||
margin: 10px 0;
|
||||
font-weight: 400;
|
||||
font-size: 1.08em;
|
||||
}
|
||||
|
||||
> input {
|
||||
border: solid 1px rgba(0, 0, 0, 0.23);
|
||||
padding: 10px;
|
||||
border-radius: 3px;
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
transition: border-color 0.2s ease;
|
||||
font-size: 1em;
|
||||
|
||||
&:focus {
|
||||
border-color: black;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.finalStep {
|
||||
button {
|
||||
width: 225px;
|
||||
margin-right: 10px;
|
||||
}
|
||||
}
|
||||
|
||||
.error {
|
||||
background: #FFEBEE;
|
||||
color: #B71C1C;
|
||||
padding: 10px;
|
||||
margin-bottom: 20px;
|
||||
border-radius: 2px;
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
.Install {
|
||||
max-width: 900px;
|
||||
margin: 0 auto;
|
||||
text-align: center;
|
||||
padding: 50px 0;
|
||||
|
||||
h2 {
|
||||
font-size: 2em;
|
||||
font-weight: 500;
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
@@ -8,7 +8,11 @@ import {PermissionRequired} from '../components/PermissionRequired';
|
||||
class LayoutContainer extends Component {
|
||||
componentWillMount () {
|
||||
const {checkLogin} = this.props;
|
||||
checkLogin();
|
||||
checkLogin().then(() => {
|
||||
if (!this.props.auth.isAdmin) {
|
||||
location.href = '/admin/login';
|
||||
}
|
||||
});
|
||||
}
|
||||
render () {
|
||||
const {isAdmin, loggedIn, loadingUser} = this.props.auth;
|
||||
@@ -32,4 +36,3 @@ export default connect(
|
||||
mapStateToProps,
|
||||
mapDispatchToProps
|
||||
)(LayoutContainer);
|
||||
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
import React, {Component} from 'react';
|
||||
import {connect} from 'react-redux';
|
||||
import {compose} from 'react-apollo';
|
||||
import key from 'keymaster';
|
||||
import isEqual from 'lodash/isEqual';
|
||||
|
||||
import {modQueueQuery} from '../../graphql/queries';
|
||||
import {banUser, setCommentStatus} from '../../graphql/mutations';
|
||||
|
||||
import {fetchSettings} from 'actions/settings';
|
||||
import {updateAssets} from 'actions/assets';
|
||||
import {toggleModal, singleView, showBanUserDialog, hideBanUserDialog} from 'actions/moderation';
|
||||
|
||||
import {Spinner} from 'coral-ui';
|
||||
import BanUserDialog from '../../components/BanUserDialog';
|
||||
import ModerationQueue from './ModerationQueue';
|
||||
import ModerationMenu from './components/ModerationMenu';
|
||||
import ModerationHeader from './components/ModerationHeader';
|
||||
import NotFoundAsset from './components/NotFoundAsset';
|
||||
|
||||
class ModerationContainer extends Component {
|
||||
componentWillMount() {
|
||||
const {toggleModal, singleView} = this.props;
|
||||
|
||||
this.props.fetchSettings();
|
||||
|
||||
key('s', () => singleView());
|
||||
key('shift+/', () => toggleModal(true));
|
||||
key('esc', () => toggleModal(false));
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
key.unbind('s');
|
||||
key.unbind('shift+/');
|
||||
key.unbind('esc');
|
||||
}
|
||||
|
||||
componentWillReceiveProps(nextProps) {
|
||||
const {updateAssets} = this.props;
|
||||
if(!isEqual(nextProps.data.assets, this.props.data.assets)) {
|
||||
updateAssets(nextProps.data.assets);
|
||||
}
|
||||
}
|
||||
|
||||
render () {
|
||||
const {data, moderation, settings, assets, ...props} = this.props;
|
||||
const providedAssetId = this.props.params.id;
|
||||
const activeTab = this.props.route.path === ':id' ? 'premod' : this.props.route.path;
|
||||
|
||||
let asset;
|
||||
|
||||
if (data.loading) {
|
||||
return <div><Spinner/></div>;
|
||||
}
|
||||
|
||||
if (data.error) {
|
||||
console.log(data);
|
||||
return <div>Error</div>;
|
||||
}
|
||||
|
||||
if (providedAssetId) {
|
||||
asset = assets.find(asset => asset.id === this.props.params.id);
|
||||
|
||||
if (!asset) {
|
||||
return <NotFoundAsset assetId={providedAssetId} />;
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<ModerationHeader asset={asset} />
|
||||
<ModerationMenu
|
||||
asset={asset}
|
||||
premodCount={data.premodCount}
|
||||
rejectedCount={data.rejectedCount}
|
||||
flaggedCount={data.flaggedCount}
|
||||
/>
|
||||
<ModerationQueue
|
||||
data={data}
|
||||
currentAsset={asset}
|
||||
activeTab={activeTab}
|
||||
suspectWords={settings.wordlist.suspect}
|
||||
showBanUserDialog={props.showBanUserDialog}
|
||||
acceptComment={props.acceptComment}
|
||||
rejectComment={props.rejectComment}
|
||||
/>
|
||||
<BanUserDialog
|
||||
open={moderation.banDialog}
|
||||
user={moderation.user}
|
||||
handleClose={props.hideBanUserDialog}
|
||||
handleBanUser={props.banUser}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const mapStateToProps = state => ({
|
||||
moderation: state.moderation.toJS(),
|
||||
settings: state.settings.toJS(),
|
||||
assets: state.assets.get('assets')
|
||||
});
|
||||
|
||||
const mapDispatchToProps = dispatch => ({
|
||||
toggleModal: toggle => dispatch(toggleModal(toggle)),
|
||||
onClose: () => dispatch(toggleModal(false)),
|
||||
singleView: () => dispatch(singleView()),
|
||||
updateAssets: assets => dispatch(updateAssets(assets)),
|
||||
fetchSettings: () => dispatch(fetchSettings()),
|
||||
showBanUserDialog: (user, commentId) => dispatch(showBanUserDialog(user, commentId)),
|
||||
hideBanUserDialog: () => dispatch(hideBanUserDialog(false)),
|
||||
});
|
||||
|
||||
export default compose(
|
||||
connect(mapStateToProps, mapDispatchToProps),
|
||||
setCommentStatus,
|
||||
modQueueQuery,
|
||||
banUser
|
||||
)(ModerationContainer);
|
||||
@@ -0,0 +1,9 @@
|
||||
import React from 'react';
|
||||
|
||||
const ModerationLayout = props => (
|
||||
<div>
|
||||
{props.children}
|
||||
</div>
|
||||
);
|
||||
|
||||
export default ModerationLayout;
|
||||
@@ -1,33 +0,0 @@
|
||||
@custom-media --big-viewport (min-width: 780px);
|
||||
|
||||
.listContainer {
|
||||
max-width: 860px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.tabBar {
|
||||
background: #9E9E9E;
|
||||
}
|
||||
|
||||
.tab {
|
||||
flex: 1;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.showShortcuts {
|
||||
position: absolute;
|
||||
right: 130px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
font-size: 13px;
|
||||
|
||||
span {
|
||||
margin-left: 7px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (--big-viewport) {
|
||||
.tab {
|
||||
flex: none;
|
||||
}
|
||||
}
|
||||
@@ -1,168 +1,48 @@
|
||||
import React from 'react';
|
||||
import {connect} from 'react-redux';
|
||||
import key from 'keymaster';
|
||||
|
||||
import ModerationKeysModal from 'components/ModerationKeysModal';
|
||||
import CommentList from 'components/CommentList';
|
||||
import BanUserDialog from 'components/BanUserDialog';
|
||||
|
||||
import {
|
||||
updateStatus,
|
||||
showBanUserDialog,
|
||||
hideBanUserDialog,
|
||||
fetchModerationQueueComments
|
||||
} from 'actions/comments';
|
||||
import {userStatusUpdate} from 'actions/users';
|
||||
import {fetchSettings} from 'actions/settings';
|
||||
import styles from './ModerationQueue.css';
|
||||
import React, {PropTypes} from 'react';
|
||||
|
||||
import Comment from './components/Comment';
|
||||
import EmptyCard from '../../components/EmptyCard';
|
||||
import {actionsMap} from './helpers/moderationQueueActionsMap';
|
||||
import I18n from 'coral-framework/modules/i18n/i18n';
|
||||
import translations from '../../translations.json';
|
||||
|
||||
/*
|
||||
* Renders the moderation queue as a tabbed layout with 3 moderation
|
||||
* queues :
|
||||
* * pending: filtered by status Untouched
|
||||
* * rejected: filtered by status Rejected
|
||||
* * flagged: with a flagged action on them
|
||||
*/
|
||||
|
||||
class ModerationQueue extends React.Component {
|
||||
|
||||
constructor (props) {
|
||||
super(props);
|
||||
|
||||
this.state = {activeTab: 'pending', singleView: false, modalOpen: false};
|
||||
}
|
||||
|
||||
// Fetch comments and bind singleView key before render
|
||||
componentWillMount () {
|
||||
this.props.dispatch(fetchSettings());
|
||||
this.props.dispatch(fetchModerationQueueComments());
|
||||
key('s', () => this.setState({singleView: !this.state.singleView}));
|
||||
key('shift+/', () => this.setState({modalOpen: true}));
|
||||
key('esc', () => this.setState({modalOpen: false}));
|
||||
}
|
||||
|
||||
// Unbind singleView key before unmount
|
||||
componentWillUnmount () {
|
||||
key.unbind('s');
|
||||
key.unbind('shift+/');
|
||||
key.unbind('esc');
|
||||
}
|
||||
|
||||
// Hack for dynamic mdl tabs
|
||||
componentDidMount () {
|
||||
if (typeof componentHandler !== 'undefined') {
|
||||
|
||||
// FIXME: fix this hack
|
||||
componentHandler.upgradeAllRegistered(); // eslint-disable-line no-undef
|
||||
}
|
||||
}
|
||||
|
||||
// Dispatch the update status action
|
||||
onCommentAction (action, comment) {
|
||||
|
||||
// If not banning then change the status to approved or flagged as action = status
|
||||
this.props.dispatch(updateStatus(action, comment));
|
||||
}
|
||||
|
||||
showBanUserDialog (userId, userName, commentId) {
|
||||
this.props.dispatch(showBanUserDialog(userId, userName, commentId));
|
||||
}
|
||||
|
||||
hideBanUserDialog () {
|
||||
this.props.dispatch(hideBanUserDialog(false));
|
||||
}
|
||||
|
||||
banUser (userId, commentId) {
|
||||
this.props.dispatch(userStatusUpdate('banned', userId, commentId))
|
||||
.then(() => {
|
||||
this.props.dispatch(fetchModerationQueueComments());
|
||||
});
|
||||
}
|
||||
|
||||
onTabClick (activeTab) {
|
||||
this.setState({activeTab});
|
||||
}
|
||||
|
||||
// Render the tabbed lists moderation queues
|
||||
render () {
|
||||
const {comments, users, settings} = this.props;
|
||||
const {activeTab, singleView, modalOpen} = this.state;
|
||||
|
||||
const premodIds = comments.ids.filter(id => comments.byId[id].status === 'premod');
|
||||
const rejectedIds = comments.ids.filter(id => comments.byId[id].status === 'rejected');
|
||||
const flaggedIds = comments.ids.filter(id => comments.byId[id].flagged === true);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className='mdl-tabs mdl-js-tabs mdl-js-ripple-effect'>
|
||||
<div className={`mdl-tabs__tab-bar ${styles.tabBar}`}>
|
||||
<a href='#pending' onClick={() => this.onTabClick('pending')}
|
||||
className={`mdl-tabs__tab ${styles.tab}`}>{lang.t('modqueue.pending')}</a>
|
||||
<a href='#rejected' onClick={() => this.onTabClick('rejected')}
|
||||
className={`mdl-tabs__tab ${styles.tab}`}>{lang.t('modqueue.rejected')}</a>
|
||||
<a href='#flagged' onClick={() => this.onTabClick('flagged')}
|
||||
className={`mdl-tabs__tab ${styles.tab}`}>{lang.t('modqueue.flagged')}</a>
|
||||
</div>
|
||||
<div className={`mdl-tabs__panel is-active ${styles.listContainer}`} id='pending'>
|
||||
<CommentList
|
||||
suspectWords={settings.settings.wordlist.suspect}
|
||||
isActive={activeTab === 'pending'}
|
||||
singleView={singleView}
|
||||
commentIds={premodIds}
|
||||
comments={comments.byId}
|
||||
users={users.byId}
|
||||
onClickAction={(action, comment) => this.onCommentAction(action, comment)}
|
||||
onClickShowBanDialog={(userId, userName, commentId) => this.showBanUserDialog(userId, userName, commentId)}
|
||||
modActions={['reject', 'approve', 'ban']}
|
||||
loading={comments.loading} />
|
||||
<BanUserDialog
|
||||
open={comments.showBanUserDialog}
|
||||
handleClose={() => this.hideBanUserDialog()}
|
||||
onClickBanUser={(userId, commentId) => this.banUser(userId, commentId)}
|
||||
user={comments.banUser}/>
|
||||
</div>
|
||||
<div className={`mdl-tabs__panel ${styles.listContainer}`} id='rejected'>
|
||||
<CommentList
|
||||
suspectWords={settings.settings.wordlist.suspect}
|
||||
isActive={activeTab === 'rejected'}
|
||||
singleView={singleView}
|
||||
commentIds={rejectedIds}
|
||||
comments={comments.byId}
|
||||
users={users.byId}
|
||||
onClickAction={(action, comment) => this.onCommentAction(action, comment)}
|
||||
modActions={['approve']}
|
||||
loading={comments.loading} />
|
||||
</div>
|
||||
<div className={`mdl-tabs__panel ${styles.listContainer}`} id='flagged'>
|
||||
<CommentList
|
||||
isActive={activeTab === 'rejected'}
|
||||
suspectWords={settings.settings.wordlist.suspect}
|
||||
singleView={singleView}
|
||||
commentIds={flaggedIds}
|
||||
comments={comments.byId}
|
||||
users={users.byId}
|
||||
onClickAction={(action, comment) => this.onCommentAction(action, comment)}
|
||||
modActions={['reject', 'approve']}
|
||||
loading={comments.loading} />
|
||||
</div>
|
||||
<ModerationKeysModal open={modalOpen}
|
||||
onClose={() => this.setState({modalOpen: false})} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const mapStateToProps = state => ({
|
||||
actions: state.actions.toJS(),
|
||||
settings: state.settings.toJS(),
|
||||
comments: state.comments.toJS(),
|
||||
users: state.users.toJS()
|
||||
});
|
||||
|
||||
export default connect(mapStateToProps)(ModerationQueue);
|
||||
import translations from 'coral-admin/src/translations';
|
||||
|
||||
const lang = new I18n(translations);
|
||||
|
||||
const ModerationQueue = ({activeTab = 'premod', ...props}) => {
|
||||
const areComments = props.data[activeTab].length;
|
||||
return (
|
||||
<div id="moderationList">
|
||||
<ul style={{paddingLeft: 0}}>
|
||||
{
|
||||
areComments
|
||||
? props.data[activeTab].map((comment, i) => {
|
||||
const status = comment.action_summaries ? 'FLAGGED' : comment.status;
|
||||
return <Comment
|
||||
key={i}
|
||||
index={i}
|
||||
comment={comment}
|
||||
suspectWords={props.suspectWords}
|
||||
actions={actionsMap[status]}
|
||||
showBanUserDialog={props.showBanUserDialog}
|
||||
acceptComment={props.acceptComment}
|
||||
rejectComment={props.rejectComment}
|
||||
currentAsset={props.currentAsset}
|
||||
/>;
|
||||
})
|
||||
: <EmptyCard>{lang.t('modqueue.emptyqueue')}</EmptyCard>
|
||||
}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
ModerationQueue.propTypes = {
|
||||
data: PropTypes.object.isRequired,
|
||||
acceptComment: PropTypes.func.isRequired,
|
||||
rejectComment: PropTypes.func.isRequired,
|
||||
showBanUserDialog: PropTypes.func.isRequired,
|
||||
currentAsset: PropTypes.object,
|
||||
suspectWords: PropTypes.arrayOf(PropTypes.string).isRequired
|
||||
};
|
||||
|
||||
export default ModerationQueue;
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
import React, {PropTypes} from 'react';
|
||||
import timeago from 'timeago.js';
|
||||
import Linkify from 'react-linkify';
|
||||
import Highlighter from 'react-highlight-words';
|
||||
import {Link} from 'react-router';
|
||||
|
||||
import styles from './styles.css';
|
||||
import {Icon} from 'coral-ui';
|
||||
import ActionButton from '../../../components/ActionButton';
|
||||
import FlagBox from './FlagBox';
|
||||
|
||||
const linkify = new Linkify();
|
||||
|
||||
import I18n from 'coral-framework/modules/i18n/i18n';
|
||||
import translations from 'coral-admin/src/translations.json';
|
||||
const lang = new I18n(translations);
|
||||
|
||||
const Comment = ({actions = [], ...props}) => {
|
||||
const links = linkify.getMatches(props.comment.body);
|
||||
const actionSummaries = props.comment.action_summaries;
|
||||
return (
|
||||
<li tabIndex={props.index}
|
||||
className={`mdl-card mdl-shadow--2dp ${styles.Comment} ${styles.listItem} ${props.isActive && !props.hideActive ? styles.activeItem : ''}`}>
|
||||
<div className={styles.itemHeader}>
|
||||
<div className={styles.author}>
|
||||
<span>{props.comment.user.name}</span>
|
||||
<span className={styles.created}>
|
||||
{timeago().format(props.comment.created_at || (Date.now() - props.index * 60 * 1000), lang.getLocale().replace('-', '_'))}
|
||||
</span>
|
||||
{props.comment.action_summaries ? <p className={styles.flagged}>{lang.t('comment.flagged')}</p> : null}
|
||||
</div>
|
||||
<div className={styles.sideActions}>
|
||||
{links ? <span className={styles.hasLinks}><Icon name='error_outline'/> Contains Link</span> : null}
|
||||
<div className={`actions ${styles.actions}`}>
|
||||
{actions.map((action, i) =>
|
||||
<ActionButton key={i}
|
||||
type={action}
|
||||
user={props.comment.user}
|
||||
acceptComment={() => props.acceptComment({commentId: props.comment.id})}
|
||||
rejectComment={() => props.rejectComment({commentId: props.comment.id})}
|
||||
showBanUserDialog={() => props.showBanUserDialog(props.comment.user, props.comment.id)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{props.comment.user.status === 'banned' ?
|
||||
<span className={styles.banned}>
|
||||
<Icon name='error_outline'/>
|
||||
{lang.t('comment.banned_user')}
|
||||
</span>
|
||||
: null}
|
||||
</div>
|
||||
</div>
|
||||
{!props.currentAsset && (
|
||||
<div className={styles.moderateArticle}>
|
||||
Article: {props.comment.asset.title} <Link to={`/admin/moderate/${props.comment.asset.id}`}>Moderate Article</Link>
|
||||
</div>
|
||||
)}
|
||||
<div className={styles.itemBody}>
|
||||
<p className={styles.body}>
|
||||
<Linkify component='span' properties={{style: linkStyles}}>
|
||||
<Highlighter searchWords={props.suspectWords} textToHighlight={props.comment.body}/>
|
||||
</Linkify>
|
||||
</p>
|
||||
</div>
|
||||
{actionSummaries && <FlagBox actionSummaries={actionSummaries} />}
|
||||
</li>
|
||||
);
|
||||
};
|
||||
|
||||
Comment.propTypes = {
|
||||
acceptComment: PropTypes.func.isRequired,
|
||||
rejectComment: PropTypes.func.isRequired,
|
||||
suspectWords: PropTypes.arrayOf(PropTypes.string).isRequired,
|
||||
currentAsset: PropTypes.object,
|
||||
isActive: PropTypes.bool.isRequired,
|
||||
comment: PropTypes.shape({
|
||||
body: PropTypes.string.isRequired,
|
||||
action_summaries: PropTypes.array,
|
||||
created_at: PropTypes.string.isRequired,
|
||||
user: PropTypes.shape({
|
||||
status: PropTypes.string
|
||||
}),
|
||||
asset: PropTypes.shape({
|
||||
title: PropTypes.string,
|
||||
id: PropTypes.string
|
||||
})
|
||||
})
|
||||
};
|
||||
|
||||
const linkStyles = {
|
||||
backgroundColor: 'rgb(255, 219, 135)',
|
||||
padding: '1px 2px'
|
||||
};
|
||||
|
||||
export default Comment;
|
||||
@@ -0,0 +1,14 @@
|
||||
.count {
|
||||
display: inline-block;
|
||||
background: #989797;
|
||||
margin: 2px;
|
||||
vertical-align: middle;
|
||||
padding: 4px 7px;
|
||||
border-radius: 2px;
|
||||
margin-left: 10px;
|
||||
line-height: 20px;
|
||||
box-sizing: border-box;
|
||||
height: 27px;
|
||||
right: 0;
|
||||
margin-top: -2px;
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import React, {PropTypes} from 'react';
|
||||
import styles from './CommentCount.css';
|
||||
|
||||
const CommentCount = props => (
|
||||
<span className={styles.count}>{props.count}</span>
|
||||
);
|
||||
|
||||
CommentCount.propTypes = {
|
||||
count: PropTypes.number.isRequired
|
||||
};
|
||||
|
||||
export default CommentCount;
|
||||
@@ -0,0 +1,19 @@
|
||||
import React, {PropTypes} from 'react';
|
||||
import styles from './styles.css';
|
||||
|
||||
const FlagBox = props => (
|
||||
<div className={styles.flagBox}>
|
||||
<h3>Flags:</h3>
|
||||
<ul>
|
||||
{props.actionSummaries.map((action, i) =>
|
||||
<li key={i}>{!action.reason ? <i>No reason provided</i> : action.reason} (<strong>{action.count}</strong>)</li>
|
||||
)}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
|
||||
FlagBox.propTypes = {
|
||||
actionSummaries: PropTypes.array.isRequired
|
||||
};
|
||||
|
||||
export default FlagBox;
|
||||
@@ -0,0 +1,29 @@
|
||||
import React from 'react';
|
||||
import {Link} from 'react-router';
|
||||
import {Icon} from 'coral-ui';
|
||||
import styles from './styles.css';
|
||||
|
||||
const ModerationHeader = props => (
|
||||
<div className=''>
|
||||
<div className={`mdl-tabs ${styles.header}`}>
|
||||
{
|
||||
props.asset ?
|
||||
<div className={`mdl-tabs__tab-bar ${styles.moderateAsset}`}>
|
||||
<Link className="mdl-tabs__tab" to="/admin/moderate">All Streams</Link>
|
||||
<a className="mdl-tabs__tab">
|
||||
{props.asset.title}
|
||||
<a href={props.asset.url} className={styles.settingsButton}><Icon name="settings"/></a>
|
||||
</a>
|
||||
<Link className="mdl-tabs__tab" to="/admin/streams">Select Stream</Link>
|
||||
</div>
|
||||
:
|
||||
<div className={`mdl-tabs__tab-bar ${styles.moderateAsset}`}>
|
||||
<a className="mdl-tabs__tab" />
|
||||
<a className="mdl-tabs__tab">All Streams</a>
|
||||
<Link className="mdl-tabs__tab" to="/admin/streams">Select Stream</Link>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
export default ModerationHeader;
|
||||
@@ -0,0 +1,42 @@
|
||||
import React, {PropTypes} from 'react';
|
||||
import CommentCount from './CommentCount';
|
||||
import styles from './styles.css';
|
||||
import I18n from 'coral-framework/modules/i18n/i18n';
|
||||
import translations from 'coral-admin/src/translations.json';
|
||||
import {Link} from 'react-router';
|
||||
|
||||
const lang = new I18n(translations);
|
||||
|
||||
const ModerationMenu = ({asset, premodCount, rejectedCount, flaggedCount}) => {
|
||||
const premodPath = asset ? `/admin/moderate/premod/${asset.id}` : '/admin/moderate/premod';
|
||||
const rejectPath = asset ? `/admin/moderate/rejected/${asset.id}` : '/admin/moderate/rejected';
|
||||
const flagPath = asset ? `/admin/moderate/flagged/${asset.id}` : '/admin/moderate/flagged';
|
||||
return (
|
||||
<div className='mdl-tabs'>
|
||||
<div className={`mdl-tabs__tab-bar ${styles.tabBar}`}>
|
||||
<div>
|
||||
<Link to={premodPath} className={`mdl-tabs__tab ${styles.tab}`} activeClassName={styles.active}>
|
||||
{lang.t('modqueue.premod')} <CommentCount count={premodCount} />
|
||||
</Link>
|
||||
<Link to={rejectPath} className={`mdl-tabs__tab ${styles.tab}`} activeClassName={styles.active}>
|
||||
{lang.t('modqueue.rejected')} <CommentCount count={rejectedCount} />
|
||||
</Link>
|
||||
<Link to={flagPath} className={`mdl-tabs__tab ${styles.tab}`} activeClassName={styles.active}>
|
||||
{lang.t('modqueue.flagged')} <CommentCount count={flaggedCount} />
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
ModerationMenu.propTypes = {
|
||||
premodCount: PropTypes.number.isRequired,
|
||||
rejectedCount: PropTypes.number.isRequired,
|
||||
flaggedCount: PropTypes.number.isRequired,
|
||||
asset: PropTypes.shape({
|
||||
id: PropTypes.string
|
||||
})
|
||||
};
|
||||
|
||||
export default ModerationMenu;
|
||||
@@ -0,0 +1,14 @@
|
||||
import React from 'react';
|
||||
import {Link} from 'react-router';
|
||||
import styles from './styles.css';
|
||||
|
||||
const NotFound = props => (
|
||||
<div className={`mdl-card mdl-shadow--2dp ${styles.notFound}`}>
|
||||
<p>
|
||||
The provided asset id <Link to={`/admin/moderate/${props.assetId}`}>{props.assetId}</Link> does not exist.
|
||||
<Link className={styles.goToStreams} to="/admin/streams">Go to Streams</Link>
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
|
||||
export default NotFound;
|
||||
@@ -0,0 +1,336 @@
|
||||
@custom-media --big-viewport (min-width: 780px);
|
||||
|
||||
.listContainer {
|
||||
max-width: 860px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.tabBar {
|
||||
background-color: rgba(44, 44, 44, 0.89);
|
||||
z-index: 5;
|
||||
}
|
||||
|
||||
.tab {
|
||||
flex: 1;
|
||||
color: white;
|
||||
text-transform: capitalize;
|
||||
font-weight: 500;
|
||||
font-size: 15px;
|
||||
letter-spacing: 1px;
|
||||
transition: border-bottom 200ms;
|
||||
}
|
||||
|
||||
.active {
|
||||
color: white;
|
||||
box-sizing: border-box;
|
||||
border-bottom: solid 5px #F36451;
|
||||
}
|
||||
|
||||
.active > span {
|
||||
color: white;
|
||||
}
|
||||
|
||||
.active:after {
|
||||
background: transparent !important;
|
||||
}
|
||||
|
||||
.showShortcuts {
|
||||
position: absolute;
|
||||
right: 130px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
font-size: 13px;
|
||||
|
||||
span {
|
||||
margin-left: 7px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (--big-viewport) {
|
||||
.tab {
|
||||
flex: none;
|
||||
}
|
||||
}
|
||||
|
||||
.notFound {
|
||||
position: relative;
|
||||
margin: 20px auto;
|
||||
text-align: center;
|
||||
padding: 68px 45px;
|
||||
vertical-align: middle;
|
||||
min-width: 500px;
|
||||
|
||||
a {
|
||||
color: rgb(244, 126, 107);
|
||||
font-weight: 500;
|
||||
|
||||
&.goToStreams {
|
||||
position: absolute;
|
||||
right: 10px;
|
||||
bottom: 10px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.header {
|
||||
background-color: #2c2c2c;
|
||||
color: white;
|
||||
margin-bottom: -1px;
|
||||
|
||||
.settingsButton {
|
||||
i {
|
||||
vertical-align: middle;
|
||||
margin-left: 10px;
|
||||
margin-top: -4px;
|
||||
}
|
||||
}
|
||||
|
||||
.moderateAsset {
|
||||
a {
|
||||
-webkit-box-flex: 1;
|
||||
-ms-flex: 1;
|
||||
flex: 1;
|
||||
color: white;
|
||||
text-transform: capitalize;
|
||||
font-weight: 500;
|
||||
font-size: 15px;
|
||||
letter-spacing: 1px;
|
||||
transition: opacity 200ms;
|
||||
opacity: 1;
|
||||
|
||||
&:hover {
|
||||
opacity: .8;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
&:first-child {
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
&:nth-child(2) {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
&:last-child {
|
||||
text-align: right;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@custom-media --big-viewport (min-width: 780px);
|
||||
|
||||
.list {
|
||||
padding: 8px 0;
|
||||
list-style: none;
|
||||
display: block;
|
||||
|
||||
&.singleView .listItem {
|
||||
display: none;
|
||||
}
|
||||
|
||||
&.singleView .listItem.activeItem {
|
||||
display: block;
|
||||
height: 100%;
|
||||
font-size: 1.5em;
|
||||
line-height: 1.5em;
|
||||
border: none;
|
||||
|
||||
.actions {
|
||||
position: fixed;
|
||||
bottom: 60px;
|
||||
left: 25%;
|
||||
margin: 0 auto;
|
||||
display: flex;
|
||||
justify-content: space-around;
|
||||
width: 50%;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.actionButton {
|
||||
transform: scale(1.4);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.listItem {
|
||||
border-bottom: 1px solid #e0e0e0;
|
||||
font-size: 16px;
|
||||
width: 100%;
|
||||
max-width: 660px;
|
||||
min-width: 400px;
|
||||
margin: 0 auto;
|
||||
padding: 16px 14px;
|
||||
position: relative;
|
||||
transition: box-shadow 200ms;
|
||||
margin-top: 0;
|
||||
|
||||
|
||||
&:hover {
|
||||
box-shadow: 0 3px 6px rgba(0,0,0,0.16), 0 3px 6px rgba(0,0,0,0.23);
|
||||
}
|
||||
|
||||
&:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.context {
|
||||
a {
|
||||
color: #f36451;
|
||||
text-decoration: underline;
|
||||
float: right;
|
||||
}
|
||||
}
|
||||
|
||||
.sideActions {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
height: 100%;
|
||||
top: 0;
|
||||
padding: 40px 18px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.itemHeader {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
|
||||
.author {
|
||||
min-width: 230px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
}
|
||||
|
||||
.itemBody {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.avatar {
|
||||
margin-right: 16px;
|
||||
height: 40px;
|
||||
width: 40px;
|
||||
border-radius: 50%;
|
||||
background-color: #757575;
|
||||
font-size: 40px;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.created {
|
||||
color: #666;
|
||||
font-size: 13px;
|
||||
margin-left: 40px;
|
||||
}
|
||||
|
||||
.actionButton {
|
||||
transform: scale(.8);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.body {
|
||||
margin-top: 0px;
|
||||
flex: 1;
|
||||
font-size: 0.88em;
|
||||
color: black;
|
||||
max-width: 500px;
|
||||
word-wrap: break-word;
|
||||
}
|
||||
|
||||
.flagged {
|
||||
color: rgba(255, 0, 0, .5);
|
||||
padding-top: 15px;
|
||||
padding-left: 10px;
|
||||
}
|
||||
|
||||
.flagCount{
|
||||
font-size: 12px;
|
||||
color: #d32f2f;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
.empty {
|
||||
color: #444;
|
||||
margin-top: 50px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
|
||||
@media (--big-viewport) {
|
||||
.listItem {
|
||||
border: 1px solid #e0e0e0;
|
||||
margin-bottom: 30px;
|
||||
|
||||
&:last-child {
|
||||
border-bottom: 1px solid #e0e0e0;
|
||||
}
|
||||
|
||||
&.activeItem {
|
||||
border: 2px solid #333;
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
.hasLinks {
|
||||
color: #f00;
|
||||
text-align: right;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
i {
|
||||
margin-right: 5px;
|
||||
}
|
||||
}
|
||||
|
||||
.banned {
|
||||
color: #f00;
|
||||
text-align: left;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
i {
|
||||
margin-right: 5px;
|
||||
}
|
||||
}
|
||||
|
||||
.ban {
|
||||
display: block;
|
||||
text-align: center;
|
||||
margin-top: 5px;
|
||||
}
|
||||
|
||||
.Comment {
|
||||
.moderateArticle {
|
||||
font-size: 12px;
|
||||
a {
|
||||
display: inline-block;
|
||||
color: #679af3;
|
||||
text-decoration: none;
|
||||
font-size: 1em;
|
||||
font-weight: 400;
|
||||
letter-spacing: .5px;
|
||||
font-size: 12px;
|
||||
margin-left: 10px;
|
||||
|
||||
&:hover {
|
||||
text-decoration: underline;
|
||||
opacity: .9;
|
||||
cursor: pointer;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.flagBox {
|
||||
max-width: 480px;
|
||||
border-top: 1px solid rgba(66, 66, 66, 0.12);
|
||||
h3 {
|
||||
font-size: 14px;
|
||||
margin: 0;
|
||||
font-weight: 500;
|
||||
}
|
||||
}
|
||||
+13
@@ -0,0 +1,13 @@
|
||||
export const actionsMap = {
|
||||
PREMOD: ['REJECT', 'APPROVE', 'BAN'],
|
||||
FLAGGED: ['REJECT', 'APPROVE', 'BAN'],
|
||||
REJECTED: ['APPROVE']
|
||||
};
|
||||
|
||||
export const menuActionsMap = {
|
||||
'REJECT': {status: 'REJECTED', icon: 'close', key: 'r'},
|
||||
'APPROVE': {status: 'ACCEPTED', icon: 'done', key: 't'},
|
||||
'FLAGGED': {status: 'FLAGGED', icon: 'flag', filter: 'Untouched'},
|
||||
'BAN': {status: 'BANNED', icon: 'not interested'},
|
||||
'': {icon: 'done'}
|
||||
};
|
||||
@@ -4,11 +4,14 @@
|
||||
}
|
||||
|
||||
.leftColumn {
|
||||
width: 200px;
|
||||
padding: 42px 56px;
|
||||
width: 234px;
|
||||
}
|
||||
|
||||
.mainContent {
|
||||
width: calc(90% - 200px);
|
||||
width: calc(100% - 300px);
|
||||
padding: 34px 14px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.searchIcon {
|
||||
@@ -18,11 +21,12 @@
|
||||
}
|
||||
|
||||
.searchBox {
|
||||
padding: 3px;
|
||||
width: 100%;
|
||||
padding: 9px;
|
||||
border: 1px solid #ccc;
|
||||
border-radius: 3px;
|
||||
width: 90%;
|
||||
border-radius: 2px;
|
||||
display: flex;
|
||||
background: white;
|
||||
}
|
||||
|
||||
.searchBoxInput {
|
||||
@@ -48,6 +52,22 @@
|
||||
|
||||
.streamsTable {
|
||||
width: 100%;
|
||||
border-left: none;
|
||||
border-right: none;
|
||||
|
||||
a {
|
||||
color: rgb(44, 44, 44);
|
||||
font-weight: 500;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
th {
|
||||
font-size: 1.1em;
|
||||
}
|
||||
|
||||
th.status {
|
||||
width: 100px;
|
||||
}
|
||||
}
|
||||
|
||||
.radio {
|
||||
@@ -55,18 +75,20 @@
|
||||
}
|
||||
|
||||
.statusMenu {
|
||||
border-radius: 3px;
|
||||
border-radius: 2px;
|
||||
width: 10em;
|
||||
text-align: center;
|
||||
float: right;
|
||||
border: 1px solid #ccc;
|
||||
color: #fff;
|
||||
cursor: pointer;
|
||||
letter-spacing: 0.7px;
|
||||
font-weight: 400;
|
||||
box-shadow: 0 2px 2px 0 rgba(0,0,0,.14), 0 3px 1px -2px rgba(0,0,0,.2), 0 1px 5px 0 rgba(0,0,0,.12);
|
||||
}
|
||||
|
||||
.statusMenuOpen {
|
||||
padding: 10px;
|
||||
background-color: #4caf50;
|
||||
background-color: #268D81;
|
||||
}
|
||||
|
||||
.statusMenuIcon {
|
||||
@@ -75,9 +97,17 @@
|
||||
|
||||
.statusMenuClosed {
|
||||
padding: 10px;
|
||||
background-color: #000;
|
||||
background-color: #262626;
|
||||
}
|
||||
|
||||
.hidden {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.radioGroup {
|
||||
margin-top: 5px;
|
||||
span {
|
||||
margin-bottom: 7px;
|
||||
display: inline-block;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,14 +4,11 @@ import {connect} from 'react-redux';
|
||||
import I18n from 'coral-framework/modules/i18n/i18n';
|
||||
import {fetchAssets, updateAssetState} from '../../actions/assets';
|
||||
import translations from '../../translations.json';
|
||||
import {
|
||||
RadioGroup,
|
||||
Radio,
|
||||
Icon,
|
||||
DataTable,
|
||||
TableHeader
|
||||
} from 'react-mdl';
|
||||
import Pager from 'coral-ui/components/Pager';
|
||||
import {Link} from 'react-router';
|
||||
|
||||
import {Pager, Icon} from 'coral-ui';
|
||||
import {DataTable, TableHeader, RadioGroup, Radio} from 'react-mdl';
|
||||
import EmptyCard from 'coral-admin/src/components/EmptyCard';
|
||||
|
||||
const limit = 25;
|
||||
|
||||
@@ -38,12 +35,13 @@ class Streams extends Component {
|
||||
}
|
||||
|
||||
onSearchChange = (e) => {
|
||||
const search = e.target.value;
|
||||
this.setState((prevState) => {
|
||||
prevState.search = e.target.value;
|
||||
prevState.search = search;
|
||||
clearTimeout(prevState.timer);
|
||||
const fetchAssets = this.props.fetchAssets;
|
||||
prevState.timer = setTimeout(() => {
|
||||
fetchAssets(0, limit, e.target.value, this.state.sort, this.state.filter);
|
||||
fetchAssets(0, limit, search, this.state.sort, this.state.filter);
|
||||
}, 350);
|
||||
return prevState;
|
||||
});
|
||||
@@ -73,6 +71,8 @@ class Streams extends Component {
|
||||
}
|
||||
}
|
||||
|
||||
renderTitle = (title, {id}) => <Link to={`/admin/moderate/${id}`}>{title}</Link>
|
||||
|
||||
renderStatus = (closedAt, {id}) => {
|
||||
const closed = closedAt && new Date(closedAt).getTime() < Date.now();
|
||||
const statusMenuOpen = this.state.statusMenus[id];
|
||||
@@ -104,60 +104,66 @@ class Streams extends Component {
|
||||
const {search, sort, filter} = this.state;
|
||||
const {assets} = this.props;
|
||||
|
||||
return <div className={styles.container}>
|
||||
<div className={styles.leftColumn}>
|
||||
const assetsIds = assets.ids.map((id) => assets.byId[id]);
|
||||
|
||||
<div className={styles.searchBox}>
|
||||
<Icon name='search' className={styles.searchIcon}/>
|
||||
<input
|
||||
type='text'
|
||||
value={search}
|
||||
className={styles.searchBoxInput}
|
||||
onChange={this.onSearchChange}
|
||||
placeholder={lang.t('streams.search')}/>
|
||||
</div>
|
||||
|
||||
<div className={styles.optionHeader}>{lang.t('streams.filter-streams')}</div>
|
||||
<div className={styles.optionDetail}>{lang.t('streams.stream-status')}</div>
|
||||
return (
|
||||
<div className={styles.container}>
|
||||
<div className={styles.leftColumn}>
|
||||
<div className={styles.searchBox}>
|
||||
<Icon name='search' className={styles.searchIcon}/>
|
||||
<input
|
||||
type='text'
|
||||
value={search}
|
||||
className={styles.searchBoxInput}
|
||||
onChange={this.onSearchChange}
|
||||
placeholder={lang.t('streams.search')}/>
|
||||
</div>
|
||||
<div className={styles.optionHeader}>{lang.t('streams.filter-streams')}</div>
|
||||
<div className={styles.optionDetail}>{lang.t('streams.stream-status')}</div>
|
||||
<RadioGroup
|
||||
name='status filter'
|
||||
value={filter}
|
||||
childContainer='div'
|
||||
onChange={this.onSettingChange('filter')}>
|
||||
onChange={this.onSettingChange('filter')}
|
||||
className={styles.radioGroup}
|
||||
>
|
||||
<Radio value='all'>{lang.t('streams.all')}</Radio>
|
||||
<Radio value='open'>{lang.t('streams.open')}</Radio>
|
||||
<Radio value='closed'>{lang.t('streams.closed')}</Radio>
|
||||
</RadioGroup>
|
||||
<div className={styles.optionHeader}>{lang.t('streams.sort-by')}</div>
|
||||
<RadioGroup
|
||||
name='sort by'
|
||||
value={sort}
|
||||
childContainer='div'
|
||||
onChange={this.onSettingChange('sort')}>
|
||||
<Radio value='desc'>{lang.t('streams.newest')}</Radio>
|
||||
<Radio value='asc'>{lang.t('streams.oldest')}</Radio>
|
||||
</RadioGroup>
|
||||
<RadioGroup
|
||||
name='sort by'
|
||||
value={sort}
|
||||
childContainer='div'
|
||||
onChange={this.onSettingChange('sort')}
|
||||
className={styles.radioGroup}
|
||||
>
|
||||
<Radio value='desc'>{lang.t('streams.newest')}</Radio>
|
||||
<Radio value='asc'>{lang.t('streams.oldest')}</Radio>
|
||||
</RadioGroup>
|
||||
</div>
|
||||
{
|
||||
assetsIds.length
|
||||
? <div className={styles.mainContent}>
|
||||
<DataTable className={styles.streamsTable} rows={assetsIds} onClick={this.goToModeration}>
|
||||
<TableHeader name="title" cellFormatter={this.renderTitle}>{lang.t('streams.article')}</TableHeader>
|
||||
<TableHeader name="publication_date" cellFormatter={this.renderDate}>
|
||||
{lang.t('streams.pubdate')}
|
||||
</TableHeader>
|
||||
<TableHeader name="closedAt" cellFormatter={this.renderStatus} className={styles.status}>
|
||||
{lang.t('streams.status')}
|
||||
</TableHeader>
|
||||
</DataTable>
|
||||
<Pager
|
||||
totalPages={Math.ceil((assets.count || 0) / limit)}
|
||||
page={this.state.page}
|
||||
onNewPageHandler={this.onPageClick} />
|
||||
</div>
|
||||
: <EmptyCard>{lang.t('streams.empty_result')}</EmptyCard>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div className={styles.mainContent}>
|
||||
<DataTable
|
||||
className={styles.streamsTable}
|
||||
rows={assets.ids.map((id) => assets.byId[id])}>
|
||||
<TableHeader name="title">{lang.t('streams.article')}</TableHeader>
|
||||
<TableHeader numeric name="publication_date" cellFormatter={this.renderDate}>
|
||||
{lang.t('streams.pubdate')}
|
||||
</TableHeader>
|
||||
<TableHeader numeric name="closedAt" cellFormatter={this.renderStatus}>
|
||||
{lang.t('streams.status')}
|
||||
</TableHeader>
|
||||
</DataTable>
|
||||
<Pager
|
||||
totalPages={Math.ceil((assets.count || 0) / limit)}
|
||||
page={this.state.page}
|
||||
onNewPageHandler={this.onPageClick}
|
||||
/>
|
||||
</div>
|
||||
</div>;
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -166,6 +172,7 @@ const mapStateToProps = ({assets}) => {
|
||||
assets: assets.toJS()
|
||||
};
|
||||
};
|
||||
|
||||
const mapDispatchToProps = (dispatch) => {
|
||||
return {
|
||||
fetchAssets: (...args) => {
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
fragment commentView on Comment {
|
||||
id
|
||||
body
|
||||
created_at
|
||||
status
|
||||
user {
|
||||
id
|
||||
name: username
|
||||
status
|
||||
}
|
||||
asset {
|
||||
id
|
||||
title
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import {graphql} from 'react-apollo';
|
||||
import SET_USER_STATUS from './setUserStatus.graphql';
|
||||
import SET_COMMENT_STATUS from './setCommentStatus.graphql';
|
||||
|
||||
export const banUser = graphql(SET_USER_STATUS, {
|
||||
props: ({mutate}) => ({
|
||||
banUser: ({userId}) => {
|
||||
return mutate({
|
||||
variables: {
|
||||
userId,
|
||||
status: 'BANNED'
|
||||
}
|
||||
});
|
||||
}}),
|
||||
});
|
||||
|
||||
export const setCommentStatus = graphql(SET_COMMENT_STATUS, {
|
||||
props: ({mutate}) => ({
|
||||
acceptComment: ({commentId}) => {
|
||||
return mutate({
|
||||
variables: {
|
||||
commentId,
|
||||
status: 'ACCEPTED'
|
||||
},
|
||||
refetchQueries: ['ModQueue']
|
||||
});
|
||||
},
|
||||
rejectComment: ({commentId}) => {
|
||||
return mutate({
|
||||
variables: {
|
||||
commentId,
|
||||
status: 'REJECTED'
|
||||
},
|
||||
refetchQueries: ['ModQueue']
|
||||
});
|
||||
}
|
||||
})
|
||||
});
|
||||
@@ -0,0 +1,7 @@
|
||||
mutation setCommentStatus($commentId: ID!, $status: COMMENT_STATUS!){
|
||||
setCommentStatus(id: $commentId, status: $status) {
|
||||
errors {
|
||||
translation_key
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
mutation setUserStatus($userId: ID!, $status: USER_STATUS!) {
|
||||
setUserStatus(id: $userId, status: $status) {
|
||||
errors {
|
||||
translation_key
|
||||
}
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user