mirror of
https://github.com/flarum/core.git
synced 2025-08-13 20:04:24 +02:00
Compare commits
249 Commits
as/cleanup
...
ck/floatin
Author | SHA1 | Date | |
---|---|---|---|
|
6b00b4afc3 | ||
|
f4222b76fe | ||
|
127393b778 | ||
|
039109559d | ||
|
1c53ef0a0a | ||
|
bed3207798 | ||
|
fc73d47e4c | ||
|
6e01c47c11 | ||
|
a9526917b8 | ||
|
e37fdef709 | ||
|
56d7796c47 | ||
|
b7379bf91b | ||
|
7fa22a131f | ||
|
f0c6050654 | ||
|
9627eb73f1 | ||
|
d0adb244da | ||
|
458a5cc6be | ||
|
ea840ba594 | ||
|
ea291508ab | ||
|
7d79912d36 | ||
|
67306a9d34 | ||
|
8cc207b139 | ||
|
023871ef86 | ||
|
1c578a83e4 | ||
|
454c525cb2 | ||
|
49009d268f | ||
|
ef2d6a65f4 | ||
|
509adf228a | ||
|
fa10d794a4 | ||
|
40ede179cd | ||
|
0ed71ed581 | ||
|
dc75ebad00 | ||
|
900711687f | ||
|
71ccdc00e6 | ||
|
c4ebebe48e | ||
|
56d8301b2d | ||
|
09076e005b | ||
|
73a8efaec2 | ||
|
cdeb229396 | ||
|
122a99b51e | ||
|
e7aed89e8f | ||
|
a1254bc21a | ||
|
03231b2931 | ||
|
a2901cef23 | ||
|
95b021a839 | ||
|
76d6442557 | ||
|
5df22e92ae | ||
|
7306d8ef13 | ||
|
0595aba76a | ||
|
8366ec720e | ||
|
17f15e36eb | ||
|
ac249e5b07 | ||
|
e13772075c | ||
|
0fa33439d7 | ||
|
a4880453a4 | ||
|
964f827ee5 | ||
|
843daf633d | ||
|
930fcf9250 | ||
|
9bb4423dd7 | ||
|
9347b12b47 | ||
|
65b5c2043c | ||
|
08f72e7135 | ||
|
26c4e492fe | ||
|
00913d5b0b | ||
|
1851d1678e | ||
|
14dc46e226 | ||
|
be163412ab | ||
|
92d5c716be | ||
|
e42df50d31 | ||
|
203a6456ee | ||
|
40b918e139 | ||
|
f8eea5b7c7 | ||
|
b50d806534 | ||
|
cbcf83ed3b | ||
|
3394ff31e9 | ||
|
86d39fb003 | ||
|
bbb7679417 | ||
|
46248f601d | ||
|
a68e2b27a4 | ||
|
e2335e867d | ||
|
a10da427ff | ||
|
4561f56fb9 | ||
|
fae79ea910 | ||
|
9493e6230d | ||
|
927ea4eec5 | ||
|
89e821e70f | ||
|
9b2d7856d1 | ||
|
f93ec1b3b8 | ||
|
2e3197d510 | ||
|
85210ff6a1 | ||
|
e5f277e640 | ||
|
4bac667dfd | ||
|
6771b3e3b7 | ||
|
fd79a14cac | ||
|
c1aa1455d3 | ||
|
ae280016e7 | ||
|
0a8816938a | ||
|
008ec95505 | ||
|
925628c208 | ||
|
aae83c4fbc | ||
|
cacc8b4945 | ||
|
31765388c1 | ||
|
a08fd3e475 | ||
|
d4b2d89da0 | ||
|
9b27b0d9d7 | ||
|
a47187462d | ||
|
843a149b80 | ||
|
94381dca62 | ||
|
a2d5dd3397 | ||
|
f8edc2d827 | ||
|
62235a16ca | ||
|
36c55e8f69 | ||
|
859f014539 | ||
|
06e1d21331 | ||
|
fd5de6929e | ||
|
84b1666b24 | ||
|
0c61fcc61c | ||
|
8e25bcb68f | ||
|
fad783547c | ||
|
210a6b3e25 | ||
|
73409184b9 | ||
|
afe038699e | ||
|
649851d356 | ||
|
d1dfa758e4 | ||
|
8901073d12 | ||
|
e0437d237a | ||
|
07a43f52b4 | ||
|
9e9118fa0d | ||
|
4679448300 | ||
|
ef4bf8128e | ||
|
67a2aac635 | ||
|
51a97fb12e | ||
|
056d420c7b | ||
|
cfa533ebd6 | ||
|
eed407812f | ||
|
641619e820 | ||
|
984f751c71 | ||
|
8830e9dd09 | ||
|
fe41bc1fdc | ||
|
5a763050a6 | ||
|
8c813bc340 | ||
|
f67dee0a9e | ||
|
f968420216 | ||
|
d5e124b4a2 | ||
|
09e2736cbc | ||
|
ddb3d3edb0 | ||
|
28d56f5fc8 | ||
|
9b4012bbb5 | ||
|
1a5e4d454e | ||
|
387b4fd315 | ||
|
66482c2815 | ||
|
277a5c3fac | ||
|
286d8dec5b | ||
|
e1c61a0e85 | ||
|
102e76b084 | ||
|
d09d4bc507 | ||
|
c3989cc952 | ||
|
9cb9097b24 | ||
|
571a835be0 | ||
|
0c95774333 | ||
|
67741c7a6f | ||
|
f5cfec15e3 | ||
|
47d2eee9ce | ||
|
c10cc92deb | ||
|
529d2edcaf | ||
|
f0e77a5789 | ||
|
87c258b2f8 | ||
|
cee87848fe | ||
|
967cd0e3ca | ||
|
b79152b977 | ||
|
ace624db66 | ||
|
5842dd1200 | ||
|
b311512502 | ||
|
9b9f2c4bb7 | ||
|
0b2a5fa5b8 | ||
|
52e45aacad | ||
|
8b1de457bf | ||
|
21c2a4b2a4 | ||
|
12c03dc4e1 | ||
|
d2927cfdb9 | ||
|
24b7a21507 | ||
|
c9a04fe009 | ||
|
bd7fa11b5a | ||
|
7055f6d941 | ||
|
f765001f06 | ||
|
683739a617 | ||
|
69b7fe8d01 | ||
|
1936b9117d | ||
|
d53eeded44 | ||
|
0650788e7c | ||
|
6a77184611 | ||
|
a8b36cb76d | ||
|
5cd14d594b | ||
|
f4ad9d2d5a | ||
|
d409484abf | ||
|
1fc24635f6 | ||
|
ff7ac0b322 | ||
|
d460aaa3ad | ||
|
7634a766cb | ||
|
e5f53b93a6 | ||
|
a38c92d409 | ||
|
3da655a62f | ||
|
46c3124b0b | ||
|
e6f59b834f | ||
|
9f5737eb93 | ||
|
35cb5b20a0 | ||
|
988b6c9023 | ||
|
c1d91be2f4 | ||
|
f534398645 | ||
|
cd05ec6589 | ||
|
78be6e2194 | ||
|
eb498a0a9f | ||
|
ac42a5900d | ||
|
543b136f7c | ||
|
8546fb734f | ||
|
20b9455f04 | ||
|
008f1da539 | ||
|
a3bd431503 | ||
|
6da81a71a4 | ||
|
a48d38614b | ||
|
7358437c59 | ||
|
08ec274a24 | ||
|
d45478564f | ||
|
8296ffe8c9 | ||
|
1d2f0ca407 | ||
|
bb69c3bd57 | ||
|
2b1581875a | ||
|
a0c36a015b | ||
|
656409794c | ||
|
245f3c6846 | ||
|
962b49567c | ||
|
12498b7620 | ||
|
84f7d29d8c | ||
|
561e8c6b6a | ||
|
ad9917f0d6 | ||
|
6977c24dd8 | ||
|
d1b72429ac | ||
|
63692f12c5 | ||
|
441ccec8e7 | ||
|
414b0ff6d3 | ||
|
2ff0e1efcb | ||
|
8c46b37a6f | ||
|
9be629cfcc | ||
|
df9be1b063 | ||
|
97c36f2f7d | ||
|
13efd02085 | ||
|
0b44c48433 | ||
|
b562072471 | ||
|
718445cb0c |
21
.github/workflows/test.yml
vendored
21
.github/workflows/test.yml
vendored
@@ -8,7 +8,7 @@ jobs:
|
||||
|
||||
strategy:
|
||||
matrix:
|
||||
php: [7.2, 7.3, 7.4]
|
||||
php: ['7.2', '7.3', '7.4', '8.0']
|
||||
service: ['mysql:5.7', mariadb]
|
||||
prefix: ['', flarum_]
|
||||
|
||||
@@ -33,6 +33,12 @@ jobs:
|
||||
- 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:
|
||||
@@ -45,13 +51,22 @@ jobs:
|
||||
steps:
|
||||
- uses: actions/checkout@master
|
||||
|
||||
- name: Select PHP version
|
||||
run: sudo update-alternatives --set php $(which php${{ matrix.php }})
|
||||
- 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
|
||||
|
||||
# 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
|
||||
|
||||
- name: Install Composer dependencies
|
||||
run: composer install
|
||||
|
1
.gitignore
vendored
1
.gitignore
vendored
@@ -7,3 +7,4 @@ Thumbs.db
|
||||
/tests/integration/tmp
|
||||
.vagrant
|
||||
.idea/*
|
||||
.vscode
|
||||
|
165
CHANGELOG.md
165
CHANGELOG.md
@@ -1,5 +1,170 @@
|
||||
# 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
|
||||
|
@@ -6,27 +6,9 @@
|
||||
"license": "MIT",
|
||||
"authors": [
|
||||
{
|
||||
"name": "Franz Liedke",
|
||||
"email": "franz@develophp.org"
|
||||
},
|
||||
{
|
||||
"name": "Daniel Klabbers",
|
||||
"email": "daniel@klabbers.email",
|
||||
"homepage": "https://luceos.com"
|
||||
},
|
||||
{
|
||||
"name": "David Sevilla Martin",
|
||||
"email": "me+flarum@datitisev.me",
|
||||
"homepage": "https://datitisev.me"
|
||||
},
|
||||
{
|
||||
"name": "Clark Winkelmann",
|
||||
"email": "clark.winkelmann@gmail.com",
|
||||
"homepage": "https://clarkwinkelmann.com"
|
||||
},
|
||||
{
|
||||
"name": "Matthew Kilgore",
|
||||
"email": "matthew@kilgore.dev"
|
||||
"name": "Flarum",
|
||||
"email": "info@flarum.org",
|
||||
"homepage": "https://flarum.org/team"
|
||||
}
|
||||
],
|
||||
"support": {
|
||||
@@ -38,9 +20,9 @@
|
||||
"php": ">=7.2",
|
||||
"axy/sourcemap": "^0.1.4",
|
||||
"components/font-awesome": "^5.14.0",
|
||||
"dflydev/fig-cookies": "^2.0.1",
|
||||
"dflydev/fig-cookies": "^3.0.0",
|
||||
"doctrine/dbal": "^2.7",
|
||||
"franzl/whoops-middleware": "^0.4.0",
|
||||
"franzl/whoops-middleware": "^2.0.0",
|
||||
"illuminate/bus": "^6.0",
|
||||
"illuminate/cache": "^6.0",
|
||||
"illuminate/config": "^6.0",
|
||||
@@ -57,14 +39,14 @@
|
||||
"illuminate/validation": "^6.0",
|
||||
"illuminate/view": "^6.0",
|
||||
"intervention/image": "^2.5.0",
|
||||
"laminas/laminas-diactoros": "^1.8.4",
|
||||
"laminas/laminas-httphandlerrunner": "^1.0",
|
||||
"laminas/laminas-stratigility": "^3.0",
|
||||
"laminas/laminas-diactoros": "^2.4.1",
|
||||
"laminas/laminas-httphandlerrunner": "^1.2.0",
|
||||
"laminas/laminas-stratigility": "^3.2.2",
|
||||
"league/flysystem": "^1.0.11",
|
||||
"matthiasmullie/minify": "^1.3",
|
||||
"middlewares/base-path": "^1.1",
|
||||
"middlewares/base-path-router": "^0.2.1",
|
||||
"middlewares/request-handler": "^1.2",
|
||||
"middlewares/base-path": "^2.0.1",
|
||||
"middlewares/base-path-router": "^2.0.1",
|
||||
"middlewares/request-handler": "^2.0.1",
|
||||
"monolog/monolog": "^1.16.0",
|
||||
"nesbot/carbon": "^2.0",
|
||||
"nikic/fast-route": "^0.6",
|
||||
@@ -72,17 +54,18 @@
|
||||
"psr/http-server-handler": "^1.0",
|
||||
"psr/http-server-middleware": "^1.0",
|
||||
"s9e/text-formatter": "^2.3.6",
|
||||
"symfony/config": "^3.3",
|
||||
"symfony/console": "^4.2",
|
||||
"symfony/event-dispatcher": "^4.3.2",
|
||||
"symfony/translation": "^3.3",
|
||||
"symfony/yaml": "^3.3",
|
||||
"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"
|
||||
},
|
||||
"require-dev": {
|
||||
"mockery/mockery": "^1.0",
|
||||
"phpunit/phpunit": "^7.0"
|
||||
"mockery/mockery": "^1.3.3",
|
||||
"phpunit/phpunit": "^8.0"
|
||||
},
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
|
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
3598
js/package-lock.json
generated
3598
js/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -10,13 +10,13 @@
|
||||
"dayjs": "^1.8.28",
|
||||
"expose-loader": "^0.7.5",
|
||||
"flarum-webpack-config": "0.1.0-beta.10",
|
||||
"jquery": "^3.4.1",
|
||||
"jquery": "^3.5.1",
|
||||
"jquery.hotkeys": "^0.1.0",
|
||||
"lodash-es": "^4.17.14",
|
||||
"m.attrs.bidi": "github:tobscure/m.attrs.bidi",
|
||||
"mithril": "^2.0.4",
|
||||
"punycode": "^2.1.1",
|
||||
"spin.js": "^3.1.0",
|
||||
"textarea-caret": "^3.1.0",
|
||||
"webpack": "^4.43.0",
|
||||
"webpack-cli": "^3.3.11",
|
||||
"webpack-merge": "^4.1.4"
|
||||
|
@@ -4,9 +4,16 @@ 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 {
|
||||
extensionSettings = {};
|
||||
extensionData = new ExtensionData();
|
||||
|
||||
extensionCategories = {
|
||||
feature: 30,
|
||||
theme: 20,
|
||||
language: 10,
|
||||
};
|
||||
|
||||
history = {
|
||||
canGoBack: () => true,
|
||||
@@ -27,27 +34,24 @@ export default class AdminApplication extends Application {
|
||||
* @inheritdoc
|
||||
*/
|
||||
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);
|
||||
|
||||
// Mithril does not render the home route on https://example.com/admin, so
|
||||
// we need to go to https://example.com/admin#/ explicitly.
|
||||
if (!document.location.hash) document.location.hash = '#/';
|
||||
|
||||
m.route.prefix = '#';
|
||||
|
||||
super.mount();
|
||||
|
||||
// If an extension has just been enabled, then we will run its settings
|
||||
// callback.
|
||||
const enabled = localStorage.getItem('enabledExtension');
|
||||
if (enabled && this.extensionSettings[enabled]) {
|
||||
this.extensionSettings[enabled]();
|
||||
localStorage.removeItem('enabledExtension');
|
||||
}
|
||||
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);
|
||||
}
|
||||
|
||||
getRequiredPermissions(permission) {
|
||||
|
@@ -1,19 +1,24 @@
|
||||
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 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 AddExtensionModal from './components/AddExtensionModal';
|
||||
import ExtensionsPage from './components/ExtensionsPage';
|
||||
import AdminLinkButton from './components/AdminLinkButton';
|
||||
import ExtensionPage from './components/ExtensionPage';
|
||||
import ExtensionLinkButton from './components/ExtensionLinkButton';
|
||||
import PermissionGrid from './components/PermissionGrid';
|
||||
import ExtensionPermissionGrid from './components/ExtensionPermissionGrid';
|
||||
import MailPage from './components/MailPage';
|
||||
import UploadImageButton from './components/UploadImageButton';
|
||||
import LoadingModal from './components/LoadingModal';
|
||||
@@ -23,6 +28,7 @@ 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';
|
||||
@@ -30,19 +36,24 @@ 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/StatusWidget': StatusWidget,
|
||||
'components/ExtensionsWidget': ExtensionsWidget,
|
||||
'components/HeaderSecondary': HeaderSecondary,
|
||||
'components/SettingsModal': SettingsModal,
|
||||
'components/DashboardWidget': DashboardWidget,
|
||||
'components/AddExtensionModal': AddExtensionModal,
|
||||
'components/ExtensionsPage': ExtensionsPage,
|
||||
'components/AdminLinkButton': AdminLinkButton,
|
||||
'components/ExtensionPage': ExtensionPage,
|
||||
'components/ExtensionLinkButton': ExtensionLinkButton,
|
||||
'components/PermissionGrid': PermissionGrid,
|
||||
'components/ExtensionPermissionGrid': ExtensionPermissionGrid,
|
||||
'components/MailPage': MailPage,
|
||||
'components/UploadImageButton': UploadImageButton,
|
||||
'components/LoadingModal': LoadingModal,
|
||||
@@ -52,6 +63,7 @@ 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,
|
||||
|
@@ -1,32 +0,0 @@
|
||||
/*
|
||||
* This file is part of Flarum.
|
||||
*
|
||||
* (c) Toby Zerner <toby.zerner@gmail.com>
|
||||
*
|
||||
* For the full copyright and license information, please view the LICENSE
|
||||
* file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
import Modal from '../../common/components/Modal';
|
||||
|
||||
export default class AddExtensionModal extends Modal {
|
||||
className() {
|
||||
return 'AddExtensionModal Modal--small';
|
||||
}
|
||||
|
||||
title() {
|
||||
return app.translator.trans('core.admin.add_extension.title');
|
||||
}
|
||||
|
||||
content() {
|
||||
return (
|
||||
<div className="Modal-body">
|
||||
<p>{app.translator.trans('core.admin.add_extension.temporary_text')}</p>
|
||||
<p>
|
||||
{app.translator.trans('core.admin.add_extension.install_text', { a: <a href="https://discuss.flarum.org/t/extensions" target="_blank" /> })}
|
||||
</p>
|
||||
<p>{app.translator.trans('core.admin.add_extension.developer_text', { a: <a href="http://flarum.org/docs/extend" target="_blank" /> })}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
19
js/src/admin/components/AdminHeader.js
Normal file
19
js/src/admin/components/AdminHeader.js
Normal file
@@ -0,0 +1,19 @@
|
||||
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>,
|
||||
];
|
||||
}
|
||||
}
|
@@ -1,16 +0,0 @@
|
||||
/*
|
||||
* This file is part of Flarum.
|
||||
*
|
||||
* (c) Toby Zerner <toby.zerner@gmail.com>
|
||||
*
|
||||
* For the full copyright and license information, please view the LICENSE
|
||||
* file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
import LinkButton from '../../common/components/LinkButton';
|
||||
|
||||
export default class AdminLinkButton extends LinkButton {
|
||||
getButtonContent(children) {
|
||||
return [...super.getButtonContent(children), <div className="AdminLinkButton-description">{this.attrs.description}</div>];
|
||||
}
|
||||
}
|
@@ -1,106 +1,150 @@
|
||||
/*
|
||||
* 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 ExtensionLinkButton from './ExtensionLinkButton';
|
||||
import Component from '../../common/Component';
|
||||
import AdminLinkButton from './AdminLinkButton';
|
||||
import LinkButton from '../../common/components/LinkButton';
|
||||
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" buttonClassName="Button">
|
||||
{this.items().toArray()}
|
||||
<SelectDropdown className="AdminNav App-titleControl AdminNav-Main" buttonClassName="Button">
|
||||
{this.items().toArray().concat(this.extensionItems().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 links to show in the admin navigation.
|
||||
* Build an item list of main 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',
|
||||
description: app.translator.trans('core.admin.nav.dashboard_text'),
|
||||
},
|
||||
app.translator.trans('core.admin.nav.dashboard_button')
|
||||
)
|
||||
<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',
|
||||
description: app.translator.trans('core.admin.nav.basics_text'),
|
||||
},
|
||||
app.translator.trans('core.admin.nav.basics_button')
|
||||
)
|
||||
<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',
|
||||
description: app.translator.trans('core.admin.nav.email_text'),
|
||||
},
|
||||
app.translator.trans('core.admin.nav.email_button')
|
||||
)
|
||||
<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',
|
||||
description: app.translator.trans('core.admin.nav.permissions_text'),
|
||||
},
|
||||
app.translator.trans('core.admin.nav.permissions_button')
|
||||
)
|
||||
<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',
|
||||
description: app.translator.trans('core.admin.nav.appearance_text'),
|
||||
},
|
||||
app.translator.trans('core.admin.nav.appearance_button')
|
||||
)
|
||||
<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(
|
||||
'extensions',
|
||||
AdminLinkButton.component(
|
||||
{
|
||||
href: app.route('extensions'),
|
||||
icon: 'fas fa-puzzle-piece',
|
||||
description: app.translator.trans('core.admin.nav.extensions_text'),
|
||||
},
|
||||
app.translator.trans('core.admin.nav.extensions_button')
|
||||
)
|
||||
'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]
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
return items;
|
||||
}
|
||||
}
|
||||
|
174
js/src/admin/components/AdminPage.js
Normal file
174
js/src/admin/components/AdminPage.js
Normal file
@@ -0,0 +1,174 @@
|
||||
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,133 +1,120 @@
|
||||
import Page from '../../common/components/Page';
|
||||
import Button from '../../common/components/Button';
|
||||
import Switch from '../../common/components/Switch';
|
||||
import Stream from '../../common/utils/Stream';
|
||||
import EditCustomCssModal from './EditCustomCssModal';
|
||||
import EditCustomHeaderModal from './EditCustomHeaderModal';
|
||||
import EditCustomFooterModal from './EditCustomFooterModal';
|
||||
import UploadImageButton from './UploadImageButton';
|
||||
import saveSettings from '../utils/saveSettings';
|
||||
import AdminPage from './AdminPage';
|
||||
|
||||
export default class AppearancePage extends Page {
|
||||
oninit(vnode) {
|
||||
super.oninit(vnode);
|
||||
|
||||
this.primaryColor = Stream(app.data.settings.theme_primary_color);
|
||||
this.secondaryColor = Stream(app.data.settings.theme_secondary_color);
|
||||
this.darkMode = Stream(app.data.settings.theme_dark_mode);
|
||||
this.coloredHeader = Stream(app.data.settings.theme_colored_header);
|
||||
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'),
|
||||
};
|
||||
}
|
||||
|
||||
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>
|
||||
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>
|
||||
|
||||
<div className="AppearancePage-colors-input">
|
||||
<input className="FormControl" type="text" placeholder="#aaaaaa" bidi={this.primaryColor} />
|
||||
<input className="FormControl" type="text" placeholder="#aaaaaa" bidi={this.secondaryColor} />
|
||||
</div>
|
||||
<div className="AppearancePage-colors-input">
|
||||
{this.buildSettingComponent({
|
||||
type: 'text',
|
||||
setting: 'theme_primary_color',
|
||||
placeholder: '#aaaaaa',
|
||||
})}
|
||||
{this.buildSettingComponent({
|
||||
type: 'text',
|
||||
setting: 'theme_secondary_color',
|
||||
placeholder: '#aaaaaa',
|
||||
})}
|
||||
</div>
|
||||
|
||||
{Switch.component(
|
||||
{
|
||||
state: this.darkMode(),
|
||||
onchange: this.darkMode,
|
||||
},
|
||||
app.translator.trans('core.admin.appearance.dark_mode_label')
|
||||
)}
|
||||
{this.buildSettingComponent({
|
||||
type: 'switch',
|
||||
setting: 'theme_dark_mode',
|
||||
label: app.translator.trans('core.admin.appearance.dark_mode_label'),
|
||||
})}
|
||||
|
||||
{Switch.component(
|
||||
{
|
||||
state: this.coloredHeader(),
|
||||
onchange: this.coloredHeader,
|
||||
},
|
||||
app.translator.trans('core.admin.appearance.colored_header_label')
|
||||
)}
|
||||
{this.buildSettingComponent({
|
||||
type: 'switch',
|
||||
setting: 'theme_colored_header',
|
||||
label: app.translator.trans('core.admin.appearance.colored_header_label'),
|
||||
})}
|
||||
|
||||
{Button.component(
|
||||
{
|
||||
className: 'Button Button--primary',
|
||||
type: 'submit',
|
||||
loading: this.loading,
|
||||
},
|
||||
app.translator.trans('core.admin.appearance.submit_button')
|
||||
)}
|
||||
</fieldset>
|
||||
</form>
|
||||
{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.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.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_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_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>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
<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>,
|
||||
];
|
||||
}
|
||||
|
||||
onsubmit(e) {
|
||||
onsaved() {
|
||||
window.location.reload();
|
||||
}
|
||||
|
||||
saveSettings(e) {
|
||||
e.preventDefault();
|
||||
|
||||
const hex = /^#[0-9a-f]{3}([0-9a-f]{3})?$/i;
|
||||
|
||||
if (!hex.test(this.primaryColor()) || !hex.test(this.secondaryColor())) {
|
||||
if (!hex.test(this.settings['theme_primary_color']()) || !hex.test(this.settings['theme_secondary_color']())) {
|
||||
alert(app.translator.trans('core.admin.appearance.enter_hex_message'));
|
||||
return;
|
||||
}
|
||||
|
||||
this.loading = true;
|
||||
|
||||
saveSettings({
|
||||
theme_primary_color: this.primaryColor(),
|
||||
theme_secondary_color: this.secondaryColor(),
|
||||
theme_dark_mode: this.darkMode(),
|
||||
theme_colored_header: this.coloredHeader(),
|
||||
}).then(() => window.location.reload());
|
||||
super.saveSettings(e);
|
||||
}
|
||||
}
|
||||
|
@@ -1,34 +1,11 @@
|
||||
import Page from '../../common/components/Page';
|
||||
import FieldSet from '../../common/components/FieldSet';
|
||||
import Select from '../../common/components/Select';
|
||||
import Button from '../../common/components/Button';
|
||||
import saveSettings from '../utils/saveSettings';
|
||||
import ItemList from '../../common/utils/ItemList';
|
||||
import Switch from '../../common/components/Switch';
|
||||
import Stream from '../../common/utils/Stream';
|
||||
import withAttr from '../../common/utils/withAttr';
|
||||
import AdminPage from './AdminPage';
|
||||
|
||||
export default class BasicsPage extends Page {
|
||||
export default class BasicsPage extends AdminPage {
|
||||
oninit(vnode) {
|
||||
super.oninit(vnode);
|
||||
|
||||
this.loading = false;
|
||||
|
||||
this.fields = [
|
||||
'forum_title',
|
||||
'forum_description',
|
||||
'default_locale',
|
||||
'show_language_selector',
|
||||
'default_route',
|
||||
'welcome_title',
|
||||
'welcome_message',
|
||||
'display_name_driver',
|
||||
];
|
||||
this.values = {};
|
||||
|
||||
const settings = app.data.settings;
|
||||
this.fields.forEach((key) => (this.values[key] = Stream(settings[key])));
|
||||
|
||||
this.localeOptions = {};
|
||||
const locales = app.data.locales;
|
||||
for (const i in locales) {
|
||||
@@ -41,125 +18,101 @@ export default class BasicsPage extends Page {
|
||||
this.displayNameOptions[identifier] = identifier;
|
||||
}, this);
|
||||
|
||||
if (!this.values.display_name_driver() && displayNameDrivers.includes('username')) this.values.display_name_driver('username');
|
||||
this.slugDriverOptions = {};
|
||||
Object.keys(app.data.slugDrivers).forEach((model) => {
|
||||
this.slugDriverOptions[model] = {};
|
||||
|
||||
if (typeof this.values.show_language_selector() !== 'number') this.values.show_language_selector(1);
|
||||
app.data.slugDrivers[model].forEach((option) => {
|
||||
this.slugDriverOptions[model][option] = option;
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
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'),
|
||||
},
|
||||
[<input className="FormControl" bidi={this.values.forum_title} />]
|
||||
)}
|
||||
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'),
|
||||
};
|
||||
}
|
||||
|
||||
{FieldSet.component(
|
||||
{
|
||||
label: app.translator.trans('core.admin.basics.forum_description_heading'),
|
||||
},
|
||||
[
|
||||
<div className="helpText">{app.translator.trans('core.admin.basics.forum_description_text')}</div>,
|
||||
<textarea className="FormControl" bidi={this.values.forum_description} />,
|
||||
]
|
||||
)}
|
||||
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'),
|
||||
})}
|
||||
|
||||
{Object.keys(this.localeOptions).length > 1
|
||||
? FieldSet.component(
|
||||
{
|
||||
label: app.translator.trans('core.admin.basics.default_language_heading'),
|
||||
},
|
||||
[
|
||||
Select.component({
|
||||
options: this.localeOptions,
|
||||
value: this.values.default_locale(),
|
||||
onchange: this.values.default_locale,
|
||||
}),
|
||||
Switch.component(
|
||||
{
|
||||
state: this.values.show_language_selector(),
|
||||
onchange: this.values.show_language_selector,
|
||||
},
|
||||
app.translator.trans('core.admin.basics.show_language_selector_label')
|
||||
),
|
||||
]
|
||||
)
|
||||
: ''}
|
||||
{Object.keys(this.localeOptions).length > 1
|
||||
? [
|
||||
this.buildSettingComponent({
|
||||
type: 'select',
|
||||
setting: 'default_locale',
|
||||
options: this.localeOptions,
|
||||
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'),
|
||||
}),
|
||||
]
|
||||
: ''}
|
||||
|
||||
{FieldSet.component(
|
||||
{
|
||||
label: app.translator.trans('core.admin.basics.home_page_heading'),
|
||||
className: 'BasicsPage-homePage',
|
||||
},
|
||||
[
|
||||
<div className="helpText">{app.translator.trans('core.admin.basics.home_page_text')}</div>,
|
||||
this.homePageItems()
|
||||
.toArray()
|
||||
.map(({ path, label }) => (
|
||||
<label className="checkbox">
|
||||
<input
|
||||
type="radio"
|
||||
name="homePage"
|
||||
value={path}
|
||||
checked={this.values.default_route() === path}
|
||||
onclick={withAttr('value', this.values.default_route)}
|
||||
/>
|
||||
{label}
|
||||
</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.welcome_banner_heading'),
|
||||
className: 'BasicsPage-welcomeBanner',
|
||||
},
|
||||
[
|
||||
<div className="helpText">{app.translator.trans('core.admin.basics.welcome_banner_text')}</div>,
|
||||
<div className="BasicsPage-welcomeBanner-input">
|
||||
<input className="FormControl" bidi={this.values.welcome_title} />
|
||||
<textarea className="FormControl" bidi={this.values.welcome_message} />
|
||||
</div>,
|
||||
]
|
||||
)}
|
||||
|
||||
{Object.keys(this.displayNameOptions).length > 1
|
||||
? FieldSet.component(
|
||||
{
|
||||
label: app.translator.trans('core.admin.basics.display_name_heading'),
|
||||
},
|
||||
[
|
||||
<div className="helpText">{app.translator.trans('core.admin.basics.display_name_text')}</div>,
|
||||
Select.component({
|
||||
options: this.displayNameOptions,
|
||||
bidi: this.values.display_name_driver,
|
||||
}),
|
||||
]
|
||||
)
|
||||
: ''}
|
||||
|
||||
{Button.component(
|
||||
{
|
||||
type: 'submit',
|
||||
className: 'Button Button--primary',
|
||||
loading: this.loading,
|
||||
disabled: !this.changed(),
|
||||
},
|
||||
app.translator.trans('core.admin.basics.submit_button')
|
||||
)}
|
||||
</form>
|
||||
<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')} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
changed() {
|
||||
return this.fields.some((key) => this.values[key]() !== app.data.settings[key]);
|
||||
{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>,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -179,27 +132,4 @@ export default class BasicsPage extends Page {
|
||||
|
||||
return items;
|
||||
}
|
||||
|
||||
onsubmit(e) {
|
||||
e.preventDefault();
|
||||
|
||||
if (this.loading) return;
|
||||
|
||||
this.loading = true;
|
||||
app.alerts.dismiss(this.successAlert);
|
||||
|
||||
const settings = {};
|
||||
|
||||
this.fields.forEach((key) => (settings[key] = this.values[key]()));
|
||||
|
||||
saveSettings(settings)
|
||||
.then(() => {
|
||||
this.successAlert = app.alerts.show({ type: 'success' }, app.translator.trans('core.admin.basics.saved_message'));
|
||||
})
|
||||
.catch(() => {})
|
||||
.then(() => {
|
||||
this.loading = false;
|
||||
m.redraw();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
@@ -1,16 +1,29 @@
|
||||
import Page from '../../common/components/Page';
|
||||
import StatusWidget from './StatusWidget';
|
||||
import ExtensionsWidget from './ExtensionsWidget';
|
||||
import ItemList from '../../common/utils/ItemList';
|
||||
import AdminPage from './AdminPage';
|
||||
|
||||
export default class DashboardPage extends Page {
|
||||
view() {
|
||||
return (
|
||||
<div className="DashboardPage">
|
||||
<div className="container">{this.availableWidgets()}</div>
|
||||
</div>
|
||||
);
|
||||
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();
|
||||
}
|
||||
|
||||
availableWidgets() {
|
||||
return [<StatusWidget />];
|
||||
const items = new ItemList();
|
||||
|
||||
items.add('status', <StatusWidget />, 30);
|
||||
|
||||
items.add('extensions', <ExtensionsWidget />, 10);
|
||||
|
||||
return items;
|
||||
}
|
||||
}
|
||||
|
29
js/src/admin/components/ExtensionLinkButton.js
Normal file
29
js/src/admin/components/ExtensionLinkButton.js
Normal file
@@ -0,0 +1,29 @@
|
||||
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;
|
||||
}
|
||||
}
|
248
js/src/admin/components/ExtensionPage.js
Normal file
248
js/src/admin/components/ExtensionPage.js
Normal file
@@ -0,0 +1,248 @@
|
||||
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(', '),
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
53
js/src/admin/components/ExtensionPermissionGrid.js
Normal file
53
js/src/admin/components/ExtensionPermissionGrid.js
Normal file
@@ -0,0 +1,53 @@
|
||||
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;
|
||||
}
|
||||
}
|
@@ -1,154 +0,0 @@
|
||||
import Page from '../../common/components/Page';
|
||||
import Button from '../../common/components/Button';
|
||||
import Dropdown from '../../common/components/Dropdown';
|
||||
import AddExtensionModal from './AddExtensionModal';
|
||||
import LoadingModal from './LoadingModal';
|
||||
import ItemList from '../../common/utils/ItemList';
|
||||
import icon from '../../common/helpers/icon';
|
||||
|
||||
export default class ExtensionsPage extends Page {
|
||||
view() {
|
||||
return (
|
||||
<div className="ExtensionsPage">
|
||||
<div className="ExtensionsPage-header">
|
||||
<div className="container">
|
||||
{Button.component(
|
||||
{
|
||||
icon: 'fas fa-plus',
|
||||
className: 'Button Button--primary',
|
||||
onclick: () => app.modal.show(AddExtensionModal),
|
||||
},
|
||||
app.translator.trans('core.admin.extensions.add_button')
|
||||
)}
|
||||
</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',
|
||||
onclick: app.extensionSettings[name],
|
||||
},
|
||||
app.translator.trans('core.admin.extensions.settings_button')
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
if (!enabled) {
|
||||
items.add(
|
||||
'uninstall',
|
||||
Button.component(
|
||||
{
|
||||
icon: 'far fa-trash-alt',
|
||||
onclick: () => {
|
||||
app
|
||||
.request({
|
||||
url: app.forum.attribute('apiUrl') + '/extensions/' + name,
|
||||
method: 'DELETE',
|
||||
})
|
||||
.then(() => window.location.reload());
|
||||
|
||||
app.modal.show(LoadingModal);
|
||||
},
|
||||
},
|
||||
app.translator.trans('core.admin.extensions.uninstall_button')
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
return items;
|
||||
}
|
||||
|
||||
isEnabled(name) {
|
||||
const enabled = JSON.parse(app.data.settings.extensions_enabled);
|
||||
|
||||
return enabled.indexOf(name) !== -1;
|
||||
}
|
||||
|
||||
toggle(id) {
|
||||
const enabled = this.isEnabled(id);
|
||||
|
||||
app
|
||||
.request({
|
||||
url: app.forum.attribute('apiUrl') + '/extensions/' + id,
|
||||
method: 'PATCH',
|
||||
body: { enabled: !enabled },
|
||||
errorHandler: this.onerror.bind(this),
|
||||
})
|
||||
.then(() => {
|
||||
if (!enabled) localStorage.setItem('enabledExtension', id);
|
||||
window.location.reload();
|
||||
});
|
||||
|
||||
app.modal.show(LoadingModal);
|
||||
}
|
||||
|
||||
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();
|
||||
|
||||
const error = JSON.parse(e.responseText).errors[0];
|
||||
|
||||
app.alerts.show(
|
||||
{ type: 'error' },
|
||||
app.translator.trans(`core.lib.error.${error.code}_message`, {
|
||||
extension: error.extension,
|
||||
extensions: error.extensions.join(', '),
|
||||
})
|
||||
);
|
||||
}, 250);
|
||||
}
|
||||
}
|
51
js/src/admin/components/ExtensionsWidget.js
Normal file
51
js/src/admin/components/ExtensionsWidget.js
Normal file
@@ -0,0 +1,51 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
}
|
@@ -1,4 +1,5 @@
|
||||
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';
|
||||
@@ -19,6 +20,13 @@ 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,32 +1,31 @@
|
||||
import Page from '../../common/components/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 saveSettings from '../utils/saveSettings';
|
||||
import Stream from '../../common/utils/Stream';
|
||||
import AdminPage from './AdminPage';
|
||||
|
||||
export default class MailPage extends Page {
|
||||
export default class MailPage extends AdminPage {
|
||||
oninit(vnode) {
|
||||
super.oninit(vnode);
|
||||
|
||||
this.saving = false;
|
||||
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.driverFields = {};
|
||||
this.fields = ['mail_driver', 'mail_from'];
|
||||
this.values = {};
|
||||
this.status = { sending: false, errors: {} };
|
||||
|
||||
const settings = app.data.settings;
|
||||
this.fields.forEach((key) => (this.values[key] = Stream(settings[key])));
|
||||
|
||||
app
|
||||
.request({
|
||||
method: 'GET',
|
||||
@@ -37,150 +36,78 @@ export default class MailPage extends Page {
|
||||
this.status.sending = response['data']['attributes']['sending'];
|
||||
this.status.errors = response['data']['attributes']['errors'];
|
||||
|
||||
for (const driver in this.driverFields) {
|
||||
for (const field in this.driverFields[driver]) {
|
||||
this.fields.push(field);
|
||||
this.values[field] = Stream(settings[field]);
|
||||
}
|
||||
}
|
||||
|
||||
this.loading = false;
|
||||
m.redraw();
|
||||
});
|
||||
}
|
||||
|
||||
view() {
|
||||
if (this.loading || this.saving) {
|
||||
return (
|
||||
<div className="MailPage">
|
||||
<div className="container">
|
||||
<LoadingIndicator />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
content() {
|
||||
if (this.loading) {
|
||||
return <LoadingIndicator />;
|
||||
}
|
||||
|
||||
const fields = this.driverFields[this.values.mail_driver()];
|
||||
const fields = this.driverFields[this.setting('mail_driver')()];
|
||||
const fieldKeys = Object.keys(fields);
|
||||
|
||||
return (
|
||||
<div className="MailPage">
|
||||
<div className="container">
|
||||
<form onsubmit={this.onsubmit.bind(this)}>
|
||||
<h2>{app.translator.trans('core.admin.email.heading')}</h2>
|
||||
<div className="helpText">{app.translator.trans('core.admin.email.text')}</div>
|
||||
<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')
|
||||
)}
|
||||
|
||||
{FieldSet.component(
|
||||
{
|
||||
label: app.translator.trans('core.admin.email.addresses_heading'),
|
||||
className: 'MailPage-MailSettings',
|
||||
},
|
||||
[
|
||||
<div className="MailPage-MailSettings-input">
|
||||
<label>
|
||||
{app.translator.trans('core.admin.email.from_label')}
|
||||
<input className="FormControl" bidi={this.values.mail_from} />
|
||||
</label>
|
||||
</div>,
|
||||
]
|
||||
)}
|
||||
{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];
|
||||
|
||||
{FieldSet.component(
|
||||
{
|
||||
label: app.translator.trans('core.admin.email.driver_heading'),
|
||||
className: 'MailPage-MailSettings',
|
||||
},
|
||||
[
|
||||
<div className="MailPage-MailSettings-input">
|
||||
<label>
|
||||
{app.translator.trans('core.admin.email.driver_label')}
|
||||
<Select
|
||||
value={this.values.mail_driver()}
|
||||
options={Object.keys(this.driverFields).reduce((memo, val) => ({ ...memo, [val]: val }), {})}
|
||||
onchange={this.values.mail_driver}
|
||||
/>
|
||||
</label>
|
||||
</div>,
|
||||
]
|
||||
)}
|
||||
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>
|
||||
</FieldSet>
|
||||
)}
|
||||
{this.submitButton()}
|
||||
|
||||
{this.status.sending ||
|
||||
Alert.component(
|
||||
{
|
||||
dismissible: false,
|
||||
},
|
||||
app.translator.trans('core.admin.email.not_sending_message')
|
||||
)}
|
||||
|
||||
{fieldKeys.length > 0 &&
|
||||
FieldSet.component(
|
||||
{
|
||||
label: app.translator.trans(`core.admin.email.${this.values.mail_driver()}_heading`),
|
||||
className: 'MailPage-MailSettings',
|
||||
},
|
||||
[
|
||||
<div className="MailPage-MailSettings-input">
|
||||
{fieldKeys.map((field) => [
|
||||
<label>
|
||||
{app.translator.trans(`core.admin.email.${field}_label`)}
|
||||
{this.renderField(field)}
|
||||
</label>,
|
||||
this.status.errors[field] && <p className="ValidationError">{this.status.errors[field]}</p>,
|
||||
])}
|
||||
</div>,
|
||||
]
|
||||
)}
|
||||
|
||||
<FieldSet>
|
||||
{Button.component(
|
||||
{
|
||||
type: 'submit',
|
||||
className: 'Button Button--primary',
|
||||
disabled: !this.changed(),
|
||||
},
|
||||
app.translator.trans('core.admin.email.submit_button')
|
||||
)}
|
||||
</FieldSet>
|
||||
|
||||
{FieldSet.component(
|
||||
{
|
||||
label: app.translator.trans('core.admin.email.send_test_mail_heading'),
|
||||
className: 'MailPage-MailSettings',
|
||||
},
|
||||
[
|
||||
<div className="helpText">{app.translator.trans('core.admin.email.send_test_mail_text', { email: app.session.user.email() })}</div>,
|
||||
Button.component(
|
||||
{
|
||||
className: 'Button Button--primary',
|
||||
disabled: this.sendingTest || this.changed(),
|
||||
onclick: () => this.sendTestEmail(),
|
||||
},
|
||||
app.translator.trans('core.admin.email.send_test_mail_button')
|
||||
),
|
||||
]
|
||||
)}
|
||||
</form>
|
||||
</div>
|
||||
<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(
|
||||
{
|
||||
className: 'Button Button--primary',
|
||||
disabled: this.sendingTest || this.isChanged(),
|
||||
onclick: () => this.sendTestEmail(),
|
||||
},
|
||||
app.translator.trans('core.admin.email.send_test_mail_button')
|
||||
)}
|
||||
</FieldSet>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
renderField(name) {
|
||||
const driver = this.values.mail_driver();
|
||||
const field = this.driverFields[driver][name];
|
||||
const prop = this.values[name];
|
||||
|
||||
if (typeof field === 'string') {
|
||||
return <input className="FormControl" bidi={prop} />;
|
||||
} else {
|
||||
return <Select value={prop()} options={field} onchange={prop} />;
|
||||
}
|
||||
}
|
||||
|
||||
changed() {
|
||||
return this.fields.some((key) => this.values[key]() !== app.data.settings[key]);
|
||||
}
|
||||
|
||||
sendTestEmail() {
|
||||
if (this.saving || this.sendingTest) return;
|
||||
|
||||
@@ -203,26 +130,7 @@ export default class MailPage extends Page {
|
||||
});
|
||||
}
|
||||
|
||||
onsubmit(e) {
|
||||
e.preventDefault();
|
||||
|
||||
if (this.saving || this.sendingTest) return;
|
||||
|
||||
this.saving = true;
|
||||
app.alerts.dismiss(this.successAlert);
|
||||
|
||||
const settings = {};
|
||||
|
||||
this.fields.forEach((key) => (settings[key] = this.values[key]()));
|
||||
|
||||
saveSettings(settings)
|
||||
.then(() => {
|
||||
this.successAlert = app.alerts.show({ type: 'success' }, app.translator.trans('core.admin.basics.saved_message'));
|
||||
})
|
||||
.catch(() => {})
|
||||
.then(() => {
|
||||
this.saving = false;
|
||||
this.refresh();
|
||||
});
|
||||
saveSettings(e) {
|
||||
super.saveSettings(e).then(this.refresh());
|
||||
}
|
||||
}
|
||||
|
@@ -6,12 +6,6 @@ import ItemList from '../../common/utils/ItemList';
|
||||
import icon from '../../common/helpers/icon';
|
||||
|
||||
export default class PermissionGrid extends Component {
|
||||
oninit(vnode) {
|
||||
super.oninit(vnode);
|
||||
|
||||
this.permissions = this.permissionItems().toArray();
|
||||
}
|
||||
|
||||
view() {
|
||||
const scopes = this.scopeItems().toArray();
|
||||
|
||||
@@ -35,25 +29,27 @@ export default class PermissionGrid extends Component {
|
||||
<th>{this.scopeControlItems().toArray()}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
{this.permissions.map((section) => (
|
||||
<tbody>
|
||||
<tr className="PermissionGrid-section">
|
||||
<th>{section.label}</th>
|
||||
{permissionCells(section)}
|
||||
<td />
|
||||
</tr>
|
||||
{section.children.map((child) => (
|
||||
<tr className="PermissionGrid-child">
|
||||
<th>
|
||||
{icon(child.icon)}
|
||||
{child.label}
|
||||
</th>
|
||||
{permissionCells(child)}
|
||||
{this.permissionItems()
|
||||
.toArray()
|
||||
.map((section) => (
|
||||
<tbody>
|
||||
<tr className="PermissionGrid-section">
|
||||
<th>{section.label}</th>
|
||||
{permissionCells(section)}
|
||||
<td />
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
))}
|
||||
{section.children.map((child) => (
|
||||
<tr className="PermissionGrid-child">
|
||||
<th>
|
||||
{icon(child.icon)}
|
||||
{child.label}
|
||||
</th>
|
||||
{permissionCells(child)}
|
||||
<td />
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
))}
|
||||
</table>
|
||||
);
|
||||
}
|
||||
@@ -158,6 +154,8 @@ export default class PermissionGrid extends Component {
|
||||
permission: 'user.viewLastSeenAt',
|
||||
});
|
||||
|
||||
items.merge(app.extensionData.getAllExtensionPermissions('view'));
|
||||
|
||||
return items;
|
||||
}
|
||||
|
||||
@@ -198,6 +196,8 @@ export default class PermissionGrid extends Component {
|
||||
90
|
||||
);
|
||||
|
||||
items.merge(app.extensionData.getAllExtensionPermissions('start'));
|
||||
|
||||
return items;
|
||||
}
|
||||
|
||||
@@ -238,6 +238,8 @@ export default class PermissionGrid extends Component {
|
||||
90
|
||||
);
|
||||
|
||||
items.merge(app.extensionData.getAllExtensionPermissions('reply'));
|
||||
|
||||
return items;
|
||||
}
|
||||
|
||||
@@ -325,15 +327,37 @@ export default class PermissionGrid extends Component {
|
||||
);
|
||||
|
||||
items.add(
|
||||
'userEdit',
|
||||
'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'));
|
||||
|
||||
return items;
|
||||
}
|
||||
|
||||
|
@@ -1,40 +1,43 @@
|
||||
import Page from '../../common/components/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 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(EditGroupModal, { group })}>
|
||||
{GroupBadge.component({
|
||||
group,
|
||||
className: 'Group-icon',
|
||||
label: null,
|
||||
})}
|
||||
<span className="Group-name">{group.namePlural()}</span>
|
||||
</button>
|
||||
))}
|
||||
<button className="Button Group Group--add" onclick={() => app.modal.show(EditGroupModal)}>
|
||||
{icon('fas fa-plus', { className: 'Group-icon' })}
|
||||
<span className="Group-name">{app.translator.trans('core.admin.permissions.new_group_button')}</span>
|
||||
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>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
<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 className="PermissionsPage-permissions">
|
||||
<div className="container">{PermissionGrid.component()}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
<div className="PermissionsPage-permissions">{PermissionGrid.component()}</div>,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
@@ -10,8 +10,9 @@ export { app };
|
||||
// Export public API
|
||||
|
||||
// Export compat API
|
||||
import compat from './compat';
|
||||
import compatObj from './compat';
|
||||
import proxifyCompat from '../common/utils/proxifyCompat';
|
||||
|
||||
compat.app = app;
|
||||
compatObj.app = app;
|
||||
|
||||
export { compat };
|
||||
export const compat = proxifyCompat(compatObj, 'admin');
|
||||
|
19
js/src/admin/resolvers/ExtensionPageResolver.ts
Normal file
19
js/src/admin/resolvers/ExtensionPageResolver.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
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,8 +2,9 @@ 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.
|
||||
@@ -16,7 +17,7 @@ export default function (app) {
|
||||
basics: { path: '/basics', component: BasicsPage },
|
||||
permissions: { path: '/permissions', component: PermissionsPage },
|
||||
appearance: { path: '/appearance', component: AppearancePage },
|
||||
extensions: { path: '/extensions', component: ExtensionsPage },
|
||||
mail: { path: '/mail', component: MailPage },
|
||||
extension: { path: '/extension/:id', component: ExtensionPage, resolverClass: ExtensionPageResolver },
|
||||
};
|
||||
}
|
||||
|
177
js/src/admin/utils/ExtensionData.js
Normal file
177
js/src/admin/utils/ExtensionData.js
Normal file
@@ -0,0 +1,177 @@
|
||||
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;
|
||||
}
|
||||
}
|
25
js/src/admin/utils/getCategorizedExtensions.js
Normal file
25
js/src/admin/utils/getCategorizedExtensions.js
Normal file
@@ -0,0 +1,25 @@
|
||||
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;
|
||||
}
|
5
js/src/admin/utils/isExtensionEnabled.js
Normal file
5
js/src/admin/utils/isExtensionEnabled.js
Normal file
@@ -0,0 +1,5 @@
|
||||
export default function isExtensionEnabled(name) {
|
||||
const enabled = JSON.parse(app.data.settings.extensions_enabled);
|
||||
|
||||
return enabled.includes(name);
|
||||
}
|
@@ -198,13 +198,19 @@ export default class Application {
|
||||
m.route(document.getElementById('content'), basePath + '/', mapRoutes(this.routes, basePath));
|
||||
|
||||
// Add a class to the body which indicates that the page has been scrolled
|
||||
// down.
|
||||
new ScrollListener((top) => {
|
||||
// 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) => {
|
||||
const $app = $('#app');
|
||||
const offset = $app.offset().top;
|
||||
|
||||
$app.toggleClass('affix', top >= offset).toggleClass('scrolled', top > offset);
|
||||
}).start();
|
||||
$('.App-header').toggleClass('navbar-fixed-top', top >= offset);
|
||||
});
|
||||
|
||||
scrollListener.start();
|
||||
scrollListener.update();
|
||||
|
||||
$(() => {
|
||||
$('body').addClass('ontouchstart' in window ? 'touch' : 'no-touch');
|
||||
@@ -264,7 +270,7 @@ export default class Application {
|
||||
|
||||
updateTitle() {
|
||||
const count = this.titleCount ? `(${this.titleCount}) ` : '';
|
||||
const pageTitleWithSeparator = this.title && m.route.get() !== '/' ? this.title + ' - ' : '';
|
||||
const pageTitleWithSeparator = this.title && m.route.get() !== this.forum.attribute('basePath') + '/' ? this.title + ' - ' : '';
|
||||
const title = this.forum.attribute('title');
|
||||
document.title = count + pageTitleWithSeparator + title;
|
||||
}
|
||||
@@ -272,7 +278,7 @@ export default class Application {
|
||||
/**
|
||||
* Make an AJAX request, handling any low-level errors that may occur.
|
||||
*
|
||||
* @see https://lhorie.github.io/mithril/mithril.request.html
|
||||
* @see https://mithril.js.org/request.html
|
||||
* @param {Object} options
|
||||
* @return {Promise}
|
||||
* @public
|
||||
|
@@ -1,8 +1,5 @@
|
||||
import * as Mithril from 'mithril';
|
||||
|
||||
let deprecatedPropsWarned = false;
|
||||
let deprecatedInitPropsWarned = false;
|
||||
|
||||
export interface ComponentAttrs extends Mithril.Attributes {}
|
||||
|
||||
/**
|
||||
@@ -131,38 +128,5 @@ export default abstract class Component<T extends ComponentAttrs = ComponentAttr
|
||||
*
|
||||
* This can be used to assign default values for missing, optional attrs.
|
||||
*/
|
||||
protected static initAttrs<T>(attrs: T): void {
|
||||
// Deprecated, part of Mithril 2 BC layer
|
||||
if ('initProps' in this && !deprecatedInitPropsWarned) {
|
||||
deprecatedInitPropsWarned = true;
|
||||
console.warn('initProps is deprecated, please use initAttrs instead.');
|
||||
(this as any).initProps(attrs);
|
||||
}
|
||||
}
|
||||
|
||||
// BEGIN DEPRECATED MITHRIL 2 BC LAYER
|
||||
|
||||
/**
|
||||
* The attributes passed into the component.
|
||||
*
|
||||
* @see https://mithril.js.org/components.html#passing-data-to-components
|
||||
*
|
||||
* @deprecated, use attrs instead.
|
||||
*/
|
||||
get props() {
|
||||
if (!deprecatedPropsWarned) {
|
||||
deprecatedPropsWarned = true;
|
||||
console.warn('this.props is deprecated, please use this.attrs instead.');
|
||||
}
|
||||
return this.attrs;
|
||||
}
|
||||
set props(props) {
|
||||
if (!deprecatedPropsWarned) {
|
||||
deprecatedPropsWarned = true;
|
||||
console.warn('this.props is deprecated, please use this.attrs instead.');
|
||||
}
|
||||
this.attrs = props;
|
||||
}
|
||||
|
||||
// END DEPRECATED MITHRIL 2 BC LAYER
|
||||
protected static initAttrs<T>(attrs: T): void {}
|
||||
}
|
||||
|
@@ -20,6 +20,7 @@ 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';
|
||||
@@ -67,6 +68,7 @@ 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,
|
||||
@@ -91,6 +93,7 @@ export default {
|
||||
'utils/subclassOf': subclassOf,
|
||||
'utils/setRouteWithForcedRefresh': setRouteWithForcedRefresh,
|
||||
'utils/patchMithril': patchMithril,
|
||||
'utils/proxifyCompat': proxifyCompat,
|
||||
'utils/classList': classList,
|
||||
'utils/extractText': extractText,
|
||||
'utils/formatNumber': formatNumber,
|
||||
@@ -138,4 +141,5 @@ export default {
|
||||
'helpers/username': username,
|
||||
'helpers/userOnline': userOnline,
|
||||
'helpers/listItems': listItems,
|
||||
'resolvers/DefaultResolver': DefaultResolver,
|
||||
};
|
||||
|
@@ -35,6 +35,11 @@ export default class Button extends Component {
|
||||
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);
|
||||
|
@@ -12,12 +12,14 @@ import Link from './Link';
|
||||
* 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;
|
||||
}
|
||||
|
||||
view(vnode) {
|
||||
|
@@ -35,7 +35,8 @@ export default class Modal extends Component {
|
||||
this.attrs.animateHide();
|
||||
// Here, we ensure that the animation has time to complete.
|
||||
// See https://mithril.js.org/lifecycle-methods.html#onbeforeremove
|
||||
return new Promise((resolve) => setTimeout(resolve, 1000));
|
||||
// Bootstrap's Modal.TRANSITION_DURATION is 300 ms.
|
||||
return new Promise((resolve) => setTimeout(resolve, 300));
|
||||
}
|
||||
}
|
||||
|
||||
|
@@ -10,7 +10,8 @@ export default class Page extends Component {
|
||||
oninit(vnode) {
|
||||
super.oninit(vnode);
|
||||
|
||||
this.onNewRoute();
|
||||
app.previous = app.current;
|
||||
app.current = new PageState(this.constructor, { routeName: this.attrs.routeName });
|
||||
|
||||
app.drawer.hide();
|
||||
app.modal.close();
|
||||
@@ -21,17 +22,20 @@ export default class Page extends Component {
|
||||
* @type {String}
|
||||
*/
|
||||
this.bodyClass = '';
|
||||
}
|
||||
|
||||
/**
|
||||
* A collections of actions to run when the route changes.
|
||||
* This is extracted here, and not hardcoded in oninit, as oninit is not called
|
||||
* when a different route is handled by the same component, but we still need to
|
||||
* adjust the current route name.
|
||||
*/
|
||||
onNewRoute() {
|
||||
app.previous = app.current;
|
||||
app.current = new PageState(this.constructor, { routeName: this.attrs.routeName });
|
||||
/**
|
||||
* 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) {
|
||||
@@ -40,6 +44,14 @@ export default class Page extends Component {
|
||||
if (this.bodyClass) {
|
||||
$('#app').addClass(this.bodyClass);
|
||||
}
|
||||
|
||||
if (this.scrollTopOnCreate) {
|
||||
$(window).scrollTop(0);
|
||||
}
|
||||
|
||||
if ('scrollRestoration' in history) {
|
||||
history.scrollRestoration = this.useBrowserScrollRestoration ? 'auto' : 'manual';
|
||||
}
|
||||
}
|
||||
|
||||
onremove() {
|
||||
|
@@ -12,6 +12,9 @@ import icon from '../helpers/icon';
|
||||
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);
|
||||
}
|
||||
|
@@ -1,11 +1,11 @@
|
||||
import dayjs from 'dayjs';
|
||||
import * as Mithril from 'mithril';
|
||||
|
||||
/**
|
||||
* The `fullTime` helper displays a formatted time string wrapped in a <time>
|
||||
* tag.
|
||||
*
|
||||
* @param {Date} time
|
||||
* @return {Object}
|
||||
*/
|
||||
export default function fullTime(time) {
|
||||
export default function fullTime(time: Date): Mithril.Vnode {
|
||||
const d = dayjs(time);
|
||||
|
||||
const datetime = d.format();
|
@@ -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} 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.
|
||||
* @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.
|
||||
* The string will be truncated surrounding the first match.
|
||||
* @return {Object}
|
||||
*/
|
||||
export default function highlight(string, phrase, length) {
|
||||
export default function highlight(string: string, phrase: string | RegExp, length?: number): Mithril.Vnode<any, any> | string {
|
||||
if (!phrase && !length) return string;
|
||||
|
||||
// Convert the word phrase into a global regular expression (if it isn't
|
@@ -1,14 +1,13 @@
|
||||
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.
|
||||
*
|
||||
* @param {Date} time
|
||||
* @return {Object}
|
||||
*/
|
||||
export default function humanTime(time) {
|
||||
export default function humanTime(time: Date): Mithril.Vnode {
|
||||
const d = dayjs(time);
|
||||
|
||||
const datetime = d.format();
|
@@ -1,12 +0,0 @@
|
||||
/**
|
||||
* 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} />;
|
||||
}
|
13
js/src/common/helpers/icon.tsx
Normal file
13
js/src/common/helpers/icon.tsx
Normal file
@@ -0,0 +1,13 @@
|
||||
import * as Mithril from 'mithril';
|
||||
|
||||
/**
|
||||
* The `icon` helper displays an icon.
|
||||
*
|
||||
* @param fontClass The full icon class, prefix and the icon’s name.
|
||||
* @param attrs Any other attributes to apply.
|
||||
*/
|
||||
export default function icon(fontClass: string, attrs: Mithril.Attributes = {}): Mithril.Vnode {
|
||||
attrs.className = 'icon ' + fontClass + ' ' + (attrs.className || '');
|
||||
|
||||
return <i {...attrs} />;
|
||||
}
|
@@ -1,7 +1,6 @@
|
||||
import 'expose-loader?$!expose-loader?jQuery!jquery';
|
||||
import 'expose-loader?m!mithril';
|
||||
import 'expose-loader?moment!expose-loader?dayjs!dayjs';
|
||||
import 'expose-loader?m.bidi!m.attrs.bidi';
|
||||
import 'expose-loader?dayjs!dayjs';
|
||||
import 'bootstrap/js/affix';
|
||||
import 'bootstrap/js/dropdown';
|
||||
import 'bootstrap/js/modal';
|
||||
|
@@ -10,6 +10,7 @@ export default class User extends Model {}
|
||||
|
||||
Object.assign(User.prototype, {
|
||||
username: Model.attribute('username'),
|
||||
slug: Model.attribute('slug'),
|
||||
displayName: Model.attribute('displayName'),
|
||||
email: Model.attribute('email'),
|
||||
isEmailConfirmed: Model.attribute('isEmailConfirmed'),
|
||||
@@ -29,6 +30,8 @@ Object.assign(User.prototype, {
|
||||
commentCount: Model.attribute('commentCount'),
|
||||
|
||||
canEdit: Model.attribute('canEdit'),
|
||||
canEditCredentials: Model.attribute('canEditCredentials'),
|
||||
canEditGroups: Model.attribute('canEditGroups'),
|
||||
canDelete: Model.attribute('canDelete'),
|
||||
|
||||
avatarColor: null,
|
||||
|
41
js/src/common/resolvers/DefaultResolver.ts
Normal file
41
js/src/common/resolvers/DefaultResolver.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import Mithril from 'mithril';
|
||||
|
||||
/**
|
||||
* Generates a route resolver for a given component.
|
||||
* In addition to regular route resolver functionality:
|
||||
* - It provide the current route name as an attr
|
||||
* - It sets a key on the component so a rerender will be triggered on route change.
|
||||
*/
|
||||
export default class DefaultResolver {
|
||||
component: Mithril.Component;
|
||||
routeName: string;
|
||||
|
||||
constructor(component, routeName) {
|
||||
this.component = component;
|
||||
this.routeName = routeName;
|
||||
}
|
||||
|
||||
/**
|
||||
* When a route change results in a changed key, a full page
|
||||
* rerender occurs. This method can be overriden in subclasses
|
||||
* to prevent rerenders on some route changes.
|
||||
*/
|
||||
makeKey() {
|
||||
return this.routeName + JSON.stringify(m.route.param());
|
||||
}
|
||||
|
||||
makeAttrs(vnode) {
|
||||
return {
|
||||
...vnode.attrs,
|
||||
routeName: this.routeName,
|
||||
};
|
||||
}
|
||||
|
||||
onmatch(args, requestedPath, route) {
|
||||
return this.component;
|
||||
}
|
||||
|
||||
render(vnode) {
|
||||
return [{ ...vnode, attrs: this.makeAttrs(vnode), key: this.makeKey() }];
|
||||
}
|
||||
}
|
@@ -58,7 +58,7 @@ export default class ScrollListener {
|
||||
*/
|
||||
start() {
|
||||
if (!this.active) {
|
||||
window.addEventListener('scroll', (this.active = this.loop.bind(this)));
|
||||
window.addEventListener('scroll', (this.active = this.loop.bind(this)), { passive: true });
|
||||
}
|
||||
}
|
||||
|
||||
|
@@ -28,6 +28,9 @@ export default class SubtreeRetainer {
|
||||
constructor(...callbacks) {
|
||||
this.callbacks = callbacks;
|
||||
this.data = {};
|
||||
// Build the initial data, so it is available when calling
|
||||
// needsRebuild from the onbeforeupdate hook.
|
||||
this.needsRebuild();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -60,6 +63,8 @@ export default class SubtreeRetainer {
|
||||
*/
|
||||
check(...callbacks) {
|
||||
this.callbacks = this.callbacks.concat(callbacks);
|
||||
// Update the data cache when new checks are added.
|
||||
this.needsRebuild();
|
||||
}
|
||||
|
||||
/**
|
||||
|
@@ -1,109 +0,0 @@
|
||||
/**
|
||||
* A textarea wrapper with powerful helpers for text manipulation.
|
||||
*
|
||||
* This wraps a <textarea> DOM element and allows directly manipulating its text
|
||||
* contents and cursor positions.
|
||||
*
|
||||
* I apologize for the pretentious name. :)
|
||||
*/
|
||||
export default class SuperTextarea {
|
||||
/**
|
||||
* @param {HTMLTextAreaElement} textarea
|
||||
*/
|
||||
constructor(textarea) {
|
||||
this.el = textarea;
|
||||
this.$ = $(textarea);
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the value of the text editor.
|
||||
*
|
||||
* @param {String} value
|
||||
*/
|
||||
setValue(value) {
|
||||
this.$.val(value).trigger('input');
|
||||
|
||||
this.el.dispatchEvent(new CustomEvent('input', { bubbles: true, cancelable: true }));
|
||||
}
|
||||
|
||||
/**
|
||||
* Focus the textarea and place the cursor at the given index.
|
||||
*
|
||||
* @param {number} position
|
||||
*/
|
||||
moveCursorTo(position) {
|
||||
this.setSelectionRange(position, position);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the selected range of the textarea.
|
||||
*
|
||||
* @return {Array}
|
||||
*/
|
||||
getSelectionRange() {
|
||||
return [this.el.selectionStart, this.el.selectionEnd];
|
||||
}
|
||||
|
||||
/**
|
||||
* Insert content into the textarea at the position of the cursor.
|
||||
*
|
||||
* @param {String} text
|
||||
*/
|
||||
insertAtCursor(text) {
|
||||
this.insertAt(this.el.selectionStart, text);
|
||||
}
|
||||
|
||||
/**
|
||||
* Insert content into the textarea at the given position.
|
||||
*
|
||||
* @param {number} pos
|
||||
* @param {String} text
|
||||
*/
|
||||
insertAt(pos, text) {
|
||||
this.insertBetween(pos, pos, text);
|
||||
}
|
||||
|
||||
/**
|
||||
* Insert content into the textarea between the given positions.
|
||||
*
|
||||
* If the start and end positions are different, any text between them will be
|
||||
* overwritten.
|
||||
*
|
||||
* @param start
|
||||
* @param end
|
||||
* @param text
|
||||
*/
|
||||
insertBetween(start, end, text) {
|
||||
const value = this.el.value;
|
||||
|
||||
const before = value.slice(0, start);
|
||||
const after = value.slice(end);
|
||||
|
||||
this.setValue(`${before}${text}${after}`);
|
||||
|
||||
// Move the textarea cursor to the end of the content we just inserted.
|
||||
this.moveCursorTo(start + text.length);
|
||||
}
|
||||
|
||||
/**
|
||||
* Replace existing content from the start to the current cursor position.
|
||||
*
|
||||
* @param start
|
||||
* @param text
|
||||
*/
|
||||
replaceBeforeCursor(start, text) {
|
||||
this.insertBetween(start, this.el.selectionStart, text);
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the selected range of the textarea.
|
||||
*
|
||||
* @param {number} start
|
||||
* @param {number} end
|
||||
* @private
|
||||
*/
|
||||
setSelectionRange(start, end) {
|
||||
this.el.setSelectionRange(start, end);
|
||||
this.$.focus();
|
||||
}
|
||||
}
|
50
js/src/common/utils/bidi.js
Normal file
50
js/src/common/utils/bidi.js
Normal file
@@ -0,0 +1,50 @@
|
||||
function bidi(node, prop) {
|
||||
var type = node.tag === 'select' ? (node.attrs.multi ? 'multi' : 'select') : node.attrs.type;
|
||||
|
||||
// Setup: bind listeners
|
||||
if (type === 'multi') {
|
||||
node.attrs.onchange = function () {
|
||||
prop(
|
||||
[].slice.call(this.selectedOptions, function (x) {
|
||||
return x.value;
|
||||
})
|
||||
);
|
||||
};
|
||||
} else if (type === 'select') {
|
||||
node.attrs.onchange = function (e) {
|
||||
prop(this.selectedOptions[0].value);
|
||||
};
|
||||
} else if (type === 'checkbox') {
|
||||
node.attrs.onchange = function (e) {
|
||||
prop(this.checked);
|
||||
};
|
||||
} else {
|
||||
node.attrs.onchange = node.attrs.oninput = function (e) {
|
||||
prop(this.value);
|
||||
};
|
||||
}
|
||||
|
||||
if (node.tag === 'select') {
|
||||
node.children.forEach(function (option) {
|
||||
if (option.attrs.value === prop() || option.children[0] === prop()) {
|
||||
option.attrs.selected = true;
|
||||
}
|
||||
});
|
||||
} else if (type === 'checkbox') {
|
||||
node.attrs.checked = prop();
|
||||
} else if (type === 'radio') {
|
||||
node.attrs.checked = prop() === node.attrs.value;
|
||||
} else {
|
||||
node.attrs.value = prop();
|
||||
}
|
||||
|
||||
node.attrs.bidi = null;
|
||||
|
||||
return node;
|
||||
}
|
||||
|
||||
bidi.view = function (ctrl, node, prop) {
|
||||
return bidi(node, node.attrs.bidi);
|
||||
};
|
||||
|
||||
export default bidi;
|
@@ -1,3 +1,6 @@
|
||||
import dayjs from 'dayjs';
|
||||
import 'dayjs/plugin/relativeTime';
|
||||
|
||||
/**
|
||||
* The `humanTime` utility converts a date to a localized, human-readable time-
|
||||
* ago string.
|
||||
|
@@ -1,6 +1,9 @@
|
||||
import DefaultResolver from '../resolvers/DefaultResolver';
|
||||
|
||||
/**
|
||||
* The `mapRoutes` utility converts a map of named application routes into a
|
||||
* format that can be understood by Mithril.
|
||||
* format that can be understood by Mithril, and wraps them in route resolvers
|
||||
* to provide each route with the current route name.
|
||||
*
|
||||
* @see https://mithril.js.org/route.html#signature
|
||||
* @param {Object} routes
|
||||
@@ -10,14 +13,17 @@
|
||||
export default function mapRoutes(routes, basePath = '') {
|
||||
const map = {};
|
||||
|
||||
for (const key in routes) {
|
||||
const route = routes[key];
|
||||
for (const routeName in routes) {
|
||||
const route = routes[routeName];
|
||||
|
||||
map[basePath + route.path] = {
|
||||
render() {
|
||||
return m(route.component, { routeName: key });
|
||||
},
|
||||
};
|
||||
if ('resolver' in route) {
|
||||
map[basePath + route.path] = route.resolver;
|
||||
} else if ('component' in route) {
|
||||
const resolverClass = 'resolverClass' in route ? route.resolverClass : DefaultResolver;
|
||||
map[basePath + route.path] = new resolverClass(route.component, routeName);
|
||||
} else {
|
||||
throw new Error(`Either a resolver or a component must be provided for the route [${routeName}]`);
|
||||
}
|
||||
}
|
||||
|
||||
return map;
|
||||
|
@@ -1,8 +1,4 @@
|
||||
import withAttr from './withAttr';
|
||||
import Stream from './Stream';
|
||||
|
||||
let deprecatedMPropWarned = false;
|
||||
let deprecatedMWithAttrWarned = false;
|
||||
import bidi from './bidi';
|
||||
|
||||
export default function patchMithril(global) {
|
||||
const defaultMithril = global.m;
|
||||
@@ -14,7 +10,7 @@ export default function patchMithril(global) {
|
||||
|
||||
// Allows the use of the bidi attr.
|
||||
if (node.attrs.bidi) {
|
||||
modifiedMithril.bidi(node, node.attrs.bidi);
|
||||
bidi(node, node.attrs.bidi);
|
||||
}
|
||||
|
||||
return node;
|
||||
@@ -22,23 +18,5 @@ export default function patchMithril(global) {
|
||||
|
||||
Object.keys(defaultMithril).forEach((key) => (modifiedMithril[key] = defaultMithril[key]));
|
||||
|
||||
// BEGIN DEPRECATED MITHRIL 2 BC LAYER
|
||||
modifiedMithril.prop = function (...args) {
|
||||
if (!deprecatedMPropWarned) {
|
||||
deprecatedMPropWarned = true;
|
||||
console.warn('m.prop() is deprecated, please use the Stream util (flarum/utils/Streams) instead.');
|
||||
}
|
||||
return Stream.bind(this)(...args);
|
||||
};
|
||||
|
||||
modifiedMithril.withAttr = function (...args) {
|
||||
if (!deprecatedMWithAttrWarned) {
|
||||
deprecatedMWithAttrWarned = true;
|
||||
console.warn("m.withAttr() is deprecated, please use flarum's withAttr util (flarum/utils/withAttr) instead.");
|
||||
}
|
||||
return withAttr.bind(this)(...args);
|
||||
};
|
||||
// END DEPRECATED MITHRIL 2 BC LAYER
|
||||
|
||||
global.m = modifiedMithril;
|
||||
}
|
||||
|
10
js/src/common/utils/proxifyCompat.ts
Normal file
10
js/src/common/utils/proxifyCompat.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
export default (compat: { [key: string]: any }, namespace: string) => {
|
||||
// regex to replace common/ and NAMESPACE/ for core & core extensions
|
||||
// e.g. admin/utils/extract --> utils/extract
|
||||
// e.g. tags/common/utils/sortTags --> tags/utils/sortTags
|
||||
const regex = new RegExp(`(\\w+\\/)?(${namespace}|common)\\/`);
|
||||
|
||||
return new Proxy(compat, {
|
||||
get: (obj, prop: string) => obj[prop] || obj[prop.replace(regex, '$1')],
|
||||
});
|
||||
};
|
@@ -90,11 +90,6 @@ export default class ForumApplication extends Application {
|
||||
* @type {DiscussionListState}
|
||||
*/
|
||||
this.discussions = new DiscussionListState({}, this);
|
||||
|
||||
/**
|
||||
* @deprecated beta 14, remove in beta 15.
|
||||
*/
|
||||
this.cache.discussionList = this.discussions;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -115,17 +110,19 @@ export default class ForumApplication extends Application {
|
||||
this.routes[defaultAction].path = '/';
|
||||
this.history.push(defaultAction, this.translator.trans('core.forum.header.back_to_index_tooltip'), '/');
|
||||
|
||||
this.pane = new Pane(document.getElementById('app'));
|
||||
|
||||
m.route.prefix = '';
|
||||
super.mount(this.forum.attribute('basePath'));
|
||||
|
||||
// We mount navigation and header components after the page, so components
|
||||
// like the back button can access the updated state when rendering.
|
||||
m.mount(document.getElementById('app-navigation'), { view: () => Navigation.component({ className: 'App-backControl', drawer: true }) });
|
||||
m.mount(document.getElementById('header-navigation'), Navigation);
|
||||
m.mount(document.getElementById('header-primary'), HeaderPrimary);
|
||||
m.mount(document.getElementById('header-secondary'), HeaderSecondary);
|
||||
m.mount(document.getElementById('composer'), { view: () => Composer.component({ state: this.composer }) });
|
||||
|
||||
this.pane = new Pane(document.getElementById('app'));
|
||||
|
||||
m.route.prefix = '';
|
||||
super.mount(this.forum.attribute('basePath'));
|
||||
|
||||
alertEmailConfirmation(this);
|
||||
|
||||
// Route the home link back home when clicked. We do not want it to register
|
||||
|
@@ -16,6 +16,7 @@ import PostStreamState from './states/PostStreamState';
|
||||
import SearchState from './states/SearchState';
|
||||
import AffixedSidebar from './components/AffixedSidebar';
|
||||
import DiscussionPage from './components/DiscussionPage';
|
||||
import DiscussionListPane from './components/DiscussionListPane';
|
||||
import LogInModal from './components/LogInModal';
|
||||
import ComposerBody from './components/ComposerBody';
|
||||
import ForgotPasswordModal from './components/ForgotPasswordModal';
|
||||
@@ -71,6 +72,8 @@ import Search from './components/Search';
|
||||
import DiscussionListItem from './components/DiscussionListItem';
|
||||
import LoadingPost from './components/LoadingPost';
|
||||
import PostsUserPage from './components/PostsUserPage';
|
||||
import DiscussionPageResolver from './resolvers/DiscussionPageResolver';
|
||||
import BasicEditorDriver from './utils/BasicEditorDriver';
|
||||
import routes from './routes';
|
||||
import ForumApplication from './ForumApplication';
|
||||
|
||||
@@ -83,6 +86,8 @@ export default Object.assign(compat, {
|
||||
'utils/alertEmailConfirmation': alertEmailConfirmation,
|
||||
'utils/UserControls': UserControls,
|
||||
'utils/Pane': Pane,
|
||||
'utils/BasicEditorDriver': BasicEditorDriver,
|
||||
'utils/SuperTextarea': BasicEditorDriver, // @deprecated beta 16, remove beta 17
|
||||
'states/ComposerState': ComposerState,
|
||||
'states/DiscussionListState': DiscussionListState,
|
||||
'states/GlobalSearchState': GlobalSearchState,
|
||||
@@ -91,6 +96,7 @@ export default Object.assign(compat, {
|
||||
'states/SearchState': SearchState,
|
||||
'components/AffixedSidebar': AffixedSidebar,
|
||||
'components/DiscussionPage': DiscussionPage,
|
||||
'components/DiscussionListPane': DiscussionListPane,
|
||||
'components/LogInModal': LogInModal,
|
||||
'components/ComposerBody': ComposerBody,
|
||||
'components/ForgotPasswordModal': ForgotPasswordModal,
|
||||
@@ -146,6 +152,7 @@ export default Object.assign(compat, {
|
||||
'components/DiscussionListItem': DiscussionListItem,
|
||||
'components/LoadingPost': LoadingPost,
|
||||
'components/PostsUserPage': PostsUserPage,
|
||||
'resolvers/DiscussionPageResolver': DiscussionPageResolver,
|
||||
routes: routes,
|
||||
ForumApplication: ForumApplication,
|
||||
});
|
||||
|
@@ -106,9 +106,8 @@ export default class ChangeEmailModal extends Modal {
|
||||
return;
|
||||
}
|
||||
|
||||
const oldEmail = app.session.user.email();
|
||||
|
||||
this.loading = true;
|
||||
this.alertAttrs = null;
|
||||
|
||||
app.session.user
|
||||
.save(
|
||||
@@ -118,7 +117,9 @@ export default class ChangeEmailModal extends Modal {
|
||||
meta: { password: this.password() },
|
||||
}
|
||||
)
|
||||
.then(() => (this.success = true))
|
||||
.then(() => {
|
||||
this.success = true;
|
||||
})
|
||||
.catch(() => {})
|
||||
.then(this.loaded.bind(this));
|
||||
}
|
||||
|
@@ -56,9 +56,7 @@ export default class CommentPost extends Post {
|
||||
]);
|
||||
}
|
||||
|
||||
onupdate(vnode) {
|
||||
super.onupdate();
|
||||
|
||||
refreshContent() {
|
||||
const contentHtml = this.isEditing() ? '' : this.attrs.post.contentHtml();
|
||||
|
||||
// If the post content has changed since the last render, we'll run through
|
||||
@@ -66,13 +64,28 @@ export default class CommentPost extends Post {
|
||||
// necessary because TextFormatter outputs them for e.g. syntax highlighting.
|
||||
if (this.contentHtml !== contentHtml) {
|
||||
this.$('.Post-body script').each(function () {
|
||||
eval.call(window, $(this).text());
|
||||
const script = document.createElement('script');
|
||||
script.textContent = this.textContent;
|
||||
Array.from(this.attributes).forEach((attr) => script.setAttribute(attr.name, attr.value));
|
||||
this.parentNode.replaceChild(script, this);
|
||||
});
|
||||
}
|
||||
|
||||
this.contentHtml = contentHtml;
|
||||
}
|
||||
|
||||
oncreate(vnode) {
|
||||
super.oncreate(vnode);
|
||||
|
||||
this.refreshContent();
|
||||
}
|
||||
|
||||
onupdate(vnode) {
|
||||
super.onupdate(vnode);
|
||||
|
||||
this.refreshContent();
|
||||
}
|
||||
|
||||
isEditing() {
|
||||
return app.composer.bodyMatches(EditPostComposer, { post: this.attrs.post });
|
||||
}
|
||||
|
@@ -76,13 +76,13 @@ export default class Composer extends Component {
|
||||
|
||||
// Whenever any of the inputs inside the composer are have focus, we want to
|
||||
// add a class to the composer to draw attention to it.
|
||||
this.$().on('focus blur', ':input', (e) => {
|
||||
this.$().on('focus blur', ':input,.TextEditor-editorContainer', (e) => {
|
||||
this.active = e.type === 'focusin';
|
||||
m.redraw();
|
||||
});
|
||||
|
||||
// When the escape key is pressed on any inputs, close the composer.
|
||||
this.$().on('keydown', ':input', 'esc', () => this.state.close());
|
||||
this.$().on('keydown', ':input,.TextEditor-editorContainer', 'esc', () => this.state.close());
|
||||
|
||||
this.handlers = {};
|
||||
|
||||
@@ -157,7 +157,7 @@ export default class Composer extends Component {
|
||||
* Draw focus to the first focusable content element (the text editor).
|
||||
*/
|
||||
focus() {
|
||||
this.$('.Composer-content :input:enabled:visible:first').focus();
|
||||
this.$('.Composer-content :input:enabled:visible, .TextEditor-editor').first().focus();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -199,7 +199,7 @@ export default class Composer extends Component {
|
||||
*/
|
||||
animatePositionChange() {
|
||||
// When exiting full-screen mode: focus content
|
||||
if (this.prevPosition === ComposerState.Position.FULLSCREEN) {
|
||||
if (this.prevPosition === ComposerState.Position.FULLSCREEN && this.state.position === ComposerState.Position.NORMAL) {
|
||||
this.focus();
|
||||
return;
|
||||
}
|
||||
@@ -265,7 +265,7 @@ export default class Composer extends Component {
|
||||
this.animateHeightChange().then(() => this.focus());
|
||||
|
||||
if (app.screen() === 'phone') {
|
||||
this.$().css('top', $(window).scrollTop());
|
||||
this.$().css('top', 0);
|
||||
this.showBackdrop();
|
||||
}
|
||||
}
|
||||
|
@@ -44,12 +44,6 @@ export default class ComposerBody extends Component {
|
||||
}
|
||||
|
||||
this.composer.fields.content(this.attrs.originalContent || '');
|
||||
|
||||
/**
|
||||
* @deprecated BC layer, remove in Beta 15.
|
||||
*/
|
||||
this.content = this.composer.fields.content;
|
||||
this.editor = this.composer;
|
||||
}
|
||||
|
||||
view() {
|
||||
|
@@ -100,7 +100,7 @@ export default class DiscussionComposer extends ComposerBody {
|
||||
.save(data)
|
||||
.then((discussion) => {
|
||||
this.composer.hide();
|
||||
app.discussions.refresh();
|
||||
app.discussions.refresh({ deferClear: true });
|
||||
m.route.set(app.route.discussion(discussion));
|
||||
}, this.loaded.bind(this));
|
||||
}
|
||||
|
@@ -14,6 +14,7 @@ import DiscussionControls from '../utils/DiscussionControls';
|
||||
import slidable from '../utils/slidable';
|
||||
import extractText from '../../common/utils/extractText';
|
||||
import classList from '../../common/utils/classList';
|
||||
import DiscussionPage from './DiscussionPage';
|
||||
|
||||
import { escapeRegExp } from 'lodash-es';
|
||||
/**
|
||||
@@ -91,12 +92,12 @@ export default class DiscussionListItem extends Component {
|
||||
)
|
||||
: ''}
|
||||
|
||||
<a
|
||||
<span
|
||||
className={'Slidable-underneath Slidable-underneath--left Slidable-underneath--elastic' + (isUnread ? '' : ' disabled')}
|
||||
onclick={this.markAsRead.bind(this)}
|
||||
>
|
||||
{icon('fas fa-check')}
|
||||
</a>
|
||||
</span>
|
||||
|
||||
<div className={'DiscussionListItem-content Slidable-content' + (isUnread ? ' unread' : '') + (isRead ? ' read' : '')}>
|
||||
<Link
|
||||
@@ -120,6 +121,8 @@ export default class DiscussionListItem extends Component {
|
||||
</Link>
|
||||
|
||||
<span
|
||||
tabindex="0"
|
||||
role="button"
|
||||
className="DiscussionListItem-count"
|
||||
onclick={this.markAsRead.bind(this)}
|
||||
title={showUnread ? app.translator.trans('core.forum.discussion_list.mark_as_read_tooltip') : ''}
|
||||
@@ -156,9 +159,7 @@ export default class DiscussionListItem extends Component {
|
||||
* @return {Boolean}
|
||||
*/
|
||||
active() {
|
||||
const idParam = m.route.param('id');
|
||||
|
||||
return idParam && idParam.split('-')[0] === this.attrs.discussion.id();
|
||||
return app.current.matches(DiscussionPage, { discussion: this.attrs.discussion });
|
||||
}
|
||||
|
||||
/**
|
||||
|
@@ -1,5 +1,6 @@
|
||||
import DiscussionList from './DiscussionList';
|
||||
import Component from '../../common/Component';
|
||||
import DiscussionPage from './DiscussionPage';
|
||||
|
||||
const hotEdge = (e) => {
|
||||
if (e.pageX < 10) app.pane.show();
|
||||
@@ -36,23 +37,31 @@ export default class DiscussionListPane extends Component {
|
||||
|
||||
$(document).on('mousemove', hotEdge);
|
||||
|
||||
// If the discussion we are viewing is listed in the discussion list, then
|
||||
// we will make sure it is visible in the viewport – if it is not we will
|
||||
// scroll the list down to it.
|
||||
const $discussion = $list.find('.DiscussionListItem.active');
|
||||
if ($discussion.length) {
|
||||
const listTop = $list.offset().top;
|
||||
const listBottom = listTop + $list.outerHeight();
|
||||
const discussionTop = $discussion.offset().top;
|
||||
const discussionBottom = discussionTop + $discussion.outerHeight();
|
||||
// When coming from another discussion, scroll to the previous postition
|
||||
// to prevent the discussion list jumping around.
|
||||
if (app.previous.matches(DiscussionPage)) {
|
||||
const top = app.cache.discussionListPaneScrollTop || 0;
|
||||
$list.scrollTop(top);
|
||||
} else {
|
||||
// If the discussion we are viewing is listed in the discussion list, then
|
||||
// we will make sure it is visible in the viewport – if it is not we will
|
||||
// scroll the list down to it.
|
||||
const $discussion = $list.find('.DiscussionListItem.active');
|
||||
if ($discussion.length) {
|
||||
const listTop = $list.offset().top;
|
||||
const listBottom = listTop + $list.outerHeight();
|
||||
const discussionTop = $discussion.offset().top;
|
||||
const discussionBottom = discussionTop + $discussion.outerHeight();
|
||||
|
||||
if (discussionTop < listTop || discussionBottom > listBottom) {
|
||||
$list.scrollTop($list.scrollTop() - listTop + discussionTop);
|
||||
if (discussionTop < listTop || discussionBottom > listBottom) {
|
||||
$list.scrollTop($list.scrollTop() - listTop + discussionTop);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
onremove() {
|
||||
onremove(vnode) {
|
||||
app.cache.discussionListPaneScrollTop = $(vnode.dom).scrollTop();
|
||||
$(document).off('mousemove', hotEdge);
|
||||
}
|
||||
|
||||
|
@@ -9,6 +9,7 @@ import SplitDropdown from '../../common/components/SplitDropdown';
|
||||
import listItems from '../../common/helpers/listItems';
|
||||
import DiscussionControls from '../utils/DiscussionControls';
|
||||
import PostStreamState from '../states/PostStreamState';
|
||||
import ScrollListener from '../../common/utils/ScrollListener';
|
||||
|
||||
/**
|
||||
* The `DiscussionPage` component displays a whole discussion page, including
|
||||
@@ -18,6 +19,8 @@ export default class DiscussionPage extends Page {
|
||||
oninit(vnode) {
|
||||
super.oninit(vnode);
|
||||
|
||||
this.useBrowserScrollRestoration = false;
|
||||
|
||||
/**
|
||||
* The discussion that is being viewed.
|
||||
*
|
||||
@@ -47,11 +50,30 @@ export default class DiscussionPage extends Page {
|
||||
app.history.push('discussion');
|
||||
|
||||
this.bodyClass = 'App--discussion';
|
||||
}
|
||||
|
||||
this.prevRoute = m.route.get();
|
||||
oncreate(vnode) {
|
||||
super.oncreate(vnode);
|
||||
|
||||
const scrollListener = new ScrollListener((top) => {
|
||||
const $hero = $('.DiscussionHero');
|
||||
if ($hero.offset()) {
|
||||
const container = $('.DiscussionHero').children('.container');
|
||||
const containerPadding = parseInt(container.css('padding-top')) + parseInt(container.css('padding-bottom'));
|
||||
|
||||
$hero.toggleClass('DiscussionHero--floating', top > 22 + containerPadding);
|
||||
$('.DiscussionPage-discussion')
|
||||
.children('.container')
|
||||
.toggleClass('scrolled', top > 22 + containerPadding);
|
||||
}
|
||||
});
|
||||
|
||||
scrollListener.start();
|
||||
scrollListener.update();
|
||||
}
|
||||
|
||||
onremove() {
|
||||
super.onremove();
|
||||
// If we are indeed navigating away from this discussion, then disable the
|
||||
// discussion list pane. Also, if we're composing a reply to this
|
||||
// discussion, minimize the composer – unless it's empty, in which case
|
||||
@@ -83,7 +105,6 @@ export default class DiscussionPage extends Page {
|
||||
{PostStream.component({
|
||||
discussion,
|
||||
stream: this.stream,
|
||||
targetPost: this.stream.targetPost,
|
||||
onPositionChange: this.positionChanged.bind(this),
|
||||
})}
|
||||
</div>
|
||||
@@ -95,34 +116,6 @@ export default class DiscussionPage extends Page {
|
||||
);
|
||||
}
|
||||
|
||||
onbeforeupdate(vnode) {
|
||||
super.onbeforeupdate(vnode);
|
||||
|
||||
if (m.route.get() !== this.prevRoute) {
|
||||
this.onNewRoute();
|
||||
this.prevRoute = m.route.get();
|
||||
|
||||
// If we have routed to the same discussion as we were viewing previously,
|
||||
// cancel the unloading of this controller and instead prompt the post
|
||||
// stream to jump to the new 'near' param.
|
||||
if (this.discussion) {
|
||||
const idParam = m.route.param('id');
|
||||
|
||||
if (idParam && idParam.split('-')[0] === this.discussion.id()) {
|
||||
const near = m.route.param('near') || '1';
|
||||
|
||||
if (near !== String(this.near)) {
|
||||
this.stream.goToNumber(near);
|
||||
}
|
||||
|
||||
this.near = near;
|
||||
} else {
|
||||
this.oninit(vnode);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Load the discussion from the API or use the preloaded one.
|
||||
*/
|
||||
@@ -137,7 +130,7 @@ export default class DiscussionPage extends Page {
|
||||
} else {
|
||||
const params = this.requestParams();
|
||||
|
||||
app.store.find('discussions', m.route.param('id').split('-')[0], params).then(this.show.bind(this));
|
||||
app.store.find('discussions', m.route.param('id'), params).then(this.show.bind(this));
|
||||
}
|
||||
|
||||
m.redraw();
|
||||
@@ -151,6 +144,7 @@ export default class DiscussionPage extends Page {
|
||||
*/
|
||||
requestParams() {
|
||||
return {
|
||||
bySlug: true,
|
||||
page: { near: this.near },
|
||||
};
|
||||
}
|
||||
@@ -185,7 +179,7 @@ export default class DiscussionPage extends Page {
|
||||
record.relationships.discussion.data.id === discussionId
|
||||
)
|
||||
.map((record) => app.store.getById('posts', record.id))
|
||||
.sort((a, b) => a.id() - b.id())
|
||||
.sort((a, b) => a.createdAt() - b.createdAt())
|
||||
.slice(0, 20);
|
||||
}
|
||||
|
||||
@@ -247,10 +241,7 @@ export default class DiscussionPage extends Page {
|
||||
// replace it into the window's history and our own history stack.
|
||||
const url = app.route.discussion(discussion, (this.near = startNumber));
|
||||
|
||||
this.prevRoute = url;
|
||||
m.route.set(url, null, { replace: true });
|
||||
window.history.replaceState(null, document.title, url);
|
||||
|
||||
app.history.push('discussion', discussion.title());
|
||||
|
||||
// If the user hasn't read past here before, then we'll update their read
|
||||
|
@@ -37,9 +37,10 @@ export default class EditUserModal extends Modal {
|
||||
}
|
||||
|
||||
content() {
|
||||
const fields = this.fields().toArray();
|
||||
return (
|
||||
<div className="Modal-body">
|
||||
<div className="Form">{this.fields().toArray()}</div>
|
||||
{fields.length > 1 ? <div className="Form">{this.fields().toArray()}</div> : app.translator.trans('core.forum.edit_user.nothing_available')}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -47,96 +48,112 @@ export default class EditUserModal extends Modal {
|
||||
fields() {
|
||||
const items = new ItemList();
|
||||
|
||||
items.add(
|
||||
'username',
|
||||
<div className="Form-group">
|
||||
<label>{app.translator.trans('core.forum.edit_user.username_heading')}</label>
|
||||
<input className="FormControl" placeholder={extractText(app.translator.trans('core.forum.edit_user.username_label'))} bidi={this.username} />
|
||||
</div>,
|
||||
40
|
||||
);
|
||||
|
||||
if (app.session.user !== this.attrs.user) {
|
||||
if (app.session.user.canEditCredentials()) {
|
||||
items.add(
|
||||
'email',
|
||||
'username',
|
||||
<div className="Form-group">
|
||||
<label>{app.translator.trans('core.forum.edit_user.email_heading')}</label>
|
||||
<div>
|
||||
<input className="FormControl" placeholder={extractText(app.translator.trans('core.forum.edit_user.email_label'))} bidi={this.email} />
|
||||
</div>
|
||||
{!this.isEmailConfirmed() ? (
|
||||
<div>
|
||||
{Button.component(
|
||||
{
|
||||
className: 'Button Button--block',
|
||||
loading: this.loading,
|
||||
onclick: this.activate.bind(this),
|
||||
},
|
||||
app.translator.trans('core.forum.edit_user.activate_button')
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
''
|
||||
)}
|
||||
<label>{app.translator.trans('core.forum.edit_user.username_heading')}</label>
|
||||
<input
|
||||
className="FormControl"
|
||||
placeholder={extractText(app.translator.trans('core.forum.edit_user.username_label'))}
|
||||
bidi={this.username}
|
||||
disabled={this.nonAdminEditingAdmin()}
|
||||
/>
|
||||
</div>,
|
||||
30
|
||||
40
|
||||
);
|
||||
|
||||
items.add(
|
||||
'password',
|
||||
<div className="Form-group">
|
||||
<label>{app.translator.trans('core.forum.edit_user.password_heading')}</label>
|
||||
<div>
|
||||
<label className="checkbox">
|
||||
<input
|
||||
type="checkbox"
|
||||
onchange={(e) => {
|
||||
this.setPassword(e.target.checked);
|
||||
m.redraw.sync();
|
||||
if (e.target.checked) this.$('[name=password]').select();
|
||||
e.redraw = false;
|
||||
}}
|
||||
/>
|
||||
{app.translator.trans('core.forum.edit_user.set_password_label')}
|
||||
</label>
|
||||
{this.setPassword() ? (
|
||||
if (app.session.user !== this.attrs.user) {
|
||||
items.add(
|
||||
'email',
|
||||
<div className="Form-group">
|
||||
<label>{app.translator.trans('core.forum.edit_user.email_heading')}</label>
|
||||
<div>
|
||||
<input
|
||||
className="FormControl"
|
||||
type="password"
|
||||
name="password"
|
||||
placeholder={extractText(app.translator.trans('core.forum.edit_user.password_label'))}
|
||||
bidi={this.password}
|
||||
placeholder={extractText(app.translator.trans('core.forum.edit_user.email_label'))}
|
||||
bidi={this.email}
|
||||
disabled={this.nonAdminEditingAdmin()}
|
||||
/>
|
||||
</div>
|
||||
{!this.isEmailConfirmed() && this.userIsAdmin(app.session.user) ? (
|
||||
<div>
|
||||
{Button.component(
|
||||
{
|
||||
className: 'Button Button--block',
|
||||
loading: this.loading,
|
||||
onclick: this.activate.bind(this),
|
||||
},
|
||||
app.translator.trans('core.forum.edit_user.activate_button')
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
''
|
||||
)}
|
||||
</div>
|
||||
</div>,
|
||||
20
|
||||
);
|
||||
}
|
||||
</div>,
|
||||
30
|
||||
);
|
||||
|
||||
items.add(
|
||||
'groups',
|
||||
<div className="Form-group EditUserModal-groups">
|
||||
<label>{app.translator.trans('core.forum.edit_user.groups_heading')}</label>
|
||||
<div>
|
||||
{Object.keys(this.groups)
|
||||
.map((id) => app.store.getById('groups', id))
|
||||
.map((group) => (
|
||||
items.add(
|
||||
'password',
|
||||
<div className="Form-group">
|
||||
<label>{app.translator.trans('core.forum.edit_user.password_heading')}</label>
|
||||
<div>
|
||||
<label className="checkbox">
|
||||
<input
|
||||
type="checkbox"
|
||||
bidi={this.groups[group.id()]}
|
||||
disabled={this.attrs.user.id() === '1' && group.id() === Group.ADMINISTRATOR_ID}
|
||||
onchange={(e) => {
|
||||
this.setPassword(e.target.checked);
|
||||
m.redraw.sync();
|
||||
if (e.target.checked) this.$('[name=password]').select();
|
||||
e.redraw = false;
|
||||
}}
|
||||
disabled={this.nonAdminEditingAdmin()}
|
||||
/>
|
||||
{GroupBadge.component({ group, label: '' })} {group.nameSingular()}
|
||||
{app.translator.trans('core.forum.edit_user.set_password_label')}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>,
|
||||
10
|
||||
);
|
||||
{this.setPassword() ? (
|
||||
<input
|
||||
className="FormControl"
|
||||
type="password"
|
||||
name="password"
|
||||
placeholder={extractText(app.translator.trans('core.forum.edit_user.password_label'))}
|
||||
bidi={this.password}
|
||||
disabled={this.nonAdminEditingAdmin()}
|
||||
/>
|
||||
) : (
|
||||
''
|
||||
)}
|
||||
</div>
|
||||
</div>,
|
||||
20
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (app.session.user.canEditGroups()) {
|
||||
items.add(
|
||||
'groups',
|
||||
<div className="Form-group EditUserModal-groups">
|
||||
<label>{app.translator.trans('core.forum.edit_user.groups_heading')}</label>
|
||||
<div>
|
||||
{Object.keys(this.groups)
|
||||
.map((id) => app.store.getById('groups', id))
|
||||
.map((group) => (
|
||||
<label className="checkbox">
|
||||
<input
|
||||
type="checkbox"
|
||||
bidi={this.groups[group.id()]}
|
||||
disabled={group.id() === Group.ADMINISTRATOR_ID && (this.attrs.user === app.session.user || !this.userIsAdmin(app.session.user))}
|
||||
/>
|
||||
{GroupBadge.component({ group, label: '' })} {group.nameSingular()}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>,
|
||||
10
|
||||
);
|
||||
}
|
||||
|
||||
items.add(
|
||||
'submit',
|
||||
@@ -176,21 +193,26 @@ export default class EditUserModal extends Modal {
|
||||
}
|
||||
|
||||
data() {
|
||||
const groups = Object.keys(this.groups)
|
||||
.filter((id) => this.groups[id]())
|
||||
.map((id) => app.store.getById('groups', id));
|
||||
|
||||
const data = {
|
||||
username: this.username(),
|
||||
relationships: { groups },
|
||||
relationships: {},
|
||||
};
|
||||
|
||||
if (app.session.user !== this.attrs.user) {
|
||||
data.email = this.email();
|
||||
if (this.attrs.user.canEditCredentials() && !this.nonAdminEditingAdmin()) {
|
||||
data.username = this.username();
|
||||
|
||||
if (app.session.user !== this.attrs.user) {
|
||||
data.email = this.email();
|
||||
}
|
||||
|
||||
if (this.setPassword()) {
|
||||
data.password = this.password();
|
||||
}
|
||||
}
|
||||
|
||||
if (this.setPassword()) {
|
||||
data.password = this.password();
|
||||
if (this.attrs.user.canEditGroups()) {
|
||||
data.relationships.groups = Object.keys(this.groups)
|
||||
.filter((id) => this.groups[id]())
|
||||
.map((id) => app.store.getById('groups', id));
|
||||
}
|
||||
|
||||
return data;
|
||||
@@ -209,4 +231,15 @@ export default class EditUserModal extends Modal {
|
||||
m.redraw();
|
||||
});
|
||||
}
|
||||
|
||||
nonAdminEditingAdmin() {
|
||||
return this.userIsAdmin(this.attrs.user) && !this.userIsAdmin(app.session.user);
|
||||
}
|
||||
|
||||
/**
|
||||
* @internal @protected
|
||||
*/
|
||||
userIsAdmin(user) {
|
||||
return user.groups().some((g) => g.id() === Group.ADMINISTRATOR_ID);
|
||||
}
|
||||
}
|
||||
|
@@ -42,26 +42,7 @@ export default class IndexPage extends Page {
|
||||
app.history.push('index', app.translator.trans('core.forum.header.back_to_index_tooltip'));
|
||||
|
||||
this.bodyClass = 'App--index';
|
||||
|
||||
this.currentPath = m.route.get();
|
||||
}
|
||||
|
||||
onbeforeupdate(vnode) {
|
||||
super.onbeforeupdate(vnode);
|
||||
|
||||
const curPath = m.route.get();
|
||||
|
||||
if (this.currentPath !== curPath) {
|
||||
this.onNewRoute();
|
||||
|
||||
app.discussions.clear();
|
||||
|
||||
app.discussions.refreshParams(app.search.params());
|
||||
|
||||
this.currentPath = curPath;
|
||||
|
||||
this.setTitle();
|
||||
}
|
||||
this.scrollTopOnCreate = false;
|
||||
}
|
||||
|
||||
view() {
|
||||
@@ -105,18 +86,22 @@ export default class IndexPage extends Page {
|
||||
|
||||
$('#app').css('min-height', $(window).height() + heroHeight);
|
||||
|
||||
// Scroll to the remembered position. We do this after a short delay so that
|
||||
// it happens after the browser has done its own "back button" scrolling,
|
||||
// which isn't right. https://github.com/flarum/core/issues/835
|
||||
const scroll = () => $(window).scrollTop(scrollTop - oldHeroHeight + heroHeight);
|
||||
scroll();
|
||||
setTimeout(scroll, 1);
|
||||
// Let browser handle scrolling on page reload.
|
||||
if (app.previous.type == null) return;
|
||||
|
||||
// When on mobile, only retain scroll if we're coming from a discussion page.
|
||||
// Otherwise, we've just changed the filter, so we should go to the top of the page.
|
||||
if (app.screen() == 'desktop' || app.screen() == 'desktop-hd' || this.lastDiscussion) {
|
||||
$(window).scrollTop(scrollTop - oldHeroHeight + heroHeight);
|
||||
} else {
|
||||
$(window).scrollTop(0);
|
||||
}
|
||||
|
||||
// If we've just returned from a discussion page, then the constructor will
|
||||
// have set the `lastDiscussion` property. If this is the case, we want to
|
||||
// scroll down to that discussion so that it's in view.
|
||||
if (this.lastDiscussion) {
|
||||
const $discussion = this.$(`.DiscussionListItem[data-id="${this.lastDiscussion.id()}"]`);
|
||||
const $discussion = this.$(`li[data-id="${this.lastDiscussion.id()}"] .DiscussionListItem`);
|
||||
|
||||
if ($discussion.length) {
|
||||
const indexTop = $('#header').outerHeight();
|
||||
@@ -131,14 +116,16 @@ export default class IndexPage extends Page {
|
||||
}
|
||||
}
|
||||
|
||||
onbeforeremove() {
|
||||
// Save the scroll position so we can restore it when we return to the
|
||||
// discussion list.
|
||||
app.cache.scrollTop = $(window).scrollTop();
|
||||
}
|
||||
|
||||
onremove() {
|
||||
super.onremove();
|
||||
|
||||
$('#app').css('min-height', '');
|
||||
|
||||
// Save the scroll position so we can restore it when we return to the
|
||||
// discussion list.
|
||||
app.cache.scrollTop = $(window).scrollTop();
|
||||
}
|
||||
|
||||
/**
|
||||
|
@@ -24,7 +24,7 @@ export default class Notification extends Component {
|
||||
<Link
|
||||
className={'Notification Notification--' + notification.contentType() + ' ' + (!notification.isRead() ? 'unread' : '')}
|
||||
href={href}
|
||||
external={href.indexOf('://') === -1}
|
||||
external={href.includes('://')}
|
||||
onclick={this.markAsRead.bind(this)}
|
||||
>
|
||||
{!notification.isRead() &&
|
||||
|
@@ -99,7 +99,9 @@ export default class NotificationList extends Component {
|
||||
super.oncreate(vnode);
|
||||
|
||||
this.$notifications = this.$('.NotificationList-content');
|
||||
this.$scrollParent = this.$notifications.css('overflow') === 'auto' ? this.$notifications : $(window);
|
||||
|
||||
// If we are on the notifications page, the window will be scrolling and not the $notifications element.
|
||||
this.$scrollParent = this.inPanel() ? this.$notifications : $(window);
|
||||
|
||||
this.boundScrollHandler = this.scrollHandler.bind(this);
|
||||
this.$scrollParent.on('scroll', this.boundScrollHandler);
|
||||
@@ -112,14 +114,24 @@ export default class NotificationList extends Component {
|
||||
scrollHandler() {
|
||||
const state = this.attrs.state;
|
||||
|
||||
const scrollTop = this.$scrollParent.scrollTop();
|
||||
const viewportHeight = this.$scrollParent.height();
|
||||
// Whole-page scroll events are listened to on `window`, but we need to get the actual
|
||||
// scrollHeight, scrollTop, and clientHeight from the document element.
|
||||
const scrollParent = this.inPanel() ? this.$scrollParent[0] : document.documentElement;
|
||||
|
||||
const contentTop = this.$scrollParent === this.$notifications ? 0 : this.$notifications.offset().top;
|
||||
const contentHeight = this.$notifications[0].scrollHeight;
|
||||
// On very short screens, the scrollHeight + scrollTop might not reach the clientHeight
|
||||
// by a fraction of a pixel, so we compensate for that.
|
||||
const atBottom = Math.abs(scrollParent.scrollHeight - scrollParent.scrollTop - scrollParent.clientHeight) <= 1;
|
||||
|
||||
if (state.hasMoreResults() && !state.isLoading() && scrollTop + viewportHeight >= contentTop + contentHeight) {
|
||||
if (state.hasMoreResults() && !state.isLoading() && atBottom) {
|
||||
state.loadMore();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* If the NotificationList component isn't in a panel (e.g. on NotificationPage when mobile),
|
||||
* we need to listen to scroll events on the window, and get scroll state from the body.
|
||||
*/
|
||||
inPanel() {
|
||||
return this.$notifications.css('overflow') === 'auto';
|
||||
}
|
||||
}
|
||||
|
@@ -32,6 +32,13 @@ export default class PostStream extends Component {
|
||||
const posts = this.stream.posts();
|
||||
const postIds = this.discussion.postIds();
|
||||
|
||||
const postFadeIn = (vnode) => {
|
||||
$(vnode.dom).addClass('fadeIn');
|
||||
// 500 is the duration of the fadeIn CSS animation + 100ms,
|
||||
// so the animation has time to complete
|
||||
setTimeout(() => $(vnode.dom).removeClass('fadeIn'), 500);
|
||||
};
|
||||
|
||||
const items = posts.map((post, i) => {
|
||||
let content;
|
||||
const attrs = { 'data-index': this.stream.visibleStart + i };
|
||||
@@ -42,6 +49,7 @@ export default class PostStream extends Component {
|
||||
content = PostComponent ? PostComponent.component({ post }) : '';
|
||||
|
||||
attrs.key = 'post' + post.id();
|
||||
attrs.oncreate = postFadeIn;
|
||||
attrs['data-time'] = time.toISOString();
|
||||
attrs['data-number'] = post.number();
|
||||
attrs['data-id'] = post.id();
|
||||
@@ -89,7 +97,7 @@ export default class PostStream extends Component {
|
||||
// is not already doing so, then show a 'write a reply' placeholder.
|
||||
if (viewingEnd && (!app.session.user || this.discussion.canReply())) {
|
||||
items.push(
|
||||
<div className="PostStream-item" key="reply">
|
||||
<div className="PostStream-item" key="reply" data-index={this.stream.count()} oncreate={postFadeIn}>
|
||||
{ReplyPlaceholder.component({ discussion: this.discussion })}
|
||||
</div>
|
||||
);
|
||||
@@ -121,27 +129,42 @@ export default class PostStream extends Component {
|
||||
* Start scrolling, if appropriate, to a newly-targeted post.
|
||||
*/
|
||||
triggerScroll() {
|
||||
if (!this.attrs.targetPost || !this.stream.needsScroll) return;
|
||||
if (!this.stream.needsScroll) return;
|
||||
|
||||
const newTarget = this.attrs.targetPost;
|
||||
const target = this.stream.targetPost;
|
||||
this.stream.needsScroll = false;
|
||||
|
||||
if ('number' in newTarget) {
|
||||
this.scrollToNumber(newTarget.number, this.stream.animateScroll);
|
||||
} else if ('index' in newTarget) {
|
||||
const backwards = newTarget.index === this.stream.count() - 1;
|
||||
this.scrollToIndex(newTarget.index, this.stream.animateScroll, backwards);
|
||||
if ('number' in target) {
|
||||
this.scrollToNumber(target.number, this.stream.animateScroll);
|
||||
} else if ('index' in target) {
|
||||
this.scrollToIndex(target.index, this.stream.animateScroll, target.reply);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* When the window is scrolled, check if either extreme of the post stream is
|
||||
* in the viewport, and if so, trigger loading the next/previous page.
|
||||
*
|
||||
* @param {Integer} top
|
||||
*/
|
||||
onscroll(top = window.pageYOffset) {
|
||||
if (this.stream.paused) return;
|
||||
if (this.stream.paused || this.stream.pagesLoading) return;
|
||||
|
||||
this.updateScrubber(top);
|
||||
|
||||
this.loadPostsIfNeeded(top);
|
||||
|
||||
// Throttle calculation of our position (start/end numbers of posts in the
|
||||
// viewport) to 100ms.
|
||||
clearTimeout(this.calculatePositionTimeout);
|
||||
this.calculatePositionTimeout = setTimeout(this.calculatePosition.bind(this, top), 100);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if either extreme of the post stream is in the viewport,
|
||||
* and if so, trigger loading the next/previous page.
|
||||
*
|
||||
* @param {Integer} top
|
||||
*/
|
||||
loadPostsIfNeeded(top = window.pageYOffset) {
|
||||
const marginTop = this.getMarginTop();
|
||||
const viewportHeight = $(window).height() - marginTop;
|
||||
const viewportTop = top + marginTop;
|
||||
@@ -162,13 +185,6 @@ export default class PostStream extends Component {
|
||||
this.stream.loadNext();
|
||||
}
|
||||
}
|
||||
|
||||
// Throttle calculation of our position (start/end numbers of posts in the
|
||||
// viewport) to 100ms.
|
||||
clearTimeout(this.calculatePositionTimeout);
|
||||
this.calculatePositionTimeout = setTimeout(this.calculatePosition.bind(this, top), 100);
|
||||
|
||||
this.updateScrubber(top);
|
||||
}
|
||||
|
||||
updateScrubber(top = window.pageYOffset) {
|
||||
@@ -181,9 +197,9 @@ export default class PostStream extends Component {
|
||||
// seen if the browser were scrolled right up to the top of the page,
|
||||
// and the viewport had a height of 0.
|
||||
const $items = this.$('.PostStream-item[data-index]');
|
||||
let index = $items.first().data('index') || 0;
|
||||
let visible = 0;
|
||||
let period = '';
|
||||
let indexFromViewPort = null;
|
||||
|
||||
// Now loop through each of the items in the discussion. An 'item' is
|
||||
// either a single post or a 'gap' of one or more posts that haven't
|
||||
@@ -209,8 +225,10 @@ export default class PostStream extends Component {
|
||||
const visibleBottom = Math.min(height, viewportTop + viewportHeight - top);
|
||||
const visiblePost = visibleBottom - visibleTop;
|
||||
|
||||
if (top <= viewportTop) {
|
||||
index = parseFloat($this.data('index')) + visibleTop / height;
|
||||
// We take the index of the first item that passed the previous checks.
|
||||
// It is the item that is first visible in the viewport.
|
||||
if (indexFromViewPort === null) {
|
||||
indexFromViewPort = parseFloat($this.data('index')) + visibleTop / height;
|
||||
}
|
||||
|
||||
if (visiblePost > 0) {
|
||||
@@ -223,7 +241,10 @@ export default class PostStream extends Component {
|
||||
if (time) period = time;
|
||||
});
|
||||
|
||||
this.stream.index = index + 1;
|
||||
// If indexFromViewPort is null, it means no posts are visible in the
|
||||
// viewport. This can happen, when drafting a long reply post. In that case
|
||||
// set the index to the last post.
|
||||
this.stream.index = indexFromViewPort !== null ? indexFromViewPort + 1 : this.stream.count();
|
||||
this.stream.visible = visible;
|
||||
if (period) this.stream.description = dayjs(period).format('MMMM YYYY');
|
||||
}
|
||||
@@ -275,7 +296,9 @@ export default class PostStream extends Component {
|
||||
* @return {Integer}
|
||||
*/
|
||||
getMarginTop() {
|
||||
return this.$() && $('#header').outerHeight() + parseInt(this.$().css('margin-top'), 10);
|
||||
const headerId = app.screen() === 'phone' ? '#app-navigation' : '#header';
|
||||
|
||||
return this.$() && $(headerId).outerHeight() + parseInt(this.$().css('margin-top'), 10);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -296,18 +319,17 @@ export default class PostStream extends Component {
|
||||
*
|
||||
* @param {Integer} index
|
||||
* @param {Boolean} animate
|
||||
* @param {Boolean} bottom Whether or not to scroll to the bottom of the post
|
||||
* at the given index, instead of the top of it.
|
||||
* @param {Boolean} reply Whether or not to scroll to the reply placeholder.
|
||||
* @return {jQuery.Deferred}
|
||||
*/
|
||||
scrollToIndex(index, animate, bottom) {
|
||||
const $item = this.$(`.PostStream-item[data-index=${index}]`);
|
||||
scrollToIndex(index, animate, reply) {
|
||||
const $item = reply ? $('.PostStream-item:last-child') : this.$(`.PostStream-item[data-index=${index}]`);
|
||||
|
||||
return this.scrollToItem($item, animate, true, bottom).then(() => {
|
||||
if (index == this.stream.count() - 1) {
|
||||
this.flashItem(this.$('.PostStream-item:last-child'));
|
||||
}
|
||||
});
|
||||
this.scrollToItem($item, animate, true, reply);
|
||||
|
||||
if (reply) {
|
||||
this.flashItem($item);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -317,11 +339,10 @@ export default class PostStream extends Component {
|
||||
* @param {Boolean} animate
|
||||
* @param {Boolean} force Whether or not to force scrolling to the item, even
|
||||
* if it is already in the viewport.
|
||||
* @param {Boolean} bottom Whether or not to scroll to the bottom of the post
|
||||
* at the given index, instead of the top of it.
|
||||
* @param {Boolean} reply Whether or not to scroll to the reply placeholder.
|
||||
* @return {jQuery.Deferred}
|
||||
*/
|
||||
scrollToItem($item, animate, force, bottom) {
|
||||
scrollToItem($item, animate, force, reply) {
|
||||
const $container = $('html, body').stop(true);
|
||||
const index = $item.data('index');
|
||||
|
||||
@@ -332,10 +353,10 @@ export default class PostStream extends Component {
|
||||
const scrollBottom = scrollTop + $(window).height();
|
||||
|
||||
// If the item is already in the viewport, we may not need to scroll.
|
||||
// If we're scrolling to the bottom of an item, then we'll make sure the
|
||||
// If we're scrolling to the reply placeholder, we'll make sure its
|
||||
// bottom will line up with the top of the composer.
|
||||
if (force || itemTop < scrollTop || itemBottom > scrollBottom) {
|
||||
const top = bottom ? itemBottom - $(window).height() + app.composer.computedHeight() : $item.is(':first-child') ? 0 : itemTop;
|
||||
const top = reply ? itemBottom - $(window).height() + app.composer.computedHeight() : $item.is(':first-child') ? 0 : itemTop;
|
||||
|
||||
if (!animate) {
|
||||
$container.scrollTop(top);
|
||||
@@ -349,7 +370,7 @@ export default class PostStream extends Component {
|
||||
// We manually set the index because we want to display the index of the
|
||||
// exact post we've scrolled to, not just that of the first post within viewport.
|
||||
this.updateScrubber();
|
||||
this.stream.index = index;
|
||||
if (index !== undefined) this.stream.index = index + 1;
|
||||
};
|
||||
|
||||
// If we don't update this before the scroll, the scrubber will start
|
||||
@@ -359,26 +380,33 @@ export default class PostStream extends Component {
|
||||
|
||||
return Promise.all([$container.promise(), this.stream.loadPromise]).then(() => {
|
||||
m.redraw.sync();
|
||||
// We want to adjust this again after posts have been loaded in so that
|
||||
// the height of the scrubber is accurate.
|
||||
updateScrubberHeight();
|
||||
|
||||
// After post data has been loaded in, we will attempt to scroll back
|
||||
// to the top of the requested post (or to the top of the page if the
|
||||
// first post was requested). In some cases, we may have scrolled to
|
||||
// the end of the available post range, in which case, the next range
|
||||
// of posts will be loaded in. However, in those cases, the post we
|
||||
// requested won't exist, so scrolling to it would cause an error.
|
||||
// Accordingly, we start by checking that it's offset is defined.
|
||||
const offset = $(`.PostStream-item[data-index=${index}]`).offset();
|
||||
if (index === 0) {
|
||||
// Rendering post contents will probably throw off our position.
|
||||
// To counter this, we'll scroll either:
|
||||
// - To the reply placeholder (aligned with composer top)
|
||||
// - To the top of the page if we're on the first post
|
||||
// - To the top of a post (if that post exists)
|
||||
// If the post does not currently exist, it's probably
|
||||
// outside of the range we loaded in, so we won't adjust anything,
|
||||
// as it will soon be rendered by the "load more" system.
|
||||
let itemOffset;
|
||||
if (reply) {
|
||||
const $placeholder = $('.PostStream-item:last-child');
|
||||
$(window).scrollTop($placeholder.offset().top + $placeholder.height() - $(window).height() + app.composer.computedHeight());
|
||||
} else if (index === 0) {
|
||||
$(window).scrollTop(0);
|
||||
} else if (offset) {
|
||||
$(window).scrollTop($(`.PostStream-item[data-index=${index}]`).offset().top - this.getMarginTop());
|
||||
} else if ((itemOffset = $(`.PostStream-item[data-index=${index}]`).offset())) {
|
||||
$(window).scrollTop(itemOffset.top - this.getMarginTop());
|
||||
}
|
||||
|
||||
// We want to adjust this again after posts have been loaded in
|
||||
// and position adjusted so that the scrubber's height is accurate.
|
||||
updateScrubberHeight();
|
||||
|
||||
this.calculatePosition();
|
||||
this.stream.paused = false;
|
||||
// Check if we need to load more posts after scrolling.
|
||||
this.loadPostsIfNeeded();
|
||||
});
|
||||
}
|
||||
|
||||
@@ -388,10 +416,11 @@ export default class PostStream extends Component {
|
||||
* @param {jQuery} $item
|
||||
*/
|
||||
flashItem($item) {
|
||||
// This might execute before the fadeIn class has been removed in PostStreamItem's
|
||||
// oncreate, so we remove it just to be safe and avoid a double animation.
|
||||
$item.removeClass('fadeIn');
|
||||
$item.addClass('flash').on('animationend webkitAnimationEnd', (e) => {
|
||||
if (e.animationName === 'fadeIn') {
|
||||
$item.removeClass('flash');
|
||||
}
|
||||
$item.removeClass('flash');
|
||||
});
|
||||
}
|
||||
}
|
||||
|
@@ -5,6 +5,7 @@ import avatar from '../../common/helpers/avatar';
|
||||
import username from '../../common/helpers/username';
|
||||
import DiscussionControls from '../utils/DiscussionControls';
|
||||
import ComposerPostPreview from './ComposerPostPreview';
|
||||
import listItems from '../../common/helpers/listItems';
|
||||
|
||||
/**
|
||||
* The `ReplyPlaceholder` component displays a placeholder for a reply, which,
|
||||
@@ -25,6 +26,7 @@ export default class ReplyPlaceholder extends Component {
|
||||
{avatar(app.session.user, { className: 'PostUser-avatar' })}
|
||||
{username(app.session.user)}
|
||||
</h3>
|
||||
<ul className="PostUser-badges badges">{listItems(app.session.user.badges().toArray())}</ul>
|
||||
</div>
|
||||
</header>
|
||||
<ComposerPostPreview className="Post-body" composer={app.composer} surround={this.anchorPreview.bind(this)} />
|
||||
@@ -33,7 +35,7 @@ export default class ReplyPlaceholder extends Component {
|
||||
}
|
||||
|
||||
const reply = () => {
|
||||
DiscussionControls.replyAction.call(this.attrs.discussion, true);
|
||||
DiscussionControls.replyAction.call(this.attrs.discussion, true).catch(() => {});
|
||||
};
|
||||
|
||||
return (
|
||||
|
@@ -21,6 +21,8 @@ import UsersSearchSource from './UsersSearchSource';
|
||||
* - state: SearchState instance.
|
||||
*/
|
||||
export default class Search extends Component {
|
||||
static MIN_SEARCH_LEN = 3;
|
||||
|
||||
oninit(vnode) {
|
||||
super.oninit(vnode);
|
||||
this.state = this.attrs.state;
|
||||
@@ -152,7 +154,7 @@ export default class Search extends Component {
|
||||
search.searchTimeout = setTimeout(() => {
|
||||
if (state.isCached(query)) return;
|
||||
|
||||
if (query.length >= 3) {
|
||||
if (query.length >= Search.MIN_SEARCH_LEN) {
|
||||
search.sources.map((source) => {
|
||||
if (!source.search) return;
|
||||
|
||||
|
@@ -1,9 +1,10 @@
|
||||
import Component from '../../common/Component';
|
||||
import ItemList from '../../common/utils/ItemList';
|
||||
import SuperTextarea from '../../common/utils/SuperTextarea';
|
||||
import listItems from '../../common/helpers/listItems';
|
||||
import Button from '../../common/components/Button';
|
||||
|
||||
import BasicEditorDriver from '../utils/BasicEditorDriver';
|
||||
|
||||
/**
|
||||
* The `TextEditor` component displays a textarea with controls, including a
|
||||
* submit button.
|
||||
@@ -22,25 +23,22 @@ export default class TextEditor extends Component {
|
||||
super.oninit(vnode);
|
||||
|
||||
/**
|
||||
* The value of the textarea.
|
||||
* The value of the editor.
|
||||
*
|
||||
* @type {String}
|
||||
*/
|
||||
this.value = this.attrs.value || '';
|
||||
|
||||
/**
|
||||
* Whether the editor is disabled.
|
||||
*/
|
||||
this.disabled = !!this.attrs.disabled;
|
||||
}
|
||||
|
||||
view() {
|
||||
return (
|
||||
<div className="TextEditor">
|
||||
<textarea
|
||||
className="FormControl Composer-flexible"
|
||||
oninput={(e) => {
|
||||
this.oninput(e.target.value, e);
|
||||
}}
|
||||
placeholder={this.attrs.placeholder || ''}
|
||||
disabled={!!this.attrs.disabled}
|
||||
value={this.value}
|
||||
/>
|
||||
<div className="TextEditor-editorContainer"></div>
|
||||
|
||||
<ul className="TextEditor-controls Composer-footer">
|
||||
{listItems(this.controlItems().toArray())}
|
||||
@@ -53,15 +51,35 @@ export default class TextEditor extends Component {
|
||||
oncreate(vnode) {
|
||||
super.oncreate(vnode);
|
||||
|
||||
const handler = () => {
|
||||
this.onsubmit();
|
||||
m.redraw();
|
||||
this.attrs.composer.editor = this.buildEditor(this.$('.TextEditor-editorContainer')[0]);
|
||||
}
|
||||
|
||||
onupdate() {
|
||||
const newDisabled = !!this.attrs.disabled;
|
||||
|
||||
if (this.disabled !== newDisabled) {
|
||||
this.disabled = newDisabled;
|
||||
this.attrs.composer.editor.disabled(newDisabled);
|
||||
}
|
||||
}
|
||||
|
||||
buildEditorParams() {
|
||||
return {
|
||||
classNames: ['FormControl', 'Composer-flexible', 'TextEditor-editor'],
|
||||
disabled: this.disabled,
|
||||
placeholder: this.attrs.placeholder || '',
|
||||
value: this.value,
|
||||
oninput: this.oninput.bind(this),
|
||||
inputListeners: [],
|
||||
onsubmit: () => {
|
||||
this.onsubmit();
|
||||
m.redraw();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
this.$('textarea').bind('keydown', 'meta+return', handler);
|
||||
this.$('textarea').bind('keydown', 'ctrl+return', handler);
|
||||
|
||||
this.attrs.composer.editor = new SuperTextarea(this.$('textarea')[0]);
|
||||
buildEditor(dom) {
|
||||
return new BasicEditorDriver(dom, this.buildEditorParams());
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -115,12 +133,10 @@ export default class TextEditor extends Component {
|
||||
*
|
||||
* @param {String} value
|
||||
*/
|
||||
oninput(value, e) {
|
||||
oninput(value) {
|
||||
this.value = value;
|
||||
|
||||
this.attrs.onchange(this.value);
|
||||
|
||||
e.redraw = false;
|
||||
}
|
||||
|
||||
/**
|
||||
|
@@ -27,19 +27,6 @@ export default class UserPage extends Page {
|
||||
this.user = null;
|
||||
|
||||
this.bodyClass = 'App--user';
|
||||
|
||||
this.prevUsername = m.route.param('username');
|
||||
}
|
||||
|
||||
onbeforeupdate() {
|
||||
const currUsername = m.route.param('username');
|
||||
if (currUsername !== this.prevUsername) {
|
||||
this.onNewRoute();
|
||||
|
||||
this.prevUsername = currUsername;
|
||||
|
||||
this.loadUser(currUsername);
|
||||
}
|
||||
}
|
||||
|
||||
view() {
|
||||
@@ -115,7 +102,7 @@ export default class UserPage extends Page {
|
||||
});
|
||||
|
||||
if (!this.user) {
|
||||
app.store.find('users', username).then(this.show.bind(this));
|
||||
app.store.find('users', username, { bySlug: true }).then(this.show.bind(this));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -148,7 +135,7 @@ export default class UserPage extends Page {
|
||||
|
||||
items.add(
|
||||
'posts',
|
||||
<LinkButton href={app.route('user.posts', { username: user.username() })} force icon="far fa-comment">
|
||||
<LinkButton href={app.route('user.posts', { username: user.username() })} icon="far fa-comment">
|
||||
{app.translator.trans('core.forum.user.posts_link')} <span className="Button-badge">{user.commentCount()}</span>
|
||||
</LinkButton>,
|
||||
100
|
||||
@@ -156,7 +143,7 @@ export default class UserPage extends Page {
|
||||
|
||||
items.add(
|
||||
'discussions',
|
||||
<LinkButton href={app.route('user.discussions', { username: user.username() })} force icon="fas fa-bars">
|
||||
<LinkButton href={app.route('user.discussions', { username: user.username() })} icon="fas fa-bars">
|
||||
{app.translator.trans('core.forum.user.discussions_link')} <span className="Button-badge">{user.discussionCount()}</span>
|
||||
</LinkButton>,
|
||||
90
|
||||
|
@@ -15,8 +15,9 @@ export { app };
|
||||
// export { IndexPage, DicsussionList } from './components';
|
||||
|
||||
// Export compat API
|
||||
import compat from './compat';
|
||||
import compatObj from './compat';
|
||||
import proxifyCompat from '../common/utils/proxifyCompat';
|
||||
|
||||
compat.app = app;
|
||||
compatObj.app = app;
|
||||
|
||||
export { compat };
|
||||
export const compat = proxifyCompat(compatObj, 'forum');
|
||||
|
55
js/src/forum/resolvers/DiscussionPageResolver.ts
Normal file
55
js/src/forum/resolvers/DiscussionPageResolver.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
import DefaultResolver from '../../common/resolvers/DefaultResolver';
|
||||
import DiscussionPage from '../components/DiscussionPage';
|
||||
|
||||
/**
|
||||
* A custom route resolver for DiscussionPage that generates the same key to all posts
|
||||
* on the same discussion. It triggers a scroll when going from one post to another
|
||||
* in the same discussion.
|
||||
*/
|
||||
export default class DiscussionPageResolver extends DefaultResolver {
|
||||
static scrollToPostNumber: string | null = null;
|
||||
|
||||
/**
|
||||
* Remove optional parts of a discussion's slug to keep the substring
|
||||
* that bijectively maps to a discussion object. By default this just
|
||||
* extracts the numerical ID from the slug. If a custom discussion
|
||||
* slugging driver is used, this may need to be overriden.
|
||||
* @param slug
|
||||
*/
|
||||
canonicalizeDiscussionSlug(slug: string | undefined) {
|
||||
if (!slug) return;
|
||||
return slug.split('-')[0];
|
||||
}
|
||||
|
||||
/**
|
||||
* @inheritdoc
|
||||
*/
|
||||
makeKey() {
|
||||
const params = { ...m.route.param() };
|
||||
if ('near' in params) {
|
||||
delete params.near;
|
||||
}
|
||||
params.id = this.canonicalizeDiscussionSlug(params.id);
|
||||
return this.routeName.replace('.near', '') + JSON.stringify(params);
|
||||
}
|
||||
|
||||
onmatch(args, requestedPath, route) {
|
||||
if (app.current.matches(DiscussionPage) && this.canonicalizeDiscussionSlug(args.id) === this.canonicalizeDiscussionSlug(m.route.param('id'))) {
|
||||
// By default, the first post number of any discussion is 1
|
||||
DiscussionPageResolver.scrollToPostNumber = args.near || '1';
|
||||
}
|
||||
|
||||
return super.onmatch(args, requestedPath, route);
|
||||
}
|
||||
|
||||
render(vnode) {
|
||||
if (DiscussionPageResolver.scrollToPostNumber !== null) {
|
||||
const number = DiscussionPageResolver.scrollToPostNumber;
|
||||
// Scroll after a timeout to avoid clashes with the render.
|
||||
setTimeout(() => app.current.get('stream').goToNumber(number));
|
||||
DiscussionPageResolver.scrollToPostNumber = null;
|
||||
}
|
||||
|
||||
return super.render(vnode);
|
||||
}
|
||||
}
|
@@ -4,6 +4,7 @@ import PostsUserPage from './components/PostsUserPage';
|
||||
import DiscussionsUserPage from './components/DiscussionsUserPage';
|
||||
import SettingsPage from './components/SettingsPage';
|
||||
import NotificationsPage from './components/NotificationsPage';
|
||||
import DiscussionPageResolver from './resolvers/DiscussionPageResolver';
|
||||
|
||||
/**
|
||||
* The `routes` initializer defines the forum app's routes.
|
||||
@@ -14,8 +15,8 @@ export default function (app) {
|
||||
app.routes = {
|
||||
index: { path: '/all', component: IndexPage },
|
||||
|
||||
discussion: { path: '/d/:id', component: DiscussionPage },
|
||||
'discussion.near': { path: '/d/:id/:near', component: DiscussionPage },
|
||||
discussion: { path: '/d/:id', component: DiscussionPage, resolverClass: DiscussionPageResolver },
|
||||
'discussion.near': { path: '/d/:id/:near', component: DiscussionPage, resolverClass: DiscussionPageResolver },
|
||||
|
||||
user: { path: '/u/:username', component: PostsUserPage },
|
||||
'user.posts': { path: '/u/:username', component: PostsUserPage },
|
||||
@@ -33,9 +34,8 @@ export default function (app) {
|
||||
* @return {String}
|
||||
*/
|
||||
app.route.discussion = (discussion, near) => {
|
||||
const slug = discussion.slug();
|
||||
return app.route(near && near !== 1 ? 'discussion.near' : 'discussion', {
|
||||
id: discussion.id() + (slug.trim() ? '-' + slug : ''),
|
||||
id: discussion.slug(),
|
||||
near: near && near !== 1 ? near : undefined,
|
||||
});
|
||||
};
|
||||
@@ -58,7 +58,7 @@ export default function (app) {
|
||||
*/
|
||||
app.route.user = (user) => {
|
||||
return app.route('user', {
|
||||
username: user.username(),
|
||||
username: user.slug(),
|
||||
});
|
||||
};
|
||||
}
|
||||
|
@@ -1,6 +1,7 @@
|
||||
import subclassOf from '../../common/utils/subclassOf';
|
||||
import Stream from '../../common/utils/Stream';
|
||||
import ReplyComposer from '../components/ReplyComposer';
|
||||
import EditorDriverInterface from '../utils/EditorDriverInterface';
|
||||
|
||||
class ComposerState {
|
||||
constructor() {
|
||||
@@ -29,16 +30,11 @@ class ComposerState {
|
||||
/**
|
||||
* A reference to the text editor that allows text manipulation.
|
||||
*
|
||||
* @type {SuperTextArea|null}
|
||||
* @type {EditorDriverInterface|null}
|
||||
*/
|
||||
this.editor = null;
|
||||
|
||||
this.clear();
|
||||
|
||||
/**
|
||||
* @deprecated BC layer, remove in Beta 15.
|
||||
*/
|
||||
this.component = this;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -71,18 +67,16 @@ class ComposerState {
|
||||
clear() {
|
||||
this.position = ComposerState.Position.HIDDEN;
|
||||
this.body = { attrs: {} };
|
||||
this.editor = null;
|
||||
this.onExit = null;
|
||||
|
||||
this.fields = {
|
||||
content: Stream(''),
|
||||
};
|
||||
|
||||
/**
|
||||
* @deprecated BC layer, remove in Beta 15.
|
||||
*/
|
||||
this.content = this.fields.content;
|
||||
this.value = this.fields.content;
|
||||
if (this.editor) {
|
||||
this.editor.destroy();
|
||||
}
|
||||
this.editor = null;
|
||||
}
|
||||
|
||||
/**
|
||||
|
@@ -1,3 +1,4 @@
|
||||
import { throttle } from 'lodash-es';
|
||||
import anchorScroll from '../../common/utils/anchorScroll';
|
||||
|
||||
class PostStreamState {
|
||||
@@ -49,6 +50,9 @@ class PostStreamState {
|
||||
*/
|
||||
this.forceUpdateScrubber = false;
|
||||
|
||||
this.loadNext = throttle(this._loadNext, 300);
|
||||
this.loadPrevious = throttle(this._loadPrevious, 300);
|
||||
|
||||
this.show(includedPosts);
|
||||
}
|
||||
|
||||
@@ -96,7 +100,9 @@ class PostStreamState {
|
||||
// If we want to go to the reply preview, then we will go to the end of the
|
||||
// discussion and then scroll to the very bottom of the page.
|
||||
if (number === 'reply') {
|
||||
return this.goToLast();
|
||||
const resultPromise = this.goToLast();
|
||||
this.targetPost.reply = true;
|
||||
return resultPromise;
|
||||
}
|
||||
|
||||
this.paused = true;
|
||||
@@ -170,7 +176,7 @@ class PostStreamState {
|
||||
* @return {Promise}
|
||||
*/
|
||||
loadNearIndex(index) {
|
||||
if (index >= this.visibleStart && index <= this.visibleEnd) {
|
||||
if (index >= this.visibleStart && index < this.visibleEnd) {
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
@@ -185,7 +191,7 @@ class PostStreamState {
|
||||
/**
|
||||
* Load the next page of posts.
|
||||
*/
|
||||
loadNext() {
|
||||
_loadNext() {
|
||||
const start = this.visibleEnd;
|
||||
const end = (this.visibleEnd = this.sanitizeIndex(this.visibleEnd + this.constructor.loadCount));
|
||||
|
||||
@@ -208,7 +214,7 @@ class PostStreamState {
|
||||
/**
|
||||
* Load the previous page of posts.
|
||||
*/
|
||||
loadPrevious() {
|
||||
_loadPrevious() {
|
||||
const end = this.visibleStart;
|
||||
const start = (this.visibleStart = this.sanitizeIndex(this.visibleStart - this.constructor.loadCount));
|
||||
|
||||
@@ -236,23 +242,26 @@ class PostStreamState {
|
||||
* @param {Boolean} backwards
|
||||
*/
|
||||
loadPage(start, end, backwards = false) {
|
||||
m.redraw();
|
||||
this.pagesLoading++;
|
||||
|
||||
const redraw = () => {
|
||||
if (start < this.visibleStart || end > this.visibleEnd) return;
|
||||
|
||||
const anchorIndex = backwards ? this.visibleEnd - 1 : this.visibleStart;
|
||||
anchorScroll(`.PostStream-item[data-index="${anchorIndex}"]`, m.redraw.sync);
|
||||
};
|
||||
redraw();
|
||||
|
||||
this.loadPageTimeouts[start] = setTimeout(
|
||||
() => {
|
||||
this.loadRange(start, end).then(() => {
|
||||
if (start >= this.visibleStart && end <= this.visibleEnd) {
|
||||
const anchorIndex = backwards ? this.visibleEnd - 1 : this.visibleStart;
|
||||
anchorScroll(`.PostStream-item[data-index="${anchorIndex}"]`, () => m.redraw.sync());
|
||||
}
|
||||
redraw();
|
||||
this.pagesLoading--;
|
||||
});
|
||||
this.loadPageTimeouts[start] = null;
|
||||
},
|
||||
this.pagesLoading ? 1000 : 0
|
||||
this.pagesLoading - 1 ? 1000 : 0
|
||||
);
|
||||
|
||||
this.pagesLoading++;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -280,7 +289,13 @@ class PostStreamState {
|
||||
}
|
||||
});
|
||||
|
||||
return loadIds.length ? app.store.find('posts', loadIds) : Promise.resolve(loaded);
|
||||
if (loadIds.length) {
|
||||
return app.store.find('posts', loadIds).then((newPosts) => {
|
||||
return loaded.concat(newPosts).sort((a, b) => a.createdAt() - b.createdAt());
|
||||
});
|
||||
}
|
||||
|
||||
return Promise.resolve(loaded);
|
||||
}
|
||||
|
||||
/**
|
||||
|
124
js/src/forum/utils/BasicEditorDriver.ts
Normal file
124
js/src/forum/utils/BasicEditorDriver.ts
Normal file
@@ -0,0 +1,124 @@
|
||||
import getCaretCoordinates from 'textarea-caret';
|
||||
import EditorDriverInterface, { EditorDriverParams } from './EditorDriverInterface';
|
||||
|
||||
export default class BasicEditorDriver implements EditorDriverInterface {
|
||||
el: HTMLTextAreaElement;
|
||||
|
||||
constructor(dom: HTMLElement, params: EditorDriverParams) {
|
||||
this.el = document.createElement('textarea');
|
||||
|
||||
this.build(dom, params);
|
||||
}
|
||||
|
||||
build(dom: HTMLElement, params: EditorDriverParams) {
|
||||
this.el.className = params.classNames.join(' ');
|
||||
this.el.disabled = params.disabled;
|
||||
this.el.placeholder = params.placeholder;
|
||||
this.el.value = params.value;
|
||||
|
||||
const callInputListeners = (e) => {
|
||||
params.inputListeners.forEach((listener) => {
|
||||
listener();
|
||||
});
|
||||
|
||||
e.redraw = false;
|
||||
};
|
||||
|
||||
this.el.oninput = (e) => {
|
||||
params.oninput(this.el.value);
|
||||
callInputListeners(e);
|
||||
};
|
||||
|
||||
this.el.onclick = callInputListeners;
|
||||
this.el.onkeyup = callInputListeners;
|
||||
|
||||
this.el.addEventListener('keydown', function (e) {
|
||||
if ((e.metaKey || e.ctrlKey) && e.key === 'Enter') {
|
||||
params.onsubmit();
|
||||
}
|
||||
});
|
||||
|
||||
dom.append(this.el);
|
||||
}
|
||||
|
||||
protected setValue(value: string) {
|
||||
$(this.el).val(value).trigger('input');
|
||||
|
||||
this.el.dispatchEvent(new CustomEvent('input', { bubbles: true, cancelable: true }));
|
||||
}
|
||||
|
||||
moveCursorTo(position: number) {
|
||||
this.setSelectionRange(position, position);
|
||||
}
|
||||
|
||||
getSelectionRange(): Array<number> {
|
||||
return [this.el.selectionStart, this.el.selectionEnd];
|
||||
}
|
||||
|
||||
getLastNChars(n: number): string {
|
||||
const value = this.el.value;
|
||||
|
||||
return value.slice(Math.max(0, this.el.selectionStart - n), this.el.selectionStart);
|
||||
}
|
||||
|
||||
insertAtCursor(text: string) {
|
||||
this.insertAt(this.el.selectionStart, text);
|
||||
}
|
||||
|
||||
insertAt(pos: number, text: string) {
|
||||
this.insertBetween(pos, pos, text);
|
||||
}
|
||||
|
||||
insertBetween(start: number, end: number, text: string) {
|
||||
const value = this.el.value;
|
||||
|
||||
const before = value.slice(0, start);
|
||||
const after = value.slice(end);
|
||||
|
||||
this.setValue(`${before}${text}${after}`);
|
||||
|
||||
// Move the textarea cursor to the end of the content we just inserted.
|
||||
this.moveCursorTo(start + text.length);
|
||||
}
|
||||
|
||||
replaceBeforeCursor(start: number, text: string) {
|
||||
this.insertBetween(start, this.el.selectionStart, text);
|
||||
}
|
||||
|
||||
protected setSelectionRange(start: number, end: number) {
|
||||
this.el.setSelectionRange(start, end);
|
||||
this.focus();
|
||||
}
|
||||
|
||||
getCaretCoordinates(position: number) {
|
||||
const relCoords = getCaretCoordinates(this.el, position);
|
||||
|
||||
return {
|
||||
top: relCoords.top - this.el.scrollTop,
|
||||
left: relCoords.left,
|
||||
};
|
||||
}
|
||||
|
||||
// DOM Interactions
|
||||
|
||||
/**
|
||||
* Set the disabled status of the editor.
|
||||
*/
|
||||
disabled(disabled: boolean) {
|
||||
this.el.disabled = disabled;
|
||||
}
|
||||
|
||||
/**
|
||||
* Focus on the editor.
|
||||
*/
|
||||
focus() {
|
||||
this.el.focus();
|
||||
}
|
||||
|
||||
/**
|
||||
* Destroy the editor
|
||||
*/
|
||||
destroy() {
|
||||
this.el.remove();
|
||||
}
|
||||
}
|
105
js/src/forum/utils/EditorDriverInterface.ts
Normal file
105
js/src/forum/utils/EditorDriverInterface.ts
Normal file
@@ -0,0 +1,105 @@
|
||||
export interface EditorDriverParams {
|
||||
/**
|
||||
* An array of HTML class names to apply to the editor's main DOM element.
|
||||
*/
|
||||
classNames: string[];
|
||||
|
||||
/**
|
||||
* Whether the editor should be initially disabled.
|
||||
*/
|
||||
disabled: boolean;
|
||||
|
||||
/**
|
||||
* An optional placeholder for the editor.
|
||||
*/
|
||||
placeholder: string;
|
||||
|
||||
/**
|
||||
* An optional initial value for the editor.
|
||||
*/
|
||||
value: string;
|
||||
|
||||
/**
|
||||
* This is separate from inputListeners since the full serialized content will be passed to it.
|
||||
* It is considered private API, and should not be used/modified by extensions not implementing
|
||||
* EditorDriverInterface.
|
||||
*/
|
||||
oninput: Function;
|
||||
|
||||
/**
|
||||
* Each of these functions will be called on click, input, and keyup.
|
||||
* No arguments will be passed.
|
||||
*/
|
||||
inputListeners: Function[];
|
||||
|
||||
/**
|
||||
* This function will be called if submission is triggered programmatically via keybind.
|
||||
* No arguments should be passed.
|
||||
*/
|
||||
onsubmit: Function;
|
||||
}
|
||||
|
||||
export default interface EditorDriverInterface {
|
||||
/**
|
||||
* Focus the editor and place the cursor at the given position.
|
||||
*/
|
||||
moveCursorTo(position: number): void;
|
||||
|
||||
/**
|
||||
* Get the selected range of the editor.
|
||||
*/
|
||||
getSelectionRange(): Array<number>;
|
||||
|
||||
/**
|
||||
* Get the last N characters from the current "text block".
|
||||
*
|
||||
* A textarea-based driver would just return the last N characters,
|
||||
* but more advanced implementations might restrict to the current block.
|
||||
*
|
||||
* This is useful for monitoring recent user input to trigger autocomplete.
|
||||
*/
|
||||
getLastNChars(n: number): string;
|
||||
|
||||
/**
|
||||
* Insert content into the editor at the position of the cursor.
|
||||
*/
|
||||
insertAtCursor(text: string, escape: boolean): void;
|
||||
|
||||
/**
|
||||
* Insert content into the editor at the given position.
|
||||
*/
|
||||
insertAt(pos: number, text: string, escape: boolean): void;
|
||||
|
||||
/**
|
||||
* Insert content into the editor between the given positions.
|
||||
*
|
||||
* If the start and end positions are different, any text between them will be
|
||||
* overwritten.
|
||||
*/
|
||||
insertBetween(start: number, end: number, text: string, escape: boolean): void;
|
||||
|
||||
/**
|
||||
* Replace existing content from the start to the current cursor position.
|
||||
*/
|
||||
replaceBeforeCursor(start: number, text: string, escape: boolean): void;
|
||||
|
||||
/**
|
||||
* Get left and top coordinates of the caret relative to the editor viewport.
|
||||
*/
|
||||
getCaretCoordinates(position: number): { left: number; top: number };
|
||||
|
||||
/**
|
||||
* Set the disabled status of the editor.
|
||||
*/
|
||||
disabled(disabled: boolean): void;
|
||||
|
||||
/**
|
||||
* Focus on the editor.
|
||||
*/
|
||||
focus(): void;
|
||||
|
||||
/**
|
||||
* Destroy the editor
|
||||
*/
|
||||
destroy(): void;
|
||||
}
|
@@ -57,7 +57,7 @@ export default {
|
||||
moderationControls(user) {
|
||||
const items = new ItemList();
|
||||
|
||||
if (user.canEdit()) {
|
||||
if (user.canEdit() || user.canEditCredentials() || user.canEditGroups()) {
|
||||
items.add(
|
||||
'edit',
|
||||
<Button icon="fas fa-pencil-alt" onclick={this.editAction.bind(this, user)}>
|
||||
|
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"include": ["src/**/*.ts"],
|
||||
"include": ["src/**/*.ts", "src/**/*.tsx"],
|
||||
"files": ["shims.d.ts"],
|
||||
"compilerOptions": {
|
||||
"allowUmdGlobalAccess": true,
|
||||
|
@@ -1,10 +1,12 @@
|
||||
@import "common/common";
|
||||
|
||||
@import "admin/AdminHeader";
|
||||
@import "admin/AdminNav";
|
||||
@import "admin/DashboardPage";
|
||||
@import "admin/BasicsPage";
|
||||
@import "admin/PermissionsPage";
|
||||
@import "admin/EditGroupModal";
|
||||
@import "admin/ExtensionsPage";
|
||||
@import "admin/ExtensionPage";
|
||||
@import "admin/ExtensionWidget";
|
||||
@import "admin/AppearancePage";
|
||||
@import "admin/MailPage";
|
||||
|
20
less/admin/AdminHeader.less
Normal file
20
less/admin/AdminHeader.less
Normal file
@@ -0,0 +1,20 @@
|
||||
.AdminHeader {
|
||||
background: @control-bg;
|
||||
margin-bottom: 20px;
|
||||
padding: 20px 0;
|
||||
|
||||
h2 {
|
||||
margin-top: 0;
|
||||
margin-bottom: 10px;
|
||||
color: @muted-color;
|
||||
}
|
||||
|
||||
.AdminHeader-description {
|
||||
margin: 0;
|
||||
color: @control-color;
|
||||
}
|
||||
|
||||
.icon {
|
||||
margin-right: 15px;
|
||||
}
|
||||
}
|
@@ -1,18 +1,83 @@
|
||||
@admin-pane-width: 300px;
|
||||
@admin-pane-width: 250px;
|
||||
|
||||
.App {
|
||||
padding-bottom: 0;
|
||||
}
|
||||
.AdminLinkButton-description {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.AdminContent {
|
||||
padding: 20px 0;
|
||||
}
|
||||
.App-content .sideNavOffset {
|
||||
margin-top: 0;
|
||||
}
|
||||
@media @desktop, @desktop-hd {
|
||||
|
||||
.Header-controls {
|
||||
> li {
|
||||
margin-left: 10px;
|
||||
}
|
||||
}
|
||||
|
||||
@media @phone {
|
||||
.Dropdown-menu {
|
||||
height: 70vh;
|
||||
|
||||
.item-search {
|
||||
margin: 10px;
|
||||
|
||||
.SearchBar {
|
||||
width: 100%
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.ExtensionNavButton {
|
||||
.Button-label {
|
||||
margin-left: 30px;
|
||||
}
|
||||
.ExtensionIcon {
|
||||
margin: 0 0 0 -4px !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@media @tablet {
|
||||
.AdminNav {
|
||||
.item-search,
|
||||
li[class^="item-category"],
|
||||
li[class^="item-extension"],
|
||||
.AdminLinkButton-description {
|
||||
display: none !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@media @phone, @tablet {
|
||||
.App-nav .AdminNav {
|
||||
.Dropdown-menu {
|
||||
> li {
|
||||
.ExtensionListTitle {
|
||||
color: @muted-color;
|
||||
text-transform: uppercase;
|
||||
margin: 25px 0 10px 15px;
|
||||
}
|
||||
|
||||
.ExtensionIcon {
|
||||
margin: -2px -29px;
|
||||
width: 25px;
|
||||
height: 25px;
|
||||
font-size: 12.5px;
|
||||
|
||||
.icon {
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@media @desktop-up {
|
||||
.App-nav {
|
||||
position: absolute;
|
||||
top: @header-height;
|
||||
@@ -20,60 +85,95 @@
|
||||
width: @admin-pane-width;
|
||||
.box-shadow(0 6px 6px @shadow-color);
|
||||
background: @body-bg;
|
||||
border-top: 1px solid @control-bg;
|
||||
z-index: @zindex-pane;
|
||||
overflow: auto;
|
||||
overflow-y: scroll;
|
||||
padding-bottom: 40px;
|
||||
|
||||
.affix & {
|
||||
position: fixed;
|
||||
bottom: 0;
|
||||
height: auto;
|
||||
}
|
||||
}
|
||||
.App-content .sideNavOffset {
|
||||
margin-left: @admin-pane-width;
|
||||
}
|
||||
.App-nav .AdminNav {
|
||||
.Dropdown-menu > li {
|
||||
> a {
|
||||
padding: 15px 15px 15px 45px;
|
||||
display: block;
|
||||
text-decoration: none;
|
||||
white-space: normal;
|
||||
.Dropdown-menu {
|
||||
.item-search {
|
||||
margin-top: 10px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
> a, > a:hover, &.active > a {
|
||||
color: @muted-color;
|
||||
}
|
||||
> a:hover {
|
||||
background: @control-bg;
|
||||
}
|
||||
&.active > a {
|
||||
background: @control-bg;
|
||||
font-weight: normal;
|
||||
|
||||
.Button-label, .Button-icon {
|
||||
.item-category-core {
|
||||
> .ExtensionListTitle {
|
||||
margin-top: 10px;
|
||||
}
|
||||
}
|
||||
|
||||
> li {
|
||||
> a {
|
||||
padding: 10px 10px 10px 45px;
|
||||
display: block;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
> a,
|
||||
> a:hover,
|
||||
&.active > a {
|
||||
color: @text-color;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
> a:hover {
|
||||
background: @control-bg;
|
||||
}
|
||||
|
||||
&.active > a {
|
||||
background: @control-color;
|
||||
font-weight: normal;
|
||||
color: @body-bg;
|
||||
|
||||
.Button-label,
|
||||
.Button-icon {
|
||||
font-weight: bold;
|
||||
}
|
||||
}
|
||||
|
||||
.Button-icon {
|
||||
float: left;
|
||||
font-size: 13px !important;
|
||||
margin-left: -25px !important;
|
||||
margin-top: 4px !important;
|
||||
}
|
||||
|
||||
.Button-label {
|
||||
padding-left: 5px;
|
||||
font-size: 14px;
|
||||
font-weight: normal;
|
||||
}
|
||||
|
||||
.Search-input,
|
||||
.SearchBar {
|
||||
max-width: 215px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.ExtensionListTitle {
|
||||
color: @muted-color;
|
||||
text-transform: uppercase;
|
||||
margin: 25px 0 8px 15px;
|
||||
}
|
||||
|
||||
.ExtensionIcon {
|
||||
width: 25px;
|
||||
height: 25px;
|
||||
font-size: 15px;
|
||||
margin-left: -29px;
|
||||
vertical-align: middle;
|
||||
}
|
||||
}
|
||||
}
|
||||
.Button-icon {
|
||||
float: left;
|
||||
margin-left: -30px;
|
||||
font-size: 14px;
|
||||
margin-top: 4px !important;
|
||||
}
|
||||
.Button-label {
|
||||
display: block;
|
||||
font-size: 15px;
|
||||
font-weight: normal;
|
||||
margin: 0 0 5px;
|
||||
}
|
||||
.AdminLinkButton-description {
|
||||
display: block;
|
||||
font-size: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
.container {
|
||||
width: 100%;
|
||||
margin: 0;
|
||||
@@ -85,4 +185,39 @@
|
||||
padding: 0;
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
.AdminLinkButton-description {
|
||||
white-space: normal;
|
||||
padding-left: 5px;
|
||||
}
|
||||
|
||||
.ExtensionListItem-Dot {
|
||||
height: 10px;
|
||||
width: 10px;
|
||||
border-radius: 50%;
|
||||
display: inline-block;
|
||||
right: 13px;
|
||||
margin: 6px 5px;
|
||||
position: absolute;
|
||||
}
|
||||
|
||||
.ExtensionNavButton {
|
||||
.Button-label {
|
||||
display: inline-block;
|
||||
max-width: ~"calc(100% - 18px)";
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
vertical-align: middle;
|
||||
margin-left: 5px;
|
||||
}
|
||||
}
|
||||
|
||||
.ExtensionListItem-Dot.enabled {
|
||||
background-color: #2ECC40;
|
||||
}
|
||||
.ExtensionListItem-Dot.disabled {
|
||||
border: 2px solid #FF4136;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
@@ -1,8 +1,9 @@
|
||||
.AppearancePage {
|
||||
padding-bottom: 30px;
|
||||
|
||||
@media @desktop-up {
|
||||
.container {
|
||||
max-width: 600px;
|
||||
padding: 30px;
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
@@ -14,8 +15,16 @@
|
||||
.AppearancePage-colors-input {
|
||||
overflow: hidden;
|
||||
|
||||
.Form-group {
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.Form-group:last-child {
|
||||
margin-bottom: 24px !important;
|
||||
margin-left: 10px;
|
||||
}
|
||||
|
||||
input {
|
||||
width: 49%;
|
||||
float: left;
|
||||
|
||||
&:first-child {
|
||||
@@ -23,7 +32,7 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
.AppearancePage-colors-input,
|
||||
|
||||
.AppearancePage-colors .Checkbox {
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
@@ -1,5 +1,5 @@
|
||||
.BasicsPage {
|
||||
padding: 20px 0;
|
||||
padding-bottom: 30px;
|
||||
|
||||
@media @desktop-up {
|
||||
.container {
|
||||
@@ -8,26 +8,27 @@
|
||||
}
|
||||
}
|
||||
|
||||
fieldset {
|
||||
.Form-group {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
> ul {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
ul {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.BasicsPage-welcomeBanner-input {
|
||||
:first-child {
|
||||
input {
|
||||
margin-bottom: 1px;
|
||||
border-bottom-right-radius: 0;
|
||||
border-bottom-left-radius: 0;
|
||||
font-weight: bold;
|
||||
}
|
||||
:last-child {
|
||||
textarea {
|
||||
border-top-right-radius: 0;
|
||||
border-top-left-radius: 0;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
}
|
||||
|
@@ -1,33 +1,31 @@
|
||||
.DashboardPage {
|
||||
background: @control-bg;
|
||||
background: @body-bg;
|
||||
color: @control-color;
|
||||
min-height: 100vh;
|
||||
|
||||
@media @desktop-up {
|
||||
.container {
|
||||
padding: 30px;
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
padding-bottom: 30px;
|
||||
}
|
||||
|
||||
.DashboardWidget {
|
||||
background: @body-bg;
|
||||
.Widget {
|
||||
background: @control-bg;
|
||||
color: @text-color;
|
||||
border-radius: @border-radius;
|
||||
padding: 20px;
|
||||
margin-bottom: 20px;
|
||||
|
||||
.Button {
|
||||
.Button--color(@control-color, @body-bg)
|
||||
}
|
||||
}
|
||||
|
||||
.StatusWidget {
|
||||
color: @muted-color;
|
||||
|
||||
> ul {
|
||||
>ul {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style-type: none;
|
||||
|
||||
> li {
|
||||
>li {
|
||||
display: inline-block;
|
||||
margin-right: 30px;
|
||||
vertical-align: middle;
|
||||
@@ -38,6 +36,7 @@
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
&.item-tools {
|
||||
float: right;
|
||||
margin-right: 0;
|
||||
|
158
less/admin/ExtensionPage.less
Normal file
158
less/admin/ExtensionPage.less
Normal file
@@ -0,0 +1,158 @@
|
||||
.ExtensionPage {
|
||||
|
||||
.ExtensionPage-header {
|
||||
.ExtensionTitle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
margin: 20px 0 15px;
|
||||
}
|
||||
|
||||
.helpText {
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
|
||||
h2 {
|
||||
display: inline-block;
|
||||
margin: 0;
|
||||
vertical-align: middle;
|
||||
}
|
||||
}
|
||||
|
||||
.ExtensionPage-header,
|
||||
.ExtensionPage-permissions-header {
|
||||
background: @control-bg;
|
||||
|
||||
h2 {
|
||||
color: @muted-color;
|
||||
|
||||
span {
|
||||
font-size: 13px;
|
||||
color: @muted-color;
|
||||
font-weight: normal;
|
||||
}
|
||||
}
|
||||
|
||||
.Button-icon {
|
||||
display: unset;
|
||||
}
|
||||
|
||||
ul {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
list-style-type: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
|
||||
> li {
|
||||
display: inline;
|
||||
color: @muted-color;
|
||||
margin-left: 13px;
|
||||
|
||||
|
||||
> a {
|
||||
color: @muted-color;
|
||||
}
|
||||
|
||||
> .icon {
|
||||
margin-right: 5px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.ExtensionPage-headerItems {
|
||||
padding: 15px 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
|
||||
.Checkbox {
|
||||
margin: 5px 0 0 0;
|
||||
display: inline-block;
|
||||
}
|
||||
}
|
||||
|
||||
.Checkbox.off {
|
||||
.Checkbox-display {
|
||||
background: @muted-more-color;
|
||||
}
|
||||
}
|
||||
|
||||
.ExtensionInfo {
|
||||
margin-left: auto;
|
||||
|
||||
.item-authors {
|
||||
a {
|
||||
color: @muted-color;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
.ExtensionName {
|
||||
display: inline-block;
|
||||
margin-left: 8px;
|
||||
}
|
||||
|
||||
.ExtensionIcon {
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
font-size: 15px;
|
||||
margin-left: 0;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.ExtensionPage-headerTopItems {
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
@media (max-width: @screen-phone-max) {
|
||||
.ExtensionPage-headerTopItems {
|
||||
float: right;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.item-website, .item-source, .item-documentation {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.ExtensionPage-settings, .ExtensionPage-permissions {
|
||||
.ExtensionPage-subHeader {
|
||||
margin: 5px 0px;
|
||||
}
|
||||
}
|
||||
|
||||
.ExtensionPage-settings {
|
||||
margin-top: 20px;
|
||||
padding: 10px 0;
|
||||
|
||||
input {
|
||||
max-width: 400px;
|
||||
}
|
||||
}
|
||||
|
||||
.ExtensionPage-subHeader {
|
||||
color: @muted-color;
|
||||
font-weight: normal;
|
||||
}
|
||||
|
||||
|
||||
.ExtensionPage-permissions {
|
||||
|
||||
.PermissionGrid-removeScope {
|
||||
display: none;
|
||||
}
|
||||
|
||||
> .container {
|
||||
overflow-x: auto;
|
||||
padding-bottom: 25vh;
|
||||
}
|
||||
|
||||
.ExtensionPage-permissions-header {
|
||||
margin: 20px 0 20px;
|
||||
padding: 5px 0;
|
||||
}
|
||||
}
|
||||
}
|
91
less/admin/ExtensionWidget.less
Normal file
91
less/admin/ExtensionWidget.less
Normal file
@@ -0,0 +1,91 @@
|
||||
.ExtensionsWidget {
|
||||
background-color: @body-bg;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.ExtensionsWidget-list {
|
||||
padding: 0;
|
||||
background-color: @body-bg;
|
||||
|
||||
.ExtensionGroup {
|
||||
margin-bottom: 20px;
|
||||
|
||||
h3 {
|
||||
color: @muted-color;
|
||||
text-transform: uppercase;
|
||||
font-size: 12px;
|
||||
margin: 0 0 10px;
|
||||
}
|
||||
}
|
||||
|
||||
.ExtensionList {
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
display: grid;
|
||||
grid-gap: 10px;
|
||||
grid-template-columns: repeat(auto-fit, 90px);
|
||||
margin-bottom: 0;
|
||||
|
||||
> li {
|
||||
text-align: left;
|
||||
position: relative;
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.ExtensionList-Category {
|
||||
background: @control-bg;
|
||||
padding: 20px 0 20px 20px;
|
||||
margin-bottom: 20px;
|
||||
border-radius: @border-radius;
|
||||
}
|
||||
|
||||
.ExtensionList-Label {
|
||||
margin-top: 0;
|
||||
color: @muted-color;
|
||||
}
|
||||
|
||||
.ExtensionListItem.disabled {
|
||||
.ExtensionListItem-title {
|
||||
opacity: 0.5;
|
||||
color: @muted-color;
|
||||
}
|
||||
|
||||
.ExtensionListItem-icon {
|
||||
opacity: 0.5;
|
||||
}
|
||||
}
|
||||
|
||||
.ExtensionListItem {
|
||||
transition: .15s ease-in-out;
|
||||
|
||||
&:hover {
|
||||
transform: scale(1.05);
|
||||
}
|
||||
|
||||
a:hover {
|
||||
text-decoration: none;
|
||||
}
|
||||
}
|
||||
|
||||
.ExtensionListItem-title {
|
||||
display: block;
|
||||
text-align: center;
|
||||
margin-top: 5px;
|
||||
color: @text-color;
|
||||
}
|
||||
|
||||
.ExtensionIcon {
|
||||
width: 90px;
|
||||
height: 90px;
|
||||
background: @control-bg;
|
||||
color: @control-color;
|
||||
border-radius: 6px;
|
||||
display: inline-flex;
|
||||
font-size: 45px;
|
||||
text-align: center;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
vertical-align: middle;
|
||||
}
|
@@ -1,115 +0,0 @@
|
||||
@extension-list-column-width: 410px;
|
||||
|
||||
.ExtensionsPage-header {
|
||||
padding: 20px 0;
|
||||
background: @control-bg;
|
||||
}
|
||||
|
||||
.ExtensionsPage-list {
|
||||
padding: 30px 0;
|
||||
}
|
||||
.ExtensionGroup {
|
||||
margin-bottom: 20px;
|
||||
|
||||
h3 {
|
||||
color: @muted-color;
|
||||
text-transform: uppercase;
|
||||
font-size: 12px;
|
||||
margin: 0 0 10px;
|
||||
}
|
||||
}
|
||||
|
||||
.ExtensionList {
|
||||
columns: 3;
|
||||
column-width: @extension-list-column-width;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
.clearfix();
|
||||
|
||||
> li {
|
||||
-webkit-column-break-inside: avoid;
|
||||
break-inside: avoid-column;
|
||||
page-break-inside: avoid;
|
||||
text-align: left;
|
||||
position: relative;
|
||||
border-radius: 4px;
|
||||
transition: background .2s;
|
||||
}
|
||||
}
|
||||
.ExtensionListItem.disabled {
|
||||
.ExtensionListItem-title {
|
||||
opacity: 0.5;
|
||||
color: @muted-color;
|
||||
}
|
||||
.ExtensionListItem-icon {
|
||||
opacity: 0.5;
|
||||
}
|
||||
}
|
||||
.ExtensionListItem {
|
||||
padding: 10px;
|
||||
}
|
||||
.ExtensionListItem:hover {
|
||||
background: @control-bg;
|
||||
}
|
||||
.ExtensionListItem-content {
|
||||
padding: 0 50px;
|
||||
min-height: 40px;
|
||||
}
|
||||
.ExtensionListItem-main {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.ExtensionListItem-title {
|
||||
display: inline-block;
|
||||
font-size: 13px;
|
||||
font-weight: bold;
|
||||
white-space: nowrap;
|
||||
cursor: pointer;
|
||||
padding-right: 10px;
|
||||
}
|
||||
.ExtensionListItem-version {
|
||||
color: @muted-more-color;
|
||||
font-size: 11px;
|
||||
font-weight: normal;
|
||||
display: inline-flex;
|
||||
}
|
||||
.ExtensionListItem-controls {
|
||||
float: right;
|
||||
display: none;
|
||||
margin-right: -50px;
|
||||
margin-top: 1px;
|
||||
|
||||
.ExtensionListItem:hover &, &.open {
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
.ExtensionListItem-description {
|
||||
font-size: 11px;
|
||||
font-weight: normal;
|
||||
text-align: justify;
|
||||
}
|
||||
|
||||
.ExtensionIcon {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
background: @control-bg;
|
||||
color: @control-color;
|
||||
border-radius: 6px;
|
||||
display: inline-block;
|
||||
font-size: 20px;
|
||||
line-height: 40px;
|
||||
text-align: center;
|
||||
margin-left: -50px;
|
||||
position: absolute;
|
||||
}
|
||||
|
||||
@media (max-width: @extension-list-column-width) {
|
||||
.ExtensionListItem-description {
|
||||
display: none;
|
||||
}
|
||||
.ExtensionListItem-version {
|
||||
display: block;
|
||||
margin-top: 8px;
|
||||
}
|
||||
}
|
@@ -1,5 +1,5 @@
|
||||
.MailPage {
|
||||
padding: 20px 0;
|
||||
padding-bottom: 30px;
|
||||
|
||||
@media @desktop-up {
|
||||
.container {
|
||||
@@ -8,11 +8,11 @@
|
||||
}
|
||||
}
|
||||
|
||||
fieldset, .Alert {
|
||||
button, .Alert {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
fieldset > ul {
|
||||
ul {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
|
@@ -1,6 +1,9 @@
|
||||
.PermissionsPage-groups {
|
||||
background: @control-bg;
|
||||
padding: 20px 0;
|
||||
border-radius: @border-radius;
|
||||
display: block;
|
||||
overflow-x: auto;
|
||||
padding: 10px;
|
||||
}
|
||||
.Group {
|
||||
width: 90px;
|
||||
@@ -112,7 +115,7 @@
|
||||
}
|
||||
.PermissionGrid-section {
|
||||
td, th {
|
||||
padding-top: 20px;
|
||||
padding-top: 10px;
|
||||
}
|
||||
}
|
||||
.PermissionGrid-child {
|
||||
|
@@ -10,29 +10,23 @@
|
||||
}
|
||||
}
|
||||
|
||||
// Fix a solid white box to the top of the viewport. This toolbar's contents
|
||||
// will differ depending on the device: on phones it will be content
|
||||
// controls, whereas on desktops it will be the header. We will overlay
|
||||
// these things on top of it later.
|
||||
.App:before {
|
||||
content: " ";
|
||||
.header-background();
|
||||
border-bottom: 0;
|
||||
position: absolute;
|
||||
|
||||
.affix& {
|
||||
position: fixed;
|
||||
}
|
||||
|
||||
.scrolled& {
|
||||
.box-shadow(0 2px 6px @shadow-color);
|
||||
}
|
||||
}
|
||||
|
||||
// PHONES: Somewhere on the page there will be a .App-backControl, a
|
||||
// .App-primaryControl, and a .App-titleControl. We will position these on the
|
||||
// left, right, and center of the header respectively.
|
||||
@media @phone {
|
||||
.App-navigation {
|
||||
.header-background();
|
||||
border-bottom: 0;
|
||||
position: absolute;
|
||||
|
||||
.affix & {
|
||||
position: fixed;
|
||||
}
|
||||
|
||||
.scrolled & {
|
||||
.box-shadow(0 2px 6px @shadow-color);
|
||||
}
|
||||
}
|
||||
.App-primaryControl, .App-titleControl, .App-backControl {
|
||||
position: absolute !important;
|
||||
z-index: @zindex-header + 1;
|
||||
@@ -234,13 +228,18 @@
|
||||
display: none;
|
||||
}
|
||||
.App-header {
|
||||
.header-background();
|
||||
padding: 8px;
|
||||
height: @header-height;
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
z-index: @zindex-header;
|
||||
position: absolute;
|
||||
border-bottom: 0;
|
||||
|
||||
.affix & {
|
||||
position: fixed;
|
||||
}
|
||||
|
||||
.scrolled & {
|
||||
.box-shadow(0 2px 6px @shadow-color);
|
||||
}
|
||||
|
||||
& when (@config-colored-header = true) {
|
||||
.light-contents(@header-color, @header-control-bg, @header-control-color);
|
||||
|
@@ -32,7 +32,7 @@
|
||||
|
||||
&[disabled],
|
||||
fieldset[disabled] & {
|
||||
cursor: disallowed;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
textarea& {
|
||||
|
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user