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

Compare commits

..

188 Commits

Author SHA1 Message Date
Alexander Skvortsov
641330ce52 Don't scroll on m.route.set() to different post on same page.
This removes some messy logic, and the potential for glitches. This system worked well with Mithril 0.2 where we could listen in before (and prevent) page unload, but since that's not possible in Mithril 2, the implementation of the replacement was done in `onbeforeupdate`, which might be called while the page route is being updated, glitching out the page. Instead, extensions should check if they are already on the discussion page for the post they are linking to, and if so, use `app.current.get('stream').goToNumber(TARGET)`.

Please note that this does NOT affect going directly to posts from external links (or page reload), OR from other pages via m.route.set.
2020-10-08 11:34:56 -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
277 changed files with 5610 additions and 4309 deletions

2
.gitattributes vendored
View File

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

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

View File

@@ -37,25 +37,25 @@
"require": { "require": {
"php": ">=7.2", "php": ">=7.2",
"axy/sourcemap": "^0.1.4", "axy/sourcemap": "^0.1.4",
"components/font-awesome": "5.9.*", "components/font-awesome": "^5.14.0",
"dflydev/fig-cookies": "^2.0.1", "dflydev/fig-cookies": "^2.0.1",
"doctrine/dbal": "^2.7", "doctrine/dbal": "^2.7",
"franzl/whoops-middleware": "^0.4.0", "franzl/whoops-middleware": "^0.4.0",
"illuminate/bus": "5.8.*", "illuminate/bus": "^6.0",
"illuminate/cache": "5.8.*", "illuminate/cache": "^6.0",
"illuminate/config": "5.8.*", "illuminate/config": "^6.0",
"illuminate/container": "5.8.*", "illuminate/container": "^6.0",
"illuminate/contracts": "5.8.*", "illuminate/contracts": "^6.0",
"illuminate/database": "5.8.*", "illuminate/database": "^6.0",
"illuminate/events": "5.8.*", "illuminate/events": "^6.0",
"illuminate/filesystem": "5.8.*", "illuminate/filesystem": "^6.0",
"illuminate/hashing": "5.8.*", "illuminate/hashing": "^6.0",
"illuminate/mail": "5.8.*", "illuminate/mail": "^6.0",
"illuminate/queue": "5.8.*", "illuminate/queue": "^6.0",
"illuminate/session": "5.8.*", "illuminate/session": "^6.0",
"illuminate/support": "5.8.*", "illuminate/support": "^6.0",
"illuminate/validation": "5.8.*", "illuminate/validation": "^6.0",
"illuminate/view": "5.8.*", "illuminate/view": "^6.0",
"intervention/image": "^2.5.0", "intervention/image": "^2.5.0",
"laminas/laminas-diactoros": "^1.8.4", "laminas/laminas-diactoros": "^1.8.4",
"laminas/laminas-httphandlerrunner": "^1.0", "laminas/laminas-httphandlerrunner": "^1.0",
@@ -66,6 +66,7 @@
"middlewares/base-path-router": "^0.2.1", "middlewares/base-path-router": "^0.2.1",
"middlewares/request-handler": "^1.2", "middlewares/request-handler": "^1.2",
"monolog/monolog": "^1.16.0", "monolog/monolog": "^1.16.0",
"nesbot/carbon": "^2.0",
"nikic/fast-route": "^0.6", "nikic/fast-route": "^0.6",
"psr/http-message": "^1.0", "psr/http-message": "^1.0",
"psr/http-server-handler": "^1.0", "psr/http-server-handler": "^1.0",

6
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

8
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

222
js/package-lock.json generated
View File

@@ -289,6 +289,11 @@
"@babel/types": "^7.0.0" "@babel/types": "^7.0.0"
} }
}, },
"@babel/helper-validator-identifier": {
"version": "7.10.3",
"resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.10.3.tgz",
"integrity": "sha512-bU8JvtlYpJSBPuj1VUmKpFGaDZuLxASky3LhaKj3bmpSTY6VWooSM8msk+Z0CZoErFye2tlABF6yDkT3FOPAXw=="
},
"@babel/helper-wrap-function": { "@babel/helper-wrap-function": {
"version": "7.2.0", "version": "7.2.0",
"resolved": "https://registry.npmjs.org/@babel/helper-wrap-function/-/helper-wrap-function-7.2.0.tgz", "resolved": "https://registry.npmjs.org/@babel/helper-wrap-function/-/helper-wrap-function-7.2.0.tgz",
@@ -445,6 +450,21 @@
"@babel/helper-plugin-utils": "^7.0.0" "@babel/helper-plugin-utils": "^7.0.0"
} }
}, },
"@babel/plugin-syntax-typescript": {
"version": "7.10.1",
"resolved": "https://registry.npmjs.org/@babel/plugin-syntax-typescript/-/plugin-syntax-typescript-7.10.1.tgz",
"integrity": "sha512-X/d8glkrAtra7CaQGMiGs/OGa6XgUzqPcBXCIGFCpCqnfGlT0Wfbzo/B89xHhnInTaItPK8LALblVXcUOEh95Q==",
"requires": {
"@babel/helper-plugin-utils": "^7.10.1"
},
"dependencies": {
"@babel/helper-plugin-utils": {
"version": "7.10.3",
"resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.10.3.tgz",
"integrity": "sha512-j/+j8NAWUTxOtx4LKHybpSClxHoq6I91DQ/mKgAXn5oNUPIUiGppjPIX3TDtJWPrdfP9Kfl7e4fgVMiQR9VE/g=="
}
}
},
"@babel/plugin-transform-arrow-functions": { "@babel/plugin-transform-arrow-functions": {
"version": "7.2.0", "version": "7.2.0",
"resolved": "https://registry.npmjs.org/@babel/plugin-transform-arrow-functions/-/plugin-transform-arrow-functions-7.2.0.tgz", "resolved": "https://registry.npmjs.org/@babel/plugin-transform-arrow-functions/-/plugin-transform-arrow-functions-7.2.0.tgz",
@@ -740,6 +760,159 @@
"@babel/helper-plugin-utils": "^7.0.0" "@babel/helper-plugin-utils": "^7.0.0"
} }
}, },
"@babel/plugin-transform-typescript": {
"version": "7.10.3",
"resolved": "https://registry.npmjs.org/@babel/plugin-transform-typescript/-/plugin-transform-typescript-7.10.3.tgz",
"integrity": "sha512-qU9Lu7oQyh3PGMQncNjQm8RWkzw6LqsWZQlZPQMgrGt6s3YiBIaQ+3CQV/FA/icGS5XlSWZGwo/l8ErTyelS0Q==",
"requires": {
"@babel/helper-create-class-features-plugin": "^7.10.3",
"@babel/helper-plugin-utils": "^7.10.3",
"@babel/plugin-syntax-typescript": "^7.10.1"
},
"dependencies": {
"@babel/code-frame": {
"version": "7.10.3",
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.10.3.tgz",
"integrity": "sha512-fDx9eNW0qz0WkUeqL6tXEXzVlPh6Y5aCDEZesl0xBGA8ndRukX91Uk44ZqnkECp01NAZUdCAl+aiQNGi0k88Eg==",
"requires": {
"@babel/highlight": "^7.10.3"
}
},
"@babel/generator": {
"version": "7.10.3",
"resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.10.3.tgz",
"integrity": "sha512-drt8MUHbEqRzNR0xnF8nMehbY11b1SDkRw03PSNH/3Rb2Z35oxkddVSi3rcaak0YJQ86PCuE7Qx1jSFhbLNBMA==",
"requires": {
"@babel/types": "^7.10.3",
"jsesc": "^2.5.1",
"lodash": "^4.17.13",
"source-map": "^0.5.0"
}
},
"@babel/helper-create-class-features-plugin": {
"version": "7.10.3",
"resolved": "https://registry.npmjs.org/@babel/helper-create-class-features-plugin/-/helper-create-class-features-plugin-7.10.3.tgz",
"integrity": "sha512-iRT9VwqtdFmv7UheJWthGc/h2s7MqoweBF9RUj77NFZsg9VfISvBTum3k6coAhJ8RWv2tj3yUjA03HxPd0vfpQ==",
"requires": {
"@babel/helper-function-name": "^7.10.3",
"@babel/helper-member-expression-to-functions": "^7.10.3",
"@babel/helper-optimise-call-expression": "^7.10.3",
"@babel/helper-plugin-utils": "^7.10.3",
"@babel/helper-replace-supers": "^7.10.1",
"@babel/helper-split-export-declaration": "^7.10.1"
}
},
"@babel/helper-function-name": {
"version": "7.10.3",
"resolved": "https://registry.npmjs.org/@babel/helper-function-name/-/helper-function-name-7.10.3.tgz",
"integrity": "sha512-FvSj2aiOd8zbeqijjgqdMDSyxsGHaMt5Tr0XjQsGKHD3/1FP3wksjnLAWzxw7lvXiej8W1Jt47SKTZ6upQNiRw==",
"requires": {
"@babel/helper-get-function-arity": "^7.10.3",
"@babel/template": "^7.10.3",
"@babel/types": "^7.10.3"
}
},
"@babel/helper-get-function-arity": {
"version": "7.10.3",
"resolved": "https://registry.npmjs.org/@babel/helper-get-function-arity/-/helper-get-function-arity-7.10.3.tgz",
"integrity": "sha512-iUD/gFsR+M6uiy69JA6fzM5seno8oE85IYZdbVVEuQaZlEzMO2MXblh+KSPJgsZAUx0EEbWXU0yJaW7C9CdAVg==",
"requires": {
"@babel/types": "^7.10.3"
}
},
"@babel/helper-member-expression-to-functions": {
"version": "7.10.3",
"resolved": "https://registry.npmjs.org/@babel/helper-member-expression-to-functions/-/helper-member-expression-to-functions-7.10.3.tgz",
"integrity": "sha512-q7+37c4EPLSjNb2NmWOjNwj0+BOyYlssuQ58kHEWk1Z78K5i8vTUsteq78HMieRPQSl/NtpQyJfdjt3qZ5V2vw==",
"requires": {
"@babel/types": "^7.10.3"
}
},
"@babel/helper-optimise-call-expression": {
"version": "7.10.3",
"resolved": "https://registry.npmjs.org/@babel/helper-optimise-call-expression/-/helper-optimise-call-expression-7.10.3.tgz",
"integrity": "sha512-kT2R3VBH/cnSz+yChKpaKRJQJWxdGoc6SjioRId2wkeV3bK0wLLioFpJROrX0U4xr/NmxSSAWT/9Ih5snwIIzg==",
"requires": {
"@babel/types": "^7.10.3"
}
},
"@babel/helper-plugin-utils": {
"version": "7.10.3",
"resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.10.3.tgz",
"integrity": "sha512-j/+j8NAWUTxOtx4LKHybpSClxHoq6I91DQ/mKgAXn5oNUPIUiGppjPIX3TDtJWPrdfP9Kfl7e4fgVMiQR9VE/g=="
},
"@babel/helper-replace-supers": {
"version": "7.10.1",
"resolved": "https://registry.npmjs.org/@babel/helper-replace-supers/-/helper-replace-supers-7.10.1.tgz",
"integrity": "sha512-SOwJzEfpuQwInzzQJGjGaiG578UYmyi2Xw668klPWV5n07B73S0a9btjLk/52Mlcxa+5AdIYqws1KyXRfMoB7A==",
"requires": {
"@babel/helper-member-expression-to-functions": "^7.10.1",
"@babel/helper-optimise-call-expression": "^7.10.1",
"@babel/traverse": "^7.10.1",
"@babel/types": "^7.10.1"
}
},
"@babel/helper-split-export-declaration": {
"version": "7.10.1",
"resolved": "https://registry.npmjs.org/@babel/helper-split-export-declaration/-/helper-split-export-declaration-7.10.1.tgz",
"integrity": "sha512-UQ1LVBPrYdbchNhLwj6fetj46BcFwfS4NllJo/1aJsT+1dLTEnXJL0qHqtY7gPzF8S2fXBJamf1biAXV3X077g==",
"requires": {
"@babel/types": "^7.10.1"
}
},
"@babel/highlight": {
"version": "7.10.3",
"resolved": "https://registry.npmjs.org/@babel/highlight/-/highlight-7.10.3.tgz",
"integrity": "sha512-Ih9B/u7AtgEnySE2L2F0Xm0GaM729XqqLfHkalTsbjXGyqmf/6M0Cu0WpvqueUlW+xk88BHw9Nkpj49naU+vWw==",
"requires": {
"@babel/helper-validator-identifier": "^7.10.3",
"chalk": "^2.0.0",
"js-tokens": "^4.0.0"
}
},
"@babel/parser": {
"version": "7.10.3",
"resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.10.3.tgz",
"integrity": "sha512-oJtNJCMFdIMwXGmx+KxuaD7i3b8uS7TTFYW/FNG2BT8m+fmGHoiPYoH0Pe3gya07WuFmM5FCDIr1x0irkD/hyA=="
},
"@babel/template": {
"version": "7.10.3",
"resolved": "https://registry.npmjs.org/@babel/template/-/template-7.10.3.tgz",
"integrity": "sha512-5BjI4gdtD+9fHZUsaxPHPNpwa+xRkDO7c7JbhYn2afvrkDu5SfAAbi9AIMXw2xEhO/BR35TqiW97IqNvCo/GqA==",
"requires": {
"@babel/code-frame": "^7.10.3",
"@babel/parser": "^7.10.3",
"@babel/types": "^7.10.3"
}
},
"@babel/traverse": {
"version": "7.10.3",
"resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.10.3.tgz",
"integrity": "sha512-qO6623eBFhuPm0TmmrUFMT1FulCmsSeJuVGhiLodk2raUDFhhTECLd9E9jC4LBIWziqt4wgF6KuXE4d+Jz9yug==",
"requires": {
"@babel/code-frame": "^7.10.3",
"@babel/generator": "^7.10.3",
"@babel/helper-function-name": "^7.10.3",
"@babel/helper-split-export-declaration": "^7.10.1",
"@babel/parser": "^7.10.3",
"@babel/types": "^7.10.3",
"debug": "^4.1.0",
"globals": "^11.1.0",
"lodash": "^4.17.13"
}
},
"@babel/types": {
"version": "7.10.3",
"resolved": "https://registry.npmjs.org/@babel/types/-/types-7.10.3.tgz",
"integrity": "sha512-nZxaJhBXBQ8HVoIcGsf9qWep3Oh3jCENK54V4mRF7qaJabVsAYdbTtmSD8WmAp1R6ytPiu5apMwSXyxB1WlaBA==",
"requires": {
"@babel/helper-validator-identifier": "^7.10.3",
"lodash": "^4.17.13",
"to-fast-properties": "^2.0.0"
}
}
}
},
"@babel/plugin-transform-unicode-regex": { "@babel/plugin-transform-unicode-regex": {
"version": "7.2.0", "version": "7.2.0",
"resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-regex/-/plugin-transform-unicode-regex-7.2.0.tgz", "resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-regex/-/plugin-transform-unicode-regex-7.2.0.tgz",
@@ -812,6 +985,22 @@
"@babel/plugin-transform-react-jsx-source": "^7.0.0" "@babel/plugin-transform-react-jsx-source": "^7.0.0"
} }
}, },
"@babel/preset-typescript": {
"version": "7.10.1",
"resolved": "https://registry.npmjs.org/@babel/preset-typescript/-/preset-typescript-7.10.1.tgz",
"integrity": "sha512-m6GV3y1ShiqxnyQj10600ZVOFrSSAa8HQ3qIUk2r+gcGtHTIRw0dJnFLt1WNXpKjtVw7yw1DAPU/6ma2ZvgJuA==",
"requires": {
"@babel/helper-plugin-utils": "^7.10.1",
"@babel/plugin-transform-typescript": "^7.10.1"
},
"dependencies": {
"@babel/helper-plugin-utils": {
"version": "7.10.3",
"resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.10.3.tgz",
"integrity": "sha512-j/+j8NAWUTxOtx4LKHybpSClxHoq6I91DQ/mKgAXn5oNUPIUiGppjPIX3TDtJWPrdfP9Kfl7e4fgVMiQR9VE/g=="
}
}
},
"@babel/runtime": { "@babel/runtime": {
"version": "7.1.5", "version": "7.1.5",
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.1.5.tgz", "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.1.5.tgz",
@@ -886,6 +1075,11 @@
"integrity": "sha512-rr+OQyAjxze7GgWrSaJwydHStIhHq2lvY3BOC2Mj7KnzI7XK0Uw1TOOdI9lDoajEbSWLiYgoo4f1R51erQfhPQ==", "integrity": "sha512-rr+OQyAjxze7GgWrSaJwydHStIhHq2lvY3BOC2Mj7KnzI7XK0Uw1TOOdI9lDoajEbSWLiYgoo4f1R51erQfhPQ==",
"dev": true "dev": true
}, },
"@types/mithril": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/@types/mithril/-/mithril-2.0.3.tgz",
"integrity": "sha512-cZHOdO2IiXYeyjeDYdbOisSdfaJRzfmRo3zVzgu33IWTMA0KEQObp9fdvqcuYdPz93iJ1yCl19GcEjo/9yv+yA=="
},
"@types/parse-json": { "@types/parse-json": {
"version": "4.0.0", "version": "4.0.0",
"resolved": "https://registry.npmjs.org/@types/parse-json/-/parse-json-4.0.0.tgz", "resolved": "https://registry.npmjs.org/@types/parse-json/-/parse-json-4.0.0.tgz",
@@ -1834,6 +2028,11 @@
"resolved": "https://registry.npmjs.org/cyclist/-/cyclist-1.0.1.tgz", "resolved": "https://registry.npmjs.org/cyclist/-/cyclist-1.0.1.tgz",
"integrity": "sha1-WW6WmP0MgOEgOMK4LW6xs1tiJNk=" "integrity": "sha1-WW6WmP0MgOEgOMK4LW6xs1tiJNk="
}, },
"dayjs": {
"version": "1.8.28",
"resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.8.28.tgz",
"integrity": "sha512-ccnYgKC0/hPSGXxj7Ju6AV/BP4HUkXC2u15mikXT5mX9YorEaoi1bEKOmAqdkJHN4EEkmAf97SpH66Try5Mbeg=="
},
"debug": { "debug": {
"version": "4.1.1", "version": "4.1.1",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.1.1.tgz", "resolved": "https://registry.npmjs.org/debug/-/debug-4.1.1.tgz",
@@ -1942,9 +2141,9 @@
"integrity": "sha512-De+lPAxEcpxvqPTyZAXELNpRZXABRxf+uL/rSykstQhzj/B0l1150G/ExIIxKc16lI89Hgz81J0BHAcbTqK49g==" "integrity": "sha512-De+lPAxEcpxvqPTyZAXELNpRZXABRxf+uL/rSykstQhzj/B0l1150G/ExIIxKc16lI89Hgz81J0BHAcbTqK49g=="
}, },
"elliptic": { "elliptic": {
"version": "6.5.2", "version": "6.5.3",
"resolved": "https://registry.npmjs.org/elliptic/-/elliptic-6.5.2.tgz", "resolved": "https://registry.npmjs.org/elliptic/-/elliptic-6.5.3.tgz",
"integrity": "sha512-f4x70okzZbIQl/NSRLkI/+tteV/9WqL98zx+SQ69KbXxmVrmjwsNUPn/gYJJ0sHvEak24cZgHIPegRePAtA/xw==", "integrity": "sha512-IMqzv5wNQf+E6aHeIqATs0tOLeOTwj1QKbRcS3jBbYkl5oLAserA8yJTT7/VyHUYG91PRmPyeQDObKLPpeS4dw==",
"requires": { "requires": {
"bn.js": "^4.4.0", "bn.js": "^4.4.0",
"brorand": "^1.0.1", "brorand": "^1.0.1",
@@ -3443,9 +3642,9 @@
} }
}, },
"lodash": { "lodash": {
"version": "4.17.15", "version": "4.17.19",
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.15.tgz", "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.19.tgz",
"integrity": "sha512-8xOcRHvCjnocdS5cpwXQXVzmmh5e5+saE2QGoeQmbKmRS6J3VQppPOIt0MnmE+4xlZoumy0GPG0D0MVIQbNA1A==" "integrity": "sha512-JNvd8XER9GQX0v2qJgsaN/mzFCNA5BRe/j8JN9d+tWyGLSodKQHKFicdwNYzWwI3wjRnaKPsGj1XkBjx/F96DQ=="
}, },
"lodash-es": { "lodash-es": {
"version": "4.17.14", "version": "4.17.14",
@@ -3613,9 +3812,9 @@
} }
}, },
"mithril": { "mithril": {
"version": "0.2.8", "version": "2.0.4",
"resolved": "https://registry.npmjs.org/mithril/-/mithril-0.2.8.tgz", "resolved": "https://registry.npmjs.org/mithril/-/mithril-2.0.4.tgz",
"integrity": "sha512-9XuGnVmS2OyFexUuP/CcJFFJjHLM+RGYBxyVRNyQ6khbMfDJIF/xyZ4zq18ZRfPagpFmWUFpjHd5ZqPULGZyNg==" "integrity": "sha512-mgw+DMZlhMS4PpprF6dl7ZoeZq5GGcAuWnrg5e12MvaGauc4jzWsDZtVGRCktsiQczOEUr2K5teKbE5k44RlOg=="
}, },
"mixin-deep": { "mixin-deep": {
"version": "1.3.2", "version": "1.3.2",
@@ -3644,11 +3843,6 @@
"minimist": "^1.2.5" "minimist": "^1.2.5"
} }
}, },
"moment": {
"version": "2.22.2",
"resolved": "https://registry.npmjs.org/moment/-/moment-2.22.2.tgz",
"integrity": "sha1-PCV/mDn8DpP/UxSWMiOeuQeD/2Y="
},
"move-concurrently": { "move-concurrently": {
"version": "1.0.1", "version": "1.0.1",
"resolved": "https://registry.npmjs.org/move-concurrently/-/move-concurrently-1.0.1.tgz", "resolved": "https://registry.npmjs.org/move-concurrently/-/move-concurrently-1.0.1.tgz",

View File

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

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

@@ -27,13 +27,18 @@ export default class AdminApplication extends Application {
* @inheritdoc * @inheritdoc
*/ */
mount() { mount() {
m.mount(document.getElementById('app-navigation'), Navigation.component({ className: 'App-backControl', drawer: true })); m.mount(document.getElementById('app-navigation'), { view: () => Navigation.component({ className: 'App-backControl', drawer: true }) });
m.mount(document.getElementById('header-navigation'), Navigation.component()); m.mount(document.getElementById('header-navigation'), Navigation);
m.mount(document.getElementById('header-primary'), HeaderPrimary.component()); m.mount(document.getElementById('header-primary'), HeaderPrimary);
m.mount(document.getElementById('header-secondary'), HeaderSecondary.component()); m.mount(document.getElementById('header-secondary'), HeaderSecondary);
m.mount(document.getElementById('admin-navigation'), AdminNav.component()); m.mount(document.getElementById('admin-navigation'), AdminNav);
// Mithril does not render the home route on https://example.com/admin, so
// we need to go to https://example.com/admin#/ explicitly.
if (!document.location.hash) document.location.hash = '#/';
m.route.prefix = '#';
m.route.mode = 'hash';
super.mount(); super.mount();
// If an extension has just been enabled, then we will run its settings // If an extension has just been enabled, then we will run its settings

View File

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

View File

@@ -31,62 +31,74 @@ export default class AdminNav extends Component {
items.add( items.add(
'dashboard', 'dashboard',
AdminLinkButton.component({ AdminLinkButton.component(
href: app.route('dashboard'), {
icon: 'far fa-chart-bar', href: app.route('dashboard'),
children: app.translator.trans('core.admin.nav.dashboard_button'), icon: 'far fa-chart-bar',
description: app.translator.trans('core.admin.nav.dashboard_text'), description: app.translator.trans('core.admin.nav.dashboard_text'),
}) },
app.translator.trans('core.admin.nav.dashboard_button')
)
); );
items.add( items.add(
'basics', 'basics',
AdminLinkButton.component({ AdminLinkButton.component(
href: app.route('basics'), {
icon: 'fas fa-pencil-alt', href: app.route('basics'),
children: app.translator.trans('core.admin.nav.basics_button'), icon: 'fas fa-pencil-alt',
description: app.translator.trans('core.admin.nav.basics_text'), description: app.translator.trans('core.admin.nav.basics_text'),
}) },
app.translator.trans('core.admin.nav.basics_button')
)
); );
items.add( items.add(
'mail', 'mail',
AdminLinkButton.component({ AdminLinkButton.component(
href: app.route('mail'), {
icon: 'fas fa-envelope', href: app.route('mail'),
children: app.translator.trans('core.admin.nav.email_button'), icon: 'fas fa-envelope',
description: app.translator.trans('core.admin.nav.email_text'), description: app.translator.trans('core.admin.nav.email_text'),
}) },
app.translator.trans('core.admin.nav.email_button')
)
); );
items.add( items.add(
'permissions', 'permissions',
AdminLinkButton.component({ AdminLinkButton.component(
href: app.route('permissions'), {
icon: 'fas fa-key', href: app.route('permissions'),
children: app.translator.trans('core.admin.nav.permissions_button'), icon: 'fas fa-key',
description: app.translator.trans('core.admin.nav.permissions_text'), description: app.translator.trans('core.admin.nav.permissions_text'),
}) },
app.translator.trans('core.admin.nav.permissions_button')
)
); );
items.add( items.add(
'appearance', 'appearance',
AdminLinkButton.component({ AdminLinkButton.component(
href: app.route('appearance'), {
icon: 'fas fa-paint-brush', href: app.route('appearance'),
children: app.translator.trans('core.admin.nav.appearance_button'), icon: 'fas fa-paint-brush',
description: app.translator.trans('core.admin.nav.appearance_text'), description: app.translator.trans('core.admin.nav.appearance_text'),
}) },
app.translator.trans('core.admin.nav.appearance_button')
)
); );
items.add( items.add(
'extensions', 'extensions',
AdminLinkButton.component({ AdminLinkButton.component(
href: app.route('extensions'), {
icon: 'fas fa-puzzle-piece', href: app.route('extensions'),
children: app.translator.trans('core.admin.nav.extensions_button'), icon: 'fas fa-puzzle-piece',
description: app.translator.trans('core.admin.nav.extensions_text'), description: app.translator.trans('core.admin.nav.extensions_text'),
}) },
app.translator.trans('core.admin.nav.extensions_button')
)
); );
return items; return items;

View File

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

View File

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

View File

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

View File

@@ -12,12 +12,14 @@ export default class ExtensionsPage extends Page {
<div className="ExtensionsPage"> <div className="ExtensionsPage">
<div className="ExtensionsPage-header"> <div className="ExtensionsPage-header">
<div className="container"> <div className="container">
{Button.component({ {Button.component(
children: app.translator.trans('core.admin.extensions.add_button'), {
icon: 'fas fa-plus', icon: 'fas fa-plus',
className: 'Button Button--primary', className: 'Button Button--primary',
onclick: () => app.modal.show(new AddExtensionModal()), onclick: () => app.modal.show(AddExtensionModal),
})} },
app.translator.trans('core.admin.extensions.add_button')
)}
</div> </div>
</div> </div>
@@ -72,31 +74,35 @@ export default class ExtensionsPage extends Page {
if (app.extensionSettings[name]) { if (app.extensionSettings[name]) {
items.add( items.add(
'settings', 'settings',
Button.component({ Button.component(
icon: 'fas fa-cog', {
children: app.translator.trans('core.admin.extensions.settings_button'), icon: 'fas fa-cog',
onclick: app.extensionSettings[name], onclick: app.extensionSettings[name],
}) },
app.translator.trans('core.admin.extensions.settings_button')
)
); );
} }
if (!enabled) { if (!enabled) {
items.add( items.add(
'uninstall', 'uninstall',
Button.component({ Button.component(
icon: 'far fa-trash-alt', {
children: app.translator.trans('core.admin.extensions.uninstall_button'), icon: 'far fa-trash-alt',
onclick: () => { onclick: () => {
app app
.request({ .request({
url: app.forum.attribute('apiUrl') + '/extensions/' + name, url: app.forum.attribute('apiUrl') + '/extensions/' + name,
method: 'DELETE', method: 'DELETE',
}) })
.then(() => window.location.reload()); .then(() => window.location.reload());
app.modal.show(new LoadingModal()); app.modal.show(LoadingModal);
},
}, },
}) app.translator.trans('core.admin.extensions.uninstall_button')
)
); );
} }
@@ -116,13 +122,33 @@ export default class ExtensionsPage extends Page {
.request({ .request({
url: app.forum.attribute('apiUrl') + '/extensions/' + id, url: app.forum.attribute('apiUrl') + '/extensions/' + id,
method: 'PATCH', method: 'PATCH',
data: { enabled: !enabled }, body: { enabled: !enabled },
errorHandler: this.onerror.bind(this),
}) })
.then(() => { .then(() => {
if (!enabled) localStorage.setItem('enabledExtension', id); if (!enabled) localStorage.setItem('enabledExtension', id);
window.location.reload(); window.location.reload();
}); });
app.modal.show(new LoadingModal()); app.modal.show(LoadingModal);
}
onerror(e) {
// We need to give the modal animation time to start; if we close the modal too early,
// it breaks the bootstrap modal library.
// TODO: This workaround should be removed when we move away from bootstrap JS for modals.
setTimeout(() => {
app.modal.close();
const error = JSON.parse(e.responseText).errors[0];
app.alerts.show(
{ type: 'error' },
app.translator.trans(`core.lib.error.${error.code}_message`, {
extension: error.extension,
extensions: error.extensions.join(', '),
})
);
}, 250);
} }
} }

View File

@@ -11,13 +11,6 @@ export default class HeaderSecondary extends Component {
return <ul className="Header-controls">{listItems(this.items().toArray())}</ul>; 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. * Build an item list for the controls.
* *

View File

@@ -1,9 +1,10 @@
import Modal from '../../common/components/Modal'; import Modal from '../../common/components/Modal';
export default class LoadingModal extends Modal { export default class LoadingModal extends Modal {
isDismissible() { /**
return false; * @inheritdoc
} */
static isDismissible = false;
className() { className() {
return 'LoadingModal Modal--small'; return 'LoadingModal Modal--small';

View File

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

View File

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

View File

@@ -6,7 +6,9 @@ import ItemList from '../../common/utils/ItemList';
import icon from '../../common/helpers/icon'; import icon from '../../common/helpers/icon';
export default class PermissionGrid extends Component { export default class PermissionGrid extends Component {
init() { oninit(vnode) {
super.oninit(vnode);
this.permissions = this.permissionItems().toArray(); this.permissions = this.permissionItems().toArray();
} }

View File

@@ -15,7 +15,7 @@ export default class PermissionsPage extends Page {
.all('groups') .all('groups')
.filter((group) => [Group.GUEST_ID, Group.MEMBER_ID].indexOf(group.id()) === -1) .filter((group) => [Group.GUEST_ID, Group.MEMBER_ID].indexOf(group.id()) === -1)
.map((group) => ( .map((group) => (
<button className="Button Group" onclick={() => app.modal.show(new EditGroupModal({ group }))}> <button className="Button Group" onclick={() => app.modal.show(EditGroupModal, { group })}>
{GroupBadge.component({ {GroupBadge.component({
group, group,
className: 'Group-icon', className: 'Group-icon',
@@ -24,7 +24,7 @@ export default class PermissionsPage extends Page {
<span className="Group-name">{group.namePlural()}</span> <span className="Group-name">{group.namePlural()}</span>
</button> </button>
))} ))}
<button className="Button Group Group--add" onclick={() => app.modal.show(new EditGroupModal())}> <button className="Button Group Group--add" onclick={() => app.modal.show(EditGroupModal)}>
{icon('fas fa-plus', { className: 'Group-icon' })} {icon('fas fa-plus', { className: 'Group-icon' })}
<span className="Group-name">{app.translator.trans('core.admin.permissions.new_group_button')}</span> <span className="Group-name">{app.translator.trans('core.admin.permissions.new_group_button')}</span>
</button> </button>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -12,11 +12,11 @@ import MailPage from './components/MailPage';
*/ */
export default function (app) { export default function (app) {
app.routes = { app.routes = {
dashboard: { path: '/', component: DashboardPage.component() }, dashboard: { path: '/', component: DashboardPage },
basics: { path: '/basics', component: BasicsPage.component() }, basics: { path: '/basics', component: BasicsPage },
permissions: { path: '/permissions', component: PermissionsPage.component() }, permissions: { path: '/permissions', component: PermissionsPage },
appearance: { path: '/appearance', component: AppearancePage.component() }, appearance: { path: '/appearance', component: AppearancePage },
extensions: { path: '/extensions', component: ExtensionsPage.component() }, extensions: { path: '/extensions', component: ExtensionsPage },
mail: { path: '/mail', component: MailPage.component() }, mail: { path: '/mail', component: MailPage },
}; };
} }

View File

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

View File

@@ -1,5 +1,4 @@
import ItemList from './utils/ItemList'; import ItemList from './utils/ItemList';
import Alert from './components/Alert';
import Button from './components/Button'; import Button from './components/Button';
import ModalManager from './components/ModalManager'; import ModalManager from './components/ModalManager';
import AlertManager from './components/AlertManager'; import AlertManager from './components/AlertManager';
@@ -23,6 +22,8 @@ import Group from './models/Group';
import Notification from './models/Notification'; import Notification from './models/Notification';
import { flattenDeep } from 'lodash-es'; import { flattenDeep } from 'lodash-es';
import PageState from './states/PageState'; 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 * The `App` class provides a container for an application, as well as various
@@ -109,13 +110,13 @@ export default class Application {
booted = false; booted = false;
/** /**
* An Alert that was shown as a result of an AJAX request error. If present, * The key for an Alert that was shown as a result of an AJAX request error.
* it will be dismissed on the next successful request. * If present, it will be dismissed on the next successful request.
* *
* @type {null|Alert} * @type {int}
* @private * @private
*/ */
requestError = null; requestErrorAlert = null;
/** /**
* The page the app is currently on. * The page the app is currently on.
@@ -139,6 +140,20 @@ export default class Application {
*/ */
previous = new PageState(null); 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; data;
title = ''; title = '';
@@ -174,8 +189,9 @@ export default class Application {
} }
mount(basePath = '') { mount(basePath = '') {
this.modal = m.mount(document.getElementById('modal'), <ModalManager />); // An object with a callable view property is used in order to pass arguments to the component; see https://mithril.js.org/mount.html
this.alerts = m.mount(document.getElementById('alerts'), <AlertManager />); 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(); this.drawer = new Drawer();
@@ -215,6 +231,16 @@ export default class Application {
return null; 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. * Set the <title> of the page.
* *
@@ -237,13 +263,16 @@ export default class Application {
} }
updateTitle() { updateTitle() {
document.title = (this.titleCount ? `(${this.titleCount}) ` : '') + (this.title ? this.title + ' - ' : '') + this.forum.attribute('title'); const count = this.titleCount ? `(${this.titleCount}) ` : '';
const pageTitleWithSeparator = this.title && m.route.get() !== '/' ? this.title + ' - ' : '';
const title = this.forum.attribute('title');
document.title = count + pageTitleWithSeparator + title;
} }
/** /**
* Make an AJAX request, handling any low-level errors that may occur. * Make an AJAX request, handling any low-level errors that may occur.
* *
* @see https://lhorie.github.io/mithril/mithril.request.html * @see https://mithril.js.org/request.html
* @param {Object} options * @param {Object} options
* @return {Promise} * @return {Promise}
* @public * @public
@@ -310,22 +339,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 // Now make the request. If it's a failure, inspect the error that was
// returned and show an alert containing its contents. // returned and show an alert containing its contents.
const deferred = m.deferred(); return m.request(options).then(
(response) => response,
m.request(options).then(
(response) => deferred.resolve(response),
(error) => { (error) => {
this.requestError = error; let content;
let children;
switch (error.status) { switch (error.status) {
case 422: case 422:
children = error.response.errors content = error.response.errors
.map((error) => [error.detail, <br />]) .map((error) => [error.detail, <br />])
.reduce((a, b) => a.concat(b), []) .reduce((a, b) => a.concat(b), [])
.slice(0, -1); .slice(0, -1);
@@ -333,36 +358,37 @@ export default class Application {
case 401: case 401:
case 403: case 403:
children = app.translator.trans('core.lib.error.permission_denied_message'); content = app.translator.trans('core.lib.error.permission_denied_message');
break; break;
case 404: case 404:
case 410: case 410:
children = app.translator.trans('core.lib.error.not_found_message'); content = app.translator.trans('core.lib.error.not_found_message');
break; break;
case 429: 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; break;
default: 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'); const isDebug = app.forum.attribute('debug');
// contains a formatted errors if possible, response must be an JSON API array of errors // 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' // 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', type: 'error',
children, content,
controls: isDebug && [ controls: isDebug && [
<Button className="Button Button--link" onclick={this.showDebug.bind(this, error, formattedError)}> <Button className="Button Button--link" onclick={this.showDebug.bind(this, error, formattedError)}>
Debug Debug
</Button>, </Button>,
], ],
}); };
try { try {
options.errorHandler(error); options.errorHandler(error);
@@ -378,14 +404,12 @@ export default class Application {
console.groupEnd(); 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;
} }
/** /**
@@ -394,9 +418,9 @@ export default class Application {
* @private * @private
*/ */
showDebug(error, formattedError) { 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 });
} }
/** /**
@@ -408,9 +432,19 @@ export default class Application {
* @public * @public
*/ */
route(name, params = {}) { route(name, params = {}) {
const url = this.routes[name].path.replace(/:([^\/]+)/g, (m, key) => extract(params, key)); const route = this.routes[name];
const queryString = m.route.buildQueryString(params);
const prefix = m.route.mode === 'pathname' ? app.forum.attribute('basePath') : ''; 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 : ''); 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) {}
}

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

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

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

View File

@@ -30,13 +30,13 @@ export default class Session {
* @return {Promise} * @return {Promise}
* @public * @public
*/ */
login(data, options = {}) { login(body, options = {}) {
return app.request( return app.request(
Object.assign( Object.assign(
{ {
method: 'POST', method: 'POST',
url: app.forum.attribute('baseUrl') + '/login', url: `${app.forum.attribute('baseUrl')}/login`,
data, body,
}, },
options options
) )
@@ -49,6 +49,6 @@ export default class Session {
* @public * @public
*/ */
logout() { 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 * @public
*/ */
find(type, id, query = {}, options = {}) { find(type, id, query = {}, options = {}) {
let data = query; let params = query;
let url = app.forum.attribute('apiUrl') + '/' + type; let url = app.forum.attribute('apiUrl') + '/' + type;
if (id instanceof Array) { if (id instanceof Array) {
url += '?filter[id]=' + id.join(','); url += '?filter[id]=' + id.join(',');
} else if (typeof id === 'object') { } else if (typeof id === 'object') {
data = id; params = id;
} else if (id) { } else if (id) {
url += '/' + id; url += '/' + id;
} }
@@ -99,7 +99,7 @@ export default class Store {
{ {
method: 'GET', method: 'GET',
url, url,
data, params,
}, },
options options
) )

View File

@@ -1,4 +1,3 @@
import User from './models/User';
import username from './helpers/username'; import username from './helpers/username';
import extract from './utils/extract'; 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')); const match = part.match(new RegExp('{([a-z0-9_]+)}|<(/?)([a-z0-9_]+)>', 'i'));
if (match) { if (match) {
// Either an opening or closing tag.
if (match[1]) { if (match[1]) {
open[0].push(input[match[1]]); open[0].push(input[match[1]]);
} else if (match[3]) { } else if (match[3]) {
if (match[2]) { 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(); open.shift();
} else { } 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: [] }; let tag = input[match[3]] || { tag: match[3], children: [] };
open[0].push(tag); 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); open.unshift(tag.children || tag);
} }
} }
} else { } 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); open[0].push(part);
} }
}); });

View File

@@ -12,15 +12,19 @@ import anchorScroll from './utils/anchorScroll';
import RequestError from './utils/RequestError'; import RequestError from './utils/RequestError';
import abbreviateNumber from './utils/abbreviateNumber'; import abbreviateNumber from './utils/abbreviateNumber';
import * as string from './utils/string'; import * as string from './utils/string';
import Stream from './utils/Stream';
import SubtreeRetainer from './utils/SubtreeRetainer'; import SubtreeRetainer from './utils/SubtreeRetainer';
import setRouteWithForcedRefresh from './utils/setRouteWithForcedRefresh';
import extract from './utils/extract'; import extract from './utils/extract';
import ScrollListener from './utils/ScrollListener'; import ScrollListener from './utils/ScrollListener';
import stringToColor from './utils/stringToColor'; import stringToColor from './utils/stringToColor';
import subclassOf from './utils/subclassOf';
import patchMithril from './utils/patchMithril'; import patchMithril from './utils/patchMithril';
import classList from './utils/classList'; import classList from './utils/classList';
import extractText from './utils/extractText'; import extractText from './utils/extractText';
import formatNumber from './utils/formatNumber'; import formatNumber from './utils/formatNumber';
import mapRoutes from './utils/mapRoutes'; import mapRoutes from './utils/mapRoutes';
import withAttr from './utils/withAttr';
import Notification from './models/Notification'; import Notification from './models/Notification';
import User from './models/User'; import User from './models/User';
import Post from './models/Post'; import Post from './models/Post';
@@ -43,6 +47,7 @@ import FieldSet from './components/FieldSet';
import Select from './components/Select'; import Select from './components/Select';
import Navigation from './components/Navigation'; import Navigation from './components/Navigation';
import Alert from './components/Alert'; import Alert from './components/Alert';
import Link from './components/Link';
import LinkButton from './components/LinkButton'; import LinkButton from './components/LinkButton';
import Checkbox from './components/Checkbox'; import Checkbox from './components/Checkbox';
import SelectDropdown from './components/SelectDropdown'; import SelectDropdown from './components/SelectDropdown';
@@ -61,6 +66,7 @@ import highlight from './helpers/highlight';
import username from './helpers/username'; import username from './helpers/username';
import userOnline from './helpers/userOnline'; import userOnline from './helpers/userOnline';
import listItems from './helpers/listItems'; import listItems from './helpers/listItems';
import Fragment from './Fragment';
export default { export default {
extend: extend, extend: extend,
@@ -81,11 +87,15 @@ export default {
'utils/extract': extract, 'utils/extract': extract,
'utils/ScrollListener': ScrollListener, 'utils/ScrollListener': ScrollListener,
'utils/stringToColor': stringToColor, 'utils/stringToColor': stringToColor,
'utils/Stream': Stream,
'utils/subclassOf': subclassOf,
'utils/setRouteWithForcedRefresh': setRouteWithForcedRefresh,
'utils/patchMithril': patchMithril, 'utils/patchMithril': patchMithril,
'utils/classList': classList, 'utils/classList': classList,
'utils/extractText': extractText, 'utils/extractText': extractText,
'utils/formatNumber': formatNumber, 'utils/formatNumber': formatNumber,
'utils/mapRoutes': mapRoutes, 'utils/mapRoutes': mapRoutes,
'utils/withAttr': withAttr,
'models/Notification': Notification, 'models/Notification': Notification,
'models/User': User, 'models/User': User,
'models/Post': Post, 'models/Post': Post,
@@ -93,6 +103,7 @@ export default {
'models/Group': Group, 'models/Group': Group,
'models/Forum': Forum, 'models/Forum': Forum,
Component: Component, Component: Component,
Fragment: Fragment,
Translator: Translator, Translator: Translator,
'components/AlertManager': AlertManager, 'components/AlertManager': AlertManager,
'components/Page': Page, 'components/Page': Page,
@@ -108,6 +119,7 @@ export default {
'components/Select': Select, 'components/Select': Select,
'components/Navigation': Navigation, 'components/Navigation': Navigation,
'components/Alert': Alert, 'components/Alert': Alert,
'components/Link': Link,
'components/LinkButton': LinkButton, 'components/LinkButton': LinkButton,
'components/Checkbox': Checkbox, 'components/Checkbox': Checkbox,
'components/SelectDropdown': SelectDropdown, 'components/SelectDropdown': SelectDropdown,

View File

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

View File

@@ -6,72 +6,23 @@ import Alert from './Alert';
* be shown and dismissed. * be shown and dismissed.
*/ */
export default class AlertManager extends Component { export default class AlertManager extends Component {
init() { oninit(vnode) {
/** super.oninit(vnode);
* An array of Alert components which are currently showing.
* this.state = this.attrs.state;
* @type {Alert[]}
* @protected
*/
this.components = [];
} }
view() { view() {
return ( return (
<div className="AlertManager"> <div className="AlertManager">
{this.components.map((component) => ( {Object.entries(this.state.getActiveAlerts()).map(([key, alert]) => (
<div className="AlertManager-alert">{component}</div> <div className="AlertManager-alert">
<alert.componentClass {...alert.attrs} ondismiss={this.state.dismiss.bind(this.state, key)}>
{alert.children}
</alert.componentClass>
</div>
))} ))}
</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 * The `Badge` component represents a user/discussion badge, indicating some
* status (e.g. a discussion is stickied, a user is an admin). * 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 * - `type` The type of badge this is. This will be used to give the badge a
* class name of `Badge--{type}`. * class name of `Badge--{type}`.
* - `icon` The name of an icon to show inside the badge. * - `icon` The name of an icon to show inside the badge.
* - `label` * - `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 { export default class Badge extends Component {
view() { view() {
const attrs = Object.assign({}, this.props); const attrs = Object.assign({}, this.attrs);
const type = extract(attrs, 'type'); const type = extract(attrs, 'type');
const iconName = extract(attrs, 'icon'); 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>; return <span {...attrs}>{iconName ? icon(iconName, { className: 'Badge-icon' }) : m.trust('&nbsp;')}</span>;
} }
config(isInitialized) { oncreate(vnode) {
if (isInitialized) return; 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 Component from '../Component';
import icon from '../helpers/icon'; import icon from '../helpers/icon';
import classList from '../utils/classList';
import extract from '../utils/extract'; import extract from '../utils/extract';
import extractText from '../utils/extractText'; import extractText from '../utils/extractText';
import LoadingIndicator from './LoadingIndicator'; import LoadingIndicator from './LoadingIndicator';
/** /**
* The `Button` component defines an element which, when clicked, performs an * 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 * - `icon` The name of the icon class. If specified, the button will be given a
* 'has-icon' class name. * 'has-icon' class name.
@@ -15,41 +18,38 @@ import LoadingIndicator from './LoadingIndicator';
* removed. * removed.
* - `loading` Whether or not the button should be in a disabled loading state. * - `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 * 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. * be used to represent any generic clickable control, like a menu item.
*/ */
export default class Button extends Component { export default class Button extends Component {
view() { view(vnode) {
const attrs = Object.assign({}, this.props); const attrs = Object.assign({}, this.attrs);
delete attrs.children;
attrs.className = attrs.className || '';
attrs.type = attrs.type || 'button'; attrs.type = attrs.type || 'button';
// If a tooltip was provided for buttons without additional content, we also // If a tooltip was provided for buttons without additional content, we also
// use this tooltip as text for screen readers // use this tooltip as text for screen readers
if (attrs.title && !this.props.children) { if (attrs.title && !vnode.children) {
attrs['aria-label'] = attrs.title; attrs['aria-label'] = attrs.title;
} }
// If nothing else is provided, we use the textual button content as tooltip // If nothing else is provided, we use the textual button content as tooltip
if (!attrs.title && this.props.children) { if (!attrs.title && vnode.children) {
attrs.title = extractText(this.props.children); attrs.title = extractText(vnode.children);
} }
const iconName = extract(attrs, 'icon'); const iconName = extract(attrs, 'icon');
if (iconName) attrs.className += ' hasIcon';
const loading = extract(attrs, 'loading'); const loading = extract(attrs, 'loading');
if (attrs.disabled || loading) { if (attrs.disabled || loading) {
attrs.className += ' disabled' + (loading ? ' loading' : '');
delete attrs.onclick; 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 +58,13 @@ export default class Button extends Component {
* @return {*} * @return {*}
* @protected * @protected
*/ */
getButtonContent() { getButtonContent(children) {
const iconName = this.props.icon; const iconName = this.attrs.icon;
return [ return [
iconName && iconName !== true ? icon(iconName, { className: 'Button-icon' }) : '', iconName && iconName !== true ? icon(iconName, { className: 'Button-icon' }) : '',
this.props.children ? <span className="Button-label">{this.props.children}</span> : '', children ? <span className="Button-label">{children}</span> : '',
this.props.loading ? LoadingIndicator.component({ size: 'tiny', className: 'LoadingIndicator--inline' }) : '', this.attrs.loading ? <LoadingIndicator size="tiny" className="LoadingIndicator--inline" /> : '',
]; ];
} }
} }

View File

@@ -1,11 +1,13 @@
import Component from '../Component'; import Component from '../Component';
import LoadingIndicator from './LoadingIndicator'; import LoadingIndicator from './LoadingIndicator';
import icon from '../helpers/icon'; import icon from '../helpers/icon';
import classList from '../utils/classList';
import withAttr from '../utils/withAttr';
/** /**
* The `Checkbox` component defines a checkbox input. * The `Checkbox` component defines a checkbox input.
* *
* ### Props * ### Attrs
* *
* - `state` Whether or not the checkbox is checked. * - `state` Whether or not the checkbox is checked.
* - `className` The class name for the root element. * - `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. * - `children` A text label to display next to the checkbox.
*/ */
export default class Checkbox extends Component { export default class Checkbox extends Component {
view() { view(vnode) {
let className = 'Checkbox ' + (this.props.state ? 'on' : 'off') + ' ' + (this.props.className || ''); // Sometimes, false is stored in the DB as '0'. This is a temporary
if (this.props.loading) className += ' loading'; // conversion layer until a more robust settings encoding is introduced
if (this.props.disabled) className += ' disabled'; 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 ( return (
<label className={className}> <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> <div className="Checkbox-display">{this.getDisplay()}</div>
{this.props.children} {vnode.children}
</label> </label>
); );
} }
@@ -36,7 +46,7 @@ export default class Checkbox extends Component {
* @protected * @protected
*/ */
getDisplay() { 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 * @protected
*/ */
onchange(checked) { 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 * The `Dropdown` component displays a button which, when clicked, shows a
* dropdown menu beneath it. * dropdown menu beneath it.
* *
* ### Props * ### Attrs
* *
* - `buttonClassName` A class name to apply to the dropdown toggle button. * - `buttonClassName` A class name to apply to the dropdown toggle button.
* - `menuClassName` A class name to apply to the dropdown menu. * - `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. * The children will be displayed as a list inside of the dropdown menu.
*/ */
export default class Dropdown extends Component { export default class Dropdown extends Component {
static initProps(props) { static initAttrs(attrs) {
super.initProps(props); attrs.className = attrs.className || '';
attrs.buttonClassName = attrs.buttonClassName || '';
props.className = props.className || ''; attrs.menuClassName = attrs.menuClassName || '';
props.buttonClassName = props.buttonClassName || ''; attrs.label = attrs.label || '';
props.menuClassName = props.menuClassName || ''; attrs.caretIcon = typeof attrs.caretIcon !== 'undefined' ? attrs.caretIcon : 'fas fa-caret-down';
props.label = props.label || '';
props.caretIcon = typeof props.caretIcon !== 'undefined' ? props.caretIcon : 'fas fa-caret-down';
} }
init() { oninit(vnode) {
super.oninit(vnode);
this.showing = false; this.showing = false;
} }
view() { view(vnode) {
const items = this.props.children ? listItems(this.props.children) : []; const items = vnode.children ? listItems(vnode.children) : [];
return ( return (
<div className={'ButtonGroup Dropdown dropdown ' + this.props.className + ' itemCount' + items.length + (this.showing ? ' open' : '')}> <div className={'ButtonGroup Dropdown dropdown ' + this.attrs.className + ' itemCount' + items.length + (this.showing ? ' open' : '')}>
{this.getButton()} {this.getButton(vnode.children)}
{this.getMenu(items)} {this.getMenu(items)}
</div> </div>
); );
} }
config(isInitialized) { oncreate(vnode) {
if (isInitialized) return; super.oncreate(vnode);
// When opening the dropdown menu, work out if the menu goes beyond the // 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 // 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.$().on('shown.bs.dropdown', () => {
this.showing = true; this.showing = true;
if (this.props.onshow) { if (this.attrs.onshow) {
this.props.onshow(); this.attrs.onshow();
} }
m.redraw(); m.redraw();
@@ -76,8 +76,8 @@ export default class Dropdown extends Component {
this.$().on('hidden.bs.dropdown', () => { this.$().on('hidden.bs.dropdown', () => {
this.showing = false; this.showing = false;
if (this.props.onhide) { if (this.attrs.onhide) {
this.props.onhide(); this.attrs.onhide();
} }
m.redraw(); m.redraw();
@@ -90,10 +90,10 @@ export default class Dropdown extends Component {
* @return {*} * @return {*}
* @protected * @protected
*/ */
getButton() { getButton(children) {
return ( return (
<button className={'Dropdown-toggle ' + this.props.buttonClassName} data-toggle="dropdown" onclick={this.props.onclick}> <button className={'Dropdown-toggle ' + this.attrs.buttonClassName} data-toggle="dropdown" onclick={this.attrs.onclick}>
{this.getButtonContent()} {this.getButtonContent(children)}
</button> </button>
); );
} }
@@ -104,15 +104,15 @@ export default class Dropdown extends Component {
* @return {*} * @return {*}
* @protected * @protected
*/ */
getButtonContent() { getButtonContent(children) {
return [ return [
this.props.icon ? icon(this.props.icon, { className: 'Button-icon' }) : '', this.attrs.icon ? icon(this.attrs.icon, { className: 'Button-icon' }) : '',
<span className="Button-label">{this.props.label}</span>, <span className="Button-label">{this.attrs.label}</span>,
this.props.caretIcon ? icon(this.props.caretIcon, { className: 'Button-caret' }) : '', this.attrs.caretIcon ? icon(this.attrs.caretIcon, { className: 'Button-caret' }) : '',
]; ];
} }
getMenu(items) { 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. * The children should be an array of items to show in the fieldset.
*/ */
export default class FieldSet extends Component { export default class FieldSet extends Component {
view() { view(vnode) {
return ( return (
<fieldset className={this.props.className}> <fieldset className={this.attrs.className}>
<legend>{this.props.label}</legend> <legend>{this.attrs.label}</legend>
<ul>{listItems(this.props.children)}</ul> <ul>{listItems(vnode.children)}</ul>
</fieldset> </fieldset>
); );
} }

View File

@@ -1,16 +1,16 @@
import Badge from './Badge'; import Badge from './Badge';
export default class GroupBadge extends Badge { export default class GroupBadge extends Badge {
static initProps(props) { static initAttrs(attrs) {
super.initProps(props); super.initAttrs(attrs);
if (props.group) { if (attrs.group) {
props.icon = props.group.icon(); attrs.icon = attrs.group.icon();
props.style = { backgroundColor: props.group.color() }; attrs.style = { backgroundColor: attrs.group.color() };
props.label = typeof props.label === 'undefined' ? props.group.nameSingular() : props.label; attrs.label = typeof attrs.label === 'undefined' ? attrs.group.nameSingular() : attrs.label;
props.type = 'group--' + props.group.id(); 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,11 +1,12 @@
import Button from './Button'; import Button from './Button';
import Link from './Link';
/** /**
* The `LinkButton` component defines a `Button` which links to a route. * The `LinkButton` component defines a `Button` which links to a route.
* *
* ### 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` Whether or not the page that this button links to is currently
* active. * active.
@@ -13,26 +14,28 @@ import Button from './Button';
* the `active` prop will automatically be set to true. * the `active` prop will automatically be set to true.
*/ */
export default class LinkButton extends Button { export default class LinkButton extends Button {
static initProps(props) { static initAttrs(attrs) {
props.active = this.isActive(props); super.initAttrs(attrs);
props.config = props.config || m.route;
attrs.active = this.isActive(attrs);
} }
view() { view(vnode) {
const vdom = super.view(); const vdom = super.view(vnode);
vdom.tag = 'a'; vdom.tag = Link;
vdom.attrs.active = String(vdom.attrs.active);
return vdom; 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} * @return {Boolean}
*/ */
static isActive(props) { static isActive(attrs) {
return typeof props.active !== 'undefined' ? props.active : m.route() === props.href; 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'; import { Spinner } from 'spin.js';
/** /**
* The `LoadingIndicator` component displays a loading spinner with spin.js. It * The `LoadingIndicator` component displays a loading spinner with spin.js.
* may have the following special props: *
* ### Attrs
* *
* - `size` The spin.js size preset to use. Defaults to 'small'. * - `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 { export default class LoadingIndicator extends Component {
view() { view() {
const attrs = Object.assign({}, this.props); const attrs = Object.assign({}, this.attrs);
attrs.className = 'LoadingIndicator ' + (attrs.className || ''); attrs.className = 'LoadingIndicator ' + (attrs.className || '');
delete attrs.size; delete attrs.size;
@@ -19,12 +20,12 @@ export default class LoadingIndicator extends Component {
return <div {...attrs}>{m.trust('&nbsp;')}</div>; return <div {...attrs}>{m.trust('&nbsp;')}</div>;
} }
config(isInitialized) { oncreate(vnode) {
if (isInitialized) return; super.oncreate(vnode);
const options = { zIndex: 'auto', color: this.$().css('color') }; const options = { zIndex: 'auto', color: this.$().css('color') };
switch (this.props.size) { switch (this.attrs.size) {
case 'large': case 'large':
Object.assign(options, { lines: 10, length: 8, width: 4, radius: 8 }); Object.assign(options, { lines: 10, length: 8, width: 4, radius: 8 });
break; break;

View File

@@ -9,24 +9,45 @@ import Button from './Button';
* @abstract * @abstract
*/ */
export default class Modal extends Component { export default class Modal extends Component {
init() { /**
/** * Determine whether or not the modal should be dismissible via an 'x' button.
* An alert component to show below the header. */
* static isDismissible = true;
* @type {Alert}
*/ /**
this.alert = null; * 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
return new Promise((resolve) => setTimeout(resolve, 1000));
}
} }
view() { view() {
if (this.alert) { if (this.alertAttrs) {
this.alert.props.dismissible = false; this.alertAttrs.dismissible = false;
} }
return ( return (
<div className={'Modal modal-dialog ' + this.className()}> <div className={'Modal modal-dialog ' + this.className()}>
<div className="Modal-content"> <div className="Modal-content">
{this.isDismissible() ? ( {this.constructor.isDismissible ? (
<div className="Modal-close App-backControl"> <div className="Modal-close App-backControl">
{Button.component({ {Button.component({
icon: 'fas fa-times', icon: 'fas fa-times',
@@ -43,7 +64,7 @@ export default class Modal extends Component {
<h3 className="App-titleControl App-titleControl--text">{this.title()}</h3> <h3 className="App-titleControl App-titleControl--text">{this.title()}</h3>
</div> </div>
{alert ? <div className="Modal-alert">{this.alert}</div> : ''} {this.alertAttrs ? <div className="Modal-alert">{Alert.component(this.alertAttrs)}</div> : ''}
{this.content()} {this.content()}
</form> </form>
@@ -52,15 +73,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. * Get the class name to apply to the modal.
* *
@@ -99,13 +111,11 @@ export default class Modal extends Component {
this.$('form').find('input, select, textarea').first().focus().select(); this.$('form').find('input, select, textarea').first().focus().select();
} }
onhide() {}
/** /**
* Hide the modal. * Hide the modal.
*/ */
hide() { hide() {
app.modal.close(); this.attrs.state.close();
} }
/** /**
@@ -123,7 +133,7 @@ export default class Modal extends Component {
* @param {RequestError} error * @param {RequestError} error
*/ */
onerror(error) { onerror(error) {
this.alert = error.alert; this.alertAttrs = error.alert;
m.redraw(); m.redraw();

View File

@@ -1,5 +1,4 @@
import Component from '../Component'; import Component from '../Component';
import Modal from './Modal';
/** /**
* The `ModalManager` component manages a modal dialog. Only one modal dialog * The `ModalManager` component manages a modal dialog. Only one modal dialog
@@ -7,46 +6,45 @@ import Modal from './Modal';
* overwrite the previous one. * overwrite the previous one.
*/ */
export default class ModalManager extends Component { export default class ModalManager extends Component {
init() {
this.showing = false;
this.component = null;
}
view() { 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) { oncreate(vnode) {
if (isInitialized) return; super.oncreate(vnode);
// Since this component is 'above' the content of the page (that is, it is a // Ensure the modal state is notified about a closed modal, even when the
// part of the global UI that persists between routes), we will flag the DOM // DOM-based Bootstrap JavaScript code triggered the closing of the modal,
// to be retained across route changes. // e.g. via ESC key or a click on the modal backdrop.
context.retain = true; this.$().on('hidden.bs.modal', this.attrs.state.close.bind(this.attrs.state));
this.$().on('hidden.bs.modal', this.clear.bind(this)).on('shown.bs.modal', this.onready.bind(this));
} }
/** animateShow(readyCallback) {
* Show a modal dialog. const dismissible = !!this.attrs.state.modal.componentClass.isDismissible;
*
* @param {Modal} component // If we are opening this modal while another modal is already open,
* @public // the shown event will not run, because the modal is already open.
*/ // So, we need to manually trigger the readyCallback.
show(component) { if (this.$().hasClass('in')) {
if (!(component instanceof Modal)) { readyCallback();
throw new Error('The ModalManager component can only show Modal components'); return;
} }
clearTimeout(this.hideTimeout);
this.showing = true;
this.component = component;
m.redraw(true);
const dismissible = !!this.component.isDismissible();
this.$() this.$()
.one('shown.bs.modal', readyCallback)
.modal({ .modal({
backdrop: dismissible || 'static', backdrop: dismissible || 'static',
keyboard: dismissible, keyboard: dismissible,
@@ -54,50 +52,7 @@ export default class ModalManager extends Component {
.modal('show'); .modal('show');
} }
/** animateHide() {
* Close the modal dialog. this.$().modal('hide');
*
* @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.$());
}
} }
} }

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 * If the app has a pane, it will also include a 'pin' button which toggles the
* pinned state of the pane. * 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. * - `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 * - `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 ( return (
<div <div
className={'Navigation ButtonGroup ' + (this.props.className || '')} className={'Navigation ButtonGroup ' + (this.attrs.className || '')}
onmouseenter={pane && pane.show.bind(pane)} onmouseenter={pane && pane.show.bind(pane)}
onmouseleave={pane && pane.onmouseleave.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. * Get the back button.
* *
@@ -54,7 +47,6 @@ export default class Navigation extends Component {
href: history.backUrl(), href: history.backUrl(),
icon: 'fas fa-chevron-left', icon: 'fas fa-chevron-left',
title: previous.title, title: previous.title,
config: () => {},
onclick: (e) => { onclick: (e) => {
if (e.shiftKey || e.ctrlKey || e.metaKey || e.which === 2) return; if (e.shiftKey || e.ctrlKey || e.metaKey || e.which === 2) return;
e.preventDefault(); e.preventDefault();
@@ -88,7 +80,7 @@ export default class Navigation extends Component {
* @protected * @protected
*/ */
getDrawerButton() { getDrawerButton() {
if (!this.props.drawer) return ''; if (!this.attrs.drawer) return '';
const { drawer } = app; const { drawer } = app;
const user = app.session.user; const user = app.session.user;

View File

@@ -7,12 +7,10 @@ import PageState from '../states/PageState';
* @abstract * @abstract
*/ */
export default class Page extends Component { export default class Page extends Component {
init() { oninit(vnode) {
app.previous = app.current; super.oninit(vnode);
app.current = new PageState(this.constructor);
app.drawer.hide(); this.onNewRoute();
app.modal.close();
/** /**
* A class name to apply to the body while the route is active. * A class name to apply to the body while the route is active.
@@ -22,13 +20,31 @@ export default class Page extends Component {
this.bodyClass = ''; this.bodyClass = '';
} }
config(isInitialized, context) { /**
if (isInitialized) return; * A collections of actions to run when the route changes.
* This is extracted here, and not hardcoded in oninit, as oninit is not called
* when a different route is handled by the same component, but we still need to
* adjust the current route name.
*/
onNewRoute() {
app.previous = app.current;
app.current = new PageState(this.constructor, { routeName: this.attrs.routeName });
app.drawer.hide();
app.modal.close();
}
oncreate(vnode) {
super.oncreate(vnode);
if (this.bodyClass) { if (this.bodyClass) {
$('#app').addClass(this.bodyClass); $('#app').addClass(this.bodyClass);
}
}
context.onunload = () => $('#app').removeClass(this.bodyClass); 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, * The `Placeholder` component displays a muted text with some call to action,
* usually used as an empty state. * usually used as an empty state.
* *
* ### Props * ### Attrs
* *
* - `text` * - `text`
*/ */
@@ -12,7 +12,7 @@ export default class Placeholder extends Component {
view() { view() {
return ( return (
<div className="Placeholder"> <div className="Placeholder">
<p>{this.props.text}</p> <p>{this.attrs.text}</p>
</div> </div>
); );
} }

View File

@@ -6,11 +6,11 @@ export default class RequestErrorModal extends Modal {
} }
title() { 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() { content() {
const { error, formattedError } = this.props; const { error, formattedError } = this.attrs;
let responseText; let responseText;
@@ -31,7 +31,7 @@ export default class RequestErrorModal extends Modal {
return ( return (
<div className="Modal-body"> <div className="Modal-body">
<pre> <pre>
{this.props.error.options.method} {this.props.error.options.url} {this.attrs.error.options.method} {this.attrs.error.options.url}
<br /> <br />
<br /> <br />
{responseText} {responseText}

View File

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

View File

@@ -1,31 +1,49 @@
import Dropdown from './Dropdown'; import Dropdown from './Dropdown';
import icon from '../helpers/icon'; 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;
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 * 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 * button's label is set as the label of the first child which has a truthy
* `active` prop. * `active` prop.
* *
* ### Props * ### Attrs
* *
* - `caretIcon` * - `caretIcon`
* - `defaultLabel` * - `defaultLabel`
*/ */
export default class SelectDropdown extends Dropdown { export default class SelectDropdown extends Dropdown {
static initProps(props) { static initAttrs(attrs) {
props.caretIcon = typeof props.caretIcon !== 'undefined' ? props.caretIcon : 'fas fa-sort'; 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() { getButtonContent(children) {
const activeChild = this.props.children.filter((child) => child.props.active)[0]; const activeChild = children.find(isActive);
let label = (activeChild && activeChild.props.children) || this.props.defaultLabel; let label = (activeChild && activeChild.children) || this.attrs.defaultLabel;
if (label instanceof Array) label = label[0]; 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. * is displayed as its own button prior to the toggle button.
*/ */
export default class SplitDropdown extends Dropdown { export default class SplitDropdown extends Dropdown {
static initProps(props) { static initAttrs(attrs) {
super.initProps(props); super.initAttrs(attrs);
props.className += ' Dropdown--split'; attrs.className += ' Dropdown--split';
props.menuClassName += ' Dropdown-menu--right'; attrs.menuClassName += ' Dropdown-menu--right';
} }
getButton() { getButton(children) {
// Make a copy of the props of the first child component. We will assign // Make a copy of the attrs of the first child component. We will assign
// these props to a new button, so that it has exactly the same behaviour as // these attrs to a new button, so that it has exactly the same behaviour as
// the first child. // the first child.
const firstChild = this.getFirstChild(); const firstChild = this.getFirstChild(children);
const buttonProps = Object.assign({}, firstChild.props); const buttonAttrs = Object.assign({}, firstChild.attrs);
buttonProps.className = (buttonProps.className || '') + ' SplitDropdown-button Button ' + this.props.buttonClassName; buttonAttrs.className = (buttonAttrs.className || '') + ' SplitDropdown-button Button ' + this.attrs.buttonClassName;
return [ return [
Button.component(buttonProps), Button.component(buttonAttrs, firstChild.children),
<button className={'Dropdown-toggle Button Button--icon ' + this.props.buttonClassName} data-toggle="dropdown"> <button className={'Dropdown-toggle Button Button--icon ' + this.attrs.buttonClassName} data-toggle="dropdown">
{icon(this.props.icon, { className: 'Button-icon' })} {icon(this.attrs.icon, { className: 'Button-icon' })}
{icon('fas fa-caret-down', { className: 'Button-caret' })} {icon('fas fa-caret-down', { className: 'Button-caret' })}
</button>, </button>,
]; ];
@@ -38,8 +38,8 @@ export default class SplitDropdown extends Dropdown {
* @return {*} * @return {*}
* @protected * @protected
*/ */
getFirstChild() { getFirstChild(children) {
let firstChild = this.props.children; let firstChild = children;
while (firstChild instanceof Array) firstChild = firstChild[0]; while (firstChild instanceof Array) firstChild = firstChild[0];

View File

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

View File

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

View File

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

View File

@@ -6,10 +6,10 @@
* @return {Object} * @return {Object}
*/ */
export default function fullTime(time) { export default function fullTime(time) {
const mo = moment(time); const d = dayjs(time);
const datetime = mo.format(); const datetime = d.format();
const full = mo.format('LLLL'); const full = d.format('LLLL');
return ( return (
<time pubdate datetime={datetime}> <time pubdate datetime={datetime}>

View File

@@ -9,10 +9,10 @@ import humanTimeUtil from '../utils/humanTime';
* @return {Object} * @return {Object}
*/ */
export default function humanTime(time) { export default function humanTime(time) {
const mo = moment(time); const d = dayjs(time);
const datetime = mo.format(); const datetime = d.format();
const full = mo.format('LLLL'); const full = d.format('LLLL');
const ago = humanTimeUtil(time); const ago = humanTimeUtil(time);
return ( 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

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

View File

@@ -1,6 +1,6 @@
import 'expose-loader?$!expose-loader?jQuery!jquery'; import 'expose-loader?$!expose-loader?jQuery!jquery';
import 'expose-loader?m!mithril'; import 'expose-loader?m!mithril';
import 'expose-loader?moment!moment'; import 'expose-loader?moment!expose-loader?dayjs!dayjs';
import 'expose-loader?m.bidi!m.attrs.bidi'; import 'expose-loader?m.bidi!m.attrs.bidi';
import 'bootstrap/js/affix'; import 'bootstrap/js/affix';
import 'bootstrap/js/dropdown'; import 'bootstrap/js/dropdown';
@@ -9,6 +9,12 @@ import 'bootstrap/js/tooltip';
import 'bootstrap/js/transition'; import 'bootstrap/js/transition';
import 'jquery.hotkeys/jquery.hotkeys'; 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'; import patchMithril from './utils/patchMithril';
patchMithril(window); patchMithril(window);

View File

@@ -54,7 +54,7 @@ Object.assign(User.prototype, {
* @public * @public
*/ */
isOnline() { isOnline() {
return this.lastSeenAt() > moment().subtract(5, 'minutes').toDate(); return dayjs().subtract(5, 'minutes').isBefore(this.lastSeenAt());
}, },
/** /**

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

View File

@@ -1,5 +1,9 @@
class Item { class Item {
constructor(content, priority) { content: any;
priority: number;
key?: number;
constructor(content: any, priority?: number) {
this.content = content; this.content = content;
this.priority = priority; this.priority = priority;
} }
@@ -10,23 +14,15 @@ class Item {
* by priority. * by priority.
*/ */
export default class ItemList { export default class ItemList {
constructor() { /**
/** * The items in the list
* The items in the list. */
* items: { [key: string]: Item } = {};
* @type {Object}
* @public
*/
this.items = {};
}
/** /**
* Check whether the list is empty. * Check whether the list is empty.
*
* @returns {boolean}
* @public
*/ */
isEmpty() { isEmpty(): boolean {
for (const i in this.items) { for (const i in this.items) {
if (this.items.hasOwnProperty(i)) { if (this.items.hasOwnProperty(i)) {
return false; return false;
@@ -38,36 +34,27 @@ export default class ItemList {
/** /**
* Check whether an item is present in the list. * Check whether an item is present in the list.
*
* @param key
* @returns {boolean}
*/ */
has(key) { has(key: string): boolean {
return !!this.items[key]; return !!this.items[key];
} }
/** /**
* Get the content of an item. * Get the content of an item.
*
* @param {String} key
* @return {*}
* @public
*/ */
get(key) { get(key: string): any {
return this.items[key].content; return this.items[key].content;
} }
/** /**
* Add an item to the list. * Add an item to the list.
* *
* @param {String} key A unique key for the item. * @param key A unique key for the item.
* @param {*} content The item's content. * @param content The item's content.
* @param {Integer} [priority] The priority of the item. Items with a higher * @param [priority] The priority of the item. Items with a higher
* priority will be positioned before items with a lower priority. * priority will be positioned before items with a lower priority.
* @return {ItemList}
* @public
*/ */
add(key, content, priority = 0) { add(key: string, content: any, priority: number = 0): this {
this.items[key] = new Item(content, priority); this.items[key] = new Item(content, priority);
return this; return this;
@@ -75,14 +62,8 @@ export default class ItemList {
/** /**
* Replace an item in the list, only if it is already present. * Replace an item in the list, only if it is already present.
*
* @param {String} key
* @param {*} [content]
* @param {Integer} [priority]
* @return {ItemList}
* @public
*/ */
replace(key, content = null, priority = null) { replace(key: string, content: any = null, priority: number = null): this {
if (this.items[key]) { if (this.items[key]) {
if (content !== null) { if (content !== null) {
this.items[key].content = content; this.items[key].content = content;
@@ -98,12 +79,8 @@ export default class ItemList {
/** /**
* Remove an item from the list. * Remove an item from the list.
*
* @param {String} key
* @return {ItemList}
* @public
*/ */
remove(key) { remove(key: string): this {
delete this.items[key]; delete this.items[key];
return this; return this;
@@ -111,12 +88,8 @@ export default class ItemList {
/** /**
* Merge another list's items into this one. * Merge another list's items into this one.
*
* @param {ItemList} items
* @return {ItemList}
* @public
*/ */
merge(items) { merge(items: this): this {
for (const i in items.items) { for (const i in items.items) {
if (items.items.hasOwnProperty(i) && items.items[i] instanceof Item) { if (items.items.hasOwnProperty(i) && items.items[i] instanceof Item) {
this.items[i] = items.items[i]; this.items[i] = items.items[i];
@@ -130,12 +103,9 @@ export default class ItemList {
* Convert the list into an array of item content arranged by priority. Each * Convert the list into an array of item content arranged by priority. Each
* item's content will be assigned an `itemName` property equal to the item's * item's content will be assigned an `itemName` property equal to the item's
* unique key. * unique key.
*
* @return {Array}
* @public
*/ */
toArray() { toArray(): any[] {
const items = []; const items: Item[] = [];
for (const i in this.items) { for (const i in this.items) {
if (this.items.hasOwnProperty(i) && this.items[i] instanceof Item) { if (this.items.hasOwnProperty(i) && this.items[i] instanceof Item) {

View File

@@ -1,5 +1,14 @@
export default class RequestError { export default class RequestError {
constructor(status, responseText, options, xhr) { status: string;
options: object;
xhr: XMLHttpRequest;
responseText: string | null;
response: object | null;
alert: any;
constructor(status: string, responseText: string | null, options: object, xhr: XMLHttpRequest) {
this.status = status; this.status = status;
this.responseText = responseText; this.responseText = responseText;
this.options = options; this.options = options;

View File

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

View File

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

View File

@@ -0,0 +1,109 @@
/**
* A textarea wrapper with powerful helpers for text manipulation.
*
* This wraps a <textarea> DOM element and allows directly manipulating its text
* contents and cursor positions.
*
* I apologize for the pretentious name. :)
*/
export default class SuperTextarea {
/**
* @param {HTMLTextAreaElement} textarea
*/
constructor(textarea) {
this.el = textarea;
this.$ = $(textarea);
}
/**
* Set the value of the text editor.
*
* @param {String} value
*/
setValue(value) {
this.$.val(value).trigger('input');
this.el.dispatchEvent(new CustomEvent('input', { bubbles: true, cancelable: true }));
}
/**
* Focus the textarea and place the cursor at the given index.
*
* @param {number} position
*/
moveCursorTo(position) {
this.setSelectionRange(position, position);
}
/**
* Get the selected range of the textarea.
*
* @return {Array}
*/
getSelectionRange() {
return [this.el.selectionStart, this.el.selectionEnd];
}
/**
* Insert content into the textarea at the position of the cursor.
*
* @param {String} text
*/
insertAtCursor(text) {
this.insertAt(this.el.selectionStart, text);
}
/**
* Insert content into the textarea at the given position.
*
* @param {number} pos
* @param {String} text
*/
insertAt(pos, text) {
this.insertBetween(pos, pos, text);
}
/**
* Insert content into the textarea between the given positions.
*
* If the start and end positions are different, any text between them will be
* overwritten.
*
* @param start
* @param end
* @param text
*/
insertBetween(start, end, text) {
const value = this.el.value;
const before = value.slice(0, start);
const after = value.slice(end);
this.setValue(`${before}${text}${after}`);
// Move the textarea cursor to the end of the content we just inserted.
this.moveCursorTo(start + text.length);
}
/**
* Replace existing content from the start to the current cursor position.
*
* @param start
* @param text
*/
replaceBeforeCursor(start, text) {
this.insertBetween(start, this.el.selectionStart, text);
}
/**
* Set the selected range of the textarea.
*
* @param {number} start
* @param {number} end
* @private
*/
setSelectionRange(start, end) {
this.el.setSelectionRange(start, end);
this.$.focus();
}
}

View File

@@ -4,16 +4,13 @@
* @example * @example
* abbreviateNumber(1234); * abbreviateNumber(1234);
* // "1.2K" * // "1.2K"
*
* @param {Integer} number
* @return {String}
*/ */
export default function abbreviateNumber(number) { export default function abbreviateNumber(number: number): string {
// TODO: translation // TODO: translation
if (number >= 1000000) { if (number >= 1000000) {
return Math.floor(number / 1000000) + app.translator.trans('core.lib.number_suffix.mega_text'); return Math.floor(number / 1000000) + app.translator.trans('core.lib.number_suffix.mega_text');
} else if (number >= 1000) { } else if (number >= 1000) {
return Math.floor(number / 1000) + app.translator.trans('core.lib.number_suffix.kilo_text'); return (number / 1000).toFixed(1) + app.translator.trans('core.lib.number_suffix.kilo_text');
} else { } else {
return number.toString(); return number.toString();
} }

View File

@@ -1,15 +0,0 @@
/**
* The `extract` utility deletes a property from an object and returns its
* value.
*
* @param {Object} object The object that owns the property
* @param {String} property The name of the property to extract
* @return {*} The value of the property
*/
export default function extract(object, property) {
const value = object[property];
delete object[property];
return value;
}

View File

@@ -0,0 +1,15 @@
/**
* The `extract` utility deletes a property from an object and returns its
* value.
*
* @param object The object that owns the property
* @param property The name of the property to extract
* @return The value of the property
*/
export default function extract<T, K extends keyof T>(object: T, property: K): T[K] {
const value = object[property];
delete object[property];
return value;
}

View File

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

View File

@@ -5,10 +5,7 @@
* @example * @example
* formatNumber(1234); * formatNumber(1234);
* // 1,234 * // 1,234
*
* @param {Number} number
* @return {String}
*/ */
export default function formatNumber(number) { export default function formatNumber(number: number): string {
return number.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ','); return number.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ',');
} }

View File

@@ -1,35 +1,32 @@
/** /**
* The `humanTime` utility converts a date to a localized, human-readable time- * The `humanTime` utility converts a date to a localized, human-readable time-
* ago string. * ago string.
*
* @param {Date} time
* @return {String}
*/ */
export default function humanTime(time) { export default function humanTime(time: Date): string {
let m = moment(time); let d = dayjs(time);
const now = moment(); const now = dayjs();
// To prevent showing things like "in a few seconds" due to small offsets // To prevent showing things like "in a few seconds" due to small offsets
// between client and server time, we always reset future dates to the // between client and server time, we always reset future dates to the
// current time. This will result in "just now" being shown instead. // current time. This will result in "just now" being shown instead.
if (m.isAfter(now)) { if (d.isAfter(now)) {
m = now; d = now;
} }
const day = 864e5; const day = 864e5;
const diff = m.diff(moment()); const diff = d.diff(dayjs());
let ago = null; let ago: string;
// If this date was more than a month ago, we'll show the name of the month // If this date was more than a month ago, we'll show the name of the month
// in the string. If it wasn't this year, we'll show the year as well. // in the string. If it wasn't this year, we'll show the year as well.
if (diff < -30 * day) { if (diff < -30 * day) {
if (m.year() === moment().year()) { if (d.year() === dayjs().year()) {
ago = m.format('D MMM'); ago = d.format('D MMM');
} else { } else {
ago = m.format('ll'); ago = d.format('ll');
} }
} else { } else {
ago = m.fromNow(); ago = d.fromNow();
} }
return ago; return ago;

View File

@@ -1,18 +1,18 @@
import humanTimeUtil from './humanTime'; import humanTime from './humanTime';
function updateHumanTimes() { function updateHumanTimes() {
$('[data-humantime]').each(function () { $('[data-humantime]').each(function () {
const $this = $(this); const $this = $(this);
const ago = humanTimeUtil($this.attr('datetime')); const ago = humanTime($this.attr('datetime'));
$this.html(ago); $this.html(ago);
}); });
} }
/** /**
* The `humanTime` initializer sets up a loop every 1 second to update * The `liveHumanTimes` initializer sets up a loop every 1 second to update
* timestamps rendered with the `humanTime` helper. * timestamps rendered with the `humanTime` helper.
*/ */
export default function humanTime() { export default function liveHumanTimes() {
setInterval(updateHumanTimes, 10000); setInterval(updateHumanTimes, 10000);
} }

View File

@@ -2,7 +2,7 @@
* The `mapRoutes` utility converts a map of named application routes into a * The `mapRoutes` utility converts a map of named application routes into a
* format that can be understood by Mithril. * format that can be understood by Mithril.
* *
* @see https://lhorie.github.io/mithril/mithril.route.html#defining-routes * @see https://mithril.js.org/route.html#signature
* @param {Object} routes * @param {Object} routes
* @param {String} [basePath] * @param {String} [basePath]
* @return {Object} * @return {Object}
@@ -13,9 +13,11 @@ export default function mapRoutes(routes, basePath = '') {
for (const key in routes) { for (const key in routes) {
const route = routes[key]; const route = routes[key];
if (route.component) route.component.props.routeName = key; map[basePath + route.path] = {
render() {
map[basePath + route.path] = route.component; return m(route.component, { routeName: key });
},
};
} }
return map; return map;

View File

@@ -1,45 +1,44 @@
import Component from '../Component'; import withAttr from './withAttr';
import Stream from './Stream';
let deprecatedMPropWarned = false;
let deprecatedMWithAttrWarned = false;
export default function patchMithril(global) { export default function patchMithril(global) {
const mo = global.m; const defaultMithril = global.m;
const m = function (comp, ...args) { const modifiedMithril = function (comp, ...args) {
if (comp.prototype && comp.prototype instanceof Component) { const node = defaultMithril.apply(this, arguments);
let children = args.slice(1);
if (children.length === 1 && Array.isArray(children[0])) {
children = children[0];
}
return comp.component(args[0], children); if (!node.attrs) node.attrs = {};
}
const node = mo.apply(this, arguments);
// Allows the use of the bidi attr.
if (node.attrs.bidi) { if (node.attrs.bidi) {
m.bidi(node, node.attrs.bidi); modifiedMithril.bidi(node, node.attrs.bidi);
}
if (node.attrs.route) {
node.attrs.href = node.attrs.route;
node.attrs.config = m.route;
delete node.attrs.route;
} }
return node; return node;
}; };
Object.keys(mo).forEach((key) => (m[key] = mo[key])); Object.keys(defaultMithril).forEach((key) => (modifiedMithril[key] = defaultMithril[key]));
/** // BEGIN DEPRECATED MITHRIL 2 BC LAYER
* Redraw only if not in the middle of a computation (e.g. a route change). modifiedMithril.prop = function (...args) {
* if (!deprecatedMPropWarned) {
* @return {void} deprecatedMPropWarned = true;
*/ console.warn('m.prop() is deprecated, please use the Stream util (flarum/utils/Streams) instead.');
m.lazyRedraw = function () { }
m.startComputation(); return Stream.bind(this)(...args);
m.endComputation();
}; };
global.m = m; modifiedMithril.withAttr = function (...args) {
if (!deprecatedMWithAttrWarned) {
deprecatedMWithAttrWarned = true;
console.warn("m.withAttr() is deprecated, please use flarum's withAttr util (flarum/utils/withAttr) instead.");
}
return withAttr.bind(this)(...args);
};
// END DEPRECATED MITHRIL 2 BC LAYER
global.m = modifiedMithril;
} }

View File

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

View File

@@ -1,12 +1,7 @@
/** /**
* Truncate a string to the given length, appending ellipses if necessary. * Truncate a string to the given length, appending ellipses if necessary.
*
* @param {String} string
* @param {Number} length
* @param {Number} [start=0]
* @return {String}
*/ */
export function truncate(string, length, start = 0) { export function truncate(string: string, length: number, start: number = 0): string {
return (start > 0 ? '...' : '') + string.substring(start, start + length) + (string.length > start + length ? '...' : ''); return (start > 0 ? '...' : '') + string.substring(start, start + length) + (string.length > start + length ? '...' : '');
} }
@@ -17,11 +12,8 @@ export function truncate(string, length, start = 0) {
* NOTE: This method does not use the comparably sophisticated transliteration * NOTE: This method does not use the comparably sophisticated transliteration
* mechanism that is employed in the backend. Therefore, it should only be used * mechanism that is employed in the backend. Therefore, it should only be used
* to *suggest* slugs that can be overridden by the user. * to *suggest* slugs that can be overridden by the user.
*
* @param {String} string
* @return {String}
*/ */
export function slug(string) { export function slug(string: string): string {
return string return string
.toLowerCase() .toLowerCase()
.replace(/[^a-z0-9]/gi, '-') .replace(/[^a-z0-9]/gi, '-')
@@ -32,11 +24,8 @@ export function slug(string) {
/** /**
* Strip HTML tags and quotes out of the given string, replacing them with * Strip HTML tags and quotes out of the given string, replacing them with
* meaningful punctuation. * meaningful punctuation.
*
* @param {String} string
* @return {String}
*/ */
export function getPlainContent(string) { export function getPlainContent(string: string): string {
const html = string.replace(/(<\/p>|<br>)/g, '$1 &nbsp;').replace(/<img\b[^>]*>/gi, ' '); const html = string.replace(/(<\/p>|<br>)/g, '$1 &nbsp;').replace(/<img\b[^>]*>/gi, ' ');
const dom = $('<div/>').html(html); const dom = $('<div/>').html(html);
@@ -55,10 +44,7 @@ getPlainContent.removeSelectors = ['blockquote', 'script'];
/** /**
* Make a string's first character uppercase. * Make a string's first character uppercase.
*
* @param {String} string
* @return {String}
*/ */
export function ucfirst(string) { export function ucfirst(string: string): string {
return string.substr(0, 1).toUpperCase() + string.substr(1); return string.substr(0, 1).toUpperCase() + string.substr(1);
} }

View File

@@ -1,4 +1,6 @@
function hsvToRgb(h, s, v) { type RGB = { r: number; g: number; b: number };
function hsvToRgb(h: number, s: number, v: number): RGB {
let r; let r;
let g; let g;
let b; let b;
@@ -51,11 +53,8 @@ function hsvToRgb(h, s, v) {
/** /**
* Convert the given string to a unique color. * Convert the given string to a unique color.
*
* @param {String} string
* @return {String}
*/ */
export default function stringToColor(string) { export default function stringToColor(string: string): string {
let num = 0; let num = 0;
// Convert the username into a number based on the ASCII value of each // Convert the username into a number based on the ASCII value of each

View File

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

View File

@@ -1,6 +1,5 @@
import History from './utils/History'; import History from './utils/History';
import Pane from './utils/Pane'; import Pane from './utils/Pane';
import ReplyComposer from './components/ReplyComposer';
import DiscussionPage from './components/DiscussionPage'; import DiscussionPage from './components/DiscussionPage';
import SignUpModal from './components/SignUpModal'; import SignUpModal from './components/SignUpModal';
import HeaderPrimary from './components/HeaderPrimary'; import HeaderPrimary from './components/HeaderPrimary';
@@ -15,7 +14,8 @@ import Application from '../common/Application';
import Navigation from '../common/components/Navigation'; import Navigation from '../common/components/Navigation';
import NotificationListState from './states/NotificationListState'; import NotificationListState from './states/NotificationListState';
import GlobalSearchState from './states/GlobalSearchState'; import GlobalSearchState from './states/GlobalSearchState';
import DiscussionListState from './state/DiscussionListState'; import DiscussionListState from './states/DiscussionListState';
import ComposerState from './states/ComposerState';
export default class ForumApplication extends Application { export default class ForumApplication extends Application {
/** /**
@@ -73,6 +73,11 @@ export default class ForumApplication extends Application {
*/ */
search = new GlobalSearchState(); search = new GlobalSearchState();
/*
* An object which controls the state of the composer.
*/
composer = new ComposerState();
constructor() { constructor() {
super(); super();
@@ -84,7 +89,7 @@ export default class ForumApplication extends Application {
* *
* @type {DiscussionListState} * @type {DiscussionListState}
*/ */
this.discussions = new DiscussionListState({ forumApp: this }); this.discussions = new DiscussionListState({}, this);
/** /**
* @deprecated beta 14, remove in beta 15. * @deprecated beta 14, remove in beta 15.
@@ -110,15 +115,15 @@ export default class ForumApplication extends Application {
this.routes[defaultAction].path = '/'; this.routes[defaultAction].path = '/';
this.history.push(defaultAction, this.translator.trans('core.forum.header.back_to_index_tooltip'), '/'); this.history.push(defaultAction, this.translator.trans('core.forum.header.back_to_index_tooltip'), '/');
m.mount(document.getElementById('app-navigation'), Navigation.component({ className: 'App-backControl', drawer: true })); m.mount(document.getElementById('app-navigation'), { view: () => Navigation.component({ className: 'App-backControl', drawer: true }) });
m.mount(document.getElementById('header-navigation'), Navigation.component()); m.mount(document.getElementById('header-navigation'), Navigation);
m.mount(document.getElementById('header-primary'), HeaderPrimary.component()); m.mount(document.getElementById('header-primary'), HeaderPrimary);
m.mount(document.getElementById('header-secondary'), HeaderSecondary.component()); m.mount(document.getElementById('header-secondary'), HeaderSecondary);
m.mount(document.getElementById('composer'), { view: () => Composer.component({ state: this.composer }) });
this.pane = new Pane(document.getElementById('app')); this.pane = new Pane(document.getElementById('app'));
this.composer = m.mount(document.getElementById('composer'), Composer.component());
m.route.mode = 'pathname'; m.route.prefix = '';
super.mount(this.forum.attribute('basePath')); super.mount(this.forum.attribute('basePath'));
alertEmailConfirmation(this); alertEmailConfirmation(this);
@@ -138,21 +143,6 @@ export default class ForumApplication extends Application {
}); });
} }
/**
* Check whether or not the user is currently composing a reply to a
* discussion.
*
* @param {Discussion} discussion
* @return {Boolean}
*/
composingReplyTo(discussion) {
return (
this.composer.component instanceof ReplyComposer &&
this.composer.component.props.discussion === discussion &&
this.composer.position !== Composer.PositionEnum.HIDDEN
);
}
/** /**
* Check whether or not the user is currently viewing a discussion. * Check whether or not the user is currently viewing a discussion.
* *
@@ -171,8 +161,8 @@ export default class ForumApplication extends Application {
* will be reloaded. Otherwise, a SignUpModal will be opened, prefilled * will be reloaded. Otherwise, a SignUpModal will be opened, prefilled
* with the provided details. * with the provided details.
* *
* @param {Object} payload A dictionary of props to pass into the sign up * @param {Object} payload A dictionary of attrs to pass into the sign up
* modal. A truthy `loggedIn` prop indicates that the user has logged * modal. A truthy `loggedIn` attr indicates that the user has logged
* in, and thus the page is reloaded. * in, and thus the page is reloaded.
* @public * @public
*/ */
@@ -180,8 +170,7 @@ export default class ForumApplication extends Application {
if (payload.loggedIn) { if (payload.loggedIn) {
window.location.reload(); window.location.reload();
} else { } else {
const modal = new SignUpModal(payload); this.modal.show(SignUpModal, payload);
this.modal.show(modal);
} }
} }
} }

View File

@@ -3,12 +3,18 @@ import compat from '../common/compat';
import PostControls from './utils/PostControls'; import PostControls from './utils/PostControls';
import KeyboardNavigatable from './utils/KeyboardNavigatable'; import KeyboardNavigatable from './utils/KeyboardNavigatable';
import slidable from './utils/slidable'; import slidable from './utils/slidable';
import affixSidebar from './utils/affixSidebar';
import History from './utils/History'; import History from './utils/History';
import DiscussionControls from './utils/DiscussionControls'; import DiscussionControls from './utils/DiscussionControls';
import alertEmailConfirmation from './utils/alertEmailConfirmation'; import alertEmailConfirmation from './utils/alertEmailConfirmation';
import UserControls from './utils/UserControls'; import UserControls from './utils/UserControls';
import Pane from './utils/Pane'; import Pane from './utils/Pane';
import ComposerState from './states/ComposerState';
import DiscussionListState from './states/DiscussionListState';
import GlobalSearchState from './states/GlobalSearchState';
import NotificationListState from './states/NotificationListState';
import PostStreamState from './states/PostStreamState';
import SearchState from './states/SearchState';
import AffixedSidebar from './components/AffixedSidebar';
import DiscussionPage from './components/DiscussionPage'; import DiscussionPage from './components/DiscussionPage';
import LogInModal from './components/LogInModal'; import LogInModal from './components/LogInModal';
import ComposerBody from './components/ComposerBody'; import ComposerBody from './components/ComposerBody';
@@ -55,6 +61,7 @@ import NotificationList from './components/NotificationList';
import WelcomeHero from './components/WelcomeHero'; import WelcomeHero from './components/WelcomeHero';
import SignUpModal from './components/SignUpModal'; import SignUpModal from './components/SignUpModal';
import CommentPost from './components/CommentPost'; import CommentPost from './components/CommentPost';
import ComposerPostPreview from './components/ComposerPostPreview';
import ReplyComposer from './components/ReplyComposer'; import ReplyComposer from './components/ReplyComposer';
import NotificationsPage from './components/NotificationsPage'; import NotificationsPage from './components/NotificationsPage';
import PostStreamScrubber from './components/PostStreamScrubber'; import PostStreamScrubber from './components/PostStreamScrubber';
@@ -71,12 +78,18 @@ export default Object.assign(compat, {
'utils/PostControls': PostControls, 'utils/PostControls': PostControls,
'utils/KeyboardNavigatable': KeyboardNavigatable, 'utils/KeyboardNavigatable': KeyboardNavigatable,
'utils/slidable': slidable, 'utils/slidable': slidable,
'utils/affixSidebar': affixSidebar,
'utils/History': History, 'utils/History': History,
'utils/DiscussionControls': DiscussionControls, 'utils/DiscussionControls': DiscussionControls,
'utils/alertEmailConfirmation': alertEmailConfirmation, 'utils/alertEmailConfirmation': alertEmailConfirmation,
'utils/UserControls': UserControls, 'utils/UserControls': UserControls,
'utils/Pane': Pane, 'utils/Pane': Pane,
'states/ComposerState': ComposerState,
'states/DiscussionListState': DiscussionListState,
'states/GlobalSearchState': GlobalSearchState,
'states/NotificationListState': NotificationListState,
'states/PostStreamState': PostStreamState,
'states/SearchState': SearchState,
'components/AffixedSidebar': AffixedSidebar,
'components/DiscussionPage': DiscussionPage, 'components/DiscussionPage': DiscussionPage,
'components/LogInModal': LogInModal, 'components/LogInModal': LogInModal,
'components/ComposerBody': ComposerBody, 'components/ComposerBody': ComposerBody,
@@ -123,6 +136,7 @@ export default Object.assign(compat, {
'components/WelcomeHero': WelcomeHero, 'components/WelcomeHero': WelcomeHero,
'components/SignUpModal': SignUpModal, 'components/SignUpModal': SignUpModal,
'components/CommentPost': CommentPost, 'components/CommentPost': CommentPost,
'components/ComposerPostPreview': ComposerPostPreview,
'components/ReplyComposer': ReplyComposer, 'components/ReplyComposer': ReplyComposer,
'components/NotificationsPage': NotificationsPage, 'components/NotificationsPage': NotificationsPage,
'components/PostStreamScrubber': PostStreamScrubber, 'components/PostStreamScrubber': PostStreamScrubber,

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -3,28 +3,23 @@ import ItemList from '../../common/utils/ItemList';
import ComposerButton from './ComposerButton'; import ComposerButton from './ComposerButton';
import listItems from '../../common/helpers/listItems'; import listItems from '../../common/helpers/listItems';
import classList from '../../common/utils/classList'; import classList from '../../common/utils/classList';
import ComposerState from '../states/ComposerState';
/** /**
* The `Composer` component displays the composer. It can be loaded with a * The `Composer` component displays the composer. It can be loaded with a
* content component with `load` and then its position/state can be altered with * content component with `load` and then its position/state can be altered with
* `show`, `hide`, `close`, `minimize`, `fullScreen`, and `exitFullScreen`. * `show`, `hide`, `close`, `minimize`, `fullScreen`, and `exitFullScreen`.
*/ */
class Composer extends Component { export default class Composer extends Component {
init() { oninit(vnode) {
/** super.oninit(vnode);
* The composer's current position.
*
* @type {Composer.PositionEnum}
*/
this.position = Composer.PositionEnum.HIDDEN;
/** /**
* The composer's intended height, which can be modified by the user * The composer's "state".
* (by dragging the composer handle).
* *
* @type {Integer} * @type {ComposerState}
*/ */
this.height = null; this.state = this.attrs.state;
/** /**
* Whether or not the composer currently has focus. * Whether or not the composer currently has focus.
@@ -32,48 +27,52 @@ class Composer extends Component {
* @type {Boolean} * @type {Boolean}
*/ */
this.active = false; this.active = false;
// Store the initial position so that we can trigger animations correctly.
this.prevPosition = this.state.position;
} }
view() { view() {
const body = this.state.body;
const classes = { const classes = {
normal: this.position === Composer.PositionEnum.NORMAL, normal: this.state.position === ComposerState.Position.NORMAL,
minimized: this.position === Composer.PositionEnum.MINIMIZED, minimized: this.state.position === ComposerState.Position.MINIMIZED,
fullScreen: this.position === Composer.PositionEnum.FULLSCREEN, fullScreen: this.state.position === ComposerState.Position.FULLSCREEN,
active: this.active, active: this.active,
visible: this.state.isVisible(),
}; };
classes.visible = classes.normal || classes.minimized || classes.fullScreen;
// If the composer is minimized, tell the composer's content component that // Set up a handler so that clicks on the content will show the composer.
// it shouldn't let the user interact with it. Set up a handler so that if const showIfMinimized = this.state.position === ComposerState.Position.MINIMIZED ? this.state.show.bind(this.state) : undefined;
// the content IS clicked, the composer will be shown.
if (this.component) this.component.props.disabled = classes.minimized;
const showIfMinimized = this.position === Composer.PositionEnum.MINIMIZED ? this.show.bind(this) : undefined;
return ( return (
<div className={'Composer ' + classList(classes)}> <div className={'Composer ' + classList(classes)}>
<div className="Composer-handle" config={this.configHandle.bind(this)} /> <div className="Composer-handle" oncreate={this.configHandle.bind(this)} />
<ul className="Composer-controls">{listItems(this.controlItems().toArray())}</ul> <ul className="Composer-controls">{listItems(this.controlItems().toArray())}</ul>
<div className="Composer-content" onclick={showIfMinimized}> <div className="Composer-content" onclick={showIfMinimized}>
{this.component ? this.component.render() : ''} {body.componentClass ? body.componentClass.component({ ...body.attrs, composer: this.state, disabled: classes.minimized }) : ''}
</div> </div>
</div> </div>
); );
} }
config(isInitialized, context) { onupdate() {
// Set the height of the Composer element and its contents on each redraw, if (this.state.position === this.prevPosition) {
// so that they do not lose it if their DOM elements are recreated. // Set the height of the Composer element and its contents on each redraw,
this.updateHeight(); // so that they do not lose it if their DOM elements are recreated.
this.updateHeight();
} else {
this.animatePositionChange();
if (isInitialized) return; this.prevPosition = this.state.position;
}
}
// Since this component is a part of the global UI that persists between oncreate(vnode) {
// routes, we will flag the DOM to be retained across route changes. super.oncreate(vnode);
context.retain = true;
this.initializeHeight(); this.initializeHeight();
this.$().hide().css('bottom', -this.computedHeight()); this.$().hide().css('bottom', -this.state.computedHeight());
// Whenever any of the inputs inside the composer are have focus, we want to // Whenever any of the inputs inside the composer are have focus, we want to
// add a class to the composer to draw attention to it. // add a class to the composer to draw attention to it.
@@ -83,45 +82,33 @@ class Composer extends Component {
}); });
// When the escape key is pressed on any inputs, close the composer. // When the escape key is pressed on any inputs, close the composer.
this.$().on('keydown', ':input', 'esc', () => this.close()); this.$().on('keydown', ':input', 'esc', () => this.state.close());
// Don't let the user leave the page without first giving the composer's this.handlers = {};
// component a chance to scream at the user to make sure they don't
// unintentionally lose any contnet.
window.onbeforeunload = () => {
return (this.component && this.component.preventExit()) || undefined;
};
const handlers = {};
$(window) $(window)
.on('resize', (handlers.onresize = this.updateHeight.bind(this))) .on('resize', (this.handlers.onresize = this.updateHeight.bind(this)))
.resize(); .resize();
$(document) $(document)
.on('mousemove', (handlers.onmousemove = this.onmousemove.bind(this))) .on('mousemove', (this.handlers.onmousemove = this.onmousemove.bind(this)))
.on('mouseup', (handlers.onmouseup = this.onmouseup.bind(this))); .on('mouseup', (this.handlers.onmouseup = this.onmouseup.bind(this)));
}
context.onunload = () => { onremove() {
$(window).off('resize', handlers.onresize); $(window).off('resize', this.handlers.onresize);
$(document).off('mousemove', handlers.onmousemove).off('mouseup', handlers.onmouseup); $(document).off('mousemove', this.handlers.onmousemove).off('mouseup', this.handlers.onmouseup);
};
} }
/** /**
* Add the necessary event handlers to the composer's handle so that it can * Add the necessary event handlers to the composer's handle so that it can
* be used to resize the composer. * be used to resize the composer.
*
* @param {DOMElement} element
* @param {Boolean} isInitialized
*/ */
configHandle(element, isInitialized) { configHandle(vnode) {
if (isInitialized) return;
const composer = this; const composer = this;
$(element) $(vnode.dom)
.css('cursor', 'row-resize') .css('cursor', 'row-resize')
.bind('dragstart mousedown', (e) => e.preventDefault()) .bind('dragstart mousedown', (e) => e.preventDefault())
.mousedown(function (e) { .mousedown(function (e) {
@@ -166,13 +153,20 @@ class Composer extends Component {
$('body').css('cursor', ''); $('body').css('cursor', '');
} }
/**
* Draw focus to the first focusable content element (the text editor).
*/
focus() {
this.$('.Composer-content :input:enabled:visible:first').focus();
}
/** /**
* Update the DOM to reflect the composer's current height. This involves * Update the DOM to reflect the composer's current height. This involves
* setting the height of the composer's root element, and adjusting the height * setting the height of the composer's root element, and adjusting the height
* of any flexible elements inside the composer's body. * of any flexible elements inside the composer's body.
*/ */
updateHeight() { updateHeight() {
const height = this.computedHeight(); const height = this.state.computedHeight();
const $flexible = this.$('.Composer-flexible'); const $flexible = this.$('.Composer-flexible');
this.$().height(height); this.$().height(height);
@@ -193,109 +187,59 @@ class Composer extends Component {
*/ */
updateBodyPadding() { updateBodyPadding() {
const visible = const visible =
this.position !== Composer.PositionEnum.HIDDEN && this.position !== Composer.PositionEnum.MINIMIZED && this.$().css('position') !== 'absolute'; this.state.position !== ComposerState.Position.HIDDEN && this.state.position !== ComposerState.Position.MINIMIZED && app.screen() !== 'phone';
const paddingBottom = visible ? this.computedHeight() - parseInt($('#app').css('padding-bottom'), 10) : 0; const paddingBottom = visible ? this.state.computedHeight() - parseInt($('#app').css('padding-bottom'), 10) : 0;
$('#content').css({ paddingBottom }); $('#content').css({ paddingBottom });
} }
/** /**
* Determine whether or not the Composer is covering the screen. * Trigger the right animation depending on the desired new position.
*
* This will be true if the Composer is in full-screen mode on desktop, or
* if the Composer is positioned absolutely as on mobile devices.
*
* @return {Boolean}
* @public
*/ */
isFullScreen() { animatePositionChange() {
return this.position === Composer.PositionEnum.FULLSCREEN || this.$().css('position') === 'absolute'; // When exiting full-screen mode: focus content
} if (this.prevPosition === ComposerState.Position.FULLSCREEN) {
this.focus();
return;
}
/** switch (this.state.position) {
* Confirm with the user that they want to close the composer and lose their case ComposerState.Position.HIDDEN:
* content. return this.hide();
* case ComposerState.Position.MINIMIZED:
* @return {Boolean} Whether or not the exit was cancelled. return this.minimize();
*/ case ComposerState.Position.FULLSCREEN:
preventExit() { return this.focus();
if (this.component) { case ComposerState.Position.NORMAL:
const preventExit = this.component.preventExit(); return this.show();
if (preventExit) {
return !confirm(preventExit);
}
} }
} }
/** /**
* Load a content component into the composer. * Animate the Composer into the new position by changing the height.
*
* @param {Component} component
* @public
*/ */
load(component) { animateHeightChange() {
if (this.preventExit()) return;
// If we load a similar component into the composer, then Mithril will be
// able to diff the old/new contents and some DOM-related state from the
// old composer will remain. To prevent this from happening, we clear the
// component and force a redraw, so that the new component will be working
// on a blank slate.
if (this.component) {
this.clear();
m.redraw(true);
}
this.component = component;
}
/**
* Clear the composer's content component.
*
* @public
*/
clear() {
this.component = null;
}
/**
* Animate the Composer into the given position.
*
* @param {Composer.PositionEnum} position
*/
animateToPosition(position) {
// Before we redraw the composer to its new state, we need to save the
// current height of the composer, as well as the page's scroll position, so
// that we can smoothly transition from the old to the new state.
const oldPosition = this.position;
const $composer = this.$().stop(true); const $composer = this.$().stop(true);
const oldHeight = $composer.outerHeight(); const oldHeight = $composer.outerHeight();
const scrollTop = $(window).scrollTop(); const scrollTop = $(window).scrollTop();
this.position = position;
m.redraw(true);
// Now that we've redrawn and the composer's DOM has been updated, we want
// to update the composer's height. Once we've done that, we'll capture the
// real value to use as the end point for our animation later on.
$composer.show(); $composer.show();
this.updateHeight(); this.updateHeight();
const newHeight = $composer.outerHeight(); const newHeight = $composer.outerHeight();
if (oldPosition === Composer.PositionEnum.HIDDEN) { if (this.prevPosition === ComposerState.Position.HIDDEN) {
$composer.css({ bottom: -newHeight, height: newHeight }); $composer.css({ bottom: -newHeight, height: newHeight });
} else { } else {
$composer.css({ height: oldHeight }); $composer.css({ height: oldHeight });
} }
$composer.animate({ bottom: 0, height: newHeight }, 'fast', () => this.component.focus()); const animation = $composer.animate({ bottom: 0, height: newHeight }, 'fast').promise();
this.updateBodyPadding(); this.updateBodyPadding();
$(window).scrollTop(scrollTop); $(window).scrollTop(scrollTop);
return animation;
} }
/** /**
@@ -313,40 +257,30 @@ class Composer extends Component {
} }
/** /**
* Show the composer. * Animate the composer sliding up from the bottom to take its normal height.
* *
* @public * @private
*/ */
show() { show() {
if (this.position === Composer.PositionEnum.NORMAL || this.position === Composer.PositionEnum.FULLSCREEN) { this.animateHeightChange().then(() => this.focus());
return;
}
this.animateToPosition(Composer.PositionEnum.NORMAL); if (app.screen() === 'phone') {
if (this.isFullScreen()) {
this.$().css('top', $(window).scrollTop()); this.$().css('top', $(window).scrollTop());
this.showBackdrop(); this.showBackdrop();
this.component.focus();
} }
} }
/** /**
* Close the composer. * Animate closing the composer.
* *
* @public * @private
*/ */
hide() { hide() {
const $composer = this.$(); const $composer = this.$();
// Animate the composer sliding down off the bottom edge of the viewport. // Animate the composer sliding down off the bottom edge of the viewport.
// Only when the animation is completed, update the Composer state flag and // Only when the animation is completed, update other elements on the page.
// other elements on the page.
$composer.stop(true).animate({ bottom: -$composer.height() }, 'fast', () => { $composer.stop(true).animate({ bottom: -$composer.height() }, 'fast', () => {
this.position = Composer.PositionEnum.HIDDEN;
this.clear();
m.redraw();
$composer.hide(); $composer.hide();
this.hideBackdrop(); this.hideBackdrop();
this.updateBodyPadding(); this.updateBodyPadding();
@@ -354,60 +288,17 @@ class Composer extends Component {
} }
/** /**
* Confirm with the user so they don't lose their content, then close the * Shrink the composer until only its title is visible.
* composer.
* *
* @public * @private
*/
close() {
if (!this.preventExit()) {
this.hide();
}
}
/**
* Minimize the composer. Has no effect if the composer is hidden.
*
* @public
*/ */
minimize() { minimize() {
if (this.position === Composer.PositionEnum.HIDDEN) return; this.animateHeightChange();
this.animateToPosition(Composer.PositionEnum.MINIMIZED);
this.$().css('top', 'auto'); this.$().css('top', 'auto');
this.hideBackdrop(); this.hideBackdrop();
} }
/**
* Take the composer into fullscreen mode. Has no effect if the composer is
* hidden.
*
* @public
*/
fullScreen() {
if (this.position !== Composer.PositionEnum.HIDDEN) {
this.position = Composer.PositionEnum.FULLSCREEN;
m.redraw();
this.updateHeight();
this.component.focus();
}
}
/**
* Exit fullscreen mode.
*
* @public
*/
exitFullScreen() {
if (this.position === Composer.PositionEnum.FULLSCREEN) {
this.position = Composer.PositionEnum.NORMAL;
m.redraw();
this.updateHeight();
this.component.focus();
}
}
/** /**
* Build an item list for the composer's controls. * Build an item list for the composer's controls.
* *
@@ -416,23 +307,23 @@ class Composer extends Component {
controlItems() { controlItems() {
const items = new ItemList(); const items = new ItemList();
if (this.position === Composer.PositionEnum.FULLSCREEN) { if (this.state.position === ComposerState.Position.FULLSCREEN) {
items.add( items.add(
'exitFullScreen', 'exitFullScreen',
ComposerButton.component({ ComposerButton.component({
icon: 'fas fa-compress', icon: 'fas fa-compress',
title: app.translator.trans('core.forum.composer.exit_full_screen_tooltip'), title: app.translator.trans('core.forum.composer.exit_full_screen_tooltip'),
onclick: this.exitFullScreen.bind(this), onclick: this.state.exitFullScreen.bind(this.state),
}) })
); );
} else { } else {
if (this.position !== Composer.PositionEnum.MINIMIZED) { if (this.state.position !== ComposerState.Position.MINIMIZED) {
items.add( items.add(
'minimize', 'minimize',
ComposerButton.component({ ComposerButton.component({
icon: 'fas fa-minus minimize', icon: 'fas fa-minus minimize',
title: app.translator.trans('core.forum.composer.minimize_tooltip'), title: app.translator.trans('core.forum.composer.minimize_tooltip'),
onclick: this.minimize.bind(this), onclick: this.state.minimize.bind(this.state),
itemClassName: 'App-backControl', itemClassName: 'App-backControl',
}) })
); );
@@ -442,7 +333,7 @@ class Composer extends Component {
ComposerButton.component({ ComposerButton.component({
icon: 'fas fa-expand', icon: 'fas fa-expand',
title: app.translator.trans('core.forum.composer.full_screen_tooltip'), title: app.translator.trans('core.forum.composer.full_screen_tooltip'),
onclick: this.fullScreen.bind(this), onclick: this.state.fullScreen.bind(this.state),
}) })
); );
} }
@@ -452,7 +343,7 @@ class Composer extends Component {
ComposerButton.component({ ComposerButton.component({
icon: 'fas fa-times', icon: 'fas fa-times',
title: app.translator.trans('core.forum.composer.close_tooltip'), title: app.translator.trans('core.forum.composer.close_tooltip'),
onclick: this.close.bind(this), onclick: this.state.close.bind(this.state),
}) })
); );
} }
@@ -464,10 +355,10 @@ class Composer extends Component {
* Initialize default Composer height. * Initialize default Composer height.
*/ */
initializeHeight() { initializeHeight() {
this.height = localStorage.getItem('composerHeight'); this.state.height = localStorage.getItem('composerHeight');
if (!this.height) { if (!this.state.height) {
this.height = this.defaultHeight(); this.state.height = this.defaultHeight();
} }
} }
@@ -479,60 +370,14 @@ class Composer extends Component {
return this.$().height(); return this.$().height();
} }
/**
* Minimum height of the Composer.
* @returns {Integer}
*/
minimumHeight() {
return 200;
}
/**
* Maxmimum height of the Composer.
* @returns {Integer}
*/
maximumHeight() {
return $(window).height() - $('#header').outerHeight();
}
/**
* Computed the composer's current height, based on the intended height, and
* the composer's current state. This will be applied to the composer's
* content's DOM element.
* @returns {Integer|String}
*/
computedHeight() {
// If the composer is minimized, then we don't want to set a height; we'll
// let the CSS decide how high it is. If it's fullscreen, then we need to
// make it as high as the window.
if (this.position === Composer.PositionEnum.MINIMIZED) {
return '';
} else if (this.position === Composer.PositionEnum.FULLSCREEN) {
return $(window).height();
}
// Otherwise, if it's normal or hidden, then we use the intended height.
// We don't let the composer get too small or too big, though.
return Math.max(this.minimumHeight(), Math.min(this.height, this.maximumHeight()));
}
/** /**
* Save a new Composer height and update the DOM. * Save a new Composer height and update the DOM.
* @param {Integer} height * @param {Integer} height
*/ */
changeHeight(height) { changeHeight(height) {
this.height = height; this.state.height = height;
this.updateHeight(); this.updateHeight();
localStorage.setItem('composerHeight', this.height); localStorage.setItem('composerHeight', this.state.height);
} }
} }
Composer.PositionEnum = {
HIDDEN: 'hidden',
NORMAL: 'normal',
MINIMIZED: 'minimized',
FULLSCREEN: 'fullScreen',
};
export default Composer;

View File

@@ -1,5 +1,6 @@
import Component from '../../common/Component'; import Component from '../../common/Component';
import LoadingIndicator from '../../common/components/LoadingIndicator'; import LoadingIndicator from '../../common/components/LoadingIndicator';
import ConfirmDocumentUnload from '../../common/components/ConfirmDocumentUnload';
import TextEditor from './TextEditor'; import TextEditor from './TextEditor';
import avatar from '../../common/helpers/avatar'; import avatar from '../../common/helpers/avatar';
import listItems from '../../common/helpers/listItems'; import listItems from '../../common/helpers/listItems';
@@ -10,8 +11,9 @@ import ItemList from '../../common/utils/ItemList';
* composer. Subclasses should implement the `onsubmit` method and override * composer. Subclasses should implement the `onsubmit` method and override
* `headerTimes`. * `headerTimes`.
* *
* ### Props * ### Attrs
* *
* - `composer`
* - `originalContent` * - `originalContent`
* - `submitLabel` * - `submitLabel`
* - `placeholder` * - `placeholder`
@@ -22,7 +24,11 @@ import ItemList from '../../common/utils/ItemList';
* @abstract * @abstract
*/ */
export default class ComposerBody extends Component { export default class ComposerBody extends Component {
init() { oninit(vnode) {
super.oninit(vnode);
this.composer = this.attrs.composer;
/** /**
* Whether or not the component is loading. * Whether or not the component is loading.
* *
@@ -30,60 +36,57 @@ export default class ComposerBody extends Component {
*/ */
this.loading = false; this.loading = false;
/** // Let the composer state know to ask for confirmation under certain
* The content of the text editor. // circumstances, if the body supports / requires it and has a corresponding
* // confirmation question to ask.
* @type {Function} if (this.attrs.confirmExit) {
*/ this.composer.preventClosingWhen(() => this.hasChanges(), this.attrs.confirmExit);
this.content = m.prop(this.props.originalContent); }
this.composer.fields.content(this.attrs.originalContent || '');
/** /**
* The text editor component instance. * @deprecated BC layer, remove in Beta 15.
*
* @type {TextEditor}
*/ */
this.editor = new TextEditor({ this.content = this.composer.fields.content;
submitLabel: this.props.submitLabel, this.editor = this.composer;
placeholder: this.props.placeholder,
onchange: this.content,
onsubmit: this.onsubmit.bind(this),
value: this.content(),
});
} }
view() { view() {
// If the component is loading, we should disable the text editor.
this.editor.props.disabled = this.loading;
return ( return (
<div className={'ComposerBody ' + (this.props.className || '')}> <ConfirmDocumentUnload when={this.hasChanges.bind(this)}>
{avatar(this.props.user, { className: 'ComposerBody-avatar' })} <div className={'ComposerBody ' + (this.attrs.className || '')}>
<div className="ComposerBody-content"> {avatar(this.attrs.user, { className: 'ComposerBody-avatar' })}
<ul className="ComposerBody-header">{listItems(this.headerItems().toArray())}</ul> <div className="ComposerBody-content">
<div className="ComposerBody-editor">{this.editor.render()}</div> <ul className="ComposerBody-header">{listItems(this.headerItems().toArray())}</ul>
<div className="ComposerBody-editor">
{TextEditor.component({
submitLabel: this.attrs.submitLabel,
placeholder: this.attrs.placeholder,
disabled: this.loading || this.attrs.disabled,
composer: this.composer,
preview: this.jumpToPreview && this.jumpToPreview.bind(this),
onchange: this.composer.fields.content,
onsubmit: this.onsubmit.bind(this),
value: this.composer.fields.content(),
})}
</div>
</div>
{LoadingIndicator.component({ className: 'ComposerBody-loading' + (this.loading ? ' active' : '') })}
</div> </div>
{LoadingIndicator.component({ className: 'ComposerBody-loading' + (this.loading ? ' active' : '') })} </ConfirmDocumentUnload>
</div>
); );
} }
/** /**
* Draw focus to the text editor. * Check if there is any unsaved data.
*/
focus() {
this.$(':input:enabled:visible:first').focus();
}
/**
* Check if there is any unsaved data if there is, return a confirmation
* message to prompt the user with.
* *
* @return {String} * @return {String}
*/ */
preventExit() { hasChanges() {
const content = this.content(); const content = this.composer.fields.content();
return content && content !== this.props.originalContent && this.props.confirmExit; return content && content !== this.attrs.originalContent;
} }
/** /**

View File

@@ -5,9 +5,9 @@ import Button from '../../common/components/Button';
* controls. * controls.
*/ */
export default class ComposerButton extends Button { export default class ComposerButton extends Button {
static initProps(props) { static initAttrs(attrs) {
super.initProps(props); super.initAttrs(attrs);
props.className = props.className || 'Button Button--icon Button--link'; attrs.className = attrs.className || 'Button Button--icon Button--link';
} }
} }

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