mirror of
https://github.com/wassname/talk.git
synced 2026-09-13 13:10:43 +08:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8b4c2876c7 | ||
|
|
9ff2ccf1ed | ||
|
|
2c2c2dc604 | ||
|
|
94e429e431 | ||
|
|
7d0aac5d4a | ||
|
|
d4c4b5a3e9 | ||
|
|
b88cbe30de | ||
|
|
9c448d5143 | ||
|
|
67301e6e85 | ||
|
|
ad4386b235 | ||
|
|
5666c4ac90 | ||
|
|
37ac5157dd | ||
|
|
5db9ea6520 | ||
|
|
a4b9f86e29 | ||
|
|
cee1c5bc1c | ||
|
|
19a83a902a | ||
|
|
c74de328f2 | ||
|
|
801ce13960 | ||
|
|
b71b7cc883 | ||
|
|
78ef98bc8c | ||
|
|
b2428371c7 | ||
|
|
bb4f660bb6 | ||
|
|
aaa5d17ca3 | ||
|
|
f6938271f6 | ||
|
|
54b5dcb244 | ||
|
|
35938bbd38 | ||
|
|
ac2a636274 | ||
|
|
56e2f70ebb | ||
|
|
188db361ac | ||
|
|
860f434335 | ||
|
|
31c6e2f891 | ||
|
|
a8a32ef36f | ||
|
|
76e2066a51 | ||
|
|
c60cfebde7 | ||
|
|
2743f6faf6 | ||
|
|
a41108a53c | ||
|
|
e9c81f0639 | ||
|
|
d1ab279f74 | ||
|
|
c68afe9819 | ||
|
|
fcf25e0b96 | ||
|
|
5048fbb7f0 | ||
|
|
4e060b884b | ||
|
|
f20f0c5d10 | ||
|
|
35f01c2ecc | ||
|
|
945b0b5df8 | ||
|
|
a58e15c4c8 | ||
|
|
0dee09dd93 | ||
|
|
e8c3bdc5b1 | ||
|
|
bcf31e5fa9 | ||
|
|
a2131c205a | ||
|
|
4ae68b4bd3 | ||
|
|
267338c851 | ||
|
|
fddd8d63c7 | ||
|
|
bdd8ec3789 | ||
|
|
8708fcf2cd | ||
|
|
1db168df8c | ||
|
|
3dc2674f47 | ||
|
|
1ec30854c2 | ||
|
|
7c38b9b54f | ||
|
|
4519a550a2 | ||
|
|
00e24ce88a | ||
|
|
467a32038e | ||
|
|
2d212393ff | ||
|
|
d1729e5115 | ||
|
|
85e552cf04 | ||
|
|
2c5f46b3ae | ||
|
|
3ff9313b03 | ||
|
|
fc4b821f05 | ||
|
|
8fc2d5e8aa | ||
|
|
f0e584f0b3 | ||
|
|
6df2103da3 | ||
|
|
900cb33122 | ||
|
|
6075317564 | ||
|
|
f3f651c562 | ||
|
|
1ffa78dcfb | ||
|
|
cbba3acf4a | ||
|
|
f3f1cc53fe | ||
|
|
0de87af07a | ||
|
|
c47047c3ff | ||
|
|
c9207fd620 | ||
|
|
09cb518049 | ||
|
|
5b7750f7a5 | ||
|
|
50774f1922 | ||
|
|
eda15538fe | ||
|
|
50219922d5 | ||
|
|
b12d4ef4a2 | ||
|
|
348c01ae80 | ||
|
|
1b95fbd852 | ||
|
|
3bf2187688 | ||
|
|
51b866cd65 | ||
|
|
b7ffbdb762 | ||
|
|
ffdec225c7 | ||
|
|
59fbff3caf | ||
|
|
53f7ab86c0 | ||
|
|
4ac71c884b | ||
|
|
6155a46e71 | ||
|
|
13965e1111 | ||
|
|
7b4e8ef204 | ||
|
|
1abafb7037 | ||
|
|
1535f10450 | ||
|
|
82927b77be | ||
|
|
b34cd2d4c1 | ||
|
|
bd0852bf68 | ||
|
|
c72a5c6df9 | ||
|
|
9ddc6c35fd | ||
|
|
f724353cf0 | ||
|
|
7698bdf930 | ||
|
|
1750510d34 | ||
|
|
2eda1cb1e4 | ||
|
|
4ce4c74fd9 | ||
|
|
bfacc8f2f7 | ||
|
|
ad15b38a5d | ||
|
|
fd6282077f | ||
|
|
673bbb61b1 | ||
|
|
449a6f67f1 | ||
|
|
b5210f3727 | ||
|
|
cc6882168b | ||
|
|
df859dc782 | ||
|
|
a5d11a05d1 | ||
|
|
7526d64bc0 | ||
|
|
04a4918fd5 | ||
|
|
371e3b951d | ||
|
|
f66e12be18 | ||
|
|
fb010bfa67 | ||
|
|
e729a673a3 | ||
|
|
d6b2d6d876 | ||
|
|
55cdc98303 | ||
|
|
d5ff9d3eeb | ||
|
|
404301bd43 | ||
|
|
8c478d1c13 | ||
|
|
bee27da725 | ||
|
|
a873034288 | ||
|
|
8164fa7813 | ||
|
|
d1bc747009 | ||
|
|
e028e30afd | ||
|
|
ff3cffdc8f | ||
|
|
d4fc8a474d | ||
|
|
a19fe02782 | ||
|
|
81e040397e | ||
|
|
07ea548d46 | ||
|
|
99b0f8f2ad | ||
|
|
cb652dbb86 | ||
|
|
fa944927e4 | ||
|
|
fb6ba37366 | ||
|
|
9cf31e0bfa | ||
|
|
061e775227 | ||
|
|
258321201b | ||
|
|
71e1c01552 | ||
|
|
1fc0bf6ec1 | ||
|
|
e58cf8e933 | ||
|
|
7e34bc1d13 | ||
|
|
193ed8c863 | ||
|
|
642a9b87ca | ||
|
|
d9c8882d8f | ||
|
|
902071c0fe | ||
|
|
018e88f7cf | ||
|
|
c395bc8849 | ||
|
|
bd782b98ab | ||
|
|
d5552df41f | ||
|
|
7aa262e97f | ||
|
|
fa72a661ad | ||
|
|
90db03bc29 | ||
|
|
6aaca9130a | ||
|
|
7bcd336f51 | ||
|
|
9dae72dd8a | ||
|
|
118da885fd | ||
|
|
abc7aa7b30 | ||
|
|
7969985f59 | ||
|
|
22fc8555ad | ||
|
|
d9d3f5e85b | ||
|
|
32defa66e6 | ||
|
|
2e37512851 | ||
|
|
78286d3374 | ||
|
|
941db338d0 | ||
|
|
7424fc9774 | ||
|
|
d5016dff58 | ||
|
|
7fca451263 | ||
|
|
dbc95db805 | ||
|
|
42a1026f9c | ||
|
|
9e16e58d19 | ||
|
|
e04f0f7070 | ||
|
|
2fcfaa8a99 | ||
|
|
f7ee08bd3a | ||
|
|
31960d67f3 | ||
|
|
b3ce4507a3 | ||
|
|
c7e1943272 | ||
|
|
c0913d8e98 | ||
|
|
e8d9f8aab8 | ||
|
|
3e882452db | ||
|
|
29fe5e2901 | ||
|
|
6c0e00155f | ||
|
|
3837c316d5 | ||
|
|
336dd261ca | ||
|
|
29aacdc00e | ||
|
|
d892d1327d | ||
|
|
309b41a3cf | ||
|
|
cbde4cc8bc | ||
|
|
81bd983f0b | ||
|
|
92205a2c6f | ||
|
|
48795b29bf | ||
|
|
f4f8e5053f | ||
|
|
51774323de | ||
|
|
e256c4a014 | ||
|
|
0254086c28 | ||
|
|
191026ecf2 | ||
|
|
19f402ddc8 | ||
|
|
c19e2979fc | ||
|
|
a19d6544a7 | ||
|
|
d74cdd938f | ||
|
|
d4aa2011ff | ||
|
|
8e76a34631 | ||
|
|
ac632a69d2 | ||
|
|
72c698231b | ||
|
|
143ce795c3 | ||
|
|
83eb6492c2 | ||
|
|
7590cee986 | ||
|
|
358743b3ab | ||
|
|
f313d2a7d2 | ||
|
|
1f862cce74 | ||
|
|
6f4d591d0b | ||
|
|
c084c92c31 | ||
|
|
1a678a0cb3 | ||
|
|
cc0f757746 | ||
|
|
2ecf64ae3e | ||
|
|
6ac4bcf796 | ||
|
|
2e3021e66b | ||
|
|
63b630833a | ||
|
|
e4aa98b87a | ||
|
|
f900727455 | ||
|
|
3c6ce7a80f | ||
|
|
f44798361e | ||
|
|
56dedb551f | ||
|
|
b15fd7acbf | ||
|
|
1dcfa269b3 | ||
|
|
36dff1afb8 | ||
|
|
fbcea2de6d | ||
|
|
3307bf5ef5 | ||
|
|
f6cb97e29c | ||
|
|
247583b858 | ||
|
|
8cf2130e25 | ||
|
|
295f4ddec2 | ||
|
|
7ab8dbf3c6 | ||
|
|
4ce37a4bbb | ||
|
|
a7be2772c3 | ||
|
|
847ad2263b | ||
|
|
eb1c1db09b | ||
|
|
12e383ef13 | ||
|
|
a43e713ac0 | ||
|
|
0dff670b70 | ||
|
|
58ffd61884 | ||
|
|
4d2e5ed81f | ||
|
|
7dfe12b874 | ||
|
|
58736586e4 | ||
|
|
b561e9f643 | ||
|
|
873da1cea9 | ||
|
|
c7d8c40770 | ||
|
|
80658f51d1 | ||
|
|
2656141d59 | ||
|
|
c59ddb72a6 | ||
|
|
70ec5d5f54 | ||
|
|
bc3ec84180 | ||
|
|
803db345ac | ||
|
|
990b9794e9 | ||
|
|
a9412c17c9 | ||
|
|
545f176bb8 | ||
|
|
d3d7045227 | ||
|
|
2dc882a12e | ||
|
|
3a3b89b03b | ||
|
|
152a9417b2 | ||
|
|
313e368565 | ||
|
|
8c627c5a7b | ||
|
|
7838347861 | ||
|
|
c5520e3fb6 | ||
|
|
972cce4e8f | ||
|
|
93ca0bf79d | ||
|
|
a7493af979 | ||
|
|
9335856474 | ||
|
|
a27044b691 | ||
|
|
a77a623e94 | ||
|
|
5d81429673 | ||
|
|
536b2ed371 | ||
|
|
aeaa9ae80f | ||
|
|
014c23bb95 | ||
|
|
826f051175 | ||
|
|
4c1ae22765 | ||
|
|
b7c2861a28 | ||
|
|
97666df1e0 | ||
|
|
34486028e5 | ||
|
|
2c8d03bd8d | ||
|
|
6aad4411d9 | ||
|
|
917be12667 | ||
|
|
b2ee1f0c57 | ||
|
|
e888161fe4 | ||
|
|
f648d923a3 | ||
|
|
8e33d25a28 | ||
|
|
b4e8648177 | ||
|
|
bcf31cd19a | ||
|
|
63850acf73 | ||
|
|
735aa6fbd3 | ||
|
|
0a0f82cd5a | ||
|
|
aef0f433c9 | ||
|
|
c6189e8b3a | ||
|
|
ca04db80dc | ||
|
|
029b00033e | ||
|
|
a4d631546a | ||
|
|
dfe8e88faa | ||
|
|
79d9e24cb6 | ||
|
|
5a4441e309 | ||
|
|
5245dfaef9 | ||
|
|
8b9e69e8a6 | ||
|
|
16f15c5faf | ||
|
|
4377a48e03 | ||
|
|
3bd3bca09a | ||
|
|
a79a5128c9 | ||
|
|
27408e1fa1 | ||
|
|
5e2d64709c | ||
|
|
f779a55096 | ||
|
|
a2df5df58c | ||
|
|
e1ebba5b1c | ||
|
|
ad8c6cc140 | ||
|
|
ae5bcf6900 | ||
|
|
b2563ca766 | ||
|
|
acb5686c66 | ||
|
|
54518af2a5 | ||
|
|
3b24e3c045 | ||
|
|
449152d400 | ||
|
|
10836f7918 | ||
|
|
e6ada54d0e | ||
|
|
770a4e6fe2 | ||
|
|
4bc7848e24 | ||
|
|
943d4cf98a | ||
|
|
a9c88f52ce | ||
|
|
fb8b33b2eb | ||
|
|
28ce77a002 | ||
|
|
9cab34da73 | ||
|
|
1f367e666b | ||
|
|
e92fdc97bb | ||
|
|
e70cfdfd00 | ||
|
|
58f296ef88 | ||
|
|
79d5c28d00 | ||
|
|
7c2b16521a | ||
|
|
26fff5b950 | ||
|
|
2b23a6625f | ||
|
|
6f8b375519 | ||
|
|
73344fa528 | ||
|
|
6117dd3358 | ||
|
|
31cdf964f7 | ||
|
|
025df1918d | ||
|
|
92146f326f | ||
|
|
a018a8f9bb | ||
|
|
5cb9fdccf9 | ||
|
|
b64e46c007 | ||
|
|
4d51fb276d | ||
|
|
be64905426 | ||
|
|
f91f2b143f | ||
|
|
d32c2f5177 | ||
|
|
990fe34873 | ||
|
|
7b7686bc79 | ||
|
|
ca2782ba06 | ||
|
|
bbf0839890 | ||
|
|
dfcb661c21 | ||
|
|
e4e2ff03b3 | ||
|
|
c5ce22c5e6 | ||
|
|
44aa98eb28 | ||
|
|
5dbeb695a4 | ||
|
|
a270aef42e | ||
|
|
2bbbf2bbf7 | ||
|
|
b0effe8731 | ||
|
|
b24ee81e34 | ||
|
|
baf5a34001 | ||
|
|
582f799b6c | ||
|
|
21eb532c6e | ||
|
|
9505ffdedd | ||
|
|
8a06118d94 | ||
|
|
eef64f5f1b | ||
|
|
d9835a17d3 | ||
|
|
877372814a | ||
|
|
97727118dd | ||
|
|
74596b9e08 | ||
|
|
9c3450ebad | ||
|
|
4aa8b5f234 | ||
|
|
18d6846f4e | ||
|
|
4a49a910aa | ||
|
|
18a29bf0df | ||
|
|
b3d1053fd7 | ||
|
|
f6a5d9f721 | ||
|
|
765bee40b4 | ||
|
|
528f5738c8 | ||
|
|
a4ec278125 | ||
|
|
6e2fd0851e | ||
|
|
bdc3a79b27 | ||
|
|
5609715e77 | ||
|
|
2546902bcf | ||
|
|
52948a4165 | ||
|
|
c1dac11a2b | ||
|
|
8d3524dd61 | ||
|
|
c5098a3e91 | ||
|
|
febe264320 | ||
|
|
368838df98 | ||
|
|
b5280b1221 | ||
|
|
9c806cb6e4 | ||
|
|
881f79a571 | ||
|
|
d4c7dac945 | ||
|
|
96dcd5f74a | ||
|
|
37230ce7fa | ||
|
|
24aba5084b | ||
|
|
512405b3f0 | ||
|
|
62c4072631 | ||
|
|
73e2b899d8 | ||
|
|
e0540a5031 | ||
|
|
e0a3e8a3c1 | ||
|
|
a2f1f8018f | ||
|
|
c221dbb4a0 | ||
|
|
1b8489165b | ||
|
|
f1239b4b19 | ||
|
|
c4edab9bbd | ||
|
|
0108673566 | ||
|
|
59c1656da3 | ||
|
|
f74d6071df | ||
|
|
b93f1e2a7d | ||
|
|
8196cb9c24 | ||
|
|
a5e463ea0c | ||
|
|
cc2af70962 | ||
|
|
5a5f1bc508 | ||
|
|
56a1503e3a | ||
|
|
c4decbd844 | ||
|
|
0a77fe92bd | ||
|
|
ba4f39aecd | ||
|
|
1da5c570bf | ||
|
|
004e8d5fe9 | ||
|
|
ca4c79681b | ||
|
|
e5da8354d6 | ||
|
|
093c13c103 | ||
|
|
72c1774cf3 | ||
|
|
173e1c16ea | ||
|
|
e1c4b69038 | ||
|
|
1ee49f76f6 | ||
|
|
3d245c2029 | ||
|
|
78d072a04a | ||
|
|
cd08987502 | ||
|
|
b7e00f1da4 | ||
|
|
492d919053 | ||
|
|
5fec7c3a11 | ||
|
|
2d99206b51 | ||
|
|
da4eceafe9 | ||
|
|
a1bb0cd55f | ||
|
|
1bb3ed2399 | ||
|
|
126387becc | ||
|
|
87ff9306de | ||
|
|
fd968b2070 | ||
|
|
79cbdaf92b | ||
|
|
5fe96cf47c | ||
|
|
b9f0633365 | ||
|
|
52b6ce06e6 | ||
|
|
0ec582e411 | ||
|
|
a7def6fc2b | ||
|
|
7efbdc8b59 | ||
|
|
679962168c | ||
|
|
4c79f45556 | ||
|
|
f7f0a9d99e | ||
|
|
a22d7af8bb | ||
|
|
17a8f48b4f | ||
|
|
ab6f455ec7 | ||
|
|
4d89db931e | ||
|
|
2d0208046b | ||
|
|
e878d1ebfd | ||
|
|
659c63e5cb | ||
|
|
9df334866d | ||
|
|
f37c768662 | ||
|
|
3007ba68f8 | ||
|
|
4ca2a48bc9 | ||
|
|
635df54a2c | ||
|
|
cfb28c4c2a | ||
|
|
1e80991614 | ||
|
|
0dc105405c | ||
|
|
5dc9811e06 | ||
|
|
ebcb3b4b02 | ||
|
|
f11a11cf67 | ||
|
|
c5d36a9f19 | ||
|
|
f80f253b98 | ||
|
|
71d2baa010 | ||
|
|
55973859ef | ||
|
|
6548a76bfb | ||
|
|
009b381744 | ||
|
|
3e00a4e842 | ||
|
|
57c1e58bc3 | ||
|
|
0726603a4e | ||
|
|
9d59c95cdf | ||
|
|
771f99cee8 | ||
|
|
6bb9d23fdc | ||
|
|
520392829b | ||
|
|
00a74f05cb | ||
|
|
8680d6cab8 | ||
|
|
184b6c00a6 | ||
|
|
870fcac3b1 | ||
|
|
d80ed467f1 | ||
|
|
8435358879 | ||
|
|
5092b849b3 | ||
|
|
daa6ce60ab | ||
|
|
b7ce669cfc | ||
|
|
e75663f5b9 | ||
|
|
4e60689fa4 | ||
|
|
2513ef2df5 | ||
|
|
b76fa94354 | ||
|
|
1a115e4f2c | ||
|
|
e7c0c5b638 | ||
|
|
4ff683b3af | ||
|
|
4f1f85d127 | ||
|
|
155be523ff | ||
|
|
697d2f0ec1 | ||
|
|
3a47dd4c4b | ||
|
|
2ec5b243fb | ||
|
|
0769a836d1 | ||
|
|
68584cf2cf | ||
|
|
e85ebba21b | ||
|
|
8a8700ebb2 | ||
|
|
542a261e5e | ||
|
|
d539e23c00 | ||
|
|
a4dabd0308 | ||
|
|
c35043f799 | ||
|
|
a6fa9a22c3 | ||
|
|
a2551a499f | ||
|
|
b5d86a3380 | ||
|
|
9eaed90318 | ||
|
|
2b90f2f588 | ||
|
|
8114608626 | ||
|
|
228fdeac4d | ||
|
|
701994f8f7 | ||
|
|
5d5ad2585d | ||
|
|
05efbc4ded | ||
|
|
323749915a | ||
|
|
84f1d3ece2 | ||
|
|
b9c816285d | ||
|
|
3be9b13691 | ||
|
|
2e80d2a36e | ||
|
|
341a7320f9 |
@@ -1,3 +1,18 @@
|
||||
# excluded because we'll likely need to rebuild this.
|
||||
node_modules
|
||||
|
||||
# scripts are used during development and testing, not
|
||||
# production.
|
||||
scripts
|
||||
|
||||
# documentation should not be visable in production.
|
||||
docs
|
||||
|
||||
# static assets are rebuild in the docker container.
|
||||
dist
|
||||
|
||||
# tests are not run in the docker container.
|
||||
test
|
||||
|
||||
# we won't use the .git folder in production.
|
||||
.git
|
||||
|
||||
+1
-4
@@ -2,14 +2,11 @@ root = true
|
||||
|
||||
[*]
|
||||
indent_style = space
|
||||
indent_size = 2
|
||||
end_of_line = lf
|
||||
charset = utf-8
|
||||
trim_trailing_whitespace = true
|
||||
insert_final_newline = true
|
||||
|
||||
[{package.json,.*rc,*.yml}]
|
||||
indent_style = space
|
||||
indent_size = 2
|
||||
|
||||
[*.md]
|
||||
trim_trailing_whitespace = false
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
dist
|
||||
client/lib
|
||||
**/*.html
|
||||
plugins/*
|
||||
!plugins/coral-plugin-facebook-auth
|
||||
!plugins/coral-plugin-respect
|
||||
node_modules
|
||||
|
||||
+14
-9
@@ -1,18 +1,23 @@
|
||||
node_modules
|
||||
npm-debug.log*
|
||||
|
||||
dist
|
||||
!dist/coral-admin
|
||||
dist/coral-admin/bundle.js
|
||||
test/e2e/reports
|
||||
.DS_Store
|
||||
*.iml
|
||||
*.swp
|
||||
|
||||
npm-debug.log*
|
||||
dump.rdb
|
||||
|
||||
.env
|
||||
*.cfg
|
||||
|
||||
.idea/
|
||||
*.swp
|
||||
|
||||
test/e2e/reports
|
||||
coverage/
|
||||
|
||||
# remove plugin folders
|
||||
plugins
|
||||
plugins.json
|
||||
plugins/*
|
||||
!plugins/coral-plugin-facebook-auth
|
||||
!plugins/coral-plugin-respect
|
||||
!plugins/coral-plugin-offtopic
|
||||
|
||||
**/node_modules/*
|
||||
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"verbose": true,
|
||||
"ignore": ["test/*", "client/*", "dist/*"],
|
||||
"ignore": ["test/*", "client/*", "dist/*", "plugins/*/client"],
|
||||
"ext": "js,json,graphql"
|
||||
}
|
||||
|
||||
+3
-2
@@ -10,10 +10,11 @@ By contributing to this project you agree to the [Code of Conduct](https://coral
|
||||
|
||||
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.
|
||||
If you'd like to see our longer term roadmap, [get in touch.](https://coralproject.net/contact.html)
|
||||
|
||||
|
||||
## 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 looking for _documentarians_ to:
|
||||
@@ -55,7 +56,7 @@ Please [contact us](https://github.com/coralproject/talk/wiki/Contact-Us) early
|
||||
|
||||
To get an idea of where the Coral Team is going, see:
|
||||
|
||||
* our [product/design Trello board](https://trello.com/b/ILND751a/talk),
|
||||
* our [Slack channel](https://coralprojectslackin.herokuapp.com/), where you can talk directly with the Team and other community members,
|
||||
* our [current stories](https://www.pivotaltracker.com/n/projects/1863625), and
|
||||
* our [issues](https://github.com/coralproject/talk/issues).
|
||||
|
||||
|
||||
+11
-6
@@ -1,20 +1,25 @@
|
||||
FROM node:7
|
||||
FROM node:7.8
|
||||
|
||||
# Create app directory
|
||||
RUN mkdir -p /usr/src/app
|
||||
WORKDIR /usr/src/app
|
||||
|
||||
# Setup the environment
|
||||
ENV NODE_ENV production
|
||||
ENV PATH /usr/src/app/bin:$PATH
|
||||
ENV TALK_PORT 5000
|
||||
EXPOSE 5000
|
||||
|
||||
# Install app dependencies
|
||||
COPY package.json yarn.lock /usr/src/app/
|
||||
RUN yarn install --production
|
||||
|
||||
# Bundle app source
|
||||
COPY . /usr/src/app
|
||||
|
||||
# Install app dependencies and build static assets.
|
||||
RUN yarn install --frozen-lockfile && \
|
||||
cli plugins reconcile && \
|
||||
yarn build && \
|
||||
yarn install --production && \
|
||||
yarn cache clean
|
||||
|
||||
# Ensure the runtime of the container is in production mode.
|
||||
ENV NODE_ENV production
|
||||
|
||||
CMD ["yarn", "start"]
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
FROM coralproject/talk:latest
|
||||
|
||||
# Bundle app source
|
||||
ONBUILD COPY . /usr/src/app
|
||||
|
||||
# At this stage, we need to install the development dependancies again because
|
||||
# we need to have webpack available. We then build the new dependancies and
|
||||
# clear out the development dependancies again. After this we of course need to
|
||||
# clear out the yarn cache, this saves quite a lot of size.
|
||||
ONBUILD RUN NODE_ENV=development yarn install --frozen-lockfile && \
|
||||
NODE_ENV=production cli plugins reconcile && \
|
||||
NODE_ENV=production yarn build && \
|
||||
NODE_ENV=production yarn install --production --force && \
|
||||
yarn cache clean
|
||||
+218
-37
@@ -1,37 +1,204 @@
|
||||
## Contents
|
||||
|
||||
- [Installation](#installation) - install the application on a machine
|
||||
- [Via Docker](#installation-from-docker)
|
||||
- [Via Source](#installation-from-source)
|
||||
- [Setup](#setup) - setup the application for first use
|
||||
- [Usage](#usage) - connect the application to a website
|
||||
|
||||
# Installation
|
||||
|
||||
## Requirements
|
||||
|
||||
### System
|
||||
|
||||
- Any flavour of Linux, OSX or Windows
|
||||
- 1GB memory (minimum)
|
||||
- 5GB storage (minimum)
|
||||
- [MongoDB](https://www.mongodb.com/) v3.4 or later
|
||||
- [Redis](https://redis.io/) v3.2 or later
|
||||
- SSL Certificate
|
||||
- This application assumes that you will be serving this application in a
|
||||
production environment, and therefore requires that you serve it behind a
|
||||
webserver with a valid SSL certificate. This is chosen in order to secure
|
||||
user's sessions.
|
||||
|
||||
## Installation From Docker
|
||||
|
||||
We currently support packaging the Talk application via Docker, which automates
|
||||
the dependency install and asset build process. This is the recommended way to
|
||||
deploy the application when used in production.
|
||||
|
||||
Available as [coralproject/talk](https://hub.docker.com/r/coralproject/talk/) on Docker Hub.
|
||||
|
||||
Images are tagged using the following notation:
|
||||
|
||||
- `x` (where `x` is the major version number): any minor or patch updates will be included in this. If you're ok getting
|
||||
new features occasionally and all the bug fixes, this is the tag for you.
|
||||
- `x.y` (where `y` is the minor version number): any patch updates will be
|
||||
included with this tag. If you like getting fixes and having features change
|
||||
only when you want, this is the tag for you. **(recommended)**
|
||||
- `x.y.z` (where `z` is the patch version): this tag never gets updated, and
|
||||
essentially freezes your version, this should only be used when you are either
|
||||
extending Talk or are sure of a specific version you want to freeze.
|
||||
|
||||
We provide tags with `*-onbuild` that can be used for easy plugin integration and
|
||||
acts as a customization endpoint. Instructions are provided in the `PLUGINS.md`
|
||||
document as to how to use it.
|
||||
|
||||
### Requirements
|
||||
|
||||
There are some runtime requirements for running Talk for Docker:
|
||||
|
||||
- [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. Incorrect versions
|
||||
of these may lead to unexpected errors!_
|
||||
|
||||
### Installing
|
||||
|
||||
An example docker-compose.yml:
|
||||
|
||||
```yaml
|
||||
version: '2'
|
||||
services:
|
||||
talk:
|
||||
image: coralproject/talk:1.5
|
||||
restart: always
|
||||
ports:
|
||||
- "5000:5000"
|
||||
depends_on:
|
||||
- mongo
|
||||
- redis
|
||||
environment:
|
||||
- TALK_MONGO_URL=mongodb://mongo/talk
|
||||
- TALK_REDIS_URL=redis://redis
|
||||
mongo:
|
||||
image: mongo:3.2
|
||||
restart: always
|
||||
volumes:
|
||||
- mongo:/data/db
|
||||
redis:
|
||||
image: redis:3.2
|
||||
restart: always
|
||||
volumes:
|
||||
- redis:/data
|
||||
volumes:
|
||||
mongo:
|
||||
external: false
|
||||
redis:
|
||||
external: false
|
||||
```
|
||||
|
||||
At this stage, you should refer to the `README.md` for configuration variables
|
||||
that are specific to your installation. Some pre-defined fields have been filled
|
||||
in the above example which are consistent with Docker Compose naming conventions
|
||||
for [Docker Links](https://docs.docker.com/compose/networking/#links).
|
||||
|
||||
### Scaling
|
||||
|
||||
If you are interested in splitting apart services, you can simply adjust the
|
||||
command being executed in the container to optimize for your use case. An
|
||||
example would be if you wanted to run the API server and the job processor
|
||||
on different machines. You can achieve this easily with docker compose:
|
||||
|
||||
```yaml
|
||||
version: '2'
|
||||
services:
|
||||
talk-api:
|
||||
image: coralproject/talk:1.5
|
||||
command: cli serve
|
||||
restart: always
|
||||
ports:
|
||||
- "5000:5000"
|
||||
depends_on:
|
||||
- mongo
|
||||
- redis
|
||||
environment:
|
||||
- TALK_MONGO_URL=mongodb://mongo/talk
|
||||
- TALK_REDIS_URL=redis://redis
|
||||
talk-jobs:
|
||||
image: coralproject/talk:1.5
|
||||
command: cli jobs process
|
||||
restart: always
|
||||
ports:
|
||||
- "5001:5000"
|
||||
depends_on:
|
||||
- mongo
|
||||
- redis
|
||||
environment:
|
||||
- TALK_MONGO_URL=mongodb://mongo/talk
|
||||
- TALK_REDIS_URL=redis://redis
|
||||
mongo:
|
||||
image: mongo:3.2
|
||||
restart: always
|
||||
volumes:
|
||||
- mongo:/data/db
|
||||
redis:
|
||||
image: redis:3.2
|
||||
restart: always
|
||||
volumes:
|
||||
- redis:/data
|
||||
volumes:
|
||||
mongo:
|
||||
external: false
|
||||
redis:
|
||||
external: false
|
||||
```
|
||||
|
||||
Note that the only difference is in the `command` key. From this, you are able
|
||||
to discretely control which modules are running in order to have the maximum
|
||||
flexibility when managing your application.
|
||||
|
||||
### Running
|
||||
|
||||
If you're using docker compose:
|
||||
|
||||
```bash
|
||||
# Start the services using compose
|
||||
docker-compose up -d
|
||||
```
|
||||
|
||||
If you're using plain docker:
|
||||
|
||||
```bash
|
||||
docker run -d -P coralproject/talk:latest
|
||||
```
|
||||
|
||||
## Installation From Source
|
||||
|
||||
This provides information on how to setup the application from source. Note that
|
||||
this is not recommended for production deploys, but will work for development
|
||||
and testing purposes.
|
||||
|
||||
### 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
|
||||
- [Node](https://nodejs.org/) ~7.8
|
||||
- [Yarn](https://yarnpkg.com/) ^0.22.0
|
||||
|
||||
_Please be sure to check the versions of these requirements. Insufficient versions of these may lead to unexpected errors!_
|
||||
_Please be sure to check the versions of these requirements. Incorrect versions
|
||||
of these may lead to unexpected errors!_
|
||||
|
||||
### Installing
|
||||
|
||||
#### Download
|
||||
|
||||
It is highly recommended that you download a released version as the code
|
||||
available in `master` may not be stable. You can download the latest release
|
||||
from the [releases page](https://github.com/coralproject/talk/releases).
|
||||
|
||||
You can also clone the git repository via:
|
||||
|
||||
```bash
|
||||
# Download the tarball containing the repository
|
||||
curl -L https://github.com/coralproject/talk/tarball/master -o coralproject-talk.tar.gz
|
||||
git clone https://github.com/coralproject/talk.git
|
||||
```
|
||||
|
||||
# Untar that file and change to that directory
|
||||
tar xpf coralproject-talk.tar.gz
|
||||
mv coralproject-talk-* coralproject-talk
|
||||
cd coralproject-talk
|
||||
#### Building
|
||||
|
||||
We now have to install the dependencies and build the static assets.
|
||||
|
||||
```bash
|
||||
# Install package dependancies
|
||||
yarn
|
||||
|
||||
@@ -39,6 +206,17 @@ yarn
|
||||
yarn build
|
||||
```
|
||||
|
||||
After you create/modify the `plugins.json` (refer to `PLUGINS.md` for plugin
|
||||
docs) file, you can re-run the following to install their dependencies:
|
||||
|
||||
```bash
|
||||
# Reconcile plugins
|
||||
./bin/cli plugins reconcile
|
||||
|
||||
# Build static files
|
||||
yarn build
|
||||
```
|
||||
|
||||
### Running
|
||||
|
||||
Refer to the `README.md` file for required configuration variables to add to the
|
||||
@@ -50,45 +228,48 @@ You can start the server after configuring the server using the command:
|
||||
yarn start
|
||||
```
|
||||
|
||||
This will setup the server to serve everything on a single node.js process and
|
||||
is designed to be used in production.
|
||||
|
||||
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
|
||||
- `yarn dev-start` watch for changes to server files and reload the server while
|
||||
also sourcing a `.env` file in your local directory for configuration
|
||||
|
||||
## Installation From Docker Hub
|
||||
# Setup
|
||||
|
||||
### Requirements
|
||||
Once you've installed Talk (either via Docker or source), you still need to
|
||||
setup the application. If you are unfamiliar with any terminoligy used in the
|
||||
setup process, refer to the `TERMINOLOGY.md` document.
|
||||
|
||||
There are some runtime requirements for running Talk for Docker:
|
||||
## Via Web
|
||||
|
||||
- [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
|
||||
If you want to perform your setup via the web, you can navigate to your
|
||||
installation of Talk at the path `/admin/install`. There you will be asked a
|
||||
series of questions for your installation.
|
||||
|
||||
_Please be sure to check the versions of these requirements. Insufficient versions of these may lead to unexpected errors!_
|
||||
## Via CLI
|
||||
|
||||
### Installing
|
||||
If you want to perform your setup through the terminal, you can simply run:
|
||||
|
||||
```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
|
||||
cli setup
|
||||
```
|
||||
|
||||
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.
|
||||
And follow the instructions to perform initial setup and create your first user
|
||||
account.
|
||||
|
||||
### Running
|
||||
|
||||
```bash
|
||||
# Start the services using compose
|
||||
docker-compose up -d
|
||||
```
|
||||
# Usage
|
||||
|
||||
After setup is complete, you can then refer to the `/admin/configure` path to
|
||||
get the embed code that you can copy/paste onto your blog or website in order to
|
||||
start using Talk.
|
||||
|
||||
_In order for the embed to work correctly, you will need to whitelist the domain
|
||||
that is allowed to embed your site on the `/admin/configure` page, failure to do
|
||||
so will result in the comment stream not loading._
|
||||
|
||||
+176
-5
@@ -3,13 +3,20 @@
|
||||
Plugins for Talk can take various forms, currently we are only supporting server
|
||||
side plugins.
|
||||
|
||||
## Plugin Registration: `plugins.json`
|
||||
## Plugin Registration
|
||||
|
||||
All plugins must be registered in the root file `plugins.json`.
|
||||
The parsing order for the plugin registration is as follows:
|
||||
|
||||
The format for this file is thus:
|
||||
- `TALK_PLUGINS_JSON` environment variable
|
||||
- `plugins.json` file
|
||||
- `plugins.default.json` file
|
||||
|
||||
```js
|
||||
If you need to "disable all plugins", you can simply provide `{}` as the
|
||||
contents of `process.env.TALK_PLUGINS_JSON` or the `plugins.json`.
|
||||
|
||||
The format for this is thus:
|
||||
|
||||
```json
|
||||
{
|
||||
"server": [
|
||||
"people"
|
||||
@@ -18,12 +25,94 @@ The format for this file is thus:
|
||||
```
|
||||
|
||||
Where we have a `server` key with an array of plugins that match the folder
|
||||
name in the `plugins/` folder. For example, the above `plugins.json` would
|
||||
name in the `plugins/` folder. For example, the above config would
|
||||
require a plugin from `plugins/people`, which must provide a `index.js` file
|
||||
that returns an object that matches the Plugin Specification.
|
||||
|
||||
If the package is external (available on NPM) you can specify the string for
|
||||
the version by using an object instead, for example:
|
||||
|
||||
```json
|
||||
{
|
||||
"server": [
|
||||
{"people": "^1.2.0"}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
External plugins can be resolved by running:
|
||||
|
||||
```bash
|
||||
./bin/cli plugins reconcile
|
||||
```
|
||||
|
||||
This achieves two things:
|
||||
|
||||
1. It will traverse into local plugin folders and install their dependencies.
|
||||
_Note that if the plugin is already installed and available in the node_modules folder, it will not be
|
||||
fetched again unless there is a version mismatch._ This will result in the
|
||||
project `package.json` and `yarn.lock` files to be modified, this is normal as
|
||||
this ensures that repeated deployments (with the same config) will have the
|
||||
same config, these changes should not be committed to source control.
|
||||
2. It will seek out dependencies that are listed in the object notation and try
|
||||
to install them from npm.
|
||||
|
||||
## Plugin Dependencies
|
||||
|
||||
You may also include additional external dependencies in your local packages by
|
||||
specifying a `package.json` at your plugin root which will result in a
|
||||
`node_modules` folder being generated at the plugin root with your specific
|
||||
dependencies.
|
||||
|
||||
## Deployment Solutions
|
||||
|
||||
Plugins can be deployed with a production instance of Talk.
|
||||
|
||||
### Source
|
||||
|
||||
Source deployments can just modify the `plugins.json` file and include any
|
||||
local plugins into the `plugins/` directory. After including the config, you
|
||||
need to reconcile the plugins and build the static assets:
|
||||
|
||||
```bash
|
||||
# get plugin dependancies and remote plugins
|
||||
./bin/cli plugins reconcile
|
||||
|
||||
# build staic assets (including enabled client side plugins)
|
||||
yarn build
|
||||
```
|
||||
|
||||
Then the application can be started as is.
|
||||
|
||||
### Docker
|
||||
|
||||
If you deploy using Docker, you can extend from the `*-onbuild` image, an
|
||||
example `Dockerfile` for your project could be:
|
||||
|
||||
```Dockerfile
|
||||
FROM coralproject/talk:latest-onbuild
|
||||
```
|
||||
|
||||
Where the directory for your instance would contain a `plugins.json` file
|
||||
describing the plugin requirements and a `plugins` directory containing any
|
||||
other local plugins that should be included.
|
||||
|
||||
Onbuild triggers will execute when the image is building with your custom
|
||||
configuration and will ensure that the image is ready to use by building all
|
||||
assets inside the image as well.
|
||||
|
||||
## Server Plugins
|
||||
|
||||
### API
|
||||
|
||||
You can access any API available inside the talk directory in a plugin by simply
|
||||
importing the file relative to the talk project root. An example would be if you
|
||||
wanted to import the `MetadataService`, you would simply write:
|
||||
|
||||
```javascript
|
||||
const MetadataService = require('services/metadata');
|
||||
```
|
||||
|
||||
### Specification
|
||||
|
||||
Each plugin should export a single object with all hooks available on it.
|
||||
@@ -170,6 +259,88 @@ If your post function accepts four parameters, then it can modify the field
|
||||
result. It is *required* that the function resolves a promise (or returns) with
|
||||
the modified value or simply the original if you didn't modify it.
|
||||
|
||||
#### Field: `router`
|
||||
|
||||
```js
|
||||
(router) => {
|
||||
router.get('/api/v1/people', (req, res) => {
|
||||
res.json({people: [{name: 'Bob'}]});
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
The Router hook allows you to create a function that accepts the base express
|
||||
router where you can mount any amount of middleware/routes to do any form of
|
||||
action needed by external applications. We also provide the authorization
|
||||
middleware via:
|
||||
|
||||
```js
|
||||
const authorization = require('middleware/authorization');
|
||||
|
||||
module.exports = {
|
||||
router(router) {
|
||||
router.get('/api/v1/people', authorization.needed('ADMIN'), (req, res) => {
|
||||
res.json({people: [{name: 'SECRET PEOPLE'}]});
|
||||
});
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### Field: `passport`
|
||||
|
||||
```js
|
||||
const FacebookStrategy = require('passport-facebook').Strategy;
|
||||
const UsersService = require('services/users');
|
||||
const {ValidateUserLogin, HandleAuthPopupCallback} = require('services/passport');
|
||||
|
||||
module.exports = {
|
||||
passport(passport) {
|
||||
passport.use(new FacebookStrategy({
|
||||
clientID: process.env.TALK_FACEBOOK_APP_ID,
|
||||
clientSecret: process.env.TALK_FACEBOOK_APP_SECRET,
|
||||
callbackURL: `${process.env.TALK_ROOT_URL}/api/v1/auth/facebook/callback`,
|
||||
passReqToCallback: true,
|
||||
profileFields: ['id', 'displayName', 'picture.type(large)']
|
||||
}, async (req, accessToken, refreshToken, profile, done) => {
|
||||
|
||||
let user;
|
||||
try {
|
||||
user = await UsersService.findOrCreateExternalUser(profile);
|
||||
} catch (err) {
|
||||
return done(err);
|
||||
}
|
||||
|
||||
return ValidateUserLogin(profile, user, done);
|
||||
}));
|
||||
},
|
||||
router(router) {
|
||||
|
||||
// Note that we have to import the passport instance here, it is
|
||||
// instantiated after all the strategies have been mounted.
|
||||
const {passport} = require('services/passport');
|
||||
|
||||
/**
|
||||
* Facebook auth endpoint, this will redirect the user immediatly to facebook
|
||||
* for authorization.
|
||||
*/
|
||||
router.get('/facebook', passport.authenticate('facebook', {display: 'popup', authType: 'rerequest', scope: ['public_profile']}));
|
||||
|
||||
/**
|
||||
* Facebook callback endpoint, this will send the user a html page designed to
|
||||
* send back the user credentials upon sucesfull login.
|
||||
*/
|
||||
router.get('/facebook/callback', (req, res, next) => {
|
||||
|
||||
// Perform the facebook login flow and pass the data back through the opener.
|
||||
passport.authenticate('facebook', HandleAuthPopupCallback(req, res, next))(req, res, next);
|
||||
});
|
||||
}
|
||||
};
|
||||
```
|
||||
|
||||
This is a full example including the routes hook to add the required components
|
||||
to the application router to support a different auth strategy.
|
||||
|
||||
### Full Example
|
||||
|
||||
Contents of `plugins.json`:
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Talk [](https://circleci.com/gh/coralproject/talk)
|
||||
|
||||
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)
|
||||
Talk is currently in Beta! [Read more about Talk here.](https://coralproject.net/products/talk.html)
|
||||
|
||||
Third party licenses are available via the `/client/3rdpartylicenses.txt`
|
||||
endpoint when the server is running with built assets.
|
||||
@@ -28,12 +28,6 @@ The Talk application looks for the following configuration values either as envi
|
||||
secure cookies.
|
||||
- `TALK_ROOT_URL` (*required*) - root url of the installed application externally
|
||||
available in the format: `<scheme>://<host>` without the path.
|
||||
|
||||
- `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 for email*) - username of the SMTP provider you are using.
|
||||
@@ -43,6 +37,7 @@ Facebook Login enabled app.
|
||||
- `TALK_INSTALL_LOCK` (_optional for dynamic setup_) - Defaults to `FALSE`. When `TRUE`, disables the dynamic setup endpoint.
|
||||
- `TALK_RECAPTCHA_SECRET` (*required for reCAPTCHA support*) - server secret used for enabling reCAPTCHA powered logins. If not provided it will instead default to providing only a time based lockout.
|
||||
- `TALK_RECAPTCHA_PUBLIC` (*required for reCAPTCHA support*) - client secret used for enabling reCAPTCHA powered logins. If not provided it will instead default to providing only a time based lockout.
|
||||
- `TALK_PLUGINS_JSON` (_optional_) - used to specify the plugin config via the environment
|
||||
|
||||
Refer to the wiki page on [Configuration Loading](https://github.com/coralproject/talk/wiki/Configuration-Loading) for
|
||||
alternative methods of loading configuration during development.
|
||||
|
||||
@@ -3,7 +3,8 @@ const bodyParser = require('body-parser');
|
||||
const morgan = require('morgan');
|
||||
const path = require('path');
|
||||
const helmet = require('helmet');
|
||||
const passport = require('./services/passport');
|
||||
const {passport} = require('./services/passport');
|
||||
const plugins = require('./services/plugins');
|
||||
const session = require('express-session');
|
||||
const enabled = require('debug').enabled;
|
||||
const RedisStore = require('connect-redis')(session);
|
||||
@@ -75,6 +76,17 @@ app.use(session(session_opts));
|
||||
// PASSPORT MIDDLEWARE
|
||||
//==============================================================================
|
||||
|
||||
const passportDebug = require('debug')('talk:passport');
|
||||
|
||||
// Install the passport plugins.
|
||||
plugins.get('server', 'passport').forEach((plugin) => {
|
||||
passportDebug(`added plugin '${plugin.plugin.name}'`);
|
||||
|
||||
// Pass the passport.js instance to the plugin to allow it to inject it's
|
||||
// functionality.
|
||||
plugin.passport(passport);
|
||||
});
|
||||
|
||||
// Setup the PassportJS Middleware.
|
||||
app.use(passport.initialize());
|
||||
app.use(passport.session());
|
||||
|
||||
@@ -13,6 +13,7 @@ program
|
||||
.command('setup', 'setup the application')
|
||||
.command('jobs', 'work with the job queues')
|
||||
.command('users', 'work with the application auth')
|
||||
.command('plugins', 'provides utilities for interacting with the plugin system')
|
||||
.parse(process.argv);
|
||||
|
||||
/**
|
||||
|
||||
Executable
+298
@@ -0,0 +1,298 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
/**
|
||||
* Module dependencies.
|
||||
*/
|
||||
|
||||
// Interface heavily inspired by the yarn package manager:
|
||||
// https://yarnpkg.com/
|
||||
|
||||
const program = require('./commander');
|
||||
|
||||
// Make things colorful!
|
||||
require('colors');
|
||||
const emoji = require('node-emoji');
|
||||
|
||||
const dir = process.cwd();
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const spawn = require('cross-spawn');
|
||||
const semver = require('semver');
|
||||
const resolve = require('resolve');
|
||||
const {plugins, itteratePlugins, isInternal} = require('../plugins');
|
||||
|
||||
function existsInNodeModules(name) {
|
||||
try {
|
||||
resolve.sync(name, {basedir: dir});
|
||||
|
||||
return true;
|
||||
} catch (e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function versionMatch(name, version) {
|
||||
try {
|
||||
let matched = false;
|
||||
|
||||
resolve.sync(name, {
|
||||
basedir: dir,
|
||||
packageFilter: (pkg) => {
|
||||
if (pkg && pkg.version && semver.satisfies(pkg.version, version)) {
|
||||
matched = true;
|
||||
}
|
||||
|
||||
return pkg;
|
||||
}
|
||||
});
|
||||
|
||||
return matched;
|
||||
} catch (e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
const EXTERNAL = /^\w[a-z\-0-9\.]+$/; // Match "react", "path", "fs", "lodash.random", etc.
|
||||
|
||||
function reconcilePackages({quiet = false, upgradeRemote = false}) {
|
||||
const fetchable = [];
|
||||
const local = [];
|
||||
const upgradable = [];
|
||||
|
||||
if (!quiet) {
|
||||
console.log();
|
||||
console.log(' +local (l) packages in your project');
|
||||
console.log(' +external (e) packages are external');
|
||||
console.log(' +outofdate (oe) packages are external but are out of date');
|
||||
console.log(' +missing (m) packages are not found');
|
||||
console.log();
|
||||
}
|
||||
|
||||
for (let i in plugins) {
|
||||
let section = itteratePlugins(plugins[i]);
|
||||
|
||||
for (let j in section) {
|
||||
let {name, version} = section[j];
|
||||
|
||||
let namespaced = name.charAt(0) === '@';
|
||||
let dep = name.split('/')
|
||||
.slice(0, namespaced ? 2 : 1)
|
||||
.join('/');
|
||||
|
||||
// Ignore relative modules, which aren't installed by NPM
|
||||
if (!dep.match(EXTERNAL) && !namespaced) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (isInternal(dep)) {
|
||||
if (!quiet) {
|
||||
console.log(` l ${name}`);
|
||||
}
|
||||
|
||||
local.push({name, version});
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!existsInNodeModules(dep)) {
|
||||
if (!quiet) {
|
||||
console.log(` m ${name}`);
|
||||
}
|
||||
fetchable.push({name, version});
|
||||
} else if (!versionMatch(dep, version)) {
|
||||
|
||||
// A plugin was found, yet the current version does not match the
|
||||
// current version installed. We should warn if upgradeRemote is
|
||||
// not enabled that it is currently not supported.
|
||||
if (!upgradeRemote) {
|
||||
if (!quiet) {
|
||||
console.warn(` oe ${name} (package upgrade may be required)`.bgRed);
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
console.log(` oe ${name} (package upgrade may be required)`);
|
||||
|
||||
upgradable.push({name, version});
|
||||
} else {
|
||||
if (!quiet) {
|
||||
console.log(` e ${name}`);
|
||||
}
|
||||
|
||||
if (upgradeRemote) {
|
||||
upgradable.push({name, version});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!quiet) {
|
||||
console.log();
|
||||
}
|
||||
|
||||
return {local, fetchable, upgradable};
|
||||
}
|
||||
|
||||
async function reconcileRemotePlugins({skipLocal, dryRun, upgradeRemote}) {
|
||||
console.log(`\n[${skipLocal ? '1/2' : '2/3'}] ${emoji.get('mag')} Reconciling plugins...`.yellow);
|
||||
const {fetchable, upgradable} = reconcilePackages({upgradeRemote});
|
||||
|
||||
console.log(`[${skipLocal ? '2/2' : '3/3'}] ${emoji.get('truck')} Fetching plugins...\n`.yellow);
|
||||
|
||||
if (fetchable.length > 0) {
|
||||
|
||||
console.log(`$ yarn add --ignore-scripts ${fetchable.map(({name, version}) => `${name}@${version}`.cyan)}`);
|
||||
|
||||
if (!dryRun) {
|
||||
|
||||
let args = [
|
||||
'add',
|
||||
'--ignore-scripts',
|
||||
...fetchable.map(({name, version}) => `${name}@${version}`)
|
||||
];
|
||||
|
||||
let output = spawn.sync('yarn', args, {
|
||||
stdio: ['ignore', 'pipe', 'inherit']
|
||||
});
|
||||
|
||||
if (output.status) {
|
||||
throw new Error('Could not install external plugins, errors occured during install');
|
||||
}
|
||||
|
||||
console.log(output.stdout.toString());
|
||||
}
|
||||
}
|
||||
|
||||
if (upgradable.length > 0) {
|
||||
console.log(`$ yarn upgrade ${upgradable.map(({name, version}) => `${name}@${version}`.cyan)}`);
|
||||
|
||||
if (!dryRun) {
|
||||
|
||||
let args = [
|
||||
'upgrade',
|
||||
...upgradable.map(({name, version}) => `${name}@${version}`)
|
||||
];
|
||||
|
||||
let output = spawn.sync('yarn', args, {
|
||||
stdio: ['ignore', 'pipe', 'inherit']
|
||||
});
|
||||
|
||||
if (output.status) {
|
||||
throw new Error('Could not install external plugins, errors occured during install');
|
||||
}
|
||||
|
||||
console.log(output.stdout.toString());
|
||||
}
|
||||
}
|
||||
|
||||
return {upgradable, fetchable};
|
||||
}
|
||||
|
||||
async function reconcileLocalPlugins({skipRemote, dryRun}) {
|
||||
console.log(`\n[${skipRemote ? '1/1' : '1/3'}] ${emoji.get('pick')} Installing local plugin dependencies...\n`.yellow);
|
||||
const {local} = reconcilePackages({quiet: true});
|
||||
|
||||
for (let i in local) {
|
||||
let {name} = local[i];
|
||||
|
||||
if (!fs.existsSync(path.join(dir, 'plugins', name, 'package.json'))) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let wd = path.join(dir, 'plugins', name);
|
||||
|
||||
console.log(`$ cd ${wd.cyan} && yarn`);
|
||||
|
||||
if (!dryRun) {
|
||||
let args = [];
|
||||
|
||||
let output = spawn.sync('yarn', args, {
|
||||
stdio: ['ignore', 'pipe', 'inherit'],
|
||||
cwd: wd
|
||||
});
|
||||
|
||||
if (output.status) {
|
||||
throw new Error('Could not install local plugin dependencies, errors occured during install');
|
||||
}
|
||||
|
||||
console.log(output.stdout.toString());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// This traverses the local plugins and installs any dependencies listed there,
|
||||
// this only is really needed for plugins that are installed via docker because
|
||||
// core plugins will have their dependencies already included in core.
|
||||
async function reconcilePluginDeps({skipLocal, skipRemote, dryRun, upgradeRemote}) {
|
||||
let startTime = new Date();
|
||||
|
||||
// We don't need to do anything if we skip everything....
|
||||
if (skipLocal && skipRemote) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Traverse local plugins and install dependencies if enabled.
|
||||
if (!skipLocal) {
|
||||
await reconcileLocalPlugins({skipRemote, dryRun});
|
||||
}
|
||||
|
||||
// Locate any external plugins and install them.
|
||||
if (!skipRemote) {
|
||||
let results = [];
|
||||
try {
|
||||
results = await reconcileRemotePlugins({skipLocal, skipRemote, dryRun, upgradeRemote});
|
||||
} catch (e) {
|
||||
throw e;
|
||||
}
|
||||
|
||||
let status;
|
||||
if (dryRun) {
|
||||
status = '[dry-run] success'.green;
|
||||
} else {
|
||||
status = 'success'.green;
|
||||
}
|
||||
|
||||
let message;
|
||||
if (results.upgradable.length === 0 && results.fetchable.length === 0) {
|
||||
message = 'Already up-to-date.';
|
||||
} else if (results.upgradable.length === 0) {
|
||||
message = `Fetched ${results.fetchable.length} new plugins.`;
|
||||
} else if (results.fetchable.length === 0) {
|
||||
message = `Upgraded ${results.upgradable.length} new plugins.`;
|
||||
} else {
|
||||
message = `Fetched ${results.fetchable.length} new plugins, upgraded ${results.upgradable.length} plugins.`;
|
||||
}
|
||||
|
||||
console.log(`\n${status} ${message}`);
|
||||
}
|
||||
|
||||
let endTime = new Date();
|
||||
|
||||
let totalTime = ((endTime.getTime() - startTime.getTime()) / 1000).toFixed(2);
|
||||
console.log(`✨ Done in ${totalTime}s.`);
|
||||
}
|
||||
|
||||
//==============================================================================
|
||||
// Setting up the program command line arguments.
|
||||
//==============================================================================
|
||||
|
||||
program
|
||||
.command('list')
|
||||
.description('')
|
||||
.action(reconcilePackages);
|
||||
|
||||
program
|
||||
.command('reconcile')
|
||||
.description('reconciles local plugin dependencies and downloads external plugins')
|
||||
.option('-u, --upgrade-remote', 'upgrades remote dependencies')
|
||||
.option('-d, --dry-run', 'does not actually change anything on the filesystem acts only as a simulation')
|
||||
.option('--skip-local', 'skips the local dependancy reconciliation')
|
||||
.option('--skip-remote', 'skips the remote plugin reconciliation')
|
||||
.action(reconcilePluginDeps);
|
||||
|
||||
program.parse(process.argv);
|
||||
|
||||
// If there is no command listed, output help.
|
||||
if (!process.argv.slice(2).length) {
|
||||
program.outputHelp();
|
||||
}
|
||||
+4
-3
@@ -1,6 +1,6 @@
|
||||
machine:
|
||||
node:
|
||||
version: 7.6
|
||||
version: 7.9
|
||||
services:
|
||||
- docker
|
||||
- redis
|
||||
@@ -20,6 +20,7 @@ dependencies:
|
||||
# - sudo service mongod restart
|
||||
|
||||
# Install node dependencies.
|
||||
- yarn --version
|
||||
- yarn
|
||||
cache_directories:
|
||||
- ~/.cache/yarn
|
||||
@@ -47,9 +48,9 @@ deployment:
|
||||
release:
|
||||
tag: /v[0-9]+(\.[0-9]+)*/
|
||||
commands:
|
||||
- bash ./scripts/deploy.sh
|
||||
- bash ./scripts/docker.sh deploy
|
||||
|
||||
latest:
|
||||
branch: master
|
||||
commands:
|
||||
- bash ./scripts/deploy.sh
|
||||
- bash ./scripts/docker.sh deploy
|
||||
|
||||
+9
-1
@@ -1,6 +1,14 @@
|
||||
{
|
||||
"extends": "../.babelrc",
|
||||
"presets": [
|
||||
"es2015"
|
||||
],
|
||||
"plugins": [
|
||||
"add-module-exports",
|
||||
"transform-class-properties",
|
||||
"transform-decorators-legacy",
|
||||
"transform-object-assign",
|
||||
"transform-object-rest-spread",
|
||||
"transform-async-to-generator",
|
||||
"transform-react-jsx"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -1,23 +0,0 @@
|
||||
|
||||
# Coral Admin
|
||||
|
||||
This app handles moderation for Talk (and maybe more later on)
|
||||
|
||||
## Installation
|
||||
|
||||
$ yarn install
|
||||
$ cp config.sample.json config.json
|
||||
|
||||
Then change `config.json` to adjust it to your project
|
||||
|
||||
## Building for production
|
||||
|
||||
$ yarn build
|
||||
|
||||
The public folder has everything you need for deployment. You can just copy that folder to your favorite static web server
|
||||
|
||||
## Development
|
||||
|
||||
$ yarn start
|
||||
|
||||
A development server will be running at http://localhost:4132
|
||||
@@ -5,10 +5,13 @@ import Stories from 'containers/Stories/Stories';
|
||||
import Configure from 'containers/Configure/Configure';
|
||||
import LayoutContainer from 'containers/LayoutContainer';
|
||||
import InstallContainer from 'containers/Install/InstallContainer';
|
||||
|
||||
import CommunityLayout from 'containers/Community/CommunityLayout';
|
||||
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 = (
|
||||
@@ -21,9 +24,24 @@ const routes = (
|
||||
<Route path='stories' component={Stories} />
|
||||
<Route path='dashboard' component={Dashboard} />
|
||||
|
||||
{/* Community Routes */}
|
||||
|
||||
<Route path='community' component={CommunityLayout}>
|
||||
<Route path='flagged' components={CommunityContainer}>
|
||||
<Route path=':id' components={CommunityContainer} />
|
||||
</Route>
|
||||
<Route path='people' components={CommunityContainer}>
|
||||
<Route path=':id' components={CommunityContainer} />
|
||||
</Route>
|
||||
<IndexRedirect to='flagged' />
|
||||
</Route>
|
||||
|
||||
{/* Moderation Routes */}
|
||||
|
||||
<Route path='moderate' component={ModerationLayout}>
|
||||
<Route path='all' components={ModerationContainer}>
|
||||
<Route path=':id' components={ModerationContainer} />
|
||||
</Route>
|
||||
<Route path='premod' components={ModerationContainer}>
|
||||
<Route path=':id' components={ModerationContainer} />
|
||||
</Route>
|
||||
|
||||
@@ -7,28 +7,33 @@ import {
|
||||
SORT_UPDATE,
|
||||
COMMENTERS_NEW_PAGE,
|
||||
SET_ROLE,
|
||||
SET_COMMENTER_STATUS
|
||||
SET_COMMENTER_STATUS,
|
||||
SHOW_BANUSER_DIALOG,
|
||||
HIDE_BANUSER_DIALOG,
|
||||
SHOW_SUSPENDUSER_DIALOG,
|
||||
HIDE_SUSPENDUSER_DIALOG
|
||||
} from '../constants/community';
|
||||
|
||||
import coralApi from '../../../coral-framework/helpers/response';
|
||||
|
||||
export const fetchCommenters = (query = {}) => dispatch => {
|
||||
dispatch(requestFetchCommenters());
|
||||
export const fetchAccounts = (query = {}) => dispatch => {
|
||||
|
||||
dispatch(requestFetchAccounts());
|
||||
coralApi(`/users?${qs.stringify(query)}`)
|
||||
.then(({result, page, count, limit, totalPages}) =>
|
||||
.then(({result, page, count, limit, totalPages}) =>{
|
||||
dispatch({
|
||||
type: FETCH_COMMENTERS_SUCCESS,
|
||||
commenters: result,
|
||||
accounts: result,
|
||||
page,
|
||||
count,
|
||||
limit,
|
||||
totalPages
|
||||
})
|
||||
)
|
||||
});
|
||||
})
|
||||
.catch(error => dispatch({type: FETCH_COMMENTERS_FAILURE, error}));
|
||||
};
|
||||
|
||||
const requestFetchCommenters = () => ({
|
||||
const requestFetchAccounts = () => ({
|
||||
type: FETCH_COMMENTERS_REQUEST
|
||||
});
|
||||
|
||||
@@ -55,3 +60,11 @@ export const setCommenterStatus = (id, status) => (dispatch) => {
|
||||
return dispatch({type: SET_COMMENTER_STATUS, id, status});
|
||||
});
|
||||
};
|
||||
|
||||
// Ban User Dialog
|
||||
export const showBanUserDialog = (user) => ({type: SHOW_BANUSER_DIALOG, user});
|
||||
export const hideBanUserDialog = () => ({type: HIDE_BANUSER_DIALOG});
|
||||
|
||||
// Suspend User Dialog
|
||||
export const showSuspendUserDialog = (user) => ({type: SHOW_SUSPENDUSER_DIALOG, user});
|
||||
export const hideSuspendUserDialog = () => ({type: HIDE_SUSPENDUSER_DIALOG});
|
||||
|
||||
@@ -6,3 +6,15 @@ export const singleView = () => ({type: actions.SINGLE_VIEW});
|
||||
// Ban User Dialog
|
||||
export const showBanUserDialog = (user, commentId, showRejectedNote) => ({type: actions.SHOW_BANUSER_DIALOG, user, commentId, showRejectedNote});
|
||||
export const hideBanUserDialog = (showDialog) => ({type: actions.HIDE_BANUSER_DIALOG, showDialog});
|
||||
|
||||
// hide shortcuts note
|
||||
export const hideShortcutsNote = () => {
|
||||
try {
|
||||
window.localStorage.setItem('coral:shortcutsNote', 'hide');
|
||||
} catch (e) {
|
||||
|
||||
// above will fail in Safari private mode
|
||||
}
|
||||
|
||||
return {type: actions.HIDE_SHORTCUTS_NOTE};
|
||||
};
|
||||
|
||||
@@ -1,17 +1,24 @@
|
||||
import React from 'react';
|
||||
import React, {PropTypes} from 'react';
|
||||
import styles from './ModerationList.css';
|
||||
import {Button} from 'coral-ui';
|
||||
import {menuActionsMap} from '../containers/ModerationQueue/helpers/moderationQueueActionsMap';
|
||||
|
||||
const ActionButton = ({type = '', ...props}) => {
|
||||
const ActionButton = ({type = '', status, ...props}) => {
|
||||
const typeName = type.toLowerCase();
|
||||
const active = ((type === 'REJECT' && status === 'REJECTED') || (type === 'APPROVE' && status === 'ACCEPTED'));
|
||||
|
||||
return (
|
||||
<Button
|
||||
className={`${type.toLowerCase()} ${styles.actionButton}`}
|
||||
cStyle={type.toLowerCase()}
|
||||
className={`${typeName} ${styles.actionButton} ${active ? styles[`${typeName}__active`] : ''}`}
|
||||
cStyle={typeName}
|
||||
icon={menuActionsMap[type].icon}
|
||||
onClick={type === 'APPROVE' ? props.acceptComment : props.rejectComment}
|
||||
>{menuActionsMap[type].text}</Button>
|
||||
);
|
||||
};
|
||||
|
||||
ActionButton.propTypes = {
|
||||
status: PropTypes.string
|
||||
};
|
||||
|
||||
export default ActionButton;
|
||||
|
||||
@@ -0,0 +1,622 @@
|
||||
$minHeight: 200px;
|
||||
$fullscreenZIndex: 10;
|
||||
|
||||
.wrapper {
|
||||
/* Fix blockquote styling of MDL: https://github.com/google/material-design-lite/issues/2037 */
|
||||
blockquote {
|
||||
> p:first-child {
|
||||
padding-top: 16px;
|
||||
}
|
||||
> p:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
&::after {
|
||||
margin-left: -0.5em;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.iconBold {
|
||||
composes: material-icons from global;
|
||||
&::before {
|
||||
content: 'format_bold';
|
||||
}
|
||||
}
|
||||
|
||||
.iconItalic {
|
||||
composes: material-icons from global;
|
||||
&::before {
|
||||
content: 'format_italic';
|
||||
}
|
||||
}
|
||||
|
||||
.iconTitle {
|
||||
composes: material-icons from global;
|
||||
&::before {
|
||||
content: 'title';
|
||||
}
|
||||
}
|
||||
|
||||
.iconQuote {
|
||||
composes: material-icons from global;
|
||||
&::before {
|
||||
content: 'format_quote';
|
||||
}
|
||||
}
|
||||
|
||||
.iconUnorderedList {
|
||||
composes: material-icons from global;
|
||||
&::before {
|
||||
content: 'format_list_bulleted';
|
||||
}
|
||||
}
|
||||
|
||||
.iconOrderedList {
|
||||
composes: material-icons from global;
|
||||
&::before {
|
||||
content: 'format_list_numbered';
|
||||
}
|
||||
}
|
||||
|
||||
.iconLink {
|
||||
composes: material-icons from global;
|
||||
&::before {
|
||||
content: 'link';
|
||||
}
|
||||
}
|
||||
|
||||
.iconImage {
|
||||
composes: material-icons from global;
|
||||
&::before {
|
||||
content: 'insert_photo';
|
||||
}
|
||||
}
|
||||
|
||||
.iconPreview {
|
||||
composes: material-icons from global;
|
||||
&::before {
|
||||
content: 'remove_red_eye';
|
||||
}
|
||||
}
|
||||
|
||||
.iconSideBySide {
|
||||
composes: material-icons from global;
|
||||
&::before {
|
||||
content: 'chrome_reader_mode';
|
||||
}
|
||||
}
|
||||
|
||||
.iconFullscreen {
|
||||
composes: material-icons from global;
|
||||
&::before {
|
||||
content: 'fullscreen';
|
||||
}
|
||||
}
|
||||
|
||||
.iconGuide {
|
||||
composes: material-icons from global;
|
||||
&::before {
|
||||
content: 'help_outline';
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
* These are modified styles taken from https://github.com/NextStepWebs/simplemde-markdown-editor and
|
||||
* put through http://sebastianpontow.de/css2compass/.
|
||||
*/
|
||||
|
||||
/*
|
||||
* simplemde v1.11.2
|
||||
* Copyright Next Step Webs, Inc.
|
||||
* @link https://github.com/NextStepWebs/simplemde-markdown-editor
|
||||
* @license MIT
|
||||
*/
|
||||
:global {
|
||||
.CodeMirror {
|
||||
font-family: monospace;
|
||||
color: black;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
background: white;
|
||||
height: auto;
|
||||
min-height: $minHeight;
|
||||
border: 1px solid #ddd;
|
||||
border-bottom-left-radius: 4px;
|
||||
border-bottom-right-radius: 4px;
|
||||
padding: 10px;
|
||||
font: inherit;
|
||||
z-index: 1;
|
||||
pre {
|
||||
padding: 0 4px;
|
||||
border-radius: 0;
|
||||
border-width: 0;
|
||||
background: transparent;
|
||||
font-family: inherit;
|
||||
font-size: inherit;
|
||||
margin: 0;
|
||||
white-space: pre;
|
||||
word-wrap: normal;
|
||||
line-height: inherit;
|
||||
color: inherit;
|
||||
z-index: 2;
|
||||
position: relative;
|
||||
overflow: visible;
|
||||
font-variant-ligatures: none;
|
||||
}
|
||||
span {
|
||||
/*TODO: vertical-align: text-bottom;*/
|
||||
}
|
||||
.CodeMirror-code {
|
||||
.cm-tag {
|
||||
color: #63a35c;
|
||||
}
|
||||
.cm-attribute {
|
||||
color: #795da3;
|
||||
}
|
||||
.cm-string {
|
||||
color: #183691;
|
||||
}
|
||||
.cm-header-1 {
|
||||
font-size: 200%;
|
||||
line-height: 200%;
|
||||
}
|
||||
.cm-header-2 {
|
||||
font-size: 160%;
|
||||
line-height: 160%;
|
||||
}
|
||||
.cm-header-3 {
|
||||
font-size: 125%;
|
||||
line-height: 125%;
|
||||
}
|
||||
.cm-header-4 {
|
||||
font-size: 110%;
|
||||
line-height: 110%;
|
||||
}
|
||||
.cm-comment {
|
||||
background: rgba(0, 0, 0, .05);
|
||||
border-radius: 2px;
|
||||
}
|
||||
.cm-link {
|
||||
color: #7f8c8d;
|
||||
}
|
||||
.cm-url {
|
||||
color: #aab2b3;
|
||||
}
|
||||
.cm-strikethrough {
|
||||
text-decoration: line-through;
|
||||
}
|
||||
.cm-tab {
|
||||
display: inline-block;
|
||||
text-decoration: inherit;
|
||||
}
|
||||
.CodeMirror-ruler {
|
||||
border-left: 1px solid #ccc;
|
||||
position: absolute;
|
||||
}
|
||||
.cm-header {
|
||||
font-weight: bold;
|
||||
}
|
||||
.cm-strong {
|
||||
font-weight: bold;
|
||||
}
|
||||
.cm-em {
|
||||
font-style: italic;
|
||||
}
|
||||
.cm-link {
|
||||
text-decoration: underline;
|
||||
}
|
||||
.cm-strikethrough {
|
||||
text-decoration: line-through;
|
||||
}
|
||||
.cm-invalidchar {
|
||||
color: #f00;
|
||||
}
|
||||
}
|
||||
.CodeMirror-selected {
|
||||
background: #d9d9d9;
|
||||
}
|
||||
.CodeMirror-placeholder {
|
||||
opacity: .5;
|
||||
}
|
||||
div.CodeMirror-secondarycursor {
|
||||
border-left: 1px solid silver;
|
||||
}
|
||||
.cm-spell-error:not(.cm-url):not(.cm-comment):not(.cm-tag):not(.cm-word) {
|
||||
background: rgba(255, 0, 0, .15);
|
||||
}
|
||||
}
|
||||
.CodeMirror-lines {
|
||||
padding: 4px 0;
|
||||
cursor: text;
|
||||
min-height: 1px;
|
||||
}
|
||||
.CodeMirror-scrollbar-filler {
|
||||
background-color: white;
|
||||
position: absolute;
|
||||
z-index: 6;
|
||||
display: none;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
}
|
||||
.CodeMirror-gutter-filler {
|
||||
background-color: white;
|
||||
position: absolute;
|
||||
z-index: 6;
|
||||
display: none;
|
||||
left: 0;
|
||||
bottom: 0;
|
||||
}
|
||||
.CodeMirror-gutters {
|
||||
border-right: 1px solid #ddd;
|
||||
background-color: #f7f7f7;
|
||||
white-space: nowrap;
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
min-height: 100%;
|
||||
z-index: 3;
|
||||
box-sizing: content-box;
|
||||
}
|
||||
.CodeMirror-guttermarker {
|
||||
color: black;
|
||||
}
|
||||
.CodeMirror-guttermarker-subtle {
|
||||
color: #999;
|
||||
}
|
||||
.CodeMirror-cursors {
|
||||
visibility: hidden;
|
||||
position: relative;
|
||||
z-index: 3;
|
||||
}
|
||||
.CodeMirror-cursor {
|
||||
border-left: 1px solid black;
|
||||
border-right: none;
|
||||
width: 0;
|
||||
position: absolute;
|
||||
}
|
||||
@keyframes blink {
|
||||
0% {
|
||||
}
|
||||
50% {
|
||||
background-color: transparent;
|
||||
}
|
||||
100% {
|
||||
}
|
||||
}
|
||||
.CodeMirror-scroll {
|
||||
overflow: scroll !important;
|
||||
margin-bottom: -30px;
|
||||
margin-right: -30px;
|
||||
padding-bottom: 30px;
|
||||
height: 100%;
|
||||
outline: none;
|
||||
position: relative;
|
||||
min-height: $minHeight;
|
||||
box-sizing: content-box;
|
||||
}
|
||||
.CodeMirror-sizer {
|
||||
position: relative;
|
||||
border-right: 30px solid transparent;
|
||||
box-sizing: content-box;
|
||||
}
|
||||
.CodeMirror-vscrollbar {
|
||||
position: absolute;
|
||||
z-index: 6;
|
||||
display: none;
|
||||
right: 0;
|
||||
top: 0;
|
||||
overflow-x: hidden;
|
||||
overflow-y: scroll;
|
||||
}
|
||||
.CodeMirror-hscrollbar {
|
||||
position: absolute;
|
||||
z-index: 6;
|
||||
display: none;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
overflow-y: hidden;
|
||||
overflow-x: scroll;
|
||||
}
|
||||
.CodeMirror-gutter {
|
||||
white-space: normal;
|
||||
height: 100%;
|
||||
display: inline-block;
|
||||
vertical-align: top;
|
||||
margin-bottom: -30px;
|
||||
*zoom: 1;
|
||||
*display: inline;
|
||||
box-sizing: content-box;
|
||||
}
|
||||
.CodeMirror-gutter-wrapper {
|
||||
position: absolute;
|
||||
z-index: 4;
|
||||
background: none !important;
|
||||
border: none !important;
|
||||
user-select: none;
|
||||
}
|
||||
.CodeMirror-gutter-background {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
z-index: 4;
|
||||
}
|
||||
.CodeMirror-gutter-elt {
|
||||
position: absolute;
|
||||
cursor: default;
|
||||
z-index: 4;
|
||||
}
|
||||
.CodeMirror-code {
|
||||
outline: none;
|
||||
}
|
||||
.CodeMirror-measure {
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
height: 0;
|
||||
overflow: hidden;
|
||||
visibility: hidden;
|
||||
pre {
|
||||
position: static;
|
||||
}
|
||||
}
|
||||
.CodeMirror-focused {
|
||||
.CodeMirror-selected {
|
||||
background: #d7d4f0;
|
||||
}
|
||||
div.CodeMirror-cursors {
|
||||
visibility: visible;
|
||||
}
|
||||
}
|
||||
.CodeMirror-selected {
|
||||
background: #d9d9d9;
|
||||
}
|
||||
.CodeMirror-line::selection {
|
||||
background: #d7d4f0;
|
||||
}
|
||||
.CodeMirror-line {
|
||||
> span::selection {
|
||||
background: #d7d4f0;
|
||||
}
|
||||
> span {
|
||||
> span::selection {
|
||||
background: #d7d4f0;
|
||||
}
|
||||
}
|
||||
}
|
||||
@media print {
|
||||
.CodeMirror div.CodeMirror-cursors {
|
||||
visibility: hidden;
|
||||
}
|
||||
}
|
||||
.CodeMirror-fullscreen {
|
||||
background: #fff;
|
||||
position: fixed !important;
|
||||
top: 50px;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
height: auto;
|
||||
z-index: $fullscreenZIndex;
|
||||
}
|
||||
.CodeMirror-sided {
|
||||
width: 50% !important;
|
||||
}
|
||||
.editor-toolbar {
|
||||
position: relative;
|
||||
opacity: .6;
|
||||
user-select: none;
|
||||
padding: 0 10px;
|
||||
border-top: 1px solid #bbb;
|
||||
border-left: 1px solid #bbb;
|
||||
border-right: 1px solid #bbb;
|
||||
border-top-left-radius: 4px;
|
||||
border-top-right-radius: 4px;
|
||||
&:after {
|
||||
display: block;
|
||||
content: ' ';
|
||||
height: 1px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
&:before {
|
||||
display: block;
|
||||
content: ' ';
|
||||
height: 1px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
&:hover {
|
||||
opacity: .8;
|
||||
}
|
||||
&.fullscreen {
|
||||
width: 100%;
|
||||
height: 50px;
|
||||
overflow-x: auto;
|
||||
overflow-y: hidden;
|
||||
white-space: nowrap;
|
||||
padding-top: 10px;
|
||||
padding-bottom: 10px;
|
||||
box-sizing: border-box;
|
||||
background: #fff;
|
||||
border: 0;
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
opacity: 1;
|
||||
z-index: $fullscreenZIndex;
|
||||
}
|
||||
&.fullscreen::before {
|
||||
width: 20px;
|
||||
height: 50px;
|
||||
background: linear-gradient(to right, rgba(255, 255, 255, 1) 0, rgba(255, 255, 255, 0) 100%);
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
&.fullscreen::after {
|
||||
width: 20px;
|
||||
height: 50px;
|
||||
background: linear-gradient(to right, rgba(255, 255, 255, 0) 0, rgba(255, 255, 255, 1) 100%);
|
||||
position: fixed;
|
||||
top: 0;
|
||||
right: 0;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
a {
|
||||
display: inline-block;
|
||||
text-align: center;
|
||||
text-decoration: none!important;
|
||||
color: #2c3e50!important;
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
margin: 0;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 3px;
|
||||
cursor: pointer;
|
||||
outline: 0;
|
||||
margin-right: 2px;
|
||||
&.active {
|
||||
background: #fcfcfc;
|
||||
border-color: #95a5a6;
|
||||
}
|
||||
&:hover {
|
||||
background: #fcfcfc;
|
||||
border-color: #95a5a6;
|
||||
}
|
||||
&:active {
|
||||
background: #eee;
|
||||
}
|
||||
&:before {
|
||||
line-height: 30px;
|
||||
}
|
||||
}
|
||||
i.separator {
|
||||
display: inline-block;
|
||||
width: 0;
|
||||
border-left: 1px solid #d9d9d9;
|
||||
border-right: 1px solid #fff;
|
||||
color: transparent;
|
||||
text-indent: -10px;
|
||||
margin: 0 6px;
|
||||
}
|
||||
&.disabled-for-preview a:not(.no-disable) {
|
||||
pointer-events: none;
|
||||
background: #fff;
|
||||
border-color: transparent;
|
||||
text-shadow: inherit;
|
||||
}
|
||||
}
|
||||
@media only screen and(max-width: 700px) {
|
||||
.editor-toolbar a.no-mobile {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
.editor-statusbar {
|
||||
padding: 8px 10px;
|
||||
font-size: 12px;
|
||||
color: #959694;
|
||||
text-align: right;
|
||||
span {
|
||||
display: inline-block;
|
||||
min-width: 4em;
|
||||
margin-left: 1em;
|
||||
}
|
||||
.lines:before {
|
||||
content: 'lines: ';
|
||||
}
|
||||
.words:before {
|
||||
content: 'words: ';
|
||||
}
|
||||
.characters:before {
|
||||
content: 'characters: ';
|
||||
}
|
||||
}
|
||||
.editor-preview {
|
||||
padding: 10px;
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
top: 0;
|
||||
left: 0;
|
||||
background: #fafafa;
|
||||
z-index: 7;
|
||||
overflow: auto;
|
||||
display: none;
|
||||
box-sizing: border-box;
|
||||
> p {
|
||||
margin-top: 0;
|
||||
}
|
||||
pre {
|
||||
background: #eee;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
table {
|
||||
td {
|
||||
border: 1px solid #ddd;
|
||||
padding: 5px;
|
||||
}
|
||||
th {
|
||||
border: 1px solid #ddd;
|
||||
padding: 5px;
|
||||
}
|
||||
}
|
||||
}
|
||||
.editor-preview-side {
|
||||
padding: 10px;
|
||||
position: fixed;
|
||||
bottom: 0;
|
||||
width: 50%;
|
||||
top: 50px;
|
||||
right: 0;
|
||||
background: #fafafa;
|
||||
z-index: $fullscreenZIndex;
|
||||
overflow: auto;
|
||||
display: none;
|
||||
box-sizing: border-box;
|
||||
border: 1px solid #ddd;
|
||||
> p {
|
||||
margin-top: 0;
|
||||
}
|
||||
pre {
|
||||
background: #eee;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
table {
|
||||
td {
|
||||
border: 1px solid #ddd;
|
||||
padding: 5px;
|
||||
}
|
||||
th {
|
||||
border: 1px solid #ddd;
|
||||
padding: 5px;
|
||||
}
|
||||
}
|
||||
}
|
||||
.editor-preview-active-side {
|
||||
display: block;
|
||||
}
|
||||
.editor-preview-active {
|
||||
display: block;
|
||||
}
|
||||
.CodeMirror-overwrite .CodeMirror-cursor {
|
||||
}
|
||||
.CodeMirror-wrap pre {
|
||||
word-wrap: break-word;
|
||||
white-space: pre-wrap;
|
||||
word-break: normal;
|
||||
}
|
||||
.cm-tab-wrap-hack:after {
|
||||
content: '';
|
||||
}
|
||||
span.CodeMirror-selectedtext {
|
||||
background: none;
|
||||
}
|
||||
.editor-wrapper input.title {
|
||||
&:focus {
|
||||
opacity: .8;
|
||||
}
|
||||
&:hover {
|
||||
opacity: .8;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
import React, {Component, PropTypes} from 'react';
|
||||
import SimpleMDE from 'simplemde';
|
||||
import cn from 'classnames';
|
||||
import noop from 'lodash/noop';
|
||||
import styles from './MarkdownEditor.css';
|
||||
|
||||
const config = {
|
||||
status: false,
|
||||
|
||||
// Do not download fontAwesome icons as we replace them with
|
||||
// material icons.
|
||||
autoDownloadFontAwesome: false,
|
||||
|
||||
// Disable built-in spell checker as it is very rudimentary.
|
||||
spellChecker: false,
|
||||
|
||||
toolbar: [
|
||||
{
|
||||
name: 'bold',
|
||||
action: SimpleMDE.toggleBold,
|
||||
className: styles.iconBold,
|
||||
title: 'Bold',
|
||||
},
|
||||
{
|
||||
name: 'italic',
|
||||
action: SimpleMDE.toggleItalic,
|
||||
className: styles.iconItalic,
|
||||
title: 'Italic',
|
||||
},
|
||||
{
|
||||
name: 'title',
|
||||
action: SimpleMDE.toggleHeadingSmaller,
|
||||
className: styles.iconTitle,
|
||||
title: 'Title, Subtitle, Heading',
|
||||
},
|
||||
'|',
|
||||
{
|
||||
name: 'quote',
|
||||
action: SimpleMDE.toggleBlockquote,
|
||||
className: styles.iconQuote,
|
||||
title: 'Quote',
|
||||
},
|
||||
{
|
||||
name: 'unordered-list',
|
||||
action: SimpleMDE.toggleUnorderedList,
|
||||
className: styles.iconUnorderedList,
|
||||
title: 'Generic List',
|
||||
},
|
||||
{
|
||||
name: 'ordered-list',
|
||||
action: SimpleMDE.toggleOrderedList,
|
||||
className: styles.iconOrderedList,
|
||||
title: 'Numbered List',
|
||||
},
|
||||
'|',
|
||||
{
|
||||
name: 'link',
|
||||
action: SimpleMDE.drawLink,
|
||||
className: styles.iconLink,
|
||||
title: 'Create Link',
|
||||
},
|
||||
{
|
||||
name: 'image',
|
||||
action: SimpleMDE.drawImage,
|
||||
className: styles.iconImage,
|
||||
title: 'Insert Image',
|
||||
},
|
||||
'|',
|
||||
{
|
||||
name: 'preview',
|
||||
action: SimpleMDE.togglePreview,
|
||||
className: cn(styles.iconPreview, 'no-disable'),
|
||||
title: 'Toggle Preview',
|
||||
},
|
||||
{
|
||||
name: 'side-by-side',
|
||||
action: SimpleMDE.toggleSideBySide,
|
||||
className: cn(styles.iconSideBySide, 'no-disable'),
|
||||
title: 'Toggle Side by Side',
|
||||
},
|
||||
{
|
||||
name: 'fullscreen',
|
||||
action: SimpleMDE.toggleFullScreen,
|
||||
className: cn(styles.iconFullscreen, 'no-disable'),
|
||||
title: 'Toggle Fullscreen',
|
||||
},
|
||||
'|',
|
||||
{
|
||||
name: 'guide',
|
||||
action: 'https://simplemde.com/markdown-guide',
|
||||
className: styles.iconGuide,
|
||||
title: 'Markdown Guide',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
export default class MarkdownEditor extends Component {
|
||||
|
||||
textarea = null
|
||||
editor = null
|
||||
|
||||
onRef = (ref) => this.textarea = ref
|
||||
|
||||
componentDidMount() {
|
||||
this.editor = new SimpleMDE({
|
||||
...config,
|
||||
element: this.textarea,
|
||||
});
|
||||
this.editor.codemirror.on('change', this.onChange);
|
||||
}
|
||||
|
||||
componentWillReceiveProps(nextProps) {
|
||||
if (this.props.value !== nextProps.value && nextProps.value !== this.editor.value()) {
|
||||
this.editor.value(nextProps.value);
|
||||
}
|
||||
}
|
||||
|
||||
componentDidUpdate() {
|
||||
|
||||
// Workaround empty render issue.
|
||||
// https://github.com/NextStepWebs/simplemde-markdown-editor/issues/313
|
||||
this.editor.codemirror.refresh();
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
this.editor.toTextArea();
|
||||
}
|
||||
|
||||
onChange = () => {
|
||||
if (this.props.onChange) {
|
||||
this.props.onChange(this.editor.value());
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
return (
|
||||
<div className={styles.wrapper}>
|
||||
<textarea ref={this.onRef} {...this.props} onChange={noop} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
MarkdownEditor.propTypes = {
|
||||
onChange: PropTypes.func,
|
||||
value: PropTypes.string,
|
||||
};
|
||||
@@ -1,4 +1,3 @@
|
||||
|
||||
import React from 'react';
|
||||
import {Button, Icon} from 'react-mdl';
|
||||
import styles from './Modal.css';
|
||||
|
||||
@@ -18,3 +18,47 @@
|
||||
border-radius: 4px;
|
||||
border: 1px solid #999;
|
||||
}
|
||||
|
||||
.callToAction {
|
||||
position: fixed;
|
||||
left: 10px;
|
||||
bottom: 10px;
|
||||
width: 280px;
|
||||
height: 200px;
|
||||
background: white;
|
||||
padding: 15px;
|
||||
box-sizing: border-box;
|
||||
box-shadow: 0px 3px 5px 0px rgba(0,0,0,0.15);
|
||||
|
||||
.ctaHeader {
|
||||
font-size: 16px;
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
|
||||
ul {
|
||||
padding-left: 0;
|
||||
list-style: none;
|
||||
margin: 0 0 10px 0;
|
||||
}
|
||||
|
||||
p, li {
|
||||
font-size: 12px;
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
|
||||
li span:first-child, p:last-child span:first-child {
|
||||
display: inline-block;
|
||||
width: 120px;
|
||||
}
|
||||
}
|
||||
|
||||
.closeButton {
|
||||
float: right;
|
||||
font-size: 20px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.smallKey {
|
||||
padding: 5px;
|
||||
border: 1px solid #ccc;
|
||||
}
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import I18n from 'coral-framework/modules/i18n/i18n';
|
||||
import translations from '../translations.json';
|
||||
import React from 'react';
|
||||
import React, {PropTypes} from 'react';
|
||||
import Modal from 'components/Modal';
|
||||
import styles from './ModerationKeysModal.css';
|
||||
|
||||
const lang = new I18n(translations);
|
||||
|
||||
const shortcuts = [
|
||||
{
|
||||
title: 'modqueue.navigation',
|
||||
@@ -23,29 +25,50 @@ const shortcuts = [
|
||||
}
|
||||
];
|
||||
|
||||
export default ({open, onClose}) => (
|
||||
<Modal open={open} onClose={onClose}>
|
||||
<h3>{lang.t('modqueue.shortcuts')}</h3>
|
||||
<div className={styles.container}>
|
||||
{shortcuts.map((shortcut, i) => (
|
||||
<table className={styles.table} key={i}>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{lang.t(shortcut.title)}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{Object.keys(shortcut.shortcuts).map(key => (
|
||||
<tr key={`${key }tr`}>
|
||||
<td className={styles.shortcut}><span className={styles.key}>{key}</span></td>
|
||||
<td>{lang.t(shortcut.shortcuts[key])}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
))}
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
export default class ModerationKeysModal extends React.Component {
|
||||
|
||||
const lang = new I18n(translations);
|
||||
static propTypes = {
|
||||
hideShortcutsNote: PropTypes.func.isRequired,
|
||||
shortcutsNoteVisible: PropTypes.string.isRequired
|
||||
}
|
||||
|
||||
render () {
|
||||
const {open, onClose, hideShortcutsNote, shortcutsNoteVisible} = this.props;
|
||||
return (
|
||||
<div>
|
||||
<div className={styles.callToAction} style={{display: shortcutsNoteVisible === 'show' ? 'block' : 'none'}}>
|
||||
<div onClick={hideShortcutsNote} className={styles.closeButton}>×</div>
|
||||
<p className={styles.ctaHeader}>{lang.t('modqueue.mod-faster')}</p>
|
||||
<p><strong>{lang.t('modqueue.try-these')}:</strong></p>
|
||||
<ul>
|
||||
<li><span>{lang.t('modqueue.approve')}</span> <span className={styles.smallKey}>t</span></li>
|
||||
<li><span>{lang.t('modqueue.reject')}</span> <span className={styles.smallKey}>r</span></li>
|
||||
</ul>
|
||||
<p><span>{lang.t('modqueue.view-more-shortcuts')}</span> <span className={styles.smallKey}>{lang.t('modqueue.shift-key')}</span> + <span className={styles.smallKey}>/</span></p>
|
||||
</div>
|
||||
<Modal open={open} onClose={onClose}>
|
||||
<h3>{lang.t('modqueue.shortcuts')}</h3>
|
||||
<div className={styles.container}>
|
||||
{shortcuts.map((shortcut, i) => (
|
||||
<table className={styles.table} key={i}>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{lang.t(shortcut.title)}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{Object.keys(shortcut.shortcuts).map(key => (
|
||||
<tr key={`${key }tr`}>
|
||||
<td className={styles.shortcut}><span className={styles.key}>{key}</span></td>
|
||||
<td>{lang.t(shortcut.shortcuts[key])}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
))}
|
||||
</div>
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -188,3 +188,15 @@
|
||||
margin: 0;
|
||||
width: 140px;
|
||||
}
|
||||
|
||||
.approve__active {
|
||||
box-shadow: none;
|
||||
color: white;
|
||||
background-color: #519954;
|
||||
}
|
||||
|
||||
.reject__active, .rejected__active {
|
||||
color: white;
|
||||
background-color: #D03235;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
@@ -1,106 +0,0 @@
|
||||
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);
|
||||
@@ -1,50 +0,0 @@
|
||||
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,6 +1,7 @@
|
||||
export const SHOW_BANUSER_DIALOG = 'SHOW_BANUSER_DIALOG';
|
||||
export const HIDE_BANUSER_DIALOG = 'HIDE_BANUSER_DIALOG';
|
||||
export const USERS_MODERATION_QUEUE_FETCH_SUCCESS = 'USERS_MODERATION_QUEUE_FETCH_SUCCESS';
|
||||
export const SHOW_SUSPENDUSER_DIALOG = 'SHOW_SUSPENDUSER_DIALOG';
|
||||
export const HIDE_SUSPENDUSER_DIALOG = 'HIDE_SUSPENDUSER_DIALOG';
|
||||
export const COMMENTS_MODERATION_QUEUE_FETCH_REQUEST = 'COMMENTS_MODERATION_QUEUE_FETCH_REQUEST';
|
||||
export const COMMENTS_MODERATION_QUEUE_FETCH_SUCCESS = 'COMMENTS_MODERATION_QUEUE_FETCH_SUCCESS';
|
||||
export const COMMENT_CREATE_SUCCESS = 'COMMENT_CREATE_SUCCESS';
|
||||
|
||||
@@ -5,3 +5,13 @@ export const SORT_UPDATE = 'SORT_UPDATE';
|
||||
export const COMMENTERS_NEW_PAGE = 'COMMENTERS_NEW_PAGE';
|
||||
export const SET_ROLE = 'SET_ROLE';
|
||||
export const SET_COMMENTER_STATUS = 'SET_COMMENTER_STATUS';
|
||||
|
||||
export const FETCH_FLAGGED_COMMENTERS_REQUEST = 'FETCH_FLAGGED_COMMENTERS_REQUEST';
|
||||
export const FETCH_FLAGGED_COMMENTERS_SUCCESS = 'FETCH_FLAGGED_COMMENTERS_SUCCESS';
|
||||
export const FETCH_FLAGGED_COMMENTERS_FAILURE = 'FETCH_FLAGGED_COMMENTERS_FAILURE';
|
||||
|
||||
export const SHOW_BANUSER_DIALOG = 'SHOW_BANUSER_DIALOG';
|
||||
export const HIDE_BANUSER_DIALOG = 'HIDE_BANUSER_DIALOG';
|
||||
|
||||
export const SHOW_SUSPENDUSER_DIALOG = 'SHOW_SUSPENDUSER_DIALOG';
|
||||
export const HIDE_SUSPENDUSER_DIALOG = 'HIDE_SUSPENDUSER_DIALOG';
|
||||
|
||||
@@ -2,3 +2,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';
|
||||
export const HIDE_SHORTCUTS_NOTE = 'HIDE_SHORTCUTS_NOTE';
|
||||
|
||||
@@ -3,4 +3,3 @@ 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,3 +1,5 @@
|
||||
@custom-media --big-viewport (min-width: 780px);
|
||||
|
||||
.container {
|
||||
padding: 10px;
|
||||
display: flex;
|
||||
@@ -15,6 +17,12 @@
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.mainFlaggedContent {
|
||||
width: 100%;
|
||||
padding: 34px 14px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.roleButton {
|
||||
display: block;
|
||||
}
|
||||
@@ -94,3 +102,232 @@
|
||||
cursor: pointer;
|
||||
}
|
||||
}
|
||||
|
||||
.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: 18px;
|
||||
width: 100%;
|
||||
max-width: 660px;
|
||||
min-width: 400px;
|
||||
margin: 0 auto;
|
||||
position: relative;
|
||||
transition: all 200ms;
|
||||
padding: 10px 0 0;
|
||||
min-height: 220px;
|
||||
|
||||
.container {
|
||||
display: block;
|
||||
padding: 0 14px;
|
||||
min-height: 180px;
|
||||
}
|
||||
|
||||
&: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 {
|
||||
font-size: 16px;
|
||||
font-weight: bold;
|
||||
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;
|
||||
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;
|
||||
}
|
||||
|
||||
.banButton {
|
||||
width: 114px;
|
||||
letter-spacing: 1px;
|
||||
|
||||
i {
|
||||
vertical-align: middle;
|
||||
margin-right: 10px;
|
||||
font-size: 14px;
|
||||
}
|
||||
}
|
||||
|
||||
.actionButton {
|
||||
transform: scale(.8);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.flaggedByCount {
|
||||
display: block;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.flaggedBy {
|
||||
display: inline;
|
||||
padding: 3px;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.flaggedByLabel {
|
||||
font-weight: bold;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.flaggedReasons {
|
||||
padding-top: 15px;
|
||||
margin-left: 24px;
|
||||
}
|
||||
|
||||
.flaggedByReason {
|
||||
font-size: 1tpx;
|
||||
}
|
||||
|
||||
@@ -1,27 +1,47 @@
|
||||
import React, {Component} from 'react';
|
||||
import {connect} from 'react-redux';
|
||||
import {compose} from 'react-apollo';
|
||||
|
||||
import {modUserFlaggedQuery} from 'coral-admin/src/graphql/queries';
|
||||
import {banUser, setUserStatus, suspendUser} from 'coral-admin/src/graphql/mutations';
|
||||
|
||||
import {
|
||||
fetchCommenters,
|
||||
fetchAccounts,
|
||||
updateSorting,
|
||||
newPage,
|
||||
showBanUserDialog,
|
||||
hideBanUserDialog,
|
||||
showSuspendUserDialog,
|
||||
hideSuspendUserDialog
|
||||
} from '../../actions/community';
|
||||
|
||||
import Community from './Community';
|
||||
import CommunityMenu from './components/CommunityMenu';
|
||||
import BanUserDialog from './components/BanUserDialog';
|
||||
import SuspendUserDialog from './components/SuspendUserDialog';
|
||||
|
||||
import People from './People';
|
||||
import FlaggedAccounts from './FlaggedAccounts';
|
||||
|
||||
class CommunityContainer extends Component {
|
||||
|
||||
constructor(props) {
|
||||
super(props);
|
||||
|
||||
this.state = {
|
||||
searchValue: ''
|
||||
searchValue: '',
|
||||
timer: null
|
||||
};
|
||||
|
||||
this.onKeyDownHandler = this.onKeyDownHandler.bind(this);
|
||||
this.onChangeHandler = this.onChangeHandler.bind(this);
|
||||
this.onSearchChange = this.onSearchChange.bind(this);
|
||||
this.onHeaderClickHandler = this.onHeaderClickHandler.bind(this);
|
||||
this.onNewPageHandler = this.onNewPageHandler.bind(this);
|
||||
}
|
||||
|
||||
componentWillMount() {
|
||||
this.props.fetchAccounts({});
|
||||
}
|
||||
|
||||
onKeyDownHandler(e) {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
@@ -29,25 +49,29 @@ class CommunityContainer extends Component {
|
||||
}
|
||||
}
|
||||
|
||||
onChangeHandler(e) {
|
||||
this.setState({
|
||||
searchValue: e.target.value
|
||||
onSearchChange(e) {
|
||||
const value = e.target.value;
|
||||
this.setState((prevState) => {
|
||||
prevState.searchValue = value;
|
||||
clearTimeout(prevState.timer);
|
||||
const fetchAccounts = this.props.fetchAccounts;
|
||||
prevState.timer = setTimeout(() => {
|
||||
fetchAccounts({value});
|
||||
}, 350);
|
||||
return prevState;
|
||||
});
|
||||
}
|
||||
|
||||
search(query = {}) {
|
||||
const {community} = this.props;
|
||||
|
||||
this.props.dispatch(fetchCommenters({
|
||||
this.props.fetchAccounts({
|
||||
value: this.state.searchValue,
|
||||
field: community.field,
|
||||
asc: community.asc,
|
||||
field: community.fieldPeople,
|
||||
asc: community.ascPeople,
|
||||
...query
|
||||
}));
|
||||
}
|
||||
});
|
||||
|
||||
componentDidMount() {
|
||||
this.search();
|
||||
}
|
||||
|
||||
onHeaderClickHandler(sort) {
|
||||
@@ -60,19 +84,66 @@ class CommunityContainer extends Component {
|
||||
this.search({page});
|
||||
}
|
||||
|
||||
getTabContent(searchValue, props) {
|
||||
const {community, data} = props;
|
||||
const activeTab = props.route.path === ':id' ? 'flagged' : props.route.path;
|
||||
|
||||
if (activeTab === 'people') {
|
||||
return (
|
||||
<People
|
||||
isFetching={community.isFetchingPeople}
|
||||
commenters={community.accounts}
|
||||
searchValue={searchValue}
|
||||
onSearchChange={this.onSearchChange}
|
||||
error={community.errorPeople}
|
||||
totalPages={community.totalPagesPeople}
|
||||
page={community.pagePeople}
|
||||
onKeyDown={this.onKeyDownHandler}
|
||||
onHeaderClickHandler={this.onHeaderClickHandler}
|
||||
onNewPageHandler={this.onNewPageHandler}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<FlaggedAccounts
|
||||
commenters={data.users}
|
||||
isFetching={data.loading}
|
||||
error={data.error}
|
||||
showBanUserDialog={props.showBanUserDialog}
|
||||
approveUser={props.approveUser}
|
||||
suspendUser={props.suspendUser}
|
||||
showSuspendUserDialog={props.showSuspendUserDialog}
|
||||
/>
|
||||
<BanUserDialog
|
||||
open={community.banDialog}
|
||||
user={community.user}
|
||||
handleClose={props.hideBanUserDialog}
|
||||
handleBanUser={props.banUser}
|
||||
/>
|
||||
<SuspendUserDialog
|
||||
open={community.suspendDialog}
|
||||
handleClose={props.hideSuspendUserDialog}
|
||||
user={community.user}
|
||||
suspendUser={props.suspendUser}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
render() {
|
||||
const {searchValue} = this.state;
|
||||
const {community} = this.props;
|
||||
|
||||
const tab = this.getTabContent(searchValue, this.props);
|
||||
|
||||
return (
|
||||
<Community
|
||||
searchValue={searchValue}
|
||||
commenters={community.commenters}
|
||||
isFetching={community.isFetching}
|
||||
error={community.error}
|
||||
totalPages={community.totalPages}
|
||||
page={community.page}
|
||||
{...this}
|
||||
/>
|
||||
<div>
|
||||
<CommunityMenu />
|
||||
<div>
|
||||
{ tab }
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -81,4 +152,18 @@ const mapStateToProps = state => ({
|
||||
community: state.community.toJS()
|
||||
});
|
||||
|
||||
export default connect(mapStateToProps)(CommunityContainer);
|
||||
const mapDispatchToProps = dispatch => ({
|
||||
fetchAccounts: query => dispatch(fetchAccounts(query)),
|
||||
showBanUserDialog: (user) => dispatch(showBanUserDialog(user)),
|
||||
hideBanUserDialog: () => dispatch(hideBanUserDialog(false)),
|
||||
showSuspendUserDialog: (user) => dispatch(showSuspendUserDialog(user)),
|
||||
hideSuspendUserDialog: () => dispatch(hideSuspendUserDialog())
|
||||
});
|
||||
|
||||
export default compose(
|
||||
connect(mapStateToProps, mapDispatchToProps),
|
||||
modUserFlaggedQuery,
|
||||
banUser,
|
||||
setUserStatus,
|
||||
suspendUser
|
||||
)(CommunityContainer);
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
import React from 'react';
|
||||
|
||||
const CommunityLayout = props => (
|
||||
<div>
|
||||
{props.children}
|
||||
</div>
|
||||
);
|
||||
|
||||
export default CommunityLayout;
|
||||
@@ -0,0 +1,44 @@
|
||||
import React from 'react';
|
||||
|
||||
import I18n from 'coral-framework/modules/i18n/i18n';
|
||||
import translations from 'coral-admin/src/translations.json';
|
||||
const lang = new I18n(translations);
|
||||
|
||||
import styles from './Community.css';
|
||||
|
||||
import Loading from './Loading';
|
||||
import EmptyCard from 'coral-admin/src/components/EmptyCard';
|
||||
import User from './components/User';
|
||||
|
||||
const FlaggedAccounts = ({...props}) => {
|
||||
const {commenters, isFetching} = props;
|
||||
const hasResults = !isFetching && commenters && !!commenters.length;
|
||||
|
||||
// if (commenter.status === 'PENDING' && commenter.actions.length > 0) {
|
||||
|
||||
return (
|
||||
<div className={styles.container}>
|
||||
<div className={styles.mainFlaggedContent}>
|
||||
{ isFetching && <Loading /> }
|
||||
{
|
||||
hasResults
|
||||
? commenters.map((commenter, index) => {
|
||||
return <User
|
||||
user={commenter}
|
||||
key={index}
|
||||
index={index}
|
||||
modActionButtons={['APPROVE', 'REJECT']}
|
||||
showBanUserDialog={props.showBanUserDialog}
|
||||
showSuspendUserDialog={props.showSuspendUserDialog}
|
||||
approveUser={props.approveUser}
|
||||
suspendUser={props.suspendUser}
|
||||
/>;
|
||||
})
|
||||
: <EmptyCard>{lang.t('community.no-flagged-accounts')}</EmptyCard>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default FlaggedAccounts;
|
||||
+15
-21
@@ -5,7 +5,7 @@ import translations from 'coral-admin/src/translations.json';
|
||||
import styles from './Community.css';
|
||||
import Table from './Table';
|
||||
import Loading from './Loading';
|
||||
import {Pager} from 'coral-ui';
|
||||
import {Pager, Icon} from 'coral-ui';
|
||||
import EmptyCard from '../../components/EmptyCard';
|
||||
|
||||
const lang = new I18n(translations);
|
||||
@@ -29,28 +29,22 @@ const tableHeaders = [
|
||||
}
|
||||
];
|
||||
|
||||
const Community = ({isFetching, commenters, ...props}) => {
|
||||
const People = ({isFetching, commenters, searchValue, onSearchChange, ...props}) => {
|
||||
const hasResults = !isFetching && !!commenters.length;
|
||||
return (
|
||||
<div className={styles.container}>
|
||||
<div className={styles.leftColumn}>
|
||||
<form action="">
|
||||
<div className={`${styles.searchBox}`}>
|
||||
<label htmlFor="commenters-search">
|
||||
<i className="material-icons">search</i>
|
||||
</label>
|
||||
<div className="">
|
||||
<input
|
||||
id="commenters-search"
|
||||
className={`${styles.searchInput}`}
|
||||
type="text"
|
||||
value={props.searchValue}
|
||||
onKeyDown={props.onKeyDownHandler}
|
||||
onChange={props.onChangeHandler}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
<div className={styles.searchBox}>
|
||||
<Icon name='search' className={styles.searchIcon}/>
|
||||
<input
|
||||
id="commenters-search"
|
||||
type="text"
|
||||
className={styles.searchBoxInput}
|
||||
value={searchValue}
|
||||
onChange={onSearchChange}
|
||||
placeholder={lang.t('streams.search')}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles.mainContent}>
|
||||
{ isFetching && <Loading /> }
|
||||
@@ -58,7 +52,7 @@ const Community = ({isFetching, commenters, ...props}) => {
|
||||
hasResults
|
||||
? <Table
|
||||
headers={tableHeaders}
|
||||
data={commenters}
|
||||
commenters={commenters}
|
||||
onHeaderClickHandler={props.onHeaderClickHandler}
|
||||
/>
|
||||
: <EmptyCard>{lang.t('community.no-results')}</EmptyCard>
|
||||
@@ -73,4 +67,4 @@ const Community = ({isFetching, commenters, ...props}) => {
|
||||
);
|
||||
};
|
||||
|
||||
export default Community;
|
||||
export default People;
|
||||
@@ -77,4 +77,4 @@ class Table extends Component {
|
||||
}
|
||||
}
|
||||
|
||||
export default connect(state => ({commenters: state.community.get('commenters')}))(Table);
|
||||
export default connect(state => ({commenters: state.community.get('accounts')}))(Table);
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
import React from 'react';
|
||||
import styles from '../Community.css';
|
||||
import BanUserButton from '../../../components/BanUserButton';
|
||||
import {Button} from 'coral-ui';
|
||||
import {menuActionsMap} from '../../../containers/ModerationQueue/helpers/moderationQueueActionsMap';
|
||||
|
||||
const ActionButton = ({type = '', user, ...props}) => {
|
||||
if (type === 'BAN') {
|
||||
return <BanUserButton icon='not interested' user={user} onClick={() => props.showBanUserDialog(user)} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<Button
|
||||
className={`${type.toLowerCase()} ${styles.actionButton}`}
|
||||
cStyle={type.toLowerCase()}
|
||||
icon={menuActionsMap[type].icon}
|
||||
onClick={() => {
|
||||
type === 'APPROVE' ? props.approveUser({userId: user.id}) : props.showSuspendUserDialog({user: user});
|
||||
}}
|
||||
>{menuActionsMap[type].text}</Button>
|
||||
);
|
||||
};
|
||||
|
||||
export default ActionButton;
|
||||
@@ -0,0 +1,164 @@
|
||||
.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: 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;
|
||||
}
|
||||
}
|
||||
|
||||
.textField {
|
||||
margin-top: 15px;
|
||||
}
|
||||
|
||||
.textField label {
|
||||
font-size: 1.08em;
|
||||
font-weight: bold;
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
|
||||
.textField input {
|
||||
width: 100%;
|
||||
display: block;
|
||||
border: none;
|
||||
outline: none;
|
||||
border: 1px solid rgba(0,0,0,.12);
|
||||
padding: 10px 6px;
|
||||
box-sizing: border-box;
|
||||
border-radius: 2px;
|
||||
margin: 5px auto;
|
||||
}
|
||||
|
||||
.footer {
|
||||
margin: 20px auto 10px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.footer span {
|
||||
display: block;
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
|
||||
.footer a {
|
||||
color: #2c69b6;
|
||||
cursor: pointer;
|
||||
margin: 0 5px;
|
||||
}
|
||||
|
||||
.socialConnections {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.signInButton {
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.close {
|
||||
font-size: 20px;
|
||||
line-height: 14px;
|
||||
top: 10px;
|
||||
right: 10px;
|
||||
position: absolute;
|
||||
display: block;
|
||||
font-weight: bold;
|
||||
color: #363636;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.close:hover {
|
||||
color: #6b6b6b;
|
||||
}
|
||||
|
||||
input.error{
|
||||
border: solid 2px #f44336;
|
||||
}
|
||||
|
||||
.errorMsg, .hint {
|
||||
color: grey;
|
||||
font-weight: 600;
|
||||
padding: 3px 0 16px;
|
||||
}
|
||||
|
||||
.alert {
|
||||
padding: 10px;
|
||||
margin-bottom: 20px;
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
.alert--success {
|
||||
border: solid 1px #1ec00e;
|
||||
background: #cbf1b8;
|
||||
color: #006900;
|
||||
}
|
||||
|
||||
.alert--error {
|
||||
background: #FFEBEE;
|
||||
color: #B71C1C;
|
||||
}
|
||||
|
||||
.userBox a {
|
||||
color: #2c69b6;
|
||||
cursor: pointer;
|
||||
margin: 0px;
|
||||
}
|
||||
|
||||
.attention {
|
||||
display: inline-block;
|
||||
width: 15px;
|
||||
height: 15px;
|
||||
background: #B71C1C;
|
||||
color: #FFEBEE;
|
||||
font-weight: bolder;
|
||||
padding: 4px;
|
||||
vertical-align: middle;
|
||||
border-radius: 20px;
|
||||
box-sizing: border-box;
|
||||
font-size: 9px;
|
||||
line-height: 7px;
|
||||
text-align: center;
|
||||
margin-right: 5px;
|
||||
}
|
||||
|
||||
.action {
|
||||
margin-top: 15px;
|
||||
}
|
||||
|
||||
.passwordRequestSuccess {
|
||||
border: 1px solid green;
|
||||
background-color: lightgreen;
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.passwordRequestFailure {
|
||||
border: 1px solid orange;
|
||||
background-color: 1px solid coral;
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.cancel {
|
||||
margin-right: 10px;
|
||||
width: 47%;
|
||||
}
|
||||
|
||||
.ban {
|
||||
width: 47%;
|
||||
}
|
||||
|
||||
.buttons {
|
||||
margin: 20px 0;
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import React, {PropTypes} from 'react';
|
||||
import {Dialog} from 'coral-ui';
|
||||
import styles from './BanUserDialog.css';
|
||||
|
||||
import Button from 'coral-ui/components/Button';
|
||||
|
||||
import I18n from 'coral-framework/modules/i18n/i18n';
|
||||
import translations from 'coral-admin/src/translations.json';
|
||||
|
||||
const lang = new I18n(translations);
|
||||
|
||||
const BanUserDialog = ({open, handleClose, handleBanUser, user}) => (
|
||||
<Dialog
|
||||
className={styles.dialog}
|
||||
id="banuserDialog"
|
||||
open={open}
|
||||
onClose={handleClose}
|
||||
onCancel={handleClose}
|
||||
title={lang.t('community.ban_user')}>
|
||||
<span className={styles.close} onClick={handleClose}>×</span>
|
||||
<div>
|
||||
<div className={styles.header}>
|
||||
<h2>{lang.t('community.ban_user')}</h2>
|
||||
</div>
|
||||
<div className={styles.separator}>
|
||||
<h3>{lang.t('community.are_you_sure', user.username)}</h3>
|
||||
<i>{lang.t('community.note')}</i>
|
||||
</div>
|
||||
<div className={styles.buttons}>
|
||||
<Button cStyle="cancel" className={styles.cancel} onClick={handleClose} raised>
|
||||
{lang.t('community.cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
cStyle="black" className={styles.ban}
|
||||
onClick={() => {
|
||||
handleBanUser({userId: user.id}).then(() => {
|
||||
handleClose();
|
||||
});
|
||||
}}
|
||||
raised>
|
||||
{lang.t('community.yes_ban_user')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Dialog>
|
||||
);
|
||||
|
||||
BanUserDialog.propTypes = {
|
||||
handleBanUser: PropTypes.func.isRequired,
|
||||
handleClose: PropTypes.func.isRequired,
|
||||
user: PropTypes.object.isRequired,
|
||||
};
|
||||
|
||||
export default BanUserDialog;
|
||||
@@ -0,0 +1,31 @@
|
||||
import React from 'react';
|
||||
|
||||
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 CommunityMenu = () => {
|
||||
const flaggedPath = '/admin/community/flagged';
|
||||
const peoplePath = '/admin/community/people';
|
||||
return (
|
||||
<div className='mdl-tabs'>
|
||||
<div className={`mdl-tabs__tab-bar ${styles.tabBar}`}>
|
||||
<div>
|
||||
<Link to={flaggedPath} className={`mdl-tabs__tab ${styles.tab}`} activeClassName={styles.active}>
|
||||
{lang.t('community.flaggedaccounts')}
|
||||
</Link>
|
||||
<Link to={peoplePath} className={`mdl-tabs__tab ${styles.tab}`} activeClassName={styles.active}>
|
||||
{lang.t('community.people')}
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default CommunityMenu;
|
||||
@@ -0,0 +1,115 @@
|
||||
import React, {Component, PropTypes} from 'react';
|
||||
|
||||
import {Dialog, Button} from 'coral-ui';
|
||||
import styles from './SuspendUserDialog.css';
|
||||
|
||||
import I18n from 'coral-framework/modules/i18n/i18n';
|
||||
import translations from 'coral-admin/src/translations.json';
|
||||
|
||||
const lang = new I18n(translations);
|
||||
|
||||
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 SuspendUserDialog extends Component {
|
||||
|
||||
state = {email: '', stage: 0}
|
||||
|
||||
static propTypes = {
|
||||
stage: PropTypes.number,
|
||||
handleClose: PropTypes.func.isRequired,
|
||||
suspendUser: PropTypes.func.isRequired
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
this.setState({email: lang.t('suspenduser.email'), about: lang.t('suspenduser.username')});
|
||||
}
|
||||
|
||||
/*
|
||||
* 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, user} = this.props;
|
||||
const {stage} = this.state;
|
||||
|
||||
const cancel = this.props.handleClose;
|
||||
const next = () => this.setState({stage: stage + 1});
|
||||
const suspend = () => {
|
||||
suspendUser({userId: user.user.id, message: this.state.email})
|
||||
.then(() => {
|
||||
this.props.handleClose();
|
||||
});
|
||||
};
|
||||
|
||||
const suspendModalActions = [
|
||||
[ cancel, next ],
|
||||
[ cancel, suspend ]
|
||||
];
|
||||
return suspendModalActions[stage][menuOption]();
|
||||
}
|
||||
|
||||
onEmailChange = (e) => {
|
||||
this.setState({email: e.target.value});
|
||||
}
|
||||
|
||||
render () {
|
||||
const {open, handleClose} = this.props;
|
||||
const {stage} = this.state;
|
||||
|
||||
return <Dialog
|
||||
className={styles.suspendDialog}
|
||||
id="suspendUserDialog"
|
||||
open={open}
|
||||
onClose={handleClose}
|
||||
onCancel={handleClose}
|
||||
title={lang.t('suspenduser.title')}>
|
||||
<div className={styles.title}>
|
||||
{lang.t(stages[stage].title, lang.t('suspenduser.username'))}
|
||||
</div>
|
||||
<div className={styles.container}>
|
||||
<div className={styles.description}>
|
||||
{lang.t(stages[stage].description, lang.t('suspenduser.username'))}
|
||||
</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], lang.t('suspenduser.username'))}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</Dialog>;
|
||||
}
|
||||
}
|
||||
|
||||
export default SuspendUserDialog;
|
||||
@@ -0,0 +1,90 @@
|
||||
import React from 'react';
|
||||
import styles from '../Community.css';
|
||||
|
||||
import ActionButton from './ActionButton';
|
||||
|
||||
import I18n from 'coral-framework/modules/i18n/i18n';
|
||||
import translations from '../../../translations.json';
|
||||
|
||||
const lang = new I18n(translations);
|
||||
|
||||
// Render a single user for the list
|
||||
const User = props => {
|
||||
const {user, modActionButtons} = 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 ${props.selected ? 'mdl-shadow--8dp' : 'mdl-shadow--2dp'} ${styles.listItem} ${props.isActive && !props.hideActive ? styles.activeItem : ''}`}>
|
||||
<div className={styles.container}>
|
||||
<div className={styles.itemHeader}>
|
||||
<div className={styles.author}>
|
||||
<span>
|
||||
{user.username}
|
||||
</span>
|
||||
<ActionButton
|
||||
className={styles.banButton}
|
||||
type='BAN'
|
||||
user={user}
|
||||
showBanUserDialog={props.showBanUserDialog}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={styles.itemBody}>
|
||||
<div className={styles.body}>
|
||||
<div className={styles.flaggedByCount}>
|
||||
<i className="material-icons">flag</i><span className={styles.flaggedByLabel}>{lang.t('community.flags')}({ user.actions.length })</span>:
|
||||
{ user.action_summaries.map(
|
||||
(action, i ) => {
|
||||
return <span className={styles.flaggedBy} key={i}>
|
||||
{lang.t(`community.${action.reason}`)} ({action.count})
|
||||
</span>;
|
||||
}
|
||||
)}
|
||||
</div>
|
||||
<div className={styles.flaggedReasons}>
|
||||
{ user.action_summaries.map(
|
||||
(action_sum, i ) => {
|
||||
return <div key={i}>
|
||||
<span className={styles.flaggedByLabel}>
|
||||
{lang.t(`community.${action_sum.reason}`)} ({action_sum.count})
|
||||
</span>
|
||||
{user.actions.map(
|
||||
|
||||
// find the action by action_sum.reason
|
||||
(action, j) => {
|
||||
if (action.reason === action_sum.reason) {
|
||||
return <p className={styles.flaggedByReason} key={j}>
|
||||
{action.user && action.user.username}: {action.message ? action.message : 'n/a'}
|
||||
</p>;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
)}
|
||||
</div>;
|
||||
}
|
||||
)}
|
||||
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles.sideActions}>
|
||||
<div className={`actions ${styles.actions}`}>
|
||||
{modActionButtons.map((action, i) =>
|
||||
<ActionButton key={i}
|
||||
type={action.toUpperCase()}
|
||||
user={user}
|
||||
approveUser={props.approveUser}
|
||||
suspendUser={props.suspendUser}
|
||||
showSuspendUserDialog={props.showSuspendUserDialog}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</li>;
|
||||
};
|
||||
|
||||
export default User;
|
||||
@@ -0,0 +1,339 @@
|
||||
@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%;
|
||||
display: flex;
|
||||
justify-content: space-around;
|
||||
width: 50%;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.actionButton {
|
||||
transform: scale(1.4);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.listItem {
|
||||
border-bottom: 1px solid #e0e0e0;
|
||||
font-size: 18px;
|
||||
width: 100%;
|
||||
max-width: 660px;
|
||||
min-width: 400px;
|
||||
margin: 0 auto;
|
||||
position: relative;
|
||||
transition: all 200ms;
|
||||
padding: 10px 0 0;
|
||||
min-height: 220px;
|
||||
|
||||
.container {
|
||||
padding: 0 14px;
|
||||
min-height: 180px;
|
||||
}
|
||||
|
||||
&: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;
|
||||
color: black;
|
||||
max-width: 500px;
|
||||
word-wrap: break-word;
|
||||
font-weight: 300;
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
}
|
||||
@@ -73,7 +73,6 @@
|
||||
.configSettingInfoBox {
|
||||
min-height: 100px;
|
||||
margin-bottom: 20px;
|
||||
cursor: pointer;
|
||||
width: auto;
|
||||
height: auto;
|
||||
text-align: left;
|
||||
@@ -180,7 +179,9 @@
|
||||
|
||||
.content {
|
||||
display: inline-block;
|
||||
padding-left: 30px;
|
||||
padding: 0px 30px;
|
||||
box-sizing: border-box;
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -192,12 +193,11 @@
|
||||
}
|
||||
|
||||
.wrapper {
|
||||
width: 550px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.descriptionBox {
|
||||
margin-top: 15px;
|
||||
max-width: 550px;
|
||||
|
||||
input {
|
||||
height: 150px;
|
||||
|
||||
@@ -5,6 +5,7 @@ import translations from '../../translations.json';
|
||||
import styles from './Configure.css';
|
||||
import {Checkbox, Textfield} from 'react-mdl';
|
||||
import {Card, Icon, TextArea} from 'coral-ui';
|
||||
import MarkdownEditor from 'coral-admin/src/components/MarkdownEditor';
|
||||
|
||||
const TIMESTAMPS = {
|
||||
weeks: 60 * 60 * 24 * 7,
|
||||
@@ -32,11 +33,15 @@ const updateInfoBoxEnable = (updateSettings, infoBox) => () => {
|
||||
updateSettings({infoBoxEnable});
|
||||
};
|
||||
|
||||
const updateInfoBoxContent = (updateSettings) => (event) => {
|
||||
const infoBoxContent = event.target.value;
|
||||
const updateInfoBoxContent = (updateSettings) => (value) => {
|
||||
const infoBoxContent = value;
|
||||
updateSettings({infoBoxContent});
|
||||
};
|
||||
|
||||
const updateAutoClose = (updateSettings, autoCloseStream) => () => {
|
||||
updateSettings({autoCloseStream});
|
||||
};
|
||||
|
||||
const updateClosedMessage = (updateSettings) => (event) => {
|
||||
const closedMessage = event.target.value;
|
||||
updateSettings({closedMessage});
|
||||
@@ -108,19 +113,17 @@ const StreamSettings = ({updateSettings, settingsError, settings, errors}) => {
|
||||
{lang.t('configure.include-comment-stream-desc')}
|
||||
</p>
|
||||
<div className={`${styles.configSettingInfoBox} ${settings.infoBoxEnable ? null : styles.hidden}`} >
|
||||
<div>
|
||||
<TextArea
|
||||
className={styles.descriptionBox}
|
||||
onChange={updateInfoBoxContent(updateSettings)}
|
||||
value={settings.infoBoxContent}
|
||||
/>
|
||||
</div>
|
||||
<MarkdownEditor
|
||||
className={styles.descriptionBox}
|
||||
onChange={updateInfoBoxContent(updateSettings)}
|
||||
value={settings.infoBoxContent}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
<Card className={`${styles.configSetting} ${styles.configSettingInfoBox}`}>
|
||||
<div className={styles.settingsHeader}>{lang.t('configure.closed-stream-settings')}</div>
|
||||
<div className={styles.wrapper}>
|
||||
<div className={styles.settingsHeader}>{lang.t('configure.closed-stream-settings')}</div>
|
||||
<p>{lang.t('configure.closed-comments-desc')}</p>
|
||||
<div>
|
||||
<TextArea className={styles.descriptionBox}
|
||||
@@ -131,6 +134,11 @@ const StreamSettings = ({updateSettings, settingsError, settings, errors}) => {
|
||||
</div>
|
||||
</Card>
|
||||
<Card className={`${styles.configSetting} ${styles.configSettingInfoBox}`}>
|
||||
<div className={styles.action}>
|
||||
<Checkbox
|
||||
onChange={updateAutoClose(updateSettings, !settings.autoCloseStream)}
|
||||
checked={settings.autoCloseStream} />
|
||||
</div>
|
||||
<div className={styles.content}>
|
||||
{lang.t('configure.close-after')}
|
||||
<br />
|
||||
|
||||
@@ -10,7 +10,7 @@ const FlagWidget = ({assets}) => {
|
||||
|
||||
return (
|
||||
<div className={styles.widget}>
|
||||
<h2 className={styles.heading}>Articles with the most flags</h2>
|
||||
<h2 className={styles.heading}>{lang.t('dashboard.most_flags')}</h2>
|
||||
<div className={styles.widgetHead}>
|
||||
<p>{lang.t('streams.article')}</p>
|
||||
<p>{lang.t('dashboard.flags')}</p>
|
||||
|
||||
@@ -10,7 +10,7 @@ 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 {toggleModal, singleView, showBanUserDialog, hideBanUserDialog, hideShortcutsNote} from 'actions/moderation';
|
||||
|
||||
import {Spinner} from 'coral-ui';
|
||||
import BanUserDialog from '../../components/BanUserDialog';
|
||||
@@ -116,7 +116,7 @@ class ModerationContainer extends Component {
|
||||
|
||||
let asset;
|
||||
|
||||
if (data.loading) {
|
||||
if (!('premodCount' in data)) {
|
||||
return <div><Spinner/></div>;
|
||||
}
|
||||
|
||||
@@ -135,6 +135,9 @@ class ModerationContainer extends Component {
|
||||
const comments = data[activeTab];
|
||||
let activeTabCount;
|
||||
switch(activeTab) {
|
||||
case 'all':
|
||||
activeTabCount = data.allCount;
|
||||
break;
|
||||
case 'premod':
|
||||
activeTabCount = data.premodCount;
|
||||
break;
|
||||
@@ -151,6 +154,7 @@ class ModerationContainer extends Component {
|
||||
<ModerationHeader asset={asset} />
|
||||
<ModerationMenu
|
||||
asset={asset}
|
||||
allCount={data.allCount}
|
||||
premodCount={data.premodCount}
|
||||
rejectedCount={data.rejectedCount}
|
||||
flaggedCount={data.flaggedCount}
|
||||
@@ -183,6 +187,8 @@ class ModerationContainer extends Component {
|
||||
rejectComment={props.rejectComment}
|
||||
/>
|
||||
<ModerationKeysModal
|
||||
hideShortcutsNote={props.hideShortcutsNote}
|
||||
shortcutsNoteVisible={moderation.shortcutsNoteVisible}
|
||||
open={moderation.modalOpen}
|
||||
onClose={onClose}/>
|
||||
</div>
|
||||
@@ -204,6 +210,7 @@ const mapDispatchToProps = dispatch => ({
|
||||
fetchSettings: () => dispatch(fetchSettings()),
|
||||
showBanUserDialog: (user, commentId, showRejectedNote) => dispatch(showBanUserDialog(user, commentId, showRejectedNote)),
|
||||
hideBanUserDialog: () => dispatch(hideBanUserDialog(false)),
|
||||
hideShortcutsNote: () => dispatch(hideShortcutsNote()),
|
||||
});
|
||||
|
||||
export default compose(
|
||||
|
||||
@@ -21,7 +21,6 @@ const ModerationQueue = ({comments, selectedIndex, commentCount, singleView, loa
|
||||
key={i}
|
||||
index={i}
|
||||
comment={comment}
|
||||
commentType={activeTab}
|
||||
selected={i === selectedIndex}
|
||||
suspectWords={props.suspectWords}
|
||||
bannedWords={props.bannedWords}
|
||||
|
||||
@@ -10,6 +10,7 @@ import FlagBox from './FlagBox';
|
||||
import CommentType from './CommentType';
|
||||
import ActionButton from 'coral-admin/src/components/ActionButton';
|
||||
import BanUserButton from 'coral-admin/src/components/BanUserButton';
|
||||
import {getActionSummary} from 'coral-framework/utils';
|
||||
|
||||
const linkify = new Linkify();
|
||||
|
||||
@@ -17,25 +18,33 @@ 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 Comment = ({actions = [], comment, ...props}) => {
|
||||
const links = linkify.getMatches(comment.body);
|
||||
const linkText = links ? links.map(link => link.raw) : [];
|
||||
const actionSummaries = props.comment.action_summaries;
|
||||
const flagActionSummaries = getActionSummary('FlagActionSummary', comment);
|
||||
const flagActions = comment.actions && comment.actions.filter(a => a.__typename === 'FlagAction');
|
||||
let commentType = '';
|
||||
if (comment.status === 'PREMOD') {
|
||||
commentType = 'premod';
|
||||
} else if (flagActions && flagActions.length) {
|
||||
commentType = 'flagged';
|
||||
}
|
||||
|
||||
return (
|
||||
<li tabIndex={props.index} className={`mdl-card ${props.selected ? 'mdl-shadow--8dp' : 'mdl-shadow--2dp'} ${styles.Comment} ${styles.listItem} ${props.selected ? styles.selected : ''}`}>
|
||||
<li tabIndex={props.index} className={`mdl-card ${props.selected ? 'mdl-shadow--8dp' : 'mdl-shadow--2dp'} ${styles.Comment} ${styles.listItem}`}>
|
||||
<div className={styles.container}>
|
||||
<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>
|
||||
<BanUserButton user={props.comment.user} onClick={() => props.showBanUserDialog(props.comment.user, props.comment.id, props.comment.status !== 'REJECTED')} />
|
||||
<CommentType type={props.commentType} />
|
||||
</div>
|
||||
{props.comment.user.status === 'banned' ?
|
||||
<span>
|
||||
{comment.user.name}
|
||||
</span>
|
||||
<span className={styles.created}>
|
||||
{timeago().format(comment.created_at || (Date.now() - props.index * 60 * 1000), lang.getLocale().replace('-', '_'))}
|
||||
</span>
|
||||
<BanUserButton user={comment.user} onClick={() => props.showBanUserDialog(comment.user, comment.id, comment.status !== 'REJECTED')} />
|
||||
<CommentType type={commentType} />
|
||||
</div>
|
||||
{comment.user.status === 'banned' ?
|
||||
<span className={styles.banned}>
|
||||
<Icon name='error_outline'/>
|
||||
{lang.t('comment.banned_user')}
|
||||
@@ -43,16 +52,16 @@ const Comment = ({actions = [], ...props}) => {
|
||||
: null}
|
||||
</div>
|
||||
<div className={styles.moderateArticle}>
|
||||
Story: {props.comment.asset.title}
|
||||
Story: {comment.asset.title}
|
||||
{!props.currentAsset && (
|
||||
<Link to={`/admin/moderate/${props.comment.asset.id}`}>Moderate →</Link>
|
||||
<Link to={`/admin/moderate/${comment.asset.id}`}>Moderate →</Link>
|
||||
)}
|
||||
</div>
|
||||
<div className={styles.itemBody}>
|
||||
<p className={styles.body}>
|
||||
<Highlighter
|
||||
searchWords={[...props.suspectWords, ...props.bannedWords, ...linkText]}
|
||||
textToHighlight={props.comment.body} />
|
||||
textToHighlight={comment.body} />
|
||||
</p>
|
||||
<div className={styles.sideActions}>
|
||||
{links ? <span className={styles.hasLinks}><Icon name='error_outline'/> Contains Link</span> : null}
|
||||
@@ -60,16 +69,21 @@ const Comment = ({actions = [], ...props}) => {
|
||||
{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})}
|
||||
user={comment.user}
|
||||
status={comment.status}
|
||||
acceptComment={() => props.acceptComment({commentId: comment.id})}
|
||||
rejectComment={() => props.rejectComment({commentId: comment.id})}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{actionSummaries && <FlagBox actionSummaries={actionSummaries} />}
|
||||
{
|
||||
flagActions && flagActions.length
|
||||
? <FlagBox actions={flagActions} actionSummaries={flagActionSummaries} />
|
||||
: null
|
||||
}
|
||||
</li>
|
||||
);
|
||||
};
|
||||
@@ -83,6 +97,7 @@ Comment.propTypes = {
|
||||
comment: PropTypes.shape({
|
||||
body: PropTypes.string.isRequired,
|
||||
action_summaries: PropTypes.array,
|
||||
actions: PropTypes.array,
|
||||
created_at: PropTypes.string.isRequired,
|
||||
user: PropTypes.shape({
|
||||
status: PropTypes.string
|
||||
|
||||
@@ -53,3 +53,17 @@
|
||||
font-size: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
.lessDetail {
|
||||
display: inline-block;
|
||||
margin-right: 10px;
|
||||
}
|
||||
|
||||
.subDetail {
|
||||
font-weight: normal;
|
||||
color: #888;
|
||||
|
||||
span {
|
||||
color: black;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,17 @@
|
||||
import React, {Component, PropTypes} from 'react';
|
||||
import {Icon} from 'coral-ui';
|
||||
import styles from './FlagBox.css';
|
||||
import I18n from 'coral-framework/modules/i18n/i18n';
|
||||
import translations from 'coral-admin/src/translations.json';
|
||||
const lang = new I18n(translations);
|
||||
|
||||
const shortReasons = {
|
||||
'This comment is offensive': lang.t('modqueue.offensive'),
|
||||
'This looks like an ad/marketing': lang.t('modqueue.spam/ads'),
|
||||
'This user is impersonating': lang.t('modqueue.impersonating'),
|
||||
'I don\'t like this username': lang.t('modqueue.dont-like-username'),
|
||||
'Other': lang.t('modqueue.other')
|
||||
};
|
||||
|
||||
class FlagBox extends Component {
|
||||
constructor () {
|
||||
@@ -16,27 +27,50 @@ class FlagBox extends Component {
|
||||
}));
|
||||
}
|
||||
|
||||
reasonMap = (reason) => {
|
||||
const shortReason = shortReasons[reason];
|
||||
|
||||
// if the short reason isn't found, just return the long one.
|
||||
return shortReason ? shortReason : reason;
|
||||
}
|
||||
|
||||
render() {
|
||||
const {props} = this;
|
||||
const {actionSummaries, actions} = this.props;
|
||||
const {showDetail} = this.state;
|
||||
|
||||
return (
|
||||
<div className={styles.flagBox}>
|
||||
<div className={styles.container}>
|
||||
<div className={styles.header}>
|
||||
<Icon name='flag'/><h3>Flags ({props.actionSummaries.length}):</h3>
|
||||
<Icon name='flag'/><h3>Flags ({actionSummaries.length}):</h3>
|
||||
<ul>
|
||||
{props.actionSummaries.map((action, i) =>
|
||||
<li key={i}>{!action.reason ? <i>No reason provided</i> : action.reason} (<strong>{action.count}</strong>)</li>
|
||||
{actionSummaries.map((action, i) =>
|
||||
<li key={i} className={styles.lessDetail}>{this.reasonMap(action.reason)} (<strong>{action.count}</strong>)</li>
|
||||
)}
|
||||
</ul>
|
||||
{/* <a onClick={this.toggleDetail} className={styles.moreDetail}>More detail</a>*/}
|
||||
<a onClick={this.toggleDetail} className={styles.moreDetail}>{showDetail ? lang.t('modqueue.less-detail') : lang.t('modqueue.more-detail')}</a>
|
||||
</div>
|
||||
{this.state.showDetail && (<div className={styles.detail}>
|
||||
<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>)}
|
||||
{showDetail && (
|
||||
<div className={styles.detail}>
|
||||
<ul>
|
||||
{actionSummaries.map((summary, i) => {
|
||||
|
||||
const actionList = actions.filter(a => a.reason === summary.reason);
|
||||
|
||||
return (
|
||||
<li key={i}>
|
||||
{this.reasonMap(summary.reason)} (<strong>{summary.count}</strong>)
|
||||
<ul>
|
||||
{
|
||||
actionList.map((action, j) => <li key={`${i}_${j}`} className={styles.subDetail}><span>{action.user.username}</span> {action.message}</li>)
|
||||
}
|
||||
</ul>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -44,7 +78,14 @@ class FlagBox extends Component {
|
||||
}
|
||||
|
||||
FlagBox.propTypes = {
|
||||
actionSummaries: PropTypes.array.isRequired
|
||||
actionSummaries: PropTypes.arrayOf(PropTypes.shape({
|
||||
reason: PropTypes.string,
|
||||
count: PropTypes.number
|
||||
})).isRequired,
|
||||
actions: PropTypes.arrayOf(PropTypes.shape({
|
||||
message: PropTypes.string,
|
||||
user: PropTypes.shape({username: PropTypes.string})
|
||||
})).isRequired
|
||||
};
|
||||
|
||||
export default FlagBox;
|
||||
|
||||
@@ -23,7 +23,7 @@ LoadMore.propTypes = {
|
||||
comments: PropTypes.array.isRequired,
|
||||
loadMore: PropTypes.func.isRequired,
|
||||
sort: PropTypes.oneOf(['CHRONOLOGICAL', 'REVERSE_CHRONOLOGICAL']).isRequired,
|
||||
tab: PropTypes.oneOf(['rejected', 'premod', 'flagged']).isRequired,
|
||||
tab: PropTypes.oneOf(['rejected', 'premod', 'flagged', 'all']).isRequired,
|
||||
assetId: PropTypes.string,
|
||||
showLoadMore: PropTypes.bool.isRequired
|
||||
};
|
||||
|
||||
@@ -14,13 +14,13 @@ const ModerationHeader = props => (
|
||||
<span>{props.asset.title}</span>
|
||||
<Icon className={styles.settingsButton} name="open_in_new"/>
|
||||
</a>
|
||||
<Link className="mdl-tabs__tab" to="/admin/streams">Select Stream</Link>
|
||||
<Link className="mdl-tabs__tab" to="/admin/stories">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>
|
||||
<Link className="mdl-tabs__tab" to="/admin/stories">Select Stream</Link>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
@@ -1,25 +1,21 @@
|
||||
import React, { PropTypes } from 'react';
|
||||
import React, {PropTypes} from 'react';
|
||||
import CommentCount from './CommentCount';
|
||||
import styles from './styles.css';
|
||||
import { SelectField, Option } from 'react-mdl-selectfield';
|
||||
import {SelectField, Option} from 'react-mdl-selectfield';
|
||||
import I18n from 'coral-framework/modules/i18n/i18n';
|
||||
import translations from 'coral-admin/src/translations.json';
|
||||
import { Link } from 'react-router';
|
||||
import {Icon} from 'coral-ui';
|
||||
import {Link} from 'react-router';
|
||||
|
||||
const lang = new I18n(translations);
|
||||
|
||||
const ModerationMenu = (
|
||||
{ asset, premodCount, rejectedCount, flaggedCount, selectSort, sort }
|
||||
{asset, allCount, premodCount, rejectedCount, flaggedCount, selectSort, sort}
|
||||
) => {
|
||||
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';
|
||||
|
||||
function getPath (type) {
|
||||
return asset ? `/admin/moderate/${type}/${asset.id}` : `/admin/moderate/${type}`;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mdl-tabs">
|
||||
@@ -27,22 +23,28 @@ const ModerationMenu = (
|
||||
<div className={styles.tabBarPadding} />
|
||||
<div>
|
||||
<Link
|
||||
to={premodPath}
|
||||
to={getPath('all')}
|
||||
className={`mdl-tabs__tab ${styles.tab}`}
|
||||
activeClassName={styles.active}>
|
||||
{lang.t('modqueue.premod')} <CommentCount count={premodCount} />
|
||||
<Icon name='question_answer' className={styles.tabIcon} /> {lang.t('modqueue.all')} <CommentCount count={allCount} />
|
||||
</Link>
|
||||
<Link
|
||||
to={flagPath}
|
||||
to={getPath('premod')}
|
||||
className={`mdl-tabs__tab ${styles.tab}`}
|
||||
activeClassName={styles.active}>
|
||||
{lang.t('modqueue.flagged')} <CommentCount count={flaggedCount} />
|
||||
<Icon name='access_time' className={styles.tabIcon} /> {lang.t('modqueue.premod')} <CommentCount count={premodCount} />
|
||||
</Link>
|
||||
<Link
|
||||
to={rejectPath}
|
||||
to={getPath('flagged')}
|
||||
className={`mdl-tabs__tab ${styles.tab}`}
|
||||
activeClassName={styles.active}>
|
||||
{lang.t('modqueue.rejected')} <CommentCount count={rejectedCount} />
|
||||
<Icon name='flag' className={styles.tabIcon} /> {lang.t('modqueue.flagged')} <CommentCount count={flaggedCount} />
|
||||
</Link>
|
||||
<Link
|
||||
to={getPath('rejected')}
|
||||
className={`mdl-tabs__tab ${styles.tab}`}
|
||||
activeClassName={styles.active}>
|
||||
<Icon name='close' className={styles.tabIcon} /> {lang.t('modqueue.rejected')} <CommentCount count={rejectedCount} />
|
||||
</Link>
|
||||
</div>
|
||||
<SelectField
|
||||
@@ -59,6 +61,7 @@ const ModerationMenu = (
|
||||
};
|
||||
|
||||
ModerationMenu.propTypes = {
|
||||
allCount: PropTypes.number.isRequired,
|
||||
premodCount: PropTypes.number.isRequired,
|
||||
rejectedCount: PropTypes.number.isRequired,
|
||||
flaggedCount: PropTypes.number.isRequired,
|
||||
|
||||
@@ -6,7 +6,7 @@ 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>
|
||||
<Link className={styles.goToStreams} to="/admin/stories">Go to Streams</Link>
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -418,3 +418,8 @@ span {
|
||||
.loadMore:hover {
|
||||
background-color: #4399FF;
|
||||
}
|
||||
|
||||
.tabIcon {
|
||||
position: relative;
|
||||
top: 7px;
|
||||
}
|
||||
|
||||
@@ -12,4 +12,19 @@ fragment commentView on Comment {
|
||||
id
|
||||
title
|
||||
}
|
||||
action_summaries {
|
||||
count
|
||||
... on FlagActionSummary {
|
||||
reason
|
||||
}
|
||||
}
|
||||
actions {
|
||||
... on FlagAction {
|
||||
reason
|
||||
message
|
||||
user {
|
||||
username
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import {graphql} from 'react-apollo';
|
||||
import SET_USER_STATUS from './setUserStatus.graphql';
|
||||
import SET_COMMENT_STATUS from './setCommentStatus.graphql';
|
||||
import SUSPEND_USER from './suspendUser.graphql';
|
||||
|
||||
export const banUser = graphql(SET_USER_STATUS, {
|
||||
props: ({mutate}) => ({
|
||||
@@ -9,11 +10,40 @@ export const banUser = graphql(SET_USER_STATUS, {
|
||||
variables: {
|
||||
userId,
|
||||
status: 'BANNED'
|
||||
}
|
||||
},
|
||||
refetchQueries: ['Users']
|
||||
});
|
||||
}}),
|
||||
});
|
||||
|
||||
export const setUserStatus = graphql(SET_USER_STATUS, {
|
||||
props: ({mutate}) => ({
|
||||
approveUser: ({userId}) => {
|
||||
return mutate({
|
||||
variables: {
|
||||
userId,
|
||||
status: 'APPROVED'
|
||||
},
|
||||
refetchQueries: ['Users']
|
||||
});
|
||||
}
|
||||
})
|
||||
});
|
||||
|
||||
export const suspendUser = graphql(SUSPEND_USER, {
|
||||
props: ({mutate}) => ({
|
||||
suspendUser: ({userId, message}) => {
|
||||
return mutate({
|
||||
variables: {
|
||||
userId,
|
||||
message
|
||||
},
|
||||
refetchQueries: ['Users']
|
||||
});
|
||||
}
|
||||
})
|
||||
});
|
||||
|
||||
export const setCommentStatus = graphql(SET_COMMENT_STATUS, {
|
||||
props: ({mutate}) => ({
|
||||
acceptComment: ({commentId}) => {
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
mutation suspendUser($userId: ID!, $message: String) {
|
||||
suspendUser(id: $userId, message: $message) {
|
||||
errors {
|
||||
translation_key
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import {graphql} from 'react-apollo';
|
||||
|
||||
import MOD_QUEUE_QUERY from './modQueueQuery.graphql';
|
||||
import MOD_QUEUE_LOAD_MORE from './loadMore.graphql';
|
||||
import MOD_USER_FLAGGED_QUERY from './modUserFlaggedQuery.graphql';
|
||||
import METRICS from './metricsQuery.graphql';
|
||||
|
||||
export const modQueueQuery = graphql(MOD_QUEUE_QUERY, {
|
||||
@@ -35,6 +36,9 @@ export const getMetrics = graphql(METRICS, {
|
||||
export const loadMore = (fetchMore) => ({limit, cursor, sort, tab, asset_id}) => {
|
||||
let statuses;
|
||||
switch(tab) {
|
||||
case 'all':
|
||||
statuses = null;
|
||||
break;
|
||||
case 'premod':
|
||||
statuses = ['PREMOD'];
|
||||
break;
|
||||
@@ -54,7 +58,7 @@ export const loadMore = (fetchMore) => ({limit, cursor, sort, tab, asset_id}) =>
|
||||
statuses,
|
||||
asset_id
|
||||
},
|
||||
updateQuery: (oldData, {fetchMoreResult:{data:{comments}}}) => {
|
||||
updateQuery: (oldData, {fetchMoreResult:{comments}}) => {
|
||||
return {
|
||||
...oldData,
|
||||
[tab]: [
|
||||
@@ -66,6 +70,16 @@ export const loadMore = (fetchMore) => ({limit, cursor, sort, tab, asset_id}) =>
|
||||
});
|
||||
};
|
||||
|
||||
export const modUserFlaggedQuery = graphql(MOD_USER_FLAGGED_QUERY, {
|
||||
options: ({params: {action_type = 'FLAG'}}) => {
|
||||
return {
|
||||
variables: {
|
||||
action_type: action_type
|
||||
}
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
export const modQueueResort = (id, fetchMore) => (sort) => {
|
||||
return fetchMore({
|
||||
query: MOD_QUEUE_QUERY,
|
||||
|
||||
@@ -1,6 +1,13 @@
|
||||
#import "../fragments/commentView.graphql"
|
||||
|
||||
query ModQueue ($asset_id: ID, $sort: SORT_ORDER) {
|
||||
all: comments(query: {
|
||||
statuses: [NONE, PREMOD, ACCEPTED, REJECTED],
|
||||
asset_id: $asset_id,
|
||||
sort: $sort
|
||||
}) {
|
||||
...commentView
|
||||
}
|
||||
premod: comments(query: {
|
||||
statuses: [PREMOD],
|
||||
asset_id: $asset_id,
|
||||
@@ -15,12 +22,6 @@ query ModQueue ($asset_id: ID, $sort: SORT_ORDER) {
|
||||
sort: $sort
|
||||
}) {
|
||||
...commentView
|
||||
action_summaries {
|
||||
count
|
||||
... on FlagActionSummary {
|
||||
reason
|
||||
}
|
||||
}
|
||||
}
|
||||
rejected: comments(query: {
|
||||
statuses: [REJECTED],
|
||||
@@ -34,6 +35,9 @@ query ModQueue ($asset_id: ID, $sort: SORT_ORDER) {
|
||||
title
|
||||
url
|
||||
}
|
||||
allCount: commentCount(query: {
|
||||
asset_id: $asset_id
|
||||
})
|
||||
premodCount: commentCount(query: {
|
||||
statuses: [PREMOD],
|
||||
asset_id: $asset_id
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
query Users ($action_type: ACTION_TYPE) {
|
||||
users (query:{action_type: $action_type}){
|
||||
id
|
||||
username
|
||||
status
|
||||
roles
|
||||
actions{
|
||||
id
|
||||
created_at
|
||||
... on FlagAction {
|
||||
reason
|
||||
message
|
||||
user {
|
||||
id
|
||||
username
|
||||
}
|
||||
}
|
||||
}
|
||||
action_summaries {
|
||||
count
|
||||
... on FlagActionSummary {
|
||||
reason
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -7,7 +7,6 @@ import store from './services/store';
|
||||
|
||||
import App from './components/App';
|
||||
|
||||
import 'react-mdl/extra/material.css';
|
||||
import 'react-mdl/extra/material.js';
|
||||
|
||||
render(
|
||||
|
||||
@@ -6,58 +6,88 @@ import {
|
||||
FETCH_COMMENTERS_SUCCESS,
|
||||
SORT_UPDATE,
|
||||
SET_ROLE,
|
||||
SET_COMMENTER_STATUS
|
||||
SET_COMMENTER_STATUS,
|
||||
SHOW_BANUSER_DIALOG,
|
||||
HIDE_BANUSER_DIALOG,
|
||||
SHOW_SUSPENDUSER_DIALOG,
|
||||
HIDE_SUSPENDUSER_DIALOG
|
||||
} from '../constants/community';
|
||||
|
||||
const initialState = Map({
|
||||
community: Map(),
|
||||
isFetching: false,
|
||||
error: '',
|
||||
commenters: [],
|
||||
field: 'created_at',
|
||||
asc: false,
|
||||
totalPages: 0,
|
||||
page: 0
|
||||
isFetchingPeople: false,
|
||||
errorPeople: '',
|
||||
accounts: [],
|
||||
fieldPeople: 'created_at',
|
||||
ascPeople: false,
|
||||
totalPagesPeople: 0,
|
||||
pagePeople: 0,
|
||||
user: Map({}),
|
||||
banDialog: false,
|
||||
suspendDialog: false
|
||||
});
|
||||
|
||||
export default function community (state = initialState, action) {
|
||||
switch (action.type) {
|
||||
case FETCH_COMMENTERS_REQUEST :
|
||||
return state
|
||||
.set('isFetching', true);
|
||||
.set('isFetchingPeople', true);
|
||||
case FETCH_COMMENTERS_FAILURE :
|
||||
return state
|
||||
.set('isFetching', false)
|
||||
.set('error', action.error);
|
||||
.set('isFetchingPeople', false)
|
||||
.set('errorPeople', action.error);
|
||||
|
||||
case FETCH_COMMENTERS_SUCCESS : {
|
||||
const {commenters, type, ...rest} = action; // eslint-disable-line
|
||||
const {accounts, type, page, count, limit, totalPages, ...rest} = action; // eslint-disable-line
|
||||
return state
|
||||
.merge({
|
||||
isFetching: false,
|
||||
error: '',
|
||||
isFetchingPeople: false,
|
||||
errorPeople: '',
|
||||
pagePeople: page,
|
||||
countPeople: count,
|
||||
limitPeople: limit,
|
||||
totalPagesPeople: totalPages,
|
||||
...rest
|
||||
})
|
||||
.set('commenters', commenters); // Sets to normal array
|
||||
.set('accounts', accounts); // Sets to normal array
|
||||
}
|
||||
case SET_ROLE : {
|
||||
const commenters = state.get('commenters');
|
||||
const commenters = state.get('accounts');
|
||||
const idx = commenters.findIndex(el => el.id === action.id);
|
||||
|
||||
commenters[idx].roles[0] = action.role;
|
||||
return state.set('commenters', commenters.map(id => id));
|
||||
return state.set('accounts', commenters.map(id => id));
|
||||
}
|
||||
case SET_COMMENTER_STATUS: {
|
||||
const commenters = state.get('commenters');
|
||||
const commenters = state.get('accounts');
|
||||
const idx = commenters.findIndex(el => el.id === action.id);
|
||||
|
||||
commenters[idx].status = action.status;
|
||||
return state.set('commenters', commenters.map(id => id));
|
||||
return state.set('accounts', commenters.map(id => id));
|
||||
|
||||
}
|
||||
case SORT_UPDATE :
|
||||
return state
|
||||
.set('field', action.sort.field)
|
||||
.set('asc', !state.get('asc'));
|
||||
.set('fieldPeople', action.sort.field)
|
||||
.set('ascPeople', !state.get('ascPeople'));
|
||||
case HIDE_BANUSER_DIALOG:
|
||||
return state
|
||||
.set('banDialog', false);
|
||||
case SHOW_BANUSER_DIALOG:
|
||||
return state
|
||||
.merge({
|
||||
user: Map(action.user),
|
||||
banDialog: true
|
||||
});
|
||||
case HIDE_SUSPENDUSER_DIALOG:
|
||||
return state
|
||||
.set('suspendDialog', false);
|
||||
case SHOW_SUSPENDUSER_DIALOG:
|
||||
return state
|
||||
.merge({
|
||||
user: Map(action.user),
|
||||
suspendDialog: true
|
||||
});
|
||||
default :
|
||||
return state;
|
||||
}
|
||||
|
||||
@@ -6,7 +6,8 @@ const initialState = Map({
|
||||
modalOpen: false,
|
||||
user: Map({}),
|
||||
commentId: null,
|
||||
banDialog: false
|
||||
banDialog: false,
|
||||
shortcutsNoteVisible: window.localStorage.getItem('coral:shortcutsNote') || 'show'
|
||||
});
|
||||
|
||||
export default function moderation (state = initialState, action) {
|
||||
@@ -31,6 +32,9 @@ export default function moderation (state = initialState, action) {
|
||||
case actions.SINGLE_VIEW:
|
||||
return state
|
||||
.set('singleView', !state.get('singleView'));
|
||||
case actions.HIDE_SHORTCUTS_NOTE:
|
||||
return state
|
||||
.set('shortcutsNoteVisible', 'hide');
|
||||
default :
|
||||
return state;
|
||||
}
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import ApolloClient, {addTypename} from 'apollo-client';
|
||||
import getNetworkInterface from './transport';
|
||||
import fragmentMatcher from './fragmentMatcher';
|
||||
|
||||
export const client = new ApolloClient({
|
||||
fragmentMatcher,
|
||||
addTypename: true,
|
||||
queryTransformer: addTypename,
|
||||
dataIdFromObject: (result) => {
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
import {IntrospectionFragmentMatcher} from 'apollo-client';
|
||||
|
||||
// TODO this is a short-term fix
|
||||
// we need to set up something to query the server for the schema before ApolloClient initialization
|
||||
// https://github.com/apollographql/apollo-client/issues/1555#issuecomment-295834774
|
||||
const fm = new IntrospectionFragmentMatcher({
|
||||
introspectionQueryResultData: {
|
||||
__schema: {
|
||||
types: [
|
||||
{
|
||||
kind: 'INTERFACE',
|
||||
name: 'Action',
|
||||
possibleTypes: [
|
||||
{name: 'FlagAction'},
|
||||
{name: 'LikeAction'},
|
||||
{name: 'DontAgreeAction'}
|
||||
],
|
||||
},
|
||||
{
|
||||
kind: 'INTERFACE',
|
||||
name: 'ActionSummary',
|
||||
possibleTypes: [
|
||||
{name: 'FlagActionSummary'},
|
||||
{name: 'LikeActionSummary'},
|
||||
{name: 'DontAgreeActionSummary'}
|
||||
],
|
||||
}
|
||||
],
|
||||
},
|
||||
}
|
||||
});
|
||||
|
||||
export default fm;
|
||||
@@ -17,7 +17,21 @@
|
||||
"active": "Active",
|
||||
"banned": "Banned",
|
||||
"banned-user": "Banned User",
|
||||
"loading": "Loading results"
|
||||
"loading": "Loading results",
|
||||
"flags": "Flags",
|
||||
"flaggedaccounts": "Flagged Usernames",
|
||||
"people": "People",
|
||||
"no-flagged-accounts": "The Account Flags queue is currently empty.",
|
||||
"I don't like this username": "I don't like this username",
|
||||
"This user is impersonating": "Impersonation",
|
||||
"This looks like an ad/marketing": "Spam/Ads",
|
||||
"This username is offensive": "Offensive",
|
||||
"Other": "Other",
|
||||
"ban_user": "Ban User?",
|
||||
"are_you_sure": "Are you sure you would like to ban {0}?",
|
||||
"note": "Note: Banning this user will not let them edit, comment or remove anything.",
|
||||
"cancel": "Cancel",
|
||||
"yes_ban_user": "Yes, Ban User"
|
||||
},
|
||||
"modqueue": {
|
||||
"likes": "likes",
|
||||
@@ -30,6 +44,10 @@
|
||||
"close": "Close",
|
||||
"actions": "Actions",
|
||||
"navigation": "Navigation",
|
||||
"mod-faster": "Moderate faster with keyboard shortcuts",
|
||||
"try-these": "Try these",
|
||||
"view-more-shortcuts": "View more shortcuts",
|
||||
"shift-key": "⇧",
|
||||
"approve": "Approve comment",
|
||||
"reject": "Reject comment",
|
||||
"nextcomment": "Go to the next comment",
|
||||
@@ -37,7 +55,14 @@
|
||||
"singleview": "Toggle single comment edit view",
|
||||
"thismenu": "Open this menu",
|
||||
"emptyqueue": "No more comments to moderate! You're all caught up. Go have some ☕️",
|
||||
"showshortcuts": "Show Shortcuts"
|
||||
"showshortcuts": "Show Shortcuts",
|
||||
"more-detail": "More detail",
|
||||
"less-detail": "Less detail",
|
||||
"dont-like-username": "Don't like username",
|
||||
"impersonating": "Impersonating",
|
||||
"offensive": "Offensive",
|
||||
"spam/ads": "Spam/Ads",
|
||||
"other": "Other"
|
||||
},
|
||||
"comment": {
|
||||
"flagged": "flagged",
|
||||
@@ -55,6 +80,10 @@
|
||||
},
|
||||
"configure": {
|
||||
"closed-stream-settings": "Closed Stream Message",
|
||||
"open-stream-configuration": "This comment stream is currently open. By closing this comment stream, no new comments may be submitted and all previous comments will still be displayed.",
|
||||
"close-stream-configuration": "This comment stream is currently closed. By opening this comment stream, new comments may be submitted and displayed",
|
||||
"close-stream": "Close Stream",
|
||||
"open-stream": "Open Stream",
|
||||
"stream-settings": "Stream Settings",
|
||||
"moderation-settings": "Moderation Settings",
|
||||
"tech-settings": "Tech Settings",
|
||||
@@ -104,8 +133,9 @@
|
||||
"yes_ban_user": "Yes, Ban User"
|
||||
},
|
||||
"suspenduser": {
|
||||
"title_0": "We noticed you rejected a {0}",
|
||||
"description_0": "Would you like to temporarily ban this user becuase of their {0}? Doing so will temporarily hide their comments until they rewrite their {0}.",
|
||||
"title": "Suspend a user",
|
||||
"title_0": "We noticed you rejected a username",
|
||||
"description_0": "Would you like to temporarily ban this user because of their {0}? Doing so will temporarily hide their comments until they rewrite their {0}.",
|
||||
"title_1": "Notify the user of their temporary suspension",
|
||||
"description_1": "Suspending this user will temporarily disable their account and hide all of their comments on the site.",
|
||||
"no_cancel": "No, cancel",
|
||||
@@ -114,7 +144,7 @@
|
||||
"bio": "bio",
|
||||
"username": "username",
|
||||
"email_subject": "Your account has been suspended",
|
||||
"email": "Another member of the community recently flagged your {0} for review. Because of its content your {0} was rejected. This means you can no longer comment, like, or flag content until you rewrite your {0}. Please e-mail moderator@newsorg.com if you have any questions or concerns.",
|
||||
"email": "Another member of the community recently flagged your username for review. Because of its content your user was rejected. This means you can no longer comment, like, or flag content until you rewrite your username. Please e-mail us if you have any questions or concerns.",
|
||||
"write_message": "Write a message"
|
||||
},
|
||||
"dashboard": {
|
||||
@@ -124,7 +154,8 @@
|
||||
"no_likes": "There have been no likes in the last 5 minutes. All quiet.",
|
||||
"flags": "Flags",
|
||||
"no_activity": "There haven't been any comments anywhere in the last five minutes.",
|
||||
"comment_count": "comments"
|
||||
"comment_count": "comments",
|
||||
"most_flags": "Articles with the most flags"
|
||||
},
|
||||
"streams": {
|
||||
"empty_result": "No assets match this search. Maybe try widening your search?",
|
||||
@@ -163,6 +194,7 @@
|
||||
"banned": "Suspendido",
|
||||
"banned-user": "Usuario Suspendido",
|
||||
"loading": "Cargando resultados",
|
||||
"flags": "Reporte",
|
||||
"flaggedaccounts": "Nombres de Usuario Reportados",
|
||||
"people": "Gente",
|
||||
"no-flagged-accounts": "No hay ninguna cuenta reportada.",
|
||||
@@ -179,16 +211,16 @@
|
||||
},
|
||||
"suspenduser": {
|
||||
"title": "Suspendiendo un usuario",
|
||||
"title_0": "¿Esta queriendo suspender un usuario?",
|
||||
"description_0": "¿Le gustaría suspender a esta usuaria temporalmente devido a su nombre de usuario? Si lo hace sus comentarios serán escondidos temporalmente hasta que el usuario pueda cambiar su nombre.",
|
||||
"title_1": "Enviarle un correo al usuario informandole que su cuenta fue suspendida",
|
||||
"title_0": "Esta queriendo suspender un usuario?",
|
||||
"description_0": "Le gustaria suspender a esta usuaria temporarianmente por su nombre de usuario? Si lo hace sus comentarios serán escondidos temporariamente hasta que puedan reescribir su nombre de usuario.",
|
||||
"title_1": "Enviarle una nota al usuario sobre su cuenta suspendida",
|
||||
"description_1": "Si suspende a este usuario, su cuenta va a ser deshabilitada y todos sus comentarios escondidos del sitio.",
|
||||
"no_cancel": "No, cancelar",
|
||||
"yes_suspend": "Si, suspender",
|
||||
"send": "Enviar",
|
||||
"username": "nombre de usuario",
|
||||
"email_subject": "Su cuenta ha sido suspendida temporalmente",
|
||||
"email": "Una persona de la comunidad recientemente marcó su nombre de usuario para ser revisado. Por su contenido, el nombre de usuario ha sido rechazado. Esto quiere decir que no puede comentar, gustar o marcar contenido hasta que modifique su nombre de usuario. Por favor, envienos un correo si tiene alguna pregunta o comentario.",
|
||||
"email_subject": "Su cuenta ha sido suspendida temporariamente",
|
||||
"email": "Otra persona de la comunidad recientemente marcó su nombre de usuario para ser revisado. Por su contenido, el nombre de usuario ha sido rechazado. Esto quiere decir que no puede comentar, gustar o marcar contenido hasta que modifique su nombre de usuario. Por favor, envienos un correo a moderator@newsorg.com si tiene alguna pregunta o preocupación",
|
||||
"write_message": "Escribir un mensaje",
|
||||
"loading": "Cargando resultados"
|
||||
},
|
||||
@@ -198,9 +230,20 @@
|
||||
"rejected": "rechazado",
|
||||
"flagged": "marcado",
|
||||
"shortcuts": "Atajos de teclado",
|
||||
"mod-faster": "Moderar más rápido con atajos del teclado",
|
||||
"try-these": "Intenta estos",
|
||||
"view-more-shortcuts": "Ver más atajos",
|
||||
"shift-key": "⇧",
|
||||
"close": "Cerrar",
|
||||
"emptyqueue": "No se encontro ningún usuario. Están escondidos.",
|
||||
"showshortcuts": "Mostrar atajos"
|
||||
"showshortcuts": "Mostrar atajos",
|
||||
"more-detail": "Mas detalle",
|
||||
"less-detail": "Menos detalle",
|
||||
"dont-like-username": "No me gusta ese nombre de usuario",
|
||||
"impersonating": "Suplantación",
|
||||
"offensive": "Ofensivo",
|
||||
"spam/ads": "Spam/Propaganda",
|
||||
"other": "Otros"
|
||||
},
|
||||
"comment": {
|
||||
"flagged": "marcado",
|
||||
@@ -215,6 +258,10 @@
|
||||
},
|
||||
"configure": {
|
||||
"closed-stream-settings": "Mensaje a enviar cuando los comentarios están cerrados en el artículo",
|
||||
"open-stream-configuration": "Este hilo de comentarios esta abierto. Al cerrarlo, ningún nuevo comentario será publicado y todos los comentarios anteriores serán mostrados.",
|
||||
"close-stream-configuration": "Este hilo de comentario está en este momento cerrado. Al abrirlo, nuevos comentarios serán publicaods y mostrados.",
|
||||
"close-stream": "Cerrar Comentarios",
|
||||
"open-stream": "Abrir Comentarios",
|
||||
"stream-settings": "Configuración de Comentarios",
|
||||
"moderation-settings": "Configuración de Moderación",
|
||||
"tech-settings": "Configuración Técnica",
|
||||
@@ -267,14 +314,15 @@
|
||||
"cancel": "Cancelar",
|
||||
"yes_ban_user": "Si, Suspendan el usuario"
|
||||
},
|
||||
"dashbord": {
|
||||
"dashboard": {
|
||||
"next-update": "{0} minutos hasta la siguiente actualización.",
|
||||
"auto-update": "Los datos se actualizan automaticamente cada 5 minutos o cuando recargas.",
|
||||
"no_flags": "¡Nadie ha marcado nada en los últimos 5 minutos! ¡Bravo!",
|
||||
"no_likes": "A nadie le ha gustado algún comentario en los últimos 5 minutos. Todo tranquilo.",
|
||||
"flags": "Marcados",
|
||||
"no_activity": "No hubo comentarios en los ultimos 5 minutos",
|
||||
"comment_count": "comentarios"
|
||||
"comment_count": "comentarios",
|
||||
"most_flags": "Articulos con más reportes"
|
||||
},
|
||||
"streams": {
|
||||
"empty_result": "No se encuentro articulo con esta busqueda. ¿Tal vez puedas extender la busqueda?",
|
||||
|
||||
@@ -1,23 +1,25 @@
|
||||
import React from 'react';
|
||||
import {Button} from 'coral-ui';
|
||||
|
||||
import I18n from 'coral-framework/modules/i18n/i18n';
|
||||
import translations from 'coral-admin/src/translations';
|
||||
|
||||
const lang = new I18n(translations);
|
||||
|
||||
export default ({status, onClick}) => (
|
||||
status === 'open' ? (
|
||||
<div className="close-comments-intro-wrapper">
|
||||
<p>
|
||||
This comment stream is currently open. By closing this comment stream,
|
||||
no new comments may be submitted and all previous comments will still
|
||||
be displayed.
|
||||
{lang.t('configure.open-stream-configuration')}
|
||||
</p>
|
||||
<Button onClick={onClick}>Close Stream</Button>
|
||||
<Button onClick={onClick}>{lang.t('configure.close-stream')}</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="close-comments-intro-wrapper">
|
||||
<p>
|
||||
This comment stream is currently closed. By opening this comment stream,
|
||||
new comments may be submitted and displayed
|
||||
{lang.t('configure.close-stream-configuration')}
|
||||
</p>
|
||||
<Button onClick={onClick}>Open Stream</Button>
|
||||
<Button onClick={onClick}>{lang.t('configure.open-stream')}</Button>
|
||||
</div>
|
||||
)
|
||||
);
|
||||
|
||||
@@ -9,12 +9,12 @@
|
||||
right: 0;
|
||||
}
|
||||
|
||||
ul {
|
||||
.wrapper ul {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
ul ul {
|
||||
.wrapper ul ul {
|
||||
padding-left: 20px
|
||||
}
|
||||
|
||||
@@ -23,12 +23,12 @@ ul ul {
|
||||
margin: 12px 12px 12px 0;
|
||||
}
|
||||
|
||||
h4 {
|
||||
.wrapper h4 {
|
||||
font-size: 14px;
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
|
||||
p {
|
||||
.wrapper p {
|
||||
max-width: 380px;
|
||||
}
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ import I18n from 'coral-framework/modules/i18n/i18n';
|
||||
import translations from '../translations.json';
|
||||
const lang = new I18n(translations);
|
||||
|
||||
export default ({handleChange, handleApply, changed, updateQuestionBoxContent, ...props}) => (
|
||||
export default ({handleChange, handleApply, changed, ...props}) => (
|
||||
<form onSubmit={handleApply}>
|
||||
<div className={styles.wrapper}>
|
||||
<div className={styles.container}>
|
||||
@@ -38,9 +38,9 @@ export default ({handleChange, handleApply, changed, updateQuestionBoxContent, .
|
||||
<Checkbox
|
||||
className={styles.checkbox}
|
||||
cStyle={changed ? 'green' : 'darkGrey'}
|
||||
name="premodLinks"
|
||||
name="plinksenable"
|
||||
onChange={handleChange}
|
||||
defaultChecked={props.premodLinks}
|
||||
defaultChecked={props.premodLinksEnable}
|
||||
info={{
|
||||
title: lang.t('configureCommentStream.enablePremodLinks'),
|
||||
description: lang.t('configureCommentStream.enablePremodLinksDescription')
|
||||
@@ -57,11 +57,10 @@ export default ({handleChange, handleApply, changed, updateQuestionBoxContent, .
|
||||
title: lang.t('configureCommentStream.enableQuestionBox'),
|
||||
description: lang.t('configureCommentStream.enableQuestionBoxDescription')
|
||||
}} />
|
||||
|
||||
<div className={`${props.questionBoxEnable ? null : styles.hidden}`} >
|
||||
<TextField
|
||||
id="qboxcontent"
|
||||
onChange={updateQuestionBoxContent}
|
||||
onChange={handleChange}
|
||||
rows={3}
|
||||
value={props.questionBoxContent}
|
||||
label={lang.t('configureCommentStream.includeQuestionHere')}
|
||||
|
||||
@@ -15,13 +15,14 @@ class ConfigureStreamContainer extends Component {
|
||||
super(props);
|
||||
|
||||
this.state = {
|
||||
changed: false
|
||||
changed: false,
|
||||
dirtySettings: props.asset.settings,
|
||||
closedAt: (props.asset.closedAt === null ? 'open' : 'closed')
|
||||
};
|
||||
|
||||
this.toggleStatus = this.toggleStatus.bind(this);
|
||||
this.handleChange = this.handleChange.bind(this);
|
||||
this.handleApply = this.handleApply.bind(this);
|
||||
this.updateQuestionBoxContent = this.updateQuestionBoxContent.bind(this);
|
||||
}
|
||||
|
||||
handleApply (e) {
|
||||
@@ -31,7 +32,7 @@ class ConfigureStreamContainer extends Component {
|
||||
const questionBoxEnable = elements.qboxenable.checked;
|
||||
const questionBoxContent = elements.qboxcontent.value;
|
||||
|
||||
const premodLinksEnable = elements.premodLinks.checked;
|
||||
const premodLinksEnable = elements.plinksenable.checked;
|
||||
const {changed} = this.state;
|
||||
|
||||
const newConfig = {
|
||||
@@ -48,39 +49,52 @@ class ConfigureStreamContainer extends Component {
|
||||
changed: false
|
||||
});
|
||||
}, 300);
|
||||
|
||||
// this.props.loadAsset(this.props.data.asset);
|
||||
}
|
||||
}
|
||||
|
||||
handleChange (e) {
|
||||
|
||||
// TODO: Don’t directly manipulate state and make state change immutable.
|
||||
if (e.target && e.target.id === 'qboxenable') {
|
||||
this.props.asset.settings.questionBoxEnable = e.target.checked;
|
||||
this.state.dirtySettings.questionBoxEnable = e.target.checked;
|
||||
}
|
||||
if (e.target && e.target.id === 'qboxcontent') {
|
||||
this.state.dirtySettings.questionBoxContent = e.target.value;
|
||||
}
|
||||
if (e.target && e.target.id === 'plinksenable') {
|
||||
this.state.dirtySettings.premodLinksEnable = e.target.value;
|
||||
}
|
||||
|
||||
this.setState({
|
||||
changed: true
|
||||
});
|
||||
}
|
||||
|
||||
updateQuestionBoxContent(e) {
|
||||
this.props.asset.settings.questionBoxContent = e.target.value;
|
||||
this.handleChange(e);
|
||||
}
|
||||
|
||||
toggleStatus () {
|
||||
|
||||
// update the closedAt status for the asset
|
||||
this.props.updateStatus(
|
||||
this.props.asset.closedAt === null ? 'closed' : 'open'
|
||||
this.state.closedAt === 'open' ? 'closed' : 'open'
|
||||
);
|
||||
this.setState({
|
||||
closedAt: (this.state.closedAt === 'open' ? 'closed' : 'open')
|
||||
});
|
||||
}
|
||||
|
||||
getClosedIn () {
|
||||
const {closedTimeout} = this.props.asset.settings;
|
||||
const {created_at} = this.props.asset;
|
||||
|
||||
return lang.timeago(new Date(created_at).getTime() + (1000 * closedTimeout));
|
||||
}
|
||||
|
||||
render () {
|
||||
const {settings, closedAt} = this.props.asset;
|
||||
const status = closedAt === null ? 'open' : 'closed';
|
||||
const premod = settings.moderation === 'PRE';
|
||||
const {dirtySettings} = this.state;
|
||||
const premod = dirtySettings.moderation === 'PRE';
|
||||
const {closedAt} = this.state;
|
||||
const closedTimeout = dirtySettings.closedTimeout;
|
||||
|
||||
return (
|
||||
<div>
|
||||
@@ -88,18 +102,17 @@ class ConfigureStreamContainer extends Component {
|
||||
handleChange={this.handleChange}
|
||||
handleApply={this.handleApply}
|
||||
changed={this.state.changed}
|
||||
premodLinks={settings.premodLinks}
|
||||
premodLinksEnable={dirtySettings.premodLinksEnable}
|
||||
premod={premod}
|
||||
updateQuestionBoxContent={this.updateQuestionBoxContent}
|
||||
questionBoxEnable={settings.questionBoxEnable}
|
||||
questionBoxContent={settings.questionBoxContent}
|
||||
questionBoxEnable={dirtySettings.questionBoxEnable}
|
||||
questionBoxContent={dirtySettings.questionBoxContent}
|
||||
/>
|
||||
<hr />
|
||||
<h3>{status === 'open' ? 'Close' : 'Open'} Comment Stream</h3>
|
||||
{status === 'open' ? <p>The comment stream will close in {this.getClosedIn()}.</p> : ''}
|
||||
<h3>{closedAt === 'open' ? 'Close' : 'Open'} Comment Stream</h3>
|
||||
{(closedAt === 'open' && closedTimeout) ? <p>The comment stream will close in {this.getClosedIn()}.</p> : ''}
|
||||
<CloseCommentsInfo
|
||||
onClick={this.toggleStatus}
|
||||
status={status}
|
||||
status={closedAt}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
"enablePremodLinks": "Pre-Moderate Comments Containing Links",
|
||||
"enablePremodLinksDescription": "Moderators must approve any comment containing a link before its published.",
|
||||
"enableQuestionBox": "Ask readers a question",
|
||||
"enableQuestionBoxDescription": "This question will appear at the top of this comment stram. Ask readers about a certain issue in the article or pose discussion questions, etc.",
|
||||
"enableQuestionBoxDescription": "This question will appear at the top of this comment stream. Ask readers about a certain issue in the article or pose discussion questions, etc.",
|
||||
"includeQuestionHere": "Write your question here."
|
||||
}
|
||||
},
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import React from 'react';
|
||||
import ReactDOM from 'react-dom';
|
||||
import {render} from 'react-dom';
|
||||
import {GraphQLDocs} from 'graphql-docs';
|
||||
|
||||
import fetcher from './services/fetcher';
|
||||
|
||||
// Render the application into the DOM
|
||||
ReactDOM.render(<GraphQLDocs fetcher={fetcher} />, document.querySelector('#root'));
|
||||
render(<GraphQLDocs fetcher={fetcher} />, document.querySelector('#root'));
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
import React from 'react';
|
||||
import {Router, Route, browserHistory} from 'react-router';
|
||||
|
||||
import Embed from './Embed';
|
||||
import SignInContainer from 'coral-sign-in/containers/SignInContainer';
|
||||
|
||||
const routes = (
|
||||
<div>
|
||||
<Route exact path="/embed/stream/login" component={SignInContainer}/>
|
||||
<Route exact path="*" component={Embed}/>
|
||||
</div>
|
||||
);
|
||||
|
||||
const AppRouter = () => <Router history={browserHistory} routes={routes} />;
|
||||
|
||||
export default AppRouter;
|
||||
@@ -5,9 +5,21 @@
|
||||
|
||||
.Comment {
|
||||
margin-bottom: 15px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.pendingComment {
|
||||
filter: blur(2px);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.topRightMenu {
|
||||
float: right;
|
||||
text-align: right;
|
||||
cursor: pointer;
|
||||
margin-top: 5px;
|
||||
}
|
||||
|
||||
.topRightMenu > * {
|
||||
text-align: initial;
|
||||
}
|
||||
|
||||
@@ -19,11 +19,13 @@ import FlagComment from 'coral-plugin-flags/FlagComment';
|
||||
import LikeButton from 'coral-plugin-likes/LikeButton';
|
||||
import {BestButton, IfUserCanModifyBest, BEST_TAG, commentIsBest, BestIndicator} from 'coral-plugin-best/BestButton';
|
||||
import LoadMore from 'coral-embed-stream/src/LoadMore';
|
||||
import Slot from 'coral-framework/components/Slot';
|
||||
import IgnoredCommentTombstone from './IgnoredCommentTombstone';
|
||||
import {TopRightMenu} from './TopRightMenu';
|
||||
import {getActionSummary, getTotalActionCount, iPerformedThisAction} from 'coral-framework/utils';
|
||||
|
||||
import styles from './Comment.css';
|
||||
|
||||
const getActionSummary = (type, comment) => comment.action_summaries
|
||||
.filter((a) => a.__typename === type)[0];
|
||||
const isStaff = (tags) => !tags.every((t) => t.name !== 'STAFF') ;
|
||||
|
||||
// hold actions links (e.g. Like, Reply) along the comment footer
|
||||
@@ -62,6 +64,8 @@ class Comment extends React.Component {
|
||||
currentUser: PropTypes.shape({
|
||||
id: PropTypes.string.isRequired
|
||||
}),
|
||||
charCountEnable: PropTypes.bool.isRequired,
|
||||
maxCharCount: PropTypes.number,
|
||||
comment: PropTypes.shape({
|
||||
depth: PropTypes.number,
|
||||
action_summaries: PropTypes.array.isRequired,
|
||||
@@ -83,11 +87,17 @@ class Comment extends React.Component {
|
||||
}).isRequired
|
||||
}).isRequired,
|
||||
|
||||
// given a comment, return whether it should be rendered as ignored
|
||||
commentIsIgnored: React.PropTypes.func,
|
||||
|
||||
// dispatch action to add a tag to a comment
|
||||
addCommentTag: React.PropTypes.func,
|
||||
|
||||
// dispatch action to remove a tag from a comment
|
||||
removeCommentTag: React.PropTypes.func,
|
||||
|
||||
// dispatch action to ignore another user
|
||||
ignoreUser: React.PropTypes.func,
|
||||
}
|
||||
|
||||
render () {
|
||||
@@ -110,21 +120,31 @@ class Comment extends React.Component {
|
||||
deleteAction,
|
||||
addCommentTag,
|
||||
removeCommentTag,
|
||||
ignoreUser,
|
||||
disableReply,
|
||||
commentIsIgnored,
|
||||
maxCharCount,
|
||||
charCountEnable,
|
||||
} = this.props;
|
||||
|
||||
const like = getActionSummary('LikeActionSummary', comment);
|
||||
const flag = getActionSummary('FlagActionSummary', comment);
|
||||
const dontagree = getActionSummary('DontAgreeActionSummary', comment);
|
||||
const likeSummary = getActionSummary('LikeActionSummary', comment);
|
||||
const flagSummary = getActionSummary('FlagActionSummary', comment);
|
||||
const dontAgreeSummary = getActionSummary('DontAgreeActionSummary', comment);
|
||||
let myFlag = null;
|
||||
if (iPerformedThisAction('FlagActionSummary', comment)) {
|
||||
myFlag = flagSummary.find(s => s.current_user);
|
||||
} else if (iPerformedThisAction('DontAgreeActionSummary', comment)) {
|
||||
myFlag = dontAgreeSummary.find(s => s.current_user);
|
||||
}
|
||||
|
||||
let commentClass = parentId ? `reply ${styles.Reply}` : `comment ${styles.Comment}`;
|
||||
commentClass += highlighted === comment.id ? ' highlighted-comment' : '';
|
||||
commentClass += comment.id === 'pending' ? ` ${styles.pendingComment}` : '';
|
||||
|
||||
// call a function, and if it errors, call addNotification('error', ...) (e.g. to show user a snackbar)
|
||||
const notifyOnError = (fn, errorToMessage) => async () => {
|
||||
const notifyOnError = (fn, errorToMessage) => async function (...args) {
|
||||
if (typeof errorToMessage !== 'function') {errorToMessage = (error) => error.message;}
|
||||
try {
|
||||
return await fn();
|
||||
return await fn(...args);
|
||||
} catch (error) {
|
||||
addNotification('error', errorToMessage(error));
|
||||
throw error;
|
||||
@@ -147,22 +167,36 @@ class Comment extends React.Component {
|
||||
id={`c_${comment.id}`}
|
||||
style={{marginLeft: depth * 30}}>
|
||||
<hr aria-hidden={true} />
|
||||
<AuthorName
|
||||
author={comment.user}/>
|
||||
{ isStaff(comment.tags)
|
||||
? <TagLabel>Staff</TagLabel>
|
||||
<div className={highlighted === comment.id ? 'highlighted-comment' : ''}>
|
||||
<AuthorName
|
||||
author={comment.user}/>
|
||||
{ isStaff(comment.tags)
|
||||
? <TagLabel>Staff</TagLabel>
|
||||
: null }
|
||||
|
||||
{ commentIsBest(comment)
|
||||
? <TagLabel><BestIndicator /></TagLabel>
|
||||
{ commentIsBest(comment)
|
||||
? <TagLabel><BestIndicator /></TagLabel>
|
||||
: null }
|
||||
<PubDate created_at={comment.created_at} />
|
||||
<PubDate created_at={comment.created_at} />
|
||||
<Slot fill="commentInfoBar" comment={comment} commentId={comment.id} inline/>
|
||||
|
||||
<Content body={comment.body} />
|
||||
{ (currentUser && (comment.user.id !== currentUser.id))
|
||||
? <span className={styles.topRightMenu}>
|
||||
<TopRightMenu
|
||||
comment={comment}
|
||||
ignoreUser={ignoreUser}
|
||||
addNotification={addNotification} />
|
||||
</span>
|
||||
: null
|
||||
}
|
||||
|
||||
<Content body={comment.body} />
|
||||
<div className="commentActionsLeft comment__action-container">
|
||||
<ActionButton>
|
||||
{/* TODO implmement iPerformedThisAction for the like */}
|
||||
<LikeButton
|
||||
like={like}
|
||||
totalLikes={getTotalActionCount('LikeActionSummary', comment)}
|
||||
like={likeSummary[0]}
|
||||
id={comment.id}
|
||||
postLike={postLike}
|
||||
deleteAction={deleteAction}
|
||||
@@ -187,22 +221,25 @@ class Comment extends React.Component {
|
||||
removeBest={removeBestTag} />
|
||||
</IfUserCanModifyBest>
|
||||
</ActionButton>
|
||||
<Slot fill="commentDetail" comment={comment} commentId={comment.id} inline/>
|
||||
</div>
|
||||
<div className="commentActionsRight comment__action-container">
|
||||
<ActionButton>
|
||||
<PermalinkButton articleURL={asset.url} commentId={comment.id} />
|
||||
</ActionButton>
|
||||
<ActionButton>
|
||||
<FlagComment
|
||||
flaggedByCurrentUser={!!myFlag}
|
||||
flag={myFlag}
|
||||
id={comment.id}
|
||||
author_id={comment.user.id}
|
||||
postFlag={postFlag}
|
||||
postDontAgree={postDontAgree}
|
||||
deleteAction={deleteAction}
|
||||
showSignInDialog={showSignInDialog}
|
||||
currentUser={currentUser} />
|
||||
</ActionButton>
|
||||
</div>
|
||||
<div className="commentActionsRight comment__action-container">
|
||||
<ActionButton>
|
||||
<PermalinkButton articleURL={asset.url} commentId={comment.id} />
|
||||
</ActionButton>
|
||||
<ActionButton>
|
||||
<FlagComment
|
||||
flag={flag && flag.current_user ? flag : dontagree}
|
||||
id={comment.id}
|
||||
author_id={comment.user.id}
|
||||
postFlag={postFlag}
|
||||
postDontAgree={postDontAgree}
|
||||
deleteAction={deleteAction}
|
||||
showSignInDialog={showSignInDialog}
|
||||
currentUser={currentUser} />
|
||||
</ActionButton>
|
||||
</div>
|
||||
{
|
||||
activeReplyBox === comment.id
|
||||
@@ -210,6 +247,8 @@ class Comment extends React.Component {
|
||||
commentPostedHandler={() => {
|
||||
setActiveReplyBox('');
|
||||
}}
|
||||
charCountEnable={charCountEnable}
|
||||
maxCharCount={maxCharCount}
|
||||
setActiveReplyBox={setActiveReplyBox}
|
||||
parentId={parentId || comment.id}
|
||||
addNotification={addNotification}
|
||||
@@ -221,26 +260,31 @@ class Comment extends React.Component {
|
||||
{
|
||||
comment.replies &&
|
||||
comment.replies.map(reply => {
|
||||
return <Comment
|
||||
setActiveReplyBox={setActiveReplyBox}
|
||||
disableReply={disableReply}
|
||||
activeReplyBox={activeReplyBox}
|
||||
addNotification={addNotification}
|
||||
parentId={comment.id}
|
||||
postItem={postItem}
|
||||
depth={depth + 1}
|
||||
asset={asset}
|
||||
highlighted={highlighted}
|
||||
currentUser={currentUser}
|
||||
postLike={postLike}
|
||||
postFlag={postFlag}
|
||||
deleteAction={deleteAction}
|
||||
addCommentTag={addCommentTag}
|
||||
removeCommentTag={removeCommentTag}
|
||||
showSignInDialog={showSignInDialog}
|
||||
reactKey={reply.id}
|
||||
key={reply.id}
|
||||
comment={reply} />;
|
||||
return commentIsIgnored(reply)
|
||||
? <IgnoredCommentTombstone key={reply.id} />
|
||||
: <Comment
|
||||
setActiveReplyBox={setActiveReplyBox}
|
||||
disableReply={disableReply}
|
||||
activeReplyBox={activeReplyBox}
|
||||
addNotification={addNotification}
|
||||
parentId={comment.id}
|
||||
postItem={postItem}
|
||||
depth={depth + 1}
|
||||
asset={asset}
|
||||
highlighted={highlighted}
|
||||
currentUser={currentUser}
|
||||
postLike={postLike}
|
||||
postFlag={postFlag}
|
||||
deleteAction={deleteAction}
|
||||
addCommentTag={addCommentTag}
|
||||
removeCommentTag={removeCommentTag}
|
||||
ignoreUser={ignoreUser}
|
||||
charCountEnable={charCountEnable}
|
||||
maxCharCount={maxCharCount}
|
||||
showSignInDialog={showSignInDialog}
|
||||
reactKey={reply.id}
|
||||
key={reply.id}
|
||||
comment={reply} />;
|
||||
})
|
||||
}
|
||||
{
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, {Component} from 'react';
|
||||
import React from 'react';
|
||||
import {compose} from 'react-apollo';
|
||||
import {connect} from 'react-redux';
|
||||
import isEqual from 'lodash/isEqual';
|
||||
@@ -6,16 +6,17 @@ import I18n from 'coral-framework/modules/i18n/i18n';
|
||||
import translations from 'coral-framework/translations';
|
||||
const lang = new I18n(translations);
|
||||
|
||||
import {TabBar, Tab, TabContent, Spinner} from 'coral-ui';
|
||||
import {TabBar, Tab, TabContent, Spinner, Button} from 'coral-ui';
|
||||
|
||||
const {logout, showSignInDialog, requestConfirmEmail} = authActions;
|
||||
const {logout, showSignInDialog, requestConfirmEmail, openSignInPopUp, checkLogin} = authActions;
|
||||
const {addNotification, clearNotification} = notificationActions;
|
||||
const {fetchAssetSuccess} = assetActions;
|
||||
import {NEW_COMMENT_COUNT_POLL_INTERVAL} from 'coral-framework/constants/comments';
|
||||
|
||||
import {queryStream} from 'coral-framework/graphql/queries';
|
||||
import {postComment, postFlag, postLike, postDontAgree, deleteAction, addCommentTag, removeCommentTag} from 'coral-framework/graphql/mutations';
|
||||
import {postComment, postFlag, postLike, postDontAgree, deleteAction, addCommentTag, removeCommentTag, ignoreUser} from 'coral-framework/graphql/mutations';
|
||||
import {editName} from 'coral-framework/actions/user';
|
||||
import {updateCountCache} from 'coral-framework/actions/asset';
|
||||
import {updateCountCache, viewAllComments} from 'coral-framework/actions/asset';
|
||||
import {notificationActions, authActions, assetActions, pym} from 'coral-framework';
|
||||
|
||||
import Stream from './Stream';
|
||||
@@ -25,24 +26,31 @@ import {ModerationLink} from 'coral-plugin-moderation';
|
||||
import Count from 'coral-plugin-comment-count/CommentCount';
|
||||
import CommentBox from 'coral-plugin-commentbox/CommentBox';
|
||||
import UserBox from 'coral-sign-in/components/UserBox';
|
||||
import SignInContainer from 'coral-sign-in/containers/SignInContainer';
|
||||
import SuspendedAccount from 'coral-framework/components/SuspendedAccount';
|
||||
import ChangeUsernameContainer from '../../coral-sign-in/containers/ChangeUsernameContainer';
|
||||
import ProfileContainer from 'coral-settings/containers/ProfileContainer';
|
||||
import RestrictedContent from 'coral-framework/components/RestrictedContent';
|
||||
import ConfigureStreamContainer from 'coral-configure/containers/ConfigureStreamContainer';
|
||||
import Comment from './Comment';
|
||||
import HighlightedComment from './Comment';
|
||||
import LoadMore from './LoadMore';
|
||||
import NewCount from './NewCount';
|
||||
|
||||
class Embed extends Component {
|
||||
class Embed extends React.Component {
|
||||
|
||||
state = {activeTab: 0, showSignInDialog: false, activeReplyBox: ''};
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.state = {
|
||||
activeTab: 0,
|
||||
showSignInDialog: false,
|
||||
activeReplyBox: ''
|
||||
};
|
||||
}
|
||||
|
||||
changeTab = (tab) => {
|
||||
|
||||
// Everytime the comes from another tab, the Stream needs to be updated.
|
||||
if (tab === 0) {
|
||||
this.props.viewAllComments();
|
||||
this.props.data.refetch();
|
||||
}
|
||||
|
||||
@@ -62,16 +70,42 @@ class Embed extends Component {
|
||||
|
||||
// dispatch action to remove a tag from a comment
|
||||
removeCommentTag: React.PropTypes.func,
|
||||
|
||||
// dispatch action to ignore another user
|
||||
ignoreUser: React.PropTypes.func,
|
||||
}
|
||||
|
||||
componentDidMount () {
|
||||
pym.sendMessage('childReady');
|
||||
this.props.checkLogin();
|
||||
}
|
||||
|
||||
componentWillUnmount () {
|
||||
clearInterval(this.state.countPoll);
|
||||
}
|
||||
|
||||
componentWillReceiveProps (nextProps) {
|
||||
const {loadAsset} = this.props;
|
||||
if(!isEqual(nextProps.data.asset, this.props.data.asset)) {
|
||||
loadAsset(nextProps.data.asset);
|
||||
|
||||
const {getCounts, updateCountCache, asset: {countCache}} = this.props;
|
||||
const {asset} = nextProps.data;
|
||||
|
||||
if (!countCache) {
|
||||
updateCountCache(asset.id, asset.commentCount);
|
||||
}
|
||||
|
||||
this.setState({
|
||||
countPoll: setInterval(() => {
|
||||
const {asset} = this.props.data;
|
||||
getCounts({
|
||||
asset_id: asset.id,
|
||||
limit: asset.comments.length,
|
||||
sort: 'REVERSE_CHRONOLOGICAL'
|
||||
});
|
||||
}, NEW_COMMENT_COUNT_POLL_INTERVAL)
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -79,14 +113,13 @@ class Embed extends Component {
|
||||
if(!isEqual(prevProps.data.comment, this.props.data.comment)) {
|
||||
|
||||
// Scroll to a permalinked comment if one is in the URL once the page is done rendering.
|
||||
setTimeout(()=>pym.scrollParentToChildEl(`c_${this.props.data.comment.id}`), 0);
|
||||
setTimeout(() => pym.scrollParentToChildEl('coralStream'), 0);
|
||||
}
|
||||
}
|
||||
|
||||
setActiveReplyBox = (reactKey) => {
|
||||
if (!this.props.auth.user) {
|
||||
const offset = document.getElementById(`c_${reactKey}`).getBoundingClientRect().top - 75;
|
||||
this.props.showSignInDialog(offset);
|
||||
this.props.showSignInDialog();
|
||||
} else {
|
||||
this.setState({activeReplyBox: reactKey});
|
||||
}
|
||||
@@ -95,19 +128,27 @@ class Embed extends Component {
|
||||
render () {
|
||||
const {activeTab} = this.state;
|
||||
const {closedAt, countCache = {}} = this.props.asset;
|
||||
const {loading, asset, refetch, comment} = this.props.data;
|
||||
const {loggedIn, isAdmin, user, showSignInDialog, signInOffset} = this.props.auth;
|
||||
const {asset, refetch, comment} = this.props.data;
|
||||
const {loggedIn, isAdmin, user, showSignInDialog} = this.props.auth;
|
||||
|
||||
// even though the permalinked comment is the highlighted one, we're displaying its parent + replies
|
||||
const highlightedComment = comment && comment.parent ? comment.parent : comment;
|
||||
|
||||
const openStream = closedAt === null;
|
||||
|
||||
const banned = user && user.status === 'BANNED';
|
||||
|
||||
const hasOlderComments = !!(
|
||||
asset &&
|
||||
asset.lastComment &&
|
||||
asset.lastComment.id !== asset.comments[asset.comments.length - 1].id
|
||||
);
|
||||
|
||||
const expandForLogin = showSignInDialog ? {
|
||||
minHeight: document.body.scrollHeight + 200
|
||||
} : {};
|
||||
|
||||
if (loading || !asset) {
|
||||
if (!asset) {
|
||||
return <Spinner />;
|
||||
}
|
||||
|
||||
@@ -116,16 +157,32 @@ class Embed extends Component {
|
||||
? asset.comments[0].created_at
|
||||
: new Date(Date.now() - 1000 * 60 * 60 * 24 * 7).toISOString();
|
||||
|
||||
const userBox = <UserBox user={user} logout={() => this.props.logout().then(refetch)} changeTab={this.changeTab}/>;
|
||||
|
||||
// TODO: This is a quickfix and will be replaced after our refactor.
|
||||
const ignoredUsers = this.props.userData.ignoredUsers;
|
||||
const commentIsIgnored = (comment) => ignoredUsers && ignoredUsers.includes(comment.user.id);
|
||||
|
||||
return (
|
||||
<div style={expandForLogin}>
|
||||
<div className="commentStream">
|
||||
<TabBar onChange={this.changeTab} activeTab={activeTab}>
|
||||
<Tab><Count count={asset.commentCount}/></Tab>
|
||||
<Tab>{lang.t('MY_COMMENTS')}</Tab>
|
||||
<Tab><Count count={asset.totalCommentCount}/></Tab>
|
||||
<Tab>{lang.t('myProfile')}</Tab>
|
||||
<Tab restricted={!isAdmin}>Configure Stream</Tab>
|
||||
</TabBar>
|
||||
{loggedIn && <UserBox user={user} logout={() => this.props.logout().then(refetch)} changeTab={this.changeTab}/>}
|
||||
{
|
||||
highlightedComment &&
|
||||
<Button
|
||||
cStyle='darkGrey'
|
||||
style={{float: 'right'}}
|
||||
onClick={() => {
|
||||
this.props.viewAllComments();
|
||||
this.props.data.refetch();
|
||||
}}>{lang.t('showAllComments')}</Button>
|
||||
}
|
||||
<TabContent show={activeTab === 0}>
|
||||
{ loggedIn ? userBox : null }
|
||||
{
|
||||
openStream
|
||||
? <div id="commentBox">
|
||||
@@ -157,22 +214,24 @@ class Embed extends Component {
|
||||
isReply={false}
|
||||
currentUser={this.props.auth.user}
|
||||
authorId={user.id}
|
||||
charCount={asset.settings.charCountEnable && asset.settings.charCount} />
|
||||
charCountEnable={asset.settings.charCountEnable}
|
||||
maxCharCount={asset.settings.charCount} />
|
||||
: null
|
||||
}
|
||||
</RestrictedContent>
|
||||
</div>
|
||||
: <p>{asset.settings.closedMessage}</p>
|
||||
}
|
||||
{!loggedIn && <SignInContainer
|
||||
requireEmailConfirmation={asset.settings.requireEmailConfirmation}
|
||||
refetch={refetch}
|
||||
offset={signInOffset}/>}
|
||||
{loggedIn && user && <ChangeUsernameContainer loggedIn={loggedIn} offset={signInOffset} user={user} />}
|
||||
|
||||
{!loggedIn && <Button id='coralSignInButton' onClick={this.props.showSignInDialog} full>Sign in to comment</Button>}
|
||||
|
||||
{loggedIn && user && <ChangeUsernameContainer loggedIn={loggedIn} user={user} />}
|
||||
{loggedIn && <ModerationLink assetId={asset.id} isAdmin={isAdmin} />}
|
||||
|
||||
{/* the highlightedComment is isolated after the user followed a permalink */}
|
||||
{
|
||||
highlightedComment &&
|
||||
<Comment
|
||||
highlightedComment
|
||||
? <HighlightedComment
|
||||
refetch={refetch}
|
||||
setActiveReplyBox={this.setActiveReplyBox}
|
||||
activeReplyBox={this.state.activeReplyBox}
|
||||
@@ -188,61 +247,66 @@ class Embed extends Component {
|
||||
loadMore={this.props.loadMore}
|
||||
deleteAction={this.props.deleteAction}
|
||||
showSignInDialog={this.props.showSignInDialog}
|
||||
commentIsIgnored={commentIsIgnored}
|
||||
key={highlightedComment.id}
|
||||
reactKey={highlightedComment.id}
|
||||
comment={highlightedComment} />
|
||||
: <div>
|
||||
<NewCount
|
||||
commentCount={asset.commentCount}
|
||||
countCache={countCache[asset.id]}
|
||||
loadMore={this.props.loadMore}
|
||||
firstCommentDate={firstCommentDate}
|
||||
assetId={asset.id}
|
||||
updateCountCache={this.props.updateCountCache}
|
||||
/>
|
||||
<div className="embed__stream">
|
||||
<Stream
|
||||
open={openStream}
|
||||
addNotification={this.props.addNotification}
|
||||
postItem={this.props.postItem}
|
||||
setActiveReplyBox={this.setActiveReplyBox}
|
||||
activeReplyBox={this.state.activeReplyBox}
|
||||
asset={asset}
|
||||
currentUser={user}
|
||||
postLike={this.props.postLike}
|
||||
postFlag={this.props.postFlag}
|
||||
postDontAgree={this.props.postDontAgree}
|
||||
addCommentTag={this.props.addCommentTag}
|
||||
removeCommentTag={this.props.removeCommentTag}
|
||||
ignoreUser={this.props.ignoreUser}
|
||||
loadMore={this.props.loadMore}
|
||||
deleteAction={this.props.deleteAction}
|
||||
showSignInDialog={this.props.showSignInDialog}
|
||||
comments={asset.comments}
|
||||
maxCharCount={asset.settings.charCount}
|
||||
charCountEnable={asset.settings.charCountEnable}
|
||||
ignoredUsers={this.props.userData.ignoredUsers} />
|
||||
</div>
|
||||
<LoadMore
|
||||
topLevel={true}
|
||||
assetId={asset.id}
|
||||
comments={asset.comments}
|
||||
moreComments={hasOlderComments}
|
||||
loadMore={this.props.loadMore} />
|
||||
</div>
|
||||
}
|
||||
<NewCount
|
||||
commentCount={asset.commentCount}
|
||||
countCache={countCache[asset.id]}
|
||||
loadMore={this.props.loadMore}
|
||||
firstCommentDate={firstCommentDate}
|
||||
assetId={asset.id}
|
||||
updateCountCache={this.props.updateCountCache}
|
||||
</TabContent>
|
||||
<TabContent show={activeTab === 1}>
|
||||
<ProfileContainer
|
||||
loggedIn={loggedIn}
|
||||
userData={this.props.userData}
|
||||
/>
|
||||
</TabContent>
|
||||
<TabContent show={activeTab === 2}>
|
||||
<RestrictedContent restricted={!loggedIn}>
|
||||
{ loggedIn ? userBox : null }
|
||||
<ConfigureStreamContainer
|
||||
status={status}
|
||||
onClick={this.toggleStatus}
|
||||
/>
|
||||
<div className="embed__stream">
|
||||
<Stream
|
||||
open={openStream}
|
||||
addNotification={this.props.addNotification}
|
||||
postItem={this.props.postItem}
|
||||
setActiveReplyBox={this.setActiveReplyBox}
|
||||
activeReplyBox={this.state.activeReplyBox}
|
||||
asset={asset}
|
||||
currentUser={user}
|
||||
postLike={this.props.postLike}
|
||||
postFlag={this.props.postFlag}
|
||||
postDontAgree={this.props.postDontAgree}
|
||||
getCounts={this.props.getCounts}
|
||||
addCommentTag={this.props.addCommentTag}
|
||||
removeCommentTag={this.props.removeCommentTag}
|
||||
updateCountCache={this.props.updateCountCache}
|
||||
loadMore={this.props.loadMore}
|
||||
deleteAction={this.props.deleteAction}
|
||||
showSignInDialog={this.props.showSignInDialog}
|
||||
comments={asset.comments} />
|
||||
</div>
|
||||
<LoadMore
|
||||
topLevel={true}
|
||||
assetId={asset.id}
|
||||
comments={asset.comments}
|
||||
moreComments={countCache[asset.id] > asset.comments.length}
|
||||
loadMore={this.props.loadMore}/>
|
||||
</TabContent>
|
||||
<TabContent show={activeTab === 1}>
|
||||
<ProfileContainer
|
||||
loggedIn={loggedIn}
|
||||
userData={this.props.userData}
|
||||
showSignInDialog={this.props.showSignInDialog}
|
||||
/>
|
||||
</TabContent>
|
||||
<TabContent show={activeTab === 2}>
|
||||
<RestrictedContent restricted={!loggedIn}>
|
||||
<ConfigureStreamContainer
|
||||
status={status}
|
||||
onClick={this.toggleStatus}
|
||||
/>
|
||||
</RestrictedContent>
|
||||
</TabContent>
|
||||
</RestrictedContent>
|
||||
</TabContent>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -252,7 +316,7 @@ class Embed extends Component {
|
||||
const mapStateToProps = state => ({
|
||||
auth: state.auth.toJS(),
|
||||
userData: state.user.toJS(),
|
||||
asset: state.asset.toJS()
|
||||
asset: state.asset.toJS(),
|
||||
});
|
||||
|
||||
const mapDispatchToProps = dispatch => ({
|
||||
@@ -261,10 +325,13 @@ const mapDispatchToProps = dispatch => ({
|
||||
addNotification: (type, text) => addNotification(type, text),
|
||||
clearNotification: () => dispatch(clearNotification()),
|
||||
editName: (username) => dispatch(editName(username)),
|
||||
showSignInDialog: (offset) => dispatch(showSignInDialog(offset)),
|
||||
showSignInDialog: () => dispatch(showSignInDialog()),
|
||||
updateCountCache: (id, count) => dispatch(updateCountCache(id, count)),
|
||||
viewAllComments: () => dispatch(viewAllComments()),
|
||||
logout: () => dispatch(logout()),
|
||||
dispatch: d => dispatch(d)
|
||||
openSignInPopUp: cb => dispatch(openSignInPopUp(cb)),
|
||||
checkLogin: () => dispatch(checkLogin()),
|
||||
dispatch: d => dispatch(d),
|
||||
});
|
||||
|
||||
export default compose(
|
||||
@@ -275,6 +342,7 @@ export default compose(
|
||||
postDontAgree,
|
||||
addCommentTag,
|
||||
removeCommentTag,
|
||||
ignoreUser,
|
||||
deleteAction,
|
||||
queryStream
|
||||
queryStream,
|
||||
)(Embed);
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
.IgnoreUserWizard {
|
||||
background-color: #2E343B;
|
||||
color: white;
|
||||
padding: 1em;
|
||||
max-width: 220px;
|
||||
}
|
||||
|
||||
.IgnoreUserWizard header {
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.IgnoreUserWizard .textAlignRight {
|
||||
text-align: right;
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
import React, {PropTypes} from 'react';
|
||||
import styles from './IgnoreUserWizard.css';
|
||||
import {Button} from 'coral-ui';
|
||||
|
||||
// Guides the user through ignoring another user, including confirming their decision
|
||||
export class IgnoreUserWizard extends React.Component {
|
||||
static propTypes = {
|
||||
|
||||
// comment on which this menu appears
|
||||
user: PropTypes.shape({
|
||||
id: PropTypes.string.isRequired,
|
||||
name: PropTypes.string.isRequired
|
||||
}).isRequired,
|
||||
cancel: PropTypes.func.isRequired,
|
||||
|
||||
// actually submit the ignore. Provide {id: user id to ignore}
|
||||
ignoreUser: PropTypes.func.isRequired,
|
||||
}
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.state = {
|
||||
|
||||
// what step of the wizard is the user on
|
||||
step: 1
|
||||
};
|
||||
this.onClickCancel = this.onClickCancel.bind(this);
|
||||
}
|
||||
onClickCancel() {
|
||||
this.props.cancel();
|
||||
}
|
||||
render() {
|
||||
const {user, ignoreUser} = this.props;
|
||||
const goToStep = (stepNum) => this.setState({step: stepNum});
|
||||
const step1 = (
|
||||
<div>
|
||||
<header>Ignore User</header>
|
||||
<p>When you ignore a user, all comments they wrote on the site will be hidden from you. You can undo this later from the Profile tab.</p>
|
||||
<div className={styles.textAlignRight}>
|
||||
<Button cStyle='cancel' onClick={this.onClickCancel}>Cancel</Button>
|
||||
<Button onClick={() => goToStep(2)}>Ignore user</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
const onClickIgnoreUser = async () => {
|
||||
await ignoreUser({id: user.id});
|
||||
};
|
||||
const step2Confirmation = (
|
||||
<div>
|
||||
<header>Ignore User</header>
|
||||
<p>Are you sure you want to ignore { user.name }?</p>
|
||||
<div className={styles.textAlignRight}>
|
||||
<Button cStyle='cancel' onClick={this.onClickCancel}>Cancel</Button>
|
||||
<Button onClick={onClickIgnoreUser}>Ignore user</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
const elsForStep = [step1, step2Confirmation];
|
||||
const {step} = this.state;
|
||||
const elForThisStep = elsForStep[step - 1];
|
||||
return (
|
||||
<div className={styles.IgnoreUserWizard}>
|
||||
{ elForThisStep }
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import React from 'react';
|
||||
|
||||
import I18n from 'coral-framework/modules/i18n/i18n';
|
||||
import translations from 'coral-framework/translations';
|
||||
const lang = new I18n(translations);
|
||||
|
||||
// Render in place of a Comment when the author of the comment is ignored
|
||||
const IgnoredCommentTombstone = () => (
|
||||
<div>
|
||||
<hr aria-hidden={true} />
|
||||
<p style={{
|
||||
backgroundColor: '#F0F0F0',
|
||||
textAlign: 'center',
|
||||
padding: '1em',
|
||||
color: '#3E4F71',
|
||||
}}>
|
||||
{lang.t('commentIsIgnored')}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
|
||||
export default IgnoredCommentTombstone;
|
||||
@@ -5,7 +5,7 @@ import {ADDTL_COMMENTS_ON_LOAD_MORE} from 'coral-framework/constants/comments';
|
||||
import {Button} from 'coral-ui';
|
||||
const lang = new I18n(translations);
|
||||
|
||||
const loadMoreComments = (assetId, comments, loadMore, parentId) => {
|
||||
const loadMoreComments = (assetId, comments, loadMore, parentId, replyCount) => {
|
||||
|
||||
let cursor = null;
|
||||
if (comments.length) {
|
||||
@@ -15,7 +15,7 @@ const loadMoreComments = (assetId, comments, loadMore, parentId) => {
|
||||
}
|
||||
|
||||
loadMore({
|
||||
limit: ADDTL_COMMENTS_ON_LOAD_MORE,
|
||||
limit: parentId ? replyCount : ADDTL_COMMENTS_ON_LOAD_MORE,
|
||||
cursor,
|
||||
asset_id: assetId,
|
||||
parent_id: parentId,
|
||||
@@ -48,7 +48,7 @@ class LoadMore extends React.Component {
|
||||
<Button
|
||||
onClick={() => {
|
||||
this.initialState = false;
|
||||
loadMoreComments(assetId, comments, loadMore, parentId);
|
||||
loadMoreComments(assetId, comments, loadMore, parentId, replyCount);
|
||||
}}>
|
||||
{topLevel ? lang.t('viewMoreComments') : this.replyCountFormat(replyCount)}
|
||||
</Button>
|
||||
|
||||
@@ -7,9 +7,9 @@ const onLoadMoreClick = ({loadMore, commentCount, firstCommentDate, assetId, upd
|
||||
e.preventDefault();
|
||||
updateCountCache(assetId, commentCount);
|
||||
loadMore({
|
||||
asset_id: assetId,
|
||||
limit: 500,
|
||||
cursor: firstCommentDate,
|
||||
assetId,
|
||||
sort: 'CHRONOLOGICAL'
|
||||
}, true);
|
||||
};
|
||||
@@ -17,10 +17,10 @@ const onLoadMoreClick = ({loadMore, commentCount, firstCommentDate, assetId, upd
|
||||
const NewCount = (props) => {
|
||||
const newComments = props.commentCount - props.countCache;
|
||||
|
||||
return <div className='coral-new-comments'>
|
||||
return <div className='coral-new-comments coral-load-more'>
|
||||
{
|
||||
props.countCache && newComments > 0 ?
|
||||
<button onClick={onLoadMoreClick(props)} className='coral-load-more'>
|
||||
<button onClick={onLoadMoreClick(props)}>
|
||||
{newComments === 1
|
||||
? lang.t('newCount', newComments, lang.t('comment'))
|
||||
: lang.t('newCount', newComments, lang.t('comments'))}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React, {PropTypes} from 'react';
|
||||
import Comment from './Comment';
|
||||
import {NEW_COMMENT_COUNT_POLL_INTERVAL} from 'coral-framework/constants/comments';
|
||||
import IgnoredCommentTombstone from './IgnoredCommentTombstone';
|
||||
|
||||
class Stream extends React.Component {
|
||||
|
||||
@@ -15,11 +15,20 @@ class Stream extends React.Component {
|
||||
id: PropTypes.string
|
||||
}),
|
||||
|
||||
charCountEnable: PropTypes.bool.isRequired,
|
||||
maxCharCount: PropTypes.number,
|
||||
|
||||
// dispatch action to add a tag to a comment
|
||||
addCommentTag: PropTypes.func,
|
||||
|
||||
// dispatch action to remove a tag from a comment
|
||||
removeCommentTag: PropTypes.func,
|
||||
|
||||
// dispatch action to ignore another user
|
||||
ignoreUser: React.PropTypes.func,
|
||||
|
||||
// list of user ids that should be rendered as ignored
|
||||
ignoredUsers: React.PropTypes.arrayOf(React.PropTypes.string)
|
||||
}
|
||||
|
||||
constructor(props) {
|
||||
@@ -27,26 +36,6 @@ class Stream extends React.Component {
|
||||
this.state = {activeReplyBox: '', countPoll: null};
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
const {asset, getCounts, updateCountCache} = this.props;
|
||||
|
||||
updateCountCache(asset.id, asset.commentCount);
|
||||
|
||||
// Note: Apollo's built-in polling doesn't work with fetchMore queries, so a
|
||||
// setInterval is being used instead.
|
||||
this.setState({
|
||||
countPoll: setInterval(() => getCounts({
|
||||
asset_id: asset.id,
|
||||
limit: asset.comments.length,
|
||||
sort: 'REVERSE_CHRONOLOGICAL'
|
||||
}), NEW_COMMENT_COUNT_POLL_INTERVAL),
|
||||
});
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
clearInterval(this.state.countPoll);
|
||||
}
|
||||
|
||||
render () {
|
||||
const {
|
||||
comments,
|
||||
@@ -62,33 +51,48 @@ class Stream extends React.Component {
|
||||
deleteAction,
|
||||
showSignInDialog,
|
||||
addCommentTag,
|
||||
removeCommentTag
|
||||
removeCommentTag,
|
||||
pluginProps,
|
||||
ignoreUser,
|
||||
ignoredUsers,
|
||||
charCountEnable,
|
||||
maxCharCount,
|
||||
} = this.props;
|
||||
|
||||
const commentIsIgnored = (comment) => ignoredUsers && ignoredUsers.includes(comment.user.id);
|
||||
return (
|
||||
<div id='stream'>
|
||||
{
|
||||
comments.map(comment =>
|
||||
<Comment
|
||||
disableReply={!open}
|
||||
setActiveReplyBox={this.props.setActiveReplyBox}
|
||||
activeReplyBox={this.props.activeReplyBox}
|
||||
addNotification={addNotification}
|
||||
depth={0}
|
||||
postItem={postItem}
|
||||
asset={asset}
|
||||
currentUser={currentUser}
|
||||
postLike={postLike}
|
||||
postFlag={postFlag}
|
||||
postDontAgree={postDontAgree}
|
||||
addCommentTag={addCommentTag}
|
||||
removeCommentTag={removeCommentTag}
|
||||
loadMore={loadMore}
|
||||
deleteAction={deleteAction}
|
||||
showSignInDialog={showSignInDialog}
|
||||
key={comment.id}
|
||||
reactKey={comment.id}
|
||||
comment={comment} />
|
||||
commentIsIgnored(comment)
|
||||
? <IgnoredCommentTombstone
|
||||
key={comment.id}
|
||||
/>
|
||||
: <Comment
|
||||
disableReply={!open}
|
||||
setActiveReplyBox={this.props.setActiveReplyBox}
|
||||
activeReplyBox={this.props.activeReplyBox}
|
||||
addNotification={addNotification}
|
||||
depth={0}
|
||||
postItem={postItem}
|
||||
asset={asset}
|
||||
currentUser={currentUser}
|
||||
postLike={postLike}
|
||||
postFlag={postFlag}
|
||||
postDontAgree={postDontAgree}
|
||||
addCommentTag={addCommentTag}
|
||||
removeCommentTag={removeCommentTag}
|
||||
ignoreUser={ignoreUser}
|
||||
commentIsIgnored={commentIsIgnored}
|
||||
loadMore={loadMore}
|
||||
deleteAction={deleteAction}
|
||||
showSignInDialog={showSignInDialog}
|
||||
key={comment.id}
|
||||
reactKey={comment.id}
|
||||
comment={comment}
|
||||
maxCharCount={maxCharCount}
|
||||
charCountEnable={charCountEnable}
|
||||
pluginProps={pluginProps}
|
||||
/>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
.Toggleable:focus {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
/**
|
||||
* Up/Down Chevrons for the top right menu
|
||||
*/
|
||||
.chevron {
|
||||
}
|
||||
.chevron:before {
|
||||
content: '⌃';
|
||||
display: inline-block;
|
||||
position: relative;
|
||||
top: 0.25em;
|
||||
}
|
||||
|
||||
/* Down Arrow */
|
||||
.chevron.down:before {
|
||||
display: inline-block;
|
||||
position: relative;
|
||||
transform: rotate(180deg);
|
||||
top: 0;
|
||||
/*top: -0.25em;*/
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
import React, {PropTypes} from 'react';
|
||||
import classnames from 'classnames';
|
||||
|
||||
import {IgnoreUserWizard} from './IgnoreUserWizard';
|
||||
import styles from './TopRightMenu.css';
|
||||
|
||||
// TopRightMenu appears as a dropdown in the top right of the comment.
|
||||
// when you click the down cehvron, it expands and shows IgnoreUserWizard
|
||||
// when you click 'cancel' in the wizard, it closes the menu
|
||||
export class TopRightMenu extends React.Component {
|
||||
static propTypes = {
|
||||
|
||||
// comment on which this menu appears
|
||||
comment: PropTypes.shape({
|
||||
user: PropTypes.shape({
|
||||
id: PropTypes.string.isRequired,
|
||||
name: PropTypes.string.isRequired
|
||||
}).isRequired
|
||||
}).isRequired,
|
||||
ignoreUser: PropTypes.func,
|
||||
|
||||
// show notification to the user (e.g. for errors)
|
||||
addNotification: PropTypes.func.isRequired,
|
||||
}
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.state = {
|
||||
timesReset: 0
|
||||
};
|
||||
}
|
||||
render() {
|
||||
const {comment, ignoreUser, addNotification} = this.props;
|
||||
|
||||
// timesReset is used as Toggleable key so it re-renders on reset (closing the toggleable)
|
||||
const reset = () => this.setState({timesReset: this.state.timesReset + 1});
|
||||
const ignoreUserAndCloseMenuAndNotifyOnError = async ({id}) => {
|
||||
|
||||
// close menu
|
||||
reset();
|
||||
|
||||
// ignore user
|
||||
try {
|
||||
await ignoreUser({id});
|
||||
} catch (error) {
|
||||
addNotification('error', 'Failed to ignore user');
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
return (
|
||||
<Toggleable key={this.state.timesReset}>
|
||||
<div style={{position: 'absolute', right: 0, zIndex: 1}}>
|
||||
<IgnoreUserWizard
|
||||
user={comment.user}
|
||||
cancel={reset}
|
||||
ignoreUser={ignoreUserAndCloseMenuAndNotifyOnError}
|
||||
/>
|
||||
</div>
|
||||
</Toggleable>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const upArrow = <span className={classnames(styles.chevron, styles.up)}></span>;
|
||||
const downArrow = <span className={classnames(styles.chevron, styles.down)}></span>;
|
||||
class Toggleable extends React.Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.toggle = this.toggle.bind(this);
|
||||
this.close = this.close.bind(this);
|
||||
this.state = {
|
||||
isOpen: false
|
||||
};
|
||||
}
|
||||
toggle() {
|
||||
this.setState({isOpen: ! this.state.isOpen});
|
||||
}
|
||||
close() {
|
||||
this.setState({isOpen: false});
|
||||
}
|
||||
render() {
|
||||
const {children} = this.props;
|
||||
const {isOpen} = this.state;
|
||||
return (
|
||||
|
||||
// /*onBlur={ this.close } */
|
||||
<span className={styles.Toggleable} tabIndex="0" >
|
||||
<span className={styles.toggler}
|
||||
onClick={this.toggle}>{isOpen ? upArrow : downArrow}</span>
|
||||
{isOpen ? children : null}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,13 +3,15 @@ import {render} from 'react-dom';
|
||||
import {ApolloProvider} from 'react-apollo';
|
||||
|
||||
import {client} from 'coral-framework/services/client';
|
||||
import store from 'coral-framework/services/store';
|
||||
import localStore from 'coral-framework/services/store';
|
||||
|
||||
import Embed from './Embed';
|
||||
import AppRouter from './AppRouter';
|
||||
|
||||
const store = (window.opener && window.opener.coralStore) ? window.opener.coralStore : localStore;
|
||||
|
||||
render(
|
||||
<ApolloProvider client={client} store={store}>
|
||||
<Embed />
|
||||
<AppRouter />
|
||||
</ApolloProvider>
|
||||
, document.querySelector('#coralStream')
|
||||
);
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
* {
|
||||
font-weight: inherit;
|
||||
font-family: inherit;
|
||||
font-style: inherit;
|
||||
font-size: 100%;
|
||||
}
|
||||
|
||||
html, body {
|
||||
@@ -11,11 +9,12 @@ html, body {
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: 'Lato', sans-serif;
|
||||
font-family: Helvetica, 'Helvetica Neue', Verdana, sans-serif;
|
||||
width: 100%;
|
||||
font-size: 14px;
|
||||
margin: 0px;
|
||||
padding: 0px 0px 50px 0px;
|
||||
height: auto !important;
|
||||
}
|
||||
|
||||
.expandForSignin {
|
||||
@@ -79,8 +78,8 @@ hr {
|
||||
.coral-plugin-infobox-info {
|
||||
top: 0;
|
||||
border: 0;
|
||||
background: rgb(35,118,216);
|
||||
color: white;
|
||||
background: #DEEDFF;
|
||||
color: #2a2a2a;
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
padding: 10px;
|
||||
@@ -91,6 +90,24 @@ hr {
|
||||
}
|
||||
|
||||
|
||||
.coral-plugin-infobox-info em{
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.coral-plugin-infobox-info strong{
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.coral-plugin-infobox-info blockquote{
|
||||
border-left: solid 2px #2a2a2a;
|
||||
padding-left: 10px;
|
||||
}
|
||||
|
||||
|
||||
.coral-plugin-infobox-info a{
|
||||
color: #2a2a2a;
|
||||
}
|
||||
|
||||
.commentStream .material-icons {
|
||||
vertical-align: middle;
|
||||
width: 1em;
|
||||
@@ -110,15 +127,15 @@ hr {
|
||||
margin-bottom: 0px;
|
||||
font-weight: bold;
|
||||
font-size: 14px;
|
||||
display: block;
|
||||
overflow: hidden;
|
||||
height: 50px;
|
||||
min-height: 50px;
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.coral-plugin-questionbox-icon.bubble{
|
||||
position: absolute;
|
||||
top: 11px;
|
||||
left: 15px;
|
||||
left: 10px;
|
||||
color: #949393;
|
||||
font-size: 20px;
|
||||
z-index: 0;
|
||||
@@ -127,7 +144,7 @@ hr {
|
||||
.coral-plugin-questionbox-icon.person{
|
||||
z-index: 2;
|
||||
top: 20px;
|
||||
left: 20px;
|
||||
left: 15px;
|
||||
position: absolute;
|
||||
font-size: 24px;
|
||||
color: white;
|
||||
@@ -142,12 +159,19 @@ hr {
|
||||
margin-left: 0px !important;
|
||||
margin-right: 10px;
|
||||
display: inline-block;
|
||||
width: 15px;
|
||||
height: 100%;
|
||||
padding: 3px 20px;
|
||||
width: 10px;
|
||||
min-height: 100%;
|
||||
padding: 5px 20px;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.coral-plugin-questionbox-content {
|
||||
padding: 5px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.hidden {
|
||||
visibility: hidden;
|
||||
display: none;
|
||||
@@ -163,6 +187,7 @@ hr {
|
||||
/* Comment Box Styles */
|
||||
.coral-plugin-commentbox-container {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.coral-plugin-commentbox-textarea {
|
||||
@@ -170,7 +195,8 @@ hr {
|
||||
padding: 5px;
|
||||
min-height: 100px;
|
||||
margin-top: 10px;
|
||||
font-size: 14px;
|
||||
font-size: 16px;
|
||||
border: 1px solid #ccc;
|
||||
}
|
||||
|
||||
.coral-plugin-commentbox-button-container {
|
||||
@@ -254,13 +280,13 @@ hr {
|
||||
.commentActionsRight, .replyActionsRight {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
width: 50%;
|
||||
width: 30%;
|
||||
}
|
||||
.commentActionsLeft, .replyActionsLeft {
|
||||
display: flex;
|
||||
justify-content: flex-start;
|
||||
float: left;
|
||||
width: 50%;
|
||||
width: 70%;
|
||||
}
|
||||
|
||||
.comment__action-container .material-icons {
|
||||
@@ -306,10 +332,7 @@ button.comment__action-button[disabled],
|
||||
display: none;
|
||||
background-color: white;
|
||||
border: 1px solid black;
|
||||
width: calc(100% - 15px);
|
||||
position: absolute;
|
||||
top: 70px;
|
||||
right: 0;
|
||||
padding: 5px;
|
||||
}
|
||||
|
||||
@@ -375,6 +398,7 @@ button.comment__action-button[disabled],
|
||||
margin-left: 20px;
|
||||
margin-top: 5px;
|
||||
width: 75%;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
/* Close comments */
|
||||
@@ -454,3 +478,19 @@ button.comment__action-button[disabled],
|
||||
.coral-load-more-replies button.coral-load-more, .coral-new-comments button.coral-load-more{
|
||||
width: initial;
|
||||
}
|
||||
|
||||
@media (min-device-width : 300px) and (max-device-width : 420px) {
|
||||
.commentActionsLeft.comment__action-container .coral-plugin-likes-button-text,
|
||||
.commentActionsLeft.comment__action-container > div span {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.commentActionsLeft.comment__action-container .coral-plugin-replies-reply-button {
|
||||
visibility: collapse;
|
||||
margin-left: -30px;
|
||||
}
|
||||
|
||||
.commentActionsLeft.comment__action-container .coral-plugin-replies-reply-button .coral-plugin-replies-icon {
|
||||
visibility: visible;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import pym from 'pym.js';
|
||||
|
||||
import {stringify} from 'querystring';
|
||||
|
||||
// TODO: Styles should live in a separate file
|
||||
const snackbarStyles = {
|
||||
position: 'fixed',
|
||||
cursor: 'default',
|
||||
@@ -18,7 +21,8 @@ const snackbarStyles = {
|
||||
opacity: 0,
|
||||
transform: 'translate(-50%, 20px)',
|
||||
bottom: 0,
|
||||
boxSizing: 'border-box'
|
||||
boxSizing: 'border-box',
|
||||
fontFamily: 'Helvetica, \'Helvetica Neue\', Verdana, sans-serif'
|
||||
};
|
||||
|
||||
// This function should return value of window.Coral
|
||||
@@ -26,30 +30,21 @@ const Coral = {};
|
||||
const Talk = Coral.Talk = {};
|
||||
|
||||
// build the URL to load in the pym iframe
|
||||
function buildStreamIframeUrl(talkBaseUrl, asset_url, comment, asset_id) {
|
||||
let iframeArray = [
|
||||
function buildStreamIframeUrl(talkBaseUrl, query) {
|
||||
let url = [
|
||||
talkBaseUrl,
|
||||
(talkBaseUrl.match(/\/$/) ? '' : '/'), // make sure no double-'/' if opts.talk already ends with '/'
|
||||
'embed/stream?asset_url=',
|
||||
encodeURIComponent(asset_url)
|
||||
];
|
||||
'embed/stream?'
|
||||
].join('');
|
||||
|
||||
if (comment) {
|
||||
iframeArray.push('&comment_id=');
|
||||
iframeArray.push(encodeURIComponent(comment));
|
||||
}
|
||||
url += stringify(query);
|
||||
|
||||
if (asset_id) {
|
||||
iframeArray.push('&asset_id=');
|
||||
iframeArray.push(encodeURIComponent(asset_id));
|
||||
}
|
||||
|
||||
return iframeArray.join('');
|
||||
return url;
|
||||
}
|
||||
|
||||
// Set up postMessage listeners/handlers on the pymParent
|
||||
// e.g. to resize the iframe, and navigate the host page
|
||||
function configurePymParent(pymParent, asset_url) {
|
||||
function configurePymParent(pymParent) {
|
||||
let notificationOffset = 200;
|
||||
let ready = false;
|
||||
let cachedHeight;
|
||||
@@ -62,6 +57,10 @@ function configurePymParent(pymParent, asset_url) {
|
||||
|
||||
window.document.body.appendChild(snackbar);
|
||||
|
||||
// Workaround: IOS Safari ignores `width` but respects `min-width` value.
|
||||
pymParent.el.firstChild.style.width = '1px';
|
||||
pymParent.el.firstChild.style.minWidth = '100%';
|
||||
|
||||
// Resize parent iframe height when child height changes
|
||||
pymParent.onMessage('height', function(height) {
|
||||
if (height !== cachedHeight) {
|
||||
@@ -74,6 +73,15 @@ function configurePymParent(pymParent, asset_url) {
|
||||
snackbar.style.opacity = 0;
|
||||
});
|
||||
|
||||
// remove the permalink comment id from the hash
|
||||
pymParent.onMessage('coral-view-all-comments', function () {
|
||||
window.history.replaceState(
|
||||
{},
|
||||
document.title,
|
||||
location.origin + location.pathname + location.search
|
||||
);
|
||||
});
|
||||
|
||||
pymParent.onMessage('coral-alert', function (message) {
|
||||
const [type, text] = message.split('|');
|
||||
snackbar.style.transform = 'translate(-50%, 20px)';
|
||||
@@ -108,8 +116,8 @@ function configurePymParent(pymParent, asset_url) {
|
||||
if (ready) {
|
||||
window.clearInterval(interval);
|
||||
|
||||
// @todo - It's weird to me that this is sent here in addition to the iframe URL. Could it just be in one place?
|
||||
pymParent.sendMessage('DOMContentLoaded', asset_url);
|
||||
// TODO: It's weird to me that this is sent here
|
||||
pymParent.sendMessage('DOMContentLoaded');
|
||||
}
|
||||
}, 100);
|
||||
});
|
||||
@@ -157,42 +165,37 @@ Talk.render = function (el, opts) {
|
||||
}
|
||||
opts = opts || {};
|
||||
|
||||
// @todo infer this URL without explicit user input (if possible, may have to be added at build/render time of this script)
|
||||
// TODO: infer this URL without explicit user input (if possible, may have to be added at build/render time of this script)
|
||||
if (!opts.talk) {
|
||||
throw new Error('Coral.Talk.render() expects opts.talk as the Talk Base URL');
|
||||
}
|
||||
|
||||
// ensure el has an id, as pym can't directly accept the HTMLElement
|
||||
// Ensure el has an id, as pym can't directly accept the HTMLElement.
|
||||
if (!el.id) {
|
||||
el.id = `_${Math.random()}`;
|
||||
}
|
||||
|
||||
let asset_url = opts.asset_url;
|
||||
if (!asset_url) {
|
||||
// Compose the query to send down to the Talk API so it knows what to load.
|
||||
let query = {};
|
||||
|
||||
query.comment_id = window.location.hash.slice(1);
|
||||
query.asset_id = opts.asset_id;
|
||||
|
||||
query.asset_url = opts.asset_url;
|
||||
if (!query.asset_url) {
|
||||
try {
|
||||
asset_url = document.querySelector('link[rel="canonical"]').href;
|
||||
query.asset_url = document.querySelector('link[rel="canonical"]').href;
|
||||
} catch (e) {
|
||||
console.warn('This page does not include a canonical link tag. Talk has inferred this asset_url from the window object. Query params have been stripped, which may cause a single thread to be present across multiple pages.');
|
||||
asset_url = window.location.origin + window.location.pathname;
|
||||
query.asset_url = window.location.origin + window.location.pathname;
|
||||
}
|
||||
}
|
||||
|
||||
let comment = window.location.hash.slice(1);
|
||||
|
||||
let query = {
|
||||
configurePymParent(new pym.Parent(el.id, buildStreamIframeUrl(opts.talk, query), {
|
||||
title: opts.title,
|
||||
asset_url: asset_url,
|
||||
id: `${el.id}_iframe`,
|
||||
name: `${el.id}_iframe`
|
||||
};
|
||||
|
||||
if (opts.asset_id && opts.asset_id.length > 0) {
|
||||
query.asset_id = opts.asset_id;
|
||||
}
|
||||
|
||||
let pymParent = new pym.Parent(el.id, buildStreamIframeUrl(opts.talk, asset_url, comment), query);
|
||||
|
||||
configurePymParent(pymParent, asset_url);
|
||||
}));
|
||||
};
|
||||
|
||||
export default Coral;
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import * as actions from '../constants/asset';
|
||||
import coralApi from '../helpers/response';
|
||||
import {addNotification} from '../actions/notification';
|
||||
import {pym} from 'coral-framework';
|
||||
|
||||
import I18n from '../../coral-framework/modules/i18n/i18n';
|
||||
import I18n from 'coral-framework/modules/i18n/i18n';
|
||||
import translations from './../translations';
|
||||
const lang = new I18n(translations);
|
||||
|
||||
@@ -49,3 +50,37 @@ export const updateOpenStatus = status => dispatch => {
|
||||
dispatch(updateOpenStream({closedAt: new Date().getTime()}));
|
||||
}
|
||||
};
|
||||
|
||||
function removeParam(key, sourceURL) {
|
||||
let rtn = sourceURL.split('?')[0];
|
||||
let param;
|
||||
let params_arr = [];
|
||||
let queryString = (sourceURL.indexOf('?') !== -1) ? sourceURL.split('?')[1] : '';
|
||||
if (queryString !== '') {
|
||||
params_arr = queryString.split('&');
|
||||
for (let i = params_arr.length - 1; i >= 0; i -= 1) {
|
||||
param = params_arr[i].split('=')[0];
|
||||
if (param === key) {
|
||||
params_arr.splice(i, 1);
|
||||
}
|
||||
}
|
||||
rtn = `${rtn}?${params_arr.join('&')}`;
|
||||
}
|
||||
return rtn;
|
||||
}
|
||||
|
||||
export const viewAllComments = () => {
|
||||
|
||||
// remove the comment_id url param
|
||||
const modifiedUrl = removeParam('comment_id', location.href);
|
||||
try {
|
||||
|
||||
// "window" here refers to the embedded iframe
|
||||
window.history.replaceState({}, document.title, modifiedUrl);
|
||||
|
||||
// also change the parent url
|
||||
pym.sendMessage('coral-view-all-comments');
|
||||
} catch (e) { /* not sure if we're worried about old browsers */ }
|
||||
|
||||
return {type: actions.VIEW_ALL_COMMENTS};
|
||||
};
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import {gql} from 'react-apollo';
|
||||
import client from 'coral-framework/services/client';
|
||||
import I18n from '../../coral-framework/modules/i18n/i18n';
|
||||
import translations from './../translations';
|
||||
const lang = new I18n(translations);
|
||||
@@ -5,9 +7,48 @@ import * as actions from '../constants/auth';
|
||||
import coralApi, {base} from '../helpers/response';
|
||||
import {pym} from 'coral-framework';
|
||||
|
||||
const ME_QUERY = gql`
|
||||
query Me {
|
||||
me {
|
||||
status
|
||||
comments {
|
||||
id
|
||||
body
|
||||
asset {
|
||||
id
|
||||
title
|
||||
url
|
||||
}
|
||||
created_at
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
function fetchMe() {
|
||||
return client.query({
|
||||
fetchPolicy: 'network-only',
|
||||
query: ME_QUERY});
|
||||
}
|
||||
|
||||
// Dialog Actions
|
||||
export const showSignInDialog = (offset = 0) => ({type: actions.SHOW_SIGNIN_DIALOG, offset});
|
||||
export const hideSignInDialog = () => ({type: actions.HIDE_SIGNIN_DIALOG});
|
||||
export const showSignInDialog = () => dispatch => {
|
||||
const signInPopUp = window.open(
|
||||
'/embed/stream/login',
|
||||
'Login',
|
||||
'menubar=0,resizable=0,width=500,height=550,top=200,left=500'
|
||||
);
|
||||
|
||||
signInPopUp.onbeforeunload = () => {
|
||||
dispatch(checkLogin());
|
||||
fetchMe();
|
||||
};
|
||||
dispatch({type: actions.SHOW_SIGNIN_DIALOG});
|
||||
};
|
||||
export const hideSignInDialog = () => dispatch => {
|
||||
dispatch({type: actions.HIDE_SIGNIN_DIALOG});
|
||||
window.close();
|
||||
};
|
||||
|
||||
export const createUsernameRequest = () => ({type: actions.CREATE_USERNAME_REQUEST});
|
||||
export const showCreateUsernameDialog = () => ({type: actions.SHOW_CREATEUSERNAME_DIALOG});
|
||||
@@ -31,28 +72,39 @@ export const createUsername = (userId, formData) => dispatch => {
|
||||
});
|
||||
};
|
||||
|
||||
export const changeView = view => dispatch =>
|
||||
export const changeView = view => dispatch => {
|
||||
dispatch({
|
||||
type: actions.CHANGE_VIEW,
|
||||
view
|
||||
});
|
||||
|
||||
switch(view) {
|
||||
case 'SIGNUP':
|
||||
window.resizeTo(500, 800);
|
||||
break;
|
||||
case 'FORGOT':
|
||||
window.resizeTo(500, 400);
|
||||
break;
|
||||
default:
|
||||
window.resizeTo(500, 550);
|
||||
}
|
||||
};
|
||||
|
||||
export const cleanState = () => ({type: actions.CLEAN_STATE});
|
||||
|
||||
// Sign In Actions
|
||||
|
||||
const signInRequest = () => ({type: actions.FETCH_SIGNIN_REQUEST});
|
||||
const signInSuccess = (user, isAdmin) => ({type: actions.FETCH_SIGNIN_SUCCESS, user, isAdmin});
|
||||
|
||||
// TODO: revisit login redux flow.
|
||||
// const signInSuccess = (user, isAdmin) => ({type: actions.FETCH_SIGNIN_SUCCESS, user, isAdmin});
|
||||
//
|
||||
const signInFailure = error => ({type: actions.FETCH_SIGNIN_FAILURE, error});
|
||||
|
||||
export const fetchSignIn = (formData) => (dispatch) => {
|
||||
dispatch(signInRequest());
|
||||
return coralApi('/auth/local', {method: 'POST', body: formData})
|
||||
.then(({user}) => {
|
||||
const isAdmin = !!user && !!user.roles.filter(i => i === 'ADMIN').length;
|
||||
dispatch(signInSuccess(user, isAdmin));
|
||||
dispatch(hideSignInDialog());
|
||||
})
|
||||
.then(() => dispatch(hideSignInDialog()))
|
||||
.catch(error => {
|
||||
if (error.metadata) {
|
||||
|
||||
@@ -104,6 +156,7 @@ export const facebookCallback = (err, data) => dispatch => {
|
||||
dispatch(signInFacebookSuccess(user));
|
||||
dispatch(hideSignInDialog());
|
||||
dispatch(showCreateUsernameDialog());
|
||||
dispatch(hideSignInDialog());
|
||||
} catch (err) {
|
||||
dispatch(signInFacebookFailure(err));
|
||||
return;
|
||||
@@ -151,7 +204,10 @@ const logOutFailure = () => ({type: actions.LOGOUT_FAILURE});
|
||||
export const logout = () => dispatch => {
|
||||
dispatch(logOutRequest());
|
||||
return coralApi('/auth', {method: 'DELETE'})
|
||||
.then(() => dispatch(logOutSuccess()))
|
||||
.then(() => {
|
||||
dispatch(logOutSuccess());
|
||||
fetchMe();
|
||||
})
|
||||
.catch(error => dispatch(logOutFailure(error)));
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
import * as authActions from './auth';
|
||||
import * as assetActions from './asset';
|
||||
import * as notificationActions from './notification';
|
||||
|
||||
export default {
|
||||
authActions,
|
||||
assetActions,
|
||||
notificationActions,
|
||||
};
|
||||
@@ -1,9 +1,12 @@
|
||||
import {pym} from '../../coral-framework';
|
||||
import pym from '../services/PymConnection';
|
||||
import * as actions from '../constants/notification';
|
||||
|
||||
export const addNotification = (notifType, text) => {
|
||||
pym.sendMessage('coral-alert', `${notifType}|${text}`);
|
||||
return {type: actions.ADD_NOTIFICATION, notifType, text};
|
||||
};
|
||||
|
||||
export const clearNotification = () => {
|
||||
pym.sendMessage('coral-clear-notification');
|
||||
return {type: actions.CLEAR_NOTIFICATION};
|
||||
};
|
||||
|
||||
@@ -1,13 +1,21 @@
|
||||
import {addNotification} from '../actions/notification';
|
||||
import coralApi from '../helpers/response';
|
||||
import * as actions from '../constants/auth';
|
||||
|
||||
import I18n from 'coral-framework/modules/i18n/i18n';
|
||||
import translations from './../translations';
|
||||
const lang = new I18n(translations);
|
||||
|
||||
const editUsernameFailure = error => ({type: actions.EDIT_USERNAME_FAILURE, error});
|
||||
const editUsernameSuccess = () => ({type: actions.EDIT_USERNAME_SUCCESS});
|
||||
|
||||
export const editName = (username) => (dispatch) => {
|
||||
return coralApi('/account/username', {method: 'PUT', body: {username}})
|
||||
.then(() => {
|
||||
dispatch(editUsernameSuccess());
|
||||
dispatch(addNotification('success', lang.t('successNameUpdate')));
|
||||
})
|
||||
.catch(error => {
|
||||
dispatch(editUsernameFailure(lang.t(`error.${error.translation_key}`)));
|
||||
});
|
||||
};
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
.inline {
|
||||
display: inline-block;
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import React, {Component} from 'react';
|
||||
import {getSlotElements} from 'coral-framework/helpers/plugins';
|
||||
import styles from './Slot.css';
|
||||
|
||||
class Slot extends Component {
|
||||
render() {
|
||||
const {fill, inline = false, ...rest} = this.props;
|
||||
return (
|
||||
<div className={inline ? styles.inline : ''}>
|
||||
{getSlotElements(fill, rest)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Slot.propTypes = {
|
||||
fill: React.PropTypes.string
|
||||
};
|
||||
|
||||
export default Slot;
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user