mirror of
https://github.com/flarum/core.git
synced 2025-08-13 20:04:24 +02:00
Compare commits
201 Commits
ck/floatin
...
1236-user-
Author | SHA1 | Date | |
---|---|---|---|
|
4eaf9ae23f | ||
|
4d80700e97 | ||
|
8ad95a4dfb | ||
|
03faaaedef | ||
|
5c3a0e3e6e | ||
|
588f7498e1 | ||
|
8d1240559b | ||
|
7ad8eb7544 | ||
|
ebd2c69c8d | ||
|
637ae1624c | ||
|
0c9bcba3a6 | ||
|
5b3acfc0d9 | ||
|
7adfb5bd7e | ||
|
6b916065e9 | ||
|
f99f48b155 | ||
|
d2c345c834 | ||
|
966a093911 | ||
|
0560238945 | ||
|
63801484fa | ||
|
f8d92edc9a | ||
|
e1dbfa7d68 | ||
|
ef7623e4ff | ||
|
d7fd076220 | ||
|
30a2421749 | ||
|
1bf6e79b32 | ||
|
8aaa39bd4e | ||
|
b1e11830d1 | ||
|
3375f283eb | ||
|
df4c193ab7 | ||
|
5a8326f442 | ||
|
d8dd870efe | ||
|
60572b93fb | ||
|
810bfdc28f | ||
|
9c4a24b258 | ||
|
18462c079f | ||
|
10d6e653cb | ||
|
4e1f753f59 | ||
|
3c2dd23765 | ||
|
aebc23cba2 | ||
|
f6d03771cb | ||
|
54be9573ac | ||
|
bf46ea3840 | ||
|
bab49650e6 | ||
|
a789c6b4e9 | ||
|
3b3459ad3d | ||
|
223f4d93d4 | ||
|
5d1fe9b815 | ||
|
521834f5da | ||
|
622e2a6644 | ||
|
c5e38a5b1f | ||
|
9d2595d531 | ||
|
3526083320 | ||
|
82562294b7 | ||
|
51ae92f841 | ||
|
6448babaa5 | ||
|
f7feea496d | ||
|
75e624d7ca | ||
|
5a01b63c99 | ||
|
cae4a6eb45 | ||
|
36d6d79011 | ||
|
22c599b283 | ||
|
456f5095da | ||
|
0b3ce2e7d0 | ||
|
607eeb530d | ||
|
5f4efe3c66 | ||
|
fac61b5bce | ||
|
ea64de5952 | ||
|
ff6c407e53 | ||
|
c4ba13f608 | ||
|
cef46ec357 | ||
|
5788c7373e | ||
|
8fdd8a1089 | ||
|
dc31a0a076 | ||
|
29eb233dd1 | ||
|
f6e48fa054 | ||
|
1b5e8f221a | ||
|
f903487ef3 | ||
|
1da4b72eac | ||
|
55bdad55fc | ||
|
b603c7b336 | ||
|
a530c52fb4 | ||
|
f7dc716042 | ||
|
a72f87d7ee | ||
|
5dbe97630c | ||
|
23709a77a2 | ||
|
29a2c247a1 | ||
|
a7f97c14ec | ||
|
8fcd62955b | ||
|
618e91805f | ||
|
431ab9f3e8 | ||
|
4f06133d75 | ||
|
bf79f2474c | ||
|
1b74e43cb9 | ||
|
3643e2010b | ||
|
58299edc20 | ||
|
18774e0b10 | ||
|
86d890d043 | ||
|
ab0ba707e7 | ||
|
04b2cf4462 | ||
|
28e3ec4014 | ||
|
a6decb2350 | ||
|
1e55361539 | ||
|
e80f5429d0 | ||
|
108a23c1eb | ||
|
1dd329982a | ||
|
e0c2ef5e64 | ||
|
d654517c91 | ||
|
0232d949e9 | ||
|
6363753d0f | ||
|
0918b04fe2 | ||
|
929d7b87c1 | ||
|
544f687cf4 | ||
|
a7ed625d16 | ||
|
a67eca0c9e | ||
|
855dd2445a | ||
|
1a3d955b4f | ||
|
8db91e3395 | ||
|
d725012a84 | ||
|
5a03cd865a | ||
|
0a32a96207 | ||
|
1587d48e59 | ||
|
b750554011 | ||
|
db7e28d316 | ||
|
14e89546ca | ||
|
92642519d4 | ||
|
f779f4d092 | ||
|
7b73036441 | ||
|
8b628be507 | ||
|
51f4bcdcb0 | ||
|
47a528305b | ||
|
6121229c6f | ||
|
df7f1291a7 | ||
|
52e73b2481 | ||
|
d08f851c0b | ||
|
22b32bd601 | ||
|
6797770c75 | ||
|
4cab48c0fd | ||
|
f7222d7e20 | ||
|
53c728b184 | ||
|
1d525d0a78 | ||
|
301e571772 | ||
|
e7c12ce928 | ||
|
5d5ebc088e | ||
|
6e62240153 | ||
|
17d1942c5c | ||
|
e786e297ef | ||
|
2829618814 | ||
|
5875b31fd5 | ||
|
ae59bf549f | ||
|
d45bf04341 | ||
|
7f9588af62 | ||
|
17dfb58590 | ||
|
c5e3e26d07 | ||
|
5d768db6d2 | ||
|
6e089c12d4 | ||
|
5ddb843eb2 | ||
|
bbeacc0299 | ||
|
82480457ce | ||
|
685459c0bc | ||
|
347edcf2cd | ||
|
731a038f29 | ||
|
af5113eb7b | ||
|
ddfb2c1ec1 | ||
|
6cf3c1088d | ||
|
2f174edfd0 | ||
|
2c231aa475 | ||
|
1e5c7e54ee | ||
|
408043a203 | ||
|
9b449386d6 | ||
|
f1d9753aee | ||
|
54f733ca80 | ||
|
a737b98e7f | ||
|
80546b9ed7 | ||
|
9758dfac47 | ||
|
970c0f5604 | ||
|
42a7f2f586 | ||
|
3611fa1bb9 | ||
|
c881f9f633 | ||
|
0a22a66189 | ||
|
64b53fb0ac | ||
|
627724839d | ||
|
fa3915fa53 | ||
|
1948b7e6f4 | ||
|
4465e7648b | ||
|
aef56c055a | ||
|
dd3a4173a1 | ||
|
7c204c82ab | ||
|
12fff33763 | ||
|
603367a41a | ||
|
6bdebfbf3c | ||
|
58ab6052ad | ||
|
3737ce8146 | ||
|
ca5404db76 | ||
|
d6fc3a91a6 | ||
|
31134ca16d | ||
|
6cfc9182f4 | ||
|
caa63107ad | ||
|
0acab8f1c7 | ||
|
c19b2e99bd | ||
|
e7a5e1e5e2 | ||
|
209c4c6143 |
2
.gitattributes
vendored
2
.gitattributes
vendored
@@ -11,5 +11,3 @@ phpunit.xml export-ignore
|
||||
tests export-ignore
|
||||
|
||||
js/dist/* -diff
|
||||
|
||||
* text=auto eol=lf
|
||||
|
31
.github/workflows/lint.yml
vendored
31
.github/workflows/lint.yml
vendored
@@ -1,31 +0,0 @@
|
||||
name: Lint
|
||||
|
||||
on:
|
||||
push:
|
||||
paths:
|
||||
- 'js/src/**'
|
||||
pull_request:
|
||||
paths:
|
||||
- 'js/src/**'
|
||||
|
||||
jobs:
|
||||
prettier:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
name: JS / 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
|
37
.github/workflows/test.yml
vendored
37
.github/workflows/test.yml
vendored
@@ -8,7 +8,7 @@ jobs:
|
||||
|
||||
strategy:
|
||||
matrix:
|
||||
php: ['7.2', '7.3', '7.4', '8.0']
|
||||
php: [7.1, 7.2, 7.3]
|
||||
service: ['mysql:5.7', mariadb]
|
||||
prefix: ['', flarum_]
|
||||
|
||||
@@ -21,24 +21,18 @@ jobs:
|
||||
prefixStr: (prefix)
|
||||
|
||||
exclude:
|
||||
- php: 7.1
|
||||
service: 'mysql:5.7'
|
||||
prefix: flarum_
|
||||
- php: 7.1
|
||||
service: mariadb
|
||||
prefix: flarum_
|
||||
- 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_
|
||||
- php: 8.0
|
||||
service: 'mysql:5.7'
|
||||
prefix: flarum_
|
||||
- php: 8.0
|
||||
service: mariadb
|
||||
prefix: flarum_
|
||||
|
||||
services:
|
||||
mysql:
|
||||
@@ -51,22 +45,11 @@ jobs:
|
||||
steps:
|
||||
- uses: actions/checkout@master
|
||||
|
||||
- name: Setup PHP
|
||||
uses: shivammathur/setup-php@v2
|
||||
with:
|
||||
php-version: ${{ matrix.php }}
|
||||
coverage: xdebug
|
||||
extensions: curl, dom, gd, json, mbstring, openssl, pdo_mysql, tokenizer, zip
|
||||
tools: phpunit, composer:v2
|
||||
- name: Select PHP version
|
||||
run: sudo update-alternatives --set php $(which php${{ matrix.php }})
|
||||
|
||||
# The authentication alter is necessary because newer mysql versions use the `caching_sha2_password` driver,
|
||||
# which isn't supported prior to PHP7.4
|
||||
# When we drop support for PHP7.3, we should remove this from the setup.
|
||||
- name: Create MySQL Database
|
||||
run: |
|
||||
sudo systemctl start mysql
|
||||
mysql -uroot -proot -e 'CREATE DATABASE flarum_test;' --port 13306
|
||||
mysql -uroot -proot -e "ALTER USER 'root'@'localhost' IDENTIFIED WITH mysql_native_password BY 'root';" --port 13306
|
||||
run: mysql -uroot -proot -e 'CREATE DATABASE flarum_test;' --port 13306
|
||||
|
||||
- name: Install Composer dependencies
|
||||
run: composer install
|
||||
|
1
.gitignore
vendored
1
.gitignore
vendored
@@ -7,4 +7,3 @@ Thumbs.db
|
||||
/tests/integration/tmp
|
||||
.vagrant
|
||||
.idea/*
|
||||
.vscode
|
||||
|
244
CHANGELOG.md
244
CHANGELOG.md
@@ -1,251 +1,9 @@
|
||||
# Changelog
|
||||
|
||||
## [0.1.0-beta.15](https://github.com/flarum/core/compare/v0.1.0-beta.14.1...v0.1.0-beta.15)
|
||||
|
||||
### Added
|
||||
|
||||
- Slug drivers support (https://github.com/flarum/core/pull/2456).
|
||||
- Notification type extender (https://github.com/flarum/core/pull/2424).
|
||||
- Validation extender (https://github.com/flarum/core/pull/2102).
|
||||
- Post extender (https://github.com/flarum/core/pull/2101).
|
||||
- Notification channel extender (https://github.com/flarum/core/pull/2432).
|
||||
- Service provider extender (https://github.com/flarum/core/pull/2437).
|
||||
- API serializer extender (https://github.com/flarum/core/pull/2438).
|
||||
- User preferences extender (https://github.com/flarum/core/pull/2463).
|
||||
- Settings extender (https://github.com/flarum/core/pull/2452).
|
||||
- ApiController extender (https://github.com/flarum/core/pull/2451).
|
||||
- Model visibility extender (https://github.com/flarum/core/pull/2460).
|
||||
- Policy extender (https://github.com/flarum/core/pull/2461).
|
||||
|
||||
### Changed
|
||||
|
||||
- Time helpers converted to Typescript (https://github.com/flarum/core/pull/2391).
|
||||
- Improved the formatter extender (https://github.com/flarum/core/pull/2098).
|
||||
- Improve wording on installer when facing file permission issues (https://github.com/flarum/core/pull/2435).
|
||||
- Background color of checkbox toggles improved for better usability (https://github.com/flarum/core/pull/2443).
|
||||
- Route resolving refactored (https://github.com/flarum/core/pull/2425).
|
||||
- Administration panel UX refactored (https://github.com/flarum/core/pull/2409).
|
||||
- Floodgate moved to middleware and extender added (https://github.com/flarum/core/pull/2170).
|
||||
- DRY up image uploading logic (https://github.com/flarum/core/pull/2477).
|
||||
- Process isolation on testing (https://github.com/flarum/core/commit/984f751c718c89501cc09857bc271efa2c7eea8c).
|
||||
- Forum and admin javascript exports namespaced (https://github.com/flarum/core/pull/2488).
|
||||
|
||||
### Fixed
|
||||
|
||||
- Web updater does not take into account subfolder installations (https://github.com/flarum/core/pull/2426).
|
||||
- Callables handling in extenders failed (https://github.com/flarum/core/pull/2423).
|
||||
- Scrolling on mobile from PostSteam changes didn't work correctly (https://github.com/flarum/core/pull/2385).
|
||||
- Side pane covers part of the discussion page due to `app.discussions` being empty (https://github.com/flarum/core/commit/102e76b084bf47fdfb4c73f95e1fbb322537f7aa).
|
||||
- Change email modal keeps showing the previous error message even on success (https://github.com/flarum/core/pull/2467).
|
||||
- Comment count not updated when discussions are deleted (https://github.com/flarum/core/pull/2472).
|
||||
- `goToIndex` in PostStream does not trigger an xhr to retrieve new data (https://github.com/flarum/core/commit/09e2736cbcc267594b660beabbd001d9030f9880).
|
||||
- On refresh the post number is reduced by one (https://github.com/flarum/core/pull/2476).
|
||||
- Queue worker would instantiate a new Queue factory, not the bound one (https://github.com/flarum/core/pull/2481).
|
||||
- Header accidentally has a border bottom (https://github.com/flarum/core/pull/2489).
|
||||
- Namespace mentioned in docblock is incorrect (https://github.com/flarum/core/pull/2494).
|
||||
- Scrolling inside longer discussions (especially Firefox) skips posts (https://github.com/flarum/core/commit/210a6b3e253d7917bd1eacd3ed8d2f95073ae99d).
|
||||
- Uploading avatars that are jpg/jpeg fails with a validation error (https://github.com/flarum/core/pull/2497).
|
||||
|
||||
### Removed
|
||||
|
||||
- MomentJS alias (https://github.com/flarum/core/pull/2428).
|
||||
- Deprecated user events `GetDisplayName` and `PrepareUserGroups` (https://github.com/flarum/core/pull/2428).
|
||||
- AssertPermissionTrait (https://github.com/flarum/core/pull/2428).
|
||||
- Path related helpers and methods in Application (https://github.com/flarum/core/pull/2428).
|
||||
- Backward compatibility layers from the frontend rewrite (https://github.com/flarum/core/pull/2428).
|
||||
|
||||
### Deprecated
|
||||
|
||||
- `CheckingForFlooding` (https://github.com/flarum/core/commit/8e25bcb68f86cc992c46dfa70368419fe9f936ac).
|
||||
|
||||
## [0.1.0-beta.14.1](https://github.com/flarum/core/compare/v0.1.0-beta.14...v0.1.0-beta.14.1)
|
||||
|
||||
### Fixed
|
||||
|
||||
- SuperTextarea component is not exported.
|
||||
- Symfony dependencies do not match those depended on by Laravel (https://github.com/flarum/core/pull/2407).
|
||||
- Scripts from textformatter aren't executed (https://github.com/flarum/core/pull/2415)
|
||||
- Sub path installations have no page title.
|
||||
- Losing focus of Composer area when coming from fullscreen.
|
||||
|
||||
## [0.1.0-beta.14](https://github.com/flarum/core/compare/v0.1.0-beta.13...v0.1.0-beta.14)
|
||||
|
||||
### Added
|
||||
|
||||
- Check dependencies before enabling / disabling extensions (https://github.com/flarum/core/pull/2188)
|
||||
- Set up temporary infrastructure for TypeScript in core (https://github.com/flarum/core/pull/2206)
|
||||
- Better UI for request error modals (https://github.com/flarum/core/pull/1929)
|
||||
- Display name extender, tests, frontend UI (https://github.com/flarum/core/pull/2174)
|
||||
- Scroll to post or show alert when editing a post from another page (https://github.com/flarum/core/pull/2108)
|
||||
- Feature to test email config by sending an email to the current user (https://github.com/flarum/core/pull/2023)
|
||||
- Allow searching users by group ID using the group gambit (https://github.com/flarum/core/pull/2192)
|
||||
- Use `liveHumanTimes` helper to update times without reload/rerender (https://github.com/flarum/core/pull/2208)
|
||||
- View extender, tests (https://github.com/flarum/core/pull/2134)
|
||||
- User extender to replace `PrepareUserGroups` (https://github.com/flarum/core/pull/2110)
|
||||
- Increase extensibility of skeleton PHP (https://github.com/flarum/core/pull/2308, https://github.com/flarum/core/pull/2318)
|
||||
- Pass a translator instance to `getEmailSubject` in `MailableInterface` (https://github.com/flarum/core/pull/2244)
|
||||
- Force LF line endings on windows (https://github.com/flarum/core/pull/2321)
|
||||
- Add a `Link` component for internal and external links (https://github.com/flarum/core/pull/2315)
|
||||
- `ConfirmDocumentUnload` component
|
||||
- Error handler middleware can now be manipulated by the middleware extender
|
||||
|
||||
### Changed
|
||||
|
||||
- Update to Mithril 2 (https://github.com/flarum/core/pull/2255)
|
||||
- Stop storing component instances (https://github.com/flarum/core/issues/1821, https://github.com/flarum/core/issues/2144)
|
||||
- Update to Laravel 6.x (https://github.com/flarum/core/issues/2055)
|
||||
- `Flarum\Foundation\Application` no longer implements `Illuminate\Contracts\Foundation\Application` (#2142)
|
||||
- `Flarum\Foundation\Application` no longer inherits `Illuminate\Container\Container` (#2142)
|
||||
- `paths` have been split off from `Flarum\Foundation\Application` into `Flarum\Foundation\Paths`, which can be injected where needed (#2142)
|
||||
- `Flarum\User\Gate` no longer implements `Illuminate\Contracts\Auth\Access\Gate` (https://github.com/flarum/core/pull/2181)
|
||||
- Improve Group Gambit performance (https://github.com/flarum/core/pull/2192)
|
||||
- Switch to `dayjs` from `momentjs` (https://github.com/flarum/core/pull/2219)
|
||||
- Don't create a `bio` column in `users` for new installations (https://github.com/flarum/core/pull/2215)
|
||||
- Start converting core JS to TypeScript (https://github.com/flarum/core/pull/2207)
|
||||
- Make Carbon an explicit dependency (https://github.com/flarum/core/commit/3b39c212e0fef7522e7d541a9214ff3817138d5d)
|
||||
- Use Symfony's translator interface instead of Laravel's (https://github.com/flarum/core/pull/2243)
|
||||
- Use newer versions of fontawesome (https://github.com/flarum/core/pull/2274)
|
||||
- Use URL generator instead of `app()->url()` where possible (https://github.com/flarum/core/pull/2302)
|
||||
- Move config from `config.php` into an injectable helper class (https://github.com/flarum/core/pull/2271)
|
||||
- Use reserved TLD for bogus and test urls (https://github.com/flarum/core/commit/6860b24b70bd04544dde90e537ce021a5fc5a689)
|
||||
- Replace `m.stream` with `flarum/utils/Stream` (https://github.com/flarum/core/pull/2316)
|
||||
- Replace `affixedSidebar` util with `AffixedSidebar` component
|
||||
- Replace `m.withAttr` with `flarum/utils/withAttr`
|
||||
- Scroll Listener is now passive, performance improvement (https://github.com/flarum/core/pull/2387)
|
||||
|
||||
### Fixed
|
||||
|
||||
- `generate:migration` command for extensions (https://github.com/flarum/core/commit/443949f7b9d7558dbc1e0994cb898cbac59bec87)
|
||||
- Container config for `UninstalledSite` (https://github.com/flarum/core/commit/ecdce44d555dd36a365fd472b2916e677ef173cf)
|
||||
- Tooltip glitch on page chang (https://github.com/flarum/core/issues/2118)
|
||||
- Using multiple extenders in tests (https://github.com/flarum/core/commit/c4f4f218bf4b175a30880b807f9ccb1a37a25330)
|
||||
- Header glitch when opening modals (https://github.com/flarum/core/pull/2131)
|
||||
- Ensure `SameSite` is explicitly set for cookies (https://github.com/flarum/core/pull/2159)
|
||||
- Ensure `Flarum\User\Event\AvatarChanged` event is properly dispatched (https://github.com/flarum/core/pull/2197)
|
||||
- Show correct error message on wrong password when changing email (https://github.com/flarum/core/pull/2171)
|
||||
- Discussion unreadCount could be higher than commentCount if posts deleted (https://github.com/flarum/core/pull/2195)
|
||||
- Don't show page title on the default route (https://github.com/flarum/core/pull/2047)
|
||||
- Add page title to `All Discussions` page when it isn't the default route (https://github.com/flarum/core/pull/2047)
|
||||
- Accept `'0'` as `false` for `flarum/components/Checkbox` (https://github.com/flarum/core/pull/2210)
|
||||
- Fix PostStreamScrubber background (https://github.com/flarum/core/pull/2222)
|
||||
- Test port on BaseUrl tests (https://github.com/flarum/core/pull/2226)
|
||||
- `UrlGenerator` can now generate urls with optional parameters (https://github.com/flarum/core/pull/2246)
|
||||
- Allow `less` to be compiled independently of Flarum (https://github.com/flarum/core/pull/2252)
|
||||
- Use correct number abbreviation (https://github.com/flarum/core/pull/2261)
|
||||
- Ensure avatar html uses alt tags for accessibility (https://github.com/flarum/core/pull/2269)
|
||||
- Escape regex when searching (https://github.com/flarum/core/pull/2273)
|
||||
- Remove unneeded semicolons inserted during JS compilation (https://github.com/flarum/core/pull/2280)
|
||||
- Don't require a username/password for SMTP (https://github.com/flarum/core/pull/2287)
|
||||
- Allow uppercase entries for SMTP encryption validation (https://github.com/flarum/core/pull/2289)
|
||||
- Ensure that the right number of posts is returned from list posts API (https://github.com/flarum/core/pull/2291)
|
||||
- Fix a variety of PostStream bugs (https://github.com/flarum/core/pull/2160, https://github.com/flarum/core/pull/2160)
|
||||
- Sliding discussion glitch on mobile (https://github.com/flarum/core/pull/2324)
|
||||
- Sliding discussion button in wrong place (https://github.com/flarum/core/pull/2330, https://github.com/flarum/core/pull/2383)
|
||||
- Sliding discussion glitch on mobile (https://github.com/flarum/core/pull/2381)
|
||||
- Fix PostStream for posts with top margins, and scrubber position when scrolling below posts (https://github.com/flarum/core/pull/2369)
|
||||
|
||||
### Removed
|
||||
|
||||
- `Flarum\Event\AbstractConfigureRoutes` event class
|
||||
- `Flarum\Event\ConfigureApiRoutes` event class
|
||||
- `Flarum\Event\ConfigureForumRoutes` event class
|
||||
- `Flarum\Console\Event\Configuring` event class
|
||||
- `Flarum\Event\ConfigureModelDates` event class
|
||||
- `Flarum\Event\ConfigureLocales` event class
|
||||
- `Flarum\Event\ConfigureModelDefaultAttributes` event class
|
||||
- `Flarum\Event\GetModelRelationship` event class
|
||||
- `Flarum\User\Event\BioChanged` event class
|
||||
- `Flarum\Database\MigrationServiceProvider` moved into `Flarum\Database\DatabaseServiceProvider`
|
||||
- Unused `admin/components/Widget` component (`admin/component/DashboardWidget` should be used instead)
|
||||
- Mandrill mail driver (https://github.com/flarum/core/commit/bca833d3f1c34d45d95bf905902368a2753b8908)
|
||||
|
||||
### Deprecated
|
||||
|
||||
- `Flarum\User\Event\GetDisplayName` event class
|
||||
- Global path helpers, `Flarum\Foundation\Application` path methods (https://github.com/flarum/core/pull/2155)
|
||||
- `Flarum\User\AssertPermissionTrait` (https://github.com/flarum/core/pull/2044)
|
||||
|
||||
## [0.1.0-beta.13](https://github.com/flarum/core/compare/v0.1.0-beta.12...v0.1.0-beta.13)
|
||||
|
||||
### Added
|
||||
- Console extender (#2057)
|
||||
- CSRF extender (#2095)
|
||||
- Event extender (#2097)
|
||||
- Mail extender (#2012)
|
||||
- Model extender (#2100)
|
||||
- Posts by users that started a discussion now have the CSS class `.Post--by-start-user`
|
||||
- PHPUnit 8 compatibility
|
||||
- Composer 2 compatibility
|
||||
- Permission groups can now be hidden (#2129)
|
||||
- Confirmation popup when hiding or deleting posts (#2135)
|
||||
|
||||
### Changed
|
||||
- Updated less.php dependency version to 3.0
|
||||
- Updated JS dependencies
|
||||
- All notifications and other emails now processed through the queue, if enabled (#978, #1928, #1931, #2096)
|
||||
- Simplified uploads, removing need to store intermediate files (#2117)
|
||||
- Improved date handling for dates older than 1 year (#2034)
|
||||
- Linting and automatic formatting for JS (#2099)
|
||||
- Translation files from Language Packs are only loaded for extensions that are enabled (#2020)
|
||||
- PHP extenders' properties are now `private` instead of `protected`, intentionally making it harder to extend these classes (#1958)
|
||||
- Preparation for upgrading Laravel components to 5.8 and then 6.0 (#2055, #2117)
|
||||
- Allowed permission checks based on model classes in addition to instances (#1977)
|
||||
|
||||
### Fixed
|
||||
- Users can no longer restore discussions hidden by admins (#2037)
|
||||
- Issues of the Modal not showing or auto hiding (#1504, #1813, #2080)
|
||||
- Columnar layout on admin extensions page was broken in Firefox (#2029, #2111)
|
||||
- Non-dismissible modals could still be dismissed using the ESC key (#1917)
|
||||
- New discussions were added to the discussion list above unread sticky posts (#1751, #1868)
|
||||
- New discussions not visible to users when using Pusher (#2076, #2077)
|
||||
- Permission icons were aligned unevenly in admin permissions list (#2016, #2018)
|
||||
- Notification bubble not inversed on mobile with colored header (#1983, #2109)
|
||||
- Post stream scrubber clicks jumped back to first post (#1945)
|
||||
- Loading state of Switch toggle component was hard to see (#2039, #1491)
|
||||
- `Flarum\Extend\Middleware`: The methods `insertBefore()` and `insertAfter()` did not work as described (#2063, #2084)
|
||||
|
||||
### Removed
|
||||
- Support for PHP 7.1 (#2014)
|
||||
- Zend compatibility bridge (#2010)
|
||||
- SES mail support (#2011)
|
||||
- Backward compatibility layer for `Flarum\Mail\DriverInterface`, new methods from beta.12 are now required
|
||||
- `Flarum\Util\Str` helper class
|
||||
- `Flarum\Event\ConfigureMiddleware` event
|
||||
|
||||
### Deprecated
|
||||
- `Flarum\Event\AbstractConfigureRoutes` event class
|
||||
- `Flarum\Event\ConfigureApiRoutes` event class
|
||||
- `Flarum\Event\ConfigureForumRoutes` event class
|
||||
- `Flarum\Event\ConfigureLocales` event class
|
||||
|
||||
## [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)
|
||||
|
13
README.md
13
README.md
@@ -1,14 +1,12 @@
|
||||
<p align="center"><img src="https://flarum.org/assets/img/logo.png"></p>
|
||||
<p align="center"><img src="https://flarum.org/img/logo.png"></p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://github.com/flarum/core/actions?query=workflow%3ATests"><img src="https://github.com/flarum/core/workflows/Tests/badge.svg" alt="PHP Tests"></a>
|
||||
<a href="https://packagist.org/packages/flarum/core"><img src="https://img.shields.io/packagist/dt/flarum/core" alt="Total Downloads"></a>
|
||||
<a href="https://packagist.org/packages/flarum/core"><img src="https://img.shields.io/github/v/release/flarum/core?sort=semver" alt="Latest Version"></a>
|
||||
<a href="https://packagist.org/packages/flarum/core"><img src="https://img.shields.io/packagist/l/flarum/core" alt="License"></a>
|
||||
<a href="https://github.styleci.io/repos/28257573"><img src="https://github.styleci.io/repos/28257573/shield?style=flat" alt="StyleCI"></a>
|
||||
<a href="https://travis-ci.org/flarum/core"><img src="https://travis-ci.org/flarum/core.svg" alt="Build Status"></a>
|
||||
<a href="https://packagist.org/packages/flarum/core"><img src="https://poser.pugx.org/flarum/core/d/total.svg" alt="Total Downloads"></a>
|
||||
<a href="https://packagist.org/packages/flarum/core"><img src="https://poser.pugx.org/flarum/core/v/stable.svg" alt="Latest Stable Version"></a>
|
||||
<a href="https://packagist.org/packages/flarum/core"><img src="https://poser.pugx.org/flarum/core/license.svg" alt="License"></a>
|
||||
</p>
|
||||
|
||||
|
||||
## About Flarum
|
||||
|
||||
**[Flarum](https://flarum.org/) is a delightfully simple discussion platform for your website.** It's fast and easy to use, with all the features you need to run a successful community. It is designed to be:
|
||||
@@ -34,3 +32,4 @@ If you discover a security vulnerability within Flarum, please send an e-mail to
|
||||
## License
|
||||
|
||||
Flarum is open-source software licensed under the [MIT License](https://github.com/flarum/flarum/blob/master/LICENSE).
|
||||
|
||||
|
@@ -6,9 +6,16 @@
|
||||
"license": "MIT",
|
||||
"authors": [
|
||||
{
|
||||
"name": "Flarum",
|
||||
"email": "info@flarum.org",
|
||||
"homepage": "https://flarum.org/team"
|
||||
"name": "Toby Zerner",
|
||||
"email": "toby.zerner@gmail.com"
|
||||
},
|
||||
{
|
||||
"name": "Franz Liedke",
|
||||
"email": "franz@develophp.org"
|
||||
},
|
||||
{
|
||||
"name": "Daniel Klabbers",
|
||||
"email": "daniel@klabbers.email"
|
||||
}
|
||||
],
|
||||
"support": {
|
||||
@@ -17,55 +24,54 @@
|
||||
"docs": "https://flarum.org/docs/"
|
||||
},
|
||||
"require": {
|
||||
"php": ">=7.2",
|
||||
"php": ">=7.1",
|
||||
"axy/sourcemap": "^0.1.4",
|
||||
"components/font-awesome": "^5.14.0",
|
||||
"dflydev/fig-cookies": "^3.0.0",
|
||||
"components/font-awesome": "5.9.*",
|
||||
"dflydev/fig-cookies": "^1.0.2",
|
||||
"doctrine/dbal": "^2.7",
|
||||
"franzl/whoops-middleware": "^2.0.0",
|
||||
"illuminate/bus": "^6.0",
|
||||
"illuminate/cache": "^6.0",
|
||||
"illuminate/config": "^6.0",
|
||||
"illuminate/container": "^6.0",
|
||||
"illuminate/contracts": "^6.0",
|
||||
"illuminate/database": "^6.0",
|
||||
"illuminate/events": "^6.0",
|
||||
"illuminate/filesystem": "^6.0",
|
||||
"illuminate/hashing": "^6.0",
|
||||
"illuminate/mail": "^6.0",
|
||||
"illuminate/queue": "^6.0",
|
||||
"illuminate/session": "^6.0",
|
||||
"illuminate/support": "^6.0",
|
||||
"illuminate/validation": "^6.0",
|
||||
"illuminate/view": "^6.0",
|
||||
"intervention/image": "^2.5.0",
|
||||
"laminas/laminas-diactoros": "^2.4.1",
|
||||
"laminas/laminas-httphandlerrunner": "^1.2.0",
|
||||
"laminas/laminas-stratigility": "^3.2.2",
|
||||
"franzl/whoops-middleware": "^0.4.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.3.0",
|
||||
"laminas/laminas-diactoros": "^1.8.4",
|
||||
"laminas/laminas-httphandlerrunner": "^1.0",
|
||||
"laminas/laminas-stratigility": "^3.0",
|
||||
"laminas/laminas-zendframework-bridge": "^1.0",
|
||||
"league/flysystem": "^1.0.11",
|
||||
"matthiasmullie/minify": "^1.3",
|
||||
"middlewares/base-path": "^2.0.1",
|
||||
"middlewares/base-path-router": "^2.0.1",
|
||||
"middlewares/request-handler": "^2.0.1",
|
||||
"middlewares/base-path": "^1.1",
|
||||
"middlewares/base-path-router": "^0.2.1",
|
||||
"middlewares/request-handler": "^1.2",
|
||||
"monolog/monolog": "^1.16.0",
|
||||
"nesbot/carbon": "^2.0",
|
||||
"nikic/fast-route": "^0.6",
|
||||
"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": "^2.3.6",
|
||||
"symfony/config": "^4.3.4",
|
||||
"symfony/console": "^4.3.4",
|
||||
"symfony/event-dispatcher": "^4.3.4",
|
||||
"symfony/mime": "^5.2.0",
|
||||
"symfony/translation": "^4.3.4",
|
||||
"symfony/yaml": "^4.3.4",
|
||||
"tobscure/json-api": "^0.3.0",
|
||||
"wikimedia/less.php": "^3.0"
|
||||
"s9e/text-formatter": "^1.2.0",
|
||||
"symfony/config": "^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"
|
||||
},
|
||||
"require-dev": {
|
||||
"mockery/mockery": "^1.3.3",
|
||||
"phpunit/phpunit": "^8.0"
|
||||
"mockery/mockery": "^1.0",
|
||||
"phpunit/phpunit": "^7.0"
|
||||
},
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
|
@@ -1,6 +0,0 @@
|
||||
{
|
||||
"printWidth": 150,
|
||||
"singleQuote": true,
|
||||
"tabWidth": 2,
|
||||
"trailingComma": "es5"
|
||||
}
|
14
js/dist/admin.js
vendored
14
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
16
js/dist/forum.js
vendored
16
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
3981
js/package-lock.json
generated
3981
js/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -2,38 +2,25 @@
|
||||
"private": true,
|
||||
"name": "@flarum/core",
|
||||
"dependencies": {
|
||||
"@babel/preset-typescript": "^7.10.1",
|
||||
"@types/mithril": "^2.0.3",
|
||||
"bootstrap": "^3.4.1",
|
||||
"classnames": "^2.2.5",
|
||||
"color-thief-browser": "^2.0.2",
|
||||
"dayjs": "^1.8.28",
|
||||
"expose-loader": "^0.7.5",
|
||||
"flarum-webpack-config": "0.1.0-beta.10",
|
||||
"jquery": "^3.5.1",
|
||||
"jquery": "^3.4.1",
|
||||
"jquery.hotkeys": "^0.1.0",
|
||||
"lodash-es": "^4.17.14",
|
||||
"mithril": "^2.0.4",
|
||||
"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",
|
||||
"textarea-caret": "^3.1.0",
|
||||
"webpack": "^4.43.0",
|
||||
"webpack-cli": "^3.3.11",
|
||||
"webpack": "^4.41.2",
|
||||
"webpack-cli": "^3.1.2",
|
||||
"webpack-merge": "^4.1.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"husky": "^4.2.5",
|
||||
"prettier": "2.0.2"
|
||||
},
|
||||
"scripts": {
|
||||
"dev": "webpack --mode development --watch",
|
||||
"build": "webpack --mode production",
|
||||
"format": "prettier --write src",
|
||||
"format-check": "prettier --check src"
|
||||
},
|
||||
"husky": {
|
||||
"hooks": {
|
||||
"pre-commit": "npm run format"
|
||||
}
|
||||
"build": "webpack --mode production"
|
||||
}
|
||||
}
|
||||
|
32
js/shims.d.ts
vendored
32
js/shims.d.ts
vendored
@@ -1,32 +0,0 @@
|
||||
// Mithril
|
||||
import Mithril from 'mithril';
|
||||
|
||||
// Other third-party libs
|
||||
import * as _dayjs from 'dayjs';
|
||||
import * as _$ from 'jquery';
|
||||
|
||||
// Globals from flarum/core
|
||||
import Application from './src/common/Application';
|
||||
|
||||
/**
|
||||
* flarum/core exposes several extensions globally:
|
||||
*
|
||||
* - jQuery for convenient DOM manipulation
|
||||
* - Mithril for VDOM and components
|
||||
* - dayjs for date/time operations
|
||||
*
|
||||
* Since these are already part of the global namespace, extensions won't need
|
||||
* to (and should not) bundle these themselves.
|
||||
*/
|
||||
declare global {
|
||||
const $: typeof _$;
|
||||
const m: Mithril.Static;
|
||||
const dayjs: typeof _dayjs;
|
||||
}
|
||||
|
||||
/**
|
||||
* All global variables owned by flarum/core.
|
||||
*/
|
||||
declare global {
|
||||
const app: Application;
|
||||
}
|
@@ -4,24 +4,17 @@ import routes from './routes';
|
||||
import Application from '../common/Application';
|
||||
import Navigation from '../common/components/Navigation';
|
||||
import AdminNav from './components/AdminNav';
|
||||
import ExtensionData from './utils/ExtensionData';
|
||||
|
||||
export default class AdminApplication extends Application {
|
||||
extensionData = new ExtensionData();
|
||||
|
||||
extensionCategories = {
|
||||
feature: 30,
|
||||
theme: 20,
|
||||
language: 10,
|
||||
};
|
||||
extensionSettings = {};
|
||||
|
||||
history = {
|
||||
canGoBack: () => true,
|
||||
getPrevious: () => {},
|
||||
backUrl: () => this.forum.attribute('baseUrl'),
|
||||
back: function () {
|
||||
back: function() {
|
||||
window.location = this.backUrl();
|
||||
},
|
||||
}
|
||||
};
|
||||
|
||||
constructor() {
|
||||
@@ -34,24 +27,22 @@ export default class AdminApplication extends Application {
|
||||
* @inheritdoc
|
||||
*/
|
||||
mount() {
|
||||
// Mithril does not render the home route on https://example.com/admin, so
|
||||
// we need to go to https://example.com/admin#/ explicitly.
|
||||
if (!document.location.hash) document.location.hash = '#/';
|
||||
m.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.prefix = '#';
|
||||
m.route.mode = 'hash';
|
||||
super.mount();
|
||||
|
||||
m.mount(document.getElementById('app-navigation'), {
|
||||
view: () =>
|
||||
Navigation.component({
|
||||
className: 'App-backControl',
|
||||
drawer: true,
|
||||
}),
|
||||
});
|
||||
m.mount(document.getElementById('header-navigation'), Navigation);
|
||||
m.mount(document.getElementById('header-primary'), HeaderPrimary);
|
||||
m.mount(document.getElementById('header-secondary'), HeaderSecondary);
|
||||
m.mount(document.getElementById('admin-navigation'), AdminNav);
|
||||
// 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) {
|
||||
@@ -68,5 +59,5 @@ export default class AdminApplication extends Application {
|
||||
}
|
||||
|
||||
return required;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
@@ -1,24 +1,21 @@
|
||||
import compat from '../common/compat';
|
||||
|
||||
import saveSettings from './utils/saveSettings';
|
||||
import ExtensionData from './utils/ExtensionData';
|
||||
import isExtensionEnabled from './utils/isExtensionEnabled';
|
||||
import getCategorizedExtensions from './utils/getCategorizedExtensions';
|
||||
import SettingDropdown from './components/SettingDropdown';
|
||||
import EditCustomFooterModal from './components/EditCustomFooterModal';
|
||||
import SessionDropdown from './components/SessionDropdown';
|
||||
import HeaderPrimary from './components/HeaderPrimary';
|
||||
import AdminPage from './components/AdminPage';
|
||||
import AppearancePage from './components/AppearancePage';
|
||||
import Page from './components/Page';
|
||||
import StatusWidget from './components/StatusWidget';
|
||||
import ExtensionsWidget from './components/ExtensionsWidget';
|
||||
import HeaderSecondary from './components/HeaderSecondary';
|
||||
import SettingsModal from './components/SettingsModal';
|
||||
import DashboardWidget from './components/DashboardWidget';
|
||||
import ExtensionPage from './components/ExtensionPage';
|
||||
import ExtensionLinkButton from './components/ExtensionLinkButton';
|
||||
import AddExtensionModal from './components/AddExtensionModal';
|
||||
import ExtensionsPage from './components/ExtensionsPage';
|
||||
import AdminLinkButton from './components/AdminLinkButton';
|
||||
import PermissionGrid from './components/PermissionGrid';
|
||||
import ExtensionPermissionGrid from './components/ExtensionPermissionGrid';
|
||||
import Widget from './components/Widget';
|
||||
import MailPage from './components/MailPage';
|
||||
import UploadImageButton from './components/UploadImageButton';
|
||||
import LoadingModal from './components/LoadingModal';
|
||||
@@ -28,7 +25,6 @@ import EditCustomHeaderModal from './components/EditCustomHeaderModal';
|
||||
import PermissionsPage from './components/PermissionsPage';
|
||||
import PermissionDropdown from './components/PermissionDropdown';
|
||||
import AdminNav from './components/AdminNav';
|
||||
import AdminHeader from './components/AdminHeader';
|
||||
import EditCustomCssModal from './components/EditCustomCssModal';
|
||||
import EditGroupModal from './components/EditGroupModal';
|
||||
import routes from './routes';
|
||||
@@ -36,24 +32,21 @@ import AdminApplication from './AdminApplication';
|
||||
|
||||
export default Object.assign(compat, {
|
||||
'utils/saveSettings': saveSettings,
|
||||
'utils/ExtensionData': ExtensionData,
|
||||
'utils/isExtensionEnabled': isExtensionEnabled,
|
||||
'utils/getCategorizedExtensions': getCategorizedExtensions,
|
||||
'components/SettingDropdown': SettingDropdown,
|
||||
'components/EditCustomFooterModal': EditCustomFooterModal,
|
||||
'components/SessionDropdown': SessionDropdown,
|
||||
'components/HeaderPrimary': HeaderPrimary,
|
||||
'components/AdminPage': AdminPage,
|
||||
'components/AppearancePage': AppearancePage,
|
||||
'components/Page': Page,
|
||||
'components/StatusWidget': StatusWidget,
|
||||
'components/ExtensionsWidget': ExtensionsWidget,
|
||||
'components/HeaderSecondary': HeaderSecondary,
|
||||
'components/SettingsModal': SettingsModal,
|
||||
'components/DashboardWidget': DashboardWidget,
|
||||
'components/ExtensionPage': ExtensionPage,
|
||||
'components/ExtensionLinkButton': ExtensionLinkButton,
|
||||
'components/AddExtensionModal': AddExtensionModal,
|
||||
'components/ExtensionsPage': ExtensionsPage,
|
||||
'components/AdminLinkButton': AdminLinkButton,
|
||||
'components/PermissionGrid': PermissionGrid,
|
||||
'components/ExtensionPermissionGrid': ExtensionPermissionGrid,
|
||||
'components/Widget': Widget,
|
||||
'components/MailPage': MailPage,
|
||||
'components/UploadImageButton': UploadImageButton,
|
||||
'components/LoadingModal': LoadingModal,
|
||||
@@ -63,9 +56,8 @@ export default Object.assign(compat, {
|
||||
'components/PermissionsPage': PermissionsPage,
|
||||
'components/PermissionDropdown': PermissionDropdown,
|
||||
'components/AdminNav': AdminNav,
|
||||
'components/AdminHeader': AdminHeader,
|
||||
'components/EditCustomCssModal': EditCustomCssModal,
|
||||
'components/EditGroupModal': EditGroupModal,
|
||||
routes: routes,
|
||||
AdminApplication: AdminApplication,
|
||||
'routes': routes,
|
||||
'AdminApplication': AdminApplication
|
||||
});
|
||||
|
30
js/src/admin/components/AddExtensionModal.js
Normal file
30
js/src/admin/components/AddExtensionModal.js
Normal file
@@ -0,0 +1,30 @@
|
||||
/*
|
||||
* 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>
|
||||
);
|
||||
}
|
||||
}
|
@@ -1,19 +0,0 @@
|
||||
import Component from '../../common/Component';
|
||||
import classList from '../../common/utils/classList';
|
||||
import icon from '../../common/helpers/icon';
|
||||
|
||||
export default class AdminHeader extends Component {
|
||||
view(vnode) {
|
||||
return [
|
||||
<div className={classList(['AdminHeader', this.attrs.className])}>
|
||||
<div className="container">
|
||||
<h2>
|
||||
{icon(this.attrs.icon)}
|
||||
{vnode.children}
|
||||
</h2>
|
||||
<div className="AdminHeader-description">{this.attrs.description}</div>
|
||||
</div>
|
||||
</div>,
|
||||
];
|
||||
}
|
||||
}
|
24
js/src/admin/components/AdminLinkButton.js
Normal file
24
js/src/admin/components/AdminLinkButton.js
Normal file
@@ -0,0 +1,24 @@
|
||||
/*
|
||||
* 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;
|
||||
}
|
||||
}
|
@@ -1,149 +1,77 @@
|
||||
import ExtensionLinkButton from './ExtensionLinkButton';
|
||||
/*
|
||||
* 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 LinkButton from '../../common/components/LinkButton';
|
||||
import AdminLinkButton from './AdminLinkButton';
|
||||
import SelectDropdown from '../../common/components/SelectDropdown';
|
||||
import getCategorizedExtensions from '../utils/getCategorizedExtensions';
|
||||
import ItemList from '../../common/utils/ItemList';
|
||||
import Stream from '../../common/utils/Stream';
|
||||
|
||||
export default class AdminNav extends Component {
|
||||
oninit(vnode) {
|
||||
super.oninit(vnode);
|
||||
|
||||
this.query = Stream('');
|
||||
}
|
||||
|
||||
view() {
|
||||
return (
|
||||
<SelectDropdown className="AdminNav App-titleControl AdminNav-Main" buttonClassName="Button">
|
||||
{this.items().toArray().concat(this.extensionItems().toArray())}
|
||||
<SelectDropdown
|
||||
className="AdminNav App-titleControl"
|
||||
buttonClassName="Button">
|
||||
{this.items().toArray()}
|
||||
</SelectDropdown>
|
||||
);
|
||||
}
|
||||
|
||||
oncreate(vnode) {
|
||||
super.oncreate(vnode);
|
||||
|
||||
this.scrollToActive();
|
||||
}
|
||||
|
||||
onupdate() {
|
||||
this.scrollToActive();
|
||||
}
|
||||
|
||||
scrollToActive() {
|
||||
const children = $('.Dropdown-menu').children('.active');
|
||||
const nav = $('#admin-navigation');
|
||||
const time = app.previous.type ? 250 : 0;
|
||||
|
||||
if (
|
||||
children.length > 0 &&
|
||||
(children[0].offsetTop > nav.scrollTop() + nav.outerHeight() || children[0].offsetTop + children[0].offsetHeight < nav.scrollTop())
|
||||
) {
|
||||
nav.animate(
|
||||
{
|
||||
scrollTop: children[0].offsetTop - nav.height() / 2,
|
||||
},
|
||||
time
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Build an item list of main links to show in the admin navigation.
|
||||
* Build an item list of links to show in the admin navigation.
|
||||
*
|
||||
* @return {ItemList}
|
||||
*/
|
||||
items() {
|
||||
const items = new ItemList();
|
||||
|
||||
items.add('category-core', <h4 className="ExtensionListTitle">{app.translator.trans('core.admin.nav.categories.core')}</h4>);
|
||||
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(
|
||||
'dashboard',
|
||||
<LinkButton href={app.route('dashboard')} icon="far fa-chart-bar" title={app.translator.trans('core.admin.nav.dashboard_title')}>
|
||||
{app.translator.trans('core.admin.nav.dashboard_button')}
|
||||
</LinkButton>
|
||||
);
|
||||
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(
|
||||
'basics',
|
||||
<LinkButton href={app.route('basics')} icon="fas fa-pencil-alt" title={app.translator.trans('core.admin.nav.basics_title')}>
|
||||
{app.translator.trans('core.admin.nav.basics_button')}
|
||||
</LinkButton>
|
||||
);
|
||||
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(
|
||||
'mail',
|
||||
<LinkButton href={app.route('mail')} icon="fas fa-envelope" title={app.translator.trans('core.admin.nav.email_title')}>
|
||||
{app.translator.trans('core.admin.nav.email_button')}
|
||||
</LinkButton>
|
||||
);
|
||||
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(
|
||||
'permissions',
|
||||
<LinkButton href={app.route('permissions')} icon="fas fa-key" title={app.translator.trans('core.admin.nav.permissions_title')}>
|
||||
{app.translator.trans('core.admin.nav.permissions_button')}
|
||||
</LinkButton>
|
||||
);
|
||||
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(
|
||||
'appearance',
|
||||
<LinkButton href={app.route('appearance')} icon="fas fa-paint-brush" title={app.translator.trans('core.admin.nav.appearance_title')}>
|
||||
{app.translator.trans('core.admin.nav.appearance_button')}
|
||||
</LinkButton>
|
||||
);
|
||||
|
||||
items.add(
|
||||
'search',
|
||||
<div className="Search-input">
|
||||
<input
|
||||
className="FormControl SearchBar"
|
||||
bidi={this.query}
|
||||
type="search"
|
||||
placeholder={app.translator.trans('core.admin.nav.search_placeholder')}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
return items;
|
||||
}
|
||||
|
||||
extensionItems() {
|
||||
const items = new ItemList();
|
||||
|
||||
const categorizedExtensions = getCategorizedExtensions();
|
||||
const categories = app.extensionCategories;
|
||||
|
||||
Object.keys(categorizedExtensions).map((category) => {
|
||||
if (!this.query()) {
|
||||
items.add(
|
||||
`category-${category}`,
|
||||
<h4 className="ExtensionListTitle">{app.translator.trans(`core.admin.nav.categories.${category}`)}</h4>,
|
||||
categories[category]
|
||||
);
|
||||
}
|
||||
|
||||
categorizedExtensions[category].map((extension) => {
|
||||
const query = this.query().toUpperCase();
|
||||
const title = extension.extra['flarum-extension'].title;
|
||||
|
||||
if (!query || title.toUpperCase().includes(query) || extension.description.toUpperCase().includes(query)) {
|
||||
items.add(
|
||||
`extension-${extension.id}`,
|
||||
<ExtensionLinkButton
|
||||
href={app.route('extension', { id: extension.id })}
|
||||
extensionId={extension.id}
|
||||
className="ExtensionNavButton"
|
||||
title={extension.description}
|
||||
>
|
||||
{title}
|
||||
</ExtensionLinkButton>,
|
||||
categories[category]
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
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,174 +0,0 @@
|
||||
import Page from '../../common/components/Page';
|
||||
import Button from '../../common/components/Button';
|
||||
import Switch from '../../common/components/Switch';
|
||||
import Select from '../../common/components/Select';
|
||||
import classList from '../../common/utils/classList';
|
||||
import Stream from '../../common/utils/Stream';
|
||||
import saveSettings from '../utils/saveSettings';
|
||||
import AdminHeader from './AdminHeader';
|
||||
|
||||
export default class AdminPage extends Page {
|
||||
oninit(vnode) {
|
||||
super.oninit(vnode);
|
||||
|
||||
this.settings = {};
|
||||
|
||||
this.loading = false;
|
||||
}
|
||||
|
||||
view() {
|
||||
const className = classList(['AdminPage', this.headerInfo().className]);
|
||||
|
||||
return (
|
||||
<div className={className}>
|
||||
{this.header()}
|
||||
<div className="container">{this.content()}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
content() {
|
||||
return '';
|
||||
}
|
||||
|
||||
submitButton() {
|
||||
return (
|
||||
<Button onclick={this.saveSettings.bind(this)} className="Button Button--primary" loading={this.loading} disabled={!this.isChanged()}>
|
||||
{app.translator.trans('core.admin.settings.submit_button')}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
header() {
|
||||
const headerInfo = this.headerInfo();
|
||||
|
||||
return (
|
||||
<AdminHeader icon={headerInfo.icon} description={headerInfo.description} className={headerInfo.className + '-header'}>
|
||||
{headerInfo.title}
|
||||
</AdminHeader>
|
||||
);
|
||||
}
|
||||
|
||||
headerInfo() {
|
||||
return {
|
||||
className: '',
|
||||
icon: '',
|
||||
title: '',
|
||||
description: '',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* buildSettingComponent takes a settings object and turns it into a component.
|
||||
* Depending on the type of input, you can set the type to 'bool', 'select', or
|
||||
* any standard <input> type. Any values inside the 'extra' object will be added
|
||||
* to the component as an attribute.
|
||||
*
|
||||
* Alternatively, you can pass a callback that will be executed in ExtensionPage's
|
||||
* context to include custom JSX elements.
|
||||
*
|
||||
* @example
|
||||
*
|
||||
* {
|
||||
* setting: 'acme.checkbox',
|
||||
* label: app.translator.trans('acme.admin.setting_label'),
|
||||
* type: 'bool',
|
||||
* help: app.translator.trans('acme.admin.setting_help'),
|
||||
* className: 'Setting-item'
|
||||
* }
|
||||
*
|
||||
* @example
|
||||
*
|
||||
* {
|
||||
* setting: 'acme.select',
|
||||
* label: app.translator.trans('acme.admin.setting_label'),
|
||||
* type: 'select',
|
||||
* options: {
|
||||
* 'option1': 'Option 1 label',
|
||||
* 'option2': 'Option 2 label',
|
||||
* },
|
||||
* default: 'option1',
|
||||
* }
|
||||
*
|
||||
* @param setting
|
||||
* @returns {JSX.Element}
|
||||
*/
|
||||
buildSettingComponent(entry) {
|
||||
if (typeof entry === 'function') {
|
||||
return entry.call(this);
|
||||
}
|
||||
|
||||
const setting = entry.setting;
|
||||
const help = entry.help;
|
||||
delete entry.help;
|
||||
|
||||
const value = this.setting([setting])();
|
||||
if (['bool', 'checkbox', 'switch', 'boolean'].includes(entry.type)) {
|
||||
return (
|
||||
<div className="Form-group">
|
||||
<Switch state={!!value && value !== '0'} onchange={this.settings[setting]} {...entry}>
|
||||
{entry.label}
|
||||
</Switch>
|
||||
<div className="helpText">{help}</div>
|
||||
</div>
|
||||
);
|
||||
} else if (['select', 'dropdown', 'selectdropdown'].includes(entry.type)) {
|
||||
return (
|
||||
<div className="Form-group">
|
||||
<label>{entry.label}</label>
|
||||
<div className="helpText">{help}</div>
|
||||
<Select value={value || entry.default} options={entry.options} buttonClassName="Button" onchange={this.settings[setting]} {...entry} />
|
||||
</div>
|
||||
);
|
||||
} else {
|
||||
entry.className = classList(['FormControl', entry.className]);
|
||||
return (
|
||||
<div className="Form-group">
|
||||
{entry.label ? <label>{entry.label}</label> : ''}
|
||||
<div className="helpText">{help}</div>
|
||||
<input type={entry.type} bidi={this.setting(setting)} {...entry} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
onsaved() {
|
||||
this.loading = false;
|
||||
|
||||
app.alerts.show({ type: 'success' }, app.translator.trans('core.admin.settings.saved_message'));
|
||||
}
|
||||
|
||||
setting(key, fallback = '') {
|
||||
this.settings[key] = this.settings[key] || Stream(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;
|
||||
}
|
||||
|
||||
isChanged() {
|
||||
return Object.keys(this.dirty()).length;
|
||||
}
|
||||
|
||||
saveSettings(e) {
|
||||
e.preventDefault();
|
||||
|
||||
app.alerts.clear();
|
||||
|
||||
this.loading = true;
|
||||
|
||||
return saveSettings(this.dirty()).then(this.onsaved.bind(this));
|
||||
}
|
||||
}
|
@@ -1,120 +1,132 @@
|
||||
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 AdminPage from './AdminPage';
|
||||
import saveSettings from '../utils/saveSettings';
|
||||
|
||||
export default class AppearancePage extends AdminPage {
|
||||
headerInfo() {
|
||||
return {
|
||||
className: 'AppearancePage',
|
||||
icon: 'fas fa-paint-brush',
|
||||
title: app.translator.trans('core.admin.appearance.title'),
|
||||
description: app.translator.trans('core.admin.appearance.description'),
|
||||
};
|
||||
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');
|
||||
}
|
||||
|
||||
content() {
|
||||
return [
|
||||
<div className="Form">
|
||||
<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>
|
||||
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">
|
||||
{this.buildSettingComponent({
|
||||
type: 'text',
|
||||
setting: 'theme_primary_color',
|
||||
placeholder: '#aaaaaa',
|
||||
<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())
|
||||
})}
|
||||
{this.buildSettingComponent({
|
||||
type: 'text',
|
||||
setting: 'theme_secondary_color',
|
||||
placeholder: '#aaaaaa',
|
||||
</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())
|
||||
})}
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
{this.buildSettingComponent({
|
||||
type: 'switch',
|
||||
setting: 'theme_dark_mode',
|
||||
label: app.translator.trans('core.admin.appearance.dark_mode_label'),
|
||||
})}
|
||||
|
||||
{this.buildSettingComponent({
|
||||
type: 'switch',
|
||||
setting: 'theme_colored_header',
|
||||
label: app.translator.trans('core.admin.appearance.colored_header_label'),
|
||||
})}
|
||||
|
||||
{this.submitButton()}
|
||||
</fieldset>
|
||||
</div>,
|
||||
|
||||
<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',
|
||||
onclick: () => app.modal.show(EditCustomHeaderModal),
|
||||
},
|
||||
app.translator.trans('core.admin.appearance.edit_header_button')
|
||||
)}
|
||||
</fieldset>,
|
||||
|
||||
<fieldset>
|
||||
<legend>{app.translator.trans('core.admin.appearance.custom_footer_heading')}</legend>
|
||||
<div className="helpText">{app.translator.trans('core.admin.appearance.custom_footer_text')}</div>
|
||||
{Button.component(
|
||||
{
|
||||
className: 'Button',
|
||||
onclick: () => app.modal.show(EditCustomFooterModal),
|
||||
},
|
||||
app.translator.trans('core.admin.appearance.edit_footer_button')
|
||||
)}
|
||||
</fieldset>,
|
||||
|
||||
<fieldset>
|
||||
<legend>{app.translator.trans('core.admin.appearance.custom_styles_heading')}</legend>
|
||||
<div className="helpText">{app.translator.trans('core.admin.appearance.custom_styles_text')}</div>
|
||||
{Button.component(
|
||||
{
|
||||
className: 'Button',
|
||||
onclick: () => app.modal.show(EditCustomCssModal),
|
||||
},
|
||||
app.translator.trans('core.admin.appearance.edit_css_button')
|
||||
)}
|
||||
</fieldset>,
|
||||
];
|
||||
<fieldset>
|
||||
<legend>{app.translator.trans('core.admin.appearance.custom_styles_heading')}</legend>
|
||||
<div className="helpText">
|
||||
{app.translator.trans('core.admin.appearance.custom_styles_text')}
|
||||
</div>
|
||||
{Button.component({
|
||||
className: 'Button',
|
||||
children: app.translator.trans('core.admin.appearance.edit_css_button'),
|
||||
onclick: () => app.modal.show(new EditCustomCssModal())
|
||||
})}
|
||||
</fieldset>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
onsaved() {
|
||||
window.location.reload();
|
||||
}
|
||||
|
||||
saveSettings(e) {
|
||||
onsubmit(e) {
|
||||
e.preventDefault();
|
||||
|
||||
const hex = /^#[0-9a-f]{3}([0-9a-f]{3})?$/i;
|
||||
|
||||
if (!hex.test(this.settings['theme_primary_color']()) || !hex.test(this.settings['theme_secondary_color']())) {
|
||||
if (!hex.test(this.primaryColor()) || !hex.test(this.secondaryColor())) {
|
||||
alert(app.translator.trans('core.admin.appearance.enter_hex_message'));
|
||||
return;
|
||||
}
|
||||
|
||||
super.saveSettings(e);
|
||||
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,10 +1,31 @@
|
||||
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 AdminPage from './AdminPage';
|
||||
import Switch from '../../common/components/Switch';
|
||||
|
||||
export default class BasicsPage extends AdminPage {
|
||||
oninit(vnode) {
|
||||
super.oninit(vnode);
|
||||
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;
|
||||
@@ -12,107 +33,94 @@ export default class BasicsPage extends AdminPage {
|
||||
this.localeOptions[i] = `${locales[i]} (${i})`;
|
||||
}
|
||||
|
||||
this.displayNameOptions = {};
|
||||
const displayNameDrivers = app.data.displayNameDrivers;
|
||||
displayNameDrivers.forEach(function (identifier) {
|
||||
this.displayNameOptions[identifier] = identifier;
|
||||
}, this);
|
||||
|
||||
this.slugDriverOptions = {};
|
||||
Object.keys(app.data.slugDrivers).forEach((model) => {
|
||||
this.slugDriverOptions[model] = {};
|
||||
|
||||
app.data.slugDrivers[model].forEach((option) => {
|
||||
this.slugDriverOptions[model][option] = option;
|
||||
});
|
||||
});
|
||||
if (typeof this.values.show_language_selector() !== "number") this.values.show_language_selector(1);
|
||||
}
|
||||
|
||||
headerInfo() {
|
||||
return {
|
||||
className: 'BasicsPage',
|
||||
icon: 'fas fa-pencil-alt',
|
||||
title: app.translator.trans('core.admin.basics.title'),
|
||||
description: app.translator.trans('core.admin.basics.description'),
|
||||
};
|
||||
}
|
||||
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)}/>
|
||||
]
|
||||
})}
|
||||
|
||||
content() {
|
||||
return [
|
||||
<div className="Form">
|
||||
{this.buildSettingComponent({
|
||||
type: 'text',
|
||||
setting: 'forum_title',
|
||||
label: app.translator.trans('core.admin.basics.forum_title_heading'),
|
||||
})}
|
||||
{this.buildSettingComponent({
|
||||
type: 'text',
|
||||
setting: 'forum_description',
|
||||
label: app.translator.trans('core.admin.basics.forum_description_heading'),
|
||||
help: app.translator.trans('core.admin.basics.forum_description_text'),
|
||||
})}
|
||||
{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
|
||||
? [
|
||||
this.buildSettingComponent({
|
||||
type: 'select',
|
||||
setting: 'default_locale',
|
||||
options: this.localeOptions,
|
||||
{Object.keys(this.localeOptions).length > 1
|
||||
? FieldSet.component({
|
||||
label: app.translator.trans('core.admin.basics.default_language_heading'),
|
||||
}),
|
||||
this.buildSettingComponent({
|
||||
type: 'switch',
|
||||
setting: 'show_language_selector',
|
||||
label: app.translator.trans('core.admin.basics.show_language_selector_label'),
|
||||
}),
|
||||
]
|
||||
: ''}
|
||||
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 className="BasicsPage-homePage Form-group" label={app.translator.trans('core.admin.basics.home_page_heading')}>
|
||||
<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} bidi={this.setting('default_route')} />
|
||||
{label}
|
||||
</label>
|
||||
))}
|
||||
</FieldSet>
|
||||
{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>
|
||||
)
|
||||
]
|
||||
})}
|
||||
|
||||
<div className="Form-group BasicsPage-welcomeBanner-input">
|
||||
<label>{app.translator.trans('core.admin.basics.welcome_banner_heading')}</label>
|
||||
<div className="helpText">{app.translator.trans('core.admin.basics.welcome_banner_text')}</div>
|
||||
<input type="text" className="FormControl" bidi={this.setting('welcome_title')} />
|
||||
<textarea className="FormControl" bidi={this.setting('welcome_message')} />
|
||||
{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>
|
||||
);
|
||||
}
|
||||
|
||||
{Object.keys(this.displayNameOptions).length > 1
|
||||
? this.buildSettingComponent({
|
||||
type: 'select',
|
||||
setting: 'display_name_driver',
|
||||
options: this.displayNameOptions,
|
||||
label: app.translator.trans('core.admin.basics.display_name_heading'),
|
||||
help: app.translator.trans('core.admin.basics.display_name_text'),
|
||||
})
|
||||
: ''}
|
||||
|
||||
{Object.keys(this.slugDriverOptions).map((model) => {
|
||||
const options = this.slugDriverOptions[model];
|
||||
if (Object.keys(options).length > 1) {
|
||||
return this.buildSettingComponent({
|
||||
type: 'select',
|
||||
setting: `slug_driver_${model}`,
|
||||
options,
|
||||
label: app.translator.trans('core.admin.basics.slug_driver_heading', { model }),
|
||||
help: app.translator.trans('core.admin.basics.slug_driver_text', { model }),
|
||||
});
|
||||
}
|
||||
})}
|
||||
|
||||
{this.submitButton()}
|
||||
</div>,
|
||||
];
|
||||
changed() {
|
||||
return this.fields.some(key => this.values[key]() !== app.data.settings[key]);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -127,9 +135,32 @@ export default class BasicsPage extends AdminPage {
|
||||
|
||||
items.add('allDiscussions', {
|
||||
path: '/all',
|
||||
label: app.translator.trans('core.admin.basics.all_discussions_label'),
|
||||
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();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
@@ -1,29 +1,18 @@
|
||||
import Page from './Page';
|
||||
import StatusWidget from './StatusWidget';
|
||||
import ExtensionsWidget from './ExtensionsWidget';
|
||||
import ItemList from '../../common/utils/ItemList';
|
||||
import AdminPage from './AdminPage';
|
||||
|
||||
export default class DashboardPage extends AdminPage {
|
||||
headerInfo() {
|
||||
return {
|
||||
className: 'DashboardPage',
|
||||
icon: 'fas fa-chart-bar',
|
||||
title: app.translator.trans('core.admin.dashboard.title'),
|
||||
description: app.translator.trans('core.admin.dashboard.description'),
|
||||
};
|
||||
}
|
||||
|
||||
content() {
|
||||
return this.availableWidgets().toArray();
|
||||
export default class DashboardPage extends Page {
|
||||
view() {
|
||||
return (
|
||||
<div className="DashboardPage">
|
||||
<div className="container">
|
||||
{this.availableWidgets()}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
availableWidgets() {
|
||||
const items = new ItemList();
|
||||
|
||||
items.add('status', <StatusWidget />, 30);
|
||||
|
||||
items.add('extensions', <ExtensionsWidget />, 10);
|
||||
|
||||
return items;
|
||||
return [<StatusWidget/>];
|
||||
}
|
||||
}
|
||||
|
@@ -1,8 +1,21 @@
|
||||
/*
|
||||
* 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 {
|
||||
export default class Widget extends Component {
|
||||
view() {
|
||||
return <div className={'DashboardWidget Widget ' + this.className()}>{this.content()}</div>;
|
||||
return (
|
||||
<div className={"Widget "+this.className()}>
|
||||
{this.content()}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
|
@@ -11,14 +11,10 @@ export default class EditCustomCssModal extends SettingsModal {
|
||||
|
||||
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>,
|
||||
<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>,
|
||||
<textarea className="FormControl" rows="30" bidi={this.setting('custom_less')}/>
|
||||
</div>
|
||||
];
|
||||
}
|
||||
|
||||
|
@@ -13,8 +13,8 @@ export default class EditCustomFooterModal extends SettingsModal {
|
||||
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>,
|
||||
<textarea className="FormControl" rows="30" bidi={this.setting('custom_footer')}/>
|
||||
</div>
|
||||
];
|
||||
}
|
||||
|
||||
|
@@ -13,8 +13,8 @@ export default class EditCustomHeaderModal extends SettingsModal {
|
||||
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>,
|
||||
<textarea className="FormControl" rows="30" bidi={this.setting('custom_header')}/>
|
||||
</div>
|
||||
];
|
||||
}
|
||||
|
||||
|
@@ -3,24 +3,19 @@ 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 Switch from '../../common/components/Switch';
|
||||
import Stream from '../../common/utils/Stream';
|
||||
|
||||
/**
|
||||
* The `EditGroupModal` component shows a modal dialog which allows the user
|
||||
* to create or edit a group.
|
||||
*/
|
||||
export default class EditGroupModal extends Modal {
|
||||
oninit(vnode) {
|
||||
super.oninit(vnode);
|
||||
init() {
|
||||
this.group = this.props.group || app.store.createRecord('groups');
|
||||
|
||||
this.group = this.attrs.group || app.store.createRecord('groups');
|
||||
|
||||
this.nameSingular = Stream(this.group.nameSingular() || '');
|
||||
this.namePlural = Stream(this.group.namePlural() || '');
|
||||
this.icon = Stream(this.group.icon() || '');
|
||||
this.color = Stream(this.group.color() || '');
|
||||
this.isHidden = Stream(this.group.isHidden() || false);
|
||||
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() {
|
||||
@@ -29,21 +24,21 @@ export default class EditGroupModal extends Modal {
|
||||
|
||||
title() {
|
||||
return [
|
||||
this.color() || this.icon()
|
||||
? Badge.component({
|
||||
icon: this.icon(),
|
||||
style: { backgroundColor: this.color() },
|
||||
})
|
||||
: '',
|
||||
this.color() || this.icon() ? Badge.component({
|
||||
icon: this.icon(),
|
||||
style: {backgroundColor: this.color()}
|
||||
}) : '',
|
||||
' ',
|
||||
this.namePlural() || app.translator.trans('core.admin.edit_group.title'),
|
||||
this.namePlural() || app.translator.trans('core.admin.edit_group.title')
|
||||
];
|
||||
}
|
||||
|
||||
content() {
|
||||
return (
|
||||
<div className="Modal-body">
|
||||
<div className="Form">{this.fields().toArray()}</div>
|
||||
<div className="Form">
|
||||
{this.fields().toArray()}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -51,74 +46,40 @@ export default class EditGroupModal extends Modal {
|
||||
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')} bidi={this.nameSingular} />
|
||||
<input className="FormControl" placeholder={app.translator.trans('core.admin.edit_group.plural_placeholder')} bidi={this.namePlural} />
|
||||
</div>
|
||||
</div>,
|
||||
30
|
||||
);
|
||||
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" bidi={this.color} />
|
||||
</div>,
|
||||
20
|
||||
);
|
||||
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" bidi={this.icon} />
|
||||
</div>,
|
||||
10
|
||||
);
|
||||
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(
|
||||
'hidden',
|
||||
<div className="Form-group">
|
||||
{Switch.component(
|
||||
{
|
||||
state: !!Number(this.isHidden()),
|
||||
onchange: this.isHidden,
|
||||
},
|
||||
app.translator.trans('core.admin.edit_group.hide_label')
|
||||
)}
|
||||
</div>,
|
||||
10
|
||||
);
|
||||
|
||||
items.add(
|
||||
'submit',
|
||||
<div className="Form-group">
|
||||
{Button.component(
|
||||
{
|
||||
type: 'submit',
|
||||
className: 'Button Button--primary EditGroupModal-save',
|
||||
loading: this.loading,
|
||||
},
|
||||
app.translator.trans('core.admin.edit_group.submit_button')
|
||||
)}
|
||||
{this.group.exists && this.group.id() !== Group.ADMINISTRATOR_ID ? (
|
||||
<button type="button" className="Button EditGroupModal-delete" onclick={this.deleteGroup.bind(this)}>
|
||||
{app.translator.trans('core.admin.edit_group.delete_button')}
|
||||
</button>
|
||||
) : (
|
||||
''
|
||||
)}
|
||||
</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;
|
||||
}
|
||||
@@ -128,8 +89,7 @@ export default class EditGroupModal extends Modal {
|
||||
nameSingular: this.nameSingular(),
|
||||
namePlural: this.namePlural(),
|
||||
color: this.color(),
|
||||
icon: this.icon(),
|
||||
isHidden: this.isHidden(),
|
||||
icon: this.icon()
|
||||
};
|
||||
}
|
||||
|
||||
@@ -138,8 +98,7 @@ export default class EditGroupModal extends Modal {
|
||||
|
||||
this.loading = true;
|
||||
|
||||
this.group
|
||||
.save(this.submitData(), { errorHandler: this.onerror.bind(this) })
|
||||
this.group.save(this.submitData(), {errorHandler: this.onerror.bind(this)})
|
||||
.then(this.hide.bind(this))
|
||||
.catch(() => {
|
||||
this.loading = false;
|
||||
|
@@ -1,29 +0,0 @@
|
||||
import isExtensionEnabled from '../utils/isExtensionEnabled';
|
||||
import LinkButton from '../../common/components/LinkButton';
|
||||
import icon from '../../common/helpers/icon';
|
||||
import ItemList from '../../common/utils/ItemList';
|
||||
|
||||
export default class ExtensionLinkButton extends LinkButton {
|
||||
getButtonContent(children) {
|
||||
const content = super.getButtonContent(children);
|
||||
const extension = app.data.extensions[this.attrs.extensionId];
|
||||
const statuses = this.statusItems(extension.id).toArray();
|
||||
|
||||
content.unshift(
|
||||
<span className="ExtensionListItem-icon ExtensionIcon" style={extension.icon}>
|
||||
{extension.icon ? icon(extension.icon.name) : ''}
|
||||
</span>
|
||||
);
|
||||
content.push(statuses);
|
||||
|
||||
return content;
|
||||
}
|
||||
|
||||
statusItems(name) {
|
||||
const items = new ItemList();
|
||||
|
||||
items.add('enabled', <span class={'ExtensionListItem-Dot ' + (isExtensionEnabled(name) ? 'enabled' : 'disabled')} />);
|
||||
|
||||
return items;
|
||||
}
|
||||
}
|
@@ -1,248 +0,0 @@
|
||||
import Button from '../../common/components/Button';
|
||||
import Link from '../../common/components/Link';
|
||||
import LinkButton from '../../common/components/LinkButton';
|
||||
import Switch from '../../common/components/Switch';
|
||||
import icon from '../../common/helpers/icon';
|
||||
import punctuateSeries from '../../common/helpers/punctuateSeries';
|
||||
import listItems from '../../common/helpers/listItems';
|
||||
import ItemList from '../../common/utils/ItemList';
|
||||
import LoadingModal from './LoadingModal';
|
||||
import ExtensionPermissionGrid from './ExtensionPermissionGrid';
|
||||
import isExtensionEnabled from '../utils/isExtensionEnabled';
|
||||
import AdminPage from './AdminPage';
|
||||
|
||||
export default class ExtensionPage extends AdminPage {
|
||||
oninit(vnode) {
|
||||
super.oninit(vnode);
|
||||
|
||||
this.extension = app.data.extensions[this.attrs.id];
|
||||
this.changingState = false;
|
||||
|
||||
this.infoFields = {
|
||||
discuss: 'fas fa-comment-alt',
|
||||
documentation: 'fas fa-book',
|
||||
support: 'fas fa-life-ring',
|
||||
website: 'fas fa-link',
|
||||
donate: 'fas fa-donate',
|
||||
source: 'fas fa-code',
|
||||
};
|
||||
|
||||
if (!this.extension) {
|
||||
return m.route.set(app.route('dashboard'));
|
||||
}
|
||||
}
|
||||
|
||||
className() {
|
||||
if (!this.extension) return '';
|
||||
|
||||
return this.extension.id + '-Page';
|
||||
}
|
||||
|
||||
view() {
|
||||
if (!this.extension) return null;
|
||||
|
||||
return (
|
||||
<div className={'ExtensionPage ' + this.className()}>
|
||||
{this.header()}
|
||||
{!this.isEnabled() ? (
|
||||
<div className="container">
|
||||
<h3 className="ExtensionPage-subHeader">{app.translator.trans('core.admin.extension.enable_to_see')}</h3>
|
||||
</div>
|
||||
) : (
|
||||
<div className="ExtensionPage-body">{this.sections().toArray()}</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
header() {
|
||||
const isEnabled = this.isEnabled();
|
||||
|
||||
return [
|
||||
<div className="ExtensionPage-header">
|
||||
<div className="container">
|
||||
<div className="ExtensionTitle">
|
||||
<span className="ExtensionIcon" style={this.extension.icon}>
|
||||
{this.extension.icon ? icon(this.extension.icon.name) : ''}
|
||||
</span>
|
||||
<div className="ExtensionName">
|
||||
<h2>{this.extension.extra['flarum-extension'].title}</h2>
|
||||
</div>
|
||||
<div className="ExtensionPage-headerTopItems">
|
||||
<ul>{listItems(this.topItems().toArray())}</ul>
|
||||
</div>
|
||||
</div>
|
||||
<div className="helpText">{this.extension.description}</div>
|
||||
<div className="ExtensionPage-headerItems">
|
||||
<Switch
|
||||
state={this.changingState ? !isEnabled : isEnabled}
|
||||
loading={this.changingState}
|
||||
onchange={this.toggle.bind(this, this.extension.id)}
|
||||
>
|
||||
{isEnabled ? app.translator.trans('core.admin.extension.enabled') : app.translator.trans('core.admin.extension.disabled')}
|
||||
</Switch>
|
||||
<aside className="ExtensionInfo">
|
||||
<ul>{listItems(this.infoItems().toArray())}</ul>
|
||||
</aside>
|
||||
</div>
|
||||
</div>
|
||||
</div>,
|
||||
];
|
||||
}
|
||||
|
||||
sections() {
|
||||
const items = new ItemList();
|
||||
|
||||
items.add('content', this.content());
|
||||
|
||||
items.add('permissions', [
|
||||
<div className="ExtensionPage-permissions">
|
||||
<div className="ExtensionPage-permissions-header">
|
||||
<div className="container">
|
||||
<h2 className="ExtensionTitle">{app.translator.trans('core.admin.extension.permissions_title')}</h2>
|
||||
</div>
|
||||
</div>
|
||||
<div className="container">
|
||||
{app.extensionData.extensionHasPermissions(this.extension.id) ? (
|
||||
ExtensionPermissionGrid.component({ extensionId: this.extension.id })
|
||||
) : (
|
||||
<h3 className="ExtensionPage-subHeader">{app.translator.trans('core.admin.extension.no_permissions')}</h3>
|
||||
)}
|
||||
</div>
|
||||
</div>,
|
||||
]);
|
||||
|
||||
return items;
|
||||
}
|
||||
|
||||
content() {
|
||||
const settings = app.extensionData.getSettings(this.extension.id);
|
||||
|
||||
return (
|
||||
<div className="ExtensionPage-settings">
|
||||
<div className="container">
|
||||
{settings ? (
|
||||
<div className="Form">
|
||||
{settings.map(this.buildSettingComponent.bind(this))}
|
||||
<div className="Form-group">{this.submitButton()}</div>
|
||||
</div>
|
||||
) : (
|
||||
<h3 className="ExtensionPage-subHeader">{app.translator.trans('core.admin.extension.no_settings')}</h3>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
topItems() {
|
||||
const items = new ItemList();
|
||||
|
||||
items.add('version', <span className="ExtensionVersion">{this.extension.version}</span>);
|
||||
|
||||
if (!this.isEnabled()) {
|
||||
const uninstall = () => {
|
||||
if (confirm(app.translator.trans('core.admin.extension.confirm_uninstall'))) {
|
||||
app
|
||||
.request({
|
||||
url: app.forum.attribute('apiUrl') + '/extensions/' + this.extension.id,
|
||||
method: 'DELETE',
|
||||
})
|
||||
.then(() => window.location.reload());
|
||||
|
||||
app.modal.show(LoadingModal);
|
||||
}
|
||||
};
|
||||
|
||||
items.add(
|
||||
'uninstall',
|
||||
<Button icon="fas fa-trash-alt" className="Button Button--primary" onclick={uninstall.bind(this)}>
|
||||
{app.translator.trans('core.admin.extension.uninstall_button')}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
return items;
|
||||
}
|
||||
|
||||
infoItems() {
|
||||
const items = new ItemList();
|
||||
|
||||
const links = this.extension.links;
|
||||
|
||||
if (links.authors.length) {
|
||||
let authors = [];
|
||||
|
||||
links.authors.map((author) => {
|
||||
authors.push(
|
||||
<Link href={author.link} external={true} target="_blank">
|
||||
{author.name}
|
||||
</Link>
|
||||
);
|
||||
});
|
||||
|
||||
items.add('authors', [icon('fas fa-user'), <span>{punctuateSeries(authors)}</span>]);
|
||||
}
|
||||
|
||||
Object.keys(this.infoFields).map((field) => {
|
||||
if (links[field]) {
|
||||
items.add(
|
||||
field,
|
||||
<LinkButton href={links[field]} icon={this.infoFields[field]} external={true} target="_blank">
|
||||
{app.translator.trans(`core.admin.extension.info_links.${field}`)}
|
||||
</LinkButton>
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
return items;
|
||||
}
|
||||
|
||||
toggle() {
|
||||
const enabled = this.isEnabled();
|
||||
|
||||
this.changingState = true;
|
||||
|
||||
app
|
||||
.request({
|
||||
url: app.forum.attribute('apiUrl') + '/extensions/' + this.extension.id,
|
||||
method: 'PATCH',
|
||||
body: { enabled: !enabled },
|
||||
errorHandler: this.onerror.bind(this),
|
||||
})
|
||||
.then(() => {
|
||||
if (!enabled) localStorage.setItem('enabledExtension', this.extension.id);
|
||||
window.location.reload();
|
||||
});
|
||||
|
||||
app.modal.show(LoadingModal);
|
||||
}
|
||||
|
||||
isEnabled() {
|
||||
return isExtensionEnabled(this.extension.id);
|
||||
}
|
||||
|
||||
onerror(e) {
|
||||
// We need to give the modal animation time to start; if we close the modal too early,
|
||||
// it breaks the bootstrap modal library.
|
||||
// TODO: This workaround should be removed when we move away from bootstrap JS for modals.
|
||||
setTimeout(() => {
|
||||
app.modal.close();
|
||||
}, 300); // Bootstrap's Modal.TRANSITION_DURATION is 300 ms.
|
||||
|
||||
this.changingState = false;
|
||||
|
||||
if (e.status !== 409) {
|
||||
throw e;
|
||||
}
|
||||
|
||||
const error = e.response.errors[0];
|
||||
|
||||
app.alerts.show(
|
||||
{ type: 'error' },
|
||||
app.translator.trans(`core.lib.error.${error.code}_message`, {
|
||||
extension: error.extension,
|
||||
extensions: error.extensions.join(', '),
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
@@ -1,53 +0,0 @@
|
||||
import PermissionGrid from './PermissionGrid';
|
||||
import Button from '../../common/components/Button';
|
||||
import ItemList from '../../common/utils/ItemList';
|
||||
|
||||
export default class ExtensionPermissionGrid extends PermissionGrid {
|
||||
oninit(vnode) {
|
||||
super.oninit(vnode);
|
||||
|
||||
this.extensionId = this.attrs.extensionId;
|
||||
}
|
||||
|
||||
permissionItems() {
|
||||
const permissionCategories = super.permissionItems();
|
||||
|
||||
permissionCategories.items = Object.entries(permissionCategories.items)
|
||||
.filter(([category, info]) => info.content.children.length > 0)
|
||||
.reduce((obj, [category, info]) => {
|
||||
obj[category] = info;
|
||||
return obj;
|
||||
}, {});
|
||||
|
||||
return permissionCategories;
|
||||
}
|
||||
|
||||
viewItems() {
|
||||
return app.extensionData.getExtensionPermissions(this.extensionId, 'view') || new ItemList();
|
||||
}
|
||||
|
||||
startItems() {
|
||||
return app.extensionData.getExtensionPermissions(this.extensionId, 'start') || new ItemList();
|
||||
}
|
||||
|
||||
replyItems() {
|
||||
return app.extensionData.getExtensionPermissions(this.extensionId, 'reply') || new ItemList();
|
||||
}
|
||||
|
||||
moderateItems() {
|
||||
return app.extensionData.getExtensionPermissions(this.extensionId, 'moderate') || new ItemList();
|
||||
}
|
||||
|
||||
scopeControlItems() {
|
||||
const items = new ItemList();
|
||||
|
||||
items.add(
|
||||
'configureScopes',
|
||||
<Button className="Button Button--text" onclick={() => m.route.set(app.route('permissions'))}>
|
||||
{app.translator.trans('core.admin.extension.configure_scopes')}
|
||||
</Button>
|
||||
);
|
||||
|
||||
return items;
|
||||
}
|
||||
}
|
117
js/src/admin/components/ExtensionsPage.js
Normal file
117
js/src/admin/components/ExtensionsPage.js
Normal file
@@ -0,0 +1,117 @@
|
||||
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());
|
||||
}
|
||||
}
|
@@ -1,51 +0,0 @@
|
||||
import DashboardWidget from './DashboardWidget';
|
||||
import isExtensionEnabled from '../utils/isExtensionEnabled';
|
||||
import getCategorizedExtensions from '../utils/getCategorizedExtensions';
|
||||
import Link from '../../common/components/Link';
|
||||
import icon from '../../common/helpers/icon';
|
||||
|
||||
export default class ExtensionsWidget extends DashboardWidget {
|
||||
oninit(vnode) {
|
||||
super.oninit(vnode);
|
||||
|
||||
this.categorizedExtensions = getCategorizedExtensions();
|
||||
}
|
||||
|
||||
className() {
|
||||
return 'ExtensionsWidget';
|
||||
}
|
||||
|
||||
content() {
|
||||
const categories = app.extensionCategories;
|
||||
|
||||
return (
|
||||
<div className="ExtensionsWidget-list">
|
||||
{Object.keys(categories).map((category) => (this.categorizedExtensions[category] ? this.extensionCategory(category) : ''))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
extensionCategory(category) {
|
||||
return (
|
||||
<div className="ExtensionList-Category">
|
||||
<h4 className="ExtensionList-Label">{app.translator.trans(`core.admin.nav.categories.${category}`)}</h4>
|
||||
<ul className="ExtensionList">{this.categorizedExtensions[category].map((extension) => this.extensionWidget(extension))}</ul>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
extensionWidget(extension) {
|
||||
return (
|
||||
<li className={'ExtensionListItem ' + (!isExtensionEnabled(extension.id) ? 'disabled' : '')}>
|
||||
<Link href={app.route('extension', { id: extension.id })}>
|
||||
<div className="ExtensionListItem-content">
|
||||
<span className="ExtensionListItem-icon ExtensionIcon" style={extension.icon}>
|
||||
{extension.icon ? icon(extension.icon.name) : ''}
|
||||
</span>
|
||||
<span className="ExtensionListItem-title">{extension.extra['flarum-extension'].title}</span>
|
||||
</div>
|
||||
</Link>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
}
|
@@ -8,7 +8,11 @@ import listItems from '../../common/helpers/listItems';
|
||||
*/
|
||||
export default class HeaderPrimary extends Component {
|
||||
view() {
|
||||
return <ul className="Header-controls">{listItems(this.items().toArray())}</ul>;
|
||||
return (
|
||||
<ul className="Header-controls">
|
||||
{listItems(this.items().toArray())}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
|
||||
config(isInitialized, context) {
|
||||
|
@@ -1,5 +1,4 @@
|
||||
import Component from '../../common/Component';
|
||||
import LinkButton from '../../common/components/LinkButton';
|
||||
import SessionDropdown from './SessionDropdown';
|
||||
import ItemList from '../../common/utils/ItemList';
|
||||
import listItems from '../../common/helpers/listItems';
|
||||
@@ -9,7 +8,18 @@ import listItems from '../../common/helpers/listItems';
|
||||
*/
|
||||
export default class HeaderSecondary extends Component {
|
||||
view() {
|
||||
return <ul className="Header-controls">{listItems(this.items().toArray())}</ul>;
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -20,13 +30,6 @@ export default class HeaderSecondary extends Component {
|
||||
items() {
|
||||
const items = new ItemList();
|
||||
|
||||
items.add(
|
||||
'help',
|
||||
<LinkButton href="https://docs.flarum.org/troubleshoot.html" icon="fas fa-question-circle" external={true} target="_blank">
|
||||
{app.translator.trans('core.admin.header.get_help')}
|
||||
</LinkButton>
|
||||
);
|
||||
|
||||
items.add('session', SessionDropdown.component());
|
||||
|
||||
return items;
|
||||
|
@@ -1,10 +1,9 @@
|
||||
import Modal from '../../common/components/Modal';
|
||||
|
||||
export default class LoadingModal extends Modal {
|
||||
/**
|
||||
* @inheritdoc
|
||||
*/
|
||||
static isDismissible = false;
|
||||
isDismissible() {
|
||||
return false;
|
||||
}
|
||||
|
||||
className() {
|
||||
return 'LoadingModal Modal--small';
|
||||
|
@@ -1,136 +1,150 @@
|
||||
import Page from './Page';
|
||||
import FieldSet from '../../common/components/FieldSet';
|
||||
import Button from '../../common/components/Button';
|
||||
import Alert from '../../common/components/Alert';
|
||||
import Select from '../../common/components/Select';
|
||||
import LoadingIndicator from '../../common/components/LoadingIndicator';
|
||||
import AdminPage from './AdminPage';
|
||||
import saveSettings from '../utils/saveSettings';
|
||||
|
||||
export default class MailPage extends AdminPage {
|
||||
oninit(vnode) {
|
||||
super.oninit(vnode);
|
||||
export default class MailPage extends Page {
|
||||
init() {
|
||||
super.init();
|
||||
|
||||
this.sendingTest = false;
|
||||
this.refresh();
|
||||
}
|
||||
|
||||
headerInfo() {
|
||||
return {
|
||||
className: 'MailPage',
|
||||
icon: 'fas fa-envelope',
|
||||
title: app.translator.trans('core.admin.email.title'),
|
||||
description: app.translator.trans('core.admin.email.description'),
|
||||
};
|
||||
}
|
||||
|
||||
refresh() {
|
||||
this.loading = true;
|
||||
this.saving = false;
|
||||
|
||||
this.status = { sending: false, errors: {} };
|
||||
this.driverFields = {};
|
||||
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'];
|
||||
const settings = app.data.settings;
|
||||
this.fields.forEach(key => this.values[key] = m.prop(settings[key]));
|
||||
|
||||
this.loading = false;
|
||||
m.redraw();
|
||||
});
|
||||
app.request({
|
||||
method: 'GET',
|
||||
url: app.forum.attribute('apiUrl') + '/mail-drivers'
|
||||
}).then(response => {
|
||||
this.driverFields = response['data'].reduce(
|
||||
(hash, driver) => ({...hash, [driver['id']]: driver['attributes']['fields']}),
|
||||
{}
|
||||
);
|
||||
|
||||
for (const driver in this.driverFields) {
|
||||
for (const field in this.driverFields[driver]) {
|
||||
this.fields.push(field);
|
||||
this.values[field] = m.prop(settings[field]);
|
||||
}
|
||||
}
|
||||
|
||||
this.loading = false;
|
||||
m.redraw();
|
||||
});
|
||||
}
|
||||
|
||||
content() {
|
||||
view() {
|
||||
if (this.loading) {
|
||||
return <LoadingIndicator />;
|
||||
return (
|
||||
<div className="MailPage">
|
||||
<div className="container">
|
||||
<LoadingIndicator />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const fields = this.driverFields[this.setting('mail_driver')()];
|
||||
const fieldKeys = Object.keys(fields);
|
||||
|
||||
return (
|
||||
<div className="Form">
|
||||
{this.buildSettingComponent({
|
||||
type: 'text',
|
||||
setting: 'mail_from',
|
||||
label: app.translator.trans('core.admin.email.addresses_heading'),
|
||||
className: 'MailPage-MailSettings',
|
||||
})}
|
||||
{this.buildSettingComponent({
|
||||
type: 'select',
|
||||
setting: 'mail_driver',
|
||||
options: Object.keys(this.driverFields).reduce((memo, val) => ({ ...memo, [val]: val }), {}),
|
||||
label: app.translator.trans('core.admin.email.driver_heading'),
|
||||
className: 'MailPage-MailSettings',
|
||||
})}
|
||||
{this.status.sending ||
|
||||
Alert.component(
|
||||
{
|
||||
dismissible: false,
|
||||
},
|
||||
app.translator.trans('core.admin.email.not_sending_message')
|
||||
)}
|
||||
|
||||
{fieldKeys.length > 0 && (
|
||||
<FieldSet label={app.translator.trans(`core.admin.email.${this.setting('mail_driver')()}_heading`)} className="MailPage-MailSettings">
|
||||
<div className="MailPage-MailSettings-input">
|
||||
{fieldKeys.map((field) => {
|
||||
const fieldInfo = fields[field];
|
||||
|
||||
return [
|
||||
this.buildSettingComponent({
|
||||
type: typeof this.setting(field)() === 'string' ? 'text' : 'select',
|
||||
label: app.translator.trans(`core.admin.email.${field}_label`),
|
||||
setting: field,
|
||||
options: fieldInfo,
|
||||
}),
|
||||
this.status.errors[field] && <p className="ValidationError">{this.status.errors[field]}</p>,
|
||||
];
|
||||
})}
|
||||
<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>
|
||||
)}
|
||||
{this.submitButton()}
|
||||
|
||||
<FieldSet label={app.translator.trans('core.admin.email.send_test_mail_heading')} className="MailPage-MailSettings">
|
||||
<div className="helpText">{app.translator.trans('core.admin.email.send_test_mail_text', { email: app.session.user.email() })}</div>
|
||||
{Button.component(
|
||||
{
|
||||
{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>
|
||||
]
|
||||
})}
|
||||
|
||||
{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')}</label>
|
||||
<Select value={this.values.mail_driver()} options={Object.keys(this.driverFields).reduce((memo, val) => ({...memo, [val]: val}), {})} onchange={this.values.mail_driver} />
|
||||
</div>
|
||||
]
|
||||
})}
|
||||
|
||||
{Object.keys(this.driverFields[this.values.mail_driver()]).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">
|
||||
{Object.keys(this.driverFields[this.values.mail_driver()]).map(field => [
|
||||
<label>{app.translator.trans(`core.admin.email.${field}_label`)}</label>,
|
||||
this.renderField(field),
|
||||
])}
|
||||
</div>
|
||||
]
|
||||
})}
|
||||
|
||||
{Button.component({
|
||||
type: 'submit',
|
||||
className: 'Button Button--primary',
|
||||
disabled: this.sendingTest || this.isChanged(),
|
||||
onclick: () => this.sendTestEmail(),
|
||||
},
|
||||
app.translator.trans('core.admin.email.send_test_mail_button')
|
||||
)}
|
||||
</FieldSet>
|
||||
children: app.translator.trans('core.admin.email.submit_button'),
|
||||
loading: this.saving,
|
||||
disabled: !this.changed()
|
||||
})}
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
sendTestEmail() {
|
||||
if (this.saving || this.sendingTest) return;
|
||||
renderField(name) {
|
||||
const driver = this.values.mail_driver();
|
||||
const field = this.driverFields[driver][name];
|
||||
const prop = this.values[name];
|
||||
|
||||
this.sendingTest = true;
|
||||
app.alerts.dismiss(this.testEmailSuccessAlert);
|
||||
if (typeof field === 'string') {
|
||||
return <input className="FormControl" value={prop() || ''} oninput={m.withAttr('value', prop)}/>;
|
||||
} else {
|
||||
return <Select value={prop()} options={field} onchange={prop} />;
|
||||
}
|
||||
}
|
||||
|
||||
app
|
||||
.request({
|
||||
method: 'POST',
|
||||
url: app.forum.attribute('apiUrl') + '/mail/test',
|
||||
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(() => {
|
||||
app.alerts.show(this.successAlert = new Alert({type: 'success', children: app.translator.trans('core.admin.basics.saved_message')}));
|
||||
})
|
||||
.then((response) => {
|
||||
this.sendingTest = false;
|
||||
this.testEmailSuccessAlert = app.alerts.show({ type: 'success' }, app.translator.trans('core.admin.email.send_test_mail_success'));
|
||||
})
|
||||
.catch((error) => {
|
||||
this.sendingTest = false;
|
||||
.catch(() => {})
|
||||
.then(() => {
|
||||
this.saving = false;
|
||||
m.redraw();
|
||||
throw error;
|
||||
});
|
||||
}
|
||||
|
||||
saveSettings(e) {
|
||||
super.saveSettings(e).then(this.refresh());
|
||||
}
|
||||
}
|
||||
|
32
js/src/admin/components/Page.js
Normal file
32
js/src/admin/components/Page.js
Normal file
@@ -0,0 +1,32 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
@@ -8,133 +8,126 @@ import GroupBadge from '../../common/components/GroupBadge';
|
||||
function badgeForId(id) {
|
||||
const group = app.store.getById('groups', id);
|
||||
|
||||
return group ? GroupBadge.component({ group, label: null }) : '';
|
||||
return group ? GroupBadge.component({group, label: null}) : '';
|
||||
}
|
||||
|
||||
function filterByRequiredPermissions(groupIds, permission) {
|
||||
app.getRequiredPermissions(permission).forEach((required) => {
|
||||
const restrictToGroupIds = app.data.permissions[required] || [];
|
||||
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);
|
||||
}
|
||||
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);
|
||||
});
|
||||
groupIds = filterByRequiredPermissions(groupIds, required);
|
||||
});
|
||||
|
||||
return groupIds;
|
||||
}
|
||||
|
||||
export default class PermissionDropdown extends Dropdown {
|
||||
static initAttrs(attrs) {
|
||||
super.initAttrs(attrs);
|
||||
static initProps(props) {
|
||||
super.initProps(props);
|
||||
|
||||
attrs.className = 'PermissionDropdown';
|
||||
attrs.buttonClassName = 'Button Button--text';
|
||||
props.className = 'PermissionDropdown';
|
||||
props.buttonClassName = 'Button Button--text';
|
||||
}
|
||||
|
||||
view(vnode) {
|
||||
const children = [];
|
||||
view() {
|
||||
this.props.children = [];
|
||||
|
||||
let groupIds = app.data.permissions[this.attrs.permission] || [];
|
||||
let groupIds = app.data.permissions[this.props.permission] || [];
|
||||
|
||||
groupIds = filterByRequiredPermissions(groupIds, this.attrs.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.attrs.label = Badge.component({ icon: 'fas fa-globe' });
|
||||
this.props.label = Badge.component({icon: 'fas fa-globe'});
|
||||
} else if (members) {
|
||||
this.attrs.label = Badge.component({ icon: 'fas fa-user' });
|
||||
this.props.label = Badge.component({icon: 'fas fa-user'});
|
||||
} else {
|
||||
this.attrs.label = [badgeForId(Group.ADMINISTRATOR_ID), groupIds.map(badgeForId)];
|
||||
this.props.label = [
|
||||
badgeForId(Group.ADMINISTRATOR_ID),
|
||||
groupIds.map(badgeForId)
|
||||
];
|
||||
}
|
||||
|
||||
if (this.showing) {
|
||||
if (this.attrs.allowGuest) {
|
||||
children.push(
|
||||
Button.component(
|
||||
{
|
||||
icon: everyone ? 'fas fa-check' : true,
|
||||
onclick: () => this.save([Group.GUEST_ID]),
|
||||
disabled: this.isGroupDisabled(Group.GUEST_ID),
|
||||
},
|
||||
[Badge.component({ icon: 'fas fa-globe' }), ' ', app.translator.trans('core.admin.permissions_controls.everyone_button')]
|
||||
)
|
||||
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)
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
children.push(
|
||||
Button.component(
|
||||
{
|
||||
icon: members ? 'fas fa-check' : true,
|
||||
onclick: () => this.save([Group.MEMBER_ID]),
|
||||
disabled: this.isGroupDisabled(Group.MEMBER_ID),
|
||||
},
|
||||
[Badge.component({ icon: 'fas fa-user' }), ' ', app.translator.trans('core.admin.permissions_controls.members_button')]
|
||||
),
|
||||
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(
|
||||
{
|
||||
icon: !everyone && !members ? 'fas fa-check' : true,
|
||||
disabled: !everyone && !members,
|
||||
onclick: (e) => {
|
||||
if (e.shiftKey) e.stopPropagation();
|
||||
this.save([]);
|
||||
},
|
||||
},
|
||||
[badgeForId(adminGroup.id()), ' ', adminGroup.namePlural()]
|
||||
)
|
||||
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(
|
||||
children,
|
||||
app.store
|
||||
.all('groups')
|
||||
.filter((group) => [Group.ADMINISTRATOR_ID, Group.GUEST_ID, Group.MEMBER_ID].indexOf(group.id()) === -1)
|
||||
.map((group) =>
|
||||
Button.component(
|
||||
{
|
||||
icon: groupIds.indexOf(group.id()) !== -1 ? 'fas fa-check' : true,
|
||||
onclick: (e) => {
|
||||
if (e.shiftKey) e.stopPropagation();
|
||||
this.toggle(group.id());
|
||||
},
|
||||
disabled: this.isGroupDisabled(group.id()) && this.isGroupDisabled(Group.MEMBER_ID) && this.isGroupDisabled(Group.GUEST_ID),
|
||||
},
|
||||
[badgeForId(group.id()), ' ', group.namePlural()]
|
||||
)
|
||||
)
|
||||
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({ ...vnode, children });
|
||||
return super.view();
|
||||
}
|
||||
|
||||
save(groupIds) {
|
||||
const permission = this.attrs.permission;
|
||||
const permission = this.props.permission;
|
||||
|
||||
app.data.permissions[permission] = groupIds;
|
||||
|
||||
app.request({
|
||||
method: 'POST',
|
||||
url: app.forum.attribute('apiUrl') + '/permission',
|
||||
body: { permission, groupIds },
|
||||
data: {permission, groupIds}
|
||||
});
|
||||
}
|
||||
|
||||
toggle(groupId) {
|
||||
const permission = this.attrs.permission;
|
||||
const permission = this.props.permission;
|
||||
|
||||
let groupIds = app.data.permissions[permission] || [];
|
||||
|
||||
@@ -144,13 +137,13 @@ export default class PermissionDropdown extends Dropdown {
|
||||
groupIds.splice(index, 1);
|
||||
} else {
|
||||
groupIds.push(groupId);
|
||||
groupIds = groupIds.filter((id) => [Group.GUEST_ID, Group.MEMBER_ID].indexOf(id) === -1);
|
||||
groupIds = groupIds.filter(id => [Group.GUEST_ID, Group.MEMBER_ID].indexOf(id) === -1);
|
||||
}
|
||||
|
||||
this.save(groupIds);
|
||||
}
|
||||
|
||||
isGroupDisabled(id) {
|
||||
return filterByRequiredPermissions([id], this.attrs.permission).indexOf(id) === -1;
|
||||
return filterByRequiredPermissions([id], this.props.permission).indexOf(id) === -1;
|
||||
}
|
||||
}
|
||||
|
@@ -6,11 +6,19 @@ 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>);
|
||||
const permissionCells = permission => {
|
||||
return scopes.map(scope => (
|
||||
<td>
|
||||
{scope.render(permission)}
|
||||
</td>
|
||||
));
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -18,38 +26,31 @@ export default class PermissionGrid extends Component {
|
||||
<thead>
|
||||
<tr>
|
||||
<td></td>
|
||||
{scopes.map((scope) => (
|
||||
{scopes.map(scope => (
|
||||
<th>
|
||||
{scope.label}{' '}
|
||||
{scope.onremove
|
||||
? Button.component({ icon: 'fas fa-times', className: 'Button Button--text PermissionGrid-removeScope', onclick: scope.onremove })
|
||||
: ''}
|
||||
{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 />
|
||||
{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>
|
||||
{section.children.map((child) => (
|
||||
<tr className="PermissionGrid-child">
|
||||
<th>
|
||||
{icon(child.icon)}
|
||||
{child.label}
|
||||
</th>
|
||||
{permissionCells(child)}
|
||||
<td />
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
))}
|
||||
))}
|
||||
</tbody>
|
||||
))}
|
||||
</table>
|
||||
);
|
||||
}
|
||||
@@ -57,41 +58,25 @@ export default class PermissionGrid extends Component {
|
||||
permissionItems() {
|
||||
const items = new ItemList();
|
||||
|
||||
items.add(
|
||||
'view',
|
||||
{
|
||||
label: app.translator.trans('core.admin.permissions.read_heading'),
|
||||
children: this.viewItems().toArray(),
|
||||
},
|
||||
100
|
||||
);
|
||||
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('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('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
|
||||
);
|
||||
items.add('moderate', {
|
||||
label: app.translator.trans('core.admin.permissions.moderate_heading'),
|
||||
children: this.moderateItems().toArray()
|
||||
}, 70);
|
||||
|
||||
return items;
|
||||
}
|
||||
@@ -99,54 +84,31 @@ export default class PermissionGrid extends Component {
|
||||
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('viewDiscussions', {
|
||||
icon: 'fas fa-eye',
|
||||
label: app.translator.trans('core.admin.permissions.view_discussions_label'),
|
||||
permission: 'viewDiscussions',
|
||||
allowGuest: true
|
||||
}, 100);
|
||||
|
||||
items.add(
|
||||
'viewHiddenGroups',
|
||||
{
|
||||
icon: 'fas fa-users',
|
||||
label: app.translator.trans('core.admin.permissions.view_hidden_groups_label'),
|
||||
permission: 'viewHiddenGroups',
|
||||
},
|
||||
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(
|
||||
'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('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',
|
||||
@@ -154,49 +116,37 @@ export default class PermissionGrid extends Component {
|
||||
permission: 'user.viewLastSeenAt',
|
||||
});
|
||||
|
||||
items.merge(app.extensionData.getAllExtensionPermissions('view'));
|
||||
|
||||
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('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);
|
||||
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
|
||||
);
|
||||
|
||||
items.merge(app.extensionData.getAllExtensionPermissions('start'));
|
||||
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;
|
||||
}
|
||||
@@ -204,41 +154,31 @@ export default class PermissionGrid extends Component {
|
||||
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('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);
|
||||
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
|
||||
);
|
||||
|
||||
items.merge(app.extensionData.getAllExtensionPermissions('reply'));
|
||||
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;
|
||||
}
|
||||
@@ -246,143 +186,75 @@ export default class PermissionGrid extends Component {
|
||||
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('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('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('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('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(
|
||||
'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(
|
||||
'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(
|
||||
'userEditCredentials',
|
||||
{
|
||||
icon: 'fas fa-user-cog',
|
||||
label: app.translator.trans('core.admin.permissions.edit_users_credentials_label'),
|
||||
permission: 'user.editCredentials',
|
||||
},
|
||||
60
|
||||
);
|
||||
|
||||
items.add(
|
||||
'userEditGroups',
|
||||
{
|
||||
icon: 'fas fa-users-cog',
|
||||
label: app.translator.trans('core.admin.permissions.edit_users_groups_label'),
|
||||
permission: 'user.editGroups',
|
||||
},
|
||||
60
|
||||
);
|
||||
|
||||
items.add(
|
||||
'userEdit',
|
||||
{
|
||||
icon: 'fas fa-address-card',
|
||||
label: app.translator.trans('core.admin.permissions.edit_users_label'),
|
||||
permission: 'user.edit',
|
||||
},
|
||||
60
|
||||
);
|
||||
|
||||
items.merge(app.extensionData.getAllExtensionPermissions('moderate'));
|
||||
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,
|
||||
});
|
||||
}
|
||||
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 '';
|
||||
}
|
||||
}, 100);
|
||||
|
||||
return items;
|
||||
}
|
||||
|
@@ -1,43 +1,41 @@
|
||||
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';
|
||||
import AdminPage from './AdminPage';
|
||||
|
||||
export default class PermissionsPage extends AdminPage {
|
||||
headerInfo() {
|
||||
return {
|
||||
className: 'PermissionsPage',
|
||||
icon: 'fas fa-key',
|
||||
title: app.translator.trans('core.admin.permissions.title'),
|
||||
description: app.translator.trans('core.admin.permissions.description'),
|
||||
};
|
||||
}
|
||||
|
||||
content() {
|
||||
return [
|
||||
<div className="PermissionsPage-groups">
|
||||
{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(EditGroupModal, { group })}>
|
||||
{GroupBadge.component({
|
||||
group,
|
||||
className: 'Group-icon',
|
||||
label: null,
|
||||
})}
|
||||
<span className="Group-name">{group.namePlural()}</span>
|
||||
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>
|
||||
))}
|
||||
<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>
|
||||
|
||||
<div className="PermissionsPage-permissions">{PermissionGrid.component()}</div>,
|
||||
];
|
||||
<div className="PermissionsPage-permissions">
|
||||
<div className="container">
|
||||
{PermissionGrid.component()}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
@@ -9,22 +9,27 @@ import ItemList from '../../common/utils/ItemList';
|
||||
* avatar/name, with a dropdown of session controls.
|
||||
*/
|
||||
export default class SessionDropdown extends Dropdown {
|
||||
static initAttrs(attrs) {
|
||||
super.initAttrs(attrs);
|
||||
static initProps(props) {
|
||||
super.initProps(props);
|
||||
|
||||
attrs.className = 'SessionDropdown';
|
||||
attrs.buttonClassName = 'Button Button--user Button--flat';
|
||||
attrs.menuClassName = 'Dropdown-menu--right';
|
||||
props.className = 'SessionDropdown';
|
||||
props.buttonClassName = 'Button Button--user Button--flat';
|
||||
props.menuClassName = 'Dropdown-menu--right';
|
||||
}
|
||||
|
||||
view(vnode) {
|
||||
return super.view({ ...vnode, children: this.items().toArray() });
|
||||
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>];
|
||||
return [
|
||||
avatar(user), ' ',
|
||||
<span className="Button-label">{username(user)}</span>
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -35,15 +40,12 @@ export default class SessionDropdown extends Dropdown {
|
||||
items() {
|
||||
const items = new ItemList();
|
||||
|
||||
items.add(
|
||||
'logOut',
|
||||
Button.component(
|
||||
{
|
||||
icon: 'fas fa-sign-out-alt',
|
||||
onclick: app.session.logout.bind(app.session),
|
||||
},
|
||||
app.translator.trans('core.admin.header.log_out_button')
|
||||
),
|
||||
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
|
||||
);
|
||||
|
||||
|
@@ -3,30 +3,23 @@ import Button from '../../common/components/Button';
|
||||
import saveSettings from '../utils/saveSettings';
|
||||
|
||||
export default class SettingDropdown extends SelectDropdown {
|
||||
static initAttrs(attrs) {
|
||||
super.initAttrs(attrs);
|
||||
static initProps(props) {
|
||||
super.initProps(props);
|
||||
|
||||
attrs.className = 'SettingDropdown';
|
||||
attrs.buttonClassName = 'Button Button--text';
|
||||
attrs.caretIcon = 'fas fa-caret-down';
|
||||
attrs.defaultLabel = 'Custom';
|
||||
}
|
||||
props.className = 'SettingDropdown';
|
||||
props.buttonClassName = 'Button Button--text';
|
||||
props.caretIcon = 'fas fa-caret-down';
|
||||
props.defaultLabel = 'Custom';
|
||||
|
||||
view(vnode) {
|
||||
return super.view({
|
||||
...vnode,
|
||||
children: this.attrs.options.map(({ value, label }) => {
|
||||
const active = app.data.settings[this.attrs.key] === value;
|
||||
props.children = props.options.map(({value, label}) => {
|
||||
const active = app.data.settings[props.key] === value;
|
||||
|
||||
return Button.component(
|
||||
{
|
||||
icon: active ? 'fas fa-check' : true,
|
||||
onclick: saveSettings.bind(this, { [this.attrs.key]: value }),
|
||||
active,
|
||||
},
|
||||
label
|
||||
);
|
||||
}),
|
||||
return Button.component({
|
||||
children: label,
|
||||
icon: active ? 'fas fa-check' : true,
|
||||
onclick: saveSettings.bind(this, {[props.key]: value}),
|
||||
active
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
@@ -1,12 +1,9 @@
|
||||
import Modal from '../../common/components/Modal';
|
||||
import Button from '../../common/components/Button';
|
||||
import Stream from '../../common/utils/Stream';
|
||||
import saveSettings from '../utils/saveSettings';
|
||||
|
||||
export default class SettingsModal extends Modal {
|
||||
oninit(vnode) {
|
||||
super.oninit(vnode);
|
||||
|
||||
init() {
|
||||
this.settings = {};
|
||||
this.loading = false;
|
||||
}
|
||||
@@ -21,7 +18,9 @@ export default class SettingsModal extends Modal {
|
||||
<div className="Form">
|
||||
{this.form()}
|
||||
|
||||
<div className="Form-group">{this.submitButton()}</div>
|
||||
<div className="Form-group">
|
||||
{this.submitButton()}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -29,14 +28,18 @@ export default class SettingsModal extends Modal {
|
||||
|
||||
submitButton() {
|
||||
return (
|
||||
<Button type="submit" className="Button Button--primary" loading={this.loading} disabled={!this.changed()}>
|
||||
<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] || Stream(app.data.settings[key] || fallback);
|
||||
this.settings[key] = this.settings[key] || m.prop(app.data.settings[key] || fallback);
|
||||
|
||||
return this.settings[key];
|
||||
}
|
||||
@@ -44,7 +47,7 @@ export default class SettingsModal extends Modal {
|
||||
dirty() {
|
||||
const dirty = {};
|
||||
|
||||
Object.keys(this.settings).forEach((key) => {
|
||||
Object.keys(this.settings).forEach(key => {
|
||||
const value = this.settings[key]();
|
||||
|
||||
if (value !== app.data.settings[key]) {
|
||||
@@ -64,7 +67,10 @@ export default class SettingsModal extends Modal {
|
||||
|
||||
this.loading = true;
|
||||
|
||||
saveSettings(this.dirty()).then(this.onsaved.bind(this), this.loaded.bind(this));
|
||||
saveSettings(this.dirty()).then(
|
||||
this.onsaved.bind(this),
|
||||
this.loaded.bind(this)
|
||||
);
|
||||
}
|
||||
|
||||
onsaved() {
|
||||
|
@@ -20,39 +20,39 @@ export default class StatusWidget extends DashboardWidget {
|
||||
}
|
||||
|
||||
content() {
|
||||
return <ul>{listItems(this.items().toArray())}</ul>;
|
||||
return (
|
||||
<ul>{listItems(this.items().toArray())}</ul>
|
||||
);
|
||||
}
|
||||
|
||||
items() {
|
||||
const items = new ItemList();
|
||||
|
||||
items.add(
|
||||
'tools',
|
||||
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>
|
||||
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]);
|
||||
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.modal.show(new LoadingModal());
|
||||
|
||||
app
|
||||
.request({
|
||||
method: 'DELETE',
|
||||
url: app.forum.attribute('apiUrl') + '/cache',
|
||||
})
|
||||
.then(() => window.location.reload());
|
||||
app.request({
|
||||
method: 'DELETE',
|
||||
url: app.forum.attribute('apiUrl') + '/cache'
|
||||
}).then(() => window.location.reload());
|
||||
}
|
||||
}
|
||||
|
@@ -1,28 +1,30 @@
|
||||
import Button from '../../common/components/Button';
|
||||
|
||||
export default class UploadImageButton extends Button {
|
||||
loading = false;
|
||||
init() {
|
||||
this.loading = false;
|
||||
}
|
||||
|
||||
view(vnode) {
|
||||
this.attrs.loading = this.loading;
|
||||
this.attrs.className = (this.attrs.className || '') + ' Button';
|
||||
view() {
|
||||
this.props.loading = this.loading;
|
||||
this.props.className = (this.props.className || '') + ' Button';
|
||||
|
||||
if (app.data.settings[this.attrs.name + '_path']) {
|
||||
this.attrs.onclick = this.remove.bind(this);
|
||||
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.attrs.name + 'Url')} alt="" />
|
||||
</p>
|
||||
<p>{super.view({ ...vnode, children: app.translator.trans('core.admin.upload_image.remove_button') })}</p>
|
||||
<p><img src={app.forum.attribute(this.props.name+'Url')} alt=""/></p>
|
||||
<p>{super.view()}</p>
|
||||
</div>
|
||||
);
|
||||
} else {
|
||||
this.attrs.onclick = this.upload.bind(this);
|
||||
this.props.onclick = this.upload.bind(this);
|
||||
this.props.children = app.translator.trans('core.admin.upload_image.upload_button');
|
||||
}
|
||||
|
||||
return super.view({ ...vnode, children: app.translator.trans('core.admin.upload_image.upload_button') });
|
||||
return super.view();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -33,26 +35,23 @@ export default class UploadImageButton extends Button {
|
||||
|
||||
const $input = $('<input type="file">');
|
||||
|
||||
$input
|
||||
.appendTo('body')
|
||||
.hide()
|
||||
.click()
|
||||
.on('change', (e) => {
|
||||
const body = new FormData();
|
||||
body.append(this.attrs.name, $(e.target)[0].files[0]);
|
||||
$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();
|
||||
this.loading = true;
|
||||
m.redraw();
|
||||
|
||||
app
|
||||
.request({
|
||||
method: 'POST',
|
||||
url: this.resourceUrl(),
|
||||
serialize: (raw) => raw,
|
||||
body,
|
||||
})
|
||||
.then(this.success.bind(this), this.failure.bind(this));
|
||||
});
|
||||
app.request({
|
||||
method: 'POST',
|
||||
url: this.resourceUrl(),
|
||||
serialize: raw => raw,
|
||||
data
|
||||
}).then(
|
||||
this.success.bind(this),
|
||||
this.failure.bind(this)
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -62,16 +61,17 @@ export default class UploadImageButton extends Button {
|
||||
this.loading = true;
|
||||
m.redraw();
|
||||
|
||||
app
|
||||
.request({
|
||||
method: 'DELETE',
|
||||
url: this.resourceUrl(),
|
||||
})
|
||||
.then(this.success.bind(this), this.failure.bind(this));
|
||||
app.request({
|
||||
method: 'DELETE',
|
||||
url: this.resourceUrl()
|
||||
}).then(
|
||||
this.success.bind(this),
|
||||
this.failure.bind(this)
|
||||
);
|
||||
}
|
||||
|
||||
resourceUrl() {
|
||||
return app.forum.attribute('apiUrl') + '/' + this.attrs.name;
|
||||
return app.forum.attribute('apiUrl') + '/' + this.props.name;
|
||||
}
|
||||
|
||||
/**
|
||||
|
38
js/src/admin/components/Widget.js
Normal file
38
js/src/admin/components/Widget.js
Normal file
@@ -0,0 +1,38 @@
|
||||
/*
|
||||
* 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 [];
|
||||
}
|
||||
}
|
@@ -9,10 +9,10 @@ export { app };
|
||||
|
||||
// Export public API
|
||||
|
||||
|
||||
// Export compat API
|
||||
import compatObj from './compat';
|
||||
import proxifyCompat from '../common/utils/proxifyCompat';
|
||||
import compat from './compat';
|
||||
|
||||
compatObj.app = app;
|
||||
compat.app = app;
|
||||
|
||||
export const compat = proxifyCompat(compatObj, 'admin');
|
||||
export { compat };
|
||||
|
@@ -1,19 +0,0 @@
|
||||
import DefaultResolver from '../../common/resolvers/DefaultResolver';
|
||||
|
||||
/**
|
||||
* A custom route resolver for ExtensionPage that generates handles routes
|
||||
* to default extension pages or a page provided by an extension.
|
||||
*/
|
||||
export default class ExtensionPageResolver extends DefaultResolver {
|
||||
static extension: string | null = null;
|
||||
|
||||
onmatch(args, requestedPath, route) {
|
||||
const extensionPage = app.extensionData.getPage(args.id);
|
||||
|
||||
if (extensionPage) {
|
||||
return extensionPage;
|
||||
}
|
||||
|
||||
return super.onmatch(args, requestedPath, route);
|
||||
}
|
||||
}
|
@@ -2,22 +2,21 @@ 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';
|
||||
import ExtensionPage from './components/ExtensionPage';
|
||||
import ExtensionPageResolver from './resolvers/ExtensionPageResolver';
|
||||
|
||||
/**
|
||||
* The `routes` initializer defines the forum app's routes.
|
||||
*
|
||||
* @param {App} app
|
||||
*/
|
||||
export default function (app) {
|
||||
export default function(app) {
|
||||
app.routes = {
|
||||
dashboard: { path: '/', component: DashboardPage },
|
||||
basics: { path: '/basics', component: BasicsPage },
|
||||
permissions: { path: '/permissions', component: PermissionsPage },
|
||||
appearance: { path: '/appearance', component: AppearancePage },
|
||||
mail: { path: '/mail', component: MailPage },
|
||||
extension: { path: '/extension/:id', component: ExtensionPage, resolverClass: ExtensionPageResolver },
|
||||
'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()}
|
||||
};
|
||||
}
|
||||
|
@@ -1,177 +0,0 @@
|
||||
import ItemList from '../../common/utils/ItemList';
|
||||
|
||||
export default class ExtensionData {
|
||||
constructor() {
|
||||
this.data = {};
|
||||
this.currentExtension = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* This function simply takes the extension id
|
||||
*
|
||||
* @example
|
||||
* app.extensionData.load('flarum-tags')
|
||||
*
|
||||
* flarum/flags -> flarum-flags | acme/extension -> acme-extension
|
||||
*
|
||||
* @param extension
|
||||
*/
|
||||
for(extension) {
|
||||
this.currentExtension = extension;
|
||||
this.data[extension] = this.data[extension] || {};
|
||||
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* This function registers your settings with Flarum
|
||||
*
|
||||
* It takes either a settings object or a callback.
|
||||
*
|
||||
* @example
|
||||
*
|
||||
* .registerSetting({
|
||||
* setting: 'flarum-flags.guidelines_url',
|
||||
* type: 'text', // This will be inputted into the input tag for the setting (text/number/etc)
|
||||
* label: app.translator.trans('flarum-flags.admin.settings.guidelines_url_label')
|
||||
* }, 15) // priority is optional (ItemList)
|
||||
*
|
||||
*
|
||||
* @param content
|
||||
* @param priority
|
||||
* @returns {ExtensionData}
|
||||
*/
|
||||
registerSetting(content, priority = 0) {
|
||||
this.data[this.currentExtension].settings = this.data[this.currentExtension].settings || new ItemList();
|
||||
|
||||
// Callbacks can be passed in instead of settings to display custom content.
|
||||
// By default, they will be added with the `null` key, since they don't have a `.setting` attr.
|
||||
// To support multiple such items for one extension, we assign a random ID.
|
||||
// 36 is arbitrary length, but makes collisions very unlikely.
|
||||
if (typeof content === 'function') {
|
||||
content.setting = Math.random().toString(36);
|
||||
}
|
||||
|
||||
this.data[this.currentExtension].settings.add(content.setting, content, priority);
|
||||
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* This function registers your permission with Flarum
|
||||
*
|
||||
* @example
|
||||
*
|
||||
* .registerPermission('permissions', {
|
||||
* icon: 'fas fa-flag',
|
||||
* label: app.translator.trans('flarum-flags.admin.permissions.view_flags_label'),
|
||||
* permission: 'discussion.viewFlags'
|
||||
* }, 'moderate', 65)
|
||||
*
|
||||
* @param content
|
||||
* @param permissionType
|
||||
* @param priority
|
||||
* @returns {ExtensionData}
|
||||
*/
|
||||
registerPermission(content, permissionType = null, priority = 0) {
|
||||
this.data[this.currentExtension].permissions = this.data[this.currentExtension].permissions || {};
|
||||
|
||||
if (!this.data[this.currentExtension].permissions[permissionType]) {
|
||||
this.data[this.currentExtension].permissions[permissionType] = new ItemList();
|
||||
}
|
||||
|
||||
this.data[this.currentExtension].permissions[permissionType].add(content.permission, content, priority);
|
||||
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Replace the default extension page with a custom component.
|
||||
* This component would typically extend ExtensionPage
|
||||
*
|
||||
* @param component
|
||||
* @returns {ExtensionData}
|
||||
*/
|
||||
registerPage(component) {
|
||||
this.data[this.currentExtension].page = component;
|
||||
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get an extension's registered settings
|
||||
*
|
||||
* @param extensionId
|
||||
* @returns {boolean|*}
|
||||
*/
|
||||
getSettings(extensionId) {
|
||||
if (this.data[extensionId] && this.data[extensionId].settings) {
|
||||
return this.data[extensionId].settings.toArray();
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* Get an ItemList of all extensions' registered permissions
|
||||
*
|
||||
* @param extension
|
||||
* @param type
|
||||
* @returns {ItemList}
|
||||
*/
|
||||
getAllExtensionPermissions(type) {
|
||||
const items = new ItemList();
|
||||
|
||||
Object.keys(this.data).map((extension) => {
|
||||
if (this.extensionHasPermissions(extension) && this.data[extension].permissions[type]) {
|
||||
items.merge(this.data[extension].permissions[type]);
|
||||
}
|
||||
});
|
||||
|
||||
return items;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a singular extension's registered permissions
|
||||
*
|
||||
* @param extension
|
||||
* @param type
|
||||
* @returns {boolean|*}
|
||||
*/
|
||||
getExtensionPermissions(extension, type) {
|
||||
if (this.extensionHasPermissions(extension) && this.data[extension].permissions[type]) {
|
||||
return this.data[extension].permissions[type];
|
||||
}
|
||||
|
||||
return new ItemList();
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks whether a given extension has registered permissions.
|
||||
*
|
||||
* @param extension
|
||||
* @returns {boolean}
|
||||
*/
|
||||
extensionHasPermissions(extension) {
|
||||
if (this.data[extension] && this.data[extension].permissions) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns an extension's custom page component if it exists.
|
||||
*
|
||||
* @param extension
|
||||
* @returns {boolean|*}
|
||||
*/
|
||||
getPage(extension) {
|
||||
if (this.data[extension]) {
|
||||
return this.data[extension].page;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
@@ -1,25 +0,0 @@
|
||||
export default function getCategorizedExtensions() {
|
||||
let extensions = {};
|
||||
|
||||
Object.keys(app.data.extensions).map((id) => {
|
||||
const extension = app.data.extensions[id];
|
||||
let category = extension.extra['flarum-extension'].category;
|
||||
|
||||
// Wrap languages packs into new system
|
||||
if (extension.extra['flarum-locale']) {
|
||||
category = 'language';
|
||||
}
|
||||
|
||||
if (category in app.extensionCategories) {
|
||||
extensions[category] = extensions[category] || [];
|
||||
|
||||
extensions[category].push(extension);
|
||||
} else {
|
||||
extensions.feature = extensions.feature || [];
|
||||
|
||||
extensions.feature.push(extension);
|
||||
}
|
||||
});
|
||||
|
||||
return extensions;
|
||||
}
|
@@ -1,5 +0,0 @@
|
||||
export default function isExtensionEnabled(name) {
|
||||
const enabled = JSON.parse(app.data.settings.extensions_enabled);
|
||||
|
||||
return enabled.includes(name);
|
||||
}
|
@@ -3,14 +3,12 @@ export default function saveSettings(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;
|
||||
});
|
||||
return app.request({
|
||||
method: 'POST',
|
||||
url: app.forum.attribute('apiUrl') + '/settings',
|
||||
data: settings
|
||||
}).catch(error => {
|
||||
app.data.settings = oldSettings;
|
||||
throw error;
|
||||
});
|
||||
}
|
||||
|
@@ -1,4 +1,5 @@
|
||||
import ItemList from './utils/ItemList';
|
||||
import Alert from './components/Alert';
|
||||
import Button from './components/Button';
|
||||
import ModalManager from './components/ModalManager';
|
||||
import AlertManager from './components/AlertManager';
|
||||
@@ -11,7 +12,6 @@ import Drawer from './utils/Drawer';
|
||||
import mapRoutes from './utils/mapRoutes';
|
||||
import RequestError from './utils/RequestError';
|
||||
import ScrollListener from './utils/ScrollListener';
|
||||
import liveHumanTimes from './utils/liveHumanTimes';
|
||||
import { extend } from './extend';
|
||||
|
||||
import Forum from './models/Forum';
|
||||
@@ -21,9 +21,6 @@ import Post from './models/Post';
|
||||
import Group from './models/Group';
|
||||
import Notification from './models/Notification';
|
||||
import { flattenDeep } from 'lodash-es';
|
||||
import PageState from './states/PageState';
|
||||
import ModalManagerState from './states/ModalManagerState';
|
||||
import AlertManagerState from './states/AlertManagerState';
|
||||
|
||||
/**
|
||||
* The `App` class provides a container for an application, as well as various
|
||||
@@ -89,7 +86,7 @@ export default class Application {
|
||||
discussions: Discussion,
|
||||
posts: Post,
|
||||
groups: Group,
|
||||
notifications: Notification,
|
||||
notifications: Notification
|
||||
});
|
||||
|
||||
/**
|
||||
@@ -110,49 +107,13 @@ export default class Application {
|
||||
booted = false;
|
||||
|
||||
/**
|
||||
* The key for an Alert that was shown as a result of an AJAX request error.
|
||||
* If present, it will be dismissed on the next successful request.
|
||||
* 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 {int}
|
||||
* @type {null|Alert}
|
||||
* @private
|
||||
*/
|
||||
requestErrorAlert = null;
|
||||
|
||||
/**
|
||||
* The page the app is currently on.
|
||||
*
|
||||
* This object holds information about the type of page we are currently
|
||||
* visiting, and sometimes additional arbitrary page state that may be
|
||||
* relevant to lower-level components.
|
||||
*
|
||||
* @type {PageState}
|
||||
*/
|
||||
current = new PageState(null);
|
||||
|
||||
/**
|
||||
* The page the app was on before the current page.
|
||||
*
|
||||
* Once the application navigates to another page, the object previously
|
||||
* assigned to this.current will be moved to this.previous, while this.current
|
||||
* is re-initialized.
|
||||
*
|
||||
* @type {PageState}
|
||||
*/
|
||||
previous = new PageState(null);
|
||||
|
||||
/*
|
||||
* An object that manages modal state.
|
||||
*
|
||||
* @type {ModalManagerState}
|
||||
*/
|
||||
modal = new ModalManagerState();
|
||||
|
||||
/**
|
||||
* An object that manages the state of active alerts.
|
||||
*
|
||||
* @type {AlertManagerState}
|
||||
*/
|
||||
alerts = new AlertManagerState();
|
||||
requestError = null;
|
||||
|
||||
data;
|
||||
|
||||
@@ -165,19 +126,22 @@ export default class Application {
|
||||
}
|
||||
|
||||
boot() {
|
||||
this.initializers.toArray().forEach((initializer) => initializer(this));
|
||||
this.initializers.toArray().forEach(initializer => initializer(this));
|
||||
|
||||
this.store.pushPayload({ data: this.data.resources });
|
||||
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.session = new Session(
|
||||
this.store.getById('users', this.data.session.userId),
|
||||
this.data.session.csrfToken
|
||||
);
|
||||
|
||||
this.mount();
|
||||
}
|
||||
|
||||
bootExtensions(extensions) {
|
||||
Object.keys(extensions).forEach((name) => {
|
||||
Object.keys(extensions).forEach(name => {
|
||||
const extension = extensions[name];
|
||||
|
||||
const extenders = flattenDeep(extension.extend);
|
||||
@@ -189,34 +153,31 @@ export default class Application {
|
||||
}
|
||||
|
||||
mount(basePath = '') {
|
||||
// An object with a callable view property is used in order to pass arguments to the component; see https://mithril.js.org/mount.html
|
||||
m.mount(document.getElementById('modal'), { view: () => ModalManager.component({ state: this.modal }) });
|
||||
m.mount(document.getElementById('alerts'), { view: () => AlertManager.component({ state: this.alerts }) });
|
||||
this.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));
|
||||
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. When this happens, we'll add classes to the header and app body
|
||||
// which will set the navbar's position to fixed. We don't want to always
|
||||
// have it fixed, as that could overlap with custom headers.
|
||||
const scrollListener = new ScrollListener((top) => {
|
||||
// down.
|
||||
new ScrollListener(top => {
|
||||
const $app = $('#app');
|
||||
const offset = $app.offset().top;
|
||||
|
||||
$app.toggleClass('affix', top >= offset).toggleClass('scrolled', top > offset);
|
||||
$('.App-header').toggleClass('navbar-fixed-top', top >= offset);
|
||||
});
|
||||
|
||||
scrollListener.start();
|
||||
scrollListener.update();
|
||||
$app
|
||||
.toggleClass('affix', top >= offset)
|
||||
.toggleClass('scrolled', top > offset);
|
||||
}).start();
|
||||
|
||||
$(() => {
|
||||
$('body').addClass('ontouchstart' in window ? 'touch' : 'no-touch');
|
||||
});
|
||||
|
||||
liveHumanTimes();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -237,16 +198,6 @@ export default class Application {
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine the current screen mode, based on our media queries.
|
||||
*
|
||||
* @returns {String} - one of "phone", "tablet", "desktop" or "desktop-hd"
|
||||
*/
|
||||
screen() {
|
||||
const styles = getComputedStyle(document.documentElement);
|
||||
return styles.getPropertyValue('--flarum-screen');
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the <title> of the page.
|
||||
*
|
||||
@@ -269,16 +220,15 @@ export default class Application {
|
||||
}
|
||||
|
||||
updateTitle() {
|
||||
const count = this.titleCount ? `(${this.titleCount}) ` : '';
|
||||
const pageTitleWithSeparator = this.title && m.route.get() !== this.forum.attribute('basePath') + '/' ? this.title + ' - ' : '';
|
||||
const title = this.forum.attribute('title');
|
||||
document.title = count + pageTitleWithSeparator + title;
|
||||
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://mithril.js.org/request.html
|
||||
* @see https://lhorie.github.io/mithril/mithril.request.html
|
||||
* @param {Object} options
|
||||
* @return {Promise}
|
||||
* @public
|
||||
@@ -306,19 +256,17 @@ export default class Application {
|
||||
// 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.deserialize = options.deserialize || (responseText => responseText);
|
||||
|
||||
options.errorHandler =
|
||||
options.errorHandler ||
|
||||
((error) => {
|
||||
throw error;
|
||||
});
|
||||
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) => {
|
||||
options.extract = xhr => {
|
||||
let responseText;
|
||||
|
||||
if (original) {
|
||||
@@ -345,88 +293,73 @@ export default class Application {
|
||||
}
|
||||
};
|
||||
|
||||
if (this.requestErrorAlert) this.alerts.dismiss(this.requestErrorAlert);
|
||||
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(
|
||||
(response) => response,
|
||||
(error) => {
|
||||
let content;
|
||||
const deferred = m.deferred();
|
||||
|
||||
switch (error.status) {
|
||||
case 422:
|
||||
content = error.response.errors
|
||||
.map((error) => [error.detail, <br />])
|
||||
.reduce((a, b) => a.concat(b), [])
|
||||
.slice(0, -1);
|
||||
break;
|
||||
m.request(options).then(response => deferred.resolve(response), error => {
|
||||
this.requestError = error;
|
||||
|
||||
case 401:
|
||||
case 403:
|
||||
content = app.translator.trans('core.lib.error.permission_denied_message');
|
||||
break;
|
||||
let children;
|
||||
|
||||
case 404:
|
||||
case 410:
|
||||
content = app.translator.trans('core.lib.error.not_found_message');
|
||||
break;
|
||||
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 429:
|
||||
content = app.translator.trans('core.lib.error.rate_limit_exceeded_message');
|
||||
break;
|
||||
case 401:
|
||||
case 403:
|
||||
children = app.translator.trans('core.lib.error.permission_denied_message');
|
||||
break;
|
||||
|
||||
default:
|
||||
content = app.translator.trans('core.lib.error.generic_message');
|
||||
}
|
||||
case 404:
|
||||
case 410:
|
||||
children = app.translator.trans('core.lib.error.not_found_message');
|
||||
break;
|
||||
|
||||
const isDebug = app.forum.attribute('debug');
|
||||
// contains a formatted errors if possible, response must be an JSON API array of errors
|
||||
// the details property is decoded to transform escaped characters such as '\n'
|
||||
const errors = error.response && error.response.errors;
|
||||
const formattedError = Array.isArray(errors) && errors[0] && errors[0].detail && errors.map((e) => decodeURI(e.detail));
|
||||
case 429:
|
||||
children = app.translator.trans('core.lib.error.rate_limit_exceeded_message');
|
||||
break;
|
||||
|
||||
error.alert = {
|
||||
type: 'error',
|
||||
content,
|
||||
controls: isDebug && [
|
||||
<Button className="Button Button--link" onclick={this.showDebug.bind(this, error, formattedError)}>
|
||||
Debug
|
||||
</Button>,
|
||||
],
|
||||
};
|
||||
|
||||
try {
|
||||
options.errorHandler(error);
|
||||
} catch (error) {
|
||||
if (isDebug && error.xhr) {
|
||||
const { method, url } = error.options;
|
||||
const { status = '' } = error.xhr;
|
||||
|
||||
console.group(`${method} ${url} ${status}`);
|
||||
|
||||
console.error(...(formattedError || [error]));
|
||||
|
||||
console.groupEnd();
|
||||
}
|
||||
|
||||
this.requestErrorAlert = this.alerts.show(error.alert, error.alert.content);
|
||||
}
|
||||
|
||||
return Promise.reject(error);
|
||||
default:
|
||||
children = app.translator.trans('core.lib.error.generic_message');
|
||||
}
|
||||
);
|
||||
|
||||
const isDebug = app.forum.attribute('debug');
|
||||
|
||||
error.alert = new Alert({
|
||||
type: 'error',
|
||||
children,
|
||||
controls: isDebug && [
|
||||
<Button className="Button Button--link" onclick={this.showDebug.bind(this, error)}>Debug</Button>
|
||||
]
|
||||
});
|
||||
|
||||
try {
|
||||
options.errorHandler(error);
|
||||
} catch (error) {
|
||||
this.alerts.show(error.alert);
|
||||
}
|
||||
|
||||
deferred.reject(error);
|
||||
});
|
||||
|
||||
return deferred.promise;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {RequestError} error
|
||||
* @param {string[]} [formattedError]
|
||||
* @private
|
||||
*/
|
||||
showDebug(error, formattedError) {
|
||||
this.alerts.dismiss(this.requestErrorAlert);
|
||||
showDebug(error) {
|
||||
this.alerts.dismiss(this.requestError.alert);
|
||||
|
||||
this.modal.show(RequestErrorModal, { error, formattedError });
|
||||
this.modal.show(new RequestErrorModal({error}));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -438,19 +371,9 @@ export default class Application {
|
||||
* @public
|
||||
*/
|
||||
route(name, params = {}) {
|
||||
const route = this.routes[name];
|
||||
|
||||
if (!route) throw new Error(`Route '${name}' does not exist`);
|
||||
|
||||
const url = route.path.replace(/:([^\/]+)/g, (m, key) => extract(params, key));
|
||||
|
||||
// Remove falsy values in params to avoid having urls like '/?sort&q'
|
||||
for (const key in params) {
|
||||
if (params.hasOwnProperty(key) && !params[key]) delete params[key];
|
||||
}
|
||||
|
||||
const queryString = m.buildQueryString(params);
|
||||
const prefix = m.route.prefix === '' ? this.forum.attribute('basePath') : '';
|
||||
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 : '');
|
||||
}
|
||||
|
225
js/src/common/Component.js
Normal file
225
js/src/common/Component.js
Normal file
@@ -0,0 +1,225 @@
|
||||
/*
|
||||
* 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) {
|
||||
}
|
||||
}
|
@@ -1,132 +0,0 @@
|
||||
import * as Mithril from 'mithril';
|
||||
|
||||
export interface ComponentAttrs extends Mithril.Attributes {}
|
||||
|
||||
/**
|
||||
* The `Component` class defines a user interface 'building block'. A component
|
||||
* generates a virtual DOM to be rendered on each redraw.
|
||||
*
|
||||
* Essentially, this is a wrapper for Mithril's components that adds several useful features:
|
||||
*
|
||||
* - In the `oninit` and `onbeforeupdate` lifecycle hooks, we store vnode attrs in `this.attrs.
|
||||
* This allows us to use attrs across components without having to pass the vnode to every single
|
||||
* method.
|
||||
* - The static `initAttrs` method allows a convenient way to provide defaults (or to otherwise modify)
|
||||
* the attrs that have been passed into a component.
|
||||
* - When the component is created in the DOM, we store its DOM element under `this.element`; this lets
|
||||
* us use jQuery to modify child DOM state from internal methods via the `this.$()` method.
|
||||
* - A convenience `component` method, which serves as an alternative to hyperscript and JSX.
|
||||
*
|
||||
* As with other Mithril components, components extending Component can be initialized
|
||||
* and nested using JSX, hyperscript, or a combination of both. The `component` method can also
|
||||
* be used.
|
||||
*
|
||||
* @example
|
||||
* return m('div', <MyComponent foo="bar"><p>Hello World</p></MyComponent>);
|
||||
*
|
||||
* @example
|
||||
* return m('div', MyComponent.component({foo: 'bar'), m('p', 'Hello World!'));
|
||||
*
|
||||
* @see https://mithril.js.org/components.html
|
||||
*/
|
||||
export default abstract class Component<T extends ComponentAttrs = ComponentAttrs> implements Mithril.ClassComponent<T> {
|
||||
/**
|
||||
* The root DOM element for the component.
|
||||
*/
|
||||
protected element!: Element;
|
||||
|
||||
/**
|
||||
* The attributes passed into the component.
|
||||
*
|
||||
* @see https://mithril.js.org/components.html#passing-data-to-components
|
||||
*/
|
||||
protected attrs!: T;
|
||||
|
||||
/**
|
||||
* @inheritdoc
|
||||
*/
|
||||
abstract view(vnode: Mithril.Vnode<T, this>): Mithril.Children;
|
||||
|
||||
/**
|
||||
* @inheritdoc
|
||||
*/
|
||||
oninit(vnode: Mithril.Vnode<T, this>) {
|
||||
this.setAttrs(vnode.attrs);
|
||||
}
|
||||
|
||||
/**
|
||||
* @inheritdoc
|
||||
*/
|
||||
oncreate(vnode: Mithril.VnodeDOM<T, this>) {
|
||||
this.element = vnode.dom;
|
||||
}
|
||||
|
||||
/**
|
||||
* @inheritdoc
|
||||
*/
|
||||
onbeforeupdate(vnode: Mithril.VnodeDOM<T, this>) {
|
||||
this.setAttrs(vnode.attrs);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a jQuery object for this component's element. If you pass in a
|
||||
* selector string, this method will return a jQuery object, using the current
|
||||
* element as its buffer.
|
||||
*
|
||||
* For example, calling `component.$('li')` will return a jQuery object
|
||||
* containing all of the `li` elements inside the DOM element of this
|
||||
* component.
|
||||
*
|
||||
* @param {String} [selector] a jQuery-compatible selector string
|
||||
* @returns {jQuery} the jQuery object for the DOM node
|
||||
* @final
|
||||
*/
|
||||
protected $(selector) {
|
||||
const $element = $(this.element);
|
||||
|
||||
return selector ? $element.find(selector) : $element;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convenience method to attach a component without JSX.
|
||||
* Has the same effect as calling `m(THIS_CLASS, attrs, children)`.
|
||||
*
|
||||
* @see https://mithril.js.org/hyperscript.html#mselector,-attributes,-children
|
||||
*/
|
||||
static component(attrs = {}, children = null): Mithril.Vnode {
|
||||
const componentAttrs = Object.assign({}, attrs);
|
||||
|
||||
return m(this as any, componentAttrs, children);
|
||||
}
|
||||
|
||||
/**
|
||||
* Saves a reference to the vnode attrs after running them through initAttrs,
|
||||
* and checking for common issues.
|
||||
*/
|
||||
private setAttrs(attrs: T = {} as T): void {
|
||||
(this.constructor as typeof Component).initAttrs(attrs);
|
||||
|
||||
if (attrs) {
|
||||
if ('children' in attrs) {
|
||||
throw new Error(
|
||||
`[${
|
||||
(this.constructor as any).name
|
||||
}] The "children" attribute of attrs should never be used. Either pass children in as the vnode children or rename the attribute`
|
||||
);
|
||||
}
|
||||
|
||||
if ('tag' in attrs) {
|
||||
throw new Error(`[${(this.constructor as any).name}] You cannot use the "tag" attribute name with Mithril 2.`);
|
||||
}
|
||||
}
|
||||
|
||||
this.attrs = attrs;
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize the component's attrs.
|
||||
*
|
||||
* This can be used to assign default values for missing, optional attrs.
|
||||
*/
|
||||
protected static initAttrs<T>(attrs: T): void {}
|
||||
}
|
@@ -1,74 +0,0 @@
|
||||
import * as Mithril from 'mithril';
|
||||
|
||||
/**
|
||||
* The `Fragment` class represents a chunk of DOM that is rendered once with Mithril and then takes
|
||||
* over control of its own DOM and lifecycle.
|
||||
*
|
||||
* This is very similar to the `Component` wrapper class, but is used for more fine-grained control over
|
||||
* the rendering and display of some significant chunks of the DOM. In contrast to components, fragments
|
||||
* do not offer Mithril's lifecycle hooks.
|
||||
*
|
||||
* Use this when you want to enjoy the benefits of JSX / VDOM for initial rendering, combined with
|
||||
* small helper methods that then make updates to that DOM directly, instead of fully redrawing
|
||||
* everything through Mithril.
|
||||
*
|
||||
* This should only be used when necessary, and only with `m.render`. If you are unsure whether you need
|
||||
* this or `Component, you probably need `Component`.
|
||||
*/
|
||||
export default abstract class Fragment {
|
||||
/**
|
||||
* The root DOM element for the fragment.
|
||||
*/
|
||||
protected element!: Element;
|
||||
|
||||
/**
|
||||
* Returns a jQuery object for this fragment's element. If you pass in a
|
||||
* selector string, this method will return a jQuery object, using the current
|
||||
* element as its buffer.
|
||||
*
|
||||
* For example, calling `fragment.$('li')` will return a jQuery object
|
||||
* containing all of the `li` elements inside the DOM element of this
|
||||
* fragment.
|
||||
*
|
||||
* @param {String} [selector] a jQuery-compatible selector string
|
||||
* @returns {jQuery} the jQuery object for the DOM node
|
||||
* @final
|
||||
*/
|
||||
public $(selector) {
|
||||
const $element = $(this.element);
|
||||
|
||||
return selector ? $element.find(selector) : $element;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the renderable virtual DOM that represents the fragment's view.
|
||||
*
|
||||
* This should NOT be overridden by subclasses. Subclasses wishing to define
|
||||
* their virtual DOM should override Fragment#view instead.
|
||||
*
|
||||
* @example
|
||||
* const fragment = new MyFragment();
|
||||
* m.render(document.body, fragment.render());
|
||||
*
|
||||
* @final
|
||||
*/
|
||||
public render(): Mithril.Vnode<Mithril.Attributes, this> {
|
||||
const vdom = this.view();
|
||||
|
||||
vdom.attrs = vdom.attrs || {};
|
||||
|
||||
const originalOnCreate = vdom.attrs.oncreate;
|
||||
|
||||
vdom.attrs.oncreate = (vnode) => {
|
||||
this.element = vnode.dom;
|
||||
if (originalOnCreate) originalOnCreate.apply(this, [vnode]);
|
||||
};
|
||||
|
||||
return vdom;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a view out of virtual elements.
|
||||
*/
|
||||
abstract view(): Mithril.Vnode<Mithril.Attributes, this>;
|
||||
}
|
@@ -88,7 +88,7 @@ export default class Model {
|
||||
// relationship data object.
|
||||
for (const innerKey in data[key]) {
|
||||
if (data[key][innerKey] instanceof Model) {
|
||||
data[key][innerKey] = { data: Model.getIdentifier(data[key][innerKey]) };
|
||||
data[key][innerKey] = {data: Model.getIdentifier(data[key][innerKey])};
|
||||
}
|
||||
this.data[key][innerKey] = data[key][innerKey];
|
||||
}
|
||||
@@ -109,7 +109,7 @@ export default class Model {
|
||||
* @public
|
||||
*/
|
||||
pushAttributes(attributes) {
|
||||
this.pushData({ attributes });
|
||||
this.pushData({attributes});
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -125,7 +125,7 @@ export default class Model {
|
||||
const data = {
|
||||
type: this.data.type,
|
||||
id: this.data.id,
|
||||
attributes,
|
||||
attributes
|
||||
};
|
||||
|
||||
// If a 'relationships' key exists, extract it from the attributes hash and
|
||||
@@ -138,7 +138,9 @@ export default class Model {
|
||||
const model = attributes.relationships[key];
|
||||
|
||||
data.relationships[key] = {
|
||||
data: model instanceof Array ? model.map(Model.getIdentifier) : Model.getIdentifier(model),
|
||||
data: model instanceof Array
|
||||
? model.map(Model.getIdentifier)
|
||||
: Model.getIdentifier(model)
|
||||
};
|
||||
}
|
||||
|
||||
@@ -152,66 +154,52 @@ export default class Model {
|
||||
|
||||
this.pushData(data);
|
||||
|
||||
const request = { 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);
|
||||
},
|
||||
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.redraw();
|
||||
throw response;
|
||||
}
|
||||
);
|
||||
// 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} body Data to send along with the DELETE request.
|
||||
* @param {Object} data Data to send along with the DELETE request.
|
||||
* @param {Object} [options]
|
||||
* @return {Promise}
|
||||
* @public
|
||||
*/
|
||||
delete(body, options = {}) {
|
||||
if (!this.exists) return Promise.resolve();
|
||||
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(),
|
||||
body,
|
||||
},
|
||||
options
|
||||
)
|
||||
)
|
||||
.then(() => {
|
||||
this.exists = false;
|
||||
this.store.remove(this);
|
||||
});
|
||||
return app.request(Object.assign({
|
||||
method: 'DELETE',
|
||||
url: app.forum.attribute('apiUrl') + this.apiEndpoint(),
|
||||
data
|
||||
}, options)).then(() => {
|
||||
this.exists = false;
|
||||
this.store.remove(this);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -237,7 +225,7 @@ export default class Model {
|
||||
* @public
|
||||
*/
|
||||
static attribute(name, transform) {
|
||||
return function () {
|
||||
return function() {
|
||||
const value = this.data.attributes && this.data.attributes[name];
|
||||
|
||||
return transform ? transform(value) : value;
|
||||
@@ -255,7 +243,7 @@ export default class Model {
|
||||
* @public
|
||||
*/
|
||||
static hasOne(name) {
|
||||
return function () {
|
||||
return function() {
|
||||
if (this.data.relationships) {
|
||||
const relationship = this.data.relationships[name];
|
||||
|
||||
@@ -279,12 +267,12 @@ export default class Model {
|
||||
* @public
|
||||
*/
|
||||
static hasMany(name) {
|
||||
return function () {
|
||||
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 relationship.data.map(data => app.store.getById(data.type, data.id));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -313,7 +301,7 @@ export default class Model {
|
||||
static getIdentifier(model) {
|
||||
return {
|
||||
type: model.data.type,
|
||||
id: model.data.id,
|
||||
id: model.data.id
|
||||
};
|
||||
}
|
||||
}
|
||||
|
@@ -30,17 +30,12 @@ export default class Session {
|
||||
* @return {Promise}
|
||||
* @public
|
||||
*/
|
||||
login(body, options = {}) {
|
||||
return app.request(
|
||||
Object.assign(
|
||||
{
|
||||
method: 'POST',
|
||||
url: `${app.forum.attribute('baseUrl')}/login`,
|
||||
body,
|
||||
},
|
||||
options
|
||||
)
|
||||
);
|
||||
login(data, options = {}) {
|
||||
return app.request(Object.assign({
|
||||
method: 'POST',
|
||||
url: app.forum.attribute('baseUrl') + '/login',
|
||||
data
|
||||
}, options));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -49,6 +44,6 @@ export default class Session {
|
||||
* @public
|
||||
*/
|
||||
logout() {
|
||||
window.location = `${app.forum.attribute('baseUrl')}/logout?token=${this.csrfToken}`;
|
||||
window.location = app.forum.attribute('baseUrl') + '/logout?token=' + this.csrfToken;
|
||||
}
|
||||
}
|
||||
|
@@ -34,7 +34,9 @@ export default class Store {
|
||||
pushPayload(payload) {
|
||||
if (payload.included) payload.included.map(this.pushObject.bind(this));
|
||||
|
||||
const result = payload.data instanceof Array ? payload.data.map(this.pushObject.bind(this)) : this.pushObject(payload.data);
|
||||
const result = payload.data instanceof Array
|
||||
? payload.data.map(this.pushObject.bind(this))
|
||||
: this.pushObject(payload.data);
|
||||
|
||||
// Attach the original payload to the model that we give back. This is
|
||||
// useful to consumers as it allows them to access meta information
|
||||
@@ -56,7 +58,7 @@ export default class Store {
|
||||
pushObject(data) {
|
||||
if (!this.models[data.type]) return null;
|
||||
|
||||
const type = (this.data[data.type] = this.data[data.type] || {});
|
||||
const type = this.data[data.type] = this.data[data.type] || {};
|
||||
|
||||
if (type[data.id]) {
|
||||
type[data.id].pushData(data);
|
||||
@@ -82,29 +84,22 @@ export default class Store {
|
||||
* @public
|
||||
*/
|
||||
find(type, id, query = {}, options = {}) {
|
||||
let params = query;
|
||||
let data = query;
|
||||
let url = app.forum.attribute('apiUrl') + '/' + type;
|
||||
|
||||
if (id instanceof Array) {
|
||||
url += '?filter[id]=' + id.join(',');
|
||||
} else if (typeof id === 'object') {
|
||||
params = id;
|
||||
data = id;
|
||||
} else if (id) {
|
||||
url += '/' + id;
|
||||
}
|
||||
|
||||
return app
|
||||
.request(
|
||||
Object.assign(
|
||||
{
|
||||
method: 'GET',
|
||||
url,
|
||||
params,
|
||||
},
|
||||
options
|
||||
)
|
||||
)
|
||||
.then(this.pushPayload.bind(this));
|
||||
return app.request(Object.assign({
|
||||
method: 'GET',
|
||||
url,
|
||||
data
|
||||
}, options)).then(this.pushPayload.bind(this));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -129,7 +124,7 @@ export default class Store {
|
||||
* @public
|
||||
*/
|
||||
getBy(type, key, value) {
|
||||
return this.all(type).filter((model) => model[key]() === value)[0];
|
||||
return this.all(type).filter(model => model[key]() === value)[0];
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -142,7 +137,7 @@ export default class Store {
|
||||
all(type) {
|
||||
const records = this.data[type];
|
||||
|
||||
return records ? Object.keys(records).map((id) => records[id]) : [];
|
||||
return records ? Object.keys(records).map(id => records[id]) : [];
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -165,6 +160,6 @@ export default class Store {
|
||||
createRecord(type, data = {}) {
|
||||
data.type = data.type || type;
|
||||
|
||||
return new this.models[type](data, this);
|
||||
return new (this.models[type])(data, this);
|
||||
}
|
||||
}
|
||||
|
@@ -1,3 +1,4 @@
|
||||
import User from './models/User';
|
||||
import username from './helpers/username';
|
||||
import extract from './utils/extract';
|
||||
|
||||
@@ -66,43 +67,27 @@ export default class Translator {
|
||||
const hydrated = [];
|
||||
const open = [hydrated];
|
||||
|
||||
translation.forEach((part) => {
|
||||
translation.forEach(part => {
|
||||
const match = part.match(new RegExp('{([a-z0-9_]+)}|<(/?)([a-z0-9_]+)>', 'i'));
|
||||
|
||||
if (match) {
|
||||
// Either an opening or closing tag.
|
||||
if (match[1]) {
|
||||
open[0].push(input[match[1]]);
|
||||
} else if (match[3]) {
|
||||
if (match[2]) {
|
||||
// Closing tag. We start by removing all raw children (generally in the form of strings) from the temporary
|
||||
// holding array, then run them through m.fragment to convert them to vnodes. Usually this will just give us a
|
||||
// text vnode, but using m.fragment as opposed to an explicit conversion should be more flexible. This is necessary because
|
||||
// otherwise, our generated vnode will have raw strings as its children, and mithril expects vnodes.
|
||||
// Finally, we add the now-processed vnodes back onto the holding array (which is the same object in memory as the
|
||||
// children array of the vnode we are currently processing), and remove the reference to the holding array so that
|
||||
// further text will be added to the full set of returned elements.
|
||||
const rawChildren = open[0].splice(0, open[0].length);
|
||||
open[0].push(...m.fragment(rawChildren).children);
|
||||
open.shift();
|
||||
} else {
|
||||
// If a vnode with a matching tag was provided in the translator input, we use that. Otherwise, we create a new vnode
|
||||
// with this tag, and an empty children array (since we're expecting to insert children, as that's the point of having this in translator)
|
||||
let tag = input[match[3]] || { tag: match[3], children: [] };
|
||||
let tag = input[match[3]] || {tag: match[3], children: []};
|
||||
open[0].push(tag);
|
||||
// Insert the tag's children array as the first element of open, so that text in between the opening
|
||||
// and closing tags will be added to the tag's children, not to the full set of returned elements.
|
||||
open.unshift(tag.children || tag);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Not an html tag, we add it to open[0], which is either the full set of returned elements (vnodes and text),
|
||||
// or if an html tag is currently being processed, the children attribute of that html tag's vnode.
|
||||
open[0].push(part);
|
||||
}
|
||||
});
|
||||
|
||||
return hydrated.filter((part) => part);
|
||||
return hydrated.filter(part => part);
|
||||
}
|
||||
|
||||
pluralize(translation, number) {
|
||||
@@ -112,7 +97,7 @@ export default class Translator {
|
||||
standardRules = [],
|
||||
explicitRules = [];
|
||||
|
||||
translation.split('|').forEach((part) => {
|
||||
translation.split('|').forEach(part => {
|
||||
if (cPluralRegex.test(part)) {
|
||||
const matches = part.match(cPluralRegex);
|
||||
explicitRules[matches[0]] = matches[matches.length - 1];
|
||||
@@ -137,13 +122,11 @@ export default class Translator {
|
||||
}
|
||||
}
|
||||
} else {
|
||||
var leftNumber = this.convertNumber(matches[4]);
|
||||
var leftNumber = this.convertNumber(matches[4]);
|
||||
var rightNumber = this.convertNumber(matches[5]);
|
||||
|
||||
if (
|
||||
('[' === matches[3] ? number >= leftNumber : number > leftNumber) &&
|
||||
(']' === matches[6] ? number <= rightNumber : number < rightNumber)
|
||||
) {
|
||||
if (('[' === matches[3] ? number >= leftNumber : number > leftNumber) &&
|
||||
(']' === matches[6] ? number <= rightNumber : number < rightNumber)) {
|
||||
return explicitRules[e];
|
||||
}
|
||||
}
|
||||
@@ -240,7 +223,7 @@ export default class Translator {
|
||||
case 'tr':
|
||||
case 'ur':
|
||||
case 'zu':
|
||||
return number == 1 ? 0 : 1;
|
||||
return (number == 1) ? 0 : 1;
|
||||
|
||||
case 'am':
|
||||
case 'bh':
|
||||
@@ -254,7 +237,7 @@ export default class Translator {
|
||||
case 'xbr':
|
||||
case 'ti':
|
||||
case 'wa':
|
||||
return number === 0 || number == 1 ? 0 : 1;
|
||||
return ((number === 0) || (number == 1)) ? 0 : 1;
|
||||
|
||||
case 'be':
|
||||
case 'bs':
|
||||
@@ -262,41 +245,41 @@ export default class Translator {
|
||||
case 'ru':
|
||||
case 'sr':
|
||||
case 'uk':
|
||||
return number % 10 == 1 && number % 100 != 11 ? 0 : number % 10 >= 2 && number % 10 <= 4 && (number % 100 < 10 || number % 100 >= 20) ? 1 : 2;
|
||||
return ((number % 10 == 1) && (number % 100 != 11)) ? 0 : (((number % 10 >= 2) && (number % 10 <= 4) && ((number % 100 < 10) || (number % 100 >= 20))) ? 1 : 2);
|
||||
|
||||
case 'cs':
|
||||
case 'sk':
|
||||
return number == 1 ? 0 : number >= 2 && number <= 4 ? 1 : 2;
|
||||
return (number == 1) ? 0 : (((number >= 2) && (number <= 4)) ? 1 : 2);
|
||||
|
||||
case 'ga':
|
||||
return number == 1 ? 0 : number == 2 ? 1 : 2;
|
||||
return (number == 1) ? 0 : ((number == 2) ? 1 : 2);
|
||||
|
||||
case 'lt':
|
||||
return number % 10 == 1 && number % 100 != 11 ? 0 : number % 10 >= 2 && (number % 100 < 10 || number % 100 >= 20) ? 1 : 2;
|
||||
return ((number % 10 == 1) && (number % 100 != 11)) ? 0 : (((number % 10 >= 2) && ((number % 100 < 10) || (number % 100 >= 20))) ? 1 : 2);
|
||||
|
||||
case 'sl':
|
||||
return number % 100 == 1 ? 0 : number % 100 == 2 ? 1 : number % 100 == 3 || number % 100 == 4 ? 2 : 3;
|
||||
return (number % 100 == 1) ? 0 : ((number % 100 == 2) ? 1 : (((number % 100 == 3) || (number % 100 == 4)) ? 2 : 3));
|
||||
|
||||
case 'mk':
|
||||
return number % 10 == 1 ? 0 : 1;
|
||||
return (number % 10 == 1) ? 0 : 1;
|
||||
|
||||
case 'mt':
|
||||
return number == 1 ? 0 : number === 0 || (number % 100 > 1 && number % 100 < 11) ? 1 : number % 100 > 10 && number % 100 < 20 ? 2 : 3;
|
||||
return (number == 1) ? 0 : (((number === 0) || ((number % 100 > 1) && (number % 100 < 11))) ? 1 : (((number % 100 > 10) && (number % 100 < 20)) ? 2 : 3));
|
||||
|
||||
case 'lv':
|
||||
return number === 0 ? 0 : number % 10 == 1 && number % 100 != 11 ? 1 : 2;
|
||||
return (number === 0) ? 0 : (((number % 10 == 1) && (number % 100 != 11)) ? 1 : 2);
|
||||
|
||||
case 'pl':
|
||||
return number == 1 ? 0 : number % 10 >= 2 && number % 10 <= 4 && (number % 100 < 12 || number % 100 > 14) ? 1 : 2;
|
||||
return (number == 1) ? 0 : (((number % 10 >= 2) && (number % 10 <= 4) && ((number % 100 < 12) || (number % 100 > 14))) ? 1 : 2);
|
||||
|
||||
case 'cy':
|
||||
return number == 1 ? 0 : number == 2 ? 1 : number == 8 || number == 11 ? 2 : 3;
|
||||
return (number == 1) ? 0 : ((number == 2) ? 1 : (((number == 8) || (number == 11)) ? 2 : 3));
|
||||
|
||||
case 'ro':
|
||||
return number == 1 ? 0 : number === 0 || (number % 100 > 0 && number % 100 < 20) ? 1 : 2;
|
||||
return (number == 1) ? 0 : (((number === 0) || ((number % 100 > 0) && (number % 100 < 20))) ? 1 : 2);
|
||||
|
||||
case 'ar':
|
||||
return number === 0 ? 0 : number == 1 ? 1 : number == 2 ? 2 : number >= 3 && number <= 10 ? 3 : number >= 11 && number <= 99 ? 4 : 5;
|
||||
return (number === 0) ? 0 : ((number == 1) ? 1 : ((number == 2) ? 2 : (((number >= 3) && (number <= 10)) ? 3 : (((number >= 11) && (number <= 99)) ? 4 : 5))));
|
||||
|
||||
default:
|
||||
return 0;
|
||||
|
@@ -12,20 +12,15 @@ import anchorScroll from './utils/anchorScroll';
|
||||
import RequestError from './utils/RequestError';
|
||||
import abbreviateNumber from './utils/abbreviateNumber';
|
||||
import * as string from './utils/string';
|
||||
import Stream from './utils/Stream';
|
||||
import SubtreeRetainer from './utils/SubtreeRetainer';
|
||||
import setRouteWithForcedRefresh from './utils/setRouteWithForcedRefresh';
|
||||
import extract from './utils/extract';
|
||||
import ScrollListener from './utils/ScrollListener';
|
||||
import stringToColor from './utils/stringToColor';
|
||||
import subclassOf from './utils/subclassOf';
|
||||
import patchMithril from './utils/patchMithril';
|
||||
import proxifyCompat from './utils/proxifyCompat';
|
||||
import classList from './utils/classList';
|
||||
import extractText from './utils/extractText';
|
||||
import formatNumber from './utils/formatNumber';
|
||||
import mapRoutes from './utils/mapRoutes';
|
||||
import withAttr from './utils/withAttr';
|
||||
import Notification from './models/Notification';
|
||||
import User from './models/User';
|
||||
import Post from './models/Post';
|
||||
@@ -35,7 +30,6 @@ import Forum from './models/Forum';
|
||||
import Component from './Component';
|
||||
import Translator from './Translator';
|
||||
import AlertManager from './components/AlertManager';
|
||||
import Page from './components/Page';
|
||||
import Switch from './components/Switch';
|
||||
import Badge from './components/Badge';
|
||||
import LoadingIndicator from './components/LoadingIndicator';
|
||||
@@ -48,7 +42,6 @@ import FieldSet from './components/FieldSet';
|
||||
import Select from './components/Select';
|
||||
import Navigation from './components/Navigation';
|
||||
import Alert from './components/Alert';
|
||||
import Link from './components/Link';
|
||||
import LinkButton from './components/LinkButton';
|
||||
import Checkbox from './components/Checkbox';
|
||||
import SelectDropdown from './components/SelectDropdown';
|
||||
@@ -67,13 +60,11 @@ import highlight from './helpers/highlight';
|
||||
import username from './helpers/username';
|
||||
import userOnline from './helpers/userOnline';
|
||||
import listItems from './helpers/listItems';
|
||||
import Fragment from './Fragment';
|
||||
import DefaultResolver from './resolvers/DefaultResolver';
|
||||
|
||||
export default {
|
||||
extend: extend,
|
||||
Session: Session,
|
||||
Store: Store,
|
||||
'extend': extend,
|
||||
'Session': Session,
|
||||
'Store': Store,
|
||||
'utils/evented': evented,
|
||||
'utils/liveHumanTimes': liveHumanTimes,
|
||||
'utils/ItemList': ItemList,
|
||||
@@ -89,27 +80,20 @@ export default {
|
||||
'utils/extract': extract,
|
||||
'utils/ScrollListener': ScrollListener,
|
||||
'utils/stringToColor': stringToColor,
|
||||
'utils/Stream': Stream,
|
||||
'utils/subclassOf': subclassOf,
|
||||
'utils/setRouteWithForcedRefresh': setRouteWithForcedRefresh,
|
||||
'utils/patchMithril': patchMithril,
|
||||
'utils/proxifyCompat': proxifyCompat,
|
||||
'utils/classList': classList,
|
||||
'utils/extractText': extractText,
|
||||
'utils/formatNumber': formatNumber,
|
||||
'utils/mapRoutes': mapRoutes,
|
||||
'utils/withAttr': withAttr,
|
||||
'models/Notification': Notification,
|
||||
'models/User': User,
|
||||
'models/Post': Post,
|
||||
'models/Discussion': Discussion,
|
||||
'models/Group': Group,
|
||||
'models/Forum': Forum,
|
||||
Component: Component,
|
||||
Fragment: Fragment,
|
||||
Translator: Translator,
|
||||
'Component': Component,
|
||||
'Translator': Translator,
|
||||
'components/AlertManager': AlertManager,
|
||||
'components/Page': Page,
|
||||
'components/Switch': Switch,
|
||||
'components/Badge': Badge,
|
||||
'components/LoadingIndicator': LoadingIndicator,
|
||||
@@ -122,7 +106,6 @@ export default {
|
||||
'components/Select': Select,
|
||||
'components/Navigation': Navigation,
|
||||
'components/Alert': Alert,
|
||||
'components/Link': Link,
|
||||
'components/LinkButton': LinkButton,
|
||||
'components/Checkbox': Checkbox,
|
||||
'components/SelectDropdown': SelectDropdown,
|
||||
@@ -130,8 +113,8 @@ export default {
|
||||
'components/Button': Button,
|
||||
'components/Modal': Modal,
|
||||
'components/GroupBadge': GroupBadge,
|
||||
Model: Model,
|
||||
Application: Application,
|
||||
'Model': Model,
|
||||
'Application': Application,
|
||||
'helpers/fullTime': fullTime,
|
||||
'helpers/avatar': avatar,
|
||||
'helpers/icon': icon,
|
||||
@@ -140,6 +123,5 @@ export default {
|
||||
'helpers/highlight': highlight,
|
||||
'helpers/username': username,
|
||||
'helpers/userOnline': userOnline,
|
||||
'helpers/listItems': listItems,
|
||||
'resolvers/DefaultResolver': DefaultResolver,
|
||||
'helpers/listItems': listItems
|
||||
};
|
||||
|
57
js/src/common/components/Alert.js
Normal file
57
js/src/common/components/Alert.js
Normal file
@@ -0,0 +1,57 @@
|
||||
import Component from '../Component';
|
||||
import Button from './Button';
|
||||
import listItems from '../helpers/listItems';
|
||||
import extract from '../utils/extract';
|
||||
|
||||
/**
|
||||
* The `Alert` component represents an alert box, which contains a message,
|
||||
* some controls, and may be dismissible.
|
||||
*
|
||||
* The alert may have the following special props:
|
||||
*
|
||||
* - `type` The type of alert this is. Will be used to give the alert a class
|
||||
* name of `Alert--{type}`.
|
||||
* - `controls` An array of controls to show in the alert.
|
||||
* - `dismissible` Whether or not the alert can be dismissed.
|
||||
* - `ondismiss` A callback to run when the alert is dismissed.
|
||||
*
|
||||
* All other props will be assigned as attributes on the alert element.
|
||||
*/
|
||||
export default class Alert extends Component {
|
||||
view() {
|
||||
const attrs = Object.assign({}, this.props);
|
||||
|
||||
const type = extract(attrs, 'type');
|
||||
attrs.className = 'Alert Alert--' + type + ' ' + (attrs.className || '');
|
||||
|
||||
const children = extract(attrs, 'children');
|
||||
const controls = extract(attrs, 'controls') || [];
|
||||
|
||||
// If the alert is meant to be dismissible (which is the case by default),
|
||||
// then we will create a dismiss button to append as the final control in
|
||||
// the alert.
|
||||
const dismissible = extract(attrs, 'dismissible');
|
||||
const ondismiss = extract(attrs, 'ondismiss');
|
||||
const dismissControl = [];
|
||||
|
||||
if (dismissible || dismissible === undefined) {
|
||||
dismissControl.push(
|
||||
<Button
|
||||
icon="fas fa-times"
|
||||
className="Button Button--link Button--icon Alert-dismiss"
|
||||
onclick={ondismiss}/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div {...attrs}>
|
||||
<span className="Alert-body">
|
||||
{children}
|
||||
</span>
|
||||
<ul className="Alert-controls">
|
||||
{listItems(controls.concat(dismissControl))}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
@@ -1,50 +0,0 @@
|
||||
import Component, { ComponentAttrs } from '../Component';
|
||||
import Button from './Button';
|
||||
import listItems from '../helpers/listItems';
|
||||
import extract from '../utils/extract';
|
||||
import Mithril from 'mithril';
|
||||
|
||||
export interface AlertAttrs extends ComponentAttrs {
|
||||
/** The type of alert this is. Will be used to give the alert a class name of `Alert--{type}`. */
|
||||
type?: string;
|
||||
/** An array of controls to show in the alert. */
|
||||
controls?: Mithril.Children;
|
||||
/** Whether or not the alert can be dismissed. */
|
||||
dismissible?: boolean;
|
||||
/** A callback to run when the alert is dismissed */
|
||||
ondismiss?: Function;
|
||||
}
|
||||
|
||||
/**
|
||||
* The `Alert` component represents an alert box, which contains a message,
|
||||
* some controls, and may be dismissible.
|
||||
*/
|
||||
export default class Alert<T extends AlertAttrs = AlertAttrs> extends Component<T> {
|
||||
view(vnode: Mithril.Vnode) {
|
||||
const attrs = Object.assign({}, this.attrs);
|
||||
|
||||
const type = extract(attrs, 'type');
|
||||
attrs.className = 'Alert Alert--' + type + ' ' + (attrs.className || '');
|
||||
|
||||
const content = extract(attrs, 'content') || vnode.children;
|
||||
const controls = (extract(attrs, 'controls') || []) as Mithril.ChildArray;
|
||||
|
||||
// If the alert is meant to be dismissible (which is the case by default),
|
||||
// then we will create a dismiss button to append as the final control in
|
||||
// the alert.
|
||||
const dismissible = extract(attrs, 'dismissible');
|
||||
const ondismiss = extract(attrs, 'ondismiss');
|
||||
const dismissControl = [];
|
||||
|
||||
if (dismissible || dismissible === undefined) {
|
||||
dismissControl.push(<Button icon="fas fa-times" className="Button Button--link Button--icon Alert-dismiss" onclick={ondismiss} />);
|
||||
}
|
||||
|
||||
return (
|
||||
<div {...attrs}>
|
||||
<span className="Alert-body">{content}</span>
|
||||
<ul className="Alert-controls">{listItems(controls.concat(dismissControl))}</ul>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
@@ -6,23 +6,70 @@ import Alert from './Alert';
|
||||
* be shown and dismissed.
|
||||
*/
|
||||
export default class AlertManager extends Component {
|
||||
oninit(vnode) {
|
||||
super.oninit(vnode);
|
||||
|
||||
this.state = this.attrs.state;
|
||||
init() {
|
||||
/**
|
||||
* An array of Alert components which are currently showing.
|
||||
*
|
||||
* @type {Alert[]}
|
||||
* @protected
|
||||
*/
|
||||
this.components = [];
|
||||
}
|
||||
|
||||
view() {
|
||||
return (
|
||||
<div className="AlertManager">
|
||||
{Object.entries(this.state.getActiveAlerts()).map(([key, alert]) => (
|
||||
<div className="AlertManager-alert">
|
||||
<alert.componentClass {...alert.attrs} ondismiss={this.state.dismiss.bind(this.state, key)}>
|
||||
{alert.children}
|
||||
</alert.componentClass>
|
||||
</div>
|
||||
))}
|
||||
{this.components.map(component => <div className="AlertManager-alert">{component}</div>)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
config(isInitialized, context) {
|
||||
// Since this component is 'above' the content of the page (that is, it is a
|
||||
// part of the global UI that persists between routes), we will flag the DOM
|
||||
// to be retained across route changes.
|
||||
context.retain = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Show an Alert in the alerts area.
|
||||
*
|
||||
* @param {Alert} component
|
||||
* @public
|
||||
*/
|
||||
show(component) {
|
||||
if (!(component instanceof Alert)) {
|
||||
throw new Error('The AlertManager component can only show Alert components');
|
||||
}
|
||||
|
||||
component.props.ondismiss = this.dismiss.bind(this, component);
|
||||
|
||||
this.components.push(component);
|
||||
m.redraw();
|
||||
}
|
||||
|
||||
/**
|
||||
* Dismiss an alert.
|
||||
*
|
||||
* @param {Alert} component
|
||||
* @public
|
||||
*/
|
||||
dismiss(component) {
|
||||
const index = this.components.indexOf(component);
|
||||
|
||||
if (index !== -1) {
|
||||
this.components.splice(index, 1);
|
||||
m.redraw();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all alerts.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
clear() {
|
||||
this.components = [];
|
||||
m.redraw();
|
||||
}
|
||||
}
|
||||
|
@@ -6,30 +6,34 @@ import extract from '../utils/extract';
|
||||
* The `Badge` component represents a user/discussion badge, indicating some
|
||||
* status (e.g. a discussion is stickied, a user is an admin).
|
||||
*
|
||||
* A badge may have the following special attrs:
|
||||
* A badge may have the following special props:
|
||||
*
|
||||
* - `type` The type of badge this is. This will be used to give the badge a
|
||||
* class name of `Badge--{type}`.
|
||||
* - `icon` The name of an icon to show inside the badge.
|
||||
* - `label`
|
||||
*
|
||||
* All other attrs will be assigned as attributes on the badge element.
|
||||
* All other props will be assigned as attributes on the badge element.
|
||||
*/
|
||||
export default class Badge extends Component {
|
||||
view() {
|
||||
const attrs = Object.assign({}, this.attrs);
|
||||
const attrs = Object.assign({}, this.props);
|
||||
const type = extract(attrs, 'type');
|
||||
const iconName = extract(attrs, 'icon');
|
||||
|
||||
attrs.className = 'Badge ' + (type ? 'Badge--' + type : '') + ' ' + (attrs.className || '');
|
||||
attrs.title = extract(attrs, 'label') || '';
|
||||
|
||||
return <span {...attrs}>{iconName ? icon(iconName, { className: 'Badge-icon' }) : m.trust(' ')}</span>;
|
||||
return (
|
||||
<span {...attrs}>
|
||||
{iconName ? icon(iconName, {className: 'Badge-icon'}) : m.trust(' ')}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
oncreate(vnode) {
|
||||
super.oncreate(vnode);
|
||||
config(isInitialized) {
|
||||
if (isInitialized) return;
|
||||
|
||||
if (this.attrs.label) this.$().tooltip();
|
||||
if (this.props.label) this.$().tooltip({container: 'body'});
|
||||
}
|
||||
}
|
||||
|
@@ -1,15 +1,12 @@
|
||||
import Component from '../Component';
|
||||
import icon from '../helpers/icon';
|
||||
import classList from '../utils/classList';
|
||||
import extract from '../utils/extract';
|
||||
import extractText from '../utils/extractText';
|
||||
import LoadingIndicator from './LoadingIndicator';
|
||||
|
||||
/**
|
||||
* The `Button` component defines an element which, when clicked, performs an
|
||||
* action.
|
||||
*
|
||||
* ### Attrs
|
||||
* action. The button may have the following special props:
|
||||
*
|
||||
* - `icon` The name of the icon class. If specified, the button will be given a
|
||||
* 'has-icon' class name.
|
||||
@@ -18,43 +15,41 @@ import LoadingIndicator from './LoadingIndicator';
|
||||
* removed.
|
||||
* - `loading` Whether or not the button should be in a disabled loading state.
|
||||
*
|
||||
* All other attrs will be assigned as attributes on the button element.
|
||||
* All other props will be assigned as attributes on the button element.
|
||||
*
|
||||
* Note that a Button has no default class names. This is because a Button can
|
||||
* be used to represent any generic clickable control, like a menu item.
|
||||
*/
|
||||
export default class Button extends Component {
|
||||
view(vnode) {
|
||||
const attrs = Object.assign({}, this.attrs);
|
||||
view() {
|
||||
const attrs = Object.assign({}, this.props);
|
||||
|
||||
delete attrs.children;
|
||||
|
||||
attrs.className = attrs.className || '';
|
||||
attrs.type = attrs.type || 'button';
|
||||
|
||||
// If a tooltip was provided for buttons without additional content, we also
|
||||
// use this tooltip as text for screen readers
|
||||
if (attrs.title && !vnode.children) {
|
||||
if (attrs.title && !this.props.children) {
|
||||
attrs['aria-label'] = attrs.title;
|
||||
}
|
||||
|
||||
// If given a translation object, extract the text.
|
||||
if (typeof attrs.title === 'object') {
|
||||
attrs.title = extractText(attrs.title);
|
||||
}
|
||||
|
||||
// If nothing else is provided, we use the textual button content as tooltip
|
||||
if (!attrs.title && vnode.children) {
|
||||
attrs.title = extractText(vnode.children);
|
||||
if (!attrs.title && this.props.children) {
|
||||
attrs.title = extractText(this.props.children);
|
||||
}
|
||||
|
||||
const iconName = extract(attrs, 'icon');
|
||||
if (iconName) attrs.className += ' hasIcon';
|
||||
|
||||
const loading = extract(attrs, 'loading');
|
||||
if (attrs.disabled || loading) {
|
||||
attrs.className += ' disabled' + (loading ? ' loading' : '');
|
||||
delete attrs.onclick;
|
||||
}
|
||||
|
||||
attrs.className = classList([attrs.className, iconName && 'hasIcon', (attrs.disabled || loading) && 'disabled', loading && 'loading']);
|
||||
|
||||
return <button {...attrs}>{this.getButtonContent(vnode.children)}</button>;
|
||||
return <button {...attrs}>{this.getButtonContent()}</button>;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -63,13 +58,13 @@ export default class Button extends Component {
|
||||
* @return {*}
|
||||
* @protected
|
||||
*/
|
||||
getButtonContent(children) {
|
||||
const iconName = this.attrs.icon;
|
||||
getButtonContent() {
|
||||
const iconName = this.props.icon;
|
||||
|
||||
return [
|
||||
iconName && iconName !== true ? icon(iconName, { className: 'Button-icon' }) : '',
|
||||
children ? <span className="Button-label">{children}</span> : '',
|
||||
this.attrs.loading ? <LoadingIndicator size="tiny" className="LoadingIndicator--inline" /> : '',
|
||||
iconName && iconName !== true ? icon(iconName, {className: 'Button-icon'}) : '',
|
||||
this.props.children ? <span className="Button-label">{this.props.children}</span> : '',
|
||||
this.props.loading ? LoadingIndicator.component({size: 'tiny', className: 'LoadingIndicator--inline'}) : ''
|
||||
];
|
||||
}
|
||||
}
|
||||
|
@@ -1,40 +1,44 @@
|
||||
import Component from '../Component';
|
||||
import LoadingIndicator from './LoadingIndicator';
|
||||
import icon from '../helpers/icon';
|
||||
import classList from '../utils/classList';
|
||||
import withAttr from '../utils/withAttr';
|
||||
|
||||
/**
|
||||
* The `Checkbox` component defines a checkbox input.
|
||||
*
|
||||
* ### Attrs
|
||||
* ### Props
|
||||
*
|
||||
* - `state` Whether or not the checkbox is checked.
|
||||
* - `className` The class name for the root element.
|
||||
* - `disabled` Whether or not the checkbox is disabled.
|
||||
* - `loading` Whether or not the checkbox is loading.
|
||||
* - `onchange` A callback to run when the checkbox is checked/unchecked.
|
||||
* - `children` A text label to display next to the checkbox.
|
||||
*/
|
||||
export default class Checkbox extends Component {
|
||||
view(vnode) {
|
||||
// Sometimes, false is stored in the DB as '0'. This is a temporary
|
||||
// conversion layer until a more robust settings encoding is introduced
|
||||
if (this.attrs.state === '0') this.attrs.state = false;
|
||||
init() {
|
||||
/**
|
||||
* Whether or not the checkbox's value is in the process of being saved.
|
||||
*
|
||||
* @type {Boolean}
|
||||
* @public
|
||||
*/
|
||||
this.loading = false;
|
||||
}
|
||||
|
||||
const className = classList([
|
||||
'Checkbox',
|
||||
this.attrs.state ? 'on' : 'off',
|
||||
this.attrs.className,
|
||||
this.attrs.loading && 'loading',
|
||||
this.attrs.disabled && 'disabled',
|
||||
]);
|
||||
view() {
|
||||
let className = 'Checkbox ' + (this.props.state ? 'on' : 'off') + ' ' + (this.props.className || '');
|
||||
if (this.loading) className += ' loading';
|
||||
if (this.props.disabled) className += ' disabled';
|
||||
|
||||
return (
|
||||
<label className={className}>
|
||||
<input type="checkbox" checked={this.attrs.state} disabled={this.attrs.disabled} onchange={withAttr('checked', this.onchange.bind(this))} />
|
||||
<div className="Checkbox-display">{this.getDisplay()}</div>
|
||||
{vnode.children}
|
||||
<input type="checkbox"
|
||||
checked={this.props.state}
|
||||
disabled={this.props.disabled}
|
||||
onchange={m.withAttr('checked', this.onchange.bind(this))}/>
|
||||
<div className="Checkbox-display">
|
||||
{this.getDisplay()}
|
||||
</div>
|
||||
{this.props.children}
|
||||
</label>
|
||||
);
|
||||
}
|
||||
@@ -46,7 +50,9 @@ export default class Checkbox extends Component {
|
||||
* @protected
|
||||
*/
|
||||
getDisplay() {
|
||||
return this.attrs.loading ? <LoadingIndicator size="tiny" /> : icon(this.attrs.state ? 'fas fa-check' : 'fas fa-times');
|
||||
return this.loading
|
||||
? LoadingIndicator.component({size: 'tiny'})
|
||||
: icon(this.props.state ? 'fas fa-check' : 'fas fa-times');
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -56,6 +62,6 @@ export default class Checkbox extends Component {
|
||||
* @protected
|
||||
*/
|
||||
onchange(checked) {
|
||||
if (this.attrs.onchange) this.attrs.onchange(checked, this);
|
||||
if (this.props.onchange) this.props.onchange(checked, this);
|
||||
}
|
||||
}
|
||||
|
@@ -1,40 +0,0 @@
|
||||
import Component from '../Component';
|
||||
|
||||
/**
|
||||
* The `ConfirmDocumentUnload` component can be used to register a global
|
||||
* event handler that prevents closing the browser window/tab based on the
|
||||
* return value of a given callback prop.
|
||||
*
|
||||
* ### Attrs
|
||||
*
|
||||
* - `when` - a callback returning true when the browser should prompt for
|
||||
* confirmation before closing the window/tab
|
||||
*
|
||||
* ### Children
|
||||
*
|
||||
* NOTE: Only the first child will be rendered. (Use this component to wrap
|
||||
* another component / DOM element.)
|
||||
*
|
||||
*/
|
||||
export default class ConfirmDocumentUnload extends Component {
|
||||
handler() {
|
||||
return this.attrs.when() || undefined;
|
||||
}
|
||||
|
||||
oncreate(vnode) {
|
||||
super.oncreate(vnode);
|
||||
|
||||
this.boundHandler = this.handler.bind(this);
|
||||
$(window).on('beforeunload', this.boundHandler);
|
||||
}
|
||||
|
||||
onremove() {
|
||||
$(window).off('beforeunload', this.boundHandler);
|
||||
}
|
||||
|
||||
view(vnode) {
|
||||
// To avoid having to render another wrapping <div> here, we assume that
|
||||
// this component is only wrapped around a single element / component.
|
||||
return vnode.children[0];
|
||||
}
|
||||
}
|
@@ -6,7 +6,7 @@ import listItems from '../helpers/listItems';
|
||||
* The `Dropdown` component displays a button which, when clicked, shows a
|
||||
* dropdown menu beneath it.
|
||||
*
|
||||
* ### Attrs
|
||||
* ### Props
|
||||
*
|
||||
* - `buttonClassName` A class name to apply to the dropdown toggle button.
|
||||
* - `menuClassName` A class name to apply to the dropdown menu.
|
||||
@@ -19,33 +19,33 @@ import listItems from '../helpers/listItems';
|
||||
* The children will be displayed as a list inside of the dropdown menu.
|
||||
*/
|
||||
export default class Dropdown extends Component {
|
||||
static initAttrs(attrs) {
|
||||
attrs.className = attrs.className || '';
|
||||
attrs.buttonClassName = attrs.buttonClassName || '';
|
||||
attrs.menuClassName = attrs.menuClassName || '';
|
||||
attrs.label = attrs.label || '';
|
||||
attrs.caretIcon = typeof attrs.caretIcon !== 'undefined' ? attrs.caretIcon : 'fas fa-caret-down';
|
||||
static initProps(props) {
|
||||
super.initProps(props);
|
||||
|
||||
props.className = props.className || '';
|
||||
props.buttonClassName = props.buttonClassName || '';
|
||||
props.menuClassName = props.menuClassName || '';
|
||||
props.label = props.label || '';
|
||||
props.caretIcon = typeof props.caretIcon !== 'undefined' ? props.caretIcon : 'fas fa-caret-down';
|
||||
}
|
||||
|
||||
oninit(vnode) {
|
||||
super.oninit(vnode);
|
||||
|
||||
init() {
|
||||
this.showing = false;
|
||||
}
|
||||
|
||||
view(vnode) {
|
||||
const items = vnode.children ? listItems(vnode.children) : [];
|
||||
view() {
|
||||
const items = this.props.children ? listItems(this.props.children) : [];
|
||||
|
||||
return (
|
||||
<div className={'ButtonGroup Dropdown dropdown ' + this.attrs.className + ' itemCount' + items.length + (this.showing ? ' open' : '')}>
|
||||
{this.getButton(vnode.children)}
|
||||
<div className={'ButtonGroup Dropdown dropdown ' + this.props.className + ' itemCount' + items.length + (this.showing ? ' open' : '')}>
|
||||
{this.getButton()}
|
||||
{this.getMenu(items)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
oncreate(vnode) {
|
||||
super.oncreate(vnode);
|
||||
config(isInitialized) {
|
||||
if (isInitialized) return;
|
||||
|
||||
// When opening the dropdown menu, work out if the menu goes beyond the
|
||||
// bottom of the viewport. If it does, we will apply class to make it show
|
||||
@@ -53,8 +53,8 @@ export default class Dropdown extends Component {
|
||||
this.$().on('shown.bs.dropdown', () => {
|
||||
this.showing = true;
|
||||
|
||||
if (this.attrs.onshow) {
|
||||
this.attrs.onshow();
|
||||
if (this.props.onshow) {
|
||||
this.props.onshow();
|
||||
}
|
||||
|
||||
m.redraw();
|
||||
@@ -64,20 +64,26 @@ export default class Dropdown extends Component {
|
||||
|
||||
$menu.removeClass('Dropdown-menu--top Dropdown-menu--right');
|
||||
|
||||
$menu.toggleClass('Dropdown-menu--top', $menu.offset().top + $menu.height() > $(window).scrollTop() + $(window).height());
|
||||
$menu.toggleClass(
|
||||
'Dropdown-menu--top',
|
||||
$menu.offset().top + $menu.height() > $(window).scrollTop() + $(window).height()
|
||||
);
|
||||
|
||||
if ($menu.offset().top < 0) {
|
||||
$menu.removeClass('Dropdown-menu--top');
|
||||
}
|
||||
|
||||
$menu.toggleClass('Dropdown-menu--right', isRight || $menu.offset().left + $menu.width() > $(window).scrollLeft() + $(window).width());
|
||||
$menu.toggleClass(
|
||||
'Dropdown-menu--right',
|
||||
isRight || $menu.offset().left + $menu.width() > $(window).scrollLeft() + $(window).width()
|
||||
);
|
||||
});
|
||||
|
||||
this.$().on('hidden.bs.dropdown', () => {
|
||||
this.showing = false;
|
||||
|
||||
if (this.attrs.onhide) {
|
||||
this.attrs.onhide();
|
||||
if (this.props.onhide) {
|
||||
this.props.onhide();
|
||||
}
|
||||
|
||||
m.redraw();
|
||||
@@ -90,10 +96,13 @@ export default class Dropdown extends Component {
|
||||
* @return {*}
|
||||
* @protected
|
||||
*/
|
||||
getButton(children) {
|
||||
getButton() {
|
||||
return (
|
||||
<button className={'Dropdown-toggle ' + this.attrs.buttonClassName} data-toggle="dropdown" onclick={this.attrs.onclick}>
|
||||
{this.getButtonContent(children)}
|
||||
<button
|
||||
className={'Dropdown-toggle ' + this.props.buttonClassName}
|
||||
data-toggle="dropdown"
|
||||
onclick={this.props.onclick}>
|
||||
{this.getButtonContent()}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -104,15 +113,19 @@ export default class Dropdown extends Component {
|
||||
* @return {*}
|
||||
* @protected
|
||||
*/
|
||||
getButtonContent(children) {
|
||||
getButtonContent() {
|
||||
return [
|
||||
this.attrs.icon ? icon(this.attrs.icon, { className: 'Button-icon' }) : '',
|
||||
<span className="Button-label">{this.attrs.label}</span>,
|
||||
this.attrs.caretIcon ? icon(this.attrs.caretIcon, { className: 'Button-caret' }) : '',
|
||||
this.props.icon ? icon(this.props.icon, {className: 'Button-icon'}) : '',
|
||||
<span className="Button-label">{this.props.label}</span>,
|
||||
this.props.caretIcon ? icon(this.props.caretIcon, {className: 'Button-caret'}) : ''
|
||||
];
|
||||
}
|
||||
|
||||
getMenu(items) {
|
||||
return <ul className={'Dropdown-menu dropdown-menu ' + this.attrs.menuClassName}>{items}</ul>;
|
||||
return (
|
||||
<ul className={'Dropdown-menu dropdown-menu ' + this.props.menuClassName}>
|
||||
{items}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
@@ -11,11 +11,11 @@ import listItems from '../helpers/listItems';
|
||||
* The children should be an array of items to show in the fieldset.
|
||||
*/
|
||||
export default class FieldSet extends Component {
|
||||
view(vnode) {
|
||||
view() {
|
||||
return (
|
||||
<fieldset className={this.attrs.className}>
|
||||
<legend>{this.attrs.label}</legend>
|
||||
<ul>{listItems(vnode.children)}</ul>
|
||||
<fieldset className={this.props.className}>
|
||||
<legend>{this.props.label}</legend>
|
||||
<ul>{listItems(this.props.children)}</ul>
|
||||
</fieldset>
|
||||
);
|
||||
}
|
||||
|
@@ -1,16 +1,16 @@
|
||||
import Badge from './Badge';
|
||||
|
||||
export default class GroupBadge extends Badge {
|
||||
static initAttrs(attrs) {
|
||||
super.initAttrs(attrs);
|
||||
static initProps(props) {
|
||||
super.initProps(props);
|
||||
|
||||
if (attrs.group) {
|
||||
attrs.icon = attrs.group.icon();
|
||||
attrs.style = { backgroundColor: attrs.group.color() };
|
||||
attrs.label = typeof attrs.label === 'undefined' ? attrs.group.nameSingular() : attrs.label;
|
||||
attrs.type = 'group--' + attrs.group.id();
|
||||
if (props.group) {
|
||||
props.icon = props.group.icon();
|
||||
props.style = {backgroundColor: props.group.color()};
|
||||
props.label = typeof props.label === 'undefined' ? props.group.nameSingular() : props.label;
|
||||
props.type = 'group--' + props.group.id();
|
||||
|
||||
delete attrs.group;
|
||||
delete props.group;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@@ -1,47 +0,0 @@
|
||||
import Component from '../Component';
|
||||
import extract from '../utils/extract';
|
||||
|
||||
/**
|
||||
* The link component enables both internal and external links.
|
||||
* It will return a regular HTML link for any links to external sites,
|
||||
* and it will use Mithril's m.route.Link for any internal links.
|
||||
*
|
||||
* Links will default to internal; the 'external' attr must be set to
|
||||
* `true` for the link to be external.
|
||||
*/
|
||||
export default class Link extends Component {
|
||||
view(vnode) {
|
||||
let { options = {}, ...attrs } = vnode.attrs;
|
||||
|
||||
attrs.href = attrs.href || '';
|
||||
|
||||
// For some reason, m.route.Link does not like vnode.text, so if present, we
|
||||
// need to convert it to text vnodes and store it in children.
|
||||
const children = vnode.children || { tag: '#', children: vnode.text };
|
||||
|
||||
if (attrs.external) {
|
||||
return <a {...attrs}>{children}</a>;
|
||||
}
|
||||
|
||||
// If the href URL of the link is the same as the current page path
|
||||
// we will not add a new entry to the browser history.
|
||||
// This allows us to still refresh the Page component
|
||||
// without adding endless history entries.
|
||||
if (attrs.href === m.route.get()) {
|
||||
if (!('replace' in options)) options.replace = true;
|
||||
}
|
||||
|
||||
// Mithril 2 does not completely rerender the page if a route change leads to the same route
|
||||
// (or the same component handling a different route).
|
||||
// Here, the `force` parameter will use Mithril's key system to force a full rerender
|
||||
// see https://mithril.js.org/route.html#key-parameter
|
||||
if (extract(attrs, 'force')) {
|
||||
if (!('state' in options)) options.state = {};
|
||||
if (!('key' in options.state)) options.state.key = Date.now();
|
||||
}
|
||||
|
||||
attrs.options = options;
|
||||
|
||||
return <m.route.Link {...attrs}>{children}</m.route.Link>;
|
||||
}
|
||||
}
|
@@ -1,43 +1,40 @@
|
||||
import Button from './Button';
|
||||
import Link from './Link';
|
||||
|
||||
/**
|
||||
* The `LinkButton` component defines a `Button` which links to a route.
|
||||
*
|
||||
* ### Attrs
|
||||
* ### Props
|
||||
*
|
||||
* All of the attrs accepted by `Button`, plus:
|
||||
* All of the props accepted by `Button`, plus:
|
||||
*
|
||||
* - `active` Whether or not the page that this button links to is currently
|
||||
* active.
|
||||
* - `href` The URL to link to. If the current URL `m.route()` matches this,
|
||||
* the `active` prop will automatically be set to true.
|
||||
* - `force` Whether the page should be fully rerendered. Defaults to `true`.
|
||||
*/
|
||||
export default class LinkButton extends Button {
|
||||
static initAttrs(attrs) {
|
||||
super.initAttrs(attrs);
|
||||
|
||||
attrs.active = this.isActive(attrs);
|
||||
if (attrs.force === undefined) attrs.force = true;
|
||||
static initProps(props) {
|
||||
props.active = this.isActive(props);
|
||||
props.config = props.config || m.route;
|
||||
}
|
||||
|
||||
view(vnode) {
|
||||
const vdom = super.view(vnode);
|
||||
view() {
|
||||
const vdom = super.view();
|
||||
|
||||
vdom.tag = Link;
|
||||
vdom.attrs.active = String(vdom.attrs.active);
|
||||
vdom.tag = 'a';
|
||||
|
||||
return vdom;
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine whether a component with the given attrs is 'active'.
|
||||
* Determine whether a component with the given props is 'active'.
|
||||
*
|
||||
* @param {Object} attrs
|
||||
* @param {Object} props
|
||||
* @return {Boolean}
|
||||
*/
|
||||
static isActive(attrs) {
|
||||
return typeof attrs.active !== 'undefined' ? attrs.active : m.route.get() === attrs.href;
|
||||
static isActive(props) {
|
||||
return typeof props.active !== 'undefined'
|
||||
? props.active
|
||||
: m.route() === props.href;
|
||||
}
|
||||
}
|
||||
|
@@ -2,17 +2,16 @@ import Component from '../Component';
|
||||
import { Spinner } from 'spin.js';
|
||||
|
||||
/**
|
||||
* The `LoadingIndicator` component displays a loading spinner with spin.js.
|
||||
*
|
||||
* ### Attrs
|
||||
* The `LoadingIndicator` component displays a loading spinner with spin.js. It
|
||||
* may have the following special props:
|
||||
*
|
||||
* - `size` The spin.js size preset to use. Defaults to 'small'.
|
||||
*
|
||||
* All other attrs will be assigned as attributes on the DOM element.
|
||||
* All other props will be assigned as attributes on the element.
|
||||
*/
|
||||
export default class LoadingIndicator extends Component {
|
||||
view() {
|
||||
const attrs = Object.assign({}, this.attrs);
|
||||
const attrs = Object.assign({}, this.props);
|
||||
|
||||
attrs.className = 'LoadingIndicator ' + (attrs.className || '');
|
||||
delete attrs.size;
|
||||
@@ -20,12 +19,12 @@ export default class LoadingIndicator extends Component {
|
||||
return <div {...attrs}>{m.trust(' ')}</div>;
|
||||
}
|
||||
|
||||
oncreate(vnode) {
|
||||
super.oncreate(vnode);
|
||||
config(isInitialized) {
|
||||
if (isInitialized) return;
|
||||
|
||||
const options = { zIndex: 'auto', color: this.$().css('color') };
|
||||
|
||||
switch (this.attrs.size) {
|
||||
switch (this.props.size) {
|
||||
case 'large':
|
||||
Object.assign(options, { lines: 10, length: 8, width: 4, radius: 8 });
|
||||
break;
|
||||
|
@@ -9,63 +9,39 @@ import Button from './Button';
|
||||
* @abstract
|
||||
*/
|
||||
export default class Modal extends Component {
|
||||
/**
|
||||
* Determine whether or not the modal should be dismissible via an 'x' button.
|
||||
*/
|
||||
static isDismissible = true;
|
||||
|
||||
/**
|
||||
* Attributes for an alert component to show below the header.
|
||||
*
|
||||
* @type {object}
|
||||
*/
|
||||
alertAttrs = null;
|
||||
|
||||
oncreate(vnode) {
|
||||
super.oncreate(vnode);
|
||||
|
||||
this.attrs.animateShow(() => this.onready());
|
||||
}
|
||||
|
||||
onbeforeremove() {
|
||||
// If the global modal state currently contains a modal,
|
||||
// we've just opened up a new one, and accordingly,
|
||||
// we don't need to show a hide animation.
|
||||
if (!this.attrs.state.modal) {
|
||||
this.attrs.animateHide();
|
||||
// Here, we ensure that the animation has time to complete.
|
||||
// See https://mithril.js.org/lifecycle-methods.html#onbeforeremove
|
||||
// Bootstrap's Modal.TRANSITION_DURATION is 300 ms.
|
||||
return new Promise((resolve) => setTimeout(resolve, 300));
|
||||
}
|
||||
init() {
|
||||
/**
|
||||
* An alert component to show below the header.
|
||||
*
|
||||
* @type {Alert}
|
||||
*/
|
||||
this.alert = null;
|
||||
}
|
||||
|
||||
view() {
|
||||
if (this.alertAttrs) {
|
||||
this.alertAttrs.dismissible = false;
|
||||
if (this.alert) {
|
||||
this.alert.props.dismissible = false;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={'Modal modal-dialog ' + this.className()}>
|
||||
<div className="Modal-content">
|
||||
{this.constructor.isDismissible ? (
|
||||
{this.isDismissible() ? (
|
||||
<div className="Modal-close App-backControl">
|
||||
{Button.component({
|
||||
icon: 'fas fa-times',
|
||||
onclick: this.hide.bind(this),
|
||||
className: 'Button Button--icon Button--link',
|
||||
className: 'Button Button--icon Button--link'
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
''
|
||||
)}
|
||||
) : ''}
|
||||
|
||||
<form onsubmit={this.onsubmit.bind(this)}>
|
||||
<div className="Modal-header">
|
||||
<h3 className="App-titleControl App-titleControl--text">{this.title()}</h3>
|
||||
</div>
|
||||
|
||||
{this.alertAttrs ? <div className="Modal-alert">{Alert.component(this.alertAttrs)}</div> : ''}
|
||||
{alert ? <div className="Modal-alert">{this.alert}</div> : ''}
|
||||
|
||||
{this.content()}
|
||||
</form>
|
||||
@@ -74,13 +50,23 @@ export default class Modal extends Component {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine whether or not the modal should be dismissible via an 'x' button.
|
||||
*
|
||||
* @return {Boolean}
|
||||
*/
|
||||
isDismissible() {
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the class name to apply to the modal.
|
||||
*
|
||||
* @return {String}
|
||||
* @abstract
|
||||
*/
|
||||
className() {}
|
||||
className() {
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the title of the modal dialog.
|
||||
@@ -88,7 +74,8 @@ export default class Modal extends Component {
|
||||
* @return {String}
|
||||
* @abstract
|
||||
*/
|
||||
title() {}
|
||||
title() {
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the content of the modal.
|
||||
@@ -96,14 +83,16 @@ export default class Modal extends Component {
|
||||
* @return {VirtualElement}
|
||||
* @abstract
|
||||
*/
|
||||
content() {}
|
||||
content() {
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle the modal form's submit event.
|
||||
*
|
||||
* @param {Event} e
|
||||
*/
|
||||
onsubmit() {}
|
||||
onsubmit() {
|
||||
}
|
||||
|
||||
/**
|
||||
* Focus on the first input when the modal is ready to be used.
|
||||
@@ -112,11 +101,14 @@ export default class Modal extends Component {
|
||||
this.$('form').find('input, select, textarea').first().focus().select();
|
||||
}
|
||||
|
||||
onhide() {
|
||||
}
|
||||
|
||||
/**
|
||||
* Hide the modal.
|
||||
*/
|
||||
hide() {
|
||||
this.attrs.state.close();
|
||||
app.modal.close();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -134,7 +126,7 @@ export default class Modal extends Component {
|
||||
* @param {RequestError} error
|
||||
*/
|
||||
onerror(error) {
|
||||
this.alertAttrs = error.alert;
|
||||
this.alert = error.alert;
|
||||
|
||||
m.redraw();
|
||||
|
||||
|
@@ -1,4 +1,5 @@
|
||||
import Component from '../Component';
|
||||
import Modal from './Modal';
|
||||
|
||||
/**
|
||||
* The `ModalManager` component manages a modal dialog. Only one modal dialog
|
||||
@@ -6,53 +7,100 @@ import Component from '../Component';
|
||||
* overwrite the previous one.
|
||||
*/
|
||||
export default class ModalManager extends Component {
|
||||
view() {
|
||||
const modal = this.attrs.state.modal;
|
||||
init() {
|
||||
this.showing = false;
|
||||
this.component = null;
|
||||
}
|
||||
|
||||
view() {
|
||||
return (
|
||||
<div className="ModalManager modal fade">
|
||||
{modal
|
||||
? modal.componentClass.component({
|
||||
...modal.attrs,
|
||||
animateShow: this.animateShow.bind(this),
|
||||
animateHide: this.animateHide.bind(this),
|
||||
state: this.attrs.state,
|
||||
})
|
||||
: ''}
|
||||
{this.component && this.component.render()}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
oncreate(vnode) {
|
||||
super.oncreate(vnode);
|
||||
config(isInitialized, context) {
|
||||
if (isInitialized) return;
|
||||
|
||||
// Ensure the modal state is notified about a closed modal, even when the
|
||||
// DOM-based Bootstrap JavaScript code triggered the closing of the modal,
|
||||
// e.g. via ESC key or a click on the modal backdrop.
|
||||
this.$().on('hidden.bs.modal', this.attrs.state.close.bind(this.attrs.state));
|
||||
}
|
||||
|
||||
animateShow(readyCallback) {
|
||||
const dismissible = !!this.attrs.state.modal.componentClass.isDismissible;
|
||||
|
||||
// If we are opening this modal while another modal is already open,
|
||||
// the shown event will not run, because the modal is already open.
|
||||
// So, we need to manually trigger the readyCallback.
|
||||
if (this.$().hasClass('in')) {
|
||||
readyCallback();
|
||||
return;
|
||||
}
|
||||
// Since this component is 'above' the content of the page (that is, it is a
|
||||
// part of the global UI that persists between routes), we will flag the DOM
|
||||
// to be retained across route changes.
|
||||
context.retain = true;
|
||||
|
||||
this.$()
|
||||
.one('shown.bs.modal', readyCallback)
|
||||
.modal({
|
||||
backdrop: dismissible || 'static',
|
||||
keyboard: dismissible,
|
||||
})
|
||||
.modal('show');
|
||||
.on('hidden.bs.modal', this.clear.bind(this))
|
||||
.on('shown.bs.modal', this.onready.bind(this));
|
||||
}
|
||||
|
||||
animateHide() {
|
||||
this.$().modal('hide');
|
||||
/**
|
||||
* Show a modal dialog.
|
||||
*
|
||||
* @param {Modal} component
|
||||
* @public
|
||||
*/
|
||||
show(component) {
|
||||
if (!(component instanceof Modal)) {
|
||||
throw new Error('The ModalManager component can only show Modal components');
|
||||
}
|
||||
|
||||
clearTimeout(this.hideTimeout);
|
||||
|
||||
this.showing = true;
|
||||
this.component = component;
|
||||
|
||||
if (app.current) app.current.retain = true;
|
||||
|
||||
m.redraw(true);
|
||||
|
||||
this.$().modal({backdrop: this.component.isDismissible() ? true : 'static'}).modal('show');
|
||||
this.onready();
|
||||
}
|
||||
|
||||
/**
|
||||
* Close the modal dialog.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
close() {
|
||||
if (!this.showing) return;
|
||||
|
||||
// Don't hide the modal immediately, because if the consumer happens to call
|
||||
// the `show` method straight after to show another modal dialog, it will
|
||||
// cause Bootstrap's modal JS to misbehave. Instead we will wait for a tiny
|
||||
// bit to give the `show` method the opportunity to prevent this from going
|
||||
// ahead.
|
||||
this.hideTimeout = setTimeout(() => {
|
||||
this.$().modal('hide');
|
||||
this.showing = false;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear content from the modal area.
|
||||
*
|
||||
* @protected
|
||||
*/
|
||||
clear() {
|
||||
if (this.component) {
|
||||
this.component.onhide();
|
||||
}
|
||||
|
||||
this.component = null;
|
||||
|
||||
app.current.retain = false;
|
||||
|
||||
m.lazyRedraw();
|
||||
}
|
||||
|
||||
/**
|
||||
* When the modal dialog is ready to be used, tell it!
|
||||
*
|
||||
* @protected
|
||||
*/
|
||||
onready() {
|
||||
if (this.component && this.component.onready) {
|
||||
this.component.onready(this.$());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@@ -11,7 +11,7 @@ import LinkButton from './LinkButton';
|
||||
* If the app has a pane, it will also include a 'pin' button which toggles the
|
||||
* pinned state of the pane.
|
||||
*
|
||||
* Accepts the following attrs:
|
||||
* Accepts the following props:
|
||||
*
|
||||
* - `className` The name of a class to set on the root element.
|
||||
* - `drawer` Whether or not to show a button to toggle the app's drawer if
|
||||
@@ -19,19 +19,26 @@ import LinkButton from './LinkButton';
|
||||
*/
|
||||
export default class Navigation extends Component {
|
||||
view() {
|
||||
const { history, pane } = app;
|
||||
const {history, pane} = app;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={'Navigation ButtonGroup ' + (this.attrs.className || '')}
|
||||
<div className={'Navigation ButtonGroup ' + (this.props.className || '')}
|
||||
onmouseenter={pane && pane.show.bind(pane)}
|
||||
onmouseleave={pane && pane.onmouseleave.bind(pane)}
|
||||
>
|
||||
{history.canGoBack() ? [this.getBackButton(), this.getPaneButton()] : this.getDrawerButton()}
|
||||
onmouseleave={pane && pane.onmouseleave.bind(pane)}>
|
||||
{history.canGoBack()
|
||||
? [this.getBackButton(), this.getPaneButton()]
|
||||
: this.getDrawerButton()}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
config(isInitialized, context) {
|
||||
// Since this component is 'above' the content of the page (that is, it is a
|
||||
// part of the global UI that persists between routes), we will flag the DOM
|
||||
// to be retained across route changes.
|
||||
context.retain = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the back button.
|
||||
*
|
||||
@@ -39,7 +46,7 @@ export default class Navigation extends Component {
|
||||
* @protected
|
||||
*/
|
||||
getBackButton() {
|
||||
const { history } = app;
|
||||
const {history} = app;
|
||||
const previous = history.getPrevious() || {};
|
||||
|
||||
return LinkButton.component({
|
||||
@@ -47,11 +54,12 @@ export default class Navigation extends Component {
|
||||
href: history.backUrl(),
|
||||
icon: 'fas fa-chevron-left',
|
||||
title: previous.title,
|
||||
onclick: (e) => {
|
||||
config: () => {},
|
||||
onclick: e => {
|
||||
if (e.shiftKey || e.ctrlKey || e.metaKey || e.which === 2) return;
|
||||
e.preventDefault();
|
||||
history.back();
|
||||
},
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -62,14 +70,14 @@ export default class Navigation extends Component {
|
||||
* @protected
|
||||
*/
|
||||
getPaneButton() {
|
||||
const { pane } = app;
|
||||
const {pane} = app;
|
||||
|
||||
if (!pane || !pane.active) return '';
|
||||
|
||||
return Button.component({
|
||||
className: 'Button Button--icon Navigation-pin' + (pane.pinned ? ' active' : ''),
|
||||
onclick: pane.togglePinned.bind(pane),
|
||||
icon: 'fas fa-thumbtack',
|
||||
icon: 'fas fa-thumbtack'
|
||||
});
|
||||
}
|
||||
|
||||
@@ -80,18 +88,19 @@ export default class Navigation extends Component {
|
||||
* @protected
|
||||
*/
|
||||
getDrawerButton() {
|
||||
if (!this.attrs.drawer) return '';
|
||||
if (!this.props.drawer) return '';
|
||||
|
||||
const { drawer } = app;
|
||||
const {drawer} = app;
|
||||
const user = app.session.user;
|
||||
|
||||
return Button.component({
|
||||
className: 'Button Button--icon Navigation-drawer' + (user && user.newNotificationCount() ? ' new' : ''),
|
||||
onclick: (e) => {
|
||||
className: 'Button Button--icon Navigation-drawer' +
|
||||
(user && user.newNotificationCount() ? ' new' : ''),
|
||||
onclick: e => {
|
||||
e.stopPropagation();
|
||||
drawer.show();
|
||||
},
|
||||
icon: 'fas fa-bars',
|
||||
icon: 'fas fa-bars'
|
||||
});
|
||||
}
|
||||
}
|
||||
|
@@ -1,62 +0,0 @@
|
||||
import Component from '../Component';
|
||||
import PageState from '../states/PageState';
|
||||
|
||||
/**
|
||||
* The `Page` component
|
||||
*
|
||||
* @abstract
|
||||
*/
|
||||
export default class Page extends Component {
|
||||
oninit(vnode) {
|
||||
super.oninit(vnode);
|
||||
|
||||
app.previous = app.current;
|
||||
app.current = new PageState(this.constructor, { routeName: this.attrs.routeName });
|
||||
|
||||
app.drawer.hide();
|
||||
app.modal.close();
|
||||
|
||||
/**
|
||||
* A class name to apply to the body while the route is active.
|
||||
*
|
||||
* @type {String}
|
||||
*/
|
||||
this.bodyClass = '';
|
||||
|
||||
/**
|
||||
* Whether we should scroll to the top of the page when its rendered.
|
||||
*
|
||||
* @type {Boolean}
|
||||
*/
|
||||
this.scrollTopOnCreate = true;
|
||||
|
||||
/**
|
||||
* Whether the browser should restore scroll state on refreshes.
|
||||
*
|
||||
* @type {Boolean}
|
||||
*/
|
||||
this.useBrowserScrollRestoration = true;
|
||||
}
|
||||
|
||||
oncreate(vnode) {
|
||||
super.oncreate(vnode);
|
||||
|
||||
if (this.bodyClass) {
|
||||
$('#app').addClass(this.bodyClass);
|
||||
}
|
||||
|
||||
if (this.scrollTopOnCreate) {
|
||||
$(window).scrollTop(0);
|
||||
}
|
||||
|
||||
if ('scrollRestoration' in history) {
|
||||
history.scrollRestoration = this.useBrowserScrollRestoration ? 'auto' : 'manual';
|
||||
}
|
||||
}
|
||||
|
||||
onremove() {
|
||||
if (this.bodyClass) {
|
||||
$('#app').removeClass(this.bodyClass);
|
||||
}
|
||||
}
|
||||
}
|
@@ -4,7 +4,7 @@ import Component from '../Component';
|
||||
* The `Placeholder` component displays a muted text with some call to action,
|
||||
* usually used as an empty state.
|
||||
*
|
||||
* ### Attrs
|
||||
* ### Props
|
||||
*
|
||||
* - `text`
|
||||
*/
|
||||
@@ -12,7 +12,7 @@ export default class Placeholder extends Component {
|
||||
view() {
|
||||
return (
|
||||
<div className="Placeholder">
|
||||
<p>{this.attrs.text}</p>
|
||||
<p>{this.props.text}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
@@ -6,37 +6,25 @@ export default class RequestErrorModal extends Modal {
|
||||
}
|
||||
|
||||
title() {
|
||||
return this.attrs.error.xhr ? `${this.attrs.error.xhr.status} ${this.attrs.error.xhr.statusText}` : '';
|
||||
return this.props.error.xhr
|
||||
? this.props.error.xhr.status+' '+this.props.error.xhr.statusText
|
||||
: '';
|
||||
}
|
||||
|
||||
content() {
|
||||
const { error, formattedError } = this.attrs;
|
||||
|
||||
let responseText;
|
||||
|
||||
// If the error is already formatted, just add line endings;
|
||||
// else try to parse it as JSON and stringify it with indentation
|
||||
if (formattedError) {
|
||||
responseText = formattedError.join('\n\n');
|
||||
} else {
|
||||
try {
|
||||
const json = error.response || JSON.parse(error.responseText);
|
||||
|
||||
responseText = JSON.stringify(json, null, 2);
|
||||
} catch (e) {
|
||||
responseText = error.responseText;
|
||||
}
|
||||
try {
|
||||
responseText = JSON.stringify(JSON.parse(this.props.error.responseText), null, 2);
|
||||
} catch (e) {
|
||||
responseText = this.props.error.responseText;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="Modal-body">
|
||||
<pre>
|
||||
{this.attrs.error.options.method} {this.attrs.error.options.url}
|
||||
<br />
|
||||
<br />
|
||||
{responseText}
|
||||
</pre>
|
||||
</div>
|
||||
);
|
||||
return <div className="Modal-body">
|
||||
<pre>
|
||||
{this.props.error.options.method} {this.props.error.options.url}<br/><br/>
|
||||
{responseText}
|
||||
</pre>
|
||||
</div>;
|
||||
}
|
||||
}
|
||||
|
@@ -1,33 +1,24 @@
|
||||
import Component from '../Component';
|
||||
import icon from '../helpers/icon';
|
||||
import withAttr from '../utils/withAttr';
|
||||
|
||||
/**
|
||||
* The `Select` component displays a <select> input, surrounded with some extra
|
||||
* elements for styling. It accepts the following attrs:
|
||||
* elements for styling. It accepts the following props:
|
||||
*
|
||||
* - `options` A map of option values to labels.
|
||||
* - `onchange` A callback to run when the selected value is changed.
|
||||
* - `value` The value of the selected option.
|
||||
* - `disabled` Disabled state for the input.
|
||||
*/
|
||||
export default class Select extends Component {
|
||||
view() {
|
||||
const { options, onchange, value, disabled } = this.attrs;
|
||||
const {options, onchange, value} = this.props;
|
||||
|
||||
return (
|
||||
<span className="Select">
|
||||
<select
|
||||
className="Select-input FormControl"
|
||||
onchange={onchange ? withAttr('value', onchange.bind(this)) : undefined}
|
||||
value={value}
|
||||
disabled={disabled}
|
||||
>
|
||||
{Object.keys(options).map((key) => (
|
||||
<option value={key}>{options[key]}</option>
|
||||
))}
|
||||
<select className="Select-input FormControl" onchange={onchange ? m.withAttr('value', onchange.bind(this)) : undefined} value={value}>
|
||||
{Object.keys(options).map(key => <option value={key}>{options[key]}</option>)}
|
||||
</select>
|
||||
{icon('fas fa-sort', { className: 'Select-caret' })}
|
||||
{icon('fas fa-sort', {className: 'Select-caret'})}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
@@ -1,52 +1,34 @@
|
||||
import Dropdown from './Dropdown';
|
||||
import icon from '../helpers/icon';
|
||||
|
||||
/**
|
||||
* Determines via a vnode is currently "active".
|
||||
* Due to changes in Mithril 2, attrs will not be instantiated until AFTER view()
|
||||
* is initially called on the parent component, so we can not always depend on the
|
||||
* active attr to determine which element should be displayed as the "active child".
|
||||
*
|
||||
* This is a temporary patch, and as so, is not exported / placed in utils.
|
||||
*/
|
||||
function isActive(vnode) {
|
||||
const tag = vnode.tag;
|
||||
|
||||
// Allow non-selectable dividers/headers to be added.
|
||||
if (typeof tag === 'string' && tag !== 'a' && tag !== 'button') return false;
|
||||
|
||||
if ('initAttrs' in tag) {
|
||||
tag.initAttrs(vnode.attrs);
|
||||
}
|
||||
|
||||
return 'isActive' in tag ? tag.isActive(vnode.attrs) : vnode.attrs.active;
|
||||
}
|
||||
|
||||
/**
|
||||
* The `SelectDropdown` component is the same as a `Dropdown`, except the toggle
|
||||
* button's label is set as the label of the first child which has a truthy
|
||||
* `active` prop.
|
||||
*
|
||||
* ### Attrs
|
||||
* ### Props
|
||||
*
|
||||
* - `caretIcon`
|
||||
* - `defaultLabel`
|
||||
*/
|
||||
export default class SelectDropdown extends Dropdown {
|
||||
static initAttrs(attrs) {
|
||||
attrs.caretIcon = typeof attrs.caretIcon !== 'undefined' ? attrs.caretIcon : 'fas fa-sort';
|
||||
static initProps(props) {
|
||||
props.caretIcon = typeof props.caretIcon !== 'undefined' ? props.caretIcon : 'fas fa-sort';
|
||||
|
||||
super.initAttrs(attrs);
|
||||
super.initProps(props);
|
||||
|
||||
attrs.className += ' Dropdown--select';
|
||||
props.className += ' Dropdown--select';
|
||||
}
|
||||
|
||||
getButtonContent(children) {
|
||||
const activeChild = children.find(isActive);
|
||||
let label = (activeChild && activeChild.children) || this.attrs.defaultLabel;
|
||||
getButtonContent() {
|
||||
const activeChild = this.props.children.filter(child => child.props.active)[0];
|
||||
let label = activeChild && activeChild.props.children || this.props.defaultLabel;
|
||||
|
||||
if (label instanceof Array) label = label[0];
|
||||
|
||||
return [<span className="Button-label">{label}</span>, icon(this.attrs.caretIcon, { className: 'Button-caret' })];
|
||||
return [
|
||||
<span className="Button-label">{label}</span>,
|
||||
icon(this.props.caretIcon, {className: 'Button-caret'})
|
||||
];
|
||||
}
|
||||
}
|
||||
|
@@ -5,7 +5,7 @@ import Component from '../Component';
|
||||
*/
|
||||
class Separator extends Component {
|
||||
view() {
|
||||
return <li className="Dropdown-separator" />;
|
||||
return <li className="Dropdown-separator"/>;
|
||||
}
|
||||
}
|
||||
|
||||
|
@@ -7,27 +7,29 @@ import icon from '../helpers/icon';
|
||||
* is displayed as its own button prior to the toggle button.
|
||||
*/
|
||||
export default class SplitDropdown extends Dropdown {
|
||||
static initAttrs(attrs) {
|
||||
super.initAttrs(attrs);
|
||||
static initProps(props) {
|
||||
super.initProps(props);
|
||||
|
||||
attrs.className += ' Dropdown--split';
|
||||
attrs.menuClassName += ' Dropdown-menu--right';
|
||||
props.className += ' Dropdown--split';
|
||||
props.menuClassName += ' Dropdown-menu--right';
|
||||
}
|
||||
|
||||
getButton(children) {
|
||||
// Make a copy of the attrs of the first child component. We will assign
|
||||
// these attrs to a new button, so that it has exactly the same behaviour as
|
||||
getButton() {
|
||||
// Make a copy of the props of the first child component. We will assign
|
||||
// these props to a new button, so that it has exactly the same behaviour as
|
||||
// the first child.
|
||||
const firstChild = this.getFirstChild(children);
|
||||
const buttonAttrs = Object.assign({}, firstChild.attrs);
|
||||
buttonAttrs.className = (buttonAttrs.className || '') + ' SplitDropdown-button Button ' + this.attrs.buttonClassName;
|
||||
const firstChild = this.getFirstChild();
|
||||
const buttonProps = Object.assign({}, firstChild.props);
|
||||
buttonProps.className = (buttonProps.className || '') + ' SplitDropdown-button Button ' + this.props.buttonClassName;
|
||||
|
||||
return [
|
||||
Button.component(buttonAttrs, firstChild.children),
|
||||
<button className={'Dropdown-toggle Button Button--icon ' + this.attrs.buttonClassName} data-toggle="dropdown">
|
||||
{icon(this.attrs.icon, { className: 'Button-icon' })}
|
||||
{icon('fas fa-caret-down', { className: 'Button-caret' })}
|
||||
</button>,
|
||||
Button.component(buttonProps),
|
||||
<button
|
||||
className={'Dropdown-toggle Button Button--icon ' + this.props.buttonClassName}
|
||||
data-toggle="dropdown">
|
||||
{icon(this.props.icon, {className: 'Button-icon'})}
|
||||
{icon('fas fa-caret-down', {className: 'Button-caret'})}
|
||||
</button>
|
||||
];
|
||||
}
|
||||
|
||||
@@ -38,8 +40,8 @@ export default class SplitDropdown extends Dropdown {
|
||||
* @return {*}
|
||||
* @protected
|
||||
*/
|
||||
getFirstChild(children) {
|
||||
let firstChild = children;
|
||||
getFirstChild() {
|
||||
let firstChild = this.props.children;
|
||||
|
||||
while (firstChild instanceof Array) firstChild = firstChild[0];
|
||||
|
||||
|
@@ -5,13 +5,13 @@ import Checkbox from './Checkbox';
|
||||
* a tick/cross one.
|
||||
*/
|
||||
export default class Switch extends Checkbox {
|
||||
static initAttrs(attrs) {
|
||||
super.initAttrs(attrs);
|
||||
static initProps(props) {
|
||||
super.initProps(props);
|
||||
|
||||
attrs.className = (attrs.className || '') + ' Checkbox--switch';
|
||||
props.className = (props.className || '') + ' Checkbox--switch';
|
||||
}
|
||||
|
||||
getDisplay() {
|
||||
return this.attrs.loading ? super.getDisplay() : '';
|
||||
return this.loading ? super.getDisplay() : '';
|
||||
}
|
||||
}
|
||||
|
@@ -21,7 +21,7 @@
|
||||
export function extend(object, method, callback) {
|
||||
const original = object[method];
|
||||
|
||||
object[method] = function (...args) {
|
||||
object[method] = function(...args) {
|
||||
const value = original ? original.apply(this, args) : undefined;
|
||||
|
||||
callback.apply(this, [value].concat(args));
|
||||
@@ -57,7 +57,7 @@ export function extend(object, method, callback) {
|
||||
export function override(object, method, newMethod) {
|
||||
const original = object[method];
|
||||
|
||||
object[method] = function (...args) {
|
||||
object[method] = function(...args) {
|
||||
return newMethod.apply(this, [original.bind(this)].concat(args));
|
||||
};
|
||||
|
||||
|
@@ -1,4 +1,4 @@
|
||||
export default class Model {
|
||||
export default class Routes {
|
||||
type;
|
||||
attributes = [];
|
||||
hasOnes = [];
|
||||
@@ -31,11 +31,11 @@ export default class Model {
|
||||
if (this.model) {
|
||||
app.store.models[this.type] = this.model;
|
||||
}
|
||||
|
||||
|
||||
const model = app.store.models[this.type];
|
||||
|
||||
this.attributes.forEach((name) => (model.prototype[name] = model.attribute(name)));
|
||||
this.hasOnes.forEach((name) => (model.prototype[name] = model.hasOne(name)));
|
||||
this.hasManys.forEach((name) => (model.prototype[name] = model.hasMany(name)));
|
||||
this.attributes.forEach(name => model.prototype[name] = model.attribute(name));
|
||||
this.hasOnes.forEach(name => model.prototype[name] = model.hasOne(name));
|
||||
this.hasManys.forEach(name => model.prototype[name] = model.hasMany(name));
|
||||
}
|
||||
}
|
||||
}
|
@@ -10,4 +10,4 @@ export default class PostTypes {
|
||||
extend(app, extension) {
|
||||
Object.assign(app.postComponents, this.postComponents);
|
||||
}
|
||||
}
|
||||
}
|
@@ -10,4 +10,4 @@ export default class Routes {
|
||||
extend(app, extension) {
|
||||
Object.assign(app.routes, this.routes);
|
||||
}
|
||||
}
|
||||
}
|
@@ -25,11 +25,11 @@ export default function avatar(user, attrs = {}) {
|
||||
if (hasTitle) attrs.title = attrs.title || username;
|
||||
|
||||
if (avatarUrl) {
|
||||
return <img {...attrs} src={avatarUrl} alt="" />;
|
||||
return <img {...attrs} src={avatarUrl}/>;
|
||||
}
|
||||
|
||||
content = username.charAt(0).toUpperCase();
|
||||
attrs.style = { background: user.color() };
|
||||
attrs.style = {background: user.color()};
|
||||
}
|
||||
|
||||
return <span {...attrs}>{content}</span>;
|
||||
|
15
js/src/common/helpers/fullTime.js
Normal file
15
js/src/common/helpers/fullTime.js
Normal file
@@ -0,0 +1,15 @@
|
||||
/**
|
||||
* The `fullTime` helper displays a formatted time string wrapped in a <time>
|
||||
* tag.
|
||||
*
|
||||
* @param {Date} time
|
||||
* @return {Object}
|
||||
*/
|
||||
export default function fullTime(time) {
|
||||
const mo = moment(time);
|
||||
|
||||
const datetime = mo.format();
|
||||
const full = mo.format('LLLL');
|
||||
|
||||
return <time pubdate datetime={datetime}>{full}</time>;
|
||||
}
|
@@ -1,19 +0,0 @@
|
||||
import dayjs from 'dayjs';
|
||||
import * as Mithril from 'mithril';
|
||||
|
||||
/**
|
||||
* The `fullTime` helper displays a formatted time string wrapped in a <time>
|
||||
* tag.
|
||||
*/
|
||||
export default function fullTime(time: Date): Mithril.Vnode {
|
||||
const d = dayjs(time);
|
||||
|
||||
const datetime = d.format();
|
||||
const full = d.format('LLLL');
|
||||
|
||||
return (
|
||||
<time pubdate datetime={datetime}>
|
||||
{full}
|
||||
</time>
|
||||
);
|
||||
}
|
@@ -1,16 +1,16 @@
|
||||
import * as Mithril from 'mithril';
|
||||
import { truncate } from '../utils/string';
|
||||
|
||||
/**
|
||||
* The `highlight` helper searches for a word phrase in a string, and wraps
|
||||
* matches with the <mark> tag.
|
||||
*
|
||||
* @param string The string to highlight.
|
||||
* @param phrase The word or words to highlight.
|
||||
* @param [length] The number of characters to truncate the string to.
|
||||
* @param {String} string The string to highlight.
|
||||
* @param {String|RegExp} phrase The word or words to highlight.
|
||||
* @param {Integer} [length] The number of characters to truncate the string to.
|
||||
* The string will be truncated surrounding the first match.
|
||||
* @return {Object}
|
||||
*/
|
||||
export default function highlight(string: string, phrase: string | RegExp, length?: number): Mithril.Vnode<any, any> | string {
|
||||
export default function highlight(string, phrase, length) {
|
||||
if (!phrase && !length) return string;
|
||||
|
||||
// Convert the word phrase into a global regular expression (if it isn't
|
19
js/src/common/helpers/humanTime.js
Normal file
19
js/src/common/helpers/humanTime.js
Normal file
@@ -0,0 +1,19 @@
|
||||
import humanTimeUtil from '../utils/humanTime';
|
||||
|
||||
/**
|
||||
* The `humanTime` helper displays a time in a human-friendly time-ago format
|
||||
* (e.g. '12 days ago'), wrapped in a <time> tag with other information about
|
||||
* the time.
|
||||
*
|
||||
* @param {Date} time
|
||||
* @return {Object}
|
||||
*/
|
||||
export default function humanTime(time) {
|
||||
const mo = moment(time);
|
||||
|
||||
const datetime = mo.format();
|
||||
const full = mo.format('LLLL');
|
||||
const ago = humanTimeUtil(time);
|
||||
|
||||
return <time pubdate datetime={datetime} title={full} data-humantime>{ago}</time>;
|
||||
}
|
@@ -1,22 +0,0 @@
|
||||
import dayjs from 'dayjs';
|
||||
import * as Mithril from 'mithril';
|
||||
import humanTimeUtil from '../utils/humanTime';
|
||||
|
||||
/**
|
||||
* The `humanTime` helper displays a time in a human-friendly time-ago format
|
||||
* (e.g. '12 days ago'), wrapped in a <time> tag with other information about
|
||||
* the time.
|
||||
*/
|
||||
export default function humanTime(time: Date): Mithril.Vnode {
|
||||
const d = dayjs(time);
|
||||
|
||||
const datetime = d.format();
|
||||
const full = d.format('LLLL');
|
||||
const ago = humanTimeUtil(time);
|
||||
|
||||
return (
|
||||
<time pubdate datetime={datetime} title={full} data-humantime>
|
||||
{ago}
|
||||
</time>
|
||||
);
|
||||
}
|
12
js/src/common/helpers/icon.js
Normal file
12
js/src/common/helpers/icon.js
Normal file
@@ -0,0 +1,12 @@
|
||||
/**
|
||||
* The `icon` helper displays an icon.
|
||||
*
|
||||
* @param {String} fontClass The full icon class, prefix and the icon’s name.
|
||||
* @param {Object} attrs Any other attributes to apply.
|
||||
* @return {Object}
|
||||
*/
|
||||
export default function icon(fontClass, attrs = {}) {
|
||||
attrs.className = 'icon ' + fontClass + ' ' + (attrs.className || '');
|
||||
|
||||
return <i {...attrs}/>;
|
||||
}
|
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user