1
0
mirror of https://github.com/flarum/core.git synced 2025-08-13 11:54:32 +02:00

Compare commits

..

69 Commits

Author SHA1 Message Date
Franz Liedke
fc5eddb99d Default parameter value 2020-08-02 09:25:36 +02:00
Franz Liedke
bad8115a4a Remove obsolete properties in post stream state 2020-08-02 08:01:55 +02:00
Franz Liedke
1fc76acf06 Stop injecting post stream state into scrubber 2020-08-02 07:56:58 +02:00
Franz Liedke
527d93120a Shrink the diff, e.g. by moving methods around 2020-08-01 02:59:55 +02:00
Franz Liedke
53582ab999 WIP: Re-work stream, scrubber and state
Mostly, this tries to move common logic up to the DiscussionPage as the
lowest common ancestor of these components sharing certain state.

It's still messy, though. :-/
2020-08-01 02:47:36 +02:00
Franz Liedke
6f6a09d7c4 Start decoupling scrolling from state 2020-07-31 23:23:32 +02:00
Franz Liedke
e8394e4a1d Unify pausing 2020-07-31 16:42:42 +02:00
Franz Liedke
e455e6c431 Restore old implementation of goToLast()
It's not clear whether this was intentionally omitted.
2020-07-31 16:13:16 +02:00
Franz Liedke
a044c642f6 Add default parameter value
This is actually relied on already by not passing the parameter in other
methods.
2020-07-31 16:12:17 +02:00
Franz Liedke
01384139ef Encapsulate a bit more logic in the state 2020-07-31 15:42:01 +02:00
Franz Liedke
57f5ad4893 Move method to previous position 2020-07-31 13:27:26 +02:00
Franz Liedke
8b69b24272 Fix docblocks 2020-07-31 13:21:45 +02:00
Franz Liedke
09c722e522 Remove unused prop 2020-07-31 12:14:00 +02:00
Franz Liedke
3ce94757fc Rename props 2020-07-31 12:13:25 +02:00
Franz Liedke
aae6f24356 Fix docblock 2020-07-31 12:06:33 +02:00
Franz Liedke
1a2f9527fd Revert formatting changes 2020-07-31 11:32:54 +02:00
Alexander Skvortsov
8c362bf7c7 Don't save index twice in post stream post-load 2020-07-31 11:18:12 +02:00
Alexander Skvortsov
f99f79e3c0 De-propify visible 2020-07-31 11:18:12 +02:00
Alexander Skvortsov
bbd8136695 A bit more cleanup 2020-07-31 11:17:46 +02:00
Alexander Skvortsov
1d8662088f A bit more cleanup and bugfixes 2020-07-31 11:17:45 +02:00
Alexander Skvortsov
a850f4a6fb Restore old scrubber index calculation system 2020-07-31 11:17:45 +02:00
Alexander Skvortsov
af55a13c61 A bit more cleanup, UI bugfixes 2020-07-31 11:17:45 +02:00
Alexander Skvortsov
92b62e7ab6 Minor cleanup of PostStreamState methods 2020-07-31 11:17:44 +02:00
Alexander Skvortsov
5ef4de75d1 Fix date not showing up properly 2020-07-31 11:17:44 +02:00
Alexander Skvortsov
88e6be9d0e When scrolling to first post, scroll all the way to top, simplify scrollToItem promise structure 2020-07-31 11:17:44 +02:00
Alexander Skvortsov
228c7b883d move index calculation back out of show 2020-07-31 11:17:44 +02:00
Alexander Skvortsov
cdcf64852e Restore scrubber behavior 2020-07-31 11:17:43 +02:00
Alexander Skvortsov
d20650fb42 Use date of the post in index 2020-07-31 11:17:43 +02:00
Alexander Skvortsov
875a1f70c1 Fix date, index calculation on reload 2020-07-31 11:17:42 +02:00
Alexander Skvortsov
ef206495cd Try calculating index before redraw to avoid calling redraw immediately after scroll 2020-07-31 11:17:42 +02:00
Alexander Skvortsov
2360745237 Fix jumping around on page reload 2020-07-31 11:17:42 +02:00
Alexander Skvortsov
cc10eaadd2 Get rid of separate system for scrollToLast 2020-07-31 11:17:42 +02:00
Alexander Skvortsov
c98c0b027f Fix missing method call 2020-07-31 11:17:41 +02:00
Alexander Skvortsov
73507f403a Don't use anchorScroll on goToNumber, instead scrolling directly to item 2020-07-31 11:17:41 +02:00
Alexander Skvortsov
d3fb5ee77c Handle scroll to end as a special case of scroll to index to ensure that we get completely to the bottom and flash the bottom element 2020-07-31 11:17:41 +02:00
Alexander Skvortsov
479e5a8cf6 in goToNumber, only redraw when the response has been returned. 2020-07-31 11:17:40 +02:00
Alexander Skvortsov
4bce030115 Use same logic as in updateScrubber to calculate current post number 2020-07-31 11:17:40 +02:00
Alexander Skvortsov
9f2540dbe3 Properly bind loadNext button to the state 2020-07-31 11:17:40 +02:00
Alexander Skvortsov
aa15db6f44 Ensure consistent index in scrubber, rework current post index calculation logic. 2020-07-31 11:17:39 +02:00
Alexander Skvortsov
0c63be527b Pass in a selector string to anchorScroll instead of a DOM element. Because the DOM element gets destroyed on redraw, it's offset height is interpreted as 0 which throws off our position in the stream. 2020-07-31 11:17:39 +02:00
Alexander Skvortsov
9db2f78939 Incorporate math floor in sanitizeIndex, use that for scrubber index display. 2020-07-31 11:17:39 +02:00
Alexander Skvortsov
9572863648 Add anchorScroll with redraw after loadPromise loads in scrollToItem 2020-07-31 11:17:39 +02:00
Alexander Skvortsov
ac1eef7578 Remove unused anchorScroll import 2020-07-31 11:17:38 +02:00
Alexander Skvortsov
514165c3af Anchor scroll after loading posts 2020-07-31 11:17:38 +02:00
Alexander Skvortsov
e84960dcd1 Cleanup 2020-07-31 11:17:38 +02:00
Alexander Skvortsov
f8d1c7a317 Add redraws after posts have been loaded from the API 2020-07-31 11:17:37 +02:00
Alexander Skvortsov
ba82969a58 Remove unnecessary redraw 2020-07-31 11:17:37 +02:00
Alexander Skvortsov
b2917c8716 Add more console logs 2020-07-31 11:17:12 +02:00
Alexander Skvortsov
c150c097c1 Add some more debugging flags 2020-07-31 11:17:11 +02:00
Alexander Skvortsov
beab8ce39c Update scrubber after scrollToItem 2020-07-31 11:17:11 +02:00
Alexander Skvortsov
1360723c3f Separate updateScrubber into separate method from onscroll 2020-07-31 11:17:11 +02:00
Alexander Skvortsov
5cdfeaf9a5 Move scrollPromise log into scrollToItem 2020-07-31 11:17:10 +02:00
Alexander Skvortsov
6e1d385268 Code cleanup, added a bunch of debug console logging 2020-07-31 11:17:10 +02:00
Alexander Skvortsov
193f3b040d Slightly improve scrubber label accuracy on click 2020-07-31 11:17:10 +02:00
Alexander Skvortsov
74cb4f9007 Get rid of post stream events. Initial load is still buggy 2020-07-31 11:17:09 +02:00
Alexander Skvortsov
eb24e628fa Get rid of js-PostStream 2020-07-31 11:17:09 +02:00
Alexander Skvortsov
c03feceb9f Fix goToLast 2020-07-31 11:17:09 +02:00
Alexander Skvortsov
51008bc65d Use scrollToIndex to contain scrollToLast 2020-07-31 11:17:09 +02:00
Alexander Skvortsov
9a357f5d19 Add scrubber height change transition css, don't apply when dragging 2020-07-31 11:17:08 +02:00
Alexander Skvortsov
9c63c54868 Simplify paused logic 2020-07-31 11:17:08 +02:00
Alexander Skvortsov
5427b35c6d Large simplifications of PostStreamScrubber 2020-07-31 11:17:08 +02:00
Franz Liedke
2ec49db6df Pass discussion as prop to stream components
- Law of demeter (no need to access discussion through the state)
- Less public API surface of the state object
2020-07-31 11:17:07 +02:00
Franz Liedke
062dc8f57f Don't call protected method outside of state
In addition, this again avoids writing a state property from
outside the state class.

I am not 100% sure whether this extra sanitization is necessary,
but it seems to be the only place where it is not applied when
changing the value of `visibleEnd` (and not safeguarded otherwise),
so I erred on the safe side.
2020-07-31 11:17:07 +02:00
Franz Liedke
8a9e50d192 Encapsulate viewingEnd() in state
...instead of calculating this derived value outside the state class.
2020-07-31 11:17:07 +02:00
Franz Liedke
6c087da65f Remove obsolete event handler
The event is not triggered anymore.
This is now handled through the `positionHandler` prop.
2020-07-31 11:17:06 +02:00
Franz Liedke
6bcecd623b Revert inlining method, rename the method instead 2020-07-31 11:17:06 +02:00
Alexander Skvortsov
614bb0d71e Moved refresh method of discussionpage into init, as its not used externally (and using it would be bad practice), fixing up PostStream 2020-07-31 11:17:06 +02:00
Alexander Skvortsov
cff9b327a9 Remove event from PostState, pass handler in via props, 2020-07-31 11:17:06 +02:00
Alexander Skvortsov
7af8e35a6e Extract PostStream state 2020-07-31 11:17:03 +02:00
296 changed files with 3659 additions and 7963 deletions

2
.gitattributes vendored
View File

@@ -11,5 +11,3 @@ phpunit.xml export-ignore
tests export-ignore
js/dist/* -diff
* text=auto eol=lf

View File

@@ -1,112 +1,5 @@
# Changelog
## [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 (#2407)
- Scripts from textformatter aren't executed (#2415)
- Sub path installations have no page title.
- Losing focus of Composer area when coming from fullscreen.
## [0.1.0-beta.14](https://github.com/flarum/core/compare/v0.1.0-beta.13...v0.1.0-beta.14)
### Added
- Check dependencies before enabling / disabling extensions (https://github.com/flarum/core/pull/2188)
- Set up temporary infrastructure for TypeScript in core (https://github.com/flarum/core/pull/2206)
- Better UI for request error modals (https://github.com/flarum/core/pull/1929)
- Display name extender, tests, frontend UI (https://github.com/flarum/core/pull/2174)
- Scroll to post or show alert when editing a post from another page (https://github.com/flarum/core/pull/2108)
- Feature to test email config by sending an email to the current user (https://github.com/flarum/core/pull/2023)
- Allow searching users by group ID using the group gambit (https://github.com/flarum/core/pull/2192)
- Use `liveHumanTimes` helper to update times without reload/rerender (https://github.com/flarum/core/pull/2208)
- View extender, tests (https://github.com/flarum/core/pull/2134)
- User extender to replace `PrepareUserGroups` (https://github.com/flarum/core/pull/2110)
- Increase extensibility of skeleton PHP (https://github.com/flarum/core/pull/2308, https://github.com/flarum/core/pull/2318)
- Pass a translator instance to `getEmailSubject` in `MailableInterface` (https://github.com/flarum/core/pull/2244)
- Force LF line endings on windows (https://github.com/flarum/core/pull/2321)
- Add a `Link` component for internal and external links (https://github.com/flarum/core/pull/2315)
- `ConfirmDocumentUnload` component
- Error handler middleware can now be manipulated by the middleware extender
### Changed
- Update to Mithril 2 (https://github.com/flarum/core/pull/2255)
- Stop storing component instances (https://github.com/flarum/core/issues/1821, https://github.com/flarum/core/issues/2144)
- Update to Laravel 6.x (https://github.com/flarum/core/issues/2055)
- `Flarum\Foundation\Application` no longer implements `Illuminate\Contracts\Foundation\Application` (#2142)
- `Flarum\Foundation\Application` no longer inherits `Illuminate\Container\Container` (#2142)
- `paths` have been split off from `Flarum\Foundation\Application` into `Flarum\Foundation\Paths`, which can be injected where needed (#2142)
- `Flarum\User\Gate` no longer implements `Illuminate\Contracts\Auth\Access\Gate` (https://github.com/flarum/core/pull/2181)
- Improve Group Gambit performance (https://github.com/flarum/core/pull/2192)
- Switch to `dayjs` from `momentjs` (https://github.com/flarum/core/pull/2219)
- Don't create a `bio` column in `users` for new installations (https://github.com/flarum/core/pull/2215)
- Start converting core JS to TypeScript (https://github.com/flarum/core/pull/2207)
- Make Carbon an explicit dependency (https://github.com/flarum/core/commit/3b39c212e0fef7522e7d541a9214ff3817138d5d)
- Use Symfony's translator interface instead of Laravel's (https://github.com/flarum/core/pull/2243)
- Use newer versions of fontawesome (https://github.com/flarum/core/pull/2274)
- Use URL generator instead of `app()->url()` where possible (https://github.com/flarum/core/pull/2302)
- Move config from `config.php` into an injectable helper class (https://github.com/flarum/core/pull/2271)
- Use reserved TLD for bogus and test urls (https://github.com/flarum/core/commit/6860b24b70bd04544dde90e537ce021a5fc5a689)
- Replace `m.stream` with `flarum/utils/Stream` (https://github.com/flarum/core/pull/2316)
- Replace `affixedSidebar` util with `AffixedSidebar` component
- Replace `m.withAttr` with `flarum/utils/withAttr`
- Scroll Listener is now passive, performance improvement (https://github.com/flarum/core/pull/2387)
### Fixed
- `generate:migration` command for extensions (https://github.com/flarum/core/commit/443949f7b9d7558dbc1e0994cb898cbac59bec87)
- Container config for `UninstalledSite` (https://github.com/flarum/core/commit/ecdce44d555dd36a365fd472b2916e677ef173cf)
- Tooltip glitch on page chang (https://github.com/flarum/core/issues/2118)
- Using multiple extenders in tests (https://github.com/flarum/core/commit/c4f4f218bf4b175a30880b807f9ccb1a37a25330)
- Header glitch when opening modals (https://github.com/flarum/core/pull/2131)
- Ensure `SameSite` is explicitly set for cookies (https://github.com/flarum/core/pull/2159)
- Ensure `Flarum\User\Event\AvatarChanged` event is properly dispatched (https://github.com/flarum/core/pull/2197)
- Show correct error message on wrong password when changing email (https://github.com/flarum/core/pull/2171)
- Discussion unreadCount could be higher than commentCount if posts deleted (https://github.com/flarum/core/pull/2195)
- Don't show page title on the default route (https://github.com/flarum/core/pull/2047)
- Add page title to `All Discussions` page when it isn't the default route (https://github.com/flarum/core/pull/2047)
- Accept `'0'` as `false` for `flarum/components/Checkbox` (https://github.com/flarum/core/pull/2210)
- Fix PostStreamScrubber background (https://github.com/flarum/core/pull/2222)
- Test port on BaseUrl tests (https://github.com/flarum/core/pull/2226)
- `UrlGenerator` can now generate urls with optional parameters (https://github.com/flarum/core/pull/2246)
- Allow `less` to be compiled independently of Flarum (https://github.com/flarum/core/pull/2252)
- Use correct number abbreviation (https://github.com/flarum/core/pull/2261)
- Ensure avatar html uses alt tags for accessibility (https://github.com/flarum/core/pull/2269)
- Escape regex when searching (https://github.com/flarum/core/pull/2273)
- Remove unneeded semicolons inserted during JS compilation (https://github.com/flarum/core/pull/2280)
- Don't require a username/password for SMTP (https://github.com/flarum/core/pull/2287)
- Allow uppercase entries for SMTP encryption validation (https://github.com/flarum/core/pull/2289)
- Ensure that the right number of posts is returned from list posts API (https://github.com/flarum/core/pull/2291)
- Fix a variety of PostStream bugs (https://github.com/flarum/core/pull/2160, https://github.com/flarum/core/pull/2160)
- Sliding discussion glitch on mobile (https://github.com/flarum/core/pull/2324)
- Sliding discussion button in wrong place (https://github.com/flarum/core/pull/2330, https://github.com/flarum/core/pull/2383)
- Sliding discussion glitch on mobile (https://github.com/flarum/core/pull/2381)
- Fix PostStream for posts with top margins, and scrubber position when scrolling below posts (https://github.com/flarum/core/pull/2369)
### Removed
- `Flarum\Event\AbstractConfigureRoutes` event class
- `Flarum\Event\ConfigureApiRoutes` event class
- `Flarum\Event\ConfigureForumRoutes` event class
- `Flarum\Console\Event\Configuring` event class
- `Flarum\Event\ConfigureModelDates` event class
- `Flarum\Event\ConfigureLocales` event class
- `Flarum\Event\ConfigureModelDefaultAttributes` event class
- `Flarum\Event\GetModelRelationship` event class
- `Flarum\User\Event\BioChanged` event class
- `Flarum\Database\MigrationServiceProvider` moved into `Flarum\Database\DatabaseServiceProvider`
- Unused `admin/components/Widget` component (`admin/component/DashboardWidget` should be used instead)
- Mandrill mail driver (https://github.com/flarum/core/commit/bca833d3f1c34d45d95bf905902368a2753b8908)
### Deprecated
- `Flarum\User\Event\GetDisplayName` event class
- Global path helpers, `Flarum\Foundation\Application` path methods (https://github.com/flarum/core/pull/2155)
- `Flarum\User\AssertPermissionTrait` (https://github.com/flarum/core/pull/2044)
## [0.1.0-beta.13](https://github.com/flarum/core/compare/v0.1.0-beta.12...v0.1.0-beta.13)
### Added

View File

@@ -1,14 +1,12 @@
<p align="center"><img src="https://flarum.org/assets/img/logo.png"></p>
<p align="center"><img src="https://flarum.org/img/logo.png"></p>
<p align="center">
<a href="https://github.com/flarum/core/actions?query=workflow%3ATests"><img src="https://github.com/flarum/core/workflows/Tests/badge.svg" alt="PHP Tests"></a>
<a href="https://packagist.org/packages/flarum/core"><img src="https://img.shields.io/packagist/dt/flarum/core" alt="Total Downloads"></a>
<a href="https://packagist.org/packages/flarum/core"><img src="https://img.shields.io/github/v/release/flarum/core?sort=semver" alt="Latest Version"></a>
<a href="https://packagist.org/packages/flarum/core"><img src="https://img.shields.io/packagist/l/flarum/core" alt="License"></a>
<a href="https://github.styleci.io/repos/28257573"><img src="https://github.styleci.io/repos/28257573/shield?style=flat" alt="StyleCI"></a>
<a href="https://travis-ci.org/flarum/core"><img src="https://travis-ci.org/flarum/core.svg" alt="Build Status"></a>
<a href="https://packagist.org/packages/flarum/core"><img src="https://poser.pugx.org/flarum/core/d/total.svg" alt="Total Downloads"></a>
<a href="https://packagist.org/packages/flarum/core"><img src="https://poser.pugx.org/flarum/core/v/stable.svg" alt="Latest Stable Version"></a>
<a href="https://packagist.org/packages/flarum/core"><img src="https://poser.pugx.org/flarum/core/license.svg" alt="License"></a>
</p>
## About Flarum
**[Flarum](https://flarum.org/) is a delightfully simple discussion platform for your website.** It's fast and easy to use, with all the features you need to run a successful community. It is designed to be:
@@ -34,3 +32,4 @@ If you discover a security vulnerability within Flarum, please send an e-mail to
## License
Flarum is open-source software licensed under the [MIT License](https://github.com/flarum/flarum/blob/master/LICENSE).

View File

@@ -10,7 +10,7 @@
"email": "franz@develophp.org"
},
{
"name": "Daniël Klabbers",
"name": "Daniel Klabbers",
"email": "daniel@klabbers.email",
"homepage": "https://luceos.com"
},
@@ -27,10 +27,6 @@
{
"name": "Matthew Kilgore",
"email": "matthew@kilgore.dev"
},
{
"name": "Alexander (Sasha) Skvortsov",
"email": "askvortsov@flarum.org"
}
],
"support": {
@@ -41,7 +37,7 @@
"require": {
"php": ">=7.2",
"axy/sourcemap": "^0.1.4",
"components/font-awesome": "^5.14.0",
"components/font-awesome": "5.9.*",
"dflydev/fig-cookies": "^2.0.1",
"doctrine/dbal": "^2.7",
"franzl/whoops-middleware": "^0.4.0",
@@ -76,11 +72,11 @@
"psr/http-server-handler": "^1.0",
"psr/http-server-middleware": "^1.0",
"s9e/text-formatter": "^2.3.6",
"symfony/config": "^4.3.4",
"symfony/console": "^4.3.4",
"symfony/event-dispatcher": "^4.3.4",
"symfony/translation": "^4.3.4",
"symfony/yaml": "^4.3.4",
"symfony/config": "^3.3",
"symfony/console": "^4.2",
"symfony/event-dispatcher": "^4.3.2",
"symfony/translation": "^3.3",
"symfony/yaml": "^3.3",
"tobscure/json-api": "^0.3.0",
"wikimedia/less.php": "^3.0"
},

14
js/dist/admin.js vendored

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

16
js/dist/forum.js vendored

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

38
js/package-lock.json generated
View File

@@ -1075,11 +1075,6 @@
"integrity": "sha512-rr+OQyAjxze7GgWrSaJwydHStIhHq2lvY3BOC2Mj7KnzI7XK0Uw1TOOdI9lDoajEbSWLiYgoo4f1R51erQfhPQ==",
"dev": true
},
"@types/mithril": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/@types/mithril/-/mithril-2.0.3.tgz",
"integrity": "sha512-cZHOdO2IiXYeyjeDYdbOisSdfaJRzfmRo3zVzgu33IWTMA0KEQObp9fdvqcuYdPz93iJ1yCl19GcEjo/9yv+yA=="
},
"@types/parse-json": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/@types/parse-json/-/parse-json-4.0.0.tgz",
@@ -3556,9 +3551,9 @@
"integrity": "sha1-TkMekrEalzFjaqH5yNHMvP2reN8="
},
"jquery": {
"version": "3.5.1",
"resolved": "https://registry.npmjs.org/jquery/-/jquery-3.5.1.tgz",
"integrity": "sha512-XwIBPqcMn57FxfT+Go5pzySnm4KWkT1Tv7gjrpT1srtf8Weynl6R273VJ5GjkRb51IzMp5nbaPjJXMWeju2MKg=="
"version": "3.4.1",
"resolved": "https://registry.npmjs.org/jquery/-/jquery-3.4.1.tgz",
"integrity": "sha512-36+AdBzCL+y6qjw5Tx7HgzeGCzC81MDDgaUP8ld2zhx58HdqXGoBd+tHdrBMiyjGQs0Hxs/MLZTu/eHNJJuWPw=="
},
"jquery.hotkeys": {
"version": "0.1.0",
@@ -3812,9 +3807,9 @@
}
},
"mithril": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/mithril/-/mithril-2.0.4.tgz",
"integrity": "sha512-mgw+DMZlhMS4PpprF6dl7ZoeZq5GGcAuWnrg5e12MvaGauc4jzWsDZtVGRCktsiQczOEUr2K5teKbE5k44RlOg=="
"version": "0.2.8",
"resolved": "https://registry.npmjs.org/mithril/-/mithril-0.2.8.tgz",
"integrity": "sha512-9XuGnVmS2OyFexUuP/CcJFFJjHLM+RGYBxyVRNyQ6khbMfDJIF/xyZ4zq18ZRfPagpFmWUFpjHd5ZqPULGZyNg=="
},
"mixin-deep": {
"version": "1.3.2",
@@ -4546,6 +4541,11 @@
"integrity": "sha512-mUdIBBvdn0PLOeP3TEkMH7HHeUP3GjsXCwKarjv/kGmUFOYg1VqEemKhoQpWMu6X2I8kHeuVdGibLGkVK+/5Qw==",
"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": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz",
@@ -4895,29 +4895,21 @@
}
},
"terser-webpack-plugin": {
"version": "1.4.5",
"resolved": "https://registry.npmjs.org/terser-webpack-plugin/-/terser-webpack-plugin-1.4.5.tgz",
"integrity": "sha512-04Rfe496lN8EYruwi6oPQkG0vo8C+HT49X687FZnpPF0qMAIHONI6HEXYPKDOE8e5HjXTyKfqRd/agHtH0kOtw==",
"version": "1.4.3",
"resolved": "https://registry.npmjs.org/terser-webpack-plugin/-/terser-webpack-plugin-1.4.3.tgz",
"integrity": "sha512-QMxecFz/gHQwteWwSo5nTc6UaICqN1bMedC5sMtUc7y3Ha3Q8y6ZO0iCR8pq4RJC8Hjf0FEPEHZqcMB/+DFCrA==",
"requires": {
"cacache": "^12.0.2",
"find-cache-dir": "^2.1.0",
"is-wsl": "^1.1.0",
"schema-utils": "^1.0.0",
"serialize-javascript": "^4.0.0",
"serialize-javascript": "^2.1.2",
"source-map": "^0.6.1",
"terser": "^4.1.2",
"webpack-sources": "^1.4.0",
"worker-farm": "^1.7.0"
},
"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": {
"version": "0.6.1",
"resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz",

View File

@@ -3,18 +3,17 @@
"name": "@flarum/core",
"dependencies": {
"@babel/preset-typescript": "^7.10.1",
"@types/mithril": "^2.0.3",
"bootstrap": "^3.4.1",
"classnames": "^2.2.5",
"color-thief-browser": "^2.0.2",
"dayjs": "^1.8.28",
"expose-loader": "^0.7.5",
"flarum-webpack-config": "0.1.0-beta.10",
"jquery": "^3.5.1",
"jquery": "^3.4.1",
"jquery.hotkeys": "^0.1.0",
"lodash-es": "^4.17.14",
"m.attrs.bidi": "github:tobscure/m.attrs.bidi",
"mithril": "^2.0.4",
"mithril": "^0.2.8",
"punycode": "^2.1.1",
"spin.js": "^3.1.0",
"webpack": "^4.43.0",

32
js/shims.d.ts vendored
View File

@@ -1,32 +0,0 @@
// Mithril
import Mithril from 'mithril';
// Other third-party libs
import * as _dayjs from 'dayjs';
import * as _$ from 'jquery';
// Globals from flarum/core
import Application from './src/common/Application';
/**
* flarum/core exposes several extensions globally:
*
* - jQuery for convenient DOM manipulation
* - Mithril for VDOM and components
* - dayjs for date/time operations
*
* Since these are already part of the global namespace, extensions won't need
* to (and should not) bundle these themselves.
*/
declare global {
const $: typeof _$;
const m: Mithril.Static;
const dayjs: typeof _dayjs;
}
/**
* All global variables owned by flarum/core.
*/
declare global {
const app: Application;
}

View File

@@ -1,29 +1,13 @@
import HeaderPrimary from './components/HeaderPrimary';
import HeaderSecondary from './components/HeaderSecondary';
import routes from './routes';
import ExtensionPage from './components/ExtensionPage';
import Application from '../common/Application';
import Navigation from '../common/components/Navigation';
import AdminNav from './components/AdminNav';
import ExtensionData from './utils/ExtensionData';
export default class AdminApplication extends Application {
// Deprecated as of beta 15
extensionSettings = {};
extensionData = new ExtensionData();
extensionCategories = {
discussion: 70,
moderation: 60,
feature: 50,
formatting: 40,
theme: 30,
authentication: 20,
language: 10,
other: 0,
};
history = {
canGoBack: () => true,
getPrevious: () => {},
@@ -43,29 +27,19 @@ export default class AdminApplication extends Application {
* @inheritdoc
*/
mount() {
// Mithril does not render the home route on https://example.com/admin, so
// we need to go to https://example.com/admin#/ explicitly.
if (!document.location.hash) document.location.hash = '#/';
m.mount(document.getElementById('app-navigation'), Navigation.component({ className: 'App-backControl', drawer: true }));
m.mount(document.getElementById('header-navigation'), Navigation.component());
m.mount(document.getElementById('header-primary'), HeaderPrimary.component());
m.mount(document.getElementById('header-secondary'), HeaderSecondary.component());
m.mount(document.getElementById('admin-navigation'), AdminNav.component());
m.route.prefix = '#';
m.route.mode = 'hash';
super.mount();
m.mount(document.getElementById('app-navigation'), {
view: () =>
Navigation.component({
className: 'App-backControl',
drawer: true,
}),
});
m.mount(document.getElementById('header-navigation'), Navigation);
m.mount(document.getElementById('header-primary'), HeaderPrimary);
m.mount(document.getElementById('header-secondary'), HeaderSecondary);
m.mount(document.getElementById('admin-navigation'), AdminNav);
// If an extension has just been enabled, then we will run its settings
// callback.
const enabled = localStorage.getItem('enabledExtension');
if (enabled && this.extensionSettings[enabled] && typeof this.extensionSettings[enabled] === 'function') {
if (enabled && this.extensionSettings[enabled]) {
this.extensionSettings[enabled]();
localStorage.removeItem('enabledExtension');
}

View File

@@ -1,21 +1,17 @@
import compat from '../common/compat';
import saveSettings from './utils/saveSettings';
import ExtensionData from './utils/ExtensionData';
import isExtensionEnabled from './utils/isExtensionEnabled';
import getCategorizedExtensions from './utils/getCategorizedExtensions';
import SettingDropdown from './components/SettingDropdown';
import EditCustomFooterModal from './components/EditCustomFooterModal';
import SessionDropdown from './components/SessionDropdown';
import HeaderPrimary from './components/HeaderPrimary';
import AppearancePage from './components/AppearancePage';
import StatusWidget from './components/StatusWidget';
import ExtensionsWidget from './components/ExtensionsWidget';
import HeaderSecondary from './components/HeaderSecondary';
import SettingsModal from './components/SettingsModal';
import DashboardWidget from './components/DashboardWidget';
import ExtensionPage from './components/ExtensionPage';
import ExtensionLinkButton from './components/ExtensionLinkButton';
import AddExtensionModal from './components/AddExtensionModal';
import ExtensionsPage from './components/ExtensionsPage';
import AdminLinkButton from './components/AdminLinkButton';
import PermissionGrid from './components/PermissionGrid';
import MailPage from './components/MailPage';
@@ -27,7 +23,6 @@ import EditCustomHeaderModal from './components/EditCustomHeaderModal';
import PermissionsPage from './components/PermissionsPage';
import PermissionDropdown from './components/PermissionDropdown';
import AdminNav from './components/AdminNav';
import AdminHeader from './components/AdminHeader';
import EditCustomCssModal from './components/EditCustomCssModal';
import EditGroupModal from './components/EditGroupModal';
import routes from './routes';
@@ -35,21 +30,17 @@ import AdminApplication from './AdminApplication';
export default Object.assign(compat, {
'utils/saveSettings': saveSettings,
'utils/ExtensionData': ExtensionData,
'utils/isExtensionEnabled': isExtensionEnabled,
'utils/getCategorizedExtensions': getCategorizedExtensions,
'components/SettingDropdown': SettingDropdown,
'components/EditCustomFooterModal': EditCustomFooterModal,
'components/SessionDropdown': SessionDropdown,
'components/HeaderPrimary': HeaderPrimary,
'components/AppearancePage': AppearancePage,
'components/StatusWidget': StatusWidget,
'components/ExtensionsWidget': ExtensionsWidget,
'components/HeaderSecondary': HeaderSecondary,
'components/SettingsModal': SettingsModal,
'components/DashboardWidget': DashboardWidget,
'components/ExtensionPage': ExtensionPage,
'components/ExtensionLinkButton': ExtensionLinkButton,
'components/AddExtensionModal': AddExtensionModal,
'components/ExtensionsPage': ExtensionsPage,
'components/AdminLinkButton': AdminLinkButton,
'components/PermissionGrid': PermissionGrid,
'components/MailPage': MailPage,
@@ -61,7 +52,6 @@ export default Object.assign(compat, {
'components/PermissionsPage': PermissionsPage,
'components/PermissionDropdown': PermissionDropdown,
'components/AdminNav': AdminNav,
'components/AdminHeader': AdminHeader,
'components/EditCustomCssModal': EditCustomCssModal,
'components/EditGroupModal': EditGroupModal,
routes: routes,

View File

@@ -1,19 +0,0 @@
import Component from '../../common/Component';
import classList from '../../common/utils/classList';
import icon from '../../common/helpers/icon';
export default class AdminHeader extends Component {
view(vnode) {
return [
<div className={classList(['AdminHeader', this.attrs.className])}>
<div className="container">
<h2>
{icon(this.attrs.icon)}
{vnode.children}
</h2>
<div className="AdminHeader-description">{this.attrs.description}</div>
</div>
</div>,
];
}
}

View File

@@ -10,7 +10,11 @@
import LinkButton from '../../common/components/LinkButton';
export default class AdminLinkButton extends LinkButton {
getButtonContent(children) {
return [...super.getButtonContent(children), <div className="AdminLinkButton-description">{this.attrs.description}</div>];
getButtonContent() {
const content = super.getButtonContent();
content.push(<div className="AdminLinkButton-description">{this.props.description}</div>);
return content;
}
}

View File

@@ -1,28 +1,28 @@
import ExtensionLinkButton from './ExtensionLinkButton';
/*
* This file is part of Flarum.
*
* (c) Toby Zerner <toby.zerner@gmail.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
import Component from '../../common/Component';
import LinkButton from '../../common/components/LinkButton';
import AdminLinkButton from './AdminLinkButton';
import SelectDropdown from '../../common/components/SelectDropdown';
import getCategorizedExtensions from '../utils/getCategorizedExtensions';
import ItemList from '../../common/utils/ItemList';
import Stream from '../../common/utils/Stream';
export default class AdminNav extends Component {
oninit(vnode) {
super.oninit(vnode);
this.query = Stream('');
}
view() {
return (
<SelectDropdown className="AdminNav App-titleControl AdminNav-Main" buttonClassName="Button">
{this.items().toArray().concat(this.extensionItems().toArray())}
<SelectDropdown className="AdminNav App-titleControl" buttonClassName="Button">
{this.items().toArray()}
</SelectDropdown>
);
}
/**
* Build an item list of main links to show in the admin navigation.
* Build an item list of links to show in the admin navigation.
*
* @return {ItemList}
*/
@@ -31,90 +31,64 @@ export default class AdminNav extends Component {
items.add(
'dashboard',
<LinkButton href={app.route('dashboard')} icon="far fa-chart-bar" title={app.translator.trans('core.admin.nav.dashboard_title')}>
{app.translator.trans('core.admin.nav.dashboard_button')}
</LinkButton>
AdminLinkButton.component({
href: app.route('dashboard'),
icon: 'far fa-chart-bar',
children: app.translator.trans('core.admin.nav.dashboard_button'),
description: app.translator.trans('core.admin.nav.dashboard_text'),
})
);
items.add(
'basics',
<LinkButton href={app.route('basics')} icon="fas fa-pencil-alt" title={app.translator.trans('core.admin.nav.basics_title')}>
{app.translator.trans('core.admin.nav.basics_button')}
</LinkButton>
AdminLinkButton.component({
href: app.route('basics'),
icon: 'fas fa-pencil-alt',
children: app.translator.trans('core.admin.nav.basics_button'),
description: app.translator.trans('core.admin.nav.basics_text'),
})
);
items.add(
'mail',
<LinkButton href={app.route('mail')} icon="fas fa-envelope" title={app.translator.trans('core.admin.nav.email_title')}>
{app.translator.trans('core.admin.nav.email_button')}
</LinkButton>
AdminLinkButton.component({
href: app.route('mail'),
icon: 'fas fa-envelope',
children: app.translator.trans('core.admin.nav.email_button'),
description: app.translator.trans('core.admin.nav.email_text'),
})
);
items.add(
'permissions',
<LinkButton href={app.route('permissions')} icon="fas fa-key" title={app.translator.trans('core.admin.nav.permissions_title')}>
{app.translator.trans('core.admin.nav.permissions_button')}
</LinkButton>
AdminLinkButton.component({
href: app.route('permissions'),
icon: 'fas fa-key',
children: app.translator.trans('core.admin.nav.permissions_button'),
description: app.translator.trans('core.admin.nav.permissions_text'),
})
);
items.add(
'appearance',
<LinkButton href={app.route('appearance')} icon="fas fa-paint-brush" title={app.translator.trans('core.admin.nav.appearance_title')}>
{app.translator.trans('core.admin.nav.appearance_button')}
</LinkButton>
AdminLinkButton.component({
href: app.route('appearance'),
icon: 'fas fa-paint-brush',
children: app.translator.trans('core.admin.nav.appearance_button'),
description: app.translator.trans('core.admin.nav.appearance_text'),
})
);
items.add(
'search',
<div className="Search-input">
<input
className="FormControl SearchBar"
bidi={this.query}
type="search"
placeholder={app.translator.trans('core.admin.nav.search_placeholder')}
/>
</div>
'extensions',
AdminLinkButton.component({
href: app.route('extensions'),
icon: 'fas fa-puzzle-piece',
children: app.translator.trans('core.admin.nav.extensions_button'),
description: app.translator.trans('core.admin.nav.extensions_text'),
})
);
return items;
}
extensionItems() {
const items = new ItemList();
const categorizedExtensions = getCategorizedExtensions();
const categories = app.extensionCategories;
Object.keys(categorizedExtensions).map((category) => {
if (!this.query()) {
items.add(
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.id,
<ExtensionLinkButton
href={app.route('extension', { id: extension.id })}
extensionId={extension.id}
className="ExtensionNavButton"
title={extension.description}
>
{title}
</ExtensionLinkButton>,
categories[category]
);
}
});
});
return items;
}
}

View File

@@ -1,34 +1,25 @@
import Page from '../../common/components/Page';
import Button from '../../common/components/Button';
import Switch from '../../common/components/Switch';
import Stream from '../../common/utils/Stream';
import EditCustomCssModal from './EditCustomCssModal';
import EditCustomHeaderModal from './EditCustomHeaderModal';
import EditCustomFooterModal from './EditCustomFooterModal';
import UploadImageButton from './UploadImageButton';
import saveSettings from '../utils/saveSettings';
import AdminHeader from './AdminHeader';
export default class AppearancePage extends Page {
oninit(vnode) {
super.oninit(vnode);
init() {
super.init();
this.primaryColor = Stream(app.data.settings.theme_primary_color);
this.secondaryColor = Stream(app.data.settings.theme_secondary_color);
this.darkMode = Stream(app.data.settings.theme_dark_mode);
this.coloredHeader = Stream(app.data.settings.theme_colored_header);
this.primaryColor = m.prop(app.data.settings.theme_primary_color);
this.secondaryColor = m.prop(app.data.settings.theme_secondary_color);
this.darkMode = m.prop(app.data.settings.theme_dark_mode);
this.coloredHeader = m.prop(app.data.settings.theme_colored_header);
}
view() {
return (
<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">
<form onsubmit={this.onsubmit.bind(this)}>
<fieldset className="AppearancePage-colors">
@@ -36,34 +27,40 @@ export default class AppearancePage extends Page {
<div className="helpText">{app.translator.trans('core.admin.appearance.colors_text')}</div>
<div className="AppearancePage-colors-input">
<input className="FormControl" type="text" placeholder="#aaaaaa" bidi={this.primaryColor} />
<input className="FormControl" type="text" placeholder="#aaaaaa" bidi={this.secondaryColor} />
<input
className="FormControl"
type="text"
placeholder="#aaaaaa"
value={this.primaryColor()}
onchange={m.withAttr('value', this.primaryColor)}
/>
<input
className="FormControl"
type="text"
placeholder="#aaaaaa"
value={this.secondaryColor()}
onchange={m.withAttr('value', this.secondaryColor)}
/>
</div>
{Switch.component(
{
state: this.darkMode(),
onchange: this.darkMode,
},
app.translator.trans('core.admin.appearance.dark_mode_label')
)}
{Switch.component({
state: this.darkMode(),
children: app.translator.trans('core.admin.appearance.dark_mode_label'),
onchange: this.darkMode,
})}
{Switch.component(
{
state: this.coloredHeader(),
onchange: this.coloredHeader,
},
app.translator.trans('core.admin.appearance.colored_header_label')
)}
{Switch.component({
state: this.coloredHeader(),
children: app.translator.trans('core.admin.appearance.colored_header_label'),
onchange: this.coloredHeader,
})}
{Button.component(
{
className: 'Button Button--primary',
type: 'submit',
loading: this.loading,
},
app.translator.trans('core.admin.appearance.submit_button')
)}
{Button.component({
className: 'Button Button--primary',
type: 'submit',
children: app.translator.trans('core.admin.appearance.submit_button'),
loading: this.loading,
})}
</fieldset>
</form>
@@ -82,37 +79,31 @@ export default class AppearancePage extends Page {
<fieldset>
<legend>{app.translator.trans('core.admin.appearance.custom_header_heading')}</legend>
<div className="helpText">{app.translator.trans('core.admin.appearance.custom_header_text')}</div>
{Button.component(
{
className: 'Button',
onclick: () => app.modal.show(EditCustomHeaderModal),
},
app.translator.trans('core.admin.appearance.edit_header_button')
)}
{Button.component({
className: 'Button',
children: app.translator.trans('core.admin.appearance.edit_header_button'),
onclick: () => app.modal.show(EditCustomHeaderModal),
})}
</fieldset>
<fieldset>
<legend>{app.translator.trans('core.admin.appearance.custom_footer_heading')}</legend>
<div className="helpText">{app.translator.trans('core.admin.appearance.custom_footer_text')}</div>
{Button.component(
{
className: 'Button',
onclick: () => app.modal.show(EditCustomFooterModal),
},
app.translator.trans('core.admin.appearance.edit_footer_button')
)}
{Button.component({
className: 'Button',
children: app.translator.trans('core.admin.appearance.edit_footer_button'),
onclick: () => app.modal.show(EditCustomFooterModal),
})}
</fieldset>
<fieldset>
<legend>{app.translator.trans('core.admin.appearance.custom_styles_heading')}</legend>
<div className="helpText">{app.translator.trans('core.admin.appearance.custom_styles_text')}</div>
{Button.component(
{
className: 'Button',
onclick: () => app.modal.show(EditCustomCssModal),
},
app.translator.trans('core.admin.appearance.edit_css_button')
)}
{Button.component({
className: 'Button',
children: app.translator.trans('core.admin.appearance.edit_css_button'),
onclick: () => app.modal.show(EditCustomCssModal),
})}
</fieldset>
</div>
</div>

View File

@@ -5,13 +5,10 @@ import Button from '../../common/components/Button';
import saveSettings from '../utils/saveSettings';
import ItemList from '../../common/utils/ItemList';
import Switch from '../../common/components/Switch';
import Stream from '../../common/utils/Stream';
import withAttr from '../../common/utils/withAttr';
import AdminHeader from './AdminHeader';
export default class BasicsPage extends Page {
oninit(vnode) {
super.oninit(vnode);
init() {
super.init();
this.loading = false;
@@ -28,7 +25,7 @@ export default class BasicsPage extends Page {
this.values = {};
const settings = app.data.settings;
this.fields.forEach((key) => (this.values[key] = Stream(settings[key])));
this.fields.forEach((key) => (this.values[key] = m.prop(settings[key])));
this.localeOptions = {};
const locales = app.data.locales;
@@ -50,56 +47,47 @@ export default class BasicsPage extends Page {
view() {
return (
<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">
<form onsubmit={this.onsubmit.bind(this)}>
{FieldSet.component(
{
label: app.translator.trans('core.admin.basics.forum_title_heading'),
},
[<input className="FormControl" bidi={this.values.forum_title} />]
)}
{FieldSet.component({
label: app.translator.trans('core.admin.basics.forum_title_heading'),
children: [<input className="FormControl" value={this.values.forum_title()} oninput={m.withAttr('value', this.values.forum_title)} />],
})}
{FieldSet.component(
{
label: app.translator.trans('core.admin.basics.forum_description_heading'),
},
[
{FieldSet.component({
label: app.translator.trans('core.admin.basics.forum_description_heading'),
children: [
<div className="helpText">{app.translator.trans('core.admin.basics.forum_description_text')}</div>,
<textarea className="FormControl" bidi={this.values.forum_description} />,
]
)}
<textarea
className="FormControl"
value={this.values.forum_description()}
oninput={m.withAttr('value', this.values.forum_description)}
/>,
],
})}
{Object.keys(this.localeOptions).length > 1
? FieldSet.component(
{
label: app.translator.trans('core.admin.basics.default_language_heading'),
},
[
? FieldSet.component({
label: app.translator.trans('core.admin.basics.default_language_heading'),
children: [
Select.component({
options: this.localeOptions,
value: this.values.default_locale(),
onchange: this.values.default_locale,
}),
Switch.component(
{
state: this.values.show_language_selector(),
onchange: this.values.show_language_selector,
},
app.translator.trans('core.admin.basics.show_language_selector_label')
),
]
)
Switch.component({
state: this.values.show_language_selector(),
onchange: this.values.show_language_selector,
children: app.translator.trans('core.admin.basics.show_language_selector_label'),
}),
],
})
: ''}
{FieldSet.component(
{
label: app.translator.trans('core.admin.basics.home_page_heading'),
className: 'BasicsPage-homePage',
},
[
{FieldSet.component({
label: app.translator.trans('core.admin.basics.home_page_heading'),
className: 'BasicsPage-homePage',
children: [
<div className="helpText">{app.translator.trans('core.admin.basics.home_page_text')}</div>,
this.homePageItems()
.toArray()
@@ -110,52 +98,51 @@ export default class BasicsPage extends Page {
name="homePage"
value={path}
checked={this.values.default_route() === path}
onclick={withAttr('value', this.values.default_route)}
onclick={m.withAttr('value', this.values.default_route)}
/>
{label}
</label>
)),
]
)}
],
})}
{FieldSet.component(
{
label: app.translator.trans('core.admin.basics.welcome_banner_heading'),
className: 'BasicsPage-welcomeBanner',
},
[
{FieldSet.component({
label: app.translator.trans('core.admin.basics.welcome_banner_heading'),
className: 'BasicsPage-welcomeBanner',
children: [
<div className="helpText">{app.translator.trans('core.admin.basics.welcome_banner_text')}</div>,
<div className="BasicsPage-welcomeBanner-input">
<input className="FormControl" bidi={this.values.welcome_title} />
<textarea className="FormControl" bidi={this.values.welcome_message} />
<input className="FormControl" value={this.values.welcome_title()} oninput={m.withAttr('value', this.values.welcome_title)} />
<textarea
className="FormControl"
value={this.values.welcome_message()}
oninput={m.withAttr('value', this.values.welcome_message)}
/>
</div>,
]
)}
],
})}
{Object.keys(this.displayNameOptions).length > 1
? FieldSet.component(
{
label: app.translator.trans('core.admin.basics.display_name_heading'),
},
[
? FieldSet.component({
label: app.translator.trans('core.admin.basics.display_name_heading'),
children: [
<div className="helpText">{app.translator.trans('core.admin.basics.display_name_text')}</div>,
Select.component({
options: this.displayNameOptions,
bidi: this.values.display_name_driver,
value: this.values.display_name_driver(),
onchange: this.values.display_name_driver,
}),
]
)
],
})
: ''}
{Button.component(
{
type: 'submit',
className: 'Button Button--primary',
loading: this.loading,
disabled: !this.changed(),
},
app.translator.trans('core.admin.basics.submit_button')
)}
{Button.component({
type: 'submit',
className: 'Button Button--primary',
children: app.translator.trans('core.admin.basics.submit_button'),
loading: this.loading,
disabled: !this.changed(),
})}
</form>
</div>
</div>
@@ -198,7 +185,10 @@ export default class BasicsPage extends Page {
saveSettings(settings)
.then(() => {
this.successAlert = app.alerts.show({ type: 'success' }, app.translator.trans('core.admin.basics.saved_message'));
this.successAlert = app.alerts.show({
type: 'success',
children: app.translator.trans('core.admin.basics.saved_message'),
});
})
.catch(() => {})
.then(() => {

View File

@@ -1,29 +1,16 @@
import Page from '../../common/components/Page';
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 {
view() {
return (
<div className="DashboardPage">
<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 className="container">{this.availableWidgets()}</div>
</div>
);
}
availableWidgets() {
const items = new ItemList();
items.add('status', <StatusWidget />, 30);
items.add('extensions', <ExtensionsWidget />, 10);
return items;
return [<StatusWidget />];
}
}

View File

@@ -4,23 +4,20 @@ import Badge from '../../common/components/Badge';
import Group from '../../common/models/Group';
import ItemList from '../../common/utils/ItemList';
import Switch from '../../common/components/Switch';
import Stream from '../../common/utils/Stream';
/**
* The `EditGroupModal` component shows a modal dialog which allows the user
* to create or edit a group.
*/
export default class EditGroupModal extends Modal {
oninit(vnode) {
super.oninit(vnode);
init() {
this.group = this.props.group || app.store.createRecord('groups');
this.group = this.attrs.group || app.store.createRecord('groups');
this.nameSingular = Stream(this.group.nameSingular() || '');
this.namePlural = Stream(this.group.namePlural() || '');
this.icon = Stream(this.group.icon() || '');
this.color = Stream(this.group.color() || '');
this.isHidden = Stream(this.group.isHidden() || false);
this.nameSingular = m.prop(this.group.nameSingular() || '');
this.namePlural = m.prop(this.group.namePlural() || '');
this.icon = m.prop(this.group.icon() || '');
this.color = m.prop(this.group.color() || '');
this.isHidden = m.prop(this.group.isHidden() || false);
}
className() {
@@ -56,8 +53,18 @@ export default class EditGroupModal extends Modal {
<div className="Form-group">
<label>{app.translator.trans('core.admin.edit_group.name_label')}</label>
<div className="EditGroupModal-name-input">
<input className="FormControl" placeholder={app.translator.trans('core.admin.edit_group.singular_placeholder')} bidi={this.nameSingular} />
<input className="FormControl" placeholder={app.translator.trans('core.admin.edit_group.plural_placeholder')} bidi={this.namePlural} />
<input
className="FormControl"
placeholder={app.translator.trans('core.admin.edit_group.singular_placeholder')}
value={this.nameSingular()}
oninput={m.withAttr('value', this.nameSingular)}
/>
<input
className="FormControl"
placeholder={app.translator.trans('core.admin.edit_group.plural_placeholder')}
value={this.namePlural()}
oninput={m.withAttr('value', this.namePlural)}
/>
</div>
</div>,
30
@@ -67,7 +74,7 @@ export default class EditGroupModal extends Modal {
'color',
<div className="Form-group">
<label>{app.translator.trans('core.admin.edit_group.color_label')}</label>
<input className="FormControl" placeholder="#aaaaaa" bidi={this.color} />
<input className="FormControl" placeholder="#aaaaaa" value={this.color()} oninput={m.withAttr('value', this.color)} />
</div>,
20
);
@@ -79,7 +86,7 @@ export default class EditGroupModal extends Modal {
<div className="helpText">
{app.translator.trans('core.admin.edit_group.icon_text', { a: <a href="https://fontawesome.com/icons?m=free" tabindex="-1" /> })}
</div>
<input className="FormControl" placeholder="fas fa-bolt" bidi={this.icon} />
<input className="FormControl" placeholder="fas fa-bolt" value={this.icon()} oninput={m.withAttr('value', this.icon)} />
</div>,
10
);
@@ -87,13 +94,11 @@ export default class EditGroupModal extends Modal {
items.add(
'hidden',
<div className="Form-group">
{Switch.component(
{
state: !!Number(this.isHidden()),
onchange: this.isHidden,
},
app.translator.trans('core.admin.edit_group.hide_label')
)}
{Switch.component({
state: !!Number(this.isHidden()),
children: app.translator.trans('core.admin.edit_group.hide_label'),
onchange: this.isHidden,
})}
</div>,
10
);
@@ -101,14 +106,12 @@ export default class EditGroupModal extends Modal {
items.add(
'submit',
<div className="Form-group">
{Button.component(
{
type: 'submit',
className: 'Button Button--primary EditGroupModal-save',
loading: this.loading,
},
app.translator.trans('core.admin.edit_group.submit_button')
)}
{Button.component({
type: 'submit',
className: 'Button Button--primary EditGroupModal-save',
loading: this.loading,
children: app.translator.trans('core.admin.edit_group.submit_button'),
})}
{this.group.exists && this.group.id() !== Group.ADMINISTRATOR_ID ? (
<button type="button" className="Button EditGroupModal-delete" onclick={this.deleteGroup.bind(this)}>
{app.translator.trans('core.admin.edit_group.delete_button')}

View File

@@ -1,29 +0,0 @@
import isExtensionEnabled from '../utils/isExtensionEnabled';
import LinkButton from '../../common/components/LinkButton';
import icon from '../../common/helpers/icon';
import ItemList from '../../common/utils/ItemList';
export default class ExtensionLinkButton extends LinkButton {
getButtonContent(children) {
const content = super.getButtonContent(children);
const extension = app.data.extensions[this.attrs.extensionId];
const statuses = this.statusItems(extension.id).toArray();
content.unshift(
<span className="ExtensionListItem-icon ExtensionIcon" style={extension.icon}>
{extension.icon ? icon(extension.icon.name) : ''}
</span>
);
content.push(statuses);
return content;
}
statusItems(name) {
const items = new ItemList();
items.add('enabled', <span class={'ExtensionListItem-Dot ' + (isExtensionEnabled(name) ? 'enabled' : 'disabled')} />);
return items;
}
}

View File

@@ -1,376 +0,0 @@
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 ExtensionData from '../utils/ExtensionData';
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',
};
// 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">
<h2 className="ExtensionPage-subHeader">{app.translator.trans('core.admin.extension.enable_to_see')}</h2>
</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 })
) : (
<h2 className="ExtensionPage-subHeader">{app.translator.trans('core.admin.extension.no_permissions')}</h2>
)}
</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>
) : (
<h2 className="ExtensionPage-subHeader">{app.translator.trans('core.admin.extension.no_settings')}</h2>
)}
</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();
if (this.extension.authors) {
let authors = [];
Object.keys(this.extension.authors).map((author, i) => {
const link = this.extension.authors[author].homepage
? this.extension.authors[author].homepage
: 'mailto:' + this.extension.authors[author].email;
authors.push(
<Link href={link} external={true} target="_blank">
{this.extension.authors[author].name}
</Link>
);
});
items.add('authors', [icon('fas fa-user'), <span>{punctuateSeries(authors)}</span>]);
}
const infoData = {};
if (this.extension.source || this.extension.support) {
infoData.source = {
icon: 'fas fa-code',
href: this.extension.source ? this.extension.source.url : this.extension.support.source,
};
}
Object.keys(this.infoFields).map((field) => {
const info = this.extension.extra['flarum-extension'].info;
if (info && info[field]) {
infoData[field] = {
icon: this.infoFields[field],
href: info[field],
};
}
});
Object.entries(infoData).map(([field, value]) => {
items.add(
field,
<LinkButton href={value.href} icon={value.icon} 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.
*
* @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) {
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(', '),
})
);
}
}

View File

@@ -1,39 +0,0 @@
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();
}
}

View File

@@ -0,0 +1,128 @@
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({
children: app.translator.trans('core.admin.extensions.add_button'),
icon: 'fas fa-plus',
className: 'Button Button--primary',
onclick: () => app.modal.show(AddExtensionModal),
})}
</div>
</div>
<div className="ExtensionsPage-list">
<div className="container">
<ul className="ExtensionList">
{Object.keys(app.data.extensions).map((id) => {
const extension = app.data.extensions[id];
const controls = this.controlItems(extension.id).toArray();
return (
<li className={'ExtensionListItem ' + (!this.isEnabled(extension.id) ? 'disabled' : '')}>
<div className="ExtensionListItem-content">
<span className="ExtensionListItem-icon ExtensionIcon" style={extension.icon}>
{extension.icon ? icon(extension.icon.name) : ''}
</span>
{controls.length ? (
<Dropdown
className="ExtensionListItem-controls"
buttonClassName="Button Button--icon Button--flat"
menuClassName="Dropdown-menu--right"
icon="fas fa-ellipsis-h"
>
{controls}
</Dropdown>
) : (
''
)}
<div className="ExtensionListItem-main">
<label className="ExtensionListItem-title">
<input type="checkbox" checked={this.isEnabled(extension.id)} onclick={this.toggle.bind(this, extension.id)} />{' '}
{extension.extra['flarum-extension'].title}
</label>
<div className="ExtensionListItem-version">{extension.version}</div>
<div className="ExtensionListItem-description">{extension.description}</div>
</div>
</div>
</li>
);
})}
</ul>
</div>
</div>
</div>
);
}
controlItems(name) {
const items = new ItemList();
const enabled = this.isEnabled(name);
if (app.extensionSettings[name]) {
items.add(
'settings',
Button.component({
icon: 'fas fa-cog',
children: app.translator.trans('core.admin.extensions.settings_button'),
onclick: app.extensionSettings[name],
})
);
}
if (!enabled) {
items.add(
'uninstall',
Button.component({
icon: 'far fa-trash-alt',
children: app.translator.trans('core.admin.extensions.uninstall_button'),
onclick: () => {
app
.request({
url: app.forum.attribute('apiUrl') + '/extensions/' + name,
method: 'DELETE',
})
.then(() => window.location.reload());
app.modal.show(LoadingModal);
},
})
);
}
return items;
}
isEnabled(name) {
const enabled = JSON.parse(app.data.settings.extensions_enabled);
return enabled.indexOf(name) !== -1;
}
toggle(id) {
const enabled = this.isEnabled(id);
app
.request({
url: app.forum.attribute('apiUrl') + '/extensions/' + id,
method: 'PATCH',
data: { enabled: !enabled },
})
.then(() => {
if (!enabled) localStorage.setItem('enabledExtension', id);
window.location.reload();
});
app.modal.show(LoadingModal);
}
}

View File

@@ -1,48 +0,0 @@
import DashboardWidget from './DashboardWidget';
import isExtensionEnabled from '../utils/isExtensionEnabled';
import getCategorizedExtensions from '../utils/getCategorizedExtensions';
import Link from '../../common/components/Link';
import icon from '../../common/helpers/icon';
export default class ExtensionsWidget extends DashboardWidget {
className() {
return 'ExtensionsWidget';
}
content() {
const categorizedExtensions = getCategorizedExtensions();
const categories = app.extensionCategories;
return (
<div className="ExtensionsWidget-list">
<div className="container">
{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>
</div>
);
}
}

View File

@@ -1,5 +1,4 @@
import Component from '../../common/Component';
import LinkButton from '../../common/components/LinkButton';
import SessionDropdown from './SessionDropdown';
import ItemList from '../../common/utils/ItemList';
import listItems from '../../common/helpers/listItems';
@@ -12,6 +11,13 @@ export default class HeaderSecondary extends Component {
return <ul className="Header-controls">{listItems(this.items().toArray())}</ul>;
}
config(isInitialized, context) {
// Since this component is 'above' the content of the page (that is, it is a
// part of the global UI that persists between routes), we will flag the DOM
// to be retained across route changes.
context.retain = true;
}
/**
* Build an item list for the controls.
*
@@ -20,13 +26,6 @@ export default class HeaderSecondary extends Component {
items() {
const items = new ItemList();
items.add(
'help',
<LinkButton href="https://docs.flarum.org/troubleshoot.html" icon="fas fa-question-circle" external={true} target="_blank">
{app.translator.trans('core.admin.header.get_help')}
</LinkButton>
);
items.add('session', SessionDropdown.component());
return items;

View File

@@ -5,13 +5,10 @@ import Alert from '../../common/components/Alert';
import Select from '../../common/components/Select';
import LoadingIndicator from '../../common/components/LoadingIndicator';
import saveSettings from '../utils/saveSettings';
import Stream from '../../common/utils/Stream';
import icon from '../../common/helpers/icon';
import AdminHeader from './AdminHeader';
export default class MailPage extends Page {
oninit(vnode) {
super.oninit(vnode);
init() {
super.init();
this.saving = false;
this.sendingTest = false;
@@ -27,7 +24,7 @@ export default class MailPage extends Page {
this.status = { sending: false, errors: {} };
const settings = app.data.settings;
this.fields.forEach((key) => (this.values[key] = Stream(settings[key])));
this.fields.forEach((key) => (this.values[key] = m.prop(settings[key])));
app
.request({
@@ -42,7 +39,7 @@ export default class MailPage extends Page {
for (const driver in this.driverFields) {
for (const field in this.driverFields[driver]) {
this.fields.push(field);
this.values[field] = Stream(settings[field]);
this.values[field] = m.prop(settings[field]);
}
}
@@ -67,32 +64,28 @@ export default class MailPage extends Page {
return (
<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">
<form onsubmit={this.onsubmit.bind(this)}>
{FieldSet.component(
{
label: app.translator.trans('core.admin.email.addresses_heading'),
className: 'MailPage-MailSettings',
},
[
<h2>{app.translator.trans('core.admin.email.heading')}</h2>
<div className="helpText">{app.translator.trans('core.admin.email.text')}</div>
{FieldSet.component({
label: app.translator.trans('core.admin.email.addresses_heading'),
className: 'MailPage-MailSettings',
children: [
<div className="MailPage-MailSettings-input">
<label>
{app.translator.trans('core.admin.email.from_label')}
<input className="FormControl" bidi={this.values.mail_from} />
<input className="FormControl" value={this.values.mail_from() || ''} oninput={m.withAttr('value', this.values.mail_from)} />
</label>
</div>,
]
)}
],
})}
{FieldSet.component(
{
label: app.translator.trans('core.admin.email.driver_heading'),
className: 'MailPage-MailSettings',
},
[
{FieldSet.component({
label: app.translator.trans('core.admin.email.driver_heading'),
className: 'MailPage-MailSettings',
children: [
<div className="MailPage-MailSettings-input">
<label>
{app.translator.trans('core.admin.email.driver_label')}
@@ -103,24 +96,20 @@ export default class MailPage extends Page {
/>
</label>
</div>,
]
)}
],
})}
{this.status.sending ||
Alert.component(
{
dismissible: false,
},
app.translator.trans('core.admin.email.not_sending_message')
)}
Alert.component({
children: app.translator.trans('core.admin.email.not_sending_message'),
dismissible: false,
})}
{fieldKeys.length > 0 &&
FieldSet.component(
{
label: app.translator.trans(`core.admin.email.${this.values.mail_driver()}_heading`),
className: 'MailPage-MailSettings',
},
[
FieldSet.component({
label: app.translator.trans(`core.admin.email.${this.values.mail_driver()}_heading`),
className: 'MailPage-MailSettings',
children: [
<div className="MailPage-MailSettings-input">
{fieldKeys.map((field) => [
<label>
@@ -130,37 +119,31 @@ export default class MailPage extends Page {
this.status.errors[field] && <p className="ValidationError">{this.status.errors[field]}</p>,
])}
</div>,
]
)}
],
})}
<FieldSet>
{Button.component(
{
type: 'submit',
className: 'Button Button--primary',
disabled: !this.changed(),
},
app.translator.trans('core.admin.email.submit_button')
)}
{Button.component({
type: 'submit',
className: 'Button Button--primary',
children: app.translator.trans('core.admin.email.submit_button'),
disabled: !this.changed(),
})}
</FieldSet>
{FieldSet.component(
{
label: app.translator.trans('core.admin.email.send_test_mail_heading'),
className: 'MailPage-MailSettings',
},
[
{FieldSet.component({
label: app.translator.trans('core.admin.email.send_test_mail_heading'),
className: 'MailPage-MailSettings',
children: [
<div className="helpText">{app.translator.trans('core.admin.email.send_test_mail_text', { email: app.session.user.email() })}</div>,
Button.component(
{
className: 'Button Button--primary',
disabled: this.sendingTest || this.changed(),
onclick: () => this.sendTestEmail(),
},
app.translator.trans('core.admin.email.send_test_mail_button')
),
]
)}
Button.component({
className: 'Button Button--primary',
children: app.translator.trans('core.admin.email.send_test_mail_button'),
disabled: this.sendingTest || this.changed(),
onclick: () => this.sendTestEmail(),
}),
],
})}
</form>
</div>
</div>
@@ -173,7 +156,7 @@ export default class MailPage extends Page {
const prop = this.values[name];
if (typeof field === 'string') {
return <input className="FormControl" bidi={prop} />;
return <input className="FormControl" value={prop() || ''} oninput={m.withAttr('value', prop)} />;
} else {
return <Select value={prop()} options={field} onchange={prop} />;
}
@@ -196,7 +179,10 @@ export default class MailPage extends Page {
})
.then((response) => {
this.sendingTest = false;
this.testEmailSuccessAlert = app.alerts.show({ type: 'success' }, app.translator.trans('core.admin.email.send_test_mail_success'));
this.testEmailSuccessAlert = app.alerts.show({
type: 'success',
children: app.translator.trans('core.admin.email.send_test_mail_success'),
});
})
.catch((error) => {
this.sendingTest = false;
@@ -219,7 +205,10 @@ export default class MailPage extends Page {
saveSettings(settings)
.then(() => {
this.successAlert = app.alerts.show({ type: 'success' }, app.translator.trans('core.admin.basics.saved_message'));
this.successAlert = app.alerts.show({
type: 'success',
children: app.translator.trans('core.admin.basics.saved_message'),
});
})
.catch(() => {})
.then(() => {

View File

@@ -32,109 +32,101 @@ function filterByRequiredPermissions(groupIds, permission) {
}
export default class PermissionDropdown extends Dropdown {
static initAttrs(attrs) {
super.initAttrs(attrs);
static initProps(props) {
super.initProps(props);
attrs.className = 'PermissionDropdown';
attrs.buttonClassName = 'Button Button--text';
props.className = 'PermissionDropdown';
props.buttonClassName = 'Button Button--text';
}
view(vnode) {
const children = [];
view() {
this.props.children = [];
let groupIds = app.data.permissions[this.attrs.permission] || [];
let groupIds = app.data.permissions[this.props.permission] || [];
groupIds = filterByRequiredPermissions(groupIds, this.attrs.permission);
groupIds = filterByRequiredPermissions(groupIds, this.props.permission);
const everyone = groupIds.indexOf(Group.GUEST_ID) !== -1;
const members = groupIds.indexOf(Group.MEMBER_ID) !== -1;
const adminGroup = app.store.getById('groups', Group.ADMINISTRATOR_ID);
if (everyone) {
this.attrs.label = Badge.component({ icon: 'fas fa-globe' });
this.props.label = Badge.component({ icon: 'fas fa-globe' });
} else if (members) {
this.attrs.label = Badge.component({ icon: 'fas fa-user' });
this.props.label = Badge.component({ icon: 'fas fa-user' });
} else {
this.attrs.label = [badgeForId(Group.ADMINISTRATOR_ID), groupIds.map(badgeForId)];
this.props.label = [badgeForId(Group.ADMINISTRATOR_ID), groupIds.map(badgeForId)];
}
if (this.showing) {
if (this.attrs.allowGuest) {
children.push(
Button.component(
{
icon: everyone ? 'fas fa-check' : true,
onclick: () => this.save([Group.GUEST_ID]),
disabled: this.isGroupDisabled(Group.GUEST_ID),
},
[Badge.component({ icon: 'fas fa-globe' }), ' ', app.translator.trans('core.admin.permissions_controls.everyone_button')]
)
if (this.props.allowGuest) {
this.props.children.push(
Button.component({
children: [Badge.component({ icon: 'fas fa-globe' }), ' ', app.translator.trans('core.admin.permissions_controls.everyone_button')],
icon: everyone ? 'fas fa-check' : true,
onclick: () => this.save([Group.GUEST_ID]),
disabled: this.isGroupDisabled(Group.GUEST_ID),
})
);
}
children.push(
Button.component(
{
icon: members ? 'fas fa-check' : true,
onclick: () => this.save([Group.MEMBER_ID]),
disabled: this.isGroupDisabled(Group.MEMBER_ID),
},
[Badge.component({ icon: 'fas fa-user' }), ' ', app.translator.trans('core.admin.permissions_controls.members_button')]
),
this.props.children.push(
Button.component({
children: [Badge.component({ icon: 'fas fa-user' }), ' ', app.translator.trans('core.admin.permissions_controls.members_button')],
icon: members ? 'fas fa-check' : true,
onclick: () => this.save([Group.MEMBER_ID]),
disabled: this.isGroupDisabled(Group.MEMBER_ID),
}),
Separator.component(),
Button.component(
{
icon: !everyone && !members ? 'fas fa-check' : true,
disabled: !everyone && !members,
onclick: (e) => {
if (e.shiftKey) e.stopPropagation();
this.save([]);
},
Button.component({
children: [badgeForId(adminGroup.id()), ' ', adminGroup.namePlural()],
icon: !everyone && !members ? 'fas fa-check' : true,
disabled: !everyone && !members,
onclick: (e) => {
if (e.shiftKey) e.stopPropagation();
this.save([]);
},
[badgeForId(adminGroup.id()), ' ', adminGroup.namePlural()]
)
})
);
[].push.apply(
children,
this.props.children,
app.store
.all('groups')
.filter((group) => [Group.ADMINISTRATOR_ID, Group.GUEST_ID, Group.MEMBER_ID].indexOf(group.id()) === -1)
.map((group) =>
Button.component(
{
icon: groupIds.indexOf(group.id()) !== -1 ? 'fas fa-check' : true,
onclick: (e) => {
if (e.shiftKey) e.stopPropagation();
this.toggle(group.id());
},
disabled: this.isGroupDisabled(group.id()) && this.isGroupDisabled(Group.MEMBER_ID) && this.isGroupDisabled(Group.GUEST_ID),
Button.component({
children: [badgeForId(group.id()), ' ', group.namePlural()],
icon: groupIds.indexOf(group.id()) !== -1 ? 'fas fa-check' : true,
onclick: (e) => {
if (e.shiftKey) e.stopPropagation();
this.toggle(group.id());
},
[badgeForId(group.id()), ' ', group.namePlural()]
)
disabled: this.isGroupDisabled(group.id()) && this.isGroupDisabled(Group.MEMBER_ID) && this.isGroupDisabled(Group.GUEST_ID),
})
)
);
}
return super.view({ ...vnode, children });
return super.view();
}
save(groupIds) {
const permission = this.attrs.permission;
const permission = this.props.permission;
app.data.permissions[permission] = groupIds;
app.request({
method: 'POST',
url: app.forum.attribute('apiUrl') + '/permission',
body: { permission, groupIds },
data: { permission, groupIds },
});
}
toggle(groupId) {
const permission = this.attrs.permission;
const permission = this.props.permission;
let groupIds = app.data.permissions[permission] || [];
@@ -151,6 +143,6 @@ export default class PermissionDropdown extends Dropdown {
}
isGroupDisabled(id) {
return filterByRequiredPermissions([id], this.attrs.permission).indexOf(id) === -1;
return filterByRequiredPermissions([id], this.props.permission).indexOf(id) === -1;
}
}

View File

@@ -6,6 +6,10 @@ import ItemList from '../../common/utils/ItemList';
import icon from '../../common/helpers/icon';
export default class PermissionGrid extends Component {
init() {
this.permissions = this.permissionItems().toArray();
}
view() {
const scopes = this.scopeItems().toArray();
@@ -29,27 +33,25 @@ export default class PermissionGrid extends Component {
<th>{this.scopeControlItems().toArray()}</th>
</tr>
</thead>
{this.permissionItems()
.toArray()
.map((section) => (
<tbody>
<tr className="PermissionGrid-section">
<th>{section.label}</th>
{permissionCells(section)}
{this.permissions.map((section) => (
<tbody>
<tr className="PermissionGrid-section">
<th>{section.label}</th>
{permissionCells(section)}
<td />
</tr>
{section.children.map((child) => (
<tr className="PermissionGrid-child">
<th>
{icon(child.icon)}
{child.label}
</th>
{permissionCells(child)}
<td />
</tr>
{section.children.map((child) => (
<tr className="PermissionGrid-child">
<th>
{icon(child.icon)}
{child.label}
</th>
{permissionCells(child)}
<td />
</tr>
))}
</tbody>
))}
))}
</tbody>
))}
</table>
);
}
@@ -154,8 +156,6 @@ export default class PermissionGrid extends Component {
permission: 'user.viewLastSeenAt',
});
items.merge(app.extensionData.getAllExtensionPermissions('view'));
return items;
}
@@ -196,8 +196,6 @@ export default class PermissionGrid extends Component {
90
);
items.merge(app.extensionData.getAllExtensionPermissions('start'));
return items;
}
@@ -238,8 +236,6 @@ export default class PermissionGrid extends Component {
90
);
items.merge(app.extensionData.getAllExtensionPermissions('reply'));
return items;
}
@@ -336,8 +332,6 @@ export default class PermissionGrid extends Component {
60
);
items.merge(app.extensionData.getAllExtensionPermissions('moderate'));
return items;
}

View File

@@ -4,15 +4,11 @@ import EditGroupModal from './EditGroupModal';
import Group from '../../common/models/Group';
import icon from '../../common/helpers/icon';
import PermissionGrid from './PermissionGrid';
import AdminHeader from './AdminHeader';
export default class PermissionsPage extends Page {
view() {
return (
<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="container">
{app.store

View File

@@ -9,16 +9,18 @@ import ItemList from '../../common/utils/ItemList';
* avatar/name, with a dropdown of session controls.
*/
export default class SessionDropdown extends Dropdown {
static initAttrs(attrs) {
super.initAttrs(attrs);
static initProps(props) {
super.initProps(props);
attrs.className = 'SessionDropdown';
attrs.buttonClassName = 'Button Button--user Button--flat';
attrs.menuClassName = 'Dropdown-menu--right';
props.className = 'SessionDropdown';
props.buttonClassName = 'Button Button--user Button--flat';
props.menuClassName = 'Dropdown-menu--right';
}
view(vnode) {
return super.view({ ...vnode, children: this.items().toArray() });
view() {
this.props.children = this.items().toArray();
return super.view();
}
getButtonContent() {
@@ -37,13 +39,11 @@ export default class SessionDropdown extends Dropdown {
items.add(
'logOut',
Button.component(
{
icon: 'fas fa-sign-out-alt',
onclick: app.session.logout.bind(app.session),
},
app.translator.trans('core.admin.header.log_out_button')
),
Button.component({
icon: 'fas fa-sign-out-alt',
children: app.translator.trans('core.admin.header.log_out_button'),
onclick: app.session.logout.bind(app.session),
}),
-100
);

View File

@@ -3,30 +3,23 @@ import Button from '../../common/components/Button';
import saveSettings from '../utils/saveSettings';
export default class SettingDropdown extends SelectDropdown {
static initAttrs(attrs) {
super.initAttrs(attrs);
static initProps(props) {
super.initProps(props);
attrs.className = 'SettingDropdown';
attrs.buttonClassName = 'Button Button--text';
attrs.caretIcon = 'fas fa-caret-down';
attrs.defaultLabel = 'Custom';
}
props.className = 'SettingDropdown';
props.buttonClassName = 'Button Button--text';
props.caretIcon = 'fas fa-caret-down';
props.defaultLabel = 'Custom';
view(vnode) {
return super.view({
...vnode,
children: this.attrs.options.map(({ value, label }) => {
const active = app.data.settings[this.attrs.key] === value;
props.children = props.options.map(({ value, label }) => {
const active = app.data.settings[props.key] === value;
return Button.component(
{
icon: active ? 'fas fa-check' : true,
onclick: saveSettings.bind(this, { [this.attrs.key]: value }),
active,
},
label
);
}),
return Button.component({
children: label,
icon: active ? 'fas fa-check' : true,
onclick: saveSettings.bind(this, { [props.key]: value }),
active,
});
});
}
}

View File

@@ -1,12 +1,9 @@
import Modal from '../../common/components/Modal';
import Button from '../../common/components/Button';
import Stream from '../../common/utils/Stream';
import saveSettings from '../utils/saveSettings';
export default class SettingsModal extends Modal {
oninit(vnode) {
super.oninit(vnode);
init() {
this.settings = {};
this.loading = false;
}
@@ -36,7 +33,7 @@ export default class SettingsModal extends Modal {
}
setting(key, fallback = '') {
this.settings[key] = this.settings[key] || Stream(app.data.settings[key] || fallback);
this.settings[key] = this.settings[key] || m.prop(app.data.settings[key] || fallback);
return this.settings[key];
}

View File

@@ -1,28 +1,32 @@
import Button from '../../common/components/Button';
export default class UploadImageButton extends Button {
loading = false;
init() {
this.loading = false;
}
view(vnode) {
this.attrs.loading = this.loading;
this.attrs.className = (this.attrs.className || '') + ' Button';
view() {
this.props.loading = this.loading;
this.props.className = (this.props.className || '') + ' Button';
if (app.data.settings[this.attrs.name + '_path']) {
this.attrs.onclick = this.remove.bind(this);
if (app.data.settings[this.props.name + '_path']) {
this.props.onclick = this.remove.bind(this);
this.props.children = app.translator.trans('core.admin.upload_image.remove_button');
return (
<div>
<p>
<img src={app.forum.attribute(this.attrs.name + 'Url')} alt="" />
<img src={app.forum.attribute(this.props.name + 'Url')} alt="" />
</p>
<p>{super.view({ ...vnode, children: app.translator.trans('core.admin.upload_image.remove_button') })}</p>
<p>{super.view()}</p>
</div>
);
} else {
this.attrs.onclick = this.upload.bind(this);
this.props.onclick = this.upload.bind(this);
this.props.children = app.translator.trans('core.admin.upload_image.upload_button');
}
return super.view({ ...vnode, children: app.translator.trans('core.admin.upload_image.upload_button') });
return super.view();
}
/**
@@ -38,8 +42,8 @@ export default class UploadImageButton extends Button {
.hide()
.click()
.on('change', (e) => {
const body = new FormData();
body.append(this.attrs.name, $(e.target)[0].files[0]);
const data = new FormData();
data.append(this.props.name, $(e.target)[0].files[0]);
this.loading = true;
m.redraw();
@@ -49,7 +53,7 @@ export default class UploadImageButton extends Button {
method: 'POST',
url: this.resourceUrl(),
serialize: (raw) => raw,
body,
data,
})
.then(this.success.bind(this), this.failure.bind(this));
});
@@ -71,7 +75,7 @@ export default class UploadImageButton extends Button {
}
resourceUrl() {
return app.forum.attribute('apiUrl') + '/' + this.attrs.name;
return app.forum.attribute('apiUrl') + '/' + this.props.name;
}
/**

View File

@@ -1,19 +0,0 @@
import DefaultResolver from '../../common/resolvers/DefaultResolver';
/**
* A custom route resolver for ExtensionPage that generates handles routes
* to default extension pages or a page provided by an extension.
*/
export default class ExtensionPageResolver extends DefaultResolver {
static extension: string | null = null;
onmatch(args, requestedPath, route) {
const extensionPage = app.extensionData.getPage(args.id);
if (extensionPage) {
return extensionPage;
}
return super.onmatch(args, requestedPath, route);
}
}

View File

@@ -2,9 +2,8 @@ import DashboardPage from './components/DashboardPage';
import BasicsPage from './components/BasicsPage';
import PermissionsPage from './components/PermissionsPage';
import AppearancePage from './components/AppearancePage';
import ExtensionsPage from './components/ExtensionsPage';
import MailPage from './components/MailPage';
import ExtensionPage from './components/ExtensionPage';
import ExtensionPageResolver from './resolvers/ExtensionPageResolver';
/**
* The `routes` initializer defines the forum app's routes.
@@ -13,11 +12,11 @@ import ExtensionPageResolver from './resolvers/ExtensionPageResolver';
*/
export default function (app) {
app.routes = {
dashboard: { path: '/', component: DashboardPage },
basics: { path: '/basics', component: BasicsPage },
permissions: { path: '/permissions', component: PermissionsPage },
appearance: { path: '/appearance', component: AppearancePage },
mail: { path: '/mail', component: MailPage },
extension: { path: '/extension/:id', component: ExtensionPage, resolverClass: ExtensionPageResolver },
dashboard: { path: '/', component: DashboardPage.component() },
basics: { path: '/basics', component: BasicsPage.component() },
permissions: { path: '/permissions', component: PermissionsPage.component() },
appearance: { path: '/appearance', component: AppearancePage.component() },
extensions: { path: '/extensions', component: ExtensionsPage.component() },
mail: { path: '/mail', component: MailPage.component() },
};
}

View File

@@ -1,167 +0,0 @@
import ItemList from '../../common/utils/ItemList';
export default class ExtensionData {
constructor() {
this.data = {};
this.currentExtension = null;
}
/**
* This function simply takes the extension id
*
* @example
* app.extensionData.load('flarum-tags')
*
* flarum/flags -> flarum-flags | acme/extension -> acme-extension
*
* @param extension
*/
for(extension) {
this.currentExtension = extension;
this.data[extension] = this.data[extension] || {};
return this;
}
/**
* This function registers your settings with Flarum
*
* @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();
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;
}
}

View File

@@ -1,25 +0,0 @@
export default function getCategorizedExtensions() {
let extensions = {};
Object.keys(app.data.extensions).map((id) => {
const extension = app.data.extensions[id];
let category = extension.extra['flarum-extension'].category;
// Wrap languages packs into new system
if (extension.extra['flarum-locale']) {
category = 'language';
}
if (category in app.extensionCategories) {
extensions[category] = extensions[category] || [];
extensions[category].push(extension);
} else {
extensions.other = extensions.other || [];
extensions.other.push(extension);
}
});
return extensions;
}

View File

@@ -1,5 +0,0 @@
export default function isExtensionEnabled(name) {
const enabled = JSON.parse(app.data.settings.extensions_enabled);
return enabled.includes(name);
}

View File

@@ -7,7 +7,7 @@ export default function saveSettings(settings) {
.request({
method: 'POST',
url: app.forum.attribute('apiUrl') + '/settings',
body: settings,
data: settings,
})
.catch((error) => {
app.data.settings = oldSettings;

View File

@@ -189,28 +189,21 @@ export default class Application {
}
mount(basePath = '') {
// An object with a callable view property is used in order to pass arguments to the component; see https://mithril.js.org/mount.html
m.mount(document.getElementById('modal'), { view: () => ModalManager.component({ state: this.modal }) });
m.mount(document.getElementById('alerts'), { view: () => AlertManager.component({ state: this.alerts }) });
m.mount(document.getElementById('modal'), <ModalManager state={this.modal} />);
m.mount(document.getElementById('alerts'), <AlertManager state={this.alerts} />);
this.drawer = new Drawer();
m.route(document.getElementById('content'), basePath + '/', mapRoutes(this.routes, basePath));
// Add a class to the body which indicates that the page has been scrolled
// down. When this happens, we'll add classes to the header and app body
// which will set the navbar's position to fixed. We don't want to always
// have it fixed, as that could overlap with custom headers.
const scrollListener = new ScrollListener((top) => {
// down.
new ScrollListener((top) => {
const $app = $('#app');
const offset = $app.offset().top;
$app.toggleClass('affix', top >= offset).toggleClass('scrolled', top > offset);
$('.App-header').toggleClass('navbar-fixed-top', top >= offset);
});
scrollListener.start();
scrollListener.update();
}).start();
$(() => {
$('body').addClass('ontouchstart' in window ? 'touch' : 'no-touch');
@@ -270,7 +263,7 @@ export default class Application {
updateTitle() {
const count = this.titleCount ? `(${this.titleCount}) ` : '';
const pageTitleWithSeparator = this.title && m.route.get() !== this.forum.attribute('basePath') + '/' ? this.title + ' - ' : '';
const pageTitleWithSeparator = this.title && m.route() !== '/' ? this.title + ' - ' : '';
const title = this.forum.attribute('title');
document.title = count + pageTitleWithSeparator + title;
}
@@ -278,7 +271,7 @@ export default class Application {
/**
* Make an AJAX request, handling any low-level errors that may occur.
*
* @see https://mithril.js.org/request.html
* @see https://lhorie.github.io/mithril/mithril.request.html
* @param {Object} options
* @return {Promise}
* @public
@@ -349,14 +342,16 @@ export default class Application {
// Now make the request. If it's a failure, inspect the error that was
// returned and show an alert containing its contents.
return m.request(options).then(
(response) => response,
const deferred = m.deferred();
m.request(options).then(
(response) => deferred.resolve(response),
(error) => {
let content;
let children;
switch (error.status) {
case 422:
content = error.response.errors
children = error.response.errors
.map((error) => [error.detail, <br />])
.reduce((a, b) => a.concat(b), [])
.slice(0, -1);
@@ -364,31 +359,30 @@ export default class Application {
case 401:
case 403:
content = app.translator.trans('core.lib.error.permission_denied_message');
children = app.translator.trans('core.lib.error.permission_denied_message');
break;
case 404:
case 410:
content = app.translator.trans('core.lib.error.not_found_message');
children = app.translator.trans('core.lib.error.not_found_message');
break;
case 429:
content = app.translator.trans('core.lib.error.rate_limit_exceeded_message');
children = app.translator.trans('core.lib.error.rate_limit_exceeded_message');
break;
default:
content = app.translator.trans('core.lib.error.generic_message');
children = app.translator.trans('core.lib.error.generic_message');
}
const isDebug = app.forum.attribute('debug');
// contains a formatted errors if possible, response must be an JSON API array of errors
// the details property is decoded to transform escaped characters such as '\n'
const errors = error.response && error.response.errors;
const formattedError = Array.isArray(errors) && errors[0] && errors[0].detail && errors.map((e) => decodeURI(e.detail));
const formattedError = error.response && Array.isArray(error.response.errors) && error.response.errors.map((e) => decodeURI(e.detail));
error.alert = {
type: 'error',
content,
children,
controls: isDebug && [
<Button className="Button Button--link" onclick={this.showDebug.bind(this, error, formattedError)}>
Debug
@@ -410,12 +404,14 @@ export default class Application {
console.groupEnd();
}
this.requestErrorAlert = this.alerts.show(error.alert, error.alert.content);
this.requestErrorAlert = this.alerts.show(error.alert);
}
return Promise.reject(error);
deferred.reject(error);
}
);
return deferred.promise;
}
/**
@@ -438,19 +434,9 @@ export default class Application {
* @public
*/
route(name, params = {}) {
const route = this.routes[name];
if (!route) throw new Error(`Route '${name}' does not exist`);
const url = route.path.replace(/:([^\/]+)/g, (m, key) => extract(params, key));
// Remove falsy values in params to avoid having urls like '/?sort&q'
for (const key in params) {
if (params.hasOwnProperty(key) && !params[key]) delete params[key];
}
const queryString = m.buildQueryString(params);
const prefix = m.route.prefix === '' ? this.forum.attribute('basePath') : '';
const url = this.routes[name].path.replace(/:([^\/]+)/g, (m, key) => extract(params, key));
const queryString = m.route.buildQueryString(params);
const prefix = m.route.mode === 'pathname' ? app.forum.attribute('basePath') : '';
return prefix + url + (queryString ? '?' + queryString : '');
}

221
js/src/common/Component.js Normal file
View File

@@ -0,0 +1,221 @@
/*
* This file is part of Flarum.
*
* (c) Toby Zerner <toby.zerner@gmail.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
/**
* The `Component` class defines a user interface 'building block'. A component
* can generate a virtual DOM to be rendered on each redraw.
*
* An instance's virtual DOM can be retrieved directly using the {@link
* Component#render} method.
*
* @example
* this.myComponentInstance = new MyComponent({foo: 'bar'});
* return m('div', this.myComponentInstance.render());
*
* Alternatively, components can be nested, letting Mithril take care of
* instance persistence. For this, the static {@link Component.component} method
* can be used.
*
* @example
* return m('div', MyComponent.component({foo: 'bar'));
*
* @see https://lhorie.github.io/mithril/mithril.component.html
* @abstract
*/
export default class Component {
/**
* @param {Object} props
* @param {Array|Object} children
* @public
*/
constructor(props = {}, children = null) {
if (children) props.children = children;
this.constructor.initProps(props);
/**
* The properties passed into the component.
*
* @type {Object}
*/
this.props = props;
/**
* The root DOM element for the component.
*
* @type DOMElement
* @public
*/
this.element = null;
/**
* Whether or not to retain the component's subtree on redraw.
*
* @type {boolean}
* @public
*/
this.retain = false;
this.init();
}
/**
* Called when the component is constructed.
*
* @protected
*/
init() {}
/**
* Called when the component is destroyed, i.e. after a redraw where it is no
* longer a part of the view.
*
* @see https://lhorie.github.io/mithril/mithril.component.html#unloading-components
* @param {Object} e
* @public
*/
onunload() {}
/**
* Get the renderable virtual DOM that represents the component's view.
*
* This should NOT be overridden by subclasses. Subclasses wishing to define
* their virtual DOM should override Component#view instead.
*
* @example
* this.myComponentInstance = new MyComponent({foo: 'bar'});
* return m('div', this.myComponentInstance.render());
*
* @returns {Object}
* @final
* @public
*/
render() {
const vdom = this.retain ? { subtree: 'retain' } : this.view();
// Override the root element's config attribute with our own function, which
// will set the component instance's element property to the root DOM
// element, and then run the component class' config method.
vdom.attrs = vdom.attrs || {};
const originalConfig = vdom.attrs.config;
vdom.attrs.config = (...args) => {
this.element = args[0];
this.config.apply(this, args.slice(1));
if (originalConfig) originalConfig.apply(this, args);
};
return vdom;
}
/**
* Returns a jQuery object for this component's element. If you pass in a
* selector string, this method will return a jQuery object, using the current
* element as its buffer.
*
* For example, calling `component.$('li')` will return a jQuery object
* containing all of the `li` elements inside the DOM element of this
* component.
*
* @param {String} [selector] a jQuery-compatible selector string
* @returns {jQuery} the jQuery object for the DOM node
* @final
* @public
*/
$(selector) {
const $element = $(this.element);
return selector ? $element.find(selector) : $element;
}
/**
* Called after the component's root element is redrawn. This hook can be used
* to perform any actions on the DOM, both on the initial draw and any
* subsequent redraws. See Mithril's documentation for more information.
*
* @see https://lhorie.github.io/mithril/mithril.html#the-config-attribute
* @param {Boolean} isInitialized
* @param {Object} context
* @param {Object} vdom
* @public
*/
config() {}
/**
* Get the virtual DOM that represents the component's view.
*
* @return {Object} The virtual DOM
* @protected
*/
view() {
throw new Error('Component#view must be implemented by subclass');
}
/**
* Get a Mithril component object for this component, preloaded with props.
*
* @see https://lhorie.github.io/mithril/mithril.component.html
* @param {Object} [props] Properties to set on the component
* @param children
* @return {Object} The Mithril component object
* @property {function} controller
* @property {function} view
* @property {Object} component The class of this component
* @property {Object} props The props that were passed to the component
* @public
*/
static component(props = {}, children = null) {
const componentProps = Object.assign({}, props);
if (children) componentProps.children = children;
this.initProps(componentProps);
// Set up a function for Mithril to get the component's view. It will accept
// the component's controller (which happens to be the component itself, in
// our case), update its props with the ones supplied, and then render the view.
const view = (component) => {
component.props = componentProps;
return component.render();
};
// Mithril uses this property on the view function to cache component
// controllers between redraws, thus persisting component state.
view.$original = this.prototype.view;
// Our output object consists of a controller constructor + a view function
// which Mithril will use to instantiate and render the component. We also
// attach a reference to the props that were passed through and the
// component's class for reference.
const output = {
controller: this.bind(undefined, componentProps),
view: view,
props: componentProps,
component: this,
};
// If a `key` prop was set, then we'll assume that we want that to actually
// show up as an attribute on the component object so that Mithril's key
// algorithm can be applied.
if (componentProps.key) {
output.attrs = { key: componentProps.key };
}
return output;
}
/**
* Initialize the component's props.
*
* @param {Object} props
* @public
*/
static initProps(props) {}
}

View File

@@ -1,132 +0,0 @@
import * as Mithril from 'mithril';
export interface ComponentAttrs extends Mithril.Attributes {}
/**
* The `Component` class defines a user interface 'building block'. A component
* generates a virtual DOM to be rendered on each redraw.
*
* Essentially, this is a wrapper for Mithril's components that adds several useful features:
*
* - In the `oninit` and `onbeforeupdate` lifecycle hooks, we store vnode attrs in `this.attrs.
* This allows us to use attrs across components without having to pass the vnode to every single
* method.
* - The static `initAttrs` method allows a convenient way to provide defaults (or to otherwise modify)
* the attrs that have been passed into a component.
* - When the component is created in the DOM, we store its DOM element under `this.element`; this lets
* us use jQuery to modify child DOM state from internal methods via the `this.$()` method.
* - A convenience `component` method, which serves as an alternative to hyperscript and JSX.
*
* As with other Mithril components, components extending Component can be initialized
* and nested using JSX, hyperscript, or a combination of both. The `component` method can also
* be used.
*
* @example
* return m('div', <MyComponent foo="bar"><p>Hello World</p></MyComponent>);
*
* @example
* return m('div', MyComponent.component({foo: 'bar'), m('p', 'Hello World!'));
*
* @see https://mithril.js.org/components.html
*/
export default abstract class Component<T extends ComponentAttrs = ComponentAttrs> implements Mithril.ClassComponent<T> {
/**
* The root DOM element for the component.
*/
protected element!: Element;
/**
* The attributes passed into the component.
*
* @see https://mithril.js.org/components.html#passing-data-to-components
*/
protected attrs!: T;
/**
* @inheritdoc
*/
abstract view(vnode: Mithril.Vnode<T, this>): Mithril.Children;
/**
* @inheritdoc
*/
oninit(vnode: Mithril.Vnode<T, this>) {
this.setAttrs(vnode.attrs);
}
/**
* @inheritdoc
*/
oncreate(vnode: Mithril.VnodeDOM<T, this>) {
this.element = vnode.dom;
}
/**
* @inheritdoc
*/
onbeforeupdate(vnode: Mithril.VnodeDOM<T, this>) {
this.setAttrs(vnode.attrs);
}
/**
* Returns a jQuery object for this component's element. If you pass in a
* selector string, this method will return a jQuery object, using the current
* element as its buffer.
*
* For example, calling `component.$('li')` will return a jQuery object
* containing all of the `li` elements inside the DOM element of this
* component.
*
* @param {String} [selector] a jQuery-compatible selector string
* @returns {jQuery} the jQuery object for the DOM node
* @final
*/
protected $(selector) {
const $element = $(this.element);
return selector ? $element.find(selector) : $element;
}
/**
* Convenience method to attach a component without JSX.
* Has the same effect as calling `m(THIS_CLASS, attrs, children)`.
*
* @see https://mithril.js.org/hyperscript.html#mselector,-attributes,-children
*/
static component(attrs = {}, children = null): Mithril.Vnode {
const componentAttrs = Object.assign({}, attrs);
return m(this as any, componentAttrs, children);
}
/**
* Saves a reference to the vnode attrs after running them through initAttrs,
* and checking for common issues.
*/
private setAttrs(attrs: T = {} as T): void {
(this.constructor as typeof Component).initAttrs(attrs);
if (attrs) {
if ('children' in attrs) {
throw new Error(
`[${
(this.constructor as any).name
}] The "children" attribute of attrs should never be used. Either pass children in as the vnode children or rename the attribute`
);
}
if ('tag' in attrs) {
throw new Error(`[${(this.constructor as any).name}] You cannot use the "tag" attribute name with Mithril 2.`);
}
}
this.attrs = attrs;
}
/**
* Initialize the component's attrs.
*
* This can be used to assign default values for missing, optional attrs.
*/
protected static initAttrs<T>(attrs: T): void {}
}

View File

@@ -1,74 +0,0 @@
import * as Mithril from 'mithril';
/**
* The `Fragment` class represents a chunk of DOM that is rendered once with Mithril and then takes
* over control of its own DOM and lifecycle.
*
* This is very similar to the `Component` wrapper class, but is used for more fine-grained control over
* the rendering and display of some significant chunks of the DOM. In contrast to components, fragments
* do not offer Mithril's lifecycle hooks.
*
* Use this when you want to enjoy the benefits of JSX / VDOM for initial rendering, combined with
* small helper methods that then make updates to that DOM directly, instead of fully redrawing
* everything through Mithril.
*
* This should only be used when necessary, and only with `m.render`. If you are unsure whether you need
* this or `Component, you probably need `Component`.
*/
export default abstract class Fragment {
/**
* The root DOM element for the fragment.
*/
protected element!: Element;
/**
* Returns a jQuery object for this fragment's element. If you pass in a
* selector string, this method will return a jQuery object, using the current
* element as its buffer.
*
* For example, calling `fragment.$('li')` will return a jQuery object
* containing all of the `li` elements inside the DOM element of this
* fragment.
*
* @param {String} [selector] a jQuery-compatible selector string
* @returns {jQuery} the jQuery object for the DOM node
* @final
*/
public $(selector) {
const $element = $(this.element);
return selector ? $element.find(selector) : $element;
}
/**
* Get the renderable virtual DOM that represents the fragment's view.
*
* This should NOT be overridden by subclasses. Subclasses wishing to define
* their virtual DOM should override Fragment#view instead.
*
* @example
* const fragment = new MyFragment();
* m.render(document.body, fragment.render());
*
* @final
*/
public render(): Mithril.Vnode<Mithril.Attributes, this> {
const vdom = this.view();
vdom.attrs = vdom.attrs || {};
const originalOnCreate = vdom.attrs.oncreate;
vdom.attrs.oncreate = (vnode) => {
this.element = vnode.dom;
if (originalOnCreate) originalOnCreate.apply(this, [vnode]);
};
return vdom;
}
/**
* Creates a view out of virtual elements.
*/
abstract view(): Mithril.Vnode<Mithril.Attributes, this>;
}

View File

@@ -161,7 +161,7 @@ export default class Model {
{
method: this.exists ? 'PATCH' : 'POST',
url: app.forum.attribute('apiUrl') + this.apiEndpoint(),
body: request,
data: request,
},
options
)
@@ -180,7 +180,7 @@ export default class Model {
// old data! We'll revert to that and let others handle the error.
(response) => {
this.pushData(oldData);
m.redraw();
m.lazyRedraw();
throw response;
}
);
@@ -189,13 +189,13 @@ export default class Model {
/**
* Send a request to delete the resource.
*
* @param {Object} body Data to send along with the DELETE request.
* @param {Object} data Data to send along with the DELETE request.
* @param {Object} [options]
* @return {Promise}
* @public
*/
delete(body, options = {}) {
if (!this.exists) return Promise.resolve();
delete(data, options = {}) {
if (!this.exists) return m.deferred().resolve().promise;
return app
.request(
@@ -203,7 +203,7 @@ export default class Model {
{
method: 'DELETE',
url: app.forum.attribute('apiUrl') + this.apiEndpoint(),
body,
data,
},
options
)

View File

@@ -30,13 +30,13 @@ export default class Session {
* @return {Promise}
* @public
*/
login(body, options = {}) {
login(data, options = {}) {
return app.request(
Object.assign(
{
method: 'POST',
url: `${app.forum.attribute('baseUrl')}/login`,
body,
url: app.forum.attribute('baseUrl') + '/login',
data,
},
options
)
@@ -49,6 +49,6 @@ export default class Session {
* @public
*/
logout() {
window.location = `${app.forum.attribute('baseUrl')}/logout?token=${this.csrfToken}`;
window.location = app.forum.attribute('baseUrl') + '/logout?token=' + this.csrfToken;
}
}

View File

@@ -82,13 +82,13 @@ export default class Store {
* @public
*/
find(type, id, query = {}, options = {}) {
let params = query;
let data = query;
let url = app.forum.attribute('apiUrl') + '/' + type;
if (id instanceof Array) {
url += '?filter[id]=' + id.join(',');
} else if (typeof id === 'object') {
params = id;
data = id;
} else if (id) {
url += '/' + id;
}
@@ -99,7 +99,7 @@ export default class Store {
{
method: 'GET',
url,
params,
data,
},
options
)

View File

@@ -1,3 +1,4 @@
import User from './models/User';
import username from './helpers/username';
import extract from './utils/extract';
@@ -70,34 +71,18 @@ export default class Translator {
const match = part.match(new RegExp('{([a-z0-9_]+)}|<(/?)([a-z0-9_]+)>', 'i'));
if (match) {
// Either an opening or closing tag.
if (match[1]) {
open[0].push(input[match[1]]);
} else if (match[3]) {
if (match[2]) {
// Closing tag. We start by removing all raw children (generally in the form of strings) from the temporary
// holding array, then run them through m.fragment to convert them to vnodes. Usually this will just give us a
// text vnode, but using m.fragment as opposed to an explicit conversion should be more flexible. This is necessary because
// otherwise, our generated vnode will have raw strings as its children, and mithril expects vnodes.
// Finally, we add the now-processed vnodes back onto the holding array (which is the same object in memory as the
// children array of the vnode we are currently processing), and remove the reference to the holding array so that
// further text will be added to the full set of returned elements.
const rawChildren = open[0].splice(0, open[0].length);
open[0].push(...m.fragment(rawChildren).children);
open.shift();
} else {
// If a vnode with a matching tag was provided in the translator input, we use that. Otherwise, we create a new vnode
// with this tag, and an empty children array (since we're expecting to insert children, as that's the point of having this in translator)
let tag = input[match[3]] || { tag: match[3], children: [] };
open[0].push(tag);
// Insert the tag's children array as the first element of open, so that text in between the opening
// and closing tags will be added to the tag's children, not to the full set of returned elements.
open.unshift(tag.children || tag);
}
}
} else {
// Not an html tag, we add it to open[0], which is either the full set of returned elements (vnodes and text),
// or if an html tag is currently being processed, the children attribute of that html tag's vnode.
open[0].push(part);
}
});

View File

@@ -12,20 +12,15 @@ import anchorScroll from './utils/anchorScroll';
import RequestError from './utils/RequestError';
import abbreviateNumber from './utils/abbreviateNumber';
import * as string from './utils/string';
import Stream from './utils/Stream';
import SubtreeRetainer from './utils/SubtreeRetainer';
import setRouteWithForcedRefresh from './utils/setRouteWithForcedRefresh';
import extract from './utils/extract';
import ScrollListener from './utils/ScrollListener';
import stringToColor from './utils/stringToColor';
import subclassOf from './utils/subclassOf';
import SuperTextarea from './utils/SuperTextarea';
import patchMithril from './utils/patchMithril';
import classList from './utils/classList';
import extractText from './utils/extractText';
import formatNumber from './utils/formatNumber';
import mapRoutes from './utils/mapRoutes';
import withAttr from './utils/withAttr';
import Notification from './models/Notification';
import User from './models/User';
import Post from './models/Post';
@@ -48,7 +43,6 @@ import FieldSet from './components/FieldSet';
import Select from './components/Select';
import Navigation from './components/Navigation';
import Alert from './components/Alert';
import Link from './components/Link';
import LinkButton from './components/LinkButton';
import Checkbox from './components/Checkbox';
import SelectDropdown from './components/SelectDropdown';
@@ -67,8 +61,6 @@ import highlight from './helpers/highlight';
import username from './helpers/username';
import userOnline from './helpers/userOnline';
import listItems from './helpers/listItems';
import Fragment from './Fragment';
import DefaultResolver from './resolvers/DefaultResolver';
export default {
extend: extend,
@@ -89,16 +81,11 @@ export default {
'utils/extract': extract,
'utils/ScrollListener': ScrollListener,
'utils/stringToColor': stringToColor,
'utils/Stream': Stream,
'utils/subclassOf': subclassOf,
'utils/SuperTextarea': SuperTextarea,
'utils/setRouteWithForcedRefresh': setRouteWithForcedRefresh,
'utils/patchMithril': patchMithril,
'utils/classList': classList,
'utils/extractText': extractText,
'utils/formatNumber': formatNumber,
'utils/mapRoutes': mapRoutes,
'utils/withAttr': withAttr,
'models/Notification': Notification,
'models/User': User,
'models/Post': Post,
@@ -106,7 +93,6 @@ export default {
'models/Group': Group,
'models/Forum': Forum,
Component: Component,
Fragment: Fragment,
Translator: Translator,
'components/AlertManager': AlertManager,
'components/Page': Page,
@@ -122,7 +108,6 @@ export default {
'components/Select': Select,
'components/Navigation': Navigation,
'components/Alert': Alert,
'components/Link': Link,
'components/LinkButton': LinkButton,
'components/Checkbox': Checkbox,
'components/SelectDropdown': SelectDropdown,
@@ -141,5 +126,4 @@ export default {
'helpers/username': username,
'helpers/userOnline': userOnline,
'helpers/listItems': listItems,
'resolvers/DefaultResolver': DefaultResolver,
};

View File

@@ -1,33 +1,31 @@
import Component, { ComponentAttrs } from '../Component';
import Component from '../Component';
import Button from './Button';
import listItems from '../helpers/listItems';
import extract from '../utils/extract';
import Mithril from 'mithril';
export interface AlertAttrs extends ComponentAttrs {
/** The type of alert this is. Will be used to give the alert a class name of `Alert--{type}`. */
type?: string;
/** An array of controls to show in the alert. */
controls?: Mithril.Children;
/** Whether or not the alert can be dismissed. */
dismissible?: boolean;
/** A callback to run when the alert is dismissed */
ondismiss?: Function;
}
/**
* The `Alert` component represents an alert box, which contains a message,
* some controls, and may be dismissible.
*
* The alert may have the following special props:
*
* - `type` The type of alert this is. Will be used to give the alert a class
* name of `Alert--{type}`.
* - `controls` An array of controls to show in the alert.
* - `dismissible` Whether or not the alert can be dismissed.
* - `ondismiss` A callback to run when the alert is dismissed.
*
* All other props will be assigned as attributes on the alert element.
*/
export default class Alert<T extends AlertAttrs = AlertAttrs> extends Component<T> {
view(vnode: Mithril.Vnode) {
const attrs = Object.assign({}, this.attrs);
export default class Alert extends Component {
view() {
const attrs = Object.assign({}, this.props);
const type = extract(attrs, 'type');
attrs.className = 'Alert Alert--' + type + ' ' + (attrs.className || '');
const content = extract(attrs, 'content') || vnode.children;
const controls = (extract(attrs, 'controls') || []) as Mithril.ChildArray;
const children = extract(attrs, 'children');
const controls = extract(attrs, 'controls') || [];
// If the alert is meant to be dismissible (which is the case by default),
// then we will create a dismiss button to append as the final control in
@@ -42,7 +40,7 @@ export default class Alert<T extends AlertAttrs = AlertAttrs> extends Component<
return (
<div {...attrs}>
<span className="Alert-body">{content}</span>
<span className="Alert-body">{children}</span>
<ul className="Alert-controls">{listItems(controls.concat(dismissControl))}</ul>
</div>
);

View File

@@ -6,10 +6,8 @@ import Alert from './Alert';
* be shown and dismissed.
*/
export default class AlertManager extends Component {
oninit(vnode) {
super.oninit(vnode);
this.state = this.attrs.state;
init() {
this.state = this.props.state;
}
view() {
@@ -17,12 +15,17 @@ export default class AlertManager extends Component {
<div className="AlertManager">
{Object.entries(this.state.getActiveAlerts()).map(([key, alert]) => (
<div className="AlertManager-alert">
<alert.componentClass {...alert.attrs} ondismiss={this.state.dismiss.bind(this.state, key)}>
{alert.children}
</alert.componentClass>
{(alert.componentClass || Alert).component({ ...alert.attrs, ondismiss: this.state.dismiss.bind(this.state, key) })}
</div>
))}
</div>
);
}
config(isInitialized, context) {
// Since this component is 'above' the content of the page (that is, it is a
// part of the global UI that persists between routes), we will flag the DOM
// to be retained across route changes.
context.retain = true;
}
}

View File

@@ -6,18 +6,18 @@ import extract from '../utils/extract';
* The `Badge` component represents a user/discussion badge, indicating some
* status (e.g. a discussion is stickied, a user is an admin).
*
* A badge may have the following special attrs:
* A badge may have the following special props:
*
* - `type` The type of badge this is. This will be used to give the badge a
* class name of `Badge--{type}`.
* - `icon` The name of an icon to show inside the badge.
* - `label`
*
* All other attrs will be assigned as attributes on the badge element.
* All other props will be assigned as attributes on the badge element.
*/
export default class Badge extends Component {
view() {
const attrs = Object.assign({}, this.attrs);
const attrs = Object.assign({}, this.props);
const type = extract(attrs, 'type');
const iconName = extract(attrs, 'icon');
@@ -27,9 +27,9 @@ export default class Badge extends Component {
return <span {...attrs}>{iconName ? icon(iconName, { className: 'Badge-icon' }) : m.trust('&nbsp;')}</span>;
}
oncreate(vnode) {
super.oncreate(vnode);
config(isInitialized) {
if (isInitialized) return;
if (this.attrs.label) this.$().tooltip();
if (this.props.label) this.$().tooltip();
}
}

View File

@@ -1,15 +1,12 @@
import Component from '../Component';
import icon from '../helpers/icon';
import classList from '../utils/classList';
import extract from '../utils/extract';
import extractText from '../utils/extractText';
import LoadingIndicator from './LoadingIndicator';
/**
* The `Button` component defines an element which, when clicked, performs an
* action.
*
* ### Attrs
* action. The button may have the following special props:
*
* - `icon` The name of the icon class. If specified, the button will be given a
* 'has-icon' class name.
@@ -18,43 +15,41 @@ import LoadingIndicator from './LoadingIndicator';
* removed.
* - `loading` Whether or not the button should be in a disabled loading state.
*
* All other attrs will be assigned as attributes on the button element.
* All other props will be assigned as attributes on the button element.
*
* Note that a Button has no default class names. This is because a Button can
* be used to represent any generic clickable control, like a menu item.
*/
export default class Button extends Component {
view(vnode) {
const attrs = Object.assign({}, this.attrs);
view() {
const attrs = Object.assign({}, this.props);
delete attrs.children;
attrs.className = attrs.className || '';
attrs.type = attrs.type || 'button';
// If a tooltip was provided for buttons without additional content, we also
// use this tooltip as text for screen readers
if (attrs.title && !vnode.children) {
if (attrs.title && !this.props.children) {
attrs['aria-label'] = attrs.title;
}
// If given a translation object, extract the text.
if (typeof attrs.title === 'object') {
attrs.title = extractText(attrs.title);
}
// If nothing else is provided, we use the textual button content as tooltip
if (!attrs.title && vnode.children) {
attrs.title = extractText(vnode.children);
if (!attrs.title && this.props.children) {
attrs.title = extractText(this.props.children);
}
const iconName = extract(attrs, 'icon');
if (iconName) attrs.className += ' hasIcon';
const loading = extract(attrs, 'loading');
if (attrs.disabled || loading) {
attrs.className += ' disabled' + (loading ? ' loading' : '');
delete attrs.onclick;
}
attrs.className = classList([attrs.className, iconName && 'hasIcon', (attrs.disabled || loading) && 'disabled', loading && 'loading']);
return <button {...attrs}>{this.getButtonContent(vnode.children)}</button>;
return <button {...attrs}>{this.getButtonContent()}</button>;
}
/**
@@ -63,13 +58,13 @@ export default class Button extends Component {
* @return {*}
* @protected
*/
getButtonContent(children) {
const iconName = this.attrs.icon;
getButtonContent() {
const iconName = this.props.icon;
return [
iconName && iconName !== true ? icon(iconName, { className: 'Button-icon' }) : '',
children ? <span className="Button-label">{children}</span> : '',
this.attrs.loading ? <LoadingIndicator size="tiny" className="LoadingIndicator--inline" /> : '',
this.props.children ? <span className="Button-label">{this.props.children}</span> : '',
this.props.loading ? LoadingIndicator.component({ size: 'tiny', className: 'LoadingIndicator--inline' }) : '',
];
}
}

View File

@@ -1,13 +1,11 @@
import Component from '../Component';
import LoadingIndicator from './LoadingIndicator';
import icon from '../helpers/icon';
import classList from '../utils/classList';
import withAttr from '../utils/withAttr';
/**
* The `Checkbox` component defines a checkbox input.
*
* ### Attrs
* ### Props
*
* - `state` Whether or not the checkbox is checked.
* - `className` The class name for the root element.
@@ -17,24 +15,19 @@ import withAttr from '../utils/withAttr';
* - `children` A text label to display next to the checkbox.
*/
export default class Checkbox extends Component {
view(vnode) {
view() {
// Sometimes, false is stored in the DB as '0'. This is a temporary
// conversion layer until a more robust settings encoding is introduced
if (this.attrs.state === '0') this.attrs.state = false;
const className = classList([
'Checkbox',
this.attrs.state ? 'on' : 'off',
this.attrs.className,
this.attrs.loading && 'loading',
this.attrs.disabled && 'disabled',
]);
if (this.props.state === '0') this.props.state = false;
let className = 'Checkbox ' + (this.props.state ? 'on' : 'off') + ' ' + (this.props.className || '');
if (this.props.loading) className += ' loading';
if (this.props.disabled) className += ' disabled';
return (
<label className={className}>
<input type="checkbox" checked={this.attrs.state} disabled={this.attrs.disabled} onchange={withAttr('checked', this.onchange.bind(this))} />
<input type="checkbox" checked={this.props.state} disabled={this.props.disabled} onchange={m.withAttr('checked', this.onchange.bind(this))} />
<div className="Checkbox-display">{this.getDisplay()}</div>
{vnode.children}
{this.props.children}
</label>
);
}
@@ -46,7 +39,7 @@ export default class Checkbox extends Component {
* @protected
*/
getDisplay() {
return this.attrs.loading ? <LoadingIndicator size="tiny" /> : icon(this.attrs.state ? 'fas fa-check' : 'fas fa-times');
return this.props.loading ? LoadingIndicator.component({ size: 'tiny' }) : icon(this.props.state ? 'fas fa-check' : 'fas fa-times');
}
/**
@@ -56,6 +49,6 @@ export default class Checkbox extends Component {
* @protected
*/
onchange(checked) {
if (this.attrs.onchange) this.attrs.onchange(checked, this);
if (this.props.onchange) this.props.onchange(checked, this);
}
}

View File

@@ -5,7 +5,7 @@ import Component from '../Component';
* event handler that prevents closing the browser window/tab based on the
* return value of a given callback prop.
*
* ### Attrs
* ### Props
*
* - `when` - a callback returning true when the browser should prompt for
* confirmation before closing the window/tab
@@ -17,24 +17,21 @@ import Component from '../Component';
*
*/
export default class ConfirmDocumentUnload extends Component {
handler() {
return this.attrs.when() || undefined;
config(isInitialized, context) {
if (isInitialized) return;
const handler = () => this.props.when() || undefined;
$(window).on('beforeunload', handler);
context.onunload = () => {
$(window).off('beforeunload', handler);
};
}
oncreate(vnode) {
super.oncreate(vnode);
this.boundHandler = this.handler.bind(this);
$(window).on('beforeunload', this.boundHandler);
}
onremove() {
$(window).off('beforeunload', this.boundHandler);
}
view(vnode) {
view() {
// To avoid having to render another wrapping <div> here, we assume that
// this component is only wrapped around a single element / component.
return vnode.children[0];
return this.props.children[0];
}
}

View File

@@ -6,7 +6,7 @@ import listItems from '../helpers/listItems';
* The `Dropdown` component displays a button which, when clicked, shows a
* dropdown menu beneath it.
*
* ### Attrs
* ### Props
*
* - `buttonClassName` A class name to apply to the dropdown toggle button.
* - `menuClassName` A class name to apply to the dropdown menu.
@@ -19,33 +19,33 @@ import listItems from '../helpers/listItems';
* The children will be displayed as a list inside of the dropdown menu.
*/
export default class Dropdown extends Component {
static initAttrs(attrs) {
attrs.className = attrs.className || '';
attrs.buttonClassName = attrs.buttonClassName || '';
attrs.menuClassName = attrs.menuClassName || '';
attrs.label = attrs.label || '';
attrs.caretIcon = typeof attrs.caretIcon !== 'undefined' ? attrs.caretIcon : 'fas fa-caret-down';
static initProps(props) {
super.initProps(props);
props.className = props.className || '';
props.buttonClassName = props.buttonClassName || '';
props.menuClassName = props.menuClassName || '';
props.label = props.label || '';
props.caretIcon = typeof props.caretIcon !== 'undefined' ? props.caretIcon : 'fas fa-caret-down';
}
oninit(vnode) {
super.oninit(vnode);
init() {
this.showing = false;
}
view(vnode) {
const items = vnode.children ? listItems(vnode.children) : [];
view() {
const items = this.props.children ? listItems(this.props.children) : [];
return (
<div className={'ButtonGroup Dropdown dropdown ' + this.attrs.className + ' itemCount' + items.length + (this.showing ? ' open' : '')}>
{this.getButton(vnode.children)}
<div className={'ButtonGroup Dropdown dropdown ' + this.props.className + ' itemCount' + items.length + (this.showing ? ' open' : '')}>
{this.getButton()}
{this.getMenu(items)}
</div>
);
}
oncreate(vnode) {
super.oncreate(vnode);
config(isInitialized) {
if (isInitialized) return;
// When opening the dropdown menu, work out if the menu goes beyond the
// bottom of the viewport. If it does, we will apply class to make it show
@@ -53,8 +53,8 @@ export default class Dropdown extends Component {
this.$().on('shown.bs.dropdown', () => {
this.showing = true;
if (this.attrs.onshow) {
this.attrs.onshow();
if (this.props.onshow) {
this.props.onshow();
}
m.redraw();
@@ -76,8 +76,8 @@ export default class Dropdown extends Component {
this.$().on('hidden.bs.dropdown', () => {
this.showing = false;
if (this.attrs.onhide) {
this.attrs.onhide();
if (this.props.onhide) {
this.props.onhide();
}
m.redraw();
@@ -90,10 +90,10 @@ export default class Dropdown extends Component {
* @return {*}
* @protected
*/
getButton(children) {
getButton() {
return (
<button className={'Dropdown-toggle ' + this.attrs.buttonClassName} data-toggle="dropdown" onclick={this.attrs.onclick}>
{this.getButtonContent(children)}
<button className={'Dropdown-toggle ' + this.props.buttonClassName} data-toggle="dropdown" onclick={this.props.onclick}>
{this.getButtonContent()}
</button>
);
}
@@ -104,15 +104,15 @@ export default class Dropdown extends Component {
* @return {*}
* @protected
*/
getButtonContent(children) {
getButtonContent() {
return [
this.attrs.icon ? icon(this.attrs.icon, { className: 'Button-icon' }) : '',
<span className="Button-label">{this.attrs.label}</span>,
this.attrs.caretIcon ? icon(this.attrs.caretIcon, { className: 'Button-caret' }) : '',
this.props.icon ? icon(this.props.icon, { className: 'Button-icon' }) : '',
<span className="Button-label">{this.props.label}</span>,
this.props.caretIcon ? icon(this.props.caretIcon, { className: 'Button-caret' }) : '',
];
}
getMenu(items) {
return <ul className={'Dropdown-menu dropdown-menu ' + this.attrs.menuClassName}>{items}</ul>;
return <ul className={'Dropdown-menu dropdown-menu ' + this.props.menuClassName}>{items}</ul>;
}
}

View File

@@ -11,11 +11,11 @@ import listItems from '../helpers/listItems';
* The children should be an array of items to show in the fieldset.
*/
export default class FieldSet extends Component {
view(vnode) {
view() {
return (
<fieldset className={this.attrs.className}>
<legend>{this.attrs.label}</legend>
<ul>{listItems(vnode.children)}</ul>
<fieldset className={this.props.className}>
<legend>{this.props.label}</legend>
<ul>{listItems(this.props.children)}</ul>
</fieldset>
);
}

View File

@@ -1,16 +1,16 @@
import Badge from './Badge';
export default class GroupBadge extends Badge {
static initAttrs(attrs) {
super.initAttrs(attrs);
static initProps(props) {
super.initProps(props);
if (attrs.group) {
attrs.icon = attrs.group.icon();
attrs.style = { backgroundColor: attrs.group.color() };
attrs.label = typeof attrs.label === 'undefined' ? attrs.group.nameSingular() : attrs.label;
attrs.type = 'group--' + attrs.group.id();
if (props.group) {
props.icon = props.group.icon();
props.style = { backgroundColor: props.group.color() };
props.label = typeof props.label === 'undefined' ? props.group.nameSingular() : props.label;
props.type = 'group--' + props.group.id();
delete attrs.group;
delete props.group;
}
}
}

View File

@@ -1,47 +0,0 @@
import Component from '../Component';
import extract from '../utils/extract';
/**
* The link component enables both internal and external links.
* It will return a regular HTML link for any links to external sites,
* and it will use Mithril's m.route.Link for any internal links.
*
* Links will default to internal; the 'external' attr must be set to
* `true` for the link to be external.
*/
export default class Link extends Component {
view(vnode) {
let { options = {}, ...attrs } = vnode.attrs;
attrs.href = attrs.href || '';
// For some reason, m.route.Link does not like vnode.text, so if present, we
// need to convert it to text vnodes and store it in children.
const children = vnode.children || { tag: '#', children: vnode.text };
if (attrs.external) {
return <a {...attrs}>{children}</a>;
}
// If the href URL of the link is the same as the current page path
// we will not add a new entry to the browser history.
// This allows us to still refresh the Page component
// without adding endless history entries.
if (attrs.href === m.route.get()) {
if (!('replace' in options)) options.replace = true;
}
// Mithril 2 does not completely rerender the page if a route change leads to the same route
// (or the same component handling a different route).
// Here, the `force` parameter will use Mithril's key system to force a full rerender
// see https://mithril.js.org/route.html#key-parameter
if (extract(attrs, 'force')) {
if (!('state' in options)) options.state = {};
if (!('key' in options.state)) options.state.key = Date.now();
}
attrs.options = options;
return <m.route.Link {...attrs}>{children}</m.route.Link>;
}
}

View File

@@ -1,43 +1,38 @@
import Button from './Button';
import Link from './Link';
/**
* The `LinkButton` component defines a `Button` which links to a route.
*
* ### Attrs
* ### Props
*
* All of the attrs accepted by `Button`, plus:
* All of the props accepted by `Button`, plus:
*
* - `active` Whether or not the page that this button links to is currently
* active.
* - `href` The URL to link to. If the current URL `m.route()` matches this,
* the `active` prop will automatically be set to true.
* - `force` Whether the page should be fully rerendered. Defaults to `true`.
*/
export default class LinkButton extends Button {
static initAttrs(attrs) {
super.initAttrs(attrs);
attrs.active = this.isActive(attrs);
if (attrs.force === undefined) attrs.force = true;
static initProps(props) {
props.active = this.isActive(props);
props.config = props.config || m.route;
}
view(vnode) {
const vdom = super.view(vnode);
view() {
const vdom = super.view();
vdom.tag = Link;
vdom.attrs.active = String(vdom.attrs.active);
vdom.tag = 'a';
return vdom;
}
/**
* Determine whether a component with the given attrs is 'active'.
* Determine whether a component with the given props is 'active'.
*
* @param {Object} attrs
* @param {Object} props
* @return {Boolean}
*/
static isActive(attrs) {
return typeof attrs.active !== 'undefined' ? attrs.active : m.route.get() === attrs.href;
static isActive(props) {
return typeof props.active !== 'undefined' ? props.active : m.route() === props.href;
}
}

View File

@@ -2,17 +2,16 @@ import Component from '../Component';
import { Spinner } from 'spin.js';
/**
* The `LoadingIndicator` component displays a loading spinner with spin.js.
*
* ### Attrs
* The `LoadingIndicator` component displays a loading spinner with spin.js. It
* may have the following special props:
*
* - `size` The spin.js size preset to use. Defaults to 'small'.
*
* All other attrs will be assigned as attributes on the DOM element.
* All other props will be assigned as attributes on the element.
*/
export default class LoadingIndicator extends Component {
view() {
const attrs = Object.assign({}, this.attrs);
const attrs = Object.assign({}, this.props);
attrs.className = 'LoadingIndicator ' + (attrs.className || '');
delete attrs.size;
@@ -20,12 +19,12 @@ export default class LoadingIndicator extends Component {
return <div {...attrs}>{m.trust('&nbsp;')}</div>;
}
oncreate(vnode) {
super.oncreate(vnode);
config(isInitialized) {
if (isInitialized) return;
const options = { zIndex: 'auto', color: this.$().css('color') };
switch (this.attrs.size) {
switch (this.props.size) {
case 'large':
Object.assign(options, { lines: 10, length: 8, width: 4, radius: 8 });
break;

View File

@@ -14,30 +14,23 @@ export default class Modal extends Component {
*/
static isDismissible = true;
/**
* Attributes for an alert component to show below the header.
*
* @type {object}
*/
alertAttrs = null;
oncreate(vnode) {
super.oncreate(vnode);
this.attrs.animateShow(() => this.onready());
init() {
/**
* Attributes for an alert component to show below the header.
*
* @type {object}
*/
this.alertAttrs = null;
}
onbeforeremove() {
// If the global modal state currently contains a modal,
// we've just opened up a new one, and accordingly,
// we don't need to show a hide animation.
if (!this.attrs.state.modal) {
this.attrs.animateHide();
// Here, we ensure that the animation has time to complete.
// See https://mithril.js.org/lifecycle-methods.html#onbeforeremove
// Bootstrap's Modal.TRANSITION_DURATION is 300 ms.
return new Promise((resolve) => setTimeout(resolve, 300));
}
config(isInitialized, context) {
if (isInitialized) return;
this.props.onshow(() => this.onready());
context.onunload = () => {
this.props.onhide();
};
}
view() {
@@ -112,11 +105,13 @@ export default class Modal extends Component {
this.$('form').find('input, select, textarea').first().focus().select();
}
onhide() {}
/**
* Hide the modal.
*/
hide() {
this.attrs.state.close();
this.props.onhide();
}
/**

View File

@@ -6,42 +6,36 @@ import Component from '../Component';
* overwrite the previous one.
*/
export default class ModalManager extends Component {
init() {
this.state = this.props.state;
}
view() {
const modal = this.attrs.state.modal;
const modal = this.state.modal;
return (
<div className="ModalManager modal fade">
{modal
? modal.componentClass.component({
...modal.attrs,
animateShow: this.animateShow.bind(this),
animateHide: this.animateHide.bind(this),
state: this.attrs.state,
})
: ''}
{modal ? modal.componentClass.component({ ...modal.attrs, onshow: this.animateShow.bind(this), onhide: this.animateHide.bind(this) }) : ''}
</div>
);
}
oncreate(vnode) {
super.oncreate(vnode);
config(isInitialized, context) {
if (isInitialized) return;
// Since this component is 'above' the content of the page (that is, it is a
// part of the global UI that persists between routes), we will flag the DOM
// to be retained across route changes.
context.retain = true;
// Ensure the modal state is notified about a closed modal, even when the
// DOM-based Bootstrap JavaScript code triggered the closing of the modal,
// e.g. via ESC key or a click on the modal backdrop.
this.$().on('hidden.bs.modal', this.attrs.state.close.bind(this.attrs.state));
this.$().on('hidden.bs.modal', this.state.close.bind(this.state));
}
animateShow(readyCallback) {
const dismissible = !!this.attrs.state.modal.componentClass.isDismissible;
// If we are opening this modal while another modal is already open,
// the shown event will not run, because the modal is already open.
// So, we need to manually trigger the readyCallback.
if (this.$().hasClass('in')) {
readyCallback();
return;
}
const dismissible = !!this.state.modal.componentClass.isDismissible;
this.$()
.one('shown.bs.modal', readyCallback)

View File

@@ -11,7 +11,7 @@ import LinkButton from './LinkButton';
* If the app has a pane, it will also include a 'pin' button which toggles the
* pinned state of the pane.
*
* Accepts the following attrs:
* Accepts the following props:
*
* - `className` The name of a class to set on the root element.
* - `drawer` Whether or not to show a button to toggle the app's drawer if
@@ -23,7 +23,7 @@ export default class Navigation extends Component {
return (
<div
className={'Navigation ButtonGroup ' + (this.attrs.className || '')}
className={'Navigation ButtonGroup ' + (this.props.className || '')}
onmouseenter={pane && pane.show.bind(pane)}
onmouseleave={pane && pane.onmouseleave.bind(pane)}
>
@@ -32,6 +32,13 @@ export default class Navigation extends Component {
);
}
config(isInitialized, context) {
// Since this component is 'above' the content of the page (that is, it is a
// part of the global UI that persists between routes), we will flag the DOM
// to be retained across route changes.
context.retain = true;
}
/**
* Get the back button.
*
@@ -47,6 +54,7 @@ export default class Navigation extends Component {
href: history.backUrl(),
icon: 'fas fa-chevron-left',
title: previous.title,
config: () => {},
onclick: (e) => {
if (e.shiftKey || e.ctrlKey || e.metaKey || e.which === 2) return;
e.preventDefault();
@@ -80,7 +88,7 @@ export default class Navigation extends Component {
* @protected
*/
getDrawerButton() {
if (!this.attrs.drawer) return '';
if (!this.props.drawer) return '';
const { drawer } = app;
const user = app.session.user;

View File

@@ -7,11 +7,9 @@ import PageState from '../states/PageState';
* @abstract
*/
export default class Page extends Component {
oninit(vnode) {
super.oninit(vnode);
init() {
app.previous = app.current;
app.current = new PageState(this.constructor, { routeName: this.attrs.routeName });
app.current = new PageState(this.constructor);
app.drawer.hide();
app.modal.close();
@@ -22,41 +20,15 @@ export default class Page extends Component {
* @type {String}
*/
this.bodyClass = '';
/**
* Whether we should scroll to the top of the page when its rendered.
*
* @type {Boolean}
*/
this.scrollTopOnCreate = true;
/**
* Whether the browser should restore scroll state on refreshes.
*
* @type {Boolean}
*/
this.useBrowserScrollRestoration = true;
}
oncreate(vnode) {
super.oncreate(vnode);
config(isInitialized, context) {
if (isInitialized) return;
if (this.bodyClass) {
$('#app').addClass(this.bodyClass);
}
if (this.scrollTopOnCreate) {
$(window).scrollTop(0);
}
if ('scrollRestoration' in history) {
history.scrollRestoration = this.useBrowserScrollRestoration ? 'auto' : 'manual';
}
}
onremove() {
if (this.bodyClass) {
$('#app').removeClass(this.bodyClass);
context.onunload = () => $('#app').removeClass(this.bodyClass);
}
}
}

View File

@@ -4,7 +4,7 @@ import Component from '../Component';
* The `Placeholder` component displays a muted text with some call to action,
* usually used as an empty state.
*
* ### Attrs
* ### Props
*
* - `text`
*/
@@ -12,7 +12,7 @@ export default class Placeholder extends Component {
view() {
return (
<div className="Placeholder">
<p>{this.attrs.text}</p>
<p>{this.props.text}</p>
</div>
);
}

View File

@@ -6,11 +6,11 @@ export default class RequestErrorModal extends Modal {
}
title() {
return this.attrs.error.xhr ? `${this.attrs.error.xhr.status} ${this.attrs.error.xhr.statusText}` : '';
return this.props.error.xhr ? `${this.props.error.xhr.status} ${this.props.error.xhr.statusText}` : '';
}
content() {
const { error, formattedError } = this.attrs;
const { error, formattedError } = this.props;
let responseText;
@@ -31,7 +31,7 @@ export default class RequestErrorModal extends Modal {
return (
<div className="Modal-body">
<pre>
{this.attrs.error.options.method} {this.attrs.error.options.url}
{this.props.error.options.method} {this.props.error.options.url}
<br />
<br />
{responseText}

View File

@@ -1,10 +1,9 @@
import Component from '../Component';
import icon from '../helpers/icon';
import withAttr from '../utils/withAttr';
/**
* The `Select` component displays a <select> input, surrounded with some extra
* elements for styling. It accepts the following attrs:
* elements for styling. It accepts the following props:
*
* - `options` A map of option values to labels.
* - `onchange` A callback to run when the selected value is changed.
@@ -13,13 +12,13 @@ import withAttr from '../utils/withAttr';
*/
export default class Select extends Component {
view() {
const { options, onchange, value, disabled } = this.attrs;
const { options, onchange, value, disabled } = this.props;
return (
<span className="Select">
<select
className="Select-input FormControl"
onchange={onchange ? withAttr('value', onchange.bind(this)) : undefined}
onchange={onchange ? m.withAttr('value', onchange.bind(this)) : undefined}
value={value}
disabled={disabled}
>

View File

@@ -1,52 +1,31 @@
import Dropdown from './Dropdown';
import icon from '../helpers/icon';
/**
* Determines via a vnode is currently "active".
* Due to changes in Mithril 2, attrs will not be instantiated until AFTER view()
* is initially called on the parent component, so we can not always depend on the
* active attr to determine which element should be displayed as the "active child".
*
* This is a temporary patch, and as so, is not exported / placed in utils.
*/
function isActive(vnode) {
const tag = vnode.tag;
// Allow non-selectable dividers/headers to be added.
if (typeof tag === 'string' && tag !== 'a' && tag !== 'button') return false;
if ('initAttrs' in tag) {
tag.initAttrs(vnode.attrs);
}
return 'isActive' in tag ? tag.isActive(vnode.attrs) : vnode.attrs.active;
}
/**
* The `SelectDropdown` component is the same as a `Dropdown`, except the toggle
* button's label is set as the label of the first child which has a truthy
* `active` prop.
*
* ### Attrs
* ### Props
*
* - `caretIcon`
* - `defaultLabel`
*/
export default class SelectDropdown extends Dropdown {
static initAttrs(attrs) {
attrs.caretIcon = typeof attrs.caretIcon !== 'undefined' ? attrs.caretIcon : 'fas fa-sort';
static initProps(props) {
props.caretIcon = typeof props.caretIcon !== 'undefined' ? props.caretIcon : 'fas fa-sort';
super.initAttrs(attrs);
super.initProps(props);
attrs.className += ' Dropdown--select';
props.className += ' Dropdown--select';
}
getButtonContent(children) {
const activeChild = children.find(isActive);
let label = (activeChild && activeChild.children) || this.attrs.defaultLabel;
getButtonContent() {
const activeChild = this.props.children.filter((child) => child.props.active)[0];
let label = (activeChild && activeChild.props.children) || this.props.defaultLabel;
if (label instanceof Array) label = label[0];
return [<span className="Button-label">{label}</span>, icon(this.attrs.caretIcon, { className: 'Button-caret' })];
return [<span className="Button-label">{label}</span>, icon(this.props.caretIcon, { className: 'Button-caret' })];
}
}

View File

@@ -7,25 +7,25 @@ import icon from '../helpers/icon';
* is displayed as its own button prior to the toggle button.
*/
export default class SplitDropdown extends Dropdown {
static initAttrs(attrs) {
super.initAttrs(attrs);
static initProps(props) {
super.initProps(props);
attrs.className += ' Dropdown--split';
attrs.menuClassName += ' Dropdown-menu--right';
props.className += ' Dropdown--split';
props.menuClassName += ' Dropdown-menu--right';
}
getButton(children) {
// Make a copy of the attrs of the first child component. We will assign
// these attrs to a new button, so that it has exactly the same behaviour as
getButton() {
// Make a copy of the props of the first child component. We will assign
// these props to a new button, so that it has exactly the same behaviour as
// the first child.
const firstChild = this.getFirstChild(children);
const buttonAttrs = Object.assign({}, firstChild.attrs);
buttonAttrs.className = (buttonAttrs.className || '') + ' SplitDropdown-button Button ' + this.attrs.buttonClassName;
const firstChild = this.getFirstChild();
const buttonProps = Object.assign({}, firstChild.props);
buttonProps.className = (buttonProps.className || '') + ' SplitDropdown-button Button ' + this.props.buttonClassName;
return [
Button.component(buttonAttrs, firstChild.children),
<button className={'Dropdown-toggle Button Button--icon ' + this.attrs.buttonClassName} data-toggle="dropdown">
{icon(this.attrs.icon, { className: 'Button-icon' })}
Button.component(buttonProps),
<button className={'Dropdown-toggle Button Button--icon ' + this.props.buttonClassName} data-toggle="dropdown">
{icon(this.props.icon, { className: 'Button-icon' })}
{icon('fas fa-caret-down', { className: 'Button-caret' })}
</button>,
];
@@ -38,8 +38,8 @@ export default class SplitDropdown extends Dropdown {
* @return {*}
* @protected
*/
getFirstChild(children) {
let firstChild = children;
getFirstChild() {
let firstChild = this.props.children;
while (firstChild instanceof Array) firstChild = firstChild[0];

View File

@@ -5,13 +5,13 @@ import Checkbox from './Checkbox';
* a tick/cross one.
*/
export default class Switch extends Checkbox {
static initAttrs(attrs) {
super.initAttrs(attrs);
static initProps(props) {
super.initProps(props);
attrs.className = (attrs.className || '') + ' Checkbox--switch';
props.className = (props.className || '') + ' Checkbox--switch';
}
getDisplay() {
return this.attrs.loading ? super.getDisplay() : '';
return this.props.loading ? super.getDisplay() : '';
}
}

View File

@@ -25,7 +25,7 @@ export default function avatar(user, attrs = {}) {
if (hasTitle) attrs.title = attrs.title || username;
if (avatarUrl) {
return <img {...attrs} src={avatarUrl} alt="" />;
return <img {...attrs} src={avatarUrl} />;
}
content = username.charAt(0).toUpperCase();

View File

@@ -1,11 +1,11 @@
import dayjs from 'dayjs';
import * as Mithril from 'mithril';
/**
* The `fullTime` helper displays a formatted time string wrapped in a <time>
* tag.
*
* @param {Date} time
* @return {Object}
*/
export default function fullTime(time: Date): Mithril.Vnode {
export default function fullTime(time) {
const d = dayjs(time);
const datetime = d.format();

View File

@@ -1,13 +1,14 @@
import dayjs from 'dayjs';
import * as Mithril from 'mithril';
import humanTimeUtil from '../utils/humanTime';
/**
* The `humanTime` helper displays a time in a human-friendly time-ago format
* (e.g. '12 days ago'), wrapped in a <time> tag with other information about
* the time.
*
* @param {Date} time
* @return {Object}
*/
export default function humanTime(time: Date): Mithril.Vnode {
export default function humanTime(time) {
const d = dayjs(time);
const datetime = d.format();

View File

@@ -0,0 +1,12 @@
/**
* The `icon` helper displays an icon.
*
* @param {String} fontClass The full icon class, prefix and the icons 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} />;
}

View File

@@ -1,13 +0,0 @@
import * as Mithril from 'mithril';
/**
* The `icon` helper displays an icon.
*
* @param fontClass The full icon class, prefix and the icons 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} />;
}

View File

@@ -2,14 +2,14 @@ import Separator from '../components/Separator';
import classList from '../utils/classList';
function isSeparator(item) {
return item.tag === Separator;
return item && item.component === Separator;
}
function withoutUnnecessarySeparators(items) {
const newItems = [];
let prevItem;
items.filter(Boolean).forEach((item, i) => {
items.forEach((item, i) => {
if (!isSeparator(item) || (prevItem && !isSeparator(prevItem) && i !== items.length - 1)) {
prevItem = item;
newItems.push(item);
@@ -30,27 +30,21 @@ export default function listItems(items) {
if (!(items instanceof Array)) items = [items];
return withoutUnnecessarySeparators(items).map((item) => {
const isListItem = item.tag && item.tag.isListItem;
const active = item.tag && item.tag.isActive && item.tag.isActive(item.attrs);
const className = (item.attrs && item.attrs.itemClassName) || item.itemClassName;
const isListItem = item.component && item.component.isListItem;
const active = item.component && item.component.isActive && item.component.isActive(item.props);
const className = item.props ? item.props.itemClassName : item.itemClassName;
if (isListItem) {
item.attrs = item.attrs || {};
item.attrs.key = item.attrs.key || item.itemName;
item.key = item.attrs.key;
}
const node = isListItem ? (
return isListItem ? (
item
) : (
<li
className={classList([className, item.itemName && `item-${item.itemName}`, active && 'active'])}
key={(item.attrs && item.attrs.key) || item.itemName}
>
<li className={classList([item.itemName ? 'item-' + item.itemName : '', className, active ? 'active' : ''])} key={item.itemName}>
{item}
</li>
);
return node;
});
}

View File

@@ -1,6 +1,6 @@
import 'expose-loader?$!expose-loader?jQuery!jquery';
import 'expose-loader?m!mithril';
import 'expose-loader?dayjs!dayjs';
import 'expose-loader?moment!expose-loader?dayjs!dayjs';
import 'expose-loader?m.bidi!m.attrs.bidi';
import 'bootstrap/js/affix';
import 'bootstrap/js/dropdown';

View File

@@ -1,41 +0,0 @@
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() }];
}
}

View File

@@ -0,0 +1,50 @@
import Alert from '../components/Alert';
export default class AlertManagerState {
constructor() {
this.activeAlerts = {};
this.alertId = 0;
}
getActiveAlerts() {
return this.activeAlerts;
}
/**
* Show an Alert in the alerts area.
*/
show(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] = { 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();
}
}

View File

@@ -1,80 +0,0 @@
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();
}
}

View File

@@ -32,7 +32,7 @@ export default class ModalManagerState {
this.modal = { componentClass, attrs };
m.redraw.sync();
m.redraw(true);
}
/**
@@ -50,7 +50,7 @@ export default class ModalManagerState {
// ahead.
this.closeTimeout = setTimeout(() => {
this.modal = null;
m.redraw();
m.lazyRedraw();
});
}
}

View File

@@ -58,7 +58,7 @@ export default class ScrollListener {
*/
start() {
if (!this.active) {
window.addEventListener('scroll', (this.active = this.loop.bind(this)), { passive: true });
window.addEventListener('scroll', (this.active = this.loop.bind(this)));
}
}

View File

@@ -1,3 +0,0 @@
import Stream from 'mithril/stream';
export default Stream;

View File

@@ -1,25 +1,20 @@
/**
* The `SubtreeRetainer` class keeps track of a number of pieces of data,
* comparing the values of these pieces at every iteration.
*
* This is useful for preventing redraws to relatively static (or huge)
* components whose VDOM only depends on very few values, when none of them
* have changed.
* The `SubtreeRetainer` class represents a Mithril virtual DOM subtree. It
* keeps track of a number of pieces of data, allowing the subtree to be
* retained if none of them have changed.
*
* @example
* // Check two callbacks for changes on each update
* // constructor
* this.subtree = new SubtreeRetainer(
* () => this.attrs.post.freshness,
* () => this.props.post.freshness,
* () => this.showing
* );
* this.subtree.check(() => this.props.user.freshness);
*
* // Add more callbacks to be checked for updates
* this.subtree.check(() => this.attrs.user.freshness);
* // view
* this.subtree.retain() || 'expensive expression'
*
* // In a component's onbeforeupdate() method:
* return this.subtree.needsRebuild()
*
* @see https://mithril.js.org/lifecycle-methods.html#onbeforeupdate
* @see https://lhorie.github.io/mithril/mithril.html#persisting-dom-elements-across-route-changes
*/
export default class SubtreeRetainer {
/**
@@ -28,19 +23,16 @@ export default class SubtreeRetainer {
constructor(...callbacks) {
this.callbacks = callbacks;
this.data = {};
// Build the initial data, so it is available when calling
// needsRebuild from the onbeforeupdate hook.
this.needsRebuild();
}
/**
* Return whether any data has changed since the last check.
* If so, Mithril needs to re-diff the vnode and its children.
* Return a virtual DOM directive that will retain a subtree if no data has
* changed since the last check.
*
* @return {boolean}
* @return {Object|false}
* @public
*/
needsRebuild() {
retain() {
let needsRebuild = false;
this.callbacks.forEach((callback, i) => {
@@ -52,7 +44,7 @@ export default class SubtreeRetainer {
}
});
return needsRebuild;
return needsRebuild ? false : { subtree: 'retain' };
}
/**
@@ -63,8 +55,6 @@ export default class SubtreeRetainer {
*/
check(...callbacks) {
this.callbacks = this.callbacks.concat(callbacks);
// Update the data cache when new checks are added.
this.needsRebuild();
}
/**

View File

@@ -22,8 +22,6 @@ export default class SuperTextarea {
*/
setValue(value) {
this.$.val(value).trigger('input');
this.el.dispatchEvent(new CustomEvent('input', { bubbles: true, cancelable: true }));
}
/**
@@ -51,6 +49,8 @@ export default class SuperTextarea {
*/
insertAtCursor(text) {
this.insertAt(this.el.selectionStart, text);
this.el.dispatchEvent(new CustomEvent('input', { bubbles: true, cancelable: true }));
}
/**

View File

@@ -10,7 +10,7 @@ export default function abbreviateNumber(number: number): string {
if (number >= 1000000) {
return Math.floor(number / 1000000) + app.translator.trans('core.lib.number_suffix.mega_text');
} else if (number >= 1000) {
return (number / 1000).toFixed(1) + app.translator.trans('core.lib.number_suffix.kilo_text');
return Math.floor(number / 1000) + app.translator.trans('core.lib.number_suffix.kilo_text');
} else {
return number.toString();
}

View File

@@ -8,7 +8,7 @@ export default function extractText(vdom) {
if (vdom instanceof Array) {
return vdom.map((element) => extractText(element)).join('');
} else if (typeof vdom === 'object' && vdom !== null) {
return vdom.children ? extractText(vdom.children) : vdom.text;
return extractText(vdom.children);
} else {
return vdom;
}

View File

@@ -1,6 +1,3 @@
import dayjs from 'dayjs';
import 'dayjs/plugin/relativeTime';
/**
* The `humanTime` utility converts a date to a localized, human-readable time-
* ago string.

View File

@@ -1,11 +1,8 @@
import DefaultResolver from '../resolvers/DefaultResolver';
/**
* The `mapRoutes` utility converts a map of named application routes into a
* format that can be understood by Mithril, and wraps them in route resolvers
* to provide each route with the current route name.
* format that can be understood by Mithril.
*
* @see https://mithril.js.org/route.html#signature
* @see https://lhorie.github.io/mithril/mithril.route.html#defining-routes
* @param {Object} routes
* @param {String} [basePath]
* @return {Object}
@@ -13,17 +10,12 @@ import DefaultResolver from '../resolvers/DefaultResolver';
export default function mapRoutes(routes, basePath = '') {
const map = {};
for (const routeName in routes) {
const route = routes[routeName];
for (const key in routes) {
const route = routes[key];
if ('resolver' in route) {
map[basePath + route.path] = route.resolver;
} else if ('component' in route) {
const resolverClass = 'resolverClass' in route ? route.resolverClass : DefaultResolver;
map[basePath + route.path] = new resolverClass(route.component, routeName);
} else {
throw new Error(`Either a resolver or a component must be provided for the route [${routeName}]`);
}
if (route.component) route.component.props.routeName = key;
map[basePath + route.path] = route.component;
}
return map;

View File

@@ -1,20 +1,45 @@
import Component from '../Component';
export default function patchMithril(global) {
const defaultMithril = global.m;
const mo = global.m;
const modifiedMithril = function (comp, ...args) {
const node = defaultMithril.apply(this, arguments);
const m = function (comp, ...args) {
if (comp.prototype && comp.prototype instanceof Component) {
let children = args.slice(1);
if (children.length === 1 && Array.isArray(children[0])) {
children = children[0];
}
if (!node.attrs) node.attrs = {};
return comp.component(args[0], children);
}
const node = mo.apply(this, arguments);
// Allows the use of the bidi attr.
if (node.attrs.bidi) {
modifiedMithril.bidi(node, node.attrs.bidi);
m.bidi(node, node.attrs.bidi);
}
if (node.attrs.route) {
node.attrs.href = node.attrs.route;
node.attrs.config = m.route;
delete node.attrs.route;
}
return node;
};
Object.keys(defaultMithril).forEach((key) => (modifiedMithril[key] = defaultMithril[key]));
Object.keys(mo).forEach((key) => (m[key] = mo[key]));
global.m = modifiedMithril;
/**
* Redraw only if not in the middle of a computation (e.g. a route change).
*
* @return {void}
*/
m.lazyRedraw = function () {
m.startComputation();
m.endComputation();
};
global.m = m;
}

View File

@@ -1,15 +0,0 @@
import Mithril from 'mithril';
/**
* Mithril 2 does not completely rerender the page if a route change leads to the same route
* (or the same component handling a different route). This util calls m.route.set, forcing a reonit.
*
* @see https://mithril.js.org/route.html#key-parameter
*/
export default function setRouteWithForcedRefresh(route: string, params = null, options: Mithril.RouteOptions = {}) {
const newOptions = { ...options };
newOptions.state = newOptions.state || {};
newOptions.state.key = Date.now();
m.route.set(route, params, newOptions);
}

View File

@@ -1,15 +0,0 @@
/**
* An event handler factory that makes it simpler to implement data binding
* for component event listeners.
*
* The handler created by this factory passes the DOM element's attribute
* identified by the first argument to the callback (usually a bidirectional
* Mithril stream: https://mithril.js.org/stream.html#bidirectional-bindings).
*
* Replaces m.withAttr for Mithril 2.0.
* @see https://mithril.js.org/archive/v0.2.5/mithril.withAttr.html
*/
export default (key: string, cb: Function) =>
function (this: Element) {
cb(this.getAttribute(key) || this[key]);
};

View File

@@ -90,6 +90,11 @@ export default class ForumApplication extends Application {
* @type {DiscussionListState}
*/
this.discussions = new DiscussionListState({}, this);
/**
* @deprecated beta 14, remove in beta 15.
*/
this.cache.discussionList = this.discussions;
}
/**
@@ -110,19 +115,17 @@ export default class ForumApplication extends Application {
this.routes[defaultAction].path = '/';
this.history.push(defaultAction, this.translator.trans('core.forum.header.back_to_index_tooltip'), '/');
m.mount(document.getElementById('app-navigation'), Navigation.component({ className: 'App-backControl', drawer: true }));
m.mount(document.getElementById('header-navigation'), Navigation.component());
m.mount(document.getElementById('header-primary'), HeaderPrimary.component());
m.mount(document.getElementById('header-secondary'), HeaderSecondary.component());
m.mount(document.getElementById('composer'), Composer.component({ state: this.composer }));
this.pane = new Pane(document.getElementById('app'));
m.route.prefix = '';
m.route.mode = 'pathname';
super.mount(this.forum.attribute('basePath'));
// We mount navigation and header components after the page, so components
// like the back button can access the updated state when rendering.
m.mount(document.getElementById('app-navigation'), { view: () => Navigation.component({ className: 'App-backControl', drawer: true }) });
m.mount(document.getElementById('header-navigation'), Navigation);
m.mount(document.getElementById('header-primary'), HeaderPrimary);
m.mount(document.getElementById('header-secondary'), HeaderSecondary);
m.mount(document.getElementById('composer'), { view: () => Composer.component({ state: this.composer }) });
alertEmailConfirmation(this);
// Route the home link back home when clicked. We do not want it to register
@@ -158,8 +161,8 @@ export default class ForumApplication extends Application {
* will be reloaded. Otherwise, a SignUpModal will be opened, prefilled
* with the provided details.
*
* @param {Object} payload A dictionary of attrs to pass into the sign up
* modal. A truthy `loggedIn` attr indicates that the user has logged
* @param {Object} payload A dictionary of props to pass into the sign up
* modal. A truthy `loggedIn` prop indicates that the user has logged
* in, and thus the page is reloaded.
* @public
*/

View File

@@ -3,18 +3,16 @@ import compat from '../common/compat';
import PostControls from './utils/PostControls';
import KeyboardNavigatable from './utils/KeyboardNavigatable';
import slidable from './utils/slidable';
import affixSidebar from './utils/affixSidebar';
import History from './utils/History';
import DiscussionControls from './utils/DiscussionControls';
import alertEmailConfirmation from './utils/alertEmailConfirmation';
import UserControls from './utils/UserControls';
import Pane from './utils/Pane';
import ComposerState from './states/ComposerState';
import DiscussionListState from './states/DiscussionListState';
import GlobalSearchState from './states/GlobalSearchState';
import NotificationListState from './states/NotificationListState';
import PostStreamState from './states/PostStreamState';
import SearchState from './states/SearchState';
import AffixedSidebar from './components/AffixedSidebar';
import DiscussionPage from './components/DiscussionPage';
import LogInModal from './components/LogInModal';
import ComposerBody from './components/ComposerBody';
@@ -61,7 +59,6 @@ import NotificationList from './components/NotificationList';
import WelcomeHero from './components/WelcomeHero';
import SignUpModal from './components/SignUpModal';
import CommentPost from './components/CommentPost';
import ComposerPostPreview from './components/ComposerPostPreview';
import ReplyComposer from './components/ReplyComposer';
import NotificationsPage from './components/NotificationsPage';
import PostStreamScrubber from './components/PostStreamScrubber';
@@ -71,7 +68,6 @@ import Search from './components/Search';
import DiscussionListItem from './components/DiscussionListItem';
import LoadingPost from './components/LoadingPost';
import PostsUserPage from './components/PostsUserPage';
import DiscussionPageResolver from './resolvers/DiscussionPageResolver';
import routes from './routes';
import ForumApplication from './ForumApplication';
@@ -79,18 +75,16 @@ export default Object.assign(compat, {
'utils/PostControls': PostControls,
'utils/KeyboardNavigatable': KeyboardNavigatable,
'utils/slidable': slidable,
'utils/affixSidebar': affixSidebar,
'utils/History': History,
'utils/DiscussionControls': DiscussionControls,
'utils/alertEmailConfirmation': alertEmailConfirmation,
'utils/UserControls': UserControls,
'utils/Pane': Pane,
'states/ComposerState': ComposerState,
'states/DiscussionListState': DiscussionListState,
'states/GlobalSearchState': GlobalSearchState,
'states/NotificationListState': NotificationListState,
'states/PostStreamState': PostStreamState,
'states/SearchState': SearchState,
'components/AffixedSidebar': AffixedSidebar,
'components/DiscussionPage': DiscussionPage,
'components/LogInModal': LogInModal,
'components/ComposerBody': ComposerBody,
@@ -137,7 +131,6 @@ export default Object.assign(compat, {
'components/WelcomeHero': WelcomeHero,
'components/SignUpModal': SignUpModal,
'components/CommentPost': CommentPost,
'components/ComposerPostPreview': ComposerPostPreview,
'components/ReplyComposer': ReplyComposer,
'components/NotificationsPage': NotificationsPage,
'components/PostStreamScrubber': PostStreamScrubber,
@@ -147,7 +140,6 @@ export default Object.assign(compat, {
'components/DiscussionListItem': DiscussionListItem,
'components/LoadingPost': LoadingPost,
'components/PostsUserPage': PostsUserPage,
'resolvers/DiscussionPageResolver': DiscussionPageResolver,
routes: routes,
ForumApplication: ForumApplication,
});

View File

@@ -1,51 +0,0 @@
import Component from '../../common/Component';
/**
* The `AffixedSidebar` component uses Bootstrap's "affix" plugin to keep a
* sidebar navigation at the top of the viewport when scrolling.
*
* ### Children
*
* The component must wrap an element that itself wraps an <ul> element, which
* will be "affixed".
*
* @see https://getbootstrap.com/docs/3.4/javascript/#affix
*/
export default class AffixedSidebar extends Component {
view(vnode) {
return vnode.children[0];
}
oncreate(vnode) {
super.oncreate(vnode);
// Register the affix plugin to execute on every window resize (and trigger)
this.boundOnresize = this.onresize.bind(this);
$(window).on('resize', this.boundOnresize).resize();
}
onremove() {
$(window).off('resize', this.boundOnresize);
}
onresize() {
const $sidebar = this.$();
const $header = $('#header');
const $footer = $('#footer');
const $affixElement = $sidebar.find('> ul');
$(window).off('.affix');
$affixElement.removeClass('affix affix-top affix-bottom').removeData('bs.affix');
// Don't affix the sidebar if it is taller than the viewport (otherwise
// there would be no way to scroll through its content).
if ($sidebar.outerHeight(true) > $(window).height() - $header.outerHeight(true)) return;
$affixElement.affix({
offset: {
top: () => $sidebar.offset().top - $header.outerHeight(true) - parseInt($sidebar.css('margin-top'), 10),
bottom: () => (this.bottom = $footer.outerHeight(true)),
},
});
}
}

View File

@@ -3,7 +3,6 @@ import avatar from '../../common/helpers/avatar';
import icon from '../../common/helpers/icon';
import listItems from '../../common/helpers/listItems';
import ItemList from '../../common/utils/ItemList';
import classList from '../../common/utils/classList';
import Button from '../../common/components/Button';
import LoadingIndicator from '../../common/components/LoadingIndicator';
@@ -11,15 +10,13 @@ import LoadingIndicator from '../../common/components/LoadingIndicator';
* The `AvatarEditor` component displays a user's avatar along with a dropdown
* menu which allows the user to upload/remove the avatar.
*
* ### Attrs
* ### Props
*
* - `className`
* - `user`
*/
export default class AvatarEditor extends Component {
oninit(vnode) {
super.oninit(vnode);
init() {
/**
* Whether or not an avatar upload is in progress.
*
@@ -35,11 +32,17 @@ export default class AvatarEditor extends Component {
this.isDraggedOver = false;
}
static initProps(props) {
super.initProps(props);
props.className = props.className || '';
}
view() {
const user = this.attrs.user;
const user = this.props.user;
return (
<div className={classList(['AvatarEditor', 'Dropdown', this.attrs.className, this.loading && 'loading', this.isDraggedOver && 'dragover'])}>
<div className={'AvatarEditor Dropdown ' + this.props.className + (this.loading ? ' loading' : '') + (this.isDraggedOver ? ' dragover' : '')}>
{avatar(user)}
<a
className={user.avatarUrl() ? 'Dropdown-toggle' : 'Dropdown-toggle AvatarEditor--noAvatar'}
@@ -52,7 +55,7 @@ export default class AvatarEditor extends Component {
ondragend={this.disableDragover.bind(this)}
ondrop={this.dropUpload.bind(this)}
>
{this.loading ? <LoadingIndicator /> : user.avatarUrl() ? icon('fas fa-pencil-alt') : icon('fas fa-plus-circle')}
{this.loading ? LoadingIndicator.component() : user.avatarUrl() ? icon('fas fa-pencil-alt') : icon('fas fa-plus-circle')}
</a>
<ul className="Dropdown-menu Menu">{listItems(this.controlItems().toArray())}</ul>
</div>
@@ -69,16 +72,20 @@ export default class AvatarEditor extends Component {
items.add(
'upload',
<Button icon="fas fa-upload" onclick={this.openPicker.bind(this)}>
{app.translator.trans('core.forum.user.avatar_upload_button')}
</Button>
Button.component({
icon: 'fas fa-upload',
children: app.translator.trans('core.forum.user.avatar_upload_button'),
onclick: this.openPicker.bind(this),
})
);
items.add(
'remove',
<Button icon="fas fa-times" onclick={this.remove.bind(this)}>
{app.translator.trans('core.forum.user.avatar_remove_button')}
</Button>
Button.component({
icon: 'fas fa-times',
children: app.translator.trans('core.forum.user.avatar_remove_button'),
onclick: this.remove.bind(this),
})
);
return items;
@@ -127,7 +134,7 @@ export default class AvatarEditor extends Component {
* @param {Event} e
*/
quickUpload(e) {
if (!this.attrs.user.avatarUrl()) {
if (!this.props.user.avatarUrl()) {
e.preventDefault();
e.stopPropagation();
this.openPicker();
@@ -142,6 +149,7 @@ export default class AvatarEditor extends Component {
// Create a hidden HTML input element and click on it so the user can select
// an avatar file. Once they have, we will upload it via the API.
const user = this.props.user;
const $input = $('<input type="file">');
$input
@@ -161,7 +169,7 @@ export default class AvatarEditor extends Component {
upload(file) {
if (this.loading) return;
const user = this.attrs.user;
const user = this.props.user;
const data = new FormData();
data.append('avatar', file);
@@ -171,9 +179,9 @@ export default class AvatarEditor extends Component {
app
.request({
method: 'POST',
url: `${app.forum.attribute('apiUrl')}/users/${user.id()}/avatar`,
url: app.forum.attribute('apiUrl') + '/users/' + user.id() + '/avatar',
serialize: (raw) => raw,
body: data,
data,
})
.then(this.success.bind(this), this.failure.bind(this));
}
@@ -182,7 +190,7 @@ export default class AvatarEditor extends Component {
* Remove the user's avatar.
*/
remove() {
const user = this.attrs.user;
const user = this.props.user;
this.loading = true;
m.redraw();
@@ -190,7 +198,7 @@ export default class AvatarEditor extends Component {
app
.request({
method: 'DELETE',
url: `${app.forum.attribute('apiUrl')}/users/${user.id()}/avatar`,
url: app.forum.attribute('apiUrl') + '/users/' + user.id() + '/avatar',
})
.then(this.success.bind(this), this.failure.bind(this));
}
@@ -204,7 +212,7 @@ export default class AvatarEditor extends Component {
*/
success(response) {
app.store.pushPayload(response);
delete this.attrs.user.avatarColor;
delete this.props.user.avatarColor;
this.loading = false;
m.redraw();

View File

@@ -1,14 +1,13 @@
import Modal from '../../common/components/Modal';
import Button from '../../common/components/Button';
import Stream from '../../common/utils/Stream';
/**
* The `ChangeEmailModal` component shows a modal dialog which allows the user
* to change their email address.
*/
export default class ChangeEmailModal extends Modal {
oninit(vnode) {
super.oninit(vnode);
init() {
super.init();
/**
* Whether or not the email has been changed successfully.
@@ -22,14 +21,14 @@ export default class ChangeEmailModal extends Modal {
*
* @type {function}
*/
this.email = Stream(app.session.user.email());
this.email = m.prop(app.session.user.email());
/**
* The value of the password input.
*
* @type {function}
*/
this.password = Stream('');
this.password = m.prop('');
}
className() {
@@ -82,14 +81,12 @@ export default class ChangeEmailModal extends Modal {
/>
</div>
<div className="Form-group">
{Button.component(
{
className: 'Button Button--primary Button--block',
type: 'submit',
loading: this.loading,
},
app.translator.trans('core.forum.change_email.submit_button')
)}
{Button.component({
className: 'Button Button--primary Button--block',
type: 'submit',
loading: this.loading,
children: app.translator.trans('core.forum.change_email.submit_button'),
})}
</div>
</div>
</div>
@@ -118,17 +115,14 @@ export default class ChangeEmailModal extends Modal {
meta: { password: this.password() },
}
)
.then(() => {
this.success = true;
this.alertAttrs = null;
})
.then(() => (this.success = true))
.catch(() => {})
.then(this.loaded.bind(this));
}
onerror(error) {
if (error.status === 401) {
error.alert.content = app.translator.trans('core.forum.change_email.incorrect_password_message');
error.alert.children = app.translator.trans('core.forum.change_email.incorrect_password_message');
}
super.onerror(error);

View File

@@ -20,14 +20,12 @@ export default class ChangePasswordModal extends Modal {
<div className="Form Form--centered">
<p className="helpText">{app.translator.trans('core.forum.change_password.text')}</p>
<div className="Form-group">
{Button.component(
{
className: 'Button Button--primary Button--block',
type: 'submit',
loading: this.loading,
},
app.translator.trans('core.forum.change_password.send_button')
)}
{Button.component({
className: 'Button Button--primary Button--block',
type: 'submit',
loading: this.loading,
children: app.translator.trans('core.forum.change_password.send_button'),
})}
</div>
</div>
</div>
@@ -43,7 +41,7 @@ export default class ChangePasswordModal extends Modal {
.request({
method: 'POST',
url: app.forum.attribute('apiUrl') + '/forgot',
body: { email: app.session.user.email() },
data: { email: app.session.user.email() },
})
.then(this.hide.bind(this), this.loaded.bind(this));
}

View File

@@ -1,3 +1,5 @@
/*global s9e, hljs*/
import Post from './Post';
import classList from '../../common/utils/classList';
import PostUser from './PostUser';
@@ -7,20 +9,19 @@ import EditPostComposer from './EditPostComposer';
import ItemList from '../../common/utils/ItemList';
import listItems from '../../common/helpers/listItems';
import Button from '../../common/components/Button';
import ComposerPostPreview from './ComposerPostPreview';
/**
* The `CommentPost` component displays a standard `comment`-typed post. This
* includes a number of item lists (controls, header, and footer) surrounding
* the post's HTML content.
*
* ### Attrs
* ### Props
*
* - `post`
*/
export default class CommentPost extends Post {
oninit(vnode) {
super.oninit(vnode);
init() {
super.init();
/**
* If the post has been hidden, then this flag determines whether or not its
@@ -40,59 +41,48 @@ export default class CommentPost extends Post {
this.subtree.check(
() => this.cardVisible,
() => this.isEditing(),
() => this.revealContent
() => this.isEditing()
);
}
content() {
return super.content().concat([
<header className="Post-header">
<ul>{listItems(this.headerItems().toArray())}</ul>
</header>,
<div className="Post-body">
{this.isEditing() ? <ComposerPostPreview className="Post-preview" composer={app.composer} /> : m.trust(this.attrs.post.contentHtml())}
</div>,
]);
// Note: we avoid using JSX for the <ul> below because it results in some
// weirdness in Mithril.js 0.1.x (see flarum/core#975). This workaround can
// be reverted when we upgrade to Mithril 1.0.
return super
.content()
.concat([
<header className="Post-header">{m('ul', listItems(this.headerItems().toArray()))}</header>,
<div className="Post-body">
{this.isEditing() ? <div className="Post-preview" config={this.configPreview.bind(this)} /> : m.trust(this.props.post.contentHtml())}
</div>,
]);
}
refreshContent() {
const contentHtml = this.isEditing() ? '' : this.attrs.post.contentHtml();
config(isInitialized, context) {
super.config(...arguments);
const contentHtml = this.isEditing() ? '' : this.props.post.contentHtml();
// If the post content has changed since the last render, we'll run through
// all of the <script> tags in the content and evaluate them. This is
// necessary because TextFormatter outputs them for e.g. syntax highlighting.
if (this.contentHtml !== contentHtml) {
if (context.contentHtml !== contentHtml) {
this.$('.Post-body script').each(function () {
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);
eval.call(window, $(this).text());
});
}
this.contentHtml = contentHtml;
}
oncreate(vnode) {
super.oncreate(vnode);
this.refreshContent();
}
onupdate(vnode) {
super.onupdate(vnode);
this.refreshContent();
context.contentHtml = contentHtml;
}
isEditing() {
return app.composer.bodyMatches(EditPostComposer, { post: this.attrs.post });
return app.composer.bodyMatches(EditPostComposer, { post: this.props.post });
}
elementAttrs() {
const post = this.attrs.post;
const attrs = super.elementAttrs();
attrs() {
const post = this.props.post;
const attrs = super.attrs();
attrs.className =
(attrs.className || '') +
@@ -108,6 +98,27 @@ export default class CommentPost extends Post {
return attrs;
}
configPreview(element, isInitialized, context) {
if (isInitialized) return;
// Every 50ms, if the composer content has changed, then update the post's
// body with a preview.
let preview;
const updatePreview = () => {
const content = app.composer.fields.content();
if (preview === content) return;
preview = content;
s9e.TextFormatter.preview(preview || '', element);
};
updatePreview();
const updateInterval = setInterval(updatePreview, 50);
context.onunload = () => clearInterval(updateInterval);
}
/**
* Toggle the visibility of a hidden post's content.
*/
@@ -122,7 +133,7 @@ export default class CommentPost extends Post {
*/
headerItems() {
const items = new ItemList();
const post = this.attrs.post;
const post = this.props.post;
items.add(
'user',

Some files were not shown because too many files have changed in this diff Show More