mirror of
https://github.com/flarum/core.git
synced 2025-08-16 05:14:20 +02:00
Compare commits
638 Commits
v0.1.0-bet
...
ds/fronten
Author | SHA1 | Date | |
---|---|---|---|
|
4c8af1cdf8 | ||
|
4a5a5a9ef0 | ||
|
1d83b740d2 | ||
|
b180cd9daf | ||
|
0302a2c5a7 | ||
|
bd2850d5ea | ||
|
c5f1e30855 | ||
|
b00eae1ce9 | ||
|
717e8aa27e | ||
|
8b4046fdd4 | ||
|
8437e44112 | ||
|
df08d174ff | ||
|
ef9612f55e | ||
|
14ea7101fc | ||
|
5e6251204b | ||
|
c1c2a15b96 | ||
|
6823cdc3b8 | ||
|
8980189917 | ||
|
ea880632e1 | ||
|
e9ad848530 | ||
|
9a3aec6079 | ||
|
77eefc85c6 | ||
|
8e9f006c3a | ||
|
b7af59fe43 | ||
|
b952f6a2bc | ||
|
2bf190ffab | ||
|
5a8eb2f978 | ||
|
7756c78805 | ||
|
a344319dde | ||
|
c6062019c5 | ||
|
532e866760 | ||
|
28f2341430 | ||
|
bfc6ed2e99 | ||
|
968ece2c61 | ||
|
d83d082c4a | ||
|
8b6f9ddbfe | ||
|
990cdbc571 | ||
|
a7937edac7 | ||
|
91522f84c5 | ||
|
2c2f6fd4ed | ||
|
81b2f4a74e | ||
|
11e373f299 | ||
|
c1a4f19399 | ||
|
d644b490cd | ||
|
5e853aa52d | ||
|
84d977f819 | ||
|
58164b680a | ||
|
860e91f317 | ||
|
be844519f2 | ||
|
458045ad33 | ||
|
99b5b5ff00 | ||
|
8a07bb68f6 | ||
|
dbc3aac14e | ||
|
37cec1487e | ||
|
39dc303b80 | ||
|
88aa9fc038 | ||
|
d73f1d8a67 | ||
|
82ef5f975c | ||
|
717442741f | ||
|
0dc846bc4a | ||
|
83d0345e93 | ||
|
8f7435f3fc | ||
|
f9cda85937 | ||
|
cfc0000df0 | ||
|
c819a8d520 | ||
|
2a6360216e | ||
|
c95f7b89bf | ||
|
afda17bc5f | ||
|
3027916d97 | ||
|
babbda044b | ||
|
66b839d241 | ||
|
5bc6e52190 | ||
|
58e096a8cc | ||
|
9b83159be5 | ||
|
e86940b6a3 | ||
|
c615fb96c9 | ||
|
0356ecf379 | ||
|
ef47e09300 | ||
|
4484f3e35f | ||
|
cc6619466e | ||
|
0a5493c631 | ||
|
93e565ccee | ||
|
c4cb731f1b | ||
|
58ccb8415a | ||
|
d29b5c7262 | ||
|
2ca078618b | ||
|
22a031a3f1 | ||
|
da31fc2619 | ||
|
c3237d4845 | ||
|
f0140c6656 | ||
|
35b91c98da | ||
|
d6b07153ec | ||
|
6a67167eed | ||
|
dcb3cc1701 | ||
|
b9583943c5 | ||
|
31cfe0f9df | ||
|
dfcc099040 | ||
|
c8e97f295d | ||
|
4910205dc7 | ||
|
8ea7f9bc17 | ||
|
3bf7f6f85b | ||
|
68c17f2c30 | ||
|
c03e0f7f75 | ||
|
bcaa6f4d8a | ||
|
fa47228b3f | ||
|
241b8cc99c | ||
|
eeae395a15 | ||
|
f24aafd47b | ||
|
2a66dc5572 | ||
|
80d8707d15 | ||
|
21d19df9bd | ||
|
7485559cbf | ||
|
4368dfcc6c | ||
|
8ba86f9c5e | ||
|
4f79a05a4b | ||
|
eae6a11719 | ||
|
f75c2cfc9c | ||
|
f22f4c02e6 | ||
|
3410bf0935 | ||
|
6656820f24 | ||
|
66745916b3 | ||
|
220a36c2e4 | ||
|
dfedd585f5 | ||
|
dd13ff4169 | ||
|
4e96900dee | ||
|
d404b11fcd | ||
|
fb50540be4 | ||
|
b2cbbd5862 | ||
|
d6a4058c28 | ||
|
be6a41ad0e | ||
|
557bb086f9 | ||
|
1f1986c527 | ||
|
6978c0aa77 | ||
|
660cd1c81e | ||
|
87792f5911 | ||
|
056e6c0fea | ||
|
0de0c83353 | ||
|
49d2539aef | ||
|
b47ba94a9b | ||
|
39c8ef4ddb | ||
|
654a0b5da1 | ||
|
2fd3aa8c71 | ||
|
48dccda707 | ||
|
b885346029 | ||
|
c037598537 | ||
|
6401e45b56 | ||
|
c6bcb79541 | ||
|
46eab64f41 | ||
|
9a5063c083 | ||
|
3c84f41070 | ||
|
798a3486bf | ||
|
89ef14faf1 | ||
|
84cf938379 | ||
|
899cdfda4e | ||
|
72ed4faa83 | ||
|
64ad21e5da | ||
|
14e8e9a7cb | ||
|
ee996e2cae | ||
|
7b35674e4a | ||
|
1d953b3514 | ||
|
b7d8f77529 | ||
|
b343206c7b | ||
|
2aead54aea | ||
|
dbfae0b55e | ||
|
2d86eb9b9f | ||
|
3ac5e58fa1 | ||
|
ffa56595c3 | ||
|
453c44632d | ||
|
117c2f65ac | ||
|
cd9edf656b | ||
|
8c19ba1aaa | ||
|
3f5554816e | ||
|
cb9801a324 | ||
|
fd4c0d30d8 | ||
|
922e294668 | ||
|
1fa37a7a6a | ||
|
1cbb2a365e | ||
|
4c50c8d77a | ||
|
0d57820b50 | ||
|
ecdd7a2b49 | ||
|
30942bdf38 | ||
|
345ad4bc6d | ||
|
03a4997a1c | ||
|
857fd95b5e | ||
|
dd43e49d0a | ||
|
4efdd2a4f2 | ||
|
b286e39429 | ||
|
1cda9dca4f | ||
|
e16d57d4e2 | ||
|
2e2aa8747e | ||
|
44ac2ec8ee | ||
|
6bbd603a41 | ||
|
a4910f3d94 | ||
|
f003f6e04a | ||
|
2fe3987c19 | ||
|
f4ab6f4b1f | ||
|
9ae8bcdffe | ||
|
29bdd471bc | ||
|
fb70826469 | ||
|
bbe7e97ba1 | ||
|
310065fb1c | ||
|
23da7b3373 | ||
|
2ba29a9088 | ||
|
cd8a8e9dd7 | ||
|
6b3d634917 | ||
|
8c6fac62d6 | ||
|
02e72f4b03 | ||
|
e3f1e69748 | ||
|
bc7cea6e61 | ||
|
30c6ea9912 | ||
|
0bc06e1bb1 | ||
|
b10a17529d | ||
|
bc80085ce4 | ||
|
f31fbc5bcf | ||
|
25f772c1ea | ||
|
a13c0bb612 | ||
|
4791cc77b3 | ||
|
e10da825d4 | ||
|
a2d1d2b819 | ||
|
fb277df3b0 | ||
|
a854fa8bcb | ||
|
bc69588785 | ||
|
a2d1245e90 | ||
|
090b05736a | ||
|
4b45ce0a58 | ||
|
9f8ee7dc94 | ||
|
4413848c11 | ||
|
9212330ac2 | ||
|
455d070599 | ||
|
84ae88794f | ||
|
ec3e9c722b | ||
|
2e6cd584aa | ||
|
27b0d1802e | ||
|
2c02702d60 | ||
|
9c3a016123 | ||
|
0d208dc443 | ||
|
e7c71ec445 | ||
|
46e2e17c3c | ||
|
f574f97174 | ||
|
674303b997 | ||
|
0fba2c0c0a | ||
|
0666448ef5 | ||
|
08e40bc693 | ||
|
eaf1767008 | ||
|
9f1eca555f | ||
|
72fd32dbf6 | ||
|
d5ebbab3a7 | ||
|
17257aacaf | ||
|
f87c8c6dcd | ||
|
f9556d9d6a | ||
|
fdfc6c0de2 | ||
|
64e4132c92 | ||
|
4b78a3114f | ||
|
c01eea58b6 | ||
|
19cb74c856 | ||
|
27bcdb949b | ||
|
94fc460240 | ||
|
fc59f0fdd8 | ||
|
b91e903284 | ||
|
711e775de7 | ||
|
736e90d423 | ||
|
2f3d9995d1 | ||
|
ac14f84a9a | ||
|
1d7641cbb0 | ||
|
dce36cbeed | ||
|
7e1087cba5 | ||
|
8877bf97c4 | ||
|
7e74f5a03c | ||
|
02ceed4fed | ||
|
27f159f6b8 | ||
|
499f33fbb6 | ||
|
8dd3bd420b | ||
|
2ca3188eff | ||
|
f275bcdd2c | ||
|
64c702aaf7 | ||
|
833ea4e06e | ||
|
5643ee649b | ||
|
97b2db84c6 | ||
|
4fea25959c | ||
|
8b70cec6a1 | ||
|
a330a8fa28 | ||
|
02899d4f68 | ||
|
76f7d566b2 | ||
|
e296bbf0aa | ||
|
0a4ee93fde | ||
|
1e7fbf1ed9 | ||
|
1170d5c2cf | ||
|
fcbbedd884 | ||
|
4c89e2eb77 | ||
|
809f353c52 | ||
|
d7a5a6ad14 | ||
|
ca0c52d60a | ||
|
2325e33e38 | ||
|
aba291c542 | ||
|
9b00244454 | ||
|
c1878fe29b | ||
|
43c551929b | ||
|
840e740309 | ||
|
babb36d375 | ||
|
25b9d88469 | ||
|
b5c2285167 | ||
|
beaaa21f58 | ||
|
8a1bcf30d2 | ||
|
ff384569f8 | ||
|
f64a253450 | ||
|
da5628d125 | ||
|
a9c18c4753 | ||
|
d492579638 | ||
|
19188e3eda | ||
|
8cc44a695f | ||
|
7bb8b66596 | ||
|
40f709e7c6 | ||
|
264ff9f7bb | ||
|
308f2c9efd | ||
|
2a8ed53934 | ||
|
17c86b82bf | ||
|
63b039a800 | ||
|
213045aa03 | ||
|
6d10dbe9af | ||
|
4adf342ce3 | ||
|
b150636906 | ||
|
4f1adba387 | ||
|
879b801600 | ||
|
c712d23e9c | ||
|
d69c4035d9 | ||
|
b83adbccfd | ||
|
7b6c666e7b | ||
|
8b9f03e998 | ||
|
e69f8965c7 | ||
|
6d2b50722a | ||
|
99a05900b1 | ||
|
cc5e586d38 | ||
|
17074b8aab | ||
|
406c8ff834 | ||
|
1ba4a0b87e | ||
|
36017f89fe | ||
|
1f2566c32c | ||
|
0c74927eab | ||
|
19ecd968c6 | ||
|
fc64660f5d | ||
|
d5d769ebb1 | ||
|
f5ee37b394 | ||
|
54c5c09693 | ||
|
c87ebaef08 | ||
|
9c0d921f49 | ||
|
d7bdc173a4 | ||
|
937354512b | ||
|
2dedfe4b92 | ||
|
9f6ec80432 | ||
|
aa31b8307d | ||
|
dc06d5b5c9 | ||
|
2c867d2292 | ||
|
b09ac3f3f8 | ||
|
21f54c5562 | ||
|
a0ace316e8 | ||
|
6c96c932e0 | ||
|
522e41aa71 | ||
|
bbd891965f | ||
|
0f43445a90 | ||
|
7a684660e9 | ||
|
12dc4fff57 | ||
|
1b5a200781 | ||
|
1bdf7764a9 | ||
|
3417c0cbee | ||
|
738ca405fe | ||
|
09609a9f20 | ||
|
fb0a875c6d | ||
|
74b6b9935b | ||
|
3b5691ee28 | ||
|
18593e0d7d | ||
|
40e1b61fe6 | ||
|
95dcb45d65 | ||
|
bd989df769 | ||
|
538136153c | ||
|
c330662241 | ||
|
588cbaee2d | ||
|
a9557c399a | ||
|
14e7bc73ee | ||
|
edc579fa6f | ||
|
119831e51c | ||
|
2aee020c14 | ||
|
f20696210e | ||
|
ea84fc4836 | ||
|
5ff04d0c68 | ||
|
e2ec52c28c | ||
|
6196081bdf | ||
|
6d8e6583c8 | ||
|
c2b0060852 | ||
|
24964b94bf | ||
|
2b624c935d | ||
|
2e647cdda8 | ||
|
ba175144f4 | ||
|
e9af36ab47 | ||
|
8b3913339a | ||
|
3cced4156f | ||
|
e88a9394ed | ||
|
ba73c59601 | ||
|
0191babb05 | ||
|
ed51f9ff0a | ||
|
0a2bdbaa09 | ||
|
26229db1fd | ||
|
1aef3162be | ||
|
dcf88df0c7 | ||
|
3eb28dfb16 | ||
|
1d43371fa9 | ||
|
4df455cf04 | ||
|
2c43ccf66c | ||
|
1d010efbca | ||
|
2135d5908e | ||
|
9640dd6419 | ||
|
98464a8a33 | ||
|
2b6535525b | ||
|
b60617b849 | ||
|
0836d99e83 | ||
|
279c7df9b9 | ||
|
04bcf1eef6 | ||
|
70e98f810c | ||
|
3851d805f7 | ||
|
085468382a | ||
|
7dbdd8c024 | ||
|
ad25307e68 | ||
|
6c454b8279 | ||
|
9f15e9ba86 | ||
|
41009dba74 | ||
|
a045f8bef9 | ||
|
689d767f82 | ||
|
77fff9fde8 | ||
|
c6c1ae32e6 | ||
|
bdac88b573 | ||
|
31ee65be93 | ||
|
29df6b60be | ||
|
1e6f175379 | ||
|
065ff3456f | ||
|
37e0a5579b | ||
|
cd9aa0096e | ||
|
d06493c61e | ||
|
9f71e2c3cb | ||
|
81a8736ba9 | ||
|
57ce25301d | ||
|
cfbaa84fbc | ||
|
3417f5a77e | ||
|
1035636d0f | ||
|
d00fc2c49d | ||
|
f3b889a665 | ||
|
c5122bf5d5 | ||
|
5ed55195e1 | ||
|
8604ea3020 | ||
|
2648e960a7 | ||
|
f0dff95d62 | ||
|
894db01ad8 | ||
|
bd04023359 | ||
|
f357434a72 | ||
|
c2586586c4 | ||
|
06cd062a1b | ||
|
1502fc98d8 | ||
|
ed97989ca2 | ||
|
7f1048352d | ||
|
d2700961ba | ||
|
b2dbb0439c | ||
|
085c924a07 | ||
|
f31f02d4cc | ||
|
797f6eea50 | ||
|
9fb3a31b51 | ||
|
f8061bbca1 | ||
|
de67927ef2 | ||
|
8c841c3266 | ||
|
2f656146a7 | ||
|
d66d2aa26e | ||
|
f4c0d4ba87 | ||
|
646bd40bca | ||
|
307b912019 | ||
|
cbc896eba7 | ||
|
cc4e4a068b | ||
|
a720f6f651 | ||
|
54d7c0d3b6 | ||
|
b5876d9f31 | ||
|
25ef4c10bd | ||
|
985b87da6c | ||
|
a6aa28566c | ||
|
e3340ba3e1 | ||
|
590b311570 | ||
|
935a968257 | ||
|
fe558eb0ba | ||
|
fda9cba4ce | ||
|
89f6cfd949 | ||
|
803582c437 | ||
|
8e86d38804 | ||
|
fd66722945 | ||
|
ce42b5e035 | ||
|
bfd3a667dd | ||
|
b669490d33 | ||
|
ba956f51ac | ||
|
c126b95451 | ||
|
7f7484e790 | ||
|
5d64056e89 | ||
|
e927254e99 | ||
|
8061bfd74a | ||
|
4c309d2ad7 | ||
|
54876cfbd6 | ||
|
9e2b796a7c | ||
|
7f5bd1e96b | ||
|
5e1680c458 | ||
|
6e26b988bd | ||
|
2e8d4e4b6b | ||
|
14bede2847 | ||
|
54660ebd63 | ||
|
1a62b7e07a | ||
|
4b04c0e0ce | ||
|
4d45ce389b | ||
|
d2674fb309 | ||
|
5eb69e1f59 | ||
|
f42142979d | ||
|
5f79d3b499 | ||
|
8e4d97260f | ||
|
ee3640e160 | ||
|
bd584802e5 | ||
|
f4dd045326 | ||
|
24522943f6 | ||
|
56fde28e43 | ||
|
1c1d661bdd | ||
|
d3be186fb6 | ||
|
8f8cc558be | ||
|
5ea9e1cf5e | ||
|
99a6066f96 | ||
|
8b7db726dc | ||
|
7a44086bf3 | ||
|
12fdfc9b54 | ||
|
ecc3b5e227 | ||
|
bf2c5a5564 | ||
|
d3a5c91845 | ||
|
e17bb0b433 | ||
|
c4ba41f850 | ||
|
0c4de6f163 | ||
|
cd313952c7 | ||
|
ef57b443c1 | ||
|
5154d7e5a6 | ||
|
2bd40b50c7 | ||
|
c50d58d0f4 | ||
|
8c65316961 | ||
|
0a818cfdf3 | ||
|
57204c6ed0 | ||
|
a21052c903 | ||
|
441ebacfd7 | ||
|
46acfb6c23 | ||
|
9910e884fc | ||
|
d292aaabf8 | ||
|
d822a6f84c | ||
|
26c3bcdb74 | ||
|
33deea4791 | ||
|
20227a2201 | ||
|
0493682dba | ||
|
49dda87e86 | ||
|
d959d08561 | ||
|
e8ab49abc1 | ||
|
296677b5fc | ||
|
f3931b537c | ||
|
d0ba4e5268 | ||
|
654ab4cc29 | ||
|
e0becd0c7b | ||
|
ed43ad9c3f | ||
|
4611abe5db | ||
|
df0bd52283 | ||
|
d387a9ff02 | ||
|
5556df54f9 | ||
|
cf746079ed | ||
|
4d10536d35 | ||
|
ba16ebe61f | ||
|
6484dc4982 | ||
|
1a9f1f7a3d | ||
|
4d1411e2a8 | ||
|
968152b740 | ||
|
af185fd3d1 | ||
|
ed9591c16f | ||
|
8ad326941f | ||
|
8e4f02d994 | ||
|
8ae85bc49f | ||
|
7ff9a90204 | ||
|
f4fb1ab272 | ||
|
484c6d2edb | ||
|
8b68ff6232 | ||
|
0a59b7164e | ||
|
0879829dc4 | ||
|
78ba3bd854 | ||
|
44c91099cd | ||
|
4585f03ee3 | ||
|
bc9e8f68f1 | ||
|
f5a21584c2 | ||
|
e0a508a765 | ||
|
89e018a4f0 | ||
|
de6001f4cf | ||
|
790d5beee5 | ||
|
abf224bb0a | ||
|
c7d2e165d7 | ||
|
0ab9facc4b | ||
|
9b68bbe44e | ||
|
862404f052 | ||
|
b9a93f3440 | ||
|
c67fb2d4b6 | ||
|
1b2d4f1e1d | ||
|
54fdc40d87 | ||
|
390148456c | ||
|
167059027e | ||
|
208bad393f | ||
|
8a93f8b6b6 | ||
|
9db04a4e19 | ||
|
ac5e26a254 | ||
|
9794a08f39 | ||
|
ababb8ebef | ||
|
cb3baf9955 | ||
|
dbe8cba14e | ||
|
9fe671c9bb | ||
|
0e5f334a0b | ||
|
e4514d8413 | ||
|
1080d25561 | ||
|
ba594de13a | ||
|
209d13affd | ||
|
671fdec8d0 | ||
|
9eca9192ca | ||
|
3468bdf511 | ||
|
54503d2c29 | ||
|
565131e2a7 | ||
|
f0da3cf304 | ||
|
6acc91577d | ||
|
3e0cd3a21f | ||
|
5c9fa4c62d | ||
|
4b00f7996b | ||
|
b58380e224 | ||
|
b0e996e7ff | ||
|
b41d9fb0e7 | ||
|
ed02eed88f | ||
|
c761802900 | ||
|
16eb1fa63b | ||
|
0ceb8d64df | ||
|
9712eccb03 | ||
|
9684fbc4da | ||
|
67f9375d47 | ||
|
0d16fac001 | ||
|
a8f5ca8d97 |
BIN
.deploy.enc
BIN
.deploy.enc
Binary file not shown.
@@ -15,5 +15,5 @@ indent_size = 2
|
||||
[*.{diff,md}]
|
||||
trim_trailing_whitespace = false
|
||||
|
||||
[*.{php,xml}]
|
||||
[*.{php,xml,ts,tsx}]
|
||||
indent_size = 4
|
||||
|
3
.github/FUNDING.yml
vendored
Normal file
3
.github/FUNDING.yml
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
github: flarum
|
||||
open_collective: flarum
|
||||
tidelift: packagist/flarum/core
|
3
.github/ISSUE_TEMPLATE/bug-report.md
vendored
3
.github/ISSUE_TEMPLATE/bug-report.md
vendored
@@ -3,9 +3,6 @@ name: "🐛 Bug Report"
|
||||
about: "If something isn't working as expected"
|
||||
|
||||
---
|
||||
<!--
|
||||
IMPORTANT: If you discover a security vulnerability within Flarum, please send an email to [security@flarum.org](mailto:security@flarum.org) instead. We will address these with the utmost urgency and it will prevent vulnerabilities, which may be abused, from popping up on our issue tracker.
|
||||
-->
|
||||
## Bug Report
|
||||
|
||||
**Current Behavior**
|
||||
|
2
.github/PULL_REQUEST_TEMPLATE.md
vendored
2
.github/PULL_REQUEST_TEMPLATE.md
vendored
@@ -16,7 +16,7 @@ IMPORTANT: We applaud pull requests, they excite us every single time. As we hav
|
||||
**Confirmed**
|
||||
|
||||
- [ ] Frontend changes: tested on a local Flarum installation.
|
||||
- [ ] Backend changes: tests are green (run `php vendor/bin/phpunit`).
|
||||
- [ ] Backend changes: tests are green (run `composer test`).
|
||||
|
||||
**Required changes:**
|
||||
|
||||
|
13
.github/SECURITY.md
vendored
Normal file
13
.github/SECURITY.md
vendored
Normal file
@@ -0,0 +1,13 @@
|
||||
# Security Policy
|
||||
|
||||
## Supported Versions
|
||||
|
||||
During the beta phase, we will only patch security vulnerabilities in the latest beta release.
|
||||
|
||||
## Reporting a Vulnerability
|
||||
|
||||
If you discover a security vulnerability within Flarum, please send an email to security@flarum.org so we can address it promptly.
|
||||
|
||||
We will get back to you as time allows.
|
||||
Discussions may commence internally, so you may not hear back immediately.
|
||||
When reporting a vulnerability, please provide your GitHub username (if available), so that we can invite you to collaborate on a [security advisory on GitHub](https://help.github.com/en/articles/about-maintainer-security-advisories).
|
26
.github/stale.yml
vendored
Normal file
26
.github/stale.yml
vendored
Normal file
@@ -0,0 +1,26 @@
|
||||
daysUntilStale: 90
|
||||
daysUntilClose: 30
|
||||
|
||||
staleLabel: stale
|
||||
|
||||
exemptLabels:
|
||||
- org/keep
|
||||
- type/bug
|
||||
- type/regression
|
||||
- critical
|
||||
- security
|
||||
exemptAssignees: true
|
||||
exemptMilestones: true
|
||||
exemptProjects: true
|
||||
|
||||
markComment: >
|
||||
This issue has been automatically marked as stale because it has not had
|
||||
recent activity. It will be closed if no further activity occurs. We do this
|
||||
to keep the amount of open issues to a manageable minimum.
|
||||
|
||||
In any case, thanks for taking an interest in this software and contributing
|
||||
by opening the issue in the first place!
|
||||
|
||||
closeComment: >
|
||||
We are closing this issue as it seems to have grown stale. If you still
|
||||
encounter this problem with the latest version, feel free to re-open it.
|
16
.github/workflows/build.yml
vendored
Normal file
16
.github/workflows/build.yml
vendored
Normal file
@@ -0,0 +1,16 @@
|
||||
name: JavaScript
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- master
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@master
|
||||
- uses: flarum/action-build@master
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
31
.github/workflows/lint.yml
vendored
Normal file
31
.github/workflows/lint.yml
vendored
Normal file
@@ -0,0 +1,31 @@
|
||||
name: Lint code
|
||||
|
||||
on:
|
||||
push:
|
||||
paths:
|
||||
- 'js/src/**'
|
||||
pull_request:
|
||||
paths:
|
||||
- 'js/src/**'
|
||||
|
||||
jobs:
|
||||
prettier:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
name: Lint JS code with Prettier
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@master
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v1
|
||||
with:
|
||||
node-version: "12"
|
||||
|
||||
- name: Install JS dependencies
|
||||
run: npm ci
|
||||
working-directory: ./js
|
||||
|
||||
- name: Check JS code for formatting
|
||||
run: node_modules/.bin/prettier --check src
|
||||
working-directory: ./js
|
67
.github/workflows/test.yml
vendored
Normal file
67
.github/workflows/test.yml
vendored
Normal file
@@ -0,0 +1,67 @@
|
||||
name: Tests
|
||||
|
||||
on: [push, pull_request]
|
||||
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
strategy:
|
||||
matrix:
|
||||
php: [7.2, 7.3, 7.4]
|
||||
service: ['mysql:5.7', mariadb]
|
||||
prefix: ['', flarum_]
|
||||
|
||||
include:
|
||||
- service: 'mysql:5.7'
|
||||
db: MySQL
|
||||
- service: mariadb
|
||||
db: MariaDB
|
||||
- prefix: flarum_
|
||||
prefixStr: (prefix)
|
||||
|
||||
exclude:
|
||||
- php: 7.2
|
||||
service: 'mysql:5.7'
|
||||
prefix: flarum_
|
||||
- php: 7.2
|
||||
service: mariadb
|
||||
prefix: flarum_
|
||||
- php: 7.3
|
||||
service: 'mysql:5.7'
|
||||
prefix: flarum_
|
||||
- php: 7.3
|
||||
service: mariadb
|
||||
prefix: flarum_
|
||||
|
||||
services:
|
||||
mysql:
|
||||
image: ${{ matrix.service }}
|
||||
ports:
|
||||
- 13306:3306
|
||||
|
||||
name: 'PHP ${{ matrix.php }} / ${{ matrix.db }} ${{ matrix.prefixStr }}'
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@master
|
||||
|
||||
- name: Select PHP version
|
||||
run: sudo update-alternatives --set php $(which php${{ matrix.php }})
|
||||
|
||||
- name: Create MySQL Database
|
||||
run: |
|
||||
sudo systemctl start mysql
|
||||
mysql -uroot -proot -e 'CREATE DATABASE flarum_test;' --port 13306
|
||||
|
||||
- name: Install Composer dependencies
|
||||
run: composer install
|
||||
|
||||
- name: Setup Composer tests
|
||||
run: composer test:setup
|
||||
env:
|
||||
DB_PORT: 13306
|
||||
DB_PASSWORD: root
|
||||
DB_PREFIX: ${{ matrix.prefix }}
|
||||
|
||||
- name: Run Composer tests
|
||||
run: composer test
|
2
.gitignore
vendored
2
.gitignore
vendored
@@ -4,6 +4,6 @@ composer.phar
|
||||
node_modules
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
/tests/tmp
|
||||
/tests/integration/tmp
|
||||
.vagrant
|
||||
.idea/*
|
||||
|
46
.travis.yml
46
.travis.yml
@@ -1,46 +0,0 @@
|
||||
language: php
|
||||
|
||||
cache:
|
||||
directories:
|
||||
- $HOME/.composer/cache
|
||||
- $HOME/.npm
|
||||
|
||||
install:
|
||||
- composer install
|
||||
- mysql -e 'CREATE DATABASE flarum;'
|
||||
|
||||
script:
|
||||
- vendor/bin/phpunit --coverage-clover=coverage.xml
|
||||
|
||||
after_success:
|
||||
- bash <(curl -s https://codecov.io/bash)
|
||||
|
||||
jobs:
|
||||
include:
|
||||
- php: 7.1
|
||||
env: DB=mysql
|
||||
|
||||
- php: 7.2
|
||||
env: DB=mysql
|
||||
|
||||
- php: 7.2
|
||||
env: DB=mysql PREFIX=forum_
|
||||
|
||||
- php: 7.1
|
||||
addons:
|
||||
mariadb: '10.2'
|
||||
env: DB=mariadb
|
||||
|
||||
- php: 7.2
|
||||
addons:
|
||||
mariadb: '10.2'
|
||||
env: DB=mariadb
|
||||
|
||||
- stage: build
|
||||
language: generic
|
||||
if: branch = master AND type = push
|
||||
install: skip
|
||||
script: bash .travis/build.sh
|
||||
-k $encrypted_678139e2bc67_key
|
||||
-i $encrypted_678139e2bc67_iv
|
||||
after_success: skip
|
@@ -1,33 +0,0 @@
|
||||
#!/bin/bash
|
||||
|
||||
main() {
|
||||
while getopts ":k:i:" opt; do
|
||||
case $opt in
|
||||
k) encrypted_key="$OPTARG"
|
||||
;;
|
||||
i) encrypted_iv="$OPTARG"
|
||||
;;
|
||||
\?) echo "Invalid option -$OPTARG" >&2
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
git checkout -f $TRAVIS_BRANCH
|
||||
git config user.name "flarum-bot"
|
||||
git config user.email "bot@flarum.org"
|
||||
|
||||
cd js
|
||||
npm i -g npm@6.1.0
|
||||
npm ci
|
||||
npm run build
|
||||
|
||||
git add dist/* -f
|
||||
git commit -m "Bundled output for commit $TRAVIS_COMMIT [skip ci]"
|
||||
|
||||
eval `ssh-agent -s`
|
||||
openssl aes-256-cbc -K $encrypted_key -iv $encrypted_iv -in ../.deploy.enc -d | ssh-add -
|
||||
|
||||
git push git@github.com:$TRAVIS_REPO_SLUG.git $TRAVIS_BRANCH
|
||||
}
|
||||
|
||||
main "$@"
|
132
CHANGELOG.md
132
CHANGELOG.md
@@ -1,12 +1,142 @@
|
||||
# Changelog
|
||||
|
||||
## [0.1.0-beta.12](https://github.com/flarum/core/compare/v0.1.0-beta.11.1...v0.1.0-beta.12)
|
||||
|
||||
### Added
|
||||
- Full support for PHP 7.4 (#1980)
|
||||
- Mail settings: Configure region for the Mailgun driver (#1834, #1850)
|
||||
- Mail settings: Alert admins about incomplete settings (#1763, #1921)
|
||||
- New permission that allows users to post without throttling (#1255, #1938)
|
||||
- Basic transliteration of discussion "slugs" / pretty URLs (#194, #1975)
|
||||
- User profiles: Render basic content on server side (#1901)
|
||||
- New extender for configuring middleware (#1919, #1952, #1957, #1971)
|
||||
- New extender for configuring error handling (#1781, #1970)
|
||||
- Automated tests for PHP extenders to guarantee their backwards compatibility
|
||||
|
||||
### Changed
|
||||
- Profile URLs for non-existing users properly return HTTP 404 (#1846, #1901)
|
||||
- Confirmation email subject no longer contains the forum title (#1613)
|
||||
- Improved error handling during Flarum's early boot phase (#1607)
|
||||
- Updated deprecated "Zend" libraries to their new "Laminas" equivalents (#1963)
|
||||
|
||||
### Fixed
|
||||
- Update page did not work when installed in subdirectories (#1947)
|
||||
- Avatar upload did not work in IE11 / Edge (#1125, #1570)
|
||||
- Translation fallback was ignored for client-rendered pages (#1774, #1961)
|
||||
- The success alert when posting replies was invisible (#1976)
|
||||
|
||||
## [0.1.0-beta.11.1](https://github.com/flarum/core/compare/v0.1.0-beta.11...v0.1.0-beta.11.1)
|
||||
|
||||
### Fixed
|
||||
- Saving custom css in admin failed (#1946)
|
||||
|
||||
## [0.1.0-beta.11](https://github.com/flarum/core/compare/v0.1.0-beta.10...v0.1.0-beta.11)
|
||||
|
||||
### Added
|
||||
- Comments have an additional class `Post--by-actor` when posted by the user (#1927)
|
||||
|
||||
### Changed
|
||||
- Improved support for URL identification during installation (#1861)
|
||||
- KeyboardNavigatable now has a callback ability (#1922)
|
||||
- Links are no longer opened with target `_blank` but in the same window (#859)
|
||||
- Links now have `nofollow ugc` by default as their `rel` attribute (#859, #1884)
|
||||
- Improved performance of the full text gambit when searching for users (#1877)
|
||||
- The Queue implementation is now available under its Illuminate contract
|
||||
|
||||
### Fixed
|
||||
- No error handling was possible in the console/cli (#1789)
|
||||
- Enable scrollbars in log in modals so it fits for GitHub (#1716)
|
||||
- Reduce log in modal for SSO so it fits for Facebook (#1727)
|
||||
- Deleting discussions permanently did not delete its posts (#1909)
|
||||
- Fixed the queue:restart command (#1932)
|
||||
- Deleted posts were visible to all visitors (#1827)
|
||||
- Old avatars weren't being deleted when replaced (#1918)
|
||||
- The search performance regression was reverted (#1764)
|
||||
- No profile background could be set for remote images (#445)
|
||||
- Back button sends to home even though it could actually go back (#1942)
|
||||
- Debug button no longer visible (#1687)
|
||||
- Modals on smaller screens use the whole width of the page
|
||||
|
||||
## [0.1.0-beta.10](https://github.com/flarum/core/compare/v0.1.0-beta.9...v0.1.0-beta.10)
|
||||
|
||||
### Added
|
||||
- Initial queue support: Infrastructure for offloading long-running tasks (e.g. email sending) to background workers (#1773)
|
||||
- Notifications can now be marked as read without visiting a discussion (#151)
|
||||
- SEO: The discussion list now has a `rel="canonical"` meta tag, preventing duplicate content (#1134, #1814)
|
||||
- The "Edit User" permission can now be edited in the UI (#1845)
|
||||
- New status message and redirect after user deletion (#1750, #1777)
|
||||
- Errors in Flarum's boot process are now presented with more detailed information (#1607)
|
||||
|
||||
### Changed
|
||||
- Better, more detailed and extensible error handling (#1641, #1843)
|
||||
- Error pages in debug mode now return the same HTTP status codes as in production (#1648)
|
||||
- Tweak HTTP status codes for authentication / authorization errors (#1854)
|
||||
- Already-used links from account activation emails now show a better error message (#1337)
|
||||
|
||||
### Fixed
|
||||
- Security vulnerabilities in dependencies
|
||||
- Performance: High CPU usage when scrolling in a discussion (#1222)
|
||||
- Special characters crashed the search (#1498)
|
||||
- Missing declarations for language and text direction in HTML output (#1772)
|
||||
- Private messages were counted in user post counts (#1695)
|
||||
- Extensions could not change the forum's default page (#1819)
|
||||
- API requests authenticated using access tokens needed to provide a CSRF token (#1828)
|
||||
- Accessibility: Screenreaders did not read the "Back to discussion list" link (#1835)
|
||||
|
||||
## [0.1.0-beta.9](https://github.com/flarum/core/compare/v0.1.0-beta.8.2...v0.1.0-beta.9)
|
||||
|
||||
### Added
|
||||
- New `hasPermission()` helper method for `Group` objects ([9684fbc](https://github.com/flarum/core/commit/9684fbc4da07d32aa322d9228302a23418412cb9))
|
||||
- Expose supported mail drivers in IoC container ([208bad3](https://github.com/flarum/core/commit/208bad393f37bfdb76007afcddfa4b7451563e9d))
|
||||
- More test for some API endpoints ([1670590](https://github.com/flarum/core/commit/167059027e5a066d618599c90164ef1b5a509148))
|
||||
- The `Formatter\Rendering` event now receives the HTTP request instance as well ([0ab9fac](https://github.com/flarum/core/commit/0ab9facc4bd59a260575e6fc650793c663e5866a))
|
||||
- More and better validation in installer UIs
|
||||
- Check and enforce minimum MariaDB ([7ff9a90](https://github.com/flarum/core/commit/7ff9a90204923293adc520d3c02dc984845d4f9f))
|
||||
- Revert publication of assets when installation fails ([ed9591c](https://github.com/flarum/core/commit/ed9591c16fb2ea7a4be3387b805d855a53e0a7d5))
|
||||
- Benefit from Laravel's database reconnection logic in long-running tasks ([e0becd0](https://github.com/flarum/core/commit/e0becd0c7bda939048923c1f86648793feee78d5))
|
||||
- The "vendor path" (where Composer dependencies can be found) can now be configured ([5e1680c](https://github.com/flarum/core/commit/5e1680c458cd3ba274faeb92de3ac2053789131e))
|
||||
|
||||
### Changed
|
||||
- Performance: Actually cache translations on disk ([0d16fac](https://github.com/flarum/core/commit/0d16fac001bb735ee66e82871183516aeac269b7))
|
||||
- Allow per-site extenders to override extension extenders ([ba594de](https://github.com/flarum/core/commit/ba594de13a033480834d53d73f747b05fe9796f8))
|
||||
- Do not resolve objects from the IoC container (in service providers and extenders) until they are actually used
|
||||
- Replace event subscribers (that resolve objects from the IoC container) with listeners (that resolve lazily)
|
||||
- Use custom service provider for Mail component ([ac5e26a](https://github.com/flarum/core/commit/ac5e26a254d89e21bd4c115b6cbd40338e2e4b4b))
|
||||
- Update to Laravel 5.7, revert custom logic for building database index names
|
||||
- Refactored installer, extracted Installation class and pipeline for reuse in CLI and web installers ([790d5be](https://github.com/flarum/core/commit/790d5beee5e283178716bc8f9901c758d9e5b6a0))
|
||||
- Use whitelist for enabling pre-installed extensions during installation ([4585f03](https://github.com/flarum/core/commit/4585f03ee356c92942fbc2ae8c683c651b473954))
|
||||
- Update minimum MySQL version ([7ff9a90](https://github.com/flarum/core/commit/7ff9a90204923293adc520d3c02dc984845d4f9f))
|
||||
|
||||
### Fixed
|
||||
- Signing up via OAuth providers was broken ([67f9375](https://github.com/flarum/core/commit/67f9375d4745add194ae3249d526197c32fd5461))
|
||||
- Group badges were overlapping ([16eb1fa](https://github.com/flarum/core/commit/16eb1fa63b6d7b80ec30c24c0e406a2b7ab09934))
|
||||
- API: Endpoint for uninstalling extensions returned an error ([c761802](https://github.com/flarum/core/commit/c76180290056ddbab67baf5ede814fcedf1dcf14))
|
||||
- Documentation links in installer were outdated ([b58380e](https://github.com/flarum/core/commit/b58380e224ee54abdade3d0a4cc107ef5c91c9a9))
|
||||
- Event posts where counted when aggregating user posts ([671fdec](https://github.com/flarum/core/commit/671fdec8d0a092ccceb5d4d5f657d0f4287fc4c7))
|
||||
- Admins could not reset user passwords ([c67fb2d](https://github.com/flarum/core/commit/c67fb2d4b6a128c71d65dc6703310c0b62f91be2))
|
||||
- Several down migrations were invalid
|
||||
- Validation errors on reset password page resulted in HTTP 404 ([4611abe](https://github.com/flarum/core/commit/4611abe5db8b94ca3dc7bf9c447fca7c67358ee3))
|
||||
- `is:unread` gambit generated an invalid query ([e17bb0b](https://github.com/flarum/core/commit/e17bb0b4331f2c92459292195c6b7db8cde1f9f3))
|
||||
- Entire forum was breaking when the `custom_less` setting was missing from the database ([bf2c5a5](https://github.com/flarum/core/commit/bf2c5a5564dff3f5ef13efe7a8d69f2617570ce6))
|
||||
- Dropdown icon was not showing in user card when on user page ([12fdfc9](https://github.com/flarum/core/commit/12fdfc9b544a27f6fe59c82ad6bddd3420cc0181))
|
||||
- Requests were missing the `original*` attributes, which broke installations in subfolders ([56fde28](https://github.com/flarum/core/commit/56fde28e436f52fee0c03c538f0a6049bc584b53))
|
||||
- Special characters such as `%` and `_` could return incorrect results ([ee3640e](https://github.com/flarum/core/commit/ee3640e1605ff67fef4b3d5cd0596f14a6ae73c9))
|
||||
- FontAwesome component package changed paths in version 5.9.0 ([5eb69e1](https://github.com/flarum/core/commit/5eb69e1f59fa73fdfd5badbf41a05a6a040e7426))
|
||||
- Some server environments had problems accessing the system-wide tmp path for storing JS file maps ([54660eb](https://github.com/flarum/core/commit/54660ebd6311f9ea142f1b573263d0d907400786))
|
||||
- Content length of posts.content was not migrated to mediumText in 2017 ([590b311](https://github.com/flarum/core/commit/590b3115708bf94a9c7f169d98c6126380c7056e))
|
||||
- An error occurred when going to the previous route if there was no previous route found ([985b87da](https://github.com/flarum/core/commit/985b87da6c9942c568a1a192e2fdcfde72e030ee))
|
||||
|
||||
### Removed
|
||||
- `php flarum install --defaults` - this was meant to be used in our old development VM ([44c9109](https://github.com/flarum/core/commit/44c91099cd77138bb5fc29f14fb1e81a9781272d))
|
||||
- Obsolete `id` attributes in JSON-API responses ([ecc3b5e](https://github.com/flarum/core/commit/ecc3b5e2271f8d9b38d52cd54476d86995dbe32e) and [7a44086](https://github.com/flarum/core/commit/7a44086bf3a0e3ba907dceb13d07ac695eca05ea))
|
||||
|
||||
## [0.1.0-beta.8.1](https://github.com/flarum/core/compare/v0.1.0-beta.8...v0.1.0-beta.8.1)
|
||||
|
||||
### Fixed
|
||||
- Fix live output in `migrate:reset` command ([f591585](https://github.com/flarum/core/commit/f591585d02f8c4ff0211c5bf4413dd6baa724c05))
|
||||
- Fix search with database prefix ([7705a2b](https://github.com/flarum/core/commit/7705a2b7d751943ef9d0c7379ec34f8530b99310))
|
||||
- Fix invalid join time of admin user created by installer ([57f73c9](https://github.com/flarum/core/commit/57f73c9638eeb825f9e336ed3c443afccfd8995e))
|
||||
- Ensure InnoDB engine is used for all tables ([fb6b51b](https://github.com/flarum/core/commit/fb6b51b1cfef0af399607fe038603c8240800b2b))
|
||||
- Ensure InnoDB engine is used for all tables ([fb6b51b](https://github.com/flarum/core/commit/fb6b51b1cfef0af399607fe038603c8240800b2b), [6370f7e](https://github.com/flarum/core/commit/6370f7ecffa9ea7d5fb64d9551400edbc63318db))
|
||||
- Fix dropping foreign keys in `down` migrations ([57d5846](https://github.com/flarum/core/commit/57d5846b647881009d9e60f9ffca20b1bb77776e))
|
||||
- Fix discussion list scroll position not being maintained when hero is not visible ([40dc6ac](https://github.com/flarum/core/commit/40dc6ac604c2a0973356b38217aa8d09352daae5))
|
||||
- Fix empty meta description tag ([88e43cc](https://github.com/flarum/core/commit/88e43cc6940ee30d6529e9ce659471ec4fb1c474))
|
||||
|
@@ -1,3 +0,0 @@
|
||||
# Contributing to Flarum
|
||||
|
||||
Thank you for considering contributing to Flarum! Please read the **[Contributing guide](https://flarum.org/docs/contributing.html)** to learn how you can help.
|
26
FRONTEND FRAMEWORK REWRITE CHANGES.md
Normal file
26
FRONTEND FRAMEWORK REWRITE CHANGES.md
Normal file
@@ -0,0 +1,26 @@
|
||||
### Changes
|
||||
|
||||
* Mithril
|
||||
- See changes from v0.2.x @ https://mithril.js.org/migration-v02x.html
|
||||
- Kept `m.prop` and `m.withAttr`
|
||||
- Actual Promises are used now instead of `m.deferred`
|
||||
* Component
|
||||
- Use new Mithril lifecycle hooks (`component.config` is gone)
|
||||
- When implementing your own, you *must* call `super.<hook>(vnode)` to update `this.attrs`
|
||||
- `component.render` now doesn't use the current state instance
|
||||
- this is because of how Mithril v2 works
|
||||
- now calls mithril on the component class (not instance) and its props
|
||||
* Translator
|
||||
- Added `app.translator.transText`, automatically extracts text from `translator.trans` output
|
||||
* Utils
|
||||
- Changed `computed` util to require multiple keys to be passed as an array
|
||||
- `SubtreeRetainer` now has an `update` method instead of `retain`, and its output is used in `onbeforeupdate` lifecycle hook
|
||||
- `Evented` util is now a class instead of an object
|
||||
- `formatNumber` now uses `Number.prototype.toLocaleString` with the current application locale, and supports passing an options object (eg. for currency formatting - https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/NumberFormat/resolvedOptions#Description)
|
||||
* Modals
|
||||
- `app.modal.show` now takes the Modal _class_ (not instance) and optional props (`app.modal.show(ForgotPasswordModal, props)`)
|
||||
|
||||
#### Forum
|
||||
* Forum Application
|
||||
- Renamed to `Forum`
|
||||
- `app.search` is no longer global, extend using `extend`
|
3
LICENSE
3
LICENSE
@@ -1,6 +1,7 @@
|
||||
The MIT License (MIT)
|
||||
|
||||
Copyright (c) Toby Zerner
|
||||
Copyright (c) 2019-2020 Stichting Flarum (Flarum Foundation)
|
||||
Copyright (c) 2014-2019 Toby Zerner (toby.zerner@gmail.com)
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
|
@@ -27,7 +27,7 @@ Thank you for considering contributing to Flarum! Please read the **[Contributin
|
||||
|
||||
## Security Vulnerabilities
|
||||
|
||||
If you discover a security vulnerability within Flarum, please send an e-mail to [security@flarum.org](mailto:security@flarum.org). All security vulnerabilities will be promptly addressed.
|
||||
If you discover a security vulnerability within Flarum, please send an e-mail to [security@flarum.org](mailto:security@flarum.org). All security vulnerabilities will be promptly addressed. More details can be found in our [security policy](https://github.com/flarum/core/security/policy).
|
||||
|
||||
## License
|
||||
|
||||
|
@@ -5,13 +5,28 @@
|
||||
"homepage": "https://flarum.org/",
|
||||
"license": "MIT",
|
||||
"authors": [
|
||||
{
|
||||
"name": "Toby Zerner",
|
||||
"email": "toby.zerner@gmail.com"
|
||||
},
|
||||
{
|
||||
"name": "Franz Liedke",
|
||||
"email": "franz@develophp.org"
|
||||
},
|
||||
{
|
||||
"name": "Daniel Klabbers",
|
||||
"email": "daniel@klabbers.email",
|
||||
"homepage": "https://luceos.com"
|
||||
},
|
||||
{
|
||||
"name": "David Sevilla Martin",
|
||||
"email": "me+flarum@datitisev.me",
|
||||
"homepage": "https://datitisev.me"
|
||||
},
|
||||
{
|
||||
"name": "Clark Winkelmann",
|
||||
"email": "clark.winkelmann@gmail.com",
|
||||
"homepage": "https://clarkwinkelmann.com"
|
||||
},
|
||||
{
|
||||
"name": "Matthew Kilgore",
|
||||
"email": "matthew@kilgore.dev"
|
||||
}
|
||||
],
|
||||
"support": {
|
||||
@@ -20,27 +35,31 @@
|
||||
"docs": "https://flarum.org/docs/"
|
||||
},
|
||||
"require": {
|
||||
"php": ">=7.1",
|
||||
"php": ">=7.2",
|
||||
"axy/sourcemap": "^0.1.4",
|
||||
"components/font-awesome": "5.9.*",
|
||||
"dflydev/fig-cookies": "^1.0.2",
|
||||
"doctrine/dbal": "^2.7",
|
||||
"franzl/whoops-middleware": "^0.4.0",
|
||||
"illuminate/bus": "5.5.*",
|
||||
"illuminate/cache": "5.5.*",
|
||||
"illuminate/config": "5.5.*",
|
||||
"illuminate/container": "5.5.*",
|
||||
"illuminate/contracts": "5.5.*",
|
||||
"illuminate/database": "5.5.*",
|
||||
"illuminate/events": "5.5.*",
|
||||
"illuminate/filesystem": "5.5.*",
|
||||
"illuminate/hashing": "5.5.*",
|
||||
"illuminate/mail": "5.5.*",
|
||||
"illuminate/session": "5.5.*",
|
||||
"illuminate/support": "5.5.*",
|
||||
"illuminate/validation": "5.5.*",
|
||||
"illuminate/view": "5.5.*",
|
||||
"intervention/image": "^2.3.0",
|
||||
"illuminate/bus": "5.7.*",
|
||||
"illuminate/cache": "5.7.*",
|
||||
"illuminate/config": "5.7.*",
|
||||
"illuminate/container": "5.7.*",
|
||||
"illuminate/contracts": "5.7.*",
|
||||
"illuminate/database": "5.7.*",
|
||||
"illuminate/events": "5.7.*",
|
||||
"illuminate/filesystem": "5.7.*",
|
||||
"illuminate/hashing": "5.7.*",
|
||||
"illuminate/mail": "5.7.*",
|
||||
"illuminate/queue": "5.7.*",
|
||||
"illuminate/session": "5.7.*",
|
||||
"illuminate/support": "5.7.*",
|
||||
"illuminate/validation": "5.7.*",
|
||||
"illuminate/view": "5.7.*",
|
||||
"intervention/image": "^2.5.0",
|
||||
"laminas/laminas-diactoros": "^1.8.4",
|
||||
"laminas/laminas-httphandlerrunner": "^1.0",
|
||||
"laminas/laminas-stratigility": "^3.0",
|
||||
"league/flysystem": "^1.0.11",
|
||||
"matthiasmullie/minify": "^1.3",
|
||||
"middlewares/base-path": "^1.1",
|
||||
@@ -48,24 +67,21 @@
|
||||
"middlewares/request-handler": "^1.2",
|
||||
"monolog/monolog": "^1.16.0",
|
||||
"nikic/fast-route": "^0.6",
|
||||
"oyejorge/less.php": "^1.7",
|
||||
"psr/http-message": "^1.0",
|
||||
"psr/http-server-handler": "^1.0",
|
||||
"psr/http-server-middleware": "^1.0",
|
||||
"s9e/text-formatter": "^1.2.0",
|
||||
"s9e/text-formatter": "^2.3.6",
|
||||
"symfony/config": "^3.3",
|
||||
"symfony/console": "^3.3",
|
||||
"symfony/http-foundation": "^3.3",
|
||||
"symfony/console": "^4.2",
|
||||
"symfony/event-dispatcher": "^4.3.2",
|
||||
"symfony/translation": "^3.3",
|
||||
"symfony/yaml": "^3.3",
|
||||
"tobscure/json-api": "^0.3.0",
|
||||
"zendframework/zend-diactoros": "^1.8.4",
|
||||
"zendframework/zend-httphandlerrunner": "^1.0",
|
||||
"zendframework/zend-stratigility": "^3.0"
|
||||
"wikimedia/less.php": "^3.0"
|
||||
},
|
||||
"require-dev": {
|
||||
"mockery/mockery": "^0.9.4",
|
||||
"phpunit/phpunit": "^6.0"
|
||||
"mockery/mockery": "^1.0",
|
||||
"phpunit/phpunit": "^7.0"
|
||||
},
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
@@ -87,5 +103,20 @@
|
||||
"branch-alias": {
|
||||
"dev-master": "0.1.x-dev"
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"test": [
|
||||
"@test:unit",
|
||||
"@test:integration"
|
||||
],
|
||||
"test:unit": "phpunit -c tests/phpunit.unit.xml",
|
||||
"test:integration": "phpunit -c tests/phpunit.integration.xml",
|
||||
"test:setup": "@php tests/integration/setup.php"
|
||||
},
|
||||
"scripts-descriptions": {
|
||||
"test": "Runs all tests.",
|
||||
"test:unit": "Runs all unit tests.",
|
||||
"test:integration": "Runs all integration tests.",
|
||||
"test:setup": "Sets up a database for use with integration tests. Execute this only once."
|
||||
}
|
||||
}
|
||||
|
6
js/.prettierrc.json
Normal file
6
js/.prettierrc.json
Normal file
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"printWidth": 150,
|
||||
"singleQuote": true,
|
||||
"tabWidth": 4,
|
||||
"trailingComma": "es5"
|
||||
}
|
11
js/admin.js
11
js/admin.js
@@ -1,11 +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.
|
||||
*/
|
||||
|
||||
export * from './src/common';
|
||||
export * from './src/admin';
|
2
js/admin.ts
Normal file
2
js/admin.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export * from './src/common';
|
||||
export * from './src/admin';
|
20903
js/dist/admin.js
vendored
20903
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
26555
js/dist/forum.js
vendored
26555
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
11
js/forum.js
11
js/forum.js
@@ -1,11 +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.
|
||||
*/
|
||||
|
||||
export * from './src/common';
|
||||
export * from './src/forum';
|
2
js/forum.ts
Normal file
2
js/forum.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export * from './src/common';
|
||||
export * from './src/forum';
|
5612
js/package-lock.json
generated
5612
js/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -1,26 +1,60 @@
|
||||
{
|
||||
"private": true,
|
||||
"name": "@flarum/core",
|
||||
"dependencies": {
|
||||
"bootstrap": "^3.3.7",
|
||||
"classnames": "^2.2.5",
|
||||
"color-thief-browser": "^2.0.2",
|
||||
"expose-loader": "^0.7.5",
|
||||
"flarum-webpack-config": "0.1.0-beta.10",
|
||||
"jquery": "^3.3.1",
|
||||
"jquery.hotkeys": "^0.1.0",
|
||||
"lodash-es": "^4.17.11",
|
||||
"m.attrs.bidi": "github:tobscure/m.attrs.bidi",
|
||||
"mithril": "^0.2.8",
|
||||
"moment": "^2.22.2",
|
||||
"punycode": "^2.1.1",
|
||||
"spin.js": "^3.1.0",
|
||||
"webpack": "^4.26.0",
|
||||
"webpack-cli": "^3.1.2",
|
||||
"webpack-merge": "^4.1.4"
|
||||
},
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "webpack --mode development --watch",
|
||||
"build": "webpack --mode production"
|
||||
"build": "webpack --mode production",
|
||||
"format": "prettier --write src \"*.{ts,js}\"",
|
||||
"format-check": "prettier --check src \"*.{ts,js}\""
|
||||
},
|
||||
"dependencies": {
|
||||
"bootstrap": "^3.4.1",
|
||||
"classnames": "^2.2.6",
|
||||
"colorthief": "^2.3.0",
|
||||
"dayjs": "^1.8.26",
|
||||
"flarum-webpack-config": "^0.1.0-beta.10",
|
||||
"hc-sticky": "^2.2.3",
|
||||
"jump.js": "^1.0.2",
|
||||
"lodash": "^4.17.15",
|
||||
"m.attrs.bidi": "github:tobscure/m.attrs.bidi",
|
||||
"micromodal": "^0.4.6",
|
||||
"mithril": "^2.0.4",
|
||||
"mousetrap": "^1.6.5",
|
||||
"punycode": "^2.1.1",
|
||||
"spin.js": "^4.1.0",
|
||||
"tooltip.js": "^1.3.3",
|
||||
"zepto": "^1.2.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/core": "^7.9.6",
|
||||
"@babel/plugin-proposal-class-properties": "^7.8.3",
|
||||
"@babel/plugin-syntax-dynamic-import": "^7.8.3",
|
||||
"@babel/plugin-transform-object-assign": "^7.8.3",
|
||||
"@babel/plugin-transform-react-jsx": "^7.9.4",
|
||||
"@babel/plugin-transform-runtime": "^7.9.6",
|
||||
"@babel/preset-env": "^7.9.6",
|
||||
"@babel/preset-react": "^7.9.4",
|
||||
"@babel/preset-typescript": "^7.9.0",
|
||||
"@babel/runtime": "^7.9.6",
|
||||
"@types/classnames": "^2.2.10",
|
||||
"@types/mithril": "^2.0.2",
|
||||
"@types/zepto": "^1.0.30",
|
||||
"babel-loader": "^8.0.6",
|
||||
"expose-loader": "^0.7.5",
|
||||
"friendly-errors-webpack-plugin": "^1.7.0",
|
||||
"husky": "^4.2.5",
|
||||
"imports-loader": "^0.8.0",
|
||||
"prettier": "^2.0.5",
|
||||
"source-map-loader": "^0.2.4",
|
||||
"typescript": "^3.8.3",
|
||||
"webpack": "^4.43.0",
|
||||
"webpack-bundle-analyzer": "^3.7.0",
|
||||
"webpack-cli": "^3.3.11",
|
||||
"webpack-merge": "^4.2.2"
|
||||
},
|
||||
"husky": {
|
||||
"hooks": {
|
||||
"pre-commit": "npm run format"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
7
js/shims.d.ts
vendored
Normal file
7
js/shims.d.ts
vendored
Normal file
@@ -0,0 +1,7 @@
|
||||
export * from './webpack-flarum-shims';
|
||||
|
||||
import Application from './src/common/Application';
|
||||
|
||||
declare global {
|
||||
const app: Application;
|
||||
}
|
91
js/src/admin/Admin.ts
Normal file
91
js/src/admin/Admin.ts
Normal file
@@ -0,0 +1,91 @@
|
||||
import HeaderPrimary from './components/HeaderPrimary';
|
||||
import HeaderSecondary from './components/HeaderSecondary';
|
||||
import routes from './routes';
|
||||
import Application, { ApplicationData } from '../common/Application';
|
||||
import Navigation from '../common/components/Navigation';
|
||||
import AdminNav from './components/AdminNav';
|
||||
|
||||
type Extension = {
|
||||
description: string;
|
||||
extra: object;
|
||||
icon: {
|
||||
name: string;
|
||||
};
|
||||
id: number;
|
||||
version: string;
|
||||
};
|
||||
|
||||
export type AdminData = ApplicationData & {
|
||||
mysqlVersion: string;
|
||||
phpVersion: string;
|
||||
extensions: {
|
||||
[key: string]: Extension;
|
||||
};
|
||||
permissions: {
|
||||
[key: string]: string[];
|
||||
};
|
||||
settings: {
|
||||
[key: string]: string;
|
||||
};
|
||||
};
|
||||
|
||||
export default class Admin extends Application {
|
||||
extensionSettings = {};
|
||||
|
||||
history = {
|
||||
canGoBack: () => true,
|
||||
getPrevious: () => {},
|
||||
backUrl: () => this.forum.attribute('baseUrl'),
|
||||
back: function () {
|
||||
window.location = this.backUrl();
|
||||
},
|
||||
};
|
||||
|
||||
data!: AdminData;
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
|
||||
routes(this);
|
||||
}
|
||||
|
||||
/**
|
||||
* @inheritdoc
|
||||
*/
|
||||
mount() {
|
||||
m.mount(document.getElementById('app-navigation'), new Navigation({ className: 'App-backControl', drawer: true }));
|
||||
m.mount(document.getElementById('header-navigation'), new Navigation());
|
||||
m.mount(document.getElementById('header-primary'), new HeaderPrimary());
|
||||
m.mount(document.getElementById('header-secondary'), new HeaderSecondary());
|
||||
m.mount(document.getElementById('admin-navigation'), new AdminNav());
|
||||
|
||||
if (!document.location.hash) document.location.hash = '#/';
|
||||
|
||||
m.route.prefix = '#';
|
||||
super.mount();
|
||||
|
||||
// If an extension has just been enabled, then we will run its settings
|
||||
// callback.
|
||||
const enabled = localStorage.getItem('enabledExtension');
|
||||
if (enabled && this.extensionSettings[enabled]) {
|
||||
this.extensionSettings[enabled]();
|
||||
localStorage.removeItem('enabledExtension');
|
||||
}
|
||||
}
|
||||
|
||||
getRequiredPermissions(permission) {
|
||||
const required: string[] = [];
|
||||
|
||||
if (permission === 'startDiscussion' || permission.indexOf('discussion.') === 0) {
|
||||
required.push('viewDiscussions');
|
||||
}
|
||||
if (permission === 'discussion.delete') {
|
||||
required.push('discussion.hide');
|
||||
}
|
||||
if (permission === 'discussion.deletePosts') {
|
||||
required.push('discussion.hidePosts');
|
||||
}
|
||||
|
||||
return required;
|
||||
}
|
||||
}
|
@@ -1,63 +0,0 @@
|
||||
import HeaderPrimary from './components/HeaderPrimary';
|
||||
import HeaderSecondary from './components/HeaderSecondary';
|
||||
import routes from './routes';
|
||||
import Application from '../common/Application';
|
||||
import Navigation from '../common/components/Navigation';
|
||||
import AdminNav from './components/AdminNav';
|
||||
|
||||
export default class AdminApplication extends Application {
|
||||
extensionSettings = {};
|
||||
|
||||
history = {
|
||||
canGoBack: () => true,
|
||||
getPrevious: () => {},
|
||||
backUrl: () => this.forum.attribute('baseUrl'),
|
||||
back: function() {
|
||||
window.location = this.backUrl();
|
||||
}
|
||||
};
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
|
||||
routes(this);
|
||||
}
|
||||
|
||||
/**
|
||||
* @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.route.mode = 'hash';
|
||||
super.mount();
|
||||
|
||||
// If an extension has just been enabled, then we will run its settings
|
||||
// callback.
|
||||
const enabled = localStorage.getItem('enabledExtension');
|
||||
if (enabled && this.extensionSettings[enabled]) {
|
||||
this.extensionSettings[enabled]();
|
||||
localStorage.removeItem('enabledExtension');
|
||||
}
|
||||
}
|
||||
|
||||
getRequiredPermissions(permission) {
|
||||
const required = [];
|
||||
|
||||
if (permission === 'startDiscussion' || permission.indexOf('discussion.') === 0) {
|
||||
required.push('viewDiscussions');
|
||||
}
|
||||
if (permission === 'discussion.delete') {
|
||||
required.push('discussion.hide');
|
||||
}
|
||||
if (permission === 'discussion.deletePosts') {
|
||||
required.push('discussion.hidePosts');
|
||||
}
|
||||
|
||||
return required;
|
||||
};
|
||||
}
|
8
js/src/admin/app.ts
Normal file
8
js/src/admin/app.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import Admin from './Admin';
|
||||
|
||||
const app = new Admin();
|
||||
|
||||
// @ts-ignore
|
||||
window.app = app;
|
||||
|
||||
export default app;
|
@@ -15,7 +15,6 @@ import AddExtensionModal from './components/AddExtensionModal';
|
||||
import ExtensionsPage from './components/ExtensionsPage';
|
||||
import AdminLinkButton from './components/AdminLinkButton';
|
||||
import PermissionGrid from './components/PermissionGrid';
|
||||
import Widget from './components/Widget';
|
||||
import MailPage from './components/MailPage';
|
||||
import UploadImageButton from './components/UploadImageButton';
|
||||
import LoadingModal from './components/LoadingModal';
|
||||
@@ -28,7 +27,7 @@ import AdminNav from './components/AdminNav';
|
||||
import EditCustomCssModal from './components/EditCustomCssModal';
|
||||
import EditGroupModal from './components/EditGroupModal';
|
||||
import routes from './routes';
|
||||
import AdminApplication from './AdminApplication';
|
||||
import Admin from './Admin';
|
||||
|
||||
export default Object.assign(compat, {
|
||||
'utils/saveSettings': saveSettings,
|
||||
@@ -46,7 +45,6 @@ export default Object.assign(compat, {
|
||||
'components/ExtensionsPage': ExtensionsPage,
|
||||
'components/AdminLinkButton': AdminLinkButton,
|
||||
'components/PermissionGrid': PermissionGrid,
|
||||
'components/Widget': Widget,
|
||||
'components/MailPage': MailPage,
|
||||
'components/UploadImageButton': UploadImageButton,
|
||||
'components/LoadingModal': LoadingModal,
|
||||
@@ -58,6 +56,6 @@ export default Object.assign(compat, {
|
||||
'components/AdminNav': AdminNav,
|
||||
'components/EditCustomCssModal': EditCustomCssModal,
|
||||
'components/EditGroupModal': EditGroupModal,
|
||||
'routes': routes,
|
||||
'AdminApplication': AdminApplication
|
||||
});
|
||||
routes: routes,
|
||||
Admin: Admin,
|
||||
}) as any;
|
@@ -1,30 +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.
|
||||
*/
|
||||
|
||||
import Modal from '../../common/components/Modal';
|
||||
|
||||
export default class AddExtensionModal extends Modal {
|
||||
className() {
|
||||
return 'AddExtensionModal Modal--small';
|
||||
}
|
||||
|
||||
title() {
|
||||
return app.translator.trans('core.admin.add_extension.title');
|
||||
}
|
||||
|
||||
content() {
|
||||
return (
|
||||
<div className="Modal-body">
|
||||
<p>{app.translator.trans('core.admin.add_extension.temporary_text')}</p>
|
||||
<p>{app.translator.trans('core.admin.add_extension.install_text', {a: <a href="https://discuss.flarum.org/t/extensions" target="_blank"/>})}</p>
|
||||
<p>{app.translator.trans('core.admin.add_extension.developer_text', {a: <a href="http://flarum.org/docs/extend" target="_blank"/>})}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
30
js/src/admin/components/AddExtensionModal.tsx
Normal file
30
js/src/admin/components/AddExtensionModal.tsx
Normal file
@@ -0,0 +1,30 @@
|
||||
import app from '../app';
|
||||
import Modal from '../../common/components/Modal';
|
||||
|
||||
export default class AddExtensionModal extends Modal {
|
||||
className() {
|
||||
return 'AddExtensionModal Modal--small';
|
||||
}
|
||||
|
||||
title() {
|
||||
return app.translator.trans('core.admin.add_extension.title');
|
||||
}
|
||||
|
||||
content() {
|
||||
return (
|
||||
<div className="Modal-body">
|
||||
<p>{app.translator.trans('core.admin.add_extension.temporary_text')}</p>
|
||||
<p>
|
||||
{app.translator.trans('core.admin.add_extension.install_text', {
|
||||
a: <a href="https://discuss.flarum.org/t/extensions" target="_blank" />,
|
||||
})}
|
||||
</p>
|
||||
<p>
|
||||
{app.translator.trans('core.admin.add_extension.developer_text', {
|
||||
a: <a href="http://flarum.org/docs/extend" target="_blank" />,
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
@@ -1,24 +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.
|
||||
*/
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
15
js/src/admin/components/AdminLinkButton.tsx
Normal file
15
js/src/admin/components/AdminLinkButton.tsx
Normal file
@@ -0,0 +1,15 @@
|
||||
import LinkButton, { LinkButtonProps } from '../../common/components/LinkButton';
|
||||
|
||||
export interface AdminLinkButtonProps extends LinkButtonProps {
|
||||
description?: string;
|
||||
}
|
||||
|
||||
export default class AdminLinkButton<T extends AdminLinkButtonProps = AdminLinkButtonProps> extends LinkButton<T> {
|
||||
getButtonContent() {
|
||||
const content = super.getButtonContent(this.props.icon, this.props.loading, this.props.children);
|
||||
|
||||
content.push(<div className="AdminLinkButton-description">{this.props.description}</div>);
|
||||
|
||||
return content;
|
||||
}
|
||||
}
|
@@ -1,78 +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.
|
||||
*/
|
||||
|
||||
import Component from '../../common/Component';
|
||||
import AdminLinkButton from './AdminLinkButton';
|
||||
import SelectDropdown from '../../common/components/SelectDropdown';
|
||||
import ItemList from '../../common/utils/ItemList';
|
||||
|
||||
export default class AdminNav extends Component {
|
||||
view() {
|
||||
return (
|
||||
<SelectDropdown
|
||||
className="AdminNav App-titleControl"
|
||||
buttonClassName="Button">
|
||||
{this.items().toArray()}
|
||||
</SelectDropdown>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build an item list of links to show in the admin navigation.
|
||||
*
|
||||
* @return {ItemList}
|
||||
*/
|
||||
items() {
|
||||
const items = new ItemList();
|
||||
|
||||
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')
|
||||
}));
|
||||
|
||||
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')
|
||||
}));
|
||||
|
||||
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')
|
||||
}));
|
||||
|
||||
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')
|
||||
}));
|
||||
|
||||
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')
|
||||
}));
|
||||
|
||||
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')
|
||||
}));
|
||||
|
||||
return items;
|
||||
}
|
||||
}
|
85
js/src/admin/components/AdminNav.tsx
Normal file
85
js/src/admin/components/AdminNav.tsx
Normal file
@@ -0,0 +1,85 @@
|
||||
import Component from '../../common/Component';
|
||||
import AdminLinkButton from './AdminLinkButton';
|
||||
import SelectDropdown from '../../common/components/SelectDropdown';
|
||||
import ItemList from '../../common/utils/ItemList';
|
||||
|
||||
export default class AdminNav<T> extends Component<T> {
|
||||
view() {
|
||||
return (
|
||||
<SelectDropdown className="AdminNav App-titleControl" buttonClassName="Button">
|
||||
{this.items().toArray()}
|
||||
</SelectDropdown>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build an item list of links to show in the admin navigation.
|
||||
*
|
||||
* @return {ItemList}
|
||||
*/
|
||||
items() {
|
||||
const items = new ItemList();
|
||||
|
||||
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'),
|
||||
})
|
||||
);
|
||||
|
||||
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'),
|
||||
})
|
||||
);
|
||||
|
||||
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'),
|
||||
})
|
||||
);
|
||||
|
||||
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'),
|
||||
})
|
||||
);
|
||||
|
||||
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'),
|
||||
})
|
||||
);
|
||||
|
||||
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'),
|
||||
})
|
||||
);
|
||||
|
||||
return items;
|
||||
}
|
||||
}
|
@@ -1,132 +0,0 @@
|
||||
import Page from './Page';
|
||||
import Button from '../../common/components/Button';
|
||||
import Switch from '../../common/components/Switch';
|
||||
import EditCustomCssModal from './EditCustomCssModal';
|
||||
import EditCustomHeaderModal from './EditCustomHeaderModal';
|
||||
import EditCustomFooterModal from './EditCustomFooterModal';
|
||||
import UploadImageButton from './UploadImageButton';
|
||||
import saveSettings from '../utils/saveSettings';
|
||||
|
||||
export default class AppearancePage extends Page {
|
||||
init() {
|
||||
super.init();
|
||||
|
||||
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');
|
||||
}
|
||||
|
||||
view() {
|
||||
return (
|
||||
<div className="AppearancePage">
|
||||
<div className="container">
|
||||
<form onsubmit={this.onsubmit.bind(this)}>
|
||||
<fieldset className="AppearancePage-colors">
|
||||
<legend>{app.translator.trans('core.admin.appearance.colors_heading')}</legend>
|
||||
<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)}/>
|
||||
</div>
|
||||
|
||||
{Switch.component({
|
||||
state: this.darkMode(),
|
||||
children: app.translator.trans('core.admin.appearance.dark_mode_label'),
|
||||
onchange: this.darkMode
|
||||
})}
|
||||
|
||||
{Switch.component({
|
||||
state: this.coloredHeader(),
|
||||
children: app.translator.trans('core.admin.appearance.colored_header_label'),
|
||||
onchange: this.coloredHeader
|
||||
})}
|
||||
|
||||
{Button.component({
|
||||
className: 'Button Button--primary',
|
||||
type: 'submit',
|
||||
children: app.translator.trans('core.admin.appearance.submit_button'),
|
||||
loading: this.loading
|
||||
})}
|
||||
</fieldset>
|
||||
</form>
|
||||
|
||||
<fieldset>
|
||||
<legend>{app.translator.trans('core.admin.appearance.logo_heading')}</legend>
|
||||
<div className="helpText">
|
||||
{app.translator.trans('core.admin.appearance.logo_text')}
|
||||
</div>
|
||||
<UploadImageButton name="logo"/>
|
||||
</fieldset>
|
||||
|
||||
<fieldset>
|
||||
<legend>{app.translator.trans('core.admin.appearance.favicon_heading')}</legend>
|
||||
<div className="helpText">
|
||||
{app.translator.trans('core.admin.appearance.favicon_text')}
|
||||
</div>
|
||||
<UploadImageButton name="favicon"/>
|
||||
</fieldset>
|
||||
|
||||
<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())
|
||||
})}
|
||||
</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())
|
||||
})}
|
||||
</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())
|
||||
})}
|
||||
</fieldset>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
onsubmit(e) {
|
||||
e.preventDefault();
|
||||
|
||||
const hex = /^#[0-9a-f]{3}([0-9a-f]{3})?$/i;
|
||||
|
||||
if (!hex.test(this.primaryColor()) || !hex.test(this.secondaryColor())) {
|
||||
alert(app.translator.trans('core.admin.appearance.enter_hex_message'));
|
||||
return;
|
||||
}
|
||||
|
||||
this.loading = true;
|
||||
|
||||
saveSettings({
|
||||
theme_primary_color: this.primaryColor(),
|
||||
theme_secondary_color: this.secondaryColor(),
|
||||
theme_dark_mode: this.darkMode(),
|
||||
theme_colored_header: this.coloredHeader()
|
||||
}).then(() => window.location.reload());
|
||||
}
|
||||
}
|
131
js/src/admin/components/AppearancePage.tsx
Normal file
131
js/src/admin/components/AppearancePage.tsx
Normal file
@@ -0,0 +1,131 @@
|
||||
import app from '../app';
|
||||
|
||||
import Page from './Page';
|
||||
import Button from '../../common/components/Button';
|
||||
import Switch from '../../common/components/Switch';
|
||||
import EditCustomCssModal from './EditCustomCssModal';
|
||||
import EditCustomHeaderModal from './EditCustomHeaderModal';
|
||||
import EditCustomFooterModal from './EditCustomFooterModal';
|
||||
import UploadImageButton from './UploadImageButton';
|
||||
import saveSettings from '../utils/saveSettings';
|
||||
|
||||
export default class AppearancePage extends Page {
|
||||
loading = false;
|
||||
primaryColor = m.prop(app.data.settings.theme_primary_color);
|
||||
secondaryColor = m.prop(app.data.settings.theme_secondary_color);
|
||||
darkMode = m.prop(app.data.settings.theme_dark_mode === '1');
|
||||
coloredHeader = m.prop(app.data.settings.theme_colored_header === '1');
|
||||
|
||||
view() {
|
||||
return (
|
||||
<div className="AppearancePage">
|
||||
<div className="container">
|
||||
<form onsubmit={this.onsubmit.bind(this)}>
|
||||
<fieldset className="AppearancePage-colors">
|
||||
<legend>{app.translator.trans('core.admin.appearance.colors_heading')}</legend>
|
||||
<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)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{Switch.component({
|
||||
state: this.darkMode(),
|
||||
children: app.translator.trans('core.admin.appearance.dark_mode_label'),
|
||||
onchange: this.darkMode,
|
||||
})}
|
||||
|
||||
{Switch.component({
|
||||
state: this.coloredHeader(),
|
||||
children: app.translator.trans('core.admin.appearance.colored_header_label'),
|
||||
onchange: this.coloredHeader,
|
||||
})}
|
||||
|
||||
{Button.component({
|
||||
className: 'Button Button--primary',
|
||||
type: 'submit',
|
||||
children: app.translator.trans('core.admin.appearance.submit_button'),
|
||||
loading: this.loading,
|
||||
})}
|
||||
</fieldset>
|
||||
</form>
|
||||
|
||||
<fieldset>
|
||||
<legend>{app.translator.trans('core.admin.appearance.logo_heading')}</legend>
|
||||
<div className="helpText">{app.translator.trans('core.admin.appearance.logo_text')}</div>
|
||||
<UploadImageButton name="logo" />
|
||||
</fieldset>
|
||||
|
||||
<fieldset>
|
||||
<legend>{app.translator.trans('core.admin.appearance.favicon_heading')}</legend>
|
||||
<div className="helpText">{app.translator.trans('core.admin.appearance.favicon_text')}</div>
|
||||
<UploadImageButton name="favicon" />
|
||||
</fieldset>
|
||||
|
||||
<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(EditCustomHeaderModal),
|
||||
})}
|
||||
</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(EditCustomFooterModal),
|
||||
})}
|
||||
</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(EditCustomCssModal),
|
||||
})}
|
||||
</fieldset>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
onsubmit(e) {
|
||||
e.preventDefault();
|
||||
|
||||
const hex = /^#[0-9a-f]{3}([0-9a-f]{3})?$/i;
|
||||
|
||||
if (!hex.test(this.primaryColor()) || !hex.test(this.secondaryColor())) {
|
||||
alert(app.translator.trans('core.admin.appearance.enter_hex_message'));
|
||||
return;
|
||||
}
|
||||
|
||||
this.loading = true;
|
||||
|
||||
saveSettings({
|
||||
theme_primary_color: this.primaryColor(),
|
||||
theme_secondary_color: this.secondaryColor(),
|
||||
theme_dark_mode: this.darkMode(),
|
||||
theme_colored_header: this.coloredHeader(),
|
||||
}).then(() => window.location.reload());
|
||||
}
|
||||
}
|
@@ -1,166 +0,0 @@
|
||||
import Page from './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';
|
||||
|
||||
export default class BasicsPage extends Page {
|
||||
init() {
|
||||
super.init();
|
||||
|
||||
this.loading = false;
|
||||
|
||||
this.fields = [
|
||||
'forum_title',
|
||||
'forum_description',
|
||||
'default_locale',
|
||||
'show_language_selector',
|
||||
'default_route',
|
||||
'welcome_title',
|
||||
'welcome_message'
|
||||
];
|
||||
this.values = {};
|
||||
|
||||
const settings = app.data.settings;
|
||||
this.fields.forEach(key => this.values[key] = m.prop(settings[key]));
|
||||
|
||||
this.localeOptions = {};
|
||||
const locales = app.data.locales;
|
||||
for (const i in locales) {
|
||||
this.localeOptions[i] = `${locales[i]} (${i})`;
|
||||
}
|
||||
|
||||
if (typeof this.values.show_language_selector() !== "number") this.values.show_language_selector(1);
|
||||
}
|
||||
|
||||
view() {
|
||||
return (
|
||||
<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_description_heading'),
|
||||
children: [
|
||||
<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)}/>
|
||||
]
|
||||
})}
|
||||
|
||||
{Object.keys(this.localeOptions).length > 1
|
||||
? FieldSet.component({
|
||||
label: app.translator.trans('core.admin.basics.default_language_heading'),
|
||||
children: [
|
||||
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'),
|
||||
})
|
||||
]
|
||||
})
|
||||
: ''}
|
||||
|
||||
{FieldSet.component({
|
||||
label: app.translator.trans('core.admin.basics.home_page_heading'),
|
||||
className: 'BasicsPage-homePage',
|
||||
children: [
|
||||
<div className="helpText">
|
||||
{app.translator.trans('core.admin.basics.home_page_text')}
|
||||
</div>,
|
||||
this.homePageItems().toArray().map(({path, label}) =>
|
||||
<label className="checkbox">
|
||||
<input type="radio" name="homePage" value={path} checked={this.values.default_route() === path} onclick={m.withAttr('value', this.values.default_route)}/>
|
||||
{label}
|
||||
</label>
|
||||
)
|
||||
]
|
||||
})}
|
||||
|
||||
{FieldSet.component({
|
||||
label: app.translator.trans('core.admin.basics.welcome_banner_heading'),
|
||||
className: 'BasicsPage-welcomeBanner',
|
||||
children: [
|
||||
<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)}/>
|
||||
</div>
|
||||
]
|
||||
})}
|
||||
|
||||
{Button.component({
|
||||
type: 'submit',
|
||||
className: 'Button Button--primary',
|
||||
children: app.translator.trans('core.admin.basics.submit_button'),
|
||||
loading: this.loading,
|
||||
disabled: !this.changed()
|
||||
})}
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
changed() {
|
||||
return this.fields.some(key => this.values[key]() !== app.data.settings[key]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a list of options for the default homepage. Each option must be an
|
||||
* object with `path` and `label` properties.
|
||||
*
|
||||
* @return {ItemList}
|
||||
* @public
|
||||
*/
|
||||
homePageItems() {
|
||||
const items = new ItemList();
|
||||
|
||||
items.add('allDiscussions', {
|
||||
path: '/all',
|
||||
label: app.translator.trans('core.admin.basics.all_discussions_label')
|
||||
});
|
||||
|
||||
return items;
|
||||
}
|
||||
|
||||
onsubmit(e) {
|
||||
e.preventDefault();
|
||||
|
||||
if (this.loading) return;
|
||||
|
||||
this.loading = true;
|
||||
app.alerts.dismiss(this.successAlert);
|
||||
|
||||
const settings = {};
|
||||
|
||||
this.fields.forEach(key => settings[key] = this.values[key]());
|
||||
|
||||
saveSettings(settings)
|
||||
.then(() => {
|
||||
app.alerts.show(this.successAlert = new Alert({type: 'success', children: app.translator.trans('core.admin.basics.saved_message')}));
|
||||
})
|
||||
.catch(() => {})
|
||||
.then(() => {
|
||||
this.loading = false;
|
||||
m.redraw();
|
||||
});
|
||||
}
|
||||
}
|
191
js/src/admin/components/BasicsPage.tsx
Normal file
191
js/src/admin/components/BasicsPage.tsx
Normal file
@@ -0,0 +1,191 @@
|
||||
import app from '../app';
|
||||
|
||||
import Page from './Page';
|
||||
import Button from '../../common/components/Button';
|
||||
import FieldSet from '../../common/components/FieldSet';
|
||||
import Select from '../../common/components/Select';
|
||||
import Switch from '../../common/components/Switch';
|
||||
import saveSettings from '../utils/saveSettings';
|
||||
import ItemList from '../../common/utils/ItemList';
|
||||
import AlertState from '../../common/states/AlertState';
|
||||
|
||||
import Stream from 'mithril/stream';
|
||||
|
||||
export default class BasicsPage extends Page {
|
||||
loading: boolean = false;
|
||||
fields: string[] = [
|
||||
'forum_title',
|
||||
'forum_description',
|
||||
'default_locale',
|
||||
'show_language_selector',
|
||||
'default_route',
|
||||
'welcome_title',
|
||||
'welcome_message',
|
||||
];
|
||||
|
||||
values: { [key: string]: Stream<any> } = {};
|
||||
|
||||
localeOptions: object = {};
|
||||
|
||||
successAlert?: number;
|
||||
|
||||
oninit(vnode) {
|
||||
super.oninit(vnode);
|
||||
|
||||
const settings = app.data.settings;
|
||||
this.fields.forEach((key) => (this.values[key] = m.prop(settings[key])));
|
||||
|
||||
const locales = app.data.locales;
|
||||
for (const i in locales) {
|
||||
this.localeOptions[i] = `${locales[i]} (${i})`;
|
||||
}
|
||||
|
||||
if (typeof this.values.show_language_selector() !== 'number') this.values.show_language_selector(1);
|
||||
}
|
||||
|
||||
view() {
|
||||
return (
|
||||
<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_description_heading'),
|
||||
children: [
|
||||
<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)}
|
||||
/>,
|
||||
],
|
||||
})}
|
||||
|
||||
{Object.keys(this.localeOptions).length > 1
|
||||
? FieldSet.component({
|
||||
label: app.translator.trans('core.admin.basics.default_language_heading'),
|
||||
children: [
|
||||
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'),
|
||||
}),
|
||||
],
|
||||
})
|
||||
: ''}
|
||||
|
||||
{FieldSet.component({
|
||||
label: app.translator.trans('core.admin.basics.home_page_heading'),
|
||||
className: 'BasicsPage-homePage',
|
||||
children: [
|
||||
<div className="helpText">{app.translator.trans('core.admin.basics.home_page_text')}</div>,
|
||||
this.homePageItems()
|
||||
.toArray()
|
||||
.map(({ path, label }) => (
|
||||
<label className="checkbox">
|
||||
<input
|
||||
type="radio"
|
||||
name="homePage"
|
||||
value={path}
|
||||
checked={this.values.default_route() === path}
|
||||
onclick={m.withAttr('value', this.values.default_route)}
|
||||
/>
|
||||
{label}
|
||||
</label>
|
||||
)),
|
||||
],
|
||||
})}
|
||||
|
||||
{FieldSet.component({
|
||||
label: app.translator.trans('core.admin.basics.welcome_banner_heading'),
|
||||
className: 'BasicsPage-welcomeBanner',
|
||||
children: [
|
||||
<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)}
|
||||
/>
|
||||
</div>,
|
||||
],
|
||||
})}
|
||||
|
||||
{Button.component({
|
||||
type: 'submit',
|
||||
className: 'Button Button--primary',
|
||||
children: app.translator.trans('core.admin.basics.submit_button'),
|
||||
loading: this.loading,
|
||||
disabled: !this.changed(),
|
||||
})}
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
changed() {
|
||||
return this.fields.some((key) => this.values[key]() !== app.data.settings[key]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a list of options for the default homepage. Each option must be an
|
||||
* object with `path` and `label` properties.
|
||||
*
|
||||
* @return {ItemList}
|
||||
* @public
|
||||
*/
|
||||
homePageItems() {
|
||||
const items = new ItemList();
|
||||
|
||||
items.add('allDiscussions', {
|
||||
path: '/all',
|
||||
label: app.translator.trans('core.admin.basics.all_discussions_label'),
|
||||
});
|
||||
|
||||
return items;
|
||||
}
|
||||
|
||||
onsubmit(e) {
|
||||
e.preventDefault();
|
||||
|
||||
if (this.loading) return;
|
||||
|
||||
this.loading = true;
|
||||
app.alerts.dismiss(this.successAlert);
|
||||
|
||||
const settings = {};
|
||||
|
||||
this.fields.forEach((key) => (settings[key] = this.values[key]()));
|
||||
|
||||
saveSettings(settings)
|
||||
.then(() => {
|
||||
this.successAlert = app.alerts.show({ type: 'success', children: app.translator.trans('core.admin.basics.saved_message') });
|
||||
})
|
||||
.catch(() => {})
|
||||
.then(() => {
|
||||
this.loading = false;
|
||||
m.redraw();
|
||||
});
|
||||
}
|
||||
}
|
@@ -1,18 +0,0 @@
|
||||
import Page from './Page';
|
||||
import StatusWidget from './StatusWidget';
|
||||
|
||||
export default class DashboardPage extends Page {
|
||||
view() {
|
||||
return (
|
||||
<div className="DashboardPage">
|
||||
<div className="container">
|
||||
{this.availableWidgets()}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
availableWidgets() {
|
||||
return [<StatusWidget/>];
|
||||
}
|
||||
}
|
16
js/src/admin/components/DashboardPage.tsx
Normal file
16
js/src/admin/components/DashboardPage.tsx
Normal file
@@ -0,0 +1,16 @@
|
||||
import Page from './Page';
|
||||
import StatusWidget from './StatusWidget';
|
||||
|
||||
export default class DashboardPage extends Page {
|
||||
view() {
|
||||
return (
|
||||
<div className="DashboardPage">
|
||||
<div className="container">{this.availableWidgets()}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
availableWidgets() {
|
||||
return [<StatusWidget />];
|
||||
}
|
||||
}
|
@@ -1,38 +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.
|
||||
*/
|
||||
|
||||
import Component from '../../common/Component';
|
||||
|
||||
export default class Widget extends Component {
|
||||
view() {
|
||||
return (
|
||||
<div className={"Widget "+this.className()}>
|
||||
{this.content()}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the class name to apply to the widget.
|
||||
*
|
||||
* @return {String}
|
||||
*/
|
||||
className() {
|
||||
return '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the content of the widget.
|
||||
*
|
||||
* @return {VirtualElement}
|
||||
*/
|
||||
content() {
|
||||
return [];
|
||||
}
|
||||
}
|
23
js/src/admin/components/DashboardWidget.tsx
Normal file
23
js/src/admin/components/DashboardWidget.tsx
Normal file
@@ -0,0 +1,23 @@
|
||||
import Component from '../../common/Component';
|
||||
|
||||
export default abstract class DashboardWidget extends Component {
|
||||
view() {
|
||||
return <div className={'DashboardWidget ' + this.className()}>{this.content()}</div>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the class name to apply to the widget.
|
||||
*
|
||||
* @return {String}
|
||||
*/
|
||||
className() {
|
||||
return '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the content of the widget.
|
||||
*
|
||||
* @return {VirtualElement}
|
||||
*/
|
||||
abstract content(): JSX.Element;
|
||||
}
|
@@ -1,24 +0,0 @@
|
||||
import SettingsModal from './SettingsModal';
|
||||
|
||||
export default class EditCustomCssModal extends SettingsModal {
|
||||
className() {
|
||||
return 'EditCustomCssModal Modal--large';
|
||||
}
|
||||
|
||||
title() {
|
||||
return app.translator.trans('core.admin.edit_css.title');
|
||||
}
|
||||
|
||||
form() {
|
||||
return [
|
||||
<p>{app.translator.trans('core.admin.edit_css.customize_text', {a: <a href="https://github.com/flarum/core/tree/master/less" target="_blank"/>})}</p>,
|
||||
<div className="Form-group">
|
||||
<textarea className="FormControl" rows="30" bidi={this.setting('custom_less')}/>
|
||||
</div>
|
||||
];
|
||||
}
|
||||
|
||||
onsaved() {
|
||||
window.location.reload();
|
||||
}
|
||||
}
|
28
js/src/admin/components/EditCustomCssModal.tsx
Normal file
28
js/src/admin/components/EditCustomCssModal.tsx
Normal file
@@ -0,0 +1,28 @@
|
||||
import SettingsModal from './SettingsModal';
|
||||
|
||||
export default class EditCustomCssModal extends SettingsModal {
|
||||
className() {
|
||||
return 'EditCustomCssModal Modal--large';
|
||||
}
|
||||
|
||||
title() {
|
||||
return app.translator.trans('core.admin.edit_css.title');
|
||||
}
|
||||
|
||||
form() {
|
||||
return [
|
||||
<p>
|
||||
{app.translator.trans('core.admin.edit_css.customize_text', {
|
||||
a: <a href="https://github.com/flarum/core/tree/master/less" target="_blank" />,
|
||||
})}
|
||||
</p>,
|
||||
<div className="Form-group">
|
||||
<textarea className="FormControl" rows="30" bidi={this.setting('custom_less')} />
|
||||
</div>,
|
||||
];
|
||||
}
|
||||
|
||||
onsaved() {
|
||||
window.location.reload();
|
||||
}
|
||||
}
|
@@ -1,24 +0,0 @@
|
||||
import SettingsModal from './SettingsModal';
|
||||
|
||||
export default class EditCustomFooterModal extends SettingsModal {
|
||||
className() {
|
||||
return 'EditCustomFooterModal Modal--large';
|
||||
}
|
||||
|
||||
title() {
|
||||
return app.translator.trans('core.admin.edit_footer.title');
|
||||
}
|
||||
|
||||
form() {
|
||||
return [
|
||||
<p>{app.translator.trans('core.admin.edit_footer.customize_text')}</p>,
|
||||
<div className="Form-group">
|
||||
<textarea className="FormControl" rows="30" bidi={this.setting('custom_footer')}/>
|
||||
</div>
|
||||
];
|
||||
}
|
||||
|
||||
onsaved() {
|
||||
window.location.reload();
|
||||
}
|
||||
}
|
24
js/src/admin/components/EditCustomFooterModal.tsx
Normal file
24
js/src/admin/components/EditCustomFooterModal.tsx
Normal file
@@ -0,0 +1,24 @@
|
||||
import SettingsModal from './SettingsModal';
|
||||
|
||||
export default class EditCustomFooterModal extends SettingsModal {
|
||||
className() {
|
||||
return 'EditCustomFooterModal Modal--large';
|
||||
}
|
||||
|
||||
title() {
|
||||
return app.translator.trans('core.admin.edit_footer.title');
|
||||
}
|
||||
|
||||
form() {
|
||||
return [
|
||||
<p>{app.translator.trans('core.admin.edit_footer.customize_text')}</p>,
|
||||
<div className="Form-group">
|
||||
<textarea className="FormControl" rows="30" bidi={this.setting('custom_footer')} />
|
||||
</div>,
|
||||
];
|
||||
}
|
||||
|
||||
onsaved() {
|
||||
window.location.reload();
|
||||
}
|
||||
}
|
@@ -1,24 +0,0 @@
|
||||
import SettingsModal from './SettingsModal';
|
||||
|
||||
export default class EditCustomHeaderModal extends SettingsModal {
|
||||
className() {
|
||||
return 'EditCustomHeaderModal Modal--large';
|
||||
}
|
||||
|
||||
title() {
|
||||
return app.translator.trans('core.admin.edit_header.title');
|
||||
}
|
||||
|
||||
form() {
|
||||
return [
|
||||
<p>{app.translator.trans('core.admin.edit_header.customize_text')}</p>,
|
||||
<div className="Form-group">
|
||||
<textarea className="FormControl" rows="30" bidi={this.setting('custom_header')}/>
|
||||
</div>
|
||||
];
|
||||
}
|
||||
|
||||
onsaved() {
|
||||
window.location.reload();
|
||||
}
|
||||
}
|
24
js/src/admin/components/EditCustomHeaderModal.tsx
Normal file
24
js/src/admin/components/EditCustomHeaderModal.tsx
Normal file
@@ -0,0 +1,24 @@
|
||||
import SettingsModal from './SettingsModal';
|
||||
|
||||
export default class EditCustomHeaderModal extends SettingsModal {
|
||||
className() {
|
||||
return 'EditCustomHeaderModal Modal--large';
|
||||
}
|
||||
|
||||
title() {
|
||||
return app.translator.trans('core.admin.edit_header.title');
|
||||
}
|
||||
|
||||
form() {
|
||||
return [
|
||||
<p>{app.translator.trans('core.admin.edit_header.customize_text')}</p>,
|
||||
<div className="Form-group">
|
||||
<textarea className="FormControl" rows="30" bidi={this.setting('custom_header')} />
|
||||
</div>,
|
||||
];
|
||||
}
|
||||
|
||||
onsaved() {
|
||||
window.location.reload();
|
||||
}
|
||||
}
|
@@ -1,111 +0,0 @@
|
||||
import Modal from '../../common/components/Modal';
|
||||
import Button from '../../common/components/Button';
|
||||
import Badge from '../../common/components/Badge';
|
||||
import Group from '../../common/models/Group';
|
||||
import ItemList from '../../common/utils/ItemList';
|
||||
|
||||
/**
|
||||
* 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');
|
||||
|
||||
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() || '');
|
||||
}
|
||||
|
||||
className() {
|
||||
return 'EditGroupModal Modal--small';
|
||||
}
|
||||
|
||||
title() {
|
||||
return [
|
||||
this.color() || this.icon() ? Badge.component({
|
||||
icon: this.icon(),
|
||||
style: {backgroundColor: this.color()}
|
||||
}) : '',
|
||||
' ',
|
||||
this.namePlural() || app.translator.trans('core.admin.edit_group.title')
|
||||
];
|
||||
}
|
||||
|
||||
content() {
|
||||
return (
|
||||
<div className="Modal-body">
|
||||
<div className="Form">
|
||||
{this.fields().toArray()}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
fields() {
|
||||
const items = new ItemList();
|
||||
|
||||
items.add('name', <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)}/>
|
||||
</div>
|
||||
</div>, 30);
|
||||
|
||||
items.add('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)}/>
|
||||
</div>, 20);
|
||||
|
||||
items.add('icon', <div className="Form-group">
|
||||
<label>{app.translator.trans('core.admin.edit_group.icon_label')}</label>
|
||||
<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)}/>
|
||||
</div>, 10);
|
||||
|
||||
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')
|
||||
})}
|
||||
{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')}
|
||||
</button>
|
||||
) : ''}
|
||||
</div>, -10);
|
||||
|
||||
return items;
|
||||
}
|
||||
|
||||
onsubmit(e) {
|
||||
e.preventDefault();
|
||||
|
||||
this.loading = true;
|
||||
|
||||
this.group.save({
|
||||
nameSingular: this.nameSingular(),
|
||||
namePlural: this.namePlural(),
|
||||
color: this.color(),
|
||||
icon: this.icon()
|
||||
}, {errorHandler: this.onerror.bind(this)})
|
||||
.then(this.hide.bind(this))
|
||||
.catch(() => {
|
||||
this.loading = false;
|
||||
m.redraw();
|
||||
});
|
||||
}
|
||||
|
||||
deleteGroup() {
|
||||
if (confirm(app.translator.trans('core.admin.edit_group.delete_confirmation'))) {
|
||||
this.group.delete().then(() => m.redraw());
|
||||
this.hide();
|
||||
}
|
||||
}
|
||||
}
|
157
js/src/admin/components/EditGroupModal.tsx
Normal file
157
js/src/admin/components/EditGroupModal.tsx
Normal file
@@ -0,0 +1,157 @@
|
||||
import app from '../app';
|
||||
import { ComponentProps } from '../../common/Component';
|
||||
import Modal from '../../common/components/Modal';
|
||||
import Button from '../../common/components/Button';
|
||||
import Badge from '../../common/components/Badge';
|
||||
import Group from '../../common/models/Group';
|
||||
import ItemList from '../../common/utils/ItemList';
|
||||
|
||||
import Stream from 'mithril/stream';
|
||||
|
||||
/**
|
||||
* The `EditGroupModal` component shows a modal dialog which allows the user
|
||||
* to create or edit a group.
|
||||
*/
|
||||
export default class EditGroupModal extends Modal<ComponentProps> {
|
||||
group: Group;
|
||||
|
||||
nameSingular: Stream<string>;
|
||||
namePlural: Stream<string>;
|
||||
icon: Stream<string>;
|
||||
color: Stream<string>;
|
||||
|
||||
oninit(vnode) {
|
||||
super.oninit(vnode);
|
||||
|
||||
this.group = this.props.group || app.store.createRecord('groups');
|
||||
|
||||
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() || '');
|
||||
}
|
||||
|
||||
className() {
|
||||
return 'EditGroupModal Modal--small';
|
||||
}
|
||||
|
||||
title() {
|
||||
return [
|
||||
this.color() || this.icon()
|
||||
? Badge.component({
|
||||
icon: this.icon(),
|
||||
style: { backgroundColor: this.color() },
|
||||
})
|
||||
: '',
|
||||
' ',
|
||||
this.namePlural() || app.translator.trans('core.admin.edit_group.title'),
|
||||
];
|
||||
}
|
||||
|
||||
content() {
|
||||
return (
|
||||
<div className="Modal-body">
|
||||
<div className="Form">{this.fields().toArray()}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
fields() {
|
||||
const items = new ItemList();
|
||||
|
||||
items.add(
|
||||
'name',
|
||||
<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.transText('core.admin.edit_group.singular_placeholder')}
|
||||
value={this.nameSingular()}
|
||||
oninput={m.withAttr('value', this.nameSingular)}
|
||||
/>
|
||||
<input
|
||||
className="FormControl"
|
||||
placeholder={app.translator.transText('core.admin.edit_group.plural_placeholder')}
|
||||
value={this.namePlural()}
|
||||
oninput={m.withAttr('value', this.namePlural)}
|
||||
/>
|
||||
</div>
|
||||
</div>,
|
||||
30
|
||||
);
|
||||
|
||||
items.add(
|
||||
'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)} />
|
||||
</div>,
|
||||
20
|
||||
);
|
||||
|
||||
items.add(
|
||||
'icon',
|
||||
<div className="Form-group">
|
||||
<label>{app.translator.trans('core.admin.edit_group.icon_label')}</label>
|
||||
<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)} />
|
||||
</div>,
|
||||
10
|
||||
);
|
||||
|
||||
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'),
|
||||
})}
|
||||
{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')}
|
||||
</button>
|
||||
) : (
|
||||
''
|
||||
)}
|
||||
</div>,
|
||||
-10
|
||||
);
|
||||
|
||||
return items;
|
||||
}
|
||||
|
||||
submitData() {
|
||||
return {
|
||||
nameSingular: this.nameSingular(),
|
||||
namePlural: this.namePlural(),
|
||||
color: this.color(),
|
||||
icon: this.icon(),
|
||||
};
|
||||
}
|
||||
|
||||
onsubmit(e) {
|
||||
e.preventDefault();
|
||||
|
||||
this.loading = true;
|
||||
|
||||
this.group
|
||||
.save(this.submitData(), { errorHandler: this.onerror.bind(this) })
|
||||
.then(this.hide.bind(this))
|
||||
.catch(() => {
|
||||
this.loading = false;
|
||||
m.redraw();
|
||||
});
|
||||
}
|
||||
|
||||
deleteGroup() {
|
||||
if (confirm(app.translator.transText('core.admin.edit_group.delete_confirmation'))) {
|
||||
this.group.delete().then(() => m.redraw());
|
||||
this.hide();
|
||||
}
|
||||
}
|
||||
}
|
@@ -1,117 +0,0 @@
|
||||
import Page from './Page';
|
||||
import LinkButton from '../../common/components/LinkButton';
|
||||
import Button from '../../common/components/Button';
|
||||
import Dropdown from '../../common/components/Dropdown';
|
||||
import Separator from '../../common/components/Separator';
|
||||
import AddExtensionModal from './AddExtensionModal';
|
||||
import LoadingModal from './LoadingModal';
|
||||
import ItemList from '../../common/utils/ItemList';
|
||||
import icon from '../../common/helpers/icon';
|
||||
import listItems from '../../common/helpers/listItems';
|
||||
|
||||
export default class ExtensionsPage extends Page {
|
||||
view() {
|
||||
return (
|
||||
<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())
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="ExtensionsPage-list">
|
||||
<div className="container">
|
||||
<ul className="ExtensionList">
|
||||
{Object.keys(app.data.extensions)
|
||||
.map(id => {
|
||||
const extension = app.data.extensions[id];
|
||||
const controls = this.controlItems(extension.id).toArray();
|
||||
|
||||
return <li className={'ExtensionListItem ' + (!this.isEnabled(extension.id) ? 'disabled' : '')}>
|
||||
<div className="ExtensionListItem-content">
|
||||
<span className="ExtensionListItem-icon ExtensionIcon" style={extension.icon}>
|
||||
{extension.icon ? icon(extension.icon.name) : ''}
|
||||
</span>
|
||||
{controls.length ? (
|
||||
<Dropdown
|
||||
className="ExtensionListItem-controls"
|
||||
buttonClassName="Button Button--icon Button--flat"
|
||||
menuClassName="Dropdown-menu--right"
|
||||
icon="fas fa-ellipsis-h">
|
||||
{controls}
|
||||
</Dropdown>
|
||||
) : ''}
|
||||
<div className="ExtensionListItem-main">
|
||||
<label className="ExtensionListItem-title">
|
||||
<input type="checkbox" checked={this.isEnabled(extension.id)} onclick={this.toggle.bind(this, extension.id)}/> {' '}
|
||||
{extension.extra['flarum-extension'].title}
|
||||
</label>
|
||||
<div className="ExtensionListItem-version">{extension.version}</div>
|
||||
<div className="ExtensionListItem-description">{extension.description}</div>
|
||||
</div>
|
||||
</div>
|
||||
</li>;
|
||||
})}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
controlItems(name) {
|
||||
const items = new ItemList();
|
||||
const enabled = this.isEnabled(name);
|
||||
|
||||
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]
|
||||
}));
|
||||
}
|
||||
|
||||
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());
|
||||
|
||||
app.modal.show(new LoadingModal());
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
return items;
|
||||
}
|
||||
|
||||
isEnabled(name) {
|
||||
const enabled = JSON.parse(app.data.settings.extensions_enabled);
|
||||
|
||||
return enabled.indexOf(name) !== -1;
|
||||
}
|
||||
|
||||
toggle(id) {
|
||||
const enabled = this.isEnabled(id);
|
||||
|
||||
app.request({
|
||||
url: app.forum.attribute('apiUrl') + '/extensions/' + id,
|
||||
method: 'PATCH',
|
||||
data: {enabled: !enabled}
|
||||
}).then(() => {
|
||||
if (!enabled) localStorage.setItem('enabledExtension', id);
|
||||
window.location.reload();
|
||||
});
|
||||
|
||||
app.modal.show(new LoadingModal());
|
||||
}
|
||||
}
|
130
js/src/admin/components/ExtensionsPage.tsx
Normal file
130
js/src/admin/components/ExtensionsPage.tsx
Normal file
@@ -0,0 +1,130 @@
|
||||
import app from '../app';
|
||||
|
||||
import Page from './Page';
|
||||
import Button from '../../common/components/Button';
|
||||
import Dropdown from '../../common/components/Dropdown';
|
||||
import AddExtensionModal from './AddExtensionModal';
|
||||
import LoadingModal from './LoadingModal';
|
||||
import ItemList from '../../common/utils/ItemList';
|
||||
import icon from '../../common/helpers/icon';
|
||||
|
||||
export default class ExtensionsPage extends Page {
|
||||
view() {
|
||||
return (
|
||||
<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(AddExtensionModal),
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="ExtensionsPage-list">
|
||||
<div className="container">
|
||||
<ul className="ExtensionList">
|
||||
{Object.keys(app.data.extensions).map((id) => {
|
||||
const extension = app.data.extensions[id];
|
||||
const controls = this.controlItems(extension.id).toArray();
|
||||
|
||||
return (
|
||||
<li className={'ExtensionListItem ' + (!this.isEnabled(extension.id) ? 'disabled' : '')}>
|
||||
<div className="ExtensionListItem-content">
|
||||
<span className="ExtensionListItem-icon ExtensionIcon" style={extension.icon}>
|
||||
{extension.icon ? icon(extension.icon.name) : ''}
|
||||
</span>
|
||||
{controls.length ? (
|
||||
<Dropdown
|
||||
className="ExtensionListItem-controls"
|
||||
buttonClassName="Button Button--icon Button--flat"
|
||||
menuClassName="Dropdown-menu--right"
|
||||
icon="fas fa-ellipsis-h"
|
||||
>
|
||||
{controls}
|
||||
</Dropdown>
|
||||
) : (
|
||||
''
|
||||
)}
|
||||
<div className="ExtensionListItem-main">
|
||||
<label className="ExtensionListItem-title">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={this.isEnabled(extension.id)}
|
||||
onclick={this.toggle.bind(this, extension.id)}
|
||||
/>{' '}
|
||||
{extension.extra['flarum-extension'].title}
|
||||
</label>
|
||||
<div className="ExtensionListItem-version">{extension.version}</div>
|
||||
<div className="ExtensionListItem-description">{extension.description}</div>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
controlItems(name) {
|
||||
const items = new ItemList();
|
||||
const enabled = this.isEnabled(name);
|
||||
|
||||
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],
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
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());
|
||||
|
||||
app.modal.show(LoadingModal);
|
||||
},
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
return items;
|
||||
}
|
||||
|
||||
isEnabled(name) {
|
||||
const enabled = JSON.parse(app.data.settings.extensions_enabled);
|
||||
|
||||
return enabled.indexOf(name) !== -1;
|
||||
}
|
||||
|
||||
toggle(id) {
|
||||
const enabled = this.isEnabled(id);
|
||||
|
||||
app.request({
|
||||
url: app.forum.attribute('apiUrl') + '/extensions/' + id,
|
||||
method: 'PATCH',
|
||||
body: { enabled: !enabled },
|
||||
}).then(() => {
|
||||
if (!enabled) localStorage.setItem('enabledExtension', id);
|
||||
window.location.reload();
|
||||
});
|
||||
|
||||
app.modal.show(LoadingModal);
|
||||
}
|
||||
}
|
@@ -1,33 +0,0 @@
|
||||
import Component from '../../common/Component';
|
||||
import ItemList from '../../common/utils/ItemList';
|
||||
import listItems from '../../common/helpers/listItems';
|
||||
|
||||
/**
|
||||
* The `HeaderPrimary` component displays primary header controls. On the
|
||||
* default skin, these are shown just to the right of the forum title.
|
||||
*/
|
||||
export default class HeaderPrimary extends Component {
|
||||
view() {
|
||||
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.
|
||||
*
|
||||
* @return {ItemList}
|
||||
*/
|
||||
items() {
|
||||
return new ItemList();
|
||||
}
|
||||
}
|
22
js/src/admin/components/HeaderPrimary.tsx
Normal file
22
js/src/admin/components/HeaderPrimary.tsx
Normal file
@@ -0,0 +1,22 @@
|
||||
import Component from '../../common/Component';
|
||||
import ItemList from '../../common/utils/ItemList';
|
||||
import listItems from '../../common/helpers/listItems';
|
||||
|
||||
/**
|
||||
* The `HeaderPrimary` component displays primary header controls. On the
|
||||
* default skin, these are shown just to the right of the forum title.
|
||||
*/
|
||||
export default class HeaderPrimary extends Component {
|
||||
view() {
|
||||
return <ul className="Header-controls">{listItems(this.items().toArray())}</ul>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build an item list for the controls.
|
||||
*
|
||||
* @return {ItemList}
|
||||
*/
|
||||
items() {
|
||||
return new ItemList();
|
||||
}
|
||||
}
|
@@ -1,37 +0,0 @@
|
||||
import Component from '../../common/Component';
|
||||
import SessionDropdown from './SessionDropdown';
|
||||
import ItemList from '../../common/utils/ItemList';
|
||||
import listItems from '../../common/helpers/listItems';
|
||||
|
||||
/**
|
||||
* The `HeaderSecondary` component displays secondary header controls.
|
||||
*/
|
||||
export default class HeaderSecondary extends Component {
|
||||
view() {
|
||||
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.
|
||||
*
|
||||
* @return {ItemList}
|
||||
*/
|
||||
items() {
|
||||
const items = new ItemList();
|
||||
|
||||
items.add('session', SessionDropdown.component());
|
||||
|
||||
return items;
|
||||
}
|
||||
}
|
26
js/src/admin/components/HeaderSecondary.tsx
Normal file
26
js/src/admin/components/HeaderSecondary.tsx
Normal file
@@ -0,0 +1,26 @@
|
||||
import Component from '../../common/Component';
|
||||
import SessionDropdown from './SessionDropdown';
|
||||
import ItemList from '../../common/utils/ItemList';
|
||||
import listItems from '../../common/helpers/listItems';
|
||||
|
||||
/**
|
||||
* The `HeaderSecondary` component displays secondary header controls.
|
||||
*/
|
||||
export default class HeaderSecondary extends Component {
|
||||
view() {
|
||||
return <ul className="Header-controls">{listItems(this.items().toArray())}</ul>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build an item list for the controls.
|
||||
*
|
||||
* @return {ItemList}
|
||||
*/
|
||||
items() {
|
||||
const items = new ItemList();
|
||||
|
||||
items.add('session', SessionDropdown.component());
|
||||
|
||||
return items;
|
||||
}
|
||||
}
|
@@ -1,19 +0,0 @@
|
||||
import Modal from '../../common/components/Modal';
|
||||
|
||||
export default class LoadingModal extends Modal {
|
||||
isDismissible() {
|
||||
return false;
|
||||
}
|
||||
|
||||
className() {
|
||||
return 'LoadingModal Modal--small';
|
||||
}
|
||||
|
||||
title() {
|
||||
return app.translator.trans('core.admin.loading.title');
|
||||
}
|
||||
|
||||
content() {
|
||||
return '';
|
||||
}
|
||||
}
|
20
js/src/admin/components/LoadingModal.tsx
Normal file
20
js/src/admin/components/LoadingModal.tsx
Normal file
@@ -0,0 +1,20 @@
|
||||
import { ComponentProps } from '../../common/Component';
|
||||
import Modal from '../../common/components/Modal';
|
||||
|
||||
export default class LoadingModal extends Modal<ComponentProps> {
|
||||
isDismissible() {
|
||||
return false;
|
||||
}
|
||||
|
||||
className() {
|
||||
return 'LoadingModal Modal--small';
|
||||
}
|
||||
|
||||
title() {
|
||||
return app.translator.transText('core.admin.loading.title');
|
||||
}
|
||||
|
||||
content() {
|
||||
return '';
|
||||
}
|
||||
}
|
@@ -1,124 +0,0 @@
|
||||
import Page from './Page';
|
||||
import FieldSet from '../../common/components/FieldSet';
|
||||
import Button from '../../common/components/Button';
|
||||
import Alert from '../../common/components/Alert';
|
||||
import saveSettings from '../utils/saveSettings';
|
||||
|
||||
export default class MailPage extends Page {
|
||||
init() {
|
||||
super.init();
|
||||
|
||||
this.loading = false;
|
||||
|
||||
this.fields = [
|
||||
'mail_driver',
|
||||
'mail_host',
|
||||
'mail_from',
|
||||
'mail_port',
|
||||
'mail_username',
|
||||
'mail_password',
|
||||
'mail_encryption'
|
||||
];
|
||||
this.values = {};
|
||||
|
||||
const settings = app.data.settings;
|
||||
this.fields.forEach(key => this.values[key] = m.prop(settings[key]));
|
||||
|
||||
this.localeOptions = {};
|
||||
const locales = app.locales;
|
||||
for (const i in locales) {
|
||||
this.localeOptions[i] = `${locales[i]} (${i})`;
|
||||
}
|
||||
}
|
||||
|
||||
view() {
|
||||
return (
|
||||
<div className="MailPage">
|
||||
<div className="container">
|
||||
<form onsubmit={this.onsubmit.bind(this)}>
|
||||
<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.server_heading'),
|
||||
className: 'MailPage-MailSettings',
|
||||
children: [
|
||||
<div className="MailPage-MailSettings-input">
|
||||
<label>{app.translator.trans('core.admin.email.driver_label')}</label>
|
||||
<input className="FormControl" value={this.values.mail_driver() || ''} oninput={m.withAttr('value', this.values.mail_driver)} />
|
||||
<label>{app.translator.trans('core.admin.email.host_label')}</label>
|
||||
<input className="FormControl" value={this.values.mail_host() || ''} oninput={m.withAttr('value', this.values.mail_host)} />
|
||||
<label>{app.translator.trans('core.admin.email.port_label')}</label>
|
||||
<input className="FormControl" value={this.values.mail_port() || ''} oninput={m.withAttr('value', this.values.mail_port)} />
|
||||
<label>{app.translator.trans('core.admin.email.encryption_label')}</label>
|
||||
<input className="FormControl" value={this.values.mail_encryption() || ''} oninput={m.withAttr('value', this.values.mail_encryption)} />
|
||||
</div>
|
||||
]
|
||||
})}
|
||||
|
||||
{FieldSet.component({
|
||||
label: app.translator.trans('core.admin.email.account_heading'),
|
||||
className: 'MailPage-MailSettings',
|
||||
children: [
|
||||
<div className="MailPage-MailSettings-input">
|
||||
<label>{app.translator.trans('core.admin.email.username_label')}</label>
|
||||
<input className="FormControl" value={this.values.mail_username() || ''} oninput={m.withAttr('value', this.values.mail_username)} />
|
||||
<label>{app.translator.trans('core.admin.email.password_label')}</label>
|
||||
<input className="FormControl" value={this.values.mail_password() || ''} oninput={m.withAttr('value', this.values.mail_password)} />
|
||||
</div>
|
||||
]
|
||||
})}
|
||||
|
||||
{FieldSet.component({
|
||||
label: app.translator.trans('core.admin.email.addresses_heading'),
|
||||
className: 'MailPage-MailSettings',
|
||||
children: [
|
||||
<div className="MailPage-MailSettings-input">
|
||||
<label>{app.translator.trans('core.admin.email.from_label')}</label>
|
||||
<input className="FormControl" value={this.values.mail_from() || ''} oninput={m.withAttr('value', this.values.mail_from)} />
|
||||
</div>
|
||||
]
|
||||
})}
|
||||
|
||||
{Button.component({
|
||||
type: 'submit',
|
||||
className: 'Button Button--primary',
|
||||
children: app.translator.trans('core.admin.email.submit_button'),
|
||||
loading: this.loading,
|
||||
disabled: !this.changed()
|
||||
})}
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
changed() {
|
||||
return this.fields.some(key => this.values[key]() !== app.data.settings[key]);
|
||||
}
|
||||
|
||||
onsubmit(e) {
|
||||
e.preventDefault();
|
||||
|
||||
if (this.loading) return;
|
||||
|
||||
this.loading = true;
|
||||
app.alerts.dismiss(this.successAlert);
|
||||
|
||||
const settings = {};
|
||||
|
||||
this.fields.forEach(key => settings[key] = this.values[key]());
|
||||
|
||||
saveSettings(settings)
|
||||
.then(() => {
|
||||
app.alerts.show(this.successAlert = new Alert({type: 'success', children: app.translator.trans('core.admin.basics.saved_message')}));
|
||||
})
|
||||
.catch(() => {})
|
||||
.then(() => {
|
||||
this.loading = false;
|
||||
m.redraw();
|
||||
});
|
||||
}
|
||||
}
|
191
js/src/admin/components/MailPage.tsx
Normal file
191
js/src/admin/components/MailPage.tsx
Normal file
@@ -0,0 +1,191 @@
|
||||
import app from '../app';
|
||||
|
||||
import Page from './Page';
|
||||
import Alert from '../../common/components/Alert';
|
||||
import Button from '../../common/components/Button';
|
||||
import FieldSet from '../../common/components/FieldSet';
|
||||
import LoadingIndicator from '../../common/components/LoadingIndicator';
|
||||
import Select from '../../common/components/Select';
|
||||
import saveSettings from '../utils/saveSettings';
|
||||
import AlertState from '../../common/states/AlertState';
|
||||
|
||||
import Stream from 'mithril/stream';
|
||||
|
||||
export default class MailPage extends Page {
|
||||
loading = true;
|
||||
saving = false;
|
||||
|
||||
driverFields = {};
|
||||
fields = [];
|
||||
|
||||
values: { [key: string]: Stream<any> } = {};
|
||||
|
||||
status = { sending: false, errors: {} };
|
||||
|
||||
successAlert?: number;
|
||||
|
||||
oninit(vnode) {
|
||||
super.oninit(vnode);
|
||||
|
||||
this.refresh();
|
||||
}
|
||||
|
||||
refresh() {
|
||||
this.loading = true;
|
||||
this.fields = ['mail_driver', 'mail_from'];
|
||||
this.values = {};
|
||||
|
||||
app.request({
|
||||
method: 'GET',
|
||||
url: app.forum.attribute('apiUrl') + '/mail-settings',
|
||||
}).then((response) => {
|
||||
this.driverFields = response['data']['attributes']['fields'];
|
||||
this.status.sending = response['data']['attributes']['sending'];
|
||||
this.status.errors = response['data']['attributes']['errors'];
|
||||
|
||||
for (const driver in this.driverFields) {
|
||||
for (const field in this.driverFields[driver]) {
|
||||
this.fields.push(field);
|
||||
}
|
||||
}
|
||||
|
||||
const settings = app.data.settings;
|
||||
this.fields.forEach((key) => (this.values[key] = m.prop(settings[key])));
|
||||
|
||||
this.loading = false;
|
||||
m.redraw();
|
||||
});
|
||||
}
|
||||
|
||||
view() {
|
||||
if (this.loading || this.saving) {
|
||||
return (
|
||||
<div className="MailPage">
|
||||
<div className="container">
|
||||
<LoadingIndicator />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const fields = this.driverFields[this.values.mail_driver()];
|
||||
const fieldKeys = Object.keys(fields);
|
||||
|
||||
return (
|
||||
<div className="MailPage">
|
||||
<div className="container">
|
||||
<form onsubmit={this.onsubmit.bind(this)}>
|
||||
<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: [
|
||||
<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)}
|
||||
/>
|
||||
</label>
|
||||
</div>,
|
||||
],
|
||||
})}
|
||||
|
||||
{FieldSet.component({
|
||||
label: app.translator.trans('core.admin.email.driver_heading'),
|
||||
className: 'MailPage-MailSettings',
|
||||
children: [
|
||||
<div className="MailPage-MailSettings-input">
|
||||
<label>
|
||||
{app.translator.trans('core.admin.email.driver_label')}
|
||||
<Select
|
||||
value={this.values.mail_driver()}
|
||||
options={Object.keys(this.driverFields).reduce((memo, val) => ({ ...memo, [val]: val }), {})}
|
||||
onchange={this.values.mail_driver}
|
||||
/>
|
||||
</label>
|
||||
</div>,
|
||||
],
|
||||
})}
|
||||
|
||||
{this.status.sending ||
|
||||
Alert.component({
|
||||
children: app.translator.trans('core.admin.email.not_sending_message'),
|
||||
dismissible: false,
|
||||
})}
|
||||
|
||||
{fieldKeys.length > 0 &&
|
||||
FieldSet.component({
|
||||
label: app.translator.trans(`core.admin.email.${this.values.mail_driver()}_heading`),
|
||||
className: 'MailPage-MailSettings',
|
||||
children: [
|
||||
<div className="MailPage-MailSettings-input">
|
||||
{fieldKeys.map((field) => [
|
||||
<label>
|
||||
{app.translator.trans(`core.admin.email.${field}_label`)}
|
||||
{this.renderField(field)}
|
||||
</label>,
|
||||
this.status.errors[field] && <p className="ValidationError">{this.status.errors[field]}</p>,
|
||||
])}
|
||||
</div>,
|
||||
],
|
||||
})}
|
||||
|
||||
{Button.component({
|
||||
type: 'submit',
|
||||
className: 'Button Button--primary',
|
||||
children: app.translator.trans('core.admin.email.submit_button'),
|
||||
disabled: !this.changed(),
|
||||
})}
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
renderField(name) {
|
||||
const driver = this.values.mail_driver();
|
||||
const field = this.driverFields[driver][name];
|
||||
const prop = this.values[name];
|
||||
|
||||
if (prop == undefined) {
|
||||
}
|
||||
|
||||
if (typeof field === 'string') {
|
||||
return <input className="FormControl" value={prop() || ''} oninput={m.withAttr('value', prop)} />;
|
||||
} else {
|
||||
return <Select value={prop()} options={field} onchange={prop} />;
|
||||
}
|
||||
}
|
||||
|
||||
changed() {
|
||||
return this.fields.some((key) => this.values[key]() !== app.data.settings[key]);
|
||||
}
|
||||
|
||||
onsubmit(e) {
|
||||
e.preventDefault();
|
||||
|
||||
if (this.saving) return;
|
||||
|
||||
this.saving = true;
|
||||
app.alerts.dismiss(this.successAlert);
|
||||
|
||||
const settings = {};
|
||||
|
||||
this.fields.forEach((key) => (settings[key] = this.values[key]()));
|
||||
|
||||
saveSettings(settings)
|
||||
.then(() => {
|
||||
this.successAlert = app.alerts.show({ type: 'success', children: app.translator.trans('core.admin.basics.saved_message') });
|
||||
})
|
||||
.catch(() => {})
|
||||
.then(() => {
|
||||
this.saving = false;
|
||||
this.refresh();
|
||||
});
|
||||
}
|
||||
}
|
@@ -1,32 +0,0 @@
|
||||
import Component from '../../common/Component';
|
||||
|
||||
/**
|
||||
* The `Page` component
|
||||
*
|
||||
* @abstract
|
||||
*/
|
||||
export default class Page extends Component {
|
||||
init() {
|
||||
app.previous = app.current;
|
||||
app.current = this;
|
||||
|
||||
app.modal.close();
|
||||
|
||||
/**
|
||||
* A class name to apply to the body while the route is active.
|
||||
*
|
||||
* @type {String}
|
||||
*/
|
||||
this.bodyClass = '';
|
||||
}
|
||||
|
||||
config(isInitialized, context) {
|
||||
if (isInitialized) return;
|
||||
|
||||
if (this.bodyClass) {
|
||||
$('#app').addClass(this.bodyClass);
|
||||
|
||||
context.onunload = () => $('#app').removeClass(this.bodyClass);
|
||||
}
|
||||
}
|
||||
}
|
3
js/src/admin/components/Page.tsx
Normal file
3
js/src/admin/components/Page.tsx
Normal file
@@ -0,0 +1,3 @@
|
||||
import Page from '../../common/components/Page';
|
||||
|
||||
export default Page;
|
@@ -1,149 +0,0 @@
|
||||
import Dropdown from '../../common/components/Dropdown';
|
||||
import Button from '../../common/components/Button';
|
||||
import Separator from '../../common/components/Separator';
|
||||
import Group from '../../common/models/Group';
|
||||
import Badge from '../../common/components/Badge';
|
||||
import GroupBadge from '../../common/components/GroupBadge';
|
||||
|
||||
function badgeForId(id) {
|
||||
const group = app.store.getById('groups', id);
|
||||
|
||||
return group ? GroupBadge.component({group, label: null}) : '';
|
||||
}
|
||||
|
||||
function filterByRequiredPermissions(groupIds, permission) {
|
||||
app.getRequiredPermissions(permission)
|
||||
.forEach(required => {
|
||||
const restrictToGroupIds = app.data.permissions[required] || [];
|
||||
|
||||
if (restrictToGroupIds.indexOf(Group.GUEST_ID) !== -1) {
|
||||
// do nothing
|
||||
} else if (restrictToGroupIds.indexOf(Group.MEMBER_ID) !== -1) {
|
||||
groupIds = groupIds.filter(id => id !== Group.GUEST_ID);
|
||||
} else if (groupIds.indexOf(Group.MEMBER_ID) !== -1) {
|
||||
groupIds = restrictToGroupIds;
|
||||
} else {
|
||||
groupIds = restrictToGroupIds.filter(id => groupIds.indexOf(id) !== -1);
|
||||
}
|
||||
|
||||
groupIds = filterByRequiredPermissions(groupIds, required);
|
||||
});
|
||||
|
||||
return groupIds;
|
||||
}
|
||||
|
||||
export default class PermissionDropdown extends Dropdown {
|
||||
static initProps(props) {
|
||||
super.initProps(props);
|
||||
|
||||
props.className = 'PermissionDropdown';
|
||||
props.buttonClassName = 'Button Button--text';
|
||||
}
|
||||
|
||||
view() {
|
||||
this.props.children = [];
|
||||
|
||||
let groupIds = app.data.permissions[this.props.permission] || [];
|
||||
|
||||
groupIds = filterByRequiredPermissions(groupIds, this.props.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'});
|
||||
} else if (members) {
|
||||
this.props.label = Badge.component({icon: 'fas fa-user'});
|
||||
} else {
|
||||
this.props.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)
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
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)
|
||||
}),
|
||||
|
||||
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([]);
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
[].push.apply(
|
||||
this.props.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());
|
||||
},
|
||||
disabled: this.isGroupDisabled(group.id()) && this.isGroupDisabled(Group.MEMBER_ID) && this.isGroupDisabled(Group.GUEST_ID)
|
||||
}))
|
||||
);
|
||||
}
|
||||
|
||||
return super.view();
|
||||
}
|
||||
|
||||
save(groupIds) {
|
||||
const permission = this.props.permission;
|
||||
|
||||
app.data.permissions[permission] = groupIds;
|
||||
|
||||
app.request({
|
||||
method: 'POST',
|
||||
url: app.forum.attribute('apiUrl') + '/permission',
|
||||
data: {permission, groupIds}
|
||||
});
|
||||
}
|
||||
|
||||
toggle(groupId) {
|
||||
const permission = this.props.permission;
|
||||
|
||||
let groupIds = app.data.permissions[permission] || [];
|
||||
|
||||
const index = groupIds.indexOf(groupId);
|
||||
|
||||
if (index !== -1) {
|
||||
groupIds.splice(index, 1);
|
||||
} else {
|
||||
groupIds.push(groupId);
|
||||
groupIds = groupIds.filter(id => [Group.GUEST_ID, Group.MEMBER_ID].indexOf(id) === -1);
|
||||
}
|
||||
|
||||
this.save(groupIds);
|
||||
}
|
||||
|
||||
isGroupDisabled(id) {
|
||||
return filterByRequiredPermissions([id], this.props.permission).indexOf(id) === -1;
|
||||
}
|
||||
}
|
159
js/src/admin/components/PermissionDropdown.tsx
Normal file
159
js/src/admin/components/PermissionDropdown.tsx
Normal file
@@ -0,0 +1,159 @@
|
||||
import app from '../app';
|
||||
|
||||
import Dropdown, { DropdownProps } from '../../common/components/Dropdown';
|
||||
import Button from '../../common/components/Button';
|
||||
import Separator from '../../common/components/Separator';
|
||||
import Group from '../../common/models/Group';
|
||||
import Badge from '../../common/components/Badge';
|
||||
import GroupBadge from '../../common/components/GroupBadge';
|
||||
|
||||
function badgeForId(id) {
|
||||
const group = app.store.getById('groups', id);
|
||||
|
||||
return group ? GroupBadge.component({ group, label: null }) : '';
|
||||
}
|
||||
|
||||
function filterByRequiredPermissions(groupIds, permission) {
|
||||
app.getRequiredPermissions(permission).forEach((required) => {
|
||||
const restrictToGroupIds = app.data.permissions[required] || [];
|
||||
|
||||
if (restrictToGroupIds.indexOf(Group.GUEST_ID) !== -1) {
|
||||
// do nothing
|
||||
} else if (restrictToGroupIds.indexOf(Group.MEMBER_ID) !== -1) {
|
||||
groupIds = groupIds.filter((id) => id !== Group.GUEST_ID);
|
||||
} else if (groupIds.indexOf(Group.MEMBER_ID) !== -1) {
|
||||
groupIds = restrictToGroupIds;
|
||||
} else {
|
||||
groupIds = restrictToGroupIds.filter((id) => groupIds.indexOf(id) !== -1);
|
||||
}
|
||||
|
||||
groupIds = filterByRequiredPermissions(groupIds, required);
|
||||
});
|
||||
|
||||
return groupIds;
|
||||
}
|
||||
|
||||
export interface PermissionDropdownProps extends DropdownProps {
|
||||
label?: Badge[];
|
||||
}
|
||||
|
||||
export default class PermissionDropdown<T extends PermissionDropdownProps = PermissionDropdownProps> extends Dropdown<T> {
|
||||
static initProps(props) {
|
||||
super.initProps(props);
|
||||
|
||||
props.className = 'PermissionDropdown';
|
||||
props.buttonClassName = 'Button Button--text';
|
||||
}
|
||||
|
||||
view() {
|
||||
this.props.children = [];
|
||||
|
||||
let groupIds = app.data.permissions[this.props.permission] || [];
|
||||
|
||||
groupIds = filterByRequiredPermissions(groupIds, this.props.permission);
|
||||
|
||||
const everyone = groupIds.indexOf(Group.GUEST_ID) !== -1;
|
||||
const members = groupIds.indexOf(Group.MEMBER_ID) !== -1;
|
||||
const adminGroup: Group = app.store.getById('groups', Group.ADMINISTRATOR_ID);
|
||||
|
||||
if (everyone) {
|
||||
this.props.label = Badge.component({ icon: 'fas fa-globe' });
|
||||
} else if (members) {
|
||||
this.props.label = Badge.component({ icon: 'fas fa-user' });
|
||||
} else {
|
||||
this.props.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),
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
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),
|
||||
}),
|
||||
|
||||
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([]);
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
[].push.apply(
|
||||
this.props.children,
|
||||
app.store
|
||||
.all('groups')
|
||||
.filter((group) => [Group.ADMINISTRATOR_ID, Group.GUEST_ID, Group.MEMBER_ID].indexOf(group.id()) === -1)
|
||||
.map((group: 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());
|
||||
},
|
||||
disabled:
|
||||
this.isGroupDisabled(group.id()) && this.isGroupDisabled(Group.MEMBER_ID) && this.isGroupDisabled(Group.GUEST_ID),
|
||||
})
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
return super.view();
|
||||
}
|
||||
|
||||
save(groupIds) {
|
||||
const permission = this.props.permission;
|
||||
|
||||
app.data.permissions[permission] = groupIds;
|
||||
|
||||
app.request({
|
||||
method: 'POST',
|
||||
url: app.forum.attribute('apiUrl') + '/permission',
|
||||
body: { permission, groupIds },
|
||||
});
|
||||
}
|
||||
|
||||
toggle(groupId) {
|
||||
const permission = this.props.permission;
|
||||
|
||||
let groupIds = app.data.permissions[permission] || [];
|
||||
|
||||
const index = groupIds.indexOf(groupId);
|
||||
|
||||
if (index !== -1) {
|
||||
groupIds.splice(index, 1);
|
||||
} else {
|
||||
groupIds.push(groupId);
|
||||
groupIds = groupIds.filter((id) => [Group.GUEST_ID, Group.MEMBER_ID].indexOf(id) === -1);
|
||||
}
|
||||
|
||||
this.save(groupIds);
|
||||
}
|
||||
|
||||
isGroupDisabled(id) {
|
||||
return filterByRequiredPermissions([id], this.props.permission).indexOf(id) === -1;
|
||||
}
|
||||
}
|
@@ -1,259 +0,0 @@
|
||||
import Component from '../../common/Component';
|
||||
import PermissionDropdown from './PermissionDropdown';
|
||||
import SettingDropdown from './SettingDropdown';
|
||||
import Button from '../../common/components/Button';
|
||||
import ItemList from '../../common/utils/ItemList';
|
||||
import icon from '../../common/helpers/icon';
|
||||
|
||||
export default class PermissionGrid extends Component {
|
||||
init() {
|
||||
this.permissions = this.permissionItems().toArray();
|
||||
}
|
||||
|
||||
view() {
|
||||
const scopes = this.scopeItems().toArray();
|
||||
|
||||
const permissionCells = permission => {
|
||||
return scopes.map(scope => (
|
||||
<td>
|
||||
{scope.render(permission)}
|
||||
</td>
|
||||
));
|
||||
};
|
||||
|
||||
return (
|
||||
<table className="PermissionGrid">
|
||||
<thead>
|
||||
<tr>
|
||||
<td></td>
|
||||
{scopes.map(scope => (
|
||||
<th>
|
||||
{scope.label}{' '}
|
||||
{scope.onremove ? Button.component({icon: 'fas fa-times', className: 'Button Button--text PermissionGrid-removeScope', onclick: scope.onremove}) : ''}
|
||||
</th>
|
||||
))}
|
||||
<th>{this.scopeControlItems().toArray()}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
{this.permissions.map(section => (
|
||||
<tbody>
|
||||
<tr className="PermissionGrid-section">
|
||||
<th>{section.label}</th>
|
||||
{permissionCells(section)}
|
||||
<td/>
|
||||
</tr>
|
||||
{section.children.map(child => (
|
||||
<tr className="PermissionGrid-child">
|
||||
<th>{icon(child.icon)}{child.label}</th>
|
||||
{permissionCells(child)}
|
||||
<td/>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
))}
|
||||
</table>
|
||||
);
|
||||
}
|
||||
|
||||
permissionItems() {
|
||||
const items = new ItemList();
|
||||
|
||||
items.add('view', {
|
||||
label: app.translator.trans('core.admin.permissions.read_heading'),
|
||||
children: this.viewItems().toArray()
|
||||
}, 100);
|
||||
|
||||
items.add('start', {
|
||||
label: app.translator.trans('core.admin.permissions.create_heading'),
|
||||
children: this.startItems().toArray()
|
||||
}, 90);
|
||||
|
||||
items.add('reply', {
|
||||
label: app.translator.trans('core.admin.permissions.participate_heading'),
|
||||
children: this.replyItems().toArray()
|
||||
}, 80);
|
||||
|
||||
items.add('moderate', {
|
||||
label: app.translator.trans('core.admin.permissions.moderate_heading'),
|
||||
children: this.moderateItems().toArray()
|
||||
}, 70);
|
||||
|
||||
return items;
|
||||
}
|
||||
|
||||
viewItems() {
|
||||
const items = new ItemList();
|
||||
|
||||
items.add('viewDiscussions', {
|
||||
icon: 'fas fa-eye',
|
||||
label: app.translator.trans('core.admin.permissions.view_discussions_label'),
|
||||
permission: 'viewDiscussions',
|
||||
allowGuest: true
|
||||
}, 100);
|
||||
|
||||
items.add('viewUserList', {
|
||||
icon: 'fas fa-users',
|
||||
label: app.translator.trans('core.admin.permissions.view_user_list_label'),
|
||||
permission: 'viewUserList',
|
||||
allowGuest: true
|
||||
}, 100);
|
||||
|
||||
items.add('signUp', {
|
||||
icon: 'fas fa-user-plus',
|
||||
label: app.translator.trans('core.admin.permissions.sign_up_label'),
|
||||
setting: () => SettingDropdown.component({
|
||||
key: 'allow_sign_up',
|
||||
options: [
|
||||
{value: '1', label: app.translator.trans('core.admin.permissions_controls.signup_open_button')},
|
||||
{value: '0', label: app.translator.trans('core.admin.permissions_controls.signup_closed_button')}
|
||||
]
|
||||
})
|
||||
}, 90);
|
||||
|
||||
items.add('viewLastSeenAt', {
|
||||
icon: 'far fa-clock',
|
||||
label: app.translator.trans('core.admin.permissions.view_last_seen_at_label'),
|
||||
permission: 'user.viewLastSeenAt',
|
||||
});
|
||||
|
||||
return items;
|
||||
}
|
||||
|
||||
startItems() {
|
||||
const items = new ItemList();
|
||||
|
||||
items.add('start', {
|
||||
icon: 'fas fa-edit',
|
||||
label: app.translator.trans('core.admin.permissions.start_discussions_label'),
|
||||
permission: 'startDiscussion'
|
||||
}, 100);
|
||||
|
||||
items.add('allowRenaming', {
|
||||
icon: 'fas fa-i-cursor',
|
||||
label: app.translator.trans('core.admin.permissions.allow_renaming_label'),
|
||||
setting: () => {
|
||||
const minutes = parseInt(app.data.settings.allow_renaming, 10);
|
||||
|
||||
return SettingDropdown.component({
|
||||
defaultLabel: minutes
|
||||
? app.translator.transChoice('core.admin.permissions_controls.allow_some_minutes_button', minutes, {count: minutes})
|
||||
: app.translator.trans('core.admin.permissions_controls.allow_indefinitely_button'),
|
||||
key: 'allow_renaming',
|
||||
options: [
|
||||
{value: '-1', label: app.translator.trans('core.admin.permissions_controls.allow_indefinitely_button')},
|
||||
{value: '10', label: app.translator.trans('core.admin.permissions_controls.allow_ten_minutes_button')},
|
||||
{value: 'reply', label: app.translator.trans('core.admin.permissions_controls.allow_until_reply_button')}
|
||||
]
|
||||
});
|
||||
}
|
||||
}, 90);
|
||||
|
||||
return items;
|
||||
}
|
||||
|
||||
replyItems() {
|
||||
const items = new ItemList();
|
||||
|
||||
items.add('reply', {
|
||||
icon: 'fas fa-reply',
|
||||
label: app.translator.trans('core.admin.permissions.reply_to_discussions_label'),
|
||||
permission: 'discussion.reply'
|
||||
}, 100);
|
||||
|
||||
items.add('allowPostEditing', {
|
||||
icon: 'fas fa-pencil-alt',
|
||||
label: app.translator.trans('core.admin.permissions.allow_post_editing_label'),
|
||||
setting: () => {
|
||||
const minutes = parseInt(app.data.settings.allow_post_editing, 10);
|
||||
|
||||
return SettingDropdown.component({
|
||||
defaultLabel: minutes
|
||||
? app.translator.transChoice('core.admin.permissions_controls.allow_some_minutes_button', minutes, {count: minutes})
|
||||
: app.translator.trans('core.admin.permissions_controls.allow_indefinitely_button'),
|
||||
key: 'allow_post_editing',
|
||||
options: [
|
||||
{value: '-1', label: app.translator.trans('core.admin.permissions_controls.allow_indefinitely_button')},
|
||||
{value: '10', label: app.translator.trans('core.admin.permissions_controls.allow_ten_minutes_button')},
|
||||
{value: 'reply', label: app.translator.trans('core.admin.permissions_controls.allow_until_reply_button')}
|
||||
]
|
||||
});
|
||||
}
|
||||
}, 90);
|
||||
|
||||
return items;
|
||||
}
|
||||
|
||||
moderateItems() {
|
||||
const items = new ItemList();
|
||||
|
||||
items.add('viewIpsPosts', {
|
||||
icon: 'fas fa-bullseye',
|
||||
label: app.translator.trans('core.admin.permissions.view_post_ips_label'),
|
||||
permission: 'discussion.viewIpsPosts'
|
||||
}, 110);
|
||||
|
||||
items.add('renameDiscussions', {
|
||||
icon: 'fas fa-i-cursor',
|
||||
label: app.translator.trans('core.admin.permissions.rename_discussions_label'),
|
||||
permission: 'discussion.rename'
|
||||
}, 100);
|
||||
|
||||
items.add('hideDiscussions', {
|
||||
icon: 'far fa-trash-alt',
|
||||
label: app.translator.trans('core.admin.permissions.delete_discussions_label'),
|
||||
permission: 'discussion.hide'
|
||||
}, 90);
|
||||
|
||||
items.add('deleteDiscussions', {
|
||||
icon: 'fas fa-times',
|
||||
label: app.translator.trans('core.admin.permissions.delete_discussions_forever_label'),
|
||||
permission: 'discussion.delete'
|
||||
}, 80);
|
||||
|
||||
items.add('editPosts', {
|
||||
icon: 'fas fa-pencil-alt',
|
||||
label: app.translator.trans('core.admin.permissions.edit_posts_label'),
|
||||
permission: 'discussion.editPosts'
|
||||
}, 70);
|
||||
|
||||
items.add('hidePosts', {
|
||||
icon: 'far fa-trash-alt',
|
||||
label: app.translator.trans('core.admin.permissions.delete_posts_label'),
|
||||
permission: 'discussion.hidePosts'
|
||||
}, 60);
|
||||
|
||||
items.add('deletePosts', {
|
||||
icon: 'fas fa-times',
|
||||
label: app.translator.trans('core.admin.permissions.delete_posts_forever_label'),
|
||||
permission: 'discussion.deletePosts'
|
||||
}, 60);
|
||||
|
||||
return items;
|
||||
}
|
||||
|
||||
scopeItems() {
|
||||
const items = new ItemList();
|
||||
|
||||
items.add('global', {
|
||||
label: app.translator.trans('core.admin.permissions.global_heading'),
|
||||
render: item => {
|
||||
if (item.setting) {
|
||||
return item.setting();
|
||||
} else if (item.permission) {
|
||||
return PermissionDropdown.component({
|
||||
permission: item.permission,
|
||||
allowGuest: item.allowGuest
|
||||
});
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
}, 100);
|
||||
|
||||
return items;
|
||||
}
|
||||
|
||||
scopeControlItems() {
|
||||
return new ItemList();
|
||||
}
|
||||
}
|
361
js/src/admin/components/PermissionGrid.tsx
Normal file
361
js/src/admin/components/PermissionGrid.tsx
Normal file
@@ -0,0 +1,361 @@
|
||||
import app from '../app';
|
||||
|
||||
import Component from '../../common/Component';
|
||||
import PermissionDropdown from './PermissionDropdown';
|
||||
import SettingDropdown from './SettingDropdown';
|
||||
import Button from '../../common/components/Button';
|
||||
import ItemList from '../../common/utils/ItemList';
|
||||
import icon from '../../common/helpers/icon';
|
||||
|
||||
export default class PermissionGrid extends Component {
|
||||
view() {
|
||||
const scopes = this.scopeItems().toArray();
|
||||
|
||||
const permissionCells = (permission) => {
|
||||
return scopes.map((scope) => <td>{scope.render(permission)}</td>);
|
||||
};
|
||||
|
||||
return (
|
||||
<table className="PermissionGrid">
|
||||
<thead>
|
||||
<tr>
|
||||
<td></td>
|
||||
{scopes.map((scope) => (
|
||||
<th>
|
||||
{scope.label}{' '}
|
||||
{scope.onremove
|
||||
? Button.component({
|
||||
icon: 'fas fa-times',
|
||||
className: 'Button Button--text PermissionGrid-removeScope',
|
||||
onclick: scope.onremove,
|
||||
})
|
||||
: ''}
|
||||
</th>
|
||||
))}
|
||||
<th>{this.scopeControlItems().toArray()}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
{this.permissionItems()
|
||||
.toArray()
|
||||
.map((section) => (
|
||||
<tbody>
|
||||
<tr className="PermissionGrid-section">
|
||||
<th>{section.label}</th>
|
||||
{permissionCells(section)}
|
||||
<td />
|
||||
</tr>
|
||||
{section.children.map((child) => (
|
||||
<tr className="PermissionGrid-child">
|
||||
<th>
|
||||
{icon(child.icon)}
|
||||
{child.label}
|
||||
</th>
|
||||
{permissionCells(child)}
|
||||
<td />
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
))}
|
||||
</table>
|
||||
);
|
||||
}
|
||||
|
||||
permissionItems() {
|
||||
const items = new ItemList();
|
||||
|
||||
items.add(
|
||||
'view',
|
||||
{
|
||||
label: app.translator.trans('core.admin.permissions.read_heading'),
|
||||
children: this.viewItems().toArray(),
|
||||
},
|
||||
100
|
||||
);
|
||||
|
||||
items.add(
|
||||
'start',
|
||||
{
|
||||
label: app.translator.trans('core.admin.permissions.create_heading'),
|
||||
children: this.startItems().toArray(),
|
||||
},
|
||||
90
|
||||
);
|
||||
|
||||
items.add(
|
||||
'reply',
|
||||
{
|
||||
label: app.translator.trans('core.admin.permissions.participate_heading'),
|
||||
children: this.replyItems().toArray(),
|
||||
},
|
||||
80
|
||||
);
|
||||
|
||||
items.add(
|
||||
'moderate',
|
||||
{
|
||||
label: app.translator.trans('core.admin.permissions.moderate_heading'),
|
||||
children: this.moderateItems().toArray(),
|
||||
},
|
||||
70
|
||||
);
|
||||
|
||||
return items;
|
||||
}
|
||||
|
||||
viewItems() {
|
||||
const items = new ItemList();
|
||||
|
||||
items.add(
|
||||
'viewDiscussions',
|
||||
{
|
||||
icon: 'fas fa-eye',
|
||||
label: app.translator.trans('core.admin.permissions.view_discussions_label'),
|
||||
permission: 'viewDiscussions',
|
||||
allowGuest: true,
|
||||
},
|
||||
100
|
||||
);
|
||||
|
||||
items.add(
|
||||
'viewUserList',
|
||||
{
|
||||
icon: 'fas fa-users',
|
||||
label: app.translator.trans('core.admin.permissions.view_user_list_label'),
|
||||
permission: 'viewUserList',
|
||||
allowGuest: true,
|
||||
},
|
||||
100
|
||||
);
|
||||
|
||||
items.add(
|
||||
'signUp',
|
||||
{
|
||||
icon: 'fas fa-user-plus',
|
||||
label: app.translator.trans('core.admin.permissions.sign_up_label'),
|
||||
setting: () =>
|
||||
SettingDropdown.component({
|
||||
key: 'allow_sign_up',
|
||||
options: [
|
||||
{ value: '1', label: app.translator.transText('core.admin.permissions_controls.signup_open_button') },
|
||||
{ value: '0', label: app.translator.transText('core.admin.permissions_controls.signup_closed_button') },
|
||||
],
|
||||
}),
|
||||
},
|
||||
90
|
||||
);
|
||||
|
||||
items.add('viewLastSeenAt', {
|
||||
icon: 'far fa-clock',
|
||||
label: app.translator.trans('core.admin.permissions.view_last_seen_at_label'),
|
||||
permission: 'user.viewLastSeenAt',
|
||||
});
|
||||
|
||||
return items;
|
||||
}
|
||||
|
||||
startItems() {
|
||||
const items = new ItemList();
|
||||
|
||||
items.add(
|
||||
'start',
|
||||
{
|
||||
icon: 'fas fa-edit',
|
||||
label: app.translator.trans('core.admin.permissions.start_discussions_label'),
|
||||
permission: 'startDiscussion',
|
||||
},
|
||||
100
|
||||
);
|
||||
|
||||
items.add(
|
||||
'allowRenaming',
|
||||
{
|
||||
icon: 'fas fa-i-cursor',
|
||||
label: app.translator.trans('core.admin.permissions.allow_renaming_label'),
|
||||
setting: () => {
|
||||
const minutes = parseInt(app.data.settings.allow_renaming, 10);
|
||||
|
||||
return SettingDropdown.component({
|
||||
defaultLabel: minutes
|
||||
? app.translator.transChoice('core.admin.permissions_controls.allow_some_minutes_button', minutes, { count: minutes })
|
||||
: app.translator.trans('core.admin.permissions_controls.allow_indefinitely_button'),
|
||||
key: 'allow_renaming',
|
||||
options: [
|
||||
{ value: '-1', label: app.translator.transText('core.admin.permissions_controls.allow_indefinitely_button') },
|
||||
{ value: '10', label: app.translator.transText('core.admin.permissions_controls.allow_ten_minutes_button') },
|
||||
{ value: 'reply', label: app.translator.transText('core.admin.permissions_controls.allow_until_reply_button') },
|
||||
],
|
||||
});
|
||||
},
|
||||
},
|
||||
90
|
||||
);
|
||||
|
||||
return items;
|
||||
}
|
||||
|
||||
replyItems() {
|
||||
const items = new ItemList();
|
||||
|
||||
items.add(
|
||||
'reply',
|
||||
{
|
||||
icon: 'fas fa-reply',
|
||||
label: app.translator.trans('core.admin.permissions.reply_to_discussions_label'),
|
||||
permission: 'discussion.reply',
|
||||
},
|
||||
100
|
||||
);
|
||||
|
||||
items.add(
|
||||
'allowPostEditing',
|
||||
{
|
||||
icon: 'fas fa-pencil-alt',
|
||||
label: app.translator.trans('core.admin.permissions.allow_post_editing_label'),
|
||||
setting: () => {
|
||||
const minutes = parseInt(app.data.settings.allow_post_editing, 10);
|
||||
|
||||
return SettingDropdown.component({
|
||||
defaultLabel: minutes
|
||||
? app.translator.transChoice('core.admin.permissions_controls.allow_some_minutes_button', minutes, { count: minutes })
|
||||
: app.translator.trans('core.admin.permissions_controls.allow_indefinitely_button'),
|
||||
key: 'allow_post_editing',
|
||||
options: [
|
||||
{ value: '-1', label: app.translator.transText('core.admin.permissions_controls.allow_indefinitely_button') },
|
||||
{ value: '10', label: app.translator.transText('core.admin.permissions_controls.allow_ten_minutes_button') },
|
||||
{ value: 'reply', label: app.translator.transText('core.admin.permissions_controls.allow_until_reply_button') },
|
||||
],
|
||||
});
|
||||
},
|
||||
},
|
||||
90
|
||||
);
|
||||
|
||||
return items;
|
||||
}
|
||||
|
||||
moderateItems() {
|
||||
const items = new ItemList();
|
||||
|
||||
items.add(
|
||||
'viewIpsPosts',
|
||||
{
|
||||
icon: 'fas fa-bullseye',
|
||||
label: app.translator.trans('core.admin.permissions.view_post_ips_label'),
|
||||
permission: 'discussion.viewIpsPosts',
|
||||
},
|
||||
110
|
||||
);
|
||||
|
||||
items.add(
|
||||
'renameDiscussions',
|
||||
{
|
||||
icon: 'fas fa-i-cursor',
|
||||
label: app.translator.trans('core.admin.permissions.rename_discussions_label'),
|
||||
permission: 'discussion.rename',
|
||||
},
|
||||
100
|
||||
);
|
||||
|
||||
items.add(
|
||||
'hideDiscussions',
|
||||
{
|
||||
icon: 'far fa-trash-alt',
|
||||
label: app.translator.trans('core.admin.permissions.delete_discussions_label'),
|
||||
permission: 'discussion.hide',
|
||||
},
|
||||
90
|
||||
);
|
||||
|
||||
items.add(
|
||||
'deleteDiscussions',
|
||||
{
|
||||
icon: 'fas fa-times',
|
||||
label: app.translator.trans('core.admin.permissions.delete_discussions_forever_label'),
|
||||
permission: 'discussion.delete',
|
||||
},
|
||||
80
|
||||
);
|
||||
|
||||
items.add(
|
||||
'postWithoutThrottle',
|
||||
{
|
||||
icon: 'fas fa-swimmer',
|
||||
label: app.translator.trans('core.admin.permissions.post_without_throttle_label'),
|
||||
permission: 'postWithoutThrottle',
|
||||
},
|
||||
70
|
||||
);
|
||||
|
||||
items.add(
|
||||
'editPosts',
|
||||
{
|
||||
icon: 'fas fa-pencil-alt',
|
||||
label: app.translator.trans('core.admin.permissions.edit_posts_label'),
|
||||
permission: 'discussion.editPosts',
|
||||
},
|
||||
70
|
||||
);
|
||||
|
||||
items.add(
|
||||
'hidePosts',
|
||||
{
|
||||
icon: 'far fa-trash-alt',
|
||||
label: app.translator.trans('core.admin.permissions.delete_posts_label'),
|
||||
permission: 'discussion.hidePosts',
|
||||
},
|
||||
60
|
||||
);
|
||||
|
||||
items.add(
|
||||
'deletePosts',
|
||||
{
|
||||
icon: 'fas fa-times',
|
||||
label: app.translator.trans('core.admin.permissions.delete_posts_forever_label'),
|
||||
permission: 'discussion.deletePosts',
|
||||
},
|
||||
60
|
||||
);
|
||||
|
||||
items.add(
|
||||
'userEdit',
|
||||
{
|
||||
icon: 'fas fa-user-cog',
|
||||
label: app.translator.trans('core.admin.permissions.edit_users_label'),
|
||||
permission: 'user.edit',
|
||||
},
|
||||
60
|
||||
);
|
||||
|
||||
return items;
|
||||
}
|
||||
|
||||
scopeItems() {
|
||||
const items = new ItemList();
|
||||
|
||||
items.add(
|
||||
'global',
|
||||
{
|
||||
label: app.translator.trans('core.admin.permissions.global_heading'),
|
||||
render: (item) => {
|
||||
if (item.setting) {
|
||||
return item.setting();
|
||||
} else if (item.permission) {
|
||||
return PermissionDropdown.component({
|
||||
permission: item.permission,
|
||||
allowGuest: item.allowGuest,
|
||||
});
|
||||
}
|
||||
|
||||
return '';
|
||||
},
|
||||
},
|
||||
100
|
||||
);
|
||||
|
||||
return items;
|
||||
}
|
||||
|
||||
scopeControlItems() {
|
||||
return new ItemList();
|
||||
}
|
||||
}
|
@@ -1,41 +0,0 @@
|
||||
import Page from './Page';
|
||||
import GroupBadge from '../../common/components/GroupBadge';
|
||||
import EditGroupModal from './EditGroupModal';
|
||||
import Group from '../../common/models/Group';
|
||||
import icon from '../../common/helpers/icon';
|
||||
import PermissionGrid from './PermissionGrid';
|
||||
|
||||
export default class PermissionsPage extends Page {
|
||||
view() {
|
||||
return (
|
||||
<div className="PermissionsPage">
|
||||
<div className="PermissionsPage-groups">
|
||||
<div className="container">
|
||||
{app.store.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}))}>
|
||||
{GroupBadge.component({
|
||||
group,
|
||||
className: 'Group-icon',
|
||||
label: null
|
||||
})}
|
||||
<span className="Group-name">{group.namePlural()}</span>
|
||||
</button>
|
||||
))}
|
||||
<button className="Button Group Group--add" onclick={() => app.modal.show(new EditGroupModal())}>
|
||||
{icon('fas fa-plus', {className: 'Group-icon'})}
|
||||
<span className="Group-name">{app.translator.trans('core.admin.permissions.new_group_button')}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="PermissionsPage-permissions">
|
||||
<div className="container">
|
||||
{PermissionGrid.component()}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
42
js/src/admin/components/PermissionsPage.tsx
Normal file
42
js/src/admin/components/PermissionsPage.tsx
Normal file
@@ -0,0 +1,42 @@
|
||||
import app from '../app';
|
||||
|
||||
import Page from './Page';
|
||||
import GroupBadge from '../../common/components/GroupBadge';
|
||||
import EditGroupModal from './EditGroupModal';
|
||||
import Group from '../../common/models/Group';
|
||||
import icon from '../../common/helpers/icon';
|
||||
import PermissionGrid from './PermissionGrid';
|
||||
|
||||
export default class PermissionsPage extends Page {
|
||||
view() {
|
||||
return (
|
||||
<div className="PermissionsPage">
|
||||
<div className="PermissionsPage-groups">
|
||||
<div className="container">
|
||||
{app.store
|
||||
.all('groups')
|
||||
.filter((group) => [Group.GUEST_ID, Group.MEMBER_ID].indexOf(group.id()) === -1)
|
||||
.map((group: Group) => (
|
||||
<button className="Button Group" onclick={() => app.modal.show(EditGroupModal, { group })}>
|
||||
{GroupBadge.component({
|
||||
group,
|
||||
className: 'Group-icon',
|
||||
label: null,
|
||||
})}
|
||||
<span className="Group-name">{group.namePlural()}</span>
|
||||
</button>
|
||||
))}
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="PermissionsPage-permissions">
|
||||
<div className="container">{PermissionGrid.component()}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
@@ -1,54 +0,0 @@
|
||||
import avatar from '../../common/helpers/avatar';
|
||||
import username from '../../common/helpers/username';
|
||||
import Dropdown from '../../common/components/Dropdown';
|
||||
import Button from '../../common/components/Button';
|
||||
import ItemList from '../../common/utils/ItemList';
|
||||
|
||||
/**
|
||||
* The `SessionDropdown` component shows a button with the current user's
|
||||
* avatar/name, with a dropdown of session controls.
|
||||
*/
|
||||
export default class SessionDropdown extends Dropdown {
|
||||
static initProps(props) {
|
||||
super.initProps(props);
|
||||
|
||||
props.className = 'SessionDropdown';
|
||||
props.buttonClassName = 'Button Button--user Button--flat';
|
||||
props.menuClassName = 'Dropdown-menu--right';
|
||||
}
|
||||
|
||||
view() {
|
||||
this.props.children = this.items().toArray();
|
||||
|
||||
return super.view();
|
||||
}
|
||||
|
||||
getButtonContent() {
|
||||
const user = app.session.user;
|
||||
|
||||
return [
|
||||
avatar(user), ' ',
|
||||
<span className="Button-label">{username(user)}</span>
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Build an item list for the contents of the dropdown menu.
|
||||
*
|
||||
* @return {ItemList}
|
||||
*/
|
||||
items() {
|
||||
const items = new ItemList();
|
||||
|
||||
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)
|
||||
}),
|
||||
-100
|
||||
);
|
||||
|
||||
return items;
|
||||
}
|
||||
}
|
52
js/src/admin/components/SessionDropdown.tsx
Normal file
52
js/src/admin/components/SessionDropdown.tsx
Normal file
@@ -0,0 +1,52 @@
|
||||
import avatar from '../../common/helpers/avatar';
|
||||
import username from '../../common/helpers/username';
|
||||
import Dropdown from '../../common/components/Dropdown';
|
||||
import Button from '../../common/components/Button';
|
||||
import ItemList from '../../common/utils/ItemList';
|
||||
|
||||
/**
|
||||
* The `SessionDropdown` component shows a button with the current user's
|
||||
* avatar/name, with a dropdown of session controls.
|
||||
*/
|
||||
export default class SessionDropdown extends Dropdown {
|
||||
static initProps(props) {
|
||||
super.initProps(props);
|
||||
|
||||
props.className = 'SessionDropdown';
|
||||
props.buttonClassName = 'Button Button--user Button--flat';
|
||||
props.menuClassName = 'Dropdown-menu--right';
|
||||
}
|
||||
|
||||
view() {
|
||||
this.props.children = this.items().toArray();
|
||||
|
||||
return super.view();
|
||||
}
|
||||
|
||||
getButtonContent() {
|
||||
const user = app.session.user;
|
||||
|
||||
return [avatar(user), ' ', <span className="Button-label">{username(user)}</span>];
|
||||
}
|
||||
|
||||
/**
|
||||
* Build an item list for the contents of the dropdown menu.
|
||||
*
|
||||
* @return {ItemList}
|
||||
*/
|
||||
items() {
|
||||
const items = new ItemList();
|
||||
|
||||
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),
|
||||
}),
|
||||
-100
|
||||
);
|
||||
|
||||
return items;
|
||||
}
|
||||
}
|
@@ -1,25 +0,0 @@
|
||||
import SelectDropdown from '../../common/components/SelectDropdown';
|
||||
import Button from '../../common/components/Button';
|
||||
import saveSettings from '../utils/saveSettings';
|
||||
|
||||
export default class SettingDropdown extends SelectDropdown {
|
||||
static initProps(props) {
|
||||
super.initProps(props);
|
||||
|
||||
props.className = 'SettingDropdown';
|
||||
props.buttonClassName = 'Button Button--text';
|
||||
props.caretIcon = 'fas fa-caret-down';
|
||||
props.defaultLabel = 'Custom';
|
||||
|
||||
props.children = props.options.map(({value, label}) => {
|
||||
const active = app.data.settings[props.key] === value;
|
||||
|
||||
return Button.component({
|
||||
children: label,
|
||||
icon: active ? 'fas fa-check' : true,
|
||||
onclick: saveSettings.bind(this, {[props.key]: value}),
|
||||
active
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
27
js/src/admin/components/SettingDropdown.tsx
Normal file
27
js/src/admin/components/SettingDropdown.tsx
Normal file
@@ -0,0 +1,27 @@
|
||||
import app from '../app';
|
||||
|
||||
import SelectDropdown from '../../common/components/SelectDropdown';
|
||||
import Button from '../../common/components/Button';
|
||||
import saveSettings from '../utils/saveSettings';
|
||||
|
||||
export default class SettingDropdown extends SelectDropdown {
|
||||
static initProps(props) {
|
||||
super.initProps(props);
|
||||
|
||||
props.className = 'SettingDropdown';
|
||||
props.buttonClassName = 'Button Button--text';
|
||||
props.caretIcon = 'fas fa-caret-down';
|
||||
props.defaultLabel = 'Custom';
|
||||
|
||||
props.children = props.options.map(({ value, label }) => {
|
||||
const active = app.data.settings[props.key] === value;
|
||||
|
||||
return Button.component({
|
||||
children: label,
|
||||
icon: active ? 'fas fa-check' : true,
|
||||
onclick: saveSettings.bind(this, { [props.key]: value }),
|
||||
active,
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
@@ -1,79 +0,0 @@
|
||||
import Modal from '../../common/components/Modal';
|
||||
import Button from '../../common/components/Button';
|
||||
import saveSettings from '../utils/saveSettings';
|
||||
|
||||
export default class SettingsModal extends Modal {
|
||||
init() {
|
||||
this.settings = {};
|
||||
this.loading = false;
|
||||
}
|
||||
|
||||
form() {
|
||||
return '';
|
||||
}
|
||||
|
||||
content() {
|
||||
return (
|
||||
<div className="Modal-body">
|
||||
<div className="Form">
|
||||
{this.form()}
|
||||
|
||||
<div className="Form-group">
|
||||
{this.submitButton()}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
submitButton() {
|
||||
return (
|
||||
<Button
|
||||
type="submit"
|
||||
className="Button Button--primary"
|
||||
loading={this.loading}
|
||||
disabled={!this.changed()}>
|
||||
{app.translator.trans('core.admin.settings.submit_button')}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
setting(key, fallback = '') {
|
||||
this.settings[key] = this.settings[key] || m.prop(app.data.settings[key] || fallback);
|
||||
|
||||
return this.settings[key];
|
||||
}
|
||||
|
||||
dirty() {
|
||||
const dirty = {};
|
||||
|
||||
Object.keys(this.settings).forEach(key => {
|
||||
const value = this.settings[key]();
|
||||
|
||||
if (value !== app.data.settings[key]) {
|
||||
dirty[key] = value;
|
||||
}
|
||||
});
|
||||
|
||||
return dirty;
|
||||
}
|
||||
|
||||
changed() {
|
||||
return Object.keys(this.dirty()).length;
|
||||
}
|
||||
|
||||
onsubmit(e) {
|
||||
e.preventDefault();
|
||||
|
||||
this.loading = true;
|
||||
|
||||
saveSettings(this.dirty()).then(
|
||||
this.onsaved.bind(this),
|
||||
this.loaded.bind(this)
|
||||
);
|
||||
}
|
||||
|
||||
onsaved() {
|
||||
this.hide();
|
||||
}
|
||||
}
|
70
js/src/admin/components/SettingsModal.tsx
Normal file
70
js/src/admin/components/SettingsModal.tsx
Normal file
@@ -0,0 +1,70 @@
|
||||
import app from '../app';
|
||||
|
||||
import Modal from '../../common/components/Modal';
|
||||
import Button from '../../common/components/Button';
|
||||
import saveSettings from '../utils/saveSettings';
|
||||
|
||||
export default abstract class SettingsModal extends Modal {
|
||||
settings: object = {};
|
||||
loading: boolean = false;
|
||||
|
||||
form(): string | JSX.Element[] {
|
||||
return '';
|
||||
}
|
||||
|
||||
content() {
|
||||
return (
|
||||
<div className="Modal-body">
|
||||
<div className="Form">
|
||||
{this.form()}
|
||||
|
||||
<div className="Form-group">{this.submitButton()}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
submitButton() {
|
||||
return (
|
||||
<Button type="submit" className="Button Button--primary" loading={this.loading} disabled={!this.changed()}>
|
||||
{app.translator.trans('core.admin.settings.submit_button')}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
setting(key, fallback = '') {
|
||||
this.settings[key] = this.settings[key] || m.prop(app.data.settings[key] || fallback);
|
||||
|
||||
return this.settings[key];
|
||||
}
|
||||
|
||||
dirty() {
|
||||
const dirty = {};
|
||||
|
||||
Object.keys(this.settings).forEach((key) => {
|
||||
const value = this.settings[key]();
|
||||
|
||||
if (value !== app.data.settings[key]) {
|
||||
dirty[key] = value;
|
||||
}
|
||||
});
|
||||
|
||||
return dirty;
|
||||
}
|
||||
|
||||
changed() {
|
||||
return Object.keys(this.dirty()).length;
|
||||
}
|
||||
|
||||
onsubmit(e) {
|
||||
e.preventDefault();
|
||||
|
||||
this.loading = true;
|
||||
|
||||
saveSettings(this.dirty()).then(this.onsaved.bind(this), this.loaded.bind(this));
|
||||
}
|
||||
|
||||
onsaved() {
|
||||
this.hide();
|
||||
}
|
||||
}
|
@@ -1,58 +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.
|
||||
*/
|
||||
|
||||
import DashboardWidget from './DashboardWidget';
|
||||
import listItems from '../../common/helpers/listItems';
|
||||
import ItemList from '../../common/utils/ItemList';
|
||||
import Dropdown from '../../common/components/Dropdown';
|
||||
import Button from '../../common/components/Button';
|
||||
import LoadingModal from './LoadingModal';
|
||||
|
||||
export default class StatusWidget extends DashboardWidget {
|
||||
className() {
|
||||
return 'StatusWidget';
|
||||
}
|
||||
|
||||
content() {
|
||||
return (
|
||||
<ul>{listItems(this.items().toArray())}</ul>
|
||||
);
|
||||
}
|
||||
|
||||
items() {
|
||||
const items = new ItemList();
|
||||
|
||||
items.add('tools', (
|
||||
<Dropdown
|
||||
label={app.translator.trans('core.admin.dashboard.tools_button')}
|
||||
icon="fas fa-cog"
|
||||
buttonClassName="Button"
|
||||
menuClassName="Dropdown-menu--right">
|
||||
<Button onclick={this.handleClearCache.bind(this)}>
|
||||
{app.translator.trans('core.admin.dashboard.clear_cache_button')}
|
||||
</Button>
|
||||
</Dropdown>
|
||||
));
|
||||
|
||||
items.add('version-flarum', [<strong>Flarum</strong>, <br/>, app.forum.attribute('version')]);
|
||||
items.add('version-php', [<strong>PHP</strong>, <br/>, app.data.phpVersion]);
|
||||
items.add('version-mysql', [<strong>MySQL</strong>, <br/>, app.data.mysqlVersion]);
|
||||
|
||||
return items;
|
||||
}
|
||||
|
||||
handleClearCache(e) {
|
||||
app.modal.show(new LoadingModal());
|
||||
|
||||
app.request({
|
||||
method: 'DELETE',
|
||||
url: app.forum.attribute('apiUrl') + '/cache'
|
||||
}).then(() => window.location.reload());
|
||||
}
|
||||
}
|
49
js/src/admin/components/StatusWidget.tsx
Normal file
49
js/src/admin/components/StatusWidget.tsx
Normal file
@@ -0,0 +1,49 @@
|
||||
import app from '../app';
|
||||
|
||||
import DashboardWidget from './DashboardWidget';
|
||||
import listItems from '../../common/helpers/listItems';
|
||||
import ItemList from '../../common/utils/ItemList';
|
||||
import Dropdown from '../../common/components/Dropdown';
|
||||
import Button from '../../common/components/Button';
|
||||
import LoadingModal from './LoadingModal';
|
||||
|
||||
export default class StatusWidget extends DashboardWidget {
|
||||
className() {
|
||||
return 'StatusWidget';
|
||||
}
|
||||
|
||||
content() {
|
||||
return <ul>{listItems(this.items().toArray())}</ul>;
|
||||
}
|
||||
|
||||
items() {
|
||||
const items = new ItemList();
|
||||
|
||||
items.add(
|
||||
'tools',
|
||||
<Dropdown
|
||||
label={app.translator.transText('core.admin.dashboard.tools_button')}
|
||||
icon="fas fa-cog"
|
||||
buttonClassName="Button"
|
||||
menuClassName="Dropdown-menu--right"
|
||||
>
|
||||
<Button onclick={this.handleClearCache.bind(this)}>{app.translator.trans('core.admin.dashboard.clear_cache_button')}</Button>
|
||||
</Dropdown>
|
||||
);
|
||||
|
||||
items.add('version-flarum', [<strong>Flarum</strong>, <br />, app.forum.attribute('version')]);
|
||||
items.add('version-php', [<strong>PHP</strong>, <br />, app.data.phpVersion]);
|
||||
items.add('version-mysql', [<strong>MySQL</strong>, <br />, app.data.mysqlVersion]);
|
||||
|
||||
return items;
|
||||
}
|
||||
|
||||
handleClearCache(e) {
|
||||
app.modal.show(LoadingModal);
|
||||
|
||||
app.request({
|
||||
method: 'DELETE',
|
||||
url: app.forum.attribute('apiUrl') + '/cache',
|
||||
}).then(() => window.location.reload());
|
||||
}
|
||||
}
|
@@ -1,97 +0,0 @@
|
||||
import Button from '../../common/components/Button';
|
||||
|
||||
export default class UploadImageButton extends Button {
|
||||
init() {
|
||||
this.loading = false;
|
||||
}
|
||||
|
||||
view() {
|
||||
this.props.loading = this.loading;
|
||||
this.props.className = (this.props.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');
|
||||
|
||||
return (
|
||||
<div>
|
||||
<p><img src={app.forum.attribute(this.props.name+'Url')} alt=""/></p>
|
||||
<p>{super.view()}</p>
|
||||
</div>
|
||||
);
|
||||
} else {
|
||||
this.props.onclick = this.upload.bind(this);
|
||||
this.props.children = app.translator.trans('core.admin.upload_image.upload_button');
|
||||
}
|
||||
|
||||
return super.view();
|
||||
}
|
||||
|
||||
/**
|
||||
* Prompt the user to upload an image.
|
||||
*/
|
||||
upload() {
|
||||
if (this.loading) return;
|
||||
|
||||
const $input = $('<input type="file">');
|
||||
|
||||
$input.appendTo('body').hide().click().on('change', e => {
|
||||
const data = new FormData();
|
||||
data.append(this.props.name, $(e.target)[0].files[0]);
|
||||
|
||||
this.loading = true;
|
||||
m.redraw();
|
||||
|
||||
app.request({
|
||||
method: 'POST',
|
||||
url: this.resourceUrl(),
|
||||
serialize: raw => raw,
|
||||
data
|
||||
}).then(
|
||||
this.success.bind(this),
|
||||
this.failure.bind(this)
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove the logo.
|
||||
*/
|
||||
remove() {
|
||||
this.loading = true;
|
||||
m.redraw();
|
||||
|
||||
app.request({
|
||||
method: 'DELETE',
|
||||
url: this.resourceUrl()
|
||||
}).then(
|
||||
this.success.bind(this),
|
||||
this.failure.bind(this)
|
||||
);
|
||||
}
|
||||
|
||||
resourceUrl() {
|
||||
return app.forum.attribute('apiUrl') + '/' + this.props.name;
|
||||
}
|
||||
|
||||
/**
|
||||
* After a successful upload/removal, reload the page.
|
||||
*
|
||||
* @param {Object} response
|
||||
* @protected
|
||||
*/
|
||||
success(response) {
|
||||
window.location.reload();
|
||||
}
|
||||
|
||||
/**
|
||||
* If upload/removal fails, stop loading.
|
||||
*
|
||||
* @param {Object} response
|
||||
* @protected
|
||||
*/
|
||||
failure(response) {
|
||||
this.loading = false;
|
||||
m.redraw();
|
||||
}
|
||||
}
|
97
js/src/admin/components/UploadImageButton.tsx
Normal file
97
js/src/admin/components/UploadImageButton.tsx
Normal file
@@ -0,0 +1,97 @@
|
||||
import app from '../app';
|
||||
|
||||
import Button, { ButtonProps } from '../../common/components/Button';
|
||||
|
||||
export default class UploadImageButton<T extends ButtonProps = ButtonProps> extends Button<T> {
|
||||
loading: boolean = false;
|
||||
|
||||
view() {
|
||||
this.props.loading = this.loading;
|
||||
this.props.className = (this.props.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');
|
||||
|
||||
return (
|
||||
<div>
|
||||
<p>
|
||||
<img src={app.forum.attribute(this.props.name + 'Url')} alt="" />
|
||||
</p>
|
||||
<p>{super.view()}</p>
|
||||
</div>
|
||||
);
|
||||
} else {
|
||||
this.props.onclick = this.upload.bind(this);
|
||||
this.props.children = app.translator.trans('core.admin.upload_image.upload_button');
|
||||
}
|
||||
|
||||
return super.view();
|
||||
}
|
||||
|
||||
/**
|
||||
* Prompt the user to upload an image.
|
||||
*/
|
||||
upload() {
|
||||
if (this.loading) return;
|
||||
|
||||
const $input = $('<input type="file">');
|
||||
|
||||
$input
|
||||
.appendTo('body')
|
||||
.hide()
|
||||
.click()
|
||||
.on('change', (e) => {
|
||||
const data = new FormData();
|
||||
data.append(this.props.name, $(e.target)[0].files[0]);
|
||||
|
||||
this.loading = true;
|
||||
m.redraw();
|
||||
|
||||
app.request({
|
||||
method: 'POST',
|
||||
url: this.resourceUrl(),
|
||||
serialize: (raw) => raw,
|
||||
body: data,
|
||||
}).then(this.success.bind(this), this.failure.bind(this));
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove the logo.
|
||||
*/
|
||||
remove() {
|
||||
this.loading = true;
|
||||
m.redraw();
|
||||
|
||||
app.request({
|
||||
method: 'DELETE',
|
||||
url: this.resourceUrl(),
|
||||
}).then(this.success.bind(this), this.failure.bind(this));
|
||||
}
|
||||
|
||||
resourceUrl() {
|
||||
return app.forum.attribute('apiUrl') + '/' + this.props.name;
|
||||
}
|
||||
|
||||
/**
|
||||
* After a successful upload/removal, reload the page.
|
||||
*
|
||||
* @param {Object} response
|
||||
* @protected
|
||||
*/
|
||||
success(response) {
|
||||
window.location.reload();
|
||||
}
|
||||
|
||||
/**
|
||||
* If upload/removal fails, stop loading.
|
||||
*
|
||||
* @param {Object} response
|
||||
* @protected
|
||||
*/
|
||||
failure(response) {
|
||||
this.loading = false;
|
||||
m.redraw();
|
||||
}
|
||||
}
|
@@ -1,38 +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.
|
||||
*/
|
||||
|
||||
import Component from '../../common/Component';
|
||||
|
||||
export default class DashboardWidget extends Component {
|
||||
view() {
|
||||
return (
|
||||
<div className={"DashboardWidget "+this.className()}>
|
||||
{this.content()}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the class name to apply to the widget.
|
||||
*
|
||||
* @return {String}
|
||||
*/
|
||||
className() {
|
||||
return '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the content of the widget.
|
||||
*
|
||||
* @return {VirtualElement}
|
||||
*/
|
||||
content() {
|
||||
return [];
|
||||
}
|
||||
}
|
@@ -1,18 +0,0 @@
|
||||
import AdminApplication from './AdminApplication';
|
||||
|
||||
const app = new AdminApplication();
|
||||
|
||||
// Backwards compatibility
|
||||
window.app = app;
|
||||
|
||||
export { app };
|
||||
|
||||
// Export public API
|
||||
|
||||
|
||||
// Export compat API
|
||||
import compat from './compat';
|
||||
|
||||
compat.app = app;
|
||||
|
||||
export { compat };
|
10
js/src/admin/index.ts
Normal file
10
js/src/admin/index.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import app from './app';
|
||||
|
||||
export { app };
|
||||
|
||||
// Export compat API
|
||||
import compat from './compat';
|
||||
|
||||
compat.app = app;
|
||||
|
||||
export { compat };
|
@@ -1,22 +0,0 @@
|
||||
import DashboardPage from './components/DashboardPage';
|
||||
import BasicsPage from './components/BasicsPage';
|
||||
import PermissionsPage from './components/PermissionsPage';
|
||||
import AppearancePage from './components/AppearancePage';
|
||||
import ExtensionsPage from './components/ExtensionsPage';
|
||||
import MailPage from './components/MailPage';
|
||||
|
||||
/**
|
||||
* The `routes` initializer defines the forum app's routes.
|
||||
*
|
||||
* @param {App} app
|
||||
*/
|
||||
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()}
|
||||
};
|
||||
}
|
17
js/src/admin/routes.ts
Normal file
17
js/src/admin/routes.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import BasicsPage from './components/BasicsPage';
|
||||
import DashboardPage from './components/DashboardPage';
|
||||
import MailPage from './components/MailPage';
|
||||
import PermissionsPage from './components/PermissionsPage';
|
||||
import AppearancePage from './components/AppearancePage';
|
||||
import ExtensionsPage from './components/ExtensionsPage';
|
||||
|
||||
export default (app) => {
|
||||
app.routes = {
|
||||
dashboard: { path: '/', component: DashboardPage },
|
||||
basics: { path: '/basics', component: BasicsPage },
|
||||
mail: { path: '/mail', component: MailPage },
|
||||
permissions: { path: '/permissions', component: PermissionsPage },
|
||||
appearance: { path: '/appearance', component: AppearancePage },
|
||||
extensions: { path: '/extensions', component: ExtensionsPage },
|
||||
};
|
||||
};
|
@@ -1,14 +0,0 @@
|
||||
export default function saveSettings(settings) {
|
||||
const oldSettings = JSON.parse(JSON.stringify(app.data.settings));
|
||||
|
||||
Object.assign(app.data.settings, settings);
|
||||
|
||||
return app.request({
|
||||
method: 'POST',
|
||||
url: app.forum.attribute('apiUrl') + '/settings',
|
||||
data: settings
|
||||
}).catch(error => {
|
||||
app.data.settings = oldSettings;
|
||||
throw error;
|
||||
});
|
||||
}
|
18
js/src/admin/utils/saveSettings.ts
Normal file
18
js/src/admin/utils/saveSettings.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import app from '../app';
|
||||
|
||||
export default function saveSettings(settings) {
|
||||
const oldSettings = JSON.parse(JSON.stringify(app.data.settings));
|
||||
|
||||
Object.assign(app.data.settings, settings);
|
||||
|
||||
return app
|
||||
.request({
|
||||
method: 'POST',
|
||||
url: app.forum.attribute('apiUrl') + '/settings',
|
||||
body: settings,
|
||||
})
|
||||
.catch((error) => {
|
||||
app.data.settings = oldSettings;
|
||||
throw error;
|
||||
});
|
||||
}
|
@@ -1,363 +0,0 @@
|
||||
import ItemList from './utils/ItemList';
|
||||
import Alert from './components/Alert';
|
||||
import ModalManager from './components/ModalManager';
|
||||
import AlertManager from './components/AlertManager';
|
||||
import Translator from './Translator';
|
||||
import Store from './Store';
|
||||
import Session from './Session';
|
||||
import extract from './utils/extract';
|
||||
import Drawer from './utils/Drawer';
|
||||
import mapRoutes from './utils/mapRoutes';
|
||||
import RequestError from './utils/RequestError';
|
||||
import ScrollListener from './utils/ScrollListener';
|
||||
import { extend } from './extend';
|
||||
|
||||
import Forum from './models/Forum';
|
||||
import User from './models/User';
|
||||
import Discussion from './models/Discussion';
|
||||
import Post from './models/Post';
|
||||
import Group from './models/Group';
|
||||
import Notification from './models/Notification';
|
||||
import { flattenDeep } from 'lodash-es';
|
||||
|
||||
/**
|
||||
* The `App` class provides a container for an application, as well as various
|
||||
* utilities for the rest of the app to use.
|
||||
*/
|
||||
export default class Application {
|
||||
/**
|
||||
* The forum model for this application.
|
||||
*
|
||||
* @type {Forum}
|
||||
* @public
|
||||
*/
|
||||
forum = null;
|
||||
|
||||
/**
|
||||
* A map of routes, keyed by a unique route name. Each route is an object
|
||||
* containing the following properties:
|
||||
*
|
||||
* - `path` The path that the route is accessed at.
|
||||
* - `component` The Mithril component to render when this route is active.
|
||||
*
|
||||
* @example
|
||||
* app.routes.discussion = {path: '/d/:id', component: DiscussionPage.component()};
|
||||
*
|
||||
* @type {Object}
|
||||
* @public
|
||||
*/
|
||||
routes = {};
|
||||
|
||||
/**
|
||||
* An ordered list of initializers to bootstrap the application.
|
||||
*
|
||||
* @type {ItemList}
|
||||
* @public
|
||||
*/
|
||||
initializers = new ItemList();
|
||||
|
||||
/**
|
||||
* The app's session.
|
||||
*
|
||||
* @type {Session}
|
||||
* @public
|
||||
*/
|
||||
session = null;
|
||||
|
||||
/**
|
||||
* The app's translator.
|
||||
*
|
||||
* @type {Translator}
|
||||
* @public
|
||||
*/
|
||||
translator = new Translator();
|
||||
|
||||
/**
|
||||
* The app's data store.
|
||||
*
|
||||
* @type {Store}
|
||||
* @public
|
||||
*/
|
||||
store = new Store({
|
||||
forums: Forum,
|
||||
users: User,
|
||||
discussions: Discussion,
|
||||
posts: Post,
|
||||
groups: Group,
|
||||
notifications: Notification
|
||||
});
|
||||
|
||||
/**
|
||||
* A local cache that can be used to store data at the application level, so
|
||||
* that is persists between different routes.
|
||||
*
|
||||
* @type {Object}
|
||||
* @public
|
||||
*/
|
||||
cache = {};
|
||||
|
||||
/**
|
||||
* Whether or not the app has been booted.
|
||||
*
|
||||
* @type {Boolean}
|
||||
* @public
|
||||
*/
|
||||
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.
|
||||
*
|
||||
* @type {null|Alert}
|
||||
* @private
|
||||
*/
|
||||
requestError = null;
|
||||
|
||||
data;
|
||||
|
||||
title = '';
|
||||
titleCount = 0;
|
||||
|
||||
load(payload) {
|
||||
this.data = payload;
|
||||
this.translator.locale = payload.locale;
|
||||
}
|
||||
|
||||
boot() {
|
||||
this.initializers.toArray().forEach(initializer => initializer(this));
|
||||
|
||||
this.store.pushPayload({data: this.data.resources});
|
||||
|
||||
this.forum = this.store.getById('forums', 1);
|
||||
|
||||
this.session = new Session(
|
||||
this.store.getById('users', this.data.session.userId),
|
||||
this.data.session.csrfToken
|
||||
);
|
||||
|
||||
this.mount();
|
||||
}
|
||||
|
||||
bootExtensions(extensions) {
|
||||
Object.keys(extensions).forEach(name => {
|
||||
const extension = extensions[name];
|
||||
|
||||
const extenders = flattenDeep(extension.extend);
|
||||
|
||||
for (const extender of extenders) {
|
||||
extender.extend(this, { name, exports: extension });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
mount(basePath = '') {
|
||||
this.modal = m.mount(document.getElementById('modal'), <ModalManager/>);
|
||||
this.alerts = m.mount(document.getElementById('alerts'), <AlertManager/>);
|
||||
|
||||
this.drawer = new Drawer();
|
||||
|
||||
m.route(
|
||||
document.getElementById('content'),
|
||||
basePath + '/',
|
||||
mapRoutes(this.routes, basePath)
|
||||
);
|
||||
|
||||
// Add a class to the body which indicates that the page has been scrolled
|
||||
// down.
|
||||
new ScrollListener(top => {
|
||||
const $app = $('#app');
|
||||
const offset = $app.offset().top;
|
||||
|
||||
$app
|
||||
.toggleClass('affix', top >= offset)
|
||||
.toggleClass('scrolled', top > offset);
|
||||
}).start();
|
||||
|
||||
$(() => {
|
||||
$('body').addClass('ontouchstart' in window ? 'touch' : 'no-touch');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the API response document that has been preloaded into the application.
|
||||
*
|
||||
* @return {Object|null}
|
||||
* @public
|
||||
*/
|
||||
preloadedApiDocument() {
|
||||
if (this.data.apiDocument) {
|
||||
const results = this.store.pushPayload(this.data.apiDocument);
|
||||
|
||||
this.data.apiDocument = null;
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the <title> of the page.
|
||||
*
|
||||
* @param {String} title
|
||||
* @public
|
||||
*/
|
||||
setTitle(title) {
|
||||
this.title = title;
|
||||
this.updateTitle();
|
||||
}
|
||||
|
||||
/**
|
||||
* Set a number to display in the <title> of the page.
|
||||
*
|
||||
* @param {Integer} count
|
||||
*/
|
||||
setTitleCount(count) {
|
||||
this.titleCount = count;
|
||||
this.updateTitle();
|
||||
}
|
||||
|
||||
updateTitle() {
|
||||
document.title = (this.titleCount ? `(${this.titleCount}) ` : '') +
|
||||
(this.title ? this.title + ' - ' : '') +
|
||||
this.forum.attribute('title');
|
||||
}
|
||||
|
||||
/**
|
||||
* Make an AJAX request, handling any low-level errors that may occur.
|
||||
*
|
||||
* @see https://lhorie.github.io/mithril/mithril.request.html
|
||||
* @param {Object} options
|
||||
* @return {Promise}
|
||||
* @public
|
||||
*/
|
||||
request(originalOptions) {
|
||||
const options = Object.assign({}, originalOptions);
|
||||
|
||||
// Set some default options if they haven't been overridden. We want to
|
||||
// authenticate all requests with the session token. We also want all
|
||||
// requests to run asynchronously in the background, so that they don't
|
||||
// prevent redraws from occurring.
|
||||
options.background = options.background || true;
|
||||
|
||||
extend(options, 'config', (result, xhr) => xhr.setRequestHeader('X-CSRF-Token', this.session.csrfToken));
|
||||
|
||||
// If the method is something like PATCH or DELETE, which not all servers
|
||||
// and clients support, then we'll send it as a POST request with the
|
||||
// intended method specified in the X-HTTP-Method-Override header.
|
||||
if (options.method !== 'GET' && options.method !== 'POST') {
|
||||
const method = options.method;
|
||||
extend(options, 'config', (result, xhr) => xhr.setRequestHeader('X-HTTP-Method-Override', method));
|
||||
options.method = 'POST';
|
||||
}
|
||||
|
||||
// When we deserialize JSON data, if for some reason the server has provided
|
||||
// a dud response, we don't want the application to crash. We'll show an
|
||||
// error message to the user instead.
|
||||
options.deserialize = options.deserialize || (responseText => responseText);
|
||||
|
||||
options.errorHandler = options.errorHandler || (error => {
|
||||
throw error;
|
||||
});
|
||||
|
||||
// When extracting the data from the response, we can check the server
|
||||
// response code and show an error message to the user if something's gone
|
||||
// awry.
|
||||
const original = options.extract;
|
||||
options.extract = xhr => {
|
||||
let responseText;
|
||||
|
||||
if (original) {
|
||||
responseText = original(xhr.responseText);
|
||||
} else {
|
||||
responseText = xhr.responseText || null;
|
||||
}
|
||||
|
||||
const status = xhr.status;
|
||||
|
||||
if (status < 200 || status > 299) {
|
||||
throw new RequestError(status, responseText, options, xhr);
|
||||
}
|
||||
|
||||
if (xhr.getResponseHeader) {
|
||||
const csrfToken = xhr.getResponseHeader('X-CSRF-Token');
|
||||
if (csrfToken) app.session.csrfToken = csrfToken;
|
||||
}
|
||||
|
||||
try {
|
||||
return JSON.parse(responseText);
|
||||
} catch (e) {
|
||||
throw new RequestError(500, responseText, options, xhr);
|
||||
}
|
||||
};
|
||||
|
||||
if (this.requestError) this.alerts.dismiss(this.requestError.alert);
|
||||
|
||||
// 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), error => {
|
||||
this.requestError = error;
|
||||
|
||||
let children;
|
||||
|
||||
switch (error.status) {
|
||||
case 422:
|
||||
children = error.response.errors
|
||||
.map(error => [error.detail, <br/>])
|
||||
.reduce((a, b) => a.concat(b), [])
|
||||
.slice(0, -1);
|
||||
break;
|
||||
|
||||
case 401:
|
||||
case 403:
|
||||
children = app.translator.trans('core.lib.error.permission_denied_message');
|
||||
break;
|
||||
|
||||
case 404:
|
||||
case 410:
|
||||
children = app.translator.trans('core.lib.error.not_found_message');
|
||||
break;
|
||||
|
||||
case 429:
|
||||
children = app.translator.trans('core.lib.error.rate_limit_exceeded_message');
|
||||
break;
|
||||
|
||||
default:
|
||||
children = app.translator.trans('core.lib.error.generic_message');
|
||||
}
|
||||
|
||||
error.alert = new Alert({
|
||||
type: 'error',
|
||||
children
|
||||
});
|
||||
|
||||
try {
|
||||
options.errorHandler(error);
|
||||
} catch (error) {
|
||||
this.alerts.show(error.alert);
|
||||
}
|
||||
|
||||
deferred.reject(error);
|
||||
});
|
||||
|
||||
return deferred.promise;
|
||||
}
|
||||
|
||||
/**
|
||||
* Construct a URL to the route with the given name.
|
||||
*
|
||||
* @param {String} name
|
||||
* @param {Object} params
|
||||
* @return {String}
|
||||
* @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') : '';
|
||||
|
||||
return prefix + url + (queryString ? '?' + queryString : '');
|
||||
}
|
||||
}
|
363
js/src/common/Application.ts
Normal file
363
js/src/common/Application.ts
Normal file
@@ -0,0 +1,363 @@
|
||||
import Mithril from 'mithril';
|
||||
|
||||
import Translator from './Translator';
|
||||
import Session from './Session';
|
||||
import Store from './Store';
|
||||
import { extend } from './extend';
|
||||
|
||||
import extract from './utils/extract';
|
||||
import mapRoutes from './utils/mapRoutes';
|
||||
import Drawer from './utils/Drawer';
|
||||
import RequestError from './utils/RequestError';
|
||||
import ItemList from './utils/ItemList';
|
||||
import ScrollListener from './utils/ScrollListener';
|
||||
|
||||
import Forum from './models/Forum';
|
||||
import Discussion from './models/Discussion';
|
||||
import User from './models/User';
|
||||
import Post from './models/Post';
|
||||
import Group from './models/Group';
|
||||
import Notification from './models/Notification';
|
||||
|
||||
import AlertManager from './components/AlertManager';
|
||||
import Button from './components/Button';
|
||||
import ModalManager from './components/ModalManager';
|
||||
import Page from './components/Page';
|
||||
import RequestErrorModal from './components/RequestErrorModal';
|
||||
|
||||
import AlertState from './states/AlertState';
|
||||
|
||||
import flattenDeep from 'lodash/flattenDeep';
|
||||
|
||||
export type ApplicationData = {
|
||||
apiDocument: any;
|
||||
locale: string;
|
||||
locales: any;
|
||||
resources: any[];
|
||||
session: any;
|
||||
};
|
||||
|
||||
export default abstract class Application {
|
||||
/**
|
||||
* The forum model for this application.
|
||||
*/
|
||||
public forum!: Forum;
|
||||
|
||||
/**
|
||||
* A map of routes, keyed by a unique route name. Each route is an object
|
||||
* containing the following properties:
|
||||
*
|
||||
* - `path` The path that the route is accessed at.
|
||||
* - `component` The Mithril component to render when this route is active.
|
||||
*
|
||||
* @example
|
||||
* app.routes.discussion = {path: '/d/:id', component: DiscussionPage.component()};
|
||||
*/
|
||||
public routes: { [key: string]: { path: string; component: any; [key: string]: any } } = {};
|
||||
|
||||
/**
|
||||
* An ordered list of initializers to bootstrap the application.
|
||||
*/
|
||||
public initializers = new ItemList();
|
||||
|
||||
/**
|
||||
* The app's session.
|
||||
*/
|
||||
public session!: Session;
|
||||
|
||||
/**
|
||||
* The app's translator.
|
||||
*/
|
||||
public translator = new Translator();
|
||||
|
||||
/**
|
||||
* The app's data store.
|
||||
*/
|
||||
public store = new Store({
|
||||
forums: Forum,
|
||||
users: User,
|
||||
discussions: Discussion,
|
||||
posts: Post,
|
||||
groups: Group,
|
||||
notifications: Notification,
|
||||
});
|
||||
|
||||
/**
|
||||
* A local cache that can be used to store data at the application level, so
|
||||
* that is persists between different routes.
|
||||
*/
|
||||
public cache: { [key: string]: any } = {};
|
||||
|
||||
/**
|
||||
* Whether or not the app has been booted.
|
||||
*/
|
||||
public booted: boolean = 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.
|
||||
*/
|
||||
private requestError: RequestError | null = null;
|
||||
|
||||
data!: ApplicationData;
|
||||
|
||||
title = '';
|
||||
titleCount = 0;
|
||||
|
||||
drawer = new Drawer();
|
||||
|
||||
modal!: ModalManager;
|
||||
alerts!: AlertManager;
|
||||
|
||||
current?: Page;
|
||||
previous?: Page;
|
||||
|
||||
load(payload) {
|
||||
this.data = payload;
|
||||
this.translator.locale = payload.locale;
|
||||
}
|
||||
|
||||
boot() {
|
||||
this.initializers.toArray().forEach((initializer) => initializer(this));
|
||||
|
||||
this.store.pushPayload({ data: this.data.resources });
|
||||
|
||||
this.forum = this.store.getById('forums', 1);
|
||||
|
||||
this.session = new Session(this.store.getById('users', this.data.session.userId), this.data.session.csrfToken);
|
||||
|
||||
this.mount();
|
||||
|
||||
this.booted = true;
|
||||
}
|
||||
|
||||
bootExtensions(extensions) {
|
||||
Object.keys(extensions).forEach((name) => {
|
||||
const extension = extensions[name];
|
||||
|
||||
const extenders = flattenDeep(extension.extend);
|
||||
|
||||
for (const extender of extenders) {
|
||||
extender.extend(this, { name, exports: extension });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
mount(basePath = '') {
|
||||
const $modal = document.getElementById('modal');
|
||||
const $alerts = document.getElementById('alerts');
|
||||
const $content = document.getElementById('content');
|
||||
|
||||
if ($modal) m.mount($modal, (this.modal = new ModalManager()));
|
||||
|
||||
if ($alerts) m.mount($alerts, (this.alerts = new AlertManager({ oninit: (vnode) => (this.alerts = vnode.state) })));
|
||||
|
||||
if ($content) m.route($content, basePath + '/', mapRoutes(this.routes, basePath));
|
||||
|
||||
// Add a class to the body which indicates that the page has been scrolled
|
||||
// down.
|
||||
new ScrollListener((top) => {
|
||||
const $app = $('#app');
|
||||
const offset = $app.offset().top;
|
||||
|
||||
$app.toggleClass('affix', top >= offset).toggleClass('scrolled', top > offset);
|
||||
}).start();
|
||||
|
||||
$(() => {
|
||||
$('body').addClass('ontouchstart' in window ? 'touch' : 'no-touch');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the API response document that has been preloaded into the application.
|
||||
*/
|
||||
preloadedApiDocument() {
|
||||
if (this.data.apiDocument) {
|
||||
const results = this.store.pushPayload(this.data.apiDocument);
|
||||
|
||||
this.data.apiDocument = null;
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the <title> of the page.
|
||||
*/
|
||||
setTitle(title: string) {
|
||||
this.title = title;
|
||||
this.updateTitle();
|
||||
}
|
||||
|
||||
/**
|
||||
* Set a number to display in the <title> of the page.
|
||||
*/
|
||||
setTitleCount(count: number) {
|
||||
this.titleCount = count;
|
||||
this.updateTitle();
|
||||
}
|
||||
|
||||
updateTitle() {
|
||||
document.title = (this.titleCount ? `(${this.titleCount}) ` : '') + (this.title ? this.title + ' - ' : '') + this.forum.attribute('title');
|
||||
}
|
||||
|
||||
/**
|
||||
* Construct a URL to the route with the given name.
|
||||
*/
|
||||
route(name: string, params: object = {}): string {
|
||||
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 as Mithril.Params);
|
||||
const prefix = m.route.prefix === '' ? this.forum.attribute('basePath') : '';
|
||||
|
||||
return prefix + url + (queryString ? '?' + queryString : '');
|
||||
}
|
||||
|
||||
/**
|
||||
* Make an AJAX request, handling any low-level errors that may occur.
|
||||
*
|
||||
* @see https://mithril.js.org/request.html
|
||||
*/
|
||||
request(originalOptions: Mithril.RequestOptions<JSON> | any): Promise<any> {
|
||||
const options: Mithril.RequestOptions<JSON> | any = Object.assign({}, originalOptions);
|
||||
|
||||
// Set some default options if they haven't been overridden. We want to
|
||||
// authenticate all requests with the session token. We also want all
|
||||
// requests to run asynchronously in the background, so that they don't
|
||||
// prevent redraws from occurring.
|
||||
options.background = options.background || true;
|
||||
|
||||
extend(options, 'config', (result, xhr: XMLHttpRequest) => xhr.setRequestHeader('X-CSRF-Token', this.session.csrfToken!));
|
||||
|
||||
// If the method is something like PATCH or DELETE, which not all servers
|
||||
// and clients support, then we'll send it as a POST request with the
|
||||
// intended method specified in the X-HTTP-Method-Override header.
|
||||
if (options.method !== 'GET' && options.method !== 'POST') {
|
||||
const method = options.method;
|
||||
extend(options, 'config', (result, xhr: XMLHttpRequest) => xhr.setRequestHeader('X-HTTP-Method-Override', method));
|
||||
options.method = 'POST';
|
||||
}
|
||||
|
||||
// When we deserialize JSON data, if for some reason the server has provided
|
||||
// a dud response, we don't want the application to crash. We'll show an
|
||||
// error message to the user instead.
|
||||
options.deserialize = options.deserialize || ((responseText) => responseText);
|
||||
|
||||
options.errorHandler =
|
||||
options.errorHandler ||
|
||||
((error) => {
|
||||
throw error;
|
||||
});
|
||||
|
||||
// When extracting the data from the response, we can check the server
|
||||
// response code and show an error message to the user if something's gone
|
||||
// awry.
|
||||
const original = options.extract;
|
||||
options.extract = (xhr) => {
|
||||
let responseText;
|
||||
|
||||
if (original) {
|
||||
responseText = original(xhr.responseText);
|
||||
} else {
|
||||
responseText = xhr.responseText || null;
|
||||
}
|
||||
|
||||
const status = xhr.status;
|
||||
|
||||
if (status < 200 || status > 299) {
|
||||
throw new RequestError(status, responseText, options, xhr);
|
||||
}
|
||||
|
||||
if (xhr.getResponseHeader) {
|
||||
const csrfToken = xhr.getResponseHeader('X-CSRF-Token');
|
||||
if (csrfToken) app.session.csrfToken = csrfToken;
|
||||
}
|
||||
|
||||
try {
|
||||
return JSON.parse(responseText);
|
||||
} catch (e) {
|
||||
throw new RequestError(500, responseText, options, xhr);
|
||||
}
|
||||
};
|
||||
|
||||
if (this.requestError) this.alerts.dismiss(this.requestError.alert);
|
||||
|
||||
// Now make the request. If it's a failure, inspect the error that was
|
||||
// returned and show an alert containing its contents.
|
||||
return m.request(options).then(
|
||||
(res) => res,
|
||||
(error) => {
|
||||
this.requestError = error;
|
||||
|
||||
let children;
|
||||
|
||||
switch (error.status) {
|
||||
case 422:
|
||||
children = error.response.errors
|
||||
.map((error) => [error.detail, m('br')])
|
||||
.reduce((a, b) => a.concat(b), [])
|
||||
.slice(0, -1);
|
||||
break;
|
||||
|
||||
case 401:
|
||||
case 403:
|
||||
children = this.translator.trans('core.lib.error.permission_denied_message');
|
||||
break;
|
||||
|
||||
case 404:
|
||||
case 410:
|
||||
children = this.translator.trans('core.lib.error.not_found_message');
|
||||
break;
|
||||
|
||||
case 429:
|
||||
children = this.translator.trans('core.lib.error.rate_limit_exceeded_message');
|
||||
break;
|
||||
|
||||
default:
|
||||
children = this.translator.trans('core.lib.error.generic_message');
|
||||
}
|
||||
|
||||
const isDebug = app.forum.attribute('debug');
|
||||
|
||||
error.alert = new AlertState({
|
||||
type: 'error',
|
||||
children,
|
||||
controls: isDebug && [
|
||||
Button.component({
|
||||
className: 'Button Button--link',
|
||||
onclick: this.showDebug.bind(this, error),
|
||||
children: 'DEBUG', // TODO make translatable
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
try {
|
||||
options.errorHandler(error);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
this.alerts.show(error.alert);
|
||||
}
|
||||
|
||||
return Promise.reject(error);
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
private showDebug(error: RequestError) {
|
||||
this.alerts.dismiss(this.requestError!.alert);
|
||||
|
||||
this.modal.show(RequestErrorModal, { error });
|
||||
}
|
||||
}
|
@@ -1,225 +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) {
|
||||
}
|
||||
}
|
90
js/src/common/Component.ts
Normal file
90
js/src/common/Component.ts
Normal file
@@ -0,0 +1,90 @@
|
||||
import Mithril, { ClassComponent, Vnode } from 'mithril';
|
||||
|
||||
export type ComponentProps = {
|
||||
children?: Mithril.Children;
|
||||
|
||||
className?: string;
|
||||
|
||||
[key: string]: any;
|
||||
};
|
||||
|
||||
export default class Component<T extends ComponentProps = any> implements ClassComponent {
|
||||
element!: HTMLElement;
|
||||
|
||||
props: T;
|
||||
|
||||
constructor(props: T = <T>{}) {
|
||||
this.props = props.tag ? <T>{} : props;
|
||||
}
|
||||
|
||||
view(vnode) {
|
||||
throw new Error('Component#view must be implemented by subclass');
|
||||
}
|
||||
|
||||
oninit(vnode) {
|
||||
this.setProps(vnode);
|
||||
}
|
||||
|
||||
oncreate(vnode) {
|
||||
this.setProps(vnode);
|
||||
this.element = vnode.dom;
|
||||
}
|
||||
|
||||
onbeforeupdate(vnode) {
|
||||
this.setProps(vnode);
|
||||
}
|
||||
|
||||
onupdate(vnode) {
|
||||
this.setProps(vnode);
|
||||
}
|
||||
|
||||
onbeforeremove(vnode) {
|
||||
this.setProps(vnode);
|
||||
}
|
||||
|
||||
onremove(vnode) {
|
||||
this.setProps(vnode);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 selector a jQuery-compatible selector string
|
||||
* @final
|
||||
*/
|
||||
$(selector?: string): ZeptoCollection {
|
||||
const $element = $(this.element);
|
||||
|
||||
return selector ? $element.find(selector) : $element;
|
||||
}
|
||||
|
||||
render() {
|
||||
return m(this.constructor as typeof Component, this.props);
|
||||
}
|
||||
|
||||
static component(props: ComponentProps | any = {}, children?: Mithril.Children) {
|
||||
const componentProps: ComponentProps = Object.assign({}, props);
|
||||
|
||||
if (children) componentProps.children = children;
|
||||
|
||||
return m(this, componentProps);
|
||||
}
|
||||
|
||||
static initProps(props: ComponentProps = {}) {}
|
||||
|
||||
private setProps(vnode: Vnode<T, this>) {
|
||||
const props = vnode.attrs || {};
|
||||
|
||||
(this.constructor as typeof Component).initProps(props);
|
||||
|
||||
if (!props.children) props.children = vnode.children;
|
||||
|
||||
this.props = props;
|
||||
}
|
||||
}
|
@@ -1,307 +0,0 @@
|
||||
/**
|
||||
* The `Model` class represents a local data resource. It provides methods to
|
||||
* persist changes via the API.
|
||||
*
|
||||
* @abstract
|
||||
*/
|
||||
export default class Model {
|
||||
/**
|
||||
* @param {Object} data A resource object from the API.
|
||||
* @param {Store} store The data store that this model should be persisted to.
|
||||
* @public
|
||||
*/
|
||||
constructor(data = {}, store = null) {
|
||||
/**
|
||||
* The resource object from the API.
|
||||
*
|
||||
* @type {Object}
|
||||
* @public
|
||||
*/
|
||||
this.data = data;
|
||||
|
||||
/**
|
||||
* The time at which the model's data was last updated. Watching the value
|
||||
* of this property is a fast way to retain/cache a subtree if data hasn't
|
||||
* changed.
|
||||
*
|
||||
* @type {Date}
|
||||
* @public
|
||||
*/
|
||||
this.freshness = new Date();
|
||||
|
||||
/**
|
||||
* Whether or not the resource exists on the server.
|
||||
*
|
||||
* @type {Boolean}
|
||||
* @public
|
||||
*/
|
||||
this.exists = false;
|
||||
|
||||
/**
|
||||
* The data store that this resource should be persisted to.
|
||||
*
|
||||
* @type {Store}
|
||||
* @protected
|
||||
*/
|
||||
this.store = store;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the model's ID.
|
||||
*
|
||||
* @return {Integer}
|
||||
* @public
|
||||
* @final
|
||||
*/
|
||||
id() {
|
||||
return this.data.id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get one of the model's attributes.
|
||||
*
|
||||
* @param {String} attribute
|
||||
* @return {*}
|
||||
* @public
|
||||
* @final
|
||||
*/
|
||||
attribute(attribute) {
|
||||
return this.data.attributes[attribute];
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge new data into this model locally.
|
||||
*
|
||||
* @param {Object} data A resource object to merge into this model
|
||||
* @public
|
||||
*/
|
||||
pushData(data) {
|
||||
// Since most of the top-level items in a resource object are objects
|
||||
// (e.g. relationships, attributes), we'll need to check and perform the
|
||||
// merge at the second level if that's the case.
|
||||
for (const key in data) {
|
||||
if (typeof data[key] === 'object') {
|
||||
this.data[key] = this.data[key] || {};
|
||||
|
||||
// For every item in a second-level object, we want to check if we've
|
||||
// been handed a Model instance. If so, we will convert it to a
|
||||
// relationship data object.
|
||||
for (const innerKey in data[key]) {
|
||||
if (data[key][innerKey] instanceof Model) {
|
||||
data[key][innerKey] = {data: Model.getIdentifier(data[key][innerKey])};
|
||||
}
|
||||
this.data[key][innerKey] = data[key][innerKey];
|
||||
}
|
||||
} else {
|
||||
this.data[key] = data[key];
|
||||
}
|
||||
}
|
||||
|
||||
// Now that we've updated the data, we can say that the model is fresh.
|
||||
// This is an easy way to invalidate retained subtrees etc.
|
||||
this.freshness = new Date();
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge new attributes into this model locally.
|
||||
*
|
||||
* @param {Object} attributes The attributes to merge.
|
||||
* @public
|
||||
*/
|
||||
pushAttributes(attributes) {
|
||||
this.pushData({attributes});
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge new attributes into this model, both locally and with persistence.
|
||||
*
|
||||
* @param {Object} attributes The attributes to save. If a 'relationships' key
|
||||
* exists, it will be extracted and relationships will also be saved.
|
||||
* @param {Object} [options]
|
||||
* @return {Promise}
|
||||
* @public
|
||||
*/
|
||||
save(attributes, options = {}) {
|
||||
const data = {
|
||||
type: this.data.type,
|
||||
id: this.data.id,
|
||||
attributes
|
||||
};
|
||||
|
||||
// If a 'relationships' key exists, extract it from the attributes hash and
|
||||
// set it on the top-level data object instead. We will be sending this data
|
||||
// object to the API for persistence.
|
||||
if (attributes.relationships) {
|
||||
data.relationships = {};
|
||||
|
||||
for (const key in attributes.relationships) {
|
||||
const model = attributes.relationships[key];
|
||||
|
||||
data.relationships[key] = {
|
||||
data: model instanceof Array
|
||||
? model.map(Model.getIdentifier)
|
||||
: Model.getIdentifier(model)
|
||||
};
|
||||
}
|
||||
|
||||
delete attributes.relationships;
|
||||
}
|
||||
|
||||
// Before we update the model's data, we should make a copy of the model's
|
||||
// old data so that we can revert back to it if something goes awry during
|
||||
// persistence.
|
||||
const oldData = this.copyData();
|
||||
|
||||
this.pushData(data);
|
||||
|
||||
const request = {data};
|
||||
if (options.meta) request.meta = options.meta;
|
||||
|
||||
return app.request(Object.assign({
|
||||
method: this.exists ? 'PATCH' : 'POST',
|
||||
url: app.forum.attribute('apiUrl') + this.apiEndpoint(),
|
||||
data: request
|
||||
}, options)).then(
|
||||
// If everything went well, we'll make sure the store knows that this
|
||||
// model exists now (if it didn't already), and we'll push the data that
|
||||
// the API returned into the store.
|
||||
payload => {
|
||||
this.store.data[payload.data.type] = this.store.data[payload.data.type] || {};
|
||||
this.store.data[payload.data.type][payload.data.id] = this;
|
||||
return this.store.pushPayload(payload);
|
||||
},
|
||||
|
||||
// If something went wrong, though... good thing we backed up our model's
|
||||
// old data! We'll revert to that and let others handle the error.
|
||||
response => {
|
||||
this.pushData(oldData);
|
||||
m.lazyRedraw();
|
||||
throw response;
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a request to delete the resource.
|
||||
*
|
||||
* @param {Object} data 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;
|
||||
|
||||
return app.request(Object.assign({
|
||||
method: 'DELETE',
|
||||
url: app.forum.attribute('apiUrl') + this.apiEndpoint(),
|
||||
data
|
||||
}, options)).then(() => {
|
||||
this.exists = false;
|
||||
this.store.remove(this);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Construct a path to the API endpoint for this resource.
|
||||
*
|
||||
* @return {String}
|
||||
* @protected
|
||||
*/
|
||||
apiEndpoint() {
|
||||
return '/' + this.data.type + (this.exists ? '/' + this.data.id : '');
|
||||
}
|
||||
|
||||
copyData() {
|
||||
return JSON.parse(JSON.stringify(this.data));
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a function which returns the value of the given attribute.
|
||||
*
|
||||
* @param {String} name
|
||||
* @param {function} [transform] A function to transform the attribute value
|
||||
* @return {*}
|
||||
* @public
|
||||
*/
|
||||
static attribute(name, transform) {
|
||||
return function() {
|
||||
const value = this.data.attributes && this.data.attributes[name];
|
||||
|
||||
return transform ? transform(value) : value;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a function which returns the value of the given has-one
|
||||
* relationship.
|
||||
*
|
||||
* @param {String} name
|
||||
* @return {Model|Boolean|undefined} false if no information about the
|
||||
* relationship exists; undefined if the relationship exists but the model
|
||||
* has not been loaded; or the model if it has been loaded.
|
||||
* @public
|
||||
*/
|
||||
static hasOne(name) {
|
||||
return function() {
|
||||
if (this.data.relationships) {
|
||||
const relationship = this.data.relationships[name];
|
||||
|
||||
if (relationship) {
|
||||
return app.store.getById(relationship.data.type, relationship.data.id);
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a function which returns the value of the given has-many
|
||||
* relationship.
|
||||
*
|
||||
* @param {String} name
|
||||
* @return {Array|Boolean} false if no information about the relationship
|
||||
* exists; an array if it does, containing models if they have been
|
||||
* loaded, and undefined for those that have not.
|
||||
* @public
|
||||
*/
|
||||
static hasMany(name) {
|
||||
return function() {
|
||||
if (this.data.relationships) {
|
||||
const relationship = this.data.relationships[name];
|
||||
|
||||
if (relationship) {
|
||||
return relationship.data.map(data => app.store.getById(data.type, data.id));
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Transform the given value into a Date object.
|
||||
*
|
||||
* @param {String} value
|
||||
* @return {Date|null}
|
||||
* @public
|
||||
*/
|
||||
static transformDate(value) {
|
||||
return value ? new Date(value) : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a resource identifier object for the given model.
|
||||
*
|
||||
* @param {Model} model
|
||||
* @return {Object}
|
||||
* @protected
|
||||
*/
|
||||
static getIdentifier(model) {
|
||||
return {
|
||||
type: model.data.type,
|
||||
id: model.data.id
|
||||
};
|
||||
}
|
||||
}
|
299
js/src/common/Model.ts
Normal file
299
js/src/common/Model.ts
Normal file
@@ -0,0 +1,299 @@
|
||||
import Store from './Store';
|
||||
|
||||
export interface Identifier {
|
||||
type: string;
|
||||
id: string;
|
||||
}
|
||||
|
||||
export interface Data extends Identifier {
|
||||
attributes?: { [key: string]: any };
|
||||
relationships?: { [key: string]: { data: Identifier | Identifier[] } };
|
||||
}
|
||||
|
||||
/**
|
||||
* The `Model` class represents a local data resource. It provides methods to
|
||||
* persist changes via the API.
|
||||
*/
|
||||
export default abstract class Model {
|
||||
/**
|
||||
* The resource object from the API.
|
||||
*/
|
||||
data: Data;
|
||||
|
||||
payload: any;
|
||||
|
||||
/**
|
||||
* The time at which the model's data was last updated. Watching the value
|
||||
* of this property is a fast way to retain/cache a subtree if data hasn't
|
||||
* changed.
|
||||
*/
|
||||
freshness: Date;
|
||||
|
||||
/**
|
||||
* Whether or not the resource exists on the server.
|
||||
*/
|
||||
exists: boolean;
|
||||
|
||||
/**
|
||||
* The data store that this resource should be persisted to.
|
||||
*/
|
||||
protected store?: Store;
|
||||
|
||||
/**
|
||||
* @param data A resource object from the API.
|
||||
* @param store The data store that this model should be persisted to.
|
||||
*/
|
||||
constructor(data = <Data>{}, store?: Store) {
|
||||
this.data = data;
|
||||
this.store = store;
|
||||
|
||||
this.freshness = new Date();
|
||||
this.exists = false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the model's ID.
|
||||
* @final
|
||||
*/
|
||||
id(): string {
|
||||
return this.data.id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get one of the model's attributes.
|
||||
* @final
|
||||
*/
|
||||
attribute(attribute: string): any {
|
||||
return this.data.attributes && this.data.attributes[attribute];
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge new data into this model locally.
|
||||
*
|
||||
* @param data A resource object to merge into this model
|
||||
*/
|
||||
public pushData(data: {}) {
|
||||
// Since most of the top-level items in a resource object are objects
|
||||
// (e.g. relationships, attributes), we'll need to check and perform the
|
||||
// merge at the second level if that's the case.
|
||||
for (const key in data) {
|
||||
if (typeof data[key] === 'object') {
|
||||
this.data[key] = this.data[key] || {};
|
||||
|
||||
// For every item in a second-level object, we want to check if we've
|
||||
// been handed a Model instance. If so, we will convert it to a
|
||||
// relationship data object.
|
||||
for (const innerKey in data[key]) {
|
||||
if (data[key][innerKey] instanceof Model) {
|
||||
data[key][innerKey] = { data: Model.getIdentifier(data[key][innerKey]) };
|
||||
}
|
||||
this.data[key][innerKey] = data[key][innerKey];
|
||||
}
|
||||
} else {
|
||||
this.data[key] = data[key];
|
||||
}
|
||||
}
|
||||
|
||||
// Now that we've updated the data, we can say that the model is fresh.
|
||||
// This is an easy way to invalidate retained subtrees etc.
|
||||
this.freshness = new Date();
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge new attributes into this model locally.
|
||||
*
|
||||
* @param attributes The attributes to merge.
|
||||
*/
|
||||
pushAttributes(attributes: any) {
|
||||
this.pushData({ attributes });
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge new attributes into this model, both locally and with persistence.
|
||||
*
|
||||
* @param attributes The attributes to save. If a 'relationships' key
|
||||
* exists, it will be extracted and relationships will also be saved.
|
||||
* @param [options]
|
||||
*/
|
||||
save(attributes: any, options: any = {}): Promise<Model | Model[]> {
|
||||
const data: Data = {
|
||||
type: this.data.type,
|
||||
id: this.data.id,
|
||||
attributes,
|
||||
};
|
||||
|
||||
// If a 'relationships' key exists, extract it from the attributes hash and
|
||||
// set it on the top-level data object instead. We will be sending this data
|
||||
// object to the API for persistence.
|
||||
if (attributes.relationships) {
|
||||
data.relationships = {};
|
||||
|
||||
for (const key in attributes.relationships) {
|
||||
const model = attributes.relationships[key];
|
||||
|
||||
data.relationships[key] = {
|
||||
data: model instanceof Array ? model.map(Model.getIdentifier) : Model.getIdentifier(model),
|
||||
};
|
||||
}
|
||||
|
||||
delete attributes.relationships;
|
||||
}
|
||||
|
||||
// Before we update the model's data, we should make a copy of the model's
|
||||
// old data so that we can revert back to it if something goes awry during
|
||||
// persistence.
|
||||
const oldData = this.copyData();
|
||||
|
||||
this.pushData(data);
|
||||
|
||||
const request = { data };
|
||||
if (options.meta) request.meta = options.meta;
|
||||
|
||||
return app
|
||||
.request(
|
||||
Object.assign(
|
||||
{
|
||||
method: this.exists ? 'PATCH' : 'POST',
|
||||
url: app.forum.attribute('apiUrl') + this.apiEndpoint(),
|
||||
body: request,
|
||||
},
|
||||
options
|
||||
)
|
||||
)
|
||||
.then(
|
||||
// If everything went well, we'll make sure the store knows that this
|
||||
// model exists now (if it didn't already), and we'll push the data that
|
||||
// the API returned into the store.
|
||||
(payload) => {
|
||||
this.store.data[payload.data.type] = this.store.data[payload.data.type] || {};
|
||||
this.store.data[payload.data.type][payload.data.id] = this;
|
||||
return this.store.pushPayload(payload);
|
||||
},
|
||||
|
||||
// If something went wrong, though... good thing we backed up our model's
|
||||
// old data! We'll revert to that and let others handle the error.
|
||||
(response) => {
|
||||
this.pushData(oldData);
|
||||
m.redraw();
|
||||
throw response;
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a request to delete the resource.
|
||||
*
|
||||
* @param {Object} body Data to send along with the DELETE request.
|
||||
* @param {Object} [options]
|
||||
* @return {Promise}
|
||||
* @public
|
||||
*/
|
||||
delete(body = {}, options = {}) {
|
||||
if (!this.exists) return Promise.resolve();
|
||||
|
||||
return app
|
||||
.request(
|
||||
Object.assign(
|
||||
{
|
||||
method: 'DELETE',
|
||||
url: app.forum.attribute('apiUrl') + this.apiEndpoint(),
|
||||
body,
|
||||
},
|
||||
options
|
||||
)
|
||||
)
|
||||
.then(() => {
|
||||
this.exists = false;
|
||||
this.store!.remove(this);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Construct a path to the API endpoint for this resource.
|
||||
*
|
||||
* @return {String}
|
||||
* @protected
|
||||
*/
|
||||
apiEndpoint() {
|
||||
return '/' + this.data.type + (this.exists ? '/' + this.data.id : '');
|
||||
}
|
||||
|
||||
copyData() {
|
||||
return JSON.parse(JSON.stringify(this.data));
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a function which returns the value of the given attribute.
|
||||
*
|
||||
* @param name
|
||||
* @param [transform] A function to transform the attribute value
|
||||
*/
|
||||
static attribute(name: string, transform?: Function): () => any {
|
||||
return function (this: Model) {
|
||||
const value = this.data.attributes && this.data.attributes[name];
|
||||
|
||||
return transform ? transform(value) : value;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a function which returns the value of the given has-one
|
||||
* relationship.
|
||||
*
|
||||
* @return false if no information about the
|
||||
* relationship exists; undefined if the relationship exists but the model
|
||||
* has not been loaded; or the model if it has been loaded.
|
||||
*/
|
||||
static hasOne(name: string): () => Model | boolean {
|
||||
return function (this: Model) {
|
||||
if (this.data.relationships) {
|
||||
const relationship = this.data.relationships[name];
|
||||
|
||||
if (relationship && !Array.isArray(relationship.data)) {
|
||||
return app.store.getById(relationship.data.type, relationship.data.id);
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a function which returns the value of the given has-many
|
||||
* relationship.
|
||||
*
|
||||
* @return false if no information about the relationship
|
||||
* exists; an array if it does, containing models if they have been
|
||||
* loaded, and undefined for those that have not.
|
||||
*/
|
||||
static hasMany(name: string): () => any[] | false {
|
||||
return function (this: Model) {
|
||||
if (this.data.relationships) {
|
||||
const relationship = this.data.relationships[name];
|
||||
|
||||
if (relationship && Array.isArray(relationship.data)) {
|
||||
return relationship.data.map((data) => app.store.getById(data.type, data.id));
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Transform the given value into a Date object.
|
||||
*/
|
||||
static transformDate(value: string): Date | null {
|
||||
return value ? new Date(value) : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a resource identifier object for the given model.
|
||||
*/
|
||||
protected static getIdentifier(model: Model): Identifier {
|
||||
return {
|
||||
type: model.data.type,
|
||||
id: model.data.id,
|
||||
};
|
||||
}
|
||||
}
|
@@ -1,49 +0,0 @@
|
||||
/**
|
||||
* The `Session` class defines the current user session. It stores a reference
|
||||
* to the current authenticated user, and provides methods to log in/out.
|
||||
*/
|
||||
export default class Session {
|
||||
constructor(user, csrfToken) {
|
||||
/**
|
||||
* The current authenticated user.
|
||||
*
|
||||
* @type {User|null}
|
||||
* @public
|
||||
*/
|
||||
this.user = user;
|
||||
|
||||
/**
|
||||
* The CSRF token.
|
||||
*
|
||||
* @type {String|null}
|
||||
* @public
|
||||
*/
|
||||
this.csrfToken = csrfToken;
|
||||
}
|
||||
|
||||
/**
|
||||
* Attempt to log in a user.
|
||||
*
|
||||
* @param {String} identification The username/email.
|
||||
* @param {String} password
|
||||
* @param {Object} [options]
|
||||
* @return {Promise}
|
||||
* @public
|
||||
*/
|
||||
login(data, options = {}) {
|
||||
return app.request(Object.assign({
|
||||
method: 'POST',
|
||||
url: app.forum.attribute('baseUrl') + '/login',
|
||||
data
|
||||
}, options));
|
||||
}
|
||||
|
||||
/**
|
||||
* Log the user out.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
logout() {
|
||||
window.location = app.forum.attribute('baseUrl') + '/logout?token=' + this.csrfToken;
|
||||
}
|
||||
}
|
48
js/src/common/Session.ts
Normal file
48
js/src/common/Session.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
import User from './models/User';
|
||||
|
||||
/**
|
||||
* The `Session` class defines the current user session. It stores a reference
|
||||
* to the current authenticated user, and provides methods to log in/out.
|
||||
*/
|
||||
export default class Session {
|
||||
/**
|
||||
* The current authenticated user.
|
||||
*/
|
||||
user: User;
|
||||
|
||||
/**
|
||||
* The CSRF token.
|
||||
*/
|
||||
csrfToken?: string;
|
||||
|
||||
constructor(user, csrfToken) {
|
||||
this.user = user;
|
||||
|
||||
this.csrfToken = csrfToken;
|
||||
}
|
||||
|
||||
/**
|
||||
* Attempt to log in a user.
|
||||
*/
|
||||
login(body: { identification: string; password: string; remember?: boolean }, options = {}) {
|
||||
return app.request(
|
||||
Object.assign(
|
||||
{
|
||||
method: 'POST',
|
||||
url: `${app.forum.attribute('baseUrl')}/login`,
|
||||
body,
|
||||
},
|
||||
options
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Log the user out.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
logout() {
|
||||
window.location.href = `${app.forum.attribute('baseUrl')}/logout?token=${this.csrfToken}`;
|
||||
}
|
||||
}
|
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user