mirror of
https://github.com/flarum/core.git
synced 2025-08-16 21:34:08 +02:00
Compare commits
225 Commits
mithril-2-
...
ds/discuss
Author | SHA1 | Date | |
---|---|---|---|
|
f6d88bf724 | ||
|
925628c208 | ||
|
aae83c4fbc | ||
|
d4b2d89da0 | ||
|
9b27b0d9d7 | ||
|
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 | ||
|
c6f2ff0c80 | ||
|
67962f48e5 | ||
|
f8a0d9459a | ||
|
27d562f3fc | ||
|
17a7155f60 | ||
|
4cdce71d65 | ||
|
eb03f51c4f | ||
|
d695d96e00 | ||
|
dc4884485a | ||
|
40548d7c61 | ||
|
60714b7ac4 | ||
|
84d14f485a | ||
|
0a6c5217c1 | ||
|
44a96a82ef | ||
|
0b3fe10516 | ||
|
5ecb74fb59 | ||
|
b66d16e44b | ||
|
a013d647e0 | ||
|
20b99bcab1 | ||
|
8325b6eed8 | ||
|
f9704f9153 | ||
|
09a39d5d95 | ||
|
a26f01e49c | ||
|
6d826e5b30 | ||
|
f38605b387 | ||
|
93f8ce78b3 | ||
|
a001736298 | ||
|
86d4bf0214 | ||
|
c7b67b922b | ||
|
3b63d774d3 | ||
|
86f7550bec | ||
|
9d1a87a4c4 | ||
|
a2263b8538 | ||
|
1ac09dbc4d | ||
|
be8fe44f0b | ||
|
b7593bc6a8 | ||
|
7fc0963e3c | ||
|
30f3056f70 | ||
|
ed23d7d4e7 | ||
|
1e9f7b7d52 | ||
|
08540fd1db | ||
|
74fa7122ca | ||
|
4b2d20cd85 | ||
|
077eaaa2f9 | ||
|
6668e75019 | ||
|
d6511e0df5 | ||
|
efd68df13a | ||
|
f1360a1394 | ||
|
cc875f3e95 | ||
|
6860b24b70 | ||
|
65766a8386 | ||
|
c53509d7d0 | ||
|
4c3e1e2625 | ||
|
6508e64f55 | ||
|
963c27ed60 | ||
|
304f05be36 | ||
|
82af307280 | ||
|
50cbb7be5c | ||
|
9ea57e6329 | ||
|
6639678fb2 | ||
|
f869999011 | ||
|
f885cebdc5 | ||
|
54ff6e720c | ||
|
aea8a3ff1f | ||
|
cc48e9ab22 | ||
|
6d38de9c8f | ||
|
87634449c0 | ||
|
b00ca4ef29 | ||
|
fd0f0cdf8b | ||
|
5b157f0adb | ||
|
dc8b203037 | ||
|
db71f8bf68 | ||
|
a004b8e057 | ||
|
1ff4076f2a | ||
|
6e9db779cd | ||
|
f4449e962d | ||
|
71f3379fcc | ||
|
1321b8cc28 |
2
.gitattributes
vendored
2
.gitattributes
vendored
@@ -11,3 +11,5 @@ phpunit.xml export-ignore
|
|||||||
tests export-ignore
|
tests export-ignore
|
||||||
|
|
||||||
js/dist/* -diff
|
js/dist/* -diff
|
||||||
|
|
||||||
|
* text=auto eol=lf
|
||||||
|
1
.gitignore
vendored
1
.gitignore
vendored
@@ -7,3 +7,4 @@ Thumbs.db
|
|||||||
/tests/integration/tmp
|
/tests/integration/tmp
|
||||||
.vagrant
|
.vagrant
|
||||||
.idea/*
|
.idea/*
|
||||||
|
.vscode
|
||||||
|
165
CHANGELOG.md
165
CHANGELOG.md
@@ -1,5 +1,170 @@
|
|||||||
# Changelog
|
# 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)
|
## [0.1.0-beta.13](https://github.com/flarum/core/compare/v0.1.0-beta.12...v0.1.0-beta.13)
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
13
README.md
13
README.md
@@ -1,12 +1,14 @@
|
|||||||
<p align="center"><img src="https://flarum.org/img/logo.png"></p>
|
<p align="center"><img src="https://flarum.org/assets/img/logo.png"></p>
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<a href="https://travis-ci.org/flarum/core"><img src="https://travis-ci.org/flarum/core.svg" alt="Build Status"></a>
|
<a href="https://github.com/flarum/core/actions?query=workflow%3ATests"><img src="https://github.com/flarum/core/workflows/Tests/badge.svg" alt="PHP Tests"></a>
|
||||||
<a href="https://packagist.org/packages/flarum/core"><img src="https://poser.pugx.org/flarum/core/d/total.svg" alt="Total Downloads"></a>
|
<a href="https://packagist.org/packages/flarum/core"><img src="https://img.shields.io/packagist/dt/flarum/core" alt="Total Downloads"></a>
|
||||||
<a href="https://packagist.org/packages/flarum/core"><img src="https://poser.pugx.org/flarum/core/v/stable.svg" alt="Latest Stable Version"></a>
|
<a href="https://packagist.org/packages/flarum/core"><img src="https://img.shields.io/github/v/release/flarum/core?sort=semver" alt="Latest Version"></a>
|
||||||
<a href="https://packagist.org/packages/flarum/core"><img src="https://poser.pugx.org/flarum/core/license.svg" alt="License"></a>
|
<a href="https://packagist.org/packages/flarum/core"><img src="https://img.shields.io/packagist/l/flarum/core" alt="License"></a>
|
||||||
|
<a href="https://github.styleci.io/repos/28257573"><img src="https://github.styleci.io/repos/28257573/shield?style=flat" alt="StyleCI"></a>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
|
||||||
## About Flarum
|
## About Flarum
|
||||||
|
|
||||||
**[Flarum](https://flarum.org/) is a delightfully simple discussion platform for your website.** It's fast and easy to use, with all the features you need to run a successful community. It is designed to be:
|
**[Flarum](https://flarum.org/) is a delightfully simple discussion platform for your website.** It's fast and easy to use, with all the features you need to run a successful community. It is designed to be:
|
||||||
@@ -32,4 +34,3 @@ If you discover a security vulnerability within Flarum, please send an e-mail to
|
|||||||
## License
|
## License
|
||||||
|
|
||||||
Flarum is open-source software licensed under the [MIT License](https://github.com/flarum/flarum/blob/master/LICENSE).
|
Flarum is open-source software licensed under the [MIT License](https://github.com/flarum/flarum/blob/master/LICENSE).
|
||||||
|
|
||||||
|
@@ -10,7 +10,7 @@
|
|||||||
"email": "franz@develophp.org"
|
"email": "franz@develophp.org"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "Daniel Klabbers",
|
"name": "Daniël Klabbers",
|
||||||
"email": "daniel@klabbers.email",
|
"email": "daniel@klabbers.email",
|
||||||
"homepage": "https://luceos.com"
|
"homepage": "https://luceos.com"
|
||||||
},
|
},
|
||||||
@@ -27,6 +27,10 @@
|
|||||||
{
|
{
|
||||||
"name": "Matthew Kilgore",
|
"name": "Matthew Kilgore",
|
||||||
"email": "matthew@kilgore.dev"
|
"email": "matthew@kilgore.dev"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Alexander (Sasha) Skvortsov",
|
||||||
|
"email": "askvortsov@flarum.org"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"support": {
|
"support": {
|
||||||
@@ -72,11 +76,12 @@
|
|||||||
"psr/http-server-handler": "^1.0",
|
"psr/http-server-handler": "^1.0",
|
||||||
"psr/http-server-middleware": "^1.0",
|
"psr/http-server-middleware": "^1.0",
|
||||||
"s9e/text-formatter": "^2.3.6",
|
"s9e/text-formatter": "^2.3.6",
|
||||||
"symfony/config": "^3.3",
|
"symfony/config": "^4.3.4",
|
||||||
"symfony/console": "^4.2",
|
"symfony/console": "^4.3.4",
|
||||||
"symfony/event-dispatcher": "^4.3.2",
|
"symfony/event-dispatcher": "^4.3.4",
|
||||||
"symfony/translation": "^3.3",
|
"symfony/mime": "^5.2.0",
|
||||||
"symfony/yaml": "^3.3",
|
"symfony/translation": "^4.3.4",
|
||||||
|
"symfony/yaml": "^4.3.4",
|
||||||
"tobscure/json-api": "^0.3.0",
|
"tobscure/json-api": "^0.3.0",
|
||||||
"wikimedia/less.php": "^3.0"
|
"wikimedia/less.php": "^3.0"
|
||||||
},
|
},
|
||||||
|
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
33
js/package-lock.json
generated
33
js/package-lock.json
generated
@@ -3382,9 +3382,9 @@
|
|||||||
"integrity": "sha1-Yzwsg+PaQqUC9SRmAiSA9CCCYd4="
|
"integrity": "sha1-Yzwsg+PaQqUC9SRmAiSA9CCCYd4="
|
||||||
},
|
},
|
||||||
"ini": {
|
"ini": {
|
||||||
"version": "1.3.5",
|
"version": "1.3.8",
|
||||||
"resolved": "https://registry.npmjs.org/ini/-/ini-1.3.5.tgz",
|
"resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz",
|
||||||
"integrity": "sha512-RZY5huIKCMRWDUqZlEi72f/lmXKMvuszcMBduliQ3nnWbx9X/ZBQO7DijMEYS9EhHBb2qacRUMtC7svLwe0lcw=="
|
"integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew=="
|
||||||
},
|
},
|
||||||
"interpret": {
|
"interpret": {
|
||||||
"version": "1.2.0",
|
"version": "1.2.0",
|
||||||
@@ -3556,9 +3556,9 @@
|
|||||||
"integrity": "sha1-TkMekrEalzFjaqH5yNHMvP2reN8="
|
"integrity": "sha1-TkMekrEalzFjaqH5yNHMvP2reN8="
|
||||||
},
|
},
|
||||||
"jquery": {
|
"jquery": {
|
||||||
"version": "3.4.1",
|
"version": "3.5.1",
|
||||||
"resolved": "https://registry.npmjs.org/jquery/-/jquery-3.4.1.tgz",
|
"resolved": "https://registry.npmjs.org/jquery/-/jquery-3.5.1.tgz",
|
||||||
"integrity": "sha512-36+AdBzCL+y6qjw5Tx7HgzeGCzC81MDDgaUP8ld2zhx58HdqXGoBd+tHdrBMiyjGQs0Hxs/MLZTu/eHNJJuWPw=="
|
"integrity": "sha512-XwIBPqcMn57FxfT+Go5pzySnm4KWkT1Tv7gjrpT1srtf8Weynl6R273VJ5GjkRb51IzMp5nbaPjJXMWeju2MKg=="
|
||||||
},
|
},
|
||||||
"jquery.hotkeys": {
|
"jquery.hotkeys": {
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
@@ -4546,11 +4546,6 @@
|
|||||||
"integrity": "sha512-mUdIBBvdn0PLOeP3TEkMH7HHeUP3GjsXCwKarjv/kGmUFOYg1VqEemKhoQpWMu6X2I8kHeuVdGibLGkVK+/5Qw==",
|
"integrity": "sha512-mUdIBBvdn0PLOeP3TEkMH7HHeUP3GjsXCwKarjv/kGmUFOYg1VqEemKhoQpWMu6X2I8kHeuVdGibLGkVK+/5Qw==",
|
||||||
"dev": true
|
"dev": true
|
||||||
},
|
},
|
||||||
"serialize-javascript": {
|
|
||||||
"version": "2.1.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-2.1.2.tgz",
|
|
||||||
"integrity": "sha512-rs9OggEUF0V4jUSecXazOYsLfu7OGK2qIn3c7IPBiffz32XniEp/TX9Xmc9LQfK2nQ2QKHvZ2oygKUGU0lG4jQ=="
|
|
||||||
},
|
|
||||||
"set-blocking": {
|
"set-blocking": {
|
||||||
"version": "2.0.0",
|
"version": "2.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz",
|
||||||
@@ -4900,21 +4895,29 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"terser-webpack-plugin": {
|
"terser-webpack-plugin": {
|
||||||
"version": "1.4.3",
|
"version": "1.4.5",
|
||||||
"resolved": "https://registry.npmjs.org/terser-webpack-plugin/-/terser-webpack-plugin-1.4.3.tgz",
|
"resolved": "https://registry.npmjs.org/terser-webpack-plugin/-/terser-webpack-plugin-1.4.5.tgz",
|
||||||
"integrity": "sha512-QMxecFz/gHQwteWwSo5nTc6UaICqN1bMedC5sMtUc7y3Ha3Q8y6ZO0iCR8pq4RJC8Hjf0FEPEHZqcMB/+DFCrA==",
|
"integrity": "sha512-04Rfe496lN8EYruwi6oPQkG0vo8C+HT49X687FZnpPF0qMAIHONI6HEXYPKDOE8e5HjXTyKfqRd/agHtH0kOtw==",
|
||||||
"requires": {
|
"requires": {
|
||||||
"cacache": "^12.0.2",
|
"cacache": "^12.0.2",
|
||||||
"find-cache-dir": "^2.1.0",
|
"find-cache-dir": "^2.1.0",
|
||||||
"is-wsl": "^1.1.0",
|
"is-wsl": "^1.1.0",
|
||||||
"schema-utils": "^1.0.0",
|
"schema-utils": "^1.0.0",
|
||||||
"serialize-javascript": "^2.1.2",
|
"serialize-javascript": "^4.0.0",
|
||||||
"source-map": "^0.6.1",
|
"source-map": "^0.6.1",
|
||||||
"terser": "^4.1.2",
|
"terser": "^4.1.2",
|
||||||
"webpack-sources": "^1.4.0",
|
"webpack-sources": "^1.4.0",
|
||||||
"worker-farm": "^1.7.0"
|
"worker-farm": "^1.7.0"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"serialize-javascript": {
|
||||||
|
"version": "4.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-4.0.0.tgz",
|
||||||
|
"integrity": "sha512-GaNA54380uFefWghODBWEGisLZFj00nS5ACs6yHa9nLqlLpVLO8ChDGeKRjZnV4Nh4n0Qi7nhYZD/9fCPzEqkw==",
|
||||||
|
"requires": {
|
||||||
|
"randombytes": "^2.1.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"source-map": {
|
"source-map": {
|
||||||
"version": "0.6.1",
|
"version": "0.6.1",
|
||||||
"resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz",
|
"resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz",
|
||||||
|
@@ -10,7 +10,7 @@
|
|||||||
"dayjs": "^1.8.28",
|
"dayjs": "^1.8.28",
|
||||||
"expose-loader": "^0.7.5",
|
"expose-loader": "^0.7.5",
|
||||||
"flarum-webpack-config": "0.1.0-beta.10",
|
"flarum-webpack-config": "0.1.0-beta.10",
|
||||||
"jquery": "^3.4.1",
|
"jquery": "^3.5.1",
|
||||||
"jquery.hotkeys": "^0.1.0",
|
"jquery.hotkeys": "^0.1.0",
|
||||||
"lodash-es": "^4.17.14",
|
"lodash-es": "^4.17.14",
|
||||||
"m.attrs.bidi": "github:tobscure/m.attrs.bidi",
|
"m.attrs.bidi": "github:tobscure/m.attrs.bidi",
|
||||||
|
20
js/shims.d.ts
vendored
20
js/shims.d.ts
vendored
@@ -1,6 +1,5 @@
|
|||||||
// Mithril
|
// Mithril
|
||||||
import * as Mithril from 'mithril';
|
import Mithril from 'mithril';
|
||||||
import Stream from 'mithril/stream';
|
|
||||||
|
|
||||||
// Other third-party libs
|
// Other third-party libs
|
||||||
import * as _dayjs from 'dayjs';
|
import * as _dayjs from 'dayjs';
|
||||||
@@ -9,21 +8,6 @@ import * as _$ from 'jquery';
|
|||||||
// Globals from flarum/core
|
// Globals from flarum/core
|
||||||
import Application from './src/common/Application';
|
import Application from './src/common/Application';
|
||||||
|
|
||||||
/**
|
|
||||||
* Helpers that flarum/core patches into Mithril
|
|
||||||
*/
|
|
||||||
interface m extends Mithril.Static {
|
|
||||||
prop: typeof Stream;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Export Mithril typings globally.
|
|
||||||
*
|
|
||||||
* This lets us use these typings without an extra import everywhere we use
|
|
||||||
* Mithril in a TypeScript file.
|
|
||||||
*/
|
|
||||||
export as namespace Mithril;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* flarum/core exposes several extensions globally:
|
* flarum/core exposes several extensions globally:
|
||||||
*
|
*
|
||||||
@@ -36,7 +20,7 @@ export as namespace Mithril;
|
|||||||
*/
|
*/
|
||||||
declare global {
|
declare global {
|
||||||
const $: typeof _$;
|
const $: typeof _$;
|
||||||
const m: m;
|
const m: Mithril.Static;
|
||||||
const dayjs: typeof _dayjs;
|
const dayjs: typeof _dayjs;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@@ -1,13 +1,29 @@
|
|||||||
import HeaderPrimary from './components/HeaderPrimary';
|
import HeaderPrimary from './components/HeaderPrimary';
|
||||||
import HeaderSecondary from './components/HeaderSecondary';
|
import HeaderSecondary from './components/HeaderSecondary';
|
||||||
import routes from './routes';
|
import routes from './routes';
|
||||||
|
import ExtensionPage from './components/ExtensionPage';
|
||||||
import Application from '../common/Application';
|
import Application from '../common/Application';
|
||||||
import Navigation from '../common/components/Navigation';
|
import Navigation from '../common/components/Navigation';
|
||||||
import AdminNav from './components/AdminNav';
|
import AdminNav from './components/AdminNav';
|
||||||
|
import ExtensionData from './utils/ExtensionData';
|
||||||
|
|
||||||
export default class AdminApplication extends Application {
|
export default class AdminApplication extends Application {
|
||||||
|
// Deprecated as of beta 15
|
||||||
extensionSettings = {};
|
extensionSettings = {};
|
||||||
|
|
||||||
|
extensionData = new ExtensionData();
|
||||||
|
|
||||||
|
extensionCategories = {
|
||||||
|
discussion: 70,
|
||||||
|
moderation: 60,
|
||||||
|
feature: 50,
|
||||||
|
formatting: 40,
|
||||||
|
theme: 30,
|
||||||
|
authentication: 20,
|
||||||
|
language: 10,
|
||||||
|
other: 0,
|
||||||
|
};
|
||||||
|
|
||||||
history = {
|
history = {
|
||||||
canGoBack: () => true,
|
canGoBack: () => true,
|
||||||
getPrevious: () => {},
|
getPrevious: () => {},
|
||||||
@@ -27,24 +43,29 @@ export default class AdminApplication extends Application {
|
|||||||
* @inheritdoc
|
* @inheritdoc
|
||||||
*/
|
*/
|
||||||
mount() {
|
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
|
// Mithril does not render the home route on https://example.com/admin, so
|
||||||
// we need to go to https://example.com/admin#/ explicitly.
|
// we need to go to https://example.com/admin#/ explicitly.
|
||||||
if (!document.location.hash) document.location.hash = '#/';
|
if (!document.location.hash) document.location.hash = '#/';
|
||||||
|
|
||||||
m.route.prefix = '#';
|
m.route.prefix = '#';
|
||||||
|
|
||||||
super.mount();
|
super.mount();
|
||||||
|
|
||||||
|
m.mount(document.getElementById('app-navigation'), {
|
||||||
|
view: () =>
|
||||||
|
Navigation.component({
|
||||||
|
className: 'App-backControl',
|
||||||
|
drawer: true,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
m.mount(document.getElementById('header-navigation'), Navigation);
|
||||||
|
m.mount(document.getElementById('header-primary'), HeaderPrimary);
|
||||||
|
m.mount(document.getElementById('header-secondary'), HeaderSecondary);
|
||||||
|
m.mount(document.getElementById('admin-navigation'), AdminNav);
|
||||||
|
|
||||||
// If an extension has just been enabled, then we will run its settings
|
// If an extension has just been enabled, then we will run its settings
|
||||||
// callback.
|
// callback.
|
||||||
const enabled = localStorage.getItem('enabledExtension');
|
const enabled = localStorage.getItem('enabledExtension');
|
||||||
if (enabled && this.extensionSettings[enabled]) {
|
if (enabled && this.extensionSettings[enabled] && typeof this.extensionSettings[enabled] === 'function') {
|
||||||
this.extensionSettings[enabled]();
|
this.extensionSettings[enabled]();
|
||||||
localStorage.removeItem('enabledExtension');
|
localStorage.removeItem('enabledExtension');
|
||||||
}
|
}
|
||||||
|
@@ -1,19 +1,24 @@
|
|||||||
import compat from '../common/compat';
|
import compat from '../common/compat';
|
||||||
|
|
||||||
import saveSettings from './utils/saveSettings';
|
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 SettingDropdown from './components/SettingDropdown';
|
||||||
import EditCustomFooterModal from './components/EditCustomFooterModal';
|
import EditCustomFooterModal from './components/EditCustomFooterModal';
|
||||||
import SessionDropdown from './components/SessionDropdown';
|
import SessionDropdown from './components/SessionDropdown';
|
||||||
import HeaderPrimary from './components/HeaderPrimary';
|
import HeaderPrimary from './components/HeaderPrimary';
|
||||||
import AppearancePage from './components/AppearancePage';
|
import AppearancePage from './components/AppearancePage';
|
||||||
import StatusWidget from './components/StatusWidget';
|
import StatusWidget from './components/StatusWidget';
|
||||||
|
import ExtensionsWidget from './components/ExtensionsWidget';
|
||||||
import HeaderSecondary from './components/HeaderSecondary';
|
import HeaderSecondary from './components/HeaderSecondary';
|
||||||
import SettingsModal from './components/SettingsModal';
|
import SettingsModal from './components/SettingsModal';
|
||||||
import DashboardWidget from './components/DashboardWidget';
|
import DashboardWidget from './components/DashboardWidget';
|
||||||
import AddExtensionModal from './components/AddExtensionModal';
|
import ExtensionPage from './components/ExtensionPage';
|
||||||
import ExtensionsPage from './components/ExtensionsPage';
|
import ExtensionLinkButton from './components/ExtensionLinkButton';
|
||||||
import AdminLinkButton from './components/AdminLinkButton';
|
import AdminLinkButton from './components/AdminLinkButton';
|
||||||
import PermissionGrid from './components/PermissionGrid';
|
import PermissionGrid from './components/PermissionGrid';
|
||||||
|
import ExtensionPermissionGrid from './components/ExtensionPermissionGrid';
|
||||||
import MailPage from './components/MailPage';
|
import MailPage from './components/MailPage';
|
||||||
import UploadImageButton from './components/UploadImageButton';
|
import UploadImageButton from './components/UploadImageButton';
|
||||||
import LoadingModal from './components/LoadingModal';
|
import LoadingModal from './components/LoadingModal';
|
||||||
@@ -23,6 +28,7 @@ import EditCustomHeaderModal from './components/EditCustomHeaderModal';
|
|||||||
import PermissionsPage from './components/PermissionsPage';
|
import PermissionsPage from './components/PermissionsPage';
|
||||||
import PermissionDropdown from './components/PermissionDropdown';
|
import PermissionDropdown from './components/PermissionDropdown';
|
||||||
import AdminNav from './components/AdminNav';
|
import AdminNav from './components/AdminNav';
|
||||||
|
import AdminHeader from './components/AdminHeader';
|
||||||
import EditCustomCssModal from './components/EditCustomCssModal';
|
import EditCustomCssModal from './components/EditCustomCssModal';
|
||||||
import EditGroupModal from './components/EditGroupModal';
|
import EditGroupModal from './components/EditGroupModal';
|
||||||
import routes from './routes';
|
import routes from './routes';
|
||||||
@@ -30,19 +36,24 @@ import AdminApplication from './AdminApplication';
|
|||||||
|
|
||||||
export default Object.assign(compat, {
|
export default Object.assign(compat, {
|
||||||
'utils/saveSettings': saveSettings,
|
'utils/saveSettings': saveSettings,
|
||||||
|
'utils/ExtensionData': ExtensionData,
|
||||||
|
'utils/isExtensionEnabled': isExtensionEnabled,
|
||||||
|
'utils/getCategorizedExtensions': getCategorizedExtensions,
|
||||||
'components/SettingDropdown': SettingDropdown,
|
'components/SettingDropdown': SettingDropdown,
|
||||||
'components/EditCustomFooterModal': EditCustomFooterModal,
|
'components/EditCustomFooterModal': EditCustomFooterModal,
|
||||||
'components/SessionDropdown': SessionDropdown,
|
'components/SessionDropdown': SessionDropdown,
|
||||||
'components/HeaderPrimary': HeaderPrimary,
|
'components/HeaderPrimary': HeaderPrimary,
|
||||||
'components/AppearancePage': AppearancePage,
|
'components/AppearancePage': AppearancePage,
|
||||||
'components/StatusWidget': StatusWidget,
|
'components/StatusWidget': StatusWidget,
|
||||||
|
'components/ExtensionsWidget': ExtensionsWidget,
|
||||||
'components/HeaderSecondary': HeaderSecondary,
|
'components/HeaderSecondary': HeaderSecondary,
|
||||||
'components/SettingsModal': SettingsModal,
|
'components/SettingsModal': SettingsModal,
|
||||||
'components/DashboardWidget': DashboardWidget,
|
'components/DashboardWidget': DashboardWidget,
|
||||||
'components/AddExtensionModal': AddExtensionModal,
|
'components/ExtensionPage': ExtensionPage,
|
||||||
'components/ExtensionsPage': ExtensionsPage,
|
'components/ExtensionLinkButton': ExtensionLinkButton,
|
||||||
'components/AdminLinkButton': AdminLinkButton,
|
'components/AdminLinkButton': AdminLinkButton,
|
||||||
'components/PermissionGrid': PermissionGrid,
|
'components/PermissionGrid': PermissionGrid,
|
||||||
|
'components/ExtensionPermissionGrid': ExtensionPermissionGrid,
|
||||||
'components/MailPage': MailPage,
|
'components/MailPage': MailPage,
|
||||||
'components/UploadImageButton': UploadImageButton,
|
'components/UploadImageButton': UploadImageButton,
|
||||||
'components/LoadingModal': LoadingModal,
|
'components/LoadingModal': LoadingModal,
|
||||||
@@ -52,6 +63,7 @@ export default Object.assign(compat, {
|
|||||||
'components/PermissionsPage': PermissionsPage,
|
'components/PermissionsPage': PermissionsPage,
|
||||||
'components/PermissionDropdown': PermissionDropdown,
|
'components/PermissionDropdown': PermissionDropdown,
|
||||||
'components/AdminNav': AdminNav,
|
'components/AdminNav': AdminNav,
|
||||||
|
'components/AdminHeader': AdminHeader,
|
||||||
'components/EditCustomCssModal': EditCustomCssModal,
|
'components/EditCustomCssModal': EditCustomCssModal,
|
||||||
'components/EditGroupModal': EditGroupModal,
|
'components/EditGroupModal': EditGroupModal,
|
||||||
routes: routes,
|
routes: routes,
|
||||||
|
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,106 +1,150 @@
|
|||||||
/*
|
import ExtensionLinkButton from './ExtensionLinkButton';
|
||||||
* This file is part of Flarum.
|
|
||||||
*
|
|
||||||
* (c) Toby Zerner <toby.zerner@gmail.com>
|
|
||||||
*
|
|
||||||
* For the full copyright and license information, please view the LICENSE
|
|
||||||
* file that was distributed with this source code.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import Component from '../../common/Component';
|
import Component from '../../common/Component';
|
||||||
import AdminLinkButton from './AdminLinkButton';
|
import LinkButton from '../../common/components/LinkButton';
|
||||||
import SelectDropdown from '../../common/components/SelectDropdown';
|
import SelectDropdown from '../../common/components/SelectDropdown';
|
||||||
|
import getCategorizedExtensions from '../utils/getCategorizedExtensions';
|
||||||
import ItemList from '../../common/utils/ItemList';
|
import ItemList from '../../common/utils/ItemList';
|
||||||
|
import Stream from '../../common/utils/Stream';
|
||||||
|
|
||||||
export default class AdminNav extends Component {
|
export default class AdminNav extends Component {
|
||||||
|
oninit(vnode) {
|
||||||
|
super.oninit(vnode);
|
||||||
|
|
||||||
|
this.query = Stream('');
|
||||||
|
}
|
||||||
|
|
||||||
view() {
|
view() {
|
||||||
return (
|
return (
|
||||||
<SelectDropdown className="AdminNav App-titleControl" buttonClassName="Button">
|
<SelectDropdown className="AdminNav App-titleControl AdminNav-Main" buttonClassName="Button">
|
||||||
{this.items().toArray()}
|
{this.items().toArray().concat(this.extensionItems().toArray())}
|
||||||
</SelectDropdown>
|
</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}
|
* @return {ItemList}
|
||||||
*/
|
*/
|
||||||
items() {
|
items() {
|
||||||
const items = new ItemList();
|
const items = new ItemList();
|
||||||
|
|
||||||
|
items.add('category-core', <h4 className="ExtensionListTitle">{app.translator.trans('core.admin.nav.categories.core')}</h4>);
|
||||||
|
|
||||||
items.add(
|
items.add(
|
||||||
'dashboard',
|
'dashboard',
|
||||||
AdminLinkButton.component(
|
<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')}
|
||||||
href: app.route('dashboard'),
|
</LinkButton>
|
||||||
icon: 'far fa-chart-bar',
|
|
||||||
description: app.translator.trans('core.admin.nav.dashboard_text'),
|
|
||||||
},
|
|
||||||
app.translator.trans('core.admin.nav.dashboard_button')
|
|
||||||
)
|
|
||||||
);
|
);
|
||||||
|
|
||||||
items.add(
|
items.add(
|
||||||
'basics',
|
'basics',
|
||||||
AdminLinkButton.component(
|
<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')}
|
||||||
href: app.route('basics'),
|
</LinkButton>
|
||||||
icon: 'fas fa-pencil-alt',
|
|
||||||
description: app.translator.trans('core.admin.nav.basics_text'),
|
|
||||||
},
|
|
||||||
app.translator.trans('core.admin.nav.basics_button')
|
|
||||||
)
|
|
||||||
);
|
);
|
||||||
|
|
||||||
items.add(
|
items.add(
|
||||||
'mail',
|
'mail',
|
||||||
AdminLinkButton.component(
|
<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')}
|
||||||
href: app.route('mail'),
|
</LinkButton>
|
||||||
icon: 'fas fa-envelope',
|
|
||||||
description: app.translator.trans('core.admin.nav.email_text'),
|
|
||||||
},
|
|
||||||
app.translator.trans('core.admin.nav.email_button')
|
|
||||||
)
|
|
||||||
);
|
);
|
||||||
|
|
||||||
items.add(
|
items.add(
|
||||||
'permissions',
|
'permissions',
|
||||||
AdminLinkButton.component(
|
<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')}
|
||||||
href: app.route('permissions'),
|
</LinkButton>
|
||||||
icon: 'fas fa-key',
|
|
||||||
description: app.translator.trans('core.admin.nav.permissions_text'),
|
|
||||||
},
|
|
||||||
app.translator.trans('core.admin.nav.permissions_button')
|
|
||||||
)
|
|
||||||
);
|
);
|
||||||
|
|
||||||
items.add(
|
items.add(
|
||||||
'appearance',
|
'appearance',
|
||||||
AdminLinkButton.component(
|
<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')}
|
||||||
href: app.route('appearance'),
|
</LinkButton>
|
||||||
icon: 'fas fa-paint-brush',
|
|
||||||
description: app.translator.trans('core.admin.nav.appearance_text'),
|
|
||||||
},
|
|
||||||
app.translator.trans('core.admin.nav.appearance_button')
|
|
||||||
)
|
|
||||||
);
|
);
|
||||||
|
|
||||||
items.add(
|
items.add(
|
||||||
'extensions',
|
'search',
|
||||||
AdminLinkButton.component(
|
<div className="Search-input">
|
||||||
{
|
<input
|
||||||
href: app.route('extensions'),
|
className="FormControl SearchBar"
|
||||||
icon: 'fas fa-puzzle-piece',
|
bidi={this.query}
|
||||||
description: app.translator.trans('core.admin.nav.extensions_text'),
|
type="search"
|
||||||
},
|
placeholder={app.translator.trans('core.admin.nav.search_placeholder')}
|
||||||
app.translator.trans('core.admin.nav.extensions_button')
|
/>
|
||||||
)
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
return items;
|
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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@@ -1,26 +1,34 @@
|
|||||||
import Page from '../../common/components/Page';
|
import Page from '../../common/components/Page';
|
||||||
import Button from '../../common/components/Button';
|
import Button from '../../common/components/Button';
|
||||||
import Switch from '../../common/components/Switch';
|
import Switch from '../../common/components/Switch';
|
||||||
|
import Stream from '../../common/utils/Stream';
|
||||||
import EditCustomCssModal from './EditCustomCssModal';
|
import EditCustomCssModal from './EditCustomCssModal';
|
||||||
import EditCustomHeaderModal from './EditCustomHeaderModal';
|
import EditCustomHeaderModal from './EditCustomHeaderModal';
|
||||||
import EditCustomFooterModal from './EditCustomFooterModal';
|
import EditCustomFooterModal from './EditCustomFooterModal';
|
||||||
import UploadImageButton from './UploadImageButton';
|
import UploadImageButton from './UploadImageButton';
|
||||||
import saveSettings from '../utils/saveSettings';
|
import saveSettings from '../utils/saveSettings';
|
||||||
import withAttr from '../../common/utils/withAttr';
|
import AdminHeader from './AdminHeader';
|
||||||
|
|
||||||
export default class AppearancePage extends Page {
|
export default class AppearancePage extends Page {
|
||||||
oninit(vnode) {
|
oninit(vnode) {
|
||||||
super.oninit(vnode);
|
super.oninit(vnode);
|
||||||
|
|
||||||
this.primaryColor = m.stream(app.data.settings.theme_primary_color);
|
this.primaryColor = Stream(app.data.settings.theme_primary_color);
|
||||||
this.secondaryColor = m.stream(app.data.settings.theme_secondary_color);
|
this.secondaryColor = Stream(app.data.settings.theme_secondary_color);
|
||||||
this.darkMode = m.stream(app.data.settings.theme_dark_mode);
|
this.darkMode = Stream(app.data.settings.theme_dark_mode);
|
||||||
this.coloredHeader = m.stream(app.data.settings.theme_colored_header);
|
this.coloredHeader = Stream(app.data.settings.theme_colored_header);
|
||||||
}
|
}
|
||||||
|
|
||||||
view() {
|
view() {
|
||||||
return (
|
return (
|
||||||
<div className="AppearancePage">
|
<div className="AppearancePage">
|
||||||
|
<AdminHeader
|
||||||
|
icon="fas fa-paint-brush"
|
||||||
|
description={app.translator.trans('core.admin.appearance.description')}
|
||||||
|
className="AppearancePage-header"
|
||||||
|
>
|
||||||
|
{app.translator.trans('core.admin.appearance.title')}
|
||||||
|
</AdminHeader>
|
||||||
<div className="container">
|
<div className="container">
|
||||||
<form onsubmit={this.onsubmit.bind(this)}>
|
<form onsubmit={this.onsubmit.bind(this)}>
|
||||||
<fieldset className="AppearancePage-colors">
|
<fieldset className="AppearancePage-colors">
|
||||||
|
@@ -5,7 +5,9 @@ import Button from '../../common/components/Button';
|
|||||||
import saveSettings from '../utils/saveSettings';
|
import saveSettings from '../utils/saveSettings';
|
||||||
import ItemList from '../../common/utils/ItemList';
|
import ItemList from '../../common/utils/ItemList';
|
||||||
import Switch from '../../common/components/Switch';
|
import Switch from '../../common/components/Switch';
|
||||||
|
import Stream from '../../common/utils/Stream';
|
||||||
import withAttr from '../../common/utils/withAttr';
|
import withAttr from '../../common/utils/withAttr';
|
||||||
|
import AdminHeader from './AdminHeader';
|
||||||
|
|
||||||
export default class BasicsPage extends Page {
|
export default class BasicsPage extends Page {
|
||||||
oninit(vnode) {
|
oninit(vnode) {
|
||||||
@@ -23,10 +25,6 @@ export default class BasicsPage extends Page {
|
|||||||
'welcome_message',
|
'welcome_message',
|
||||||
'display_name_driver',
|
'display_name_driver',
|
||||||
];
|
];
|
||||||
this.values = {};
|
|
||||||
|
|
||||||
const settings = app.data.settings;
|
|
||||||
this.fields.forEach((key) => (this.values[key] = m.stream(settings[key])));
|
|
||||||
|
|
||||||
this.localeOptions = {};
|
this.localeOptions = {};
|
||||||
const locales = app.data.locales;
|
const locales = app.data.locales;
|
||||||
@@ -40,14 +38,38 @@ export default class BasicsPage extends Page {
|
|||||||
this.displayNameOptions[identifier] = identifier;
|
this.displayNameOptions[identifier] = identifier;
|
||||||
}, this);
|
}, this);
|
||||||
|
|
||||||
|
this.slugDriverOptions = {};
|
||||||
|
Object.keys(app.data.slugDrivers).forEach((model) => {
|
||||||
|
this.fields.push(`slug_driver_${model}`);
|
||||||
|
this.slugDriverOptions[model] = {};
|
||||||
|
|
||||||
|
app.data.slugDrivers[model].forEach((option) => {
|
||||||
|
this.slugDriverOptions[model][option] = option;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
this.values = {};
|
||||||
|
|
||||||
|
const settings = app.data.settings;
|
||||||
|
this.fields.forEach((key) => (this.values[key] = Stream(settings[key])));
|
||||||
|
|
||||||
if (!this.values.display_name_driver() && displayNameDrivers.includes('username')) this.values.display_name_driver('username');
|
if (!this.values.display_name_driver() && displayNameDrivers.includes('username')) this.values.display_name_driver('username');
|
||||||
|
|
||||||
|
Object.keys(app.data.slugDrivers).forEach((model) => {
|
||||||
|
if (!this.values[`slug_driver_${model}`]() && 'default' in this.slugDriverOptions[model]) {
|
||||||
|
this.values[`slug_driver_${model}`]('default');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
if (typeof this.values.show_language_selector() !== 'number') this.values.show_language_selector(1);
|
if (typeof this.values.show_language_selector() !== 'number') this.values.show_language_selector(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
view() {
|
view() {
|
||||||
return (
|
return (
|
||||||
<div className="BasicsPage">
|
<div className="BasicsPage">
|
||||||
|
<AdminHeader icon="fas fa-pencil-alt" description={app.translator.trans('core.admin.basics.description')} className="BasicsPage-header">
|
||||||
|
{app.translator.trans('core.admin.basics.title')}
|
||||||
|
</AdminHeader>
|
||||||
<div className="container">
|
<div className="container">
|
||||||
<form onsubmit={this.onsubmit.bind(this)}>
|
<form onsubmit={this.onsubmit.bind(this)}>
|
||||||
{FieldSet.component(
|
{FieldSet.component(
|
||||||
@@ -127,20 +149,30 @@ export default class BasicsPage extends Page {
|
|||||||
]
|
]
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{Object.keys(this.displayNameOptions).length > 1
|
{Object.keys(this.displayNameOptions).length > 1 ? (
|
||||||
? FieldSet.component(
|
<FieldSet label={app.translator.trans('core.admin.basics.display_name_heading')}>
|
||||||
{
|
<div className="helpText">{app.translator.trans('core.admin.basics.display_name_text')}</div>
|
||||||
label: app.translator.trans('core.admin.basics.display_name_heading'),
|
<Select
|
||||||
},
|
options={this.displayNameOptions}
|
||||||
[
|
value={this.values.display_name_driver()}
|
||||||
<div className="helpText">{app.translator.trans('core.admin.basics.display_name_text')}</div>,
|
onchange={this.values.display_name_driver}
|
||||||
Select.component({
|
></Select>
|
||||||
options: this.displayNameOptions,
|
</FieldSet>
|
||||||
bidi: this.values.display_name_driver,
|
) : (
|
||||||
}),
|
''
|
||||||
]
|
)}
|
||||||
)
|
|
||||||
: ''}
|
{Object.keys(this.slugDriverOptions).map((model) => {
|
||||||
|
const options = this.slugDriverOptions[model];
|
||||||
|
if (Object.keys(options).length > 1) {
|
||||||
|
return (
|
||||||
|
<FieldSet label={app.translator.trans('core.admin.basics.slug_driver_heading', { model })}>
|
||||||
|
<div className="helpText">{app.translator.trans('core.admin.basics.slug_driver_text', { model })}</div>
|
||||||
|
<Select options={options} value={this.values[`slug_driver_${model}`]()} onchange={this.values[`slug_driver_${model}`]}></Select>
|
||||||
|
</FieldSet>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
})}
|
||||||
|
|
||||||
{Button.component(
|
{Button.component(
|
||||||
{
|
{
|
||||||
@@ -193,9 +225,7 @@ export default class BasicsPage extends Page {
|
|||||||
|
|
||||||
saveSettings(settings)
|
saveSettings(settings)
|
||||||
.then(() => {
|
.then(() => {
|
||||||
this.successAlert = app.alerts.show(app.translator.trans('core.admin.basics.saved_message'), {
|
this.successAlert = app.alerts.show({ type: 'success' }, app.translator.trans('core.admin.basics.saved_message'));
|
||||||
type: 'success',
|
|
||||||
});
|
|
||||||
})
|
})
|
||||||
.catch(() => {})
|
.catch(() => {})
|
||||||
.then(() => {
|
.then(() => {
|
||||||
|
@@ -1,16 +1,29 @@
|
|||||||
import Page from '../../common/components/Page';
|
import Page from '../../common/components/Page';
|
||||||
import StatusWidget from './StatusWidget';
|
import StatusWidget from './StatusWidget';
|
||||||
|
import ExtensionsWidget from './ExtensionsWidget';
|
||||||
|
import AdminHeader from './AdminHeader';
|
||||||
|
import ItemList from '../../common/utils/ItemList';
|
||||||
|
import listItems from '../../common/helpers/listItems';
|
||||||
|
|
||||||
export default class DashboardPage extends Page {
|
export default class DashboardPage extends Page {
|
||||||
view() {
|
view() {
|
||||||
return (
|
return (
|
||||||
<div className="DashboardPage">
|
<div className="DashboardPage">
|
||||||
<div className="container">{this.availableWidgets()}</div>
|
<AdminHeader icon="fas fa-chart-bar" description={app.translator.trans('core.admin.dashboard.description')} className="DashboardPage-header">
|
||||||
|
{app.translator.trans('core.admin.dashboard.title')}
|
||||||
|
</AdminHeader>
|
||||||
|
<div className="container">{this.availableWidgets().toArray()}</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
availableWidgets() {
|
availableWidgets() {
|
||||||
return [<StatusWidget />];
|
const items = new ItemList();
|
||||||
|
|
||||||
|
items.add('status', <StatusWidget />, 30);
|
||||||
|
|
||||||
|
items.add('extensions', <ExtensionsWidget />, 10);
|
||||||
|
|
||||||
|
return items;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@@ -4,7 +4,7 @@ import Badge from '../../common/components/Badge';
|
|||||||
import Group from '../../common/models/Group';
|
import Group from '../../common/models/Group';
|
||||||
import ItemList from '../../common/utils/ItemList';
|
import ItemList from '../../common/utils/ItemList';
|
||||||
import Switch from '../../common/components/Switch';
|
import Switch from '../../common/components/Switch';
|
||||||
import withAttr from '../../common/utils/withAttr';
|
import Stream from '../../common/utils/Stream';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The `EditGroupModal` component shows a modal dialog which allows the user
|
* The `EditGroupModal` component shows a modal dialog which allows the user
|
||||||
@@ -16,11 +16,11 @@ export default class EditGroupModal extends Modal {
|
|||||||
|
|
||||||
this.group = this.attrs.group || app.store.createRecord('groups');
|
this.group = this.attrs.group || app.store.createRecord('groups');
|
||||||
|
|
||||||
this.nameSingular = m.stream(this.group.nameSingular() || '');
|
this.nameSingular = Stream(this.group.nameSingular() || '');
|
||||||
this.namePlural = m.stream(this.group.namePlural() || '');
|
this.namePlural = Stream(this.group.namePlural() || '');
|
||||||
this.icon = m.stream(this.group.icon() || '');
|
this.icon = Stream(this.group.icon() || '');
|
||||||
this.color = m.stream(this.group.color() || '');
|
this.color = Stream(this.group.color() || '');
|
||||||
this.isHidden = m.stream(this.group.isHidden() || false);
|
this.isHidden = Stream(this.group.isHidden() || false);
|
||||||
}
|
}
|
||||||
|
|
||||||
className() {
|
className() {
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
363
js/src/admin/components/ExtensionPage.js
Normal file
363
js/src/admin/components/ExtensionPage.js
Normal file
@@ -0,0 +1,363 @@
|
|||||||
|
import Button from '../../common/components/Button';
|
||||||
|
import Link from '../../common/components/Link';
|
||||||
|
import LinkButton from '../../common/components/LinkButton';
|
||||||
|
import Page from '../../common/components/Page';
|
||||||
|
import Select from '../../common/components/Select';
|
||||||
|
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 Stream from '../../common/utils/Stream';
|
||||||
|
import LoadingModal from './LoadingModal';
|
||||||
|
import ExtensionPermissionGrid from './ExtensionPermissionGrid';
|
||||||
|
import saveSettings from '../utils/saveSettings';
|
||||||
|
import isExtensionEnabled from '../utils/isExtensionEnabled';
|
||||||
|
|
||||||
|
export default class ExtensionPage extends Page {
|
||||||
|
oninit(vnode) {
|
||||||
|
super.oninit(vnode);
|
||||||
|
|
||||||
|
this.loading = false;
|
||||||
|
this.extension = app.data.extensions[this.attrs.id];
|
||||||
|
this.changingState = false;
|
||||||
|
this.settings = {};
|
||||||
|
|
||||||
|
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',
|
||||||
|
};
|
||||||
|
|
||||||
|
// Backwards compatibility layer will be removed in
|
||||||
|
// Beta 16
|
||||||
|
if (app.extensionSettings[this.extension.id]) {
|
||||||
|
app.extensionData[this.extension.id] = app.extensionSettings[this.extension.id];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
className() {
|
||||||
|
return this.extension.id + '-Page';
|
||||||
|
}
|
||||||
|
|
||||||
|
view() {
|
||||||
|
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() {
|
||||||
|
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.isEnabled()} onchange={this.toggle.bind(this, this.extension.id)}>
|
||||||
|
{this.isEnabled(this.extension.id)
|
||||||
|
? 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">
|
||||||
|
{typeof app.extensionData[this.extension.id] === 'function' ? (
|
||||||
|
<Button onclick={app.extensionData[this.extension.id].bind(this)} className="Button Button--primary">
|
||||||
|
{app.translator.trans('core.admin.extension.open_modal')}
|
||||||
|
</Button>
|
||||||
|
) : 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* getSetting 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.
|
||||||
|
*
|
||||||
|
* 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'
|
||||||
|
* }
|
||||||
|
*
|
||||||
|
* @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 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.label}
|
||||||
|
</Switch>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
} else if (['select', 'dropdown', 'selectdropdown'].includes(entry.type)) {
|
||||||
|
return (
|
||||||
|
<div className="Form-group">
|
||||||
|
<label>{entry.label}</label>
|
||||||
|
<Select value={value || entry.default} options={entry.options} buttonClassName="Button" onchange={this.settings[setting]} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
return (
|
||||||
|
<div className="Form-group">
|
||||||
|
<label>{entry.label}</label>
|
||||||
|
<input type={entry.type} className="FormControl" bidi={this.setting(setting)} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
|
||||||
|
saveSettings(this.dirty()).then(this.onsaved.bind(this));
|
||||||
|
}
|
||||||
|
|
||||||
|
onsaved() {
|
||||||
|
this.loading = false;
|
||||||
|
|
||||||
|
app.alerts.show({ type: 'success' }, app.translator.trans('core.admin.extension.saved_message'));
|
||||||
|
}
|
||||||
|
|
||||||
|
setting(key, fallback = '') {
|
||||||
|
this.settings[key] = this.settings[key] || Stream(app.data.settings[key] || fallback);
|
||||||
|
|
||||||
|
return this.settings[key];
|
||||||
|
}
|
||||||
|
|
||||||
|
isEnabled() {
|
||||||
|
let isEnabled = isExtensionEnabled(this.extension.id);
|
||||||
|
|
||||||
|
return this.changingState ? !isEnabled : isEnabled;
|
||||||
|
}
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
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(', '),
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
39
js/src/admin/components/ExtensionPermissionGrid.js
Normal file
39
js/src/admin/components/ExtensionPermissionGrid.js
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
import PermissionGrid from './PermissionGrid';
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
}
|
@@ -1,134 +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 },
|
|
||||||
})
|
|
||||||
.then(() => {
|
|
||||||
if (!enabled) localStorage.setItem('enabledExtension', id);
|
|
||||||
window.location.reload();
|
|
||||||
});
|
|
||||||
|
|
||||||
app.modal.show(LoadingModal);
|
|
||||||
}
|
|
||||||
}
|
|
46
js/src/admin/components/ExtensionsWidget.js
Normal file
46
js/src/admin/components/ExtensionsWidget.js
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
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 {
|
||||||
|
className() {
|
||||||
|
return 'ExtensionsWidget';
|
||||||
|
}
|
||||||
|
|
||||||
|
content() {
|
||||||
|
const categorizedExtensions = getCategorizedExtensions();
|
||||||
|
const categories = app.extensionCategories;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="ExtensionsWidget-list">
|
||||||
|
{Object.keys(categories).map((category) => {
|
||||||
|
if (categorizedExtensions[category]) {
|
||||||
|
return (
|
||||||
|
<div className="ExtensionList-Category">
|
||||||
|
<h4 className="ExtensionList-Label">{app.translator.trans(`core.admin.nav.categories.${category}`)}</h4>
|
||||||
|
<ul className="ExtensionList">
|
||||||
|
{categorizedExtensions[category].map((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>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
@@ -1,4 +1,5 @@
|
|||||||
import Component from '../../common/Component';
|
import Component from '../../common/Component';
|
||||||
|
import LinkButton from '../../common/components/LinkButton';
|
||||||
import SessionDropdown from './SessionDropdown';
|
import SessionDropdown from './SessionDropdown';
|
||||||
import ItemList from '../../common/utils/ItemList';
|
import ItemList from '../../common/utils/ItemList';
|
||||||
import listItems from '../../common/helpers/listItems';
|
import listItems from '../../common/helpers/listItems';
|
||||||
@@ -19,6 +20,13 @@ export default class HeaderSecondary extends Component {
|
|||||||
items() {
|
items() {
|
||||||
const items = new ItemList();
|
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());
|
items.add('session', SessionDropdown.component());
|
||||||
|
|
||||||
return items;
|
return items;
|
||||||
|
@@ -5,7 +5,9 @@ import Alert from '../../common/components/Alert';
|
|||||||
import Select from '../../common/components/Select';
|
import Select from '../../common/components/Select';
|
||||||
import LoadingIndicator from '../../common/components/LoadingIndicator';
|
import LoadingIndicator from '../../common/components/LoadingIndicator';
|
||||||
import saveSettings from '../utils/saveSettings';
|
import saveSettings from '../utils/saveSettings';
|
||||||
import withAttr from '../../common/utils/withAttr';
|
import Stream from '../../common/utils/Stream';
|
||||||
|
import icon from '../../common/helpers/icon';
|
||||||
|
import AdminHeader from './AdminHeader';
|
||||||
|
|
||||||
export default class MailPage extends Page {
|
export default class MailPage extends Page {
|
||||||
oninit(vnode) {
|
oninit(vnode) {
|
||||||
@@ -25,7 +27,7 @@ export default class MailPage extends Page {
|
|||||||
this.status = { sending: false, errors: {} };
|
this.status = { sending: false, errors: {} };
|
||||||
|
|
||||||
const settings = app.data.settings;
|
const settings = app.data.settings;
|
||||||
this.fields.forEach((key) => (this.values[key] = m.stream(settings[key])));
|
this.fields.forEach((key) => (this.values[key] = Stream(settings[key])));
|
||||||
|
|
||||||
app
|
app
|
||||||
.request({
|
.request({
|
||||||
@@ -40,7 +42,7 @@ export default class MailPage extends Page {
|
|||||||
for (const driver in this.driverFields) {
|
for (const driver in this.driverFields) {
|
||||||
for (const field in this.driverFields[driver]) {
|
for (const field in this.driverFields[driver]) {
|
||||||
this.fields.push(field);
|
this.fields.push(field);
|
||||||
this.values[field] = m.stream(settings[field]);
|
this.values[field] = Stream(settings[field]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -65,11 +67,11 @@ export default class MailPage extends Page {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="MailPage">
|
<div className="MailPage">
|
||||||
|
<AdminHeader icon="fas fa-envelope" description={app.translator.trans('core.admin.email.description')} className="MailPage-header">
|
||||||
|
{app.translator.trans('core.admin.email.title')}
|
||||||
|
</AdminHeader>
|
||||||
<div className="container">
|
<div className="container">
|
||||||
<form onsubmit={this.onsubmit.bind(this)}>
|
<form onsubmit={this.onsubmit.bind(this)}>
|
||||||
<h2>{app.translator.trans('core.admin.email.heading')}</h2>
|
|
||||||
<div className="helpText">{app.translator.trans('core.admin.email.text')}</div>
|
|
||||||
|
|
||||||
{FieldSet.component(
|
{FieldSet.component(
|
||||||
{
|
{
|
||||||
label: app.translator.trans('core.admin.email.addresses_heading'),
|
label: app.translator.trans('core.admin.email.addresses_heading'),
|
||||||
@@ -194,9 +196,7 @@ export default class MailPage extends Page {
|
|||||||
})
|
})
|
||||||
.then((response) => {
|
.then((response) => {
|
||||||
this.sendingTest = false;
|
this.sendingTest = false;
|
||||||
this.testEmailSuccessAlert = app.alerts.show(app.translator.trans('core.admin.email.send_test_mail_success'), {
|
this.testEmailSuccessAlert = app.alerts.show({ type: 'success' }, app.translator.trans('core.admin.email.send_test_mail_success'));
|
||||||
type: 'success',
|
|
||||||
});
|
|
||||||
})
|
})
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
this.sendingTest = false;
|
this.sendingTest = false;
|
||||||
@@ -219,9 +219,7 @@ export default class MailPage extends Page {
|
|||||||
|
|
||||||
saveSettings(settings)
|
saveSettings(settings)
|
||||||
.then(() => {
|
.then(() => {
|
||||||
this.successAlert = app.alerts.show(app.translator.trans('core.admin.basics.saved_message'), {
|
this.successAlert = app.alerts.show({ type: 'success' }, app.translator.trans('core.admin.basics.saved_message'));
|
||||||
type: 'success',
|
|
||||||
});
|
|
||||||
})
|
})
|
||||||
.catch(() => {})
|
.catch(() => {})
|
||||||
.then(() => {
|
.then(() => {
|
||||||
|
@@ -6,12 +6,6 @@ import ItemList from '../../common/utils/ItemList';
|
|||||||
import icon from '../../common/helpers/icon';
|
import icon from '../../common/helpers/icon';
|
||||||
|
|
||||||
export default class PermissionGrid extends Component {
|
export default class PermissionGrid extends Component {
|
||||||
oninit(vnode) {
|
|
||||||
super.oninit(vnode);
|
|
||||||
|
|
||||||
this.permissions = this.permissionItems().toArray();
|
|
||||||
}
|
|
||||||
|
|
||||||
view() {
|
view() {
|
||||||
const scopes = this.scopeItems().toArray();
|
const scopes = this.scopeItems().toArray();
|
||||||
|
|
||||||
@@ -35,25 +29,27 @@ export default class PermissionGrid extends Component {
|
|||||||
<th>{this.scopeControlItems().toArray()}</th>
|
<th>{this.scopeControlItems().toArray()}</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
{this.permissions.map((section) => (
|
{this.permissionItems()
|
||||||
<tbody>
|
.toArray()
|
||||||
<tr className="PermissionGrid-section">
|
.map((section) => (
|
||||||
<th>{section.label}</th>
|
<tbody>
|
||||||
{permissionCells(section)}
|
<tr className="PermissionGrid-section">
|
||||||
<td />
|
<th>{section.label}</th>
|
||||||
</tr>
|
{permissionCells(section)}
|
||||||
{section.children.map((child) => (
|
|
||||||
<tr className="PermissionGrid-child">
|
|
||||||
<th>
|
|
||||||
{icon(child.icon)}
|
|
||||||
{child.label}
|
|
||||||
</th>
|
|
||||||
{permissionCells(child)}
|
|
||||||
<td />
|
<td />
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
{section.children.map((child) => (
|
||||||
</tbody>
|
<tr className="PermissionGrid-child">
|
||||||
))}
|
<th>
|
||||||
|
{icon(child.icon)}
|
||||||
|
{child.label}
|
||||||
|
</th>
|
||||||
|
{permissionCells(child)}
|
||||||
|
<td />
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
))}
|
||||||
</table>
|
</table>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -158,6 +154,8 @@ export default class PermissionGrid extends Component {
|
|||||||
permission: 'user.viewLastSeenAt',
|
permission: 'user.viewLastSeenAt',
|
||||||
});
|
});
|
||||||
|
|
||||||
|
items.merge(app.extensionData.getAllExtensionPermissions('view'));
|
||||||
|
|
||||||
return items;
|
return items;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -198,6 +196,8 @@ export default class PermissionGrid extends Component {
|
|||||||
90
|
90
|
||||||
);
|
);
|
||||||
|
|
||||||
|
items.merge(app.extensionData.getAllExtensionPermissions('start'));
|
||||||
|
|
||||||
return items;
|
return items;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -238,6 +238,8 @@ export default class PermissionGrid extends Component {
|
|||||||
90
|
90
|
||||||
);
|
);
|
||||||
|
|
||||||
|
items.merge(app.extensionData.getAllExtensionPermissions('reply'));
|
||||||
|
|
||||||
return items;
|
return items;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -334,6 +336,8 @@ export default class PermissionGrid extends Component {
|
|||||||
60
|
60
|
||||||
);
|
);
|
||||||
|
|
||||||
|
items.merge(app.extensionData.getAllExtensionPermissions('moderate'));
|
||||||
|
|
||||||
return items;
|
return items;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@@ -4,11 +4,15 @@ import EditGroupModal from './EditGroupModal';
|
|||||||
import Group from '../../common/models/Group';
|
import Group from '../../common/models/Group';
|
||||||
import icon from '../../common/helpers/icon';
|
import icon from '../../common/helpers/icon';
|
||||||
import PermissionGrid from './PermissionGrid';
|
import PermissionGrid from './PermissionGrid';
|
||||||
|
import AdminHeader from './AdminHeader';
|
||||||
|
|
||||||
export default class PermissionsPage extends Page {
|
export default class PermissionsPage extends Page {
|
||||||
view() {
|
view() {
|
||||||
return (
|
return (
|
||||||
<div className="PermissionsPage">
|
<div className="PermissionsPage">
|
||||||
|
<AdminHeader icon="fas fa-key" description={app.translator.trans('core.admin.permissions.description')} className="PermissionsPage-header">
|
||||||
|
{app.translator.trans('core.admin.permissions.title')}
|
||||||
|
</AdminHeader>
|
||||||
<div className="PermissionsPage-groups">
|
<div className="PermissionsPage-groups">
|
||||||
<div className="container">
|
<div className="container">
|
||||||
{app.store
|
{app.store
|
||||||
|
@@ -1,5 +1,6 @@
|
|||||||
import Modal from '../../common/components/Modal';
|
import Modal from '../../common/components/Modal';
|
||||||
import Button from '../../common/components/Button';
|
import Button from '../../common/components/Button';
|
||||||
|
import Stream from '../../common/utils/Stream';
|
||||||
import saveSettings from '../utils/saveSettings';
|
import saveSettings from '../utils/saveSettings';
|
||||||
|
|
||||||
export default class SettingsModal extends Modal {
|
export default class SettingsModal extends Modal {
|
||||||
@@ -35,7 +36,7 @@ export default class SettingsModal extends Modal {
|
|||||||
}
|
}
|
||||||
|
|
||||||
setting(key, fallback = '') {
|
setting(key, fallback = '') {
|
||||||
this.settings[key] = this.settings[key] || m.stream(app.data.settings[key] || fallback);
|
this.settings[key] = this.settings[key] || Stream(app.data.settings[key] || fallback);
|
||||||
|
|
||||||
return this.settings[key];
|
return this.settings[key];
|
||||||
}
|
}
|
||||||
|
@@ -10,8 +10,9 @@ export { app };
|
|||||||
// Export public API
|
// Export public API
|
||||||
|
|
||||||
// Export compat 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 BasicsPage from './components/BasicsPage';
|
||||||
import PermissionsPage from './components/PermissionsPage';
|
import PermissionsPage from './components/PermissionsPage';
|
||||||
import AppearancePage from './components/AppearancePage';
|
import AppearancePage from './components/AppearancePage';
|
||||||
import ExtensionsPage from './components/ExtensionsPage';
|
|
||||||
import MailPage from './components/MailPage';
|
import MailPage from './components/MailPage';
|
||||||
|
import ExtensionPage from './components/ExtensionPage';
|
||||||
|
import ExtensionPageResolver from './resolvers/ExtensionPageResolver';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The `routes` initializer defines the forum app's routes.
|
* The `routes` initializer defines the forum app's routes.
|
||||||
@@ -16,7 +17,7 @@ export default function (app) {
|
|||||||
basics: { path: '/basics', component: BasicsPage },
|
basics: { path: '/basics', component: BasicsPage },
|
||||||
permissions: { path: '/permissions', component: PermissionsPage },
|
permissions: { path: '/permissions', component: PermissionsPage },
|
||||||
appearance: { path: '/appearance', component: AppearancePage },
|
appearance: { path: '/appearance', component: AppearancePage },
|
||||||
extensions: { path: '/extensions', component: ExtensionsPage },
|
|
||||||
mail: { path: '/mail', component: MailPage },
|
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.other = extensions.other || [];
|
||||||
|
|
||||||
|
extensions.other.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));
|
m.route(document.getElementById('content'), basePath + '/', mapRoutes(this.routes, basePath));
|
||||||
|
|
||||||
// Add a class to the body which indicates that the page has been scrolled
|
// Add a class to the body which indicates that the page has been scrolled
|
||||||
// down.
|
// down. When this happens, we'll add classes to the header and app body
|
||||||
new ScrollListener((top) => {
|
// 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 $app = $('#app');
|
||||||
const offset = $app.offset().top;
|
const offset = $app.offset().top;
|
||||||
|
|
||||||
$app.toggleClass('affix', top >= offset).toggleClass('scrolled', top > offset);
|
$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');
|
$('body').addClass('ontouchstart' in window ? 'touch' : 'no-touch');
|
||||||
@@ -264,7 +270,7 @@ export default class Application {
|
|||||||
|
|
||||||
updateTitle() {
|
updateTitle() {
|
||||||
const count = this.titleCount ? `(${this.titleCount}) ` : '';
|
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');
|
const title = this.forum.attribute('title');
|
||||||
document.title = count + pageTitleWithSeparator + 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.
|
* 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
|
* @param {Object} options
|
||||||
* @return {Promise}
|
* @return {Promise}
|
||||||
* @public
|
* @public
|
||||||
@@ -404,7 +410,7 @@ export default class Application {
|
|||||||
console.groupEnd();
|
console.groupEnd();
|
||||||
}
|
}
|
||||||
|
|
||||||
this.requestErrorAlert = this.alerts.show(error.alert.content, error.alert);
|
this.requestErrorAlert = this.alerts.show(error.alert, error.alert.content);
|
||||||
}
|
}
|
||||||
|
|
||||||
return Promise.reject(error);
|
return Promise.reject(error);
|
||||||
|
@@ -1,10 +1,6 @@
|
|||||||
import * as Mithril from 'mithril';
|
import * as Mithril from 'mithril';
|
||||||
|
|
||||||
export type ComponentAttrs = {
|
export interface ComponentAttrs extends Mithril.Attributes {}
|
||||||
className?: string;
|
|
||||||
|
|
||||||
[key: string]: any;
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The `Component` class defines a user interface 'building block'. A component
|
* The `Component` class defines a user interface 'building block'. A component
|
||||||
@@ -33,7 +29,7 @@ export type ComponentAttrs = {
|
|||||||
*
|
*
|
||||||
* @see https://mithril.js.org/components.html
|
* @see https://mithril.js.org/components.html
|
||||||
*/
|
*/
|
||||||
export default abstract class Component<T extends ComponentAttrs = any> implements Mithril.ClassComponent<T> {
|
export default abstract class Component<T extends ComponentAttrs = ComponentAttrs> implements Mithril.ClassComponent<T> {
|
||||||
/**
|
/**
|
||||||
* The root DOM element for the component.
|
* The root DOM element for the component.
|
||||||
*/
|
*/
|
||||||
|
@@ -15,7 +15,7 @@ import * as Mithril from 'mithril';
|
|||||||
* This should only be used when necessary, and only with `m.render`. If you are unsure whether you need
|
* This should only be used when necessary, and only with `m.render`. If you are unsure whether you need
|
||||||
* this or `Component, you probably need `Component`.
|
* this or `Component, you probably need `Component`.
|
||||||
*/
|
*/
|
||||||
export default abstract class Fragment implements Mithril.ClassComponent {
|
export default abstract class Fragment {
|
||||||
/**
|
/**
|
||||||
* The root DOM element for the fragment.
|
* The root DOM element for the fragment.
|
||||||
*/
|
*/
|
||||||
@@ -52,7 +52,7 @@ export default abstract class Fragment implements Mithril.ClassComponent {
|
|||||||
*
|
*
|
||||||
* @final
|
* @final
|
||||||
*/
|
*/
|
||||||
public render(): Mithril.Vnode {
|
public render(): Mithril.Vnode<Mithril.Attributes, this> {
|
||||||
const vdom = this.view();
|
const vdom = this.view();
|
||||||
|
|
||||||
vdom.attrs = vdom.attrs || {};
|
vdom.attrs = vdom.attrs || {};
|
||||||
@@ -61,15 +61,14 @@ export default abstract class Fragment implements Mithril.ClassComponent {
|
|||||||
|
|
||||||
vdom.attrs.oncreate = (vnode) => {
|
vdom.attrs.oncreate = (vnode) => {
|
||||||
this.element = vnode.dom;
|
this.element = vnode.dom;
|
||||||
if (this.oncreate) this.oncreate.apply(this, vnode);
|
if (originalOnCreate) originalOnCreate.apply(this, [vnode]);
|
||||||
if (originalOnCreate) originalOnCreate.apply(this, vnode);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
return vdom;
|
return vdom;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @inheritdoc
|
* Creates a view out of virtual elements.
|
||||||
*/
|
*/
|
||||||
abstract view();
|
abstract view(): Mithril.Vnode<Mithril.Attributes, this>;
|
||||||
}
|
}
|
||||||
|
@@ -12,13 +12,16 @@ import anchorScroll from './utils/anchorScroll';
|
|||||||
import RequestError from './utils/RequestError';
|
import RequestError from './utils/RequestError';
|
||||||
import abbreviateNumber from './utils/abbreviateNumber';
|
import abbreviateNumber from './utils/abbreviateNumber';
|
||||||
import * as string from './utils/string';
|
import * as string from './utils/string';
|
||||||
|
import Stream from './utils/Stream';
|
||||||
import SubtreeRetainer from './utils/SubtreeRetainer';
|
import SubtreeRetainer from './utils/SubtreeRetainer';
|
||||||
import setRouteWithForcedRefresh from './utils/setRouteWithForcedRefresh';
|
import setRouteWithForcedRefresh from './utils/setRouteWithForcedRefresh';
|
||||||
import extract from './utils/extract';
|
import extract from './utils/extract';
|
||||||
import ScrollListener from './utils/ScrollListener';
|
import ScrollListener from './utils/ScrollListener';
|
||||||
import stringToColor from './utils/stringToColor';
|
import stringToColor from './utils/stringToColor';
|
||||||
import subclassOf from './utils/subclassOf';
|
import subclassOf from './utils/subclassOf';
|
||||||
|
import SuperTextarea from './utils/SuperTextarea';
|
||||||
import patchMithril from './utils/patchMithril';
|
import patchMithril from './utils/patchMithril';
|
||||||
|
import proxifyCompat from './utils/proxifyCompat';
|
||||||
import classList from './utils/classList';
|
import classList from './utils/classList';
|
||||||
import extractText from './utils/extractText';
|
import extractText from './utils/extractText';
|
||||||
import formatNumber from './utils/formatNumber';
|
import formatNumber from './utils/formatNumber';
|
||||||
@@ -46,6 +49,7 @@ import FieldSet from './components/FieldSet';
|
|||||||
import Select from './components/Select';
|
import Select from './components/Select';
|
||||||
import Navigation from './components/Navigation';
|
import Navigation from './components/Navigation';
|
||||||
import Alert from './components/Alert';
|
import Alert from './components/Alert';
|
||||||
|
import Link from './components/Link';
|
||||||
import LinkButton from './components/LinkButton';
|
import LinkButton from './components/LinkButton';
|
||||||
import Checkbox from './components/Checkbox';
|
import Checkbox from './components/Checkbox';
|
||||||
import SelectDropdown from './components/SelectDropdown';
|
import SelectDropdown from './components/SelectDropdown';
|
||||||
@@ -65,6 +69,7 @@ import username from './helpers/username';
|
|||||||
import userOnline from './helpers/userOnline';
|
import userOnline from './helpers/userOnline';
|
||||||
import listItems from './helpers/listItems';
|
import listItems from './helpers/listItems';
|
||||||
import Fragment from './Fragment';
|
import Fragment from './Fragment';
|
||||||
|
import DefaultResolver from './resolvers/DefaultResolver';
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
extend: extend,
|
extend: extend,
|
||||||
@@ -85,9 +90,12 @@ export default {
|
|||||||
'utils/extract': extract,
|
'utils/extract': extract,
|
||||||
'utils/ScrollListener': ScrollListener,
|
'utils/ScrollListener': ScrollListener,
|
||||||
'utils/stringToColor': stringToColor,
|
'utils/stringToColor': stringToColor,
|
||||||
|
'utils/Stream': Stream,
|
||||||
'utils/subclassOf': subclassOf,
|
'utils/subclassOf': subclassOf,
|
||||||
|
'utils/SuperTextarea': SuperTextarea,
|
||||||
'utils/setRouteWithForcedRefresh': setRouteWithForcedRefresh,
|
'utils/setRouteWithForcedRefresh': setRouteWithForcedRefresh,
|
||||||
'utils/patchMithril': patchMithril,
|
'utils/patchMithril': patchMithril,
|
||||||
|
'utils/proxifyCompat': proxifyCompat,
|
||||||
'utils/classList': classList,
|
'utils/classList': classList,
|
||||||
'utils/extractText': extractText,
|
'utils/extractText': extractText,
|
||||||
'utils/formatNumber': formatNumber,
|
'utils/formatNumber': formatNumber,
|
||||||
@@ -116,6 +124,7 @@ export default {
|
|||||||
'components/Select': Select,
|
'components/Select': Select,
|
||||||
'components/Navigation': Navigation,
|
'components/Navigation': Navigation,
|
||||||
'components/Alert': Alert,
|
'components/Alert': Alert,
|
||||||
|
'components/Link': Link,
|
||||||
'components/LinkButton': LinkButton,
|
'components/LinkButton': LinkButton,
|
||||||
'components/Checkbox': Checkbox,
|
'components/Checkbox': Checkbox,
|
||||||
'components/SelectDropdown': SelectDropdown,
|
'components/SelectDropdown': SelectDropdown,
|
||||||
@@ -134,4 +143,5 @@ export default {
|
|||||||
'helpers/username': username,
|
'helpers/username': username,
|
||||||
'helpers/userOnline': userOnline,
|
'helpers/userOnline': userOnline,
|
||||||
'helpers/listItems': listItems,
|
'helpers/listItems': listItems,
|
||||||
|
'resolvers/DefaultResolver': DefaultResolver,
|
||||||
};
|
};
|
||||||
|
@@ -1,31 +1,33 @@
|
|||||||
import Component from '../Component';
|
import Component, { ComponentAttrs } from '../Component';
|
||||||
import Button from './Button';
|
import Button from './Button';
|
||||||
import listItems from '../helpers/listItems';
|
import listItems from '../helpers/listItems';
|
||||||
import extract from '../utils/extract';
|
import extract from '../utils/extract';
|
||||||
|
import Mithril from 'mithril';
|
||||||
|
|
||||||
|
export interface AlertAttrs extends ComponentAttrs {
|
||||||
|
/** The type of alert this is. Will be used to give the alert a class name of `Alert--{type}`. */
|
||||||
|
type?: string;
|
||||||
|
/** An array of controls to show in the alert. */
|
||||||
|
controls?: Mithril.Children;
|
||||||
|
/** Whether or not the alert can be dismissed. */
|
||||||
|
dismissible?: boolean;
|
||||||
|
/** A callback to run when the alert is dismissed */
|
||||||
|
ondismiss?: Function;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The `Alert` component represents an alert box, which contains a message,
|
* The `Alert` component represents an alert box, which contains a message,
|
||||||
* some controls, and may be dismissible.
|
* some controls, and may be dismissible.
|
||||||
*
|
|
||||||
* ### Attrs
|
|
||||||
*
|
|
||||||
* - `type` The type of alert this is. Will be used to give the alert a class
|
|
||||||
* name of `Alert--{type}`.
|
|
||||||
* - `controls` An array of controls to show in the alert.
|
|
||||||
* - `dismissible` Whether or not the alert can be dismissed.
|
|
||||||
* - `ondismiss` A callback to run when the alert is dismissed.
|
|
||||||
*
|
|
||||||
* All other attrs will be assigned as attributes on the DOM element.
|
|
||||||
*/
|
*/
|
||||||
export default class Alert extends Component {
|
export default class Alert<T extends AlertAttrs = AlertAttrs> extends Component<T> {
|
||||||
view(vnode) {
|
view(vnode: Mithril.Vnode) {
|
||||||
const attrs = Object.assign({}, this.attrs);
|
const attrs = Object.assign({}, this.attrs);
|
||||||
|
|
||||||
const type = extract(attrs, 'type');
|
const type = extract(attrs, 'type');
|
||||||
attrs.className = 'Alert Alert--' + type + ' ' + (attrs.className || '');
|
attrs.className = 'Alert Alert--' + type + ' ' + (attrs.className || '');
|
||||||
|
|
||||||
const content = extract(attrs, 'content') || vnode.children;
|
const content = extract(attrs, 'content') || vnode.children;
|
||||||
const controls = extract(attrs, 'controls') || [];
|
const controls = (extract(attrs, 'controls') || []) as Mithril.ChildArray;
|
||||||
|
|
||||||
// If the alert is meant to be dismissible (which is the case by default),
|
// If the alert is meant to be dismissible (which is the case by default),
|
||||||
// then we will create a dismiss button to append as the final control in
|
// then we will create a dismiss button to append as the final control in
|
@@ -35,6 +35,11 @@ export default class Button extends Component {
|
|||||||
attrs['aria-label'] = attrs.title;
|
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 nothing else is provided, we use the textual button content as tooltip
|
||||||
if (!attrs.title && vnode.children) {
|
if (!attrs.title && vnode.children) {
|
||||||
attrs.title = extractText(vnode.children);
|
attrs.title = extractText(vnode.children);
|
||||||
|
47
js/src/common/components/Link.js
Normal file
47
js/src/common/components/Link.js
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
import Component from '../Component';
|
||||||
|
import extract from '../utils/extract';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The link component enables both internal and external links.
|
||||||
|
* It will return a regular HTML link for any links to external sites,
|
||||||
|
* and it will use Mithril's m.route.Link for any internal links.
|
||||||
|
*
|
||||||
|
* Links will default to internal; the 'external' attr must be set to
|
||||||
|
* `true` for the link to be external.
|
||||||
|
*/
|
||||||
|
export default class Link extends Component {
|
||||||
|
view(vnode) {
|
||||||
|
let { options = {}, ...attrs } = vnode.attrs;
|
||||||
|
|
||||||
|
attrs.href = attrs.href || '';
|
||||||
|
|
||||||
|
// For some reason, m.route.Link does not like vnode.text, so if present, we
|
||||||
|
// need to convert it to text vnodes and store it in children.
|
||||||
|
const children = vnode.children || { tag: '#', children: vnode.text };
|
||||||
|
|
||||||
|
if (attrs.external) {
|
||||||
|
return <a {...attrs}>{children}</a>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// If the href URL of the link is the same as the current page path
|
||||||
|
// we will not add a new entry to the browser history.
|
||||||
|
// This allows us to still refresh the Page component
|
||||||
|
// without adding endless history entries.
|
||||||
|
if (attrs.href === m.route.get()) {
|
||||||
|
if (!('replace' in options)) options.replace = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mithril 2 does not completely rerender the page if a route change leads to the same route
|
||||||
|
// (or the same component handling a different route).
|
||||||
|
// Here, the `force` parameter will use Mithril's key system to force a full rerender
|
||||||
|
// see https://mithril.js.org/route.html#key-parameter
|
||||||
|
if (extract(attrs, 'force')) {
|
||||||
|
if (!('state' in options)) options.state = {};
|
||||||
|
if (!('key' in options.state)) options.state.key = Date.now();
|
||||||
|
}
|
||||||
|
|
||||||
|
attrs.options = options;
|
||||||
|
|
||||||
|
return <m.route.Link {...attrs}>{children}</m.route.Link>;
|
||||||
|
}
|
||||||
|
}
|
@@ -1,4 +1,5 @@
|
|||||||
import Button from './Button';
|
import Button from './Button';
|
||||||
|
import Link from './Link';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The `LinkButton` component defines a `Button` which links to a route.
|
* The `LinkButton` component defines a `Button` which links to a route.
|
||||||
@@ -11,18 +12,20 @@ import Button from './Button';
|
|||||||
* active.
|
* active.
|
||||||
* - `href` The URL to link to. If the current URL `m.route()` matches this,
|
* - `href` The URL to link to. If the current URL `m.route()` matches this,
|
||||||
* the `active` prop will automatically be set to true.
|
* 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 {
|
export default class LinkButton extends Button {
|
||||||
static initAttrs(attrs) {
|
static initAttrs(attrs) {
|
||||||
super.initAttrs(attrs);
|
super.initAttrs(attrs);
|
||||||
|
|
||||||
attrs.active = this.isActive(attrs);
|
attrs.active = this.isActive(attrs);
|
||||||
|
if (attrs.force === undefined) attrs.force = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
view(vnode) {
|
view(vnode) {
|
||||||
const vdom = super.view(vnode);
|
const vdom = super.view(vnode);
|
||||||
|
|
||||||
vdom.tag = m.route.Link;
|
vdom.tag = Link;
|
||||||
vdom.attrs.active = String(vdom.attrs.active);
|
vdom.attrs.active = String(vdom.attrs.active);
|
||||||
|
|
||||||
return vdom;
|
return vdom;
|
||||||
|
@@ -24,11 +24,20 @@ export default class Modal extends Component {
|
|||||||
oncreate(vnode) {
|
oncreate(vnode) {
|
||||||
super.oncreate(vnode);
|
super.oncreate(vnode);
|
||||||
|
|
||||||
this.attrs.onshow(() => this.onready());
|
this.attrs.animateShow(() => this.onready());
|
||||||
}
|
}
|
||||||
|
|
||||||
onremove() {
|
onbeforeremove() {
|
||||||
this.attrs.onhide();
|
// If the global modal state currently contains a modal,
|
||||||
|
// we've just opened up a new one, and accordingly,
|
||||||
|
// we don't need to show a hide animation.
|
||||||
|
if (!this.attrs.state.modal) {
|
||||||
|
this.attrs.animateHide();
|
||||||
|
// Here, we ensure that the animation has time to complete.
|
||||||
|
// See https://mithril.js.org/lifecycle-methods.html#onbeforeremove
|
||||||
|
// Bootstrap's Modal.TRANSITION_DURATION is 300 ms.
|
||||||
|
return new Promise((resolve) => setTimeout(resolve, 300));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
view() {
|
view() {
|
||||||
@@ -103,13 +112,11 @@ export default class Modal extends Component {
|
|||||||
this.$('form').find('input, select, textarea').first().focus().select();
|
this.$('form').find('input, select, textarea').first().focus().select();
|
||||||
}
|
}
|
||||||
|
|
||||||
onhide() {}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Hide the modal.
|
* Hide the modal.
|
||||||
*/
|
*/
|
||||||
hide() {
|
hide() {
|
||||||
this.attrs.onhide();
|
this.attrs.state.close();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@@ -11,7 +11,14 @@ export default class ModalManager extends Component {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="ModalManager modal fade">
|
<div className="ModalManager modal fade">
|
||||||
{modal ? modal.componentClass.component({ ...modal.attrs, onshow: this.animateShow.bind(this), onhide: this.animateHide.bind(this) }) : ''}
|
{modal
|
||||||
|
? modal.componentClass.component({
|
||||||
|
...modal.attrs,
|
||||||
|
animateShow: this.animateShow.bind(this),
|
||||||
|
animateHide: this.animateHide.bind(this),
|
||||||
|
state: this.attrs.state,
|
||||||
|
})
|
||||||
|
: ''}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -28,6 +35,14 @@ export default class ModalManager extends Component {
|
|||||||
animateShow(readyCallback) {
|
animateShow(readyCallback) {
|
||||||
const dismissible = !!this.attrs.state.modal.componentClass.isDismissible;
|
const dismissible = !!this.attrs.state.modal.componentClass.isDismissible;
|
||||||
|
|
||||||
|
// If we are opening this modal while another modal is already open,
|
||||||
|
// the shown event will not run, because the modal is already open.
|
||||||
|
// So, we need to manually trigger the readyCallback.
|
||||||
|
if (this.$().hasClass('in')) {
|
||||||
|
readyCallback();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
this.$()
|
this.$()
|
||||||
.one('shown.bs.modal', readyCallback)
|
.one('shown.bs.modal', readyCallback)
|
||||||
.modal({
|
.modal({
|
||||||
|
@@ -11,9 +11,7 @@ export default class Page extends Component {
|
|||||||
super.oninit(vnode);
|
super.oninit(vnode);
|
||||||
|
|
||||||
app.previous = app.current;
|
app.previous = app.current;
|
||||||
app.current = new PageState(this.constructor);
|
app.current = new PageState(this.constructor, { routeName: this.attrs.routeName });
|
||||||
|
|
||||||
this.onNewRoute();
|
|
||||||
|
|
||||||
app.drawer.hide();
|
app.drawer.hide();
|
||||||
app.modal.close();
|
app.modal.close();
|
||||||
@@ -24,16 +22,20 @@ export default class Page extends Component {
|
|||||||
* @type {String}
|
* @type {String}
|
||||||
*/
|
*/
|
||||||
this.bodyClass = '';
|
this.bodyClass = '';
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A collections of actions to run when the route changes.
|
* Whether we should scroll to the top of the page when its rendered.
|
||||||
* 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
|
* @type {Boolean}
|
||||||
* adjust the current route name.
|
*/
|
||||||
*/
|
this.scrollTopOnCreate = true;
|
||||||
onNewRoute() {
|
|
||||||
app.current.set('routeName', this.attrs.routeName);
|
/**
|
||||||
|
* Whether the browser should restore scroll state on refreshes.
|
||||||
|
*
|
||||||
|
* @type {Boolean}
|
||||||
|
*/
|
||||||
|
this.useBrowserScrollRestoration = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
oncreate(vnode) {
|
oncreate(vnode) {
|
||||||
@@ -42,6 +44,14 @@ export default class Page extends Component {
|
|||||||
if (this.bodyClass) {
|
if (this.bodyClass) {
|
||||||
$('#app').addClass(this.bodyClass);
|
$('#app').addClass(this.bodyClass);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (this.scrollTopOnCreate) {
|
||||||
|
$(window).scrollTop(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ('scrollRestoration' in history) {
|
||||||
|
history.scrollRestoration = this.useBrowserScrollRestoration ? 'auto' : 'manual';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
onremove() {
|
onremove() {
|
||||||
|
@@ -12,6 +12,9 @@ import icon from '../helpers/icon';
|
|||||||
function isActive(vnode) {
|
function isActive(vnode) {
|
||||||
const tag = vnode.tag;
|
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) {
|
if ('initAttrs' in tag) {
|
||||||
tag.initAttrs(vnode.attrs);
|
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>
|
* The `fullTime` helper displays a formatted time string wrapped in a <time>
|
||||||
* tag.
|
* tag.
|
||||||
*
|
|
||||||
* @param {Date} time
|
|
||||||
* @return {Object}
|
|
||||||
*/
|
*/
|
||||||
export default function fullTime(time) {
|
export default function fullTime(time: Date): Mithril.Vnode {
|
||||||
const d = dayjs(time);
|
const d = dayjs(time);
|
||||||
|
|
||||||
const datetime = d.format();
|
const datetime = d.format();
|
@@ -1,14 +1,13 @@
|
|||||||
|
import dayjs from 'dayjs';
|
||||||
|
import * as Mithril from 'mithril';
|
||||||
import humanTimeUtil from '../utils/humanTime';
|
import humanTimeUtil from '../utils/humanTime';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The `humanTime` helper displays a time in a human-friendly time-ago format
|
* 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
|
* (e.g. '12 days ago'), wrapped in a <time> tag with other information about
|
||||||
* the time.
|
* 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 d = dayjs(time);
|
||||||
|
|
||||||
const datetime = d.format();
|
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} />;
|
||||||
|
}
|
@@ -51,8 +51,6 @@ export default function listItems(items) {
|
|||||||
</li>
|
</li>
|
||||||
);
|
);
|
||||||
|
|
||||||
node.state = node.state || {};
|
|
||||||
|
|
||||||
return node;
|
return node;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
@@ -1,6 +1,6 @@
|
|||||||
import 'expose-loader?$!expose-loader?jQuery!jquery';
|
import 'expose-loader?$!expose-loader?jQuery!jquery';
|
||||||
import 'expose-loader?m!mithril';
|
import 'expose-loader?m!mithril';
|
||||||
import 'expose-loader?moment!expose-loader?dayjs!dayjs';
|
import 'expose-loader?dayjs!dayjs';
|
||||||
import 'expose-loader?m.bidi!m.attrs.bidi';
|
import 'expose-loader?m.bidi!m.attrs.bidi';
|
||||||
import 'bootstrap/js/affix';
|
import 'bootstrap/js/affix';
|
||||||
import 'bootstrap/js/dropdown';
|
import 'bootstrap/js/dropdown';
|
||||||
|
@@ -10,6 +10,7 @@ export default class User extends Model {}
|
|||||||
|
|
||||||
Object.assign(User.prototype, {
|
Object.assign(User.prototype, {
|
||||||
username: Model.attribute('username'),
|
username: Model.attribute('username'),
|
||||||
|
slug: Model.attribute('slug'),
|
||||||
displayName: Model.attribute('displayName'),
|
displayName: Model.attribute('displayName'),
|
||||||
email: Model.attribute('email'),
|
email: Model.attribute('email'),
|
||||||
isEmailConfirmed: Model.attribute('isEmailConfirmed'),
|
isEmailConfirmed: Model.attribute('isEmailConfirmed'),
|
||||||
|
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() }];
|
||||||
|
}
|
||||||
|
}
|
@@ -1,50 +0,0 @@
|
|||||||
import Alert from '../components/Alert';
|
|
||||||
|
|
||||||
export default class AlertManagerState {
|
|
||||||
constructor() {
|
|
||||||
this.activeAlerts = {};
|
|
||||||
this.alertId = 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
getActiveAlerts() {
|
|
||||||
return this.activeAlerts;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Show an Alert in the alerts area.
|
|
||||||
*/
|
|
||||||
show(children, attrs, componentClass = Alert) {
|
|
||||||
// Breaking Change Compliance Warning, Remove in Beta 15.
|
|
||||||
// This is applied to the first argument (attrs) because previously, the alert was passed as the first argument.
|
|
||||||
if (attrs === Alert || attrs instanceof Alert) {
|
|
||||||
// This is duplicated so that if the error is caught, an error message still shows up in the debug console.
|
|
||||||
console.error('The AlertManager can only show Alerts. Whichever extension triggered this alert should be updated to comply with beta 14.');
|
|
||||||
throw new Error('The AlertManager can only show Alerts. Whichever extension triggered this alert should be updated to comply with beta 14.');
|
|
||||||
}
|
|
||||||
// End Change Compliance Warning, Remove in Beta 15
|
|
||||||
this.activeAlerts[++this.alertId] = { children, attrs, componentClass };
|
|
||||||
m.redraw();
|
|
||||||
|
|
||||||
return this.alertId;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Dismiss an alert.
|
|
||||||
*/
|
|
||||||
dismiss(key) {
|
|
||||||
if (!key || !(key in this.activeAlerts)) return;
|
|
||||||
|
|
||||||
delete this.activeAlerts[key];
|
|
||||||
m.redraw();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Clear all alerts.
|
|
||||||
*
|
|
||||||
* @public
|
|
||||||
*/
|
|
||||||
clear() {
|
|
||||||
this.activeAlerts = {};
|
|
||||||
m.redraw();
|
|
||||||
}
|
|
||||||
}
|
|
80
js/src/common/states/AlertManagerState.ts
Normal file
80
js/src/common/states/AlertManagerState.ts
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
import Mithril from 'mithril';
|
||||||
|
import Alert, { AlertAttrs } from '../components/Alert';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returned by `AlertManagerState.show`. Used to dismiss alerts.
|
||||||
|
*/
|
||||||
|
export type AlertIdentifier = number;
|
||||||
|
|
||||||
|
export interface AlertState {
|
||||||
|
componentClass: typeof Alert;
|
||||||
|
attrs: AlertAttrs;
|
||||||
|
children: Mithril.Children;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default class AlertManagerState {
|
||||||
|
protected activeAlerts: { [id: number]: AlertState } = {};
|
||||||
|
protected alertId = 0;
|
||||||
|
|
||||||
|
getActiveAlerts() {
|
||||||
|
return this.activeAlerts;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Show an Alert in the alerts area.
|
||||||
|
*
|
||||||
|
* @returns The alert's ID, which can be used to dismiss the alert.
|
||||||
|
*/
|
||||||
|
show(children: Mithril.Children): AlertIdentifier;
|
||||||
|
show(attrs: AlertAttrs, children: Mithril.Children): AlertIdentifier;
|
||||||
|
show(componentClass: Alert, attrs: AlertAttrs, children: Mithril.Children): AlertIdentifier;
|
||||||
|
|
||||||
|
show(arg1: any, arg2?: any, arg3?: any) {
|
||||||
|
// Assigns variables as per the above signatures
|
||||||
|
let componentClass = Alert;
|
||||||
|
let attrs: AlertAttrs = {};
|
||||||
|
let children: Mithril.Children;
|
||||||
|
|
||||||
|
if (arguments.length == 1) {
|
||||||
|
children = arg1 as Mithril.Children;
|
||||||
|
} else if (arguments.length == 2) {
|
||||||
|
attrs = arg1 as AlertAttrs;
|
||||||
|
children = arg2 as Mithril.Children;
|
||||||
|
} else if (arguments.length == 3) {
|
||||||
|
componentClass = arg1 as typeof Alert;
|
||||||
|
attrs = arg2 as AlertAttrs;
|
||||||
|
children = arg3;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Breaking Change Compliance Warning, Remove in Beta 15.
|
||||||
|
// This is applied to the first argument (attrs) because previously, the alert was passed as the first argument.
|
||||||
|
if (attrs === Alert || attrs instanceof Alert) {
|
||||||
|
// This is duplicated so that if the error is caught, an error message still shows up in the debug console.
|
||||||
|
console.error('The AlertManager can only show Alerts. Whichever extension triggered this alert should be updated to comply with beta 14.');
|
||||||
|
throw new Error('The AlertManager can only show Alerts. Whichever extension triggered this alert should be updated to comply with beta 14.');
|
||||||
|
}
|
||||||
|
// End Change Compliance Warning, Remove in Beta 15
|
||||||
|
this.activeAlerts[++this.alertId] = { children, attrs, componentClass };
|
||||||
|
m.redraw();
|
||||||
|
|
||||||
|
return this.alertId;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Dismiss an alert.
|
||||||
|
*/
|
||||||
|
dismiss(key: AlertIdentifier): void {
|
||||||
|
if (!key || !(key in this.activeAlerts)) return;
|
||||||
|
|
||||||
|
delete this.activeAlerts[key];
|
||||||
|
m.redraw();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clear all alerts.
|
||||||
|
*/
|
||||||
|
clear(): void {
|
||||||
|
this.activeAlerts = {};
|
||||||
|
m.redraw();
|
||||||
|
}
|
||||||
|
}
|
80
js/src/common/utils/Pagination.ts
Normal file
80
js/src/common/utils/Pagination.ts
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
export default class Pagination<T> {
|
||||||
|
private readonly loadFunction: (page: number) => Promise<any>;
|
||||||
|
|
||||||
|
public loading = {
|
||||||
|
prev: false,
|
||||||
|
next: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
public page: number;
|
||||||
|
|
||||||
|
public data: { [page: number]: T } = {};
|
||||||
|
|
||||||
|
public pages: {
|
||||||
|
first: number;
|
||||||
|
last: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
constructor(load: (page: number) => Promise<any>, page: number = 1) {
|
||||||
|
this.loadFunction = load;
|
||||||
|
this.page = page;
|
||||||
|
|
||||||
|
this.pages = {
|
||||||
|
first: page,
|
||||||
|
last: page,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
clear() {
|
||||||
|
this.data = {};
|
||||||
|
}
|
||||||
|
|
||||||
|
refresh(page: number) {
|
||||||
|
this.clear();
|
||||||
|
|
||||||
|
this.page = page;
|
||||||
|
this.pages.last = page - 1;
|
||||||
|
this.pages.first = page;
|
||||||
|
|
||||||
|
return this.loadNext();
|
||||||
|
}
|
||||||
|
|
||||||
|
loadNext() {
|
||||||
|
this.loading.next = true;
|
||||||
|
const page = this.pages.last + 1;
|
||||||
|
|
||||||
|
return this.load(
|
||||||
|
page,
|
||||||
|
() => (this.loading.next = false),
|
||||||
|
() => (this.pages.last = this.page = page)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
loadPrev() {
|
||||||
|
this.loading.prev = true;
|
||||||
|
const page = this.pages.first - 1;
|
||||||
|
|
||||||
|
return this.load(
|
||||||
|
page,
|
||||||
|
() => (this.loading.prev = false),
|
||||||
|
() => (this.pages.first = this.page = page)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private load(page, done, success) {
|
||||||
|
return this.loadFunction(page)
|
||||||
|
.then((out) => {
|
||||||
|
done();
|
||||||
|
success();
|
||||||
|
|
||||||
|
this.data[this.page] = out;
|
||||||
|
|
||||||
|
return out;
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
done();
|
||||||
|
|
||||||
|
return Promise.reject(err);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
@@ -58,7 +58,7 @@ export default class ScrollListener {
|
|||||||
*/
|
*/
|
||||||
start() {
|
start() {
|
||||||
if (!this.active) {
|
if (!this.active) {
|
||||||
window.addEventListener('scroll', (this.active = this.loop.bind(this)));
|
window.addEventListener('scroll', (this.active = this.loop.bind(this)), { passive: true });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
3
js/src/common/utils/Stream.js
Normal file
3
js/src/common/utils/Stream.js
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
import Stream from 'mithril/stream';
|
||||||
|
|
||||||
|
export default Stream;
|
@@ -28,6 +28,9 @@ export default class SubtreeRetainer {
|
|||||||
constructor(...callbacks) {
|
constructor(...callbacks) {
|
||||||
this.callbacks = callbacks;
|
this.callbacks = callbacks;
|
||||||
this.data = {};
|
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) {
|
check(...callbacks) {
|
||||||
this.callbacks = this.callbacks.concat(callbacks);
|
this.callbacks = this.callbacks.concat(callbacks);
|
||||||
|
// Update the data cache when new checks are added.
|
||||||
|
this.needsRebuild();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@@ -22,6 +22,8 @@ export default class SuperTextarea {
|
|||||||
*/
|
*/
|
||||||
setValue(value) {
|
setValue(value) {
|
||||||
this.$.val(value).trigger('input');
|
this.$.val(value).trigger('input');
|
||||||
|
|
||||||
|
this.el.dispatchEvent(new CustomEvent('input', { bubbles: true, cancelable: true }));
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -49,8 +51,6 @@ export default class SuperTextarea {
|
|||||||
*/
|
*/
|
||||||
insertAtCursor(text) {
|
insertAtCursor(text) {
|
||||||
this.insertAt(this.el.selectionStart, text);
|
this.insertAt(this.el.selectionStart, text);
|
||||||
|
|
||||||
this.el.dispatchEvent(new CustomEvent('input', { bubbles: true, cancelable: true }));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@@ -1,3 +1,6 @@
|
|||||||
|
import dayjs from 'dayjs';
|
||||||
|
import 'dayjs/plugin/relativeTime';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The `humanTime` utility converts a date to a localized, human-readable time-
|
* The `humanTime` utility converts a date to a localized, human-readable time-
|
||||||
* ago string.
|
* ago string.
|
||||||
|
@@ -1,6 +1,9 @@
|
|||||||
|
import DefaultResolver from '../resolvers/DefaultResolver';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The `mapRoutes` utility converts a map of named application routes into a
|
* 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
|
* @see https://mithril.js.org/route.html#signature
|
||||||
* @param {Object} routes
|
* @param {Object} routes
|
||||||
@@ -10,14 +13,17 @@
|
|||||||
export default function mapRoutes(routes, basePath = '') {
|
export default function mapRoutes(routes, basePath = '') {
|
||||||
const map = {};
|
const map = {};
|
||||||
|
|
||||||
for (const key in routes) {
|
for (const routeName in routes) {
|
||||||
const route = routes[key];
|
const route = routes[routeName];
|
||||||
|
|
||||||
map[basePath + route.path] = {
|
if ('resolver' in route) {
|
||||||
render() {
|
map[basePath + route.path] = route.resolver;
|
||||||
return m(route.component, { routeName: key });
|
} 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;
|
return map;
|
||||||
|
@@ -1,39 +1,6 @@
|
|||||||
import Stream from 'mithril/stream';
|
|
||||||
import extract from './extract';
|
|
||||||
|
|
||||||
export default function patchMithril(global) {
|
export default function patchMithril(global) {
|
||||||
const defaultMithril = global.m;
|
const defaultMithril = global.m;
|
||||||
|
|
||||||
/**
|
|
||||||
* If the href URL of the link is the same as the current page path
|
|
||||||
* we will not add a new entry to the browser history.
|
|
||||||
*
|
|
||||||
* This allows us to still refresh the Page component
|
|
||||||
* without adding endless history entries.
|
|
||||||
*
|
|
||||||
* We also add the `force` attribute that adds a custom state key
|
|
||||||
* for when you want to force a complete refresh of the Page
|
|
||||||
*/
|
|
||||||
const defaultLinkView = defaultMithril.route.Link.view;
|
|
||||||
const modifiedLink = {
|
|
||||||
view: function (vnode) {
|
|
||||||
let { href, options = {} } = vnode.attrs;
|
|
||||||
|
|
||||||
if (href === m.route.get()) {
|
|
||||||
if (!('replace' in options)) options.replace = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (extract(vnode.attrs, 'force')) {
|
|
||||||
if (!('state' in options)) options.state = {};
|
|
||||||
if (!('key' in options.state)) options.state.key = Date.now();
|
|
||||||
}
|
|
||||||
|
|
||||||
vnode.attrs.options = options;
|
|
||||||
|
|
||||||
return defaultLinkView(vnode);
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
const modifiedMithril = function (comp, ...args) {
|
const modifiedMithril = function (comp, ...args) {
|
||||||
const node = defaultMithril.apply(this, arguments);
|
const node = defaultMithril.apply(this, arguments);
|
||||||
|
|
||||||
@@ -44,29 +11,10 @@ export default function patchMithril(global) {
|
|||||||
modifiedMithril.bidi(node, node.attrs.bidi);
|
modifiedMithril.bidi(node, node.attrs.bidi);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Allows us to use a "route" attr on links, which will automatically convert the link to one which
|
|
||||||
// supports linking to other pages in the SPA without refreshing the document.
|
|
||||||
if (node.attrs.route) {
|
|
||||||
node.attrs.href = node.attrs.route;
|
|
||||||
node.tag = modifiedLink;
|
|
||||||
|
|
||||||
// For some reason, m.route.Link does not like vnode.text, so if present, we
|
|
||||||
// need to convert it to text vnodes and store it in children.
|
|
||||||
if (node.text) {
|
|
||||||
node.children = { tag: '#', children: node.text };
|
|
||||||
}
|
|
||||||
|
|
||||||
delete node.attrs.route;
|
|
||||||
}
|
|
||||||
|
|
||||||
return node;
|
return node;
|
||||||
};
|
};
|
||||||
|
|
||||||
Object.keys(defaultMithril).forEach((key) => (modifiedMithril[key] = defaultMithril[key]));
|
Object.keys(defaultMithril).forEach((key) => (modifiedMithril[key] = defaultMithril[key]));
|
||||||
|
|
||||||
modifiedMithril.stream = Stream;
|
|
||||||
|
|
||||||
modifiedMithril.route.Link = modifiedLink;
|
|
||||||
|
|
||||||
global.m = modifiedMithril;
|
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}
|
* @type {DiscussionListState}
|
||||||
*/
|
*/
|
||||||
this.discussions = new DiscussionListState({}, this);
|
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.routes[defaultAction].path = '/';
|
||||||
this.history.push(defaultAction, this.translator.trans('core.forum.header.back_to_index_tooltip'), '/');
|
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('app-navigation'), { view: () => Navigation.component({ className: 'App-backControl', drawer: true }) });
|
||||||
m.mount(document.getElementById('header-navigation'), Navigation);
|
m.mount(document.getElementById('header-navigation'), Navigation);
|
||||||
m.mount(document.getElementById('header-primary'), HeaderPrimary);
|
m.mount(document.getElementById('header-primary'), HeaderPrimary);
|
||||||
m.mount(document.getElementById('header-secondary'), HeaderSecondary);
|
m.mount(document.getElementById('header-secondary'), HeaderSecondary);
|
||||||
m.mount(document.getElementById('composer'), { view: () => Composer.component({ state: this.composer }) });
|
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);
|
alertEmailConfirmation(this);
|
||||||
|
|
||||||
// Route the home link back home when clicked. We do not want it to register
|
// Route the home link back home when clicked. We do not want it to register
|
||||||
|
@@ -71,6 +71,7 @@ import Search from './components/Search';
|
|||||||
import DiscussionListItem from './components/DiscussionListItem';
|
import DiscussionListItem from './components/DiscussionListItem';
|
||||||
import LoadingPost from './components/LoadingPost';
|
import LoadingPost from './components/LoadingPost';
|
||||||
import PostsUserPage from './components/PostsUserPage';
|
import PostsUserPage from './components/PostsUserPage';
|
||||||
|
import DiscussionPageResolver from './resolvers/DiscussionPageResolver';
|
||||||
import routes from './routes';
|
import routes from './routes';
|
||||||
import ForumApplication from './ForumApplication';
|
import ForumApplication from './ForumApplication';
|
||||||
|
|
||||||
@@ -146,6 +147,7 @@ export default Object.assign(compat, {
|
|||||||
'components/DiscussionListItem': DiscussionListItem,
|
'components/DiscussionListItem': DiscussionListItem,
|
||||||
'components/LoadingPost': LoadingPost,
|
'components/LoadingPost': LoadingPost,
|
||||||
'components/PostsUserPage': PostsUserPage,
|
'components/PostsUserPage': PostsUserPage,
|
||||||
|
'resolvers/DiscussionPageResolver': DiscussionPageResolver,
|
||||||
routes: routes,
|
routes: routes,
|
||||||
ForumApplication: ForumApplication,
|
ForumApplication: ForumApplication,
|
||||||
});
|
});
|
||||||
|
@@ -1,5 +1,6 @@
|
|||||||
import Modal from '../../common/components/Modal';
|
import Modal from '../../common/components/Modal';
|
||||||
import Button from '../../common/components/Button';
|
import Button from '../../common/components/Button';
|
||||||
|
import Stream from '../../common/utils/Stream';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The `ChangeEmailModal` component shows a modal dialog which allows the user
|
* The `ChangeEmailModal` component shows a modal dialog which allows the user
|
||||||
@@ -21,14 +22,14 @@ export default class ChangeEmailModal extends Modal {
|
|||||||
*
|
*
|
||||||
* @type {function}
|
* @type {function}
|
||||||
*/
|
*/
|
||||||
this.email = m.stream(app.session.user.email());
|
this.email = Stream(app.session.user.email());
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The value of the password input.
|
* The value of the password input.
|
||||||
*
|
*
|
||||||
* @type {function}
|
* @type {function}
|
||||||
*/
|
*/
|
||||||
this.password = m.stream('');
|
this.password = Stream('');
|
||||||
}
|
}
|
||||||
|
|
||||||
className() {
|
className() {
|
||||||
@@ -117,7 +118,10 @@ export default class ChangeEmailModal extends Modal {
|
|||||||
meta: { password: this.password() },
|
meta: { password: this.password() },
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
.then(() => (this.success = true))
|
.then(() => {
|
||||||
|
this.success = true;
|
||||||
|
this.alertAttrs = null;
|
||||||
|
})
|
||||||
.catch(() => {})
|
.catch(() => {})
|
||||||
.then(this.loaded.bind(this));
|
.then(this.loaded.bind(this));
|
||||||
}
|
}
|
||||||
|
@@ -56,9 +56,7 @@ export default class CommentPost extends Post {
|
|||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
onupdate(vnode) {
|
refreshContent() {
|
||||||
super.onupdate();
|
|
||||||
|
|
||||||
const contentHtml = this.isEditing() ? '' : this.attrs.post.contentHtml();
|
const contentHtml = this.isEditing() ? '' : this.attrs.post.contentHtml();
|
||||||
|
|
||||||
// If the post content has changed since the last render, we'll run through
|
// 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.
|
// necessary because TextFormatter outputs them for e.g. syntax highlighting.
|
||||||
if (this.contentHtml !== contentHtml) {
|
if (this.contentHtml !== contentHtml) {
|
||||||
this.$('.Post-body script').each(function () {
|
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;
|
this.contentHtml = contentHtml;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
oncreate(vnode) {
|
||||||
|
super.oncreate(vnode);
|
||||||
|
|
||||||
|
this.refreshContent();
|
||||||
|
}
|
||||||
|
|
||||||
|
onupdate(vnode) {
|
||||||
|
super.onupdate(vnode);
|
||||||
|
|
||||||
|
this.refreshContent();
|
||||||
|
}
|
||||||
|
|
||||||
isEditing() {
|
isEditing() {
|
||||||
return app.composer.bodyMatches(EditPostComposer, { post: this.attrs.post });
|
return app.composer.bodyMatches(EditPostComposer, { post: this.attrs.post });
|
||||||
}
|
}
|
||||||
|
@@ -82,7 +82,7 @@ export default class Composer extends Component {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// When the escape key is pressed on any inputs, close the composer.
|
// When the escape key is pressed on any inputs, close the composer.
|
||||||
this.$().on('keydown', ':input', 'esc', () => this.close());
|
this.$().on('keydown', ':input', 'esc', () => this.state.close());
|
||||||
|
|
||||||
this.handlers = {};
|
this.handlers = {};
|
||||||
|
|
||||||
@@ -199,7 +199,7 @@ export default class Composer extends Component {
|
|||||||
*/
|
*/
|
||||||
animatePositionChange() {
|
animatePositionChange() {
|
||||||
// When exiting full-screen mode: focus content
|
// 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();
|
this.focus();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -265,7 +265,7 @@ export default class Composer extends Component {
|
|||||||
this.animateHeightChange().then(() => this.focus());
|
this.animateHeightChange().then(() => this.focus());
|
||||||
|
|
||||||
if (app.screen() === 'phone') {
|
if (app.screen() === 'phone') {
|
||||||
this.$().css('top', $(window).scrollTop());
|
this.$().css('top', 0);
|
||||||
this.showBackdrop();
|
this.showBackdrop();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@@ -44,12 +44,6 @@ export default class ComposerBody extends Component {
|
|||||||
}
|
}
|
||||||
|
|
||||||
this.composer.fields.content(this.attrs.originalContent || '');
|
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() {
|
view() {
|
||||||
|
@@ -1,5 +1,6 @@
|
|||||||
import ComposerBody from './ComposerBody';
|
import ComposerBody from './ComposerBody';
|
||||||
import extractText from '../../common/utils/extractText';
|
import extractText from '../../common/utils/extractText';
|
||||||
|
import Stream from '../../common/utils/Stream';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The `DiscussionComposer` component displays the composer content for starting
|
* The `DiscussionComposer` component displays the composer content for starting
|
||||||
@@ -26,7 +27,7 @@ export default class DiscussionComposer extends ComposerBody {
|
|||||||
oninit(vnode) {
|
oninit(vnode) {
|
||||||
super.oninit(vnode);
|
super.oninit(vnode);
|
||||||
|
|
||||||
this.composer.fields.title = this.composer.fields.title || m.stream('');
|
this.composer.fields.title = this.composer.fields.title || Stream('');
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The value of the title input.
|
* The value of the title input.
|
||||||
@@ -99,7 +100,7 @@ export default class DiscussionComposer extends ComposerBody {
|
|||||||
.save(data)
|
.save(data)
|
||||||
.then((discussion) => {
|
.then((discussion) => {
|
||||||
this.composer.hide();
|
this.composer.hide();
|
||||||
app.discussions.refresh();
|
app.discussions.refresh({ deferClear: true });
|
||||||
m.route.set(app.route.discussion(discussion));
|
m.route.set(app.route.discussion(discussion));
|
||||||
}, this.loaded.bind(this));
|
}, this.loaded.bind(this));
|
||||||
}
|
}
|
||||||
|
@@ -21,13 +21,7 @@ export default class DiscussionList extends Component {
|
|||||||
if (state.isLoading()) {
|
if (state.isLoading()) {
|
||||||
loading = LoadingIndicator.component();
|
loading = LoadingIndicator.component();
|
||||||
} else if (state.moreResults) {
|
} else if (state.moreResults) {
|
||||||
loading = Button.component(
|
loading = this.getLoadButton('more', state.loadMore.bind(state));
|
||||||
{
|
|
||||||
className: 'Button',
|
|
||||||
onclick: state.loadMore.bind(state),
|
|
||||||
},
|
|
||||||
app.translator.trans('core.forum.discussion_list.load_more_button')
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (state.empty()) {
|
if (state.empty()) {
|
||||||
@@ -35,8 +29,18 @@ export default class DiscussionList extends Component {
|
|||||||
return <div className="DiscussionList">{Placeholder.component({ text })}</div>;
|
return <div className="DiscussionList">{Placeholder.component({ text })}</div>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
console.log(state);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={'DiscussionList' + (state.isSearchResults() ? ' DiscussionList--searchResults' : '')}>
|
<div className={'DiscussionList' + (state.isSearchResults() ? ' DiscussionList--searchResults' : '')}>
|
||||||
|
{state.isLoadingPrev() ? (
|
||||||
|
<LoadingIndicator />
|
||||||
|
) : state.pagination.pages.first !== 1 ? (
|
||||||
|
<div className="DiscussionList-loadMore">{this.getLoadButton('prev', state.loadPrev.bind(state))}</div>
|
||||||
|
) : (
|
||||||
|
''
|
||||||
|
)}
|
||||||
|
|
||||||
<ul className="DiscussionList-discussions">
|
<ul className="DiscussionList-discussions">
|
||||||
{state.discussions.map((discussion) => {
|
{state.discussions.map((discussion) => {
|
||||||
return (
|
return (
|
||||||
@@ -46,8 +50,17 @@ export default class DiscussionList extends Component {
|
|||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
<div className="DiscussionList-loadMore">{loading}</div>
|
<div className="DiscussionList-loadMore">{loading}</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
getLoadButton(key, onclick) {
|
||||||
|
return (
|
||||||
|
<Button className="Button" onclick={onclick}>
|
||||||
|
{app.translator.trans(`core.forum.discussion_list.load_${key}_button`)}
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@@ -1,4 +1,5 @@
|
|||||||
import Component from '../../common/Component';
|
import Component from '../../common/Component';
|
||||||
|
import Link from '../../common/components/Link';
|
||||||
import avatar from '../../common/helpers/avatar';
|
import avatar from '../../common/helpers/avatar';
|
||||||
import listItems from '../../common/helpers/listItems';
|
import listItems from '../../common/helpers/listItems';
|
||||||
import highlight from '../../common/helpers/highlight';
|
import highlight from '../../common/helpers/highlight';
|
||||||
@@ -13,6 +14,7 @@ import DiscussionControls from '../utils/DiscussionControls';
|
|||||||
import slidable from '../utils/slidable';
|
import slidable from '../utils/slidable';
|
||||||
import extractText from '../../common/utils/extractText';
|
import extractText from '../../common/utils/extractText';
|
||||||
import classList from '../../common/utils/classList';
|
import classList from '../../common/utils/classList';
|
||||||
|
import DiscussionPage from './DiscussionPage';
|
||||||
|
|
||||||
import { escapeRegExp } from 'lodash-es';
|
import { escapeRegExp } from 'lodash-es';
|
||||||
/**
|
/**
|
||||||
@@ -50,6 +52,7 @@ export default class DiscussionListItem extends Component {
|
|||||||
'DiscussionListItem',
|
'DiscussionListItem',
|
||||||
this.active() ? 'active' : '',
|
this.active() ? 'active' : '',
|
||||||
this.attrs.discussion.isHidden() ? 'DiscussionListItem--hidden' : '',
|
this.attrs.discussion.isHidden() ? 'DiscussionListItem--hidden' : '',
|
||||||
|
'ontouchstart' in window ? 'Slidable' : '',
|
||||||
]),
|
]),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -89,16 +92,16 @@ export default class DiscussionListItem extends Component {
|
|||||||
)
|
)
|
||||||
: ''}
|
: ''}
|
||||||
|
|
||||||
<a
|
<span
|
||||||
className={'Slidable-underneath Slidable-underneath--left Slidable-underneath--elastic' + (isUnread ? '' : ' disabled')}
|
className={'Slidable-underneath Slidable-underneath--left Slidable-underneath--elastic' + (isUnread ? '' : ' disabled')}
|
||||||
onclick={this.markAsRead.bind(this)}
|
onclick={this.markAsRead.bind(this)}
|
||||||
>
|
>
|
||||||
{icon('fas fa-check')}
|
{icon('fas fa-check')}
|
||||||
</a>
|
</span>
|
||||||
|
|
||||||
<div className={'DiscussionListItem-content Slidable-content' + (isUnread ? ' unread' : '') + (isRead ? ' read' : '')}>
|
<div className={'DiscussionListItem-content Slidable-content' + (isUnread ? ' unread' : '') + (isRead ? ' read' : '')}>
|
||||||
<a
|
<Link
|
||||||
route={user ? app.route.user(user) : '#'}
|
href={user ? app.route.user(user) : '#'}
|
||||||
className="DiscussionListItem-author"
|
className="DiscussionListItem-author"
|
||||||
title={extractText(
|
title={extractText(
|
||||||
app.translator.trans('core.forum.discussion_list.started_text', { user: user, ago: humanTime(discussion.createdAt()) })
|
app.translator.trans('core.forum.discussion_list.started_text', { user: user, ago: humanTime(discussion.createdAt()) })
|
||||||
@@ -108,14 +111,14 @@ export default class DiscussionListItem extends Component {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{avatar(user, { title: '' })}
|
{avatar(user, { title: '' })}
|
||||||
</a>
|
</Link>
|
||||||
|
|
||||||
<ul className="DiscussionListItem-badges badges">{listItems(discussion.badges().toArray())}</ul>
|
<ul className="DiscussionListItem-badges badges">{listItems(discussion.badges().toArray())}</ul>
|
||||||
|
|
||||||
<a route={app.route.discussion(discussion, jumpTo)} className="DiscussionListItem-main">
|
<Link href={app.route.discussion(discussion, jumpTo)} className="DiscussionListItem-main">
|
||||||
<h3 className="DiscussionListItem-title">{highlight(discussion.title(), this.highlightRegExp)}</h3>
|
<h3 className="DiscussionListItem-title">{highlight(discussion.title(), this.highlightRegExp)}</h3>
|
||||||
<ul className="DiscussionListItem-info">{listItems(this.infoItems().toArray())}</ul>
|
<ul className="DiscussionListItem-info">{listItems(this.infoItems().toArray())}</ul>
|
||||||
</a>
|
</Link>
|
||||||
|
|
||||||
<span
|
<span
|
||||||
className="DiscussionListItem-count"
|
className="DiscussionListItem-count"
|
||||||
@@ -136,7 +139,7 @@ export default class DiscussionListItem extends Component {
|
|||||||
// This allows the user to drag the row to either side of the screen to
|
// This allows the user to drag the row to either side of the screen to
|
||||||
// reveal controls.
|
// reveal controls.
|
||||||
if ('ontouchstart' in window) {
|
if ('ontouchstart' in window) {
|
||||||
const slidableInstance = slidable(this.$().addClass('Slidable'));
|
const slidableInstance = slidable(this.$());
|
||||||
|
|
||||||
this.$('.DiscussionListItem-controls').on('hidden.bs.dropdown', () => slidableInstance.reset());
|
this.$('.DiscussionListItem-controls').on('hidden.bs.dropdown', () => slidableInstance.reset());
|
||||||
}
|
}
|
||||||
@@ -154,9 +157,7 @@ export default class DiscussionListItem extends Component {
|
|||||||
* @return {Boolean}
|
* @return {Boolean}
|
||||||
*/
|
*/
|
||||||
active() {
|
active() {
|
||||||
const idParam = m.route.param('id');
|
return app.current.matches(DiscussionPage, { discussion: this.attrs.discussion });
|
||||||
|
|
||||||
return idParam && idParam.split('-')[0] === this.attrs.discussion.id();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@@ -18,6 +18,8 @@ export default class DiscussionPage extends Page {
|
|||||||
oninit(vnode) {
|
oninit(vnode) {
|
||||||
super.oninit(vnode);
|
super.oninit(vnode);
|
||||||
|
|
||||||
|
this.useBrowserScrollRestoration = false;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The discussion that is being viewed.
|
* The discussion that is being viewed.
|
||||||
*
|
*
|
||||||
@@ -47,11 +49,10 @@ export default class DiscussionPage extends Page {
|
|||||||
app.history.push('discussion');
|
app.history.push('discussion');
|
||||||
|
|
||||||
this.bodyClass = 'App--discussion';
|
this.bodyClass = 'App--discussion';
|
||||||
|
|
||||||
this.prevRoute = m.route.get();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
onremove() {
|
onremove() {
|
||||||
|
super.onremove();
|
||||||
// If we are indeed navigating away from this discussion, then disable the
|
// 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 list pane. Also, if we're composing a reply to this
|
||||||
// discussion, minimize the composer – unless it's empty, in which case
|
// discussion, minimize the composer – unless it's empty, in which case
|
||||||
@@ -83,7 +84,6 @@ export default class DiscussionPage extends Page {
|
|||||||
{PostStream.component({
|
{PostStream.component({
|
||||||
discussion,
|
discussion,
|
||||||
stream: this.stream,
|
stream: this.stream,
|
||||||
targetPost: this.stream.targetPost,
|
|
||||||
onPositionChange: this.positionChanged.bind(this),
|
onPositionChange: this.positionChanged.bind(this),
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
@@ -95,33 +95,6 @@ export default class DiscussionPage extends Page {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
onbeforeupdate(vnode) {
|
|
||||||
super.onbeforeupdate(vnode);
|
|
||||||
|
|
||||||
if (m.route.get() !== this.prevRoute) {
|
|
||||||
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.
|
* Load the discussion from the API or use the preloaded one.
|
||||||
*/
|
*/
|
||||||
@@ -136,7 +109,7 @@ export default class DiscussionPage extends Page {
|
|||||||
} else {
|
} else {
|
||||||
const params = this.requestParams();
|
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();
|
m.redraw();
|
||||||
@@ -150,6 +123,7 @@ export default class DiscussionPage extends Page {
|
|||||||
*/
|
*/
|
||||||
requestParams() {
|
requestParams() {
|
||||||
return {
|
return {
|
||||||
|
bySlug: true,
|
||||||
page: { near: this.near },
|
page: { near: this.near },
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -160,10 +134,8 @@ export default class DiscussionPage extends Page {
|
|||||||
* @param {Discussion} discussion
|
* @param {Discussion} discussion
|
||||||
*/
|
*/
|
||||||
show(discussion) {
|
show(discussion) {
|
||||||
this.discussion = discussion;
|
|
||||||
|
|
||||||
app.history.push('discussion', discussion.title());
|
app.history.push('discussion', discussion.title());
|
||||||
app.setTitle(this.discussion.title());
|
app.setTitle(discussion.title());
|
||||||
app.setTitleCount(0);
|
app.setTitleCount(0);
|
||||||
|
|
||||||
// When the API responds with a discussion, it will also include a number of
|
// When the API responds with a discussion, it will also include a number of
|
||||||
@@ -186,7 +158,7 @@ export default class DiscussionPage extends Page {
|
|||||||
record.relationships.discussion.data.id === discussionId
|
record.relationships.discussion.data.id === discussionId
|
||||||
)
|
)
|
||||||
.map((record) => app.store.getById('posts', record.id))
|
.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);
|
.slice(0, 20);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -194,10 +166,12 @@ export default class DiscussionPage extends Page {
|
|||||||
// posts we want to display. Tell the stream to scroll down and highlight
|
// posts we want to display. Tell the stream to scroll down and highlight
|
||||||
// the specific post that was routed to.
|
// the specific post that was routed to.
|
||||||
this.stream = new PostStreamState(discussion, includedPosts);
|
this.stream = new PostStreamState(discussion, includedPosts);
|
||||||
this.stream.goToNumber(m.route.param('near') || (includedPosts[0] && includedPosts[0].number()), true);
|
this.stream.goToNumber(m.route.param('near') || (includedPosts[0] && includedPosts[0].number()), true).then(() => {
|
||||||
|
this.discussion = discussion;
|
||||||
|
|
||||||
app.current.set('discussion', discussion);
|
app.current.set('discussion', discussion);
|
||||||
app.current.set('stream', this.stream);
|
app.current.set('stream', this.stream);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -246,10 +220,7 @@ export default class DiscussionPage extends Page {
|
|||||||
// replace it into the window's history and our own history stack.
|
// replace it into the window's history and our own history stack.
|
||||||
const url = app.route.discussion(discussion, (this.near = startNumber));
|
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);
|
window.history.replaceState(null, document.title, url);
|
||||||
|
|
||||||
app.history.push('discussion', discussion.title());
|
app.history.push('discussion', discussion.title());
|
||||||
|
|
||||||
// If the user hasn't read past here before, then we'll update their read
|
// If the user hasn't read past here before, then we'll update their read
|
||||||
|
@@ -1,5 +1,6 @@
|
|||||||
import highlight from '../../common/helpers/highlight';
|
import highlight from '../../common/helpers/highlight';
|
||||||
import LinkButton from '../../common/components/LinkButton';
|
import LinkButton from '../../common/components/LinkButton';
|
||||||
|
import Link from '../../common/components/Link';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The `DiscussionsSearchSource` finds and displays discussion search results in
|
* The `DiscussionsSearchSource` finds and displays discussion search results in
|
||||||
@@ -47,10 +48,10 @@ export default class DiscussionsSearchSource {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<li className="DiscussionSearchResult" data-index={'discussions' + discussion.id()}>
|
<li className="DiscussionSearchResult" data-index={'discussions' + discussion.id()}>
|
||||||
<a route={app.route.discussion(discussion, mostRelevantPost && mostRelevantPost.number())}>
|
<Link href={app.route.discussion(discussion, mostRelevantPost && mostRelevantPost.number())}>
|
||||||
<div className="DiscussionSearchResult-title">{highlight(discussion.title(), query)}</div>
|
<div className="DiscussionSearchResult-title">{highlight(discussion.title(), query)}</div>
|
||||||
{mostRelevantPost ? <div className="DiscussionSearchResult-excerpt">{highlight(mostRelevantPost.contentPlain(), query, 100)}</div> : ''}
|
{mostRelevantPost ? <div className="DiscussionSearchResult-excerpt">{highlight(mostRelevantPost.contentPlain(), query, 100)}</div> : ''}
|
||||||
</a>
|
</Link>
|
||||||
</li>
|
</li>
|
||||||
);
|
);
|
||||||
}),
|
}),
|
||||||
|
@@ -1,5 +1,6 @@
|
|||||||
import ComposerBody from './ComposerBody';
|
import ComposerBody from './ComposerBody';
|
||||||
import Button from '../../common/components/Button';
|
import Button from '../../common/components/Button';
|
||||||
|
import Link from '../../common/components/Link';
|
||||||
import icon from '../../common/helpers/icon';
|
import icon from '../../common/helpers/icon';
|
||||||
|
|
||||||
function minimizeComposerIfFullScreen(e) {
|
function minimizeComposerIfFullScreen(e) {
|
||||||
@@ -39,9 +40,9 @@ export default class EditPostComposer extends ComposerBody {
|
|||||||
'title',
|
'title',
|
||||||
<h3>
|
<h3>
|
||||||
{icon('fas fa-pencil-alt')}{' '}
|
{icon('fas fa-pencil-alt')}{' '}
|
||||||
<a route={app.route.discussion(post.discussion(), post.number())} onclick={minimizeComposerIfFullScreen}>
|
<Link href={app.route.discussion(post.discussion(), post.number())} onclick={minimizeComposerIfFullScreen}>
|
||||||
{app.translator.trans('core.forum.composer_edit.post_link', { number: post.number(), discussion: post.discussion().title() })}
|
{app.translator.trans('core.forum.composer_edit.post_link', { number: post.number(), discussion: post.discussion().title() })}
|
||||||
</a>
|
</Link>
|
||||||
</h3>
|
</h3>
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -95,10 +96,13 @@ export default class EditPostComposer extends ComposerBody {
|
|||||||
},
|
},
|
||||||
app.translator.trans('core.forum.composer_edit.view_button')
|
app.translator.trans('core.forum.composer_edit.view_button')
|
||||||
);
|
);
|
||||||
alert = app.alerts.show(app.translator.trans('core.forum.composer_edit.edited_message'), {
|
alert = app.alerts.show(
|
||||||
type: 'success',
|
{
|
||||||
controls: [viewButton],
|
type: 'success',
|
||||||
});
|
controls: [viewButton],
|
||||||
|
},
|
||||||
|
app.translator.trans('core.forum.composer_edit.edited_message')
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
this.composer.hide();
|
this.composer.hide();
|
||||||
|
@@ -4,6 +4,7 @@ import GroupBadge from '../../common/components/GroupBadge';
|
|||||||
import Group from '../../common/models/Group';
|
import Group from '../../common/models/Group';
|
||||||
import extractText from '../../common/utils/extractText';
|
import extractText from '../../common/utils/extractText';
|
||||||
import ItemList from '../../common/utils/ItemList';
|
import ItemList from '../../common/utils/ItemList';
|
||||||
|
import Stream from '../../common/utils/Stream';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The `EditUserModal` component displays a modal dialog with a login form.
|
* The `EditUserModal` component displays a modal dialog with a login form.
|
||||||
@@ -14,17 +15,17 @@ export default class EditUserModal extends Modal {
|
|||||||
|
|
||||||
const user = this.attrs.user;
|
const user = this.attrs.user;
|
||||||
|
|
||||||
this.username = m.stream(user.username() || '');
|
this.username = Stream(user.username() || '');
|
||||||
this.email = m.stream(user.email() || '');
|
this.email = Stream(user.email() || '');
|
||||||
this.isEmailConfirmed = m.stream(user.isEmailConfirmed() || false);
|
this.isEmailConfirmed = Stream(user.isEmailConfirmed() || false);
|
||||||
this.setPassword = m.stream(false);
|
this.setPassword = Stream(false);
|
||||||
this.password = m.stream(user.password() || '');
|
this.password = Stream(user.password() || '');
|
||||||
this.groups = {};
|
this.groups = {};
|
||||||
|
|
||||||
app.store
|
app.store
|
||||||
.all('groups')
|
.all('groups')
|
||||||
.filter((group) => [Group.GUEST_ID, Group.MEMBER_ID].indexOf(group.id()) === -1)
|
.filter((group) => [Group.GUEST_ID, Group.MEMBER_ID].indexOf(group.id()) === -1)
|
||||||
.forEach((group) => (this.groups[group.id()] = m.stream(user.groups().indexOf(group) !== -1)));
|
.forEach((group) => (this.groups[group.id()] = Stream(user.groups().indexOf(group) !== -1)));
|
||||||
}
|
}
|
||||||
|
|
||||||
className() {
|
className() {
|
||||||
|
@@ -2,6 +2,7 @@ import Post from './Post';
|
|||||||
import { ucfirst } from '../../common/utils/string';
|
import { ucfirst } from '../../common/utils/string';
|
||||||
import usernameHelper from '../../common/helpers/username';
|
import usernameHelper from '../../common/helpers/username';
|
||||||
import icon from '../../common/helpers/icon';
|
import icon from '../../common/helpers/icon';
|
||||||
|
import Link from '../../common/components/Link';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The `EventPost` component displays a post which indicating a discussion
|
* The `EventPost` component displays a post which indicating a discussion
|
||||||
@@ -29,9 +30,9 @@ export default class EventPost extends Post {
|
|||||||
const data = Object.assign(this.descriptionData(), {
|
const data = Object.assign(this.descriptionData(), {
|
||||||
user,
|
user,
|
||||||
username: user ? (
|
username: user ? (
|
||||||
<a className="EventPost-user" route={app.route.user(user)}>
|
<Link className="EventPost-user" href={app.route.user(user)}>
|
||||||
{username}
|
{username}
|
||||||
</a>
|
</Link>
|
||||||
) : (
|
) : (
|
||||||
username
|
username
|
||||||
),
|
),
|
||||||
|
@@ -1,6 +1,7 @@
|
|||||||
import Modal from '../../common/components/Modal';
|
import Modal from '../../common/components/Modal';
|
||||||
import Button from '../../common/components/Button';
|
import Button from '../../common/components/Button';
|
||||||
import extractText from '../../common/utils/extractText';
|
import extractText from '../../common/utils/extractText';
|
||||||
|
import Stream from '../../common/utils/Stream';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The `ForgotPasswordModal` component displays a modal which allows the user to
|
* The `ForgotPasswordModal` component displays a modal which allows the user to
|
||||||
@@ -19,7 +20,7 @@ export default class ForgotPasswordModal extends Modal {
|
|||||||
*
|
*
|
||||||
* @type {Function}
|
* @type {Function}
|
||||||
*/
|
*/
|
||||||
this.email = m.stream(this.attrs.email || '');
|
this.email = Stream(this.attrs.email || '');
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Whether or not the password reset email was sent successfully.
|
* Whether or not the password reset email was sent successfully.
|
||||||
|
@@ -42,26 +42,7 @@ export default class IndexPage extends Page {
|
|||||||
app.history.push('index', app.translator.trans('core.forum.header.back_to_index_tooltip'));
|
app.history.push('index', app.translator.trans('core.forum.header.back_to_index_tooltip'));
|
||||||
|
|
||||||
this.bodyClass = 'App--index';
|
this.bodyClass = 'App--index';
|
||||||
|
this.scrollTopOnCreate = false;
|
||||||
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();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
view() {
|
view() {
|
||||||
@@ -105,18 +86,22 @@ export default class IndexPage extends Page {
|
|||||||
|
|
||||||
$('#app').css('min-height', $(window).height() + heroHeight);
|
$('#app').css('min-height', $(window).height() + heroHeight);
|
||||||
|
|
||||||
// Scroll to the remembered position. We do this after a short delay so that
|
// Let browser handle scrolling on page reload.
|
||||||
// it happens after the browser has done its own "back button" scrolling,
|
if (app.previous.type == null) return;
|
||||||
// which isn't right. https://github.com/flarum/core/issues/835
|
|
||||||
const scroll = () => $(window).scrollTop(scrollTop - oldHeroHeight + heroHeight);
|
// When on mobile, only retain scroll if we're coming from a discussion page.
|
||||||
scroll();
|
// Otherwise, we've just changed the filter, so we should go to the top of the page.
|
||||||
setTimeout(scroll, 1);
|
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
|
// 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
|
// have set the `lastDiscussion` property. If this is the case, we want to
|
||||||
// scroll down to that discussion so that it's in view.
|
// scroll down to that discussion so that it's in view.
|
||||||
if (this.lastDiscussion) {
|
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) {
|
if ($discussion.length) {
|
||||||
const indexTop = $('#header').outerHeight();
|
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() {
|
onremove() {
|
||||||
super.onremove();
|
super.onremove();
|
||||||
|
|
||||||
$('#app').css('min-height', '');
|
$('#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();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@@ -5,6 +5,7 @@ import Button from '../../common/components/Button';
|
|||||||
import LogInButtons from './LogInButtons';
|
import LogInButtons from './LogInButtons';
|
||||||
import extractText from '../../common/utils/extractText';
|
import extractText from '../../common/utils/extractText';
|
||||||
import ItemList from '../../common/utils/ItemList';
|
import ItemList from '../../common/utils/ItemList';
|
||||||
|
import Stream from '../../common/utils/Stream';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The `LogInModal` component displays a modal dialog with a login form.
|
* The `LogInModal` component displays a modal dialog with a login form.
|
||||||
@@ -23,21 +24,21 @@ export default class LogInModal extends Modal {
|
|||||||
*
|
*
|
||||||
* @type {Function}
|
* @type {Function}
|
||||||
*/
|
*/
|
||||||
this.identification = m.stream(this.attrs.identification || '');
|
this.identification = Stream(this.attrs.identification || '');
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The value of the password input.
|
* The value of the password input.
|
||||||
*
|
*
|
||||||
* @type {Function}
|
* @type {Function}
|
||||||
*/
|
*/
|
||||||
this.password = m.stream(this.attrs.password || '');
|
this.password = Stream(this.attrs.password || '');
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The value of the remember me input.
|
* The value of the remember me input.
|
||||||
*
|
*
|
||||||
* @type {Function}
|
* @type {Function}
|
||||||
*/
|
*/
|
||||||
this.remember = m.stream(!!this.attrs.remember);
|
this.remember = Stream(!!this.attrs.remember);
|
||||||
}
|
}
|
||||||
|
|
||||||
className() {
|
className() {
|
||||||
|
@@ -3,6 +3,7 @@ import avatar from '../../common/helpers/avatar';
|
|||||||
import icon from '../../common/helpers/icon';
|
import icon from '../../common/helpers/icon';
|
||||||
import humanTime from '../../common/helpers/humanTime';
|
import humanTime from '../../common/helpers/humanTime';
|
||||||
import Button from '../../common/components/Button';
|
import Button from '../../common/components/Button';
|
||||||
|
import Link from '../../common/components/Link';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The `Notification` component abstract displays a single notification.
|
* The `Notification` component abstract displays a single notification.
|
||||||
@@ -19,13 +20,11 @@ export default class Notification extends Component {
|
|||||||
const notification = this.attrs.notification;
|
const notification = this.attrs.notification;
|
||||||
const href = this.href();
|
const href = this.href();
|
||||||
|
|
||||||
const linkAttrs = {};
|
|
||||||
linkAttrs[href.indexOf('://') === -1 ? 'route' : 'href'] = href;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<a
|
<Link
|
||||||
className={'Notification Notification--' + notification.contentType() + ' ' + (!notification.isRead() ? 'unread' : '')}
|
className={'Notification Notification--' + notification.contentType() + ' ' + (!notification.isRead() ? 'unread' : '')}
|
||||||
{...linkAttrs}
|
href={href}
|
||||||
|
external={href.includes('://')}
|
||||||
onclick={this.markAsRead.bind(this)}
|
onclick={this.markAsRead.bind(this)}
|
||||||
>
|
>
|
||||||
{!notification.isRead() &&
|
{!notification.isRead() &&
|
||||||
@@ -45,7 +44,7 @@ export default class Notification extends Component {
|
|||||||
<span className="Notification-content">{this.content()}</span>
|
<span className="Notification-content">{this.content()}</span>
|
||||||
{humanTime(notification.createdAt())}
|
{humanTime(notification.createdAt())}
|
||||||
<div className="Notification-excerpt">{this.excerpt()}</div>
|
<div className="Notification-excerpt">{this.excerpt()}</div>
|
||||||
</a>
|
</Link>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@@ -1,6 +1,7 @@
|
|||||||
import Component from '../../common/Component';
|
import Component from '../../common/Component';
|
||||||
import listItems from '../../common/helpers/listItems';
|
import listItems from '../../common/helpers/listItems';
|
||||||
import Button from '../../common/components/Button';
|
import Button from '../../common/components/Button';
|
||||||
|
import Link from '../../common/components/Link';
|
||||||
import LoadingIndicator from '../../common/components/LoadingIndicator';
|
import LoadingIndicator from '../../common/components/LoadingIndicator';
|
||||||
import Discussion from '../../common/models/Discussion';
|
import Discussion from '../../common/models/Discussion';
|
||||||
|
|
||||||
@@ -63,10 +64,10 @@ export default class NotificationList extends Component {
|
|||||||
return (
|
return (
|
||||||
<div className="NotificationGroup">
|
<div className="NotificationGroup">
|
||||||
{group.discussion ? (
|
{group.discussion ? (
|
||||||
<a className="NotificationGroup-header" route={app.route.discussion(group.discussion)}>
|
<Link className="NotificationGroup-header" href={app.route.discussion(group.discussion)}>
|
||||||
{badges && badges.length ? <ul className="NotificationGroup-badges badges">{listItems(badges)}</ul> : ''}
|
{badges && badges.length ? <ul className="NotificationGroup-badges badges">{listItems(badges)}</ul> : ''}
|
||||||
{group.discussion.title()}
|
{group.discussion.title()}
|
||||||
</a>
|
</Link>
|
||||||
) : (
|
) : (
|
||||||
<div className="NotificationGroup-header">{app.forum.attribute('title')}</div>
|
<div className="NotificationGroup-header">{app.forum.attribute('title')}</div>
|
||||||
)}
|
)}
|
||||||
|
@@ -1,4 +1,5 @@
|
|||||||
import Component from '../../common/Component';
|
import Component from '../../common/Component';
|
||||||
|
import Link from '../../common/components/Link';
|
||||||
import avatar from '../../common/helpers/avatar';
|
import avatar from '../../common/helpers/avatar';
|
||||||
import username from '../../common/helpers/username';
|
import username from '../../common/helpers/username';
|
||||||
import highlight from '../../common/helpers/highlight';
|
import highlight from '../../common/helpers/highlight';
|
||||||
@@ -18,12 +19,12 @@ export default class PostPreview extends Component {
|
|||||||
const excerpt = highlight(post.contentPlain(), this.attrs.highlight, 300);
|
const excerpt = highlight(post.contentPlain(), this.attrs.highlight, 300);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<a className="PostPreview" route={app.route.post(post)} onclick={this.attrs.onclick}>
|
<Link className="PostPreview" href={app.route.post(post)} onclick={this.attrs.onclick}>
|
||||||
<span className="PostPreview-content">
|
<span className="PostPreview-content">
|
||||||
{avatar(user)}
|
{avatar(user)}
|
||||||
{username(user)} <span className="PostPreview-excerpt">{excerpt}</span>
|
{username(user)} <span className="PostPreview-excerpt">{excerpt}</span>
|
||||||
</span>
|
</span>
|
||||||
</a>
|
</Link>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@@ -26,17 +26,19 @@ export default class PostStream extends Component {
|
|||||||
}
|
}
|
||||||
|
|
||||||
view() {
|
view() {
|
||||||
function fadeIn(element, isInitialized, context) {
|
|
||||||
if (!context.fadedIn) $(element).hide().fadeIn();
|
|
||||||
context.fadedIn = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
let lastTime;
|
let lastTime;
|
||||||
|
|
||||||
const viewingEnd = this.stream.viewingEnd();
|
const viewingEnd = this.stream.viewingEnd();
|
||||||
const posts = this.stream.posts();
|
const posts = this.stream.posts();
|
||||||
const postIds = this.discussion.postIds();
|
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) => {
|
const items = posts.map((post, i) => {
|
||||||
let content;
|
let content;
|
||||||
const attrs = { 'data-index': this.stream.visibleStart + i };
|
const attrs = { 'data-index': this.stream.visibleStart + i };
|
||||||
@@ -47,13 +49,13 @@ export default class PostStream extends Component {
|
|||||||
content = PostComponent ? PostComponent.component({ post }) : '';
|
content = PostComponent ? PostComponent.component({ post }) : '';
|
||||||
|
|
||||||
attrs.key = 'post' + post.id();
|
attrs.key = 'post' + post.id();
|
||||||
attrs.config = fadeIn;
|
attrs.oncreate = postFadeIn;
|
||||||
attrs['data-time'] = time.toISOString();
|
attrs['data-time'] = time.toISOString();
|
||||||
attrs['data-number'] = post.number();
|
attrs['data-number'] = post.number();
|
||||||
attrs['data-id'] = post.id();
|
attrs['data-id'] = post.id();
|
||||||
attrs['data-type'] = post.contentType();
|
attrs['data-type'] = post.contentType();
|
||||||
|
|
||||||
// If the post before this one was more than 4 hours ago, we will
|
// If the post before this one was more than 4 days ago, we will
|
||||||
// display a 'time gap' indicating how long it has been in between
|
// display a 'time gap' indicating how long it has been in between
|
||||||
// the posts.
|
// the posts.
|
||||||
const dt = time - lastTime;
|
const dt = time - lastTime;
|
||||||
@@ -95,7 +97,7 @@ export default class PostStream extends Component {
|
|||||||
// is not already doing so, then show a 'write a reply' placeholder.
|
// is not already doing so, then show a 'write a reply' placeholder.
|
||||||
if (viewingEnd && (!app.session.user || this.discussion.canReply())) {
|
if (viewingEnd && (!app.session.user || this.discussion.canReply())) {
|
||||||
items.push(
|
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 })}
|
{ReplyPlaceholder.component({ discussion: this.discussion })}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -127,24 +129,16 @@ export default class PostStream extends Component {
|
|||||||
* Start scrolling, if appropriate, to a newly-targeted post.
|
* Start scrolling, if appropriate, to a newly-targeted post.
|
||||||
*/
|
*/
|
||||||
triggerScroll() {
|
triggerScroll() {
|
||||||
if (!this.attrs.targetPost) return;
|
if (!this.stream.needsScroll) return;
|
||||||
|
|
||||||
const oldTarget = this.prevTarget;
|
const target = this.stream.targetPost;
|
||||||
const newTarget = this.attrs.targetPost;
|
this.stream.needsScroll = false;
|
||||||
|
|
||||||
if (oldTarget) {
|
if ('number' in target) {
|
||||||
if ('number' in oldTarget && oldTarget.number === newTarget.number) return;
|
this.scrollToNumber(target.number, this.stream.animateScroll);
|
||||||
if ('index' in oldTarget && oldTarget.index === newTarget.index) return;
|
} else if ('index' in target) {
|
||||||
|
this.scrollToIndex(target.index, this.stream.animateScroll, target.reply);
|
||||||
}
|
}
|
||||||
|
|
||||||
if ('number' in newTarget) {
|
|
||||||
this.scrollToNumber(newTarget.number, this.stream.noAnimationScroll);
|
|
||||||
} else if ('index' in newTarget) {
|
|
||||||
const backwards = newTarget.index === this.stream.count() - 1;
|
|
||||||
this.scrollToIndex(newTarget.index, this.stream.noAnimationScroll, backwards);
|
|
||||||
}
|
|
||||||
|
|
||||||
this.prevTarget = newTarget;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -155,6 +149,11 @@ export default class PostStream extends Component {
|
|||||||
*/
|
*/
|
||||||
onscroll(top = window.pageYOffset) {
|
onscroll(top = window.pageYOffset) {
|
||||||
if (this.stream.paused) return;
|
if (this.stream.paused) return;
|
||||||
|
|
||||||
|
this.updateScrubber(top);
|
||||||
|
|
||||||
|
if (this.stream.pagesLoading) return;
|
||||||
|
|
||||||
const marginTop = this.getMarginTop();
|
const marginTop = this.getMarginTop();
|
||||||
const viewportHeight = $(window).height() - marginTop;
|
const viewportHeight = $(window).height() - marginTop;
|
||||||
const viewportTop = top + marginTop;
|
const viewportTop = top + marginTop;
|
||||||
@@ -180,8 +179,6 @@ export default class PostStream extends Component {
|
|||||||
// viewport) to 100ms.
|
// viewport) to 100ms.
|
||||||
clearTimeout(this.calculatePositionTimeout);
|
clearTimeout(this.calculatePositionTimeout);
|
||||||
this.calculatePositionTimeout = setTimeout(this.calculatePosition.bind(this, top), 100);
|
this.calculatePositionTimeout = setTimeout(this.calculatePosition.bind(this, top), 100);
|
||||||
|
|
||||||
this.updateScrubber(top);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
updateScrubber(top = window.pageYOffset) {
|
updateScrubber(top = window.pageYOffset) {
|
||||||
@@ -194,9 +191,9 @@ export default class PostStream extends Component {
|
|||||||
// seen if the browser were scrolled right up to the top of the page,
|
// seen if the browser were scrolled right up to the top of the page,
|
||||||
// and the viewport had a height of 0.
|
// and the viewport had a height of 0.
|
||||||
const $items = this.$('.PostStream-item[data-index]');
|
const $items = this.$('.PostStream-item[data-index]');
|
||||||
let index = $items.first().data('index') || 0;
|
|
||||||
let visible = 0;
|
let visible = 0;
|
||||||
let period = '';
|
let period = '';
|
||||||
|
let indexFromViewPort = null;
|
||||||
|
|
||||||
// Now loop through each of the items in the discussion. An 'item' is
|
// 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
|
// either a single post or a 'gap' of one or more posts that haven't
|
||||||
@@ -222,8 +219,10 @@ export default class PostStream extends Component {
|
|||||||
const visibleBottom = Math.min(height, viewportTop + viewportHeight - top);
|
const visibleBottom = Math.min(height, viewportTop + viewportHeight - top);
|
||||||
const visiblePost = visibleBottom - visibleTop;
|
const visiblePost = visibleBottom - visibleTop;
|
||||||
|
|
||||||
if (top <= viewportTop) {
|
// We take the index of the first item that passed the previous checks.
|
||||||
index = parseFloat($this.data('index')) + visibleTop / height;
|
// It is the item that is first visible in the viewport.
|
||||||
|
if (indexFromViewPort === null) {
|
||||||
|
indexFromViewPort = parseFloat($this.data('index')) + visibleTop / height;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (visiblePost > 0) {
|
if (visiblePost > 0) {
|
||||||
@@ -236,7 +235,10 @@ export default class PostStream extends Component {
|
|||||||
if (time) period = time;
|
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;
|
this.stream.visible = visible;
|
||||||
if (period) this.stream.description = dayjs(period).format('MMMM YYYY');
|
if (period) this.stream.description = dayjs(period).format('MMMM YYYY');
|
||||||
}
|
}
|
||||||
@@ -288,7 +290,9 @@ export default class PostStream extends Component {
|
|||||||
* @return {Integer}
|
* @return {Integer}
|
||||||
*/
|
*/
|
||||||
getMarginTop() {
|
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);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -309,18 +313,17 @@ export default class PostStream extends Component {
|
|||||||
*
|
*
|
||||||
* @param {Integer} index
|
* @param {Integer} index
|
||||||
* @param {Boolean} animate
|
* @param {Boolean} animate
|
||||||
* @param {Boolean} bottom Whether or not to scroll to the bottom of the post
|
* @param {Boolean} reply Whether or not to scroll to the reply placeholder.
|
||||||
* at the given index, instead of the top of it.
|
|
||||||
* @return {jQuery.Deferred}
|
* @return {jQuery.Deferred}
|
||||||
*/
|
*/
|
||||||
scrollToIndex(index, animate, bottom) {
|
scrollToIndex(index, animate, reply) {
|
||||||
const $item = this.$(`.PostStream-item[data-index=${index}]`);
|
const $item = reply ? $('.PostStream-item:last-child') : this.$(`.PostStream-item[data-index=${index}]`);
|
||||||
|
|
||||||
return this.scrollToItem($item, animate, true, bottom).then(() => {
|
this.scrollToItem($item, animate, true, reply);
|
||||||
if (index == this.stream.count() - 1) {
|
|
||||||
this.flashItem(this.$('.PostStream-item:last-child'));
|
if (reply) {
|
||||||
}
|
this.flashItem($item);
|
||||||
});
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -330,12 +333,12 @@ export default class PostStream extends Component {
|
|||||||
* @param {Boolean} animate
|
* @param {Boolean} animate
|
||||||
* @param {Boolean} force Whether or not to force scrolling to the item, even
|
* @param {Boolean} force Whether or not to force scrolling to the item, even
|
||||||
* if it is already in the viewport.
|
* if it is already in the viewport.
|
||||||
* @param {Boolean} bottom Whether or not to scroll to the bottom of the post
|
* @param {Boolean} reply Whether or not to scroll to the reply placeholder.
|
||||||
* at the given index, instead of the top of it.
|
|
||||||
* @return {jQuery.Deferred}
|
* @return {jQuery.Deferred}
|
||||||
*/
|
*/
|
||||||
scrollToItem($item, animate, force, bottom) {
|
scrollToItem($item, animate, force, reply) {
|
||||||
const $container = $('html, body').stop(true);
|
const $container = $('html, body').stop(true);
|
||||||
|
const index = $item.data('index');
|
||||||
|
|
||||||
if ($item.length) {
|
if ($item.length) {
|
||||||
const itemTop = $item.offset().top - this.getMarginTop();
|
const itemTop = $item.offset().top - this.getMarginTop();
|
||||||
@@ -344,10 +347,10 @@ export default class PostStream extends Component {
|
|||||||
const scrollBottom = scrollTop + $(window).height();
|
const scrollBottom = scrollTop + $(window).height();
|
||||||
|
|
||||||
// If the item is already in the viewport, we may not need to scroll.
|
// 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.
|
// bottom will line up with the top of the composer.
|
||||||
if (force || itemTop < scrollTop || itemBottom > scrollBottom) {
|
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) {
|
if (!animate) {
|
||||||
$container.scrollTop(top);
|
$container.scrollTop(top);
|
||||||
@@ -357,12 +360,43 @@ export default class PostStream extends Component {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return Promise.all([$container.promise(), this.stream.loadPromise]).then(() => {
|
const updateScrubberHeight = () => {
|
||||||
|
// 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.updateScrubber();
|
||||||
const index = $item.data('index');
|
if (index !== undefined) this.stream.index = index + 1;
|
||||||
|
};
|
||||||
|
|
||||||
|
// If we don't update this before the scroll, the scrubber will start
|
||||||
|
// at the top, and animate down, which can be confusing
|
||||||
|
updateScrubberHeight();
|
||||||
|
this.stream.forceUpdateScrubber = true;
|
||||||
|
|
||||||
|
return Promise.all([$container.promise(), this.stream.loadPromise]).then(() => {
|
||||||
m.redraw.sync();
|
m.redraw.sync();
|
||||||
const scroll = index == 0 ? 0 : $(`.PostStream-item[data-index=${$item.data('index')}]`).offset().top - this.getMarginTop();
|
|
||||||
$(window).scrollTop(scroll);
|
// 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 ((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.calculatePosition();
|
||||||
this.stream.paused = false;
|
this.stream.paused = false;
|
||||||
});
|
});
|
||||||
@@ -374,6 +408,11 @@ export default class PostStream extends Component {
|
|||||||
* @param {jQuery} $item
|
* @param {jQuery} $item
|
||||||
*/
|
*/
|
||||||
flashItem($item) {
|
flashItem($item) {
|
||||||
$item.addClass('flash').one('animationend webkitAnimationEnd', () => $item.removeClass('flash'));
|
// 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) => {
|
||||||
|
$item.removeClass('flash');
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@@ -90,7 +90,10 @@ export default class PostStreamScrubber extends Component {
|
|||||||
}
|
}
|
||||||
|
|
||||||
onupdate() {
|
onupdate() {
|
||||||
this.stream.loadPromise.then(() => this.updateScrubberValues({ animate: true, forceHeightChange: true }));
|
if (this.stream.forceUpdateScrubber) {
|
||||||
|
this.stream.forceUpdateScrubber = false;
|
||||||
|
this.stream.loadPromise.then(() => this.updateScrubberValues({ animate: true, forceHeightChange: true }));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
oncreate(vnode) {
|
oncreate(vnode) {
|
||||||
@@ -137,7 +140,7 @@ export default class PostStreamScrubber extends Component {
|
|||||||
|
|
||||||
setTimeout(() => this.scrollListener.start());
|
setTimeout(() => this.scrollListener.start());
|
||||||
|
|
||||||
this.updateScrubberValues({ animate: true, forceHeightChange: true });
|
this.stream.loadPromise.then(() => this.updateScrubberValues({ animate: false, forceHeightChange: true }));
|
||||||
}
|
}
|
||||||
|
|
||||||
onremove() {
|
onremove() {
|
||||||
|
@@ -1,4 +1,5 @@
|
|||||||
import Component from '../../common/Component';
|
import Component from '../../common/Component';
|
||||||
|
import Link from '../../common/components/Link';
|
||||||
import UserCard from './UserCard';
|
import UserCard from './UserCard';
|
||||||
import avatar from '../../common/helpers/avatar';
|
import avatar from '../../common/helpers/avatar';
|
||||||
import username from '../../common/helpers/username';
|
import username from '../../common/helpers/username';
|
||||||
@@ -40,11 +41,11 @@ export default class PostUser extends Component {
|
|||||||
return (
|
return (
|
||||||
<div className="PostUser">
|
<div className="PostUser">
|
||||||
<h3>
|
<h3>
|
||||||
<a route={app.route.user(user)}>
|
<Link href={app.route.user(user)}>
|
||||||
{avatar(user, { className: 'PostUser-avatar' })}
|
{avatar(user, { className: 'PostUser-avatar' })}
|
||||||
{userOnline(user)}
|
{userOnline(user)}
|
||||||
{username(user)}
|
{username(user)}
|
||||||
</a>
|
</Link>
|
||||||
</h3>
|
</h3>
|
||||||
<ul className="PostUser-badges badges">{listItems(user.badges().toArray())}</ul>
|
<ul className="PostUser-badges badges">{listItems(user.badges().toArray())}</ul>
|
||||||
{card}
|
{card}
|
||||||
|
@@ -1,6 +1,7 @@
|
|||||||
import UserPage from './UserPage';
|
import UserPage from './UserPage';
|
||||||
import LoadingIndicator from '../../common/components/LoadingIndicator';
|
import LoadingIndicator from '../../common/components/LoadingIndicator';
|
||||||
import Button from '../../common/components/Button';
|
import Button from '../../common/components/Button';
|
||||||
|
import Link from '../../common/components/Link';
|
||||||
import Placeholder from '../../common/components/Placeholder';
|
import Placeholder from '../../common/components/Placeholder';
|
||||||
import CommentPost from './CommentPost';
|
import CommentPost from './CommentPost';
|
||||||
|
|
||||||
@@ -73,7 +74,7 @@ export default class PostsUserPage extends UserPage {
|
|||||||
<li>
|
<li>
|
||||||
<div className="PostsUserPage-discussion">
|
<div className="PostsUserPage-discussion">
|
||||||
{app.translator.trans('core.forum.user.in_discussion_text', {
|
{app.translator.trans('core.forum.user.in_discussion_text', {
|
||||||
discussion: <a route={app.route.post(post)}>{post.discussion().title()}</a>,
|
discussion: <Link href={app.route.post(post)}>{post.discussion().title()}</Link>,
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
@@ -1,5 +1,6 @@
|
|||||||
import Modal from '../../common/components/Modal';
|
import Modal from '../../common/components/Modal';
|
||||||
import Button from '../../common/components/Button';
|
import Button from '../../common/components/Button';
|
||||||
|
import Stream from '../../common/utils/Stream';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The 'RenameDiscussionModal' displays a modal dialog with an input to rename a discussion
|
* The 'RenameDiscussionModal' displays a modal dialog with an input to rename a discussion
|
||||||
@@ -10,7 +11,7 @@ export default class RenameDiscussionModal extends Modal {
|
|||||||
|
|
||||||
this.discussion = this.attrs.discussion;
|
this.discussion = this.attrs.discussion;
|
||||||
this.currentTitle = this.attrs.currentTitle;
|
this.currentTitle = this.attrs.currentTitle;
|
||||||
this.newTitle = m.stream(this.currentTitle);
|
this.newTitle = Stream(this.currentTitle);
|
||||||
}
|
}
|
||||||
|
|
||||||
className() {
|
className() {
|
||||||
|
@@ -1,5 +1,6 @@
|
|||||||
import ComposerBody from './ComposerBody';
|
import ComposerBody from './ComposerBody';
|
||||||
import Button from '../../common/components/Button';
|
import Button from '../../common/components/Button';
|
||||||
|
import Link from '../../common/components/Link';
|
||||||
import icon from '../../common/helpers/icon';
|
import icon from '../../common/helpers/icon';
|
||||||
import extractText from '../../common/utils/extractText';
|
import extractText from '../../common/utils/extractText';
|
||||||
|
|
||||||
@@ -36,9 +37,9 @@ export default class ReplyComposer extends ComposerBody {
|
|||||||
'title',
|
'title',
|
||||||
<h3>
|
<h3>
|
||||||
{icon('fas fa-reply')}{' '}
|
{icon('fas fa-reply')}{' '}
|
||||||
<a route={app.route.discussion(discussion)} onclick={minimizeComposerIfFullScreen}>
|
<Link href={app.route.discussion(discussion)} onclick={minimizeComposerIfFullScreen}>
|
||||||
{discussion.title()}
|
{discussion.title()}
|
||||||
</a>
|
</Link>
|
||||||
</h3>
|
</h3>
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -98,10 +99,13 @@ export default class ReplyComposer extends ComposerBody {
|
|||||||
},
|
},
|
||||||
app.translator.trans('core.forum.composer_reply.view_button')
|
app.translator.trans('core.forum.composer_reply.view_button')
|
||||||
);
|
);
|
||||||
alert = app.alerts.show(app.translator.trans('core.forum.composer_reply.posted_message'), {
|
alert = app.alerts.show(
|
||||||
type: 'success',
|
{
|
||||||
controls: [viewButton],
|
type: 'success',
|
||||||
});
|
controls: [viewButton],
|
||||||
|
},
|
||||||
|
app.translator.trans('core.forum.composer_reply.posted_message')
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
this.composer.hide();
|
this.composer.hide();
|
||||||
|
@@ -33,7 +33,7 @@ export default class ReplyPlaceholder extends Component {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const reply = () => {
|
const reply = () => {
|
||||||
DiscussionControls.replyAction.call(this.attrs.discussion, true);
|
DiscussionControls.replyAction.call(this.attrs.discussion, true).catch(() => {});
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
@@ -21,6 +21,8 @@ import UsersSearchSource from './UsersSearchSource';
|
|||||||
* - state: SearchState instance.
|
* - state: SearchState instance.
|
||||||
*/
|
*/
|
||||||
export default class Search extends Component {
|
export default class Search extends Component {
|
||||||
|
static MIN_SEARCH_LEN = 3;
|
||||||
|
|
||||||
oninit(vnode) {
|
oninit(vnode) {
|
||||||
super.oninit(vnode);
|
super.oninit(vnode);
|
||||||
this.state = this.attrs.state;
|
this.state = this.attrs.state;
|
||||||
@@ -152,7 +154,7 @@ export default class Search extends Component {
|
|||||||
search.searchTimeout = setTimeout(() => {
|
search.searchTimeout = setTimeout(() => {
|
||||||
if (state.isCached(query)) return;
|
if (state.isCached(query)) return;
|
||||||
|
|
||||||
if (query.length >= 3) {
|
if (query.length >= Search.MIN_SEARCH_LEN) {
|
||||||
search.sources.map((source) => {
|
search.sources.map((source) => {
|
||||||
if (!source.search) return;
|
if (!source.search) return;
|
||||||
|
|
||||||
|
@@ -4,6 +4,7 @@ import Button from '../../common/components/Button';
|
|||||||
import LogInButtons from './LogInButtons';
|
import LogInButtons from './LogInButtons';
|
||||||
import extractText from '../../common/utils/extractText';
|
import extractText from '../../common/utils/extractText';
|
||||||
import ItemList from '../../common/utils/ItemList';
|
import ItemList from '../../common/utils/ItemList';
|
||||||
|
import Stream from '../../common/utils/Stream';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The `SignUpModal` component displays a modal dialog with a singup form.
|
* The `SignUpModal` component displays a modal dialog with a singup form.
|
||||||
@@ -24,21 +25,21 @@ export default class SignUpModal extends Modal {
|
|||||||
*
|
*
|
||||||
* @type {Function}
|
* @type {Function}
|
||||||
*/
|
*/
|
||||||
this.username = m.stream(this.attrs.username || '');
|
this.username = Stream(this.attrs.username || '');
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The value of the email input.
|
* The value of the email input.
|
||||||
*
|
*
|
||||||
* @type {Function}
|
* @type {Function}
|
||||||
*/
|
*/
|
||||||
this.email = m.stream(this.attrs.email || '');
|
this.email = Stream(this.attrs.email || '');
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The value of the password input.
|
* The value of the password input.
|
||||||
*
|
*
|
||||||
* @type {Function}
|
* @type {Function}
|
||||||
*/
|
*/
|
||||||
this.password = m.stream(this.attrs.password || '');
|
this.password = Stream(this.attrs.password || '');
|
||||||
}
|
}
|
||||||
|
|
||||||
className() {
|
className() {
|
||||||
@@ -174,7 +175,7 @@ export default class SignUpModal extends Modal {
|
|||||||
* Get the data that should be submitted in the sign-up request.
|
* Get the data that should be submitted in the sign-up request.
|
||||||
*
|
*
|
||||||
* @return {Object}
|
* @return {Object}
|
||||||
* @public
|
* @protected
|
||||||
*/
|
*/
|
||||||
submitData() {
|
submitData() {
|
||||||
const data = {
|
const data = {
|
||||||
|
@@ -6,6 +6,7 @@ import avatar from '../../common/helpers/avatar';
|
|||||||
import username from '../../common/helpers/username';
|
import username from '../../common/helpers/username';
|
||||||
import icon from '../../common/helpers/icon';
|
import icon from '../../common/helpers/icon';
|
||||||
import Dropdown from '../../common/components/Dropdown';
|
import Dropdown from '../../common/components/Dropdown';
|
||||||
|
import Link from '../../common/components/Link';
|
||||||
import AvatarEditor from './AvatarEditor';
|
import AvatarEditor from './AvatarEditor';
|
||||||
import listItems from '../../common/helpers/listItems';
|
import listItems from '../../common/helpers/listItems';
|
||||||
|
|
||||||
@@ -50,10 +51,10 @@ export default class UserCard extends Component {
|
|||||||
{this.attrs.editable ? (
|
{this.attrs.editable ? (
|
||||||
[AvatarEditor.component({ user, className: 'UserCard-avatar' }), username(user)]
|
[AvatarEditor.component({ user, className: 'UserCard-avatar' }), username(user)]
|
||||||
) : (
|
) : (
|
||||||
<a route={app.route.user(user)}>
|
<Link href={app.route.user(user)}>
|
||||||
<div className="UserCard-avatar">{avatar(user)}</div>
|
<div className="UserCard-avatar">{avatar(user)}</div>
|
||||||
{username(user)}
|
{username(user)}
|
||||||
</a>
|
</Link>
|
||||||
)}
|
)}
|
||||||
</h2>
|
</h2>
|
||||||
|
|
||||||
|
@@ -27,17 +27,6 @@ export default class UserPage extends Page {
|
|||||||
this.user = null;
|
this.user = null;
|
||||||
|
|
||||||
this.bodyClass = 'App--user';
|
this.bodyClass = 'App--user';
|
||||||
|
|
||||||
this.prevUsername = m.route.param('username');
|
|
||||||
}
|
|
||||||
|
|
||||||
onbeforeupdate() {
|
|
||||||
const currUsername = m.route.param('username');
|
|
||||||
if (currUsername !== this.prevUsername) {
|
|
||||||
this.prevUsername = currUsername;
|
|
||||||
|
|
||||||
this.loadUser(currUsername);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
view() {
|
view() {
|
||||||
@@ -113,7 +102,7 @@ export default class UserPage extends Page {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (!this.user) {
|
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));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -146,7 +135,7 @@ export default class UserPage extends Page {
|
|||||||
|
|
||||||
items.add(
|
items.add(
|
||||||
'posts',
|
'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>
|
{app.translator.trans('core.forum.user.posts_link')} <span className="Button-badge">{user.commentCount()}</span>
|
||||||
</LinkButton>,
|
</LinkButton>,
|
||||||
100
|
100
|
||||||
@@ -154,7 +143,7 @@ export default class UserPage extends Page {
|
|||||||
|
|
||||||
items.add(
|
items.add(
|
||||||
'discussions',
|
'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>
|
{app.translator.trans('core.forum.user.discussions_link')} <span className="Button-badge">{user.discussionCount()}</span>
|
||||||
</LinkButton>,
|
</LinkButton>,
|
||||||
90
|
90
|
||||||
|
@@ -1,6 +1,7 @@
|
|||||||
import highlight from '../../common/helpers/highlight';
|
import highlight from '../../common/helpers/highlight';
|
||||||
import avatar from '../../common/helpers/avatar';
|
import avatar from '../../common/helpers/avatar';
|
||||||
import username from '../../common/helpers/username';
|
import username from '../../common/helpers/username';
|
||||||
|
import Link from '../../common/components/Link';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The `UsersSearchSource` finds and displays user search results in the search
|
* The `UsersSearchSource` finds and displays user search results in the search
|
||||||
@@ -48,10 +49,10 @@ export default class UsersSearchResults {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<li className="UserSearchResult" data-index={'users' + user.id()}>
|
<li className="UserSearchResult" data-index={'users' + user.id()}>
|
||||||
<a route={app.route.user(user)}>
|
<Link href={app.route.user(user)}>
|
||||||
{avatar(user)}
|
{avatar(user)}
|
||||||
{{ ...name, text: undefined, children }}
|
{{ ...name, text: undefined, children }}
|
||||||
</a>
|
</Link>
|
||||||
</li>
|
</li>
|
||||||
);
|
);
|
||||||
}),
|
}),
|
||||||
|
@@ -15,8 +15,9 @@ export { app };
|
|||||||
// export { IndexPage, DicsussionList } from './components';
|
// export { IndexPage, DicsussionList } from './components';
|
||||||
|
|
||||||
// Export compat 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, 'forum');
|
||||||
|
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user