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

Compare commits

..

491 Commits

Author SHA1 Message Date
Franz Liedke
dd17fa3342 Remove unnecessary change 2020-09-18 22:27:11 +02:00
Franz Liedke
db8755d5d9 Remove unrelated change 2020-09-18 22:27:11 +02:00
Franz Liedke
5c2a233b74 Remove unnecessary conditional
The filter(Boolean) covers this.
2020-09-18 22:27:10 +02:00
Alexander Skvortsov
4dd0361ccf Remove unnecessary class name 2020-09-18 22:27:10 +02:00
Alexander Skvortsov
ccd2b0ee0b Revert diff reduction change
Sometimes, items passed to listItems might be null. We still need that filter check
2020-09-18 22:27:10 +02:00
Alexander Skvortsov
b954e79ec8 Move data from vnode to vnode.state
`vnode.state` is persisted, `vnode` might not be
2020-09-18 22:27:09 +02:00
Franz Liedke
bdc64f98a5 I want my small diff back! 2020-09-18 22:27:09 +02:00
Franz Liedke
f9e5aa4193 Email confirmation alert: Simplify implementation
Now that we don't mess with component instances anymore, we can follow
normal Mithril patterns.
2020-09-18 22:27:09 +02:00
Franz Liedke
801220cc10 Fix type annotation 2020-09-18 22:27:08 +02:00
Franz Liedke
bd52aaaa61 Fragment: Update documentation 2020-09-18 22:27:08 +02:00
Franz Liedke
4a5d12626b Revert "Remove deprecated "BC Layers", since BC is broken anyway"
This reverts commit 37c2956efae828e4f25448f4fdff33a79ab0aace.
2020-09-18 22:27:08 +02:00
Franz Liedke
1e4a7f3282 props -> attrs in code 2020-09-18 22:27:07 +02:00
Franz Liedke
cbcb0429d0 props -> attrs in comments 2020-09-18 22:27:07 +02:00
Franz Liedke
14b12f8ae8 Revert "Fix Post-actions being on top of Post Controls Dropdown"
This reverts commit fa29937d9e4dd868198bc2b003320c8431afed6f.
2020-09-18 22:27:07 +02:00
Franz Liedke
37d7068569 Revert "Run npm audit fix"
This reverts commit 791fd306e999066180cb67337566e17fa5e4bb1b.
2020-09-18 22:27:07 +02:00
Franz Liedke
6e1a9193ac Revert "infrastructure: Run npm audit fix"
This reverts commit ff1c52dcf84a4408fdc156a140f8718b401e7d7f.
2020-09-18 22:27:06 +02:00
Franz Liedke
ea6aea409d TS: Merge shims and tsconfig for now
This makes the structure less confusing, as long as the two parts are
not needed.

Once we actually have decided where to move the shared parts (to share
them with bundled and community extensions), and which parts to share,
this can easily be extracted again.
2020-09-18 22:27:06 +02:00
Franz Liedke
d572200cfb TS: Document shims files 2020-09-18 22:27:06 +02:00
Franz Liedke
e10ebf0489 TS config / shims: Fix indentation / whitespace 2020-09-18 22:27:05 +02:00
Franz Liedke
b251ff0469 Extend documentation 2020-09-18 22:27:05 +02:00
Franz Liedke
4a0947db4b TS: Be more specific in type assertion
See https://stackoverflow.com/a/33404548.
2020-09-18 22:27:05 +02:00
Alexander Skvortsov
32767d6321 Fix typescript errors 2020-09-18 22:27:04 +02:00
Alexander Skvortsov
27017b7181 Register jQuery with shims 2020-09-18 22:27:04 +02:00
Franz Liedke
bdd30ceecc Add documentation, keep lifecycle methods on top 2020-09-18 22:27:04 +02:00
Franz Liedke
abc98645a0 Fix typo, reduce diff 2020-09-18 22:27:03 +02:00
Franz Liedke
5d538adc33 Tweak Mithril documentation link 2020-09-18 22:27:03 +02:00
Franz Liedke
6f11567f91 Update comment
Who cares about Mithril 0.2? ;)
2020-09-18 22:27:03 +02:00
Alexander Skvortsov
537e2690e8 Replace get first element from filter with find 2020-09-18 22:27:03 +02:00
Franz Liedke
0e715a8c40 Restore comment
I love small diffs.
2020-09-18 22:27:02 +02:00
Franz Liedke
383a7e559f DiscussionListPane: Inject state, extract method 2020-09-18 22:27:02 +02:00
Franz Liedke
aeaa9a4b73 Reuse ComposerPostPreview in ReplyPlaceholder 2020-09-18 22:27:02 +02:00
Franz Liedke
85f8fc52b7 ComposerPostPreview: Add scroll anchoring callback
This will be needed to replace the `ReplyPlaceholderPreview` component.
2020-09-18 22:27:01 +02:00
Franz Liedke
174f3aba90 ComposerPostPreview: Allow injecting CSS class 2020-09-18 22:27:01 +02:00
Franz Liedke
47ce93d2fd Stop rendering previews when composer is hidden
This brings this component in line with the `ReplyPlaceholder` one.
2020-09-18 22:27:01 +02:00
Franz Liedke
24935eacaf ComposerPostPreview: Inject entire composer 2020-09-18 22:27:00 +02:00
Franz Liedke
0f99e7c015 Rename post preview component again
I want to reuse it for the reply placeholder as well, and that will
require injecting a composer state instance, so this naming seems to
make more sense.
2020-09-18 22:27:00 +02:00
Franz Liedke
691ae85e50 TextFormatterPreview: Add documentation 2020-09-18 22:27:00 +02:00
Franz Liedke
fb83f8c59c TextFormatterPreview: Pass in content callback
This makes the component itself less dependent on global state, which
has benefits for testability and reusability.
2020-09-18 22:26:59 +02:00
Franz Liedke
a6e6c54972 TextFormatterPreview: Expose in compat 2020-09-18 22:26:59 +02:00
Franz Liedke
6b5bdb5c41 Rename CommentPostPreview -> TextFormatterPreview
The idea is to be a bit more precise as to what this component does.
Also, there's already the `PostPreview` component which is actually a
link *to* a preview. Very confusing.
2020-09-18 22:26:59 +02:00
Alexander Skvortsov
d7ef260c54 Run code to rebuild tooltip oncreate AND onupdate 2020-09-18 22:26:58 +02:00
Alexander Skvortsov
571ed8d8e5 Don't store stuff on DOM elements. 2020-09-18 22:26:58 +02:00
Franz Liedke
42ad490096 mapRoutes: Fix outdated link to Mithril docs 2020-09-18 22:26:58 +02:00
Franz Liedke
09bead3ba2 Extend / correct documentation of SubtreeRetainer 2020-09-18 22:26:58 +02:00
Franz Liedke
abb896d430 Extend documentation of withAttr 2020-09-18 22:26:57 +02:00
Alexander Skvortsov
6e7c86ac50 Format 2020-09-18 22:26:57 +02:00
Alexander Skvortsov
2673dd2ee3 Don't show an error in console on promise rejection when not logged in and trying to reply / start a discussion 2020-09-18 22:26:57 +02:00
Alexander Skvortsov
cbd9c8dd4f Add a workaround for the blank SelectDropdown issue.
Workaround explained in the code.
2020-09-18 22:26:56 +02:00
Alexander Skvortsov
29d995de45 Fix NotificationList storing jquery objects 2020-09-18 22:26:42 +02:00
Franz Liedke
a5b2768836 Revert "Use some for needsRebuild in SubtreeRetainer"
This reverts commit 5318bd456856407dd92f44583bed5c88f0813a80.

My bad, I did not realize there was a side effect (storing stuff in
this.data) when suggesting this change. With that, using some() is much
less useful - plus it probably causes a bug because it does not always
iterate over all items once the callback returns true.
2020-09-18 22:26:42 +02:00
Alexander Skvortsov
398951f282 Fix SubtreeRetainer docblock 2020-09-18 22:26:42 +02:00
Alexander Skvortsov
07f04c7ba7 Use some for needsRebuild in SubtreeRetainer 2020-09-18 22:26:42 +02:00
Alexander Skvortsov
c8d5ca51bb Document AdminApplication location fix 2020-09-18 22:26:41 +02:00
Alexander Skvortsov
60dbd3f26c Significantly increase documentation for Component and Fragment 2020-09-18 22:26:41 +02:00
Alexander Skvortsov
e7f6e37799 Store $scrollParent and $notifications in the component state so we don't need to reobtain them 3 separate times 2020-09-18 22:26:41 +02:00
Alexander Skvortsov
27ffeb204e Add AffixedSidebar to compat 2020-09-18 22:26:40 +02:00
Alexander Skvortsov
884a5cf3b9 Run npm audit fix 2020-09-18 22:26:40 +02:00
Alexander Skvortsov
e895ca738d Move setRouteWithForcedRefresh util to common, add it to compat 2020-09-18 22:26:40 +02:00
Alexander Skvortsov
f41aec1043 Replace stray m.lazyRedraw and m.deferred on Model.js 2020-09-18 22:26:09 +02:00
Alexander Skvortsov
4c73d76668 Fix SuperTextarea instantiation: provide a DOM element, not a jquery collection 2020-09-18 22:26:09 +02:00
Alexander Skvortsov
f67194484b Fix going from one user page directly to another 2020-09-18 22:26:08 +02:00
Alexander Skvortsov
c8f47d519d Use the current route when when applying changeSort instead of always going to index 2020-09-18 22:26:08 +02:00
Alexander Skvortsov
e9b267a33a Call onRoute before refreshParams, so we can use app.current.get('routeName') in discussion params 2020-09-18 22:26:08 +02:00
Alexander Skvortsov
554c72c6db IndexPage: change routeName on app.current 2020-09-18 22:26:07 +02:00
Alexander Skvortsov
fb2b0a1d3e Replace leftover m.redraw(true) with m.redraw.sync() 2020-09-18 22:26:07 +02:00
Alexander Skvortsov
2ac18a39ed Make routeName available to pages again 2020-09-18 22:26:07 +02:00
Alexander Skvortsov
6ed3cb56d4 Fix ellipses button on comment post
The subtree retainer should take revealContent into account, so that when revealContent is toggled, it redraws the post
2020-09-18 22:26:06 +02:00
Alexander Skvortsov
038744f092 Introduce setRouteWithForcedRefresh util 2020-09-18 22:26:06 +02:00
Alexander Skvortsov
2edbd4508a Remove unnecessary vnode argument 2020-09-18 22:26:06 +02:00
Alexander Skvortsov
a6d4658dff Simplify TextEditor by moving oncreate from the textarea vnode in view to the component itself, and removing the redundant vnode lifecycle hooks (since those can be applied on the component as well) 2020-09-18 22:26:05 +02:00
Alexander Skvortsov
955c8121d3 Resolve import typo 2020-09-18 22:26:05 +02:00
Alexander Skvortsov
fd2dcd38d6 format 2020-09-18 22:26:05 +02:00
Alexander Skvortsov
520c7e7d0f Made texteditor actual textarea lifecycle methods extensible 2020-09-18 22:26:05 +02:00
Alexander Skvortsov
5ec9c52b04 formatting fix 2020-09-18 22:26:04 +02:00
Alexander Skvortsov
0341e64057 Make app.composer.show redraw synchronous, implement promises in reply, edit, and new discussion actions properly 2020-09-18 22:26:04 +02:00
Alexander Skvortsov
f1a480d3d7 Rename leftover props to attrs. ALthough this piece of code will probably be removed soon as per https://github.com/flarum/core/pull/2263 2020-09-18 22:25:42 +02:00
Alexander Skvortsov
aceac88013 Store bound handlers in this 2020-09-18 22:25:42 +02:00
Alexander Skvortsov
8cc9e18990 Fix undefined $scrollParent 2020-09-18 22:25:42 +02:00
Alexander Skvortsov
94d3bea53e Fix initAttrs being made static 2020-09-18 22:25:41 +02:00
Alexander Skvortsov
9e6cfcf05a Revert fix for opening modal from modal. Will be split to separate PR. 2020-09-18 22:25:41 +02:00
Alexander Skvortsov
b8abd2522e Add documentation for withAttr util 2020-09-18 22:25:41 +02:00
Alexander Skvortsov
376a00f24f make initAttrs static again 2020-09-18 22:25:40 +02:00
Alexander Skvortsov
43bfaa7400 Update SubtreeRetainer docblock 2020-09-18 22:25:40 +02:00
Alexander Skvortsov
7ba8a7122b Don't pass attrs to this.isProvided unnecessarily 2020-09-18 22:25:40 +02:00
Alexander Skvortsov
88bc291c86 Don't accept unnecessary vnode arguments in view 2020-09-18 22:25:39 +02:00
Alexander Skvortsov
e699ada1cc Don't pass attrs to Modal.content (we forgot to remove it, were experimenting with something else previously) 2020-09-18 22:25:39 +02:00
Alexander Skvortsov
2186584878 Fix m.route that should be m.route.set 2020-09-18 22:25:39 +02:00
Alexander Skvortsov
7beeae6269 typo fix 2020-09-18 22:25:38 +02:00
Alexander Skvortsov
e0e3d6ecae provide vnode to onupdate where it was actually needed 2020-09-18 22:25:38 +02:00
Alexander Skvortsov
f8bc58fd1a format 2020-09-18 22:25:38 +02:00
Alexander Skvortsov
764f50f469 Re-implement UsersSearchSource highlight logic without overriding vnode.children 2020-09-18 22:25:38 +02:00
Alexander Skvortsov
e8485db484 Revert removal of "controls" postfix in UserControls 2020-09-18 22:25:37 +02:00
Alexander Skvortsov
f017d7afbe Fix method that should have been oncreate initially being made as onupdate 2020-09-18 22:25:37 +02:00
Alexander Skvortsov
44c1e91f05 Call logic from onupdate in oncreate where relevant 2020-09-18 22:25:37 +02:00
Alexander Skvortsov
4b05e0073a Don't accept unnecessary vnode argument where it isn't used 2020-09-18 22:25:36 +02:00
Alexander Skvortsov
3764abee51 format 2020-09-18 22:25:36 +02:00
Alexander Skvortsov
015cedb29d Create new objects with spread when modifying vnode children instead of directly setting children when possible. 2020-09-18 22:25:36 +02:00
Alexander Skvortsov
32e9fa1f0b format 2020-09-18 22:25:35 +02:00
Alexander Skvortsov
2bdf0d7096 More consistent use of bidi (don't unecessarily define value 2020-09-18 22:25:35 +02:00
Alexander Skvortsov
b889fa1bbf format 2020-09-18 22:25:35 +02:00
Alexander Skvortsov
76c3494f9b Alert.js: move content definition back where it used to be 2020-09-18 22:25:35 +02:00
Alexander Skvortsov
6bcb76b914 Fix typo (and remove the whole unnecessary method) 2020-09-18 22:25:34 +02:00
Alexander Skvortsov
63984b43f9 Revert changes that have been separated out into https://github.com/flarum/core/pull/2262/files 2020-09-18 22:25:34 +02:00
Alexander Skvortsov
389bc59745 Formatting, docblock for Fragment 2020-09-18 22:25:18 +02:00
Alexander Skvortsov
2eb28ea396 Add fragment as parallel to component 2020-09-18 22:25:18 +02:00
Alexander Skvortsov
169b0fbd9b Fix modal backdrop not closing on app.modal.close() 2020-09-18 22:25:17 +02:00
Alexander Skvortsov
6b178b8204 Get rid of index filter route resolved (no longer necessary since filter route isn't a thing anymore) 2020-09-18 22:25:17 +02:00
Alexander Skvortsov
1d6e985107 Get rid of index.filter route 2020-09-18 22:24:27 +02:00
Alexander Skvortsov
f1fc0fecb7 Move title setting for index page to its own method so it can be used in reloads caused by route changes 2020-09-18 22:24:27 +02:00
David Sevilla Martin
94d8f7e726 remove unused attribute in DiscussionComposer input 2020-09-18 22:24:27 +02:00
David Sevilla Martin
b0891c42da create route resolver for /:filter (route overrides any others such as /tags, etc) 2020-09-18 22:24:26 +02:00
David Sevilla Martin
7d5bebb70a do not use custom route resolver solution for plain index page 2020-09-18 22:22:57 +02:00
David Sevilla Martin
b78db0268a update Component to error if 'tag' attribute is passed - messes with mithril 2020-09-18 22:22:56 +02:00
David Sevilla Martin
fcda092558 fix tsconfig.json 2020-09-18 22:22:56 +02:00
Alexander Skvortsov
f6dd87f72f Fix double-fade of posts in post stream by moving fadeIn to css 2020-09-18 22:22:56 +02:00
Alexander Skvortsov
d8d43d95e0 Remove unnecessary options=true, which breaks with patchMithril (used for a previous but now discarded patch) 2020-09-18 22:21:37 +02:00
David Sevilla Martin
51c7b17305 fix: add 'force' to UserPage sidebar buttons to refresh post/discussion lists 2020-09-18 22:21:37 +02:00
David Sevilla Martin
b592ceb199 update patchMithril to add 'force' attribute & not add endless browser history for same page 2020-09-18 22:21:36 +02:00
David Sevilla Martin
a083876b5f fix: DiscussionPage not updating when switching to a separate discussion (using pane) 2020-09-18 22:21:18 +02:00
David Sevilla Martin
b0cbe277c2 update IndexPage to clear discussions & refresh params using custom route solution 2020-09-18 22:21:18 +02:00
David Sevilla Martin
b1d948becc revert patchMithril changes for all links to re-call oninit 2020-09-18 22:21:18 +02:00
Alexander Skvortsov
a48568b17a Fix back button moving between posts in discussion 2020-09-18 22:21:17 +02:00
Alexander Skvortsov
e5cebd85ed update: admin/components/ExtensionsPage 2020-09-18 22:21:17 +02:00
Alexander Skvortsov
fbd5f6245b update: admin/components/AppearancePage 2020-09-18 22:21:17 +02:00
Alexander Skvortsov
3ce63bc035 update admin/components/SettingsModal 2020-09-18 22:21:16 +02:00
Alexander Skvortsov
bac5e7c94c update: admin/components/UploadImageButton 2020-09-18 22:21:16 +02:00
Alexander Skvortsov
97b0e61f61 update: admin/components/EditGroupModal 2020-09-18 22:21:16 +02:00
Alexander Skvortsov
604989be72 update: admin/components/PermissionGrid 2020-09-18 22:21:16 +02:00
Alexander Skvortsov
f9b1dfe499 update: admin/components/PermissionDropdown 2020-09-18 22:21:15 +02:00
Alexander Skvortsov
f611a44a08 update: admin/components/SettingDropdown 2020-09-18 22:21:15 +02:00
Alexander Skvortsov
911f1fd5c9 update: admin/components/MailPage 2020-09-18 22:21:15 +02:00
Alexander Skvortsov
0087b956ef update: admin/components/BasicsPage
- Not tested: locale selector, homepage selector, display name driver selector
2020-09-18 22:21:14 +02:00
Alexander Skvortsov
99b119f1fa update: admin/utils/saveSettings 2020-09-18 22:21:14 +02:00
Alexander Skvortsov
6be37fd376 update: admin/routes 2020-09-18 22:21:14 +02:00
Alexander Skvortsov
46741f63fe update: admin/components/AdminLinkButton 2020-09-18 22:21:13 +02:00
Alexander Skvortsov
cec00c0dd6 update: admin/components/AdminNav 2020-09-18 22:21:13 +02:00
Alexander Skvortsov
5f2c9da2f5 update: admin/components/SessionDropdown 2020-09-18 22:21:13 +02:00
Alexander Skvortsov
92de05e911 update: admin/components/HeaderSecondary 2020-09-18 22:21:12 +02:00
Alexander Skvortsov
68aa6e26da update: admin/AdminApplication 2020-09-18 22:21:12 +02:00
Alexander Skvortsov
8b7fa012c7 Remove workaround for m.route.Link vnode.text issue, as a more global patch has been added to patchMithril 2020-09-18 22:21:12 +02:00
Alexander Skvortsov
61231debd3 fix forum/components/DiscussionPage: page title not being updated when opening a discussion 2020-09-18 22:21:12 +02:00
Alexander Skvortsov
e771ec90c4 Remove patch for double oninit on DiscussionPage: we need to find and fix the underlying issue, or it will affect ALL links to posts/discussions 2020-09-18 22:21:11 +02:00
Alexander Skvortsov
ab85b49845 fix patchMithril: add Fix for m.route.Link with vnode.children not showing anything. 2020-09-18 22:21:11 +02:00
Alexander Skvortsov
04e5d5884f Patch DiscussionListItem to avoid double re-oninit when going to a discussion page 2020-09-18 22:21:11 +02:00
Alexander Skvortsov
ddc1141106 Fix empty error alerts. We might want to bring back the old order for alerts.show (children after attrs) 2020-09-18 22:21:10 +02:00
Alexander Skvortsov
a48cc19814 Remove unused config method from SessionDropdown 2020-09-18 22:21:10 +02:00
Alexander Skvortsov
6d18b700ec Rename config to oncreate for NotificationGrid 2020-09-18 22:21:10 +02:00
Alexander Skvortsov
b2bc427b3f fix: forum/components/ForgotPasswordModal (fix alert content) 2020-09-18 22:21:09 +02:00
Alexander Skvortsov
1f94ffc842 cleanup: common/components/Navigation (remove unused config 2020-09-18 22:21:09 +02:00
Alexander Skvortsov
3d91268493 Fix showing alert on user deletion (UserControls) 2020-09-18 22:21:09 +02:00
Alexander Skvortsov
81fd986881 forum/components/Notification: Support external links 2020-09-18 22:21:09 +02:00
Alexander Skvortsov
d1b0030292 update: forum/utils/alertEmailConfirmation
- ResendButton has been expanded into a full(er) component to avoid storing component instances, ContainedAlert has been removed
2020-09-18 22:21:08 +02:00
Alexander Skvortsov
2c395a781c update: forum/components/NotificationsPage 2020-09-18 22:21:08 +02:00
Alexander Skvortsov
21861f231b Remove deprecated "BC Layers", since BC is broken anyway 2020-09-18 22:21:08 +02:00
Alexander Skvortsov
537f5e833e update: common/components/Select 2020-09-18 22:21:07 +02:00
Alexander Skvortsov
4d45aaa9ae update: forum/components/LoginButton 2020-09-18 22:21:07 +02:00
Alexander Skvortsov
529f8e5f32 SubtreeRetainer: change docblock "this.props" to "this.attrs" 2020-09-18 22:21:07 +02:00
Alexander Skvortsov
991d90bf4a LoginModal: change references from props to attrs 2020-09-18 22:21:06 +02:00
Alexander Skvortsov
38fed603f8 Remove deprecated preferenceSaver from settingspage 2020-09-18 22:21:06 +02:00
Alexander Skvortsov
9691a6ab92 SignupModal: rename props to attrs 2020-09-18 22:21:06 +02:00
Alexander Skvortsov
e8e4b64d7d mapRoutes: remove obsolete setting of routeName attr on components 2020-09-18 22:21:06 +02:00
Alexander Skvortsov
35d76515d3 update: forum/components/TextEditorButton 2020-09-18 22:21:05 +02:00
Alexander Skvortsov
5d34124a02 replace config and href with route on Notification and NotificationList 2020-09-18 22:21:05 +02:00
Alexander Skvortsov
16a6f82e8f replace href and config with route on PostPreview (which is unused, also, update the component 2020-09-18 22:21:05 +02:00
Alexander Skvortsov
8a0c241a8e remove affixSidebar from compat.js 2020-09-18 22:21:04 +02:00
Alexander Skvortsov
a376c0e596 Rename UserPageSidebar to AffixedSidebar, make it a much more reusable component 2020-09-18 22:21:04 +02:00
Alexander Skvortsov
5ccf9d420e Remove affixSidebar util (now contained in UserPageSidebar component 2020-09-18 22:21:04 +02:00
Alexander Skvortsov
742f89f660 add: forum/components/UserPageSidebar
- extracted from UserPage, replaces affixSidebar util
2020-09-18 22:21:03 +02:00
Alexander Skvortsov
916cf4b546 fix: Composer height handle 2020-09-18 22:21:03 +02:00
Alexander Skvortsov
f127e67fd4 Update: EventPost, DiscussionRenamedPost 2020-09-18 22:21:03 +02:00
Alexander Skvortsov
3e79c3e3ff update: forum/components/EditUserModal 2020-09-18 22:21:02 +02:00
Alexander Skvortsov
0172dfd79c update: forum/components/ChangeEmailModal 2020-09-18 22:21:02 +02:00
Alexander Skvortsov
eb627544fa update:forum/components/ChangePasswordModal 2020-09-18 22:21:02 +02:00
Alexander Skvortsov
73c0a90da7 update: forum/components/RenameDiscussionModal 2020-09-18 22:21:01 +02:00
Alexander Skvortsov
ca0f8f2d72 Fix preview functionality of ReplyComposer and EditPostComposer
- options= true means that the init method of DiscussionPage will not be recalled (as per patchMithril
- DiscussionPage now caches the previous route, and compares it to the current one onupdate. If the route has change. We update this cached prevRoute in positionChanged to avoid unneessary goToNumber calls.
2020-09-18 22:21:01 +02:00
Alexander Skvortsov
090df13e7f fix: ComposerState (use m.redraw.sync() instead of m.redraw(true)) 2020-09-18 22:21:01 +02:00
Alexander Skvortsov
d1a1277f88 update: forum/components/EditPostComponent 2020-09-18 22:21:01 +02:00
Alexander Skvortsov
31b2ab1b2b update: forum/components/ReplyComposer 2020-09-18 22:21:00 +02:00
Alexander Skvortsov
2590073a50 fix: forum/components/TextEditor 2020-09-18 22:21:00 +02:00
Alexander Skvortsov
4402dc81ac wip fix: forum/components/DiscussionPage
- rename onunload to onremove. We are still unable to cancel the unload though.
2020-09-18 22:21:00 +02:00
Alexander Skvortsov
f93a255a2f fix: ConfirmDocumentUnload 2020-09-18 22:20:59 +02:00
Alexander Skvortsov
ddb0a9f1ce update/fix: forum/components/SplitDropdown 2020-09-18 22:20:59 +02:00
Alexander Skvortsov
f44caf1600 update: forum/components/PostStreamScrubber 2020-09-18 22:20:59 +02:00
Alexander Skvortsov
89b6847710 update: forum/components/PostStream 2020-09-18 22:20:58 +02:00
Alexander Skvortsov
2fb885175a update: forum/components/TextEditor 2020-09-18 22:20:04 +02:00
Alexander Skvortsov
7d9db2f4ae update: forum/components/CommentPost
- removed now irrelevant Mithril 0.1 workaround for ul jsx
- Move CommentPostPreview into its own class
2020-09-18 22:20:04 +02:00
Alexander Skvortsov
5d073941c9 formatting fix 2020-09-18 22:20:03 +02:00
Alexander Skvortsov
f664fa5be7 update: forum/utils/PostControls 2020-09-18 22:20:03 +02:00
Alexander Skvortsov
aa4b58d7aa update: forum/components/PostMeta 2020-09-18 22:20:03 +02:00
Alexander Skvortsov
3120eb6f63 update: forum/components/PostEdited 2020-09-18 22:20:02 +02:00
Alexander Skvortsov
aac54a1d28 update: forum/components/DiscussionHero 2020-09-18 22:20:02 +02:00
Alexander Skvortsov
0d0841d019 formatting fix 2020-09-18 22:20:02 +02:00
Alexander Skvortsov
83d2dbd290 Fix: extend Component in DiscussionListPane 2020-09-18 22:20:02 +02:00
Alexander Skvortsov
7e5b40c532 Fix: extend Component in ReplyPlaceholderComponent 2020-09-18 22:20:01 +02:00
Alexander Skvortsov
6c9971eeba add: forum/components/CommentPostPreview
- This has been extracted from CommentPost
2020-09-18 22:20:01 +02:00
Alexander Skvortsov
37a690833a update: forum/components/Post
- attrs has been renamed to elementAttrs
- As subtree retention is now implemented via onbeforeupdate, view no longer needs to return a retain vnode, and as such, has been significantly simplified
2020-09-18 22:20:01 +02:00
Alexander Skvortsov
fa2301b5c1 update: forum/components/DiscussionPage
- The discussion list pane has been extracted to the DiscussionListPane component
2020-09-18 22:20:00 +02:00
Alexander Skvortsov
edca7b93ec add forum/components/DiscussionListPane
- Extract this from DiscussionPage
2020-09-18 22:20:00 +02:00
Alexander Skvortsov
095dce9a3e Mount AlertManager in Application 2020-09-18 22:20:00 +02:00
Alexander Skvortsov
479f655bb3 update: common/components/AlertManager 2020-09-18 22:19:59 +02:00
Alexander Skvortsov
41d6e91318 update: common/states/AlertManagerState
- Children is now the first argument of the alerts.show function, since it's no longer managed through attrs. It's also more relvant than attrs, which is why I put it first, but we can definitely reverse the two.
2020-09-18 22:19:59 +02:00
Alexander Skvortsov
87414995b6 fix: forum/components/UserSearchSource
- Use route attr instead of href and config
2020-09-18 22:19:59 +02:00
Alexander Skvortsov
2c93b5f801 update: forum/components/PostUser 2020-09-18 22:19:59 +02:00
Alexander Skvortsov
7498f5e506 fix: forum/components/UserCard
- Use route attr instead of href and config
2020-09-18 22:19:58 +02:00
Alexander Skvortsov
79f5291f04 update: forum/states/PostStreamState
- Change m.deferred to native promise
2020-09-18 22:19:58 +02:00
Alexander Skvortsov
ed3b923f58 update: forum/components/ReplyPlaceholder
- The preview has been extracted to ReplyPlaceholderPreview
2020-09-18 22:19:58 +02:00
Alexander Skvortsov
1ce06611ce add: Extract ReplyPlaceholderPreview from ReplyPlaceholder 2020-09-18 22:19:57 +02:00
Alexander Skvortsov
27bacd779b Update patchMithril modified link documentation with a better link to mithril's docs. 2020-09-18 22:19:57 +02:00
Alexander Skvortsov
02acacfdcb Merge branch 'mithril-2-update' of github.com:flarum/core into mithril-2-update 2020-09-18 22:19:57 +02:00
Alexander Skvortsov
23d95a7566 Add Pane to ForumApplication 2020-09-18 22:19:56 +02:00
Alexander Skvortsov
43164df79e formatting fix 2020-09-18 22:19:56 +02:00
Alexander Skvortsov
46e704b27b update: IndexPage (move onunload contents into onremove) 2020-09-18 22:19:56 +02:00
David Sevilla Martin
e55867acb4 format 2020-09-18 22:19:55 +02:00
Alexander Skvortsov
3596425bde update: History (re-init component even if already on index page) 2020-09-18 22:19:55 +02:00
David Sevilla Martin
b43452223f fix: Button not appearing disabled when loading 2020-09-18 22:19:55 +02:00
Alexander Skvortsov
70697be8c0 infrastructure: (mostly) force re-calling oninit when a route change is handled by the same component
- Due to mithril 2.0, setting a route will not re-call oninit if the component handling the route has not changed.
- Mithril allows us to provide an options parameter to m.route.set, which, if has the state.key parameter changed, will force a re-oninit. However, manually implementing this on every button and component is both tedious, and will make further changes in functionality difficult
- To that end, we can add in this patch here, which will take care of most cases. Code that explicitly calls m.route.set will still need to include this options parameter.
2020-09-18 22:19:55 +02:00
Alexander Skvortsov
c20ae678f5 Move config method of IndexPage into oncreate and onremove 2020-09-18 22:19:54 +02:00
Alexander Skvortsov
30a61b8b42 fix: forum/components/DiscussionListItem (use route instead of config) 2020-09-18 22:19:54 +02:00
Alexander Skvortsov
824fe95346 update: DisussionSearchSource (use route attr to support linking without refreshing) 2020-09-18 22:19:54 +02:00
David Sevilla Martin
8475d176e0 fix: forum/routes /:filter handling /settings 2020-09-18 22:19:53 +02:00
David Sevilla Martin
95f4dc771d update: forum/components/SettingsPage 2020-09-18 22:19:53 +02:00
David Sevilla Martin
68caf45f33 update: forum/components/NotificationGrid 2020-09-18 22:19:53 +02:00
David Sevilla Martin
f72d118bec cleanup: common/components/Button 2020-09-18 22:19:52 +02:00
David Sevilla Martin
652d961907 update: common/components/FieldSet 2020-09-18 22:19:52 +02:00
David Sevilla Martin
71178245fc update: common/components/Switch 2020-09-18 22:19:52 +02:00
David Sevilla Martin
cfd1f01299 update: common/components/Checkbox 2020-09-18 22:19:52 +02:00
David Sevilla Martin
674f55e91d fix: support text-only vnodes with extractText
Also seems to fix not having a space between 'Posts' and the number of posts in the UserPage tooltips
2020-09-18 22:19:51 +02:00
David Sevilla Martin
f897b58f29 add: common/utils/withAttr
Replaces m.withAttr
2020-09-18 22:19:51 +02:00
David Sevilla Martin
5c49b71c02 update: forum/utils/UserControls 2020-09-18 22:19:51 +02:00
David Sevilla Martin
b9ba5b63f1 update: forum/components/DiscussionsUserPage 2020-09-18 22:19:50 +02:00
David Sevilla Martin
6e88dfb2cb update: forum/components/PostsUserPage 2020-09-18 22:19:50 +02:00
David Sevilla Martin
c899e11070 update: forum/components/UserPage 2020-09-18 22:19:50 +02:00
David Sevilla Martin
784b5cc03c update: forum/components/AvatarEditor 2020-09-18 22:19:49 +02:00
David Sevilla Martin
c17d7cd23f fix: Application#updateTitle using m.route() instead of m.route.get() 2020-09-18 22:19:49 +02:00
David Sevilla Martin
4af34265cc update: common/components/GroupBadge 2020-09-18 22:19:49 +02:00
Alexander Skvortsov
1616d8f1c7 Fix patchMithril for route 2020-09-18 22:19:48 +02:00
Alexander Skvortsov
57b85f501a Add composer mount to ForumApplication 2020-09-18 22:19:48 +02:00
Alexander Skvortsov
91609b8a71 update: forum/components/DiscussionComposer 2020-09-18 22:19:48 +02:00
Alexander Skvortsov
9d8b466d57 update: forum/states/ComposerState 2020-09-18 22:19:48 +02:00
Alexander Skvortsov
02154d05e5 update: forum/components/TextEditor 2020-09-18 22:19:47 +02:00
Alexander Skvortsov
a454b185e9 update forum/components/ComposerButton 2020-09-18 22:19:47 +02:00
Alexander Skvortsov
84c5248872 update: forum/components/ComposerBody 2020-09-18 22:19:47 +02:00
Alexander Skvortsov
2a784009fb update: Composer 2020-09-18 22:19:46 +02:00
Alexander Skvortsov
070865f825 update: IndexPage to fix newDiscussionAction (use promise instead of deferred) 2020-09-18 22:19:46 +02:00
Alexander Skvortsov
9615fd3e39 update: common/components/ConfirmDocumentUnload 2020-09-18 22:19:46 +02:00
Alexander Skvortsov
dde9c9c51b Mount navigation in ForumApplication 2020-09-18 22:19:45 +02:00
Alexander Skvortsov
6547290472 update: common/components/Navigation 2020-09-18 22:19:45 +02:00
Alexander Skvortsov
74f6a3e6ce Set empty object as default attrs for all components 2020-09-18 22:19:45 +02:00
Alexander Skvortsov
98740472a8 update: common/Model (use body instead of data on m.request) 2020-09-18 22:19:45 +02:00
Alexander Skvortsov
1e9825de4f update: forum/components/WelcomeHero 2020-09-18 22:19:44 +02:00
Alexander Skvortsov
d14ca79dfa update: listItems (filter out null/falsy values) 2020-09-18 22:19:44 +02:00
Alexander Skvortsov
5cd5f27769 update: forum/components/HeaderSecondary (add search, finishing the header) 2020-09-18 22:19:44 +02:00
Alexander Skvortsov
d05b63eddd update: forum/states/GlobalSearchState 2020-09-18 22:19:43 +02:00
Alexander Skvortsov
48c7354c08 update: forum/components/Search 2020-09-18 22:19:43 +02:00
Alexander Skvortsov
0fe635d32c update: common/components/Badge 2020-09-18 22:19:43 +02:00
Alexander Skvortsov
2c2a42030a update: UsersSearchSource
- mithril 2 prefers to store single text children in vnode.text, so we need to move it back to children to give us a consistent API
2020-09-18 22:19:42 +02:00
Alexander Skvortsov
37d2902cd1 Fix m.route => m.route.set in NotificationsDropdown 2020-09-18 22:19:42 +02:00
Alexander Skvortsov
66c2f6b76a update: forum/components/DiscussionSearchSource 2020-09-18 22:19:42 +02:00
Alexander Skvortsov
4d68636544 update: forum/components/SessionDropdown 2020-09-18 22:19:41 +02:00
Alexander Skvortsov
73891b751b update: forum/components/Notification, forum/components/DiscussionRenamedNotification
- Not yet tested
2020-09-18 22:19:41 +02:00
Alexander Skvortsov
fe011bf285 Add notifications to HeaderSecondary 2020-09-18 22:19:41 +02:00
Alexander Skvortsov
524dde31d4 update: forum/components/NotificationList 2020-09-18 22:19:41 +02:00
Alexander Skvortsov
b69fc01ab3 Uncomment notifications in ForumApplication 2020-09-18 22:19:40 +02:00
Alexander Skvortsov
20e69e6351 update: NotificationsDropdown 2020-09-18 22:19:40 +02:00
Alexander Skvortsov
d097e7ed4b Remove stray console.log 2020-09-18 22:19:40 +02:00
Alexander Skvortsov
63e07b2044 update: forum/utils/DiscussionControls
- replyAction has not yet been tested.
2020-09-18 22:19:39 +02:00
Alexander Skvortsov
60c3f23667 update: forum/components/DiscussionListItem 2020-09-18 22:19:39 +02:00
Alexander Skvortsov
9793c10610 infrastructure: For SubtreeRetainer, return a boolean, not a vnode, as subtree retaining is now managed by onbeforeupdate 2020-09-18 22:18:32 +02:00
Alexander Skvortsov
29af3e6d8b infrastructure: use params instead of data for get requests in common/Store 2020-09-18 22:18:32 +02:00
Alexander Skvortsov
703c3442da update: forum/components/UserCard 2020-09-18 22:18:32 +02:00
Alexander Skvortsov
e0d3a8c733 update: common/helpers/listItems: fix isSeparator 2020-09-18 22:18:32 +02:00
Alexander Skvortsov
527748ff66 update: forum/components/TerminalPost 2020-09-18 22:18:31 +02:00
Alexander Skvortsov
eb440bb9b6 update: forum/components/DiscussionList 2020-09-18 22:18:31 +02:00
Matthew Kilgore
d71f77d592 update: common/components/Placeholder.js 2020-09-18 22:18:31 +02:00
Matthew Kilgore
8b891abb2b update: forum/components/IndexPage.js 2020-09-18 22:18:30 +02:00
Matthew Kilgore
5f0dcc71ba update: common/components.Page.js 2020-09-18 22:18:30 +02:00
Matthew Kilgore
4fcafe3b2f Set application drawer 2020-09-18 22:18:30 +02:00
Matthew Kilgore
29065e1ee9 infrastructure: provide component classes instead of instances to routes.js 2020-09-18 22:18:29 +02:00
Matthew Kilgore
2b39bd7a0d Fix classList usage in listItems 2020-09-18 22:18:29 +02:00
Matthew Kilgore
94fe3236d7 infrastructure: Add routing to common/Application 2020-09-18 22:18:29 +02:00
Matthew Kilgore
3b6e5a0caf infrastructure: Add routes, history, search to ForumApplication 2020-09-18 22:18:29 +02:00
Matthew Kilgore
9ac8f1543a update: forum/utils/History.js 2020-09-18 22:18:28 +02:00
Matthew Kilgore
439e3a5a9a update: common/components/LinkButton 2020-09-18 22:18:28 +02:00
Matthew Kilgore
883e1a9d6a infrastruture: include the class name of the component where children is provided 2020-09-18 22:18:28 +02:00
Matthew Kilgore
2ac2edbbad Replace mithril-stream with mithril/stream, set it as a global via m.stream 2020-09-18 22:18:27 +02:00
Matthew Kilgore
18148141c3 update: common/components/RequestErrorModal 2020-09-18 22:18:27 +02:00
Matthew Kilgore
10f4223028 Add error warning if children attr is ever used 2020-09-18 22:18:27 +02:00
Matthew Kilgore
e10220ae47 update: Fix Alert on modals 2020-09-18 22:18:26 +02:00
Matthew Kilgore
dcd14821c2 infrastructure: revert to using this.attrs 2020-09-18 22:18:26 +02:00
Alexander Skvortsov
edeaa5855c update: SignUpModal 2020-09-18 22:18:26 +02:00
Alexander Skvortsov
cc91244f1a update: LoginModal 2020-09-18 22:18:25 +02:00
Alexander Skvortsov
5faab8bdbd update: ForgotPasswordModal 2020-09-18 22:18:25 +02:00
Alexander Skvortsov
99d79e7571 update: Modal 2020-09-18 22:18:25 +02:00
Alexander Skvortsov
5606eae0f1 update: ModalManager 2020-09-18 22:18:25 +02:00
Alexander Skvortsov
62cb71d4e1 update: ModalManagerState 2020-09-18 22:18:24 +02:00
Alexander Skvortsov
e28ba4acff infrastructure: fix broken translation of strings with html tags (vnodes) passed into them
Under the old system, we would set the "childen" property of the vnode in the translation to an array containing the text between the tag as a raw string. Mithril 2 expects everything to be a vnode, and as such, errors when processing this raw string. This commit runs that "children" array of raw strings (and possibly other vnodes) through m.fragment to convert everything to vnodes.
2020-09-18 22:18:24 +02:00
Alexander Skvortsov
5b3914535d infrastructure: Replace m.deferred in Application with native promise functionality 2020-09-18 22:18:24 +02:00
Alexander Skvortsov
44975bc606 update: common/Session.js
- Use body instead of data
- Use template literal instead of concatenation
2020-09-18 22:18:23 +02:00
Alexander Skvortsov
f7931e8a30 infrastructure: uncomment modal manager mount in application mount 2020-09-18 22:18:23 +02:00
Alexander Skvortsov
3ba655b2f9 infrastructure: install mithril streams package 2020-09-18 22:18:23 +02:00
Alexander Skvortsov
735ecab446 infrastructure: Run npm audit fix 2020-09-18 22:18:22 +02:00
Matthew Kilgore
64d4eb8c4c update: forum/components/HeaderSecondary (no search, session, notifs) 2020-09-18 22:18:22 +02:00
Matthew Kilgore
e4f1a397d6 update: forum/components/HeaderPrimary 2020-09-18 22:18:22 +02:00
Matthew Kilgore
5a244dcfd2 update: common/components/SelectDropdown 2020-09-18 22:18:21 +02:00
Matthew Kilgore
b60309284c update: common/components/Dropdown 2020-09-18 22:18:21 +02:00
Matthew Kilgore
392fe98c02 comment routes and components and states in forum application 2020-09-18 22:18:21 +02:00
Matthew Kilgore
873f489fec update: common/helpers/listItems 2020-09-18 22:18:21 +02:00
Matthew Kilgore
9d8374208f update: common/components/Button 2020-09-18 22:18:20 +02:00
Matthew Kilgore
07551b4890 update: common/components/Button 2020-09-18 22:18:20 +02:00
Matthew Kilgore
b2c147c147 update: common/components/LoadingIndicator 2020-09-18 22:18:20 +02:00
Matthew Kilgore
5bf5bd36ee update: common/utils/patchMithril (remove unnecessary hacks)
Removed m.prop AND m.withAttr, no alternatives yet (m.prop is mithril/stream import)
2020-09-18 22:18:19 +02:00
Matthew Kilgore
4435ff193a update: common/Component (& converted a tad) 2020-09-18 22:18:19 +02:00
Matthew Kilgore
5c30f8fa67 Setup Mithril 2 2020-09-18 22:18:19 +02:00
flarum-bot
fa0ff204dd Bundled output for commit 872e3bdc92 [skip ci] 2020-09-18 19:33:18 +00:00
Alexander Skvortsov
872e3bdc92 Add missing exports to compat 2020-09-18 21:31:35 +02:00
Alexander Skvortsov
79f9012694 Fix Post-actions being on top of Post Controls Dropdown
- This was accidentially introduced when an explicit z-index was added to reply-actions to prevent Post-footer from covering it
- Here, we revert that zindex, while making Post-footer inline-block to stop it from covering everything. We also set height=0 to stop implicitly added height
2020-09-18 15:33:01 +02:00
Wadim Kalmykov
633cc14d09 Fix issue where posts API doesn't return the right amount of posts (#2291) 2020-09-17 23:59:34 -04:00
Alexander Skvortsov
c6e85ef330 Allow upper case TLS and SSL for SMTP encryption (#2289) 2020-09-12 17:43:06 -04:00
phanlyhuynh
3f8432a589 Fix SMTP username and password shouldn't be required (#2287) 2020-09-06 15:12:03 -04:00
flarum-bot
96c95f2b6a Bundled output for commit 8e3e8826f9 [skip ci] 2020-09-04 17:00:30 +00:00
Franz Liedke
8e3e8826f9 app.composer.show: Trigger synchronous redraw
This is needed to have access to the newly created SuperTextarea
instance (app.composer.editor) directly after calling show().

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

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

Regression from #2161.

* Remove obsolete method

Regression from #2162.

* Mark method as protected

* Fade in posts in post stream using CSS

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

* Fix fadeIn for post stream items

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

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

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

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

New features:

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

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

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

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

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

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

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

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

Sorry for the back and forth. ;)

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

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

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

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

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

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

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

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

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

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

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

* Drop useless app_path() helper

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

* Deprecate global path helpers

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

* Stop storing paths as strings in container

* Avoid using path helpers from Application class

* Deprecate path helpers from Application class

* Avoid using public_path() in prerequisite check

a) The comparison was already outdated, as a different path was passed.
b) We're trying to get rid of these global helpers.
2020-06-19 16:16:03 -04:00
flarum-bot
b82504b4b1 Bundled output for commit 898d68d9f3 [skip ci] 2020-06-19 00:30:16 +00:00
Franz Liedke
898d68d9f3 Remove leftover property
Refs #2150.
2020-06-19 02:27:01 +02:00
flarum-bot
69f0172b92 Bundled output for commit 62fe9db732 [skip ci] 2020-06-19 00:11:51 +00:00
Alexander Skvortsov
62fe9db732 Don't store PostUser instance in CommentPost (#2184)
* Don't save component state in CommentPost
2020-06-18 20:10:25 -04:00
flarum-bot
ed566cd18f Bundled output for commit 5c1663d8f1 [skip ci] 2020-06-18 23:54:42 +00:00
Alexander Skvortsov
5c1663d8f1 Move Discussion List State into its own class (#2150)
Extract discussion list state
2020-06-18 19:53:40 -04:00
flarum-bot
c5d3b058ba Bundled output for commit 4a804dbbbc [skip ci] 2020-06-18 22:48:18 +00:00
Alexander Skvortsov
4a804dbbbc Remove app.search instance, cache app.cache.searched (#2151)
* Moved search state logic into search state
2020-06-18 18:47:01 -04:00
flarum-bot
f4afb006ed Bundled output for commit 646b35374d [skip ci] 2020-06-18 21:29:07 +00:00
Alexander Skvortsov
646b35374d Don't store checkbox instances in NotificationGrid (#2183)
* Don't store checkbox states in NotificaitonGrid, use props for loading in Checkbox and Switch, replace preferenceSaver with internal management of loading state
2020-06-18 17:28:05 -04:00
flarum-bot
4fc06336df Bundled output for commit 65f2d5fb75 [skip ci] 2020-06-18 21:09:49 +00:00
Alexander Skvortsov
65f2d5fb75 Extract NotificationList state (#2185)
* Extract NotificationList state
2020-06-18 17:08:06 -04:00
Alexander Skvortsov
5bca4fda9d Return the proper error code when wrong password when changing email (#2171) 2020-06-17 20:43:04 -04:00
Clark Winkelmann
b87c7189cc Remove BioChanged event which is no longer used since beta 8 (#2196) 2020-06-15 00:21:06 -04:00
Clark Winkelmann
17c239388a Fix AvatarChanged event (#2197)
* Fix AvatarChanged event not being dispatched when changing avatar
Also fix the uploader to trigger the event only once
2020-06-15 00:20:24 -04:00
Alexander Skvortsov
4da2994d1f Group Gambit Improvements (#2192)
* - Add ID to fields searched in group gambit
- Use joins instead of looping in group gambit
* Add visibility scoping to group gambit
* call IDs userIds
* If group identifier is numerical, treat it as an ID
2020-06-08 17:35:24 -04:00
Matt Kilgore
293e2251ca Fixes #2157, Explicitly set SameSite value for cookies (#2159)
* Fixes #2157, Explicitly set SameSite value for cookies by making samesite a config option in config.php. Also contains an update for the cookie library dependency
2020-06-03 22:53:30 -04:00
flarum-bot
3b1f5ca07b Bundled output for commit d1750fecc0 [skip ci] 2020-05-31 02:50:39 +00:00
Alexander Skvortsov
d1750fecc0 Send Test Mail Feature (#2023)
- Add UI, backend for sending test emails
- Change mail settings endpoint to /api/mail/settings
2020-05-30 22:49:36 -04:00
flarum-bot
63242edeb3 Bundled output for commit 0aed3764c4 [skip ci] 2020-05-31 02:29:29 +00:00
Hasan Özbey
0aed3764c4 Scroll to edited post or inform the user (#2108)
* scroll to edit or inform the user
2020-05-30 22:28:08 -04:00
Alexander Skvortsov
7b1269207e Get rid of Laravel Gate contract (#2181)
* Get rid of unnecessary uses of gate

* Move gate off of Laravel's gate contract
2020-05-28 18:00:44 -04:00
Sami Mazouz
bab084a75f Fix Paths test failing on Windows (#2187)
* Fix directory separator for windows os

* Change Paths to use a forward slash instead
2020-05-28 12:42:54 -04:00
Alexander Skvortsov
3c87f800dd Instances of models should not matter when checking permissions (#2186) 2020-05-27 12:22:08 -04:00
Matt Kilgore
26256c436f Fix installer removing URL port (#2182)
* Fix installer removing URL port
2020-05-25 14:35:22 +02:00
Franz Liedke
63397bb466 Allow manipulating error handler through extender
By giving each middleware a name, they can now be replaced or moved
around using the Middleware extender.

Fixes #2115.
2020-05-24 08:47:26 +02:00
w-4
4b6864534b Fix header contents moving when opening modal (#2131)
* add navbar-fixed-top css class

* App-header position:fixed
2020-05-23 14:41:54 -04:00
Franz Liedke
c4f4f218bf Tests: Actually accept multiple extenders
We did pass multiple extenders to this method in the tests for the
`Model` extender - now this actually has the desired effect.
2020-05-23 02:00:25 +02:00
Franz Liedke
4866e7d9ba Stop using app() helper in tests 2020-05-23 01:56:21 +02:00
Sami Mazouz
d6acf28fcb Add z-index rule as part of fixing replies dropdown menu width (#2178) 2020-05-22 18:50:39 -04:00
Alexander Skvortsov
e627616750 Inject Url Generator and Translator Interface into notification mailer (#2169) 2020-05-22 18:10:31 -04:00
flarum-bot
bbd815a9ab Bundled output for commit acf4e9c80d [skip ci] 2020-05-20 00:53:05 +00:00
Alexander Skvortsov
acf4e9c80d Removed excess Widget class in favor of DashboardWidget (#2164) 2020-05-19 20:52:07 -04:00
flarum-bot
1bb5f99a27 Bundled output for commit b0822df759 [skip ci] 2020-05-19 22:46:59 +00:00
Alexander Skvortsov
b0822df759 Use drivers for display names, add display name extender (#2174)
* Deprecate GetDisplayName event

* Add interface for display name driver

* Add username driver as default

* Add code to register supported drivers / used driver as singletons

* Configured User class to use new driver-based system for display names

* Add extender for adding display name driver

* Add integration test for user display name driver

* Add frontend UI for selecting display name driver
2020-05-19 18:45:56 -04:00
flarum-bot
998e32c208 Bundled output for commit f89f114fad [skip ci] 2020-05-16 00:11:53 +00:00
julakali
f89f114fad Don't use body as tooltip container, allow notification area overflow (#2166)
* Don't use body as tooltip container, allow notification area overflow

Badge tooltips are using container: 'body', so they can overflow the
notification area. When the user navigates back while a badge tooltip is
showing, the tooltip remains visible.
This commit removes the body container attribute and instead allows the
notificationDropDown to overflow, so badge tooltips aren't cut off.
Instead, this adds overflow: hidden to NotificationList.
Fixes #2118.

* Remove newline
2020-05-15 20:10:40 -04:00
flarum-bot
9b936d4baa Bundled output for commit 7e661df15d [skip ci] 2020-05-12 16:24:38 +00:00
David Sevilla Martín
7e661df15d Some improvements to request error handling and modal error formatting (#1929)
* Use decodeURI instead of unescape & don't close modals

* Add comment

* Don't use a try/catch, clean up the group log code

* Remove double negative

* Format; fix issues from rebasing
2020-05-12 12:23:13 -04:00
Franz Liedke
b7355db2b7 Merge pull request #2154 from flarum/fl/2055-l58
Upgrade to Laravel 5.8
2020-05-12 15:20:01 +02:00
Franz Liedke
5dc9451c21 Fix notification query with DB prefix
This was fixed in https://github.com/laravel/framework/pull/28400.
See commit 7f1048352d.
2020-05-09 14:45:57 +02:00
Franz Liedke
220c8c66b0 Fix signature of HandleErrors middleware
In Laravel 5.8, the `Container::tagged()` method was changed to return
an iterator [1].

We only use the result for iteration, or, in this case, to pass a bunch
of "reporters" to the error handler middleware, therefore we need to
accept an iterable here.

[1]: https://laravel.com/docs/5.8/upgrade#container-generators
2020-05-08 23:30:17 +02:00
Franz Liedke
484933db7d Test setup: Do not use env() helper
Not needed, and not working without a full Laravel installation.
2020-05-08 23:30:17 +02:00
Franz Liedke
f6347dcc46 Update Laravel components to v5.8
First part of #2055.
2020-05-08 21:46:13 +02:00
Franz Liedke
107b4be726 Remove empty comment 2020-05-08 16:05:25 +02:00
Franz Liedke
93d4192b54 Apply fixes from StyleCI
[ci skip] [skip ci]
2020-05-08 14:03:48 +00:00
Franz Liedke
ecdce44d55 Fix container configuration when not installed 2020-05-08 16:03:20 +02:00
Franz Liedke
a5e286e662 Drop MigrationServiceProvider 2020-05-08 12:04:24 +02:00
Franz Liedke
443949f7b9 Fix generate:migration command for extensions
Apparently, this code was from back when we had a special "extensions"
directory for Composer packages marked as Flarum extensions.

While we're at it, we now inject the Paths instance instead of using one
of the global helpers (which I am trying to get rid of).

Refs #2055.
2020-05-08 12:01:11 +02:00
Franz Liedke
4884aad2f0 Update beta.13 changelog 2020-05-08 11:35:46 +02:00
Franz Liedke
365eb15d29 Merge pull request #2142 from flarum/fl/2055-prepare-for-laravel-58
Split up Application and Container
2020-05-07 22:49:36 +02:00
flarum-bot
85e2623622 Bundled output for commit 7d99727168 [skip ci] 2020-05-07 07:20:06 +00:00
Daniël Klabbers
7d99727168 commit version constant 2020-05-07 09:17:26 +02:00
Daniël Klabbers
84784c9839 Release v0.1.0-beta.13 2020-05-07 09:18:04 +02:00
Franz Liedke
a9470b463f Make two more tests compatible with PHPUnit 8 2020-05-07 09:18:04 +02:00
Franz Liedke
deb48bd173 Remove obsolete method 2020-05-07 09:18:04 +02:00
Alexander Skvortsov
b38bd60362 Added simply confirmation popup for hiding / deleting posts (#2135) 2020-05-07 09:18:04 +02:00
Franz Liedke
260e7cd48f Inject new Paths class instead of Application
This (and similar work in other areas) will allow us to further
reduce the API surface of the Application class.

Separation of concerns etc.
2020-05-01 15:47:35 +02:00
Franz Liedke
41a56c4ad1 Split up Application and Container
- Stop trying to implement Laravel's Application contract, which
  has no value for us.
- Stop inheriting from the Container, injecting one works equally
  well and does not clutter up the interfaces.
- Inject the Paths collection instead of unwrapping it again, for
  better encapsulation.

This brings us one step closer toward upgrading our Laravel
components (#2055), because we no longer need to adopt the changes
to the Application contract.
2020-05-01 15:47:35 +02:00
Franz Liedke
d0ae2839f0 Extract a class to hold / determine paths 2020-05-01 15:24:20 +02:00
flarum-bot
d31a747631 Bundled output for commit 526081bd06 [skip ci] 2020-05-01 09:53:55 +00:00
Franz Liedke
526081bd06 Update Webpack 2020-05-01 11:52:26 +02:00
Franz Liedke
cbdd3c5cc7 Apply fixes from StyleCI
[ci skip] [skip ci]
2020-04-27 20:04:41 +00:00
Franz Liedke
7d1ef9d891 Remove a bunch of deprecated events
Use extenders instead!

Refs #1891.
2020-04-27 22:04:08 +02:00
Alexander Skvortsov
7794546845 Model extender: Fix inheritance (#2132)
This ensures that default values, date attributes and relationships are properly inherited, when we have deeper model class hierarchies.

This also adds test cases to ensure that inheritance order is honored for relationship and default attribute extender. As there's no way to remove date attributes, the order of evaluation there doesn't matter.
2020-04-24 21:17:31 +02:00
Franz Liedke
c43cc874ee Model extender: Add failing test
We determined that child classes are not properly affected when
extending the parent classes.

Refs #2100.
2020-04-24 17:54:30 +02:00
Franz Liedke
33cf94c192 Fix test to match its description
Refs #2100.
2020-04-24 17:31:08 +02:00
Franz Liedke
036e519865 Apply fixes from StyleCI
[ci skip] [skip ci]
2020-04-24 14:56:37 +00:00
Franz Liedke
9386c91af9 Tweak model extender tests
- Format code
- Reorder methods
- Test a different scenario to avoid the use of sleep()

Refs #2100.
2020-04-24 16:55:04 +02:00
Franz Liedke
8306cef963 Clean up model extender
- Remove unused private attributes
- Complete docblocks
- Add scalar type hints
- Format code
- Reorder methods

Refs #2100.
2020-04-24 16:33:08 +02:00
Franz Liedke
51ea326959 Apply fixes from StyleCI
[ci skip] [skip ci]
2020-04-24 13:10:36 +00:00
Alexander Skvortsov
15bed971e6 Add model extender (#2100)
This covers default attribute values, date attributes and custom relationships.
2020-04-24 15:10:24 +02:00
Franz Liedke
c896cd8696 npm audit fix 2020-04-24 14:30:16 +02:00
flarum-bot
54ac83d0b6 Bundled output for commit 1592cd1013 [skip ci] 2020-04-22 21:38:57 +00:00
Franz Liedke
1592cd1013 CI: Shorten the lint job name 2020-04-22 23:37:37 +02:00
Alexander Skvortsov
6e8884f190 Implement hidden permission groups (#2129)
Only users that have the new `viewHiddenGroups` permissions will be able to see these groups.

You might want this when you want to give certain users special permissions, but don't want to make your authorization scheme public to regular users.

Co-authored-by: luceos <daniel+github@klabbers.email>
2020-04-21 17:49:53 +02:00
Franz Liedke
df8f73bd3d Statically access Flarum version everywhere
One less reason to inject the huge Application class.

Refs #2055.
2020-04-21 16:48:36 +02:00
Franz Liedke
3f0f89afb1 Use Container contract where easily possible
Less usages of the Application god-class simplifies splitting it up.

Refs #2055.
2020-04-21 16:48:06 +02:00
Franz Liedke
f0f301c5f4 Add compatiblity with Composer 2.0
- The structure of vendor/composer/installed.json will change.
- The same file will now contain the relative path to package locations.

References:
- https://github.com/composer/composer/blob/master/UPGRADE-2.0.md
- https://php.watch/articles/composer-2
2020-04-21 15:47:58 +02:00
Franz Liedke
3045bde167 Format code
- Early returns
- Comments
- Write variables only when needed

Refs #2020.
2020-04-19 16:53:52 +02:00
Robert Korulczyk
ee7a4627d8 Load only translations for enabled extensions from language packs (#2020)
fix #1837

Co-authored-by: Daniel Klabbers <daniel+git@klabbers.email>
2020-04-19 16:29:45 +02:00
Franz Liedke
b9fb92d49a Inline test class
Refs #1977.
2020-04-19 15:55:10 +02:00
Clark Winkelmann
b5accca957 Make AbstractPolicy compatible with both object and class as $model (#1977) 2020-04-19 15:52:59 +02:00
496 changed files with 19635 additions and 65463 deletions

View File

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

View File

@@ -1,4 +1,4 @@
name: Lint code
name: Lint
on:
push:
@@ -12,7 +12,7 @@ jobs:
prettier:
runs-on: ubuntu-latest
name: Lint JS code with Prettier
name: JS / Prettier
steps:
- uses: actions/checkout@master

View File

@@ -1,5 +1,58 @@
# Changelog
## [0.1.0-beta.13](https://github.com/flarum/core/compare/v0.1.0-beta.12...v0.1.0-beta.13)
### Added
- Console extender (#2057)
- CSRF extender (#2095)
- Event extender (#2097)
- Mail extender (#2012)
- Model extender (#2100)
- Posts by users that started a discussion now have the CSS class `.Post--by-start-user`
- PHPUnit 8 compatibility
- Composer 2 compatibility
- Permission groups can now be hidden (#2129)
- Confirmation popup when hiding or deleting posts (#2135)
### Changed
- Updated less.php dependency version to 3.0
- Updated JS dependencies
- All notifications and other emails now processed through the queue, if enabled (#978, #1928, #1931, #2096)
- Simplified uploads, removing need to store intermediate files (#2117)
- Improved date handling for dates older than 1 year (#2034)
- Linting and automatic formatting for JS (#2099)
- Translation files from Language Packs are only loaded for extensions that are enabled (#2020)
- PHP extenders' properties are now `private` instead of `protected`, intentionally making it harder to extend these classes (#1958)
- Preparation for upgrading Laravel components to 5.8 and then 6.0 (#2055, #2117)
- Allowed permission checks based on model classes in addition to instances (#1977)
### Fixed
- Users can no longer restore discussions hidden by admins (#2037)
- Issues of the Modal not showing or auto hiding (#1504, #1813, #2080)
- Columnar layout on admin extensions page was broken in Firefox (#2029, #2111)
- Non-dismissible modals could still be dismissed using the ESC key (#1917)
- New discussions were added to the discussion list above unread sticky posts (#1751, #1868)
- New discussions not visible to users when using Pusher (#2076, #2077)
- Permission icons were aligned unevenly in admin permissions list (#2016, #2018)
- Notification bubble not inversed on mobile with colored header (#1983, #2109)
- Post stream scrubber clicks jumped back to first post (#1945)
- Loading state of Switch toggle component was hard to see (#2039, #1491)
- `Flarum\Extend\Middleware`: The methods `insertBefore()` and `insertAfter()` did not work as described (#2063, #2084)
### Removed
- Support for PHP 7.1 (#2014)
- Zend compatibility bridge (#2010)
- SES mail support (#2011)
- Backward compatibility layer for `Flarum\Mail\DriverInterface`, new methods from beta.12 are now required
- `Flarum\Util\Str` helper class
- `Flarum\Event\ConfigureMiddleware` event
### Deprecated
- `Flarum\Event\AbstractConfigureRoutes` event class
- `Flarum\Event\ConfigureApiRoutes` event class
- `Flarum\Event\ConfigureForumRoutes` event class
- `Flarum\Event\ConfigureLocales` event class
## [0.1.0-beta.12](https://github.com/flarum/core/compare/v0.1.0-beta.11.1...v0.1.0-beta.12)
### Added

View File

@@ -1,26 +0,0 @@
### Changes
* Mithril
- See changes from v0.2.x @ https://mithril.js.org/migration-v02x.html
- Kept `m.prop` and `m.withAttr`
- Actual Promises are used now instead of `m.deferred`
* Component
- Use new Mithril lifecycle hooks (`component.config` is gone)
- When implementing your own, you *must* call `super.<hook>(vnode)` to update `this.attrs`
- `component.render` now doesn't use the current state instance
- this is because of how Mithril v2 works
- now calls mithril on the component class (not instance) and its props
* Translator
- Added `app.translator.transText`, automatically extracts text from `translator.trans` output
* Utils
- Changed `computed` util to require multiple keys to be passed as an array
- `SubtreeRetainer` now has an `update` method instead of `retain`, and its output is used in `onbeforeupdate` lifecycle hook
- `Evented` util is now a class instead of an object
- `formatNumber` now uses `Number.prototype.toLocaleString` with the current application locale, and supports passing an options object (eg. for currency formatting - https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/NumberFormat/resolvedOptions#Description)
* Modals
- `app.modal.show` now takes the Modal _class_ (not instance) and optional props (`app.modal.show(ForgotPasswordModal, props)`)
#### Forum
* Forum Application
- Renamed to `Forum`
- `app.search` is no longer global, extend using `extend`

View File

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

View File

@@ -1,6 +1,6 @@
{
"printWidth": 150,
"singleQuote": true,
"tabWidth": 4,
"tabWidth": 2,
"trailingComma": "es5"
}

11
js/admin.js Normal file
View File

@@ -0,0 +1,11 @@
/*
* 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.
*/
export * from './src/common';
export * from './src/admin';

View File

@@ -1,2 +0,0 @@
export * from './src/common';
export * from './src/admin';

20895
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

26571
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

11
js/forum.js Normal file
View File

@@ -0,0 +1,11 @@
/*
* 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.
*/
export * from './src/common';
export * from './src/forum';

View File

@@ -1,2 +0,0 @@
export * from './src/common';
export * from './src/forum';

4103
js/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,56 +1,35 @@
{
"name": "@flarum/core",
"private": true,
"name": "@flarum/core",
"dependencies": {
"@babel/preset-typescript": "^7.10.1",
"@types/mithril": "^2.0.3",
"bootstrap": "^3.4.1",
"classnames": "^2.2.5",
"color-thief-browser": "^2.0.2",
"dayjs": "^1.8.28",
"expose-loader": "^0.7.5",
"flarum-webpack-config": "0.1.0-beta.10",
"jquery": "^3.4.1",
"jquery.hotkeys": "^0.1.0",
"lodash-es": "^4.17.14",
"m.attrs.bidi": "github:tobscure/m.attrs.bidi",
"mithril": "^2.0.4",
"punycode": "^2.1.1",
"spin.js": "^3.1.0",
"webpack": "^4.43.0",
"webpack-cli": "^3.3.11",
"webpack-merge": "^4.1.4"
},
"devDependencies": {
"husky": "^4.2.5",
"prettier": "2.0.2"
},
"scripts": {
"dev": "webpack --mode development --watch",
"build": "webpack --mode production",
"format": "prettier --write src \"*.{ts,js}\"",
"format-check": "prettier --check src \"*.{ts,js}\""
},
"dependencies": {
"bootstrap": "^3.4.1",
"classnames": "^2.2.6",
"colorthief": "^2.3.0",
"dayjs": "^1.8.26",
"flarum-webpack-config": "^0.1.0-beta.10",
"hc-sticky": "^2.2.3",
"jump.js": "^1.0.2",
"lodash": "^4.17.15",
"m.attrs.bidi": "github:tobscure/m.attrs.bidi",
"micromodal": "^0.4.6",
"mithril": "^2.0.4",
"mousetrap": "^1.6.5",
"punycode": "^2.1.1",
"spin.js": "^4.1.0",
"tooltip.js": "^1.3.3",
"zepto": "^1.2.0"
},
"devDependencies": {
"@babel/core": "^7.9.6",
"@babel/plugin-proposal-class-properties": "^7.8.3",
"@babel/plugin-syntax-dynamic-import": "^7.8.3",
"@babel/plugin-transform-object-assign": "^7.8.3",
"@babel/plugin-transform-react-jsx": "^7.9.4",
"@babel/plugin-transform-runtime": "^7.9.6",
"@babel/preset-env": "^7.9.6",
"@babel/preset-react": "^7.9.4",
"@babel/preset-typescript": "^7.9.0",
"@babel/runtime": "^7.9.6",
"@types/classnames": "^2.2.10",
"@types/mithril": "^2.0.2",
"@types/zepto": "^1.0.30",
"babel-loader": "^8.0.6",
"expose-loader": "^0.7.5",
"friendly-errors-webpack-plugin": "^1.7.0",
"husky": "^4.2.5",
"imports-loader": "^0.8.0",
"prettier": "^2.0.5",
"source-map-loader": "^0.2.4",
"typescript": "^3.8.3",
"webpack": "^4.43.0",
"webpack-bundle-analyzer": "^3.7.0",
"webpack-cli": "^3.3.11",
"webpack-merge": "^4.2.2"
"format": "prettier --write src",
"format-check": "prettier --check src"
},
"husky": {
"hooks": {

47
js/shims.d.ts vendored
View File

@@ -1,7 +1,48 @@
export * from './webpack-flarum-shims';
// Mithril
import * as Mithril from 'mithril';
import Stream from 'mithril/stream';
// Other third-party libs
import * as _dayjs from 'dayjs';
import * as _$ from 'jquery';
// Globals from flarum/core
import Application from './src/common/Application';
declare global {
const app: Application;
/**
* Helpers that flarum/core patches into Mithril
*/
interface m extends Mithril.Static {
prop: typeof Stream;
}
/**
* Export Mithril typings globally.
*
* This lets us use these typings without an extra import everywhere we use
* Mithril in a TypeScript file.
*/
export as namespace Mithril;
/**
* flarum/core exposes several extensions globally:
*
* - 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: m;
const dayjs: typeof _dayjs;
}
/**
* All global variables owned by flarum/core.
*/
declare global {
const app: Application;
}

View File

@@ -1,91 +0,0 @@
import HeaderPrimary from './components/HeaderPrimary';
import HeaderSecondary from './components/HeaderSecondary';
import routes from './routes';
import Application, { ApplicationData } from '../common/Application';
import Navigation from '../common/components/Navigation';
import AdminNav from './components/AdminNav';
type Extension = {
description: string;
extra: object;
icon: {
name: string;
};
id: number;
version: string;
};
export type AdminData = ApplicationData & {
mysqlVersion: string;
phpVersion: string;
extensions: {
[key: string]: Extension;
};
permissions: {
[key: string]: string[];
};
settings: {
[key: string]: string;
};
};
export default class Admin extends Application {
extensionSettings = {};
history = {
canGoBack: () => true,
getPrevious: () => {},
backUrl: () => this.forum.attribute('baseUrl'),
back: function () {
window.location = this.backUrl();
},
};
data!: AdminData;
constructor() {
super();
routes(this);
}
/**
* @inheritdoc
*/
mount() {
m.mount(document.getElementById('app-navigation'), new Navigation({ className: 'App-backControl', drawer: true }));
m.mount(document.getElementById('header-navigation'), new Navigation());
m.mount(document.getElementById('header-primary'), new HeaderPrimary());
m.mount(document.getElementById('header-secondary'), new HeaderSecondary());
m.mount(document.getElementById('admin-navigation'), new AdminNav());
if (!document.location.hash) document.location.hash = '#/';
m.route.prefix = '#';
super.mount();
// If an extension has just been enabled, then we will run its settings
// callback.
const enabled = localStorage.getItem('enabledExtension');
if (enabled && this.extensionSettings[enabled]) {
this.extensionSettings[enabled]();
localStorage.removeItem('enabledExtension');
}
}
getRequiredPermissions(permission) {
const required: string[] = [];
if (permission === 'startDiscussion' || permission.indexOf('discussion.') === 0) {
required.push('viewDiscussions');
}
if (permission === 'discussion.delete') {
required.push('discussion.hide');
}
if (permission === 'discussion.deletePosts') {
required.push('discussion.hidePosts');
}
return required;
}
}

View File

@@ -0,0 +1,68 @@
import HeaderPrimary from './components/HeaderPrimary';
import HeaderSecondary from './components/HeaderSecondary';
import routes from './routes';
import Application from '../common/Application';
import Navigation from '../common/components/Navigation';
import AdminNav from './components/AdminNav';
export default class AdminApplication extends Application {
extensionSettings = {};
history = {
canGoBack: () => true,
getPrevious: () => {},
backUrl: () => this.forum.attribute('baseUrl'),
back: function () {
window.location = this.backUrl();
},
};
constructor() {
super();
routes(this);
}
/**
* @inheritdoc
*/
mount() {
m.mount(document.getElementById('app-navigation'), { view: () => Navigation.component({ className: 'App-backControl', drawer: true }) });
m.mount(document.getElementById('header-navigation'), Navigation);
m.mount(document.getElementById('header-primary'), HeaderPrimary);
m.mount(document.getElementById('header-secondary'), HeaderSecondary);
m.mount(document.getElementById('admin-navigation'), AdminNav);
// Mithril does not render the home route on https://example.com/admin, so
// we need to go to https://example.com/admin#/ explicitly.
if (!document.location.hash) document.location.hash = '#/';
m.route.prefix = '#';
super.mount();
// If an extension has just been enabled, then we will run its settings
// callback.
const enabled = localStorage.getItem('enabledExtension');
if (enabled && this.extensionSettings[enabled]) {
this.extensionSettings[enabled]();
localStorage.removeItem('enabledExtension');
}
}
getRequiredPermissions(permission) {
const required = [];
if (permission === 'startDiscussion' || permission.indexOf('discussion.') === 0) {
required.push('viewDiscussions');
}
if (permission === 'discussion.delete') {
required.push('discussion.hide');
}
if (permission === 'discussion.deletePosts') {
required.push('discussion.hidePosts');
}
return required;
}
}

View File

@@ -1,8 +0,0 @@
import Admin from './Admin';
const app = new Admin();
// @ts-ignore
window.app = app;
export default app;

View File

@@ -6,7 +6,6 @@ import EditCustomFooterModal from './components/EditCustomFooterModal';
import SessionDropdown from './components/SessionDropdown';
import HeaderPrimary from './components/HeaderPrimary';
import AppearancePage from './components/AppearancePage';
import Page from './components/Page';
import StatusWidget from './components/StatusWidget';
import HeaderSecondary from './components/HeaderSecondary';
import SettingsModal from './components/SettingsModal';
@@ -27,35 +26,34 @@ import AdminNav from './components/AdminNav';
import EditCustomCssModal from './components/EditCustomCssModal';
import EditGroupModal from './components/EditGroupModal';
import routes from './routes';
import Admin from './Admin';
import AdminApplication from './AdminApplication';
export default Object.assign(compat, {
'utils/saveSettings': saveSettings,
'components/SettingDropdown': SettingDropdown,
'components/EditCustomFooterModal': EditCustomFooterModal,
'components/SessionDropdown': SessionDropdown,
'components/HeaderPrimary': HeaderPrimary,
'components/AppearancePage': AppearancePage,
'components/Page': Page,
'components/StatusWidget': StatusWidget,
'components/HeaderSecondary': HeaderSecondary,
'components/SettingsModal': SettingsModal,
'components/DashboardWidget': DashboardWidget,
'components/AddExtensionModal': AddExtensionModal,
'components/ExtensionsPage': ExtensionsPage,
'components/AdminLinkButton': AdminLinkButton,
'components/PermissionGrid': PermissionGrid,
'components/MailPage': MailPage,
'components/UploadImageButton': UploadImageButton,
'components/LoadingModal': LoadingModal,
'components/DashboardPage': DashboardPage,
'components/BasicsPage': BasicsPage,
'components/EditCustomHeaderModal': EditCustomHeaderModal,
'components/PermissionsPage': PermissionsPage,
'components/PermissionDropdown': PermissionDropdown,
'components/AdminNav': AdminNav,
'components/EditCustomCssModal': EditCustomCssModal,
'components/EditGroupModal': EditGroupModal,
routes: routes,
Admin: Admin,
}) as any;
'utils/saveSettings': saveSettings,
'components/SettingDropdown': SettingDropdown,
'components/EditCustomFooterModal': EditCustomFooterModal,
'components/SessionDropdown': SessionDropdown,
'components/HeaderPrimary': HeaderPrimary,
'components/AppearancePage': AppearancePage,
'components/StatusWidget': StatusWidget,
'components/HeaderSecondary': HeaderSecondary,
'components/SettingsModal': SettingsModal,
'components/DashboardWidget': DashboardWidget,
'components/AddExtensionModal': AddExtensionModal,
'components/ExtensionsPage': ExtensionsPage,
'components/AdminLinkButton': AdminLinkButton,
'components/PermissionGrid': PermissionGrid,
'components/MailPage': MailPage,
'components/UploadImageButton': UploadImageButton,
'components/LoadingModal': LoadingModal,
'components/DashboardPage': DashboardPage,
'components/BasicsPage': BasicsPage,
'components/EditCustomHeaderModal': EditCustomHeaderModal,
'components/PermissionsPage': PermissionsPage,
'components/PermissionDropdown': PermissionDropdown,
'components/AdminNav': AdminNav,
'components/EditCustomCssModal': EditCustomCssModal,
'components/EditGroupModal': EditGroupModal,
routes: routes,
AdminApplication: AdminApplication,
});

View File

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

View File

@@ -1,30 +0,0 @@
import app from '../app';
import Modal from '../../common/components/Modal';
export default class AddExtensionModal extends Modal {
className() {
return 'AddExtensionModal Modal--small';
}
title() {
return app.translator.trans('core.admin.add_extension.title');
}
content() {
return (
<div className="Modal-body">
<p>{app.translator.trans('core.admin.add_extension.temporary_text')}</p>
<p>
{app.translator.trans('core.admin.add_extension.install_text', {
a: <a href="https://discuss.flarum.org/t/extensions" target="_blank" />,
})}
</p>
<p>
{app.translator.trans('core.admin.add_extension.developer_text', {
a: <a href="http://flarum.org/docs/extend" target="_blank" />,
})}
</p>
</div>
);
}
}

View File

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

View File

@@ -1,15 +0,0 @@
import LinkButton, { LinkButtonProps } from '../../common/components/LinkButton';
export interface AdminLinkButtonProps extends LinkButtonProps {
description?: string;
}
export default class AdminLinkButton<T extends AdminLinkButtonProps = AdminLinkButtonProps> extends LinkButton<T> {
getButtonContent() {
const content = super.getButtonContent(this.props.icon, this.props.loading, this.props.children);
content.push(<div className="AdminLinkButton-description">{this.props.description}</div>);
return content;
}
}

View File

@@ -0,0 +1,106 @@
/*
* This file is part of Flarum.
*
* (c) Toby Zerner <toby.zerner@gmail.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
import Component from '../../common/Component';
import AdminLinkButton from './AdminLinkButton';
import SelectDropdown from '../../common/components/SelectDropdown';
import ItemList from '../../common/utils/ItemList';
export default class AdminNav extends Component {
view() {
return (
<SelectDropdown className="AdminNav App-titleControl" buttonClassName="Button">
{this.items().toArray()}
</SelectDropdown>
);
}
/**
* Build an item list of links to show in the admin navigation.
*
* @return {ItemList}
*/
items() {
const items = new ItemList();
items.add(
'dashboard',
AdminLinkButton.component(
{
href: app.route('dashboard'),
icon: 'far fa-chart-bar',
description: app.translator.trans('core.admin.nav.dashboard_text'),
},
app.translator.trans('core.admin.nav.dashboard_button')
)
);
items.add(
'basics',
AdminLinkButton.component(
{
href: app.route('basics'),
icon: 'fas fa-pencil-alt',
description: app.translator.trans('core.admin.nav.basics_text'),
},
app.translator.trans('core.admin.nav.basics_button')
)
);
items.add(
'mail',
AdminLinkButton.component(
{
href: app.route('mail'),
icon: 'fas fa-envelope',
description: app.translator.trans('core.admin.nav.email_text'),
},
app.translator.trans('core.admin.nav.email_button')
)
);
items.add(
'permissions',
AdminLinkButton.component(
{
href: app.route('permissions'),
icon: 'fas fa-key',
description: app.translator.trans('core.admin.nav.permissions_text'),
},
app.translator.trans('core.admin.nav.permissions_button')
)
);
items.add(
'appearance',
AdminLinkButton.component(
{
href: app.route('appearance'),
icon: 'fas fa-paint-brush',
description: app.translator.trans('core.admin.nav.appearance_text'),
},
app.translator.trans('core.admin.nav.appearance_button')
)
);
items.add(
'extensions',
AdminLinkButton.component(
{
href: app.route('extensions'),
icon: 'fas fa-puzzle-piece',
description: app.translator.trans('core.admin.nav.extensions_text'),
},
app.translator.trans('core.admin.nav.extensions_button')
)
);
return items;
}
}

View File

@@ -1,85 +0,0 @@
import Component from '../../common/Component';
import AdminLinkButton from './AdminLinkButton';
import SelectDropdown from '../../common/components/SelectDropdown';
import ItemList from '../../common/utils/ItemList';
export default class AdminNav<T> extends Component<T> {
view() {
return (
<SelectDropdown className="AdminNav App-titleControl" buttonClassName="Button">
{this.items().toArray()}
</SelectDropdown>
);
}
/**
* Build an item list of links to show in the admin navigation.
*
* @return {ItemList}
*/
items() {
const items = new ItemList();
items.add(
'dashboard',
AdminLinkButton.component({
href: app.route('dashboard'),
icon: 'far fa-chart-bar',
children: app.translator.trans('core.admin.nav.dashboard_button'),
description: app.translator.trans('core.admin.nav.dashboard_text'),
})
);
items.add(
'basics',
AdminLinkButton.component({
href: app.route('basics'),
icon: 'fas fa-pencil-alt',
children: app.translator.trans('core.admin.nav.basics_button'),
description: app.translator.trans('core.admin.nav.basics_text'),
})
);
items.add(
'mail',
AdminLinkButton.component({
href: app.route('mail'),
icon: 'fas fa-envelope',
children: app.translator.trans('core.admin.nav.email_button'),
description: app.translator.trans('core.admin.nav.email_text'),
})
);
items.add(
'permissions',
AdminLinkButton.component({
href: app.route('permissions'),
icon: 'fas fa-key',
children: app.translator.trans('core.admin.nav.permissions_button'),
description: app.translator.trans('core.admin.nav.permissions_text'),
})
);
items.add(
'appearance',
AdminLinkButton.component({
href: app.route('appearance'),
icon: 'fas fa-paint-brush',
children: app.translator.trans('core.admin.nav.appearance_button'),
description: app.translator.trans('core.admin.nav.appearance_text'),
})
);
items.add(
'extensions',
AdminLinkButton.component({
href: app.route('extensions'),
icon: 'fas fa-puzzle-piece',
children: app.translator.trans('core.admin.nav.extensions_button'),
description: app.translator.trans('core.admin.nav.extensions_text'),
})
);
return items;
}
}

View File

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

View File

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

View File

@@ -0,0 +1,206 @@
import Page from '../../common/components/Page';
import FieldSet from '../../common/components/FieldSet';
import Select from '../../common/components/Select';
import Button from '../../common/components/Button';
import saveSettings from '../utils/saveSettings';
import ItemList from '../../common/utils/ItemList';
import Switch from '../../common/components/Switch';
import withAttr from '../../common/utils/withAttr';
export default class BasicsPage extends Page {
oninit(vnode) {
super.oninit(vnode);
this.loading = false;
this.fields = [
'forum_title',
'forum_description',
'default_locale',
'show_language_selector',
'default_route',
'welcome_title',
'welcome_message',
'display_name_driver',
];
this.values = {};
const settings = app.data.settings;
this.fields.forEach((key) => (this.values[key] = m.stream(settings[key])));
this.localeOptions = {};
const locales = app.data.locales;
for (const i in locales) {
this.localeOptions[i] = `${locales[i]} (${i})`;
}
this.displayNameOptions = {};
const displayNameDrivers = app.data.displayNameDrivers;
displayNameDrivers.forEach(function (identifier) {
this.displayNameOptions[identifier] = identifier;
}, this);
if (!this.values.display_name_driver() && displayNameDrivers.includes('username')) this.values.display_name_driver('username');
if (typeof this.values.show_language_selector() !== 'number') this.values.show_language_selector(1);
}
view() {
return (
<div className="BasicsPage">
<div className="container">
<form onsubmit={this.onsubmit.bind(this)}>
{FieldSet.component(
{
label: app.translator.trans('core.admin.basics.forum_title_heading'),
},
[<input className="FormControl" bidi={this.values.forum_title} />]
)}
{FieldSet.component(
{
label: app.translator.trans('core.admin.basics.forum_description_heading'),
},
[
<div className="helpText">{app.translator.trans('core.admin.basics.forum_description_text')}</div>,
<textarea className="FormControl" bidi={this.values.forum_description} />,
]
)}
{Object.keys(this.localeOptions).length > 1
? FieldSet.component(
{
label: app.translator.trans('core.admin.basics.default_language_heading'),
},
[
Select.component({
options: this.localeOptions,
value: this.values.default_locale(),
onchange: this.values.default_locale,
}),
Switch.component(
{
state: this.values.show_language_selector(),
onchange: this.values.show_language_selector,
},
app.translator.trans('core.admin.basics.show_language_selector_label')
),
]
)
: ''}
{FieldSet.component(
{
label: app.translator.trans('core.admin.basics.home_page_heading'),
className: 'BasicsPage-homePage',
},
[
<div className="helpText">{app.translator.trans('core.admin.basics.home_page_text')}</div>,
this.homePageItems()
.toArray()
.map(({ path, label }) => (
<label className="checkbox">
<input
type="radio"
name="homePage"
value={path}
checked={this.values.default_route() === path}
onclick={withAttr('value', this.values.default_route)}
/>
{label}
</label>
)),
]
)}
{FieldSet.component(
{
label: app.translator.trans('core.admin.basics.welcome_banner_heading'),
className: 'BasicsPage-welcomeBanner',
},
[
<div className="helpText">{app.translator.trans('core.admin.basics.welcome_banner_text')}</div>,
<div className="BasicsPage-welcomeBanner-input">
<input className="FormControl" bidi={this.values.welcome_title} />
<textarea className="FormControl" bidi={this.values.welcome_message} />
</div>,
]
)}
{Object.keys(this.displayNameOptions).length > 1
? FieldSet.component(
{
label: app.translator.trans('core.admin.basics.display_name_heading'),
},
[
<div className="helpText">{app.translator.trans('core.admin.basics.display_name_text')}</div>,
Select.component({
options: this.displayNameOptions,
bidi: this.values.display_name_driver,
}),
]
)
: ''}
{Button.component(
{
type: 'submit',
className: 'Button Button--primary',
loading: this.loading,
disabled: !this.changed(),
},
app.translator.trans('core.admin.basics.submit_button')
)}
</form>
</div>
</div>
);
}
changed() {
return this.fields.some((key) => this.values[key]() !== app.data.settings[key]);
}
/**
* Build a list of options for the default homepage. Each option must be an
* object with `path` and `label` properties.
*
* @return {ItemList}
* @public
*/
homePageItems() {
const items = new ItemList();
items.add('allDiscussions', {
path: '/all',
label: app.translator.trans('core.admin.basics.all_discussions_label'),
});
return items;
}
onsubmit(e) {
e.preventDefault();
if (this.loading) return;
this.loading = true;
app.alerts.dismiss(this.successAlert);
const settings = {};
this.fields.forEach((key) => (settings[key] = this.values[key]()));
saveSettings(settings)
.then(() => {
this.successAlert = app.alerts.show(app.translator.trans('core.admin.basics.saved_message'), {
type: 'success',
});
})
.catch(() => {})
.then(() => {
this.loading = false;
m.redraw();
});
}
}

View File

@@ -1,191 +0,0 @@
import app from '../app';
import Page from './Page';
import Button from '../../common/components/Button';
import FieldSet from '../../common/components/FieldSet';
import Select from '../../common/components/Select';
import Switch from '../../common/components/Switch';
import saveSettings from '../utils/saveSettings';
import ItemList from '../../common/utils/ItemList';
import AlertState from '../../common/states/AlertState';
import Stream from 'mithril/stream';
export default class BasicsPage extends Page {
loading: boolean = false;
fields: string[] = [
'forum_title',
'forum_description',
'default_locale',
'show_language_selector',
'default_route',
'welcome_title',
'welcome_message',
];
values: { [key: string]: Stream<any> } = {};
localeOptions: object = {};
successAlert?: number;
oninit(vnode) {
super.oninit(vnode);
const settings = app.data.settings;
this.fields.forEach((key) => (this.values[key] = m.prop(settings[key])));
const locales = app.data.locales;
for (const i in locales) {
this.localeOptions[i] = `${locales[i]} (${i})`;
}
if (typeof this.values.show_language_selector() !== 'number') this.values.show_language_selector(1);
}
view() {
return (
<div className="BasicsPage">
<div className="container">
<form onsubmit={this.onsubmit.bind(this)}>
{FieldSet.component({
label: app.translator.trans('core.admin.basics.forum_title_heading'),
children: [
<input
className="FormControl"
value={this.values.forum_title()}
oninput={m.withAttr('value', this.values.forum_title)}
/>,
],
})}
{FieldSet.component({
label: app.translator.trans('core.admin.basics.forum_description_heading'),
children: [
<div className="helpText">{app.translator.trans('core.admin.basics.forum_description_text')}</div>,
<textarea
className="FormControl"
value={this.values.forum_description()}
oninput={m.withAttr('value', this.values.forum_description)}
/>,
],
})}
{Object.keys(this.localeOptions).length > 1
? FieldSet.component({
label: app.translator.trans('core.admin.basics.default_language_heading'),
children: [
Select.component({
options: this.localeOptions,
value: this.values.default_locale(),
onchange: this.values.default_locale,
}),
Switch.component({
state: this.values.show_language_selector(),
onchange: this.values.show_language_selector,
children: app.translator.trans('core.admin.basics.show_language_selector_label'),
}),
],
})
: ''}
{FieldSet.component({
label: app.translator.trans('core.admin.basics.home_page_heading'),
className: 'BasicsPage-homePage',
children: [
<div className="helpText">{app.translator.trans('core.admin.basics.home_page_text')}</div>,
this.homePageItems()
.toArray()
.map(({ path, label }) => (
<label className="checkbox">
<input
type="radio"
name="homePage"
value={path}
checked={this.values.default_route() === path}
onclick={m.withAttr('value', this.values.default_route)}
/>
{label}
</label>
)),
],
})}
{FieldSet.component({
label: app.translator.trans('core.admin.basics.welcome_banner_heading'),
className: 'BasicsPage-welcomeBanner',
children: [
<div className="helpText">{app.translator.trans('core.admin.basics.welcome_banner_text')}</div>,
<div className="BasicsPage-welcomeBanner-input">
<input
className="FormControl"
value={this.values.welcome_title()}
oninput={m.withAttr('value', this.values.welcome_title)}
/>
<textarea
className="FormControl"
value={this.values.welcome_message()}
oninput={m.withAttr('value', this.values.welcome_message)}
/>
</div>,
],
})}
{Button.component({
type: 'submit',
className: 'Button Button--primary',
children: app.translator.trans('core.admin.basics.submit_button'),
loading: this.loading,
disabled: !this.changed(),
})}
</form>
</div>
</div>
);
}
changed() {
return this.fields.some((key) => this.values[key]() !== app.data.settings[key]);
}
/**
* Build a list of options for the default homepage. Each option must be an
* object with `path` and `label` properties.
*
* @return {ItemList}
* @public
*/
homePageItems() {
const items = new ItemList();
items.add('allDiscussions', {
path: '/all',
label: app.translator.trans('core.admin.basics.all_discussions_label'),
});
return items;
}
onsubmit(e) {
e.preventDefault();
if (this.loading) return;
this.loading = true;
app.alerts.dismiss(this.successAlert);
const settings = {};
this.fields.forEach((key) => (settings[key] = this.values[key]()));
saveSettings(settings)
.then(() => {
this.successAlert = app.alerts.show({ type: 'success', children: app.translator.trans('core.admin.basics.saved_message') });
})
.catch(() => {})
.then(() => {
this.loading = false;
m.redraw();
});
}
}

View File

@@ -0,0 +1,16 @@
import Page from '../../common/components/Page';
import StatusWidget from './StatusWidget';
export default class DashboardPage extends Page {
view() {
return (
<div className="DashboardPage">
<div className="container">{this.availableWidgets()}</div>
</div>
);
}
availableWidgets() {
return [<StatusWidget />];
}
}

View File

@@ -1,16 +0,0 @@
import Page from './Page';
import StatusWidget from './StatusWidget';
export default class DashboardPage extends Page {
view() {
return (
<div className="DashboardPage">
<div className="container">{this.availableWidgets()}</div>
</div>
);
}
availableWidgets() {
return [<StatusWidget />];
}
}

View File

@@ -0,0 +1,25 @@
import Component from '../../common/Component';
export default class DashboardWidget extends Component {
view() {
return <div className={'DashboardWidget Widget ' + this.className()}>{this.content()}</div>;
}
/**
* Get the class name to apply to the widget.
*
* @return {String}
*/
className() {
return '';
}
/**
* Get the content of the widget.
*
* @return {VirtualElement}
*/
content() {
return [];
}
}

View File

@@ -1,23 +0,0 @@
import Component from '../../common/Component';
export default abstract class DashboardWidget extends Component {
view() {
return <div className={'DashboardWidget ' + this.className()}>{this.content()}</div>;
}
/**
* Get the class name to apply to the widget.
*
* @return {String}
*/
className() {
return '';
}
/**
* Get the content of the widget.
*
* @return {VirtualElement}
*/
abstract content(): JSX.Element;
}

View File

@@ -0,0 +1,28 @@
import SettingsModal from './SettingsModal';
export default class EditCustomCssModal extends SettingsModal {
className() {
return 'EditCustomCssModal Modal--large';
}
title() {
return app.translator.trans('core.admin.edit_css.title');
}
form() {
return [
<p>
{app.translator.trans('core.admin.edit_css.customize_text', {
a: <a href="https://github.com/flarum/core/tree/master/less" target="_blank" />,
})}
</p>,
<div className="Form-group">
<textarea className="FormControl" rows="30" bidi={this.setting('custom_less')} />
</div>,
];
}
onsaved() {
window.location.reload();
}
}

View File

@@ -1,28 +0,0 @@
import SettingsModal from './SettingsModal';
export default class EditCustomCssModal extends SettingsModal {
className() {
return 'EditCustomCssModal Modal--large';
}
title() {
return app.translator.trans('core.admin.edit_css.title');
}
form() {
return [
<p>
{app.translator.trans('core.admin.edit_css.customize_text', {
a: <a href="https://github.com/flarum/core/tree/master/less" target="_blank" />,
})}
</p>,
<div className="Form-group">
<textarea className="FormControl" rows="30" bidi={this.setting('custom_less')} />
</div>,
];
}
onsaved() {
window.location.reload();
}
}

View File

@@ -0,0 +1,24 @@
import SettingsModal from './SettingsModal';
export default class EditCustomFooterModal extends SettingsModal {
className() {
return 'EditCustomFooterModal Modal--large';
}
title() {
return app.translator.trans('core.admin.edit_footer.title');
}
form() {
return [
<p>{app.translator.trans('core.admin.edit_footer.customize_text')}</p>,
<div className="Form-group">
<textarea className="FormControl" rows="30" bidi={this.setting('custom_footer')} />
</div>,
];
}
onsaved() {
window.location.reload();
}
}

View File

@@ -1,24 +0,0 @@
import SettingsModal from './SettingsModal';
export default class EditCustomFooterModal extends SettingsModal {
className() {
return 'EditCustomFooterModal Modal--large';
}
title() {
return app.translator.trans('core.admin.edit_footer.title');
}
form() {
return [
<p>{app.translator.trans('core.admin.edit_footer.customize_text')}</p>,
<div className="Form-group">
<textarea className="FormControl" rows="30" bidi={this.setting('custom_footer')} />
</div>,
];
}
onsaved() {
window.location.reload();
}
}

View File

@@ -0,0 +1,24 @@
import SettingsModal from './SettingsModal';
export default class EditCustomHeaderModal extends SettingsModal {
className() {
return 'EditCustomHeaderModal Modal--large';
}
title() {
return app.translator.trans('core.admin.edit_header.title');
}
form() {
return [
<p>{app.translator.trans('core.admin.edit_header.customize_text')}</p>,
<div className="Form-group">
<textarea className="FormControl" rows="30" bidi={this.setting('custom_header')} />
</div>,
];
}
onsaved() {
window.location.reload();
}
}

View File

@@ -1,24 +0,0 @@
import SettingsModal from './SettingsModal';
export default class EditCustomHeaderModal extends SettingsModal {
className() {
return 'EditCustomHeaderModal Modal--large';
}
title() {
return app.translator.trans('core.admin.edit_header.title');
}
form() {
return [
<p>{app.translator.trans('core.admin.edit_header.customize_text')}</p>,
<div className="Form-group">
<textarea className="FormControl" rows="30" bidi={this.setting('custom_header')} />
</div>,
];
}
onsaved() {
window.location.reload();
}
}

View File

@@ -0,0 +1,156 @@
import Modal from '../../common/components/Modal';
import Button from '../../common/components/Button';
import Badge from '../../common/components/Badge';
import Group from '../../common/models/Group';
import ItemList from '../../common/utils/ItemList';
import Switch from '../../common/components/Switch';
import withAttr from '../../common/utils/withAttr';
/**
* The `EditGroupModal` component shows a modal dialog which allows the user
* to create or edit a group.
*/
export default class EditGroupModal extends Modal {
oninit(vnode) {
super.oninit(vnode);
this.group = this.attrs.group || app.store.createRecord('groups');
this.nameSingular = m.stream(this.group.nameSingular() || '');
this.namePlural = m.stream(this.group.namePlural() || '');
this.icon = m.stream(this.group.icon() || '');
this.color = m.stream(this.group.color() || '');
this.isHidden = m.stream(this.group.isHidden() || false);
}
className() {
return 'EditGroupModal Modal--small';
}
title() {
return [
this.color() || this.icon()
? Badge.component({
icon: this.icon(),
style: { backgroundColor: this.color() },
})
: '',
' ',
this.namePlural() || app.translator.trans('core.admin.edit_group.title'),
];
}
content() {
return (
<div className="Modal-body">
<div className="Form">{this.fields().toArray()}</div>
</div>
);
}
fields() {
const items = new ItemList();
items.add(
'name',
<div className="Form-group">
<label>{app.translator.trans('core.admin.edit_group.name_label')}</label>
<div className="EditGroupModal-name-input">
<input className="FormControl" placeholder={app.translator.trans('core.admin.edit_group.singular_placeholder')} bidi={this.nameSingular} />
<input className="FormControl" placeholder={app.translator.trans('core.admin.edit_group.plural_placeholder')} bidi={this.namePlural} />
</div>
</div>,
30
);
items.add(
'color',
<div className="Form-group">
<label>{app.translator.trans('core.admin.edit_group.color_label')}</label>
<input className="FormControl" placeholder="#aaaaaa" bidi={this.color} />
</div>,
20
);
items.add(
'icon',
<div className="Form-group">
<label>{app.translator.trans('core.admin.edit_group.icon_label')}</label>
<div className="helpText">
{app.translator.trans('core.admin.edit_group.icon_text', { a: <a href="https://fontawesome.com/icons?m=free" tabindex="-1" /> })}
</div>
<input className="FormControl" placeholder="fas fa-bolt" bidi={this.icon} />
</div>,
10
);
items.add(
'hidden',
<div className="Form-group">
{Switch.component(
{
state: !!Number(this.isHidden()),
onchange: this.isHidden,
},
app.translator.trans('core.admin.edit_group.hide_label')
)}
</div>,
10
);
items.add(
'submit',
<div className="Form-group">
{Button.component(
{
type: 'submit',
className: 'Button Button--primary EditGroupModal-save',
loading: this.loading,
},
app.translator.trans('core.admin.edit_group.submit_button')
)}
{this.group.exists && this.group.id() !== Group.ADMINISTRATOR_ID ? (
<button type="button" className="Button EditGroupModal-delete" onclick={this.deleteGroup.bind(this)}>
{app.translator.trans('core.admin.edit_group.delete_button')}
</button>
) : (
''
)}
</div>,
-10
);
return items;
}
submitData() {
return {
nameSingular: this.nameSingular(),
namePlural: this.namePlural(),
color: this.color(),
icon: this.icon(),
isHidden: this.isHidden(),
};
}
onsubmit(e) {
e.preventDefault();
this.loading = true;
this.group
.save(this.submitData(), { errorHandler: this.onerror.bind(this) })
.then(this.hide.bind(this))
.catch(() => {
this.loading = false;
m.redraw();
});
}
deleteGroup() {
if (confirm(app.translator.trans('core.admin.edit_group.delete_confirmation'))) {
this.group.delete().then(() => m.redraw());
this.hide();
}
}
}

View File

@@ -1,157 +0,0 @@
import app from '../app';
import { ComponentProps } from '../../common/Component';
import Modal from '../../common/components/Modal';
import Button from '../../common/components/Button';
import Badge from '../../common/components/Badge';
import Group from '../../common/models/Group';
import ItemList from '../../common/utils/ItemList';
import Stream from 'mithril/stream';
/**
* The `EditGroupModal` component shows a modal dialog which allows the user
* to create or edit a group.
*/
export default class EditGroupModal extends Modal<ComponentProps> {
group: Group;
nameSingular: Stream<string>;
namePlural: Stream<string>;
icon: Stream<string>;
color: Stream<string>;
oninit(vnode) {
super.oninit(vnode);
this.group = this.props.group || app.store.createRecord('groups');
this.nameSingular = m.prop(this.group.nameSingular() || '');
this.namePlural = m.prop(this.group.namePlural() || '');
this.icon = m.prop(this.group.icon() || '');
this.color = m.prop(this.group.color() || '');
}
className() {
return 'EditGroupModal Modal--small';
}
title() {
return [
this.color() || this.icon()
? Badge.component({
icon: this.icon(),
style: { backgroundColor: this.color() },
})
: '',
' ',
this.namePlural() || app.translator.trans('core.admin.edit_group.title'),
];
}
content() {
return (
<div className="Modal-body">
<div className="Form">{this.fields().toArray()}</div>
</div>
);
}
fields() {
const items = new ItemList();
items.add(
'name',
<div className="Form-group">
<label>{app.translator.trans('core.admin.edit_group.name_label')}</label>
<div className="EditGroupModal-name-input">
<input
className="FormControl"
placeholder={app.translator.transText('core.admin.edit_group.singular_placeholder')}
value={this.nameSingular()}
oninput={m.withAttr('value', this.nameSingular)}
/>
<input
className="FormControl"
placeholder={app.translator.transText('core.admin.edit_group.plural_placeholder')}
value={this.namePlural()}
oninput={m.withAttr('value', this.namePlural)}
/>
</div>
</div>,
30
);
items.add(
'color',
<div className="Form-group">
<label>{app.translator.trans('core.admin.edit_group.color_label')}</label>
<input className="FormControl" placeholder="#aaaaaa" value={this.color()} oninput={m.withAttr('value', this.color)} />
</div>,
20
);
items.add(
'icon',
<div className="Form-group">
<label>{app.translator.trans('core.admin.edit_group.icon_label')}</label>
<div className="helpText">
{app.translator.trans('core.admin.edit_group.icon_text', { a: <a href="https://fontawesome.com/icons?m=free" tabindex="-1" /> })}
</div>
<input className="FormControl" placeholder="fas fa-bolt" value={this.icon()} oninput={m.withAttr('value', this.icon)} />
</div>,
10
);
items.add(
'submit',
<div className="Form-group">
{Button.component({
type: 'submit',
className: 'Button Button--primary EditGroupModal-save',
loading: this.loading,
children: app.translator.trans('core.admin.edit_group.submit_button'),
})}
{this.group.exists && this.group.id() !== Group.ADMINISTRATOR_ID ? (
<button type="button" className="Button EditGroupModal-delete" onclick={this.deleteGroup.bind(this)}>
{app.translator.trans('core.admin.edit_group.delete_button')}
</button>
) : (
''
)}
</div>,
-10
);
return items;
}
submitData() {
return {
nameSingular: this.nameSingular(),
namePlural: this.namePlural(),
color: this.color(),
icon: this.icon(),
};
}
onsubmit(e) {
e.preventDefault();
this.loading = true;
this.group
.save(this.submitData(), { errorHandler: this.onerror.bind(this) })
.then(this.hide.bind(this))
.catch(() => {
this.loading = false;
m.redraw();
});
}
deleteGroup() {
if (confirm(app.translator.transText('core.admin.edit_group.delete_confirmation'))) {
this.group.delete().then(() => m.redraw());
this.hide();
}
}
}

View File

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

View File

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

View File

@@ -0,0 +1,29 @@
import Component from '../../common/Component';
import ItemList from '../../common/utils/ItemList';
import listItems from '../../common/helpers/listItems';
/**
* The `HeaderPrimary` component displays primary header controls. On the
* default skin, these are shown just to the right of the forum title.
*/
export default class HeaderPrimary extends Component {
view() {
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.
*
* @return {ItemList}
*/
items() {
return new ItemList();
}
}

View File

@@ -1,22 +0,0 @@
import Component from '../../common/Component';
import ItemList from '../../common/utils/ItemList';
import listItems from '../../common/helpers/listItems';
/**
* The `HeaderPrimary` component displays primary header controls. On the
* default skin, these are shown just to the right of the forum title.
*/
export default class HeaderPrimary extends Component {
view() {
return <ul className="Header-controls">{listItems(this.items().toArray())}</ul>;
}
/**
* Build an item list for the controls.
*
* @return {ItemList}
*/
items() {
return new ItemList();
}
}

View File

@@ -7,20 +7,20 @@ import listItems from '../../common/helpers/listItems';
* The `HeaderSecondary` component displays secondary header controls.
*/
export default class HeaderSecondary extends Component {
view() {
return <ul className="Header-controls">{listItems(this.items().toArray())}</ul>;
}
view() {
return <ul className="Header-controls">{listItems(this.items().toArray())}</ul>;
}
/**
* Build an item list for the controls.
*
* @return {ItemList}
*/
items() {
const items = new ItemList();
/**
* Build an item list for the controls.
*
* @return {ItemList}
*/
items() {
const items = new ItemList();
items.add('session', SessionDropdown.component());
items.add('session', SessionDropdown.component());
return items;
}
return items;
}
}

View File

@@ -0,0 +1,20 @@
import Modal from '../../common/components/Modal';
export default class LoadingModal extends Modal {
/**
* @inheritdoc
*/
static isDismissible = false;
className() {
return 'LoadingModal Modal--small';
}
title() {
return app.translator.trans('core.admin.loading.title');
}
content() {
return '';
}
}

View File

@@ -1,20 +0,0 @@
import { ComponentProps } from '../../common/Component';
import Modal from '../../common/components/Modal';
export default class LoadingModal extends Modal<ComponentProps> {
isDismissible() {
return false;
}
className() {
return 'LoadingModal Modal--small';
}
title() {
return app.translator.transText('core.admin.loading.title');
}
content() {
return '';
}
}

View File

@@ -0,0 +1,232 @@
import Page from '../../common/components/Page';
import FieldSet from '../../common/components/FieldSet';
import Button from '../../common/components/Button';
import Alert from '../../common/components/Alert';
import Select from '../../common/components/Select';
import LoadingIndicator from '../../common/components/LoadingIndicator';
import saveSettings from '../utils/saveSettings';
import withAttr from '../../common/utils/withAttr';
export default class MailPage extends Page {
oninit(vnode) {
super.oninit(vnode);
this.saving = false;
this.sendingTest = false;
this.refresh();
}
refresh() {
this.loading = true;
this.driverFields = {};
this.fields = ['mail_driver', 'mail_from'];
this.values = {};
this.status = { sending: false, errors: {} };
const settings = app.data.settings;
this.fields.forEach((key) => (this.values[key] = m.stream(settings[key])));
app
.request({
method: 'GET',
url: app.forum.attribute('apiUrl') + '/mail/settings',
})
.then((response) => {
this.driverFields = response['data']['attributes']['fields'];
this.status.sending = response['data']['attributes']['sending'];
this.status.errors = response['data']['attributes']['errors'];
for (const driver in this.driverFields) {
for (const field in this.driverFields[driver]) {
this.fields.push(field);
this.values[field] = m.stream(settings[field]);
}
}
this.loading = false;
m.redraw();
});
}
view() {
if (this.loading || this.saving) {
return (
<div className="MailPage">
<div className="container">
<LoadingIndicator />
</div>
</div>
);
}
const fields = this.driverFields[this.values.mail_driver()];
const fieldKeys = Object.keys(fields);
return (
<div className="MailPage">
<div className="container">
<form onsubmit={this.onsubmit.bind(this)}>
<h2>{app.translator.trans('core.admin.email.heading')}</h2>
<div className="helpText">{app.translator.trans('core.admin.email.text')}</div>
{FieldSet.component(
{
label: app.translator.trans('core.admin.email.addresses_heading'),
className: 'MailPage-MailSettings',
},
[
<div className="MailPage-MailSettings-input">
<label>
{app.translator.trans('core.admin.email.from_label')}
<input className="FormControl" bidi={this.values.mail_from} />
</label>
</div>,
]
)}
{FieldSet.component(
{
label: app.translator.trans('core.admin.email.driver_heading'),
className: 'MailPage-MailSettings',
},
[
<div className="MailPage-MailSettings-input">
<label>
{app.translator.trans('core.admin.email.driver_label')}
<Select
value={this.values.mail_driver()}
options={Object.keys(this.driverFields).reduce((memo, val) => ({ ...memo, [val]: val }), {})}
onchange={this.values.mail_driver}
/>
</label>
</div>,
]
)}
{this.status.sending ||
Alert.component(
{
dismissible: false,
},
app.translator.trans('core.admin.email.not_sending_message')
)}
{fieldKeys.length > 0 &&
FieldSet.component(
{
label: app.translator.trans(`core.admin.email.${this.values.mail_driver()}_heading`),
className: 'MailPage-MailSettings',
},
[
<div className="MailPage-MailSettings-input">
{fieldKeys.map((field) => [
<label>
{app.translator.trans(`core.admin.email.${field}_label`)}
{this.renderField(field)}
</label>,
this.status.errors[field] && <p className="ValidationError">{this.status.errors[field]}</p>,
])}
</div>,
]
)}
<FieldSet>
{Button.component(
{
type: 'submit',
className: 'Button Button--primary',
disabled: !this.changed(),
},
app.translator.trans('core.admin.email.submit_button')
)}
</FieldSet>
{FieldSet.component(
{
label: app.translator.trans('core.admin.email.send_test_mail_heading'),
className: 'MailPage-MailSettings',
},
[
<div className="helpText">{app.translator.trans('core.admin.email.send_test_mail_text', { email: app.session.user.email() })}</div>,
Button.component(
{
className: 'Button Button--primary',
disabled: this.sendingTest || this.changed(),
onclick: () => this.sendTestEmail(),
},
app.translator.trans('core.admin.email.send_test_mail_button')
),
]
)}
</form>
</div>
</div>
);
}
renderField(name) {
const driver = this.values.mail_driver();
const field = this.driverFields[driver][name];
const prop = this.values[name];
if (typeof field === 'string') {
return <input className="FormControl" bidi={prop} />;
} else {
return <Select value={prop()} options={field} onchange={prop} />;
}
}
changed() {
return this.fields.some((key) => this.values[key]() !== app.data.settings[key]);
}
sendTestEmail() {
if (this.saving || this.sendingTest) return;
this.sendingTest = true;
app.alerts.dismiss(this.testEmailSuccessAlert);
app
.request({
method: 'POST',
url: app.forum.attribute('apiUrl') + '/mail/test',
})
.then((response) => {
this.sendingTest = false;
this.testEmailSuccessAlert = app.alerts.show(app.translator.trans('core.admin.email.send_test_mail_success'), {
type: 'success',
});
})
.catch((error) => {
this.sendingTest = false;
m.redraw();
throw error;
});
}
onsubmit(e) {
e.preventDefault();
if (this.saving || this.sendingTest) return;
this.saving = true;
app.alerts.dismiss(this.successAlert);
const settings = {};
this.fields.forEach((key) => (settings[key] = this.values[key]()));
saveSettings(settings)
.then(() => {
this.successAlert = app.alerts.show(app.translator.trans('core.admin.basics.saved_message'), {
type: 'success',
});
})
.catch(() => {})
.then(() => {
this.saving = false;
this.refresh();
});
}
}

View File

@@ -1,191 +0,0 @@
import app from '../app';
import Page from './Page';
import Alert from '../../common/components/Alert';
import Button from '../../common/components/Button';
import FieldSet from '../../common/components/FieldSet';
import LoadingIndicator from '../../common/components/LoadingIndicator';
import Select from '../../common/components/Select';
import saveSettings from '../utils/saveSettings';
import AlertState from '../../common/states/AlertState';
import Stream from 'mithril/stream';
export default class MailPage extends Page {
loading = true;
saving = false;
driverFields = {};
fields = [];
values: { [key: string]: Stream<any> } = {};
status = { sending: false, errors: {} };
successAlert?: number;
oninit(vnode) {
super.oninit(vnode);
this.refresh();
}
refresh() {
this.loading = true;
this.fields = ['mail_driver', 'mail_from'];
this.values = {};
app.request({
method: 'GET',
url: app.forum.attribute('apiUrl') + '/mail-settings',
}).then((response) => {
this.driverFields = response['data']['attributes']['fields'];
this.status.sending = response['data']['attributes']['sending'];
this.status.errors = response['data']['attributes']['errors'];
for (const driver in this.driverFields) {
for (const field in this.driverFields[driver]) {
this.fields.push(field);
}
}
const settings = app.data.settings;
this.fields.forEach((key) => (this.values[key] = m.prop(settings[key])));
this.loading = false;
m.redraw();
});
}
view() {
if (this.loading || this.saving) {
return (
<div className="MailPage">
<div className="container">
<LoadingIndicator />
</div>
</div>
);
}
const fields = this.driverFields[this.values.mail_driver()];
const fieldKeys = Object.keys(fields);
return (
<div className="MailPage">
<div className="container">
<form onsubmit={this.onsubmit.bind(this)}>
<h2>{app.translator.trans('core.admin.email.heading')}</h2>
<div className="helpText">{app.translator.trans('core.admin.email.text')}</div>
{FieldSet.component({
label: app.translator.trans('core.admin.email.addresses_heading'),
className: 'MailPage-MailSettings',
children: [
<div className="MailPage-MailSettings-input">
<label>
{app.translator.trans('core.admin.email.from_label')}
<input
className="FormControl"
value={this.values.mail_from() || ''}
oninput={m.withAttr('value', this.values.mail_from)}
/>
</label>
</div>,
],
})}
{FieldSet.component({
label: app.translator.trans('core.admin.email.driver_heading'),
className: 'MailPage-MailSettings',
children: [
<div className="MailPage-MailSettings-input">
<label>
{app.translator.trans('core.admin.email.driver_label')}
<Select
value={this.values.mail_driver()}
options={Object.keys(this.driverFields).reduce((memo, val) => ({ ...memo, [val]: val }), {})}
onchange={this.values.mail_driver}
/>
</label>
</div>,
],
})}
{this.status.sending ||
Alert.component({
children: app.translator.trans('core.admin.email.not_sending_message'),
dismissible: false,
})}
{fieldKeys.length > 0 &&
FieldSet.component({
label: app.translator.trans(`core.admin.email.${this.values.mail_driver()}_heading`),
className: 'MailPage-MailSettings',
children: [
<div className="MailPage-MailSettings-input">
{fieldKeys.map((field) => [
<label>
{app.translator.trans(`core.admin.email.${field}_label`)}
{this.renderField(field)}
</label>,
this.status.errors[field] && <p className="ValidationError">{this.status.errors[field]}</p>,
])}
</div>,
],
})}
{Button.component({
type: 'submit',
className: 'Button Button--primary',
children: app.translator.trans('core.admin.email.submit_button'),
disabled: !this.changed(),
})}
</form>
</div>
</div>
);
}
renderField(name) {
const driver = this.values.mail_driver();
const field = this.driverFields[driver][name];
const prop = this.values[name];
if (prop == undefined) {
}
if (typeof field === 'string') {
return <input className="FormControl" value={prop() || ''} oninput={m.withAttr('value', prop)} />;
} else {
return <Select value={prop()} options={field} onchange={prop} />;
}
}
changed() {
return this.fields.some((key) => this.values[key]() !== app.data.settings[key]);
}
onsubmit(e) {
e.preventDefault();
if (this.saving) return;
this.saving = true;
app.alerts.dismiss(this.successAlert);
const settings = {};
this.fields.forEach((key) => (settings[key] = this.values[key]()));
saveSettings(settings)
.then(() => {
this.successAlert = app.alerts.show({ type: 'success', children: app.translator.trans('core.admin.basics.saved_message') });
})
.catch(() => {})
.then(() => {
this.saving = false;
this.refresh();
});
}
}

View File

@@ -1,3 +0,0 @@
import Page from '../../common/components/Page';
export default Page;

View File

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

View File

@@ -1,159 +0,0 @@
import app from '../app';
import Dropdown, { DropdownProps } from '../../common/components/Dropdown';
import Button from '../../common/components/Button';
import Separator from '../../common/components/Separator';
import Group from '../../common/models/Group';
import Badge from '../../common/components/Badge';
import GroupBadge from '../../common/components/GroupBadge';
function badgeForId(id) {
const group = app.store.getById('groups', id);
return group ? GroupBadge.component({ group, label: null }) : '';
}
function filterByRequiredPermissions(groupIds, permission) {
app.getRequiredPermissions(permission).forEach((required) => {
const restrictToGroupIds = app.data.permissions[required] || [];
if (restrictToGroupIds.indexOf(Group.GUEST_ID) !== -1) {
// do nothing
} else if (restrictToGroupIds.indexOf(Group.MEMBER_ID) !== -1) {
groupIds = groupIds.filter((id) => id !== Group.GUEST_ID);
} else if (groupIds.indexOf(Group.MEMBER_ID) !== -1) {
groupIds = restrictToGroupIds;
} else {
groupIds = restrictToGroupIds.filter((id) => groupIds.indexOf(id) !== -1);
}
groupIds = filterByRequiredPermissions(groupIds, required);
});
return groupIds;
}
export interface PermissionDropdownProps extends DropdownProps {
label?: Badge[];
}
export default class PermissionDropdown<T extends PermissionDropdownProps = PermissionDropdownProps> extends Dropdown<T> {
static initProps(props) {
super.initProps(props);
props.className = 'PermissionDropdown';
props.buttonClassName = 'Button Button--text';
}
view() {
this.props.children = [];
let groupIds = app.data.permissions[this.props.permission] || [];
groupIds = filterByRequiredPermissions(groupIds, this.props.permission);
const everyone = groupIds.indexOf(Group.GUEST_ID) !== -1;
const members = groupIds.indexOf(Group.MEMBER_ID) !== -1;
const adminGroup: Group = app.store.getById('groups', Group.ADMINISTRATOR_ID);
if (everyone) {
this.props.label = Badge.component({ icon: 'fas fa-globe' });
} else if (members) {
this.props.label = Badge.component({ icon: 'fas fa-user' });
} else {
this.props.label = [badgeForId(Group.ADMINISTRATOR_ID), groupIds.map(badgeForId)];
}
if (this.showing) {
if (this.props.allowGuest) {
this.props.children.push(
Button.component({
children: [
Badge.component({ icon: 'fas fa-globe' }),
' ',
app.translator.trans('core.admin.permissions_controls.everyone_button'),
],
icon: everyone ? 'fas fa-check' : true,
onclick: () => this.save([Group.GUEST_ID]),
disabled: this.isGroupDisabled(Group.GUEST_ID),
})
);
}
this.props.children.push(
Button.component({
children: [Badge.component({ icon: 'fas fa-user' }), ' ', app.translator.trans('core.admin.permissions_controls.members_button')],
icon: members ? 'fas fa-check' : true,
onclick: () => this.save([Group.MEMBER_ID]),
disabled: this.isGroupDisabled(Group.MEMBER_ID),
}),
Separator.component(),
Button.component({
children: [badgeForId(adminGroup.id()), ' ', adminGroup.namePlural()],
icon: !everyone && !members ? 'fas fa-check' : true,
disabled: !everyone && !members,
onclick: (e) => {
if (e.shiftKey) e.stopPropagation();
this.save([]);
},
})
);
[].push.apply(
this.props.children,
app.store
.all('groups')
.filter((group) => [Group.ADMINISTRATOR_ID, Group.GUEST_ID, Group.MEMBER_ID].indexOf(group.id()) === -1)
.map((group: Group) =>
Button.component({
children: [badgeForId(group.id()), ' ', group.namePlural()],
icon: groupIds.indexOf(group.id()) !== -1 ? 'fas fa-check' : true,
onclick: (e) => {
if (e.shiftKey) e.stopPropagation();
this.toggle(group.id());
},
disabled:
this.isGroupDisabled(group.id()) && this.isGroupDisabled(Group.MEMBER_ID) && this.isGroupDisabled(Group.GUEST_ID),
})
)
);
}
return super.view();
}
save(groupIds) {
const permission = this.props.permission;
app.data.permissions[permission] = groupIds;
app.request({
method: 'POST',
url: app.forum.attribute('apiUrl') + '/permission',
body: { permission, groupIds },
});
}
toggle(groupId) {
const permission = this.props.permission;
let groupIds = app.data.permissions[permission] || [];
const index = groupIds.indexOf(groupId);
if (index !== -1) {
groupIds.splice(index, 1);
} else {
groupIds.push(groupId);
groupIds = groupIds.filter((id) => [Group.GUEST_ID, Group.MEMBER_ID].indexOf(id) === -1);
}
this.save(groupIds);
}
isGroupDisabled(id) {
return filterByRequiredPermissions([id], this.props.permission).indexOf(id) === -1;
}
}

View File

@@ -0,0 +1,369 @@
import Component from '../../common/Component';
import PermissionDropdown from './PermissionDropdown';
import SettingDropdown from './SettingDropdown';
import Button from '../../common/components/Button';
import ItemList from '../../common/utils/ItemList';
import icon from '../../common/helpers/icon';
export default class PermissionGrid extends Component {
oninit(vnode) {
super.oninit(vnode);
this.permissions = this.permissionItems().toArray();
}
view() {
const scopes = this.scopeItems().toArray();
const permissionCells = (permission) => {
return scopes.map((scope) => <td>{scope.render(permission)}</td>);
};
return (
<table className="PermissionGrid">
<thead>
<tr>
<td></td>
{scopes.map((scope) => (
<th>
{scope.label}{' '}
{scope.onremove
? Button.component({ icon: 'fas fa-times', className: 'Button Button--text PermissionGrid-removeScope', onclick: scope.onremove })
: ''}
</th>
))}
<th>{this.scopeControlItems().toArray()}</th>
</tr>
</thead>
{this.permissions.map((section) => (
<tbody>
<tr className="PermissionGrid-section">
<th>{section.label}</th>
{permissionCells(section)}
<td />
</tr>
{section.children.map((child) => (
<tr className="PermissionGrid-child">
<th>
{icon(child.icon)}
{child.label}
</th>
{permissionCells(child)}
<td />
</tr>
))}
</tbody>
))}
</table>
);
}
permissionItems() {
const items = new ItemList();
items.add(
'view',
{
label: app.translator.trans('core.admin.permissions.read_heading'),
children: this.viewItems().toArray(),
},
100
);
items.add(
'start',
{
label: app.translator.trans('core.admin.permissions.create_heading'),
children: this.startItems().toArray(),
},
90
);
items.add(
'reply',
{
label: app.translator.trans('core.admin.permissions.participate_heading'),
children: this.replyItems().toArray(),
},
80
);
items.add(
'moderate',
{
label: app.translator.trans('core.admin.permissions.moderate_heading'),
children: this.moderateItems().toArray(),
},
70
);
return items;
}
viewItems() {
const items = new ItemList();
items.add(
'viewDiscussions',
{
icon: 'fas fa-eye',
label: app.translator.trans('core.admin.permissions.view_discussions_label'),
permission: 'viewDiscussions',
allowGuest: true,
},
100
);
items.add(
'viewHiddenGroups',
{
icon: 'fas fa-users',
label: app.translator.trans('core.admin.permissions.view_hidden_groups_label'),
permission: 'viewHiddenGroups',
},
100
);
items.add(
'viewUserList',
{
icon: 'fas fa-users',
label: app.translator.trans('core.admin.permissions.view_user_list_label'),
permission: 'viewUserList',
allowGuest: true,
},
100
);
items.add(
'signUp',
{
icon: 'fas fa-user-plus',
label: app.translator.trans('core.admin.permissions.sign_up_label'),
setting: () =>
SettingDropdown.component({
key: 'allow_sign_up',
options: [
{ value: '1', label: app.translator.trans('core.admin.permissions_controls.signup_open_button') },
{ value: '0', label: app.translator.trans('core.admin.permissions_controls.signup_closed_button') },
],
}),
},
90
);
items.add('viewLastSeenAt', {
icon: 'far fa-clock',
label: app.translator.trans('core.admin.permissions.view_last_seen_at_label'),
permission: 'user.viewLastSeenAt',
});
return items;
}
startItems() {
const items = new ItemList();
items.add(
'start',
{
icon: 'fas fa-edit',
label: app.translator.trans('core.admin.permissions.start_discussions_label'),
permission: 'startDiscussion',
},
100
);
items.add(
'allowRenaming',
{
icon: 'fas fa-i-cursor',
label: app.translator.trans('core.admin.permissions.allow_renaming_label'),
setting: () => {
const minutes = parseInt(app.data.settings.allow_renaming, 10);
return SettingDropdown.component({
defaultLabel: minutes
? app.translator.transChoice('core.admin.permissions_controls.allow_some_minutes_button', minutes, { count: minutes })
: app.translator.trans('core.admin.permissions_controls.allow_indefinitely_button'),
key: 'allow_renaming',
options: [
{ value: '-1', label: app.translator.trans('core.admin.permissions_controls.allow_indefinitely_button') },
{ value: '10', label: app.translator.trans('core.admin.permissions_controls.allow_ten_minutes_button') },
{ value: 'reply', label: app.translator.trans('core.admin.permissions_controls.allow_until_reply_button') },
],
});
},
},
90
);
return items;
}
replyItems() {
const items = new ItemList();
items.add(
'reply',
{
icon: 'fas fa-reply',
label: app.translator.trans('core.admin.permissions.reply_to_discussions_label'),
permission: 'discussion.reply',
},
100
);
items.add(
'allowPostEditing',
{
icon: 'fas fa-pencil-alt',
label: app.translator.trans('core.admin.permissions.allow_post_editing_label'),
setting: () => {
const minutes = parseInt(app.data.settings.allow_post_editing, 10);
return SettingDropdown.component({
defaultLabel: minutes
? app.translator.transChoice('core.admin.permissions_controls.allow_some_minutes_button', minutes, { count: minutes })
: app.translator.trans('core.admin.permissions_controls.allow_indefinitely_button'),
key: 'allow_post_editing',
options: [
{ value: '-1', label: app.translator.trans('core.admin.permissions_controls.allow_indefinitely_button') },
{ value: '10', label: app.translator.trans('core.admin.permissions_controls.allow_ten_minutes_button') },
{ value: 'reply', label: app.translator.trans('core.admin.permissions_controls.allow_until_reply_button') },
],
});
},
},
90
);
return items;
}
moderateItems() {
const items = new ItemList();
items.add(
'viewIpsPosts',
{
icon: 'fas fa-bullseye',
label: app.translator.trans('core.admin.permissions.view_post_ips_label'),
permission: 'discussion.viewIpsPosts',
},
110
);
items.add(
'renameDiscussions',
{
icon: 'fas fa-i-cursor',
label: app.translator.trans('core.admin.permissions.rename_discussions_label'),
permission: 'discussion.rename',
},
100
);
items.add(
'hideDiscussions',
{
icon: 'far fa-trash-alt',
label: app.translator.trans('core.admin.permissions.delete_discussions_label'),
permission: 'discussion.hide',
},
90
);
items.add(
'deleteDiscussions',
{
icon: 'fas fa-times',
label: app.translator.trans('core.admin.permissions.delete_discussions_forever_label'),
permission: 'discussion.delete',
},
80
);
items.add(
'postWithoutThrottle',
{
icon: 'fas fa-swimmer',
label: app.translator.trans('core.admin.permissions.post_without_throttle_label'),
permission: 'postWithoutThrottle',
},
70
);
items.add(
'editPosts',
{
icon: 'fas fa-pencil-alt',
label: app.translator.trans('core.admin.permissions.edit_posts_label'),
permission: 'discussion.editPosts',
},
70
);
items.add(
'hidePosts',
{
icon: 'far fa-trash-alt',
label: app.translator.trans('core.admin.permissions.delete_posts_label'),
permission: 'discussion.hidePosts',
},
60
);
items.add(
'deletePosts',
{
icon: 'fas fa-times',
label: app.translator.trans('core.admin.permissions.delete_posts_forever_label'),
permission: 'discussion.deletePosts',
},
60
);
items.add(
'userEdit',
{
icon: 'fas fa-user-cog',
label: app.translator.trans('core.admin.permissions.edit_users_label'),
permission: 'user.edit',
},
60
);
return items;
}
scopeItems() {
const items = new ItemList();
items.add(
'global',
{
label: app.translator.trans('core.admin.permissions.global_heading'),
render: (item) => {
if (item.setting) {
return item.setting();
} else if (item.permission) {
return PermissionDropdown.component({
permission: item.permission,
allowGuest: item.allowGuest,
});
}
return '';
},
},
100
);
return items;
}
scopeControlItems() {
return new ItemList();
}
}

View File

@@ -1,361 +0,0 @@
import app from '../app';
import Component from '../../common/Component';
import PermissionDropdown from './PermissionDropdown';
import SettingDropdown from './SettingDropdown';
import Button from '../../common/components/Button';
import ItemList from '../../common/utils/ItemList';
import icon from '../../common/helpers/icon';
export default class PermissionGrid extends Component {
view() {
const scopes = this.scopeItems().toArray();
const permissionCells = (permission) => {
return scopes.map((scope) => <td>{scope.render(permission)}</td>);
};
return (
<table className="PermissionGrid">
<thead>
<tr>
<td></td>
{scopes.map((scope) => (
<th>
{scope.label}{' '}
{scope.onremove
? Button.component({
icon: 'fas fa-times',
className: 'Button Button--text PermissionGrid-removeScope',
onclick: scope.onremove,
})
: ''}
</th>
))}
<th>{this.scopeControlItems().toArray()}</th>
</tr>
</thead>
{this.permissionItems()
.toArray()
.map((section) => (
<tbody>
<tr className="PermissionGrid-section">
<th>{section.label}</th>
{permissionCells(section)}
<td />
</tr>
{section.children.map((child) => (
<tr className="PermissionGrid-child">
<th>
{icon(child.icon)}
{child.label}
</th>
{permissionCells(child)}
<td />
</tr>
))}
</tbody>
))}
</table>
);
}
permissionItems() {
const items = new ItemList();
items.add(
'view',
{
label: app.translator.trans('core.admin.permissions.read_heading'),
children: this.viewItems().toArray(),
},
100
);
items.add(
'start',
{
label: app.translator.trans('core.admin.permissions.create_heading'),
children: this.startItems().toArray(),
},
90
);
items.add(
'reply',
{
label: app.translator.trans('core.admin.permissions.participate_heading'),
children: this.replyItems().toArray(),
},
80
);
items.add(
'moderate',
{
label: app.translator.trans('core.admin.permissions.moderate_heading'),
children: this.moderateItems().toArray(),
},
70
);
return items;
}
viewItems() {
const items = new ItemList();
items.add(
'viewDiscussions',
{
icon: 'fas fa-eye',
label: app.translator.trans('core.admin.permissions.view_discussions_label'),
permission: 'viewDiscussions',
allowGuest: true,
},
100
);
items.add(
'viewUserList',
{
icon: 'fas fa-users',
label: app.translator.trans('core.admin.permissions.view_user_list_label'),
permission: 'viewUserList',
allowGuest: true,
},
100
);
items.add(
'signUp',
{
icon: 'fas fa-user-plus',
label: app.translator.trans('core.admin.permissions.sign_up_label'),
setting: () =>
SettingDropdown.component({
key: 'allow_sign_up',
options: [
{ value: '1', label: app.translator.transText('core.admin.permissions_controls.signup_open_button') },
{ value: '0', label: app.translator.transText('core.admin.permissions_controls.signup_closed_button') },
],
}),
},
90
);
items.add('viewLastSeenAt', {
icon: 'far fa-clock',
label: app.translator.trans('core.admin.permissions.view_last_seen_at_label'),
permission: 'user.viewLastSeenAt',
});
return items;
}
startItems() {
const items = new ItemList();
items.add(
'start',
{
icon: 'fas fa-edit',
label: app.translator.trans('core.admin.permissions.start_discussions_label'),
permission: 'startDiscussion',
},
100
);
items.add(
'allowRenaming',
{
icon: 'fas fa-i-cursor',
label: app.translator.trans('core.admin.permissions.allow_renaming_label'),
setting: () => {
const minutes = parseInt(app.data.settings.allow_renaming, 10);
return SettingDropdown.component({
defaultLabel: minutes
? app.translator.transChoice('core.admin.permissions_controls.allow_some_minutes_button', minutes, { count: minutes })
: app.translator.trans('core.admin.permissions_controls.allow_indefinitely_button'),
key: 'allow_renaming',
options: [
{ value: '-1', label: app.translator.transText('core.admin.permissions_controls.allow_indefinitely_button') },
{ value: '10', label: app.translator.transText('core.admin.permissions_controls.allow_ten_minutes_button') },
{ value: 'reply', label: app.translator.transText('core.admin.permissions_controls.allow_until_reply_button') },
],
});
},
},
90
);
return items;
}
replyItems() {
const items = new ItemList();
items.add(
'reply',
{
icon: 'fas fa-reply',
label: app.translator.trans('core.admin.permissions.reply_to_discussions_label'),
permission: 'discussion.reply',
},
100
);
items.add(
'allowPostEditing',
{
icon: 'fas fa-pencil-alt',
label: app.translator.trans('core.admin.permissions.allow_post_editing_label'),
setting: () => {
const minutes = parseInt(app.data.settings.allow_post_editing, 10);
return SettingDropdown.component({
defaultLabel: minutes
? app.translator.transChoice('core.admin.permissions_controls.allow_some_minutes_button', minutes, { count: minutes })
: app.translator.trans('core.admin.permissions_controls.allow_indefinitely_button'),
key: 'allow_post_editing',
options: [
{ value: '-1', label: app.translator.transText('core.admin.permissions_controls.allow_indefinitely_button') },
{ value: '10', label: app.translator.transText('core.admin.permissions_controls.allow_ten_minutes_button') },
{ value: 'reply', label: app.translator.transText('core.admin.permissions_controls.allow_until_reply_button') },
],
});
},
},
90
);
return items;
}
moderateItems() {
const items = new ItemList();
items.add(
'viewIpsPosts',
{
icon: 'fas fa-bullseye',
label: app.translator.trans('core.admin.permissions.view_post_ips_label'),
permission: 'discussion.viewIpsPosts',
},
110
);
items.add(
'renameDiscussions',
{
icon: 'fas fa-i-cursor',
label: app.translator.trans('core.admin.permissions.rename_discussions_label'),
permission: 'discussion.rename',
},
100
);
items.add(
'hideDiscussions',
{
icon: 'far fa-trash-alt',
label: app.translator.trans('core.admin.permissions.delete_discussions_label'),
permission: 'discussion.hide',
},
90
);
items.add(
'deleteDiscussions',
{
icon: 'fas fa-times',
label: app.translator.trans('core.admin.permissions.delete_discussions_forever_label'),
permission: 'discussion.delete',
},
80
);
items.add(
'postWithoutThrottle',
{
icon: 'fas fa-swimmer',
label: app.translator.trans('core.admin.permissions.post_without_throttle_label'),
permission: 'postWithoutThrottle',
},
70
);
items.add(
'editPosts',
{
icon: 'fas fa-pencil-alt',
label: app.translator.trans('core.admin.permissions.edit_posts_label'),
permission: 'discussion.editPosts',
},
70
);
items.add(
'hidePosts',
{
icon: 'far fa-trash-alt',
label: app.translator.trans('core.admin.permissions.delete_posts_label'),
permission: 'discussion.hidePosts',
},
60
);
items.add(
'deletePosts',
{
icon: 'fas fa-times',
label: app.translator.trans('core.admin.permissions.delete_posts_forever_label'),
permission: 'discussion.deletePosts',
},
60
);
items.add(
'userEdit',
{
icon: 'fas fa-user-cog',
label: app.translator.trans('core.admin.permissions.edit_users_label'),
permission: 'user.edit',
},
60
);
return items;
}
scopeItems() {
const items = new ItemList();
items.add(
'global',
{
label: app.translator.trans('core.admin.permissions.global_heading'),
render: (item) => {
if (item.setting) {
return item.setting();
} else if (item.permission) {
return PermissionDropdown.component({
permission: item.permission,
allowGuest: item.allowGuest,
});
}
return '';
},
},
100
);
return items;
}
scopeControlItems() {
return new ItemList();
}
}

View File

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

View File

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

View File

@@ -0,0 +1,52 @@
import avatar from '../../common/helpers/avatar';
import username from '../../common/helpers/username';
import Dropdown from '../../common/components/Dropdown';
import Button from '../../common/components/Button';
import ItemList from '../../common/utils/ItemList';
/**
* The `SessionDropdown` component shows a button with the current user's
* avatar/name, with a dropdown of session controls.
*/
export default class SessionDropdown extends Dropdown {
static initAttrs(attrs) {
super.initAttrs(attrs);
attrs.className = 'SessionDropdown';
attrs.buttonClassName = 'Button Button--user Button--flat';
attrs.menuClassName = 'Dropdown-menu--right';
}
view(vnode) {
return super.view({ ...vnode, children: this.items().toArray() });
}
getButtonContent() {
const user = app.session.user;
return [avatar(user), ' ', <span className="Button-label">{username(user)}</span>];
}
/**
* Build an item list for the contents of the dropdown menu.
*
* @return {ItemList}
*/
items() {
const items = new ItemList();
items.add(
'logOut',
Button.component(
{
icon: 'fas fa-sign-out-alt',
onclick: app.session.logout.bind(app.session),
},
app.translator.trans('core.admin.header.log_out_button')
),
-100
);
return items;
}
}

View File

@@ -1,52 +0,0 @@
import avatar from '../../common/helpers/avatar';
import username from '../../common/helpers/username';
import Dropdown from '../../common/components/Dropdown';
import Button from '../../common/components/Button';
import ItemList from '../../common/utils/ItemList';
/**
* The `SessionDropdown` component shows a button with the current user's
* avatar/name, with a dropdown of session controls.
*/
export default class SessionDropdown extends Dropdown {
static initProps(props) {
super.initProps(props);
props.className = 'SessionDropdown';
props.buttonClassName = 'Button Button--user Button--flat';
props.menuClassName = 'Dropdown-menu--right';
}
view() {
this.props.children = this.items().toArray();
return super.view();
}
getButtonContent() {
const user = app.session.user;
return [avatar(user), ' ', <span className="Button-label">{username(user)}</span>];
}
/**
* Build an item list for the contents of the dropdown menu.
*
* @return {ItemList}
*/
items() {
const items = new ItemList();
items.add(
'logOut',
Button.component({
icon: 'fas fa-sign-out-alt',
children: app.translator.trans('core.admin.header.log_out_button'),
onclick: app.session.logout.bind(app.session),
}),
-100
);
return items;
}
}

View File

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

View File

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

View File

@@ -0,0 +1,72 @@
import Modal from '../../common/components/Modal';
import Button from '../../common/components/Button';
import saveSettings from '../utils/saveSettings';
export default class SettingsModal extends Modal {
oninit(vnode) {
super.oninit(vnode);
this.settings = {};
this.loading = false;
}
form() {
return '';
}
content() {
return (
<div className="Modal-body">
<div className="Form">
{this.form()}
<div className="Form-group">{this.submitButton()}</div>
</div>
</div>
);
}
submitButton() {
return (
<Button type="submit" className="Button Button--primary" loading={this.loading} disabled={!this.changed()}>
{app.translator.trans('core.admin.settings.submit_button')}
</Button>
);
}
setting(key, fallback = '') {
this.settings[key] = this.settings[key] || m.stream(app.data.settings[key] || fallback);
return this.settings[key];
}
dirty() {
const dirty = {};
Object.keys(this.settings).forEach((key) => {
const value = this.settings[key]();
if (value !== app.data.settings[key]) {
dirty[key] = value;
}
});
return dirty;
}
changed() {
return Object.keys(this.dirty()).length;
}
onsubmit(e) {
e.preventDefault();
this.loading = true;
saveSettings(this.dirty()).then(this.onsaved.bind(this), this.loaded.bind(this));
}
onsaved() {
this.hide();
}
}

View File

@@ -1,70 +0,0 @@
import app from '../app';
import Modal from '../../common/components/Modal';
import Button from '../../common/components/Button';
import saveSettings from '../utils/saveSettings';
export default abstract class SettingsModal extends Modal {
settings: object = {};
loading: boolean = false;
form(): string | JSX.Element[] {
return '';
}
content() {
return (
<div className="Modal-body">
<div className="Form">
{this.form()}
<div className="Form-group">{this.submitButton()}</div>
</div>
</div>
);
}
submitButton() {
return (
<Button type="submit" className="Button Button--primary" loading={this.loading} disabled={!this.changed()}>
{app.translator.trans('core.admin.settings.submit_button')}
</Button>
);
}
setting(key, fallback = '') {
this.settings[key] = this.settings[key] || m.prop(app.data.settings[key] || fallback);
return this.settings[key];
}
dirty() {
const dirty = {};
Object.keys(this.settings).forEach((key) => {
const value = this.settings[key]();
if (value !== app.data.settings[key]) {
dirty[key] = value;
}
});
return dirty;
}
changed() {
return Object.keys(this.dirty()).length;
}
onsubmit(e) {
e.preventDefault();
this.loading = true;
saveSettings(this.dirty()).then(this.onsaved.bind(this), this.loaded.bind(this));
}
onsaved() {
this.hide();
}
}

View File

@@ -0,0 +1,58 @@
/*
* This file is part of Flarum.
*
* (c) Toby Zerner <toby.zerner@gmail.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
import DashboardWidget from './DashboardWidget';
import listItems from '../../common/helpers/listItems';
import ItemList from '../../common/utils/ItemList';
import Dropdown from '../../common/components/Dropdown';
import Button from '../../common/components/Button';
import LoadingModal from './LoadingModal';
export default class StatusWidget extends DashboardWidget {
className() {
return 'StatusWidget';
}
content() {
return <ul>{listItems(this.items().toArray())}</ul>;
}
items() {
const items = new ItemList();
items.add(
'tools',
<Dropdown
label={app.translator.trans('core.admin.dashboard.tools_button')}
icon="fas fa-cog"
buttonClassName="Button"
menuClassName="Dropdown-menu--right"
>
<Button onclick={this.handleClearCache.bind(this)}>{app.translator.trans('core.admin.dashboard.clear_cache_button')}</Button>
</Dropdown>
);
items.add('version-flarum', [<strong>Flarum</strong>, <br />, app.forum.attribute('version')]);
items.add('version-php', [<strong>PHP</strong>, <br />, app.data.phpVersion]);
items.add('version-mysql', [<strong>MySQL</strong>, <br />, app.data.mysqlVersion]);
return items;
}
handleClearCache(e) {
app.modal.show(LoadingModal);
app
.request({
method: 'DELETE',
url: app.forum.attribute('apiUrl') + '/cache',
})
.then(() => window.location.reload());
}
}

View File

@@ -1,49 +0,0 @@
import app from '../app';
import DashboardWidget from './DashboardWidget';
import listItems from '../../common/helpers/listItems';
import ItemList from '../../common/utils/ItemList';
import Dropdown from '../../common/components/Dropdown';
import Button from '../../common/components/Button';
import LoadingModal from './LoadingModal';
export default class StatusWidget extends DashboardWidget {
className() {
return 'StatusWidget';
}
content() {
return <ul>{listItems(this.items().toArray())}</ul>;
}
items() {
const items = new ItemList();
items.add(
'tools',
<Dropdown
label={app.translator.transText('core.admin.dashboard.tools_button')}
icon="fas fa-cog"
buttonClassName="Button"
menuClassName="Dropdown-menu--right"
>
<Button onclick={this.handleClearCache.bind(this)}>{app.translator.trans('core.admin.dashboard.clear_cache_button')}</Button>
</Dropdown>
);
items.add('version-flarum', [<strong>Flarum</strong>, <br />, app.forum.attribute('version')]);
items.add('version-php', [<strong>PHP</strong>, <br />, app.data.phpVersion]);
items.add('version-mysql', [<strong>MySQL</strong>, <br />, app.data.mysqlVersion]);
return items;
}
handleClearCache(e) {
app.modal.show(LoadingModal);
app.request({
method: 'DELETE',
url: app.forum.attribute('apiUrl') + '/cache',
}).then(() => window.location.reload());
}
}

View File

@@ -0,0 +1,97 @@
import Button from '../../common/components/Button';
export default class UploadImageButton extends Button {
loading = false;
view(vnode) {
this.attrs.loading = this.loading;
this.attrs.className = (this.attrs.className || '') + ' Button';
if (app.data.settings[this.attrs.name + '_path']) {
this.attrs.onclick = this.remove.bind(this);
return (
<div>
<p>
<img src={app.forum.attribute(this.attrs.name + 'Url')} alt="" />
</p>
<p>{super.view({ ...vnode, children: app.translator.trans('core.admin.upload_image.remove_button') })}</p>
</div>
);
} else {
this.attrs.onclick = this.upload.bind(this);
}
return super.view({ ...vnode, children: app.translator.trans('core.admin.upload_image.upload_button') });
}
/**
* Prompt the user to upload an image.
*/
upload() {
if (this.loading) return;
const $input = $('<input type="file">');
$input
.appendTo('body')
.hide()
.click()
.on('change', (e) => {
const body = new FormData();
body.append(this.attrs.name, $(e.target)[0].files[0]);
this.loading = true;
m.redraw();
app
.request({
method: 'POST',
url: this.resourceUrl(),
serialize: (raw) => raw,
body,
})
.then(this.success.bind(this), this.failure.bind(this));
});
}
/**
* Remove the logo.
*/
remove() {
this.loading = true;
m.redraw();
app
.request({
method: 'DELETE',
url: this.resourceUrl(),
})
.then(this.success.bind(this), this.failure.bind(this));
}
resourceUrl() {
return app.forum.attribute('apiUrl') + '/' + this.attrs.name;
}
/**
* After a successful upload/removal, reload the page.
*
* @param {Object} response
* @protected
*/
success(response) {
window.location.reload();
}
/**
* If upload/removal fails, stop loading.
*
* @param {Object} response
* @protected
*/
failure(response) {
this.loading = false;
m.redraw();
}
}

View File

@@ -1,97 +0,0 @@
import app from '../app';
import Button, { ButtonProps } from '../../common/components/Button';
export default class UploadImageButton<T extends ButtonProps = ButtonProps> extends Button<T> {
loading: boolean = false;
view() {
this.props.loading = this.loading;
this.props.className = (this.props.className || '') + ' Button';
if (app.data.settings[this.props.name + '_path']) {
this.props.onclick = this.remove.bind(this);
this.props.children = app.translator.trans('core.admin.upload_image.remove_button');
return (
<div>
<p>
<img src={app.forum.attribute(this.props.name + 'Url')} alt="" />
</p>
<p>{super.view()}</p>
</div>
);
} else {
this.props.onclick = this.upload.bind(this);
this.props.children = app.translator.trans('core.admin.upload_image.upload_button');
}
return super.view();
}
/**
* Prompt the user to upload an image.
*/
upload() {
if (this.loading) return;
const $input = $('<input type="file">');
$input
.appendTo('body')
.hide()
.click()
.on('change', (e) => {
const data = new FormData();
data.append(this.props.name, $(e.target)[0].files[0]);
this.loading = true;
m.redraw();
app.request({
method: 'POST',
url: this.resourceUrl(),
serialize: (raw) => raw,
body: data,
}).then(this.success.bind(this), this.failure.bind(this));
});
}
/**
* Remove the logo.
*/
remove() {
this.loading = true;
m.redraw();
app.request({
method: 'DELETE',
url: this.resourceUrl(),
}).then(this.success.bind(this), this.failure.bind(this));
}
resourceUrl() {
return app.forum.attribute('apiUrl') + '/' + this.props.name;
}
/**
* After a successful upload/removal, reload the page.
*
* @param {Object} response
* @protected
*/
success(response) {
window.location.reload();
}
/**
* If upload/removal fails, stop loading.
*
* @param {Object} response
* @protected
*/
failure(response) {
this.loading = false;
m.redraw();
}
}

17
js/src/admin/index.js Normal file
View File

@@ -0,0 +1,17 @@
import AdminApplication from './AdminApplication';
const app = new AdminApplication();
// Backwards compatibility
window.app = app;
export { app };
// Export public API
// Export compat API
import compat from './compat';
compat.app = app;
export { compat };

View File

@@ -1,10 +0,0 @@
import app from './app';
export { app };
// Export compat API
import compat from './compat';
compat.app = app;
export { compat };

22
js/src/admin/routes.js Normal file
View File

@@ -0,0 +1,22 @@
import DashboardPage from './components/DashboardPage';
import BasicsPage from './components/BasicsPage';
import PermissionsPage from './components/PermissionsPage';
import AppearancePage from './components/AppearancePage';
import ExtensionsPage from './components/ExtensionsPage';
import MailPage from './components/MailPage';
/**
* The `routes` initializer defines the forum app's routes.
*
* @param {App} app
*/
export default function (app) {
app.routes = {
dashboard: { path: '/', component: DashboardPage },
basics: { path: '/basics', component: BasicsPage },
permissions: { path: '/permissions', component: PermissionsPage },
appearance: { path: '/appearance', component: AppearancePage },
extensions: { path: '/extensions', component: ExtensionsPage },
mail: { path: '/mail', component: MailPage },
};
}

View File

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

View File

@@ -0,0 +1,16 @@
export default function saveSettings(settings) {
const oldSettings = JSON.parse(JSON.stringify(app.data.settings));
Object.assign(app.data.settings, settings);
return app
.request({
method: 'POST',
url: app.forum.attribute('apiUrl') + '/settings',
body: settings,
})
.catch((error) => {
app.data.settings = oldSettings;
throw error;
});
}

View File

@@ -1,18 +0,0 @@
import app from '../app';
export default function saveSettings(settings) {
const oldSettings = JSON.parse(JSON.stringify(app.data.settings));
Object.assign(app.data.settings, settings);
return app
.request({
method: 'POST',
url: app.forum.attribute('apiUrl') + '/settings',
body: settings,
})
.catch((error) => {
app.data.settings = oldSettings;
throw error;
});
}

View File

@@ -0,0 +1,451 @@
import ItemList from './utils/ItemList';
import Button from './components/Button';
import ModalManager from './components/ModalManager';
import AlertManager from './components/AlertManager';
import RequestErrorModal from './components/RequestErrorModal';
import Translator from './Translator';
import Store from './Store';
import Session from './Session';
import extract from './utils/extract';
import Drawer from './utils/Drawer';
import mapRoutes from './utils/mapRoutes';
import RequestError from './utils/RequestError';
import ScrollListener from './utils/ScrollListener';
import liveHumanTimes from './utils/liveHumanTimes';
import { extend } from './extend';
import Forum from './models/Forum';
import User from './models/User';
import Discussion from './models/Discussion';
import Post from './models/Post';
import Group from './models/Group';
import Notification from './models/Notification';
import { flattenDeep } from 'lodash-es';
import PageState from './states/PageState';
import ModalManagerState from './states/ModalManagerState';
import AlertManagerState from './states/AlertManagerState';
/**
* The `App` class provides a container for an application, as well as various
* utilities for the rest of the app to use.
*/
export default class Application {
/**
* The forum model for this application.
*
* @type {Forum}
* @public
*/
forum = null;
/**
* A map of routes, keyed by a unique route name. Each route is an object
* containing the following properties:
*
* - `path` The path that the route is accessed at.
* - `component` The Mithril component to render when this route is active.
*
* @example
* app.routes.discussion = {path: '/d/:id', component: DiscussionPage.component()};
*
* @type {Object}
* @public
*/
routes = {};
/**
* An ordered list of initializers to bootstrap the application.
*
* @type {ItemList}
* @public
*/
initializers = new ItemList();
/**
* The app's session.
*
* @type {Session}
* @public
*/
session = null;
/**
* The app's translator.
*
* @type {Translator}
* @public
*/
translator = new Translator();
/**
* The app's data store.
*
* @type {Store}
* @public
*/
store = new Store({
forums: Forum,
users: User,
discussions: Discussion,
posts: Post,
groups: Group,
notifications: Notification,
});
/**
* A local cache that can be used to store data at the application level, so
* that is persists between different routes.
*
* @type {Object}
* @public
*/
cache = {};
/**
* Whether or not the app has been booted.
*
* @type {Boolean}
* @public
*/
booted = false;
/**
* The key for an Alert that was shown as a result of an AJAX request error.
* If present, it will be dismissed on the next successful request.
*
* @type {int}
* @private
*/
requestErrorAlert = null;
/**
* The page the app is currently on.
*
* This object holds information about the type of page we are currently
* visiting, and sometimes additional arbitrary page state that may be
* relevant to lower-level components.
*
* @type {PageState}
*/
current = new PageState(null);
/**
* The page the app was on before the current page.
*
* Once the application navigates to another page, the object previously
* assigned to this.current will be moved to this.previous, while this.current
* is re-initialized.
*
* @type {PageState}
*/
previous = new PageState(null);
/*
* An object that manages modal state.
*
* @type {ModalManagerState}
*/
modal = new ModalManagerState();
/**
* An object that manages the state of active alerts.
*
* @type {AlertManagerState}
*/
alerts = new AlertManagerState();
data;
title = '';
titleCount = 0;
load(payload) {
this.data = payload;
this.translator.locale = payload.locale;
}
boot() {
this.initializers.toArray().forEach((initializer) => initializer(this));
this.store.pushPayload({ data: this.data.resources });
this.forum = this.store.getById('forums', 1);
this.session = new Session(this.store.getById('users', this.data.session.userId), this.data.session.csrfToken);
this.mount();
}
bootExtensions(extensions) {
Object.keys(extensions).forEach((name) => {
const extension = extensions[name];
const extenders = flattenDeep(extension.extend);
for (const extender of extenders) {
extender.extend(this, { name, exports: extension });
}
});
}
mount(basePath = '') {
// An object with a callable view property is used in order to pass arguments to the component; see https://mithril.js.org/mount.html
m.mount(document.getElementById('modal'), { view: () => ModalManager.component({ state: this.modal }) });
m.mount(document.getElementById('alerts'), { view: () => AlertManager.component({ state: this.alerts }) });
this.drawer = new Drawer();
m.route(document.getElementById('content'), basePath + '/', mapRoutes(this.routes, basePath));
// Add a class to the body which indicates that the page has been scrolled
// down.
new ScrollListener((top) => {
const $app = $('#app');
const offset = $app.offset().top;
$app.toggleClass('affix', top >= offset).toggleClass('scrolled', top > offset);
}).start();
$(() => {
$('body').addClass('ontouchstart' in window ? 'touch' : 'no-touch');
});
liveHumanTimes();
}
/**
* Get the API response document that has been preloaded into the application.
*
* @return {Object|null}
* @public
*/
preloadedApiDocument() {
if (this.data.apiDocument) {
const results = this.store.pushPayload(this.data.apiDocument);
this.data.apiDocument = null;
return results;
}
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.
*
* @param {String} title
* @public
*/
setTitle(title) {
this.title = title;
this.updateTitle();
}
/**
* Set a number to display in the <title> of the page.
*
* @param {Integer} count
*/
setTitleCount(count) {
this.titleCount = count;
this.updateTitle();
}
updateTitle() {
const count = this.titleCount ? `(${this.titleCount}) ` : '';
const pageTitleWithSeparator = this.title && m.route.get() !== '/' ? this.title + ' - ' : '';
const title = this.forum.attribute('title');
document.title = count + pageTitleWithSeparator + title;
}
/**
* Make an AJAX request, handling any low-level errors that may occur.
*
* @see https://lhorie.github.io/mithril/mithril.request.html
* @param {Object} options
* @return {Promise}
* @public
*/
request(originalOptions) {
const options = Object.assign({}, originalOptions);
// Set some default options if they haven't been overridden. We want to
// authenticate all requests with the session token. We also want all
// requests to run asynchronously in the background, so that they don't
// prevent redraws from occurring.
options.background = options.background || true;
extend(options, 'config', (result, xhr) => xhr.setRequestHeader('X-CSRF-Token', this.session.csrfToken));
// If the method is something like PATCH or DELETE, which not all servers
// and clients support, then we'll send it as a POST request with the
// intended method specified in the X-HTTP-Method-Override header.
if (options.method !== 'GET' && options.method !== 'POST') {
const method = options.method;
extend(options, 'config', (result, xhr) => xhr.setRequestHeader('X-HTTP-Method-Override', method));
options.method = 'POST';
}
// When we deserialize JSON data, if for some reason the server has provided
// a dud response, we don't want the application to crash. We'll show an
// error message to the user instead.
options.deserialize = options.deserialize || ((responseText) => responseText);
options.errorHandler =
options.errorHandler ||
((error) => {
throw error;
});
// When extracting the data from the response, we can check the server
// response code and show an error message to the user if something's gone
// awry.
const original = options.extract;
options.extract = (xhr) => {
let responseText;
if (original) {
responseText = original(xhr.responseText);
} else {
responseText = xhr.responseText || null;
}
const status = xhr.status;
if (status < 200 || status > 299) {
throw new RequestError(status, responseText, options, xhr);
}
if (xhr.getResponseHeader) {
const csrfToken = xhr.getResponseHeader('X-CSRF-Token');
if (csrfToken) app.session.csrfToken = csrfToken;
}
try {
return JSON.parse(responseText);
} catch (e) {
throw new RequestError(500, responseText, options, xhr);
}
};
if (this.requestErrorAlert) this.alerts.dismiss(this.requestErrorAlert);
// Now make the request. If it's a failure, inspect the error that was
// returned and show an alert containing its contents.
return m.request(options).then(
(response) => response,
(error) => {
let content;
switch (error.status) {
case 422:
content = error.response.errors
.map((error) => [error.detail, <br />])
.reduce((a, b) => a.concat(b), [])
.slice(0, -1);
break;
case 401:
case 403:
content = app.translator.trans('core.lib.error.permission_denied_message');
break;
case 404:
case 410:
content = app.translator.trans('core.lib.error.not_found_message');
break;
case 429:
content = app.translator.trans('core.lib.error.rate_limit_exceeded_message');
break;
default:
content = app.translator.trans('core.lib.error.generic_message');
}
const isDebug = app.forum.attribute('debug');
// contains a formatted errors if possible, response must be an JSON API array of errors
// the details property is decoded to transform escaped characters such as '\n'
const errors = error.response && error.response.errors;
const formattedError = Array.isArray(errors) && errors[0] && errors[0].detail && errors.map((e) => decodeURI(e.detail));
error.alert = {
type: 'error',
content,
controls: isDebug && [
<Button className="Button Button--link" onclick={this.showDebug.bind(this, error, formattedError)}>
Debug
</Button>,
],
};
try {
options.errorHandler(error);
} catch (error) {
if (isDebug && error.xhr) {
const { method, url } = error.options;
const { status = '' } = error.xhr;
console.group(`${method} ${url} ${status}`);
console.error(...(formattedError || [error]));
console.groupEnd();
}
this.requestErrorAlert = this.alerts.show(error.alert.content, error.alert);
}
return Promise.reject(error);
}
);
}
/**
* @param {RequestError} error
* @param {string[]} [formattedError]
* @private
*/
showDebug(error, formattedError) {
this.alerts.dismiss(this.requestErrorAlert);
this.modal.show(RequestErrorModal, { error, formattedError });
}
/**
* Construct a URL to the route with the given name.
*
* @param {String} name
* @param {Object} params
* @return {String}
* @public
*/
route(name, params = {}) {
const route = this.routes[name];
if (!route) throw new Error(`Route '${name}' does not exist`);
const url = route.path.replace(/:([^\/]+)/g, (m, key) => extract(params, key));
// Remove falsy values in params to avoid having urls like '/?sort&q'
for (const key in params) {
if (params.hasOwnProperty(key) && !params[key]) delete params[key];
}
const queryString = m.buildQueryString(params);
const prefix = m.route.prefix === '' ? this.forum.attribute('basePath') : '';
return prefix + url + (queryString ? '?' + queryString : '');
}
}

View File

@@ -1,363 +0,0 @@
import Mithril from 'mithril';
import Translator from './Translator';
import Session from './Session';
import Store from './Store';
import { extend } from './extend';
import extract from './utils/extract';
import mapRoutes from './utils/mapRoutes';
import Drawer from './utils/Drawer';
import RequestError from './utils/RequestError';
import ItemList from './utils/ItemList';
import ScrollListener from './utils/ScrollListener';
import Forum from './models/Forum';
import Discussion from './models/Discussion';
import User from './models/User';
import Post from './models/Post';
import Group from './models/Group';
import Notification from './models/Notification';
import AlertManager from './components/AlertManager';
import Button from './components/Button';
import ModalManager from './components/ModalManager';
import Page from './components/Page';
import RequestErrorModal from './components/RequestErrorModal';
import AlertState from './states/AlertState';
import flattenDeep from 'lodash/flattenDeep';
export type ApplicationData = {
apiDocument: any;
locale: string;
locales: any;
resources: any[];
session: any;
};
export default abstract class Application {
/**
* The forum model for this application.
*/
public forum!: Forum;
/**
* A map of routes, keyed by a unique route name. Each route is an object
* containing the following properties:
*
* - `path` The path that the route is accessed at.
* - `component` The Mithril component to render when this route is active.
*
* @example
* app.routes.discussion = {path: '/d/:id', component: DiscussionPage.component()};
*/
public routes: { [key: string]: { path: string; component: any; [key: string]: any } } = {};
/**
* An ordered list of initializers to bootstrap the application.
*/
public initializers = new ItemList();
/**
* The app's session.
*/
public session!: Session;
/**
* The app's translator.
*/
public translator = new Translator();
/**
* The app's data store.
*/
public store = new Store({
forums: Forum,
users: User,
discussions: Discussion,
posts: Post,
groups: Group,
notifications: Notification,
});
/**
* A local cache that can be used to store data at the application level, so
* that is persists between different routes.
*/
public cache: { [key: string]: any } = {};
/**
* Whether or not the app has been booted.
*/
public booted: boolean = false;
/**
* An Alert that was shown as a result of an AJAX request error. If present,
* it will be dismissed on the next successful request.
*/
private requestError: RequestError | null = null;
data!: ApplicationData;
title = '';
titleCount = 0;
drawer = new Drawer();
modal!: ModalManager;
alerts!: AlertManager;
current?: Page;
previous?: Page;
load(payload) {
this.data = payload;
this.translator.locale = payload.locale;
}
boot() {
this.initializers.toArray().forEach((initializer) => initializer(this));
this.store.pushPayload({ data: this.data.resources });
this.forum = this.store.getById('forums', 1);
this.session = new Session(this.store.getById('users', this.data.session.userId), this.data.session.csrfToken);
this.mount();
this.booted = true;
}
bootExtensions(extensions) {
Object.keys(extensions).forEach((name) => {
const extension = extensions[name];
const extenders = flattenDeep(extension.extend);
for (const extender of extenders) {
extender.extend(this, { name, exports: extension });
}
});
}
mount(basePath = '') {
const $modal = document.getElementById('modal');
const $alerts = document.getElementById('alerts');
const $content = document.getElementById('content');
if ($modal) m.mount($modal, (this.modal = new ModalManager()));
if ($alerts) m.mount($alerts, (this.alerts = new AlertManager({ oninit: (vnode) => (this.alerts = vnode.state) })));
if ($content) m.route($content, basePath + '/', mapRoutes(this.routes, basePath));
// Add a class to the body which indicates that the page has been scrolled
// down.
new ScrollListener((top) => {
const $app = $('#app');
const offset = $app.offset().top;
$app.toggleClass('affix', top >= offset).toggleClass('scrolled', top > offset);
}).start();
$(() => {
$('body').addClass('ontouchstart' in window ? 'touch' : 'no-touch');
});
}
/**
* Get the API response document that has been preloaded into the application.
*/
preloadedApiDocument() {
if (this.data.apiDocument) {
const results = this.store.pushPayload(this.data.apiDocument);
this.data.apiDocument = null;
return results;
}
return null;
}
/**
* Set the <title> of the page.
*/
setTitle(title: string) {
this.title = title;
this.updateTitle();
}
/**
* Set a number to display in the <title> of the page.
*/
setTitleCount(count: number) {
this.titleCount = count;
this.updateTitle();
}
updateTitle() {
document.title = (this.titleCount ? `(${this.titleCount}) ` : '') + (this.title ? this.title + ' - ' : '') + this.forum.attribute('title');
}
/**
* Construct a URL to the route with the given name.
*/
route(name: string, params: object = {}): string {
const route = this.routes[name];
if (!route) throw new Error(`Route '${name}' does not exist`);
const url = route.path.replace(/:([^\/]+)/g, (m, key) => extract(params, key));
// Remove falsy values in params to avoid
// having urls like '/?sort&q'
for (const key in params) {
if (params.hasOwnProperty(key) && !params[key]) delete params[key];
}
const queryString = m.buildQueryString(params as Mithril.Params);
const prefix = m.route.prefix === '' ? this.forum.attribute('basePath') : '';
return prefix + url + (queryString ? '?' + queryString : '');
}
/**
* Make an AJAX request, handling any low-level errors that may occur.
*
* @see https://mithril.js.org/request.html
*/
request(originalOptions: Mithril.RequestOptions<JSON> | any): Promise<any> {
const options: Mithril.RequestOptions<JSON> | any = Object.assign({}, originalOptions);
// Set some default options if they haven't been overridden. We want to
// authenticate all requests with the session token. We also want all
// requests to run asynchronously in the background, so that they don't
// prevent redraws from occurring.
options.background = options.background || true;
extend(options, 'config', (result, xhr: XMLHttpRequest) => xhr.setRequestHeader('X-CSRF-Token', this.session.csrfToken!));
// If the method is something like PATCH or DELETE, which not all servers
// and clients support, then we'll send it as a POST request with the
// intended method specified in the X-HTTP-Method-Override header.
if (options.method !== 'GET' && options.method !== 'POST') {
const method = options.method;
extend(options, 'config', (result, xhr: XMLHttpRequest) => xhr.setRequestHeader('X-HTTP-Method-Override', method));
options.method = 'POST';
}
// When we deserialize JSON data, if for some reason the server has provided
// a dud response, we don't want the application to crash. We'll show an
// error message to the user instead.
options.deserialize = options.deserialize || ((responseText) => responseText);
options.errorHandler =
options.errorHandler ||
((error) => {
throw error;
});
// When extracting the data from the response, we can check the server
// response code and show an error message to the user if something's gone
// awry.
const original = options.extract;
options.extract = (xhr) => {
let responseText;
if (original) {
responseText = original(xhr.responseText);
} else {
responseText = xhr.responseText || null;
}
const status = xhr.status;
if (status < 200 || status > 299) {
throw new RequestError(status, responseText, options, xhr);
}
if (xhr.getResponseHeader) {
const csrfToken = xhr.getResponseHeader('X-CSRF-Token');
if (csrfToken) app.session.csrfToken = csrfToken;
}
try {
return JSON.parse(responseText);
} catch (e) {
throw new RequestError(500, responseText, options, xhr);
}
};
if (this.requestError) this.alerts.dismiss(this.requestError.alert);
// Now make the request. If it's a failure, inspect the error that was
// returned and show an alert containing its contents.
return m.request(options).then(
(res) => res,
(error) => {
this.requestError = error;
let children;
switch (error.status) {
case 422:
children = error.response.errors
.map((error) => [error.detail, m('br')])
.reduce((a, b) => a.concat(b), [])
.slice(0, -1);
break;
case 401:
case 403:
children = this.translator.trans('core.lib.error.permission_denied_message');
break;
case 404:
case 410:
children = this.translator.trans('core.lib.error.not_found_message');
break;
case 429:
children = this.translator.trans('core.lib.error.rate_limit_exceeded_message');
break;
default:
children = this.translator.trans('core.lib.error.generic_message');
}
const isDebug = app.forum.attribute('debug');
error.alert = new AlertState({
type: 'error',
children,
controls: isDebug && [
Button.component({
className: 'Button Button--link',
onclick: this.showDebug.bind(this, error),
children: 'DEBUG', // TODO make translatable
}),
],
});
try {
options.errorHandler(error);
} catch (error) {
console.error(error);
this.alerts.show(error.alert);
}
return Promise.reject(error);
}
);
}
private showDebug(error: RequestError) {
this.alerts.dismiss(this.requestError!.alert);
this.modal.show(RequestErrorModal, { error });
}
}

View File

@@ -1,90 +1,136 @@
import Mithril, { ClassComponent, Vnode } from 'mithril';
import * as Mithril from 'mithril';
export type ComponentProps = {
children?: Mithril.Children;
export type ComponentAttrs = {
className?: string;
className?: string;
[key: string]: any;
[key: string]: any;
};
export default class Component<T extends ComponentProps = any> implements ClassComponent {
element!: HTMLElement;
/**
* 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 = any> implements Mithril.ClassComponent<T> {
/**
* The root DOM element for the component.
*/
protected element!: Element;
props: T;
/**
* The attributes passed into the component.
*
* @see https://mithril.js.org/components.html#passing-data-to-components
*/
protected attrs!: T;
constructor(props: T = <T>{}) {
this.props = props.tag ? <T>{} : props;
/**
* @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.`);
}
}
view(vnode) {
throw new Error('Component#view must be implemented by subclass');
}
this.attrs = attrs;
}
oninit(vnode) {
this.setProps(vnode);
}
oncreate(vnode) {
this.setProps(vnode);
this.element = vnode.dom;
}
onbeforeupdate(vnode) {
this.setProps(vnode);
}
onupdate(vnode) {
this.setProps(vnode);
}
onbeforeremove(vnode) {
this.setProps(vnode);
}
onremove(vnode) {
this.setProps(vnode);
}
/**
* 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 selector a jQuery-compatible selector string
* @final
*/
$(selector?: string): ZeptoCollection {
const $element = $(this.element);
return selector ? $element.find(selector) : $element;
}
render() {
return m(this.constructor as typeof Component, this.props);
}
static component(props: ComponentProps | any = {}, children?: Mithril.Children) {
const componentProps: ComponentProps = Object.assign({}, props);
if (children) componentProps.children = children;
return m(this, componentProps);
}
static initProps(props: ComponentProps = {}) {}
private setProps(vnode: Vnode<T, this>) {
const props = vnode.attrs || {};
(this.constructor as typeof Component).initProps(props);
if (!props.children) props.children = vnode.children;
this.props = props;
}
/**
* Initialize the component's attrs.
*
* This can be used to assign default values for missing, optional attrs.
*/
protected static initAttrs<T>(attrs: T): void {}
}

75
js/src/common/Fragment.ts Normal file
View File

@@ -0,0 +1,75 @@
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 implements Mithril.ClassComponent {
/**
* 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 {
const vdom = this.view();
vdom.attrs = vdom.attrs || {};
const originalOnCreate = vdom.attrs.oncreate;
vdom.attrs.oncreate = (vnode) => {
this.element = vnode.dom;
if (this.oncreate) this.oncreate.apply(this, vnode);
if (originalOnCreate) originalOnCreate.apply(this, vnode);
};
return vdom;
}
/**
* @inheritdoc
*/
abstract view();
}

319
js/src/common/Model.js Normal file
View File

@@ -0,0 +1,319 @@
/**
* The `Model` class represents a local data resource. It provides methods to
* persist changes via the API.
*
* @abstract
*/
export default class Model {
/**
* @param {Object} data A resource object from the API.
* @param {Store} store The data store that this model should be persisted to.
* @public
*/
constructor(data = {}, store = null) {
/**
* The resource object from the API.
*
* @type {Object}
* @public
*/
this.data = data;
/**
* The time at which the model's data was last updated. Watching the value
* of this property is a fast way to retain/cache a subtree if data hasn't
* changed.
*
* @type {Date}
* @public
*/
this.freshness = new Date();
/**
* Whether or not the resource exists on the server.
*
* @type {Boolean}
* @public
*/
this.exists = false;
/**
* The data store that this resource should be persisted to.
*
* @type {Store}
* @protected
*/
this.store = store;
}
/**
* Get the model's ID.
*
* @return {Integer}
* @public
* @final
*/
id() {
return this.data.id;
}
/**
* Get one of the model's attributes.
*
* @param {String} attribute
* @return {*}
* @public
* @final
*/
attribute(attribute) {
return this.data.attributes[attribute];
}
/**
* Merge new data into this model locally.
*
* @param {Object} data A resource object to merge into this model
* @public
*/
pushData(data) {
// Since most of the top-level items in a resource object are objects
// (e.g. relationships, attributes), we'll need to check and perform the
// merge at the second level if that's the case.
for (const key in data) {
if (typeof data[key] === 'object') {
this.data[key] = this.data[key] || {};
// For every item in a second-level object, we want to check if we've
// been handed a Model instance. If so, we will convert it to a
// relationship data object.
for (const innerKey in data[key]) {
if (data[key][innerKey] instanceof Model) {
data[key][innerKey] = { data: Model.getIdentifier(data[key][innerKey]) };
}
this.data[key][innerKey] = data[key][innerKey];
}
} else {
this.data[key] = data[key];
}
}
// Now that we've updated the data, we can say that the model is fresh.
// This is an easy way to invalidate retained subtrees etc.
this.freshness = new Date();
}
/**
* Merge new attributes into this model locally.
*
* @param {Object} attributes The attributes to merge.
* @public
*/
pushAttributes(attributes) {
this.pushData({ attributes });
}
/**
* Merge new attributes into this model, both locally and with persistence.
*
* @param {Object} attributes The attributes to save. If a 'relationships' key
* exists, it will be extracted and relationships will also be saved.
* @param {Object} [options]
* @return {Promise}
* @public
*/
save(attributes, options = {}) {
const data = {
type: this.data.type,
id: this.data.id,
attributes,
};
// If a 'relationships' key exists, extract it from the attributes hash and
// set it on the top-level data object instead. We will be sending this data
// object to the API for persistence.
if (attributes.relationships) {
data.relationships = {};
for (const key in attributes.relationships) {
const model = attributes.relationships[key];
data.relationships[key] = {
data: model instanceof Array ? model.map(Model.getIdentifier) : Model.getIdentifier(model),
};
}
delete attributes.relationships;
}
// Before we update the model's data, we should make a copy of the model's
// old data so that we can revert back to it if something goes awry during
// persistence.
const oldData = this.copyData();
this.pushData(data);
const request = { data };
if (options.meta) request.meta = options.meta;
return app
.request(
Object.assign(
{
method: this.exists ? 'PATCH' : 'POST',
url: app.forum.attribute('apiUrl') + this.apiEndpoint(),
body: request,
},
options
)
)
.then(
// If everything went well, we'll make sure the store knows that this
// model exists now (if it didn't already), and we'll push the data that
// the API returned into the store.
(payload) => {
this.store.data[payload.data.type] = this.store.data[payload.data.type] || {};
this.store.data[payload.data.type][payload.data.id] = this;
return this.store.pushPayload(payload);
},
// If something went wrong, though... good thing we backed up our model's
// old data! We'll revert to that and let others handle the error.
(response) => {
this.pushData(oldData);
m.redraw();
throw response;
}
);
}
/**
* Send a request to delete the resource.
*
* @param {Object} body Data to send along with the DELETE request.
* @param {Object} [options]
* @return {Promise}
* @public
*/
delete(body, options = {}) {
if (!this.exists) return Promise.resolve();
return app
.request(
Object.assign(
{
method: 'DELETE',
url: app.forum.attribute('apiUrl') + this.apiEndpoint(),
body,
},
options
)
)
.then(() => {
this.exists = false;
this.store.remove(this);
});
}
/**
* Construct a path to the API endpoint for this resource.
*
* @return {String}
* @protected
*/
apiEndpoint() {
return '/' + this.data.type + (this.exists ? '/' + this.data.id : '');
}
copyData() {
return JSON.parse(JSON.stringify(this.data));
}
/**
* Generate a function which returns the value of the given attribute.
*
* @param {String} name
* @param {function} [transform] A function to transform the attribute value
* @return {*}
* @public
*/
static attribute(name, transform) {
return function () {
const value = this.data.attributes && this.data.attributes[name];
return transform ? transform(value) : value;
};
}
/**
* Generate a function which returns the value of the given has-one
* relationship.
*
* @param {String} name
* @return {Model|Boolean|undefined} false if no information about the
* relationship exists; undefined if the relationship exists but the model
* has not been loaded; or the model if it has been loaded.
* @public
*/
static hasOne(name) {
return function () {
if (this.data.relationships) {
const relationship = this.data.relationships[name];
if (relationship) {
return app.store.getById(relationship.data.type, relationship.data.id);
}
}
return false;
};
}
/**
* Generate a function which returns the value of the given has-many
* relationship.
*
* @param {String} name
* @return {Array|Boolean} false if no information about the relationship
* exists; an array if it does, containing models if they have been
* loaded, and undefined for those that have not.
* @public
*/
static hasMany(name) {
return function () {
if (this.data.relationships) {
const relationship = this.data.relationships[name];
if (relationship) {
return relationship.data.map((data) => app.store.getById(data.type, data.id));
}
}
return false;
};
}
/**
* Transform the given value into a Date object.
*
* @param {String} value
* @return {Date|null}
* @public
*/
static transformDate(value) {
return value ? new Date(value) : null;
}
/**
* Get a resource identifier object for the given model.
*
* @param {Model} model
* @return {Object}
* @protected
*/
static getIdentifier(model) {
return {
type: model.data.type,
id: model.data.id,
};
}
}

View File

@@ -1,299 +0,0 @@
import Store from './Store';
export interface Identifier {
type: string;
id: string;
}
export interface Data extends Identifier {
attributes?: { [key: string]: any };
relationships?: { [key: string]: { data: Identifier | Identifier[] } };
}
/**
* The `Model` class represents a local data resource. It provides methods to
* persist changes via the API.
*/
export default abstract class Model {
/**
* The resource object from the API.
*/
data: Data;
payload: any;
/**
* The time at which the model's data was last updated. Watching the value
* of this property is a fast way to retain/cache a subtree if data hasn't
* changed.
*/
freshness: Date;
/**
* Whether or not the resource exists on the server.
*/
exists: boolean;
/**
* The data store that this resource should be persisted to.
*/
protected store?: Store;
/**
* @param data A resource object from the API.
* @param store The data store that this model should be persisted to.
*/
constructor(data = <Data>{}, store?: Store) {
this.data = data;
this.store = store;
this.freshness = new Date();
this.exists = false;
}
/**
* Get the model's ID.
* @final
*/
id(): string {
return this.data.id;
}
/**
* Get one of the model's attributes.
* @final
*/
attribute(attribute: string): any {
return this.data.attributes && this.data.attributes[attribute];
}
/**
* Merge new data into this model locally.
*
* @param data A resource object to merge into this model
*/
public pushData(data: {}) {
// Since most of the top-level items in a resource object are objects
// (e.g. relationships, attributes), we'll need to check and perform the
// merge at the second level if that's the case.
for (const key in data) {
if (typeof data[key] === 'object') {
this.data[key] = this.data[key] || {};
// For every item in a second-level object, we want to check if we've
// been handed a Model instance. If so, we will convert it to a
// relationship data object.
for (const innerKey in data[key]) {
if (data[key][innerKey] instanceof Model) {
data[key][innerKey] = { data: Model.getIdentifier(data[key][innerKey]) };
}
this.data[key][innerKey] = data[key][innerKey];
}
} else {
this.data[key] = data[key];
}
}
// Now that we've updated the data, we can say that the model is fresh.
// This is an easy way to invalidate retained subtrees etc.
this.freshness = new Date();
}
/**
* Merge new attributes into this model locally.
*
* @param attributes The attributes to merge.
*/
pushAttributes(attributes: any) {
this.pushData({ attributes });
}
/**
* Merge new attributes into this model, both locally and with persistence.
*
* @param attributes The attributes to save. If a 'relationships' key
* exists, it will be extracted and relationships will also be saved.
* @param [options]
*/
save(attributes: any, options: any = {}): Promise<Model | Model[]> {
const data: Data = {
type: this.data.type,
id: this.data.id,
attributes,
};
// If a 'relationships' key exists, extract it from the attributes hash and
// set it on the top-level data object instead. We will be sending this data
// object to the API for persistence.
if (attributes.relationships) {
data.relationships = {};
for (const key in attributes.relationships) {
const model = attributes.relationships[key];
data.relationships[key] = {
data: model instanceof Array ? model.map(Model.getIdentifier) : Model.getIdentifier(model),
};
}
delete attributes.relationships;
}
// Before we update the model's data, we should make a copy of the model's
// old data so that we can revert back to it if something goes awry during
// persistence.
const oldData = this.copyData();
this.pushData(data);
const request = { data };
if (options.meta) request.meta = options.meta;
return app
.request(
Object.assign(
{
method: this.exists ? 'PATCH' : 'POST',
url: app.forum.attribute('apiUrl') + this.apiEndpoint(),
body: request,
},
options
)
)
.then(
// If everything went well, we'll make sure the store knows that this
// model exists now (if it didn't already), and we'll push the data that
// the API returned into the store.
(payload) => {
this.store.data[payload.data.type] = this.store.data[payload.data.type] || {};
this.store.data[payload.data.type][payload.data.id] = this;
return this.store.pushPayload(payload);
},
// If something went wrong, though... good thing we backed up our model's
// old data! We'll revert to that and let others handle the error.
(response) => {
this.pushData(oldData);
m.redraw();
throw response;
}
);
}
/**
* Send a request to delete the resource.
*
* @param {Object} body Data to send along with the DELETE request.
* @param {Object} [options]
* @return {Promise}
* @public
*/
delete(body = {}, options = {}) {
if (!this.exists) return Promise.resolve();
return app
.request(
Object.assign(
{
method: 'DELETE',
url: app.forum.attribute('apiUrl') + this.apiEndpoint(),
body,
},
options
)
)
.then(() => {
this.exists = false;
this.store!.remove(this);
});
}
/**
* Construct a path to the API endpoint for this resource.
*
* @return {String}
* @protected
*/
apiEndpoint() {
return '/' + this.data.type + (this.exists ? '/' + this.data.id : '');
}
copyData() {
return JSON.parse(JSON.stringify(this.data));
}
/**
* Generate a function which returns the value of the given attribute.
*
* @param name
* @param [transform] A function to transform the attribute value
*/
static attribute(name: string, transform?: Function): () => any {
return function (this: Model) {
const value = this.data.attributes && this.data.attributes[name];
return transform ? transform(value) : value;
};
}
/**
* Generate a function which returns the value of the given has-one
* relationship.
*
* @return false if no information about the
* relationship exists; undefined if the relationship exists but the model
* has not been loaded; or the model if it has been loaded.
*/
static hasOne(name: string): () => Model | boolean {
return function (this: Model) {
if (this.data.relationships) {
const relationship = this.data.relationships[name];
if (relationship && !Array.isArray(relationship.data)) {
return app.store.getById(relationship.data.type, relationship.data.id);
}
}
return false;
};
}
/**
* Generate a function which returns the value of the given has-many
* relationship.
*
* @return false if no information about the relationship
* exists; an array if it does, containing models if they have been
* loaded, and undefined for those that have not.
*/
static hasMany(name: string): () => any[] | false {
return function (this: Model) {
if (this.data.relationships) {
const relationship = this.data.relationships[name];
if (relationship && Array.isArray(relationship.data)) {
return relationship.data.map((data) => app.store.getById(data.type, data.id));
}
}
return false;
};
}
/**
* Transform the given value into a Date object.
*/
static transformDate(value: string): Date | null {
return value ? new Date(value) : null;
}
/**
* Get a resource identifier object for the given model.
*/
protected static getIdentifier(model: Model): Identifier {
return {
type: model.data.type,
id: model.data.id,
};
}
}

54
js/src/common/Session.js Normal file
View File

@@ -0,0 +1,54 @@
/**
* The `Session` class defines the current user session. It stores a reference
* to the current authenticated user, and provides methods to log in/out.
*/
export default class Session {
constructor(user, csrfToken) {
/**
* The current authenticated user.
*
* @type {User|null}
* @public
*/
this.user = user;
/**
* The CSRF token.
*
* @type {String|null}
* @public
*/
this.csrfToken = csrfToken;
}
/**
* Attempt to log in a user.
*
* @param {String} identification The username/email.
* @param {String} password
* @param {Object} [options]
* @return {Promise}
* @public
*/
login(body, options = {}) {
return app.request(
Object.assign(
{
method: 'POST',
url: `${app.forum.attribute('baseUrl')}/login`,
body,
},
options
)
);
}
/**
* Log the user out.
*
* @public
*/
logout() {
window.location = `${app.forum.attribute('baseUrl')}/logout?token=${this.csrfToken}`;
}
}

View File

@@ -1,48 +0,0 @@
import User from './models/User';
/**
* The `Session` class defines the current user session. It stores a reference
* to the current authenticated user, and provides methods to log in/out.
*/
export default class Session {
/**
* The current authenticated user.
*/
user: User;
/**
* The CSRF token.
*/
csrfToken?: string;
constructor(user, csrfToken) {
this.user = user;
this.csrfToken = csrfToken;
}
/**
* Attempt to log in a user.
*/
login(body: { identification: string; password: string; remember?: boolean }, options = {}) {
return app.request(
Object.assign(
{
method: 'POST',
url: `${app.forum.attribute('baseUrl')}/login`,
body,
},
options
)
);
}
/**
* Log the user out.
*
* @public
*/
logout() {
window.location.href = `${app.forum.attribute('baseUrl')}/logout?token=${this.csrfToken}`;
}
}

170
js/src/common/Store.js Normal file
View File

@@ -0,0 +1,170 @@
/**
* The `Store` class defines a local data store, and provides methods to
* retrieve data from the API.
*/
export default class Store {
constructor(models) {
/**
* The local data store. A tree of resource types to IDs, such that
* accessing data[type][id] will return the model for that type/ID.
*
* @type {Object}
* @protected
*/
this.data = {};
/**
* The model registry. A map of resource types to the model class that
* should be used to represent resources of that type.
*
* @type {Object}
* @public
*/
this.models = models;
}
/**
* Push resources contained within an API payload into the store.
*
* @param {Object} payload
* @return {Model|Model[]} The model(s) representing the resource(s) contained
* within the 'data' key of the payload.
* @public
*/
pushPayload(payload) {
if (payload.included) payload.included.map(this.pushObject.bind(this));
const result = payload.data instanceof Array ? payload.data.map(this.pushObject.bind(this)) : this.pushObject(payload.data);
// Attach the original payload to the model that we give back. This is
// useful to consumers as it allows them to access meta information
// associated with their request.
result.payload = payload;
return result;
}
/**
* Create a model to represent a resource object (or update an existing one),
* and push it into the store.
*
* @param {Object} data The resource object
* @return {Model|null} The model, or null if no model class has been
* registered for this resource type.
* @public
*/
pushObject(data) {
if (!this.models[data.type]) return null;
const type = (this.data[data.type] = this.data[data.type] || {});
if (type[data.id]) {
type[data.id].pushData(data);
} else {
type[data.id] = this.createRecord(data.type, data);
}
type[data.id].exists = true;
return type[data.id];
}
/**
* Make a request to the API to find record(s) of a specific type.
*
* @param {String} type The resource type.
* @param {Integer|Integer[]|Object} [id] The ID(s) of the model(s) to retrieve.
* Alternatively, if an object is passed, it will be handled as the
* `query` parameter.
* @param {Object} [query]
* @param {Object} [options]
* @return {Promise}
* @public
*/
find(type, id, query = {}, options = {}) {
let params = query;
let url = app.forum.attribute('apiUrl') + '/' + type;
if (id instanceof Array) {
url += '?filter[id]=' + id.join(',');
} else if (typeof id === 'object') {
params = id;
} else if (id) {
url += '/' + id;
}
return app
.request(
Object.assign(
{
method: 'GET',
url,
params,
},
options
)
)
.then(this.pushPayload.bind(this));
}
/**
* Get a record from the store by ID.
*
* @param {String} type The resource type.
* @param {Integer} id The resource ID.
* @return {Model}
* @public
*/
getById(type, id) {
return this.data[type] && this.data[type][id];
}
/**
* Get a record from the store by the value of a model attribute.
*
* @param {String} type The resource type.
* @param {String} key The name of the method on the model.
* @param {*} value The value of the model attribute.
* @return {Model}
* @public
*/
getBy(type, key, value) {
return this.all(type).filter((model) => model[key]() === value)[0];
}
/**
* Get all loaded records of a specific type.
*
* @param {String} type
* @return {Model[]}
* @public
*/
all(type) {
const records = this.data[type];
return records ? Object.keys(records).map((id) => records[id]) : [];
}
/**
* Remove the given model from the store.
*
* @param {Model} model
*/
remove(model) {
delete this.data[model.data.type][model.id()];
}
/**
* Create a new record of the given type.
*
* @param {String} type The resource type
* @param {Object} [data] Any data to initialize the model with
* @return {Model}
* @public
*/
createRecord(type, data = {}) {
data.type = data.type || type;
return new this.models[type](data, this);
}
}

View File

@@ -1,152 +0,0 @@
import Model from './Model';
/**
* The `Store` class defines a local data store, and provides methods to
* retrieve data from the API.
*/
export default class Store {
/**
* The local data store. A tree of resource types to IDs, such that
* accessing data[type][id] will return the model for that type/ID.
*/
data: { [key: string]: Model[] } = {};
/**
* The model registry. A map of resource types to the model class that
* should be used to represent resources of that type.
*/
models: {};
constructor(models) {
this.models = models;
}
/**
* Push resources contained within an API payload into the store.
*
* @param payload
* @return The model(s) representing the resource(s) contained
* within the 'data' key of the payload.
*/
pushPayload(payload: { included?: {}[]; data?: {} | {}[] }): Model | Model[] {
if (payload.included) payload.included.map(this.pushObject.bind(this));
const result: any = payload.data instanceof Array ? payload.data.map(this.pushObject.bind(this)) : this.pushObject(payload.data);
// Attach the original payload to the model that we give back. This is
// useful to consumers as it allows them to access meta information
// associated with their request.
result.payload = payload;
return result;
}
/**
* Create a model to represent a resource object (or update an existing one),
* and push it into the store.
*
* @param {Object} data The resource object
* @return The model, or null if no model class has been
* registered for this resource type.
*/
pushObject(data): Model | null {
if (!this.models[data.type]) return null;
const type = (this.data[data.type] = this.data[data.type] || {});
if (type[data.id]) {
type[data.id].pushData(data);
} else {
type[data.id] = this.createRecord(data.type, data);
}
type[data.id].exists = true;
return type[data.id];
}
/**
* Make a request to the API to find record(s) of a specific type.
*
* @param type The resource type.
* @param [id] The ID(s) of the model(s) to retrieve.
* Alternatively, if an object is passed, it will be handled as the
* `query` parameter.
* @param query
* @param options
*/
find<T extends Model = Model>(type: string, id?: number | number[] | any, query = {}, options = {}): Promise<T | T[]> {
let params = query;
let url = `${app.forum.attribute('apiUrl')}/${type}`;
if (id instanceof Array) {
url += `?filter[id]=${id.join(',')}`;
} else if (typeof id === 'object') {
params = id;
} else if (id) {
url += `/${id}`;
}
return <Promise<T | T[]>>app
.request(
Object.assign(
{
method: 'GET',
url,
params,
},
options
)
)
.then(this.pushPayload.bind(this));
}
/**
* Get a record from the store by ID.
*
* @param type The resource type.
* @param id The resource ID.
*/
getById<T extends Model = Model>(type: string, id: number | string): T {
return this.data[type] && (this.data[type][id] as T);
}
/**
* Get a record from the store by the value of a model attribute.
*
* @param type The resource type.
* @param key The name of the method on the model.
* @param value The value of the model attribute.
*/
getBy<T extends Model = Model>(type: string, key: string, value: any): T {
return this.all<T>(type).filter((model) => model[key]() === value)[0];
}
/**
* Get all loaded records of a specific type.
*/
all<T extends Model = Model>(type: string): T[] {
const records = this.data[type];
return records ? Object.keys(records).map((id) => records[id]) : [];
}
/**
* Remove the given model from the store.
*/
remove(model: Model) {
delete this.data[model.data.type][model.id()];
}
/**
* Create a new record of the given type.
*
* @param {String} type The resource type
* @param {Object} [data] Any data to initialize the model with
*/
createRecord<T extends Model = Model>(type: string, data: any = {}): T {
data.type = data.type || type;
return new this.models[type](data, this);
}
}

305
js/src/common/Translator.js Normal file
View File

@@ -0,0 +1,305 @@
import username from './helpers/username';
import extract from './utils/extract';
/**
* Translator with the same API as Symfony's.
*
* Derived from https://github.com/willdurand/BazingaJsTranslationBundle
* which is available under the MIT License.
* Copyright (c) William Durand <william.durand1@gmail.com>
*/
export default class Translator {
constructor() {
/**
* A map of translation keys to their translated values.
*
* @type {Object}
* @public
*/
this.translations = {};
this.locale = null;
}
addTranslations(translations) {
Object.assign(this.translations, translations);
}
trans(id, parameters) {
const translation = this.translations[id];
if (translation) {
return this.apply(translation, parameters || {});
}
return id;
}
transChoice(id, number, parameters) {
let translation = this.translations[id];
if (translation) {
number = parseInt(number, 10);
translation = this.pluralize(translation, number);
return this.apply(translation, parameters || {});
}
return id;
}
apply(translation, input) {
// If we've been given a user model as one of the input parameters, then
// we'll extract the username and use that for the translation. In the
// future there should be a hook here to inspect the user and change the
// translation key. This will allow a gender property to determine which
// translation key is used.
if ('user' in input) {
const user = extract(input, 'user');
if (!input.username) input.username = username(user);
}
translation = translation.split(new RegExp('({[a-z0-9_]+}|</?[a-z0-9_]+>)', 'gi'));
const hydrated = [];
const open = [hydrated];
translation.forEach((part) => {
const match = part.match(new RegExp('{([a-z0-9_]+)}|<(/?)([a-z0-9_]+)>', 'i'));
if (match) {
// Either an opening or closing tag.
if (match[1]) {
open[0].push(input[match[1]]);
} else if (match[3]) {
if (match[2]) {
// Closing tag. We start by removing all raw children (generally in the form of strings) from the temporary
// holding array, then run them through m.fragment to convert them to vnodes. Usually this will just give us a
// text vnode, but using m.fragment as opposed to an explicit conversion should be more flexible. This is necessary because
// otherwise, our generated vnode will have raw strings as its children, and mithril expects vnodes.
// Finally, we add the now-processed vnodes back onto the holding array (which is the same object in memory as the
// children array of the vnode we are currently processing), and remove the reference to the holding array so that
// further text will be added to the full set of returned elements.
const rawChildren = open[0].splice(0, open[0].length);
open[0].push(...m.fragment(rawChildren).children);
open.shift();
} else {
// If a vnode with a matching tag was provided in the translator input, we use that. Otherwise, we create a new vnode
// with this tag, and an empty children array (since we're expecting to insert children, as that's the point of having this in translator)
let tag = input[match[3]] || { tag: match[3], children: [] };
open[0].push(tag);
// Insert the tag's children array as the first element of open, so that text in between the opening
// and closing tags will be added to the tag's children, not to the full set of returned elements.
open.unshift(tag.children || tag);
}
}
} else {
// Not an html tag, we add it to open[0], which is either the full set of returned elements (vnodes and text),
// or if an html tag is currently being processed, the children attribute of that html tag's vnode.
open[0].push(part);
}
});
return hydrated.filter((part) => part);
}
pluralize(translation, number) {
const sPluralRegex = new RegExp(/^\w+\: +(.+)$/),
cPluralRegex = new RegExp(/^\s*((\{\s*(\-?\d+[\s*,\s*\-?\d+]*)\s*\})|([\[\]])\s*(-Inf|\-?\d+)\s*,\s*(\+?Inf|\-?\d+)\s*([\[\]]))\s?(.+?)$/),
iPluralRegex = new RegExp(/^\s*(\{\s*(\-?\d+[\s*,\s*\-?\d+]*)\s*\})|([\[\]])\s*(-Inf|\-?\d+)\s*,\s*(\+?Inf|\-?\d+)\s*([\[\]])/),
standardRules = [],
explicitRules = [];
translation.split('|').forEach((part) => {
if (cPluralRegex.test(part)) {
const matches = part.match(cPluralRegex);
explicitRules[matches[0]] = matches[matches.length - 1];
} else if (sPluralRegex.test(part)) {
const matches = part.match(sPluralRegex);
standardRules.push(matches[1]);
} else {
standardRules.push(part);
}
});
explicitRules.forEach((rule, e) => {
if (iPluralRegex.test(e)) {
const matches = e.match(iPluralRegex);
if (matches[1]) {
const ns = matches[2].split(',');
for (let n in ns) {
if (number == ns[n]) {
return explicitRules[e];
}
}
} else {
var leftNumber = this.convertNumber(matches[4]);
var rightNumber = this.convertNumber(matches[5]);
if (
('[' === matches[3] ? number >= leftNumber : number > leftNumber) &&
(']' === matches[6] ? number <= rightNumber : number < rightNumber)
) {
return explicitRules[e];
}
}
}
});
return standardRules[this.pluralPosition(number, this.locale)] || standardRules[0] || undefined;
}
convertNumber(number) {
if ('-Inf' === number) {
return Number.NEGATIVE_INFINITY;
} else if ('+Inf' === number || 'Inf' === number) {
return Number.POSITIVE_INFINITY;
}
return parseInt(number, 10);
}
pluralPosition(number, locale) {
if ('pt_BR' === locale) {
locale = 'xbr';
}
if (locale.length > 3) {
locale = locale.split('_')[0];
}
switch (locale) {
case 'bo':
case 'dz':
case 'id':
case 'ja':
case 'jv':
case 'ka':
case 'km':
case 'kn':
case 'ko':
case 'ms':
case 'th':
case 'vi':
case 'zh':
return 0;
case 'af':
case 'az':
case 'bn':
case 'bg':
case 'ca':
case 'da':
case 'de':
case 'el':
case 'en':
case 'eo':
case 'es':
case 'et':
case 'eu':
case 'fa':
case 'fi':
case 'fo':
case 'fur':
case 'fy':
case 'gl':
case 'gu':
case 'ha':
case 'he':
case 'hu':
case 'is':
case 'it':
case 'ku':
case 'lb':
case 'ml':
case 'mn':
case 'mr':
case 'nah':
case 'nb':
case 'ne':
case 'nl':
case 'nn':
case 'no':
case 'om':
case 'or':
case 'pa':
case 'pap':
case 'ps':
case 'pt':
case 'so':
case 'sq':
case 'sv':
case 'sw':
case 'ta':
case 'te':
case 'tk':
case 'tr':
case 'ur':
case 'zu':
return number == 1 ? 0 : 1;
case 'am':
case 'bh':
case 'fil':
case 'fr':
case 'gun':
case 'hi':
case 'ln':
case 'mg':
case 'nso':
case 'xbr':
case 'ti':
case 'wa':
return number === 0 || number == 1 ? 0 : 1;
case 'be':
case 'bs':
case 'hr':
case 'ru':
case 'sr':
case 'uk':
return number % 10 == 1 && number % 100 != 11 ? 0 : number % 10 >= 2 && number % 10 <= 4 && (number % 100 < 10 || number % 100 >= 20) ? 1 : 2;
case 'cs':
case 'sk':
return number == 1 ? 0 : number >= 2 && number <= 4 ? 1 : 2;
case 'ga':
return number == 1 ? 0 : number == 2 ? 1 : 2;
case 'lt':
return number % 10 == 1 && number % 100 != 11 ? 0 : number % 10 >= 2 && (number % 100 < 10 || number % 100 >= 20) ? 1 : 2;
case 'sl':
return number % 100 == 1 ? 0 : number % 100 == 2 ? 1 : number % 100 == 3 || number % 100 == 4 ? 2 : 3;
case 'mk':
return number % 10 == 1 ? 0 : 1;
case 'mt':
return number == 1 ? 0 : number === 0 || (number % 100 > 1 && number % 100 < 11) ? 1 : number % 100 > 10 && number % 100 < 20 ? 2 : 3;
case 'lv':
return number === 0 ? 0 : number % 10 == 1 && number % 100 != 11 ? 1 : 2;
case 'pl':
return number == 1 ? 0 : number % 10 >= 2 && number % 10 <= 4 && (number % 100 < 12 || number % 100 > 14) ? 1 : 2;
case 'cy':
return number == 1 ? 0 : number == 2 ? 1 : number == 8 || number == 11 ? 2 : 3;
case 'ro':
return number == 1 ? 0 : number === 0 || (number % 100 > 0 && number % 100 < 20) ? 1 : 2;
case 'ar':
return number === 0 ? 0 : number == 1 ? 1 : number == 2 ? 2 : number >= 3 && number <= 10 ? 3 : number >= 11 && number <= 99 ? 4 : 5;
default:
return 0;
}
}
}

View File

@@ -1,281 +0,0 @@
import extract from './utils/extract';
import extractText from './utils/extractText';
import username from './helpers/username';
type Translations = { [key: string]: string };
export default class Translator {
/**
* A map of translation keys to their translated values.
*/
translations: Translations = {};
locale?: string;
addTranslations(translations) {
Object.assign(this.translations, translations);
}
trans(id: string, parameters?: any): string | any[] {
const translation = this.translations[id];
if (translation) {
return this.apply(translation, parameters || {});
}
return id;
}
transText(id: string, parameters?: any): string {
return extractText(this.trans(id, parameters));
}
transChoice(id: string, number: number, parameters: any): string | any[] {
let translation: string = this.translations[id];
if (translation) {
translation = this.pluralize(translation, number);
return this.apply(translation, parameters || {});
}
return id;
}
apply(translation: string, input: any) {
if ('user' in input) {
const user = extract(input, 'user');
if (!input.username) input.username = username(user);
}
const parts = translation.split(new RegExp('({[a-z0-9_]+}|</?[a-z0-9_]+>)', 'gi'));
const hydrated: any[] = [];
const open: any[][] = [hydrated];
parts.forEach((part) => {
const match = part.match(new RegExp('{([a-z0-9_]+)}|<(/?)([a-z0-9_]+)>', 'i'));
if (match) {
if (match[1]) {
open[0].push(input[match[1]]);
} else if (match[3]) {
if (match[2]) {
open.shift();
} else {
let tag = input[match[3]] || { tag: match[3], children: [] };
open[0].push(tag);
open.unshift(tag.children || tag);
}
}
} else {
open[0].push({ tag: 'span', text: part });
}
});
return hydrated.filter((part) => part);
}
pluralize(translation: string, number: number): string | undefined {
const sPluralRegex = new RegExp(/^\w+\: +(.+)$/),
cPluralRegex = new RegExp(
/^\s*((\{\s*(\-?\d+[\s*,\s*\-?\d+]*)\s*\})|([\[\]])\s*(-Inf|\-?\d+)\s*,\s*(\+?Inf|\-?\d+)\s*([\[\]]))\s?(.+?)$/
),
iPluralRegex = new RegExp(/^\s*(\{\s*(\-?\d+[\s*,\s*\-?\d+]*)\s*\})|([\[\]])\s*(-Inf|\-?\d+)\s*,\s*(\+?Inf|\-?\d+)\s*([\[\]])/),
standardRules = [],
explicitRules = [];
translation.split('|').forEach((part) => {
if (cPluralRegex.test(part)) {
const matches = part.match(cPluralRegex);
explicitRules[matches[0]] = matches[matches.length - 1];
} else if (sPluralRegex.test(part)) {
const matches = part.match(sPluralRegex);
standardRules.push(matches[1]);
} else {
standardRules.push(part);
}
});
explicitRules.forEach((rule, e) => {
if (iPluralRegex.test(e)) {
const matches = e.match(iPluralRegex);
if (matches[1]) {
const ns = matches[2].split(',');
for (let n in ns) {
if (number == ns[n]) {
return explicitRules[e];
}
}
} else {
const leftNumber = this.convertNumber(matches[4]);
const rightNumber = this.convertNumber(matches[5]);
if (
('[' === matches[3] ? number >= leftNumber : number > leftNumber) &&
(']' === matches[6] ? number <= rightNumber : number < rightNumber)
) {
return explicitRules[e];
}
}
}
});
return standardRules[this.pluralPosition(number, this.locale)] || standardRules[0] || undefined;
}
convertNumber(number: string): number {
if ('-Inf' === number) {
return Number.NEGATIVE_INFINITY;
} else if ('+Inf' === number || 'Inf' === number) {
return Number.POSITIVE_INFINITY;
}
return parseInt(number, 10);
}
pluralPosition(number: number, locale: string): number {
if ('pt_BR' === locale) {
locale = 'xbr';
}
if (locale.length > 3) {
locale = locale.split('_')[0];
}
switch (locale) {
case 'bo':
case 'dz':
case 'id':
case 'ja':
case 'jv':
case 'ka':
case 'km':
case 'kn':
case 'ko':
case 'ms':
case 'th':
case 'vi':
case 'zh':
return 0;
case 'af':
case 'az':
case 'bn':
case 'bg':
case 'ca':
case 'da':
case 'de':
case 'el':
case 'en':
case 'eo':
case 'es':
case 'et':
case 'eu':
case 'fa':
case 'fi':
case 'fo':
case 'fur':
case 'fy':
case 'gl':
case 'gu':
case 'ha':
case 'he':
case 'hu':
case 'is':
case 'it':
case 'ku':
case 'lb':
case 'ml':
case 'mn':
case 'mr':
case 'nah':
case 'nb':
case 'ne':
case 'nl':
case 'nn':
case 'no':
case 'om':
case 'or':
case 'pa':
case 'pap':
case 'ps':
case 'pt':
case 'so':
case 'sq':
case 'sv':
case 'sw':
case 'ta':
case 'te':
case 'tk':
case 'tr':
case 'ur':
case 'zu':
return number == 1 ? 0 : 1;
case 'am':
case 'bh':
case 'fil':
case 'fr':
case 'gun':
case 'hi':
case 'ln':
case 'mg':
case 'nso':
case 'xbr':
case 'ti':
case 'wa':
return number === 0 || number == 1 ? 0 : 1;
case 'be':
case 'bs':
case 'hr':
case 'ru':
case 'sr':
case 'uk':
return number % 10 == 1 && number % 100 != 11
? 0
: number % 10 >= 2 && number % 10 <= 4 && (number % 100 < 10 || number % 100 >= 20)
? 1
: 2;
case 'cs':
case 'sk':
return number == 1 ? 0 : number >= 2 && number <= 4 ? 1 : 2;
case 'ga':
return number == 1 ? 0 : number == 2 ? 1 : 2;
case 'lt':
return number % 10 == 1 && number % 100 != 11 ? 0 : number % 10 >= 2 && (number % 100 < 10 || number % 100 >= 20) ? 1 : 2;
case 'sl':
return number % 100 == 1 ? 0 : number % 100 == 2 ? 1 : number % 100 == 3 || number % 100 == 4 ? 2 : 3;
case 'mk':
return number % 10 == 1 ? 0 : 1;
case 'mt':
return number == 1 ? 0 : number === 0 || (number % 100 > 1 && number % 100 < 11) ? 1 : number % 100 > 10 && number % 100 < 20 ? 2 : 3;
case 'lv':
return number === 0 ? 0 : number % 10 == 1 && number % 100 != 11 ? 1 : 2;
case 'pl':
return number == 1 ? 0 : number % 10 >= 2 && number % 10 <= 4 && (number % 100 < 12 || number % 100 > 14) ? 1 : 2;
case 'cy':
return number == 1 ? 0 : number == 2 ? 1 : number == 8 || number == 11 ? 2 : 3;
case 'ro':
return number == 1 ? 0 : number === 0 || (number % 100 > 0 && number % 100 < 20) ? 1 : 2;
case 'ar':
return number === 0 ? 0 : number == 1 ? 1 : number == 2 ? 2 : number >= 3 && number <= 10 ? 3 : number >= 11 && number <= 99 ? 4 : 5;
default:
return 0;
}
}
}

137
js/src/common/compat.js Normal file
View File

@@ -0,0 +1,137 @@
import * as extend from './extend';
import Session from './Session';
import Store from './Store';
import evented from './utils/evented';
import liveHumanTimes from './utils/liveHumanTimes';
import ItemList from './utils/ItemList';
import mixin from './utils/mixin';
import humanTime from './utils/humanTime';
import computed from './utils/computed';
import Drawer from './utils/Drawer';
import anchorScroll from './utils/anchorScroll';
import RequestError from './utils/RequestError';
import abbreviateNumber from './utils/abbreviateNumber';
import * as string from './utils/string';
import SubtreeRetainer from './utils/SubtreeRetainer';
import setRouteWithForcedRefresh from './utils/setRouteWithForcedRefresh';
import extract from './utils/extract';
import ScrollListener from './utils/ScrollListener';
import stringToColor from './utils/stringToColor';
import subclassOf from './utils/subclassOf';
import patchMithril from './utils/patchMithril';
import classList from './utils/classList';
import extractText from './utils/extractText';
import formatNumber from './utils/formatNumber';
import mapRoutes from './utils/mapRoutes';
import withAttr from './utils/withAttr';
import Notification from './models/Notification';
import User from './models/User';
import Post from './models/Post';
import Discussion from './models/Discussion';
import Group from './models/Group';
import Forum from './models/Forum';
import Component from './Component';
import Translator from './Translator';
import AlertManager from './components/AlertManager';
import Page from './components/Page';
import Switch from './components/Switch';
import Badge from './components/Badge';
import LoadingIndicator from './components/LoadingIndicator';
import Placeholder from './components/Placeholder';
import Separator from './components/Separator';
import Dropdown from './components/Dropdown';
import SplitDropdown from './components/SplitDropdown';
import RequestErrorModal from './components/RequestErrorModal';
import FieldSet from './components/FieldSet';
import Select from './components/Select';
import Navigation from './components/Navigation';
import Alert from './components/Alert';
import LinkButton from './components/LinkButton';
import Checkbox from './components/Checkbox';
import SelectDropdown from './components/SelectDropdown';
import ModalManager from './components/ModalManager';
import Button from './components/Button';
import Modal from './components/Modal';
import GroupBadge from './components/GroupBadge';
import Model from './Model';
import Application from './Application';
import fullTime from './helpers/fullTime';
import avatar from './helpers/avatar';
import icon from './helpers/icon';
import humanTimeHelper from './helpers/humanTime';
import punctuateSeries from './helpers/punctuateSeries';
import highlight from './helpers/highlight';
import username from './helpers/username';
import userOnline from './helpers/userOnline';
import listItems from './helpers/listItems';
import Fragment from './Fragment';
export default {
extend: extend,
Session: Session,
Store: Store,
'utils/evented': evented,
'utils/liveHumanTimes': liveHumanTimes,
'utils/ItemList': ItemList,
'utils/mixin': mixin,
'utils/humanTime': humanTime,
'utils/computed': computed,
'utils/Drawer': Drawer,
'utils/anchorScroll': anchorScroll,
'utils/RequestError': RequestError,
'utils/abbreviateNumber': abbreviateNumber,
'utils/string': string,
'utils/SubtreeRetainer': SubtreeRetainer,
'utils/extract': extract,
'utils/ScrollListener': ScrollListener,
'utils/stringToColor': stringToColor,
'utils/subclassOf': subclassOf,
'utils/setRouteWithForcedRefresh': setRouteWithForcedRefresh,
'utils/patchMithril': patchMithril,
'utils/classList': classList,
'utils/extractText': extractText,
'utils/formatNumber': formatNumber,
'utils/mapRoutes': mapRoutes,
'utils/withAttr': withAttr,
'models/Notification': Notification,
'models/User': User,
'models/Post': Post,
'models/Discussion': Discussion,
'models/Group': Group,
'models/Forum': Forum,
Component: Component,
Fragment: Fragment,
Translator: Translator,
'components/AlertManager': AlertManager,
'components/Page': Page,
'components/Switch': Switch,
'components/Badge': Badge,
'components/LoadingIndicator': LoadingIndicator,
'components/Placeholder': Placeholder,
'components/Separator': Separator,
'components/Dropdown': Dropdown,
'components/SplitDropdown': SplitDropdown,
'components/RequestErrorModal': RequestErrorModal,
'components/FieldSet': FieldSet,
'components/Select': Select,
'components/Navigation': Navigation,
'components/Alert': Alert,
'components/LinkButton': LinkButton,
'components/Checkbox': Checkbox,
'components/SelectDropdown': SelectDropdown,
'components/ModalManager': ModalManager,
'components/Button': Button,
'components/Modal': Modal,
'components/GroupBadge': GroupBadge,
Model: Model,
Application: Application,
'helpers/fullTime': fullTime,
'helpers/avatar': avatar,
'helpers/icon': icon,
'helpers/humanTime': humanTimeHelper,
'helpers/punctuateSeries': punctuateSeries,
'helpers/highlight': highlight,
'helpers/username': username,
'helpers/userOnline': userOnline,
'helpers/listItems': listItems,
};

View File

@@ -1,129 +0,0 @@
import Application from './Application';
import Component from './Component';
import * as extend from './extend';
import Model from './Model';
import Session from './Session';
import Store from './Store';
import Translator from './Translator';
import Evented from './utils/Evented';
// import liveHumanTimes from './utils/liveHumanTimes';
import ItemList from './utils/ItemList';
import humanTime from './utils/humanTime';
import computed from './utils/computed';
import Drawer from './utils/Drawer';
import anchorScroll from './utils/anchorScroll';
import RequestError from './utils/RequestError';
import abbreviateNumber from './utils/abbreviateNumber';
import * as string from './utils/string';
import SubtreeRetainer from './utils/SubtreeRetainer';
import extract from './utils/extract';
import ScrollListener from './utils/ScrollListener';
import stringToColor from './utils/stringToColor';
import patchMithril from './utils/patchMithril';
import extractText from './utils/extractText';
import formatNumber from './utils/formatNumber';
import mapRoutes from './utils/mapRoutes';
import Notification from './models/Notification';
import User from './models/User';
import Post from './models/Post';
import Discussion from './models/Discussion';
import Group from './models/Group';
import Forum from './models/Forum';
import AlertManager from './components/AlertManager';
import Switch from './components/Switch';
import Badge from './components/Badge';
import LoadingIndicator from './components/LoadingIndicator';
import Placeholder from './components/Placeholder';
import Separator from './components/Separator';
import Dropdown from './components/Dropdown';
import SplitDropdown from './components/SplitDropdown';
import RequestErrorModal from './components/RequestErrorModal';
import FieldSet from './components/FieldSet';
import Select from './components/Select';
import Navigation from './components/Navigation';
import Alert from './components/Alert';
import LinkButton from './components/LinkButton';
import Checkbox from './components/Checkbox';
import SelectDropdown from './components/SelectDropdown';
import ModalManager from './components/ModalManager';
import Button from './components/Button';
import Modal from './components/Modal';
import GroupBadge from './components/GroupBadge';
import fullTime from './helpers/fullTime';
import avatar from './helpers/avatar';
import icon from './helpers/icon';
import humanTimeHelper from './helpers/humanTime';
// import punctuateSeries from './helpers/punctuateSeries';
import highlight from './helpers/highlight';
import username from './helpers/username';
import userOnline from './helpers/userOnline';
import listItems from './helpers/listItems';
export default {
Application: Application,
Component: Component,
extend: extend,
Model: Model,
Session: Session,
Store: Store,
Translator: Translator,
'utils/Evented': Evented,
// 'utils/liveHumanTimes': liveHumanTimes,
'utils/ItemList': ItemList,
'utils/humanTime': humanTime,
'utils/computed': computed,
'utils/Drawer': Drawer,
'utils/anchorScroll': anchorScroll,
'utils/RequestError': RequestError,
'utils/abbreviateNumber': abbreviateNumber,
'utils/string': string,
'utils/SubtreeRetainer': SubtreeRetainer,
'utils/extract': extract,
'utils/ScrollListener': ScrollListener,
'utils/stringToColor': stringToColor,
'utils/patchMithril': patchMithril,
'utils/extractText': extractText,
'utils/formatNumber': formatNumber,
'utils/mapRoutes': mapRoutes,
'models/Notification': Notification,
'models/User': User,
'models/Post': Post,
'models/Discussion': Discussion,
'models/Group': Group,
'models/Forum': Forum,
'components/AlertManager': AlertManager,
'components/Switch': Switch,
'components/Badge': Badge,
'components/LoadingIndicator': LoadingIndicator,
'components/Placeholder': Placeholder,
'components/Separator': Separator,
'components/Dropdown': Dropdown,
'components/SplitDropdown': SplitDropdown,
'components/RequestErrorModal': RequestErrorModal,
'components/FieldSet': FieldSet,
'components/Select': Select,
'components/Navigation': Navigation,
'components/Alert': Alert,
'components/LinkButton': LinkButton,
'components/Checkbox': Checkbox,
'components/SelectDropdown': SelectDropdown,
'components/ModalManager': ModalManager,
'components/Button': Button,
'components/Modal': Modal,
'components/GroupBadge': GroupBadge,
'helpers/fullTime': fullTime,
'helpers/avatar': avatar,
'helpers/icon': icon,
'helpers/humanTime': humanTimeHelper,
// 'helpers/punctuateSeries': punctuateSeries,
'helpers/highlight': highlight,
'helpers/username': username,
'helpers/userOnline': userOnline,
'helpers/listItems': listItems,
};

View File

@@ -0,0 +1,48 @@
import Component from '../Component';
import Button from './Button';
import listItems from '../helpers/listItems';
import extract from '../utils/extract';
/**
* The `Alert` component represents an alert box, which contains a message,
* some controls, and may be dismissible.
*
* ### Attrs
*
* - `type` The type of alert this is. Will be used to give the alert a class
* name of `Alert--{type}`.
* - `controls` An array of controls to show in the alert.
* - `dismissible` Whether or not the alert can be dismissed.
* - `ondismiss` A callback to run when the alert is dismissed.
*
* All other attrs will be assigned as attributes on the DOM element.
*/
export default class Alert extends Component {
view(vnode) {
const attrs = Object.assign({}, this.attrs);
const type = extract(attrs, 'type');
attrs.className = 'Alert Alert--' + type + ' ' + (attrs.className || '');
const content = extract(attrs, 'content') || vnode.children;
const controls = extract(attrs, 'controls') || [];
// If the alert is meant to be dismissible (which is the case by default),
// then we will create a dismiss button to append as the final control in
// the alert.
const dismissible = extract(attrs, 'dismissible');
const ondismiss = extract(attrs, 'ondismiss');
const dismissControl = [];
if (dismissible || dismissible === undefined) {
dismissControl.push(<Button icon="fas fa-times" className="Button Button--link Button--icon Alert-dismiss" onclick={ondismiss} />);
}
return (
<div {...attrs}>
<span className="Alert-body">{content}</span>
<ul className="Alert-controls">{listItems(controls.concat(dismissControl))}</ul>
</div>
);
}
}

View File

@@ -1,71 +0,0 @@
import * as Mithril from 'mithril';
import Component, { ComponentProps } from '../Component';
import Button from './Button';
import listItems from '../helpers/listItems';
import extract from '../utils/extract';
import AlertState from '../states/AlertState';
export interface AlertData extends ComponentProps {
/**
* An array of controls to show in the alert.
*/
controls?: Mithril.ChildArray;
/**
* The type of alert this is. Will be used to give the alert a class
* name of `Alert--{type}`.
*/
type?: string;
/**
* Whether or not the alert can be dismissed.
*/
dismissible?: boolean;
/**
* A callback to run when the alert is dismissed.
*/
ondismiss?: () => any;
}
export interface AlertProps extends AlertData {
state: AlertState;
}
/**
* The `Alert` component represents an alert box, which contains a message,
* some controls, and may be dismissible.
*
* All other props will be assigned as attributes on the alert element.
*/
export default class Alert extends Component<AlertProps> {
view() {
const data = this.props.state?.data || this.props;
const attrs: AlertData = Object.assign({}, data);
const type: string = extract(attrs, 'type');
attrs.className = `Alert Alert--${type} ${attrs.className || ''}`;
const children: Mithril.Children = extract(attrs, 'children');
const controls: Mithril.ChildArray = extract(attrs, 'controls') || [];
// If the alert is meant to be dismissible (which is the case by default),
// then we will create a dismiss button to append as the final control in
// the alert.
const dismissible: boolean | undefined = extract(attrs, 'dismissible');
const ondismiss: () => any = extract(attrs, 'ondismiss');
const dismissControl: JSX.Element[] = [];
if (dismissible || dismissible === undefined) {
dismissControl.push(<Button icon="fas fa-times" className="Button Button--link Button--icon Alert-dismiss" onclick={ondismiss} />);
}
return (
<div {...attrs}>
<span className="Alert-body">{children}</span>
<ul className="Alert-controls">{listItems(controls.concat(dismissControl))}</ul>
</div>
);
}
}

View File

@@ -0,0 +1,28 @@
import Component from '../Component';
import Alert from './Alert';
/**
* The `AlertManager` component provides an area in which `Alert` components can
* be shown and dismissed.
*/
export default class AlertManager extends Component {
oninit(vnode) {
super.oninit(vnode);
this.state = this.attrs.state;
}
view() {
return (
<div className="AlertManager">
{Object.entries(this.state.getActiveAlerts()).map(([key, alert]) => (
<div className="AlertManager-alert">
<alert.componentClass {...alert.attrs} ondismiss={this.state.dismiss.bind(this.state, key)}>
{alert.children}
</alert.componentClass>
</div>
))}
</div>
);
}
}

View File

@@ -1,62 +0,0 @@
import Component from '../Component';
import AlertState from '../states/AlertState';
import Alert, { AlertData } from './Alert';
/**
* The `AlertManager` component provides an area in which `Alert` components can
* be shown and dismissed.
*/
export default class AlertManager extends Component {
/**
* An array of Alert components which are currently showing.
*/
protected states: AlertState[] = [];
view() {
return (
<div className="AlertManager">
{this.states.map((state) => (
<div className="AlertManager-alert">
<Alert state={state} ondismiss={this.dismiss.bind(this)} />
</div>
))}
</div>
);
}
/**
* Show an Alert in the alerts area.
*/
public show(state: AlertState | AlertData): number {
if (!(state instanceof AlertState)) state = new AlertState(state);
this.states.push(state as AlertState);
m.redraw();
return state.key;
}
/**
* Dismiss an alert.
*/
public dismiss(keyOrState?: AlertState | number) {
if (!keyOrState) return;
const key = keyOrState instanceof AlertState ? keyOrState.key : keyOrState;
let index = this.states.indexOf(this.states.filter((a) => a.key == key)[0]);
if (index !== -1) {
this.states.splice(index, 1);
m.redraw();
}
}
/**
* Clear all alerts.
*/
public clear() {
this.states = [];
m.redraw();
}
}

View File

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

View File

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

View File

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

View File

@@ -1,90 +0,0 @@
import Component, { ComponentProps } from '../Component';
import icon from '../helpers/icon';
import extract from '../utils/extract';
import extractText from '../utils/extractText';
import LoadingIndicator from './LoadingIndicator';
export interface ButtonProps extends ComponentProps {
/**
* A tooltip for the button.
*/
title?: string;
/**
* An html type attribute for the button.
*/
type?: string;
/**
* The name of the icon class. If specified, the button will be given a
* 'has-icon' class name.
*/
icon?: string;
/**
* Whether or not the button should be in a disabled loading state.
*/
loading?: boolean;
/**
* Whether or not the button is disabled. If truthy, the button
* will be given a 'disabled' class name, and any `onclick` handler will be
* removed.
*/
disabled?: boolean;
/**
* A callback to run when the button is clicked.
*/
onclick?: Function;
}
/**
* The `Button` component defines an element which, when clicked, performs an
* action.
*
* Note that a Button has no default class names. This is because a Button can
* be used to represent any generic clickable control, like a menu item.
*/
export default class Button<T extends ButtonProps = ButtonProps> extends Component<T> {
view() {
const attrs = (({ children, ...o }) => o)(this.props) as T;
const children = this.props.children;
attrs.className = attrs.className || '';
attrs.type = attrs.type || 'button';
// If a tooltip was provided for buttons without additional content, we also
// use this tooltip as text for screen readers
if (attrs.title && !children) {
attrs['aria-label'] = attrs.title;
}
// If nothing else is provided, we use the textual button content as tooltip
if (!attrs.title && children) {
attrs.title = extractText(children);
}
const iconName = extract(attrs, 'icon');
if (iconName) attrs.className += ' hasIcon';
const loading = extract(attrs, 'loading');
if (attrs.disabled || loading) {
attrs.className = classNames(attrs.className, 'disabled', loading && 'loading');
delete attrs.onclick;
}
return <button {...attrs}>{this.getButtonContent(iconName, loading, children)}</button>;
}
/**
* Get the template for the button's content.
*/
protected getButtonContent(iconName?: string | boolean, loading?: boolean, children?: any): any[] {
return [
iconName && iconName !== true ? icon(iconName, { className: 'Button-icon' }) : '',
children ? <span className="Button-label">{children}</span> : '',
loading ? LoadingIndicator.component({ size: 'tiny', className: 'LoadingIndicator--inline' }) : '',
];
}
}

View File

@@ -0,0 +1,61 @@
import Component from '../Component';
import LoadingIndicator from './LoadingIndicator';
import icon from '../helpers/icon';
import classList from '../utils/classList';
import withAttr from '../utils/withAttr';
/**
* The `Checkbox` component defines a checkbox input.
*
* ### Attrs
*
* - `state` Whether or not the checkbox is checked.
* - `className` The class name for the root element.
* - `disabled` Whether or not the checkbox is disabled.
* - `loading` Whether or not the checkbox is loading.
* - `onchange` A callback to run when the checkbox is checked/unchecked.
* - `children` A text label to display next to the checkbox.
*/
export default class Checkbox extends Component {
view(vnode) {
// Sometimes, false is stored in the DB as '0'. This is a temporary
// conversion layer until a more robust settings encoding is introduced
if (this.attrs.state === '0') this.attrs.state = false;
const className = classList([
'Checkbox',
this.attrs.state ? 'on' : 'off',
this.attrs.className,
this.attrs.loading && 'loading',
this.attrs.disabled && 'disabled',
]);
return (
<label className={className}>
<input type="checkbox" checked={this.attrs.state} disabled={this.attrs.disabled} onchange={withAttr('checked', this.onchange.bind(this))} />
<div className="Checkbox-display">{this.getDisplay()}</div>
{vnode.children}
</label>
);
}
/**
* Get the template for the checkbox's display (tick/cross icon).
*
* @return {*}
* @protected
*/
getDisplay() {
return this.attrs.loading ? <LoadingIndicator size="tiny" /> : icon(this.attrs.state ? 'fas fa-check' : 'fas fa-times');
}
/**
* Run a callback when the state of the checkbox is changed.
*
* @param {Boolean} checked
* @protected
*/
onchange(checked) {
if (this.attrs.onchange) this.attrs.onchange(checked, this);
}
}

View File

@@ -1,67 +0,0 @@
import Component, { ComponentProps } from '../Component';
import LoadingIndicator from './LoadingIndicator';
import icon from '../helpers/icon';
export interface CheckboxProps extends ComponentProps {
/**
* Whether or not the checkbox is checked
*/
state: boolean;
/**
* Whether or not the checkbox is disabled.
*/
disabled: boolean;
/**
* A callback to run when the checkbox is checked/unchecked.
*/
onchange?: Function;
}
/**
* The `Checkbox` component defines a checkbox input.
*/
export default class Checkbox<T extends CheckboxProps = CheckboxProps> extends Component<CheckboxProps> {
/**
* Whether or not the checkbox's value is in the process of being saved.
*/
loading = false;
view() {
const className = classNames(
'Checkbox',
this.props.className,
this.props.state ? 'on' : 'off',
this.loading && 'loading',
this.props.disabled && 'disabled'
);
return (
<label className={className}>
<input
type="checkbox"
checked={this.props.state}
disabled={this.props.disabled}
onchange={m.withAttr('checked', this.onchange.bind(this))}
/>
<div className="Checkbox-display">{this.getDisplay()}</div>
{this.props.children}
</label>
);
}
/**
* Get the template for the checkbox's display (tick/cross icon).
*/
protected getDisplay() {
return this.loading ? LoadingIndicator.component({ size: 'tiny' }) : icon(this.props.state ? 'fas fa-check' : 'fas fa-times');
}
/**
* Run a callback when the state of the checkbox is changed.
*/
protected onchange(checked: boolean) {
if (this.props.onchange) this.props.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];
}
}

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