mirror of
https://github.com/flarum/core.git
synced 2025-08-13 20:04:24 +02:00
Compare commits
6 Commits
v0.1.0-bet
...
next-front
Author | SHA1 | Date | |
---|---|---|---|
|
fd3f484eaf | ||
|
8b3971f202 | ||
|
5b68b80e73 | ||
|
ef4c9d4f8a | ||
|
4585002118 | ||
|
5451aac693 |
@@ -15,5 +15,5 @@ indent_size = 2
|
||||
[*.{diff,md}]
|
||||
trim_trailing_whitespace = false
|
||||
|
||||
[*.{php,xml,json}]
|
||||
[*.php]
|
||||
indent_size = 4
|
||||
|
6
.gitattributes
vendored
6
.gitattributes
vendored
@@ -1,8 +1,6 @@
|
||||
.gitattributes export-ignore
|
||||
.gitignore export-ignore
|
||||
.gitmodules export-ignore
|
||||
.github export-ignore
|
||||
.travis export-ignore
|
||||
.travis.yml export-ignore
|
||||
.editorconfig export-ignore
|
||||
.styleci.yml export-ignore
|
||||
@@ -10,6 +8,4 @@
|
||||
phpunit.xml export-ignore
|
||||
tests export-ignore
|
||||
|
||||
js/dist/* -diff
|
||||
|
||||
* text=auto eol=lf
|
||||
js/*/dist/*.js -diff
|
||||
|
3
.github/CONTRIBUTING.md
vendored
Normal file
3
.github/CONTRIBUTING.md
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
# Contributing to Flarum
|
||||
|
||||
Howdy! We're really excited that you are interested in contributing to Flarum. Before submitting your contribution, please take a moment and read through the [Contributing Guidelines](https://github.com/flarum/flarum/blob/master/CONTRIBUTING.md).
|
3
.github/FUNDING.yml
vendored
3
.github/FUNDING.yml
vendored
@@ -1,3 +0,0 @@
|
||||
github: flarum
|
||||
open_collective: flarum
|
||||
tidelift: packagist/flarum/core
|
26
.github/ISSUE_TEMPLATE.md
vendored
Normal file
26
.github/ISSUE_TEMPLATE.md
vendored
Normal file
@@ -0,0 +1,26 @@
|
||||
> Issues on Github are meant for bug reporting. Please post feature requests on the [discussion forum](https://discuss.flarum.org/t/features).
|
||||
---
|
||||
> Try to complete the below form as far as you are able and are willing to share. Add a screenshot of the issue if you can.
|
||||
|
||||
## Bug report
|
||||
- Version of Flarum: x.y.z
|
||||
- Website URL where the bug is visible: http://example.com
|
||||
- The webserver you are running: apache, nginx or something else
|
||||
- PHP version: x.y.z
|
||||
- Hosted environment: shared or vps
|
||||
- Hosting provider: http://some-amazing-provider.com
|
||||
|
||||
## Flarum info
|
||||
|
||||
```
|
||||
Output of "php flarum info", run this in terminal in your Flarum directory.
|
||||
```
|
||||
|
||||
## Additional comments
|
||||
Some additional information you'd like to share, eg what have you tried so far.
|
||||
|
||||
## Log files
|
||||
|
||||
```
|
||||
Put any relevant logs here.
|
||||
```
|
39
.github/ISSUE_TEMPLATE/bug-report.md
vendored
39
.github/ISSUE_TEMPLATE/bug-report.md
vendored
@@ -1,39 +0,0 @@
|
||||
---
|
||||
name: "🐛 Bug Report"
|
||||
about: "If something isn't working as expected"
|
||||
|
||||
---
|
||||
## Bug Report
|
||||
|
||||
**Current Behavior**
|
||||
A clear and concise description of the behavior.
|
||||
|
||||
**Steps to Reproduce**
|
||||
1. Go to '...'
|
||||
2. Click on '....'
|
||||
3. Scroll down to '....'
|
||||
4. See error
|
||||
|
||||
**Expected Behavior**
|
||||
A clear and concise description of what you expected to happen.
|
||||
|
||||
**Screenshots**
|
||||
If applicable, add screenshots to help explain your problem.
|
||||
|
||||
**Environment**
|
||||
- Flarum version: x.y.z
|
||||
- Website URL: http://example.com
|
||||
- Webserver: [e.g. apache, nginx]
|
||||
- Hosting environment: [e.g. shared, vps]
|
||||
- PHP version: x.y.z
|
||||
- Browser: [e.g. chrome 67, safari 11]
|
||||
|
||||
```
|
||||
Output of "php flarum info", run this in terminal in your Flarum directory.
|
||||
```
|
||||
|
||||
**Possible Solution**
|
||||
<!--- Only if you have suggestions or a fix for the bug -->
|
||||
|
||||
**Additional Context**
|
||||
Add any other context about the problem here.
|
26
.github/ISSUE_TEMPLATE/feature-request.md
vendored
26
.github/ISSUE_TEMPLATE/feature-request.md
vendored
@@ -1,26 +0,0 @@
|
||||
---
|
||||
name: "🚀 Feature Request"
|
||||
about: "I have a suggestion (and may want to implement it!)"
|
||||
|
||||
---
|
||||
|
||||
<!--
|
||||
IMPORTANT: Feature requests on this GitHub issue tracker are only accepted in case they have been approved by a core developer or contain extensive argumentation and directions for implementation. For all other feature requests, ideas and feedback please post in the Flarum Community: https://discuss.flarum.org/t/feedback.
|
||||
-->
|
||||
|
||||
## Feature Request
|
||||
|
||||
**Is your feature request related to a problem? Please describe.**
|
||||
A clear and concise description of what the problem is. eg. I have an issue when [...]
|
||||
|
||||
**Describe the solution you'd like**
|
||||
A detailed description of your proposed solution. Include:
|
||||
- How the feature would work/behave
|
||||
- Any potential drawbacks
|
||||
- Maybe a screenshot, design, or example code
|
||||
|
||||
**Justify why this feature belongs in Flarum's core, rather than in a third-party extension**
|
||||
Consider who this change will be useful to – most Flarum forums, or just a few?
|
||||
|
||||
**Describe alternatives you've considered**
|
||||
A clear and concise description of any alternative solutions or features you've considered.
|
11
.github/ISSUE_TEMPLATE/support-question.md
vendored
11
.github/ISSUE_TEMPLATE/support-question.md
vendored
@@ -1,11 +0,0 @@
|
||||
---
|
||||
name: "🙋 Support Question"
|
||||
about: "If you have a question, please check out our forum or Discord!"
|
||||
|
||||
---
|
||||
|
||||
We primarily use GitHub as an issue tracker; for usage and support questions, please check out these resources below. Thanks!
|
||||
|
||||
* Flarum Community: https://discuss.flarum.org/
|
||||
* Discord Chat: https://flarum.org/discord/
|
||||
* Twitter: https://twitter.com/Flarum
|
24
.github/PULL_REQUEST_TEMPLATE.md
vendored
24
.github/PULL_REQUEST_TEMPLATE.md
vendored
@@ -1,24 +0,0 @@
|
||||
<!--
|
||||
IMPORTANT: We applaud pull requests, they excite us every single time. As we have an obligation to maintain a healthy code standard and quality, we take sufficient time for reviews. Please do create a separate pull request per change/issue/feature; we will ask you to split bundled pull requests.
|
||||
-->
|
||||
|
||||
**Fixes #0000**
|
||||
|
||||
**Changes proposed in this pull request:**
|
||||
<!-- fill this out, mention the pages and/or components which have been impacted -->
|
||||
|
||||
**Reviewers should focus on:**
|
||||
<!-- fill this out, ask for feedback on specific changes you are unsure about -->
|
||||
|
||||
**Screenshot**
|
||||
<!-- include an image of the most relevant user-facing change, if any -->
|
||||
|
||||
**Confirmed**
|
||||
|
||||
- [ ] Frontend changes: tested on a local Flarum installation.
|
||||
- [ ] Backend changes: tests are green (run `composer test`).
|
||||
|
||||
**Required changes:**
|
||||
|
||||
- [ ] Related documentation PR: (Remove if irrelevant)
|
||||
- [ ] Related core extension PRs: (Remove if irrelevant)
|
13
.github/SECURITY.md
vendored
13
.github/SECURITY.md
vendored
@@ -1,13 +0,0 @@
|
||||
# Security Policy
|
||||
|
||||
## Supported Versions
|
||||
|
||||
During the beta phase, we will only patch security vulnerabilities in the latest beta release.
|
||||
|
||||
## Reporting a Vulnerability
|
||||
|
||||
If you discover a security vulnerability within Flarum, please send an email to security@flarum.org so we can address it promptly.
|
||||
|
||||
We will get back to you as time allows.
|
||||
Discussions may commence internally, so you may not hear back immediately.
|
||||
When reporting a vulnerability, please provide your GitHub username (if available), so that we can invite you to collaborate on a [security advisory on GitHub](https://help.github.com/en/articles/about-maintainer-security-advisories).
|
26
.github/stale.yml
vendored
26
.github/stale.yml
vendored
@@ -1,26 +0,0 @@
|
||||
daysUntilStale: 90
|
||||
daysUntilClose: 30
|
||||
|
||||
staleLabel: stale
|
||||
|
||||
exemptLabels:
|
||||
- org/keep
|
||||
- type/bug
|
||||
- type/regression
|
||||
- critical
|
||||
- security
|
||||
exemptAssignees: true
|
||||
exemptMilestones: true
|
||||
exemptProjects: true
|
||||
|
||||
markComment: >
|
||||
This issue has been automatically marked as stale because it has not had
|
||||
recent activity. It will be closed if no further activity occurs. We do this
|
||||
to keep the amount of open issues to a manageable minimum.
|
||||
|
||||
In any case, thanks for taking an interest in this software and contributing
|
||||
by opening the issue in the first place!
|
||||
|
||||
closeComment: >
|
||||
We are closing this issue as it seems to have grown stale. If you still
|
||||
encounter this problem with the latest version, feel free to re-open it.
|
16
.github/workflows/build.yml
vendored
16
.github/workflows/build.yml
vendored
@@ -1,16 +0,0 @@
|
||||
name: JavaScript
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- master
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@master
|
||||
- uses: flarum/action-build@master
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
31
.github/workflows/lint.yml
vendored
31
.github/workflows/lint.yml
vendored
@@ -1,31 +0,0 @@
|
||||
name: Lint
|
||||
|
||||
on:
|
||||
push:
|
||||
paths:
|
||||
- 'js/src/**'
|
||||
pull_request:
|
||||
paths:
|
||||
- 'js/src/**'
|
||||
|
||||
jobs:
|
||||
prettier:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
name: JS / Prettier
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@master
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v1
|
||||
with:
|
||||
node-version: "12"
|
||||
|
||||
- name: Install JS dependencies
|
||||
run: npm ci
|
||||
working-directory: ./js
|
||||
|
||||
- name: Check JS code for formatting
|
||||
run: node_modules/.bin/prettier --check src
|
||||
working-directory: ./js
|
78
.github/workflows/test.yml
vendored
78
.github/workflows/test.yml
vendored
@@ -1,78 +0,0 @@
|
||||
name: Tests
|
||||
|
||||
on: [push, pull_request]
|
||||
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
strategy:
|
||||
matrix:
|
||||
php: [7.3, 7.4, '8.0']
|
||||
service: ['mysql:5.7', mariadb]
|
||||
prefix: ['', flarum_]
|
||||
|
||||
include:
|
||||
- service: 'mysql:5.7'
|
||||
db: MySQL
|
||||
- service: mariadb
|
||||
db: MariaDB
|
||||
- prefix: flarum_
|
||||
prefixStr: (prefix)
|
||||
|
||||
exclude:
|
||||
- php: 7.3
|
||||
service: 'mysql:5.7'
|
||||
prefix: flarum_
|
||||
- php: 7.3
|
||||
service: mariadb
|
||||
prefix: flarum_
|
||||
- php: 8.0
|
||||
service: 'mysql:5.7'
|
||||
prefix: flarum_
|
||||
- php: 8.0
|
||||
service: mariadb
|
||||
prefix: flarum_
|
||||
|
||||
services:
|
||||
mysql:
|
||||
image: ${{ matrix.service }}
|
||||
ports:
|
||||
- 13306:3306
|
||||
|
||||
name: 'PHP ${{ matrix.php }} / ${{ matrix.db }} ${{ matrix.prefixStr }}'
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@master
|
||||
|
||||
- name: Setup PHP
|
||||
uses: shivammathur/setup-php@v2
|
||||
with:
|
||||
php-version: ${{ matrix.php }}
|
||||
coverage: xdebug
|
||||
extensions: curl, dom, gd, json, mbstring, openssl, pdo_mysql, tokenizer, zip
|
||||
tools: phpunit, composer:v2
|
||||
|
||||
# The authentication alter is necessary because newer mysql versions use the `caching_sha2_password` driver,
|
||||
# which isn't supported prior to PHP7.4
|
||||
# When we drop support for PHP7.3, we should remove this from the setup.
|
||||
- name: Create MySQL Database
|
||||
run: |
|
||||
sudo systemctl start mysql
|
||||
mysql -uroot -proot -e 'CREATE DATABASE flarum_test;' --port 13306
|
||||
mysql -uroot -proot -e "ALTER USER 'root'@'localhost' IDENTIFIED WITH mysql_native_password BY 'root';" --port 13306
|
||||
|
||||
- name: Install Composer dependencies
|
||||
run: composer install
|
||||
|
||||
- name: Setup Composer tests
|
||||
run: composer test:setup
|
||||
env:
|
||||
DB_PORT: 13306
|
||||
DB_PASSWORD: root
|
||||
DB_PREFIX: ${{ matrix.prefix }}
|
||||
|
||||
- name: Run Composer tests
|
||||
run: composer test
|
||||
env:
|
||||
COMPOSER_PROCESS_TIMEOUT: 600
|
8
.gitignore
vendored
8
.gitignore
vendored
@@ -1,11 +1,9 @@
|
||||
/vendor
|
||||
composer.lock
|
||||
composer.phar
|
||||
node_modules
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
tests/.phpunit.result.cache
|
||||
/tests/integration/tmp
|
||||
tests/_output/*
|
||||
.vagrant
|
||||
.idea/*
|
||||
.vscode
|
||||
node_modules
|
||||
bower_components
|
35
.travis.yml
Normal file
35
.travis.yml
Normal file
@@ -0,0 +1,35 @@
|
||||
language: php
|
||||
|
||||
php:
|
||||
- 5.6
|
||||
- 7.0
|
||||
- 7.1
|
||||
- hhvm
|
||||
|
||||
matrix:
|
||||
allow_failures:
|
||||
- php: hhvm
|
||||
fast_finish: true
|
||||
|
||||
before_script:
|
||||
- if [[ "$TRAVIS_PHP_VERSION" != "hhvm" ]]; then phpenv config-rm xdebug.ini; fi;
|
||||
- composer self-update
|
||||
- composer install
|
||||
|
||||
script:
|
||||
- vendor/bin/phpunit --coverage-clover=coverage.xml
|
||||
|
||||
notifications:
|
||||
email:
|
||||
on_failure: change
|
||||
webhooks:
|
||||
urls:
|
||||
- https://webhooks.gitter.im/e/7b9e9827a03b44a16588
|
||||
on_success: always
|
||||
on_failure: always
|
||||
on_start: false
|
||||
|
||||
after_success:
|
||||
- bash <(curl -s https://codecov.io/bash)
|
||||
|
||||
sudo: false
|
452
CHANGELOG.md
452
CHANGELOG.md
@@ -1,452 +0,0 @@
|
||||
# Changelog
|
||||
|
||||
## [0.1.0-beta.16](https://github.com/flarum/core/compare/v0.1.0-beta.15...v0.1.0-beta.16)
|
||||
|
||||
### Added
|
||||
- Allow event subscribers (https://github.com/flarum/core/pull/2535)
|
||||
- Allow Settings extender to have a default value (https://github.com/flarum/core/pull/2495)
|
||||
- Allow hooking into the sending of notifications before being send (https://github.com/flarum/core/pull/2533)
|
||||
- PHP 8 support (https://github.com/flarum/core/pull/2507)
|
||||
- Search extender (https://github.com/flarum/core/pull/2483)
|
||||
- User badges to post preview (https://github.com/flarum/core/pull/2555)
|
||||
- Optional extension dependencies allow a booting order (https://github.com/flarum/core/pull/2579)
|
||||
- Auth extender (https://github.com/flarum/core/pull/2176)
|
||||
- `X-Powered-By` header added to allow indexers easier data aggregation of Flarum adoption (https://github.com/flarum/core/pull/2618)
|
||||
|
||||
### Changed
|
||||
- Run integration tests in transaction (https://github.com/flarum/core/pull/2304)
|
||||
- Allow policies to return a boolean for simplified allow/deny (https://github.com/flarum/core/pull/2534)
|
||||
- Converted highlight helper to typescript (https://github.com/flarum/core/pull/2532)
|
||||
- Add accessibility attributes to Mark as Read button (https://github.com/flarum/core/pull/2564)
|
||||
- Dismiss errors on change email modal upon a new request ([00913d5](https://github.com/flarum/core/commit/00913d5b0be2172cfce1f16aaf64a24f3d2e6d4b))
|
||||
- Disabled extensions now are marked with a red circle instead of a red dot (https://github.com/flarum/core/pull/2562)
|
||||
- Extension dependency errors now show the extension title instead of the ID (https://github.com/flarum/core/pull/2563)
|
||||
- Change `mutate` method on ApiSerializer extender to `attributes` (https://github.com/flarum/core/pull/2578)
|
||||
- Moved locale files to the core from the language pack (https://github.com/flarum/core/pull/2408)
|
||||
- AdminPage extensibility and generic improvements (https://github.com/flarum/core/pull/2593)
|
||||
- Remove entry of authors, link to https://flarum.org/team (https://github.com/flarum/core/pull/2625)
|
||||
- Search and filtering are split (https://github.com/flarum/core/pull/2454)
|
||||
- Move IP identification into a middleware (https://github.com/flarum/core/pull/2624)
|
||||
- Editor Driver abstraction introduced (https://github.com/flarum/core/pull/2594)
|
||||
- Allow overriding routes (https://github.com/flarum/core/pull/2577)
|
||||
- Split user edit permissions into permissions for editing of user credentials, username, groups and suspending (https://github.com/flarum/core/pull/2620)
|
||||
- Reduced number of admin extension categories (https://github.com/flarum/core/pull/2604)
|
||||
- Move search related classes to a dedicated Query namespace (https://github.com/flarum/core/pull/2645)
|
||||
- Rewrite common helpers into typescript (https://github.com/flarum/core/pull/2541)
|
||||
- `TextEditor` is moved to the common namespace for use in the admin frontend (https://github.com/flarum/core/pull/2649)
|
||||
- Update Laravel/Illuminate components to 8 (https://github.com/flarum/core/pull/2576)
|
||||
- Eager load relations in discussion listing to improve performance (https://github.com/flarum/core/pull/2639)
|
||||
- Adopt flarum/testing package (https://github.com/flarum/core/pull/2545)
|
||||
- Replace `user` gambit with `author` gambit ([612a57c](https://github.com/flarum/core/commit/612a57c4664415a3ea120103483645c32acc6f12))
|
||||
- Posts page of on user profile loads posts using username instead of id ([30017ee](https://github.com/flarum/core/commit/30017eef09ae9e78640c4e2cacd4909fffa8d775))
|
||||
|
||||
### Fixed
|
||||
- Transform css breaks iOS scroll functionality (https://github.com/flarum/core/pull/2527)
|
||||
- Composer header is hidden on mobile devices (https://github.com/flarum/core/pull/2279)
|
||||
- Cannot delete a post or discussion of a deleted user (https://github.com/flarum/core/pull/2521)
|
||||
- DiscussionListPane jumps around not keeping the scroll position (https://github.com/flarum/core/pull/2402)
|
||||
- Infinite scroll on notifications dropdown broken (https://github.com/flarum/core/pull/2524)
|
||||
- The show language selector switch remains toggled on ([9347b12](https://github.com/flarum/core/commit/9347b12b47bf4ab97ffb7ca92673604b237c1012))
|
||||
- Model Visibility extender throws exception on extensions that aren't installed or enabled (https://github.com/flarum/core/pull/2580)
|
||||
- Extensions are marked as enabled when enabling fails to unmet extension dependencies (https://github.com/flarum/core/pull/2558)
|
||||
- Routes to admin extension pages without a valid ID break the admin page (https://github.com/flarum/core/pull/2584)
|
||||
- Disabled fieldset use an incorrect CSS property `disallowed` (https://github.com/flarum/core/pull/2585)
|
||||
- Scrolling to a post that is already loaded the Load More button shows and does not trigger (https://github.com/flarum/core/pull/2388)
|
||||
- Opening discussions on some mobile devices require a double tap (https://github.com/flarum/core/pull/2607)
|
||||
- iOS devices show erratic behavior in the post stream while updating (https://github.com/flarum/core/pull/2548)
|
||||
- Small mobile screens partially hides the composer when the keyboard is open (https://github.com/flarum/core/pull/2631)
|
||||
- Clearing cache does not clear the template cache in storage/views (https://github.com/flarum/core/pull/2648)
|
||||
- Boot errors show critical information (https://github.com/flarum/core/pull/2633)
|
||||
- List user endpoint discloses last online even if user choose against it (https://github.com/flarum/core/pull/2634)
|
||||
- Group gambit disclosed hidden groups (https://github.com/flarum/core/pull/2657)
|
||||
- Search results on small windows not fully visible (https://github.com/flarum/core/pull/2650)
|
||||
- Composer goes off screen on Safari when starting to type (https://github.com/flarum/core/pull/2660)
|
||||
- A search that has no results shows the search results dropdown ([b88a7cb](https://github.com/flarum/core/commit/b88a7cb33b56e318f11670e9e2d563aef94db039))
|
||||
- The composer modal moves around when typing on Safari ([a64c398](https://github.com/flarum/core/commit/a64c39835aba43e831209609f4a9638ae589aa41))
|
||||
|
||||
### Removed
|
||||
- Deprecated CSRF wildcard path match
|
||||
- Deprecated policy and visibility scoping events
|
||||
- Deprecated post types event
|
||||
- Deprecated validation events
|
||||
- Deprecated notification events
|
||||
- Deprecated floodgate
|
||||
- Deprecated user preferences event
|
||||
- Deprecated formatting events
|
||||
- Deprecated api events
|
||||
- Deprecated bootstrap.php support
|
||||
- PHP 7.2 support (https://github.com/flarum/core/pull/2507)
|
||||
- Bidi attribute in the rendered HTML (https://github.com/flarum/core/pull/2602)
|
||||
- `AccessToken::find`, use `AccessToken::findValid` instead (https://github.com/flarum/core/pull/2651)
|
||||
|
||||
### Deprecated
|
||||
- `GetModelIsPrivate` event (https://github.com/flarum/core/pull/2587)
|
||||
- `CheckingPassword` event (https://github.com/flarum/core/pull/2176)
|
||||
- `event()` helper (https://github.com/flarum/core/pull/2608)
|
||||
- `AccessToken::generate` argument `$lifetime` (https://github.com/flarum/core/pull/2651)
|
||||
- `Rememberer::remember` argument `$token` should receive an instance of `RememberAccessToken` with `AccessToken` being deprecated (https://github.com/flarum/core/pull/2651)
|
||||
- `Rememberer::rememberUser` (https://github.com/flarum/core/pull/2651)
|
||||
- `SessionAuthenticator::logIn` argument `$userId`, should be replaced with `AccessToken` (https://github.com/flarum/core/pull/2651)
|
||||
- `TextEditor` has been moved to `common` (https://github.com/flarum/core/pull/2649)
|
||||
- `UserFilter` ([91e8b56](https://github.com/flarum/core/commit/91e8b569618957c86757ef89bac666e9102db5ae))
|
||||
|
||||
|
||||
## [0.1.0-beta.15](https://github.com/flarum/core/compare/v0.1.0-beta.14.1...v0.1.0-beta.15)
|
||||
|
||||
### Added
|
||||
|
||||
- Slug drivers support (https://github.com/flarum/core/pull/2456).
|
||||
- Notification type extender (https://github.com/flarum/core/pull/2424).
|
||||
- Validation extender (https://github.com/flarum/core/pull/2102).
|
||||
- Post extender (https://github.com/flarum/core/pull/2101).
|
||||
- Notification channel extender (https://github.com/flarum/core/pull/2432).
|
||||
- Service provider extender (https://github.com/flarum/core/pull/2437).
|
||||
- API serializer extender (https://github.com/flarum/core/pull/2438).
|
||||
- User preferences extender (https://github.com/flarum/core/pull/2463).
|
||||
- Settings extender (https://github.com/flarum/core/pull/2452).
|
||||
- ApiController extender (https://github.com/flarum/core/pull/2451).
|
||||
- Model visibility extender (https://github.com/flarum/core/pull/2460).
|
||||
- Policy extender (https://github.com/flarum/core/pull/2461).
|
||||
|
||||
### Changed
|
||||
|
||||
- Time helpers converted to Typescript (https://github.com/flarum/core/pull/2391).
|
||||
- Improved the formatter extender (https://github.com/flarum/core/pull/2098).
|
||||
- Improve wording on installer when facing file permission issues (https://github.com/flarum/core/pull/2435).
|
||||
- Background color of checkbox toggles improved for better usability (https://github.com/flarum/core/pull/2443).
|
||||
- Route resolving refactored (https://github.com/flarum/core/pull/2425).
|
||||
- Administration panel UX refactored (https://github.com/flarum/core/pull/2409).
|
||||
- Floodgate moved to middleware and extender added (https://github.com/flarum/core/pull/2170).
|
||||
- DRY up image uploading logic (https://github.com/flarum/core/pull/2477).
|
||||
- Process isolation on testing (https://github.com/flarum/core/commit/984f751c718c89501cc09857bc271efa2c7eea8c).
|
||||
- Forum and admin javascript exports namespaced (https://github.com/flarum/core/pull/2488).
|
||||
|
||||
### Fixed
|
||||
|
||||
- Web updater does not take into account subfolder installations (https://github.com/flarum/core/pull/2426).
|
||||
- Callables handling in extenders failed (https://github.com/flarum/core/pull/2423).
|
||||
- Scrolling on mobile from PostSteam changes didn't work correctly (https://github.com/flarum/core/pull/2385).
|
||||
- Side pane covers part of the discussion page due to `app.discussions` being empty (https://github.com/flarum/core/commit/102e76b084bf47fdfb4c73f95e1fbb322537f7aa).
|
||||
- Change email modal keeps showing the previous error message even on success (https://github.com/flarum/core/pull/2467).
|
||||
- Comment count not updated when discussions are deleted (https://github.com/flarum/core/pull/2472).
|
||||
- `goToIndex` in PostStream does not trigger an xhr to retrieve new data (https://github.com/flarum/core/commit/09e2736cbcc267594b660beabbd001d9030f9880).
|
||||
- On refresh the post number is reduced by one (https://github.com/flarum/core/pull/2476).
|
||||
- Queue worker would instantiate a new Queue factory, not the bound one (https://github.com/flarum/core/pull/2481).
|
||||
- Header accidentally has a border bottom (https://github.com/flarum/core/pull/2489).
|
||||
- Namespace mentioned in docblock is incorrect (https://github.com/flarum/core/pull/2494).
|
||||
- Scrolling inside longer discussions (especially Firefox) skips posts (https://github.com/flarum/core/commit/210a6b3e253d7917bd1eacd3ed8d2f95073ae99d).
|
||||
- Uploading avatars that are jpg/jpeg fails with a validation error (https://github.com/flarum/core/pull/2497).
|
||||
|
||||
### Removed
|
||||
|
||||
- MomentJS alias (https://github.com/flarum/core/pull/2428).
|
||||
- Deprecated user events `GetDisplayName` and `PrepareUserGroups` (https://github.com/flarum/core/pull/2428).
|
||||
- AssertPermissionTrait (https://github.com/flarum/core/pull/2428).
|
||||
- Path related helpers and methods in Application (https://github.com/flarum/core/pull/2428).
|
||||
- Backward compatibility layers from the frontend rewrite (https://github.com/flarum/core/pull/2428).
|
||||
|
||||
### Deprecated
|
||||
|
||||
- `CheckingForFlooding` (https://github.com/flarum/core/commit/8e25bcb68f86cc992c46dfa70368419fe9f936ac).
|
||||
|
||||
## [0.1.0-beta.14.1](https://github.com/flarum/core/compare/v0.1.0-beta.14...v0.1.0-beta.14.1)
|
||||
|
||||
### Fixed
|
||||
|
||||
- SuperTextarea component is not exported.
|
||||
- Symfony dependencies do not match those depended on by Laravel (https://github.com/flarum/core/pull/2407).
|
||||
- Scripts from textformatter aren't executed (https://github.com/flarum/core/pull/2415)
|
||||
- Sub path installations have no page title.
|
||||
- Losing focus of Composer area when coming from fullscreen.
|
||||
|
||||
## [0.1.0-beta.14](https://github.com/flarum/core/compare/v0.1.0-beta.13...v0.1.0-beta.14)
|
||||
|
||||
### Added
|
||||
|
||||
- Check dependencies before enabling / disabling extensions (https://github.com/flarum/core/pull/2188)
|
||||
- Set up temporary infrastructure for TypeScript in core (https://github.com/flarum/core/pull/2206)
|
||||
- Better UI for request error modals (https://github.com/flarum/core/pull/1929)
|
||||
- Display name extender, tests, frontend UI (https://github.com/flarum/core/pull/2174)
|
||||
- Scroll to post or show alert when editing a post from another page (https://github.com/flarum/core/pull/2108)
|
||||
- Feature to test email config by sending an email to the current user (https://github.com/flarum/core/pull/2023)
|
||||
- Allow searching users by group ID using the group gambit (https://github.com/flarum/core/pull/2192)
|
||||
- Use `liveHumanTimes` helper to update times without reload/rerender (https://github.com/flarum/core/pull/2208)
|
||||
- View extender, tests (https://github.com/flarum/core/pull/2134)
|
||||
- User extender to replace `PrepareUserGroups` (https://github.com/flarum/core/pull/2110)
|
||||
- Increase extensibility of skeleton PHP (https://github.com/flarum/core/pull/2308, https://github.com/flarum/core/pull/2318)
|
||||
- Pass a translator instance to `getEmailSubject` in `MailableInterface` (https://github.com/flarum/core/pull/2244)
|
||||
- Force LF line endings on windows (https://github.com/flarum/core/pull/2321)
|
||||
- Add a `Link` component for internal and external links (https://github.com/flarum/core/pull/2315)
|
||||
- `ConfirmDocumentUnload` component
|
||||
- Error handler middleware can now be manipulated by the middleware extender
|
||||
|
||||
### Changed
|
||||
|
||||
- Update to Mithril 2 (https://github.com/flarum/core/pull/2255)
|
||||
- Stop storing component instances (https://github.com/flarum/core/issues/1821, https://github.com/flarum/core/issues/2144)
|
||||
- Update to Laravel 6.x (https://github.com/flarum/core/issues/2055)
|
||||
- `Flarum\Foundation\Application` no longer implements `Illuminate\Contracts\Foundation\Application` (#2142)
|
||||
- `Flarum\Foundation\Application` no longer inherits `Illuminate\Container\Container` (#2142)
|
||||
- `paths` have been split off from `Flarum\Foundation\Application` into `Flarum\Foundation\Paths`, which can be injected where needed (#2142)
|
||||
- `Flarum\User\Gate` no longer implements `Illuminate\Contracts\Auth\Access\Gate` (https://github.com/flarum/core/pull/2181)
|
||||
- Improve Group Gambit performance (https://github.com/flarum/core/pull/2192)
|
||||
- Switch to `dayjs` from `momentjs` (https://github.com/flarum/core/pull/2219)
|
||||
- Don't create a `bio` column in `users` for new installations (https://github.com/flarum/core/pull/2215)
|
||||
- Start converting core JS to TypeScript (https://github.com/flarum/core/pull/2207)
|
||||
- Make Carbon an explicit dependency (https://github.com/flarum/core/commit/3b39c212e0fef7522e7d541a9214ff3817138d5d)
|
||||
- Use Symfony's translator interface instead of Laravel's (https://github.com/flarum/core/pull/2243)
|
||||
- Use newer versions of fontawesome (https://github.com/flarum/core/pull/2274)
|
||||
- Use URL generator instead of `app()->url()` where possible (https://github.com/flarum/core/pull/2302)
|
||||
- Move config from `config.php` into an injectable helper class (https://github.com/flarum/core/pull/2271)
|
||||
- Use reserved TLD for bogus and test urls (https://github.com/flarum/core/commit/6860b24b70bd04544dde90e537ce021a5fc5a689)
|
||||
- Replace `m.stream` with `flarum/utils/Stream` (https://github.com/flarum/core/pull/2316)
|
||||
- Replace `affixedSidebar` util with `AffixedSidebar` component
|
||||
- Replace `m.withAttr` with `flarum/utils/withAttr`
|
||||
- Scroll Listener is now passive, performance improvement (https://github.com/flarum/core/pull/2387)
|
||||
|
||||
### Fixed
|
||||
|
||||
- `generate:migration` command for extensions (https://github.com/flarum/core/commit/443949f7b9d7558dbc1e0994cb898cbac59bec87)
|
||||
- Container config for `UninstalledSite` (https://github.com/flarum/core/commit/ecdce44d555dd36a365fd472b2916e677ef173cf)
|
||||
- Tooltip glitch on page chang (https://github.com/flarum/core/issues/2118)
|
||||
- Using multiple extenders in tests (https://github.com/flarum/core/commit/c4f4f218bf4b175a30880b807f9ccb1a37a25330)
|
||||
- Header glitch when opening modals (https://github.com/flarum/core/pull/2131)
|
||||
- Ensure `SameSite` is explicitly set for cookies (https://github.com/flarum/core/pull/2159)
|
||||
- Ensure `Flarum\User\Event\AvatarChanged` event is properly dispatched (https://github.com/flarum/core/pull/2197)
|
||||
- Show correct error message on wrong password when changing email (https://github.com/flarum/core/pull/2171)
|
||||
- Discussion unreadCount could be higher than commentCount if posts deleted (https://github.com/flarum/core/pull/2195)
|
||||
- Don't show page title on the default route (https://github.com/flarum/core/pull/2047)
|
||||
- Add page title to `All Discussions` page when it isn't the default route (https://github.com/flarum/core/pull/2047)
|
||||
- Accept `'0'` as `false` for `flarum/components/Checkbox` (https://github.com/flarum/core/pull/2210)
|
||||
- Fix PostStreamScrubber background (https://github.com/flarum/core/pull/2222)
|
||||
- Test port on BaseUrl tests (https://github.com/flarum/core/pull/2226)
|
||||
- `UrlGenerator` can now generate urls with optional parameters (https://github.com/flarum/core/pull/2246)
|
||||
- Allow `less` to be compiled independently of Flarum (https://github.com/flarum/core/pull/2252)
|
||||
- Use correct number abbreviation (https://github.com/flarum/core/pull/2261)
|
||||
- Ensure avatar html uses alt tags for accessibility (https://github.com/flarum/core/pull/2269)
|
||||
- Escape regex when searching (https://github.com/flarum/core/pull/2273)
|
||||
- Remove unneeded semicolons inserted during JS compilation (https://github.com/flarum/core/pull/2280)
|
||||
- Don't require a username/password for SMTP (https://github.com/flarum/core/pull/2287)
|
||||
- Allow uppercase entries for SMTP encryption validation (https://github.com/flarum/core/pull/2289)
|
||||
- Ensure that the right number of posts is returned from list posts API (https://github.com/flarum/core/pull/2291)
|
||||
- Fix a variety of PostStream bugs (https://github.com/flarum/core/pull/2160, https://github.com/flarum/core/pull/2160)
|
||||
- Sliding discussion glitch on mobile (https://github.com/flarum/core/pull/2324)
|
||||
- Sliding discussion button in wrong place (https://github.com/flarum/core/pull/2330, https://github.com/flarum/core/pull/2383)
|
||||
- Sliding discussion glitch on mobile (https://github.com/flarum/core/pull/2381)
|
||||
- Fix PostStream for posts with top margins, and scrubber position when scrolling below posts (https://github.com/flarum/core/pull/2369)
|
||||
|
||||
### Removed
|
||||
|
||||
- `Flarum\Event\AbstractConfigureRoutes` event class
|
||||
- `Flarum\Event\ConfigureApiRoutes` event class
|
||||
- `Flarum\Event\ConfigureForumRoutes` event class
|
||||
- `Flarum\Console\Event\Configuring` event class
|
||||
- `Flarum\Event\ConfigureModelDates` event class
|
||||
- `Flarum\Event\ConfigureLocales` event class
|
||||
- `Flarum\Event\ConfigureModelDefaultAttributes` event class
|
||||
- `Flarum\Event\GetModelRelationship` event class
|
||||
- `Flarum\User\Event\BioChanged` event class
|
||||
- `Flarum\Database\MigrationServiceProvider` moved into `Flarum\Database\DatabaseServiceProvider`
|
||||
- Unused `admin/components/Widget` component (`admin/component/DashboardWidget` should be used instead)
|
||||
- Mandrill mail driver (https://github.com/flarum/core/commit/bca833d3f1c34d45d95bf905902368a2753b8908)
|
||||
|
||||
### Deprecated
|
||||
|
||||
- `Flarum\User\Event\GetDisplayName` event class
|
||||
- Global path helpers, `Flarum\Foundation\Application` path methods (https://github.com/flarum/core/pull/2155)
|
||||
- `Flarum\User\AssertPermissionTrait` (https://github.com/flarum/core/pull/2044)
|
||||
|
||||
## [0.1.0-beta.13](https://github.com/flarum/core/compare/v0.1.0-beta.12...v0.1.0-beta.13)
|
||||
|
||||
### Added
|
||||
- Console extender (#2057)
|
||||
- CSRF extender (#2095)
|
||||
- Event extender (#2097)
|
||||
- Mail extender (#2012)
|
||||
- Model extender (#2100)
|
||||
- Posts by users that started a discussion now have the CSS class `.Post--by-start-user`
|
||||
- PHPUnit 8 compatibility
|
||||
- Composer 2 compatibility
|
||||
- Permission groups can now be hidden (#2129)
|
||||
- Confirmation popup when hiding or deleting posts (#2135)
|
||||
|
||||
### Changed
|
||||
- Updated less.php dependency version to 3.0
|
||||
- Updated JS dependencies
|
||||
- All notifications and other emails now processed through the queue, if enabled (#978, #1928, #1931, #2096)
|
||||
- Simplified uploads, removing need to store intermediate files (#2117)
|
||||
- Improved date handling for dates older than 1 year (#2034)
|
||||
- Linting and automatic formatting for JS (#2099)
|
||||
- Translation files from Language Packs are only loaded for extensions that are enabled (#2020)
|
||||
- PHP extenders' properties are now `private` instead of `protected`, intentionally making it harder to extend these classes (#1958)
|
||||
- Preparation for upgrading Laravel components to 5.8 and then 6.0 (#2055, #2117)
|
||||
- Allowed permission checks based on model classes in addition to instances (#1977)
|
||||
|
||||
### Fixed
|
||||
- Users can no longer restore discussions hidden by admins (#2037)
|
||||
- Issues of the Modal not showing or auto hiding (#1504, #1813, #2080)
|
||||
- Columnar layout on admin extensions page was broken in Firefox (#2029, #2111)
|
||||
- Non-dismissible modals could still be dismissed using the ESC key (#1917)
|
||||
- New discussions were added to the discussion list above unread sticky posts (#1751, #1868)
|
||||
- New discussions not visible to users when using Pusher (#2076, #2077)
|
||||
- Permission icons were aligned unevenly in admin permissions list (#2016, #2018)
|
||||
- Notification bubble not inversed on mobile with colored header (#1983, #2109)
|
||||
- Post stream scrubber clicks jumped back to first post (#1945)
|
||||
- Loading state of Switch toggle component was hard to see (#2039, #1491)
|
||||
- `Flarum\Extend\Middleware`: The methods `insertBefore()` and `insertAfter()` did not work as described (#2063, #2084)
|
||||
|
||||
### Removed
|
||||
- Support for PHP 7.1 (#2014)
|
||||
- Zend compatibility bridge (#2010)
|
||||
- SES mail support (#2011)
|
||||
- Backward compatibility layer for `Flarum\Mail\DriverInterface`, new methods from beta.12 are now required
|
||||
- `Flarum\Util\Str` helper class
|
||||
- `Flarum\Event\ConfigureMiddleware` event
|
||||
|
||||
### Deprecated
|
||||
- `Flarum\Event\AbstractConfigureRoutes` event class
|
||||
- `Flarum\Event\ConfigureApiRoutes` event class
|
||||
- `Flarum\Event\ConfigureForumRoutes` event class
|
||||
- `Flarum\Event\ConfigureLocales` event class
|
||||
|
||||
## [0.1.0-beta.12](https://github.com/flarum/core/compare/v0.1.0-beta.11.1...v0.1.0-beta.12)
|
||||
|
||||
### Added
|
||||
- Full support for PHP 7.4 (#1980)
|
||||
- Mail settings: Configure region for the Mailgun driver (#1834, #1850)
|
||||
- Mail settings: Alert admins about incomplete settings (#1763, #1921)
|
||||
- New permission that allows users to post without throttling (#1255, #1938)
|
||||
- Basic transliteration of discussion "slugs" / pretty URLs (#194, #1975)
|
||||
- User profiles: Render basic content on server side (#1901)
|
||||
- New extender for configuring middleware (#1919, #1952, #1957, #1971)
|
||||
- New extender for configuring error handling (#1781, #1970)
|
||||
- Automated tests for PHP extenders to guarantee their backwards compatibility
|
||||
|
||||
### Changed
|
||||
- Profile URLs for non-existing users properly return HTTP 404 (#1846, #1901)
|
||||
- Confirmation email subject no longer contains the forum title (#1613)
|
||||
- Improved error handling during Flarum's early boot phase (#1607)
|
||||
- Updated deprecated "Zend" libraries to their new "Laminas" equivalents (#1963)
|
||||
|
||||
### Fixed
|
||||
- Update page did not work when installed in subdirectories (#1947)
|
||||
- Avatar upload did not work in IE11 / Edge (#1125, #1570)
|
||||
- Translation fallback was ignored for client-rendered pages (#1774, #1961)
|
||||
- The success alert when posting replies was invisible (#1976)
|
||||
|
||||
## [0.1.0-beta.11.1](https://github.com/flarum/core/compare/v0.1.0-beta.11...v0.1.0-beta.11.1)
|
||||
|
||||
### Fixed
|
||||
- Saving custom css in admin failed (#1946)
|
||||
|
||||
## [0.1.0-beta.11](https://github.com/flarum/core/compare/v0.1.0-beta.10...v0.1.0-beta.11)
|
||||
|
||||
### Added
|
||||
- Comments have an additional class `Post--by-actor` when posted by the user (#1927)
|
||||
|
||||
### Changed
|
||||
- Improved support for URL identification during installation (#1861)
|
||||
- KeyboardNavigatable now has a callback ability (#1922)
|
||||
- Links are no longer opened with target `_blank` but in the same window (#859)
|
||||
- Links now have `nofollow ugc` by default as their `rel` attribute (#859, #1884)
|
||||
- Improved performance of the full text gambit when searching for users (#1877)
|
||||
- The Queue implementation is now available under its Illuminate contract
|
||||
|
||||
### Fixed
|
||||
- No error handling was possible in the console/cli (#1789)
|
||||
- Enable scrollbars in log in modals so it fits for GitHub (#1716)
|
||||
- Reduce log in modal for SSO so it fits for Facebook (#1727)
|
||||
- Deleting discussions permanently did not delete its posts (#1909)
|
||||
- Fixed the queue:restart command (#1932)
|
||||
- Deleted posts were visible to all visitors (#1827)
|
||||
- Old avatars weren't being deleted when replaced (#1918)
|
||||
- The search performance regression was reverted (#1764)
|
||||
- No profile background could be set for remote images (#445)
|
||||
- Back button sends to home even though it could actually go back (#1942)
|
||||
- Debug button no longer visible (#1687)
|
||||
- Modals on smaller screens use the whole width of the page
|
||||
|
||||
## [0.1.0-beta.10](https://github.com/flarum/core/compare/v0.1.0-beta.9...v0.1.0-beta.10)
|
||||
|
||||
### Added
|
||||
- Initial queue support: Infrastructure for offloading long-running tasks (e.g. email sending) to background workers (#1773)
|
||||
- Notifications can now be marked as read without visiting a discussion (#151)
|
||||
- SEO: The discussion list now has a `rel="canonical"` meta tag, preventing duplicate content (#1134, #1814)
|
||||
- The "Edit User" permission can now be edited in the UI (#1845)
|
||||
- New status message and redirect after user deletion (#1750, #1777)
|
||||
- Errors in Flarum's boot process are now presented with more detailed information (#1607)
|
||||
|
||||
### Changed
|
||||
- Better, more detailed and extensible error handling (#1641, #1843)
|
||||
- Error pages in debug mode now return the same HTTP status codes as in production (#1648)
|
||||
- Tweak HTTP status codes for authentication / authorization errors (#1854)
|
||||
- Already-used links from account activation emails now show a better error message (#1337)
|
||||
|
||||
### Fixed
|
||||
- Security vulnerabilities in dependencies
|
||||
- Performance: High CPU usage when scrolling in a discussion (#1222)
|
||||
- Special characters crashed the search (#1498)
|
||||
- Missing declarations for language and text direction in HTML output (#1772)
|
||||
- Private messages were counted in user post counts (#1695)
|
||||
- Extensions could not change the forum's default page (#1819)
|
||||
- API requests authenticated using access tokens needed to provide a CSRF token (#1828)
|
||||
- Accessibility: Screenreaders did not read the "Back to discussion list" link (#1835)
|
||||
|
||||
## [0.1.0-beta.9](https://github.com/flarum/core/compare/v0.1.0-beta.8.2...v0.1.0-beta.9)
|
||||
|
||||
### Added
|
||||
- New `hasPermission()` helper method for `Group` objects ([9684fbc](https://github.com/flarum/core/commit/9684fbc4da07d32aa322d9228302a23418412cb9))
|
||||
- Expose supported mail drivers in IoC container ([208bad3](https://github.com/flarum/core/commit/208bad393f37bfdb76007afcddfa4b7451563e9d))
|
||||
- More test for some API endpoints ([1670590](https://github.com/flarum/core/commit/167059027e5a066d618599c90164ef1b5a509148))
|
||||
- The `Formatter\Rendering` event now receives the HTTP request instance as well ([0ab9fac](https://github.com/flarum/core/commit/0ab9facc4bd59a260575e6fc650793c663e5866a))
|
||||
- More and better validation in installer UIs
|
||||
- Check and enforce minimum MariaDB ([7ff9a90](https://github.com/flarum/core/commit/7ff9a90204923293adc520d3c02dc984845d4f9f))
|
||||
- Revert publication of assets when installation fails ([ed9591c](https://github.com/flarum/core/commit/ed9591c16fb2ea7a4be3387b805d855a53e0a7d5))
|
||||
- Benefit from Laravel's database reconnection logic in long-running tasks ([e0becd0](https://github.com/flarum/core/commit/e0becd0c7bda939048923c1f86648793feee78d5))
|
||||
- The "vendor path" (where Composer dependencies can be found) can now be configured ([5e1680c](https://github.com/flarum/core/commit/5e1680c458cd3ba274faeb92de3ac2053789131e))
|
||||
|
||||
### Changed
|
||||
- Performance: Actually cache translations on disk ([0d16fac](https://github.com/flarum/core/commit/0d16fac001bb735ee66e82871183516aeac269b7))
|
||||
- Allow per-site extenders to override extension extenders ([ba594de](https://github.com/flarum/core/commit/ba594de13a033480834d53d73f747b05fe9796f8))
|
||||
- Do not resolve objects from the IoC container (in service providers and extenders) until they are actually used
|
||||
- Replace event subscribers (that resolve objects from the IoC container) with listeners (that resolve lazily)
|
||||
- Use custom service provider for Mail component ([ac5e26a](https://github.com/flarum/core/commit/ac5e26a254d89e21bd4c115b6cbd40338e2e4b4b))
|
||||
- Update to Laravel 5.7, revert custom logic for building database index names
|
||||
- Refactored installer, extracted Installation class and pipeline for reuse in CLI and web installers ([790d5be](https://github.com/flarum/core/commit/790d5beee5e283178716bc8f9901c758d9e5b6a0))
|
||||
- Use whitelist for enabling pre-installed extensions during installation ([4585f03](https://github.com/flarum/core/commit/4585f03ee356c92942fbc2ae8c683c651b473954))
|
||||
- Update minimum MySQL version ([7ff9a90](https://github.com/flarum/core/commit/7ff9a90204923293adc520d3c02dc984845d4f9f))
|
||||
|
||||
### Fixed
|
||||
- Signing up via OAuth providers was broken ([67f9375](https://github.com/flarum/core/commit/67f9375d4745add194ae3249d526197c32fd5461))
|
||||
- Group badges were overlapping ([16eb1fa](https://github.com/flarum/core/commit/16eb1fa63b6d7b80ec30c24c0e406a2b7ab09934))
|
||||
- API: Endpoint for uninstalling extensions returned an error ([c761802](https://github.com/flarum/core/commit/c76180290056ddbab67baf5ede814fcedf1dcf14))
|
||||
- Documentation links in installer were outdated ([b58380e](https://github.com/flarum/core/commit/b58380e224ee54abdade3d0a4cc107ef5c91c9a9))
|
||||
- Event posts where counted when aggregating user posts ([671fdec](https://github.com/flarum/core/commit/671fdec8d0a092ccceb5d4d5f657d0f4287fc4c7))
|
||||
- Admins could not reset user passwords ([c67fb2d](https://github.com/flarum/core/commit/c67fb2d4b6a128c71d65dc6703310c0b62f91be2))
|
||||
- Several down migrations were invalid
|
||||
- Validation errors on reset password page resulted in HTTP 404 ([4611abe](https://github.com/flarum/core/commit/4611abe5db8b94ca3dc7bf9c447fca7c67358ee3))
|
||||
- `is:unread` gambit generated an invalid query ([e17bb0b](https://github.com/flarum/core/commit/e17bb0b4331f2c92459292195c6b7db8cde1f9f3))
|
||||
- Entire forum was breaking when the `custom_less` setting was missing from the database ([bf2c5a5](https://github.com/flarum/core/commit/bf2c5a5564dff3f5ef13efe7a8d69f2617570ce6))
|
||||
- Dropdown icon was not showing in user card when on user page ([12fdfc9](https://github.com/flarum/core/commit/12fdfc9b544a27f6fe59c82ad6bddd3420cc0181))
|
||||
- Requests were missing the `original*` attributes, which broke installations in subfolders ([56fde28](https://github.com/flarum/core/commit/56fde28e436f52fee0c03c538f0a6049bc584b53))
|
||||
- Special characters such as `%` and `_` could return incorrect results ([ee3640e](https://github.com/flarum/core/commit/ee3640e1605ff67fef4b3d5cd0596f14a6ae73c9))
|
||||
- FontAwesome component package changed paths in version 5.9.0 ([5eb69e1](https://github.com/flarum/core/commit/5eb69e1f59fa73fdfd5badbf41a05a6a040e7426))
|
||||
- Some server environments had problems accessing the system-wide tmp path for storing JS file maps ([54660eb](https://github.com/flarum/core/commit/54660ebd6311f9ea142f1b573263d0d907400786))
|
||||
- Content length of posts.content was not migrated to mediumText in 2017 ([590b311](https://github.com/flarum/core/commit/590b3115708bf94a9c7f169d98c6126380c7056e))
|
||||
- An error occurred when going to the previous route if there was no previous route found ([985b87da](https://github.com/flarum/core/commit/985b87da6c9942c568a1a192e2fdcfde72e030ee))
|
||||
|
||||
### Removed
|
||||
- `php flarum install --defaults` - this was meant to be used in our old development VM ([44c9109](https://github.com/flarum/core/commit/44c91099cd77138bb5fc29f14fb1e81a9781272d))
|
||||
- Obsolete `id` attributes in JSON-API responses ([ecc3b5e](https://github.com/flarum/core/commit/ecc3b5e2271f8d9b38d52cd54476d86995dbe32e) and [7a44086](https://github.com/flarum/core/commit/7a44086bf3a0e3ba907dceb13d07ac695eca05ea))
|
||||
|
||||
## [0.1.0-beta.8.1](https://github.com/flarum/core/compare/v0.1.0-beta.8...v0.1.0-beta.8.1)
|
||||
|
||||
### Fixed
|
||||
- Fix live output in `migrate:reset` command ([f591585](https://github.com/flarum/core/commit/f591585d02f8c4ff0211c5bf4413dd6baa724c05))
|
||||
- Fix search with database prefix ([7705a2b](https://github.com/flarum/core/commit/7705a2b7d751943ef9d0c7379ec34f8530b99310))
|
||||
- Fix invalid join time of admin user created by installer ([57f73c9](https://github.com/flarum/core/commit/57f73c9638eeb825f9e336ed3c443afccfd8995e))
|
||||
- Ensure InnoDB engine is used for all tables ([fb6b51b](https://github.com/flarum/core/commit/fb6b51b1cfef0af399607fe038603c8240800b2b), [6370f7e](https://github.com/flarum/core/commit/6370f7ecffa9ea7d5fb64d9551400edbc63318db))
|
||||
- Fix dropping foreign keys in `down` migrations ([57d5846](https://github.com/flarum/core/commit/57d5846b647881009d9e60f9ffca20b1bb77776e))
|
||||
- Fix discussion list scroll position not being maintained when hero is not visible ([40dc6ac](https://github.com/flarum/core/commit/40dc6ac604c2a0973356b38217aa8d09352daae5))
|
||||
- Fix empty meta description tag ([88e43cc](https://github.com/flarum/core/commit/88e43cc6940ee30d6529e9ce659471ec4fb1c474))
|
||||
- Remove empty attributes on `<html>` tag ([796b577](https://github.com/flarum/core/commit/796b57753d34d4ea741dbebcbc550b17808f6c94))
|
3
LICENSE
3
LICENSE
@@ -1,7 +1,6 @@
|
||||
The MIT License (MIT)
|
||||
|
||||
Copyright (c) 2019-2020 Stichting Flarum (Flarum Foundation)
|
||||
Copyright (c) 2014-2019 Toby Zerner (toby.zerner@gmail.com)
|
||||
Copyright (c) 2014-2017 Toby Zerner
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
|
35
README.md
35
README.md
@@ -1,36 +1,7 @@
|
||||
<p align="center"><img src="https://flarum.org/assets/img/logo.png"></p>
|
||||
# Flarum Core
|
||||
|
||||
<p align="center">
|
||||
<a href="https://github.com/flarum/core/actions?query=workflow%3ATests"><img src="https://github.com/flarum/core/workflows/Tests/badge.svg" alt="PHP Tests"></a>
|
||||
<a href="https://packagist.org/packages/flarum/core"><img src="https://img.shields.io/packagist/dt/flarum/core" alt="Total Downloads"></a>
|
||||
<a href="https://packagist.org/packages/flarum/core"><img src="https://img.shields.io/github/v/release/flarum/core?sort=semver" alt="Latest Version"></a>
|
||||
<a href="https://packagist.org/packages/flarum/core"><img src="https://img.shields.io/packagist/l/flarum/core" alt="License"></a>
|
||||
<a href="https://github.styleci.io/repos/28257573"><img src="https://github.styleci.io/repos/28257573/shield?style=flat" alt="StyleCI"></a>
|
||||
</p>
|
||||
|
||||
|
||||
## About Flarum
|
||||
|
||||
**[Flarum](https://flarum.org/) is a delightfully simple discussion platform for your website.** It's fast and easy to use, with all the features you need to run a successful community. It is designed to be:
|
||||
|
||||
* **Fast and simple.** No clutter, no bloat, no complex dependencies. Flarum is built with PHP so it’s quick and easy to deploy. The interface is powered by Mithril, a performant JavaScript framework with a tiny footprint.
|
||||
|
||||
* **Beautiful and responsive.** This is forum software for humans. Flarum is carefully designed to be consistent and intuitive across platforms, out-of-the-box.
|
||||
|
||||
* **Powerful and extensible.** Customize, extend, and integrate Flarum to suit your community. Flarum’s architecture is amazingly flexible, with a powerful Extension API.
|
||||
|
||||
## Installation
|
||||
|
||||
This repository contains Flarum's core code. If you want to set up a forum, visit the [Flarum skeleton repository](https://github.com/flarum/flarum).
|
||||
This repository contains Flarum's core code. If you want to set up a forum, visit the [main Flarum repository](http://github.com/flarum/flarum).
|
||||
|
||||
## Contributing
|
||||
|
||||
Thank you for considering contributing to Flarum! Please read the **[Contributing guide](https://flarum.org/docs/contributing.html)** to learn how you can help.
|
||||
|
||||
## Security Vulnerabilities
|
||||
|
||||
If you discover a security vulnerability within Flarum, please send an e-mail to [security@flarum.org](mailto:security@flarum.org). All security vulnerabilities will be promptly addressed. More details can be found in our [security policy](https://github.com/flarum/core/security/policy).
|
||||
|
||||
## License
|
||||
|
||||
Flarum is open-source software licensed under the [MIT License](https://github.com/flarum/flarum/blob/master/LICENSE).
|
||||
Flarum is open-source and we would love your help building it! Please read the [Contributing Guide](https://github.com/flarum/flarum/blob/master/CONTRIBUTING.md) to learn how you can help.
|
||||
|
111
composer.json
111
composer.json
@@ -1,109 +1,80 @@
|
||||
{
|
||||
"name": "flarum/core",
|
||||
"description": "Delightfully simple forum software.",
|
||||
"keywords": [
|
||||
"forum",
|
||||
"discussion"
|
||||
],
|
||||
"homepage": "https://flarum.org/",
|
||||
"keywords": ["forum", "discussion"],
|
||||
"homepage": "http://flarum.org",
|
||||
"license": "MIT",
|
||||
"authors": [
|
||||
{
|
||||
"name": "Flarum",
|
||||
"email": "info@flarum.org",
|
||||
"homepage": "https://flarum.org/team"
|
||||
"name": "Toby Zerner",
|
||||
"email": "toby.zerner@gmail.com"
|
||||
},
|
||||
{
|
||||
"name": "Franz Liedke",
|
||||
"email": "franz@develophp.org"
|
||||
}
|
||||
],
|
||||
"support": {
|
||||
"issues": "https://github.com/flarum/core/issues",
|
||||
"source": "https://github.com/flarum/core",
|
||||
"docs": "https://flarum.org/docs/"
|
||||
"docs": "http://flarum.org/docs"
|
||||
},
|
||||
"require": {
|
||||
"php": ">=7.3",
|
||||
"axy/sourcemap": "^0.1.4",
|
||||
"components/font-awesome": "^5.14.0",
|
||||
"dflydev/fig-cookies": "^3.0.0",
|
||||
"doctrine/dbal": "^2.7",
|
||||
"franzl/whoops-middleware": "^2.0.0",
|
||||
"illuminate/bus": "^8.0",
|
||||
"illuminate/cache": "^8.0",
|
||||
"illuminate/config": "^8.0",
|
||||
"illuminate/container": "^8.0",
|
||||
"illuminate/contracts": "^8.0",
|
||||
"illuminate/database": "^8.0",
|
||||
"illuminate/events": "^8.0",
|
||||
"illuminate/filesystem": "^8.0",
|
||||
"illuminate/hashing": "^8.0",
|
||||
"illuminate/mail": "^8.0",
|
||||
"illuminate/queue": "^8.0",
|
||||
"illuminate/session": "^8.0",
|
||||
"illuminate/support": "^8.0",
|
||||
"illuminate/validation": "^8.0",
|
||||
"illuminate/view": "^8.0",
|
||||
"intervention/image": "^2.5.0",
|
||||
"laminas/laminas-diactoros": "^2.4.1",
|
||||
"laminas/laminas-httphandlerrunner": "^1.2.0",
|
||||
"laminas/laminas-stratigility": "^3.2.2",
|
||||
"php": ">=5.6.0",
|
||||
"dflydev/fig-cookies": "^1.0.2",
|
||||
"doctrine/dbal": "^2.5",
|
||||
"components/font-awesome": "^4.6",
|
||||
"franzl/whoops-middleware": "^0.4.0",
|
||||
"illuminate/bus": "5.1.*",
|
||||
"illuminate/cache": "5.1.*",
|
||||
"illuminate/config": "5.1.*",
|
||||
"illuminate/container": "5.1.*",
|
||||
"illuminate/contracts": "5.1.*",
|
||||
"illuminate/database": "^5.1.31",
|
||||
"illuminate/events": "5.1.*",
|
||||
"illuminate/filesystem": "5.1.*",
|
||||
"illuminate/hashing": "5.1.*",
|
||||
"illuminate/mail": "5.1.*",
|
||||
"illuminate/support": "5.1.*",
|
||||
"illuminate/validation": "5.1.*",
|
||||
"illuminate/view": "5.1.*",
|
||||
"intervention/image": "^2.3.0",
|
||||
"league/flysystem": "^1.0.11",
|
||||
"league/oauth2-client": "~1.0",
|
||||
"matthiasmullie/minify": "^1.3",
|
||||
"middlewares/base-path": "^2.0.1",
|
||||
"middlewares/base-path-router": "^2.0.1",
|
||||
"middlewares/request-handler": "^2.0.1",
|
||||
"monolog/monolog": "^1.16.0",
|
||||
"nesbot/carbon": "^2.0",
|
||||
"nikic/fast-route": "^0.6",
|
||||
"oyejorge/less.php": "~1.5",
|
||||
"psr/http-message": "^1.0",
|
||||
"psr/http-server-handler": "^1.0",
|
||||
"psr/http-server-middleware": "^1.0",
|
||||
"s9e/text-formatter": "^2.3.6",
|
||||
"symfony/config": "^5.2.2",
|
||||
"symfony/console": "^5.2.2",
|
||||
"symfony/event-dispatcher": "^5.2.2",
|
||||
"symfony/mime": "^5.2.0",
|
||||
"symfony/translation": "^5.1.5",
|
||||
"symfony/yaml": "^5.2.2",
|
||||
"symfony/console": "^2.7",
|
||||
"symfony/http-foundation": "^2.7",
|
||||
"symfony/translation": "^2.7",
|
||||
"symfony/yaml": "^2.7",
|
||||
"s9e/text-formatter": "^0.8.1",
|
||||
"tobscure/json-api": "^0.3.0",
|
||||
"wikimedia/less.php": "^3.0"
|
||||
"zendframework/zend-diactoros": "^1.1",
|
||||
"zendframework/zend-stratigility": "^1.3"
|
||||
},
|
||||
"require-dev": {
|
||||
"flarum/testing": "^0.1.0-beta.16"
|
||||
"mockery/mockery": "^0.9.4",
|
||||
"phpunit/phpunit": "^4.8"
|
||||
},
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"Flarum\\": "src/"
|
||||
},
|
||||
"files": [
|
||||
"src/helpers.php",
|
||||
"src/TranslatorInterface.php"
|
||||
"src/helpers.php"
|
||||
]
|
||||
},
|
||||
"autoload-dev": {
|
||||
"psr-4": {
|
||||
"Flarum\\Tests\\": "tests/"
|
||||
"Tests\\": "tests/"
|
||||
}
|
||||
},
|
||||
"config": {
|
||||
"sort-packages": true
|
||||
},
|
||||
"extra": {
|
||||
"branch-alias": {
|
||||
"dev-master": "0.1.x-dev"
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"test": [
|
||||
"@test:unit",
|
||||
"@test:integration"
|
||||
],
|
||||
"test:unit": "phpunit -c tests/phpunit.unit.xml",
|
||||
"test:integration": "phpunit -c tests/phpunit.integration.xml",
|
||||
"test:setup": "@php tests/integration/setup.php"
|
||||
},
|
||||
"scripts-descriptions": {
|
||||
"test": "Runs all tests.",
|
||||
"test:unit": "Runs all unit tests.",
|
||||
"test:integration": "Runs all integration tests.",
|
||||
"test:setup": "Sets up a database for use with integration tests. Execute this only once."
|
||||
}
|
||||
}
|
||||
|
13
error/403.html
Normal file
13
error/403.html
Normal file
@@ -0,0 +1,13 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head lang="en">
|
||||
<meta charset="UTF-8">
|
||||
<title></title>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<h1>403 Forbidden</h1>
|
||||
<p>You do not have permissions to access this page.</p>
|
||||
|
||||
</body>
|
||||
</html>
|
13
error/404.html
Normal file
13
error/404.html
Normal file
@@ -0,0 +1,13 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head lang="en">
|
||||
<meta charset="UTF-8">
|
||||
<title></title>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<h1>404 Not Found</h1>
|
||||
<p>Looks like this page could not be found.</p>
|
||||
|
||||
</body>
|
||||
</html>
|
13
error/500.html
Normal file
13
error/500.html
Normal file
@@ -0,0 +1,13 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head lang="en">
|
||||
<meta charset="UTF-8">
|
||||
<title></title>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<h1>500 Internal Server Error</h1>
|
||||
<p>Something went wrong on our server.</p>
|
||||
|
||||
</body>
|
||||
</html>
|
13
error/503.html
Normal file
13
error/503.html
Normal file
@@ -0,0 +1,13 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head lang="en">
|
||||
<meta charset="UTF-8">
|
||||
<title></title>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<h1>503 Service Unavailable</h1>
|
||||
<p>This forum is down for maintenance.</p>
|
||||
|
||||
</body>
|
||||
</html>
|
@@ -1,6 +0,0 @@
|
||||
{
|
||||
"printWidth": 150,
|
||||
"singleQuote": true,
|
||||
"tabWidth": 2,
|
||||
"trailingComma": "es5"
|
||||
}
|
1
js/admin/.gitignore
vendored
Normal file
1
js/admin/.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
||||
dist
|
53
js/admin/src/AdminApplication.tsx
Normal file
53
js/admin/src/AdminApplication.tsx
Normal file
@@ -0,0 +1,53 @@
|
||||
import * as m from 'mithril';
|
||||
import Application from './lib/Application';
|
||||
import routes from './routes';
|
||||
import Nav from './components/Nav';
|
||||
|
||||
export default class AdminApplication extends Application {
|
||||
/**
|
||||
* A map of extension names to their settings callbacks.
|
||||
*
|
||||
* @type {Object}
|
||||
*/
|
||||
extensionSettings = {};
|
||||
|
||||
/**
|
||||
* Construct a list of permissions required to have the given permission.
|
||||
*
|
||||
* @param {String} permission
|
||||
* @return {Array}
|
||||
*/
|
||||
getRequiredPermissions(permission) {
|
||||
const required = [];
|
||||
|
||||
if (permission === 'startDiscussion' || permission.indexOf('discussion.') === 0) {
|
||||
required.push('viewDiscussions');
|
||||
}
|
||||
if (permission === 'discussion.delete') {
|
||||
required.push('discussion.hide');
|
||||
}
|
||||
if (permission === 'discussion.deletePosts') {
|
||||
required.push('discussion.editPosts');
|
||||
}
|
||||
|
||||
return required;
|
||||
}
|
||||
|
||||
/**
|
||||
* @inheritdoc
|
||||
*/
|
||||
mount() {
|
||||
m.route.prefix('#');
|
||||
|
||||
super.mount();
|
||||
|
||||
m.mount(document.getElementById('nav'), <Nav/>);
|
||||
}
|
||||
|
||||
/**
|
||||
* @inheritdoc
|
||||
*/
|
||||
registerDefaultRoutes(router) {
|
||||
routes(router);
|
||||
}
|
||||
}
|
40
js/admin/src/components/AddExtensionModal.tsx
Normal file
40
js/admin/src/components/AddExtensionModal.tsx
Normal file
@@ -0,0 +1,40 @@
|
||||
/*
|
||||
* This file is part of Flarum.
|
||||
*
|
||||
* (c) Toby Zerner <toby.zerner@gmail.com>
|
||||
*
|
||||
* For the full copyright and license information, please view the LICENSE
|
||||
* file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
import flarum from 'flarum';
|
||||
import Modal from 'flarum/lib/components/Modal';
|
||||
|
||||
export default class AddExtensionModal extends Modal {
|
||||
/**
|
||||
* @inheritdoc
|
||||
*/
|
||||
className() {
|
||||
return super.className() + ' AddExtensionModal Modal--small';
|
||||
}
|
||||
|
||||
/**
|
||||
* @inheritdoc
|
||||
*/
|
||||
title() {
|
||||
return flarum.translator.trans('admin.add_extension.title');
|
||||
}
|
||||
|
||||
/**
|
||||
* @inheritdoc
|
||||
*/
|
||||
content() {
|
||||
return (
|
||||
<div className="Modal-body">
|
||||
<p>{flarum.translator.trans('admin.add_extension.temporary_text')}</p>
|
||||
<p>{flarum.translator.trans('admin.add_extension.install_text', {a: <a href="https://discuss.flarum.org/t/extensions" target="_blank"/>})}</p>
|
||||
<p>{flarum.translator.trans('admin.add_extension.developer_text', {a: <a href="http://flarum.org/docs/extend" target="_blank"/>})}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
33
js/admin/src/components/AdminLinkButton.tsx
Normal file
33
js/admin/src/components/AdminLinkButton.tsx
Normal file
@@ -0,0 +1,33 @@
|
||||
/*
|
||||
* This file is part of Flarum.
|
||||
*
|
||||
* (c) Toby Zerner <toby.zerner@gmail.com>
|
||||
*
|
||||
* For the full copyright and license information, please view the LICENSE
|
||||
* file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
import LinkButton from 'flarum/lib/components/LinkButton';
|
||||
|
||||
/**
|
||||
*
|
||||
*/
|
||||
export default class AdminLinkButton extends LinkButton {
|
||||
/**
|
||||
* @inheritdoc
|
||||
*/
|
||||
className() {
|
||||
return super.className() + ' AdminLinkButton';
|
||||
}
|
||||
|
||||
/**
|
||||
* @inheritdoc
|
||||
*/
|
||||
content() {
|
||||
const content = super.content();
|
||||
|
||||
content.push(<div className="AdminLinkButton-description">{this.attrs.description}</div>);
|
||||
|
||||
return content;
|
||||
}
|
||||
}
|
52
js/admin/src/components/AdminNav.tsx
Normal file
52
js/admin/src/components/AdminNav.tsx
Normal file
@@ -0,0 +1,52 @@
|
||||
/*
|
||||
* This file is part of Flarum.
|
||||
*
|
||||
* (c) Toby Zerner <toby.zerner@gmail.com>
|
||||
*
|
||||
* For the full copyright and license information, please view the LICENSE
|
||||
* file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
import Component from 'flarum/lib/Component';
|
||||
import ItemList from 'flarum/lib/utils/ItemList';
|
||||
import AdminLinkButton from 'flarum/components/AdminLinkButton';
|
||||
import SelectDropdown from 'flarum/components/SelectDropdown';
|
||||
|
||||
function addLink(items, route, icon) {
|
||||
items.add(route, <AdminLinkButton
|
||||
href={flarum.router.to(route)}
|
||||
icon={icon}
|
||||
children={flarum.translator.trans(`admin.nav.${route}_button`)}
|
||||
description={flarum.translator.trans(`admin.nav.${route}_text`)}/>);
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
*/
|
||||
export default class Nav extends Component {
|
||||
/**
|
||||
* @inheritdoc
|
||||
*/
|
||||
view() {
|
||||
return <SelectDropdown className="Nav" buttonClassName="Button" children={this.items().toArray()}/>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build an item list of links to show in the admin navigation.
|
||||
*
|
||||
* @return {ItemList}
|
||||
* @public
|
||||
*/
|
||||
items() {
|
||||
const items = new ItemList();
|
||||
|
||||
addLink(items, 'dashboard', 'bar-chart'));
|
||||
addLink(items, 'basics', 'pencil'));
|
||||
addLink(items, 'mail', 'envelope'));
|
||||
addLink(items, 'permissions', 'key'));
|
||||
addLink(items, 'appearance', 'paint-brush'));
|
||||
addLink(items, 'extensions', 'puzzle-piece'));
|
||||
|
||||
return items;
|
||||
}
|
||||
}
|
119
js/admin/src/components/AppearancePage.tsx
Normal file
119
js/admin/src/components/AppearancePage.tsx
Normal file
@@ -0,0 +1,119 @@
|
||||
import Page from 'flarum/components/Page';
|
||||
import Button from 'flarum/components/Button';
|
||||
import Switch from 'flarum/components/Switch';
|
||||
import EditCustomCssModal from 'flarum/components/EditCustomCssModal';
|
||||
import EditCustomHeaderModal from 'flarum/components/EditCustomHeaderModal';
|
||||
import UploadImageButton from 'flarum/components/UploadImageButton';
|
||||
import saveSettings from 'flarum/utils/saveSettings';
|
||||
|
||||
export default class AppearancePage extends Page {
|
||||
init() {
|
||||
super.init();
|
||||
|
||||
this.primaryColor = m.prop(app.data.settings.theme_primary_color);
|
||||
this.secondaryColor = m.prop(app.data.settings.theme_secondary_color);
|
||||
this.darkMode = m.prop(app.data.settings.theme_dark_mode === '1');
|
||||
this.coloredHeader = m.prop(app.data.settings.theme_colored_header === '1');
|
||||
}
|
||||
|
||||
view() {
|
||||
return (
|
||||
<div className="AppearancePage">
|
||||
<div className="container">
|
||||
<form onsubmit={this.onsubmit.bind(this)}>
|
||||
<fieldset className="AppearancePage-colors">
|
||||
<legend>{app.translator.trans('core.admin.appearance.colors_heading')}</legend>
|
||||
<div className="helpText">
|
||||
{app.translator.trans('core.admin.appearance.colors_text')}
|
||||
</div>
|
||||
|
||||
<div className="AppearancePage-colors-input">
|
||||
<input className="FormControl" type="color" placeholder="#aaaaaa" value={this.primaryColor()} onchange={m.withAttr('value', this.primaryColor)}/>
|
||||
<input className="FormControl" type="color" placeholder="#aaaaaa" value={this.secondaryColor()} onchange={m.withAttr('value', this.secondaryColor)}/>
|
||||
</div>
|
||||
|
||||
{Switch.component({
|
||||
state: this.darkMode(),
|
||||
children: app.translator.trans('core.admin.appearance.dark_mode_label'),
|
||||
onchange: this.darkMode
|
||||
})}
|
||||
|
||||
{Switch.component({
|
||||
state: this.coloredHeader(),
|
||||
children: app.translator.trans('core.admin.appearance.colored_header_label'),
|
||||
onchange: this.coloredHeader
|
||||
})}
|
||||
|
||||
{Button.component({
|
||||
className: 'Button Button--primary',
|
||||
type: 'submit',
|
||||
children: app.translator.trans('core.admin.appearance.submit_button'),
|
||||
loading: this.loading
|
||||
})}
|
||||
</fieldset>
|
||||
</form>
|
||||
|
||||
<fieldset>
|
||||
<legend>{app.translator.trans('core.admin.appearance.logo_heading')}</legend>
|
||||
<div className="helpText">
|
||||
{app.translator.trans('core.admin.appearance.logo_text')}
|
||||
</div>
|
||||
<UploadImageButton name="logo"/>
|
||||
</fieldset>
|
||||
|
||||
<fieldset>
|
||||
<legend>{app.translator.trans('core.admin.appearance.favicon_heading')}</legend>
|
||||
<div className="helpText">
|
||||
{app.translator.trans('core.admin.appearance.favicon_text')}
|
||||
</div>
|
||||
<UploadImageButton name="favicon"/>
|
||||
</fieldset>
|
||||
|
||||
<fieldset>
|
||||
<legend>{app.translator.trans('core.admin.appearance.custom_header_heading')}</legend>
|
||||
<div className="helpText">
|
||||
{app.translator.trans('core.admin.appearance.custom_header_text')}
|
||||
</div>
|
||||
{Button.component({
|
||||
className: 'Button',
|
||||
children: app.translator.trans('core.admin.appearance.edit_header_button'),
|
||||
onclick: () => app.modal.show(new EditCustomHeaderModal())
|
||||
})}
|
||||
</fieldset>
|
||||
|
||||
<fieldset>
|
||||
<legend>{app.translator.trans('core.admin.appearance.custom_styles_heading')}</legend>
|
||||
<div className="helpText">
|
||||
{app.translator.trans('core.admin.appearance.custom_styles_text')}
|
||||
</div>
|
||||
{Button.component({
|
||||
className: 'Button',
|
||||
children: app.translator.trans('core.admin.appearance.edit_css_button'),
|
||||
onclick: () => app.modal.show(new EditCustomCssModal())
|
||||
})}
|
||||
</fieldset>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
onsubmit(e) {
|
||||
e.preventDefault();
|
||||
|
||||
const hex = /^#[0-9a-f]{3}([0-9a-f]{3})?$/i;
|
||||
|
||||
if (!hex.test(this.primaryColor()) || !hex.test(this.secondaryColor())) {
|
||||
alert(app.translator.trans('core.admin.appearance.enter_hex_message'));
|
||||
return;
|
||||
}
|
||||
|
||||
this.loading = true;
|
||||
|
||||
saveSettings({
|
||||
theme_primary_color: this.primaryColor(),
|
||||
theme_secondary_color: this.secondaryColor(),
|
||||
theme_dark_mode: this.darkMode(),
|
||||
theme_colored_header: this.coloredHeader()
|
||||
}).then(() => window.location.reload());
|
||||
}
|
||||
}
|
169
js/admin/src/components/BasicsPage.tsx
Normal file
169
js/admin/src/components/BasicsPage.tsx
Normal file
@@ -0,0 +1,169 @@
|
||||
import Page from 'flarum/components/Page';
|
||||
import FieldSet from 'flarum/components/FieldSet';
|
||||
import Select from 'flarum/components/Select';
|
||||
import Button from 'flarum/components/Button';
|
||||
import Alert from 'flarum/components/Alert';
|
||||
import saveSettings from 'flarum/utils/saveSettings';
|
||||
import ItemList from 'flarum/utils/ItemList';
|
||||
import Switch from 'flarum/components/Switch';
|
||||
|
||||
export default class BasicsPage extends Page {
|
||||
init() {
|
||||
super.init();
|
||||
|
||||
this.loading = false;
|
||||
|
||||
this.fields = [
|
||||
'forum_title',
|
||||
'forum_description',
|
||||
'default_locale',
|
||||
'show_language_selector',
|
||||
'default_route',
|
||||
'welcome_title',
|
||||
'welcome_message'
|
||||
];
|
||||
this.values = {};
|
||||
|
||||
const settings = app.data.settings;
|
||||
this.fields.forEach(key => this.values[key] = m.prop(settings[key] || false));
|
||||
|
||||
this.localeOptions = {};
|
||||
const locales = app.data.locales;
|
||||
for (const i in locales) {
|
||||
this.localeOptions[i] = `${locales[i]} (${i})`;
|
||||
}
|
||||
|
||||
if (typeof this.values.show_language_selector() !== "number") this.values.show_language_selector(1);
|
||||
}
|
||||
|
||||
view() {
|
||||
return (
|
||||
<div className="BasicsPage">
|
||||
<div className="container">
|
||||
<form onsubmit={this.onsubmit.bind(this)}>
|
||||
{FieldSet.component({
|
||||
label: app.translator.trans('core.admin.basics.forum_title_heading'),
|
||||
children: [
|
||||
<input className="FormControl" value={this.values.forum_title()} oninput={m.withAttr('value', this.values.forum_title)}/>
|
||||
]
|
||||
})}
|
||||
|
||||
{FieldSet.component({
|
||||
label: app.translator.trans('core.admin.basics.forum_description_heading'),
|
||||
children: [
|
||||
<div className="helpText">
|
||||
{app.translator.trans('core.admin.basics.forum_description_text')}
|
||||
</div>,
|
||||
<textarea className="FormControl" value={this.values.forum_description()} oninput={m.withAttr('value', this.values.forum_description)}/>
|
||||
]
|
||||
})}
|
||||
|
||||
{Object.keys(this.localeOptions).length > 1
|
||||
? FieldSet.component({
|
||||
label: app.translator.trans('core.admin.basics.default_language_heading'),
|
||||
children: [
|
||||
Select.component({
|
||||
options: this.localeOptions,
|
||||
value: this.values.default_locale(),
|
||||
onchange: this.values.default_locale
|
||||
})
|
||||
]
|
||||
})
|
||||
: ''}
|
||||
|
||||
{Switch.component({
|
||||
state: this.values.show_language_selector(),
|
||||
onchange: this.values.show_language_selector,
|
||||
children: app.translator.trans('core.admin.basics.show_language_selector_label'),
|
||||
})}
|
||||
|
||||
<br/>
|
||||
|
||||
{FieldSet.component({
|
||||
label: app.translator.trans('core.admin.basics.home_page_heading'),
|
||||
className: 'BasicsPage-homePage',
|
||||
children: [
|
||||
<div className="helpText">
|
||||
{app.translator.trans('core.admin.basics.home_page_text')}
|
||||
</div>,
|
||||
this.homePageItems().toArray().map(({path, label}) =>
|
||||
<label className="checkbox">
|
||||
<input type="radio" name="homePage" value={path} checked={this.values.default_route() === path} onclick={m.withAttr('value', this.values.default_route)}/>
|
||||
{label}
|
||||
</label>
|
||||
)
|
||||
]
|
||||
})}
|
||||
|
||||
{FieldSet.component({
|
||||
label: app.translator.trans('core.admin.basics.welcome_banner_heading'),
|
||||
className: 'BasicsPage-welcomeBanner',
|
||||
children: [
|
||||
<div className="helpText">
|
||||
{app.translator.trans('core.admin.basics.welcome_banner_text')}
|
||||
</div>,
|
||||
<div className="BasicsPage-welcomeBanner-input">
|
||||
<input className="FormControl" value={this.values.welcome_title()} oninput={m.withAttr('value', this.values.welcome_title)}/>
|
||||
<textarea className="FormControl" value={this.values.welcome_message()} oninput={m.withAttr('value', this.values.welcome_message)}/>
|
||||
</div>
|
||||
]
|
||||
})}
|
||||
|
||||
{Button.component({
|
||||
type: 'submit',
|
||||
className: 'Button Button--primary',
|
||||
children: app.translator.trans('core.admin.basics.submit_button'),
|
||||
loading: this.loading,
|
||||
disabled: !this.changed()
|
||||
})}
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
changed() {
|
||||
return this.fields.some(key => this.values[key]() !== app.data.settings[key]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a list of options for the default homepage. Each option must be an
|
||||
* object with `path` and `label` properties.
|
||||
*
|
||||
* @return {ItemList}
|
||||
* @public
|
||||
*/
|
||||
homePageItems() {
|
||||
const items = new ItemList();
|
||||
|
||||
items.add('allDiscussions', {
|
||||
path: '/all',
|
||||
label: app.translator.trans('core.admin.basics.all_discussions_label')
|
||||
});
|
||||
|
||||
return items;
|
||||
}
|
||||
|
||||
onsubmit(e) {
|
||||
e.preventDefault();
|
||||
|
||||
if (this.loading) return;
|
||||
|
||||
this.loading = true;
|
||||
app.alerts.dismiss(this.successAlert);
|
||||
|
||||
const settings = {};
|
||||
|
||||
this.fields.forEach(key => settings[key] = this.values[key]());
|
||||
|
||||
saveSettings(settings)
|
||||
.then(() => {
|
||||
app.alerts.show(this.successAlert = new Alert({type: 'success', children: app.translator.trans('core.admin.basics.saved_message')}));
|
||||
})
|
||||
.catch(() => {})
|
||||
.then(() => {
|
||||
this.loading = false;
|
||||
m.redraw();
|
||||
});
|
||||
}
|
||||
}
|
22
js/admin/src/components/DashboardPage.tsx
Normal file
22
js/admin/src/components/DashboardPage.tsx
Normal file
@@ -0,0 +1,22 @@
|
||||
import Page from 'flarum/components/Page';
|
||||
|
||||
export default class DashboardPage extends Page {
|
||||
view() {
|
||||
return (
|
||||
<div className="DashboardPage">
|
||||
<div className="container">
|
||||
<h2>{app.translator.trans('core.admin.dashboard.welcome_text')}</h2>
|
||||
<p>{app.translator.trans('core.admin.dashboard.version_text', {version: <strong>{app.forum.attribute('version')}</strong>})}</p>
|
||||
<p>{app.translator.trans('core.admin.dashboard.beta_warning_text', {strong: <strong/>})}</p>
|
||||
<ul>
|
||||
<li>{app.translator.trans('core.admin.dashboard.contributing_text', {a: <a href="http://flarum.org/docs/contributing" target="_blank"/>})}</li>
|
||||
<li>{app.translator.trans('core.admin.dashboard.troubleshooting_text', {a: <a href="http://flarum.org/docs/troubleshooting" target="_blank"/>})}</li>
|
||||
<li>{app.translator.trans('core.admin.dashboard.support_text', {a: <a href="http://discuss.flarum.org/t/support" target="_blank"/>})}</li>
|
||||
<li>{app.translator.trans('core.admin.dashboard.features_text', {a: <a href="http://discuss.flarum.org/t/features" target="_blank"/>})}</li>
|
||||
<li>{app.translator.trans('core.admin.dashboard.extension_text', {a: <a href="http://flarum.org/docs/extend" target="_blank"/>})}</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
@@ -1,4 +1,4 @@
|
||||
import SettingsModal from './SettingsModal';
|
||||
import SettingsModal from 'flarum/components/SettingsModal';
|
||||
|
||||
export default class EditCustomCssModal extends SettingsModal {
|
||||
className() {
|
||||
@@ -11,14 +11,10 @@ export default class EditCustomCssModal extends SettingsModal {
|
||||
|
||||
form() {
|
||||
return [
|
||||
<p>
|
||||
{app.translator.trans('core.admin.edit_css.customize_text', {
|
||||
a: <a href="https://github.com/flarum/core/tree/master/less" target="_blank" />,
|
||||
})}
|
||||
</p>,
|
||||
<p>{app.translator.trans('core.admin.edit_css.customize_text', {a: <a href="https://github.com/flarum/core/tree/master/less" target="_blank"/>})}</p>,
|
||||
<div className="Form-group">
|
||||
<textarea className="FormControl" rows="30" bidi={this.setting('custom_less')} />
|
||||
</div>,
|
||||
<textarea className="FormControl" rows="30" bidi={this.setting('custom_less')}/>
|
||||
</div>
|
||||
];
|
||||
}
|
||||
|
@@ -1,4 +1,4 @@
|
||||
import SettingsModal from './SettingsModal';
|
||||
import SettingsModal from 'flarum/components/SettingsModal';
|
||||
|
||||
export default class EditCustomHeaderModal extends SettingsModal {
|
||||
className() {
|
||||
@@ -13,8 +13,8 @@ export default class EditCustomHeaderModal extends SettingsModal {
|
||||
return [
|
||||
<p>{app.translator.trans('core.admin.edit_header.customize_text')}</p>,
|
||||
<div className="Form-group">
|
||||
<textarea className="FormControl" rows="30" bidi={this.setting('custom_header')} />
|
||||
</div>,
|
||||
<textarea className="FormControl" rows="30" bidi={this.setting('custom_header')}/>
|
||||
</div>
|
||||
];
|
||||
}
|
||||
|
102
js/admin/src/components/EditGroupModal.tsx
Normal file
102
js/admin/src/components/EditGroupModal.tsx
Normal file
@@ -0,0 +1,102 @@
|
||||
import Modal from 'flarum/components/Modal';
|
||||
import Button from 'flarum/components/Button';
|
||||
import Badge from 'flarum/components/Badge';
|
||||
import Group from 'flarum/models/Group';
|
||||
|
||||
/**
|
||||
* The `EditGroupModal` component shows a modal dialog which allows the user
|
||||
* to create or edit a group.
|
||||
*/
|
||||
export default class EditGroupModal extends Modal {
|
||||
init() {
|
||||
this.group = this.props.group || app.store.createRecord('groups');
|
||||
|
||||
this.nameSingular = m.prop(this.group.nameSingular() || '');
|
||||
this.namePlural = m.prop(this.group.namePlural() || '');
|
||||
this.icon = m.prop(this.group.icon() || '');
|
||||
this.color = m.prop(this.group.color() || '');
|
||||
}
|
||||
|
||||
className() {
|
||||
return 'EditGroupModal Modal--small';
|
||||
}
|
||||
|
||||
title() {
|
||||
return [
|
||||
this.color() || this.icon() ? Badge.component({
|
||||
icon: this.icon(),
|
||||
style: {backgroundColor: this.color()}
|
||||
}) : '',
|
||||
' ',
|
||||
this.namePlural() || app.translator.trans('core.admin.edit_group.title')
|
||||
];
|
||||
}
|
||||
|
||||
content() {
|
||||
return (
|
||||
<div className="Modal-body">
|
||||
<div className="Form">
|
||||
<div className="Form-group">
|
||||
<label>{app.translator.trans('core.admin.edit_group.name_label')}</label>
|
||||
<div className="EditGroupModal-name-input">
|
||||
<input className="FormControl" placeholder={app.translator.trans('core.admin.edit_group.singular_placeholder')} value={this.nameSingular()} oninput={m.withAttr('value', this.nameSingular)}/>
|
||||
<input className="FormControl" placeholder={app.translator.trans('core.admin.edit_group.plural_placeholder')} value={this.namePlural()} oninput={m.withAttr('value', this.namePlural)}/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="Form-group">
|
||||
<label>{app.translator.trans('core.admin.edit_group.color_label')}</label>
|
||||
<input className="FormControl" placeholder="#aaaaaa" value={this.color()} oninput={m.withAttr('value', this.color)}/>
|
||||
</div>
|
||||
|
||||
<div className="Form-group">
|
||||
<label>{app.translator.trans('core.admin.edit_group.icon_label')}</label>
|
||||
<div className="helpText">
|
||||
{app.translator.trans('core.admin.edit_group.icon_text', {a: <a href="http://fortawesome.github.io/Font-Awesome/icons/" tabindex="-1"/>})}
|
||||
</div>
|
||||
<input className="FormControl" placeholder="bolt" value={this.icon()} oninput={m.withAttr('value', this.icon)}/>
|
||||
</div>
|
||||
|
||||
<div className="Form-group">
|
||||
{Button.component({
|
||||
type: 'submit',
|
||||
className: 'Button Button--primary EditGroupModal-save',
|
||||
loading: this.loading,
|
||||
children: app.translator.trans('core.admin.edit_group.submit_button')
|
||||
})}
|
||||
{this.group.exists && this.group.id() !== Group.ADMINISTRATOR_ID ? (
|
||||
<button type="button" className="Button EditGroupModal-delete" onclick={this.deleteGroup.bind(this)}>
|
||||
{app.translator.trans('core.admin.edit_group.delete_button')}
|
||||
</button>
|
||||
) : ''}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
onsubmit(e) {
|
||||
e.preventDefault();
|
||||
|
||||
this.loading = true;
|
||||
|
||||
this.group.save({
|
||||
nameSingular: this.nameSingular(),
|
||||
namePlural: this.namePlural(),
|
||||
color: this.color(),
|
||||
icon: this.icon()
|
||||
}, {errorHandler: this.onerror.bind(this)})
|
||||
.then(this.hide.bind(this))
|
||||
.catch(() => {
|
||||
this.loading = false;
|
||||
m.redraw();
|
||||
});
|
||||
}
|
||||
|
||||
deleteGroup() {
|
||||
if (confirm(app.translator.trans('core.admin.edit_group.delete_confirmation'))) {
|
||||
this.group.delete().then(() => m.redraw());
|
||||
this.hide();
|
||||
}
|
||||
}
|
||||
}
|
114
js/admin/src/components/ExtensionsPage.tsx
Normal file
114
js/admin/src/components/ExtensionsPage.tsx
Normal file
@@ -0,0 +1,114 @@
|
||||
import Page from 'flarum/components/Page';
|
||||
import LinkButton from 'flarum/components/LinkButton';
|
||||
import Button from 'flarum/components/Button';
|
||||
import Dropdown from 'flarum/components/Dropdown';
|
||||
import Separator from 'flarum/components/Separator';
|
||||
import AddExtensionModal from 'flarum/components/AddExtensionModal';
|
||||
import LoadingModal from 'flarum/components/LoadingModal';
|
||||
import ItemList from 'flarum/utils/ItemList';
|
||||
import icon from 'flarum/helpers/icon';
|
||||
import listItems from 'flarum/helpers/listItems';
|
||||
|
||||
export default class ExtensionsPage extends Page {
|
||||
view() {
|
||||
return (
|
||||
<div className="ExtensionsPage">
|
||||
<div className="ExtensionsPage-header">
|
||||
<div className="container">
|
||||
{Button.component({
|
||||
children: app.translator.trans('core.admin.extensions.add_button'),
|
||||
icon: 'plus',
|
||||
className: 'Button Button--primary',
|
||||
onclick: () => app.modal.show(new AddExtensionModal())
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="ExtensionsPage-list">
|
||||
<div className="container">
|
||||
<ul className="ExtensionList">
|
||||
{Object.keys(app.data.extensions)
|
||||
.map(id => {
|
||||
const extension = app.data.extensions[id];
|
||||
const controls = this.controlItems(extension.id).toArray();
|
||||
|
||||
return <li className={'ExtensionListItem ' + (!this.isEnabled(extension.id) ? 'disabled' : '')}>
|
||||
<div className="ExtensionListItem-content">
|
||||
<span className="ExtensionListItem-icon ExtensionIcon" style={extension.icon}>
|
||||
{extension.icon ? icon(extension.icon.name) : ''}
|
||||
</span>
|
||||
{controls.length ? (
|
||||
<Dropdown
|
||||
className="ExtensionListItem-controls"
|
||||
buttonClassName="Button Button--icon Button--flat"
|
||||
menuClassName="Dropdown-menu--right"
|
||||
icon="ellipsis-h">
|
||||
{controls}
|
||||
</Dropdown>
|
||||
) : ''}
|
||||
<label className="ExtensionListItem-title">
|
||||
<input type="checkbox" checked={this.isEnabled(extension.id)} onclick={this.toggle.bind(this, extension.id)}/> {' '}
|
||||
{extension.extra['flarum-extension'].title}
|
||||
</label>
|
||||
<div className="ExtensionListItem-version">{extension.version}</div>
|
||||
</div>
|
||||
</li>;
|
||||
})}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
controlItems(name) {
|
||||
const items = new ItemList();
|
||||
const enabled = this.isEnabled(name);
|
||||
|
||||
if (app.extensionSettings[name]) {
|
||||
items.add('settings', Button.component({
|
||||
icon: 'cog',
|
||||
children: app.translator.trans('core.admin.extensions.settings_button'),
|
||||
onclick: app.extensionSettings[name]
|
||||
}));
|
||||
}
|
||||
|
||||
if (!enabled) {
|
||||
items.add('uninstall', Button.component({
|
||||
icon: 'trash-o',
|
||||
children: app.translator.trans('core.admin.extensions.uninstall_button'),
|
||||
onclick: () => {
|
||||
app.request({
|
||||
url: app.forum.attribute('apiUrl') + '/extensions/' + name,
|
||||
method: 'DELETE'
|
||||
}).then(() => window.location.reload());
|
||||
|
||||
app.modal.show(new LoadingModal());
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
return items;
|
||||
}
|
||||
|
||||
isEnabled(name) {
|
||||
const enabled = JSON.parse(app.data.settings.extensions_enabled);
|
||||
|
||||
return enabled.indexOf(name) !== -1;
|
||||
}
|
||||
|
||||
toggle(id) {
|
||||
const enabled = this.isEnabled(id);
|
||||
|
||||
app.request({
|
||||
url: app.forum.attribute('apiUrl') + '/extensions/' + id,
|
||||
method: 'PATCH',
|
||||
data: {enabled: !enabled}
|
||||
}).then(() => {
|
||||
if (!enabled) localStorage.setItem('enabledExtension', id);
|
||||
window.location.reload();
|
||||
});
|
||||
|
||||
app.modal.show(new LoadingModal());
|
||||
}
|
||||
}
|
25
js/admin/src/components/Header.tsx
Normal file
25
js/admin/src/components/Header.tsx
Normal file
@@ -0,0 +1,25 @@
|
||||
import Component from 'flarum/Component';
|
||||
import ItemList from 'flarum/utils/ItemList';
|
||||
import SessionDropdown from './SessionDropdown';
|
||||
|
||||
export default class Header extends Component {
|
||||
/**
|
||||
* @inheritdoc
|
||||
*/
|
||||
view() {
|
||||
return this.items().toVnodes();
|
||||
}
|
||||
|
||||
/**
|
||||
* Build an item list for the header contents.
|
||||
*
|
||||
* @return {ItemList}
|
||||
*/
|
||||
items() {
|
||||
const items = new ItemList();
|
||||
|
||||
items.add('session', <SessionDropdown/>);
|
||||
|
||||
return items;
|
||||
}
|
||||
}
|
@@ -1,10 +1,9 @@
|
||||
import Modal from '../../common/components/Modal';
|
||||
import Modal from 'flarum/components/Modal';
|
||||
|
||||
export default class LoadingModal extends Modal {
|
||||
/**
|
||||
* @inheritdoc
|
||||
*/
|
||||
static isDismissible = false;
|
||||
isDismissible() {
|
||||
return false;
|
||||
}
|
||||
|
||||
className() {
|
||||
return 'LoadingModal Modal--small';
|
124
js/admin/src/components/MailPage.tsx
Normal file
124
js/admin/src/components/MailPage.tsx
Normal file
@@ -0,0 +1,124 @@
|
||||
import Page from 'flarum/components/Page';
|
||||
import FieldSet from 'flarum/components/FieldSet';
|
||||
import Button from 'flarum/components/Button';
|
||||
import Alert from 'flarum/components/Alert';
|
||||
import saveSettings from 'flarum/utils/saveSettings';
|
||||
|
||||
export default class MailPage extends Page {
|
||||
init() {
|
||||
super.init();
|
||||
|
||||
this.loading = false;
|
||||
|
||||
this.fields = [
|
||||
'mail_driver',
|
||||
'mail_host',
|
||||
'mail_from',
|
||||
'mail_port',
|
||||
'mail_username',
|
||||
'mail_password',
|
||||
'mail_encryption'
|
||||
];
|
||||
this.values = {};
|
||||
|
||||
const settings = app.data.settings;
|
||||
this.fields.forEach(key => this.values[key] = m.prop(settings[key]));
|
||||
|
||||
this.localeOptions = {};
|
||||
const locales = app.locales;
|
||||
for (const i in locales) {
|
||||
this.localeOptions[i] = `${locales[i]} (${i})`;
|
||||
}
|
||||
}
|
||||
|
||||
view() {
|
||||
return (
|
||||
<div className="MailPage">
|
||||
<div className="container">
|
||||
<form onsubmit={this.onsubmit.bind(this)}>
|
||||
<h2>{app.translator.trans('core.admin.email.heading')}</h2>
|
||||
<div className="helpText">
|
||||
{app.translator.trans('core.admin.email.text')}
|
||||
</div>
|
||||
|
||||
{FieldSet.component({
|
||||
label: app.translator.trans('core.admin.email.server_heading'),
|
||||
className: 'MailPage-MailSettings',
|
||||
children: [
|
||||
<div className="MailPage-MailSettings-input">
|
||||
<label>{app.translator.trans('core.admin.email.driver_label')}</label>
|
||||
<input className="FormControl" value={this.values.mail_driver() || ''} oninput={m.withAttr('value', this.values.mail_driver)} />
|
||||
<label>{app.translator.trans('core.admin.email.host_label')}</label>
|
||||
<input className="FormControl" value={this.values.mail_host() || ''} oninput={m.withAttr('value', this.values.mail_host)} />
|
||||
<label>{app.translator.trans('core.admin.email.port_label')}</label>
|
||||
<input className="FormControl" value={this.values.mail_port() || ''} oninput={m.withAttr('value', this.values.mail_port)} />
|
||||
<label>{app.translator.trans('core.admin.email.encryption_label')}</label>
|
||||
<input className="FormControl" value={this.values.mail_encryption() || ''} oninput={m.withAttr('value', this.values.mail_encryption)} />
|
||||
</div>
|
||||
]
|
||||
})}
|
||||
|
||||
{FieldSet.component({
|
||||
label: app.translator.trans('core.admin.email.account_heading'),
|
||||
className: 'MailPage-MailSettings',
|
||||
children: [
|
||||
<div className="MailPage-MailSettings-input">
|
||||
<label>{app.translator.trans('core.admin.email.username_label')}</label>
|
||||
<input className="FormControl" value={this.values.mail_username() || ''} oninput={m.withAttr('value', this.values.mail_username)} />
|
||||
<label>{app.translator.trans('core.admin.email.password_label')}</label>
|
||||
<input className="FormControl" value={this.values.mail_password() || ''} oninput={m.withAttr('value', this.values.mail_password)} />
|
||||
</div>
|
||||
]
|
||||
})}
|
||||
|
||||
{FieldSet.component({
|
||||
label: app.translator.trans('core.admin.email.addresses_heading'),
|
||||
className: 'MailPage-MailSettings',
|
||||
children: [
|
||||
<div className="MailPage-MailSettings-input">
|
||||
<label>{app.translator.trans('core.admin.email.from_label')}</label>
|
||||
<input className="FormControl" value={this.values.mail_from() || ''} oninput={m.withAttr('value', this.values.mail_from)} />
|
||||
</div>
|
||||
]
|
||||
})}
|
||||
|
||||
{Button.component({
|
||||
type: 'submit',
|
||||
className: 'Button Button--primary',
|
||||
children: app.translator.trans('core.admin.email.submit_button'),
|
||||
loading: this.loading,
|
||||
disabled: !this.changed()
|
||||
})}
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
changed() {
|
||||
return this.fields.some(key => this.values[key]() !== app.data.settings[key]);
|
||||
}
|
||||
|
||||
onsubmit(e) {
|
||||
e.preventDefault();
|
||||
|
||||
if (this.loading) return;
|
||||
|
||||
this.loading = true;
|
||||
app.alerts.dismiss(this.successAlert);
|
||||
|
||||
const settings = {};
|
||||
|
||||
this.fields.forEach(key => settings[key] = this.values[key]());
|
||||
|
||||
saveSettings(settings)
|
||||
.then(() => {
|
||||
app.alerts.show(this.successAlert = new Alert({type: 'success', children: app.translator.trans('core.admin.basics.saved_message')}));
|
||||
})
|
||||
.catch(() => {})
|
||||
.then(() => {
|
||||
this.loading = false;
|
||||
m.redraw();
|
||||
});
|
||||
}
|
||||
}
|
32
js/admin/src/components/Page.tsx
Normal file
32
js/admin/src/components/Page.tsx
Normal file
@@ -0,0 +1,32 @@
|
||||
import Component from 'flarum/Component';
|
||||
|
||||
/**
|
||||
* The `Page` component
|
||||
*
|
||||
* @abstract
|
||||
*/
|
||||
export default class Page extends Component {
|
||||
init() {
|
||||
app.previous = app.current;
|
||||
app.current = this;
|
||||
|
||||
app.modal.close();
|
||||
|
||||
/**
|
||||
* A class name to apply to the body while the route is active.
|
||||
*
|
||||
* @type {String}
|
||||
*/
|
||||
this.bodyClass = '';
|
||||
}
|
||||
|
||||
config(isInitialized, context) {
|
||||
if (isInitialized) return;
|
||||
|
||||
if (this.bodyClass) {
|
||||
$('#app').addClass(this.bodyClass);
|
||||
|
||||
context.onunload = () => $('#app').removeClass(this.bodyClass);
|
||||
}
|
||||
}
|
||||
}
|
149
js/admin/src/components/PermissionDropdown.tsx
Normal file
149
js/admin/src/components/PermissionDropdown.tsx
Normal file
@@ -0,0 +1,149 @@
|
||||
import Dropdown from 'flarum/components/Dropdown';
|
||||
import Button from 'flarum/components/Button';
|
||||
import Separator from 'flarum/components/Separator';
|
||||
import Group from 'flarum/models/Group';
|
||||
import Badge from 'flarum/components/Badge';
|
||||
import GroupBadge from 'flarum/components/GroupBadge';
|
||||
|
||||
function badgeForId(id) {
|
||||
const group = app.store.getById('groups', id);
|
||||
|
||||
return group ? GroupBadge.component({group, label: null}) : '';
|
||||
}
|
||||
|
||||
function filterByRequiredPermissions(groupIds, permission) {
|
||||
app.getRequiredPermissions(permission)
|
||||
.forEach(required => {
|
||||
const restrictToGroupIds = app.data.permissions[required] || [];
|
||||
|
||||
if (restrictToGroupIds.indexOf(Group.GUEST_ID) !== -1) {
|
||||
// do nothing
|
||||
} else if (restrictToGroupIds.indexOf(Group.MEMBER_ID) !== -1) {
|
||||
groupIds = groupIds.filter(id => id !== Group.GUEST_ID);
|
||||
} else if (groupIds.indexOf(Group.MEMBER_ID) !== -1) {
|
||||
groupIds = restrictToGroupIds;
|
||||
} else {
|
||||
groupIds = restrictToGroupIds.filter(id => groupIds.indexOf(id) !== -1);
|
||||
}
|
||||
|
||||
groupIds = filterByRequiredPermissions(groupIds, required);
|
||||
});
|
||||
|
||||
return groupIds;
|
||||
}
|
||||
|
||||
export default class PermissionDropdown extends Dropdown {
|
||||
static initProps(props) {
|
||||
super.initProps(props);
|
||||
|
||||
props.className = 'PermissionDropdown';
|
||||
props.buttonClassName = 'Button Button--text';
|
||||
}
|
||||
|
||||
view() {
|
||||
this.props.children = [];
|
||||
|
||||
let groupIds = app.data.permissions[this.props.permission] || [];
|
||||
|
||||
groupIds = filterByRequiredPermissions(groupIds, this.props.permission);
|
||||
|
||||
const everyone = groupIds.indexOf(Group.GUEST_ID) !== -1;
|
||||
const members = groupIds.indexOf(Group.MEMBER_ID) !== -1;
|
||||
const adminGroup = app.store.getById('groups', Group.ADMINISTRATOR_ID);
|
||||
|
||||
if (everyone) {
|
||||
this.props.label = Badge.component({icon: 'globe'});
|
||||
} else if (members) {
|
||||
this.props.label = Badge.component({icon: 'user'});
|
||||
} else {
|
||||
this.props.label = [
|
||||
badgeForId(Group.ADMINISTRATOR_ID),
|
||||
groupIds.map(badgeForId)
|
||||
];
|
||||
}
|
||||
|
||||
if (this.showing) {
|
||||
if (this.props.allowGuest) {
|
||||
this.props.children.push(
|
||||
Button.component({
|
||||
children: [Badge.component({icon: 'globe'}), ' ', app.translator.trans('core.admin.permissions_controls.everyone_button')],
|
||||
icon: everyone ? 'check' : true,
|
||||
onclick: () => this.save([Group.GUEST_ID]),
|
||||
disabled: this.isGroupDisabled(Group.GUEST_ID)
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
this.props.children.push(
|
||||
Button.component({
|
||||
children: [Badge.component({icon: 'user'}), ' ', app.translator.trans('core.admin.permissions_controls.members_button')],
|
||||
icon: members ? 'check' : true,
|
||||
onclick: () => this.save([Group.MEMBER_ID]),
|
||||
disabled: this.isGroupDisabled(Group.MEMBER_ID)
|
||||
}),
|
||||
|
||||
Separator.component(),
|
||||
|
||||
Button.component({
|
||||
children: [badgeForId(adminGroup.id()), ' ', adminGroup.namePlural()],
|
||||
icon: !everyone && !members ? 'check' : true,
|
||||
disabled: !everyone && !members,
|
||||
onclick: e => {
|
||||
if (e.shiftKey) e.stopPropagation();
|
||||
this.save([]);
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
[].push.apply(
|
||||
this.props.children,
|
||||
app.store.all('groups')
|
||||
.filter(group => [Group.ADMINISTRATOR_ID, Group.GUEST_ID, Group.MEMBER_ID].indexOf(group.id()) === -1)
|
||||
.map(group => Button.component({
|
||||
children: [badgeForId(group.id()), ' ', group.namePlural()],
|
||||
icon: groupIds.indexOf(group.id()) !== -1 ? 'check' : true,
|
||||
onclick: (e) => {
|
||||
if (e.shiftKey) e.stopPropagation();
|
||||
this.toggle(group.id());
|
||||
},
|
||||
disabled: this.isGroupDisabled(group.id()) && this.isGroupDisabled(Group.MEMBER_ID) && this.isGroupDisabled(Group.GUEST_ID)
|
||||
}))
|
||||
);
|
||||
}
|
||||
|
||||
return super.view();
|
||||
}
|
||||
|
||||
save(groupIds) {
|
||||
const permission = this.props.permission;
|
||||
|
||||
app.data.permissions[permission] = groupIds;
|
||||
|
||||
app.request({
|
||||
method: 'POST',
|
||||
url: app.forum.attribute('apiUrl') + '/permission',
|
||||
data: {permission, groupIds}
|
||||
});
|
||||
}
|
||||
|
||||
toggle(groupId) {
|
||||
const permission = this.props.permission;
|
||||
|
||||
let groupIds = app.data.permissions[permission] || [];
|
||||
|
||||
const index = groupIds.indexOf(groupId);
|
||||
|
||||
if (index !== -1) {
|
||||
groupIds.splice(index, 1);
|
||||
} else {
|
||||
groupIds.push(groupId);
|
||||
groupIds = groupIds.filter(id => [Group.GUEST_ID, Group.MEMBER_ID].indexOf(id) === -1);
|
||||
}
|
||||
|
||||
this.save(groupIds);
|
||||
}
|
||||
|
||||
isGroupDisabled(id) {
|
||||
return filterByRequiredPermissions([id], this.props.permission).indexOf(id) === -1;
|
||||
}
|
||||
}
|
247
js/admin/src/components/PermissionGrid.tsx
Normal file
247
js/admin/src/components/PermissionGrid.tsx
Normal file
@@ -0,0 +1,247 @@
|
||||
import Component from 'flarum/Component';
|
||||
import PermissionDropdown from 'flarum/components/PermissionDropdown';
|
||||
import SettingDropdown from 'flarum/components/SettingDropdown';
|
||||
import Button from 'flarum/components/Button';
|
||||
import ItemList from 'flarum/utils/ItemList';
|
||||
import icon from 'flarum/helpers/icon';
|
||||
|
||||
export default class PermissionGrid extends Component {
|
||||
init() {
|
||||
this.permissions = this.permissionItems().toArray();
|
||||
}
|
||||
|
||||
view() {
|
||||
const scopes = this.scopeItems().toArray();
|
||||
|
||||
const permissionCells = permission => {
|
||||
return scopes.map(scope => (
|
||||
<td>
|
||||
{scope.render(permission)}
|
||||
</td>
|
||||
));
|
||||
};
|
||||
|
||||
return (
|
||||
<table className="PermissionGrid">
|
||||
<thead>
|
||||
<tr>
|
||||
<td></td>
|
||||
{scopes.map(scope => (
|
||||
<th>
|
||||
{scope.label}{' '}
|
||||
{scope.onremove ? Button.component({icon: 'times', className: 'Button Button--text PermissionGrid-removeScope', onclick: scope.onremove}) : ''}
|
||||
</th>
|
||||
))}
|
||||
<th>{this.scopeControlItems().toArray()}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
{this.permissions.map(section => (
|
||||
<tbody>
|
||||
<tr className="PermissionGrid-section">
|
||||
<th>{section.label}</th>
|
||||
{permissionCells(section)}
|
||||
<td/>
|
||||
</tr>
|
||||
{section.children.map(child => (
|
||||
<tr className="PermissionGrid-child">
|
||||
<th>{icon(child.icon)}{child.label}</th>
|
||||
{permissionCells(child)}
|
||||
<td/>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
))}
|
||||
</table>
|
||||
);
|
||||
}
|
||||
|
||||
permissionItems() {
|
||||
const items = new ItemList();
|
||||
|
||||
items.add('view', {
|
||||
label: app.translator.trans('core.admin.permissions.read_heading'),
|
||||
children: this.viewItems().toArray()
|
||||
}, 100);
|
||||
|
||||
items.add('start', {
|
||||
label: app.translator.trans('core.admin.permissions.create_heading'),
|
||||
children: this.startItems().toArray()
|
||||
}, 90);
|
||||
|
||||
items.add('reply', {
|
||||
label: app.translator.trans('core.admin.permissions.participate_heading'),
|
||||
children: this.replyItems().toArray()
|
||||
}, 80);
|
||||
|
||||
items.add('moderate', {
|
||||
label: app.translator.trans('core.admin.permissions.moderate_heading'),
|
||||
children: this.moderateItems().toArray()
|
||||
}, 70);
|
||||
|
||||
return items;
|
||||
}
|
||||
|
||||
viewItems() {
|
||||
const items = new ItemList();
|
||||
|
||||
items.add('viewDiscussions', {
|
||||
icon: 'eye',
|
||||
label: app.translator.trans('core.admin.permissions.view_discussions_label'),
|
||||
permission: 'viewDiscussions',
|
||||
allowGuest: true
|
||||
}, 100);
|
||||
|
||||
items.add('viewUserList', {
|
||||
icon: 'users',
|
||||
label: app.translator.trans('core.admin.permissions.view_user_list_label'),
|
||||
permission: 'viewUserList',
|
||||
allowGuest: true
|
||||
}, 100);
|
||||
|
||||
items.add('signUp', {
|
||||
icon: 'user-plus',
|
||||
label: app.translator.trans('core.admin.permissions.sign_up_label'),
|
||||
setting: () => SettingDropdown.component({
|
||||
key: 'allow_sign_up',
|
||||
options: [
|
||||
{value: '1', label: app.translator.trans('core.admin.permissions_controls.signup_open_button')},
|
||||
{value: '0', label: app.translator.trans('core.admin.permissions_controls.signup_closed_button')}
|
||||
]
|
||||
})
|
||||
}, 90);
|
||||
|
||||
return items;
|
||||
}
|
||||
|
||||
startItems() {
|
||||
const items = new ItemList();
|
||||
|
||||
items.add('start', {
|
||||
icon: 'edit',
|
||||
label: app.translator.trans('core.admin.permissions.start_discussions_label'),
|
||||
permission: 'startDiscussion'
|
||||
}, 100);
|
||||
|
||||
items.add('allowRenaming', {
|
||||
icon: 'i-cursor',
|
||||
label: app.translator.trans('core.admin.permissions.allow_renaming_label'),
|
||||
setting: () => {
|
||||
const minutes = parseInt(app.data.settings.allow_renaming, 10);
|
||||
|
||||
return SettingDropdown.component({
|
||||
defaultLabel: minutes
|
||||
? app.translator.transChoice('core.admin.permissions_controls.allow_some_minutes_button', minutes, {count: minutes})
|
||||
: app.translator.trans('core.admin.permissions_controls.allow_indefinitely_button'),
|
||||
key: 'allow_renaming',
|
||||
options: [
|
||||
{value: '-1', label: app.translator.trans('core.admin.permissions_controls.allow_indefinitely_button')},
|
||||
{value: '10', label: app.translator.trans('core.admin.permissions_controls.allow_ten_minutes_button')},
|
||||
{value: 'reply', label: app.translator.trans('core.admin.permissions_controls.allow_until_reply_button')}
|
||||
]
|
||||
});
|
||||
}
|
||||
}, 90);
|
||||
|
||||
return items;
|
||||
}
|
||||
|
||||
replyItems() {
|
||||
const items = new ItemList();
|
||||
|
||||
items.add('reply', {
|
||||
icon: 'reply',
|
||||
label: app.translator.trans('core.admin.permissions.reply_to_discussions_label'),
|
||||
permission: 'discussion.reply'
|
||||
}, 100);
|
||||
|
||||
items.add('allowPostEditing', {
|
||||
icon: 'pencil',
|
||||
label: app.translator.trans('core.admin.permissions.allow_post_editing_label'),
|
||||
setting: () => {
|
||||
const minutes = parseInt(app.data.settings.allow_post_editing, 10);
|
||||
|
||||
return SettingDropdown.component({
|
||||
defaultLabel: minutes
|
||||
? app.translator.transChoice('core.admin.permissions_controls.allow_some_minutes_button', minutes, {count: minutes})
|
||||
: app.translator.trans('core.admin.permissions_controls.allow_indefinitely_button'),
|
||||
key: 'allow_post_editing',
|
||||
options: [
|
||||
{value: '-1', label: app.translator.trans('core.admin.permissions_controls.allow_indefinitely_button')},
|
||||
{value: '10', label: app.translator.trans('core.admin.permissions_controls.allow_ten_minutes_button')},
|
||||
{value: 'reply', label: app.translator.trans('core.admin.permissions_controls.allow_until_reply_button')}
|
||||
]
|
||||
});
|
||||
}
|
||||
}, 90);
|
||||
|
||||
return items;
|
||||
}
|
||||
|
||||
moderateItems() {
|
||||
const items = new ItemList();
|
||||
|
||||
items.add('viewIpsPosts', {
|
||||
icon: 'bullseye',
|
||||
label: app.translator.trans('core.admin.permissions.view_post_ips_label'),
|
||||
permission: 'discussion.viewIpsPosts'
|
||||
}, 110);
|
||||
|
||||
items.add('renameDiscussions', {
|
||||
icon: 'i-cursor',
|
||||
label: app.translator.trans('core.admin.permissions.rename_discussions_label'),
|
||||
permission: 'discussion.rename'
|
||||
}, 100);
|
||||
|
||||
items.add('hideDiscussions', {
|
||||
icon: 'trash-o',
|
||||
label: app.translator.trans('core.admin.permissions.delete_discussions_label'),
|
||||
permission: 'discussion.hide'
|
||||
}, 90);
|
||||
|
||||
items.add('deleteDiscussions', {
|
||||
icon: 'times',
|
||||
label: app.translator.trans('core.admin.permissions.delete_discussions_forever_label'),
|
||||
permission: 'discussion.delete'
|
||||
}, 80);
|
||||
|
||||
items.add('editPosts', {
|
||||
icon: 'pencil',
|
||||
label: app.translator.trans('core.admin.permissions.edit_and_delete_posts_label'),
|
||||
permission: 'discussion.editPosts'
|
||||
}, 70);
|
||||
|
||||
items.add('deletePosts', {
|
||||
icon: 'times',
|
||||
label: app.translator.trans('core.admin.permissions.delete_posts_forever_label'),
|
||||
permission: 'discussion.deletePosts'
|
||||
}, 60);
|
||||
|
||||
return items;
|
||||
}
|
||||
|
||||
scopeItems() {
|
||||
const items = new ItemList();
|
||||
|
||||
items.add('global', {
|
||||
label: app.translator.trans('core.admin.permissions.global_heading'),
|
||||
render: item => {
|
||||
if (item.setting) {
|
||||
return item.setting();
|
||||
} else if (item.permission) {
|
||||
return PermissionDropdown.component({
|
||||
permission: item.permission,
|
||||
allowGuest: item.allowGuest
|
||||
});
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
}, 100);
|
||||
|
||||
return items;
|
||||
}
|
||||
|
||||
scopeControlItems() {
|
||||
return new ItemList();
|
||||
}
|
||||
}
|
41
js/admin/src/components/PermissionsPage.tsx
Normal file
41
js/admin/src/components/PermissionsPage.tsx
Normal file
@@ -0,0 +1,41 @@
|
||||
import Page from 'flarum/components/Page';
|
||||
import GroupBadge from 'flarum/components/GroupBadge';
|
||||
import EditGroupModal from 'flarum/components/EditGroupModal';
|
||||
import Group from 'flarum/models/Group';
|
||||
import icon from 'flarum/helpers/icon';
|
||||
import PermissionGrid from 'flarum/components/PermissionGrid';
|
||||
|
||||
export default class PermissionsPage extends Page {
|
||||
view() {
|
||||
return (
|
||||
<div className="PermissionsPage">
|
||||
<div className="PermissionsPage-groups">
|
||||
<div className="container">
|
||||
{app.store.all('groups')
|
||||
.filter(group => [Group.GUEST_ID, Group.MEMBER_ID].indexOf(group.id()) === -1)
|
||||
.map(group => (
|
||||
<button className="Button Group" onclick={() => app.modal.show(new EditGroupModal({group}))}>
|
||||
{GroupBadge.component({
|
||||
group,
|
||||
className: 'Group-icon',
|
||||
label: null
|
||||
})}
|
||||
<span className="Group-name">{group.namePlural()}</span>
|
||||
</button>
|
||||
))}
|
||||
<button className="Button Group Group--add" onclick={() => app.modal.show(new EditGroupModal())}>
|
||||
{icon('plus', {className: 'Group-icon'})}
|
||||
<span className="Group-name">{app.translator.trans('core.admin.permissions.new_group_button')}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="PermissionsPage-permissions">
|
||||
<div className="container">
|
||||
{PermissionGrid.component()}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
54
js/admin/src/components/SessionDropdown.tsx
Normal file
54
js/admin/src/components/SessionDropdown.tsx
Normal file
@@ -0,0 +1,54 @@
|
||||
import avatar from 'flarum/helpers/avatar';
|
||||
import username from 'flarum/helpers/username';
|
||||
import Dropdown from 'flarum/components/Dropdown';
|
||||
import Button from 'flarum/components/Button';
|
||||
import ItemList from 'flarum/utils/ItemList';
|
||||
|
||||
/**
|
||||
* The `SessionDropdown` component shows a button with the current user's
|
||||
* avatar/name, with a dropdown of session controls.
|
||||
*/
|
||||
export default class SessionDropdown extends Dropdown {
|
||||
static initProps(props) {
|
||||
super.initProps(props);
|
||||
|
||||
props.className = 'SessionDropdown';
|
||||
props.buttonClassName = 'Button Button--user Button--flat';
|
||||
props.menuClassName = 'Dropdown-menu--right';
|
||||
}
|
||||
|
||||
view() {
|
||||
this.props.children = this.items().toArray();
|
||||
|
||||
return super.view();
|
||||
}
|
||||
|
||||
getButtonContent() {
|
||||
const user = app.session.user;
|
||||
|
||||
return [
|
||||
avatar(user), ' ',
|
||||
<span className="Button-label">{username(user)}</span>
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Build an item list for the contents of the dropdown menu.
|
||||
*
|
||||
* @return {ItemList}
|
||||
*/
|
||||
items() {
|
||||
const items = new ItemList();
|
||||
|
||||
items.add('logOut',
|
||||
Button.component({
|
||||
icon: 'sign-out',
|
||||
children: app.translator.trans('core.admin.header.log_out_button'),
|
||||
onclick: app.session.logout.bind(app.session)
|
||||
}),
|
||||
-100
|
||||
);
|
||||
|
||||
return items;
|
||||
}
|
||||
}
|
25
js/admin/src/components/SettingDropdown.tsx
Normal file
25
js/admin/src/components/SettingDropdown.tsx
Normal file
@@ -0,0 +1,25 @@
|
||||
import SelectDropdown from 'flarum/components/SelectDropdown';
|
||||
import Button from 'flarum/components/Button';
|
||||
import saveSettings from 'flarum/utils/saveSettings';
|
||||
|
||||
export default class SettingDropdown extends SelectDropdown {
|
||||
static initProps(props) {
|
||||
super.initProps(props);
|
||||
|
||||
props.className = 'SettingDropdown';
|
||||
props.buttonClassName = 'Button Button--text';
|
||||
props.caretIcon = 'caret-down';
|
||||
props.defaultLabel = 'Custom';
|
||||
|
||||
props.children = props.options.map(({value, label}) => {
|
||||
const active = app.data.settings[props.key] === value;
|
||||
|
||||
return Button.component({
|
||||
children: label,
|
||||
icon: active ? 'check' : true,
|
||||
onclick: saveSettings.bind(this, {[props.key]: value}),
|
||||
active
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
@@ -1,12 +1,9 @@
|
||||
import Modal from '../../common/components/Modal';
|
||||
import Button from '../../common/components/Button';
|
||||
import Stream from '../../common/utils/Stream';
|
||||
import saveSettings from '../utils/saveSettings';
|
||||
import Modal from 'flarum/components/Modal';
|
||||
import Button from 'flarum/components/Button';
|
||||
import saveSettings from 'flarum/utils/saveSettings';
|
||||
|
||||
export default class SettingsModal extends Modal {
|
||||
oninit(vnode) {
|
||||
super.oninit(vnode);
|
||||
|
||||
init() {
|
||||
this.settings = {};
|
||||
this.loading = false;
|
||||
}
|
||||
@@ -21,7 +18,9 @@ export default class SettingsModal extends Modal {
|
||||
<div className="Form">
|
||||
{this.form()}
|
||||
|
||||
<div className="Form-group">{this.submitButton()}</div>
|
||||
<div className="Form-group">
|
||||
{this.submitButton()}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -29,14 +28,18 @@ export default class SettingsModal extends Modal {
|
||||
|
||||
submitButton() {
|
||||
return (
|
||||
<Button type="submit" className="Button Button--primary" loading={this.loading} disabled={!this.changed()}>
|
||||
<Button
|
||||
type="submit"
|
||||
className="Button Button--primary"
|
||||
loading={this.loading}
|
||||
disabled={!this.changed()}>
|
||||
{app.translator.trans('core.admin.settings.submit_button')}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
setting(key, fallback = '') {
|
||||
this.settings[key] = this.settings[key] || Stream(app.data.settings[key] || fallback);
|
||||
this.settings[key] = this.settings[key] || m.prop(app.data.settings[key] || fallback);
|
||||
|
||||
return this.settings[key];
|
||||
}
|
||||
@@ -44,7 +47,7 @@ export default class SettingsModal extends Modal {
|
||||
dirty() {
|
||||
const dirty = {};
|
||||
|
||||
Object.keys(this.settings).forEach((key) => {
|
||||
Object.keys(this.settings).forEach(key => {
|
||||
const value = this.settings[key]();
|
||||
|
||||
if (value !== app.data.settings[key]) {
|
||||
@@ -64,7 +67,10 @@ export default class SettingsModal extends Modal {
|
||||
|
||||
this.loading = true;
|
||||
|
||||
saveSettings(this.dirty()).then(this.onsaved.bind(this), this.loaded.bind(this));
|
||||
saveSettings(this.dirty()).then(
|
||||
this.onsaved.bind(this),
|
||||
this.loaded.bind(this)
|
||||
);
|
||||
}
|
||||
|
||||
onsaved() {
|
97
js/admin/src/components/UploadImageButton.tsx
Normal file
97
js/admin/src/components/UploadImageButton.tsx
Normal file
@@ -0,0 +1,97 @@
|
||||
import Button from 'flarum/components/Button';
|
||||
|
||||
export default class UploadImageButton extends Button {
|
||||
init() {
|
||||
this.loading = false;
|
||||
}
|
||||
|
||||
view() {
|
||||
this.props.loading = this.loading;
|
||||
this.props.className = (this.props.className || '') + ' Button';
|
||||
|
||||
if (app.data.settings[this.props.name + '_path']) {
|
||||
this.props.onclick = this.remove.bind(this);
|
||||
this.props.children = app.translator.trans('core.admin.upload_image.remove_button');
|
||||
|
||||
return (
|
||||
<div>
|
||||
<p><img src={app.forum.attribute(this.props.name+'Url')} alt=""/></p>
|
||||
<p>{super.view()}</p>
|
||||
</div>
|
||||
);
|
||||
} else {
|
||||
this.props.onclick = this.upload.bind(this);
|
||||
this.props.children = app.translator.trans('core.admin.upload_image.upload_button');
|
||||
}
|
||||
|
||||
return super.view();
|
||||
}
|
||||
|
||||
/**
|
||||
* Prompt the user to upload an image.
|
||||
*/
|
||||
upload() {
|
||||
if (this.loading) return;
|
||||
|
||||
const $input = $('<input type="file">');
|
||||
|
||||
$input.appendTo('body').hide().click().on('change', e => {
|
||||
const data = new FormData();
|
||||
data.append(this.props.name, $(e.target)[0].files[0]);
|
||||
|
||||
this.loading = true;
|
||||
m.redraw();
|
||||
|
||||
app.request({
|
||||
method: 'POST',
|
||||
url: this.resourceUrl(),
|
||||
serialize: raw => raw,
|
||||
data
|
||||
}).then(
|
||||
this.success.bind(this),
|
||||
this.failure.bind(this)
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove the logo.
|
||||
*/
|
||||
remove() {
|
||||
this.loading = true;
|
||||
m.redraw();
|
||||
|
||||
app.request({
|
||||
method: 'DELETE',
|
||||
url: this.resourceUrl()
|
||||
}).then(
|
||||
this.success.bind(this),
|
||||
this.failure.bind(this)
|
||||
);
|
||||
}
|
||||
|
||||
resourceUrl() {
|
||||
return app.forum.attribute('apiUrl') + '/' + this.props.name;
|
||||
}
|
||||
|
||||
/**
|
||||
* After a successful upload/removal, reload the page.
|
||||
*
|
||||
* @param {Object} response
|
||||
* @protected
|
||||
*/
|
||||
success(response) {
|
||||
window.location.reload();
|
||||
}
|
||||
|
||||
/**
|
||||
* If upload/removal fails, stop loading.
|
||||
*
|
||||
* @param {Object} response
|
||||
* @protected
|
||||
*/
|
||||
failure(response) {
|
||||
this.loading = false;
|
||||
m.redraw();
|
||||
}
|
||||
}
|
9
js/admin/src/index.ts
Normal file
9
js/admin/src/index.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import AdminApplication from './AdminApplication';
|
||||
|
||||
export const app = new AdminApplication();
|
||||
|
||||
export const extensions = [];
|
||||
|
||||
// Export public API
|
||||
// export { default as Extend } from './Extend';
|
||||
// export { IndexPage, DicsussionList } from './components';
|
1
js/admin/src/lib
Symbolic link
1
js/admin/src/lib
Symbolic link
@@ -0,0 +1 @@
|
||||
/Users/toby/Projects/Flarum/app/packages/core/js/lib
|
15
js/admin/src/routes.ts
Normal file
15
js/admin/src/routes.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import DashboardPage from './components/DashboardPage';
|
||||
import BasicsPage from './components/BasicsPage';
|
||||
import PermissionsPage from './components/PermissionsPage';
|
||||
import AppearancePage from './components/AppearancePage';
|
||||
import ExtensionsPage from './components/ExtensionsPage';
|
||||
import MailPage from './components/MailPage';
|
||||
|
||||
export default function(router) {
|
||||
router.add('dashboard', '/', DashboardPage);
|
||||
router.add('basics', '/basics', BasicsPage);
|
||||
router.add('permissions', '/permissions', PermissionsPage);
|
||||
router.add('appearance', '/appearance', AppearancePage);
|
||||
router.add('extensions', '/extensions', ExtensionsPage);
|
||||
router.add('mail', '/mail', MailPage);
|
||||
}
|
22
js/admin/src/utils/saveSettings.tsx
Normal file
22
js/admin/src/utils/saveSettings.tsx
Normal file
@@ -0,0 +1,22 @@
|
||||
import flarum from 'flarum';
|
||||
|
||||
/**
|
||||
* Make a request to save the given settings to the database.
|
||||
*
|
||||
* @param {Object} settings
|
||||
* @return {Promise}
|
||||
*/
|
||||
export default function saveSettings(settings) {
|
||||
const oldSettings = JSON.parse(JSON.stringify(flarum.data.settings));
|
||||
|
||||
Object.assign(flarum.data.settings, settings);
|
||||
|
||||
return flarum.ajax.request({
|
||||
method: 'POST',
|
||||
url: flarum.forum.apiUrl + '/settings',
|
||||
data: settings
|
||||
}).catch(error => {
|
||||
flarum.data.settings = oldSettings;
|
||||
throw error;
|
||||
});
|
||||
}
|
23
js/admin/tsconfig.json
Normal file
23
js/admin/tsconfig.json
Normal file
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"outDir": "./dist/",
|
||||
"sourceMap": true,
|
||||
"noImplicitAny": false,
|
||||
"module": "commonjs",
|
||||
"target": "es5",
|
||||
"jsx": "react",
|
||||
"jsxFactory": "m",
|
||||
"declaration": true,
|
||||
"lib": ["dom", "es2015"],
|
||||
"types": [
|
||||
"mithril",
|
||||
"classnames"
|
||||
]
|
||||
},
|
||||
"include": [
|
||||
"./src/**/*"
|
||||
],
|
||||
"exclude": [
|
||||
"node_modules"
|
||||
]
|
||||
}
|
30
js/admin/webpack.config.js
Normal file
30
js/admin/webpack.config.js
Normal file
@@ -0,0 +1,30 @@
|
||||
const path = require('path');
|
||||
const { CheckerPlugin } = require('awesome-typescript-loader');
|
||||
|
||||
module.exports = {
|
||||
entry: path.resolve(__dirname, 'src/index.tsx'),
|
||||
resolve: {
|
||||
extensions: ['.ts', '.tsx', '.js', '.jsx']
|
||||
},
|
||||
devtool: 'source-map',
|
||||
output: {
|
||||
filename: 'bundle.js',
|
||||
path: path.resolve(__dirname, 'dist'),
|
||||
library: 'flarum',
|
||||
libraryTarget: 'var'
|
||||
},
|
||||
module: {
|
||||
loaders: [
|
||||
{
|
||||
test: /\.tsx?$/,
|
||||
loader: 'awesome-typescript-loader'
|
||||
}
|
||||
]
|
||||
},
|
||||
plugins: [
|
||||
new CheckerPlugin()
|
||||
],
|
||||
externals: {
|
||||
mithril: 'm'
|
||||
}
|
||||
};
|
26
js/dist/admin.js
vendored
26
js/dist/admin.js
vendored
File diff suppressed because one or more lines are too long
1
js/dist/admin.js.map
vendored
1
js/dist/admin.js.map
vendored
File diff suppressed because one or more lines are too long
53
js/dist/forum.js
vendored
53
js/dist/forum.js
vendored
File diff suppressed because one or more lines are too long
1
js/dist/forum.js.map
vendored
1
js/dist/forum.js.map
vendored
File diff suppressed because one or more lines are too long
1
js/forum/.gitignore
vendored
Normal file
1
js/forum/.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
||||
dist
|
86
js/forum/src/ForumApplication.tsx
Normal file
86
js/forum/src/ForumApplication.tsx
Normal file
@@ -0,0 +1,86 @@
|
||||
import Application from './lib/Application';
|
||||
import routes from './routes';
|
||||
import Search from './components/Search';
|
||||
|
||||
export default class ForumApplication extends Application {
|
||||
/**
|
||||
* The page's search component instance.
|
||||
*
|
||||
* @type {SearchBox}
|
||||
*/
|
||||
search = new Search();
|
||||
|
||||
/**
|
||||
* A map of notification types to their components.
|
||||
*
|
||||
* @type {Object}
|
||||
*/
|
||||
notificationComponents = {};
|
||||
|
||||
/**
|
||||
* A map of post types to their components.
|
||||
*
|
||||
* @type {Object}
|
||||
*/
|
||||
postComponents = {};
|
||||
|
||||
//app.postComponents.comment = CommentPost;
|
||||
//app.postComponents.discussionRenamed = DiscussionRenamedPost;
|
||||
|
||||
// app.notificationComponents.discussionRenamed = DiscussionRenamedNotification;
|
||||
|
||||
/**
|
||||
* @inheritdoc
|
||||
*/
|
||||
registerDefaultRoutes(router) {
|
||||
routes(router);
|
||||
}
|
||||
|
||||
// TODO: work out where to put these helper functions
|
||||
// /**
|
||||
// * Check whether or not the user is currently composing a reply to a
|
||||
// * discussion.
|
||||
// *
|
||||
// * @param {Discussion} discussion
|
||||
// * @return {Boolean}
|
||||
// */
|
||||
// composingReplyTo(discussion) {
|
||||
// return this.composer.component instanceof ReplyComposer &&
|
||||
// this.composer.component.props.discussion === discussion &&
|
||||
// this.composer.position !== Composer.PositionEnum.HIDDEN;
|
||||
// }
|
||||
//
|
||||
// /**
|
||||
// * Check whether or not the user is currently viewing a discussion.
|
||||
// *
|
||||
// * @param {Discussion} discussion
|
||||
// * @return {Boolean}
|
||||
// */
|
||||
// viewingDiscussion(discussion) {
|
||||
// return this.current instanceof DiscussionPage &&
|
||||
// this.current.discussion === discussion;
|
||||
// }
|
||||
//
|
||||
// /**
|
||||
// * Callback for when an external authenticator (social login) action has
|
||||
// * completed.
|
||||
// *
|
||||
// * If the payload indicates that the user has been logged in, then the page
|
||||
// * will be reloaded. Otherwise, a SignUpModal will be opened, prefilled
|
||||
// * with the provided details.
|
||||
// *
|
||||
// * @param {Object} payload A dictionary of props to pass into the sign up
|
||||
// * modal. A truthy `authenticated` prop indicates that the user has logged
|
||||
// * in, and thus the page is reloaded.
|
||||
// * @public
|
||||
// */
|
||||
// authenticationComplete(payload) {
|
||||
// if (payload.authenticated) {
|
||||
// window.location.reload();
|
||||
// } else {
|
||||
// const modal = new SignUpModal(payload);
|
||||
// this.modal.show(modal);
|
||||
// modal.$('[name=password]').focus();
|
||||
// }
|
||||
// }
|
||||
}
|
169
js/forum/src/components/AvatarEditor.tsx
Normal file
169
js/forum/src/components/AvatarEditor.tsx
Normal file
@@ -0,0 +1,169 @@
|
||||
import Component from 'flarum/Component';
|
||||
import avatar from 'flarum/helpers/avatar';
|
||||
import icon from 'flarum/helpers/icon';
|
||||
import listItems from 'flarum/helpers/listItems';
|
||||
import ItemList from 'flarum/utils/ItemList';
|
||||
import Button from 'flarum/components/Button';
|
||||
import LoadingIndicator from 'flarum/components/LoadingIndicator';
|
||||
|
||||
/**
|
||||
* The `AvatarEditor` component displays a user's avatar along with a dropdown
|
||||
* menu which allows the user to upload/remove the avatar.
|
||||
*
|
||||
* ### Props
|
||||
*
|
||||
* - `className`
|
||||
* - `user`
|
||||
*/
|
||||
export default class AvatarEditor extends Component {
|
||||
init() {
|
||||
/**
|
||||
* Whether or not an avatar upload is in progress.
|
||||
*
|
||||
* @type {Boolean}
|
||||
*/
|
||||
this.loading = false;
|
||||
}
|
||||
|
||||
static initProps(props) {
|
||||
super.initProps(props);
|
||||
|
||||
props.className = props.className || '';
|
||||
}
|
||||
|
||||
view() {
|
||||
const user = this.props.user;
|
||||
|
||||
return (
|
||||
<div className={'AvatarEditor Dropdown ' + this.props.className + (this.loading ? ' loading' : '')}>
|
||||
{avatar(user)}
|
||||
<a className={ user.avatarUrl() ? "Dropdown-toggle" : "Dropdown-toggle AvatarEditor--noAvatar" }
|
||||
title={app.translator.trans('core.forum.user.avatar_upload_tooltip')}
|
||||
data-toggle="dropdown"
|
||||
onclick={this.quickUpload.bind(this)}>
|
||||
{this.loading ? LoadingIndicator.component() : (user.avatarUrl() ? icon('pencil') : icon('plus-circle'))}
|
||||
</a>
|
||||
<ul className="Dropdown-menu Menu">
|
||||
{listItems(this.controlItems().toArray())}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the items in the edit avatar dropdown menu.
|
||||
*
|
||||
* @return {ItemList}
|
||||
*/
|
||||
controlItems() {
|
||||
const items = new ItemList();
|
||||
|
||||
items.add('upload',
|
||||
Button.component({
|
||||
icon: 'upload',
|
||||
children: app.translator.trans('core.forum.user.avatar_upload_button'),
|
||||
onclick: this.upload.bind(this)
|
||||
})
|
||||
);
|
||||
|
||||
items.add('remove',
|
||||
Button.component({
|
||||
icon: 'times',
|
||||
children: app.translator.trans('core.forum.user.avatar_remove_button'),
|
||||
onclick: this.remove.bind(this)
|
||||
})
|
||||
);
|
||||
|
||||
return items;
|
||||
}
|
||||
|
||||
/**
|
||||
* If the user doesn't have an avatar, there's no point in showing the
|
||||
* controls dropdown, because only one option would be viable: uploading.
|
||||
* Thus, when the avatar editor's dropdown toggle button is clicked, we prompt
|
||||
* the user to upload an avatar immediately.
|
||||
*
|
||||
* @param {Event} e
|
||||
*/
|
||||
quickUpload(e) {
|
||||
if (!this.props.user.avatarUrl()) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
this.upload();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Prompt the user to upload a new avatar.
|
||||
*/
|
||||
upload() {
|
||||
if (this.loading) return;
|
||||
|
||||
// Create a hidden HTML input element and click on it so the user can select
|
||||
// an avatar file. Once they have, we will upload it via the API.
|
||||
const user = this.props.user;
|
||||
const $input = $('<input type="file">');
|
||||
|
||||
$input.appendTo('body').hide().click().on('change', e => {
|
||||
const data = new FormData();
|
||||
data.append('avatar', $(e.target)[0].files[0]);
|
||||
|
||||
this.loading = true;
|
||||
m.redraw();
|
||||
|
||||
app.request({
|
||||
method: 'POST',
|
||||
url: app.forum.attribute('apiUrl') + '/users/' + user.id() + '/avatar',
|
||||
serialize: raw => raw,
|
||||
data
|
||||
}).then(
|
||||
this.success.bind(this),
|
||||
this.failure.bind(this)
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove the user's avatar.
|
||||
*/
|
||||
remove() {
|
||||
const user = this.props.user;
|
||||
|
||||
this.loading = true;
|
||||
m.redraw();
|
||||
|
||||
app.request({
|
||||
method: 'DELETE',
|
||||
url: app.forum.attribute('apiUrl') + '/users/' + user.id() + '/avatar'
|
||||
}).then(
|
||||
this.success.bind(this),
|
||||
this.failure.bind(this)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* After a successful upload/removal, push the updated user data into the
|
||||
* store, and force a recomputation of the user's avatar color.
|
||||
*
|
||||
* @param {Object} response
|
||||
* @protected
|
||||
*/
|
||||
success(response) {
|
||||
app.store.pushPayload(response);
|
||||
delete this.props.user.avatarColor;
|
||||
|
||||
this.loading = false;
|
||||
m.redraw();
|
||||
}
|
||||
|
||||
/**
|
||||
* If avatar upload/removal fails, stop loading.
|
||||
*
|
||||
* @param {Object} response
|
||||
* @protected
|
||||
*/
|
||||
failure(response) {
|
||||
this.loading = false;
|
||||
m.redraw();
|
||||
}
|
||||
}
|
@@ -1,14 +1,13 @@
|
||||
import Modal from '../../common/components/Modal';
|
||||
import Button from '../../common/components/Button';
|
||||
import Stream from '../../common/utils/Stream';
|
||||
import Modal from 'flarum/components/Modal';
|
||||
import Button from 'flarum/components/Button';
|
||||
|
||||
/**
|
||||
* The `ChangeEmailModal` component shows a modal dialog which allows the user
|
||||
* to change their email address.
|
||||
*/
|
||||
export default class ChangeEmailModal extends Modal {
|
||||
oninit(vnode) {
|
||||
super.oninit(vnode);
|
||||
init() {
|
||||
super.init();
|
||||
|
||||
/**
|
||||
* Whether or not the email has been changed successfully.
|
||||
@@ -22,14 +21,14 @@ export default class ChangeEmailModal extends Modal {
|
||||
*
|
||||
* @type {function}
|
||||
*/
|
||||
this.email = Stream(app.session.user.email());
|
||||
this.email = m.prop(app.session.user.email());
|
||||
|
||||
/**
|
||||
* The value of the password input.
|
||||
*
|
||||
* @type {function}
|
||||
*/
|
||||
this.password = Stream('');
|
||||
this.password = m.prop('');
|
||||
}
|
||||
|
||||
className() {
|
||||
@@ -45,9 +44,7 @@ export default class ChangeEmailModal extends Modal {
|
||||
return (
|
||||
<div className="Modal-body">
|
||||
<div className="Form Form--centered">
|
||||
<p className="helpText">
|
||||
{app.translator.trans('core.forum.change_email.confirmation_message', { email: <strong>{this.email()}</strong> })}
|
||||
</p>
|
||||
<p className="helpText">{app.translator.trans('core.forum.change_email.confirmation_message', {email: <strong>{this.email()}</strong>})}</p>
|
||||
<div className="Form-group">
|
||||
<Button className="Button Button--primary Button--block" onclick={this.hide.bind(this)}>
|
||||
{app.translator.trans('core.forum.change_email.dismiss_button')}
|
||||
@@ -62,34 +59,24 @@ export default class ChangeEmailModal extends Modal {
|
||||
<div className="Modal-body">
|
||||
<div className="Form Form--centered">
|
||||
<div className="Form-group">
|
||||
<input
|
||||
type="email"
|
||||
name="email"
|
||||
className="FormControl"
|
||||
<input type="email" name="email" className="FormControl"
|
||||
placeholder={app.session.user.email()}
|
||||
bidi={this.email}
|
||||
disabled={this.loading}
|
||||
/>
|
||||
disabled={this.loading}/>
|
||||
</div>
|
||||
<div className="Form-group">
|
||||
<input
|
||||
type="password"
|
||||
name="password"
|
||||
className="FormControl"
|
||||
<input type="password" name="password" className="FormControl"
|
||||
placeholder={app.translator.trans('core.forum.change_email.confirm_password_placeholder')}
|
||||
bidi={this.password}
|
||||
disabled={this.loading}
|
||||
/>
|
||||
disabled={this.loading}/>
|
||||
</div>
|
||||
<div className="Form-group">
|
||||
{Button.component(
|
||||
{
|
||||
className: 'Button Button--primary Button--block',
|
||||
type: 'submit',
|
||||
loading: this.loading,
|
||||
},
|
||||
app.translator.trans('core.forum.change_email.submit_button')
|
||||
)}
|
||||
{Button.component({
|
||||
className: 'Button Button--primary Button--block',
|
||||
type: 'submit',
|
||||
loading: this.loading,
|
||||
children: app.translator.trans('core.forum.change_email.submit_button')
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -106,27 +93,22 @@ export default class ChangeEmailModal extends Modal {
|
||||
return;
|
||||
}
|
||||
|
||||
this.loading = true;
|
||||
this.alertAttrs = null;
|
||||
const oldEmail = app.session.user.email();
|
||||
|
||||
app.session.user
|
||||
.save(
|
||||
{ email: this.email() },
|
||||
{
|
||||
errorHandler: this.onerror.bind(this),
|
||||
meta: { password: this.password() },
|
||||
}
|
||||
)
|
||||
.then(() => {
|
||||
this.success = true;
|
||||
})
|
||||
this.loading = true;
|
||||
|
||||
app.session.user.save({email: this.email()}, {
|
||||
errorHandler: this.onerror.bind(this),
|
||||
meta: {password: this.password()}
|
||||
})
|
||||
.then(() => this.success = true)
|
||||
.catch(() => {})
|
||||
.then(this.loaded.bind(this));
|
||||
}
|
||||
|
||||
onerror(error) {
|
||||
if (error.status === 401) {
|
||||
error.alert.content = app.translator.trans('core.forum.change_email.incorrect_password_message');
|
||||
error.alert.props.children = app.translator.trans('core.forum.change_email.incorrect_password_message');
|
||||
}
|
||||
|
||||
super.onerror(error);
|
@@ -1,5 +1,5 @@
|
||||
import Modal from '../../common/components/Modal';
|
||||
import Button from '../../common/components/Button';
|
||||
import Modal from 'flarum/components/Modal';
|
||||
import Button from 'flarum/components/Button';
|
||||
|
||||
/**
|
||||
* The `ChangePasswordModal` component shows a modal dialog which allows the
|
||||
@@ -20,14 +20,12 @@ export default class ChangePasswordModal extends Modal {
|
||||
<div className="Form Form--centered">
|
||||
<p className="helpText">{app.translator.trans('core.forum.change_password.text')}</p>
|
||||
<div className="Form-group">
|
||||
{Button.component(
|
||||
{
|
||||
className: 'Button Button--primary Button--block',
|
||||
type: 'submit',
|
||||
loading: this.loading,
|
||||
},
|
||||
app.translator.trans('core.forum.change_password.send_button')
|
||||
)}
|
||||
{Button.component({
|
||||
className: 'Button Button--primary Button--block',
|
||||
type: 'submit',
|
||||
loading: this.loading,
|
||||
children: app.translator.trans('core.forum.change_password.send_button')
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -39,12 +37,13 @@ export default class ChangePasswordModal extends Modal {
|
||||
|
||||
this.loading = true;
|
||||
|
||||
app
|
||||
.request({
|
||||
method: 'POST',
|
||||
url: app.forum.attribute('apiUrl') + '/forgot',
|
||||
body: { email: app.session.user.email() },
|
||||
})
|
||||
.then(this.hide.bind(this), this.loaded.bind(this));
|
||||
app.request({
|
||||
method: 'POST',
|
||||
url: app.forum.attribute('apiUrl') + '/forgot',
|
||||
data: {email: app.session.user.email()}
|
||||
}).then(
|
||||
this.hide.bind(this),
|
||||
this.loaded.bind(this)
|
||||
);
|
||||
}
|
||||
}
|
153
js/forum/src/components/CommentPost.tsx
Normal file
153
js/forum/src/components/CommentPost.tsx
Normal file
@@ -0,0 +1,153 @@
|
||||
/*global s9e, hljs*/
|
||||
|
||||
import Post from 'flarum/components/Post';
|
||||
import classList from 'flarum/utils/classList';
|
||||
import PostUser from 'flarum/components/PostUser';
|
||||
import PostMeta from 'flarum/components/PostMeta';
|
||||
import PostEdited from 'flarum/components/PostEdited';
|
||||
import EditPostComposer from 'flarum/components/EditPostComposer';
|
||||
import ItemList from 'flarum/utils/ItemList';
|
||||
import listItems from 'flarum/helpers/listItems';
|
||||
import Button from 'flarum/components/Button';
|
||||
|
||||
/**
|
||||
* The `CommentPost` component displays a standard `comment`-typed post. This
|
||||
* includes a number of item lists (controls, header, and footer) surrounding
|
||||
* the post's HTML content.
|
||||
*
|
||||
* ### Props
|
||||
*
|
||||
* - `post`
|
||||
*/
|
||||
export default class CommentPost extends Post {
|
||||
init() {
|
||||
super.init();
|
||||
|
||||
/**
|
||||
* If the post has been hidden, then this flag determines whether or not its
|
||||
* content has been expanded.
|
||||
*
|
||||
* @type {Boolean}
|
||||
*/
|
||||
this.revealContent = false;
|
||||
|
||||
// Create an instance of the component that displays the post's author so
|
||||
// that we can force the post to rerender when the user card is shown.
|
||||
this.postUser = new PostUser({post: this.props.post});
|
||||
this.subtree.check(
|
||||
() => this.postUser.cardVisible,
|
||||
() => this.isEditing()
|
||||
);
|
||||
}
|
||||
|
||||
content() {
|
||||
// Note: we avoid using JSX for the <ul> below because it results in some
|
||||
// weirdness in Mithril.js 0.1.x (see flarum/core#975). This workaround can
|
||||
// be reverted when we upgrade to Mithril 1.0.
|
||||
return super.content().concat([
|
||||
<header className="Post-header">{m('ul', listItems(this.headerItems().toArray()))}</header>,
|
||||
<div className="Post-body">
|
||||
{this.isEditing()
|
||||
? <div className="Post-preview" config={this.configPreview.bind(this)}/>
|
||||
: m.trust(this.props.post.contentHtml())}
|
||||
</div>
|
||||
]);
|
||||
}
|
||||
|
||||
config(isInitialized, context) {
|
||||
super.config(...arguments);
|
||||
|
||||
const contentHtml = this.isEditing() ? '' : this.props.post.contentHtml();
|
||||
|
||||
// If the post content has changed since the last render, we'll run through
|
||||
// all of the <script> tags in the content and evaluate them. This is
|
||||
// necessary because TextFormatter outputs them for e.g. syntax highlighting.
|
||||
if (context.contentHtml !== contentHtml) {
|
||||
this.$('.Post-body script').each(function() {
|
||||
eval.call(window, $(this).text());
|
||||
});
|
||||
}
|
||||
|
||||
context.contentHtml = contentHtml;
|
||||
}
|
||||
|
||||
isEditing() {
|
||||
return app.composer.component instanceof EditPostComposer &&
|
||||
app.composer.component.props.post === this.props.post;
|
||||
}
|
||||
|
||||
attrs() {
|
||||
const post = this.props.post;
|
||||
const attrs = super.attrs();
|
||||
|
||||
attrs.className += ' '+classList({
|
||||
'CommentPost': true,
|
||||
'Post--hidden': post.isHidden(),
|
||||
'Post--edited': post.isEdited(),
|
||||
'revealContent': this.revealContent,
|
||||
'editing': this.isEditing()
|
||||
});
|
||||
|
||||
return attrs;
|
||||
}
|
||||
|
||||
configPreview(element, isInitialized, context) {
|
||||
if (isInitialized) return;
|
||||
|
||||
// Every 50ms, if the composer content has changed, then update the post's
|
||||
// body with a preview.
|
||||
let preview;
|
||||
const updatePreview = () => {
|
||||
const content = app.composer.component.content();
|
||||
|
||||
if (preview === content) return;
|
||||
|
||||
preview = content;
|
||||
|
||||
s9e.TextFormatter.preview(preview || '', element);
|
||||
};
|
||||
updatePreview();
|
||||
|
||||
const updateInterval = setInterval(updatePreview, 50);
|
||||
context.onunload = () => clearInterval(updateInterval);
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle the visibility of a hidden post's content.
|
||||
*/
|
||||
toggleContent() {
|
||||
this.revealContent = !this.revealContent;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build an item list for the post's header.
|
||||
*
|
||||
* @return {ItemList}
|
||||
*/
|
||||
headerItems() {
|
||||
const items = new ItemList();
|
||||
const post = this.props.post;
|
||||
const props = {post};
|
||||
|
||||
items.add('user', this.postUser.render(), 100);
|
||||
items.add('meta', PostMeta.component(props));
|
||||
|
||||
if (post.isEdited() && !post.isHidden()) {
|
||||
items.add('edited', PostEdited.component(props));
|
||||
}
|
||||
|
||||
// If the post is hidden, add a button that allows toggling the visibility
|
||||
// of the post's content.
|
||||
if (post.isHidden()) {
|
||||
items.add('toggle', (
|
||||
Button.component({
|
||||
className: 'Button Button--default Button--more',
|
||||
icon: 'ellipsis-h',
|
||||
onclick: this.toggleContent.bind(this)
|
||||
})
|
||||
));
|
||||
}
|
||||
|
||||
return items;
|
||||
}
|
||||
}
|
494
js/forum/src/components/Composer.tsx
Normal file
494
js/forum/src/components/Composer.tsx
Normal file
@@ -0,0 +1,494 @@
|
||||
import Component from 'flarum/Component';
|
||||
import ItemList from 'flarum/utils/ItemList';
|
||||
import ComposerButton from 'flarum/components/ComposerButton';
|
||||
import listItems from 'flarum/helpers/listItems';
|
||||
import classList from 'flarum/utils/classList';
|
||||
import computed from 'flarum/utils/computed';
|
||||
|
||||
/**
|
||||
* The `Composer` component displays the composer. It can be loaded with a
|
||||
* content component with `load` and then its position/state can be altered with
|
||||
* `show`, `hide`, `close`, `minimize`, `fullScreen`, and `exitFullScreen`.
|
||||
*/
|
||||
class Composer extends Component {
|
||||
init() {
|
||||
/**
|
||||
* The composer's current position.
|
||||
*
|
||||
* @type {Composer.PositionEnum}
|
||||
*/
|
||||
this.position = Composer.PositionEnum.HIDDEN;
|
||||
|
||||
/**
|
||||
* The composer's intended height, which can be modified by the user
|
||||
* (by dragging the composer handle).
|
||||
*
|
||||
* @type {Integer}
|
||||
*/
|
||||
this.height = null;
|
||||
|
||||
/**
|
||||
* Whether or not the composer currently has focus.
|
||||
*
|
||||
* @type {Boolean}
|
||||
*/
|
||||
this.active = false;
|
||||
|
||||
/**
|
||||
* Computed the composer's current height, based on the intended height, and
|
||||
* the composer's current state. This will be applied to the composer's
|
||||
* content's DOM element.
|
||||
*
|
||||
* @return {Integer}
|
||||
*/
|
||||
this.computedHeight = computed('height', 'position', (height, position) => {
|
||||
// If the composer is minimized, then we don't want to set a height; we'll
|
||||
// let the CSS decide how high it is. If it's fullscreen, then we need to
|
||||
// make it as high as the window.
|
||||
if (position === Composer.PositionEnum.MINIMIZED) {
|
||||
return '';
|
||||
} else if (position === Composer.PositionEnum.FULLSCREEN) {
|
||||
return $(window).height();
|
||||
}
|
||||
|
||||
// Otherwise, if it's normal or hidden, then we use the intended height.
|
||||
// We don't let the composer get too small or too big, though.
|
||||
return Math.max(200, Math.min(height, $(window).height() - $('#header').outerHeight()));
|
||||
});
|
||||
}
|
||||
|
||||
view() {
|
||||
const classes = {
|
||||
'normal': this.position === Composer.PositionEnum.NORMAL,
|
||||
'minimized': this.position === Composer.PositionEnum.MINIMIZED,
|
||||
'fullScreen': this.position === Composer.PositionEnum.FULLSCREEN,
|
||||
'active': this.active
|
||||
};
|
||||
classes.visible = classes.normal || classes.minimized || classes.fullScreen;
|
||||
|
||||
// If the composer is minimized, tell the composer's content component that
|
||||
// it shouldn't let the user interact with it. Set up a handler so that if
|
||||
// the content IS clicked, the composer will be shown.
|
||||
if (this.component) this.component.props.disabled = classes.minimized;
|
||||
|
||||
const showIfMinimized = this.position === Composer.PositionEnum.MINIMIZED ? this.show.bind(this) : undefined;
|
||||
|
||||
return (
|
||||
<div className={'Composer ' + classList(classes)}>
|
||||
<div className="Composer-handle" config={this.configHandle.bind(this)}/>
|
||||
<ul className="Composer-controls">{listItems(this.controlItems().toArray())}</ul>
|
||||
<div className="Composer-content" onclick={showIfMinimized}>
|
||||
{this.component ? this.component.render() : ''}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
config(isInitialized, context) {
|
||||
let defaultHeight;
|
||||
|
||||
if (!isInitialized) {
|
||||
defaultHeight = this.$().height();
|
||||
}
|
||||
|
||||
if (isInitialized) return;
|
||||
|
||||
// Since this component is a part of the global UI that persists between
|
||||
// routes, we will flag the DOM to be retained across route changes.
|
||||
context.retain = true;
|
||||
|
||||
// Initialize the composer's intended height based on what the user has set
|
||||
// it at previously, or otherwise the composer's default height. After that,
|
||||
// we'll hide the composer.
|
||||
this.height = localStorage.getItem('composerHeight') || defaultHeight;
|
||||
this.$().hide().css('bottom', -this.height);
|
||||
|
||||
// Whenever any of the inputs inside the composer are have focus, we want to
|
||||
// add a class to the composer to draw attention to it.
|
||||
this.$().on('focus blur', ':input', e => {
|
||||
this.active = e.type === 'focusin';
|
||||
m.redraw();
|
||||
});
|
||||
|
||||
// When the escape key is pressed on any inputs, close the composer.
|
||||
this.$().on('keydown', ':input', 'esc', () => this.close());
|
||||
|
||||
// Don't let the user leave the page without first giving the composer's
|
||||
// component a chance to scream at the user to make sure they don't
|
||||
// unintentionally lose any contnet.
|
||||
window.onbeforeunload = () => {
|
||||
return (this.component && this.component.preventExit()) || undefined;
|
||||
};
|
||||
|
||||
const handlers = {};
|
||||
|
||||
$(window).on('resize', handlers.onresize = this.updateHeight.bind(this)).resize();
|
||||
|
||||
$(document)
|
||||
.on('mousemove', handlers.onmousemove = this.onmousemove.bind(this))
|
||||
.on('mouseup', handlers.onmouseup = this.onmouseup.bind(this));
|
||||
|
||||
context.onunload = () => {
|
||||
$(window).off('resize', handlers.onresize);
|
||||
|
||||
$(document)
|
||||
.off('mousemove', handlers.onmousemove)
|
||||
.off('mouseup', handlers.onmouseup);
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Add the necessary event handlers to the composer's handle so that it can
|
||||
* be used to resize the composer.
|
||||
*
|
||||
* @param {DOMElement} element
|
||||
* @param {Boolean} isInitialized
|
||||
*/
|
||||
configHandle(element, isInitialized) {
|
||||
if (isInitialized) return;
|
||||
|
||||
const composer = this;
|
||||
|
||||
$(element).css('cursor', 'row-resize')
|
||||
.on('dragstart mousedown', e => e.preventDefault())
|
||||
.mousedown(function(e) {
|
||||
composer.mouseStart = e.clientY;
|
||||
composer.heightStart = composer.$().height();
|
||||
composer.handle = $(this);
|
||||
$('body').css('cursor', 'row-resize');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Resize the composer according to mouse movement.
|
||||
*
|
||||
* @param {Event} e
|
||||
*/
|
||||
onmousemove(e) {
|
||||
if (!this.handle) return;
|
||||
|
||||
// Work out how much the mouse has been moved, and set the height
|
||||
// relative to the old one based on that. Then update the content's
|
||||
// height so that it fills the height of the composer, and update the
|
||||
// body's padding.
|
||||
const deltaPixels = this.mouseStart - e.clientY;
|
||||
this.height = this.heightStart + deltaPixels;
|
||||
this.updateHeight();
|
||||
|
||||
// Update the body's padding-bottom so that no content on the page will ever
|
||||
// get permanently hidden behind the composer. If the user is already
|
||||
// scrolled to the bottom of the page, then we will keep them scrolled to
|
||||
// the bottom after the padding has been updated.
|
||||
const scrollTop = $(window).scrollTop();
|
||||
const anchorToBottom = scrollTop > 0 && scrollTop + $(window).height() >= $(document).height();
|
||||
this.updateBodyPadding(anchorToBottom);
|
||||
|
||||
localStorage.setItem('composerHeight', this.height);
|
||||
}
|
||||
|
||||
/**
|
||||
* Finish resizing the composer when the mouse is released.
|
||||
*/
|
||||
onmouseup() {
|
||||
if (!this.handle) return;
|
||||
|
||||
this.handle = null;
|
||||
$('body').css('cursor', '');
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the DOM to reflect the composer's current height. This involves
|
||||
* setting the height of the composer's root element, and adjusting the height
|
||||
* of any flexible elements inside the composer's body.
|
||||
*/
|
||||
updateHeight() {
|
||||
const height = this.computedHeight();
|
||||
const $flexible = this.$('.Composer-flexible');
|
||||
|
||||
this.$().height(height);
|
||||
|
||||
if ($flexible.length) {
|
||||
const headerHeight = $flexible.offset().top - this.$().offset().top;
|
||||
const paddingBottom = parseInt($flexible.css('padding-bottom'), 10);
|
||||
const footerHeight = this.$('.Composer-footer').outerHeight(true);
|
||||
|
||||
$flexible.height(this.$().outerHeight() - headerHeight - paddingBottom - footerHeight);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the amount of padding-bottom on the body so that the page's
|
||||
* content will still be visible above the composer when the page is
|
||||
* scrolled right to the bottom.
|
||||
*/
|
||||
updateBodyPadding() {
|
||||
const visible = this.position !== Composer.PositionEnum.HIDDEN &&
|
||||
this.position !== Composer.PositionEnum.MINIMIZED &&
|
||||
this.$().css('position') !== 'absolute';
|
||||
|
||||
const paddingBottom = visible
|
||||
? this.computedHeight() - parseInt($('#app').css('padding-bottom'), 10)
|
||||
: 0;
|
||||
|
||||
$('#content').css({paddingBottom});
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine whether or not the Composer is covering the screen.
|
||||
*
|
||||
* This will be true if the Composer is in full-screen mode on desktop, or
|
||||
* if the Composer is positioned absolutely as on mobile devices.
|
||||
*
|
||||
* @return {Boolean}
|
||||
* @public
|
||||
*/
|
||||
isFullScreen() {
|
||||
return this.position === Composer.PositionEnum.FULLSCREEN || this.$().css('position') === 'absolute';
|
||||
}
|
||||
|
||||
/**
|
||||
* Confirm with the user that they want to close the composer and lose their
|
||||
* content.
|
||||
*
|
||||
* @return {Boolean} Whether or not the exit was cancelled.
|
||||
*/
|
||||
preventExit() {
|
||||
if (this.component) {
|
||||
const preventExit = this.component.preventExit();
|
||||
|
||||
if (preventExit) {
|
||||
return !confirm(preventExit);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Load a content component into the composer.
|
||||
*
|
||||
* @param {Component} component
|
||||
* @public
|
||||
*/
|
||||
load(component) {
|
||||
if (this.preventExit()) return;
|
||||
|
||||
// If we load a similar component into the composer, then Mithril will be
|
||||
// able to diff the old/new contents and some DOM-related state from the
|
||||
// old composer will remain. To prevent this from happening, we clear the
|
||||
// component and force a redraw, so that the new component will be working
|
||||
// on a blank slate.
|
||||
if (this.component) {
|
||||
this.clear();
|
||||
m.redraw(true);
|
||||
}
|
||||
|
||||
this.component = component;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear the composer's content component.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
clear() {
|
||||
this.component = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Animate the Composer into the given position.
|
||||
*
|
||||
* @param {Composer.PositionEnum} position
|
||||
*/
|
||||
animateToPosition(position) {
|
||||
// Before we redraw the composer to its new state, we need to save the
|
||||
// current height of the composer, as well as the page's scroll position, so
|
||||
// that we can smoothly transition from the old to the new state.
|
||||
const oldPosition = this.position;
|
||||
const $composer = this.$().stop(true);
|
||||
const oldHeight = $composer.outerHeight();
|
||||
const scrollTop = $(window).scrollTop();
|
||||
|
||||
this.position = position;
|
||||
|
||||
m.redraw(true);
|
||||
|
||||
// Now that we've redrawn and the composer's DOM has been updated, we want
|
||||
// to update the composer's height. Once we've done that, we'll capture the
|
||||
// real value to use as the end point for our animation later on.
|
||||
$composer.show();
|
||||
this.updateHeight();
|
||||
|
||||
const newHeight = $composer.outerHeight();
|
||||
|
||||
if (oldPosition === Composer.PositionEnum.HIDDEN) {
|
||||
$composer.css({bottom: -newHeight, height: newHeight});
|
||||
} else {
|
||||
$composer.css({height: oldHeight});
|
||||
}
|
||||
|
||||
$composer.animate({bottom: 0, height: newHeight}, 'fast', () => this.component.focus());
|
||||
|
||||
this.updateBodyPadding();
|
||||
$(window).scrollTop(scrollTop);
|
||||
}
|
||||
|
||||
/**
|
||||
* Show the Composer backdrop.
|
||||
*/
|
||||
showBackdrop() {
|
||||
this.$backdrop = $('<div/>')
|
||||
.addClass('composer-backdrop')
|
||||
.appendTo('body');
|
||||
}
|
||||
|
||||
/**
|
||||
* Hide the Composer backdrop.
|
||||
*/
|
||||
hideBackdrop() {
|
||||
if (this.$backdrop) this.$backdrop.remove();
|
||||
}
|
||||
|
||||
/**
|
||||
* Show the composer.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
show() {
|
||||
if (this.position === Composer.PositionEnum.NORMAL || this.position === Composer.PositionEnum.FULLSCREEN) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.animateToPosition(Composer.PositionEnum.NORMAL);
|
||||
|
||||
if (this.isFullScreen()) {
|
||||
this.$().css('top', $(window).scrollTop());
|
||||
this.showBackdrop();
|
||||
this.component.focus();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Close the composer.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
hide() {
|
||||
const $composer = this.$();
|
||||
|
||||
// Animate the composer sliding down off the bottom edge of the viewport.
|
||||
// Only when the animation is completed, update the Composer state flag and
|
||||
// other elements on the page.
|
||||
$composer.stop(true).animate({bottom: -$composer.height()}, 'fast', () => {
|
||||
this.position = Composer.PositionEnum.HIDDEN;
|
||||
this.clear();
|
||||
m.redraw();
|
||||
|
||||
$composer.hide();
|
||||
this.hideBackdrop();
|
||||
this.updateBodyPadding();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Confirm with the user so they don't lose their content, then close the
|
||||
* composer.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
close() {
|
||||
if (!this.preventExit()) {
|
||||
this.hide();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Minimize the composer. Has no effect if the composer is hidden.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
minimize() {
|
||||
if (this.position === Composer.PositionEnum.HIDDEN) return;
|
||||
|
||||
this.animateToPosition(Composer.PositionEnum.MINIMIZED);
|
||||
|
||||
this.$().css('top', 'auto');
|
||||
this.hideBackdrop();
|
||||
}
|
||||
|
||||
/**
|
||||
* Take the composer into fullscreen mode. Has no effect if the composer is
|
||||
* hidden.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
fullScreen() {
|
||||
if (this.position !== Composer.PositionEnum.HIDDEN) {
|
||||
this.position = Composer.PositionEnum.FULLSCREEN;
|
||||
m.redraw();
|
||||
this.updateHeight();
|
||||
this.component.focus();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Exit fullscreen mode.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
exitFullScreen() {
|
||||
if (this.position === Composer.PositionEnum.FULLSCREEN) {
|
||||
this.position = Composer.PositionEnum.NORMAL;
|
||||
m.redraw();
|
||||
this.updateHeight();
|
||||
this.component.focus();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Build an item list for the composer's controls.
|
||||
*
|
||||
* @return {ItemList}
|
||||
*/
|
||||
controlItems() {
|
||||
const items = new ItemList();
|
||||
|
||||
if (this.position === Composer.PositionEnum.FULLSCREEN) {
|
||||
items.add('exitFullScreen', ComposerButton.component({
|
||||
icon: 'compress',
|
||||
title: app.translator.trans('core.forum.composer.exit_full_screen_tooltip'),
|
||||
onclick: this.exitFullScreen.bind(this)
|
||||
}));
|
||||
} else {
|
||||
if (this.position !== Composer.PositionEnum.MINIMIZED) {
|
||||
items.add('minimize', ComposerButton.component({
|
||||
icon: 'minus minimize',
|
||||
title: app.translator.trans('core.forum.composer.minimize_tooltip'),
|
||||
onclick: this.minimize.bind(this),
|
||||
itemClassName: 'App-backControl'
|
||||
}));
|
||||
|
||||
items.add('fullScreen', ComposerButton.component({
|
||||
icon: 'expand',
|
||||
title: app.translator.trans('core.forum.composer.full_screen_tooltip'),
|
||||
onclick: this.fullScreen.bind(this)
|
||||
}));
|
||||
}
|
||||
|
||||
items.add('close', ComposerButton.component({
|
||||
icon: 'times',
|
||||
title: app.translator.trans('core.forum.composer.close_tooltip'),
|
||||
onclick: this.close.bind(this)
|
||||
}));
|
||||
}
|
||||
|
||||
return items;
|
||||
}
|
||||
}
|
||||
|
||||
Composer.PositionEnum = {
|
||||
HIDDEN: 'hidden',
|
||||
NORMAL: 'normal',
|
||||
MINIMIZED: 'minimized',
|
||||
FULLSCREEN: 'fullScreen'
|
||||
};
|
||||
|
||||
export default Composer;
|
113
js/forum/src/components/ComposerBody.tsx
Normal file
113
js/forum/src/components/ComposerBody.tsx
Normal file
@@ -0,0 +1,113 @@
|
||||
import Component from 'flarum/Component';
|
||||
import LoadingIndicator from 'flarum/components/LoadingIndicator';
|
||||
import TextEditor from 'flarum/components/TextEditor';
|
||||
import avatar from 'flarum/helpers/avatar';
|
||||
import listItems from 'flarum/helpers/listItems';
|
||||
import ItemList from 'flarum/utils/ItemList';
|
||||
|
||||
/**
|
||||
* The `ComposerBody` component handles the body, or the content, of the
|
||||
* composer. Subclasses should implement the `onsubmit` method and override
|
||||
* `headerTimes`.
|
||||
*
|
||||
* ### Props
|
||||
*
|
||||
* - `originalContent`
|
||||
* - `submitLabel`
|
||||
* - `placeholder`
|
||||
* - `user`
|
||||
* - `confirmExit`
|
||||
* - `disabled`
|
||||
*
|
||||
* @abstract
|
||||
*/
|
||||
export default class ComposerBody extends Component {
|
||||
init() {
|
||||
/**
|
||||
* Whether or not the component is loading.
|
||||
*
|
||||
* @type {Boolean}
|
||||
*/
|
||||
this.loading = false;
|
||||
|
||||
/**
|
||||
* The content of the text editor.
|
||||
*
|
||||
* @type {Function}
|
||||
*/
|
||||
this.content = m.prop(this.props.originalContent);
|
||||
|
||||
/**
|
||||
* The text editor component instance.
|
||||
*
|
||||
* @type {TextEditor}
|
||||
*/
|
||||
this.editor = new TextEditor({
|
||||
submitLabel: this.props.submitLabel,
|
||||
placeholder: this.props.placeholder,
|
||||
onchange: this.content,
|
||||
onsubmit: this.onsubmit.bind(this),
|
||||
value: this.content()
|
||||
});
|
||||
}
|
||||
|
||||
view() {
|
||||
// If the component is loading, we should disable the text editor.
|
||||
this.editor.props.disabled = this.loading;
|
||||
|
||||
return (
|
||||
<div className={'ComposerBody ' + (this.props.className || '')}>
|
||||
{avatar(this.props.user, {className: 'ComposerBody-avatar'})}
|
||||
<div className="ComposerBody-content">
|
||||
<ul className="ComposerBody-header">{listItems(this.headerItems().toArray())}</ul>
|
||||
<div className="ComposerBody-editor">{this.editor.render()}</div>
|
||||
</div>
|
||||
{LoadingIndicator.component({className: 'ComposerBody-loading' + (this.loading ? ' active' : '')})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Draw focus to the text editor.
|
||||
*/
|
||||
focus() {
|
||||
this.$(':input:enabled:visible:first').focus();
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if there is any unsaved data – if there is, return a confirmation
|
||||
* message to prompt the user with.
|
||||
*
|
||||
* @return {String}
|
||||
*/
|
||||
preventExit() {
|
||||
const content = this.content();
|
||||
|
||||
return content && content !== this.props.originalContent && this.props.confirmExit;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build an item list for the composer's header.
|
||||
*
|
||||
* @return {ItemList}
|
||||
*/
|
||||
headerItems() {
|
||||
return new ItemList();
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle the submit event of the text editor.
|
||||
*
|
||||
* @abstract
|
||||
*/
|
||||
onsubmit() {
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop loading.
|
||||
*/
|
||||
loaded() {
|
||||
this.loading = false;
|
||||
m.redraw();
|
||||
}
|
||||
}
|
@@ -1,13 +1,13 @@
|
||||
import Button from '../../common/components/Button';
|
||||
import Button from 'flarum/components/Button';
|
||||
|
||||
/**
|
||||
* The `ComposerButton` component displays a button suitable for the composer
|
||||
* controls.
|
||||
*/
|
||||
export default class ComposerButton extends Button {
|
||||
static initAttrs(attrs) {
|
||||
super.initAttrs(attrs);
|
||||
static initProps(props) {
|
||||
super.initProps(props);
|
||||
|
||||
attrs.className = attrs.className || 'Button Button--icon Button--link';
|
||||
props.className = props.className || 'Button Button--icon Button--link';
|
||||
}
|
||||
}
|
101
js/forum/src/components/DiscussionComposer.tsx
Normal file
101
js/forum/src/components/DiscussionComposer.tsx
Normal file
@@ -0,0 +1,101 @@
|
||||
import ComposerBody from 'flarum/components/ComposerBody';
|
||||
import extractText from 'flarum/utils/extractText';
|
||||
|
||||
/**
|
||||
* The `DiscussionComposer` component displays the composer content for starting
|
||||
* a new discussion. It adds a text field as a header control so the user can
|
||||
* enter the title of their discussion. It also overrides the `submit` and
|
||||
* `willExit` actions to account for the title.
|
||||
*
|
||||
* ### Props
|
||||
*
|
||||
* - All of the props for ComposerBody
|
||||
* - `titlePlaceholder`
|
||||
*/
|
||||
export default class DiscussionComposer extends ComposerBody {
|
||||
init() {
|
||||
super.init();
|
||||
|
||||
/**
|
||||
* The value of the title input.
|
||||
*
|
||||
* @type {Function}
|
||||
*/
|
||||
this.title = m.prop('');
|
||||
}
|
||||
|
||||
static initProps(props) {
|
||||
super.initProps(props);
|
||||
|
||||
props.placeholder = props.placeholder || extractText(app.translator.trans('core.forum.composer_discussion.body_placeholder'));
|
||||
props.submitLabel = props.submitLabel || app.translator.trans('core.forum.composer_discussion.submit_button');
|
||||
props.confirmExit = props.confirmExit || extractText(app.translator.trans('core.forum.composer_discussion.discard_confirmation'));
|
||||
props.titlePlaceholder = props.titlePlaceholder || extractText(app.translator.trans('core.forum.composer_discussion.title_placeholder'));
|
||||
props.className = 'ComposerBody--discussion';
|
||||
}
|
||||
|
||||
headerItems() {
|
||||
const items = super.headerItems();
|
||||
|
||||
items.add('title', <h3>{app.translator.trans('core.forum.composer_discussion.title')}</h3>, 100);
|
||||
|
||||
items.add('discussionTitle', (
|
||||
<h3>
|
||||
<input className="FormControl"
|
||||
value={this.title()}
|
||||
oninput={m.withAttr('value', this.title)}
|
||||
placeholder={this.props.titlePlaceholder}
|
||||
disabled={!!this.props.disabled}
|
||||
onkeydown={this.onkeydown.bind(this)}/>
|
||||
</h3>
|
||||
));
|
||||
|
||||
return items;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle the title input's keydown event. When the return key is pressed,
|
||||
* move the focus to the start of the text editor.
|
||||
*
|
||||
* @param {Event} e
|
||||
*/
|
||||
onkeydown(e) {
|
||||
if (e.which === 13) { // Return
|
||||
e.preventDefault();
|
||||
this.editor.setSelectionRange(0, 0);
|
||||
}
|
||||
|
||||
m.redraw.strategy('none');
|
||||
}
|
||||
|
||||
preventExit() {
|
||||
return (this.title() || this.content()) && this.props.confirmExit;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the data to submit to the server when the discussion is saved.
|
||||
*
|
||||
* @return {Object}
|
||||
*/
|
||||
data() {
|
||||
return {
|
||||
title: this.title(),
|
||||
content: this.content()
|
||||
};
|
||||
}
|
||||
|
||||
onsubmit() {
|
||||
this.loading = true;
|
||||
|
||||
const data = this.data();
|
||||
|
||||
app.store.createRecord('discussions').save(data).then(
|
||||
discussion => {
|
||||
app.composer.hide();
|
||||
app.cache.discussionList.addDiscussion(discussion);
|
||||
m.route(app.route.discussion(discussion));
|
||||
},
|
||||
this.loaded.bind(this)
|
||||
);
|
||||
}
|
||||
}
|
@@ -1,11 +1,11 @@
|
||||
import Component from '../../common/Component';
|
||||
import ItemList from '../../common/utils/ItemList';
|
||||
import listItems from '../../common/helpers/listItems';
|
||||
import Component from 'flarum/Component';
|
||||
import ItemList from 'flarum/utils/ItemList';
|
||||
import listItems from 'flarum/helpers/listItems';
|
||||
|
||||
/**
|
||||
* The `DiscussionHero` component displays the hero on a discussion page.
|
||||
*
|
||||
* ### attrs
|
||||
* ### Props
|
||||
*
|
||||
* - `discussion`
|
||||
*/
|
||||
@@ -27,7 +27,7 @@ export default class DiscussionHero extends Component {
|
||||
*/
|
||||
items() {
|
||||
const items = new ItemList();
|
||||
const discussion = this.attrs.discussion;
|
||||
const discussion = this.props.discussion;
|
||||
const badges = discussion.badges().toArray();
|
||||
|
||||
if (badges.length) {
|
218
js/forum/src/components/DiscussionList.tsx
Normal file
218
js/forum/src/components/DiscussionList.tsx
Normal file
@@ -0,0 +1,218 @@
|
||||
import Component from 'flarum/Component';
|
||||
import DiscussionListItem from 'flarum/components/DiscussionListItem';
|
||||
import Button from 'flarum/components/Button';
|
||||
import LoadingIndicator from 'flarum/components/LoadingIndicator';
|
||||
import Placeholder from 'flarum/components/Placeholder';
|
||||
|
||||
/**
|
||||
* The `DiscussionList` component displays a list of discussions.
|
||||
*
|
||||
* ### Props
|
||||
*
|
||||
* - `params` A map of parameters used to construct a refined parameter object
|
||||
* to send along in the API request to get discussion results.
|
||||
*/
|
||||
export default class DiscussionList extends Component {
|
||||
init() {
|
||||
/**
|
||||
* Whether or not discussion results are loading.
|
||||
*
|
||||
* @type {Boolean}
|
||||
*/
|
||||
this.loading = true;
|
||||
|
||||
/**
|
||||
* Whether or not there are more results that can be loaded.
|
||||
*
|
||||
* @type {Boolean}
|
||||
*/
|
||||
this.moreResults = false;
|
||||
|
||||
/**
|
||||
* The discussions in the discussion list.
|
||||
*
|
||||
* @type {Discussion[]}
|
||||
*/
|
||||
this.discussions = [];
|
||||
|
||||
this.refresh();
|
||||
}
|
||||
|
||||
view() {
|
||||
const params = this.props.params;
|
||||
let loading;
|
||||
|
||||
if (this.loading) {
|
||||
loading = LoadingIndicator.component();
|
||||
} else if (this.moreResults) {
|
||||
loading = Button.component({
|
||||
children: app.translator.trans('core.forum.discussion_list.load_more_button'),
|
||||
className: 'Button',
|
||||
onclick: this.loadMore.bind(this)
|
||||
});
|
||||
}
|
||||
|
||||
if (this.discussions.length === 0 && !this.loading) {
|
||||
const text = app.translator.trans('core.forum.discussion_list.empty_text');
|
||||
return (
|
||||
<div className="DiscussionList">
|
||||
{Placeholder.component({text})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="DiscussionList">
|
||||
<ul className="DiscussionList-discussions">
|
||||
{this.discussions.map(discussion => {
|
||||
return (
|
||||
<li key={discussion.id()} data-id={discussion.id()}>
|
||||
{DiscussionListItem.component({discussion, params})}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
<div className="DiscussionList-loadMore">
|
||||
{loading}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the parameters that should be passed in the API request to get
|
||||
* discussion results.
|
||||
*
|
||||
* @return {Object}
|
||||
* @api
|
||||
*/
|
||||
requestParams() {
|
||||
const params = {include: ['startUser', 'lastUser'], filter: {}};
|
||||
|
||||
params.sort = this.sortMap()[this.props.params.sort];
|
||||
|
||||
if (this.props.params.q) {
|
||||
params.filter.q = this.props.params.q;
|
||||
|
||||
params.include.push('relevantPosts', 'relevantPosts.discussion', 'relevantPosts.user');
|
||||
}
|
||||
|
||||
return params;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a map of sort keys (which appear in the URL, and are used for
|
||||
* translation) to the API sort value that they represent.
|
||||
*
|
||||
* @return {Object}
|
||||
*/
|
||||
sortMap() {
|
||||
const map = {};
|
||||
|
||||
if (this.props.params.q) {
|
||||
map.relevance = '';
|
||||
}
|
||||
map.latest = '-lastTime';
|
||||
map.top = '-commentsCount';
|
||||
map.newest = '-startTime';
|
||||
map.oldest = 'startTime';
|
||||
|
||||
return map;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear and reload the discussion list.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
refresh(clear = true) {
|
||||
if (clear) {
|
||||
this.loading = true;
|
||||
this.discussions = [];
|
||||
}
|
||||
|
||||
return this.loadResults().then(
|
||||
results => {
|
||||
this.discussions = [];
|
||||
this.parseResults(results);
|
||||
},
|
||||
() => {
|
||||
this.loading = false;
|
||||
m.redraw();
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Load a new page of discussion results.
|
||||
*
|
||||
* @param {Integer} offset The index to start the page at.
|
||||
* @return {Promise}
|
||||
*/
|
||||
loadResults(offset) {
|
||||
const preloadedDiscussions = app.preloadedDocument();
|
||||
|
||||
if (preloadedDiscussions) {
|
||||
return m.deferred().resolve(preloadedDiscussions).promise;
|
||||
}
|
||||
|
||||
const params = this.requestParams();
|
||||
params.page = {offset};
|
||||
params.include = params.include.join(',');
|
||||
|
||||
return app.store.find('discussions', params);
|
||||
}
|
||||
|
||||
/**
|
||||
* Load the next page of discussion results.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
loadMore() {
|
||||
this.loading = true;
|
||||
|
||||
this.loadResults(this.discussions.length)
|
||||
.then(this.parseResults.bind(this));
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse results and append them to the discussion list.
|
||||
*
|
||||
* @param {Discussion[]} results
|
||||
* @return {Discussion[]}
|
||||
*/
|
||||
parseResults(results) {
|
||||
[].push.apply(this.discussions, results);
|
||||
|
||||
this.loading = false;
|
||||
this.moreResults = !!results.payload.links.next;
|
||||
|
||||
m.lazyRedraw();
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a discussion from the list if it is present.
|
||||
*
|
||||
* @param {Discussion} discussion
|
||||
* @public
|
||||
*/
|
||||
removeDiscussion(discussion) {
|
||||
const index = this.discussions.indexOf(discussion);
|
||||
|
||||
if (index !== -1) {
|
||||
this.discussions.splice(index, 1);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a discussion to the top of the list.
|
||||
*
|
||||
* @param {Discussion} discussion
|
||||
* @public
|
||||
*/
|
||||
addDiscussion(discussion) {
|
||||
this.discussions.unshift(discussion);
|
||||
}
|
||||
}
|
200
js/forum/src/components/DiscussionListItem.tsx
Normal file
200
js/forum/src/components/DiscussionListItem.tsx
Normal file
@@ -0,0 +1,200 @@
|
||||
import Component from 'flarum/Component';
|
||||
import avatar from 'flarum/helpers/avatar';
|
||||
import listItems from 'flarum/helpers/listItems';
|
||||
import highlight from 'flarum/helpers/highlight';
|
||||
import icon from 'flarum/helpers/icon';
|
||||
import humanTime from 'flarum/utils/humanTime';
|
||||
import ItemList from 'flarum/utils/ItemList';
|
||||
import abbreviateNumber from 'flarum/utils/abbreviateNumber';
|
||||
import Dropdown from 'flarum/components/Dropdown';
|
||||
import TerminalPost from 'flarum/components/TerminalPost';
|
||||
import PostPreview from 'flarum/components/PostPreview';
|
||||
import SubtreeRetainer from 'flarum/utils/SubtreeRetainer';
|
||||
import DiscussionControls from 'flarum/utils/DiscussionControls';
|
||||
import slidable from 'flarum/utils/slidable';
|
||||
import extractText from 'flarum/utils/extractText';
|
||||
import classList from 'flarum/utils/classList';
|
||||
|
||||
/**
|
||||
* The `DiscussionListItem` component shows a single discussion in the
|
||||
* discussion list.
|
||||
*
|
||||
* ### Props
|
||||
*
|
||||
* - `discussion`
|
||||
* - `params`
|
||||
*/
|
||||
export default class DiscussionListItem extends Component {
|
||||
init() {
|
||||
/**
|
||||
* Set up a subtree retainer so that the discussion will not be redrawn
|
||||
* unless new data comes in.
|
||||
*
|
||||
* @type {SubtreeRetainer}
|
||||
*/
|
||||
this.subtree = new SubtreeRetainer(
|
||||
() => this.props.discussion.freshness,
|
||||
() => {
|
||||
const time = app.session.user && app.session.user.readTime();
|
||||
return time && time.getTime();
|
||||
},
|
||||
() => this.active()
|
||||
);
|
||||
}
|
||||
|
||||
attrs() {
|
||||
return {
|
||||
className: classList([
|
||||
'DiscussionListItem',
|
||||
this.active() ? 'active' : '',
|
||||
this.props.discussion.isHidden() ? 'DiscussionListItem--hidden' : ''
|
||||
])
|
||||
};
|
||||
}
|
||||
|
||||
view() {
|
||||
const retain = this.subtree.retain();
|
||||
|
||||
if (retain) return retain;
|
||||
|
||||
const discussion = this.props.discussion;
|
||||
const startUser = discussion.startUser();
|
||||
const isUnread = discussion.isUnread();
|
||||
const isRead = discussion.isRead();
|
||||
const showUnread = !this.showRepliesCount() && isUnread;
|
||||
const jumpTo = Math.min(discussion.lastPostNumber(), (discussion.readNumber() || 0) + 1);
|
||||
const relevantPosts = this.props.params.q ? discussion.relevantPosts() : [];
|
||||
const controls = DiscussionControls.controls(discussion, this).toArray();
|
||||
const attrs = this.attrs();
|
||||
|
||||
return (
|
||||
<div {...attrs}>
|
||||
|
||||
{controls.length ? Dropdown.component({
|
||||
icon: 'ellipsis-v',
|
||||
children: controls,
|
||||
className: 'DiscussionListItem-controls',
|
||||
buttonClassName: 'Button Button--icon Button--flat Slidable-underneath Slidable-underneath--right'
|
||||
}) : ''}
|
||||
|
||||
<a className={'Slidable-underneath Slidable-underneath--left Slidable-underneath--elastic' + (isUnread ? '' : ' disabled')}
|
||||
onclick={this.markAsRead.bind(this)}>
|
||||
{icon('check')}
|
||||
</a>
|
||||
|
||||
<div className={'DiscussionListItem-content Slidable-content' + (isUnread ? ' unread' : '') + (isRead ? ' read' : '')}>
|
||||
<a href={startUser ? app.route.user(startUser) : '#'}
|
||||
className="DiscussionListItem-author"
|
||||
title={extractText(app.translator.trans('core.forum.discussion_list.started_text', {user: startUser, ago: humanTime(discussion.startTime())}))}
|
||||
config={function(element) {
|
||||
$(element).tooltip({placement: 'right'});
|
||||
m.route.apply(this, arguments);
|
||||
}}>
|
||||
{avatar(startUser, {title: ''})}
|
||||
</a>
|
||||
|
||||
<ul className="DiscussionListItem-badges badges">
|
||||
{listItems(discussion.badges().toArray())}
|
||||
</ul>
|
||||
|
||||
<a href={app.route.discussion(discussion, jumpTo)}
|
||||
config={m.route}
|
||||
className="DiscussionListItem-main">
|
||||
<h3 className="DiscussionListItem-title">{highlight(discussion.title(), this.props.params.q)}</h3>
|
||||
<ul className="DiscussionListItem-info">{listItems(this.infoItems().toArray())}</ul>
|
||||
</a>
|
||||
|
||||
<span className="DiscussionListItem-count"
|
||||
onclick={this.markAsRead.bind(this)}
|
||||
title={showUnread ? app.translator.trans('core.forum.discussion_list.mark_as_read_tooltip') : ''}>
|
||||
{abbreviateNumber(discussion[showUnread ? 'unreadCount' : 'repliesCount']())}
|
||||
</span>
|
||||
|
||||
{relevantPosts && relevantPosts.length
|
||||
? <div className="DiscussionListItem-relevantPosts">
|
||||
{relevantPosts.map(post => PostPreview.component({post, highlight: this.props.params.q}))}
|
||||
</div>
|
||||
: ''}
|
||||
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
config(isInitialized) {
|
||||
if (isInitialized) return;
|
||||
|
||||
// If we're on a touch device, set up the discussion row to be slidable.
|
||||
// This allows the user to drag the row to either side of the screen to
|
||||
// reveal controls.
|
||||
if ('ontouchstart' in window) {
|
||||
const slidableInstance = slidable(this.$().addClass('Slidable'));
|
||||
|
||||
this.$('.DiscussionListItem-controls')
|
||||
.on('hidden.bs.dropdown', () => slidableInstance.reset());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine whether or not the discussion is currently being viewed.
|
||||
*
|
||||
* @return {Boolean}
|
||||
*/
|
||||
active() {
|
||||
const idParam = m.route.param('id');
|
||||
|
||||
return idParam && idParam.split('-')[0] === this.props.discussion.id();
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine whether or not information about who started the discussion
|
||||
* should be displayed instead of information about the most recent reply to
|
||||
* the discussion.
|
||||
*
|
||||
* @return {Boolean}
|
||||
*/
|
||||
showStartPost() {
|
||||
return ['newest', 'oldest'].indexOf(this.props.params.sort) !== -1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine whether or not the number of replies should be shown instead of
|
||||
* the number of unread posts.
|
||||
*
|
||||
* @return {Boolean}
|
||||
*/
|
||||
showRepliesCount() {
|
||||
return this.props.params.sort === 'replies';
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark the discussion as read.
|
||||
*/
|
||||
markAsRead() {
|
||||
const discussion = this.props.discussion;
|
||||
|
||||
if (discussion.isUnread()) {
|
||||
discussion.save({readNumber: discussion.lastPostNumber()});
|
||||
m.redraw();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Build an item list of info for a discussion listing. By default this is
|
||||
* just the first/last post indicator.
|
||||
*
|
||||
* @return {ItemList}
|
||||
*/
|
||||
infoItems() {
|
||||
const items = new ItemList();
|
||||
|
||||
items.add('terminalPost',
|
||||
TerminalPost.component({
|
||||
discussion: this.props.discussion,
|
||||
lastPost: !this.showStartPost()
|
||||
})
|
||||
);
|
||||
|
||||
return items;
|
||||
}
|
||||
}
|
286
js/forum/src/components/DiscussionPage.tsx
Normal file
286
js/forum/src/components/DiscussionPage.tsx
Normal file
@@ -0,0 +1,286 @@
|
||||
import Page from 'flarum/components/Page';
|
||||
import ItemList from 'flarum/utils/ItemList';
|
||||
import DiscussionHero from 'flarum/components/DiscussionHero';
|
||||
import PostStream from 'flarum/components/PostStream';
|
||||
import PostStreamScrubber from 'flarum/components/PostStreamScrubber';
|
||||
import LoadingIndicator from 'flarum/components/LoadingIndicator';
|
||||
import SplitDropdown from 'flarum/components/SplitDropdown';
|
||||
import listItems from 'flarum/helpers/listItems';
|
||||
import DiscussionControls from 'flarum/utils/DiscussionControls';
|
||||
|
||||
/**
|
||||
* The `DiscussionPage` component displays a whole discussion page, including
|
||||
* the discussion list pane, the hero, the posts, and the sidebar.
|
||||
*/
|
||||
export default class DiscussionPage extends Page {
|
||||
init() {
|
||||
super.init();
|
||||
|
||||
/**
|
||||
* The discussion that is being viewed.
|
||||
*
|
||||
* @type {Discussion}
|
||||
*/
|
||||
this.discussion = null;
|
||||
|
||||
/**
|
||||
* The number of the first post that is currently visible in the viewport.
|
||||
*
|
||||
* @type {Integer}
|
||||
*/
|
||||
this.near = null;
|
||||
|
||||
this.refresh();
|
||||
|
||||
// If the discussion list has been loaded, then we'll enable the pane (and
|
||||
// hide it by default). Also, if we've just come from another discussion
|
||||
// page, then we don't want Mithril to redraw the whole page – if it did,
|
||||
// then the pane would which would be slow and would cause problems with
|
||||
// event handlers.
|
||||
if (app.cache.discussionList) {
|
||||
app.pane.enable();
|
||||
app.pane.hide();
|
||||
|
||||
if (app.previous instanceof DiscussionPage) {
|
||||
m.redraw.strategy('diff');
|
||||
}
|
||||
}
|
||||
|
||||
app.history.push('discussion');
|
||||
|
||||
this.bodyClass = 'App--discussion';
|
||||
}
|
||||
|
||||
onunload(e) {
|
||||
// If we have routed to the same discussion as we were viewing previously,
|
||||
// cancel the unloading of this controller and instead prompt the post
|
||||
// stream to jump to the new 'near' param.
|
||||
if (this.discussion) {
|
||||
const idParam = m.route.param('id');
|
||||
|
||||
if (idParam && idParam.split('-')[0] === this.discussion.id()) {
|
||||
e.preventDefault();
|
||||
|
||||
const near = m.route.param('near') || '1';
|
||||
|
||||
if (near !== String(this.near)) {
|
||||
this.stream.goToNumber(near);
|
||||
}
|
||||
|
||||
this.near = null;
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// If we are indeed navigating away from this discussion, then disable the
|
||||
// discussion list pane. Also, if we're composing a reply to this
|
||||
// discussion, minimize the composer – unless it's empty, in which case
|
||||
// we'll just close it.
|
||||
app.pane.disable();
|
||||
|
||||
if (app.composingReplyTo(this.discussion) && !app.composer.component.content()) {
|
||||
app.composer.hide();
|
||||
} else {
|
||||
app.composer.minimize();
|
||||
}
|
||||
}
|
||||
|
||||
view() {
|
||||
const discussion = this.discussion;
|
||||
|
||||
return (
|
||||
<div className="DiscussionPage">
|
||||
{app.cache.discussionList
|
||||
? <div className="DiscussionPage-list" config={this.configPane.bind(this)}>
|
||||
{!$('.App-navigation').is(':visible') ? app.cache.discussionList.render() : ''}
|
||||
</div>
|
||||
: ''}
|
||||
|
||||
<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">
|
||||
{this.stream.render()}
|
||||
</div>
|
||||
</div>
|
||||
]
|
||||
: LoadingIndicator.component({className: 'LoadingIndicator--block'})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear and reload the discussion.
|
||||
*/
|
||||
refresh() {
|
||||
this.near = m.route.param('near') || 0;
|
||||
this.discussion = null;
|
||||
|
||||
const preloadedDiscussion = app.preloadedDocument();
|
||||
if (preloadedDiscussion) {
|
||||
// We must wrap this in a setTimeout because if we are mounting this
|
||||
// component for the first time on page load, then any calls to m.redraw
|
||||
// will be ineffective and thus any configs (scroll code) will be run
|
||||
// before stuff is drawn to the page.
|
||||
setTimeout(this.show.bind(this, preloadedDiscussion), 0);
|
||||
} else {
|
||||
const params = this.requestParams();
|
||||
|
||||
app.store.find('discussions', m.route.param('id').split('-')[0], params)
|
||||
.then(this.show.bind(this));
|
||||
}
|
||||
|
||||
m.lazyRedraw();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the parameters that should be passed in the API request to get the
|
||||
* discussion.
|
||||
*
|
||||
* @return {Object}
|
||||
*/
|
||||
requestParams() {
|
||||
return {
|
||||
page: {near: this.near}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize the component to display the given discussion.
|
||||
*
|
||||
* @param {Discussion} discussion
|
||||
*/
|
||||
show(discussion) {
|
||||
this.discussion = discussion;
|
||||
|
||||
app.history.push('discussion', discussion.title());
|
||||
app.setTitle(discussion.title());
|
||||
app.setTitleCount(0);
|
||||
|
||||
// When the API responds with a discussion, it will also include a number of
|
||||
// posts. Some of these posts are included because they are on the first
|
||||
// page of posts we want to display (determined by the `near` parameter) –
|
||||
// others may be included because due to other relationships introduced by
|
||||
// extensions. We need to distinguish the two so we don't end up displaying
|
||||
// the wrong posts. We do so by filtering out the posts that don't have
|
||||
// the 'discussion' relationship linked, then sorting and splicing.
|
||||
let includedPosts = [];
|
||||
if (discussion.payload && discussion.payload.included) {
|
||||
includedPosts = discussion.payload.included
|
||||
.filter(record => record.type === 'posts' && record.relationships && record.relationships.discussion)
|
||||
.map(record => app.store.getById('posts', record.id))
|
||||
.sort((a, b) => a.id() - b.id())
|
||||
.slice(0, 20);
|
||||
}
|
||||
|
||||
// Set up the post stream for this discussion, along with the first page of
|
||||
// posts we want to display. Tell the stream to scroll down and highlight
|
||||
// the specific post that was routed to.
|
||||
this.stream = new PostStream({discussion, includedPosts});
|
||||
this.stream.on('positionChanged', this.positionChanged.bind(this));
|
||||
this.stream.goToNumber(m.route.param('near') || (includedPosts[0] && includedPosts[0].number()), true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Configure the discussion list pane.
|
||||
*
|
||||
* @param {DOMElement} element
|
||||
* @param {Boolean} isInitialized
|
||||
* @param {Object} context
|
||||
*/
|
||||
configPane(element, isInitialized, context) {
|
||||
if (isInitialized) return;
|
||||
|
||||
context.retain = true;
|
||||
|
||||
const $list = $(element);
|
||||
|
||||
// When the mouse enters and leaves the discussions pane, we want to show
|
||||
// and hide the pane respectively. We also create a 10px 'hot edge' on the
|
||||
// left of the screen to activate the pane.
|
||||
const pane = app.pane;
|
||||
$list.hover(pane.show.bind(pane), pane.onmouseleave.bind(pane));
|
||||
|
||||
const hotEdge = e => {
|
||||
if (e.pageX < 10) pane.show();
|
||||
};
|
||||
$(document).on('mousemove', hotEdge);
|
||||
context.onunload = () => $(document).off('mousemove', hotEdge);
|
||||
|
||||
// If the discussion we are viewing is listed in the discussion list, then
|
||||
// we will make sure it is visible in the viewport – if it is not we will
|
||||
// scroll the list down to it.
|
||||
const $discussion = $list.find('.DiscussionListItem.active');
|
||||
if ($discussion.length) {
|
||||
const listTop = $list.offset().top;
|
||||
const listBottom = listTop + $list.outerHeight();
|
||||
const discussionTop = $discussion.offset().top;
|
||||
const discussionBottom = discussionTop + $discussion.outerHeight();
|
||||
|
||||
if (discussionTop < listTop || discussionBottom > listBottom) {
|
||||
$list.scrollTop($list.scrollTop() - listTop + discussionTop);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Build an item list for the contents of the sidebar.
|
||||
*
|
||||
* @return {ItemList}
|
||||
*/
|
||||
sidebarItems() {
|
||||
const items = new ItemList();
|
||||
|
||||
items.add('controls',
|
||||
SplitDropdown.component({
|
||||
children: DiscussionControls.controls(this.discussion, this).toArray(),
|
||||
icon: 'ellipsis-v',
|
||||
className: 'App-primaryControl',
|
||||
buttonClassName: 'Button--primary'
|
||||
})
|
||||
);
|
||||
|
||||
items.add('scrubber',
|
||||
PostStreamScrubber.component({
|
||||
stream: this.stream,
|
||||
className: 'App-titleControl'
|
||||
}),
|
||||
-100
|
||||
);
|
||||
|
||||
return items;
|
||||
}
|
||||
|
||||
/**
|
||||
* When the posts that are visible in the post stream change (i.e. the user
|
||||
* scrolls up or down), then we update the URL and mark the posts as read.
|
||||
*
|
||||
* @param {Integer} startNumber
|
||||
* @param {Integer} endNumber
|
||||
*/
|
||||
positionChanged(startNumber, endNumber) {
|
||||
const discussion = this.discussion;
|
||||
|
||||
// Construct a URL to this discussion with the updated position, then
|
||||
// replace it into the window's history and our own history stack.
|
||||
const url = app.route.discussion(discussion, this.near = startNumber);
|
||||
|
||||
m.route(url, true);
|
||||
window.history.replaceState(null, document.title, url);
|
||||
|
||||
app.history.push('discussion', discussion.title());
|
||||
|
||||
// If the user hasn't read past here before, then we'll update their read
|
||||
// state and redraw.
|
||||
if (app.session.user && endNumber > (discussion.readNumber() || 0)) {
|
||||
discussion.save({readNumber: endNumber});
|
||||
m.redraw();
|
||||
}
|
||||
}
|
||||
}
|
@@ -1,25 +1,25 @@
|
||||
import Notification from './Notification';
|
||||
import Notification from 'flarum/components/Notification';
|
||||
|
||||
/**
|
||||
* The `DiscussionRenamedNotification` component displays a notification which
|
||||
* indicates that a discussion has had its title changed.
|
||||
*
|
||||
* ### Attrs
|
||||
* ### Props
|
||||
*
|
||||
* - All of the attrs for Notification
|
||||
* - All of the props for Notification
|
||||
*/
|
||||
export default class DiscussionRenamedNotification extends Notification {
|
||||
icon() {
|
||||
return 'fas fa-pencil-alt';
|
||||
return 'pencil';
|
||||
}
|
||||
|
||||
href() {
|
||||
const notification = this.attrs.notification;
|
||||
const notification = this.props.notification;
|
||||
|
||||
return app.route.discussion(notification.subject(), notification.content().postNumber);
|
||||
}
|
||||
|
||||
content() {
|
||||
return app.translator.trans('core.forum.notifications.discussion_renamed_text', { user: this.attrs.notification.fromUser() });
|
||||
return app.translator.trans('core.forum.notifications.discussion_renamed_text', {user: this.props.notification.sender()});
|
||||
}
|
||||
}
|
@@ -1,17 +1,17 @@
|
||||
import EventPost from './EventPost';
|
||||
import extractText from '../../common/utils/extractText';
|
||||
import EventPost from 'flarum/components/EventPost';
|
||||
import extractText from 'flarum/utils/extractText';
|
||||
|
||||
/**
|
||||
* The `DiscussionRenamedPost` component displays a discussion event post
|
||||
* indicating that the discussion has been renamed.
|
||||
*
|
||||
* ### Attrs
|
||||
* ### Props
|
||||
*
|
||||
* - All of the attrs for EventPost
|
||||
* - All of the props for EventPost
|
||||
*/
|
||||
export default class DiscussionRenamedPost extends EventPost {
|
||||
icon() {
|
||||
return 'fas fa-pencil-alt';
|
||||
return 'pencil';
|
||||
}
|
||||
|
||||
description(data) {
|
||||
@@ -22,13 +22,13 @@ export default class DiscussionRenamedPost extends EventPost {
|
||||
}
|
||||
|
||||
descriptionData() {
|
||||
const post = this.attrs.post;
|
||||
const post = this.props.post;
|
||||
const oldTitle = post.content()[0];
|
||||
const newTitle = post.content()[1];
|
||||
|
||||
return {
|
||||
old: oldTitle,
|
||||
new: <strong className="DiscussionRenamedPost-new">{newTitle}</strong>,
|
||||
'old': oldTitle,
|
||||
'new': <strong className="DiscussionRenamedPost-new">{newTitle}</strong>
|
||||
};
|
||||
}
|
||||
}
|
58
js/forum/src/components/DiscussionsSearchSource.tsx
Normal file
58
js/forum/src/components/DiscussionsSearchSource.tsx
Normal file
@@ -0,0 +1,58 @@
|
||||
import highlight from 'flarum/helpers/highlight';
|
||||
import LinkButton from 'flarum/components/LinkButton';
|
||||
|
||||
/**
|
||||
* The `DiscussionsSearchSource` finds and displays discussion search results in
|
||||
* the search dropdown.
|
||||
*
|
||||
* @implements SearchSource
|
||||
*/
|
||||
export default class DiscussionsSearchSource {
|
||||
constructor() {
|
||||
this.results = {};
|
||||
}
|
||||
|
||||
search(query) {
|
||||
query = query.toLowerCase();
|
||||
|
||||
this.results[query] = [];
|
||||
|
||||
const params = {
|
||||
filter: {q: query},
|
||||
page: {limit: 3},
|
||||
include: 'relevantPosts,relevantPosts.discussion,relevantPosts.user'
|
||||
};
|
||||
|
||||
return app.store.find('discussions', params).then(results => this.results[query] = results);
|
||||
}
|
||||
|
||||
view(query) {
|
||||
query = query.toLowerCase();
|
||||
|
||||
const results = this.results[query] || [];
|
||||
|
||||
return [
|
||||
<li className="Dropdown-header">{app.translator.trans('core.forum.search.discussions_heading')}</li>,
|
||||
<li>
|
||||
{LinkButton.component({
|
||||
icon: 'search',
|
||||
children: app.translator.trans('core.forum.search.all_discussions_button', {query}),
|
||||
href: app.route('index', {q: query})
|
||||
})}
|
||||
</li>,
|
||||
results.map(discussion => {
|
||||
const relevantPosts = discussion.relevantPosts();
|
||||
const post = relevantPosts && relevantPosts[0];
|
||||
|
||||
return (
|
||||
<li className="DiscussionSearchResult" data-index={'discussions' + discussion.id()}>
|
||||
<a href={app.route.discussion(discussion, post && post.number())} config={m.route}>
|
||||
<div className="DiscussionSearchResult-title">{highlight(discussion.title(), query)}</div>
|
||||
{post ? <div className="DiscussionSearchResult-excerpt">{highlight(post.contentPlain(), query, 100)}</div> : ''}
|
||||
</a>
|
||||
</li>
|
||||
);
|
||||
})
|
||||
];
|
||||
}
|
||||
}
|
26
js/forum/src/components/DiscussionsUserPage.tsx
Normal file
26
js/forum/src/components/DiscussionsUserPage.tsx
Normal file
@@ -0,0 +1,26 @@
|
||||
import UserPage from 'flarum/components/UserPage';
|
||||
import DiscussionList from 'flarum/components/DiscussionList';
|
||||
|
||||
/**
|
||||
* The `DiscussionsUserPage` component shows a discussion list inside of a user
|
||||
* page.
|
||||
*/
|
||||
export default class DiscussionsUserPage extends UserPage {
|
||||
init() {
|
||||
super.init();
|
||||
|
||||
this.loadUser(m.route.param('username'));
|
||||
}
|
||||
|
||||
content() {
|
||||
return (
|
||||
<div className="DiscussionsUserPage">
|
||||
{DiscussionList.component({
|
||||
params: {
|
||||
q: 'author:' + this.user.username()
|
||||
}
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
86
js/forum/src/components/EditPostComposer.tsx
Normal file
86
js/forum/src/components/EditPostComposer.tsx
Normal file
@@ -0,0 +1,86 @@
|
||||
import ComposerBody from 'flarum/components/ComposerBody';
|
||||
import icon from 'flarum/helpers/icon';
|
||||
|
||||
function minimizeComposerIfFullScreen(e) {
|
||||
if (app.composer.isFullScreen()) {
|
||||
app.composer.minimize();
|
||||
e.stopPropagation();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The `EditPostComposer` component displays the composer content for editing a
|
||||
* post. It sets the initial content to the content of the post that is being
|
||||
* edited, and adds a header control to indicate which post is being edited.
|
||||
*
|
||||
* ### Props
|
||||
*
|
||||
* - All of the props for ComposerBody
|
||||
* - `post`
|
||||
*/
|
||||
export default class EditPostComposer extends ComposerBody {
|
||||
init() {
|
||||
super.init();
|
||||
|
||||
this.editor.props.preview = e => {
|
||||
minimizeComposerIfFullScreen(e);
|
||||
|
||||
m.route(app.route.post(this.props.post));
|
||||
};
|
||||
}
|
||||
|
||||
static initProps(props) {
|
||||
super.initProps(props);
|
||||
|
||||
props.submitLabel = props.submitLabel || app.translator.trans('core.forum.composer_edit.submit_button');
|
||||
props.confirmExit = props.confirmExit || app.translator.trans('core.forum.composer_edit.discard_confirmation');
|
||||
props.originalContent = props.originalContent || props.post.content();
|
||||
props.user = props.user || props.post.user();
|
||||
|
||||
props.post.editedContent = props.originalContent;
|
||||
}
|
||||
|
||||
headerItems() {
|
||||
const items = super.headerItems();
|
||||
const post = this.props.post;
|
||||
|
||||
const routeAndMinimize = function(element, isInitialized) {
|
||||
if (isInitialized) return;
|
||||
$(element).on('click', minimizeComposerIfFullScreen);
|
||||
m.route.apply(this, arguments);
|
||||
};
|
||||
|
||||
items.add('title', (
|
||||
<h3>
|
||||
{icon('pencil')} {' '}
|
||||
<a href={app.route.discussion(post.discussion(), post.number())} config={routeAndMinimize}>
|
||||
{app.translator.trans('core.forum.composer_edit.post_link', {number: post.number(), discussion: post.discussion().title()})}
|
||||
</a>
|
||||
</h3>
|
||||
));
|
||||
|
||||
return items;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the data to submit to the server when the post is saved.
|
||||
*
|
||||
* @return {Object}
|
||||
*/
|
||||
data() {
|
||||
return {
|
||||
content: this.content()
|
||||
};
|
||||
}
|
||||
|
||||
onsubmit() {
|
||||
this.loading = true;
|
||||
|
||||
const data = this.data();
|
||||
|
||||
this.props.post.save(data).then(
|
||||
() => app.composer.hide(),
|
||||
this.loaded.bind(this)
|
||||
);
|
||||
}
|
||||
}
|
165
js/forum/src/components/EditUserModal.tsx
Normal file
165
js/forum/src/components/EditUserModal.tsx
Normal file
@@ -0,0 +1,165 @@
|
||||
import Modal from 'flarum/components/Modal';
|
||||
import Button from 'flarum/components/Button';
|
||||
import GroupBadge from 'flarum/components/GroupBadge';
|
||||
import Group from 'flarum/models/Group';
|
||||
import extractText from 'flarum/utils/extractText';
|
||||
|
||||
/**
|
||||
* The `EditUserModal` component displays a modal dialog with a login form.
|
||||
*/
|
||||
export default class EditUserModal extends Modal {
|
||||
init() {
|
||||
super.init();
|
||||
|
||||
const user = this.props.user;
|
||||
|
||||
this.username = m.prop(user.username() || '');
|
||||
this.email = m.prop(user.email() || '');
|
||||
this.isActivated = m.prop(user.isActivated() || false);
|
||||
this.setPassword = m.prop(false);
|
||||
this.password = m.prop(user.password() || '');
|
||||
this.groups = {};
|
||||
|
||||
app.store.all('groups')
|
||||
.filter(group => [Group.GUEST_ID, Group.MEMBER_ID].indexOf(group.id()) === -1)
|
||||
.forEach(group => this.groups[group.id()] = m.prop(user.groups().indexOf(group) !== -1));
|
||||
}
|
||||
|
||||
className() {
|
||||
return 'EditUserModal Modal--small';
|
||||
}
|
||||
|
||||
title() {
|
||||
return app.translator.trans('core.forum.edit_user.title');
|
||||
}
|
||||
|
||||
content() {
|
||||
return (
|
||||
<div className="Modal-body">
|
||||
<div className="Form">
|
||||
<div className="Form-group">
|
||||
<label>{app.translator.trans('core.forum.edit_user.username_heading')}</label>
|
||||
<input className="FormControl" placeholder={extractText(app.translator.trans('core.forum.edit_user.username_label'))}
|
||||
bidi={this.username} />
|
||||
</div>
|
||||
|
||||
{app.session.user !== this.props.user ? [
|
||||
<div className="Form-group">
|
||||
<label>{app.translator.trans('core.forum.edit_user.email_heading')}</label>
|
||||
<div>
|
||||
<input className="FormControl" placeholder={extractText(app.translator.trans('core.forum.edit_user.email_label'))}
|
||||
bidi={this.email} />
|
||||
</div>
|
||||
{!this.isActivated() ? (
|
||||
<div>
|
||||
{Button.component({
|
||||
className: 'Button Button--block',
|
||||
children: app.translator.trans('core.forum.edit_user.activate_button'),
|
||||
loading: this.loading,
|
||||
onclick: this.activate.bind(this)
|
||||
})}
|
||||
</div>
|
||||
) : ''}
|
||||
</div>,
|
||||
|
||||
<div className="Form-group">
|
||||
<label>{app.translator.trans('core.forum.edit_user.password_heading')}</label>
|
||||
<div>
|
||||
<label className="checkbox">
|
||||
<input type="checkbox" checked={this.setPassword()} onchange={e => {
|
||||
this.setPassword(e.target.checked);
|
||||
m.redraw(true);
|
||||
if (e.target.checked) this.$('[name=password]').select();
|
||||
m.redraw.strategy('none');
|
||||
}}/>
|
||||
{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.forum.edit_user.password_label'))}
|
||||
bidi={this.password} />
|
||||
) : ''}
|
||||
</div>
|
||||
</div>
|
||||
] : ''}
|
||||
|
||||
<div className="Form-group EditUserModal-groups">
|
||||
<label>{app.translator.trans('core.forum.edit_user.groups_heading')}</label>
|
||||
<div>
|
||||
{Object.keys(this.groups)
|
||||
.map(id => app.store.getById('groups', id))
|
||||
.map(group => (
|
||||
<label className="checkbox">
|
||||
<input type="checkbox"
|
||||
bidi={this.groups[group.id()]}
|
||||
disabled={this.props.user.id() === '1' && group.id() === Group.ADMINISTRATOR_ID} />
|
||||
{GroupBadge.component({group, label: ''})} {group.nameSingular()}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="Form-group">
|
||||
{Button.component({
|
||||
className: 'Button Button--primary',
|
||||
type: 'submit',
|
||||
loading: this.loading,
|
||||
children: app.translator.trans('core.forum.edit_user.submit_button')
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
activate() {
|
||||
this.loading = true;
|
||||
const data = {
|
||||
username: this.username(),
|
||||
isActivated: true,
|
||||
};
|
||||
this.props.user.save(data, {errorHandler: this.onerror.bind(this)})
|
||||
.then(() => {
|
||||
this.isActivated(true);
|
||||
this.loading = false;
|
||||
m.redraw();
|
||||
})
|
||||
.catch(() => {
|
||||
this.loading = false;
|
||||
m.redraw();
|
||||
});
|
||||
}
|
||||
|
||||
data() {
|
||||
const groups = Object.keys(this.groups)
|
||||
.filter(id => this.groups[id]())
|
||||
.map(id => app.store.getById('groups', id));
|
||||
|
||||
const data = {
|
||||
username: this.username(),
|
||||
relationships: {groups}
|
||||
};
|
||||
|
||||
if (app.session.user !== this.props.user) {
|
||||
data.email = this.email();
|
||||
}
|
||||
|
||||
if (this.setPassword()) {
|
||||
data.password = this.password();
|
||||
}
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
onsubmit(e) {
|
||||
e.preventDefault();
|
||||
|
||||
this.loading = true;
|
||||
|
||||
this.props.user.save(this.data(), {errorHandler: this.onerror.bind(this)})
|
||||
.then(this.hide.bind(this))
|
||||
.catch(() => {
|
||||
this.loading = false;
|
||||
m.redraw();
|
||||
});
|
||||
}
|
||||
}
|
@@ -1,44 +1,44 @@
|
||||
import Post from './Post';
|
||||
import { ucfirst } from '../../common/utils/string';
|
||||
import usernameHelper from '../../common/helpers/username';
|
||||
import icon from '../../common/helpers/icon';
|
||||
import Link from '../../common/components/Link';
|
||||
import Post from 'flarum/components/Post';
|
||||
import { ucfirst } from 'flarum/utils/string';
|
||||
import usernameHelper from 'flarum/helpers/username';
|
||||
import icon from 'flarum/helpers/icon';
|
||||
|
||||
/**
|
||||
* The `EventPost` component displays a post which indicating a discussion
|
||||
* event, like a discussion being renamed or stickied. Subclasses must implement
|
||||
* the `icon` and `description` methods.
|
||||
*
|
||||
* ### Attrs
|
||||
* ### Props
|
||||
*
|
||||
* - All of the attrs for `Post`
|
||||
* - All of the props for `Post`
|
||||
*
|
||||
* @abstract
|
||||
*/
|
||||
export default class EventPost extends Post {
|
||||
elementAttrs() {
|
||||
const attrs = super.elementAttrs();
|
||||
attrs() {
|
||||
const attrs = super.attrs();
|
||||
|
||||
attrs.className = (attrs.className || '') + ' EventPost ' + ucfirst(this.attrs.post.contentType()) + 'Post';
|
||||
attrs.className += ' EventPost ' + ucfirst(this.props.post.contentType()) + 'Post';
|
||||
|
||||
return attrs;
|
||||
}
|
||||
|
||||
content() {
|
||||
const user = this.attrs.post.user();
|
||||
const user = this.props.post.user();
|
||||
const username = usernameHelper(user);
|
||||
const data = Object.assign(this.descriptionData(), {
|
||||
user,
|
||||
username: user ? (
|
||||
<Link className="EventPost-user" href={app.route.user(user)}>
|
||||
{username}
|
||||
</Link>
|
||||
) : (
|
||||
username
|
||||
),
|
||||
username: user
|
||||
? <a className="EventPost-user" href={app.route.user(user)} config={m.route}>{username}</a>
|
||||
: username
|
||||
});
|
||||
|
||||
return super.content().concat([icon(this.icon(), { className: 'EventPost-icon' }), <div class="EventPost-info">{this.description(data)}</div>]);
|
||||
return super.content().concat([
|
||||
icon(this.icon(), {className: 'EventPost-icon'}),
|
||||
<div class="EventPost-info">
|
||||
{this.description(data)}
|
||||
</div>
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
@@ -1,26 +1,26 @@
|
||||
import Modal from '../../common/components/Modal';
|
||||
import Button from '../../common/components/Button';
|
||||
import extractText from '../../common/utils/extractText';
|
||||
import Stream from '../../common/utils/Stream';
|
||||
import Modal from 'flarum/components/Modal';
|
||||
import Alert from 'flarum/components/Alert';
|
||||
import Button from 'flarum/components/Button';
|
||||
import extractText from 'flarum/utils/extractText';
|
||||
|
||||
/**
|
||||
* The `ForgotPasswordModal` component displays a modal which allows the user to
|
||||
* enter their email address and request a link to reset their password.
|
||||
*
|
||||
* ### Attrs
|
||||
* ### Props
|
||||
*
|
||||
* - `email`
|
||||
*/
|
||||
export default class ForgotPasswordModal extends Modal {
|
||||
oninit(vnode) {
|
||||
super.oninit(vnode);
|
||||
init() {
|
||||
super.init();
|
||||
|
||||
/**
|
||||
* The value of the email input.
|
||||
*
|
||||
* @type {Function}
|
||||
*/
|
||||
this.email = Stream(this.attrs.email || '');
|
||||
this.email = m.prop(this.props.email || '');
|
||||
|
||||
/**
|
||||
* Whether or not the password reset email was sent successfully.
|
||||
@@ -59,24 +59,18 @@ export default class ForgotPasswordModal extends Modal {
|
||||
<div className="Form Form--centered">
|
||||
<p className="helpText">{app.translator.trans('core.forum.forgot_password.text')}</p>
|
||||
<div className="Form-group">
|
||||
<input
|
||||
className="FormControl"
|
||||
name="email"
|
||||
type="email"
|
||||
placeholder={extractText(app.translator.trans('core.forum.forgot_password.email_placeholder'))}
|
||||
bidi={this.email}
|
||||
disabled={this.loading}
|
||||
/>
|
||||
<input className="FormControl" name="email" type="email" placeholder={extractText(app.translator.trans('core.forum.forgot_password.email_placeholder'))}
|
||||
value={this.email()}
|
||||
onchange={m.withAttr('value', this.email)}
|
||||
disabled={this.loading} />
|
||||
</div>
|
||||
<div className="Form-group">
|
||||
{Button.component(
|
||||
{
|
||||
className: 'Button Button--primary Button--block',
|
||||
type: 'submit',
|
||||
loading: this.loading,
|
||||
},
|
||||
app.translator.trans('core.forum.forgot_password.submit_button')
|
||||
)}
|
||||
{Button.component({
|
||||
className: 'Button Button--primary Button--block',
|
||||
type: 'submit',
|
||||
loading: this.loading,
|
||||
children: app.translator.trans('core.forum.forgot_password.submit_button')
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -88,13 +82,12 @@ export default class ForgotPasswordModal extends Modal {
|
||||
|
||||
this.loading = true;
|
||||
|
||||
app
|
||||
.request({
|
||||
method: 'POST',
|
||||
url: app.forum.attribute('apiUrl') + '/forgot',
|
||||
body: { email: this.email() },
|
||||
errorHandler: this.onerror.bind(this),
|
||||
})
|
||||
app.request({
|
||||
method: 'POST',
|
||||
url: app.forum.attribute('apiUrl') + '/forgot',
|
||||
data: {email: this.email()},
|
||||
errorHandler: this.onerror.bind(this)
|
||||
})
|
||||
.then(() => {
|
||||
this.success = true;
|
||||
this.alert = null;
|
||||
@@ -105,7 +98,7 @@ export default class ForgotPasswordModal extends Modal {
|
||||
|
||||
onerror(error) {
|
||||
if (error.status === 404) {
|
||||
error.alert.content = app.translator.trans('core.forum.forgot_password.not_found_message');
|
||||
error.alert.props.children = app.translator.trans('core.forum.forgot_password.not_found_message');
|
||||
}
|
||||
|
||||
super.onerror(error);
|
@@ -1,6 +1,6 @@
|
||||
import Component from '../../common/Component';
|
||||
import ItemList from '../../common/utils/ItemList';
|
||||
import listItems from '../../common/helpers/listItems';
|
||||
import Component from 'flarum/Component';
|
||||
import ItemList from 'flarum/utils/ItemList';
|
||||
import listItems from 'flarum/helpers/listItems';
|
||||
|
||||
/**
|
||||
* The `HeaderPrimary` component displays primary header controls. On the
|
||||
@@ -8,7 +8,11 @@ import listItems from '../../common/helpers/listItems';
|
||||
*/
|
||||
export default class HeaderPrimary extends Component {
|
||||
view() {
|
||||
return <ul className="Header-controls">{listItems(this.items().toArray())}</ul>;
|
||||
return (
|
||||
<ul className="Header-controls">
|
||||
{listItems(this.items().toArray())}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
|
||||
config(isInitialized, context) {
|
92
js/forum/src/components/HeaderSecondary.tsx
Normal file
92
js/forum/src/components/HeaderSecondary.tsx
Normal file
@@ -0,0 +1,92 @@
|
||||
import Component from 'flarum/Component';
|
||||
import Button from 'flarum/components/Button';
|
||||
import LogInModal from 'flarum/components/LogInModal';
|
||||
import SignUpModal from 'flarum/components/SignUpModal';
|
||||
import SessionDropdown from 'flarum/components/SessionDropdown';
|
||||
import SelectDropdown from 'flarum/components/SelectDropdown';
|
||||
import NotificationsDropdown from 'flarum/components/NotificationsDropdown';
|
||||
import ItemList from 'flarum/utils/ItemList';
|
||||
import listItems from 'flarum/helpers/listItems';
|
||||
|
||||
/**
|
||||
* The `HeaderSecondary` component displays secondary header controls, such as
|
||||
* the search box and the user menu. On the default skin, these are shown on the
|
||||
* right side of the header.
|
||||
*/
|
||||
export default class HeaderSecondary extends Component {
|
||||
view() {
|
||||
return (
|
||||
<ul className="Header-controls">
|
||||
{listItems(this.items().toArray())}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
|
||||
config(isInitialized, context) {
|
||||
// Since this component is 'above' the content of the page (that is, it is a
|
||||
// part of the global UI that persists between routes), we will flag the DOM
|
||||
// to be retained across route changes.
|
||||
context.retain = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build an item list for the controls.
|
||||
*
|
||||
* @return {ItemList}
|
||||
*/
|
||||
items() {
|
||||
const items = new ItemList();
|
||||
|
||||
items.add('search', app.search.render(), 30);
|
||||
|
||||
if (app.forum.attribute("showLanguageSelector") && Object.keys(app.data.locales).length > 1) {
|
||||
const locales = [];
|
||||
|
||||
for (const locale in app.data.locales) {
|
||||
locales.push(Button.component({
|
||||
active: app.data.locale === locale,
|
||||
children: app.data.locales[locale],
|
||||
icon: app.data.locale === locale ? 'check' : true,
|
||||
onclick: () => {
|
||||
if (app.session.user) {
|
||||
app.session.user.savePreferences({locale}).then(() => window.location.reload());
|
||||
} else {
|
||||
document.cookie = `locale=${locale}; path=/; expires=Tue, 19 Jan 2038 03:14:07 GMT`;
|
||||
window.location.reload();
|
||||
}
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
items.add('locale', SelectDropdown.component({
|
||||
children: locales,
|
||||
buttonClassName: 'Button Button--link'
|
||||
}), 20);
|
||||
}
|
||||
|
||||
if (app.session.user) {
|
||||
items.add('notifications', NotificationsDropdown.component(), 10);
|
||||
items.add('session', SessionDropdown.component(), 0);
|
||||
} else {
|
||||
if (app.forum.attribute('allowSignUp')) {
|
||||
items.add('signUp',
|
||||
Button.component({
|
||||
children: app.translator.trans('core.forum.header.sign_up_link'),
|
||||
className: 'Button Button--link',
|
||||
onclick: () => app.modal.show(new SignUpModal())
|
||||
}), 10
|
||||
);
|
||||
}
|
||||
|
||||
items.add('logIn',
|
||||
Button.component({
|
||||
children: app.translator.trans('core.forum.header.log_in_link'),
|
||||
className: 'Button Button--link',
|
||||
onclick: () => app.modal.show(new LogInModal())
|
||||
}), 0
|
||||
);
|
||||
}
|
||||
|
||||
return items;
|
||||
}
|
||||
}
|
379
js/forum/src/components/IndexPage.tsx
Normal file
379
js/forum/src/components/IndexPage.tsx
Normal file
@@ -0,0 +1,379 @@
|
||||
import { extend } from 'flarum/extend';
|
||||
import Page from 'flarum/components/Page';
|
||||
import ItemList from 'flarum/utils/ItemList';
|
||||
import listItems from 'flarum/helpers/listItems';
|
||||
import icon from 'flarum/helpers/icon';
|
||||
import DiscussionList from 'flarum/components/DiscussionList';
|
||||
import WelcomeHero from 'flarum/components/WelcomeHero';
|
||||
import DiscussionComposer from 'flarum/components/DiscussionComposer';
|
||||
import LogInModal from 'flarum/components/LogInModal';
|
||||
import DiscussionPage from 'flarum/components/DiscussionPage';
|
||||
import Select from 'flarum/components/Select';
|
||||
import Button from 'flarum/components/Button';
|
||||
import LinkButton from 'flarum/components/LinkButton';
|
||||
import SelectDropdown from 'flarum/components/SelectDropdown';
|
||||
|
||||
/**
|
||||
* The `IndexPage` component displays the index page, including the welcome
|
||||
* hero, the sidebar, and the discussion list.
|
||||
*/
|
||||
export default class IndexPage extends Page {
|
||||
init() {
|
||||
super.init();
|
||||
|
||||
// If the user is returning from a discussion page, then take note of which
|
||||
// discussion they have just visited. After the view is rendered, we will
|
||||
// scroll down so that this discussion is in view.
|
||||
if (app.previous instanceof DiscussionPage) {
|
||||
this.lastDiscussion = app.previous.discussion;
|
||||
}
|
||||
|
||||
// If the user is coming from the discussion list, then they have either
|
||||
// just switched one of the parameters (filter, sort, search) or they
|
||||
// probably want to refresh the results. We will clear the discussion list
|
||||
// cache so that results are reloaded.
|
||||
if (app.previous instanceof IndexPage) {
|
||||
app.cache.discussionList = null;
|
||||
}
|
||||
|
||||
const params = this.params();
|
||||
|
||||
if (app.cache.discussionList) {
|
||||
// Compare the requested parameters (sort, search query) to the ones that
|
||||
// are currently present in the cached discussion list. If they differ, we
|
||||
// will clear the cache and set up a new discussion list component with
|
||||
// the new parameters.
|
||||
Object.keys(params).some(key => {
|
||||
if (app.cache.discussionList.props.params[key] !== params[key]) {
|
||||
app.cache.discussionList = null;
|
||||
return true;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (!app.cache.discussionList) {
|
||||
app.cache.discussionList = new DiscussionList({params});
|
||||
}
|
||||
|
||||
app.history.push('index', icon('bars'));
|
||||
|
||||
this.bodyClass = 'App--index';
|
||||
}
|
||||
|
||||
onunload() {
|
||||
// Save the scroll position so we can restore it when we return to the
|
||||
// discussion list.
|
||||
app.cache.scrollTop = $(window).scrollTop();
|
||||
}
|
||||
|
||||
view() {
|
||||
return (
|
||||
<div className="IndexPage">
|
||||
{this.hero()}
|
||||
<div className="container">
|
||||
<nav className="IndexPage-nav sideNav">
|
||||
<ul>{listItems(this.sidebarItems().toArray())}</ul>
|
||||
</nav>
|
||||
<div className="IndexPage-results sideNavOffset">
|
||||
<div className="IndexPage-toolbar">
|
||||
<ul className="IndexPage-toolbar-view">{listItems(this.viewItems().toArray())}</ul>
|
||||
<ul className="IndexPage-toolbar-action">{listItems(this.actionItems().toArray())}</ul>
|
||||
</div>
|
||||
{app.cache.discussionList.render()}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
config(isInitialized, context) {
|
||||
super.config(...arguments);
|
||||
|
||||
if (isInitialized) return;
|
||||
|
||||
extend(context, 'onunload', () => $('#app').css('min-height', ''));
|
||||
|
||||
app.setTitle('');
|
||||
app.setTitleCount(0);
|
||||
|
||||
// Work out the difference between the height of this hero and that of the
|
||||
// previous hero. Maintain the same scroll position relative to the bottom
|
||||
// of the hero so that the sidebar doesn't jump around.
|
||||
const oldHeroHeight = app.cache.heroHeight;
|
||||
const heroHeight = app.cache.heroHeight = this.$('.Hero').outerHeight();
|
||||
const scrollTop = app.cache.scrollTop;
|
||||
|
||||
$('#app').css('min-height', $(window).height() + heroHeight);
|
||||
|
||||
// Scroll to the remembered position. We do this after a short delay so that
|
||||
// it happens after the browser has done its own "back button" scrolling,
|
||||
// which isn't right. https://github.com/flarum/core/issues/835
|
||||
const scroll = () => $(window).scrollTop(scrollTop - oldHeroHeight + heroHeight);
|
||||
scroll();
|
||||
setTimeout(scroll, 1);
|
||||
|
||||
// If we've just returned from a discussion page, then the constructor will
|
||||
// have set the `lastDiscussion` property. If this is the case, we want to
|
||||
// scroll down to that discussion so that it's in view.
|
||||
if (this.lastDiscussion) {
|
||||
const $discussion = this.$(`.DiscussionListItem[data-id="${this.lastDiscussion.id()}"]`);
|
||||
|
||||
if ($discussion.length) {
|
||||
const indexTop = $('#header').outerHeight();
|
||||
const indexBottom = $(window).height();
|
||||
const discussionTop = $discussion.offset().top;
|
||||
const discussionBottom = discussionTop + $discussion.outerHeight();
|
||||
|
||||
if (discussionTop < scrollTop + indexTop || discussionBottom > scrollTop + indexBottom) {
|
||||
$(window).scrollTop(discussionTop - indexTop);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the component to display as the hero.
|
||||
*
|
||||
* @return {MithrilComponent}
|
||||
*/
|
||||
hero() {
|
||||
return WelcomeHero.component();
|
||||
}
|
||||
|
||||
/**
|
||||
* Build an item list for the sidebar of the index page. By default this is a
|
||||
* "New Discussion" button, and then a DropdownSelect component containing a
|
||||
* list of navigation items.
|
||||
*
|
||||
* @return {ItemList}
|
||||
*/
|
||||
sidebarItems() {
|
||||
const items = new ItemList();
|
||||
const canStartDiscussion = app.forum.attribute('canStartDiscussion') || !app.session.user;
|
||||
|
||||
items.add('newDiscussion',
|
||||
Button.component({
|
||||
children: app.translator.trans(canStartDiscussion ? 'core.forum.index.start_discussion_button' : 'core.forum.index.cannot_start_discussion_button'),
|
||||
icon: 'edit',
|
||||
className: 'Button Button--primary IndexPage-newDiscussion',
|
||||
itemClassName: 'App-primaryControl',
|
||||
onclick: this.newDiscussion.bind(this),
|
||||
disabled: !canStartDiscussion
|
||||
})
|
||||
);
|
||||
|
||||
items.add('nav',
|
||||
SelectDropdown.component({
|
||||
children: this.navItems(this).toArray(),
|
||||
buttonClassName: 'Button',
|
||||
className: 'App-titleControl'
|
||||
})
|
||||
);
|
||||
|
||||
return items;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build an item list for the navigation in the sidebar of the index page. By
|
||||
* default this is just the 'All Discussions' link.
|
||||
*
|
||||
* @return {ItemList}
|
||||
*/
|
||||
navItems() {
|
||||
const items = new ItemList();
|
||||
const params = this.stickyParams();
|
||||
|
||||
items.add('allDiscussions',
|
||||
LinkButton.component({
|
||||
href: app.route('index', params),
|
||||
children: app.translator.trans('core.forum.index.all_discussions_link'),
|
||||
icon: 'comments-o'
|
||||
}),
|
||||
100
|
||||
);
|
||||
|
||||
return items;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build an item list for the part of the toolbar which is concerned with how
|
||||
* the results are displayed. By default this is just a select box to change
|
||||
* the way discussions are sorted.
|
||||
*
|
||||
* @return {ItemList}
|
||||
*/
|
||||
viewItems() {
|
||||
const items = new ItemList();
|
||||
const sortMap = app.cache.discussionList.sortMap();
|
||||
|
||||
const sortOptions = {};
|
||||
for (const i in sortMap) {
|
||||
sortOptions[i] = app.translator.trans('core.forum.index_sort.' + i + '_button');
|
||||
}
|
||||
|
||||
items.add('sort',
|
||||
Select.component({
|
||||
options: sortOptions,
|
||||
value: this.params().sort || Object.keys(sortMap)[0],
|
||||
onchange: this.changeSort.bind(this)
|
||||
})
|
||||
);
|
||||
|
||||
return items;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build an item list for the part of the toolbar which is about taking action
|
||||
* on the results. By default this is just a "mark all as read" button.
|
||||
*
|
||||
* @return {ItemList}
|
||||
*/
|
||||
actionItems() {
|
||||
const items = new ItemList();
|
||||
|
||||
items.add('refresh',
|
||||
Button.component({
|
||||
title: app.translator.trans('core.forum.index.refresh_tooltip'),
|
||||
icon: 'refresh',
|
||||
className: 'Button Button--icon',
|
||||
onclick: () => {
|
||||
app.cache.discussionList.refresh();
|
||||
if (app.session.user) {
|
||||
app.store.find('users', app.session.user.id());
|
||||
m.redraw();
|
||||
}
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
if (app.session.user) {
|
||||
items.add('markAllAsRead',
|
||||
Button.component({
|
||||
title: app.translator.trans('core.forum.index.mark_all_as_read_tooltip'),
|
||||
icon: 'check',
|
||||
className: 'Button Button--icon',
|
||||
onclick: this.markAllAsRead.bind(this)
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
return items;
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the current search query, if any. This is implemented to activate
|
||||
* the search box in the header.
|
||||
*
|
||||
* @see Search
|
||||
* @return {String}
|
||||
*/
|
||||
searching() {
|
||||
return this.params().q;
|
||||
}
|
||||
|
||||
/**
|
||||
* Redirect to the index page without a search filter. This is called when the
|
||||
* 'x' is clicked in the search box in the header.
|
||||
*
|
||||
* @see Search
|
||||
*/
|
||||
clearSearch() {
|
||||
const params = this.params();
|
||||
delete params.q;
|
||||
|
||||
m.route(app.route(this.props.routeName, params));
|
||||
}
|
||||
|
||||
/**
|
||||
* Redirect to the index page using the given sort parameter.
|
||||
*
|
||||
* @param {String} sort
|
||||
*/
|
||||
changeSort(sort) {
|
||||
const params = this.params();
|
||||
|
||||
if (sort === Object.keys(app.cache.discussionList.sortMap())[0]) {
|
||||
delete params.sort;
|
||||
} else {
|
||||
params.sort = sort;
|
||||
}
|
||||
|
||||
m.route(app.route(this.props.routeName, params));
|
||||
}
|
||||
|
||||
/**
|
||||
* Get URL parameters that stick between filter changes.
|
||||
*
|
||||
* @return {Object}
|
||||
*/
|
||||
stickyParams() {
|
||||
return {
|
||||
sort: m.route.param('sort'),
|
||||
q: m.route.param('q')
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get parameters to pass to the DiscussionList component.
|
||||
*
|
||||
* @return {Object}
|
||||
*/
|
||||
params() {
|
||||
const params = this.stickyParams();
|
||||
|
||||
params.filter = m.route.param('filter');
|
||||
|
||||
return params;
|
||||
}
|
||||
|
||||
/**
|
||||
* Log the user in and then open the composer for a new discussion.
|
||||
*
|
||||
* @return {Promise}
|
||||
*/
|
||||
newDiscussion() {
|
||||
const deferred = m.deferred();
|
||||
|
||||
if (app.session.user) {
|
||||
this.composeNewDiscussion(deferred);
|
||||
} else {
|
||||
app.modal.show(
|
||||
new LogInModal({
|
||||
onlogin: this.composeNewDiscussion.bind(this, deferred)
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
return deferred.promise;
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize the composer for a new discussion.
|
||||
*
|
||||
* @param {Deferred} deferred
|
||||
* @return {Promise}
|
||||
*/
|
||||
composeNewDiscussion(deferred) {
|
||||
const component = new DiscussionComposer({user: app.session.user});
|
||||
|
||||
app.composer.load(component);
|
||||
app.composer.show();
|
||||
|
||||
deferred.resolve(component);
|
||||
|
||||
return deferred.promise;
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark all discussions as read.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
markAllAsRead() {
|
||||
const confirmation = confirm(app.translator.trans('core.forum.index.mark_all_as_read_confirmation'));
|
||||
|
||||
if (confirmation) {
|
||||
app.session.user.save({readTime: new Date()});
|
||||
}
|
||||
}
|
||||
}
|
@@ -1,5 +1,5 @@
|
||||
import Component from '../../common/Component';
|
||||
import avatar from '../../common/helpers/avatar';
|
||||
import Component from 'flarum/Component';
|
||||
import avatar from 'flarum/helpers/avatar';
|
||||
|
||||
/**
|
||||
* The `LoadingPost` component shows a placeholder that looks like a post,
|
||||
@@ -10,14 +10,14 @@ export default class LoadingPost extends Component {
|
||||
return (
|
||||
<div className="Post CommentPost LoadingPost">
|
||||
<header className="Post-header">
|
||||
{avatar(null, { className: 'PostUser-avatar' })}
|
||||
<div className="fakeText" />
|
||||
{avatar(null, {className: 'PostUser-avatar'})}
|
||||
<div className="fakeText"/>
|
||||
</header>
|
||||
|
||||
<div className="Post-body">
|
||||
<div className="fakeText" />
|
||||
<div className="fakeText" />
|
||||
<div className="fakeText" />
|
||||
<div className="fakeText"/>
|
||||
<div className="fakeText"/>
|
||||
<div className="fakeText"/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
30
js/forum/src/components/LogInButton.tsx
Normal file
30
js/forum/src/components/LogInButton.tsx
Normal file
@@ -0,0 +1,30 @@
|
||||
import Button from 'flarum/components/Button';
|
||||
|
||||
/**
|
||||
* The `LogInButton` component displays a social login button which will open
|
||||
* a popup window containing the specified path.
|
||||
*
|
||||
* ### Props
|
||||
*
|
||||
* - `path`
|
||||
*/
|
||||
export default class LogInButton extends Button {
|
||||
static initProps(props) {
|
||||
props.className = (props.className || '') + ' LogInButton';
|
||||
|
||||
props.onclick = function() {
|
||||
const width = 600;
|
||||
const height = 400;
|
||||
const $window = $(window);
|
||||
|
||||
window.open(app.forum.attribute('baseUrl') + props.path, 'logInPopup',
|
||||
`width=${width},` +
|
||||
`height=${height},` +
|
||||
`top=${$window.height() / 2 - height / 2},` +
|
||||
`left=${$window.width() / 2 - width / 2},` +
|
||||
'status=no,scrollbars=no,resizable=no');
|
||||
};
|
||||
|
||||
super.initProps(props);
|
||||
}
|
||||
}
|
@@ -1,12 +1,16 @@
|
||||
import Component from '../../common/Component';
|
||||
import ItemList from '../../common/utils/ItemList';
|
||||
import Component from 'flarum/Component';
|
||||
import ItemList from 'flarum/utils/ItemList';
|
||||
|
||||
/**
|
||||
* The `LogInButtons` component displays a collection of social login buttons.
|
||||
*/
|
||||
export default class LogInButtons extends Component {
|
||||
view() {
|
||||
return <div className="LogInButtons">{this.items().toArray()}</div>;
|
||||
return (
|
||||
<div className="LogInButtons">
|
||||
{this.items().toArray()}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
157
js/forum/src/components/LogInModal.tsx
Normal file
157
js/forum/src/components/LogInModal.tsx
Normal file
@@ -0,0 +1,157 @@
|
||||
import Modal from 'flarum/components/Modal';
|
||||
import ForgotPasswordModal from 'flarum/components/ForgotPasswordModal';
|
||||
import SignUpModal from 'flarum/components/SignUpModal';
|
||||
import Alert from 'flarum/components/Alert';
|
||||
import Button from 'flarum/components/Button';
|
||||
import LogInButtons from 'flarum/components/LogInButtons';
|
||||
import Switch from 'flarum/components/Switch';
|
||||
import extractText from 'flarum/utils/extractText';
|
||||
|
||||
/**
|
||||
* The `LogInModal` component displays a modal dialog with a login form.
|
||||
*
|
||||
* ### Props
|
||||
*
|
||||
* - `identification`
|
||||
* - `password`
|
||||
*/
|
||||
export default class LogInModal extends Modal {
|
||||
init() {
|
||||
super.init();
|
||||
|
||||
/**
|
||||
* The value of the identification input.
|
||||
*
|
||||
* @type {Function}
|
||||
*/
|
||||
this.identification = m.prop(this.props.identification || '');
|
||||
|
||||
/**
|
||||
* The value of the password input.
|
||||
*
|
||||
* @type {Function}
|
||||
*/
|
||||
this.password = m.prop(this.props.password || '');
|
||||
|
||||
/**
|
||||
* The value of the remember me input.
|
||||
*
|
||||
* @type {Function}
|
||||
*/
|
||||
this.remember = m.prop(this.props.remember && true);
|
||||
}
|
||||
|
||||
className() {
|
||||
return 'LogInModal Modal--small';
|
||||
}
|
||||
|
||||
title() {
|
||||
return app.translator.trans('core.forum.log_in.title');
|
||||
}
|
||||
|
||||
content() {
|
||||
return [
|
||||
<div className="Modal-body">
|
||||
<LogInButtons/>
|
||||
|
||||
<div className="Form Form--centered">
|
||||
<div className="Form-group">
|
||||
<input className="FormControl" name="identification" type="text" placeholder={extractText(app.translator.trans('core.forum.log_in.username_or_email_placeholder'))}
|
||||
bidi={this.identification}
|
||||
disabled={this.loading} />
|
||||
</div>
|
||||
|
||||
<div className="Form-group">
|
||||
<input className="FormControl" name="password" type="password" placeholder={extractText(app.translator.trans('core.forum.log_in.password_placeholder'))}
|
||||
bidi={this.password}
|
||||
disabled={this.loading} />
|
||||
</div>
|
||||
|
||||
<div className="Form-group">
|
||||
{Switch.component({
|
||||
children: app.translator.trans('core.forum.log_in.remember_me_label'),
|
||||
disabled: this.loading,
|
||||
onchange: this.remember,
|
||||
state: this.remember()
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="Form-group">
|
||||
{Button.component({
|
||||
className: 'Button Button--primary Button--block',
|
||||
type: 'submit',
|
||||
loading: this.loading,
|
||||
children: app.translator.trans('core.forum.log_in.submit_button')
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>,
|
||||
<div className="Modal-footer">
|
||||
<p className="LogInModal-forgotPassword">
|
||||
<a onclick={this.forgotPassword.bind(this)}>{app.translator.trans('core.forum.log_in.forgot_password_link')}</a>
|
||||
</p>
|
||||
|
||||
{app.forum.attribute('allowSignUp') ? (
|
||||
<p className="LogInModal-signUp">
|
||||
{app.translator.trans('core.forum.log_in.sign_up_text', {a: <a onclick={this.signUp.bind(this)}/>})}
|
||||
</p>
|
||||
) : ''}
|
||||
</div>
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Open the forgot password modal, prefilling it with an email if the user has
|
||||
* entered one.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
forgotPassword() {
|
||||
const email = this.identification();
|
||||
const props = email.indexOf('@') !== -1 ? {email} : undefined;
|
||||
|
||||
app.modal.show(new ForgotPasswordModal(props));
|
||||
}
|
||||
|
||||
/**
|
||||
* Open the sign up modal, prefilling it with an email/username/password if
|
||||
* the user has entered one.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
signUp() {
|
||||
const props = {password: this.password()};
|
||||
const identification = this.identification();
|
||||
props[identification.indexOf('@') !== -1 ? 'email' : 'username'] = identification;
|
||||
|
||||
app.modal.show(new SignUpModal(props));
|
||||
}
|
||||
|
||||
onready() {
|
||||
this.$('[name=' + (this.identification() ? 'password' : 'identification') + ']').select();
|
||||
}
|
||||
|
||||
onsubmit(e) {
|
||||
e.preventDefault();
|
||||
|
||||
this.loading = true;
|
||||
|
||||
const identification = this.identification();
|
||||
const password = this.password();
|
||||
const remember = this.remember();
|
||||
|
||||
app.session.login({identification, password, remember}, {errorHandler: this.onerror.bind(this)})
|
||||
.then(
|
||||
() => window.location.reload(),
|
||||
this.loaded.bind(this)
|
||||
);
|
||||
}
|
||||
|
||||
onerror(error) {
|
||||
if (error.status === 401) {
|
||||
error.alert.props.children = app.translator.trans('core.forum.log_in.invalid_login_message');
|
||||
}
|
||||
|
||||
super.onerror(error);
|
||||
}
|
||||
}
|
86
js/forum/src/components/Notification.tsx
Normal file
86
js/forum/src/components/Notification.tsx
Normal file
@@ -0,0 +1,86 @@
|
||||
import Component from 'flarum/Component';
|
||||
import avatar from 'flarum/helpers/avatar';
|
||||
import icon from 'flarum/helpers/icon';
|
||||
import humanTime from 'flarum/helpers/humanTime';
|
||||
|
||||
/**
|
||||
* The `Notification` component abstract displays a single notification.
|
||||
* Subclasses should implement the `icon`, `href`, and `content` methods.
|
||||
*
|
||||
* ### Props
|
||||
*
|
||||
* - `notification`
|
||||
*
|
||||
* @abstract
|
||||
*/
|
||||
export default class Notification extends Component {
|
||||
view() {
|
||||
const notification = this.props.notification;
|
||||
const href = this.href();
|
||||
|
||||
return (
|
||||
<a className={'Notification Notification--' + notification.contentType() + ' ' + (!notification.isRead() ? 'unread' : '')}
|
||||
href={href}
|
||||
config={function(element, isInitialized) {
|
||||
if (href.indexOf('://') === -1) m.route.apply(this, arguments);
|
||||
|
||||
if (!isInitialized) $(element).click(this.markAsRead.bind(this));
|
||||
}}>
|
||||
{avatar(notification.sender())}
|
||||
{icon(this.icon(), {className: 'Notification-icon'})}
|
||||
<span className="Notification-content">{this.content()}</span>
|
||||
{humanTime(notification.time())}
|
||||
<div className="Notification-excerpt">
|
||||
{this.excerpt()}
|
||||
</div>
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the name of the icon that should be displayed in the notification.
|
||||
*
|
||||
* @return {String}
|
||||
* @abstract
|
||||
*/
|
||||
icon() {
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the URL that the notification should link to.
|
||||
*
|
||||
* @return {String}
|
||||
* @abstract
|
||||
*/
|
||||
href() {
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the content of the notification.
|
||||
*
|
||||
* @return {VirtualElement}
|
||||
* @abstract
|
||||
*/
|
||||
content() {
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the excerpt of the notification.
|
||||
*
|
||||
* @return {VirtualElement}
|
||||
* @abstract
|
||||
*/
|
||||
excerpt() {
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark the notification as read.
|
||||
*/
|
||||
markAsRead() {
|
||||
if (this.props.notification.isRead()) return;
|
||||
|
||||
app.session.user.pushAttributes({unreadNotificationsCount: app.session.user.unreadNotificationsCount() - 1});
|
||||
|
||||
this.props.notification.save({isRead: true});
|
||||
}
|
||||
}
|
188
js/forum/src/components/NotificationGrid.tsx
Normal file
188
js/forum/src/components/NotificationGrid.tsx
Normal file
@@ -0,0 +1,188 @@
|
||||
import Component from 'flarum/Component';
|
||||
import Checkbox from 'flarum/components/Checkbox';
|
||||
import icon from 'flarum/helpers/icon';
|
||||
import ItemList from 'flarum/utils/ItemList';
|
||||
|
||||
/**
|
||||
* The `NotificationGrid` component displays a table of notification types and
|
||||
* methods, allowing the user to toggle each combination.
|
||||
*
|
||||
* ### Props
|
||||
*
|
||||
* - `user`
|
||||
*/
|
||||
export default class NotificationGrid extends Component {
|
||||
init() {
|
||||
/**
|
||||
* Information about the available notification methods.
|
||||
*
|
||||
* @type {Array}
|
||||
*/
|
||||
this.methods = [
|
||||
{name: 'alert', icon: 'bell', label: app.translator.trans('core.forum.settings.notify_by_web_heading')},
|
||||
{name: 'email', icon: 'envelope-o', label: app.translator.trans('core.forum.settings.notify_by_email_heading')}
|
||||
];
|
||||
|
||||
/**
|
||||
* A map of notification type-method combinations to the checkbox instances
|
||||
* that represent them.
|
||||
*
|
||||
* @type {Object}
|
||||
*/
|
||||
this.inputs = {};
|
||||
|
||||
/**
|
||||
* Information about the available notification types.
|
||||
*
|
||||
* @type {Object}
|
||||
*/
|
||||
this.types = this.notificationTypes().toArray();
|
||||
|
||||
// For each of the notification type-method combinations, create and store a
|
||||
// new checkbox component instance, which we will render in the view.
|
||||
this.types.forEach(type => {
|
||||
this.methods.forEach(method => {
|
||||
const key = this.preferenceKey(type.name, method.name);
|
||||
const preference = this.props.user.preferences()[key];
|
||||
|
||||
this.inputs[key] = new Checkbox({
|
||||
state: !!preference,
|
||||
disabled: typeof preference === 'undefined',
|
||||
onchange: () => this.toggle([key])
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
view() {
|
||||
return (
|
||||
<table className="NotificationGrid">
|
||||
<thead>
|
||||
<tr>
|
||||
<td/>
|
||||
{this.methods.map(method => (
|
||||
<th className="NotificationGrid-groupToggle" onclick={this.toggleMethod.bind(this, method.name)}>
|
||||
{icon(method.icon)} {method.label}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
|
||||
<tbody>
|
||||
{this.types.map(type => (
|
||||
<tr>
|
||||
<td className="NotificationGrid-groupToggle" onclick={this.toggleType.bind(this, type.name)}>
|
||||
{icon(type.icon)} {type.label}
|
||||
</td>
|
||||
{this.methods.map(method => (
|
||||
<td className="NotificationGrid-checkbox">
|
||||
{this.inputs[this.preferenceKey(type.name, method.name)].render()}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
);
|
||||
}
|
||||
|
||||
config(isInitialized) {
|
||||
if (isInitialized) return;
|
||||
|
||||
this.$('thead .NotificationGrid-groupToggle').on('mouseenter mouseleave', function(e) {
|
||||
const i = parseInt($(this).index(), 10) + 1;
|
||||
$(this).parents('table').find('td:nth-child(' + i + ')').toggleClass('highlighted', e.type === 'mouseenter');
|
||||
});
|
||||
|
||||
this.$('tbody .NotificationGrid-groupToggle').on('mouseenter mouseleave', function(e) {
|
||||
$(this).parent().find('td').toggleClass('highlighted', e.type === 'mouseenter');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle the state of the given preferences, based on the value of the first
|
||||
* one.
|
||||
*
|
||||
* @param {Array} keys
|
||||
*/
|
||||
toggle(keys) {
|
||||
const user = this.props.user;
|
||||
const preferences = user.preferences();
|
||||
const enabled = !preferences[keys[0]];
|
||||
|
||||
keys.forEach(key => {
|
||||
const control = this.inputs[key];
|
||||
|
||||
control.loading = true;
|
||||
preferences[key] = control.props.state = enabled;
|
||||
});
|
||||
|
||||
m.redraw();
|
||||
|
||||
user.save({preferences}).then(() => {
|
||||
keys.forEach(key => this.inputs[key].loading = false);
|
||||
|
||||
m.redraw();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle all notification types for the given method.
|
||||
*
|
||||
* @param {String} method
|
||||
*/
|
||||
toggleMethod(method) {
|
||||
const keys = this.types
|
||||
.map(type => this.preferenceKey(type.name, method))
|
||||
.filter(key => !this.inputs[key].props.disabled);
|
||||
|
||||
this.toggle(keys);
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle all notification methods for the given type.
|
||||
*
|
||||
* @param {String} type
|
||||
*/
|
||||
toggleType(type) {
|
||||
const keys = this.methods
|
||||
.map(method => this.preferenceKey(type, method.name))
|
||||
.filter(key => !this.inputs[key].props.disabled);
|
||||
|
||||
this.toggle(keys);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the name of the preference key for the given notification type-method
|
||||
* combination.
|
||||
*
|
||||
* @param {String} type
|
||||
* @param {String} method
|
||||
* @return {String}
|
||||
*/
|
||||
preferenceKey(type, method) {
|
||||
return 'notify_' + type + '_' + method;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build an item list for the notification types to display in the grid.
|
||||
*
|
||||
* Each notification type is an object which has the following properties:
|
||||
*
|
||||
* - `name` The name of the notification type.
|
||||
* - `label` The label to display in the notification grid row.
|
||||
*
|
||||
* @return {ItemList}
|
||||
*/
|
||||
notificationTypes() {
|
||||
const items = new ItemList();
|
||||
|
||||
items.add('discussionRenamed', {
|
||||
name: 'discussionRenamed',
|
||||
icon: 'pencil',
|
||||
label: app.translator.trans('core.forum.settings.notify_discussion_renamed_label')
|
||||
});
|
||||
|
||||
return items;
|
||||
}
|
||||
}
|
145
js/forum/src/components/NotificationList.tsx
Normal file
145
js/forum/src/components/NotificationList.tsx
Normal file
@@ -0,0 +1,145 @@
|
||||
import Component from 'flarum/Component';
|
||||
import listItems from 'flarum/helpers/listItems';
|
||||
import Button from 'flarum/components/Button';
|
||||
import LoadingIndicator from 'flarum/components/LoadingIndicator';
|
||||
import Discussion from 'flarum/models/Discussion';
|
||||
|
||||
/**
|
||||
* The `NotificationList` component displays a list of the logged-in user's
|
||||
* notifications, grouped by discussion.
|
||||
*/
|
||||
export default class NotificationList extends Component {
|
||||
init() {
|
||||
/**
|
||||
* Whether or not the notifications are loading.
|
||||
*
|
||||
* @type {Boolean}
|
||||
*/
|
||||
this.loading = false;
|
||||
}
|
||||
|
||||
view() {
|
||||
const groups = [];
|
||||
|
||||
if (app.cache.notifications) {
|
||||
const discussions = {};
|
||||
|
||||
// Build an array of discussions which the notifications are related to,
|
||||
// and add the notifications as children.
|
||||
app.cache.notifications.forEach(notification => {
|
||||
const subject = notification.subject();
|
||||
|
||||
if (typeof subject === 'undefined') return;
|
||||
|
||||
// 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 = false;
|
||||
if (subject instanceof Discussion) discussion = subject;
|
||||
else if (subject && subject.discussion) discussion = subject.discussion();
|
||||
|
||||
// If the notification is not related to a discussion directly or
|
||||
// indirectly, then we will assign it to a neutral group.
|
||||
const key = discussion ? discussion.id() : 0;
|
||||
discussions[key] = discussions[key] || {discussion: discussion, notifications: []};
|
||||
discussions[key].notifications.push(notification);
|
||||
|
||||
if (groups.indexOf(discussions[key]) === -1) {
|
||||
groups.push(discussions[key]);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="NotificationList">
|
||||
<div className="NotificationList-header">
|
||||
<div className="App-primaryControl">
|
||||
{Button.component({
|
||||
className: 'Button Button--icon Button--link',
|
||||
icon: 'check',
|
||||
title: app.translator.trans('core.forum.notifications.mark_all_as_read_tooltip'),
|
||||
onclick: this.markAllAsRead.bind(this)
|
||||
})}
|
||||
</div>
|
||||
|
||||
<h4 className="App-titleControl App-titleControl--text">{app.translator.trans('core.forum.notifications.title')}</h4>
|
||||
</div>
|
||||
|
||||
<div className="NotificationList-content">
|
||||
{groups.length
|
||||
? groups.map(group => {
|
||||
const badges = group.discussion && group.discussion.badges().toArray();
|
||||
|
||||
return (
|
||||
<div className="NotificationGroup">
|
||||
{group.discussion
|
||||
? (
|
||||
<a className="NotificationGroup-header"
|
||||
href={app.route.discussion(group.discussion)}
|
||||
config={m.route}>
|
||||
{badges && badges.length ? <ul className="NotificationGroup-badges badges">{listItems(badges)}</ul> : ''}
|
||||
{group.discussion.title()}
|
||||
</a>
|
||||
) : (
|
||||
<div className="NotificationGroup-header">
|
||||
{app.forum.attribute('title')}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<ul className="NotificationGroup-content">
|
||||
{group.notifications.map(notification => {
|
||||
const NotificationComponent = app.notificationComponents[notification.contentType()];
|
||||
return NotificationComponent ? <li>{NotificationComponent.component({notification})}</li> : '';
|
||||
})}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
: !this.loading
|
||||
? <div className="NotificationList-empty">{app.translator.trans('core.forum.notifications.empty_text')}</div>
|
||||
: LoadingIndicator.component({className: 'LoadingIndicator--block'})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Load notifications into the application's cache if they haven't already
|
||||
* been loaded.
|
||||
*/
|
||||
load() {
|
||||
if (app.cache.notifications && !app.session.user.newNotificationsCount()) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.loading = true;
|
||||
m.redraw();
|
||||
|
||||
app.store.find('notifications')
|
||||
.then(notifications => {
|
||||
app.session.user.pushAttributes({newNotificationsCount: 0});
|
||||
app.cache.notifications = notifications.sort((a, b) => b.time() - a.time());
|
||||
})
|
||||
.catch(() => {})
|
||||
.then(() => {
|
||||
this.loading = false;
|
||||
m.redraw();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark all of the notifications as read.
|
||||
*/
|
||||
markAllAsRead() {
|
||||
if (!app.cache.notifications) return;
|
||||
|
||||
app.session.user.pushAttributes({unreadNotificationsCount: 0});
|
||||
|
||||
app.cache.notifications.forEach(notification => notification.pushAttributes({isRead: true}));
|
||||
|
||||
app.request({
|
||||
url: app.forum.attribute('apiUrl') + '/notifications/read',
|
||||
method: 'POST'
|
||||
});
|
||||
}
|
||||
}
|
77
js/forum/src/components/NotificationsDropdown.tsx
Normal file
77
js/forum/src/components/NotificationsDropdown.tsx
Normal file
@@ -0,0 +1,77 @@
|
||||
import Dropdown from 'flarum/components/Dropdown';
|
||||
import icon from 'flarum/helpers/icon';
|
||||
import NotificationList from 'flarum/components/NotificationList';
|
||||
|
||||
export default class NotificationsDropdown extends Dropdown {
|
||||
static initProps(props) {
|
||||
props.className = props.className || 'NotificationsDropdown';
|
||||
props.buttonClassName = props.buttonClassName || 'Button Button--flat';
|
||||
props.menuClassName = props.menuClassName || 'Dropdown-menu--right';
|
||||
props.label = props.label || app.translator.trans('core.forum.notifications.tooltip');
|
||||
props.icon = props.icon || 'bell';
|
||||
|
||||
super.initProps(props);
|
||||
}
|
||||
|
||||
init() {
|
||||
super.init();
|
||||
|
||||
this.list = new NotificationList();
|
||||
}
|
||||
|
||||
getButton() {
|
||||
const newNotifications = this.getNewCount();
|
||||
const vdom = super.getButton();
|
||||
|
||||
vdom.attrs.title = this.props.label;
|
||||
|
||||
vdom.attrs.className += (newNotifications ? ' new' : '');
|
||||
vdom.attrs.onclick = this.onclick.bind(this);
|
||||
|
||||
return vdom;
|
||||
}
|
||||
|
||||
getButtonContent() {
|
||||
const unread = this.getUnreadCount();
|
||||
|
||||
return [
|
||||
icon(this.props.icon, {className: 'Button-icon'}),
|
||||
unread ? <span className="NotificationsDropdown-unread">{unread}</span> : '',
|
||||
<span className="Button-label">{this.props.label}</span>
|
||||
];
|
||||
}
|
||||
|
||||
getMenu() {
|
||||
return (
|
||||
<div className={'Dropdown-menu ' + this.props.menuClassName} onclick={this.menuClick.bind(this)}>
|
||||
{this.showing ? this.list.render() : ''}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
onclick() {
|
||||
if (app.drawer.isOpen()) {
|
||||
this.goToRoute();
|
||||
} else {
|
||||
this.list.load();
|
||||
}
|
||||
}
|
||||
|
||||
goToRoute() {
|
||||
m.route(app.route('notifications'));
|
||||
}
|
||||
|
||||
getUnreadCount() {
|
||||
return app.session.user.unreadNotificationsCount();
|
||||
}
|
||||
|
||||
getNewCount() {
|
||||
return app.session.user.newNotificationsCount();
|
||||
}
|
||||
|
||||
menuClick(e) {
|
||||
// Don't close the notifications dropdown if the user is opening a link in a
|
||||
// new tab or window.
|
||||
if (e.shiftKey || e.metaKey || e.ctrlKey || e.which === 2) e.stopPropagation();
|
||||
}
|
||||
}
|
@@ -1,26 +1,23 @@
|
||||
import Page from '../../common/components/Page';
|
||||
import NotificationList from './NotificationList';
|
||||
import Page from 'flarum/components/Page';
|
||||
import NotificationList from 'flarum/components/NotificationList';
|
||||
|
||||
/**
|
||||
* The `NotificationsPage` component shows the notifications list. It is only
|
||||
* used on mobile devices where the notifications dropdown is within the drawer.
|
||||
*/
|
||||
export default class NotificationsPage extends Page {
|
||||
oninit(vnode) {
|
||||
super.oninit(vnode);
|
||||
init() {
|
||||
super.init();
|
||||
|
||||
app.history.push('notifications');
|
||||
|
||||
app.notifications.load();
|
||||
this.list = new NotificationList();
|
||||
this.list.load();
|
||||
|
||||
this.bodyClass = 'App--notifications';
|
||||
}
|
||||
|
||||
view() {
|
||||
return (
|
||||
<div className="NotificationsPage">
|
||||
<NotificationList state={app.notifications}></NotificationList>
|
||||
</div>
|
||||
);
|
||||
return <div className="NotificationsPage">{this.list.render()}</div>;
|
||||
}
|
||||
}
|
33
js/forum/src/components/Page.tsx
Normal file
33
js/forum/src/components/Page.tsx
Normal file
@@ -0,0 +1,33 @@
|
||||
import Component from 'flarum/Component';
|
||||
|
||||
/**
|
||||
* The `Page` component
|
||||
*
|
||||
* @abstract
|
||||
*/
|
||||
export default class Page extends Component {
|
||||
init() {
|
||||
app.previous = app.current;
|
||||
app.current = this;
|
||||
|
||||
app.drawer.hide();
|
||||
app.modal.close();
|
||||
|
||||
/**
|
||||
* A class name to apply to the body while the route is active.
|
||||
*
|
||||
* @type {String}
|
||||
*/
|
||||
this.bodyClass = '';
|
||||
}
|
||||
|
||||
config(isInitialized, context) {
|
||||
if (isInitialized) return;
|
||||
|
||||
if (this.bodyClass) {
|
||||
$('#app').addClass(this.bodyClass);
|
||||
|
||||
context.onunload = () => $('#app').removeClass(this.bodyClass);
|
||||
}
|
||||
}
|
||||
}
|
118
js/forum/src/components/Post.tsx
Normal file
118
js/forum/src/components/Post.tsx
Normal file
@@ -0,0 +1,118 @@
|
||||
import Component from 'flarum/Component';
|
||||
import SubtreeRetainer from 'flarum/utils/SubtreeRetainer';
|
||||
import Dropdown from 'flarum/components/Dropdown';
|
||||
import PostControls from 'flarum/utils/PostControls';
|
||||
import listItems from 'flarum/helpers/listItems';
|
||||
import ItemList from 'flarum/utils/ItemList';
|
||||
|
||||
/**
|
||||
* The `Post` component displays a single post. The basic post template just
|
||||
* includes a controls dropdown; subclasses must implement `content` and `attrs`
|
||||
* methods.
|
||||
*
|
||||
* ### Props
|
||||
*
|
||||
* - `post`
|
||||
*
|
||||
* @abstract
|
||||
*/
|
||||
export default class Post extends Component {
|
||||
init() {
|
||||
this.loading = false;
|
||||
|
||||
/**
|
||||
* Set up a subtree retainer so that the post will not be redrawn
|
||||
* unless new data comes in.
|
||||
*
|
||||
* @type {SubtreeRetainer}
|
||||
*/
|
||||
this.subtree = new SubtreeRetainer(
|
||||
() => this.props.post.freshness,
|
||||
() => {
|
||||
const user = this.props.post.user();
|
||||
return user && user.freshness;
|
||||
},
|
||||
() => this.controlsOpen
|
||||
);
|
||||
}
|
||||
|
||||
view() {
|
||||
const attrs = this.attrs();
|
||||
|
||||
attrs.className = 'Post ' + (this.loading ? 'Post--loading ' : '') + (attrs.className || '');
|
||||
|
||||
return (
|
||||
<article {...attrs}>
|
||||
{this.subtree.retain() || (() => {
|
||||
const controls = PostControls.controls(this.props.post, this).toArray();
|
||||
|
||||
return (
|
||||
<div>
|
||||
{this.content()}
|
||||
<aside className="Post-actions">
|
||||
<ul>
|
||||
{listItems(this.actionItems().toArray())}
|
||||
{controls.length ? <li>
|
||||
<Dropdown
|
||||
className="Post-controls"
|
||||
buttonClassName="Button Button--icon Button--flat"
|
||||
menuClassName="Dropdown-menu--right"
|
||||
icon="ellipsis-h"
|
||||
onshow={() => this.$('.Post-actions').addClass('open')}
|
||||
onhide={() => this.$('.Post-actions').removeClass('open')}>
|
||||
{controls}
|
||||
</Dropdown>
|
||||
</li> : ''}
|
||||
</ul>
|
||||
</aside>
|
||||
<footer className="Post-footer"><ul>{listItems(this.footerItems().toArray())}</ul></footer>
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
</article>
|
||||
);
|
||||
}
|
||||
|
||||
config(isInitialized) {
|
||||
const $actions = this.$('.Post-actions');
|
||||
const $controls = this.$('.Post-controls');
|
||||
|
||||
$actions.toggleClass('open', $controls.hasClass('open'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Get attributes for the post element.
|
||||
*
|
||||
* @return {Object}
|
||||
*/
|
||||
attrs() {
|
||||
return {};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the post's content.
|
||||
*
|
||||
* @return {Array}
|
||||
*/
|
||||
content() {
|
||||
return [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Build an item list for the post's actions.
|
||||
*
|
||||
* @return {ItemList}
|
||||
*/
|
||||
actionItems() {
|
||||
return new ItemList();
|
||||
}
|
||||
|
||||
/**
|
||||
* Build an item list for the post's footer.
|
||||
*
|
||||
* @return {ItemList}
|
||||
*/
|
||||
footerItems() {
|
||||
return new ItemList();
|
||||
}
|
||||
}
|
@@ -1,27 +1,28 @@
|
||||
import Component from '../../common/Component';
|
||||
import humanTime from '../../common/utils/humanTime';
|
||||
import extractText from '../../common/utils/extractText';
|
||||
import Component from 'flarum/Component';
|
||||
import humanTime from 'flarum/utils/humanTime';
|
||||
import extractText from 'flarum/utils/extractText';
|
||||
|
||||
/**
|
||||
* The `PostEdited` component displays information about when and by whom a post
|
||||
* was edited.
|
||||
*
|
||||
* ### Attrs
|
||||
* ### Props
|
||||
*
|
||||
* - `post`
|
||||
*/
|
||||
export default class PostEdited extends Component {
|
||||
oninit(vnode) {
|
||||
super.oninit(vnode);
|
||||
|
||||
init() {
|
||||
this.shouldUpdateTooltip = false;
|
||||
this.oldEditedInfo = null;
|
||||
}
|
||||
|
||||
view() {
|
||||
const post = this.attrs.post;
|
||||
const editedUser = post.editedUser();
|
||||
const editedInfo = extractText(app.translator.trans('core.forum.post.edited_tooltip', { user: editedUser, ago: humanTime(post.editedAt()) }));
|
||||
const post = this.props.post;
|
||||
const editUser = post.editUser();
|
||||
const editedInfo = extractText(app.translator.trans(
|
||||
'core.forum.post.edited_tooltip',
|
||||
{user: editUser, ago: humanTime(post.editTime())}
|
||||
));
|
||||
if (editedInfo !== this.oldEditedInfo) {
|
||||
this.shouldUpdateTooltip = true;
|
||||
this.oldEditedInfo = editedInfo;
|
||||
@@ -34,17 +35,7 @@ export default class PostEdited extends Component {
|
||||
);
|
||||
}
|
||||
|
||||
oncreate(vnode) {
|
||||
super.oncreate(vnode);
|
||||
|
||||
this.rebuildTooltip();
|
||||
}
|
||||
|
||||
onupdate() {
|
||||
this.rebuildTooltip();
|
||||
}
|
||||
|
||||
rebuildTooltip() {
|
||||
config(isInitialized) {
|
||||
if (this.shouldUpdateTooltip) {
|
||||
this.$().tooltip('destroy').tooltip();
|
||||
this.shouldUpdateTooltip = false;
|
@@ -1,29 +1,29 @@
|
||||
import Component from '../../common/Component';
|
||||
import humanTime from '../../common/helpers/humanTime';
|
||||
import fullTime from '../../common/helpers/fullTime';
|
||||
import Component from 'flarum/Component';
|
||||
import humanTime from 'flarum/helpers/humanTime';
|
||||
import fullTime from 'flarum/helpers/fullTime';
|
||||
|
||||
/**
|
||||
* The `PostMeta` component displays the time of a post, and when clicked, shows
|
||||
* a dropdown containing more information about the post (number, full time,
|
||||
* permalink).
|
||||
*
|
||||
* ### Attrs
|
||||
* ### Props
|
||||
*
|
||||
* - `post`
|
||||
*/
|
||||
export default class PostMeta extends Component {
|
||||
view() {
|
||||
const post = this.attrs.post;
|
||||
const time = post.createdAt();
|
||||
const post = this.props.post;
|
||||
const time = post.time();
|
||||
const permalink = this.getPermalink(post);
|
||||
const touch = 'ontouchstart' in document.documentElement;
|
||||
|
||||
// When the dropdown menu is shown, select the contents of the permalink
|
||||
// input so that the user can quickly copy the URL.
|
||||
const selectPermalink = function (e) {
|
||||
const selectPermalink = function() {
|
||||
setTimeout(() => $(this).parent().find('.PostMeta-permalink').select());
|
||||
|
||||
e.redraw = false;
|
||||
m.redraw.strategy('none');
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -33,15 +33,12 @@ export default class PostMeta extends Component {
|
||||
</a>
|
||||
|
||||
<div className="Dropdown-menu dropdown-menu">
|
||||
<span className="PostMeta-number">{app.translator.trans('core.forum.post.number_tooltip', { number: post.number() })}</span>{' '}
|
||||
<span className="PostMeta-time">{fullTime(time)}</span> <span className="PostMeta-ip">{post.data.attributes.ipAddress}</span>
|
||||
{touch ? (
|
||||
<a className="Button PostMeta-permalink" href={permalink}>
|
||||
{permalink}
|
||||
</a>
|
||||
) : (
|
||||
<input className="FormControl PostMeta-permalink" value={permalink} onclick={(e) => e.stopPropagation()} />
|
||||
)}
|
||||
<span className="PostMeta-number">{app.translator.trans('core.forum.post.number_tooltip', {number: post.number()})}</span>{' '}
|
||||
<span className="PostMeta-time">{fullTime(time)}</span>{' '}
|
||||
<span className="PostMeta-ip">{post.data.attributes.ipAddress}</span>
|
||||
{touch
|
||||
? <a className="Button PostMeta-permalink" href={permalink}>{permalink}</a>
|
||||
: <input className="FormControl PostMeta-permalink" value={permalink} onclick={e => e.stopPropagation()} />}
|
||||
</div>
|
||||
</div>
|
||||
);
|
30
js/forum/src/components/PostPreview.tsx
Normal file
30
js/forum/src/components/PostPreview.tsx
Normal file
@@ -0,0 +1,30 @@
|
||||
import Component from 'flarum/Component';
|
||||
import avatar from 'flarum/helpers/avatar';
|
||||
import username from 'flarum/helpers/username';
|
||||
import highlight from 'flarum/helpers/highlight';
|
||||
|
||||
/**
|
||||
* The `PostPreview` component shows a link to a post containing the avatar and
|
||||
* username of the author, and a short excerpt of the post's content.
|
||||
*
|
||||
* ### Props
|
||||
*
|
||||
* - `post`
|
||||
*/
|
||||
export default class PostPreview extends Component {
|
||||
view() {
|
||||
const post = this.props.post;
|
||||
const user = post.user();
|
||||
const excerpt = highlight(post.contentPlain(), this.props.highlight, 300);
|
||||
|
||||
return (
|
||||
<a className="PostPreview" href={app.route.post(post)} config={m.route} onclick={this.props.onclick}>
|
||||
<span className="PostPreview-content">
|
||||
{avatar(user)}
|
||||
{username(user)}{' '}
|
||||
<span className="PostPreview-excerpt">{excerpt}</span>
|
||||
</span>
|
||||
</a>
|
||||
);
|
||||
}
|
||||
}
|
603
js/forum/src/components/PostStream.tsx
Normal file
603
js/forum/src/components/PostStream.tsx
Normal file
@@ -0,0 +1,603 @@
|
||||
import Component from 'flarum/Component';
|
||||
import ScrollListener from 'flarum/utils/ScrollListener';
|
||||
import PostLoading from 'flarum/components/LoadingPost';
|
||||
import anchorScroll from 'flarum/utils/anchorScroll';
|
||||
import mixin from 'flarum/utils/mixin';
|
||||
import evented from 'flarum/utils/evented';
|
||||
import ReplyPlaceholder from 'flarum/components/ReplyPlaceholder';
|
||||
import Button from 'flarum/components/Button';
|
||||
|
||||
/**
|
||||
* The `PostStream` component displays an infinitely-scrollable wall of posts in
|
||||
* a discussion. Posts that have not loaded will be displayed as placeholders.
|
||||
*
|
||||
* ### Props
|
||||
*
|
||||
* - `discussion`
|
||||
* - `includedPosts`
|
||||
*/
|
||||
class PostStream extends Component {
|
||||
init() {
|
||||
/**
|
||||
* The discussion to display the post stream for.
|
||||
*
|
||||
* @type {Discussion}
|
||||
*/
|
||||
this.discussion = this.props.discussion;
|
||||
|
||||
/**
|
||||
* Whether or not the infinite-scrolling auto-load functionality is
|
||||
* disabled.
|
||||
*
|
||||
* @type {Boolean}
|
||||
*/
|
||||
this.paused = false;
|
||||
|
||||
this.scrollListener = new ScrollListener(this.onscroll.bind(this));
|
||||
this.loadPageTimeouts = {};
|
||||
this.pagesLoading = 0;
|
||||
|
||||
this.show(this.props.includedPosts);
|
||||
}
|
||||
|
||||
/**
|
||||
* Load and scroll to a post with a certain number.
|
||||
*
|
||||
* @param {Integer|String} number The post number to go to. If 'reply', go to
|
||||
* the last post and scroll the reply preview into view.
|
||||
* @param {Boolean} noAnimation
|
||||
* @return {Promise}
|
||||
*/
|
||||
goToNumber(number, noAnimation) {
|
||||
// If we want to go to the reply preview, then we will go to the end of the
|
||||
// discussion and then scroll to the very bottom of the page.
|
||||
if (number === 'reply') {
|
||||
return this.goToLast().then(() => {
|
||||
$('html,body').stop(true).animate({
|
||||
scrollTop: $(document).height() - $(window).height()
|
||||
}, 'fast', () => {
|
||||
this.flashItem(this.$('.PostStream-item:last-child'));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
this.paused = true;
|
||||
|
||||
const promise = this.loadNearNumber(number);
|
||||
|
||||
m.redraw(true);
|
||||
|
||||
return promise.then(() => {
|
||||
m.redraw(true);
|
||||
|
||||
this.scrollToNumber(number, noAnimation).done(this.unpause.bind(this));
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Load and scroll to a certain index within the discussion.
|
||||
*
|
||||
* @param {Integer} index
|
||||
* @param {Boolean} backwards Whether or not to load backwards from the given
|
||||
* index.
|
||||
* @param {Boolean} noAnimation
|
||||
* @return {Promise}
|
||||
*/
|
||||
goToIndex(index, backwards, noAnimation) {
|
||||
this.paused = true;
|
||||
|
||||
const promise = this.loadNearIndex(index);
|
||||
|
||||
m.redraw(true);
|
||||
|
||||
return promise.then(() => {
|
||||
anchorScroll(this.$('.PostStream-item:' + (backwards ? 'last' : 'first')), () => m.redraw(true));
|
||||
|
||||
this.scrollToIndex(index, noAnimation, backwards).done(this.unpause.bind(this));
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Load and scroll up to the first post in the discussion.
|
||||
*
|
||||
* @return {Promise}
|
||||
*/
|
||||
goToFirst() {
|
||||
return this.goToIndex(0);
|
||||
}
|
||||
|
||||
/**
|
||||
* Load and scroll down to the last post in the discussion.
|
||||
*
|
||||
* @return {Promise}
|
||||
*/
|
||||
goToLast() {
|
||||
return this.goToIndex(this.count() - 1, true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the stream so that it loads and includes the latest posts in the
|
||||
* discussion, if the end is being viewed.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
update() {
|
||||
if (!this.viewingEnd) return;
|
||||
|
||||
this.visibleEnd = this.count();
|
||||
|
||||
this.loadRange(this.visibleStart, this.visibleEnd).then(() => m.redraw());
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the total number of posts in the discussion.
|
||||
*
|
||||
* @return {Integer}
|
||||
*/
|
||||
count() {
|
||||
return this.discussion.postIds().length;
|
||||
}
|
||||
|
||||
/**
|
||||
* Make sure that the given index is not outside of the possible range of
|
||||
* indexes in the discussion.
|
||||
*
|
||||
* @param {Integer} index
|
||||
* @protected
|
||||
*/
|
||||
sanitizeIndex(index) {
|
||||
return Math.max(0, Math.min(this.count(), index));
|
||||
}
|
||||
|
||||
/**
|
||||
* Set up the stream with the given array of posts.
|
||||
*
|
||||
* @param {Post[]} posts
|
||||
*/
|
||||
show(posts) {
|
||||
this.visibleStart = posts.length ? this.discussion.postIds().indexOf(posts[0].id()) : 0;
|
||||
this.visibleEnd = this.visibleStart + posts.length;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset the stream so that a specific range of posts is displayed. If a range
|
||||
* is not specified, the first page of posts will be displayed.
|
||||
*
|
||||
* @param {Integer} [start]
|
||||
* @param {Integer} [end]
|
||||
*/
|
||||
reset(start, end) {
|
||||
this.visibleStart = start || 0;
|
||||
this.visibleEnd = this.sanitizeIndex(end || this.constructor.loadCount);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the visible page of posts.
|
||||
*
|
||||
* @return {Post[]}
|
||||
*/
|
||||
posts() {
|
||||
return this.discussion.postIds()
|
||||
.slice(this.visibleStart, this.visibleEnd)
|
||||
.map(id => {
|
||||
const post = app.store.getById('posts', id);
|
||||
|
||||
return post && post.discussion() && typeof post.canEdit() !== 'undefined' ? post : null;
|
||||
});
|
||||
}
|
||||
|
||||
view() {
|
||||
function fadeIn(element, isInitialized, context) {
|
||||
if (!context.fadedIn) $(element).hide().fadeIn();
|
||||
context.fadedIn = true;
|
||||
}
|
||||
|
||||
let lastTime;
|
||||
|
||||
this.visibleEnd = this.sanitizeIndex(this.visibleEnd);
|
||||
this.viewingEnd = this.visibleEnd === this.count();
|
||||
|
||||
const posts = this.posts();
|
||||
const postIds = this.discussion.postIds();
|
||||
|
||||
const items = posts.map((post, i) => {
|
||||
let content;
|
||||
const attrs = {'data-index': this.visibleStart + i};
|
||||
|
||||
if (post) {
|
||||
const time = post.time();
|
||||
const PostComponent = app.postComponents[post.contentType()];
|
||||
content = PostComponent ? PostComponent.component({post}) : '';
|
||||
|
||||
attrs.key = 'post' + post.id();
|
||||
attrs.config = fadeIn;
|
||||
attrs['data-time'] = time.toISOString();
|
||||
attrs['data-number'] = post.number();
|
||||
attrs['data-id'] = post.id();
|
||||
attrs['data-type'] = post.contentType();
|
||||
|
||||
// If the post before this one was more than 4 hours ago, we will
|
||||
// display a 'time gap' indicating how long it has been in between
|
||||
// the posts.
|
||||
const dt = time - lastTime;
|
||||
|
||||
if (dt > 1000 * 60 * 60 * 24 * 4) {
|
||||
content = [
|
||||
<div className="PostStream-timeGap">
|
||||
<span>{app.translator.trans('core.forum.post_stream.time_lapsed_text', {period: moment.duration(dt).humanize()})}</span>
|
||||
</div>,
|
||||
content
|
||||
];
|
||||
}
|
||||
|
||||
lastTime = time;
|
||||
} else {
|
||||
attrs.key = 'post' + postIds[this.visibleStart + i];
|
||||
|
||||
content = PostLoading.component();
|
||||
}
|
||||
|
||||
return <div className="PostStream-item" {...attrs}>{content}</div>;
|
||||
});
|
||||
|
||||
if (!this.viewingEnd && posts[this.visibleEnd - this.visibleStart - 1]) {
|
||||
items.push(
|
||||
<div className="PostStream-loadMore" key="loadMore">
|
||||
<Button className="Button" onclick={this.loadNext.bind(this)}>
|
||||
{app.translator.trans('core.forum.post_stream.load_more_button')}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// If we're viewing the end of the discussion, the user can reply, and
|
||||
// is not already doing so, then show a 'write a reply' placeholder.
|
||||
if (this.viewingEnd && (!app.session.user || this.discussion.canReply())) {
|
||||
items.push(
|
||||
<div className="PostStream-item" key="reply">
|
||||
{ReplyPlaceholder.component({discussion: this.discussion})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="PostStream">
|
||||
{items}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
config(isInitialized, context) {
|
||||
if (isInitialized) return;
|
||||
|
||||
// This is wrapped in setTimeout due to the following Mithril issue:
|
||||
// https://github.com/lhorie/mithril.js/issues/637
|
||||
setTimeout(() => this.scrollListener.start());
|
||||
|
||||
context.onunload = () => {
|
||||
this.scrollListener.stop();
|
||||
clearTimeout(this.calculatePositionTimeout);
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* When the window is scrolled, check if either extreme of the post stream is
|
||||
* in the viewport, and if so, trigger loading the next/previous page.
|
||||
*
|
||||
* @param {Integer} top
|
||||
*/
|
||||
onscroll(top) {
|
||||
if (this.paused) return;
|
||||
|
||||
const marginTop = this.getMarginTop();
|
||||
const viewportHeight = $(window).height() - marginTop;
|
||||
const viewportTop = top + marginTop;
|
||||
const loadAheadDistance = 300;
|
||||
|
||||
if (this.visibleStart > 0) {
|
||||
const $item = this.$('.PostStream-item[data-index=' + this.visibleStart + ']');
|
||||
|
||||
if ($item.length && $item.offset().top > viewportTop - loadAheadDistance) {
|
||||
this.loadPrevious();
|
||||
}
|
||||
}
|
||||
|
||||
if (this.visibleEnd < this.count()) {
|
||||
const $item = this.$('.PostStream-item[data-index=' + (this.visibleEnd - 1) + ']');
|
||||
|
||||
if ($item.length && $item.offset().top + $item.outerHeight(true) < viewportTop + viewportHeight + loadAheadDistance) {
|
||||
this.loadNext();
|
||||
}
|
||||
}
|
||||
|
||||
// Throttle calculation of our position (start/end numbers of posts in the
|
||||
// viewport) to 100ms.
|
||||
clearTimeout(this.calculatePositionTimeout);
|
||||
this.calculatePositionTimeout = setTimeout(this.calculatePosition.bind(this), 100);
|
||||
}
|
||||
|
||||
/**
|
||||
* Load the next page of posts.
|
||||
*/
|
||||
loadNext() {
|
||||
const start = this.visibleEnd;
|
||||
const end = this.visibleEnd = this.sanitizeIndex(this.visibleEnd + this.constructor.loadCount);
|
||||
|
||||
// Unload the posts which are two pages back from the page we're currently
|
||||
// loading.
|
||||
const twoPagesAway = start - this.constructor.loadCount * 2;
|
||||
if (twoPagesAway > this.visibleStart && twoPagesAway >= 0) {
|
||||
this.visibleStart = twoPagesAway + this.constructor.loadCount + 1;
|
||||
|
||||
if (this.loadPageTimeouts[twoPagesAway]) {
|
||||
clearTimeout(this.loadPageTimeouts[twoPagesAway]);
|
||||
this.loadPageTimeouts[twoPagesAway] = null;
|
||||
this.pagesLoading--;
|
||||
}
|
||||
}
|
||||
|
||||
this.loadPage(start, end);
|
||||
}
|
||||
|
||||
/**
|
||||
* Load the previous page of posts.
|
||||
*/
|
||||
loadPrevious() {
|
||||
const end = this.visibleStart;
|
||||
const start = this.visibleStart = this.sanitizeIndex(this.visibleStart - this.constructor.loadCount);
|
||||
|
||||
// Unload the posts which are two pages back from the page we're currently
|
||||
// loading.
|
||||
const twoPagesAway = start + this.constructor.loadCount * 2;
|
||||
if (twoPagesAway < this.visibleEnd && twoPagesAway <= this.count()) {
|
||||
this.visibleEnd = twoPagesAway;
|
||||
|
||||
if (this.loadPageTimeouts[twoPagesAway]) {
|
||||
clearTimeout(this.loadPageTimeouts[twoPagesAway]);
|
||||
this.loadPageTimeouts[twoPagesAway] = null;
|
||||
this.pagesLoading--;
|
||||
}
|
||||
}
|
||||
|
||||
this.loadPage(start, end, true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Load a page of posts into the stream and redraw.
|
||||
*
|
||||
* @param {Integer} start
|
||||
* @param {Integer} end
|
||||
* @param {Boolean} backwards
|
||||
*/
|
||||
loadPage(start, end, backwards) {
|
||||
const redraw = () => {
|
||||
if (start < this.visibleStart || end > this.visibleEnd) return;
|
||||
|
||||
const anchorIndex = backwards ? this.visibleEnd - 1 : this.visibleStart;
|
||||
anchorScroll(`.PostStream-item[data-index="${anchorIndex}"]`, () => m.redraw(true));
|
||||
|
||||
this.unpause();
|
||||
};
|
||||
redraw();
|
||||
|
||||
this.loadPageTimeouts[start] = setTimeout(() => {
|
||||
this.loadRange(start, end).then(() => {
|
||||
redraw();
|
||||
this.pagesLoading--;
|
||||
});
|
||||
this.loadPageTimeouts[start] = null;
|
||||
}, this.pagesLoading ? 1000 : 0);
|
||||
|
||||
this.pagesLoading++;
|
||||
}
|
||||
|
||||
/**
|
||||
* Load and inject the specified range of posts into the stream, without
|
||||
* clearing it.
|
||||
*
|
||||
* @param {Integer} start
|
||||
* @param {Integer} end
|
||||
* @return {Promise}
|
||||
*/
|
||||
loadRange(start, end) {
|
||||
const loadIds = [];
|
||||
const loaded = [];
|
||||
|
||||
this.discussion.postIds().slice(start, end).forEach(id => {
|
||||
const post = app.store.getById('posts', id);
|
||||
|
||||
if (post && post.discussion() && typeof post.canEdit() !== 'undefined') {
|
||||
loaded.push(post);
|
||||
} else {
|
||||
loadIds.push(id);
|
||||
}
|
||||
});
|
||||
|
||||
return loadIds.length
|
||||
? app.store.find('posts', loadIds)
|
||||
: m.deferred().resolve(loaded).promise;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear the stream and load posts near a certain number. Returns a promise.
|
||||
* If the post with the given number is already loaded, the promise will be
|
||||
* resolved immediately.
|
||||
*
|
||||
* @param {Integer} number
|
||||
* @return {Promise}
|
||||
*/
|
||||
loadNearNumber(number) {
|
||||
if (this.posts().some(post => post && Number(post.number()) === Number(number))) {
|
||||
return m.deferred().resolve().promise;
|
||||
}
|
||||
|
||||
this.reset();
|
||||
|
||||
return app.store.find('posts', {
|
||||
filter: {discussion: this.discussion.id()},
|
||||
page: {near: number}
|
||||
}).then(this.show.bind(this));
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear the stream and load posts near a certain index. A page of posts
|
||||
* surrounding the given index will be loaded. Returns a promise. If the given
|
||||
* index is already loaded, the promise will be resolved immediately.
|
||||
*
|
||||
* @param {Integer} index
|
||||
* @return {Promise}
|
||||
*/
|
||||
loadNearIndex(index) {
|
||||
if (index >= this.visibleStart && index <= this.visibleEnd) {
|
||||
return m.deferred().resolve().promise;
|
||||
}
|
||||
|
||||
const start = this.sanitizeIndex(index - this.constructor.loadCount / 2);
|
||||
const end = start + this.constructor.loadCount;
|
||||
|
||||
this.reset(start, end);
|
||||
|
||||
return this.loadRange(start, end).then(this.show.bind(this));
|
||||
}
|
||||
|
||||
/**
|
||||
* Work out which posts (by number) are currently visible in the viewport, and
|
||||
* fire an event with the information.
|
||||
*/
|
||||
calculatePosition() {
|
||||
const marginTop = this.getMarginTop();
|
||||
const $window = $(window);
|
||||
const viewportHeight = $window.height() - marginTop;
|
||||
const scrollTop = $window.scrollTop() + marginTop;
|
||||
let startNumber;
|
||||
let endNumber;
|
||||
|
||||
this.$('.PostStream-item').each(function() {
|
||||
const $item = $(this);
|
||||
const top = $item.offset().top;
|
||||
const height = $item.outerHeight(true);
|
||||
|
||||
if (top + height > scrollTop) {
|
||||
if (!startNumber) {
|
||||
startNumber = endNumber = $item.data('number');
|
||||
}
|
||||
|
||||
if (top + height < scrollTop + viewportHeight) {
|
||||
if ($item.data('number')) {
|
||||
endNumber = $item.data('number');
|
||||
}
|
||||
} else return false;
|
||||
}
|
||||
});
|
||||
|
||||
if (startNumber) {
|
||||
this.trigger('positionChanged', startNumber || 1, endNumber);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the distance from the top of the viewport to the point at which we
|
||||
* would consider a post to be the first one visible.
|
||||
*
|
||||
* @return {Integer}
|
||||
*/
|
||||
getMarginTop() {
|
||||
return this.$() && $('#header').outerHeight() + parseInt(this.$().css('margin-top'), 10);
|
||||
}
|
||||
|
||||
/**
|
||||
* Scroll down to a certain post by number and 'flash' it.
|
||||
*
|
||||
* @param {Integer} number
|
||||
* @param {Boolean} noAnimation
|
||||
* @return {jQuery.Deferred}
|
||||
*/
|
||||
scrollToNumber(number, noAnimation) {
|
||||
const $item = this.$(`.PostStream-item[data-number=${number}]`);
|
||||
|
||||
return this.scrollToItem($item, noAnimation).done(this.flashItem.bind(this, $item));
|
||||
}
|
||||
|
||||
/**
|
||||
* Scroll down to a certain post by index.
|
||||
*
|
||||
* @param {Integer} index
|
||||
* @param {Boolean} noAnimation
|
||||
* @param {Boolean} bottom Whether or not to scroll to the bottom of the post
|
||||
* at the given index, instead of the top of it.
|
||||
* @return {jQuery.Deferred}
|
||||
*/
|
||||
scrollToIndex(index, noAnimation, bottom) {
|
||||
const $item = this.$(`.PostStream-item[data-index=${index}]`);
|
||||
|
||||
return this.scrollToItem($item, noAnimation, true, bottom);
|
||||
}
|
||||
|
||||
/**
|
||||
* Scroll down to the given post.
|
||||
*
|
||||
* @param {jQuery} $item
|
||||
* @param {Boolean} noAnimation
|
||||
* @param {Boolean} force Whether or not to force scrolling to the item, even
|
||||
* if it is already in the viewport.
|
||||
* @param {Boolean} bottom Whether or not to scroll to the bottom of the post
|
||||
* at the given index, instead of the top of it.
|
||||
* @return {jQuery.Deferred}
|
||||
*/
|
||||
scrollToItem($item, noAnimation, force, bottom) {
|
||||
const $container = $('html, body').stop(true);
|
||||
|
||||
if ($item.length) {
|
||||
const itemTop = $item.offset().top - this.getMarginTop();
|
||||
const itemBottom = $item.offset().top + $item.height();
|
||||
const scrollTop = $(document).scrollTop();
|
||||
const scrollBottom = scrollTop + $(window).height();
|
||||
|
||||
// If the item is already in the viewport, we may not need to scroll.
|
||||
// If we're scrolling to the bottom of an item, then we'll make sure the
|
||||
// bottom will line up with the top of the composer.
|
||||
if (force || itemTop < scrollTop || itemBottom > scrollBottom) {
|
||||
const top = bottom
|
||||
? itemBottom - $(window).height() + app.composer.computedHeight()
|
||||
: ($item.is(':first-child') ? 0 : itemTop);
|
||||
|
||||
if (noAnimation) {
|
||||
$container.scrollTop(top);
|
||||
} else if (top !== scrollTop) {
|
||||
$container.animate({scrollTop: top}, 'fast');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $container.promise();
|
||||
}
|
||||
|
||||
/**
|
||||
* 'Flash' the given post, drawing the user's attention to it.
|
||||
*
|
||||
* @param {jQuery} $item
|
||||
*/
|
||||
flashItem($item) {
|
||||
$item.addClass('flash').one('animationend webkitAnimationEnd', () => $item.removeClass('flash'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Resume the stream's ability to auto-load posts on scroll.
|
||||
*/
|
||||
unpause() {
|
||||
this.paused = false;
|
||||
this.scrollListener.update(true);
|
||||
this.trigger('unpaused');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The number of posts to load per page.
|
||||
*
|
||||
* @type {Integer}
|
||||
*/
|
||||
PostStream.loadCount = 20;
|
||||
|
||||
Object.assign(PostStream.prototype, evented);
|
||||
|
||||
export default PostStream;
|
447
js/forum/src/components/PostStreamScrubber.tsx
Normal file
447
js/forum/src/components/PostStreamScrubber.tsx
Normal file
@@ -0,0 +1,447 @@
|
||||
import Component from 'flarum/Component';
|
||||
import icon from 'flarum/helpers/icon';
|
||||
import ScrollListener from 'flarum/utils/ScrollListener';
|
||||
import SubtreeRetainer from 'flarum/utils/SubtreeRetainer';
|
||||
import computed from 'flarum/utils/computed';
|
||||
import formatNumber from 'flarum/utils/formatNumber';
|
||||
|
||||
/**
|
||||
* The `PostStreamScrubber` component displays a scrubber which can be used to
|
||||
* navigate/scrub through a post stream.
|
||||
*
|
||||
* ### Props
|
||||
*
|
||||
* - `stream`
|
||||
* - `className`
|
||||
*/
|
||||
export default class PostStreamScrubber extends Component {
|
||||
init() {
|
||||
this.handlers = {};
|
||||
|
||||
/**
|
||||
* The index of the post that is currently at the top of the viewport.
|
||||
*
|
||||
* @type {Number}
|
||||
*/
|
||||
this.index = 0;
|
||||
|
||||
/**
|
||||
* The number of posts that are currently visible in the viewport.
|
||||
*
|
||||
* @type {Number}
|
||||
*/
|
||||
this.visible = 1;
|
||||
|
||||
/**
|
||||
* The description to render on the scrubber.
|
||||
*
|
||||
* @type {String}
|
||||
*/
|
||||
this.description = '';
|
||||
|
||||
// When the post stream begins loading posts at a certain index, we want our
|
||||
// scrubber scrollbar to jump to that position.
|
||||
this.props.stream.on('unpaused', this.handlers.streamWasUnpaused = this.streamWasUnpaused.bind(this));
|
||||
|
||||
// Define a handler to update the state of the scrollbar to reflect the
|
||||
// current scroll position of the page.
|
||||
this.scrollListener = new ScrollListener(this.onscroll.bind(this));
|
||||
|
||||
// Create a subtree retainer that will always cache the subtree after the
|
||||
// initial draw. We render parts of the scrubber using this because we
|
||||
// modify their DOM directly, and do not want Mithril messing around with
|
||||
// our changes.
|
||||
this.subtree = new SubtreeRetainer(() => true);
|
||||
}
|
||||
|
||||
view() {
|
||||
const retain = this.subtree.retain();
|
||||
const count = this.count();
|
||||
const unreadCount = this.props.stream.discussion.unreadCount();
|
||||
const unreadPercent = count ? Math.min(count - this.index, unreadCount) / count : 0;
|
||||
|
||||
const viewing = app.translator.transChoice('core.forum.post_scrubber.viewing_text', count, {
|
||||
index: <span className="Scrubber-index">{retain || formatNumber(Math.min(Math.ceil(this.index + this.visible), count))}</span>,
|
||||
count: <span className="Scrubber-count">{formatNumber(count)}</span>
|
||||
});
|
||||
|
||||
function styleUnread(element, isInitialized, context) {
|
||||
const $element = $(element);
|
||||
const newStyle = {
|
||||
top: (100 - unreadPercent * 100) + '%',
|
||||
height: (unreadPercent * 100) + '%'
|
||||
};
|
||||
|
||||
if (context.oldStyle) {
|
||||
$element.stop(true).css(context.oldStyle).animate(newStyle);
|
||||
} else {
|
||||
$element.css(newStyle);
|
||||
}
|
||||
|
||||
context.oldStyle = newStyle;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={'PostStreamScrubber Dropdown ' + (this.disabled() ? 'disabled ' : '') + (this.props.className || '')}>
|
||||
<button className="Button Dropdown-toggle" data-toggle="dropdown">
|
||||
{viewing} {icon('sort')}
|
||||
</button>
|
||||
|
||||
<div className="Dropdown-menu dropdown-menu">
|
||||
<div className="Scrubber">
|
||||
<a className="Scrubber-first" onclick={this.goToFirst.bind(this)}>
|
||||
{icon('angle-double-up')} {app.translator.trans('core.forum.post_scrubber.original_post_link')}
|
||||
</a>
|
||||
|
||||
<div className="Scrubber-scrollbar">
|
||||
<div className="Scrubber-before"/>
|
||||
<div className="Scrubber-handle">
|
||||
<div className="Scrubber-bar"/>
|
||||
<div className="Scrubber-info">
|
||||
<strong>{viewing}</strong>
|
||||
<span class="Scrubber-description">{retain || this.description}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="Scrubber-after"/>
|
||||
|
||||
<div className="Scrubber-unread" config={styleUnread}>
|
||||
{app.translator.trans('core.forum.post_scrubber.unread_text', {count: unreadCount})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<a className="Scrubber-last" onclick={this.goToLast.bind(this)}>
|
||||
{icon('angle-double-down')} {app.translator.trans('core.forum.post_scrubber.now_link')}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Go to the first post in the discussion.
|
||||
*/
|
||||
goToFirst() {
|
||||
this.props.stream.goToFirst();
|
||||
this.index = 0;
|
||||
this.renderScrollbar(true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Go to the last post in the discussion.
|
||||
*/
|
||||
goToLast() {
|
||||
this.props.stream.goToLast();
|
||||
this.index = this.props.stream.count();
|
||||
this.renderScrollbar(true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the number of posts in the discussion.
|
||||
*
|
||||
* @return {Integer}
|
||||
*/
|
||||
count() {
|
||||
return this.props.stream.count();
|
||||
}
|
||||
|
||||
/**
|
||||
* When the stream is unpaused, update the scrubber to reflect its position.
|
||||
*/
|
||||
streamWasUnpaused() {
|
||||
this.update(window.pageYOffset);
|
||||
this.renderScrollbar(true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check whether or not the scrubber should be disabled, i.e. if all of the
|
||||
* posts are visible in the viewport.
|
||||
*
|
||||
* @return {Boolean}
|
||||
*/
|
||||
disabled() {
|
||||
return this.visible >= this.count();
|
||||
}
|
||||
|
||||
/**
|
||||
* When the page is scrolled, update the scrollbar to reflect the visible
|
||||
* posts.
|
||||
*
|
||||
* @param {Integer} top
|
||||
*/
|
||||
onscroll(top) {
|
||||
const stream = this.props.stream;
|
||||
|
||||
if (stream.paused || !stream.$()) return;
|
||||
|
||||
this.update(top);
|
||||
this.renderScrollbar();
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the index/visible/description properties according to the window's
|
||||
* current scroll position.
|
||||
*
|
||||
* @param {Integer} scrollTop
|
||||
*/
|
||||
update(scrollTop) {
|
||||
const stream = this.props.stream;
|
||||
|
||||
const marginTop = stream.getMarginTop();
|
||||
const viewportTop = scrollTop + marginTop;
|
||||
const viewportHeight = $(window).height() - marginTop;
|
||||
const viewportBottom = viewportTop + viewportHeight;
|
||||
|
||||
// Before looping through all of the posts, we reset the scrollbar
|
||||
// properties to a 'default' state. These values reflect what would be
|
||||
// seen if the browser were scrolled right up to the top of the page,
|
||||
// and the viewport had a height of 0.
|
||||
const $items = stream.$('> .PostStream-item[data-index]');
|
||||
let index = $items.first().data('index') || 0;
|
||||
let visible = 0;
|
||||
let period = '';
|
||||
|
||||
// Now loop through each of the items in the discussion. An 'item' is
|
||||
// either a single post or a 'gap' of one or more posts that haven't
|
||||
// been loaded yet.
|
||||
$items.each(function() {
|
||||
const $this = $(this);
|
||||
const top = $this.offset().top;
|
||||
const height = $this.outerHeight(true);
|
||||
|
||||
// If this item is above the top of the viewport, skip to the next
|
||||
// one. If it's below the bottom of the viewport, break out of the
|
||||
// loop.
|
||||
if (top + height < viewportTop) {
|
||||
return true;
|
||||
}
|
||||
if (top > viewportTop + viewportHeight) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Work out how many pixels of this item are visible inside the viewport.
|
||||
// Then add the proportion of this item's total height to the index.
|
||||
const visibleTop = Math.max(0, viewportTop - top);
|
||||
const visibleBottom = Math.min(height, viewportTop + viewportHeight - top);
|
||||
const visiblePost = visibleBottom - visibleTop;
|
||||
|
||||
if (top <= viewportTop) {
|
||||
index = parseFloat($this.data('index')) + visibleTop / height;
|
||||
}
|
||||
|
||||
if (visiblePost > 0) {
|
||||
visible += visiblePost / height;
|
||||
}
|
||||
|
||||
// If this item has a time associated with it, then set the
|
||||
// scrollbar's current period to a formatted version of this time.
|
||||
const time = $this.data('time');
|
||||
if (time) period = time;
|
||||
});
|
||||
|
||||
this.index = index;
|
||||
this.visible = visible;
|
||||
this.description = period ? moment(period).format('MMMM YYYY') : '';
|
||||
}
|
||||
|
||||
config(isInitialized, context) {
|
||||
if (isInitialized) return;
|
||||
|
||||
context.onunload = this.ondestroy.bind(this);
|
||||
|
||||
this.scrollListener.start();
|
||||
|
||||
// Whenever the window is resized, adjust the height of the scrollbar
|
||||
// so that it fills the height of the sidebar.
|
||||
$(window).on('resize', this.handlers.onresize = this.onresize.bind(this)).resize();
|
||||
|
||||
// When any part of the whole scrollbar is clicked, we want to jump to
|
||||
// that position.
|
||||
this.$('.Scrubber-scrollbar')
|
||||
.click(this.onclick.bind(this))
|
||||
|
||||
// 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' })
|
||||
.on('dragstart mousedown touchstart', e => e.preventDefault());
|
||||
|
||||
// When the mouse is pressed on the scrollbar handle, we capture some
|
||||
// information about its current position. We will store this
|
||||
// information in an object and pass it on to the document's
|
||||
// mousemove/mouseup events later.
|
||||
this.dragging = false;
|
||||
this.mouseStart = 0;
|
||||
this.indexStart = 0;
|
||||
|
||||
this.$('.Scrubber-handle')
|
||||
.css('cursor', 'move')
|
||||
.on('mousedown touchstart', this.onmousedown.bind(this))
|
||||
|
||||
// Exempt the scrollbar handle from the 'jump to' click event.
|
||||
.click(e => e.stopPropagation());
|
||||
|
||||
// When the mouse moves and when it is released, we pass the
|
||||
// information that we captured when the mouse was first pressed onto
|
||||
// some event handlers. These handlers will move the scrollbar/stream-
|
||||
// content as appropriate.
|
||||
$(document)
|
||||
.on('mousemove touchmove', this.handlers.onmousemove = this.onmousemove.bind(this))
|
||||
.on('mouseup touchend', this.handlers.onmouseup = this.onmouseup.bind(this));
|
||||
}
|
||||
|
||||
ondestroy() {
|
||||
this.scrollListener.stop();
|
||||
|
||||
this.props.stream.off('unpaused', this.handlers.streamWasUnpaused);
|
||||
|
||||
$(window)
|
||||
.off('resize', this.handlers.onresize);
|
||||
|
||||
$(document)
|
||||
.off('mousemove touchmove', this.handlers.onmousemove)
|
||||
.off('mouseup touchend', this.handlers.onmouseup);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the scrollbar's position to reflect the current values of the
|
||||
* index/visible properties.
|
||||
*
|
||||
* @param {Boolean} animate
|
||||
*/
|
||||
renderScrollbar(animate) {
|
||||
const percentPerPost = this.percentPerPost();
|
||||
const index = this.index;
|
||||
const count = this.count();
|
||||
const visible = this.visible || 1;
|
||||
|
||||
const $scrubber = this.$();
|
||||
$scrubber.find('.Scrubber-index').text(formatNumber(Math.ceil(index + visible)));
|
||||
$scrubber.find('.Scrubber-description').text(this.description);
|
||||
$scrubber.toggleClass('disabled', this.disabled());
|
||||
|
||||
const heights = {};
|
||||
heights.before = Math.max(0, percentPerPost.index * Math.min(index, count - visible));
|
||||
heights.handle = Math.min(100 - heights.before, percentPerPost.visible * visible);
|
||||
heights.after = 100 - heights.before - heights.handle;
|
||||
|
||||
const func = animate ? 'animate' : 'css';
|
||||
for (const part in heights) {
|
||||
const $part = $scrubber.find(`.Scrubber-${part}`);
|
||||
$part.stop(true, true)[func]({height: heights[part] + '%'}, 'fast');
|
||||
|
||||
// jQuery likes to put overflow:hidden, but because the scrollbar handle
|
||||
// has a negative margin-left, we need to override.
|
||||
if (func === 'animate') $part.css('overflow', 'visible');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the percentage of the height of the scrubber that should be allocated
|
||||
* to each post.
|
||||
*
|
||||
* @return {Object}
|
||||
* @property {Number} index The percent per post for posts on either side of
|
||||
* the visible part of the scrubber.
|
||||
* @property {Number} visible The percent per post for the visible part of the
|
||||
* scrubber.
|
||||
*/
|
||||
percentPerPost() {
|
||||
const count = this.count() || 1;
|
||||
const visible = this.visible || 1;
|
||||
|
||||
// To stop the handle of the scrollbar from getting too small when there
|
||||
// are many posts, we define a minimum percentage height for the handle
|
||||
// calculated from a 50 pixel limit. From this, we can calculate the
|
||||
// minimum percentage per visible post. If this is greater than the actual
|
||||
// percentage per post, then we need to adjust the 'before' percentage to
|
||||
// account for it.
|
||||
const minPercentVisible = 50 / this.$('.Scrubber-scrollbar').outerHeight() * 100;
|
||||
const percentPerVisiblePost = Math.max(100 / count, minPercentVisible / visible);
|
||||
const percentPerPost = count === visible ? 0 : (100 - percentPerVisiblePost * visible) / (count - visible);
|
||||
|
||||
return {
|
||||
index: percentPerPost,
|
||||
visible: percentPerVisiblePost
|
||||
};
|
||||
}
|
||||
|
||||
onresize() {
|
||||
this.scrollListener.update(true);
|
||||
|
||||
// Adjust the height of the scrollbar so that it fills the height of
|
||||
// the sidebar and doesn't overlap the footer.
|
||||
const scrubber = this.$();
|
||||
const scrollbar = this.$('.Scrubber-scrollbar');
|
||||
|
||||
scrollbar.css('max-height', $(window).height() -
|
||||
scrubber.offset().top + $(window).scrollTop() -
|
||||
parseInt($('#app').css('padding-bottom'), 10) -
|
||||
(scrubber.outerHeight() - scrollbar.outerHeight()));
|
||||
}
|
||||
|
||||
onmousedown(e) {
|
||||
this.mouseStart = e.clientY || e.originalEvent.touches[0].clientY;
|
||||
this.indexStart = this.index;
|
||||
this.dragging = true;
|
||||
this.props.stream.paused = true;
|
||||
$('body').css('cursor', 'move');
|
||||
}
|
||||
|
||||
onmousemove(e) {
|
||||
if (!this.dragging) return;
|
||||
|
||||
// Work out how much the mouse has moved by - first in pixels, then
|
||||
// convert it to a percentage of the scrollbar's height, and then
|
||||
// finally convert it into an index. Add this delta index onto
|
||||
// the index at which the drag was started, and then scroll there.
|
||||
const deltaPixels = (e.clientY || e.originalEvent.touches[0].clientY) - this.mouseStart;
|
||||
const deltaPercent = deltaPixels / this.$('.Scrubber-scrollbar').outerHeight() * 100;
|
||||
const deltaIndex = (deltaPercent / this.percentPerPost().index) || 0;
|
||||
const newIndex = Math.min(this.indexStart + deltaIndex, this.count() - 1);
|
||||
|
||||
this.index = Math.max(0, newIndex);
|
||||
this.renderScrollbar();
|
||||
}
|
||||
|
||||
onmouseup() {
|
||||
if (!this.dragging) return;
|
||||
|
||||
this.mouseStart = 0;
|
||||
this.indexStart = 0;
|
||||
this.dragging = false;
|
||||
$('body').css('cursor', '');
|
||||
|
||||
this.$().removeClass('open');
|
||||
|
||||
// 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.index);
|
||||
this.props.stream.goToIndex(intIndex);
|
||||
this.renderScrollbar(true);
|
||||
}
|
||||
|
||||
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.
|
||||
const $scrollbar = this.$('.Scrubber-scrollbar');
|
||||
const offsetPixels = (e.clientY || e.originalEvent.touches[0].clientY) - $scrollbar.offset().top + $('body').scrollTop();
|
||||
let offsetPercent = offsetPixels / $scrollbar.outerHeight() * 100;
|
||||
|
||||
// 2. We want the handle of the scrollbar to end up centered on the click
|
||||
// position. Thus, we calculate the height of the handle in percent and
|
||||
// use that to find a new offset percentage.
|
||||
offsetPercent = offsetPercent - parseFloat($scrollbar.find('.Scrubber-handle')[0].style.height) / 2;
|
||||
|
||||
// 3. Now we can convert the percentage into an index, and tell the stream-
|
||||
// content component to jump to that index.
|
||||
let offsetIndex = offsetPercent / this.percentPerPost().index;
|
||||
offsetIndex = Math.max(0, Math.min(this.count() - 1, offsetIndex));
|
||||
this.props.stream.goToIndex(Math.floor(offsetIndex));
|
||||
this.index = offsetIndex;
|
||||
this.renderScrollbar(true);
|
||||
|
||||
this.$().removeClass('open');
|
||||
}
|
||||
}
|
@@ -1,60 +1,67 @@
|
||||
import Component from '../../common/Component';
|
||||
import Link from '../../common/components/Link';
|
||||
import UserCard from './UserCard';
|
||||
import avatar from '../../common/helpers/avatar';
|
||||
import username from '../../common/helpers/username';
|
||||
import userOnline from '../../common/helpers/userOnline';
|
||||
import listItems from '../../common/helpers/listItems';
|
||||
import Component from 'flarum/Component';
|
||||
import UserCard from 'flarum/components/UserCard';
|
||||
import avatar from 'flarum/helpers/avatar';
|
||||
import username from 'flarum/helpers/username';
|
||||
import userOnline from 'flarum/helpers/userOnline';
|
||||
import listItems from 'flarum/helpers/listItems';
|
||||
|
||||
/**
|
||||
* The `PostUser` component shows the avatar and username of a post's author.
|
||||
*
|
||||
* ### Attrs
|
||||
* ### Props
|
||||
*
|
||||
* - `post`
|
||||
*/
|
||||
export default class PostUser extends Component {
|
||||
init() {
|
||||
/**
|
||||
* Whether or not the user hover card is visible.
|
||||
*
|
||||
* @type {Boolean}
|
||||
*/
|
||||
this.cardVisible = false;
|
||||
}
|
||||
|
||||
view() {
|
||||
const post = this.attrs.post;
|
||||
const post = this.props.post;
|
||||
const user = post.user();
|
||||
|
||||
if (!user) {
|
||||
return (
|
||||
<div className="PostUser">
|
||||
<h3>
|
||||
{avatar(user, { className: 'PostUser-avatar' })} {username(user)}
|
||||
</h3>
|
||||
<h3>{avatar(user, {className: 'PostUser-avatar'})} {username(user)}</h3>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
let card = '';
|
||||
|
||||
if (!post.isHidden() && this.attrs.cardVisible) {
|
||||
if (!post.isHidden() && this.cardVisible) {
|
||||
card = UserCard.component({
|
||||
user,
|
||||
className: 'UserCard--popover',
|
||||
controlsButtonClassName: 'Button Button--icon Button--flat',
|
||||
controlsButtonClassName: 'Button Button--icon Button--flat'
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="PostUser">
|
||||
{userOnline(user)}
|
||||
<h3>
|
||||
<Link href={app.route.user(user)}>
|
||||
{avatar(user, { className: 'PostUser-avatar' })}
|
||||
{userOnline(user)}
|
||||
{username(user)}
|
||||
</Link>
|
||||
<a href={app.route.user(user)} config={m.route}>
|
||||
{avatar(user, {className: 'PostUser-avatar'})}{' '}{username(user)}
|
||||
</a>
|
||||
</h3>
|
||||
<ul className="PostUser-badges badges">{listItems(user.badges().toArray())}</ul>
|
||||
<ul className="PostUser-badges badges">
|
||||
{listItems(user.badges().toArray())}
|
||||
</ul>
|
||||
{card}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
oncreate(vnode) {
|
||||
super.oncreate(vnode);
|
||||
config(isInitialized) {
|
||||
if (isInitialized) return;
|
||||
|
||||
let timeout;
|
||||
|
||||
@@ -73,7 +80,9 @@ export default class PostUser extends Component {
|
||||
* Show the user card.
|
||||
*/
|
||||
showCard() {
|
||||
this.attrs.oncardshow();
|
||||
this.cardVisible = true;
|
||||
|
||||
m.redraw();
|
||||
|
||||
setTimeout(() => this.$('.UserCard').addClass('in'));
|
||||
}
|
||||
@@ -82,10 +91,10 @@ export default class PostUser extends Component {
|
||||
* Hide the user card.
|
||||
*/
|
||||
hideCard() {
|
||||
this.$('.UserCard')
|
||||
.removeClass('in')
|
||||
this.$('.UserCard').removeClass('in')
|
||||
.one('transitionend webkitTransitionEnd oTransitionEnd', () => {
|
||||
this.attrs.oncardhide();
|
||||
this.cardVisible = false;
|
||||
m.redraw();
|
||||
});
|
||||
}
|
||||
}
|
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user