mirror of
https://github.com/flarum/core.git
synced 2025-08-17 22:01:44 +02:00
Compare commits
135 Commits
ds/discuss
...
v0.1.0-bet
Author | SHA1 | Date | |
---|---|---|---|
|
9fffb8ec1a | ||
|
92e590f1ab | ||
|
098908cb4a | ||
|
901846d0cf | ||
|
5a3aefb76c | ||
|
cf2a636e81 | ||
|
a8ba510655 | ||
|
9c3b6c596f | ||
|
2310d782a3 | ||
|
e9642250ae | ||
|
a6dd545dbc | ||
|
a64c39835a | ||
|
db0d8e89c7 | ||
|
4e126708e9 | ||
|
b88a7cb33b | ||
|
a2f52c09fd | ||
|
30017eef09 | ||
|
d0ffa26b0b | ||
|
612a57c466 | ||
|
91e8b56961 | ||
|
ba9665e9db | ||
|
8eea0985a4 | ||
|
1bcb9d3ea1 | ||
|
2c3e1f9923 | ||
|
bc607e089e | ||
|
91d5d9c176 | ||
|
3aa118ab94 | ||
|
4b0ad6972d | ||
|
84ded0ce50 | ||
|
725863a6e2 | ||
|
c81f629b0b | ||
|
15cbe4daaa | ||
|
ddac07d991 | ||
|
08ba2599d7 | ||
|
8eef7230e9 | ||
|
a61f9e7328 | ||
|
a65e1de641 | ||
|
bed3207798 | ||
|
fc73d47e4c | ||
|
6e01c47c11 | ||
|
a9526917b8 | ||
|
e37fdef709 | ||
|
56d7796c47 | ||
|
b7379bf91b | ||
|
7fa22a131f | ||
|
f0c6050654 | ||
|
9627eb73f1 | ||
|
d0adb244da | ||
|
458a5cc6be | ||
|
ea840ba594 | ||
|
ea291508ab | ||
|
7d79912d36 | ||
|
67306a9d34 | ||
|
8cc207b139 | ||
|
023871ef86 | ||
|
1c578a83e4 | ||
|
454c525cb2 | ||
|
49009d268f | ||
|
ef2d6a65f4 | ||
|
509adf228a | ||
|
fa10d794a4 | ||
|
40ede179cd | ||
|
0ed71ed581 | ||
|
dc75ebad00 | ||
|
900711687f | ||
|
71ccdc00e6 | ||
|
c4ebebe48e | ||
|
56d8301b2d | ||
|
09076e005b | ||
|
73a8efaec2 | ||
|
cdeb229396 | ||
|
122a99b51e | ||
|
e7aed89e8f | ||
|
a1254bc21a | ||
|
03231b2931 | ||
|
a2901cef23 | ||
|
95b021a839 | ||
|
76d6442557 | ||
|
5df22e92ae | ||
|
7306d8ef13 | ||
|
0595aba76a | ||
|
8366ec720e | ||
|
17f15e36eb | ||
|
ac249e5b07 | ||
|
e13772075c | ||
|
0fa33439d7 | ||
|
a4880453a4 | ||
|
964f827ee5 | ||
|
843daf633d | ||
|
930fcf9250 | ||
|
9bb4423dd7 | ||
|
9347b12b47 | ||
|
65b5c2043c | ||
|
08f72e7135 | ||
|
26c4e492fe | ||
|
00913d5b0b | ||
|
1851d1678e | ||
|
14dc46e226 | ||
|
be163412ab | ||
|
92d5c716be | ||
|
e42df50d31 | ||
|
203a6456ee | ||
|
40b918e139 | ||
|
f8eea5b7c7 | ||
|
b50d806534 | ||
|
cbcf83ed3b | ||
|
3394ff31e9 | ||
|
86d39fb003 | ||
|
bbb7679417 | ||
|
46248f601d | ||
|
a68e2b27a4 | ||
|
e2335e867d | ||
|
a10da427ff | ||
|
4561f56fb9 | ||
|
fae79ea910 | ||
|
9493e6230d | ||
|
927ea4eec5 | ||
|
89e821e70f | ||
|
9b2d7856d1 | ||
|
f93ec1b3b8 | ||
|
2e3197d510 | ||
|
85210ff6a1 | ||
|
e5f277e640 | ||
|
4bac667dfd | ||
|
6771b3e3b7 | ||
|
fd79a14cac | ||
|
c1aa1455d3 | ||
|
ae280016e7 | ||
|
0a8816938a | ||
|
008ec95505 | ||
|
cacc8b4945 | ||
|
31765388c1 | ||
|
a08fd3e475 | ||
|
a47187462d | ||
|
843a149b80 |
@@ -15,5 +15,5 @@ indent_size = 2
|
||||
[*.{diff,md}]
|
||||
trim_trailing_whitespace = false
|
||||
|
||||
[*.{php,xml}]
|
||||
[*.{php,xml,json}]
|
||||
indent_size = 4
|
||||
|
25
.github/workflows/test.yml
vendored
25
.github/workflows/test.yml
vendored
@@ -8,7 +8,7 @@ jobs:
|
||||
|
||||
strategy:
|
||||
matrix:
|
||||
php: [7.2, 7.3, 7.4]
|
||||
php: [7.3, 7.4, '8.0']
|
||||
service: ['mysql:5.7', mariadb]
|
||||
prefix: ['', flarum_]
|
||||
|
||||
@@ -21,16 +21,16 @@ jobs:
|
||||
prefixStr: (prefix)
|
||||
|
||||
exclude:
|
||||
- php: 7.2
|
||||
- php: 7.3
|
||||
service: 'mysql:5.7'
|
||||
prefix: flarum_
|
||||
- php: 7.2
|
||||
- php: 7.3
|
||||
service: mariadb
|
||||
prefix: flarum_
|
||||
- php: 7.3
|
||||
- php: 8.0
|
||||
service: 'mysql:5.7'
|
||||
prefix: flarum_
|
||||
- php: 7.3
|
||||
- php: 8.0
|
||||
service: mariadb
|
||||
prefix: flarum_
|
||||
|
||||
@@ -45,13 +45,22 @@ jobs:
|
||||
steps:
|
||||
- uses: actions/checkout@master
|
||||
|
||||
- name: Select PHP version
|
||||
run: sudo update-alternatives --set php $(which php${{ matrix.php }})
|
||||
- name: Setup PHP
|
||||
uses: shivammathur/setup-php@v2
|
||||
with:
|
||||
php-version: ${{ matrix.php }}
|
||||
coverage: xdebug
|
||||
extensions: curl, dom, gd, json, mbstring, openssl, pdo_mysql, tokenizer, zip
|
||||
tools: phpunit, composer:v2
|
||||
|
||||
# The authentication alter is necessary because newer mysql versions use the `caching_sha2_password` driver,
|
||||
# which isn't supported prior to PHP7.4
|
||||
# When we drop support for PHP7.3, we should remove this from the setup.
|
||||
- name: Create MySQL Database
|
||||
run: |
|
||||
sudo systemctl start mysql
|
||||
mysql -uroot -proot -e 'CREATE DATABASE flarum_test;' --port 13306
|
||||
mysql -uroot -proot -e "ALTER USER 'root'@'localhost' IDENTIFIED WITH mysql_native_password BY 'root';" --port 13306
|
||||
|
||||
- name: Install Composer dependencies
|
||||
run: composer install
|
||||
@@ -65,3 +74,5 @@ jobs:
|
||||
|
||||
- name: Run Composer tests
|
||||
run: composer test
|
||||
env:
|
||||
COMPOSER_PROCESS_TIMEOUT: 600
|
||||
|
1
.gitignore
vendored
1
.gitignore
vendored
@@ -4,6 +4,7 @@ composer.phar
|
||||
node_modules
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
tests/.phpunit.result.cache
|
||||
/tests/integration/tmp
|
||||
.vagrant
|
||||
.idea/*
|
||||
|
93
CHANGELOG.md
93
CHANGELOG.md
@@ -1,5 +1,96 @@
|
||||
# Changelog
|
||||
|
||||
## [0.1.0-beta.16](https://github.com/flarum/core/compare/v0.1.0-beta.15...v0.1.0-beta.16)
|
||||
|
||||
### Added
|
||||
- Allow event subscribers (https://github.com/flarum/core/pull/2535)
|
||||
- Allow Settings extender to have a default value (https://github.com/flarum/core/pull/2495)
|
||||
- Allow hooking into the sending of notifications before being send (https://github.com/flarum/core/pull/2533)
|
||||
- PHP 8 support (https://github.com/flarum/core/pull/2507)
|
||||
- Search extender (https://github.com/flarum/core/pull/2483)
|
||||
- User badges to post preview (https://github.com/flarum/core/pull/2555)
|
||||
- Optional extension dependencies allow a booting order (https://github.com/flarum/core/pull/2579)
|
||||
- Auth extender (https://github.com/flarum/core/pull/2176)
|
||||
- `X-Powered-By` header added to allow indexers easier data aggregation of Flarum adoption (https://github.com/flarum/core/pull/2618)
|
||||
|
||||
### Changed
|
||||
- Run integration tests in transaction (https://github.com/flarum/core/pull/2304)
|
||||
- Allow policies to return a boolean for simplified allow/deny (https://github.com/flarum/core/pull/2534)
|
||||
- Converted highlight helper to typescript (https://github.com/flarum/core/pull/2532)
|
||||
- Add accessibility attributes to Mark as Read button (https://github.com/flarum/core/pull/2564)
|
||||
- Dismiss errors on change email modal upon a new request ([00913d5](https://github.com/flarum/core/commit/00913d5b0be2172cfce1f16aaf64a24f3d2e6d4b))
|
||||
- Disabled extensions now are marked with a red circle instead of a red dot (https://github.com/flarum/core/pull/2562)
|
||||
- Extension dependency errors now show the extension title instead of the ID (https://github.com/flarum/core/pull/2563)
|
||||
- Change `mutate` method on ApiSerializer extender to `attributes` (https://github.com/flarum/core/pull/2578)
|
||||
- Moved locale files to the core from the language pack (https://github.com/flarum/core/pull/2408)
|
||||
- AdminPage extensibility and generic improvements (https://github.com/flarum/core/pull/2593)
|
||||
- Remove entry of authors, link to https://flarum.org/team (https://github.com/flarum/core/pull/2625)
|
||||
- Search and filtering are split (https://github.com/flarum/core/pull/2454)
|
||||
- Move IP identification into a middleware (https://github.com/flarum/core/pull/2624)
|
||||
- Editor Driver abstraction introduced (https://github.com/flarum/core/pull/2594)
|
||||
- Allow overriding routes (https://github.com/flarum/core/pull/2577)
|
||||
- Split user edit permissions into permissions for editing of user credentials, username, groups and suspending (https://github.com/flarum/core/pull/2620)
|
||||
- Reduced number of admin extension categories (https://github.com/flarum/core/pull/2604)
|
||||
- Move search related classes to a dedicated Query namespace (https://github.com/flarum/core/pull/2645)
|
||||
- Rewrite common helpers into typescript (https://github.com/flarum/core/pull/2541)
|
||||
- `TextEditor` is moved to the common namespace for use in the admin frontend (https://github.com/flarum/core/pull/2649)
|
||||
- Update Laravel/Illuminate components to 8 (https://github.com/flarum/core/pull/2576)
|
||||
- Eager load relations in discussion listing to improve performance (https://github.com/flarum/core/pull/2639)
|
||||
- Adopt flarum/testing package (https://github.com/flarum/core/pull/2545)
|
||||
- Replace `user` gambit with `author` gambit ([612a57c](https://github.com/flarum/core/commit/612a57c4664415a3ea120103483645c32acc6f12))
|
||||
- Posts page of on user profile loads posts using username instead of id ([30017ee](https://github.com/flarum/core/commit/30017eef09ae9e78640c4e2cacd4909fffa8d775))
|
||||
|
||||
### Fixed
|
||||
- Transform css breaks iOS scroll functionality (https://github.com/flarum/core/pull/2527)
|
||||
- Composer header is hidden on mobile devices (https://github.com/flarum/core/pull/2279)
|
||||
- Cannot delete a post or discussion of a deleted user (https://github.com/flarum/core/pull/2521)
|
||||
- DiscussionListPane jumps around not keeping the scroll position (https://github.com/flarum/core/pull/2402)
|
||||
- Infinite scroll on notifications dropdown broken (https://github.com/flarum/core/pull/2524)
|
||||
- The show language selector switch remains toggled on ([9347b12](https://github.com/flarum/core/commit/9347b12b47bf4ab97ffb7ca92673604b237c1012))
|
||||
- Model Visibility extender throws exception on extensions that aren't installed or enabled (https://github.com/flarum/core/pull/2580)
|
||||
- Extensions are marked as enabled when enabling fails to unmet extension dependencies (https://github.com/flarum/core/pull/2558)
|
||||
- Routes to admin extension pages without a valid ID break the admin page (https://github.com/flarum/core/pull/2584)
|
||||
- Disabled fieldset use an incorrect CSS property `disallowed` (https://github.com/flarum/core/pull/2585)
|
||||
- Scrolling to a post that is already loaded the Load More button shows and does not trigger (https://github.com/flarum/core/pull/2388)
|
||||
- Opening discussions on some mobile devices require a double tap (https://github.com/flarum/core/pull/2607)
|
||||
- iOS devices show erratic behavior in the post stream while updating (https://github.com/flarum/core/pull/2548)
|
||||
- Small mobile screens partially hides the composer when the keyboard is open (https://github.com/flarum/core/pull/2631)
|
||||
- Clearing cache does not clear the template cache in storage/views (https://github.com/flarum/core/pull/2648)
|
||||
- Boot errors show critical information (https://github.com/flarum/core/pull/2633)
|
||||
- List user endpoint discloses last online even if user choose against it (https://github.com/flarum/core/pull/2634)
|
||||
- Group gambit disclosed hidden groups (https://github.com/flarum/core/pull/2657)
|
||||
- Search results on small windows not fully visible (https://github.com/flarum/core/pull/2650)
|
||||
- Composer goes off screen on Safari when starting to type (https://github.com/flarum/core/pull/2660)
|
||||
- A search that has no results shows the search results dropdown ([b88a7cb](https://github.com/flarum/core/commit/b88a7cb33b56e318f11670e9e2d563aef94db039))
|
||||
- The composer modal moves around when typing on Safari ([a64c398](https://github.com/flarum/core/commit/a64c39835aba43e831209609f4a9638ae589aa41))
|
||||
|
||||
### Removed
|
||||
- Deprecated CSRF wildcard path match
|
||||
- Deprecated policy and visibility scoping events
|
||||
- Deprecated post types event
|
||||
- Deprecated validation events
|
||||
- Deprecated notification events
|
||||
- Deprecated floodgate
|
||||
- Deprecated user preferences event
|
||||
- Deprecated formatting events
|
||||
- Deprecated api events
|
||||
- Deprecated bootstrap.php support
|
||||
- PHP 7.2 support (https://github.com/flarum/core/pull/2507)
|
||||
- Bidi attribute in the rendered HTML (https://github.com/flarum/core/pull/2602)
|
||||
- `AccessToken::find`, use `AccessToken::findValid` instead (https://github.com/flarum/core/pull/2651)
|
||||
|
||||
### Deprecated
|
||||
- `GetModelIsPrivate` event (https://github.com/flarum/core/pull/2587)
|
||||
- `CheckingPassword` event (https://github.com/flarum/core/pull/2176)
|
||||
- `event()` helper (https://github.com/flarum/core/pull/2608)
|
||||
- `AccessToken::generate` argument `$lifetime` (https://github.com/flarum/core/pull/2651)
|
||||
- `Rememberer::remember` argument `$token` should receive an instance of `RememberAccessToken` with `AccessToken` being deprecated (https://github.com/flarum/core/pull/2651)
|
||||
- `Rememberer::rememberUser` (https://github.com/flarum/core/pull/2651)
|
||||
- `SessionAuthenticator::logIn` argument `$userId`, should be replaced with `AccessToken` (https://github.com/flarum/core/pull/2651)
|
||||
- `TextEditor` has been moved to `common` (https://github.com/flarum/core/pull/2649)
|
||||
- `UserFilter` ([91e8b56](https://github.com/flarum/core/commit/91e8b569618957c86757ef89bac666e9102db5ae))
|
||||
|
||||
|
||||
## [0.1.0-beta.15](https://github.com/flarum/core/compare/v0.1.0-beta.14.1...v0.1.0-beta.15)
|
||||
|
||||
### Added
|
||||
@@ -210,7 +301,7 @@
|
||||
- SES mail support (#2011)
|
||||
- Backward compatibility layer for `Flarum\Mail\DriverInterface`, new methods from beta.12 are now required
|
||||
- `Flarum\Util\Str` helper class
|
||||
- `Flarum\Event\ConfigureMiddleware` event
|
||||
- `Flarum\Event\ConfigureMiddleware` event
|
||||
|
||||
### Deprecated
|
||||
- `Flarum\Event\AbstractConfigureRoutes` event class
|
||||
|
@@ -1,36 +1,17 @@
|
||||
{
|
||||
"name": "flarum/core",
|
||||
"description": "Delightfully simple forum software.",
|
||||
"keywords": ["forum", "discussion"],
|
||||
"keywords": [
|
||||
"forum",
|
||||
"discussion"
|
||||
],
|
||||
"homepage": "https://flarum.org/",
|
||||
"license": "MIT",
|
||||
"authors": [
|
||||
{
|
||||
"name": "Franz Liedke",
|
||||
"email": "franz@develophp.org"
|
||||
},
|
||||
{
|
||||
"name": "Daniël Klabbers",
|
||||
"email": "daniel@klabbers.email",
|
||||
"homepage": "https://luceos.com"
|
||||
},
|
||||
{
|
||||
"name": "David Sevilla Martin",
|
||||
"email": "me+flarum@datitisev.me",
|
||||
"homepage": "https://datitisev.me"
|
||||
},
|
||||
{
|
||||
"name": "Clark Winkelmann",
|
||||
"email": "clark.winkelmann@gmail.com",
|
||||
"homepage": "https://clarkwinkelmann.com"
|
||||
},
|
||||
{
|
||||
"name": "Matthew Kilgore",
|
||||
"email": "matthew@kilgore.dev"
|
||||
},
|
||||
{
|
||||
"name": "Alexander (Sasha) Skvortsov",
|
||||
"email": "askvortsov@flarum.org"
|
||||
"name": "Flarum",
|
||||
"email": "info@flarum.org",
|
||||
"homepage": "https://flarum.org/team"
|
||||
}
|
||||
],
|
||||
"support": {
|
||||
@@ -39,36 +20,36 @@
|
||||
"docs": "https://flarum.org/docs/"
|
||||
},
|
||||
"require": {
|
||||
"php": ">=7.2",
|
||||
"php": ">=7.3",
|
||||
"axy/sourcemap": "^0.1.4",
|
||||
"components/font-awesome": "^5.14.0",
|
||||
"dflydev/fig-cookies": "^2.0.1",
|
||||
"dflydev/fig-cookies": "^3.0.0",
|
||||
"doctrine/dbal": "^2.7",
|
||||
"franzl/whoops-middleware": "^0.4.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",
|
||||
"franzl/whoops-middleware": "^2.0.0",
|
||||
"illuminate/bus": "^8.0",
|
||||
"illuminate/cache": "^8.0",
|
||||
"illuminate/config": "^8.0",
|
||||
"illuminate/container": "^8.0",
|
||||
"illuminate/contracts": "^8.0",
|
||||
"illuminate/database": "^8.0",
|
||||
"illuminate/events": "^8.0",
|
||||
"illuminate/filesystem": "^8.0",
|
||||
"illuminate/hashing": "^8.0",
|
||||
"illuminate/mail": "^8.0",
|
||||
"illuminate/queue": "^8.0",
|
||||
"illuminate/session": "^8.0",
|
||||
"illuminate/support": "^8.0",
|
||||
"illuminate/validation": "^8.0",
|
||||
"illuminate/view": "^8.0",
|
||||
"intervention/image": "^2.5.0",
|
||||
"laminas/laminas-diactoros": "^1.8.4",
|
||||
"laminas/laminas-httphandlerrunner": "^1.0",
|
||||
"laminas/laminas-stratigility": "^3.0",
|
||||
"laminas/laminas-diactoros": "^2.4.1",
|
||||
"laminas/laminas-httphandlerrunner": "^1.2.0",
|
||||
"laminas/laminas-stratigility": "^3.2.2",
|
||||
"league/flysystem": "^1.0.11",
|
||||
"matthiasmullie/minify": "^1.3",
|
||||
"middlewares/base-path": "^1.1",
|
||||
"middlewares/base-path-router": "^0.2.1",
|
||||
"middlewares/request-handler": "^1.2",
|
||||
"middlewares/base-path": "^2.0.1",
|
||||
"middlewares/base-path-router": "^2.0.1",
|
||||
"middlewares/request-handler": "^2.0.1",
|
||||
"monolog/monolog": "^1.16.0",
|
||||
"nesbot/carbon": "^2.0",
|
||||
"nikic/fast-route": "^0.6",
|
||||
@@ -76,25 +57,25 @@
|
||||
"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/config": "^5.2.2",
|
||||
"symfony/console": "^5.2.2",
|
||||
"symfony/event-dispatcher": "^5.2.2",
|
||||
"symfony/mime": "^5.2.0",
|
||||
"symfony/translation": "^4.3.4",
|
||||
"symfony/yaml": "^4.3.4",
|
||||
"symfony/translation": "^5.1.5",
|
||||
"symfony/yaml": "^5.2.2",
|
||||
"tobscure/json-api": "^0.3.0",
|
||||
"wikimedia/less.php": "^3.0"
|
||||
},
|
||||
"require-dev": {
|
||||
"mockery/mockery": "^1.0",
|
||||
"phpunit/phpunit": "^7.0"
|
||||
"flarum/testing": "^0.1.0-beta.16"
|
||||
},
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"Flarum\\": "src/"
|
||||
},
|
||||
"files": [
|
||||
"src/helpers.php"
|
||||
"src/helpers.php",
|
||||
"src/TranslatorInterface.php"
|
||||
]
|
||||
},
|
||||
"autoload-dev": {
|
||||
|
6
js/dist/admin.js
vendored
6
js/dist/admin.js
vendored
File diff suppressed because one or more lines are too long
2
js/dist/admin.js.map
vendored
2
js/dist/admin.js.map
vendored
File diff suppressed because one or more lines are too long
8
js/dist/forum.js
vendored
8
js/dist/forum.js
vendored
File diff suppressed because one or more lines are too long
2
js/dist/forum.js.map
vendored
2
js/dist/forum.js.map
vendored
File diff suppressed because one or more lines are too long
9496
js/package-lock.json
generated
9496
js/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -13,10 +13,10 @@
|
||||
"jquery": "^3.5.1",
|
||||
"jquery.hotkeys": "^0.1.0",
|
||||
"lodash-es": "^4.17.14",
|
||||
"m.attrs.bidi": "github:tobscure/m.attrs.bidi",
|
||||
"mithril": "^2.0.4",
|
||||
"punycode": "^2.1.1",
|
||||
"spin.js": "^3.1.0",
|
||||
"textarea-caret": "^3.1.0",
|
||||
"webpack": "^4.43.0",
|
||||
"webpack-cli": "^3.3.11",
|
||||
"webpack-merge": "^4.1.4"
|
||||
|
@@ -1,27 +1,18 @@
|
||||
import HeaderPrimary from './components/HeaderPrimary';
|
||||
import HeaderSecondary from './components/HeaderSecondary';
|
||||
import routes from './routes';
|
||||
import ExtensionPage from './components/ExtensionPage';
|
||||
import Application from '../common/Application';
|
||||
import Navigation from '../common/components/Navigation';
|
||||
import AdminNav from './components/AdminNav';
|
||||
import ExtensionData from './utils/ExtensionData';
|
||||
|
||||
export default class AdminApplication extends Application {
|
||||
// Deprecated as of beta 15
|
||||
extensionSettings = {};
|
||||
|
||||
extensionData = new ExtensionData();
|
||||
|
||||
extensionCategories = {
|
||||
discussion: 70,
|
||||
moderation: 60,
|
||||
feature: 50,
|
||||
formatting: 40,
|
||||
theme: 30,
|
||||
authentication: 20,
|
||||
feature: 30,
|
||||
theme: 20,
|
||||
language: 10,
|
||||
other: 0,
|
||||
};
|
||||
|
||||
history = {
|
||||
@@ -61,14 +52,6 @@ export default class AdminApplication extends Application {
|
||||
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] && typeof this.extensionSettings[enabled] === 'function') {
|
||||
this.extensionSettings[enabled]();
|
||||
localStorage.removeItem('enabledExtension');
|
||||
}
|
||||
}
|
||||
|
||||
getRequiredPermissions(permission) {
|
||||
|
@@ -8,6 +8,7 @@ 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 StatusWidget from './components/StatusWidget';
|
||||
import ExtensionsWidget from './components/ExtensionsWidget';
|
||||
@@ -16,7 +17,6 @@ import SettingsModal from './components/SettingsModal';
|
||||
import DashboardWidget from './components/DashboardWidget';
|
||||
import ExtensionPage from './components/ExtensionPage';
|
||||
import ExtensionLinkButton from './components/ExtensionLinkButton';
|
||||
import AdminLinkButton from './components/AdminLinkButton';
|
||||
import PermissionGrid from './components/PermissionGrid';
|
||||
import ExtensionPermissionGrid from './components/ExtensionPermissionGrid';
|
||||
import MailPage from './components/MailPage';
|
||||
@@ -43,6 +43,7 @@ export default Object.assign(compat, {
|
||||
'components/EditCustomFooterModal': EditCustomFooterModal,
|
||||
'components/SessionDropdown': SessionDropdown,
|
||||
'components/HeaderPrimary': HeaderPrimary,
|
||||
'components/AdminPage': AdminPage,
|
||||
'components/AppearancePage': AppearancePage,
|
||||
'components/StatusWidget': StatusWidget,
|
||||
'components/ExtensionsWidget': ExtensionsWidget,
|
||||
@@ -51,7 +52,6 @@ export default Object.assign(compat, {
|
||||
'components/DashboardWidget': DashboardWidget,
|
||||
'components/ExtensionPage': ExtensionPage,
|
||||
'components/ExtensionLinkButton': ExtensionLinkButton,
|
||||
'components/AdminLinkButton': AdminLinkButton,
|
||||
'components/PermissionGrid': PermissionGrid,
|
||||
'components/ExtensionPermissionGrid': ExtensionPermissionGrid,
|
||||
'components/MailPage': MailPage,
|
||||
|
@@ -1,32 +0,0 @@
|
||||
/*
|
||||
* This file is part of Flarum.
|
||||
*
|
||||
* (c) Toby Zerner <toby.zerner@gmail.com>
|
||||
*
|
||||
* For the full copyright and license information, please view the LICENSE
|
||||
* file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
import Modal from '../../common/components/Modal';
|
||||
|
||||
export default class AddExtensionModal extends Modal {
|
||||
className() {
|
||||
return 'AddExtensionModal Modal--small';
|
||||
}
|
||||
|
||||
title() {
|
||||
return app.translator.trans('core.admin.add_extension.title');
|
||||
}
|
||||
|
||||
content() {
|
||||
return (
|
||||
<div className="Modal-body">
|
||||
<p>{app.translator.trans('core.admin.add_extension.temporary_text')}</p>
|
||||
<p>
|
||||
{app.translator.trans('core.admin.add_extension.install_text', { a: <a href="https://discuss.flarum.org/t/extensions" target="_blank" /> })}
|
||||
</p>
|
||||
<p>{app.translator.trans('core.admin.add_extension.developer_text', { a: <a href="http://flarum.org/docs/extend" target="_blank" /> })}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
@@ -1,16 +0,0 @@
|
||||
/*
|
||||
* This file is part of Flarum.
|
||||
*
|
||||
* (c) Toby Zerner <toby.zerner@gmail.com>
|
||||
*
|
||||
* For the full copyright and license information, please view the LICENSE
|
||||
* file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
import LinkButton from '../../common/components/LinkButton';
|
||||
|
||||
export default class AdminLinkButton extends LinkButton {
|
||||
getButtonContent(children) {
|
||||
return [...super.getButtonContent(children), <div className="AdminLinkButton-description">{this.attrs.description}</div>];
|
||||
}
|
||||
}
|
174
js/src/admin/components/AdminPage.js
Normal file
174
js/src/admin/components/AdminPage.js
Normal file
@@ -0,0 +1,174 @@
|
||||
import Page from '../../common/components/Page';
|
||||
import Button from '../../common/components/Button';
|
||||
import Switch from '../../common/components/Switch';
|
||||
import Select from '../../common/components/Select';
|
||||
import classList from '../../common/utils/classList';
|
||||
import Stream from '../../common/utils/Stream';
|
||||
import saveSettings from '../utils/saveSettings';
|
||||
import AdminHeader from './AdminHeader';
|
||||
|
||||
export default class AdminPage extends Page {
|
||||
oninit(vnode) {
|
||||
super.oninit(vnode);
|
||||
|
||||
this.settings = {};
|
||||
|
||||
this.loading = false;
|
||||
}
|
||||
|
||||
view() {
|
||||
const className = classList(['AdminPage', this.headerInfo().className]);
|
||||
|
||||
return (
|
||||
<div className={className}>
|
||||
{this.header()}
|
||||
<div className="container">{this.content()}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
content() {
|
||||
return '';
|
||||
}
|
||||
|
||||
submitButton() {
|
||||
return (
|
||||
<Button onclick={this.saveSettings.bind(this)} className="Button Button--primary" loading={this.loading} disabled={!this.isChanged()}>
|
||||
{app.translator.trans('core.admin.settings.submit_button')}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
header() {
|
||||
const headerInfo = this.headerInfo();
|
||||
|
||||
return (
|
||||
<AdminHeader icon={headerInfo.icon} description={headerInfo.description} className={headerInfo.className + '-header'}>
|
||||
{headerInfo.title}
|
||||
</AdminHeader>
|
||||
);
|
||||
}
|
||||
|
||||
headerInfo() {
|
||||
return {
|
||||
className: '',
|
||||
icon: '',
|
||||
title: '',
|
||||
description: '',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* buildSettingComponent takes a settings object and turns it into a component.
|
||||
* Depending on the type of input, you can set the type to 'bool', 'select', or
|
||||
* any standard <input> type. Any values inside the 'extra' object will be added
|
||||
* to the component as an attribute.
|
||||
*
|
||||
* Alternatively, you can pass a callback that will be executed in ExtensionPage's
|
||||
* context to include custom JSX elements.
|
||||
*
|
||||
* @example
|
||||
*
|
||||
* {
|
||||
* setting: 'acme.checkbox',
|
||||
* label: app.translator.trans('acme.admin.setting_label'),
|
||||
* type: 'bool',
|
||||
* help: app.translator.trans('acme.admin.setting_help'),
|
||||
* className: 'Setting-item'
|
||||
* }
|
||||
*
|
||||
* @example
|
||||
*
|
||||
* {
|
||||
* setting: 'acme.select',
|
||||
* label: app.translator.trans('acme.admin.setting_label'),
|
||||
* type: 'select',
|
||||
* options: {
|
||||
* 'option1': 'Option 1 label',
|
||||
* 'option2': 'Option 2 label',
|
||||
* },
|
||||
* default: 'option1',
|
||||
* }
|
||||
*
|
||||
* @param setting
|
||||
* @returns {JSX.Element}
|
||||
*/
|
||||
buildSettingComponent(entry) {
|
||||
if (typeof entry === 'function') {
|
||||
return entry.call(this);
|
||||
}
|
||||
|
||||
const setting = entry.setting;
|
||||
const help = entry.help;
|
||||
delete entry.help;
|
||||
|
||||
const value = this.setting([setting])();
|
||||
if (['bool', 'checkbox', 'switch', 'boolean'].includes(entry.type)) {
|
||||
return (
|
||||
<div className="Form-group">
|
||||
<Switch state={!!value && value !== '0'} onchange={this.settings[setting]} {...entry}>
|
||||
{entry.label}
|
||||
</Switch>
|
||||
<div className="helpText">{help}</div>
|
||||
</div>
|
||||
);
|
||||
} else if (['select', 'dropdown', 'selectdropdown'].includes(entry.type)) {
|
||||
return (
|
||||
<div className="Form-group">
|
||||
<label>{entry.label}</label>
|
||||
<div className="helpText">{help}</div>
|
||||
<Select value={value || entry.default} options={entry.options} buttonClassName="Button" onchange={this.settings[setting]} {...entry} />
|
||||
</div>
|
||||
);
|
||||
} else {
|
||||
entry.className = classList(['FormControl', entry.className]);
|
||||
return (
|
||||
<div className="Form-group">
|
||||
{entry.label ? <label>{entry.label}</label> : ''}
|
||||
<div className="helpText">{help}</div>
|
||||
<input type={entry.type} bidi={this.setting(setting)} {...entry} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
onsaved() {
|
||||
this.loading = false;
|
||||
|
||||
app.alerts.show({ type: 'success' }, app.translator.trans('core.admin.settings.saved_message'));
|
||||
}
|
||||
|
||||
setting(key, fallback = '') {
|
||||
this.settings[key] = this.settings[key] || Stream(app.data.settings[key] || fallback);
|
||||
|
||||
return this.settings[key];
|
||||
}
|
||||
|
||||
dirty() {
|
||||
const dirty = {};
|
||||
|
||||
Object.keys(this.settings).forEach((key) => {
|
||||
const value = this.settings[key]();
|
||||
|
||||
if (value !== app.data.settings[key]) {
|
||||
dirty[key] = value;
|
||||
}
|
||||
});
|
||||
|
||||
return dirty;
|
||||
}
|
||||
|
||||
isChanged() {
|
||||
return Object.keys(this.dirty()).length;
|
||||
}
|
||||
|
||||
saveSettings(e) {
|
||||
e.preventDefault();
|
||||
|
||||
app.alerts.clear();
|
||||
|
||||
this.loading = true;
|
||||
|
||||
return saveSettings(this.dirty()).then(this.onsaved.bind(this));
|
||||
}
|
||||
}
|
@@ -1,141 +1,120 @@
|
||||
import Page from '../../common/components/Page';
|
||||
import Button from '../../common/components/Button';
|
||||
import Switch from '../../common/components/Switch';
|
||||
import Stream from '../../common/utils/Stream';
|
||||
import EditCustomCssModal from './EditCustomCssModal';
|
||||
import EditCustomHeaderModal from './EditCustomHeaderModal';
|
||||
import EditCustomFooterModal from './EditCustomFooterModal';
|
||||
import UploadImageButton from './UploadImageButton';
|
||||
import saveSettings from '../utils/saveSettings';
|
||||
import AdminHeader from './AdminHeader';
|
||||
import AdminPage from './AdminPage';
|
||||
|
||||
export default class AppearancePage extends Page {
|
||||
oninit(vnode) {
|
||||
super.oninit(vnode);
|
||||
|
||||
this.primaryColor = Stream(app.data.settings.theme_primary_color);
|
||||
this.secondaryColor = Stream(app.data.settings.theme_secondary_color);
|
||||
this.darkMode = Stream(app.data.settings.theme_dark_mode);
|
||||
this.coloredHeader = Stream(app.data.settings.theme_colored_header);
|
||||
export default class AppearancePage extends AdminPage {
|
||||
headerInfo() {
|
||||
return {
|
||||
className: 'AppearancePage',
|
||||
icon: 'fas fa-paint-brush',
|
||||
title: app.translator.trans('core.admin.appearance.title'),
|
||||
description: app.translator.trans('core.admin.appearance.description'),
|
||||
};
|
||||
}
|
||||
|
||||
view() {
|
||||
return (
|
||||
<div className="AppearancePage">
|
||||
<AdminHeader
|
||||
icon="fas fa-paint-brush"
|
||||
description={app.translator.trans('core.admin.appearance.description')}
|
||||
className="AppearancePage-header"
|
||||
>
|
||||
{app.translator.trans('core.admin.appearance.title')}
|
||||
</AdminHeader>
|
||||
<div className="container">
|
||||
<form onsubmit={this.onsubmit.bind(this)}>
|
||||
<fieldset className="AppearancePage-colors">
|
||||
<legend>{app.translator.trans('core.admin.appearance.colors_heading')}</legend>
|
||||
<div className="helpText">{app.translator.trans('core.admin.appearance.colors_text')}</div>
|
||||
content() {
|
||||
return [
|
||||
<div className="Form">
|
||||
<fieldset className="AppearancePage-colors">
|
||||
<legend>{app.translator.trans('core.admin.appearance.colors_heading')}</legend>
|
||||
<div className="helpText">{app.translator.trans('core.admin.appearance.colors_text')}</div>
|
||||
|
||||
<div className="AppearancePage-colors-input">
|
||||
<input className="FormControl" type="text" placeholder="#aaaaaa" bidi={this.primaryColor} />
|
||||
<input className="FormControl" type="text" placeholder="#aaaaaa" bidi={this.secondaryColor} />
|
||||
</div>
|
||||
<div className="AppearancePage-colors-input">
|
||||
{this.buildSettingComponent({
|
||||
type: 'text',
|
||||
setting: 'theme_primary_color',
|
||||
placeholder: '#aaaaaa',
|
||||
})}
|
||||
{this.buildSettingComponent({
|
||||
type: 'text',
|
||||
setting: 'theme_secondary_color',
|
||||
placeholder: '#aaaaaa',
|
||||
})}
|
||||
</div>
|
||||
|
||||
{Switch.component(
|
||||
{
|
||||
state: this.darkMode(),
|
||||
onchange: this.darkMode,
|
||||
},
|
||||
app.translator.trans('core.admin.appearance.dark_mode_label')
|
||||
)}
|
||||
{this.buildSettingComponent({
|
||||
type: 'switch',
|
||||
setting: 'theme_dark_mode',
|
||||
label: app.translator.trans('core.admin.appearance.dark_mode_label'),
|
||||
})}
|
||||
|
||||
{Switch.component(
|
||||
{
|
||||
state: this.coloredHeader(),
|
||||
onchange: this.coloredHeader,
|
||||
},
|
||||
app.translator.trans('core.admin.appearance.colored_header_label')
|
||||
)}
|
||||
{this.buildSettingComponent({
|
||||
type: 'switch',
|
||||
setting: 'theme_colored_header',
|
||||
label: app.translator.trans('core.admin.appearance.colored_header_label'),
|
||||
})}
|
||||
|
||||
{Button.component(
|
||||
{
|
||||
className: 'Button Button--primary',
|
||||
type: 'submit',
|
||||
loading: this.loading,
|
||||
},
|
||||
app.translator.trans('core.admin.appearance.submit_button')
|
||||
)}
|
||||
</fieldset>
|
||||
</form>
|
||||
{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.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.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_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_footer_heading')}</legend>
|
||||
<div className="helpText">{app.translator.trans('core.admin.appearance.custom_footer_text')}</div>
|
||||
{Button.component(
|
||||
{
|
||||
className: 'Button',
|
||||
onclick: () => app.modal.show(EditCustomFooterModal),
|
||||
},
|
||||
app.translator.trans('core.admin.appearance.edit_footer_button')
|
||||
)}
|
||||
</fieldset>,
|
||||
|
||||
<fieldset>
|
||||
<legend>{app.translator.trans('core.admin.appearance.custom_styles_heading')}</legend>
|
||||
<div className="helpText">{app.translator.trans('core.admin.appearance.custom_styles_text')}</div>
|
||||
{Button.component(
|
||||
{
|
||||
className: 'Button',
|
||||
onclick: () => app.modal.show(EditCustomCssModal),
|
||||
},
|
||||
app.translator.trans('core.admin.appearance.edit_css_button')
|
||||
)}
|
||||
</fieldset>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
<fieldset>
|
||||
<legend>{app.translator.trans('core.admin.appearance.custom_styles_heading')}</legend>
|
||||
<div className="helpText">{app.translator.trans('core.admin.appearance.custom_styles_text')}</div>
|
||||
{Button.component(
|
||||
{
|
||||
className: 'Button',
|
||||
onclick: () => app.modal.show(EditCustomCssModal),
|
||||
},
|
||||
app.translator.trans('core.admin.appearance.edit_css_button')
|
||||
)}
|
||||
</fieldset>,
|
||||
];
|
||||
}
|
||||
|
||||
onsubmit(e) {
|
||||
onsaved() {
|
||||
window.location.reload();
|
||||
}
|
||||
|
||||
saveSettings(e) {
|
||||
e.preventDefault();
|
||||
|
||||
const hex = /^#[0-9a-f]{3}([0-9a-f]{3})?$/i;
|
||||
|
||||
if (!hex.test(this.primaryColor()) || !hex.test(this.secondaryColor())) {
|
||||
if (!hex.test(this.settings['theme_primary_color']()) || !hex.test(this.settings['theme_secondary_color']())) {
|
||||
alert(app.translator.trans('core.admin.appearance.enter_hex_message'));
|
||||
return;
|
||||
}
|
||||
|
||||
this.loading = true;
|
||||
|
||||
saveSettings({
|
||||
theme_primary_color: this.primaryColor(),
|
||||
theme_secondary_color: this.secondaryColor(),
|
||||
theme_dark_mode: this.darkMode(),
|
||||
theme_colored_header: this.coloredHeader(),
|
||||
}).then(() => window.location.reload());
|
||||
super.saveSettings(e);
|
||||
}
|
||||
}
|
||||
|
@@ -1,31 +1,11 @@
|
||||
import Page from '../../common/components/Page';
|
||||
import FieldSet from '../../common/components/FieldSet';
|
||||
import Select from '../../common/components/Select';
|
||||
import Button from '../../common/components/Button';
|
||||
import saveSettings from '../utils/saveSettings';
|
||||
import ItemList from '../../common/utils/ItemList';
|
||||
import Switch from '../../common/components/Switch';
|
||||
import Stream from '../../common/utils/Stream';
|
||||
import withAttr from '../../common/utils/withAttr';
|
||||
import AdminHeader from './AdminHeader';
|
||||
import AdminPage from './AdminPage';
|
||||
|
||||
export default class BasicsPage extends Page {
|
||||
export default class BasicsPage extends AdminPage {
|
||||
oninit(vnode) {
|
||||
super.oninit(vnode);
|
||||
|
||||
this.loading = false;
|
||||
|
||||
this.fields = [
|
||||
'forum_title',
|
||||
'forum_description',
|
||||
'default_locale',
|
||||
'show_language_selector',
|
||||
'default_route',
|
||||
'welcome_title',
|
||||
'welcome_message',
|
||||
'display_name_driver',
|
||||
];
|
||||
|
||||
this.localeOptions = {};
|
||||
const locales = app.data.locales;
|
||||
for (const i in locales) {
|
||||
@@ -40,157 +20,99 @@ export default class BasicsPage extends Page {
|
||||
|
||||
this.slugDriverOptions = {};
|
||||
Object.keys(app.data.slugDrivers).forEach((model) => {
|
||||
this.fields.push(`slug_driver_${model}`);
|
||||
this.slugDriverOptions[model] = {};
|
||||
|
||||
app.data.slugDrivers[model].forEach((option) => {
|
||||
this.slugDriverOptions[model][option] = option;
|
||||
});
|
||||
});
|
||||
|
||||
this.values = {};
|
||||
|
||||
const settings = app.data.settings;
|
||||
this.fields.forEach((key) => (this.values[key] = Stream(settings[key])));
|
||||
|
||||
if (!this.values.display_name_driver() && displayNameDrivers.includes('username')) this.values.display_name_driver('username');
|
||||
|
||||
Object.keys(app.data.slugDrivers).forEach((model) => {
|
||||
if (!this.values[`slug_driver_${model}`]() && 'default' in this.slugDriverOptions[model]) {
|
||||
this.values[`slug_driver_${model}`]('default');
|
||||
}
|
||||
});
|
||||
|
||||
if (typeof this.values.show_language_selector() !== 'number') this.values.show_language_selector(1);
|
||||
}
|
||||
|
||||
view() {
|
||||
return (
|
||||
<div className="BasicsPage">
|
||||
<AdminHeader icon="fas fa-pencil-alt" description={app.translator.trans('core.admin.basics.description')} className="BasicsPage-header">
|
||||
{app.translator.trans('core.admin.basics.title')}
|
||||
</AdminHeader>
|
||||
<div className="container">
|
||||
<form onsubmit={this.onsubmit.bind(this)}>
|
||||
{FieldSet.component(
|
||||
{
|
||||
label: app.translator.trans('core.admin.basics.forum_title_heading'),
|
||||
},
|
||||
[<input className="FormControl" bidi={this.values.forum_title} />]
|
||||
)}
|
||||
headerInfo() {
|
||||
return {
|
||||
className: 'BasicsPage',
|
||||
icon: 'fas fa-pencil-alt',
|
||||
title: app.translator.trans('core.admin.basics.title'),
|
||||
description: app.translator.trans('core.admin.basics.description'),
|
||||
};
|
||||
}
|
||||
|
||||
{FieldSet.component(
|
||||
{
|
||||
label: app.translator.trans('core.admin.basics.forum_description_heading'),
|
||||
},
|
||||
[
|
||||
<div className="helpText">{app.translator.trans('core.admin.basics.forum_description_text')}</div>,
|
||||
<textarea className="FormControl" bidi={this.values.forum_description} />,
|
||||
]
|
||||
)}
|
||||
content() {
|
||||
return [
|
||||
<div className="Form">
|
||||
{this.buildSettingComponent({
|
||||
type: 'text',
|
||||
setting: 'forum_title',
|
||||
label: app.translator.trans('core.admin.basics.forum_title_heading'),
|
||||
})}
|
||||
{this.buildSettingComponent({
|
||||
type: 'text',
|
||||
setting: 'forum_description',
|
||||
label: app.translator.trans('core.admin.basics.forum_description_heading'),
|
||||
help: app.translator.trans('core.admin.basics.forum_description_text'),
|
||||
})}
|
||||
|
||||
{Object.keys(this.localeOptions).length > 1
|
||||
? FieldSet.component(
|
||||
{
|
||||
label: app.translator.trans('core.admin.basics.default_language_heading'),
|
||||
},
|
||||
[
|
||||
Select.component({
|
||||
options: this.localeOptions,
|
||||
value: this.values.default_locale(),
|
||||
onchange: this.values.default_locale,
|
||||
}),
|
||||
Switch.component(
|
||||
{
|
||||
state: this.values.show_language_selector(),
|
||||
onchange: this.values.show_language_selector,
|
||||
},
|
||||
app.translator.trans('core.admin.basics.show_language_selector_label')
|
||||
),
|
||||
]
|
||||
)
|
||||
: ''}
|
||||
{Object.keys(this.localeOptions).length > 1
|
||||
? [
|
||||
this.buildSettingComponent({
|
||||
type: 'select',
|
||||
setting: 'default_locale',
|
||||
options: this.localeOptions,
|
||||
label: app.translator.trans('core.admin.basics.default_language_heading'),
|
||||
}),
|
||||
this.buildSettingComponent({
|
||||
type: 'switch',
|
||||
setting: 'show_language_selector',
|
||||
label: app.translator.trans('core.admin.basics.show_language_selector_label'),
|
||||
}),
|
||||
]
|
||||
: ''}
|
||||
|
||||
{FieldSet.component(
|
||||
{
|
||||
label: app.translator.trans('core.admin.basics.home_page_heading'),
|
||||
className: 'BasicsPage-homePage',
|
||||
},
|
||||
[
|
||||
<div className="helpText">{app.translator.trans('core.admin.basics.home_page_text')}</div>,
|
||||
this.homePageItems()
|
||||
.toArray()
|
||||
.map(({ path, label }) => (
|
||||
<label className="checkbox">
|
||||
<input
|
||||
type="radio"
|
||||
name="homePage"
|
||||
value={path}
|
||||
checked={this.values.default_route() === path}
|
||||
onclick={withAttr('value', this.values.default_route)}
|
||||
/>
|
||||
{label}
|
||||
</label>
|
||||
)),
|
||||
]
|
||||
)}
|
||||
<FieldSet className="BasicsPage-homePage Form-group" label={app.translator.trans('core.admin.basics.home_page_heading')}>
|
||||
<div className="helpText">{app.translator.trans('core.admin.basics.home_page_text')}</div>
|
||||
{this.homePageItems()
|
||||
.toArray()
|
||||
.map(({ path, label }) => (
|
||||
<label className="checkbox">
|
||||
<input type="radio" name="homePage" value={path} bidi={this.setting('default_route')} />
|
||||
{label}
|
||||
</label>
|
||||
))}
|
||||
</FieldSet>
|
||||
|
||||
{FieldSet.component(
|
||||
{
|
||||
label: app.translator.trans('core.admin.basics.welcome_banner_heading'),
|
||||
className: 'BasicsPage-welcomeBanner',
|
||||
},
|
||||
[
|
||||
<div className="helpText">{app.translator.trans('core.admin.basics.welcome_banner_text')}</div>,
|
||||
<div className="BasicsPage-welcomeBanner-input">
|
||||
<input className="FormControl" bidi={this.values.welcome_title} />
|
||||
<textarea className="FormControl" bidi={this.values.welcome_message} />
|
||||
</div>,
|
||||
]
|
||||
)}
|
||||
|
||||
{Object.keys(this.displayNameOptions).length > 1 ? (
|
||||
<FieldSet label={app.translator.trans('core.admin.basics.display_name_heading')}>
|
||||
<div className="helpText">{app.translator.trans('core.admin.basics.display_name_text')}</div>
|
||||
<Select
|
||||
options={this.displayNameOptions}
|
||||
value={this.values.display_name_driver()}
|
||||
onchange={this.values.display_name_driver}
|
||||
></Select>
|
||||
</FieldSet>
|
||||
) : (
|
||||
''
|
||||
)}
|
||||
|
||||
{Object.keys(this.slugDriverOptions).map((model) => {
|
||||
const options = this.slugDriverOptions[model];
|
||||
if (Object.keys(options).length > 1) {
|
||||
return (
|
||||
<FieldSet label={app.translator.trans('core.admin.basics.slug_driver_heading', { model })}>
|
||||
<div className="helpText">{app.translator.trans('core.admin.basics.slug_driver_text', { model })}</div>
|
||||
<Select options={options} value={this.values[`slug_driver_${model}`]()} onchange={this.values[`slug_driver_${model}`]}></Select>
|
||||
</FieldSet>
|
||||
);
|
||||
}
|
||||
})}
|
||||
|
||||
{Button.component(
|
||||
{
|
||||
type: 'submit',
|
||||
className: 'Button Button--primary',
|
||||
loading: this.loading,
|
||||
disabled: !this.changed(),
|
||||
},
|
||||
app.translator.trans('core.admin.basics.submit_button')
|
||||
)}
|
||||
</form>
|
||||
<div className="Form-group BasicsPage-welcomeBanner-input">
|
||||
<label>{app.translator.trans('core.admin.basics.welcome_banner_heading')}</label>
|
||||
<div className="helpText">{app.translator.trans('core.admin.basics.welcome_banner_text')}</div>
|
||||
<input type="text" className="FormControl" bidi={this.setting('welcome_title')} />
|
||||
<textarea className="FormControl" bidi={this.setting('welcome_message')} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
changed() {
|
||||
return this.fields.some((key) => this.values[key]() !== app.data.settings[key]);
|
||||
{Object.keys(this.displayNameOptions).length > 1
|
||||
? this.buildSettingComponent({
|
||||
type: 'select',
|
||||
setting: 'display_name_driver',
|
||||
options: this.displayNameOptions,
|
||||
label: app.translator.trans('core.admin.basics.display_name_heading'),
|
||||
help: app.translator.trans('core.admin.basics.display_name_text'),
|
||||
})
|
||||
: ''}
|
||||
|
||||
{Object.keys(this.slugDriverOptions).map((model) => {
|
||||
const options = this.slugDriverOptions[model];
|
||||
if (Object.keys(options).length > 1) {
|
||||
return this.buildSettingComponent({
|
||||
type: 'select',
|
||||
setting: `slug_driver_${model}`,
|
||||
options,
|
||||
label: app.translator.trans('core.admin.basics.slug_driver_heading', { model }),
|
||||
help: app.translator.trans('core.admin.basics.slug_driver_text', { model }),
|
||||
});
|
||||
}
|
||||
})}
|
||||
|
||||
{this.submitButton()}
|
||||
</div>,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -210,27 +132,4 @@ export default class BasicsPage extends Page {
|
||||
|
||||
return items;
|
||||
}
|
||||
|
||||
onsubmit(e) {
|
||||
e.preventDefault();
|
||||
|
||||
if (this.loading) return;
|
||||
|
||||
this.loading = true;
|
||||
app.alerts.dismiss(this.successAlert);
|
||||
|
||||
const settings = {};
|
||||
|
||||
this.fields.forEach((key) => (settings[key] = this.values[key]()));
|
||||
|
||||
saveSettings(settings)
|
||||
.then(() => {
|
||||
this.successAlert = app.alerts.show({ type: 'success' }, app.translator.trans('core.admin.basics.saved_message'));
|
||||
})
|
||||
.catch(() => {})
|
||||
.then(() => {
|
||||
this.loading = false;
|
||||
m.redraw();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
@@ -1,20 +1,20 @@
|
||||
import Page from '../../common/components/Page';
|
||||
import StatusWidget from './StatusWidget';
|
||||
import ExtensionsWidget from './ExtensionsWidget';
|
||||
import AdminHeader from './AdminHeader';
|
||||
import ItemList from '../../common/utils/ItemList';
|
||||
import listItems from '../../common/helpers/listItems';
|
||||
import AdminPage from './AdminPage';
|
||||
|
||||
export default class DashboardPage extends Page {
|
||||
view() {
|
||||
return (
|
||||
<div className="DashboardPage">
|
||||
<AdminHeader icon="fas fa-chart-bar" description={app.translator.trans('core.admin.dashboard.description')} className="DashboardPage-header">
|
||||
{app.translator.trans('core.admin.dashboard.title')}
|
||||
</AdminHeader>
|
||||
<div className="container">{this.availableWidgets().toArray()}</div>
|
||||
</div>
|
||||
);
|
||||
export default class DashboardPage extends AdminPage {
|
||||
headerInfo() {
|
||||
return {
|
||||
className: 'DashboardPage',
|
||||
icon: 'fas fa-chart-bar',
|
||||
title: app.translator.trans('core.admin.dashboard.title'),
|
||||
description: app.translator.trans('core.admin.dashboard.description'),
|
||||
};
|
||||
}
|
||||
|
||||
content() {
|
||||
return this.availableWidgets().toArray();
|
||||
}
|
||||
|
||||
availableWidgets() {
|
||||
|
@@ -1,27 +1,22 @@
|
||||
import Button from '../../common/components/Button';
|
||||
import Link from '../../common/components/Link';
|
||||
import LinkButton from '../../common/components/LinkButton';
|
||||
import Page from '../../common/components/Page';
|
||||
import Select from '../../common/components/Select';
|
||||
import Switch from '../../common/components/Switch';
|
||||
import icon from '../../common/helpers/icon';
|
||||
import punctuateSeries from '../../common/helpers/punctuateSeries';
|
||||
import listItems from '../../common/helpers/listItems';
|
||||
import ItemList from '../../common/utils/ItemList';
|
||||
import Stream from '../../common/utils/Stream';
|
||||
import LoadingModal from './LoadingModal';
|
||||
import ExtensionPermissionGrid from './ExtensionPermissionGrid';
|
||||
import saveSettings from '../utils/saveSettings';
|
||||
import isExtensionEnabled from '../utils/isExtensionEnabled';
|
||||
import AdminPage from './AdminPage';
|
||||
|
||||
export default class ExtensionPage extends Page {
|
||||
export default class ExtensionPage extends AdminPage {
|
||||
oninit(vnode) {
|
||||
super.oninit(vnode);
|
||||
|
||||
this.loading = false;
|
||||
this.extension = app.data.extensions[this.attrs.id];
|
||||
this.changingState = false;
|
||||
this.settings = {};
|
||||
|
||||
this.infoFields = {
|
||||
discuss: 'fas fa-comment-alt',
|
||||
@@ -32,18 +27,20 @@ export default class ExtensionPage extends Page {
|
||||
source: 'fas fa-code',
|
||||
};
|
||||
|
||||
// Backwards compatibility layer will be removed in
|
||||
// Beta 16
|
||||
if (app.extensionSettings[this.extension.id]) {
|
||||
app.extensionData[this.extension.id] = app.extensionSettings[this.extension.id];
|
||||
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()}
|
||||
@@ -59,6 +56,8 @@ export default class ExtensionPage extends Page {
|
||||
}
|
||||
|
||||
header() {
|
||||
const isEnabled = this.isEnabled();
|
||||
|
||||
return [
|
||||
<div className="ExtensionPage-header">
|
||||
<div className="container">
|
||||
@@ -75,10 +74,12 @@ export default class ExtensionPage extends Page {
|
||||
</div>
|
||||
<div className="helpText">{this.extension.description}</div>
|
||||
<div className="ExtensionPage-headerItems">
|
||||
<Switch state={this.isEnabled()} onchange={this.toggle.bind(this, this.extension.id)}>
|
||||
{this.isEnabled(this.extension.id)
|
||||
? app.translator.trans('core.admin.extension.enabled')
|
||||
: app.translator.trans('core.admin.extension.disabled')}
|
||||
<Switch
|
||||
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>
|
||||
@@ -120,11 +121,7 @@ export default class ExtensionPage extends Page {
|
||||
return (
|
||||
<div className="ExtensionPage-settings">
|
||||
<div className="container">
|
||||
{typeof app.extensionData[this.extension.id] === 'function' ? (
|
||||
<Button onclick={app.extensionData[this.extension.id].bind(this)} className="Button Button--primary">
|
||||
{app.translator.trans('core.admin.extension.open_modal')}
|
||||
</Button>
|
||||
) : settings ? (
|
||||
{settings ? (
|
||||
<div className="Form">
|
||||
{settings.map(this.buildSettingComponent.bind(this))}
|
||||
<div className="Form-group">{this.submitButton()}</div>
|
||||
@@ -200,78 +197,6 @@ export default class ExtensionPage extends Page {
|
||||
return items;
|
||||
}
|
||||
|
||||
submitButton() {
|
||||
return (
|
||||
<Button onclick={this.saveSettings.bind(this)} className="Button Button--primary" loading={this.loading} disabled={!this.isChanged()}>
|
||||
{app.translator.trans('core.admin.settings.submit_button')}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* getSetting takes a settings object and turns it into a component.
|
||||
* Depending on the type of input, you can set the type to 'bool', 'select', or
|
||||
* any standard <input> type.
|
||||
*
|
||||
* 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'
|
||||
* }
|
||||
*
|
||||
* @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 value = this.setting([setting])();
|
||||
if (['bool', 'checkbox', 'switch', 'boolean'].includes(entry.type)) {
|
||||
return (
|
||||
<div className="Form-group">
|
||||
<Switch state={!!value && value !== '0'} onchange={this.settings[setting]}>
|
||||
{entry.label}
|
||||
</Switch>
|
||||
</div>
|
||||
);
|
||||
} else if (['select', 'dropdown', 'selectdropdown'].includes(entry.type)) {
|
||||
return (
|
||||
<div className="Form-group">
|
||||
<label>{entry.label}</label>
|
||||
<Select value={value || entry.default} options={entry.options} buttonClassName="Button" onchange={this.settings[setting]} />
|
||||
</div>
|
||||
);
|
||||
} else {
|
||||
return (
|
||||
<div className="Form-group">
|
||||
<label>{entry.label}</label>
|
||||
<input type={entry.type} className="FormControl" bidi={this.setting(setting)} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
toggle() {
|
||||
const enabled = this.isEnabled();
|
||||
|
||||
@@ -292,50 +217,8 @@ export default class ExtensionPage extends Page {
|
||||
app.modal.show(LoadingModal);
|
||||
}
|
||||
|
||||
dirty() {
|
||||
const dirty = {};
|
||||
|
||||
Object.keys(this.settings).forEach((key) => {
|
||||
const value = this.settings[key]();
|
||||
|
||||
if (value !== app.data.settings[key]) {
|
||||
dirty[key] = value;
|
||||
}
|
||||
});
|
||||
|
||||
return dirty;
|
||||
}
|
||||
|
||||
isChanged() {
|
||||
return Object.keys(this.dirty()).length;
|
||||
}
|
||||
|
||||
saveSettings(e) {
|
||||
e.preventDefault();
|
||||
|
||||
app.alerts.clear();
|
||||
|
||||
this.loading = true;
|
||||
|
||||
saveSettings(this.dirty()).then(this.onsaved.bind(this));
|
||||
}
|
||||
|
||||
onsaved() {
|
||||
this.loading = false;
|
||||
|
||||
app.alerts.show({ type: 'success' }, app.translator.trans('core.admin.extension.saved_message'));
|
||||
}
|
||||
|
||||
setting(key, fallback = '') {
|
||||
this.settings[key] = this.settings[key] || Stream(app.data.settings[key] || fallback);
|
||||
|
||||
return this.settings[key];
|
||||
}
|
||||
|
||||
isEnabled() {
|
||||
let isEnabled = isExtensionEnabled(this.extension.id);
|
||||
|
||||
return this.changingState ? !isEnabled : isEnabled;
|
||||
return isExtensionEnabled(this.extension.id);
|
||||
}
|
||||
|
||||
onerror(e) {
|
||||
@@ -346,6 +229,8 @@ export default class ExtensionPage extends Page {
|
||||
app.modal.close();
|
||||
}, 300); // Bootstrap's Modal.TRANSITION_DURATION is 300 ms.
|
||||
|
||||
this.changingState = false;
|
||||
|
||||
if (e.status !== 409) {
|
||||
throw e;
|
||||
}
|
||||
|
@@ -1,4 +1,5 @@
|
||||
import PermissionGrid from './PermissionGrid';
|
||||
import Button from '../../common/components/Button';
|
||||
import ItemList from '../../common/utils/ItemList';
|
||||
|
||||
export default class ExtensionPermissionGrid extends PermissionGrid {
|
||||
@@ -36,4 +37,17 @@ export default class ExtensionPermissionGrid extends PermissionGrid {
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
@@ -5,42 +5,47 @@ 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 categorizedExtensions = getCategorizedExtensions();
|
||||
const categories = app.extensionCategories;
|
||||
|
||||
return (
|
||||
<div className="ExtensionsWidget-list">
|
||||
{Object.keys(categories).map((category) => {
|
||||
if (categorizedExtensions[category]) {
|
||||
return (
|
||||
<div className="ExtensionList-Category">
|
||||
<h4 className="ExtensionList-Label">{app.translator.trans(`core.admin.nav.categories.${category}`)}</h4>
|
||||
<ul className="ExtensionList">
|
||||
{categorizedExtensions[category].map((extension) => {
|
||||
return (
|
||||
<li className={'ExtensionListItem ' + (!isExtensionEnabled(extension.id) ? 'disabled' : '')}>
|
||||
<Link href={app.route('extension', { id: extension.id })}>
|
||||
<div className="ExtensionListItem-content">
|
||||
<span className="ExtensionListItem-icon ExtensionIcon" style={extension.icon}>
|
||||
{extension.icon ? icon(extension.icon.name) : ''}
|
||||
</span>
|
||||
<span className="ExtensionListItem-title">{extension.extra['flarum-extension'].title}</span>
|
||||
</div>
|
||||
</Link>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
})}
|
||||
{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>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
@@ -1,34 +1,31 @@
|
||||
import Page from '../../common/components/Page';
|
||||
import FieldSet from '../../common/components/FieldSet';
|
||||
import Button from '../../common/components/Button';
|
||||
import Alert from '../../common/components/Alert';
|
||||
import Select from '../../common/components/Select';
|
||||
import LoadingIndicator from '../../common/components/LoadingIndicator';
|
||||
import saveSettings from '../utils/saveSettings';
|
||||
import Stream from '../../common/utils/Stream';
|
||||
import icon from '../../common/helpers/icon';
|
||||
import AdminHeader from './AdminHeader';
|
||||
import AdminPage from './AdminPage';
|
||||
|
||||
export default class MailPage extends Page {
|
||||
export default class MailPage extends AdminPage {
|
||||
oninit(vnode) {
|
||||
super.oninit(vnode);
|
||||
|
||||
this.saving = false;
|
||||
this.sendingTest = false;
|
||||
this.refresh();
|
||||
}
|
||||
|
||||
headerInfo() {
|
||||
return {
|
||||
className: 'MailPage',
|
||||
icon: 'fas fa-envelope',
|
||||
title: app.translator.trans('core.admin.email.title'),
|
||||
description: app.translator.trans('core.admin.email.description'),
|
||||
};
|
||||
}
|
||||
|
||||
refresh() {
|
||||
this.loading = true;
|
||||
|
||||
this.driverFields = {};
|
||||
this.fields = ['mail_driver', 'mail_from'];
|
||||
this.values = {};
|
||||
this.status = { sending: false, errors: {} };
|
||||
|
||||
const settings = app.data.settings;
|
||||
this.fields.forEach((key) => (this.values[key] = Stream(settings[key])));
|
||||
|
||||
app
|
||||
.request({
|
||||
method: 'GET',
|
||||
@@ -39,150 +36,78 @@ export default class MailPage extends Page {
|
||||
this.status.sending = response['data']['attributes']['sending'];
|
||||
this.status.errors = response['data']['attributes']['errors'];
|
||||
|
||||
for (const driver in this.driverFields) {
|
||||
for (const field in this.driverFields[driver]) {
|
||||
this.fields.push(field);
|
||||
this.values[field] = Stream(settings[field]);
|
||||
}
|
||||
}
|
||||
|
||||
this.loading = false;
|
||||
m.redraw();
|
||||
});
|
||||
}
|
||||
|
||||
view() {
|
||||
if (this.loading || this.saving) {
|
||||
return (
|
||||
<div className="MailPage">
|
||||
<div className="container">
|
||||
<LoadingIndicator />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
content() {
|
||||
if (this.loading) {
|
||||
return <LoadingIndicator />;
|
||||
}
|
||||
|
||||
const fields = this.driverFields[this.values.mail_driver()];
|
||||
const fields = this.driverFields[this.setting('mail_driver')()];
|
||||
const fieldKeys = Object.keys(fields);
|
||||
|
||||
return (
|
||||
<div className="MailPage">
|
||||
<AdminHeader icon="fas fa-envelope" description={app.translator.trans('core.admin.email.description')} className="MailPage-header">
|
||||
{app.translator.trans('core.admin.email.title')}
|
||||
</AdminHeader>
|
||||
<div className="container">
|
||||
<form onsubmit={this.onsubmit.bind(this)}>
|
||||
{FieldSet.component(
|
||||
{
|
||||
label: app.translator.trans('core.admin.email.addresses_heading'),
|
||||
className: 'MailPage-MailSettings',
|
||||
},
|
||||
[
|
||||
<div className="MailPage-MailSettings-input">
|
||||
<label>
|
||||
{app.translator.trans('core.admin.email.from_label')}
|
||||
<input className="FormControl" bidi={this.values.mail_from} />
|
||||
</label>
|
||||
</div>,
|
||||
]
|
||||
)}
|
||||
<div className="Form">
|
||||
{this.buildSettingComponent({
|
||||
type: 'text',
|
||||
setting: 'mail_from',
|
||||
label: app.translator.trans('core.admin.email.addresses_heading'),
|
||||
className: 'MailPage-MailSettings',
|
||||
})}
|
||||
{this.buildSettingComponent({
|
||||
type: 'select',
|
||||
setting: 'mail_driver',
|
||||
options: Object.keys(this.driverFields).reduce((memo, val) => ({ ...memo, [val]: val }), {}),
|
||||
label: app.translator.trans('core.admin.email.driver_heading'),
|
||||
className: 'MailPage-MailSettings',
|
||||
})}
|
||||
{this.status.sending ||
|
||||
Alert.component(
|
||||
{
|
||||
dismissible: false,
|
||||
},
|
||||
app.translator.trans('core.admin.email.not_sending_message')
|
||||
)}
|
||||
|
||||
{FieldSet.component(
|
||||
{
|
||||
label: app.translator.trans('core.admin.email.driver_heading'),
|
||||
className: 'MailPage-MailSettings',
|
||||
},
|
||||
[
|
||||
<div className="MailPage-MailSettings-input">
|
||||
<label>
|
||||
{app.translator.trans('core.admin.email.driver_label')}
|
||||
<Select
|
||||
value={this.values.mail_driver()}
|
||||
options={Object.keys(this.driverFields).reduce((memo, val) => ({ ...memo, [val]: val }), {})}
|
||||
onchange={this.values.mail_driver}
|
||||
/>
|
||||
</label>
|
||||
</div>,
|
||||
]
|
||||
)}
|
||||
{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];
|
||||
|
||||
{this.status.sending ||
|
||||
Alert.component(
|
||||
{
|
||||
dismissible: false,
|
||||
},
|
||||
app.translator.trans('core.admin.email.not_sending_message')
|
||||
)}
|
||||
return [
|
||||
this.buildSettingComponent({
|
||||
type: typeof this.setting(field)() === 'string' ? 'text' : 'select',
|
||||
label: app.translator.trans(`core.admin.email.${field}_label`),
|
||||
setting: field,
|
||||
options: fieldInfo,
|
||||
}),
|
||||
this.status.errors[field] && <p className="ValidationError">{this.status.errors[field]}</p>,
|
||||
];
|
||||
})}
|
||||
</div>
|
||||
</FieldSet>
|
||||
)}
|
||||
{this.submitButton()}
|
||||
|
||||
{fieldKeys.length > 0 &&
|
||||
FieldSet.component(
|
||||
{
|
||||
label: app.translator.trans(`core.admin.email.${this.values.mail_driver()}_heading`),
|
||||
className: 'MailPage-MailSettings',
|
||||
},
|
||||
[
|
||||
<div className="MailPage-MailSettings-input">
|
||||
{fieldKeys.map((field) => [
|
||||
<label>
|
||||
{app.translator.trans(`core.admin.email.${field}_label`)}
|
||||
{this.renderField(field)}
|
||||
</label>,
|
||||
this.status.errors[field] && <p className="ValidationError">{this.status.errors[field]}</p>,
|
||||
])}
|
||||
</div>,
|
||||
]
|
||||
)}
|
||||
|
||||
<FieldSet>
|
||||
{Button.component(
|
||||
{
|
||||
type: 'submit',
|
||||
className: 'Button Button--primary',
|
||||
disabled: !this.changed(),
|
||||
},
|
||||
app.translator.trans('core.admin.email.submit_button')
|
||||
)}
|
||||
</FieldSet>
|
||||
|
||||
{FieldSet.component(
|
||||
{
|
||||
label: app.translator.trans('core.admin.email.send_test_mail_heading'),
|
||||
className: 'MailPage-MailSettings',
|
||||
},
|
||||
[
|
||||
<div className="helpText">{app.translator.trans('core.admin.email.send_test_mail_text', { email: app.session.user.email() })}</div>,
|
||||
Button.component(
|
||||
{
|
||||
className: 'Button Button--primary',
|
||||
disabled: this.sendingTest || this.changed(),
|
||||
onclick: () => this.sendTestEmail(),
|
||||
},
|
||||
app.translator.trans('core.admin.email.send_test_mail_button')
|
||||
),
|
||||
]
|
||||
)}
|
||||
</form>
|
||||
</div>
|
||||
<FieldSet label={app.translator.trans('core.admin.email.send_test_mail_heading')} className="MailPage-MailSettings">
|
||||
<div className="helpText">{app.translator.trans('core.admin.email.send_test_mail_text', { email: app.session.user.email() })}</div>
|
||||
{Button.component(
|
||||
{
|
||||
className: 'Button Button--primary',
|
||||
disabled: this.sendingTest || this.isChanged(),
|
||||
onclick: () => this.sendTestEmail(),
|
||||
},
|
||||
app.translator.trans('core.admin.email.send_test_mail_button')
|
||||
)}
|
||||
</FieldSet>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
renderField(name) {
|
||||
const driver = this.values.mail_driver();
|
||||
const field = this.driverFields[driver][name];
|
||||
const prop = this.values[name];
|
||||
|
||||
if (typeof field === 'string') {
|
||||
return <input className="FormControl" bidi={prop} />;
|
||||
} else {
|
||||
return <Select value={prop()} options={field} onchange={prop} />;
|
||||
}
|
||||
}
|
||||
|
||||
changed() {
|
||||
return this.fields.some((key) => this.values[key]() !== app.data.settings[key]);
|
||||
}
|
||||
|
||||
sendTestEmail() {
|
||||
if (this.saving || this.sendingTest) return;
|
||||
|
||||
@@ -205,26 +130,7 @@ export default class MailPage extends Page {
|
||||
});
|
||||
}
|
||||
|
||||
onsubmit(e) {
|
||||
e.preventDefault();
|
||||
|
||||
if (this.saving || this.sendingTest) return;
|
||||
|
||||
this.saving = true;
|
||||
app.alerts.dismiss(this.successAlert);
|
||||
|
||||
const settings = {};
|
||||
|
||||
this.fields.forEach((key) => (settings[key] = this.values[key]()));
|
||||
|
||||
saveSettings(settings)
|
||||
.then(() => {
|
||||
this.successAlert = app.alerts.show({ type: 'success' }, app.translator.trans('core.admin.basics.saved_message'));
|
||||
})
|
||||
.catch(() => {})
|
||||
.then(() => {
|
||||
this.saving = false;
|
||||
this.refresh();
|
||||
});
|
||||
saveSettings(e) {
|
||||
super.saveSettings(e).then(this.refresh());
|
||||
}
|
||||
}
|
||||
|
@@ -327,9 +327,29 @@ export default class PermissionGrid extends Component {
|
||||
);
|
||||
|
||||
items.add(
|
||||
'userEdit',
|
||||
'userEditCredentials',
|
||||
{
|
||||
icon: 'fas fa-user-cog',
|
||||
label: app.translator.trans('core.admin.permissions.edit_users_credentials_label'),
|
||||
permission: 'user.editCredentials',
|
||||
},
|
||||
60
|
||||
);
|
||||
|
||||
items.add(
|
||||
'userEditGroups',
|
||||
{
|
||||
icon: 'fas fa-users-cog',
|
||||
label: app.translator.trans('core.admin.permissions.edit_users_groups_label'),
|
||||
permission: 'user.editGroups',
|
||||
},
|
||||
60
|
||||
);
|
||||
|
||||
items.add(
|
||||
'userEdit',
|
||||
{
|
||||
icon: 'fas fa-address-card',
|
||||
label: app.translator.trans('core.admin.permissions.edit_users_label'),
|
||||
permission: 'user.edit',
|
||||
},
|
||||
|
@@ -1,44 +1,43 @@
|
||||
import Page from '../../common/components/Page';
|
||||
import GroupBadge from '../../common/components/GroupBadge';
|
||||
import EditGroupModal from './EditGroupModal';
|
||||
import Group from '../../common/models/Group';
|
||||
import icon from '../../common/helpers/icon';
|
||||
import PermissionGrid from './PermissionGrid';
|
||||
import AdminHeader from './AdminHeader';
|
||||
import AdminPage from './AdminPage';
|
||||
|
||||
export default class PermissionsPage extends Page {
|
||||
view() {
|
||||
return (
|
||||
<div className="PermissionsPage">
|
||||
<AdminHeader icon="fas fa-key" description={app.translator.trans('core.admin.permissions.description')} className="PermissionsPage-header">
|
||||
{app.translator.trans('core.admin.permissions.title')}
|
||||
</AdminHeader>
|
||||
<div className="PermissionsPage-groups">
|
||||
<div className="container">
|
||||
{app.store
|
||||
.all('groups')
|
||||
.filter((group) => [Group.GUEST_ID, Group.MEMBER_ID].indexOf(group.id()) === -1)
|
||||
.map((group) => (
|
||||
<button className="Button Group" onclick={() => app.modal.show(EditGroupModal, { group })}>
|
||||
{GroupBadge.component({
|
||||
group,
|
||||
className: 'Group-icon',
|
||||
label: null,
|
||||
})}
|
||||
<span className="Group-name">{group.namePlural()}</span>
|
||||
</button>
|
||||
))}
|
||||
<button className="Button Group Group--add" onclick={() => app.modal.show(EditGroupModal)}>
|
||||
{icon('fas fa-plus', { className: 'Group-icon' })}
|
||||
<span className="Group-name">{app.translator.trans('core.admin.permissions.new_group_button')}</span>
|
||||
export default class PermissionsPage extends AdminPage {
|
||||
headerInfo() {
|
||||
return {
|
||||
className: 'PermissionsPage',
|
||||
icon: 'fas fa-key',
|
||||
title: app.translator.trans('core.admin.permissions.title'),
|
||||
description: app.translator.trans('core.admin.permissions.description'),
|
||||
};
|
||||
}
|
||||
|
||||
content() {
|
||||
return [
|
||||
<div className="PermissionsPage-groups">
|
||||
{app.store
|
||||
.all('groups')
|
||||
.filter((group) => [Group.GUEST_ID, Group.MEMBER_ID].indexOf(group.id()) === -1)
|
||||
.map((group) => (
|
||||
<button className="Button Group" onclick={() => app.modal.show(EditGroupModal, { group })}>
|
||||
{GroupBadge.component({
|
||||
group,
|
||||
className: 'Group-icon',
|
||||
label: null,
|
||||
})}
|
||||
<span className="Group-name">{group.namePlural()}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
<button className="Button Group Group--add" onclick={() => app.modal.show(EditGroupModal)}>
|
||||
{icon('fas fa-plus', { className: 'Group-icon' })}
|
||||
<span className="Group-name">{app.translator.trans('core.admin.permissions.new_group_button')}</span>
|
||||
</button>
|
||||
</div>,
|
||||
|
||||
<div className="PermissionsPage-permissions">
|
||||
<div className="container">{PermissionGrid.component()}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
<div className="PermissionsPage-permissions">{PermissionGrid.component()}</div>,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
@@ -15,9 +15,9 @@ export default function getCategorizedExtensions() {
|
||||
|
||||
extensions[category].push(extension);
|
||||
} else {
|
||||
extensions.other = extensions.other || [];
|
||||
extensions.feature = extensions.feature || [];
|
||||
|
||||
extensions.other.push(extension);
|
||||
extensions.feature.push(extension);
|
||||
}
|
||||
});
|
||||
|
||||
|
@@ -1,6 +1,7 @@
|
||||
import * as extend from './extend';
|
||||
import Session from './Session';
|
||||
import Store from './Store';
|
||||
import BasicEditorDriver from './utils/BasicEditorDriver';
|
||||
import evented from './utils/evented';
|
||||
import liveHumanTimes from './utils/liveHumanTimes';
|
||||
import ItemList from './utils/ItemList';
|
||||
@@ -19,7 +20,6 @@ import extract from './utils/extract';
|
||||
import ScrollListener from './utils/ScrollListener';
|
||||
import stringToColor from './utils/stringToColor';
|
||||
import subclassOf from './utils/subclassOf';
|
||||
import SuperTextarea from './utils/SuperTextarea';
|
||||
import patchMithril from './utils/patchMithril';
|
||||
import proxifyCompat from './utils/proxifyCompat';
|
||||
import classList from './utils/classList';
|
||||
@@ -57,6 +57,8 @@ import ModalManager from './components/ModalManager';
|
||||
import Button from './components/Button';
|
||||
import Modal from './components/Modal';
|
||||
import GroupBadge from './components/GroupBadge';
|
||||
import TextEditor from './components/TextEditor';
|
||||
import TextEditorButton from './components/TextEditorButton';
|
||||
import Model from './Model';
|
||||
import Application from './Application';
|
||||
import fullTime from './helpers/fullTime';
|
||||
@@ -75,6 +77,7 @@ export default {
|
||||
extend: extend,
|
||||
Session: Session,
|
||||
Store: Store,
|
||||
'utils/BasicEditorDriver': BasicEditorDriver,
|
||||
'utils/evented': evented,
|
||||
'utils/liveHumanTimes': liveHumanTimes,
|
||||
'utils/ItemList': ItemList,
|
||||
@@ -92,7 +95,6 @@ export default {
|
||||
'utils/stringToColor': stringToColor,
|
||||
'utils/Stream': Stream,
|
||||
'utils/subclassOf': subclassOf,
|
||||
'utils/SuperTextarea': SuperTextarea,
|
||||
'utils/setRouteWithForcedRefresh': setRouteWithForcedRefresh,
|
||||
'utils/patchMithril': patchMithril,
|
||||
'utils/proxifyCompat': proxifyCompat,
|
||||
@@ -132,6 +134,8 @@ export default {
|
||||
'components/Button': Button,
|
||||
'components/Modal': Modal,
|
||||
'components/GroupBadge': GroupBadge,
|
||||
'components/TextEditor': TextEditor,
|
||||
'components/TextEditorButton': TextEditorButton,
|
||||
Model: Model,
|
||||
Application: Application,
|
||||
'helpers/fullTime': fullTime,
|
||||
|
@@ -1,8 +1,9 @@
|
||||
import Component from '../../common/Component';
|
||||
import ItemList from '../../common/utils/ItemList';
|
||||
import SuperTextarea from '../../common/utils/SuperTextarea';
|
||||
import listItems from '../../common/helpers/listItems';
|
||||
import Button from '../../common/components/Button';
|
||||
import Component from '../Component';
|
||||
import ItemList from '../utils/ItemList';
|
||||
import listItems from '../helpers/listItems';
|
||||
import Button from './Button';
|
||||
|
||||
import BasicEditorDriver from '../utils/BasicEditorDriver';
|
||||
|
||||
/**
|
||||
* The `TextEditor` component displays a textarea with controls, including a
|
||||
@@ -22,25 +23,22 @@ export default class TextEditor extends Component {
|
||||
super.oninit(vnode);
|
||||
|
||||
/**
|
||||
* The value of the textarea.
|
||||
* The value of the editor.
|
||||
*
|
||||
* @type {String}
|
||||
*/
|
||||
this.value = this.attrs.value || '';
|
||||
|
||||
/**
|
||||
* Whether the editor is disabled.
|
||||
*/
|
||||
this.disabled = !!this.attrs.disabled;
|
||||
}
|
||||
|
||||
view() {
|
||||
return (
|
||||
<div className="TextEditor">
|
||||
<textarea
|
||||
className="FormControl Composer-flexible"
|
||||
oninput={(e) => {
|
||||
this.oninput(e.target.value, e);
|
||||
}}
|
||||
placeholder={this.attrs.placeholder || ''}
|
||||
disabled={!!this.attrs.disabled}
|
||||
value={this.value}
|
||||
/>
|
||||
<div className="TextEditor-editorContainer"></div>
|
||||
|
||||
<ul className="TextEditor-controls Composer-footer">
|
||||
{listItems(this.controlItems().toArray())}
|
||||
@@ -53,15 +51,35 @@ export default class TextEditor extends Component {
|
||||
oncreate(vnode) {
|
||||
super.oncreate(vnode);
|
||||
|
||||
const handler = () => {
|
||||
this.onsubmit();
|
||||
m.redraw();
|
||||
this.attrs.composer.editor = this.buildEditor(this.$('.TextEditor-editorContainer')[0]);
|
||||
}
|
||||
|
||||
onupdate() {
|
||||
const newDisabled = !!this.attrs.disabled;
|
||||
|
||||
if (this.disabled !== newDisabled) {
|
||||
this.disabled = newDisabled;
|
||||
this.attrs.composer.editor.disabled(newDisabled);
|
||||
}
|
||||
}
|
||||
|
||||
buildEditorParams() {
|
||||
return {
|
||||
classNames: ['FormControl', 'Composer-flexible', 'TextEditor-editor'],
|
||||
disabled: this.disabled,
|
||||
placeholder: this.attrs.placeholder || '',
|
||||
value: this.value,
|
||||
oninput: this.oninput.bind(this),
|
||||
inputListeners: [],
|
||||
onsubmit: () => {
|
||||
this.onsubmit();
|
||||
m.redraw();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
this.$('textarea').bind('keydown', 'meta+return', handler);
|
||||
this.$('textarea').bind('keydown', 'ctrl+return', handler);
|
||||
|
||||
this.attrs.composer.editor = new SuperTextarea(this.$('textarea')[0]);
|
||||
buildEditor(dom) {
|
||||
return new BasicEditorDriver(dom, this.buildEditorParams());
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -115,12 +133,10 @@ export default class TextEditor extends Component {
|
||||
*
|
||||
* @param {String} value
|
||||
*/
|
||||
oninput(value, e) {
|
||||
oninput(value) {
|
||||
this.value = value;
|
||||
|
||||
this.attrs.onchange(this.value);
|
||||
|
||||
e.redraw = false;
|
||||
}
|
||||
|
||||
/**
|
@@ -1,4 +1,4 @@
|
||||
import Button from '../../common/components/Button';
|
||||
import Button from './Button';
|
||||
|
||||
/**
|
||||
* The `TextEditorButton` component displays a button suitable for the text
|
@@ -1,26 +1,28 @@
|
||||
import * as Mithril from 'mithril';
|
||||
import User from '../models/User';
|
||||
|
||||
/**
|
||||
* The `avatar` helper displays a user's avatar.
|
||||
*
|
||||
* @param {User} user
|
||||
* @param {Object} attrs Attributes to apply to the avatar element
|
||||
* @return {Object}
|
||||
* @param user
|
||||
* @param attrs Attributes to apply to the avatar element
|
||||
*/
|
||||
export default function avatar(user, attrs = {}) {
|
||||
export default function avatar(user: User, attrs: Object = {}): Mithril.Vnode {
|
||||
attrs.className = 'Avatar ' + (attrs.className || '');
|
||||
let content = '';
|
||||
let content: string = '';
|
||||
|
||||
// If the `title` attribute is set to null or false, we don't want to give the
|
||||
// avatar a title. On the other hand, if it hasn't been given at all, we can
|
||||
// safely default it to the user's username.
|
||||
const hasTitle = attrs.title === 'undefined' || attrs.title;
|
||||
const hasTitle: boolean | string = attrs.title === 'undefined' || attrs.title;
|
||||
if (!hasTitle) delete attrs.title;
|
||||
|
||||
// If a user has been passed, then we will set up an avatar using their
|
||||
// uploaded image, or the first letter of their username if they haven't
|
||||
// uploaded one.
|
||||
if (user) {
|
||||
const username = user.displayName() || '?';
|
||||
const avatarUrl = user.avatarUrl();
|
||||
const username: string = user.displayName() || '?';
|
||||
const avatarUrl: string = user.avatarUrl();
|
||||
|
||||
if (hasTitle) attrs.title = attrs.title || username;
|
||||
|
@@ -1,16 +1,16 @@
|
||||
import * as Mithril from 'mithril';
|
||||
import { truncate } from '../utils/string';
|
||||
|
||||
/**
|
||||
* The `highlight` helper searches for a word phrase in a string, and wraps
|
||||
* matches with the <mark> tag.
|
||||
*
|
||||
* @param {String} string The string to highlight.
|
||||
* @param {String|RegExp} phrase The word or words to highlight.
|
||||
* @param {Integer} [length] The number of characters to truncate the string to.
|
||||
* @param string The string to highlight.
|
||||
* @param phrase The word or words to highlight.
|
||||
* @param [length] The number of characters to truncate the string to.
|
||||
* The string will be truncated surrounding the first match.
|
||||
* @return {Object}
|
||||
*/
|
||||
export default function highlight(string, phrase, length) {
|
||||
export default function highlight(string: string, phrase: string | RegExp, length?: number): Mithril.Vnode<any, any> | string {
|
||||
if (!phrase && !length) return string;
|
||||
|
||||
// Convert the word phrase into a global regular expression (if it isn't
|
@@ -1,15 +1,16 @@
|
||||
import * as Mithril from 'mithril';
|
||||
import Separator from '../components/Separator';
|
||||
import classList from '../utils/classList';
|
||||
|
||||
function isSeparator(item) {
|
||||
function isSeparator(item): boolean {
|
||||
return item.tag === Separator;
|
||||
}
|
||||
|
||||
function withoutUnnecessarySeparators(items) {
|
||||
function withoutUnnecessarySeparators(items: Array<Mithril.Vnode>): Array<Mithril.Vnode> {
|
||||
const newItems = [];
|
||||
let prevItem;
|
||||
|
||||
items.filter(Boolean).forEach((item, i) => {
|
||||
items.filter(Boolean).forEach((item: Mithril.Vnode, i: number) => {
|
||||
if (!isSeparator(item) || (prevItem && !isSeparator(prevItem) && i !== items.length - 1)) {
|
||||
prevItem = item;
|
||||
newItems.push(item);
|
||||
@@ -22,14 +23,11 @@ function withoutUnnecessarySeparators(items) {
|
||||
/**
|
||||
* The `listItems` helper wraps a collection of components in <li> tags,
|
||||
* stripping out any unnecessary `Separator` components.
|
||||
*
|
||||
* @param {*} items
|
||||
* @return {Array}
|
||||
*/
|
||||
export default function listItems(items) {
|
||||
export default function listItems(items: Mithril.Vnode | Array<Mithril.Vnode>): Array<Mithril.Vnode> {
|
||||
if (!(items instanceof Array)) items = [items];
|
||||
|
||||
return withoutUnnecessarySeparators(items).map((item) => {
|
||||
return withoutUnnecessarySeparators(items).map((item: Mithril.Vnode) => {
|
||||
const isListItem = item.tag && item.tag.isListItem;
|
||||
const active = item.tag && item.tag.isActive && item.tag.isActive(item.attrs);
|
||||
const className = (item.attrs && item.attrs.itemClassName) || item.itemClassName;
|
||||
@@ -40,7 +38,7 @@ export default function listItems(items) {
|
||||
item.key = item.attrs.key;
|
||||
}
|
||||
|
||||
const node = isListItem ? (
|
||||
const node: Mithril.Vnode = isListItem ? (
|
||||
item
|
||||
) : (
|
||||
<li
|
@@ -1,12 +1,11 @@
|
||||
import * as Mithril from 'mithril';
|
||||
import User from '../models/User';
|
||||
import icon from './icon';
|
||||
|
||||
/**
|
||||
* The `useronline` helper displays a green circle if the user is online
|
||||
*
|
||||
* @param {User} user
|
||||
* @return {Object}
|
||||
*/
|
||||
export default function userOnline(user) {
|
||||
export default function userOnline(user: User): Mithril.Vnode {
|
||||
if (user.lastSeenAt() && user.isOnline()) {
|
||||
return <span className="UserOnline">{icon('fas fa-circle')}</span>;
|
||||
}
|
@@ -1,11 +1,11 @@
|
||||
import * as Mithril from 'mithril';
|
||||
import User from '../models/User';
|
||||
|
||||
/**
|
||||
* The `username` helper displays a user's username in a <span class="username">
|
||||
* tag. If the user doesn't exist, the username will be displayed as [deleted].
|
||||
*
|
||||
* @param {User} user
|
||||
* @return {Object}
|
||||
*/
|
||||
export default function username(user) {
|
||||
export default function username(user: User): Mithril.Vnode {
|
||||
const name = (user && user.displayName()) || app.translator.trans('core.lib.username.deleted_text');
|
||||
|
||||
return <span className="username">{name}</span>;
|
@@ -1,7 +1,6 @@
|
||||
import 'expose-loader?$!expose-loader?jQuery!jquery';
|
||||
import 'expose-loader?m!mithril';
|
||||
import 'expose-loader?dayjs!dayjs';
|
||||
import 'expose-loader?m.bidi!m.attrs.bidi';
|
||||
import 'bootstrap/js/affix';
|
||||
import 'bootstrap/js/dropdown';
|
||||
import 'bootstrap/js/modal';
|
||||
|
@@ -30,6 +30,8 @@ Object.assign(User.prototype, {
|
||||
commentCount: Model.attribute('commentCount'),
|
||||
|
||||
canEdit: Model.attribute('canEdit'),
|
||||
canEditCredentials: Model.attribute('canEditCredentials'),
|
||||
canEditGroups: Model.attribute('canEditGroups'),
|
||||
canDelete: Model.attribute('canDelete'),
|
||||
|
||||
avatarColor: null,
|
||||
|
124
js/src/common/utils/BasicEditorDriver.ts
Normal file
124
js/src/common/utils/BasicEditorDriver.ts
Normal file
@@ -0,0 +1,124 @@
|
||||
import getCaretCoordinates from 'textarea-caret';
|
||||
import EditorDriverInterface, { EditorDriverParams } from './EditorDriverInterface';
|
||||
|
||||
export default class BasicEditorDriver implements EditorDriverInterface {
|
||||
el: HTMLTextAreaElement;
|
||||
|
||||
constructor(dom: HTMLElement, params: EditorDriverParams) {
|
||||
this.el = document.createElement('textarea');
|
||||
|
||||
this.build(dom, params);
|
||||
}
|
||||
|
||||
build(dom: HTMLElement, params: EditorDriverParams) {
|
||||
this.el.className = params.classNames.join(' ');
|
||||
this.el.disabled = params.disabled;
|
||||
this.el.placeholder = params.placeholder;
|
||||
this.el.value = params.value;
|
||||
|
||||
const callInputListeners = (e) => {
|
||||
params.inputListeners.forEach((listener) => {
|
||||
listener();
|
||||
});
|
||||
|
||||
e.redraw = false;
|
||||
};
|
||||
|
||||
this.el.oninput = (e) => {
|
||||
params.oninput(this.el.value);
|
||||
callInputListeners(e);
|
||||
};
|
||||
|
||||
this.el.onclick = callInputListeners;
|
||||
this.el.onkeyup = callInputListeners;
|
||||
|
||||
this.el.addEventListener('keydown', function (e) {
|
||||
if ((e.metaKey || e.ctrlKey) && e.key === 'Enter') {
|
||||
params.onsubmit();
|
||||
}
|
||||
});
|
||||
|
||||
dom.append(this.el);
|
||||
}
|
||||
|
||||
protected setValue(value: string) {
|
||||
$(this.el).val(value).trigger('input');
|
||||
|
||||
this.el.dispatchEvent(new CustomEvent('input', { bubbles: true, cancelable: true }));
|
||||
}
|
||||
|
||||
moveCursorTo(position: number) {
|
||||
this.setSelectionRange(position, position);
|
||||
}
|
||||
|
||||
getSelectionRange(): Array<number> {
|
||||
return [this.el.selectionStart, this.el.selectionEnd];
|
||||
}
|
||||
|
||||
getLastNChars(n: number): string {
|
||||
const value = this.el.value;
|
||||
|
||||
return value.slice(Math.max(0, this.el.selectionStart - n), this.el.selectionStart);
|
||||
}
|
||||
|
||||
insertAtCursor(text: string) {
|
||||
this.insertAt(this.el.selectionStart, text);
|
||||
}
|
||||
|
||||
insertAt(pos: number, text: string) {
|
||||
this.insertBetween(pos, pos, text);
|
||||
}
|
||||
|
||||
insertBetween(start: number, end: number, text: string) {
|
||||
const value = this.el.value;
|
||||
|
||||
const before = value.slice(0, start);
|
||||
const after = value.slice(end);
|
||||
|
||||
this.setValue(`${before}${text}${after}`);
|
||||
|
||||
// Move the textarea cursor to the end of the content we just inserted.
|
||||
this.moveCursorTo(start + text.length);
|
||||
}
|
||||
|
||||
replaceBeforeCursor(start: number, text: string) {
|
||||
this.insertBetween(start, this.el.selectionStart, text);
|
||||
}
|
||||
|
||||
protected setSelectionRange(start: number, end: number) {
|
||||
this.el.setSelectionRange(start, end);
|
||||
this.focus();
|
||||
}
|
||||
|
||||
getCaretCoordinates(position: number) {
|
||||
const relCoords = getCaretCoordinates(this.el, position);
|
||||
|
||||
return {
|
||||
top: relCoords.top - this.el.scrollTop,
|
||||
left: relCoords.left,
|
||||
};
|
||||
}
|
||||
|
||||
// DOM Interactions
|
||||
|
||||
/**
|
||||
* Set the disabled status of the editor.
|
||||
*/
|
||||
disabled(disabled: boolean) {
|
||||
this.el.disabled = disabled;
|
||||
}
|
||||
|
||||
/**
|
||||
* Focus on the editor.
|
||||
*/
|
||||
focus() {
|
||||
this.el.focus();
|
||||
}
|
||||
|
||||
/**
|
||||
* Destroy the editor
|
||||
*/
|
||||
destroy() {
|
||||
this.el.remove();
|
||||
}
|
||||
}
|
105
js/src/common/utils/EditorDriverInterface.ts
Normal file
105
js/src/common/utils/EditorDriverInterface.ts
Normal file
@@ -0,0 +1,105 @@
|
||||
export interface EditorDriverParams {
|
||||
/**
|
||||
* An array of HTML class names to apply to the editor's main DOM element.
|
||||
*/
|
||||
classNames: string[];
|
||||
|
||||
/**
|
||||
* Whether the editor should be initially disabled.
|
||||
*/
|
||||
disabled: boolean;
|
||||
|
||||
/**
|
||||
* An optional placeholder for the editor.
|
||||
*/
|
||||
placeholder: string;
|
||||
|
||||
/**
|
||||
* An optional initial value for the editor.
|
||||
*/
|
||||
value: string;
|
||||
|
||||
/**
|
||||
* This is separate from inputListeners since the full serialized content will be passed to it.
|
||||
* It is considered private API, and should not be used/modified by extensions not implementing
|
||||
* EditorDriverInterface.
|
||||
*/
|
||||
oninput: Function;
|
||||
|
||||
/**
|
||||
* Each of these functions will be called on click, input, and keyup.
|
||||
* No arguments will be passed.
|
||||
*/
|
||||
inputListeners: Function[];
|
||||
|
||||
/**
|
||||
* This function will be called if submission is triggered programmatically via keybind.
|
||||
* No arguments should be passed.
|
||||
*/
|
||||
onsubmit: Function;
|
||||
}
|
||||
|
||||
export default interface EditorDriverInterface {
|
||||
/**
|
||||
* Focus the editor and place the cursor at the given position.
|
||||
*/
|
||||
moveCursorTo(position: number): void;
|
||||
|
||||
/**
|
||||
* Get the selected range of the editor.
|
||||
*/
|
||||
getSelectionRange(): Array<number>;
|
||||
|
||||
/**
|
||||
* Get the last N characters from the current "text block".
|
||||
*
|
||||
* A textarea-based driver would just return the last N characters,
|
||||
* but more advanced implementations might restrict to the current block.
|
||||
*
|
||||
* This is useful for monitoring recent user input to trigger autocomplete.
|
||||
*/
|
||||
getLastNChars(n: number): string;
|
||||
|
||||
/**
|
||||
* Insert content into the editor at the position of the cursor.
|
||||
*/
|
||||
insertAtCursor(text: string, escape: boolean): void;
|
||||
|
||||
/**
|
||||
* Insert content into the editor at the given position.
|
||||
*/
|
||||
insertAt(pos: number, text: string, escape: boolean): void;
|
||||
|
||||
/**
|
||||
* Insert content into the editor between the given positions.
|
||||
*
|
||||
* If the start and end positions are different, any text between them will be
|
||||
* overwritten.
|
||||
*/
|
||||
insertBetween(start: number, end: number, text: string, escape: boolean): void;
|
||||
|
||||
/**
|
||||
* Replace existing content from the start to the current cursor position.
|
||||
*/
|
||||
replaceBeforeCursor(start: number, text: string, escape: boolean): void;
|
||||
|
||||
/**
|
||||
* Get left and top coordinates of the caret relative to the editor viewport.
|
||||
*/
|
||||
getCaretCoordinates(position: number): { left: number; top: number };
|
||||
|
||||
/**
|
||||
* Set the disabled status of the editor.
|
||||
*/
|
||||
disabled(disabled: boolean): void;
|
||||
|
||||
/**
|
||||
* Focus on the editor.
|
||||
*/
|
||||
focus(): void;
|
||||
|
||||
/**
|
||||
* Destroy the editor
|
||||
*/
|
||||
destroy(): void;
|
||||
}
|
@@ -1,80 +0,0 @@
|
||||
export default class Pagination<T> {
|
||||
private readonly loadFunction: (page: number) => Promise<any>;
|
||||
|
||||
public loading = {
|
||||
prev: false,
|
||||
next: false,
|
||||
};
|
||||
|
||||
public page: number;
|
||||
|
||||
public data: { [page: number]: T } = {};
|
||||
|
||||
public pages: {
|
||||
first: number;
|
||||
last: number;
|
||||
};
|
||||
|
||||
constructor(load: (page: number) => Promise<any>, page: number = 1) {
|
||||
this.loadFunction = load;
|
||||
this.page = page;
|
||||
|
||||
this.pages = {
|
||||
first: page,
|
||||
last: page,
|
||||
};
|
||||
}
|
||||
|
||||
clear() {
|
||||
this.data = {};
|
||||
}
|
||||
|
||||
refresh(page: number) {
|
||||
this.clear();
|
||||
|
||||
this.page = page;
|
||||
this.pages.last = page - 1;
|
||||
this.pages.first = page;
|
||||
|
||||
return this.loadNext();
|
||||
}
|
||||
|
||||
loadNext() {
|
||||
this.loading.next = true;
|
||||
const page = this.pages.last + 1;
|
||||
|
||||
return this.load(
|
||||
page,
|
||||
() => (this.loading.next = false),
|
||||
() => (this.pages.last = this.page = page)
|
||||
);
|
||||
}
|
||||
|
||||
loadPrev() {
|
||||
this.loading.prev = true;
|
||||
const page = this.pages.first - 1;
|
||||
|
||||
return this.load(
|
||||
page,
|
||||
() => (this.loading.prev = false),
|
||||
() => (this.pages.first = this.page = page)
|
||||
);
|
||||
}
|
||||
|
||||
private load(page, done, success) {
|
||||
return this.loadFunction(page)
|
||||
.then((out) => {
|
||||
done();
|
||||
success();
|
||||
|
||||
this.data[this.page] = out;
|
||||
|
||||
return out;
|
||||
})
|
||||
.catch((err) => {
|
||||
done();
|
||||
|
||||
return Promise.reject(err);
|
||||
});
|
||||
}
|
||||
}
|
@@ -1,109 +0,0 @@
|
||||
/**
|
||||
* A textarea wrapper with powerful helpers for text manipulation.
|
||||
*
|
||||
* This wraps a <textarea> DOM element and allows directly manipulating its text
|
||||
* contents and cursor positions.
|
||||
*
|
||||
* I apologize for the pretentious name. :)
|
||||
*/
|
||||
export default class SuperTextarea {
|
||||
/**
|
||||
* @param {HTMLTextAreaElement} textarea
|
||||
*/
|
||||
constructor(textarea) {
|
||||
this.el = textarea;
|
||||
this.$ = $(textarea);
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the value of the text editor.
|
||||
*
|
||||
* @param {String} value
|
||||
*/
|
||||
setValue(value) {
|
||||
this.$.val(value).trigger('input');
|
||||
|
||||
this.el.dispatchEvent(new CustomEvent('input', { bubbles: true, cancelable: true }));
|
||||
}
|
||||
|
||||
/**
|
||||
* Focus the textarea and place the cursor at the given index.
|
||||
*
|
||||
* @param {number} position
|
||||
*/
|
||||
moveCursorTo(position) {
|
||||
this.setSelectionRange(position, position);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the selected range of the textarea.
|
||||
*
|
||||
* @return {Array}
|
||||
*/
|
||||
getSelectionRange() {
|
||||
return [this.el.selectionStart, this.el.selectionEnd];
|
||||
}
|
||||
|
||||
/**
|
||||
* Insert content into the textarea at the position of the cursor.
|
||||
*
|
||||
* @param {String} text
|
||||
*/
|
||||
insertAtCursor(text) {
|
||||
this.insertAt(this.el.selectionStart, text);
|
||||
}
|
||||
|
||||
/**
|
||||
* Insert content into the textarea at the given position.
|
||||
*
|
||||
* @param {number} pos
|
||||
* @param {String} text
|
||||
*/
|
||||
insertAt(pos, text) {
|
||||
this.insertBetween(pos, pos, text);
|
||||
}
|
||||
|
||||
/**
|
||||
* Insert content into the textarea between the given positions.
|
||||
*
|
||||
* If the start and end positions are different, any text between them will be
|
||||
* overwritten.
|
||||
*
|
||||
* @param start
|
||||
* @param end
|
||||
* @param text
|
||||
*/
|
||||
insertBetween(start, end, text) {
|
||||
const value = this.el.value;
|
||||
|
||||
const before = value.slice(0, start);
|
||||
const after = value.slice(end);
|
||||
|
||||
this.setValue(`${before}${text}${after}`);
|
||||
|
||||
// Move the textarea cursor to the end of the content we just inserted.
|
||||
this.moveCursorTo(start + text.length);
|
||||
}
|
||||
|
||||
/**
|
||||
* Replace existing content from the start to the current cursor position.
|
||||
*
|
||||
* @param start
|
||||
* @param text
|
||||
*/
|
||||
replaceBeforeCursor(start, text) {
|
||||
this.insertBetween(start, this.el.selectionStart, text);
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the selected range of the textarea.
|
||||
*
|
||||
* @param {number} start
|
||||
* @param {number} end
|
||||
* @private
|
||||
*/
|
||||
setSelectionRange(start, end) {
|
||||
this.el.setSelectionRange(start, end);
|
||||
this.$.focus();
|
||||
}
|
||||
}
|
50
js/src/common/utils/bidi.js
Normal file
50
js/src/common/utils/bidi.js
Normal file
@@ -0,0 +1,50 @@
|
||||
function bidi(node, prop) {
|
||||
var type = node.tag === 'select' ? (node.attrs.multi ? 'multi' : 'select') : node.attrs.type;
|
||||
|
||||
// Setup: bind listeners
|
||||
if (type === 'multi') {
|
||||
node.attrs.onchange = function () {
|
||||
prop(
|
||||
[].slice.call(this.selectedOptions, function (x) {
|
||||
return x.value;
|
||||
})
|
||||
);
|
||||
};
|
||||
} else if (type === 'select') {
|
||||
node.attrs.onchange = function (e) {
|
||||
prop(this.selectedOptions[0].value);
|
||||
};
|
||||
} else if (type === 'checkbox') {
|
||||
node.attrs.onchange = function (e) {
|
||||
prop(this.checked);
|
||||
};
|
||||
} else {
|
||||
node.attrs.onchange = node.attrs.oninput = function (e) {
|
||||
prop(this.value);
|
||||
};
|
||||
}
|
||||
|
||||
if (node.tag === 'select') {
|
||||
node.children.forEach(function (option) {
|
||||
if (option.attrs.value === prop() || option.children[0] === prop()) {
|
||||
option.attrs.selected = true;
|
||||
}
|
||||
});
|
||||
} else if (type === 'checkbox') {
|
||||
node.attrs.checked = prop();
|
||||
} else if (type === 'radio') {
|
||||
node.attrs.checked = prop() === node.attrs.value;
|
||||
} else {
|
||||
node.attrs.value = prop();
|
||||
}
|
||||
|
||||
node.attrs.bidi = null;
|
||||
|
||||
return node;
|
||||
}
|
||||
|
||||
bidi.view = function (ctrl, node, prop) {
|
||||
return bidi(node, node.attrs.bidi);
|
||||
};
|
||||
|
||||
export default bidi;
|
@@ -1,3 +1,5 @@
|
||||
import bidi from './bidi';
|
||||
|
||||
export default function patchMithril(global) {
|
||||
const defaultMithril = global.m;
|
||||
|
||||
@@ -8,7 +10,7 @@ export default function patchMithril(global) {
|
||||
|
||||
// Allows the use of the bidi attr.
|
||||
if (node.attrs.bidi) {
|
||||
modifiedMithril.bidi(node, node.attrs.bidi);
|
||||
bidi(node, node.attrs.bidi);
|
||||
}
|
||||
|
||||
return node;
|
||||
|
@@ -16,6 +16,7 @@ import NotificationListState from './states/NotificationListState';
|
||||
import GlobalSearchState from './states/GlobalSearchState';
|
||||
import DiscussionListState from './states/DiscussionListState';
|
||||
import ComposerState from './states/ComposerState';
|
||||
import isSafariMobile from './utils/isSafariMobile';
|
||||
|
||||
export default class ForumApplication extends Application {
|
||||
/**
|
||||
@@ -138,6 +139,12 @@ export default class ForumApplication extends Application {
|
||||
m.redraw();
|
||||
}
|
||||
});
|
||||
|
||||
if (isSafariMobile()) {
|
||||
$(() => {
|
||||
$('.App').addClass('mobile-safari');
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
@@ -16,6 +16,7 @@ import PostStreamState from './states/PostStreamState';
|
||||
import SearchState from './states/SearchState';
|
||||
import AffixedSidebar from './components/AffixedSidebar';
|
||||
import DiscussionPage from './components/DiscussionPage';
|
||||
import DiscussionListPane from './components/DiscussionListPane';
|
||||
import LogInModal from './components/LogInModal';
|
||||
import ComposerBody from './components/ComposerBody';
|
||||
import ForgotPasswordModal from './components/ForgotPasswordModal';
|
||||
@@ -35,8 +36,8 @@ import HeaderSecondary from './components/HeaderSecondary';
|
||||
import ComposerButton from './components/ComposerButton';
|
||||
import DiscussionList from './components/DiscussionList';
|
||||
import ReplyPlaceholder from './components/ReplyPlaceholder';
|
||||
import TextEditor from './components/TextEditor';
|
||||
import TextEditorButton from './components/TextEditorButton';
|
||||
import TextEditor from '../common/components/TextEditor'; // @deprecated beta 16, remove beta 17. Moved to common.
|
||||
import TextEditorButton from '../common/components/TextEditorButton'; // @deprecated beta 16, remove beta 17. Moved to common.
|
||||
import AvatarEditor from './components/AvatarEditor';
|
||||
import Post from './components/Post';
|
||||
import SettingsPage from './components/SettingsPage';
|
||||
@@ -72,6 +73,7 @@ import DiscussionListItem from './components/DiscussionListItem';
|
||||
import LoadingPost from './components/LoadingPost';
|
||||
import PostsUserPage from './components/PostsUserPage';
|
||||
import DiscussionPageResolver from './resolvers/DiscussionPageResolver';
|
||||
import BasicEditorDriver from '../common/utils/BasicEditorDriver';
|
||||
import routes from './routes';
|
||||
import ForumApplication from './ForumApplication';
|
||||
|
||||
@@ -84,6 +86,8 @@ export default Object.assign(compat, {
|
||||
'utils/alertEmailConfirmation': alertEmailConfirmation,
|
||||
'utils/UserControls': UserControls,
|
||||
'utils/Pane': Pane,
|
||||
'utils/BasicEditorDriver': BasicEditorDriver,
|
||||
'utils/SuperTextarea': BasicEditorDriver, // @deprecated beta 16, remove beta 17
|
||||
'states/ComposerState': ComposerState,
|
||||
'states/DiscussionListState': DiscussionListState,
|
||||
'states/GlobalSearchState': GlobalSearchState,
|
||||
@@ -92,6 +96,7 @@ export default Object.assign(compat, {
|
||||
'states/SearchState': SearchState,
|
||||
'components/AffixedSidebar': AffixedSidebar,
|
||||
'components/DiscussionPage': DiscussionPage,
|
||||
'components/DiscussionListPane': DiscussionListPane,
|
||||
'components/LogInModal': LogInModal,
|
||||
'components/ComposerBody': ComposerBody,
|
||||
'components/ForgotPasswordModal': ForgotPasswordModal,
|
||||
@@ -111,8 +116,8 @@ export default Object.assign(compat, {
|
||||
'components/ComposerButton': ComposerButton,
|
||||
'components/DiscussionList': DiscussionList,
|
||||
'components/ReplyPlaceholder': ReplyPlaceholder,
|
||||
'components/TextEditor': TextEditor,
|
||||
'components/TextEditorButton': TextEditorButton,
|
||||
'components/TextEditor': TextEditor, // @deprecated beta 16, remove beta 17. Moved to common.
|
||||
'components/TextEditorButton': TextEditorButton, // @deprecated beta 16, remove beta 17. Moved to common.
|
||||
'components/AvatarEditor': AvatarEditor,
|
||||
'components/Post': Post,
|
||||
'components/SettingsPage': SettingsPage,
|
||||
|
@@ -106,9 +106,8 @@ export default class ChangeEmailModal extends Modal {
|
||||
return;
|
||||
}
|
||||
|
||||
const oldEmail = app.session.user.email();
|
||||
|
||||
this.loading = true;
|
||||
this.alertAttrs = null;
|
||||
|
||||
app.session.user
|
||||
.save(
|
||||
@@ -120,7 +119,6 @@ export default class ChangeEmailModal extends Modal {
|
||||
)
|
||||
.then(() => {
|
||||
this.success = true;
|
||||
this.alertAttrs = null;
|
||||
})
|
||||
.catch(() => {})
|
||||
.then(this.loaded.bind(this));
|
||||
|
@@ -76,13 +76,13 @@ export default class Composer extends Component {
|
||||
|
||||
// Whenever any of the inputs inside the composer are have focus, we want to
|
||||
// add a class to the composer to draw attention to it.
|
||||
this.$().on('focus blur', ':input', (e) => {
|
||||
this.$().on('focus blur', ':input,.TextEditor-editorContainer', (e) => {
|
||||
this.active = e.type === 'focusin';
|
||||
m.redraw();
|
||||
});
|
||||
|
||||
// When the escape key is pressed on any inputs, close the composer.
|
||||
this.$().on('keydown', ':input', 'esc', () => this.state.close());
|
||||
this.$().on('keydown', ':input,.TextEditor-editorContainer', 'esc', () => this.state.close());
|
||||
|
||||
this.handlers = {};
|
||||
|
||||
@@ -157,7 +157,7 @@ export default class Composer extends Component {
|
||||
* Draw focus to the first focusable content element (the text editor).
|
||||
*/
|
||||
focus() {
|
||||
this.$('.Composer-content :input:enabled:visible:first').focus();
|
||||
this.$('.Composer-content :input:enabled:visible, .TextEditor-editor').first().focus();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -265,7 +265,17 @@ export default class Composer extends Component {
|
||||
this.animateHeightChange().then(() => this.focus());
|
||||
|
||||
if (app.screen() === 'phone') {
|
||||
this.$().css('top', 0);
|
||||
// On safari fixed position doesn't properly work on mobile,
|
||||
// So we use absolute and set the top value.
|
||||
// https://github.com/flarum/core/issues/2652
|
||||
|
||||
// Due to another safari bug, `scrollTop` is unreliable when
|
||||
// at the very bottom of the page AND opening the composer.
|
||||
// So we fallback to a calculated version of scrollTop.
|
||||
// https://github.com/flarum/core/issues/2683
|
||||
const scrollElement = document.documentElement;
|
||||
const topOfViewport = Math.min(scrollElement.scrollTop, scrollElement.scrollHeight - scrollElement.clientHeight);
|
||||
this.$().css('top', $('.App').is('.mobile-safari') ? topOfViewport : 0);
|
||||
this.showBackdrop();
|
||||
}
|
||||
}
|
||||
|
@@ -1,7 +1,7 @@
|
||||
import Component from '../../common/Component';
|
||||
import LoadingIndicator from '../../common/components/LoadingIndicator';
|
||||
import ConfirmDocumentUnload from '../../common/components/ConfirmDocumentUnload';
|
||||
import TextEditor from './TextEditor';
|
||||
import TextEditor from '../../common/components/TextEditor';
|
||||
import avatar from '../../common/helpers/avatar';
|
||||
import listItems from '../../common/helpers/listItems';
|
||||
import ItemList from '../../common/utils/ItemList';
|
||||
|
@@ -21,7 +21,13 @@ export default class DiscussionList extends Component {
|
||||
if (state.isLoading()) {
|
||||
loading = LoadingIndicator.component();
|
||||
} else if (state.moreResults) {
|
||||
loading = this.getLoadButton('more', state.loadMore.bind(state));
|
||||
loading = Button.component(
|
||||
{
|
||||
className: 'Button',
|
||||
onclick: state.loadMore.bind(state),
|
||||
},
|
||||
app.translator.trans('core.forum.discussion_list.load_more_button')
|
||||
);
|
||||
}
|
||||
|
||||
if (state.empty()) {
|
||||
@@ -29,18 +35,8 @@ export default class DiscussionList extends Component {
|
||||
return <div className="DiscussionList">{Placeholder.component({ text })}</div>;
|
||||
}
|
||||
|
||||
console.log(state);
|
||||
|
||||
return (
|
||||
<div className={'DiscussionList' + (state.isSearchResults() ? ' DiscussionList--searchResults' : '')}>
|
||||
{state.isLoadingPrev() ? (
|
||||
<LoadingIndicator />
|
||||
) : state.pagination.pages.first !== 1 ? (
|
||||
<div className="DiscussionList-loadMore">{this.getLoadButton('prev', state.loadPrev.bind(state))}</div>
|
||||
) : (
|
||||
''
|
||||
)}
|
||||
|
||||
<ul className="DiscussionList-discussions">
|
||||
{state.discussions.map((discussion) => {
|
||||
return (
|
||||
@@ -50,17 +46,8 @@ export default class DiscussionList extends Component {
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
|
||||
<div className="DiscussionList-loadMore">{loading}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
getLoadButton(key, onclick) {
|
||||
return (
|
||||
<Button className="Button" onclick={onclick}>
|
||||
{app.translator.trans(`core.forum.discussion_list.load_${key}_button`)}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
@@ -121,6 +121,8 @@ export default class DiscussionListItem extends Component {
|
||||
</Link>
|
||||
|
||||
<span
|
||||
tabindex="0"
|
||||
role="button"
|
||||
className="DiscussionListItem-count"
|
||||
onclick={this.markAsRead.bind(this)}
|
||||
title={showUnread ? app.translator.trans('core.forum.discussion_list.mark_as_read_tooltip') : ''}
|
||||
|
@@ -1,5 +1,6 @@
|
||||
import DiscussionList from './DiscussionList';
|
||||
import Component from '../../common/Component';
|
||||
import DiscussionPage from './DiscussionPage';
|
||||
|
||||
const hotEdge = (e) => {
|
||||
if (e.pageX < 10) app.pane.show();
|
||||
@@ -36,23 +37,31 @@ export default class DiscussionListPane extends Component {
|
||||
|
||||
$(document).on('mousemove', hotEdge);
|
||||
|
||||
// If the discussion we are viewing is listed in the discussion list, then
|
||||
// we will make sure it is visible in the viewport – if it is not we will
|
||||
// scroll the list down to it.
|
||||
const $discussion = $list.find('.DiscussionListItem.active');
|
||||
if ($discussion.length) {
|
||||
const listTop = $list.offset().top;
|
||||
const listBottom = listTop + $list.outerHeight();
|
||||
const discussionTop = $discussion.offset().top;
|
||||
const discussionBottom = discussionTop + $discussion.outerHeight();
|
||||
// When coming from another discussion, scroll to the previous postition
|
||||
// to prevent the discussion list jumping around.
|
||||
if (app.previous.matches(DiscussionPage)) {
|
||||
const top = app.cache.discussionListPaneScrollTop || 0;
|
||||
$list.scrollTop(top);
|
||||
} else {
|
||||
// If the discussion we are viewing is listed in the discussion list, then
|
||||
// we will make sure it is visible in the viewport – if it is not we will
|
||||
// scroll the list down to it.
|
||||
const $discussion = $list.find('.DiscussionListItem.active');
|
||||
if ($discussion.length) {
|
||||
const listTop = $list.offset().top;
|
||||
const listBottom = listTop + $list.outerHeight();
|
||||
const discussionTop = $discussion.offset().top;
|
||||
const discussionBottom = discussionTop + $discussion.outerHeight();
|
||||
|
||||
if (discussionTop < listTop || discussionBottom > listBottom) {
|
||||
$list.scrollTop($list.scrollTop() - listTop + discussionTop);
|
||||
if (discussionTop < listTop || discussionBottom > listBottom) {
|
||||
$list.scrollTop($list.scrollTop() - listTop + discussionTop);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
onremove() {
|
||||
onremove(vnode) {
|
||||
app.cache.discussionListPaneScrollTop = $(vnode.dom).scrollTop();
|
||||
$(document).off('mousemove', hotEdge);
|
||||
}
|
||||
|
||||
|
@@ -37,9 +37,10 @@ export default class EditUserModal extends Modal {
|
||||
}
|
||||
|
||||
content() {
|
||||
const fields = this.fields().toArray();
|
||||
return (
|
||||
<div className="Modal-body">
|
||||
<div className="Form">{this.fields().toArray()}</div>
|
||||
{fields.length > 1 ? <div className="Form">{this.fields().toArray()}</div> : app.translator.trans('core.forum.edit_user.nothing_available')}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -47,96 +48,112 @@ export default class EditUserModal extends Modal {
|
||||
fields() {
|
||||
const items = new ItemList();
|
||||
|
||||
items.add(
|
||||
'username',
|
||||
<div className="Form-group">
|
||||
<label>{app.translator.trans('core.forum.edit_user.username_heading')}</label>
|
||||
<input className="FormControl" placeholder={extractText(app.translator.trans('core.forum.edit_user.username_label'))} bidi={this.username} />
|
||||
</div>,
|
||||
40
|
||||
);
|
||||
|
||||
if (app.session.user !== this.attrs.user) {
|
||||
if (app.session.user.canEditCredentials()) {
|
||||
items.add(
|
||||
'email',
|
||||
'username',
|
||||
<div className="Form-group">
|
||||
<label>{app.translator.trans('core.forum.edit_user.email_heading')}</label>
|
||||
<div>
|
||||
<input className="FormControl" placeholder={extractText(app.translator.trans('core.forum.edit_user.email_label'))} bidi={this.email} />
|
||||
</div>
|
||||
{!this.isEmailConfirmed() ? (
|
||||
<div>
|
||||
{Button.component(
|
||||
{
|
||||
className: 'Button Button--block',
|
||||
loading: this.loading,
|
||||
onclick: this.activate.bind(this),
|
||||
},
|
||||
app.translator.trans('core.forum.edit_user.activate_button')
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
''
|
||||
)}
|
||||
<label>{app.translator.trans('core.forum.edit_user.username_heading')}</label>
|
||||
<input
|
||||
className="FormControl"
|
||||
placeholder={extractText(app.translator.trans('core.forum.edit_user.username_label'))}
|
||||
bidi={this.username}
|
||||
disabled={this.nonAdminEditingAdmin()}
|
||||
/>
|
||||
</div>,
|
||||
30
|
||||
40
|
||||
);
|
||||
|
||||
items.add(
|
||||
'password',
|
||||
<div className="Form-group">
|
||||
<label>{app.translator.trans('core.forum.edit_user.password_heading')}</label>
|
||||
<div>
|
||||
<label className="checkbox">
|
||||
<input
|
||||
type="checkbox"
|
||||
onchange={(e) => {
|
||||
this.setPassword(e.target.checked);
|
||||
m.redraw.sync();
|
||||
if (e.target.checked) this.$('[name=password]').select();
|
||||
e.redraw = false;
|
||||
}}
|
||||
/>
|
||||
{app.translator.trans('core.forum.edit_user.set_password_label')}
|
||||
</label>
|
||||
{this.setPassword() ? (
|
||||
if (app.session.user !== this.attrs.user) {
|
||||
items.add(
|
||||
'email',
|
||||
<div className="Form-group">
|
||||
<label>{app.translator.trans('core.forum.edit_user.email_heading')}</label>
|
||||
<div>
|
||||
<input
|
||||
className="FormControl"
|
||||
type="password"
|
||||
name="password"
|
||||
placeholder={extractText(app.translator.trans('core.forum.edit_user.password_label'))}
|
||||
bidi={this.password}
|
||||
placeholder={extractText(app.translator.trans('core.forum.edit_user.email_label'))}
|
||||
bidi={this.email}
|
||||
disabled={this.nonAdminEditingAdmin()}
|
||||
/>
|
||||
</div>
|
||||
{!this.isEmailConfirmed() && this.userIsAdmin(app.session.user) ? (
|
||||
<div>
|
||||
{Button.component(
|
||||
{
|
||||
className: 'Button Button--block',
|
||||
loading: this.loading,
|
||||
onclick: this.activate.bind(this),
|
||||
},
|
||||
app.translator.trans('core.forum.edit_user.activate_button')
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
''
|
||||
)}
|
||||
</div>
|
||||
</div>,
|
||||
20
|
||||
);
|
||||
}
|
||||
</div>,
|
||||
30
|
||||
);
|
||||
|
||||
items.add(
|
||||
'groups',
|
||||
<div className="Form-group EditUserModal-groups">
|
||||
<label>{app.translator.trans('core.forum.edit_user.groups_heading')}</label>
|
||||
<div>
|
||||
{Object.keys(this.groups)
|
||||
.map((id) => app.store.getById('groups', id))
|
||||
.map((group) => (
|
||||
items.add(
|
||||
'password',
|
||||
<div className="Form-group">
|
||||
<label>{app.translator.trans('core.forum.edit_user.password_heading')}</label>
|
||||
<div>
|
||||
<label className="checkbox">
|
||||
<input
|
||||
type="checkbox"
|
||||
bidi={this.groups[group.id()]}
|
||||
disabled={this.attrs.user.id() === '1' && group.id() === Group.ADMINISTRATOR_ID}
|
||||
onchange={(e) => {
|
||||
this.setPassword(e.target.checked);
|
||||
m.redraw.sync();
|
||||
if (e.target.checked) this.$('[name=password]').select();
|
||||
e.redraw = false;
|
||||
}}
|
||||
disabled={this.nonAdminEditingAdmin()}
|
||||
/>
|
||||
{GroupBadge.component({ group, label: '' })} {group.nameSingular()}
|
||||
{app.translator.trans('core.forum.edit_user.set_password_label')}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>,
|
||||
10
|
||||
);
|
||||
{this.setPassword() ? (
|
||||
<input
|
||||
className="FormControl"
|
||||
type="password"
|
||||
name="password"
|
||||
placeholder={extractText(app.translator.trans('core.forum.edit_user.password_label'))}
|
||||
bidi={this.password}
|
||||
disabled={this.nonAdminEditingAdmin()}
|
||||
/>
|
||||
) : (
|
||||
''
|
||||
)}
|
||||
</div>
|
||||
</div>,
|
||||
20
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (app.session.user.canEditGroups()) {
|
||||
items.add(
|
||||
'groups',
|
||||
<div className="Form-group EditUserModal-groups">
|
||||
<label>{app.translator.trans('core.forum.edit_user.groups_heading')}</label>
|
||||
<div>
|
||||
{Object.keys(this.groups)
|
||||
.map((id) => app.store.getById('groups', id))
|
||||
.map((group) => (
|
||||
<label className="checkbox">
|
||||
<input
|
||||
type="checkbox"
|
||||
bidi={this.groups[group.id()]}
|
||||
disabled={group.id() === Group.ADMINISTRATOR_ID && (this.attrs.user === app.session.user || !this.userIsAdmin(app.session.user))}
|
||||
/>
|
||||
{GroupBadge.component({ group, label: '' })} {group.nameSingular()}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>,
|
||||
10
|
||||
);
|
||||
}
|
||||
|
||||
items.add(
|
||||
'submit',
|
||||
@@ -176,21 +193,26 @@ export default class EditUserModal extends Modal {
|
||||
}
|
||||
|
||||
data() {
|
||||
const groups = Object.keys(this.groups)
|
||||
.filter((id) => this.groups[id]())
|
||||
.map((id) => app.store.getById('groups', id));
|
||||
|
||||
const data = {
|
||||
username: this.username(),
|
||||
relationships: { groups },
|
||||
relationships: {},
|
||||
};
|
||||
|
||||
if (app.session.user !== this.attrs.user) {
|
||||
data.email = this.email();
|
||||
if (this.attrs.user.canEditCredentials() && !this.nonAdminEditingAdmin()) {
|
||||
data.username = this.username();
|
||||
|
||||
if (app.session.user !== this.attrs.user) {
|
||||
data.email = this.email();
|
||||
}
|
||||
|
||||
if (this.setPassword()) {
|
||||
data.password = this.password();
|
||||
}
|
||||
}
|
||||
|
||||
if (this.setPassword()) {
|
||||
data.password = this.password();
|
||||
if (this.attrs.user.canEditGroups()) {
|
||||
data.relationships.groups = Object.keys(this.groups)
|
||||
.filter((id) => this.groups[id]())
|
||||
.map((id) => app.store.getById('groups', id));
|
||||
}
|
||||
|
||||
return data;
|
||||
@@ -209,4 +231,16 @@ export default class EditUserModal extends Modal {
|
||||
m.redraw();
|
||||
});
|
||||
}
|
||||
|
||||
nonAdminEditingAdmin() {
|
||||
return this.userIsAdmin(this.attrs.user) && !this.userIsAdmin(app.session.user);
|
||||
}
|
||||
|
||||
/**
|
||||
* @internal
|
||||
* @protected
|
||||
*/
|
||||
userIsAdmin(user) {
|
||||
return user.groups().some((g) => g.id() === Group.ADMINISTRATOR_ID);
|
||||
}
|
||||
}
|
||||
|
@@ -99,7 +99,9 @@ export default class NotificationList extends Component {
|
||||
super.oncreate(vnode);
|
||||
|
||||
this.$notifications = this.$('.NotificationList-content');
|
||||
this.$scrollParent = this.$notifications.css('overflow') === 'auto' ? this.$notifications : $(window);
|
||||
|
||||
// If we are on the notifications page, the window will be scrolling and not the $notifications element.
|
||||
this.$scrollParent = this.inPanel() ? this.$notifications : $(window);
|
||||
|
||||
this.boundScrollHandler = this.scrollHandler.bind(this);
|
||||
this.$scrollParent.on('scroll', this.boundScrollHandler);
|
||||
@@ -112,14 +114,24 @@ export default class NotificationList extends Component {
|
||||
scrollHandler() {
|
||||
const state = this.attrs.state;
|
||||
|
||||
const scrollTop = this.$scrollParent.scrollTop();
|
||||
const viewportHeight = this.$scrollParent.height();
|
||||
// Whole-page scroll events are listened to on `window`, but we need to get the actual
|
||||
// scrollHeight, scrollTop, and clientHeight from the document element.
|
||||
const scrollParent = this.inPanel() ? this.$scrollParent[0] : document.documentElement;
|
||||
|
||||
const contentTop = this.$scrollParent === this.$notifications ? 0 : this.$notifications.offset().top;
|
||||
const contentHeight = this.$notifications[0].scrollHeight;
|
||||
// On very short screens, the scrollHeight + scrollTop might not reach the clientHeight
|
||||
// by a fraction of a pixel, so we compensate for that.
|
||||
const atBottom = Math.abs(scrollParent.scrollHeight - scrollParent.scrollTop - scrollParent.clientHeight) <= 1;
|
||||
|
||||
if (state.hasMoreResults() && !state.isLoading() && scrollTop + viewportHeight >= contentTop + contentHeight) {
|
||||
if (state.hasMoreResults() && !state.isLoading() && atBottom) {
|
||||
state.loadMore();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* If the NotificationList component isn't in a panel (e.g. on NotificationPage when mobile),
|
||||
* we need to listen to scroll events on the window, and get scroll state from the body.
|
||||
*/
|
||||
inPanel() {
|
||||
return this.$notifications.css('overflow') === 'auto';
|
||||
}
|
||||
}
|
||||
|
@@ -142,18 +142,29 @@ export default class PostStream extends Component {
|
||||
}
|
||||
|
||||
/**
|
||||
* When the window is scrolled, check if either extreme of the post stream is
|
||||
* in the viewport, and if so, trigger loading the next/previous page.
|
||||
*
|
||||
* @param {Integer} top
|
||||
*/
|
||||
onscroll(top = window.pageYOffset) {
|
||||
if (this.stream.paused) return;
|
||||
if (this.stream.paused || this.stream.pagesLoading) return;
|
||||
|
||||
this.updateScrubber(top);
|
||||
|
||||
if (this.stream.pagesLoading) return;
|
||||
this.loadPostsIfNeeded(top);
|
||||
|
||||
// Throttle calculation of our position (start/end numbers of posts in the
|
||||
// viewport) to 100ms.
|
||||
clearTimeout(this.calculatePositionTimeout);
|
||||
this.calculatePositionTimeout = setTimeout(this.calculatePosition.bind(this, top), 100);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if either extreme of the post stream is in the viewport,
|
||||
* and if so, trigger loading the next/previous page.
|
||||
*
|
||||
* @param {Integer} top
|
||||
*/
|
||||
loadPostsIfNeeded(top = window.pageYOffset) {
|
||||
const marginTop = this.getMarginTop();
|
||||
const viewportHeight = $(window).height() - marginTop;
|
||||
const viewportTop = top + marginTop;
|
||||
@@ -174,11 +185,6 @@ export default class PostStream extends Component {
|
||||
this.stream.loadNext();
|
||||
}
|
||||
}
|
||||
|
||||
// Throttle calculation of our position (start/end numbers of posts in the
|
||||
// viewport) to 100ms.
|
||||
clearTimeout(this.calculatePositionTimeout);
|
||||
this.calculatePositionTimeout = setTimeout(this.calculatePosition.bind(this, top), 100);
|
||||
}
|
||||
|
||||
updateScrubber(top = window.pageYOffset) {
|
||||
@@ -399,6 +405,8 @@ export default class PostStream extends Component {
|
||||
|
||||
this.calculatePosition();
|
||||
this.stream.paused = false;
|
||||
// Check if we need to load more posts after scrolling.
|
||||
this.loadPostsIfNeeded();
|
||||
});
|
||||
}
|
||||
|
||||
|
@@ -121,7 +121,7 @@ export default class PostsUserPage extends UserPage {
|
||||
loadResults(offset) {
|
||||
return app.store.find('posts', {
|
||||
filter: {
|
||||
user: this.user.id(),
|
||||
author: this.user.username(),
|
||||
type: 'comment',
|
||||
},
|
||||
page: { offset, limit: this.loadLimit },
|
||||
|
@@ -5,6 +5,7 @@ import avatar from '../../common/helpers/avatar';
|
||||
import username from '../../common/helpers/username';
|
||||
import DiscussionControls from '../utils/DiscussionControls';
|
||||
import ComposerPostPreview from './ComposerPostPreview';
|
||||
import listItems from '../../common/helpers/listItems';
|
||||
|
||||
/**
|
||||
* The `ReplyPlaceholder` component displays a placeholder for a reply, which,
|
||||
@@ -25,6 +26,7 @@ export default class ReplyPlaceholder extends Component {
|
||||
{avatar(app.session.user, { className: 'PostUser-avatar' })}
|
||||
{username(app.session.user)}
|
||||
</h3>
|
||||
<ul className="PostUser-badges badges">{listItems(app.session.user.badges().toArray())}</ul>
|
||||
</div>
|
||||
</header>
|
||||
<ComposerPostPreview className="Post-body" composer={app.composer} surround={this.anchorPreview.bind(this)} />
|
||||
|
@@ -113,6 +113,16 @@ export default class Search extends Component {
|
||||
onupdate() {
|
||||
// Highlight the item that is currently selected.
|
||||
this.setIndex(this.getCurrentNumericIndex());
|
||||
|
||||
// If there are no sources, the search view is not shown.
|
||||
if (!this.sources.length) return;
|
||||
|
||||
// Since extensions might add elements above the search box on mobile,
|
||||
// we need to calculate and set the max height dynamically.
|
||||
const resultsElementMargin = 14;
|
||||
const maxHeight =
|
||||
window.innerHeight - this.element.querySelector('.Search-input>.FormControl').getBoundingClientRect().bottom - resultsElementMargin;
|
||||
this.element.querySelector('.Search-results').style['max-height'] = `${maxHeight}px`;
|
||||
}
|
||||
|
||||
oncreate(vnode) {
|
||||
|
@@ -1,6 +1,7 @@
|
||||
import subclassOf from '../../common/utils/subclassOf';
|
||||
import Stream from '../../common/utils/Stream';
|
||||
import ReplyComposer from '../components/ReplyComposer';
|
||||
import EditorDriverInterface from '../../common/utils/EditorDriverInterface';
|
||||
|
||||
class ComposerState {
|
||||
constructor() {
|
||||
@@ -29,7 +30,7 @@ class ComposerState {
|
||||
/**
|
||||
* A reference to the text editor that allows text manipulation.
|
||||
*
|
||||
* @type {SuperTextArea|null}
|
||||
* @type {EditorDriverInterface|null}
|
||||
*/
|
||||
this.editor = null;
|
||||
|
||||
@@ -66,12 +67,16 @@ class ComposerState {
|
||||
clear() {
|
||||
this.position = ComposerState.Position.HIDDEN;
|
||||
this.body = { attrs: {} };
|
||||
this.editor = null;
|
||||
this.onExit = null;
|
||||
|
||||
this.fields = {
|
||||
content: Stream(''),
|
||||
};
|
||||
|
||||
if (this.editor) {
|
||||
this.editor.destroy();
|
||||
}
|
||||
this.editor = null;
|
||||
}
|
||||
|
||||
/**
|
||||
|
@@ -1,8 +1,4 @@
|
||||
import Pagination from '../../common/utils/Pagination';
|
||||
|
||||
export default class DiscussionListState {
|
||||
static DISCUSSIONS_PER_PAGE = 20;
|
||||
|
||||
constructor(params = {}, app = window.app) {
|
||||
this.params = params;
|
||||
|
||||
@@ -12,7 +8,7 @@ export default class DiscussionListState {
|
||||
|
||||
this.moreResults = false;
|
||||
|
||||
this.pagination = new Pagination(this.load.bind(this));
|
||||
this.loading = false;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -86,16 +82,33 @@ export default class DiscussionListState {
|
||||
* This can be used to refresh discussions without loading animations.
|
||||
*/
|
||||
refresh({ deferClear = false } = {}) {
|
||||
this.pagination.loading.next = true;
|
||||
this.loading = true;
|
||||
|
||||
if (!deferClear) {
|
||||
this.clear();
|
||||
}
|
||||
|
||||
return this.pagination.refresh(Number(m.route.param('page')) || 1).then(this.parse.bind(this));
|
||||
return this.loadResults().then(
|
||||
(results) => {
|
||||
// This ensures that any changes made while waiting on this request
|
||||
// are ignored. Otherwise, we could get duplicate discussions.
|
||||
// We don't use `this.clear()` to avoid an unnecessary redraw.
|
||||
this.discussions = [];
|
||||
this.parseResults(results);
|
||||
},
|
||||
() => {
|
||||
this.loading = false;
|
||||
m.redraw();
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
load(page) {
|
||||
/**
|
||||
* Load a new page of discussion results.
|
||||
*
|
||||
* @param offset The index to start the page at.
|
||||
*/
|
||||
loadResults(offset) {
|
||||
const preloadedDiscussions = this.app.preloadedApiDocument();
|
||||
|
||||
if (preloadedDiscussions) {
|
||||
@@ -103,54 +116,44 @@ export default class DiscussionListState {
|
||||
}
|
||||
|
||||
const params = this.requestParams();
|
||||
params.page = { offset: DiscussionListState.DISCUSSIONS_PER_PAGE * (page - 1) };
|
||||
params.page = { offset };
|
||||
params.include = params.include.join(',');
|
||||
|
||||
return this.app.store.find('discussions', params);
|
||||
}
|
||||
|
||||
loadPrev() {
|
||||
return this.pagination.loadPrev().then(this.parse.bind(this));
|
||||
}
|
||||
|
||||
/**
|
||||
* Load the next page of discussion results.
|
||||
*/
|
||||
loadMore() {
|
||||
return this.pagination.loadNext().then(this.parse.bind(this));
|
||||
this.loading = true;
|
||||
|
||||
this.loadResults(this.discussions.length).then(this.parseResults.bind(this));
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse results and append them to the discussion list.
|
||||
*/
|
||||
parse() {
|
||||
const discussions = [];
|
||||
const { first, last } = this.pagination.pages;
|
||||
parseResults(results) {
|
||||
this.discussions.push(...results);
|
||||
|
||||
for (let page = first; page <= last; page++) {
|
||||
const results = this.pagination.data[page];
|
||||
|
||||
if (Array.isArray(results)) discussions.push(...results);
|
||||
}
|
||||
|
||||
this.discussions = discussions;
|
||||
|
||||
const results = this.pagination.data[last];
|
||||
this.moreResults = !!results.payload.links.next;
|
||||
this.loading = false;
|
||||
this.moreResults = !!results.payload.links && !!results.payload.links.next;
|
||||
|
||||
m.redraw();
|
||||
|
||||
return discussions;
|
||||
return results;
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a discussion from the list if it is present.
|
||||
*/
|
||||
removeDiscussion(discussion) {
|
||||
Object.keys(this.pagination.data).forEach((key) => {
|
||||
const index = this.pagination.data[key].indexOf(discussion);
|
||||
const index = this.discussions.indexOf(discussion);
|
||||
|
||||
this.pagination.data[key].splice(index, 1);
|
||||
});
|
||||
|
||||
this.parse();
|
||||
if (index !== -1) {
|
||||
this.discussions.splice(index, 1);
|
||||
}
|
||||
|
||||
m.redraw();
|
||||
}
|
||||
@@ -174,11 +177,7 @@ export default class DiscussionListState {
|
||||
* Are discussions currently being loaded?
|
||||
*/
|
||||
isLoading() {
|
||||
return this.pagination.loading.next;
|
||||
}
|
||||
|
||||
isLoadingPrev() {
|
||||
return this.pagination.loading.prev;
|
||||
return this.loading;
|
||||
}
|
||||
|
||||
/**
|
||||
|
@@ -1,3 +1,4 @@
|
||||
import { throttle } from 'lodash-es';
|
||||
import anchorScroll from '../../common/utils/anchorScroll';
|
||||
|
||||
class PostStreamState {
|
||||
@@ -49,6 +50,9 @@ class PostStreamState {
|
||||
*/
|
||||
this.forceUpdateScrubber = false;
|
||||
|
||||
this.loadNext = throttle(this._loadNext, 300);
|
||||
this.loadPrevious = throttle(this._loadPrevious, 300);
|
||||
|
||||
this.show(includedPosts);
|
||||
}
|
||||
|
||||
@@ -187,7 +191,7 @@ class PostStreamState {
|
||||
/**
|
||||
* Load the next page of posts.
|
||||
*/
|
||||
loadNext() {
|
||||
_loadNext() {
|
||||
const start = this.visibleEnd;
|
||||
const end = (this.visibleEnd = this.sanitizeIndex(this.visibleEnd + this.constructor.loadCount));
|
||||
|
||||
@@ -210,7 +214,7 @@ class PostStreamState {
|
||||
/**
|
||||
* Load the previous page of posts.
|
||||
*/
|
||||
loadPrevious() {
|
||||
_loadPrevious() {
|
||||
const end = this.visibleStart;
|
||||
const start = (this.visibleStart = this.sanitizeIndex(this.visibleStart - this.constructor.loadCount));
|
||||
|
||||
|
@@ -57,7 +57,7 @@ export default {
|
||||
moderationControls(user) {
|
||||
const items = new ItemList();
|
||||
|
||||
if (user.canEdit()) {
|
||||
if (user.canEdit() || user.canEditCredentials() || user.canEditGroups()) {
|
||||
items.add(
|
||||
'edit',
|
||||
<Button icon="fas fa-pencil-alt" onclick={this.editAction.bind(this, user)}>
|
||||
|
13
js/src/forum/utils/isSafariMobile.ts
Normal file
13
js/src/forum/utils/isSafariMobile.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
/**
|
||||
* @see https://stackoverflow.com/a/31732310
|
||||
*/
|
||||
export default function isSafariMobile(): boolean {
|
||||
return (
|
||||
'ontouchstart' in window &&
|
||||
navigator.vendor &&
|
||||
navigator.vendor.includes('Apple') &&
|
||||
navigator.userAgent &&
|
||||
!navigator.userAgent.includes('CriOS') &&
|
||||
!navigator.userAgent.includes('FxiOS')
|
||||
);
|
||||
}
|
@@ -206,7 +206,7 @@
|
||||
.ExtensionNavButton {
|
||||
.Button-label {
|
||||
display: inline-block;
|
||||
max-width: calc(100% - 18px);
|
||||
max-width: ~"calc(100% - 18px)";
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
vertical-align: middle;
|
||||
@@ -218,5 +218,6 @@
|
||||
background-color: #2ECC40;
|
||||
}
|
||||
.ExtensionListItem-Dot.disabled {
|
||||
background-color: #FF4136;
|
||||
border: 2px solid #FF4136;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
@@ -1,4 +1,5 @@
|
||||
.AppearancePage {
|
||||
padding-bottom: 30px;
|
||||
|
||||
@media @desktop-up {
|
||||
.container {
|
||||
@@ -14,8 +15,16 @@
|
||||
.AppearancePage-colors-input {
|
||||
overflow: hidden;
|
||||
|
||||
.Form-group {
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.Form-group:last-child {
|
||||
margin-bottom: 24px !important;
|
||||
margin-left: 10px;
|
||||
}
|
||||
|
||||
input {
|
||||
width: 49%;
|
||||
float: left;
|
||||
|
||||
&:first-child {
|
||||
@@ -23,7 +32,7 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
.AppearancePage-colors-input,
|
||||
|
||||
.AppearancePage-colors .Checkbox {
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
@@ -1,4 +1,5 @@
|
||||
.BasicsPage {
|
||||
padding-bottom: 30px;
|
||||
|
||||
@media @desktop-up {
|
||||
.container {
|
||||
@@ -7,26 +8,27 @@
|
||||
}
|
||||
}
|
||||
|
||||
fieldset {
|
||||
.Form-group {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
> ul {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
ul {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.BasicsPage-welcomeBanner-input {
|
||||
:first-child {
|
||||
input {
|
||||
margin-bottom: 1px;
|
||||
border-bottom-right-radius: 0;
|
||||
border-bottom-left-radius: 0;
|
||||
font-weight: bold;
|
||||
}
|
||||
:last-child {
|
||||
textarea {
|
||||
border-top-right-radius: 0;
|
||||
border-top-left-radius: 0;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
}
|
||||
|
@@ -2,6 +2,7 @@
|
||||
background: @body-bg;
|
||||
color: @control-color;
|
||||
min-height: 100vh;
|
||||
padding-bottom: 30px;
|
||||
}
|
||||
|
||||
.Widget {
|
||||
|
@@ -1,5 +1,4 @@
|
||||
.ExtensionPage {
|
||||
min-height: 110vh;
|
||||
|
||||
.ExtensionPage-header {
|
||||
.ExtensionTitle {
|
||||
@@ -142,11 +141,13 @@
|
||||
|
||||
.ExtensionPage-permissions {
|
||||
|
||||
@media @phone {
|
||||
> .container {
|
||||
overflow-x: scroll;
|
||||
padding-bottom: 20px;
|
||||
}
|
||||
.PermissionGrid-removeScope {
|
||||
display: none;
|
||||
}
|
||||
|
||||
> .container {
|
||||
overflow-x: auto;
|
||||
padding-bottom: 25vh;
|
||||
}
|
||||
|
||||
.ExtensionPage-permissions-header {
|
||||
|
@@ -7,18 +7,6 @@
|
||||
padding: 0;
|
||||
background-color: @body-bg;
|
||||
|
||||
.ExtensionList-Category {
|
||||
background: @control-bg;
|
||||
padding: 20px 0 20px 20px;
|
||||
margin-bottom: 20px;
|
||||
border-radius: @border-radius;
|
||||
|
||||
.ExtensionList-Label {
|
||||
margin-top: 0;
|
||||
color: @muted-color;
|
||||
}
|
||||
}
|
||||
|
||||
.ExtensionGroup {
|
||||
margin-bottom: 20px;
|
||||
|
||||
@@ -46,6 +34,18 @@
|
||||
}
|
||||
}
|
||||
|
||||
.ExtensionList-Category {
|
||||
background: @control-bg;
|
||||
padding: 20px 0 20px 20px;
|
||||
margin-bottom: 20px;
|
||||
border-radius: @border-radius;
|
||||
}
|
||||
|
||||
.ExtensionList-Label {
|
||||
margin-top: 0;
|
||||
color: @muted-color;
|
||||
}
|
||||
|
||||
.ExtensionListItem.disabled {
|
||||
.ExtensionListItem-title {
|
||||
opacity: 0.5;
|
||||
@@ -64,18 +64,18 @@
|
||||
transform: scale(1.05);
|
||||
}
|
||||
|
||||
.ExtensionListItem-title {
|
||||
display: block;
|
||||
text-align: center;
|
||||
margin-top: 5px;
|
||||
color: @text-color;
|
||||
}
|
||||
|
||||
a:hover {
|
||||
text-decoration: none;
|
||||
}
|
||||
}
|
||||
|
||||
.ExtensionListItem-title {
|
||||
display: block;
|
||||
text-align: center;
|
||||
margin-top: 5px;
|
||||
color: @text-color;
|
||||
}
|
||||
|
||||
.ExtensionIcon {
|
||||
width: 90px;
|
||||
height: 90px;
|
||||
|
@@ -1,4 +1,5 @@
|
||||
.MailPage {
|
||||
padding-bottom: 30px;
|
||||
|
||||
@media @desktop-up {
|
||||
.container {
|
||||
@@ -7,11 +8,11 @@
|
||||
}
|
||||
}
|
||||
|
||||
fieldset, .Alert {
|
||||
button, .Alert {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
fieldset > ul {
|
||||
ul {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
|
@@ -1,15 +1,9 @@
|
||||
.PermissionsPage-groups {
|
||||
background: @control-bg;
|
||||
border-radius: @border-radius;
|
||||
max-width: calc(~'100% - 60px');
|
||||
display: block;
|
||||
margin-left: 30px;
|
||||
overflow-x: auto;
|
||||
padding: 10px 0 8px;
|
||||
|
||||
> .container {
|
||||
padding: 0 10px;
|
||||
}
|
||||
padding: 10px;
|
||||
}
|
||||
.Group {
|
||||
width: 90px;
|
||||
@@ -46,7 +40,6 @@
|
||||
|
||||
.PermissionGrid {
|
||||
white-space: nowrap;
|
||||
padding-left: 0 12px;
|
||||
|
||||
td, th {
|
||||
padding: 5px;
|
||||
|
@@ -32,7 +32,7 @@
|
||||
|
||||
&[disabled],
|
||||
fieldset[disabled] & {
|
||||
cursor: disallowed;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
textarea& {
|
||||
|
@@ -15,7 +15,6 @@
|
||||
}
|
||||
}
|
||||
.Search-results {
|
||||
max-height: 70vh;
|
||||
overflow: auto;
|
||||
left: auto;
|
||||
right: 0;
|
||||
|
49
less/common/TextEditor.less
Normal file
49
less/common/TextEditor.less
Normal file
@@ -0,0 +1,49 @@
|
||||
.TextEditor .TextEditor-editor {
|
||||
border-radius: 0;
|
||||
padding: 0 0 10px;
|
||||
border: 0;
|
||||
resize: none;
|
||||
color: @text-color;
|
||||
font-size: 14px;
|
||||
line-height: 1.7;
|
||||
|
||||
&, &:focus, &[disabled] {
|
||||
background: none;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
@media @phone {
|
||||
font-size: 16px; // minimum font-size required to prevent page zoom on focus in iOS 10
|
||||
}
|
||||
}
|
||||
.TextEditor-controls {
|
||||
margin: 0;
|
||||
padding: 10px 0;
|
||||
list-style-type: none;
|
||||
overflow-x: auto;
|
||||
white-space: nowrap;
|
||||
|
||||
li {
|
||||
display: inline-block;
|
||||
margin-right: 10px;
|
||||
}
|
||||
}
|
||||
.TextEditor-toolbar {
|
||||
.Button--icon {
|
||||
width: 28px;
|
||||
}
|
||||
}
|
||||
|
||||
@media @tablet-up {
|
||||
.TextEditor-controls {
|
||||
margin: 0 -20px 0 -105px;
|
||||
padding: 10px 20px;
|
||||
border-top: 1px solid @control-bg;
|
||||
|
||||
.fullScreen & {
|
||||
margin: 0;
|
||||
border-top: 0;
|
||||
padding: 20px 0;
|
||||
}
|
||||
}
|
||||
}
|
@@ -25,5 +25,6 @@
|
||||
@import "Placeholder";
|
||||
@import "Search";
|
||||
@import "Select";
|
||||
@import "TextEditor";
|
||||
@import "Tooltip";
|
||||
@import "ValidationError";
|
||||
|
@@ -117,8 +117,15 @@
|
||||
position: fixed;
|
||||
top: 0;
|
||||
height: 350px !important;
|
||||
max-height: 100%;
|
||||
padding-top: @header-height-phone;
|
||||
|
||||
// Fixes a bug where fixed position doesn't properly work in Safari mobile
|
||||
// https://github.com/flarum/core/issues/2652
|
||||
.mobile-safari & {
|
||||
position: absolute;
|
||||
}
|
||||
|
||||
&:before {
|
||||
content: " ";
|
||||
.header-background();
|
||||
@@ -292,7 +299,7 @@
|
||||
}
|
||||
}
|
||||
.ComposerBody-editor {
|
||||
.fullScreen & textarea {
|
||||
.fullScreen & .TextEditor-editor {
|
||||
font-size: 16px;
|
||||
}
|
||||
}
|
||||
@@ -318,56 +325,3 @@
|
||||
left: @pane-width;
|
||||
}
|
||||
}
|
||||
|
||||
// ------------------------------------
|
||||
// Text Editor
|
||||
|
||||
.TextEditor textarea {
|
||||
border-radius: 0;
|
||||
padding: 0 0 10px;
|
||||
border: 0;
|
||||
resize: none;
|
||||
color: @text-color;
|
||||
font-size: 14px;
|
||||
line-height: 1.7;
|
||||
|
||||
&, &:focus, &[disabled] {
|
||||
background: none;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
@media @phone {
|
||||
font-size: 16px; // minimum font-size required to prevent page zoom on focus in iOS 10
|
||||
}
|
||||
}
|
||||
.TextEditor-controls {
|
||||
margin: 0;
|
||||
padding: 10px 0;
|
||||
list-style-type: none;
|
||||
overflow-x: auto;
|
||||
white-space: nowrap;
|
||||
|
||||
li {
|
||||
display: inline-block;
|
||||
margin-right: 10px;
|
||||
}
|
||||
}
|
||||
.TextEditor-toolbar {
|
||||
.Button--icon {
|
||||
width: 28px;
|
||||
}
|
||||
}
|
||||
|
||||
@media @tablet-up {
|
||||
.TextEditor-controls {
|
||||
margin: 0 -20px 0 -105px;
|
||||
padding: 10px 20px;
|
||||
border-top: 1px solid @control-bg;
|
||||
|
||||
.fullScreen & {
|
||||
margin: 0;
|
||||
border-top: 0;
|
||||
padding: 20px 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@@ -110,6 +110,11 @@
|
||||
}
|
||||
}
|
||||
|
||||
@media (any-hover: none) {
|
||||
.DiscussionListItem-controls > .Dropdown-toggle {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
@media @phone {
|
||||
.DiscussionListItem-controls {
|
||||
|
653
locale/core.yml
Normal file
653
locale/core.yml
Normal file
@@ -0,0 +1,653 @@
|
||||
core:
|
||||
|
||||
##
|
||||
# UNIQUE KEYS - The following keys are used in only one location each.
|
||||
##
|
||||
|
||||
# Translations in this namespace are used by the admin interface.
|
||||
admin:
|
||||
|
||||
# These translations are used in the Appearance page.
|
||||
appearance:
|
||||
colored_header_label: Colored Header
|
||||
colors_heading: Colors
|
||||
colors_text: "Choose two colors to theme your forum with. The first will be used as a highlight color, while the second will be used to style background elements."
|
||||
custom_footer_heading: Custom Footer
|
||||
custom_footer_text: => core.ref.custom_footer_text
|
||||
custom_header_heading: Custom Header
|
||||
custom_header_text: => core.ref.custom_header_text
|
||||
custom_styles_heading: Custom Styles
|
||||
custom_styles_text: Customize your forum's appearance by adding your own LESS/CSS code to be applied on top of Flarum's default styles.
|
||||
dark_mode_label: Dark Mode
|
||||
description: "Customize your forum's colors, logos, and other variables."
|
||||
edit_css_button: Edit Custom CSS
|
||||
edit_footer_button: => core.ref.custom_footer_title
|
||||
edit_header_button: => core.ref.custom_header_title
|
||||
enter_hex_message: Please enter a hexadecimal color code.
|
||||
favicon_heading: Favicon
|
||||
favicon_text: Upload an image to be displayed as the forum's shortcut icon.
|
||||
logo_heading: Logo
|
||||
logo_text: Upload an image to be displayed in place of the forum title.
|
||||
title: Appearance
|
||||
|
||||
# These translations are used in the Basics page.
|
||||
basics:
|
||||
all_discussions_label: => core.ref.all_discussions
|
||||
default_language_heading: Default Language
|
||||
description: "Set your forum title, language, and other basic settings."
|
||||
display_name_heading: User Display Name
|
||||
display_name_text: Select the driver that should be used for users' display names. By default, the username is shown.
|
||||
forum_description_heading: Forum Description
|
||||
forum_description_text: Enter a short sentence or two that describes your community. This will appear in the meta tag and show up in search engines.
|
||||
forum_title_heading: Forum Title
|
||||
home_page_heading: Home Page
|
||||
home_page_text: Choose the page which users will first see when they visit your forum.
|
||||
show_language_selector_label: Show language selector
|
||||
slug_driver_heading: "Slug Driver: {model}"
|
||||
slug_driver_text: Select a driver to be used for slugging this model.
|
||||
title: Basics
|
||||
welcome_banner_heading: Welcome Banner
|
||||
welcome_banner_text: Configure the text that displays in the banner on the All Discussions page. Use this to welcome guests to your forum.
|
||||
|
||||
# These translations are used in the Dashboard page.
|
||||
dashboard:
|
||||
clear_cache_button: Clear Cache
|
||||
description: Your forum at a glance.
|
||||
title: Dashboard
|
||||
tools_button: Tools
|
||||
|
||||
# These translations are used in the Edit Custom CSS modal dialog.
|
||||
edit_css:
|
||||
customize_text: "Customize your forum's appearance by adding your own LESS/CSS code to be applied on top of Flarum's <a>default styles</a>."
|
||||
submit_button: => core.ref.save_changes
|
||||
title: Edit Custom CSS
|
||||
|
||||
# These translations are used in the Edit Custom Footer modal dialog.
|
||||
edit_footer:
|
||||
customize_text: => core.ref.custom_footer_text
|
||||
submit_button: => core.ref.save_changes
|
||||
title: => core.ref.custom_footer_title
|
||||
|
||||
# These translations are used in the Edit Group modal dialog.
|
||||
edit_group:
|
||||
color_label: => core.ref.color
|
||||
delete_button: Delete Group
|
||||
delete_confirmation: "Are you sure you want to delete this group? The group members will NOT be deleted."
|
||||
hide_label: Hide on forum
|
||||
icon_label: => core.ref.icon
|
||||
icon_text: => core.ref.icon_text
|
||||
name_label: Name
|
||||
plural_placeholder: Plural (e.g. Mods)
|
||||
singular_placeholder: Singular (e.g. Mod)
|
||||
submit_button: => core.ref.save_changes
|
||||
title: Create Group
|
||||
|
||||
# These translations are used in the Edit Custom Header modal dialog.
|
||||
edit_header:
|
||||
customize_text: => core.ref.custom_header_text
|
||||
submit_button: => core.ref.save_changes
|
||||
title: => core.ref.custom_header_title
|
||||
|
||||
# These translations are used in the email page of the admin interface.
|
||||
email:
|
||||
addresses_heading: Addresses
|
||||
description: "Configure the driver, settings and addresses your forum will use to send email."
|
||||
driver_heading: Choose a Driver
|
||||
driver_label: Driver
|
||||
from_label: Sender
|
||||
mail_encryption_label: Encryption
|
||||
mail_host_label: Host
|
||||
mail_mailgun_domain_label: Domain
|
||||
mail_mailgun_region_label: Region
|
||||
mail_mailgun_secret_label: Secret key
|
||||
mail_password_label: => core.ref.password
|
||||
mail_port_label: Port
|
||||
mail_username_label: => core.ref.username
|
||||
mailgun_heading: Mailgun Settings
|
||||
not_sending_message: Flarum currently does not send emails. This can be due to the selected driver, or errors in its configuration.
|
||||
send_test_mail_button: Send
|
||||
send_test_mail_heading: Send Test Mail
|
||||
send_test_mail_success: "Test mail sent successfully!"
|
||||
send_test_mail_text: "This will send an email using the above configuration to your email, {email}."
|
||||
smtp_heading: SMTP Settings
|
||||
title: => core.ref.email
|
||||
|
||||
# These translations are used on default extension pages.
|
||||
extension:
|
||||
configure_scopes: Configure Scopes
|
||||
confirm_uninstall: Uninstalling will remove all database entries and assets related to the extension. Are you sure you want to continue?
|
||||
disabled: Disabled
|
||||
enable_to_see: Enable the extension to view and change settings.
|
||||
enabled: Enabled
|
||||
info_links:
|
||||
discuss: Discuss
|
||||
documentation: Documentation
|
||||
donate: Donate
|
||||
source: Source
|
||||
support: Support
|
||||
website: Website
|
||||
no_permissions: This extension has no permissions.
|
||||
no_settings: This extension has no settings.
|
||||
open_modal: Open Settings
|
||||
permissions_title: Permissions
|
||||
uninstall_button: Uninstall
|
||||
|
||||
# These translations are used in the secondary header.
|
||||
header:
|
||||
get_help: Get Help
|
||||
log_out_button: => core.ref.log_out
|
||||
|
||||
# These translations are used in the modal dialog displayed when loading extensions.
|
||||
loading:
|
||||
title: Please Wait...
|
||||
|
||||
# These translations are used in the navigation bar.
|
||||
nav:
|
||||
appearance_button: => core.admin.appearance.title
|
||||
appearance_title: => core.admin.appearance.description
|
||||
basics_button: => core.admin.basics.title
|
||||
basics_title: => core.admin.basics.description
|
||||
categories:
|
||||
authentication: Authentication
|
||||
core: Core Configuration
|
||||
discussion: Discussion
|
||||
feature: Features
|
||||
formatting: Formatting
|
||||
language: Languages
|
||||
moderation: Moderation
|
||||
other: Other Extensions
|
||||
theme: Themes
|
||||
dashboard_button: => core.admin.dashboard.title
|
||||
dashboard_title: => core.admin.dashboard.description
|
||||
email_button: => core.ref.email
|
||||
email_title: => core.admin.email.description
|
||||
permissions_button: => core.admin.permissions.title
|
||||
permissions_title: => core.admin.permissions.description
|
||||
search_placeholder: Search Extensions
|
||||
|
||||
# These translations are used in the Permissions page of the admin interface.
|
||||
permissions:
|
||||
allow_post_editing_label: Allow post editing
|
||||
allow_renaming_label: Allow renaming
|
||||
create_heading: Create
|
||||
delete_discussions_forever_label: Delete discussions forever
|
||||
delete_discussions_label: Delete discussions
|
||||
delete_posts_forever_label: Delete posts forever
|
||||
delete_posts_label: Delete posts
|
||||
description: Configure who can see and do what.
|
||||
edit_posts_label: Edit posts
|
||||
edit_users_label: Edit user attributes
|
||||
edit_users_credentials_label: Edit user credentials
|
||||
edit_users_groups_label: Edit user groups
|
||||
global_heading: Global
|
||||
moderate_heading: Moderate
|
||||
new_group_button: New Group
|
||||
participate_heading: Participate
|
||||
post_without_throttle_label: Reply multiple times without waiting
|
||||
read_heading: Read
|
||||
rename_discussions_label: Rename discussions
|
||||
reply_to_discussions_label: Reply to discussions
|
||||
sign_up_label: Sign up
|
||||
start_discussions_label: Start discussions
|
||||
title: Permissions
|
||||
view_discussions_label: View discussions
|
||||
view_hidden_groups_label: View hidden group badges
|
||||
view_last_seen_at_label: Always view user last seen time
|
||||
view_post_ips_label: View post IP addresses
|
||||
view_user_list_label: View user list
|
||||
|
||||
# These translations are used in the dropdown menus on the Permissions page.
|
||||
permissions_controls:
|
||||
allow_indefinitely_button: Indefinitely
|
||||
allow_some_minutes_button: "For {count} minute|For {count} minutes"
|
||||
allow_ten_minutes_button: For 10 minutes
|
||||
allow_until_reply_button: Until next reply
|
||||
everyone_button: Everyone
|
||||
members_button: => core.group.members
|
||||
signup_closed_button: Closed
|
||||
signup_open_button: Open
|
||||
|
||||
# These translations are used generically in setting fields.
|
||||
settings:
|
||||
saved_message: Your changes were saved.
|
||||
submit_button: => core.ref.save_changes
|
||||
|
||||
# These translations are used in image upload buttons.
|
||||
upload_image:
|
||||
remove_button: => core.ref.remove
|
||||
upload_button: Choose an Image...
|
||||
|
||||
# Translations in this namespace are used by the forum user interface.
|
||||
forum:
|
||||
|
||||
# These translations are used in the Change Email modal dialog.
|
||||
change_email:
|
||||
confirm_password_placeholder: => core.ref.confirm_password
|
||||
confirmation_message: => core.ref.confirmation_email_sent
|
||||
dismiss_button: => core.ref.okay
|
||||
incorrect_password_message: The password you entered is incorrect.
|
||||
submit_button: => core.ref.save_changes
|
||||
title: => core.ref.change_email
|
||||
|
||||
# These translations are used in the Change Password modal dialog.
|
||||
change_password:
|
||||
send_button: Send Password Reset Email
|
||||
text: Click the button below and check your email for a link to change your password.
|
||||
title: => core.ref.change_password
|
||||
|
||||
# These translations are used by the composer controls.
|
||||
composer:
|
||||
close_tooltip: Close
|
||||
exit_full_screen_tooltip: Exit Full Screen
|
||||
full_screen_tooltip: Full Screen
|
||||
minimize_tooltip: Minimize
|
||||
preview_tooltip: Preview
|
||||
|
||||
# These translations are used by the composer when starting a discussion.
|
||||
composer_discussion:
|
||||
body_placeholder: Write a Post...
|
||||
discard_confirmation: "You have not posted your discussion. Do you wish to discard it?"
|
||||
submit_button: Post Discussion
|
||||
title: => core.ref.start_a_discussion
|
||||
title_placeholder: Discussion Title
|
||||
|
||||
# These translations are used by the composer when editing a post.
|
||||
composer_edit:
|
||||
discard_confirmation: "You have not saved your changes. Do you wish to discard them?"
|
||||
edited_message: Your edit was made.
|
||||
post_link: "Post #{number} in {discussion}"
|
||||
submit_button: => core.ref.save_changes
|
||||
view_button: => core.ref.view
|
||||
|
||||
# These translations are used by the composer when replying to a discussion.
|
||||
composer_reply:
|
||||
body_placeholder: => core.ref.write_a_reply
|
||||
discard_confirmation: "You have not posted your reply. Do you wish to discard it?"
|
||||
posted_message: Your reply was posted.
|
||||
submit_button: Post Reply
|
||||
view_button: => core.ref.view
|
||||
|
||||
# These translations are used by the discussion control buttons.
|
||||
discussion_controls:
|
||||
cannot_reply_button: Can't Reply
|
||||
cannot_reply_text: You don't have permission to reply to this discussion.
|
||||
delete_button: => core.ref.delete
|
||||
delete_confirmation: "Are you sure you want to delete this discussion?"
|
||||
delete_forever_button: => core.ref.delete_forever
|
||||
log_in_to_reply_button: Log In to Reply
|
||||
rename_button: => core.ref.rename
|
||||
reply_button: => core.ref.reply
|
||||
restore_button: => core.ref.restore
|
||||
|
||||
# These translations are used in the discussion list.
|
||||
discussion_list:
|
||||
empty_text: It looks as though there are no discussions here.
|
||||
load_more_button: => core.ref.load_more
|
||||
mark_as_read_tooltip: Mark as Read
|
||||
replied_text: "{username} replied {ago}"
|
||||
started_text: "{username} started {ago}"
|
||||
|
||||
# These translations are used in the Edit User modal dialog (admin function).
|
||||
edit_user:
|
||||
activate_button: Activate User
|
||||
email_heading: => core.ref.email
|
||||
email_label: => core.ref.email
|
||||
groups_heading: Groups
|
||||
nothing_available: There is nothing available for you to edit at this time.
|
||||
password_heading: => core.ref.password
|
||||
password_label: => core.ref.password
|
||||
set_password_label: Set new password
|
||||
submit_button: => core.ref.save_changes
|
||||
title: Edit User
|
||||
username_heading: => core.ref.username
|
||||
username_label: => core.ref.username
|
||||
|
||||
# These translations are used in the Forgot Password modal dialog.
|
||||
forgot_password:
|
||||
dismiss_button: => core.ref.okay
|
||||
email_placeholder: => core.ref.email
|
||||
email_sent_message: We've sent you an email containing a link to reset your password. Check your spam folder if you don't receive it within the next minute or two.
|
||||
not_found_message: There is no user registered with that email address.
|
||||
submit_button: Recover Password
|
||||
text: Enter your email address and we will send you a link to reset your password.
|
||||
title: Forgot Password
|
||||
|
||||
# These translations are used in the header and session dropdown menu.
|
||||
header:
|
||||
admin_button: Administration
|
||||
back_to_index_tooltip: Back to Discussion List
|
||||
log_in_link: => core.ref.log_in
|
||||
log_out_button: => core.ref.log_out
|
||||
profile_button: Profile
|
||||
search_placeholder: Search Forum
|
||||
settings_button: => core.ref.settings
|
||||
sign_up_link: => core.ref.sign_up
|
||||
|
||||
# These translations are used on the index page, peripheral to the discussion list.
|
||||
index:
|
||||
all_discussions_link: => core.ref.all_discussions
|
||||
cannot_start_discussion_button: Can't Start Discussion
|
||||
mark_all_as_read_confirmation: "Are you sure you want to mark all discussions as read?"
|
||||
mark_all_as_read_tooltip: => core.ref.mark_all_as_read
|
||||
meta_title_text: => core.ref.all_discussions
|
||||
refresh_tooltip: Refresh
|
||||
start_discussion_button: => core.ref.start_a_discussion
|
||||
|
||||
# These translations are used by the sorting control above the discussion list.
|
||||
index_sort:
|
||||
latest_button: Latest
|
||||
newest_button: Newest
|
||||
oldest_button: Oldest
|
||||
relevance_button: Relevance
|
||||
top_button: Top
|
||||
|
||||
# These translations are used in the Log In modal dialog.
|
||||
log_in:
|
||||
forgot_password_link: "Forgot password?"
|
||||
invalid_login_message: Your login details were incorrect.
|
||||
password_placeholder: => core.ref.password
|
||||
remember_me_label: Remember Me
|
||||
sign_up_text: "Don't have an account? <a>Sign Up</a>"
|
||||
submit_button: => core.ref.log_in
|
||||
title: => core.ref.log_in
|
||||
username_or_email_placeholder: Username or Email
|
||||
|
||||
# These translations are used by the Notifications dropdown, a.k.a. "the bell".
|
||||
notifications:
|
||||
discussion_renamed_text: "{username} changed the title"
|
||||
empty_text: No Notifications
|
||||
mark_all_as_read_tooltip: => core.ref.mark_all_as_read
|
||||
mark_as_read_tooltip: Mark as Read
|
||||
title: => core.ref.notifications
|
||||
tooltip: => core.ref.notifications
|
||||
|
||||
# These translations are used by tooltips displayed for individual posts.
|
||||
post:
|
||||
edited_text: Edited
|
||||
edited_tooltip: "{username} edited {ago}"
|
||||
number_tooltip: "Post #{number}"
|
||||
|
||||
# These translations are used by the post control buttons.
|
||||
post_controls:
|
||||
delete_button: => core.ref.delete
|
||||
delete_confirmation: "Are you sure you want to delete this post forever? This action cannot be undone."
|
||||
delete_forever_button: => core.ref.delete_forever
|
||||
edit_button: => core.ref.edit
|
||||
hide_confirmation: "Are you sure you want to delete this post?"
|
||||
restore_button: => core.ref.restore
|
||||
|
||||
# These translations are used in the scrubber to the right of the post stream.
|
||||
post_scrubber:
|
||||
now_link: Now
|
||||
original_post_link: Original Post
|
||||
unread_text: "{count} unread"
|
||||
viewing_text: "{index} of {count} post|{index} of {count} posts"
|
||||
|
||||
# These translations are displayed between posts in the post stream.
|
||||
post_stream:
|
||||
discussion_renamed_old_tooltip: 'The old title was: "{old}"'
|
||||
discussion_renamed_text: "{username} changed the title to {new}."
|
||||
load_more_button: => core.ref.load_more
|
||||
reply_placeholder: => core.ref.write_a_reply
|
||||
time_lapsed_text: "{period} later"
|
||||
|
||||
# These translations are used by the rename discussion modal.
|
||||
rename_discussion:
|
||||
submit_button: => core.ref.rename
|
||||
title: Rename Discussion
|
||||
|
||||
# These translations are used by the search results dropdown list.
|
||||
search:
|
||||
all_discussions_button: 'Search all discussions for "{query}"'
|
||||
discussions_heading: => core.ref.discussions
|
||||
users_heading: => core.ref.users
|
||||
|
||||
# These translations are used in the Settings page.
|
||||
settings:
|
||||
account_heading: Account
|
||||
change_email_button: => core.ref.change_email
|
||||
change_password_button: => core.ref.change_password
|
||||
notifications_heading: => core.ref.notifications
|
||||
notify_by_email_heading: => core.ref.email
|
||||
notify_by_web_heading: Web
|
||||
notify_discussion_renamed_label: Someone renames a discussion I started
|
||||
privacy_disclose_online_label: Allow others to see when I am online
|
||||
privacy_heading: Privacy
|
||||
title: => core.ref.settings
|
||||
|
||||
# These translations are used in the Sign Up modal dialog.
|
||||
sign_up:
|
||||
dismiss_button: => core.ref.okay
|
||||
email_placeholder: => core.ref.email
|
||||
log_in_text: "Already have an account? <a>Log In</a>"
|
||||
password_placeholder: => core.ref.password
|
||||
submit_button: => core.ref.sign_up
|
||||
title: => core.ref.sign_up
|
||||
username_placeholder: => core.ref.username
|
||||
welcome_text: "Welcome, {username}!"
|
||||
|
||||
# These translations are used in the user profile page and profile popup.
|
||||
user:
|
||||
avatar_remove_button: => core.ref.remove
|
||||
avatar_upload_button: Upload
|
||||
avatar_upload_tooltip: Upload a new avatar
|
||||
discussions_link: => core.ref.discussions
|
||||
in_discussion_text: "In {discussion}"
|
||||
joined_date_text: "Joined {ago}"
|
||||
online_text: Online
|
||||
posts_empty_text: It looks like there are no posts here.
|
||||
posts_link: => core.ref.posts
|
||||
posts_load_more_button: => core.ref.load_more
|
||||
settings_link: => core.ref.settings
|
||||
|
||||
# These translations are found on the user profile page (admin function).
|
||||
user_controls:
|
||||
button: Controls
|
||||
delete_button: => core.ref.delete
|
||||
delete_confirmation: "Are you sure you want to delete this user? The user's posts will NOT be deleted."
|
||||
delete_error_message: "Deletion of user <i>{username} ({email})</i> failed"
|
||||
delete_success_message: "User <i>{username} ({email})</i> was deleted"
|
||||
edit_button: => core.ref.edit
|
||||
|
||||
# These translations are used in the alert that is shown when a new user has not confirmed their email address.
|
||||
user_email_confirmation:
|
||||
alert_message: => core.ref.confirmation_email_sent
|
||||
resend_button: Resend Confirmation Email
|
||||
sent_message: Sent
|
||||
|
||||
# Translations in this namespace are used by the forum and admin interfaces.
|
||||
lib:
|
||||
|
||||
# These translations are displayed as tooltips for discussion badges.
|
||||
badge:
|
||||
hidden_tooltip: Hidden
|
||||
|
||||
# These translations are displayed as error messages.
|
||||
error:
|
||||
dependent_extensions_message: "Cannot disable {extension} until the following dependent extensions are disabled: {extensions}"
|
||||
generic_message: "Oops! Something went wrong. Please reload the page and try again."
|
||||
missing_dependencies_message: "Cannot enable {extension} until the following dependencies are enabled: {extensions}"
|
||||
not_found_message: The requested resource was not found.
|
||||
permission_denied_message: You do not have permission to do that.
|
||||
rate_limit_exceeded_message: You're going a little too quickly. Please try again in a few seconds.
|
||||
|
||||
# These translations are used as suffixes when abbreviating numbers.
|
||||
number_suffix:
|
||||
kilo_text: K
|
||||
mega_text: M
|
||||
|
||||
# These translations are used to punctuate a series of items.
|
||||
series:
|
||||
glue_text: ", "
|
||||
three_text: "{first}, {second}, and {third}"
|
||||
two_text: "{first} and {second}"
|
||||
|
||||
# These translations are used to modify usernames.
|
||||
username:
|
||||
deleted_text: "[deleted]"
|
||||
|
||||
# Translations in this namespace are used in views other than Flarum's normal JS client.
|
||||
views:
|
||||
|
||||
# Translations in this namespace are displayed by the basic HTML content loader.
|
||||
content:
|
||||
javascript_disabled_message: This site is best viewed in a modern browser with JavaScript enabled.
|
||||
load_error_message: Something went wrong while trying to load the full version of this site. Try hard-refreshing this page to fix the error.
|
||||
loading_text: Loading...
|
||||
|
||||
# Translations in this namespace are displayed in the basic HTML discussion view.
|
||||
discussion:
|
||||
next_page_button: => core.ref.next_page
|
||||
previous_page_button: => core.ref.previous_page
|
||||
|
||||
# Translations in this namespace are displayed when Flarum encounters an error.
|
||||
error:
|
||||
csrf_token_mismatch: You have been inactive for too long.
|
||||
csrf_token_mismatch_return_link: Go back, to try again
|
||||
invalid_confirmation_token: This confirmation link has already been used or is invalid.
|
||||
not_authenticated: You do not have permission to access this page. Try again after logging in.
|
||||
not_found: The page you requested could not be found.
|
||||
not_found_return_link: "Return to {forum}"
|
||||
permission_denied: You do not have permission to access this page.
|
||||
unknown: An error occurred while trying to load this page.
|
||||
|
||||
# Translations in this namespace are displayed by the basic HTML discussion index.
|
||||
index:
|
||||
all_discussions_heading: => core.ref.all_discussions
|
||||
next_page_button: => core.ref.next_page
|
||||
previous_page_button: => core.ref.previous_page
|
||||
|
||||
# Translations in this namespace are displayed by the Log Out confirmation interface.
|
||||
log_out:
|
||||
log_out_button: => core.ref.log_out
|
||||
log_out_confirmation: "Are you sure you want to log out of {forum}?"
|
||||
title: => core.ref.log_out
|
||||
|
||||
# Translations in this namespace are displayed by the Reset Password interface.
|
||||
reset_password:
|
||||
confirm_password_label: Confirm New Password
|
||||
new_password_label: New Password
|
||||
submit_button: => core.ref.save_changes
|
||||
title: => core.ref.reset_your_password
|
||||
|
||||
# Translations in this namespace are used in messages output by the API.
|
||||
api:
|
||||
invalid_username_message: "The username may only contain letters, numbers, and dashes."
|
||||
|
||||
# Translations in this namespace are used in emails sent by the forum.
|
||||
email:
|
||||
|
||||
# These translations are used in emails sent when users register new accounts.
|
||||
activate_account:
|
||||
subject: Activate Your New Account
|
||||
body: |
|
||||
Hey {username}!
|
||||
|
||||
Someone (hopefully you!) has signed up to {forum} with this email address.
|
||||
|
||||
If this was you, simply click the following link and your account will be activated:
|
||||
{url}
|
||||
|
||||
If you did not sign up, please ignore this email.
|
||||
|
||||
# These translations are used in emails sent when users change their email address.
|
||||
confirm_email:
|
||||
subject: Confirm Your New Email Address
|
||||
body: |
|
||||
Hey {username}!
|
||||
|
||||
Someone (hopefully you!) has changed their email address on {forum} to this one.
|
||||
|
||||
If this was you, simply click the following link and your email will be confirmed:
|
||||
{url}
|
||||
|
||||
If this was not you, please ignore this email.
|
||||
|
||||
# These translations are used in emails sent when users ask to reset their passwords.
|
||||
reset_password:
|
||||
subject: => core.ref.reset_your_password
|
||||
body: |
|
||||
Hey {username}!
|
||||
|
||||
Someone (hopefully you!) has submitted a forgotten password request for your account on {forum}.
|
||||
|
||||
If this was you, click the following link to reset your password:
|
||||
{url}
|
||||
|
||||
If you do not wish to change your password, just ignore this email and nothing will happen.
|
||||
|
||||
# These translations are used when testing mailing configuration
|
||||
send_test:
|
||||
subject: Flarum Email Test
|
||||
body: |
|
||||
Hey {username}!
|
||||
|
||||
This is a test email to confirm that your Flarum email configuration is working properly.
|
||||
|
||||
If this was you, this email means that your configuration works!
|
||||
|
||||
If this was not you, please ignore this email.
|
||||
|
||||
##
|
||||
# REUSED TRANSLATIONS - These keys should not be used directly in code!
|
||||
##
|
||||
|
||||
# Translations in this namespace are referenced by two or more unique keys.
|
||||
ref:
|
||||
all_discussions: All Discussions
|
||||
change_email: Change Email
|
||||
change_password: Change Password
|
||||
color: Color # Referenced by flarum-tags.yml
|
||||
confirm_password: Confirm Password
|
||||
confirmation_email_sent: "We've sent a confirmation email to {email}. If it doesn't arrive soon, check your spam folder."
|
||||
custom_footer_text: Add HTML to be displayed at the very bottom of the page.
|
||||
custom_footer_title: Edit Custom Footer
|
||||
custom_header_text: "Add HTML to be displayed at the very top of the page, above Flarum's own header."
|
||||
custom_header_title: Edit Custom Header
|
||||
delete: Delete
|
||||
delete_forever: Delete Forever
|
||||
discussions: Discussions # Referenced by flarum-statistics.yml
|
||||
edit: Edit
|
||||
email: Email
|
||||
icon: Icon
|
||||
icon_text: "Enter the name of any <a>FontAwesome</a> icon class, <em>including</em> the <code>fas fa-</code> prefix."
|
||||
load_more: Load More
|
||||
log_in: Log In
|
||||
log_out: Log Out
|
||||
mark_all_as_read: Mark All as Read
|
||||
next_page: Next Page
|
||||
notifications: Notifications
|
||||
okay: OK # Referenced by flarum-tags.yml
|
||||
password: Password
|
||||
posts: Posts # Referenced by flarum-statistics.yml
|
||||
previous_page: Previous Page
|
||||
remove: Remove
|
||||
rename: Rename
|
||||
reply: Reply # Referenced by flarum-mentions.yml
|
||||
reset_your_password: Reset Your Password
|
||||
restore: Restore
|
||||
save_changes: Save Changes # Referenced by flarum-suspend.yml, flarum-tags.yml
|
||||
settings: Settings
|
||||
sign_up: Sign Up
|
||||
some_others: "{count} other|{count} others" # Referenced by flarum-likes.yml, flarum-mentions.yml
|
||||
start_a_discussion: Start a Discussion
|
||||
username: Username
|
||||
users: Users # Referenced by flarum-statistics.yml
|
||||
view: View
|
||||
write_a_reply: Write a Reply...
|
||||
you: You # Referenced by flarum-likes.yml, flarum-mentions.yml
|
||||
|
||||
##
|
||||
# GROUP NAMES - These keys are translated at the back end.
|
||||
##
|
||||
|
||||
# Translations in this namespace are used to translate default group names.
|
||||
group:
|
||||
admin: Admin
|
||||
admins: Admins
|
||||
guest: Guest
|
||||
guests: Guests
|
||||
member: Member
|
||||
members: Members
|
||||
mod: Mod
|
||||
mods: Mods
|
108
locale/validation.yml
Normal file
108
locale/validation.yml
Normal file
@@ -0,0 +1,108 @@
|
||||
validation:
|
||||
accepted: "The :attribute must be accepted."
|
||||
active_url: "The :attribute is not a valid URL."
|
||||
after: "The :attribute must be a date after :date."
|
||||
after_or_equal: "The :attribute must be a date after or equal to :date."
|
||||
alpha: "The :attribute may only contain letters."
|
||||
alpha_dash: "The :attribute may only contain letters, numbers, dashes and underscores."
|
||||
alpha_num: "The :attribute may only contain letters and numbers."
|
||||
array: "The :attribute must be an array."
|
||||
before: "The :attribute must be a date before :date."
|
||||
before_or_equal: "The :attribute must be a date before or equal to :date."
|
||||
between:
|
||||
numeric: "The :attribute must be between :min and :max."
|
||||
file: "The :attribute must be between :min and :max kilobytes."
|
||||
string: "The :attribute must be between :min and :max characters."
|
||||
array: "The :attribute must have between :min and :max items."
|
||||
boolean: "The :attribute field must be true or false."
|
||||
confirmed: "The :attribute confirmation does not match."
|
||||
date: "The :attribute is not a valid date."
|
||||
date_equals: "The :attribute must be a date equal to :date."
|
||||
date_format: "The :attribute does not match the format :format."
|
||||
different: "The :attribute and :other must be different."
|
||||
digits: "The :attribute must be :digits digits."
|
||||
digits_between: "The :attribute must be between :min and :max digits."
|
||||
dimensions: "The :attribute has invalid image dimensions."
|
||||
distinct: "The :attribute field has a duplicate value."
|
||||
email: "The :attribute must be a valid email address."
|
||||
ends_with: "The :attribute must end with one of the following: :values."
|
||||
exists: "The selected :attribute is invalid."
|
||||
file: "The :attribute must be a file."
|
||||
filled: "The :attribute field must have a value."
|
||||
gt:
|
||||
numeric: "The :attribute must be greater than :value."
|
||||
file: "The :attribute must be greater than :value kilobytes."
|
||||
string: "The :attribute must be greater than :value characters."
|
||||
array: "The :attribute must have more than :value items."
|
||||
gte:
|
||||
numeric: "The :attribute must be greater than or equal :value."
|
||||
file: "The :attribute must be greater than or equal :value kilobytes."
|
||||
string: "The :attribute must be greater than or equal :value characters."
|
||||
array: "The :attribute must have :value items or more."
|
||||
image: "The :attribute must be an image."
|
||||
in: "The selected :attribute is invalid."
|
||||
in_array: "The :attribute field does not exist in :other."
|
||||
integer: "The :attribute must be an integer."
|
||||
ip: "The :attribute must be a valid IP address."
|
||||
ipv4: "The :attribute must be a valid IPv4 address."
|
||||
ipv6: "The :attribute must be a valid IPv6 address."
|
||||
json: "The :attribute must be a valid JSON string."
|
||||
lt:
|
||||
numeric: "The :attribute must be less than :value."
|
||||
file: "The :attribute must be less than :value kilobytes."
|
||||
string: "The :attribute must be less than :value characters."
|
||||
array: "The :attribute must have less than :value items."
|
||||
lte:
|
||||
numeric: "The :attribute must be less than or equal :value."
|
||||
file: "The :attribute must be less than or equal :value kilobytes."
|
||||
string: "The :attribute must be less than or equal :value characters."
|
||||
array: "The :attribute must not have more than :value items."
|
||||
max:
|
||||
numeric: "The :attribute may not be greater than :max."
|
||||
file: "The :attribute may not be greater than :max kilobytes."
|
||||
string: "The :attribute may not be greater than :max characters."
|
||||
array: "The :attribute may not have more than :max items."
|
||||
mimes: "The :attribute must be a file of type: :values."
|
||||
mimetypes: "The :attribute must be a file of type: :values."
|
||||
min:
|
||||
numeric: "The :attribute must be at least :min."
|
||||
file: "The :attribute must be at least :min kilobytes."
|
||||
string: "The :attribute must be at least :min characters."
|
||||
array: "The :attribute must have at least :min items."
|
||||
not_in: "The selected :attribute is invalid."
|
||||
not_regex: "The :attribute format is invalid."
|
||||
numeric: "The :attribute must be a number."
|
||||
password: "The password is incorrect."
|
||||
present: "The :attribute field must be present."
|
||||
regex: "The :attribute format is invalid."
|
||||
required: "The :attribute field is required."
|
||||
required_if: "The :attribute field is required when :other is :value."
|
||||
required_unless: "The :attribute field is required unless :other is in :values."
|
||||
required_with: "The :attribute field is required when :values is present."
|
||||
required_with_all: "The :attribute field is required when :values are present."
|
||||
required_without: "The :attribute field is required when :values is not present."
|
||||
required_without_all: "The :attribute field is required when none of :values are present."
|
||||
same: "The :attribute and :other must match."
|
||||
size:
|
||||
numeric: "The :attribute must be :size."
|
||||
file: "The :attribute must be :size kilobytes."
|
||||
string: "The :attribute must be :size characters."
|
||||
array: "The :attribute must contain :size items."
|
||||
starts_with: "The :attribute must start with one of the following: :values."
|
||||
string: "The :attribute must be a string."
|
||||
timezone: "The :attribute must be a valid zone."
|
||||
unique: "The :attribute has already been taken."
|
||||
uploaded: "The :attribute failed to upload."
|
||||
url: "The :attribute format is invalid."
|
||||
uuid: "The :attribute must be a valid UUID."
|
||||
|
||||
attributes:
|
||||
username: username
|
||||
password: password
|
||||
email: email
|
||||
title: title
|
||||
content: content
|
||||
name_singular: singular name
|
||||
name_plural: plural name
|
||||
tag_count_primary: number of primary tags
|
||||
tag_count_secondary: number of secondary tags
|
@@ -0,0 +1,43 @@
|
||||
<?php
|
||||
|
||||
/*
|
||||
* This file is part of Flarum.
|
||||
*
|
||||
* For detailed copyright and license information, please view the
|
||||
* LICENSE file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Database\Schema\Builder;
|
||||
|
||||
return [
|
||||
'up' => function (Builder $schema) {
|
||||
$schema->table('access_tokens', function (Blueprint $table) {
|
||||
$table->string('type', 100)->index();
|
||||
});
|
||||
|
||||
// Since all active sessions will stop working on update due to switching from user_id to access_token
|
||||
// We can do things simple here by terminating all tokens that have the previously default lifetime
|
||||
$schema->getConnection()->table('access_tokens')
|
||||
->where('lifetime_seconds', 3600)
|
||||
->delete();
|
||||
|
||||
// We will then assume that all remaining tokens are remember tokens
|
||||
// This will include tokens that previously had a custom lifetime
|
||||
$schema->getConnection()->table('access_tokens')
|
||||
->update([
|
||||
'type' => 'session_remember',
|
||||
]);
|
||||
|
||||
$schema->table('access_tokens', function (Blueprint $table) {
|
||||
$table->dropColumn('lifetime_seconds');
|
||||
});
|
||||
},
|
||||
|
||||
'down' => function (Builder $schema) {
|
||||
$schema->table('access_tokens', function (Blueprint $table) {
|
||||
$table->dropColumn('type');
|
||||
$table->integer('lifetime_seconds');
|
||||
});
|
||||
}
|
||||
];
|
35
migrations/2021_03_02_040500_change_access_tokens_add_id.php
Normal file
35
migrations/2021_03_02_040500_change_access_tokens_add_id.php
Normal file
@@ -0,0 +1,35 @@
|
||||
<?php
|
||||
|
||||
/*
|
||||
* This file is part of Flarum.
|
||||
*
|
||||
* For detailed copyright and license information, please view the
|
||||
* LICENSE file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Database\Schema\Builder;
|
||||
|
||||
return [
|
||||
'up' => function (Builder $schema) {
|
||||
$schema->table('access_tokens', function (Blueprint $table) {
|
||||
// Replace primary key with unique index so we can create a new primary
|
||||
$table->dropPrimary('token');
|
||||
$table->unique('token');
|
||||
});
|
||||
|
||||
// This needs to be done in a second statement because of the order Laravel runs operations in
|
||||
$schema->table('access_tokens', function (Blueprint $table) {
|
||||
// Introduce new increment-based ID
|
||||
$table->increments('id')->first();
|
||||
});
|
||||
},
|
||||
|
||||
'down' => function (Builder $schema) {
|
||||
$schema->table('access_tokens', function (Blueprint $table) {
|
||||
$table->dropColumn('id');
|
||||
$table->dropIndex('token');
|
||||
$table->primary('token');
|
||||
});
|
||||
}
|
||||
];
|
@@ -0,0 +1,21 @@
|
||||
<?php
|
||||
|
||||
/*
|
||||
* This file is part of Flarum.
|
||||
*
|
||||
* For detailed copyright and license information, please view the
|
||||
* LICENSE file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
use Flarum\Database\Migration;
|
||||
|
||||
return Migration::addColumns('access_tokens', [
|
||||
'title' => ['string', 'length' => 150, 'nullable' => true],
|
||||
// Accommodates both IPv4 and IPv6 as strings
|
||||
'last_ip_address' => ['string', 'length' => 45, 'nullable' => true],
|
||||
// Technically, there's no limit to a user agent length
|
||||
// Most are around 150 in length, and the general recommendation seems to be below 200
|
||||
// We're going to use the longest string possible to be safe
|
||||
// There will still be exceptions, we'll just truncate them
|
||||
'last_user_agent' => ['string', 'length' => 255, 'nullable' => true],
|
||||
]);
|
@@ -36,18 +36,18 @@ class AdminServiceProvider extends AbstractServiceProvider
|
||||
*/
|
||||
public function register()
|
||||
{
|
||||
$this->app->extend(UrlGenerator::class, function (UrlGenerator $url) {
|
||||
return $url->addCollection('admin', $this->app->make('flarum.admin.routes'), 'admin');
|
||||
$this->container->extend(UrlGenerator::class, function (UrlGenerator $url) {
|
||||
return $url->addCollection('admin', $this->container->make('flarum.admin.routes'), 'admin');
|
||||
});
|
||||
|
||||
$this->app->singleton('flarum.admin.routes', function () {
|
||||
$this->container->singleton('flarum.admin.routes', function () {
|
||||
$routes = new RouteCollection;
|
||||
$this->populateRoutes($routes);
|
||||
|
||||
return $routes;
|
||||
});
|
||||
|
||||
$this->app->singleton('flarum.admin.middleware', function () {
|
||||
$this->container->singleton('flarum.admin.middleware', function () {
|
||||
return [
|
||||
'flarum.admin.error_handler',
|
||||
HttpMiddleware\ParseJsonBody::class,
|
||||
@@ -61,23 +61,23 @@ class AdminServiceProvider extends AbstractServiceProvider
|
||||
];
|
||||
});
|
||||
|
||||
$this->app->bind('flarum.admin.error_handler', function () {
|
||||
$this->container->bind('flarum.admin.error_handler', function () {
|
||||
return new HttpMiddleware\HandleErrors(
|
||||
$this->app->make(Registry::class),
|
||||
$this->app['flarum.config']->inDebugMode() ? $this->app->make(WhoopsFormatter::class) : $this->app->make(ViewFormatter::class),
|
||||
$this->app->tagged(Reporter::class)
|
||||
$this->container->make(Registry::class),
|
||||
$this->container['flarum.config']->inDebugMode() ? $this->container->make(WhoopsFormatter::class) : $this->container->make(ViewFormatter::class),
|
||||
$this->container->tagged(Reporter::class)
|
||||
);
|
||||
});
|
||||
|
||||
$this->app->bind('flarum.admin.route_resolver', function () {
|
||||
return new HttpMiddleware\ResolveRoute($this->app->make('flarum.admin.routes'));
|
||||
$this->container->bind('flarum.admin.route_resolver', function () {
|
||||
return new HttpMiddleware\ResolveRoute($this->container->make('flarum.admin.routes'));
|
||||
});
|
||||
|
||||
$this->app->singleton('flarum.admin.handler', function () {
|
||||
$this->container->singleton('flarum.admin.handler', function () {
|
||||
$pipe = new MiddlewarePipe;
|
||||
|
||||
foreach ($this->app->make('flarum.admin.middleware') as $middleware) {
|
||||
$pipe->pipe($this->app->make($middleware));
|
||||
foreach ($this->container->make('flarum.admin.middleware') as $middleware) {
|
||||
$pipe->pipe($this->container->make($middleware));
|
||||
}
|
||||
|
||||
$pipe->pipe(new HttpMiddleware\ExecuteRoute());
|
||||
@@ -85,9 +85,9 @@ class AdminServiceProvider extends AbstractServiceProvider
|
||||
return $pipe;
|
||||
});
|
||||
|
||||
$this->app->bind('flarum.assets.admin', function () {
|
||||
$this->container->bind('flarum.assets.admin', function () {
|
||||
/** @var \Flarum\Frontend\Assets $assets */
|
||||
$assets = $this->app->make('flarum.assets.factory')('admin');
|
||||
$assets = $this->container->make('flarum.assets.factory')('admin');
|
||||
|
||||
$assets->js(function (SourceCollector $sources) {
|
||||
$sources->addFile(__DIR__.'/../../js/dist/admin.js');
|
||||
@@ -97,17 +97,17 @@ class AdminServiceProvider extends AbstractServiceProvider
|
||||
$sources->addFile(__DIR__.'/../../less/admin.less');
|
||||
});
|
||||
|
||||
$this->app->make(AddTranslations::class)->forFrontend('admin')->to($assets);
|
||||
$this->app->make(AddLocaleAssets::class)->to($assets);
|
||||
$this->container->make(AddTranslations::class)->forFrontend('admin')->to($assets);
|
||||
$this->container->make(AddLocaleAssets::class)->to($assets);
|
||||
|
||||
return $assets;
|
||||
});
|
||||
|
||||
$this->app->bind('flarum.frontend.admin', function () {
|
||||
$this->container->bind('flarum.frontend.admin', function () {
|
||||
/** @var \Flarum\Frontend\Frontend $frontend */
|
||||
$frontend = $this->app->make('flarum.frontend.factory')('admin');
|
||||
$frontend = $this->container->make('flarum.frontend.factory')('admin');
|
||||
|
||||
$frontend->content($this->app->make(Content\AdminPayload::class));
|
||||
$frontend->content($this->container->make(Content\AdminPayload::class));
|
||||
|
||||
return $frontend;
|
||||
});
|
||||
@@ -120,14 +120,14 @@ class AdminServiceProvider extends AbstractServiceProvider
|
||||
{
|
||||
$this->loadViewsFrom(__DIR__.'/../../views', 'flarum.admin');
|
||||
|
||||
$events = $this->app->make('events');
|
||||
$events = $this->container->make('events');
|
||||
|
||||
$events->listen(
|
||||
[Enabled::class, Disabled::class, ClearingCache::class],
|
||||
function () {
|
||||
$recompile = new RecompileFrontendAssets(
|
||||
$this->app->make('flarum.assets.admin'),
|
||||
$this->app->make(LocaleManager::class)
|
||||
$this->container->make('flarum.assets.admin'),
|
||||
$this->container->make(LocaleManager::class)
|
||||
);
|
||||
$recompile->flush();
|
||||
}
|
||||
@@ -137,8 +137,8 @@ class AdminServiceProvider extends AbstractServiceProvider
|
||||
Saved::class,
|
||||
function (Saved $event) {
|
||||
$recompile = new RecompileFrontendAssets(
|
||||
$this->app->make('flarum.assets.admin'),
|
||||
$this->app->make(LocaleManager::class)
|
||||
$this->container->make('flarum.assets.admin'),
|
||||
$this->container->make(LocaleManager::class)
|
||||
);
|
||||
$recompile->whenSettingsSaved($event);
|
||||
}
|
||||
@@ -150,7 +150,7 @@ class AdminServiceProvider extends AbstractServiceProvider
|
||||
*/
|
||||
protected function populateRoutes(RouteCollection $routes)
|
||||
{
|
||||
$factory = $this->app->make(RouteHandlerFactory::class);
|
||||
$factory = $this->container->make(RouteHandlerFactory::class);
|
||||
|
||||
$callback = include __DIR__.'/routes.php';
|
||||
$callback($routes, $factory);
|
||||
|
@@ -13,7 +13,6 @@ use Flarum\Api\Controller\AbstractSerializeController;
|
||||
use Flarum\Api\Serializer\AbstractSerializer;
|
||||
use Flarum\Api\Serializer\BasicDiscussionSerializer;
|
||||
use Flarum\Api\Serializer\NotificationSerializer;
|
||||
use Flarum\Event\ConfigureNotificationTypes;
|
||||
use Flarum\Foundation\AbstractServiceProvider;
|
||||
use Flarum\Foundation\ErrorHandling\JsonApiFormatter;
|
||||
use Flarum\Foundation\ErrorHandling\Registry;
|
||||
@@ -31,18 +30,18 @@ class ApiServiceProvider extends AbstractServiceProvider
|
||||
*/
|
||||
public function register()
|
||||
{
|
||||
$this->app->extend(UrlGenerator::class, function (UrlGenerator $url) {
|
||||
return $url->addCollection('api', $this->app->make('flarum.api.routes'), 'api');
|
||||
$this->container->extend(UrlGenerator::class, function (UrlGenerator $url) {
|
||||
return $url->addCollection('api', $this->container->make('flarum.api.routes'), 'api');
|
||||
});
|
||||
|
||||
$this->app->singleton('flarum.api.routes', function () {
|
||||
$this->container->singleton('flarum.api.routes', function () {
|
||||
$routes = new RouteCollection;
|
||||
$this->populateRoutes($routes);
|
||||
|
||||
return $routes;
|
||||
});
|
||||
|
||||
$this->app->singleton('flarum.api.throttlers', function () {
|
||||
$this->container->singleton('flarum.api.throttlers', function () {
|
||||
return [
|
||||
'bypassThrottlingAttribute' => function ($request) {
|
||||
if ($request->getAttribute('bypassThrottling')) {
|
||||
@@ -52,11 +51,11 @@ class ApiServiceProvider extends AbstractServiceProvider
|
||||
];
|
||||
});
|
||||
|
||||
$this->app->bind(Middleware\ThrottleApi::class, function ($app) {
|
||||
return new Middleware\ThrottleApi($app->make('flarum.api.throttlers'));
|
||||
$this->container->bind(Middleware\ThrottleApi::class, function ($container) {
|
||||
return new Middleware\ThrottleApi($container->make('flarum.api.throttlers'));
|
||||
});
|
||||
|
||||
$this->app->singleton('flarum.api.middleware', function () {
|
||||
$this->container->singleton('flarum.api.middleware', function () {
|
||||
return [
|
||||
'flarum.api.error_handler',
|
||||
HttpMiddleware\ParseJsonBody::class,
|
||||
@@ -72,23 +71,23 @@ class ApiServiceProvider extends AbstractServiceProvider
|
||||
];
|
||||
});
|
||||
|
||||
$this->app->bind('flarum.api.error_handler', function () {
|
||||
$this->container->bind('flarum.api.error_handler', function () {
|
||||
return new HttpMiddleware\HandleErrors(
|
||||
$this->app->make(Registry::class),
|
||||
new JsonApiFormatter($this->app['flarum.config']->inDebugMode()),
|
||||
$this->app->tagged(Reporter::class)
|
||||
$this->container->make(Registry::class),
|
||||
new JsonApiFormatter($this->container['flarum.config']->inDebugMode()),
|
||||
$this->container->tagged(Reporter::class)
|
||||
);
|
||||
});
|
||||
|
||||
$this->app->bind('flarum.api.route_resolver', function () {
|
||||
return new HttpMiddleware\ResolveRoute($this->app->make('flarum.api.routes'));
|
||||
$this->container->bind('flarum.api.route_resolver', function () {
|
||||
return new HttpMiddleware\ResolveRoute($this->container->make('flarum.api.routes'));
|
||||
});
|
||||
|
||||
$this->app->singleton('flarum.api.handler', function () {
|
||||
$this->container->singleton('flarum.api.handler', function () {
|
||||
$pipe = new MiddlewarePipe;
|
||||
|
||||
foreach ($this->app->make('flarum.api.middleware') as $middleware) {
|
||||
$pipe->pipe($this->app->make($middleware));
|
||||
foreach ($this->container->make('flarum.api.middleware') as $middleware) {
|
||||
$pipe->pipe($this->container->make($middleware));
|
||||
}
|
||||
|
||||
$pipe->pipe(new HttpMiddleware\ExecuteRoute());
|
||||
@@ -96,7 +95,7 @@ class ApiServiceProvider extends AbstractServiceProvider
|
||||
return $pipe;
|
||||
});
|
||||
|
||||
$this->app->singleton('flarum.api.notification_serializers', function () {
|
||||
$this->container->singleton('flarum.api.notification_serializers', function () {
|
||||
return [
|
||||
'discussionRenamed' => BasicDiscussionSerializer::class
|
||||
];
|
||||
@@ -110,11 +109,9 @@ class ApiServiceProvider extends AbstractServiceProvider
|
||||
{
|
||||
$this->setNotificationSerializers();
|
||||
|
||||
AbstractSerializeController::setContainer($this->app);
|
||||
AbstractSerializeController::setEventDispatcher($events = $this->app->make('events'));
|
||||
AbstractSerializeController::setContainer($this->container);
|
||||
|
||||
AbstractSerializer::setContainer($this->app);
|
||||
AbstractSerializer::setEventDispatcher($events);
|
||||
AbstractSerializer::setContainer($this->container);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -122,13 +119,7 @@ class ApiServiceProvider extends AbstractServiceProvider
|
||||
*/
|
||||
protected function setNotificationSerializers()
|
||||
{
|
||||
$blueprints = [];
|
||||
$serializers = $this->app->make('flarum.api.notification_serializers');
|
||||
|
||||
// Deprecated in beta 15, remove in beta 16
|
||||
$this->app->make('events')->dispatch(
|
||||
new ConfigureNotificationTypes($blueprints, $serializers)
|
||||
);
|
||||
$serializers = $this->container->make('flarum.api.notification_serializers');
|
||||
|
||||
foreach ($serializers as $type => $serializer) {
|
||||
NotificationSerializer::setSubjectSerializer($type, $serializer);
|
||||
@@ -142,7 +133,7 @@ class ApiServiceProvider extends AbstractServiceProvider
|
||||
*/
|
||||
protected function populateRoutes(RouteCollection $routes)
|
||||
{
|
||||
$factory = $this->app->make(RouteHandlerFactory::class);
|
||||
$factory = $this->container->make(RouteHandlerFactory::class);
|
||||
|
||||
$callback = include __DIR__.'/routes.php';
|
||||
$callback($routes, $factory);
|
||||
|
@@ -9,11 +9,8 @@
|
||||
|
||||
namespace Flarum\Api\Controller;
|
||||
|
||||
use Flarum\Api\Event\WillGetData;
|
||||
use Flarum\Api\Event\WillSerializeData;
|
||||
use Flarum\Api\JsonApiResponse;
|
||||
use Illuminate\Contracts\Container\Container;
|
||||
use Illuminate\Contracts\Events\Dispatcher;
|
||||
use Psr\Http\Message\ResponseInterface;
|
||||
use Psr\Http\Message\ServerRequestInterface;
|
||||
use Psr\Http\Server\RequestHandlerInterface;
|
||||
@@ -77,11 +74,6 @@ abstract class AbstractSerializeController implements RequestHandlerInterface
|
||||
*/
|
||||
protected static $container;
|
||||
|
||||
/**
|
||||
* @var Dispatcher
|
||||
*/
|
||||
protected static $events;
|
||||
|
||||
/**
|
||||
* @var array
|
||||
*/
|
||||
@@ -107,11 +99,6 @@ abstract class AbstractSerializeController implements RequestHandlerInterface
|
||||
}
|
||||
}
|
||||
|
||||
// Deprected in beta 15, removed in beta 16
|
||||
static::$events->dispatch(
|
||||
new WillGetData($this)
|
||||
);
|
||||
|
||||
$data = $this->data($request, $document);
|
||||
|
||||
foreach (array_reverse(array_merge([static::class], class_parents($this))) as $class) {
|
||||
@@ -122,11 +109,6 @@ abstract class AbstractSerializeController implements RequestHandlerInterface
|
||||
}
|
||||
}
|
||||
|
||||
// Deprecated in beta 15, removed in beta 16
|
||||
static::$events->dispatch(
|
||||
new WillSerializeData($this, $data, $request, $document)
|
||||
);
|
||||
|
||||
$serializer = static::$container->make($this->serializer);
|
||||
$serializer->setRequest($request);
|
||||
|
||||
@@ -325,22 +307,6 @@ abstract class AbstractSerializeController implements RequestHandlerInterface
|
||||
$this->sort = $sort;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return Dispatcher
|
||||
*/
|
||||
public static function getEventDispatcher()
|
||||
{
|
||||
return static::$events;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param Dispatcher $events
|
||||
*/
|
||||
public static function setEventDispatcher(Dispatcher $events)
|
||||
{
|
||||
static::$events = $events;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return Container
|
||||
*/
|
||||
|
@@ -12,7 +12,6 @@ namespace Flarum\Api\Controller;
|
||||
use Flarum\Api\Serializer\DiscussionSerializer;
|
||||
use Flarum\Discussion\Command\ReadDiscussion;
|
||||
use Flarum\Discussion\Command\StartDiscussion;
|
||||
use Flarum\Post\Floodgate;
|
||||
use Illuminate\Contracts\Bus\Dispatcher;
|
||||
use Illuminate\Support\Arr;
|
||||
use Psr\Http\Message\ServerRequestInterface;
|
||||
@@ -41,19 +40,12 @@ class CreateDiscussionController extends AbstractCreateController
|
||||
*/
|
||||
protected $bus;
|
||||
|
||||
/**
|
||||
* @var Floodgate
|
||||
*/
|
||||
protected $floodgate;
|
||||
|
||||
/**
|
||||
* @param Dispatcher $bus
|
||||
* @param Floodgate $floodgate
|
||||
*/
|
||||
public function __construct(Dispatcher $bus, Floodgate $floodgate)
|
||||
public function __construct(Dispatcher $bus)
|
||||
{
|
||||
$this->bus = $bus;
|
||||
$this->floodgate = $floodgate;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -62,14 +54,7 @@ class CreateDiscussionController extends AbstractCreateController
|
||||
protected function data(ServerRequestInterface $request, Document $document)
|
||||
{
|
||||
$actor = $request->getAttribute('actor');
|
||||
$ipAddress = Arr::get($request->getServerParams(), 'REMOTE_ADDR', '127.0.0.1');
|
||||
|
||||
/**
|
||||
* @deprecated, remove in beta 15.
|
||||
*/
|
||||
if (! $request->getAttribute('bypassFloodgate')) {
|
||||
$this->floodgate->assertNotFlooding($actor);
|
||||
}
|
||||
$ipAddress = $request->getAttribute('ipAddress');
|
||||
|
||||
$discussion = $this->bus->dispatch(
|
||||
new StartDiscussion($actor, Arr::get($request->getParsedBody(), 'data', []), $ipAddress)
|
||||
|
@@ -12,7 +12,6 @@ namespace Flarum\Api\Controller;
|
||||
use Flarum\Api\Serializer\PostSerializer;
|
||||
use Flarum\Discussion\Command\ReadDiscussion;
|
||||
use Flarum\Post\Command\PostReply;
|
||||
use Flarum\Post\Floodgate;
|
||||
use Illuminate\Contracts\Bus\Dispatcher;
|
||||
use Illuminate\Support\Arr;
|
||||
use Psr\Http\Message\ServerRequestInterface;
|
||||
@@ -40,19 +39,12 @@ class CreatePostController extends AbstractCreateController
|
||||
*/
|
||||
protected $bus;
|
||||
|
||||
/**
|
||||
* @var \Flarum\Post\Floodgate
|
||||
*/
|
||||
protected $floodgate;
|
||||
|
||||
/**
|
||||
* @param Dispatcher $bus
|
||||
* @param \Flarum\Post\Floodgate $floodgate
|
||||
*/
|
||||
public function __construct(Dispatcher $bus, Floodgate $floodgate)
|
||||
public function __construct(Dispatcher $bus)
|
||||
{
|
||||
$this->bus = $bus;
|
||||
$this->floodgate = $floodgate;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -63,14 +55,7 @@ class CreatePostController extends AbstractCreateController
|
||||
$actor = $request->getAttribute('actor');
|
||||
$data = Arr::get($request->getParsedBody(), 'data', []);
|
||||
$discussionId = Arr::get($data, 'relationships.discussion.data.id');
|
||||
$ipAddress = Arr::get($request->getServerParams(), 'REMOTE_ADDR', '127.0.0.1');
|
||||
|
||||
/**
|
||||
* @deprecated, remove in beta 15.
|
||||
*/
|
||||
if (! $request->getAttribute('bypassFloodgate')) {
|
||||
$this->floodgate->assertNotFlooding($actor);
|
||||
}
|
||||
$ipAddress = $request->getAttribute('ipAddress');
|
||||
|
||||
$post = $this->bus->dispatch(
|
||||
new PostReply($discussionId, $actor, $data, $ipAddress)
|
||||
|
@@ -9,7 +9,8 @@
|
||||
|
||||
namespace Flarum\Api\Controller;
|
||||
|
||||
use Flarum\Http\AccessToken;
|
||||
use Flarum\Http\RememberAccessToken;
|
||||
use Flarum\Http\SessionAccessToken;
|
||||
use Flarum\User\Exception\NotAuthenticatedException;
|
||||
use Flarum\User\UserRepository;
|
||||
use Illuminate\Contracts\Bus\Dispatcher as BusDispatcher;
|
||||
@@ -66,8 +67,20 @@ class CreateTokenController implements RequestHandlerInterface
|
||||
throw new NotAuthenticatedException;
|
||||
}
|
||||
|
||||
$token = AccessToken::generate($user->id, $lifetime);
|
||||
$token->save();
|
||||
// Use of lifetime attribute is deprecated in beta 16, removed in beta 17
|
||||
// For backward compatibility with custom integrations, longer lifetimes will be interpreted as remember tokens
|
||||
if ($lifetime > 3600 || Arr::get($body, 'remember')) {
|
||||
if ($lifetime > 3600) {
|
||||
trigger_error('Use of parameter lifetime is deprecated in beta 16, will be removed in beta 17. Use remember parameter to start a remember session', E_USER_DEPRECATED);
|
||||
}
|
||||
|
||||
$token = RememberAccessToken::generate($user->id);
|
||||
} else {
|
||||
$token = SessionAccessToken::generate($user->id);
|
||||
}
|
||||
|
||||
// We do a first update here to log the IP/agent of the token creator, even if the token is never used afterwards
|
||||
$token->touch($request);
|
||||
|
||||
return new JsonResponse([
|
||||
'token' => $token->token,
|
||||
|
@@ -11,10 +11,10 @@ namespace Flarum\Api\Controller;
|
||||
|
||||
use Flarum\Api\Serializer\DiscussionSerializer;
|
||||
use Flarum\Discussion\Discussion;
|
||||
use Flarum\Discussion\Filter\DiscussionFilterer;
|
||||
use Flarum\Discussion\Search\DiscussionSearcher;
|
||||
use Flarum\Http\UrlGenerator;
|
||||
use Flarum\Search\SearchCriteria;
|
||||
use Illuminate\Support\Arr;
|
||||
use Flarum\Query\QueryCriteria;
|
||||
use Psr\Http\Message\ServerRequestInterface;
|
||||
use Tobscure\JsonApi\Document;
|
||||
|
||||
@@ -43,11 +43,21 @@ class ListDiscussionsController extends AbstractListController
|
||||
'lastPost'
|
||||
];
|
||||
|
||||
/**
|
||||
* {@inheritDoc}
|
||||
*/
|
||||
public $sort = ['lastPostedAt' => 'desc'];
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
public $sortFields = ['lastPostedAt', 'commentCount', 'createdAt'];
|
||||
|
||||
/**
|
||||
* @var DiscussionFilterer
|
||||
*/
|
||||
protected $filterer;
|
||||
|
||||
/**
|
||||
* @var DiscussionSearcher
|
||||
*/
|
||||
@@ -59,11 +69,13 @@ class ListDiscussionsController extends AbstractListController
|
||||
protected $url;
|
||||
|
||||
/**
|
||||
* @param DiscussionFilterer $filterer
|
||||
* @param DiscussionSearcher $searcher
|
||||
* @param UrlGenerator $url
|
||||
*/
|
||||
public function __construct(DiscussionSearcher $searcher, UrlGenerator $url)
|
||||
public function __construct(DiscussionFilterer $filterer, DiscussionSearcher $searcher, UrlGenerator $url)
|
||||
{
|
||||
$this->filterer = $filterer;
|
||||
$this->searcher = $searcher;
|
||||
$this->url = $url;
|
||||
}
|
||||
@@ -74,16 +86,19 @@ class ListDiscussionsController extends AbstractListController
|
||||
protected function data(ServerRequestInterface $request, Document $document)
|
||||
{
|
||||
$actor = $request->getAttribute('actor');
|
||||
$query = Arr::get($this->extractFilter($request), 'q');
|
||||
$filters = $this->extractFilter($request);
|
||||
$sort = $this->extractSort($request);
|
||||
|
||||
$criteria = new SearchCriteria($actor, $query, $sort);
|
||||
|
||||
$limit = $this->extractLimit($request);
|
||||
$offset = $this->extractOffset($request);
|
||||
$load = array_merge($this->extractInclude($request), ['state']);
|
||||
$include = array_merge($this->extractInclude($request), ['state']);
|
||||
|
||||
$results = $this->searcher->search($criteria, $limit, $offset);
|
||||
$criteria = new QueryCriteria($actor, $filters, $sort);
|
||||
if (array_key_exists('q', $filters)) {
|
||||
$results = $this->searcher->search($criteria, $limit, $offset);
|
||||
} else {
|
||||
$results = $this->filterer->filter($criteria, $limit, $offset);
|
||||
}
|
||||
|
||||
$document->addPaginationLinks(
|
||||
$this->url->to('api')->route('discussions.index'),
|
||||
@@ -95,9 +110,20 @@ class ListDiscussionsController extends AbstractListController
|
||||
|
||||
Discussion::setStateUser($actor);
|
||||
|
||||
$results = $results->getResults()->load($load);
|
||||
// Eager load groups for use in the policies (isAdmin check)
|
||||
if (in_array('mostRelevantPost.user', $include)) {
|
||||
$include[] = 'mostRelevantPost.user.groups';
|
||||
|
||||
if ($relations = array_intersect($load, ['firstPost', 'lastPost'])) {
|
||||
// If the first level of the relationship wasn't explicitly included,
|
||||
// add it so the code below can look for it
|
||||
if (! in_array('mostRelevantPost', $include)) {
|
||||
$include[] = 'mostRelevantPost';
|
||||
}
|
||||
}
|
||||
|
||||
$results = $results->getResults()->load($include);
|
||||
|
||||
if ($relations = array_intersect($include, ['firstPost', 'lastPost', 'mostRelevantPost'])) {
|
||||
foreach ($results as $discussion) {
|
||||
foreach ($relations as $relation) {
|
||||
if ($discussion->$relation) {
|
||||
|
@@ -10,11 +10,11 @@
|
||||
namespace Flarum\Api\Controller;
|
||||
|
||||
use Flarum\Api\Serializer\PostSerializer;
|
||||
use Flarum\Event\ConfigurePostsQuery;
|
||||
use Flarum\Http\UrlGenerator;
|
||||
use Flarum\Post\Filter\PostFilterer;
|
||||
use Flarum\Post\PostRepository;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Flarum\Query\QueryCriteria;
|
||||
use Illuminate\Support\Arr;
|
||||
use Illuminate\Support\Str;
|
||||
use Psr\Http\Message\ServerRequestInterface;
|
||||
use Tobscure\JsonApi\Document;
|
||||
use Tobscure\JsonApi\Exception\InvalidParameterException;
|
||||
@@ -43,16 +43,30 @@ class ListPostsController extends AbstractListController
|
||||
public $sortFields = ['createdAt'];
|
||||
|
||||
/**
|
||||
* @var \Flarum\Post\PostRepository
|
||||
* @var PostFilterer
|
||||
*/
|
||||
protected $filterer;
|
||||
|
||||
/**
|
||||
* @var PostRepository
|
||||
*/
|
||||
protected $posts;
|
||||
|
||||
/**
|
||||
* @param \Flarum\Post\PostRepository $posts
|
||||
* @var UrlGenerator
|
||||
*/
|
||||
public function __construct(PostRepository $posts)
|
||||
protected $url;
|
||||
|
||||
/**
|
||||
* @param PostFilterer $filterer
|
||||
* @param PostRepository $posts
|
||||
* @param UrlGenerator $url
|
||||
*/
|
||||
public function __construct(PostFilterer $filterer, PostRepository $posts, UrlGenerator $url)
|
||||
{
|
||||
$this->filterer = $filterer;
|
||||
$this->posts = $posts;
|
||||
$this->url = $url;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -61,18 +75,25 @@ class ListPostsController extends AbstractListController
|
||||
protected function data(ServerRequestInterface $request, Document $document)
|
||||
{
|
||||
$actor = $request->getAttribute('actor');
|
||||
$filter = $this->extractFilter($request);
|
||||
|
||||
$filters = $this->extractFilter($request);
|
||||
$sort = $this->extractSort($request);
|
||||
|
||||
$limit = $this->extractLimit($request);
|
||||
$offset = $this->extractOffset($request);
|
||||
$include = $this->extractInclude($request);
|
||||
|
||||
if ($postIds = Arr::get($filter, 'id')) {
|
||||
$postIds = explode(',', $postIds);
|
||||
} else {
|
||||
$postIds = $this->getPostIds($request);
|
||||
}
|
||||
$results = $this->filterer->filter(new QueryCriteria($actor, $filters, $sort), $limit, $offset);
|
||||
|
||||
$posts = $this->posts->findByIds($postIds, $actor);
|
||||
$document->addPaginationLinks(
|
||||
$this->url->to('api')->route('posts.index'),
|
||||
$request->getQueryParams(),
|
||||
$offset,
|
||||
$limit,
|
||||
$results->areMoreResults() ? null : 0
|
||||
);
|
||||
|
||||
return $posts->load($include);
|
||||
return $results->getResults()->load($include);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -100,55 +121,4 @@ class ListPostsController extends AbstractListController
|
||||
|
||||
return parent::extractOffset($request);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param ServerRequestInterface $request
|
||||
* @return array
|
||||
* @throws InvalidParameterException
|
||||
*/
|
||||
private function getPostIds(ServerRequestInterface $request)
|
||||
{
|
||||
$actor = $request->getAttribute('actor');
|
||||
$filter = $this->extractFilter($request);
|
||||
$sort = $this->extractSort($request);
|
||||
$limit = $this->extractLimit($request);
|
||||
$offset = $this->extractOffset($request);
|
||||
|
||||
$query = $this->posts->query()->whereVisibleTo($actor);
|
||||
|
||||
$this->applyFilters($query, $filter);
|
||||
|
||||
$query->skip($offset)->take($limit);
|
||||
|
||||
foreach ((array) $sort as $field => $order) {
|
||||
$query->orderBy(Str::snake($field), $order);
|
||||
}
|
||||
|
||||
return $query->pluck('id')->all();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param Builder $query
|
||||
* @param array $filter
|
||||
*/
|
||||
private function applyFilters(Builder $query, array $filter)
|
||||
{
|
||||
if ($discussionId = Arr::get($filter, 'discussion')) {
|
||||
$query->where('discussion_id', $discussionId);
|
||||
}
|
||||
|
||||
if ($number = Arr::get($filter, 'number')) {
|
||||
$query->where('number', $number);
|
||||
}
|
||||
|
||||
if ($userId = Arr::get($filter, 'user')) {
|
||||
$query->where('user_id', $userId);
|
||||
}
|
||||
|
||||
if ($type = Arr::get($filter, 'type')) {
|
||||
$query->where('type', $type);
|
||||
}
|
||||
|
||||
event(new ConfigurePostsQuery($query, $filter));
|
||||
}
|
||||
}
|
||||
|
@@ -11,9 +11,9 @@ namespace Flarum\Api\Controller;
|
||||
|
||||
use Flarum\Api\Serializer\UserSerializer;
|
||||
use Flarum\Http\UrlGenerator;
|
||||
use Flarum\Search\SearchCriteria;
|
||||
use Flarum\Query\QueryCriteria;
|
||||
use Flarum\User\Filter\UserFilterer;
|
||||
use Flarum\User\Search\UserSearcher;
|
||||
use Illuminate\Support\Arr;
|
||||
use Psr\Http\Message\ServerRequestInterface;
|
||||
use Tobscure\JsonApi\Document;
|
||||
|
||||
@@ -40,6 +40,11 @@ class ListUsersController extends AbstractListController
|
||||
'joinedAt'
|
||||
];
|
||||
|
||||
/**
|
||||
* @var UserFilterer
|
||||
*/
|
||||
protected $filterer;
|
||||
|
||||
/**
|
||||
* @var UserSearcher
|
||||
*/
|
||||
@@ -51,11 +56,13 @@ class ListUsersController extends AbstractListController
|
||||
protected $url;
|
||||
|
||||
/**
|
||||
* @param UserFilterer $filterer
|
||||
* @param UserSearcher $searcher
|
||||
* @param UrlGenerator $url
|
||||
*/
|
||||
public function __construct(UserSearcher $searcher, UrlGenerator $url)
|
||||
public function __construct(UserFilterer $filterer, UserSearcher $searcher, UrlGenerator $url)
|
||||
{
|
||||
$this->filterer = $filterer;
|
||||
$this->searcher = $searcher;
|
||||
$this->url = $url;
|
||||
}
|
||||
@@ -69,16 +76,26 @@ class ListUsersController extends AbstractListController
|
||||
|
||||
$actor->assertCan('viewUserList');
|
||||
|
||||
$query = Arr::get($this->extractFilter($request), 'q');
|
||||
$sort = $this->extractSort($request);
|
||||
if (! $actor->hasPermission('user.viewLastSeenAt')) {
|
||||
// If a user cannot see everyone's last online date, we prevent them from sorting by it
|
||||
// Otherwise this sort field would defeat the privacy setting discloseOnline
|
||||
// We use remove instead of add so that extensions can still completely disable the sort using the extender
|
||||
$this->removeSortField('lastSeenAt');
|
||||
}
|
||||
|
||||
$criteria = new SearchCriteria($actor, $query, $sort);
|
||||
$filters = $this->extractFilter($request);
|
||||
$sort = $this->extractSort($request);
|
||||
|
||||
$limit = $this->extractLimit($request);
|
||||
$offset = $this->extractOffset($request);
|
||||
$load = $this->extractInclude($request);
|
||||
$include = $this->extractInclude($request);
|
||||
|
||||
$results = $this->searcher->search($criteria, $limit, $offset, $load);
|
||||
$criteria = new QueryCriteria($actor, $filters, $sort);
|
||||
if (array_key_exists('q', $filters)) {
|
||||
$results = $this->searcher->search($criteria, $limit, $offset);
|
||||
} else {
|
||||
$results = $this->filterer->filter($criteria, $limit, $offset);
|
||||
}
|
||||
|
||||
$document->addPaginationLinks(
|
||||
$this->url->to('api')->route('users.index'),
|
||||
@@ -88,6 +105,6 @@ class ListUsersController extends AbstractListController
|
||||
$results->areMoreResults() ? null : 0
|
||||
);
|
||||
|
||||
return $results->getResults();
|
||||
return $results->getResults()->load($include);
|
||||
}
|
||||
}
|
||||
|
@@ -20,7 +20,7 @@ use Laminas\Diactoros\Response\EmptyResponse;
|
||||
use Psr\Http\Message\ResponseInterface;
|
||||
use Psr\Http\Message\ServerRequestInterface;
|
||||
use Psr\Http\Server\RequestHandlerInterface;
|
||||
use Symfony\Component\Translation\TranslatorInterface;
|
||||
use Symfony\Contracts\Translation\TranslatorInterface;
|
||||
|
||||
class SendConfirmationEmailController implements RequestHandlerInterface
|
||||
{
|
||||
|
@@ -16,7 +16,7 @@ use Laminas\Diactoros\Response\EmptyResponse;
|
||||
use Psr\Http\Message\ResponseInterface;
|
||||
use Psr\Http\Message\ServerRequestInterface;
|
||||
use Psr\Http\Server\RequestHandlerInterface;
|
||||
use Symfony\Component\Translation\TranslatorInterface;
|
||||
use Symfony\Contracts\Translation\TranslatorInterface;
|
||||
|
||||
class SendTestMailController implements RequestHandlerInterface
|
||||
{
|
||||
|
@@ -1,83 +0,0 @@
|
||||
<?php
|
||||
|
||||
/*
|
||||
* This file is part of Flarum.
|
||||
*
|
||||
* For detailed copyright and license information, please view the
|
||||
* LICENSE file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace Flarum\Api\Event;
|
||||
|
||||
use DateTime;
|
||||
use Flarum\Api\Serializer\AbstractSerializer;
|
||||
|
||||
/**
|
||||
* Prepare API attributes.
|
||||
*
|
||||
* This event is fired when a serializer is constructing an array of resource
|
||||
* attributes for API output.
|
||||
*
|
||||
* @deprecated in beta 15, removed in beta 16
|
||||
*/
|
||||
class Serializing
|
||||
{
|
||||
/**
|
||||
* The class doing the serializing.
|
||||
*
|
||||
* @var AbstractSerializer
|
||||
*/
|
||||
public $serializer;
|
||||
|
||||
/**
|
||||
* The model being serialized.
|
||||
*
|
||||
* @var object
|
||||
*/
|
||||
public $model;
|
||||
|
||||
/**
|
||||
* The serialized attributes of the resource.
|
||||
*
|
||||
* @var array
|
||||
*/
|
||||
public $attributes;
|
||||
|
||||
/**
|
||||
* @var \Flarum\User\User
|
||||
*/
|
||||
public $actor;
|
||||
|
||||
/**
|
||||
* @param AbstractSerializer $serializer The class doing the serializing.
|
||||
* @param object|array $model The model being serialized.
|
||||
* @param array $attributes The serialized attributes of the resource.
|
||||
*/
|
||||
public function __construct(AbstractSerializer $serializer, $model, array &$attributes)
|
||||
{
|
||||
$this->serializer = $serializer;
|
||||
$this->model = $model;
|
||||
$this->attributes = &$attributes;
|
||||
$this->actor = $serializer->getActor();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param string $serializer
|
||||
* @return bool
|
||||
*/
|
||||
public function isSerializer($serializer)
|
||||
{
|
||||
return $this->serializer instanceof $serializer;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param DateTime|null $date
|
||||
* @return string|null
|
||||
*/
|
||||
public function formatDate(DateTime $date = null)
|
||||
{
|
||||
if ($date) {
|
||||
return $date->format(DateTime::RFC3339);
|
||||
}
|
||||
}
|
||||
}
|
@@ -1,141 +0,0 @@
|
||||
<?php
|
||||
|
||||
/*
|
||||
* This file is part of Flarum.
|
||||
*
|
||||
* For detailed copyright and license information, please view the
|
||||
* LICENSE file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace Flarum\Api\Event;
|
||||
|
||||
use Flarum\Api\Controller\AbstractSerializeController;
|
||||
use Illuminate\Support\Arr;
|
||||
|
||||
/**
|
||||
* @deprecated in beta 15, removed in beta 16
|
||||
*/
|
||||
class WillGetData
|
||||
{
|
||||
/**
|
||||
* @var AbstractSerializeController
|
||||
*/
|
||||
public $controller;
|
||||
|
||||
/**
|
||||
* @param AbstractSerializeController $controller
|
||||
*/
|
||||
public function __construct(AbstractSerializeController $controller)
|
||||
{
|
||||
$this->controller = $controller;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param string $controller
|
||||
* @return bool
|
||||
*/
|
||||
public function isController($controller)
|
||||
{
|
||||
return $this->controller instanceof $controller;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the serializer that will serialize data for the endpoint.
|
||||
*
|
||||
* @param string $serializer
|
||||
*/
|
||||
public function setSerializer($serializer)
|
||||
{
|
||||
$this->controller->serializer = $serializer;
|
||||
}
|
||||
|
||||
/**
|
||||
* Include the given relationship by default.
|
||||
*
|
||||
* @param string|array $name
|
||||
*/
|
||||
public function addInclude($name)
|
||||
{
|
||||
$this->controller->include = array_merge($this->controller->include, (array) $name);
|
||||
}
|
||||
|
||||
/**
|
||||
* Don't include the given relationship by default.
|
||||
*
|
||||
* @param string $name
|
||||
*/
|
||||
public function removeInclude($name)
|
||||
{
|
||||
Arr::forget($this->controller->include, $name);
|
||||
}
|
||||
|
||||
/**
|
||||
* Make the given relationship available for inclusion.
|
||||
*
|
||||
* @param string $name
|
||||
*/
|
||||
public function addOptionalInclude($name)
|
||||
{
|
||||
$this->controller->optionalInclude[] = $name;
|
||||
}
|
||||
|
||||
/**
|
||||
* Don't allow the given relationship to be included.
|
||||
*
|
||||
* @param string $name
|
||||
*/
|
||||
public function removeOptionalInclude($name)
|
||||
{
|
||||
Arr::forget($this->controller->optionalInclude, $name);
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the default number of results.
|
||||
*
|
||||
* @param int $limit
|
||||
*/
|
||||
public function setLimit($limit)
|
||||
{
|
||||
$this->controller->limit = $limit;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the maximum number of results.
|
||||
*
|
||||
* @param int $max
|
||||
*/
|
||||
public function setMaxLimit($max)
|
||||
{
|
||||
$this->controller->maxLimit = $max;
|
||||
}
|
||||
|
||||
/**
|
||||
* Allow sorting results by the given field.
|
||||
*
|
||||
* @param string $field
|
||||
*/
|
||||
public function addSortField($field)
|
||||
{
|
||||
$this->controller->sortFields[] = $field;
|
||||
}
|
||||
|
||||
/**
|
||||
* Disallow sorting results by the given field.
|
||||
*
|
||||
* @param string $field
|
||||
*/
|
||||
public function removeSortField($field)
|
||||
{
|
||||
Arr::forget($this->controller->sortFields, $field);
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the default sort order for the results.
|
||||
*
|
||||
* @param array $sort
|
||||
*/
|
||||
public function setSort(array $sort)
|
||||
{
|
||||
$this->controller->sort = $sort;
|
||||
}
|
||||
}
|
@@ -1,73 +0,0 @@
|
||||
<?php
|
||||
|
||||
/*
|
||||
* This file is part of Flarum.
|
||||
*
|
||||
* For detailed copyright and license information, please view the
|
||||
* LICENSE file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace Flarum\Api\Event;
|
||||
|
||||
use Flarum\Api\Controller\AbstractSerializeController;
|
||||
use Psr\Http\Message\ServerRequestInterface;
|
||||
use Tobscure\JsonApi\Document;
|
||||
|
||||
/**
|
||||
* @deprecated in beta 15, removed in beta 16
|
||||
*/
|
||||
class WillSerializeData
|
||||
{
|
||||
/**
|
||||
* @var AbstractSerializeController
|
||||
*/
|
||||
public $controller;
|
||||
|
||||
/**
|
||||
* @var mixed
|
||||
*/
|
||||
public $data;
|
||||
|
||||
/**
|
||||
* @var ServerRequestInterface
|
||||
*/
|
||||
public $request;
|
||||
|
||||
/**
|
||||
* @var Document
|
||||
*/
|
||||
public $document;
|
||||
|
||||
/**
|
||||
* @var \Flarum\User\User
|
||||
*/
|
||||
public $actor;
|
||||
|
||||
/**
|
||||
* @param AbstractSerializeController $controller
|
||||
* @param mixed $data
|
||||
* @param ServerRequestInterface $request
|
||||
* @param Document $document
|
||||
*/
|
||||
public function __construct(
|
||||
AbstractSerializeController $controller,
|
||||
&$data,
|
||||
ServerRequestInterface $request,
|
||||
Document $document
|
||||
) {
|
||||
$this->controller = $controller;
|
||||
$this->data = &$data;
|
||||
$this->request = $request;
|
||||
$this->document = $document;
|
||||
$this->actor = $request->getAttribute('actor');
|
||||
}
|
||||
|
||||
/**
|
||||
* @param string $controller
|
||||
* @return bool
|
||||
*/
|
||||
public function isController($controller)
|
||||
{
|
||||
return $this->controller instanceof $controller;
|
||||
}
|
||||
}
|
@@ -11,11 +11,8 @@ namespace Flarum\Api\Serializer;
|
||||
|
||||
use Closure;
|
||||
use DateTime;
|
||||
use Flarum\Api\Event\Serializing;
|
||||
use Flarum\Event\GetApiRelationship;
|
||||
use Flarum\User\User;
|
||||
use Illuminate\Contracts\Container\Container;
|
||||
use Illuminate\Contracts\Events\Dispatcher;
|
||||
use Illuminate\Support\Arr;
|
||||
use InvalidArgumentException;
|
||||
use LogicException;
|
||||
@@ -38,11 +35,6 @@ abstract class AbstractSerializer extends BaseAbstractSerializer
|
||||
*/
|
||||
protected $actor;
|
||||
|
||||
/**
|
||||
* @var Dispatcher
|
||||
*/
|
||||
protected static $dispatcher;
|
||||
|
||||
/**
|
||||
* @var Container
|
||||
*/
|
||||
@@ -51,7 +43,7 @@ abstract class AbstractSerializer extends BaseAbstractSerializer
|
||||
/**
|
||||
* @var callable[]
|
||||
*/
|
||||
protected static $mutators = [];
|
||||
protected static $attributeMutators = [];
|
||||
|
||||
/**
|
||||
* @var array
|
||||
@@ -95,8 +87,8 @@ abstract class AbstractSerializer extends BaseAbstractSerializer
|
||||
$attributes = $this->getDefaultAttributes($model);
|
||||
|
||||
foreach (array_reverse(array_merge([static::class], class_parents($this))) as $class) {
|
||||
if (isset(static::$mutators[$class])) {
|
||||
foreach (static::$mutators[$class] as $callback) {
|
||||
if (isset(static::$attributeMutators[$class])) {
|
||||
foreach (static::$attributeMutators[$class] as $callback) {
|
||||
$attributes = array_merge(
|
||||
$attributes,
|
||||
$callback($this, $model, $attributes)
|
||||
@@ -105,11 +97,6 @@ abstract class AbstractSerializer extends BaseAbstractSerializer
|
||||
}
|
||||
}
|
||||
|
||||
// Deprecated in beta 15, removed in beta 16
|
||||
static::$dispatcher->dispatch(
|
||||
new Serializing($this, $model, $attributes)
|
||||
);
|
||||
|
||||
return $attributes;
|
||||
}
|
||||
|
||||
@@ -153,27 +140,21 @@ abstract class AbstractSerializer extends BaseAbstractSerializer
|
||||
*/
|
||||
protected function getCustomRelationship($model, $name)
|
||||
{
|
||||
// Deprecated in beta 15, removed in beta 16
|
||||
$relationship = static::$dispatcher->until(
|
||||
new GetApiRelationship($this, $name, $model)
|
||||
);
|
||||
|
||||
foreach (array_merge([static::class], class_parents($this)) as $class) {
|
||||
$callback = Arr::get(static::$customRelations, "$class.$name");
|
||||
|
||||
if (is_callable($callback)) {
|
||||
$relationship = $callback($this, $model);
|
||||
break;
|
||||
|
||||
if (isset($relationship) && ! ($relationship instanceof Relationship)) {
|
||||
throw new LogicException(
|
||||
'GetApiRelationship handler must return an instance of '.Relationship::class
|
||||
);
|
||||
}
|
||||
|
||||
return $relationship;
|
||||
}
|
||||
}
|
||||
|
||||
if ($relationship && ! ($relationship instanceof Relationship)) {
|
||||
throw new LogicException(
|
||||
'GetApiRelationship handler must return an instance of '.Relationship::class
|
||||
);
|
||||
}
|
||||
|
||||
return $relationship;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -282,22 +263,6 @@ abstract class AbstractSerializer extends BaseAbstractSerializer
|
||||
return $serializer;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return Dispatcher
|
||||
*/
|
||||
public static function getEventDispatcher()
|
||||
{
|
||||
return static::$dispatcher;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param Dispatcher $dispatcher
|
||||
*/
|
||||
public static function setEventDispatcher(Dispatcher $dispatcher)
|
||||
{
|
||||
static::$dispatcher = $dispatcher;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return Container
|
||||
*/
|
||||
@@ -316,15 +281,15 @@ abstract class AbstractSerializer extends BaseAbstractSerializer
|
||||
|
||||
/**
|
||||
* @param string $serializerClass
|
||||
* @param callable $mutator
|
||||
* @param callable $callback
|
||||
*/
|
||||
public static function addMutator(string $serializerClass, callable $mutator)
|
||||
public static function addAttributeMutator(string $serializerClass, callable $callback)
|
||||
{
|
||||
if (! isset(static::$mutators[$serializerClass])) {
|
||||
static::$mutators[$serializerClass] = [];
|
||||
if (! isset(static::$attributeMutators[$serializerClass])) {
|
||||
static::$attributeMutators[$serializerClass] = [];
|
||||
}
|
||||
|
||||
static::$mutators[$serializerClass][] = $mutator;
|
||||
static::$attributeMutators[$serializerClass][] = $callback;
|
||||
}
|
||||
|
||||
/**
|
||||
|
@@ -11,7 +11,7 @@ namespace Flarum\Api\Serializer;
|
||||
|
||||
use Flarum\Group\Group;
|
||||
use InvalidArgumentException;
|
||||
use Symfony\Component\Translation\TranslatorInterface;
|
||||
use Symfony\Contracts\Translation\TranslatorInterface;
|
||||
|
||||
class GroupSerializer extends AbstractSerializer
|
||||
{
|
||||
|
@@ -19,14 +19,14 @@ class UserSerializer extends BasicUserSerializer
|
||||
{
|
||||
$attributes = parent::getDefaultAttributes($user);
|
||||
|
||||
$canEdit = $this->actor->can('edit', $user);
|
||||
|
||||
$attributes += [
|
||||
'joinTime' => $this->formatDate($user->joined_at),
|
||||
'discussionCount' => (int) $user->discussion_count,
|
||||
'commentCount' => (int) $user->comment_count,
|
||||
'canEdit' => $canEdit,
|
||||
'canDelete' => $this->actor->can('delete', $user),
|
||||
'joinTime' => $this->formatDate($user->joined_at),
|
||||
'discussionCount' => (int) $user->discussion_count,
|
||||
'commentCount' => (int) $user->comment_count,
|
||||
'canEdit' => $this->actor->can('edit', $user),
|
||||
'canEditCredentials' => $this->actor->can('editCredentials', $user),
|
||||
'canEditGroups' => $this->actor->can('editGroups', $user),
|
||||
'canDelete' => $this->actor->can('delete', $user),
|
||||
];
|
||||
|
||||
if ($user->getPreference('discloseOnline') || $this->actor->can('viewLastSeenAt', $user)) {
|
||||
@@ -35,7 +35,7 @@ class UserSerializer extends BasicUserSerializer
|
||||
];
|
||||
}
|
||||
|
||||
if ($canEdit || $this->actor->id === $user->id) {
|
||||
if ($attributes['canEditCredentials'] || $this->actor->id === $user->id) {
|
||||
$attributes += [
|
||||
'isEmailConfirmed' => (bool) $user->is_email_confirmed,
|
||||
'email' => $user->email
|
||||
|
@@ -19,18 +19,18 @@ class BusServiceProvider extends AbstractServiceProvider
|
||||
{
|
||||
public function register()
|
||||
{
|
||||
$this->app->bind(BaseDispatcher::class, function ($app) {
|
||||
return new Dispatcher($app, function ($connection = null) use ($app) {
|
||||
return $app[QueueFactoryContract::class]->connection($connection);
|
||||
$this->container->bind(BaseDispatcher::class, function ($container) {
|
||||
return new Dispatcher($container, function ($connection = null) use ($container) {
|
||||
return $container[QueueFactoryContract::class]->connection($connection);
|
||||
});
|
||||
});
|
||||
|
||||
$this->app->alias(
|
||||
$this->container->alias(
|
||||
BaseDispatcher::class,
|
||||
DispatcherContract::class
|
||||
);
|
||||
|
||||
$this->app->alias(
|
||||
$this->container->alias(
|
||||
BaseDispatcher::class,
|
||||
QueueingDispatcherContract::class
|
||||
);
|
||||
|
@@ -34,7 +34,7 @@ abstract class AbstractCommand extends Command
|
||||
$this->input = $input;
|
||||
$this->output = $output;
|
||||
|
||||
$this->fire();
|
||||
return $this->fire() ?: 0;
|
||||
}
|
||||
|
||||
/**
|
||||
|
@@ -23,7 +23,7 @@ class ConsoleServiceProvider extends AbstractServiceProvider
|
||||
*/
|
||||
public function register()
|
||||
{
|
||||
$this->app->singleton('flarum.console.commands', function () {
|
||||
$this->container->singleton('flarum.console.commands', function () {
|
||||
return [
|
||||
CacheClearCommand::class,
|
||||
GenerateMigrationCommand::class,
|
||||
|
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user