mirror of
https://github.com/flarum/core.git
synced 2025-08-14 04:14:06 +02:00
Compare commits
401 Commits
dk/1236-us
...
mithril-2-
Author | SHA1 | Date | |
---|---|---|---|
|
dd17fa3342 | ||
|
db8755d5d9 | ||
|
5c2a233b74 | ||
|
4dd0361ccf | ||
|
ccd2b0ee0b | ||
|
b954e79ec8 | ||
|
bdc64f98a5 | ||
|
f9e5aa4193 | ||
|
801220cc10 | ||
|
bd52aaaa61 | ||
|
4a5d12626b | ||
|
1e4a7f3282 | ||
|
cbcb0429d0 | ||
|
14b12f8ae8 | ||
|
37d7068569 | ||
|
6e1a9193ac | ||
|
ea6aea409d | ||
|
d572200cfb | ||
|
e10ebf0489 | ||
|
b251ff0469 | ||
|
4a0947db4b | ||
|
32767d6321 | ||
|
27017b7181 | ||
|
bdd30ceecc | ||
|
abc98645a0 | ||
|
5d538adc33 | ||
|
6f11567f91 | ||
|
537e2690e8 | ||
|
0e715a8c40 | ||
|
383a7e559f | ||
|
aeaa9a4b73 | ||
|
85f8fc52b7 | ||
|
174f3aba90 | ||
|
47ce93d2fd | ||
|
24935eacaf | ||
|
0f99e7c015 | ||
|
691ae85e50 | ||
|
fb83f8c59c | ||
|
a6e6c54972 | ||
|
6b5bdb5c41 | ||
|
d7ef260c54 | ||
|
571ed8d8e5 | ||
|
42ad490096 | ||
|
09bead3ba2 | ||
|
abb896d430 | ||
|
6e7c86ac50 | ||
|
2673dd2ee3 | ||
|
cbd9c8dd4f | ||
|
29d995de45 | ||
|
a5b2768836 | ||
|
398951f282 | ||
|
07f04c7ba7 | ||
|
c8d5ca51bb | ||
|
60dbd3f26c | ||
|
e7f6e37799 | ||
|
27ffeb204e | ||
|
884a5cf3b9 | ||
|
e895ca738d | ||
|
f41aec1043 | ||
|
4c73d76668 | ||
|
f67194484b | ||
|
c8f47d519d | ||
|
e9b267a33a | ||
|
554c72c6db | ||
|
fb2b0a1d3e | ||
|
2ac18a39ed | ||
|
6ed3cb56d4 | ||
|
038744f092 | ||
|
2edbd4508a | ||
|
a6d4658dff | ||
|
955c8121d3 | ||
|
fd2dcd38d6 | ||
|
520c7e7d0f | ||
|
5ec9c52b04 | ||
|
0341e64057 | ||
|
f1a480d3d7 | ||
|
aceac88013 | ||
|
8cc9e18990 | ||
|
94d3bea53e | ||
|
9e6cfcf05a | ||
|
b8abd2522e | ||
|
376a00f24f | ||
|
43bfaa7400 | ||
|
7ba8a7122b | ||
|
88bc291c86 | ||
|
e699ada1cc | ||
|
2186584878 | ||
|
7beeae6269 | ||
|
e0e3d6ecae | ||
|
f8bc58fd1a | ||
|
764f50f469 | ||
|
e8485db484 | ||
|
f017d7afbe | ||
|
44c1e91f05 | ||
|
4b05e0073a | ||
|
3764abee51 | ||
|
015cedb29d | ||
|
32e9fa1f0b | ||
|
2bdf0d7096 | ||
|
b889fa1bbf | ||
|
76c3494f9b | ||
|
6bcb76b914 | ||
|
63984b43f9 | ||
|
389bc59745 | ||
|
2eb28ea396 | ||
|
169b0fbd9b | ||
|
6b178b8204 | ||
|
1d6e985107 | ||
|
f1fc0fecb7 | ||
|
94d8f7e726 | ||
|
b0891c42da | ||
|
7d5bebb70a | ||
|
b78db0268a | ||
|
fcda092558 | ||
|
f6dd87f72f | ||
|
d8d43d95e0 | ||
|
51c7b17305 | ||
|
b592ceb199 | ||
|
a083876b5f | ||
|
b0cbe277c2 | ||
|
b1d948becc | ||
|
a48568b17a | ||
|
e5cebd85ed | ||
|
fbd5f6245b | ||
|
3ce63bc035 | ||
|
bac5e7c94c | ||
|
97b0e61f61 | ||
|
604989be72 | ||
|
f9b1dfe499 | ||
|
f611a44a08 | ||
|
911f1fd5c9 | ||
|
0087b956ef | ||
|
99b119f1fa | ||
|
6be37fd376 | ||
|
46741f63fe | ||
|
cec00c0dd6 | ||
|
5f2c9da2f5 | ||
|
92de05e911 | ||
|
68aa6e26da | ||
|
8b7fa012c7 | ||
|
61231debd3 | ||
|
e771ec90c4 | ||
|
ab85b49845 | ||
|
04e5d5884f | ||
|
ddc1141106 | ||
|
a48cc19814 | ||
|
6d18b700ec | ||
|
b2bc427b3f | ||
|
1f94ffc842 | ||
|
3d91268493 | ||
|
81fd986881 | ||
|
d1b0030292 | ||
|
2c395a781c | ||
|
21861f231b | ||
|
537f5e833e | ||
|
4d45aaa9ae | ||
|
529f8e5f32 | ||
|
991d90bf4a | ||
|
38fed603f8 | ||
|
9691a6ab92 | ||
|
e8e4b64d7d | ||
|
35d76515d3 | ||
|
5d34124a02 | ||
|
16a6f82e8f | ||
|
8a0c241a8e | ||
|
a376c0e596 | ||
|
5ccf9d420e | ||
|
742f89f660 | ||
|
916cf4b546 | ||
|
f127e67fd4 | ||
|
3e79c3e3ff | ||
|
0172dfd79c | ||
|
eb627544fa | ||
|
73c0a90da7 | ||
|
ca0f8f2d72 | ||
|
090df13e7f | ||
|
d1a1277f88 | ||
|
31b2ab1b2b | ||
|
2590073a50 | ||
|
4402dc81ac | ||
|
f93a255a2f | ||
|
ddb0a9f1ce | ||
|
f44caf1600 | ||
|
89b6847710 | ||
|
2fb885175a | ||
|
7d9db2f4ae | ||
|
5d073941c9 | ||
|
f664fa5be7 | ||
|
aa4b58d7aa | ||
|
3120eb6f63 | ||
|
aac54a1d28 | ||
|
0d0841d019 | ||
|
83d2dbd290 | ||
|
7e5b40c532 | ||
|
6c9971eeba | ||
|
37a690833a | ||
|
fa2301b5c1 | ||
|
edca7b93ec | ||
|
095dce9a3e | ||
|
479f655bb3 | ||
|
41d6e91318 | ||
|
87414995b6 | ||
|
2c93b5f801 | ||
|
7498f5e506 | ||
|
79f5291f04 | ||
|
ed3b923f58 | ||
|
1ce06611ce | ||
|
27bacd779b | ||
|
02acacfdcb | ||
|
23d95a7566 | ||
|
43164df79e | ||
|
46e704b27b | ||
|
e55867acb4 | ||
|
3596425bde | ||
|
b43452223f | ||
|
70697be8c0 | ||
|
c20ae678f5 | ||
|
30a61b8b42 | ||
|
824fe95346 | ||
|
8475d176e0 | ||
|
95f4dc771d | ||
|
68caf45f33 | ||
|
f72d118bec | ||
|
652d961907 | ||
|
71178245fc | ||
|
cfd1f01299 | ||
|
674f55e91d | ||
|
f897b58f29 | ||
|
5c49b71c02 | ||
|
b9ba5b63f1 | ||
|
6e88dfb2cb | ||
|
c899e11070 | ||
|
784b5cc03c | ||
|
c17d7cd23f | ||
|
4af34265cc | ||
|
1616d8f1c7 | ||
|
57b85f501a | ||
|
91609b8a71 | ||
|
9d8b466d57 | ||
|
02154d05e5 | ||
|
a454b185e9 | ||
|
84c5248872 | ||
|
2a784009fb | ||
|
070865f825 | ||
|
9615fd3e39 | ||
|
dde9c9c51b | ||
|
6547290472 | ||
|
74f6a3e6ce | ||
|
98740472a8 | ||
|
1e9825de4f | ||
|
d14ca79dfa | ||
|
5cd5f27769 | ||
|
d05b63eddd | ||
|
48c7354c08 | ||
|
0fe635d32c | ||
|
2c2a42030a | ||
|
37d2902cd1 | ||
|
66c2f6b76a | ||
|
4d68636544 | ||
|
73891b751b | ||
|
fe011bf285 | ||
|
524dde31d4 | ||
|
b69fc01ab3 | ||
|
20e69e6351 | ||
|
d097e7ed4b | ||
|
63e07b2044 | ||
|
60c3f23667 | ||
|
9793c10610 | ||
|
29af3e6d8b | ||
|
703c3442da | ||
|
e0d3a8c733 | ||
|
527748ff66 | ||
|
eb440bb9b6 | ||
|
d71f77d592 | ||
|
8b891abb2b | ||
|
5f0dcc71ba | ||
|
4fcafe3b2f | ||
|
29065e1ee9 | ||
|
2b39bd7a0d | ||
|
94fe3236d7 | ||
|
3b6e5a0caf | ||
|
9ac8f1543a | ||
|
439e3a5a9a | ||
|
883e1a9d6a | ||
|
2ac2edbbad | ||
|
18148141c3 | ||
|
10f4223028 | ||
|
e10220ae47 | ||
|
dcd14821c2 | ||
|
edeaa5855c | ||
|
cc91244f1a | ||
|
5faab8bdbd | ||
|
99d79e7571 | ||
|
5606eae0f1 | ||
|
62cb71d4e1 | ||
|
e28ba4acff | ||
|
5b3914535d | ||
|
44975bc606 | ||
|
f7931e8a30 | ||
|
3ba655b2f9 | ||
|
735ecab446 | ||
|
64d4eb8c4c | ||
|
e4f1a397d6 | ||
|
5a244dcfd2 | ||
|
b60309284c | ||
|
392fe98c02 | ||
|
873f489fec | ||
|
9d8374208f | ||
|
07551b4890 | ||
|
b2c147c147 | ||
|
5bf5bd36ee | ||
|
4435ff193a | ||
|
5c30f8fa67 | ||
|
fa0ff204dd | ||
|
872e3bdc92 | ||
|
79f9012694 | ||
|
633cc14d09 | ||
|
c6e85ef330 | ||
|
3f8432a589 | ||
|
96c95f2b6a | ||
|
8e3e8826f9 | ||
|
384edfa52b | ||
|
f939d164b7 | ||
|
ebbef75cfb | ||
|
2caa5cf19c | ||
|
fe718a1490 | ||
|
beb03b7771 | ||
|
97186e6086 | ||
|
47f3ee0ce2 | ||
|
a9eb14889e | ||
|
5e5a5294c3 | ||
|
c39b6a6d2f | ||
|
22bf03d872 | ||
|
a2f3534bf7 | ||
|
6953d93c6d | ||
|
f9c9b5d5e4 | ||
|
8a73cc522e | ||
|
db83003eb5 | ||
|
4dc4dc624e | ||
|
ad42058a8a | ||
|
5e465f6051 | ||
|
62a2e8463d | ||
|
0098c64ebf | ||
|
2b5939d538 | ||
|
2431df5602 | ||
|
264ff67304 | ||
|
c08a56e9d8 | ||
|
4ee6d6fd88 | ||
|
9c09fe8465 | ||
|
b46d5e67a3 | ||
|
7fd23ff950 | ||
|
e4077ab4ad | ||
|
3b39c212e0 | ||
|
bca833d3f1 | ||
|
451a557532 | ||
|
eaac78650f | ||
|
2b3dec2be1 | ||
|
37ebeb5705 | ||
|
71abac0323 | ||
|
7e3d71a0a0 | ||
|
b5e891df30 | ||
|
3117d2ad7a | ||
|
1ce0b926b6 | ||
|
24b16f9d7c | ||
|
bd40353bcc | ||
|
455327cca1 | ||
|
20baa93ca7 | ||
|
4f34e326ef | ||
|
521cefbc2d | ||
|
dc738d68dc | ||
|
286af7084b | ||
|
4869baea74 | ||
|
24a48310ff | ||
|
bdb759c558 | ||
|
36eb5cc5fb | ||
|
d189272473 | ||
|
7d48c24dda | ||
|
5786f1a10b | ||
|
b4421e1cce | ||
|
359b4ab5a3 | ||
|
8a686911ff | ||
|
0b5a9a2fe6 | ||
|
50a9f7ce86 | ||
|
8dd5420405 | ||
|
640cc0989b | ||
|
44376cef61 | ||
|
4f181c84fc | ||
|
ea9d601338 | ||
|
aaebd3581f | ||
|
e2c416903e | ||
|
e81159249f | ||
|
d93cf4a574 | ||
|
a33fbbf814 | ||
|
0c645a6c15 | ||
|
b44b79eba9 | ||
|
93398b738b | ||
|
7816b61bfb | ||
|
7dc3a194c3 | ||
|
cea7824b57 | ||
|
088eb0c4f2 | ||
|
2ba67b021f |
@@ -37,25 +37,25 @@
|
||||
"require": {
|
||||
"php": ">=7.2",
|
||||
"axy/sourcemap": "^0.1.4",
|
||||
"components/font-awesome": "5.9.*",
|
||||
"components/font-awesome": "^5.14.0",
|
||||
"dflydev/fig-cookies": "^2.0.1",
|
||||
"doctrine/dbal": "^2.7",
|
||||
"franzl/whoops-middleware": "^0.4.0",
|
||||
"illuminate/bus": "5.8.*",
|
||||
"illuminate/cache": "5.8.*",
|
||||
"illuminate/config": "5.8.*",
|
||||
"illuminate/container": "5.8.*",
|
||||
"illuminate/contracts": "5.8.*",
|
||||
"illuminate/database": "5.8.*",
|
||||
"illuminate/events": "5.8.*",
|
||||
"illuminate/filesystem": "5.8.*",
|
||||
"illuminate/hashing": "5.8.*",
|
||||
"illuminate/mail": "5.8.*",
|
||||
"illuminate/queue": "5.8.*",
|
||||
"illuminate/session": "5.8.*",
|
||||
"illuminate/support": "5.8.*",
|
||||
"illuminate/validation": "5.8.*",
|
||||
"illuminate/view": "5.8.*",
|
||||
"illuminate/bus": "^6.0",
|
||||
"illuminate/cache": "^6.0",
|
||||
"illuminate/config": "^6.0",
|
||||
"illuminate/container": "^6.0",
|
||||
"illuminate/contracts": "^6.0",
|
||||
"illuminate/database": "^6.0",
|
||||
"illuminate/events": "^6.0",
|
||||
"illuminate/filesystem": "^6.0",
|
||||
"illuminate/hashing": "^6.0",
|
||||
"illuminate/mail": "^6.0",
|
||||
"illuminate/queue": "^6.0",
|
||||
"illuminate/session": "^6.0",
|
||||
"illuminate/support": "^6.0",
|
||||
"illuminate/validation": "^6.0",
|
||||
"illuminate/view": "^6.0",
|
||||
"intervention/image": "^2.5.0",
|
||||
"laminas/laminas-diactoros": "^1.8.4",
|
||||
"laminas/laminas-httphandlerrunner": "^1.0",
|
||||
@@ -66,6 +66,7 @@
|
||||
"middlewares/base-path-router": "^0.2.1",
|
||||
"middlewares/request-handler": "^1.2",
|
||||
"monolog/monolog": "^1.16.0",
|
||||
"nesbot/carbon": "^2.0",
|
||||
"nikic/fast-route": "^0.6",
|
||||
"psr/http-message": "^1.0",
|
||||
"psr/http-server-handler": "^1.0",
|
||||
|
6
js/dist/admin.js
vendored
6
js/dist/admin.js
vendored
File diff suppressed because one or more lines are too long
2
js/dist/admin.js.map
vendored
2
js/dist/admin.js.map
vendored
File diff suppressed because one or more lines are too long
8
js/dist/forum.js
vendored
8
js/dist/forum.js
vendored
File diff suppressed because one or more lines are too long
2
js/dist/forum.js.map
vendored
2
js/dist/forum.js.map
vendored
File diff suppressed because one or more lines are too long
222
js/package-lock.json
generated
222
js/package-lock.json
generated
@@ -289,6 +289,11 @@
|
||||
"@babel/types": "^7.0.0"
|
||||
}
|
||||
},
|
||||
"@babel/helper-validator-identifier": {
|
||||
"version": "7.10.3",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.10.3.tgz",
|
||||
"integrity": "sha512-bU8JvtlYpJSBPuj1VUmKpFGaDZuLxASky3LhaKj3bmpSTY6VWooSM8msk+Z0CZoErFye2tlABF6yDkT3FOPAXw=="
|
||||
},
|
||||
"@babel/helper-wrap-function": {
|
||||
"version": "7.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helper-wrap-function/-/helper-wrap-function-7.2.0.tgz",
|
||||
@@ -445,6 +450,21 @@
|
||||
"@babel/helper-plugin-utils": "^7.0.0"
|
||||
}
|
||||
},
|
||||
"@babel/plugin-syntax-typescript": {
|
||||
"version": "7.10.1",
|
||||
"resolved": "https://registry.npmjs.org/@babel/plugin-syntax-typescript/-/plugin-syntax-typescript-7.10.1.tgz",
|
||||
"integrity": "sha512-X/d8glkrAtra7CaQGMiGs/OGa6XgUzqPcBXCIGFCpCqnfGlT0Wfbzo/B89xHhnInTaItPK8LALblVXcUOEh95Q==",
|
||||
"requires": {
|
||||
"@babel/helper-plugin-utils": "^7.10.1"
|
||||
},
|
||||
"dependencies": {
|
||||
"@babel/helper-plugin-utils": {
|
||||
"version": "7.10.3",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.10.3.tgz",
|
||||
"integrity": "sha512-j/+j8NAWUTxOtx4LKHybpSClxHoq6I91DQ/mKgAXn5oNUPIUiGppjPIX3TDtJWPrdfP9Kfl7e4fgVMiQR9VE/g=="
|
||||
}
|
||||
}
|
||||
},
|
||||
"@babel/plugin-transform-arrow-functions": {
|
||||
"version": "7.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@babel/plugin-transform-arrow-functions/-/plugin-transform-arrow-functions-7.2.0.tgz",
|
||||
@@ -740,6 +760,159 @@
|
||||
"@babel/helper-plugin-utils": "^7.0.0"
|
||||
}
|
||||
},
|
||||
"@babel/plugin-transform-typescript": {
|
||||
"version": "7.10.3",
|
||||
"resolved": "https://registry.npmjs.org/@babel/plugin-transform-typescript/-/plugin-transform-typescript-7.10.3.tgz",
|
||||
"integrity": "sha512-qU9Lu7oQyh3PGMQncNjQm8RWkzw6LqsWZQlZPQMgrGt6s3YiBIaQ+3CQV/FA/icGS5XlSWZGwo/l8ErTyelS0Q==",
|
||||
"requires": {
|
||||
"@babel/helper-create-class-features-plugin": "^7.10.3",
|
||||
"@babel/helper-plugin-utils": "^7.10.3",
|
||||
"@babel/plugin-syntax-typescript": "^7.10.1"
|
||||
},
|
||||
"dependencies": {
|
||||
"@babel/code-frame": {
|
||||
"version": "7.10.3",
|
||||
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.10.3.tgz",
|
||||
"integrity": "sha512-fDx9eNW0qz0WkUeqL6tXEXzVlPh6Y5aCDEZesl0xBGA8ndRukX91Uk44ZqnkECp01NAZUdCAl+aiQNGi0k88Eg==",
|
||||
"requires": {
|
||||
"@babel/highlight": "^7.10.3"
|
||||
}
|
||||
},
|
||||
"@babel/generator": {
|
||||
"version": "7.10.3",
|
||||
"resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.10.3.tgz",
|
||||
"integrity": "sha512-drt8MUHbEqRzNR0xnF8nMehbY11b1SDkRw03PSNH/3Rb2Z35oxkddVSi3rcaak0YJQ86PCuE7Qx1jSFhbLNBMA==",
|
||||
"requires": {
|
||||
"@babel/types": "^7.10.3",
|
||||
"jsesc": "^2.5.1",
|
||||
"lodash": "^4.17.13",
|
||||
"source-map": "^0.5.0"
|
||||
}
|
||||
},
|
||||
"@babel/helper-create-class-features-plugin": {
|
||||
"version": "7.10.3",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helper-create-class-features-plugin/-/helper-create-class-features-plugin-7.10.3.tgz",
|
||||
"integrity": "sha512-iRT9VwqtdFmv7UheJWthGc/h2s7MqoweBF9RUj77NFZsg9VfISvBTum3k6coAhJ8RWv2tj3yUjA03HxPd0vfpQ==",
|
||||
"requires": {
|
||||
"@babel/helper-function-name": "^7.10.3",
|
||||
"@babel/helper-member-expression-to-functions": "^7.10.3",
|
||||
"@babel/helper-optimise-call-expression": "^7.10.3",
|
||||
"@babel/helper-plugin-utils": "^7.10.3",
|
||||
"@babel/helper-replace-supers": "^7.10.1",
|
||||
"@babel/helper-split-export-declaration": "^7.10.1"
|
||||
}
|
||||
},
|
||||
"@babel/helper-function-name": {
|
||||
"version": "7.10.3",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helper-function-name/-/helper-function-name-7.10.3.tgz",
|
||||
"integrity": "sha512-FvSj2aiOd8zbeqijjgqdMDSyxsGHaMt5Tr0XjQsGKHD3/1FP3wksjnLAWzxw7lvXiej8W1Jt47SKTZ6upQNiRw==",
|
||||
"requires": {
|
||||
"@babel/helper-get-function-arity": "^7.10.3",
|
||||
"@babel/template": "^7.10.3",
|
||||
"@babel/types": "^7.10.3"
|
||||
}
|
||||
},
|
||||
"@babel/helper-get-function-arity": {
|
||||
"version": "7.10.3",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helper-get-function-arity/-/helper-get-function-arity-7.10.3.tgz",
|
||||
"integrity": "sha512-iUD/gFsR+M6uiy69JA6fzM5seno8oE85IYZdbVVEuQaZlEzMO2MXblh+KSPJgsZAUx0EEbWXU0yJaW7C9CdAVg==",
|
||||
"requires": {
|
||||
"@babel/types": "^7.10.3"
|
||||
}
|
||||
},
|
||||
"@babel/helper-member-expression-to-functions": {
|
||||
"version": "7.10.3",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helper-member-expression-to-functions/-/helper-member-expression-to-functions-7.10.3.tgz",
|
||||
"integrity": "sha512-q7+37c4EPLSjNb2NmWOjNwj0+BOyYlssuQ58kHEWk1Z78K5i8vTUsteq78HMieRPQSl/NtpQyJfdjt3qZ5V2vw==",
|
||||
"requires": {
|
||||
"@babel/types": "^7.10.3"
|
||||
}
|
||||
},
|
||||
"@babel/helper-optimise-call-expression": {
|
||||
"version": "7.10.3",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helper-optimise-call-expression/-/helper-optimise-call-expression-7.10.3.tgz",
|
||||
"integrity": "sha512-kT2R3VBH/cnSz+yChKpaKRJQJWxdGoc6SjioRId2wkeV3bK0wLLioFpJROrX0U4xr/NmxSSAWT/9Ih5snwIIzg==",
|
||||
"requires": {
|
||||
"@babel/types": "^7.10.3"
|
||||
}
|
||||
},
|
||||
"@babel/helper-plugin-utils": {
|
||||
"version": "7.10.3",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.10.3.tgz",
|
||||
"integrity": "sha512-j/+j8NAWUTxOtx4LKHybpSClxHoq6I91DQ/mKgAXn5oNUPIUiGppjPIX3TDtJWPrdfP9Kfl7e4fgVMiQR9VE/g=="
|
||||
},
|
||||
"@babel/helper-replace-supers": {
|
||||
"version": "7.10.1",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helper-replace-supers/-/helper-replace-supers-7.10.1.tgz",
|
||||
"integrity": "sha512-SOwJzEfpuQwInzzQJGjGaiG578UYmyi2Xw668klPWV5n07B73S0a9btjLk/52Mlcxa+5AdIYqws1KyXRfMoB7A==",
|
||||
"requires": {
|
||||
"@babel/helper-member-expression-to-functions": "^7.10.1",
|
||||
"@babel/helper-optimise-call-expression": "^7.10.1",
|
||||
"@babel/traverse": "^7.10.1",
|
||||
"@babel/types": "^7.10.1"
|
||||
}
|
||||
},
|
||||
"@babel/helper-split-export-declaration": {
|
||||
"version": "7.10.1",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helper-split-export-declaration/-/helper-split-export-declaration-7.10.1.tgz",
|
||||
"integrity": "sha512-UQ1LVBPrYdbchNhLwj6fetj46BcFwfS4NllJo/1aJsT+1dLTEnXJL0qHqtY7gPzF8S2fXBJamf1biAXV3X077g==",
|
||||
"requires": {
|
||||
"@babel/types": "^7.10.1"
|
||||
}
|
||||
},
|
||||
"@babel/highlight": {
|
||||
"version": "7.10.3",
|
||||
"resolved": "https://registry.npmjs.org/@babel/highlight/-/highlight-7.10.3.tgz",
|
||||
"integrity": "sha512-Ih9B/u7AtgEnySE2L2F0Xm0GaM729XqqLfHkalTsbjXGyqmf/6M0Cu0WpvqueUlW+xk88BHw9Nkpj49naU+vWw==",
|
||||
"requires": {
|
||||
"@babel/helper-validator-identifier": "^7.10.3",
|
||||
"chalk": "^2.0.0",
|
||||
"js-tokens": "^4.0.0"
|
||||
}
|
||||
},
|
||||
"@babel/parser": {
|
||||
"version": "7.10.3",
|
||||
"resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.10.3.tgz",
|
||||
"integrity": "sha512-oJtNJCMFdIMwXGmx+KxuaD7i3b8uS7TTFYW/FNG2BT8m+fmGHoiPYoH0Pe3gya07WuFmM5FCDIr1x0irkD/hyA=="
|
||||
},
|
||||
"@babel/template": {
|
||||
"version": "7.10.3",
|
||||
"resolved": "https://registry.npmjs.org/@babel/template/-/template-7.10.3.tgz",
|
||||
"integrity": "sha512-5BjI4gdtD+9fHZUsaxPHPNpwa+xRkDO7c7JbhYn2afvrkDu5SfAAbi9AIMXw2xEhO/BR35TqiW97IqNvCo/GqA==",
|
||||
"requires": {
|
||||
"@babel/code-frame": "^7.10.3",
|
||||
"@babel/parser": "^7.10.3",
|
||||
"@babel/types": "^7.10.3"
|
||||
}
|
||||
},
|
||||
"@babel/traverse": {
|
||||
"version": "7.10.3",
|
||||
"resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.10.3.tgz",
|
||||
"integrity": "sha512-qO6623eBFhuPm0TmmrUFMT1FulCmsSeJuVGhiLodk2raUDFhhTECLd9E9jC4LBIWziqt4wgF6KuXE4d+Jz9yug==",
|
||||
"requires": {
|
||||
"@babel/code-frame": "^7.10.3",
|
||||
"@babel/generator": "^7.10.3",
|
||||
"@babel/helper-function-name": "^7.10.3",
|
||||
"@babel/helper-split-export-declaration": "^7.10.1",
|
||||
"@babel/parser": "^7.10.3",
|
||||
"@babel/types": "^7.10.3",
|
||||
"debug": "^4.1.0",
|
||||
"globals": "^11.1.0",
|
||||
"lodash": "^4.17.13"
|
||||
}
|
||||
},
|
||||
"@babel/types": {
|
||||
"version": "7.10.3",
|
||||
"resolved": "https://registry.npmjs.org/@babel/types/-/types-7.10.3.tgz",
|
||||
"integrity": "sha512-nZxaJhBXBQ8HVoIcGsf9qWep3Oh3jCENK54V4mRF7qaJabVsAYdbTtmSD8WmAp1R6ytPiu5apMwSXyxB1WlaBA==",
|
||||
"requires": {
|
||||
"@babel/helper-validator-identifier": "^7.10.3",
|
||||
"lodash": "^4.17.13",
|
||||
"to-fast-properties": "^2.0.0"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"@babel/plugin-transform-unicode-regex": {
|
||||
"version": "7.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-regex/-/plugin-transform-unicode-regex-7.2.0.tgz",
|
||||
@@ -812,6 +985,22 @@
|
||||
"@babel/plugin-transform-react-jsx-source": "^7.0.0"
|
||||
}
|
||||
},
|
||||
"@babel/preset-typescript": {
|
||||
"version": "7.10.1",
|
||||
"resolved": "https://registry.npmjs.org/@babel/preset-typescript/-/preset-typescript-7.10.1.tgz",
|
||||
"integrity": "sha512-m6GV3y1ShiqxnyQj10600ZVOFrSSAa8HQ3qIUk2r+gcGtHTIRw0dJnFLt1WNXpKjtVw7yw1DAPU/6ma2ZvgJuA==",
|
||||
"requires": {
|
||||
"@babel/helper-plugin-utils": "^7.10.1",
|
||||
"@babel/plugin-transform-typescript": "^7.10.1"
|
||||
},
|
||||
"dependencies": {
|
||||
"@babel/helper-plugin-utils": {
|
||||
"version": "7.10.3",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.10.3.tgz",
|
||||
"integrity": "sha512-j/+j8NAWUTxOtx4LKHybpSClxHoq6I91DQ/mKgAXn5oNUPIUiGppjPIX3TDtJWPrdfP9Kfl7e4fgVMiQR9VE/g=="
|
||||
}
|
||||
}
|
||||
},
|
||||
"@babel/runtime": {
|
||||
"version": "7.1.5",
|
||||
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.1.5.tgz",
|
||||
@@ -886,6 +1075,11 @@
|
||||
"integrity": "sha512-rr+OQyAjxze7GgWrSaJwydHStIhHq2lvY3BOC2Mj7KnzI7XK0Uw1TOOdI9lDoajEbSWLiYgoo4f1R51erQfhPQ==",
|
||||
"dev": true
|
||||
},
|
||||
"@types/mithril": {
|
||||
"version": "2.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@types/mithril/-/mithril-2.0.3.tgz",
|
||||
"integrity": "sha512-cZHOdO2IiXYeyjeDYdbOisSdfaJRzfmRo3zVzgu33IWTMA0KEQObp9fdvqcuYdPz93iJ1yCl19GcEjo/9yv+yA=="
|
||||
},
|
||||
"@types/parse-json": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/parse-json/-/parse-json-4.0.0.tgz",
|
||||
@@ -1834,6 +2028,11 @@
|
||||
"resolved": "https://registry.npmjs.org/cyclist/-/cyclist-1.0.1.tgz",
|
||||
"integrity": "sha1-WW6WmP0MgOEgOMK4LW6xs1tiJNk="
|
||||
},
|
||||
"dayjs": {
|
||||
"version": "1.8.28",
|
||||
"resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.8.28.tgz",
|
||||
"integrity": "sha512-ccnYgKC0/hPSGXxj7Ju6AV/BP4HUkXC2u15mikXT5mX9YorEaoi1bEKOmAqdkJHN4EEkmAf97SpH66Try5Mbeg=="
|
||||
},
|
||||
"debug": {
|
||||
"version": "4.1.1",
|
||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.1.1.tgz",
|
||||
@@ -1942,9 +2141,9 @@
|
||||
"integrity": "sha512-De+lPAxEcpxvqPTyZAXELNpRZXABRxf+uL/rSykstQhzj/B0l1150G/ExIIxKc16lI89Hgz81J0BHAcbTqK49g=="
|
||||
},
|
||||
"elliptic": {
|
||||
"version": "6.5.2",
|
||||
"resolved": "https://registry.npmjs.org/elliptic/-/elliptic-6.5.2.tgz",
|
||||
"integrity": "sha512-f4x70okzZbIQl/NSRLkI/+tteV/9WqL98zx+SQ69KbXxmVrmjwsNUPn/gYJJ0sHvEak24cZgHIPegRePAtA/xw==",
|
||||
"version": "6.5.3",
|
||||
"resolved": "https://registry.npmjs.org/elliptic/-/elliptic-6.5.3.tgz",
|
||||
"integrity": "sha512-IMqzv5wNQf+E6aHeIqATs0tOLeOTwj1QKbRcS3jBbYkl5oLAserA8yJTT7/VyHUYG91PRmPyeQDObKLPpeS4dw==",
|
||||
"requires": {
|
||||
"bn.js": "^4.4.0",
|
||||
"brorand": "^1.0.1",
|
||||
@@ -3443,9 +3642,9 @@
|
||||
}
|
||||
},
|
||||
"lodash": {
|
||||
"version": "4.17.15",
|
||||
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.15.tgz",
|
||||
"integrity": "sha512-8xOcRHvCjnocdS5cpwXQXVzmmh5e5+saE2QGoeQmbKmRS6J3VQppPOIt0MnmE+4xlZoumy0GPG0D0MVIQbNA1A=="
|
||||
"version": "4.17.19",
|
||||
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.19.tgz",
|
||||
"integrity": "sha512-JNvd8XER9GQX0v2qJgsaN/mzFCNA5BRe/j8JN9d+tWyGLSodKQHKFicdwNYzWwI3wjRnaKPsGj1XkBjx/F96DQ=="
|
||||
},
|
||||
"lodash-es": {
|
||||
"version": "4.17.14",
|
||||
@@ -3613,9 +3812,9 @@
|
||||
}
|
||||
},
|
||||
"mithril": {
|
||||
"version": "0.2.8",
|
||||
"resolved": "https://registry.npmjs.org/mithril/-/mithril-0.2.8.tgz",
|
||||
"integrity": "sha512-9XuGnVmS2OyFexUuP/CcJFFJjHLM+RGYBxyVRNyQ6khbMfDJIF/xyZ4zq18ZRfPagpFmWUFpjHd5ZqPULGZyNg=="
|
||||
"version": "2.0.4",
|
||||
"resolved": "https://registry.npmjs.org/mithril/-/mithril-2.0.4.tgz",
|
||||
"integrity": "sha512-mgw+DMZlhMS4PpprF6dl7ZoeZq5GGcAuWnrg5e12MvaGauc4jzWsDZtVGRCktsiQczOEUr2K5teKbE5k44RlOg=="
|
||||
},
|
||||
"mixin-deep": {
|
||||
"version": "1.3.2",
|
||||
@@ -3644,11 +3843,6 @@
|
||||
"minimist": "^1.2.5"
|
||||
}
|
||||
},
|
||||
"moment": {
|
||||
"version": "2.22.2",
|
||||
"resolved": "https://registry.npmjs.org/moment/-/moment-2.22.2.tgz",
|
||||
"integrity": "sha1-PCV/mDn8DpP/UxSWMiOeuQeD/2Y="
|
||||
},
|
||||
"move-concurrently": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/move-concurrently/-/move-concurrently-1.0.1.tgz",
|
||||
|
@@ -2,17 +2,19 @@
|
||||
"private": true,
|
||||
"name": "@flarum/core",
|
||||
"dependencies": {
|
||||
"@babel/preset-typescript": "^7.10.1",
|
||||
"@types/mithril": "^2.0.3",
|
||||
"bootstrap": "^3.4.1",
|
||||
"classnames": "^2.2.5",
|
||||
"color-thief-browser": "^2.0.2",
|
||||
"dayjs": "^1.8.28",
|
||||
"expose-loader": "^0.7.5",
|
||||
"flarum-webpack-config": "0.1.0-beta.10",
|
||||
"jquery": "^3.4.1",
|
||||
"jquery.hotkeys": "^0.1.0",
|
||||
"lodash-es": "^4.17.14",
|
||||
"m.attrs.bidi": "github:tobscure/m.attrs.bidi",
|
||||
"mithril": "^0.2.8",
|
||||
"moment": "^2.22.2",
|
||||
"mithril": "^2.0.4",
|
||||
"punycode": "^2.1.1",
|
||||
"spin.js": "^3.1.0",
|
||||
"webpack": "^4.43.0",
|
||||
|
48
js/shims.d.ts
vendored
Normal file
48
js/shims.d.ts
vendored
Normal file
@@ -0,0 +1,48 @@
|
||||
// Mithril
|
||||
import * as Mithril from 'mithril';
|
||||
import Stream from 'mithril/stream';
|
||||
|
||||
// Other third-party libs
|
||||
import * as _dayjs from 'dayjs';
|
||||
import * as _$ from 'jquery';
|
||||
|
||||
// Globals from flarum/core
|
||||
import Application from './src/common/Application';
|
||||
|
||||
/**
|
||||
* Helpers that flarum/core patches into Mithril
|
||||
*/
|
||||
interface m extends Mithril.Static {
|
||||
prop: typeof Stream;
|
||||
}
|
||||
|
||||
/**
|
||||
* Export Mithril typings globally.
|
||||
*
|
||||
* This lets us use these typings without an extra import everywhere we use
|
||||
* Mithril in a TypeScript file.
|
||||
*/
|
||||
export as namespace Mithril;
|
||||
|
||||
/**
|
||||
* flarum/core exposes several extensions globally:
|
||||
*
|
||||
* - jQuery for convenient DOM manipulation
|
||||
* - Mithril for VDOM and components
|
||||
* - dayjs for date/time operations
|
||||
*
|
||||
* Since these are already part of the global namespace, extensions won't need
|
||||
* to (and should not) bundle these themselves.
|
||||
*/
|
||||
declare global {
|
||||
const $: typeof _$;
|
||||
const m: m;
|
||||
const dayjs: typeof _dayjs;
|
||||
}
|
||||
|
||||
/**
|
||||
* All global variables owned by flarum/core.
|
||||
*/
|
||||
declare global {
|
||||
const app: Application;
|
||||
}
|
@@ -27,13 +27,18 @@ export default class AdminApplication extends Application {
|
||||
* @inheritdoc
|
||||
*/
|
||||
mount() {
|
||||
m.mount(document.getElementById('app-navigation'), Navigation.component({ className: 'App-backControl', drawer: true }));
|
||||
m.mount(document.getElementById('header-navigation'), Navigation.component());
|
||||
m.mount(document.getElementById('header-primary'), HeaderPrimary.component());
|
||||
m.mount(document.getElementById('header-secondary'), HeaderSecondary.component());
|
||||
m.mount(document.getElementById('admin-navigation'), AdminNav.component());
|
||||
m.mount(document.getElementById('app-navigation'), { view: () => Navigation.component({ className: 'App-backControl', drawer: true }) });
|
||||
m.mount(document.getElementById('header-navigation'), Navigation);
|
||||
m.mount(document.getElementById('header-primary'), HeaderPrimary);
|
||||
m.mount(document.getElementById('header-secondary'), HeaderSecondary);
|
||||
m.mount(document.getElementById('admin-navigation'), AdminNav);
|
||||
|
||||
// Mithril does not render the home route on https://example.com/admin, so
|
||||
// we need to go to https://example.com/admin#/ explicitly.
|
||||
if (!document.location.hash) document.location.hash = '#/';
|
||||
|
||||
m.route.prefix = '#';
|
||||
|
||||
m.route.mode = 'hash';
|
||||
super.mount();
|
||||
|
||||
// If an extension has just been enabled, then we will run its settings
|
||||
|
@@ -10,11 +10,7 @@
|
||||
import LinkButton from '../../common/components/LinkButton';
|
||||
|
||||
export default class AdminLinkButton extends LinkButton {
|
||||
getButtonContent() {
|
||||
const content = super.getButtonContent();
|
||||
|
||||
content.push(<div className="AdminLinkButton-description">{this.props.description}</div>);
|
||||
|
||||
return content;
|
||||
getButtonContent(children) {
|
||||
return [...super.getButtonContent(children), <div className="AdminLinkButton-description">{this.attrs.description}</div>];
|
||||
}
|
||||
}
|
||||
|
@@ -31,62 +31,74 @@ export default class AdminNav extends Component {
|
||||
|
||||
items.add(
|
||||
'dashboard',
|
||||
AdminLinkButton.component({
|
||||
href: app.route('dashboard'),
|
||||
icon: 'far fa-chart-bar',
|
||||
children: app.translator.trans('core.admin.nav.dashboard_button'),
|
||||
description: app.translator.trans('core.admin.nav.dashboard_text'),
|
||||
})
|
||||
AdminLinkButton.component(
|
||||
{
|
||||
href: app.route('dashboard'),
|
||||
icon: 'far fa-chart-bar',
|
||||
description: app.translator.trans('core.admin.nav.dashboard_text'),
|
||||
},
|
||||
app.translator.trans('core.admin.nav.dashboard_button')
|
||||
)
|
||||
);
|
||||
|
||||
items.add(
|
||||
'basics',
|
||||
AdminLinkButton.component({
|
||||
href: app.route('basics'),
|
||||
icon: 'fas fa-pencil-alt',
|
||||
children: app.translator.trans('core.admin.nav.basics_button'),
|
||||
description: app.translator.trans('core.admin.nav.basics_text'),
|
||||
})
|
||||
AdminLinkButton.component(
|
||||
{
|
||||
href: app.route('basics'),
|
||||
icon: 'fas fa-pencil-alt',
|
||||
description: app.translator.trans('core.admin.nav.basics_text'),
|
||||
},
|
||||
app.translator.trans('core.admin.nav.basics_button')
|
||||
)
|
||||
);
|
||||
|
||||
items.add(
|
||||
'mail',
|
||||
AdminLinkButton.component({
|
||||
href: app.route('mail'),
|
||||
icon: 'fas fa-envelope',
|
||||
children: app.translator.trans('core.admin.nav.email_button'),
|
||||
description: app.translator.trans('core.admin.nav.email_text'),
|
||||
})
|
||||
AdminLinkButton.component(
|
||||
{
|
||||
href: app.route('mail'),
|
||||
icon: 'fas fa-envelope',
|
||||
description: app.translator.trans('core.admin.nav.email_text'),
|
||||
},
|
||||
app.translator.trans('core.admin.nav.email_button')
|
||||
)
|
||||
);
|
||||
|
||||
items.add(
|
||||
'permissions',
|
||||
AdminLinkButton.component({
|
||||
href: app.route('permissions'),
|
||||
icon: 'fas fa-key',
|
||||
children: app.translator.trans('core.admin.nav.permissions_button'),
|
||||
description: app.translator.trans('core.admin.nav.permissions_text'),
|
||||
})
|
||||
AdminLinkButton.component(
|
||||
{
|
||||
href: app.route('permissions'),
|
||||
icon: 'fas fa-key',
|
||||
description: app.translator.trans('core.admin.nav.permissions_text'),
|
||||
},
|
||||
app.translator.trans('core.admin.nav.permissions_button')
|
||||
)
|
||||
);
|
||||
|
||||
items.add(
|
||||
'appearance',
|
||||
AdminLinkButton.component({
|
||||
href: app.route('appearance'),
|
||||
icon: 'fas fa-paint-brush',
|
||||
children: app.translator.trans('core.admin.nav.appearance_button'),
|
||||
description: app.translator.trans('core.admin.nav.appearance_text'),
|
||||
})
|
||||
AdminLinkButton.component(
|
||||
{
|
||||
href: app.route('appearance'),
|
||||
icon: 'fas fa-paint-brush',
|
||||
description: app.translator.trans('core.admin.nav.appearance_text'),
|
||||
},
|
||||
app.translator.trans('core.admin.nav.appearance_button')
|
||||
)
|
||||
);
|
||||
|
||||
items.add(
|
||||
'extensions',
|
||||
AdminLinkButton.component({
|
||||
href: app.route('extensions'),
|
||||
icon: 'fas fa-puzzle-piece',
|
||||
children: app.translator.trans('core.admin.nav.extensions_button'),
|
||||
description: app.translator.trans('core.admin.nav.extensions_text'),
|
||||
})
|
||||
AdminLinkButton.component(
|
||||
{
|
||||
href: app.route('extensions'),
|
||||
icon: 'fas fa-puzzle-piece',
|
||||
description: app.translator.trans('core.admin.nav.extensions_text'),
|
||||
},
|
||||
app.translator.trans('core.admin.nav.extensions_button')
|
||||
)
|
||||
);
|
||||
|
||||
return items;
|
||||
|
@@ -6,15 +6,16 @@ import EditCustomHeaderModal from './EditCustomHeaderModal';
|
||||
import EditCustomFooterModal from './EditCustomFooterModal';
|
||||
import UploadImageButton from './UploadImageButton';
|
||||
import saveSettings from '../utils/saveSettings';
|
||||
import withAttr from '../../common/utils/withAttr';
|
||||
|
||||
export default class AppearancePage extends Page {
|
||||
init() {
|
||||
super.init();
|
||||
oninit(vnode) {
|
||||
super.oninit(vnode);
|
||||
|
||||
this.primaryColor = m.prop(app.data.settings.theme_primary_color);
|
||||
this.secondaryColor = m.prop(app.data.settings.theme_secondary_color);
|
||||
this.darkMode = m.prop(app.data.settings.theme_dark_mode === '1');
|
||||
this.coloredHeader = m.prop(app.data.settings.theme_colored_header === '1');
|
||||
this.primaryColor = m.stream(app.data.settings.theme_primary_color);
|
||||
this.secondaryColor = m.stream(app.data.settings.theme_secondary_color);
|
||||
this.darkMode = m.stream(app.data.settings.theme_dark_mode);
|
||||
this.coloredHeader = m.stream(app.data.settings.theme_colored_header);
|
||||
}
|
||||
|
||||
view() {
|
||||
@@ -27,40 +28,34 @@ export default class AppearancePage extends Page {
|
||||
<div className="helpText">{app.translator.trans('core.admin.appearance.colors_text')}</div>
|
||||
|
||||
<div className="AppearancePage-colors-input">
|
||||
<input
|
||||
className="FormControl"
|
||||
type="text"
|
||||
placeholder="#aaaaaa"
|
||||
value={this.primaryColor()}
|
||||
onchange={m.withAttr('value', this.primaryColor)}
|
||||
/>
|
||||
<input
|
||||
className="FormControl"
|
||||
type="text"
|
||||
placeholder="#aaaaaa"
|
||||
value={this.secondaryColor()}
|
||||
onchange={m.withAttr('value', this.secondaryColor)}
|
||||
/>
|
||||
<input className="FormControl" type="text" placeholder="#aaaaaa" bidi={this.primaryColor} />
|
||||
<input className="FormControl" type="text" placeholder="#aaaaaa" bidi={this.secondaryColor} />
|
||||
</div>
|
||||
|
||||
{Switch.component({
|
||||
state: this.darkMode(),
|
||||
children: app.translator.trans('core.admin.appearance.dark_mode_label'),
|
||||
onchange: this.darkMode,
|
||||
})}
|
||||
{Switch.component(
|
||||
{
|
||||
state: this.darkMode(),
|
||||
onchange: this.darkMode,
|
||||
},
|
||||
app.translator.trans('core.admin.appearance.dark_mode_label')
|
||||
)}
|
||||
|
||||
{Switch.component({
|
||||
state: this.coloredHeader(),
|
||||
children: app.translator.trans('core.admin.appearance.colored_header_label'),
|
||||
onchange: this.coloredHeader,
|
||||
})}
|
||||
{Switch.component(
|
||||
{
|
||||
state: this.coloredHeader(),
|
||||
onchange: this.coloredHeader,
|
||||
},
|
||||
app.translator.trans('core.admin.appearance.colored_header_label')
|
||||
)}
|
||||
|
||||
{Button.component({
|
||||
className: 'Button Button--primary',
|
||||
type: 'submit',
|
||||
children: app.translator.trans('core.admin.appearance.submit_button'),
|
||||
loading: this.loading,
|
||||
})}
|
||||
{Button.component(
|
||||
{
|
||||
className: 'Button Button--primary',
|
||||
type: 'submit',
|
||||
loading: this.loading,
|
||||
},
|
||||
app.translator.trans('core.admin.appearance.submit_button')
|
||||
)}
|
||||
</fieldset>
|
||||
</form>
|
||||
|
||||
@@ -79,31 +74,37 @@ export default class AppearancePage extends Page {
|
||||
<fieldset>
|
||||
<legend>{app.translator.trans('core.admin.appearance.custom_header_heading')}</legend>
|
||||
<div className="helpText">{app.translator.trans('core.admin.appearance.custom_header_text')}</div>
|
||||
{Button.component({
|
||||
className: 'Button',
|
||||
children: app.translator.trans('core.admin.appearance.edit_header_button'),
|
||||
onclick: () => app.modal.show(new EditCustomHeaderModal()),
|
||||
})}
|
||||
{Button.component(
|
||||
{
|
||||
className: 'Button',
|
||||
onclick: () => app.modal.show(EditCustomHeaderModal),
|
||||
},
|
||||
app.translator.trans('core.admin.appearance.edit_header_button')
|
||||
)}
|
||||
</fieldset>
|
||||
|
||||
<fieldset>
|
||||
<legend>{app.translator.trans('core.admin.appearance.custom_footer_heading')}</legend>
|
||||
<div className="helpText">{app.translator.trans('core.admin.appearance.custom_footer_text')}</div>
|
||||
{Button.component({
|
||||
className: 'Button',
|
||||
children: app.translator.trans('core.admin.appearance.edit_footer_button'),
|
||||
onclick: () => app.modal.show(new EditCustomFooterModal()),
|
||||
})}
|
||||
{Button.component(
|
||||
{
|
||||
className: 'Button',
|
||||
onclick: () => app.modal.show(EditCustomFooterModal),
|
||||
},
|
||||
app.translator.trans('core.admin.appearance.edit_footer_button')
|
||||
)}
|
||||
</fieldset>
|
||||
|
||||
<fieldset>
|
||||
<legend>{app.translator.trans('core.admin.appearance.custom_styles_heading')}</legend>
|
||||
<div className="helpText">{app.translator.trans('core.admin.appearance.custom_styles_text')}</div>
|
||||
{Button.component({
|
||||
className: 'Button',
|
||||
children: app.translator.trans('core.admin.appearance.edit_css_button'),
|
||||
onclick: () => app.modal.show(new EditCustomCssModal()),
|
||||
})}
|
||||
{Button.component(
|
||||
{
|
||||
className: 'Button',
|
||||
onclick: () => app.modal.show(EditCustomCssModal),
|
||||
},
|
||||
app.translator.trans('core.admin.appearance.edit_css_button')
|
||||
)}
|
||||
</fieldset>
|
||||
</div>
|
||||
</div>
|
||||
|
@@ -2,14 +2,14 @@ import Page from '../../common/components/Page';
|
||||
import FieldSet from '../../common/components/FieldSet';
|
||||
import Select from '../../common/components/Select';
|
||||
import Button from '../../common/components/Button';
|
||||
import Alert from '../../common/components/Alert';
|
||||
import saveSettings from '../utils/saveSettings';
|
||||
import ItemList from '../../common/utils/ItemList';
|
||||
import Switch from '../../common/components/Switch';
|
||||
import withAttr from '../../common/utils/withAttr';
|
||||
|
||||
export default class BasicsPage extends Page {
|
||||
init() {
|
||||
super.init();
|
||||
oninit(vnode) {
|
||||
super.oninit(vnode);
|
||||
|
||||
this.loading = false;
|
||||
|
||||
@@ -26,7 +26,7 @@ export default class BasicsPage extends Page {
|
||||
this.values = {};
|
||||
|
||||
const settings = app.data.settings;
|
||||
this.fields.forEach((key) => (this.values[key] = m.prop(settings[key])));
|
||||
this.fields.forEach((key) => (this.values[key] = m.stream(settings[key])));
|
||||
|
||||
this.localeOptions = {};
|
||||
const locales = app.data.locales;
|
||||
@@ -50,45 +50,51 @@ export default class BasicsPage extends Page {
|
||||
<div className="BasicsPage">
|
||||
<div className="container">
|
||||
<form onsubmit={this.onsubmit.bind(this)}>
|
||||
{FieldSet.component({
|
||||
label: app.translator.trans('core.admin.basics.forum_title_heading'),
|
||||
children: [<input className="FormControl" value={this.values.forum_title()} oninput={m.withAttr('value', this.values.forum_title)} />],
|
||||
})}
|
||||
{FieldSet.component(
|
||||
{
|
||||
label: app.translator.trans('core.admin.basics.forum_title_heading'),
|
||||
},
|
||||
[<input className="FormControl" bidi={this.values.forum_title} />]
|
||||
)}
|
||||
|
||||
{FieldSet.component({
|
||||
label: app.translator.trans('core.admin.basics.forum_description_heading'),
|
||||
children: [
|
||||
{FieldSet.component(
|
||||
{
|
||||
label: app.translator.trans('core.admin.basics.forum_description_heading'),
|
||||
},
|
||||
[
|
||||
<div className="helpText">{app.translator.trans('core.admin.basics.forum_description_text')}</div>,
|
||||
<textarea
|
||||
className="FormControl"
|
||||
value={this.values.forum_description()}
|
||||
oninput={m.withAttr('value', this.values.forum_description)}
|
||||
/>,
|
||||
],
|
||||
})}
|
||||
<textarea className="FormControl" bidi={this.values.forum_description} />,
|
||||
]
|
||||
)}
|
||||
|
||||
{Object.keys(this.localeOptions).length > 1
|
||||
? FieldSet.component({
|
||||
label: app.translator.trans('core.admin.basics.default_language_heading'),
|
||||
children: [
|
||||
? FieldSet.component(
|
||||
{
|
||||
label: app.translator.trans('core.admin.basics.default_language_heading'),
|
||||
},
|
||||
[
|
||||
Select.component({
|
||||
options: this.localeOptions,
|
||||
value: this.values.default_locale(),
|
||||
onchange: this.values.default_locale,
|
||||
}),
|
||||
Switch.component({
|
||||
state: this.values.show_language_selector(),
|
||||
onchange: this.values.show_language_selector,
|
||||
children: app.translator.trans('core.admin.basics.show_language_selector_label'),
|
||||
}),
|
||||
],
|
||||
})
|
||||
Switch.component(
|
||||
{
|
||||
state: this.values.show_language_selector(),
|
||||
onchange: this.values.show_language_selector,
|
||||
},
|
||||
app.translator.trans('core.admin.basics.show_language_selector_label')
|
||||
),
|
||||
]
|
||||
)
|
||||
: ''}
|
||||
|
||||
{FieldSet.component({
|
||||
label: app.translator.trans('core.admin.basics.home_page_heading'),
|
||||
className: 'BasicsPage-homePage',
|
||||
children: [
|
||||
{FieldSet.component(
|
||||
{
|
||||
label: app.translator.trans('core.admin.basics.home_page_heading'),
|
||||
className: 'BasicsPage-homePage',
|
||||
},
|
||||
[
|
||||
<div className="helpText">{app.translator.trans('core.admin.basics.home_page_text')}</div>,
|
||||
this.homePageItems()
|
||||
.toArray()
|
||||
@@ -99,51 +105,52 @@ export default class BasicsPage extends Page {
|
||||
name="homePage"
|
||||
value={path}
|
||||
checked={this.values.default_route() === path}
|
||||
onclick={m.withAttr('value', this.values.default_route)}
|
||||
onclick={withAttr('value', this.values.default_route)}
|
||||
/>
|
||||
{label}
|
||||
</label>
|
||||
)),
|
||||
],
|
||||
})}
|
||||
]
|
||||
)}
|
||||
|
||||
{FieldSet.component({
|
||||
label: app.translator.trans('core.admin.basics.welcome_banner_heading'),
|
||||
className: 'BasicsPage-welcomeBanner',
|
||||
children: [
|
||||
{FieldSet.component(
|
||||
{
|
||||
label: app.translator.trans('core.admin.basics.welcome_banner_heading'),
|
||||
className: 'BasicsPage-welcomeBanner',
|
||||
},
|
||||
[
|
||||
<div className="helpText">{app.translator.trans('core.admin.basics.welcome_banner_text')}</div>,
|
||||
<div className="BasicsPage-welcomeBanner-input">
|
||||
<input className="FormControl" value={this.values.welcome_title()} oninput={m.withAttr('value', this.values.welcome_title)} />
|
||||
<textarea
|
||||
className="FormControl"
|
||||
value={this.values.welcome_message()}
|
||||
oninput={m.withAttr('value', this.values.welcome_message)}
|
||||
/>
|
||||
<input className="FormControl" bidi={this.values.welcome_title} />
|
||||
<textarea className="FormControl" bidi={this.values.welcome_message} />
|
||||
</div>,
|
||||
],
|
||||
})}
|
||||
]
|
||||
)}
|
||||
|
||||
{Object.keys(this.displayNameOptions).length > 1
|
||||
? FieldSet.component({
|
||||
label: app.translator.trans('core.admin.basics.display_name_heading'),
|
||||
children: [
|
||||
? FieldSet.component(
|
||||
{
|
||||
label: app.translator.trans('core.admin.basics.display_name_heading'),
|
||||
},
|
||||
[
|
||||
<div className="helpText">{app.translator.trans('core.admin.basics.display_name_text')}</div>,
|
||||
Select.component({
|
||||
options: this.displayNameOptions,
|
||||
value: this.values.display_name_driver(),
|
||||
onchange: this.values.display_name_driver,
|
||||
bidi: this.values.display_name_driver,
|
||||
}),
|
||||
],
|
||||
})
|
||||
]
|
||||
)
|
||||
: ''}
|
||||
|
||||
{Button.component({
|
||||
type: 'submit',
|
||||
className: 'Button Button--primary',
|
||||
children: app.translator.trans('core.admin.basics.submit_button'),
|
||||
loading: this.loading,
|
||||
disabled: !this.changed(),
|
||||
})}
|
||||
{Button.component(
|
||||
{
|
||||
type: 'submit',
|
||||
className: 'Button Button--primary',
|
||||
loading: this.loading,
|
||||
disabled: !this.changed(),
|
||||
},
|
||||
app.translator.trans('core.admin.basics.submit_button')
|
||||
)}
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
@@ -186,7 +193,9 @@ export default class BasicsPage extends Page {
|
||||
|
||||
saveSettings(settings)
|
||||
.then(() => {
|
||||
app.alerts.show((this.successAlert = new Alert({ type: 'success', children: app.translator.trans('core.admin.basics.saved_message') })));
|
||||
this.successAlert = app.alerts.show(app.translator.trans('core.admin.basics.saved_message'), {
|
||||
type: 'success',
|
||||
});
|
||||
})
|
||||
.catch(() => {})
|
||||
.then(() => {
|
||||
|
@@ -4,20 +4,23 @@ import Badge from '../../common/components/Badge';
|
||||
import Group from '../../common/models/Group';
|
||||
import ItemList from '../../common/utils/ItemList';
|
||||
import Switch from '../../common/components/Switch';
|
||||
import withAttr from '../../common/utils/withAttr';
|
||||
|
||||
/**
|
||||
* The `EditGroupModal` component shows a modal dialog which allows the user
|
||||
* to create or edit a group.
|
||||
*/
|
||||
export default class EditGroupModal extends Modal {
|
||||
init() {
|
||||
this.group = this.props.group || app.store.createRecord('groups');
|
||||
oninit(vnode) {
|
||||
super.oninit(vnode);
|
||||
|
||||
this.nameSingular = m.prop(this.group.nameSingular() || '');
|
||||
this.namePlural = m.prop(this.group.namePlural() || '');
|
||||
this.icon = m.prop(this.group.icon() || '');
|
||||
this.color = m.prop(this.group.color() || '');
|
||||
this.isHidden = m.prop(this.group.isHidden() || false);
|
||||
this.group = this.attrs.group || app.store.createRecord('groups');
|
||||
|
||||
this.nameSingular = m.stream(this.group.nameSingular() || '');
|
||||
this.namePlural = m.stream(this.group.namePlural() || '');
|
||||
this.icon = m.stream(this.group.icon() || '');
|
||||
this.color = m.stream(this.group.color() || '');
|
||||
this.isHidden = m.stream(this.group.isHidden() || false);
|
||||
}
|
||||
|
||||
className() {
|
||||
@@ -53,18 +56,8 @@ export default class EditGroupModal extends Modal {
|
||||
<div className="Form-group">
|
||||
<label>{app.translator.trans('core.admin.edit_group.name_label')}</label>
|
||||
<div className="EditGroupModal-name-input">
|
||||
<input
|
||||
className="FormControl"
|
||||
placeholder={app.translator.trans('core.admin.edit_group.singular_placeholder')}
|
||||
value={this.nameSingular()}
|
||||
oninput={m.withAttr('value', this.nameSingular)}
|
||||
/>
|
||||
<input
|
||||
className="FormControl"
|
||||
placeholder={app.translator.trans('core.admin.edit_group.plural_placeholder')}
|
||||
value={this.namePlural()}
|
||||
oninput={m.withAttr('value', this.namePlural)}
|
||||
/>
|
||||
<input className="FormControl" placeholder={app.translator.trans('core.admin.edit_group.singular_placeholder')} bidi={this.nameSingular} />
|
||||
<input className="FormControl" placeholder={app.translator.trans('core.admin.edit_group.plural_placeholder')} bidi={this.namePlural} />
|
||||
</div>
|
||||
</div>,
|
||||
30
|
||||
@@ -74,7 +67,7 @@ export default class EditGroupModal extends Modal {
|
||||
'color',
|
||||
<div className="Form-group">
|
||||
<label>{app.translator.trans('core.admin.edit_group.color_label')}</label>
|
||||
<input className="FormControl" placeholder="#aaaaaa" value={this.color()} oninput={m.withAttr('value', this.color)} />
|
||||
<input className="FormControl" placeholder="#aaaaaa" bidi={this.color} />
|
||||
</div>,
|
||||
20
|
||||
);
|
||||
@@ -86,7 +79,7 @@ export default class EditGroupModal extends Modal {
|
||||
<div className="helpText">
|
||||
{app.translator.trans('core.admin.edit_group.icon_text', { a: <a href="https://fontawesome.com/icons?m=free" tabindex="-1" /> })}
|
||||
</div>
|
||||
<input className="FormControl" placeholder="fas fa-bolt" value={this.icon()} oninput={m.withAttr('value', this.icon)} />
|
||||
<input className="FormControl" placeholder="fas fa-bolt" bidi={this.icon} />
|
||||
</div>,
|
||||
10
|
||||
);
|
||||
@@ -94,11 +87,13 @@ export default class EditGroupModal extends Modal {
|
||||
items.add(
|
||||
'hidden',
|
||||
<div className="Form-group">
|
||||
{Switch.component({
|
||||
state: !!Number(this.isHidden()),
|
||||
children: app.translator.trans('core.admin.edit_group.hide_label'),
|
||||
onchange: this.isHidden,
|
||||
})}
|
||||
{Switch.component(
|
||||
{
|
||||
state: !!Number(this.isHidden()),
|
||||
onchange: this.isHidden,
|
||||
},
|
||||
app.translator.trans('core.admin.edit_group.hide_label')
|
||||
)}
|
||||
</div>,
|
||||
10
|
||||
);
|
||||
@@ -106,12 +101,14 @@ export default class EditGroupModal extends Modal {
|
||||
items.add(
|
||||
'submit',
|
||||
<div className="Form-group">
|
||||
{Button.component({
|
||||
type: 'submit',
|
||||
className: 'Button Button--primary EditGroupModal-save',
|
||||
loading: this.loading,
|
||||
children: app.translator.trans('core.admin.edit_group.submit_button'),
|
||||
})}
|
||||
{Button.component(
|
||||
{
|
||||
type: 'submit',
|
||||
className: 'Button Button--primary EditGroupModal-save',
|
||||
loading: this.loading,
|
||||
},
|
||||
app.translator.trans('core.admin.edit_group.submit_button')
|
||||
)}
|
||||
{this.group.exists && this.group.id() !== Group.ADMINISTRATOR_ID ? (
|
||||
<button type="button" className="Button EditGroupModal-delete" onclick={this.deleteGroup.bind(this)}>
|
||||
{app.translator.trans('core.admin.edit_group.delete_button')}
|
||||
|
@@ -12,12 +12,14 @@ export default class ExtensionsPage extends Page {
|
||||
<div className="ExtensionsPage">
|
||||
<div className="ExtensionsPage-header">
|
||||
<div className="container">
|
||||
{Button.component({
|
||||
children: app.translator.trans('core.admin.extensions.add_button'),
|
||||
icon: 'fas fa-plus',
|
||||
className: 'Button Button--primary',
|
||||
onclick: () => app.modal.show(new AddExtensionModal()),
|
||||
})}
|
||||
{Button.component(
|
||||
{
|
||||
icon: 'fas fa-plus',
|
||||
className: 'Button Button--primary',
|
||||
onclick: () => app.modal.show(AddExtensionModal),
|
||||
},
|
||||
app.translator.trans('core.admin.extensions.add_button')
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -72,31 +74,35 @@ export default class ExtensionsPage extends Page {
|
||||
if (app.extensionSettings[name]) {
|
||||
items.add(
|
||||
'settings',
|
||||
Button.component({
|
||||
icon: 'fas fa-cog',
|
||||
children: app.translator.trans('core.admin.extensions.settings_button'),
|
||||
onclick: app.extensionSettings[name],
|
||||
})
|
||||
Button.component(
|
||||
{
|
||||
icon: 'fas fa-cog',
|
||||
onclick: app.extensionSettings[name],
|
||||
},
|
||||
app.translator.trans('core.admin.extensions.settings_button')
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
if (!enabled) {
|
||||
items.add(
|
||||
'uninstall',
|
||||
Button.component({
|
||||
icon: 'far fa-trash-alt',
|
||||
children: app.translator.trans('core.admin.extensions.uninstall_button'),
|
||||
onclick: () => {
|
||||
app
|
||||
.request({
|
||||
url: app.forum.attribute('apiUrl') + '/extensions/' + name,
|
||||
method: 'DELETE',
|
||||
})
|
||||
.then(() => window.location.reload());
|
||||
Button.component(
|
||||
{
|
||||
icon: 'far fa-trash-alt',
|
||||
onclick: () => {
|
||||
app
|
||||
.request({
|
||||
url: app.forum.attribute('apiUrl') + '/extensions/' + name,
|
||||
method: 'DELETE',
|
||||
})
|
||||
.then(() => window.location.reload());
|
||||
|
||||
app.modal.show(new LoadingModal());
|
||||
app.modal.show(LoadingModal);
|
||||
},
|
||||
},
|
||||
})
|
||||
app.translator.trans('core.admin.extensions.uninstall_button')
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -116,13 +122,13 @@ export default class ExtensionsPage extends Page {
|
||||
.request({
|
||||
url: app.forum.attribute('apiUrl') + '/extensions/' + id,
|
||||
method: 'PATCH',
|
||||
data: { enabled: !enabled },
|
||||
body: { enabled: !enabled },
|
||||
})
|
||||
.then(() => {
|
||||
if (!enabled) localStorage.setItem('enabledExtension', id);
|
||||
window.location.reload();
|
||||
});
|
||||
|
||||
app.modal.show(new LoadingModal());
|
||||
app.modal.show(LoadingModal);
|
||||
}
|
||||
}
|
||||
|
@@ -11,13 +11,6 @@ export default class HeaderSecondary extends Component {
|
||||
return <ul className="Header-controls">{listItems(this.items().toArray())}</ul>;
|
||||
}
|
||||
|
||||
config(isInitialized, context) {
|
||||
// Since this component is 'above' the content of the page (that is, it is a
|
||||
// part of the global UI that persists between routes), we will flag the DOM
|
||||
// to be retained across route changes.
|
||||
context.retain = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build an item list for the controls.
|
||||
*
|
||||
|
@@ -1,9 +1,10 @@
|
||||
import Modal from '../../common/components/Modal';
|
||||
|
||||
export default class LoadingModal extends Modal {
|
||||
isDismissible() {
|
||||
return false;
|
||||
}
|
||||
/**
|
||||
* @inheritdoc
|
||||
*/
|
||||
static isDismissible = false;
|
||||
|
||||
className() {
|
||||
return 'LoadingModal Modal--small';
|
||||
|
@@ -5,10 +5,11 @@ import Alert from '../../common/components/Alert';
|
||||
import Select from '../../common/components/Select';
|
||||
import LoadingIndicator from '../../common/components/LoadingIndicator';
|
||||
import saveSettings from '../utils/saveSettings';
|
||||
import withAttr from '../../common/utils/withAttr';
|
||||
|
||||
export default class MailPage extends Page {
|
||||
init() {
|
||||
super.init();
|
||||
oninit(vnode) {
|
||||
super.oninit(vnode);
|
||||
|
||||
this.saving = false;
|
||||
this.sendingTest = false;
|
||||
@@ -24,7 +25,7 @@ export default class MailPage extends Page {
|
||||
this.status = { sending: false, errors: {} };
|
||||
|
||||
const settings = app.data.settings;
|
||||
this.fields.forEach((key) => (this.values[key] = m.prop(settings[key])));
|
||||
this.fields.forEach((key) => (this.values[key] = m.stream(settings[key])));
|
||||
|
||||
app
|
||||
.request({
|
||||
@@ -39,7 +40,7 @@ export default class MailPage extends Page {
|
||||
for (const driver in this.driverFields) {
|
||||
for (const field in this.driverFields[driver]) {
|
||||
this.fields.push(field);
|
||||
this.values[field] = m.prop(settings[field]);
|
||||
this.values[field] = m.stream(settings[field]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -69,23 +70,27 @@ export default class MailPage extends Page {
|
||||
<h2>{app.translator.trans('core.admin.email.heading')}</h2>
|
||||
<div className="helpText">{app.translator.trans('core.admin.email.text')}</div>
|
||||
|
||||
{FieldSet.component({
|
||||
label: app.translator.trans('core.admin.email.addresses_heading'),
|
||||
className: 'MailPage-MailSettings',
|
||||
children: [
|
||||
{FieldSet.component(
|
||||
{
|
||||
label: app.translator.trans('core.admin.email.addresses_heading'),
|
||||
className: 'MailPage-MailSettings',
|
||||
},
|
||||
[
|
||||
<div className="MailPage-MailSettings-input">
|
||||
<label>
|
||||
{app.translator.trans('core.admin.email.from_label')}
|
||||
<input className="FormControl" value={this.values.mail_from() || ''} oninput={m.withAttr('value', this.values.mail_from)} />
|
||||
<input className="FormControl" bidi={this.values.mail_from} />
|
||||
</label>
|
||||
</div>,
|
||||
],
|
||||
})}
|
||||
]
|
||||
)}
|
||||
|
||||
{FieldSet.component({
|
||||
label: app.translator.trans('core.admin.email.driver_heading'),
|
||||
className: 'MailPage-MailSettings',
|
||||
children: [
|
||||
{FieldSet.component(
|
||||
{
|
||||
label: app.translator.trans('core.admin.email.driver_heading'),
|
||||
className: 'MailPage-MailSettings',
|
||||
},
|
||||
[
|
||||
<div className="MailPage-MailSettings-input">
|
||||
<label>
|
||||
{app.translator.trans('core.admin.email.driver_label')}
|
||||
@@ -96,20 +101,24 @@ export default class MailPage extends Page {
|
||||
/>
|
||||
</label>
|
||||
</div>,
|
||||
],
|
||||
})}
|
||||
]
|
||||
)}
|
||||
|
||||
{this.status.sending ||
|
||||
Alert.component({
|
||||
children: app.translator.trans('core.admin.email.not_sending_message'),
|
||||
dismissible: false,
|
||||
})}
|
||||
Alert.component(
|
||||
{
|
||||
dismissible: false,
|
||||
},
|
||||
app.translator.trans('core.admin.email.not_sending_message')
|
||||
)}
|
||||
|
||||
{fieldKeys.length > 0 &&
|
||||
FieldSet.component({
|
||||
label: app.translator.trans(`core.admin.email.${this.values.mail_driver()}_heading`),
|
||||
className: 'MailPage-MailSettings',
|
||||
children: [
|
||||
FieldSet.component(
|
||||
{
|
||||
label: app.translator.trans(`core.admin.email.${this.values.mail_driver()}_heading`),
|
||||
className: 'MailPage-MailSettings',
|
||||
},
|
||||
[
|
||||
<div className="MailPage-MailSettings-input">
|
||||
{fieldKeys.map((field) => [
|
||||
<label>
|
||||
@@ -119,31 +128,37 @@ export default class MailPage extends Page {
|
||||
this.status.errors[field] && <p className="ValidationError">{this.status.errors[field]}</p>,
|
||||
])}
|
||||
</div>,
|
||||
],
|
||||
})}
|
||||
]
|
||||
)}
|
||||
|
||||
<FieldSet>
|
||||
{Button.component({
|
||||
type: 'submit',
|
||||
className: 'Button Button--primary',
|
||||
children: app.translator.trans('core.admin.email.submit_button'),
|
||||
disabled: !this.changed(),
|
||||
})}
|
||||
{Button.component(
|
||||
{
|
||||
type: 'submit',
|
||||
className: 'Button Button--primary',
|
||||
disabled: !this.changed(),
|
||||
},
|
||||
app.translator.trans('core.admin.email.submit_button')
|
||||
)}
|
||||
</FieldSet>
|
||||
|
||||
{FieldSet.component({
|
||||
label: app.translator.trans('core.admin.email.send_test_mail_heading'),
|
||||
className: 'MailPage-MailSettings',
|
||||
children: [
|
||||
{FieldSet.component(
|
||||
{
|
||||
label: app.translator.trans('core.admin.email.send_test_mail_heading'),
|
||||
className: 'MailPage-MailSettings',
|
||||
},
|
||||
[
|
||||
<div className="helpText">{app.translator.trans('core.admin.email.send_test_mail_text', { email: app.session.user.email() })}</div>,
|
||||
Button.component({
|
||||
className: 'Button Button--primary',
|
||||
children: app.translator.trans('core.admin.email.send_test_mail_button'),
|
||||
disabled: this.sendingTest || this.changed(),
|
||||
onclick: () => this.sendTestEmail(),
|
||||
}),
|
||||
],
|
||||
})}
|
||||
Button.component(
|
||||
{
|
||||
className: 'Button Button--primary',
|
||||
disabled: this.sendingTest || this.changed(),
|
||||
onclick: () => this.sendTestEmail(),
|
||||
},
|
||||
app.translator.trans('core.admin.email.send_test_mail_button')
|
||||
),
|
||||
]
|
||||
)}
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
@@ -156,7 +171,7 @@ export default class MailPage extends Page {
|
||||
const prop = this.values[name];
|
||||
|
||||
if (typeof field === 'string') {
|
||||
return <input className="FormControl" value={prop() || ''} oninput={m.withAttr('value', prop)} />;
|
||||
return <input className="FormControl" bidi={prop} />;
|
||||
} else {
|
||||
return <Select value={prop()} options={field} onchange={prop} />;
|
||||
}
|
||||
@@ -179,9 +194,9 @@ export default class MailPage extends Page {
|
||||
})
|
||||
.then((response) => {
|
||||
this.sendingTest = false;
|
||||
app.alerts.show(
|
||||
(this.testEmailSuccessAlert = new Alert({ type: 'success', children: app.translator.trans('core.admin.email.send_test_mail_success') }))
|
||||
);
|
||||
this.testEmailSuccessAlert = app.alerts.show(app.translator.trans('core.admin.email.send_test_mail_success'), {
|
||||
type: 'success',
|
||||
});
|
||||
})
|
||||
.catch((error) => {
|
||||
this.sendingTest = false;
|
||||
@@ -204,7 +219,9 @@ export default class MailPage extends Page {
|
||||
|
||||
saveSettings(settings)
|
||||
.then(() => {
|
||||
app.alerts.show((this.successAlert = new Alert({ type: 'success', children: app.translator.trans('core.admin.basics.saved_message') })));
|
||||
this.successAlert = app.alerts.show(app.translator.trans('core.admin.basics.saved_message'), {
|
||||
type: 'success',
|
||||
});
|
||||
})
|
||||
.catch(() => {})
|
||||
.then(() => {
|
||||
|
@@ -32,101 +32,109 @@ function filterByRequiredPermissions(groupIds, permission) {
|
||||
}
|
||||
|
||||
export default class PermissionDropdown extends Dropdown {
|
||||
static initProps(props) {
|
||||
super.initProps(props);
|
||||
static initAttrs(attrs) {
|
||||
super.initAttrs(attrs);
|
||||
|
||||
props.className = 'PermissionDropdown';
|
||||
props.buttonClassName = 'Button Button--text';
|
||||
attrs.className = 'PermissionDropdown';
|
||||
attrs.buttonClassName = 'Button Button--text';
|
||||
}
|
||||
|
||||
view() {
|
||||
this.props.children = [];
|
||||
view(vnode) {
|
||||
const children = [];
|
||||
|
||||
let groupIds = app.data.permissions[this.props.permission] || [];
|
||||
let groupIds = app.data.permissions[this.attrs.permission] || [];
|
||||
|
||||
groupIds = filterByRequiredPermissions(groupIds, this.props.permission);
|
||||
groupIds = filterByRequiredPermissions(groupIds, this.attrs.permission);
|
||||
|
||||
const everyone = groupIds.indexOf(Group.GUEST_ID) !== -1;
|
||||
const members = groupIds.indexOf(Group.MEMBER_ID) !== -1;
|
||||
const adminGroup = app.store.getById('groups', Group.ADMINISTRATOR_ID);
|
||||
|
||||
if (everyone) {
|
||||
this.props.label = Badge.component({ icon: 'fas fa-globe' });
|
||||
this.attrs.label = Badge.component({ icon: 'fas fa-globe' });
|
||||
} else if (members) {
|
||||
this.props.label = Badge.component({ icon: 'fas fa-user' });
|
||||
this.attrs.label = Badge.component({ icon: 'fas fa-user' });
|
||||
} else {
|
||||
this.props.label = [badgeForId(Group.ADMINISTRATOR_ID), groupIds.map(badgeForId)];
|
||||
this.attrs.label = [badgeForId(Group.ADMINISTRATOR_ID), groupIds.map(badgeForId)];
|
||||
}
|
||||
|
||||
if (this.showing) {
|
||||
if (this.props.allowGuest) {
|
||||
this.props.children.push(
|
||||
Button.component({
|
||||
children: [Badge.component({ icon: 'fas fa-globe' }), ' ', app.translator.trans('core.admin.permissions_controls.everyone_button')],
|
||||
icon: everyone ? 'fas fa-check' : true,
|
||||
onclick: () => this.save([Group.GUEST_ID]),
|
||||
disabled: this.isGroupDisabled(Group.GUEST_ID),
|
||||
})
|
||||
if (this.attrs.allowGuest) {
|
||||
children.push(
|
||||
Button.component(
|
||||
{
|
||||
icon: everyone ? 'fas fa-check' : true,
|
||||
onclick: () => this.save([Group.GUEST_ID]),
|
||||
disabled: this.isGroupDisabled(Group.GUEST_ID),
|
||||
},
|
||||
[Badge.component({ icon: 'fas fa-globe' }), ' ', app.translator.trans('core.admin.permissions_controls.everyone_button')]
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
this.props.children.push(
|
||||
Button.component({
|
||||
children: [Badge.component({ icon: 'fas fa-user' }), ' ', app.translator.trans('core.admin.permissions_controls.members_button')],
|
||||
icon: members ? 'fas fa-check' : true,
|
||||
onclick: () => this.save([Group.MEMBER_ID]),
|
||||
disabled: this.isGroupDisabled(Group.MEMBER_ID),
|
||||
}),
|
||||
children.push(
|
||||
Button.component(
|
||||
{
|
||||
icon: members ? 'fas fa-check' : true,
|
||||
onclick: () => this.save([Group.MEMBER_ID]),
|
||||
disabled: this.isGroupDisabled(Group.MEMBER_ID),
|
||||
},
|
||||
[Badge.component({ icon: 'fas fa-user' }), ' ', app.translator.trans('core.admin.permissions_controls.members_button')]
|
||||
),
|
||||
|
||||
Separator.component(),
|
||||
|
||||
Button.component({
|
||||
children: [badgeForId(adminGroup.id()), ' ', adminGroup.namePlural()],
|
||||
icon: !everyone && !members ? 'fas fa-check' : true,
|
||||
disabled: !everyone && !members,
|
||||
onclick: (e) => {
|
||||
if (e.shiftKey) e.stopPropagation();
|
||||
this.save([]);
|
||||
Button.component(
|
||||
{
|
||||
icon: !everyone && !members ? 'fas fa-check' : true,
|
||||
disabled: !everyone && !members,
|
||||
onclick: (e) => {
|
||||
if (e.shiftKey) e.stopPropagation();
|
||||
this.save([]);
|
||||
},
|
||||
},
|
||||
})
|
||||
[badgeForId(adminGroup.id()), ' ', adminGroup.namePlural()]
|
||||
)
|
||||
);
|
||||
|
||||
[].push.apply(
|
||||
this.props.children,
|
||||
children,
|
||||
app.store
|
||||
.all('groups')
|
||||
.filter((group) => [Group.ADMINISTRATOR_ID, Group.GUEST_ID, Group.MEMBER_ID].indexOf(group.id()) === -1)
|
||||
.map((group) =>
|
||||
Button.component({
|
||||
children: [badgeForId(group.id()), ' ', group.namePlural()],
|
||||
icon: groupIds.indexOf(group.id()) !== -1 ? 'fas fa-check' : true,
|
||||
onclick: (e) => {
|
||||
if (e.shiftKey) e.stopPropagation();
|
||||
this.toggle(group.id());
|
||||
Button.component(
|
||||
{
|
||||
icon: groupIds.indexOf(group.id()) !== -1 ? 'fas fa-check' : true,
|
||||
onclick: (e) => {
|
||||
if (e.shiftKey) e.stopPropagation();
|
||||
this.toggle(group.id());
|
||||
},
|
||||
disabled: this.isGroupDisabled(group.id()) && this.isGroupDisabled(Group.MEMBER_ID) && this.isGroupDisabled(Group.GUEST_ID),
|
||||
},
|
||||
disabled: this.isGroupDisabled(group.id()) && this.isGroupDisabled(Group.MEMBER_ID) && this.isGroupDisabled(Group.GUEST_ID),
|
||||
})
|
||||
[badgeForId(group.id()), ' ', group.namePlural()]
|
||||
)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
return super.view();
|
||||
return super.view({ ...vnode, children });
|
||||
}
|
||||
|
||||
save(groupIds) {
|
||||
const permission = this.props.permission;
|
||||
const permission = this.attrs.permission;
|
||||
|
||||
app.data.permissions[permission] = groupIds;
|
||||
|
||||
app.request({
|
||||
method: 'POST',
|
||||
url: app.forum.attribute('apiUrl') + '/permission',
|
||||
data: { permission, groupIds },
|
||||
body: { permission, groupIds },
|
||||
});
|
||||
}
|
||||
|
||||
toggle(groupId) {
|
||||
const permission = this.props.permission;
|
||||
const permission = this.attrs.permission;
|
||||
|
||||
let groupIds = app.data.permissions[permission] || [];
|
||||
|
||||
@@ -143,6 +151,6 @@ export default class PermissionDropdown extends Dropdown {
|
||||
}
|
||||
|
||||
isGroupDisabled(id) {
|
||||
return filterByRequiredPermissions([id], this.props.permission).indexOf(id) === -1;
|
||||
return filterByRequiredPermissions([id], this.attrs.permission).indexOf(id) === -1;
|
||||
}
|
||||
}
|
||||
|
@@ -6,7 +6,9 @@ import ItemList from '../../common/utils/ItemList';
|
||||
import icon from '../../common/helpers/icon';
|
||||
|
||||
export default class PermissionGrid extends Component {
|
||||
init() {
|
||||
oninit(vnode) {
|
||||
super.oninit(vnode);
|
||||
|
||||
this.permissions = this.permissionItems().toArray();
|
||||
}
|
||||
|
||||
|
@@ -15,7 +15,7 @@ export default class PermissionsPage extends Page {
|
||||
.all('groups')
|
||||
.filter((group) => [Group.GUEST_ID, Group.MEMBER_ID].indexOf(group.id()) === -1)
|
||||
.map((group) => (
|
||||
<button className="Button Group" onclick={() => app.modal.show(new EditGroupModal({ group }))}>
|
||||
<button className="Button Group" onclick={() => app.modal.show(EditGroupModal, { group })}>
|
||||
{GroupBadge.component({
|
||||
group,
|
||||
className: 'Group-icon',
|
||||
@@ -24,7 +24,7 @@ export default class PermissionsPage extends Page {
|
||||
<span className="Group-name">{group.namePlural()}</span>
|
||||
</button>
|
||||
))}
|
||||
<button className="Button Group Group--add" onclick={() => app.modal.show(new EditGroupModal())}>
|
||||
<button className="Button Group Group--add" onclick={() => app.modal.show(EditGroupModal)}>
|
||||
{icon('fas fa-plus', { className: 'Group-icon' })}
|
||||
<span className="Group-name">{app.translator.trans('core.admin.permissions.new_group_button')}</span>
|
||||
</button>
|
||||
|
@@ -9,18 +9,16 @@ import ItemList from '../../common/utils/ItemList';
|
||||
* avatar/name, with a dropdown of session controls.
|
||||
*/
|
||||
export default class SessionDropdown extends Dropdown {
|
||||
static initProps(props) {
|
||||
super.initProps(props);
|
||||
static initAttrs(attrs) {
|
||||
super.initAttrs(attrs);
|
||||
|
||||
props.className = 'SessionDropdown';
|
||||
props.buttonClassName = 'Button Button--user Button--flat';
|
||||
props.menuClassName = 'Dropdown-menu--right';
|
||||
attrs.className = 'SessionDropdown';
|
||||
attrs.buttonClassName = 'Button Button--user Button--flat';
|
||||
attrs.menuClassName = 'Dropdown-menu--right';
|
||||
}
|
||||
|
||||
view() {
|
||||
this.props.children = this.items().toArray();
|
||||
|
||||
return super.view();
|
||||
view(vnode) {
|
||||
return super.view({ ...vnode, children: this.items().toArray() });
|
||||
}
|
||||
|
||||
getButtonContent() {
|
||||
@@ -39,11 +37,13 @@ export default class SessionDropdown extends Dropdown {
|
||||
|
||||
items.add(
|
||||
'logOut',
|
||||
Button.component({
|
||||
icon: 'fas fa-sign-out-alt',
|
||||
children: app.translator.trans('core.admin.header.log_out_button'),
|
||||
onclick: app.session.logout.bind(app.session),
|
||||
}),
|
||||
Button.component(
|
||||
{
|
||||
icon: 'fas fa-sign-out-alt',
|
||||
onclick: app.session.logout.bind(app.session),
|
||||
},
|
||||
app.translator.trans('core.admin.header.log_out_button')
|
||||
),
|
||||
-100
|
||||
);
|
||||
|
||||
|
@@ -3,23 +3,30 @@ import Button from '../../common/components/Button';
|
||||
import saveSettings from '../utils/saveSettings';
|
||||
|
||||
export default class SettingDropdown extends SelectDropdown {
|
||||
static initProps(props) {
|
||||
super.initProps(props);
|
||||
static initAttrs(attrs) {
|
||||
super.initAttrs(attrs);
|
||||
|
||||
props.className = 'SettingDropdown';
|
||||
props.buttonClassName = 'Button Button--text';
|
||||
props.caretIcon = 'fas fa-caret-down';
|
||||
props.defaultLabel = 'Custom';
|
||||
attrs.className = 'SettingDropdown';
|
||||
attrs.buttonClassName = 'Button Button--text';
|
||||
attrs.caretIcon = 'fas fa-caret-down';
|
||||
attrs.defaultLabel = 'Custom';
|
||||
}
|
||||
|
||||
props.children = props.options.map(({ value, label }) => {
|
||||
const active = app.data.settings[props.key] === value;
|
||||
view(vnode) {
|
||||
return super.view({
|
||||
...vnode,
|
||||
children: this.attrs.options.map(({ value, label }) => {
|
||||
const active = app.data.settings[this.attrs.key] === value;
|
||||
|
||||
return Button.component({
|
||||
children: label,
|
||||
icon: active ? 'fas fa-check' : true,
|
||||
onclick: saveSettings.bind(this, { [props.key]: value }),
|
||||
active,
|
||||
});
|
||||
return Button.component(
|
||||
{
|
||||
icon: active ? 'fas fa-check' : true,
|
||||
onclick: saveSettings.bind(this, { [this.attrs.key]: value }),
|
||||
active,
|
||||
},
|
||||
label
|
||||
);
|
||||
}),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
@@ -3,7 +3,9 @@ import Button from '../../common/components/Button';
|
||||
import saveSettings from '../utils/saveSettings';
|
||||
|
||||
export default class SettingsModal extends Modal {
|
||||
init() {
|
||||
oninit(vnode) {
|
||||
super.oninit(vnode);
|
||||
|
||||
this.settings = {};
|
||||
this.loading = false;
|
||||
}
|
||||
@@ -33,7 +35,7 @@ export default class SettingsModal extends Modal {
|
||||
}
|
||||
|
||||
setting(key, fallback = '') {
|
||||
this.settings[key] = this.settings[key] || m.prop(app.data.settings[key] || fallback);
|
||||
this.settings[key] = this.settings[key] || m.stream(app.data.settings[key] || fallback);
|
||||
|
||||
return this.settings[key];
|
||||
}
|
||||
|
@@ -46,7 +46,7 @@ export default class StatusWidget extends DashboardWidget {
|
||||
}
|
||||
|
||||
handleClearCache(e) {
|
||||
app.modal.show(new LoadingModal());
|
||||
app.modal.show(LoadingModal);
|
||||
|
||||
app
|
||||
.request({
|
||||
|
@@ -1,32 +1,28 @@
|
||||
import Button from '../../common/components/Button';
|
||||
|
||||
export default class UploadImageButton extends Button {
|
||||
init() {
|
||||
this.loading = false;
|
||||
}
|
||||
loading = false;
|
||||
|
||||
view() {
|
||||
this.props.loading = this.loading;
|
||||
this.props.className = (this.props.className || '') + ' Button';
|
||||
view(vnode) {
|
||||
this.attrs.loading = this.loading;
|
||||
this.attrs.className = (this.attrs.className || '') + ' Button';
|
||||
|
||||
if (app.data.settings[this.props.name + '_path']) {
|
||||
this.props.onclick = this.remove.bind(this);
|
||||
this.props.children = app.translator.trans('core.admin.upload_image.remove_button');
|
||||
if (app.data.settings[this.attrs.name + '_path']) {
|
||||
this.attrs.onclick = this.remove.bind(this);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<p>
|
||||
<img src={app.forum.attribute(this.props.name + 'Url')} alt="" />
|
||||
<img src={app.forum.attribute(this.attrs.name + 'Url')} alt="" />
|
||||
</p>
|
||||
<p>{super.view()}</p>
|
||||
<p>{super.view({ ...vnode, children: app.translator.trans('core.admin.upload_image.remove_button') })}</p>
|
||||
</div>
|
||||
);
|
||||
} else {
|
||||
this.props.onclick = this.upload.bind(this);
|
||||
this.props.children = app.translator.trans('core.admin.upload_image.upload_button');
|
||||
this.attrs.onclick = this.upload.bind(this);
|
||||
}
|
||||
|
||||
return super.view();
|
||||
return super.view({ ...vnode, children: app.translator.trans('core.admin.upload_image.upload_button') });
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -42,8 +38,8 @@ export default class UploadImageButton extends Button {
|
||||
.hide()
|
||||
.click()
|
||||
.on('change', (e) => {
|
||||
const data = new FormData();
|
||||
data.append(this.props.name, $(e.target)[0].files[0]);
|
||||
const body = new FormData();
|
||||
body.append(this.attrs.name, $(e.target)[0].files[0]);
|
||||
|
||||
this.loading = true;
|
||||
m.redraw();
|
||||
@@ -53,7 +49,7 @@ export default class UploadImageButton extends Button {
|
||||
method: 'POST',
|
||||
url: this.resourceUrl(),
|
||||
serialize: (raw) => raw,
|
||||
data,
|
||||
body,
|
||||
})
|
||||
.then(this.success.bind(this), this.failure.bind(this));
|
||||
});
|
||||
@@ -75,7 +71,7 @@ export default class UploadImageButton extends Button {
|
||||
}
|
||||
|
||||
resourceUrl() {
|
||||
return app.forum.attribute('apiUrl') + '/' + this.props.name;
|
||||
return app.forum.attribute('apiUrl') + '/' + this.attrs.name;
|
||||
}
|
||||
|
||||
/**
|
||||
|
@@ -12,11 +12,11 @@ import MailPage from './components/MailPage';
|
||||
*/
|
||||
export default function (app) {
|
||||
app.routes = {
|
||||
dashboard: { path: '/', component: DashboardPage.component() },
|
||||
basics: { path: '/basics', component: BasicsPage.component() },
|
||||
permissions: { path: '/permissions', component: PermissionsPage.component() },
|
||||
appearance: { path: '/appearance', component: AppearancePage.component() },
|
||||
extensions: { path: '/extensions', component: ExtensionsPage.component() },
|
||||
mail: { path: '/mail', component: MailPage.component() },
|
||||
dashboard: { path: '/', component: DashboardPage },
|
||||
basics: { path: '/basics', component: BasicsPage },
|
||||
permissions: { path: '/permissions', component: PermissionsPage },
|
||||
appearance: { path: '/appearance', component: AppearancePage },
|
||||
extensions: { path: '/extensions', component: ExtensionsPage },
|
||||
mail: { path: '/mail', component: MailPage },
|
||||
};
|
||||
}
|
||||
|
@@ -7,7 +7,7 @@ export default function saveSettings(settings) {
|
||||
.request({
|
||||
method: 'POST',
|
||||
url: app.forum.attribute('apiUrl') + '/settings',
|
||||
data: settings,
|
||||
body: settings,
|
||||
})
|
||||
.catch((error) => {
|
||||
app.data.settings = oldSettings;
|
||||
|
@@ -1,5 +1,4 @@
|
||||
import ItemList from './utils/ItemList';
|
||||
import Alert from './components/Alert';
|
||||
import Button from './components/Button';
|
||||
import ModalManager from './components/ModalManager';
|
||||
import AlertManager from './components/AlertManager';
|
||||
@@ -23,6 +22,8 @@ import Group from './models/Group';
|
||||
import Notification from './models/Notification';
|
||||
import { flattenDeep } from 'lodash-es';
|
||||
import PageState from './states/PageState';
|
||||
import ModalManagerState from './states/ModalManagerState';
|
||||
import AlertManagerState from './states/AlertManagerState';
|
||||
|
||||
/**
|
||||
* The `App` class provides a container for an application, as well as various
|
||||
@@ -109,13 +110,13 @@ export default class Application {
|
||||
booted = false;
|
||||
|
||||
/**
|
||||
* An Alert that was shown as a result of an AJAX request error. If present,
|
||||
* it will be dismissed on the next successful request.
|
||||
* The key for an Alert that was shown as a result of an AJAX request error.
|
||||
* If present, it will be dismissed on the next successful request.
|
||||
*
|
||||
* @type {null|Alert}
|
||||
* @type {int}
|
||||
* @private
|
||||
*/
|
||||
requestError = null;
|
||||
requestErrorAlert = null;
|
||||
|
||||
/**
|
||||
* The page the app is currently on.
|
||||
@@ -139,6 +140,20 @@ export default class Application {
|
||||
*/
|
||||
previous = new PageState(null);
|
||||
|
||||
/*
|
||||
* An object that manages modal state.
|
||||
*
|
||||
* @type {ModalManagerState}
|
||||
*/
|
||||
modal = new ModalManagerState();
|
||||
|
||||
/**
|
||||
* An object that manages the state of active alerts.
|
||||
*
|
||||
* @type {AlertManagerState}
|
||||
*/
|
||||
alerts = new AlertManagerState();
|
||||
|
||||
data;
|
||||
|
||||
title = '';
|
||||
@@ -174,8 +189,9 @@ export default class Application {
|
||||
}
|
||||
|
||||
mount(basePath = '') {
|
||||
this.modal = m.mount(document.getElementById('modal'), <ModalManager />);
|
||||
this.alerts = m.mount(document.getElementById('alerts'), <AlertManager />);
|
||||
// An object with a callable view property is used in order to pass arguments to the component; see https://mithril.js.org/mount.html
|
||||
m.mount(document.getElementById('modal'), { view: () => ModalManager.component({ state: this.modal }) });
|
||||
m.mount(document.getElementById('alerts'), { view: () => AlertManager.component({ state: this.alerts }) });
|
||||
|
||||
this.drawer = new Drawer();
|
||||
|
||||
@@ -215,6 +231,16 @@ export default class Application {
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine the current screen mode, based on our media queries.
|
||||
*
|
||||
* @returns {String} - one of "phone", "tablet", "desktop" or "desktop-hd"
|
||||
*/
|
||||
screen() {
|
||||
const styles = getComputedStyle(document.documentElement);
|
||||
return styles.getPropertyValue('--flarum-screen');
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the <title> of the page.
|
||||
*
|
||||
@@ -237,7 +263,10 @@ export default class Application {
|
||||
}
|
||||
|
||||
updateTitle() {
|
||||
document.title = (this.titleCount ? `(${this.titleCount}) ` : '') + (this.title ? this.title + ' - ' : '') + this.forum.attribute('title');
|
||||
const count = this.titleCount ? `(${this.titleCount}) ` : '';
|
||||
const pageTitleWithSeparator = this.title && m.route.get() !== '/' ? this.title + ' - ' : '';
|
||||
const title = this.forum.attribute('title');
|
||||
document.title = count + pageTitleWithSeparator + title;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -310,22 +339,18 @@ export default class Application {
|
||||
}
|
||||
};
|
||||
|
||||
if (this.requestError) this.alerts.dismiss(this.requestError.alert);
|
||||
if (this.requestErrorAlert) this.alerts.dismiss(this.requestErrorAlert);
|
||||
|
||||
// Now make the request. If it's a failure, inspect the error that was
|
||||
// returned and show an alert containing its contents.
|
||||
const deferred = m.deferred();
|
||||
|
||||
m.request(options).then(
|
||||
(response) => deferred.resolve(response),
|
||||
return m.request(options).then(
|
||||
(response) => response,
|
||||
(error) => {
|
||||
this.requestError = error;
|
||||
|
||||
let children;
|
||||
let content;
|
||||
|
||||
switch (error.status) {
|
||||
case 422:
|
||||
children = error.response.errors
|
||||
content = error.response.errors
|
||||
.map((error) => [error.detail, <br />])
|
||||
.reduce((a, b) => a.concat(b), [])
|
||||
.slice(0, -1);
|
||||
@@ -333,36 +358,37 @@ export default class Application {
|
||||
|
||||
case 401:
|
||||
case 403:
|
||||
children = app.translator.trans('core.lib.error.permission_denied_message');
|
||||
content = app.translator.trans('core.lib.error.permission_denied_message');
|
||||
break;
|
||||
|
||||
case 404:
|
||||
case 410:
|
||||
children = app.translator.trans('core.lib.error.not_found_message');
|
||||
content = app.translator.trans('core.lib.error.not_found_message');
|
||||
break;
|
||||
|
||||
case 429:
|
||||
children = app.translator.trans('core.lib.error.rate_limit_exceeded_message');
|
||||
content = app.translator.trans('core.lib.error.rate_limit_exceeded_message');
|
||||
break;
|
||||
|
||||
default:
|
||||
children = app.translator.trans('core.lib.error.generic_message');
|
||||
content = app.translator.trans('core.lib.error.generic_message');
|
||||
}
|
||||
|
||||
const isDebug = app.forum.attribute('debug');
|
||||
// contains a formatted errors if possible, response must be an JSON API array of errors
|
||||
// the details property is decoded to transform escaped characters such as '\n'
|
||||
const formattedError = error.response && Array.isArray(error.response.errors) && error.response.errors.map((e) => decodeURI(e.detail));
|
||||
const errors = error.response && error.response.errors;
|
||||
const formattedError = Array.isArray(errors) && errors[0] && errors[0].detail && errors.map((e) => decodeURI(e.detail));
|
||||
|
||||
error.alert = new Alert({
|
||||
error.alert = {
|
||||
type: 'error',
|
||||
children,
|
||||
content,
|
||||
controls: isDebug && [
|
||||
<Button className="Button Button--link" onclick={this.showDebug.bind(this, error, formattedError)}>
|
||||
Debug
|
||||
</Button>,
|
||||
],
|
||||
});
|
||||
};
|
||||
|
||||
try {
|
||||
options.errorHandler(error);
|
||||
@@ -378,14 +404,12 @@ export default class Application {
|
||||
console.groupEnd();
|
||||
}
|
||||
|
||||
this.alerts.show(error.alert);
|
||||
this.requestErrorAlert = this.alerts.show(error.alert.content, error.alert);
|
||||
}
|
||||
|
||||
deferred.reject(error);
|
||||
return Promise.reject(error);
|
||||
}
|
||||
);
|
||||
|
||||
return deferred.promise;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -394,9 +418,9 @@ export default class Application {
|
||||
* @private
|
||||
*/
|
||||
showDebug(error, formattedError) {
|
||||
this.alerts.dismiss(this.requestError.alert);
|
||||
this.alerts.dismiss(this.requestErrorAlert);
|
||||
|
||||
this.modal.show(new RequestErrorModal({ error, formattedError }));
|
||||
this.modal.show(RequestErrorModal, { error, formattedError });
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -408,9 +432,19 @@ export default class Application {
|
||||
* @public
|
||||
*/
|
||||
route(name, params = {}) {
|
||||
const url = this.routes[name].path.replace(/:([^\/]+)/g, (m, key) => extract(params, key));
|
||||
const queryString = m.route.buildQueryString(params);
|
||||
const prefix = m.route.mode === 'pathname' ? app.forum.attribute('basePath') : '';
|
||||
const route = this.routes[name];
|
||||
|
||||
if (!route) throw new Error(`Route '${name}' does not exist`);
|
||||
|
||||
const url = route.path.replace(/:([^\/]+)/g, (m, key) => extract(params, key));
|
||||
|
||||
// Remove falsy values in params to avoid having urls like '/?sort&q'
|
||||
for (const key in params) {
|
||||
if (params.hasOwnProperty(key) && !params[key]) delete params[key];
|
||||
}
|
||||
|
||||
const queryString = m.buildQueryString(params);
|
||||
const prefix = m.route.prefix === '' ? this.forum.attribute('basePath') : '';
|
||||
|
||||
return prefix + url + (queryString ? '?' + queryString : '');
|
||||
}
|
||||
|
@@ -1,221 +0,0 @@
|
||||
/*
|
||||
* This file is part of Flarum.
|
||||
*
|
||||
* (c) Toby Zerner <toby.zerner@gmail.com>
|
||||
*
|
||||
* For the full copyright and license information, please view the LICENSE
|
||||
* file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
/**
|
||||
* The `Component` class defines a user interface 'building block'. A component
|
||||
* can generate a virtual DOM to be rendered on each redraw.
|
||||
*
|
||||
* An instance's virtual DOM can be retrieved directly using the {@link
|
||||
* Component#render} method.
|
||||
*
|
||||
* @example
|
||||
* this.myComponentInstance = new MyComponent({foo: 'bar'});
|
||||
* return m('div', this.myComponentInstance.render());
|
||||
*
|
||||
* Alternatively, components can be nested, letting Mithril take care of
|
||||
* instance persistence. For this, the static {@link Component.component} method
|
||||
* can be used.
|
||||
*
|
||||
* @example
|
||||
* return m('div', MyComponent.component({foo: 'bar'));
|
||||
*
|
||||
* @see https://lhorie.github.io/mithril/mithril.component.html
|
||||
* @abstract
|
||||
*/
|
||||
export default class Component {
|
||||
/**
|
||||
* @param {Object} props
|
||||
* @param {Array|Object} children
|
||||
* @public
|
||||
*/
|
||||
constructor(props = {}, children = null) {
|
||||
if (children) props.children = children;
|
||||
|
||||
this.constructor.initProps(props);
|
||||
|
||||
/**
|
||||
* The properties passed into the component.
|
||||
*
|
||||
* @type {Object}
|
||||
*/
|
||||
this.props = props;
|
||||
|
||||
/**
|
||||
* The root DOM element for the component.
|
||||
*
|
||||
* @type DOMElement
|
||||
* @public
|
||||
*/
|
||||
this.element = null;
|
||||
|
||||
/**
|
||||
* Whether or not to retain the component's subtree on redraw.
|
||||
*
|
||||
* @type {boolean}
|
||||
* @public
|
||||
*/
|
||||
this.retain = false;
|
||||
|
||||
this.init();
|
||||
}
|
||||
|
||||
/**
|
||||
* Called when the component is constructed.
|
||||
*
|
||||
* @protected
|
||||
*/
|
||||
init() {}
|
||||
|
||||
/**
|
||||
* Called when the component is destroyed, i.e. after a redraw where it is no
|
||||
* longer a part of the view.
|
||||
*
|
||||
* @see https://lhorie.github.io/mithril/mithril.component.html#unloading-components
|
||||
* @param {Object} e
|
||||
* @public
|
||||
*/
|
||||
onunload() {}
|
||||
|
||||
/**
|
||||
* Get the renderable virtual DOM that represents the component's view.
|
||||
*
|
||||
* This should NOT be overridden by subclasses. Subclasses wishing to define
|
||||
* their virtual DOM should override Component#view instead.
|
||||
*
|
||||
* @example
|
||||
* this.myComponentInstance = new MyComponent({foo: 'bar'});
|
||||
* return m('div', this.myComponentInstance.render());
|
||||
*
|
||||
* @returns {Object}
|
||||
* @final
|
||||
* @public
|
||||
*/
|
||||
render() {
|
||||
const vdom = this.retain ? { subtree: 'retain' } : this.view();
|
||||
|
||||
// Override the root element's config attribute with our own function, which
|
||||
// will set the component instance's element property to the root DOM
|
||||
// element, and then run the component class' config method.
|
||||
vdom.attrs = vdom.attrs || {};
|
||||
|
||||
const originalConfig = vdom.attrs.config;
|
||||
|
||||
vdom.attrs.config = (...args) => {
|
||||
this.element = args[0];
|
||||
this.config.apply(this, args.slice(1));
|
||||
if (originalConfig) originalConfig.apply(this, args);
|
||||
};
|
||||
|
||||
return vdom;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a jQuery object for this component's element. If you pass in a
|
||||
* selector string, this method will return a jQuery object, using the current
|
||||
* element as its buffer.
|
||||
*
|
||||
* For example, calling `component.$('li')` will return a jQuery object
|
||||
* containing all of the `li` elements inside the DOM element of this
|
||||
* component.
|
||||
*
|
||||
* @param {String} [selector] a jQuery-compatible selector string
|
||||
* @returns {jQuery} the jQuery object for the DOM node
|
||||
* @final
|
||||
* @public
|
||||
*/
|
||||
$(selector) {
|
||||
const $element = $(this.element);
|
||||
|
||||
return selector ? $element.find(selector) : $element;
|
||||
}
|
||||
|
||||
/**
|
||||
* Called after the component's root element is redrawn. This hook can be used
|
||||
* to perform any actions on the DOM, both on the initial draw and any
|
||||
* subsequent redraws. See Mithril's documentation for more information.
|
||||
*
|
||||
* @see https://lhorie.github.io/mithril/mithril.html#the-config-attribute
|
||||
* @param {Boolean} isInitialized
|
||||
* @param {Object} context
|
||||
* @param {Object} vdom
|
||||
* @public
|
||||
*/
|
||||
config() {}
|
||||
|
||||
/**
|
||||
* Get the virtual DOM that represents the component's view.
|
||||
*
|
||||
* @return {Object} The virtual DOM
|
||||
* @protected
|
||||
*/
|
||||
view() {
|
||||
throw new Error('Component#view must be implemented by subclass');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a Mithril component object for this component, preloaded with props.
|
||||
*
|
||||
* @see https://lhorie.github.io/mithril/mithril.component.html
|
||||
* @param {Object} [props] Properties to set on the component
|
||||
* @param children
|
||||
* @return {Object} The Mithril component object
|
||||
* @property {function} controller
|
||||
* @property {function} view
|
||||
* @property {Object} component The class of this component
|
||||
* @property {Object} props The props that were passed to the component
|
||||
* @public
|
||||
*/
|
||||
static component(props = {}, children = null) {
|
||||
const componentProps = Object.assign({}, props);
|
||||
|
||||
if (children) componentProps.children = children;
|
||||
|
||||
this.initProps(componentProps);
|
||||
|
||||
// Set up a function for Mithril to get the component's view. It will accept
|
||||
// the component's controller (which happens to be the component itself, in
|
||||
// our case), update its props with the ones supplied, and then render the view.
|
||||
const view = (component) => {
|
||||
component.props = componentProps;
|
||||
return component.render();
|
||||
};
|
||||
|
||||
// Mithril uses this property on the view function to cache component
|
||||
// controllers between redraws, thus persisting component state.
|
||||
view.$original = this.prototype.view;
|
||||
|
||||
// Our output object consists of a controller constructor + a view function
|
||||
// which Mithril will use to instantiate and render the component. We also
|
||||
// attach a reference to the props that were passed through and the
|
||||
// component's class for reference.
|
||||
const output = {
|
||||
controller: this.bind(undefined, componentProps),
|
||||
view: view,
|
||||
props: componentProps,
|
||||
component: this,
|
||||
};
|
||||
|
||||
// If a `key` prop was set, then we'll assume that we want that to actually
|
||||
// show up as an attribute on the component object so that Mithril's key
|
||||
// algorithm can be applied.
|
||||
if (componentProps.key) {
|
||||
output.attrs = { key: componentProps.key };
|
||||
}
|
||||
|
||||
return output;
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize the component's props.
|
||||
*
|
||||
* @param {Object} props
|
||||
* @public
|
||||
*/
|
||||
static initProps(props) {}
|
||||
}
|
136
js/src/common/Component.ts
Normal file
136
js/src/common/Component.ts
Normal file
@@ -0,0 +1,136 @@
|
||||
import * as Mithril from 'mithril';
|
||||
|
||||
export type ComponentAttrs = {
|
||||
className?: string;
|
||||
|
||||
[key: string]: any;
|
||||
};
|
||||
|
||||
/**
|
||||
* The `Component` class defines a user interface 'building block'. A component
|
||||
* generates a virtual DOM to be rendered on each redraw.
|
||||
*
|
||||
* Essentially, this is a wrapper for Mithril's components that adds several useful features:
|
||||
*
|
||||
* - In the `oninit` and `onbeforeupdate` lifecycle hooks, we store vnode attrs in `this.attrs.
|
||||
* This allows us to use attrs across components without having to pass the vnode to every single
|
||||
* method.
|
||||
* - The static `initAttrs` method allows a convenient way to provide defaults (or to otherwise modify)
|
||||
* the attrs that have been passed into a component.
|
||||
* - When the component is created in the DOM, we store its DOM element under `this.element`; this lets
|
||||
* us use jQuery to modify child DOM state from internal methods via the `this.$()` method.
|
||||
* - A convenience `component` method, which serves as an alternative to hyperscript and JSX.
|
||||
*
|
||||
* As with other Mithril components, components extending Component can be initialized
|
||||
* and nested using JSX, hyperscript, or a combination of both. The `component` method can also
|
||||
* be used.
|
||||
*
|
||||
* @example
|
||||
* return m('div', <MyComponent foo="bar"><p>Hello World</p></MyComponent>);
|
||||
*
|
||||
* @example
|
||||
* return m('div', MyComponent.component({foo: 'bar'), m('p', 'Hello World!'));
|
||||
*
|
||||
* @see https://mithril.js.org/components.html
|
||||
*/
|
||||
export default abstract class Component<T extends ComponentAttrs = any> implements Mithril.ClassComponent<T> {
|
||||
/**
|
||||
* The root DOM element for the component.
|
||||
*/
|
||||
protected element!: Element;
|
||||
|
||||
/**
|
||||
* The attributes passed into the component.
|
||||
*
|
||||
* @see https://mithril.js.org/components.html#passing-data-to-components
|
||||
*/
|
||||
protected attrs!: T;
|
||||
|
||||
/**
|
||||
* @inheritdoc
|
||||
*/
|
||||
abstract view(vnode: Mithril.Vnode<T, this>): Mithril.Children;
|
||||
|
||||
/**
|
||||
* @inheritdoc
|
||||
*/
|
||||
oninit(vnode: Mithril.Vnode<T, this>) {
|
||||
this.setAttrs(vnode.attrs);
|
||||
}
|
||||
|
||||
/**
|
||||
* @inheritdoc
|
||||
*/
|
||||
oncreate(vnode: Mithril.VnodeDOM<T, this>) {
|
||||
this.element = vnode.dom;
|
||||
}
|
||||
|
||||
/**
|
||||
* @inheritdoc
|
||||
*/
|
||||
onbeforeupdate(vnode: Mithril.VnodeDOM<T, this>) {
|
||||
this.setAttrs(vnode.attrs);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a jQuery object for this component's element. If you pass in a
|
||||
* selector string, this method will return a jQuery object, using the current
|
||||
* element as its buffer.
|
||||
*
|
||||
* For example, calling `component.$('li')` will return a jQuery object
|
||||
* containing all of the `li` elements inside the DOM element of this
|
||||
* component.
|
||||
*
|
||||
* @param {String} [selector] a jQuery-compatible selector string
|
||||
* @returns {jQuery} the jQuery object for the DOM node
|
||||
* @final
|
||||
*/
|
||||
protected $(selector) {
|
||||
const $element = $(this.element);
|
||||
|
||||
return selector ? $element.find(selector) : $element;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convenience method to attach a component without JSX.
|
||||
* Has the same effect as calling `m(THIS_CLASS, attrs, children)`.
|
||||
*
|
||||
* @see https://mithril.js.org/hyperscript.html#mselector,-attributes,-children
|
||||
*/
|
||||
static component(attrs = {}, children = null): Mithril.Vnode {
|
||||
const componentAttrs = Object.assign({}, attrs);
|
||||
|
||||
return m(this as any, componentAttrs, children);
|
||||
}
|
||||
|
||||
/**
|
||||
* Saves a reference to the vnode attrs after running them through initAttrs,
|
||||
* and checking for common issues.
|
||||
*/
|
||||
private setAttrs(attrs: T = {} as T): void {
|
||||
(this.constructor as typeof Component).initAttrs(attrs);
|
||||
|
||||
if (attrs) {
|
||||
if ('children' in attrs) {
|
||||
throw new Error(
|
||||
`[${
|
||||
(this.constructor as any).name
|
||||
}] The "children" attribute of attrs should never be used. Either pass children in as the vnode children or rename the attribute`
|
||||
);
|
||||
}
|
||||
|
||||
if ('tag' in attrs) {
|
||||
throw new Error(`[${(this.constructor as any).name}] You cannot use the "tag" attribute name with Mithril 2.`);
|
||||
}
|
||||
}
|
||||
|
||||
this.attrs = attrs;
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize the component's attrs.
|
||||
*
|
||||
* This can be used to assign default values for missing, optional attrs.
|
||||
*/
|
||||
protected static initAttrs<T>(attrs: T): void {}
|
||||
}
|
75
js/src/common/Fragment.ts
Normal file
75
js/src/common/Fragment.ts
Normal file
@@ -0,0 +1,75 @@
|
||||
import * as Mithril from 'mithril';
|
||||
|
||||
/**
|
||||
* The `Fragment` class represents a chunk of DOM that is rendered once with Mithril and then takes
|
||||
* over control of its own DOM and lifecycle.
|
||||
*
|
||||
* This is very similar to the `Component` wrapper class, but is used for more fine-grained control over
|
||||
* the rendering and display of some significant chunks of the DOM. In contrast to components, fragments
|
||||
* do not offer Mithril's lifecycle hooks.
|
||||
*
|
||||
* Use this when you want to enjoy the benefits of JSX / VDOM for initial rendering, combined with
|
||||
* small helper methods that then make updates to that DOM directly, instead of fully redrawing
|
||||
* everything through Mithril.
|
||||
*
|
||||
* This should only be used when necessary, and only with `m.render`. If you are unsure whether you need
|
||||
* this or `Component, you probably need `Component`.
|
||||
*/
|
||||
export default abstract class Fragment implements Mithril.ClassComponent {
|
||||
/**
|
||||
* The root DOM element for the fragment.
|
||||
*/
|
||||
protected element!: Element;
|
||||
|
||||
/**
|
||||
* Returns a jQuery object for this fragment's element. If you pass in a
|
||||
* selector string, this method will return a jQuery object, using the current
|
||||
* element as its buffer.
|
||||
*
|
||||
* For example, calling `fragment.$('li')` will return a jQuery object
|
||||
* containing all of the `li` elements inside the DOM element of this
|
||||
* fragment.
|
||||
*
|
||||
* @param {String} [selector] a jQuery-compatible selector string
|
||||
* @returns {jQuery} the jQuery object for the DOM node
|
||||
* @final
|
||||
*/
|
||||
public $(selector) {
|
||||
const $element = $(this.element);
|
||||
|
||||
return selector ? $element.find(selector) : $element;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the renderable virtual DOM that represents the fragment's view.
|
||||
*
|
||||
* This should NOT be overridden by subclasses. Subclasses wishing to define
|
||||
* their virtual DOM should override Fragment#view instead.
|
||||
*
|
||||
* @example
|
||||
* const fragment = new MyFragment();
|
||||
* m.render(document.body, fragment.render());
|
||||
*
|
||||
* @final
|
||||
*/
|
||||
public render(): Mithril.Vnode {
|
||||
const vdom = this.view();
|
||||
|
||||
vdom.attrs = vdom.attrs || {};
|
||||
|
||||
const originalOnCreate = vdom.attrs.oncreate;
|
||||
|
||||
vdom.attrs.oncreate = (vnode) => {
|
||||
this.element = vnode.dom;
|
||||
if (this.oncreate) this.oncreate.apply(this, vnode);
|
||||
if (originalOnCreate) originalOnCreate.apply(this, vnode);
|
||||
};
|
||||
|
||||
return vdom;
|
||||
}
|
||||
|
||||
/**
|
||||
* @inheritdoc
|
||||
*/
|
||||
abstract view();
|
||||
}
|
@@ -161,7 +161,7 @@ export default class Model {
|
||||
{
|
||||
method: this.exists ? 'PATCH' : 'POST',
|
||||
url: app.forum.attribute('apiUrl') + this.apiEndpoint(),
|
||||
data: request,
|
||||
body: request,
|
||||
},
|
||||
options
|
||||
)
|
||||
@@ -180,7 +180,7 @@ export default class Model {
|
||||
// old data! We'll revert to that and let others handle the error.
|
||||
(response) => {
|
||||
this.pushData(oldData);
|
||||
m.lazyRedraw();
|
||||
m.redraw();
|
||||
throw response;
|
||||
}
|
||||
);
|
||||
@@ -189,13 +189,13 @@ export default class Model {
|
||||
/**
|
||||
* Send a request to delete the resource.
|
||||
*
|
||||
* @param {Object} data Data to send along with the DELETE request.
|
||||
* @param {Object} body Data to send along with the DELETE request.
|
||||
* @param {Object} [options]
|
||||
* @return {Promise}
|
||||
* @public
|
||||
*/
|
||||
delete(data, options = {}) {
|
||||
if (!this.exists) return m.deferred().resolve().promise;
|
||||
delete(body, options = {}) {
|
||||
if (!this.exists) return Promise.resolve();
|
||||
|
||||
return app
|
||||
.request(
|
||||
@@ -203,7 +203,7 @@ export default class Model {
|
||||
{
|
||||
method: 'DELETE',
|
||||
url: app.forum.attribute('apiUrl') + this.apiEndpoint(),
|
||||
data,
|
||||
body,
|
||||
},
|
||||
options
|
||||
)
|
||||
|
@@ -30,13 +30,13 @@ export default class Session {
|
||||
* @return {Promise}
|
||||
* @public
|
||||
*/
|
||||
login(data, options = {}) {
|
||||
login(body, options = {}) {
|
||||
return app.request(
|
||||
Object.assign(
|
||||
{
|
||||
method: 'POST',
|
||||
url: app.forum.attribute('baseUrl') + '/login',
|
||||
data,
|
||||
url: `${app.forum.attribute('baseUrl')}/login`,
|
||||
body,
|
||||
},
|
||||
options
|
||||
)
|
||||
@@ -49,6 +49,6 @@ export default class Session {
|
||||
* @public
|
||||
*/
|
||||
logout() {
|
||||
window.location = app.forum.attribute('baseUrl') + '/logout?token=' + this.csrfToken;
|
||||
window.location = `${app.forum.attribute('baseUrl')}/logout?token=${this.csrfToken}`;
|
||||
}
|
||||
}
|
||||
|
@@ -82,13 +82,13 @@ export default class Store {
|
||||
* @public
|
||||
*/
|
||||
find(type, id, query = {}, options = {}) {
|
||||
let data = query;
|
||||
let params = query;
|
||||
let url = app.forum.attribute('apiUrl') + '/' + type;
|
||||
|
||||
if (id instanceof Array) {
|
||||
url += '?filter[id]=' + id.join(',');
|
||||
} else if (typeof id === 'object') {
|
||||
data = id;
|
||||
params = id;
|
||||
} else if (id) {
|
||||
url += '/' + id;
|
||||
}
|
||||
@@ -99,7 +99,7 @@ export default class Store {
|
||||
{
|
||||
method: 'GET',
|
||||
url,
|
||||
data,
|
||||
params,
|
||||
},
|
||||
options
|
||||
)
|
||||
|
@@ -1,4 +1,3 @@
|
||||
import User from './models/User';
|
||||
import username from './helpers/username';
|
||||
import extract from './utils/extract';
|
||||
|
||||
@@ -71,18 +70,34 @@ export default class Translator {
|
||||
const match = part.match(new RegExp('{([a-z0-9_]+)}|<(/?)([a-z0-9_]+)>', 'i'));
|
||||
|
||||
if (match) {
|
||||
// Either an opening or closing tag.
|
||||
if (match[1]) {
|
||||
open[0].push(input[match[1]]);
|
||||
} else if (match[3]) {
|
||||
if (match[2]) {
|
||||
// Closing tag. We start by removing all raw children (generally in the form of strings) from the temporary
|
||||
// holding array, then run them through m.fragment to convert them to vnodes. Usually this will just give us a
|
||||
// text vnode, but using m.fragment as opposed to an explicit conversion should be more flexible. This is necessary because
|
||||
// otherwise, our generated vnode will have raw strings as its children, and mithril expects vnodes.
|
||||
// Finally, we add the now-processed vnodes back onto the holding array (which is the same object in memory as the
|
||||
// children array of the vnode we are currently processing), and remove the reference to the holding array so that
|
||||
// further text will be added to the full set of returned elements.
|
||||
const rawChildren = open[0].splice(0, open[0].length);
|
||||
open[0].push(...m.fragment(rawChildren).children);
|
||||
open.shift();
|
||||
} else {
|
||||
// If a vnode with a matching tag was provided in the translator input, we use that. Otherwise, we create a new vnode
|
||||
// with this tag, and an empty children array (since we're expecting to insert children, as that's the point of having this in translator)
|
||||
let tag = input[match[3]] || { tag: match[3], children: [] };
|
||||
open[0].push(tag);
|
||||
// Insert the tag's children array as the first element of open, so that text in between the opening
|
||||
// and closing tags will be added to the tag's children, not to the full set of returned elements.
|
||||
open.unshift(tag.children || tag);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Not an html tag, we add it to open[0], which is either the full set of returned elements (vnodes and text),
|
||||
// or if an html tag is currently being processed, the children attribute of that html tag's vnode.
|
||||
open[0].push(part);
|
||||
}
|
||||
});
|
||||
|
@@ -13,14 +13,17 @@ import RequestError from './utils/RequestError';
|
||||
import abbreviateNumber from './utils/abbreviateNumber';
|
||||
import * as string from './utils/string';
|
||||
import SubtreeRetainer from './utils/SubtreeRetainer';
|
||||
import setRouteWithForcedRefresh from './utils/setRouteWithForcedRefresh';
|
||||
import extract from './utils/extract';
|
||||
import ScrollListener from './utils/ScrollListener';
|
||||
import stringToColor from './utils/stringToColor';
|
||||
import subclassOf from './utils/subclassOf';
|
||||
import patchMithril from './utils/patchMithril';
|
||||
import classList from './utils/classList';
|
||||
import extractText from './utils/extractText';
|
||||
import formatNumber from './utils/formatNumber';
|
||||
import mapRoutes from './utils/mapRoutes';
|
||||
import withAttr from './utils/withAttr';
|
||||
import Notification from './models/Notification';
|
||||
import User from './models/User';
|
||||
import Post from './models/Post';
|
||||
@@ -61,6 +64,7 @@ import highlight from './helpers/highlight';
|
||||
import username from './helpers/username';
|
||||
import userOnline from './helpers/userOnline';
|
||||
import listItems from './helpers/listItems';
|
||||
import Fragment from './Fragment';
|
||||
|
||||
export default {
|
||||
extend: extend,
|
||||
@@ -81,11 +85,14 @@ export default {
|
||||
'utils/extract': extract,
|
||||
'utils/ScrollListener': ScrollListener,
|
||||
'utils/stringToColor': stringToColor,
|
||||
'utils/subclassOf': subclassOf,
|
||||
'utils/setRouteWithForcedRefresh': setRouteWithForcedRefresh,
|
||||
'utils/patchMithril': patchMithril,
|
||||
'utils/classList': classList,
|
||||
'utils/extractText': extractText,
|
||||
'utils/formatNumber': formatNumber,
|
||||
'utils/mapRoutes': mapRoutes,
|
||||
'utils/withAttr': withAttr,
|
||||
'models/Notification': Notification,
|
||||
'models/User': User,
|
||||
'models/Post': Post,
|
||||
@@ -93,6 +100,7 @@ export default {
|
||||
'models/Group': Group,
|
||||
'models/Forum': Forum,
|
||||
Component: Component,
|
||||
Fragment: Fragment,
|
||||
Translator: Translator,
|
||||
'components/AlertManager': AlertManager,
|
||||
'components/Page': Page,
|
||||
|
@@ -7,7 +7,7 @@ import extract from '../utils/extract';
|
||||
* The `Alert` component represents an alert box, which contains a message,
|
||||
* some controls, and may be dismissible.
|
||||
*
|
||||
* The alert may have the following special props:
|
||||
* ### Attrs
|
||||
*
|
||||
* - `type` The type of alert this is. Will be used to give the alert a class
|
||||
* name of `Alert--{type}`.
|
||||
@@ -15,16 +15,16 @@ import extract from '../utils/extract';
|
||||
* - `dismissible` Whether or not the alert can be dismissed.
|
||||
* - `ondismiss` A callback to run when the alert is dismissed.
|
||||
*
|
||||
* All other props will be assigned as attributes on the alert element.
|
||||
* All other attrs will be assigned as attributes on the DOM element.
|
||||
*/
|
||||
export default class Alert extends Component {
|
||||
view() {
|
||||
const attrs = Object.assign({}, this.props);
|
||||
view(vnode) {
|
||||
const attrs = Object.assign({}, this.attrs);
|
||||
|
||||
const type = extract(attrs, 'type');
|
||||
attrs.className = 'Alert Alert--' + type + ' ' + (attrs.className || '');
|
||||
|
||||
const children = extract(attrs, 'children');
|
||||
const content = extract(attrs, 'content') || vnode.children;
|
||||
const controls = extract(attrs, 'controls') || [];
|
||||
|
||||
// If the alert is meant to be dismissible (which is the case by default),
|
||||
@@ -40,7 +40,7 @@ export default class Alert extends Component {
|
||||
|
||||
return (
|
||||
<div {...attrs}>
|
||||
<span className="Alert-body">{children}</span>
|
||||
<span className="Alert-body">{content}</span>
|
||||
<ul className="Alert-controls">{listItems(controls.concat(dismissControl))}</ul>
|
||||
</div>
|
||||
);
|
||||
|
@@ -6,72 +6,23 @@ import Alert from './Alert';
|
||||
* be shown and dismissed.
|
||||
*/
|
||||
export default class AlertManager extends Component {
|
||||
init() {
|
||||
/**
|
||||
* An array of Alert components which are currently showing.
|
||||
*
|
||||
* @type {Alert[]}
|
||||
* @protected
|
||||
*/
|
||||
this.components = [];
|
||||
oninit(vnode) {
|
||||
super.oninit(vnode);
|
||||
|
||||
this.state = this.attrs.state;
|
||||
}
|
||||
|
||||
view() {
|
||||
return (
|
||||
<div className="AlertManager">
|
||||
{this.components.map((component) => (
|
||||
<div className="AlertManager-alert">{component}</div>
|
||||
{Object.entries(this.state.getActiveAlerts()).map(([key, alert]) => (
|
||||
<div className="AlertManager-alert">
|
||||
<alert.componentClass {...alert.attrs} ondismiss={this.state.dismiss.bind(this.state, key)}>
|
||||
{alert.children}
|
||||
</alert.componentClass>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
config(isInitialized, context) {
|
||||
// Since this component is 'above' the content of the page (that is, it is a
|
||||
// part of the global UI that persists between routes), we will flag the DOM
|
||||
// to be retained across route changes.
|
||||
context.retain = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Show an Alert in the alerts area.
|
||||
*
|
||||
* @param {Alert} component
|
||||
* @public
|
||||
*/
|
||||
show(component) {
|
||||
if (!(component instanceof Alert)) {
|
||||
throw new Error('The AlertManager component can only show Alert components');
|
||||
}
|
||||
|
||||
component.props.ondismiss = this.dismiss.bind(this, component);
|
||||
|
||||
this.components.push(component);
|
||||
m.redraw();
|
||||
}
|
||||
|
||||
/**
|
||||
* Dismiss an alert.
|
||||
*
|
||||
* @param {Alert} component
|
||||
* @public
|
||||
*/
|
||||
dismiss(component) {
|
||||
const index = this.components.indexOf(component);
|
||||
|
||||
if (index !== -1) {
|
||||
this.components.splice(index, 1);
|
||||
m.redraw();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all alerts.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
clear() {
|
||||
this.components = [];
|
||||
m.redraw();
|
||||
}
|
||||
}
|
||||
|
@@ -6,18 +6,18 @@ import extract from '../utils/extract';
|
||||
* The `Badge` component represents a user/discussion badge, indicating some
|
||||
* status (e.g. a discussion is stickied, a user is an admin).
|
||||
*
|
||||
* A badge may have the following special props:
|
||||
* A badge may have the following special attrs:
|
||||
*
|
||||
* - `type` The type of badge this is. This will be used to give the badge a
|
||||
* class name of `Badge--{type}`.
|
||||
* - `icon` The name of an icon to show inside the badge.
|
||||
* - `label`
|
||||
*
|
||||
* All other props will be assigned as attributes on the badge element.
|
||||
* All other attrs will be assigned as attributes on the badge element.
|
||||
*/
|
||||
export default class Badge extends Component {
|
||||
view() {
|
||||
const attrs = Object.assign({}, this.props);
|
||||
const attrs = Object.assign({}, this.attrs);
|
||||
const type = extract(attrs, 'type');
|
||||
const iconName = extract(attrs, 'icon');
|
||||
|
||||
@@ -27,9 +27,9 @@ export default class Badge extends Component {
|
||||
return <span {...attrs}>{iconName ? icon(iconName, { className: 'Badge-icon' }) : m.trust(' ')}</span>;
|
||||
}
|
||||
|
||||
config(isInitialized) {
|
||||
if (isInitialized) return;
|
||||
oncreate(vnode) {
|
||||
super.oncreate(vnode);
|
||||
|
||||
if (this.props.label) this.$().tooltip();
|
||||
if (this.attrs.label) this.$().tooltip();
|
||||
}
|
||||
}
|
||||
|
@@ -1,12 +1,15 @@
|
||||
import Component from '../Component';
|
||||
import icon from '../helpers/icon';
|
||||
import classList from '../utils/classList';
|
||||
import extract from '../utils/extract';
|
||||
import extractText from '../utils/extractText';
|
||||
import LoadingIndicator from './LoadingIndicator';
|
||||
|
||||
/**
|
||||
* The `Button` component defines an element which, when clicked, performs an
|
||||
* action. The button may have the following special props:
|
||||
* action.
|
||||
*
|
||||
* ### Attrs
|
||||
*
|
||||
* - `icon` The name of the icon class. If specified, the button will be given a
|
||||
* 'has-icon' class name.
|
||||
@@ -15,41 +18,38 @@ import LoadingIndicator from './LoadingIndicator';
|
||||
* removed.
|
||||
* - `loading` Whether or not the button should be in a disabled loading state.
|
||||
*
|
||||
* All other props will be assigned as attributes on the button element.
|
||||
* All other attrs will be assigned as attributes on the button element.
|
||||
*
|
||||
* Note that a Button has no default class names. This is because a Button can
|
||||
* be used to represent any generic clickable control, like a menu item.
|
||||
*/
|
||||
export default class Button extends Component {
|
||||
view() {
|
||||
const attrs = Object.assign({}, this.props);
|
||||
view(vnode) {
|
||||
const attrs = Object.assign({}, this.attrs);
|
||||
|
||||
delete attrs.children;
|
||||
|
||||
attrs.className = attrs.className || '';
|
||||
attrs.type = attrs.type || 'button';
|
||||
|
||||
// If a tooltip was provided for buttons without additional content, we also
|
||||
// use this tooltip as text for screen readers
|
||||
if (attrs.title && !this.props.children) {
|
||||
if (attrs.title && !vnode.children) {
|
||||
attrs['aria-label'] = attrs.title;
|
||||
}
|
||||
|
||||
// If nothing else is provided, we use the textual button content as tooltip
|
||||
if (!attrs.title && this.props.children) {
|
||||
attrs.title = extractText(this.props.children);
|
||||
if (!attrs.title && vnode.children) {
|
||||
attrs.title = extractText(vnode.children);
|
||||
}
|
||||
|
||||
const iconName = extract(attrs, 'icon');
|
||||
if (iconName) attrs.className += ' hasIcon';
|
||||
|
||||
const loading = extract(attrs, 'loading');
|
||||
if (attrs.disabled || loading) {
|
||||
attrs.className += ' disabled' + (loading ? ' loading' : '');
|
||||
delete attrs.onclick;
|
||||
}
|
||||
|
||||
return <button {...attrs}>{this.getButtonContent()}</button>;
|
||||
attrs.className = classList([attrs.className, iconName && 'hasIcon', (attrs.disabled || loading) && 'disabled', loading && 'loading']);
|
||||
|
||||
return <button {...attrs}>{this.getButtonContent(vnode.children)}</button>;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -58,13 +58,13 @@ export default class Button extends Component {
|
||||
* @return {*}
|
||||
* @protected
|
||||
*/
|
||||
getButtonContent() {
|
||||
const iconName = this.props.icon;
|
||||
getButtonContent(children) {
|
||||
const iconName = this.attrs.icon;
|
||||
|
||||
return [
|
||||
iconName && iconName !== true ? icon(iconName, { className: 'Button-icon' }) : '',
|
||||
this.props.children ? <span className="Button-label">{this.props.children}</span> : '',
|
||||
this.props.loading ? LoadingIndicator.component({ size: 'tiny', className: 'LoadingIndicator--inline' }) : '',
|
||||
children ? <span className="Button-label">{children}</span> : '',
|
||||
this.attrs.loading ? <LoadingIndicator size="tiny" className="LoadingIndicator--inline" /> : '',
|
||||
];
|
||||
}
|
||||
}
|
||||
|
@@ -1,11 +1,13 @@
|
||||
import Component from '../Component';
|
||||
import LoadingIndicator from './LoadingIndicator';
|
||||
import icon from '../helpers/icon';
|
||||
import classList from '../utils/classList';
|
||||
import withAttr from '../utils/withAttr';
|
||||
|
||||
/**
|
||||
* The `Checkbox` component defines a checkbox input.
|
||||
*
|
||||
* ### Props
|
||||
* ### Attrs
|
||||
*
|
||||
* - `state` Whether or not the checkbox is checked.
|
||||
* - `className` The class name for the root element.
|
||||
@@ -15,16 +17,24 @@ import icon from '../helpers/icon';
|
||||
* - `children` A text label to display next to the checkbox.
|
||||
*/
|
||||
export default class Checkbox extends Component {
|
||||
view() {
|
||||
let className = 'Checkbox ' + (this.props.state ? 'on' : 'off') + ' ' + (this.props.className || '');
|
||||
if (this.props.loading) className += ' loading';
|
||||
if (this.props.disabled) className += ' disabled';
|
||||
view(vnode) {
|
||||
// Sometimes, false is stored in the DB as '0'. This is a temporary
|
||||
// conversion layer until a more robust settings encoding is introduced
|
||||
if (this.attrs.state === '0') this.attrs.state = false;
|
||||
|
||||
const className = classList([
|
||||
'Checkbox',
|
||||
this.attrs.state ? 'on' : 'off',
|
||||
this.attrs.className,
|
||||
this.attrs.loading && 'loading',
|
||||
this.attrs.disabled && 'disabled',
|
||||
]);
|
||||
|
||||
return (
|
||||
<label className={className}>
|
||||
<input type="checkbox" checked={this.props.state} disabled={this.props.disabled} onchange={m.withAttr('checked', this.onchange.bind(this))} />
|
||||
<input type="checkbox" checked={this.attrs.state} disabled={this.attrs.disabled} onchange={withAttr('checked', this.onchange.bind(this))} />
|
||||
<div className="Checkbox-display">{this.getDisplay()}</div>
|
||||
{this.props.children}
|
||||
{vnode.children}
|
||||
</label>
|
||||
);
|
||||
}
|
||||
@@ -36,7 +46,7 @@ export default class Checkbox extends Component {
|
||||
* @protected
|
||||
*/
|
||||
getDisplay() {
|
||||
return this.props.loading ? LoadingIndicator.component({ size: 'tiny' }) : icon(this.props.state ? 'fas fa-check' : 'fas fa-times');
|
||||
return this.attrs.loading ? <LoadingIndicator size="tiny" /> : icon(this.attrs.state ? 'fas fa-check' : 'fas fa-times');
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -46,6 +56,6 @@ export default class Checkbox extends Component {
|
||||
* @protected
|
||||
*/
|
||||
onchange(checked) {
|
||||
if (this.props.onchange) this.props.onchange(checked, this);
|
||||
if (this.attrs.onchange) this.attrs.onchange(checked, this);
|
||||
}
|
||||
}
|
||||
|
40
js/src/common/components/ConfirmDocumentUnload.js
Normal file
40
js/src/common/components/ConfirmDocumentUnload.js
Normal file
@@ -0,0 +1,40 @@
|
||||
import Component from '../Component';
|
||||
|
||||
/**
|
||||
* The `ConfirmDocumentUnload` component can be used to register a global
|
||||
* event handler that prevents closing the browser window/tab based on the
|
||||
* return value of a given callback prop.
|
||||
*
|
||||
* ### Attrs
|
||||
*
|
||||
* - `when` - a callback returning true when the browser should prompt for
|
||||
* confirmation before closing the window/tab
|
||||
*
|
||||
* ### Children
|
||||
*
|
||||
* NOTE: Only the first child will be rendered. (Use this component to wrap
|
||||
* another component / DOM element.)
|
||||
*
|
||||
*/
|
||||
export default class ConfirmDocumentUnload extends Component {
|
||||
handler() {
|
||||
return this.attrs.when() || undefined;
|
||||
}
|
||||
|
||||
oncreate(vnode) {
|
||||
super.oncreate(vnode);
|
||||
|
||||
this.boundHandler = this.handler.bind(this);
|
||||
$(window).on('beforeunload', this.boundHandler);
|
||||
}
|
||||
|
||||
onremove() {
|
||||
$(window).off('beforeunload', this.boundHandler);
|
||||
}
|
||||
|
||||
view(vnode) {
|
||||
// To avoid having to render another wrapping <div> here, we assume that
|
||||
// this component is only wrapped around a single element / component.
|
||||
return vnode.children[0];
|
||||
}
|
||||
}
|
@@ -6,7 +6,7 @@ import listItems from '../helpers/listItems';
|
||||
* The `Dropdown` component displays a button which, when clicked, shows a
|
||||
* dropdown menu beneath it.
|
||||
*
|
||||
* ### Props
|
||||
* ### Attrs
|
||||
*
|
||||
* - `buttonClassName` A class name to apply to the dropdown toggle button.
|
||||
* - `menuClassName` A class name to apply to the dropdown menu.
|
||||
@@ -19,33 +19,33 @@ import listItems from '../helpers/listItems';
|
||||
* The children will be displayed as a list inside of the dropdown menu.
|
||||
*/
|
||||
export default class Dropdown extends Component {
|
||||
static initProps(props) {
|
||||
super.initProps(props);
|
||||
|
||||
props.className = props.className || '';
|
||||
props.buttonClassName = props.buttonClassName || '';
|
||||
props.menuClassName = props.menuClassName || '';
|
||||
props.label = props.label || '';
|
||||
props.caretIcon = typeof props.caretIcon !== 'undefined' ? props.caretIcon : 'fas fa-caret-down';
|
||||
static initAttrs(attrs) {
|
||||
attrs.className = attrs.className || '';
|
||||
attrs.buttonClassName = attrs.buttonClassName || '';
|
||||
attrs.menuClassName = attrs.menuClassName || '';
|
||||
attrs.label = attrs.label || '';
|
||||
attrs.caretIcon = typeof attrs.caretIcon !== 'undefined' ? attrs.caretIcon : 'fas fa-caret-down';
|
||||
}
|
||||
|
||||
init() {
|
||||
oninit(vnode) {
|
||||
super.oninit(vnode);
|
||||
|
||||
this.showing = false;
|
||||
}
|
||||
|
||||
view() {
|
||||
const items = this.props.children ? listItems(this.props.children) : [];
|
||||
view(vnode) {
|
||||
const items = vnode.children ? listItems(vnode.children) : [];
|
||||
|
||||
return (
|
||||
<div className={'ButtonGroup Dropdown dropdown ' + this.props.className + ' itemCount' + items.length + (this.showing ? ' open' : '')}>
|
||||
{this.getButton()}
|
||||
<div className={'ButtonGroup Dropdown dropdown ' + this.attrs.className + ' itemCount' + items.length + (this.showing ? ' open' : '')}>
|
||||
{this.getButton(vnode.children)}
|
||||
{this.getMenu(items)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
config(isInitialized) {
|
||||
if (isInitialized) return;
|
||||
oncreate(vnode) {
|
||||
super.oncreate(vnode);
|
||||
|
||||
// When opening the dropdown menu, work out if the menu goes beyond the
|
||||
// bottom of the viewport. If it does, we will apply class to make it show
|
||||
@@ -53,8 +53,8 @@ export default class Dropdown extends Component {
|
||||
this.$().on('shown.bs.dropdown', () => {
|
||||
this.showing = true;
|
||||
|
||||
if (this.props.onshow) {
|
||||
this.props.onshow();
|
||||
if (this.attrs.onshow) {
|
||||
this.attrs.onshow();
|
||||
}
|
||||
|
||||
m.redraw();
|
||||
@@ -76,8 +76,8 @@ export default class Dropdown extends Component {
|
||||
this.$().on('hidden.bs.dropdown', () => {
|
||||
this.showing = false;
|
||||
|
||||
if (this.props.onhide) {
|
||||
this.props.onhide();
|
||||
if (this.attrs.onhide) {
|
||||
this.attrs.onhide();
|
||||
}
|
||||
|
||||
m.redraw();
|
||||
@@ -90,10 +90,10 @@ export default class Dropdown extends Component {
|
||||
* @return {*}
|
||||
* @protected
|
||||
*/
|
||||
getButton() {
|
||||
getButton(children) {
|
||||
return (
|
||||
<button className={'Dropdown-toggle ' + this.props.buttonClassName} data-toggle="dropdown" onclick={this.props.onclick}>
|
||||
{this.getButtonContent()}
|
||||
<button className={'Dropdown-toggle ' + this.attrs.buttonClassName} data-toggle="dropdown" onclick={this.attrs.onclick}>
|
||||
{this.getButtonContent(children)}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -104,15 +104,15 @@ export default class Dropdown extends Component {
|
||||
* @return {*}
|
||||
* @protected
|
||||
*/
|
||||
getButtonContent() {
|
||||
getButtonContent(children) {
|
||||
return [
|
||||
this.props.icon ? icon(this.props.icon, { className: 'Button-icon' }) : '',
|
||||
<span className="Button-label">{this.props.label}</span>,
|
||||
this.props.caretIcon ? icon(this.props.caretIcon, { className: 'Button-caret' }) : '',
|
||||
this.attrs.icon ? icon(this.attrs.icon, { className: 'Button-icon' }) : '',
|
||||
<span className="Button-label">{this.attrs.label}</span>,
|
||||
this.attrs.caretIcon ? icon(this.attrs.caretIcon, { className: 'Button-caret' }) : '',
|
||||
];
|
||||
}
|
||||
|
||||
getMenu(items) {
|
||||
return <ul className={'Dropdown-menu dropdown-menu ' + this.props.menuClassName}>{items}</ul>;
|
||||
return <ul className={'Dropdown-menu dropdown-menu ' + this.attrs.menuClassName}>{items}</ul>;
|
||||
}
|
||||
}
|
||||
|
@@ -11,11 +11,11 @@ import listItems from '../helpers/listItems';
|
||||
* The children should be an array of items to show in the fieldset.
|
||||
*/
|
||||
export default class FieldSet extends Component {
|
||||
view() {
|
||||
view(vnode) {
|
||||
return (
|
||||
<fieldset className={this.props.className}>
|
||||
<legend>{this.props.label}</legend>
|
||||
<ul>{listItems(this.props.children)}</ul>
|
||||
<fieldset className={this.attrs.className}>
|
||||
<legend>{this.attrs.label}</legend>
|
||||
<ul>{listItems(vnode.children)}</ul>
|
||||
</fieldset>
|
||||
);
|
||||
}
|
||||
|
@@ -1,16 +1,16 @@
|
||||
import Badge from './Badge';
|
||||
|
||||
export default class GroupBadge extends Badge {
|
||||
static initProps(props) {
|
||||
super.initProps(props);
|
||||
static initAttrs(attrs) {
|
||||
super.initAttrs(attrs);
|
||||
|
||||
if (props.group) {
|
||||
props.icon = props.group.icon();
|
||||
props.style = { backgroundColor: props.group.color() };
|
||||
props.label = typeof props.label === 'undefined' ? props.group.nameSingular() : props.label;
|
||||
props.type = 'group--' + props.group.id();
|
||||
if (attrs.group) {
|
||||
attrs.icon = attrs.group.icon();
|
||||
attrs.style = { backgroundColor: attrs.group.color() };
|
||||
attrs.label = typeof attrs.label === 'undefined' ? attrs.group.nameSingular() : attrs.label;
|
||||
attrs.type = 'group--' + attrs.group.id();
|
||||
|
||||
delete props.group;
|
||||
delete attrs.group;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@@ -3,9 +3,9 @@ import Button from './Button';
|
||||
/**
|
||||
* The `LinkButton` component defines a `Button` which links to a route.
|
||||
*
|
||||
* ### Props
|
||||
* ### Attrs
|
||||
*
|
||||
* All of the props accepted by `Button`, plus:
|
||||
* All of the attrs accepted by `Button`, plus:
|
||||
*
|
||||
* - `active` Whether or not the page that this button links to is currently
|
||||
* active.
|
||||
@@ -13,26 +13,28 @@ import Button from './Button';
|
||||
* the `active` prop will automatically be set to true.
|
||||
*/
|
||||
export default class LinkButton extends Button {
|
||||
static initProps(props) {
|
||||
props.active = this.isActive(props);
|
||||
props.config = props.config || m.route;
|
||||
static initAttrs(attrs) {
|
||||
super.initAttrs(attrs);
|
||||
|
||||
attrs.active = this.isActive(attrs);
|
||||
}
|
||||
|
||||
view() {
|
||||
const vdom = super.view();
|
||||
view(vnode) {
|
||||
const vdom = super.view(vnode);
|
||||
|
||||
vdom.tag = 'a';
|
||||
vdom.tag = m.route.Link;
|
||||
vdom.attrs.active = String(vdom.attrs.active);
|
||||
|
||||
return vdom;
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine whether a component with the given props is 'active'.
|
||||
* Determine whether a component with the given attrs is 'active'.
|
||||
*
|
||||
* @param {Object} props
|
||||
* @param {Object} attrs
|
||||
* @return {Boolean}
|
||||
*/
|
||||
static isActive(props) {
|
||||
return typeof props.active !== 'undefined' ? props.active : m.route() === props.href;
|
||||
static isActive(attrs) {
|
||||
return typeof attrs.active !== 'undefined' ? attrs.active : m.route.get() === attrs.href;
|
||||
}
|
||||
}
|
||||
|
@@ -2,16 +2,17 @@ import Component from '../Component';
|
||||
import { Spinner } from 'spin.js';
|
||||
|
||||
/**
|
||||
* The `LoadingIndicator` component displays a loading spinner with spin.js. It
|
||||
* may have the following special props:
|
||||
* The `LoadingIndicator` component displays a loading spinner with spin.js.
|
||||
*
|
||||
* ### Attrs
|
||||
*
|
||||
* - `size` The spin.js size preset to use. Defaults to 'small'.
|
||||
*
|
||||
* All other props will be assigned as attributes on the element.
|
||||
* All other attrs will be assigned as attributes on the DOM element.
|
||||
*/
|
||||
export default class LoadingIndicator extends Component {
|
||||
view() {
|
||||
const attrs = Object.assign({}, this.props);
|
||||
const attrs = Object.assign({}, this.attrs);
|
||||
|
||||
attrs.className = 'LoadingIndicator ' + (attrs.className || '');
|
||||
delete attrs.size;
|
||||
@@ -19,12 +20,12 @@ export default class LoadingIndicator extends Component {
|
||||
return <div {...attrs}>{m.trust(' ')}</div>;
|
||||
}
|
||||
|
||||
config(isInitialized) {
|
||||
if (isInitialized) return;
|
||||
oncreate(vnode) {
|
||||
super.oncreate(vnode);
|
||||
|
||||
const options = { zIndex: 'auto', color: this.$().css('color') };
|
||||
|
||||
switch (this.props.size) {
|
||||
switch (this.attrs.size) {
|
||||
case 'large':
|
||||
Object.assign(options, { lines: 10, length: 8, width: 4, radius: 8 });
|
||||
break;
|
||||
|
@@ -9,24 +9,37 @@ import Button from './Button';
|
||||
* @abstract
|
||||
*/
|
||||
export default class Modal extends Component {
|
||||
init() {
|
||||
/**
|
||||
* An alert component to show below the header.
|
||||
*
|
||||
* @type {Alert}
|
||||
*/
|
||||
this.alert = null;
|
||||
/**
|
||||
* Determine whether or not the modal should be dismissible via an 'x' button.
|
||||
*/
|
||||
static isDismissible = true;
|
||||
|
||||
/**
|
||||
* Attributes for an alert component to show below the header.
|
||||
*
|
||||
* @type {object}
|
||||
*/
|
||||
alertAttrs = null;
|
||||
|
||||
oncreate(vnode) {
|
||||
super.oncreate(vnode);
|
||||
|
||||
this.attrs.onshow(() => this.onready());
|
||||
}
|
||||
|
||||
onremove() {
|
||||
this.attrs.onhide();
|
||||
}
|
||||
|
||||
view() {
|
||||
if (this.alert) {
|
||||
this.alert.props.dismissible = false;
|
||||
if (this.alertAttrs) {
|
||||
this.alertAttrs.dismissible = false;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={'Modal modal-dialog ' + this.className()}>
|
||||
<div className="Modal-content">
|
||||
{this.isDismissible() ? (
|
||||
{this.constructor.isDismissible ? (
|
||||
<div className="Modal-close App-backControl">
|
||||
{Button.component({
|
||||
icon: 'fas fa-times',
|
||||
@@ -43,7 +56,7 @@ export default class Modal extends Component {
|
||||
<h3 className="App-titleControl App-titleControl--text">{this.title()}</h3>
|
||||
</div>
|
||||
|
||||
{alert ? <div className="Modal-alert">{this.alert}</div> : ''}
|
||||
{this.alertAttrs ? <div className="Modal-alert">{Alert.component(this.alertAttrs)}</div> : ''}
|
||||
|
||||
{this.content()}
|
||||
</form>
|
||||
@@ -52,15 +65,6 @@ export default class Modal extends Component {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine whether or not the modal should be dismissible via an 'x' button.
|
||||
*
|
||||
* @return {Boolean}
|
||||
*/
|
||||
isDismissible() {
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the class name to apply to the modal.
|
||||
*
|
||||
@@ -105,7 +109,7 @@ export default class Modal extends Component {
|
||||
* Hide the modal.
|
||||
*/
|
||||
hide() {
|
||||
app.modal.close();
|
||||
this.attrs.onhide();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -123,7 +127,7 @@ export default class Modal extends Component {
|
||||
* @param {RequestError} error
|
||||
*/
|
||||
onerror(error) {
|
||||
this.alert = error.alert;
|
||||
this.alertAttrs = error.alert;
|
||||
|
||||
m.redraw();
|
||||
|
||||
|
@@ -1,5 +1,4 @@
|
||||
import Component from '../Component';
|
||||
import Modal from './Modal';
|
||||
|
||||
/**
|
||||
* The `ModalManager` component manages a modal dialog. Only one modal dialog
|
||||
@@ -7,46 +6,30 @@ import Modal from './Modal';
|
||||
* overwrite the previous one.
|
||||
*/
|
||||
export default class ModalManager extends Component {
|
||||
init() {
|
||||
this.showing = false;
|
||||
this.component = null;
|
||||
}
|
||||
|
||||
view() {
|
||||
return <div className="ModalManager modal fade">{this.component && this.component.render()}</div>;
|
||||
const modal = this.attrs.state.modal;
|
||||
|
||||
return (
|
||||
<div className="ModalManager modal fade">
|
||||
{modal ? modal.componentClass.component({ ...modal.attrs, onshow: this.animateShow.bind(this), onhide: this.animateHide.bind(this) }) : ''}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
config(isInitialized, context) {
|
||||
if (isInitialized) return;
|
||||
oncreate(vnode) {
|
||||
super.oncreate(vnode);
|
||||
|
||||
// Since this component is 'above' the content of the page (that is, it is a
|
||||
// part of the global UI that persists between routes), we will flag the DOM
|
||||
// to be retained across route changes.
|
||||
context.retain = true;
|
||||
|
||||
this.$().on('hidden.bs.modal', this.clear.bind(this)).on('shown.bs.modal', this.onready.bind(this));
|
||||
// Ensure the modal state is notified about a closed modal, even when the
|
||||
// DOM-based Bootstrap JavaScript code triggered the closing of the modal,
|
||||
// e.g. via ESC key or a click on the modal backdrop.
|
||||
this.$().on('hidden.bs.modal', this.attrs.state.close.bind(this.attrs.state));
|
||||
}
|
||||
|
||||
/**
|
||||
* Show a modal dialog.
|
||||
*
|
||||
* @param {Modal} component
|
||||
* @public
|
||||
*/
|
||||
show(component) {
|
||||
if (!(component instanceof Modal)) {
|
||||
throw new Error('The ModalManager component can only show Modal components');
|
||||
}
|
||||
animateShow(readyCallback) {
|
||||
const dismissible = !!this.attrs.state.modal.componentClass.isDismissible;
|
||||
|
||||
clearTimeout(this.hideTimeout);
|
||||
|
||||
this.showing = true;
|
||||
this.component = component;
|
||||
|
||||
m.redraw(true);
|
||||
|
||||
const dismissible = !!this.component.isDismissible();
|
||||
this.$()
|
||||
.one('shown.bs.modal', readyCallback)
|
||||
.modal({
|
||||
backdrop: dismissible || 'static',
|
||||
keyboard: dismissible,
|
||||
@@ -54,50 +37,7 @@ export default class ModalManager extends Component {
|
||||
.modal('show');
|
||||
}
|
||||
|
||||
/**
|
||||
* Close the modal dialog.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
close() {
|
||||
if (!this.showing) return;
|
||||
|
||||
// Don't hide the modal immediately, because if the consumer happens to call
|
||||
// the `show` method straight after to show another modal dialog, it will
|
||||
// cause Bootstrap's modal JS to misbehave. Instead we will wait for a tiny
|
||||
// bit to give the `show` method the opportunity to prevent this from going
|
||||
// ahead.
|
||||
this.hideTimeout = setTimeout(() => {
|
||||
this.$().modal('hide');
|
||||
this.showing = false;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear content from the modal area.
|
||||
*
|
||||
* @protected
|
||||
*/
|
||||
clear() {
|
||||
if (this.component) {
|
||||
this.component.onhide();
|
||||
}
|
||||
|
||||
this.component = null;
|
||||
|
||||
app.current.retain = false;
|
||||
|
||||
m.lazyRedraw();
|
||||
}
|
||||
|
||||
/**
|
||||
* When the modal dialog is ready to be used, tell it!
|
||||
*
|
||||
* @protected
|
||||
*/
|
||||
onready() {
|
||||
if (this.component && this.component.onready) {
|
||||
this.component.onready(this.$());
|
||||
}
|
||||
animateHide() {
|
||||
this.$().modal('hide');
|
||||
}
|
||||
}
|
||||
|
@@ -11,7 +11,7 @@ import LinkButton from './LinkButton';
|
||||
* If the app has a pane, it will also include a 'pin' button which toggles the
|
||||
* pinned state of the pane.
|
||||
*
|
||||
* Accepts the following props:
|
||||
* Accepts the following attrs:
|
||||
*
|
||||
* - `className` The name of a class to set on the root element.
|
||||
* - `drawer` Whether or not to show a button to toggle the app's drawer if
|
||||
@@ -23,7 +23,7 @@ export default class Navigation extends Component {
|
||||
|
||||
return (
|
||||
<div
|
||||
className={'Navigation ButtonGroup ' + (this.props.className || '')}
|
||||
className={'Navigation ButtonGroup ' + (this.attrs.className || '')}
|
||||
onmouseenter={pane && pane.show.bind(pane)}
|
||||
onmouseleave={pane && pane.onmouseleave.bind(pane)}
|
||||
>
|
||||
@@ -32,13 +32,6 @@ export default class Navigation extends Component {
|
||||
);
|
||||
}
|
||||
|
||||
config(isInitialized, context) {
|
||||
// Since this component is 'above' the content of the page (that is, it is a
|
||||
// part of the global UI that persists between routes), we will flag the DOM
|
||||
// to be retained across route changes.
|
||||
context.retain = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the back button.
|
||||
*
|
||||
@@ -54,7 +47,6 @@ export default class Navigation extends Component {
|
||||
href: history.backUrl(),
|
||||
icon: 'fas fa-chevron-left',
|
||||
title: previous.title,
|
||||
config: () => {},
|
||||
onclick: (e) => {
|
||||
if (e.shiftKey || e.ctrlKey || e.metaKey || e.which === 2) return;
|
||||
e.preventDefault();
|
||||
@@ -88,7 +80,7 @@ export default class Navigation extends Component {
|
||||
* @protected
|
||||
*/
|
||||
getDrawerButton() {
|
||||
if (!this.props.drawer) return '';
|
||||
if (!this.attrs.drawer) return '';
|
||||
|
||||
const { drawer } = app;
|
||||
const user = app.session.user;
|
||||
|
@@ -7,10 +7,14 @@ import PageState from '../states/PageState';
|
||||
* @abstract
|
||||
*/
|
||||
export default class Page extends Component {
|
||||
init() {
|
||||
oninit(vnode) {
|
||||
super.oninit(vnode);
|
||||
|
||||
app.previous = app.current;
|
||||
app.current = new PageState(this.constructor);
|
||||
|
||||
this.onNewRoute();
|
||||
|
||||
app.drawer.hide();
|
||||
app.modal.close();
|
||||
|
||||
@@ -22,13 +26,27 @@ export default class Page extends Component {
|
||||
this.bodyClass = '';
|
||||
}
|
||||
|
||||
config(isInitialized, context) {
|
||||
if (isInitialized) return;
|
||||
/**
|
||||
* A collections of actions to run when the route changes.
|
||||
* This is extracted here, and not hardcoded in oninit, as oninit is not called
|
||||
* when a different route is handled by the same component, but we still need to
|
||||
* adjust the current route name.
|
||||
*/
|
||||
onNewRoute() {
|
||||
app.current.set('routeName', this.attrs.routeName);
|
||||
}
|
||||
|
||||
oncreate(vnode) {
|
||||
super.oncreate(vnode);
|
||||
|
||||
if (this.bodyClass) {
|
||||
$('#app').addClass(this.bodyClass);
|
||||
}
|
||||
}
|
||||
|
||||
context.onunload = () => $('#app').removeClass(this.bodyClass);
|
||||
onremove() {
|
||||
if (this.bodyClass) {
|
||||
$('#app').removeClass(this.bodyClass);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@@ -4,7 +4,7 @@ import Component from '../Component';
|
||||
* The `Placeholder` component displays a muted text with some call to action,
|
||||
* usually used as an empty state.
|
||||
*
|
||||
* ### Props
|
||||
* ### Attrs
|
||||
*
|
||||
* - `text`
|
||||
*/
|
||||
@@ -12,7 +12,7 @@ export default class Placeholder extends Component {
|
||||
view() {
|
||||
return (
|
||||
<div className="Placeholder">
|
||||
<p>{this.props.text}</p>
|
||||
<p>{this.attrs.text}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
@@ -6,11 +6,11 @@ export default class RequestErrorModal extends Modal {
|
||||
}
|
||||
|
||||
title() {
|
||||
return this.props.error.xhr ? `${this.props.error.xhr.status} ${this.props.error.xhr.statusText}` : '';
|
||||
return this.attrs.error.xhr ? `${this.attrs.error.xhr.status} ${this.attrs.error.xhr.statusText}` : '';
|
||||
}
|
||||
|
||||
content() {
|
||||
const { error, formattedError } = this.props;
|
||||
const { error, formattedError } = this.attrs;
|
||||
|
||||
let responseText;
|
||||
|
||||
@@ -31,7 +31,7 @@ export default class RequestErrorModal extends Modal {
|
||||
return (
|
||||
<div className="Modal-body">
|
||||
<pre>
|
||||
{this.props.error.options.method} {this.props.error.options.url}
|
||||
{this.attrs.error.options.method} {this.attrs.error.options.url}
|
||||
<br />
|
||||
<br />
|
||||
{responseText}
|
||||
|
@@ -1,9 +1,10 @@
|
||||
import Component from '../Component';
|
||||
import icon from '../helpers/icon';
|
||||
import withAttr from '../utils/withAttr';
|
||||
|
||||
/**
|
||||
* The `Select` component displays a <select> input, surrounded with some extra
|
||||
* elements for styling. It accepts the following props:
|
||||
* elements for styling. It accepts the following attrs:
|
||||
*
|
||||
* - `options` A map of option values to labels.
|
||||
* - `onchange` A callback to run when the selected value is changed.
|
||||
@@ -12,13 +13,13 @@ import icon from '../helpers/icon';
|
||||
*/
|
||||
export default class Select extends Component {
|
||||
view() {
|
||||
const { options, onchange, value, disabled } = this.props;
|
||||
const { options, onchange, value, disabled } = this.attrs;
|
||||
|
||||
return (
|
||||
<span className="Select">
|
||||
<select
|
||||
className="Select-input FormControl"
|
||||
onchange={onchange ? m.withAttr('value', onchange.bind(this)) : undefined}
|
||||
onchange={onchange ? withAttr('value', onchange.bind(this)) : undefined}
|
||||
value={value}
|
||||
disabled={disabled}
|
||||
>
|
||||
|
@@ -1,31 +1,49 @@
|
||||
import Dropdown from './Dropdown';
|
||||
import icon from '../helpers/icon';
|
||||
|
||||
/**
|
||||
* Determines via a vnode is currently "active".
|
||||
* Due to changes in Mithril 2, attrs will not be instantiated until AFTER view()
|
||||
* is initially called on the parent component, so we can not always depend on the
|
||||
* active attr to determine which element should be displayed as the "active child".
|
||||
*
|
||||
* This is a temporary patch, and as so, is not exported / placed in utils.
|
||||
*/
|
||||
function isActive(vnode) {
|
||||
const tag = vnode.tag;
|
||||
|
||||
if ('initAttrs' in tag) {
|
||||
tag.initAttrs(vnode.attrs);
|
||||
}
|
||||
|
||||
return 'isActive' in tag ? tag.isActive(vnode.attrs) : vnode.attrs.active;
|
||||
}
|
||||
|
||||
/**
|
||||
* The `SelectDropdown` component is the same as a `Dropdown`, except the toggle
|
||||
* button's label is set as the label of the first child which has a truthy
|
||||
* `active` prop.
|
||||
*
|
||||
* ### Props
|
||||
* ### Attrs
|
||||
*
|
||||
* - `caretIcon`
|
||||
* - `defaultLabel`
|
||||
*/
|
||||
export default class SelectDropdown extends Dropdown {
|
||||
static initProps(props) {
|
||||
props.caretIcon = typeof props.caretIcon !== 'undefined' ? props.caretIcon : 'fas fa-sort';
|
||||
static initAttrs(attrs) {
|
||||
attrs.caretIcon = typeof attrs.caretIcon !== 'undefined' ? attrs.caretIcon : 'fas fa-sort';
|
||||
|
||||
super.initProps(props);
|
||||
super.initAttrs(attrs);
|
||||
|
||||
props.className += ' Dropdown--select';
|
||||
attrs.className += ' Dropdown--select';
|
||||
}
|
||||
|
||||
getButtonContent() {
|
||||
const activeChild = this.props.children.filter((child) => child.props.active)[0];
|
||||
let label = (activeChild && activeChild.props.children) || this.props.defaultLabel;
|
||||
getButtonContent(children) {
|
||||
const activeChild = children.find(isActive);
|
||||
let label = (activeChild && activeChild.children) || this.attrs.defaultLabel;
|
||||
|
||||
if (label instanceof Array) label = label[0];
|
||||
|
||||
return [<span className="Button-label">{label}</span>, icon(this.props.caretIcon, { className: 'Button-caret' })];
|
||||
return [<span className="Button-label">{label}</span>, icon(this.attrs.caretIcon, { className: 'Button-caret' })];
|
||||
}
|
||||
}
|
||||
|
@@ -7,25 +7,25 @@ import icon from '../helpers/icon';
|
||||
* is displayed as its own button prior to the toggle button.
|
||||
*/
|
||||
export default class SplitDropdown extends Dropdown {
|
||||
static initProps(props) {
|
||||
super.initProps(props);
|
||||
static initAttrs(attrs) {
|
||||
super.initAttrs(attrs);
|
||||
|
||||
props.className += ' Dropdown--split';
|
||||
props.menuClassName += ' Dropdown-menu--right';
|
||||
attrs.className += ' Dropdown--split';
|
||||
attrs.menuClassName += ' Dropdown-menu--right';
|
||||
}
|
||||
|
||||
getButton() {
|
||||
// Make a copy of the props of the first child component. We will assign
|
||||
// these props to a new button, so that it has exactly the same behaviour as
|
||||
getButton(children) {
|
||||
// Make a copy of the attrs of the first child component. We will assign
|
||||
// these attrs to a new button, so that it has exactly the same behaviour as
|
||||
// the first child.
|
||||
const firstChild = this.getFirstChild();
|
||||
const buttonProps = Object.assign({}, firstChild.props);
|
||||
buttonProps.className = (buttonProps.className || '') + ' SplitDropdown-button Button ' + this.props.buttonClassName;
|
||||
const firstChild = this.getFirstChild(children);
|
||||
const buttonAttrs = Object.assign({}, firstChild.attrs);
|
||||
buttonAttrs.className = (buttonAttrs.className || '') + ' SplitDropdown-button Button ' + this.attrs.buttonClassName;
|
||||
|
||||
return [
|
||||
Button.component(buttonProps),
|
||||
<button className={'Dropdown-toggle Button Button--icon ' + this.props.buttonClassName} data-toggle="dropdown">
|
||||
{icon(this.props.icon, { className: 'Button-icon' })}
|
||||
Button.component(buttonAttrs, firstChild.children),
|
||||
<button className={'Dropdown-toggle Button Button--icon ' + this.attrs.buttonClassName} data-toggle="dropdown">
|
||||
{icon(this.attrs.icon, { className: 'Button-icon' })}
|
||||
{icon('fas fa-caret-down', { className: 'Button-caret' })}
|
||||
</button>,
|
||||
];
|
||||
@@ -38,8 +38,8 @@ export default class SplitDropdown extends Dropdown {
|
||||
* @return {*}
|
||||
* @protected
|
||||
*/
|
||||
getFirstChild() {
|
||||
let firstChild = this.props.children;
|
||||
getFirstChild(children) {
|
||||
let firstChild = children;
|
||||
|
||||
while (firstChild instanceof Array) firstChild = firstChild[0];
|
||||
|
||||
|
@@ -5,13 +5,13 @@ import Checkbox from './Checkbox';
|
||||
* a tick/cross one.
|
||||
*/
|
||||
export default class Switch extends Checkbox {
|
||||
static initProps(props) {
|
||||
super.initProps(props);
|
||||
static initAttrs(attrs) {
|
||||
super.initAttrs(attrs);
|
||||
|
||||
props.className = (props.className || '') + ' Checkbox--switch';
|
||||
attrs.className = (attrs.className || '') + ' Checkbox--switch';
|
||||
}
|
||||
|
||||
getDisplay() {
|
||||
return this.props.loading ? super.getDisplay() : '';
|
||||
return this.attrs.loading ? super.getDisplay() : '';
|
||||
}
|
||||
}
|
||||
|
@@ -1,4 +1,4 @@
|
||||
export default class Routes {
|
||||
export default class Model {
|
||||
type;
|
||||
attributes = [];
|
||||
hasOnes = [];
|
||||
|
@@ -25,7 +25,7 @@ export default function avatar(user, attrs = {}) {
|
||||
if (hasTitle) attrs.title = attrs.title || username;
|
||||
|
||||
if (avatarUrl) {
|
||||
return <img {...attrs} src={avatarUrl} />;
|
||||
return <img {...attrs} src={avatarUrl} alt="" />;
|
||||
}
|
||||
|
||||
content = username.charAt(0).toUpperCase();
|
||||
|
@@ -6,10 +6,10 @@
|
||||
* @return {Object}
|
||||
*/
|
||||
export default function fullTime(time) {
|
||||
const mo = moment(time);
|
||||
const d = dayjs(time);
|
||||
|
||||
const datetime = mo.format();
|
||||
const full = mo.format('LLLL');
|
||||
const datetime = d.format();
|
||||
const full = d.format('LLLL');
|
||||
|
||||
return (
|
||||
<time pubdate datetime={datetime}>
|
||||
|
@@ -9,10 +9,10 @@ import humanTimeUtil from '../utils/humanTime';
|
||||
* @return {Object}
|
||||
*/
|
||||
export default function humanTime(time) {
|
||||
const mo = moment(time);
|
||||
const d = dayjs(time);
|
||||
|
||||
const datetime = mo.format();
|
||||
const full = mo.format('LLLL');
|
||||
const datetime = d.format();
|
||||
const full = d.format('LLLL');
|
||||
const ago = humanTimeUtil(time);
|
||||
|
||||
return (
|
||||
|
@@ -2,14 +2,14 @@ import Separator from '../components/Separator';
|
||||
import classList from '../utils/classList';
|
||||
|
||||
function isSeparator(item) {
|
||||
return item && item.component === Separator;
|
||||
return item.tag === Separator;
|
||||
}
|
||||
|
||||
function withoutUnnecessarySeparators(items) {
|
||||
const newItems = [];
|
||||
let prevItem;
|
||||
|
||||
items.forEach((item, i) => {
|
||||
items.filter(Boolean).forEach((item, i) => {
|
||||
if (!isSeparator(item) || (prevItem && !isSeparator(prevItem) && i !== items.length - 1)) {
|
||||
prevItem = item;
|
||||
newItems.push(item);
|
||||
@@ -30,21 +30,29 @@ export default function listItems(items) {
|
||||
if (!(items instanceof Array)) items = [items];
|
||||
|
||||
return withoutUnnecessarySeparators(items).map((item) => {
|
||||
const isListItem = item.component && item.component.isListItem;
|
||||
const active = item.component && item.component.isActive && item.component.isActive(item.props);
|
||||
const className = item.props ? item.props.itemClassName : item.itemClassName;
|
||||
const isListItem = item.tag && item.tag.isListItem;
|
||||
const active = item.tag && item.tag.isActive && item.tag.isActive(item.attrs);
|
||||
const className = (item.attrs && item.attrs.itemClassName) || item.itemClassName;
|
||||
|
||||
if (isListItem) {
|
||||
item.attrs = item.attrs || {};
|
||||
item.attrs.key = item.attrs.key || item.itemName;
|
||||
item.key = item.attrs.key;
|
||||
}
|
||||
|
||||
return isListItem ? (
|
||||
const node = isListItem ? (
|
||||
item
|
||||
) : (
|
||||
<li className={classList([item.itemName ? 'item-' + item.itemName : '', className, active ? 'active' : ''])} key={item.itemName}>
|
||||
<li
|
||||
className={classList([className, item.itemName && `item-${item.itemName}`, active && 'active'])}
|
||||
key={(item.attrs && item.attrs.key) || item.itemName}
|
||||
>
|
||||
{item}
|
||||
</li>
|
||||
);
|
||||
|
||||
node.state = node.state || {};
|
||||
|
||||
return node;
|
||||
});
|
||||
}
|
||||
|
@@ -1,6 +1,6 @@
|
||||
import 'expose-loader?$!expose-loader?jQuery!jquery';
|
||||
import 'expose-loader?m!mithril';
|
||||
import 'expose-loader?moment!moment';
|
||||
import 'expose-loader?moment!expose-loader?dayjs!dayjs';
|
||||
import 'expose-loader?m.bidi!m.attrs.bidi';
|
||||
import 'bootstrap/js/affix';
|
||||
import 'bootstrap/js/dropdown';
|
||||
@@ -9,6 +9,12 @@ import 'bootstrap/js/tooltip';
|
||||
import 'bootstrap/js/transition';
|
||||
import 'jquery.hotkeys/jquery.hotkeys';
|
||||
|
||||
import relativeTime from 'dayjs/plugin/relativeTime';
|
||||
import localizedFormat from 'dayjs/plugin/localizedFormat';
|
||||
|
||||
dayjs.extend(relativeTime);
|
||||
dayjs.extend(localizedFormat);
|
||||
|
||||
import patchMithril from './utils/patchMithril';
|
||||
|
||||
patchMithril(window);
|
||||
|
@@ -54,7 +54,7 @@ Object.assign(User.prototype, {
|
||||
* @public
|
||||
*/
|
||||
isOnline() {
|
||||
return this.lastSeenAt() > moment().subtract(5, 'minutes').toDate();
|
||||
return dayjs().subtract(5, 'minutes').isBefore(this.lastSeenAt());
|
||||
},
|
||||
|
||||
/**
|
||||
|
50
js/src/common/states/AlertManagerState.js
Normal file
50
js/src/common/states/AlertManagerState.js
Normal file
@@ -0,0 +1,50 @@
|
||||
import Alert from '../components/Alert';
|
||||
|
||||
export default class AlertManagerState {
|
||||
constructor() {
|
||||
this.activeAlerts = {};
|
||||
this.alertId = 0;
|
||||
}
|
||||
|
||||
getActiveAlerts() {
|
||||
return this.activeAlerts;
|
||||
}
|
||||
|
||||
/**
|
||||
* Show an Alert in the alerts area.
|
||||
*/
|
||||
show(children, attrs, componentClass = Alert) {
|
||||
// Breaking Change Compliance Warning, Remove in Beta 15.
|
||||
// This is applied to the first argument (attrs) because previously, the alert was passed as the first argument.
|
||||
if (attrs === Alert || attrs instanceof Alert) {
|
||||
// This is duplicated so that if the error is caught, an error message still shows up in the debug console.
|
||||
console.error('The AlertManager can only show Alerts. Whichever extension triggered this alert should be updated to comply with beta 14.');
|
||||
throw new Error('The AlertManager can only show Alerts. Whichever extension triggered this alert should be updated to comply with beta 14.');
|
||||
}
|
||||
// End Change Compliance Warning, Remove in Beta 15
|
||||
this.activeAlerts[++this.alertId] = { children, attrs, componentClass };
|
||||
m.redraw();
|
||||
|
||||
return this.alertId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Dismiss an alert.
|
||||
*/
|
||||
dismiss(key) {
|
||||
if (!key || !(key in this.activeAlerts)) return;
|
||||
|
||||
delete this.activeAlerts[key];
|
||||
m.redraw();
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all alerts.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
clear() {
|
||||
this.activeAlerts = {};
|
||||
m.redraw();
|
||||
}
|
||||
}
|
56
js/src/common/states/ModalManagerState.js
Normal file
56
js/src/common/states/ModalManagerState.js
Normal file
@@ -0,0 +1,56 @@
|
||||
import Modal from '../components/Modal';
|
||||
|
||||
export default class ModalManagerState {
|
||||
constructor() {
|
||||
this.modal = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Show a modal dialog.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
show(componentClass, attrs) {
|
||||
// Breaking Change Compliance Warning, Remove in Beta 15.
|
||||
if (!(componentClass.prototype instanceof Modal)) {
|
||||
// This is duplicated so that if the error is caught, an error message still shows up in the debug console.
|
||||
console.error('The ModalManager can only show Modals');
|
||||
throw new Error('The ModalManager can only show Modals');
|
||||
}
|
||||
if (componentClass.init) {
|
||||
// This is duplicated so that if the error is caught, an error message still shows up in the debug console.
|
||||
console.error(
|
||||
'The componentClass parameter must be a modal class, not a modal instance. Whichever extension triggered this modal should be updated to comply with beta 14.'
|
||||
);
|
||||
throw new Error(
|
||||
'The componentClass parameter must be a modal class, not a modal instance. Whichever extension triggered this modal should be updated to comply with beta 14.'
|
||||
);
|
||||
}
|
||||
// End Change Compliance Warning, Remove in Beta 15
|
||||
|
||||
clearTimeout(this.closeTimeout);
|
||||
|
||||
this.modal = { componentClass, attrs };
|
||||
|
||||
m.redraw.sync();
|
||||
}
|
||||
|
||||
/**
|
||||
* Close the modal dialog.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
close() {
|
||||
if (!this.modal) return;
|
||||
|
||||
// Don't hide the modal immediately, because if the consumer happens to call
|
||||
// the `show` method straight after to show another modal dialog, it will
|
||||
// cause Bootstrap's modal JS to misbehave. Instead we will wait for a tiny
|
||||
// bit to give the `show` method the opportunity to prevent this from going
|
||||
// ahead.
|
||||
this.closeTimeout = setTimeout(() => {
|
||||
this.modal = null;
|
||||
m.redraw();
|
||||
});
|
||||
}
|
||||
}
|
@@ -1,5 +1,9 @@
|
||||
class Item {
|
||||
constructor(content, priority) {
|
||||
content: any;
|
||||
priority: number;
|
||||
key?: number;
|
||||
|
||||
constructor(content: any, priority?: number) {
|
||||
this.content = content;
|
||||
this.priority = priority;
|
||||
}
|
||||
@@ -10,23 +14,15 @@ class Item {
|
||||
* by priority.
|
||||
*/
|
||||
export default class ItemList {
|
||||
constructor() {
|
||||
/**
|
||||
* The items in the list.
|
||||
*
|
||||
* @type {Object}
|
||||
* @public
|
||||
*/
|
||||
this.items = {};
|
||||
}
|
||||
/**
|
||||
* The items in the list
|
||||
*/
|
||||
items: { [key: string]: Item } = {};
|
||||
|
||||
/**
|
||||
* Check whether the list is empty.
|
||||
*
|
||||
* @returns {boolean}
|
||||
* @public
|
||||
*/
|
||||
isEmpty() {
|
||||
isEmpty(): boolean {
|
||||
for (const i in this.items) {
|
||||
if (this.items.hasOwnProperty(i)) {
|
||||
return false;
|
||||
@@ -38,36 +34,27 @@ export default class ItemList {
|
||||
|
||||
/**
|
||||
* Check whether an item is present in the list.
|
||||
*
|
||||
* @param key
|
||||
* @returns {boolean}
|
||||
*/
|
||||
has(key) {
|
||||
has(key: string): boolean {
|
||||
return !!this.items[key];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the content of an item.
|
||||
*
|
||||
* @param {String} key
|
||||
* @return {*}
|
||||
* @public
|
||||
*/
|
||||
get(key) {
|
||||
get(key: string): any {
|
||||
return this.items[key].content;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add an item to the list.
|
||||
*
|
||||
* @param {String} key A unique key for the item.
|
||||
* @param {*} content The item's content.
|
||||
* @param {Integer} [priority] The priority of the item. Items with a higher
|
||||
* @param key A unique key for the item.
|
||||
* @param content The item's content.
|
||||
* @param [priority] The priority of the item. Items with a higher
|
||||
* priority will be positioned before items with a lower priority.
|
||||
* @return {ItemList}
|
||||
* @public
|
||||
*/
|
||||
add(key, content, priority = 0) {
|
||||
add(key: string, content: any, priority: number = 0): this {
|
||||
this.items[key] = new Item(content, priority);
|
||||
|
||||
return this;
|
||||
@@ -75,14 +62,8 @@ export default class ItemList {
|
||||
|
||||
/**
|
||||
* Replace an item in the list, only if it is already present.
|
||||
*
|
||||
* @param {String} key
|
||||
* @param {*} [content]
|
||||
* @param {Integer} [priority]
|
||||
* @return {ItemList}
|
||||
* @public
|
||||
*/
|
||||
replace(key, content = null, priority = null) {
|
||||
replace(key: string, content: any = null, priority: number = null): this {
|
||||
if (this.items[key]) {
|
||||
if (content !== null) {
|
||||
this.items[key].content = content;
|
||||
@@ -98,12 +79,8 @@ export default class ItemList {
|
||||
|
||||
/**
|
||||
* Remove an item from the list.
|
||||
*
|
||||
* @param {String} key
|
||||
* @return {ItemList}
|
||||
* @public
|
||||
*/
|
||||
remove(key) {
|
||||
remove(key: string): this {
|
||||
delete this.items[key];
|
||||
|
||||
return this;
|
||||
@@ -111,12 +88,8 @@ export default class ItemList {
|
||||
|
||||
/**
|
||||
* Merge another list's items into this one.
|
||||
*
|
||||
* @param {ItemList} items
|
||||
* @return {ItemList}
|
||||
* @public
|
||||
*/
|
||||
merge(items) {
|
||||
merge(items: this): this {
|
||||
for (const i in items.items) {
|
||||
if (items.items.hasOwnProperty(i) && items.items[i] instanceof Item) {
|
||||
this.items[i] = items.items[i];
|
||||
@@ -130,12 +103,9 @@ export default class ItemList {
|
||||
* Convert the list into an array of item content arranged by priority. Each
|
||||
* item's content will be assigned an `itemName` property equal to the item's
|
||||
* unique key.
|
||||
*
|
||||
* @return {Array}
|
||||
* @public
|
||||
*/
|
||||
toArray() {
|
||||
const items = [];
|
||||
toArray(): any[] {
|
||||
const items: Item[] = [];
|
||||
|
||||
for (const i in this.items) {
|
||||
if (this.items.hasOwnProperty(i) && this.items[i] instanceof Item) {
|
@@ -1,5 +1,14 @@
|
||||
export default class RequestError {
|
||||
constructor(status, responseText, options, xhr) {
|
||||
status: string;
|
||||
options: object;
|
||||
xhr: XMLHttpRequest;
|
||||
|
||||
responseText: string | null;
|
||||
response: object | null;
|
||||
|
||||
alert: any;
|
||||
|
||||
constructor(status: string, responseText: string | null, options: object, xhr: XMLHttpRequest) {
|
||||
this.status = status;
|
||||
this.responseText = responseText;
|
||||
this.options = options;
|
@@ -1,20 +1,25 @@
|
||||
/**
|
||||
* The `SubtreeRetainer` class represents a Mithril virtual DOM subtree. It
|
||||
* keeps track of a number of pieces of data, allowing the subtree to be
|
||||
* retained if none of them have changed.
|
||||
* The `SubtreeRetainer` class keeps track of a number of pieces of data,
|
||||
* comparing the values of these pieces at every iteration.
|
||||
*
|
||||
* This is useful for preventing redraws to relatively static (or huge)
|
||||
* components whose VDOM only depends on very few values, when none of them
|
||||
* have changed.
|
||||
*
|
||||
* @example
|
||||
* // constructor
|
||||
* // Check two callbacks for changes on each update
|
||||
* this.subtree = new SubtreeRetainer(
|
||||
* () => this.props.post.freshness,
|
||||
* () => this.attrs.post.freshness,
|
||||
* () => this.showing
|
||||
* );
|
||||
* this.subtree.check(() => this.props.user.freshness);
|
||||
*
|
||||
* // view
|
||||
* this.subtree.retain() || 'expensive expression'
|
||||
* // Add more callbacks to be checked for updates
|
||||
* this.subtree.check(() => this.attrs.user.freshness);
|
||||
*
|
||||
* @see https://lhorie.github.io/mithril/mithril.html#persisting-dom-elements-across-route-changes
|
||||
* // In a component's onbeforeupdate() method:
|
||||
* return this.subtree.needsRebuild()
|
||||
*
|
||||
* @see https://mithril.js.org/lifecycle-methods.html#onbeforeupdate
|
||||
*/
|
||||
export default class SubtreeRetainer {
|
||||
/**
|
||||
@@ -26,13 +31,13 @@ export default class SubtreeRetainer {
|
||||
}
|
||||
|
||||
/**
|
||||
* Return a virtual DOM directive that will retain a subtree if no data has
|
||||
* changed since the last check.
|
||||
* Return whether any data has changed since the last check.
|
||||
* If so, Mithril needs to re-diff the vnode and its children.
|
||||
*
|
||||
* @return {Object|false}
|
||||
* @return {boolean}
|
||||
* @public
|
||||
*/
|
||||
retain() {
|
||||
needsRebuild() {
|
||||
let needsRebuild = false;
|
||||
|
||||
this.callbacks.forEach((callback, i) => {
|
||||
@@ -44,7 +49,7 @@ export default class SubtreeRetainer {
|
||||
}
|
||||
});
|
||||
|
||||
return needsRebuild ? false : { subtree: 'retain' };
|
||||
return needsRebuild;
|
||||
}
|
||||
|
||||
/**
|
||||
|
109
js/src/common/utils/SuperTextarea.js
Normal file
109
js/src/common/utils/SuperTextarea.js
Normal file
@@ -0,0 +1,109 @@
|
||||
/**
|
||||
* A textarea wrapper with powerful helpers for text manipulation.
|
||||
*
|
||||
* This wraps a <textarea> DOM element and allows directly manipulating its text
|
||||
* contents and cursor positions.
|
||||
*
|
||||
* I apologize for the pretentious name. :)
|
||||
*/
|
||||
export default class SuperTextarea {
|
||||
/**
|
||||
* @param {HTMLTextAreaElement} textarea
|
||||
*/
|
||||
constructor(textarea) {
|
||||
this.el = textarea;
|
||||
this.$ = $(textarea);
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the value of the text editor.
|
||||
*
|
||||
* @param {String} value
|
||||
*/
|
||||
setValue(value) {
|
||||
this.$.val(value).trigger('input');
|
||||
}
|
||||
|
||||
/**
|
||||
* Focus the textarea and place the cursor at the given index.
|
||||
*
|
||||
* @param {number} position
|
||||
*/
|
||||
moveCursorTo(position) {
|
||||
this.setSelectionRange(position, position);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the selected range of the textarea.
|
||||
*
|
||||
* @return {Array}
|
||||
*/
|
||||
getSelectionRange() {
|
||||
return [this.el.selectionStart, this.el.selectionEnd];
|
||||
}
|
||||
|
||||
/**
|
||||
* Insert content into the textarea at the position of the cursor.
|
||||
*
|
||||
* @param {String} text
|
||||
*/
|
||||
insertAtCursor(text) {
|
||||
this.insertAt(this.el.selectionStart, text);
|
||||
|
||||
this.el.dispatchEvent(new CustomEvent('input', { bubbles: true, cancelable: true }));
|
||||
}
|
||||
|
||||
/**
|
||||
* Insert content into the textarea at the given position.
|
||||
*
|
||||
* @param {number} pos
|
||||
* @param {String} text
|
||||
*/
|
||||
insertAt(pos, text) {
|
||||
this.insertBetween(pos, pos, text);
|
||||
}
|
||||
|
||||
/**
|
||||
* Insert content into the textarea between the given positions.
|
||||
*
|
||||
* If the start and end positions are different, any text between them will be
|
||||
* overwritten.
|
||||
*
|
||||
* @param start
|
||||
* @param end
|
||||
* @param text
|
||||
*/
|
||||
insertBetween(start, end, text) {
|
||||
const value = this.el.value;
|
||||
|
||||
const before = value.slice(0, start);
|
||||
const after = value.slice(end);
|
||||
|
||||
this.setValue(`${before}${text}${after}`);
|
||||
|
||||
// Move the textarea cursor to the end of the content we just inserted.
|
||||
this.moveCursorTo(start + text.length);
|
||||
}
|
||||
|
||||
/**
|
||||
* Replace existing content from the start to the current cursor position.
|
||||
*
|
||||
* @param start
|
||||
* @param text
|
||||
*/
|
||||
replaceBeforeCursor(start, text) {
|
||||
this.insertBetween(start, this.el.selectionStart, text);
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the selected range of the textarea.
|
||||
*
|
||||
* @param {number} start
|
||||
* @param {number} end
|
||||
* @private
|
||||
*/
|
||||
setSelectionRange(start, end) {
|
||||
this.el.setSelectionRange(start, end);
|
||||
this.$.focus();
|
||||
}
|
||||
}
|
@@ -4,16 +4,13 @@
|
||||
* @example
|
||||
* abbreviateNumber(1234);
|
||||
* // "1.2K"
|
||||
*
|
||||
* @param {Integer} number
|
||||
* @return {String}
|
||||
*/
|
||||
export default function abbreviateNumber(number) {
|
||||
export default function abbreviateNumber(number: number): string {
|
||||
// TODO: translation
|
||||
if (number >= 1000000) {
|
||||
return Math.floor(number / 1000000) + app.translator.trans('core.lib.number_suffix.mega_text');
|
||||
} else if (number >= 1000) {
|
||||
return Math.floor(number / 1000) + app.translator.trans('core.lib.number_suffix.kilo_text');
|
||||
return (number / 1000).toFixed(1) + app.translator.trans('core.lib.number_suffix.kilo_text');
|
||||
} else {
|
||||
return number.toString();
|
||||
}
|
@@ -1,15 +0,0 @@
|
||||
/**
|
||||
* The `extract` utility deletes a property from an object and returns its
|
||||
* value.
|
||||
*
|
||||
* @param {Object} object The object that owns the property
|
||||
* @param {String} property The name of the property to extract
|
||||
* @return {*} The value of the property
|
||||
*/
|
||||
export default function extract(object, property) {
|
||||
const value = object[property];
|
||||
|
||||
delete object[property];
|
||||
|
||||
return value;
|
||||
}
|
15
js/src/common/utils/extract.ts
Normal file
15
js/src/common/utils/extract.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
/**
|
||||
* The `extract` utility deletes a property from an object and returns its
|
||||
* value.
|
||||
*
|
||||
* @param object The object that owns the property
|
||||
* @param property The name of the property to extract
|
||||
* @return The value of the property
|
||||
*/
|
||||
export default function extract<T, K extends keyof T>(object: T, property: K): T[K] {
|
||||
const value = object[property];
|
||||
|
||||
delete object[property];
|
||||
|
||||
return value;
|
||||
}
|
@@ -8,7 +8,7 @@ export default function extractText(vdom) {
|
||||
if (vdom instanceof Array) {
|
||||
return vdom.map((element) => extractText(element)).join('');
|
||||
} else if (typeof vdom === 'object' && vdom !== null) {
|
||||
return extractText(vdom.children);
|
||||
return vdom.children ? extractText(vdom.children) : vdom.text;
|
||||
} else {
|
||||
return vdom;
|
||||
}
|
||||
|
@@ -5,10 +5,7 @@
|
||||
* @example
|
||||
* formatNumber(1234);
|
||||
* // 1,234
|
||||
*
|
||||
* @param {Number} number
|
||||
* @return {String}
|
||||
*/
|
||||
export default function formatNumber(number) {
|
||||
export default function formatNumber(number: number): string {
|
||||
return number.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ',');
|
||||
}
|
@@ -1,35 +1,32 @@
|
||||
/**
|
||||
* The `humanTime` utility converts a date to a localized, human-readable time-
|
||||
* ago string.
|
||||
*
|
||||
* @param {Date} time
|
||||
* @return {String}
|
||||
*/
|
||||
export default function humanTime(time) {
|
||||
let m = moment(time);
|
||||
const now = moment();
|
||||
export default function humanTime(time: Date): string {
|
||||
let d = dayjs(time);
|
||||
const now = dayjs();
|
||||
|
||||
// To prevent showing things like "in a few seconds" due to small offsets
|
||||
// between client and server time, we always reset future dates to the
|
||||
// current time. This will result in "just now" being shown instead.
|
||||
if (m.isAfter(now)) {
|
||||
m = now;
|
||||
if (d.isAfter(now)) {
|
||||
d = now;
|
||||
}
|
||||
|
||||
const day = 864e5;
|
||||
const diff = m.diff(moment());
|
||||
let ago = null;
|
||||
const diff = d.diff(dayjs());
|
||||
let ago: string;
|
||||
|
||||
// If this date was more than a month ago, we'll show the name of the month
|
||||
// in the string. If it wasn't this year, we'll show the year as well.
|
||||
if (diff < -30 * day) {
|
||||
if (m.year() === moment().year()) {
|
||||
ago = m.format('D MMM');
|
||||
if (d.year() === dayjs().year()) {
|
||||
ago = d.format('D MMM');
|
||||
} else {
|
||||
ago = m.format('ll');
|
||||
ago = d.format('ll');
|
||||
}
|
||||
} else {
|
||||
ago = m.fromNow();
|
||||
ago = d.fromNow();
|
||||
}
|
||||
|
||||
return ago;
|
@@ -1,18 +1,18 @@
|
||||
import humanTimeUtil from './humanTime';
|
||||
import humanTime from './humanTime';
|
||||
|
||||
function updateHumanTimes() {
|
||||
$('[data-humantime]').each(function () {
|
||||
const $this = $(this);
|
||||
const ago = humanTimeUtil($this.attr('datetime'));
|
||||
const ago = humanTime($this.attr('datetime'));
|
||||
|
||||
$this.html(ago);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* The `humanTime` initializer sets up a loop every 1 second to update
|
||||
* The `liveHumanTimes` initializer sets up a loop every 1 second to update
|
||||
* timestamps rendered with the `humanTime` helper.
|
||||
*/
|
||||
export default function humanTime() {
|
||||
export default function liveHumanTimes() {
|
||||
setInterval(updateHumanTimes, 10000);
|
||||
}
|
@@ -2,7 +2,7 @@
|
||||
* The `mapRoutes` utility converts a map of named application routes into a
|
||||
* format that can be understood by Mithril.
|
||||
*
|
||||
* @see https://lhorie.github.io/mithril/mithril.route.html#defining-routes
|
||||
* @see https://mithril.js.org/route.html#signature
|
||||
* @param {Object} routes
|
||||
* @param {String} [basePath]
|
||||
* @return {Object}
|
||||
@@ -13,9 +13,11 @@ export default function mapRoutes(routes, basePath = '') {
|
||||
for (const key in routes) {
|
||||
const route = routes[key];
|
||||
|
||||
if (route.component) route.component.props.routeName = key;
|
||||
|
||||
map[basePath + route.path] = route.component;
|
||||
map[basePath + route.path] = {
|
||||
render() {
|
||||
return m(route.component, { routeName: key });
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return map;
|
||||
|
@@ -1,27 +1,60 @@
|
||||
import Component from '../Component';
|
||||
import Stream from 'mithril/stream';
|
||||
import extract from './extract';
|
||||
|
||||
export default function patchMithril(global) {
|
||||
const mo = global.m;
|
||||
const defaultMithril = global.m;
|
||||
|
||||
const m = function (comp, ...args) {
|
||||
if (comp.prototype && comp.prototype instanceof Component) {
|
||||
let children = args.slice(1);
|
||||
if (children.length === 1 && Array.isArray(children[0])) {
|
||||
children = children[0];
|
||||
/**
|
||||
* If the href URL of the link is the same as the current page path
|
||||
* we will not add a new entry to the browser history.
|
||||
*
|
||||
* This allows us to still refresh the Page component
|
||||
* without adding endless history entries.
|
||||
*
|
||||
* We also add the `force` attribute that adds a custom state key
|
||||
* for when you want to force a complete refresh of the Page
|
||||
*/
|
||||
const defaultLinkView = defaultMithril.route.Link.view;
|
||||
const modifiedLink = {
|
||||
view: function (vnode) {
|
||||
let { href, options = {} } = vnode.attrs;
|
||||
|
||||
if (href === m.route.get()) {
|
||||
if (!('replace' in options)) options.replace = true;
|
||||
}
|
||||
|
||||
return comp.component(args[0], children);
|
||||
}
|
||||
if (extract(vnode.attrs, 'force')) {
|
||||
if (!('state' in options)) options.state = {};
|
||||
if (!('key' in options.state)) options.state.key = Date.now();
|
||||
}
|
||||
|
||||
const node = mo.apply(this, arguments);
|
||||
vnode.attrs.options = options;
|
||||
|
||||
return defaultLinkView(vnode);
|
||||
},
|
||||
};
|
||||
|
||||
const modifiedMithril = function (comp, ...args) {
|
||||
const node = defaultMithril.apply(this, arguments);
|
||||
|
||||
if (!node.attrs) node.attrs = {};
|
||||
|
||||
// Allows the use of the bidi attr.
|
||||
if (node.attrs.bidi) {
|
||||
m.bidi(node, node.attrs.bidi);
|
||||
modifiedMithril.bidi(node, node.attrs.bidi);
|
||||
}
|
||||
|
||||
// Allows us to use a "route" attr on links, which will automatically convert the link to one which
|
||||
// supports linking to other pages in the SPA without refreshing the document.
|
||||
if (node.attrs.route) {
|
||||
node.attrs.href = node.attrs.route;
|
||||
node.attrs.config = m.route;
|
||||
node.tag = modifiedLink;
|
||||
|
||||
// For some reason, m.route.Link does not like vnode.text, so if present, we
|
||||
// need to convert it to text vnodes and store it in children.
|
||||
if (node.text) {
|
||||
node.children = { tag: '#', children: node.text };
|
||||
}
|
||||
|
||||
delete node.attrs.route;
|
||||
}
|
||||
@@ -29,17 +62,11 @@ export default function patchMithril(global) {
|
||||
return node;
|
||||
};
|
||||
|
||||
Object.keys(mo).forEach((key) => (m[key] = mo[key]));
|
||||
Object.keys(defaultMithril).forEach((key) => (modifiedMithril[key] = defaultMithril[key]));
|
||||
|
||||
/**
|
||||
* Redraw only if not in the middle of a computation (e.g. a route change).
|
||||
*
|
||||
* @return {void}
|
||||
*/
|
||||
m.lazyRedraw = function () {
|
||||
m.startComputation();
|
||||
m.endComputation();
|
||||
};
|
||||
modifiedMithril.stream = Stream;
|
||||
|
||||
global.m = m;
|
||||
modifiedMithril.route.Link = modifiedLink;
|
||||
|
||||
global.m = modifiedMithril;
|
||||
}
|
||||
|
15
js/src/common/utils/setRouteWithForcedRefresh.ts
Normal file
15
js/src/common/utils/setRouteWithForcedRefresh.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import Mithril from 'mithril';
|
||||
|
||||
/**
|
||||
* Mithril 2 does not completely rerender the page if a route change leads to the same route
|
||||
* (or the same component handling a different route). This util calls m.route.set, forcing a reonit.
|
||||
*
|
||||
* @see https://mithril.js.org/route.html#key-parameter
|
||||
*/
|
||||
export default function setRouteWithForcedRefresh(route: string, params = null, options: Mithril.RouteOptions = {}) {
|
||||
const newOptions = { ...options };
|
||||
newOptions.state = newOptions.state || {};
|
||||
newOptions.state.key = Date.now();
|
||||
|
||||
m.route.set(route, params, newOptions);
|
||||
}
|
@@ -1,12 +1,7 @@
|
||||
/**
|
||||
* Truncate a string to the given length, appending ellipses if necessary.
|
||||
*
|
||||
* @param {String} string
|
||||
* @param {Number} length
|
||||
* @param {Number} [start=0]
|
||||
* @return {String}
|
||||
*/
|
||||
export function truncate(string, length, start = 0) {
|
||||
export function truncate(string: string, length: number, start: number = 0): string {
|
||||
return (start > 0 ? '...' : '') + string.substring(start, start + length) + (string.length > start + length ? '...' : '');
|
||||
}
|
||||
|
||||
@@ -17,11 +12,8 @@ export function truncate(string, length, start = 0) {
|
||||
* NOTE: This method does not use the comparably sophisticated transliteration
|
||||
* mechanism that is employed in the backend. Therefore, it should only be used
|
||||
* to *suggest* slugs that can be overridden by the user.
|
||||
*
|
||||
* @param {String} string
|
||||
* @return {String}
|
||||
*/
|
||||
export function slug(string) {
|
||||
export function slug(string: string): string {
|
||||
return string
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]/gi, '-')
|
||||
@@ -32,11 +24,8 @@ export function slug(string) {
|
||||
/**
|
||||
* Strip HTML tags and quotes out of the given string, replacing them with
|
||||
* meaningful punctuation.
|
||||
*
|
||||
* @param {String} string
|
||||
* @return {String}
|
||||
*/
|
||||
export function getPlainContent(string) {
|
||||
export function getPlainContent(string: string): string {
|
||||
const html = string.replace(/(<\/p>|<br>)/g, '$1 ').replace(/<img\b[^>]*>/gi, ' ');
|
||||
|
||||
const dom = $('<div/>').html(html);
|
||||
@@ -55,10 +44,7 @@ getPlainContent.removeSelectors = ['blockquote', 'script'];
|
||||
|
||||
/**
|
||||
* Make a string's first character uppercase.
|
||||
*
|
||||
* @param {String} string
|
||||
* @return {String}
|
||||
*/
|
||||
export function ucfirst(string) {
|
||||
export function ucfirst(string: string): string {
|
||||
return string.substr(0, 1).toUpperCase() + string.substr(1);
|
||||
}
|
@@ -1,4 +1,6 @@
|
||||
function hsvToRgb(h, s, v) {
|
||||
type RGB = { r: number; g: number; b: number };
|
||||
|
||||
function hsvToRgb(h: number, s: number, v: number): RGB {
|
||||
let r;
|
||||
let g;
|
||||
let b;
|
||||
@@ -51,11 +53,8 @@ function hsvToRgb(h, s, v) {
|
||||
|
||||
/**
|
||||
* Convert the given string to a unique color.
|
||||
*
|
||||
* @param {String} string
|
||||
* @return {String}
|
||||
*/
|
||||
export default function stringToColor(string) {
|
||||
export default function stringToColor(string: string): string {
|
||||
let num = 0;
|
||||
|
||||
// Convert the username into a number based on the ASCII value of each
|
15
js/src/common/utils/withAttr.ts
Normal file
15
js/src/common/utils/withAttr.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
/**
|
||||
* An event handler factory that makes it simpler to implement data binding
|
||||
* for component event listeners.
|
||||
*
|
||||
* The handler created by this factory passes the DOM element's attribute
|
||||
* identified by the first argument to the callback (usually a bidirectional
|
||||
* Mithril stream: https://mithril.js.org/stream.html#bidirectional-bindings).
|
||||
*
|
||||
* Replaces m.withAttr for Mithril 2.0.
|
||||
* @see https://mithril.js.org/archive/v0.2.5/mithril.withAttr.html
|
||||
*/
|
||||
export default (key: string, cb: Function) =>
|
||||
function (this: Element) {
|
||||
cb(this.getAttribute(key) || this[key]);
|
||||
};
|
@@ -1,6 +1,5 @@
|
||||
import History from './utils/History';
|
||||
import Pane from './utils/Pane';
|
||||
import ReplyComposer from './components/ReplyComposer';
|
||||
import DiscussionPage from './components/DiscussionPage';
|
||||
import SignUpModal from './components/SignUpModal';
|
||||
import HeaderPrimary from './components/HeaderPrimary';
|
||||
@@ -15,7 +14,8 @@ import Application from '../common/Application';
|
||||
import Navigation from '../common/components/Navigation';
|
||||
import NotificationListState from './states/NotificationListState';
|
||||
import GlobalSearchState from './states/GlobalSearchState';
|
||||
import DiscussionListState from './state/DiscussionListState';
|
||||
import DiscussionListState from './states/DiscussionListState';
|
||||
import ComposerState from './states/ComposerState';
|
||||
|
||||
export default class ForumApplication extends Application {
|
||||
/**
|
||||
@@ -73,6 +73,11 @@ export default class ForumApplication extends Application {
|
||||
*/
|
||||
search = new GlobalSearchState();
|
||||
|
||||
/*
|
||||
* An object which controls the state of the composer.
|
||||
*/
|
||||
composer = new ComposerState();
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
|
||||
@@ -84,7 +89,7 @@ export default class ForumApplication extends Application {
|
||||
*
|
||||
* @type {DiscussionListState}
|
||||
*/
|
||||
this.discussions = new DiscussionListState({ forumApp: this });
|
||||
this.discussions = new DiscussionListState({}, this);
|
||||
|
||||
/**
|
||||
* @deprecated beta 14, remove in beta 15.
|
||||
@@ -110,15 +115,15 @@ export default class ForumApplication extends Application {
|
||||
this.routes[defaultAction].path = '/';
|
||||
this.history.push(defaultAction, this.translator.trans('core.forum.header.back_to_index_tooltip'), '/');
|
||||
|
||||
m.mount(document.getElementById('app-navigation'), Navigation.component({ className: 'App-backControl', drawer: true }));
|
||||
m.mount(document.getElementById('header-navigation'), Navigation.component());
|
||||
m.mount(document.getElementById('header-primary'), HeaderPrimary.component());
|
||||
m.mount(document.getElementById('header-secondary'), HeaderSecondary.component());
|
||||
m.mount(document.getElementById('app-navigation'), { view: () => Navigation.component({ className: 'App-backControl', drawer: true }) });
|
||||
m.mount(document.getElementById('header-navigation'), Navigation);
|
||||
m.mount(document.getElementById('header-primary'), HeaderPrimary);
|
||||
m.mount(document.getElementById('header-secondary'), HeaderSecondary);
|
||||
m.mount(document.getElementById('composer'), { view: () => Composer.component({ state: this.composer }) });
|
||||
|
||||
this.pane = new Pane(document.getElementById('app'));
|
||||
this.composer = m.mount(document.getElementById('composer'), Composer.component());
|
||||
|
||||
m.route.mode = 'pathname';
|
||||
m.route.prefix = '';
|
||||
super.mount(this.forum.attribute('basePath'));
|
||||
|
||||
alertEmailConfirmation(this);
|
||||
@@ -138,21 +143,6 @@ export default class ForumApplication extends Application {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Check whether or not the user is currently composing a reply to a
|
||||
* discussion.
|
||||
*
|
||||
* @param {Discussion} discussion
|
||||
* @return {Boolean}
|
||||
*/
|
||||
composingReplyTo(discussion) {
|
||||
return (
|
||||
this.composer.component instanceof ReplyComposer &&
|
||||
this.composer.component.props.discussion === discussion &&
|
||||
this.composer.position !== Composer.PositionEnum.HIDDEN
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check whether or not the user is currently viewing a discussion.
|
||||
*
|
||||
@@ -171,8 +161,8 @@ export default class ForumApplication extends Application {
|
||||
* will be reloaded. Otherwise, a SignUpModal will be opened, prefilled
|
||||
* with the provided details.
|
||||
*
|
||||
* @param {Object} payload A dictionary of props to pass into the sign up
|
||||
* modal. A truthy `loggedIn` prop indicates that the user has logged
|
||||
* @param {Object} payload A dictionary of attrs to pass into the sign up
|
||||
* modal. A truthy `loggedIn` attr indicates that the user has logged
|
||||
* in, and thus the page is reloaded.
|
||||
* @public
|
||||
*/
|
||||
@@ -180,8 +170,7 @@ export default class ForumApplication extends Application {
|
||||
if (payload.loggedIn) {
|
||||
window.location.reload();
|
||||
} else {
|
||||
const modal = new SignUpModal(payload);
|
||||
this.modal.show(modal);
|
||||
this.modal.show(SignUpModal, payload);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@@ -3,12 +3,18 @@ import compat from '../common/compat';
|
||||
import PostControls from './utils/PostControls';
|
||||
import KeyboardNavigatable from './utils/KeyboardNavigatable';
|
||||
import slidable from './utils/slidable';
|
||||
import affixSidebar from './utils/affixSidebar';
|
||||
import History from './utils/History';
|
||||
import DiscussionControls from './utils/DiscussionControls';
|
||||
import alertEmailConfirmation from './utils/alertEmailConfirmation';
|
||||
import UserControls from './utils/UserControls';
|
||||
import Pane from './utils/Pane';
|
||||
import ComposerState from './states/ComposerState';
|
||||
import DiscussionListState from './states/DiscussionListState';
|
||||
import GlobalSearchState from './states/GlobalSearchState';
|
||||
import NotificationListState from './states/NotificationListState';
|
||||
import PostStreamState from './states/PostStreamState';
|
||||
import SearchState from './states/SearchState';
|
||||
import AffixedSidebar from './components/AffixedSidebar';
|
||||
import DiscussionPage from './components/DiscussionPage';
|
||||
import LogInModal from './components/LogInModal';
|
||||
import ComposerBody from './components/ComposerBody';
|
||||
@@ -55,6 +61,7 @@ import NotificationList from './components/NotificationList';
|
||||
import WelcomeHero from './components/WelcomeHero';
|
||||
import SignUpModal from './components/SignUpModal';
|
||||
import CommentPost from './components/CommentPost';
|
||||
import ComposerPostPreview from './components/ComposerPostPreview';
|
||||
import ReplyComposer from './components/ReplyComposer';
|
||||
import NotificationsPage from './components/NotificationsPage';
|
||||
import PostStreamScrubber from './components/PostStreamScrubber';
|
||||
@@ -71,12 +78,18 @@ export default Object.assign(compat, {
|
||||
'utils/PostControls': PostControls,
|
||||
'utils/KeyboardNavigatable': KeyboardNavigatable,
|
||||
'utils/slidable': slidable,
|
||||
'utils/affixSidebar': affixSidebar,
|
||||
'utils/History': History,
|
||||
'utils/DiscussionControls': DiscussionControls,
|
||||
'utils/alertEmailConfirmation': alertEmailConfirmation,
|
||||
'utils/UserControls': UserControls,
|
||||
'utils/Pane': Pane,
|
||||
'states/ComposerState': ComposerState,
|
||||
'states/DiscussionListState': DiscussionListState,
|
||||
'states/GlobalSearchState': GlobalSearchState,
|
||||
'states/NotificationListState': NotificationListState,
|
||||
'states/PostStreamState': PostStreamState,
|
||||
'states/SearchState': SearchState,
|
||||
'components/AffixedSidebar': AffixedSidebar,
|
||||
'components/DiscussionPage': DiscussionPage,
|
||||
'components/LogInModal': LogInModal,
|
||||
'components/ComposerBody': ComposerBody,
|
||||
@@ -123,6 +136,7 @@ export default Object.assign(compat, {
|
||||
'components/WelcomeHero': WelcomeHero,
|
||||
'components/SignUpModal': SignUpModal,
|
||||
'components/CommentPost': CommentPost,
|
||||
'components/ComposerPostPreview': ComposerPostPreview,
|
||||
'components/ReplyComposer': ReplyComposer,
|
||||
'components/NotificationsPage': NotificationsPage,
|
||||
'components/PostStreamScrubber': PostStreamScrubber,
|
||||
|
51
js/src/forum/components/AffixedSidebar.js
Normal file
51
js/src/forum/components/AffixedSidebar.js
Normal file
@@ -0,0 +1,51 @@
|
||||
import Component from '../../common/Component';
|
||||
|
||||
/**
|
||||
* The `AffixedSidebar` component uses Bootstrap's "affix" plugin to keep a
|
||||
* sidebar navigation at the top of the viewport when scrolling.
|
||||
*
|
||||
* ### Children
|
||||
*
|
||||
* The component must wrap an element that itself wraps an <ul> element, which
|
||||
* will be "affixed".
|
||||
*
|
||||
* @see https://getbootstrap.com/docs/3.4/javascript/#affix
|
||||
*/
|
||||
export default class AffixedSidebar extends Component {
|
||||
view(vnode) {
|
||||
return vnode.children[0];
|
||||
}
|
||||
|
||||
oncreate(vnode) {
|
||||
super.oncreate(vnode);
|
||||
|
||||
// Register the affix plugin to execute on every window resize (and trigger)
|
||||
this.boundOnresize = this.onresize.bind(this);
|
||||
$(window).on('resize', this.boundOnresize).resize();
|
||||
}
|
||||
|
||||
onremove() {
|
||||
$(window).off('resize', this.boundOnresize);
|
||||
}
|
||||
|
||||
onresize() {
|
||||
const $sidebar = this.$();
|
||||
const $header = $('#header');
|
||||
const $footer = $('#footer');
|
||||
const $affixElement = $sidebar.find('> ul');
|
||||
|
||||
$(window).off('.affix');
|
||||
$affixElement.removeClass('affix affix-top affix-bottom').removeData('bs.affix');
|
||||
|
||||
// Don't affix the sidebar if it is taller than the viewport (otherwise
|
||||
// there would be no way to scroll through its content).
|
||||
if ($sidebar.outerHeight(true) > $(window).height() - $header.outerHeight(true)) return;
|
||||
|
||||
$affixElement.affix({
|
||||
offset: {
|
||||
top: () => $sidebar.offset().top - $header.outerHeight(true) - parseInt($sidebar.css('margin-top'), 10),
|
||||
bottom: () => (this.bottom = $footer.outerHeight(true)),
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
@@ -3,6 +3,7 @@ import avatar from '../../common/helpers/avatar';
|
||||
import icon from '../../common/helpers/icon';
|
||||
import listItems from '../../common/helpers/listItems';
|
||||
import ItemList from '../../common/utils/ItemList';
|
||||
import classList from '../../common/utils/classList';
|
||||
import Button from '../../common/components/Button';
|
||||
import LoadingIndicator from '../../common/components/LoadingIndicator';
|
||||
|
||||
@@ -10,13 +11,15 @@ import LoadingIndicator from '../../common/components/LoadingIndicator';
|
||||
* The `AvatarEditor` component displays a user's avatar along with a dropdown
|
||||
* menu which allows the user to upload/remove the avatar.
|
||||
*
|
||||
* ### Props
|
||||
* ### Attrs
|
||||
*
|
||||
* - `className`
|
||||
* - `user`
|
||||
*/
|
||||
export default class AvatarEditor extends Component {
|
||||
init() {
|
||||
oninit(vnode) {
|
||||
super.oninit(vnode);
|
||||
|
||||
/**
|
||||
* Whether or not an avatar upload is in progress.
|
||||
*
|
||||
@@ -32,17 +35,11 @@ export default class AvatarEditor extends Component {
|
||||
this.isDraggedOver = false;
|
||||
}
|
||||
|
||||
static initProps(props) {
|
||||
super.initProps(props);
|
||||
|
||||
props.className = props.className || '';
|
||||
}
|
||||
|
||||
view() {
|
||||
const user = this.props.user;
|
||||
const user = this.attrs.user;
|
||||
|
||||
return (
|
||||
<div className={'AvatarEditor Dropdown ' + this.props.className + (this.loading ? ' loading' : '') + (this.isDraggedOver ? ' dragover' : '')}>
|
||||
<div className={classList(['AvatarEditor', 'Dropdown', this.attrs.className, this.loading && 'loading', this.isDraggedOver && 'dragover'])}>
|
||||
{avatar(user)}
|
||||
<a
|
||||
className={user.avatarUrl() ? 'Dropdown-toggle' : 'Dropdown-toggle AvatarEditor--noAvatar'}
|
||||
@@ -55,7 +52,7 @@ export default class AvatarEditor extends Component {
|
||||
ondragend={this.disableDragover.bind(this)}
|
||||
ondrop={this.dropUpload.bind(this)}
|
||||
>
|
||||
{this.loading ? LoadingIndicator.component() : user.avatarUrl() ? icon('fas fa-pencil-alt') : icon('fas fa-plus-circle')}
|
||||
{this.loading ? <LoadingIndicator /> : user.avatarUrl() ? icon('fas fa-pencil-alt') : icon('fas fa-plus-circle')}
|
||||
</a>
|
||||
<ul className="Dropdown-menu Menu">{listItems(this.controlItems().toArray())}</ul>
|
||||
</div>
|
||||
@@ -72,20 +69,16 @@ export default class AvatarEditor extends Component {
|
||||
|
||||
items.add(
|
||||
'upload',
|
||||
Button.component({
|
||||
icon: 'fas fa-upload',
|
||||
children: app.translator.trans('core.forum.user.avatar_upload_button'),
|
||||
onclick: this.openPicker.bind(this),
|
||||
})
|
||||
<Button icon="fas fa-upload" onclick={this.openPicker.bind(this)}>
|
||||
{app.translator.trans('core.forum.user.avatar_upload_button')}
|
||||
</Button>
|
||||
);
|
||||
|
||||
items.add(
|
||||
'remove',
|
||||
Button.component({
|
||||
icon: 'fas fa-times',
|
||||
children: app.translator.trans('core.forum.user.avatar_remove_button'),
|
||||
onclick: this.remove.bind(this),
|
||||
})
|
||||
<Button icon="fas fa-times" onclick={this.remove.bind(this)}>
|
||||
{app.translator.trans('core.forum.user.avatar_remove_button')}
|
||||
</Button>
|
||||
);
|
||||
|
||||
return items;
|
||||
@@ -134,7 +127,7 @@ export default class AvatarEditor extends Component {
|
||||
* @param {Event} e
|
||||
*/
|
||||
quickUpload(e) {
|
||||
if (!this.props.user.avatarUrl()) {
|
||||
if (!this.attrs.user.avatarUrl()) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
this.openPicker();
|
||||
@@ -149,7 +142,6 @@ export default class AvatarEditor extends Component {
|
||||
|
||||
// Create a hidden HTML input element and click on it so the user can select
|
||||
// an avatar file. Once they have, we will upload it via the API.
|
||||
const user = this.props.user;
|
||||
const $input = $('<input type="file">');
|
||||
|
||||
$input
|
||||
@@ -169,7 +161,7 @@ export default class AvatarEditor extends Component {
|
||||
upload(file) {
|
||||
if (this.loading) return;
|
||||
|
||||
const user = this.props.user;
|
||||
const user = this.attrs.user;
|
||||
const data = new FormData();
|
||||
data.append('avatar', file);
|
||||
|
||||
@@ -179,9 +171,9 @@ export default class AvatarEditor extends Component {
|
||||
app
|
||||
.request({
|
||||
method: 'POST',
|
||||
url: app.forum.attribute('apiUrl') + '/users/' + user.id() + '/avatar',
|
||||
url: `${app.forum.attribute('apiUrl')}/users/${user.id()}/avatar`,
|
||||
serialize: (raw) => raw,
|
||||
data,
|
||||
body: data,
|
||||
})
|
||||
.then(this.success.bind(this), this.failure.bind(this));
|
||||
}
|
||||
@@ -190,7 +182,7 @@ export default class AvatarEditor extends Component {
|
||||
* Remove the user's avatar.
|
||||
*/
|
||||
remove() {
|
||||
const user = this.props.user;
|
||||
const user = this.attrs.user;
|
||||
|
||||
this.loading = true;
|
||||
m.redraw();
|
||||
@@ -198,7 +190,7 @@ export default class AvatarEditor extends Component {
|
||||
app
|
||||
.request({
|
||||
method: 'DELETE',
|
||||
url: app.forum.attribute('apiUrl') + '/users/' + user.id() + '/avatar',
|
||||
url: `${app.forum.attribute('apiUrl')}/users/${user.id()}/avatar`,
|
||||
})
|
||||
.then(this.success.bind(this), this.failure.bind(this));
|
||||
}
|
||||
@@ -212,7 +204,7 @@ export default class AvatarEditor extends Component {
|
||||
*/
|
||||
success(response) {
|
||||
app.store.pushPayload(response);
|
||||
delete this.props.user.avatarColor;
|
||||
delete this.attrs.user.avatarColor;
|
||||
|
||||
this.loading = false;
|
||||
m.redraw();
|
||||
|
@@ -6,8 +6,8 @@ import Button from '../../common/components/Button';
|
||||
* to change their email address.
|
||||
*/
|
||||
export default class ChangeEmailModal extends Modal {
|
||||
init() {
|
||||
super.init();
|
||||
oninit(vnode) {
|
||||
super.oninit(vnode);
|
||||
|
||||
/**
|
||||
* Whether or not the email has been changed successfully.
|
||||
@@ -21,14 +21,14 @@ export default class ChangeEmailModal extends Modal {
|
||||
*
|
||||
* @type {function}
|
||||
*/
|
||||
this.email = m.prop(app.session.user.email());
|
||||
this.email = m.stream(app.session.user.email());
|
||||
|
||||
/**
|
||||
* The value of the password input.
|
||||
*
|
||||
* @type {function}
|
||||
*/
|
||||
this.password = m.prop('');
|
||||
this.password = m.stream('');
|
||||
}
|
||||
|
||||
className() {
|
||||
@@ -81,12 +81,14 @@ export default class ChangeEmailModal extends Modal {
|
||||
/>
|
||||
</div>
|
||||
<div className="Form-group">
|
||||
{Button.component({
|
||||
className: 'Button Button--primary Button--block',
|
||||
type: 'submit',
|
||||
loading: this.loading,
|
||||
children: app.translator.trans('core.forum.change_email.submit_button'),
|
||||
})}
|
||||
{Button.component(
|
||||
{
|
||||
className: 'Button Button--primary Button--block',
|
||||
type: 'submit',
|
||||
loading: this.loading,
|
||||
},
|
||||
app.translator.trans('core.forum.change_email.submit_button')
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -122,7 +124,7 @@ export default class ChangeEmailModal extends Modal {
|
||||
|
||||
onerror(error) {
|
||||
if (error.status === 401) {
|
||||
error.alert.props.children = app.translator.trans('core.forum.change_email.incorrect_password_message');
|
||||
error.alert.content = app.translator.trans('core.forum.change_email.incorrect_password_message');
|
||||
}
|
||||
|
||||
super.onerror(error);
|
||||
|
@@ -20,12 +20,14 @@ export default class ChangePasswordModal extends Modal {
|
||||
<div className="Form Form--centered">
|
||||
<p className="helpText">{app.translator.trans('core.forum.change_password.text')}</p>
|
||||
<div className="Form-group">
|
||||
{Button.component({
|
||||
className: 'Button Button--primary Button--block',
|
||||
type: 'submit',
|
||||
loading: this.loading,
|
||||
children: app.translator.trans('core.forum.change_password.send_button'),
|
||||
})}
|
||||
{Button.component(
|
||||
{
|
||||
className: 'Button Button--primary Button--block',
|
||||
type: 'submit',
|
||||
loading: this.loading,
|
||||
},
|
||||
app.translator.trans('core.forum.change_password.send_button')
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -41,7 +43,7 @@ export default class ChangePasswordModal extends Modal {
|
||||
.request({
|
||||
method: 'POST',
|
||||
url: app.forum.attribute('apiUrl') + '/forgot',
|
||||
data: { email: app.session.user.email() },
|
||||
body: { email: app.session.user.email() },
|
||||
})
|
||||
.then(this.hide.bind(this), this.loaded.bind(this));
|
||||
}
|
||||
|
@@ -1,5 +1,3 @@
|
||||
/*global s9e, hljs*/
|
||||
|
||||
import Post from './Post';
|
||||
import classList from '../../common/utils/classList';
|
||||
import PostUser from './PostUser';
|
||||
@@ -9,19 +7,20 @@ import EditPostComposer from './EditPostComposer';
|
||||
import ItemList from '../../common/utils/ItemList';
|
||||
import listItems from '../../common/helpers/listItems';
|
||||
import Button from '../../common/components/Button';
|
||||
import ComposerPostPreview from './ComposerPostPreview';
|
||||
|
||||
/**
|
||||
* The `CommentPost` component displays a standard `comment`-typed post. This
|
||||
* includes a number of item lists (controls, header, and footer) surrounding
|
||||
* the post's HTML content.
|
||||
*
|
||||
* ### Props
|
||||
* ### Attrs
|
||||
*
|
||||
* - `post`
|
||||
*/
|
||||
export default class CommentPost extends Post {
|
||||
init() {
|
||||
super.init();
|
||||
oninit(vnode) {
|
||||
super.oninit(vnode);
|
||||
|
||||
/**
|
||||
* If the post has been hidden, then this flag determines whether or not its
|
||||
@@ -31,47 +30,56 @@ export default class CommentPost extends Post {
|
||||
*/
|
||||
this.revealContent = false;
|
||||
|
||||
this.subtree.check(() => this.isEditing());
|
||||
/**
|
||||
* Whether or not the user hover card inside of PostUser is visible.
|
||||
* The property must be managed in CommentPost to be able to use it in the subtree check
|
||||
*
|
||||
* @type {Boolean}
|
||||
*/
|
||||
this.cardVisible = false;
|
||||
|
||||
this.subtree.check(
|
||||
() => this.cardVisible,
|
||||
() => this.isEditing(),
|
||||
() => this.revealContent
|
||||
);
|
||||
}
|
||||
|
||||
content() {
|
||||
// Note: we avoid using JSX for the <ul> below because it results in some
|
||||
// weirdness in Mithril.js 0.1.x (see flarum/core#975). This workaround can
|
||||
// be reverted when we upgrade to Mithril 1.0.
|
||||
return super
|
||||
.content()
|
||||
.concat([
|
||||
<header className="Post-header">{m('ul', listItems(this.headerItems().toArray()))}</header>,
|
||||
<div className="Post-body">
|
||||
{this.isEditing() ? <div className="Post-preview" config={this.configPreview.bind(this)} /> : m.trust(this.props.post.contentHtml())}
|
||||
</div>,
|
||||
]);
|
||||
return super.content().concat([
|
||||
<header className="Post-header">
|
||||
<ul>{listItems(this.headerItems().toArray())}</ul>
|
||||
</header>,
|
||||
<div className="Post-body">
|
||||
{this.isEditing() ? <ComposerPostPreview className="Post-preview" composer={app.composer} /> : m.trust(this.attrs.post.contentHtml())}
|
||||
</div>,
|
||||
]);
|
||||
}
|
||||
|
||||
config(isInitialized, context) {
|
||||
super.config(...arguments);
|
||||
onupdate(vnode) {
|
||||
super.onupdate();
|
||||
|
||||
const contentHtml = this.isEditing() ? '' : this.props.post.contentHtml();
|
||||
const contentHtml = this.isEditing() ? '' : this.attrs.post.contentHtml();
|
||||
|
||||
// If the post content has changed since the last render, we'll run through
|
||||
// all of the <script> tags in the content and evaluate them. This is
|
||||
// necessary because TextFormatter outputs them for e.g. syntax highlighting.
|
||||
if (context.contentHtml !== contentHtml) {
|
||||
if (this.contentHtml !== contentHtml) {
|
||||
this.$('.Post-body script').each(function () {
|
||||
eval.call(window, $(this).text());
|
||||
});
|
||||
}
|
||||
|
||||
context.contentHtml = contentHtml;
|
||||
this.contentHtml = contentHtml;
|
||||
}
|
||||
|
||||
isEditing() {
|
||||
return app.composer.component instanceof EditPostComposer && app.composer.component.props.post === this.props.post;
|
||||
return app.composer.bodyMatches(EditPostComposer, { post: this.attrs.post });
|
||||
}
|
||||
|
||||
attrs() {
|
||||
const post = this.props.post;
|
||||
const attrs = super.attrs();
|
||||
elementAttrs() {
|
||||
const post = this.attrs.post;
|
||||
const attrs = super.elementAttrs();
|
||||
|
||||
attrs.className =
|
||||
(attrs.className || '') +
|
||||
@@ -87,27 +95,6 @@ export default class CommentPost extends Post {
|
||||
return attrs;
|
||||
}
|
||||
|
||||
configPreview(element, isInitialized, context) {
|
||||
if (isInitialized) return;
|
||||
|
||||
// Every 50ms, if the composer content has changed, then update the post's
|
||||
// body with a preview.
|
||||
let preview;
|
||||
const updatePreview = () => {
|
||||
const content = app.composer.component.content();
|
||||
|
||||
if (preview === content) return;
|
||||
|
||||
preview = content;
|
||||
|
||||
s9e.TextFormatter.preview(preview || '', element);
|
||||
};
|
||||
updatePreview();
|
||||
|
||||
const updateInterval = setInterval(updatePreview, 50);
|
||||
context.onunload = () => clearInterval(updateInterval);
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle the visibility of a hidden post's content.
|
||||
*/
|
||||
@@ -122,9 +109,24 @@ export default class CommentPost extends Post {
|
||||
*/
|
||||
headerItems() {
|
||||
const items = new ItemList();
|
||||
const post = this.props.post;
|
||||
const post = this.attrs.post;
|
||||
|
||||
items.add('user', PostUser.component({ post }), 100);
|
||||
items.add(
|
||||
'user',
|
||||
PostUser.component({
|
||||
post,
|
||||
cardVisible: this.cardVisible,
|
||||
oncardshow: () => {
|
||||
this.cardVisible = true;
|
||||
m.redraw();
|
||||
},
|
||||
oncardhide: () => {
|
||||
this.cardVisible = false;
|
||||
m.redraw();
|
||||
},
|
||||
}),
|
||||
100
|
||||
);
|
||||
items.add('meta', PostMeta.component({ post }));
|
||||
|
||||
if (post.isEdited() && !post.isHidden()) {
|
||||
|
@@ -3,28 +3,23 @@ import ItemList from '../../common/utils/ItemList';
|
||||
import ComposerButton from './ComposerButton';
|
||||
import listItems from '../../common/helpers/listItems';
|
||||
import classList from '../../common/utils/classList';
|
||||
import ComposerState from '../states/ComposerState';
|
||||
|
||||
/**
|
||||
* The `Composer` component displays the composer. It can be loaded with a
|
||||
* content component with `load` and then its position/state can be altered with
|
||||
* `show`, `hide`, `close`, `minimize`, `fullScreen`, and `exitFullScreen`.
|
||||
*/
|
||||
class Composer extends Component {
|
||||
init() {
|
||||
/**
|
||||
* The composer's current position.
|
||||
*
|
||||
* @type {Composer.PositionEnum}
|
||||
*/
|
||||
this.position = Composer.PositionEnum.HIDDEN;
|
||||
export default class Composer extends Component {
|
||||
oninit(vnode) {
|
||||
super.oninit(vnode);
|
||||
|
||||
/**
|
||||
* The composer's intended height, which can be modified by the user
|
||||
* (by dragging the composer handle).
|
||||
* The composer's "state".
|
||||
*
|
||||
* @type {Integer}
|
||||
* @type {ComposerState}
|
||||
*/
|
||||
this.height = null;
|
||||
this.state = this.attrs.state;
|
||||
|
||||
/**
|
||||
* Whether or not the composer currently has focus.
|
||||
@@ -32,48 +27,52 @@ class Composer extends Component {
|
||||
* @type {Boolean}
|
||||
*/
|
||||
this.active = false;
|
||||
|
||||
// Store the initial position so that we can trigger animations correctly.
|
||||
this.prevPosition = this.state.position;
|
||||
}
|
||||
|
||||
view() {
|
||||
const body = this.state.body;
|
||||
const classes = {
|
||||
normal: this.position === Composer.PositionEnum.NORMAL,
|
||||
minimized: this.position === Composer.PositionEnum.MINIMIZED,
|
||||
fullScreen: this.position === Composer.PositionEnum.FULLSCREEN,
|
||||
normal: this.state.position === ComposerState.Position.NORMAL,
|
||||
minimized: this.state.position === ComposerState.Position.MINIMIZED,
|
||||
fullScreen: this.state.position === ComposerState.Position.FULLSCREEN,
|
||||
active: this.active,
|
||||
visible: this.state.isVisible(),
|
||||
};
|
||||
classes.visible = classes.normal || classes.minimized || classes.fullScreen;
|
||||
|
||||
// If the composer is minimized, tell the composer's content component that
|
||||
// it shouldn't let the user interact with it. Set up a handler so that if
|
||||
// the content IS clicked, the composer will be shown.
|
||||
if (this.component) this.component.props.disabled = classes.minimized;
|
||||
|
||||
const showIfMinimized = this.position === Composer.PositionEnum.MINIMIZED ? this.show.bind(this) : undefined;
|
||||
// Set up a handler so that clicks on the content will show the composer.
|
||||
const showIfMinimized = this.state.position === ComposerState.Position.MINIMIZED ? this.state.show.bind(this.state) : undefined;
|
||||
|
||||
return (
|
||||
<div className={'Composer ' + classList(classes)}>
|
||||
<div className="Composer-handle" config={this.configHandle.bind(this)} />
|
||||
<div className="Composer-handle" oncreate={this.configHandle.bind(this)} />
|
||||
<ul className="Composer-controls">{listItems(this.controlItems().toArray())}</ul>
|
||||
<div className="Composer-content" onclick={showIfMinimized}>
|
||||
{this.component ? this.component.render() : ''}
|
||||
{body.componentClass ? body.componentClass.component({ ...body.attrs, composer: this.state, disabled: classes.minimized }) : ''}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
config(isInitialized, context) {
|
||||
// Set the height of the Composer element and its contents on each redraw,
|
||||
// so that they do not lose it if their DOM elements are recreated.
|
||||
this.updateHeight();
|
||||
onupdate() {
|
||||
if (this.state.position === this.prevPosition) {
|
||||
// Set the height of the Composer element and its contents on each redraw,
|
||||
// so that they do not lose it if their DOM elements are recreated.
|
||||
this.updateHeight();
|
||||
} else {
|
||||
this.animatePositionChange();
|
||||
|
||||
if (isInitialized) return;
|
||||
this.prevPosition = this.state.position;
|
||||
}
|
||||
}
|
||||
|
||||
// Since this component is a part of the global UI that persists between
|
||||
// routes, we will flag the DOM to be retained across route changes.
|
||||
context.retain = true;
|
||||
oncreate(vnode) {
|
||||
super.oncreate(vnode);
|
||||
|
||||
this.initializeHeight();
|
||||
this.$().hide().css('bottom', -this.computedHeight());
|
||||
this.$().hide().css('bottom', -this.state.computedHeight());
|
||||
|
||||
// Whenever any of the inputs inside the composer are have focus, we want to
|
||||
// add a class to the composer to draw attention to it.
|
||||
@@ -85,43 +84,31 @@ class Composer extends Component {
|
||||
// When the escape key is pressed on any inputs, close the composer.
|
||||
this.$().on('keydown', ':input', 'esc', () => this.close());
|
||||
|
||||
// Don't let the user leave the page without first giving the composer's
|
||||
// component a chance to scream at the user to make sure they don't
|
||||
// unintentionally lose any contnet.
|
||||
window.onbeforeunload = () => {
|
||||
return (this.component && this.component.preventExit()) || undefined;
|
||||
};
|
||||
|
||||
const handlers = {};
|
||||
this.handlers = {};
|
||||
|
||||
$(window)
|
||||
.on('resize', (handlers.onresize = this.updateHeight.bind(this)))
|
||||
.on('resize', (this.handlers.onresize = this.updateHeight.bind(this)))
|
||||
.resize();
|
||||
|
||||
$(document)
|
||||
.on('mousemove', (handlers.onmousemove = this.onmousemove.bind(this)))
|
||||
.on('mouseup', (handlers.onmouseup = this.onmouseup.bind(this)));
|
||||
.on('mousemove', (this.handlers.onmousemove = this.onmousemove.bind(this)))
|
||||
.on('mouseup', (this.handlers.onmouseup = this.onmouseup.bind(this)));
|
||||
}
|
||||
|
||||
context.onunload = () => {
|
||||
$(window).off('resize', handlers.onresize);
|
||||
onremove() {
|
||||
$(window).off('resize', this.handlers.onresize);
|
||||
|
||||
$(document).off('mousemove', handlers.onmousemove).off('mouseup', handlers.onmouseup);
|
||||
};
|
||||
$(document).off('mousemove', this.handlers.onmousemove).off('mouseup', this.handlers.onmouseup);
|
||||
}
|
||||
|
||||
/**
|
||||
* Add the necessary event handlers to the composer's handle so that it can
|
||||
* be used to resize the composer.
|
||||
*
|
||||
* @param {DOMElement} element
|
||||
* @param {Boolean} isInitialized
|
||||
*/
|
||||
configHandle(element, isInitialized) {
|
||||
if (isInitialized) return;
|
||||
|
||||
configHandle(vnode) {
|
||||
const composer = this;
|
||||
|
||||
$(element)
|
||||
$(vnode.dom)
|
||||
.css('cursor', 'row-resize')
|
||||
.bind('dragstart mousedown', (e) => e.preventDefault())
|
||||
.mousedown(function (e) {
|
||||
@@ -166,13 +153,20 @@ class Composer extends Component {
|
||||
$('body').css('cursor', '');
|
||||
}
|
||||
|
||||
/**
|
||||
* Draw focus to the first focusable content element (the text editor).
|
||||
*/
|
||||
focus() {
|
||||
this.$('.Composer-content :input:enabled:visible:first').focus();
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the DOM to reflect the composer's current height. This involves
|
||||
* setting the height of the composer's root element, and adjusting the height
|
||||
* of any flexible elements inside the composer's body.
|
||||
*/
|
||||
updateHeight() {
|
||||
const height = this.computedHeight();
|
||||
const height = this.state.computedHeight();
|
||||
const $flexible = this.$('.Composer-flexible');
|
||||
|
||||
this.$().height(height);
|
||||
@@ -193,109 +187,59 @@ class Composer extends Component {
|
||||
*/
|
||||
updateBodyPadding() {
|
||||
const visible =
|
||||
this.position !== Composer.PositionEnum.HIDDEN && this.position !== Composer.PositionEnum.MINIMIZED && this.$().css('position') !== 'absolute';
|
||||
this.state.position !== ComposerState.Position.HIDDEN && this.state.position !== ComposerState.Position.MINIMIZED && app.screen() !== 'phone';
|
||||
|
||||
const paddingBottom = visible ? this.computedHeight() - parseInt($('#app').css('padding-bottom'), 10) : 0;
|
||||
const paddingBottom = visible ? this.state.computedHeight() - parseInt($('#app').css('padding-bottom'), 10) : 0;
|
||||
|
||||
$('#content').css({ paddingBottom });
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine whether or not the Composer is covering the screen.
|
||||
*
|
||||
* This will be true if the Composer is in full-screen mode on desktop, or
|
||||
* if the Composer is positioned absolutely as on mobile devices.
|
||||
*
|
||||
* @return {Boolean}
|
||||
* @public
|
||||
* Trigger the right animation depending on the desired new position.
|
||||
*/
|
||||
isFullScreen() {
|
||||
return this.position === Composer.PositionEnum.FULLSCREEN || this.$().css('position') === 'absolute';
|
||||
}
|
||||
animatePositionChange() {
|
||||
// When exiting full-screen mode: focus content
|
||||
if (this.prevPosition === ComposerState.Position.FULLSCREEN) {
|
||||
this.focus();
|
||||
return;
|
||||
}
|
||||
|
||||
/**
|
||||
* Confirm with the user that they want to close the composer and lose their
|
||||
* content.
|
||||
*
|
||||
* @return {Boolean} Whether or not the exit was cancelled.
|
||||
*/
|
||||
preventExit() {
|
||||
if (this.component) {
|
||||
const preventExit = this.component.preventExit();
|
||||
|
||||
if (preventExit) {
|
||||
return !confirm(preventExit);
|
||||
}
|
||||
switch (this.state.position) {
|
||||
case ComposerState.Position.HIDDEN:
|
||||
return this.hide();
|
||||
case ComposerState.Position.MINIMIZED:
|
||||
return this.minimize();
|
||||
case ComposerState.Position.FULLSCREEN:
|
||||
return this.focus();
|
||||
case ComposerState.Position.NORMAL:
|
||||
return this.show();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Load a content component into the composer.
|
||||
*
|
||||
* @param {Component} component
|
||||
* @public
|
||||
* Animate the Composer into the new position by changing the height.
|
||||
*/
|
||||
load(component) {
|
||||
if (this.preventExit()) return;
|
||||
|
||||
// If we load a similar component into the composer, then Mithril will be
|
||||
// able to diff the old/new contents and some DOM-related state from the
|
||||
// old composer will remain. To prevent this from happening, we clear the
|
||||
// component and force a redraw, so that the new component will be working
|
||||
// on a blank slate.
|
||||
if (this.component) {
|
||||
this.clear();
|
||||
m.redraw(true);
|
||||
}
|
||||
|
||||
this.component = component;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear the composer's content component.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
clear() {
|
||||
this.component = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Animate the Composer into the given position.
|
||||
*
|
||||
* @param {Composer.PositionEnum} position
|
||||
*/
|
||||
animateToPosition(position) {
|
||||
// Before we redraw the composer to its new state, we need to save the
|
||||
// current height of the composer, as well as the page's scroll position, so
|
||||
// that we can smoothly transition from the old to the new state.
|
||||
const oldPosition = this.position;
|
||||
animateHeightChange() {
|
||||
const $composer = this.$().stop(true);
|
||||
const oldHeight = $composer.outerHeight();
|
||||
const scrollTop = $(window).scrollTop();
|
||||
|
||||
this.position = position;
|
||||
|
||||
m.redraw(true);
|
||||
|
||||
// Now that we've redrawn and the composer's DOM has been updated, we want
|
||||
// to update the composer's height. Once we've done that, we'll capture the
|
||||
// real value to use as the end point for our animation later on.
|
||||
$composer.show();
|
||||
this.updateHeight();
|
||||
|
||||
const newHeight = $composer.outerHeight();
|
||||
|
||||
if (oldPosition === Composer.PositionEnum.HIDDEN) {
|
||||
if (this.prevPosition === ComposerState.Position.HIDDEN) {
|
||||
$composer.css({ bottom: -newHeight, height: newHeight });
|
||||
} else {
|
||||
$composer.css({ height: oldHeight });
|
||||
}
|
||||
|
||||
$composer.animate({ bottom: 0, height: newHeight }, 'fast', () => this.component.focus());
|
||||
const animation = $composer.animate({ bottom: 0, height: newHeight }, 'fast').promise();
|
||||
|
||||
this.updateBodyPadding();
|
||||
$(window).scrollTop(scrollTop);
|
||||
return animation;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -313,40 +257,30 @@ class Composer extends Component {
|
||||
}
|
||||
|
||||
/**
|
||||
* Show the composer.
|
||||
* Animate the composer sliding up from the bottom to take its normal height.
|
||||
*
|
||||
* @public
|
||||
* @private
|
||||
*/
|
||||
show() {
|
||||
if (this.position === Composer.PositionEnum.NORMAL || this.position === Composer.PositionEnum.FULLSCREEN) {
|
||||
return;
|
||||
}
|
||||
this.animateHeightChange().then(() => this.focus());
|
||||
|
||||
this.animateToPosition(Composer.PositionEnum.NORMAL);
|
||||
|
||||
if (this.isFullScreen()) {
|
||||
if (app.screen() === 'phone') {
|
||||
this.$().css('top', $(window).scrollTop());
|
||||
this.showBackdrop();
|
||||
this.component.focus();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Close the composer.
|
||||
* Animate closing the composer.
|
||||
*
|
||||
* @public
|
||||
* @private
|
||||
*/
|
||||
hide() {
|
||||
const $composer = this.$();
|
||||
|
||||
// Animate the composer sliding down off the bottom edge of the viewport.
|
||||
// Only when the animation is completed, update the Composer state flag and
|
||||
// other elements on the page.
|
||||
// Only when the animation is completed, update other elements on the page.
|
||||
$composer.stop(true).animate({ bottom: -$composer.height() }, 'fast', () => {
|
||||
this.position = Composer.PositionEnum.HIDDEN;
|
||||
this.clear();
|
||||
m.redraw();
|
||||
|
||||
$composer.hide();
|
||||
this.hideBackdrop();
|
||||
this.updateBodyPadding();
|
||||
@@ -354,60 +288,17 @@ class Composer extends Component {
|
||||
}
|
||||
|
||||
/**
|
||||
* Confirm with the user so they don't lose their content, then close the
|
||||
* composer.
|
||||
* Shrink the composer until only its title is visible.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
close() {
|
||||
if (!this.preventExit()) {
|
||||
this.hide();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Minimize the composer. Has no effect if the composer is hidden.
|
||||
*
|
||||
* @public
|
||||
* @private
|
||||
*/
|
||||
minimize() {
|
||||
if (this.position === Composer.PositionEnum.HIDDEN) return;
|
||||
|
||||
this.animateToPosition(Composer.PositionEnum.MINIMIZED);
|
||||
this.animateHeightChange();
|
||||
|
||||
this.$().css('top', 'auto');
|
||||
this.hideBackdrop();
|
||||
}
|
||||
|
||||
/**
|
||||
* Take the composer into fullscreen mode. Has no effect if the composer is
|
||||
* hidden.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
fullScreen() {
|
||||
if (this.position !== Composer.PositionEnum.HIDDEN) {
|
||||
this.position = Composer.PositionEnum.FULLSCREEN;
|
||||
m.redraw();
|
||||
this.updateHeight();
|
||||
this.component.focus();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Exit fullscreen mode.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
exitFullScreen() {
|
||||
if (this.position === Composer.PositionEnum.FULLSCREEN) {
|
||||
this.position = Composer.PositionEnum.NORMAL;
|
||||
m.redraw();
|
||||
this.updateHeight();
|
||||
this.component.focus();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Build an item list for the composer's controls.
|
||||
*
|
||||
@@ -416,23 +307,23 @@ class Composer extends Component {
|
||||
controlItems() {
|
||||
const items = new ItemList();
|
||||
|
||||
if (this.position === Composer.PositionEnum.FULLSCREEN) {
|
||||
if (this.state.position === ComposerState.Position.FULLSCREEN) {
|
||||
items.add(
|
||||
'exitFullScreen',
|
||||
ComposerButton.component({
|
||||
icon: 'fas fa-compress',
|
||||
title: app.translator.trans('core.forum.composer.exit_full_screen_tooltip'),
|
||||
onclick: this.exitFullScreen.bind(this),
|
||||
onclick: this.state.exitFullScreen.bind(this.state),
|
||||
})
|
||||
);
|
||||
} else {
|
||||
if (this.position !== Composer.PositionEnum.MINIMIZED) {
|
||||
if (this.state.position !== ComposerState.Position.MINIMIZED) {
|
||||
items.add(
|
||||
'minimize',
|
||||
ComposerButton.component({
|
||||
icon: 'fas fa-minus minimize',
|
||||
title: app.translator.trans('core.forum.composer.minimize_tooltip'),
|
||||
onclick: this.minimize.bind(this),
|
||||
onclick: this.state.minimize.bind(this.state),
|
||||
itemClassName: 'App-backControl',
|
||||
})
|
||||
);
|
||||
@@ -442,7 +333,7 @@ class Composer extends Component {
|
||||
ComposerButton.component({
|
||||
icon: 'fas fa-expand',
|
||||
title: app.translator.trans('core.forum.composer.full_screen_tooltip'),
|
||||
onclick: this.fullScreen.bind(this),
|
||||
onclick: this.state.fullScreen.bind(this.state),
|
||||
})
|
||||
);
|
||||
}
|
||||
@@ -452,7 +343,7 @@ class Composer extends Component {
|
||||
ComposerButton.component({
|
||||
icon: 'fas fa-times',
|
||||
title: app.translator.trans('core.forum.composer.close_tooltip'),
|
||||
onclick: this.close.bind(this),
|
||||
onclick: this.state.close.bind(this.state),
|
||||
})
|
||||
);
|
||||
}
|
||||
@@ -464,10 +355,10 @@ class Composer extends Component {
|
||||
* Initialize default Composer height.
|
||||
*/
|
||||
initializeHeight() {
|
||||
this.height = localStorage.getItem('composerHeight');
|
||||
this.state.height = localStorage.getItem('composerHeight');
|
||||
|
||||
if (!this.height) {
|
||||
this.height = this.defaultHeight();
|
||||
if (!this.state.height) {
|
||||
this.state.height = this.defaultHeight();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -479,60 +370,14 @@ class Composer extends Component {
|
||||
return this.$().height();
|
||||
}
|
||||
|
||||
/**
|
||||
* Minimum height of the Composer.
|
||||
* @returns {Integer}
|
||||
*/
|
||||
minimumHeight() {
|
||||
return 200;
|
||||
}
|
||||
|
||||
/**
|
||||
* Maxmimum height of the Composer.
|
||||
* @returns {Integer}
|
||||
*/
|
||||
maximumHeight() {
|
||||
return $(window).height() - $('#header').outerHeight();
|
||||
}
|
||||
|
||||
/**
|
||||
* Computed the composer's current height, based on the intended height, and
|
||||
* the composer's current state. This will be applied to the composer's
|
||||
* content's DOM element.
|
||||
* @returns {Integer|String}
|
||||
*/
|
||||
computedHeight() {
|
||||
// If the composer is minimized, then we don't want to set a height; we'll
|
||||
// let the CSS decide how high it is. If it's fullscreen, then we need to
|
||||
// make it as high as the window.
|
||||
if (this.position === Composer.PositionEnum.MINIMIZED) {
|
||||
return '';
|
||||
} else if (this.position === Composer.PositionEnum.FULLSCREEN) {
|
||||
return $(window).height();
|
||||
}
|
||||
|
||||
// Otherwise, if it's normal or hidden, then we use the intended height.
|
||||
// We don't let the composer get too small or too big, though.
|
||||
return Math.max(this.minimumHeight(), Math.min(this.height, this.maximumHeight()));
|
||||
}
|
||||
|
||||
/**
|
||||
* Save a new Composer height and update the DOM.
|
||||
* @param {Integer} height
|
||||
*/
|
||||
changeHeight(height) {
|
||||
this.height = height;
|
||||
this.state.height = height;
|
||||
this.updateHeight();
|
||||
|
||||
localStorage.setItem('composerHeight', this.height);
|
||||
localStorage.setItem('composerHeight', this.state.height);
|
||||
}
|
||||
}
|
||||
|
||||
Composer.PositionEnum = {
|
||||
HIDDEN: 'hidden',
|
||||
NORMAL: 'normal',
|
||||
MINIMIZED: 'minimized',
|
||||
FULLSCREEN: 'fullScreen',
|
||||
};
|
||||
|
||||
export default Composer;
|
||||
|
@@ -1,5 +1,6 @@
|
||||
import Component from '../../common/Component';
|
||||
import LoadingIndicator from '../../common/components/LoadingIndicator';
|
||||
import ConfirmDocumentUnload from '../../common/components/ConfirmDocumentUnload';
|
||||
import TextEditor from './TextEditor';
|
||||
import avatar from '../../common/helpers/avatar';
|
||||
import listItems from '../../common/helpers/listItems';
|
||||
@@ -10,8 +11,9 @@ import ItemList from '../../common/utils/ItemList';
|
||||
* composer. Subclasses should implement the `onsubmit` method and override
|
||||
* `headerTimes`.
|
||||
*
|
||||
* ### Props
|
||||
* ### Attrs
|
||||
*
|
||||
* - `composer`
|
||||
* - `originalContent`
|
||||
* - `submitLabel`
|
||||
* - `placeholder`
|
||||
@@ -22,7 +24,11 @@ import ItemList from '../../common/utils/ItemList';
|
||||
* @abstract
|
||||
*/
|
||||
export default class ComposerBody extends Component {
|
||||
init() {
|
||||
oninit(vnode) {
|
||||
super.oninit(vnode);
|
||||
|
||||
this.composer = this.attrs.composer;
|
||||
|
||||
/**
|
||||
* Whether or not the component is loading.
|
||||
*
|
||||
@@ -30,60 +36,57 @@ export default class ComposerBody extends Component {
|
||||
*/
|
||||
this.loading = false;
|
||||
|
||||
/**
|
||||
* The content of the text editor.
|
||||
*
|
||||
* @type {Function}
|
||||
*/
|
||||
this.content = m.prop(this.props.originalContent);
|
||||
// Let the composer state know to ask for confirmation under certain
|
||||
// circumstances, if the body supports / requires it and has a corresponding
|
||||
// confirmation question to ask.
|
||||
if (this.attrs.confirmExit) {
|
||||
this.composer.preventClosingWhen(() => this.hasChanges(), this.attrs.confirmExit);
|
||||
}
|
||||
|
||||
this.composer.fields.content(this.attrs.originalContent || '');
|
||||
|
||||
/**
|
||||
* The text editor component instance.
|
||||
*
|
||||
* @type {TextEditor}
|
||||
* @deprecated BC layer, remove in Beta 15.
|
||||
*/
|
||||
this.editor = new TextEditor({
|
||||
submitLabel: this.props.submitLabel,
|
||||
placeholder: this.props.placeholder,
|
||||
onchange: this.content,
|
||||
onsubmit: this.onsubmit.bind(this),
|
||||
value: this.content(),
|
||||
});
|
||||
this.content = this.composer.fields.content;
|
||||
this.editor = this.composer;
|
||||
}
|
||||
|
||||
view() {
|
||||
// If the component is loading, we should disable the text editor.
|
||||
this.editor.props.disabled = this.loading;
|
||||
|
||||
return (
|
||||
<div className={'ComposerBody ' + (this.props.className || '')}>
|
||||
{avatar(this.props.user, { className: 'ComposerBody-avatar' })}
|
||||
<div className="ComposerBody-content">
|
||||
<ul className="ComposerBody-header">{listItems(this.headerItems().toArray())}</ul>
|
||||
<div className="ComposerBody-editor">{this.editor.render()}</div>
|
||||
<ConfirmDocumentUnload when={this.hasChanges.bind(this)}>
|
||||
<div className={'ComposerBody ' + (this.attrs.className || '')}>
|
||||
{avatar(this.attrs.user, { className: 'ComposerBody-avatar' })}
|
||||
<div className="ComposerBody-content">
|
||||
<ul className="ComposerBody-header">{listItems(this.headerItems().toArray())}</ul>
|
||||
<div className="ComposerBody-editor">
|
||||
{TextEditor.component({
|
||||
submitLabel: this.attrs.submitLabel,
|
||||
placeholder: this.attrs.placeholder,
|
||||
disabled: this.loading || this.attrs.disabled,
|
||||
composer: this.composer,
|
||||
preview: this.jumpToPreview && this.jumpToPreview.bind(this),
|
||||
onchange: this.composer.fields.content,
|
||||
onsubmit: this.onsubmit.bind(this),
|
||||
value: this.composer.fields.content(),
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
{LoadingIndicator.component({ className: 'ComposerBody-loading' + (this.loading ? ' active' : '') })}
|
||||
</div>
|
||||
{LoadingIndicator.component({ className: 'ComposerBody-loading' + (this.loading ? ' active' : '') })}
|
||||
</div>
|
||||
</ConfirmDocumentUnload>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Draw focus to the text editor.
|
||||
*/
|
||||
focus() {
|
||||
this.$(':input:enabled:visible:first').focus();
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if there is any unsaved data – if there is, return a confirmation
|
||||
* message to prompt the user with.
|
||||
* Check if there is any unsaved data.
|
||||
*
|
||||
* @return {String}
|
||||
*/
|
||||
preventExit() {
|
||||
const content = this.content();
|
||||
hasChanges() {
|
||||
const content = this.composer.fields.content();
|
||||
|
||||
return content && content !== this.props.originalContent && this.props.confirmExit;
|
||||
return content && content !== this.attrs.originalContent;
|
||||
}
|
||||
|
||||
/**
|
||||
|
@@ -5,9 +5,9 @@ import Button from '../../common/components/Button';
|
||||
* controls.
|
||||
*/
|
||||
export default class ComposerButton extends Button {
|
||||
static initProps(props) {
|
||||
super.initProps(props);
|
||||
static initAttrs(attrs) {
|
||||
super.initAttrs(attrs);
|
||||
|
||||
props.className = props.className || 'Button Button--icon Button--link';
|
||||
attrs.className = attrs.className || 'Button Button--icon Button--link';
|
||||
}
|
||||
}
|
||||
|
54
js/src/forum/components/ComposerPostPreview.js
Normal file
54
js/src/forum/components/ComposerPostPreview.js
Normal file
@@ -0,0 +1,54 @@
|
||||
/*global s9e*/
|
||||
|
||||
import Component from '../../common/Component';
|
||||
|
||||
/**
|
||||
* The `ComposerPostPreview` component renders Markdown as HTML using the
|
||||
* TextFormatter library, polling a data source for changes every 50ms. This is
|
||||
* done to prevent expensive redraws on e.g. every single keystroke, while
|
||||
* still retaining the perception of live updates for the user.
|
||||
*
|
||||
* ### Attrs
|
||||
*
|
||||
* - `composer` The state of the composer controlling this preview.
|
||||
* - `className` A CSS class for the element surrounding the preview.
|
||||
* - `surround` A callback that can execute code before and after re-render, e.g. for scroll anchoring.
|
||||
*/
|
||||
export default class ComposerPostPreview extends Component {
|
||||
static initAttrs(attrs) {
|
||||
attrs.className = attrs.className || '';
|
||||
attrs.surround = attrs.surround || ((preview) => preview());
|
||||
}
|
||||
|
||||
view() {
|
||||
return <div className={this.attrs.className} />;
|
||||
}
|
||||
|
||||
oncreate(vnode) {
|
||||
super.oncreate(vnode);
|
||||
|
||||
// Every 50ms, if the composer content has changed, then update the post's
|
||||
// body with a preview.
|
||||
let preview;
|
||||
const updatePreview = () => {
|
||||
// Since we're polling, the composer may have been closed in the meantime,
|
||||
// so we bail in that case.
|
||||
if (!this.attrs.composer.isVisible()) return;
|
||||
|
||||
const content = this.attrs.composer.fields.content();
|
||||
|
||||
if (preview === content) return;
|
||||
|
||||
preview = content;
|
||||
|
||||
this.attrs.surround(() => s9e.TextFormatter.preview(preview || '', vnode.dom));
|
||||
};
|
||||
updatePreview();
|
||||
|
||||
this.updateInterval = setInterval(updatePreview, 50);
|
||||
}
|
||||
|
||||
onremove() {
|
||||
clearInterval(this.updateInterval);
|
||||
}
|
||||
}
|
@@ -7,31 +7,33 @@ import extractText from '../../common/utils/extractText';
|
||||
* enter the title of their discussion. It also overrides the `submit` and
|
||||
* `willExit` actions to account for the title.
|
||||
*
|
||||
* ### Props
|
||||
* ### Attrs
|
||||
*
|
||||
* - All of the props for ComposerBody
|
||||
* - All of the attrs for ComposerBody
|
||||
* - `titlePlaceholder`
|
||||
*/
|
||||
export default class DiscussionComposer extends ComposerBody {
|
||||
init() {
|
||||
super.init();
|
||||
static initAttrs(attrs) {
|
||||
super.initAttrs(attrs);
|
||||
|
||||
attrs.placeholder = attrs.placeholder || extractText(app.translator.trans('core.forum.composer_discussion.body_placeholder'));
|
||||
attrs.submitLabel = attrs.submitLabel || app.translator.trans('core.forum.composer_discussion.submit_button');
|
||||
attrs.confirmExit = attrs.confirmExit || extractText(app.translator.trans('core.forum.composer_discussion.discard_confirmation'));
|
||||
attrs.titlePlaceholder = attrs.titlePlaceholder || extractText(app.translator.trans('core.forum.composer_discussion.title_placeholder'));
|
||||
attrs.className = 'ComposerBody--discussion';
|
||||
}
|
||||
|
||||
oninit(vnode) {
|
||||
super.oninit(vnode);
|
||||
|
||||
this.composer.fields.title = this.composer.fields.title || m.stream('');
|
||||
|
||||
/**
|
||||
* The value of the title input.
|
||||
*
|
||||
* @type {Function}
|
||||
*/
|
||||
this.title = m.prop('');
|
||||
}
|
||||
|
||||
static initProps(props) {
|
||||
super.initProps(props);
|
||||
|
||||
props.placeholder = props.placeholder || extractText(app.translator.trans('core.forum.composer_discussion.body_placeholder'));
|
||||
props.submitLabel = props.submitLabel || app.translator.trans('core.forum.composer_discussion.submit_button');
|
||||
props.confirmExit = props.confirmExit || extractText(app.translator.trans('core.forum.composer_discussion.discard_confirmation'));
|
||||
props.titlePlaceholder = props.titlePlaceholder || extractText(app.translator.trans('core.forum.composer_discussion.title_placeholder'));
|
||||
props.className = 'ComposerBody--discussion';
|
||||
this.title = this.composer.fields.title;
|
||||
}
|
||||
|
||||
headerItems() {
|
||||
@@ -44,10 +46,9 @@ export default class DiscussionComposer extends ComposerBody {
|
||||
<h3>
|
||||
<input
|
||||
className="FormControl"
|
||||
value={this.title()}
|
||||
oninput={m.withAttr('value', this.title)}
|
||||
placeholder={this.props.titlePlaceholder}
|
||||
disabled={!!this.props.disabled}
|
||||
bidi={this.title}
|
||||
placeholder={this.attrs.titlePlaceholder}
|
||||
disabled={!!this.attrs.disabled}
|
||||
onkeydown={this.onkeydown.bind(this)}
|
||||
/>
|
||||
</h3>
|
||||
@@ -66,14 +67,14 @@ export default class DiscussionComposer extends ComposerBody {
|
||||
if (e.which === 13) {
|
||||
// Return
|
||||
e.preventDefault();
|
||||
this.editor.setSelectionRange(0, 0);
|
||||
this.composer.editor.moveCursorTo(0);
|
||||
}
|
||||
|
||||
m.redraw.strategy('none');
|
||||
e.redraw = false;
|
||||
}
|
||||
|
||||
preventExit() {
|
||||
return (this.title() || this.content()) && this.props.confirmExit;
|
||||
hasChanges() {
|
||||
return this.title() || this.composer.fields.content();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -84,7 +85,7 @@ export default class DiscussionComposer extends ComposerBody {
|
||||
data() {
|
||||
return {
|
||||
title: this.title(),
|
||||
content: this.content(),
|
||||
content: this.composer.fields.content(),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -97,9 +98,9 @@ export default class DiscussionComposer extends ComposerBody {
|
||||
.createRecord('discussions')
|
||||
.save(data)
|
||||
.then((discussion) => {
|
||||
app.composer.hide();
|
||||
this.composer.hide();
|
||||
app.discussions.refresh();
|
||||
m.route(app.route.discussion(discussion));
|
||||
m.route.set(app.route.discussion(discussion));
|
||||
}, this.loaded.bind(this));
|
||||
}
|
||||
}
|
||||
|
@@ -5,7 +5,7 @@ import listItems from '../../common/helpers/listItems';
|
||||
/**
|
||||
* The `DiscussionHero` component displays the hero on a discussion page.
|
||||
*
|
||||
* ### Props
|
||||
* ### attrs
|
||||
*
|
||||
* - `discussion`
|
||||
*/
|
||||
@@ -27,7 +27,7 @@ export default class DiscussionHero extends Component {
|
||||
*/
|
||||
items() {
|
||||
const items = new ItemList();
|
||||
const discussion = this.props.discussion;
|
||||
const discussion = this.attrs.discussion;
|
||||
const badges = discussion.badges().toArray();
|
||||
|
||||
if (badges.length) {
|
||||
|
@@ -7,19 +7,13 @@ import Placeholder from '../../common/components/Placeholder';
|
||||
/**
|
||||
* The `DiscussionList` component displays a list of discussions.
|
||||
*
|
||||
* ### Props
|
||||
* ### Attrs
|
||||
*
|
||||
* - `params` A map of parameters used to construct a refined parameter object
|
||||
* to send along in the API request to get discussion results.
|
||||
* - `state` A DiscussionListState object that represents the discussion lists's state.
|
||||
*/
|
||||
export default class DiscussionList extends Component {
|
||||
init() {
|
||||
this.state = this.props.state;
|
||||
}
|
||||
|
||||
view() {
|
||||
const state = this.state;
|
||||
const state = this.attrs.state;
|
||||
|
||||
const params = state.getParams();
|
||||
let loading;
|
||||
@@ -27,11 +21,13 @@ export default class DiscussionList extends Component {
|
||||
if (state.isLoading()) {
|
||||
loading = LoadingIndicator.component();
|
||||
} else if (state.moreResults) {
|
||||
loading = Button.component({
|
||||
children: app.translator.trans('core.forum.discussion_list.load_more_button'),
|
||||
className: 'Button',
|
||||
onclick: state.loadMore.bind(state),
|
||||
});
|
||||
loading = Button.component(
|
||||
{
|
||||
className: 'Button',
|
||||
onclick: state.loadMore.bind(state),
|
||||
},
|
||||
app.translator.trans('core.forum.discussion_list.load_more_button')
|
||||
);
|
||||
}
|
||||
|
||||
if (state.empty()) {
|
||||
|
@@ -8,24 +8,26 @@ import ItemList from '../../common/utils/ItemList';
|
||||
import abbreviateNumber from '../../common/utils/abbreviateNumber';
|
||||
import Dropdown from '../../common/components/Dropdown';
|
||||
import TerminalPost from './TerminalPost';
|
||||
import PostPreview from './PostPreview';
|
||||
import SubtreeRetainer from '../../common/utils/SubtreeRetainer';
|
||||
import DiscussionControls from '../utils/DiscussionControls';
|
||||
import slidable from '../utils/slidable';
|
||||
import extractText from '../../common/utils/extractText';
|
||||
import classList from '../../common/utils/classList';
|
||||
|
||||
import { escapeRegExp } from 'lodash-es';
|
||||
/**
|
||||
* The `DiscussionListItem` component shows a single discussion in the
|
||||
* discussion list.
|
||||
*
|
||||
* ### Props
|
||||
* ### Attrs
|
||||
*
|
||||
* - `discussion`
|
||||
* - `params`
|
||||
*/
|
||||
export default class DiscussionListItem extends Component {
|
||||
init() {
|
||||
oninit(vnode) {
|
||||
super.oninit(vnode);
|
||||
|
||||
/**
|
||||
* Set up a subtree retainer so that the discussion will not be redrawn
|
||||
* unless new data comes in.
|
||||
@@ -33,7 +35,7 @@ export default class DiscussionListItem extends Component {
|
||||
* @type {SubtreeRetainer}
|
||||
*/
|
||||
this.subtree = new SubtreeRetainer(
|
||||
() => this.props.discussion.freshness,
|
||||
() => this.attrs.discussion.freshness,
|
||||
() => {
|
||||
const time = app.session.user && app.session.user.markedAllAsReadAt();
|
||||
return time && time.getTime();
|
||||
@@ -42,37 +44,33 @@ export default class DiscussionListItem extends Component {
|
||||
);
|
||||
}
|
||||
|
||||
attrs() {
|
||||
elementAttrs() {
|
||||
return {
|
||||
className: classList([
|
||||
'DiscussionListItem',
|
||||
this.active() ? 'active' : '',
|
||||
this.props.discussion.isHidden() ? 'DiscussionListItem--hidden' : '',
|
||||
this.attrs.discussion.isHidden() ? 'DiscussionListItem--hidden' : '',
|
||||
]),
|
||||
};
|
||||
}
|
||||
|
||||
view() {
|
||||
const retain = this.subtree.retain();
|
||||
|
||||
if (retain) return retain;
|
||||
|
||||
const discussion = this.props.discussion;
|
||||
const discussion = this.attrs.discussion;
|
||||
const user = discussion.user();
|
||||
const isUnread = discussion.isUnread();
|
||||
const isRead = discussion.isRead();
|
||||
const showUnread = !this.showRepliesCount() && isUnread;
|
||||
let jumpTo = 0;
|
||||
const controls = DiscussionControls.controls(discussion, this).toArray();
|
||||
const attrs = this.attrs();
|
||||
const attrs = this.elementAttrs();
|
||||
|
||||
if (this.props.params.q) {
|
||||
if (this.attrs.params.q) {
|
||||
const post = discussion.mostRelevantPost();
|
||||
if (post) {
|
||||
jumpTo = post.number();
|
||||
}
|
||||
|
||||
const phrase = this.props.params.q;
|
||||
const phrase = escapeRegExp(this.attrs.params.q);
|
||||
this.highlightRegExp = new RegExp(phrase + '|' + phrase.trim().replace(/\s+/g, '|'), 'gi');
|
||||
} else {
|
||||
jumpTo = Math.min(discussion.lastPostNumber(), (discussion.lastReadPostNumber() || 0) + 1);
|
||||
@@ -81,12 +79,14 @@ export default class DiscussionListItem extends Component {
|
||||
return (
|
||||
<div {...attrs}>
|
||||
{controls.length
|
||||
? Dropdown.component({
|
||||
icon: 'fas fa-ellipsis-v',
|
||||
children: controls,
|
||||
className: 'DiscussionListItem-controls',
|
||||
buttonClassName: 'Button Button--icon Button--flat Slidable-underneath Slidable-underneath--right',
|
||||
})
|
||||
? Dropdown.component(
|
||||
{
|
||||
icon: 'fas fa-ellipsis-v',
|
||||
className: 'DiscussionListItem-controls',
|
||||
buttonClassName: 'Button Button--icon Button--flat Slidable-underneath Slidable-underneath--right',
|
||||
},
|
||||
controls
|
||||
)
|
||||
: ''}
|
||||
|
||||
<a
|
||||
@@ -98,14 +98,13 @@ export default class DiscussionListItem extends Component {
|
||||
|
||||
<div className={'DiscussionListItem-content Slidable-content' + (isUnread ? ' unread' : '') + (isRead ? ' read' : '')}>
|
||||
<a
|
||||
href={user ? app.route.user(user) : '#'}
|
||||
route={user ? app.route.user(user) : '#'}
|
||||
className="DiscussionListItem-author"
|
||||
title={extractText(
|
||||
app.translator.trans('core.forum.discussion_list.started_text', { user: user, ago: humanTime(discussion.createdAt()) })
|
||||
)}
|
||||
config={function (element) {
|
||||
$(element).tooltip({ placement: 'right' });
|
||||
m.route.apply(this, arguments);
|
||||
oncreate={function (vnode) {
|
||||
$(vnode.dom).tooltip({ placement: 'right' });
|
||||
}}
|
||||
>
|
||||
{avatar(user, { title: '' })}
|
||||
@@ -113,7 +112,7 @@ export default class DiscussionListItem extends Component {
|
||||
|
||||
<ul className="DiscussionListItem-badges badges">{listItems(discussion.badges().toArray())}</ul>
|
||||
|
||||
<a href={app.route.discussion(discussion, jumpTo)} config={m.route} className="DiscussionListItem-main">
|
||||
<a route={app.route.discussion(discussion, jumpTo)} className="DiscussionListItem-main">
|
||||
<h3 className="DiscussionListItem-title">{highlight(discussion.title(), this.highlightRegExp)}</h3>
|
||||
<ul className="DiscussionListItem-info">{listItems(this.infoItems().toArray())}</ul>
|
||||
</a>
|
||||
@@ -130,8 +129,8 @@ export default class DiscussionListItem extends Component {
|
||||
);
|
||||
}
|
||||
|
||||
config(isInitialized) {
|
||||
if (isInitialized) return;
|
||||
oncreate(vnode) {
|
||||
super.oncreate(vnode);
|
||||
|
||||
// If we're on a touch device, set up the discussion row to be slidable.
|
||||
// This allows the user to drag the row to either side of the screen to
|
||||
@@ -143,6 +142,12 @@ export default class DiscussionListItem extends Component {
|
||||
}
|
||||
}
|
||||
|
||||
onbeforeupdate(vnode, old) {
|
||||
super.onbeforeupdate(vnode, old);
|
||||
|
||||
return this.subtree.needsRebuild();
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine whether or not the discussion is currently being viewed.
|
||||
*
|
||||
@@ -151,7 +156,7 @@ export default class DiscussionListItem extends Component {
|
||||
active() {
|
||||
const idParam = m.route.param('id');
|
||||
|
||||
return idParam && idParam.split('-')[0] === this.props.discussion.id();
|
||||
return idParam && idParam.split('-')[0] === this.attrs.discussion.id();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -162,7 +167,7 @@ export default class DiscussionListItem extends Component {
|
||||
* @return {Boolean}
|
||||
*/
|
||||
showFirstPost() {
|
||||
return ['newest', 'oldest'].indexOf(this.props.params.sort) !== -1;
|
||||
return ['newest', 'oldest'].indexOf(this.attrs.params.sort) !== -1;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -172,14 +177,14 @@ export default class DiscussionListItem extends Component {
|
||||
* @return {Boolean}
|
||||
*/
|
||||
showRepliesCount() {
|
||||
return this.props.params.sort === 'replies';
|
||||
return this.attrs.params.sort === 'replies';
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark the discussion as read.
|
||||
*/
|
||||
markAsRead() {
|
||||
const discussion = this.props.discussion;
|
||||
const discussion = this.attrs.discussion;
|
||||
|
||||
if (discussion.isUnread()) {
|
||||
discussion.save({ lastReadPostNumber: discussion.lastPostNumber() });
|
||||
@@ -196,8 +201,8 @@ export default class DiscussionListItem extends Component {
|
||||
infoItems() {
|
||||
const items = new ItemList();
|
||||
|
||||
if (this.props.params.q) {
|
||||
const post = this.props.discussion.mostRelevantPost() || this.props.discussion.firstPost();
|
||||
if (this.attrs.params.q) {
|
||||
const post = this.attrs.discussion.mostRelevantPost() || this.attrs.discussion.firstPost();
|
||||
|
||||
if (post && post.contentType() === 'comment') {
|
||||
const excerpt = highlight(post.contentPlain(), this.highlightRegExp, 175);
|
||||
@@ -207,7 +212,7 @@ export default class DiscussionListItem extends Component {
|
||||
items.add(
|
||||
'terminalPost',
|
||||
TerminalPost.component({
|
||||
discussion: this.props.discussion,
|
||||
discussion: this.attrs.discussion,
|
||||
lastPost: !this.showFirstPost(),
|
||||
})
|
||||
);
|
||||
|
67
js/src/forum/components/DiscussionListPane.js
Normal file
67
js/src/forum/components/DiscussionListPane.js
Normal file
@@ -0,0 +1,67 @@
|
||||
import DiscussionList from './DiscussionList';
|
||||
import Component from '../../common/Component';
|
||||
|
||||
const hotEdge = (e) => {
|
||||
if (e.pageX < 10) app.pane.show();
|
||||
};
|
||||
|
||||
/**
|
||||
* The `DiscussionListPane` component displays the list of previously viewed
|
||||
* discussions in a panel that can be displayed by moving the mouse to the left
|
||||
* edge of the screen, where it can also be pinned in place.
|
||||
*
|
||||
* ### Attrs
|
||||
*
|
||||
* - `state` A DiscussionListState object that represents the discussion lists's state.
|
||||
*/
|
||||
export default class DiscussionListPane extends Component {
|
||||
view() {
|
||||
if (!this.attrs.state.hasDiscussions()) {
|
||||
return;
|
||||
}
|
||||
|
||||
return <div className="DiscussionPage-list">{this.enoughSpace() && <DiscussionList state={this.attrs.state} />}</div>;
|
||||
}
|
||||
|
||||
oncreate(vnode) {
|
||||
super.oncreate(vnode);
|
||||
|
||||
const $list = $(vnode.dom);
|
||||
|
||||
// When the mouse enters and leaves the discussions pane, we want to show
|
||||
// and hide the pane respectively. We also create a 10px 'hot edge' on the
|
||||
// left of the screen to activate the pane.
|
||||
const pane = app.pane;
|
||||
$list.hover(pane.show.bind(pane), pane.onmouseleave.bind(pane));
|
||||
|
||||
$(document).on('mousemove', hotEdge);
|
||||
|
||||
// If the discussion we are viewing is listed in the discussion list, then
|
||||
// we will make sure it is visible in the viewport – if it is not we will
|
||||
// scroll the list down to it.
|
||||
const $discussion = $list.find('.DiscussionListItem.active');
|
||||
if ($discussion.length) {
|
||||
const listTop = $list.offset().top;
|
||||
const listBottom = listTop + $list.outerHeight();
|
||||
const discussionTop = $discussion.offset().top;
|
||||
const discussionBottom = discussionTop + $discussion.outerHeight();
|
||||
|
||||
if (discussionTop < listTop || discussionBottom > listBottom) {
|
||||
$list.scrollTop($list.scrollTop() - listTop + discussionTop);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
onremove() {
|
||||
$(document).off('mousemove', hotEdge);
|
||||
}
|
||||
|
||||
/**
|
||||
* Are we on a device that's larger than we consider "mobile"?
|
||||
*
|
||||
* @returns {boolean}
|
||||
*/
|
||||
enoughSpace() {
|
||||
return !$('.App-navigation').is(':visible');
|
||||
}
|
||||
}
|
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user