mirror of
https://github.com/flarum/core.git
synced 2025-08-13 20:04:24 +02:00
Compare commits
278 Commits
as/comment
...
urlgenerat
Author | SHA1 | Date | |
---|---|---|---|
|
68cacd5404 | ||
|
345a8e5773 | ||
|
1cbaef579c | ||
|
8fb65e9de7 | ||
|
92a5b98b77 | ||
|
1eb316bf44 | ||
|
47e8990813 | ||
|
dbce6705fd | ||
|
9de6ebbe4f | ||
|
f63ca2be3c | ||
|
6a1017d4b1 | ||
|
696dc8ef6b | ||
|
a8fa57ad30 | ||
|
9ff02f8822 | ||
|
dde718e27f | ||
|
a155e41432 | ||
|
4574fa6290 | ||
|
e1c61a0e85 | ||
|
102e76b084 | ||
|
d09d4bc507 | ||
|
c3989cc952 | ||
|
9cb9097b24 | ||
|
571a835be0 | ||
|
0c95774333 | ||
|
67741c7a6f | ||
|
f5cfec15e3 | ||
|
47d2eee9ce | ||
|
c10cc92deb | ||
|
529d2edcaf | ||
|
f0e77a5789 | ||
|
87c258b2f8 | ||
|
cee87848fe | ||
|
5842dd1200 | ||
|
b311512502 | ||
|
0b2a5fa5b8 | ||
|
52e45aacad | ||
|
21c2a4b2a4 | ||
|
12c03dc4e1 | ||
|
d2927cfdb9 | ||
|
24b7a21507 | ||
|
c9a04fe009 | ||
|
bd7fa11b5a | ||
|
7055f6d941 | ||
|
f765001f06 | ||
|
683739a617 | ||
|
69b7fe8d01 | ||
|
1936b9117d | ||
|
d53eeded44 | ||
|
0650788e7c | ||
|
6a77184611 | ||
|
a8b36cb76d | ||
|
5cd14d594b | ||
|
f4ad9d2d5a | ||
|
d409484abf | ||
|
1fc24635f6 | ||
|
ff7ac0b322 | ||
|
d460aaa3ad | ||
|
7634a766cb | ||
|
e5f53b93a6 | ||
|
a38c92d409 | ||
|
3da655a62f | ||
|
46c3124b0b | ||
|
e6f59b834f | ||
|
9f5737eb93 | ||
|
35cb5b20a0 | ||
|
988b6c9023 | ||
|
c1d91be2f4 | ||
|
f534398645 | ||
|
cd05ec6589 | ||
|
78be6e2194 | ||
|
eb498a0a9f | ||
|
ac42a5900d | ||
|
543b136f7c | ||
|
8546fb734f | ||
|
20b9455f04 | ||
|
008f1da539 | ||
|
a3bd431503 | ||
|
6da81a71a4 | ||
|
a48d38614b | ||
|
7358437c59 | ||
|
08ec274a24 | ||
|
d45478564f | ||
|
8296ffe8c9 | ||
|
1d2f0ca407 | ||
|
bb69c3bd57 | ||
|
2b1581875a | ||
|
a0c36a015b | ||
|
656409794c | ||
|
245f3c6846 | ||
|
962b49567c | ||
|
12498b7620 | ||
|
84f7d29d8c | ||
|
561e8c6b6a | ||
|
ad9917f0d6 | ||
|
6977c24dd8 | ||
|
d1b72429ac | ||
|
63692f12c5 | ||
|
441ccec8e7 | ||
|
414b0ff6d3 | ||
|
2ff0e1efcb | ||
|
8c46b37a6f | ||
|
9be629cfcc | ||
|
df9be1b063 | ||
|
97c36f2f7d | ||
|
13efd02085 | ||
|
0b44c48433 | ||
|
b562072471 | ||
|
718445cb0c | ||
|
c6f2ff0c80 | ||
|
67962f48e5 | ||
|
f8a0d9459a | ||
|
27d562f3fc | ||
|
17a7155f60 | ||
|
4cdce71d65 | ||
|
eb03f51c4f | ||
|
d695d96e00 | ||
|
dc4884485a | ||
|
40548d7c61 | ||
|
60714b7ac4 | ||
|
84d14f485a | ||
|
0a6c5217c1 | ||
|
44a96a82ef | ||
|
0b3fe10516 | ||
|
5ecb74fb59 | ||
|
b66d16e44b | ||
|
a013d647e0 | ||
|
20b99bcab1 | ||
|
8325b6eed8 | ||
|
f9704f9153 | ||
|
09a39d5d95 | ||
|
a26f01e49c | ||
|
6d826e5b30 | ||
|
f38605b387 | ||
|
93f8ce78b3 | ||
|
a001736298 | ||
|
86d4bf0214 | ||
|
c7b67b922b | ||
|
3b63d774d3 | ||
|
86f7550bec | ||
|
9d1a87a4c4 | ||
|
a2263b8538 | ||
|
1ac09dbc4d | ||
|
be8fe44f0b | ||
|
b7593bc6a8 | ||
|
7fc0963e3c | ||
|
30f3056f70 | ||
|
ed23d7d4e7 | ||
|
1e9f7b7d52 | ||
|
08540fd1db | ||
|
74fa7122ca | ||
|
4b2d20cd85 | ||
|
077eaaa2f9 | ||
|
6668e75019 | ||
|
d6511e0df5 | ||
|
efd68df13a | ||
|
f1360a1394 | ||
|
cc875f3e95 | ||
|
6860b24b70 | ||
|
65766a8386 | ||
|
c53509d7d0 | ||
|
4c3e1e2625 | ||
|
6508e64f55 | ||
|
963c27ed60 | ||
|
304f05be36 | ||
|
82af307280 | ||
|
50cbb7be5c | ||
|
9ea57e6329 | ||
|
6639678fb2 | ||
|
f869999011 | ||
|
f885cebdc5 | ||
|
54ff6e720c | ||
|
aea8a3ff1f | ||
|
cc48e9ab22 | ||
|
6d38de9c8f | ||
|
87634449c0 | ||
|
b00ca4ef29 | ||
|
fd0f0cdf8b | ||
|
5b157f0adb | ||
|
dc8b203037 | ||
|
db71f8bf68 | ||
|
a004b8e057 | ||
|
1ff4076f2a | ||
|
6e9db779cd | ||
|
f4449e962d | ||
|
71f3379fcc | ||
|
1321b8cc28 | ||
|
fa0ff204dd | ||
|
872e3bdc92 | ||
|
79f9012694 | ||
|
633cc14d09 | ||
|
c6e85ef330 | ||
|
3f8432a589 | ||
|
96c95f2b6a | ||
|
8e3e8826f9 | ||
|
384edfa52b | ||
|
f939d164b7 | ||
|
ebbef75cfb | ||
|
2caa5cf19c | ||
|
fe718a1490 | ||
|
beb03b7771 | ||
|
97186e6086 | ||
|
47f3ee0ce2 | ||
|
a9eb14889e | ||
|
5e5a5294c3 | ||
|
c39b6a6d2f | ||
|
22bf03d872 | ||
|
a2f3534bf7 | ||
|
6953d93c6d | ||
|
f9c9b5d5e4 | ||
|
8a73cc522e | ||
|
db83003eb5 | ||
|
4dc4dc624e | ||
|
ad42058a8a | ||
|
5e465f6051 | ||
|
62a2e8463d | ||
|
0098c64ebf | ||
|
2b5939d538 | ||
|
2431df5602 | ||
|
264ff67304 | ||
|
c08a56e9d8 | ||
|
4ee6d6fd88 | ||
|
9c09fe8465 | ||
|
b46d5e67a3 | ||
|
7fd23ff950 | ||
|
e4077ab4ad | ||
|
3b39c212e0 | ||
|
bca833d3f1 | ||
|
451a557532 | ||
|
eaac78650f | ||
|
2b3dec2be1 | ||
|
37ebeb5705 | ||
|
71abac0323 | ||
|
7e3d71a0a0 | ||
|
b5e891df30 | ||
|
3117d2ad7a | ||
|
1ce0b926b6 | ||
|
24b16f9d7c | ||
|
bd40353bcc | ||
|
455327cca1 | ||
|
20baa93ca7 | ||
|
4f34e326ef | ||
|
521cefbc2d | ||
|
dc738d68dc | ||
|
286af7084b | ||
|
4869baea74 | ||
|
24a48310ff | ||
|
bdb759c558 | ||
|
36eb5cc5fb | ||
|
d189272473 | ||
|
7d48c24dda | ||
|
5786f1a10b | ||
|
b4421e1cce | ||
|
359b4ab5a3 | ||
|
8a686911ff | ||
|
0b5a9a2fe6 | ||
|
50a9f7ce86 | ||
|
8dd5420405 | ||
|
640cc0989b | ||
|
44376cef61 | ||
|
4f181c84fc | ||
|
ea9d601338 | ||
|
aaebd3581f | ||
|
e2c416903e | ||
|
e81159249f | ||
|
d93cf4a574 | ||
|
a33fbbf814 | ||
|
0c645a6c15 | ||
|
b44b79eba9 | ||
|
93398b738b | ||
|
7816b61bfb | ||
|
7dc3a194c3 | ||
|
cea7824b57 | ||
|
088eb0c4f2 | ||
|
2ba67b021f | ||
|
92791a253d | ||
|
138c784a50 | ||
|
bb567e5278 | ||
|
cf4f2f283e |
2
.gitattributes
vendored
2
.gitattributes
vendored
@@ -11,3 +11,5 @@ phpunit.xml export-ignore
|
||||
tests export-ignore
|
||||
|
||||
js/dist/* -diff
|
||||
|
||||
* text=auto eol=lf
|
||||
|
97
CHANGELOG.md
97
CHANGELOG.md
@@ -1,5 +1,102 @@
|
||||
# Changelog
|
||||
|
||||
## [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
|
||||
|
13
README.md
13
README.md
@@ -1,12 +1,14 @@
|
||||
<p align="center"><img src="https://flarum.org/img/logo.png"></p>
|
||||
<p align="center"><img src="https://flarum.org/assets/img/logo.png"></p>
|
||||
|
||||
<p align="center">
|
||||
<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>
|
||||
<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>
|
||||
</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:
|
||||
@@ -32,4 +34,3 @@ 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).
|
||||
|
||||
|
@@ -10,7 +10,7 @@
|
||||
"email": "franz@develophp.org"
|
||||
},
|
||||
{
|
||||
"name": "Daniel Klabbers",
|
||||
"name": "Daniël Klabbers",
|
||||
"email": "daniel@klabbers.email",
|
||||
"homepage": "https://luceos.com"
|
||||
},
|
||||
@@ -27,6 +27,10 @@
|
||||
{
|
||||
"name": "Matthew Kilgore",
|
||||
"email": "matthew@kilgore.dev"
|
||||
},
|
||||
{
|
||||
"name": "Alexander (Sasha) Skvortsov",
|
||||
"email": "askvortsov@flarum.org"
|
||||
}
|
||||
],
|
||||
"support": {
|
||||
@@ -37,25 +41,25 @@
|
||||
"require": {
|
||||
"php": ">=7.2",
|
||||
"axy/sourcemap": "^0.1.4",
|
||||
"components/font-awesome": "5.9.*",
|
||||
"components/font-awesome": "^5.14.0",
|
||||
"dflydev/fig-cookies": "^2.0.1",
|
||||
"doctrine/dbal": "^2.7",
|
||||
"franzl/whoops-middleware": "^0.4.0",
|
||||
"illuminate/bus": "5.8.*",
|
||||
"illuminate/cache": "5.8.*",
|
||||
"illuminate/config": "5.8.*",
|
||||
"illuminate/container": "5.8.*",
|
||||
"illuminate/contracts": "5.8.*",
|
||||
"illuminate/database": "5.8.*",
|
||||
"illuminate/events": "5.8.*",
|
||||
"illuminate/filesystem": "5.8.*",
|
||||
"illuminate/hashing": "5.8.*",
|
||||
"illuminate/mail": "5.8.*",
|
||||
"illuminate/queue": "5.8.*",
|
||||
"illuminate/session": "5.8.*",
|
||||
"illuminate/support": "5.8.*",
|
||||
"illuminate/validation": "5.8.*",
|
||||
"illuminate/view": "5.8.*",
|
||||
"illuminate/bus": "^6.0",
|
||||
"illuminate/cache": "^6.0",
|
||||
"illuminate/config": "^6.0",
|
||||
"illuminate/container": "^6.0",
|
||||
"illuminate/contracts": "^6.0",
|
||||
"illuminate/database": "^6.0",
|
||||
"illuminate/events": "^6.0",
|
||||
"illuminate/filesystem": "^6.0",
|
||||
"illuminate/hashing": "^6.0",
|
||||
"illuminate/mail": "^6.0",
|
||||
"illuminate/queue": "^6.0",
|
||||
"illuminate/session": "^6.0",
|
||||
"illuminate/support": "^6.0",
|
||||
"illuminate/validation": "^6.0",
|
||||
"illuminate/view": "^6.0",
|
||||
"intervention/image": "^2.5.0",
|
||||
"laminas/laminas-diactoros": "^1.8.4",
|
||||
"laminas/laminas-httphandlerrunner": "^1.0",
|
||||
@@ -66,16 +70,17 @@
|
||||
"middlewares/base-path-router": "^0.2.1",
|
||||
"middlewares/request-handler": "^1.2",
|
||||
"monolog/monolog": "^1.16.0",
|
||||
"nesbot/carbon": "^2.0",
|
||||
"nikic/fast-route": "^0.6",
|
||||
"psr/http-message": "^1.0",
|
||||
"psr/http-server-handler": "^1.0",
|
||||
"psr/http-server-middleware": "^1.0",
|
||||
"s9e/text-formatter": "^2.3.6",
|
||||
"symfony/config": "^3.3",
|
||||
"symfony/console": "^4.2",
|
||||
"symfony/event-dispatcher": "^4.3.2",
|
||||
"symfony/translation": "^3.3",
|
||||
"symfony/yaml": "^3.3",
|
||||
"symfony/config": "^4.3.4",
|
||||
"symfony/console": "^4.3.4",
|
||||
"symfony/event-dispatcher": "^4.3.4",
|
||||
"symfony/translation": "^4.3.4",
|
||||
"symfony/yaml": "^4.3.4",
|
||||
"tobscure/json-api": "^0.3.0",
|
||||
"wikimedia/less.php": "^3.0"
|
||||
},
|
||||
|
14
js/dist/admin.js
vendored
14
js/dist/admin.js
vendored
File diff suppressed because one or more lines are too long
2
js/dist/admin.js.map
vendored
2
js/dist/admin.js.map
vendored
File diff suppressed because one or more lines are too long
16
js/dist/forum.js
vendored
16
js/dist/forum.js
vendored
File diff suppressed because one or more lines are too long
2
js/dist/forum.js.map
vendored
2
js/dist/forum.js.map
vendored
File diff suppressed because one or more lines are too long
249
js/package-lock.json
generated
249
js/package-lock.json
generated
@@ -289,6 +289,11 @@
|
||||
"@babel/types": "^7.0.0"
|
||||
}
|
||||
},
|
||||
"@babel/helper-validator-identifier": {
|
||||
"version": "7.10.3",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.10.3.tgz",
|
||||
"integrity": "sha512-bU8JvtlYpJSBPuj1VUmKpFGaDZuLxASky3LhaKj3bmpSTY6VWooSM8msk+Z0CZoErFye2tlABF6yDkT3FOPAXw=="
|
||||
},
|
||||
"@babel/helper-wrap-function": {
|
||||
"version": "7.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helper-wrap-function/-/helper-wrap-function-7.2.0.tgz",
|
||||
@@ -445,6 +450,21 @@
|
||||
"@babel/helper-plugin-utils": "^7.0.0"
|
||||
}
|
||||
},
|
||||
"@babel/plugin-syntax-typescript": {
|
||||
"version": "7.10.1",
|
||||
"resolved": "https://registry.npmjs.org/@babel/plugin-syntax-typescript/-/plugin-syntax-typescript-7.10.1.tgz",
|
||||
"integrity": "sha512-X/d8glkrAtra7CaQGMiGs/OGa6XgUzqPcBXCIGFCpCqnfGlT0Wfbzo/B89xHhnInTaItPK8LALblVXcUOEh95Q==",
|
||||
"requires": {
|
||||
"@babel/helper-plugin-utils": "^7.10.1"
|
||||
},
|
||||
"dependencies": {
|
||||
"@babel/helper-plugin-utils": {
|
||||
"version": "7.10.3",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.10.3.tgz",
|
||||
"integrity": "sha512-j/+j8NAWUTxOtx4LKHybpSClxHoq6I91DQ/mKgAXn5oNUPIUiGppjPIX3TDtJWPrdfP9Kfl7e4fgVMiQR9VE/g=="
|
||||
}
|
||||
}
|
||||
},
|
||||
"@babel/plugin-transform-arrow-functions": {
|
||||
"version": "7.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@babel/plugin-transform-arrow-functions/-/plugin-transform-arrow-functions-7.2.0.tgz",
|
||||
@@ -740,6 +760,159 @@
|
||||
"@babel/helper-plugin-utils": "^7.0.0"
|
||||
}
|
||||
},
|
||||
"@babel/plugin-transform-typescript": {
|
||||
"version": "7.10.3",
|
||||
"resolved": "https://registry.npmjs.org/@babel/plugin-transform-typescript/-/plugin-transform-typescript-7.10.3.tgz",
|
||||
"integrity": "sha512-qU9Lu7oQyh3PGMQncNjQm8RWkzw6LqsWZQlZPQMgrGt6s3YiBIaQ+3CQV/FA/icGS5XlSWZGwo/l8ErTyelS0Q==",
|
||||
"requires": {
|
||||
"@babel/helper-create-class-features-plugin": "^7.10.3",
|
||||
"@babel/helper-plugin-utils": "^7.10.3",
|
||||
"@babel/plugin-syntax-typescript": "^7.10.1"
|
||||
},
|
||||
"dependencies": {
|
||||
"@babel/code-frame": {
|
||||
"version": "7.10.3",
|
||||
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.10.3.tgz",
|
||||
"integrity": "sha512-fDx9eNW0qz0WkUeqL6tXEXzVlPh6Y5aCDEZesl0xBGA8ndRukX91Uk44ZqnkECp01NAZUdCAl+aiQNGi0k88Eg==",
|
||||
"requires": {
|
||||
"@babel/highlight": "^7.10.3"
|
||||
}
|
||||
},
|
||||
"@babel/generator": {
|
||||
"version": "7.10.3",
|
||||
"resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.10.3.tgz",
|
||||
"integrity": "sha512-drt8MUHbEqRzNR0xnF8nMehbY11b1SDkRw03PSNH/3Rb2Z35oxkddVSi3rcaak0YJQ86PCuE7Qx1jSFhbLNBMA==",
|
||||
"requires": {
|
||||
"@babel/types": "^7.10.3",
|
||||
"jsesc": "^2.5.1",
|
||||
"lodash": "^4.17.13",
|
||||
"source-map": "^0.5.0"
|
||||
}
|
||||
},
|
||||
"@babel/helper-create-class-features-plugin": {
|
||||
"version": "7.10.3",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helper-create-class-features-plugin/-/helper-create-class-features-plugin-7.10.3.tgz",
|
||||
"integrity": "sha512-iRT9VwqtdFmv7UheJWthGc/h2s7MqoweBF9RUj77NFZsg9VfISvBTum3k6coAhJ8RWv2tj3yUjA03HxPd0vfpQ==",
|
||||
"requires": {
|
||||
"@babel/helper-function-name": "^7.10.3",
|
||||
"@babel/helper-member-expression-to-functions": "^7.10.3",
|
||||
"@babel/helper-optimise-call-expression": "^7.10.3",
|
||||
"@babel/helper-plugin-utils": "^7.10.3",
|
||||
"@babel/helper-replace-supers": "^7.10.1",
|
||||
"@babel/helper-split-export-declaration": "^7.10.1"
|
||||
}
|
||||
},
|
||||
"@babel/helper-function-name": {
|
||||
"version": "7.10.3",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helper-function-name/-/helper-function-name-7.10.3.tgz",
|
||||
"integrity": "sha512-FvSj2aiOd8zbeqijjgqdMDSyxsGHaMt5Tr0XjQsGKHD3/1FP3wksjnLAWzxw7lvXiej8W1Jt47SKTZ6upQNiRw==",
|
||||
"requires": {
|
||||
"@babel/helper-get-function-arity": "^7.10.3",
|
||||
"@babel/template": "^7.10.3",
|
||||
"@babel/types": "^7.10.3"
|
||||
}
|
||||
},
|
||||
"@babel/helper-get-function-arity": {
|
||||
"version": "7.10.3",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helper-get-function-arity/-/helper-get-function-arity-7.10.3.tgz",
|
||||
"integrity": "sha512-iUD/gFsR+M6uiy69JA6fzM5seno8oE85IYZdbVVEuQaZlEzMO2MXblh+KSPJgsZAUx0EEbWXU0yJaW7C9CdAVg==",
|
||||
"requires": {
|
||||
"@babel/types": "^7.10.3"
|
||||
}
|
||||
},
|
||||
"@babel/helper-member-expression-to-functions": {
|
||||
"version": "7.10.3",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helper-member-expression-to-functions/-/helper-member-expression-to-functions-7.10.3.tgz",
|
||||
"integrity": "sha512-q7+37c4EPLSjNb2NmWOjNwj0+BOyYlssuQ58kHEWk1Z78K5i8vTUsteq78HMieRPQSl/NtpQyJfdjt3qZ5V2vw==",
|
||||
"requires": {
|
||||
"@babel/types": "^7.10.3"
|
||||
}
|
||||
},
|
||||
"@babel/helper-optimise-call-expression": {
|
||||
"version": "7.10.3",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helper-optimise-call-expression/-/helper-optimise-call-expression-7.10.3.tgz",
|
||||
"integrity": "sha512-kT2R3VBH/cnSz+yChKpaKRJQJWxdGoc6SjioRId2wkeV3bK0wLLioFpJROrX0U4xr/NmxSSAWT/9Ih5snwIIzg==",
|
||||
"requires": {
|
||||
"@babel/types": "^7.10.3"
|
||||
}
|
||||
},
|
||||
"@babel/helper-plugin-utils": {
|
||||
"version": "7.10.3",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.10.3.tgz",
|
||||
"integrity": "sha512-j/+j8NAWUTxOtx4LKHybpSClxHoq6I91DQ/mKgAXn5oNUPIUiGppjPIX3TDtJWPrdfP9Kfl7e4fgVMiQR9VE/g=="
|
||||
},
|
||||
"@babel/helper-replace-supers": {
|
||||
"version": "7.10.1",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helper-replace-supers/-/helper-replace-supers-7.10.1.tgz",
|
||||
"integrity": "sha512-SOwJzEfpuQwInzzQJGjGaiG578UYmyi2Xw668klPWV5n07B73S0a9btjLk/52Mlcxa+5AdIYqws1KyXRfMoB7A==",
|
||||
"requires": {
|
||||
"@babel/helper-member-expression-to-functions": "^7.10.1",
|
||||
"@babel/helper-optimise-call-expression": "^7.10.1",
|
||||
"@babel/traverse": "^7.10.1",
|
||||
"@babel/types": "^7.10.1"
|
||||
}
|
||||
},
|
||||
"@babel/helper-split-export-declaration": {
|
||||
"version": "7.10.1",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helper-split-export-declaration/-/helper-split-export-declaration-7.10.1.tgz",
|
||||
"integrity": "sha512-UQ1LVBPrYdbchNhLwj6fetj46BcFwfS4NllJo/1aJsT+1dLTEnXJL0qHqtY7gPzF8S2fXBJamf1biAXV3X077g==",
|
||||
"requires": {
|
||||
"@babel/types": "^7.10.1"
|
||||
}
|
||||
},
|
||||
"@babel/highlight": {
|
||||
"version": "7.10.3",
|
||||
"resolved": "https://registry.npmjs.org/@babel/highlight/-/highlight-7.10.3.tgz",
|
||||
"integrity": "sha512-Ih9B/u7AtgEnySE2L2F0Xm0GaM729XqqLfHkalTsbjXGyqmf/6M0Cu0WpvqueUlW+xk88BHw9Nkpj49naU+vWw==",
|
||||
"requires": {
|
||||
"@babel/helper-validator-identifier": "^7.10.3",
|
||||
"chalk": "^2.0.0",
|
||||
"js-tokens": "^4.0.0"
|
||||
}
|
||||
},
|
||||
"@babel/parser": {
|
||||
"version": "7.10.3",
|
||||
"resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.10.3.tgz",
|
||||
"integrity": "sha512-oJtNJCMFdIMwXGmx+KxuaD7i3b8uS7TTFYW/FNG2BT8m+fmGHoiPYoH0Pe3gya07WuFmM5FCDIr1x0irkD/hyA=="
|
||||
},
|
||||
"@babel/template": {
|
||||
"version": "7.10.3",
|
||||
"resolved": "https://registry.npmjs.org/@babel/template/-/template-7.10.3.tgz",
|
||||
"integrity": "sha512-5BjI4gdtD+9fHZUsaxPHPNpwa+xRkDO7c7JbhYn2afvrkDu5SfAAbi9AIMXw2xEhO/BR35TqiW97IqNvCo/GqA==",
|
||||
"requires": {
|
||||
"@babel/code-frame": "^7.10.3",
|
||||
"@babel/parser": "^7.10.3",
|
||||
"@babel/types": "^7.10.3"
|
||||
}
|
||||
},
|
||||
"@babel/traverse": {
|
||||
"version": "7.10.3",
|
||||
"resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.10.3.tgz",
|
||||
"integrity": "sha512-qO6623eBFhuPm0TmmrUFMT1FulCmsSeJuVGhiLodk2raUDFhhTECLd9E9jC4LBIWziqt4wgF6KuXE4d+Jz9yug==",
|
||||
"requires": {
|
||||
"@babel/code-frame": "^7.10.3",
|
||||
"@babel/generator": "^7.10.3",
|
||||
"@babel/helper-function-name": "^7.10.3",
|
||||
"@babel/helper-split-export-declaration": "^7.10.1",
|
||||
"@babel/parser": "^7.10.3",
|
||||
"@babel/types": "^7.10.3",
|
||||
"debug": "^4.1.0",
|
||||
"globals": "^11.1.0",
|
||||
"lodash": "^4.17.13"
|
||||
}
|
||||
},
|
||||
"@babel/types": {
|
||||
"version": "7.10.3",
|
||||
"resolved": "https://registry.npmjs.org/@babel/types/-/types-7.10.3.tgz",
|
||||
"integrity": "sha512-nZxaJhBXBQ8HVoIcGsf9qWep3Oh3jCENK54V4mRF7qaJabVsAYdbTtmSD8WmAp1R6ytPiu5apMwSXyxB1WlaBA==",
|
||||
"requires": {
|
||||
"@babel/helper-validator-identifier": "^7.10.3",
|
||||
"lodash": "^4.17.13",
|
||||
"to-fast-properties": "^2.0.0"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"@babel/plugin-transform-unicode-regex": {
|
||||
"version": "7.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-regex/-/plugin-transform-unicode-regex-7.2.0.tgz",
|
||||
@@ -812,6 +985,22 @@
|
||||
"@babel/plugin-transform-react-jsx-source": "^7.0.0"
|
||||
}
|
||||
},
|
||||
"@babel/preset-typescript": {
|
||||
"version": "7.10.1",
|
||||
"resolved": "https://registry.npmjs.org/@babel/preset-typescript/-/preset-typescript-7.10.1.tgz",
|
||||
"integrity": "sha512-m6GV3y1ShiqxnyQj10600ZVOFrSSAa8HQ3qIUk2r+gcGtHTIRw0dJnFLt1WNXpKjtVw7yw1DAPU/6ma2ZvgJuA==",
|
||||
"requires": {
|
||||
"@babel/helper-plugin-utils": "^7.10.1",
|
||||
"@babel/plugin-transform-typescript": "^7.10.1"
|
||||
},
|
||||
"dependencies": {
|
||||
"@babel/helper-plugin-utils": {
|
||||
"version": "7.10.3",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.10.3.tgz",
|
||||
"integrity": "sha512-j/+j8NAWUTxOtx4LKHybpSClxHoq6I91DQ/mKgAXn5oNUPIUiGppjPIX3TDtJWPrdfP9Kfl7e4fgVMiQR9VE/g=="
|
||||
}
|
||||
}
|
||||
},
|
||||
"@babel/runtime": {
|
||||
"version": "7.1.5",
|
||||
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.1.5.tgz",
|
||||
@@ -886,6 +1075,11 @@
|
||||
"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",
|
||||
@@ -1834,6 +2028,11 @@
|
||||
"resolved": "https://registry.npmjs.org/cyclist/-/cyclist-1.0.1.tgz",
|
||||
"integrity": "sha1-WW6WmP0MgOEgOMK4LW6xs1tiJNk="
|
||||
},
|
||||
"dayjs": {
|
||||
"version": "1.8.28",
|
||||
"resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.8.28.tgz",
|
||||
"integrity": "sha512-ccnYgKC0/hPSGXxj7Ju6AV/BP4HUkXC2u15mikXT5mX9YorEaoi1bEKOmAqdkJHN4EEkmAf97SpH66Try5Mbeg=="
|
||||
},
|
||||
"debug": {
|
||||
"version": "4.1.1",
|
||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.1.1.tgz",
|
||||
@@ -1942,9 +2141,9 @@
|
||||
"integrity": "sha512-De+lPAxEcpxvqPTyZAXELNpRZXABRxf+uL/rSykstQhzj/B0l1150G/ExIIxKc16lI89Hgz81J0BHAcbTqK49g=="
|
||||
},
|
||||
"elliptic": {
|
||||
"version": "6.5.2",
|
||||
"resolved": "https://registry.npmjs.org/elliptic/-/elliptic-6.5.2.tgz",
|
||||
"integrity": "sha512-f4x70okzZbIQl/NSRLkI/+tteV/9WqL98zx+SQ69KbXxmVrmjwsNUPn/gYJJ0sHvEak24cZgHIPegRePAtA/xw==",
|
||||
"version": "6.5.3",
|
||||
"resolved": "https://registry.npmjs.org/elliptic/-/elliptic-6.5.3.tgz",
|
||||
"integrity": "sha512-IMqzv5wNQf+E6aHeIqATs0tOLeOTwj1QKbRcS3jBbYkl5oLAserA8yJTT7/VyHUYG91PRmPyeQDObKLPpeS4dw==",
|
||||
"requires": {
|
||||
"bn.js": "^4.4.0",
|
||||
"brorand": "^1.0.1",
|
||||
@@ -3357,9 +3556,9 @@
|
||||
"integrity": "sha1-TkMekrEalzFjaqH5yNHMvP2reN8="
|
||||
},
|
||||
"jquery": {
|
||||
"version": "3.4.1",
|
||||
"resolved": "https://registry.npmjs.org/jquery/-/jquery-3.4.1.tgz",
|
||||
"integrity": "sha512-36+AdBzCL+y6qjw5Tx7HgzeGCzC81MDDgaUP8ld2zhx58HdqXGoBd+tHdrBMiyjGQs0Hxs/MLZTu/eHNJJuWPw=="
|
||||
"version": "3.5.1",
|
||||
"resolved": "https://registry.npmjs.org/jquery/-/jquery-3.5.1.tgz",
|
||||
"integrity": "sha512-XwIBPqcMn57FxfT+Go5pzySnm4KWkT1Tv7gjrpT1srtf8Weynl6R273VJ5GjkRb51IzMp5nbaPjJXMWeju2MKg=="
|
||||
},
|
||||
"jquery.hotkeys": {
|
||||
"version": "0.1.0",
|
||||
@@ -3443,9 +3642,9 @@
|
||||
}
|
||||
},
|
||||
"lodash": {
|
||||
"version": "4.17.15",
|
||||
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.15.tgz",
|
||||
"integrity": "sha512-8xOcRHvCjnocdS5cpwXQXVzmmh5e5+saE2QGoeQmbKmRS6J3VQppPOIt0MnmE+4xlZoumy0GPG0D0MVIQbNA1A=="
|
||||
"version": "4.17.19",
|
||||
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.19.tgz",
|
||||
"integrity": "sha512-JNvd8XER9GQX0v2qJgsaN/mzFCNA5BRe/j8JN9d+tWyGLSodKQHKFicdwNYzWwI3wjRnaKPsGj1XkBjx/F96DQ=="
|
||||
},
|
||||
"lodash-es": {
|
||||
"version": "4.17.14",
|
||||
@@ -3613,9 +3812,9 @@
|
||||
}
|
||||
},
|
||||
"mithril": {
|
||||
"version": "0.2.8",
|
||||
"resolved": "https://registry.npmjs.org/mithril/-/mithril-0.2.8.tgz",
|
||||
"integrity": "sha512-9XuGnVmS2OyFexUuP/CcJFFJjHLM+RGYBxyVRNyQ6khbMfDJIF/xyZ4zq18ZRfPagpFmWUFpjHd5ZqPULGZyNg=="
|
||||
"version": "2.0.4",
|
||||
"resolved": "https://registry.npmjs.org/mithril/-/mithril-2.0.4.tgz",
|
||||
"integrity": "sha512-mgw+DMZlhMS4PpprF6dl7ZoeZq5GGcAuWnrg5e12MvaGauc4jzWsDZtVGRCktsiQczOEUr2K5teKbE5k44RlOg=="
|
||||
},
|
||||
"mixin-deep": {
|
||||
"version": "1.3.2",
|
||||
@@ -3644,11 +3843,6 @@
|
||||
"minimist": "^1.2.5"
|
||||
}
|
||||
},
|
||||
"moment": {
|
||||
"version": "2.22.2",
|
||||
"resolved": "https://registry.npmjs.org/moment/-/moment-2.22.2.tgz",
|
||||
"integrity": "sha1-PCV/mDn8DpP/UxSWMiOeuQeD/2Y="
|
||||
},
|
||||
"move-concurrently": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/move-concurrently/-/move-concurrently-1.0.1.tgz",
|
||||
@@ -4352,11 +4546,6 @@
|
||||
"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",
|
||||
@@ -4706,21 +4895,29 @@
|
||||
}
|
||||
},
|
||||
"terser-webpack-plugin": {
|
||||
"version": "1.4.3",
|
||||
"resolved": "https://registry.npmjs.org/terser-webpack-plugin/-/terser-webpack-plugin-1.4.3.tgz",
|
||||
"integrity": "sha512-QMxecFz/gHQwteWwSo5nTc6UaICqN1bMedC5sMtUc7y3Ha3Q8y6ZO0iCR8pq4RJC8Hjf0FEPEHZqcMB/+DFCrA==",
|
||||
"version": "1.4.5",
|
||||
"resolved": "https://registry.npmjs.org/terser-webpack-plugin/-/terser-webpack-plugin-1.4.5.tgz",
|
||||
"integrity": "sha512-04Rfe496lN8EYruwi6oPQkG0vo8C+HT49X687FZnpPF0qMAIHONI6HEXYPKDOE8e5HjXTyKfqRd/agHtH0kOtw==",
|
||||
"requires": {
|
||||
"cacache": "^12.0.2",
|
||||
"find-cache-dir": "^2.1.0",
|
||||
"is-wsl": "^1.1.0",
|
||||
"schema-utils": "^1.0.0",
|
||||
"serialize-javascript": "^2.1.2",
|
||||
"serialize-javascript": "^4.0.0",
|
||||
"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",
|
||||
|
@@ -2,17 +2,19 @@
|
||||
"private": true,
|
||||
"name": "@flarum/core",
|
||||
"dependencies": {
|
||||
"@babel/preset-typescript": "^7.10.1",
|
||||
"@types/mithril": "^2.0.3",
|
||||
"bootstrap": "^3.4.1",
|
||||
"classnames": "^2.2.5",
|
||||
"color-thief-browser": "^2.0.2",
|
||||
"dayjs": "^1.8.28",
|
||||
"expose-loader": "^0.7.5",
|
||||
"flarum-webpack-config": "0.1.0-beta.10",
|
||||
"jquery": "^3.4.1",
|
||||
"jquery": "^3.5.1",
|
||||
"jquery.hotkeys": "^0.1.0",
|
||||
"lodash-es": "^4.17.14",
|
||||
"m.attrs.bidi": "github:tobscure/m.attrs.bidi",
|
||||
"mithril": "^0.2.8",
|
||||
"moment": "^2.22.2",
|
||||
"mithril": "^2.0.4",
|
||||
"punycode": "^2.1.1",
|
||||
"spin.js": "^3.1.0",
|
||||
"webpack": "^4.43.0",
|
||||
|
32
js/shims.d.ts
vendored
Normal file
32
js/shims.d.ts
vendored
Normal file
@@ -0,0 +1,32 @@
|
||||
// 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;
|
||||
}
|
@@ -1,13 +1,29 @@
|
||||
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: () => {},
|
||||
@@ -27,19 +43,29 @@ export default class AdminApplication extends Application {
|
||||
* @inheritdoc
|
||||
*/
|
||||
mount() {
|
||||
m.mount(document.getElementById('app-navigation'), Navigation.component({ className: 'App-backControl', drawer: true }));
|
||||
m.mount(document.getElementById('header-navigation'), Navigation.component());
|
||||
m.mount(document.getElementById('header-primary'), HeaderPrimary.component());
|
||||
m.mount(document.getElementById('header-secondary'), HeaderSecondary.component());
|
||||
m.mount(document.getElementById('admin-navigation'), AdminNav.component());
|
||||
// Mithril does not render the home route on https://example.com/admin, so
|
||||
// we need to go to https://example.com/admin#/ explicitly.
|
||||
if (!document.location.hash) document.location.hash = '#/';
|
||||
|
||||
m.route.mode = 'hash';
|
||||
m.route.prefix = '#';
|
||||
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]) {
|
||||
if (enabled && this.extensionSettings[enabled] && typeof this.extensionSettings[enabled] === 'function') {
|
||||
this.extensionSettings[enabled]();
|
||||
localStorage.removeItem('enabledExtension');
|
||||
}
|
||||
|
@@ -1,17 +1,21 @@
|
||||
import compat from '../common/compat';
|
||||
|
||||
import saveSettings from './utils/saveSettings';
|
||||
import ExtensionData from './utils/ExtensionData';
|
||||
import isExtensionEnabled from './utils/isExtensionEnabled';
|
||||
import getCategorizedExtensions from './utils/getCategorizedExtensions';
|
||||
import SettingDropdown from './components/SettingDropdown';
|
||||
import EditCustomFooterModal from './components/EditCustomFooterModal';
|
||||
import SessionDropdown from './components/SessionDropdown';
|
||||
import HeaderPrimary from './components/HeaderPrimary';
|
||||
import AppearancePage from './components/AppearancePage';
|
||||
import StatusWidget from './components/StatusWidget';
|
||||
import ExtensionsWidget from './components/ExtensionsWidget';
|
||||
import HeaderSecondary from './components/HeaderSecondary';
|
||||
import SettingsModal from './components/SettingsModal';
|
||||
import DashboardWidget from './components/DashboardWidget';
|
||||
import AddExtensionModal from './components/AddExtensionModal';
|
||||
import ExtensionsPage from './components/ExtensionsPage';
|
||||
import ExtensionPage from './components/ExtensionPage';
|
||||
import ExtensionLinkButton from './components/ExtensionLinkButton';
|
||||
import AdminLinkButton from './components/AdminLinkButton';
|
||||
import PermissionGrid from './components/PermissionGrid';
|
||||
import MailPage from './components/MailPage';
|
||||
@@ -23,6 +27,7 @@ import EditCustomHeaderModal from './components/EditCustomHeaderModal';
|
||||
import PermissionsPage from './components/PermissionsPage';
|
||||
import PermissionDropdown from './components/PermissionDropdown';
|
||||
import AdminNav from './components/AdminNav';
|
||||
import AdminHeader from './components/AdminHeader';
|
||||
import EditCustomCssModal from './components/EditCustomCssModal';
|
||||
import EditGroupModal from './components/EditGroupModal';
|
||||
import routes from './routes';
|
||||
@@ -30,17 +35,21 @@ import AdminApplication from './AdminApplication';
|
||||
|
||||
export default Object.assign(compat, {
|
||||
'utils/saveSettings': saveSettings,
|
||||
'utils/ExtensionData': ExtensionData,
|
||||
'utils/isExtensionEnabled': isExtensionEnabled,
|
||||
'utils/getCategorizedExtensions': getCategorizedExtensions,
|
||||
'components/SettingDropdown': SettingDropdown,
|
||||
'components/EditCustomFooterModal': EditCustomFooterModal,
|
||||
'components/SessionDropdown': SessionDropdown,
|
||||
'components/HeaderPrimary': HeaderPrimary,
|
||||
'components/AppearancePage': AppearancePage,
|
||||
'components/StatusWidget': StatusWidget,
|
||||
'components/ExtensionsWidget': ExtensionsWidget,
|
||||
'components/HeaderSecondary': HeaderSecondary,
|
||||
'components/SettingsModal': SettingsModal,
|
||||
'components/DashboardWidget': DashboardWidget,
|
||||
'components/AddExtensionModal': AddExtensionModal,
|
||||
'components/ExtensionsPage': ExtensionsPage,
|
||||
'components/ExtensionPage': ExtensionPage,
|
||||
'components/ExtensionLinkButton': ExtensionLinkButton,
|
||||
'components/AdminLinkButton': AdminLinkButton,
|
||||
'components/PermissionGrid': PermissionGrid,
|
||||
'components/MailPage': MailPage,
|
||||
@@ -52,6 +61,7 @@ export default Object.assign(compat, {
|
||||
'components/PermissionsPage': PermissionsPage,
|
||||
'components/PermissionDropdown': PermissionDropdown,
|
||||
'components/AdminNav': AdminNav,
|
||||
'components/AdminHeader': AdminHeader,
|
||||
'components/EditCustomCssModal': EditCustomCssModal,
|
||||
'components/EditGroupModal': EditGroupModal,
|
||||
routes: routes,
|
||||
|
19
js/src/admin/components/AdminHeader.js
Normal file
19
js/src/admin/components/AdminHeader.js
Normal file
@@ -0,0 +1,19 @@
|
||||
import Component from '../../common/Component';
|
||||
import classList from '../../common/utils/classList';
|
||||
import icon from '../../common/helpers/icon';
|
||||
|
||||
export default class AdminHeader extends Component {
|
||||
view(vnode) {
|
||||
return [
|
||||
<div className={classList(['AdminHeader', this.attrs.className])}>
|
||||
<div className="container">
|
||||
<h2>
|
||||
{icon(this.attrs.icon)}
|
||||
{vnode.children}
|
||||
</h2>
|
||||
<div className="AdminHeader-description">{this.attrs.description}</div>
|
||||
</div>
|
||||
</div>,
|
||||
];
|
||||
}
|
||||
}
|
@@ -10,11 +10,7 @@
|
||||
import LinkButton from '../../common/components/LinkButton';
|
||||
|
||||
export default class AdminLinkButton extends LinkButton {
|
||||
getButtonContent() {
|
||||
const content = super.getButtonContent();
|
||||
|
||||
content.push(<div className="AdminLinkButton-description">{this.props.description}</div>);
|
||||
|
||||
return content;
|
||||
getButtonContent(children) {
|
||||
return [...super.getButtonContent(children), <div className="AdminLinkButton-description">{this.attrs.description}</div>];
|
||||
}
|
||||
}
|
||||
|
@@ -1,28 +1,28 @@
|
||||
/*
|
||||
* This file is part of Flarum.
|
||||
*
|
||||
* (c) Toby Zerner <toby.zerner@gmail.com>
|
||||
*
|
||||
* For the full copyright and license information, please view the LICENSE
|
||||
* file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
import ExtensionLinkButton from './ExtensionLinkButton';
|
||||
import Component from '../../common/Component';
|
||||
import AdminLinkButton from './AdminLinkButton';
|
||||
import LinkButton from '../../common/components/LinkButton';
|
||||
import SelectDropdown from '../../common/components/SelectDropdown';
|
||||
import getCategorizedExtensions from '../utils/getCategorizedExtensions';
|
||||
import ItemList from '../../common/utils/ItemList';
|
||||
import Stream from '../../common/utils/Stream';
|
||||
|
||||
export default class AdminNav extends Component {
|
||||
oninit(vnode) {
|
||||
super.oninit(vnode);
|
||||
|
||||
this.query = Stream('');
|
||||
}
|
||||
|
||||
view() {
|
||||
return (
|
||||
<SelectDropdown className="AdminNav App-titleControl" buttonClassName="Button">
|
||||
{this.items().toArray()}
|
||||
<SelectDropdown className="AdminNav App-titleControl AdminNav-Main" buttonClassName="Button">
|
||||
{this.items().toArray().concat(this.extensionItems().toArray())}
|
||||
</SelectDropdown>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build an item list of links to show in the admin navigation.
|
||||
* Build an item list of main links to show in the admin navigation.
|
||||
*
|
||||
* @return {ItemList}
|
||||
*/
|
||||
@@ -31,64 +31,90 @@ export default class AdminNav extends Component {
|
||||
|
||||
items.add(
|
||||
'dashboard',
|
||||
AdminLinkButton.component({
|
||||
href: app.route('dashboard'),
|
||||
icon: 'far fa-chart-bar',
|
||||
children: app.translator.trans('core.admin.nav.dashboard_button'),
|
||||
description: app.translator.trans('core.admin.nav.dashboard_text'),
|
||||
})
|
||||
<LinkButton href={app.route('dashboard')} icon="far fa-chart-bar" title={app.translator.trans('core.admin.nav.dashboard_title')}>
|
||||
{app.translator.trans('core.admin.nav.dashboard_button')}
|
||||
</LinkButton>
|
||||
);
|
||||
|
||||
items.add(
|
||||
'basics',
|
||||
AdminLinkButton.component({
|
||||
href: app.route('basics'),
|
||||
icon: 'fas fa-pencil-alt',
|
||||
children: app.translator.trans('core.admin.nav.basics_button'),
|
||||
description: app.translator.trans('core.admin.nav.basics_text'),
|
||||
})
|
||||
<LinkButton href={app.route('basics')} icon="fas fa-pencil-alt" title={app.translator.trans('core.admin.nav.basics_title')}>
|
||||
{app.translator.trans('core.admin.nav.basics_button')}
|
||||
</LinkButton>
|
||||
);
|
||||
|
||||
items.add(
|
||||
'mail',
|
||||
AdminLinkButton.component({
|
||||
href: app.route('mail'),
|
||||
icon: 'fas fa-envelope',
|
||||
children: app.translator.trans('core.admin.nav.email_button'),
|
||||
description: app.translator.trans('core.admin.nav.email_text'),
|
||||
})
|
||||
<LinkButton href={app.route('mail')} icon="fas fa-envelope" title={app.translator.trans('core.admin.nav.email_title')}>
|
||||
{app.translator.trans('core.admin.nav.email_button')}
|
||||
</LinkButton>
|
||||
);
|
||||
|
||||
items.add(
|
||||
'permissions',
|
||||
AdminLinkButton.component({
|
||||
href: app.route('permissions'),
|
||||
icon: 'fas fa-key',
|
||||
children: app.translator.trans('core.admin.nav.permissions_button'),
|
||||
description: app.translator.trans('core.admin.nav.permissions_text'),
|
||||
})
|
||||
<LinkButton href={app.route('permissions')} icon="fas fa-key" title={app.translator.trans('core.admin.nav.permissions_title')}>
|
||||
{app.translator.trans('core.admin.nav.permissions_button')}
|
||||
</LinkButton>
|
||||
);
|
||||
|
||||
items.add(
|
||||
'appearance',
|
||||
AdminLinkButton.component({
|
||||
href: app.route('appearance'),
|
||||
icon: 'fas fa-paint-brush',
|
||||
children: app.translator.trans('core.admin.nav.appearance_button'),
|
||||
description: app.translator.trans('core.admin.nav.appearance_text'),
|
||||
})
|
||||
<LinkButton href={app.route('appearance')} icon="fas fa-paint-brush" title={app.translator.trans('core.admin.nav.appearance_title')}>
|
||||
{app.translator.trans('core.admin.nav.appearance_button')}
|
||||
</LinkButton>
|
||||
);
|
||||
|
||||
items.add(
|
||||
'extensions',
|
||||
AdminLinkButton.component({
|
||||
href: app.route('extensions'),
|
||||
icon: 'fas fa-puzzle-piece',
|
||||
children: app.translator.trans('core.admin.nav.extensions_button'),
|
||||
description: app.translator.trans('core.admin.nav.extensions_text'),
|
||||
})
|
||||
'search',
|
||||
<div className="Search-input">
|
||||
<input
|
||||
className="FormControl SearchBar"
|
||||
bidi={this.query}
|
||||
type="search"
|
||||
placeholder={app.translator.trans('core.admin.nav.search_placeholder')}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
return items;
|
||||
}
|
||||
|
||||
extensionItems() {
|
||||
const items = new ItemList();
|
||||
|
||||
const categorizedExtensions = getCategorizedExtensions();
|
||||
const categories = app.extensionCategories;
|
||||
|
||||
Object.keys(categorizedExtensions).map((category) => {
|
||||
if (!this.query()) {
|
||||
items.add(
|
||||
category,
|
||||
<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;
|
||||
}
|
||||
}
|
||||
|
@@ -1,25 +1,34 @@
|
||||
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 {
|
||||
init() {
|
||||
super.init();
|
||||
oninit(vnode) {
|
||||
super.oninit(vnode);
|
||||
|
||||
this.primaryColor = m.prop(app.data.settings.theme_primary_color);
|
||||
this.secondaryColor = m.prop(app.data.settings.theme_secondary_color);
|
||||
this.darkMode = m.prop(app.data.settings.theme_dark_mode === '1');
|
||||
this.coloredHeader = m.prop(app.data.settings.theme_colored_header === '1');
|
||||
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);
|
||||
}
|
||||
|
||||
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">
|
||||
@@ -27,40 +36,34 @@ 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"
|
||||
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)}
|
||||
/>
|
||||
<input className="FormControl" type="text" placeholder="#aaaaaa" bidi={this.primaryColor} />
|
||||
<input className="FormControl" type="text" placeholder="#aaaaaa" bidi={this.secondaryColor} />
|
||||
</div>
|
||||
|
||||
{Switch.component({
|
||||
state: this.darkMode(),
|
||||
children: app.translator.trans('core.admin.appearance.dark_mode_label'),
|
||||
onchange: this.darkMode,
|
||||
})}
|
||||
{Switch.component(
|
||||
{
|
||||
state: this.darkMode(),
|
||||
onchange: this.darkMode,
|
||||
},
|
||||
app.translator.trans('core.admin.appearance.dark_mode_label')
|
||||
)}
|
||||
|
||||
{Switch.component({
|
||||
state: this.coloredHeader(),
|
||||
children: app.translator.trans('core.admin.appearance.colored_header_label'),
|
||||
onchange: this.coloredHeader,
|
||||
})}
|
||||
{Switch.component(
|
||||
{
|
||||
state: this.coloredHeader(),
|
||||
onchange: this.coloredHeader,
|
||||
},
|
||||
app.translator.trans('core.admin.appearance.colored_header_label')
|
||||
)}
|
||||
|
||||
{Button.component({
|
||||
className: 'Button Button--primary',
|
||||
type: 'submit',
|
||||
children: app.translator.trans('core.admin.appearance.submit_button'),
|
||||
loading: this.loading,
|
||||
})}
|
||||
{Button.component(
|
||||
{
|
||||
className: 'Button Button--primary',
|
||||
type: 'submit',
|
||||
loading: this.loading,
|
||||
},
|
||||
app.translator.trans('core.admin.appearance.submit_button')
|
||||
)}
|
||||
</fieldset>
|
||||
</form>
|
||||
|
||||
@@ -79,31 +82,37 @@ 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',
|
||||
children: app.translator.trans('core.admin.appearance.edit_header_button'),
|
||||
onclick: () => app.modal.show(new EditCustomHeaderModal()),
|
||||
})}
|
||||
{Button.component(
|
||||
{
|
||||
className: 'Button',
|
||||
onclick: () => app.modal.show(EditCustomHeaderModal),
|
||||
},
|
||||
app.translator.trans('core.admin.appearance.edit_header_button')
|
||||
)}
|
||||
</fieldset>
|
||||
|
||||
<fieldset>
|
||||
<legend>{app.translator.trans('core.admin.appearance.custom_footer_heading')}</legend>
|
||||
<div className="helpText">{app.translator.trans('core.admin.appearance.custom_footer_text')}</div>
|
||||
{Button.component({
|
||||
className: 'Button',
|
||||
children: app.translator.trans('core.admin.appearance.edit_footer_button'),
|
||||
onclick: () => app.modal.show(new EditCustomFooterModal()),
|
||||
})}
|
||||
{Button.component(
|
||||
{
|
||||
className: 'Button',
|
||||
onclick: () => app.modal.show(EditCustomFooterModal),
|
||||
},
|
||||
app.translator.trans('core.admin.appearance.edit_footer_button')
|
||||
)}
|
||||
</fieldset>
|
||||
|
||||
<fieldset>
|
||||
<legend>{app.translator.trans('core.admin.appearance.custom_styles_heading')}</legend>
|
||||
<div className="helpText">{app.translator.trans('core.admin.appearance.custom_styles_text')}</div>
|
||||
{Button.component({
|
||||
className: 'Button',
|
||||
children: app.translator.trans('core.admin.appearance.edit_css_button'),
|
||||
onclick: () => app.modal.show(new EditCustomCssModal()),
|
||||
})}
|
||||
{Button.component(
|
||||
{
|
||||
className: 'Button',
|
||||
onclick: () => app.modal.show(EditCustomCssModal),
|
||||
},
|
||||
app.translator.trans('core.admin.appearance.edit_css_button')
|
||||
)}
|
||||
</fieldset>
|
||||
</div>
|
||||
</div>
|
||||
|
@@ -2,14 +2,16 @@ import Page from '../../common/components/Page';
|
||||
import FieldSet from '../../common/components/FieldSet';
|
||||
import Select from '../../common/components/Select';
|
||||
import Button from '../../common/components/Button';
|
||||
import Alert from '../../common/components/Alert';
|
||||
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 {
|
||||
init() {
|
||||
super.init();
|
||||
oninit(vnode) {
|
||||
super.oninit(vnode);
|
||||
|
||||
this.loading = false;
|
||||
|
||||
@@ -23,10 +25,6 @@ export default class BasicsPage extends Page {
|
||||
'welcome_message',
|
||||
'display_name_driver',
|
||||
];
|
||||
this.values = {};
|
||||
|
||||
const settings = app.data.settings;
|
||||
this.fields.forEach((key) => (this.values[key] = m.prop(settings[key])));
|
||||
|
||||
this.localeOptions = {};
|
||||
const locales = app.data.locales;
|
||||
@@ -40,55 +38,85 @@ export default class BasicsPage extends Page {
|
||||
this.displayNameOptions[identifier] = identifier;
|
||||
}, this);
|
||||
|
||||
this.slugDriverOptions = {};
|
||||
Object.keys(app.data.slugDrivers).forEach((model) => {
|
||||
this.fields.push(`slug_driver_${model}`);
|
||||
this.slugDriverOptions[model] = {};
|
||||
|
||||
app.data.slugDrivers[model].forEach((option) => {
|
||||
this.slugDriverOptions[model][option] = option;
|
||||
});
|
||||
});
|
||||
|
||||
this.values = {};
|
||||
|
||||
const settings = app.data.settings;
|
||||
this.fields.forEach((key) => (this.values[key] = Stream(settings[key])));
|
||||
|
||||
if (!this.values.display_name_driver() && displayNameDrivers.includes('username')) this.values.display_name_driver('username');
|
||||
|
||||
Object.keys(app.data.slugDrivers).forEach((model) => {
|
||||
if (!this.values[`slug_driver_${model}`]() && 'default' in this.slugDriverOptions[model]) {
|
||||
this.values[`slug_driver_${model}`]('default');
|
||||
}
|
||||
});
|
||||
|
||||
if (typeof this.values.show_language_selector() !== 'number') this.values.show_language_selector(1);
|
||||
}
|
||||
|
||||
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'),
|
||||
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_title_heading'),
|
||||
},
|
||||
[<input className="FormControl" bidi={this.values.forum_title} />]
|
||||
)}
|
||||
|
||||
{FieldSet.component({
|
||||
label: app.translator.trans('core.admin.basics.forum_description_heading'),
|
||||
children: [
|
||||
{FieldSet.component(
|
||||
{
|
||||
label: app.translator.trans('core.admin.basics.forum_description_heading'),
|
||||
},
|
||||
[
|
||||
<div className="helpText">{app.translator.trans('core.admin.basics.forum_description_text')}</div>,
|
||||
<textarea
|
||||
className="FormControl"
|
||||
value={this.values.forum_description()}
|
||||
oninput={m.withAttr('value', this.values.forum_description)}
|
||||
/>,
|
||||
],
|
||||
})}
|
||||
<textarea className="FormControl" bidi={this.values.forum_description} />,
|
||||
]
|
||||
)}
|
||||
|
||||
{Object.keys(this.localeOptions).length > 1
|
||||
? FieldSet.component({
|
||||
label: app.translator.trans('core.admin.basics.default_language_heading'),
|
||||
children: [
|
||||
? FieldSet.component(
|
||||
{
|
||||
label: app.translator.trans('core.admin.basics.default_language_heading'),
|
||||
},
|
||||
[
|
||||
Select.component({
|
||||
options: this.localeOptions,
|
||||
value: this.values.default_locale(),
|
||||
onchange: this.values.default_locale,
|
||||
}),
|
||||
Switch.component({
|
||||
state: this.values.show_language_selector(),
|
||||
onchange: this.values.show_language_selector,
|
||||
children: app.translator.trans('core.admin.basics.show_language_selector_label'),
|
||||
}),
|
||||
],
|
||||
})
|
||||
Switch.component(
|
||||
{
|
||||
state: this.values.show_language_selector(),
|
||||
onchange: this.values.show_language_selector,
|
||||
},
|
||||
app.translator.trans('core.admin.basics.show_language_selector_label')
|
||||
),
|
||||
]
|
||||
)
|
||||
: ''}
|
||||
|
||||
{FieldSet.component({
|
||||
label: app.translator.trans('core.admin.basics.home_page_heading'),
|
||||
className: 'BasicsPage-homePage',
|
||||
children: [
|
||||
{FieldSet.component(
|
||||
{
|
||||
label: app.translator.trans('core.admin.basics.home_page_heading'),
|
||||
className: 'BasicsPage-homePage',
|
||||
},
|
||||
[
|
||||
<div className="helpText">{app.translator.trans('core.admin.basics.home_page_text')}</div>,
|
||||
this.homePageItems()
|
||||
.toArray()
|
||||
@@ -99,51 +127,62 @@ export default class BasicsPage extends Page {
|
||||
name="homePage"
|
||||
value={path}
|
||||
checked={this.values.default_route() === path}
|
||||
onclick={m.withAttr('value', this.values.default_route)}
|
||||
onclick={withAttr('value', this.values.default_route)}
|
||||
/>
|
||||
{label}
|
||||
</label>
|
||||
)),
|
||||
],
|
||||
})}
|
||||
]
|
||||
)}
|
||||
|
||||
{FieldSet.component({
|
||||
label: app.translator.trans('core.admin.basics.welcome_banner_heading'),
|
||||
className: 'BasicsPage-welcomeBanner',
|
||||
children: [
|
||||
{FieldSet.component(
|
||||
{
|
||||
label: app.translator.trans('core.admin.basics.welcome_banner_heading'),
|
||||
className: 'BasicsPage-welcomeBanner',
|
||||
},
|
||||
[
|
||||
<div className="helpText">{app.translator.trans('core.admin.basics.welcome_banner_text')}</div>,
|
||||
<div className="BasicsPage-welcomeBanner-input">
|
||||
<input className="FormControl" 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)}
|
||||
/>
|
||||
<input className="FormControl" bidi={this.values.welcome_title} />
|
||||
<textarea className="FormControl" bidi={this.values.welcome_message} />
|
||||
</div>,
|
||||
],
|
||||
]
|
||||
)}
|
||||
|
||||
{Object.keys(this.displayNameOptions).length > 1 ? (
|
||||
<FieldSet label={app.translator.trans('core.admin.basics.display_name_heading')}>
|
||||
<div className="helpText">{app.translator.trans('core.admin.basics.display_name_text')}</div>
|
||||
<Select
|
||||
options={this.displayNameOptions}
|
||||
value={this.values.display_name_driver()}
|
||||
onchange={this.values.display_name_driver}
|
||||
></Select>
|
||||
</FieldSet>
|
||||
) : (
|
||||
''
|
||||
)}
|
||||
|
||||
{Object.keys(this.slugDriverOptions).map((model) => {
|
||||
const options = this.slugDriverOptions[model];
|
||||
if (Object.keys(options).length > 1) {
|
||||
return (
|
||||
<FieldSet label={app.translator.trans('core.admin.basics.slug_driver_heading', { model })}>
|
||||
<div className="helpText">{app.translator.trans('core.admin.basics.slug_driver_text', { model })}</div>
|
||||
<Select options={options} value={this.values[`slug_driver_${model}`]()} onchange={this.values[`slug_driver_${model}`]}></Select>
|
||||
</FieldSet>
|
||||
);
|
||||
}
|
||||
})}
|
||||
|
||||
{Object.keys(this.displayNameOptions).length > 1
|
||||
? 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,
|
||||
value: this.values.display_name_driver(),
|
||||
onchange: this.values.display_name_driver,
|
||||
}),
|
||||
],
|
||||
})
|
||||
: ''}
|
||||
|
||||
{Button.component({
|
||||
type: 'submit',
|
||||
className: 'Button Button--primary',
|
||||
children: app.translator.trans('core.admin.basics.submit_button'),
|
||||
loading: this.loading,
|
||||
disabled: !this.changed(),
|
||||
})}
|
||||
{Button.component(
|
||||
{
|
||||
type: 'submit',
|
||||
className: 'Button Button--primary',
|
||||
loading: this.loading,
|
||||
disabled: !this.changed(),
|
||||
},
|
||||
app.translator.trans('core.admin.basics.submit_button')
|
||||
)}
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
@@ -186,7 +225,7 @@ export default class BasicsPage extends Page {
|
||||
|
||||
saveSettings(settings)
|
||||
.then(() => {
|
||||
app.alerts.show((this.successAlert = new Alert({ type: 'success', children: app.translator.trans('core.admin.basics.saved_message') })));
|
||||
this.successAlert = app.alerts.show({ type: 'success' }, app.translator.trans('core.admin.basics.saved_message'));
|
||||
})
|
||||
.catch(() => {})
|
||||
.then(() => {
|
||||
|
@@ -1,16 +1,29 @@
|
||||
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">
|
||||
<div className="container">{this.availableWidgets()}</div>
|
||||
<AdminHeader icon="fas fa-chart-bar" description={app.translator.trans('core.admin.dashboard.description')} className="DashboardPage-header">
|
||||
{app.translator.trans('core.admin.dashboard.title')}
|
||||
</AdminHeader>
|
||||
<div className="container">{this.availableWidgets().toArray()}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
availableWidgets() {
|
||||
return [<StatusWidget />];
|
||||
const items = new ItemList();
|
||||
|
||||
items.add('status', <StatusWidget />, 30);
|
||||
|
||||
items.add('extensions', <ExtensionsWidget />, 10);
|
||||
|
||||
return items;
|
||||
}
|
||||
}
|
||||
|
@@ -4,20 +4,23 @@ 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 {
|
||||
init() {
|
||||
this.group = this.props.group || app.store.createRecord('groups');
|
||||
oninit(vnode) {
|
||||
super.oninit(vnode);
|
||||
|
||||
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);
|
||||
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);
|
||||
}
|
||||
|
||||
className() {
|
||||
@@ -53,18 +56,8 @@ 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')}
|
||||
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)}
|
||||
/>
|
||||
<input className="FormControl" placeholder={app.translator.trans('core.admin.edit_group.singular_placeholder')} bidi={this.nameSingular} />
|
||||
<input className="FormControl" placeholder={app.translator.trans('core.admin.edit_group.plural_placeholder')} bidi={this.namePlural} />
|
||||
</div>
|
||||
</div>,
|
||||
30
|
||||
@@ -74,7 +67,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" value={this.color()} oninput={m.withAttr('value', this.color)} />
|
||||
<input className="FormControl" placeholder="#aaaaaa" bidi={this.color} />
|
||||
</div>,
|
||||
20
|
||||
);
|
||||
@@ -86,7 +79,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" value={this.icon()} oninput={m.withAttr('value', this.icon)} />
|
||||
<input className="FormControl" placeholder="fas fa-bolt" bidi={this.icon} />
|
||||
</div>,
|
||||
10
|
||||
);
|
||||
@@ -94,11 +87,13 @@ export default class EditGroupModal extends Modal {
|
||||
items.add(
|
||||
'hidden',
|
||||
<div className="Form-group">
|
||||
{Switch.component({
|
||||
state: !!Number(this.isHidden()),
|
||||
children: app.translator.trans('core.admin.edit_group.hide_label'),
|
||||
onchange: this.isHidden,
|
||||
})}
|
||||
{Switch.component(
|
||||
{
|
||||
state: !!Number(this.isHidden()),
|
||||
onchange: this.isHidden,
|
||||
},
|
||||
app.translator.trans('core.admin.edit_group.hide_label')
|
||||
)}
|
||||
</div>,
|
||||
10
|
||||
);
|
||||
@@ -106,12 +101,14 @@ 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,
|
||||
children: app.translator.trans('core.admin.edit_group.submit_button'),
|
||||
})}
|
||||
{Button.component(
|
||||
{
|
||||
type: 'submit',
|
||||
className: 'Button Button--primary EditGroupModal-save',
|
||||
loading: this.loading,
|
||||
},
|
||||
app.translator.trans('core.admin.edit_group.submit_button')
|
||||
)}
|
||||
{this.group.exists && this.group.id() !== Group.ADMINISTRATOR_ID ? (
|
||||
<button type="button" className="Button EditGroupModal-delete" onclick={this.deleteGroup.bind(this)}>
|
||||
{app.translator.trans('core.admin.edit_group.delete_button')}
|
||||
|
29
js/src/admin/components/ExtensionLinkButton.js
Normal file
29
js/src/admin/components/ExtensionLinkButton.js
Normal file
@@ -0,0 +1,29 @@
|
||||
import isExtensionEnabled from '../utils/isExtensionEnabled';
|
||||
import LinkButton from '../../common/components/LinkButton';
|
||||
import icon from '../../common/helpers/icon';
|
||||
import ItemList from '../../common/utils/ItemList';
|
||||
|
||||
export default class ExtensionLinkButton extends LinkButton {
|
||||
getButtonContent(children) {
|
||||
const content = super.getButtonContent(children);
|
||||
const extension = app.data.extensions[this.attrs.extensionId];
|
||||
const statuses = this.statusItems(extension.id).toArray();
|
||||
|
||||
content.unshift(
|
||||
<span className="ExtensionListItem-icon ExtensionIcon" style={extension.icon}>
|
||||
{extension.icon ? icon(extension.icon.name) : ''}
|
||||
</span>
|
||||
);
|
||||
content.push(statuses);
|
||||
|
||||
return content;
|
||||
}
|
||||
|
||||
statusItems(name) {
|
||||
const items = new ItemList();
|
||||
|
||||
items.add('enabled', <span class={'ExtensionListItem-Dot ' + (isExtensionEnabled(name) ? 'enabled' : 'disabled')} />);
|
||||
|
||||
return items;
|
||||
}
|
||||
}
|
376
js/src/admin/components/ExtensionPage.js
Normal file
376
js/src/admin/components/ExtensionPage.js
Normal file
@@ -0,0 +1,376 @@
|
||||
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(', '),
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
39
js/src/admin/components/ExtensionPermissionGrid.js
Normal file
39
js/src/admin/components/ExtensionPermissionGrid.js
Normal file
@@ -0,0 +1,39 @@
|
||||
import PermissionGrid from './PermissionGrid';
|
||||
import ItemList from '../../common/utils/ItemList';
|
||||
|
||||
export default class ExtensionPermissionGrid extends PermissionGrid {
|
||||
oninit(vnode) {
|
||||
super.oninit(vnode);
|
||||
|
||||
this.extensionId = this.attrs.extensionId;
|
||||
}
|
||||
|
||||
permissionItems() {
|
||||
const permissionCategories = super.permissionItems();
|
||||
|
||||
permissionCategories.items = Object.entries(permissionCategories.items)
|
||||
.filter(([category, info]) => info.content.children.length > 0)
|
||||
.reduce((obj, [category, info]) => {
|
||||
obj[category] = info;
|
||||
return obj;
|
||||
}, {});
|
||||
|
||||
return permissionCategories;
|
||||
}
|
||||
|
||||
viewItems() {
|
||||
return app.extensionData.getExtensionPermissions(this.extensionId, 'view') || new ItemList();
|
||||
}
|
||||
|
||||
startItems() {
|
||||
return app.extensionData.getExtensionPermissions(this.extensionId, 'start') || new ItemList();
|
||||
}
|
||||
|
||||
replyItems() {
|
||||
return app.extensionData.getExtensionPermissions(this.extensionId, 'reply') || new ItemList();
|
||||
}
|
||||
|
||||
moderateItems() {
|
||||
return app.extensionData.getExtensionPermissions(this.extensionId, 'moderate') || new ItemList();
|
||||
}
|
||||
}
|
@@ -1,128 +0,0 @@
|
||||
import Page from '../../common/components/Page';
|
||||
import Button from '../../common/components/Button';
|
||||
import Dropdown from '../../common/components/Dropdown';
|
||||
import AddExtensionModal from './AddExtensionModal';
|
||||
import LoadingModal from './LoadingModal';
|
||||
import ItemList from '../../common/utils/ItemList';
|
||||
import icon from '../../common/helpers/icon';
|
||||
|
||||
export default class ExtensionsPage extends Page {
|
||||
view() {
|
||||
return (
|
||||
<div className="ExtensionsPage">
|
||||
<div className="ExtensionsPage-header">
|
||||
<div className="container">
|
||||
{Button.component({
|
||||
children: app.translator.trans('core.admin.extensions.add_button'),
|
||||
icon: 'fas fa-plus',
|
||||
className: 'Button Button--primary',
|
||||
onclick: () => app.modal.show(new AddExtensionModal()),
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="ExtensionsPage-list">
|
||||
<div className="container">
|
||||
<ul className="ExtensionList">
|
||||
{Object.keys(app.data.extensions).map((id) => {
|
||||
const extension = app.data.extensions[id];
|
||||
const controls = this.controlItems(extension.id).toArray();
|
||||
|
||||
return (
|
||||
<li className={'ExtensionListItem ' + (!this.isEnabled(extension.id) ? 'disabled' : '')}>
|
||||
<div className="ExtensionListItem-content">
|
||||
<span className="ExtensionListItem-icon ExtensionIcon" style={extension.icon}>
|
||||
{extension.icon ? icon(extension.icon.name) : ''}
|
||||
</span>
|
||||
{controls.length ? (
|
||||
<Dropdown
|
||||
className="ExtensionListItem-controls"
|
||||
buttonClassName="Button Button--icon Button--flat"
|
||||
menuClassName="Dropdown-menu--right"
|
||||
icon="fas fa-ellipsis-h"
|
||||
>
|
||||
{controls}
|
||||
</Dropdown>
|
||||
) : (
|
||||
''
|
||||
)}
|
||||
<div className="ExtensionListItem-main">
|
||||
<label className="ExtensionListItem-title">
|
||||
<input type="checkbox" checked={this.isEnabled(extension.id)} onclick={this.toggle.bind(this, extension.id)} />{' '}
|
||||
{extension.extra['flarum-extension'].title}
|
||||
</label>
|
||||
<div className="ExtensionListItem-version">{extension.version}</div>
|
||||
<div className="ExtensionListItem-description">{extension.description}</div>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
controlItems(name) {
|
||||
const items = new ItemList();
|
||||
const enabled = this.isEnabled(name);
|
||||
|
||||
if (app.extensionSettings[name]) {
|
||||
items.add(
|
||||
'settings',
|
||||
Button.component({
|
||||
icon: 'fas fa-cog',
|
||||
children: app.translator.trans('core.admin.extensions.settings_button'),
|
||||
onclick: app.extensionSettings[name],
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
if (!enabled) {
|
||||
items.add(
|
||||
'uninstall',
|
||||
Button.component({
|
||||
icon: 'far fa-trash-alt',
|
||||
children: app.translator.trans('core.admin.extensions.uninstall_button'),
|
||||
onclick: () => {
|
||||
app
|
||||
.request({
|
||||
url: app.forum.attribute('apiUrl') + '/extensions/' + name,
|
||||
method: 'DELETE',
|
||||
})
|
||||
.then(() => window.location.reload());
|
||||
|
||||
app.modal.show(new LoadingModal());
|
||||
},
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
return items;
|
||||
}
|
||||
|
||||
isEnabled(name) {
|
||||
const enabled = JSON.parse(app.data.settings.extensions_enabled);
|
||||
|
||||
return enabled.indexOf(name) !== -1;
|
||||
}
|
||||
|
||||
toggle(id) {
|
||||
const enabled = this.isEnabled(id);
|
||||
|
||||
app
|
||||
.request({
|
||||
url: app.forum.attribute('apiUrl') + '/extensions/' + id,
|
||||
method: 'PATCH',
|
||||
data: { enabled: !enabled },
|
||||
})
|
||||
.then(() => {
|
||||
if (!enabled) localStorage.setItem('enabledExtension', id);
|
||||
window.location.reload();
|
||||
});
|
||||
|
||||
app.modal.show(new LoadingModal());
|
||||
}
|
||||
}
|
48
js/src/admin/components/ExtensionsWidget.js
Normal file
48
js/src/admin/components/ExtensionsWidget.js
Normal file
@@ -0,0 +1,48 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
}
|
@@ -1,4 +1,5 @@
|
||||
import Component from '../../common/Component';
|
||||
import LinkButton from '../../common/components/LinkButton';
|
||||
import SessionDropdown from './SessionDropdown';
|
||||
import ItemList from '../../common/utils/ItemList';
|
||||
import listItems from '../../common/helpers/listItems';
|
||||
@@ -11,13 +12,6 @@ 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.
|
||||
*
|
||||
@@ -26,6 +20,13 @@ export default class HeaderSecondary extends Component {
|
||||
items() {
|
||||
const items = new ItemList();
|
||||
|
||||
items.add(
|
||||
'help',
|
||||
<LinkButton href="https://docs.flarum.org/troubleshoot.html" icon="fas fa-question-circle" external={true} target="_blank">
|
||||
{app.translator.trans('core.admin.header.get_help')}
|
||||
</LinkButton>
|
||||
);
|
||||
|
||||
items.add('session', SessionDropdown.component());
|
||||
|
||||
return items;
|
||||
|
@@ -1,9 +1,10 @@
|
||||
import Modal from '../../common/components/Modal';
|
||||
|
||||
export default class LoadingModal extends Modal {
|
||||
isDismissible() {
|
||||
return false;
|
||||
}
|
||||
/**
|
||||
* @inheritdoc
|
||||
*/
|
||||
static isDismissible = false;
|
||||
|
||||
className() {
|
||||
return 'LoadingModal Modal--small';
|
||||
|
@@ -5,10 +5,13 @@ 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 {
|
||||
init() {
|
||||
super.init();
|
||||
oninit(vnode) {
|
||||
super.oninit(vnode);
|
||||
|
||||
this.saving = false;
|
||||
this.sendingTest = false;
|
||||
@@ -24,7 +27,7 @@ export default class MailPage extends Page {
|
||||
this.status = { sending: false, errors: {} };
|
||||
|
||||
const settings = app.data.settings;
|
||||
this.fields.forEach((key) => (this.values[key] = m.prop(settings[key])));
|
||||
this.fields.forEach((key) => (this.values[key] = Stream(settings[key])));
|
||||
|
||||
app
|
||||
.request({
|
||||
@@ -39,7 +42,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] = m.prop(settings[field]);
|
||||
this.values[field] = Stream(settings[field]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -64,28 +67,32 @@ 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)}>
|
||||
<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: [
|
||||
{FieldSet.component(
|
||||
{
|
||||
label: app.translator.trans('core.admin.email.addresses_heading'),
|
||||
className: 'MailPage-MailSettings',
|
||||
},
|
||||
[
|
||||
<div className="MailPage-MailSettings-input">
|
||||
<label>
|
||||
{app.translator.trans('core.admin.email.from_label')}
|
||||
<input className="FormControl" value={this.values.mail_from() || ''} oninput={m.withAttr('value', this.values.mail_from)} />
|
||||
<input className="FormControl" bidi={this.values.mail_from} />
|
||||
</label>
|
||||
</div>,
|
||||
],
|
||||
})}
|
||||
]
|
||||
)}
|
||||
|
||||
{FieldSet.component({
|
||||
label: app.translator.trans('core.admin.email.driver_heading'),
|
||||
className: 'MailPage-MailSettings',
|
||||
children: [
|
||||
{FieldSet.component(
|
||||
{
|
||||
label: app.translator.trans('core.admin.email.driver_heading'),
|
||||
className: 'MailPage-MailSettings',
|
||||
},
|
||||
[
|
||||
<div className="MailPage-MailSettings-input">
|
||||
<label>
|
||||
{app.translator.trans('core.admin.email.driver_label')}
|
||||
@@ -96,20 +103,24 @@ export default class MailPage extends Page {
|
||||
/>
|
||||
</label>
|
||||
</div>,
|
||||
],
|
||||
})}
|
||||
]
|
||||
)}
|
||||
|
||||
{this.status.sending ||
|
||||
Alert.component({
|
||||
children: app.translator.trans('core.admin.email.not_sending_message'),
|
||||
dismissible: false,
|
||||
})}
|
||||
Alert.component(
|
||||
{
|
||||
dismissible: false,
|
||||
},
|
||||
app.translator.trans('core.admin.email.not_sending_message')
|
||||
)}
|
||||
|
||||
{fieldKeys.length > 0 &&
|
||||
FieldSet.component({
|
||||
label: app.translator.trans(`core.admin.email.${this.values.mail_driver()}_heading`),
|
||||
className: 'MailPage-MailSettings',
|
||||
children: [
|
||||
FieldSet.component(
|
||||
{
|
||||
label: app.translator.trans(`core.admin.email.${this.values.mail_driver()}_heading`),
|
||||
className: 'MailPage-MailSettings',
|
||||
},
|
||||
[
|
||||
<div className="MailPage-MailSettings-input">
|
||||
{fieldKeys.map((field) => [
|
||||
<label>
|
||||
@@ -119,31 +130,37 @@ 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',
|
||||
children: app.translator.trans('core.admin.email.submit_button'),
|
||||
disabled: !this.changed(),
|
||||
})}
|
||||
{Button.component(
|
||||
{
|
||||
type: 'submit',
|
||||
className: 'Button Button--primary',
|
||||
disabled: !this.changed(),
|
||||
},
|
||||
app.translator.trans('core.admin.email.submit_button')
|
||||
)}
|
||||
</FieldSet>
|
||||
|
||||
{FieldSet.component({
|
||||
label: app.translator.trans('core.admin.email.send_test_mail_heading'),
|
||||
className: 'MailPage-MailSettings',
|
||||
children: [
|
||||
{FieldSet.component(
|
||||
{
|
||||
label: app.translator.trans('core.admin.email.send_test_mail_heading'),
|
||||
className: 'MailPage-MailSettings',
|
||||
},
|
||||
[
|
||||
<div className="helpText">{app.translator.trans('core.admin.email.send_test_mail_text', { email: app.session.user.email() })}</div>,
|
||||
Button.component({
|
||||
className: 'Button Button--primary',
|
||||
children: app.translator.trans('core.admin.email.send_test_mail_button'),
|
||||
disabled: this.sendingTest || this.changed(),
|
||||
onclick: () => this.sendTestEmail(),
|
||||
}),
|
||||
],
|
||||
})}
|
||||
Button.component(
|
||||
{
|
||||
className: 'Button Button--primary',
|
||||
disabled: this.sendingTest || this.changed(),
|
||||
onclick: () => this.sendTestEmail(),
|
||||
},
|
||||
app.translator.trans('core.admin.email.send_test_mail_button')
|
||||
),
|
||||
]
|
||||
)}
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
@@ -156,7 +173,7 @@ export default class MailPage extends Page {
|
||||
const prop = this.values[name];
|
||||
|
||||
if (typeof field === 'string') {
|
||||
return <input className="FormControl" value={prop() || ''} oninput={m.withAttr('value', prop)} />;
|
||||
return <input className="FormControl" bidi={prop} />;
|
||||
} else {
|
||||
return <Select value={prop()} options={field} onchange={prop} />;
|
||||
}
|
||||
@@ -179,9 +196,7 @@ export default class MailPage extends Page {
|
||||
})
|
||||
.then((response) => {
|
||||
this.sendingTest = false;
|
||||
app.alerts.show(
|
||||
(this.testEmailSuccessAlert = new Alert({ type: 'success', children: app.translator.trans('core.admin.email.send_test_mail_success') }))
|
||||
);
|
||||
this.testEmailSuccessAlert = app.alerts.show({ type: 'success' }, app.translator.trans('core.admin.email.send_test_mail_success'));
|
||||
})
|
||||
.catch((error) => {
|
||||
this.sendingTest = false;
|
||||
@@ -204,7 +219,7 @@ export default class MailPage extends Page {
|
||||
|
||||
saveSettings(settings)
|
||||
.then(() => {
|
||||
app.alerts.show((this.successAlert = new Alert({ type: 'success', children: app.translator.trans('core.admin.basics.saved_message') })));
|
||||
this.successAlert = app.alerts.show({ type: 'success' }, app.translator.trans('core.admin.basics.saved_message'));
|
||||
})
|
||||
.catch(() => {})
|
||||
.then(() => {
|
||||
|
@@ -32,101 +32,109 @@ function filterByRequiredPermissions(groupIds, permission) {
|
||||
}
|
||||
|
||||
export default class PermissionDropdown extends Dropdown {
|
||||
static initProps(props) {
|
||||
super.initProps(props);
|
||||
static initAttrs(attrs) {
|
||||
super.initAttrs(attrs);
|
||||
|
||||
props.className = 'PermissionDropdown';
|
||||
props.buttonClassName = 'Button Button--text';
|
||||
attrs.className = 'PermissionDropdown';
|
||||
attrs.buttonClassName = 'Button Button--text';
|
||||
}
|
||||
|
||||
view() {
|
||||
this.props.children = [];
|
||||
view(vnode) {
|
||||
const children = [];
|
||||
|
||||
let groupIds = app.data.permissions[this.props.permission] || [];
|
||||
let groupIds = app.data.permissions[this.attrs.permission] || [];
|
||||
|
||||
groupIds = filterByRequiredPermissions(groupIds, this.props.permission);
|
||||
groupIds = filterByRequiredPermissions(groupIds, this.attrs.permission);
|
||||
|
||||
const everyone = groupIds.indexOf(Group.GUEST_ID) !== -1;
|
||||
const members = groupIds.indexOf(Group.MEMBER_ID) !== -1;
|
||||
const adminGroup = app.store.getById('groups', Group.ADMINISTRATOR_ID);
|
||||
|
||||
if (everyone) {
|
||||
this.props.label = Badge.component({ icon: 'fas fa-globe' });
|
||||
this.attrs.label = Badge.component({ icon: 'fas fa-globe' });
|
||||
} else if (members) {
|
||||
this.props.label = Badge.component({ icon: 'fas fa-user' });
|
||||
this.attrs.label = Badge.component({ icon: 'fas fa-user' });
|
||||
} else {
|
||||
this.props.label = [badgeForId(Group.ADMINISTRATOR_ID), groupIds.map(badgeForId)];
|
||||
this.attrs.label = [badgeForId(Group.ADMINISTRATOR_ID), groupIds.map(badgeForId)];
|
||||
}
|
||||
|
||||
if (this.showing) {
|
||||
if (this.props.allowGuest) {
|
||||
this.props.children.push(
|
||||
Button.component({
|
||||
children: [Badge.component({ icon: 'fas fa-globe' }), ' ', app.translator.trans('core.admin.permissions_controls.everyone_button')],
|
||||
icon: everyone ? 'fas fa-check' : true,
|
||||
onclick: () => this.save([Group.GUEST_ID]),
|
||||
disabled: this.isGroupDisabled(Group.GUEST_ID),
|
||||
})
|
||||
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')]
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
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),
|
||||
}),
|
||||
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')]
|
||||
),
|
||||
|
||||
Separator.component(),
|
||||
|
||||
Button.component({
|
||||
children: [badgeForId(adminGroup.id()), ' ', adminGroup.namePlural()],
|
||||
icon: !everyone && !members ? 'fas fa-check' : true,
|
||||
disabled: !everyone && !members,
|
||||
onclick: (e) => {
|
||||
if (e.shiftKey) e.stopPropagation();
|
||||
this.save([]);
|
||||
Button.component(
|
||||
{
|
||||
icon: !everyone && !members ? 'fas fa-check' : true,
|
||||
disabled: !everyone && !members,
|
||||
onclick: (e) => {
|
||||
if (e.shiftKey) e.stopPropagation();
|
||||
this.save([]);
|
||||
},
|
||||
},
|
||||
})
|
||||
[badgeForId(adminGroup.id()), ' ', adminGroup.namePlural()]
|
||||
)
|
||||
);
|
||||
|
||||
[].push.apply(
|
||||
this.props.children,
|
||||
children,
|
||||
app.store
|
||||
.all('groups')
|
||||
.filter((group) => [Group.ADMINISTRATOR_ID, Group.GUEST_ID, Group.MEMBER_ID].indexOf(group.id()) === -1)
|
||||
.map((group) =>
|
||||
Button.component({
|
||||
children: [badgeForId(group.id()), ' ', group.namePlural()],
|
||||
icon: groupIds.indexOf(group.id()) !== -1 ? 'fas fa-check' : true,
|
||||
onclick: (e) => {
|
||||
if (e.shiftKey) e.stopPropagation();
|
||||
this.toggle(group.id());
|
||||
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),
|
||||
},
|
||||
disabled: this.isGroupDisabled(group.id()) && this.isGroupDisabled(Group.MEMBER_ID) && this.isGroupDisabled(Group.GUEST_ID),
|
||||
})
|
||||
[badgeForId(group.id()), ' ', group.namePlural()]
|
||||
)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
return super.view();
|
||||
return super.view({ ...vnode, children });
|
||||
}
|
||||
|
||||
save(groupIds) {
|
||||
const permission = this.props.permission;
|
||||
const permission = this.attrs.permission;
|
||||
|
||||
app.data.permissions[permission] = groupIds;
|
||||
|
||||
app.request({
|
||||
method: 'POST',
|
||||
url: app.forum.attribute('apiUrl') + '/permission',
|
||||
data: { permission, groupIds },
|
||||
body: { permission, groupIds },
|
||||
});
|
||||
}
|
||||
|
||||
toggle(groupId) {
|
||||
const permission = this.props.permission;
|
||||
const permission = this.attrs.permission;
|
||||
|
||||
let groupIds = app.data.permissions[permission] || [];
|
||||
|
||||
@@ -143,6 +151,6 @@ export default class PermissionDropdown extends Dropdown {
|
||||
}
|
||||
|
||||
isGroupDisabled(id) {
|
||||
return filterByRequiredPermissions([id], this.props.permission).indexOf(id) === -1;
|
||||
return filterByRequiredPermissions([id], this.attrs.permission).indexOf(id) === -1;
|
||||
}
|
||||
}
|
||||
|
@@ -6,10 +6,6 @@ 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();
|
||||
|
||||
@@ -33,25 +29,27 @@ export default class PermissionGrid extends Component {
|
||||
<th>{this.scopeControlItems().toArray()}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
{this.permissions.map((section) => (
|
||||
<tbody>
|
||||
<tr className="PermissionGrid-section">
|
||||
<th>{section.label}</th>
|
||||
{permissionCells(section)}
|
||||
<td />
|
||||
</tr>
|
||||
{section.children.map((child) => (
|
||||
<tr className="PermissionGrid-child">
|
||||
<th>
|
||||
{icon(child.icon)}
|
||||
{child.label}
|
||||
</th>
|
||||
{permissionCells(child)}
|
||||
{this.permissionItems()
|
||||
.toArray()
|
||||
.map((section) => (
|
||||
<tbody>
|
||||
<tr className="PermissionGrid-section">
|
||||
<th>{section.label}</th>
|
||||
{permissionCells(section)}
|
||||
<td />
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
))}
|
||||
{section.children.map((child) => (
|
||||
<tr className="PermissionGrid-child">
|
||||
<th>
|
||||
{icon(child.icon)}
|
||||
{child.label}
|
||||
</th>
|
||||
{permissionCells(child)}
|
||||
<td />
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
))}
|
||||
</table>
|
||||
);
|
||||
}
|
||||
@@ -156,6 +154,8 @@ export default class PermissionGrid extends Component {
|
||||
permission: 'user.viewLastSeenAt',
|
||||
});
|
||||
|
||||
items.merge(app.extensionData.getAllExtensionPermissions('view'));
|
||||
|
||||
return items;
|
||||
}
|
||||
|
||||
@@ -196,6 +196,8 @@ export default class PermissionGrid extends Component {
|
||||
90
|
||||
);
|
||||
|
||||
items.merge(app.extensionData.getAllExtensionPermissions('start'));
|
||||
|
||||
return items;
|
||||
}
|
||||
|
||||
@@ -236,6 +238,8 @@ export default class PermissionGrid extends Component {
|
||||
90
|
||||
);
|
||||
|
||||
items.merge(app.extensionData.getAllExtensionPermissions('reply'));
|
||||
|
||||
return items;
|
||||
}
|
||||
|
||||
@@ -332,6 +336,8 @@ export default class PermissionGrid extends Component {
|
||||
60
|
||||
);
|
||||
|
||||
items.merge(app.extensionData.getAllExtensionPermissions('moderate'));
|
||||
|
||||
return items;
|
||||
}
|
||||
|
||||
|
@@ -4,18 +4,22 @@ 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
|
||||
.all('groups')
|
||||
.filter((group) => [Group.GUEST_ID, Group.MEMBER_ID].indexOf(group.id()) === -1)
|
||||
.map((group) => (
|
||||
<button className="Button Group" onclick={() => app.modal.show(new EditGroupModal({ group }))}>
|
||||
<button className="Button Group" onclick={() => app.modal.show(EditGroupModal, { group })}>
|
||||
{GroupBadge.component({
|
||||
group,
|
||||
className: 'Group-icon',
|
||||
@@ -24,7 +28,7 @@ export default class PermissionsPage extends Page {
|
||||
<span className="Group-name">{group.namePlural()}</span>
|
||||
</button>
|
||||
))}
|
||||
<button className="Button Group Group--add" onclick={() => app.modal.show(new EditGroupModal())}>
|
||||
<button className="Button Group Group--add" onclick={() => app.modal.show(EditGroupModal)}>
|
||||
{icon('fas fa-plus', { className: 'Group-icon' })}
|
||||
<span className="Group-name">{app.translator.trans('core.admin.permissions.new_group_button')}</span>
|
||||
</button>
|
||||
|
@@ -9,18 +9,16 @@ import ItemList from '../../common/utils/ItemList';
|
||||
* avatar/name, with a dropdown of session controls.
|
||||
*/
|
||||
export default class SessionDropdown extends Dropdown {
|
||||
static initProps(props) {
|
||||
super.initProps(props);
|
||||
static initAttrs(attrs) {
|
||||
super.initAttrs(attrs);
|
||||
|
||||
props.className = 'SessionDropdown';
|
||||
props.buttonClassName = 'Button Button--user Button--flat';
|
||||
props.menuClassName = 'Dropdown-menu--right';
|
||||
attrs.className = 'SessionDropdown';
|
||||
attrs.buttonClassName = 'Button Button--user Button--flat';
|
||||
attrs.menuClassName = 'Dropdown-menu--right';
|
||||
}
|
||||
|
||||
view() {
|
||||
this.props.children = this.items().toArray();
|
||||
|
||||
return super.view();
|
||||
view(vnode) {
|
||||
return super.view({ ...vnode, children: this.items().toArray() });
|
||||
}
|
||||
|
||||
getButtonContent() {
|
||||
@@ -39,11 +37,13 @@ export default class SessionDropdown extends Dropdown {
|
||||
|
||||
items.add(
|
||||
'logOut',
|
||||
Button.component({
|
||||
icon: 'fas fa-sign-out-alt',
|
||||
children: app.translator.trans('core.admin.header.log_out_button'),
|
||||
onclick: app.session.logout.bind(app.session),
|
||||
}),
|
||||
Button.component(
|
||||
{
|
||||
icon: 'fas fa-sign-out-alt',
|
||||
onclick: app.session.logout.bind(app.session),
|
||||
},
|
||||
app.translator.trans('core.admin.header.log_out_button')
|
||||
),
|
||||
-100
|
||||
);
|
||||
|
||||
|
@@ -3,23 +3,30 @@ import Button from '../../common/components/Button';
|
||||
import saveSettings from '../utils/saveSettings';
|
||||
|
||||
export default class SettingDropdown extends SelectDropdown {
|
||||
static initProps(props) {
|
||||
super.initProps(props);
|
||||
static initAttrs(attrs) {
|
||||
super.initAttrs(attrs);
|
||||
|
||||
props.className = 'SettingDropdown';
|
||||
props.buttonClassName = 'Button Button--text';
|
||||
props.caretIcon = 'fas fa-caret-down';
|
||||
props.defaultLabel = 'Custom';
|
||||
attrs.className = 'SettingDropdown';
|
||||
attrs.buttonClassName = 'Button Button--text';
|
||||
attrs.caretIcon = 'fas fa-caret-down';
|
||||
attrs.defaultLabel = 'Custom';
|
||||
}
|
||||
|
||||
props.children = props.options.map(({ value, label }) => {
|
||||
const active = app.data.settings[props.key] === value;
|
||||
view(vnode) {
|
||||
return super.view({
|
||||
...vnode,
|
||||
children: this.attrs.options.map(({ value, label }) => {
|
||||
const active = app.data.settings[this.attrs.key] === value;
|
||||
|
||||
return Button.component({
|
||||
children: label,
|
||||
icon: active ? 'fas fa-check' : true,
|
||||
onclick: saveSettings.bind(this, { [props.key]: value }),
|
||||
active,
|
||||
});
|
||||
return Button.component(
|
||||
{
|
||||
icon: active ? 'fas fa-check' : true,
|
||||
onclick: saveSettings.bind(this, { [this.attrs.key]: value }),
|
||||
active,
|
||||
},
|
||||
label
|
||||
);
|
||||
}),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
@@ -1,9 +1,12 @@
|
||||
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 {
|
||||
init() {
|
||||
oninit(vnode) {
|
||||
super.oninit(vnode);
|
||||
|
||||
this.settings = {};
|
||||
this.loading = false;
|
||||
}
|
||||
@@ -33,7 +36,7 @@ export default class SettingsModal extends Modal {
|
||||
}
|
||||
|
||||
setting(key, fallback = '') {
|
||||
this.settings[key] = this.settings[key] || m.prop(app.data.settings[key] || fallback);
|
||||
this.settings[key] = this.settings[key] || Stream(app.data.settings[key] || fallback);
|
||||
|
||||
return this.settings[key];
|
||||
}
|
||||
|
@@ -46,7 +46,7 @@ export default class StatusWidget extends DashboardWidget {
|
||||
}
|
||||
|
||||
handleClearCache(e) {
|
||||
app.modal.show(new LoadingModal());
|
||||
app.modal.show(LoadingModal);
|
||||
|
||||
app
|
||||
.request({
|
||||
|
@@ -1,32 +1,28 @@
|
||||
import Button from '../../common/components/Button';
|
||||
|
||||
export default class UploadImageButton extends Button {
|
||||
init() {
|
||||
this.loading = false;
|
||||
}
|
||||
loading = false;
|
||||
|
||||
view() {
|
||||
this.props.loading = this.loading;
|
||||
this.props.className = (this.props.className || '') + ' Button';
|
||||
view(vnode) {
|
||||
this.attrs.loading = this.loading;
|
||||
this.attrs.className = (this.attrs.className || '') + ' Button';
|
||||
|
||||
if (app.data.settings[this.props.name + '_path']) {
|
||||
this.props.onclick = this.remove.bind(this);
|
||||
this.props.children = app.translator.trans('core.admin.upload_image.remove_button');
|
||||
if (app.data.settings[this.attrs.name + '_path']) {
|
||||
this.attrs.onclick = this.remove.bind(this);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<p>
|
||||
<img src={app.forum.attribute(this.props.name + 'Url')} alt="" />
|
||||
<img src={app.forum.attribute(this.attrs.name + 'Url')} alt="" />
|
||||
</p>
|
||||
<p>{super.view()}</p>
|
||||
<p>{super.view({ ...vnode, children: app.translator.trans('core.admin.upload_image.remove_button') })}</p>
|
||||
</div>
|
||||
);
|
||||
} else {
|
||||
this.props.onclick = this.upload.bind(this);
|
||||
this.props.children = app.translator.trans('core.admin.upload_image.upload_button');
|
||||
this.attrs.onclick = this.upload.bind(this);
|
||||
}
|
||||
|
||||
return super.view();
|
||||
return super.view({ ...vnode, children: app.translator.trans('core.admin.upload_image.upload_button') });
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -42,8 +38,8 @@ export default class UploadImageButton extends Button {
|
||||
.hide()
|
||||
.click()
|
||||
.on('change', (e) => {
|
||||
const data = new FormData();
|
||||
data.append(this.props.name, $(e.target)[0].files[0]);
|
||||
const body = new FormData();
|
||||
body.append(this.attrs.name, $(e.target)[0].files[0]);
|
||||
|
||||
this.loading = true;
|
||||
m.redraw();
|
||||
@@ -53,7 +49,7 @@ export default class UploadImageButton extends Button {
|
||||
method: 'POST',
|
||||
url: this.resourceUrl(),
|
||||
serialize: (raw) => raw,
|
||||
data,
|
||||
body,
|
||||
})
|
||||
.then(this.success.bind(this), this.failure.bind(this));
|
||||
});
|
||||
@@ -75,7 +71,7 @@ export default class UploadImageButton extends Button {
|
||||
}
|
||||
|
||||
resourceUrl() {
|
||||
return app.forum.attribute('apiUrl') + '/' + this.props.name;
|
||||
return app.forum.attribute('apiUrl') + '/' + this.attrs.name;
|
||||
}
|
||||
|
||||
/**
|
||||
|
19
js/src/admin/resolvers/ExtensionPageResolver.ts
Normal file
19
js/src/admin/resolvers/ExtensionPageResolver.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import DefaultResolver from '../../common/resolvers/DefaultResolver';
|
||||
|
||||
/**
|
||||
* A custom route resolver for ExtensionPage that generates handles routes
|
||||
* to default extension pages or a page provided by an extension.
|
||||
*/
|
||||
export default class ExtensionPageResolver extends DefaultResolver {
|
||||
static extension: string | null = null;
|
||||
|
||||
onmatch(args, requestedPath, route) {
|
||||
const extensionPage = app.extensionData.getPage(args.id);
|
||||
|
||||
if (extensionPage) {
|
||||
return extensionPage;
|
||||
}
|
||||
|
||||
return super.onmatch(args, requestedPath, route);
|
||||
}
|
||||
}
|
@@ -2,8 +2,9 @@ import DashboardPage from './components/DashboardPage';
|
||||
import BasicsPage from './components/BasicsPage';
|
||||
import PermissionsPage from './components/PermissionsPage';
|
||||
import AppearancePage from './components/AppearancePage';
|
||||
import ExtensionsPage from './components/ExtensionsPage';
|
||||
import MailPage from './components/MailPage';
|
||||
import ExtensionPage from './components/ExtensionPage';
|
||||
import ExtensionPageResolver from './resolvers/ExtensionPageResolver';
|
||||
|
||||
/**
|
||||
* The `routes` initializer defines the forum app's routes.
|
||||
@@ -12,11 +13,11 @@ import MailPage from './components/MailPage';
|
||||
*/
|
||||
export default function (app) {
|
||||
app.routes = {
|
||||
dashboard: { path: '/', component: DashboardPage.component() },
|
||||
basics: { path: '/basics', component: BasicsPage.component() },
|
||||
permissions: { path: '/permissions', component: PermissionsPage.component() },
|
||||
appearance: { path: '/appearance', component: AppearancePage.component() },
|
||||
extensions: { path: '/extensions', component: ExtensionsPage.component() },
|
||||
mail: { path: '/mail', component: MailPage.component() },
|
||||
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 },
|
||||
};
|
||||
}
|
||||
|
167
js/src/admin/utils/ExtensionData.js
Normal file
167
js/src/admin/utils/ExtensionData.js
Normal file
@@ -0,0 +1,167 @@
|
||||
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;
|
||||
}
|
||||
}
|
25
js/src/admin/utils/getCategorizedExtensions.js
Normal file
25
js/src/admin/utils/getCategorizedExtensions.js
Normal file
@@ -0,0 +1,25 @@
|
||||
export default function getCategorizedExtensions() {
|
||||
let extensions = {};
|
||||
|
||||
Object.keys(app.data.extensions).map((id) => {
|
||||
const extension = app.data.extensions[id];
|
||||
let category = extension.extra['flarum-extension'].category;
|
||||
|
||||
// Wrap languages packs into new system
|
||||
if (extension.extra['flarum-locale']) {
|
||||
category = 'language';
|
||||
}
|
||||
|
||||
if (category in app.extensionCategories) {
|
||||
extensions[category] = extensions[category] || [];
|
||||
|
||||
extensions[category].push(extension);
|
||||
} else {
|
||||
extensions.other = extensions.other || [];
|
||||
|
||||
extensions.other.push(extension);
|
||||
}
|
||||
});
|
||||
|
||||
return extensions;
|
||||
}
|
5
js/src/admin/utils/isExtensionEnabled.js
Normal file
5
js/src/admin/utils/isExtensionEnabled.js
Normal file
@@ -0,0 +1,5 @@
|
||||
export default function isExtensionEnabled(name) {
|
||||
const enabled = JSON.parse(app.data.settings.extensions_enabled);
|
||||
|
||||
return enabled.includes(name);
|
||||
}
|
@@ -7,7 +7,7 @@ export default function saveSettings(settings) {
|
||||
.request({
|
||||
method: 'POST',
|
||||
url: app.forum.attribute('apiUrl') + '/settings',
|
||||
data: settings,
|
||||
body: settings,
|
||||
})
|
||||
.catch((error) => {
|
||||
app.data.settings = oldSettings;
|
||||
|
@@ -1,5 +1,4 @@
|
||||
import ItemList from './utils/ItemList';
|
||||
import Alert from './components/Alert';
|
||||
import Button from './components/Button';
|
||||
import ModalManager from './components/ModalManager';
|
||||
import AlertManager from './components/AlertManager';
|
||||
@@ -12,6 +11,7 @@ import Drawer from './utils/Drawer';
|
||||
import mapRoutes from './utils/mapRoutes';
|
||||
import RequestError from './utils/RequestError';
|
||||
import ScrollListener from './utils/ScrollListener';
|
||||
import liveHumanTimes from './utils/liveHumanTimes';
|
||||
import { extend } from './extend';
|
||||
|
||||
import Forum from './models/Forum';
|
||||
@@ -22,6 +22,8 @@ import Group from './models/Group';
|
||||
import Notification from './models/Notification';
|
||||
import { flattenDeep } from 'lodash-es';
|
||||
import PageState from './states/PageState';
|
||||
import ModalManagerState from './states/ModalManagerState';
|
||||
import AlertManagerState from './states/AlertManagerState';
|
||||
|
||||
/**
|
||||
* The `App` class provides a container for an application, as well as various
|
||||
@@ -108,13 +110,13 @@ export default class Application {
|
||||
booted = false;
|
||||
|
||||
/**
|
||||
* An Alert that was shown as a result of an AJAX request error. If present,
|
||||
* it will be dismissed on the next successful request.
|
||||
* The key for an Alert that was shown as a result of an AJAX request error.
|
||||
* If present, it will be dismissed on the next successful request.
|
||||
*
|
||||
* @type {null|Alert}
|
||||
* @type {int}
|
||||
* @private
|
||||
*/
|
||||
requestError = null;
|
||||
requestErrorAlert = null;
|
||||
|
||||
/**
|
||||
* The page the app is currently on.
|
||||
@@ -138,6 +140,20 @@ export default class Application {
|
||||
*/
|
||||
previous = new PageState(null);
|
||||
|
||||
/*
|
||||
* An object that manages modal state.
|
||||
*
|
||||
* @type {ModalManagerState}
|
||||
*/
|
||||
modal = new ModalManagerState();
|
||||
|
||||
/**
|
||||
* An object that manages the state of active alerts.
|
||||
*
|
||||
* @type {AlertManagerState}
|
||||
*/
|
||||
alerts = new AlertManagerState();
|
||||
|
||||
data;
|
||||
|
||||
title = '';
|
||||
@@ -173,25 +189,34 @@ export default class Application {
|
||||
}
|
||||
|
||||
mount(basePath = '') {
|
||||
this.modal = m.mount(document.getElementById('modal'), <ModalManager />);
|
||||
this.alerts = m.mount(document.getElementById('alerts'), <AlertManager />);
|
||||
// An object with a callable view property is used in order to pass arguments to the component; see https://mithril.js.org/mount.html
|
||||
m.mount(document.getElementById('modal'), { view: () => ModalManager.component({ state: this.modal }) });
|
||||
m.mount(document.getElementById('alerts'), { view: () => AlertManager.component({ state: this.alerts }) });
|
||||
|
||||
this.drawer = new Drawer();
|
||||
|
||||
m.route(document.getElementById('content'), basePath + '/', mapRoutes(this.routes, basePath));
|
||||
|
||||
// Add a class to the body which indicates that the page has been scrolled
|
||||
// down.
|
||||
new ScrollListener((top) => {
|
||||
// down. When this happens, we'll add classes to the header and app body
|
||||
// which will set the navbar's position to fixed. We don't want to always
|
||||
// have it fixed, as that could overlap with custom headers.
|
||||
const scrollListener = new ScrollListener((top) => {
|
||||
const $app = $('#app');
|
||||
const offset = $app.offset().top;
|
||||
|
||||
$app.toggleClass('affix', top >= offset).toggleClass('scrolled', top > offset);
|
||||
}).start();
|
||||
$('.App-header').toggleClass('navbar-fixed-top', top >= offset);
|
||||
});
|
||||
|
||||
scrollListener.start();
|
||||
scrollListener.update();
|
||||
|
||||
$(() => {
|
||||
$('body').addClass('ontouchstart' in window ? 'touch' : 'no-touch');
|
||||
});
|
||||
|
||||
liveHumanTimes();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -212,6 +237,16 @@ export default class Application {
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine the current screen mode, based on our media queries.
|
||||
*
|
||||
* @returns {String} - one of "phone", "tablet", "desktop" or "desktop-hd"
|
||||
*/
|
||||
screen() {
|
||||
const styles = getComputedStyle(document.documentElement);
|
||||
return styles.getPropertyValue('--flarum-screen');
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the <title> of the page.
|
||||
*
|
||||
@@ -234,13 +269,16 @@ export default class Application {
|
||||
}
|
||||
|
||||
updateTitle() {
|
||||
document.title = (this.titleCount ? `(${this.titleCount}) ` : '') + (this.title ? this.title + ' - ' : '') + this.forum.attribute('title');
|
||||
const count = this.titleCount ? `(${this.titleCount}) ` : '';
|
||||
const pageTitleWithSeparator = this.title && m.route.get() !== '/' ? this.title + ' - ' : '';
|
||||
const title = this.forum.attribute('title');
|
||||
document.title = count + pageTitleWithSeparator + title;
|
||||
}
|
||||
|
||||
/**
|
||||
* Make an AJAX request, handling any low-level errors that may occur.
|
||||
*
|
||||
* @see https://lhorie.github.io/mithril/mithril.request.html
|
||||
* @see https://mithril.js.org/request.html
|
||||
* @param {Object} options
|
||||
* @return {Promise}
|
||||
* @public
|
||||
@@ -307,22 +345,18 @@ export default class Application {
|
||||
}
|
||||
};
|
||||
|
||||
if (this.requestError) this.alerts.dismiss(this.requestError.alert);
|
||||
if (this.requestErrorAlert) this.alerts.dismiss(this.requestErrorAlert);
|
||||
|
||||
// Now make the request. If it's a failure, inspect the error that was
|
||||
// returned and show an alert containing its contents.
|
||||
const deferred = m.deferred();
|
||||
|
||||
m.request(options).then(
|
||||
(response) => deferred.resolve(response),
|
||||
return m.request(options).then(
|
||||
(response) => response,
|
||||
(error) => {
|
||||
this.requestError = error;
|
||||
|
||||
let children;
|
||||
let content;
|
||||
|
||||
switch (error.status) {
|
||||
case 422:
|
||||
children = error.response.errors
|
||||
content = error.response.errors
|
||||
.map((error) => [error.detail, <br />])
|
||||
.reduce((a, b) => a.concat(b), [])
|
||||
.slice(0, -1);
|
||||
@@ -330,36 +364,37 @@ export default class Application {
|
||||
|
||||
case 401:
|
||||
case 403:
|
||||
children = app.translator.trans('core.lib.error.permission_denied_message');
|
||||
content = app.translator.trans('core.lib.error.permission_denied_message');
|
||||
break;
|
||||
|
||||
case 404:
|
||||
case 410:
|
||||
children = app.translator.trans('core.lib.error.not_found_message');
|
||||
content = app.translator.trans('core.lib.error.not_found_message');
|
||||
break;
|
||||
|
||||
case 429:
|
||||
children = app.translator.trans('core.lib.error.rate_limit_exceeded_message');
|
||||
content = app.translator.trans('core.lib.error.rate_limit_exceeded_message');
|
||||
break;
|
||||
|
||||
default:
|
||||
children = app.translator.trans('core.lib.error.generic_message');
|
||||
content = 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 formattedError = error.response && Array.isArray(error.response.errors) && error.response.errors.map((e) => decodeURI(e.detail));
|
||||
const errors = error.response && error.response.errors;
|
||||
const formattedError = Array.isArray(errors) && errors[0] && errors[0].detail && errors.map((e) => decodeURI(e.detail));
|
||||
|
||||
error.alert = new Alert({
|
||||
error.alert = {
|
||||
type: 'error',
|
||||
children,
|
||||
content,
|
||||
controls: isDebug && [
|
||||
<Button className="Button Button--link" onclick={this.showDebug.bind(this, error, formattedError)}>
|
||||
Debug
|
||||
</Button>,
|
||||
],
|
||||
});
|
||||
};
|
||||
|
||||
try {
|
||||
options.errorHandler(error);
|
||||
@@ -375,14 +410,12 @@ export default class Application {
|
||||
console.groupEnd();
|
||||
}
|
||||
|
||||
this.alerts.show(error.alert);
|
||||
this.requestErrorAlert = this.alerts.show(error.alert, error.alert.content);
|
||||
}
|
||||
|
||||
deferred.reject(error);
|
||||
return Promise.reject(error);
|
||||
}
|
||||
);
|
||||
|
||||
return deferred.promise;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -391,9 +424,9 @@ export default class Application {
|
||||
* @private
|
||||
*/
|
||||
showDebug(error, formattedError) {
|
||||
this.alerts.dismiss(this.requestError.alert);
|
||||
this.alerts.dismiss(this.requestErrorAlert);
|
||||
|
||||
this.modal.show(new RequestErrorModal({ error, formattedError }));
|
||||
this.modal.show(RequestErrorModal, { error, formattedError });
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -405,9 +438,19 @@ export default class Application {
|
||||
* @public
|
||||
*/
|
||||
route(name, params = {}) {
|
||||
const url = this.routes[name].path.replace(/:([^\/]+)/g, (m, key) => extract(params, key));
|
||||
const queryString = m.route.buildQueryString(params);
|
||||
const prefix = m.route.mode === 'pathname' ? app.forum.attribute('basePath') : '';
|
||||
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') : '';
|
||||
|
||||
return prefix + url + (queryString ? '?' + queryString : '');
|
||||
}
|
||||
|
@@ -1,221 +0,0 @@
|
||||
/*
|
||||
* This file is part of Flarum.
|
||||
*
|
||||
* (c) Toby Zerner <toby.zerner@gmail.com>
|
||||
*
|
||||
* For the full copyright and license information, please view the LICENSE
|
||||
* file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
/**
|
||||
* The `Component` class defines a user interface 'building block'. A component
|
||||
* can generate a virtual DOM to be rendered on each redraw.
|
||||
*
|
||||
* An instance's virtual DOM can be retrieved directly using the {@link
|
||||
* Component#render} method.
|
||||
*
|
||||
* @example
|
||||
* this.myComponentInstance = new MyComponent({foo: 'bar'});
|
||||
* return m('div', this.myComponentInstance.render());
|
||||
*
|
||||
* Alternatively, components can be nested, letting Mithril take care of
|
||||
* instance persistence. For this, the static {@link Component.component} method
|
||||
* can be used.
|
||||
*
|
||||
* @example
|
||||
* return m('div', MyComponent.component({foo: 'bar'));
|
||||
*
|
||||
* @see https://lhorie.github.io/mithril/mithril.component.html
|
||||
* @abstract
|
||||
*/
|
||||
export default class Component {
|
||||
/**
|
||||
* @param {Object} props
|
||||
* @param {Array|Object} children
|
||||
* @public
|
||||
*/
|
||||
constructor(props = {}, children = null) {
|
||||
if (children) props.children = children;
|
||||
|
||||
this.constructor.initProps(props);
|
||||
|
||||
/**
|
||||
* The properties passed into the component.
|
||||
*
|
||||
* @type {Object}
|
||||
*/
|
||||
this.props = props;
|
||||
|
||||
/**
|
||||
* The root DOM element for the component.
|
||||
*
|
||||
* @type DOMElement
|
||||
* @public
|
||||
*/
|
||||
this.element = null;
|
||||
|
||||
/**
|
||||
* Whether or not to retain the component's subtree on redraw.
|
||||
*
|
||||
* @type {boolean}
|
||||
* @public
|
||||
*/
|
||||
this.retain = false;
|
||||
|
||||
this.init();
|
||||
}
|
||||
|
||||
/**
|
||||
* Called when the component is constructed.
|
||||
*
|
||||
* @protected
|
||||
*/
|
||||
init() {}
|
||||
|
||||
/**
|
||||
* Called when the component is destroyed, i.e. after a redraw where it is no
|
||||
* longer a part of the view.
|
||||
*
|
||||
* @see https://lhorie.github.io/mithril/mithril.component.html#unloading-components
|
||||
* @param {Object} e
|
||||
* @public
|
||||
*/
|
||||
onunload() {}
|
||||
|
||||
/**
|
||||
* Get the renderable virtual DOM that represents the component's view.
|
||||
*
|
||||
* This should NOT be overridden by subclasses. Subclasses wishing to define
|
||||
* their virtual DOM should override Component#view instead.
|
||||
*
|
||||
* @example
|
||||
* this.myComponentInstance = new MyComponent({foo: 'bar'});
|
||||
* return m('div', this.myComponentInstance.render());
|
||||
*
|
||||
* @returns {Object}
|
||||
* @final
|
||||
* @public
|
||||
*/
|
||||
render() {
|
||||
const vdom = this.retain ? { subtree: 'retain' } : this.view();
|
||||
|
||||
// Override the root element's config attribute with our own function, which
|
||||
// will set the component instance's element property to the root DOM
|
||||
// element, and then run the component class' config method.
|
||||
vdom.attrs = vdom.attrs || {};
|
||||
|
||||
const originalConfig = vdom.attrs.config;
|
||||
|
||||
vdom.attrs.config = (...args) => {
|
||||
this.element = args[0];
|
||||
this.config.apply(this, args.slice(1));
|
||||
if (originalConfig) originalConfig.apply(this, args);
|
||||
};
|
||||
|
||||
return vdom;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a jQuery object for this component's element. If you pass in a
|
||||
* selector string, this method will return a jQuery object, using the current
|
||||
* element as its buffer.
|
||||
*
|
||||
* For example, calling `component.$('li')` will return a jQuery object
|
||||
* containing all of the `li` elements inside the DOM element of this
|
||||
* component.
|
||||
*
|
||||
* @param {String} [selector] a jQuery-compatible selector string
|
||||
* @returns {jQuery} the jQuery object for the DOM node
|
||||
* @final
|
||||
* @public
|
||||
*/
|
||||
$(selector) {
|
||||
const $element = $(this.element);
|
||||
|
||||
return selector ? $element.find(selector) : $element;
|
||||
}
|
||||
|
||||
/**
|
||||
* Called after the component's root element is redrawn. This hook can be used
|
||||
* to perform any actions on the DOM, both on the initial draw and any
|
||||
* subsequent redraws. See Mithril's documentation for more information.
|
||||
*
|
||||
* @see https://lhorie.github.io/mithril/mithril.html#the-config-attribute
|
||||
* @param {Boolean} isInitialized
|
||||
* @param {Object} context
|
||||
* @param {Object} vdom
|
||||
* @public
|
||||
*/
|
||||
config() {}
|
||||
|
||||
/**
|
||||
* Get the virtual DOM that represents the component's view.
|
||||
*
|
||||
* @return {Object} The virtual DOM
|
||||
* @protected
|
||||
*/
|
||||
view() {
|
||||
throw new Error('Component#view must be implemented by subclass');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a Mithril component object for this component, preloaded with props.
|
||||
*
|
||||
* @see https://lhorie.github.io/mithril/mithril.component.html
|
||||
* @param {Object} [props] Properties to set on the component
|
||||
* @param children
|
||||
* @return {Object} The Mithril component object
|
||||
* @property {function} controller
|
||||
* @property {function} view
|
||||
* @property {Object} component The class of this component
|
||||
* @property {Object} props The props that were passed to the component
|
||||
* @public
|
||||
*/
|
||||
static component(props = {}, children = null) {
|
||||
const componentProps = Object.assign({}, props);
|
||||
|
||||
if (children) componentProps.children = children;
|
||||
|
||||
this.initProps(componentProps);
|
||||
|
||||
// Set up a function for Mithril to get the component's view. It will accept
|
||||
// the component's controller (which happens to be the component itself, in
|
||||
// our case), update its props with the ones supplied, and then render the view.
|
||||
const view = (component) => {
|
||||
component.props = componentProps;
|
||||
return component.render();
|
||||
};
|
||||
|
||||
// Mithril uses this property on the view function to cache component
|
||||
// controllers between redraws, thus persisting component state.
|
||||
view.$original = this.prototype.view;
|
||||
|
||||
// Our output object consists of a controller constructor + a view function
|
||||
// which Mithril will use to instantiate and render the component. We also
|
||||
// attach a reference to the props that were passed through and the
|
||||
// component's class for reference.
|
||||
const output = {
|
||||
controller: this.bind(undefined, componentProps),
|
||||
view: view,
|
||||
props: componentProps,
|
||||
component: this,
|
||||
};
|
||||
|
||||
// If a `key` prop was set, then we'll assume that we want that to actually
|
||||
// show up as an attribute on the component object so that Mithril's key
|
||||
// algorithm can be applied.
|
||||
if (componentProps.key) {
|
||||
output.attrs = { key: componentProps.key };
|
||||
}
|
||||
|
||||
return output;
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize the component's props.
|
||||
*
|
||||
* @param {Object} props
|
||||
* @public
|
||||
*/
|
||||
static initProps(props) {}
|
||||
}
|
168
js/src/common/Component.ts
Normal file
168
js/src/common/Component.ts
Normal file
@@ -0,0 +1,168 @@
|
||||
import * as Mithril from 'mithril';
|
||||
|
||||
let deprecatedPropsWarned = false;
|
||||
let deprecatedInitPropsWarned = false;
|
||||
|
||||
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 {
|
||||
// Deprecated, part of Mithril 2 BC layer
|
||||
if ('initProps' in this && !deprecatedInitPropsWarned) {
|
||||
deprecatedInitPropsWarned = true;
|
||||
console.warn('initProps is deprecated, please use initAttrs instead.');
|
||||
(this as any).initProps(attrs);
|
||||
}
|
||||
}
|
||||
|
||||
// BEGIN DEPRECATED MITHRIL 2 BC LAYER
|
||||
|
||||
/**
|
||||
* The attributes passed into the component.
|
||||
*
|
||||
* @see https://mithril.js.org/components.html#passing-data-to-components
|
||||
*
|
||||
* @deprecated, use attrs instead.
|
||||
*/
|
||||
get props() {
|
||||
if (!deprecatedPropsWarned) {
|
||||
deprecatedPropsWarned = true;
|
||||
console.warn('this.props is deprecated, please use this.attrs instead.');
|
||||
}
|
||||
return this.attrs;
|
||||
}
|
||||
set props(props) {
|
||||
if (!deprecatedPropsWarned) {
|
||||
deprecatedPropsWarned = true;
|
||||
console.warn('this.props is deprecated, please use this.attrs instead.');
|
||||
}
|
||||
this.attrs = props;
|
||||
}
|
||||
|
||||
// END DEPRECATED MITHRIL 2 BC LAYER
|
||||
}
|
74
js/src/common/Fragment.ts
Normal file
74
js/src/common/Fragment.ts
Normal file
@@ -0,0 +1,74 @@
|
||||
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>;
|
||||
}
|
@@ -161,7 +161,7 @@ export default class Model {
|
||||
{
|
||||
method: this.exists ? 'PATCH' : 'POST',
|
||||
url: app.forum.attribute('apiUrl') + this.apiEndpoint(),
|
||||
data: request,
|
||||
body: 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.lazyRedraw();
|
||||
m.redraw();
|
||||
throw response;
|
||||
}
|
||||
);
|
||||
@@ -189,13 +189,13 @@ export default class Model {
|
||||
/**
|
||||
* Send a request to delete the resource.
|
||||
*
|
||||
* @param {Object} data Data to send along with the DELETE request.
|
||||
* @param {Object} body Data to send along with the DELETE request.
|
||||
* @param {Object} [options]
|
||||
* @return {Promise}
|
||||
* @public
|
||||
*/
|
||||
delete(data, options = {}) {
|
||||
if (!this.exists) return m.deferred().resolve().promise;
|
||||
delete(body, options = {}) {
|
||||
if (!this.exists) return Promise.resolve();
|
||||
|
||||
return app
|
||||
.request(
|
||||
@@ -203,7 +203,7 @@ export default class Model {
|
||||
{
|
||||
method: 'DELETE',
|
||||
url: app.forum.attribute('apiUrl') + this.apiEndpoint(),
|
||||
data,
|
||||
body,
|
||||
},
|
||||
options
|
||||
)
|
||||
|
@@ -30,13 +30,13 @@ export default class Session {
|
||||
* @return {Promise}
|
||||
* @public
|
||||
*/
|
||||
login(data, options = {}) {
|
||||
login(body, options = {}) {
|
||||
return app.request(
|
||||
Object.assign(
|
||||
{
|
||||
method: 'POST',
|
||||
url: app.forum.attribute('baseUrl') + '/login',
|
||||
data,
|
||||
url: `${app.forum.attribute('baseUrl')}/login`,
|
||||
body,
|
||||
},
|
||||
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}`;
|
||||
}
|
||||
}
|
||||
|
@@ -82,13 +82,13 @@ export default class Store {
|
||||
* @public
|
||||
*/
|
||||
find(type, id, query = {}, options = {}) {
|
||||
let data = query;
|
||||
let params = query;
|
||||
let url = app.forum.attribute('apiUrl') + '/' + type;
|
||||
|
||||
if (id instanceof Array) {
|
||||
url += '?filter[id]=' + id.join(',');
|
||||
} else if (typeof id === 'object') {
|
||||
data = id;
|
||||
params = id;
|
||||
} else if (id) {
|
||||
url += '/' + id;
|
||||
}
|
||||
@@ -99,7 +99,7 @@ export default class Store {
|
||||
{
|
||||
method: 'GET',
|
||||
url,
|
||||
data,
|
||||
params,
|
||||
},
|
||||
options
|
||||
)
|
||||
|
@@ -1,4 +1,3 @@
|
||||
import User from './models/User';
|
||||
import username from './helpers/username';
|
||||
import extract from './utils/extract';
|
||||
|
||||
@@ -71,18 +70,34 @@ 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);
|
||||
}
|
||||
});
|
||||
|
@@ -12,15 +12,20 @@ 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';
|
||||
@@ -43,6 +48,7 @@ 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';
|
||||
@@ -61,6 +67,8 @@ 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,
|
||||
@@ -81,11 +89,16 @@ 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,
|
||||
@@ -93,6 +106,7 @@ export default {
|
||||
'models/Group': Group,
|
||||
'models/Forum': Forum,
|
||||
Component: Component,
|
||||
Fragment: Fragment,
|
||||
Translator: Translator,
|
||||
'components/AlertManager': AlertManager,
|
||||
'components/Page': Page,
|
||||
@@ -108,6 +122,7 @@ export default {
|
||||
'components/Select': Select,
|
||||
'components/Navigation': Navigation,
|
||||
'components/Alert': Alert,
|
||||
'components/Link': Link,
|
||||
'components/LinkButton': LinkButton,
|
||||
'components/Checkbox': Checkbox,
|
||||
'components/SelectDropdown': SelectDropdown,
|
||||
@@ -126,4 +141,5 @@ export default {
|
||||
'helpers/username': username,
|
||||
'helpers/userOnline': userOnline,
|
||||
'helpers/listItems': listItems,
|
||||
'resolvers/DefaultResolver': DefaultResolver,
|
||||
};
|
||||
|
@@ -1,31 +1,33 @@
|
||||
import Component from '../Component';
|
||||
import Component, { ComponentAttrs } from '../Component';
|
||||
import Button from './Button';
|
||||
import listItems from '../helpers/listItems';
|
||||
import extract from '../utils/extract';
|
||||
import Mithril from 'mithril';
|
||||
|
||||
export interface AlertAttrs extends ComponentAttrs {
|
||||
/** The type of alert this is. Will be used to give the alert a class name of `Alert--{type}`. */
|
||||
type?: string;
|
||||
/** An array of controls to show in the alert. */
|
||||
controls?: Mithril.Children;
|
||||
/** Whether or not the alert can be dismissed. */
|
||||
dismissible?: boolean;
|
||||
/** A callback to run when the alert is dismissed */
|
||||
ondismiss?: Function;
|
||||
}
|
||||
|
||||
/**
|
||||
* The `Alert` component represents an alert box, which contains a message,
|
||||
* some controls, and may be dismissible.
|
||||
*
|
||||
* The alert may have the following special props:
|
||||
*
|
||||
* - `type` The type of alert this is. Will be used to give the alert a class
|
||||
* name of `Alert--{type}`.
|
||||
* - `controls` An array of controls to show in the alert.
|
||||
* - `dismissible` Whether or not the alert can be dismissed.
|
||||
* - `ondismiss` A callback to run when the alert is dismissed.
|
||||
*
|
||||
* All other props will be assigned as attributes on the alert element.
|
||||
*/
|
||||
export default class Alert extends Component {
|
||||
view() {
|
||||
const attrs = Object.assign({}, this.props);
|
||||
export default class Alert<T extends AlertAttrs = AlertAttrs> extends Component<T> {
|
||||
view(vnode: Mithril.Vnode) {
|
||||
const attrs = Object.assign({}, this.attrs);
|
||||
|
||||
const type = extract(attrs, 'type');
|
||||
attrs.className = 'Alert Alert--' + type + ' ' + (attrs.className || '');
|
||||
|
||||
const children = extract(attrs, 'children');
|
||||
const controls = extract(attrs, 'controls') || [];
|
||||
const content = extract(attrs, 'content') || vnode.children;
|
||||
const controls = (extract(attrs, 'controls') || []) as Mithril.ChildArray;
|
||||
|
||||
// If the alert is meant to be dismissible (which is the case by default),
|
||||
// then we will create a dismiss button to append as the final control in
|
||||
@@ -40,7 +42,7 @@ export default class Alert extends Component {
|
||||
|
||||
return (
|
||||
<div {...attrs}>
|
||||
<span className="Alert-body">{children}</span>
|
||||
<span className="Alert-body">{content}</span>
|
||||
<ul className="Alert-controls">{listItems(controls.concat(dismissControl))}</ul>
|
||||
</div>
|
||||
);
|
@@ -6,72 +6,23 @@ import Alert from './Alert';
|
||||
* be shown and dismissed.
|
||||
*/
|
||||
export default class AlertManager extends Component {
|
||||
init() {
|
||||
/**
|
||||
* An array of Alert components which are currently showing.
|
||||
*
|
||||
* @type {Alert[]}
|
||||
* @protected
|
||||
*/
|
||||
this.components = [];
|
||||
oninit(vnode) {
|
||||
super.oninit(vnode);
|
||||
|
||||
this.state = this.attrs.state;
|
||||
}
|
||||
|
||||
view() {
|
||||
return (
|
||||
<div className="AlertManager">
|
||||
{this.components.map((component) => (
|
||||
<div className="AlertManager-alert">{component}</div>
|
||||
{Object.entries(this.state.getActiveAlerts()).map(([key, alert]) => (
|
||||
<div className="AlertManager-alert">
|
||||
<alert.componentClass {...alert.attrs} ondismiss={this.state.dismiss.bind(this.state, key)}>
|
||||
{alert.children}
|
||||
</alert.componentClass>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
config(isInitialized, context) {
|
||||
// Since this component is 'above' the content of the page (that is, it is a
|
||||
// part of the global UI that persists between routes), we will flag the DOM
|
||||
// to be retained across route changes.
|
||||
context.retain = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Show an Alert in the alerts area.
|
||||
*
|
||||
* @param {Alert} component
|
||||
* @public
|
||||
*/
|
||||
show(component) {
|
||||
if (!(component instanceof Alert)) {
|
||||
throw new Error('The AlertManager component can only show Alert components');
|
||||
}
|
||||
|
||||
component.props.ondismiss = this.dismiss.bind(this, component);
|
||||
|
||||
this.components.push(component);
|
||||
m.redraw();
|
||||
}
|
||||
|
||||
/**
|
||||
* Dismiss an alert.
|
||||
*
|
||||
* @param {Alert} component
|
||||
* @public
|
||||
*/
|
||||
dismiss(component) {
|
||||
const index = this.components.indexOf(component);
|
||||
|
||||
if (index !== -1) {
|
||||
this.components.splice(index, 1);
|
||||
m.redraw();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all alerts.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
clear() {
|
||||
this.components = [];
|
||||
m.redraw();
|
||||
}
|
||||
}
|
||||
|
@@ -6,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 props:
|
||||
* A badge may have the following special attrs:
|
||||
*
|
||||
* - `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 props will be assigned as attributes on the badge element.
|
||||
* All other attrs will be assigned as attributes on the badge element.
|
||||
*/
|
||||
export default class Badge extends Component {
|
||||
view() {
|
||||
const attrs = Object.assign({}, this.props);
|
||||
const attrs = Object.assign({}, this.attrs);
|
||||
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(' ')}</span>;
|
||||
}
|
||||
|
||||
config(isInitialized) {
|
||||
if (isInitialized) return;
|
||||
oncreate(vnode) {
|
||||
super.oncreate(vnode);
|
||||
|
||||
if (this.props.label) this.$().tooltip();
|
||||
if (this.attrs.label) this.$().tooltip();
|
||||
}
|
||||
}
|
||||
|
@@ -1,12 +1,15 @@
|
||||
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. The button may have the following special props:
|
||||
* action.
|
||||
*
|
||||
* ### Attrs
|
||||
*
|
||||
* - `icon` The name of the icon class. If specified, the button will be given a
|
||||
* 'has-icon' class name.
|
||||
@@ -15,41 +18,43 @@ import LoadingIndicator from './LoadingIndicator';
|
||||
* removed.
|
||||
* - `loading` Whether or not the button should be in a disabled loading state.
|
||||
*
|
||||
* All other props will be assigned as attributes on the button element.
|
||||
* All other attrs 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() {
|
||||
const attrs = Object.assign({}, this.props);
|
||||
view(vnode) {
|
||||
const attrs = Object.assign({}, this.attrs);
|
||||
|
||||
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 && !this.props.children) {
|
||||
if (attrs.title && !vnode.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 && this.props.children) {
|
||||
attrs.title = extractText(this.props.children);
|
||||
if (!attrs.title && vnode.children) {
|
||||
attrs.title = extractText(vnode.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;
|
||||
}
|
||||
|
||||
return <button {...attrs}>{this.getButtonContent()}</button>;
|
||||
attrs.className = classList([attrs.className, iconName && 'hasIcon', (attrs.disabled || loading) && 'disabled', loading && 'loading']);
|
||||
|
||||
return <button {...attrs}>{this.getButtonContent(vnode.children)}</button>;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -58,13 +63,13 @@ export default class Button extends Component {
|
||||
* @return {*}
|
||||
* @protected
|
||||
*/
|
||||
getButtonContent() {
|
||||
const iconName = this.props.icon;
|
||||
getButtonContent(children) {
|
||||
const iconName = this.attrs.icon;
|
||||
|
||||
return [
|
||||
iconName && iconName !== true ? icon(iconName, { className: 'Button-icon' }) : '',
|
||||
this.props.children ? <span className="Button-label">{this.props.children}</span> : '',
|
||||
this.props.loading ? LoadingIndicator.component({ size: 'tiny', className: 'LoadingIndicator--inline' }) : '',
|
||||
children ? <span className="Button-label">{children}</span> : '',
|
||||
this.attrs.loading ? <LoadingIndicator size="tiny" className="LoadingIndicator--inline" /> : '',
|
||||
];
|
||||
}
|
||||
}
|
||||
|
@@ -1,11 +1,13 @@
|
||||
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.
|
||||
*
|
||||
* ### Props
|
||||
* ### Attrs
|
||||
*
|
||||
* - `state` Whether or not the checkbox is checked.
|
||||
* - `className` The class name for the root element.
|
||||
@@ -15,16 +17,24 @@ import icon from '../helpers/icon';
|
||||
* - `children` A text label to display next to the checkbox.
|
||||
*/
|
||||
export default class Checkbox extends Component {
|
||||
view() {
|
||||
let className = 'Checkbox ' + (this.props.state ? 'on' : 'off') + ' ' + (this.props.className || '');
|
||||
if (this.props.loading) className += ' loading';
|
||||
if (this.props.disabled) className += ' disabled';
|
||||
view(vnode) {
|
||||
// Sometimes, false is stored in the DB as '0'. This is a temporary
|
||||
// conversion layer until a more robust settings encoding is introduced
|
||||
if (this.attrs.state === '0') this.attrs.state = false;
|
||||
|
||||
const className = classList([
|
||||
'Checkbox',
|
||||
this.attrs.state ? 'on' : 'off',
|
||||
this.attrs.className,
|
||||
this.attrs.loading && 'loading',
|
||||
this.attrs.disabled && 'disabled',
|
||||
]);
|
||||
|
||||
return (
|
||||
<label className={className}>
|
||||
<input type="checkbox" checked={this.props.state} disabled={this.props.disabled} onchange={m.withAttr('checked', this.onchange.bind(this))} />
|
||||
<input type="checkbox" checked={this.attrs.state} disabled={this.attrs.disabled} onchange={withAttr('checked', this.onchange.bind(this))} />
|
||||
<div className="Checkbox-display">{this.getDisplay()}</div>
|
||||
{this.props.children}
|
||||
{vnode.children}
|
||||
</label>
|
||||
);
|
||||
}
|
||||
@@ -36,7 +46,7 @@ export default class Checkbox extends Component {
|
||||
* @protected
|
||||
*/
|
||||
getDisplay() {
|
||||
return this.props.loading ? LoadingIndicator.component({ size: 'tiny' }) : icon(this.props.state ? 'fas fa-check' : 'fas fa-times');
|
||||
return this.attrs.loading ? <LoadingIndicator size="tiny" /> : icon(this.attrs.state ? 'fas fa-check' : 'fas fa-times');
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -46,6 +56,6 @@ export default class Checkbox extends Component {
|
||||
* @protected
|
||||
*/
|
||||
onchange(checked) {
|
||||
if (this.props.onchange) this.props.onchange(checked, this);
|
||||
if (this.attrs.onchange) this.attrs.onchange(checked, this);
|
||||
}
|
||||
}
|
||||
|
40
js/src/common/components/ConfirmDocumentUnload.js
Normal file
40
js/src/common/components/ConfirmDocumentUnload.js
Normal file
@@ -0,0 +1,40 @@
|
||||
import Component from '../Component';
|
||||
|
||||
/**
|
||||
* The `ConfirmDocumentUnload` component can be used to register a global
|
||||
* event handler that prevents closing the browser window/tab based on the
|
||||
* return value of a given callback prop.
|
||||
*
|
||||
* ### Attrs
|
||||
*
|
||||
* - `when` - a callback returning true when the browser should prompt for
|
||||
* confirmation before closing the window/tab
|
||||
*
|
||||
* ### Children
|
||||
*
|
||||
* NOTE: Only the first child will be rendered. (Use this component to wrap
|
||||
* another component / DOM element.)
|
||||
*
|
||||
*/
|
||||
export default class ConfirmDocumentUnload extends Component {
|
||||
handler() {
|
||||
return this.attrs.when() || undefined;
|
||||
}
|
||||
|
||||
oncreate(vnode) {
|
||||
super.oncreate(vnode);
|
||||
|
||||
this.boundHandler = this.handler.bind(this);
|
||||
$(window).on('beforeunload', this.boundHandler);
|
||||
}
|
||||
|
||||
onremove() {
|
||||
$(window).off('beforeunload', this.boundHandler);
|
||||
}
|
||||
|
||||
view(vnode) {
|
||||
// To avoid having to render another wrapping <div> here, we assume that
|
||||
// this component is only wrapped around a single element / component.
|
||||
return vnode.children[0];
|
||||
}
|
||||
}
|
@@ -6,7 +6,7 @@ import listItems from '../helpers/listItems';
|
||||
* The `Dropdown` component displays a button which, when clicked, shows a
|
||||
* dropdown menu beneath it.
|
||||
*
|
||||
* ### Props
|
||||
* ### Attrs
|
||||
*
|
||||
* - `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 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';
|
||||
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';
|
||||
}
|
||||
|
||||
init() {
|
||||
oninit(vnode) {
|
||||
super.oninit(vnode);
|
||||
|
||||
this.showing = false;
|
||||
}
|
||||
|
||||
view() {
|
||||
const items = this.props.children ? listItems(this.props.children) : [];
|
||||
view(vnode) {
|
||||
const items = vnode.children ? listItems(vnode.children) : [];
|
||||
|
||||
return (
|
||||
<div className={'ButtonGroup Dropdown dropdown ' + this.props.className + ' itemCount' + items.length + (this.showing ? ' open' : '')}>
|
||||
{this.getButton()}
|
||||
<div className={'ButtonGroup Dropdown dropdown ' + this.attrs.className + ' itemCount' + items.length + (this.showing ? ' open' : '')}>
|
||||
{this.getButton(vnode.children)}
|
||||
{this.getMenu(items)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
config(isInitialized) {
|
||||
if (isInitialized) return;
|
||||
oncreate(vnode) {
|
||||
super.oncreate(vnode);
|
||||
|
||||
// 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.props.onshow) {
|
||||
this.props.onshow();
|
||||
if (this.attrs.onshow) {
|
||||
this.attrs.onshow();
|
||||
}
|
||||
|
||||
m.redraw();
|
||||
@@ -76,8 +76,8 @@ export default class Dropdown extends Component {
|
||||
this.$().on('hidden.bs.dropdown', () => {
|
||||
this.showing = false;
|
||||
|
||||
if (this.props.onhide) {
|
||||
this.props.onhide();
|
||||
if (this.attrs.onhide) {
|
||||
this.attrs.onhide();
|
||||
}
|
||||
|
||||
m.redraw();
|
||||
@@ -90,10 +90,10 @@ export default class Dropdown extends Component {
|
||||
* @return {*}
|
||||
* @protected
|
||||
*/
|
||||
getButton() {
|
||||
getButton(children) {
|
||||
return (
|
||||
<button className={'Dropdown-toggle ' + this.props.buttonClassName} data-toggle="dropdown" onclick={this.props.onclick}>
|
||||
{this.getButtonContent()}
|
||||
<button className={'Dropdown-toggle ' + this.attrs.buttonClassName} data-toggle="dropdown" onclick={this.attrs.onclick}>
|
||||
{this.getButtonContent(children)}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -104,15 +104,15 @@ export default class Dropdown extends Component {
|
||||
* @return {*}
|
||||
* @protected
|
||||
*/
|
||||
getButtonContent() {
|
||||
getButtonContent(children) {
|
||||
return [
|
||||
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' }) : '',
|
||||
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' }) : '',
|
||||
];
|
||||
}
|
||||
|
||||
getMenu(items) {
|
||||
return <ul className={'Dropdown-menu dropdown-menu ' + this.props.menuClassName}>{items}</ul>;
|
||||
return <ul className={'Dropdown-menu dropdown-menu ' + this.attrs.menuClassName}>{items}</ul>;
|
||||
}
|
||||
}
|
||||
|
@@ -11,11 +11,11 @@ import listItems from '../helpers/listItems';
|
||||
* The children should be an array of items to show in the fieldset.
|
||||
*/
|
||||
export default class FieldSet extends Component {
|
||||
view() {
|
||||
view(vnode) {
|
||||
return (
|
||||
<fieldset className={this.props.className}>
|
||||
<legend>{this.props.label}</legend>
|
||||
<ul>{listItems(this.props.children)}</ul>
|
||||
<fieldset className={this.attrs.className}>
|
||||
<legend>{this.attrs.label}</legend>
|
||||
<ul>{listItems(vnode.children)}</ul>
|
||||
</fieldset>
|
||||
);
|
||||
}
|
||||
|
@@ -1,16 +1,16 @@
|
||||
import Badge from './Badge';
|
||||
|
||||
export default class GroupBadge extends Badge {
|
||||
static initProps(props) {
|
||||
super.initProps(props);
|
||||
static initAttrs(attrs) {
|
||||
super.initAttrs(attrs);
|
||||
|
||||
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();
|
||||
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();
|
||||
|
||||
delete props.group;
|
||||
delete attrs.group;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
47
js/src/common/components/Link.js
Normal file
47
js/src/common/components/Link.js
Normal file
@@ -0,0 +1,47 @@
|
||||
import Component from '../Component';
|
||||
import extract from '../utils/extract';
|
||||
|
||||
/**
|
||||
* The link component enables both internal and external links.
|
||||
* It will return a regular HTML link for any links to external sites,
|
||||
* and it will use Mithril's m.route.Link for any internal links.
|
||||
*
|
||||
* Links will default to internal; the 'external' attr must be set to
|
||||
* `true` for the link to be external.
|
||||
*/
|
||||
export default class Link extends Component {
|
||||
view(vnode) {
|
||||
let { options = {}, ...attrs } = vnode.attrs;
|
||||
|
||||
attrs.href = attrs.href || '';
|
||||
|
||||
// For some reason, m.route.Link does not like vnode.text, so if present, we
|
||||
// need to convert it to text vnodes and store it in children.
|
||||
const children = vnode.children || { tag: '#', children: vnode.text };
|
||||
|
||||
if (attrs.external) {
|
||||
return <a {...attrs}>{children}</a>;
|
||||
}
|
||||
|
||||
// If the href URL of the link is the same as the current page path
|
||||
// we will not add a new entry to the browser history.
|
||||
// This allows us to still refresh the Page component
|
||||
// without adding endless history entries.
|
||||
if (attrs.href === m.route.get()) {
|
||||
if (!('replace' in options)) options.replace = true;
|
||||
}
|
||||
|
||||
// Mithril 2 does not completely rerender the page if a route change leads to the same route
|
||||
// (or the same component handling a different route).
|
||||
// Here, the `force` parameter will use Mithril's key system to force a full rerender
|
||||
// see https://mithril.js.org/route.html#key-parameter
|
||||
if (extract(attrs, 'force')) {
|
||||
if (!('state' in options)) options.state = {};
|
||||
if (!('key' in options.state)) options.state.key = Date.now();
|
||||
}
|
||||
|
||||
attrs.options = options;
|
||||
|
||||
return <m.route.Link {...attrs}>{children}</m.route.Link>;
|
||||
}
|
||||
}
|
@@ -1,38 +1,43 @@
|
||||
import Button from './Button';
|
||||
import Link from './Link';
|
||||
|
||||
/**
|
||||
* The `LinkButton` component defines a `Button` which links to a route.
|
||||
*
|
||||
* ### Props
|
||||
* ### Attrs
|
||||
*
|
||||
* All of the props accepted by `Button`, plus:
|
||||
* All of the attrs 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 initProps(props) {
|
||||
props.active = this.isActive(props);
|
||||
props.config = props.config || m.route;
|
||||
static initAttrs(attrs) {
|
||||
super.initAttrs(attrs);
|
||||
|
||||
attrs.active = this.isActive(attrs);
|
||||
if (attrs.force === undefined) attrs.force = true;
|
||||
}
|
||||
|
||||
view() {
|
||||
const vdom = super.view();
|
||||
view(vnode) {
|
||||
const vdom = super.view(vnode);
|
||||
|
||||
vdom.tag = 'a';
|
||||
vdom.tag = Link;
|
||||
vdom.attrs.active = String(vdom.attrs.active);
|
||||
|
||||
return vdom;
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine whether a component with the given props is 'active'.
|
||||
* Determine whether a component with the given attrs is 'active'.
|
||||
*
|
||||
* @param {Object} props
|
||||
* @param {Object} attrs
|
||||
* @return {Boolean}
|
||||
*/
|
||||
static isActive(props) {
|
||||
return typeof props.active !== 'undefined' ? props.active : m.route() === props.href;
|
||||
static isActive(attrs) {
|
||||
return typeof attrs.active !== 'undefined' ? attrs.active : m.route.get() === attrs.href;
|
||||
}
|
||||
}
|
||||
|
@@ -2,16 +2,17 @@ import Component from '../Component';
|
||||
import { Spinner } from 'spin.js';
|
||||
|
||||
/**
|
||||
* The `LoadingIndicator` component displays a loading spinner with spin.js. It
|
||||
* may have the following special props:
|
||||
* The `LoadingIndicator` component displays a loading spinner with spin.js.
|
||||
*
|
||||
* ### Attrs
|
||||
*
|
||||
* - `size` The spin.js size preset to use. Defaults to 'small'.
|
||||
*
|
||||
* All other props will be assigned as attributes on the element.
|
||||
* All other attrs will be assigned as attributes on the DOM element.
|
||||
*/
|
||||
export default class LoadingIndicator extends Component {
|
||||
view() {
|
||||
const attrs = Object.assign({}, this.props);
|
||||
const attrs = Object.assign({}, this.attrs);
|
||||
|
||||
attrs.className = 'LoadingIndicator ' + (attrs.className || '');
|
||||
delete attrs.size;
|
||||
@@ -19,12 +20,12 @@ export default class LoadingIndicator extends Component {
|
||||
return <div {...attrs}>{m.trust(' ')}</div>;
|
||||
}
|
||||
|
||||
config(isInitialized) {
|
||||
if (isInitialized) return;
|
||||
oncreate(vnode) {
|
||||
super.oncreate(vnode);
|
||||
|
||||
const options = { zIndex: 'auto', color: this.$().css('color') };
|
||||
|
||||
switch (this.props.size) {
|
||||
switch (this.attrs.size) {
|
||||
case 'large':
|
||||
Object.assign(options, { lines: 10, length: 8, width: 4, radius: 8 });
|
||||
break;
|
||||
|
@@ -9,24 +9,46 @@ import Button from './Button';
|
||||
* @abstract
|
||||
*/
|
||||
export default class Modal extends Component {
|
||||
init() {
|
||||
/**
|
||||
* An alert component to show below the header.
|
||||
*
|
||||
* @type {Alert}
|
||||
*/
|
||||
this.alert = null;
|
||||
/**
|
||||
* Determine whether or not the modal should be dismissible via an 'x' button.
|
||||
*/
|
||||
static isDismissible = true;
|
||||
|
||||
/**
|
||||
* Attributes for an alert component to show below the header.
|
||||
*
|
||||
* @type {object}
|
||||
*/
|
||||
alertAttrs = null;
|
||||
|
||||
oncreate(vnode) {
|
||||
super.oncreate(vnode);
|
||||
|
||||
this.attrs.animateShow(() => this.onready());
|
||||
}
|
||||
|
||||
onbeforeremove() {
|
||||
// If the global modal state currently contains a modal,
|
||||
// we've just opened up a new one, and accordingly,
|
||||
// we don't need to show a hide animation.
|
||||
if (!this.attrs.state.modal) {
|
||||
this.attrs.animateHide();
|
||||
// Here, we ensure that the animation has time to complete.
|
||||
// See https://mithril.js.org/lifecycle-methods.html#onbeforeremove
|
||||
// Bootstrap's Modal.TRANSITION_DURATION is 300 ms.
|
||||
return new Promise((resolve) => setTimeout(resolve, 300));
|
||||
}
|
||||
}
|
||||
|
||||
view() {
|
||||
if (this.alert) {
|
||||
this.alert.props.dismissible = false;
|
||||
if (this.alertAttrs) {
|
||||
this.alertAttrs.dismissible = false;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={'Modal modal-dialog ' + this.className()}>
|
||||
<div className="Modal-content">
|
||||
{this.isDismissible() ? (
|
||||
{this.constructor.isDismissible ? (
|
||||
<div className="Modal-close App-backControl">
|
||||
{Button.component({
|
||||
icon: 'fas fa-times',
|
||||
@@ -43,7 +65,7 @@ export default class Modal extends Component {
|
||||
<h3 className="App-titleControl App-titleControl--text">{this.title()}</h3>
|
||||
</div>
|
||||
|
||||
{alert ? <div className="Modal-alert">{this.alert}</div> : ''}
|
||||
{this.alertAttrs ? <div className="Modal-alert">{Alert.component(this.alertAttrs)}</div> : ''}
|
||||
|
||||
{this.content()}
|
||||
</form>
|
||||
@@ -52,15 +74,6 @@ export default class Modal extends Component {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine whether or not the modal should be dismissible via an 'x' button.
|
||||
*
|
||||
* @return {Boolean}
|
||||
*/
|
||||
isDismissible() {
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the class name to apply to the modal.
|
||||
*
|
||||
@@ -99,13 +112,11 @@ export default class Modal extends Component {
|
||||
this.$('form').find('input, select, textarea').first().focus().select();
|
||||
}
|
||||
|
||||
onhide() {}
|
||||
|
||||
/**
|
||||
* Hide the modal.
|
||||
*/
|
||||
hide() {
|
||||
app.modal.close();
|
||||
this.attrs.state.close();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -123,7 +134,7 @@ export default class Modal extends Component {
|
||||
* @param {RequestError} error
|
||||
*/
|
||||
onerror(error) {
|
||||
this.alert = error.alert;
|
||||
this.alertAttrs = error.alert;
|
||||
|
||||
m.redraw();
|
||||
|
||||
|
@@ -1,5 +1,4 @@
|
||||
import Component from '../Component';
|
||||
import Modal from './Modal';
|
||||
|
||||
/**
|
||||
* The `ModalManager` component manages a modal dialog. Only one modal dialog
|
||||
@@ -7,46 +6,45 @@ import Modal from './Modal';
|
||||
* overwrite the previous one.
|
||||
*/
|
||||
export default class ModalManager extends Component {
|
||||
init() {
|
||||
this.showing = false;
|
||||
this.component = null;
|
||||
}
|
||||
|
||||
view() {
|
||||
return <div className="ModalManager modal fade">{this.component && this.component.render()}</div>;
|
||||
const modal = this.attrs.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,
|
||||
})
|
||||
: ''}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
config(isInitialized, context) {
|
||||
if (isInitialized) return;
|
||||
oncreate(vnode) {
|
||||
super.oncreate(vnode);
|
||||
|
||||
// Since this component is 'above' the content of the page (that is, it is a
|
||||
// part of the global UI that persists between routes), we will flag the DOM
|
||||
// to be retained across route changes.
|
||||
context.retain = true;
|
||||
|
||||
this.$().on('hidden.bs.modal', this.clear.bind(this)).on('shown.bs.modal', this.onready.bind(this));
|
||||
// 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));
|
||||
}
|
||||
|
||||
/**
|
||||
* Show a modal dialog.
|
||||
*
|
||||
* @param {Modal} component
|
||||
* @public
|
||||
*/
|
||||
show(component) {
|
||||
if (!(component instanceof Modal)) {
|
||||
throw new Error('The ModalManager component can only show Modal components');
|
||||
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;
|
||||
}
|
||||
|
||||
clearTimeout(this.hideTimeout);
|
||||
|
||||
this.showing = true;
|
||||
this.component = component;
|
||||
|
||||
m.redraw(true);
|
||||
|
||||
const dismissible = !!this.component.isDismissible();
|
||||
this.$()
|
||||
.one('shown.bs.modal', readyCallback)
|
||||
.modal({
|
||||
backdrop: dismissible || 'static',
|
||||
keyboard: dismissible,
|
||||
@@ -54,50 +52,7 @@ export default class ModalManager extends Component {
|
||||
.modal('show');
|
||||
}
|
||||
|
||||
/**
|
||||
* Close the modal dialog.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
close() {
|
||||
if (!this.showing) return;
|
||||
|
||||
// Don't hide the modal immediately, because if the consumer happens to call
|
||||
// the `show` method straight after to show another modal dialog, it will
|
||||
// cause Bootstrap's modal JS to misbehave. Instead we will wait for a tiny
|
||||
// bit to give the `show` method the opportunity to prevent this from going
|
||||
// ahead.
|
||||
this.hideTimeout = setTimeout(() => {
|
||||
this.$().modal('hide');
|
||||
this.showing = false;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear content from the modal area.
|
||||
*
|
||||
* @protected
|
||||
*/
|
||||
clear() {
|
||||
if (this.component) {
|
||||
this.component.onhide();
|
||||
}
|
||||
|
||||
this.component = null;
|
||||
|
||||
app.current.retain = false;
|
||||
|
||||
m.lazyRedraw();
|
||||
}
|
||||
|
||||
/**
|
||||
* When the modal dialog is ready to be used, tell it!
|
||||
*
|
||||
* @protected
|
||||
*/
|
||||
onready() {
|
||||
if (this.component && this.component.onready) {
|
||||
this.component.onready(this.$());
|
||||
}
|
||||
animateHide() {
|
||||
this.$().modal('hide');
|
||||
}
|
||||
}
|
||||
|
@@ -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 props:
|
||||
* Accepts the following attrs:
|
||||
*
|
||||
* - `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.props.className || '')}
|
||||
className={'Navigation ButtonGroup ' + (this.attrs.className || '')}
|
||||
onmouseenter={pane && pane.show.bind(pane)}
|
||||
onmouseleave={pane && pane.onmouseleave.bind(pane)}
|
||||
>
|
||||
@@ -32,13 +32,6 @@ 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.
|
||||
*
|
||||
@@ -54,7 +47,6 @@ 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();
|
||||
@@ -88,7 +80,7 @@ export default class Navigation extends Component {
|
||||
* @protected
|
||||
*/
|
||||
getDrawerButton() {
|
||||
if (!this.props.drawer) return '';
|
||||
if (!this.attrs.drawer) return '';
|
||||
|
||||
const { drawer } = app;
|
||||
const user = app.session.user;
|
||||
|
@@ -7,9 +7,11 @@ import PageState from '../states/PageState';
|
||||
* @abstract
|
||||
*/
|
||||
export default class Page extends Component {
|
||||
init() {
|
||||
oninit(vnode) {
|
||||
super.oninit(vnode);
|
||||
|
||||
app.previous = app.current;
|
||||
app.current = new PageState(this.constructor);
|
||||
app.current = new PageState(this.constructor, { routeName: this.attrs.routeName });
|
||||
|
||||
app.drawer.hide();
|
||||
app.modal.close();
|
||||
@@ -20,15 +22,30 @@ 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;
|
||||
}
|
||||
|
||||
config(isInitialized, context) {
|
||||
if (isInitialized) return;
|
||||
oncreate(vnode) {
|
||||
super.oncreate(vnode);
|
||||
|
||||
if (this.bodyClass) {
|
||||
$('#app').addClass(this.bodyClass);
|
||||
}
|
||||
|
||||
context.onunload = () => $('#app').removeClass(this.bodyClass);
|
||||
if (this.scrollTopOnCreate) {
|
||||
$(window).scrollTop(0);
|
||||
}
|
||||
}
|
||||
|
||||
onremove() {
|
||||
if (this.bodyClass) {
|
||||
$('#app').removeClass(this.bodyClass);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@@ -4,7 +4,7 @@ import Component from '../Component';
|
||||
* The `Placeholder` component displays a muted text with some call to action,
|
||||
* usually used as an empty state.
|
||||
*
|
||||
* ### Props
|
||||
* ### Attrs
|
||||
*
|
||||
* - `text`
|
||||
*/
|
||||
@@ -12,7 +12,7 @@ export default class Placeholder extends Component {
|
||||
view() {
|
||||
return (
|
||||
<div className="Placeholder">
|
||||
<p>{this.props.text}</p>
|
||||
<p>{this.attrs.text}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
@@ -6,11 +6,11 @@ export default class RequestErrorModal extends Modal {
|
||||
}
|
||||
|
||||
title() {
|
||||
return this.props.error.xhr ? `${this.props.error.xhr.status} ${this.props.error.xhr.statusText}` : '';
|
||||
return this.attrs.error.xhr ? `${this.attrs.error.xhr.status} ${this.attrs.error.xhr.statusText}` : '';
|
||||
}
|
||||
|
||||
content() {
|
||||
const { error, formattedError } = this.props;
|
||||
const { error, formattedError } = this.attrs;
|
||||
|
||||
let responseText;
|
||||
|
||||
@@ -31,7 +31,7 @@ export default class RequestErrorModal extends Modal {
|
||||
return (
|
||||
<div className="Modal-body">
|
||||
<pre>
|
||||
{this.props.error.options.method} {this.props.error.options.url}
|
||||
{this.attrs.error.options.method} {this.attrs.error.options.url}
|
||||
<br />
|
||||
<br />
|
||||
{responseText}
|
||||
|
@@ -1,9 +1,10 @@
|
||||
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 props:
|
||||
* elements for styling. It accepts the following attrs:
|
||||
*
|
||||
* - `options` A map of option values to labels.
|
||||
* - `onchange` A callback to run when the selected value is changed.
|
||||
@@ -12,13 +13,13 @@ import icon from '../helpers/icon';
|
||||
*/
|
||||
export default class Select extends Component {
|
||||
view() {
|
||||
const { options, onchange, value, disabled } = this.props;
|
||||
const { options, onchange, value, disabled } = this.attrs;
|
||||
|
||||
return (
|
||||
<span className="Select">
|
||||
<select
|
||||
className="Select-input FormControl"
|
||||
onchange={onchange ? m.withAttr('value', onchange.bind(this)) : undefined}
|
||||
onchange={onchange ? withAttr('value', onchange.bind(this)) : undefined}
|
||||
value={value}
|
||||
disabled={disabled}
|
||||
>
|
||||
|
@@ -1,31 +1,52 @@
|
||||
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.
|
||||
*
|
||||
* ### Props
|
||||
* ### Attrs
|
||||
*
|
||||
* - `caretIcon`
|
||||
* - `defaultLabel`
|
||||
*/
|
||||
export default class SelectDropdown extends Dropdown {
|
||||
static initProps(props) {
|
||||
props.caretIcon = typeof props.caretIcon !== 'undefined' ? props.caretIcon : 'fas fa-sort';
|
||||
static initAttrs(attrs) {
|
||||
attrs.caretIcon = typeof attrs.caretIcon !== 'undefined' ? attrs.caretIcon : 'fas fa-sort';
|
||||
|
||||
super.initProps(props);
|
||||
super.initAttrs(attrs);
|
||||
|
||||
props.className += ' Dropdown--select';
|
||||
attrs.className += ' Dropdown--select';
|
||||
}
|
||||
|
||||
getButtonContent() {
|
||||
const activeChild = this.props.children.filter((child) => child.props.active)[0];
|
||||
let label = (activeChild && activeChild.props.children) || this.props.defaultLabel;
|
||||
getButtonContent(children) {
|
||||
const activeChild = children.find(isActive);
|
||||
let label = (activeChild && activeChild.children) || this.attrs.defaultLabel;
|
||||
|
||||
if (label instanceof Array) label = label[0];
|
||||
|
||||
return [<span className="Button-label">{label}</span>, icon(this.props.caretIcon, { className: 'Button-caret' })];
|
||||
return [<span className="Button-label">{label}</span>, icon(this.attrs.caretIcon, { className: 'Button-caret' })];
|
||||
}
|
||||
}
|
||||
|
@@ -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 initProps(props) {
|
||||
super.initProps(props);
|
||||
static initAttrs(attrs) {
|
||||
super.initAttrs(attrs);
|
||||
|
||||
props.className += ' Dropdown--split';
|
||||
props.menuClassName += ' Dropdown-menu--right';
|
||||
attrs.className += ' Dropdown--split';
|
||||
attrs.menuClassName += ' Dropdown-menu--right';
|
||||
}
|
||||
|
||||
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
|
||||
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
|
||||
// the first child.
|
||||
const firstChild = this.getFirstChild();
|
||||
const buttonProps = Object.assign({}, firstChild.props);
|
||||
buttonProps.className = (buttonProps.className || '') + ' SplitDropdown-button Button ' + this.props.buttonClassName;
|
||||
const firstChild = this.getFirstChild(children);
|
||||
const buttonAttrs = Object.assign({}, firstChild.attrs);
|
||||
buttonAttrs.className = (buttonAttrs.className || '') + ' SplitDropdown-button Button ' + this.attrs.buttonClassName;
|
||||
|
||||
return [
|
||||
Button.component(buttonProps),
|
||||
<button className={'Dropdown-toggle Button Button--icon ' + this.props.buttonClassName} data-toggle="dropdown">
|
||||
{icon(this.props.icon, { className: 'Button-icon' })}
|
||||
Button.component(buttonAttrs, firstChild.children),
|
||||
<button className={'Dropdown-toggle Button Button--icon ' + this.attrs.buttonClassName} data-toggle="dropdown">
|
||||
{icon(this.attrs.icon, { className: 'Button-icon' })}
|
||||
{icon('fas fa-caret-down', { className: 'Button-caret' })}
|
||||
</button>,
|
||||
];
|
||||
@@ -38,8 +38,8 @@ export default class SplitDropdown extends Dropdown {
|
||||
* @return {*}
|
||||
* @protected
|
||||
*/
|
||||
getFirstChild() {
|
||||
let firstChild = this.props.children;
|
||||
getFirstChild(children) {
|
||||
let firstChild = children;
|
||||
|
||||
while (firstChild instanceof Array) firstChild = firstChild[0];
|
||||
|
||||
|
@@ -5,13 +5,13 @@ import Checkbox from './Checkbox';
|
||||
* a tick/cross one.
|
||||
*/
|
||||
export default class Switch extends Checkbox {
|
||||
static initProps(props) {
|
||||
super.initProps(props);
|
||||
static initAttrs(attrs) {
|
||||
super.initAttrs(attrs);
|
||||
|
||||
props.className = (props.className || '') + ' Checkbox--switch';
|
||||
attrs.className = (attrs.className || '') + ' Checkbox--switch';
|
||||
}
|
||||
|
||||
getDisplay() {
|
||||
return this.props.loading ? super.getDisplay() : '';
|
||||
return this.attrs.loading ? super.getDisplay() : '';
|
||||
}
|
||||
}
|
||||
|
@@ -1,4 +1,4 @@
|
||||
export default class Routes {
|
||||
export default class Model {
|
||||
type;
|
||||
attributes = [];
|
||||
hasOnes = [];
|
||||
|
@@ -25,7 +25,7 @@ export default function avatar(user, attrs = {}) {
|
||||
if (hasTitle) attrs.title = attrs.title || username;
|
||||
|
||||
if (avatarUrl) {
|
||||
return <img {...attrs} src={avatarUrl} />;
|
||||
return <img {...attrs} src={avatarUrl} alt="" />;
|
||||
}
|
||||
|
||||
content = username.charAt(0).toUpperCase();
|
||||
|
@@ -1,19 +0,0 @@
|
||||
/**
|
||||
* The `fullTime` helper displays a formatted time string wrapped in a <time>
|
||||
* tag.
|
||||
*
|
||||
* @param {Date} time
|
||||
* @return {Object}
|
||||
*/
|
||||
export default function fullTime(time) {
|
||||
const mo = moment(time);
|
||||
|
||||
const datetime = mo.format();
|
||||
const full = mo.format('LLLL');
|
||||
|
||||
return (
|
||||
<time pubdate datetime={datetime}>
|
||||
{full}
|
||||
</time>
|
||||
);
|
||||
}
|
19
js/src/common/helpers/fullTime.tsx
Normal file
19
js/src/common/helpers/fullTime.tsx
Normal file
@@ -0,0 +1,19 @@
|
||||
import dayjs from 'dayjs';
|
||||
import * as Mithril from 'mithril';
|
||||
|
||||
/**
|
||||
* The `fullTime` helper displays a formatted time string wrapped in a <time>
|
||||
* tag.
|
||||
*/
|
||||
export default function fullTime(time: Date): Mithril.Vnode {
|
||||
const d = dayjs(time);
|
||||
|
||||
const datetime = d.format();
|
||||
const full = d.format('LLLL');
|
||||
|
||||
return (
|
||||
<time pubdate datetime={datetime}>
|
||||
{full}
|
||||
</time>
|
||||
);
|
||||
}
|
@@ -1,18 +1,17 @@
|
||||
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) {
|
||||
const mo = moment(time);
|
||||
export default function humanTime(time: Date): Mithril.Vnode {
|
||||
const d = dayjs(time);
|
||||
|
||||
const datetime = mo.format();
|
||||
const full = mo.format('LLLL');
|
||||
const datetime = d.format();
|
||||
const full = d.format('LLLL');
|
||||
const ago = humanTimeUtil(time);
|
||||
|
||||
return (
|
@@ -1,12 +0,0 @@
|
||||
/**
|
||||
* The `icon` helper displays an icon.
|
||||
*
|
||||
* @param {String} fontClass The full icon class, prefix and the icon’s name.
|
||||
* @param {Object} attrs Any other attributes to apply.
|
||||
* @return {Object}
|
||||
*/
|
||||
export default function icon(fontClass, attrs = {}) {
|
||||
attrs.className = 'icon ' + fontClass + ' ' + (attrs.className || '');
|
||||
|
||||
return <i {...attrs} />;
|
||||
}
|
13
js/src/common/helpers/icon.tsx
Normal file
13
js/src/common/helpers/icon.tsx
Normal file
@@ -0,0 +1,13 @@
|
||||
import * as Mithril from 'mithril';
|
||||
|
||||
/**
|
||||
* The `icon` helper displays an icon.
|
||||
*
|
||||
* @param fontClass The full icon class, prefix and the icon’s name.
|
||||
* @param attrs Any other attributes to apply.
|
||||
*/
|
||||
export default function icon(fontClass: string, attrs: Mithril.Attributes = {}): Mithril.Vnode {
|
||||
attrs.className = 'icon ' + fontClass + ' ' + (attrs.className || '');
|
||||
|
||||
return <i {...attrs} />;
|
||||
}
|
@@ -2,14 +2,14 @@ import Separator from '../components/Separator';
|
||||
import classList from '../utils/classList';
|
||||
|
||||
function isSeparator(item) {
|
||||
return item && item.component === Separator;
|
||||
return item.tag === Separator;
|
||||
}
|
||||
|
||||
function withoutUnnecessarySeparators(items) {
|
||||
const newItems = [];
|
||||
let prevItem;
|
||||
|
||||
items.forEach((item, i) => {
|
||||
items.filter(Boolean).forEach((item, i) => {
|
||||
if (!isSeparator(item) || (prevItem && !isSeparator(prevItem) && i !== items.length - 1)) {
|
||||
prevItem = item;
|
||||
newItems.push(item);
|
||||
@@ -30,21 +30,27 @@ export default function listItems(items) {
|
||||
if (!(items instanceof Array)) items = [items];
|
||||
|
||||
return withoutUnnecessarySeparators(items).map((item) => {
|
||||
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;
|
||||
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;
|
||||
|
||||
if (isListItem) {
|
||||
item.attrs = item.attrs || {};
|
||||
item.attrs.key = item.attrs.key || item.itemName;
|
||||
item.key = item.attrs.key;
|
||||
}
|
||||
|
||||
return isListItem ? (
|
||||
const node = isListItem ? (
|
||||
item
|
||||
) : (
|
||||
<li className={classList([item.itemName ? 'item-' + item.itemName : '', className, active ? 'active' : ''])} key={item.itemName}>
|
||||
<li
|
||||
className={classList([className, item.itemName && `item-${item.itemName}`, active && 'active'])}
|
||||
key={(item.attrs && item.attrs.key) || item.itemName}
|
||||
>
|
||||
{item}
|
||||
</li>
|
||||
);
|
||||
|
||||
return node;
|
||||
});
|
||||
}
|
||||
|
@@ -1,6 +1,6 @@
|
||||
import 'expose-loader?$!expose-loader?jQuery!jquery';
|
||||
import 'expose-loader?m!mithril';
|
||||
import 'expose-loader?moment!moment';
|
||||
import 'expose-loader?moment!expose-loader?dayjs!dayjs';
|
||||
import 'expose-loader?m.bidi!m.attrs.bidi';
|
||||
import 'bootstrap/js/affix';
|
||||
import 'bootstrap/js/dropdown';
|
||||
@@ -9,6 +9,12 @@ import 'bootstrap/js/tooltip';
|
||||
import 'bootstrap/js/transition';
|
||||
import 'jquery.hotkeys/jquery.hotkeys';
|
||||
|
||||
import relativeTime from 'dayjs/plugin/relativeTime';
|
||||
import localizedFormat from 'dayjs/plugin/localizedFormat';
|
||||
|
||||
dayjs.extend(relativeTime);
|
||||
dayjs.extend(localizedFormat);
|
||||
|
||||
import patchMithril from './utils/patchMithril';
|
||||
|
||||
patchMithril(window);
|
||||
|
@@ -68,7 +68,10 @@ Object.assign(Discussion.prototype, {
|
||||
const user = app.session.user;
|
||||
|
||||
if (user && user.markedAllAsReadAt() < this.lastPostedAt()) {
|
||||
return Math.max(0, this.lastPostNumber() - (this.lastReadPostNumber() || 0));
|
||||
const unreadCount = Math.max(0, this.lastPostNumber() - (this.lastReadPostNumber() || 0));
|
||||
// If posts have been deleted, it's possible that the unread count could exceed the
|
||||
// actual post count. As such, we take the min of the two to ensure this isn't an issue.
|
||||
return Math.min(unreadCount, this.commentCount());
|
||||
}
|
||||
|
||||
return 0;
|
||||
|
@@ -10,6 +10,7 @@ export default class User extends Model {}
|
||||
|
||||
Object.assign(User.prototype, {
|
||||
username: Model.attribute('username'),
|
||||
slug: Model.attribute('slug'),
|
||||
displayName: Model.attribute('displayName'),
|
||||
email: Model.attribute('email'),
|
||||
isEmailConfirmed: Model.attribute('isEmailConfirmed'),
|
||||
@@ -54,7 +55,7 @@ Object.assign(User.prototype, {
|
||||
* @public
|
||||
*/
|
||||
isOnline() {
|
||||
return this.lastSeenAt() > moment().subtract(5, 'minutes').toDate();
|
||||
return dayjs().subtract(5, 'minutes').isBefore(this.lastSeenAt());
|
||||
},
|
||||
|
||||
/**
|
||||
|
41
js/src/common/resolvers/DefaultResolver.ts
Normal file
41
js/src/common/resolvers/DefaultResolver.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import Mithril from 'mithril';
|
||||
|
||||
/**
|
||||
* Generates a route resolver for a given component.
|
||||
* In addition to regular route resolver functionality:
|
||||
* - It provide the current route name as an attr
|
||||
* - It sets a key on the component so a rerender will be triggered on route change.
|
||||
*/
|
||||
export default class DefaultResolver {
|
||||
component: Mithril.Component;
|
||||
routeName: string;
|
||||
|
||||
constructor(component, routeName) {
|
||||
this.component = component;
|
||||
this.routeName = routeName;
|
||||
}
|
||||
|
||||
/**
|
||||
* When a route change results in a changed key, a full page
|
||||
* rerender occurs. This method can be overriden in subclasses
|
||||
* to prevent rerenders on some route changes.
|
||||
*/
|
||||
makeKey() {
|
||||
return this.routeName + JSON.stringify(m.route.param());
|
||||
}
|
||||
|
||||
makeAttrs(vnode) {
|
||||
return {
|
||||
...vnode.attrs,
|
||||
routeName: this.routeName,
|
||||
};
|
||||
}
|
||||
|
||||
onmatch(args, requestedPath, route) {
|
||||
return this.component;
|
||||
}
|
||||
|
||||
render(vnode) {
|
||||
return [{ ...vnode, attrs: this.makeAttrs(vnode), key: this.makeKey() }];
|
||||
}
|
||||
}
|
80
js/src/common/states/AlertManagerState.ts
Normal file
80
js/src/common/states/AlertManagerState.ts
Normal file
@@ -0,0 +1,80 @@
|
||||
import Mithril from 'mithril';
|
||||
import Alert, { AlertAttrs } from '../components/Alert';
|
||||
|
||||
/**
|
||||
* Returned by `AlertManagerState.show`. Used to dismiss alerts.
|
||||
*/
|
||||
export type AlertIdentifier = number;
|
||||
|
||||
export interface AlertState {
|
||||
componentClass: typeof Alert;
|
||||
attrs: AlertAttrs;
|
||||
children: Mithril.Children;
|
||||
}
|
||||
|
||||
export default class AlertManagerState {
|
||||
protected activeAlerts: { [id: number]: AlertState } = {};
|
||||
protected alertId = 0;
|
||||
|
||||
getActiveAlerts() {
|
||||
return this.activeAlerts;
|
||||
}
|
||||
|
||||
/**
|
||||
* Show an Alert in the alerts area.
|
||||
*
|
||||
* @returns The alert's ID, which can be used to dismiss the alert.
|
||||
*/
|
||||
show(children: Mithril.Children): AlertIdentifier;
|
||||
show(attrs: AlertAttrs, children: Mithril.Children): AlertIdentifier;
|
||||
show(componentClass: Alert, attrs: AlertAttrs, children: Mithril.Children): AlertIdentifier;
|
||||
|
||||
show(arg1: any, arg2?: any, arg3?: any) {
|
||||
// Assigns variables as per the above signatures
|
||||
let componentClass = Alert;
|
||||
let attrs: AlertAttrs = {};
|
||||
let children: Mithril.Children;
|
||||
|
||||
if (arguments.length == 1) {
|
||||
children = arg1 as Mithril.Children;
|
||||
} else if (arguments.length == 2) {
|
||||
attrs = arg1 as AlertAttrs;
|
||||
children = arg2 as Mithril.Children;
|
||||
} else if (arguments.length == 3) {
|
||||
componentClass = arg1 as typeof Alert;
|
||||
attrs = arg2 as AlertAttrs;
|
||||
children = arg3;
|
||||
}
|
||||
|
||||
// Breaking Change Compliance Warning, Remove in Beta 15.
|
||||
// This is applied to the first argument (attrs) because previously, the alert was passed as the first argument.
|
||||
if (attrs === Alert || attrs instanceof Alert) {
|
||||
// This is duplicated so that if the error is caught, an error message still shows up in the debug console.
|
||||
console.error('The AlertManager can only show Alerts. Whichever extension triggered this alert should be updated to comply with beta 14.');
|
||||
throw new Error('The AlertManager can only show Alerts. Whichever extension triggered this alert should be updated to comply with beta 14.');
|
||||
}
|
||||
// End Change Compliance Warning, Remove in Beta 15
|
||||
this.activeAlerts[++this.alertId] = { children, attrs, componentClass };
|
||||
m.redraw();
|
||||
|
||||
return this.alertId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Dismiss an alert.
|
||||
*/
|
||||
dismiss(key: AlertIdentifier): void {
|
||||
if (!key || !(key in this.activeAlerts)) return;
|
||||
|
||||
delete this.activeAlerts[key];
|
||||
m.redraw();
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all alerts.
|
||||
*/
|
||||
clear(): void {
|
||||
this.activeAlerts = {};
|
||||
m.redraw();
|
||||
}
|
||||
}
|
56
js/src/common/states/ModalManagerState.js
Normal file
56
js/src/common/states/ModalManagerState.js
Normal file
@@ -0,0 +1,56 @@
|
||||
import Modal from '../components/Modal';
|
||||
|
||||
export default class ModalManagerState {
|
||||
constructor() {
|
||||
this.modal = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Show a modal dialog.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
show(componentClass, attrs) {
|
||||
// Breaking Change Compliance Warning, Remove in Beta 15.
|
||||
if (!(componentClass.prototype instanceof Modal)) {
|
||||
// This is duplicated so that if the error is caught, an error message still shows up in the debug console.
|
||||
console.error('The ModalManager can only show Modals');
|
||||
throw new Error('The ModalManager can only show Modals');
|
||||
}
|
||||
if (componentClass.init) {
|
||||
// This is duplicated so that if the error is caught, an error message still shows up in the debug console.
|
||||
console.error(
|
||||
'The componentClass parameter must be a modal class, not a modal instance. Whichever extension triggered this modal should be updated to comply with beta 14.'
|
||||
);
|
||||
throw new Error(
|
||||
'The componentClass parameter must be a modal class, not a modal instance. Whichever extension triggered this modal should be updated to comply with beta 14.'
|
||||
);
|
||||
}
|
||||
// End Change Compliance Warning, Remove in Beta 15
|
||||
|
||||
clearTimeout(this.closeTimeout);
|
||||
|
||||
this.modal = { componentClass, attrs };
|
||||
|
||||
m.redraw.sync();
|
||||
}
|
||||
|
||||
/**
|
||||
* Close the modal dialog.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
close() {
|
||||
if (!this.modal) return;
|
||||
|
||||
// Don't hide the modal immediately, because if the consumer happens to call
|
||||
// the `show` method straight after to show another modal dialog, it will
|
||||
// cause Bootstrap's modal JS to misbehave. Instead we will wait for a tiny
|
||||
// bit to give the `show` method the opportunity to prevent this from going
|
||||
// ahead.
|
||||
this.closeTimeout = setTimeout(() => {
|
||||
this.modal = null;
|
||||
m.redraw();
|
||||
});
|
||||
}
|
||||
}
|
@@ -1,5 +1,9 @@
|
||||
class Item {
|
||||
constructor(content, priority) {
|
||||
content: any;
|
||||
priority: number;
|
||||
key?: number;
|
||||
|
||||
constructor(content: any, priority?: number) {
|
||||
this.content = content;
|
||||
this.priority = priority;
|
||||
}
|
||||
@@ -10,23 +14,15 @@ class Item {
|
||||
* by priority.
|
||||
*/
|
||||
export default class ItemList {
|
||||
constructor() {
|
||||
/**
|
||||
* The items in the list.
|
||||
*
|
||||
* @type {Object}
|
||||
* @public
|
||||
*/
|
||||
this.items = {};
|
||||
}
|
||||
/**
|
||||
* The items in the list
|
||||
*/
|
||||
items: { [key: string]: Item } = {};
|
||||
|
||||
/**
|
||||
* Check whether the list is empty.
|
||||
*
|
||||
* @returns {boolean}
|
||||
* @public
|
||||
*/
|
||||
isEmpty() {
|
||||
isEmpty(): boolean {
|
||||
for (const i in this.items) {
|
||||
if (this.items.hasOwnProperty(i)) {
|
||||
return false;
|
||||
@@ -38,36 +34,27 @@ export default class ItemList {
|
||||
|
||||
/**
|
||||
* Check whether an item is present in the list.
|
||||
*
|
||||
* @param key
|
||||
* @returns {boolean}
|
||||
*/
|
||||
has(key) {
|
||||
has(key: string): boolean {
|
||||
return !!this.items[key];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the content of an item.
|
||||
*
|
||||
* @param {String} key
|
||||
* @return {*}
|
||||
* @public
|
||||
*/
|
||||
get(key) {
|
||||
get(key: string): any {
|
||||
return this.items[key].content;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add an item to the list.
|
||||
*
|
||||
* @param {String} key A unique key for the item.
|
||||
* @param {*} content The item's content.
|
||||
* @param {Integer} [priority] The priority of the item. Items with a higher
|
||||
* @param key A unique key for the item.
|
||||
* @param content The item's content.
|
||||
* @param [priority] The priority of the item. Items with a higher
|
||||
* priority will be positioned before items with a lower priority.
|
||||
* @return {ItemList}
|
||||
* @public
|
||||
*/
|
||||
add(key, content, priority = 0) {
|
||||
add(key: string, content: any, priority: number = 0): this {
|
||||
this.items[key] = new Item(content, priority);
|
||||
|
||||
return this;
|
||||
@@ -75,14 +62,8 @@ export default class ItemList {
|
||||
|
||||
/**
|
||||
* Replace an item in the list, only if it is already present.
|
||||
*
|
||||
* @param {String} key
|
||||
* @param {*} [content]
|
||||
* @param {Integer} [priority]
|
||||
* @return {ItemList}
|
||||
* @public
|
||||
*/
|
||||
replace(key, content = null, priority = null) {
|
||||
replace(key: string, content: any = null, priority: number = null): this {
|
||||
if (this.items[key]) {
|
||||
if (content !== null) {
|
||||
this.items[key].content = content;
|
||||
@@ -98,12 +79,8 @@ export default class ItemList {
|
||||
|
||||
/**
|
||||
* Remove an item from the list.
|
||||
*
|
||||
* @param {String} key
|
||||
* @return {ItemList}
|
||||
* @public
|
||||
*/
|
||||
remove(key) {
|
||||
remove(key: string): this {
|
||||
delete this.items[key];
|
||||
|
||||
return this;
|
||||
@@ -111,12 +88,8 @@ export default class ItemList {
|
||||
|
||||
/**
|
||||
* Merge another list's items into this one.
|
||||
*
|
||||
* @param {ItemList} items
|
||||
* @return {ItemList}
|
||||
* @public
|
||||
*/
|
||||
merge(items) {
|
||||
merge(items: this): this {
|
||||
for (const i in items.items) {
|
||||
if (items.items.hasOwnProperty(i) && items.items[i] instanceof Item) {
|
||||
this.items[i] = items.items[i];
|
||||
@@ -130,12 +103,9 @@ export default class ItemList {
|
||||
* Convert the list into an array of item content arranged by priority. Each
|
||||
* item's content will be assigned an `itemName` property equal to the item's
|
||||
* unique key.
|
||||
*
|
||||
* @return {Array}
|
||||
* @public
|
||||
*/
|
||||
toArray() {
|
||||
const items = [];
|
||||
toArray(): any[] {
|
||||
const items: Item[] = [];
|
||||
|
||||
for (const i in this.items) {
|
||||
if (this.items.hasOwnProperty(i) && this.items[i] instanceof Item) {
|
@@ -1,5 +1,14 @@
|
||||
export default class RequestError {
|
||||
constructor(status, responseText, options, xhr) {
|
||||
status: string;
|
||||
options: object;
|
||||
xhr: XMLHttpRequest;
|
||||
|
||||
responseText: string | null;
|
||||
response: object | null;
|
||||
|
||||
alert: any;
|
||||
|
||||
constructor(status: string, responseText: string | null, options: object, xhr: XMLHttpRequest) {
|
||||
this.status = status;
|
||||
this.responseText = responseText;
|
||||
this.options = options;
|
@@ -58,7 +58,7 @@ export default class ScrollListener {
|
||||
*/
|
||||
start() {
|
||||
if (!this.active) {
|
||||
window.addEventListener('scroll', (this.active = this.loop.bind(this)));
|
||||
window.addEventListener('scroll', (this.active = this.loop.bind(this)), { passive: true });
|
||||
}
|
||||
}
|
||||
|
||||
|
3
js/src/common/utils/Stream.js
Normal file
3
js/src/common/utils/Stream.js
Normal file
@@ -0,0 +1,3 @@
|
||||
import Stream from 'mithril/stream';
|
||||
|
||||
export default Stream;
|
@@ -1,20 +1,25 @@
|
||||
/**
|
||||
* 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.
|
||||
* 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.
|
||||
*
|
||||
* @example
|
||||
* // constructor
|
||||
* // Check two callbacks for changes on each update
|
||||
* this.subtree = new SubtreeRetainer(
|
||||
* () => this.props.post.freshness,
|
||||
* () => this.attrs.post.freshness,
|
||||
* () => this.showing
|
||||
* );
|
||||
* this.subtree.check(() => this.props.user.freshness);
|
||||
*
|
||||
* // view
|
||||
* this.subtree.retain() || 'expensive expression'
|
||||
* // Add more callbacks to be checked for updates
|
||||
* this.subtree.check(() => this.attrs.user.freshness);
|
||||
*
|
||||
* @see https://lhorie.github.io/mithril/mithril.html#persisting-dom-elements-across-route-changes
|
||||
* // In a component's onbeforeupdate() method:
|
||||
* return this.subtree.needsRebuild()
|
||||
*
|
||||
* @see https://mithril.js.org/lifecycle-methods.html#onbeforeupdate
|
||||
*/
|
||||
export default class SubtreeRetainer {
|
||||
/**
|
||||
@@ -23,16 +28,19 @@ 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 a virtual DOM directive that will retain a subtree if no data has
|
||||
* changed since the last check.
|
||||
* Return whether any data has changed since the last check.
|
||||
* If so, Mithril needs to re-diff the vnode and its children.
|
||||
*
|
||||
* @return {Object|false}
|
||||
* @return {boolean}
|
||||
* @public
|
||||
*/
|
||||
retain() {
|
||||
needsRebuild() {
|
||||
let needsRebuild = false;
|
||||
|
||||
this.callbacks.forEach((callback, i) => {
|
||||
@@ -44,7 +52,7 @@ export default class SubtreeRetainer {
|
||||
}
|
||||
});
|
||||
|
||||
return needsRebuild ? false : { subtree: 'retain' };
|
||||
return needsRebuild;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -55,6 +63,8 @@ export default class SubtreeRetainer {
|
||||
*/
|
||||
check(...callbacks) {
|
||||
this.callbacks = this.callbacks.concat(callbacks);
|
||||
// Update the data cache when new checks are added.
|
||||
this.needsRebuild();
|
||||
}
|
||||
|
||||
/**
|
||||
|
109
js/src/common/utils/SuperTextarea.js
Normal file
109
js/src/common/utils/SuperTextarea.js
Normal file
@@ -0,0 +1,109 @@
|
||||
/**
|
||||
* A textarea wrapper with powerful helpers for text manipulation.
|
||||
*
|
||||
* This wraps a <textarea> DOM element and allows directly manipulating its text
|
||||
* contents and cursor positions.
|
||||
*
|
||||
* I apologize for the pretentious name. :)
|
||||
*/
|
||||
export default class SuperTextarea {
|
||||
/**
|
||||
* @param {HTMLTextAreaElement} textarea
|
||||
*/
|
||||
constructor(textarea) {
|
||||
this.el = textarea;
|
||||
this.$ = $(textarea);
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the value of the text editor.
|
||||
*
|
||||
* @param {String} value
|
||||
*/
|
||||
setValue(value) {
|
||||
this.$.val(value).trigger('input');
|
||||
|
||||
this.el.dispatchEvent(new CustomEvent('input', { bubbles: true, cancelable: true }));
|
||||
}
|
||||
|
||||
/**
|
||||
* Focus the textarea and place the cursor at the given index.
|
||||
*
|
||||
* @param {number} position
|
||||
*/
|
||||
moveCursorTo(position) {
|
||||
this.setSelectionRange(position, position);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the selected range of the textarea.
|
||||
*
|
||||
* @return {Array}
|
||||
*/
|
||||
getSelectionRange() {
|
||||
return [this.el.selectionStart, this.el.selectionEnd];
|
||||
}
|
||||
|
||||
/**
|
||||
* Insert content into the textarea at the position of the cursor.
|
||||
*
|
||||
* @param {String} text
|
||||
*/
|
||||
insertAtCursor(text) {
|
||||
this.insertAt(this.el.selectionStart, text);
|
||||
}
|
||||
|
||||
/**
|
||||
* Insert content into the textarea at the given position.
|
||||
*
|
||||
* @param {number} pos
|
||||
* @param {String} text
|
||||
*/
|
||||
insertAt(pos, text) {
|
||||
this.insertBetween(pos, pos, text);
|
||||
}
|
||||
|
||||
/**
|
||||
* Insert content into the textarea between the given positions.
|
||||
*
|
||||
* If the start and end positions are different, any text between them will be
|
||||
* overwritten.
|
||||
*
|
||||
* @param start
|
||||
* @param end
|
||||
* @param text
|
||||
*/
|
||||
insertBetween(start, end, text) {
|
||||
const value = this.el.value;
|
||||
|
||||
const before = value.slice(0, start);
|
||||
const after = value.slice(end);
|
||||
|
||||
this.setValue(`${before}${text}${after}`);
|
||||
|
||||
// Move the textarea cursor to the end of the content we just inserted.
|
||||
this.moveCursorTo(start + text.length);
|
||||
}
|
||||
|
||||
/**
|
||||
* Replace existing content from the start to the current cursor position.
|
||||
*
|
||||
* @param start
|
||||
* @param text
|
||||
*/
|
||||
replaceBeforeCursor(start, text) {
|
||||
this.insertBetween(start, this.el.selectionStart, text);
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the selected range of the textarea.
|
||||
*
|
||||
* @param {number} start
|
||||
* @param {number} end
|
||||
* @private
|
||||
*/
|
||||
setSelectionRange(start, end) {
|
||||
this.el.setSelectionRange(start, end);
|
||||
this.$.focus();
|
||||
}
|
||||
}
|
@@ -4,16 +4,13 @@
|
||||
* @example
|
||||
* abbreviateNumber(1234);
|
||||
* // "1.2K"
|
||||
*
|
||||
* @param {Integer} number
|
||||
* @return {String}
|
||||
*/
|
||||
export default function abbreviateNumber(number) {
|
||||
export default function abbreviateNumber(number: number): string {
|
||||
// TODO: translation
|
||||
if (number >= 1000000) {
|
||||
return Math.floor(number / 1000000) + app.translator.trans('core.lib.number_suffix.mega_text');
|
||||
} else if (number >= 1000) {
|
||||
return Math.floor(number / 1000) + app.translator.trans('core.lib.number_suffix.kilo_text');
|
||||
return (number / 1000).toFixed(1) + app.translator.trans('core.lib.number_suffix.kilo_text');
|
||||
} else {
|
||||
return number.toString();
|
||||
}
|
@@ -1,15 +0,0 @@
|
||||
/**
|
||||
* The `extract` utility deletes a property from an object and returns its
|
||||
* value.
|
||||
*
|
||||
* @param {Object} object The object that owns the property
|
||||
* @param {String} property The name of the property to extract
|
||||
* @return {*} The value of the property
|
||||
*/
|
||||
export default function extract(object, property) {
|
||||
const value = object[property];
|
||||
|
||||
delete object[property];
|
||||
|
||||
return value;
|
||||
}
|
15
js/src/common/utils/extract.ts
Normal file
15
js/src/common/utils/extract.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
/**
|
||||
* The `extract` utility deletes a property from an object and returns its
|
||||
* value.
|
||||
*
|
||||
* @param object The object that owns the property
|
||||
* @param property The name of the property to extract
|
||||
* @return The value of the property
|
||||
*/
|
||||
export default function extract<T, K extends keyof T>(object: T, property: K): T[K] {
|
||||
const value = object[property];
|
||||
|
||||
delete object[property];
|
||||
|
||||
return value;
|
||||
}
|
@@ -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 extractText(vdom.children);
|
||||
return vdom.children ? extractText(vdom.children) : vdom.text;
|
||||
} else {
|
||||
return vdom;
|
||||
}
|
||||
|
@@ -5,10 +5,7 @@
|
||||
* @example
|
||||
* formatNumber(1234);
|
||||
* // 1,234
|
||||
*
|
||||
* @param {Number} number
|
||||
* @return {String}
|
||||
*/
|
||||
export default function formatNumber(number) {
|
||||
export default function formatNumber(number: number): string {
|
||||
return number.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ',');
|
||||
}
|
@@ -1,35 +1,35 @@
|
||||
import dayjs from 'dayjs';
|
||||
import 'dayjs/plugin/relativeTime';
|
||||
|
||||
/**
|
||||
* The `humanTime` utility converts a date to a localized, human-readable time-
|
||||
* ago string.
|
||||
*
|
||||
* @param {Date} time
|
||||
* @return {String}
|
||||
*/
|
||||
export default function humanTime(time) {
|
||||
let m = moment(time);
|
||||
const now = moment();
|
||||
export default function humanTime(time: Date): string {
|
||||
let d = dayjs(time);
|
||||
const now = dayjs();
|
||||
|
||||
// To prevent showing things like "in a few seconds" due to small offsets
|
||||
// between client and server time, we always reset future dates to the
|
||||
// current time. This will result in "just now" being shown instead.
|
||||
if (m.isAfter(now)) {
|
||||
m = now;
|
||||
if (d.isAfter(now)) {
|
||||
d = now;
|
||||
}
|
||||
|
||||
const day = 864e5;
|
||||
const diff = m.diff(moment());
|
||||
let ago = null;
|
||||
const diff = d.diff(dayjs());
|
||||
let ago: string;
|
||||
|
||||
// If this date was more than a month ago, we'll show the name of the month
|
||||
// in the string. If it wasn't this year, we'll show the year as well.
|
||||
if (diff < -30 * day) {
|
||||
if (m.year() === moment().year()) {
|
||||
ago = m.format('D MMM');
|
||||
if (d.year() === dayjs().year()) {
|
||||
ago = d.format('D MMM');
|
||||
} else {
|
||||
ago = m.format('ll');
|
||||
ago = d.format('ll');
|
||||
}
|
||||
} else {
|
||||
ago = m.fromNow();
|
||||
ago = d.fromNow();
|
||||
}
|
||||
|
||||
return ago;
|
@@ -1,18 +1,18 @@
|
||||
import humanTimeUtil from './humanTime';
|
||||
import humanTime from './humanTime';
|
||||
|
||||
function updateHumanTimes() {
|
||||
$('[data-humantime]').each(function () {
|
||||
const $this = $(this);
|
||||
const ago = humanTimeUtil($this.attr('datetime'));
|
||||
const ago = humanTime($this.attr('datetime'));
|
||||
|
||||
$this.html(ago);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* The `humanTime` initializer sets up a loop every 1 second to update
|
||||
* The `liveHumanTimes` initializer sets up a loop every 1 second to update
|
||||
* timestamps rendered with the `humanTime` helper.
|
||||
*/
|
||||
export default function humanTime() {
|
||||
export default function liveHumanTimes() {
|
||||
setInterval(updateHumanTimes, 10000);
|
||||
}
|
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user