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

Compare commits

..

462 Commits

Author SHA1 Message Date
Alexander Skvortsov
9fffb8ec1a L8 requires constructor arguments to PhpEngine 2021-03-15 12:48:47 -04:00
Daniel Klabbers
92e590f1ab Release v0.1.0-beta.16 constant updated 2021-03-15 16:21:21 +01:00
Daniel Klabbers
098908cb4a Release v0.1.0-beta.16 2021-03-15 16:09:23 +01:00
Sami Mazouz
901846d0cf Beta 16 Changelog (#2687)
* Beta 16 Changelog

* Review tweaks

* Consistent letter casing

* IOS -> iOS

* Removed accidentally added F

* Csrf -> CSRF

Co-authored-by: David Wheatley <hi@davwheat.dev>
2021-03-15 14:55:47 +01:00
Daniel Klabbers
5a3aefb76c npm audit fix 2021-03-15 13:25:12 +01:00
Alexander Skvortsov
cf2a636e81 Apply GetModelIsPrivate BC mode to CommentPost, not Post 2021-03-13 17:16:18 -05:00
Alexander Skvortsov
a8ba510655 Fix ModelPrivate docblocks 2021-03-13 17:15:50 -05:00
Alexander Skvortsov
9c3b6c596f Merge pull request #2684 from flarum/as/filter-mutator-consistency
Make filter mutator API consistent with search mutator API.
2021-03-13 17:01:33 -05:00
Alexander Skvortsov
2310d782a3 Fix Index content, only use search when applicable. 2021-03-12 15:30:36 -05:00
Alexander Skvortsov
e9642250ae Provide active filters to filter state 2021-03-12 15:30:14 -05:00
flarum-bot
a6dd545dbc Bundled output for commit a64c39835a [skip ci] 2021-03-12 05:35:57 +00:00
Alexander Skvortsov
a64c39835a Fix shaky composer on safari mobile
When the composer is opened while scrolled to the absolute bottom of the page (via hitting the "reply" button, `window.scrollTop` has a value of ~600px greater than it should. This doesn't seem to be the composer element's height (which appears to be 0 at the time). This incorrect scrollTop positions the composer off screen, which causes Safari to freak out and shake the element violently as it tries to scroll to the cursor (which is now off screen).

We can get around this by calculating scrollTop ourselves.

Fixes https://github.com/flarum/core/issues/2683
2021-03-12 00:23:37 -05:00
Alexander Skvortsov
db0d8e89c7 Make filter mutator API consistent with search mutator API.
This is inline with the docblock for the Filter extender, and is much more sensible.
2021-03-11 23:12:49 -05:00
flarum-bot
4e126708e9 Bundled output for commit b88a7cb33b [skip ci] 2021-03-09 03:46:55 +00:00
Alexander Skvortsov
b88a7cb33b Search: dont adjust height if not rendered 2021-03-08 22:45:39 -05:00
flarum-bot
a2f52c09fd Bundled output for commit 30017eef09 [skip ci] 2021-03-08 21:31:49 +00:00
Alexander Skvortsov
30017eef09 Send username as author filter value instead of id.
For consistency with the Discussion AuthorFilterGambit, this should be sent usernames, not numerical ids.
2021-03-08 16:29:48 -05:00
flarum-bot
d0ffa26b0b Bundled output for commit 612a57c466 [skip ci] 2021-03-08 21:25:06 +00:00
Alexander Skvortsov
612a57c466 Use new author key for filtering posts
Fixes https://github.com/flarum/core/issues/2671
2021-03-08 16:21:36 -05:00
Alexander Skvortsov
91e8b56961 Add deprecated "user" filter for posts
In the filterer refactor for ListPostsController, the filter key was changed to `author` for consistency with the AuthorFilterGambit used in discussions. This commit adds a deprecated `user` filter back in for a release to allow for a graceful transition
2021-03-08 16:20:26 -05:00
flarum-bot
ba9665e9db Bundled output for commit 8eea0985a4 [skip ci] 2021-03-07 22:37:00 +00:00
Alexander Skvortsov
8eea0985a4 Split JSDoc directives to separate lines. 2021-03-07 17:35:58 -05:00
flarum-bot
1bcb9d3ea1 Bundled output for commit 2c3e1f9923 [skip ci] 2021-03-07 21:33:50 +00:00
Alexander Skvortsov
2c3e1f9923 Use flarum/testing for test infrastructure (#2545) 2021-03-07 16:32:41 -05:00
Sami Mazouz
bc607e089e Eagerload some needed relations in ListDiscussionsController (#2639) 2021-03-07 16:32:23 -05:00
Sami Mazouz
91d5d9c176 Use absolute positioning for the Composer on Safari (#2660) 2021-03-07 16:31:46 -05:00
Alexander Skvortsov
3aa118ab94 Fix search box out of screen (#2650)
Programatically set search results max height
2021-03-07 16:31:23 -05:00
Daniël Klabbers
4b0ad6972d added optional powered-by header (#2618) 2021-03-05 10:05:13 -05:00
Daniël Klabbers
84ded0ce50 Laravel components v8 (#2576)
- update actions ci
- include json for 4 spaces tab
- provide output int for process code exit
- adhere to parent type hint of builder
- mailer instance now needs a name, multiple can be instantiated
- getOriginal now uses mutators in the model
- Temporarily loosen MailableInterface requirements. This avoids an immediate BC break for classes in extensions that implement this interface.
- Temporarily provide (and autoload) old symfony translator interface
- make queue exception handler compatible with the contract of L8
- Update phpunit schema for newer version
- Update phpunit assert calls for newer version
2021-03-05 09:43:35 -05:00
Sami Mazouz
725863a6e2 Move TextEditor styles to common (#2661)
Now that TextEditor js component is shared, it only makes sense to also 
have its styles shared
2021-03-05 08:14:19 -05:00
Alexander Skvortsov
c81f629b0b Rename app to container (#2609)
* Rename `app` helper to `resolve`, deprecate old version
* Rename $this->app to $this->container in service providers

We no longer couple Flarum\Foundation\Application to the Laravel container; instead, we use the container separately. Changing our naming to reflect that will make things clearer.
2021-03-04 22:14:48 -05:00
flarum-bot
15cbe4daaa Bundled output for commit ddac07d991 [skip ci] 2021-03-04 21:52:50 +00:00
Alexander Skvortsov
ddac07d991 Move TextEditor to common (#2649) 2021-03-04 16:51:34 -05:00
Clark Winkelmann
08ba2599d7 Refactor Access Tokens (#2651)
- Make session token-based instead of user-based
- Clear current session access tokens on logout
- Introduce increment ID so we can show tokens to moderators in the future without exposing secrets
- Switch to type classes to manage the different token types. New implementation fixes #2075
- Drop ability to customize lifetime per-token
- Add developer access keys that don't expire. These must be created from the database for now
- Add title in preparation for the developer token UI
- Add IP and user agent logging
- Delete all non-remember tokens in migration
2021-03-04 16:50:38 -05:00
Blake Payne
8eef7230e9 Updated GroupFilterGambit to prevent hidden groups being visible wher… (#2657)
Updated GroupFilterGambit to prevent hidden groups being visible where they shouldn't be and to ensure that only the selected groups are returned on a search. Fixes #2559
2021-03-04 10:08:12 -05:00
flarum-bot
a61f9e7328 Bundled output for commit a65e1de641 [skip ci] 2021-03-03 23:52:04 +00:00
daniellesniak
a65e1de641 Convert common helpers to Typescript (#2541) 2021-03-03 18:50:54 -05:00
Alexander Skvortsov
bed3207798 Fix CI (#2654) 2021-03-03 08:48:03 -05:00
Alexander Skvortsov
fc73d47e4c Deprecate event helper (#2608) 2021-03-02 16:21:30 -05:00
Clark Winkelmann
6e01c47c11 Restrict who can use the lastSeenAt user sort (#2634) 2021-03-02 09:59:14 -05:00
Alexander Skvortsov
a9526917b8 Query Namespace (#2645)
Move shared classes in search and filter namespaces to a new query namespace
2021-03-02 09:57:40 -05:00
Clark Winkelmann
e37fdef709 Hide boot error (#2633)
Completely redact boot error unless debug mode or display_errors is enabled. Attempt to use Flarum log file when possible. Fixes #2290
2021-03-02 09:57:06 -05:00
flarum-bot
56d7796c47 Bundled output for commit b7379bf91b [skip ci] 2021-03-01 22:27:09 +00:00
Charlie
b7379bf91b Simplify Extension Categories (#2604) 2021-03-01 17:25:55 -05:00
Emamul Khan
7fa22a131f clear cache files from storage/views (#2648)
Co-authored-by: Emamul Khan <emamul.khan@oxid-esales.com>
2021-03-01 13:45:19 -08:00
flarum-bot
f0c6050654 Bundled output for commit 9627eb73f1 [skip ci] 2021-03-01 20:53:52 +00:00
Matt Kilgore
9627eb73f1 User edit permission tightening (#2620)
- Split user edit permision into edit attributes, edit credentials, and edit groups
- Only Admins can edit Admin Credentials
- Only Admins can Promote/Demote to/from Admin
2021-03-01 15:52:29 -05:00
Alexander Skvortsov
d0adb244da Fix missing PostRepository argument
This was accidentially removed in 458a5cc6be
2021-03-01 00:30:04 -05:00
Alexander Skvortsov
458a5cc6be Use filterer for ListPostsController (#2479) 2021-02-28 14:06:07 -05:00
Sami Mazouz
ea840ba594 Allow overriding routes (#2577) 2021-02-28 14:01:30 -05:00
flarum-bot
ea291508ab Bundled output for commit 7d79912d36 [skip ci] 2021-02-26 21:18:01 +00:00
Alexander Skvortsov
7d79912d36 Editor Driver Abstraction (#2594)
This will allow drop-in replacements of the editor with a more advanced WYSIWYG solution such as ProseMirror
2021-02-26 16:17:05 -05:00
Sami Mazouz
67306a9d34 Fix keyboard on small mobile screens hiding composer (#2631) 2021-02-26 16:07:29 -05:00
Matt Kilgore
8cc207b139 Centralized IP Handler (#2624) 2021-02-25 20:08:52 -05:00
Alexander Skvortsov
023871ef86 Search Filter Split, Use Same Controller (#2454) 2021-02-24 11:17:40 -05:00
Alexander Skvortsov
1c578a83e4 Recalculate enabled extensions and their dependencies if some listed in settings aren't installed (#2629) 2021-02-23 17:57:53 -05:00
flarum-bot
454c525cb2 Bundled output for commit 49009d268f [skip ci] 2021-02-23 19:23:05 +00:00
Alexander Skvortsov
49009d268f NotificationList: Fix load on mobile
Followup to https://github.com/flarum/core/pull/2524.

In that PR, we fixed infinite scroll for the panel, but accidentially used document.body. Since scrollTop on body is (almost always) 0, this means that new pages of notifications were loaded on every scroll, which quickly becomes overwhelming. Instead, we can use `document.documentElement` for getting scrollTop, which results in the expected behavior.
2021-02-23 14:21:18 -05:00
Daniël Klabbers
ef2d6a65f4 Update composer.json (#2625)
update authors
2021-02-23 10:32:03 +01:00
Alexander Skvortsov
509adf228a Refactor password checker, add extender (#2176) 2021-02-22 17:08:36 -05:00
Alexander Skvortsov
fa10d794a4 Optional Dependencies (#2579)
* Add and calculate optional dependencies
* Add extension dependency resolver (Kahn's algorithm), plus unit tests
* Resolve extension dependency on enable/disable
2021-02-21 13:49:33 -05:00
Alexander Skvortsov
40ede179cd Adminux Patch Translations (#2616) 2021-02-19 16:12:11 -05:00
KyrneDev
0ed71ed581 Adminux locale 2021-02-19 13:03:26 -08:00
KyrneDev
dc75ebad00 Adminux locale 2021-02-19 13:02:42 -08:00
flarum-bot
900711687f Bundled output for commit 71ccdc00e6 [skip ci] 2021-02-18 23:46:54 +00:00
Charlie
71ccdc00e6 AdminUX Patch and Admin Page (#2593)
* AdminPage

* More fixes

* Settings Modal Drop

* Translation and docblock

* settingS

* Convert Fieldset to JSX

* info -> headerInfo, className

* Overflow fixes

* MailPage

* Admin Less

* Basics Page

* Changes

* Cleanup

* Permission Page

* Add padding
2021-02-18 15:45:43 -08:00
Robert Korulczyk
c4ebebe48e Move locale files from language pack to core (#2408) 2021-02-17 16:23:13 -05:00
flarum-bot
56d8301b2d Bundled output for commit 09076e005b [skip ci] 2021-02-17 15:37:58 +00:00
Alexander Skvortsov
09076e005b Various iOS scroll improvements (#2548)
* Don't update scrubber while post pages loading

This alleviates the scrubber bouncing around when scrolling up on iOS

* Throttle loadMore loadPrevious

Throttle loadMore and loadPrevious functions to alleviate skipping over pages and pages of posts during one scroll. This sometimes happens on iOS
2021-02-17 10:36:30 -05:00
Billy Wilcosky
73a8efaec2 Update DiscussionListItem.less to fix double tap on mobile (#2607)
Adds a rule to the discussion list less file which targets touch devices whose primary way of interacting does not include a mouse / ability to hover. For those devices the toggle button is hidden which fixes the double tap issue.
2021-02-17 08:51:09 -05:00
flarum-bot
cdeb229396 Bundled output for commit 122a99b51e [skip ci] 2021-02-16 22:49:29 +00:00
Charlie
122a99b51e Don't push bidi function to DOM (#2602) 2021-02-16 17:48:16 -05:00
Alexander Skvortsov
e7aed89e8f Broader support for callables in ContainerUtil (#2596)
It can be very annoying if we want to use something like boolval, but have to define an entire anonymous function to pass it in. This PR adds support for tpassing it in directly as a string, like is posible with User::registerPreference.
2021-02-10 14:51:31 -05:00
flarum-bot
a1254bc21a Bundled output for commit 03231b2931 [skip ci] 2021-02-10 19:23:42 +00:00
Wadim Kalmykov
03231b2931 PostStream: Fix minor load more issue (#2388) 2021-02-10 14:22:26 -05:00
flarum-bot
a2901cef23 Bundled output for commit 95b021a839 [skip ci] 2021-02-10 18:55:13 +00:00
Ian Morland
95b021a839 Add user badges to post preview #1765 (#2555) 2021-02-10 13:53:59 -05:00
Alexander Skvortsov
76d6442557 Simple Flarum Search Extender and tests (#2483) 2021-02-10 09:59:23 -05:00
flarum-bot
5df22e92ae Bundled output for commit 7306d8ef13 [skip ci] 2021-02-10 14:11:35 +00:00
Alexander Skvortsov
7306d8ef13 Export DiscussionListPane in compat
We forgot to do this in beta 14 when introducing the component.

Fixes https://github.com/flarum/core/issues/2591
2021-02-10 09:09:58 -05:00
Sami Mazouz
0595aba76a Rename ApiSerializer's mutate to attributes (#2578) 2021-02-05 13:21:36 -05:00
Alexander Skvortsov
8366ec720e Deprecate GetModelIsPrivate, replace with extender (#2587) 2021-02-04 10:56:10 -05:00
David Wheatley
17f15e36eb Correct non-existent cursor value (disallowed -> not-allowed) (#2585) 2021-02-01 08:58:41 -05:00
flarum-bot
ac249e5b07 Bundled output for commit e13772075c [skip ci] 2021-01-30 22:46:26 +00:00
David Sevilla Martín
e13772075c Navigate to dashboard page if extension ID not found (#2584) 2021-01-30 17:45:20 -05:00
flarum-bot
0fa33439d7 Bundled output for commit a4880453a4 [skip ci] 2021-01-30 22:44:46 +00:00
David Sevilla Martín
a4880453a4 Set this.changingState back to false in ExtensionPage if an error occurs (#2558) 2021-01-30 17:43:28 -05:00
Daniël Klabbers
964f827ee5 Fixes model visibility (#2580)
Model Visibility extender does not take into consideration missing
dependencies. For instance flarum/tags adds a policy on the Flag model
from flarum/flags. But because flarum/flags might as well not be
installed we need to check for the existence of that model. Otherwise
the exception is thrown or flarum fails to boot.
2021-01-29 08:13:16 -05:00
David Sevilla Martín
843daf633d Use extension names instead of IDs when erroring on enable/disable reqs (#2563) 2021-01-28 19:41:04 -05:00
David Sevilla Martín
930fcf9250 Make disabled extension dot a red border instead of red background (#2562) 2021-01-27 08:04:19 -05:00
flarum-bot
9bb4423dd7 Bundled output for commit 9347b12b47 [skip ci] 2021-01-27 05:04:48 +00:00
Alexander Skvortsov
9347b12b47 BasicsPage: fix "show language selector" default
Since some boolean settings might be stored as string "0" or "1", the previous system no longer works, and it always sets the switch to true. The "no setting" check has been changed to reference `undefined`, so now the switch will only be defaulted to `true` if the setting truly hasn't been set.

Fixes https://github.com/flarum/core/issues/2574
2021-01-27 00:03:30 -05:00
Daniël Klabbers
65b5c2043c PHP 8 support, cookie unit tests (#2507) 2021-01-26 17:53:28 -05:00
flarum-bot
08f72e7135 Bundled output for commit 26c4e492fe [skip ci] 2021-01-26 01:54:28 +00:00
Alexander Skvortsov
26c4e492fe Remove unused variable 2021-01-25 20:53:07 -05:00
Alexander Skvortsov
00913d5b0b ChangeEmailModal: dismiss alert on new request
Removing old errors at the beginning of the next request, rather than at the end of the next successful request, makes it clearer that any new errors are caused by the new inputs.

See https://github.com/flarum/core/pull/2467#issuecomment-749832787
2021-01-25 20:52:21 -05:00
flarum-bot
1851d1678e Bundled output for commit 14dc46e226 [skip ci] 2021-01-24 19:06:21 +00:00
David Wheatley
14dc46e226 Add missing a11y attributes (#2564) 2021-01-24 14:05:14 -05:00
flarum-bot
be163412ab Bundled output for commit 92d5c716be [skip ci] 2021-01-24 17:13:48 +00:00
Alexander Skvortsov
92d5c716be Fix notification panel infinite scroll (#2524)
Improves calculations for determining whether we are at the bottom of the notifications panel (which would trigger infinite scroll). This should be particularly effective in fixing issues on smaller screens.
2021-01-24 12:12:42 -05:00
Alexander Skvortsov
e42df50d31 Merge pull request #2557 from flarum/as/remove-deprecated
Remove deprecated PHP events, bootstrap.php fallback
2021-01-23 16:52:38 -05:00
Alexander Skvortsov
203a6456ee Remove deprecated bootstrap.php support
See https://github.com/flarum/core/issues/1557
2021-01-23 16:48:29 -05:00
Alexander Skvortsov
40b918e139 Remove deprecated API events 2021-01-23 16:48:22 -05:00
flarum-bot
f8eea5b7c7 Bundled output for commit b50d806534 [skip ci] 2021-01-23 21:44:56 +00:00
daniellesniak
b50d806534 Convert highlight helper to Typescript (#2532) 2021-01-23 16:43:40 -05:00
Alexander Skvortsov
cbcf83ed3b Remove deprecated formatting events 2021-01-20 16:25:32 -05:00
Alexander Skvortsov
3394ff31e9 Remove deprecated UserPreferences event 2021-01-20 15:23:56 -05:00
Alexander Skvortsov
86d39fb003 Remove deprecated floodgate 2021-01-20 15:23:30 -05:00
Alexander Skvortsov
bbb7679417 Remove deprecated notification events 2021-01-20 15:23:30 -05:00
Alexander Skvortsov
46248f601d Remove deprecated validation events 2021-01-20 15:23:30 -05:00
Alexander Skvortsov
a68e2b27a4 Remove deprecated post types event 2021-01-20 15:22:28 -05:00
Alexander Skvortsov
e2335e867d Remove deprecated policy and visibility scoping events 2021-01-20 15:21:30 -05:00
Alexander Skvortsov
a10da427ff Remove deprecated CSRF wildcard path match 2021-01-20 12:01:52 -05:00
flarum-bot
4561f56fb9 Bundled output for commit fae79ea910 [skip ci] 2021-01-19 22:40:14 +00:00
Alexander Skvortsov
fae79ea910 Bring m.attrs.bidi in as a util
We previously used the tobscure/m.attrs.bidi github repo, but that repo was recently taken offline. We decided to integrate it as a util instead of publishing it as a separate package since we seem to be the only project using it, and adopting it into a new project requires barneycarroll/mattr, which does not seem to be used anywhere.

The code added here was taken from https://github.com/askvortsov1/m.attrs.bidi, a fork (without changes) of the tobscure repo. Support for alternative module systems and ways of registering bidi were removed, and the file was formatted in compliance with our prettier config.
2021-01-19 17:30:03 -05:00
Alexander Skvortsov
9493e6230d NotificationTest: Rely on adminUser from installation 2021-01-19 17:05:53 -05:00
Sami Mazouz
927ea4eec5 Add Notification extender beforeSending method (#2533) 2021-01-19 14:40:19 -05:00
Alexander Skvortsov
89e821e70f Policies: treat true as allow, and false as deny (#2534) 2021-01-18 18:28:48 -05:00
Alexander Skvortsov
9b2d7856d1 Add subscribe method to event extender (#2535)
Historically, extensions using subscribers has caused problems because subscribers were constructed/applied at extension boot. This caused some classes (e.g. UrlGenerator) to be resolved early, breaking parts of Flarum. For this reason, subscriber support wasn't included in the initial version of the Event extender.

However, updating extensions has shown that there is a legitimate use case for subscribers in organizing clean code; for instance, core's own `DiscussionMetadataUpdater`.

This commit introduces support for subscribers, but only applies them after the app has booted, which avoids the early resolution issues. Since event listeners/subscribers are only intended to be used with domain events, which would never be dispatched during app boot, the late activation of subscribers should not cause issue.
2021-01-15 20:33:29 -05:00
flarum-bot
f93ec1b3b8 Bundled output for commit 2e3197d510 [skip ci] 2021-01-13 22:50:31 +00:00
Wadim Kalmykov
2e3197d510 Fix DiscussionListPane jumping around (#2402)
Ensure that scroll position is retained between page changes, so if navigating via the sidebar, you don't need to re-scroll down every time.
2021-01-13 17:49:26 -05:00
Alexander Skvortsov
85210ff6a1 Merge pull request #2304 from flarum/fl/tests-in-transaction
Run Backend Tests in Transactions
2021-01-12 21:26:59 -05:00
Alexander Skvortsov
e5f277e640 Apply fixes from StyleCI
[ci skip] [skip ci]
2021-01-09 00:36:07 -05:00
Alexander Skvortsov
4bac667dfd Fix fulltext search tests
Under InnoDB, database entries created in transactions are not processed by fulltext indexes until the transaction is committed. To work around this, cases that test fulltext search have been split off into a separate class that adds and removes seed discussions/posts outside of transactions during setUp/tearDown.
2021-01-09 00:35:55 -05:00
Alexander Skvortsov
6771b3e3b7 Tests: purge settings cache
Some tests need to change settings, but since MemoryCacheSettingsRepository caches settings in-memory, those changes aren't reflected. The new `purgeSettingsCache` removes it from the container, eliminating that cache.

For UserTest, we also need to regenerate the display name driver, since that's set statically on boot, before we'll get a change to clear the settings cache.
2021-01-09 00:35:55 -05:00
Alexander Skvortsov
fd79a14cac Tests: Add missing instantiation of data 2021-01-09 00:35:55 -05:00
Alexander Skvortsov
c1aa1455d3 Tests: Comply with default permissions
Before transactions, each test class would need to explicitly state starting state for permissions, which made the initial permission configuration somewhat arbitrary. Now, we might as well use the initial state of the default installation.

One of the User show_test tests has been commented out until
2021-01-09 00:35:55 -05:00
Alexander Skvortsov
ae280016e7 Tests: remove prepDb workaround
Previously, the `prepareDatabase` method would directly modify the database, booting the app in the process. This would prevent any extenders from being applied, since `->extend()` has no effect once the app is booted.

Since the new implementation of `prepareDatabase` simply registers seed data to be applied during app boot, the workaround of sticking this seed data into `prepDb` is no longer necessary, and seed data common to all test cases in a class can be provided in `setUp`.

When needed, app boot is explicitly triggered in individual test cases by calling `$this->app()`.
2021-01-09 00:35:55 -05:00
Alexander Skvortsov
0a8816938a Add @inheritDoc to all setUp and tearDown methods 2021-01-09 00:35:55 -05:00
Franz Liedke
008ec95505 Boot app explicitly to run seeds in time 2021-01-09 00:35:47 -05:00
Alexander Skvortsov
925628c208 Add vscode config to gitignore 2021-01-07 23:27:32 -05:00
Alexander Skvortsov
aae83c4fbc Fix deleting posts/discussions by deleted user (#2521)
Making the $user argument nullable prevents this unnecessary exception, and doesn't introduce any issues since we check that $user exists as part of the method.
2021-01-07 17:46:14 -05:00
Franz Liedke
cacc8b4945 Tests: Always start transaction before seeding 2021-01-07 17:34:13 -05:00
Franz Liedke
31765388c1 Tests: Stop using Eloquent models for seeding data 2021-01-07 17:34:13 -05:00
Franz Liedke
a08fd3e475 Tests: Rely on admin user, groups, permissions from test setup script 2021-01-07 17:34:06 -05:00
flarum-bot
d4b2d89da0 Bundled output for commit 9b27b0d9d7 [skip ci] 2021-01-07 15:26:14 +00:00
Sami Mazouz
9b27b0d9d7 Fix composer header hidden by mobile browser (#2279) 2021-01-07 10:25:12 -05:00
Franz Liedke
a47187462d Tests: DB tables no longer need to be truncated 2021-01-05 22:48:09 -05:00
Franz Liedke
843a149b80 Run integration tests in a transaction 2021-01-05 22:47:19 -05:00
Alexander Skvortsov
94381dca62 Fix IOS scroll menu bug (#2527)
Fixes https://github.com/flarum/core/issues/1959

These transform lines are known to cause issues on iOS, and were added to hack around chrome issues that have since been fixed upstream.
2021-01-05 19:40:11 -05:00
Sami Mazouz
a2d5dd3397 Add default value to Settings extender (#2495) 2021-01-05 01:28:25 -05:00
Daniël Klabbers
f8edc2d827 npm audit fix 2020-12-20 20:55:51 +01:00
flarum-bot
62235a16ca Bundled output for commit 36c55e8f69 [skip ci] 2020-12-20 17:15:07 +00:00
Sami Mazouz
36c55e8f69 Add ExtensionPermissionGrid to compat (#2501) 2020-12-20 12:14:00 -05:00
Daniël Klabbers
859f014539 beta 15 changelog and version constant 2020-12-18 20:02:22 +01:00
Daniël Klabbers
06e1d21331 Fixes validation failures of avatars that are jpg/jpeg (#2497)
Due to a commit by @fabpot in october, the mimetypes symfony class
now re-orders the shortened mimetypes that are returned when looking
up based on header mimetype. Our validator uses the first key, pops
the prefix off and then matches against our hardcoded array.

I've added a constraint to symfony/mime ^5.2.0 which ships with this change.
This constraint is fully compatible with our current lineup. In addition
I changed the hardcoded array to use the first entry from symfony mime types
now `jpg` instead of `jpeg`.
2020-12-16 13:53:17 -05:00
flarum-bot
fd5de6929e Bundled output for commit 84b1666b24 [skip ci] 2020-12-15 22:50:49 +00:00
Alexander Skvortsov
84b1666b24 Support multiple callback-based settings per-extension 2020-12-15 17:49:24 -05:00
Alexander Skvortsov
0c61fcc61c Clarify that request argument of render callbacks for Formatter must be either nullable or omitted 2020-12-14 17:20:35 -05:00
Alexander Skvortsov
8e25bcb68f Deprecate CheckingForFlooding
This should have been done earlier as part of the ThrottleApi PR
2020-12-14 17:12:05 -05:00
flarum-bot
fad783547c Bundled output for commit 210a6b3e25 [skip ci] 2020-12-14 19:07:44 +00:00
Alexander Skvortsov
210a6b3e25 Fix scroll on long discussions
- Anchor scroll when inserting post placeholders
- Indicate that pages are loading at start of `loadPage`, which allows `onscroll` to not request that multiple pages be loaded at the same time

These changes are particularly applicable to firefox, where previously, dozens of posts could be skipped at a time if scroll up was held while at the top of the viewport.
2020-12-14 14:06:32 -05:00
Ian Morland
73409184b9 Fix wrong namespace in docblock (#2494) 2020-12-12 15:36:25 -05:00
Alexander Skvortsov
afe038699e Fix composer json attribute path to links override section 2020-12-08 19:29:59 -05:00
Sami Mazouz
649851d356 Remove header bottom border (#2489) 2020-12-08 19:15:14 -05:00
Alexander Skvortsov
d1dfa758e4 Policy Extender and Tests (#2461)
Policy application has also been refactored, so that policies return one of `allow`, `deny`, `forceAllow`, `forceDeny`. The result of a set of policies is no longer the first non-null result, but rather the highest priority result (forceDeny > forceAllow > deny > allow, so if a single forceDeny is present, that beats out all other returned results). This removes order in which extensions boot as a factor.
2020-12-08 19:10:06 -05:00
Alexander Skvortsov
8901073d12 Model Visibility Scoping Extender and Tests (#2460) 2020-12-07 20:02:46 -05:00
flarum-bot
e0437d237a Bundled output for commit 07a43f52b4 [skip ci] 2020-12-07 20:15:49 +00:00
Charlie
07a43f52b4 AdminUX Overhaul Small Patches (#2468) 2020-12-07 15:14:22 -05:00
flarum-bot
9e9118fa0d Bundled output for commit 4679448300 [skip ci] 2020-12-07 18:35:10 +00:00
Matt Kilgore
4679448300 Slug Driver Support (#2456)
- Support slug drivers for core's sluggable models, easily extends to other models
- Add automated testing for affected single-model API routes
- Fix nickname selection UI
- Serialize slugs as `slug` attribute
- Make min search length a constant
2020-12-07 13:33:42 -05:00
flarum-bot
ef4bf8128e Bundled output for commit 67a2aac635 [skip ci] 2020-12-07 18:26:51 +00:00
David Sevilla Martín
67a2aac635 Replace forum and admin global compat exports with a Proxy to allow namespace use (#2488) 2020-12-07 13:25:24 -05:00
Sami Mazouz
51a97fb12e ApiController Extender and Tests (#2451) 2020-12-06 15:07:48 -05:00
Sami Mazouz
056d420c7b Pass callback wrapper parameters by reference (#2485)
Because invokable class objects are not directly called and instead it's the callback wrapper that calls these objects, it's currently not possible to receive arguments by reference on an invokable class.

To fix this we pass the arguments by reference by default when calling the object in the callback wrapper.
2020-12-06 14:58:45 -05:00
Sami Mazouz
cfa533ebd6 Add Settings Extender (#2452) 2020-12-04 17:20:06 -05:00
Alexander Skvortsov
eed407812f User Preferences Extender and Tests (#2463) 2020-12-04 15:45:08 -05:00
Daniël Klabbers
641619e820 Fixes issue with the worker defaulting to the illuminate queue manager (#2481)
We are instantiating our own queue handling factory which returns the
flarum.queue.connection binding no matter what. The queue Worker and
other queue related code rely on this manager to get its thing going.
Therefor we need to re-use our own factory everywhere, including in
the worker.
2020-12-02 13:19:25 -05:00
Alexander Skvortsov
984f751c71 Use process isolation for integration tests 2020-12-01 19:33:24 -05:00
flarum-bot
8830e9dd09 Bundled output for commit fe41bc1fdc [skip ci] 2020-12-01 16:22:59 +00:00
Alexander Skvortsov
fe41bc1fdc Remove Deprecated Beta14 Code (#2428) 2020-12-01 11:21:28 -05:00
Nina Pypchenko
5a763050a6 DRY up image uploading code (#2477) 2020-12-01 10:42:05 -05:00
Sami Mazouz
8c813bc340 ApiSerializer Extender (#2438) 2020-11-30 19:24:50 -05:00
flarum-bot
f67dee0a9e Bundled output for commit f968420216 [skip ci] 2020-11-30 19:02:41 +00:00
Alexander Skvortsov
f968420216 Don't use browser scroll restore in DiscussionPage (#2476)
Although native browser scroll restorations have become quite powerful, it interferes with Flarum's PostStream, so if we're on a DiscussionPage, we use manual scroll restoration.
2020-11-30 14:01:08 -05:00
flarum-bot
d5e124b4a2 Bundled output for commit 09e2736cbc [skip ci] 2020-11-29 23:34:50 +00:00
Alexander Skvortsov
09e2736cbc Fix goToIndex to visible end
In the PostStream, `this.visibleEnd` represents the index of the last post + 1, but `loadNearIndex` treated it as if it was the index of the last post. This means that executing `goToIndex` on the post stream's current `this.visiblePost` didn't load new posts, and as a result, the requested scrolling did not occur.
2020-11-29 18:33:29 -05:00
flarum-bot
ddb3d3edb0 Bundled output for commit 28d56f5fc8 [skip ci] 2020-11-29 22:47:21 +00:00
Alexander Skvortsov
28d56f5fc8 Merge pull request #2465 from flarum/0.1.0-beta.14.1 2020-11-29 17:45:58 -05:00
Alexander Skvortsov
9b4012bbb5 Reset dist js 2020-11-29 17:41:16 -05:00
Alexander Skvortsov
1a5e4d454e Move floodgate to middleware, add extender + integration tests (#2170) 2020-11-29 17:13:22 -05:00
sl-kr
387b4fd315 update a user's comment count if deleting a discussion (#2472) 2020-11-29 17:11:05 -05:00
flarum-bot
66482c2815 Bundled output for commit 277a5c3fac [skip ci] 2020-11-26 22:54:38 +00:00
Mohammad Reza
277a5c3fac Clear error alerts in change email modal on success (#2467) 2020-11-26 17:53:38 -05:00
Nina Pypchenko
286d8dec5b Update tsconfig file to include .tsx files (#2457) 2020-11-26 12:00:13 -05:00
flarum-bot
e1c61a0e85 Bundled output for commit 102e76b084 [skip ci] 2020-11-26 06:56:10 +00:00
Alexander Skvortsov
102e76b084 Defer clearing discussion list on discussion start
This prevents an edge case where `app.discussions` is considered empty while the new page is loading, and as a result, the side pane isn't set as "enabled". Then, if the pane has previously been pinned, when the page loads and the side pane appears, it covers up part of the discussion page.

Fixes https://github.com/flarum/core/issues/2471
2020-11-26 01:54:28 -05:00
flarum-bot
d09d4bc507 Bundled output for commit c3989cc952 [skip ci] 2020-11-24 17:46:02 +00:00
Charlie
c3989cc952 AdminUX Overhaul (#2409)
- Extensions now have their own pages
- The API for extensions to register permissions and settings has been overhauled via the `flarum/admin/utils/ExtensionData` util
- An extension grid has been added as a widget to the Dashboard page
2020-11-24 12:44:40 -05:00
flarum-bot
9cb9097b24 Bundled output for commit 571a835be0 [skip ci] 2020-11-14 22:23:04 +00:00
Wadim Kalmykov
571a835be0 Fix mobile PostStream top scroll adjustment & remove App:before (#2385)
- remove App:before so we can use #app-navigation to access the mobile header
- fix mobile postStream scroll top margin adjustment
2020-11-14 17:21:38 -05:00
Alexander Skvortsov
0c95774333 Refactor Route Resolving and Dispatch (#2425)
- Split DispatchRoute. This allows us to run middleware after we figure out which route we're on, but before we actually execute the controller for that route.
- By making the route name explicitly available to middlewares, applications like CSRF and floodgate can set patterns based on route names instead of the path, which is an implementation detail.
- Support using route name match for CSRF extender, deprecate path match
2020-11-10 12:52:12 -05:00
Nina Pypchenko
67741c7a6f Make checkbox switch component background stand out in modals (#2443) 2020-11-09 20:54:21 -05:00
Alexander Skvortsov
f5cfec15e3 Add missing import 2020-11-08 21:49:11 -05:00
Alexander Skvortsov
47d2eee9ce Fix Callables for Extenders (#2423)
- Standardize signatures and variable names for extenders that take callbacks
- Adjust model extender docblock to clarify that default calue can't be an invokable class.
- Make invokable classes provided to Model->relationship
- Add integration tests to ensure Model->relationship and User->groupProcessor extenders accept callbacks
- Extract code for wrapping callbacks into central util
2020-11-08 21:36:38 -05:00
Nina Pypchenko
c10cc92deb Improved Permissions Error Messages for Initial Install (#2435)
- Made the wording of the error more generic
- Added link to the relevant section in the installation guide

Resolves #2327.
2020-11-07 14:48:11 -05:00
Sami Mazouz
529d2edcaf Add Service Provider Extender (#2437) 2020-11-06 13:30:10 -05:00
Sami Mazouz
f0e77a5789 Add Notification Channel Extender (#2432) 2020-11-05 12:09:06 -05:00
Alexander Skvortsov
87c258b2f8 Refactor and improve formatter extender (#2098)
- Deprecated all events involved with Formatter
- Refactor ->configure() method on extender not to use events
- Add extender methods for ->render() and ->parse()
- Add integration tests
2020-11-03 13:05:33 -05:00
Alexander Skvortsov
cee87848fe Added post extender with type method, deprecated ConfigurePostTypes (#2101) 2020-11-03 10:43:49 -05:00
Daniël Klabbers
967cd0e3ca update version constant for beta 14.1 2020-11-02 13:53:20 +01:00
Daniël Klabbers
b79152b977 bundled output for js changes beta 14.1 2020-11-02 11:53:27 +01:00
Daniël Klabbers
ace624db66 changelog for v0.1.0-beta.14.1 2020-11-02 11:51:24 +01:00
Alexander Skvortsov
5842dd1200 Validator extender (#2102)
Added validator extender, integration tests, and deprecated related Validating event
2020-11-01 11:31:16 -05:00
Sami Mazouz
b311512502 Add Notification Type Extender and Tests (#2424) 2020-10-31 17:17:14 -04:00
Alexander Skvortsov
9b9f2c4bb7 Fix exiting composer while in fullscreen mode. 2020-10-30 20:44:52 -04:00
flarum-bot
0b2a5fa5b8 Bundled output for commit 52e45aacad [skip ci] 2020-10-31 00:28:56 +00:00
Lucas Henrique
52e45aacad Convert common time helpers to Typescript (#2391) 2020-10-30 20:27:40 -04:00
Alexander Skvortsov
8b1de457bf Fix broken page title logic on subpath installs
The base path needs to be accounted for when calculating whether we're on the default route.
2020-10-30 14:18:09 -04:00
Alexander Skvortsov
21c2a4b2a4 Updater should show on any subpath, like installer (#2426) 2020-10-30 13:28:20 -04:00
flarum-bot
12c03dc4e1 Bundled output for commit d2927cfdb9 [skip ci] 2020-10-29 16:54:36 +00:00
Alexander Skvortsov
d2927cfdb9 Ensure scripts provided by textformatter are run (#2415) 2020-10-29 12:53:23 -04:00
Daniël Klabbers
24b7a21507 Update Symfony components to v4 (#2407)
This matches the Symfony dependencies of our laravel dependencies.
2020-10-27 17:12:36 -04:00
flarum-bot
c9a04fe009 Bundled output for commit bd7fa11b5a [skip ci] 2020-10-25 17:36:51 +00:00
Alexander Skvortsov
bd7fa11b5a Export SuperTextarea util in compat 2020-10-25 13:35:15 -04:00
Daniël Klabbers
7055f6d941 update version constant 2020-10-20 16:34:54 +02:00
Alexander Skvortsov
f765001f06 Update email 2020-10-20 10:32:24 -04:00
Daniël Klabbers
683739a617 changelog v0.1.0-beta.14 and added core developer @askvortsov1 2020-10-20 16:24:43 +02:00
flarum-bot
69b7fe8d01 Bundled output for commit 1936b9117d [skip ci] 2020-10-17 17:43:39 +00:00
Alexander Skvortsov
1936b9117d Page Scroll Cleanup (#2396)
- Reintroduce cancellable scroll top on page change
- IndexPage: rely on browser to retain scroll position on page reload
- Remove obsolete browser hack
- Fix broken selector
- When on mobile, only retain scroll for IndexPage if we're coming from a discussion
- Move app.cache.scrollTop save into `onbeforeremove` so we make sure to do it before DOM is detached
2020-10-17 13:42:33 -04:00
flarum-bot
d53eeded44 Bundled output for commit 0650788e7c [skip ci] 2020-10-16 20:32:13 +00:00
Alexander Skvortsov
0650788e7c Fix scolling to first post via m.route.set
The default first post number is '1', so we scroll to that if we're calling `m.route.set` without a `near` parameter, as that means we're scrolling to the top.

This was present in beta 13's implementation, but accidentially omitted in 988b6c9.

We also remove unnecessary typecasting for simpler logic and increased consistency with beta 13.
2020-10-16 16:30:27 -04:00
flarum-bot
6a77184611 Bundled output for commit a8b36cb76d [skip ci] 2020-10-16 20:05:15 +00:00
Alexander Skvortsov
a8b36cb76d Fix check for going between discussion pages.
The current implementation for checking whether we are on a discussion page, and going to a discussion page, checks the route we are going to. This is problematic, because the route resolver represents the route being considered, not the route we are currently on. So, if we are currently using a DiscussionPageResolver, we must be going to a route handled by DiscussionPage. Instead, we need to check the route that we are currently on, which is done via `app.current.matches(DiscussionPage)`.
2020-10-16 16:03:32 -04:00
flarum-bot
5cd14d594b Bundled output for commit f4ad9d2d5a [skip ci] 2020-10-16 16:07:09 +00:00
Alexander Skvortsov
f4ad9d2d5a Fix scrolling to reply via 'reply' as near parameter 2020-10-16 12:04:45 -04:00
Alexander Skvortsov
d409484abf Notification: fix wrong external attr for Link 2020-10-16 11:53:45 -04:00
flarum-bot
1fc24635f6 Bundled output for commit ff7ac0b322 [skip ci] 2020-10-16 05:26:03 +00:00
Alexander Skvortsov
ff7ac0b322 Fix PostStream loadRange doesn't return all posts (#2384)
- Also, ensure that posts are ordered by creation timestamp
2020-10-16 01:24:45 -04:00
Wadim Kalmykov
d460aaa3ad order posts by creation date 2020-10-16 01:20:54 -04:00
Wadim Kalmykov
7634a766cb Fix loadRange doesn't return all posts 2020-10-16 01:20:54 -04:00
flarum-bot
e5f53b93a6 Bundled output for commit a38c92d409 [skip ci] 2020-10-16 01:59:56 +00:00
Alexander Skvortsov
a38c92d409 Fix broken import 2020-10-15 21:58:18 -04:00
Alexander Skvortsov
3da655a62f Rename resolver to resolvers for consistency 2020-10-15 21:26:34 -04:00
flarum-bot
46c3124b0b Bundled output for commit e6f59b834f [skip ci] 2020-10-15 22:20:11 +00:00
Alexander Skvortsov
e6f59b834f Default force attr to true on LinkButton
This retains beta 13 behavior.
2020-10-15 18:18:51 -04:00
Alexander Skvortsov
9f5737eb93 Fix routeName attr not being passed into pages 2020-10-15 18:14:20 -04:00
flarum-bot
35cb5b20a0 Bundled output for commit 988b6c9023 [skip ci] 2020-10-15 22:02:46 +00:00
Alexander Skvortsov
988b6c9023 Allow extensions to use route resolvers (#2275)
- mapRoutes: don't wrap components in resolvers if they are already resolvers
- Extract defaultResolver into its own class
- Allow either route resolver instances, or components with an optional resolverClass which should accept the component and route name in its constructor.
- Introduce a resolver for DiscussionPage, so that routing from one post to another on the same discussion triggers a scroll instead of rerendering
2020-10-15 18:01:17 -04:00
flarum-bot
c1d91be2f4 Bundled output for commit f534398645 [skip ci] 2020-10-15 21:47:33 +00:00
Alexander Skvortsov
f534398645 Fix PostStream Reply Scroll (#2366)
- Add an index to reply placeholder so we can scroll to it directly when replying.
- Stop pretending that the currently broken `bottom` scroll functionality works, and explicitly call it `reply` scrolling to be clearer
- Directly get target from state
- Explicitly scroll to placeholder on reply
- Clean up scrollToItem code a bit
- Account for edge case where index is undefined when scrolling to post

Co-authored-by: Wadim Kalmykov <36057469+w-4@users.noreply.github.com>
2020-10-15 17:46:02 -04:00
flarum-bot
cd05ec6589 Bundled output for commit 78be6e2194 [skip ci] 2020-10-15 21:41:56 +00:00
Wadim Kalmykov
78be6e2194 Fix lifecyle method workarounds (#2378)
Essentially, whenever a route is loaded, we add a key to that component. If the key changes, the page completely rerenders. Switching between different routes handled by the same key triggers those rerenders.
2020-10-15 17:40:25 -04:00
flarum-bot
eb498a0a9f Bundled output for commit ac42a5900d [skip ci] 2020-10-15 21:36:46 +00:00
Wadim Kalmykov
ac42a5900d Make PostStreamScrubber work for Posts that have top margin (#2369)
Also fixes incorrect page count when scrolling to bottom (https://github.com/flarum/core/issues/1897)
2020-10-15 17:35:22 -04:00
Alexander Skvortsov
543b136f7c Refactor PostStream animations (#2364)
- If the fadeIn animation is specified on the PostStream class itself, any time we add/remove another animation, it will redo fadeIn. To avoid this, we move fadeIn into it's own css class, which is applied, and then immediately removed after the animation is completed to ensure it only runs once.
- The "fix" for flashItem was actually broken, as it resulted in 'flash' never being removed, so we never went back to .PostStream's fadeIn. We adjust flashItem to ensure that '.flash' is removed. We also remove 'fadeIn' in case it hasn't yet been removed in oncreate.
2020-10-15 17:34:35 -04:00
flarum-bot
8546fb734f Bundled output for commit 20b9455f04 [skip ci] 2020-10-15 18:32:09 +00:00
Wadim Kalmykov
20b9455f04 make scroll listener passive (#2387)
see: https://developer.mozilla.org/en-US/docs/Web/API/EventTarget/addEventListener
2020-10-15 14:30:32 -04:00
Wadim Kalmykov
008f1da539 Make header and navigation components redraw after page components (#2390)
Make header and navigation components redraw after page components. Page components manipulate the header (state), but the header redraws before the page on route change. By changing the mount order, we change also the redraw order.
2020-10-15 14:29:42 -04:00
flarum-bot
a3bd431503 Bundled output for commit 6da81a71a4 [skip ci] 2020-10-14 09:39:52 +00:00
Daniël Klabbers
6da81a71a4 npm audit security vulnerabilities patched 2020-10-14 10:11:34 +02:00
flarum-bot
a48d38614b Bundled output for commit 7358437c59 [skip ci] 2020-10-11 21:27:54 +00:00
Sami Mazouz
7358437c59 Fix MarkRead Mobile Gesture (#2383) 2020-10-11 17:26:32 -04:00
flarum-bot
08ec274a24 Bundled output for commit d45478564f [skip ci] 2020-10-09 23:28:51 +00:00
Alexander Skvortsov
d45478564f Fix handling of non-409 errors in ExtensionsPage
If the error isn't a 409, we'll want to re-throw the error so it'll be handled by the default system (showing an alert).

For simplicity, we can also move 409-handling logic out of setTimeout.

Finally, we adjust the timeout to 300 milliseconds to match the modal transition animation length.
2020-10-09 19:27:07 -04:00
flarum-bot
8296ffe8c9 Bundled output for commit 1d2f0ca407 [skip ci] 2020-10-09 23:07:06 +00:00
Alexander Skvortsov
1d2f0ca407 Header UI fixes (#2371)
* Revert "Fix header contents moving when opening modal (#2131)"
* Fix header contents moving when modal opened/closed.

Conditionally apply the navbar-fixed-top class only when needed, so that we can take advantage of it without always having the navbar in position:fixed, as was done in the previous solution. That resulted in a clash with custom headers.

* Show header on refresh of scrolled page

Due to some magic in Mithril 0.1's context:retain flag, some DOM elements were cached across page reloads. Since that has been eliminated, if we refresh the page and we are scrolled down, the "affix" class which makes the header fixed (and as a result, visible) isn't applied until the first scroll. We fix this by running ScrollListener.update() immediately to set initial navbar state.
2020-10-09 19:05:53 -04:00
Wadim Kalmykov
bb69c3bd57 Reduce modal hide timeout (#2367) 2020-10-09 19:04:53 -04:00
Daniël Klabbers
2b1581875a Fixes the queue for beta 14 (#2363)
- rewrite the queue handling for illuminate 6+
- implement missing maintenance mode callable for queue Worker
- Ensure we resolve append the queue commands once the queue bindings are loaded
- Override WorkCommand because it needs the maintenance flag. It tries to use
the isDownForMaintenance method from the Container assuming it is a Laravel
Application. Circumvented this issue by resolving our Config from IOC instead.
2020-10-09 16:06:28 -04:00
Sami Mazouz
a0c36a015b Use @control-bg for Slidable content (#2381) 2020-10-09 14:37:47 -04:00
flarum-bot
656409794c Bundled output for commit 245f3c6846 [skip ci] 2020-10-07 20:25:22 +00:00
Alexander Skvortsov
245f3c6846 DiscussionPage: call onNewRoute properly
When on a discussion page, the URL changing doesn't always mean we've moved to a different page. In our custom rerender logic, we only want to call `this.onNewRoute()` if the page has actually changed.
2020-10-07 16:22:41 -04:00
Alexander Skvortsov
962b49567c Restore stricter email validation
In v5.8, Laravel expanded email validation logic to closer match the RFC. This, however, allows emails that aren't conventional (for example, emails lacking a TLD). This commit changes Flarum's UserValidator to use the `email:filter` validator, which uses PHP's filter_var, and is the pre-5.8 behavior.

See https://laravel.com/docs/5.8/validation#rule-email
2020-10-07 15:33:57 -04:00
flarum-bot
12498b7620 Bundled output for commit 84f7d29d8c [skip ci] 2020-10-07 18:11:32 +00:00
Alexander Skvortsov
84f7d29d8c Slight PostStream scrubber improvement
After we scroll to a post, we redraw to render post content.  We then update the scrubber again so its height is accurate. This commit moves that update to AFTER our adjustment of scroll position, so that scrubber height is based on actual post heights. This fixes some subtle scrubber glitches.
2020-10-07 14:09:53 -04:00
Daniël Klabbers
561e8c6b6a Add test for model object argument in callable for attribute defaults 2020-10-07 11:38:52 +02:00
Daniël Klabbers
ad9917f0d6 Allows callables for default model attribute to gain access
to the current model in order to calculate the value needed.
2020-10-07 11:26:58 +02:00
Daniël Klabbers
6977c24dd8 improve deprecated message for b15 2020-10-07 10:23:46 +02:00
flarum-bot
d1b72429ac Bundled output for commit 63692f12c5 [skip ci] 2020-10-06 15:53:21 +00:00
Wadim Kalmykov
63692f12c5 SubtreeRetainer: fix onbeforeupdate needsRebuild (#2365) 2020-10-06 11:52:05 -04:00
flarum-bot
441ccec8e7 Bundled output for commit 414b0ff6d3 [skip ci] 2020-10-06 00:52:30 +00:00
Alexander Skvortsov
414b0ff6d3 Update mithril request docs link 2020-10-05 20:50:15 -04:00
flarum-bot
2ff0e1efcb Bundled output for commit 8c46b37a6f [skip ci] 2020-10-05 22:07:23 +00:00
Lucas Henrique
8c46b37a6f Convert icon helper to Typescript (#2360) 2020-10-05 18:06:08 -04:00
flarum-bot
9be629cfcc Bundled output for commit df9be1b063 [skip ci] 2020-10-05 20:26:46 +00:00
Alexander Skvortsov
df9be1b063 Move drawer hide and modal close into onNewRoute
Let's stay consistent with previous behavior, and run these on "internal route change" (same component handles different route) as well as on initial render of a page component.
2020-10-05 16:25:23 -04:00
Alexander Skvortsov
97c36f2f7d Use Symfony TranslatorInterface for tests
This seems to be a leftover change missed in https://github.com/flarum/core/pull/2243
2020-10-05 16:02:12 -04:00
flarum-bot
13efd02085 Bundled output for commit 0b44c48433 [skip ci] 2020-10-05 18:41:55 +00:00
Alexander Skvortsov
0b44c48433 Catch promise reject when not logged in on reply 2020-10-05 14:40:33 -04:00
flarum-bot
b562072471 Bundled output for commit 718445cb0c [skip ci] 2020-10-05 14:56:45 +00:00
Wadim Kalmykov
718445cb0c call parent onremove (#2362) 2020-10-05 10:55:14 -04:00
Abhishek Verma
c6f2ff0c80 Fixed Broken Badges in ReadMe.md (#2358) 2020-10-04 16:11:39 -04:00
flarum-bot
67962f48e5 Bundled output for commit f8a0d9459a [skip ci] 2020-10-03 22:48:56 +00:00
Alexander Skvortsov
f8a0d9459a Fix email confirmation alert
Currently, the controls are on a new line due to the container div. We want to wrap ALL children of the alert, including the controls, in the container div.

We need to split it into a separate class so that we can add modify the alert vnode AFTER the alert component's `view` logic has been applied.
2020-10-03 18:47:27 -04:00
David Sevilla Martín
27d562f3fc Remove Mithril namespace export from shims.d.ts (#2347)
From using PhpStorm to try and see if the autocomplete works properly, it appears as it doesn't. The intention was to not have to import Mithril every time we wanted to type something with Mithril.*, but that doesn't seem to be possible - and it's not a big deal anyway
2020-10-02 19:34:06 -04:00
flarum-bot
17a7155f60 Bundled output for commit 4cdce71d65 [skip ci] 2020-10-02 23:06:45 +00:00
Alexander Skvortsov
4cdce71d65 Eliminate temporary BC layers from rewrite
During the frontend rewrite, we introduced mithril patches for a `route` attr, and for `m.stream`. Later, we decided not to go that route, but not to remove the patches yet to avoid breaking extensions while we were finalizing the replacements. We can now remove these.

Other BC layers are for things from before beta 14, so those remain in place.
2020-10-02 19:05:26 -04:00
flarum-bot
eb03f51c4f Bundled output for commit d695d96e00 [skip ci] 2020-10-02 22:50:48 +00:00
Alexander Skvortsov
d695d96e00 Various TypeScript improvements (#2309)
- Use Mithril.Attributes as base for ComponentAttrs, remove =any from class signature for Component
- Convert Alert to TypeScript, introduce AlertAttrs interface
- Convert AlertManagerState to TypeScript, add overload signatures for `show`, introduce AlertState interface for stored Alerts.
- Set ComponentAttrs as default T for Component
- Make attrs in AlertAttrs optional
- Add AlertIdentifier interface, simplify show type signature
- Remove mithril patch shim, as all patches onto m are now deprecated
- Use Mithril.Static for shim
2020-10-02 18:49:40 -04:00
flarum-bot
dc4884485a Bundled output for commit 40548d7c61 [skip ci] 2020-10-02 22:05:01 +00:00
Wadim Kalmykov
40548d7c61 Improve DiscussionListState refresh method (#2322)
- Ensure that the discussion list is cleared before it is updated with fetched results
- Rename `clear` to `deferClear`, improve documentation to make its purpose clearer.
2020-10-02 18:03:44 -04:00
flarum-bot
60714b7ac4 Bundled output for commit 84d14f485a [skip ci] 2020-10-02 21:55:59 +00:00
Alexander Skvortsov
84d14f485a Basic Extension Dependency Support (#2188)
- Don't enable an extension if its dependencies are not enabled
- Don't disable an extension if its dependencies are not disabled
2020-10-02 17:54:28 -04:00
flarum-bot
0a6c5217c1 Bundled output for commit 44a96a82ef [skip ci] 2020-10-02 21:11:38 +00:00
Alexander Skvortsov
44a96a82ef Minor improvements to onNewRoute (#2328)
- Call onNewRoute when page changed with same component in DiscussionPage and UserPage

- Make app.previous and app.current changed in onNewRoute, not in oninit. This way, when the route is changed, but still handled by the same component, a new PageState object will still be created.
2020-10-02 17:10:38 -04:00
flarum-bot
0b3fe10516 Bundled output for commit 5ecb74fb59 [skip ci] 2020-10-02 20:58:06 +00:00
Alexander Skvortsov
5ecb74fb59 Use Link component for links instead of mithril route patch (#2315)
This new component now also supports external links.
2020-10-02 16:56:40 -04:00
flarum-bot
b66d16e44b Bundled output for commit a013d647e0 [skip ci] 2020-10-02 15:15:05 +00:00
Alexander Skvortsov
a013d647e0 Adjust PostStreamScrubber height after scroll (#2333) 2020-10-02 11:13:55 -04:00
Alexander Skvortsov
20b99bcab1 Ensure that modal hide animates (#2332)
We want to return a promise in``onbeforeremove` with arbitrary loading time to ensure that the animateHide animation has time to complete.
2020-10-02 11:12:49 -04:00
flarum-bot
8325b6eed8 Bundled output for commit f9704f9153 [skip ci] 2020-10-01 18:52:08 +00:00
Alexander Skvortsov
f9704f9153 Fix multiple scrolls to same post in PostStream (#2264)
While more pleasant from an FSM standpoint, comparing the current targetPost to the previous one does not work if goToNumber is called twice in a row for the same post. For instance, if a user clicks the mentions link to a post twice, the post stream breaks.
2020-10-01 14:50:54 -04:00
flarum-bot
09a39d5d95 Bundled output for commit a26f01e49c [skip ci] 2020-10-01 01:03:05 +00:00
Alexander Skvortsov
a26f01e49c Use custom event on ALL SuperTextEditor actions 2020-09-30 21:01:24 -04:00
Sami Mazouz
6d826e5b30 Right align discussion controls dropdown on slidable item (#2330) 2020-09-30 16:53:50 -04:00
flarum-bot
f38605b387 Bundled output for commit 93f8ce78b3 [skip ci] 2020-09-30 20:34:47 +00:00
Alexander Skvortsov
93f8ce78b3 Improve PostStreamState.viewingEnd()
In some cases, such as if we've stickied a post, an event post
may have been added / removed.This means that `this.visibleEnd`
and`this.count()` will be out of sync by 1 post, but we are still
"viewing the end" of the post stream, so we should still reload
all posts up until the last one.
2020-09-30 16:33:10 -04:00
Daniël Klabbers
a001736298 Mark keys for Config required only with InstalledSite (#2323)
* use fallback on url to prevent errors in cli during install. The value of the fallback doesn't actually matter, we just need something.
2020-09-30 15:38:19 -04:00
Daniël Klabbers
86d4bf0214 Fix for a bug that would delete the new revision of less/js in case the filenames match. 2020-09-30 09:26:32 +02:00
Daniël Klabbers
c7b67b922b Allow easier overriding of js compiler (#2318) 2020-09-29 19:03:51 -04:00
flarum-bot
3b63d774d3 Bundled output for commit 86f7550bec [skip ci] 2020-09-29 22:42:20 +00:00
Alexander Skvortsov
86f7550bec Merge pull request #2314 from flarum/as/modal-fix
Frontend Rewrite Followup Modal Fixes
2020-09-29 18:41:03 -04:00
Alexander Skvortsov
9d1a87a4c4 Rename onshow and onhide
animateShow and animateHide are more descriptive
2020-09-29 18:37:56 -04:00
Alexander Skvortsov
a2263b8538 Return on animateShow if already loaded 2020-09-29 18:37:56 -04:00
Alexander Skvortsov
1ac09dbc4d Pass ModalManagerState into Modal instances instead of calling the global. 2020-09-29 18:37:56 -04:00
Alexander Skvortsov
be8fe44f0b Ensure that readyCallback is called on modals opened from other modals 2020-09-29 18:37:56 -04:00
Alexander Skvortsov
b7593bc6a8 Prevent hide animation when opening modal from other modal 2020-09-29 18:37:56 -04:00
Alexander Skvortsov
7fc0963e3c Revert "Fix opening modals from other modals. (#2263)"
This reverts commit 5b157f0adb.
2020-09-29 18:37:56 -04:00
flarum-bot
30f3056f70 Bundled output for commit ed23d7d4e7 [skip ci] 2020-09-29 22:35:50 +00:00
Alexander Skvortsov
ed23d7d4e7 Merge pull request #2299 from flarum/as/poststream_improvements
[Frontend Rewrite] PostStream Improvements
2020-09-29 18:34:16 -04:00
Franz Liedke
1e9f7b7d52 README: Update badge to truly show latest release
Refs #2311.
2020-09-29 23:23:37 +02:00
Abolade Eniseyin
08540fd1db Update logo and badges in README (#2311)
Fixes #2296.
2020-09-29 23:22:51 +02:00
flarum-bot
74fa7122ca Bundled output for commit 4b2d20cd85 [skip ci] 2020-09-29 20:56:44 +00:00
Wadim Kalmykov
4b2d20cd85 fix clear search (#2325) 2020-09-29 16:55:26 -04:00
flarum-bot
077eaaa2f9 Bundled output for commit 6668e75019 [skip ci] 2020-09-28 23:18:39 +00:00
Sami Mazouz
6668e75019 Fix mobile controls gesture on discussion deletion/restoration (#2324)
Because the Slidable class was always added on creation, it was lost every time the class list changed (in this case when the discussion was hidden/unhidden which added/removed DiscussionListItem--hidden class). So by determining the Slidable class's presence in elementAttrs() method, it guarantees it always properly set.
2020-09-28 19:07:05 -04:00
Wadim Kalmykov
d6511e0df5 Improve developer experience by forcing LF line endings (#2321) 2020-09-28 14:04:08 -04:00
Alexander Skvortsov
efd68df13a Pass a translator instance to getEmailSubject on MailableInterface (#2244)
* Pass a translator instance to getMailSubject (breaking change)

* Temporarily comment out getEmailSubject to avoid BC breaks
2020-09-28 00:04:28 -04:00
flarum-bot
f1360a1394 Bundled output for commit cc875f3e95 [skip ci] 2020-09-28 03:51:00 +00:00
Alexander Skvortsov
cc875f3e95 Put m.stream in flarum/utils/stream (#2316) 2020-09-27 23:49:33 -04:00
Franz Liedke
6860b24b70 Use reserved TLD for default dev hostname
See https://jdebp.eu/FGA/dns-use-domain-names-that-you-own.html.
2020-09-27 22:55:46 +02:00
flarum-bot
65766a8386 Bundled output for commit c53509d7d0 [skip ci] 2020-09-27 02:14:07 +00:00
Alexander Skvortsov
c53509d7d0 Add warnings to Mithril 2 BC layer (#2313) 2020-09-26 22:12:43 -04:00
Alexander Skvortsov
4c3e1e2625 Fixed noAnimation: previously, the opposite of what was requested happened 2020-09-25 16:02:39 -04:00
Alexander Skvortsov
6508e64f55 DiscussionPage: only set this.discussion after the initial set of posts has loaded, this results in a slightly smoother initial load. 2020-09-25 15:54:54 -04:00
Alexander Skvortsov
963c27ed60 Provide location data to scrubber earlier to avoid unnecessary and confusing scrubber animation on page load. 2020-09-25 15:52:18 -04:00
Alexander Skvortsov
304f05be36 Don't animate the initial Scrubber placement 2020-09-25 15:43:41 -04:00
Alexander Skvortsov
82af307280 Restore fadeIn to 400ms (#2312)
This is the jQuery fadeIn default, which we were relying on before this animation was changed to pure CSS.
2020-09-25 21:15:24 +02:00
Alexander Skvortsov
50cbb7be5c Merge pull request #2271 from flarum/fl/laravel-updates-config
This extracts another real class for dealing with the configuration options stored in `config.php`. The idea is to reduce the scope of the `Application` class and make it easier to inject exactly what's needed (rather than an array, which is complicated, or the bloated `Application` class).
2020-09-25 11:22:53 -04:00
Franz Liedke
9ea57e6329 Use Config class for data from config.php 2020-09-25 11:10:52 +02:00
Franz Liedke
6639678fb2 Inject/use new config class where applicable 2020-09-25 10:58:53 +02:00
Franz Liedke
f869999011 Add a helper class for managing low-level config 2020-09-25 10:58:52 +02:00
flarum-bot
f885cebdc5 Bundled output for commit 54ff6e720c [skip ci] 2020-09-25 02:32:11 +00:00
Alexander Skvortsov
54ff6e720c Add in BC layer for props, initProps, m.withAttr, and m.prop (#2310) 2020-09-24 22:30:55 -04:00
Daniël Klabbers
aea8a3ff1f Changes methods and properties from private to protected (#2308)
The goal of this PR is to offer increased flexibility for integrators and
custom solutions in skeleton modifications.
2020-09-24 14:30:16 -04:00
Alexander Skvortsov
cc48e9ab22 Replace $app->url() with url-generated link to index (#2302) 2020-09-24 11:30:57 -04:00
Alexander Skvortsov
6d38de9c8f Revert https://github.com/flarum/core/pull/1536 (#2305) 2020-09-24 11:30:27 -04:00
flarum-bot
87634449c0 Bundled output for commit b00ca4ef29 [skip ci] 2020-09-24 04:09:18 +00:00
Matteo Contrini
b00ca4ef29 Fix comment for the time gap feature in PostStream (#2294)
The time interval for the time gap feature is 4 days and not 4 hours.
2020-09-24 00:08:12 -04:00
flarum-bot
fd0f0cdf8b Bundled output for commit 5b157f0adb [skip ci] 2020-09-24 03:13:43 +00:00
Alexander Skvortsov
5b157f0adb Fix opening modals from other modals. (#2263)
While seemingly correct, an onremove method in Modal that triggers animateHide is problematic, because if one modal is opened from another, the one currently open will be removed from the DOM, triggering animateHide, and closing the new modal.

To compensate, an onupdate method now closes a modal if one is open but shouldn't be; this supports the functionality of the old method when the modal is closed not from the modal instance itself (e.g. app.modal.close())

This is not ideal, but necessary. We should consider eventually expanding the modal system to support showing multiple modals at the same time (stacked over each other). Then, we can move this back to individual modals.
2020-09-23 23:12:22 -04:00
Alexander Skvortsov
dc8b203037 Only call updateScrubberValues onupdate when necessary
When the page is scrolled, goToIndex is called, or the page is loaded,
various listeners result in the scrubber being updated with a new
position and values. However, if goToNumber is called, the scrubber
will not be updated. Accordingly, we add logic to the scrubber's
onupdate to update itself, but only when needed, as indicated by this

This saves us a LOT of unnecessary calls, and makes scrubber movement smoother.
2020-09-23 23:06:25 -04:00
Alexander Skvortsov
db71f8bf68 Execute oncreate scrubber update after loadPromise has completed
This way, we ensure that the initial position (and data) of the scrubber is correct. Otherwise, we get blank dates / incorrect location.
2020-09-23 23:06:20 -04:00
Alexander Skvortsov
a004b8e057 Fix $(...).offset() is undefined on some scrolls. 2020-09-23 23:06:14 -04:00
flarum-bot
1ff4076f2a Bundled output for commit 6e9db779cd [skip ci] 2020-09-24 03:06:06 +00:00
Alexander Skvortsov
6e9db779cd Fix double fadein for post stream (#2300) 2020-09-23 23:04:56 -04:00
flarum-bot
f4449e962d Bundled output for commit 71f3379fcc [skip ci] 2020-09-24 02:41:41 +00:00
David Sevilla Martín
71f3379fcc Mithril 2 update (#2255)
* Update frontend to Mithril 2

- Update Mithril version to v2.0.4
- Add Typescript typings for Mithril
- Rename "props" to "attrs"; "initProps" to "initAttrs"; "m.prop" to "m.stream"; "m.withAttr" to "utils/withAttr".
- Use Mithril 2's new lifecycle hooks
- SubtreeRetainer has been rewritten to be more useful for the new system
- Utils for forcing page re-initializations have been added (force attr in links, setRouteWithForcedRefresh util)
- Other mechanical changes, following the upgrade guide
- Remove some of the custom stuff in our Component base class
- Introduce "fragments" for non-components that control their own DOM
- Remove Mithril patches, introduce a few new ones (route attrs in <a>; 
- Redesign AlertManagerState `show` with 3 overloads: `show(children)`, `show(attrs, children)`, `show(componentClass, attrs, children)`
- The `affixedSidebar` util has been replaced with an `AffixedSidebar` component

Challenges:
- `children` and `tag` are now reserved, and can not be used as attr names
- Behavior of links to current page changed in Mithril. If moving to a page that is handled by the same component, the page component WILL NOT be re-initialized by default. Additional code to keep track of the current url is needed (See IndexPage, DiscussionPage, and UserPage for examples)
- Native Promise rejections are shown on console when not handled
- Instances of components can no longer be stored. The state pattern should be used instead.

Refs #1821.

Co-authored-by: Alexander Skvortsov <sasha.skvortsov109@gmail.com>
Co-authored-by: Matthew Kilgore <tankerkiller125@gmail.com>
Co-authored-by: Franz Liedke <franz@develophp.org>
2020-09-23 22:40:37 -04:00
Alexander Skvortsov
1321b8cc28 Revert "Use lifecycle interface for frontend extender (#2211)" (#2301)
This reverts commit 3117d2ad7a.
2020-09-23 00:21:45 -04:00
flarum-bot
fa0ff204dd Bundled output for commit 872e3bdc92 [skip ci] 2020-09-18 19:33:18 +00:00
Alexander Skvortsov
872e3bdc92 Add missing exports to compat 2020-09-18 21:31:35 +02:00
Alexander Skvortsov
79f9012694 Fix Post-actions being on top of Post Controls Dropdown
- This was accidentially introduced when an explicit z-index was added to reply-actions to prevent Post-footer from covering it
- Here, we revert that zindex, while making Post-footer inline-block to stop it from covering everything. We also set height=0 to stop implicitly added height
2020-09-18 15:33:01 +02:00
Wadim Kalmykov
633cc14d09 Fix issue where posts API doesn't return the right amount of posts (#2291) 2020-09-17 23:59:34 -04:00
Alexander Skvortsov
c6e85ef330 Allow upper case TLS and SSL for SMTP encryption (#2289) 2020-09-12 17:43:06 -04:00
phanlyhuynh
3f8432a589 Fix SMTP username and password shouldn't be required (#2287) 2020-09-06 15:12:03 -04:00
flarum-bot
96c95f2b6a Bundled output for commit 8e3e8826f9 [skip ci] 2020-09-04 17:00:30 +00:00
Franz Liedke
8e3e8826f9 app.composer.show: Trigger synchronous redraw
This is needed to have access to the newly created SuperTextarea
instance (app.composer.editor) directly after calling show().

Discovered when making ext-mentions work with the Composer state
changes. As far as I could reconstruct, a synchronous redraw was also
triggered in this situation before the changes in #2161.
2020-09-04 18:58:40 +02:00
Johannes Nilsson
384edfa52b Remove unwanted semicolon in assets files (#2280) 2020-08-31 23:52:37 -04:00
Franz Liedke
f939d164b7 Make queue error handler compatible with Laravel 6 (#2270) 2020-08-27 22:41:36 -04:00
flarum-bot
ebbef75cfb Bundled output for commit 2caa5cf19c [skip ci] 2020-08-28 02:41:03 +00:00
fengkx
2caa5cf19c fix: escape regexp from query (#2273)
* fix: escape regexp from query
2020-08-27 22:39:49 -04:00
Alexander Skvortsov
fe718a1490 Bump fontawesome version to ^5.14.0 (#2274) 2020-08-27 23:01:57 +02:00
flarum-bot
beb03b7771 Bundled output for commit 97186e6086 [skip ci] 2020-08-25 14:54:57 +00:00
Karan Sanjeev
97186e6086 Added an empty alt attribute to avatar's img tag (#2269)
fixes #2256
2020-08-25 10:53:44 -04:00
flarum-bot
47f3ee0ce2 Bundled output for commit a9eb14889e [skip ci] 2020-08-16 20:36:13 +00:00
Matteo Contrini
a9eb14889e Fix number abbreviation when the number is n-thousand (#2261)
This commit fixes the method `abbreviateNumber` so that it behaves as stated in the JSDoc.

Previously, an input of `1234` would have produced `1K`. With this change, the output will be `1.2K`.
2020-08-16 16:35:05 -04:00
flarum-bot
5e5a5294c3 Bundled output for commit c39b6a6d2f [skip ci] 2020-08-16 20:34:13 +00:00
Franz Liedke
c39b6a6d2f Extract a few changes from the Mithril 2 upgrade (#2262)
* Fix closing the composer with ESC key

Regression from #2161.

* Remove obsolete method

Regression from #2162.

* Mark method as protected

* Fade in posts in post stream using CSS

This also avoids a double-fade from the JavaScript code, which was
probably introduced in #2160.

* Fix fadeIn for post stream items

Co-authored-by: Alexander Skvortsov <sasha.skvortsov109@gmail.com>
2020-08-16 16:32:59 -04:00
一枚小猿
22bf03d872 Fix less build error. (#2252) 2020-08-15 20:21:06 -04:00
flarum-bot
a2f3534bf7 Bundled output for commit 6953d93c6d [skip ci] 2020-08-08 18:47:16 +00:00
Alexander Skvortsov
6953d93c6d Extract PostStream state (#2160)
Co-authored-by: Franz Liedke <franz@develophp.org>
2020-08-08 14:45:54 -04:00
dependabot[bot]
f9c9b5d5e4 Bump elliptic from 6.5.2 to 6.5.3 in /js (#2251)
Bumps [elliptic](https://github.com/indutny/elliptic) from 6.5.2 to 6.5.3.
- [Release notes](https://github.com/indutny/elliptic/releases)
- [Commits](https://github.com/indutny/elliptic/compare/v6.5.2...v6.5.3)

Signed-off-by: dependabot[bot] <support@github.com>

Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2020-07-31 01:08:39 +02:00
Alexander Skvortsov
8a73cc522e Fix optional parameters in url generator (#2246)
* Fix route collection getting wrong path when optional parameters present, add unit tests
2020-07-28 20:51:14 -04:00
Franz Liedke
db83003eb5 Apply fixes from StyleCI
[ci skip] [skip ci]
2020-07-27 19:42:23 +00:00
Franz Liedke
4dc4dc624e Merge pull request #2243 from flarum/fl/2055-l6-translator
Upgrade to Laravel 6, finally!
2020-07-27 21:42:01 +02:00
flarum-bot
ad42058a8a Bundled output for commit 5e465f6051 [skip ci] 2020-07-24 22:18:35 +00:00
Alexander Skvortsov
5e465f6051 Extract Composer state (#2161)
Like previous "state PRs", this moves app-wide logic relating to
our "composer" widget to its own "state" class, which can be
referenced and called from all parts of the app. This lets us
avoid storing component instances, which we cannot do any longer
once we update to Mithril v2.

This was not as trivial as some of the other state changes, as we
tried to separate DOM effects (e.g. animations) from actual state
changes (e.g. minimizing or opening the composer).

New features:

- A new `app.screen()` method returns the current responsive screen
  mode. This lets us check what breakpoint we're on in JS land  
  without hardcoding / duplicating the actual breakpoints from CSS.
- A new `SuperTextarea` util exposes useful methods for directly
  interacting with and manipulating the text contents of e.g. our
  post editor.
- A new `ConfirmDocumentUnload` wrapper component encapsulates the
  logic for asking the user for confirmation when trying to close
  the browser window or navigating to another page. This is used in
  the composer to prevent accidentally losing unsaved post content.

There is still potential for future cleanups, but we finally want   
to unblock the Mithril update, so these will have to wait:

- Composer height change logic is very DOM-based, so should maybe
  not sit in the state.
- I would love to experiment with using composition rather than
  inheritance for the `ComposerBody` subclasses.
2020-07-25 00:17:25 +02:00
flarum-bot
62a2e8463d Bundled output for commit 0098c64ebf [skip ci] 2020-07-24 21:53:31 +00:00
Franz Liedke
0098c64ebf Fix an irrelevant export name :P 2020-07-24 23:51:44 +02:00
Franz Liedke
2b5939d538 Simplify a few unnecessary Arr::get() calls 2020-07-24 22:56:31 +02:00
Alexander Skvortsov
2431df5602 Revert "Fixes wrong IP address when using a reverse proxy (#2236)" (#2242)
This reverts commit 451a557532 pending further discussion of https://github.com/flarum/core/pull/2236#issuecomment-663645583
2020-07-24 14:19:10 -04:00
flarum-bot
264ff67304 Bundled output for commit c08a56e9d8 [skip ci] 2020-07-24 17:03:04 +00:00
Alexander Skvortsov
c08a56e9d8 Notifications Dropdown: Remove init method that doesn't do anything (cleanup) 2020-07-24 13:01:45 -04:00
Alexander Skvortsov
4ee6d6fd88 Revert "Inject Url Generator and Translator Interface into notification mailer (#2169)"
This was actually already present and functional, so adding additional code for it
is unnecessary.

This reverts commit e627616750.
2020-07-24 12:44:59 -04:00
Franz Liedke
9c09fe8465 Update to Laravel 6, finally!
Fixes #2055.
2020-07-24 17:34:40 +02:00
Franz Liedke
b46d5e67a3 Make Translator compatible with Laravel 6
It's contract will change in Laravel 6. We extend from Symfony's
translator, but need to be compatible with that from Laravel in
order to use its validation package.

References:
- https://laravel.com/docs/6.x/upgrade#trans-and-trans-choice
- 8557dc56b1 (diff-88bc04a1548d09aa6250d902d1ac2b4c)
2020-07-24 17:32:50 +02:00
Franz Liedke
7fd23ff950 Inject Symfony translator contract, not Laravel's
The Laravel changes with v6, and our translator is primarily an
implementation of the Symfony contract.
2020-07-24 17:31:46 +02:00
Franz Liedke
e4077ab4ad Replace a few forgotten obsolete helpers
- Apparently, I forgot that `array_flatten` comes from Laravel. :)
- When I did this previously, I did not search the views directory.
2020-07-24 17:28:56 +02:00
Franz Liedke
3b39c212e0 Explicitly bundle Carbon library
We have used this transitive dependency (via illuminate/support)
for a while, so let's make this explicit.

Incidentally, we now also explicitly require version 2.x - the
previous 1.x branch will no longer be supported after the
upcoming upgrade to Laravel 6.

Refs #2055.
2020-07-24 16:46:33 +02:00
Franz Liedke
bca833d3f1 Remove Mandrill mail driver
This is in preparation for the upcoming upgrade to Laravel 6,
which dropped this driver.

Refs #2055.
2020-07-24 16:39:28 +02:00
Jake Esser
451a557532 Fixes wrong IP address when using a reverse proxy (#2236)
Added reverse proxy support to preserve forwarded IPs
2020-07-22 08:55:44 -04:00
Alexander Skvortsov
eaac78650f Deprecate AssertPermissionTrait (#2044) 2020-07-17 15:16:15 +02:00
Franz Liedke
2b3dec2be1 Fix deprecation and removal date 2020-07-17 12:19:48 +02:00
Alexander Skvortsov
37ebeb5705 User Extender (prepareGroups functionality) (#2110) 2020-07-17 12:18:35 +02:00
Franz Liedke
71abac0323 Rename view extender
As discussed in my initial review, it seems unlikely that we need
the ability to remove (or otherwise modify) namespaces again.
Therefore, it seems more consistent with other extenders to go
for a "View" extender with a "namespace" method.

Sorry for the back and forth. ;)

Refs #1891, #2134.
2020-07-17 12:05:49 +02:00
Franz Liedke
7e3d71a0a0 View extender: Do not resolve factory
Not all requests need this factory, so there is no need to
instantiate one and load the required files.

Refs #1891, #2134.
2020-07-17 12:05:38 +02:00
Alexander Skvortsov
b5e891df30 View Extender (add namespace) (#2134) 2020-07-17 11:59:00 +02:00
Alexander Skvortsov
3117d2ad7a Use lifecycle interface for frontend extender (#2211) 2020-07-17 11:49:52 +02:00
dependabot[bot]
1ce0b926b6 Bump lodash from 4.17.15 to 4.17.19 in /js (#2235)
Bumps [lodash](https://github.com/lodash/lodash) from 4.17.15 to 4.17.19.
- [Release notes](https://github.com/lodash/lodash/releases)
- [Commits](https://github.com/lodash/lodash/compare/4.17.15...4.17.19)

Signed-off-by: dependabot[bot] <support@github.com>

Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2020-07-17 10:47:28 +02:00
flarum-bot
24b16f9d7c Bundled output for commit bd40353bcc [skip ci] 2020-07-10 13:42:33 +00:00
Franz Liedke
bd40353bcc Merge pull request #2207 from flarum/ds/typescript-conversion
Convert several files in `common/utils` to TypeScript
2020-07-10 15:41:14 +02:00
David Sevilla Martin
455327cca1 convert: common/utils/stringToColor 2020-07-10 14:13:33 +02:00
David Sevilla Martin
20baa93ca7 convert: common/utils/string 2020-07-10 14:13:33 +02:00
David Sevilla Martin
4f34e326ef convert: common/utils/RequestError 2020-07-10 14:13:33 +02:00
David Sevilla Martin
521cefbc2d convert: common/utils/liveHumanTimes
This file isn't used anywhere. We should be calling it at some point. It has existed for 5 years.

Renamed function because it makes more sense for name to match file name (not that it matters when building)
2020-07-10 14:13:32 +02:00
David Sevilla Martin
dc738d68dc convert: common/utils/abbreviateNumber 2020-07-10 14:13:32 +02:00
David Sevilla Martin
286af7084b convert: common/utils/extract 2020-07-10 14:13:31 +02:00
David Sevilla Martin
4869baea74 convert: common/utils/ItemList 2020-07-10 14:13:31 +02:00
David Sevilla Martin
24a48310ff convert: common/utils/humanTime 2020-07-10 14:05:09 +02:00
David Sevilla Martin
bdb759c558 convert: common/utils/formatNumber 2020-07-10 14:05:07 +02:00
Matt Kilgore
36eb5cc5fb Add port on Url to BaseUrl Test (#2226)
Added Urls with ports to the BaseUrl Test
2020-07-10 12:17:12 +02:00
David Sevilla Martín
d189272473 Initial TypeScript infrastructure (#2206)
This allows us to get started with converting all Flarum JavaScript code to TypeScript.
In addition, we will have time to experiment to find the best Webpack configuration before integrating into flarum-webpack-config.

See flarum/flarum-webpack-config#3.
2020-07-03 14:47:44 +02:00
flarum-bot
7d48c24dda Bundled output for commit 5786f1a10b [skip ci] 2020-07-03 05:17:34 +00:00
Alexander Skvortsov
5786f1a10b Fix discussions user page (#2225)
* Fixed up discussions user page, improve discussion list state signature
2020-07-03 01:16:08 -04:00
flarum-bot
b4421e1cce Bundled output for commit 359b4ab5a3 [skip ci] 2020-07-02 22:33:46 +00:00
Clark Winkelmann
359b4ab5a3 Fix user card issue by reverting to original behavior (#2224)
* Fix user card issue by reverting to original behavior
2020-07-02 18:32:41 -04:00
Alexander Skvortsov
8a686911ff Don't create user bio column on new installations (#2215) 2020-07-01 17:31:52 -04:00
Alexander Skvortsov
0b5a9a2fe6 Make scrubber handle have transparent background (#2222) 2020-07-01 17:07:13 -04:00
flarum-bot
50a9f7ce86 Bundled output for commit 8dd5420405 [skip ci] 2020-07-01 00:34:15 +00:00
David Sevilla Martín
8dd5420405 Switch from 'moment' to 'dayjs' (#2219)
* Switch from 'moment' to 'dayjs'

* Use humanize code from duration plugin (without actual plugin) for time lapsed events
2020-06-30 20:33:00 -04:00
flarum-bot
640cc0989b Bundled output for commit 44376cef61 [skip ci] 2020-07-01 00:00:24 +00:00
Alexander Skvortsov
44376cef61 Extract ModalManagerState from ModalManager (#2162) 2020-06-30 19:59:16 -04:00
flarum-bot
4f181c84fc Bundled output for commit ea9d601338 [skip ci] 2020-06-30 22:08:06 +00:00
Alexander Skvortsov
ea9d601338 Extract AlertManagerState from AlertManager (#2163) 2020-06-30 18:06:59 -04:00
Alexander Skvortsov
aaebd3581f Fix: Use proper variable for display name drivers in user extender 2020-06-29 19:32:08 -04:00
flarum-bot
e2c416903e Bundled output for commit e81159249f [skip ci] 2020-06-28 17:45:26 +00:00
Alexander Skvortsov
e81159249f Add check to register state of '0' as false for checkboxes (#2210)
* Add check to register state of '0' as false for checkboxes
* Add comment explaining state === '0'
2020-06-28 13:44:14 -04:00
flarum-bot
d93cf4a574 Bundled output for commit a33fbbf814 [skip ci] 2020-06-27 18:20:09 +00:00
Alexander Skvortsov
a33fbbf814 Add index page title, add mechanism to clear title from defaultRoute. (#2047)
* Add "All Descriptions title to index

* Added system to clear custom title if we're on the default route
2020-06-27 14:18:49 -04:00
flarum-bot
0c645a6c15 Bundled output for commit b44b79eba9 [skip ci] 2020-06-26 16:25:45 +00:00
Franz Liedke
b44b79eba9 Fix typo and update outdated doc block 2020-06-26 18:23:56 +02:00
flarum-bot
93398b738b Bundled output for commit 7816b61bfb [skip ci] 2020-06-26 14:08:35 +00:00
Franz Liedke
7816b61bfb Remove documentation for obsolete component prop 2020-06-26 16:06:56 +02:00
Franz Liedke
7dc3a194c3 Expose a method for clearing notification list
Needed for pusher extension.

Refs #2185.
2020-06-26 15:10:41 +02:00
flarum-bot
cea7824b57 Bundled output for commit 088eb0c4f2 [skip ci] 2020-06-26 12:32:40 +00:00
Franz Liedke
088eb0c4f2 Move DiscussionListState to correct folder 2020-06-26 12:52:33 +02:00
Franz Liedke
2ba67b021f Expose state classes via compat
This way, they can be extended by extensions.
2020-06-26 12:50:43 +02:00
flarum-bot
92791a253d Bundled output for commit 138c784a50 [skip ci] 2020-06-24 00:51:55 +00:00
David Sevilla Martín
138c784a50 Call liveHumanTimes() to update ago times every 10s (#2208)
This file has existed for 5 years, yet it was never used.
2020-06-23 20:50:57 -04:00
flarum-bot
bb567e5278 Bundled output for commit cf4f2f283e [skip ci] 2020-06-20 14:19:53 +00:00
w-4
cf4f2f283e Fix discussion unreadCount could be higher than commentCount (#2195)
* Fix discussion unreadCount being higher than commentCount if posts have been deleted
2020-06-20 10:18:26 -04:00
flarum-bot
ed01f389a8 Bundled output for commit 71e313e677 [skip ci] 2020-06-19 21:42:28 +00:00
Alexander Skvortsov
71e313e677 Clean up app.current, app.previous in JS (#2156)
- Encapsulate app.current, app.previous in PageState objects
- Reorganize Page classes to use one central base class in common

Co-authored-by: Franz Liedke <franz@develophp.org>
2020-06-19 17:41:26 -04:00
Franz Liedke
88366fe8af Clean up usages / deprecate path helpers (#2155)
* Write source map without creating temp file

Less I/O, and one less place where we access the global path helpers.

* Drop useless app_path() helper

This was probably taken straight from Laravel. There is no equivalent
concept in Flarum, so this should be safe to remove.

* Deprecate global path helpers

Developers using these helpers can inject the `Paths` class instead.

* Stop storing paths as strings in container

* Avoid using path helpers from Application class

* Deprecate path helpers from Application class

* Avoid using public_path() in prerequisite check

a) The comparison was already outdated, as a different path was passed.
b) We're trying to get rid of these global helpers.
2020-06-19 16:16:03 -04:00
flarum-bot
b82504b4b1 Bundled output for commit 898d68d9f3 [skip ci] 2020-06-19 00:30:16 +00:00
Franz Liedke
898d68d9f3 Remove leftover property
Refs #2150.
2020-06-19 02:27:01 +02:00
flarum-bot
69f0172b92 Bundled output for commit 62fe9db732 [skip ci] 2020-06-19 00:11:51 +00:00
Alexander Skvortsov
62fe9db732 Don't store PostUser instance in CommentPost (#2184)
* Don't save component state in CommentPost
2020-06-18 20:10:25 -04:00
flarum-bot
ed566cd18f Bundled output for commit 5c1663d8f1 [skip ci] 2020-06-18 23:54:42 +00:00
Alexander Skvortsov
5c1663d8f1 Move Discussion List State into its own class (#2150)
Extract discussion list state
2020-06-18 19:53:40 -04:00
flarum-bot
c5d3b058ba Bundled output for commit 4a804dbbbc [skip ci] 2020-06-18 22:48:18 +00:00
Alexander Skvortsov
4a804dbbbc Remove app.search instance, cache app.cache.searched (#2151)
* Moved search state logic into search state
2020-06-18 18:47:01 -04:00
591 changed files with 29706 additions and 11319 deletions

View File

@@ -15,5 +15,5 @@ indent_size = 2
[*.{diff,md}]
trim_trailing_whitespace = false
[*.{php,xml}]
[*.{php,xml,json}]
indent_size = 4

2
.gitattributes vendored
View File

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

View File

@@ -8,7 +8,7 @@ jobs:
strategy:
matrix:
php: [7.2, 7.3, 7.4]
php: [7.3, 7.4, '8.0']
service: ['mysql:5.7', mariadb]
prefix: ['', flarum_]
@@ -21,16 +21,16 @@ jobs:
prefixStr: (prefix)
exclude:
- php: 7.2
- php: 7.3
service: 'mysql:5.7'
prefix: flarum_
- php: 7.2
- php: 7.3
service: mariadb
prefix: flarum_
- php: 7.3
- php: 8.0
service: 'mysql:5.7'
prefix: flarum_
- php: 7.3
- php: 8.0
service: mariadb
prefix: flarum_
@@ -45,13 +45,22 @@ jobs:
steps:
- uses: actions/checkout@master
- name: Select PHP version
run: sudo update-alternatives --set php $(which php${{ matrix.php }})
- name: Setup PHP
uses: shivammathur/setup-php@v2
with:
php-version: ${{ matrix.php }}
coverage: xdebug
extensions: curl, dom, gd, json, mbstring, openssl, pdo_mysql, tokenizer, zip
tools: phpunit, composer:v2
# The authentication alter is necessary because newer mysql versions use the `caching_sha2_password` driver,
# which isn't supported prior to PHP7.4
# When we drop support for PHP7.3, we should remove this from the setup.
- name: Create MySQL Database
run: |
sudo systemctl start mysql
mysql -uroot -proot -e 'CREATE DATABASE flarum_test;' --port 13306
mysql -uroot -proot -e "ALTER USER 'root'@'localhost' IDENTIFIED WITH mysql_native_password BY 'root';" --port 13306
- name: Install Composer dependencies
run: composer install
@@ -65,3 +74,5 @@ jobs:
- name: Run Composer tests
run: composer test
env:
COMPOSER_PROCESS_TIMEOUT: 600

2
.gitignore vendored
View File

@@ -4,6 +4,8 @@ composer.phar
node_modules
.DS_Store
Thumbs.db
tests/.phpunit.result.cache
/tests/integration/tmp
.vagrant
.idea/*
.vscode

View File

@@ -1,5 +1,261 @@
# Changelog
## [0.1.0-beta.16](https://github.com/flarum/core/compare/v0.1.0-beta.15...v0.1.0-beta.16)
### Added
- Allow event subscribers (https://github.com/flarum/core/pull/2535)
- Allow Settings extender to have a default value (https://github.com/flarum/core/pull/2495)
- Allow hooking into the sending of notifications before being send (https://github.com/flarum/core/pull/2533)
- PHP 8 support (https://github.com/flarum/core/pull/2507)
- Search extender (https://github.com/flarum/core/pull/2483)
- User badges to post preview (https://github.com/flarum/core/pull/2555)
- Optional extension dependencies allow a booting order (https://github.com/flarum/core/pull/2579)
- Auth extender (https://github.com/flarum/core/pull/2176)
- `X-Powered-By` header added to allow indexers easier data aggregation of Flarum adoption (https://github.com/flarum/core/pull/2618)
### Changed
- Run integration tests in transaction (https://github.com/flarum/core/pull/2304)
- Allow policies to return a boolean for simplified allow/deny (https://github.com/flarum/core/pull/2534)
- Converted highlight helper to typescript (https://github.com/flarum/core/pull/2532)
- Add accessibility attributes to Mark as Read button (https://github.com/flarum/core/pull/2564)
- Dismiss errors on change email modal upon a new request ([00913d5](https://github.com/flarum/core/commit/00913d5b0be2172cfce1f16aaf64a24f3d2e6d4b))
- Disabled extensions now are marked with a red circle instead of a red dot (https://github.com/flarum/core/pull/2562)
- Extension dependency errors now show the extension title instead of the ID (https://github.com/flarum/core/pull/2563)
- Change `mutate` method on ApiSerializer extender to `attributes` (https://github.com/flarum/core/pull/2578)
- Moved locale files to the core from the language pack (https://github.com/flarum/core/pull/2408)
- AdminPage extensibility and generic improvements (https://github.com/flarum/core/pull/2593)
- Remove entry of authors, link to https://flarum.org/team (https://github.com/flarum/core/pull/2625)
- Search and filtering are split (https://github.com/flarum/core/pull/2454)
- Move IP identification into a middleware (https://github.com/flarum/core/pull/2624)
- Editor Driver abstraction introduced (https://github.com/flarum/core/pull/2594)
- Allow overriding routes (https://github.com/flarum/core/pull/2577)
- Split user edit permissions into permissions for editing of user credentials, username, groups and suspending (https://github.com/flarum/core/pull/2620)
- Reduced number of admin extension categories (https://github.com/flarum/core/pull/2604)
- Move search related classes to a dedicated Query namespace (https://github.com/flarum/core/pull/2645)
- Rewrite common helpers into typescript (https://github.com/flarum/core/pull/2541)
- `TextEditor` is moved to the common namespace for use in the admin frontend (https://github.com/flarum/core/pull/2649)
- Update Laravel/Illuminate components to 8 (https://github.com/flarum/core/pull/2576)
- Eager load relations in discussion listing to improve performance (https://github.com/flarum/core/pull/2639)
- Adopt flarum/testing package (https://github.com/flarum/core/pull/2545)
- Replace `user` gambit with `author` gambit ([612a57c](https://github.com/flarum/core/commit/612a57c4664415a3ea120103483645c32acc6f12))
- Posts page of on user profile loads posts using username instead of id ([30017ee](https://github.com/flarum/core/commit/30017eef09ae9e78640c4e2cacd4909fffa8d775))
### Fixed
- Transform css breaks iOS scroll functionality (https://github.com/flarum/core/pull/2527)
- Composer header is hidden on mobile devices (https://github.com/flarum/core/pull/2279)
- Cannot delete a post or discussion of a deleted user (https://github.com/flarum/core/pull/2521)
- DiscussionListPane jumps around not keeping the scroll position (https://github.com/flarum/core/pull/2402)
- Infinite scroll on notifications dropdown broken (https://github.com/flarum/core/pull/2524)
- The show language selector switch remains toggled on ([9347b12](https://github.com/flarum/core/commit/9347b12b47bf4ab97ffb7ca92673604b237c1012))
- Model Visibility extender throws exception on extensions that aren't installed or enabled (https://github.com/flarum/core/pull/2580)
- Extensions are marked as enabled when enabling fails to unmet extension dependencies (https://github.com/flarum/core/pull/2558)
- Routes to admin extension pages without a valid ID break the admin page (https://github.com/flarum/core/pull/2584)
- Disabled fieldset use an incorrect CSS property `disallowed` (https://github.com/flarum/core/pull/2585)
- Scrolling to a post that is already loaded the Load More button shows and does not trigger (https://github.com/flarum/core/pull/2388)
- Opening discussions on some mobile devices require a double tap (https://github.com/flarum/core/pull/2607)
- iOS devices show erratic behavior in the post stream while updating (https://github.com/flarum/core/pull/2548)
- Small mobile screens partially hides the composer when the keyboard is open (https://github.com/flarum/core/pull/2631)
- Clearing cache does not clear the template cache in storage/views (https://github.com/flarum/core/pull/2648)
- Boot errors show critical information (https://github.com/flarum/core/pull/2633)
- List user endpoint discloses last online even if user choose against it (https://github.com/flarum/core/pull/2634)
- Group gambit disclosed hidden groups (https://github.com/flarum/core/pull/2657)
- Search results on small windows not fully visible (https://github.com/flarum/core/pull/2650)
- Composer goes off screen on Safari when starting to type (https://github.com/flarum/core/pull/2660)
- A search that has no results shows the search results dropdown ([b88a7cb](https://github.com/flarum/core/commit/b88a7cb33b56e318f11670e9e2d563aef94db039))
- The composer modal moves around when typing on Safari ([a64c398](https://github.com/flarum/core/commit/a64c39835aba43e831209609f4a9638ae589aa41))
### Removed
- Deprecated CSRF wildcard path match
- Deprecated policy and visibility scoping events
- Deprecated post types event
- Deprecated validation events
- Deprecated notification events
- Deprecated floodgate
- Deprecated user preferences event
- Deprecated formatting events
- Deprecated api events
- Deprecated bootstrap.php support
- PHP 7.2 support (https://github.com/flarum/core/pull/2507)
- Bidi attribute in the rendered HTML (https://github.com/flarum/core/pull/2602)
- `AccessToken::find`, use `AccessToken::findValid` instead (https://github.com/flarum/core/pull/2651)
### Deprecated
- `GetModelIsPrivate` event (https://github.com/flarum/core/pull/2587)
- `CheckingPassword` event (https://github.com/flarum/core/pull/2176)
- `event()` helper (https://github.com/flarum/core/pull/2608)
- `AccessToken::generate` argument `$lifetime` (https://github.com/flarum/core/pull/2651)
- `Rememberer::remember` argument `$token` should receive an instance of `RememberAccessToken` with `AccessToken` being deprecated (https://github.com/flarum/core/pull/2651)
- `Rememberer::rememberUser` (https://github.com/flarum/core/pull/2651)
- `SessionAuthenticator::logIn` argument `$userId`, should be replaced with `AccessToken` (https://github.com/flarum/core/pull/2651)
- `TextEditor` has been moved to `common` (https://github.com/flarum/core/pull/2649)
- `UserFilter` ([91e8b56](https://github.com/flarum/core/commit/91e8b569618957c86757ef89bac666e9102db5ae))
## [0.1.0-beta.15](https://github.com/flarum/core/compare/v0.1.0-beta.14.1...v0.1.0-beta.15)
### Added
- Slug drivers support (https://github.com/flarum/core/pull/2456).
- Notification type extender (https://github.com/flarum/core/pull/2424).
- Validation extender (https://github.com/flarum/core/pull/2102).
- Post extender (https://github.com/flarum/core/pull/2101).
- Notification channel extender (https://github.com/flarum/core/pull/2432).
- Service provider extender (https://github.com/flarum/core/pull/2437).
- API serializer extender (https://github.com/flarum/core/pull/2438).
- User preferences extender (https://github.com/flarum/core/pull/2463).
- Settings extender (https://github.com/flarum/core/pull/2452).
- ApiController extender (https://github.com/flarum/core/pull/2451).
- Model visibility extender (https://github.com/flarum/core/pull/2460).
- Policy extender (https://github.com/flarum/core/pull/2461).
### Changed
- Time helpers converted to Typescript (https://github.com/flarum/core/pull/2391).
- Improved the formatter extender (https://github.com/flarum/core/pull/2098).
- Improve wording on installer when facing file permission issues (https://github.com/flarum/core/pull/2435).
- Background color of checkbox toggles improved for better usability (https://github.com/flarum/core/pull/2443).
- Route resolving refactored (https://github.com/flarum/core/pull/2425).
- Administration panel UX refactored (https://github.com/flarum/core/pull/2409).
- Floodgate moved to middleware and extender added (https://github.com/flarum/core/pull/2170).
- DRY up image uploading logic (https://github.com/flarum/core/pull/2477).
- Process isolation on testing (https://github.com/flarum/core/commit/984f751c718c89501cc09857bc271efa2c7eea8c).
- Forum and admin javascript exports namespaced (https://github.com/flarum/core/pull/2488).
### Fixed
- Web updater does not take into account subfolder installations (https://github.com/flarum/core/pull/2426).
- Callables handling in extenders failed (https://github.com/flarum/core/pull/2423).
- Scrolling on mobile from PostSteam changes didn't work correctly (https://github.com/flarum/core/pull/2385).
- Side pane covers part of the discussion page due to `app.discussions` being empty (https://github.com/flarum/core/commit/102e76b084bf47fdfb4c73f95e1fbb322537f7aa).
- Change email modal keeps showing the previous error message even on success (https://github.com/flarum/core/pull/2467).
- Comment count not updated when discussions are deleted (https://github.com/flarum/core/pull/2472).
- `goToIndex` in PostStream does not trigger an xhr to retrieve new data (https://github.com/flarum/core/commit/09e2736cbcc267594b660beabbd001d9030f9880).
- On refresh the post number is reduced by one (https://github.com/flarum/core/pull/2476).
- Queue worker would instantiate a new Queue factory, not the bound one (https://github.com/flarum/core/pull/2481).
- Header accidentally has a border bottom (https://github.com/flarum/core/pull/2489).
- Namespace mentioned in docblock is incorrect (https://github.com/flarum/core/pull/2494).
- Scrolling inside longer discussions (especially Firefox) skips posts (https://github.com/flarum/core/commit/210a6b3e253d7917bd1eacd3ed8d2f95073ae99d).
- Uploading avatars that are jpg/jpeg fails with a validation error (https://github.com/flarum/core/pull/2497).
### Removed
- MomentJS alias (https://github.com/flarum/core/pull/2428).
- Deprecated user events `GetDisplayName` and `PrepareUserGroups` (https://github.com/flarum/core/pull/2428).
- AssertPermissionTrait (https://github.com/flarum/core/pull/2428).
- Path related helpers and methods in Application (https://github.com/flarum/core/pull/2428).
- Backward compatibility layers from the frontend rewrite (https://github.com/flarum/core/pull/2428).
### Deprecated
- `CheckingForFlooding` (https://github.com/flarum/core/commit/8e25bcb68f86cc992c46dfa70368419fe9f936ac).
## [0.1.0-beta.14.1](https://github.com/flarum/core/compare/v0.1.0-beta.14...v0.1.0-beta.14.1)
### Fixed
- SuperTextarea component is not exported.
- Symfony dependencies do not match those depended on by Laravel (https://github.com/flarum/core/pull/2407).
- Scripts from textformatter aren't executed (https://github.com/flarum/core/pull/2415)
- Sub path installations have no page title.
- Losing focus of Composer area when coming from fullscreen.
## [0.1.0-beta.14](https://github.com/flarum/core/compare/v0.1.0-beta.13...v0.1.0-beta.14)
### Added
- Check dependencies before enabling / disabling extensions (https://github.com/flarum/core/pull/2188)
- Set up temporary infrastructure for TypeScript in core (https://github.com/flarum/core/pull/2206)
- Better UI for request error modals (https://github.com/flarum/core/pull/1929)
- Display name extender, tests, frontend UI (https://github.com/flarum/core/pull/2174)
- Scroll to post or show alert when editing a post from another page (https://github.com/flarum/core/pull/2108)
- Feature to test email config by sending an email to the current user (https://github.com/flarum/core/pull/2023)
- Allow searching users by group ID using the group gambit (https://github.com/flarum/core/pull/2192)
- Use `liveHumanTimes` helper to update times without reload/rerender (https://github.com/flarum/core/pull/2208)
- View extender, tests (https://github.com/flarum/core/pull/2134)
- User extender to replace `PrepareUserGroups` (https://github.com/flarum/core/pull/2110)
- Increase extensibility of skeleton PHP (https://github.com/flarum/core/pull/2308, https://github.com/flarum/core/pull/2318)
- Pass a translator instance to `getEmailSubject` in `MailableInterface` (https://github.com/flarum/core/pull/2244)
- Force LF line endings on windows (https://github.com/flarum/core/pull/2321)
- Add a `Link` component for internal and external links (https://github.com/flarum/core/pull/2315)
- `ConfirmDocumentUnload` component
- Error handler middleware can now be manipulated by the middleware extender
### Changed
- Update to Mithril 2 (https://github.com/flarum/core/pull/2255)
- Stop storing component instances (https://github.com/flarum/core/issues/1821, https://github.com/flarum/core/issues/2144)
- Update to Laravel 6.x (https://github.com/flarum/core/issues/2055)
- `Flarum\Foundation\Application` no longer implements `Illuminate\Contracts\Foundation\Application` (#2142)
- `Flarum\Foundation\Application` no longer inherits `Illuminate\Container\Container` (#2142)
- `paths` have been split off from `Flarum\Foundation\Application` into `Flarum\Foundation\Paths`, which can be injected where needed (#2142)
- `Flarum\User\Gate` no longer implements `Illuminate\Contracts\Auth\Access\Gate` (https://github.com/flarum/core/pull/2181)
- Improve Group Gambit performance (https://github.com/flarum/core/pull/2192)
- Switch to `dayjs` from `momentjs` (https://github.com/flarum/core/pull/2219)
- Don't create a `bio` column in `users` for new installations (https://github.com/flarum/core/pull/2215)
- Start converting core JS to TypeScript (https://github.com/flarum/core/pull/2207)
- Make Carbon an explicit dependency (https://github.com/flarum/core/commit/3b39c212e0fef7522e7d541a9214ff3817138d5d)
- Use Symfony's translator interface instead of Laravel's (https://github.com/flarum/core/pull/2243)
- Use newer versions of fontawesome (https://github.com/flarum/core/pull/2274)
- Use URL generator instead of `app()->url()` where possible (https://github.com/flarum/core/pull/2302)
- Move config from `config.php` into an injectable helper class (https://github.com/flarum/core/pull/2271)
- Use reserved TLD for bogus and test urls (https://github.com/flarum/core/commit/6860b24b70bd04544dde90e537ce021a5fc5a689)
- Replace `m.stream` with `flarum/utils/Stream` (https://github.com/flarum/core/pull/2316)
- Replace `affixedSidebar` util with `AffixedSidebar` component
- Replace `m.withAttr` with `flarum/utils/withAttr`
- Scroll Listener is now passive, performance improvement (https://github.com/flarum/core/pull/2387)
### Fixed
- `generate:migration` command for extensions (https://github.com/flarum/core/commit/443949f7b9d7558dbc1e0994cb898cbac59bec87)
- Container config for `UninstalledSite` (https://github.com/flarum/core/commit/ecdce44d555dd36a365fd472b2916e677ef173cf)
- Tooltip glitch on page chang (https://github.com/flarum/core/issues/2118)
- Using multiple extenders in tests (https://github.com/flarum/core/commit/c4f4f218bf4b175a30880b807f9ccb1a37a25330)
- Header glitch when opening modals (https://github.com/flarum/core/pull/2131)
- Ensure `SameSite` is explicitly set for cookies (https://github.com/flarum/core/pull/2159)
- Ensure `Flarum\User\Event\AvatarChanged` event is properly dispatched (https://github.com/flarum/core/pull/2197)
- Show correct error message on wrong password when changing email (https://github.com/flarum/core/pull/2171)
- Discussion unreadCount could be higher than commentCount if posts deleted (https://github.com/flarum/core/pull/2195)
- Don't show page title on the default route (https://github.com/flarum/core/pull/2047)
- Add page title to `All Discussions` page when it isn't the default route (https://github.com/flarum/core/pull/2047)
- Accept `'0'` as `false` for `flarum/components/Checkbox` (https://github.com/flarum/core/pull/2210)
- Fix PostStreamScrubber background (https://github.com/flarum/core/pull/2222)
- Test port on BaseUrl tests (https://github.com/flarum/core/pull/2226)
- `UrlGenerator` can now generate urls with optional parameters (https://github.com/flarum/core/pull/2246)
- Allow `less` to be compiled independently of Flarum (https://github.com/flarum/core/pull/2252)
- Use correct number abbreviation (https://github.com/flarum/core/pull/2261)
- Ensure avatar html uses alt tags for accessibility (https://github.com/flarum/core/pull/2269)
- Escape regex when searching (https://github.com/flarum/core/pull/2273)
- Remove unneeded semicolons inserted during JS compilation (https://github.com/flarum/core/pull/2280)
- Don't require a username/password for SMTP (https://github.com/flarum/core/pull/2287)
- Allow uppercase entries for SMTP encryption validation (https://github.com/flarum/core/pull/2289)
- Ensure that the right number of posts is returned from list posts API (https://github.com/flarum/core/pull/2291)
- Fix a variety of PostStream bugs (https://github.com/flarum/core/pull/2160, https://github.com/flarum/core/pull/2160)
- Sliding discussion glitch on mobile (https://github.com/flarum/core/pull/2324)
- Sliding discussion button in wrong place (https://github.com/flarum/core/pull/2330, https://github.com/flarum/core/pull/2383)
- Sliding discussion glitch on mobile (https://github.com/flarum/core/pull/2381)
- Fix PostStream for posts with top margins, and scrubber position when scrolling below posts (https://github.com/flarum/core/pull/2369)
### Removed
- `Flarum\Event\AbstractConfigureRoutes` event class
- `Flarum\Event\ConfigureApiRoutes` event class
- `Flarum\Event\ConfigureForumRoutes` event class
- `Flarum\Console\Event\Configuring` event class
- `Flarum\Event\ConfigureModelDates` event class
- `Flarum\Event\ConfigureLocales` event class
- `Flarum\Event\ConfigureModelDefaultAttributes` event class
- `Flarum\Event\GetModelRelationship` event class
- `Flarum\User\Event\BioChanged` event class
- `Flarum\Database\MigrationServiceProvider` moved into `Flarum\Database\DatabaseServiceProvider`
- Unused `admin/components/Widget` component (`admin/component/DashboardWidget` should be used instead)
- Mandrill mail driver (https://github.com/flarum/core/commit/bca833d3f1c34d45d95bf905902368a2753b8908)
### Deprecated
- `Flarum\User\Event\GetDisplayName` event class
- Global path helpers, `Flarum\Foundation\Application` path methods (https://github.com/flarum/core/pull/2155)
- `Flarum\User\AssertPermissionTrait` (https://github.com/flarum/core/pull/2044)
## [0.1.0-beta.13](https://github.com/flarum/core/compare/v0.1.0-beta.12...v0.1.0-beta.13)
### Added
@@ -45,7 +301,7 @@
- SES mail support (#2011)
- Backward compatibility layer for `Flarum\Mail\DriverInterface`, new methods from beta.12 are now required
- `Flarum\Util\Str` helper class
- `Flarum\Event\ConfigureMiddleware` event
- `Flarum\Event\ConfigureMiddleware` event
### Deprecated
- `Flarum\Event\AbstractConfigureRoutes` event class

View File

@@ -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).

View File

@@ -1,32 +1,17 @@
{
"name": "flarum/core",
"description": "Delightfully simple forum software.",
"keywords": ["forum", "discussion"],
"keywords": [
"forum",
"discussion"
],
"homepage": "https://flarum.org/",
"license": "MIT",
"authors": [
{
"name": "Franz Liedke",
"email": "franz@develophp.org"
},
{
"name": "Daniel Klabbers",
"email": "daniel@klabbers.email",
"homepage": "https://luceos.com"
},
{
"name": "David Sevilla Martin",
"email": "me+flarum@datitisev.me",
"homepage": "https://datitisev.me"
},
{
"name": "Clark Winkelmann",
"email": "clark.winkelmann@gmail.com",
"homepage": "https://clarkwinkelmann.com"
},
{
"name": "Matthew Kilgore",
"email": "matthew@kilgore.dev"
"name": "Flarum",
"email": "info@flarum.org",
"homepage": "https://flarum.org/team"
}
],
"support": {
@@ -35,60 +20,62 @@
"docs": "https://flarum.org/docs/"
},
"require": {
"php": ">=7.2",
"php": ">=7.3",
"axy/sourcemap": "^0.1.4",
"components/font-awesome": "5.9.*",
"dflydev/fig-cookies": "^2.0.1",
"components/font-awesome": "^5.14.0",
"dflydev/fig-cookies": "^3.0.0",
"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.*",
"franzl/whoops-middleware": "^2.0.0",
"illuminate/bus": "^8.0",
"illuminate/cache": "^8.0",
"illuminate/config": "^8.0",
"illuminate/container": "^8.0",
"illuminate/contracts": "^8.0",
"illuminate/database": "^8.0",
"illuminate/events": "^8.0",
"illuminate/filesystem": "^8.0",
"illuminate/hashing": "^8.0",
"illuminate/mail": "^8.0",
"illuminate/queue": "^8.0",
"illuminate/session": "^8.0",
"illuminate/support": "^8.0",
"illuminate/validation": "^8.0",
"illuminate/view": "^8.0",
"intervention/image": "^2.5.0",
"laminas/laminas-diactoros": "^1.8.4",
"laminas/laminas-httphandlerrunner": "^1.0",
"laminas/laminas-stratigility": "^3.0",
"laminas/laminas-diactoros": "^2.4.1",
"laminas/laminas-httphandlerrunner": "^1.2.0",
"laminas/laminas-stratigility": "^3.2.2",
"league/flysystem": "^1.0.11",
"matthiasmullie/minify": "^1.3",
"middlewares/base-path": "^1.1",
"middlewares/base-path-router": "^0.2.1",
"middlewares/request-handler": "^1.2",
"middlewares/base-path": "^2.0.1",
"middlewares/base-path-router": "^2.0.1",
"middlewares/request-handler": "^2.0.1",
"monolog/monolog": "^1.16.0",
"nesbot/carbon": "^2.0",
"nikic/fast-route": "^0.6",
"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": "^5.2.2",
"symfony/console": "^5.2.2",
"symfony/event-dispatcher": "^5.2.2",
"symfony/mime": "^5.2.0",
"symfony/translation": "^5.1.5",
"symfony/yaml": "^5.2.2",
"tobscure/json-api": "^0.3.0",
"wikimedia/less.php": "^3.0"
},
"require-dev": {
"mockery/mockery": "^1.0",
"phpunit/phpunit": "^7.0"
"flarum/testing": "^0.1.0-beta.16"
},
"autoload": {
"psr-4": {
"Flarum\\": "src/"
},
"files": [
"src/helpers.php"
"src/helpers.php",
"src/TranslatorInterface.php"
]
},
"autoload-dev": {

14
js/dist/admin.js vendored

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

16
js/dist/forum.js vendored

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

9363
js/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -2,19 +2,21 @@
"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",
"textarea-caret": "^3.1.0",
"webpack": "^4.43.0",
"webpack-cli": "^3.3.11",
"webpack-merge": "^4.1.4"

32
js/shims.d.ts vendored Normal file
View 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;
}

View File

@@ -4,9 +4,16 @@ import routes from './routes';
import Application from '../common/Application';
import Navigation from '../common/components/Navigation';
import AdminNav from './components/AdminNav';
import ExtensionData from './utils/ExtensionData';
export default class AdminApplication extends Application {
extensionSettings = {};
extensionData = new ExtensionData();
extensionCategories = {
feature: 30,
theme: 20,
language: 10,
};
history = {
canGoBack: () => true,
@@ -27,22 +34,24 @@ 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();
// If an extension has just been enabled, then we will run its settings
// callback.
const enabled = localStorage.getItem('enabledExtension');
if (enabled && this.extensionSettings[enabled]) {
this.extensionSettings[enabled]();
localStorage.removeItem('enabledExtension');
}
m.mount(document.getElementById('app-navigation'), {
view: () =>
Navigation.component({
className: 'App-backControl',
drawer: true,
}),
});
m.mount(document.getElementById('header-navigation'), Navigation);
m.mount(document.getElementById('header-primary'), HeaderPrimary);
m.mount(document.getElementById('header-secondary'), HeaderSecondary);
m.mount(document.getElementById('admin-navigation'), AdminNav);
}
getRequiredPermissions(permission) {

View File

@@ -1,20 +1,24 @@
import compat from '../common/compat';
import saveSettings from './utils/saveSettings';
import ExtensionData from './utils/ExtensionData';
import isExtensionEnabled from './utils/isExtensionEnabled';
import getCategorizedExtensions from './utils/getCategorizedExtensions';
import SettingDropdown from './components/SettingDropdown';
import EditCustomFooterModal from './components/EditCustomFooterModal';
import SessionDropdown from './components/SessionDropdown';
import HeaderPrimary from './components/HeaderPrimary';
import AdminPage from './components/AdminPage';
import AppearancePage from './components/AppearancePage';
import Page from './components/Page';
import StatusWidget from './components/StatusWidget';
import ExtensionsWidget from './components/ExtensionsWidget';
import HeaderSecondary from './components/HeaderSecondary';
import SettingsModal from './components/SettingsModal';
import DashboardWidget from './components/DashboardWidget';
import AddExtensionModal from './components/AddExtensionModal';
import ExtensionsPage from './components/ExtensionsPage';
import AdminLinkButton from './components/AdminLinkButton';
import ExtensionPage from './components/ExtensionPage';
import ExtensionLinkButton from './components/ExtensionLinkButton';
import PermissionGrid from './components/PermissionGrid';
import ExtensionPermissionGrid from './components/ExtensionPermissionGrid';
import MailPage from './components/MailPage';
import UploadImageButton from './components/UploadImageButton';
import LoadingModal from './components/LoadingModal';
@@ -24,6 +28,7 @@ import EditCustomHeaderModal from './components/EditCustomHeaderModal';
import PermissionsPage from './components/PermissionsPage';
import PermissionDropdown from './components/PermissionDropdown';
import AdminNav from './components/AdminNav';
import AdminHeader from './components/AdminHeader';
import EditCustomCssModal from './components/EditCustomCssModal';
import EditGroupModal from './components/EditGroupModal';
import routes from './routes';
@@ -31,20 +36,24 @@ import AdminApplication from './AdminApplication';
export default Object.assign(compat, {
'utils/saveSettings': saveSettings,
'utils/ExtensionData': ExtensionData,
'utils/isExtensionEnabled': isExtensionEnabled,
'utils/getCategorizedExtensions': getCategorizedExtensions,
'components/SettingDropdown': SettingDropdown,
'components/EditCustomFooterModal': EditCustomFooterModal,
'components/SessionDropdown': SessionDropdown,
'components/HeaderPrimary': HeaderPrimary,
'components/AdminPage': AdminPage,
'components/AppearancePage': AppearancePage,
'components/Page': Page,
'components/StatusWidget': StatusWidget,
'components/ExtensionsWidget': ExtensionsWidget,
'components/HeaderSecondary': HeaderSecondary,
'components/SettingsModal': SettingsModal,
'components/DashboardWidget': DashboardWidget,
'components/AddExtensionModal': AddExtensionModal,
'components/ExtensionsPage': ExtensionsPage,
'components/AdminLinkButton': AdminLinkButton,
'components/ExtensionPage': ExtensionPage,
'components/ExtensionLinkButton': ExtensionLinkButton,
'components/PermissionGrid': PermissionGrid,
'components/ExtensionPermissionGrid': ExtensionPermissionGrid,
'components/MailPage': MailPage,
'components/UploadImageButton': UploadImageButton,
'components/LoadingModal': LoadingModal,
@@ -54,6 +63,7 @@ export default Object.assign(compat, {
'components/PermissionsPage': PermissionsPage,
'components/PermissionDropdown': PermissionDropdown,
'components/AdminNav': AdminNav,
'components/AdminHeader': AdminHeader,
'components/EditCustomCssModal': EditCustomCssModal,
'components/EditGroupModal': EditGroupModal,
routes: routes,

View File

@@ -1,32 +0,0 @@
/*
* This file is part of Flarum.
*
* (c) Toby Zerner <toby.zerner@gmail.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
import Modal from '../../common/components/Modal';
export default class AddExtensionModal extends Modal {
className() {
return 'AddExtensionModal Modal--small';
}
title() {
return app.translator.trans('core.admin.add_extension.title');
}
content() {
return (
<div className="Modal-body">
<p>{app.translator.trans('core.admin.add_extension.temporary_text')}</p>
<p>
{app.translator.trans('core.admin.add_extension.install_text', { a: <a href="https://discuss.flarum.org/t/extensions" target="_blank" /> })}
</p>
<p>{app.translator.trans('core.admin.add_extension.developer_text', { a: <a href="http://flarum.org/docs/extend" target="_blank" /> })}</p>
</div>
);
}
}

View 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>,
];
}
}

View File

@@ -1,20 +0,0 @@
/*
* This file is part of Flarum.
*
* (c) Toby Zerner <toby.zerner@gmail.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
import LinkButton from '../../common/components/LinkButton';
export default class AdminLinkButton extends LinkButton {
getButtonContent() {
const content = super.getButtonContent();
content.push(<div className="AdminLinkButton-description">{this.props.description}</div>);
return content;
}
}

View File

@@ -1,94 +1,150 @@
/*
* This file is part of Flarum.
*
* (c) Toby Zerner <toby.zerner@gmail.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
import ExtensionLinkButton from './ExtensionLinkButton';
import Component from '../../common/Component';
import AdminLinkButton from './AdminLinkButton';
import LinkButton from '../../common/components/LinkButton';
import SelectDropdown from '../../common/components/SelectDropdown';
import getCategorizedExtensions from '../utils/getCategorizedExtensions';
import ItemList from '../../common/utils/ItemList';
import Stream from '../../common/utils/Stream';
export default class AdminNav extends Component {
oninit(vnode) {
super.oninit(vnode);
this.query = Stream('');
}
view() {
return (
<SelectDropdown className="AdminNav App-titleControl" buttonClassName="Button">
{this.items().toArray()}
<SelectDropdown className="AdminNav App-titleControl AdminNav-Main" buttonClassName="Button">
{this.items().toArray().concat(this.extensionItems().toArray())}
</SelectDropdown>
);
}
oncreate(vnode) {
super.oncreate(vnode);
this.scrollToActive();
}
onupdate() {
this.scrollToActive();
}
scrollToActive() {
const children = $('.Dropdown-menu').children('.active');
const nav = $('#admin-navigation');
const time = app.previous.type ? 250 : 0;
if (
children.length > 0 &&
(children[0].offsetTop > nav.scrollTop() + nav.outerHeight() || children[0].offsetTop + children[0].offsetHeight < nav.scrollTop())
) {
nav.animate(
{
scrollTop: children[0].offsetTop - nav.height() / 2,
},
time
);
}
}
/**
* Build an item list of links to show in the admin navigation.
* Build an item list of main links to show in the admin navigation.
*
* @return {ItemList}
*/
items() {
const items = new ItemList();
items.add('category-core', <h4 className="ExtensionListTitle">{app.translator.trans('core.admin.nav.categories.core')}</h4>);
items.add(
'dashboard',
AdminLinkButton.component({
href: app.route('dashboard'),
icon: 'far fa-chart-bar',
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-${category}`,
<h4 className="ExtensionListTitle">{app.translator.trans(`core.admin.nav.categories.${category}`)}</h4>,
categories[category]
);
}
categorizedExtensions[category].map((extension) => {
const query = this.query().toUpperCase();
const title = extension.extra['flarum-extension'].title;
if (!query || title.toUpperCase().includes(query) || extension.description.toUpperCase().includes(query)) {
items.add(
`extension-${extension.id}`,
<ExtensionLinkButton
href={app.route('extension', { id: extension.id })}
extensionId={extension.id}
className="ExtensionNavButton"
title={extension.description}
>
{title}
</ExtensionLinkButton>,
categories[category]
);
}
});
});
return items;
}
}

View File

@@ -0,0 +1,174 @@
import Page from '../../common/components/Page';
import Button from '../../common/components/Button';
import Switch from '../../common/components/Switch';
import Select from '../../common/components/Select';
import classList from '../../common/utils/classList';
import Stream from '../../common/utils/Stream';
import saveSettings from '../utils/saveSettings';
import AdminHeader from './AdminHeader';
export default class AdminPage extends Page {
oninit(vnode) {
super.oninit(vnode);
this.settings = {};
this.loading = false;
}
view() {
const className = classList(['AdminPage', this.headerInfo().className]);
return (
<div className={className}>
{this.header()}
<div className="container">{this.content()}</div>
</div>
);
}
content() {
return '';
}
submitButton() {
return (
<Button onclick={this.saveSettings.bind(this)} className="Button Button--primary" loading={this.loading} disabled={!this.isChanged()}>
{app.translator.trans('core.admin.settings.submit_button')}
</Button>
);
}
header() {
const headerInfo = this.headerInfo();
return (
<AdminHeader icon={headerInfo.icon} description={headerInfo.description} className={headerInfo.className + '-header'}>
{headerInfo.title}
</AdminHeader>
);
}
headerInfo() {
return {
className: '',
icon: '',
title: '',
description: '',
};
}
/**
* buildSettingComponent takes a settings object and turns it into a component.
* Depending on the type of input, you can set the type to 'bool', 'select', or
* any standard <input> type. Any values inside the 'extra' object will be added
* to the component as an attribute.
*
* Alternatively, you can pass a callback that will be executed in ExtensionPage's
* context to include custom JSX elements.
*
* @example
*
* {
* setting: 'acme.checkbox',
* label: app.translator.trans('acme.admin.setting_label'),
* type: 'bool',
* help: app.translator.trans('acme.admin.setting_help'),
* className: 'Setting-item'
* }
*
* @example
*
* {
* setting: 'acme.select',
* label: app.translator.trans('acme.admin.setting_label'),
* type: 'select',
* options: {
* 'option1': 'Option 1 label',
* 'option2': 'Option 2 label',
* },
* default: 'option1',
* }
*
* @param setting
* @returns {JSX.Element}
*/
buildSettingComponent(entry) {
if (typeof entry === 'function') {
return entry.call(this);
}
const setting = entry.setting;
const help = entry.help;
delete entry.help;
const value = this.setting([setting])();
if (['bool', 'checkbox', 'switch', 'boolean'].includes(entry.type)) {
return (
<div className="Form-group">
<Switch state={!!value && value !== '0'} onchange={this.settings[setting]} {...entry}>
{entry.label}
</Switch>
<div className="helpText">{help}</div>
</div>
);
} else if (['select', 'dropdown', 'selectdropdown'].includes(entry.type)) {
return (
<div className="Form-group">
<label>{entry.label}</label>
<div className="helpText">{help}</div>
<Select value={value || entry.default} options={entry.options} buttonClassName="Button" onchange={this.settings[setting]} {...entry} />
</div>
);
} else {
entry.className = classList(['FormControl', entry.className]);
return (
<div className="Form-group">
{entry.label ? <label>{entry.label}</label> : ''}
<div className="helpText">{help}</div>
<input type={entry.type} bidi={this.setting(setting)} {...entry} />
</div>
);
}
}
onsaved() {
this.loading = false;
app.alerts.show({ type: 'success' }, app.translator.trans('core.admin.settings.saved_message'));
}
setting(key, fallback = '') {
this.settings[key] = this.settings[key] || Stream(app.data.settings[key] || fallback);
return this.settings[key];
}
dirty() {
const dirty = {};
Object.keys(this.settings).forEach((key) => {
const value = this.settings[key]();
if (value !== app.data.settings[key]) {
dirty[key] = value;
}
});
return dirty;
}
isChanged() {
return Object.keys(this.dirty()).length;
}
saveSettings(e) {
e.preventDefault();
app.alerts.clear();
this.loading = true;
return saveSettings(this.dirty()).then(this.onsaved.bind(this));
}
}

View File

@@ -1,132 +1,120 @@
import Page from './Page';
import Button from '../../common/components/Button';
import Switch from '../../common/components/Switch';
import EditCustomCssModal from './EditCustomCssModal';
import EditCustomHeaderModal from './EditCustomHeaderModal';
import EditCustomFooterModal from './EditCustomFooterModal';
import UploadImageButton from './UploadImageButton';
import saveSettings from '../utils/saveSettings';
import AdminPage from './AdminPage';
export default class AppearancePage extends Page {
init() {
super.init();
this.primaryColor = m.prop(app.data.settings.theme_primary_color);
this.secondaryColor = m.prop(app.data.settings.theme_secondary_color);
this.darkMode = m.prop(app.data.settings.theme_dark_mode === '1');
this.coloredHeader = m.prop(app.data.settings.theme_colored_header === '1');
export default class AppearancePage extends AdminPage {
headerInfo() {
return {
className: 'AppearancePage',
icon: 'fas fa-paint-brush',
title: app.translator.trans('core.admin.appearance.title'),
description: app.translator.trans('core.admin.appearance.description'),
};
}
view() {
return (
<div className="AppearancePage">
<div className="container">
<form onsubmit={this.onsubmit.bind(this)}>
<fieldset className="AppearancePage-colors">
<legend>{app.translator.trans('core.admin.appearance.colors_heading')}</legend>
<div className="helpText">{app.translator.trans('core.admin.appearance.colors_text')}</div>
content() {
return [
<div className="Form">
<fieldset className="AppearancePage-colors">
<legend>{app.translator.trans('core.admin.appearance.colors_heading')}</legend>
<div className="helpText">{app.translator.trans('core.admin.appearance.colors_text')}</div>
<div className="AppearancePage-colors-input">
<input
className="FormControl"
type="text"
placeholder="#aaaaaa"
value={this.primaryColor()}
onchange={m.withAttr('value', this.primaryColor)}
/>
<input
className="FormControl"
type="text"
placeholder="#aaaaaa"
value={this.secondaryColor()}
onchange={m.withAttr('value', this.secondaryColor)}
/>
</div>
{Switch.component({
state: this.darkMode(),
children: app.translator.trans('core.admin.appearance.dark_mode_label'),
onchange: this.darkMode,
})}
{Switch.component({
state: this.coloredHeader(),
children: app.translator.trans('core.admin.appearance.colored_header_label'),
onchange: this.coloredHeader,
})}
{Button.component({
className: 'Button Button--primary',
type: 'submit',
children: app.translator.trans('core.admin.appearance.submit_button'),
loading: this.loading,
})}
</fieldset>
</form>
<fieldset>
<legend>{app.translator.trans('core.admin.appearance.logo_heading')}</legend>
<div className="helpText">{app.translator.trans('core.admin.appearance.logo_text')}</div>
<UploadImageButton name="logo" />
</fieldset>
<fieldset>
<legend>{app.translator.trans('core.admin.appearance.favicon_heading')}</legend>
<div className="helpText">{app.translator.trans('core.admin.appearance.favicon_text')}</div>
<UploadImageButton name="favicon" />
</fieldset>
<fieldset>
<legend>{app.translator.trans('core.admin.appearance.custom_header_heading')}</legend>
<div className="helpText">{app.translator.trans('core.admin.appearance.custom_header_text')}</div>
{Button.component({
className: 'Button',
children: app.translator.trans('core.admin.appearance.edit_header_button'),
onclick: () => app.modal.show(new EditCustomHeaderModal()),
<div className="AppearancePage-colors-input">
{this.buildSettingComponent({
type: 'text',
setting: 'theme_primary_color',
placeholder: '#aaaaaa',
})}
</fieldset>
<fieldset>
<legend>{app.translator.trans('core.admin.appearance.custom_footer_heading')}</legend>
<div className="helpText">{app.translator.trans('core.admin.appearance.custom_footer_text')}</div>
{Button.component({
className: 'Button',
children: app.translator.trans('core.admin.appearance.edit_footer_button'),
onclick: () => app.modal.show(new EditCustomFooterModal()),
{this.buildSettingComponent({
type: 'text',
setting: 'theme_secondary_color',
placeholder: '#aaaaaa',
})}
</fieldset>
</div>
<fieldset>
<legend>{app.translator.trans('core.admin.appearance.custom_styles_heading')}</legend>
<div className="helpText">{app.translator.trans('core.admin.appearance.custom_styles_text')}</div>
{Button.component({
className: 'Button',
children: app.translator.trans('core.admin.appearance.edit_css_button'),
onclick: () => app.modal.show(new EditCustomCssModal()),
})}
</fieldset>
</div>
</div>
);
{this.buildSettingComponent({
type: 'switch',
setting: 'theme_dark_mode',
label: app.translator.trans('core.admin.appearance.dark_mode_label'),
})}
{this.buildSettingComponent({
type: 'switch',
setting: 'theme_colored_header',
label: app.translator.trans('core.admin.appearance.colored_header_label'),
})}
{this.submitButton()}
</fieldset>
</div>,
<fieldset>
<legend>{app.translator.trans('core.admin.appearance.logo_heading')}</legend>
<div className="helpText">{app.translator.trans('core.admin.appearance.logo_text')}</div>
<UploadImageButton name="logo" />
</fieldset>,
<fieldset>
<legend>{app.translator.trans('core.admin.appearance.favicon_heading')}</legend>
<div className="helpText">{app.translator.trans('core.admin.appearance.favicon_text')}</div>
<UploadImageButton name="favicon" />
</fieldset>,
<fieldset>
<legend>{app.translator.trans('core.admin.appearance.custom_header_heading')}</legend>
<div className="helpText">{app.translator.trans('core.admin.appearance.custom_header_text')}</div>
{Button.component(
{
className: 'Button',
onclick: () => app.modal.show(EditCustomHeaderModal),
},
app.translator.trans('core.admin.appearance.edit_header_button')
)}
</fieldset>,
<fieldset>
<legend>{app.translator.trans('core.admin.appearance.custom_footer_heading')}</legend>
<div className="helpText">{app.translator.trans('core.admin.appearance.custom_footer_text')}</div>
{Button.component(
{
className: 'Button',
onclick: () => app.modal.show(EditCustomFooterModal),
},
app.translator.trans('core.admin.appearance.edit_footer_button')
)}
</fieldset>,
<fieldset>
<legend>{app.translator.trans('core.admin.appearance.custom_styles_heading')}</legend>
<div className="helpText">{app.translator.trans('core.admin.appearance.custom_styles_text')}</div>
{Button.component(
{
className: 'Button',
onclick: () => app.modal.show(EditCustomCssModal),
},
app.translator.trans('core.admin.appearance.edit_css_button')
)}
</fieldset>,
];
}
onsubmit(e) {
onsaved() {
window.location.reload();
}
saveSettings(e) {
e.preventDefault();
const hex = /^#[0-9a-f]{3}([0-9a-f]{3})?$/i;
if (!hex.test(this.primaryColor()) || !hex.test(this.secondaryColor())) {
if (!hex.test(this.settings['theme_primary_color']()) || !hex.test(this.settings['theme_secondary_color']())) {
alert(app.translator.trans('core.admin.appearance.enter_hex_message'));
return;
}
this.loading = true;
saveSettings({
theme_primary_color: this.primaryColor(),
theme_secondary_color: this.secondaryColor(),
theme_dark_mode: this.darkMode(),
theme_colored_header: this.coloredHeader(),
}).then(() => window.location.reload());
super.saveSettings(e);
}
}

View File

@@ -1,32 +1,10 @@
import Page from './Page';
import FieldSet from '../../common/components/FieldSet';
import Select from '../../common/components/Select';
import Button from '../../common/components/Button';
import Alert from '../../common/components/Alert';
import saveSettings from '../utils/saveSettings';
import ItemList from '../../common/utils/ItemList';
import Switch from '../../common/components/Switch';
import AdminPage from './AdminPage';
export default class BasicsPage extends Page {
init() {
super.init();
this.loading = false;
this.fields = [
'forum_title',
'forum_description',
'default_locale',
'show_language_selector',
'default_route',
'welcome_title',
'welcome_message',
'display_name_driver',
];
this.values = {};
const settings = app.data.settings;
this.fields.forEach((key) => (this.values[key] = m.prop(settings[key])));
export default class BasicsPage extends AdminPage {
oninit(vnode) {
super.oninit(vnode);
this.localeOptions = {};
const locales = app.data.locales;
@@ -40,118 +18,101 @@ export default class BasicsPage extends Page {
this.displayNameOptions[identifier] = identifier;
}, this);
if (!this.values.display_name_driver() && displayNameDrivers.includes('username')) this.values.display_name_driver('username');
this.slugDriverOptions = {};
Object.keys(app.data.slugDrivers).forEach((model) => {
this.slugDriverOptions[model] = {};
if (typeof this.values.show_language_selector() !== 'number') this.values.show_language_selector(1);
app.data.slugDrivers[model].forEach((option) => {
this.slugDriverOptions[model][option] = option;
});
});
}
view() {
return (
<div className="BasicsPage">
<div className="container">
<form onsubmit={this.onsubmit.bind(this)}>
{FieldSet.component({
label: app.translator.trans('core.admin.basics.forum_title_heading'),
children: [<input className="FormControl" value={this.values.forum_title()} oninput={m.withAttr('value', this.values.forum_title)} />],
})}
headerInfo() {
return {
className: 'BasicsPage',
icon: 'fas fa-pencil-alt',
title: app.translator.trans('core.admin.basics.title'),
description: app.translator.trans('core.admin.basics.description'),
};
}
{FieldSet.component({
label: app.translator.trans('core.admin.basics.forum_description_heading'),
children: [
<div className="helpText">{app.translator.trans('core.admin.basics.forum_description_text')}</div>,
<textarea
className="FormControl"
value={this.values.forum_description()}
oninput={m.withAttr('value', this.values.forum_description)}
/>,
],
})}
content() {
return [
<div className="Form">
{this.buildSettingComponent({
type: 'text',
setting: 'forum_title',
label: app.translator.trans('core.admin.basics.forum_title_heading'),
})}
{this.buildSettingComponent({
type: 'text',
setting: 'forum_description',
label: app.translator.trans('core.admin.basics.forum_description_heading'),
help: app.translator.trans('core.admin.basics.forum_description_text'),
})}
{Object.keys(this.localeOptions).length > 1
? FieldSet.component({
label: app.translator.trans('core.admin.basics.default_language_heading'),
children: [
Select.component({
options: this.localeOptions,
value: this.values.default_locale(),
onchange: this.values.default_locale,
}),
Switch.component({
state: this.values.show_language_selector(),
onchange: this.values.show_language_selector,
children: app.translator.trans('core.admin.basics.show_language_selector_label'),
}),
],
})
: ''}
{Object.keys(this.localeOptions).length > 1
? [
this.buildSettingComponent({
type: 'select',
setting: 'default_locale',
options: this.localeOptions,
label: app.translator.trans('core.admin.basics.default_language_heading'),
}),
this.buildSettingComponent({
type: 'switch',
setting: 'show_language_selector',
label: app.translator.trans('core.admin.basics.show_language_selector_label'),
}),
]
: ''}
{FieldSet.component({
label: app.translator.trans('core.admin.basics.home_page_heading'),
className: 'BasicsPage-homePage',
children: [
<div className="helpText">{app.translator.trans('core.admin.basics.home_page_text')}</div>,
this.homePageItems()
.toArray()
.map(({ path, label }) => (
<label className="checkbox">
<input
type="radio"
name="homePage"
value={path}
checked={this.values.default_route() === path}
onclick={m.withAttr('value', this.values.default_route)}
/>
{label}
</label>
)),
],
})}
<FieldSet className="BasicsPage-homePage Form-group" label={app.translator.trans('core.admin.basics.home_page_heading')}>
<div className="helpText">{app.translator.trans('core.admin.basics.home_page_text')}</div>
{this.homePageItems()
.toArray()
.map(({ path, label }) => (
<label className="checkbox">
<input type="radio" name="homePage" value={path} bidi={this.setting('default_route')} />
{label}
</label>
))}
</FieldSet>
{FieldSet.component({
label: app.translator.trans('core.admin.basics.welcome_banner_heading'),
className: 'BasicsPage-welcomeBanner',
children: [
<div className="helpText">{app.translator.trans('core.admin.basics.welcome_banner_text')}</div>,
<div className="BasicsPage-welcomeBanner-input">
<input className="FormControl" value={this.values.welcome_title()} oninput={m.withAttr('value', this.values.welcome_title)} />
<textarea
className="FormControl"
value={this.values.welcome_message()}
oninput={m.withAttr('value', this.values.welcome_message)}
/>
</div>,
],
})}
{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(),
})}
</form>
<div className="Form-group BasicsPage-welcomeBanner-input">
<label>{app.translator.trans('core.admin.basics.welcome_banner_heading')}</label>
<div className="helpText">{app.translator.trans('core.admin.basics.welcome_banner_text')}</div>
<input type="text" className="FormControl" bidi={this.setting('welcome_title')} />
<textarea className="FormControl" bidi={this.setting('welcome_message')} />
</div>
</div>
);
}
changed() {
return this.fields.some((key) => this.values[key]() !== app.data.settings[key]);
{Object.keys(this.displayNameOptions).length > 1
? this.buildSettingComponent({
type: 'select',
setting: 'display_name_driver',
options: this.displayNameOptions,
label: app.translator.trans('core.admin.basics.display_name_heading'),
help: app.translator.trans('core.admin.basics.display_name_text'),
})
: ''}
{Object.keys(this.slugDriverOptions).map((model) => {
const options = this.slugDriverOptions[model];
if (Object.keys(options).length > 1) {
return this.buildSettingComponent({
type: 'select',
setting: `slug_driver_${model}`,
options,
label: app.translator.trans('core.admin.basics.slug_driver_heading', { model }),
help: app.translator.trans('core.admin.basics.slug_driver_text', { model }),
});
}
})}
{this.submitButton()}
</div>,
];
}
/**
@@ -171,27 +132,4 @@ export default class BasicsPage extends Page {
return items;
}
onsubmit(e) {
e.preventDefault();
if (this.loading) return;
this.loading = true;
app.alerts.dismiss(this.successAlert);
const settings = {};
this.fields.forEach((key) => (settings[key] = this.values[key]()));
saveSettings(settings)
.then(() => {
app.alerts.show((this.successAlert = new Alert({ type: 'success', children: app.translator.trans('core.admin.basics.saved_message') })));
})
.catch(() => {})
.then(() => {
this.loading = false;
m.redraw();
});
}
}

View File

@@ -1,16 +1,29 @@
import Page from './Page';
import StatusWidget from './StatusWidget';
import ExtensionsWidget from './ExtensionsWidget';
import ItemList from '../../common/utils/ItemList';
import AdminPage from './AdminPage';
export default class DashboardPage extends Page {
view() {
return (
<div className="DashboardPage">
<div className="container">{this.availableWidgets()}</div>
</div>
);
export default class DashboardPage extends AdminPage {
headerInfo() {
return {
className: 'DashboardPage',
icon: 'fas fa-chart-bar',
title: app.translator.trans('core.admin.dashboard.title'),
description: app.translator.trans('core.admin.dashboard.description'),
};
}
content() {
return this.availableWidgets().toArray();
}
availableWidgets() {
return [<StatusWidget />];
const items = new ItemList();
items.add('status', <StatusWidget />, 30);
items.add('extensions', <ExtensionsWidget />, 10);
return items;
}
}

View File

@@ -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')}

View 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;
}
}

View File

@@ -0,0 +1,248 @@
import Button from '../../common/components/Button';
import Link from '../../common/components/Link';
import LinkButton from '../../common/components/LinkButton';
import Switch from '../../common/components/Switch';
import icon from '../../common/helpers/icon';
import punctuateSeries from '../../common/helpers/punctuateSeries';
import listItems from '../../common/helpers/listItems';
import ItemList from '../../common/utils/ItemList';
import LoadingModal from './LoadingModal';
import ExtensionPermissionGrid from './ExtensionPermissionGrid';
import isExtensionEnabled from '../utils/isExtensionEnabled';
import AdminPage from './AdminPage';
export default class ExtensionPage extends AdminPage {
oninit(vnode) {
super.oninit(vnode);
this.extension = app.data.extensions[this.attrs.id];
this.changingState = false;
this.infoFields = {
discuss: 'fas fa-comment-alt',
documentation: 'fas fa-book',
support: 'fas fa-life-ring',
website: 'fas fa-link',
donate: 'fas fa-donate',
source: 'fas fa-code',
};
if (!this.extension) {
return m.route.set(app.route('dashboard'));
}
}
className() {
if (!this.extension) return '';
return this.extension.id + '-Page';
}
view() {
if (!this.extension) return null;
return (
<div className={'ExtensionPage ' + this.className()}>
{this.header()}
{!this.isEnabled() ? (
<div className="container">
<h3 className="ExtensionPage-subHeader">{app.translator.trans('core.admin.extension.enable_to_see')}</h3>
</div>
) : (
<div className="ExtensionPage-body">{this.sections().toArray()}</div>
)}
</div>
);
}
header() {
const isEnabled = this.isEnabled();
return [
<div className="ExtensionPage-header">
<div className="container">
<div className="ExtensionTitle">
<span className="ExtensionIcon" style={this.extension.icon}>
{this.extension.icon ? icon(this.extension.icon.name) : ''}
</span>
<div className="ExtensionName">
<h2>{this.extension.extra['flarum-extension'].title}</h2>
</div>
<div className="ExtensionPage-headerTopItems">
<ul>{listItems(this.topItems().toArray())}</ul>
</div>
</div>
<div className="helpText">{this.extension.description}</div>
<div className="ExtensionPage-headerItems">
<Switch
state={this.changingState ? !isEnabled : isEnabled}
loading={this.changingState}
onchange={this.toggle.bind(this, this.extension.id)}
>
{isEnabled ? app.translator.trans('core.admin.extension.enabled') : app.translator.trans('core.admin.extension.disabled')}
</Switch>
<aside className="ExtensionInfo">
<ul>{listItems(this.infoItems().toArray())}</ul>
</aside>
</div>
</div>
</div>,
];
}
sections() {
const items = new ItemList();
items.add('content', this.content());
items.add('permissions', [
<div className="ExtensionPage-permissions">
<div className="ExtensionPage-permissions-header">
<div className="container">
<h2 className="ExtensionTitle">{app.translator.trans('core.admin.extension.permissions_title')}</h2>
</div>
</div>
<div className="container">
{app.extensionData.extensionHasPermissions(this.extension.id) ? (
ExtensionPermissionGrid.component({ extensionId: this.extension.id })
) : (
<h3 className="ExtensionPage-subHeader">{app.translator.trans('core.admin.extension.no_permissions')}</h3>
)}
</div>
</div>,
]);
return items;
}
content() {
const settings = app.extensionData.getSettings(this.extension.id);
return (
<div className="ExtensionPage-settings">
<div className="container">
{settings ? (
<div className="Form">
{settings.map(this.buildSettingComponent.bind(this))}
<div className="Form-group">{this.submitButton()}</div>
</div>
) : (
<h3 className="ExtensionPage-subHeader">{app.translator.trans('core.admin.extension.no_settings')}</h3>
)}
</div>
</div>
);
}
topItems() {
const items = new ItemList();
items.add('version', <span className="ExtensionVersion">{this.extension.version}</span>);
if (!this.isEnabled()) {
const uninstall = () => {
if (confirm(app.translator.trans('core.admin.extension.confirm_uninstall'))) {
app
.request({
url: app.forum.attribute('apiUrl') + '/extensions/' + this.extension.id,
method: 'DELETE',
})
.then(() => window.location.reload());
app.modal.show(LoadingModal);
}
};
items.add(
'uninstall',
<Button icon="fas fa-trash-alt" className="Button Button--primary" onclick={uninstall.bind(this)}>
{app.translator.trans('core.admin.extension.uninstall_button')}
</Button>
);
}
return items;
}
infoItems() {
const items = new ItemList();
const links = this.extension.links;
if (links.authors.length) {
let authors = [];
links.authors.map((author) => {
authors.push(
<Link href={author.link} external={true} target="_blank">
{author.name}
</Link>
);
});
items.add('authors', [icon('fas fa-user'), <span>{punctuateSeries(authors)}</span>]);
}
Object.keys(this.infoFields).map((field) => {
if (links[field]) {
items.add(
field,
<LinkButton href={links[field]} icon={this.infoFields[field]} external={true} target="_blank">
{app.translator.trans(`core.admin.extension.info_links.${field}`)}
</LinkButton>
);
}
});
return items;
}
toggle() {
const enabled = this.isEnabled();
this.changingState = true;
app
.request({
url: app.forum.attribute('apiUrl') + '/extensions/' + this.extension.id,
method: 'PATCH',
body: { enabled: !enabled },
errorHandler: this.onerror.bind(this),
})
.then(() => {
if (!enabled) localStorage.setItem('enabledExtension', this.extension.id);
window.location.reload();
});
app.modal.show(LoadingModal);
}
isEnabled() {
return isExtensionEnabled(this.extension.id);
}
onerror(e) {
// We need to give the modal animation time to start; if we close the modal too early,
// it breaks the bootstrap modal library.
// TODO: This workaround should be removed when we move away from bootstrap JS for modals.
setTimeout(() => {
app.modal.close();
}, 300); // Bootstrap's Modal.TRANSITION_DURATION is 300 ms.
this.changingState = false;
if (e.status !== 409) {
throw e;
}
const error = e.response.errors[0];
app.alerts.show(
{ type: 'error' },
app.translator.trans(`core.lib.error.${error.code}_message`, {
extension: error.extension,
extensions: error.extensions.join(', '),
})
);
}
}

View File

@@ -0,0 +1,53 @@
import PermissionGrid from './PermissionGrid';
import Button from '../../common/components/Button';
import ItemList from '../../common/utils/ItemList';
export default class ExtensionPermissionGrid extends PermissionGrid {
oninit(vnode) {
super.oninit(vnode);
this.extensionId = this.attrs.extensionId;
}
permissionItems() {
const permissionCategories = super.permissionItems();
permissionCategories.items = Object.entries(permissionCategories.items)
.filter(([category, info]) => info.content.children.length > 0)
.reduce((obj, [category, info]) => {
obj[category] = info;
return obj;
}, {});
return permissionCategories;
}
viewItems() {
return app.extensionData.getExtensionPermissions(this.extensionId, 'view') || new ItemList();
}
startItems() {
return app.extensionData.getExtensionPermissions(this.extensionId, 'start') || new ItemList();
}
replyItems() {
return app.extensionData.getExtensionPermissions(this.extensionId, 'reply') || new ItemList();
}
moderateItems() {
return app.extensionData.getExtensionPermissions(this.extensionId, 'moderate') || new ItemList();
}
scopeControlItems() {
const items = new ItemList();
items.add(
'configureScopes',
<Button className="Button Button--text" onclick={() => m.route.set(app.route('permissions'))}>
{app.translator.trans('core.admin.extension.configure_scopes')}
</Button>
);
return items;
}
}

View File

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

View File

@@ -0,0 +1,51 @@
import DashboardWidget from './DashboardWidget';
import isExtensionEnabled from '../utils/isExtensionEnabled';
import getCategorizedExtensions from '../utils/getCategorizedExtensions';
import Link from '../../common/components/Link';
import icon from '../../common/helpers/icon';
export default class ExtensionsWidget extends DashboardWidget {
oninit(vnode) {
super.oninit(vnode);
this.categorizedExtensions = getCategorizedExtensions();
}
className() {
return 'ExtensionsWidget';
}
content() {
const categories = app.extensionCategories;
return (
<div className="ExtensionsWidget-list">
{Object.keys(categories).map((category) => (this.categorizedExtensions[category] ? this.extensionCategory(category) : ''))}
</div>
);
}
extensionCategory(category) {
return (
<div className="ExtensionList-Category">
<h4 className="ExtensionList-Label">{app.translator.trans(`core.admin.nav.categories.${category}`)}</h4>
<ul className="ExtensionList">{this.categorizedExtensions[category].map((extension) => this.extensionWidget(extension))}</ul>
</div>
);
}
extensionWidget(extension) {
return (
<li className={'ExtensionListItem ' + (!isExtensionEnabled(extension.id) ? 'disabled' : '')}>
<Link href={app.route('extension', { id: extension.id })}>
<div className="ExtensionListItem-content">
<span className="ExtensionListItem-icon ExtensionIcon" style={extension.icon}>
{extension.icon ? icon(extension.icon.name) : ''}
</span>
<span className="ExtensionListItem-title">{extension.extra['flarum-extension'].title}</span>
</div>
</Link>
</li>
);
}
}

View File

@@ -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;

View File

@@ -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';

View File

@@ -1,31 +1,31 @@
import Page from './Page';
import FieldSet from '../../common/components/FieldSet';
import Button from '../../common/components/Button';
import Alert from '../../common/components/Alert';
import Select from '../../common/components/Select';
import LoadingIndicator from '../../common/components/LoadingIndicator';
import saveSettings from '../utils/saveSettings';
import AdminPage from './AdminPage';
export default class MailPage extends Page {
init() {
super.init();
export default class MailPage extends AdminPage {
oninit(vnode) {
super.oninit(vnode);
this.saving = false;
this.sendingTest = false;
this.refresh();
}
headerInfo() {
return {
className: 'MailPage',
icon: 'fas fa-envelope',
title: app.translator.trans('core.admin.email.title'),
description: app.translator.trans('core.admin.email.description'),
};
}
refresh() {
this.loading = true;
this.driverFields = {};
this.fields = ['mail_driver', 'mail_from'];
this.values = {};
this.status = { sending: false, errors: {} };
const settings = app.data.settings;
this.fields.forEach((key) => (this.values[key] = m.prop(settings[key])));
app
.request({
method: 'GET',
@@ -36,136 +36,78 @@ export default class MailPage extends Page {
this.status.sending = response['data']['attributes']['sending'];
this.status.errors = response['data']['attributes']['errors'];
for (const driver in this.driverFields) {
for (const field in this.driverFields[driver]) {
this.fields.push(field);
this.values[field] = m.prop(settings[field]);
}
}
this.loading = false;
m.redraw();
});
}
view() {
if (this.loading || this.saving) {
return (
<div className="MailPage">
<div className="container">
<LoadingIndicator />
</div>
</div>
);
content() {
if (this.loading) {
return <LoadingIndicator />;
}
const fields = this.driverFields[this.values.mail_driver()];
const fields = this.driverFields[this.setting('mail_driver')()];
const fieldKeys = Object.keys(fields);
return (
<div className="MailPage">
<div className="container">
<form onsubmit={this.onsubmit.bind(this)}>
<h2>{app.translator.trans('core.admin.email.heading')}</h2>
<div className="helpText">{app.translator.trans('core.admin.email.text')}</div>
<div className="Form">
{this.buildSettingComponent({
type: 'text',
setting: 'mail_from',
label: app.translator.trans('core.admin.email.addresses_heading'),
className: 'MailPage-MailSettings',
})}
{this.buildSettingComponent({
type: 'select',
setting: 'mail_driver',
options: Object.keys(this.driverFields).reduce((memo, val) => ({ ...memo, [val]: val }), {}),
label: app.translator.trans('core.admin.email.driver_heading'),
className: 'MailPage-MailSettings',
})}
{this.status.sending ||
Alert.component(
{
dismissible: false,
},
app.translator.trans('core.admin.email.not_sending_message')
)}
{FieldSet.component({
label: app.translator.trans('core.admin.email.addresses_heading'),
className: 'MailPage-MailSettings',
children: [
<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)} />
</label>
</div>,
],
})}
{fieldKeys.length > 0 && (
<FieldSet label={app.translator.trans(`core.admin.email.${this.setting('mail_driver')()}_heading`)} className="MailPage-MailSettings">
<div className="MailPage-MailSettings-input">
{fieldKeys.map((field) => {
const fieldInfo = fields[field];
{FieldSet.component({
label: app.translator.trans('core.admin.email.driver_heading'),
className: 'MailPage-MailSettings',
children: [
<div className="MailPage-MailSettings-input">
<label>
{app.translator.trans('core.admin.email.driver_label')}
<Select
value={this.values.mail_driver()}
options={Object.keys(this.driverFields).reduce((memo, val) => ({ ...memo, [val]: val }), {})}
onchange={this.values.mail_driver}
/>
</label>
</div>,
],
})}
{this.status.sending ||
Alert.component({
children: app.translator.trans('core.admin.email.not_sending_message'),
dismissible: false,
return [
this.buildSettingComponent({
type: typeof this.setting(field)() === 'string' ? 'text' : 'select',
label: app.translator.trans(`core.admin.email.${field}_label`),
setting: field,
options: fieldInfo,
}),
this.status.errors[field] && <p className="ValidationError">{this.status.errors[field]}</p>,
];
})}
</div>
</FieldSet>
)}
{this.submitButton()}
{fieldKeys.length > 0 &&
FieldSet.component({
label: app.translator.trans(`core.admin.email.${this.values.mail_driver()}_heading`),
className: 'MailPage-MailSettings',
children: [
<div className="MailPage-MailSettings-input">
{fieldKeys.map((field) => [
<label>
{app.translator.trans(`core.admin.email.${field}_label`)}
{this.renderField(field)}
</label>,
this.status.errors[field] && <p className="ValidationError">{this.status.errors[field]}</p>,
])}
</div>,
],
})}
<FieldSet>
{Button.component({
type: 'submit',
className: 'Button Button--primary',
children: app.translator.trans('core.admin.email.submit_button'),
disabled: !this.changed(),
})}
</FieldSet>
{FieldSet.component({
label: app.translator.trans('core.admin.email.send_test_mail_heading'),
className: 'MailPage-MailSettings',
children: [
<div className="helpText">{app.translator.trans('core.admin.email.send_test_mail_text', { email: app.session.user.email() })}</div>,
Button.component({
className: 'Button Button--primary',
children: app.translator.trans('core.admin.email.send_test_mail_button'),
disabled: this.sendingTest || this.changed(),
onclick: () => this.sendTestEmail(),
}),
],
})}
</form>
</div>
<FieldSet label={app.translator.trans('core.admin.email.send_test_mail_heading')} className="MailPage-MailSettings">
<div className="helpText">{app.translator.trans('core.admin.email.send_test_mail_text', { email: app.session.user.email() })}</div>
{Button.component(
{
className: 'Button Button--primary',
disabled: this.sendingTest || this.isChanged(),
onclick: () => this.sendTestEmail(),
},
app.translator.trans('core.admin.email.send_test_mail_button')
)}
</FieldSet>
</div>
);
}
renderField(name) {
const driver = this.values.mail_driver();
const field = this.driverFields[driver][name];
const prop = this.values[name];
if (typeof field === 'string') {
return <input className="FormControl" value={prop() || ''} oninput={m.withAttr('value', prop)} />;
} else {
return <Select value={prop()} options={field} onchange={prop} />;
}
}
changed() {
return this.fields.some((key) => this.values[key]() !== app.data.settings[key]);
}
sendTestEmail() {
if (this.saving || this.sendingTest) return;
@@ -179,9 +121,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;
@@ -190,26 +130,7 @@ export default class MailPage extends Page {
});
}
onsubmit(e) {
e.preventDefault();
if (this.saving || this.sendingTest) return;
this.saving = true;
app.alerts.dismiss(this.successAlert);
const settings = {};
this.fields.forEach((key) => (settings[key] = this.values[key]()));
saveSettings(settings)
.then(() => {
app.alerts.show((this.successAlert = new Alert({ type: 'success', children: app.translator.trans('core.admin.basics.saved_message') })));
})
.catch(() => {})
.then(() => {
this.saving = false;
this.refresh();
});
saveSettings(e) {
super.saveSettings(e).then(this.refresh());
}
}

View File

@@ -1,32 +0,0 @@
import Component from '../../common/Component';
/**
* The `Page` component
*
* @abstract
*/
export default class Page extends Component {
init() {
app.previous = app.current;
app.current = this;
app.modal.close();
/**
* A class name to apply to the body while the route is active.
*
* @type {String}
*/
this.bodyClass = '';
}
config(isInitialized, context) {
if (isInitialized) return;
if (this.bodyClass) {
$('#app').addClass(this.bodyClass);
context.onunload = () => $('#app').removeClass(this.bodyClass);
}
}
}

View File

@@ -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;
}
}

View File

@@ -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;
}
@@ -323,15 +327,37 @@ export default class PermissionGrid extends Component {
);
items.add(
'userEdit',
'userEditCredentials',
{
icon: 'fas fa-user-cog',
label: app.translator.trans('core.admin.permissions.edit_users_credentials_label'),
permission: 'user.editCredentials',
},
60
);
items.add(
'userEditGroups',
{
icon: 'fas fa-users-cog',
label: app.translator.trans('core.admin.permissions.edit_users_groups_label'),
permission: 'user.editGroups',
},
60
);
items.add(
'userEdit',
{
icon: 'fas fa-address-card',
label: app.translator.trans('core.admin.permissions.edit_users_label'),
permission: 'user.edit',
},
60
);
items.merge(app.extensionData.getAllExtensionPermissions('moderate'));
return items;
}

View File

@@ -1,40 +1,43 @@
import Page from './Page';
import GroupBadge from '../../common/components/GroupBadge';
import EditGroupModal from './EditGroupModal';
import Group from '../../common/models/Group';
import icon from '../../common/helpers/icon';
import PermissionGrid from './PermissionGrid';
import AdminPage from './AdminPage';
export default class PermissionsPage extends Page {
view() {
return (
<div className="PermissionsPage">
<div className="PermissionsPage-groups">
<div className="container">
{app.store
.all('groups')
.filter((group) => [Group.GUEST_ID, Group.MEMBER_ID].indexOf(group.id()) === -1)
.map((group) => (
<button className="Button Group" onclick={() => app.modal.show(new EditGroupModal({ group }))}>
{GroupBadge.component({
group,
className: 'Group-icon',
label: null,
})}
<span className="Group-name">{group.namePlural()}</span>
</button>
))}
<button className="Button Group Group--add" onclick={() => app.modal.show(new EditGroupModal())}>
{icon('fas fa-plus', { className: 'Group-icon' })}
<span className="Group-name">{app.translator.trans('core.admin.permissions.new_group_button')}</span>
export default class PermissionsPage extends AdminPage {
headerInfo() {
return {
className: 'PermissionsPage',
icon: 'fas fa-key',
title: app.translator.trans('core.admin.permissions.title'),
description: app.translator.trans('core.admin.permissions.description'),
};
}
content() {
return [
<div className="PermissionsPage-groups">
{app.store
.all('groups')
.filter((group) => [Group.GUEST_ID, Group.MEMBER_ID].indexOf(group.id()) === -1)
.map((group) => (
<button className="Button Group" onclick={() => app.modal.show(EditGroupModal, { group })}>
{GroupBadge.component({
group,
className: 'Group-icon',
label: null,
})}
<span className="Group-name">{group.namePlural()}</span>
</button>
</div>
</div>
))}
<button className="Button Group Group--add" onclick={() => app.modal.show(EditGroupModal)}>
{icon('fas fa-plus', { className: 'Group-icon' })}
<span className="Group-name">{app.translator.trans('core.admin.permissions.new_group_button')}</span>
</button>
</div>,
<div className="PermissionsPage-permissions">
<div className="container">{PermissionGrid.component()}</div>
</div>
</div>
);
<div className="PermissionsPage-permissions">{PermissionGrid.component()}</div>,
];
}
}

View File

@@ -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
);

View File

@@ -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
);
}),
});
}
}

View File

@@ -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];
}

View File

@@ -46,7 +46,7 @@ export default class StatusWidget extends DashboardWidget {
}
handleClearCache(e) {
app.modal.show(new LoadingModal());
app.modal.show(LoadingModal);
app
.request({

View File

@@ -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;
}
/**

View File

@@ -10,8 +10,9 @@ export { app };
// Export public API
// Export compat API
import compat from './compat';
import compatObj from './compat';
import proxifyCompat from '../common/utils/proxifyCompat';
compat.app = app;
compatObj.app = app;
export { compat };
export const compat = proxifyCompat(compatObj, 'admin');

View 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);
}
}

View File

@@ -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 },
};
}

View File

@@ -0,0 +1,177 @@
import ItemList from '../../common/utils/ItemList';
export default class ExtensionData {
constructor() {
this.data = {};
this.currentExtension = null;
}
/**
* This function simply takes the extension id
*
* @example
* app.extensionData.load('flarum-tags')
*
* flarum/flags -> flarum-flags | acme/extension -> acme-extension
*
* @param extension
*/
for(extension) {
this.currentExtension = extension;
this.data[extension] = this.data[extension] || {};
return this;
}
/**
* This function registers your settings with Flarum
*
* It takes either a settings object or a callback.
*
* @example
*
* .registerSetting({
* setting: 'flarum-flags.guidelines_url',
* type: 'text', // This will be inputted into the input tag for the setting (text/number/etc)
* label: app.translator.trans('flarum-flags.admin.settings.guidelines_url_label')
* }, 15) // priority is optional (ItemList)
*
*
* @param content
* @param priority
* @returns {ExtensionData}
*/
registerSetting(content, priority = 0) {
this.data[this.currentExtension].settings = this.data[this.currentExtension].settings || new ItemList();
// Callbacks can be passed in instead of settings to display custom content.
// By default, they will be added with the `null` key, since they don't have a `.setting` attr.
// To support multiple such items for one extension, we assign a random ID.
// 36 is arbitrary length, but makes collisions very unlikely.
if (typeof content === 'function') {
content.setting = Math.random().toString(36);
}
this.data[this.currentExtension].settings.add(content.setting, content, priority);
return this;
}
/**
* This function registers your permission with Flarum
*
* @example
*
* .registerPermission('permissions', {
* icon: 'fas fa-flag',
* label: app.translator.trans('flarum-flags.admin.permissions.view_flags_label'),
* permission: 'discussion.viewFlags'
* }, 'moderate', 65)
*
* @param content
* @param permissionType
* @param priority
* @returns {ExtensionData}
*/
registerPermission(content, permissionType = null, priority = 0) {
this.data[this.currentExtension].permissions = this.data[this.currentExtension].permissions || {};
if (!this.data[this.currentExtension].permissions[permissionType]) {
this.data[this.currentExtension].permissions[permissionType] = new ItemList();
}
this.data[this.currentExtension].permissions[permissionType].add(content.permission, content, priority);
return this;
}
/**
* Replace the default extension page with a custom component.
* This component would typically extend ExtensionPage
*
* @param component
* @returns {ExtensionData}
*/
registerPage(component) {
this.data[this.currentExtension].page = component;
return this;
}
/**
* Get an extension's registered settings
*
* @param extensionId
* @returns {boolean|*}
*/
getSettings(extensionId) {
if (this.data[extensionId] && this.data[extensionId].settings) {
return this.data[extensionId].settings.toArray();
}
return false;
}
/**
*
* Get an ItemList of all extensions' registered permissions
*
* @param extension
* @param type
* @returns {ItemList}
*/
getAllExtensionPermissions(type) {
const items = new ItemList();
Object.keys(this.data).map((extension) => {
if (this.extensionHasPermissions(extension) && this.data[extension].permissions[type]) {
items.merge(this.data[extension].permissions[type]);
}
});
return items;
}
/**
* Get a singular extension's registered permissions
*
* @param extension
* @param type
* @returns {boolean|*}
*/
getExtensionPermissions(extension, type) {
if (this.extensionHasPermissions(extension) && this.data[extension].permissions[type]) {
return this.data[extension].permissions[type];
}
return new ItemList();
}
/**
* Checks whether a given extension has registered permissions.
*
* @param extension
* @returns {boolean}
*/
extensionHasPermissions(extension) {
if (this.data[extension] && this.data[extension].permissions) {
return true;
}
return false;
}
/**
* Returns an extension's custom page component if it exists.
*
* @param extension
* @returns {boolean|*}
*/
getPage(extension) {
if (this.data[extension]) {
return this.data[extension].page;
}
return false;
}
}

View File

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

View File

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

View File

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

View File

@@ -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';
@@ -21,6 +21,9 @@ import Post from './models/Post';
import Group from './models/Group';
import Notification from './models/Notification';
import { flattenDeep } from 'lodash-es';
import PageState from './states/PageState';
import ModalManagerState from './states/ModalManagerState';
import AlertManagerState from './states/AlertManagerState';
/**
* The `App` class provides a container for an application, as well as various
@@ -107,13 +110,49 @@ 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.
*
* This object holds information about the type of page we are currently
* visiting, and sometimes additional arbitrary page state that may be
* relevant to lower-level components.
*
* @type {PageState}
*/
current = new PageState(null);
/**
* The page the app was on before the current page.
*
* Once the application navigates to another page, the object previously
* assigned to this.current will be moved to this.previous, while this.current
* is re-initialized.
*
* @type {PageState}
*/
previous = new PageState(null);
/*
* An object that manages modal state.
*
* @type {ModalManagerState}
*/
modal = new ModalManagerState();
/**
* An object that manages the state of active alerts.
*
* @type {AlertManagerState}
*/
alerts = new AlertManagerState();
data;
@@ -150,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();
}
/**
@@ -189,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.
*
@@ -211,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.forum.attribute('basePath') + '/' ? 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
@@ -284,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);
@@ -307,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);
@@ -352,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;
}
/**
@@ -368,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 });
}
/**
@@ -382,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 : '');
}

View File

@@ -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) {}
}

132
js/src/common/Component.ts Normal file
View File

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

74
js/src/common/Fragment.ts Normal file
View 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>;
}

View File

@@ -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
)

View File

@@ -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}`;
}
}

View File

@@ -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
)

View File

@@ -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);
}
});

View File

@@ -1,6 +1,7 @@
import * as extend from './extend';
import Session from './Session';
import Store from './Store';
import BasicEditorDriver from './utils/BasicEditorDriver';
import evented from './utils/evented';
import liveHumanTimes from './utils/liveHumanTimes';
import ItemList from './utils/ItemList';
@@ -12,15 +13,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 patchMithril from './utils/patchMithril';
import proxifyCompat from './utils/proxifyCompat';
import classList from './utils/classList';
import extractText from './utils/extractText';
import formatNumber from './utils/formatNumber';
import mapRoutes from './utils/mapRoutes';
import withAttr from './utils/withAttr';
import Notification from './models/Notification';
import User from './models/User';
import Post from './models/Post';
@@ -30,6 +36,7 @@ import Forum from './models/Forum';
import Component from './Component';
import Translator from './Translator';
import AlertManager from './components/AlertManager';
import Page from './components/Page';
import Switch from './components/Switch';
import Badge from './components/Badge';
import LoadingIndicator from './components/LoadingIndicator';
@@ -42,6 +49,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';
@@ -49,6 +57,8 @@ import ModalManager from './components/ModalManager';
import Button from './components/Button';
import Modal from './components/Modal';
import GroupBadge from './components/GroupBadge';
import TextEditor from './components/TextEditor';
import TextEditorButton from './components/TextEditorButton';
import Model from './Model';
import Application from './Application';
import fullTime from './helpers/fullTime';
@@ -60,11 +70,14 @@ import highlight from './helpers/highlight';
import username from './helpers/username';
import userOnline from './helpers/userOnline';
import listItems from './helpers/listItems';
import Fragment from './Fragment';
import DefaultResolver from './resolvers/DefaultResolver';
export default {
extend: extend,
Session: Session,
Store: Store,
'utils/BasicEditorDriver': BasicEditorDriver,
'utils/evented': evented,
'utils/liveHumanTimes': liveHumanTimes,
'utils/ItemList': ItemList,
@@ -80,11 +93,16 @@ export default {
'utils/extract': extract,
'utils/ScrollListener': ScrollListener,
'utils/stringToColor': stringToColor,
'utils/Stream': Stream,
'utils/subclassOf': subclassOf,
'utils/setRouteWithForcedRefresh': setRouteWithForcedRefresh,
'utils/patchMithril': patchMithril,
'utils/proxifyCompat': proxifyCompat,
'utils/classList': classList,
'utils/extractText': extractText,
'utils/formatNumber': formatNumber,
'utils/mapRoutes': mapRoutes,
'utils/withAttr': withAttr,
'models/Notification': Notification,
'models/User': User,
'models/Post': Post,
@@ -92,8 +110,10 @@ export default {
'models/Group': Group,
'models/Forum': Forum,
Component: Component,
Fragment: Fragment,
Translator: Translator,
'components/AlertManager': AlertManager,
'components/Page': Page,
'components/Switch': Switch,
'components/Badge': Badge,
'components/LoadingIndicator': LoadingIndicator,
@@ -106,6 +126,7 @@ export default {
'components/Select': Select,
'components/Navigation': Navigation,
'components/Alert': Alert,
'components/Link': Link,
'components/LinkButton': LinkButton,
'components/Checkbox': Checkbox,
'components/SelectDropdown': SelectDropdown,
@@ -113,6 +134,8 @@ export default {
'components/Button': Button,
'components/Modal': Modal,
'components/GroupBadge': GroupBadge,
'components/TextEditor': TextEditor,
'components/TextEditorButton': TextEditorButton,
Model: Model,
Application: Application,
'helpers/fullTime': fullTime,
@@ -124,4 +147,5 @@ export default {
'helpers/username': username,
'helpers/userOnline': userOnline,
'helpers/listItems': listItems,
'resolvers/DefaultResolver': DefaultResolver,
};

View File

@@ -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>
);

View File

@@ -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();
}
}

View File

@@ -6,18 +6,18 @@ import extract from '../utils/extract';
* The `Badge` component represents a user/discussion badge, indicating some
* status (e.g. a discussion is stickied, a user is an admin).
*
* A badge may have the following special 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('&nbsp;')}</span>;
}
config(isInitialized) {
if (isInitialized) return;
oncreate(vnode) {
super.oncreate(vnode);
if (this.props.label) this.$().tooltip();
if (this.attrs.label) this.$().tooltip();
}
}

View File

@@ -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" /> : '',
];
}
}

View File

@@ -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);
}
}

View 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];
}
}

View File

@@ -6,7 +6,7 @@ import listItems from '../helpers/listItems';
* The `Dropdown` component displays a button which, when clicked, shows a
* dropdown menu beneath it.
*
* ### 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>;
}
}

View File

@@ -11,11 +11,11 @@ import listItems from '../helpers/listItems';
* The children should be an array of items to show in the fieldset.
*/
export default class FieldSet extends Component {
view() {
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>
);
}

View File

@@ -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;
}
}
}

View 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>;
}
}

View File

@@ -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;
}
}

View File

@@ -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('&nbsp;')}</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;

View File

@@ -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();

View File

@@ -1,5 +1,4 @@
import Component from '../Component';
import Modal from './Modal';
/**
* The `ModalManager` component manages a modal dialog. Only one modal dialog
@@ -7,48 +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;
if (app.current) app.current.retain = true;
m.redraw(true);
const dismissible = !!this.component.isDismissible();
this.$()
.one('shown.bs.modal', readyCallback)
.modal({
backdrop: dismissible || 'static',
keyboard: dismissible,
@@ -56,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');
}
}

View File

@@ -11,7 +11,7 @@ import LinkButton from './LinkButton';
* If the app has a pane, it will also include a 'pin' button which toggles the
* pinned state of the pane.
*
* Accepts the following 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;

View File

@@ -0,0 +1,62 @@
import Component from '../Component';
import PageState from '../states/PageState';
/**
* The `Page` component
*
* @abstract
*/
export default class Page extends Component {
oninit(vnode) {
super.oninit(vnode);
app.previous = app.current;
app.current = new PageState(this.constructor, { routeName: this.attrs.routeName });
app.drawer.hide();
app.modal.close();
/**
* A class name to apply to the body while the route is active.
*
* @type {String}
*/
this.bodyClass = '';
/**
* Whether we should scroll to the top of the page when its rendered.
*
* @type {Boolean}
*/
this.scrollTopOnCreate = true;
/**
* Whether the browser should restore scroll state on refreshes.
*
* @type {Boolean}
*/
this.useBrowserScrollRestoration = true;
}
oncreate(vnode) {
super.oncreate(vnode);
if (this.bodyClass) {
$('#app').addClass(this.bodyClass);
}
if (this.scrollTopOnCreate) {
$(window).scrollTop(0);
}
if ('scrollRestoration' in history) {
history.scrollRestoration = this.useBrowserScrollRestoration ? 'auto' : 'manual';
}
}
onremove() {
if (this.bodyClass) {
$('#app').removeClass(this.bodyClass);
}
}
}

View File

@@ -4,7 +4,7 @@ import Component from '../Component';
* The `Placeholder` component displays a muted text with some call to action,
* usually used as an empty state.
*
* ### 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>
);
}

View File

@@ -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}

View File

@@ -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}
>

View File

@@ -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' })];
}
}

View File

@@ -7,25 +7,25 @@ import icon from '../helpers/icon';
* is displayed as its own button prior to the toggle button.
*/
export default class SplitDropdown extends Dropdown {
static 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];

View File

@@ -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() : '';
}
}

View File

@@ -0,0 +1,148 @@
import Component from '../Component';
import ItemList from '../utils/ItemList';
import listItems from '../helpers/listItems';
import Button from './Button';
import BasicEditorDriver from '../utils/BasicEditorDriver';
/**
* The `TextEditor` component displays a textarea with controls, including a
* submit button.
*
* ### Attrs
*
* - `composer`
* - `submitLabel`
* - `value`
* - `placeholder`
* - `disabled`
* - `preview`
*/
export default class TextEditor extends Component {
oninit(vnode) {
super.oninit(vnode);
/**
* The value of the editor.
*
* @type {String}
*/
this.value = this.attrs.value || '';
/**
* Whether the editor is disabled.
*/
this.disabled = !!this.attrs.disabled;
}
view() {
return (
<div className="TextEditor">
<div className="TextEditor-editorContainer"></div>
<ul className="TextEditor-controls Composer-footer">
{listItems(this.controlItems().toArray())}
<li className="TextEditor-toolbar">{this.toolbarItems().toArray()}</li>
</ul>
</div>
);
}
oncreate(vnode) {
super.oncreate(vnode);
this.attrs.composer.editor = this.buildEditor(this.$('.TextEditor-editorContainer')[0]);
}
onupdate() {
const newDisabled = !!this.attrs.disabled;
if (this.disabled !== newDisabled) {
this.disabled = newDisabled;
this.attrs.composer.editor.disabled(newDisabled);
}
}
buildEditorParams() {
return {
classNames: ['FormControl', 'Composer-flexible', 'TextEditor-editor'],
disabled: this.disabled,
placeholder: this.attrs.placeholder || '',
value: this.value,
oninput: this.oninput.bind(this),
inputListeners: [],
onsubmit: () => {
this.onsubmit();
m.redraw();
},
};
}
buildEditor(dom) {
return new BasicEditorDriver(dom, this.buildEditorParams());
}
/**
* Build an item list for the text editor controls.
*
* @return {ItemList}
*/
controlItems() {
const items = new ItemList();
items.add(
'submit',
Button.component(
{
icon: 'fas fa-paper-plane',
className: 'Button Button--primary',
itemClassName: 'App-primaryControl',
onclick: this.onsubmit.bind(this),
},
this.attrs.submitLabel
)
);
if (this.attrs.preview) {
items.add(
'preview',
Button.component({
icon: 'far fa-eye',
className: 'Button Button--icon',
onclick: this.attrs.preview,
title: app.translator.trans('core.forum.composer.preview_tooltip'),
oncreate: (vnode) => $(vnode.dom).tooltip(),
})
);
}
return items;
}
/**
* Build an item list for the toolbar controls.
*
* @return {ItemList}
*/
toolbarItems() {
return new ItemList();
}
/**
* Handle input into the textarea.
*
* @param {String} value
*/
oninput(value) {
this.value = value;
this.attrs.onchange(this.value);
}
/**
* Handle the submit button being clicked.
*/
onsubmit() {
this.attrs.onsubmit(this.value);
}
}

View File

@@ -0,0 +1,19 @@
import Button from './Button';
/**
* The `TextEditorButton` component displays a button suitable for the text
* editor toolbar.
*/
export default class TextEditorButton extends Button {
static initAttrs(attrs) {
super.initAttrs(attrs);
attrs.className = attrs.className || 'Button Button--icon Button--link';
}
oncreate(vnode) {
super.oncreate(vnode);
this.$().tooltip();
}
}

View File

@@ -1,4 +1,4 @@
export default class Routes {
export default class Model {
type;
attributes = [];
hasOnes = [];

View File

@@ -1,31 +1,33 @@
import * as Mithril from 'mithril';
import User from '../models/User';
/**
* The `avatar` helper displays a user's avatar.
*
* @param {User} user
* @param {Object} attrs Attributes to apply to the avatar element
* @return {Object}
* @param user
* @param attrs Attributes to apply to the avatar element
*/
export default function avatar(user, attrs = {}) {
export default function avatar(user: User, attrs: Object = {}): Mithril.Vnode {
attrs.className = 'Avatar ' + (attrs.className || '');
let content = '';
let content: string = '';
// If the `title` attribute is set to null or false, we don't want to give the
// avatar a title. On the other hand, if it hasn't been given at all, we can
// safely default it to the user's username.
const hasTitle = attrs.title === 'undefined' || attrs.title;
const hasTitle: boolean | string = attrs.title === 'undefined' || attrs.title;
if (!hasTitle) delete attrs.title;
// If a user has been passed, then we will set up an avatar using their
// uploaded image, or the first letter of their username if they haven't
// uploaded one.
if (user) {
const username = user.displayName() || '?';
const avatarUrl = user.avatarUrl();
const username: string = user.displayName() || '?';
const avatarUrl: string = user.avatarUrl();
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();

View File

@@ -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>
);
}

View 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>
);
}

View File

@@ -1,16 +1,16 @@
import * as Mithril from 'mithril';
import { truncate } from '../utils/string';
/**
* The `highlight` helper searches for a word phrase in a string, and wraps
* matches with the <mark> tag.
*
* @param {String} string The string to highlight.
* @param {String|RegExp} phrase The word or words to highlight.
* @param {Integer} [length] The number of characters to truncate the string to.
* @param string The string to highlight.
* @param phrase The word or words to highlight.
* @param [length] The number of characters to truncate the string to.
* The string will be truncated surrounding the first match.
* @return {Object}
*/
export default function highlight(string, phrase, length) {
export default function highlight(string: string, phrase: string | RegExp, length?: number): Mithril.Vnode<any, any> | string {
if (!phrase && !length) return string;
// Convert the word phrase into a global regular expression (if it isn't

View File

@@ -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 (

View File

@@ -1,12 +0,0 @@
/**
* The `icon` helper displays an icon.
*
* @param {String} fontClass The full icon class, prefix and the icons name.
* @param {Object} attrs Any other attributes to apply.
* @return {Object}
*/
export default function icon(fontClass, attrs = {}) {
attrs.className = 'icon ' + fontClass + ' ' + (attrs.className || '');
return <i {...attrs} />;
}

View File

@@ -0,0 +1,13 @@
import * as Mithril from 'mithril';
/**
* The `icon` helper displays an icon.
*
* @param fontClass The full icon class, prefix and the icons name.
* @param attrs Any other attributes to apply.
*/
export default function icon(fontClass: string, attrs: Mithril.Attributes = {}): Mithril.Vnode {
attrs.className = 'icon ' + fontClass + ' ' + (attrs.className || '');
return <i {...attrs} />;
}

View File

@@ -1,50 +0,0 @@
import Separator from '../components/Separator';
import classList from '../utils/classList';
function isSeparator(item) {
return item && item.component === Separator;
}
function withoutUnnecessarySeparators(items) {
const newItems = [];
let prevItem;
items.forEach((item, i) => {
if (!isSeparator(item) || (prevItem && !isSeparator(prevItem) && i !== items.length - 1)) {
prevItem = item;
newItems.push(item);
}
});
return newItems;
}
/**
* The `listItems` helper wraps a collection of components in <li> tags,
* stripping out any unnecessary `Separator` components.
*
* @param {*} items
* @return {Array}
*/
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;
if (isListItem) {
item.attrs = item.attrs || {};
item.attrs.key = item.attrs.key || item.itemName;
}
return isListItem ? (
item
) : (
<li className={classList([item.itemName ? 'item-' + item.itemName : '', className, active ? 'active' : ''])} key={item.itemName}>
{item}
</li>
);
});
}

View File

@@ -0,0 +1,54 @@
import * as Mithril from 'mithril';
import Separator from '../components/Separator';
import classList from '../utils/classList';
function isSeparator(item): boolean {
return item.tag === Separator;
}
function withoutUnnecessarySeparators(items: Array<Mithril.Vnode>): Array<Mithril.Vnode> {
const newItems = [];
let prevItem;
items.filter(Boolean).forEach((item: Mithril.Vnode, i: number) => {
if (!isSeparator(item) || (prevItem && !isSeparator(prevItem) && i !== items.length - 1)) {
prevItem = item;
newItems.push(item);
}
});
return newItems;
}
/**
* The `listItems` helper wraps a collection of components in <li> tags,
* stripping out any unnecessary `Separator` components.
*/
export default function listItems(items: Mithril.Vnode | Array<Mithril.Vnode>): Array<Mithril.Vnode> {
if (!(items instanceof Array)) items = [items];
return withoutUnnecessarySeparators(items).map((item: Mithril.Vnode) => {
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;
}
const node: Mithril.Vnode = isListItem ? (
item
) : (
<li
className={classList([className, item.itemName && `item-${item.itemName}`, active && 'active'])}
key={(item.attrs && item.attrs.key) || item.itemName}
>
{item}
</li>
);
return node;
});
}

View File

@@ -1,12 +1,11 @@
import * as Mithril from 'mithril';
import User from '../models/User';
import icon from './icon';
/**
* The `useronline` helper displays a green circle if the user is online
*
* @param {User} user
* @return {Object}
*/
export default function userOnline(user) {
export default function userOnline(user: User): Mithril.Vnode {
if (user.lastSeenAt() && user.isOnline()) {
return <span className="UserOnline">{icon('fas fa-circle')}</span>;
}

View File

@@ -1,11 +1,11 @@
import * as Mithril from 'mithril';
import User from '../models/User';
/**
* The `username` helper displays a user's username in a <span class="username">
* tag. If the user doesn't exist, the username will be displayed as [deleted].
*
* @param {User} user
* @return {Object}
*/
export default function username(user) {
export default function username(user: User): Mithril.Vnode {
const name = (user && user.displayName()) || app.translator.trans('core.lib.username.deleted_text');
return <span className="username">{name}</span>;

View File

@@ -1,7 +1,6 @@
import 'expose-loader?$!expose-loader?jQuery!jquery';
import 'expose-loader?m!mithril';
import 'expose-loader?moment!moment';
import 'expose-loader?m.bidi!m.attrs.bidi';
import 'expose-loader?dayjs!dayjs';
import 'bootstrap/js/affix';
import 'bootstrap/js/dropdown';
import 'bootstrap/js/modal';
@@ -9,6 +8,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);

View File

@@ -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;

View File

@@ -10,6 +10,7 @@ export default class User extends Model {}
Object.assign(User.prototype, {
username: Model.attribute('username'),
slug: Model.attribute('slug'),
displayName: Model.attribute('displayName'),
email: Model.attribute('email'),
isEmailConfirmed: Model.attribute('isEmailConfirmed'),
@@ -29,6 +30,8 @@ Object.assign(User.prototype, {
commentCount: Model.attribute('commentCount'),
canEdit: Model.attribute('canEdit'),
canEditCredentials: Model.attribute('canEditCredentials'),
canEditGroups: Model.attribute('canEditGroups'),
canDelete: Model.attribute('canDelete'),
avatarColor: null,
@@ -54,7 +57,7 @@ Object.assign(User.prototype, {
* @public
*/
isOnline() {
return this.lastSeenAt() > moment().subtract(5, 'minutes').toDate();
return dayjs().subtract(5, 'minutes').isBefore(this.lastSeenAt());
},
/**

View 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() }];
}
}

View 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();
}
}

View 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();
});
}
}

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