mirror of
https://github.com/flarum/core.git
synced 2025-08-20 07:11:31 +02:00
Compare commits
6 Commits
as/export-
...
ck/ux-scru
Author | SHA1 | Date | |
---|---|---|---|
|
9f6414d1c7 | ||
|
125297278b | ||
|
727440e88b | ||
|
4464ab156f | ||
|
b965a82bb1 | ||
|
7b6ab61508 |
@@ -15,5 +15,5 @@ indent_size = 2
|
||||
[*.{diff,md}]
|
||||
trim_trailing_whitespace = false
|
||||
|
||||
[*.{php,xml,json}]
|
||||
[*.{php,xml}]
|
||||
indent_size = 4
|
||||
|
1
.gitattributes
vendored
1
.gitattributes
vendored
@@ -11,6 +11,5 @@ phpunit.xml export-ignore
|
||||
tests export-ignore
|
||||
|
||||
js/dist/* -diff
|
||||
js/dist/* linguist-generated
|
||||
|
||||
* text=auto eol=lf
|
||||
|
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 }}
|
15
.github/workflows/test.yml
vendored
15
.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', '8.0']
|
||||
service: ['mysql:5.7', mariadb]
|
||||
prefix: ['', flarum_]
|
||||
|
||||
@@ -21,6 +21,12 @@ jobs:
|
||||
prefixStr: (prefix)
|
||||
|
||||
exclude:
|
||||
- php: 7.2
|
||||
service: 'mysql:5.7'
|
||||
prefix: flarum_
|
||||
- php: 7.2
|
||||
service: mariadb
|
||||
prefix: flarum_
|
||||
- php: 7.3
|
||||
service: 'mysql:5.7'
|
||||
prefix: flarum_
|
||||
@@ -43,11 +49,10 @@ 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
|
||||
uses: shivammathur/setup-php@v2
|
||||
with:
|
||||
php-version: ${{ matrix.php }}
|
||||
coverage: xdebug
|
||||
@@ -75,5 +80,3 @@ jobs:
|
||||
|
||||
- name: Run Composer tests
|
||||
run: composer test
|
||||
env:
|
||||
COMPOSER_PROCESS_TIMEOUT: 600
|
||||
|
1
.gitignore
vendored
1
.gitignore
vendored
@@ -4,7 +4,6 @@ composer.phar
|
||||
node_modules
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
tests/.phpunit.result.cache
|
||||
/tests/integration/tmp
|
||||
.vagrant
|
||||
.idea/*
|
||||
|
@@ -12,3 +12,7 @@ disabled:
|
||||
- phpdoc_order
|
||||
- phpdoc_separation
|
||||
- phpdoc_types
|
||||
|
||||
finder:
|
||||
exclude:
|
||||
- "stubs"
|
||||
|
93
CHANGELOG.md
93
CHANGELOG.md
@@ -1,96 +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
|
||||
@@ -301,7 +210,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,10 +1,7 @@
|
||||
{
|
||||
"name": "flarum/core",
|
||||
"description": "Delightfully simple forum software.",
|
||||
"keywords": [
|
||||
"forum",
|
||||
"discussion"
|
||||
],
|
||||
"keywords": ["forum", "discussion"],
|
||||
"homepage": "https://flarum.org/",
|
||||
"license": "MIT",
|
||||
"authors": [
|
||||
@@ -20,29 +17,27 @@
|
||||
"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",
|
||||
"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",
|
||||
"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",
|
||||
@@ -59,18 +54,18 @@
|
||||
"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/config": "^4.3.4",
|
||||
"symfony/console": "^4.3.4",
|
||||
"symfony/event-dispatcher": "^4.3.4",
|
||||
"symfony/mime": "^5.2.0",
|
||||
"symfony/polyfill-intl-messageformatter": "^1.22.0",
|
||||
"symfony/translation": "^5.1.5",
|
||||
"symfony/yaml": "^5.2.2",
|
||||
"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": "dev-main#81e25f034e2b6dceaea753ad7579b5c61d641993"
|
||||
"mockery/mockery": "^1.3.3",
|
||||
"phpunit/phpunit": "^8.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
10676
js/package-lock.json
generated
10676
js/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -2,41 +2,32 @@
|
||||
"private": true,
|
||||
"name": "@flarum/core",
|
||||
"dependencies": {
|
||||
"@askvortsov/rich-icu-message-formatter": "^0.1.0",
|
||||
"@ultraq/icu-message-formatter": "^0.10.0",
|
||||
"@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": "^2.0.0",
|
||||
"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.14",
|
||||
"mithril": "^2.0.4",
|
||||
"punycode": "^2.1.1",
|
||||
"spin.js": "^3.1.0",
|
||||
"textarea-caret": "^3.1.0",
|
||||
"throttle-debounce": "^3.0.1"
|
||||
"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/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": "^1.0.0",
|
||||
"husky": "^4.3.8",
|
||||
"prettier": "^2.2.1",
|
||||
"webpack": "^5.0.0",
|
||||
"webpack-bundle-analyzer": "^4.4.1",
|
||||
"webpack-cli": "^4.0.0",
|
||||
"webpack-merge": "^4.0.0"
|
||||
"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,8 +0,0 @@
|
||||
import Admin from './AdminApplication';
|
||||
|
||||
const app = new Admin();
|
||||
|
||||
// @ts-ignore
|
||||
window.app = app;
|
||||
|
||||
export default app;
|
@@ -24,7 +24,6 @@ import UploadImageButton from './components/UploadImageButton';
|
||||
import LoadingModal from './components/LoadingModal';
|
||||
import DashboardPage from './components/DashboardPage';
|
||||
import BasicsPage from './components/BasicsPage';
|
||||
import UserListPage from './components/UserListPage';
|
||||
import EditCustomHeaderModal from './components/EditCustomHeaderModal';
|
||||
import PermissionsPage from './components/PermissionsPage';
|
||||
import PermissionDropdown from './components/PermissionDropdown';
|
||||
@@ -60,7 +59,6 @@ export default Object.assign(compat, {
|
||||
'components/LoadingModal': LoadingModal,
|
||||
'components/DashboardPage': DashboardPage,
|
||||
'components/BasicsPage': BasicsPage,
|
||||
'components/UserListPage': UserListPage,
|
||||
'components/EditCustomHeaderModal': EditCustomHeaderModal,
|
||||
'components/PermissionsPage': PermissionsPage,
|
||||
'components/PermissionDropdown': PermissionDropdown,
|
||||
|
@@ -94,13 +94,6 @@ export default class AdminNav extends Component {
|
||||
</LinkButton>
|
||||
);
|
||||
|
||||
items.add(
|
||||
'userList',
|
||||
<LinkButton href={app.route('users')} icon="fas fa-users" title={app.translator.trans('core.admin.nav.userlist_title')}>
|
||||
{app.translator.trans('core.admin.nav.userlist_button')}
|
||||
</LinkButton>
|
||||
);
|
||||
|
||||
items.add(
|
||||
'search',
|
||||
<div className="Search-input">
|
||||
@@ -133,17 +126,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}`,
|
||||
<ExtensionLinkButton
|
||||
href={app.route('extension', { id: extension.id })}
|
||||
extensionId={extension.id}
|
||||
className="ExtensionNavButton"
|
||||
title={description}
|
||||
title={extension.description}
|
||||
>
|
||||
{title}
|
||||
</ExtensionLinkButton>,
|
||||
|
@@ -98,39 +98,35 @@ export default class AdminPage extends Page {
|
||||
return entry.call(this);
|
||||
}
|
||||
|
||||
const { setting, help, ...componentAttrs } = entry;
|
||||
const setting = entry.setting;
|
||||
const help = entry.help;
|
||||
delete entry.help;
|
||||
|
||||
const value = this.setting([setting])();
|
||||
if (['bool', 'checkbox', 'switch', 'boolean'].includes(componentAttrs.type)) {
|
||||
if (['bool', 'checkbox', 'switch', 'boolean'].includes(entry.type)) {
|
||||
return (
|
||||
<div className="Form-group">
|
||||
<Switch state={!!value && value !== '0'} onchange={this.settings[setting]} {...componentAttrs}>
|
||||
{componentAttrs.label}
|
||||
<Switch state={!!value && value !== '0'} onchange={this.settings[setting]} {...entry}>
|
||||
{entry.label}
|
||||
</Switch>
|
||||
<div className="helpText">{help}</div>
|
||||
</div>
|
||||
);
|
||||
} else if (['select', 'dropdown', 'selectdropdown'].includes(componentAttrs.type)) {
|
||||
} else if (['select', 'dropdown', 'selectdropdown'].includes(entry.type)) {
|
||||
return (
|
||||
<div className="Form-group">
|
||||
<label>{componentAttrs.label}</label>
|
||||
<label>{entry.label}</label>
|
||||
<div className="helpText">{help}</div>
|
||||
<Select
|
||||
value={value || componentAttrs.default}
|
||||
options={componentAttrs.options}
|
||||
buttonClassName="Button"
|
||||
onchange={this.settings[setting]}
|
||||
{...componentAttrs}
|
||||
/>
|
||||
<Select value={value || entry.default} options={entry.options} buttonClassName="Button" onchange={this.settings[setting]} {...entry} />
|
||||
</div>
|
||||
);
|
||||
} else {
|
||||
componentAttrs.className = classList(['FormControl', componentAttrs.className]);
|
||||
entry.className = classList(['FormControl', entry.className]);
|
||||
return (
|
||||
<div className="Form-group">
|
||||
{componentAttrs.label ? <label>{componentAttrs.label}</label> : ''}
|
||||
{entry.label ? <label>{entry.label}</label> : ''}
|
||||
<div className="helpText">{help}</div>
|
||||
<input type={componentAttrs.type} bidi={this.setting(setting)} {...componentAttrs} />
|
||||
<input type={entry.type} bidi={this.setting(setting)} {...entry} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
@@ -182,7 +182,7 @@ export default class PermissionGrid extends Component {
|
||||
|
||||
return SettingDropdown.component({
|
||||
defaultLabel: minutes
|
||||
? app.translator.trans('core.admin.permissions_controls.allow_some_minutes_button', { count: minutes })
|
||||
? app.translator.transChoice('core.admin.permissions_controls.allow_some_minutes_button', minutes, { count: minutes })
|
||||
: app.translator.trans('core.admin.permissions_controls.allow_indefinitely_button'),
|
||||
key: 'allow_renaming',
|
||||
options: [
|
||||
@@ -224,7 +224,7 @@ export default class PermissionGrid extends Component {
|
||||
|
||||
return SettingDropdown.component({
|
||||
defaultLabel: minutes
|
||||
? app.translator.trans('core.admin.permissions_controls.allow_some_minutes_button', { count: minutes })
|
||||
? app.translator.transChoice('core.admin.permissions_controls.allow_some_minutes_button', minutes, { count: minutes })
|
||||
: app.translator.trans('core.admin.permissions_controls.allow_indefinitely_button'),
|
||||
key: 'allow_post_editing',
|
||||
options: [
|
||||
|
@@ -1,384 +0,0 @@
|
||||
import EditUserModal from '../../common/components/EditUserModal';
|
||||
import LoadingIndicator from '../../common/components/LoadingIndicator';
|
||||
import Button from '../../common/components/Button';
|
||||
|
||||
import icon from '../../common/helpers/icon';
|
||||
import listItems from '../../common/helpers/listItems';
|
||||
|
||||
import type User from '../../common/models/User';
|
||||
|
||||
import ItemList from '../../common/utils/ItemList';
|
||||
import classList from '../../common/utils/classList';
|
||||
import extractText from '../../common/utils/extractText';
|
||||
|
||||
import AdminPage from './AdminPage';
|
||||
|
||||
type ColumnData = {
|
||||
/**
|
||||
* Column title
|
||||
*/
|
||||
name: String;
|
||||
/**
|
||||
* Component(s) to show for this column.
|
||||
*/
|
||||
content: (user: User) => JSX.Element;
|
||||
};
|
||||
|
||||
type ApiPayload = {
|
||||
data: Record<string, unknown>[];
|
||||
included: Record<string, unknown>[];
|
||||
links: {
|
||||
first: string;
|
||||
next?: string;
|
||||
};
|
||||
};
|
||||
|
||||
type UsersApiResponse = User[] & { payload: ApiPayload };
|
||||
|
||||
/**
|
||||
* Admin page which displays a paginated list of all users on the forum.
|
||||
*/
|
||||
export default class UserListPage extends AdminPage {
|
||||
/**
|
||||
* Number of users to load per page.
|
||||
*/
|
||||
private numPerPage: number = 50;
|
||||
|
||||
/**
|
||||
* Current page number. Zero-indexed.
|
||||
*/
|
||||
private pageNumber: number = 0;
|
||||
|
||||
/**
|
||||
* Total number of forum users.
|
||||
*
|
||||
* Fetched from the active `AdminApplication` (`app`), with
|
||||
* data provided by `AdminPayload.php`, or `flarum/statistics`
|
||||
* if installed.
|
||||
*/
|
||||
readonly userCount: number = app.data.modelStatistics.users.total;
|
||||
|
||||
/**
|
||||
* Get total number of user pages.
|
||||
*/
|
||||
private getTotalPageCount(): number {
|
||||
if (this.userCount === -1) return 0;
|
||||
|
||||
return Math.ceil(this.userCount / this.numPerPage);
|
||||
}
|
||||
|
||||
/**
|
||||
* This page's array of users.
|
||||
*
|
||||
* `undefined` when page loads as no data has been fetched.
|
||||
*/
|
||||
private pageData: User[] | undefined = undefined;
|
||||
|
||||
/**
|
||||
* Are there more users available?
|
||||
*/
|
||||
private moreData: boolean = false;
|
||||
|
||||
private isLoadingPage: boolean = false;
|
||||
|
||||
/**
|
||||
* Component to render.
|
||||
*/
|
||||
content() {
|
||||
if (typeof this.pageData === 'undefined') {
|
||||
this.loadPage(0);
|
||||
|
||||
return [
|
||||
<section class="UserListPage-grid UserListPage-grid--loading">
|
||||
<LoadingIndicator containerClassName="LoadingIndicator--block" size="large" />
|
||||
</section>,
|
||||
];
|
||||
}
|
||||
|
||||
const columns: (ColumnData & { itemName: string })[] = this.columns().toArray();
|
||||
|
||||
return [
|
||||
<p class="UserListPage-totalUsers">{app.translator.trans('core.admin.users.total_users', { count: this.userCount })}</p>,
|
||||
<section
|
||||
class={classList(['UserListPage-grid', this.isLoadingPage ? 'UserListPage-grid--loadingPage' : 'UserListPage-grid--loaded'])}
|
||||
style={{ '--columns': columns.length }}
|
||||
role="table"
|
||||
// +1 to account for header
|
||||
aria-rowcount={this.pageData.length + 1}
|
||||
aria-colcount={columns.length}
|
||||
aria-live="polite"
|
||||
aria-busy={this.isLoadingPage ? 'true' : 'false'}
|
||||
>
|
||||
{/* Render columns */}
|
||||
{columns.map((column, colIndex) => (
|
||||
<div class="UserListPage-grid-header" role="columnheader" aria-colindex={colIndex + 1} aria-rowindex={1}>
|
||||
{column.name}
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* Render user data */}
|
||||
{this.pageData.map((user, rowIndex) =>
|
||||
columns.map((col, colIndex) => {
|
||||
const columnContent = col.content && col.content(user);
|
||||
|
||||
return (
|
||||
<div
|
||||
class={classList(['UserListPage-grid-rowItem', rowIndex % 2 > 0 && 'UserListPage-grid-rowItem--shaded'])}
|
||||
data-user-id={user.id()}
|
||||
data-column-name={col.itemName}
|
||||
aria-colindex={colIndex + 1}
|
||||
// +2 to account for 0-based index, and for the header row
|
||||
aria-rowindex={rowIndex + 2}
|
||||
role="cell"
|
||||
>
|
||||
{columnContent || app.translator.trans('core.admin.users.grid.invalid_column_content')}
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
|
||||
{/* Loading spinner that shows when a new page is being loaded */}
|
||||
{this.isLoadingPage && <LoadingIndicator size="large" />}
|
||||
</section>,
|
||||
<nav class="UserListPage-gridPagination">
|
||||
<Button
|
||||
disabled={this.pageNumber === 0}
|
||||
title={app.translator.trans('core.admin.users.pagination.back_button')}
|
||||
onclick={this.previousPage.bind(this)}
|
||||
icon="fas fa-chevron-left"
|
||||
className="Button Button--icon UserListPage-backBtn"
|
||||
/>
|
||||
<span class="UserListPage-pageNumber">
|
||||
{app.translator.trans('core.admin.users.pagination.page_counter', {
|
||||
current: this.pageNumber + 1,
|
||||
total: this.getTotalPageCount(),
|
||||
})}
|
||||
</span>
|
||||
<Button
|
||||
disabled={!this.moreData}
|
||||
title={app.translator.trans('core.admin.users.pagination.next_button')}
|
||||
onclick={this.nextPage.bind(this)}
|
||||
icon="fas fa-chevron-right"
|
||||
className="Button Button--icon UserListPage-nextBtn"
|
||||
/>
|
||||
</nav>,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Build an item list of columns to show for each user.
|
||||
*
|
||||
* Each column in the list should be an object with keys `name` and `content`.
|
||||
*
|
||||
* `name` is a string that will be used as the column name.
|
||||
* `content` is a function with the User model passed as the first and only argument.
|
||||
*
|
||||
* See `UserListPage.tsx` for examples.
|
||||
*/
|
||||
columns(): ItemList {
|
||||
const columns = new ItemList();
|
||||
|
||||
columns.add(
|
||||
'id',
|
||||
{
|
||||
name: app.translator.trans('core.admin.users.grid.columns.user_id.title'),
|
||||
content: (user: User) => user.id(),
|
||||
},
|
||||
100
|
||||
);
|
||||
|
||||
columns.add(
|
||||
'username',
|
||||
{
|
||||
name: app.translator.trans('core.admin.users.grid.columns.username.title'),
|
||||
content: (user: User) => {
|
||||
const profileUrl = `${app.forum.attribute('baseUrl')}/u/${user.slug()}`;
|
||||
|
||||
return (
|
||||
<a
|
||||
target="_blank"
|
||||
href={profileUrl}
|
||||
title={extractText(app.translator.trans('core.admin.users.grid.columns.username.profile_link_tooltip', { username: user.username() }))}
|
||||
>
|
||||
{user.username()}
|
||||
</a>
|
||||
);
|
||||
},
|
||||
},
|
||||
90
|
||||
);
|
||||
|
||||
columns.add(
|
||||
'joinDate',
|
||||
{
|
||||
name: app.translator.trans('core.admin.users.grid.columns.join_time.title'),
|
||||
content: (user: User) => (
|
||||
<span class="UserList-joinDate" title={user.joinTime()}>
|
||||
{dayjs(user.joinTime()).format('LLL')}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
80
|
||||
);
|
||||
|
||||
columns.add(
|
||||
'groupBadges',
|
||||
{
|
||||
name: app.translator.trans('core.admin.users.grid.columns.group_badges.title'),
|
||||
content: (user: User) => {
|
||||
const badges = user.badges().toArray();
|
||||
|
||||
if (badges.length) {
|
||||
return <ul className="DiscussionHero-badges badges">{listItems(badges)}</ul>;
|
||||
} else {
|
||||
return app.translator.trans('core.admin.users.grid.columns.group_badges.no_badges');
|
||||
}
|
||||
},
|
||||
},
|
||||
70
|
||||
);
|
||||
|
||||
columns.add(
|
||||
'emailAddress',
|
||||
{
|
||||
name: app.translator.trans('core.admin.users.grid.columns.email.title'),
|
||||
content: (user: User) => {
|
||||
function setEmailVisibility(visible: boolean) {
|
||||
// Get needed jQuery element refs
|
||||
const emailContainer = $(`[data-column-name=emailAddress][data-user-id=${user.id()}] .UserList-email`);
|
||||
const emailAddress = emailContainer.find('.UserList-emailAddress');
|
||||
const emailToggleButton = emailContainer.find('.UserList-emailIconBtn');
|
||||
const emailToggleButtonIcon = emailToggleButton.find('.icon');
|
||||
|
||||
emailToggleButton.attr(
|
||||
'title',
|
||||
extractText(
|
||||
visible
|
||||
? app.translator.trans('core.admin.users.grid.columns.email.visibility_hide')
|
||||
: app.translator.trans('core.admin.users.grid.columns.email.visibility_show')
|
||||
)
|
||||
);
|
||||
|
||||
emailAddress.attr('aria-hidden', visible ? 'false' : 'true');
|
||||
|
||||
if (visible) {
|
||||
emailToggleButtonIcon.addClass('fa-eye');
|
||||
emailToggleButtonIcon.removeClass('fa-eye-slash');
|
||||
} else {
|
||||
emailToggleButtonIcon.removeClass('fa-eye');
|
||||
emailToggleButtonIcon.addClass('fa-eye-slash');
|
||||
}
|
||||
|
||||
// Need the string interpolation to prevent TS error.
|
||||
emailContainer.attr('data-email-shown', `${visible}`);
|
||||
}
|
||||
|
||||
function toggleEmailVisibility() {
|
||||
const emailContainer = $(`[data-column-name=emailAddress][data-user-id=${user.id()}] .UserList-email`);
|
||||
const emailShown = emailContainer.attr('data-email-shown') === 'true';
|
||||
|
||||
if (emailShown) {
|
||||
setEmailVisibility(false);
|
||||
} else {
|
||||
setEmailVisibility(true);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div class="UserList-email" key={user.id()} data-email-shown="false">
|
||||
<span class="UserList-emailAddress" aria-hidden onclick={() => setEmailVisibility(true)}>
|
||||
{user.email()}
|
||||
</span>
|
||||
<button
|
||||
onclick={toggleEmailVisibility}
|
||||
class="Button Button--text UserList-emailIconBtn"
|
||||
title={app.translator.trans('core.admin.users.grid.columns.email.visibility_show')}
|
||||
>
|
||||
{icon('far fa-eye-slash fa-fw', { className: 'icon' })}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
70
|
||||
);
|
||||
|
||||
columns.add(
|
||||
'editUser',
|
||||
{
|
||||
name: app.translator.trans('core.admin.users.grid.columns.edit_user.title'),
|
||||
content: (user: User) => (
|
||||
<Button
|
||||
className="Button UserList-editModalBtn"
|
||||
title={app.translator.trans('core.admin.users.grid.columns.edit_user.tooltip', { username: user.username() })}
|
||||
onclick={() => app.modal.show(EditUserModal, { user })}
|
||||
>
|
||||
{app.translator.trans('core.admin.users.grid.columns.edit_user.button')}
|
||||
</Button>
|
||||
),
|
||||
},
|
||||
-90
|
||||
);
|
||||
|
||||
return columns;
|
||||
}
|
||||
|
||||
headerInfo() {
|
||||
return {
|
||||
className: 'UserListPage',
|
||||
icon: 'fas fa-users',
|
||||
title: app.translator.trans('core.admin.users.title'),
|
||||
description: app.translator.trans('core.admin.users.description'),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Asynchronously fetch the next set of users to be rendered.
|
||||
*
|
||||
* Returns an array of Users, plus the raw API payload.
|
||||
*
|
||||
* Uses the `this.numPerPage` as the response limit, and automatically calculates the offset required from `pageNumber`.
|
||||
*
|
||||
* @param pageNumber The page number to load and display
|
||||
*/
|
||||
async loadPage(pageNumber: number) {
|
||||
if (pageNumber < 0) pageNumber = 0;
|
||||
|
||||
app.store
|
||||
.find('users', {
|
||||
page: {
|
||||
limit: this.numPerPage,
|
||||
offset: pageNumber * this.numPerPage,
|
||||
},
|
||||
})
|
||||
.then((apiData: UsersApiResponse) => {
|
||||
// Next link won't be present if there's no more data
|
||||
this.moreData = !!apiData.payload.links.next;
|
||||
|
||||
let data = apiData;
|
||||
|
||||
// @ts-ignore
|
||||
delete data.payload;
|
||||
|
||||
this.pageData = data;
|
||||
this.pageNumber = pageNumber;
|
||||
this.isLoadingPage = false;
|
||||
|
||||
m.redraw();
|
||||
})
|
||||
.catch((err: Error) => {
|
||||
console.error(err);
|
||||
this.pageData = [];
|
||||
});
|
||||
}
|
||||
|
||||
nextPage() {
|
||||
this.isLoadingPage = true;
|
||||
this.loadPage(this.pageNumber + 1);
|
||||
}
|
||||
|
||||
previousPage() {
|
||||
this.isLoadingPage = true;
|
||||
this.loadPage(this.pageNumber - 1);
|
||||
}
|
||||
}
|
@@ -1,4 +1,9 @@
|
||||
import app from './app';
|
||||
import AdminApplication from './AdminApplication';
|
||||
|
||||
const app = new AdminApplication();
|
||||
|
||||
// Backwards compatibility
|
||||
window.app = app;
|
||||
|
||||
export { app };
|
||||
|
||||
|
@@ -3,7 +3,6 @@ import BasicsPage from './components/BasicsPage';
|
||||
import PermissionsPage from './components/PermissionsPage';
|
||||
import AppearancePage from './components/AppearancePage';
|
||||
import MailPage from './components/MailPage';
|
||||
import UserListPage from './components/UserListPage';
|
||||
import ExtensionPage from './components/ExtensionPage';
|
||||
import ExtensionPageResolver from './resolvers/ExtensionPageResolver';
|
||||
|
||||
@@ -19,7 +18,6 @@ export default function (app) {
|
||||
permissions: { path: '/permissions', component: PermissionsPage },
|
||||
appearance: { path: '/appearance', component: AppearancePage },
|
||||
mail: { path: '/mail', component: MailPage },
|
||||
users: { path: '/users', component: UserListPage },
|
||||
extension: { path: '/extension/:id', component: ExtensionPage, resolverClass: ExtensionPageResolver },
|
||||
};
|
||||
}
|
||||
|
@@ -12,7 +12,6 @@ import mapRoutes from './utils/mapRoutes';
|
||||
import RequestError from './utils/RequestError';
|
||||
import ScrollListener from './utils/ScrollListener';
|
||||
import liveHumanTimes from './utils/liveHumanTimes';
|
||||
import patchMithril from './utils/patchMithril';
|
||||
import { extend } from './extend';
|
||||
|
||||
import Forum from './models/Forum';
|
||||
@@ -21,6 +20,7 @@ import Discussion from './models/Discussion';
|
||||
import Post from './models/Post';
|
||||
import Group from './models/Group';
|
||||
import Notification from './models/Notification';
|
||||
import { flattenDeep } from 'lodash-es';
|
||||
import PageState from './states/PageState';
|
||||
import ModalManagerState from './states/ModalManagerState';
|
||||
import AlertManagerState from './states/AlertManagerState';
|
||||
@@ -159,16 +159,12 @@ export default class Application {
|
||||
title = '';
|
||||
titleCount = 0;
|
||||
|
||||
initialRoute;
|
||||
|
||||
load(payload) {
|
||||
this.data = payload;
|
||||
this.translator.setLocale(payload.locale);
|
||||
this.translator.locale = payload.locale;
|
||||
}
|
||||
|
||||
boot() {
|
||||
patchMithril(window);
|
||||
|
||||
this.initializers.toArray().forEach((initializer) => initializer(this));
|
||||
|
||||
this.store.pushPayload({ data: this.data.resources });
|
||||
@@ -178,19 +174,13 @@ 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;
|
||||
}
|
||||
|
||||
// TODO: This entire system needs a do-over for v2
|
||||
bootExtensions(extensions) {
|
||||
Object.keys(extensions).forEach((name) => {
|
||||
const extension = extensions[name];
|
||||
|
||||
// If an extension doesn't define extenders, there's nothing more to do here.
|
||||
if (!extension.extend) return;
|
||||
|
||||
const extenders = extension.extend.flat(Infinity);
|
||||
const extenders = flattenDeep(extension.extend);
|
||||
|
||||
for (const extender of extenders) {
|
||||
extender.extend(this, { name, exports: extension });
|
||||
@@ -236,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;
|
||||
|
@@ -77,12 +77,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 +94,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);
|
||||
}
|
||||
|
@@ -1,60 +0,0 @@
|
||||
interface ExportRegistry {
|
||||
moduleExports: object;
|
||||
|
||||
onLoads: object;
|
||||
|
||||
/**
|
||||
* Add an instance to the registry.
|
||||
* This serves as the equivalent of `flarum.core.compat[id] = object`
|
||||
*/
|
||||
add(namespace: string, id: string, object: any): void;
|
||||
|
||||
/**
|
||||
* Add a function to run when object of id "id" is added (or overriden).
|
||||
* If such an object is already registered, the handler will be applied immediately.
|
||||
*/
|
||||
onLoad(namespace: string, id: string, handler: Function): void;
|
||||
|
||||
/**
|
||||
* Retrieve an object of type `id` from the registry.
|
||||
*/
|
||||
get(namespace: string, id: string): any;
|
||||
}
|
||||
|
||||
export default class FlarumRegistry implements ExportRegistry {
|
||||
moduleExports = new Map<string, any>();
|
||||
onLoads = new Map<string, Function[]>();
|
||||
|
||||
protected genKey(namespace: string, id: string): string {
|
||||
return `${namespace};${id}`;
|
||||
}
|
||||
|
||||
add(namespace: string, id: string, object: any) {
|
||||
const key = this.genKey(namespace, id);
|
||||
|
||||
const onLoads = this.onLoads.get(key);
|
||||
if (onLoads) {
|
||||
onLoads.reduce((acc, handler) => handler(acc), object);
|
||||
}
|
||||
|
||||
this.moduleExports.set(key, object);
|
||||
}
|
||||
|
||||
onLoad(namespace: string, id: string, handler: Function) {
|
||||
const key = this.genKey(namespace, id);
|
||||
|
||||
const loadedObject = this.moduleExports.get(key);
|
||||
if (loadedObject) {
|
||||
this.moduleExports[id] = handler(loadedObject);
|
||||
} else {
|
||||
const currOnLoads = this.onLoads.get(key);
|
||||
this.onLoads.set(key, [...(currOnLoads || []), handler]);
|
||||
}
|
||||
}
|
||||
|
||||
get(namespace: string, id: string): any {
|
||||
const key = this.genKey(namespace, id);
|
||||
|
||||
return this.moduleExports.get(key);
|
||||
}
|
||||
}
|
@@ -1,8 +1,13 @@
|
||||
import { RichMessageFormatter, mithrilRichHandler } from '@askvortsov/rich-icu-message-formatter';
|
||||
import { pluralTypeHandler, selectTypeHandler } from '@ultraq/icu-message-formatter';
|
||||
import username from './helpers/username';
|
||||
import extract from './utils/extract';
|
||||
|
||||
/**
|
||||
* Translator with the same API as Symfony's.
|
||||
*
|
||||
* Derived from https://github.com/willdurand/BazingaJsTranslationBundle
|
||||
* which is available under the MIT License.
|
||||
* Copyright (c) William Durand <william.durand1@gmail.com>
|
||||
*/
|
||||
export default class Translator {
|
||||
constructor() {
|
||||
/**
|
||||
@@ -13,53 +18,288 @@ export default class Translator {
|
||||
*/
|
||||
this.translations = {};
|
||||
|
||||
this.formatter = new RichMessageFormatter(null, this.formatterTypeHandlers(), mithrilRichHandler);
|
||||
}
|
||||
|
||||
formatterTypeHandlers() {
|
||||
return {
|
||||
plural: pluralTypeHandler,
|
||||
select: selectTypeHandler,
|
||||
};
|
||||
}
|
||||
|
||||
setLocale(locale) {
|
||||
this.formatter.locale = locale;
|
||||
this.locale = null;
|
||||
}
|
||||
|
||||
addTranslations(translations) {
|
||||
Object.assign(this.translations, translations);
|
||||
}
|
||||
|
||||
preprocessParameters(parameters) {
|
||||
// If we've been given a user model as one of the input parameters, then
|
||||
// we'll extract the username and use that for the translation. In the
|
||||
// future there should be a hook here to inspect the user and change the
|
||||
// translation key. This will allow a gender property to determine which
|
||||
// translation key is used.
|
||||
if ('user' in parameters) {
|
||||
const user = extract(parameters, 'user');
|
||||
|
||||
if (!parameters.username) parameters.username = username(user);
|
||||
}
|
||||
return parameters;
|
||||
}
|
||||
|
||||
trans(id, parameters) {
|
||||
const translation = this.translations[id];
|
||||
|
||||
if (translation) {
|
||||
parameters = this.preprocessParameters(parameters || {});
|
||||
return this.formatter.rich(translation, parameters);
|
||||
return this.apply(translation, parameters || {});
|
||||
}
|
||||
|
||||
return id;
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated, remove before stable
|
||||
*/
|
||||
transChoice(id, number, parameters) {
|
||||
return this.trans(id, parameters);
|
||||
let translation = this.translations[id];
|
||||
|
||||
if (translation) {
|
||||
number = parseInt(number, 10);
|
||||
|
||||
translation = this.pluralize(translation, number);
|
||||
|
||||
return this.apply(translation, parameters || {});
|
||||
}
|
||||
|
||||
return id;
|
||||
}
|
||||
|
||||
apply(translation, input) {
|
||||
// If we've been given a user model as one of the input parameters, then
|
||||
// we'll extract the username and use that for the translation. In the
|
||||
// future there should be a hook here to inspect the user and change the
|
||||
// translation key. This will allow a gender property to determine which
|
||||
// translation key is used.
|
||||
if ('user' in input) {
|
||||
const user = extract(input, 'user');
|
||||
|
||||
if (!input.username) input.username = username(user);
|
||||
}
|
||||
|
||||
translation = translation.split(new RegExp('({[a-z0-9_]+}|</?[a-z0-9_]+>)', 'gi'));
|
||||
|
||||
const hydrated = [];
|
||||
const open = [hydrated];
|
||||
|
||||
translation.forEach((part) => {
|
||||
const match = part.match(new RegExp('{([a-z0-9_]+)}|<(/?)([a-z0-9_]+)>', 'i'));
|
||||
|
||||
if (match) {
|
||||
// Either an opening or closing tag.
|
||||
if (match[1]) {
|
||||
open[0].push(input[match[1]]);
|
||||
} else if (match[3]) {
|
||||
if (match[2]) {
|
||||
// Closing tag. We start by removing all raw children (generally in the form of strings) from the temporary
|
||||
// holding array, then run them through m.fragment to convert them to vnodes. Usually this will just give us a
|
||||
// text vnode, but using m.fragment as opposed to an explicit conversion should be more flexible. This is necessary because
|
||||
// otherwise, our generated vnode will have raw strings as its children, and mithril expects vnodes.
|
||||
// Finally, we add the now-processed vnodes back onto the holding array (which is the same object in memory as the
|
||||
// children array of the vnode we are currently processing), and remove the reference to the holding array so that
|
||||
// further text will be added to the full set of returned elements.
|
||||
const rawChildren = open[0].splice(0, open[0].length);
|
||||
open[0].push(...m.fragment(rawChildren).children);
|
||||
open.shift();
|
||||
} else {
|
||||
// If a vnode with a matching tag was provided in the translator input, we use that. Otherwise, we create a new vnode
|
||||
// with this tag, and an empty children array (since we're expecting to insert children, as that's the point of having this in translator)
|
||||
let tag = input[match[3]] || { tag: match[3], children: [] };
|
||||
open[0].push(tag);
|
||||
// Insert the tag's children array as the first element of open, so that text in between the opening
|
||||
// and closing tags will be added to the tag's children, not to the full set of returned elements.
|
||||
open.unshift(tag.children || tag);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Not an html tag, we add it to open[0], which is either the full set of returned elements (vnodes and text),
|
||||
// or if an html tag is currently being processed, the children attribute of that html tag's vnode.
|
||||
open[0].push(part);
|
||||
}
|
||||
});
|
||||
|
||||
return hydrated.filter((part) => part);
|
||||
}
|
||||
|
||||
pluralize(translation, number) {
|
||||
const sPluralRegex = new RegExp(/^\w+\: +(.+)$/),
|
||||
cPluralRegex = new RegExp(/^\s*((\{\s*(\-?\d+[\s*,\s*\-?\d+]*)\s*\})|([\[\]])\s*(-Inf|\-?\d+)\s*,\s*(\+?Inf|\-?\d+)\s*([\[\]]))\s?(.+?)$/),
|
||||
iPluralRegex = new RegExp(/^\s*(\{\s*(\-?\d+[\s*,\s*\-?\d+]*)\s*\})|([\[\]])\s*(-Inf|\-?\d+)\s*,\s*(\+?Inf|\-?\d+)\s*([\[\]])/),
|
||||
standardRules = [],
|
||||
explicitRules = [];
|
||||
|
||||
translation.split('|').forEach((part) => {
|
||||
if (cPluralRegex.test(part)) {
|
||||
const matches = part.match(cPluralRegex);
|
||||
explicitRules[matches[0]] = matches[matches.length - 1];
|
||||
} else if (sPluralRegex.test(part)) {
|
||||
const matches = part.match(sPluralRegex);
|
||||
standardRules.push(matches[1]);
|
||||
} else {
|
||||
standardRules.push(part);
|
||||
}
|
||||
});
|
||||
|
||||
explicitRules.forEach((rule, e) => {
|
||||
if (iPluralRegex.test(e)) {
|
||||
const matches = e.match(iPluralRegex);
|
||||
|
||||
if (matches[1]) {
|
||||
const ns = matches[2].split(',');
|
||||
|
||||
for (let n in ns) {
|
||||
if (number == ns[n]) {
|
||||
return explicitRules[e];
|
||||
}
|
||||
}
|
||||
} else {
|
||||
var leftNumber = this.convertNumber(matches[4]);
|
||||
var rightNumber = this.convertNumber(matches[5]);
|
||||
|
||||
if (
|
||||
('[' === matches[3] ? number >= leftNumber : number > leftNumber) &&
|
||||
(']' === matches[6] ? number <= rightNumber : number < rightNumber)
|
||||
) {
|
||||
return explicitRules[e];
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return standardRules[this.pluralPosition(number, this.locale)] || standardRules[0] || undefined;
|
||||
}
|
||||
|
||||
convertNumber(number) {
|
||||
if ('-Inf' === number) {
|
||||
return Number.NEGATIVE_INFINITY;
|
||||
} else if ('+Inf' === number || 'Inf' === number) {
|
||||
return Number.POSITIVE_INFINITY;
|
||||
}
|
||||
|
||||
return parseInt(number, 10);
|
||||
}
|
||||
|
||||
pluralPosition(number, locale) {
|
||||
if ('pt_BR' === locale) {
|
||||
locale = 'xbr';
|
||||
}
|
||||
|
||||
if (locale.length > 3) {
|
||||
locale = locale.split('_')[0];
|
||||
}
|
||||
|
||||
switch (locale) {
|
||||
case 'bo':
|
||||
case 'dz':
|
||||
case 'id':
|
||||
case 'ja':
|
||||
case 'jv':
|
||||
case 'ka':
|
||||
case 'km':
|
||||
case 'kn':
|
||||
case 'ko':
|
||||
case 'ms':
|
||||
case 'th':
|
||||
case 'vi':
|
||||
case 'zh':
|
||||
return 0;
|
||||
|
||||
case 'af':
|
||||
case 'az':
|
||||
case 'bn':
|
||||
case 'bg':
|
||||
case 'ca':
|
||||
case 'da':
|
||||
case 'de':
|
||||
case 'el':
|
||||
case 'en':
|
||||
case 'eo':
|
||||
case 'es':
|
||||
case 'et':
|
||||
case 'eu':
|
||||
case 'fa':
|
||||
case 'fi':
|
||||
case 'fo':
|
||||
case 'fur':
|
||||
case 'fy':
|
||||
case 'gl':
|
||||
case 'gu':
|
||||
case 'ha':
|
||||
case 'he':
|
||||
case 'hu':
|
||||
case 'is':
|
||||
case 'it':
|
||||
case 'ku':
|
||||
case 'lb':
|
||||
case 'ml':
|
||||
case 'mn':
|
||||
case 'mr':
|
||||
case 'nah':
|
||||
case 'nb':
|
||||
case 'ne':
|
||||
case 'nl':
|
||||
case 'nn':
|
||||
case 'no':
|
||||
case 'om':
|
||||
case 'or':
|
||||
case 'pa':
|
||||
case 'pap':
|
||||
case 'ps':
|
||||
case 'pt':
|
||||
case 'so':
|
||||
case 'sq':
|
||||
case 'sv':
|
||||
case 'sw':
|
||||
case 'ta':
|
||||
case 'te':
|
||||
case 'tk':
|
||||
case 'tr':
|
||||
case 'ur':
|
||||
case 'zu':
|
||||
return number == 1 ? 0 : 1;
|
||||
|
||||
case 'am':
|
||||
case 'bh':
|
||||
case 'fil':
|
||||
case 'fr':
|
||||
case 'gun':
|
||||
case 'hi':
|
||||
case 'ln':
|
||||
case 'mg':
|
||||
case 'nso':
|
||||
case 'xbr':
|
||||
case 'ti':
|
||||
case 'wa':
|
||||
return number === 0 || number == 1 ? 0 : 1;
|
||||
|
||||
case 'be':
|
||||
case 'bs':
|
||||
case 'hr':
|
||||
case 'ru':
|
||||
case 'sr':
|
||||
case 'uk':
|
||||
return number % 10 == 1 && number % 100 != 11 ? 0 : number % 10 >= 2 && number % 10 <= 4 && (number % 100 < 10 || number % 100 >= 20) ? 1 : 2;
|
||||
|
||||
case 'cs':
|
||||
case 'sk':
|
||||
return number == 1 ? 0 : number >= 2 && number <= 4 ? 1 : 2;
|
||||
|
||||
case 'ga':
|
||||
return number == 1 ? 0 : number == 2 ? 1 : 2;
|
||||
|
||||
case 'lt':
|
||||
return number % 10 == 1 && number % 100 != 11 ? 0 : number % 10 >= 2 && (number % 100 < 10 || number % 100 >= 20) ? 1 : 2;
|
||||
|
||||
case 'sl':
|
||||
return number % 100 == 1 ? 0 : number % 100 == 2 ? 1 : number % 100 == 3 || number % 100 == 4 ? 2 : 3;
|
||||
|
||||
case 'mk':
|
||||
return number % 10 == 1 ? 0 : 1;
|
||||
|
||||
case 'mt':
|
||||
return number == 1 ? 0 : number === 0 || (number % 100 > 1 && number % 100 < 11) ? 1 : number % 100 > 10 && number % 100 < 20 ? 2 : 3;
|
||||
|
||||
case 'lv':
|
||||
return number === 0 ? 0 : number % 10 == 1 && number % 100 != 11 ? 1 : 2;
|
||||
|
||||
case 'pl':
|
||||
return number == 1 ? 0 : number % 10 >= 2 && number % 10 <= 4 && (number % 100 < 12 || number % 100 > 14) ? 1 : 2;
|
||||
|
||||
case 'cy':
|
||||
return number == 1 ? 0 : number == 2 ? 1 : number == 8 || number == 11 ? 2 : 3;
|
||||
|
||||
case 'ro':
|
||||
return number == 1 ? 0 : number === 0 || (number % 100 > 0 && number % 100 < 20) ? 1 : 2;
|
||||
|
||||
case 'ar':
|
||||
return number === 0 ? 0 : number == 1 ? 1 : number == 2 ? 2 : number >= 3 && number <= 10 ? 3 : number >= 11 && number <= 99 ? 4 : 5;
|
||||
|
||||
default:
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@@ -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';
|
||||
@@ -12,9 +11,7 @@ import Drawer from './utils/Drawer';
|
||||
import anchorScroll from './utils/anchorScroll';
|
||||
import RequestError from './utils/RequestError';
|
||||
import abbreviateNumber from './utils/abbreviateNumber';
|
||||
import escapeRegExp from './utils/escapeRegExp';
|
||||
import * as string from './utils/string';
|
||||
import * as ThrottleDebounce from './utils/throttleDebounce';
|
||||
import Stream from './utils/Stream';
|
||||
import SubtreeRetainer from './utils/SubtreeRetainer';
|
||||
import setRouteWithForcedRefresh from './utils/setRouteWithForcedRefresh';
|
||||
@@ -59,9 +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 EditUserModal from './components/EditUserModal';
|
||||
import Model from './Model';
|
||||
import Application from './Application';
|
||||
import fullTime from './helpers/fullTime';
|
||||
@@ -80,7 +74,6 @@ export default {
|
||||
extend: extend,
|
||||
Session: Session,
|
||||
Store: Store,
|
||||
'utils/BasicEditorDriver': BasicEditorDriver,
|
||||
'utils/evented': evented,
|
||||
'utils/liveHumanTimes': liveHumanTimes,
|
||||
'utils/ItemList': ItemList,
|
||||
@@ -93,7 +86,6 @@ export default {
|
||||
'utils/abbreviateNumber': abbreviateNumber,
|
||||
'utils/string': string,
|
||||
'utils/SubtreeRetainer': SubtreeRetainer,
|
||||
'utils/escapeRegExp': escapeRegExp,
|
||||
'utils/extract': extract,
|
||||
'utils/ScrollListener': ScrollListener,
|
||||
'utils/stringToColor': stringToColor,
|
||||
@@ -107,7 +99,6 @@ export default {
|
||||
'utils/formatNumber': formatNumber,
|
||||
'utils/mapRoutes': mapRoutes,
|
||||
'utils/withAttr': withAttr,
|
||||
'utils/throttleDebounce': ThrottleDebounce,
|
||||
'models/Notification': Notification,
|
||||
'models/User': User,
|
||||
'models/Post': Post,
|
||||
@@ -139,9 +130,6 @@ export default {
|
||||
'components/Button': Button,
|
||||
'components/Modal': Modal,
|
||||
'components/GroupBadge': GroupBadge,
|
||||
'components/TextEditor': TextEditor,
|
||||
'components/TextEditorButton': TextEditorButton,
|
||||
'components/EditUserModal': EditUserModal,
|
||||
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>
|
||||
);
|
||||
}
|
||||
}
|
@@ -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>,
|
||||
|
@@ -9,36 +9,27 @@
|
||||
* Care should be taken to extend the correct object – in most cases, a class'
|
||||
* prototype will be the desired target of extension, not the class itself.
|
||||
*
|
||||
* @example <caption>Example usage of extending one method.</caption>
|
||||
* @example
|
||||
* extend(Discussion.prototype, 'badges', function(badges) {
|
||||
* // do something with `badges`
|
||||
* });
|
||||
*
|
||||
* @example <caption>Example usage of extending multiple methods.</caption>
|
||||
* extend(IndexPage.prototype, ['oncreate', 'onupdate'], function(vnode) {
|
||||
* // something that needs to be run on creation and update
|
||||
* });
|
||||
*
|
||||
* @param {object} object The object that owns the method
|
||||
* @param {string|string[]} methods The name or names of the method(s) to extend
|
||||
* @param {Object} object The object that owns the method
|
||||
* @param {String} method The name of the method to extend
|
||||
* @param {function} callback A callback which mutates the method's output
|
||||
*/
|
||||
export function extend(object, methods, callback) {
|
||||
const allMethods = Array.isArray(methods) ? methods : [methods];
|
||||
export function extend(object, method, callback) {
|
||||
const original = object[method];
|
||||
|
||||
allMethods.forEach((method) => {
|
||||
const original = object[method];
|
||||
object[method] = function (...args) {
|
||||
const value = original ? original.apply(this, args) : undefined;
|
||||
|
||||
object[method] = function (...args) {
|
||||
const value = original ? original.apply(this, args) : undefined;
|
||||
callback.apply(this, [value].concat(args));
|
||||
|
||||
callback.apply(this, [value].concat(args));
|
||||
return value;
|
||||
};
|
||||
|
||||
return value;
|
||||
};
|
||||
|
||||
Object.assign(object[method], original);
|
||||
});
|
||||
Object.assign(object[method], original);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -46,38 +37,29 @@ export function extend(object, methods, callback) {
|
||||
* new function will be run every time the object's method is called.
|
||||
*
|
||||
* The replacement function accepts the original method as its first argument,
|
||||
* which is like a call to `super`. Any arguments passed to the original method
|
||||
* which is like a call to 'super'. Any arguments passed to the original method
|
||||
* are also passed to the replacement.
|
||||
*
|
||||
* Care should be taken to extend the correct object – in most cases, a class'
|
||||
* prototype will be the desired target of extension, not the class itself.
|
||||
*
|
||||
* @example <caption>Example usage of overriding one method.</caption>
|
||||
* @example
|
||||
* override(Discussion.prototype, 'badges', function(original) {
|
||||
* const badges = original();
|
||||
* // do something with badges
|
||||
* return badges;
|
||||
* });
|
||||
*
|
||||
* @example <caption>Example usage of overriding multiple methods.</caption>
|
||||
* extend(Discussion.prototype, ['oncreate', 'onupdate'], function(original, vnode) {
|
||||
* // something that needs to be run on creation and update
|
||||
* });
|
||||
*
|
||||
* @param {object} object The object that owns the method
|
||||
* @param {string|string[]} method The name or names of the method(s) to override
|
||||
* @param {Object} object The object that owns the method
|
||||
* @param {String} method The name of the method to override
|
||||
* @param {function} newMethod The method to replace it with
|
||||
*/
|
||||
export function override(object, methods, newMethod) {
|
||||
const allMethods = Array.isArray(methods) ? methods : [methods];
|
||||
export function override(object, method, newMethod) {
|
||||
const original = object[method];
|
||||
|
||||
allMethods.forEach((method) => {
|
||||
const original = object[method];
|
||||
object[method] = function (...args) {
|
||||
return newMethod.apply(this, [original.bind(this)].concat(args));
|
||||
};
|
||||
|
||||
object[method] = function (...args) {
|
||||
return newMethod.apply(this, [original.bind(this)].concat(args));
|
||||
};
|
||||
|
||||
Object.assign(object[method], original);
|
||||
});
|
||||
Object.assign(object[method], original);
|
||||
}
|
||||
|
@@ -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,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,6 @@
|
||||
// Expose jQuery, mithril and dayjs to the window browser object
|
||||
import 'expose-loader?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?dayjs!dayjs';
|
||||
import 'bootstrap/js/affix';
|
||||
import 'bootstrap/js/dropdown';
|
||||
import 'bootstrap/js/modal';
|
||||
@@ -16,12 +14,10 @@ import localizedFormat from 'dayjs/plugin/localizedFormat';
|
||||
dayjs.extend(relativeTime);
|
||||
dayjs.extend(localizedFormat);
|
||||
|
||||
import FlarumRegistry from './FlarumRegistry';
|
||||
import patchMithril from './utils/patchMithril';
|
||||
|
||||
window.flreg = new FlarumRegistry();
|
||||
patchMithril(window);
|
||||
|
||||
import * as Extend from './extend/index';
|
||||
|
||||
export { Extend };
|
||||
|
||||
import './utils/arrayFlatPolyfill';
|
||||
|
@@ -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,14 +0,0 @@
|
||||
// Based off of the polyfill on MDN
|
||||
// https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/flat#reduce_concat_isarray_recursivity
|
||||
//
|
||||
// Needed to provide support for Safari on iOS < 12
|
||||
|
||||
if (!Array.prototype['flat']) {
|
||||
Array.prototype['flat'] = function flat(this: any[], depth: number = 1): any[] {
|
||||
return depth > 0
|
||||
? Array.prototype.reduce.call(this, (acc, val): any[] => acc.concat(Array.isArray(val) ? flat.call(val, depth - 1) : val), [])
|
||||
: // If no depth is provided, or depth is 0, just return a copy of
|
||||
// the array. Spread is supported in all major browsers (iOS 8+)
|
||||
[...this];
|
||||
};
|
||||
}
|
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,10 +0,0 @@
|
||||
const specialChars = /[.*+?^${}()|[\]\\]/g;
|
||||
|
||||
/**
|
||||
* Escapes the `RegExp` special characters in `input`.
|
||||
*
|
||||
* @see https://developer.mozilla.org/en-US/docs/Web/JavaScript/Guide/Regular_Expressions#escaping
|
||||
*/
|
||||
export default function escapeRegExp(input: string): string {
|
||||
return input.replace(specialChars, '\\$&');
|
||||
}
|
@@ -2,7 +2,7 @@
|
||||
* The `evented` mixin provides methods allowing an object to trigger events,
|
||||
* running externally registered event handlers.
|
||||
*/
|
||||
const evented = {
|
||||
export default {
|
||||
/**
|
||||
* Arrays of registered event handlers, grouped by the event name.
|
||||
*
|
||||
@@ -79,5 +79,3 @@ const evented = {
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
export default evented;
|
||||
|
@@ -1,3 +0,0 @@
|
||||
// Re-exports `throttle-debounce` to be used in `compat.js`.
|
||||
|
||||
export { throttle, debounce } from 'throttle-debounce';
|
@@ -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 {
|
||||
/**
|
||||
@@ -139,12 +138,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;
|
@@ -36,6 +36,8 @@ import HeaderSecondary from './components/HeaderSecondary';
|
||||
import ComposerButton from './components/ComposerButton';
|
||||
import DiscussionList from './components/DiscussionList';
|
||||
import ReplyPlaceholder from './components/ReplyPlaceholder';
|
||||
import TextEditor from './components/TextEditor';
|
||||
import TextEditorButton from './components/TextEditorButton';
|
||||
import AvatarEditor from './components/AvatarEditor';
|
||||
import Post from './components/Post';
|
||||
import SettingsPage from './components/SettingsPage';
|
||||
@@ -51,6 +53,7 @@ import PostPreview from './components/PostPreview';
|
||||
import EventPost from './components/EventPost';
|
||||
import DiscussionHero from './components/DiscussionHero';
|
||||
import PostMeta from './components/PostMeta';
|
||||
import EditUserModal from './components/EditUserModal';
|
||||
import SearchSource from './components/SearchSource';
|
||||
import DiscussionRenamedPost from './components/DiscussionRenamedPost';
|
||||
import DiscussionComposer from './components/DiscussionComposer';
|
||||
@@ -69,12 +72,8 @@ import Search from './components/Search';
|
||||
import DiscussionListItem from './components/DiscussionListItem';
|
||||
import LoadingPost from './components/LoadingPost';
|
||||
import PostsUserPage from './components/PostsUserPage';
|
||||
/**
|
||||
* @deprecated
|
||||
*/
|
||||
import EditUserModal from '../common/components/EditUserModal';
|
||||
import DiscussionPageResolver from './resolvers/DiscussionPageResolver';
|
||||
import BasicEditorDriver from '../common/utils/BasicEditorDriver';
|
||||
import BasicEditorDriver from './utils/BasicEditorDriver';
|
||||
import routes from './routes';
|
||||
import ForumApplication from './ForumApplication';
|
||||
|
||||
@@ -88,6 +87,7 @@ export default Object.assign(compat, {
|
||||
'utils/UserControls': UserControls,
|
||||
'utils/Pane': Pane,
|
||||
'utils/BasicEditorDriver': BasicEditorDriver,
|
||||
'utils/SuperTextarea': BasicEditorDriver, // @deprecated beta 16, remove beta 17
|
||||
'states/ComposerState': ComposerState,
|
||||
'states/DiscussionListState': DiscussionListState,
|
||||
'states/GlobalSearchState': GlobalSearchState,
|
||||
@@ -116,6 +116,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,
|
||||
@@ -131,9 +133,6 @@ export default Object.assign(compat, {
|
||||
'components/EventPost': EventPost,
|
||||
'components/DiscussionHero': DiscussionHero,
|
||||
'components/PostMeta': PostMeta,
|
||||
/**
|
||||
* @deprecated Used for backwards compatibility now that the EditUserModal has moved to common. Remove in beta 17.
|
||||
*/
|
||||
'components/EditUserModal': EditUserModal,
|
||||
'components/SearchSource': SearchSource,
|
||||
'components/DiscussionRenamedPost': DiscussionRenamedPost,
|
||||
|
@@ -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>
|
||||
|
@@ -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', 0);
|
||||
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
|
||||
@@ -67,7 +66,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(
|
||||
{
|
||||
|
@@ -15,8 +15,8 @@ import slidable from '../utils/slidable';
|
||||
import extractText from '../../common/utils/extractText';
|
||||
import classList from '../../common/utils/classList';
|
||||
import DiscussionPage from './DiscussionPage';
|
||||
import escapeRegExp from '../../common/utils/escapeRegExp';
|
||||
|
||||
import { escapeRegExp } from 'lodash-es';
|
||||
/**
|
||||
* The `DiscussionListItem` component shows a single discussion in the
|
||||
* discussion list.
|
||||
@@ -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
|
||||
)
|
||||
|
@@ -73,25 +73,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 +189,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()
|
||||
)
|
||||
|
@@ -1,10 +1,10 @@
|
||||
import Modal from './Modal';
|
||||
import Button from './Button';
|
||||
import GroupBadge from './GroupBadge';
|
||||
import Group from '../models/Group';
|
||||
import extractText from '../utils/extractText';
|
||||
import ItemList from '../utils/ItemList';
|
||||
import Stream from '../utils/Stream';
|
||||
import Modal from '../../common/components/Modal';
|
||||
import Button from '../../common/components/Button';
|
||||
import GroupBadge from '../../common/components/GroupBadge';
|
||||
import Group from '../../common/models/Group';
|
||||
import extractText from '../../common/utils/extractText';
|
||||
import ItemList from '../../common/utils/ItemList';
|
||||
import Stream from '../../common/utils/Stream';
|
||||
|
||||
/**
|
||||
* The `EditUserModal` component displays a modal dialog with a login form.
|
||||
@@ -33,14 +33,14 @@ export default class EditUserModal extends Modal {
|
||||
}
|
||||
|
||||
title() {
|
||||
return app.translator.trans('core.lib.edit_user.title');
|
||||
return app.translator.trans('core.forum.edit_user.title');
|
||||
}
|
||||
|
||||
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.lib.edit_user.nothing_available')}
|
||||
{fields.length > 1 ? <div className="Form">{this.fields().toArray()}</div> : app.translator.trans('core.forum.edit_user.nothing_available')}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -52,10 +52,10 @@ export default class EditUserModal extends Modal {
|
||||
items.add(
|
||||
'username',
|
||||
<div className="Form-group">
|
||||
<label>{app.translator.trans('core.lib.edit_user.username_heading')}</label>
|
||||
<label>{app.translator.trans('core.forum.edit_user.username_heading')}</label>
|
||||
<input
|
||||
className="FormControl"
|
||||
placeholder={extractText(app.translator.trans('core.lib.edit_user.username_label'))}
|
||||
placeholder={extractText(app.translator.trans('core.forum.edit_user.username_label'))}
|
||||
bidi={this.username}
|
||||
disabled={this.nonAdminEditingAdmin()}
|
||||
/>
|
||||
@@ -67,11 +67,11 @@ export default class EditUserModal extends Modal {
|
||||
items.add(
|
||||
'email',
|
||||
<div className="Form-group">
|
||||
<label>{app.translator.trans('core.lib.edit_user.email_heading')}</label>
|
||||
<label>{app.translator.trans('core.forum.edit_user.email_heading')}</label>
|
||||
<div>
|
||||
<input
|
||||
className="FormControl"
|
||||
placeholder={extractText(app.translator.trans('core.lib.edit_user.email_label'))}
|
||||
placeholder={extractText(app.translator.trans('core.forum.edit_user.email_label'))}
|
||||
bidi={this.email}
|
||||
disabled={this.nonAdminEditingAdmin()}
|
||||
/>
|
||||
@@ -84,7 +84,7 @@ export default class EditUserModal extends Modal {
|
||||
loading: this.loading,
|
||||
onclick: this.activate.bind(this),
|
||||
},
|
||||
app.translator.trans('core.lib.edit_user.activate_button')
|
||||
app.translator.trans('core.forum.edit_user.activate_button')
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
@@ -97,7 +97,7 @@ export default class EditUserModal extends Modal {
|
||||
items.add(
|
||||
'password',
|
||||
<div className="Form-group">
|
||||
<label>{app.translator.trans('core.lib.edit_user.password_heading')}</label>
|
||||
<label>{app.translator.trans('core.forum.edit_user.password_heading')}</label>
|
||||
<div>
|
||||
<label className="checkbox">
|
||||
<input
|
||||
@@ -110,14 +110,14 @@ export default class EditUserModal extends Modal {
|
||||
}}
|
||||
disabled={this.nonAdminEditingAdmin()}
|
||||
/>
|
||||
{app.translator.trans('core.lib.edit_user.set_password_label')}
|
||||
{app.translator.trans('core.forum.edit_user.set_password_label')}
|
||||
</label>
|
||||
{this.setPassword() ? (
|
||||
<input
|
||||
className="FormControl"
|
||||
type="password"
|
||||
name="password"
|
||||
placeholder={extractText(app.translator.trans('core.lib.edit_user.password_label'))}
|
||||
placeholder={extractText(app.translator.trans('core.forum.edit_user.password_label'))}
|
||||
bidi={this.password}
|
||||
disabled={this.nonAdminEditingAdmin()}
|
||||
/>
|
||||
@@ -135,7 +135,7 @@ export default class EditUserModal extends Modal {
|
||||
items.add(
|
||||
'groups',
|
||||
<div className="Form-group EditUserModal-groups">
|
||||
<label>{app.translator.trans('core.lib.edit_user.groups_heading')}</label>
|
||||
<label>{app.translator.trans('core.forum.edit_user.groups_heading')}</label>
|
||||
<div>
|
||||
{Object.keys(this.groups)
|
||||
.map((id) => app.store.getById('groups', id))
|
||||
@@ -164,7 +164,7 @@ export default class EditUserModal extends Modal {
|
||||
type: 'submit',
|
||||
loading: this.loading,
|
||||
},
|
||||
app.translator.trans('core.lib.edit_user.submit_button')
|
||||
app.translator.trans('core.forum.edit_user.submit_button')
|
||||
)}
|
||||
</div>,
|
||||
-10
|
||||
@@ -237,8 +237,7 @@ export default class EditUserModal extends Modal {
|
||||
}
|
||||
|
||||
/**
|
||||
* @internal
|
||||
* @protected
|
||||
* @internal @protected
|
||||
*/
|
||||
userIsAdmin(user) {
|
||||
return user.groups().some((g) => g.id() === Group.ADMINISTRATOR_ID);
|
@@ -57,7 +57,7 @@ export default class EventPost extends Post {
|
||||
* @return {String|Object} The description to render in the DOM
|
||||
*/
|
||||
description(data) {
|
||||
return app.translator.trans(this.descriptionKey(), data);
|
||||
return app.translator.transChoice(this.descriptionKey(), data.count, data);
|
||||
}
|
||||
|
||||
/**
|
||||
|
@@ -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];
|
||||
|
@@ -4,7 +4,6 @@ import icon from '../../common/helpers/icon';
|
||||
import humanTime from '../../common/helpers/humanTime';
|
||||
import Button from '../../common/components/Button';
|
||||
import Link from '../../common/components/Link';
|
||||
import classList from '../../common/utils/classList';
|
||||
|
||||
/**
|
||||
* The `Notification` component abstract displays a single notification.
|
||||
@@ -23,31 +22,27 @@ export default class Notification extends Component {
|
||||
|
||||
return (
|
||||
<Link
|
||||
className={classList('Notification', `Notification--${notification.contentType()}`, [!notification.isRead() && 'unread'])}
|
||||
className={'Notification Notification--' + notification.contentType() + ' ' + (!notification.isRead() ? 'unread' : '')}
|
||||
href={href}
|
||||
external={href.includes('://')}
|
||||
onclick={this.markAsRead.bind(this)}
|
||||
>
|
||||
{avatar(notification.fromUser())}
|
||||
{icon(this.icon(), { className: 'Notification-icon' })}
|
||||
<span className="Notification-title">
|
||||
<span className="Notification-content">{this.content()}</span>
|
||||
<span className="Notification-title-spring" />
|
||||
{humanTime(notification.createdAt())}
|
||||
</span>
|
||||
{!notification.isRead() && (
|
||||
<Button
|
||||
className="Notification-action Button Button--link"
|
||||
icon="fas fa-check"
|
||||
title={app.translator.trans('core.forum.notifications.mark_as_read_tooltip')}
|
||||
onclick={(e) => {
|
||||
{!notification.isRead() &&
|
||||
Button.component({
|
||||
className: 'Notification-action Button Button--icon Button--link',
|
||||
icon: 'fas fa-check',
|
||||
title: app.translator.trans('core.forum.notifications.mark_as_read_tooltip'),
|
||||
onclick: (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
this.markAsRead();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
},
|
||||
})}
|
||||
{avatar(notification.fromUser())}
|
||||
{icon(this.icon(), { className: 'Notification-icon' })}
|
||||
<span className="Notification-content">{this.content()}</span>
|
||||
{humanTime(notification.createdAt())}
|
||||
<div className="Notification-excerpt">{this.excerpt()}</div>
|
||||
</Link>
|
||||
);
|
||||
|
@@ -17,16 +17,16 @@ export default class NotificationList extends Component {
|
||||
return (
|
||||
<div className="NotificationList">
|
||||
<div className="NotificationList-header">
|
||||
<h4 className="App-titleControl App-titleControl--text">{app.translator.trans('core.forum.notifications.title')}</h4>
|
||||
|
||||
<div className="App-primaryControl">
|
||||
<Button
|
||||
className="Button Button--link"
|
||||
icon="fas fa-check"
|
||||
title={app.translator.trans('core.forum.notifications.mark_all_as_read_tooltip')}
|
||||
onclick={state.markAllAsRead.bind(state)}
|
||||
/>
|
||||
{Button.component({
|
||||
className: 'Button Button--icon Button--link',
|
||||
icon: 'fas fa-check',
|
||||
title: app.translator.trans('core.forum.notifications.mark_all_as_read_tooltip'),
|
||||
onclick: state.markAllAsRead.bind(state),
|
||||
})}
|
||||
</div>
|
||||
|
||||
<h4 className="App-titleControl App-titleControl--text">{app.translator.trans('core.forum.notifications.title')}</h4>
|
||||
</div>
|
||||
|
||||
<div className="NotificationList-content">
|
||||
@@ -43,7 +43,7 @@ export default class NotificationList extends Component {
|
||||
// Get the discussion that this notification is related to. If it's not
|
||||
// directly related to a discussion, it may be related to a post or
|
||||
// other entity which is related to a discussion.
|
||||
let discussion = null;
|
||||
let discussion = false;
|
||||
if (subject instanceof Discussion) discussion = subject;
|
||||
else if (subject && subject.discussion) discussion = subject.discussion();
|
||||
|
||||
@@ -65,8 +65,8 @@ export default class NotificationList extends Component {
|
||||
<div className="NotificationGroup">
|
||||
{group.discussion ? (
|
||||
<Link className="NotificationGroup-header" href={app.route.discussion(group.discussion)}>
|
||||
{badges && badges.length && <ul className="NotificationGroup-badges badges">{listItems(badges)}</ul>}
|
||||
<span>{group.discussion.title()}</span>
|
||||
{badges && badges.length ? <ul className="NotificationGroup-badges badges">{listItems(badges)}</ul> : ''}
|
||||
{group.discussion.title()}
|
||||
</Link>
|
||||
) : (
|
||||
<div className="NotificationGroup-header">{app.forum.attribute('title')}</div>
|
||||
@@ -84,7 +84,7 @@ export default class NotificationList extends Component {
|
||||
})
|
||||
: ''}
|
||||
{state.isLoading() ? (
|
||||
<LoadingIndicator />
|
||||
<LoadingIndicator className="LoadingIndicator--block" />
|
||||
) : pages.length ? (
|
||||
''
|
||||
) : (
|
||||
|
@@ -1,6 +1,5 @@
|
||||
import Dropdown from '../../common/components/Dropdown';
|
||||
import icon from '../../common/helpers/icon';
|
||||
import classList from '../../common/utils/classList';
|
||||
import NotificationList from './NotificationList';
|
||||
|
||||
export default class NotificationsDropdown extends Dropdown {
|
||||
@@ -11,9 +10,6 @@ export default class NotificationsDropdown extends Dropdown {
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -23,7 +19,7 @@ export default class NotificationsDropdown extends Dropdown {
|
||||
|
||||
vdom.attrs.title = this.attrs.label;
|
||||
|
||||
vdom.attrs.className = classList(vdom.attrs.className, [newNotifications && 'new']);
|
||||
vdom.attrs.className += newNotifications ? ' new' : '';
|
||||
vdom.attrs.onclick = this.onclick.bind(this);
|
||||
|
||||
return vdom;
|
||||
@@ -34,15 +30,15 @@ export default class NotificationsDropdown extends Dropdown {
|
||||
|
||||
return [
|
||||
icon(this.attrs.icon, { className: 'Button-icon' }),
|
||||
unread !== 0 && <span className="NotificationsDropdown-unread">{unread}</span>,
|
||||
unread ? <span className="NotificationsDropdown-unread">{unread}</span> : '',
|
||||
<span className="Button-label">{this.attrs.label}</span>,
|
||||
];
|
||||
}
|
||||
|
||||
getMenu() {
|
||||
return (
|
||||
<div className={classList('Dropdown-menu', this.attrs.menuClassName)} onclick={this.menuClick.bind(this)}>
|
||||
{this.showing && NotificationList.component({ state: this.attrs.state })}
|
||||
<div className={'Dropdown-menu ' + this.attrs.menuClassName} onclick={this.menuClick.bind(this)}>
|
||||
{this.showing ? NotificationList.component({ state: this.attrs.state }) : ''}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
@@ -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>
|
||||
|
@@ -1,4 +1,5 @@
|
||||
import Component from '../../common/Component';
|
||||
import Button from '../../common/components/Button';
|
||||
import icon from '../../common/helpers/icon';
|
||||
import formatNumber from '../../common/utils/formatNumber';
|
||||
import ScrollListener from '../../common/utils/ScrollListener';
|
||||
@@ -19,6 +20,8 @@ export default class PostStreamScrubber extends Component {
|
||||
this.stream = this.attrs.stream;
|
||||
this.handlers = {};
|
||||
|
||||
this.pendingMoveIndex = null;
|
||||
|
||||
this.scrollListener = new ScrollListener(this.updateScrubberValues.bind(this, { fromScroll: true, forceHeightChange: true }));
|
||||
}
|
||||
|
||||
@@ -26,14 +29,22 @@ export default class PostStreamScrubber extends Component {
|
||||
const count = this.stream.count();
|
||||
|
||||
// Index is left blank for performance reasons, it is filled in in updateScubberValues
|
||||
const viewing = app.translator.trans('core.forum.post_scrubber.viewing_text', {
|
||||
count,
|
||||
const viewing = app.translator.transChoice('core.forum.post_scrubber.viewing_text', count, {
|
||||
index: <span className="Scrubber-index"></span>,
|
||||
formattedCount: <span className="Scrubber-count">{formatNumber(count)}</span>,
|
||||
count: <span className="Scrubber-count">{formatNumber(count)}</span>,
|
||||
});
|
||||
|
||||
const index = this.stream.index;
|
||||
const previousIndex = this.stream.previousIndex;
|
||||
|
||||
// We want to make sure the back button isn't crammed in.
|
||||
// If the previous post index is less than 5% from the last/first post,
|
||||
// or if the previous post index is less than 25% from the current post, we will
|
||||
// hide the button. Additionally, this hides the button on very short screens.
|
||||
const showBackButton = previousIndex > count / 20 && previousIndex < count - count / 20 && 100 * Math.abs((index - previousIndex) / count) > 25;
|
||||
|
||||
const unreadCount = this.stream.discussion.unreadCount();
|
||||
const unreadPercent = count ? Math.min(count - this.stream.index, unreadCount) / count : 0;
|
||||
const unreadPercent = count ? Math.min(count - index, unreadCount) / count : 0;
|
||||
|
||||
function styleUnread(vnode) {
|
||||
const $element = $(vnode.dom);
|
||||
@@ -66,6 +77,18 @@ export default class PostStreamScrubber extends Component {
|
||||
</a>
|
||||
|
||||
<div className="Scrubber-scrollbar">
|
||||
{showBackButton ? (
|
||||
<a
|
||||
style={'top: ' + this.percentPerPost().index * this.stream.previousIndex + '%'}
|
||||
className="Scrubber-back"
|
||||
onclick={this.returnToLastPosition.bind(this)}
|
||||
title={app.translator.trans('core.forum.post_scrubber.back_title')}
|
||||
>
|
||||
{icon('fas fa-chevron-left')}
|
||||
</a>
|
||||
) : (
|
||||
''
|
||||
)}
|
||||
<div className="Scrubber-before" />
|
||||
<div className="Scrubber-handle">
|
||||
<div className="Scrubber-bar" />
|
||||
@@ -90,6 +113,16 @@ export default class PostStreamScrubber extends Component {
|
||||
);
|
||||
}
|
||||
|
||||
returnToLastPosition(e) {
|
||||
// Don't fire the scrubber click event as well
|
||||
e.stopPropagation();
|
||||
|
||||
this.stream.goToIndex(Math.floor(this.stream.previousIndex));
|
||||
this.updateScrubberValues({ animate: true });
|
||||
|
||||
this.$().removeClass('open');
|
||||
}
|
||||
|
||||
onupdate() {
|
||||
if (this.stream.forceUpdateScrubber) {
|
||||
this.stream.forceUpdateScrubber = false;
|
||||
@@ -113,6 +146,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
|
||||
@@ -124,6 +158,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.
|
||||
@@ -156,7 +191,7 @@ export default class PostStreamScrubber extends Component {
|
||||
* @param {Boolean} animate
|
||||
*/
|
||||
updateScrubberValues(options = {}) {
|
||||
const index = this.stream.index;
|
||||
const index = this.pendingMoveIndex || this.stream.index;
|
||||
const count = this.stream.count();
|
||||
const visible = this.stream.visible || 1;
|
||||
const percentPerPost = this.percentPerPost();
|
||||
@@ -247,7 +282,7 @@ export default class PostStreamScrubber extends Component {
|
||||
const deltaIndex = deltaPercent / this.percentPerPost().index || 0;
|
||||
const newIndex = Math.min(this.indexStart + deltaIndex, this.stream.count() - 1);
|
||||
|
||||
this.stream.index = Math.max(0, newIndex);
|
||||
this.pendingMoveIndex = Math.max(0, newIndex);
|
||||
this.updateScrubberValues();
|
||||
}
|
||||
|
||||
@@ -264,15 +299,17 @@ export default class PostStreamScrubber extends Component {
|
||||
|
||||
// If the index we've landed on is in a gap, then tell the stream-
|
||||
// content that we want to load those posts.
|
||||
const intIndex = Math.floor(this.stream.index);
|
||||
const intIndex = Math.floor(this.pendingMoveIndex);
|
||||
this.stream.goToIndex(intIndex);
|
||||
this.pendingMoveIndex = null;
|
||||
}
|
||||
|
||||
onclick(e) {
|
||||
// Calculate the index which we want to jump to based on the click position.
|
||||
|
||||
// 1. Get the offset of the click from the top of the scrollbar, as a
|
||||
// percentage of the scrollbar's height.
|
||||
// percentage of the scrollbar's height. Save current location for the
|
||||
// back button.
|
||||
const $scrollbar = this.$('.Scrubber-scrollbar');
|
||||
const offsetPixels = (e.pageY || e.originalEvent.touches[0].pageY) - $scrollbar.offset().top + $('body').scrollTop();
|
||||
let offsetPercent = (offsetPixels / $scrollbar.outerHeight()) * 100;
|
||||
|
@@ -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
|
||||
|
@@ -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,7 +1,7 @@
|
||||
import Component from '../Component';
|
||||
import ItemList from '../utils/ItemList';
|
||||
import listItems from '../helpers/listItems';
|
||||
import Button from './Button';
|
||||
import Component from '../../common/Component';
|
||||
import ItemList from '../../common/utils/ItemList';
|
||||
import listItems from '../../common/helpers/listItems';
|
||||
import Button from '../../common/components/Button';
|
||||
|
||||
import BasicEditorDriver from '../utils/BasicEditorDriver';
|
||||
|
@@ -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 };
|
||||
|
||||
|
@@ -1,7 +1,7 @@
|
||||
import subclassOf from '../../common/utils/subclassOf';
|
||||
import Stream from '../../common/utils/Stream';
|
||||
import ReplyComposer from '../components/ReplyComposer';
|
||||
import EditorDriverInterface from '../../common/utils/EditorDriverInterface';
|
||||
import EditorDriverInterface from '../utils/EditorDriverInterface';
|
||||
|
||||
class ComposerState {
|
||||
constructor() {
|
||||
|
@@ -1,4 +1,4 @@
|
||||
import { throttle } from 'throttle-debounce';
|
||||
import { throttle } from 'lodash-es';
|
||||
import anchorScroll from '../../common/utils/anchorScroll';
|
||||
|
||||
class PostStreamState {
|
||||
@@ -22,6 +22,7 @@ class PostStreamState {
|
||||
this.pagesLoading = 0;
|
||||
|
||||
this.index = 0;
|
||||
this.previousIndex = 0;
|
||||
this.number = 1;
|
||||
|
||||
/**
|
||||
@@ -50,8 +51,8 @@ class PostStreamState {
|
||||
*/
|
||||
this.forceUpdateScrubber = false;
|
||||
|
||||
this.loadNext = throttle(300, this._loadNext);
|
||||
this.loadPrevious = throttle(300, this._loadPrevious);
|
||||
this.loadNext = throttle(this._loadNext, 300);
|
||||
this.loadPrevious = throttle(this._loadPrevious, 300);
|
||||
|
||||
this.show(includedPosts);
|
||||
}
|
||||
@@ -137,6 +138,7 @@ class PostStreamState {
|
||||
this.needsScroll = true;
|
||||
this.targetPost = { index };
|
||||
this.animateScroll = !noAnimation;
|
||||
this.previousIndex = index === this.previousIndex ? index : this.index;
|
||||
this.index = index;
|
||||
|
||||
m.redraw();
|
||||
|
@@ -11,7 +11,7 @@ import extractText from '../../common/utils/extractText';
|
||||
* The `DiscussionControls` utility constructs a list of buttons for a
|
||||
* discussion which perform actions on it.
|
||||
*/
|
||||
const DiscussionControls = {
|
||||
export default {
|
||||
/**
|
||||
* Get a list of controls for a discussion.
|
||||
*
|
||||
@@ -259,5 +259,3 @@ const DiscussionControls = {
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
export default DiscussionControls;
|
||||
|
@@ -8,7 +8,7 @@ import extractText from '../../common/utils/extractText';
|
||||
* The `PostControls` utility constructs a list of buttons for a post which
|
||||
* perform actions on it.
|
||||
*/
|
||||
const PostControls = {
|
||||
export default {
|
||||
/**
|
||||
* Get a list of controls for a post.
|
||||
*
|
||||
@@ -199,5 +199,3 @@ const PostControls = {
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
export default PostControls;
|
||||
|
@@ -1,6 +1,6 @@
|
||||
import Button from '../../common/components/Button';
|
||||
import Separator from '../../common/components/Separator';
|
||||
import EditUserModal from '../../common/components/EditUserModal';
|
||||
import EditUserModal from '../components/EditUserModal';
|
||||
import UserPage from '../components/UserPage';
|
||||
import ItemList from '../../common/utils/ItemList';
|
||||
|
||||
@@ -8,7 +8,7 @@ import ItemList from '../../common/utils/ItemList';
|
||||
* The `UserControls` utility constructs a list of buttons for a user which
|
||||
* perform actions on it.
|
||||
*/
|
||||
const UserControls = {
|
||||
export default {
|
||||
/**
|
||||
* Get a list of controls for a user.
|
||||
*
|
||||
@@ -141,5 +141,3 @@ const UserControls = {
|
||||
app.modal.show(EditUserModal, { user });
|
||||
},
|
||||
};
|
||||
|
||||
export default UserControls;
|
||||
|
@@ -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,27 +1,16 @@
|
||||
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)$/;
|
||||
module.exports['module'].rules[0].use[1].options.presets.push('@babel/preset-typescript');
|
||||
module.exports['module'].rules[0].use.options.presets.push('@babel/preset-typescript');
|
||||
|
@@ -10,4 +10,3 @@
|
||||
@import "admin/ExtensionWidget";
|
||||
@import "admin/AppearancePage";
|
||||
@import "admin/MailPage";
|
||||
@import "admin/UsersListPage.less";
|
||||
|
@@ -9,7 +9,7 @@
|
||||
color: @muted-color;
|
||||
}
|
||||
|
||||
&-description {
|
||||
.AdminHeader-description {
|
||||
margin: 0;
|
||||
color: @control-color;
|
||||
}
|
||||
|
@@ -1,6 +1,6 @@
|
||||
.ExtensionPage {
|
||||
|
||||
&-header {
|
||||
.ExtensionPage-header {
|
||||
.ExtensionTitle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -17,28 +17,10 @@
|
||||
margin: 0;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
&Items {
|
||||
padding: 15px 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
|
||||
.Checkbox {
|
||||
margin: 5px 0 0 0;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.Checkbox.off {
|
||||
.Checkbox-display {
|
||||
background: @muted-more-color;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&-header,
|
||||
&-permissions-header {
|
||||
.ExtensionPage-header,
|
||||
.ExtensionPage-permissions-header {
|
||||
background: @control-bg;
|
||||
|
||||
h2 {
|
||||
@@ -78,6 +60,40 @@
|
||||
}
|
||||
}
|
||||
|
||||
.ExtensionPage-headerItems {
|
||||
padding: 15px 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
|
||||
.Checkbox {
|
||||
margin: 5px 0 0 0;
|
||||
display: inline-block;
|
||||
}
|
||||
}
|
||||
|
||||
.Checkbox.off {
|
||||
.Checkbox-display {
|
||||
background: @muted-more-color;
|
||||
}
|
||||
}
|
||||
|
||||
.ExtensionInfo {
|
||||
margin-left: auto;
|
||||
|
||||
.item-authors {
|
||||
a {
|
||||
color: @muted-color;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
.ExtensionName {
|
||||
display: inline-block;
|
||||
margin-left: 8px;
|
||||
}
|
||||
|
||||
.ExtensionIcon {
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
@@ -86,12 +102,12 @@
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
&TopItems {
|
||||
.ExtensionPage-headerTopItems {
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
@media (max-width: @screen-phone-max) {
|
||||
&TopItems {
|
||||
.ExtensionPage-headerTopItems {
|
||||
float: right;
|
||||
position: relative;
|
||||
}
|
||||
@@ -102,13 +118,13 @@
|
||||
}
|
||||
}
|
||||
|
||||
&-settings, &-permissions {
|
||||
.ExtensionPage-settings, .ExtensionPage-permissions {
|
||||
.ExtensionPage-subHeader {
|
||||
margin: 5px 0px;
|
||||
}
|
||||
}
|
||||
|
||||
&-settings {
|
||||
.ExtensionPage-settings {
|
||||
margin-top: 20px;
|
||||
padding: 10px 0;
|
||||
|
||||
@@ -117,12 +133,13 @@
|
||||
}
|
||||
}
|
||||
|
||||
&-subHeader {
|
||||
.ExtensionPage-subHeader {
|
||||
color: @muted-color;
|
||||
font-weight: normal;
|
||||
}
|
||||
|
||||
&-permissions {
|
||||
|
||||
.ExtensionPage-permissions {
|
||||
|
||||
.PermissionGrid-removeScope {
|
||||
display: none;
|
||||
@@ -133,24 +150,9 @@
|
||||
padding-bottom: 25vh;
|
||||
}
|
||||
|
||||
&-header {
|
||||
.ExtensionPage-permissions-header {
|
||||
margin: 20px 0 20px;
|
||||
padding: 5px 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.ExtensionInfo {
|
||||
margin-left: auto;
|
||||
|
||||
.item-authors {
|
||||
a {
|
||||
color: @muted-color;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.ExtensionName {
|
||||
display: inline-block;
|
||||
margin-left: 8px;
|
||||
}
|
||||
|
@@ -1,125 +0,0 @@
|
||||
.UserListPage {
|
||||
// Pad bottom of page to make nav area look less squashed
|
||||
padding-bottom: 24px;
|
||||
|
||||
&-grid {
|
||||
width: 100%;
|
||||
position: relative;
|
||||
border-radius: @border-radius;
|
||||
|
||||
// Use CSS custom properties to define the number of columns in the grid
|
||||
grid-template-columns: repeat(var(--columns), max-content);
|
||||
|
||||
// Ensure mobile scrollbar isn't on top of content
|
||||
padding-bottom: 4px;
|
||||
|
||||
// Table refreshing overlay
|
||||
&--loadingPage {
|
||||
&::after {
|
||||
content: "";
|
||||
display: block;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
background: rgba(128, 128, 128, 0.2);
|
||||
}
|
||||
|
||||
.LoadingIndicator-container {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
}
|
||||
}
|
||||
|
||||
&--loaded,
|
||||
&--loadingPage {
|
||||
display: grid;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
&-header {
|
||||
font-weight: bold;
|
||||
border-bottom: 1px solid @muted-more-color;
|
||||
padding: 8px 16px;
|
||||
background: @control-bg;
|
||||
}
|
||||
|
||||
&-rowItem {
|
||||
padding: 4px 16px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
&[data-column-name="editUser"] {
|
||||
padding: 0;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
&--shaded {
|
||||
background: darken(@body-bg, 3%);
|
||||
|
||||
& when (@config-dark-mode = true) {
|
||||
background: lighten(@body-bg, 5%);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&-gridPagination {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-top: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
// Handles styling of default UserList columns
|
||||
.UserList {
|
||||
&-joinDate {
|
||||
cursor: help;
|
||||
text-decoration: underline;
|
||||
text-decoration-style: dotted;
|
||||
}
|
||||
|
||||
&-editModalBtn {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
|
||||
border-radius: 0;
|
||||
padding: 0;
|
||||
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
&-email {
|
||||
display: flex;
|
||||
flex-grow: 1;
|
||||
|
||||
&[data-email-shown="false"] {
|
||||
.UserList-emailAddress {
|
||||
user-select: none;
|
||||
filter: blur(4px);
|
||||
cursor: pointer;
|
||||
}
|
||||
}
|
||||
|
||||
&Address {
|
||||
flex-grow: 1;
|
||||
margin-right: 4px;
|
||||
transition: filter 0.2s ease-out;
|
||||
}
|
||||
|
||||
&IconBtn {
|
||||
margin-left: 12px;
|
||||
|
||||
&:hover {
|
||||
text-decoration: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@@ -123,9 +123,6 @@
|
||||
// the left side of the screen. On other devices, the drawer has no specific
|
||||
// appearance.
|
||||
@media @phone {
|
||||
.App:not(.drawerOpen) .App-drawer {
|
||||
visibility: hidden;
|
||||
}
|
||||
.drawerOpen {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
@@ -90,11 +90,14 @@
|
||||
.Button-label {
|
||||
.transition(margin-right 0.1s);
|
||||
}
|
||||
|
||||
.LoadingIndicator-container {
|
||||
.LoadingIndicator {
|
||||
color: inherit;
|
||||
margin-top: -0.175em;
|
||||
margin-left: 4px;
|
||||
margin: 0 -5px 0 -15px;
|
||||
}
|
||||
&.loading {
|
||||
.Button-label {
|
||||
margin-right: 20px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
@@ -15,9 +15,13 @@
|
||||
float: left;
|
||||
margin-left: -65px;
|
||||
margin-top: -4px;
|
||||
|
||||
.LoadingIndicator {
|
||||
display: inline-block;
|
||||
margin-left: 20px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.Checkbox--switch .Checkbox-display {
|
||||
width: 50px;
|
||||
height: 28px;
|
||||
@@ -27,28 +31,8 @@
|
||||
background: @control-bg;
|
||||
.transition(background-color 0.2s);
|
||||
|
||||
.LoadingIndicator {
|
||||
--size: 22px !important;
|
||||
|
||||
&-container {
|
||||
height: 22px;
|
||||
}
|
||||
}
|
||||
|
||||
.on& {
|
||||
background: #58a400;
|
||||
|
||||
.LoadingIndicator-container {
|
||||
// Show loading indicator over the switch button
|
||||
justify-content: flex-end;
|
||||
}
|
||||
}
|
||||
|
||||
.off& {
|
||||
.LoadingIndicator-container {
|
||||
// Show loading indicator over the switch button
|
||||
justify-content: flex-start;
|
||||
}
|
||||
}
|
||||
|
||||
&:before {
|
||||
|
@@ -2,64 +2,13 @@
|
||||
// Loading Indicators
|
||||
|
||||
.LoadingIndicator {
|
||||
@spin-time: 750ms;
|
||||
|
||||
// Use the value of `color` to maintain backwards compatibility
|
||||
border-color: currentColor;
|
||||
border-width: var(--thickness);
|
||||
border-style: solid;
|
||||
border-top-color: transparent;
|
||||
border-radius: 50%;
|
||||
|
||||
width: var(--size);
|
||||
height: var(--size);
|
||||
|
||||
animation: spin @spin-time linear infinite;
|
||||
|
||||
// <div> container around the spinner
|
||||
// Used for positioning
|
||||
&-container {
|
||||
--size: 24px;
|
||||
--thickness: 2px;
|
||||
|
||||
color: @muted-color;
|
||||
|
||||
// Center vertically and horizontally
|
||||
// Allows people to set `height` and it'll stay centered within the new height
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
// Size
|
||||
|
||||
&--large {
|
||||
--size: 32px;
|
||||
--thickness: 3px;
|
||||
}
|
||||
|
||||
&--small {
|
||||
--size: 18px;
|
||||
}
|
||||
|
||||
// Display types
|
||||
|
||||
&--block {
|
||||
height: 100px;
|
||||
}
|
||||
|
||||
&--inline {
|
||||
display: inline-block;
|
||||
vertical-align: middle;
|
||||
}
|
||||
}
|
||||
position: relative;
|
||||
color: @muted-color;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
from {
|
||||
transform: rotate(0);
|
||||
}
|
||||
|
||||
to {
|
||||
transform: rotate(1turn);
|
||||
}
|
||||
.LoadingIndicator--inline {
|
||||
display: inline-block;
|
||||
width: 25px;
|
||||
}
|
||||
.LoadingIndicator--block {
|
||||
height: 100px;
|
||||
}
|
||||
|
@@ -8,14 +8,14 @@
|
||||
&.focused {
|
||||
margin-left: -400px;
|
||||
|
||||
input,
|
||||
.Search-results {
|
||||
input, .Search-results {
|
||||
width: 400px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.Search-results {
|
||||
max-height: 70vh;
|
||||
overflow: auto;
|
||||
left: auto;
|
||||
right: 0;
|
||||
@@ -62,21 +62,11 @@
|
||||
.transition(all 0.4s);
|
||||
box-sizing: inherit !important;
|
||||
}
|
||||
|
||||
.LoadingIndicator-container {
|
||||
height: 36px;
|
||||
}
|
||||
|
||||
.Button {
|
||||
float: left;
|
||||
margin-left: -36px;
|
||||
outline: none;
|
||||
width: 36px !important;
|
||||
|
||||
&.LoadingIndicator {
|
||||
width: var(--size) !important;
|
||||
padding: 0;
|
||||
}
|
||||
outline: none;
|
||||
}
|
||||
}
|
||||
|
||||
|
@@ -1,49 +0,0 @@
|
||||
.TextEditor .TextEditor-editor {
|
||||
border-radius: 0;
|
||||
padding: 0 0 10px;
|
||||
border: 0;
|
||||
resize: none;
|
||||
color: @text-color;
|
||||
font-size: 14px;
|
||||
line-height: 1.7;
|
||||
|
||||
&, &:focus, &[disabled] {
|
||||
background: none;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
@media @phone {
|
||||
font-size: 16px; // minimum font-size required to prevent page zoom on focus in iOS 10
|
||||
}
|
||||
}
|
||||
.TextEditor-controls {
|
||||
margin: 0;
|
||||
padding: 10px 0;
|
||||
list-style-type: none;
|
||||
overflow-x: auto;
|
||||
white-space: nowrap;
|
||||
|
||||
li {
|
||||
display: inline-block;
|
||||
margin-right: 10px;
|
||||
}
|
||||
}
|
||||
.TextEditor-toolbar {
|
||||
.Button--icon {
|
||||
width: 28px;
|
||||
}
|
||||
}
|
||||
|
||||
@media @tablet-up {
|
||||
.TextEditor-controls {
|
||||
margin: 0 -20px 0 -105px;
|
||||
padding: 10px 20px;
|
||||
border-top: 1px solid @control-bg;
|
||||
|
||||
.fullScreen & {
|
||||
margin: 0;
|
||||
border-top: 0;
|
||||
padding: 20px 0;
|
||||
}
|
||||
}
|
||||
}
|
@@ -17,7 +17,6 @@
|
||||
@import "Button";
|
||||
@import "Checkbox";
|
||||
@import "Dropdown";
|
||||
@import "EditUserModal";
|
||||
@import "Form";
|
||||
@import "FormControl";
|
||||
@import "LoadingIndicator";
|
||||
@@ -26,6 +25,5 @@
|
||||
@import "Placeholder";
|
||||
@import "Search";
|
||||
@import "Select";
|
||||
@import "TextEditor";
|
||||
@import "Tooltip";
|
||||
@import "ValidationError";
|
||||
|
@@ -1,4 +1,3 @@
|
||||
@import "mixins/accessibility.less";
|
||||
@import "mixins/border-radius.less";
|
||||
@import "mixins/clearfix.less";
|
||||
@import "mixins/light-contents.less";
|
||||
|
@@ -1,100 +0,0 @@
|
||||
// This mixin should **only** be used in this file. If you want to define your own
|
||||
// custom outline style(s), override this mixin in your own theme extension, or in
|
||||
// the Custom Less section of the Admin dashboard.
|
||||
#private {
|
||||
.__focus-ring-styles() {
|
||||
// This uses the browser's default outline styles, rather than
|
||||
// using custom ones, which could introduce more issues
|
||||
|
||||
// Source: https://css-tricks.com/copy-the-browsers-native-focus-styles
|
||||
outline: 5px auto Highlight;
|
||||
outline: 5px auto -webkit-focus-ring-color;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds a focus ring to an element.
|
||||
*
|
||||
* This is only shown when focus is provided via keyboard, using the
|
||||
* `:focus-visible` selector, and `:-moz-focusring` for older Firefox.
|
||||
*/
|
||||
.add-keyboard-focus-ring() {
|
||||
// We need to declare these separately, otherwise
|
||||
// browsers will ignore `:focus-visible` as they
|
||||
// don't understand `:-moz-focusring`
|
||||
|
||||
// These are the keyboard-only versions of :focus
|
||||
&:-moz-focusring {
|
||||
#private.__focus-ring-styles();
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
#private.__focus-ring-styles();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* This mixin allows support for a custom focus
|
||||
* selector to be supplied.
|
||||
*
|
||||
* For example...
|
||||
*
|
||||
*? button { .addKeyboardFocusRing(":focus-within") }
|
||||
* becomes
|
||||
*? button:focus-within { <styles> }
|
||||
*
|
||||
* AND
|
||||
*
|
||||
*? button { .addKeyboardFocusRing(" :focus-within") }
|
||||
* becomes
|
||||
*? button :focus-within { <styles> }
|
||||
*/
|
||||
.add-keyboard-focus-ring(@customFocusSelector) {
|
||||
@realFocusSelector: ~"@{customFocusSelector}";
|
||||
|
||||
&@{realFocusSelector} {
|
||||
#private.__focus-ring-styles();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Allows an offset to be supplied for an a11y
|
||||
* outline.
|
||||
*
|
||||
* Useful for elements whose content is right up
|
||||
* against their bounds.
|
||||
*
|
||||
* `.addKeyboardFocusRingOffset(2px)` will add an
|
||||
* offset of 2 pixels to the outline.
|
||||
*/
|
||||
.add-keyboard-focus-ring-offset(@offset) {
|
||||
.offset() {
|
||||
outline-offset: @offset;
|
||||
}
|
||||
|
||||
&:-moz-focusring {
|
||||
.offset();
|
||||
}
|
||||
&:focus-visible {
|
||||
.offset();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Allows an offset to be supplied for an a11y
|
||||
* outline.
|
||||
*
|
||||
* Useful for elements whose content is right up
|
||||
* against their bounds.
|
||||
*/
|
||||
.add-keyboard-focus-ring-offset(@customSelector, @offset) {
|
||||
.offset() {
|
||||
outline-offset: @offset;
|
||||
}
|
||||
|
||||
@realFocusSelector: ~"@{customFocusSelector}";
|
||||
|
||||
&@{realFocusSelector} {
|
||||
.offset();
|
||||
}
|
||||
}
|
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user