mirror of
https://github.com/flarum/core.git
synced 2025-08-13 20:04:24 +02:00
Compare commits
17 Commits
dk/harden-
...
urlgenerat
Author | SHA1 | Date | |
---|---|---|---|
|
68cacd5404 | ||
|
345a8e5773 | ||
|
1cbaef579c | ||
|
8fb65e9de7 | ||
|
92a5b98b77 | ||
|
1eb316bf44 | ||
|
47e8990813 | ||
|
dbce6705fd | ||
|
9de6ebbe4f | ||
|
f63ca2be3c | ||
|
6a1017d4b1 | ||
|
696dc8ef6b | ||
|
a8fa57ad30 | ||
|
9ff02f8822 | ||
|
dde718e27f | ||
|
a155e41432 | ||
|
4574fa6290 |
@@ -15,5 +15,5 @@ indent_size = 2
|
||||
[*.{diff,md}]
|
||||
trim_trailing_whitespace = false
|
||||
|
||||
[*.{php,xml,json}]
|
||||
[*.{php,xml}]
|
||||
indent_size = 4
|
||||
|
18
.github/workflows/build.yml
vendored
18
.github/workflows/build.yml
vendored
@@ -7,24 +7,10 @@ on:
|
||||
|
||||
jobs:
|
||||
build:
|
||||
name: JS / Build
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Check out code
|
||||
uses: actions/checkout@v2
|
||||
|
||||
- name: Restore npm cache
|
||||
uses: actions/cache@v2
|
||||
with:
|
||||
path: ~/.npm
|
||||
key: ${{ runner.os }}-node-${{ hashFiles('js/package-lock.json') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-node-
|
||||
|
||||
# Our action will install npm, cd into `./js`, run `npm run build`,
|
||||
# then commit and upload any changes
|
||||
- name: Build production JS
|
||||
uses: flarum/action-build@master
|
||||
- uses: actions/checkout@master
|
||||
- uses: flarum/action-build@master
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
76
.github/workflows/codeql-analysis.yml
vendored
76
.github/workflows/codeql-analysis.yml
vendored
@@ -1,76 +0,0 @@
|
||||
# For most projects, this workflow file will not need changing; you simply need
|
||||
# to commit it to your repository.
|
||||
#
|
||||
# You may wish to alter this file to override the set of languages analyzed,
|
||||
# or to provide custom queries or build logic.
|
||||
#
|
||||
# ******** NOTE ********
|
||||
# We have attempted to detect the languages in your repository. Please check
|
||||
# the `language` matrix defined below to confirm you have the correct set of
|
||||
# supported CodeQL languages.
|
||||
#
|
||||
name: "CodeQL"
|
||||
|
||||
# Run on:
|
||||
# - pushes to master, or
|
||||
# - PRs with a base of `master`
|
||||
# - which do not **only** consist of changes to .md or .less files
|
||||
on:
|
||||
push:
|
||||
branches: [ master ]
|
||||
paths-ignore:
|
||||
- '**/*.md'
|
||||
- '**/*.less'
|
||||
pull_request:
|
||||
branches: [ master ]
|
||||
paths-ignore:
|
||||
- '**/*.md'
|
||||
- '**/*.less'
|
||||
schedule:
|
||||
- cron: '0 0 * * 1,3,5'
|
||||
|
||||
jobs:
|
||||
analyze:
|
||||
name: Analyze / ${{ matrix.language }}
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
language: [ 'javascript' ]
|
||||
# CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python' ]
|
||||
# Learn more:
|
||||
# https://docs.github.com/en/free-pro-team@latest/github/finding-security-vulnerabilities-and-errors-in-your-code/configuring-code-scanning#changing-the-languages-that-are-analyzed
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v2
|
||||
|
||||
# Initializes the CodeQL tools for scanning.
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@v1
|
||||
with:
|
||||
languages: ${{ matrix.language }}
|
||||
# If you wish to specify custom queries, you can do so here or in a config file.
|
||||
# By default, queries listed here will override any specified in a config file.
|
||||
# Prefix the list here with "+" to use these queries and those in the config file.
|
||||
# queries: ./path/to/local/query, your-org/your-repo/queries@main
|
||||
|
||||
# Autobuild attempts to build any compiled languages (C/C++, C#, or Java).
|
||||
# If this step fails, then you should remove it and run the build manually (see below)
|
||||
- name: Autobuild
|
||||
uses: github/codeql-action/autobuild@v1
|
||||
|
||||
# ℹ️ Command-line programs to run using the OS shell.
|
||||
# 📚 https://git.io/JvXDl
|
||||
|
||||
# ✏️ If the Autobuild fails above, remove it and uncomment the following three lines
|
||||
# and modify them (or add more) to build your code if your project
|
||||
# uses a compiled language
|
||||
|
||||
#- run: |
|
||||
# make bootstrap
|
||||
# make release
|
||||
|
||||
- name: Perform CodeQL Analysis
|
||||
uses: github/codeql-action/analyze@v1
|
21
.github/workflows/lint.yml
vendored
21
.github/workflows/lint.yml
vendored
@@ -1,7 +1,6 @@
|
||||
name: Lint
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
push:
|
||||
paths:
|
||||
- 'js/src/**'
|
||||
@@ -11,18 +10,22 @@ on:
|
||||
|
||||
jobs:
|
||||
prettier:
|
||||
name: JS / Prettier
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
name: JS / Prettier
|
||||
|
||||
steps:
|
||||
- name: Check out code
|
||||
uses: actions/checkout@v2
|
||||
- uses: actions/checkout@master
|
||||
|
||||
- name: Set up Node
|
||||
uses: actions/setup-node@v2
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v1
|
||||
with:
|
||||
node-version: "14"
|
||||
node-version: "12"
|
||||
|
||||
- name: Check JS formatting
|
||||
run: npx prettier --check src
|
||||
- name: Install JS dependencies
|
||||
run: npm ci
|
||||
working-directory: ./js
|
||||
|
||||
- name: Check JS code for formatting
|
||||
run: node_modules/.bin/prettier --check src
|
||||
working-directory: ./js
|
||||
|
45
.github/workflows/pr_size_change.yml
vendored
45
.github/workflows/pr_size_change.yml
vendored
@@ -1,45 +0,0 @@
|
||||
name: Bundle size checker
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
|
||||
push:
|
||||
paths:
|
||||
- "js/**"
|
||||
pull_request:
|
||||
paths:
|
||||
- "js/**"
|
||||
|
||||
jobs:
|
||||
bundlewatch:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
name: Bundlewatch
|
||||
|
||||
steps:
|
||||
- name: Check out code
|
||||
uses: actions/checkout@v2
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v2
|
||||
with:
|
||||
node-version: "14"
|
||||
|
||||
- name: Use npm v7
|
||||
run: sudo npm install -g npm@7.x.x
|
||||
|
||||
- name: Install JS dependencies
|
||||
# We need to use `npm install` here. If we don't, the workflow will fail.
|
||||
run: npm install
|
||||
working-directory: ./js
|
||||
|
||||
- name: Build production assets
|
||||
run: npm run build
|
||||
working-directory: ./js
|
||||
|
||||
- name: Check bundle size change
|
||||
run: node_modules/.bin/bundlewatch --config .bundlewatch.config.json
|
||||
working-directory: ./js
|
||||
env:
|
||||
BUNDLEWATCH_GITHUB_TOKEN: ${{ secrets.BUNDLEWATCH_GITHUB_TOKEN }}
|
||||
CI_COMMIT_SHA: ${{ github.event.pull_request.head.sha }}
|
28
.github/workflows/test.yml
vendored
28
.github/workflows/test.yml
vendored
@@ -8,7 +8,7 @@ jobs:
|
||||
|
||||
strategy:
|
||||
matrix:
|
||||
php: [7.3, 7.4, '8.0']
|
||||
php: [7.2, 7.3, 7.4]
|
||||
service: ['mysql:5.7', mariadb]
|
||||
prefix: ['', flarum_]
|
||||
|
||||
@@ -21,16 +21,16 @@ jobs:
|
||||
prefixStr: (prefix)
|
||||
|
||||
exclude:
|
||||
- php: 7.3
|
||||
- php: 7.2
|
||||
service: 'mysql:5.7'
|
||||
prefix: flarum_
|
||||
- php: 7.3
|
||||
- php: 7.2
|
||||
service: mariadb
|
||||
prefix: flarum_
|
||||
- php: 8.0
|
||||
- php: 7.3
|
||||
service: 'mysql:5.7'
|
||||
prefix: flarum_
|
||||
- php: 8.0
|
||||
- php: 7.3
|
||||
service: mariadb
|
||||
prefix: flarum_
|
||||
|
||||
@@ -43,25 +43,15 @@ jobs:
|
||||
name: 'PHP ${{ matrix.php }} / ${{ matrix.db }} ${{ matrix.prefixStr }}'
|
||||
|
||||
steps:
|
||||
- name: Check out code
|
||||
uses: actions/checkout@v2
|
||||
- uses: actions/checkout@master
|
||||
|
||||
- name: Setup PHP
|
||||
uses: shivammathur/setup-php@0b9d33cd0782337377999751fc10ea079fdd7104 # pin@v2
|
||||
with:
|
||||
php-version: ${{ matrix.php }}
|
||||
coverage: xdebug
|
||||
extensions: curl, dom, gd, json, mbstring, openssl, pdo_mysql, tokenizer, zip
|
||||
tools: phpunit, composer:v2
|
||||
- name: Select PHP version
|
||||
run: sudo update-alternatives --set php $(which php${{ matrix.php }})
|
||||
|
||||
# The authentication alter is necessary because newer mysql versions use the `caching_sha2_password` driver,
|
||||
# which isn't supported prior to PHP7.4
|
||||
# When we drop support for PHP7.3, we should remove this from the setup.
|
||||
- name: Create MySQL Database
|
||||
run: |
|
||||
sudo systemctl start mysql
|
||||
mysql -uroot -proot -e 'CREATE DATABASE flarum_test;' --port 13306
|
||||
mysql -uroot -proot -e "ALTER USER 'root'@'localhost' IDENTIFIED WITH mysql_native_password BY 'root';" --port 13306
|
||||
|
||||
- name: Install Composer dependencies
|
||||
run: composer install
|
||||
@@ -75,5 +65,3 @@ jobs:
|
||||
|
||||
- name: Run Composer tests
|
||||
run: composer test
|
||||
env:
|
||||
COMPOSER_PROCESS_TIMEOUT: 600
|
||||
|
2
.gitignore
vendored
2
.gitignore
vendored
@@ -4,8 +4,6 @@ composer.phar
|
||||
node_modules
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
tests/.phpunit.result.cache
|
||||
/tests/integration/tmp
|
||||
.vagrant
|
||||
.idea/*
|
||||
.vscode
|
||||
|
@@ -12,3 +12,7 @@ disabled:
|
||||
- phpdoc_order
|
||||
- phpdoc_separation
|
||||
- phpdoc_types
|
||||
|
||||
finder:
|
||||
exclude:
|
||||
- "stubs"
|
||||
|
161
CHANGELOG.md
161
CHANGELOG.md
@@ -1,164 +1,5 @@
|
||||
# Changelog
|
||||
|
||||
## [0.1.0-beta.16](https://github.com/flarum/core/compare/v0.1.0-beta.15...v0.1.0-beta.16)
|
||||
|
||||
### Added
|
||||
- Allow event subscribers (https://github.com/flarum/core/pull/2535)
|
||||
- Allow Settings extender to have a default value (https://github.com/flarum/core/pull/2495)
|
||||
- Allow hooking into the sending of notifications before being send (https://github.com/flarum/core/pull/2533)
|
||||
- PHP 8 support (https://github.com/flarum/core/pull/2507)
|
||||
- Search extender (https://github.com/flarum/core/pull/2483)
|
||||
- User badges to post preview (https://github.com/flarum/core/pull/2555)
|
||||
- Optional extension dependencies allow a booting order (https://github.com/flarum/core/pull/2579)
|
||||
- Auth extender (https://github.com/flarum/core/pull/2176)
|
||||
- `X-Powered-By` header added to allow indexers easier data aggregation of Flarum adoption (https://github.com/flarum/core/pull/2618)
|
||||
|
||||
### Changed
|
||||
- Run integration tests in transaction (https://github.com/flarum/core/pull/2304)
|
||||
- Allow policies to return a boolean for simplified allow/deny (https://github.com/flarum/core/pull/2534)
|
||||
- Converted highlight helper to typescript (https://github.com/flarum/core/pull/2532)
|
||||
- Add accessibility attributes to Mark as Read button (https://github.com/flarum/core/pull/2564)
|
||||
- Dismiss errors on change email modal upon a new request ([00913d5](https://github.com/flarum/core/commit/00913d5b0be2172cfce1f16aaf64a24f3d2e6d4b))
|
||||
- Disabled extensions now are marked with a red circle instead of a red dot (https://github.com/flarum/core/pull/2562)
|
||||
- Extension dependency errors now show the extension title instead of the ID (https://github.com/flarum/core/pull/2563)
|
||||
- Change `mutate` method on ApiSerializer extender to `attributes` (https://github.com/flarum/core/pull/2578)
|
||||
- Moved locale files to the core from the language pack (https://github.com/flarum/core/pull/2408)
|
||||
- AdminPage extensibility and generic improvements (https://github.com/flarum/core/pull/2593)
|
||||
- Remove entry of authors, link to https://flarum.org/team (https://github.com/flarum/core/pull/2625)
|
||||
- Search and filtering are split (https://github.com/flarum/core/pull/2454)
|
||||
- Move IP identification into a middleware (https://github.com/flarum/core/pull/2624)
|
||||
- Editor Driver abstraction introduced (https://github.com/flarum/core/pull/2594)
|
||||
- Allow overriding routes (https://github.com/flarum/core/pull/2577)
|
||||
- Split user edit permissions into permissions for editing of user credentials, username, groups and suspending (https://github.com/flarum/core/pull/2620)
|
||||
- Reduced number of admin extension categories (https://github.com/flarum/core/pull/2604)
|
||||
- Move search related classes to a dedicated Query namespace (https://github.com/flarum/core/pull/2645)
|
||||
- Rewrite common helpers into typescript (https://github.com/flarum/core/pull/2541)
|
||||
- `TextEditor` is moved to the common namespace for use in the admin frontend (https://github.com/flarum/core/pull/2649)
|
||||
- Update Laravel/Illuminate components to 8 (https://github.com/flarum/core/pull/2576)
|
||||
- Eager load relations in discussion listing to improve performance (https://github.com/flarum/core/pull/2639)
|
||||
- Adopt flarum/testing package (https://github.com/flarum/core/pull/2545)
|
||||
- Replace `user` gambit with `author` gambit ([612a57c](https://github.com/flarum/core/commit/612a57c4664415a3ea120103483645c32acc6f12))
|
||||
- Posts page of on user profile loads posts using username instead of id ([30017ee](https://github.com/flarum/core/commit/30017eef09ae9e78640c4e2cacd4909fffa8d775))
|
||||
|
||||
### Fixed
|
||||
- Transform css breaks iOS scroll functionality (https://github.com/flarum/core/pull/2527)
|
||||
- Composer header is hidden on mobile devices (https://github.com/flarum/core/pull/2279)
|
||||
- Cannot delete a post or discussion of a deleted user (https://github.com/flarum/core/pull/2521)
|
||||
- DiscussionListPane jumps around not keeping the scroll position (https://github.com/flarum/core/pull/2402)
|
||||
- Infinite scroll on notifications dropdown broken (https://github.com/flarum/core/pull/2524)
|
||||
- The show language selector switch remains toggled on ([9347b12](https://github.com/flarum/core/commit/9347b12b47bf4ab97ffb7ca92673604b237c1012))
|
||||
- Model Visibility extender throws exception on extensions that aren't installed or enabled (https://github.com/flarum/core/pull/2580)
|
||||
- Extensions are marked as enabled when enabling fails to unmet extension dependencies (https://github.com/flarum/core/pull/2558)
|
||||
- Routes to admin extension pages without a valid ID break the admin page (https://github.com/flarum/core/pull/2584)
|
||||
- Disabled fieldset use an incorrect CSS property `disallowed` (https://github.com/flarum/core/pull/2585)
|
||||
- Scrolling to a post that is already loaded the Load More button shows and does not trigger (https://github.com/flarum/core/pull/2388)
|
||||
- Opening discussions on some mobile devices require a double tap (https://github.com/flarum/core/pull/2607)
|
||||
- iOS devices show erratic behavior in the post stream while updating (https://github.com/flarum/core/pull/2548)
|
||||
- Small mobile screens partially hides the composer when the keyboard is open (https://github.com/flarum/core/pull/2631)
|
||||
- Clearing cache does not clear the template cache in storage/views (https://github.com/flarum/core/pull/2648)
|
||||
- Boot errors show critical information (https://github.com/flarum/core/pull/2633)
|
||||
- List user endpoint discloses last online even if user choose against it (https://github.com/flarum/core/pull/2634)
|
||||
- Group gambit disclosed hidden groups (https://github.com/flarum/core/pull/2657)
|
||||
- Search results on small windows not fully visible (https://github.com/flarum/core/pull/2650)
|
||||
- Composer goes off screen on Safari when starting to type (https://github.com/flarum/core/pull/2660)
|
||||
- A search that has no results shows the search results dropdown ([b88a7cb](https://github.com/flarum/core/commit/b88a7cb33b56e318f11670e9e2d563aef94db039))
|
||||
- The composer modal moves around when typing on Safari ([a64c398](https://github.com/flarum/core/commit/a64c39835aba43e831209609f4a9638ae589aa41))
|
||||
|
||||
### Removed
|
||||
- Deprecated CSRF wildcard path match
|
||||
- Deprecated policy and visibility scoping events
|
||||
- Deprecated post types event
|
||||
- Deprecated validation events
|
||||
- Deprecated notification events
|
||||
- Deprecated floodgate
|
||||
- Deprecated user preferences event
|
||||
- Deprecated formatting events
|
||||
- Deprecated api events
|
||||
- Deprecated bootstrap.php support
|
||||
- PHP 7.2 support (https://github.com/flarum/core/pull/2507)
|
||||
- Bidi attribute in the rendered HTML (https://github.com/flarum/core/pull/2602)
|
||||
- `AccessToken::find`, use `AccessToken::findValid` instead (https://github.com/flarum/core/pull/2651)
|
||||
|
||||
### Deprecated
|
||||
- `GetModelIsPrivate` event (https://github.com/flarum/core/pull/2587)
|
||||
- `CheckingPassword` event (https://github.com/flarum/core/pull/2176)
|
||||
- `event()` helper (https://github.com/flarum/core/pull/2608)
|
||||
- `AccessToken::generate` argument `$lifetime` (https://github.com/flarum/core/pull/2651)
|
||||
- `Rememberer::remember` argument `$token` should receive an instance of `RememberAccessToken` with `AccessToken` being deprecated (https://github.com/flarum/core/pull/2651)
|
||||
- `Rememberer::rememberUser` (https://github.com/flarum/core/pull/2651)
|
||||
- `SessionAuthenticator::logIn` argument `$userId`, should be replaced with `AccessToken` (https://github.com/flarum/core/pull/2651)
|
||||
- `TextEditor` has been moved to `common` (https://github.com/flarum/core/pull/2649)
|
||||
- `UserFilter` ([91e8b56](https://github.com/flarum/core/commit/91e8b569618957c86757ef89bac666e9102db5ae))
|
||||
|
||||
|
||||
## [0.1.0-beta.15](https://github.com/flarum/core/compare/v0.1.0-beta.14.1...v0.1.0-beta.15)
|
||||
|
||||
### Added
|
||||
|
||||
- Slug drivers support (https://github.com/flarum/core/pull/2456).
|
||||
- Notification type extender (https://github.com/flarum/core/pull/2424).
|
||||
- Validation extender (https://github.com/flarum/core/pull/2102).
|
||||
- Post extender (https://github.com/flarum/core/pull/2101).
|
||||
- Notification channel extender (https://github.com/flarum/core/pull/2432).
|
||||
- Service provider extender (https://github.com/flarum/core/pull/2437).
|
||||
- API serializer extender (https://github.com/flarum/core/pull/2438).
|
||||
- User preferences extender (https://github.com/flarum/core/pull/2463).
|
||||
- Settings extender (https://github.com/flarum/core/pull/2452).
|
||||
- ApiController extender (https://github.com/flarum/core/pull/2451).
|
||||
- Model visibility extender (https://github.com/flarum/core/pull/2460).
|
||||
- Policy extender (https://github.com/flarum/core/pull/2461).
|
||||
|
||||
### Changed
|
||||
|
||||
- Time helpers converted to Typescript (https://github.com/flarum/core/pull/2391).
|
||||
- Improved the formatter extender (https://github.com/flarum/core/pull/2098).
|
||||
- Improve wording on installer when facing file permission issues (https://github.com/flarum/core/pull/2435).
|
||||
- Background color of checkbox toggles improved for better usability (https://github.com/flarum/core/pull/2443).
|
||||
- Route resolving refactored (https://github.com/flarum/core/pull/2425).
|
||||
- Administration panel UX refactored (https://github.com/flarum/core/pull/2409).
|
||||
- Floodgate moved to middleware and extender added (https://github.com/flarum/core/pull/2170).
|
||||
- DRY up image uploading logic (https://github.com/flarum/core/pull/2477).
|
||||
- Process isolation on testing (https://github.com/flarum/core/commit/984f751c718c89501cc09857bc271efa2c7eea8c).
|
||||
- Forum and admin javascript exports namespaced (https://github.com/flarum/core/pull/2488).
|
||||
|
||||
### Fixed
|
||||
|
||||
- Web updater does not take into account subfolder installations (https://github.com/flarum/core/pull/2426).
|
||||
- Callables handling in extenders failed (https://github.com/flarum/core/pull/2423).
|
||||
- Scrolling on mobile from PostSteam changes didn't work correctly (https://github.com/flarum/core/pull/2385).
|
||||
- Side pane covers part of the discussion page due to `app.discussions` being empty (https://github.com/flarum/core/commit/102e76b084bf47fdfb4c73f95e1fbb322537f7aa).
|
||||
- Change email modal keeps showing the previous error message even on success (https://github.com/flarum/core/pull/2467).
|
||||
- Comment count not updated when discussions are deleted (https://github.com/flarum/core/pull/2472).
|
||||
- `goToIndex` in PostStream does not trigger an xhr to retrieve new data (https://github.com/flarum/core/commit/09e2736cbcc267594b660beabbd001d9030f9880).
|
||||
- On refresh the post number is reduced by one (https://github.com/flarum/core/pull/2476).
|
||||
- Queue worker would instantiate a new Queue factory, not the bound one (https://github.com/flarum/core/pull/2481).
|
||||
- Header accidentally has a border bottom (https://github.com/flarum/core/pull/2489).
|
||||
- Namespace mentioned in docblock is incorrect (https://github.com/flarum/core/pull/2494).
|
||||
- Scrolling inside longer discussions (especially Firefox) skips posts (https://github.com/flarum/core/commit/210a6b3e253d7917bd1eacd3ed8d2f95073ae99d).
|
||||
- Uploading avatars that are jpg/jpeg fails with a validation error (https://github.com/flarum/core/pull/2497).
|
||||
|
||||
### Removed
|
||||
|
||||
- MomentJS alias (https://github.com/flarum/core/pull/2428).
|
||||
- Deprecated user events `GetDisplayName` and `PrepareUserGroups` (https://github.com/flarum/core/pull/2428).
|
||||
- AssertPermissionTrait (https://github.com/flarum/core/pull/2428).
|
||||
- Path related helpers and methods in Application (https://github.com/flarum/core/pull/2428).
|
||||
- Backward compatibility layers from the frontend rewrite (https://github.com/flarum/core/pull/2428).
|
||||
|
||||
### Deprecated
|
||||
|
||||
- `CheckingForFlooding` (https://github.com/flarum/core/commit/8e25bcb68f86cc992c46dfa70368419fe9f936ac).
|
||||
|
||||
## [0.1.0-beta.14.1](https://github.com/flarum/core/compare/v0.1.0-beta.14...v0.1.0-beta.14.1)
|
||||
|
||||
### Fixed
|
||||
|
||||
- SuperTextarea component is not exported.
|
||||
- Symfony dependencies do not match those depended on by Laravel (https://github.com/flarum/core/pull/2407).
|
||||
- Scripts from textformatter aren't executed (https://github.com/flarum/core/pull/2415)
|
||||
- Sub path installations have no page title.
|
||||
- Losing focus of Composer area when coming from fullscreen.
|
||||
|
||||
## [0.1.0-beta.14](https://github.com/flarum/core/compare/v0.1.0-beta.13...v0.1.0-beta.14)
|
||||
|
||||
### Added
|
||||
@@ -301,7 +142,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,17 +1,36 @@
|
||||
{
|
||||
"name": "flarum/core",
|
||||
"description": "Delightfully simple forum software.",
|
||||
"keywords": [
|
||||
"forum",
|
||||
"discussion"
|
||||
],
|
||||
"keywords": ["forum", "discussion"],
|
||||
"homepage": "https://flarum.org/",
|
||||
"license": "MIT",
|
||||
"authors": [
|
||||
{
|
||||
"name": "Flarum",
|
||||
"email": "info@flarum.org",
|
||||
"homepage": "https://flarum.org/team"
|
||||
"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"
|
||||
}
|
||||
],
|
||||
"support": {
|
||||
@@ -20,38 +39,36 @@
|
||||
"docs": "https://flarum.org/docs/"
|
||||
},
|
||||
"require": {
|
||||
"php": ">=7.3",
|
||||
"php": ">=7.2",
|
||||
"axy/sourcemap": "^0.1.4",
|
||||
"components/font-awesome": "^5.14.0",
|
||||
"dflydev/fig-cookies": "^3.0.0",
|
||||
"dflydev/fig-cookies": "^2.0.1",
|
||||
"doctrine/dbal": "^2.7",
|
||||
"dragonmantank/cron-expression": "^3.1.0",
|
||||
"franzl/whoops-middleware": "^2.0.0",
|
||||
"illuminate/bus": "^8.0",
|
||||
"illuminate/cache": "^8.0",
|
||||
"illuminate/config": "^8.0",
|
||||
"illuminate/console": "^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",
|
||||
"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",
|
||||
"intervention/image": "^2.5.0",
|
||||
"laminas/laminas-diactoros": "^2.4.1",
|
||||
"laminas/laminas-httphandlerrunner": "^1.2.0",
|
||||
"laminas/laminas-stratigility": "^3.2.2",
|
||||
"laminas/laminas-diactoros": "^1.8.4",
|
||||
"laminas/laminas-httphandlerrunner": "^1.0",
|
||||
"laminas/laminas-stratigility": "^3.0",
|
||||
"league/flysystem": "^1.0.11",
|
||||
"matthiasmullie/minify": "^1.3",
|
||||
"middlewares/base-path": "^2.0.1",
|
||||
"middlewares/base-path-router": "^2.0.1",
|
||||
"middlewares/request-handler": "^2.0.1",
|
||||
"middlewares/base-path": "^1.1",
|
||||
"middlewares/base-path-router": "^0.2.1",
|
||||
"middlewares/request-handler": "^1.2",
|
||||
"monolog/monolog": "^1.16.0",
|
||||
"nesbot/carbon": "^2.0",
|
||||
"nikic/fast-route": "^0.6",
|
||||
@@ -59,17 +76,17 @@
|
||||
"psr/http-server-handler": "^1.0",
|
||||
"psr/http-server-middleware": "^1.0",
|
||||
"s9e/text-formatter": "^2.3.6",
|
||||
"symfony/config": "^5.2.2",
|
||||
"symfony/console": "^5.2.2",
|
||||
"symfony/event-dispatcher": "^5.2.2",
|
||||
"symfony/mime": "^5.2.0",
|
||||
"symfony/translation": "^5.1.5",
|
||||
"symfony/yaml": "^5.2.2",
|
||||
"symfony/config": "^4.3.4",
|
||||
"symfony/console": "^4.3.4",
|
||||
"symfony/event-dispatcher": "^4.3.4",
|
||||
"symfony/translation": "^4.3.4",
|
||||
"symfony/yaml": "^4.3.4",
|
||||
"tobscure/json-api": "^0.3.0",
|
||||
"wikimedia/less.php": "^3.0"
|
||||
},
|
||||
"require-dev": {
|
||||
"flarum/testing": "^0.1.0-beta.16"
|
||||
"mockery/mockery": "^1.0",
|
||||
"phpunit/phpunit": "^7.0"
|
||||
},
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
|
@@ -1,8 +0,0 @@
|
||||
{
|
||||
"files": [
|
||||
{
|
||||
"path": "./dist/*.js"
|
||||
}
|
||||
],
|
||||
"defaultCompression": "gzip"
|
||||
}
|
16
js/dist/admin.js
vendored
16
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
18
js/dist/forum.js
vendored
18
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
12675
js/package-lock.json
generated
12675
js/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -2,40 +2,32 @@
|
||||
"private": true,
|
||||
"name": "@flarum/core",
|
||||
"dependencies": {
|
||||
"@babel/preset-typescript": "^7.10.1",
|
||||
"@types/mithril": "^2.0.3",
|
||||
"bootstrap": "^3.4.1",
|
||||
"clsx": "^1.1.1",
|
||||
"classnames": "^2.2.5",
|
||||
"color-thief-browser": "^2.0.2",
|
||||
"dayjs": "^1.10.4",
|
||||
"expose-loader": "^1.0.3",
|
||||
"jquery": "^3.6.0",
|
||||
"dayjs": "^1.8.28",
|
||||
"expose-loader": "^0.7.5",
|
||||
"flarum-webpack-config": "0.1.0-beta.10",
|
||||
"jquery": "^3.5.1",
|
||||
"jquery.hotkeys": "^0.1.0",
|
||||
"lodash-es": "^4.17.21",
|
||||
"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"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/preset-typescript": "^7.13.0",
|
||||
"@types/jquery": "^3.5.5",
|
||||
"@types/lodash-es": "^4.17.4",
|
||||
"@types/mithril": "^2.0.7",
|
||||
"@types/punycode": "^2.1.0",
|
||||
"@types/textarea-caret": "^3.0.0",
|
||||
"bundlewatch": "^0.3.2",
|
||||
"cross-env": "^7.0.3",
|
||||
"flarum-webpack-config": "0.1.0-beta.10",
|
||||
"husky": "^4.3.8",
|
||||
"prettier": "^2.2.1",
|
||||
"webpack": "^4.46.0",
|
||||
"webpack-bundle-analyzer": "^4.4.0",
|
||||
"webpack-cli": "^3.3.12",
|
||||
"webpack-merge": "^4.2.2"
|
||||
"husky": "^4.2.5",
|
||||
"prettier": "2.0.2"
|
||||
},
|
||||
"scripts": {
|
||||
"dev": "webpack --mode development --watch",
|
||||
"build": "webpack --mode production",
|
||||
"analyze": "cross-env ANALYZER=true npm run build",
|
||||
"format": "prettier --write src",
|
||||
"format-check": "prettier --check src"
|
||||
},
|
||||
|
14
js/shims.d.ts
vendored
14
js/shims.d.ts
vendored
@@ -19,21 +19,9 @@ import Application from './src/common/Application';
|
||||
* to (and should not) bundle these themselves.
|
||||
*/
|
||||
declare global {
|
||||
// $ is already defined by `@types/jquery`
|
||||
const $: typeof _$;
|
||||
const m: Mithril.Static;
|
||||
const dayjs: typeof _dayjs;
|
||||
|
||||
// Extend JQuery with our custom functions, defined with $.fn
|
||||
interface JQuery {
|
||||
/**
|
||||
* Creates a tooltip on a jQuery element reference.
|
||||
*
|
||||
* Optionally accepts placement and delay options.
|
||||
*
|
||||
* Returns the same reference to allow for method chaining.
|
||||
*/
|
||||
tooltip: (tooltipOptions?: { placement?: 'top' | 'bottom' | 'left' | 'right'; delay?: number }) => JQuery;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
@@ -1,18 +1,27 @@
|
||||
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 = {
|
||||
feature: 30,
|
||||
theme: 20,
|
||||
discussion: 70,
|
||||
moderation: 60,
|
||||
feature: 50,
|
||||
formatting: 40,
|
||||
theme: 30,
|
||||
authentication: 20,
|
||||
language: 10,
|
||||
other: 0,
|
||||
};
|
||||
|
||||
history = {
|
||||
@@ -52,6 +61,14 @@ 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) {
|
||||
|
@@ -1,8 +0,0 @@
|
||||
import Admin from './AdminApplication';
|
||||
|
||||
const app = new Admin();
|
||||
|
||||
// @ts-ignore
|
||||
window.app = app;
|
||||
|
||||
export default app;
|
@@ -8,7 +8,6 @@ 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';
|
||||
@@ -17,8 +16,8 @@ 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';
|
||||
import UploadImageButton from './components/UploadImageButton';
|
||||
import LoadingModal from './components/LoadingModal';
|
||||
@@ -43,7 +42,6 @@ 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,
|
||||
@@ -52,8 +50,8 @@ 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,
|
||||
'components/UploadImageButton': UploadImageButton,
|
||||
'components/LoadingModal': LoadingModal,
|
||||
|
32
js/src/admin/components/AddExtensionModal.js
Normal file
32
js/src/admin/components/AddExtensionModal.js
Normal file
@@ -0,0 +1,32 @@
|
||||
/*
|
||||
* This file is part of Flarum.
|
||||
*
|
||||
* (c) Toby Zerner <toby.zerner@gmail.com>
|
||||
*
|
||||
* For the full copyright and license information, please view the LICENSE
|
||||
* file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
import Modal from '../../common/components/Modal';
|
||||
|
||||
export default class AddExtensionModal extends Modal {
|
||||
className() {
|
||||
return 'AddExtensionModal Modal--small';
|
||||
}
|
||||
|
||||
title() {
|
||||
return app.translator.trans('core.admin.add_extension.title');
|
||||
}
|
||||
|
||||
content() {
|
||||
return (
|
||||
<div className="Modal-body">
|
||||
<p>{app.translator.trans('core.admin.add_extension.temporary_text')}</p>
|
||||
<p>
|
||||
{app.translator.trans('core.admin.add_extension.install_text', { a: <a href="https://discuss.flarum.org/t/extensions" target="_blank" /> })}
|
||||
</p>
|
||||
<p>{app.translator.trans('core.admin.add_extension.developer_text', { a: <a href="http://flarum.org/docs/extend" target="_blank" /> })}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
16
js/src/admin/components/AdminLinkButton.js
Normal file
16
js/src/admin/components/AdminLinkButton.js
Normal file
@@ -0,0 +1,16 @@
|
||||
/*
|
||||
* This file is part of Flarum.
|
||||
*
|
||||
* (c) Toby Zerner <toby.zerner@gmail.com>
|
||||
*
|
||||
* For the full copyright and license information, please view the LICENSE
|
||||
* file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
import LinkButton from '../../common/components/LinkButton';
|
||||
|
||||
export default class AdminLinkButton extends LinkButton {
|
||||
getButtonContent(children) {
|
||||
return [...super.getButtonContent(children), <div className="AdminLinkButton-description">{this.attrs.description}</div>];
|
||||
}
|
||||
}
|
@@ -21,34 +21,6 @@ export default class AdminNav extends Component {
|
||||
);
|
||||
}
|
||||
|
||||
oncreate(vnode) {
|
||||
super.oncreate(vnode);
|
||||
|
||||
this.scrollToActive();
|
||||
}
|
||||
|
||||
onupdate() {
|
||||
this.scrollToActive();
|
||||
}
|
||||
|
||||
scrollToActive() {
|
||||
const children = $('.Dropdown-menu').children('.active');
|
||||
const nav = $('#admin-navigation');
|
||||
const time = app.previous.type ? 250 : 0;
|
||||
|
||||
if (
|
||||
children.length > 0 &&
|
||||
(children[0].offsetTop > nav.scrollTop() + nav.outerHeight() || children[0].offsetTop + children[0].offsetHeight < nav.scrollTop())
|
||||
) {
|
||||
nav.animate(
|
||||
{
|
||||
scrollTop: children[0].offsetTop - nav.height() / 2,
|
||||
},
|
||||
time
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Build an item list of main links to show in the admin navigation.
|
||||
*
|
||||
@@ -57,8 +29,6 @@ export default class AdminNav extends Component {
|
||||
items() {
|
||||
const items = new ItemList();
|
||||
|
||||
items.add('category-core', <h4 className="ExtensionListTitle">{app.translator.trans('core.admin.nav.categories.core')}</h4>);
|
||||
|
||||
items.add(
|
||||
'dashboard',
|
||||
<LinkButton href={app.route('dashboard')} icon="far fa-chart-bar" title={app.translator.trans('core.admin.nav.dashboard_title')}>
|
||||
@@ -118,7 +88,7 @@ export default class AdminNav extends Component {
|
||||
Object.keys(categorizedExtensions).map((category) => {
|
||||
if (!this.query()) {
|
||||
items.add(
|
||||
`category-${category}`,
|
||||
category,
|
||||
<h4 className="ExtensionListTitle">{app.translator.trans(`core.admin.nav.categories.${category}`)}</h4>,
|
||||
categories[category]
|
||||
);
|
||||
@@ -126,17 +96,16 @@ export default class AdminNav extends Component {
|
||||
|
||||
categorizedExtensions[category].map((extension) => {
|
||||
const query = this.query().toUpperCase();
|
||||
const title = extension.extra['flarum-extension'].title || '';
|
||||
const description = extension.description || '';
|
||||
const title = extension.extra['flarum-extension'].title;
|
||||
|
||||
if (!query || title.toUpperCase().includes(query) || description.toUpperCase().includes(query)) {
|
||||
if (!query || title.toUpperCase().includes(query) || extension.description.toUpperCase().includes(query)) {
|
||||
items.add(
|
||||
`extension-${extension.id}`,
|
||||
extension.id,
|
||||
<ExtensionLinkButton
|
||||
href={app.route('extension', { id: extension.id })}
|
||||
extensionId={extension.id}
|
||||
className="ExtensionNavButton"
|
||||
title={description}
|
||||
title={extension.description}
|
||||
>
|
||||
{title}
|
||||
</ExtensionLinkButton>,
|
||||
|
@@ -1,180 +0,0 @@
|
||||
import Page from '../../common/components/Page';
|
||||
import Button from '../../common/components/Button';
|
||||
import Switch from '../../common/components/Switch';
|
||||
import Select from '../../common/components/Select';
|
||||
import classList from '../../common/utils/classList';
|
||||
import Stream from '../../common/utils/Stream';
|
||||
import saveSettings from '../utils/saveSettings';
|
||||
import AdminHeader from './AdminHeader';
|
||||
|
||||
export default class AdminPage extends Page {
|
||||
oninit(vnode) {
|
||||
super.oninit(vnode);
|
||||
|
||||
this.settings = {};
|
||||
|
||||
this.loading = false;
|
||||
}
|
||||
|
||||
view() {
|
||||
const className = classList(['AdminPage', this.headerInfo().className]);
|
||||
|
||||
return (
|
||||
<div className={className}>
|
||||
{this.header()}
|
||||
<div className="container">{this.content()}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
content() {
|
||||
return '';
|
||||
}
|
||||
|
||||
submitButton() {
|
||||
return (
|
||||
<Button onclick={this.saveSettings.bind(this)} className="Button Button--primary" loading={this.loading} disabled={!this.isChanged()}>
|
||||
{app.translator.trans('core.admin.settings.submit_button')}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
header() {
|
||||
const headerInfo = this.headerInfo();
|
||||
|
||||
return (
|
||||
<AdminHeader icon={headerInfo.icon} description={headerInfo.description} className={headerInfo.className + '-header'}>
|
||||
{headerInfo.title}
|
||||
</AdminHeader>
|
||||
);
|
||||
}
|
||||
|
||||
headerInfo() {
|
||||
return {
|
||||
className: '',
|
||||
icon: '',
|
||||
title: '',
|
||||
description: '',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* buildSettingComponent takes a settings object and turns it into a component.
|
||||
* Depending on the type of input, you can set the type to 'bool', 'select', or
|
||||
* any standard <input> type. Any values inside the 'extra' object will be added
|
||||
* to the component as an attribute.
|
||||
*
|
||||
* Alternatively, you can pass a callback that will be executed in ExtensionPage's
|
||||
* context to include custom JSX elements.
|
||||
*
|
||||
* @example
|
||||
*
|
||||
* {
|
||||
* setting: 'acme.checkbox',
|
||||
* label: app.translator.trans('acme.admin.setting_label'),
|
||||
* type: 'bool',
|
||||
* help: app.translator.trans('acme.admin.setting_help'),
|
||||
* className: 'Setting-item'
|
||||
* }
|
||||
*
|
||||
* @example
|
||||
*
|
||||
* {
|
||||
* setting: 'acme.select',
|
||||
* label: app.translator.trans('acme.admin.setting_label'),
|
||||
* type: 'select',
|
||||
* options: {
|
||||
* 'option1': 'Option 1 label',
|
||||
* 'option2': 'Option 2 label',
|
||||
* },
|
||||
* default: 'option1',
|
||||
* }
|
||||
*
|
||||
* @param setting
|
||||
* @returns {JSX.Element}
|
||||
*/
|
||||
buildSettingComponent(entry) {
|
||||
if (typeof entry === 'function') {
|
||||
return entry.call(this);
|
||||
}
|
||||
|
||||
const { setting, help, ...componentAttrs } = entry;
|
||||
|
||||
delete componentAttrs.help;
|
||||
|
||||
const value = this.setting([setting])();
|
||||
if (['bool', 'checkbox', 'switch', 'boolean'].includes(componentAttrs.type)) {
|
||||
return (
|
||||
<div className="Form-group">
|
||||
<Switch state={!!value && value !== '0'} onchange={this.settings[setting]} {...componentAttrs}>
|
||||
{componentAttrs.label}
|
||||
</Switch>
|
||||
<div className="helpText">{help}</div>
|
||||
</div>
|
||||
);
|
||||
} else if (['select', 'dropdown', 'selectdropdown'].includes(componentAttrs.type)) {
|
||||
return (
|
||||
<div className="Form-group">
|
||||
<label>{componentAttrs.label}</label>
|
||||
<div className="helpText">{help}</div>
|
||||
<Select
|
||||
value={value || componentAttrs.default}
|
||||
options={componentAttrs.options}
|
||||
buttonClassName="Button"
|
||||
onchange={this.settings[setting]}
|
||||
{...componentAttrs}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
} else {
|
||||
componentAttrs.className = classList(['FormControl', componentAttrs.className]);
|
||||
return (
|
||||
<div className="Form-group">
|
||||
{componentAttrs.label ? <label>{componentAttrs.label}</label> : ''}
|
||||
<div className="helpText">{help}</div>
|
||||
<input type={componentAttrs.type} bidi={this.setting(setting)} {...componentAttrs} />
|
||||
</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,120 +1,141 @@
|
||||
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 AdminPage from './AdminPage';
|
||||
import saveSettings from '../utils/saveSettings';
|
||||
import AdminHeader from './AdminHeader';
|
||||
|
||||
export default class AppearancePage extends AdminPage {
|
||||
headerInfo() {
|
||||
return {
|
||||
className: 'AppearancePage',
|
||||
icon: 'fas fa-paint-brush',
|
||||
title: app.translator.trans('core.admin.appearance.title'),
|
||||
description: app.translator.trans('core.admin.appearance.description'),
|
||||
};
|
||||
export default class AppearancePage extends Page {
|
||||
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);
|
||||
}
|
||||
|
||||
content() {
|
||||
return [
|
||||
<div className="Form">
|
||||
<fieldset className="AppearancePage-colors">
|
||||
<legend>{app.translator.trans('core.admin.appearance.colors_heading')}</legend>
|
||||
<div className="helpText">{app.translator.trans('core.admin.appearance.colors_text')}</div>
|
||||
view() {
|
||||
return (
|
||||
<div className="AppearancePage">
|
||||
<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>
|
||||
|
||||
<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>
|
||||
<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>
|
||||
|
||||
{this.buildSettingComponent({
|
||||
type: 'switch',
|
||||
setting: 'theme_dark_mode',
|
||||
label: app.translator.trans('core.admin.appearance.dark_mode_label'),
|
||||
})}
|
||||
{Switch.component(
|
||||
{
|
||||
state: this.darkMode(),
|
||||
onchange: this.darkMode,
|
||||
},
|
||||
app.translator.trans('core.admin.appearance.dark_mode_label')
|
||||
)}
|
||||
|
||||
{this.buildSettingComponent({
|
||||
type: 'switch',
|
||||
setting: 'theme_colored_header',
|
||||
label: app.translator.trans('core.admin.appearance.colored_header_label'),
|
||||
})}
|
||||
{Switch.component(
|
||||
{
|
||||
state: this.coloredHeader(),
|
||||
onchange: this.coloredHeader,
|
||||
},
|
||||
app.translator.trans('core.admin.appearance.colored_header_label')
|
||||
)}
|
||||
|
||||
{this.submitButton()}
|
||||
</fieldset>
|
||||
</div>,
|
||||
{Button.component(
|
||||
{
|
||||
className: 'Button Button--primary',
|
||||
type: 'submit',
|
||||
loading: this.loading,
|
||||
},
|
||||
app.translator.trans('core.admin.appearance.submit_button')
|
||||
)}
|
||||
</fieldset>
|
||||
</form>
|
||||
|
||||
<fieldset>
|
||||
<legend>{app.translator.trans('core.admin.appearance.logo_heading')}</legend>
|
||||
<div className="helpText">{app.translator.trans('core.admin.appearance.logo_text')}</div>
|
||||
<UploadImageButton name="logo" />
|
||||
</fieldset>,
|
||||
<fieldset>
|
||||
<legend>{app.translator.trans('core.admin.appearance.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>,
|
||||
];
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
onsaved() {
|
||||
window.location.reload();
|
||||
}
|
||||
|
||||
saveSettings(e) {
|
||||
onsubmit(e) {
|
||||
e.preventDefault();
|
||||
|
||||
const hex = /^#[0-9a-f]{3}([0-9a-f]{3})?$/i;
|
||||
|
||||
if (!hex.test(this.settings['theme_primary_color']()) || !hex.test(this.settings['theme_secondary_color']())) {
|
||||
if (!hex.test(this.primaryColor()) || !hex.test(this.secondaryColor())) {
|
||||
alert(app.translator.trans('core.admin.appearance.enter_hex_message'));
|
||||
return;
|
||||
}
|
||||
|
||||
super.saveSettings(e);
|
||||
this.loading = true;
|
||||
|
||||
saveSettings({
|
||||
theme_primary_color: this.primaryColor(),
|
||||
theme_secondary_color: this.secondaryColor(),
|
||||
theme_dark_mode: this.darkMode(),
|
||||
theme_colored_header: this.coloredHeader(),
|
||||
}).then(() => window.location.reload());
|
||||
}
|
||||
}
|
||||
|
@@ -1,11 +1,31 @@
|
||||
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 AdminPage from './AdminPage';
|
||||
import Switch from '../../common/components/Switch';
|
||||
import Stream from '../../common/utils/Stream';
|
||||
import withAttr from '../../common/utils/withAttr';
|
||||
import AdminHeader from './AdminHeader';
|
||||
|
||||
export default class BasicsPage extends AdminPage {
|
||||
export default class BasicsPage extends Page {
|
||||
oninit(vnode) {
|
||||
super.oninit(vnode);
|
||||
|
||||
this.loading = false;
|
||||
|
||||
this.fields = [
|
||||
'forum_title',
|
||||
'forum_description',
|
||||
'default_locale',
|
||||
'show_language_selector',
|
||||
'default_route',
|
||||
'welcome_title',
|
||||
'welcome_message',
|
||||
'display_name_driver',
|
||||
];
|
||||
|
||||
this.localeOptions = {};
|
||||
const locales = app.data.locales;
|
||||
for (const i in locales) {
|
||||
@@ -20,99 +40,157 @@ export default class BasicsPage extends AdminPage {
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
headerInfo() {
|
||||
return {
|
||||
className: 'BasicsPage',
|
||||
icon: 'fas fa-pencil-alt',
|
||||
title: app.translator.trans('core.admin.basics.title'),
|
||||
description: app.translator.trans('core.admin.basics.description'),
|
||||
};
|
||||
}
|
||||
view() {
|
||||
return (
|
||||
<div className="BasicsPage">
|
||||
<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} />]
|
||||
)}
|
||||
|
||||
content() {
|
||||
return [
|
||||
<div className="Form">
|
||||
{this.buildSettingComponent({
|
||||
type: 'text',
|
||||
setting: 'forum_title',
|
||||
label: app.translator.trans('core.admin.basics.forum_title_heading'),
|
||||
})}
|
||||
{this.buildSettingComponent({
|
||||
type: 'text',
|
||||
setting: 'forum_description',
|
||||
label: app.translator.trans('core.admin.basics.forum_description_heading'),
|
||||
help: app.translator.trans('core.admin.basics.forum_description_text'),
|
||||
})}
|
||||
{FieldSet.component(
|
||||
{
|
||||
label: app.translator.trans('core.admin.basics.forum_description_heading'),
|
||||
},
|
||||
[
|
||||
<div className="helpText">{app.translator.trans('core.admin.basics.forum_description_text')}</div>,
|
||||
<textarea className="FormControl" bidi={this.values.forum_description} />,
|
||||
]
|
||||
)}
|
||||
|
||||
{Object.keys(this.localeOptions).length > 1
|
||||
? [
|
||||
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'),
|
||||
}),
|
||||
]
|
||||
: ''}
|
||||
{Object.keys(this.localeOptions).length > 1
|
||||
? FieldSet.component(
|
||||
{
|
||||
label: app.translator.trans('core.admin.basics.default_language_heading'),
|
||||
},
|
||||
[
|
||||
Select.component({
|
||||
options: this.localeOptions,
|
||||
value: this.values.default_locale(),
|
||||
onchange: this.values.default_locale,
|
||||
}),
|
||||
Switch.component(
|
||||
{
|
||||
state: this.values.show_language_selector(),
|
||||
onchange: this.values.show_language_selector,
|
||||
},
|
||||
app.translator.trans('core.admin.basics.show_language_selector_label')
|
||||
),
|
||||
]
|
||||
)
|
||||
: ''}
|
||||
|
||||
<FieldSet className="BasicsPage-homePage Form-group" label={app.translator.trans('core.admin.basics.home_page_heading')}>
|
||||
<div className="helpText">{app.translator.trans('core.admin.basics.home_page_text')}</div>
|
||||
{this.homePageItems()
|
||||
.toArray()
|
||||
.map(({ path, label }) => (
|
||||
<label className="checkbox">
|
||||
<input type="radio" name="homePage" value={path} bidi={this.setting('default_route')} />
|
||||
{label}
|
||||
</label>
|
||||
))}
|
||||
</FieldSet>
|
||||
{FieldSet.component(
|
||||
{
|
||||
label: app.translator.trans('core.admin.basics.home_page_heading'),
|
||||
className: 'BasicsPage-homePage',
|
||||
},
|
||||
[
|
||||
<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>
|
||||
)),
|
||||
]
|
||||
)}
|
||||
|
||||
<div className="Form-group BasicsPage-welcomeBanner-input">
|
||||
<label>{app.translator.trans('core.admin.basics.welcome_banner_heading')}</label>
|
||||
<div className="helpText">{app.translator.trans('core.admin.basics.welcome_banner_text')}</div>
|
||||
<input type="text" className="FormControl" bidi={this.setting('welcome_title')} />
|
||||
<textarea className="FormControl" bidi={this.setting('welcome_message')} />
|
||||
{FieldSet.component(
|
||||
{
|
||||
label: app.translator.trans('core.admin.basics.welcome_banner_heading'),
|
||||
className: 'BasicsPage-welcomeBanner',
|
||||
},
|
||||
[
|
||||
<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>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
{Object.keys(this.displayNameOptions).length > 1
|
||||
? this.buildSettingComponent({
|
||||
type: 'select',
|
||||
setting: 'display_name_driver',
|
||||
options: this.displayNameOptions,
|
||||
label: app.translator.trans('core.admin.basics.display_name_heading'),
|
||||
help: app.translator.trans('core.admin.basics.display_name_text'),
|
||||
})
|
||||
: ''}
|
||||
|
||||
{Object.keys(this.slugDriverOptions).map((model) => {
|
||||
const options = this.slugDriverOptions[model];
|
||||
if (Object.keys(options).length > 1) {
|
||||
return this.buildSettingComponent({
|
||||
type: 'select',
|
||||
setting: `slug_driver_${model}`,
|
||||
options,
|
||||
label: app.translator.trans('core.admin.basics.slug_driver_heading', { model }),
|
||||
help: app.translator.trans('core.admin.basics.slug_driver_text', { model }),
|
||||
});
|
||||
}
|
||||
})}
|
||||
|
||||
{this.submitButton()}
|
||||
</div>,
|
||||
];
|
||||
changed() {
|
||||
return this.fields.some((key) => this.values[key]() !== app.data.settings[key]);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -132,4 +210,27 @@ export default class BasicsPage extends AdminPage {
|
||||
|
||||
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 AdminPage from './AdminPage';
|
||||
import listItems from '../../common/helpers/listItems';
|
||||
|
||||
export default class DashboardPage extends AdminPage {
|
||||
headerInfo() {
|
||||
return {
|
||||
className: 'DashboardPage',
|
||||
icon: 'fas fa-chart-bar',
|
||||
title: app.translator.trans('core.admin.dashboard.title'),
|
||||
description: app.translator.trans('core.admin.dashboard.description'),
|
||||
};
|
||||
}
|
||||
|
||||
content() {
|
||||
return this.availableWidgets().toArray();
|
||||
export default class DashboardPage extends Page {
|
||||
view() {
|
||||
return (
|
||||
<div className="DashboardPage">
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
availableWidgets() {
|
||||
|
@@ -1,22 +1,28 @@
|
||||
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 ExtensionData from '../utils/ExtensionData';
|
||||
import isExtensionEnabled from '../utils/isExtensionEnabled';
|
||||
import AdminPage from './AdminPage';
|
||||
|
||||
export default class ExtensionPage extends AdminPage {
|
||||
export default class ExtensionPage extends Page {
|
||||
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',
|
||||
@@ -24,29 +30,26 @@ export default class ExtensionPage extends AdminPage {
|
||||
support: 'fas fa-life-ring',
|
||||
website: 'fas fa-link',
|
||||
donate: 'fas fa-donate',
|
||||
source: 'fas fa-code',
|
||||
};
|
||||
|
||||
if (!this.extension) {
|
||||
return m.route.set(app.route('dashboard'));
|
||||
// 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];
|
||||
}
|
||||
}
|
||||
|
||||
className() {
|
||||
if (!this.extension) return '';
|
||||
|
||||
return this.extension.id + '-Page';
|
||||
}
|
||||
|
||||
view() {
|
||||
if (!this.extension) return null;
|
||||
|
||||
return (
|
||||
<div className={'ExtensionPage ' + this.className()}>
|
||||
{this.header()}
|
||||
{!this.isEnabled() ? (
|
||||
<div className="container">
|
||||
<h3 className="ExtensionPage-subHeader">{app.translator.trans('core.admin.extension.enable_to_see')}</h3>
|
||||
<h2 className="ExtensionPage-subHeader">{app.translator.trans('core.admin.extension.enable_to_see')}</h2>
|
||||
</div>
|
||||
) : (
|
||||
<div className="ExtensionPage-body">{this.sections().toArray()}</div>
|
||||
@@ -56,8 +59,6 @@ export default class ExtensionPage extends AdminPage {
|
||||
}
|
||||
|
||||
header() {
|
||||
const isEnabled = this.isEnabled();
|
||||
|
||||
return [
|
||||
<div className="ExtensionPage-header">
|
||||
<div className="container">
|
||||
@@ -74,12 +75,10 @@ export default class ExtensionPage extends AdminPage {
|
||||
</div>
|
||||
<div className="helpText">{this.extension.description}</div>
|
||||
<div className="ExtensionPage-headerItems">
|
||||
<Switch
|
||||
state={this.changingState ? !isEnabled : isEnabled}
|
||||
loading={this.changingState}
|
||||
onchange={this.toggle.bind(this, this.extension.id)}
|
||||
>
|
||||
{isEnabled ? app.translator.trans('core.admin.extension.enabled') : app.translator.trans('core.admin.extension.disabled')}
|
||||
<Switch 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>
|
||||
<aside className="ExtensionInfo">
|
||||
<ul>{listItems(this.infoItems().toArray())}</ul>
|
||||
@@ -106,7 +105,7 @@ export default class ExtensionPage extends AdminPage {
|
||||
{app.extensionData.extensionHasPermissions(this.extension.id) ? (
|
||||
ExtensionPermissionGrid.component({ extensionId: this.extension.id })
|
||||
) : (
|
||||
<h3 className="ExtensionPage-subHeader">{app.translator.trans('core.admin.extension.no_permissions')}</h3>
|
||||
<h2 className="ExtensionPage-subHeader">{app.translator.trans('core.admin.extension.no_permissions')}</h2>
|
||||
)}
|
||||
</div>
|
||||
</div>,
|
||||
@@ -121,13 +120,17 @@ export default class ExtensionPage extends AdminPage {
|
||||
return (
|
||||
<div className="ExtensionPage-settings">
|
||||
<div className="container">
|
||||
{settings ? (
|
||||
{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 ? (
|
||||
<div className="Form">
|
||||
{settings.map(this.buildSettingComponent.bind(this))}
|
||||
<div className="Form-group">{this.submitButton()}</div>
|
||||
</div>
|
||||
) : (
|
||||
<h3 className="ExtensionPage-subHeader">{app.translator.trans('core.admin.extension.no_settings')}</h3>
|
||||
<h2 className="ExtensionPage-subHeader">{app.translator.trans('core.admin.extension.no_settings')}</h2>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
@@ -167,15 +170,17 @@ export default class ExtensionPage extends AdminPage {
|
||||
infoItems() {
|
||||
const items = new ItemList();
|
||||
|
||||
const links = this.extension.links;
|
||||
|
||||
if (links.authors.length) {
|
||||
if (this.extension.authors) {
|
||||
let authors = [];
|
||||
|
||||
links.authors.map((author) => {
|
||||
Object.keys(this.extension.authors).map((author, i) => {
|
||||
const link = this.extension.authors[author].homepage
|
||||
? this.extension.authors[author].homepage
|
||||
: 'mailto:' + this.extension.authors[author].email;
|
||||
|
||||
authors.push(
|
||||
<Link href={author.link} external={true} target="_blank">
|
||||
{author.name}
|
||||
<Link href={link} external={true} target="_blank">
|
||||
{this.extension.authors[author].name}
|
||||
</Link>
|
||||
);
|
||||
});
|
||||
@@ -183,20 +188,103 @@ export default class ExtensionPage extends AdminPage {
|
||||
items.add('authors', [icon('fas fa-user'), <span>{punctuateSeries(authors)}</span>]);
|
||||
}
|
||||
|
||||
const infoData = {};
|
||||
|
||||
if (this.extension.source || this.extension.support) {
|
||||
infoData.source = {
|
||||
icon: 'fas fa-code',
|
||||
href: this.extension.source ? this.extension.source.url : this.extension.support.source,
|
||||
};
|
||||
}
|
||||
|
||||
Object.keys(this.infoFields).map((field) => {
|
||||
if (links[field]) {
|
||||
items.add(
|
||||
field,
|
||||
<LinkButton href={links[field]} icon={this.infoFields[field]} external={true} target="_blank">
|
||||
{app.translator.trans(`core.admin.extension.info_links.${field}`)}
|
||||
</LinkButton>
|
||||
);
|
||||
const info = this.extension.extra['flarum-extension'].info;
|
||||
|
||||
if (info && info[field]) {
|
||||
infoData[field] = {
|
||||
icon: this.infoFields[field],
|
||||
href: info[field],
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
Object.entries(infoData).map(([field, value]) => {
|
||||
items.add(
|
||||
field,
|
||||
<LinkButton href={value.href} icon={value.icon} external={true} target="_blank">
|
||||
{app.translator.trans(`core.admin.extension.info_links.${field}`)}
|
||||
</LinkButton>
|
||||
);
|
||||
});
|
||||
|
||||
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.
|
||||
*
|
||||
* @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) {
|
||||
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();
|
||||
|
||||
@@ -217,8 +305,50 @@ export default class ExtensionPage extends AdminPage {
|
||||
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() {
|
||||
return isExtensionEnabled(this.extension.id);
|
||||
let isEnabled = isExtensionEnabled(this.extension.id);
|
||||
|
||||
return this.changingState ? !isEnabled : isEnabled;
|
||||
}
|
||||
|
||||
onerror(e) {
|
||||
@@ -229,8 +359,6 @@ export default class ExtensionPage extends AdminPage {
|
||||
app.modal.close();
|
||||
}, 300); // Bootstrap's Modal.TRANSITION_DURATION is 300 ms.
|
||||
|
||||
this.changingState = false;
|
||||
|
||||
if (e.status !== 409) {
|
||||
throw e;
|
||||
}
|
||||
|
@@ -1,5 +1,4 @@
|
||||
import PermissionGrid from './PermissionGrid';
|
||||
import Button from '../../common/components/Button';
|
||||
import ItemList from '../../common/utils/ItemList';
|
||||
|
||||
export default class ExtensionPermissionGrid extends PermissionGrid {
|
||||
@@ -37,17 +36,4 @@ 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,47 +5,44 @@ 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) => (this.categorizedExtensions[category] ? this.extensionCategory(category) : ''))}
|
||||
<div className="container">
|
||||
{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>
|
||||
);
|
||||
}
|
||||
})}
|
||||
</div>
|
||||
</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,31 +1,34 @@
|
||||
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 AdminPage from './AdminPage';
|
||||
import saveSettings from '../utils/saveSettings';
|
||||
import Stream from '../../common/utils/Stream';
|
||||
import icon from '../../common/helpers/icon';
|
||||
import AdminHeader from './AdminHeader';
|
||||
|
||||
export default class MailPage extends AdminPage {
|
||||
export default class MailPage extends Page {
|
||||
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',
|
||||
@@ -36,78 +39,150 @@ export default class MailPage extends AdminPage {
|
||||
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();
|
||||
});
|
||||
}
|
||||
|
||||
content() {
|
||||
if (this.loading) {
|
||||
return <LoadingIndicator />;
|
||||
view() {
|
||||
if (this.loading || this.saving) {
|
||||
return (
|
||||
<div className="MailPage">
|
||||
<div className="container">
|
||||
<LoadingIndicator />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const fields = this.driverFields[this.setting('mail_driver')()];
|
||||
const fields = this.driverFields[this.values.mail_driver()];
|
||||
const fieldKeys = Object.keys(fields);
|
||||
|
||||
return (
|
||||
<div className="Form">
|
||||
{this.buildSettingComponent({
|
||||
type: 'text',
|
||||
setting: 'mail_from',
|
||||
label: app.translator.trans('core.admin.email.addresses_heading'),
|
||||
className: 'MailPage-MailSettings',
|
||||
})}
|
||||
{this.buildSettingComponent({
|
||||
type: 'select',
|
||||
setting: 'mail_driver',
|
||||
options: Object.keys(this.driverFields).reduce((memo, val) => ({ ...memo, [val]: val }), {}),
|
||||
label: app.translator.trans('core.admin.email.driver_heading'),
|
||||
className: 'MailPage-MailSettings',
|
||||
})}
|
||||
{this.status.sending ||
|
||||
Alert.component(
|
||||
{
|
||||
dismissible: false,
|
||||
},
|
||||
app.translator.trans('core.admin.email.not_sending_message')
|
||||
)}
|
||||
<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>,
|
||||
]
|
||||
)}
|
||||
|
||||
{fieldKeys.length > 0 && (
|
||||
<FieldSet label={app.translator.trans(`core.admin.email.${this.setting('mail_driver')()}_heading`)} className="MailPage-MailSettings">
|
||||
<div className="MailPage-MailSettings-input">
|
||||
{fieldKeys.map((field) => {
|
||||
const fieldInfo = fields[field];
|
||||
{FieldSet.component(
|
||||
{
|
||||
label: app.translator.trans('core.admin.email.driver_heading'),
|
||||
className: 'MailPage-MailSettings',
|
||||
},
|
||||
[
|
||||
<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>,
|
||||
]
|
||||
)}
|
||||
|
||||
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()}
|
||||
{this.status.sending ||
|
||||
Alert.component(
|
||||
{
|
||||
dismissible: false,
|
||||
},
|
||||
app.translator.trans('core.admin.email.not_sending_message')
|
||||
)}
|
||||
|
||||
<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>
|
||||
{fieldKeys.length > 0 &&
|
||||
FieldSet.component(
|
||||
{
|
||||
label: app.translator.trans(`core.admin.email.${this.values.mail_driver()}_heading`),
|
||||
className: 'MailPage-MailSettings',
|
||||
},
|
||||
[
|
||||
<div className="MailPage-MailSettings-input">
|
||||
{fieldKeys.map((field) => [
|
||||
<label>
|
||||
{app.translator.trans(`core.admin.email.${field}_label`)}
|
||||
{this.renderField(field)}
|
||||
</label>,
|
||||
this.status.errors[field] && <p className="ValidationError">{this.status.errors[field]}</p>,
|
||||
])}
|
||||
</div>,
|
||||
]
|
||||
)}
|
||||
|
||||
<FieldSet>
|
||||
{Button.component(
|
||||
{
|
||||
type: 'submit',
|
||||
className: 'Button Button--primary',
|
||||
disabled: !this.changed(),
|
||||
},
|
||||
app.translator.trans('core.admin.email.submit_button')
|
||||
)}
|
||||
</FieldSet>
|
||||
|
||||
{FieldSet.component(
|
||||
{
|
||||
label: app.translator.trans('core.admin.email.send_test_mail_heading'),
|
||||
className: 'MailPage-MailSettings',
|
||||
},
|
||||
[
|
||||
<div className="helpText">{app.translator.trans('core.admin.email.send_test_mail_text', { email: app.session.user.email() })}</div>,
|
||||
Button.component(
|
||||
{
|
||||
className: 'Button Button--primary',
|
||||
disabled: this.sendingTest || this.changed(),
|
||||
onclick: () => this.sendTestEmail(),
|
||||
},
|
||||
app.translator.trans('core.admin.email.send_test_mail_button')
|
||||
),
|
||||
]
|
||||
)}
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
renderField(name) {
|
||||
const driver = this.values.mail_driver();
|
||||
const field = this.driverFields[driver][name];
|
||||
const prop = this.values[name];
|
||||
|
||||
if (typeof field === 'string') {
|
||||
return <input className="FormControl" bidi={prop} />;
|
||||
} else {
|
||||
return <Select value={prop()} options={field} onchange={prop} />;
|
||||
}
|
||||
}
|
||||
|
||||
changed() {
|
||||
return this.fields.some((key) => this.values[key]() !== app.data.settings[key]);
|
||||
}
|
||||
|
||||
sendTestEmail() {
|
||||
if (this.saving || this.sendingTest) return;
|
||||
|
||||
@@ -130,7 +205,26 @@ export default class MailPage extends AdminPage {
|
||||
});
|
||||
}
|
||||
|
||||
saveSettings(e) {
|
||||
super.saveSettings(e).then(this.refresh());
|
||||
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();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
@@ -326,30 +326,10 @@ export default class PermissionGrid extends Component {
|
||||
60
|
||||
);
|
||||
|
||||
items.add(
|
||||
'userEditCredentials',
|
||||
{
|
||||
icon: 'fas fa-user-cog',
|
||||
label: app.translator.trans('core.admin.permissions.edit_users_credentials_label'),
|
||||
permission: 'user.editCredentials',
|
||||
},
|
||||
60
|
||||
);
|
||||
|
||||
items.add(
|
||||
'userEditGroups',
|
||||
{
|
||||
icon: 'fas fa-users-cog',
|
||||
label: app.translator.trans('core.admin.permissions.edit_users_groups_label'),
|
||||
permission: 'user.editGroups',
|
||||
},
|
||||
60
|
||||
);
|
||||
|
||||
items.add(
|
||||
'userEdit',
|
||||
{
|
||||
icon: 'fas fa-address-card',
|
||||
icon: 'fas fa-user-cog',
|
||||
label: app.translator.trans('core.admin.permissions.edit_users_label'),
|
||||
permission: 'user.edit',
|
||||
},
|
||||
|
@@ -1,43 +1,44 @@
|
||||
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 AdminPage from './AdminPage';
|
||||
import AdminHeader from './AdminHeader';
|
||||
|
||||
export default class PermissionsPage extends AdminPage {
|
||||
headerInfo() {
|
||||
return {
|
||||
className: 'PermissionsPage',
|
||||
icon: 'fas fa-key',
|
||||
title: app.translator.trans('core.admin.permissions.title'),
|
||||
description: app.translator.trans('core.admin.permissions.description'),
|
||||
};
|
||||
}
|
||||
|
||||
content() {
|
||||
return [
|
||||
<div className="PermissionsPage-groups">
|
||||
{app.store
|
||||
.all('groups')
|
||||
.filter((group) => [Group.GUEST_ID, Group.MEMBER_ID].indexOf(group.id()) === -1)
|
||||
.map((group) => (
|
||||
<button className="Button Group" onclick={() => app.modal.show(EditGroupModal, { group })}>
|
||||
{GroupBadge.component({
|
||||
group,
|
||||
className: 'Group-icon',
|
||||
label: null,
|
||||
})}
|
||||
<span className="Group-name">{group.namePlural()}</span>
|
||||
export default class PermissionsPage extends Page {
|
||||
view() {
|
||||
return (
|
||||
<div className="PermissionsPage">
|
||||
<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>
|
||||
</button>
|
||||
))}
|
||||
<button className="Button Group Group--add" onclick={() => app.modal.show(EditGroupModal)}>
|
||||
{icon('fas fa-plus', { className: 'Group-icon' })}
|
||||
<span className="Group-name">{app.translator.trans('core.admin.permissions.new_group_button')}</span>
|
||||
</button>
|
||||
</div>,
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="PermissionsPage-permissions">{PermissionGrid.component()}</div>,
|
||||
];
|
||||
<div className="PermissionsPage-permissions">
|
||||
<div className="container">{PermissionGrid.component()}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
@@ -1,13 +1,17 @@
|
||||
import app from './app';
|
||||
import AdminApplication from './AdminApplication';
|
||||
|
||||
const app = new AdminApplication();
|
||||
|
||||
// Backwards compatibility
|
||||
window.app = app;
|
||||
|
||||
export { app };
|
||||
|
||||
// Export public API
|
||||
|
||||
// Export compat API
|
||||
import compatObj from './compat';
|
||||
import proxifyCompat from '../common/utils/proxifyCompat';
|
||||
import compat from './compat';
|
||||
|
||||
compatObj.app = app;
|
||||
compat.app = app;
|
||||
|
||||
export const compat = proxifyCompat(compatObj, 'admin');
|
||||
export { compat };
|
||||
|
@@ -26,8 +26,6 @@ export default class ExtensionData {
|
||||
/**
|
||||
* This function registers your settings with Flarum
|
||||
*
|
||||
* It takes either a settings object or a callback.
|
||||
*
|
||||
* @example
|
||||
*
|
||||
* .registerSetting({
|
||||
@@ -44,14 +42,6 @@ export default class ExtensionData {
|
||||
registerSetting(content, priority = 0) {
|
||||
this.data[this.currentExtension].settings = this.data[this.currentExtension].settings || new ItemList();
|
||||
|
||||
// Callbacks can be passed in instead of settings to display custom content.
|
||||
// By default, they will be added with the `null` key, since they don't have a `.setting` attr.
|
||||
// To support multiple such items for one extension, we assign a random ID.
|
||||
// 36 is arbitrary length, but makes collisions very unlikely.
|
||||
if (typeof content === 'function') {
|
||||
content.setting = Math.random().toString(36);
|
||||
}
|
||||
|
||||
this.data[this.currentExtension].settings.add(content.setting, content, priority);
|
||||
|
||||
return this;
|
||||
|
@@ -15,9 +15,9 @@ export default function getCategorizedExtensions() {
|
||||
|
||||
extensions[category].push(extension);
|
||||
} else {
|
||||
extensions.feature = extensions.feature || [];
|
||||
extensions.other = extensions.other || [];
|
||||
|
||||
extensions.feature.push(extension);
|
||||
extensions.other.push(extension);
|
||||
}
|
||||
});
|
||||
|
||||
|
@@ -159,8 +159,6 @@ export default class Application {
|
||||
title = '';
|
||||
titleCount = 0;
|
||||
|
||||
initialRoute;
|
||||
|
||||
load(payload) {
|
||||
this.data = payload;
|
||||
this.translator.locale = payload.locale;
|
||||
@@ -176,8 +174,6 @@ export default class Application {
|
||||
this.session = new Session(this.store.getById('users', this.data.session.userId), this.data.session.csrfToken);
|
||||
|
||||
this.mount();
|
||||
|
||||
this.initialRoute = window.location.href;
|
||||
}
|
||||
|
||||
bootExtensions(extensions) {
|
||||
@@ -230,8 +226,7 @@ export default class Application {
|
||||
* @public
|
||||
*/
|
||||
preloadedApiDocument() {
|
||||
// If the URL has changed, the preloaded Api document is invalid.
|
||||
if (this.data.apiDocument && window.location.href === this.initialRoute) {
|
||||
if (this.data.apiDocument) {
|
||||
const results = this.store.pushPayload(this.data.apiDocument);
|
||||
|
||||
this.data.apiDocument = null;
|
||||
@@ -275,7 +270,7 @@ export default class Application {
|
||||
|
||||
updateTitle() {
|
||||
const count = this.titleCount ? `(${this.titleCount}) ` : '';
|
||||
const pageTitleWithSeparator = this.title && m.route.get() !== this.forum.attribute('basePath') + '/' ? this.title + ' - ' : '';
|
||||
const pageTitleWithSeparator = this.title && m.route.get() !== '/' ? this.title + ' - ' : '';
|
||||
const title = this.forum.attribute('title');
|
||||
document.title = count + pageTitleWithSeparator + title;
|
||||
}
|
||||
|
@@ -1,5 +1,8 @@
|
||||
import * as Mithril from 'mithril';
|
||||
|
||||
let deprecatedPropsWarned = false;
|
||||
let deprecatedInitPropsWarned = false;
|
||||
|
||||
export interface ComponentAttrs extends Mithril.Attributes {}
|
||||
|
||||
/**
|
||||
@@ -77,12 +80,12 @@ export default abstract class Component<T extends ComponentAttrs = ComponentAttr
|
||||
* containing all of the `li` elements inside the DOM element of this
|
||||
* component.
|
||||
*
|
||||
* @param [selector] a jQuery-compatible selector string
|
||||
* @returns the jQuery object for the DOM node
|
||||
* @param {String} [selector] a jQuery-compatible selector string
|
||||
* @returns {jQuery} the jQuery object for the DOM node
|
||||
* @final
|
||||
*/
|
||||
protected $(selector: string): JQuery {
|
||||
const $element = $(this.element) as JQuery<HTMLElement>;
|
||||
protected $(selector) {
|
||||
const $element = $(this.element);
|
||||
|
||||
return selector ? $element.find(selector) : $element;
|
||||
}
|
||||
@@ -94,7 +97,7 @@ export default abstract class Component<T extends ComponentAttrs = ComponentAttr
|
||||
* @see https://mithril.js.org/hyperscript.html#mselector,-attributes,-children
|
||||
*/
|
||||
static component(attrs = {}, children = null): Mithril.Vnode {
|
||||
const componentAttrs = Object.assign({}, attrs) as Record<string, unknown>;
|
||||
const componentAttrs = Object.assign({}, attrs);
|
||||
|
||||
return m(this as any, componentAttrs, children);
|
||||
}
|
||||
@@ -128,5 +131,38 @@ export default abstract class Component<T extends ComponentAttrs = ComponentAttr
|
||||
*
|
||||
* This can be used to assign default values for missing, optional attrs.
|
||||
*/
|
||||
protected static initAttrs<T>(attrs: T): void {}
|
||||
protected static initAttrs<T>(attrs: T): void {
|
||||
// Deprecated, part of Mithril 2 BC layer
|
||||
if ('initProps' in this && !deprecatedInitPropsWarned) {
|
||||
deprecatedInitPropsWarned = true;
|
||||
console.warn('initProps is deprecated, please use initAttrs instead.');
|
||||
(this as any).initProps(attrs);
|
||||
}
|
||||
}
|
||||
|
||||
// BEGIN DEPRECATED MITHRIL 2 BC LAYER
|
||||
|
||||
/**
|
||||
* The attributes passed into the component.
|
||||
*
|
||||
* @see https://mithril.js.org/components.html#passing-data-to-components
|
||||
*
|
||||
* @deprecated, use attrs instead.
|
||||
*/
|
||||
get props() {
|
||||
if (!deprecatedPropsWarned) {
|
||||
deprecatedPropsWarned = true;
|
||||
console.warn('this.props is deprecated, please use this.attrs instead.');
|
||||
}
|
||||
return this.attrs;
|
||||
}
|
||||
set props(props) {
|
||||
if (!deprecatedPropsWarned) {
|
||||
deprecatedPropsWarned = true;
|
||||
console.warn('this.props is deprecated, please use this.attrs instead.');
|
||||
}
|
||||
this.attrs = props;
|
||||
}
|
||||
|
||||
// END DEPRECATED MITHRIL 2 BC LAYER
|
||||
}
|
||||
|
@@ -1,7 +1,6 @@
|
||||
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';
|
||||
@@ -20,8 +19,8 @@ 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';
|
||||
import extractText from './utils/extractText';
|
||||
import formatNumber from './utils/formatNumber';
|
||||
@@ -57,8 +56,6 @@ 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';
|
||||
@@ -77,7 +74,6 @@ export default {
|
||||
extend: extend,
|
||||
Session: Session,
|
||||
Store: Store,
|
||||
'utils/BasicEditorDriver': BasicEditorDriver,
|
||||
'utils/evented': evented,
|
||||
'utils/liveHumanTimes': liveHumanTimes,
|
||||
'utils/ItemList': ItemList,
|
||||
@@ -95,9 +91,9 @@ export default {
|
||||
'utils/stringToColor': stringToColor,
|
||||
'utils/Stream': Stream,
|
||||
'utils/subclassOf': subclassOf,
|
||||
'utils/SuperTextarea': SuperTextarea,
|
||||
'utils/setRouteWithForcedRefresh': setRouteWithForcedRefresh,
|
||||
'utils/patchMithril': patchMithril,
|
||||
'utils/proxifyCompat': proxifyCompat,
|
||||
'utils/classList': classList,
|
||||
'utils/extractText': extractText,
|
||||
'utils/formatNumber': formatNumber,
|
||||
@@ -134,8 +130,6 @@ export default {
|
||||
'components/Button': Button,
|
||||
'components/Modal': Modal,
|
||||
'components/GroupBadge': GroupBadge,
|
||||
'components/TextEditor': TextEditor,
|
||||
'components/TextEditorButton': TextEditorButton,
|
||||
Model: Model,
|
||||
Application: Application,
|
||||
'helpers/fullTime': fullTime,
|
||||
|
@@ -69,7 +69,7 @@ export default class Button extends Component {
|
||||
return [
|
||||
iconName && iconName !== true ? icon(iconName, { className: 'Button-icon' }) : '',
|
||||
children ? <span className="Button-label">{children}</span> : '',
|
||||
this.attrs.loading ? <LoadingIndicator size="small" display="inline" /> : '',
|
||||
this.attrs.loading ? <LoadingIndicator size="tiny" className="LoadingIndicator--inline" /> : '',
|
||||
];
|
||||
}
|
||||
}
|
||||
|
@@ -46,7 +46,7 @@ export default class Checkbox extends Component {
|
||||
* @protected
|
||||
*/
|
||||
getDisplay() {
|
||||
return this.attrs.loading ? <LoadingIndicator display="unset" size="small" /> : icon(this.attrs.state ? 'fas fa-check' : 'fas fa-times');
|
||||
return this.attrs.loading ? <LoadingIndicator size="tiny" /> : icon(this.attrs.state ? 'fas fa-check' : 'fas fa-times');
|
||||
}
|
||||
|
||||
/**
|
||||
|
@@ -13,7 +13,6 @@ import listItems from '../helpers/listItems';
|
||||
* - `icon` The name of an icon to show in the dropdown toggle button.
|
||||
* - `caretIcon` The name of an icon to show on the right of the button.
|
||||
* - `label` The label of the dropdown toggle button. Defaults to 'Controls'.
|
||||
* - `accessibleToggleLabel` The label used to describe the dropdown toggle button to assistive readers. Defaults to 'Toggle dropdown menu'.
|
||||
* - `onhide`
|
||||
* - `onshow`
|
||||
*
|
||||
@@ -26,7 +25,6 @@ export default class Dropdown extends Component {
|
||||
attrs.menuClassName = attrs.menuClassName || '';
|
||||
attrs.label = attrs.label || '';
|
||||
attrs.caretIcon = typeof attrs.caretIcon !== 'undefined' ? attrs.caretIcon : 'fas fa-caret-down';
|
||||
attrs.accessibleToggleLabel = attrs.accessibleToggleLabel || app.translator.trans('core.lib.dropdown.toggle_dropdown_accessible_label');
|
||||
}
|
||||
|
||||
oninit(vnode) {
|
||||
@@ -94,13 +92,7 @@ export default class Dropdown extends Component {
|
||||
*/
|
||||
getButton(children) {
|
||||
return (
|
||||
<button
|
||||
className={'Dropdown-toggle ' + this.attrs.buttonClassName}
|
||||
aria-haspopup="menu"
|
||||
aria-label={this.attrs.accessibleToggleLabel}
|
||||
data-toggle="dropdown"
|
||||
onclick={this.attrs.onclick}
|
||||
>
|
||||
<button className={'Dropdown-toggle ' + this.attrs.buttonClassName} data-toggle="dropdown" onclick={this.attrs.onclick}>
|
||||
{this.getButtonContent(children)}
|
||||
</button>
|
||||
);
|
||||
|
43
js/src/common/components/LoadingIndicator.js
Normal file
43
js/src/common/components/LoadingIndicator.js
Normal file
@@ -0,0 +1,43 @@
|
||||
import Component from '../Component';
|
||||
import { Spinner } from 'spin.js';
|
||||
|
||||
/**
|
||||
* The `LoadingIndicator` component displays a loading spinner with spin.js.
|
||||
*
|
||||
* ### Attrs
|
||||
*
|
||||
* - `size` The spin.js size preset to use. Defaults to 'small'.
|
||||
*
|
||||
* All other attrs will be assigned as attributes on the DOM element.
|
||||
*/
|
||||
export default class LoadingIndicator extends Component {
|
||||
view() {
|
||||
const attrs = Object.assign({}, this.attrs);
|
||||
|
||||
attrs.className = 'LoadingIndicator ' + (attrs.className || '');
|
||||
delete attrs.size;
|
||||
|
||||
return <div {...attrs}>{m.trust(' ')}</div>;
|
||||
}
|
||||
|
||||
oncreate(vnode) {
|
||||
super.oncreate(vnode);
|
||||
|
||||
const options = { zIndex: 'auto', color: this.$().css('color') };
|
||||
|
||||
switch (this.attrs.size) {
|
||||
case 'large':
|
||||
Object.assign(options, { lines: 10, length: 8, width: 4, radius: 8 });
|
||||
break;
|
||||
|
||||
case 'tiny':
|
||||
Object.assign(options, { lines: 8, length: 2, width: 2, radius: 3 });
|
||||
break;
|
||||
|
||||
default:
|
||||
Object.assign(options, { lines: 8, length: 4, width: 3, radius: 5 });
|
||||
}
|
||||
|
||||
new Spinner(options).spin(this.element);
|
||||
}
|
||||
}
|
@@ -1,80 +0,0 @@
|
||||
import Component, { ComponentAttrs } from '../Component';
|
||||
import classList from '../utils/classList';
|
||||
|
||||
export interface LoadingIndicatorAttrs extends ComponentAttrs {
|
||||
/**
|
||||
* Custom classes fro the loading indicator's container.
|
||||
*/
|
||||
className?: string;
|
||||
/**
|
||||
* Custom classes for the loading indicator's container.
|
||||
*/
|
||||
containerClassName?: string;
|
||||
/**
|
||||
* Optional size to specify for the loading indicator.
|
||||
*/
|
||||
size?: 'large' | 'medium' | 'small';
|
||||
/**
|
||||
* Optional attributes to apply to the loading indicator's container.
|
||||
*/
|
||||
containerAttrs?: Partial<ComponentAttrs>;
|
||||
/**
|
||||
* Display type of the spinner.
|
||||
*
|
||||
* @default 'block'
|
||||
*/
|
||||
display?: 'block' | 'inline' | 'unset';
|
||||
}
|
||||
|
||||
/**
|
||||
* The `LoadingIndicator` component displays a simple CSS-based loading spinner.
|
||||
*
|
||||
* To set a custom color, use the CSS `color` property.
|
||||
*
|
||||
* To increase spacing around the spinner, use the CSS `height` property on the
|
||||
* spinner's **container**. Setting the `display` attribute to `block` will set
|
||||
* a height of `100px` by default.
|
||||
*
|
||||
* To apply a custom size to the loading indicator, set the `--size` and
|
||||
* `--thickness` CSS custom properties on the loading indicator container.
|
||||
*
|
||||
* If you *really* want to change how this looks as part of your custom theme,
|
||||
* you can override the `border-radius` and `border` then set either a
|
||||
* background image, or use `content: "\<glyph>"` (e.g. `content: "\f1ce"`)
|
||||
* and `font-family: 'Font Awesome 5 Free'` to set an FA icon if you'd rather.
|
||||
*
|
||||
* ### Attrs
|
||||
*
|
||||
* - `containerClassName` Class name(s) to apply to the indicator's parent
|
||||
* - `className` Class name(s) to apply to the indicator itself
|
||||
* - `display` Determines how the spinner should be displayed (`inline`, `block` (default) or `unset`)
|
||||
* - `size` Size of the loading indicator (`small`, `medium` or `large`)
|
||||
* - `containerAttrs` Optional attrs to be applied to the container DOM element
|
||||
*
|
||||
* All other attrs will be assigned as attributes on the DOM element.
|
||||
*/
|
||||
export default class LoadingIndicator extends Component<LoadingIndicatorAttrs> {
|
||||
view() {
|
||||
const { display = 'block', size = 'medium', containerClassName, className, ...attrs } = this.attrs;
|
||||
|
||||
const completeClassName = classList('LoadingIndicator', className);
|
||||
const completeContainerClassName = classList(
|
||||
'LoadingIndicator-container',
|
||||
display !== 'unset' && `LoadingIndicator-container--${display}`,
|
||||
size && `LoadingIndicator-container--${size}`,
|
||||
containerClassName
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
aria-label={app.translator.trans('core.lib.loading_indicator.accessible_label')}
|
||||
role="status"
|
||||
{...attrs.containerAttrs}
|
||||
data-size={size}
|
||||
className={completeContainerClassName}
|
||||
>
|
||||
<div aria-hidden className={completeClassName} {...attrs} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
@@ -29,13 +29,6 @@ export default class Page extends Component {
|
||||
* @type {Boolean}
|
||||
*/
|
||||
this.scrollTopOnCreate = true;
|
||||
|
||||
/**
|
||||
* Whether the browser should restore scroll state on refreshes.
|
||||
*
|
||||
* @type {Boolean}
|
||||
*/
|
||||
this.useBrowserScrollRestoration = true;
|
||||
}
|
||||
|
||||
oncreate(vnode) {
|
||||
@@ -48,10 +41,6 @@ export default class Page extends Component {
|
||||
if (this.scrollTopOnCreate) {
|
||||
$(window).scrollTop(0);
|
||||
}
|
||||
|
||||
if ('scrollRestoration' in history) {
|
||||
history.scrollRestoration = this.useBrowserScrollRestoration ? 'auto' : 'manual';
|
||||
}
|
||||
}
|
||||
|
||||
onremove() {
|
||||
|
@@ -24,12 +24,7 @@ export default class SplitDropdown extends Dropdown {
|
||||
|
||||
return [
|
||||
Button.component(buttonAttrs, firstChild.children),
|
||||
<button
|
||||
className={'Dropdown-toggle Button Button--icon ' + this.attrs.buttonClassName}
|
||||
aria-haspopup="menu"
|
||||
aria-label={this.attrs.accessibleToggleLabel}
|
||||
data-toggle="dropdown"
|
||||
>
|
||||
<button className={'Dropdown-toggle Button Button--icon ' + this.attrs.buttonClassName} data-toggle="dropdown">
|
||||
{icon(this.attrs.icon, { className: 'Button-icon' })}
|
||||
{icon('fas fa-caret-down', { className: 'Button-caret' })}
|
||||
</button>,
|
||||
|
@@ -1,28 +1,26 @@
|
||||
import * as Mithril from 'mithril';
|
||||
import User from '../models/User';
|
||||
|
||||
/**
|
||||
* The `avatar` helper displays a user's avatar.
|
||||
*
|
||||
* @param user
|
||||
* @param attrs Attributes to apply to the avatar element
|
||||
* @param {User} user
|
||||
* @param {Object} attrs Attributes to apply to the avatar element
|
||||
* @return {Object}
|
||||
*/
|
||||
export default function avatar(user: User, attrs: Object = {}): Mithril.Vnode {
|
||||
export default function avatar(user, attrs = {}) {
|
||||
attrs.className = 'Avatar ' + (attrs.className || '');
|
||||
let content: string = '';
|
||||
let content = '';
|
||||
|
||||
// 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: boolean | string = attrs.title === 'undefined' || attrs.title;
|
||||
const hasTitle = 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: string = user.displayName() || '?';
|
||||
const avatarUrl: string = user.avatarUrl();
|
||||
const username = user.displayName() || '?';
|
||||
const avatarUrl = 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 The string to highlight.
|
||||
* @param phrase The word or words to highlight.
|
||||
* @param [length] The number of characters to truncate the string to.
|
||||
* @param {String} string The string to highlight.
|
||||
* @param {String|RegExp} phrase The word or words to highlight.
|
||||
* @param {Integer} [length] The number of characters to truncate the string to.
|
||||
* The string will be truncated surrounding the first match.
|
||||
* @return {Object}
|
||||
*/
|
||||
export default function highlight(string: string, phrase: string | RegExp, length?: number): Mithril.Vnode<any, any> | string {
|
||||
export default function highlight(string, phrase, length) {
|
||||
if (!phrase && !length) return string;
|
||||
|
||||
// Convert the word phrase into a global regular expression (if it isn't
|
@@ -1,16 +1,15 @@
|
||||
import * as Mithril from 'mithril';
|
||||
import Separator from '../components/Separator';
|
||||
import classList from '../utils/classList';
|
||||
|
||||
function isSeparator(item): boolean {
|
||||
function isSeparator(item) {
|
||||
return item.tag === Separator;
|
||||
}
|
||||
|
||||
function withoutUnnecessarySeparators(items: Array<Mithril.Vnode>): Array<Mithril.Vnode> {
|
||||
function withoutUnnecessarySeparators(items) {
|
||||
const newItems = [];
|
||||
let prevItem;
|
||||
|
||||
items.filter(Boolean).forEach((item: Mithril.Vnode, i: number) => {
|
||||
items.filter(Boolean).forEach((item, i) => {
|
||||
if (!isSeparator(item) || (prevItem && !isSeparator(prevItem) && i !== items.length - 1)) {
|
||||
prevItem = item;
|
||||
newItems.push(item);
|
||||
@@ -23,11 +22,14 @@ function withoutUnnecessarySeparators(items: Array<Mithril.Vnode>): Array<Mithri
|
||||
/**
|
||||
* 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: Mithril.Vnode | Array<Mithril.Vnode>): Array<Mithril.Vnode> {
|
||||
export default function listItems(items) {
|
||||
if (!(items instanceof Array)) items = [items];
|
||||
|
||||
return withoutUnnecessarySeparators(items).map((item: Mithril.Vnode) => {
|
||||
return withoutUnnecessarySeparators(items).map((item) => {
|
||||
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;
|
||||
@@ -38,7 +40,7 @@ export default function listItems(items: Mithril.Vnode | Array<Mithril.Vnode>):
|
||||
item.key = item.attrs.key;
|
||||
}
|
||||
|
||||
const node: Mithril.Vnode = isListItem ? (
|
||||
const node = isListItem ? (
|
||||
item
|
||||
) : (
|
||||
<li
|
@@ -1,11 +1,12 @@
|
||||
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: User): Mithril.Vnode {
|
||||
export default function userOnline(user) {
|
||||
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: User): Mithril.Vnode {
|
||||
export default function username(user) {
|
||||
const name = (user && user.displayName()) || app.translator.trans('core.lib.username.deleted_text');
|
||||
|
||||
return <span className="username">{name}</span>;
|
@@ -1,8 +1,7 @@
|
||||
// Expose jQuery, mithril and dayjs to the window browser object
|
||||
import 'expose-loader?exposes[]=$&exposes[]=jQuery!jquery';
|
||||
import 'expose-loader?exposes=m!mithril';
|
||||
import 'expose-loader?exposes=dayjs!dayjs';
|
||||
|
||||
import 'expose-loader?$!expose-loader?jQuery!jquery';
|
||||
import 'expose-loader?m!mithril';
|
||||
import 'expose-loader?moment!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,8 +30,6 @@ 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,
|
||||
|
@@ -1,124 +0,0 @@
|
||||
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();
|
||||
}
|
||||
}
|
@@ -31,24 +31,7 @@ export default class Drawer {
|
||||
* @public
|
||||
*/
|
||||
hide() {
|
||||
/**
|
||||
* As part of hiding the drawer, this function also ensures that the drawer
|
||||
* correctly animates out, while ensuring it is not part of the navigation
|
||||
* tree while off-screen.
|
||||
*
|
||||
* More info: https://github.com/flarum/core/pull/2666#discussion_r595381014
|
||||
*/
|
||||
|
||||
const $app = $('#app');
|
||||
|
||||
if (!$app.hasClass('drawerOpen')) return;
|
||||
|
||||
const $drawer = $('#drawer');
|
||||
|
||||
// Used to prevent `visibility: hidden` from breaking the exit animation
|
||||
$drawer.css('visibility', 'visible').one('transitionend', () => $drawer.css('visibility', ''));
|
||||
|
||||
$app.removeClass('drawerOpen');
|
||||
$('#app').removeClass('drawerOpen');
|
||||
|
||||
if (this.$backdrop) this.$backdrop.remove();
|
||||
}
|
||||
|
@@ -1,105 +0,0 @@
|
||||
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;
|
||||
}
|
109
js/src/common/utils/SuperTextarea.js
Normal file
109
js/src/common/utils/SuperTextarea.js
Normal file
@@ -0,0 +1,109 @@
|
||||
/**
|
||||
* A textarea wrapper with powerful helpers for text manipulation.
|
||||
*
|
||||
* This wraps a <textarea> DOM element and allows directly manipulating its text
|
||||
* contents and cursor positions.
|
||||
*
|
||||
* I apologize for the pretentious name. :)
|
||||
*/
|
||||
export default class SuperTextarea {
|
||||
/**
|
||||
* @param {HTMLTextAreaElement} textarea
|
||||
*/
|
||||
constructor(textarea) {
|
||||
this.el = textarea;
|
||||
this.$ = $(textarea);
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the value of the text editor.
|
||||
*
|
||||
* @param {String} value
|
||||
*/
|
||||
setValue(value) {
|
||||
this.$.val(value).trigger('input');
|
||||
|
||||
this.el.dispatchEvent(new CustomEvent('input', { bubbles: true, cancelable: true }));
|
||||
}
|
||||
|
||||
/**
|
||||
* Focus the textarea and place the cursor at the given index.
|
||||
*
|
||||
* @param {number} position
|
||||
*/
|
||||
moveCursorTo(position) {
|
||||
this.setSelectionRange(position, position);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the selected range of the textarea.
|
||||
*
|
||||
* @return {Array}
|
||||
*/
|
||||
getSelectionRange() {
|
||||
return [this.el.selectionStart, this.el.selectionEnd];
|
||||
}
|
||||
|
||||
/**
|
||||
* Insert content into the textarea at the position of the cursor.
|
||||
*
|
||||
* @param {String} text
|
||||
*/
|
||||
insertAtCursor(text) {
|
||||
this.insertAt(this.el.selectionStart, text);
|
||||
}
|
||||
|
||||
/**
|
||||
* Insert content into the textarea at the given position.
|
||||
*
|
||||
* @param {number} pos
|
||||
* @param {String} text
|
||||
*/
|
||||
insertAt(pos, text) {
|
||||
this.insertBetween(pos, pos, text);
|
||||
}
|
||||
|
||||
/**
|
||||
* Insert content into the textarea between the given positions.
|
||||
*
|
||||
* If the start and end positions are different, any text between them will be
|
||||
* overwritten.
|
||||
*
|
||||
* @param start
|
||||
* @param end
|
||||
* @param text
|
||||
*/
|
||||
insertBetween(start, end, text) {
|
||||
const value = this.el.value;
|
||||
|
||||
const before = value.slice(0, start);
|
||||
const after = value.slice(end);
|
||||
|
||||
this.setValue(`${before}${text}${after}`);
|
||||
|
||||
// Move the textarea cursor to the end of the content we just inserted.
|
||||
this.moveCursorTo(start + text.length);
|
||||
}
|
||||
|
||||
/**
|
||||
* Replace existing content from the start to the current cursor position.
|
||||
*
|
||||
* @param start
|
||||
* @param text
|
||||
*/
|
||||
replaceBeforeCursor(start, text) {
|
||||
this.insertBetween(start, this.el.selectionStart, text);
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the selected range of the textarea.
|
||||
*
|
||||
* @param {number} start
|
||||
* @param {number} end
|
||||
* @private
|
||||
*/
|
||||
setSelectionRange(start, end) {
|
||||
this.el.setSelectionRange(start, end);
|
||||
this.$.focus();
|
||||
}
|
||||
}
|
@@ -1,50 +0,0 @@
|
||||
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;
|
26
js/src/common/utils/classList.js
Normal file
26
js/src/common/utils/classList.js
Normal file
@@ -0,0 +1,26 @@
|
||||
/**
|
||||
* The `classList` utility creates a list of class names by joining an object's
|
||||
* keys, but only for values which are truthy.
|
||||
*
|
||||
* @example
|
||||
* classList({ foo: true, bar: false, qux: 'qaz' });
|
||||
* // "foo qux"
|
||||
*
|
||||
* @param {Object} classes
|
||||
* @return {String}
|
||||
*/
|
||||
export default function classList(classes) {
|
||||
let classNames;
|
||||
|
||||
if (classes instanceof Array) {
|
||||
classNames = classes.filter((name) => name);
|
||||
} else {
|
||||
classNames = [];
|
||||
|
||||
for (const i in classes) {
|
||||
if (classes[i]) classNames.push(i);
|
||||
}
|
||||
}
|
||||
|
||||
return classNames.join(' ');
|
||||
}
|
@@ -1,12 +0,0 @@
|
||||
import clsx from 'clsx';
|
||||
|
||||
/**
|
||||
* This util exposes `clsx` to core and extensions as a re-usable utility.
|
||||
*
|
||||
* For full documentation, see `clsx` on GitHub.
|
||||
*
|
||||
* @see https://github.com/lukeed/clsx
|
||||
*/
|
||||
const classList = clsx;
|
||||
|
||||
export default classList;
|
@@ -1,4 +1,8 @@
|
||||
import bidi from './bidi';
|
||||
import withAttr from './withAttr';
|
||||
import Stream from './Stream';
|
||||
|
||||
let deprecatedMPropWarned = false;
|
||||
let deprecatedMWithAttrWarned = false;
|
||||
|
||||
export default function patchMithril(global) {
|
||||
const defaultMithril = global.m;
|
||||
@@ -10,7 +14,7 @@ export default function patchMithril(global) {
|
||||
|
||||
// Allows the use of the bidi attr.
|
||||
if (node.attrs.bidi) {
|
||||
bidi(node, node.attrs.bidi);
|
||||
modifiedMithril.bidi(node, node.attrs.bidi);
|
||||
}
|
||||
|
||||
return node;
|
||||
@@ -18,5 +22,23 @@ export default function patchMithril(global) {
|
||||
|
||||
Object.keys(defaultMithril).forEach((key) => (modifiedMithril[key] = defaultMithril[key]));
|
||||
|
||||
// BEGIN DEPRECATED MITHRIL 2 BC LAYER
|
||||
modifiedMithril.prop = function (...args) {
|
||||
if (!deprecatedMPropWarned) {
|
||||
deprecatedMPropWarned = true;
|
||||
console.warn('m.prop() is deprecated, please use the Stream util (flarum/utils/Streams) instead.');
|
||||
}
|
||||
return Stream.bind(this)(...args);
|
||||
};
|
||||
|
||||
modifiedMithril.withAttr = function (...args) {
|
||||
if (!deprecatedMWithAttrWarned) {
|
||||
deprecatedMWithAttrWarned = true;
|
||||
console.warn("m.withAttr() is deprecated, please use flarum's withAttr util (flarum/utils/withAttr) instead.");
|
||||
}
|
||||
return withAttr.bind(this)(...args);
|
||||
};
|
||||
// END DEPRECATED MITHRIL 2 BC LAYER
|
||||
|
||||
global.m = modifiedMithril;
|
||||
}
|
||||
|
@@ -1,10 +0,0 @@
|
||||
export default (compat: { [key: string]: any }, namespace: string) => {
|
||||
// regex to replace common/ and NAMESPACE/ for core & core extensions
|
||||
// e.g. admin/utils/extract --> utils/extract
|
||||
// e.g. tags/common/utils/sortTags --> tags/utils/sortTags
|
||||
const regex = new RegExp(`(\\w+\\/)?(${namespace}|common)\\/`);
|
||||
|
||||
return new Proxy(compat, {
|
||||
get: (obj, prop: string) => obj[prop] || obj[prop.replace(regex, '$1')],
|
||||
});
|
||||
};
|
@@ -16,7 +16,6 @@ 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 {
|
||||
/**
|
||||
@@ -91,6 +90,11 @@ export default class ForumApplication extends Application {
|
||||
* @type {DiscussionListState}
|
||||
*/
|
||||
this.discussions = new DiscussionListState({}, this);
|
||||
|
||||
/**
|
||||
* @deprecated beta 14, remove in beta 15.
|
||||
*/
|
||||
this.cache.discussionList = this.discussions;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -139,12 +143,6 @@ export default class ForumApplication extends Application {
|
||||
m.redraw();
|
||||
}
|
||||
});
|
||||
|
||||
if (isSafariMobile()) {
|
||||
$(() => {
|
||||
$('.App').addClass('mobile-safari');
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
@@ -1,8 +0,0 @@
|
||||
import Forum from './ForumApplication';
|
||||
|
||||
const app = new Forum();
|
||||
|
||||
// @ts-ignore
|
||||
window.app = app;
|
||||
|
||||
export default app;
|
@@ -16,7 +16,6 @@ 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';
|
||||
@@ -36,6 +35,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 AvatarEditor from './components/AvatarEditor';
|
||||
import Post from './components/Post';
|
||||
import SettingsPage from './components/SettingsPage';
|
||||
@@ -71,7 +72,6 @@ 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,7 +84,6 @@ export default Object.assign(compat, {
|
||||
'utils/alertEmailConfirmation': alertEmailConfirmation,
|
||||
'utils/UserControls': UserControls,
|
||||
'utils/Pane': Pane,
|
||||
'utils/BasicEditorDriver': BasicEditorDriver,
|
||||
'states/ComposerState': ComposerState,
|
||||
'states/DiscussionListState': DiscussionListState,
|
||||
'states/GlobalSearchState': GlobalSearchState,
|
||||
@@ -93,7 +92,6 @@ 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,
|
||||
@@ -113,6 +111,8 @@ export default Object.assign(compat, {
|
||||
'components/ComposerButton': ComposerButton,
|
||||
'components/DiscussionList': DiscussionList,
|
||||
'components/ReplyPlaceholder': ReplyPlaceholder,
|
||||
'components/TextEditor': TextEditor,
|
||||
'components/TextEditorButton': TextEditorButton,
|
||||
'components/AvatarEditor': AvatarEditor,
|
||||
'components/Post': Post,
|
||||
'components/SettingsPage': SettingsPage,
|
||||
|
@@ -52,13 +52,7 @@ export default class AvatarEditor extends Component {
|
||||
ondragend={this.disableDragover.bind(this)}
|
||||
ondrop={this.dropUpload.bind(this)}
|
||||
>
|
||||
{this.loading ? (
|
||||
<LoadingIndicator display="unset" size="large" />
|
||||
) : user.avatarUrl() ? (
|
||||
icon('fas fa-pencil-alt')
|
||||
) : (
|
||||
icon('fas fa-plus-circle')
|
||||
)}
|
||||
{this.loading ? <LoadingIndicator /> : user.avatarUrl() ? icon('fas fa-pencil-alt') : icon('fas fa-plus-circle')}
|
||||
</a>
|
||||
<ul className="Dropdown-menu Menu">{listItems(this.controlItems().toArray())}</ul>
|
||||
</div>
|
||||
|
@@ -106,8 +106,9 @@ export default class ChangeEmailModal extends Modal {
|
||||
return;
|
||||
}
|
||||
|
||||
const oldEmail = app.session.user.email();
|
||||
|
||||
this.loading = true;
|
||||
this.alertAttrs = null;
|
||||
|
||||
app.session.user
|
||||
.save(
|
||||
@@ -117,9 +118,7 @@ export default class ChangeEmailModal extends Modal {
|
||||
meta: { password: this.password() },
|
||||
}
|
||||
)
|
||||
.then(() => {
|
||||
this.success = true;
|
||||
})
|
||||
.then(() => (this.success = true))
|
||||
.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,.TextEditor-editorContainer', (e) => {
|
||||
this.$().on('focus blur', ':input', (e) => {
|
||||
this.active = e.type === 'focusin';
|
||||
m.redraw();
|
||||
});
|
||||
|
||||
// When the escape key is pressed on any inputs, close the composer.
|
||||
this.$().on('keydown', ':input,.TextEditor-editorContainer', 'esc', () => this.state.close());
|
||||
this.$().on('keydown', ':input', '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, .TextEditor-editor').first().focus();
|
||||
this.$('.Composer-content :input:enabled:visible:first').focus();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -199,7 +199,7 @@ export default class Composer extends Component {
|
||||
*/
|
||||
animatePositionChange() {
|
||||
// When exiting full-screen mode: focus content
|
||||
if (this.prevPosition === ComposerState.Position.FULLSCREEN && this.state.position === ComposerState.Position.NORMAL) {
|
||||
if (this.prevPosition === ComposerState.Position.FULLSCREEN) {
|
||||
this.focus();
|
||||
return;
|
||||
}
|
||||
@@ -265,17 +265,7 @@ export default class Composer extends Component {
|
||||
this.animateHeightChange().then(() => this.focus());
|
||||
|
||||
if (app.screen() === 'phone') {
|
||||
// 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.$().css('top', $(window).scrollTop());
|
||||
this.showBackdrop();
|
||||
}
|
||||
}
|
||||
|
@@ -1,11 +1,10 @@
|
||||
import Component from '../../common/Component';
|
||||
import LoadingIndicator from '../../common/components/LoadingIndicator';
|
||||
import ConfirmDocumentUnload from '../../common/components/ConfirmDocumentUnload';
|
||||
import TextEditor from '../../common/components/TextEditor';
|
||||
import TextEditor from './TextEditor';
|
||||
import avatar from '../../common/helpers/avatar';
|
||||
import listItems from '../../common/helpers/listItems';
|
||||
import ItemList from '../../common/utils/ItemList';
|
||||
import classList from '../../common/utils/classList';
|
||||
|
||||
/**
|
||||
* The `ComposerBody` component handles the body, or the content, of the
|
||||
@@ -45,6 +44,12 @@ export default class ComposerBody extends Component {
|
||||
}
|
||||
|
||||
this.composer.fields.content(this.attrs.originalContent || '');
|
||||
|
||||
/**
|
||||
* @deprecated BC layer, remove in Beta 15.
|
||||
*/
|
||||
this.content = this.composer.fields.content;
|
||||
this.editor = this.composer;
|
||||
}
|
||||
|
||||
view() {
|
||||
@@ -67,7 +72,7 @@ export default class ComposerBody extends Component {
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
<LoadingIndicator display="unset" containerClassName={classList('ComposerBody-loading', this.loading && 'active')} size="large" />
|
||||
{LoadingIndicator.component({ className: 'ComposerBody-loading' + (this.loading ? ' active' : '') })}
|
||||
</div>
|
||||
</ConfirmDocumentUnload>
|
||||
);
|
||||
|
@@ -19,7 +19,7 @@ export default class DiscussionList extends Component {
|
||||
let loading;
|
||||
|
||||
if (state.isLoading()) {
|
||||
loading = <LoadingIndicator />;
|
||||
loading = LoadingIndicator.component();
|
||||
} else if (state.moreResults) {
|
||||
loading = Button.component(
|
||||
{
|
||||
|
@@ -87,7 +87,6 @@ export default class DiscussionListItem extends Component {
|
||||
icon: 'fas fa-ellipsis-v',
|
||||
className: 'DiscussionListItem-controls',
|
||||
buttonClassName: 'Button Button--icon Button--flat Slidable-underneath Slidable-underneath--right',
|
||||
accessibleToggleLabel: app.translator.trans('core.forum.discussion_controls.toggle_dropdown_accessible_label'),
|
||||
},
|
||||
controls
|
||||
)
|
||||
@@ -122,8 +121,6 @@ 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,6 +1,5 @@
|
||||
import DiscussionList from './DiscussionList';
|
||||
import Component from '../../common/Component';
|
||||
import DiscussionPage from './DiscussionPage';
|
||||
|
||||
const hotEdge = (e) => {
|
||||
if (e.pageX < 10) app.pane.show();
|
||||
@@ -37,31 +36,23 @@ export default class DiscussionListPane extends Component {
|
||||
|
||||
$(document).on('mousemove', hotEdge);
|
||||
|
||||
// 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 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(vnode) {
|
||||
app.cache.discussionListPaneScrollTop = $(vnode.dom).scrollTop();
|
||||
onremove() {
|
||||
$(document).off('mousemove', hotEdge);
|
||||
}
|
||||
|
||||
|
@@ -18,8 +18,6 @@ export default class DiscussionPage extends Page {
|
||||
oninit(vnode) {
|
||||
super.oninit(vnode);
|
||||
|
||||
this.useBrowserScrollRestoration = false;
|
||||
|
||||
/**
|
||||
* The discussion that is being viewed.
|
||||
*
|
||||
@@ -73,25 +71,23 @@ export default class DiscussionPage extends Page {
|
||||
<div className="DiscussionPage">
|
||||
<DiscussionListPane state={app.discussions} />
|
||||
<div className="DiscussionPage-discussion">
|
||||
{discussion ? (
|
||||
[
|
||||
DiscussionHero.component({ discussion }),
|
||||
<div className="container">
|
||||
<nav className="DiscussionPage-nav">
|
||||
<ul>{listItems(this.sidebarItems().toArray())}</ul>
|
||||
</nav>
|
||||
<div className="DiscussionPage-stream">
|
||||
{PostStream.component({
|
||||
discussion,
|
||||
stream: this.stream,
|
||||
onPositionChange: this.positionChanged.bind(this),
|
||||
})}
|
||||
</div>
|
||||
</div>,
|
||||
]
|
||||
) : (
|
||||
<LoadingIndicator />
|
||||
)}
|
||||
{discussion
|
||||
? [
|
||||
DiscussionHero.component({ discussion }),
|
||||
<div className="container">
|
||||
<nav className="DiscussionPage-nav">
|
||||
<ul>{listItems(this.sidebarItems().toArray())}</ul>
|
||||
</nav>
|
||||
<div className="DiscussionPage-stream">
|
||||
{PostStream.component({
|
||||
discussion,
|
||||
stream: this.stream,
|
||||
onPositionChange: this.positionChanged.bind(this),
|
||||
})}
|
||||
</div>
|
||||
</div>,
|
||||
]
|
||||
: LoadingIndicator.component({ className: 'LoadingIndicator--block' })}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -191,7 +187,6 @@ export default class DiscussionPage extends Page {
|
||||
icon: 'fas fa-ellipsis-v',
|
||||
className: 'App-primaryControl',
|
||||
buttonClassName: 'Button--primary',
|
||||
accessibleToggleLabel: app.translator.trans('core.forum.discussion_controls.toggle_dropdown_accessible_label'),
|
||||
},
|
||||
DiscussionControls.controls(this.discussion, this).toArray()
|
||||
)
|
||||
|
@@ -37,10 +37,9 @@ export default class EditUserModal extends Modal {
|
||||
}
|
||||
|
||||
content() {
|
||||
const fields = this.fields().toArray();
|
||||
return (
|
||||
<div className="Modal-body">
|
||||
{fields.length > 1 ? <div className="Form">{this.fields().toArray()}</div> : app.translator.trans('core.forum.edit_user.nothing_available')}
|
||||
<div className="Form">{this.fields().toArray()}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -48,112 +47,96 @@ export default class EditUserModal extends Modal {
|
||||
fields() {
|
||||
const items = new ItemList();
|
||||
|
||||
if (app.session.user.canEditCredentials()) {
|
||||
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) {
|
||||
items.add(
|
||||
'username',
|
||||
'email',
|
||||
<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}
|
||||
disabled={this.nonAdminEditingAdmin()}
|
||||
/>
|
||||
<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>
|
||||
) : (
|
||||
''
|
||||
)}
|
||||
</div>,
|
||||
40
|
||||
30
|
||||
);
|
||||
|
||||
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>
|
||||
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() ? (
|
||||
<input
|
||||
className="FormControl"
|
||||
placeholder={extractText(app.translator.trans('core.forum.edit_user.email_label'))}
|
||||
bidi={this.email}
|
||||
disabled={this.nonAdminEditingAdmin()}
|
||||
type="password"
|
||||
name="password"
|
||||
placeholder={extractText(app.translator.trans('core.forum.edit_user.password_label'))}
|
||||
bidi={this.password}
|
||||
/>
|
||||
</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>,
|
||||
30
|
||||
);
|
||||
</div>
|
||||
</div>,
|
||||
20
|
||||
);
|
||||
}
|
||||
|
||||
items.add(
|
||||
'password',
|
||||
<div className="Form-group">
|
||||
<label>{app.translator.trans('core.forum.edit_user.password_heading')}</label>
|
||||
<div>
|
||||
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"
|
||||
onchange={(e) => {
|
||||
this.setPassword(e.target.checked);
|
||||
m.redraw.sync();
|
||||
if (e.target.checked) this.$('[name=password]').select();
|
||||
e.redraw = false;
|
||||
}}
|
||||
disabled={this.nonAdminEditingAdmin()}
|
||||
bidi={this.groups[group.id()]}
|
||||
disabled={this.attrs.user.id() === '1' && group.id() === Group.ADMINISTRATOR_ID}
|
||||
/>
|
||||
{app.translator.trans('core.forum.edit_user.set_password_label')}
|
||||
{GroupBadge.component({ group, label: '' })} {group.nameSingular()}
|
||||
</label>
|
||||
{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
|
||||
);
|
||||
}
|
||||
))}
|
||||
</div>
|
||||
</div>,
|
||||
10
|
||||
);
|
||||
|
||||
items.add(
|
||||
'submit',
|
||||
@@ -193,26 +176,21 @@ 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 = {
|
||||
relationships: {},
|
||||
username: this.username(),
|
||||
relationships: { groups },
|
||||
};
|
||||
|
||||
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 (app.session.user !== this.attrs.user) {
|
||||
data.email = this.email();
|
||||
}
|
||||
|
||||
if (this.attrs.user.canEditGroups()) {
|
||||
data.relationships.groups = Object.keys(this.groups)
|
||||
.filter((id) => this.groups[id]())
|
||||
.map((id) => app.store.getById('groups', id));
|
||||
if (this.setPassword()) {
|
||||
data.password = this.password();
|
||||
}
|
||||
|
||||
return data;
|
||||
@@ -231,16 +209,4 @@ 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);
|
||||
}
|
||||
}
|
||||
|
@@ -57,7 +57,6 @@ export default class HeaderSecondary extends Component {
|
||||
SelectDropdown.component(
|
||||
{
|
||||
buttonClassName: 'Button Button--link',
|
||||
accessibleToggleLabel: app.translator.trans('core.forum.header.locale_dropdown_accessible_label'),
|
||||
},
|
||||
locales
|
||||
),
|
||||
|
@@ -172,7 +172,6 @@ export default class IndexPage extends Page {
|
||||
{
|
||||
buttonClassName: 'Button',
|
||||
className: 'App-titleControl',
|
||||
accessibleToggleLabel: app.translator.trans('core.forum.index.toggle_sidenav_dropdown_accessible_label'),
|
||||
},
|
||||
this.navItems(this).toArray()
|
||||
)
|
||||
@@ -228,7 +227,6 @@ export default class IndexPage extends Page {
|
||||
{
|
||||
buttonClassName: 'Button',
|
||||
label: sortOptions[app.search.params().sort] || Object.keys(sortMap).map((key) => sortOptions[key])[0],
|
||||
accessibleToggleLabel: app.translator.trans('core.forum.index_sort.toggle_dropdown_accessible_label'),
|
||||
},
|
||||
Object.keys(sortOptions).map((value) => {
|
||||
const label = sortOptions[value];
|
||||
|
@@ -84,7 +84,7 @@ export default class NotificationList extends Component {
|
||||
})
|
||||
: ''}
|
||||
{state.isLoading() ? (
|
||||
<LoadingIndicator />
|
||||
<LoadingIndicator className="LoadingIndicator--block" />
|
||||
) : pages.length ? (
|
||||
''
|
||||
) : (
|
||||
@@ -99,9 +99,7 @@ export default class NotificationList extends Component {
|
||||
super.oncreate(vnode);
|
||||
|
||||
this.$notifications = this.$('.NotificationList-content');
|
||||
|
||||
// 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.$scrollParent = this.$notifications.css('overflow') === 'auto' ? this.$notifications : $(window);
|
||||
|
||||
this.boundScrollHandler = this.scrollHandler.bind(this);
|
||||
this.$scrollParent.on('scroll', this.boundScrollHandler);
|
||||
@@ -114,24 +112,14 @@ export default class NotificationList extends Component {
|
||||
scrollHandler() {
|
||||
const state = this.attrs.state;
|
||||
|
||||
// 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 scrollTop = this.$scrollParent.scrollTop();
|
||||
const viewportHeight = this.$scrollParent.height();
|
||||
|
||||
// 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;
|
||||
const contentTop = this.$scrollParent === this.$notifications ? 0 : this.$notifications.offset().top;
|
||||
const contentHeight = this.$notifications[0].scrollHeight;
|
||||
|
||||
if (state.hasMoreResults() && !state.isLoading() && atBottom) {
|
||||
if (state.hasMoreResults() && !state.isLoading() && scrollTop + viewportHeight >= contentTop + contentHeight) {
|
||||
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';
|
||||
}
|
||||
}
|
||||
|
@@ -9,8 +9,6 @@ export default class NotificationsDropdown extends Dropdown {
|
||||
attrs.menuClassName = attrs.menuClassName || 'Dropdown-menu--right';
|
||||
attrs.label = attrs.label || app.translator.trans('core.forum.notifications.tooltip');
|
||||
attrs.icon = attrs.icon || 'fas fa-bell';
|
||||
// For best a11y support, both `title` and `aria-label` should be used
|
||||
attrs.accessibleToggleLabel = attrs.accessibleToggleLabel || app.translator.trans('core.forum.notifications.toggle_dropdown_accessible_label');
|
||||
|
||||
super.initAttrs(attrs);
|
||||
}
|
||||
|
@@ -61,7 +61,6 @@ export default class Post extends Component {
|
||||
icon="fas fa-ellipsis-h"
|
||||
onshow={() => this.$('.Post-actions').addClass('open')}
|
||||
onhide={() => this.$('.Post-actions').removeClass('open')}
|
||||
accessibleToggleLabel={app.translator.trans('core.forum.post_controls.toggle_dropdown_accessible_label')}
|
||||
>
|
||||
{controls}
|
||||
</Dropdown>
|
||||
|
@@ -142,29 +142,13 @@ 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 || this.stream.pagesLoading) return;
|
||||
|
||||
this.updateScrubber(top);
|
||||
|
||||
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) {
|
||||
if (this.stream.paused) return;
|
||||
const marginTop = this.getMarginTop();
|
||||
const viewportHeight = $(window).height() - marginTop;
|
||||
const viewportTop = top + marginTop;
|
||||
@@ -185,6 +169,13 @@ 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);
|
||||
|
||||
this.updateScrubber(top);
|
||||
}
|
||||
|
||||
updateScrubber(top = window.pageYOffset) {
|
||||
@@ -405,8 +396,6 @@ export default class PostStream extends Component {
|
||||
|
||||
this.calculatePosition();
|
||||
this.stream.paused = false;
|
||||
// Check if we need to load more posts after scrolling.
|
||||
this.loadPostsIfNeeded();
|
||||
});
|
||||
}
|
||||
|
||||
|
@@ -112,6 +112,7 @@ export default class PostStreamScrubber extends Component {
|
||||
|
||||
// Now we want to make the scrollbar handle draggable. Let's start by
|
||||
// preventing default browser events from messing things up.
|
||||
.css({ cursor: 'pointer', 'user-select': 'none' })
|
||||
.bind('dragstart mousedown touchstart', (e) => e.preventDefault());
|
||||
|
||||
// When the mouse is pressed on the scrollbar handle, we capture some
|
||||
@@ -123,6 +124,7 @@ export default class PostStreamScrubber extends Component {
|
||||
this.indexStart = 0;
|
||||
|
||||
this.$('.Scrubber-handle')
|
||||
.css('cursor', 'move')
|
||||
.bind('mousedown touchstart', this.onmousedown.bind(this))
|
||||
|
||||
// Exempt the scrollbar handle from the 'jump to' click event.
|
||||
|
@@ -121,7 +121,7 @@ export default class PostsUserPage extends UserPage {
|
||||
loadResults(offset) {
|
||||
return app.store.find('posts', {
|
||||
filter: {
|
||||
author: this.user.username(),
|
||||
user: this.user.id(),
|
||||
type: 'comment',
|
||||
},
|
||||
page: { offset, limit: this.loadLimit },
|
||||
|
@@ -49,7 +49,7 @@ export default class RenameDiscussionModal extends Modal {
|
||||
|
||||
this.loading = true;
|
||||
|
||||
const title = this.newTitle();
|
||||
const title = this.newTitle;
|
||||
const currentTitle = this.currentTitle;
|
||||
|
||||
// If the title is different to what it was before, then save it. After the
|
||||
|
@@ -5,7 +5,6 @@ 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,
|
||||
@@ -26,7 +25,6 @@ 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)} />
|
||||
|
@@ -71,11 +71,8 @@ export default class Search extends Component {
|
||||
// Hide the search view if no sources were loaded
|
||||
if (!this.sources.length) return <div></div>;
|
||||
|
||||
const searchLabel = extractText(app.translator.trans('core.forum.header.search_placeholder'));
|
||||
|
||||
return (
|
||||
<div
|
||||
role="search"
|
||||
className={
|
||||
'Search ' +
|
||||
classList({
|
||||
@@ -88,17 +85,16 @@ export default class Search extends Component {
|
||||
>
|
||||
<div className="Search-input">
|
||||
<input
|
||||
aria-label={searchLabel}
|
||||
className="FormControl"
|
||||
type="search"
|
||||
placeholder={searchLabel}
|
||||
placeholder={extractText(app.translator.trans('core.forum.header.search_placeholder'))}
|
||||
value={this.state.getValue()}
|
||||
oninput={(e) => this.state.setValue(e.target.value)}
|
||||
onfocus={() => (this.hasFocus = true)}
|
||||
onblur={() => (this.hasFocus = false)}
|
||||
/>
|
||||
{this.loadingSources ? (
|
||||
<LoadingIndicator size="small" display="inline" containerClassName="Button Button--icon Button--link" />
|
||||
LoadingIndicator.component({ size: 'tiny', className: 'Button Button--icon Button--link' })
|
||||
) : currentSearch ? (
|
||||
<button className="Search-clear Button Button--icon Button--link" onclick={this.clear.bind(this)}>
|
||||
{icon('fas fa-times-circle')}
|
||||
@@ -114,23 +110,9 @@ export default class Search extends Component {
|
||||
);
|
||||
}
|
||||
|
||||
updateMaxHeight() {
|
||||
// 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`;
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
this.updateMaxHeight();
|
||||
}
|
||||
|
||||
oncreate(vnode) {
|
||||
@@ -195,13 +177,6 @@ export default class Search extends Component {
|
||||
.one('mouseup', (e) => e.preventDefault())
|
||||
.select();
|
||||
});
|
||||
|
||||
this.updateMaxHeightHandler = this.updateMaxHeight.bind(this);
|
||||
window.addEventListener('resize', this.updateMaxHeightHandler);
|
||||
}
|
||||
|
||||
onremove() {
|
||||
window.removeEventListener('resize', this.updateMaxHeightHandler);
|
||||
}
|
||||
|
||||
/**
|
||||
|
@@ -17,8 +17,6 @@ export default class SessionDropdown extends Dropdown {
|
||||
attrs.className = 'SessionDropdown';
|
||||
attrs.buttonClassName = 'Button Button--user Button--flat';
|
||||
attrs.menuClassName = 'Dropdown-menu--right';
|
||||
|
||||
attrs.accessibleToggleLabel = app.translator.trans('core.forum.header.session_dropdown_accessible_label');
|
||||
}
|
||||
|
||||
view(vnode) {
|
||||
|
@@ -1,9 +1,8 @@
|
||||
import Component from '../Component';
|
||||
import ItemList from '../utils/ItemList';
|
||||
import listItems from '../helpers/listItems';
|
||||
import Button from './Button';
|
||||
|
||||
import BasicEditorDriver from '../utils/BasicEditorDriver';
|
||||
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';
|
||||
|
||||
/**
|
||||
* The `TextEditor` component displays a textarea with controls, including a
|
||||
@@ -23,22 +22,25 @@ export default class TextEditor extends Component {
|
||||
super.oninit(vnode);
|
||||
|
||||
/**
|
||||
* The value of the editor.
|
||||
* The value of the textarea.
|
||||
*
|
||||
* @type {String}
|
||||
*/
|
||||
this.value = this.attrs.value || '';
|
||||
|
||||
/**
|
||||
* Whether the editor is disabled.
|
||||
*/
|
||||
this.disabled = !!this.attrs.disabled;
|
||||
}
|
||||
|
||||
view() {
|
||||
return (
|
||||
<div className="TextEditor">
|
||||
<div className="TextEditor-editorContainer"></div>
|
||||
<textarea
|
||||
className="FormControl Composer-flexible"
|
||||
oninput={(e) => {
|
||||
this.oninput(e.target.value, e);
|
||||
}}
|
||||
placeholder={this.attrs.placeholder || ''}
|
||||
disabled={!!this.attrs.disabled}
|
||||
value={this.value}
|
||||
/>
|
||||
|
||||
<ul className="TextEditor-controls Composer-footer">
|
||||
{listItems(this.controlItems().toArray())}
|
||||
@@ -51,35 +53,15 @@ export default class TextEditor extends Component {
|
||||
oncreate(vnode) {
|
||||
super.oncreate(vnode);
|
||||
|
||||
this.attrs.composer.editor = this.buildEditor(this.$('.TextEditor-editorContainer')[0]);
|
||||
}
|
||||
|
||||
onupdate() {
|
||||
const newDisabled = !!this.attrs.disabled;
|
||||
|
||||
if (this.disabled !== newDisabled) {
|
||||
this.disabled = newDisabled;
|
||||
this.attrs.composer.editor.disabled(newDisabled);
|
||||
}
|
||||
}
|
||||
|
||||
buildEditorParams() {
|
||||
return {
|
||||
classNames: ['FormControl', 'Composer-flexible', 'TextEditor-editor'],
|
||||
disabled: this.disabled,
|
||||
placeholder: this.attrs.placeholder || '',
|
||||
value: this.value,
|
||||
oninput: this.oninput.bind(this),
|
||||
inputListeners: [],
|
||||
onsubmit: () => {
|
||||
this.onsubmit();
|
||||
m.redraw();
|
||||
},
|
||||
const handler = () => {
|
||||
this.onsubmit();
|
||||
m.redraw();
|
||||
};
|
||||
}
|
||||
|
||||
buildEditor(dom) {
|
||||
return new BasicEditorDriver(dom, this.buildEditorParams());
|
||||
this.$('textarea').bind('keydown', 'meta+return', handler);
|
||||
this.$('textarea').bind('keydown', 'ctrl+return', handler);
|
||||
|
||||
this.attrs.composer.editor = new SuperTextarea(this.$('textarea')[0]);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -133,10 +115,12 @@ export default class TextEditor extends Component {
|
||||
*
|
||||
* @param {String} value
|
||||
*/
|
||||
oninput(value) {
|
||||
oninput(value, e) {
|
||||
this.value = value;
|
||||
|
||||
this.attrs.onchange(this.value);
|
||||
|
||||
e.redraw = false;
|
||||
}
|
||||
|
||||
/**
|
@@ -1,4 +1,4 @@
|
||||
import Button from './Button';
|
||||
import Button from '../../common/components/Button';
|
||||
|
||||
/**
|
||||
* The `TextEditorButton` component displays a button suitable for the text
|
@@ -40,7 +40,6 @@ export default class UserCard extends Component {
|
||||
menuClassName: 'Dropdown-menu--right',
|
||||
buttonClassName: this.attrs.controlsButtonClassName,
|
||||
label: app.translator.trans('core.forum.user_controls.button'),
|
||||
accessibleToggleLabel: app.translator.trans('core.forum.user_controls.toggle_dropdown_accessible_label'),
|
||||
icon: 'fas fa-ellipsis-v',
|
||||
},
|
||||
controls
|
||||
|
@@ -51,7 +51,7 @@ export default class UserPage extends Page {
|
||||
</div>
|
||||
</div>,
|
||||
]
|
||||
: [<LoadingIndicator display="block" />]}
|
||||
: [<LoadingIndicator className="LoadingIndicator--block" />]}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
@@ -1,8 +1,12 @@
|
||||
// Expose punycode and ColorThief to the window browser object
|
||||
import 'expose-loader?exposes=punycode!punycode';
|
||||
import 'expose-loader?exposes=ColorThief!color-thief-browser';
|
||||
import 'expose-loader?punycode!punycode';
|
||||
import 'expose-loader?ColorThief!color-thief-browser';
|
||||
|
||||
import app from './app';
|
||||
import ForumApplication from './ForumApplication';
|
||||
|
||||
const app = new ForumApplication();
|
||||
|
||||
// Backwards compatibility
|
||||
window.app = app;
|
||||
|
||||
export { app };
|
||||
|
||||
@@ -11,9 +15,8 @@ export { app };
|
||||
// export { IndexPage, DicsussionList } from './components';
|
||||
|
||||
// Export compat API
|
||||
import compatObj from './compat';
|
||||
import proxifyCompat from '../common/utils/proxifyCompat';
|
||||
import compat from './compat';
|
||||
|
||||
compatObj.app = app;
|
||||
compat.app = app;
|
||||
|
||||
export const compat = proxifyCompat(compatObj, 'forum');
|
||||
export { compat };
|
||||
|
@@ -1,7 +1,6 @@
|
||||
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() {
|
||||
@@ -30,11 +29,16 @@ class ComposerState {
|
||||
/**
|
||||
* A reference to the text editor that allows text manipulation.
|
||||
*
|
||||
* @type {EditorDriverInterface|null}
|
||||
* @type {SuperTextArea|null}
|
||||
*/
|
||||
this.editor = null;
|
||||
|
||||
this.clear();
|
||||
|
||||
/**
|
||||
* @deprecated BC layer, remove in Beta 15.
|
||||
*/
|
||||
this.component = this;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -67,16 +71,18 @@ 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;
|
||||
/**
|
||||
* @deprecated BC layer, remove in Beta 15.
|
||||
*/
|
||||
this.content = this.fields.content;
|
||||
this.value = this.fields.content;
|
||||
}
|
||||
|
||||
/**
|
||||
|
@@ -1,4 +1,3 @@
|
||||
import { throttle } from 'lodash-es';
|
||||
import anchorScroll from '../../common/utils/anchorScroll';
|
||||
|
||||
class PostStreamState {
|
||||
@@ -50,9 +49,6 @@ class PostStreamState {
|
||||
*/
|
||||
this.forceUpdateScrubber = false;
|
||||
|
||||
this.loadNext = throttle(this._loadNext, 300);
|
||||
this.loadPrevious = throttle(this._loadPrevious, 300);
|
||||
|
||||
this.show(includedPosts);
|
||||
}
|
||||
|
||||
@@ -176,7 +172,7 @@ class PostStreamState {
|
||||
* @return {Promise}
|
||||
*/
|
||||
loadNearIndex(index) {
|
||||
if (index >= this.visibleStart && index < this.visibleEnd) {
|
||||
if (index >= this.visibleStart && index <= this.visibleEnd) {
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
@@ -191,7 +187,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));
|
||||
|
||||
@@ -214,7 +210,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));
|
||||
|
||||
@@ -242,26 +238,23 @@ class PostStreamState {
|
||||
* @param {Boolean} backwards
|
||||
*/
|
||||
loadPage(start, end, backwards = false) {
|
||||
this.pagesLoading++;
|
||||
|
||||
const redraw = () => {
|
||||
if (start < this.visibleStart || end > this.visibleEnd) return;
|
||||
|
||||
const anchorIndex = backwards ? this.visibleEnd - 1 : this.visibleStart;
|
||||
anchorScroll(`.PostStream-item[data-index="${anchorIndex}"]`, m.redraw.sync);
|
||||
};
|
||||
redraw();
|
||||
m.redraw();
|
||||
|
||||
this.loadPageTimeouts[start] = setTimeout(
|
||||
() => {
|
||||
this.loadRange(start, end).then(() => {
|
||||
redraw();
|
||||
if (start >= this.visibleStart && end <= this.visibleEnd) {
|
||||
const anchorIndex = backwards ? this.visibleEnd - 1 : this.visibleStart;
|
||||
anchorScroll(`.PostStream-item[data-index="${anchorIndex}"]`, () => m.redraw.sync());
|
||||
}
|
||||
this.pagesLoading--;
|
||||
});
|
||||
this.loadPageTimeouts[start] = null;
|
||||
},
|
||||
this.pagesLoading - 1 ? 1000 : 0
|
||||
this.pagesLoading ? 1000 : 0
|
||||
);
|
||||
|
||||
this.pagesLoading++;
|
||||
}
|
||||
|
||||
/**
|
||||
|
@@ -57,7 +57,7 @@ export default {
|
||||
moderationControls(user) {
|
||||
const items = new ItemList();
|
||||
|
||||
if (user.canEdit() || user.canEditCredentials() || user.canEditGroups()) {
|
||||
if (user.canEdit()) {
|
||||
items.add(
|
||||
'edit',
|
||||
<Button icon="fas fa-pencil-alt" onclick={this.editAction.bind(this, user)}>
|
||||
|
@@ -1,13 +0,0 @@
|
||||
/**
|
||||
* @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')
|
||||
);
|
||||
}
|
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"include": ["src/**/*.ts", "src/**/*.tsx"],
|
||||
"include": ["src/**/*.ts"],
|
||||
"files": ["shims.d.ts"],
|
||||
"compilerOptions": {
|
||||
"allowUmdGlobalAccess": true,
|
||||
|
@@ -1,26 +1,15 @@
|
||||
const config = require('flarum-webpack-config');
|
||||
const { BundleAnalyzerPlugin } = require('webpack-bundle-analyzer');
|
||||
const merge = require('webpack-merge');
|
||||
|
||||
const useBundleAnalyzer = process.env.ANALYZER === 'true';
|
||||
|
||||
const plugins = [];
|
||||
|
||||
if (useBundleAnalyzer) {
|
||||
plugins.push(new BundleAnalyzerPlugin());
|
||||
}
|
||||
|
||||
module.exports = merge(config(), {
|
||||
output: {
|
||||
library: 'flarum.core',
|
||||
library: 'flarum.core'
|
||||
},
|
||||
|
||||
// temporary TS configuration
|
||||
resolve: {
|
||||
extensions: ['.ts', '.tsx', '.js', '.json'],
|
||||
},
|
||||
|
||||
plugins,
|
||||
});
|
||||
|
||||
module.exports['module'].rules[0].test = /\.(tsx?|js)$/;
|
||||
|
@@ -11,7 +11,6 @@
|
||||
|
||||
.AdminHeader-description {
|
||||
margin: 0;
|
||||
color: @control-color;
|
||||
}
|
||||
|
||||
.icon {
|
||||
|
@@ -41,13 +41,16 @@
|
||||
}
|
||||
|
||||
@media @tablet {
|
||||
.AdminNav {
|
||||
.item-search,
|
||||
li[class^="item-category"],
|
||||
li[class^="item-extension"],
|
||||
.AdminLinkButton-description {
|
||||
display: none !important;
|
||||
}
|
||||
.item-search{
|
||||
display: none;
|
||||
}
|
||||
|
||||
.ExtensionItem, .item-search {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.ExtensionListTitle {
|
||||
display: none !important;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -77,7 +80,7 @@
|
||||
}
|
||||
|
||||
|
||||
@media @desktop-up {
|
||||
@media @desktop, @desktop-hd {
|
||||
.App-nav {
|
||||
position: absolute;
|
||||
top: @header-height;
|
||||
@@ -104,47 +107,36 @@
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.item-category-core {
|
||||
> .ExtensionListTitle {
|
||||
margin-top: 10px;
|
||||
}
|
||||
}
|
||||
|
||||
> li {
|
||||
> a {
|
||||
padding: 10px 10px 10px 45px;
|
||||
display: block;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
> a,
|
||||
> a:hover,
|
||||
&.active > a {
|
||||
color: @text-color;
|
||||
}
|
||||
|
||||
> a:hover {
|
||||
background: @control-bg;
|
||||
}
|
||||
|
||||
&.active > a {
|
||||
background: @control-color;
|
||||
background: @primary-color;
|
||||
font-weight: normal;
|
||||
color: @body-bg;
|
||||
|
||||
.Button-label,
|
||||
.Button-icon {
|
||||
color: @body-bg;
|
||||
font-weight: bold;
|
||||
}
|
||||
}
|
||||
|
||||
.Button-icon {
|
||||
float: left;
|
||||
font-size: 13px !important;
|
||||
margin-left: -25px !important;
|
||||
margin-top: 4px !important;
|
||||
}
|
||||
|
||||
.Button-label {
|
||||
padding-left: 5px;
|
||||
font-size: 14px;
|
||||
@@ -160,7 +152,7 @@
|
||||
.ExtensionListTitle {
|
||||
color: @muted-color;
|
||||
text-transform: uppercase;
|
||||
margin: 25px 0 8px 15px;
|
||||
margin: 25px 0 15px 15px;
|
||||
}
|
||||
|
||||
.ExtensionIcon {
|
||||
@@ -188,11 +180,6 @@
|
||||
|
||||
}
|
||||
|
||||
.AdminLinkButton-description {
|
||||
white-space: normal;
|
||||
padding-left: 5px;
|
||||
}
|
||||
|
||||
.ExtensionListItem-Dot {
|
||||
height: 10px;
|
||||
width: 10px;
|
||||
@@ -206,7 +193,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,6 +205,5 @@
|
||||
background-color: #2ECC40;
|
||||
}
|
||||
.ExtensionListItem-Dot.disabled {
|
||||
border: 2px solid #FF4136;
|
||||
box-sizing: border-box;
|
||||
background-color: #FF4136;
|
||||
}
|
||||
|
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user