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

Compare commits

..

201 Commits

Author SHA1 Message Date
Franz Liedke
4eaf9ae23f New extender for error handling (#1970)
This extender implements several methods for extending the new error
handling stack implemented in #1843.

Most use-cases should be covered, but I expect some challenges for more
complex setups. We can tackle those once they come up, though. Basic
use-cases should be covered.

Fixes #1781.
2020-02-04 22:59:06 +01:00
flarum-bot
4d80700e97 Bundled output for commit 8877bf97c4 [skip ci] 2020-02-04 22:59:06 +01:00
Franz Liedke
8ad95a4dfb Clarify the use-case of the JS slug helper 2020-02-04 22:59:06 +01:00
Franz Liedke
03faaaedef Use Laravel's slugger for basic transliteration
This is better than the current system, as it adds transliteration rules
for special characters, rather than just throwing all of them away.

For languages that cannot be transliterated to ASCII in a reasonable
manner, more possible improvements are outlined in #194.
2020-02-04 22:59:06 +01:00
flarum-bot
5c3a0e3e6e Bundled output for commit 02ceed4fed [skip ci] 2020-02-04 22:59:06 +01:00
Clark Winkelmann
588f7498e1 Fix the "reply posted" alert empty body 2020-02-04 22:59:06 +01:00
Franz Liedke
8d1240559b Remove unnecessary use statement 2020-02-04 22:59:06 +01:00
ozzzzzzzam
7ad8eb7544 Remove forum title from confirmation email subject (#1613)
The forum title is already used as the display name for the sender email address, so having it in the subject is just a duplication and waste of space.
2020-02-04 22:59:06 +01:00
Matthew Kilgore
ebd2c69c8d Additional functionality for Middleware extender
Implements the remove, insertBefore, insertAfter and replace
functionality for middlewares.

The IoC container now holds one array of middleware (bindings) per
frontend stack - the extender operates on that array, before it is
wrapped in a middleware "pipe".

Fixes #1957, closes #1971.
2020-02-04 22:59:06 +01:00
flarum-bot
637ae1624c Bundled output for commit 02899d4f68 [skip ci] 2020-02-04 22:59:06 +01:00
David Sevilla Martín
0c9bcba3a6 Add Content for User page, preload user & throw 404 accordingly (#1901) 2020-02-04 22:59:06 +01:00
Franz Liedke
5b3acfc0d9 Convert another test
Test the request, not a controller (implementation detail). This also
focuses on the observable behavior instead of hacking our way into the
middleware pipeline in order to observe internal behavior.

The authenticated user is now determined by looking at the API response
to compare permissions and (non-)existing JSON keys.
2020-02-04 22:59:06 +01:00
David Sevilla Martín
7adfb5bd7e Initial template for Stale bot configuration (#1841) 2020-02-04 22:59:06 +01:00
Julian Berger
6b916065e9 Get translations from fallback catalogues (#1961) 2020-02-04 22:59:06 +01:00
Franz Liedke
f99f48b155 Add backwards compatibility layer for mail drivers
Support the old format (a simple list of available fields), in addition
to the new format (a map from field names to their types + metadata).

This will be removed after beta.12 is released.
2020-02-04 22:59:06 +01:00
Franz Liedke
d2c345c834 Document changes in mail driver interface 2020-02-04 22:59:06 +01:00
flarum-bot
966a093911 Bundled output for commit 4c89e2eb77 [skip ci] 2020-02-04 22:59:06 +01:00
Vladimir Vinogradov
0560238945 Add Mailgun region setting
Fixes #1834.
2020-02-04 22:59:05 +01:00
Franz Liedke
63801484fa Ensure page parameters are always integers 2020-02-04 22:59:05 +01:00
Matt Kilgore
f8d92edc9a Change Zend namespace to Laminas (#1963)
Also ensure backwards compatibility for extensions that use the Zend framework but don't explicitly require it.
2020-02-04 22:59:05 +01:00
Daniël Klabbers
e1dbfa7d68 Update LICENSE 2020-02-04 22:59:05 +01:00
Matt Kilgore
ef7623e4ff Middleware extender (#1952) 2020-02-04 22:59:05 +01:00
flarum-bot
d7fd076220 Bundled output for commit c1878fe29b [skip ci] 2020-02-04 22:59:05 +01:00
Franz Liedke
30a2421749 Update Webpack 2020-02-04 22:59:05 +01:00
Franz Liedke
1bf6e79b32 Catch more exceptions during boot process
This extends our boot exception handling block to also catch and format
all exceptions that could be thrown while building our request handler,
i.e. the middleware stack handling requests.

The only exceptions that would now not be handled in this way could be
raised by Zend's `RequestHandlerRunner` and its delegates, which we
should be able to rely on.

Exceptions on request execution will be handled by the error handler in
the middleware stack.

Fixes #1607.
2020-02-04 22:59:05 +01:00
w-4
8aaa39bd4e Fix update page with custom base path (#1947)
Calling UpdateHandler causes RouteNotFoundException when basepath is not /.
2020-02-04 22:59:05 +01:00
Franz Liedke
b1e11830d1 Link to security policy from README 2020-02-04 22:59:05 +01:00
Franz Liedke
3375f283eb FUNDING.yml does not inherit 2020-02-04 22:59:05 +01:00
Franz Liedke
df4c193ab7 Add a custom FUNDING.yml file for this repository
Let's hope GitHub inherits the lines from our default community health
files at https://github.com/flarum/.github.
2020-02-04 22:59:05 +01:00
Daniël Klabbers
5a8326f442 Update CHANGELOG.md 2020-02-04 22:59:05 +01:00
Daniel Klabbers
d8dd870efe releasing beta 11.1 2020-02-04 22:59:05 +01:00
Franz Liedke
60572b93fb Fix implementations of settings repo interface 2020-02-04 22:59:05 +01:00
Daniel Klabbers
810bfdc28f Revert "7.4 release, forcing tests to work with them"
This reverts commit da5628d125.
2020-02-04 22:59:05 +01:00
Daniel Klabbers
9c4a24b258 7.4 release, forcing tests to work with them 2020-02-04 22:59:05 +01:00
David Sevilla Martín
18462c079f Update Application version string to beta 11 2020-02-04 22:59:05 +01:00
Franz Liedke
10d6e653cb Apply fixes from StyleCI
[ci skip] [skip ci]
2020-02-04 22:59:05 +01:00
Franz Liedke
4e1f753f59 Update copyright claims in LICENSE 2020-02-04 22:59:04 +01:00
Daniel Klabbers
3c2dd23765 preparing the changelog for beta 11, part 2 2020-02-04 22:59:04 +01:00
Daniel Klabbers
aebc23cba2 preparing the changelog for beta 11 2020-02-04 22:59:04 +01:00
Clark Winkelmann
f6d03771cb Fix tests to include expectation count and run user saving events 2020-02-04 22:59:04 +01:00
Clark Winkelmann
54be9573ac Add unit test for AvatarUploader 2020-02-04 22:59:04 +01:00
Clark Winkelmann
bf46ea3840 Fix avatar files not being deleted. Fixes #1918 2020-02-04 22:59:04 +01:00
flarum-bot
bab49650e6 Bundled output for commit 17c86b82bf [skip ci] 2020-02-04 22:59:04 +01:00
w-4
a789c6b4e9 history back function fix
it shouldn't check for canGoBack again after the array pop()
2020-02-04 22:59:04 +01:00
Daniel Klabbers
3b3459ad3d incorrect ability used, drop prefix discussion. 2020-02-04 22:59:04 +01:00
Daniel Klabbers
223f4d93d4 test only on the hidePosts policy ability 2020-02-04 22:59:04 +01:00
Daniel Klabbers
5d1fe9b815 resume chain in query builder 2020-02-04 22:59:04 +01:00
Daniël Klabbers
521834f5da [review] using orWhere to allow any where to follow in extensions 2020-02-04 22:59:04 +01:00
Daniël Klabbers
622e2a6644 fixes #1827
- set default statement to block access
- added tests to confirm all scenarios work as intended
2020-02-04 22:59:04 +01:00
Franz Liedke
c5e38a5b1f Automatically set up Mockery for unit tests
- Use provided PhpUnit listener to enforce verification of expectations.
- Include Mockery's trait to auto-close Mockery after each test.
2020-02-04 22:59:04 +01:00
Franz Liedke
9d2595d531 Actually return null
Nullable return types require an explicit null return value; not
returning or returning without value is the "void" type.
2020-02-04 22:59:04 +01:00
David Sevilla Martin
3526083320 Add test for discussion posts being deleted on discussion delete from DB 2020-02-04 22:59:04 +01:00
David Sevilla Martin
82562294b7 Fix failing tests 2020-02-04 22:59:04 +01:00
datitisev
51ae92f841 Apply fixes from StyleCI
[ci skip] [skip ci]
2020-02-04 22:59:04 +01:00
David Sevilla Martin
6448babaa5 Remove 'or' from 'orWhereNotExists' 2020-02-04 22:59:04 +01:00
David Sevilla Martin
f7feea496d Add discussion_id foreign key to posts table 2020-02-04 22:59:04 +01:00
flarum-bot
75e624d7ca Bundled output for commit 6d2b50722a [skip ci] 2020-02-04 22:59:04 +01:00
Clark Winkelmann
5a01b63c99 Pass event to KeyboardNavigatable whenCallback (#1922)
This way the callback can know which key is pressed.
2020-02-04 22:59:04 +01:00
Daniël Klabbers
cae4a6eb45 Fix the queue:restart command (#1932)
Adding a proxy callStatic on our simple implementation of the Manager class allows passing through calls like `forever()` to the underlying cache driver instance.
2020-02-04 22:59:04 +01:00
Franz Liedke
36d6d79011 Add a docblock 2020-02-04 22:59:04 +01:00
Daniël Klabbers
22c599b283 only show queue commands if using another driver than sync 2020-02-04 22:59:04 +01:00
flarum-bot
456f5095da Bundled output for commit 1ba4a0b87e [skip ci] 2020-02-04 22:59:04 +01:00
Daniël Klabbers
0b3ce2e7d0 Fix existing Post component classes being dropped 2020-02-04 22:59:04 +01:00
flarum-bot
607eeb530d Bundled output for commit 1f2566c32c [skip ci] 2020-02-04 22:59:04 +01:00
Daniël Klabbers
5f4efe3c66 Improved naming of class for post by actor.
Made class list for post extensible by using a separate method.
2020-02-04 22:59:04 +01:00
flarum-bot
fac61b5bce Bundled output for commit 19ecd968c6 [skip ci] 2020-02-04 22:59:04 +01:00
Matthew Kilgore
ea64de5952 Removed LESS changes 2020-02-04 22:59:03 +01:00
Matthew Kilgore
ff6c407e53 Set border to left side only 2020-02-04 22:59:03 +01:00
Matthew Kilgore
c4ba13f608 Added border around post made by active user 2020-02-04 22:59:03 +01:00
flarum-bot
cef46ec357 Bundled output for commit 54c5c09693 [skip ci] 2020-02-04 22:59:03 +01:00
David Sevilla Martin
5788c7373e Cleanup some code and fix alert dismiss not working 2020-02-04 22:59:03 +01:00
Moritz Stueckler
8fdd8a1089 feat: re-add debug button/modal
Fixes #1687
2020-02-04 22:59:03 +01:00
David Sevilla Martin
dc31a0a076 Fix Modal width on <768px screens not occupying the whole page 2020-02-04 22:59:03 +01:00
flarum-bot
29eb233dd1 Bundled output for commit 937354512b [skip ci] 2020-02-04 22:59:03 +01:00
Daniël Klabbers
f6e48fa054 Update User.js
Use recommended `anonymous`, see https://developer.mozilla.org/en-US/docs/Web/API/HTMLImageElement/crossOrigin
2020-02-04 22:59:03 +01:00
J.C.Ködel
1b5e8f221a Fix Color Thief cross origin bug
When users have external avatar urls (for instance: in a SSO environment where the avatar is provided by another domain), color thief fails to get the avatar dominant color because the canvas would be tainted. 

Following the instructions here (https://lokeshdhakar.com/projects/color-thief/ on the "Does it work if the image is hosted on another domain?"), adding an `image.crossOrigin = 'Anonymous';` solves the issue.

Tested on my forum which before suffered from a JS error and works fine (without this fix, the canvas remain in the `body` while an script error is thrown by color thief)
2020-02-04 22:59:03 +01:00
Franz Liedke
f903487ef3 Revert search performance regression
We decided it is better to have a less intelligent search (that does not
match search terms in titles) for some people than a bad-performing
search for everyone.

We will revisit the search performance topic in the next release cycle,
possibly with larger changes around indexing.

Refs #1738, #1741, #1764.
2020-02-04 22:59:03 +01:00
Daniël Klabbers
1da4b72eac improve queue error handling 2020-02-04 22:59:03 +01:00
Daniël Klabbers
55bdad55fc added return type hint to memory cache 2020-02-04 22:59:03 +01:00
Daniël Klabbers
b603c7b336 add type hinting to settings repository 2020-02-04 22:59:03 +01:00
luceos
a530c52fb4 Apply fixes from StyleCI
[ci skip] [skip ci]
2020-02-04 22:59:03 +01:00
Daniël Klabbers
f7dc716042 added ability to re-use existing error handling stack 2020-02-04 22:59:03 +01:00
David Sevilla Martin
a72f87d7ee Alias 'flarum.queue.connection' to Queue contract 2020-02-04 22:59:03 +01:00
Tariq Hussein
5dbe97630c Fixes #1877 Replace getIdsForUsername() with subquery instead. (#1878) 2020-02-04 22:59:03 +01:00
flarum-bot
23709a77a2 Bundled output for commit bbd891965f [skip ci] 2020-02-04 22:59:03 +01:00
Madalin Tache
29a2c247a1 Update window size (#1894)
This small change attempts to fix #1727, as i just got my eye on it and figured i could simply fix it while seeing it.
2020-02-04 22:59:03 +01:00
flarum-bot
a7f97c14ec Bundled output for commit 7a684660e9 [skip ci] 2020-02-04 22:59:03 +01:00
David Sevilla Martín
8fcd62955b Enable scrollbars in login button popups (#1900)
Fixes #1716
2020-02-04 22:59:02 +01:00
Daniël Klabbers
618e91805f works towards #1789 by allowing event subscribing (#1810) 2020-02-04 22:59:02 +01:00
Franz Liedke
431ab9f3e8 Amend the existing rel attribute of links
...instead of overwriting. This will play more nicely with extensions.

Refs #859.
2020-02-04 22:59:02 +01:00
Franz Liedke
4f06133d75 Stop opening external links in new tabs
We accept that this may be desired by forum owners and will offer an
extension to enable this feature. By default, we will not make any
assumptions and simply adopt the web's and browsers' default behavior.

Fixes #859.
2020-02-04 22:59:02 +01:00
Franz Liedke
bf79f2474c Cleanup code from #1876
- Extract a method for email address generation
- Consistent types
- No docblocks for types where superfluous
- Tweak console output
- Don't inherit from integration test's base class in unit test
2020-02-04 22:59:02 +01:00
Stefan Totev
1b74e43cb9 Normalize Base URL during installation
- Fix base url when is appended with a script filename
- Add default base url http://flarum.local when CLI wizard used
- Remove some code duplication
- Add minor improvement to the UX when CLI wizard used
- Add tests
- Extract base url normalisation into its own value object
2020-02-04 22:59:02 +01:00
Matteo Contrini
3643e2010b Change rel for external links to nofollow ugc (#1884) 2020-02-04 22:59:02 +01:00
Daniël Klabbers
58299edc20 added author Daniel Klabbers 2020-02-04 22:59:02 +01:00
David Sevilla Martín
18774e0b10 Prepare beta.10 release (#1885)
* Update Application version string to beta 10
* Add beta.10 changelog
2020-02-04 22:59:02 +01:00
Franz Liedke
86d890d043 Restore beta.9 behavior of assertCan()
In flarum/core#1854, I changed the implementation of `assertCan()` to be
more aware of the user's log-in status. I came across this when unifying
our API's response status code when actors are not authenticated or not
authorized to do something.

@luceos rightfully had to tweak this again in ea84fc4, because the
behavior changed for one of the few API endpoints that checked for a
permission that even guests can have.

It turns out having this complex behavior in `assertCan()` is quite
misleading, because the name suggests a simple permission check and
nothing more.

Where we actually want to differ between HTTP 401 and 403, we can do
this using two method calls, and enforce it with our tests.

If this turns out to be problematic or extremely common, we can revisit
this and introduce a method with a different, better name in the future.

This commit restores the method's behavior in the last release, so we
also avoid another breaking change for extensions.
2020-02-04 22:59:02 +01:00
Franz Liedke
ab0ba707e7 Add a test for viewUserList guest permission
This test would have failed without commit ea84fc4. Next, I will revert
that commit and most of my PR #1854, so we need this test to ensure the
API continues to behave as desired.
2020-02-04 22:59:02 +01:00
Franz Liedke
04b2cf4462 Apply fixes from StyleCI
[ci skip] [skip ci]
2020-02-04 22:59:02 +01:00
Franz Liedke
28e3ec4014 Convert more controller tests to feature tests 2020-02-04 22:59:02 +01:00
Franz Liedke
a6decb2350 Update vulnerable JS dependencies 2020-02-04 22:59:02 +01:00
Franz Liedke
1e55361539 Send a HTTP 401 for incorrect login credentials
This fixes a regression from #1843 and #1854. Now, the frontend again
shows the proper "Incorrect login details" message instead of "You
do not have permission to do that".
2020-02-04 22:59:02 +01:00
Franz Liedke
e80f5429d0 Convert another controller test to feature test
Decouple from implementation, test closer to HTTP...
2020-02-04 22:59:02 +01:00
flarum-bot
108a23c1eb Bundled output for commit a9557c399a [skip ci] 2020-02-04 22:58:49 +01:00
David Sevilla Martín
1dd329982a Fix errors caused by deletion alert when deleting users (#1883)
Refs #1788

TypeError: t.showDeletionAlert is not a function
  at onSuccess(./src/forum/utils/UserControls.js:104:12)

Also, don't override 'this' param with user object for editAction
2020-02-04 22:58:49 +01:00
Daniël Klabbers
e0c2ef5e64 moved the artisan binary override and commented some of the bindings for queue 2020-02-04 22:58:49 +01:00
flarum-bot
d654517c91 Bundled output for commit 119831e51c [skip ci] 2020-02-04 22:58:49 +01:00
David Sevilla Martin
0232d949e9 Fixes an issue where deleting a nonexistent model would error instead of resolving gracefully 2020-02-04 22:58:49 +01:00
Daniël Klabbers
6363753d0f prevent constant to be duplicated during tests 2020-02-04 22:58:49 +01:00
luceos
0918b04fe2 Apply fixes from StyleCI
[ci skip] [skip ci]
2020-02-04 22:58:49 +01:00
Daniël Klabbers
929d7b87c1 Fixes an issue where permission checks aren't made for guest users,
due to the gate being accessed after the check whether the user
is registered/signed in.
2020-02-04 22:58:49 +01:00
Daniël Klabbers
544f687cf4 Fixes the queue listen command. We might need to rectify this implementation before stable. 2020-02-04 22:58:49 +01:00
Daniël Klabbers
a7ed625d16 Fixes an issue where a different cache driver is used and Formatter
attempts to load the s9e Renderer from the wrong cache. It has
to be saved locally so that it can be properly loaded using
the spl auto register functionality.
2020-02-04 22:58:49 +01:00
Franz Liedke
a67eca0c9e Fix instructions in PR template 2020-02-04 22:58:49 +01:00
flarum-bot
855dd2445a Bundled output for commit 24964b94bf [skip ci] 2020-02-04 22:58:49 +01:00
David Sevilla Martín
1a3d955b4f Mark notification as read without visiting discussion (#1874) 2020-02-04 22:58:48 +01:00
flarum-bot
8db91e3395 Bundled output for commit 2e647cdda8 [skip ci] 2020-02-04 22:58:48 +01:00
David Sevilla Martín
d725012a84 Fix error thrown if textarea doesn't exist in TextEditor (#1852)
* Prevent textarea not existing from causing errors to be thrown

* Replace [0] with .length
2020-02-04 22:58:48 +01:00
Daniël Klabbers
5a03cd865a listen and restart currently fail in the queue, see #1879 2020-02-04 22:58:48 +01:00
flarum-bot
0a32a96207 Bundled output for commit 8b3913339a [skip ci] 2020-02-04 22:58:48 +01:00
Matthew Kilgore
1587d48e59 Fix the new edit user permission label (#1870) 2020-02-04 22:58:48 +01:00
David Sevilla Martín
b750554011 Add DB prefix to PHP tests (#1855)
* Add test job with PHP 7.3, MySQL & custom prefix

* Add prefix MariaDB test

* Add PHP 7.4 to tests

* Remove PHP 7.4 from tests

This reverts commit 270cba2f5f.
2020-02-04 22:58:48 +01:00
David Sevilla Martín
db7e28d316 Add back defaults for language and direction attributes (#1860) 2020-02-04 22:58:48 +01:00
flarum-bot
14e89546ca Bundled output for commit 0191babb05 [skip ci] 2020-02-04 22:58:48 +01:00
Franz Liedke
92642519d4 Optimize ScrollListener performance
Listen to "scroll" event and throttle callback executions instead
of actively polling for changes to the scroll position.

Fixes #1222.
2020-02-04 22:58:48 +01:00
Franz Liedke
f779f4d092 Fix failing test 2020-02-04 22:58:48 +01:00
Franz Liedke
7b73036441 Debug mode: Include stacktrace in JSON-API errors
Refs #1843, #1865.
2020-02-04 22:58:48 +01:00
Franz Liedke
8b628be507 Refactor JSON-API error formatter 2020-02-04 22:58:48 +01:00
Franz Liedke
51f4bcdcb0 Apply fixes from StyleCI (#1867)
[ci skip] [skip ci]
2020-02-04 22:58:48 +01:00
Franz Liedke
47a528305b Restore error details in JSON-API error formatter
Fixes #1865. Refs #1843.
2020-02-04 22:58:48 +01:00
Franz Liedke
6121229c6f Convert controller test to request test
This further decouples these tests from the implementation (i.e. which
controller are we calling?).
2020-02-04 22:58:48 +01:00
Matteo Contrini
df7f1291a7 Allow formatting post content without a request (#1848) 2020-02-04 22:58:28 +01:00
Matthew Kilgore
52e73b2481 Add Edit User permission to permissions grid (#1859) 2020-02-04 22:58:28 +01:00
Franz Liedke
d08f851c0b When signups are prohibited, respond with HTTP 403 2020-02-04 22:58:28 +01:00
Franz Liedke
22b32bd601 Move authentication check into assertCan() method
This will cause the right error (HTTP 401) to be thrown whenever
we're checking for a specific permission, but the user is not even
logged in. Authenticated users will still get HTTP 403.
2020-02-04 22:58:28 +01:00
Franz Liedke
6797770c75 Remove unnecessary indirection 2020-02-04 22:58:28 +01:00
Franz Liedke
4cab48c0fd Document permission check methods 2020-02-04 22:58:28 +01:00
Franz Liedke
f7222d7e20 Fix inconsistent status codes
HTTP 401 should be used when logging in (i.e. authenticating) would make
a difference; HTTP 403 is reserved for requests that fail because the
already authenticated user is not authorized (i.e. lacking permissions)
to do something.
2020-02-04 22:58:28 +01:00
dependabot[bot]
53c728b184 Bump lodash from 4.17.11 to 4.17.15 in /js (#1863)
Bumps [lodash](https://github.com/lodash/lodash) from 4.17.11 to 4.17.15.
- [Release notes](https://github.com/lodash/lodash/releases)
- [Commits](https://github.com/lodash/lodash/compare/4.17.11...4.17.15)

Signed-off-by: dependabot[bot] <support@github.com>
2020-02-04 22:58:28 +01:00
dependabot[bot]
1d525d0a78 Bump mixin-deep from 1.3.1 to 1.3.2 in /js (#1862)
Bumps [mixin-deep](https://github.com/jonschlinkert/mixin-deep) from 1.3.1 to 1.3.2.
- [Release notes](https://github.com/jonschlinkert/mixin-deep/releases)
- [Commits](https://github.com/jonschlinkert/mixin-deep/compare/1.3.1...1.3.2)

Signed-off-by: dependabot[bot] <support@github.com>
2020-02-04 22:58:28 +01:00
Franz Liedke
301e571772 Remove unnecessary dependency
Refs #1773.
2020-02-04 22:58:28 +01:00
Franz Liedke
e7c12ce928 Remove superfluous ForbiddenException
It has the same effect as the PermissionDeniedException, so let's
just use that.

Refs #1641.
2020-02-04 22:58:28 +01:00
Franz Liedke
5d5ebc088e Travis: Remove deploy key 2020-02-04 22:55:25 +01:00
David Sevilla Martín
6e62240153 Move to GitHub Actions (#1853) 2020-02-04 22:55:25 +01:00
Franz Liedke
17d1942c5c Error handling: Document another interface 2020-02-04 22:55:25 +01:00
Franz Liedke
e786e297ef Rename method 2020-02-04 22:55:25 +01:00
Franz Liedke
2829618814 Error handling: Tweak Reporter interface
Because reporters are used for exceptions we were not able to handle, it
makes sense to simply pass the exception, not the "handled error".
2020-02-04 22:55:25 +01:00
Franz Liedke
5875b31fd5 Error handling: Document classes and interfaces 2020-02-04 22:55:25 +01:00
Franz Liedke
ae59bf549f Error handling: Rename renderers to formatters
Refs #1641.
2020-02-04 22:55:25 +01:00
Franz Liedke
d45bf04341 Remove obsolete queue config 2020-02-04 22:55:25 +01:00
Daniël Klabbers
7f9588af62 Queue support (#1773)
Implementation of clean queue handling, by default sync is used
2020-02-04 22:55:25 +01:00
Franz Liedke
17dfb58590 Don't fail when extend.php doesn't return an array
Refs #1607.
2020-02-04 22:55:25 +01:00
Franz Liedke
c5e3e26d07 #1607: Show more details when catching boot errors 2020-02-04 22:55:25 +01:00
Franz Liedke
5d768db6d2 Bubble up exception for invalid confirmation token
This way, the error handler can simply be amended to deal with this
exception type with a dedicated error message or page.

Refs #1337.
Closes #1528.
2020-02-04 22:55:25 +01:00
Franz Liedke
6e089c12d4 Determine error view and message based on type
...not based on status code.

To simplify this logic, we now use the same error "type" both when
routes are not found and specific models are not found. One exception is
ours, one is from Laravel, but for the purposes of error handling they
should be treated the same.

Fixes flarum/core#1641.
2020-02-04 22:55:25 +01:00
flarum-bot
5ddb843eb2 Bundled output for commit 29df6b60be [skip ci] 2020-02-04 22:55:25 +01:00
Franz Liedke
bbeacc0299 Tweak translation keys, always use full keys
Makes them easier to grep when editing / removing.

Refs #1750, #1788.
2020-02-04 22:55:25 +01:00
Franz Liedke
82480457ce Extract real method
Refs #1750, #1788.
2020-02-04 22:55:25 +01:00
flarum-bot
685459c0bc Bundled output for commit 37e0a5579b [skip ci] 2020-02-04 22:55:25 +01:00
Tobias Karlsson
347edcf2cd Improve feedback on user deletion
Fixes #1750, #1777
2020-02-04 22:55:25 +01:00
Franz Liedke
731a038f29 Support multiple error reporters
The error handling middleware now expects an array of reporters.
Extensions can register new reporters in the container like this:

    use Flarum\Foundation\ErrorHandling\Reporter;

    $container->tag(NewReporter::class, Reporter::class);

Note that this is just an implementation detail and will be hidden
behind an extender.
2020-02-04 22:55:25 +01:00
Franz Liedke
af5113eb7b Remove old error handler, middleware and tests 2020-02-04 22:55:25 +01:00
Franz Liedke
ddfb2c1ec1 API Client: Use new error handling mechanism 2020-02-04 22:37:25 +01:00
Franz Liedke
6cf3c1088d Use new error handler middleware 2020-02-04 22:37:24 +01:00
Franz Liedke
2f174edfd0 Wire up new error handling stack 2020-02-04 22:37:24 +01:00
Franz Liedke
2c231aa475 Make existing extensions compatible with new stack 2020-02-04 22:37:24 +01:00
Franz Liedke
1e5c7e54ee Implement new error handling stack
This separates the error registry (mapping exception types to status
codes) from actual handling (the middleware) as well as error formatting
(Whoops, pretty error pages or JSON-API?) and reporting (log? Sentry?).

The components can be reused in different places (e.g. the API client
and the error handler middleware both need the registry to understand
all the exceptions Flarum knows how to handle), while still allowing to
change only the parts that need to change (the API stack always uses the
JSON-API formatter, and the forum stack switches between Whoops and
pretty error pages based on debug mode).

Finally, this paves the way for some planned features and extensibility:
- A console error handler can build on top of the registry.
- Extensions can register new exceptions and how to handle them.
- Extensions can change how we report exceptions (e.g. Sentry).
- We can build more pretty error pages, even different ones for
  exceptions having the same status code.
2020-02-04 22:37:24 +01:00
Franz Liedke
408043a203 Remove obsolete constructor parameter
This was removed in commit 484c6d2e.
2020-02-04 22:37:24 +01:00
flarum-bot
9b449386d6 Bundled output for commit c5122bf5d5 [skip ci] 2020-02-04 22:37:24 +01:00
Franz Liedke
f1d9753aee a11y: Try to make screenreaders read tooltips
Refs #1835.
2020-02-04 22:37:24 +01:00
David Sevilla Martín
54f733ca80 Add canonical URL to discussion list (#1814) 2020-02-04 22:37:24 +01:00
Franz Liedke
a737b98e7f Bypass CSRF token check when using access tokens
Fixes #1828.
2020-02-04 22:37:24 +01:00
Franz Liedke
80546b9ed7 Make exception message dynamic as well 2020-02-04 22:37:24 +01:00
Franz Liedke
9758dfac47 Determine default route after extensions
Fixes #1819.
2020-02-04 22:37:24 +01:00
Franz Liedke
970c0f5604 PHPUnit: Get rid of deprecated annotation
Refs #1795.
2020-02-04 22:37:24 +01:00
Daniël Klabbers
42a7f2f586 Allows configuration of where the language files live. So that
language packs can optionally decide for themselves if they want
to use a different directory.
2020-02-04 22:37:24 +01:00
Daniël Klabbers
3611fa1bb9 fixes #1695, take into consideration is_private with counts on User stats 2020-02-04 22:37:24 +01:00
Daniël Klabbers
c881f9f633 fixed ci, make green again; mysql service wasnt booted 2020-02-04 22:37:24 +01:00
Franz Liedke
0a22a66189 Prevent MySQL search operators from taking effect
We do not want to inherit MySQL's fulltext query language, so let's
just drop all non-word characters from the search term.

Fixes #1498.
2020-02-04 22:37:24 +01:00
Franz Liedke
64b53fb0ac Revert "Remove deprecated bootstrap.php fallback"
This reverts commit f8061bbca1.

We will keep this fallback in place, to avoid unnecessary breakage of
backwards compatibility for extension authors.

Removal is planned for the final 0.1 release.
2020-02-04 22:37:24 +01:00
Franz Liedke
627724839d Clean up database query
- Use existing `selectRaw()` method to avoid using the global `app()`
  helper as a service locator, which hides dependencies.
- Do the same for the join.
- The `Expression` is necessary to prevent the aliased column from being
  prefixed with the database table prefix, if configured.
2020-02-04 22:37:24 +01:00
dependabot[bot]
fa3915fa53 Bump lodash-es from 4.17.11 to 4.17.14 in /js (#1818)
Bumps [lodash-es](https://github.com/lodash/lodash) from 4.17.11 to 4.17.14.
- [Release notes](https://github.com/lodash/lodash/releases)
- [Commits](https://github.com/lodash/lodash/compare/4.17.11...4.17.14)

Signed-off-by: dependabot[bot] <support@github.com>
2020-02-04 22:37:24 +01:00
luceos
1948b7e6f4 Apply fixes from StyleCI
[ci skip] [skip ci]
2020-02-04 21:11:08 +00:00
Daniël Klabbers
4465e7648b move deprecated methods to trait for cleaner code until we remove it 2020-02-04 22:10:39 +01:00
luceos
aef56c055a Apply fixes from StyleCI
[ci skip] [skip ci]
2019-11-19 11:38:56 +00:00
Daniël Klabbers
dd3a4173a1 noop instead of exception 2019-11-19 12:38:45 +01:00
Daniël Klabbers
7c204c82ab attempt to be more decisive on forcing the new user preferences 2019-11-13 14:35:42 +01:00
Daniël Klabbers
12fff33763 started refactoring the User class to the Notification Preference class 2019-10-28 10:27:38 +01:00
Daniël Klabbers
603367a41a added followAfterReply to core 2019-10-25 22:38:30 +02:00
luceos
6bdebfbf3c Apply fixes from StyleCI
[ci skip] [skip ci]
2019-10-25 20:33:57 +00:00
Daniël Klabbers
58ab6052ad reordered migrations 2019-09-28 21:02:45 +02:00
luceos
3737ce8146 Apply fixes from StyleCI
[ci skip] [skip ci]
2019-07-09 20:17:16 +00:00
Daniël Klabbers
ca5404db76 Merge branch '1236-user-preferences' of github.com:flarum/core into 1236-user-preferences 2019-07-09 22:17:00 +02:00
Daniël Klabbers
d6fc3a91a6 removed references to preferences column, now we need to refactor how notification ppreferences is integrated into the current app 2019-07-09 22:16:51 +02:00
luceos
31134ca16d Apply fixes from StyleCI
[ci skip] [skip ci]
2019-07-09 19:42:15 +00:00
Daniël Klabbers
6cfc9182f4 added the drop column statement for user.preferences and tested migrations 2019-07-09 21:41:57 +02:00
Daniël Klabbers
caa63107ad add migration to drop preferences column 2019-07-09 21:22:37 +02:00
Daniël Klabbers
0acab8f1c7 Merge branch 'master' into 1236-user-preferences 2019-07-09 21:19:41 +02:00
Daniël Klabbers
c19b2e99bd Merge branch 'master' into 1236-user-preferences 2019-03-08 22:07:54 +01:00
Daniël Klabbers
e7a5e1e5e2 Merge branch 'master' into 1236-user-preferences 2018-12-13 20:19:47 +01:00
Daniel Klabbers
209c4c6143 moved user preferences to new branch 2018-06-19 09:40:53 +02:00
648 changed files with 14371 additions and 27957 deletions

2
.gitattributes vendored
View File

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

View File

@@ -1,31 +0,0 @@
name: Lint
on:
push:
paths:
- 'js/src/**'
pull_request:
paths:
- 'js/src/**'
jobs:
prettier:
runs-on: ubuntu-latest
name: JS / Prettier
steps:
- uses: actions/checkout@master
- name: Setup Node.js
uses: actions/setup-node@v1
with:
node-version: "12"
- name: Install JS dependencies
run: npm ci
working-directory: ./js
- name: Check JS code for formatting
run: node_modules/.bin/prettier --check src
working-directory: ./js

View File

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

1
.gitignore vendored
View File

@@ -7,4 +7,3 @@ Thumbs.db
/tests/integration/tmp
.vagrant
.idea/*
.vscode

View File

@@ -1,251 +1,9 @@
# Changelog
## [0.1.0-beta.15](https://github.com/flarum/core/compare/v0.1.0-beta.14.1...v0.1.0-beta.15)
### Added
- Slug drivers support (https://github.com/flarum/core/pull/2456).
- Notification type extender (https://github.com/flarum/core/pull/2424).
- Validation extender (https://github.com/flarum/core/pull/2102).
- Post extender (https://github.com/flarum/core/pull/2101).
- Notification channel extender (https://github.com/flarum/core/pull/2432).
- Service provider extender (https://github.com/flarum/core/pull/2437).
- API serializer extender (https://github.com/flarum/core/pull/2438).
- User preferences extender (https://github.com/flarum/core/pull/2463).
- Settings extender (https://github.com/flarum/core/pull/2452).
- ApiController extender (https://github.com/flarum/core/pull/2451).
- Model visibility extender (https://github.com/flarum/core/pull/2460).
- Policy extender (https://github.com/flarum/core/pull/2461).
### Changed
- Time helpers converted to Typescript (https://github.com/flarum/core/pull/2391).
- Improved the formatter extender (https://github.com/flarum/core/pull/2098).
- Improve wording on installer when facing file permission issues (https://github.com/flarum/core/pull/2435).
- Background color of checkbox toggles improved for better usability (https://github.com/flarum/core/pull/2443).
- Route resolving refactored (https://github.com/flarum/core/pull/2425).
- Administration panel UX refactored (https://github.com/flarum/core/pull/2409).
- Floodgate moved to middleware and extender added (https://github.com/flarum/core/pull/2170).
- DRY up image uploading logic (https://github.com/flarum/core/pull/2477).
- Process isolation on testing (https://github.com/flarum/core/commit/984f751c718c89501cc09857bc271efa2c7eea8c).
- Forum and admin javascript exports namespaced (https://github.com/flarum/core/pull/2488).
### Fixed
- Web updater does not take into account subfolder installations (https://github.com/flarum/core/pull/2426).
- Callables handling in extenders failed (https://github.com/flarum/core/pull/2423).
- Scrolling on mobile from PostSteam changes didn't work correctly (https://github.com/flarum/core/pull/2385).
- Side pane covers part of the discussion page due to `app.discussions` being empty (https://github.com/flarum/core/commit/102e76b084bf47fdfb4c73f95e1fbb322537f7aa).
- Change email modal keeps showing the previous error message even on success (https://github.com/flarum/core/pull/2467).
- Comment count not updated when discussions are deleted (https://github.com/flarum/core/pull/2472).
- `goToIndex` in PostStream does not trigger an xhr to retrieve new data (https://github.com/flarum/core/commit/09e2736cbcc267594b660beabbd001d9030f9880).
- On refresh the post number is reduced by one (https://github.com/flarum/core/pull/2476).
- Queue worker would instantiate a new Queue factory, not the bound one (https://github.com/flarum/core/pull/2481).
- Header accidentally has a border bottom (https://github.com/flarum/core/pull/2489).
- Namespace mentioned in docblock is incorrect (https://github.com/flarum/core/pull/2494).
- Scrolling inside longer discussions (especially Firefox) skips posts (https://github.com/flarum/core/commit/210a6b3e253d7917bd1eacd3ed8d2f95073ae99d).
- Uploading avatars that are jpg/jpeg fails with a validation error (https://github.com/flarum/core/pull/2497).
### Removed
- MomentJS alias (https://github.com/flarum/core/pull/2428).
- Deprecated user events `GetDisplayName` and `PrepareUserGroups` (https://github.com/flarum/core/pull/2428).
- AssertPermissionTrait (https://github.com/flarum/core/pull/2428).
- Path related helpers and methods in Application (https://github.com/flarum/core/pull/2428).
- Backward compatibility layers from the frontend rewrite (https://github.com/flarum/core/pull/2428).
### Deprecated
- `CheckingForFlooding` (https://github.com/flarum/core/commit/8e25bcb68f86cc992c46dfa70368419fe9f936ac).
## [0.1.0-beta.14.1](https://github.com/flarum/core/compare/v0.1.0-beta.14...v0.1.0-beta.14.1)
### Fixed
- SuperTextarea component is not exported.
- Symfony dependencies do not match those depended on by Laravel (https://github.com/flarum/core/pull/2407).
- Scripts from textformatter aren't executed (https://github.com/flarum/core/pull/2415)
- Sub path installations have no page title.
- Losing focus of Composer area when coming from fullscreen.
## [0.1.0-beta.14](https://github.com/flarum/core/compare/v0.1.0-beta.13...v0.1.0-beta.14)
### Added
- Check dependencies before enabling / disabling extensions (https://github.com/flarum/core/pull/2188)
- Set up temporary infrastructure for TypeScript in core (https://github.com/flarum/core/pull/2206)
- Better UI for request error modals (https://github.com/flarum/core/pull/1929)
- Display name extender, tests, frontend UI (https://github.com/flarum/core/pull/2174)
- Scroll to post or show alert when editing a post from another page (https://github.com/flarum/core/pull/2108)
- Feature to test email config by sending an email to the current user (https://github.com/flarum/core/pull/2023)
- Allow searching users by group ID using the group gambit (https://github.com/flarum/core/pull/2192)
- Use `liveHumanTimes` helper to update times without reload/rerender (https://github.com/flarum/core/pull/2208)
- View extender, tests (https://github.com/flarum/core/pull/2134)
- User extender to replace `PrepareUserGroups` (https://github.com/flarum/core/pull/2110)
- Increase extensibility of skeleton PHP (https://github.com/flarum/core/pull/2308, https://github.com/flarum/core/pull/2318)
- Pass a translator instance to `getEmailSubject` in `MailableInterface` (https://github.com/flarum/core/pull/2244)
- Force LF line endings on windows (https://github.com/flarum/core/pull/2321)
- Add a `Link` component for internal and external links (https://github.com/flarum/core/pull/2315)
- `ConfirmDocumentUnload` component
- Error handler middleware can now be manipulated by the middleware extender
### Changed
- Update to Mithril 2 (https://github.com/flarum/core/pull/2255)
- Stop storing component instances (https://github.com/flarum/core/issues/1821, https://github.com/flarum/core/issues/2144)
- Update to Laravel 6.x (https://github.com/flarum/core/issues/2055)
- `Flarum\Foundation\Application` no longer implements `Illuminate\Contracts\Foundation\Application` (#2142)
- `Flarum\Foundation\Application` no longer inherits `Illuminate\Container\Container` (#2142)
- `paths` have been split off from `Flarum\Foundation\Application` into `Flarum\Foundation\Paths`, which can be injected where needed (#2142)
- `Flarum\User\Gate` no longer implements `Illuminate\Contracts\Auth\Access\Gate` (https://github.com/flarum/core/pull/2181)
- Improve Group Gambit performance (https://github.com/flarum/core/pull/2192)
- Switch to `dayjs` from `momentjs` (https://github.com/flarum/core/pull/2219)
- Don't create a `bio` column in `users` for new installations (https://github.com/flarum/core/pull/2215)
- Start converting core JS to TypeScript (https://github.com/flarum/core/pull/2207)
- Make Carbon an explicit dependency (https://github.com/flarum/core/commit/3b39c212e0fef7522e7d541a9214ff3817138d5d)
- Use Symfony's translator interface instead of Laravel's (https://github.com/flarum/core/pull/2243)
- Use newer versions of fontawesome (https://github.com/flarum/core/pull/2274)
- Use URL generator instead of `app()->url()` where possible (https://github.com/flarum/core/pull/2302)
- Move config from `config.php` into an injectable helper class (https://github.com/flarum/core/pull/2271)
- Use reserved TLD for bogus and test urls (https://github.com/flarum/core/commit/6860b24b70bd04544dde90e537ce021a5fc5a689)
- Replace `m.stream` with `flarum/utils/Stream` (https://github.com/flarum/core/pull/2316)
- Replace `affixedSidebar` util with `AffixedSidebar` component
- Replace `m.withAttr` with `flarum/utils/withAttr`
- Scroll Listener is now passive, performance improvement (https://github.com/flarum/core/pull/2387)
### Fixed
- `generate:migration` command for extensions (https://github.com/flarum/core/commit/443949f7b9d7558dbc1e0994cb898cbac59bec87)
- Container config for `UninstalledSite` (https://github.com/flarum/core/commit/ecdce44d555dd36a365fd472b2916e677ef173cf)
- Tooltip glitch on page chang (https://github.com/flarum/core/issues/2118)
- Using multiple extenders in tests (https://github.com/flarum/core/commit/c4f4f218bf4b175a30880b807f9ccb1a37a25330)
- Header glitch when opening modals (https://github.com/flarum/core/pull/2131)
- Ensure `SameSite` is explicitly set for cookies (https://github.com/flarum/core/pull/2159)
- Ensure `Flarum\User\Event\AvatarChanged` event is properly dispatched (https://github.com/flarum/core/pull/2197)
- Show correct error message on wrong password when changing email (https://github.com/flarum/core/pull/2171)
- Discussion unreadCount could be higher than commentCount if posts deleted (https://github.com/flarum/core/pull/2195)
- Don't show page title on the default route (https://github.com/flarum/core/pull/2047)
- Add page title to `All Discussions` page when it isn't the default route (https://github.com/flarum/core/pull/2047)
- Accept `'0'` as `false` for `flarum/components/Checkbox` (https://github.com/flarum/core/pull/2210)
- Fix PostStreamScrubber background (https://github.com/flarum/core/pull/2222)
- Test port on BaseUrl tests (https://github.com/flarum/core/pull/2226)
- `UrlGenerator` can now generate urls with optional parameters (https://github.com/flarum/core/pull/2246)
- Allow `less` to be compiled independently of Flarum (https://github.com/flarum/core/pull/2252)
- Use correct number abbreviation (https://github.com/flarum/core/pull/2261)
- Ensure avatar html uses alt tags for accessibility (https://github.com/flarum/core/pull/2269)
- Escape regex when searching (https://github.com/flarum/core/pull/2273)
- Remove unneeded semicolons inserted during JS compilation (https://github.com/flarum/core/pull/2280)
- Don't require a username/password for SMTP (https://github.com/flarum/core/pull/2287)
- Allow uppercase entries for SMTP encryption validation (https://github.com/flarum/core/pull/2289)
- Ensure that the right number of posts is returned from list posts API (https://github.com/flarum/core/pull/2291)
- Fix a variety of PostStream bugs (https://github.com/flarum/core/pull/2160, https://github.com/flarum/core/pull/2160)
- Sliding discussion glitch on mobile (https://github.com/flarum/core/pull/2324)
- Sliding discussion button in wrong place (https://github.com/flarum/core/pull/2330, https://github.com/flarum/core/pull/2383)
- Sliding discussion glitch on mobile (https://github.com/flarum/core/pull/2381)
- Fix PostStream for posts with top margins, and scrubber position when scrolling below posts (https://github.com/flarum/core/pull/2369)
### Removed
- `Flarum\Event\AbstractConfigureRoutes` event class
- `Flarum\Event\ConfigureApiRoutes` event class
- `Flarum\Event\ConfigureForumRoutes` event class
- `Flarum\Console\Event\Configuring` event class
- `Flarum\Event\ConfigureModelDates` event class
- `Flarum\Event\ConfigureLocales` event class
- `Flarum\Event\ConfigureModelDefaultAttributes` event class
- `Flarum\Event\GetModelRelationship` event class
- `Flarum\User\Event\BioChanged` event class
- `Flarum\Database\MigrationServiceProvider` moved into `Flarum\Database\DatabaseServiceProvider`
- Unused `admin/components/Widget` component (`admin/component/DashboardWidget` should be used instead)
- Mandrill mail driver (https://github.com/flarum/core/commit/bca833d3f1c34d45d95bf905902368a2753b8908)
### Deprecated
- `Flarum\User\Event\GetDisplayName` event class
- Global path helpers, `Flarum\Foundation\Application` path methods (https://github.com/flarum/core/pull/2155)
- `Flarum\User\AssertPermissionTrait` (https://github.com/flarum/core/pull/2044)
## [0.1.0-beta.13](https://github.com/flarum/core/compare/v0.1.0-beta.12...v0.1.0-beta.13)
### Added
- 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
- Full support for PHP 7.4 (#1980)
- Mail settings: Configure region for the Mailgun driver (#1834, #1850)
- Mail settings: Alert admins about incomplete settings (#1763, #1921)
- New permission that allows users to post without throttling (#1255, #1938)
- Basic transliteration of discussion "slugs" / pretty URLs (#194, #1975)
- User profiles: Render basic content on server side (#1901)
- New extender for configuring middleware (#1919, #1952, #1957, #1971)
- New extender for configuring error handling (#1781, #1970)
- Automated tests for PHP extenders to guarantee their backwards compatibility
### Changed
- Profile URLs for non-existing users properly return HTTP 404 (#1846, #1901)
- Confirmation email subject no longer contains the forum title (#1613)
- Improved error handling during Flarum's early boot phase (#1607)
- Updated deprecated "Zend" libraries to their new "Laminas" equivalents (#1963)
### Fixed
- Update page did not work when installed in subdirectories (#1947)
- Avatar upload did not work in IE11 / Edge (#1125, #1570)
- Translation fallback was ignored for client-rendered pages (#1774, #1961)
- The success alert when posting replies was invisible (#1976)
## [0.1.0-beta.11.1](https://github.com/flarum/core/compare/v0.1.0-beta.11...v0.1.0-beta.11.1)
### Fixed
- Saving custom css in admin failed (#1946)
## [0.1.0-beta.11](https://github.com/flarum/core/compare/v0.1.0-beta.10...v0.1.0-beta.11)

View File

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

View File

@@ -6,9 +6,16 @@
"license": "MIT",
"authors": [
{
"name": "Flarum",
"email": "info@flarum.org",
"homepage": "https://flarum.org/team"
"name": "Toby Zerner",
"email": "toby.zerner@gmail.com"
},
{
"name": "Franz Liedke",
"email": "franz@develophp.org"
},
{
"name": "Daniel Klabbers",
"email": "daniel@klabbers.email"
}
],
"support": {
@@ -17,55 +24,54 @@
"docs": "https://flarum.org/docs/"
},
"require": {
"php": ">=7.2",
"php": ">=7.1",
"axy/sourcemap": "^0.1.4",
"components/font-awesome": "^5.14.0",
"dflydev/fig-cookies": "^3.0.0",
"components/font-awesome": "5.9.*",
"dflydev/fig-cookies": "^1.0.2",
"doctrine/dbal": "^2.7",
"franzl/whoops-middleware": "^2.0.0",
"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": "^2.4.1",
"laminas/laminas-httphandlerrunner": "^1.2.0",
"laminas/laminas-stratigility": "^3.2.2",
"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.*",
"intervention/image": "^2.3.0",
"laminas/laminas-diactoros": "^1.8.4",
"laminas/laminas-httphandlerrunner": "^1.0",
"laminas/laminas-stratigility": "^3.0",
"laminas/laminas-zendframework-bridge": "^1.0",
"league/flysystem": "^1.0.11",
"matthiasmullie/minify": "^1.3",
"middlewares/base-path": "^2.0.1",
"middlewares/base-path-router": "^2.0.1",
"middlewares/request-handler": "^2.0.1",
"middlewares/base-path": "^1.1",
"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",
"oyejorge/less.php": "^1.7",
"psr/http-message": "^1.0",
"psr/http-server-handler": "^1.0",
"psr/http-server-middleware": "^1.0",
"s9e/text-formatter": "^2.3.6",
"symfony/config": "^4.3.4",
"symfony/console": "^4.3.4",
"symfony/event-dispatcher": "^4.3.4",
"symfony/mime": "^5.2.0",
"symfony/translation": "^4.3.4",
"symfony/yaml": "^4.3.4",
"tobscure/json-api": "^0.3.0",
"wikimedia/less.php": "^3.0"
"s9e/text-formatter": "^1.2.0",
"symfony/config": "^3.3",
"symfony/console": "^4.2",
"symfony/event-dispatcher": "^4.3.2",
"symfony/translation": "^3.3",
"symfony/yaml": "^3.3",
"tobscure/json-api": "^0.3.0"
},
"require-dev": {
"mockery/mockery": "^1.3.3",
"phpunit/phpunit": "^8.0"
"mockery/mockery": "^1.0",
"phpunit/phpunit": "^7.0"
},
"autoload": {
"psr-4": {

View File

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

14
js/dist/admin.js vendored

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

16
js/dist/forum.js vendored

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

3981
js/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -2,38 +2,25 @@
"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.5.1",
"jquery": "^3.4.1",
"jquery.hotkeys": "^0.1.0",
"lodash-es": "^4.17.14",
"mithril": "^2.0.4",
"m.attrs.bidi": "github:tobscure/m.attrs.bidi",
"mithril": "^0.2.8",
"moment": "^2.22.2",
"punycode": "^2.1.1",
"spin.js": "^3.1.0",
"textarea-caret": "^3.1.0",
"webpack": "^4.43.0",
"webpack-cli": "^3.3.11",
"webpack": "^4.41.2",
"webpack-cli": "^3.1.2",
"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",
"format-check": "prettier --check src"
},
"husky": {
"hooks": {
"pre-commit": "npm run format"
}
"build": "webpack --mode production"
}
}

32
js/shims.d.ts vendored
View File

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

View File

@@ -4,24 +4,17 @@ import routes from './routes';
import Application from '../common/Application';
import Navigation from '../common/components/Navigation';
import AdminNav from './components/AdminNav';
import ExtensionData from './utils/ExtensionData';
export default class AdminApplication extends Application {
extensionData = new ExtensionData();
extensionCategories = {
feature: 30,
theme: 20,
language: 10,
};
extensionSettings = {};
history = {
canGoBack: () => true,
getPrevious: () => {},
backUrl: () => this.forum.attribute('baseUrl'),
back: function () {
back: function() {
window.location = this.backUrl();
},
}
};
constructor() {
@@ -34,24 +27,22 @@ export default class AdminApplication extends Application {
* @inheritdoc
*/
mount() {
// Mithril does not render the home route on https://example.com/admin, so
// we need to go to https://example.com/admin#/ explicitly.
if (!document.location.hash) document.location.hash = '#/';
m.mount(document.getElementById('app-navigation'), Navigation.component({className: 'App-backControl', drawer: true}));
m.mount(document.getElementById('header-navigation'), Navigation.component());
m.mount(document.getElementById('header-primary'), HeaderPrimary.component());
m.mount(document.getElementById('header-secondary'), HeaderSecondary.component());
m.mount(document.getElementById('admin-navigation'), AdminNav.component());
m.route.prefix = '#';
m.route.mode = 'hash';
super.mount();
m.mount(document.getElementById('app-navigation'), {
view: () =>
Navigation.component({
className: 'App-backControl',
drawer: true,
}),
});
m.mount(document.getElementById('header-navigation'), Navigation);
m.mount(document.getElementById('header-primary'), HeaderPrimary);
m.mount(document.getElementById('header-secondary'), HeaderSecondary);
m.mount(document.getElementById('admin-navigation'), AdminNav);
// If an extension has just been enabled, then we will run its settings
// callback.
const enabled = localStorage.getItem('enabledExtension');
if (enabled && this.extensionSettings[enabled]) {
this.extensionSettings[enabled]();
localStorage.removeItem('enabledExtension');
}
}
getRequiredPermissions(permission) {
@@ -68,5 +59,5 @@ export default class AdminApplication extends Application {
}
return required;
}
};
}

View File

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

View File

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

View File

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

View File

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

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

View File

@@ -1,120 +1,132 @@
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 AdminPage from './AdminPage';
import saveSettings from '../utils/saveSettings';
export default class AppearancePage extends AdminPage {
headerInfo() {
return {
className: 'AppearancePage',
icon: 'fas fa-paint-brush',
title: app.translator.trans('core.admin.appearance.title'),
description: app.translator.trans('core.admin.appearance.description'),
};
export default class AppearancePage extends Page {
init() {
super.init();
this.primaryColor = m.prop(app.data.settings.theme_primary_color);
this.secondaryColor = m.prop(app.data.settings.theme_secondary_color);
this.darkMode = m.prop(app.data.settings.theme_dark_mode === '1');
this.coloredHeader = m.prop(app.data.settings.theme_colored_header === '1');
}
content() {
return [
<div className="Form">
<fieldset className="AppearancePage-colors">
<legend>{app.translator.trans('core.admin.appearance.colors_heading')}</legend>
<div className="helpText">{app.translator.trans('core.admin.appearance.colors_text')}</div>
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">
{this.buildSettingComponent({
type: 'text',
setting: 'theme_primary_color',
placeholder: '#aaaaaa',
<div className="AppearancePage-colors-input">
<input className="FormControl" type="text" placeholder="#aaaaaa" value={this.primaryColor()} onchange={m.withAttr('value', this.primaryColor)}/>
<input className="FormControl" type="text" placeholder="#aaaaaa" value={this.secondaryColor()} onchange={m.withAttr('value', this.secondaryColor)}/>
</div>
{Switch.component({
state: this.darkMode(),
children: app.translator.trans('core.admin.appearance.dark_mode_label'),
onchange: this.darkMode
})}
{Switch.component({
state: this.coloredHeader(),
children: app.translator.trans('core.admin.appearance.colored_header_label'),
onchange: this.coloredHeader
})}
{Button.component({
className: 'Button Button--primary',
type: 'submit',
children: app.translator.trans('core.admin.appearance.submit_button'),
loading: this.loading
})}
</fieldset>
</form>
<fieldset>
<legend>{app.translator.trans('core.admin.appearance.logo_heading')}</legend>
<div className="helpText">
{app.translator.trans('core.admin.appearance.logo_text')}
</div>
<UploadImageButton name="logo"/>
</fieldset>
<fieldset>
<legend>{app.translator.trans('core.admin.appearance.favicon_heading')}</legend>
<div className="helpText">
{app.translator.trans('core.admin.appearance.favicon_text')}
</div>
<UploadImageButton name="favicon"/>
</fieldset>
<fieldset>
<legend>{app.translator.trans('core.admin.appearance.custom_header_heading')}</legend>
<div className="helpText">
{app.translator.trans('core.admin.appearance.custom_header_text')}
</div>
{Button.component({
className: 'Button',
children: app.translator.trans('core.admin.appearance.edit_header_button'),
onclick: () => app.modal.show(new EditCustomHeaderModal())
})}
{this.buildSettingComponent({
type: 'text',
setting: 'theme_secondary_color',
placeholder: '#aaaaaa',
</fieldset>
<fieldset>
<legend>{app.translator.trans('core.admin.appearance.custom_footer_heading')}</legend>
<div className="helpText">
{app.translator.trans('core.admin.appearance.custom_footer_text')}
</div>
{Button.component({
className: 'Button',
children: app.translator.trans('core.admin.appearance.edit_footer_button'),
onclick: () => app.modal.show(new EditCustomFooterModal())
})}
</div>
</fieldset>
{this.buildSettingComponent({
type: 'switch',
setting: 'theme_dark_mode',
label: app.translator.trans('core.admin.appearance.dark_mode_label'),
})}
{this.buildSettingComponent({
type: 'switch',
setting: 'theme_colored_header',
label: app.translator.trans('core.admin.appearance.colored_header_label'),
})}
{this.submitButton()}
</fieldset>
</div>,
<fieldset>
<legend>{app.translator.trans('core.admin.appearance.logo_heading')}</legend>
<div className="helpText">{app.translator.trans('core.admin.appearance.logo_text')}</div>
<UploadImageButton name="logo" />
</fieldset>,
<fieldset>
<legend>{app.translator.trans('core.admin.appearance.favicon_heading')}</legend>
<div className="helpText">{app.translator.trans('core.admin.appearance.favicon_text')}</div>
<UploadImageButton name="favicon" />
</fieldset>,
<fieldset>
<legend>{app.translator.trans('core.admin.appearance.custom_header_heading')}</legend>
<div className="helpText">{app.translator.trans('core.admin.appearance.custom_header_text')}</div>
{Button.component(
{
className: 'Button',
onclick: () => app.modal.show(EditCustomHeaderModal),
},
app.translator.trans('core.admin.appearance.edit_header_button')
)}
</fieldset>,
<fieldset>
<legend>{app.translator.trans('core.admin.appearance.custom_footer_heading')}</legend>
<div className="helpText">{app.translator.trans('core.admin.appearance.custom_footer_text')}</div>
{Button.component(
{
className: 'Button',
onclick: () => app.modal.show(EditCustomFooterModal),
},
app.translator.trans('core.admin.appearance.edit_footer_button')
)}
</fieldset>,
<fieldset>
<legend>{app.translator.trans('core.admin.appearance.custom_styles_heading')}</legend>
<div className="helpText">{app.translator.trans('core.admin.appearance.custom_styles_text')}</div>
{Button.component(
{
className: 'Button',
onclick: () => app.modal.show(EditCustomCssModal),
},
app.translator.trans('core.admin.appearance.edit_css_button')
)}
</fieldset>,
];
<fieldset>
<legend>{app.translator.trans('core.admin.appearance.custom_styles_heading')}</legend>
<div className="helpText">
{app.translator.trans('core.admin.appearance.custom_styles_text')}
</div>
{Button.component({
className: 'Button',
children: app.translator.trans('core.admin.appearance.edit_css_button'),
onclick: () => app.modal.show(new EditCustomCssModal())
})}
</fieldset>
</div>
</div>
);
}
onsaved() {
window.location.reload();
}
saveSettings(e) {
onsubmit(e) {
e.preventDefault();
const hex = /^#[0-9a-f]{3}([0-9a-f]{3})?$/i;
if (!hex.test(this.settings['theme_primary_color']()) || !hex.test(this.settings['theme_secondary_color']())) {
if (!hex.test(this.primaryColor()) || !hex.test(this.secondaryColor())) {
alert(app.translator.trans('core.admin.appearance.enter_hex_message'));
return;
}
super.saveSettings(e);
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,10 +1,31 @@
import Page from './Page';
import FieldSet from '../../common/components/FieldSet';
import Select from '../../common/components/Select';
import Button from '../../common/components/Button';
import Alert from '../../common/components/Alert';
import saveSettings from '../utils/saveSettings';
import ItemList from '../../common/utils/ItemList';
import AdminPage from './AdminPage';
import Switch from '../../common/components/Switch';
export default class BasicsPage extends AdminPage {
oninit(vnode) {
super.oninit(vnode);
export default class BasicsPage extends Page {
init() {
super.init();
this.loading = false;
this.fields = [
'forum_title',
'forum_description',
'default_locale',
'show_language_selector',
'default_route',
'welcome_title',
'welcome_message'
];
this.values = {};
const settings = app.data.settings;
this.fields.forEach(key => this.values[key] = m.prop(settings[key]));
this.localeOptions = {};
const locales = app.data.locales;
@@ -12,107 +33,94 @@ export default class BasicsPage extends AdminPage {
this.localeOptions[i] = `${locales[i]} (${i})`;
}
this.displayNameOptions = {};
const displayNameDrivers = app.data.displayNameDrivers;
displayNameDrivers.forEach(function (identifier) {
this.displayNameOptions[identifier] = identifier;
}, this);
this.slugDriverOptions = {};
Object.keys(app.data.slugDrivers).forEach((model) => {
this.slugDriverOptions[model] = {};
app.data.slugDrivers[model].forEach((option) => {
this.slugDriverOptions[model][option] = option;
});
});
if (typeof this.values.show_language_selector() !== "number") this.values.show_language_selector(1);
}
headerInfo() {
return {
className: 'BasicsPage',
icon: 'fas fa-pencil-alt',
title: app.translator.trans('core.admin.basics.title'),
description: app.translator.trans('core.admin.basics.description'),
};
}
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)}/>
]
})}
content() {
return [
<div className="Form">
{this.buildSettingComponent({
type: 'text',
setting: 'forum_title',
label: app.translator.trans('core.admin.basics.forum_title_heading'),
})}
{this.buildSettingComponent({
type: 'text',
setting: 'forum_description',
label: app.translator.trans('core.admin.basics.forum_description_heading'),
help: app.translator.trans('core.admin.basics.forum_description_text'),
})}
{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
? [
this.buildSettingComponent({
type: 'select',
setting: 'default_locale',
options: this.localeOptions,
{Object.keys(this.localeOptions).length > 1
? FieldSet.component({
label: app.translator.trans('core.admin.basics.default_language_heading'),
}),
this.buildSettingComponent({
type: 'switch',
setting: 'show_language_selector',
label: app.translator.trans('core.admin.basics.show_language_selector_label'),
}),
]
: ''}
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 className="BasicsPage-homePage Form-group" label={app.translator.trans('core.admin.basics.home_page_heading')}>
<div className="helpText">{app.translator.trans('core.admin.basics.home_page_text')}</div>
{this.homePageItems()
.toArray()
.map(({ path, label }) => (
<label className="checkbox">
<input type="radio" name="homePage" value={path} bidi={this.setting('default_route')} />
{label}
</label>
))}
</FieldSet>
{FieldSet.component({
label: app.translator.trans('core.admin.basics.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>
)
]
})}
<div className="Form-group BasicsPage-welcomeBanner-input">
<label>{app.translator.trans('core.admin.basics.welcome_banner_heading')}</label>
<div className="helpText">{app.translator.trans('core.admin.basics.welcome_banner_text')}</div>
<input type="text" className="FormControl" bidi={this.setting('welcome_title')} />
<textarea className="FormControl" bidi={this.setting('welcome_message')} />
{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>
);
}
{Object.keys(this.displayNameOptions).length > 1
? this.buildSettingComponent({
type: 'select',
setting: 'display_name_driver',
options: this.displayNameOptions,
label: app.translator.trans('core.admin.basics.display_name_heading'),
help: app.translator.trans('core.admin.basics.display_name_text'),
})
: ''}
{Object.keys(this.slugDriverOptions).map((model) => {
const options = this.slugDriverOptions[model];
if (Object.keys(options).length > 1) {
return this.buildSettingComponent({
type: 'select',
setting: `slug_driver_${model}`,
options,
label: app.translator.trans('core.admin.basics.slug_driver_heading', { model }),
help: app.translator.trans('core.admin.basics.slug_driver_text', { model }),
});
}
})}
{this.submitButton()}
</div>,
];
changed() {
return this.fields.some(key => this.values[key]() !== app.data.settings[key]);
}
/**
@@ -127,9 +135,32 @@ export default class BasicsPage extends AdminPage {
items.add('allDiscussions', {
path: '/all',
label: app.translator.trans('core.admin.basics.all_discussions_label'),
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(() => {
app.alerts.show(this.successAlert = new Alert({type: 'success', children: app.translator.trans('core.admin.basics.saved_message')}));
})
.catch(() => {})
.then(() => {
this.loading = false;
m.redraw();
});
}
}

View File

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

View File

@@ -1,8 +1,21 @@
/*
* 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';
export default class DashboardWidget extends Component {
export default class Widget extends Component {
view() {
return <div className={'DashboardWidget Widget ' + this.className()}>{this.content()}</div>;
return (
<div className={"Widget "+this.className()}>
{this.content()}
</div>
);
}
/**

View File

@@ -11,14 +11,10 @@ export default class EditCustomCssModal extends SettingsModal {
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>,
<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>,
<textarea className="FormControl" rows="30" bidi={this.setting('custom_less')}/>
</div>
];
}

View File

@@ -13,8 +13,8 @@ export default class EditCustomFooterModal extends SettingsModal {
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>,
<textarea className="FormControl" rows="30" bidi={this.setting('custom_footer')}/>
</div>
];
}

View File

@@ -13,8 +13,8 @@ export default class EditCustomHeaderModal extends SettingsModal {
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>,
<textarea className="FormControl" rows="30" bidi={this.setting('custom_header')}/>
</div>
];
}

View File

@@ -3,24 +3,19 @@ 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 Stream from '../../common/utils/Stream';
/**
* The `EditGroupModal` component shows a modal dialog which allows the user
* to create or edit a group.
*/
export default class EditGroupModal extends Modal {
oninit(vnode) {
super.oninit(vnode);
init() {
this.group = this.props.group || app.store.createRecord('groups');
this.group = this.attrs.group || app.store.createRecord('groups');
this.nameSingular = Stream(this.group.nameSingular() || '');
this.namePlural = Stream(this.group.namePlural() || '');
this.icon = Stream(this.group.icon() || '');
this.color = Stream(this.group.color() || '');
this.isHidden = Stream(this.group.isHidden() || false);
this.nameSingular = m.prop(this.group.nameSingular() || '');
this.namePlural = m.prop(this.group.namePlural() || '');
this.icon = m.prop(this.group.icon() || '');
this.color = m.prop(this.group.color() || '');
}
className() {
@@ -29,21 +24,21 @@ export default class EditGroupModal extends Modal {
title() {
return [
this.color() || this.icon()
? Badge.component({
icon: this.icon(),
style: { backgroundColor: this.color() },
})
: '',
this.color() || this.icon() ? Badge.component({
icon: this.icon(),
style: {backgroundColor: this.color()}
}) : '',
' ',
this.namePlural() || app.translator.trans('core.admin.edit_group.title'),
this.namePlural() || app.translator.trans('core.admin.edit_group.title')
];
}
content() {
return (
<div className="Modal-body">
<div className="Form">{this.fields().toArray()}</div>
<div className="Form">
{this.fields().toArray()}
</div>
</div>
);
}
@@ -51,74 +46,40 @@ export default class EditGroupModal extends Modal {
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('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')} value={this.nameSingular()} oninput={m.withAttr('value', this.nameSingular)}/>
<input className="FormControl" placeholder={app.translator.trans('core.admin.edit_group.plural_placeholder')} value={this.namePlural()} oninput={m.withAttr('value', this.namePlural)}/>
</div>
</div>, 30);
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('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" bidi={this.icon} />
</div>,
10
);
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(
'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
);
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;
}
@@ -128,8 +89,7 @@ export default class EditGroupModal extends Modal {
nameSingular: this.nameSingular(),
namePlural: this.namePlural(),
color: this.color(),
icon: this.icon(),
isHidden: this.isHidden(),
icon: this.icon()
};
}
@@ -138,8 +98,7 @@ export default class EditGroupModal extends Modal {
this.loading = true;
this.group
.save(this.submitData(), { errorHandler: this.onerror.bind(this) })
this.group.save(this.submitData(), {errorHandler: this.onerror.bind(this)})
.then(this.hide.bind(this))
.catch(() => {
this.loading = false;

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -8,7 +8,11 @@ import listItems from '../../common/helpers/listItems';
*/
export default class HeaderPrimary extends Component {
view() {
return <ul className="Header-controls">{listItems(this.items().toArray())}</ul>;
return (
<ul className="Header-controls">
{listItems(this.items().toArray())}
</ul>
);
}
config(isInitialized, context) {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -6,11 +6,19 @@ import ItemList from '../../common/utils/ItemList';
import icon from '../../common/helpers/icon';
export default class PermissionGrid extends Component {
init() {
this.permissions = this.permissionItems().toArray();
}
view() {
const scopes = this.scopeItems().toArray();
const permissionCells = (permission) => {
return scopes.map((scope) => <td>{scope.render(permission)}</td>);
const permissionCells = permission => {
return scopes.map(scope => (
<td>
{scope.render(permission)}
</td>
));
};
return (
@@ -18,38 +26,31 @@ export default class PermissionGrid extends Component {
<thead>
<tr>
<td></td>
{scopes.map((scope) => (
{scopes.map(scope => (
<th>
{scope.label}{' '}
{scope.onremove
? Button.component({ icon: 'fas fa-times', className: 'Button Button--text PermissionGrid-removeScope', onclick: scope.onremove })
: ''}
{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 />
{this.permissions.map(section => (
<tbody>
<tr className="PermissionGrid-section">
<th>{section.label}</th>
{permissionCells(section)}
<td/>
</tr>
{section.children.map(child => (
<tr className="PermissionGrid-child">
<th>{icon(child.icon)}{child.label}</th>
{permissionCells(child)}
<td/>
</tr>
{section.children.map((child) => (
<tr className="PermissionGrid-child">
<th>
{icon(child.icon)}
{child.label}
</th>
{permissionCells(child)}
<td />
</tr>
))}
</tbody>
))}
))}
</tbody>
))}
</table>
);
}
@@ -57,41 +58,25 @@ export default class PermissionGrid extends Component {
permissionItems() {
const items = new ItemList();
items.add(
'view',
{
label: app.translator.trans('core.admin.permissions.read_heading'),
children: this.viewItems().toArray(),
},
100
);
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('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('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
);
items.add('moderate', {
label: app.translator.trans('core.admin.permissions.moderate_heading'),
children: this.moderateItems().toArray()
}, 70);
return items;
}
@@ -99,54 +84,31 @@ export default class PermissionGrid extends Component {
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('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(
'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('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',
@@ -154,49 +116,37 @@ export default class PermissionGrid extends Component {
permission: 'user.viewLastSeenAt',
});
items.merge(app.extensionData.getAllExtensionPermissions('view'));
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('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);
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
);
items.merge(app.extensionData.getAllExtensionPermissions('start'));
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;
}
@@ -204,41 +154,31 @@ export default class PermissionGrid extends Component {
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('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);
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
);
items.merge(app.extensionData.getAllExtensionPermissions('reply'));
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;
}
@@ -246,143 +186,75 @@ export default class PermissionGrid extends Component {
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('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('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('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('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(
'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(
'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(
'userEditCredentials',
{
icon: 'fas fa-user-cog',
label: app.translator.trans('core.admin.permissions.edit_users_credentials_label'),
permission: 'user.editCredentials',
},
60
);
items.add(
'userEditGroups',
{
icon: 'fas fa-users-cog',
label: app.translator.trans('core.admin.permissions.edit_users_groups_label'),
permission: 'user.editGroups',
},
60
);
items.add(
'userEdit',
{
icon: 'fas fa-address-card',
label: app.translator.trans('core.admin.permissions.edit_users_label'),
permission: 'user.edit',
},
60
);
items.merge(app.extensionData.getAllExtensionPermissions('moderate'));
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,
});
}
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 '';
}
}, 100);
return items;
}

View File

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

View File

@@ -9,22 +9,27 @@ import ItemList from '../../common/utils/ItemList';
* avatar/name, with a dropdown of session controls.
*/
export default class SessionDropdown extends Dropdown {
static initAttrs(attrs) {
super.initAttrs(attrs);
static initProps(props) {
super.initProps(props);
attrs.className = 'SessionDropdown';
attrs.buttonClassName = 'Button Button--user Button--flat';
attrs.menuClassName = 'Dropdown-menu--right';
props.className = 'SessionDropdown';
props.buttonClassName = 'Button Button--user Button--flat';
props.menuClassName = 'Dropdown-menu--right';
}
view(vnode) {
return super.view({ ...vnode, children: this.items().toArray() });
view() {
this.props.children = this.items().toArray();
return super.view();
}
getButtonContent() {
const user = app.session.user;
return [avatar(user), ' ', <span className="Button-label">{username(user)}</span>];
return [
avatar(user), ' ',
<span className="Button-label">{username(user)}</span>
];
}
/**
@@ -35,15 +40,12 @@ export default class SessionDropdown extends Dropdown {
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')
),
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
);

View File

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

View File

@@ -1,12 +1,9 @@
import Modal from '../../common/components/Modal';
import Button from '../../common/components/Button';
import Stream from '../../common/utils/Stream';
import saveSettings from '../utils/saveSettings';
export default class SettingsModal extends Modal {
oninit(vnode) {
super.oninit(vnode);
init() {
this.settings = {};
this.loading = false;
}
@@ -21,7 +18,9 @@ export default class SettingsModal extends Modal {
<div className="Form">
{this.form()}
<div className="Form-group">{this.submitButton()}</div>
<div className="Form-group">
{this.submitButton()}
</div>
</div>
</div>
);
@@ -29,14 +28,18 @@ export default class SettingsModal extends Modal {
submitButton() {
return (
<Button type="submit" className="Button Button--primary" loading={this.loading} disabled={!this.changed()}>
<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] || Stream(app.data.settings[key] || fallback);
this.settings[key] = this.settings[key] || m.prop(app.data.settings[key] || fallback);
return this.settings[key];
}
@@ -44,7 +47,7 @@ export default class SettingsModal extends Modal {
dirty() {
const dirty = {};
Object.keys(this.settings).forEach((key) => {
Object.keys(this.settings).forEach(key => {
const value = this.settings[key]();
if (value !== app.data.settings[key]) {
@@ -64,7 +67,10 @@ export default class SettingsModal extends Modal {
this.loading = true;
saveSettings(this.dirty()).then(this.onsaved.bind(this), this.loaded.bind(this));
saveSettings(this.dirty()).then(
this.onsaved.bind(this),
this.loaded.bind(this)
);
}
onsaved() {

View File

@@ -20,39 +20,39 @@ export default class StatusWidget extends DashboardWidget {
}
content() {
return <ul>{listItems(this.items().toArray())}</ul>;
return (
<ul>{listItems(this.items().toArray())}</ul>
);
}
items() {
const items = new ItemList();
items.add(
'tools',
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>
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]);
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.modal.show(new LoadingModal());
app
.request({
method: 'DELETE',
url: app.forum.attribute('apiUrl') + '/cache',
})
.then(() => window.location.reload());
app.request({
method: 'DELETE',
url: app.forum.attribute('apiUrl') + '/cache'
}).then(() => window.location.reload());
}
}

View File

@@ -1,28 +1,30 @@
import Button from '../../common/components/Button';
export default class UploadImageButton extends Button {
loading = false;
init() {
this.loading = false;
}
view(vnode) {
this.attrs.loading = this.loading;
this.attrs.className = (this.attrs.className || '') + ' Button';
view() {
this.props.loading = this.loading;
this.props.className = (this.props.className || '') + ' Button';
if (app.data.settings[this.attrs.name + '_path']) {
this.attrs.onclick = this.remove.bind(this);
if (app.data.settings[this.props.name + '_path']) {
this.props.onclick = this.remove.bind(this);
this.props.children = app.translator.trans('core.admin.upload_image.remove_button');
return (
<div>
<p>
<img src={app.forum.attribute(this.attrs.name + 'Url')} alt="" />
</p>
<p>{super.view({ ...vnode, children: app.translator.trans('core.admin.upload_image.remove_button') })}</p>
<p><img src={app.forum.attribute(this.props.name+'Url')} alt=""/></p>
<p>{super.view()}</p>
</div>
);
} else {
this.attrs.onclick = this.upload.bind(this);
this.props.onclick = this.upload.bind(this);
this.props.children = app.translator.trans('core.admin.upload_image.upload_button');
}
return super.view({ ...vnode, children: app.translator.trans('core.admin.upload_image.upload_button') });
return super.view();
}
/**
@@ -33,26 +35,23 @@ export default class UploadImageButton extends Button {
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]);
$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();
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));
});
app.request({
method: 'POST',
url: this.resourceUrl(),
serialize: raw => raw,
data
}).then(
this.success.bind(this),
this.failure.bind(this)
);
});
}
/**
@@ -62,16 +61,17 @@ export default class UploadImageButton extends Button {
this.loading = true;
m.redraw();
app
.request({
method: 'DELETE',
url: this.resourceUrl(),
})
.then(this.success.bind(this), this.failure.bind(this));
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;
return app.forum.attribute('apiUrl') + '/' + this.props.name;
}
/**

View File

@@ -0,0 +1,38 @@
/*
* 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';
export default 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}
*/
content() {
return [];
}
}

View File

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

View File

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

View File

@@ -2,22 +2,21 @@ import DashboardPage from './components/DashboardPage';
import BasicsPage from './components/BasicsPage';
import PermissionsPage from './components/PermissionsPage';
import AppearancePage from './components/AppearancePage';
import ExtensionsPage from './components/ExtensionsPage';
import MailPage from './components/MailPage';
import ExtensionPage from './components/ExtensionPage';
import ExtensionPageResolver from './resolvers/ExtensionPageResolver';
/**
* The `routes` initializer defines the forum app's routes.
*
* @param {App} app
*/
export default function (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 },
mail: { path: '/mail', component: MailPage },
extension: { path: '/extension/:id', component: ExtensionPage, resolverClass: ExtensionPageResolver },
'dashboard': {path: '/', component: DashboardPage.component()},
'basics': {path: '/basics', component: BasicsPage.component()},
'permissions': {path: '/permissions', component: PermissionsPage.component()},
'appearance': {path: '/appearance', component: AppearancePage.component()},
'extensions': {path: '/extensions', component: ExtensionsPage.component()},
'mail': {path: '/mail', component: MailPage.component()}
};
}

View File

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

View File

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

View File

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

View File

@@ -3,14 +3,12 @@ export default function saveSettings(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;
});
return app.request({
method: 'POST',
url: app.forum.attribute('apiUrl') + '/settings',
data: settings
}).catch(error => {
app.data.settings = oldSettings;
throw error;
});
}

View File

@@ -1,4 +1,5 @@
import ItemList from './utils/ItemList';
import Alert from './components/Alert';
import Button from './components/Button';
import ModalManager from './components/ModalManager';
import AlertManager from './components/AlertManager';
@@ -11,7 +12,6 @@ import Drawer from './utils/Drawer';
import mapRoutes from './utils/mapRoutes';
import RequestError from './utils/RequestError';
import ScrollListener from './utils/ScrollListener';
import liveHumanTimes from './utils/liveHumanTimes';
import { extend } from './extend';
import Forum from './models/Forum';
@@ -21,9 +21,6 @@ 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
@@ -89,7 +86,7 @@ export default class Application {
discussions: Discussion,
posts: Post,
groups: Group,
notifications: Notification,
notifications: Notification
});
/**
@@ -110,49 +107,13 @@ export default class Application {
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.
* 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}
* @type {null|Alert}
* @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();
requestError = null;
data;
@@ -165,19 +126,22 @@ export default class Application {
}
boot() {
this.initializers.toArray().forEach((initializer) => initializer(this));
this.initializers.toArray().forEach(initializer => initializer(this));
this.store.pushPayload({ data: this.data.resources });
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.session = new Session(
this.store.getById('users', this.data.session.userId),
this.data.session.csrfToken
);
this.mount();
}
bootExtensions(extensions) {
Object.keys(extensions).forEach((name) => {
Object.keys(extensions).forEach(name => {
const extension = extensions[name];
const extenders = flattenDeep(extension.extend);
@@ -189,34 +153,31 @@ export default class Application {
}
mount(basePath = '') {
// An object with a callable view property is used in order to pass arguments to the component; see https://mithril.js.org/mount.html
m.mount(document.getElementById('modal'), { view: () => ModalManager.component({ state: this.modal }) });
m.mount(document.getElementById('alerts'), { view: () => AlertManager.component({ state: this.alerts }) });
this.modal = m.mount(document.getElementById('modal'), <ModalManager/>);
this.alerts = m.mount(document.getElementById('alerts'), <AlertManager/>);
this.drawer = new Drawer();
m.route(document.getElementById('content'), basePath + '/', mapRoutes(this.routes, basePath));
m.route(
document.getElementById('content'),
basePath + '/',
mapRoutes(this.routes, basePath)
);
// Add a class to the body which indicates that the page has been scrolled
// down. When this happens, we'll add classes to the header and app body
// which will set the navbar's position to fixed. We don't want to always
// have it fixed, as that could overlap with custom headers.
const scrollListener = new ScrollListener((top) => {
// down.
new ScrollListener(top => {
const $app = $('#app');
const offset = $app.offset().top;
$app.toggleClass('affix', top >= offset).toggleClass('scrolled', top > offset);
$('.App-header').toggleClass('navbar-fixed-top', top >= offset);
});
scrollListener.start();
scrollListener.update();
$app
.toggleClass('affix', top >= offset)
.toggleClass('scrolled', top > offset);
}).start();
$(() => {
$('body').addClass('ontouchstart' in window ? 'touch' : 'no-touch');
});
liveHumanTimes();
}
/**
@@ -237,16 +198,6 @@ export default class Application {
return null;
}
/**
* Determine the current screen mode, based on our media queries.
*
* @returns {String} - one of "phone", "tablet", "desktop" or "desktop-hd"
*/
screen() {
const styles = getComputedStyle(document.documentElement);
return styles.getPropertyValue('--flarum-screen');
}
/**
* Set the <title> of the page.
*
@@ -269,16 +220,15 @@ export default class Application {
}
updateTitle() {
const count = this.titleCount ? `(${this.titleCount}) ` : '';
const pageTitleWithSeparator = this.title && m.route.get() !== this.forum.attribute('basePath') + '/' ? this.title + ' - ' : '';
const title = this.forum.attribute('title');
document.title = count + pageTitleWithSeparator + title;
document.title = (this.titleCount ? `(${this.titleCount}) ` : '') +
(this.title ? this.title + ' - ' : '') +
this.forum.attribute('title');
}
/**
* Make an AJAX request, handling any low-level errors that may occur.
*
* @see https://mithril.js.org/request.html
* @see https://lhorie.github.io/mithril/mithril.request.html
* @param {Object} options
* @return {Promise}
* @public
@@ -306,19 +256,17 @@ export default class Application {
// 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.deserialize = options.deserialize || (responseText => responseText);
options.errorHandler =
options.errorHandler ||
((error) => {
throw error;
});
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) => {
options.extract = xhr => {
let responseText;
if (original) {
@@ -345,88 +293,73 @@ export default class Application {
}
};
if (this.requestErrorAlert) this.alerts.dismiss(this.requestErrorAlert);
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(
(response) => response,
(error) => {
let content;
const deferred = m.deferred();
switch (error.status) {
case 422:
content = error.response.errors
.map((error) => [error.detail, <br />])
.reduce((a, b) => a.concat(b), [])
.slice(0, -1);
break;
m.request(options).then(response => deferred.resolve(response), error => {
this.requestError = error;
case 401:
case 403:
content = app.translator.trans('core.lib.error.permission_denied_message');
break;
let children;
case 404:
case 410:
content = app.translator.trans('core.lib.error.not_found_message');
break;
switch (error.status) {
case 422:
children = error.response.errors
.map(error => [error.detail, <br/>])
.reduce((a, b) => a.concat(b), [])
.slice(0, -1);
break;
case 429:
content = app.translator.trans('core.lib.error.rate_limit_exceeded_message');
break;
case 401:
case 403:
children = app.translator.trans('core.lib.error.permission_denied_message');
break;
default:
content = app.translator.trans('core.lib.error.generic_message');
}
case 404:
case 410:
children = app.translator.trans('core.lib.error.not_found_message');
break;
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));
case 429:
children = app.translator.trans('core.lib.error.rate_limit_exceeded_message');
break;
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, error.alert.content);
}
return Promise.reject(error);
default:
children = app.translator.trans('core.lib.error.generic_message');
}
);
const isDebug = app.forum.attribute('debug');
error.alert = new Alert({
type: 'error',
children,
controls: isDebug && [
<Button className="Button Button--link" onclick={this.showDebug.bind(this, error)}>Debug</Button>
]
});
try {
options.errorHandler(error);
} catch (error) {
this.alerts.show(error.alert);
}
deferred.reject(error);
});
return deferred.promise;
}
/**
* @param {RequestError} error
* @param {string[]} [formattedError]
* @private
*/
showDebug(error, formattedError) {
this.alerts.dismiss(this.requestErrorAlert);
showDebug(error) {
this.alerts.dismiss(this.requestError.alert);
this.modal.show(RequestErrorModal, { error, formattedError });
this.modal.show(new RequestErrorModal({error}));
}
/**
@@ -438,19 +371,9 @@ export default class Application {
* @public
*/
route(name, params = {}) {
const route = this.routes[name];
if (!route) throw new Error(`Route '${name}' does not exist`);
const url = route.path.replace(/:([^\/]+)/g, (m, key) => extract(params, key));
// Remove falsy values in params to avoid having urls like '/?sort&q'
for (const key in params) {
if (params.hasOwnProperty(key) && !params[key]) delete params[key];
}
const queryString = m.buildQueryString(params);
const prefix = m.route.prefix === '' ? this.forum.attribute('basePath') : '';
const url = this.routes[name].path.replace(/:([^\/]+)/g, (m, key) => extract(params, key));
const queryString = m.route.buildQueryString(params);
const prefix = m.route.mode === 'pathname' ? app.forum.attribute('basePath') : '';
return prefix + url + (queryString ? '?' + queryString : '');
}

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

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

View File

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

View File

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

View File

@@ -88,7 +88,7 @@ export default class Model {
// relationship data object.
for (const innerKey in data[key]) {
if (data[key][innerKey] instanceof Model) {
data[key][innerKey] = { data: Model.getIdentifier(data[key][innerKey]) };
data[key][innerKey] = {data: Model.getIdentifier(data[key][innerKey])};
}
this.data[key][innerKey] = data[key][innerKey];
}
@@ -109,7 +109,7 @@ export default class Model {
* @public
*/
pushAttributes(attributes) {
this.pushData({ attributes });
this.pushData({attributes});
}
/**
@@ -125,7 +125,7 @@ export default class Model {
const data = {
type: this.data.type,
id: this.data.id,
attributes,
attributes
};
// If a 'relationships' key exists, extract it from the attributes hash and
@@ -138,7 +138,9 @@ export default class Model {
const model = attributes.relationships[key];
data.relationships[key] = {
data: model instanceof Array ? model.map(Model.getIdentifier) : Model.getIdentifier(model),
data: model instanceof Array
? model.map(Model.getIdentifier)
: Model.getIdentifier(model)
};
}
@@ -152,66 +154,52 @@ export default class Model {
this.pushData(data);
const request = { 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);
},
return app.request(Object.assign({
method: this.exists ? 'PATCH' : 'POST',
url: app.forum.attribute('apiUrl') + this.apiEndpoint(),
data: 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;
}
);
// 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.lazyRedraw();
throw response;
}
);
}
/**
* Send a request to delete the resource.
*
* @param {Object} body Data to send along with the DELETE request.
* @param {Object} data Data to send along with the DELETE request.
* @param {Object} [options]
* @return {Promise}
* @public
*/
delete(body, options = {}) {
if (!this.exists) return Promise.resolve();
delete(data, options = {}) {
if (!this.exists) return m.deferred().resolve().promise;
return app
.request(
Object.assign(
{
method: 'DELETE',
url: app.forum.attribute('apiUrl') + this.apiEndpoint(),
body,
},
options
)
)
.then(() => {
this.exists = false;
this.store.remove(this);
});
return app.request(Object.assign({
method: 'DELETE',
url: app.forum.attribute('apiUrl') + this.apiEndpoint(),
data
}, options)).then(() => {
this.exists = false;
this.store.remove(this);
});
}
/**
@@ -237,7 +225,7 @@ export default class Model {
* @public
*/
static attribute(name, transform) {
return function () {
return function() {
const value = this.data.attributes && this.data.attributes[name];
return transform ? transform(value) : value;
@@ -255,7 +243,7 @@ export default class Model {
* @public
*/
static hasOne(name) {
return function () {
return function() {
if (this.data.relationships) {
const relationship = this.data.relationships[name];
@@ -279,12 +267,12 @@ export default class Model {
* @public
*/
static hasMany(name) {
return function () {
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 relationship.data.map(data => app.store.getById(data.type, data.id));
}
}
@@ -313,7 +301,7 @@ export default class Model {
static getIdentifier(model) {
return {
type: model.data.type,
id: model.data.id,
id: model.data.id
};
}
}

View File

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

View File

@@ -34,7 +34,9 @@ export default class Store {
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);
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
@@ -56,7 +58,7 @@ export default class Store {
pushObject(data) {
if (!this.models[data.type]) return null;
const type = (this.data[data.type] = this.data[data.type] || {});
const type = this.data[data.type] = this.data[data.type] || {};
if (type[data.id]) {
type[data.id].pushData(data);
@@ -82,29 +84,22 @@ export default class Store {
* @public
*/
find(type, id, query = {}, options = {}) {
let params = query;
let data = query;
let url = app.forum.attribute('apiUrl') + '/' + type;
if (id instanceof Array) {
url += '?filter[id]=' + id.join(',');
} else if (typeof id === 'object') {
params = id;
data = id;
} else if (id) {
url += '/' + id;
}
return app
.request(
Object.assign(
{
method: 'GET',
url,
params,
},
options
)
)
.then(this.pushPayload.bind(this));
return app.request(Object.assign({
method: 'GET',
url,
data
}, options)).then(this.pushPayload.bind(this));
}
/**
@@ -129,7 +124,7 @@ export default class Store {
* @public
*/
getBy(type, key, value) {
return this.all(type).filter((model) => model[key]() === value)[0];
return this.all(type).filter(model => model[key]() === value)[0];
}
/**
@@ -142,7 +137,7 @@ export default class Store {
all(type) {
const records = this.data[type];
return records ? Object.keys(records).map((id) => records[id]) : [];
return records ? Object.keys(records).map(id => records[id]) : [];
}
/**
@@ -165,6 +160,6 @@ export default class Store {
createRecord(type, data = {}) {
data.type = data.type || type;
return new this.models[type](data, this);
return new (this.models[type])(data, this);
}
}

View File

@@ -1,3 +1,4 @@
import User from './models/User';
import username from './helpers/username';
import extract from './utils/extract';
@@ -66,43 +67,27 @@ export default class Translator {
const hydrated = [];
const open = [hydrated];
translation.forEach((part) => {
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: [] };
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);
return hydrated.filter(part => part);
}
pluralize(translation, number) {
@@ -112,7 +97,7 @@ export default class Translator {
standardRules = [],
explicitRules = [];
translation.split('|').forEach((part) => {
translation.split('|').forEach(part => {
if (cPluralRegex.test(part)) {
const matches = part.match(cPluralRegex);
explicitRules[matches[0]] = matches[matches.length - 1];
@@ -137,13 +122,11 @@ export default class Translator {
}
}
} else {
var leftNumber = this.convertNumber(matches[4]);
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)
) {
if (('[' === matches[3] ? number >= leftNumber : number > leftNumber) &&
(']' === matches[6] ? number <= rightNumber : number < rightNumber)) {
return explicitRules[e];
}
}
@@ -240,7 +223,7 @@ export default class Translator {
case 'tr':
case 'ur':
case 'zu':
return number == 1 ? 0 : 1;
return (number == 1) ? 0 : 1;
case 'am':
case 'bh':
@@ -254,7 +237,7 @@ export default class Translator {
case 'xbr':
case 'ti':
case 'wa':
return number === 0 || number == 1 ? 0 : 1;
return ((number === 0) || (number == 1)) ? 0 : 1;
case 'be':
case 'bs':
@@ -262,41 +245,41 @@ export default class Translator {
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;
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;
return (number == 1) ? 0 : (((number >= 2) && (number <= 4)) ? 1 : 2);
case 'ga':
return number == 1 ? 0 : number == 2 ? 1 : 2;
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;
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;
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;
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;
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;
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;
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;
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;
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;
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

@@ -12,20 +12,15 @@ import anchorScroll from './utils/anchorScroll';
import RequestError from './utils/RequestError';
import abbreviateNumber from './utils/abbreviateNumber';
import * as string from './utils/string';
import Stream from './utils/Stream';
import SubtreeRetainer from './utils/SubtreeRetainer';
import setRouteWithForcedRefresh from './utils/setRouteWithForcedRefresh';
import extract from './utils/extract';
import ScrollListener from './utils/ScrollListener';
import stringToColor from './utils/stringToColor';
import subclassOf from './utils/subclassOf';
import patchMithril from './utils/patchMithril';
import proxifyCompat from './utils/proxifyCompat';
import classList from './utils/classList';
import extractText from './utils/extractText';
import formatNumber from './utils/formatNumber';
import mapRoutes from './utils/mapRoutes';
import withAttr from './utils/withAttr';
import Notification from './models/Notification';
import User from './models/User';
import Post from './models/Post';
@@ -35,7 +30,6 @@ 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';
@@ -48,7 +42,6 @@ import FieldSet from './components/FieldSet';
import Select from './components/Select';
import Navigation from './components/Navigation';
import Alert from './components/Alert';
import Link from './components/Link';
import LinkButton from './components/LinkButton';
import Checkbox from './components/Checkbox';
import SelectDropdown from './components/SelectDropdown';
@@ -67,13 +60,11 @@ import highlight from './helpers/highlight';
import username from './helpers/username';
import userOnline from './helpers/userOnline';
import listItems from './helpers/listItems';
import Fragment from './Fragment';
import DefaultResolver from './resolvers/DefaultResolver';
export default {
extend: extend,
Session: Session,
Store: Store,
'extend': extend,
'Session': Session,
'Store': Store,
'utils/evented': evented,
'utils/liveHumanTimes': liveHumanTimes,
'utils/ItemList': ItemList,
@@ -89,27 +80,20 @@ export default {
'utils/extract': extract,
'utils/ScrollListener': ScrollListener,
'utils/stringToColor': stringToColor,
'utils/Stream': Stream,
'utils/subclassOf': subclassOf,
'utils/setRouteWithForcedRefresh': setRouteWithForcedRefresh,
'utils/patchMithril': patchMithril,
'utils/proxifyCompat': proxifyCompat,
'utils/classList': classList,
'utils/extractText': extractText,
'utils/formatNumber': formatNumber,
'utils/mapRoutes': mapRoutes,
'utils/withAttr': withAttr,
'models/Notification': Notification,
'models/User': User,
'models/Post': Post,
'models/Discussion': Discussion,
'models/Group': Group,
'models/Forum': Forum,
Component: Component,
Fragment: Fragment,
Translator: Translator,
'Component': Component,
'Translator': Translator,
'components/AlertManager': AlertManager,
'components/Page': Page,
'components/Switch': Switch,
'components/Badge': Badge,
'components/LoadingIndicator': LoadingIndicator,
@@ -122,7 +106,6 @@ export default {
'components/Select': Select,
'components/Navigation': Navigation,
'components/Alert': Alert,
'components/Link': Link,
'components/LinkButton': LinkButton,
'components/Checkbox': Checkbox,
'components/SelectDropdown': SelectDropdown,
@@ -130,8 +113,8 @@ export default {
'components/Button': Button,
'components/Modal': Modal,
'components/GroupBadge': GroupBadge,
Model: Model,
Application: Application,
'Model': Model,
'Application': Application,
'helpers/fullTime': fullTime,
'helpers/avatar': avatar,
'helpers/icon': icon,
@@ -140,6 +123,5 @@ export default {
'helpers/highlight': highlight,
'helpers/username': username,
'helpers/userOnline': userOnline,
'helpers/listItems': listItems,
'resolvers/DefaultResolver': DefaultResolver,
'helpers/listItems': listItems
};

View File

@@ -0,0 +1,57 @@
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.
*
* The alert may have the following special props:
*
* - `type` The type of alert this is. Will be used to give the alert a class
* name of `Alert--{type}`.
* - `controls` An array of controls to show in the alert.
* - `dismissible` Whether or not the alert can be dismissed.
* - `ondismiss` A callback to run when the alert is dismissed.
*
* All other props will be assigned as attributes on the alert element.
*/
export default class Alert extends Component {
view() {
const attrs = Object.assign({}, this.props);
const type = extract(attrs, 'type');
attrs.className = 'Alert Alert--' + type + ' ' + (attrs.className || '');
const children = extract(attrs, 'children');
const controls = extract(attrs, 'controls') || [];
// If the alert is meant to be dismissible (which is the case by default),
// then we will create a dismiss button to append as the final control in
// 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">
{children}
</span>
<ul className="Alert-controls">
{listItems(controls.concat(dismissControl))}
</ul>
</div>
);
}
}

View File

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

@@ -6,23 +6,70 @@ import Alert from './Alert';
* be shown and dismissed.
*/
export default class AlertManager extends Component {
oninit(vnode) {
super.oninit(vnode);
this.state = this.attrs.state;
init() {
/**
* An array of Alert components which are currently showing.
*
* @type {Alert[]}
* @protected
*/
this.components = [];
}
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>
))}
{this.components.map(component => <div className="AlertManager-alert">{component}</div>)}
</div>
);
}
config(isInitialized, context) {
// Since this component is 'above' the content of the page (that is, it is a
// part of the global UI that persists between routes), we will flag the DOM
// to be retained across route changes.
context.retain = true;
}
/**
* Show an Alert in the alerts area.
*
* @param {Alert} component
* @public
*/
show(component) {
if (!(component instanceof Alert)) {
throw new Error('The AlertManager component can only show Alert components');
}
component.props.ondismiss = this.dismiss.bind(this, component);
this.components.push(component);
m.redraw();
}
/**
* Dismiss an alert.
*
* @param {Alert} component
* @public
*/
dismiss(component) {
const index = this.components.indexOf(component);
if (index !== -1) {
this.components.splice(index, 1);
m.redraw();
}
}
/**
* Clear all alerts.
*
* @public
*/
clear() {
this.components = [];
m.redraw();
}
}

View File

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

View File

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

View File

@@ -1,40 +1,44 @@
import Component from '../Component';
import LoadingIndicator from './LoadingIndicator';
import icon from '../helpers/icon';
import classList from '../utils/classList';
import withAttr from '../utils/withAttr';
/**
* The `Checkbox` component defines a checkbox input.
*
* ### Attrs
* ### Props
*
* - `state` Whether or not the checkbox is checked.
* - `className` The class name for the root element.
* - `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;
init() {
/**
* Whether or not the checkbox's value is in the process of being saved.
*
* @type {Boolean}
* @public
*/
this.loading = false;
}
const className = classList([
'Checkbox',
this.attrs.state ? 'on' : 'off',
this.attrs.className,
this.attrs.loading && 'loading',
this.attrs.disabled && 'disabled',
]);
view() {
let className = 'Checkbox ' + (this.props.state ? 'on' : 'off') + ' ' + (this.props.className || '');
if (this.loading) className += ' loading';
if (this.props.disabled) className += ' disabled';
return (
<label className={className}>
<input type="checkbox" checked={this.attrs.state} disabled={this.attrs.disabled} onchange={withAttr('checked', this.onchange.bind(this))} />
<div className="Checkbox-display">{this.getDisplay()}</div>
{vnode.children}
<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>
);
}
@@ -46,7 +50,9 @@ export default class Checkbox extends Component {
* @protected
*/
getDisplay() {
return this.attrs.loading ? <LoadingIndicator size="tiny" /> : icon(this.attrs.state ? 'fas fa-check' : 'fas fa-times');
return this.loading
? LoadingIndicator.component({size: 'tiny'})
: icon(this.props.state ? 'fas fa-check' : 'fas fa-times');
}
/**
@@ -56,6 +62,6 @@ export default class Checkbox extends Component {
* @protected
*/
onchange(checked) {
if (this.attrs.onchange) this.attrs.onchange(checked, this);
if (this.props.onchange) this.props.onchange(checked, this);
}
}

View File

@@ -1,40 +0,0 @@
import Component from '../Component';
/**
* The `ConfirmDocumentUnload` component can be used to register a global
* event handler that prevents closing the browser window/tab based on the
* return value of a given callback prop.
*
* ### Attrs
*
* - `when` - a callback returning true when the browser should prompt for
* confirmation before closing the window/tab
*
* ### Children
*
* NOTE: Only the first child will be rendered. (Use this component to wrap
* another component / DOM element.)
*
*/
export default class ConfirmDocumentUnload extends Component {
handler() {
return this.attrs.when() || undefined;
}
oncreate(vnode) {
super.oncreate(vnode);
this.boundHandler = this.handler.bind(this);
$(window).on('beforeunload', this.boundHandler);
}
onremove() {
$(window).off('beforeunload', this.boundHandler);
}
view(vnode) {
// To avoid having to render another wrapping <div> here, we assume that
// this component is only wrapped around a single element / component.
return vnode.children[0];
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -9,63 +9,39 @@ import Button from './Button';
* @abstract
*/
export default class Modal extends Component {
/**
* Determine whether or not the modal should be dismissible via an 'x' button.
*/
static isDismissible = true;
/**
* Attributes for an alert component to show below the header.
*
* @type {object}
*/
alertAttrs = null;
oncreate(vnode) {
super.oncreate(vnode);
this.attrs.animateShow(() => this.onready());
}
onbeforeremove() {
// If the global modal state currently contains a modal,
// we've just opened up a new one, and accordingly,
// we don't need to show a hide animation.
if (!this.attrs.state.modal) {
this.attrs.animateHide();
// Here, we ensure that the animation has time to complete.
// See https://mithril.js.org/lifecycle-methods.html#onbeforeremove
// Bootstrap's Modal.TRANSITION_DURATION is 300 ms.
return new Promise((resolve) => setTimeout(resolve, 300));
}
init() {
/**
* An alert component to show below the header.
*
* @type {Alert}
*/
this.alert = null;
}
view() {
if (this.alertAttrs) {
this.alertAttrs.dismissible = false;
if (this.alert) {
this.alert.props.dismissible = false;
}
return (
<div className={'Modal modal-dialog ' + this.className()}>
<div className="Modal-content">
{this.constructor.isDismissible ? (
{this.isDismissible() ? (
<div className="Modal-close App-backControl">
{Button.component({
icon: 'fas fa-times',
onclick: this.hide.bind(this),
className: 'Button Button--icon Button--link',
className: 'Button Button--icon Button--link'
})}
</div>
) : (
''
)}
) : ''}
<form onsubmit={this.onsubmit.bind(this)}>
<div className="Modal-header">
<h3 className="App-titleControl App-titleControl--text">{this.title()}</h3>
</div>
{this.alertAttrs ? <div className="Modal-alert">{Alert.component(this.alertAttrs)}</div> : ''}
{alert ? <div className="Modal-alert">{this.alert}</div> : ''}
{this.content()}
</form>
@@ -74,13 +50,23 @@ export default class Modal extends Component {
);
}
/**
* Determine whether or not the modal should be dismissible via an 'x' button.
*
* @return {Boolean}
*/
isDismissible() {
return true;
}
/**
* Get the class name to apply to the modal.
*
* @return {String}
* @abstract
*/
className() {}
className() {
}
/**
* Get the title of the modal dialog.
@@ -88,7 +74,8 @@ export default class Modal extends Component {
* @return {String}
* @abstract
*/
title() {}
title() {
}
/**
* Get the content of the modal.
@@ -96,14 +83,16 @@ export default class Modal extends Component {
* @return {VirtualElement}
* @abstract
*/
content() {}
content() {
}
/**
* Handle the modal form's submit event.
*
* @param {Event} e
*/
onsubmit() {}
onsubmit() {
}
/**
* Focus on the first input when the modal is ready to be used.
@@ -112,11 +101,14 @@ export default class Modal extends Component {
this.$('form').find('input, select, textarea').first().focus().select();
}
onhide() {
}
/**
* Hide the modal.
*/
hide() {
this.attrs.state.close();
app.modal.close();
}
/**
@@ -134,7 +126,7 @@ export default class Modal extends Component {
* @param {RequestError} error
*/
onerror(error) {
this.alertAttrs = error.alert;
this.alert = error.alert;
m.redraw();

View File

@@ -1,4 +1,5 @@
import Component from '../Component';
import Modal from './Modal';
/**
* The `ModalManager` component manages a modal dialog. Only one modal dialog
@@ -6,53 +7,100 @@ import Component from '../Component';
* overwrite the previous one.
*/
export default class ModalManager extends Component {
view() {
const modal = this.attrs.state.modal;
init() {
this.showing = false;
this.component = null;
}
view() {
return (
<div className="ModalManager modal fade">
{modal
? modal.componentClass.component({
...modal.attrs,
animateShow: this.animateShow.bind(this),
animateHide: this.animateHide.bind(this),
state: this.attrs.state,
})
: ''}
{this.component && this.component.render()}
</div>
);
}
oncreate(vnode) {
super.oncreate(vnode);
config(isInitialized, context) {
if (isInitialized) return;
// Ensure the modal state is notified about a closed modal, even when the
// DOM-based Bootstrap JavaScript code triggered the closing of the modal,
// e.g. via ESC key or a click on the modal backdrop.
this.$().on('hidden.bs.modal', this.attrs.state.close.bind(this.attrs.state));
}
animateShow(readyCallback) {
const dismissible = !!this.attrs.state.modal.componentClass.isDismissible;
// If we are opening this modal while another modal is already open,
// the shown event will not run, because the modal is already open.
// So, we need to manually trigger the readyCallback.
if (this.$().hasClass('in')) {
readyCallback();
return;
}
// Since this component is 'above' the content of the page (that is, it is a
// part of the global UI that persists between routes), we will flag the DOM
// to be retained across route changes.
context.retain = true;
this.$()
.one('shown.bs.modal', readyCallback)
.modal({
backdrop: dismissible || 'static',
keyboard: dismissible,
})
.modal('show');
.on('hidden.bs.modal', this.clear.bind(this))
.on('shown.bs.modal', this.onready.bind(this));
}
animateHide() {
this.$().modal('hide');
/**
* Show a modal dialog.
*
* @param {Modal} component
* @public
*/
show(component) {
if (!(component instanceof Modal)) {
throw new Error('The ModalManager component can only show Modal components');
}
clearTimeout(this.hideTimeout);
this.showing = true;
this.component = component;
if (app.current) app.current.retain = true;
m.redraw(true);
this.$().modal({backdrop: this.component.isDismissible() ? true : 'static'}).modal('show');
this.onready();
}
/**
* Close the modal dialog.
*
* @public
*/
close() {
if (!this.showing) return;
// Don't hide the modal immediately, because if the consumer happens to call
// the `show` method straight after to show another modal dialog, it will
// cause Bootstrap's modal JS to misbehave. Instead we will wait for a tiny
// bit to give the `show` method the opportunity to prevent this from going
// ahead.
this.hideTimeout = setTimeout(() => {
this.$().modal('hide');
this.showing = false;
});
}
/**
* Clear content from the modal area.
*
* @protected
*/
clear() {
if (this.component) {
this.component.onhide();
}
this.component = null;
app.current.retain = false;
m.lazyRedraw();
}
/**
* When the modal dialog is ready to be used, tell it!
*
* @protected
*/
onready() {
if (this.component && this.component.onready) {
this.component.onready(this.$());
}
}
}

View File

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

View File

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

View File

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

View File

@@ -6,37 +6,25 @@ export default class RequestErrorModal extends Modal {
}
title() {
return this.attrs.error.xhr ? `${this.attrs.error.xhr.status} ${this.attrs.error.xhr.statusText}` : '';
return this.props.error.xhr
? this.props.error.xhr.status+' '+this.props.error.xhr.statusText
: '';
}
content() {
const { error, formattedError } = this.attrs;
let responseText;
// If the error is already formatted, just add line endings;
// else try to parse it as JSON and stringify it with indentation
if (formattedError) {
responseText = formattedError.join('\n\n');
} else {
try {
const json = error.response || JSON.parse(error.responseText);
responseText = JSON.stringify(json, null, 2);
} catch (e) {
responseText = error.responseText;
}
try {
responseText = JSON.stringify(JSON.parse(this.props.error.responseText), null, 2);
} catch (e) {
responseText = this.props.error.responseText;
}
return (
<div className="Modal-body">
<pre>
{this.attrs.error.options.method} {this.attrs.error.options.url}
<br />
<br />
{responseText}
</pre>
</div>
);
return <div className="Modal-body">
<pre>
{this.props.error.options.method} {this.props.error.options.url}<br/><br/>
{responseText}
</pre>
</div>;
}
}

View File

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

View File

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

View File

@@ -5,7 +5,7 @@ import Component from '../Component';
*/
class Separator extends Component {
view() {
return <li className="Dropdown-separator" />;
return <li className="Dropdown-separator"/>;
}
}

View File

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

View File

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

View File

@@ -21,7 +21,7 @@
export function extend(object, method, callback) {
const original = object[method];
object[method] = function (...args) {
object[method] = function(...args) {
const value = original ? original.apply(this, args) : undefined;
callback.apply(this, [value].concat(args));
@@ -57,7 +57,7 @@ export function extend(object, method, callback) {
export function override(object, method, newMethod) {
const original = object[method];
object[method] = function (...args) {
object[method] = function(...args) {
return newMethod.apply(this, [original.bind(this)].concat(args));
};

View File

@@ -1,4 +1,4 @@
export default class Model {
export default class Routes {
type;
attributes = [];
hasOnes = [];
@@ -31,11 +31,11 @@ export default class Model {
if (this.model) {
app.store.models[this.type] = this.model;
}
const model = app.store.models[this.type];
this.attributes.forEach((name) => (model.prototype[name] = model.attribute(name)));
this.hasOnes.forEach((name) => (model.prototype[name] = model.hasOne(name)));
this.hasManys.forEach((name) => (model.prototype[name] = model.hasMany(name)));
this.attributes.forEach(name => model.prototype[name] = model.attribute(name));
this.hasOnes.forEach(name => model.prototype[name] = model.hasOne(name));
this.hasManys.forEach(name => model.prototype[name] = model.hasMany(name));
}
}
}

View File

@@ -10,4 +10,4 @@ export default class PostTypes {
extend(app, extension) {
Object.assign(app.postComponents, this.postComponents);
}
}
}

View File

@@ -10,4 +10,4 @@ export default class Routes {
extend(app, extension) {
Object.assign(app.routes, this.routes);
}
}
}

View File

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

View File

@@ -0,0 +1,15 @@
/**
* The `fullTime` helper displays a formatted time string wrapped in a <time>
* tag.
*
* @param {Date} time
* @return {Object}
*/
export default function fullTime(time) {
const mo = moment(time);
const datetime = mo.format();
const full = mo.format('LLLL');
return <time pubdate datetime={datetime}>{full}</time>;
}

View File

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

View File

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

View File

@@ -0,0 +1,19 @@
import humanTimeUtil from '../utils/humanTime';
/**
* The `humanTime` helper displays a time in a human-friendly time-ago format
* (e.g. '12 days ago'), wrapped in a <time> tag with other information about
* the time.
*
* @param {Date} time
* @return {Object}
*/
export default function humanTime(time) {
const mo = moment(time);
const datetime = mo.format();
const full = mo.format('LLLL');
const ago = humanTimeUtil(time);
return <time pubdate datetime={datetime} title={full} data-humantime>{ago}</time>;
}

View File

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

View File

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

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