1
0
mirror of https://github.com/flarum/core.git synced 2025-08-17 22:01:44 +02:00

Compare commits

..

2 Commits

Author SHA1 Message Date
Daniël Klabbers
34079108c8 update version to v0.1.0-beta.8.2 2019-06-12 16:12:01 +02:00
Daniël Klabbers
f7a8b76fa8 fixes font awesome issues in flarum 0.1.0-beta.8.1 2019-06-12 16:11:00 +02:00
865 changed files with 14707 additions and 21338 deletions

BIN
.deploy.enc Normal file

Binary file not shown.

2
.gitattributes vendored
View File

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

3
.github/FUNDING.yml vendored
View File

@@ -1,3 +0,0 @@
github: flarum
open_collective: flarum
tidelift: packagist/flarum/core

View File

@@ -3,6 +3,9 @@ name: "🐛 Bug Report"
about: "If something isn't working as expected" about: "If something isn't working as expected"
--- ---
<!--
IMPORTANT: If you discover a security vulnerability within Flarum, please send an email to [security@flarum.org](mailto:security@flarum.org) instead. We will address these with the utmost urgency and it will prevent vulnerabilities, which may be abused, from popping up on our issue tracker.
-->
## Bug Report ## Bug Report
**Current Behavior** **Current Behavior**

View File

@@ -16,7 +16,7 @@ IMPORTANT: We applaud pull requests, they excite us every single time. As we hav
**Confirmed** **Confirmed**
- [ ] Frontend changes: tested on a local Flarum installation. - [ ] Frontend changes: tested on a local Flarum installation.
- [ ] Backend changes: tests are green (run `composer test`). - [ ] Backend changes: tests are green (run `php vendor/bin/phpunit`).
**Required changes:** **Required changes:**

13
.github/SECURITY.md vendored
View File

@@ -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
View File

@@ -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.

View File

@@ -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 }}

View File

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

View File

@@ -1,67 +0,0 @@
name: Tests
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
strategy:
matrix:
php: [7.2, 7.3, 7.4]
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.2
service: 'mysql:5.7'
prefix: flarum_
- php: 7.2
service: mariadb
prefix: flarum_
- php: 7.3
service: 'mysql:5.7'
prefix: flarum_
- php: 7.3
service: mariadb
prefix: flarum_
services:
mysql:
image: ${{ matrix.service }}
ports:
- 13306:3306
name: 'PHP ${{ matrix.php }} / ${{ matrix.db }} ${{ matrix.prefixStr }}'
steps:
- uses: actions/checkout@master
- name: Select PHP version
run: sudo update-alternatives --set php $(which php${{ matrix.php }})
- name: Create MySQL Database
run: |
sudo systemctl start mysql
mysql -uroot -proot -e 'CREATE DATABASE flarum_test;' --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

2
.gitignore vendored
View File

@@ -4,6 +4,6 @@ composer.phar
node_modules node_modules
.DS_Store .DS_Store
Thumbs.db Thumbs.db
/tests/integration/tmp /tests/tmp
.vagrant .vagrant
.idea/* .idea/*

46
.travis.yml Normal file
View File

@@ -0,0 +1,46 @@
language: php
cache:
directories:
- $HOME/.composer/cache
- $HOME/.npm
install:
- composer install
- mysql -e 'CREATE DATABASE flarum;'
script:
- vendor/bin/phpunit --coverage-clover=coverage.xml
after_success:
- bash <(curl -s https://codecov.io/bash)
jobs:
include:
- php: 7.1
env: DB=mysql
- php: 7.2
env: DB=mysql
- php: 7.2
env: DB=mysql PREFIX=forum_
- php: 7.1
addons:
mariadb: '10.2'
env: DB=mariadb
- php: 7.2
addons:
mariadb: '10.2'
env: DB=mariadb
- stage: build
language: generic
if: branch = master AND type = push
install: skip
script: bash .travis/build.sh
-k $encrypted_678139e2bc67_key
-i $encrypted_678139e2bc67_iv
after_success: skip

33
.travis/build.sh Executable file
View File

@@ -0,0 +1,33 @@
#!/bin/bash
main() {
while getopts ":k:i:" opt; do
case $opt in
k) encrypted_key="$OPTARG"
;;
i) encrypted_iv="$OPTARG"
;;
\?) echo "Invalid option -$OPTARG" >&2
;;
esac
done
git checkout -f $TRAVIS_BRANCH
git config user.name "flarum-bot"
git config user.email "bot@flarum.org"
cd js
npm i -g npm@6.1.0
npm ci
npm run build
git add dist/* -f
git commit -m "Bundled output for commit $TRAVIS_COMMIT [skip ci]"
eval `ssh-agent -s`
openssl aes-256-cbc -K $encrypted_key -iv $encrypted_iv -in ../.deploy.enc -d | ssh-add -
git push git@github.com:$TRAVIS_REPO_SLUG.git $TRAVIS_BRANCH
}
main "$@"

View File

@@ -1,302 +1,12 @@
# Changelog # Changelog
## [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 (#2407)
- Scripts from textformatter aren't executed (#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) ## [0.1.0-beta.8.1](https://github.com/flarum/core/compare/v0.1.0-beta.8...v0.1.0-beta.8.1)
### Fixed ### Fixed
- Fix live output in `migrate:reset` command ([f591585](https://github.com/flarum/core/commit/f591585d02f8c4ff0211c5bf4413dd6baa724c05)) - 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 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)) - 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)) - Ensure InnoDB engine is used for all tables ([fb6b51b](https://github.com/flarum/core/commit/fb6b51b1cfef0af399607fe038603c8240800b2b))
- Fix dropping foreign keys in `down` migrations ([57d5846](https://github.com/flarum/core/commit/57d5846b647881009d9e60f9ffca20b1bb77776e)) - 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 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)) - Fix empty meta description tag ([88e43cc](https://github.com/flarum/core/commit/88e43cc6940ee30d6529e9ce659471ec4fb1c474))

3
CONTRIBUTING.md Normal file
View File

@@ -0,0 +1,3 @@
# Contributing to Flarum
Thank you for considering contributing to Flarum! Please read the **[Contributing guide](https://flarum.org/docs/contributing.html)** to learn how you can help.

View File

@@ -1,7 +1,6 @@
The MIT License (MIT) The MIT License (MIT)
Copyright (c) 2019-2020 Stichting Flarum (Flarum Foundation) Copyright (c) Toby Zerner
Copyright (c) 2014-2019 Toby Zerner (toby.zerner@gmail.com)
Permission is hereby granted, free of charge, to any person obtaining a copy Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal of this software and associated documentation files (the "Software"), to deal

View File

@@ -1,14 +1,12 @@
<p align="center"><img src="https://flarum.org/assets/img/logo.png"></p> <p align="center"><img src="https://flarum.org/img/logo.png"></p>
<p align="center"> <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://travis-ci.org/flarum/core"><img src="https://travis-ci.org/flarum/core.svg" alt="Build Status"></a>
<a href="https://packagist.org/packages/flarum/core"><img src="https://img.shields.io/packagist/dt/flarum/core" alt="Total Downloads"></a> <a href="https://packagist.org/packages/flarum/core"><img src="https://poser.pugx.org/flarum/core/d/total.svg" alt="Total Downloads"></a>
<a href="https://packagist.org/packages/flarum/core"><img src="https://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://poser.pugx.org/flarum/core/v/stable.svg" alt="Latest Stable 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://packagist.org/packages/flarum/core"><img src="https://poser.pugx.org/flarum/core/license.svg" 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> </p>
## About Flarum ## 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: **[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:
@@ -29,8 +27,9 @@ Thank you for considering contributing to Flarum! Please read the **[Contributin
## Security Vulnerabilities ## 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). 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.
## License ## License
Flarum is open-source software licensed under the [MIT License](https://github.com/flarum/flarum/blob/master/LICENSE). Flarum is open-source software licensed under the [MIT License](https://github.com/flarum/flarum/blob/master/LICENSE).

View File

@@ -5,32 +5,13 @@
"homepage": "https://flarum.org/", "homepage": "https://flarum.org/",
"license": "MIT", "license": "MIT",
"authors": [ "authors": [
{
"name": "Toby Zerner",
"email": "toby.zerner@gmail.com"
},
{ {
"name": "Franz Liedke", "name": "Franz Liedke",
"email": "franz@develophp.org" "email": "franz@develophp.org"
},
{
"name": "Daniël Klabbers",
"email": "daniel@klabbers.email",
"homepage": "https://luceos.com"
},
{
"name": "David Sevilla Martin",
"email": "me+flarum@datitisev.me",
"homepage": "https://datitisev.me"
},
{
"name": "Clark Winkelmann",
"email": "clark.winkelmann@gmail.com",
"homepage": "https://clarkwinkelmann.com"
},
{
"name": "Matthew Kilgore",
"email": "matthew@kilgore.dev"
},
{
"name": "Alexander (Sasha) Skvortsov",
"email": "askvortsov@flarum.org"
} }
], ],
"support": { "support": {
@@ -39,54 +20,52 @@
"docs": "https://flarum.org/docs/" "docs": "https://flarum.org/docs/"
}, },
"require": { "require": {
"php": ">=7.2", "php": ">=7.1",
"axy/sourcemap": "^0.1.4", "axy/sourcemap": "^0.1.4",
"components/font-awesome": "^5.14.0", "components/font-awesome": "5.9.*",
"dflydev/fig-cookies": "^2.0.1", "dflydev/fig-cookies": "^1.0.2",
"doctrine/dbal": "^2.7", "doctrine/dbal": "^2.7",
"franzl/whoops-middleware": "^0.4.0", "franzl/whoops-middleware": "^0.4.0",
"illuminate/bus": "^6.0", "illuminate/bus": "5.5.*",
"illuminate/cache": "^6.0", "illuminate/cache": "5.5.*",
"illuminate/config": "^6.0", "illuminate/config": "5.5.*",
"illuminate/container": "^6.0", "illuminate/container": "5.5.*",
"illuminate/contracts": "^6.0", "illuminate/contracts": "5.5.*",
"illuminate/database": "^6.0", "illuminate/database": "5.5.*",
"illuminate/events": "^6.0", "illuminate/events": "5.5.*",
"illuminate/filesystem": "^6.0", "illuminate/filesystem": "5.5.*",
"illuminate/hashing": "^6.0", "illuminate/hashing": "5.5.*",
"illuminate/mail": "^6.0", "illuminate/mail": "5.5.*",
"illuminate/queue": "^6.0", "illuminate/session": "5.5.*",
"illuminate/session": "^6.0", "illuminate/support": "5.5.*",
"illuminate/support": "^6.0", "illuminate/validation": "5.5.*",
"illuminate/validation": "^6.0", "illuminate/view": "5.5.*",
"illuminate/view": "^6.0", "intervention/image": "^2.3.0",
"intervention/image": "^2.5.0",
"laminas/laminas-diactoros": "^1.8.4",
"laminas/laminas-httphandlerrunner": "^1.0",
"laminas/laminas-stratigility": "^3.0",
"league/flysystem": "^1.0.11", "league/flysystem": "^1.0.11",
"matthiasmullie/minify": "^1.3", "matthiasmullie/minify": "^1.3",
"middlewares/base-path": "^1.1", "middlewares/base-path": "^1.1",
"middlewares/base-path-router": "^0.2.1", "middlewares/base-path-router": "^0.2.1",
"middlewares/request-handler": "^1.2", "middlewares/request-handler": "^1.2",
"monolog/monolog": "^1.16.0", "monolog/monolog": "^1.16.0",
"nesbot/carbon": "^2.0",
"nikic/fast-route": "^0.6", "nikic/fast-route": "^0.6",
"oyejorge/less.php": "^1.7",
"psr/http-message": "^1.0", "psr/http-message": "^1.0",
"psr/http-server-handler": "^1.0", "psr/http-server-handler": "^1.0",
"psr/http-server-middleware": "^1.0", "psr/http-server-middleware": "^1.0",
"s9e/text-formatter": "^2.3.6", "s9e/text-formatter": "^1.2.0",
"symfony/config": "^4.3.4", "symfony/config": "^3.3",
"symfony/console": "^4.3.4", "symfony/console": "^3.3",
"symfony/event-dispatcher": "^4.3.4", "symfony/http-foundation": "^3.3",
"symfony/translation": "^4.3.4", "symfony/translation": "^3.3",
"symfony/yaml": "^4.3.4", "symfony/yaml": "^3.3",
"tobscure/json-api": "^0.3.0", "tobscure/json-api": "^0.3.0",
"wikimedia/less.php": "^3.0" "zendframework/zend-diactoros": "^1.8.4",
"zendframework/zend-httphandlerrunner": "^1.0",
"zendframework/zend-stratigility": "^3.0"
}, },
"require-dev": { "require-dev": {
"mockery/mockery": "^1.0", "mockery/mockery": "^0.9.4",
"phpunit/phpunit": "^7.0" "phpunit/phpunit": "^6.0"
}, },
"autoload": { "autoload": {
"psr-4": { "psr-4": {
@@ -108,20 +87,5 @@
"branch-alias": { "branch-alias": {
"dev-master": "0.1.x-dev" "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."
} }
} }

View File

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

30
js/dist/admin.js vendored

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

32
js/dist/forum.js vendored

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

3028
js/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -2,38 +2,25 @@
"private": true, "private": true,
"name": "@flarum/core", "name": "@flarum/core",
"dependencies": { "dependencies": {
"@babel/preset-typescript": "^7.10.1", "bootstrap": "^3.3.7",
"@types/mithril": "^2.0.3",
"bootstrap": "^3.4.1",
"classnames": "^2.2.5", "classnames": "^2.2.5",
"color-thief-browser": "^2.0.2", "color-thief-browser": "^2.0.2",
"dayjs": "^1.8.28",
"expose-loader": "^0.7.5", "expose-loader": "^0.7.5",
"flarum-webpack-config": "0.1.0-beta.10", "flarum-webpack-config": "0.1.0-beta.10",
"jquery": "^3.5.1", "jquery": "^3.3.1",
"jquery.hotkeys": "^0.1.0", "jquery.hotkeys": "^0.1.0",
"lodash-es": "^4.17.14", "lodash-es": "^4.17.11",
"m.attrs.bidi": "github:tobscure/m.attrs.bidi", "m.attrs.bidi": "github:tobscure/m.attrs.bidi",
"mithril": "^2.0.4", "mithril": "^0.2.8",
"moment": "^2.22.2",
"punycode": "^2.1.1", "punycode": "^2.1.1",
"spin.js": "^3.1.0", "spin.js": "^3.1.0",
"webpack": "^4.43.0", "webpack": "^4.26.0",
"webpack-cli": "^3.3.11", "webpack-cli": "^3.1.2",
"webpack-merge": "^4.1.4" "webpack-merge": "^4.1.4"
}, },
"devDependencies": {
"husky": "^4.2.5",
"prettier": "2.0.2"
},
"scripts": { "scripts": {
"dev": "webpack --mode development --watch", "dev": "webpack --mode development --watch",
"build": "webpack --mode production", "build": "webpack --mode production"
"format": "prettier --write src",
"format-check": "prettier --check src"
},
"husky": {
"hooks": {
"pre-commit": "npm run format"
}
} }
} }

32
js/shims.d.ts vendored
View File

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

View File

@@ -12,9 +12,9 @@ export default class AdminApplication extends Application {
canGoBack: () => true, canGoBack: () => true,
getPrevious: () => {}, getPrevious: () => {},
backUrl: () => this.forum.attribute('baseUrl'), backUrl: () => this.forum.attribute('baseUrl'),
back: function () { back: function() {
window.location = this.backUrl(); window.location = this.backUrl();
}, }
}; };
constructor() { constructor() {
@@ -27,19 +27,15 @@ export default class AdminApplication extends Application {
* @inheritdoc * @inheritdoc
*/ */
mount() { mount() {
// Mithril does not render the home route on https://example.com/admin, so m.mount(document.getElementById('app-navigation'), Navigation.component({className: 'App-backControl', drawer: true}));
// we need to go to https://example.com/admin#/ explicitly. m.mount(document.getElementById('header-navigation'), Navigation.component());
if (!document.location.hash) document.location.hash = '#/'; m.mount(document.getElementById('header-primary'), HeaderPrimary.component());
m.mount(document.getElementById('header-secondary'), HeaderSecondary.component());
m.mount(document.getElementById('admin-navigation'), AdminNav.component());
m.route.prefix = '#'; m.route.mode = 'hash';
super.mount(); super.mount();
m.mount(document.getElementById('app-navigation'), { view: () => Navigation.component({ className: 'App-backControl', drawer: true }) });
m.mount(document.getElementById('header-navigation'), Navigation);
m.mount(document.getElementById('header-primary'), HeaderPrimary);
m.mount(document.getElementById('header-secondary'), HeaderSecondary);
m.mount(document.getElementById('admin-navigation'), AdminNav);
// If an extension has just been enabled, then we will run its settings // If an extension has just been enabled, then we will run its settings
// callback. // callback.
const enabled = localStorage.getItem('enabledExtension'); const enabled = localStorage.getItem('enabledExtension');
@@ -63,5 +59,5 @@ export default class AdminApplication extends Application {
} }
return required; return required;
} };
} }

View File

@@ -6,6 +6,7 @@ import EditCustomFooterModal from './components/EditCustomFooterModal';
import SessionDropdown from './components/SessionDropdown'; import SessionDropdown from './components/SessionDropdown';
import HeaderPrimary from './components/HeaderPrimary'; import HeaderPrimary from './components/HeaderPrimary';
import AppearancePage from './components/AppearancePage'; import AppearancePage from './components/AppearancePage';
import Page from './components/Page';
import StatusWidget from './components/StatusWidget'; import StatusWidget from './components/StatusWidget';
import HeaderSecondary from './components/HeaderSecondary'; import HeaderSecondary from './components/HeaderSecondary';
import SettingsModal from './components/SettingsModal'; import SettingsModal from './components/SettingsModal';
@@ -14,6 +15,7 @@ import AddExtensionModal from './components/AddExtensionModal';
import ExtensionsPage from './components/ExtensionsPage'; import ExtensionsPage from './components/ExtensionsPage';
import AdminLinkButton from './components/AdminLinkButton'; import AdminLinkButton from './components/AdminLinkButton';
import PermissionGrid from './components/PermissionGrid'; import PermissionGrid from './components/PermissionGrid';
import Widget from './components/Widget';
import MailPage from './components/MailPage'; import MailPage from './components/MailPage';
import UploadImageButton from './components/UploadImageButton'; import UploadImageButton from './components/UploadImageButton';
import LoadingModal from './components/LoadingModal'; import LoadingModal from './components/LoadingModal';
@@ -35,6 +37,7 @@ export default Object.assign(compat, {
'components/SessionDropdown': SessionDropdown, 'components/SessionDropdown': SessionDropdown,
'components/HeaderPrimary': HeaderPrimary, 'components/HeaderPrimary': HeaderPrimary,
'components/AppearancePage': AppearancePage, 'components/AppearancePage': AppearancePage,
'components/Page': Page,
'components/StatusWidget': StatusWidget, 'components/StatusWidget': StatusWidget,
'components/HeaderSecondary': HeaderSecondary, 'components/HeaderSecondary': HeaderSecondary,
'components/SettingsModal': SettingsModal, 'components/SettingsModal': SettingsModal,
@@ -43,6 +46,7 @@ export default Object.assign(compat, {
'components/ExtensionsPage': ExtensionsPage, 'components/ExtensionsPage': ExtensionsPage,
'components/AdminLinkButton': AdminLinkButton, 'components/AdminLinkButton': AdminLinkButton,
'components/PermissionGrid': PermissionGrid, 'components/PermissionGrid': PermissionGrid,
'components/Widget': Widget,
'components/MailPage': MailPage, 'components/MailPage': MailPage,
'components/UploadImageButton': UploadImageButton, 'components/UploadImageButton': UploadImageButton,
'components/LoadingModal': LoadingModal, 'components/LoadingModal': LoadingModal,
@@ -54,6 +58,6 @@ export default Object.assign(compat, {
'components/AdminNav': AdminNav, 'components/AdminNav': AdminNav,
'components/EditCustomCssModal': EditCustomCssModal, 'components/EditCustomCssModal': EditCustomCssModal,
'components/EditGroupModal': EditGroupModal, 'components/EditGroupModal': EditGroupModal,
routes: routes, 'routes': routes,
AdminApplication: AdminApplication, 'AdminApplication': AdminApplication
}); });

View File

@@ -22,10 +22,8 @@ export default class AddExtensionModal extends Modal {
return ( return (
<div className="Modal-body"> <div className="Modal-body">
<p>{app.translator.trans('core.admin.add_extension.temporary_text')}</p> <p>{app.translator.trans('core.admin.add_extension.temporary_text')}</p>
<p> <p>{app.translator.trans('core.admin.add_extension.install_text', {a: <a href="https://discuss.flarum.org/t/extensions" target="_blank"/>})}</p>
{app.translator.trans('core.admin.add_extension.install_text', { a: <a href="https://discuss.flarum.org/t/extensions" target="_blank" /> })} <p>{app.translator.trans('core.admin.add_extension.developer_text', {a: <a href="http://flarum.org/docs/extend" target="_blank"/>})}</p>
</p>
<p>{app.translator.trans('core.admin.add_extension.developer_text', { a: <a href="http://flarum.org/docs/extend" target="_blank" /> })}</p>
</div> </div>
); );
} }

View File

@@ -10,7 +10,15 @@
import LinkButton from '../../common/components/LinkButton'; import LinkButton from '../../common/components/LinkButton';
export default class AdminLinkButton extends LinkButton { export default class AdminLinkButton extends LinkButton {
getButtonContent(children) { getButtonContent() {
return [...super.getButtonContent(children), <div className="AdminLinkButton-description">{this.attrs.description}</div>]; const content = super.getButtonContent();
content.push(
<div className="AdminLinkButton-description">
{this.props.description}
</div>
);
return content;
} }
} }

View File

@@ -15,7 +15,9 @@ import ItemList from '../../common/utils/ItemList';
export default class AdminNav extends Component { export default class AdminNav extends Component {
view() { view() {
return ( return (
<SelectDropdown className="AdminNav App-titleControl" buttonClassName="Button"> <SelectDropdown
className="AdminNav App-titleControl"
buttonClassName="Button">
{this.items().toArray()} {this.items().toArray()}
</SelectDropdown> </SelectDropdown>
); );
@@ -29,77 +31,47 @@ export default class AdminNav extends Component {
items() { items() {
const items = new ItemList(); const items = new ItemList();
items.add( items.add('dashboard', AdminLinkButton.component({
'dashboard', href: app.route('dashboard'),
AdminLinkButton.component( icon: 'far fa-chart-bar',
{ children: app.translator.trans('core.admin.nav.dashboard_button'),
href: app.route('dashboard'), description: app.translator.trans('core.admin.nav.dashboard_text')
icon: 'far fa-chart-bar', }));
description: app.translator.trans('core.admin.nav.dashboard_text'),
},
app.translator.trans('core.admin.nav.dashboard_button')
)
);
items.add( items.add('basics', AdminLinkButton.component({
'basics', href: app.route('basics'),
AdminLinkButton.component( icon: 'fas fa-pencil-alt',
{ children: app.translator.trans('core.admin.nav.basics_button'),
href: app.route('basics'), description: app.translator.trans('core.admin.nav.basics_text')
icon: 'fas fa-pencil-alt', }));
description: app.translator.trans('core.admin.nav.basics_text'),
},
app.translator.trans('core.admin.nav.basics_button')
)
);
items.add( items.add('mail', AdminLinkButton.component({
'mail', href: app.route('mail'),
AdminLinkButton.component( icon: 'fas fa-envelope',
{ children: app.translator.trans('core.admin.nav.email_button'),
href: app.route('mail'), description: app.translator.trans('core.admin.nav.email_text')
icon: 'fas fa-envelope', }));
description: app.translator.trans('core.admin.nav.email_text'),
},
app.translator.trans('core.admin.nav.email_button')
)
);
items.add( items.add('permissions', AdminLinkButton.component({
'permissions', href: app.route('permissions'),
AdminLinkButton.component( icon: 'fas fa-key',
{ children: app.translator.trans('core.admin.nav.permissions_button'),
href: app.route('permissions'), description: app.translator.trans('core.admin.nav.permissions_text')
icon: 'fas fa-key', }));
description: app.translator.trans('core.admin.nav.permissions_text'),
},
app.translator.trans('core.admin.nav.permissions_button')
)
);
items.add( items.add('appearance', AdminLinkButton.component({
'appearance', href: app.route('appearance'),
AdminLinkButton.component( icon: 'fas fa-paint-brush',
{ children: app.translator.trans('core.admin.nav.appearance_button'),
href: app.route('appearance'), description: app.translator.trans('core.admin.nav.appearance_text')
icon: 'fas fa-paint-brush', }));
description: app.translator.trans('core.admin.nav.appearance_text'),
},
app.translator.trans('core.admin.nav.appearance_button')
)
);
items.add( items.add('extensions', AdminLinkButton.component({
'extensions', href: app.route('extensions'),
AdminLinkButton.component( icon: 'fas fa-puzzle-piece',
{ children: app.translator.trans('core.admin.nav.extensions_button'),
href: app.route('extensions'), description: app.translator.trans('core.admin.nav.extensions_text')
icon: 'fas fa-puzzle-piece', }));
description: app.translator.trans('core.admin.nav.extensions_text'),
},
app.translator.trans('core.admin.nav.extensions_button')
)
);
return items; return items;
} }

View File

@@ -1,7 +1,6 @@
import Page from '../../common/components/Page'; import Page from './Page';
import Button from '../../common/components/Button'; import Button from '../../common/components/Button';
import Switch from '../../common/components/Switch'; import Switch from '../../common/components/Switch';
import Stream from '../../common/utils/Stream';
import EditCustomCssModal from './EditCustomCssModal'; import EditCustomCssModal from './EditCustomCssModal';
import EditCustomHeaderModal from './EditCustomHeaderModal'; import EditCustomHeaderModal from './EditCustomHeaderModal';
import EditCustomFooterModal from './EditCustomFooterModal'; import EditCustomFooterModal from './EditCustomFooterModal';
@@ -9,13 +8,13 @@ import UploadImageButton from './UploadImageButton';
import saveSettings from '../utils/saveSettings'; import saveSettings from '../utils/saveSettings';
export default class AppearancePage extends Page { export default class AppearancePage extends Page {
oninit(vnode) { init() {
super.oninit(vnode); super.init();
this.primaryColor = Stream(app.data.settings.theme_primary_color); this.primaryColor = m.prop(app.data.settings.theme_primary_color);
this.secondaryColor = Stream(app.data.settings.theme_secondary_color); this.secondaryColor = m.prop(app.data.settings.theme_secondary_color);
this.darkMode = Stream(app.data.settings.theme_dark_mode); this.darkMode = m.prop(app.data.settings.theme_dark_mode === '1');
this.coloredHeader = Stream(app.data.settings.theme_colored_header); this.coloredHeader = m.prop(app.data.settings.theme_colored_header === '1');
} }
view() { view() {
@@ -25,86 +24,86 @@ export default class AppearancePage extends Page {
<form onsubmit={this.onsubmit.bind(this)}> <form onsubmit={this.onsubmit.bind(this)}>
<fieldset className="AppearancePage-colors"> <fieldset className="AppearancePage-colors">
<legend>{app.translator.trans('core.admin.appearance.colors_heading')}</legend> <legend>{app.translator.trans('core.admin.appearance.colors_heading')}</legend>
<div className="helpText">{app.translator.trans('core.admin.appearance.colors_text')}</div> <div className="helpText">
{app.translator.trans('core.admin.appearance.colors_text')}
<div className="AppearancePage-colors-input">
<input className="FormControl" type="text" placeholder="#aaaaaa" bidi={this.primaryColor} />
<input className="FormControl" type="text" placeholder="#aaaaaa" bidi={this.secondaryColor} />
</div> </div>
{Switch.component( <div className="AppearancePage-colors-input">
{ <input className="FormControl" type="text" placeholder="#aaaaaa" value={this.primaryColor()} onchange={m.withAttr('value', this.primaryColor)}/>
state: this.darkMode(), <input className="FormControl" type="text" placeholder="#aaaaaa" value={this.secondaryColor()} onchange={m.withAttr('value', this.secondaryColor)}/>
onchange: this.darkMode, </div>
},
app.translator.trans('core.admin.appearance.dark_mode_label')
)}
{Switch.component( {Switch.component({
{ state: this.darkMode(),
state: this.coloredHeader(), children: app.translator.trans('core.admin.appearance.dark_mode_label'),
onchange: this.coloredHeader, onchange: this.darkMode
}, })}
app.translator.trans('core.admin.appearance.colored_header_label')
)}
{Button.component( {Switch.component({
{ state: this.coloredHeader(),
className: 'Button Button--primary', children: app.translator.trans('core.admin.appearance.colored_header_label'),
type: 'submit', onchange: this.coloredHeader
loading: this.loading, })}
},
app.translator.trans('core.admin.appearance.submit_button') {Button.component({
)} className: 'Button Button--primary',
type: 'submit',
children: app.translator.trans('core.admin.appearance.submit_button'),
loading: this.loading
})}
</fieldset> </fieldset>
</form> </form>
<fieldset> <fieldset>
<legend>{app.translator.trans('core.admin.appearance.logo_heading')}</legend> <legend>{app.translator.trans('core.admin.appearance.logo_heading')}</legend>
<div className="helpText">{app.translator.trans('core.admin.appearance.logo_text')}</div> <div className="helpText">
<UploadImageButton name="logo" /> {app.translator.trans('core.admin.appearance.logo_text')}
</div>
<UploadImageButton name="logo"/>
</fieldset> </fieldset>
<fieldset> <fieldset>
<legend>{app.translator.trans('core.admin.appearance.favicon_heading')}</legend> <legend>{app.translator.trans('core.admin.appearance.favicon_heading')}</legend>
<div className="helpText">{app.translator.trans('core.admin.appearance.favicon_text')}</div> <div className="helpText">
<UploadImageButton name="favicon" /> {app.translator.trans('core.admin.appearance.favicon_text')}
</div>
<UploadImageButton name="favicon"/>
</fieldset> </fieldset>
<fieldset> <fieldset>
<legend>{app.translator.trans('core.admin.appearance.custom_header_heading')}</legend> <legend>{app.translator.trans('core.admin.appearance.custom_header_heading')}</legend>
<div className="helpText">{app.translator.trans('core.admin.appearance.custom_header_text')}</div> <div className="helpText">
{Button.component( {app.translator.trans('core.admin.appearance.custom_header_text')}
{ </div>
className: 'Button', {Button.component({
onclick: () => app.modal.show(EditCustomHeaderModal), className: 'Button',
}, children: app.translator.trans('core.admin.appearance.edit_header_button'),
app.translator.trans('core.admin.appearance.edit_header_button') onclick: () => app.modal.show(new EditCustomHeaderModal())
)} })}
</fieldset> </fieldset>
<fieldset> <fieldset>
<legend>{app.translator.trans('core.admin.appearance.custom_footer_heading')}</legend> <legend>{app.translator.trans('core.admin.appearance.custom_footer_heading')}</legend>
<div className="helpText">{app.translator.trans('core.admin.appearance.custom_footer_text')}</div> <div className="helpText">
{Button.component( {app.translator.trans('core.admin.appearance.custom_footer_text')}
{ </div>
className: 'Button', {Button.component({
onclick: () => app.modal.show(EditCustomFooterModal), className: 'Button',
}, children: app.translator.trans('core.admin.appearance.edit_footer_button'),
app.translator.trans('core.admin.appearance.edit_footer_button') onclick: () => app.modal.show(new EditCustomFooterModal())
)} })}
</fieldset> </fieldset>
<fieldset> <fieldset>
<legend>{app.translator.trans('core.admin.appearance.custom_styles_heading')}</legend> <legend>{app.translator.trans('core.admin.appearance.custom_styles_heading')}</legend>
<div className="helpText">{app.translator.trans('core.admin.appearance.custom_styles_text')}</div> <div className="helpText">
{Button.component( {app.translator.trans('core.admin.appearance.custom_styles_text')}
{ </div>
className: 'Button', {Button.component({
onclick: () => app.modal.show(EditCustomCssModal), className: 'Button',
}, children: app.translator.trans('core.admin.appearance.edit_css_button'),
app.translator.trans('core.admin.appearance.edit_css_button') onclick: () => app.modal.show(new EditCustomCssModal())
)} })}
</fieldset> </fieldset>
</div> </div>
</div> </div>
@@ -127,7 +126,7 @@ export default class AppearancePage extends Page {
theme_primary_color: this.primaryColor(), theme_primary_color: this.primaryColor(),
theme_secondary_color: this.secondaryColor(), theme_secondary_color: this.secondaryColor(),
theme_dark_mode: this.darkMode(), theme_dark_mode: this.darkMode(),
theme_colored_header: this.coloredHeader(), theme_colored_header: this.coloredHeader()
}).then(() => window.location.reload()); }).then(() => window.location.reload());
} }
} }

View File

@@ -1,16 +1,15 @@
import Page from '../../common/components/Page'; import Page from './Page';
import FieldSet from '../../common/components/FieldSet'; import FieldSet from '../../common/components/FieldSet';
import Select from '../../common/components/Select'; import Select from '../../common/components/Select';
import Button from '../../common/components/Button'; import Button from '../../common/components/Button';
import Alert from '../../common/components/Alert';
import saveSettings from '../utils/saveSettings'; import saveSettings from '../utils/saveSettings';
import ItemList from '../../common/utils/ItemList'; import ItemList from '../../common/utils/ItemList';
import Switch from '../../common/components/Switch'; import Switch from '../../common/components/Switch';
import Stream from '../../common/utils/Stream';
import withAttr from '../../common/utils/withAttr';
export default class BasicsPage extends Page { export default class BasicsPage extends Page {
oninit(vnode) { init() {
super.oninit(vnode); super.init();
this.loading = false; this.loading = false;
@@ -21,13 +20,12 @@ export default class BasicsPage extends Page {
'show_language_selector', 'show_language_selector',
'default_route', 'default_route',
'welcome_title', 'welcome_title',
'welcome_message', 'welcome_message'
'display_name_driver',
]; ];
this.values = {}; this.values = {};
const settings = app.data.settings; const settings = app.data.settings;
this.fields.forEach((key) => (this.values[key] = Stream(settings[key]))); this.fields.forEach(key => this.values[key] = m.prop(settings[key]));
this.localeOptions = {}; this.localeOptions = {};
const locales = app.data.locales; const locales = app.data.locales;
@@ -35,15 +33,7 @@ export default class BasicsPage extends Page {
this.localeOptions[i] = `${locales[i]} (${i})`; this.localeOptions[i] = `${locales[i]} (${i})`;
} }
this.displayNameOptions = {}; if (typeof this.values.show_language_selector() !== "number") this.values.show_language_selector(1);
const displayNameDrivers = app.data.displayNameDrivers;
displayNameDrivers.forEach(function (identifier) {
this.displayNameOptions[identifier] = identifier;
}, this);
if (!this.values.display_name_driver() && displayNameDrivers.includes('username')) this.values.display_name_driver('username');
if (typeof this.values.show_language_selector() !== 'number') this.values.show_language_selector(1);
} }
view() { view() {
@@ -51,107 +41,78 @@ export default class BasicsPage extends Page {
<div className="BasicsPage"> <div className="BasicsPage">
<div className="container"> <div className="container">
<form onsubmit={this.onsubmit.bind(this)}> <form onsubmit={this.onsubmit.bind(this)}>
{FieldSet.component( {FieldSet.component({
{ label: app.translator.trans('core.admin.basics.forum_title_heading'),
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)}/>
[<input className="FormControl" bidi={this.values.forum_title} />]
)}
{FieldSet.component(
{
label: app.translator.trans('core.admin.basics.forum_description_heading'),
},
[
<div className="helpText">{app.translator.trans('core.admin.basics.forum_description_text')}</div>,
<textarea className="FormControl" bidi={this.values.forum_description} />,
] ]
)} })}
{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 {Object.keys(this.localeOptions).length > 1
? FieldSet.component( ? FieldSet.component({
{ label: app.translator.trans('core.admin.basics.default_language_heading'),
label: app.translator.trans('core.admin.basics.default_language_heading'), children: [
}, Select.component({
[ options: this.localeOptions,
Select.component({ value: this.values.default_locale(),
options: this.localeOptions, onchange: this.values.default_locale
value: this.values.default_locale(), }),
onchange: this.values.default_locale, Switch.component({
}), state: this.values.show_language_selector(),
Switch.component( onchange: this.values.show_language_selector,
{ children: app.translator.trans('core.admin.basics.show_language_selector_label'),
state: this.values.show_language_selector(), })
onchange: this.values.show_language_selector, ]
}, })
app.translator.trans('core.admin.basics.show_language_selector_label')
),
]
)
: ''} : ''}
{FieldSet.component( {FieldSet.component({
{ label: app.translator.trans('core.admin.basics.home_page_heading'),
label: app.translator.trans('core.admin.basics.home_page_heading'), className: 'BasicsPage-homePage',
className: 'BasicsPage-homePage', children: [
}, <div className="helpText">
[ {app.translator.trans('core.admin.basics.home_page_text')}
<div className="helpText">{app.translator.trans('core.admin.basics.home_page_text')}</div>,
this.homePageItems()
.toArray()
.map(({ path, label }) => (
<label className="checkbox">
<input
type="radio"
name="homePage"
value={path}
checked={this.values.default_route() === path}
onclick={withAttr('value', this.values.default_route)}
/>
{label}
</label>
)),
]
)}
{FieldSet.component(
{
label: app.translator.trans('core.admin.basics.welcome_banner_heading'),
className: 'BasicsPage-welcomeBanner',
},
[
<div className="helpText">{app.translator.trans('core.admin.basics.welcome_banner_text')}</div>,
<div className="BasicsPage-welcomeBanner-input">
<input className="FormControl" bidi={this.values.welcome_title} />
<textarea className="FormControl" bidi={this.values.welcome_message} />
</div>, </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)}/>
{Object.keys(this.displayNameOptions).length > 1 {label}
? FieldSet.component( </label>
{
label: app.translator.trans('core.admin.basics.display_name_heading'),
},
[
<div className="helpText">{app.translator.trans('core.admin.basics.display_name_text')}</div>,
Select.component({
options: this.displayNameOptions,
bidi: this.values.display_name_driver,
}),
]
) )
: ''} ]
})}
{Button.component( {FieldSet.component({
{ label: app.translator.trans('core.admin.basics.welcome_banner_heading'),
type: 'submit', className: 'BasicsPage-welcomeBanner',
className: 'Button Button--primary', children: [
loading: this.loading, <div className="helpText">
disabled: !this.changed(), {app.translator.trans('core.admin.basics.welcome_banner_text')}
}, </div>,
app.translator.trans('core.admin.basics.submit_button') <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> </form>
</div> </div>
</div> </div>
@@ -159,7 +120,7 @@ export default class BasicsPage extends Page {
} }
changed() { changed() {
return this.fields.some((key) => this.values[key]() !== app.data.settings[key]); return this.fields.some(key => this.values[key]() !== app.data.settings[key]);
} }
/** /**
@@ -174,7 +135,7 @@ export default class BasicsPage extends Page {
items.add('allDiscussions', { items.add('allDiscussions', {
path: '/all', path: '/all',
label: app.translator.trans('core.admin.basics.all_discussions_label'), label: app.translator.trans('core.admin.basics.all_discussions_label')
}); });
return items; return items;
@@ -190,11 +151,11 @@ export default class BasicsPage extends Page {
const settings = {}; const settings = {};
this.fields.forEach((key) => (settings[key] = this.values[key]())); this.fields.forEach(key => settings[key] = this.values[key]());
saveSettings(settings) saveSettings(settings)
.then(() => { .then(() => {
this.successAlert = app.alerts.show({ type: 'success' }, app.translator.trans('core.admin.basics.saved_message')); app.alerts.show(this.successAlert = new Alert({type: 'success', children: app.translator.trans('core.admin.basics.saved_message')}));
}) })
.catch(() => {}) .catch(() => {})
.then(() => { .then(() => {

View File

@@ -1,16 +1,18 @@
import Page from '../../common/components/Page'; import Page from './Page';
import StatusWidget from './StatusWidget'; import StatusWidget from './StatusWidget';
export default class DashboardPage extends Page { export default class DashboardPage extends Page {
view() { view() {
return ( return (
<div className="DashboardPage"> <div className="DashboardPage">
<div className="container">{this.availableWidgets()}</div> <div className="container">
{this.availableWidgets()}
</div>
</div> </div>
); );
} }
availableWidgets() { availableWidgets() {
return [<StatusWidget />]; return [<StatusWidget/>];
} }
} }

View File

@@ -1,8 +1,21 @@
/*
* This file is part of Flarum.
*
* (c) Toby Zerner <toby.zerner@gmail.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
import Component from '../../common/Component'; import Component from '../../common/Component';
export default class DashboardWidget extends Component { export default class Widget extends Component {
view() { view() {
return <div className={'DashboardWidget Widget ' + this.className()}>{this.content()}</div>; return (
<div className={"Widget "+this.className()}>
{this.content()}
</div>
);
} }
/** /**

View File

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

View File

@@ -13,8 +13,8 @@ export default class EditCustomFooterModal extends SettingsModal {
return [ return [
<p>{app.translator.trans('core.admin.edit_footer.customize_text')}</p>, <p>{app.translator.trans('core.admin.edit_footer.customize_text')}</p>,
<div className="Form-group"> <div className="Form-group">
<textarea className="FormControl" rows="30" bidi={this.setting('custom_footer')} /> <textarea className="FormControl" rows="30" bidi={this.setting('custom_footer')}/>
</div>, </div>
]; ];
} }

View File

@@ -13,8 +13,8 @@ export default class EditCustomHeaderModal extends SettingsModal {
return [ return [
<p>{app.translator.trans('core.admin.edit_header.customize_text')}</p>, <p>{app.translator.trans('core.admin.edit_header.customize_text')}</p>,
<div className="Form-group"> <div className="Form-group">
<textarea className="FormControl" rows="30" bidi={this.setting('custom_header')} /> <textarea className="FormControl" rows="30" bidi={this.setting('custom_header')}/>
</div>, </div>
]; ];
} }

View File

@@ -3,24 +3,19 @@ import Button from '../../common/components/Button';
import Badge from '../../common/components/Badge'; import Badge from '../../common/components/Badge';
import Group from '../../common/models/Group'; import Group from '../../common/models/Group';
import ItemList from '../../common/utils/ItemList'; import ItemList from '../../common/utils/ItemList';
import Switch from '../../common/components/Switch';
import Stream from '../../common/utils/Stream';
/** /**
* The `EditGroupModal` component shows a modal dialog which allows the user * The `EditGroupModal` component shows a modal dialog which allows the user
* to create or edit a group. * to create or edit a group.
*/ */
export default class EditGroupModal extends Modal { export default class EditGroupModal extends Modal {
oninit(vnode) { init() {
super.oninit(vnode); this.group = this.props.group || app.store.createRecord('groups');
this.group = this.attrs.group || app.store.createRecord('groups'); this.nameSingular = m.prop(this.group.nameSingular() || '');
this.namePlural = m.prop(this.group.namePlural() || '');
this.nameSingular = Stream(this.group.nameSingular() || ''); this.icon = m.prop(this.group.icon() || '');
this.namePlural = Stream(this.group.namePlural() || ''); this.color = m.prop(this.group.color() || '');
this.icon = Stream(this.group.icon() || '');
this.color = Stream(this.group.color() || '');
this.isHidden = Stream(this.group.isHidden() || false);
} }
className() { className() {
@@ -29,21 +24,21 @@ export default class EditGroupModal extends Modal {
title() { title() {
return [ return [
this.color() || this.icon() this.color() || this.icon() ? Badge.component({
? Badge.component({ icon: this.icon(),
icon: this.icon(), style: {backgroundColor: this.color()}
style: { backgroundColor: this.color() }, }) : '',
})
: '',
' ', ' ',
this.namePlural() || app.translator.trans('core.admin.edit_group.title'), this.namePlural() || app.translator.trans('core.admin.edit_group.title')
]; ];
} }
content() { content() {
return ( return (
<div className="Modal-body"> <div className="Modal-body">
<div className="Form">{this.fields().toArray()}</div> <div className="Form">
{this.fields().toArray()}
</div>
</div> </div>
); );
} }
@@ -51,95 +46,55 @@ export default class EditGroupModal extends Modal {
fields() { fields() {
const items = new ItemList(); const items = new ItemList();
items.add( items.add('name', <div className="Form-group">
'name', <label>{app.translator.trans('core.admin.edit_group.name_label')}</label>
<div className="Form-group"> <div className="EditGroupModal-name-input">
<label>{app.translator.trans('core.admin.edit_group.name_label')}</label> <input className="FormControl" placeholder={app.translator.trans('core.admin.edit_group.singular_placeholder')} value={this.nameSingular()} oninput={m.withAttr('value', this.nameSingular)}/>
<div className="EditGroupModal-name-input"> <input className="FormControl" placeholder={app.translator.trans('core.admin.edit_group.plural_placeholder')} value={this.namePlural()} oninput={m.withAttr('value', this.namePlural)}/>
<input className="FormControl" placeholder={app.translator.trans('core.admin.edit_group.singular_placeholder')} bidi={this.nameSingular} /> </div>
<input className="FormControl" placeholder={app.translator.trans('core.admin.edit_group.plural_placeholder')} bidi={this.namePlural} /> </div>, 30);
</div>
</div>,
30
);
items.add( items.add('color', <div className="Form-group">
'color', <label>{app.translator.trans('core.admin.edit_group.color_label')}</label>
<div className="Form-group"> <input className="FormControl" placeholder="#aaaaaa" value={this.color()} oninput={m.withAttr('value', this.color)}/>
<label>{app.translator.trans('core.admin.edit_group.color_label')}</label> </div>, 20);
<input className="FormControl" placeholder="#aaaaaa" bidi={this.color} />
</div>,
20
);
items.add( items.add('icon', <div className="Form-group">
'icon', <label>{app.translator.trans('core.admin.edit_group.icon_label')}</label>
<div className="Form-group"> <div className="helpText">
<label>{app.translator.trans('core.admin.edit_group.icon_label')}</label> {app.translator.trans('core.admin.edit_group.icon_text', {a: <a href="https://fontawesome.com/icons?m=free" tabindex="-1"/>})}
<div className="helpText"> </div>
{app.translator.trans('core.admin.edit_group.icon_text', { a: <a href="https://fontawesome.com/icons?m=free" tabindex="-1" /> })} <input className="FormControl" placeholder="fas fa-bolt" value={this.icon()} oninput={m.withAttr('value', this.icon)}/>
</div> </div>, 10);
<input className="FormControl" placeholder="fas fa-bolt" bidi={this.icon} />
</div>,
10
);
items.add( items.add('submit', <div className="Form-group">
'hidden', {Button.component({
<div className="Form-group"> type: 'submit',
{Switch.component( className: 'Button Button--primary EditGroupModal-save',
{ loading: this.loading,
state: !!Number(this.isHidden()), children: app.translator.trans('core.admin.edit_group.submit_button')
onchange: this.isHidden, })}
}, {this.group.exists && this.group.id() !== Group.ADMINISTRATOR_ID ? (
app.translator.trans('core.admin.edit_group.hide_label') <button type="button" className="Button EditGroupModal-delete" onclick={this.deleteGroup.bind(this)}>
)} {app.translator.trans('core.admin.edit_group.delete_button')}
</div>, </button>
10 ) : ''}
); </div>, -10);
items.add(
'submit',
<div className="Form-group">
{Button.component(
{
type: 'submit',
className: 'Button Button--primary EditGroupModal-save',
loading: this.loading,
},
app.translator.trans('core.admin.edit_group.submit_button')
)}
{this.group.exists && this.group.id() !== Group.ADMINISTRATOR_ID ? (
<button type="button" className="Button EditGroupModal-delete" onclick={this.deleteGroup.bind(this)}>
{app.translator.trans('core.admin.edit_group.delete_button')}
</button>
) : (
''
)}
</div>,
-10
);
return items; return items;
} }
submitData() {
return {
nameSingular: this.nameSingular(),
namePlural: this.namePlural(),
color: this.color(),
icon: this.icon(),
isHidden: this.isHidden(),
};
}
onsubmit(e) { onsubmit(e) {
e.preventDefault(); e.preventDefault();
this.loading = true; this.loading = true;
this.group this.group.save({
.save(this.submitData(), { errorHandler: this.onerror.bind(this) }) nameSingular: this.nameSingular(),
namePlural: this.namePlural(),
color: this.color(),
icon: this.icon()
}, {errorHandler: this.onerror.bind(this)})
.then(this.hide.bind(this)) .then(this.hide.bind(this))
.catch(() => { .catch(() => {
this.loading = false; this.loading = false;

View File

@@ -1,10 +1,13 @@
import Page from '../../common/components/Page'; import Page from './Page';
import LinkButton from '../../common/components/LinkButton';
import Button from '../../common/components/Button'; import Button from '../../common/components/Button';
import Dropdown from '../../common/components/Dropdown'; import Dropdown from '../../common/components/Dropdown';
import Separator from '../../common/components/Separator';
import AddExtensionModal from './AddExtensionModal'; import AddExtensionModal from './AddExtensionModal';
import LoadingModal from './LoadingModal'; import LoadingModal from './LoadingModal';
import ItemList from '../../common/utils/ItemList'; import ItemList from '../../common/utils/ItemList';
import icon from '../../common/helpers/icon'; import icon from '../../common/helpers/icon';
import listItems from '../../common/helpers/listItems';
export default class ExtensionsPage extends Page { export default class ExtensionsPage extends Page {
view() { view() {
@@ -12,26 +15,24 @@ export default class ExtensionsPage extends Page {
<div className="ExtensionsPage"> <div className="ExtensionsPage">
<div className="ExtensionsPage-header"> <div className="ExtensionsPage-header">
<div className="container"> <div className="container">
{Button.component( {Button.component({
{ children: app.translator.trans('core.admin.extensions.add_button'),
icon: 'fas fa-plus', icon: 'fas fa-plus',
className: 'Button Button--primary', className: 'Button Button--primary',
onclick: () => app.modal.show(AddExtensionModal), onclick: () => app.modal.show(new AddExtensionModal())
}, })}
app.translator.trans('core.admin.extensions.add_button')
)}
</div> </div>
</div> </div>
<div className="ExtensionsPage-list"> <div className="ExtensionsPage-list">
<div className="container"> <div className="container">
<ul className="ExtensionList"> <ul className="ExtensionList">
{Object.keys(app.data.extensions).map((id) => { {Object.keys(app.data.extensions)
const extension = app.data.extensions[id]; .map(id => {
const controls = this.controlItems(extension.id).toArray(); const extension = app.data.extensions[id];
const controls = this.controlItems(extension.id).toArray();
return ( return <li className={'ExtensionListItem ' + (!this.isEnabled(extension.id) ? 'disabled' : '')}>
<li className={'ExtensionListItem ' + (!this.isEnabled(extension.id) ? 'disabled' : '')}>
<div className="ExtensionListItem-content"> <div className="ExtensionListItem-content">
<span className="ExtensionListItem-icon ExtensionIcon" style={extension.icon}> <span className="ExtensionListItem-icon ExtensionIcon" style={extension.icon}>
{extension.icon ? icon(extension.icon.name) : ''} {extension.icon ? icon(extension.icon.name) : ''}
@@ -41,25 +42,21 @@ export default class ExtensionsPage extends Page {
className="ExtensionListItem-controls" className="ExtensionListItem-controls"
buttonClassName="Button Button--icon Button--flat" buttonClassName="Button Button--icon Button--flat"
menuClassName="Dropdown-menu--right" menuClassName="Dropdown-menu--right"
icon="fas fa-ellipsis-h" icon="fas fa-ellipsis-h">
>
{controls} {controls}
</Dropdown> </Dropdown>
) : ( ) : ''}
''
)}
<div className="ExtensionListItem-main"> <div className="ExtensionListItem-main">
<label className="ExtensionListItem-title"> <label className="ExtensionListItem-title">
<input type="checkbox" checked={this.isEnabled(extension.id)} onclick={this.toggle.bind(this, extension.id)} />{' '} <input type="checkbox" checked={this.isEnabled(extension.id)} onclick={this.toggle.bind(this, extension.id)}/> {' '}
{extension.extra['flarum-extension'].title} {extension.extra['flarum-extension'].title}
</label> </label>
<div className="ExtensionListItem-version">{extension.version}</div> <div className="ExtensionListItem-version">{extension.version}</div>
<div className="ExtensionListItem-description">{extension.description}</div> <div className="ExtensionListItem-description">{extension.description}</div>
</div> </div>
</div> </div>
</li> </li>;
); })}
})}
</ul> </ul>
</div> </div>
</div> </div>
@@ -72,38 +69,26 @@ export default class ExtensionsPage extends Page {
const enabled = this.isEnabled(name); const enabled = this.isEnabled(name);
if (app.extensionSettings[name]) { if (app.extensionSettings[name]) {
items.add( items.add('settings', Button.component({
'settings', icon: 'fas fa-cog',
Button.component( children: app.translator.trans('core.admin.extensions.settings_button'),
{ onclick: app.extensionSettings[name]
icon: 'fas fa-cog', }));
onclick: app.extensionSettings[name],
},
app.translator.trans('core.admin.extensions.settings_button')
)
);
} }
if (!enabled) { if (!enabled) {
items.add( items.add('uninstall', Button.component({
'uninstall', icon: 'far fa-trash-alt',
Button.component( children: app.translator.trans('core.admin.extensions.uninstall_button'),
{ onclick: () => {
icon: 'far fa-trash-alt', app.request({
onclick: () => { url: app.forum.attribute('apiUrl') + '/extensions/' + name,
app method: 'DELETE'
.request({ }).then(() => window.location.reload());
url: app.forum.attribute('apiUrl') + '/extensions/' + name,
method: 'DELETE',
})
.then(() => window.location.reload());
app.modal.show(LoadingModal); app.modal.show(new LoadingModal());
}, }
}, }));
app.translator.trans('core.admin.extensions.uninstall_button')
)
);
} }
return items; return items;
@@ -118,41 +103,15 @@ export default class ExtensionsPage extends Page {
toggle(id) { toggle(id) {
const enabled = this.isEnabled(id); const enabled = this.isEnabled(id);
app app.request({
.request({ url: app.forum.attribute('apiUrl') + '/extensions/' + id,
url: app.forum.attribute('apiUrl') + '/extensions/' + id, method: 'PATCH',
method: 'PATCH', data: {enabled: !enabled}
body: { enabled: !enabled }, }).then(() => {
errorHandler: this.onerror.bind(this), if (!enabled) localStorage.setItem('enabledExtension', id);
}) window.location.reload();
.then(() => { });
if (!enabled) localStorage.setItem('enabledExtension', id);
window.location.reload();
});
app.modal.show(LoadingModal); app.modal.show(new LoadingModal());
}
onerror(e) {
// We need to give the modal animation time to start; if we close the modal too early,
// it breaks the bootstrap modal library.
// TODO: This workaround should be removed when we move away from bootstrap JS for modals.
setTimeout(() => {
app.modal.close();
}, 300); // Bootstrap's Modal.TRANSITION_DURATION is 300 ms.
if (e.status !== 409) {
throw e;
}
const error = e.response.errors[0];
app.alerts.show(
{ type: 'error' },
app.translator.trans(`core.lib.error.${error.code}_message`, {
extension: error.extension,
extensions: error.extensions.join(', '),
})
);
} }
} }

View File

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

View File

@@ -8,7 +8,18 @@ import listItems from '../../common/helpers/listItems';
*/ */
export default class HeaderSecondary extends Component { export default class HeaderSecondary extends Component {
view() { view() {
return <ul className="Header-controls">{listItems(this.items().toArray())}</ul>; return (
<ul className="Header-controls">
{listItems(this.items().toArray())}
</ul>
);
}
config(isInitialized, context) {
// Since this component is 'above' the content of the page (that is, it is a
// part of the global UI that persists between routes), we will flag the DOM
// to be retained across route changes.
context.retain = true;
} }
/** /**

View File

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

View File

@@ -1,228 +1,124 @@
import Page from '../../common/components/Page'; import Page from './Page';
import FieldSet from '../../common/components/FieldSet'; import FieldSet from '../../common/components/FieldSet';
import Button from '../../common/components/Button'; import Button from '../../common/components/Button';
import Alert from '../../common/components/Alert'; import Alert from '../../common/components/Alert';
import Select from '../../common/components/Select';
import LoadingIndicator from '../../common/components/LoadingIndicator';
import saveSettings from '../utils/saveSettings'; import saveSettings from '../utils/saveSettings';
import Stream from '../../common/utils/Stream';
export default class MailPage extends Page { export default class MailPage extends Page {
oninit(vnode) { init() {
super.oninit(vnode); super.init();
this.saving = false; this.loading = false;
this.sendingTest = false;
this.refresh();
}
refresh() { this.fields = [
this.loading = true; 'mail_driver',
'mail_host',
this.driverFields = {}; 'mail_from',
this.fields = ['mail_driver', 'mail_from']; 'mail_port',
'mail_username',
'mail_password',
'mail_encryption'
];
this.values = {}; this.values = {};
this.status = { sending: false, errors: {} };
const settings = app.data.settings; const settings = app.data.settings;
this.fields.forEach((key) => (this.values[key] = Stream(settings[key]))); this.fields.forEach(key => this.values[key] = m.prop(settings[key]));
app this.localeOptions = {};
.request({ const locales = app.locales;
method: 'GET', for (const i in locales) {
url: app.forum.attribute('apiUrl') + '/mail/settings', this.localeOptions[i] = `${locales[i]} (${i})`;
}) }
.then((response) => {
this.driverFields = response['data']['attributes']['fields'];
this.status.sending = response['data']['attributes']['sending'];
this.status.errors = response['data']['attributes']['errors'];
for (const driver in this.driverFields) {
for (const field in this.driverFields[driver]) {
this.fields.push(field);
this.values[field] = Stream(settings[field]);
}
}
this.loading = false;
m.redraw();
});
} }
view() { view() {
if (this.loading || this.saving) {
return (
<div className="MailPage">
<div className="container">
<LoadingIndicator />
</div>
</div>
);
}
const fields = this.driverFields[this.values.mail_driver()];
const fieldKeys = Object.keys(fields);
return ( return (
<div className="MailPage"> <div className="MailPage">
<div className="container"> <div className="container">
<form onsubmit={this.onsubmit.bind(this)}> <form onsubmit={this.onsubmit.bind(this)}>
<h2>{app.translator.trans('core.admin.email.heading')}</h2> <h2>{app.translator.trans('core.admin.email.heading')}</h2>
<div className="helpText">{app.translator.trans('core.admin.email.text')}</div> <div className="helpText">
{app.translator.trans('core.admin.email.text')}
</div>
{FieldSet.component( {FieldSet.component({
{ label: app.translator.trans('core.admin.email.server_heading'),
label: app.translator.trans('core.admin.email.addresses_heading'), className: 'MailPage-MailSettings',
className: 'MailPage-MailSettings', children: [
},
[
<div className="MailPage-MailSettings-input"> <div className="MailPage-MailSettings-input">
<label> <label>{app.translator.trans('core.admin.email.driver_label')}</label>
{app.translator.trans('core.admin.email.from_label')} <input className="FormControl" value={this.values.mail_driver() || ''} oninput={m.withAttr('value', this.values.mail_driver)} />
<input className="FormControl" bidi={this.values.mail_from} /> <label>{app.translator.trans('core.admin.email.host_label')}</label>
</label> <input className="FormControl" value={this.values.mail_host() || ''} oninput={m.withAttr('value', this.values.mail_host)} />
</div>, <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( {FieldSet.component({
{ label: app.translator.trans('core.admin.email.account_heading'),
label: app.translator.trans('core.admin.email.driver_heading'), className: 'MailPage-MailSettings',
className: 'MailPage-MailSettings', children: [
},
[
<div className="MailPage-MailSettings-input"> <div className="MailPage-MailSettings-input">
<label> <label>{app.translator.trans('core.admin.email.username_label')}</label>
{app.translator.trans('core.admin.email.driver_label')} <input className="FormControl" value={this.values.mail_username() || ''} oninput={m.withAttr('value', this.values.mail_username)} />
<Select <label>{app.translator.trans('core.admin.email.password_label')}</label>
value={this.values.mail_driver()} <input className="FormControl" value={this.values.mail_password() || ''} oninput={m.withAttr('value', this.values.mail_password)} />
options={Object.keys(this.driverFields).reduce((memo, val) => ({ ...memo, [val]: val }), {})} </div>
onchange={this.values.mail_driver}
/>
</label>
</div>,
] ]
)} })}
{this.status.sending || {FieldSet.component({
Alert.component( label: app.translator.trans('core.admin.email.addresses_heading'),
{ className: 'MailPage-MailSettings',
dismissible: false, children: [
}, <div className="MailPage-MailSettings-input">
app.translator.trans('core.admin.email.not_sending_message') <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>
{fieldKeys.length > 0 &&
FieldSet.component(
{
label: app.translator.trans(`core.admin.email.${this.values.mail_driver()}_heading`),
className: 'MailPage-MailSettings',
},
[
<div className="MailPage-MailSettings-input">
{fieldKeys.map((field) => [
<label>
{app.translator.trans(`core.admin.email.${field}_label`)}
{this.renderField(field)}
</label>,
this.status.errors[field] && <p className="ValidationError">{this.status.errors[field]}</p>,
])}
</div>,
]
)}
<FieldSet>
{Button.component(
{
type: 'submit',
className: 'Button Button--primary',
disabled: !this.changed(),
},
app.translator.trans('core.admin.email.submit_button')
)}
</FieldSet>
{FieldSet.component(
{
label: app.translator.trans('core.admin.email.send_test_mail_heading'),
className: 'MailPage-MailSettings',
},
[
<div className="helpText">{app.translator.trans('core.admin.email.send_test_mail_text', { email: app.session.user.email() })}</div>,
Button.component(
{
className: 'Button Button--primary',
disabled: this.sendingTest || this.changed(),
onclick: () => this.sendTestEmail(),
},
app.translator.trans('core.admin.email.send_test_mail_button')
),
] ]
)} })}
{Button.component({
type: 'submit',
className: 'Button Button--primary',
children: app.translator.trans('core.admin.email.submit_button'),
loading: this.loading,
disabled: !this.changed()
})}
</form> </form>
</div> </div>
</div> </div>
); );
} }
renderField(name) {
const driver = this.values.mail_driver();
const field = this.driverFields[driver][name];
const prop = this.values[name];
if (typeof field === 'string') {
return <input className="FormControl" bidi={prop} />;
} else {
return <Select value={prop()} options={field} onchange={prop} />;
}
}
changed() { changed() {
return this.fields.some((key) => this.values[key]() !== app.data.settings[key]); return this.fields.some(key => this.values[key]() !== app.data.settings[key]);
}
sendTestEmail() {
if (this.saving || this.sendingTest) return;
this.sendingTest = true;
app.alerts.dismiss(this.testEmailSuccessAlert);
app
.request({
method: 'POST',
url: app.forum.attribute('apiUrl') + '/mail/test',
})
.then((response) => {
this.sendingTest = false;
this.testEmailSuccessAlert = app.alerts.show({ type: 'success' }, app.translator.trans('core.admin.email.send_test_mail_success'));
})
.catch((error) => {
this.sendingTest = false;
m.redraw();
throw error;
});
} }
onsubmit(e) { onsubmit(e) {
e.preventDefault(); e.preventDefault();
if (this.saving || this.sendingTest) return; if (this.loading) return;
this.saving = true; this.loading = true;
app.alerts.dismiss(this.successAlert); app.alerts.dismiss(this.successAlert);
const settings = {}; const settings = {};
this.fields.forEach((key) => (settings[key] = this.values[key]())); this.fields.forEach(key => settings[key] = this.values[key]());
saveSettings(settings) saveSettings(settings)
.then(() => { .then(() => {
this.successAlert = app.alerts.show({ type: 'success' }, app.translator.trans('core.admin.basics.saved_message')); app.alerts.show(this.successAlert = new Alert({type: 'success', children: app.translator.trans('core.admin.basics.saved_message')}));
}) })
.catch(() => {}) .catch(() => {})
.then(() => { .then(() => {
this.saving = false; this.loading = false;
this.refresh(); m.redraw();
}); });
} }
} }

View File

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

View File

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

View File

@@ -6,17 +6,19 @@ import ItemList from '../../common/utils/ItemList';
import icon from '../../common/helpers/icon'; import icon from '../../common/helpers/icon';
export default class PermissionGrid extends Component { export default class PermissionGrid extends Component {
oninit(vnode) { init() {
super.oninit(vnode);
this.permissions = this.permissionItems().toArray(); this.permissions = this.permissionItems().toArray();
} }
view() { view() {
const scopes = this.scopeItems().toArray(); const scopes = this.scopeItems().toArray();
const permissionCells = (permission) => { const permissionCells = permission => {
return scopes.map((scope) => <td>{scope.render(permission)}</td>); return scopes.map(scope => (
<td>
{scope.render(permission)}
</td>
));
}; };
return ( return (
@@ -24,32 +26,27 @@ export default class PermissionGrid extends Component {
<thead> <thead>
<tr> <tr>
<td></td> <td></td>
{scopes.map((scope) => ( {scopes.map(scope => (
<th> <th>
{scope.label}{' '} {scope.label}{' '}
{scope.onremove {scope.onremove ? Button.component({icon: 'fas fa-times', className: 'Button Button--text PermissionGrid-removeScope', onclick: scope.onremove}) : ''}
? Button.component({ icon: 'fas fa-times', className: 'Button Button--text PermissionGrid-removeScope', onclick: scope.onremove })
: ''}
</th> </th>
))} ))}
<th>{this.scopeControlItems().toArray()}</th> <th>{this.scopeControlItems().toArray()}</th>
</tr> </tr>
</thead> </thead>
{this.permissions.map((section) => ( {this.permissions.map(section => (
<tbody> <tbody>
<tr className="PermissionGrid-section"> <tr className="PermissionGrid-section">
<th>{section.label}</th> <th>{section.label}</th>
{permissionCells(section)} {permissionCells(section)}
<td /> <td/>
</tr> </tr>
{section.children.map((child) => ( {section.children.map(child => (
<tr className="PermissionGrid-child"> <tr className="PermissionGrid-child">
<th> <th>{icon(child.icon)}{child.label}</th>
{icon(child.icon)}
{child.label}
</th>
{permissionCells(child)} {permissionCells(child)}
<td /> <td/>
</tr> </tr>
))} ))}
</tbody> </tbody>
@@ -61,41 +58,25 @@ export default class PermissionGrid extends Component {
permissionItems() { permissionItems() {
const items = new ItemList(); const items = new ItemList();
items.add( items.add('view', {
'view', label: app.translator.trans('core.admin.permissions.read_heading'),
{ children: this.viewItems().toArray()
label: app.translator.trans('core.admin.permissions.read_heading'), }, 100);
children: this.viewItems().toArray(),
},
100
);
items.add( items.add('start', {
'start', label: app.translator.trans('core.admin.permissions.create_heading'),
{ children: this.startItems().toArray()
label: app.translator.trans('core.admin.permissions.create_heading'), }, 90);
children: this.startItems().toArray(),
},
90
);
items.add( items.add('reply', {
'reply', label: app.translator.trans('core.admin.permissions.participate_heading'),
{ children: this.replyItems().toArray()
label: app.translator.trans('core.admin.permissions.participate_heading'), }, 80);
children: this.replyItems().toArray(),
},
80
);
items.add( items.add('moderate', {
'moderate', label: app.translator.trans('core.admin.permissions.moderate_heading'),
{ children: this.moderateItems().toArray()
label: app.translator.trans('core.admin.permissions.moderate_heading'), }, 70);
children: this.moderateItems().toArray(),
},
70
);
return items; return items;
} }
@@ -103,54 +84,31 @@ export default class PermissionGrid extends Component {
viewItems() { viewItems() {
const items = new ItemList(); const items = new ItemList();
items.add( items.add('viewDiscussions', {
'viewDiscussions', icon: 'fas fa-eye',
{ label: app.translator.trans('core.admin.permissions.view_discussions_label'),
icon: 'fas fa-eye', permission: 'viewDiscussions',
label: app.translator.trans('core.admin.permissions.view_discussions_label'), allowGuest: true
permission: 'viewDiscussions', }, 100);
allowGuest: true,
},
100
);
items.add( items.add('viewUserList', {
'viewHiddenGroups', icon: 'fas fa-users',
{ label: app.translator.trans('core.admin.permissions.view_user_list_label'),
icon: 'fas fa-users', permission: 'viewUserList',
label: app.translator.trans('core.admin.permissions.view_hidden_groups_label'), allowGuest: true
permission: 'viewHiddenGroups', }, 100);
},
100
);
items.add( items.add('signUp', {
'viewUserList', icon: 'fas fa-user-plus',
{ label: app.translator.trans('core.admin.permissions.sign_up_label'),
icon: 'fas fa-users', setting: () => SettingDropdown.component({
label: app.translator.trans('core.admin.permissions.view_user_list_label'), key: 'allow_sign_up',
permission: 'viewUserList', options: [
allowGuest: true, {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')}
100 ]
); })
}, 90);
items.add(
'signUp',
{
icon: 'fas fa-user-plus',
label: app.translator.trans('core.admin.permissions.sign_up_label'),
setting: () =>
SettingDropdown.component({
key: 'allow_sign_up',
options: [
{ value: '1', label: app.translator.trans('core.admin.permissions_controls.signup_open_button') },
{ value: '0', label: app.translator.trans('core.admin.permissions_controls.signup_closed_button') },
],
}),
},
90
);
items.add('viewLastSeenAt', { items.add('viewLastSeenAt', {
icon: 'far fa-clock', icon: 'far fa-clock',
@@ -164,39 +122,31 @@ export default class PermissionGrid extends Component {
startItems() { startItems() {
const items = new ItemList(); const items = new ItemList();
items.add( items.add('start', {
'start', icon: 'fas fa-edit',
{ label: app.translator.trans('core.admin.permissions.start_discussions_label'),
icon: 'fas fa-edit', permission: 'startDiscussion'
label: app.translator.trans('core.admin.permissions.start_discussions_label'), }, 100);
permission: 'startDiscussion',
},
100
);
items.add( items.add('allowRenaming', {
'allowRenaming', icon: 'fas fa-i-cursor',
{ label: app.translator.trans('core.admin.permissions.allow_renaming_label'),
icon: 'fas fa-i-cursor', setting: () => {
label: app.translator.trans('core.admin.permissions.allow_renaming_label'), const minutes = parseInt(app.data.settings.allow_renaming, 10);
setting: () => {
const minutes = parseInt(app.data.settings.allow_renaming, 10);
return SettingDropdown.component({ return SettingDropdown.component({
defaultLabel: minutes defaultLabel: minutes
? app.translator.transChoice('core.admin.permissions_controls.allow_some_minutes_button', minutes, { count: minutes }) ? app.translator.transChoice('core.admin.permissions_controls.allow_some_minutes_button', minutes, {count: minutes})
: app.translator.trans('core.admin.permissions_controls.allow_indefinitely_button'), : app.translator.trans('core.admin.permissions_controls.allow_indefinitely_button'),
key: 'allow_renaming', key: 'allow_renaming',
options: [ options: [
{ value: '-1', label: app.translator.trans('core.admin.permissions_controls.allow_indefinitely_button') }, {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: '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') }, {value: 'reply', label: app.translator.trans('core.admin.permissions_controls.allow_until_reply_button')}
], ]
}); });
}, }
}, }, 90);
90
);
return items; return items;
} }
@@ -204,39 +154,31 @@ export default class PermissionGrid extends Component {
replyItems() { replyItems() {
const items = new ItemList(); const items = new ItemList();
items.add( items.add('reply', {
'reply', icon: 'fas fa-reply',
{ label: app.translator.trans('core.admin.permissions.reply_to_discussions_label'),
icon: 'fas fa-reply', permission: 'discussion.reply'
label: app.translator.trans('core.admin.permissions.reply_to_discussions_label'), }, 100);
permission: 'discussion.reply',
},
100
);
items.add( items.add('allowPostEditing', {
'allowPostEditing', icon: 'fas fa-pencil-alt',
{ label: app.translator.trans('core.admin.permissions.allow_post_editing_label'),
icon: 'fas fa-pencil-alt', setting: () => {
label: app.translator.trans('core.admin.permissions.allow_post_editing_label'), const minutes = parseInt(app.data.settings.allow_post_editing, 10);
setting: () => {
const minutes = parseInt(app.data.settings.allow_post_editing, 10);
return SettingDropdown.component({ return SettingDropdown.component({
defaultLabel: minutes defaultLabel: minutes
? app.translator.transChoice('core.admin.permissions_controls.allow_some_minutes_button', minutes, { count: minutes }) ? app.translator.transChoice('core.admin.permissions_controls.allow_some_minutes_button', minutes, {count: minutes})
: app.translator.trans('core.admin.permissions_controls.allow_indefinitely_button'), : app.translator.trans('core.admin.permissions_controls.allow_indefinitely_button'),
key: 'allow_post_editing', key: 'allow_post_editing',
options: [ options: [
{ value: '-1', label: app.translator.trans('core.admin.permissions_controls.allow_indefinitely_button') }, {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: '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') }, {value: 'reply', label: app.translator.trans('core.admin.permissions_controls.allow_until_reply_button')}
], ]
}); });
}, }
}, }, 90);
90
);
return items; return items;
} }
@@ -244,95 +186,47 @@ export default class PermissionGrid extends Component {
moderateItems() { moderateItems() {
const items = new ItemList(); const items = new ItemList();
items.add( items.add('viewIpsPosts', {
'viewIpsPosts', icon: 'fas fa-bullseye',
{ label: app.translator.trans('core.admin.permissions.view_post_ips_label'),
icon: 'fas fa-bullseye', permission: 'discussion.viewIpsPosts'
label: app.translator.trans('core.admin.permissions.view_post_ips_label'), }, 110);
permission: 'discussion.viewIpsPosts',
},
110
);
items.add( items.add('renameDiscussions', {
'renameDiscussions', icon: 'fas fa-i-cursor',
{ label: app.translator.trans('core.admin.permissions.rename_discussions_label'),
icon: 'fas fa-i-cursor', permission: 'discussion.rename'
label: app.translator.trans('core.admin.permissions.rename_discussions_label'), }, 100);
permission: 'discussion.rename',
},
100
);
items.add( items.add('hideDiscussions', {
'hideDiscussions', icon: 'far fa-trash-alt',
{ label: app.translator.trans('core.admin.permissions.delete_discussions_label'),
icon: 'far fa-trash-alt', permission: 'discussion.hide'
label: app.translator.trans('core.admin.permissions.delete_discussions_label'), }, 90);
permission: 'discussion.hide',
},
90
);
items.add( items.add('deleteDiscussions', {
'deleteDiscussions', icon: 'fas fa-times',
{ label: app.translator.trans('core.admin.permissions.delete_discussions_forever_label'),
icon: 'fas fa-times', permission: 'discussion.delete'
label: app.translator.trans('core.admin.permissions.delete_discussions_forever_label'), }, 80);
permission: 'discussion.delete',
},
80
);
items.add( items.add('editPosts', {
'postWithoutThrottle', icon: 'fas fa-pencil-alt',
{ label: app.translator.trans('core.admin.permissions.edit_posts_label'),
icon: 'fas fa-swimmer', permission: 'discussion.editPosts'
label: app.translator.trans('core.admin.permissions.post_without_throttle_label'), }, 70);
permission: 'postWithoutThrottle',
},
70
);
items.add( items.add('hidePosts', {
'editPosts', icon: 'far fa-trash-alt',
{ label: app.translator.trans('core.admin.permissions.delete_posts_label'),
icon: 'fas fa-pencil-alt', permission: 'discussion.hidePosts'
label: app.translator.trans('core.admin.permissions.edit_posts_label'), }, 60);
permission: 'discussion.editPosts',
},
70
);
items.add( items.add('deletePosts', {
'hidePosts', icon: 'fas fa-times',
{ label: app.translator.trans('core.admin.permissions.delete_posts_forever_label'),
icon: 'far fa-trash-alt', permission: 'discussion.deletePosts'
label: app.translator.trans('core.admin.permissions.delete_posts_label'), }, 60);
permission: 'discussion.hidePosts',
},
60
);
items.add(
'deletePosts',
{
icon: 'fas fa-times',
label: app.translator.trans('core.admin.permissions.delete_posts_forever_label'),
permission: 'discussion.deletePosts',
},
60
);
items.add(
'userEdit',
{
icon: 'fas fa-user-cog',
label: app.translator.trans('core.admin.permissions.edit_users_label'),
permission: 'user.edit',
},
60
);
return items; return items;
} }
@@ -340,25 +234,21 @@ export default class PermissionGrid extends Component {
scopeItems() { scopeItems() {
const items = new ItemList(); const items = new ItemList();
items.add( items.add('global', {
'global', label: app.translator.trans('core.admin.permissions.global_heading'),
{ render: item => {
label: app.translator.trans('core.admin.permissions.global_heading'), if (item.setting) {
render: (item) => { return item.setting();
if (item.setting) { } else if (item.permission) {
return item.setting(); return PermissionDropdown.component({
} else if (item.permission) { permission: item.permission,
return PermissionDropdown.component({ allowGuest: item.allowGuest
permission: item.permission, });
allowGuest: item.allowGuest, }
});
}
return ''; return '';
}, }
}, }, 100);
100
);
return items; return items;
} }

View File

@@ -1,4 +1,4 @@
import Page from '../../common/components/Page'; import Page from './Page';
import GroupBadge from '../../common/components/GroupBadge'; import GroupBadge from '../../common/components/GroupBadge';
import EditGroupModal from './EditGroupModal'; import EditGroupModal from './EditGroupModal';
import Group from '../../common/models/Group'; import Group from '../../common/models/Group';
@@ -11,28 +11,29 @@ export default class PermissionsPage extends Page {
<div className="PermissionsPage"> <div className="PermissionsPage">
<div className="PermissionsPage-groups"> <div className="PermissionsPage-groups">
<div className="container"> <div className="container">
{app.store {app.store.all('groups')
.all('groups') .filter(group => [Group.GUEST_ID, Group.MEMBER_ID].indexOf(group.id()) === -1)
.filter((group) => [Group.GUEST_ID, Group.MEMBER_ID].indexOf(group.id()) === -1) .map(group => (
.map((group) => ( <button className="Button Group" onclick={() => app.modal.show(new EditGroupModal({group}))}>
<button className="Button Group" onclick={() => app.modal.show(EditGroupModal, { group })}>
{GroupBadge.component({ {GroupBadge.component({
group, group,
className: 'Group-icon', className: 'Group-icon',
label: null, label: null
})} })}
<span className="Group-name">{group.namePlural()}</span> <span className="Group-name">{group.namePlural()}</span>
</button> </button>
))} ))}
<button className="Button Group Group--add" onclick={() => app.modal.show(EditGroupModal)}> <button className="Button Group Group--add" onclick={() => app.modal.show(new EditGroupModal())}>
{icon('fas fa-plus', { className: 'Group-icon' })} {icon('fas fa-plus', {className: 'Group-icon'})}
<span className="Group-name">{app.translator.trans('core.admin.permissions.new_group_button')}</span> <span className="Group-name">{app.translator.trans('core.admin.permissions.new_group_button')}</span>
</button> </button>
</div> </div>
</div> </div>
<div className="PermissionsPage-permissions"> <div className="PermissionsPage-permissions">
<div className="container">{PermissionGrid.component()}</div> <div className="container">
{PermissionGrid.component()}
</div>
</div> </div>
</div> </div>
); );

View File

@@ -9,22 +9,27 @@ import ItemList from '../../common/utils/ItemList';
* avatar/name, with a dropdown of session controls. * avatar/name, with a dropdown of session controls.
*/ */
export default class SessionDropdown extends Dropdown { export default class SessionDropdown extends Dropdown {
static initAttrs(attrs) { static initProps(props) {
super.initAttrs(attrs); super.initProps(props);
attrs.className = 'SessionDropdown'; props.className = 'SessionDropdown';
attrs.buttonClassName = 'Button Button--user Button--flat'; props.buttonClassName = 'Button Button--user Button--flat';
attrs.menuClassName = 'Dropdown-menu--right'; props.menuClassName = 'Dropdown-menu--right';
} }
view(vnode) { view() {
return super.view({ ...vnode, children: this.items().toArray() }); this.props.children = this.items().toArray();
return super.view();
} }
getButtonContent() { getButtonContent() {
const user = app.session.user; const user = app.session.user;
return [avatar(user), ' ', <span className="Button-label">{username(user)}</span>]; return [
avatar(user), ' ',
<span className="Button-label">{username(user)}</span>
];
} }
/** /**
@@ -35,15 +40,12 @@ export default class SessionDropdown extends Dropdown {
items() { items() {
const items = new ItemList(); const items = new ItemList();
items.add( items.add('logOut',
'logOut', Button.component({
Button.component( icon: 'fas fa-sign-out-alt',
{ children: app.translator.trans('core.admin.header.log_out_button'),
icon: 'fas fa-sign-out-alt', onclick: app.session.logout.bind(app.session)
onclick: app.session.logout.bind(app.session), }),
},
app.translator.trans('core.admin.header.log_out_button')
),
-100 -100
); );

View File

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

View File

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

View File

@@ -20,39 +20,39 @@ export default class StatusWidget extends DashboardWidget {
} }
content() { content() {
return <ul>{listItems(this.items().toArray())}</ul>; return (
<ul>{listItems(this.items().toArray())}</ul>
);
} }
items() { items() {
const items = new ItemList(); const items = new ItemList();
items.add( items.add('tools', (
'tools',
<Dropdown <Dropdown
label={app.translator.trans('core.admin.dashboard.tools_button')} label={app.translator.trans('core.admin.dashboard.tools_button')}
icon="fas fa-cog" icon="fas fa-cog"
buttonClassName="Button" buttonClassName="Button"
menuClassName="Dropdown-menu--right" menuClassName="Dropdown-menu--right">
> <Button onclick={this.handleClearCache.bind(this)}>
<Button onclick={this.handleClearCache.bind(this)}>{app.translator.trans('core.admin.dashboard.clear_cache_button')}</Button> {app.translator.trans('core.admin.dashboard.clear_cache_button')}
</Button>
</Dropdown> </Dropdown>
); ));
items.add('version-flarum', [<strong>Flarum</strong>, <br />, app.forum.attribute('version')]); items.add('version-flarum', [<strong>Flarum</strong>, <br/>, app.forum.attribute('version')]);
items.add('version-php', [<strong>PHP</strong>, <br />, app.data.phpVersion]); items.add('version-php', [<strong>PHP</strong>, <br/>, app.data.phpVersion]);
items.add('version-mysql', [<strong>MySQL</strong>, <br />, app.data.mysqlVersion]); items.add('version-mysql', [<strong>MySQL</strong>, <br/>, app.data.mysqlVersion]);
return items; return items;
} }
handleClearCache(e) { handleClearCache(e) {
app.modal.show(LoadingModal); app.modal.show(new LoadingModal());
app app.request({
.request({ method: 'DELETE',
method: 'DELETE', url: app.forum.attribute('apiUrl') + '/cache'
url: app.forum.attribute('apiUrl') + '/cache', }).then(() => window.location.reload());
})
.then(() => window.location.reload());
} }
} }

View File

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

View File

@@ -0,0 +1,38 @@
/*
* This file is part of Flarum.
*
* (c) Toby Zerner <toby.zerner@gmail.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
import Component from '../../common/Component';
export default class DashboardWidget extends Component {
view() {
return (
<div className={"DashboardWidget "+this.className()}>
{this.content()}
</div>
);
}
/**
* Get the class name to apply to the widget.
*
* @return {String}
*/
className() {
return '';
}
/**
* Get the content of the widget.
*
* @return {VirtualElement}
*/
content() {
return [];
}
}

View File

@@ -9,6 +9,7 @@ export { app };
// Export public API // Export public API
// Export compat API // Export compat API
import compat from './compat'; import compat from './compat';

View File

@@ -10,13 +10,13 @@ import MailPage from './components/MailPage';
* *
* @param {App} app * @param {App} app
*/ */
export default function (app) { export default function(app) {
app.routes = { app.routes = {
dashboard: { path: '/', component: DashboardPage }, 'dashboard': {path: '/', component: DashboardPage.component()},
basics: { path: '/basics', component: BasicsPage }, 'basics': {path: '/basics', component: BasicsPage.component()},
permissions: { path: '/permissions', component: PermissionsPage }, 'permissions': {path: '/permissions', component: PermissionsPage.component()},
appearance: { path: '/appearance', component: AppearancePage }, 'appearance': {path: '/appearance', component: AppearancePage.component()},
extensions: { path: '/extensions', component: ExtensionsPage }, 'extensions': {path: '/extensions', component: ExtensionsPage.component()},
mail: { path: '/mail', component: MailPage }, 'mail': {path: '/mail', component: MailPage.component()}
}; };
} }

View File

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

View File

@@ -1,8 +1,7 @@
import ItemList from './utils/ItemList'; import ItemList from './utils/ItemList';
import Button from './components/Button'; import Alert from './components/Alert';
import ModalManager from './components/ModalManager'; import ModalManager from './components/ModalManager';
import AlertManager from './components/AlertManager'; import AlertManager from './components/AlertManager';
import RequestErrorModal from './components/RequestErrorModal';
import Translator from './Translator'; import Translator from './Translator';
import Store from './Store'; import Store from './Store';
import Session from './Session'; import Session from './Session';
@@ -11,7 +10,6 @@ import Drawer from './utils/Drawer';
import mapRoutes from './utils/mapRoutes'; import mapRoutes from './utils/mapRoutes';
import RequestError from './utils/RequestError'; import RequestError from './utils/RequestError';
import ScrollListener from './utils/ScrollListener'; import ScrollListener from './utils/ScrollListener';
import liveHumanTimes from './utils/liveHumanTimes';
import { extend } from './extend'; import { extend } from './extend';
import Forum from './models/Forum'; import Forum from './models/Forum';
@@ -21,9 +19,6 @@ import Post from './models/Post';
import Group from './models/Group'; import Group from './models/Group';
import Notification from './models/Notification'; import Notification from './models/Notification';
import { flattenDeep } from 'lodash-es'; import { flattenDeep } from 'lodash-es';
import PageState from './states/PageState';
import ModalManagerState from './states/ModalManagerState';
import AlertManagerState from './states/AlertManagerState';
/** /**
* The `App` class provides a container for an application, as well as various * The `App` class provides a container for an application, as well as various
@@ -89,7 +84,7 @@ export default class Application {
discussions: Discussion, discussions: Discussion,
posts: Post, posts: Post,
groups: Group, groups: Group,
notifications: Notification, notifications: Notification
}); });
/** /**
@@ -110,49 +105,13 @@ export default class Application {
booted = false; booted = false;
/** /**
* The key for an Alert that was shown as a result of an AJAX request error. * An Alert that was shown as a result of an AJAX request error. If present,
* If present, it will be dismissed on the next successful request. * it will be dismissed on the next successful request.
* *
* @type {int} * @type {null|Alert}
* @private * @private
*/ */
requestErrorAlert = null; requestError = null;
/**
* The page the app is currently on.
*
* This object holds information about the type of page we are currently
* visiting, and sometimes additional arbitrary page state that may be
* relevant to lower-level components.
*
* @type {PageState}
*/
current = new PageState(null);
/**
* The page the app was on before the current page.
*
* Once the application navigates to another page, the object previously
* assigned to this.current will be moved to this.previous, while this.current
* is re-initialized.
*
* @type {PageState}
*/
previous = new PageState(null);
/*
* An object that manages modal state.
*
* @type {ModalManagerState}
*/
modal = new ModalManagerState();
/**
* An object that manages the state of active alerts.
*
* @type {AlertManagerState}
*/
alerts = new AlertManagerState();
data; data;
@@ -165,21 +124,24 @@ export default class Application {
} }
boot() { boot() {
this.initializers.toArray().forEach((initializer) => initializer(this)); this.initializers.toArray().forEach(initializer => initializer(this));
this.store.pushPayload({ data: this.data.resources }); this.store.pushPayload({data: this.data.resources});
this.forum = this.store.getById('forums', 1); this.forum = this.store.getById('forums', 1);
this.session = new Session(this.store.getById('users', this.data.session.userId), this.data.session.csrfToken); this.session = new Session(
this.store.getById('users', this.data.session.userId),
this.data.session.csrfToken
);
this.mount(); this.mount();
} }
bootExtensions(extensions) { bootExtensions(extensions) {
Object.keys(extensions).forEach((name) => { Object.keys(extensions).forEach(name => {
const extension = extensions[name]; const extension = extensions[name];
const extenders = flattenDeep(extension.extend); const extenders = flattenDeep(extension.extend);
for (const extender of extenders) { for (const extender of extenders) {
@@ -189,34 +151,31 @@ export default class Application {
} }
mount(basePath = '') { mount(basePath = '') {
// An object with a callable view property is used in order to pass arguments to the component; see https://mithril.js.org/mount.html this.modal = m.mount(document.getElementById('modal'), <ModalManager/>);
m.mount(document.getElementById('modal'), { view: () => ModalManager.component({ state: this.modal }) }); this.alerts = m.mount(document.getElementById('alerts'), <AlertManager/>);
m.mount(document.getElementById('alerts'), { view: () => AlertManager.component({ state: this.alerts }) });
this.drawer = new Drawer(); this.drawer = new Drawer();
m.route(document.getElementById('content'), basePath + '/', mapRoutes(this.routes, basePath)); m.route(
document.getElementById('content'),
basePath + '/',
mapRoutes(this.routes, basePath)
);
// Add a class to the body which indicates that the page has been scrolled // Add a class to the body which indicates that the page has been scrolled
// down. When this happens, we'll add classes to the header and app body // down.
// which will set the navbar's position to fixed. We don't want to always new ScrollListener(top => {
// have it fixed, as that could overlap with custom headers.
const scrollListener = new ScrollListener((top) => {
const $app = $('#app'); const $app = $('#app');
const offset = $app.offset().top; const offset = $app.offset().top;
$app.toggleClass('affix', top >= offset).toggleClass('scrolled', top > offset); $app
$('.App-header').toggleClass('navbar-fixed-top', top >= offset); .toggleClass('affix', top >= offset)
}); .toggleClass('scrolled', top > offset);
}).start();
scrollListener.start();
scrollListener.update();
$(() => { $(() => {
$('body').addClass('ontouchstart' in window ? 'touch' : 'no-touch'); $('body').addClass('ontouchstart' in window ? 'touch' : 'no-touch');
}); });
liveHumanTimes();
} }
/** /**
@@ -237,16 +196,6 @@ export default class Application {
return null; return null;
} }
/**
* Determine the current screen mode, based on our media queries.
*
* @returns {String} - one of "phone", "tablet", "desktop" or "desktop-hd"
*/
screen() {
const styles = getComputedStyle(document.documentElement);
return styles.getPropertyValue('--flarum-screen');
}
/** /**
* Set the <title> of the page. * Set the <title> of the page.
* *
@@ -269,16 +218,15 @@ export default class Application {
} }
updateTitle() { updateTitle() {
const count = this.titleCount ? `(${this.titleCount}) ` : ''; document.title = (this.titleCount ? `(${this.titleCount}) ` : '') +
const pageTitleWithSeparator = this.title && m.route.get() !== this.forum.attribute('basePath') + '/' ? this.title + ' - ' : ''; (this.title ? this.title + ' - ' : '') +
const title = this.forum.attribute('title'); this.forum.attribute('title');
document.title = count + pageTitleWithSeparator + title;
} }
/** /**
* Make an AJAX request, handling any low-level errors that may occur. * Make an AJAX request, handling any low-level errors that may occur.
* *
* @see https://mithril.js.org/request.html * @see https://lhorie.github.io/mithril/mithril.request.html
* @param {Object} options * @param {Object} options
* @return {Promise} * @return {Promise}
* @public * @public
@@ -306,19 +254,17 @@ export default class Application {
// When we deserialize JSON data, if for some reason the server has provided // When we deserialize JSON data, if for some reason the server has provided
// a dud response, we don't want the application to crash. We'll show an // a dud response, we don't want the application to crash. We'll show an
// error message to the user instead. // error message to the user instead.
options.deserialize = options.deserialize || ((responseText) => responseText); options.deserialize = options.deserialize || (responseText => responseText);
options.errorHandler = options.errorHandler = options.errorHandler || (error => {
options.errorHandler || throw error;
((error) => { });
throw error;
});
// When extracting the data from the response, we can check the server // When extracting the data from the response, we can check the server
// response code and show an error message to the user if something's gone // response code and show an error message to the user if something's gone
// awry. // awry.
const original = options.extract; const original = options.extract;
options.extract = (xhr) => { options.extract = xhr => {
let responseText; let responseText;
if (original) { if (original) {
@@ -345,88 +291,58 @@ export default class Application {
} }
}; };
if (this.requestErrorAlert) this.alerts.dismiss(this.requestErrorAlert); if (this.requestError) this.alerts.dismiss(this.requestError.alert);
// Now make the request. If it's a failure, inspect the error that was // Now make the request. If it's a failure, inspect the error that was
// returned and show an alert containing its contents. // returned and show an alert containing its contents.
return m.request(options).then( const deferred = m.deferred();
(response) => response,
(error) => {
let content;
switch (error.status) { m.request(options).then(response => deferred.resolve(response), error => {
case 422: this.requestError = error;
content = error.response.errors
.map((error) => [error.detail, <br />])
.reduce((a, b) => a.concat(b), [])
.slice(0, -1);
break;
case 401: let children;
case 403:
content = app.translator.trans('core.lib.error.permission_denied_message');
break;
case 404: switch (error.status) {
case 410: case 422:
content = app.translator.trans('core.lib.error.not_found_message'); children = error.response.errors
break; .map(error => [error.detail, <br/>])
.reduce((a, b) => a.concat(b), [])
.slice(0, -1);
break;
case 429: case 401:
content = app.translator.trans('core.lib.error.rate_limit_exceeded_message'); case 403:
break; children = app.translator.trans('core.lib.error.permission_denied_message');
break;
default: case 404:
content = app.translator.trans('core.lib.error.generic_message'); case 410:
} children = app.translator.trans('core.lib.error.not_found_message');
break;
const isDebug = app.forum.attribute('debug'); case 429:
// contains a formatted errors if possible, response must be an JSON API array of errors children = app.translator.trans('core.lib.error.rate_limit_exceeded_message');
// the details property is decoded to transform escaped characters such as '\n' break;
const errors = error.response && error.response.errors;
const formattedError = Array.isArray(errors) && errors[0] && errors[0].detail && errors.map((e) => decodeURI(e.detail));
error.alert = { default:
type: 'error', children = app.translator.trans('core.lib.error.generic_message');
content,
controls: isDebug && [
<Button className="Button Button--link" onclick={this.showDebug.bind(this, error, formattedError)}>
Debug
</Button>,
],
};
try {
options.errorHandler(error);
} catch (error) {
if (isDebug && error.xhr) {
const { method, url } = error.options;
const { status = '' } = error.xhr;
console.group(`${method} ${url} ${status}`);
console.error(...(formattedError || [error]));
console.groupEnd();
}
this.requestErrorAlert = this.alerts.show(error.alert, error.alert.content);
}
return Promise.reject(error);
} }
);
}
/** error.alert = new Alert({
* @param {RequestError} error type: 'error',
* @param {string[]} [formattedError] children
* @private });
*/
showDebug(error, formattedError) {
this.alerts.dismiss(this.requestErrorAlert);
this.modal.show(RequestErrorModal, { error, formattedError }); try {
options.errorHandler(error);
} catch (error) {
this.alerts.show(error.alert);
}
deferred.reject(error);
});
return deferred.promise;
} }
/** /**
@@ -438,19 +354,9 @@ export default class Application {
* @public * @public
*/ */
route(name, params = {}) { route(name, params = {}) {
const route = this.routes[name]; const url = this.routes[name].path.replace(/:([^\/]+)/g, (m, key) => extract(params, key));
const queryString = m.route.buildQueryString(params);
if (!route) throw new Error(`Route '${name}' does not exist`); const prefix = m.route.mode === 'pathname' ? app.forum.attribute('basePath') : '';
const url = route.path.replace(/:([^\/]+)/g, (m, key) => extract(params, key));
// Remove falsy values in params to avoid having urls like '/?sort&q'
for (const key in params) {
if (params.hasOwnProperty(key) && !params[key]) delete params[key];
}
const queryString = m.buildQueryString(params);
const prefix = m.route.prefix === '' ? this.forum.attribute('basePath') : '';
return prefix + url + (queryString ? '?' + queryString : ''); return prefix + url + (queryString ? '?' + queryString : '');
} }

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

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

View File

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

View File

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

View File

@@ -88,7 +88,7 @@ export default class Model {
// relationship data object. // relationship data object.
for (const innerKey in data[key]) { for (const innerKey in data[key]) {
if (data[key][innerKey] instanceof Model) { if (data[key][innerKey] instanceof Model) {
data[key][innerKey] = { data: Model.getIdentifier(data[key][innerKey]) }; data[key][innerKey] = {data: Model.getIdentifier(data[key][innerKey])};
} }
this.data[key][innerKey] = data[key][innerKey]; this.data[key][innerKey] = data[key][innerKey];
} }
@@ -109,7 +109,7 @@ export default class Model {
* @public * @public
*/ */
pushAttributes(attributes) { pushAttributes(attributes) {
this.pushData({ attributes }); this.pushData({attributes});
} }
/** /**
@@ -125,7 +125,7 @@ export default class Model {
const data = { const data = {
type: this.data.type, type: this.data.type,
id: this.data.id, id: this.data.id,
attributes, attributes
}; };
// If a 'relationships' key exists, extract it from the attributes hash and // If a 'relationships' key exists, extract it from the attributes hash and
@@ -138,7 +138,9 @@ export default class Model {
const model = attributes.relationships[key]; const model = attributes.relationships[key];
data.relationships[key] = { data.relationships[key] = {
data: model instanceof Array ? model.map(Model.getIdentifier) : Model.getIdentifier(model), data: model instanceof Array
? model.map(Model.getIdentifier)
: Model.getIdentifier(model)
}; };
} }
@@ -152,66 +154,52 @@ export default class Model {
this.pushData(data); this.pushData(data);
const request = { data }; const request = {data};
if (options.meta) request.meta = options.meta; if (options.meta) request.meta = options.meta;
return app return app.request(Object.assign({
.request( method: this.exists ? 'PATCH' : 'POST',
Object.assign( url: app.forum.attribute('apiUrl') + this.apiEndpoint(),
{ data: request
method: this.exists ? 'PATCH' : 'POST', }, options)).then(
url: app.forum.attribute('apiUrl') + this.apiEndpoint(), // If everything went well, we'll make sure the store knows that this
body: request, // model exists now (if it didn't already), and we'll push the data that
}, // the API returned into the store.
options payload => {
) this.store.data[payload.data.type] = this.store.data[payload.data.type] || {};
) this.store.data[payload.data.type][payload.data.id] = this;
.then( return this.store.pushPayload(payload);
// If everything went well, we'll make sure the store knows that this },
// model exists now (if it didn't already), and we'll push the data that
// the API returned into the store.
(payload) => {
this.store.data[payload.data.type] = this.store.data[payload.data.type] || {};
this.store.data[payload.data.type][payload.data.id] = this;
return this.store.pushPayload(payload);
},
// If something went wrong, though... good thing we backed up our model's // If something went wrong, though... good thing we backed up our model's
// old data! We'll revert to that and let others handle the error. // old data! We'll revert to that and let others handle the error.
(response) => { response => {
this.pushData(oldData); this.pushData(oldData);
m.redraw(); m.lazyRedraw();
throw response; throw response;
} }
); );
} }
/** /**
* Send a request to delete the resource. * Send a request to delete the resource.
* *
* @param {Object} body Data to send along with the DELETE request. * @param {Object} data Data to send along with the DELETE request.
* @param {Object} [options] * @param {Object} [options]
* @return {Promise} * @return {Promise}
* @public * @public
*/ */
delete(body, options = {}) { delete(data, options = {}) {
if (!this.exists) return Promise.resolve(); if (!this.exists) return m.deferred.resolve().promise;
return app return app.request(Object.assign({
.request( method: 'DELETE',
Object.assign( url: app.forum.attribute('apiUrl') + this.apiEndpoint(),
{ data
method: 'DELETE', }, options)).then(() => {
url: app.forum.attribute('apiUrl') + this.apiEndpoint(), this.exists = false;
body, this.store.remove(this);
}, });
options
)
)
.then(() => {
this.exists = false;
this.store.remove(this);
});
} }
/** /**
@@ -237,7 +225,7 @@ export default class Model {
* @public * @public
*/ */
static attribute(name, transform) { static attribute(name, transform) {
return function () { return function() {
const value = this.data.attributes && this.data.attributes[name]; const value = this.data.attributes && this.data.attributes[name];
return transform ? transform(value) : value; return transform ? transform(value) : value;
@@ -255,7 +243,7 @@ export default class Model {
* @public * @public
*/ */
static hasOne(name) { static hasOne(name) {
return function () { return function() {
if (this.data.relationships) { if (this.data.relationships) {
const relationship = this.data.relationships[name]; const relationship = this.data.relationships[name];
@@ -279,12 +267,12 @@ export default class Model {
* @public * @public
*/ */
static hasMany(name) { static hasMany(name) {
return function () { return function() {
if (this.data.relationships) { if (this.data.relationships) {
const relationship = this.data.relationships[name]; const relationship = this.data.relationships[name];
if (relationship) { if (relationship) {
return relationship.data.map((data) => app.store.getById(data.type, data.id)); return relationship.data.map(data => app.store.getById(data.type, data.id));
} }
} }
@@ -313,7 +301,7 @@ export default class Model {
static getIdentifier(model) { static getIdentifier(model) {
return { return {
type: model.data.type, type: model.data.type,
id: model.data.id, id: model.data.id
}; };
} }
} }

View File

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

View File

@@ -34,7 +34,9 @@ export default class Store {
pushPayload(payload) { pushPayload(payload) {
if (payload.included) payload.included.map(this.pushObject.bind(this)); if (payload.included) payload.included.map(this.pushObject.bind(this));
const result = payload.data instanceof Array ? payload.data.map(this.pushObject.bind(this)) : this.pushObject(payload.data); const result = payload.data instanceof Array
? payload.data.map(this.pushObject.bind(this))
: this.pushObject(payload.data);
// Attach the original payload to the model that we give back. This is // Attach the original payload to the model that we give back. This is
// useful to consumers as it allows them to access meta information // useful to consumers as it allows them to access meta information
@@ -56,7 +58,7 @@ export default class Store {
pushObject(data) { pushObject(data) {
if (!this.models[data.type]) return null; if (!this.models[data.type]) return null;
const type = (this.data[data.type] = this.data[data.type] || {}); const type = this.data[data.type] = this.data[data.type] || {};
if (type[data.id]) { if (type[data.id]) {
type[data.id].pushData(data); type[data.id].pushData(data);
@@ -82,29 +84,22 @@ export default class Store {
* @public * @public
*/ */
find(type, id, query = {}, options = {}) { find(type, id, query = {}, options = {}) {
let params = query; let data = query;
let url = app.forum.attribute('apiUrl') + '/' + type; let url = app.forum.attribute('apiUrl') + '/' + type;
if (id instanceof Array) { if (id instanceof Array) {
url += '?filter[id]=' + id.join(','); url += '?filter[id]=' + id.join(',');
} else if (typeof id === 'object') { } else if (typeof id === 'object') {
params = id; data = id;
} else if (id) { } else if (id) {
url += '/' + id; url += '/' + id;
} }
return app return app.request(Object.assign({
.request( method: 'GET',
Object.assign( url,
{ data
method: 'GET', }, options)).then(this.pushPayload.bind(this));
url,
params,
},
options
)
)
.then(this.pushPayload.bind(this));
} }
/** /**
@@ -129,7 +124,7 @@ export default class Store {
* @public * @public
*/ */
getBy(type, key, value) { getBy(type, key, value) {
return this.all(type).filter((model) => model[key]() === value)[0]; return this.all(type).filter(model => model[key]() === value)[0];
} }
/** /**
@@ -142,7 +137,7 @@ export default class Store {
all(type) { all(type) {
const records = this.data[type]; const records = this.data[type];
return records ? Object.keys(records).map((id) => records[id]) : []; return records ? Object.keys(records).map(id => records[id]) : [];
} }
/** /**
@@ -165,6 +160,6 @@ export default class Store {
createRecord(type, data = {}) { createRecord(type, data = {}) {
data.type = data.type || type; data.type = data.type || type;
return new this.models[type](data, this); return new (this.models[type])(data, this);
} }
} }

View File

@@ -1,3 +1,4 @@
import User from './models/User';
import username from './helpers/username'; import username from './helpers/username';
import extract from './utils/extract'; import extract from './utils/extract';
@@ -66,43 +67,27 @@ export default class Translator {
const hydrated = []; const hydrated = [];
const open = [hydrated]; const open = [hydrated];
translation.forEach((part) => { translation.forEach(part => {
const match = part.match(new RegExp('{([a-z0-9_]+)}|<(/?)([a-z0-9_]+)>', 'i')); const match = part.match(new RegExp('{([a-z0-9_]+)}|<(/?)([a-z0-9_]+)>', 'i'));
if (match) { if (match) {
// Either an opening or closing tag.
if (match[1]) { if (match[1]) {
open[0].push(input[match[1]]); open[0].push(input[match[1]]);
} else if (match[3]) { } else if (match[3]) {
if (match[2]) { if (match[2]) {
// Closing tag. We start by removing all raw children (generally in the form of strings) from the temporary
// holding array, then run them through m.fragment to convert them to vnodes. Usually this will just give us a
// text vnode, but using m.fragment as opposed to an explicit conversion should be more flexible. This is necessary because
// otherwise, our generated vnode will have raw strings as its children, and mithril expects vnodes.
// Finally, we add the now-processed vnodes back onto the holding array (which is the same object in memory as the
// children array of the vnode we are currently processing), and remove the reference to the holding array so that
// further text will be added to the full set of returned elements.
const rawChildren = open[0].splice(0, open[0].length);
open[0].push(...m.fragment(rawChildren).children);
open.shift(); open.shift();
} else { } else {
// If a vnode with a matching tag was provided in the translator input, we use that. Otherwise, we create a new vnode let tag = input[match[3]] || {tag: match[3], children: []};
// with this tag, and an empty children array (since we're expecting to insert children, as that's the point of having this in translator)
let tag = input[match[3]] || { tag: match[3], children: [] };
open[0].push(tag); open[0].push(tag);
// Insert the tag's children array as the first element of open, so that text in between the opening
// and closing tags will be added to the tag's children, not to the full set of returned elements.
open.unshift(tag.children || tag); open.unshift(tag.children || tag);
} }
} }
} else { } else {
// Not an html tag, we add it to open[0], which is either the full set of returned elements (vnodes and text),
// or if an html tag is currently being processed, the children attribute of that html tag's vnode.
open[0].push(part); open[0].push(part);
} }
}); });
return hydrated.filter((part) => part); return hydrated.filter(part => part);
} }
pluralize(translation, number) { pluralize(translation, number) {
@@ -112,7 +97,7 @@ export default class Translator {
standardRules = [], standardRules = [],
explicitRules = []; explicitRules = [];
translation.split('|').forEach((part) => { translation.split('|').forEach(part => {
if (cPluralRegex.test(part)) { if (cPluralRegex.test(part)) {
const matches = part.match(cPluralRegex); const matches = part.match(cPluralRegex);
explicitRules[matches[0]] = matches[matches.length - 1]; explicitRules[matches[0]] = matches[matches.length - 1];
@@ -137,13 +122,11 @@ export default class Translator {
} }
} }
} else { } else {
var leftNumber = this.convertNumber(matches[4]); var leftNumber = this.convertNumber(matches[4]);
var rightNumber = this.convertNumber(matches[5]); var rightNumber = this.convertNumber(matches[5]);
if ( if (('[' === matches[3] ? number >= leftNumber : number > leftNumber) &&
('[' === matches[3] ? number >= leftNumber : number > leftNumber) && (']' === matches[6] ? number <= rightNumber : number < rightNumber)) {
(']' === matches[6] ? number <= rightNumber : number < rightNumber)
) {
return explicitRules[e]; return explicitRules[e];
} }
} }
@@ -240,7 +223,7 @@ export default class Translator {
case 'tr': case 'tr':
case 'ur': case 'ur':
case 'zu': case 'zu':
return number == 1 ? 0 : 1; return (number == 1) ? 0 : 1;
case 'am': case 'am':
case 'bh': case 'bh':
@@ -254,7 +237,7 @@ export default class Translator {
case 'xbr': case 'xbr':
case 'ti': case 'ti':
case 'wa': case 'wa':
return number === 0 || number == 1 ? 0 : 1; return ((number === 0) || (number == 1)) ? 0 : 1;
case 'be': case 'be':
case 'bs': case 'bs':
@@ -262,41 +245,41 @@ export default class Translator {
case 'ru': case 'ru':
case 'sr': case 'sr':
case 'uk': case 'uk':
return number % 10 == 1 && number % 100 != 11 ? 0 : number % 10 >= 2 && number % 10 <= 4 && (number % 100 < 10 || number % 100 >= 20) ? 1 : 2; return ((number % 10 == 1) && (number % 100 != 11)) ? 0 : (((number % 10 >= 2) && (number % 10 <= 4) && ((number % 100 < 10) || (number % 100 >= 20))) ? 1 : 2);
case 'cs': case 'cs':
case 'sk': case 'sk':
return number == 1 ? 0 : number >= 2 && number <= 4 ? 1 : 2; return (number == 1) ? 0 : (((number >= 2) && (number <= 4)) ? 1 : 2);
case 'ga': case 'ga':
return number == 1 ? 0 : number == 2 ? 1 : 2; return (number == 1) ? 0 : ((number == 2) ? 1 : 2);
case 'lt': case 'lt':
return number % 10 == 1 && number % 100 != 11 ? 0 : number % 10 >= 2 && (number % 100 < 10 || number % 100 >= 20) ? 1 : 2; return ((number % 10 == 1) && (number % 100 != 11)) ? 0 : (((number % 10 >= 2) && ((number % 100 < 10) || (number % 100 >= 20))) ? 1 : 2);
case 'sl': case 'sl':
return number % 100 == 1 ? 0 : number % 100 == 2 ? 1 : number % 100 == 3 || number % 100 == 4 ? 2 : 3; return (number % 100 == 1) ? 0 : ((number % 100 == 2) ? 1 : (((number % 100 == 3) || (number % 100 == 4)) ? 2 : 3));
case 'mk': case 'mk':
return number % 10 == 1 ? 0 : 1; return (number % 10 == 1) ? 0 : 1;
case 'mt': case 'mt':
return number == 1 ? 0 : number === 0 || (number % 100 > 1 && number % 100 < 11) ? 1 : number % 100 > 10 && number % 100 < 20 ? 2 : 3; return (number == 1) ? 0 : (((number === 0) || ((number % 100 > 1) && (number % 100 < 11))) ? 1 : (((number % 100 > 10) && (number % 100 < 20)) ? 2 : 3));
case 'lv': case 'lv':
return number === 0 ? 0 : number % 10 == 1 && number % 100 != 11 ? 1 : 2; return (number === 0) ? 0 : (((number % 10 == 1) && (number % 100 != 11)) ? 1 : 2);
case 'pl': case 'pl':
return number == 1 ? 0 : number % 10 >= 2 && number % 10 <= 4 && (number % 100 < 12 || number % 100 > 14) ? 1 : 2; return (number == 1) ? 0 : (((number % 10 >= 2) && (number % 10 <= 4) && ((number % 100 < 12) || (number % 100 > 14))) ? 1 : 2);
case 'cy': case 'cy':
return number == 1 ? 0 : number == 2 ? 1 : number == 8 || number == 11 ? 2 : 3; return (number == 1) ? 0 : ((number == 2) ? 1 : (((number == 8) || (number == 11)) ? 2 : 3));
case 'ro': case 'ro':
return number == 1 ? 0 : number === 0 || (number % 100 > 0 && number % 100 < 20) ? 1 : 2; return (number == 1) ? 0 : (((number === 0) || ((number % 100 > 0) && (number % 100 < 20))) ? 1 : 2);
case 'ar': case 'ar':
return number === 0 ? 0 : number == 1 ? 1 : number == 2 ? 2 : number >= 3 && number <= 10 ? 3 : number >= 11 && number <= 99 ? 4 : 5; return (number === 0) ? 0 : ((number == 1) ? 1 : ((number == 2) ? 2 : (((number >= 3) && (number <= 10)) ? 3 : (((number >= 11) && (number <= 99)) ? 4 : 5))));
default: default:
return 0; return 0;

View File

@@ -12,20 +12,15 @@ import anchorScroll from './utils/anchorScroll';
import RequestError from './utils/RequestError'; import RequestError from './utils/RequestError';
import abbreviateNumber from './utils/abbreviateNumber'; import abbreviateNumber from './utils/abbreviateNumber';
import * as string from './utils/string'; import * as string from './utils/string';
import Stream from './utils/Stream';
import SubtreeRetainer from './utils/SubtreeRetainer'; import SubtreeRetainer from './utils/SubtreeRetainer';
import setRouteWithForcedRefresh from './utils/setRouteWithForcedRefresh';
import extract from './utils/extract'; import extract from './utils/extract';
import ScrollListener from './utils/ScrollListener'; import ScrollListener from './utils/ScrollListener';
import stringToColor from './utils/stringToColor'; import stringToColor from './utils/stringToColor';
import subclassOf from './utils/subclassOf';
import SuperTextarea from './utils/SuperTextarea';
import patchMithril from './utils/patchMithril'; import patchMithril from './utils/patchMithril';
import classList from './utils/classList'; import classList from './utils/classList';
import extractText from './utils/extractText'; import extractText from './utils/extractText';
import formatNumber from './utils/formatNumber'; import formatNumber from './utils/formatNumber';
import mapRoutes from './utils/mapRoutes'; import mapRoutes from './utils/mapRoutes';
import withAttr from './utils/withAttr';
import Notification from './models/Notification'; import Notification from './models/Notification';
import User from './models/User'; import User from './models/User';
import Post from './models/Post'; import Post from './models/Post';
@@ -35,7 +30,6 @@ import Forum from './models/Forum';
import Component from './Component'; import Component from './Component';
import Translator from './Translator'; import Translator from './Translator';
import AlertManager from './components/AlertManager'; import AlertManager from './components/AlertManager';
import Page from './components/Page';
import Switch from './components/Switch'; import Switch from './components/Switch';
import Badge from './components/Badge'; import Badge from './components/Badge';
import LoadingIndicator from './components/LoadingIndicator'; import LoadingIndicator from './components/LoadingIndicator';
@@ -43,12 +37,10 @@ import Placeholder from './components/Placeholder';
import Separator from './components/Separator'; import Separator from './components/Separator';
import Dropdown from './components/Dropdown'; import Dropdown from './components/Dropdown';
import SplitDropdown from './components/SplitDropdown'; import SplitDropdown from './components/SplitDropdown';
import RequestErrorModal from './components/RequestErrorModal';
import FieldSet from './components/FieldSet'; import FieldSet from './components/FieldSet';
import Select from './components/Select'; import Select from './components/Select';
import Navigation from './components/Navigation'; import Navigation from './components/Navigation';
import Alert from './components/Alert'; import Alert from './components/Alert';
import Link from './components/Link';
import LinkButton from './components/LinkButton'; import LinkButton from './components/LinkButton';
import Checkbox from './components/Checkbox'; import Checkbox from './components/Checkbox';
import SelectDropdown from './components/SelectDropdown'; import SelectDropdown from './components/SelectDropdown';
@@ -67,13 +59,11 @@ import highlight from './helpers/highlight';
import username from './helpers/username'; import username from './helpers/username';
import userOnline from './helpers/userOnline'; import userOnline from './helpers/userOnline';
import listItems from './helpers/listItems'; import listItems from './helpers/listItems';
import Fragment from './Fragment';
import DefaultResolver from './resolvers/DefaultResolver';
export default { export default {
extend: extend, 'extend': extend,
Session: Session, 'Session': Session,
Store: Store, 'Store': Store,
'utils/evented': evented, 'utils/evented': evented,
'utils/liveHumanTimes': liveHumanTimes, 'utils/liveHumanTimes': liveHumanTimes,
'utils/ItemList': ItemList, 'utils/ItemList': ItemList,
@@ -89,27 +79,20 @@ export default {
'utils/extract': extract, 'utils/extract': extract,
'utils/ScrollListener': ScrollListener, 'utils/ScrollListener': ScrollListener,
'utils/stringToColor': stringToColor, 'utils/stringToColor': stringToColor,
'utils/Stream': Stream,
'utils/subclassOf': subclassOf,
'utils/SuperTextarea': SuperTextarea,
'utils/setRouteWithForcedRefresh': setRouteWithForcedRefresh,
'utils/patchMithril': patchMithril, 'utils/patchMithril': patchMithril,
'utils/classList': classList, 'utils/classList': classList,
'utils/extractText': extractText, 'utils/extractText': extractText,
'utils/formatNumber': formatNumber, 'utils/formatNumber': formatNumber,
'utils/mapRoutes': mapRoutes, 'utils/mapRoutes': mapRoutes,
'utils/withAttr': withAttr,
'models/Notification': Notification, 'models/Notification': Notification,
'models/User': User, 'models/User': User,
'models/Post': Post, 'models/Post': Post,
'models/Discussion': Discussion, 'models/Discussion': Discussion,
'models/Group': Group, 'models/Group': Group,
'models/Forum': Forum, 'models/Forum': Forum,
Component: Component, 'Component': Component,
Fragment: Fragment, 'Translator': Translator,
Translator: Translator,
'components/AlertManager': AlertManager, 'components/AlertManager': AlertManager,
'components/Page': Page,
'components/Switch': Switch, 'components/Switch': Switch,
'components/Badge': Badge, 'components/Badge': Badge,
'components/LoadingIndicator': LoadingIndicator, 'components/LoadingIndicator': LoadingIndicator,
@@ -117,12 +100,10 @@ export default {
'components/Separator': Separator, 'components/Separator': Separator,
'components/Dropdown': Dropdown, 'components/Dropdown': Dropdown,
'components/SplitDropdown': SplitDropdown, 'components/SplitDropdown': SplitDropdown,
'components/RequestErrorModal': RequestErrorModal,
'components/FieldSet': FieldSet, 'components/FieldSet': FieldSet,
'components/Select': Select, 'components/Select': Select,
'components/Navigation': Navigation, 'components/Navigation': Navigation,
'components/Alert': Alert, 'components/Alert': Alert,
'components/Link': Link,
'components/LinkButton': LinkButton, 'components/LinkButton': LinkButton,
'components/Checkbox': Checkbox, 'components/Checkbox': Checkbox,
'components/SelectDropdown': SelectDropdown, 'components/SelectDropdown': SelectDropdown,
@@ -130,8 +111,8 @@ export default {
'components/Button': Button, 'components/Button': Button,
'components/Modal': Modal, 'components/Modal': Modal,
'components/GroupBadge': GroupBadge, 'components/GroupBadge': GroupBadge,
Model: Model, 'Model': Model,
Application: Application, 'Application': Application,
'helpers/fullTime': fullTime, 'helpers/fullTime': fullTime,
'helpers/avatar': avatar, 'helpers/avatar': avatar,
'helpers/icon': icon, 'helpers/icon': icon,
@@ -140,6 +121,5 @@ export default {
'helpers/highlight': highlight, 'helpers/highlight': highlight,
'helpers/username': username, 'helpers/username': username,
'helpers/userOnline': userOnline, 'helpers/userOnline': userOnline,
'helpers/listItems': listItems, 'helpers/listItems': listItems
'resolvers/DefaultResolver': DefaultResolver,
}; };

View File

@@ -0,0 +1,57 @@
import Component from '../Component';
import Button from './Button';
import listItems from '../helpers/listItems';
import extract from '../utils/extract';
/**
* The `Alert` component represents an alert box, which contains a message,
* some controls, and may be dismissible.
*
* The alert may have the following special props:
*
* - `type` The type of alert this is. Will be used to give the alert a class
* name of `Alert--{type}`.
* - `controls` An array of controls to show in the alert.
* - `dismissible` Whether or not the alert can be dismissed.
* - `ondismiss` A callback to run when the alert is dismissed.
*
* All other props will be assigned as attributes on the alert element.
*/
export default class Alert extends Component {
view() {
const attrs = Object.assign({}, this.props);
const type = extract(attrs, 'type');
attrs.className = 'Alert Alert--' + type + ' ' + (attrs.className || '');
const children = extract(attrs, 'children');
const controls = extract(attrs, 'controls') || [];
// If the alert is meant to be dismissible (which is the case by default),
// then we will create a dismiss button to append as the final control in
// the alert.
const dismissible = extract(attrs, 'dismissible');
const ondismiss = extract(attrs, 'ondismiss');
const dismissControl = [];
if (dismissible || dismissible === undefined) {
dismissControl.push(
<Button
icon="fas fa-times"
className="Button Button--link Button--icon Alert-dismiss"
onclick={ondismiss}/>
);
}
return (
<div {...attrs}>
<span className="Alert-body">
{children}
</span>
<ul className="Alert-controls">
{listItems(controls.concat(dismissControl))}
</ul>
</div>
);
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -6,14 +6,10 @@
* @return {Object} * @return {Object}
*/ */
export default function fullTime(time) { export default function fullTime(time) {
const d = dayjs(time); const mo = moment(time);
const datetime = d.format(); const datetime = mo.format();
const full = d.format('LLLL'); const full = mo.format('LLLL');
return ( return <time pubdate datetime={datetime}>{full}</time>;
<time pubdate datetime={datetime}>
{full}
</time>
);
} }

View File

@@ -9,15 +9,11 @@ import humanTimeUtil from '../utils/humanTime';
* @return {Object} * @return {Object}
*/ */
export default function humanTime(time) { export default function humanTime(time) {
const d = dayjs(time); const mo = moment(time);
const datetime = d.format(); const datetime = mo.format();
const full = d.format('LLLL'); const full = mo.format('LLLL');
const ago = humanTimeUtil(time); const ago = humanTimeUtil(time);
return ( return <time pubdate datetime={datetime} title={full} data-humantime>{ago}</time>;
<time pubdate datetime={datetime} title={full} data-humantime>
{ago}
</time>
);
} }

View File

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

View File

@@ -1,13 +0,0 @@
import * as Mithril from 'mithril';
/**
* The `icon` helper displays an icon.
*
* @param fontClass The full icon class, prefix and the icons name.
* @param attrs Any other attributes to apply.
*/
export default function icon(fontClass: string, attrs: Mithril.Attributes = {}): Mithril.Vnode {
attrs.className = 'icon ' + fontClass + ' ' + (attrs.className || '');
return <i {...attrs} />;
}

View File

@@ -2,14 +2,14 @@ import Separator from '../components/Separator';
import classList from '../utils/classList'; import classList from '../utils/classList';
function isSeparator(item) { function isSeparator(item) {
return item.tag === Separator; return item && item.component === Separator;
} }
function withoutUnnecessarySeparators(items) { function withoutUnnecessarySeparators(items) {
const newItems = []; const newItems = [];
let prevItem; let prevItem;
items.filter(Boolean).forEach((item, i) => { items.forEach((item, i) => {
if (!isSeparator(item) || (prevItem && !isSeparator(prevItem) && i !== items.length - 1)) { if (!isSeparator(item) || (prevItem && !isSeparator(prevItem) && i !== items.length - 1)) {
prevItem = item; prevItem = item;
newItems.push(item); newItems.push(item);
@@ -29,28 +29,25 @@ function withoutUnnecessarySeparators(items) {
export default function listItems(items) { export default function listItems(items) {
if (!(items instanceof Array)) items = [items]; if (!(items instanceof Array)) items = [items];
return withoutUnnecessarySeparators(items).map((item) => { return withoutUnnecessarySeparators(items).map(item => {
const isListItem = item.tag && item.tag.isListItem; const isListItem = item.component && item.component.isListItem;
const active = item.tag && item.tag.isActive && item.tag.isActive(item.attrs); const active = item.component && item.component.isActive && item.component.isActive(item.props);
const className = (item.attrs && item.attrs.itemClassName) || item.itemClassName; const className = item.props ? item.props.itemClassName : item.itemClassName;
if (isListItem) { if (isListItem) {
item.attrs = item.attrs || {}; item.attrs = item.attrs || {};
item.attrs.key = item.attrs.key || item.itemName; item.attrs.key = item.attrs.key || item.itemName;
item.key = item.attrs.key;
} }
const node = isListItem ? ( return isListItem
item ? item
) : ( : <li className={classList([
<li (item.itemName ? 'item-' + item.itemName : ''),
className={classList([className, item.itemName && `item-${item.itemName}`, active && 'active'])} className,
key={(item.attrs && item.attrs.key) || item.itemName} (active ? 'active' : '')
> ])}
{item} key={item.itemName}>
</li> {item}
); </li>;
return node;
}); });
} }

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