mirror of
https://github.com/flarum/core.git
synced 2025-08-13 20:04:24 +02:00
Compare commits
150 Commits
mithril-2-
...
ds/fronten
Author | SHA1 | Date | |
---|---|---|---|
|
4c8af1cdf8 | ||
|
4a5a5a9ef0 | ||
|
1d83b740d2 | ||
|
b180cd9daf | ||
|
0302a2c5a7 | ||
|
bd2850d5ea | ||
|
c5f1e30855 | ||
|
b00eae1ce9 | ||
|
717e8aa27e | ||
|
8b4046fdd4 | ||
|
8437e44112 | ||
|
df08d174ff | ||
|
ef9612f55e | ||
|
14ea7101fc | ||
|
5e6251204b | ||
|
c1c2a15b96 | ||
|
6823cdc3b8 | ||
|
8980189917 | ||
|
ea880632e1 | ||
|
e9ad848530 | ||
|
9a3aec6079 | ||
|
77eefc85c6 | ||
|
8e9f006c3a | ||
|
b7af59fe43 | ||
|
b952f6a2bc | ||
|
2bf190ffab | ||
|
5a8eb2f978 | ||
|
7756c78805 | ||
|
a344319dde | ||
|
c6062019c5 | ||
|
532e866760 | ||
|
28f2341430 | ||
|
bfc6ed2e99 | ||
|
968ece2c61 | ||
|
d83d082c4a | ||
|
8b6f9ddbfe | ||
|
990cdbc571 | ||
|
a7937edac7 | ||
|
91522f84c5 | ||
|
2c2f6fd4ed | ||
|
81b2f4a74e | ||
|
11e373f299 | ||
|
c1a4f19399 | ||
|
d644b490cd | ||
|
5e853aa52d | ||
|
84d977f819 | ||
|
58164b680a | ||
|
860e91f317 | ||
|
be844519f2 | ||
|
458045ad33 | ||
|
99b5b5ff00 | ||
|
8a07bb68f6 | ||
|
dbc3aac14e | ||
|
37cec1487e | ||
|
39dc303b80 | ||
|
88aa9fc038 | ||
|
d73f1d8a67 | ||
|
82ef5f975c | ||
|
717442741f | ||
|
0dc846bc4a | ||
|
83d0345e93 | ||
|
8f7435f3fc | ||
|
f9cda85937 | ||
|
cfc0000df0 | ||
|
c819a8d520 | ||
|
2a6360216e | ||
|
c95f7b89bf | ||
|
afda17bc5f | ||
|
3027916d97 | ||
|
babbda044b | ||
|
66b839d241 | ||
|
5bc6e52190 | ||
|
58e096a8cc | ||
|
9b83159be5 | ||
|
e86940b6a3 | ||
|
c615fb96c9 | ||
|
0356ecf379 | ||
|
ef47e09300 | ||
|
4484f3e35f | ||
|
cc6619466e | ||
|
0a5493c631 | ||
|
93e565ccee | ||
|
c4cb731f1b | ||
|
58ccb8415a | ||
|
d29b5c7262 | ||
|
2ca078618b | ||
|
22a031a3f1 | ||
|
da31fc2619 | ||
|
c3237d4845 | ||
|
f0140c6656 | ||
|
35b91c98da | ||
|
d6b07153ec | ||
|
6a67167eed | ||
|
dcb3cc1701 | ||
|
b9583943c5 | ||
|
31cfe0f9df | ||
|
dfcc099040 | ||
|
c8e97f295d | ||
|
4910205dc7 | ||
|
8ea7f9bc17 | ||
|
3bf7f6f85b | ||
|
68c17f2c30 | ||
|
c03e0f7f75 | ||
|
bcaa6f4d8a | ||
|
fa47228b3f | ||
|
241b8cc99c | ||
|
eeae395a15 | ||
|
f24aafd47b | ||
|
2a66dc5572 | ||
|
80d8707d15 | ||
|
21d19df9bd | ||
|
7485559cbf | ||
|
4368dfcc6c | ||
|
8ba86f9c5e | ||
|
4f79a05a4b | ||
|
eae6a11719 | ||
|
f75c2cfc9c | ||
|
f22f4c02e6 | ||
|
3410bf0935 | ||
|
6656820f24 | ||
|
66745916b3 | ||
|
220a36c2e4 | ||
|
dfedd585f5 | ||
|
dd13ff4169 | ||
|
4e96900dee | ||
|
d404b11fcd | ||
|
fb50540be4 | ||
|
b2cbbd5862 | ||
|
d6a4058c28 | ||
|
be6a41ad0e | ||
|
557bb086f9 | ||
|
1f1986c527 | ||
|
6978c0aa77 | ||
|
660cd1c81e | ||
|
87792f5911 | ||
|
056e6c0fea | ||
|
0de0c83353 | ||
|
49d2539aef | ||
|
b47ba94a9b | ||
|
39c8ef4ddb | ||
|
654a0b5da1 | ||
|
2fd3aa8c71 | ||
|
48dccda707 | ||
|
b885346029 | ||
|
c037598537 | ||
|
6401e45b56 | ||
|
c6bcb79541 | ||
|
46eab64f41 | ||
|
9a5063c083 | ||
|
3c84f41070 |
@@ -15,5 +15,5 @@ indent_size = 2
|
||||
[*.{diff,md}]
|
||||
trim_trailing_whitespace = false
|
||||
|
||||
[*.{php,xml}]
|
||||
[*.{php,xml,ts,tsx}]
|
||||
indent_size = 4
|
||||
|
4
.github/workflows/lint.yml
vendored
4
.github/workflows/lint.yml
vendored
@@ -1,4 +1,4 @@
|
||||
name: Lint
|
||||
name: Lint code
|
||||
|
||||
on:
|
||||
push:
|
||||
@@ -12,7 +12,7 @@ jobs:
|
||||
prettier:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
name: JS / Prettier
|
||||
name: Lint JS code with Prettier
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@master
|
||||
|
53
CHANGELOG.md
53
CHANGELOG.md
@@ -1,58 +1,5 @@
|
||||
# Changelog
|
||||
|
||||
## [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
|
||||
|
26
FRONTEND FRAMEWORK REWRITE CHANGES.md
Normal file
26
FRONTEND FRAMEWORK REWRITE CHANGES.md
Normal file
@@ -0,0 +1,26 @@
|
||||
### Changes
|
||||
|
||||
* Mithril
|
||||
- See changes from v0.2.x @ https://mithril.js.org/migration-v02x.html
|
||||
- Kept `m.prop` and `m.withAttr`
|
||||
- Actual Promises are used now instead of `m.deferred`
|
||||
* Component
|
||||
- Use new Mithril lifecycle hooks (`component.config` is gone)
|
||||
- When implementing your own, you *must* call `super.<hook>(vnode)` to update `this.attrs`
|
||||
- `component.render` now doesn't use the current state instance
|
||||
- this is because of how Mithril v2 works
|
||||
- now calls mithril on the component class (not instance) and its props
|
||||
* Translator
|
||||
- Added `app.translator.transText`, automatically extracts text from `translator.trans` output
|
||||
* Utils
|
||||
- Changed `computed` util to require multiple keys to be passed as an array
|
||||
- `SubtreeRetainer` now has an `update` method instead of `retain`, and its output is used in `onbeforeupdate` lifecycle hook
|
||||
- `Evented` util is now a class instead of an object
|
||||
- `formatNumber` now uses `Number.prototype.toLocaleString` with the current application locale, and supports passing an options object (eg. for currency formatting - https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/NumberFormat/resolvedOptions#Description)
|
||||
* Modals
|
||||
- `app.modal.show` now takes the Modal _class_ (not instance) and optional props (`app.modal.show(ForgotPasswordModal, props)`)
|
||||
|
||||
#### Forum
|
||||
* Forum Application
|
||||
- Renamed to `Forum`
|
||||
- `app.search` is no longer global, extend using `extend`
|
@@ -37,25 +37,25 @@
|
||||
"require": {
|
||||
"php": ">=7.2",
|
||||
"axy/sourcemap": "^0.1.4",
|
||||
"components/font-awesome": "^5.14.0",
|
||||
"dflydev/fig-cookies": "^2.0.1",
|
||||
"components/font-awesome": "5.9.*",
|
||||
"dflydev/fig-cookies": "^1.0.2",
|
||||
"doctrine/dbal": "^2.7",
|
||||
"franzl/whoops-middleware": "^0.4.0",
|
||||
"illuminate/bus": "^6.0",
|
||||
"illuminate/cache": "^6.0",
|
||||
"illuminate/config": "^6.0",
|
||||
"illuminate/container": "^6.0",
|
||||
"illuminate/contracts": "^6.0",
|
||||
"illuminate/database": "^6.0",
|
||||
"illuminate/events": "^6.0",
|
||||
"illuminate/filesystem": "^6.0",
|
||||
"illuminate/hashing": "^6.0",
|
||||
"illuminate/mail": "^6.0",
|
||||
"illuminate/queue": "^6.0",
|
||||
"illuminate/session": "^6.0",
|
||||
"illuminate/support": "^6.0",
|
||||
"illuminate/validation": "^6.0",
|
||||
"illuminate/view": "^6.0",
|
||||
"illuminate/bus": "5.7.*",
|
||||
"illuminate/cache": "5.7.*",
|
||||
"illuminate/config": "5.7.*",
|
||||
"illuminate/container": "5.7.*",
|
||||
"illuminate/contracts": "5.7.*",
|
||||
"illuminate/database": "5.7.*",
|
||||
"illuminate/events": "5.7.*",
|
||||
"illuminate/filesystem": "5.7.*",
|
||||
"illuminate/hashing": "5.7.*",
|
||||
"illuminate/mail": "5.7.*",
|
||||
"illuminate/queue": "5.7.*",
|
||||
"illuminate/session": "5.7.*",
|
||||
"illuminate/support": "5.7.*",
|
||||
"illuminate/validation": "5.7.*",
|
||||
"illuminate/view": "5.7.*",
|
||||
"intervention/image": "^2.5.0",
|
||||
"laminas/laminas-diactoros": "^1.8.4",
|
||||
"laminas/laminas-httphandlerrunner": "^1.0",
|
||||
@@ -66,7 +66,6 @@
|
||||
"middlewares/base-path-router": "^0.2.1",
|
||||
"middlewares/request-handler": "^1.2",
|
||||
"monolog/monolog": "^1.16.0",
|
||||
"nesbot/carbon": "^2.0",
|
||||
"nikic/fast-route": "^0.6",
|
||||
"psr/http-message": "^1.0",
|
||||
"psr/http-server-handler": "^1.0",
|
||||
|
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"printWidth": 150,
|
||||
"singleQuote": true,
|
||||
"tabWidth": 2,
|
||||
"tabWidth": 4,
|
||||
"trailingComma": "es5"
|
||||
}
|
||||
|
11
js/admin.js
11
js/admin.js
@@ -1,11 +0,0 @@
|
||||
/*
|
||||
* This file is part of Flarum.
|
||||
*
|
||||
* (c) Toby Zerner <toby.zerner@gmail.com>
|
||||
*
|
||||
* For the full copyright and license information, please view the LICENSE
|
||||
* file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
export * from './src/common';
|
||||
export * from './src/admin';
|
2
js/admin.ts
Normal file
2
js/admin.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export * from './src/common';
|
||||
export * from './src/admin';
|
20897
js/dist/admin.js
vendored
20897
js/dist/admin.js
vendored
File diff suppressed because one or more lines are too long
2
js/dist/admin.js.map
vendored
2
js/dist/admin.js.map
vendored
File diff suppressed because one or more lines are too long
26555
js/dist/forum.js
vendored
26555
js/dist/forum.js
vendored
File diff suppressed because one or more lines are too long
2
js/dist/forum.js.map
vendored
2
js/dist/forum.js.map
vendored
File diff suppressed because one or more lines are too long
11
js/forum.js
11
js/forum.js
@@ -1,11 +0,0 @@
|
||||
/*
|
||||
* This file is part of Flarum.
|
||||
*
|
||||
* (c) Toby Zerner <toby.zerner@gmail.com>
|
||||
*
|
||||
* For the full copyright and license information, please view the LICENSE
|
||||
* file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
export * from './src/common';
|
||||
export * from './src/forum';
|
2
js/forum.ts
Normal file
2
js/forum.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export * from './src/common';
|
||||
export * from './src/forum';
|
4161
js/package-lock.json
generated
4161
js/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -1,35 +1,56 @@
|
||||
{
|
||||
"private": true,
|
||||
"name": "@flarum/core",
|
||||
"dependencies": {
|
||||
"@babel/preset-typescript": "^7.10.1",
|
||||
"@types/mithril": "^2.0.3",
|
||||
"bootstrap": "^3.4.1",
|
||||
"classnames": "^2.2.5",
|
||||
"color-thief-browser": "^2.0.2",
|
||||
"dayjs": "^1.8.28",
|
||||
"expose-loader": "^0.7.5",
|
||||
"flarum-webpack-config": "0.1.0-beta.10",
|
||||
"jquery": "^3.4.1",
|
||||
"jquery.hotkeys": "^0.1.0",
|
||||
"lodash-es": "^4.17.14",
|
||||
"m.attrs.bidi": "github:tobscure/m.attrs.bidi",
|
||||
"mithril": "^2.0.4",
|
||||
"punycode": "^2.1.1",
|
||||
"spin.js": "^3.1.0",
|
||||
"webpack": "^4.43.0",
|
||||
"webpack-cli": "^3.3.11",
|
||||
"webpack-merge": "^4.1.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"husky": "^4.2.5",
|
||||
"prettier": "2.0.2"
|
||||
},
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "webpack --mode development --watch",
|
||||
"build": "webpack --mode production",
|
||||
"format": "prettier --write src",
|
||||
"format-check": "prettier --check src"
|
||||
"format": "prettier --write src \"*.{ts,js}\"",
|
||||
"format-check": "prettier --check src \"*.{ts,js}\""
|
||||
},
|
||||
"dependencies": {
|
||||
"bootstrap": "^3.4.1",
|
||||
"classnames": "^2.2.6",
|
||||
"colorthief": "^2.3.0",
|
||||
"dayjs": "^1.8.26",
|
||||
"flarum-webpack-config": "^0.1.0-beta.10",
|
||||
"hc-sticky": "^2.2.3",
|
||||
"jump.js": "^1.0.2",
|
||||
"lodash": "^4.17.15",
|
||||
"m.attrs.bidi": "github:tobscure/m.attrs.bidi",
|
||||
"micromodal": "^0.4.6",
|
||||
"mithril": "^2.0.4",
|
||||
"mousetrap": "^1.6.5",
|
||||
"punycode": "^2.1.1",
|
||||
"spin.js": "^4.1.0",
|
||||
"tooltip.js": "^1.3.3",
|
||||
"zepto": "^1.2.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/core": "^7.9.6",
|
||||
"@babel/plugin-proposal-class-properties": "^7.8.3",
|
||||
"@babel/plugin-syntax-dynamic-import": "^7.8.3",
|
||||
"@babel/plugin-transform-object-assign": "^7.8.3",
|
||||
"@babel/plugin-transform-react-jsx": "^7.9.4",
|
||||
"@babel/plugin-transform-runtime": "^7.9.6",
|
||||
"@babel/preset-env": "^7.9.6",
|
||||
"@babel/preset-react": "^7.9.4",
|
||||
"@babel/preset-typescript": "^7.9.0",
|
||||
"@babel/runtime": "^7.9.6",
|
||||
"@types/classnames": "^2.2.10",
|
||||
"@types/mithril": "^2.0.2",
|
||||
"@types/zepto": "^1.0.30",
|
||||
"babel-loader": "^8.0.6",
|
||||
"expose-loader": "^0.7.5",
|
||||
"friendly-errors-webpack-plugin": "^1.7.0",
|
||||
"husky": "^4.2.5",
|
||||
"imports-loader": "^0.8.0",
|
||||
"prettier": "^2.0.5",
|
||||
"source-map-loader": "^0.2.4",
|
||||
"typescript": "^3.8.3",
|
||||
"webpack": "^4.43.0",
|
||||
"webpack-bundle-analyzer": "^3.7.0",
|
||||
"webpack-cli": "^3.3.11",
|
||||
"webpack-merge": "^4.2.2"
|
||||
},
|
||||
"husky": {
|
||||
"hooks": {
|
||||
|
45
js/shims.d.ts
vendored
45
js/shims.d.ts
vendored
@@ -1,48 +1,7 @@
|
||||
// Mithril
|
||||
import * as Mithril from 'mithril';
|
||||
import Stream from 'mithril/stream';
|
||||
export * from './webpack-flarum-shims';
|
||||
|
||||
// Other third-party libs
|
||||
import * as _dayjs from 'dayjs';
|
||||
import * as _$ from 'jquery';
|
||||
|
||||
// Globals from flarum/core
|
||||
import Application from './src/common/Application';
|
||||
|
||||
/**
|
||||
* Helpers that flarum/core patches into Mithril
|
||||
*/
|
||||
interface m extends Mithril.Static {
|
||||
prop: typeof Stream;
|
||||
}
|
||||
|
||||
/**
|
||||
* Export Mithril typings globally.
|
||||
*
|
||||
* This lets us use these typings without an extra import everywhere we use
|
||||
* Mithril in a TypeScript file.
|
||||
*/
|
||||
export as namespace Mithril;
|
||||
|
||||
/**
|
||||
* 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: m;
|
||||
const dayjs: typeof _dayjs;
|
||||
}
|
||||
|
||||
/**
|
||||
* All global variables owned by flarum/core.
|
||||
*/
|
||||
declare global {
|
||||
const app: Application;
|
||||
const app: Application;
|
||||
}
|
||||
|
91
js/src/admin/Admin.ts
Normal file
91
js/src/admin/Admin.ts
Normal file
@@ -0,0 +1,91 @@
|
||||
import HeaderPrimary from './components/HeaderPrimary';
|
||||
import HeaderSecondary from './components/HeaderSecondary';
|
||||
import routes from './routes';
|
||||
import Application, { ApplicationData } from '../common/Application';
|
||||
import Navigation from '../common/components/Navigation';
|
||||
import AdminNav from './components/AdminNav';
|
||||
|
||||
type Extension = {
|
||||
description: string;
|
||||
extra: object;
|
||||
icon: {
|
||||
name: string;
|
||||
};
|
||||
id: number;
|
||||
version: string;
|
||||
};
|
||||
|
||||
export type AdminData = ApplicationData & {
|
||||
mysqlVersion: string;
|
||||
phpVersion: string;
|
||||
extensions: {
|
||||
[key: string]: Extension;
|
||||
};
|
||||
permissions: {
|
||||
[key: string]: string[];
|
||||
};
|
||||
settings: {
|
||||
[key: string]: string;
|
||||
};
|
||||
};
|
||||
|
||||
export default class Admin extends Application {
|
||||
extensionSettings = {};
|
||||
|
||||
history = {
|
||||
canGoBack: () => true,
|
||||
getPrevious: () => {},
|
||||
backUrl: () => this.forum.attribute('baseUrl'),
|
||||
back: function () {
|
||||
window.location = this.backUrl();
|
||||
},
|
||||
};
|
||||
|
||||
data!: AdminData;
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
|
||||
routes(this);
|
||||
}
|
||||
|
||||
/**
|
||||
* @inheritdoc
|
||||
*/
|
||||
mount() {
|
||||
m.mount(document.getElementById('app-navigation'), new Navigation({ className: 'App-backControl', drawer: true }));
|
||||
m.mount(document.getElementById('header-navigation'), new Navigation());
|
||||
m.mount(document.getElementById('header-primary'), new HeaderPrimary());
|
||||
m.mount(document.getElementById('header-secondary'), new HeaderSecondary());
|
||||
m.mount(document.getElementById('admin-navigation'), new AdminNav());
|
||||
|
||||
if (!document.location.hash) document.location.hash = '#/';
|
||||
|
||||
m.route.prefix = '#';
|
||||
super.mount();
|
||||
|
||||
// If an extension has just been enabled, then we will run its settings
|
||||
// callback.
|
||||
const enabled = localStorage.getItem('enabledExtension');
|
||||
if (enabled && this.extensionSettings[enabled]) {
|
||||
this.extensionSettings[enabled]();
|
||||
localStorage.removeItem('enabledExtension');
|
||||
}
|
||||
}
|
||||
|
||||
getRequiredPermissions(permission) {
|
||||
const required: string[] = [];
|
||||
|
||||
if (permission === 'startDiscussion' || permission.indexOf('discussion.') === 0) {
|
||||
required.push('viewDiscussions');
|
||||
}
|
||||
if (permission === 'discussion.delete') {
|
||||
required.push('discussion.hide');
|
||||
}
|
||||
if (permission === 'discussion.deletePosts') {
|
||||
required.push('discussion.hidePosts');
|
||||
}
|
||||
|
||||
return required;
|
||||
}
|
||||
}
|
@@ -1,68 +0,0 @@
|
||||
import HeaderPrimary from './components/HeaderPrimary';
|
||||
import HeaderSecondary from './components/HeaderSecondary';
|
||||
import routes from './routes';
|
||||
import Application from '../common/Application';
|
||||
import Navigation from '../common/components/Navigation';
|
||||
import AdminNav from './components/AdminNav';
|
||||
|
||||
export default class AdminApplication extends Application {
|
||||
extensionSettings = {};
|
||||
|
||||
history = {
|
||||
canGoBack: () => true,
|
||||
getPrevious: () => {},
|
||||
backUrl: () => this.forum.attribute('baseUrl'),
|
||||
back: function () {
|
||||
window.location = this.backUrl();
|
||||
},
|
||||
};
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
|
||||
routes(this);
|
||||
}
|
||||
|
||||
/**
|
||||
* @inheritdoc
|
||||
*/
|
||||
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);
|
||||
|
||||
// Mithril does not render the home route on https://example.com/admin, so
|
||||
// we need to go to https://example.com/admin#/ explicitly.
|
||||
if (!document.location.hash) document.location.hash = '#/';
|
||||
|
||||
m.route.prefix = '#';
|
||||
|
||||
super.mount();
|
||||
|
||||
// If an extension has just been enabled, then we will run its settings
|
||||
// callback.
|
||||
const enabled = localStorage.getItem('enabledExtension');
|
||||
if (enabled && this.extensionSettings[enabled]) {
|
||||
this.extensionSettings[enabled]();
|
||||
localStorage.removeItem('enabledExtension');
|
||||
}
|
||||
}
|
||||
|
||||
getRequiredPermissions(permission) {
|
||||
const required = [];
|
||||
|
||||
if (permission === 'startDiscussion' || permission.indexOf('discussion.') === 0) {
|
||||
required.push('viewDiscussions');
|
||||
}
|
||||
if (permission === 'discussion.delete') {
|
||||
required.push('discussion.hide');
|
||||
}
|
||||
if (permission === 'discussion.deletePosts') {
|
||||
required.push('discussion.hidePosts');
|
||||
}
|
||||
|
||||
return required;
|
||||
}
|
||||
}
|
8
js/src/admin/app.ts
Normal file
8
js/src/admin/app.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import Admin from './Admin';
|
||||
|
||||
const app = new Admin();
|
||||
|
||||
// @ts-ignore
|
||||
window.app = app;
|
||||
|
||||
export default app;
|
@@ -6,6 +6,7 @@ import EditCustomFooterModal from './components/EditCustomFooterModal';
|
||||
import SessionDropdown from './components/SessionDropdown';
|
||||
import HeaderPrimary from './components/HeaderPrimary';
|
||||
import AppearancePage from './components/AppearancePage';
|
||||
import Page from './components/Page';
|
||||
import StatusWidget from './components/StatusWidget';
|
||||
import HeaderSecondary from './components/HeaderSecondary';
|
||||
import SettingsModal from './components/SettingsModal';
|
||||
@@ -26,34 +27,35 @@ import AdminNav from './components/AdminNav';
|
||||
import EditCustomCssModal from './components/EditCustomCssModal';
|
||||
import EditGroupModal from './components/EditGroupModal';
|
||||
import routes from './routes';
|
||||
import AdminApplication from './AdminApplication';
|
||||
import Admin from './Admin';
|
||||
|
||||
export default Object.assign(compat, {
|
||||
'utils/saveSettings': saveSettings,
|
||||
'components/SettingDropdown': SettingDropdown,
|
||||
'components/EditCustomFooterModal': EditCustomFooterModal,
|
||||
'components/SessionDropdown': SessionDropdown,
|
||||
'components/HeaderPrimary': HeaderPrimary,
|
||||
'components/AppearancePage': AppearancePage,
|
||||
'components/StatusWidget': StatusWidget,
|
||||
'components/HeaderSecondary': HeaderSecondary,
|
||||
'components/SettingsModal': SettingsModal,
|
||||
'components/DashboardWidget': DashboardWidget,
|
||||
'components/AddExtensionModal': AddExtensionModal,
|
||||
'components/ExtensionsPage': ExtensionsPage,
|
||||
'components/AdminLinkButton': AdminLinkButton,
|
||||
'components/PermissionGrid': PermissionGrid,
|
||||
'components/MailPage': MailPage,
|
||||
'components/UploadImageButton': UploadImageButton,
|
||||
'components/LoadingModal': LoadingModal,
|
||||
'components/DashboardPage': DashboardPage,
|
||||
'components/BasicsPage': BasicsPage,
|
||||
'components/EditCustomHeaderModal': EditCustomHeaderModal,
|
||||
'components/PermissionsPage': PermissionsPage,
|
||||
'components/PermissionDropdown': PermissionDropdown,
|
||||
'components/AdminNav': AdminNav,
|
||||
'components/EditCustomCssModal': EditCustomCssModal,
|
||||
'components/EditGroupModal': EditGroupModal,
|
||||
routes: routes,
|
||||
AdminApplication: AdminApplication,
|
||||
});
|
||||
'utils/saveSettings': saveSettings,
|
||||
'components/SettingDropdown': SettingDropdown,
|
||||
'components/EditCustomFooterModal': EditCustomFooterModal,
|
||||
'components/SessionDropdown': SessionDropdown,
|
||||
'components/HeaderPrimary': HeaderPrimary,
|
||||
'components/AppearancePage': AppearancePage,
|
||||
'components/Page': Page,
|
||||
'components/StatusWidget': StatusWidget,
|
||||
'components/HeaderSecondary': HeaderSecondary,
|
||||
'components/SettingsModal': SettingsModal,
|
||||
'components/DashboardWidget': DashboardWidget,
|
||||
'components/AddExtensionModal': AddExtensionModal,
|
||||
'components/ExtensionsPage': ExtensionsPage,
|
||||
'components/AdminLinkButton': AdminLinkButton,
|
||||
'components/PermissionGrid': PermissionGrid,
|
||||
'components/MailPage': MailPage,
|
||||
'components/UploadImageButton': UploadImageButton,
|
||||
'components/LoadingModal': LoadingModal,
|
||||
'components/DashboardPage': DashboardPage,
|
||||
'components/BasicsPage': BasicsPage,
|
||||
'components/EditCustomHeaderModal': EditCustomHeaderModal,
|
||||
'components/PermissionsPage': PermissionsPage,
|
||||
'components/PermissionDropdown': PermissionDropdown,
|
||||
'components/AdminNav': AdminNav,
|
||||
'components/EditCustomCssModal': EditCustomCssModal,
|
||||
'components/EditGroupModal': EditGroupModal,
|
||||
routes: routes,
|
||||
Admin: Admin,
|
||||
}) as any;
|
@@ -1,32 +0,0 @@
|
||||
/*
|
||||
* This file is part of Flarum.
|
||||
*
|
||||
* (c) Toby Zerner <toby.zerner@gmail.com>
|
||||
*
|
||||
* For the full copyright and license information, please view the LICENSE
|
||||
* file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
import Modal from '../../common/components/Modal';
|
||||
|
||||
export default class AddExtensionModal extends Modal {
|
||||
className() {
|
||||
return 'AddExtensionModal Modal--small';
|
||||
}
|
||||
|
||||
title() {
|
||||
return app.translator.trans('core.admin.add_extension.title');
|
||||
}
|
||||
|
||||
content() {
|
||||
return (
|
||||
<div className="Modal-body">
|
||||
<p>{app.translator.trans('core.admin.add_extension.temporary_text')}</p>
|
||||
<p>
|
||||
{app.translator.trans('core.admin.add_extension.install_text', { a: <a href="https://discuss.flarum.org/t/extensions" target="_blank" /> })}
|
||||
</p>
|
||||
<p>{app.translator.trans('core.admin.add_extension.developer_text', { a: <a href="http://flarum.org/docs/extend" target="_blank" /> })}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
30
js/src/admin/components/AddExtensionModal.tsx
Normal file
30
js/src/admin/components/AddExtensionModal.tsx
Normal file
@@ -0,0 +1,30 @@
|
||||
import app from '../app';
|
||||
import Modal from '../../common/components/Modal';
|
||||
|
||||
export default class AddExtensionModal extends Modal {
|
||||
className() {
|
||||
return 'AddExtensionModal Modal--small';
|
||||
}
|
||||
|
||||
title() {
|
||||
return app.translator.trans('core.admin.add_extension.title');
|
||||
}
|
||||
|
||||
content() {
|
||||
return (
|
||||
<div className="Modal-body">
|
||||
<p>{app.translator.trans('core.admin.add_extension.temporary_text')}</p>
|
||||
<p>
|
||||
{app.translator.trans('core.admin.add_extension.install_text', {
|
||||
a: <a href="https://discuss.flarum.org/t/extensions" target="_blank" />,
|
||||
})}
|
||||
</p>
|
||||
<p>
|
||||
{app.translator.trans('core.admin.add_extension.developer_text', {
|
||||
a: <a href="http://flarum.org/docs/extend" target="_blank" />,
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
@@ -1,16 +0,0 @@
|
||||
/*
|
||||
* This file is part of Flarum.
|
||||
*
|
||||
* (c) Toby Zerner <toby.zerner@gmail.com>
|
||||
*
|
||||
* For the full copyright and license information, please view the LICENSE
|
||||
* file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
import LinkButton from '../../common/components/LinkButton';
|
||||
|
||||
export default class AdminLinkButton extends LinkButton {
|
||||
getButtonContent(children) {
|
||||
return [...super.getButtonContent(children), <div className="AdminLinkButton-description">{this.attrs.description}</div>];
|
||||
}
|
||||
}
|
15
js/src/admin/components/AdminLinkButton.tsx
Normal file
15
js/src/admin/components/AdminLinkButton.tsx
Normal file
@@ -0,0 +1,15 @@
|
||||
import LinkButton, { LinkButtonProps } from '../../common/components/LinkButton';
|
||||
|
||||
export interface AdminLinkButtonProps extends LinkButtonProps {
|
||||
description?: string;
|
||||
}
|
||||
|
||||
export default class AdminLinkButton<T extends AdminLinkButtonProps = AdminLinkButtonProps> extends LinkButton<T> {
|
||||
getButtonContent() {
|
||||
const content = super.getButtonContent(this.props.icon, this.props.loading, this.props.children);
|
||||
|
||||
content.push(<div className="AdminLinkButton-description">{this.props.description}</div>);
|
||||
|
||||
return content;
|
||||
}
|
||||
}
|
@@ -1,106 +0,0 @@
|
||||
/*
|
||||
* This file is part of Flarum.
|
||||
*
|
||||
* (c) Toby Zerner <toby.zerner@gmail.com>
|
||||
*
|
||||
* For the full copyright and license information, please view the LICENSE
|
||||
* file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
import Component from '../../common/Component';
|
||||
import AdminLinkButton from './AdminLinkButton';
|
||||
import SelectDropdown from '../../common/components/SelectDropdown';
|
||||
import ItemList from '../../common/utils/ItemList';
|
||||
|
||||
export default class AdminNav extends Component {
|
||||
view() {
|
||||
return (
|
||||
<SelectDropdown className="AdminNav App-titleControl" buttonClassName="Button">
|
||||
{this.items().toArray()}
|
||||
</SelectDropdown>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build an item list of links to show in the admin navigation.
|
||||
*
|
||||
* @return {ItemList}
|
||||
*/
|
||||
items() {
|
||||
const items = new ItemList();
|
||||
|
||||
items.add(
|
||||
'dashboard',
|
||||
AdminLinkButton.component(
|
||||
{
|
||||
href: app.route('dashboard'),
|
||||
icon: 'far fa-chart-bar',
|
||||
description: app.translator.trans('core.admin.nav.dashboard_text'),
|
||||
},
|
||||
app.translator.trans('core.admin.nav.dashboard_button')
|
||||
)
|
||||
);
|
||||
|
||||
items.add(
|
||||
'basics',
|
||||
AdminLinkButton.component(
|
||||
{
|
||||
href: app.route('basics'),
|
||||
icon: 'fas fa-pencil-alt',
|
||||
description: app.translator.trans('core.admin.nav.basics_text'),
|
||||
},
|
||||
app.translator.trans('core.admin.nav.basics_button')
|
||||
)
|
||||
);
|
||||
|
||||
items.add(
|
||||
'mail',
|
||||
AdminLinkButton.component(
|
||||
{
|
||||
href: app.route('mail'),
|
||||
icon: 'fas fa-envelope',
|
||||
description: app.translator.trans('core.admin.nav.email_text'),
|
||||
},
|
||||
app.translator.trans('core.admin.nav.email_button')
|
||||
)
|
||||
);
|
||||
|
||||
items.add(
|
||||
'permissions',
|
||||
AdminLinkButton.component(
|
||||
{
|
||||
href: app.route('permissions'),
|
||||
icon: 'fas fa-key',
|
||||
description: app.translator.trans('core.admin.nav.permissions_text'),
|
||||
},
|
||||
app.translator.trans('core.admin.nav.permissions_button')
|
||||
)
|
||||
);
|
||||
|
||||
items.add(
|
||||
'appearance',
|
||||
AdminLinkButton.component(
|
||||
{
|
||||
href: app.route('appearance'),
|
||||
icon: 'fas fa-paint-brush',
|
||||
description: app.translator.trans('core.admin.nav.appearance_text'),
|
||||
},
|
||||
app.translator.trans('core.admin.nav.appearance_button')
|
||||
)
|
||||
);
|
||||
|
||||
items.add(
|
||||
'extensions',
|
||||
AdminLinkButton.component(
|
||||
{
|
||||
href: app.route('extensions'),
|
||||
icon: 'fas fa-puzzle-piece',
|
||||
description: app.translator.trans('core.admin.nav.extensions_text'),
|
||||
},
|
||||
app.translator.trans('core.admin.nav.extensions_button')
|
||||
)
|
||||
);
|
||||
|
||||
return items;
|
||||
}
|
||||
}
|
85
js/src/admin/components/AdminNav.tsx
Normal file
85
js/src/admin/components/AdminNav.tsx
Normal file
@@ -0,0 +1,85 @@
|
||||
import Component from '../../common/Component';
|
||||
import AdminLinkButton from './AdminLinkButton';
|
||||
import SelectDropdown from '../../common/components/SelectDropdown';
|
||||
import ItemList from '../../common/utils/ItemList';
|
||||
|
||||
export default class AdminNav<T> extends Component<T> {
|
||||
view() {
|
||||
return (
|
||||
<SelectDropdown className="AdminNav App-titleControl" buttonClassName="Button">
|
||||
{this.items().toArray()}
|
||||
</SelectDropdown>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build an item list of links to show in the admin navigation.
|
||||
*
|
||||
* @return {ItemList}
|
||||
*/
|
||||
items() {
|
||||
const items = new ItemList();
|
||||
|
||||
items.add(
|
||||
'dashboard',
|
||||
AdminLinkButton.component({
|
||||
href: app.route('dashboard'),
|
||||
icon: 'far fa-chart-bar',
|
||||
children: app.translator.trans('core.admin.nav.dashboard_button'),
|
||||
description: app.translator.trans('core.admin.nav.dashboard_text'),
|
||||
})
|
||||
);
|
||||
|
||||
items.add(
|
||||
'basics',
|
||||
AdminLinkButton.component({
|
||||
href: app.route('basics'),
|
||||
icon: 'fas fa-pencil-alt',
|
||||
children: app.translator.trans('core.admin.nav.basics_button'),
|
||||
description: app.translator.trans('core.admin.nav.basics_text'),
|
||||
})
|
||||
);
|
||||
|
||||
items.add(
|
||||
'mail',
|
||||
AdminLinkButton.component({
|
||||
href: app.route('mail'),
|
||||
icon: 'fas fa-envelope',
|
||||
children: app.translator.trans('core.admin.nav.email_button'),
|
||||
description: app.translator.trans('core.admin.nav.email_text'),
|
||||
})
|
||||
);
|
||||
|
||||
items.add(
|
||||
'permissions',
|
||||
AdminLinkButton.component({
|
||||
href: app.route('permissions'),
|
||||
icon: 'fas fa-key',
|
||||
children: app.translator.trans('core.admin.nav.permissions_button'),
|
||||
description: app.translator.trans('core.admin.nav.permissions_text'),
|
||||
})
|
||||
);
|
||||
|
||||
items.add(
|
||||
'appearance',
|
||||
AdminLinkButton.component({
|
||||
href: app.route('appearance'),
|
||||
icon: 'fas fa-paint-brush',
|
||||
children: app.translator.trans('core.admin.nav.appearance_button'),
|
||||
description: app.translator.trans('core.admin.nav.appearance_text'),
|
||||
})
|
||||
);
|
||||
|
||||
items.add(
|
||||
'extensions',
|
||||
AdminLinkButton.component({
|
||||
href: app.route('extensions'),
|
||||
icon: 'fas fa-puzzle-piece',
|
||||
children: app.translator.trans('core.admin.nav.extensions_button'),
|
||||
description: app.translator.trans('core.admin.nav.extensions_text'),
|
||||
})
|
||||
);
|
||||
|
||||
return items;
|
||||
}
|
||||
}
|
@@ -1,133 +0,0 @@
|
||||
import Page from '../../common/components/Page';
|
||||
import Button from '../../common/components/Button';
|
||||
import Switch from '../../common/components/Switch';
|
||||
import EditCustomCssModal from './EditCustomCssModal';
|
||||
import EditCustomHeaderModal from './EditCustomHeaderModal';
|
||||
import EditCustomFooterModal from './EditCustomFooterModal';
|
||||
import UploadImageButton from './UploadImageButton';
|
||||
import saveSettings from '../utils/saveSettings';
|
||||
import withAttr from '../../common/utils/withAttr';
|
||||
|
||||
export default class AppearancePage extends Page {
|
||||
oninit(vnode) {
|
||||
super.oninit(vnode);
|
||||
|
||||
this.primaryColor = m.stream(app.data.settings.theme_primary_color);
|
||||
this.secondaryColor = m.stream(app.data.settings.theme_secondary_color);
|
||||
this.darkMode = m.stream(app.data.settings.theme_dark_mode);
|
||||
this.coloredHeader = m.stream(app.data.settings.theme_colored_header);
|
||||
}
|
||||
|
||||
view() {
|
||||
return (
|
||||
<div className="AppearancePage">
|
||||
<div className="container">
|
||||
<form onsubmit={this.onsubmit.bind(this)}>
|
||||
<fieldset className="AppearancePage-colors">
|
||||
<legend>{app.translator.trans('core.admin.appearance.colors_heading')}</legend>
|
||||
<div className="helpText">{app.translator.trans('core.admin.appearance.colors_text')}</div>
|
||||
|
||||
<div className="AppearancePage-colors-input">
|
||||
<input className="FormControl" type="text" placeholder="#aaaaaa" bidi={this.primaryColor} />
|
||||
<input className="FormControl" type="text" placeholder="#aaaaaa" bidi={this.secondaryColor} />
|
||||
</div>
|
||||
|
||||
{Switch.component(
|
||||
{
|
||||
state: this.darkMode(),
|
||||
onchange: this.darkMode,
|
||||
},
|
||||
app.translator.trans('core.admin.appearance.dark_mode_label')
|
||||
)}
|
||||
|
||||
{Switch.component(
|
||||
{
|
||||
state: this.coloredHeader(),
|
||||
onchange: this.coloredHeader,
|
||||
},
|
||||
app.translator.trans('core.admin.appearance.colored_header_label')
|
||||
)}
|
||||
|
||||
{Button.component(
|
||||
{
|
||||
className: 'Button Button--primary',
|
||||
type: 'submit',
|
||||
loading: this.loading,
|
||||
},
|
||||
app.translator.trans('core.admin.appearance.submit_button')
|
||||
)}
|
||||
</fieldset>
|
||||
</form>
|
||||
|
||||
<fieldset>
|
||||
<legend>{app.translator.trans('core.admin.appearance.logo_heading')}</legend>
|
||||
<div className="helpText">{app.translator.trans('core.admin.appearance.logo_text')}</div>
|
||||
<UploadImageButton name="logo" />
|
||||
</fieldset>
|
||||
|
||||
<fieldset>
|
||||
<legend>{app.translator.trans('core.admin.appearance.favicon_heading')}</legend>
|
||||
<div className="helpText">{app.translator.trans('core.admin.appearance.favicon_text')}</div>
|
||||
<UploadImageButton name="favicon" />
|
||||
</fieldset>
|
||||
|
||||
<fieldset>
|
||||
<legend>{app.translator.trans('core.admin.appearance.custom_header_heading')}</legend>
|
||||
<div className="helpText">{app.translator.trans('core.admin.appearance.custom_header_text')}</div>
|
||||
{Button.component(
|
||||
{
|
||||
className: 'Button',
|
||||
onclick: () => app.modal.show(EditCustomHeaderModal),
|
||||
},
|
||||
app.translator.trans('core.admin.appearance.edit_header_button')
|
||||
)}
|
||||
</fieldset>
|
||||
|
||||
<fieldset>
|
||||
<legend>{app.translator.trans('core.admin.appearance.custom_footer_heading')}</legend>
|
||||
<div className="helpText">{app.translator.trans('core.admin.appearance.custom_footer_text')}</div>
|
||||
{Button.component(
|
||||
{
|
||||
className: 'Button',
|
||||
onclick: () => app.modal.show(EditCustomFooterModal),
|
||||
},
|
||||
app.translator.trans('core.admin.appearance.edit_footer_button')
|
||||
)}
|
||||
</fieldset>
|
||||
|
||||
<fieldset>
|
||||
<legend>{app.translator.trans('core.admin.appearance.custom_styles_heading')}</legend>
|
||||
<div className="helpText">{app.translator.trans('core.admin.appearance.custom_styles_text')}</div>
|
||||
{Button.component(
|
||||
{
|
||||
className: 'Button',
|
||||
onclick: () => app.modal.show(EditCustomCssModal),
|
||||
},
|
||||
app.translator.trans('core.admin.appearance.edit_css_button')
|
||||
)}
|
||||
</fieldset>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
onsubmit(e) {
|
||||
e.preventDefault();
|
||||
|
||||
const hex = /^#[0-9a-f]{3}([0-9a-f]{3})?$/i;
|
||||
|
||||
if (!hex.test(this.primaryColor()) || !hex.test(this.secondaryColor())) {
|
||||
alert(app.translator.trans('core.admin.appearance.enter_hex_message'));
|
||||
return;
|
||||
}
|
||||
|
||||
this.loading = true;
|
||||
|
||||
saveSettings({
|
||||
theme_primary_color: this.primaryColor(),
|
||||
theme_secondary_color: this.secondaryColor(),
|
||||
theme_dark_mode: this.darkMode(),
|
||||
theme_colored_header: this.coloredHeader(),
|
||||
}).then(() => window.location.reload());
|
||||
}
|
||||
}
|
131
js/src/admin/components/AppearancePage.tsx
Normal file
131
js/src/admin/components/AppearancePage.tsx
Normal file
@@ -0,0 +1,131 @@
|
||||
import app from '../app';
|
||||
|
||||
import Page from './Page';
|
||||
import Button from '../../common/components/Button';
|
||||
import Switch from '../../common/components/Switch';
|
||||
import EditCustomCssModal from './EditCustomCssModal';
|
||||
import EditCustomHeaderModal from './EditCustomHeaderModal';
|
||||
import EditCustomFooterModal from './EditCustomFooterModal';
|
||||
import UploadImageButton from './UploadImageButton';
|
||||
import saveSettings from '../utils/saveSettings';
|
||||
|
||||
export default class AppearancePage extends Page {
|
||||
loading = false;
|
||||
primaryColor = m.prop(app.data.settings.theme_primary_color);
|
||||
secondaryColor = m.prop(app.data.settings.theme_secondary_color);
|
||||
darkMode = m.prop(app.data.settings.theme_dark_mode === '1');
|
||||
coloredHeader = m.prop(app.data.settings.theme_colored_header === '1');
|
||||
|
||||
view() {
|
||||
return (
|
||||
<div className="AppearancePage">
|
||||
<div className="container">
|
||||
<form onsubmit={this.onsubmit.bind(this)}>
|
||||
<fieldset className="AppearancePage-colors">
|
||||
<legend>{app.translator.trans('core.admin.appearance.colors_heading')}</legend>
|
||||
<div className="helpText">{app.translator.trans('core.admin.appearance.colors_text')}</div>
|
||||
|
||||
<div className="AppearancePage-colors-input">
|
||||
<input
|
||||
className="FormControl"
|
||||
type="text"
|
||||
placeholder="#aaaaaa"
|
||||
value={this.primaryColor()}
|
||||
onchange={m.withAttr('value', this.primaryColor)}
|
||||
/>
|
||||
<input
|
||||
className="FormControl"
|
||||
type="text"
|
||||
placeholder="#aaaaaa"
|
||||
value={this.secondaryColor()}
|
||||
onchange={m.withAttr('value', this.secondaryColor)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{Switch.component({
|
||||
state: this.darkMode(),
|
||||
children: app.translator.trans('core.admin.appearance.dark_mode_label'),
|
||||
onchange: this.darkMode,
|
||||
})}
|
||||
|
||||
{Switch.component({
|
||||
state: this.coloredHeader(),
|
||||
children: app.translator.trans('core.admin.appearance.colored_header_label'),
|
||||
onchange: this.coloredHeader,
|
||||
})}
|
||||
|
||||
{Button.component({
|
||||
className: 'Button Button--primary',
|
||||
type: 'submit',
|
||||
children: app.translator.trans('core.admin.appearance.submit_button'),
|
||||
loading: this.loading,
|
||||
})}
|
||||
</fieldset>
|
||||
</form>
|
||||
|
||||
<fieldset>
|
||||
<legend>{app.translator.trans('core.admin.appearance.logo_heading')}</legend>
|
||||
<div className="helpText">{app.translator.trans('core.admin.appearance.logo_text')}</div>
|
||||
<UploadImageButton name="logo" />
|
||||
</fieldset>
|
||||
|
||||
<fieldset>
|
||||
<legend>{app.translator.trans('core.admin.appearance.favicon_heading')}</legend>
|
||||
<div className="helpText">{app.translator.trans('core.admin.appearance.favicon_text')}</div>
|
||||
<UploadImageButton name="favicon" />
|
||||
</fieldset>
|
||||
|
||||
<fieldset>
|
||||
<legend>{app.translator.trans('core.admin.appearance.custom_header_heading')}</legend>
|
||||
<div className="helpText">{app.translator.trans('core.admin.appearance.custom_header_text')}</div>
|
||||
{Button.component({
|
||||
className: 'Button',
|
||||
children: app.translator.trans('core.admin.appearance.edit_header_button'),
|
||||
onclick: () => app.modal.show(EditCustomHeaderModal),
|
||||
})}
|
||||
</fieldset>
|
||||
|
||||
<fieldset>
|
||||
<legend>{app.translator.trans('core.admin.appearance.custom_footer_heading')}</legend>
|
||||
<div className="helpText">{app.translator.trans('core.admin.appearance.custom_footer_text')}</div>
|
||||
{Button.component({
|
||||
className: 'Button',
|
||||
children: app.translator.trans('core.admin.appearance.edit_footer_button'),
|
||||
onclick: () => app.modal.show(EditCustomFooterModal),
|
||||
})}
|
||||
</fieldset>
|
||||
|
||||
<fieldset>
|
||||
<legend>{app.translator.trans('core.admin.appearance.custom_styles_heading')}</legend>
|
||||
<div className="helpText">{app.translator.trans('core.admin.appearance.custom_styles_text')}</div>
|
||||
{Button.component({
|
||||
className: 'Button',
|
||||
children: app.translator.trans('core.admin.appearance.edit_css_button'),
|
||||
onclick: () => app.modal.show(EditCustomCssModal),
|
||||
})}
|
||||
</fieldset>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
onsubmit(e) {
|
||||
e.preventDefault();
|
||||
|
||||
const hex = /^#[0-9a-f]{3}([0-9a-f]{3})?$/i;
|
||||
|
||||
if (!hex.test(this.primaryColor()) || !hex.test(this.secondaryColor())) {
|
||||
alert(app.translator.trans('core.admin.appearance.enter_hex_message'));
|
||||
return;
|
||||
}
|
||||
|
||||
this.loading = true;
|
||||
|
||||
saveSettings({
|
||||
theme_primary_color: this.primaryColor(),
|
||||
theme_secondary_color: this.secondaryColor(),
|
||||
theme_dark_mode: this.darkMode(),
|
||||
theme_colored_header: this.coloredHeader(),
|
||||
}).then(() => window.location.reload());
|
||||
}
|
||||
}
|
@@ -1,206 +0,0 @@
|
||||
import Page from '../../common/components/Page';
|
||||
import FieldSet from '../../common/components/FieldSet';
|
||||
import Select from '../../common/components/Select';
|
||||
import Button from '../../common/components/Button';
|
||||
import saveSettings from '../utils/saveSettings';
|
||||
import ItemList from '../../common/utils/ItemList';
|
||||
import Switch from '../../common/components/Switch';
|
||||
import withAttr from '../../common/utils/withAttr';
|
||||
|
||||
export default class BasicsPage extends Page {
|
||||
oninit(vnode) {
|
||||
super.oninit(vnode);
|
||||
|
||||
this.loading = false;
|
||||
|
||||
this.fields = [
|
||||
'forum_title',
|
||||
'forum_description',
|
||||
'default_locale',
|
||||
'show_language_selector',
|
||||
'default_route',
|
||||
'welcome_title',
|
||||
'welcome_message',
|
||||
'display_name_driver',
|
||||
];
|
||||
this.values = {};
|
||||
|
||||
const settings = app.data.settings;
|
||||
this.fields.forEach((key) => (this.values[key] = m.stream(settings[key])));
|
||||
|
||||
this.localeOptions = {};
|
||||
const locales = app.data.locales;
|
||||
for (const i in locales) {
|
||||
this.localeOptions[i] = `${locales[i]} (${i})`;
|
||||
}
|
||||
|
||||
this.displayNameOptions = {};
|
||||
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() {
|
||||
return (
|
||||
<div className="BasicsPage">
|
||||
<div className="container">
|
||||
<form onsubmit={this.onsubmit.bind(this)}>
|
||||
{FieldSet.component(
|
||||
{
|
||||
label: app.translator.trans('core.admin.basics.forum_title_heading'),
|
||||
},
|
||||
[<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} />,
|
||||
]
|
||||
)}
|
||||
|
||||
{Object.keys(this.localeOptions).length > 1
|
||||
? FieldSet.component(
|
||||
{
|
||||
label: app.translator.trans('core.admin.basics.default_language_heading'),
|
||||
},
|
||||
[
|
||||
Select.component({
|
||||
options: this.localeOptions,
|
||||
value: this.values.default_locale(),
|
||||
onchange: this.values.default_locale,
|
||||
}),
|
||||
Switch.component(
|
||||
{
|
||||
state: this.values.show_language_selector(),
|
||||
onchange: this.values.show_language_selector,
|
||||
},
|
||||
app.translator.trans('core.admin.basics.show_language_selector_label')
|
||||
),
|
||||
]
|
||||
)
|
||||
: ''}
|
||||
|
||||
{FieldSet.component(
|
||||
{
|
||||
label: app.translator.trans('core.admin.basics.home_page_heading'),
|
||||
className: 'BasicsPage-homePage',
|
||||
},
|
||||
[
|
||||
<div className="helpText">{app.translator.trans('core.admin.basics.home_page_text')}</div>,
|
||||
this.homePageItems()
|
||||
.toArray()
|
||||
.map(({ path, label }) => (
|
||||
<label className="checkbox">
|
||||
<input
|
||||
type="radio"
|
||||
name="homePage"
|
||||
value={path}
|
||||
checked={this.values.default_route() === path}
|
||||
onclick={withAttr('value', this.values.default_route)}
|
||||
/>
|
||||
{label}
|
||||
</label>
|
||||
)),
|
||||
]
|
||||
)}
|
||||
|
||||
{FieldSet.component(
|
||||
{
|
||||
label: app.translator.trans('core.admin.basics.welcome_banner_heading'),
|
||||
className: 'BasicsPage-welcomeBanner',
|
||||
},
|
||||
[
|
||||
<div className="helpText">{app.translator.trans('core.admin.basics.welcome_banner_text')}</div>,
|
||||
<div className="BasicsPage-welcomeBanner-input">
|
||||
<input className="FormControl" bidi={this.values.welcome_title} />
|
||||
<textarea className="FormControl" bidi={this.values.welcome_message} />
|
||||
</div>,
|
||||
]
|
||||
)}
|
||||
|
||||
{Object.keys(this.displayNameOptions).length > 1
|
||||
? FieldSet.component(
|
||||
{
|
||||
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(
|
||||
{
|
||||
type: 'submit',
|
||||
className: 'Button Button--primary',
|
||||
loading: this.loading,
|
||||
disabled: !this.changed(),
|
||||
},
|
||||
app.translator.trans('core.admin.basics.submit_button')
|
||||
)}
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
changed() {
|
||||
return this.fields.some((key) => this.values[key]() !== app.data.settings[key]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a list of options for the default homepage. Each option must be an
|
||||
* object with `path` and `label` properties.
|
||||
*
|
||||
* @return {ItemList}
|
||||
* @public
|
||||
*/
|
||||
homePageItems() {
|
||||
const items = new ItemList();
|
||||
|
||||
items.add('allDiscussions', {
|
||||
path: '/all',
|
||||
label: app.translator.trans('core.admin.basics.all_discussions_label'),
|
||||
});
|
||||
|
||||
return items;
|
||||
}
|
||||
|
||||
onsubmit(e) {
|
||||
e.preventDefault();
|
||||
|
||||
if (this.loading) return;
|
||||
|
||||
this.loading = true;
|
||||
app.alerts.dismiss(this.successAlert);
|
||||
|
||||
const settings = {};
|
||||
|
||||
this.fields.forEach((key) => (settings[key] = this.values[key]()));
|
||||
|
||||
saveSettings(settings)
|
||||
.then(() => {
|
||||
this.successAlert = app.alerts.show(app.translator.trans('core.admin.basics.saved_message'), {
|
||||
type: 'success',
|
||||
});
|
||||
})
|
||||
.catch(() => {})
|
||||
.then(() => {
|
||||
this.loading = false;
|
||||
m.redraw();
|
||||
});
|
||||
}
|
||||
}
|
191
js/src/admin/components/BasicsPage.tsx
Normal file
191
js/src/admin/components/BasicsPage.tsx
Normal file
@@ -0,0 +1,191 @@
|
||||
import app from '../app';
|
||||
|
||||
import Page from './Page';
|
||||
import Button from '../../common/components/Button';
|
||||
import FieldSet from '../../common/components/FieldSet';
|
||||
import Select from '../../common/components/Select';
|
||||
import Switch from '../../common/components/Switch';
|
||||
import saveSettings from '../utils/saveSettings';
|
||||
import ItemList from '../../common/utils/ItemList';
|
||||
import AlertState from '../../common/states/AlertState';
|
||||
|
||||
import Stream from 'mithril/stream';
|
||||
|
||||
export default class BasicsPage extends Page {
|
||||
loading: boolean = false;
|
||||
fields: string[] = [
|
||||
'forum_title',
|
||||
'forum_description',
|
||||
'default_locale',
|
||||
'show_language_selector',
|
||||
'default_route',
|
||||
'welcome_title',
|
||||
'welcome_message',
|
||||
];
|
||||
|
||||
values: { [key: string]: Stream<any> } = {};
|
||||
|
||||
localeOptions: object = {};
|
||||
|
||||
successAlert?: number;
|
||||
|
||||
oninit(vnode) {
|
||||
super.oninit(vnode);
|
||||
|
||||
const settings = app.data.settings;
|
||||
this.fields.forEach((key) => (this.values[key] = m.prop(settings[key])));
|
||||
|
||||
const locales = app.data.locales;
|
||||
for (const i in locales) {
|
||||
this.localeOptions[i] = `${locales[i]} (${i})`;
|
||||
}
|
||||
|
||||
if (typeof this.values.show_language_selector() !== 'number') this.values.show_language_selector(1);
|
||||
}
|
||||
|
||||
view() {
|
||||
return (
|
||||
<div className="BasicsPage">
|
||||
<div className="container">
|
||||
<form onsubmit={this.onsubmit.bind(this)}>
|
||||
{FieldSet.component({
|
||||
label: app.translator.trans('core.admin.basics.forum_title_heading'),
|
||||
children: [
|
||||
<input
|
||||
className="FormControl"
|
||||
value={this.values.forum_title()}
|
||||
oninput={m.withAttr('value', this.values.forum_title)}
|
||||
/>,
|
||||
],
|
||||
})}
|
||||
|
||||
{FieldSet.component({
|
||||
label: app.translator.trans('core.admin.basics.forum_description_heading'),
|
||||
children: [
|
||||
<div className="helpText">{app.translator.trans('core.admin.basics.forum_description_text')}</div>,
|
||||
<textarea
|
||||
className="FormControl"
|
||||
value={this.values.forum_description()}
|
||||
oninput={m.withAttr('value', this.values.forum_description)}
|
||||
/>,
|
||||
],
|
||||
})}
|
||||
|
||||
{Object.keys(this.localeOptions).length > 1
|
||||
? FieldSet.component({
|
||||
label: app.translator.trans('core.admin.basics.default_language_heading'),
|
||||
children: [
|
||||
Select.component({
|
||||
options: this.localeOptions,
|
||||
value: this.values.default_locale(),
|
||||
onchange: this.values.default_locale,
|
||||
}),
|
||||
Switch.component({
|
||||
state: this.values.show_language_selector(),
|
||||
onchange: this.values.show_language_selector,
|
||||
children: app.translator.trans('core.admin.basics.show_language_selector_label'),
|
||||
}),
|
||||
],
|
||||
})
|
||||
: ''}
|
||||
|
||||
{FieldSet.component({
|
||||
label: app.translator.trans('core.admin.basics.home_page_heading'),
|
||||
className: 'BasicsPage-homePage',
|
||||
children: [
|
||||
<div className="helpText">{app.translator.trans('core.admin.basics.home_page_text')}</div>,
|
||||
this.homePageItems()
|
||||
.toArray()
|
||||
.map(({ path, label }) => (
|
||||
<label className="checkbox">
|
||||
<input
|
||||
type="radio"
|
||||
name="homePage"
|
||||
value={path}
|
||||
checked={this.values.default_route() === path}
|
||||
onclick={m.withAttr('value', this.values.default_route)}
|
||||
/>
|
||||
{label}
|
||||
</label>
|
||||
)),
|
||||
],
|
||||
})}
|
||||
|
||||
{FieldSet.component({
|
||||
label: app.translator.trans('core.admin.basics.welcome_banner_heading'),
|
||||
className: 'BasicsPage-welcomeBanner',
|
||||
children: [
|
||||
<div className="helpText">{app.translator.trans('core.admin.basics.welcome_banner_text')}</div>,
|
||||
<div className="BasicsPage-welcomeBanner-input">
|
||||
<input
|
||||
className="FormControl"
|
||||
value={this.values.welcome_title()}
|
||||
oninput={m.withAttr('value', this.values.welcome_title)}
|
||||
/>
|
||||
<textarea
|
||||
className="FormControl"
|
||||
value={this.values.welcome_message()}
|
||||
oninput={m.withAttr('value', this.values.welcome_message)}
|
||||
/>
|
||||
</div>,
|
||||
],
|
||||
})}
|
||||
|
||||
{Button.component({
|
||||
type: 'submit',
|
||||
className: 'Button Button--primary',
|
||||
children: app.translator.trans('core.admin.basics.submit_button'),
|
||||
loading: this.loading,
|
||||
disabled: !this.changed(),
|
||||
})}
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
changed() {
|
||||
return this.fields.some((key) => this.values[key]() !== app.data.settings[key]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a list of options for the default homepage. Each option must be an
|
||||
* object with `path` and `label` properties.
|
||||
*
|
||||
* @return {ItemList}
|
||||
* @public
|
||||
*/
|
||||
homePageItems() {
|
||||
const items = new ItemList();
|
||||
|
||||
items.add('allDiscussions', {
|
||||
path: '/all',
|
||||
label: app.translator.trans('core.admin.basics.all_discussions_label'),
|
||||
});
|
||||
|
||||
return items;
|
||||
}
|
||||
|
||||
onsubmit(e) {
|
||||
e.preventDefault();
|
||||
|
||||
if (this.loading) return;
|
||||
|
||||
this.loading = true;
|
||||
app.alerts.dismiss(this.successAlert);
|
||||
|
||||
const settings = {};
|
||||
|
||||
this.fields.forEach((key) => (settings[key] = this.values[key]()));
|
||||
|
||||
saveSettings(settings)
|
||||
.then(() => {
|
||||
this.successAlert = app.alerts.show({ type: 'success', children: app.translator.trans('core.admin.basics.saved_message') });
|
||||
})
|
||||
.catch(() => {})
|
||||
.then(() => {
|
||||
this.loading = false;
|
||||
m.redraw();
|
||||
});
|
||||
}
|
||||
}
|
@@ -1,16 +0,0 @@
|
||||
import Page from '../../common/components/Page';
|
||||
import StatusWidget from './StatusWidget';
|
||||
|
||||
export default class DashboardPage extends Page {
|
||||
view() {
|
||||
return (
|
||||
<div className="DashboardPage">
|
||||
<div className="container">{this.availableWidgets()}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
availableWidgets() {
|
||||
return [<StatusWidget />];
|
||||
}
|
||||
}
|
16
js/src/admin/components/DashboardPage.tsx
Normal file
16
js/src/admin/components/DashboardPage.tsx
Normal file
@@ -0,0 +1,16 @@
|
||||
import Page from './Page';
|
||||
import StatusWidget from './StatusWidget';
|
||||
|
||||
export default class DashboardPage extends Page {
|
||||
view() {
|
||||
return (
|
||||
<div className="DashboardPage">
|
||||
<div className="container">{this.availableWidgets()}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
availableWidgets() {
|
||||
return [<StatusWidget />];
|
||||
}
|
||||
}
|
@@ -1,25 +0,0 @@
|
||||
import Component from '../../common/Component';
|
||||
|
||||
export default class DashboardWidget extends Component {
|
||||
view() {
|
||||
return <div className={'DashboardWidget Widget ' + 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 [];
|
||||
}
|
||||
}
|
23
js/src/admin/components/DashboardWidget.tsx
Normal file
23
js/src/admin/components/DashboardWidget.tsx
Normal file
@@ -0,0 +1,23 @@
|
||||
import Component from '../../common/Component';
|
||||
|
||||
export default abstract 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}
|
||||
*/
|
||||
abstract content(): JSX.Element;
|
||||
}
|
@@ -1,28 +0,0 @@
|
||||
import SettingsModal from './SettingsModal';
|
||||
|
||||
export default class EditCustomCssModal extends SettingsModal {
|
||||
className() {
|
||||
return 'EditCustomCssModal Modal--large';
|
||||
}
|
||||
|
||||
title() {
|
||||
return app.translator.trans('core.admin.edit_css.title');
|
||||
}
|
||||
|
||||
form() {
|
||||
return [
|
||||
<p>
|
||||
{app.translator.trans('core.admin.edit_css.customize_text', {
|
||||
a: <a href="https://github.com/flarum/core/tree/master/less" target="_blank" />,
|
||||
})}
|
||||
</p>,
|
||||
<div className="Form-group">
|
||||
<textarea className="FormControl" rows="30" bidi={this.setting('custom_less')} />
|
||||
</div>,
|
||||
];
|
||||
}
|
||||
|
||||
onsaved() {
|
||||
window.location.reload();
|
||||
}
|
||||
}
|
28
js/src/admin/components/EditCustomCssModal.tsx
Normal file
28
js/src/admin/components/EditCustomCssModal.tsx
Normal file
@@ -0,0 +1,28 @@
|
||||
import SettingsModal from './SettingsModal';
|
||||
|
||||
export default class EditCustomCssModal extends SettingsModal {
|
||||
className() {
|
||||
return 'EditCustomCssModal Modal--large';
|
||||
}
|
||||
|
||||
title() {
|
||||
return app.translator.trans('core.admin.edit_css.title');
|
||||
}
|
||||
|
||||
form() {
|
||||
return [
|
||||
<p>
|
||||
{app.translator.trans('core.admin.edit_css.customize_text', {
|
||||
a: <a href="https://github.com/flarum/core/tree/master/less" target="_blank" />,
|
||||
})}
|
||||
</p>,
|
||||
<div className="Form-group">
|
||||
<textarea className="FormControl" rows="30" bidi={this.setting('custom_less')} />
|
||||
</div>,
|
||||
];
|
||||
}
|
||||
|
||||
onsaved() {
|
||||
window.location.reload();
|
||||
}
|
||||
}
|
@@ -1,24 +0,0 @@
|
||||
import SettingsModal from './SettingsModal';
|
||||
|
||||
export default class EditCustomFooterModal extends SettingsModal {
|
||||
className() {
|
||||
return 'EditCustomFooterModal Modal--large';
|
||||
}
|
||||
|
||||
title() {
|
||||
return app.translator.trans('core.admin.edit_footer.title');
|
||||
}
|
||||
|
||||
form() {
|
||||
return [
|
||||
<p>{app.translator.trans('core.admin.edit_footer.customize_text')}</p>,
|
||||
<div className="Form-group">
|
||||
<textarea className="FormControl" rows="30" bidi={this.setting('custom_footer')} />
|
||||
</div>,
|
||||
];
|
||||
}
|
||||
|
||||
onsaved() {
|
||||
window.location.reload();
|
||||
}
|
||||
}
|
24
js/src/admin/components/EditCustomFooterModal.tsx
Normal file
24
js/src/admin/components/EditCustomFooterModal.tsx
Normal file
@@ -0,0 +1,24 @@
|
||||
import SettingsModal from './SettingsModal';
|
||||
|
||||
export default class EditCustomFooterModal extends SettingsModal {
|
||||
className() {
|
||||
return 'EditCustomFooterModal Modal--large';
|
||||
}
|
||||
|
||||
title() {
|
||||
return app.translator.trans('core.admin.edit_footer.title');
|
||||
}
|
||||
|
||||
form() {
|
||||
return [
|
||||
<p>{app.translator.trans('core.admin.edit_footer.customize_text')}</p>,
|
||||
<div className="Form-group">
|
||||
<textarea className="FormControl" rows="30" bidi={this.setting('custom_footer')} />
|
||||
</div>,
|
||||
];
|
||||
}
|
||||
|
||||
onsaved() {
|
||||
window.location.reload();
|
||||
}
|
||||
}
|
@@ -1,24 +0,0 @@
|
||||
import SettingsModal from './SettingsModal';
|
||||
|
||||
export default class EditCustomHeaderModal extends SettingsModal {
|
||||
className() {
|
||||
return 'EditCustomHeaderModal Modal--large';
|
||||
}
|
||||
|
||||
title() {
|
||||
return app.translator.trans('core.admin.edit_header.title');
|
||||
}
|
||||
|
||||
form() {
|
||||
return [
|
||||
<p>{app.translator.trans('core.admin.edit_header.customize_text')}</p>,
|
||||
<div className="Form-group">
|
||||
<textarea className="FormControl" rows="30" bidi={this.setting('custom_header')} />
|
||||
</div>,
|
||||
];
|
||||
}
|
||||
|
||||
onsaved() {
|
||||
window.location.reload();
|
||||
}
|
||||
}
|
24
js/src/admin/components/EditCustomHeaderModal.tsx
Normal file
24
js/src/admin/components/EditCustomHeaderModal.tsx
Normal file
@@ -0,0 +1,24 @@
|
||||
import SettingsModal from './SettingsModal';
|
||||
|
||||
export default class EditCustomHeaderModal extends SettingsModal {
|
||||
className() {
|
||||
return 'EditCustomHeaderModal Modal--large';
|
||||
}
|
||||
|
||||
title() {
|
||||
return app.translator.trans('core.admin.edit_header.title');
|
||||
}
|
||||
|
||||
form() {
|
||||
return [
|
||||
<p>{app.translator.trans('core.admin.edit_header.customize_text')}</p>,
|
||||
<div className="Form-group">
|
||||
<textarea className="FormControl" rows="30" bidi={this.setting('custom_header')} />
|
||||
</div>,
|
||||
];
|
||||
}
|
||||
|
||||
onsaved() {
|
||||
window.location.reload();
|
||||
}
|
||||
}
|
@@ -1,156 +0,0 @@
|
||||
import Modal from '../../common/components/Modal';
|
||||
import Button from '../../common/components/Button';
|
||||
import Badge from '../../common/components/Badge';
|
||||
import Group from '../../common/models/Group';
|
||||
import ItemList from '../../common/utils/ItemList';
|
||||
import Switch from '../../common/components/Switch';
|
||||
import withAttr from '../../common/utils/withAttr';
|
||||
|
||||
/**
|
||||
* The `EditGroupModal` component shows a modal dialog which allows the user
|
||||
* to create or edit a group.
|
||||
*/
|
||||
export default class EditGroupModal extends Modal {
|
||||
oninit(vnode) {
|
||||
super.oninit(vnode);
|
||||
|
||||
this.group = this.attrs.group || app.store.createRecord('groups');
|
||||
|
||||
this.nameSingular = m.stream(this.group.nameSingular() || '');
|
||||
this.namePlural = m.stream(this.group.namePlural() || '');
|
||||
this.icon = m.stream(this.group.icon() || '');
|
||||
this.color = m.stream(this.group.color() || '');
|
||||
this.isHidden = m.stream(this.group.isHidden() || false);
|
||||
}
|
||||
|
||||
className() {
|
||||
return 'EditGroupModal Modal--small';
|
||||
}
|
||||
|
||||
title() {
|
||||
return [
|
||||
this.color() || this.icon()
|
||||
? Badge.component({
|
||||
icon: this.icon(),
|
||||
style: { backgroundColor: this.color() },
|
||||
})
|
||||
: '',
|
||||
' ',
|
||||
this.namePlural() || app.translator.trans('core.admin.edit_group.title'),
|
||||
];
|
||||
}
|
||||
|
||||
content() {
|
||||
return (
|
||||
<div className="Modal-body">
|
||||
<div className="Form">{this.fields().toArray()}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
fields() {
|
||||
const items = new ItemList();
|
||||
|
||||
items.add(
|
||||
'name',
|
||||
<div className="Form-group">
|
||||
<label>{app.translator.trans('core.admin.edit_group.name_label')}</label>
|
||||
<div className="EditGroupModal-name-input">
|
||||
<input className="FormControl" placeholder={app.translator.trans('core.admin.edit_group.singular_placeholder')} bidi={this.nameSingular} />
|
||||
<input className="FormControl" placeholder={app.translator.trans('core.admin.edit_group.plural_placeholder')} bidi={this.namePlural} />
|
||||
</div>
|
||||
</div>,
|
||||
30
|
||||
);
|
||||
|
||||
items.add(
|
||||
'color',
|
||||
<div className="Form-group">
|
||||
<label>{app.translator.trans('core.admin.edit_group.color_label')}</label>
|
||||
<input className="FormControl" placeholder="#aaaaaa" bidi={this.color} />
|
||||
</div>,
|
||||
20
|
||||
);
|
||||
|
||||
items.add(
|
||||
'icon',
|
||||
<div className="Form-group">
|
||||
<label>{app.translator.trans('core.admin.edit_group.icon_label')}</label>
|
||||
<div className="helpText">
|
||||
{app.translator.trans('core.admin.edit_group.icon_text', { a: <a href="https://fontawesome.com/icons?m=free" tabindex="-1" /> })}
|
||||
</div>
|
||||
<input className="FormControl" placeholder="fas fa-bolt" bidi={this.icon} />
|
||||
</div>,
|
||||
10
|
||||
);
|
||||
|
||||
items.add(
|
||||
'hidden',
|
||||
<div className="Form-group">
|
||||
{Switch.component(
|
||||
{
|
||||
state: !!Number(this.isHidden()),
|
||||
onchange: this.isHidden,
|
||||
},
|
||||
app.translator.trans('core.admin.edit_group.hide_label')
|
||||
)}
|
||||
</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;
|
||||
}
|
||||
|
||||
submitData() {
|
||||
return {
|
||||
nameSingular: this.nameSingular(),
|
||||
namePlural: this.namePlural(),
|
||||
color: this.color(),
|
||||
icon: this.icon(),
|
||||
isHidden: this.isHidden(),
|
||||
};
|
||||
}
|
||||
|
||||
onsubmit(e) {
|
||||
e.preventDefault();
|
||||
|
||||
this.loading = true;
|
||||
|
||||
this.group
|
||||
.save(this.submitData(), { errorHandler: this.onerror.bind(this) })
|
||||
.then(this.hide.bind(this))
|
||||
.catch(() => {
|
||||
this.loading = false;
|
||||
m.redraw();
|
||||
});
|
||||
}
|
||||
|
||||
deleteGroup() {
|
||||
if (confirm(app.translator.trans('core.admin.edit_group.delete_confirmation'))) {
|
||||
this.group.delete().then(() => m.redraw());
|
||||
this.hide();
|
||||
}
|
||||
}
|
||||
}
|
157
js/src/admin/components/EditGroupModal.tsx
Normal file
157
js/src/admin/components/EditGroupModal.tsx
Normal file
@@ -0,0 +1,157 @@
|
||||
import app from '../app';
|
||||
import { ComponentProps } from '../../common/Component';
|
||||
import Modal from '../../common/components/Modal';
|
||||
import Button from '../../common/components/Button';
|
||||
import Badge from '../../common/components/Badge';
|
||||
import Group from '../../common/models/Group';
|
||||
import ItemList from '../../common/utils/ItemList';
|
||||
|
||||
import Stream from 'mithril/stream';
|
||||
|
||||
/**
|
||||
* The `EditGroupModal` component shows a modal dialog which allows the user
|
||||
* to create or edit a group.
|
||||
*/
|
||||
export default class EditGroupModal extends Modal<ComponentProps> {
|
||||
group: Group;
|
||||
|
||||
nameSingular: Stream<string>;
|
||||
namePlural: Stream<string>;
|
||||
icon: Stream<string>;
|
||||
color: Stream<string>;
|
||||
|
||||
oninit(vnode) {
|
||||
super.oninit(vnode);
|
||||
|
||||
this.group = this.props.group || app.store.createRecord('groups');
|
||||
|
||||
this.nameSingular = m.prop(this.group.nameSingular() || '');
|
||||
this.namePlural = m.prop(this.group.namePlural() || '');
|
||||
this.icon = m.prop(this.group.icon() || '');
|
||||
this.color = m.prop(this.group.color() || '');
|
||||
}
|
||||
|
||||
className() {
|
||||
return 'EditGroupModal Modal--small';
|
||||
}
|
||||
|
||||
title() {
|
||||
return [
|
||||
this.color() || this.icon()
|
||||
? Badge.component({
|
||||
icon: this.icon(),
|
||||
style: { backgroundColor: this.color() },
|
||||
})
|
||||
: '',
|
||||
' ',
|
||||
this.namePlural() || app.translator.trans('core.admin.edit_group.title'),
|
||||
];
|
||||
}
|
||||
|
||||
content() {
|
||||
return (
|
||||
<div className="Modal-body">
|
||||
<div className="Form">{this.fields().toArray()}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
fields() {
|
||||
const items = new ItemList();
|
||||
|
||||
items.add(
|
||||
'name',
|
||||
<div className="Form-group">
|
||||
<label>{app.translator.trans('core.admin.edit_group.name_label')}</label>
|
||||
<div className="EditGroupModal-name-input">
|
||||
<input
|
||||
className="FormControl"
|
||||
placeholder={app.translator.transText('core.admin.edit_group.singular_placeholder')}
|
||||
value={this.nameSingular()}
|
||||
oninput={m.withAttr('value', this.nameSingular)}
|
||||
/>
|
||||
<input
|
||||
className="FormControl"
|
||||
placeholder={app.translator.transText('core.admin.edit_group.plural_placeholder')}
|
||||
value={this.namePlural()}
|
||||
oninput={m.withAttr('value', this.namePlural)}
|
||||
/>
|
||||
</div>
|
||||
</div>,
|
||||
30
|
||||
);
|
||||
|
||||
items.add(
|
||||
'color',
|
||||
<div className="Form-group">
|
||||
<label>{app.translator.trans('core.admin.edit_group.color_label')}</label>
|
||||
<input className="FormControl" placeholder="#aaaaaa" value={this.color()} oninput={m.withAttr('value', this.color)} />
|
||||
</div>,
|
||||
20
|
||||
);
|
||||
|
||||
items.add(
|
||||
'icon',
|
||||
<div className="Form-group">
|
||||
<label>{app.translator.trans('core.admin.edit_group.icon_label')}</label>
|
||||
<div className="helpText">
|
||||
{app.translator.trans('core.admin.edit_group.icon_text', { a: <a href="https://fontawesome.com/icons?m=free" tabindex="-1" /> })}
|
||||
</div>
|
||||
<input className="FormControl" placeholder="fas fa-bolt" value={this.icon()} oninput={m.withAttr('value', this.icon)} />
|
||||
</div>,
|
||||
10
|
||||
);
|
||||
|
||||
items.add(
|
||||
'submit',
|
||||
<div className="Form-group">
|
||||
{Button.component({
|
||||
type: 'submit',
|
||||
className: 'Button Button--primary EditGroupModal-save',
|
||||
loading: this.loading,
|
||||
children: app.translator.trans('core.admin.edit_group.submit_button'),
|
||||
})}
|
||||
{this.group.exists && this.group.id() !== Group.ADMINISTRATOR_ID ? (
|
||||
<button type="button" className="Button EditGroupModal-delete" onclick={this.deleteGroup.bind(this)}>
|
||||
{app.translator.trans('core.admin.edit_group.delete_button')}
|
||||
</button>
|
||||
) : (
|
||||
''
|
||||
)}
|
||||
</div>,
|
||||
-10
|
||||
);
|
||||
|
||||
return items;
|
||||
}
|
||||
|
||||
submitData() {
|
||||
return {
|
||||
nameSingular: this.nameSingular(),
|
||||
namePlural: this.namePlural(),
|
||||
color: this.color(),
|
||||
icon: this.icon(),
|
||||
};
|
||||
}
|
||||
|
||||
onsubmit(e) {
|
||||
e.preventDefault();
|
||||
|
||||
this.loading = true;
|
||||
|
||||
this.group
|
||||
.save(this.submitData(), { errorHandler: this.onerror.bind(this) })
|
||||
.then(this.hide.bind(this))
|
||||
.catch(() => {
|
||||
this.loading = false;
|
||||
m.redraw();
|
||||
});
|
||||
}
|
||||
|
||||
deleteGroup() {
|
||||
if (confirm(app.translator.transText('core.admin.edit_group.delete_confirmation'))) {
|
||||
this.group.delete().then(() => m.redraw());
|
||||
this.hide();
|
||||
}
|
||||
}
|
||||
}
|
@@ -1,134 +0,0 @@
|
||||
import Page from '../../common/components/Page';
|
||||
import Button from '../../common/components/Button';
|
||||
import Dropdown from '../../common/components/Dropdown';
|
||||
import AddExtensionModal from './AddExtensionModal';
|
||||
import LoadingModal from './LoadingModal';
|
||||
import ItemList from '../../common/utils/ItemList';
|
||||
import icon from '../../common/helpers/icon';
|
||||
|
||||
export default class ExtensionsPage extends Page {
|
||||
view() {
|
||||
return (
|
||||
<div className="ExtensionsPage">
|
||||
<div className="ExtensionsPage-header">
|
||||
<div className="container">
|
||||
{Button.component(
|
||||
{
|
||||
icon: 'fas fa-plus',
|
||||
className: 'Button Button--primary',
|
||||
onclick: () => app.modal.show(AddExtensionModal),
|
||||
},
|
||||
app.translator.trans('core.admin.extensions.add_button')
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="ExtensionsPage-list">
|
||||
<div className="container">
|
||||
<ul className="ExtensionList">
|
||||
{Object.keys(app.data.extensions).map((id) => {
|
||||
const extension = app.data.extensions[id];
|
||||
const controls = this.controlItems(extension.id).toArray();
|
||||
|
||||
return (
|
||||
<li className={'ExtensionListItem ' + (!this.isEnabled(extension.id) ? 'disabled' : '')}>
|
||||
<div className="ExtensionListItem-content">
|
||||
<span className="ExtensionListItem-icon ExtensionIcon" style={extension.icon}>
|
||||
{extension.icon ? icon(extension.icon.name) : ''}
|
||||
</span>
|
||||
{controls.length ? (
|
||||
<Dropdown
|
||||
className="ExtensionListItem-controls"
|
||||
buttonClassName="Button Button--icon Button--flat"
|
||||
menuClassName="Dropdown-menu--right"
|
||||
icon="fas fa-ellipsis-h"
|
||||
>
|
||||
{controls}
|
||||
</Dropdown>
|
||||
) : (
|
||||
''
|
||||
)}
|
||||
<div className="ExtensionListItem-main">
|
||||
<label className="ExtensionListItem-title">
|
||||
<input type="checkbox" checked={this.isEnabled(extension.id)} onclick={this.toggle.bind(this, extension.id)} />{' '}
|
||||
{extension.extra['flarum-extension'].title}
|
||||
</label>
|
||||
<div className="ExtensionListItem-version">{extension.version}</div>
|
||||
<div className="ExtensionListItem-description">{extension.description}</div>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
controlItems(name) {
|
||||
const items = new ItemList();
|
||||
const enabled = this.isEnabled(name);
|
||||
|
||||
if (app.extensionSettings[name]) {
|
||||
items.add(
|
||||
'settings',
|
||||
Button.component(
|
||||
{
|
||||
icon: 'fas fa-cog',
|
||||
onclick: app.extensionSettings[name],
|
||||
},
|
||||
app.translator.trans('core.admin.extensions.settings_button')
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
if (!enabled) {
|
||||
items.add(
|
||||
'uninstall',
|
||||
Button.component(
|
||||
{
|
||||
icon: 'far fa-trash-alt',
|
||||
onclick: () => {
|
||||
app
|
||||
.request({
|
||||
url: app.forum.attribute('apiUrl') + '/extensions/' + name,
|
||||
method: 'DELETE',
|
||||
})
|
||||
.then(() => window.location.reload());
|
||||
|
||||
app.modal.show(LoadingModal);
|
||||
},
|
||||
},
|
||||
app.translator.trans('core.admin.extensions.uninstall_button')
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
return items;
|
||||
}
|
||||
|
||||
isEnabled(name) {
|
||||
const enabled = JSON.parse(app.data.settings.extensions_enabled);
|
||||
|
||||
return enabled.indexOf(name) !== -1;
|
||||
}
|
||||
|
||||
toggle(id) {
|
||||
const enabled = this.isEnabled(id);
|
||||
|
||||
app
|
||||
.request({
|
||||
url: app.forum.attribute('apiUrl') + '/extensions/' + id,
|
||||
method: 'PATCH',
|
||||
body: { enabled: !enabled },
|
||||
})
|
||||
.then(() => {
|
||||
if (!enabled) localStorage.setItem('enabledExtension', id);
|
||||
window.location.reload();
|
||||
});
|
||||
|
||||
app.modal.show(LoadingModal);
|
||||
}
|
||||
}
|
130
js/src/admin/components/ExtensionsPage.tsx
Normal file
130
js/src/admin/components/ExtensionsPage.tsx
Normal file
@@ -0,0 +1,130 @@
|
||||
import app from '../app';
|
||||
|
||||
import Page from './Page';
|
||||
import Button from '../../common/components/Button';
|
||||
import Dropdown from '../../common/components/Dropdown';
|
||||
import AddExtensionModal from './AddExtensionModal';
|
||||
import LoadingModal from './LoadingModal';
|
||||
import ItemList from '../../common/utils/ItemList';
|
||||
import icon from '../../common/helpers/icon';
|
||||
|
||||
export default class ExtensionsPage extends Page {
|
||||
view() {
|
||||
return (
|
||||
<div className="ExtensionsPage">
|
||||
<div className="ExtensionsPage-header">
|
||||
<div className="container">
|
||||
{Button.component({
|
||||
children: app.translator.trans('core.admin.extensions.add_button'),
|
||||
icon: 'fas fa-plus',
|
||||
className: 'Button Button--primary',
|
||||
onclick: () => app.modal.show(AddExtensionModal),
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="ExtensionsPage-list">
|
||||
<div className="container">
|
||||
<ul className="ExtensionList">
|
||||
{Object.keys(app.data.extensions).map((id) => {
|
||||
const extension = app.data.extensions[id];
|
||||
const controls = this.controlItems(extension.id).toArray();
|
||||
|
||||
return (
|
||||
<li className={'ExtensionListItem ' + (!this.isEnabled(extension.id) ? 'disabled' : '')}>
|
||||
<div className="ExtensionListItem-content">
|
||||
<span className="ExtensionListItem-icon ExtensionIcon" style={extension.icon}>
|
||||
{extension.icon ? icon(extension.icon.name) : ''}
|
||||
</span>
|
||||
{controls.length ? (
|
||||
<Dropdown
|
||||
className="ExtensionListItem-controls"
|
||||
buttonClassName="Button Button--icon Button--flat"
|
||||
menuClassName="Dropdown-menu--right"
|
||||
icon="fas fa-ellipsis-h"
|
||||
>
|
||||
{controls}
|
||||
</Dropdown>
|
||||
) : (
|
||||
''
|
||||
)}
|
||||
<div className="ExtensionListItem-main">
|
||||
<label className="ExtensionListItem-title">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={this.isEnabled(extension.id)}
|
||||
onclick={this.toggle.bind(this, extension.id)}
|
||||
/>{' '}
|
||||
{extension.extra['flarum-extension'].title}
|
||||
</label>
|
||||
<div className="ExtensionListItem-version">{extension.version}</div>
|
||||
<div className="ExtensionListItem-description">{extension.description}</div>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
controlItems(name) {
|
||||
const items = new ItemList();
|
||||
const enabled = this.isEnabled(name);
|
||||
|
||||
if (app.extensionSettings[name]) {
|
||||
items.add(
|
||||
'settings',
|
||||
Button.component({
|
||||
icon: 'fas fa-cog',
|
||||
children: app.translator.trans('core.admin.extensions.settings_button'),
|
||||
onclick: app.extensionSettings[name],
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
if (!enabled) {
|
||||
items.add(
|
||||
'uninstall',
|
||||
Button.component({
|
||||
icon: 'far fa-trash-alt',
|
||||
children: app.translator.trans('core.admin.extensions.uninstall_button'),
|
||||
onclick: () => {
|
||||
app.request({
|
||||
url: app.forum.attribute('apiUrl') + '/extensions/' + name,
|
||||
method: 'DELETE',
|
||||
}).then(() => window.location.reload());
|
||||
|
||||
app.modal.show(LoadingModal);
|
||||
},
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
return items;
|
||||
}
|
||||
|
||||
isEnabled(name) {
|
||||
const enabled = JSON.parse(app.data.settings.extensions_enabled);
|
||||
|
||||
return enabled.indexOf(name) !== -1;
|
||||
}
|
||||
|
||||
toggle(id) {
|
||||
const enabled = this.isEnabled(id);
|
||||
|
||||
app.request({
|
||||
url: app.forum.attribute('apiUrl') + '/extensions/' + id,
|
||||
method: 'PATCH',
|
||||
body: { enabled: !enabled },
|
||||
}).then(() => {
|
||||
if (!enabled) localStorage.setItem('enabledExtension', id);
|
||||
window.location.reload();
|
||||
});
|
||||
|
||||
app.modal.show(LoadingModal);
|
||||
}
|
||||
}
|
@@ -1,29 +0,0 @@
|
||||
import Component from '../../common/Component';
|
||||
import ItemList from '../../common/utils/ItemList';
|
||||
import listItems from '../../common/helpers/listItems';
|
||||
|
||||
/**
|
||||
* The `HeaderPrimary` component displays primary header controls. On the
|
||||
* default skin, these are shown just to the right of the forum title.
|
||||
*/
|
||||
export default class HeaderPrimary extends Component {
|
||||
view() {
|
||||
return <ul className="Header-controls">{listItems(this.items().toArray())}</ul>;
|
||||
}
|
||||
|
||||
config(isInitialized, context) {
|
||||
// Since this component is 'above' the content of the page (that is, it is a
|
||||
// part of the global UI that persists between routes), we will flag the DOM
|
||||
// to be retained across route changes.
|
||||
context.retain = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build an item list for the controls.
|
||||
*
|
||||
* @return {ItemList}
|
||||
*/
|
||||
items() {
|
||||
return new ItemList();
|
||||
}
|
||||
}
|
22
js/src/admin/components/HeaderPrimary.tsx
Normal file
22
js/src/admin/components/HeaderPrimary.tsx
Normal file
@@ -0,0 +1,22 @@
|
||||
import Component from '../../common/Component';
|
||||
import ItemList from '../../common/utils/ItemList';
|
||||
import listItems from '../../common/helpers/listItems';
|
||||
|
||||
/**
|
||||
* The `HeaderPrimary` component displays primary header controls. On the
|
||||
* default skin, these are shown just to the right of the forum title.
|
||||
*/
|
||||
export default class HeaderPrimary extends Component {
|
||||
view() {
|
||||
return <ul className="Header-controls">{listItems(this.items().toArray())}</ul>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build an item list for the controls.
|
||||
*
|
||||
* @return {ItemList}
|
||||
*/
|
||||
items() {
|
||||
return new ItemList();
|
||||
}
|
||||
}
|
@@ -7,20 +7,20 @@ import listItems from '../../common/helpers/listItems';
|
||||
* The `HeaderSecondary` component displays secondary header controls.
|
||||
*/
|
||||
export default class HeaderSecondary extends Component {
|
||||
view() {
|
||||
return <ul className="Header-controls">{listItems(this.items().toArray())}</ul>;
|
||||
}
|
||||
view() {
|
||||
return <ul className="Header-controls">{listItems(this.items().toArray())}</ul>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build an item list for the controls.
|
||||
*
|
||||
* @return {ItemList}
|
||||
*/
|
||||
items() {
|
||||
const items = new ItemList();
|
||||
/**
|
||||
* Build an item list for the controls.
|
||||
*
|
||||
* @return {ItemList}
|
||||
*/
|
||||
items() {
|
||||
const items = new ItemList();
|
||||
|
||||
items.add('session', SessionDropdown.component());
|
||||
items.add('session', SessionDropdown.component());
|
||||
|
||||
return items;
|
||||
}
|
||||
return items;
|
||||
}
|
||||
}
|
@@ -1,20 +0,0 @@
|
||||
import Modal from '../../common/components/Modal';
|
||||
|
||||
export default class LoadingModal extends Modal {
|
||||
/**
|
||||
* @inheritdoc
|
||||
*/
|
||||
static isDismissible = false;
|
||||
|
||||
className() {
|
||||
return 'LoadingModal Modal--small';
|
||||
}
|
||||
|
||||
title() {
|
||||
return app.translator.trans('core.admin.loading.title');
|
||||
}
|
||||
|
||||
content() {
|
||||
return '';
|
||||
}
|
||||
}
|
20
js/src/admin/components/LoadingModal.tsx
Normal file
20
js/src/admin/components/LoadingModal.tsx
Normal file
@@ -0,0 +1,20 @@
|
||||
import { ComponentProps } from '../../common/Component';
|
||||
import Modal from '../../common/components/Modal';
|
||||
|
||||
export default class LoadingModal extends Modal<ComponentProps> {
|
||||
isDismissible() {
|
||||
return false;
|
||||
}
|
||||
|
||||
className() {
|
||||
return 'LoadingModal Modal--small';
|
||||
}
|
||||
|
||||
title() {
|
||||
return app.translator.transText('core.admin.loading.title');
|
||||
}
|
||||
|
||||
content() {
|
||||
return '';
|
||||
}
|
||||
}
|
@@ -1,232 +0,0 @@
|
||||
import Page from '../../common/components/Page';
|
||||
import FieldSet from '../../common/components/FieldSet';
|
||||
import Button from '../../common/components/Button';
|
||||
import Alert from '../../common/components/Alert';
|
||||
import Select from '../../common/components/Select';
|
||||
import LoadingIndicator from '../../common/components/LoadingIndicator';
|
||||
import saveSettings from '../utils/saveSettings';
|
||||
import withAttr from '../../common/utils/withAttr';
|
||||
|
||||
export default class MailPage extends Page {
|
||||
oninit(vnode) {
|
||||
super.oninit(vnode);
|
||||
|
||||
this.saving = false;
|
||||
this.sendingTest = false;
|
||||
this.refresh();
|
||||
}
|
||||
|
||||
refresh() {
|
||||
this.loading = true;
|
||||
|
||||
this.driverFields = {};
|
||||
this.fields = ['mail_driver', 'mail_from'];
|
||||
this.values = {};
|
||||
this.status = { sending: false, errors: {} };
|
||||
|
||||
const settings = app.data.settings;
|
||||
this.fields.forEach((key) => (this.values[key] = m.stream(settings[key])));
|
||||
|
||||
app
|
||||
.request({
|
||||
method: 'GET',
|
||||
url: app.forum.attribute('apiUrl') + '/mail/settings',
|
||||
})
|
||||
.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] = m.stream(settings[field]);
|
||||
}
|
||||
}
|
||||
|
||||
this.loading = false;
|
||||
m.redraw();
|
||||
});
|
||||
}
|
||||
|
||||
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 (
|
||||
<div className="MailPage">
|
||||
<div className="container">
|
||||
<form onsubmit={this.onsubmit.bind(this)}>
|
||||
<h2>{app.translator.trans('core.admin.email.heading')}</h2>
|
||||
<div className="helpText">{app.translator.trans('core.admin.email.text')}</div>
|
||||
|
||||
{FieldSet.component(
|
||||
{
|
||||
label: app.translator.trans('core.admin.email.addresses_heading'),
|
||||
className: 'MailPage-MailSettings',
|
||||
},
|
||||
[
|
||||
<div className="MailPage-MailSettings-input">
|
||||
<label>
|
||||
{app.translator.trans('core.admin.email.from_label')}
|
||||
<input className="FormControl" bidi={this.values.mail_from} />
|
||||
</label>
|
||||
</div>,
|
||||
]
|
||||
)}
|
||||
|
||||
{FieldSet.component(
|
||||
{
|
||||
label: app.translator.trans('core.admin.email.driver_heading'),
|
||||
className: 'MailPage-MailSettings',
|
||||
},
|
||||
[
|
||||
<div className="MailPage-MailSettings-input">
|
||||
<label>
|
||||
{app.translator.trans('core.admin.email.driver_label')}
|
||||
<Select
|
||||
value={this.values.mail_driver()}
|
||||
options={Object.keys(this.driverFields).reduce((memo, val) => ({ ...memo, [val]: val }), {})}
|
||||
onchange={this.values.mail_driver}
|
||||
/>
|
||||
</label>
|
||||
</div>,
|
||||
]
|
||||
)}
|
||||
|
||||
{this.status.sending ||
|
||||
Alert.component(
|
||||
{
|
||||
dismissible: false,
|
||||
},
|
||||
app.translator.trans('core.admin.email.not_sending_message')
|
||||
)}
|
||||
|
||||
{fieldKeys.length > 0 &&
|
||||
FieldSet.component(
|
||||
{
|
||||
label: app.translator.trans(`core.admin.email.${this.values.mail_driver()}_heading`),
|
||||
className: 'MailPage-MailSettings',
|
||||
},
|
||||
[
|
||||
<div className="MailPage-MailSettings-input">
|
||||
{fieldKeys.map((field) => [
|
||||
<label>
|
||||
{app.translator.trans(`core.admin.email.${field}_label`)}
|
||||
{this.renderField(field)}
|
||||
</label>,
|
||||
this.status.errors[field] && <p className="ValidationError">{this.status.errors[field]}</p>,
|
||||
])}
|
||||
</div>,
|
||||
]
|
||||
)}
|
||||
|
||||
<FieldSet>
|
||||
{Button.component(
|
||||
{
|
||||
type: 'submit',
|
||||
className: 'Button Button--primary',
|
||||
disabled: !this.changed(),
|
||||
},
|
||||
app.translator.trans('core.admin.email.submit_button')
|
||||
)}
|
||||
</FieldSet>
|
||||
|
||||
{FieldSet.component(
|
||||
{
|
||||
label: app.translator.trans('core.admin.email.send_test_mail_heading'),
|
||||
className: 'MailPage-MailSettings',
|
||||
},
|
||||
[
|
||||
<div className="helpText">{app.translator.trans('core.admin.email.send_test_mail_text', { email: app.session.user.email() })}</div>,
|
||||
Button.component(
|
||||
{
|
||||
className: 'Button Button--primary',
|
||||
disabled: this.sendingTest || this.changed(),
|
||||
onclick: () => this.sendTestEmail(),
|
||||
},
|
||||
app.translator.trans('core.admin.email.send_test_mail_button')
|
||||
),
|
||||
]
|
||||
)}
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
renderField(name) {
|
||||
const driver = this.values.mail_driver();
|
||||
const field = this.driverFields[driver][name];
|
||||
const prop = this.values[name];
|
||||
|
||||
if (typeof field === 'string') {
|
||||
return <input className="FormControl" bidi={prop} />;
|
||||
} else {
|
||||
return <Select value={prop()} options={field} onchange={prop} />;
|
||||
}
|
||||
}
|
||||
|
||||
changed() {
|
||||
return this.fields.some((key) => this.values[key]() !== app.data.settings[key]);
|
||||
}
|
||||
|
||||
sendTestEmail() {
|
||||
if (this.saving || this.sendingTest) return;
|
||||
|
||||
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(app.translator.trans('core.admin.email.send_test_mail_success'), {
|
||||
type: 'success',
|
||||
});
|
||||
})
|
||||
.catch((error) => {
|
||||
this.sendingTest = false;
|
||||
m.redraw();
|
||||
throw error;
|
||||
});
|
||||
}
|
||||
|
||||
onsubmit(e) {
|
||||
e.preventDefault();
|
||||
|
||||
if (this.saving || this.sendingTest) return;
|
||||
|
||||
this.saving = true;
|
||||
app.alerts.dismiss(this.successAlert);
|
||||
|
||||
const settings = {};
|
||||
|
||||
this.fields.forEach((key) => (settings[key] = this.values[key]()));
|
||||
|
||||
saveSettings(settings)
|
||||
.then(() => {
|
||||
this.successAlert = app.alerts.show(app.translator.trans('core.admin.basics.saved_message'), {
|
||||
type: 'success',
|
||||
});
|
||||
})
|
||||
.catch(() => {})
|
||||
.then(() => {
|
||||
this.saving = false;
|
||||
this.refresh();
|
||||
});
|
||||
}
|
||||
}
|
191
js/src/admin/components/MailPage.tsx
Normal file
191
js/src/admin/components/MailPage.tsx
Normal file
@@ -0,0 +1,191 @@
|
||||
import app from '../app';
|
||||
|
||||
import Page from './Page';
|
||||
import Alert from '../../common/components/Alert';
|
||||
import Button from '../../common/components/Button';
|
||||
import FieldSet from '../../common/components/FieldSet';
|
||||
import LoadingIndicator from '../../common/components/LoadingIndicator';
|
||||
import Select from '../../common/components/Select';
|
||||
import saveSettings from '../utils/saveSettings';
|
||||
import AlertState from '../../common/states/AlertState';
|
||||
|
||||
import Stream from 'mithril/stream';
|
||||
|
||||
export default class MailPage extends Page {
|
||||
loading = true;
|
||||
saving = false;
|
||||
|
||||
driverFields = {};
|
||||
fields = [];
|
||||
|
||||
values: { [key: string]: Stream<any> } = {};
|
||||
|
||||
status = { sending: false, errors: {} };
|
||||
|
||||
successAlert?: number;
|
||||
|
||||
oninit(vnode) {
|
||||
super.oninit(vnode);
|
||||
|
||||
this.refresh();
|
||||
}
|
||||
|
||||
refresh() {
|
||||
this.loading = true;
|
||||
this.fields = ['mail_driver', 'mail_from'];
|
||||
this.values = {};
|
||||
|
||||
app.request({
|
||||
method: 'GET',
|
||||
url: app.forum.attribute('apiUrl') + '/mail-settings',
|
||||
}).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);
|
||||
}
|
||||
}
|
||||
|
||||
const settings = app.data.settings;
|
||||
this.fields.forEach((key) => (this.values[key] = m.prop(settings[key])));
|
||||
|
||||
this.loading = false;
|
||||
m.redraw();
|
||||
});
|
||||
}
|
||||
|
||||
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 (
|
||||
<div className="MailPage">
|
||||
<div className="container">
|
||||
<form onsubmit={this.onsubmit.bind(this)}>
|
||||
<h2>{app.translator.trans('core.admin.email.heading')}</h2>
|
||||
<div className="helpText">{app.translator.trans('core.admin.email.text')}</div>
|
||||
|
||||
{FieldSet.component({
|
||||
label: app.translator.trans('core.admin.email.addresses_heading'),
|
||||
className: 'MailPage-MailSettings',
|
||||
children: [
|
||||
<div className="MailPage-MailSettings-input">
|
||||
<label>
|
||||
{app.translator.trans('core.admin.email.from_label')}
|
||||
<input
|
||||
className="FormControl"
|
||||
value={this.values.mail_from() || ''}
|
||||
oninput={m.withAttr('value', this.values.mail_from)}
|
||||
/>
|
||||
</label>
|
||||
</div>,
|
||||
],
|
||||
})}
|
||||
|
||||
{FieldSet.component({
|
||||
label: app.translator.trans('core.admin.email.driver_heading'),
|
||||
className: 'MailPage-MailSettings',
|
||||
children: [
|
||||
<div className="MailPage-MailSettings-input">
|
||||
<label>
|
||||
{app.translator.trans('core.admin.email.driver_label')}
|
||||
<Select
|
||||
value={this.values.mail_driver()}
|
||||
options={Object.keys(this.driverFields).reduce((memo, val) => ({ ...memo, [val]: val }), {})}
|
||||
onchange={this.values.mail_driver}
|
||||
/>
|
||||
</label>
|
||||
</div>,
|
||||
],
|
||||
})}
|
||||
|
||||
{this.status.sending ||
|
||||
Alert.component({
|
||||
children: app.translator.trans('core.admin.email.not_sending_message'),
|
||||
dismissible: false,
|
||||
})}
|
||||
|
||||
{fieldKeys.length > 0 &&
|
||||
FieldSet.component({
|
||||
label: app.translator.trans(`core.admin.email.${this.values.mail_driver()}_heading`),
|
||||
className: 'MailPage-MailSettings',
|
||||
children: [
|
||||
<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>,
|
||||
],
|
||||
})}
|
||||
|
||||
{Button.component({
|
||||
type: 'submit',
|
||||
className: 'Button Button--primary',
|
||||
children: app.translator.trans('core.admin.email.submit_button'),
|
||||
disabled: !this.changed(),
|
||||
})}
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
renderField(name) {
|
||||
const driver = this.values.mail_driver();
|
||||
const field = this.driverFields[driver][name];
|
||||
const prop = this.values[name];
|
||||
|
||||
if (prop == undefined) {
|
||||
}
|
||||
|
||||
if (typeof field === 'string') {
|
||||
return <input className="FormControl" value={prop() || ''} oninput={m.withAttr('value', prop)} />;
|
||||
} else {
|
||||
return <Select value={prop()} options={field} onchange={prop} />;
|
||||
}
|
||||
}
|
||||
|
||||
changed() {
|
||||
return this.fields.some((key) => this.values[key]() !== app.data.settings[key]);
|
||||
}
|
||||
|
||||
onsubmit(e) {
|
||||
e.preventDefault();
|
||||
|
||||
if (this.saving) return;
|
||||
|
||||
this.saving = true;
|
||||
app.alerts.dismiss(this.successAlert);
|
||||
|
||||
const settings = {};
|
||||
|
||||
this.fields.forEach((key) => (settings[key] = this.values[key]()));
|
||||
|
||||
saveSettings(settings)
|
||||
.then(() => {
|
||||
this.successAlert = app.alerts.show({ type: 'success', children: app.translator.trans('core.admin.basics.saved_message') });
|
||||
})
|
||||
.catch(() => {})
|
||||
.then(() => {
|
||||
this.saving = false;
|
||||
this.refresh();
|
||||
});
|
||||
}
|
||||
}
|
3
js/src/admin/components/Page.tsx
Normal file
3
js/src/admin/components/Page.tsx
Normal file
@@ -0,0 +1,3 @@
|
||||
import Page from '../../common/components/Page';
|
||||
|
||||
export default Page;
|
@@ -1,156 +0,0 @@
|
||||
import Dropdown from '../../common/components/Dropdown';
|
||||
import Button from '../../common/components/Button';
|
||||
import Separator from '../../common/components/Separator';
|
||||
import Group from '../../common/models/Group';
|
||||
import Badge from '../../common/components/Badge';
|
||||
import GroupBadge from '../../common/components/GroupBadge';
|
||||
|
||||
function badgeForId(id) {
|
||||
const group = app.store.getById('groups', id);
|
||||
|
||||
return group ? GroupBadge.component({ group, label: null }) : '';
|
||||
}
|
||||
|
||||
function filterByRequiredPermissions(groupIds, permission) {
|
||||
app.getRequiredPermissions(permission).forEach((required) => {
|
||||
const restrictToGroupIds = app.data.permissions[required] || [];
|
||||
|
||||
if (restrictToGroupIds.indexOf(Group.GUEST_ID) !== -1) {
|
||||
// do nothing
|
||||
} else if (restrictToGroupIds.indexOf(Group.MEMBER_ID) !== -1) {
|
||||
groupIds = groupIds.filter((id) => id !== Group.GUEST_ID);
|
||||
} else if (groupIds.indexOf(Group.MEMBER_ID) !== -1) {
|
||||
groupIds = restrictToGroupIds;
|
||||
} else {
|
||||
groupIds = restrictToGroupIds.filter((id) => groupIds.indexOf(id) !== -1);
|
||||
}
|
||||
|
||||
groupIds = filterByRequiredPermissions(groupIds, required);
|
||||
});
|
||||
|
||||
return groupIds;
|
||||
}
|
||||
|
||||
export default class PermissionDropdown extends Dropdown {
|
||||
static initAttrs(attrs) {
|
||||
super.initAttrs(attrs);
|
||||
|
||||
attrs.className = 'PermissionDropdown';
|
||||
attrs.buttonClassName = 'Button Button--text';
|
||||
}
|
||||
|
||||
view(vnode) {
|
||||
const children = [];
|
||||
|
||||
let groupIds = app.data.permissions[this.attrs.permission] || [];
|
||||
|
||||
groupIds = filterByRequiredPermissions(groupIds, this.attrs.permission);
|
||||
|
||||
const everyone = groupIds.indexOf(Group.GUEST_ID) !== -1;
|
||||
const members = groupIds.indexOf(Group.MEMBER_ID) !== -1;
|
||||
const adminGroup = app.store.getById('groups', Group.ADMINISTRATOR_ID);
|
||||
|
||||
if (everyone) {
|
||||
this.attrs.label = Badge.component({ icon: 'fas fa-globe' });
|
||||
} else if (members) {
|
||||
this.attrs.label = Badge.component({ icon: 'fas fa-user' });
|
||||
} else {
|
||||
this.attrs.label = [badgeForId(Group.ADMINISTRATOR_ID), groupIds.map(badgeForId)];
|
||||
}
|
||||
|
||||
if (this.showing) {
|
||||
if (this.attrs.allowGuest) {
|
||||
children.push(
|
||||
Button.component(
|
||||
{
|
||||
icon: everyone ? 'fas fa-check' : true,
|
||||
onclick: () => this.save([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(
|
||||
Button.component(
|
||||
{
|
||||
icon: members ? 'fas fa-check' : true,
|
||||
onclick: () => this.save([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(),
|
||||
|
||||
Button.component(
|
||||
{
|
||||
icon: !everyone && !members ? 'fas fa-check' : true,
|
||||
disabled: !everyone && !members,
|
||||
onclick: (e) => {
|
||||
if (e.shiftKey) e.stopPropagation();
|
||||
this.save([]);
|
||||
},
|
||||
},
|
||||
[badgeForId(adminGroup.id()), ' ', adminGroup.namePlural()]
|
||||
)
|
||||
);
|
||||
|
||||
[].push.apply(
|
||||
children,
|
||||
app.store
|
||||
.all('groups')
|
||||
.filter((group) => [Group.ADMINISTRATOR_ID, Group.GUEST_ID, Group.MEMBER_ID].indexOf(group.id()) === -1)
|
||||
.map((group) =>
|
||||
Button.component(
|
||||
{
|
||||
icon: groupIds.indexOf(group.id()) !== -1 ? 'fas fa-check' : true,
|
||||
onclick: (e) => {
|
||||
if (e.shiftKey) e.stopPropagation();
|
||||
this.toggle(group.id());
|
||||
},
|
||||
disabled: this.isGroupDisabled(group.id()) && this.isGroupDisabled(Group.MEMBER_ID) && this.isGroupDisabled(Group.GUEST_ID),
|
||||
},
|
||||
[badgeForId(group.id()), ' ', group.namePlural()]
|
||||
)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
return super.view({ ...vnode, children });
|
||||
}
|
||||
|
||||
save(groupIds) {
|
||||
const permission = this.attrs.permission;
|
||||
|
||||
app.data.permissions[permission] = groupIds;
|
||||
|
||||
app.request({
|
||||
method: 'POST',
|
||||
url: app.forum.attribute('apiUrl') + '/permission',
|
||||
body: { permission, groupIds },
|
||||
});
|
||||
}
|
||||
|
||||
toggle(groupId) {
|
||||
const permission = this.attrs.permission;
|
||||
|
||||
let groupIds = app.data.permissions[permission] || [];
|
||||
|
||||
const index = groupIds.indexOf(groupId);
|
||||
|
||||
if (index !== -1) {
|
||||
groupIds.splice(index, 1);
|
||||
} else {
|
||||
groupIds.push(groupId);
|
||||
groupIds = groupIds.filter((id) => [Group.GUEST_ID, Group.MEMBER_ID].indexOf(id) === -1);
|
||||
}
|
||||
|
||||
this.save(groupIds);
|
||||
}
|
||||
|
||||
isGroupDisabled(id) {
|
||||
return filterByRequiredPermissions([id], this.attrs.permission).indexOf(id) === -1;
|
||||
}
|
||||
}
|
159
js/src/admin/components/PermissionDropdown.tsx
Normal file
159
js/src/admin/components/PermissionDropdown.tsx
Normal file
@@ -0,0 +1,159 @@
|
||||
import app from '../app';
|
||||
|
||||
import Dropdown, { DropdownProps } from '../../common/components/Dropdown';
|
||||
import Button from '../../common/components/Button';
|
||||
import Separator from '../../common/components/Separator';
|
||||
import Group from '../../common/models/Group';
|
||||
import Badge from '../../common/components/Badge';
|
||||
import GroupBadge from '../../common/components/GroupBadge';
|
||||
|
||||
function badgeForId(id) {
|
||||
const group = app.store.getById('groups', id);
|
||||
|
||||
return group ? GroupBadge.component({ group, label: null }) : '';
|
||||
}
|
||||
|
||||
function filterByRequiredPermissions(groupIds, permission) {
|
||||
app.getRequiredPermissions(permission).forEach((required) => {
|
||||
const restrictToGroupIds = app.data.permissions[required] || [];
|
||||
|
||||
if (restrictToGroupIds.indexOf(Group.GUEST_ID) !== -1) {
|
||||
// do nothing
|
||||
} else if (restrictToGroupIds.indexOf(Group.MEMBER_ID) !== -1) {
|
||||
groupIds = groupIds.filter((id) => id !== Group.GUEST_ID);
|
||||
} else if (groupIds.indexOf(Group.MEMBER_ID) !== -1) {
|
||||
groupIds = restrictToGroupIds;
|
||||
} else {
|
||||
groupIds = restrictToGroupIds.filter((id) => groupIds.indexOf(id) !== -1);
|
||||
}
|
||||
|
||||
groupIds = filterByRequiredPermissions(groupIds, required);
|
||||
});
|
||||
|
||||
return groupIds;
|
||||
}
|
||||
|
||||
export interface PermissionDropdownProps extends DropdownProps {
|
||||
label?: Badge[];
|
||||
}
|
||||
|
||||
export default class PermissionDropdown<T extends PermissionDropdownProps = PermissionDropdownProps> extends Dropdown<T> {
|
||||
static initProps(props) {
|
||||
super.initProps(props);
|
||||
|
||||
props.className = 'PermissionDropdown';
|
||||
props.buttonClassName = 'Button Button--text';
|
||||
}
|
||||
|
||||
view() {
|
||||
this.props.children = [];
|
||||
|
||||
let groupIds = app.data.permissions[this.props.permission] || [];
|
||||
|
||||
groupIds = filterByRequiredPermissions(groupIds, this.props.permission);
|
||||
|
||||
const everyone = groupIds.indexOf(Group.GUEST_ID) !== -1;
|
||||
const members = groupIds.indexOf(Group.MEMBER_ID) !== -1;
|
||||
const adminGroup: Group = app.store.getById('groups', Group.ADMINISTRATOR_ID);
|
||||
|
||||
if (everyone) {
|
||||
this.props.label = Badge.component({ icon: 'fas fa-globe' });
|
||||
} else if (members) {
|
||||
this.props.label = Badge.component({ icon: 'fas fa-user' });
|
||||
} else {
|
||||
this.props.label = [badgeForId(Group.ADMINISTRATOR_ID), groupIds.map(badgeForId)];
|
||||
}
|
||||
|
||||
if (this.showing) {
|
||||
if (this.props.allowGuest) {
|
||||
this.props.children.push(
|
||||
Button.component({
|
||||
children: [
|
||||
Badge.component({ icon: 'fas fa-globe' }),
|
||||
' ',
|
||||
app.translator.trans('core.admin.permissions_controls.everyone_button'),
|
||||
],
|
||||
icon: everyone ? 'fas fa-check' : true,
|
||||
onclick: () => this.save([Group.GUEST_ID]),
|
||||
disabled: this.isGroupDisabled(Group.GUEST_ID),
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
this.props.children.push(
|
||||
Button.component({
|
||||
children: [Badge.component({ icon: 'fas fa-user' }), ' ', app.translator.trans('core.admin.permissions_controls.members_button')],
|
||||
icon: members ? 'fas fa-check' : true,
|
||||
onclick: () => this.save([Group.MEMBER_ID]),
|
||||
disabled: this.isGroupDisabled(Group.MEMBER_ID),
|
||||
}),
|
||||
|
||||
Separator.component(),
|
||||
|
||||
Button.component({
|
||||
children: [badgeForId(adminGroup.id()), ' ', adminGroup.namePlural()],
|
||||
icon: !everyone && !members ? 'fas fa-check' : true,
|
||||
disabled: !everyone && !members,
|
||||
onclick: (e) => {
|
||||
if (e.shiftKey) e.stopPropagation();
|
||||
this.save([]);
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
[].push.apply(
|
||||
this.props.children,
|
||||
app.store
|
||||
.all('groups')
|
||||
.filter((group) => [Group.ADMINISTRATOR_ID, Group.GUEST_ID, Group.MEMBER_ID].indexOf(group.id()) === -1)
|
||||
.map((group: Group) =>
|
||||
Button.component({
|
||||
children: [badgeForId(group.id()), ' ', group.namePlural()],
|
||||
icon: groupIds.indexOf(group.id()) !== -1 ? 'fas fa-check' : true,
|
||||
onclick: (e) => {
|
||||
if (e.shiftKey) e.stopPropagation();
|
||||
this.toggle(group.id());
|
||||
},
|
||||
disabled:
|
||||
this.isGroupDisabled(group.id()) && this.isGroupDisabled(Group.MEMBER_ID) && this.isGroupDisabled(Group.GUEST_ID),
|
||||
})
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
return super.view();
|
||||
}
|
||||
|
||||
save(groupIds) {
|
||||
const permission = this.props.permission;
|
||||
|
||||
app.data.permissions[permission] = groupIds;
|
||||
|
||||
app.request({
|
||||
method: 'POST',
|
||||
url: app.forum.attribute('apiUrl') + '/permission',
|
||||
body: { permission, groupIds },
|
||||
});
|
||||
}
|
||||
|
||||
toggle(groupId) {
|
||||
const permission = this.props.permission;
|
||||
|
||||
let groupIds = app.data.permissions[permission] || [];
|
||||
|
||||
const index = groupIds.indexOf(groupId);
|
||||
|
||||
if (index !== -1) {
|
||||
groupIds.splice(index, 1);
|
||||
} else {
|
||||
groupIds.push(groupId);
|
||||
groupIds = groupIds.filter((id) => [Group.GUEST_ID, Group.MEMBER_ID].indexOf(id) === -1);
|
||||
}
|
||||
|
||||
this.save(groupIds);
|
||||
}
|
||||
|
||||
isGroupDisabled(id) {
|
||||
return filterByRequiredPermissions([id], this.props.permission).indexOf(id) === -1;
|
||||
}
|
||||
}
|
@@ -1,369 +0,0 @@
|
||||
import Component from '../../common/Component';
|
||||
import PermissionDropdown from './PermissionDropdown';
|
||||
import SettingDropdown from './SettingDropdown';
|
||||
import Button from '../../common/components/Button';
|
||||
import ItemList from '../../common/utils/ItemList';
|
||||
import icon from '../../common/helpers/icon';
|
||||
|
||||
export default class PermissionGrid extends Component {
|
||||
oninit(vnode) {
|
||||
super.oninit(vnode);
|
||||
|
||||
this.permissions = this.permissionItems().toArray();
|
||||
}
|
||||
|
||||
view() {
|
||||
const scopes = this.scopeItems().toArray();
|
||||
|
||||
const permissionCells = (permission) => {
|
||||
return scopes.map((scope) => <td>{scope.render(permission)}</td>);
|
||||
};
|
||||
|
||||
return (
|
||||
<table className="PermissionGrid">
|
||||
<thead>
|
||||
<tr>
|
||||
<td></td>
|
||||
{scopes.map((scope) => (
|
||||
<th>
|
||||
{scope.label}{' '}
|
||||
{scope.onremove
|
||||
? Button.component({ icon: 'fas fa-times', className: 'Button Button--text PermissionGrid-removeScope', onclick: scope.onremove })
|
||||
: ''}
|
||||
</th>
|
||||
))}
|
||||
<th>{this.scopeControlItems().toArray()}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
{this.permissions.map((section) => (
|
||||
<tbody>
|
||||
<tr className="PermissionGrid-section">
|
||||
<th>{section.label}</th>
|
||||
{permissionCells(section)}
|
||||
<td />
|
||||
</tr>
|
||||
{section.children.map((child) => (
|
||||
<tr className="PermissionGrid-child">
|
||||
<th>
|
||||
{icon(child.icon)}
|
||||
{child.label}
|
||||
</th>
|
||||
{permissionCells(child)}
|
||||
<td />
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
))}
|
||||
</table>
|
||||
);
|
||||
}
|
||||
|
||||
permissionItems() {
|
||||
const items = new ItemList();
|
||||
|
||||
items.add(
|
||||
'view',
|
||||
{
|
||||
label: app.translator.trans('core.admin.permissions.read_heading'),
|
||||
children: this.viewItems().toArray(),
|
||||
},
|
||||
100
|
||||
);
|
||||
|
||||
items.add(
|
||||
'start',
|
||||
{
|
||||
label: app.translator.trans('core.admin.permissions.create_heading'),
|
||||
children: this.startItems().toArray(),
|
||||
},
|
||||
90
|
||||
);
|
||||
|
||||
items.add(
|
||||
'reply',
|
||||
{
|
||||
label: app.translator.trans('core.admin.permissions.participate_heading'),
|
||||
children: this.replyItems().toArray(),
|
||||
},
|
||||
80
|
||||
);
|
||||
|
||||
items.add(
|
||||
'moderate',
|
||||
{
|
||||
label: app.translator.trans('core.admin.permissions.moderate_heading'),
|
||||
children: this.moderateItems().toArray(),
|
||||
},
|
||||
70
|
||||
);
|
||||
|
||||
return items;
|
||||
}
|
||||
|
||||
viewItems() {
|
||||
const items = new ItemList();
|
||||
|
||||
items.add(
|
||||
'viewDiscussions',
|
||||
{
|
||||
icon: 'fas fa-eye',
|
||||
label: app.translator.trans('core.admin.permissions.view_discussions_label'),
|
||||
permission: 'viewDiscussions',
|
||||
allowGuest: true,
|
||||
},
|
||||
100
|
||||
);
|
||||
|
||||
items.add(
|
||||
'viewHiddenGroups',
|
||||
{
|
||||
icon: 'fas fa-users',
|
||||
label: app.translator.trans('core.admin.permissions.view_hidden_groups_label'),
|
||||
permission: 'viewHiddenGroups',
|
||||
},
|
||||
100
|
||||
);
|
||||
|
||||
items.add(
|
||||
'viewUserList',
|
||||
{
|
||||
icon: 'fas fa-users',
|
||||
label: app.translator.trans('core.admin.permissions.view_user_list_label'),
|
||||
permission: 'viewUserList',
|
||||
allowGuest: true,
|
||||
},
|
||||
100
|
||||
);
|
||||
|
||||
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', {
|
||||
icon: 'far fa-clock',
|
||||
label: app.translator.trans('core.admin.permissions.view_last_seen_at_label'),
|
||||
permission: 'user.viewLastSeenAt',
|
||||
});
|
||||
|
||||
return items;
|
||||
}
|
||||
|
||||
startItems() {
|
||||
const items = new ItemList();
|
||||
|
||||
items.add(
|
||||
'start',
|
||||
{
|
||||
icon: 'fas fa-edit',
|
||||
label: app.translator.trans('core.admin.permissions.start_discussions_label'),
|
||||
permission: 'startDiscussion',
|
||||
},
|
||||
100
|
||||
);
|
||||
|
||||
items.add(
|
||||
'allowRenaming',
|
||||
{
|
||||
icon: 'fas fa-i-cursor',
|
||||
label: app.translator.trans('core.admin.permissions.allow_renaming_label'),
|
||||
setting: () => {
|
||||
const minutes = parseInt(app.data.settings.allow_renaming, 10);
|
||||
|
||||
return SettingDropdown.component({
|
||||
defaultLabel: minutes
|
||||
? app.translator.transChoice('core.admin.permissions_controls.allow_some_minutes_button', minutes, { count: minutes })
|
||||
: app.translator.trans('core.admin.permissions_controls.allow_indefinitely_button'),
|
||||
key: 'allow_renaming',
|
||||
options: [
|
||||
{ value: '-1', label: app.translator.trans('core.admin.permissions_controls.allow_indefinitely_button') },
|
||||
{ value: '10', label: app.translator.trans('core.admin.permissions_controls.allow_ten_minutes_button') },
|
||||
{ value: 'reply', label: app.translator.trans('core.admin.permissions_controls.allow_until_reply_button') },
|
||||
],
|
||||
});
|
||||
},
|
||||
},
|
||||
90
|
||||
);
|
||||
|
||||
return items;
|
||||
}
|
||||
|
||||
replyItems() {
|
||||
const items = new ItemList();
|
||||
|
||||
items.add(
|
||||
'reply',
|
||||
{
|
||||
icon: 'fas fa-reply',
|
||||
label: app.translator.trans('core.admin.permissions.reply_to_discussions_label'),
|
||||
permission: 'discussion.reply',
|
||||
},
|
||||
100
|
||||
);
|
||||
|
||||
items.add(
|
||||
'allowPostEditing',
|
||||
{
|
||||
icon: 'fas fa-pencil-alt',
|
||||
label: app.translator.trans('core.admin.permissions.allow_post_editing_label'),
|
||||
setting: () => {
|
||||
const minutes = parseInt(app.data.settings.allow_post_editing, 10);
|
||||
|
||||
return SettingDropdown.component({
|
||||
defaultLabel: minutes
|
||||
? app.translator.transChoice('core.admin.permissions_controls.allow_some_minutes_button', minutes, { count: minutes })
|
||||
: app.translator.trans('core.admin.permissions_controls.allow_indefinitely_button'),
|
||||
key: 'allow_post_editing',
|
||||
options: [
|
||||
{ value: '-1', label: app.translator.trans('core.admin.permissions_controls.allow_indefinitely_button') },
|
||||
{ value: '10', label: app.translator.trans('core.admin.permissions_controls.allow_ten_minutes_button') },
|
||||
{ value: 'reply', label: app.translator.trans('core.admin.permissions_controls.allow_until_reply_button') },
|
||||
],
|
||||
});
|
||||
},
|
||||
},
|
||||
90
|
||||
);
|
||||
|
||||
return items;
|
||||
}
|
||||
|
||||
moderateItems() {
|
||||
const items = new ItemList();
|
||||
|
||||
items.add(
|
||||
'viewIpsPosts',
|
||||
{
|
||||
icon: 'fas fa-bullseye',
|
||||
label: app.translator.trans('core.admin.permissions.view_post_ips_label'),
|
||||
permission: 'discussion.viewIpsPosts',
|
||||
},
|
||||
110
|
||||
);
|
||||
|
||||
items.add(
|
||||
'renameDiscussions',
|
||||
{
|
||||
icon: 'fas fa-i-cursor',
|
||||
label: app.translator.trans('core.admin.permissions.rename_discussions_label'),
|
||||
permission: 'discussion.rename',
|
||||
},
|
||||
100
|
||||
);
|
||||
|
||||
items.add(
|
||||
'hideDiscussions',
|
||||
{
|
||||
icon: 'far fa-trash-alt',
|
||||
label: app.translator.trans('core.admin.permissions.delete_discussions_label'),
|
||||
permission: 'discussion.hide',
|
||||
},
|
||||
90
|
||||
);
|
||||
|
||||
items.add(
|
||||
'deleteDiscussions',
|
||||
{
|
||||
icon: 'fas fa-times',
|
||||
label: app.translator.trans('core.admin.permissions.delete_discussions_forever_label'),
|
||||
permission: 'discussion.delete',
|
||||
},
|
||||
80
|
||||
);
|
||||
|
||||
items.add(
|
||||
'postWithoutThrottle',
|
||||
{
|
||||
icon: 'fas fa-swimmer',
|
||||
label: app.translator.trans('core.admin.permissions.post_without_throttle_label'),
|
||||
permission: 'postWithoutThrottle',
|
||||
},
|
||||
70
|
||||
);
|
||||
|
||||
items.add(
|
||||
'editPosts',
|
||||
{
|
||||
icon: 'fas fa-pencil-alt',
|
||||
label: app.translator.trans('core.admin.permissions.edit_posts_label'),
|
||||
permission: 'discussion.editPosts',
|
||||
},
|
||||
70
|
||||
);
|
||||
|
||||
items.add(
|
||||
'hidePosts',
|
||||
{
|
||||
icon: 'far fa-trash-alt',
|
||||
label: app.translator.trans('core.admin.permissions.delete_posts_label'),
|
||||
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;
|
||||
}
|
||||
|
||||
scopeItems() {
|
||||
const items = new ItemList();
|
||||
|
||||
items.add(
|
||||
'global',
|
||||
{
|
||||
label: app.translator.trans('core.admin.permissions.global_heading'),
|
||||
render: (item) => {
|
||||
if (item.setting) {
|
||||
return item.setting();
|
||||
} else if (item.permission) {
|
||||
return PermissionDropdown.component({
|
||||
permission: item.permission,
|
||||
allowGuest: item.allowGuest,
|
||||
});
|
||||
}
|
||||
|
||||
return '';
|
||||
},
|
||||
},
|
||||
100
|
||||
);
|
||||
|
||||
return items;
|
||||
}
|
||||
|
||||
scopeControlItems() {
|
||||
return new ItemList();
|
||||
}
|
||||
}
|
361
js/src/admin/components/PermissionGrid.tsx
Normal file
361
js/src/admin/components/PermissionGrid.tsx
Normal file
@@ -0,0 +1,361 @@
|
||||
import app from '../app';
|
||||
|
||||
import Component from '../../common/Component';
|
||||
import PermissionDropdown from './PermissionDropdown';
|
||||
import SettingDropdown from './SettingDropdown';
|
||||
import Button from '../../common/components/Button';
|
||||
import ItemList from '../../common/utils/ItemList';
|
||||
import icon from '../../common/helpers/icon';
|
||||
|
||||
export default class PermissionGrid extends Component {
|
||||
view() {
|
||||
const scopes = this.scopeItems().toArray();
|
||||
|
||||
const permissionCells = (permission) => {
|
||||
return scopes.map((scope) => <td>{scope.render(permission)}</td>);
|
||||
};
|
||||
|
||||
return (
|
||||
<table className="PermissionGrid">
|
||||
<thead>
|
||||
<tr>
|
||||
<td></td>
|
||||
{scopes.map((scope) => (
|
||||
<th>
|
||||
{scope.label}{' '}
|
||||
{scope.onremove
|
||||
? Button.component({
|
||||
icon: 'fas fa-times',
|
||||
className: 'Button Button--text PermissionGrid-removeScope',
|
||||
onclick: scope.onremove,
|
||||
})
|
||||
: ''}
|
||||
</th>
|
||||
))}
|
||||
<th>{this.scopeControlItems().toArray()}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
{this.permissionItems()
|
||||
.toArray()
|
||||
.map((section) => (
|
||||
<tbody>
|
||||
<tr className="PermissionGrid-section">
|
||||
<th>{section.label}</th>
|
||||
{permissionCells(section)}
|
||||
<td />
|
||||
</tr>
|
||||
{section.children.map((child) => (
|
||||
<tr className="PermissionGrid-child">
|
||||
<th>
|
||||
{icon(child.icon)}
|
||||
{child.label}
|
||||
</th>
|
||||
{permissionCells(child)}
|
||||
<td />
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
))}
|
||||
</table>
|
||||
);
|
||||
}
|
||||
|
||||
permissionItems() {
|
||||
const items = new ItemList();
|
||||
|
||||
items.add(
|
||||
'view',
|
||||
{
|
||||
label: app.translator.trans('core.admin.permissions.read_heading'),
|
||||
children: this.viewItems().toArray(),
|
||||
},
|
||||
100
|
||||
);
|
||||
|
||||
items.add(
|
||||
'start',
|
||||
{
|
||||
label: app.translator.trans('core.admin.permissions.create_heading'),
|
||||
children: this.startItems().toArray(),
|
||||
},
|
||||
90
|
||||
);
|
||||
|
||||
items.add(
|
||||
'reply',
|
||||
{
|
||||
label: app.translator.trans('core.admin.permissions.participate_heading'),
|
||||
children: this.replyItems().toArray(),
|
||||
},
|
||||
80
|
||||
);
|
||||
|
||||
items.add(
|
||||
'moderate',
|
||||
{
|
||||
label: app.translator.trans('core.admin.permissions.moderate_heading'),
|
||||
children: this.moderateItems().toArray(),
|
||||
},
|
||||
70
|
||||
);
|
||||
|
||||
return items;
|
||||
}
|
||||
|
||||
viewItems() {
|
||||
const items = new ItemList();
|
||||
|
||||
items.add(
|
||||
'viewDiscussions',
|
||||
{
|
||||
icon: 'fas fa-eye',
|
||||
label: app.translator.trans('core.admin.permissions.view_discussions_label'),
|
||||
permission: 'viewDiscussions',
|
||||
allowGuest: true,
|
||||
},
|
||||
100
|
||||
);
|
||||
|
||||
items.add(
|
||||
'viewUserList',
|
||||
{
|
||||
icon: 'fas fa-users',
|
||||
label: app.translator.trans('core.admin.permissions.view_user_list_label'),
|
||||
permission: 'viewUserList',
|
||||
allowGuest: true,
|
||||
},
|
||||
100
|
||||
);
|
||||
|
||||
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.transText('core.admin.permissions_controls.signup_open_button') },
|
||||
{ value: '0', label: app.translator.transText('core.admin.permissions_controls.signup_closed_button') },
|
||||
],
|
||||
}),
|
||||
},
|
||||
90
|
||||
);
|
||||
|
||||
items.add('viewLastSeenAt', {
|
||||
icon: 'far fa-clock',
|
||||
label: app.translator.trans('core.admin.permissions.view_last_seen_at_label'),
|
||||
permission: 'user.viewLastSeenAt',
|
||||
});
|
||||
|
||||
return items;
|
||||
}
|
||||
|
||||
startItems() {
|
||||
const items = new ItemList();
|
||||
|
||||
items.add(
|
||||
'start',
|
||||
{
|
||||
icon: 'fas fa-edit',
|
||||
label: app.translator.trans('core.admin.permissions.start_discussions_label'),
|
||||
permission: 'startDiscussion',
|
||||
},
|
||||
100
|
||||
);
|
||||
|
||||
items.add(
|
||||
'allowRenaming',
|
||||
{
|
||||
icon: 'fas fa-i-cursor',
|
||||
label: app.translator.trans('core.admin.permissions.allow_renaming_label'),
|
||||
setting: () => {
|
||||
const minutes = parseInt(app.data.settings.allow_renaming, 10);
|
||||
|
||||
return SettingDropdown.component({
|
||||
defaultLabel: minutes
|
||||
? app.translator.transChoice('core.admin.permissions_controls.allow_some_minutes_button', minutes, { count: minutes })
|
||||
: app.translator.trans('core.admin.permissions_controls.allow_indefinitely_button'),
|
||||
key: 'allow_renaming',
|
||||
options: [
|
||||
{ value: '-1', label: app.translator.transText('core.admin.permissions_controls.allow_indefinitely_button') },
|
||||
{ value: '10', label: app.translator.transText('core.admin.permissions_controls.allow_ten_minutes_button') },
|
||||
{ value: 'reply', label: app.translator.transText('core.admin.permissions_controls.allow_until_reply_button') },
|
||||
],
|
||||
});
|
||||
},
|
||||
},
|
||||
90
|
||||
);
|
||||
|
||||
return items;
|
||||
}
|
||||
|
||||
replyItems() {
|
||||
const items = new ItemList();
|
||||
|
||||
items.add(
|
||||
'reply',
|
||||
{
|
||||
icon: 'fas fa-reply',
|
||||
label: app.translator.trans('core.admin.permissions.reply_to_discussions_label'),
|
||||
permission: 'discussion.reply',
|
||||
},
|
||||
100
|
||||
);
|
||||
|
||||
items.add(
|
||||
'allowPostEditing',
|
||||
{
|
||||
icon: 'fas fa-pencil-alt',
|
||||
label: app.translator.trans('core.admin.permissions.allow_post_editing_label'),
|
||||
setting: () => {
|
||||
const minutes = parseInt(app.data.settings.allow_post_editing, 10);
|
||||
|
||||
return SettingDropdown.component({
|
||||
defaultLabel: minutes
|
||||
? app.translator.transChoice('core.admin.permissions_controls.allow_some_minutes_button', minutes, { count: minutes })
|
||||
: app.translator.trans('core.admin.permissions_controls.allow_indefinitely_button'),
|
||||
key: 'allow_post_editing',
|
||||
options: [
|
||||
{ value: '-1', label: app.translator.transText('core.admin.permissions_controls.allow_indefinitely_button') },
|
||||
{ value: '10', label: app.translator.transText('core.admin.permissions_controls.allow_ten_minutes_button') },
|
||||
{ value: 'reply', label: app.translator.transText('core.admin.permissions_controls.allow_until_reply_button') },
|
||||
],
|
||||
});
|
||||
},
|
||||
},
|
||||
90
|
||||
);
|
||||
|
||||
return items;
|
||||
}
|
||||
|
||||
moderateItems() {
|
||||
const items = new ItemList();
|
||||
|
||||
items.add(
|
||||
'viewIpsPosts',
|
||||
{
|
||||
icon: 'fas fa-bullseye',
|
||||
label: app.translator.trans('core.admin.permissions.view_post_ips_label'),
|
||||
permission: 'discussion.viewIpsPosts',
|
||||
},
|
||||
110
|
||||
);
|
||||
|
||||
items.add(
|
||||
'renameDiscussions',
|
||||
{
|
||||
icon: 'fas fa-i-cursor',
|
||||
label: app.translator.trans('core.admin.permissions.rename_discussions_label'),
|
||||
permission: 'discussion.rename',
|
||||
},
|
||||
100
|
||||
);
|
||||
|
||||
items.add(
|
||||
'hideDiscussions',
|
||||
{
|
||||
icon: 'far fa-trash-alt',
|
||||
label: app.translator.trans('core.admin.permissions.delete_discussions_label'),
|
||||
permission: 'discussion.hide',
|
||||
},
|
||||
90
|
||||
);
|
||||
|
||||
items.add(
|
||||
'deleteDiscussions',
|
||||
{
|
||||
icon: 'fas fa-times',
|
||||
label: app.translator.trans('core.admin.permissions.delete_discussions_forever_label'),
|
||||
permission: 'discussion.delete',
|
||||
},
|
||||
80
|
||||
);
|
||||
|
||||
items.add(
|
||||
'postWithoutThrottle',
|
||||
{
|
||||
icon: 'fas fa-swimmer',
|
||||
label: app.translator.trans('core.admin.permissions.post_without_throttle_label'),
|
||||
permission: 'postWithoutThrottle',
|
||||
},
|
||||
70
|
||||
);
|
||||
|
||||
items.add(
|
||||
'editPosts',
|
||||
{
|
||||
icon: 'fas fa-pencil-alt',
|
||||
label: app.translator.trans('core.admin.permissions.edit_posts_label'),
|
||||
permission: 'discussion.editPosts',
|
||||
},
|
||||
70
|
||||
);
|
||||
|
||||
items.add(
|
||||
'hidePosts',
|
||||
{
|
||||
icon: 'far fa-trash-alt',
|
||||
label: app.translator.trans('core.admin.permissions.delete_posts_label'),
|
||||
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;
|
||||
}
|
||||
|
||||
scopeItems() {
|
||||
const items = new ItemList();
|
||||
|
||||
items.add(
|
||||
'global',
|
||||
{
|
||||
label: app.translator.trans('core.admin.permissions.global_heading'),
|
||||
render: (item) => {
|
||||
if (item.setting) {
|
||||
return item.setting();
|
||||
} else if (item.permission) {
|
||||
return PermissionDropdown.component({
|
||||
permission: item.permission,
|
||||
allowGuest: item.allowGuest,
|
||||
});
|
||||
}
|
||||
|
||||
return '';
|
||||
},
|
||||
},
|
||||
100
|
||||
);
|
||||
|
||||
return items;
|
||||
}
|
||||
|
||||
scopeControlItems() {
|
||||
return new ItemList();
|
||||
}
|
||||
}
|
@@ -1,40 +0,0 @@
|
||||
import Page from '../../common/components/Page';
|
||||
import GroupBadge from '../../common/components/GroupBadge';
|
||||
import EditGroupModal from './EditGroupModal';
|
||||
import Group from '../../common/models/Group';
|
||||
import icon from '../../common/helpers/icon';
|
||||
import PermissionGrid from './PermissionGrid';
|
||||
|
||||
export default class PermissionsPage extends Page {
|
||||
view() {
|
||||
return (
|
||||
<div className="PermissionsPage">
|
||||
<div className="PermissionsPage-groups">
|
||||
<div className="container">
|
||||
{app.store
|
||||
.all('groups')
|
||||
.filter((group) => [Group.GUEST_ID, Group.MEMBER_ID].indexOf(group.id()) === -1)
|
||||
.map((group) => (
|
||||
<button className="Button Group" onclick={() => app.modal.show(EditGroupModal, { group })}>
|
||||
{GroupBadge.component({
|
||||
group,
|
||||
className: 'Group-icon',
|
||||
label: null,
|
||||
})}
|
||||
<span className="Group-name">{group.namePlural()}</span>
|
||||
</button>
|
||||
))}
|
||||
<button className="Button Group Group--add" onclick={() => app.modal.show(EditGroupModal)}>
|
||||
{icon('fas fa-plus', { className: 'Group-icon' })}
|
||||
<span className="Group-name">{app.translator.trans('core.admin.permissions.new_group_button')}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="PermissionsPage-permissions">
|
||||
<div className="container">{PermissionGrid.component()}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
42
js/src/admin/components/PermissionsPage.tsx
Normal file
42
js/src/admin/components/PermissionsPage.tsx
Normal file
@@ -0,0 +1,42 @@
|
||||
import app from '../app';
|
||||
|
||||
import Page from './Page';
|
||||
import GroupBadge from '../../common/components/GroupBadge';
|
||||
import EditGroupModal from './EditGroupModal';
|
||||
import Group from '../../common/models/Group';
|
||||
import icon from '../../common/helpers/icon';
|
||||
import PermissionGrid from './PermissionGrid';
|
||||
|
||||
export default class PermissionsPage extends Page {
|
||||
view() {
|
||||
return (
|
||||
<div className="PermissionsPage">
|
||||
<div className="PermissionsPage-groups">
|
||||
<div className="container">
|
||||
{app.store
|
||||
.all('groups')
|
||||
.filter((group) => [Group.GUEST_ID, Group.MEMBER_ID].indexOf(group.id()) === -1)
|
||||
.map((group: Group) => (
|
||||
<button className="Button Group" onclick={() => app.modal.show(EditGroupModal, { group })}>
|
||||
{GroupBadge.component({
|
||||
group,
|
||||
className: 'Group-icon',
|
||||
label: null,
|
||||
})}
|
||||
<span className="Group-name">{group.namePlural()}</span>
|
||||
</button>
|
||||
))}
|
||||
<button className="Button Group Group--add" onclick={() => app.modal.show(EditGroupModal)}>
|
||||
{icon('fas fa-plus', { className: 'Group-icon' })}
|
||||
<span className="Group-name">{app.translator.trans('core.admin.permissions.new_group_button')}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="PermissionsPage-permissions">
|
||||
<div className="container">{PermissionGrid.component()}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
@@ -1,52 +0,0 @@
|
||||
import avatar from '../../common/helpers/avatar';
|
||||
import username from '../../common/helpers/username';
|
||||
import Dropdown from '../../common/components/Dropdown';
|
||||
import Button from '../../common/components/Button';
|
||||
import ItemList from '../../common/utils/ItemList';
|
||||
|
||||
/**
|
||||
* The `SessionDropdown` component shows a button with the current user's
|
||||
* avatar/name, with a dropdown of session controls.
|
||||
*/
|
||||
export default class SessionDropdown extends Dropdown {
|
||||
static initAttrs(attrs) {
|
||||
super.initAttrs(attrs);
|
||||
|
||||
attrs.className = 'SessionDropdown';
|
||||
attrs.buttonClassName = 'Button Button--user Button--flat';
|
||||
attrs.menuClassName = 'Dropdown-menu--right';
|
||||
}
|
||||
|
||||
view(vnode) {
|
||||
return super.view({ ...vnode, children: this.items().toArray() });
|
||||
}
|
||||
|
||||
getButtonContent() {
|
||||
const user = app.session.user;
|
||||
|
||||
return [avatar(user), ' ', <span className="Button-label">{username(user)}</span>];
|
||||
}
|
||||
|
||||
/**
|
||||
* Build an item list for the contents of the dropdown menu.
|
||||
*
|
||||
* @return {ItemList}
|
||||
*/
|
||||
items() {
|
||||
const items = new ItemList();
|
||||
|
||||
items.add(
|
||||
'logOut',
|
||||
Button.component(
|
||||
{
|
||||
icon: 'fas fa-sign-out-alt',
|
||||
onclick: app.session.logout.bind(app.session),
|
||||
},
|
||||
app.translator.trans('core.admin.header.log_out_button')
|
||||
),
|
||||
-100
|
||||
);
|
||||
|
||||
return items;
|
||||
}
|
||||
}
|
52
js/src/admin/components/SessionDropdown.tsx
Normal file
52
js/src/admin/components/SessionDropdown.tsx
Normal file
@@ -0,0 +1,52 @@
|
||||
import avatar from '../../common/helpers/avatar';
|
||||
import username from '../../common/helpers/username';
|
||||
import Dropdown from '../../common/components/Dropdown';
|
||||
import Button from '../../common/components/Button';
|
||||
import ItemList from '../../common/utils/ItemList';
|
||||
|
||||
/**
|
||||
* The `SessionDropdown` component shows a button with the current user's
|
||||
* avatar/name, with a dropdown of session controls.
|
||||
*/
|
||||
export default class SessionDropdown extends Dropdown {
|
||||
static initProps(props) {
|
||||
super.initProps(props);
|
||||
|
||||
props.className = 'SessionDropdown';
|
||||
props.buttonClassName = 'Button Button--user Button--flat';
|
||||
props.menuClassName = 'Dropdown-menu--right';
|
||||
}
|
||||
|
||||
view() {
|
||||
this.props.children = this.items().toArray();
|
||||
|
||||
return super.view();
|
||||
}
|
||||
|
||||
getButtonContent() {
|
||||
const user = app.session.user;
|
||||
|
||||
return [avatar(user), ' ', <span className="Button-label">{username(user)}</span>];
|
||||
}
|
||||
|
||||
/**
|
||||
* Build an item list for the contents of the dropdown menu.
|
||||
*
|
||||
* @return {ItemList}
|
||||
*/
|
||||
items() {
|
||||
const items = new ItemList();
|
||||
|
||||
items.add(
|
||||
'logOut',
|
||||
Button.component({
|
||||
icon: 'fas fa-sign-out-alt',
|
||||
children: app.translator.trans('core.admin.header.log_out_button'),
|
||||
onclick: app.session.logout.bind(app.session),
|
||||
}),
|
||||
-100
|
||||
);
|
||||
|
||||
return items;
|
||||
}
|
||||
}
|
@@ -1,32 +0,0 @@
|
||||
import SelectDropdown from '../../common/components/SelectDropdown';
|
||||
import Button from '../../common/components/Button';
|
||||
import saveSettings from '../utils/saveSettings';
|
||||
|
||||
export default class SettingDropdown extends SelectDropdown {
|
||||
static initAttrs(attrs) {
|
||||
super.initAttrs(attrs);
|
||||
|
||||
attrs.className = 'SettingDropdown';
|
||||
attrs.buttonClassName = 'Button Button--text';
|
||||
attrs.caretIcon = 'fas fa-caret-down';
|
||||
attrs.defaultLabel = 'Custom';
|
||||
}
|
||||
|
||||
view(vnode) {
|
||||
return super.view({
|
||||
...vnode,
|
||||
children: this.attrs.options.map(({ value, label }) => {
|
||||
const active = app.data.settings[this.attrs.key] === value;
|
||||
|
||||
return Button.component(
|
||||
{
|
||||
icon: active ? 'fas fa-check' : true,
|
||||
onclick: saveSettings.bind(this, { [this.attrs.key]: value }),
|
||||
active,
|
||||
},
|
||||
label
|
||||
);
|
||||
}),
|
||||
});
|
||||
}
|
||||
}
|
27
js/src/admin/components/SettingDropdown.tsx
Normal file
27
js/src/admin/components/SettingDropdown.tsx
Normal file
@@ -0,0 +1,27 @@
|
||||
import app from '../app';
|
||||
|
||||
import SelectDropdown from '../../common/components/SelectDropdown';
|
||||
import Button from '../../common/components/Button';
|
||||
import saveSettings from '../utils/saveSettings';
|
||||
|
||||
export default class SettingDropdown extends SelectDropdown {
|
||||
static initProps(props) {
|
||||
super.initProps(props);
|
||||
|
||||
props.className = 'SettingDropdown';
|
||||
props.buttonClassName = 'Button Button--text';
|
||||
props.caretIcon = 'fas fa-caret-down';
|
||||
props.defaultLabel = 'Custom';
|
||||
|
||||
props.children = props.options.map(({ value, label }) => {
|
||||
const active = app.data.settings[props.key] === value;
|
||||
|
||||
return Button.component({
|
||||
children: label,
|
||||
icon: active ? 'fas fa-check' : true,
|
||||
onclick: saveSettings.bind(this, { [props.key]: value }),
|
||||
active,
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
@@ -1,72 +0,0 @@
|
||||
import Modal from '../../common/components/Modal';
|
||||
import Button from '../../common/components/Button';
|
||||
import saveSettings from '../utils/saveSettings';
|
||||
|
||||
export default class SettingsModal extends Modal {
|
||||
oninit(vnode) {
|
||||
super.oninit(vnode);
|
||||
|
||||
this.settings = {};
|
||||
this.loading = false;
|
||||
}
|
||||
|
||||
form() {
|
||||
return '';
|
||||
}
|
||||
|
||||
content() {
|
||||
return (
|
||||
<div className="Modal-body">
|
||||
<div className="Form">
|
||||
{this.form()}
|
||||
|
||||
<div className="Form-group">{this.submitButton()}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
submitButton() {
|
||||
return (
|
||||
<Button type="submit" className="Button Button--primary" loading={this.loading} disabled={!this.changed()}>
|
||||
{app.translator.trans('core.admin.settings.submit_button')}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
setting(key, fallback = '') {
|
||||
this.settings[key] = this.settings[key] || m.stream(app.data.settings[key] || fallback);
|
||||
|
||||
return this.settings[key];
|
||||
}
|
||||
|
||||
dirty() {
|
||||
const dirty = {};
|
||||
|
||||
Object.keys(this.settings).forEach((key) => {
|
||||
const value = this.settings[key]();
|
||||
|
||||
if (value !== app.data.settings[key]) {
|
||||
dirty[key] = value;
|
||||
}
|
||||
});
|
||||
|
||||
return dirty;
|
||||
}
|
||||
|
||||
changed() {
|
||||
return Object.keys(this.dirty()).length;
|
||||
}
|
||||
|
||||
onsubmit(e) {
|
||||
e.preventDefault();
|
||||
|
||||
this.loading = true;
|
||||
|
||||
saveSettings(this.dirty()).then(this.onsaved.bind(this), this.loaded.bind(this));
|
||||
}
|
||||
|
||||
onsaved() {
|
||||
this.hide();
|
||||
}
|
||||
}
|
70
js/src/admin/components/SettingsModal.tsx
Normal file
70
js/src/admin/components/SettingsModal.tsx
Normal file
@@ -0,0 +1,70 @@
|
||||
import app from '../app';
|
||||
|
||||
import Modal from '../../common/components/Modal';
|
||||
import Button from '../../common/components/Button';
|
||||
import saveSettings from '../utils/saveSettings';
|
||||
|
||||
export default abstract class SettingsModal extends Modal {
|
||||
settings: object = {};
|
||||
loading: boolean = false;
|
||||
|
||||
form(): string | JSX.Element[] {
|
||||
return '';
|
||||
}
|
||||
|
||||
content() {
|
||||
return (
|
||||
<div className="Modal-body">
|
||||
<div className="Form">
|
||||
{this.form()}
|
||||
|
||||
<div className="Form-group">{this.submitButton()}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
submitButton() {
|
||||
return (
|
||||
<Button type="submit" className="Button Button--primary" loading={this.loading} disabled={!this.changed()}>
|
||||
{app.translator.trans('core.admin.settings.submit_button')}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
setting(key, fallback = '') {
|
||||
this.settings[key] = this.settings[key] || m.prop(app.data.settings[key] || fallback);
|
||||
|
||||
return this.settings[key];
|
||||
}
|
||||
|
||||
dirty() {
|
||||
const dirty = {};
|
||||
|
||||
Object.keys(this.settings).forEach((key) => {
|
||||
const value = this.settings[key]();
|
||||
|
||||
if (value !== app.data.settings[key]) {
|
||||
dirty[key] = value;
|
||||
}
|
||||
});
|
||||
|
||||
return dirty;
|
||||
}
|
||||
|
||||
changed() {
|
||||
return Object.keys(this.dirty()).length;
|
||||
}
|
||||
|
||||
onsubmit(e) {
|
||||
e.preventDefault();
|
||||
|
||||
this.loading = true;
|
||||
|
||||
saveSettings(this.dirty()).then(this.onsaved.bind(this), this.loaded.bind(this));
|
||||
}
|
||||
|
||||
onsaved() {
|
||||
this.hide();
|
||||
}
|
||||
}
|
@@ -1,58 +0,0 @@
|
||||
/*
|
||||
* This file is part of Flarum.
|
||||
*
|
||||
* (c) Toby Zerner <toby.zerner@gmail.com>
|
||||
*
|
||||
* For the full copyright and license information, please view the LICENSE
|
||||
* file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
import DashboardWidget from './DashboardWidget';
|
||||
import listItems from '../../common/helpers/listItems';
|
||||
import ItemList from '../../common/utils/ItemList';
|
||||
import Dropdown from '../../common/components/Dropdown';
|
||||
import Button from '../../common/components/Button';
|
||||
import LoadingModal from './LoadingModal';
|
||||
|
||||
export default class StatusWidget extends DashboardWidget {
|
||||
className() {
|
||||
return 'StatusWidget';
|
||||
}
|
||||
|
||||
content() {
|
||||
return <ul>{listItems(this.items().toArray())}</ul>;
|
||||
}
|
||||
|
||||
items() {
|
||||
const items = new ItemList();
|
||||
|
||||
items.add(
|
||||
'tools',
|
||||
<Dropdown
|
||||
label={app.translator.trans('core.admin.dashboard.tools_button')}
|
||||
icon="fas fa-cog"
|
||||
buttonClassName="Button"
|
||||
menuClassName="Dropdown-menu--right"
|
||||
>
|
||||
<Button onclick={this.handleClearCache.bind(this)}>{app.translator.trans('core.admin.dashboard.clear_cache_button')}</Button>
|
||||
</Dropdown>
|
||||
);
|
||||
|
||||
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-mysql', [<strong>MySQL</strong>, <br />, app.data.mysqlVersion]);
|
||||
|
||||
return items;
|
||||
}
|
||||
|
||||
handleClearCache(e) {
|
||||
app.modal.show(LoadingModal);
|
||||
|
||||
app
|
||||
.request({
|
||||
method: 'DELETE',
|
||||
url: app.forum.attribute('apiUrl') + '/cache',
|
||||
})
|
||||
.then(() => window.location.reload());
|
||||
}
|
||||
}
|
49
js/src/admin/components/StatusWidget.tsx
Normal file
49
js/src/admin/components/StatusWidget.tsx
Normal file
@@ -0,0 +1,49 @@
|
||||
import app from '../app';
|
||||
|
||||
import DashboardWidget from './DashboardWidget';
|
||||
import listItems from '../../common/helpers/listItems';
|
||||
import ItemList from '../../common/utils/ItemList';
|
||||
import Dropdown from '../../common/components/Dropdown';
|
||||
import Button from '../../common/components/Button';
|
||||
import LoadingModal from './LoadingModal';
|
||||
|
||||
export default class StatusWidget extends DashboardWidget {
|
||||
className() {
|
||||
return 'StatusWidget';
|
||||
}
|
||||
|
||||
content() {
|
||||
return <ul>{listItems(this.items().toArray())}</ul>;
|
||||
}
|
||||
|
||||
items() {
|
||||
const items = new ItemList();
|
||||
|
||||
items.add(
|
||||
'tools',
|
||||
<Dropdown
|
||||
label={app.translator.transText('core.admin.dashboard.tools_button')}
|
||||
icon="fas fa-cog"
|
||||
buttonClassName="Button"
|
||||
menuClassName="Dropdown-menu--right"
|
||||
>
|
||||
<Button onclick={this.handleClearCache.bind(this)}>{app.translator.trans('core.admin.dashboard.clear_cache_button')}</Button>
|
||||
</Dropdown>
|
||||
);
|
||||
|
||||
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-mysql', [<strong>MySQL</strong>, <br />, app.data.mysqlVersion]);
|
||||
|
||||
return items;
|
||||
}
|
||||
|
||||
handleClearCache(e) {
|
||||
app.modal.show(LoadingModal);
|
||||
|
||||
app.request({
|
||||
method: 'DELETE',
|
||||
url: app.forum.attribute('apiUrl') + '/cache',
|
||||
}).then(() => window.location.reload());
|
||||
}
|
||||
}
|
@@ -1,97 +0,0 @@
|
||||
import Button from '../../common/components/Button';
|
||||
|
||||
export default class UploadImageButton extends Button {
|
||||
loading = false;
|
||||
|
||||
view(vnode) {
|
||||
this.attrs.loading = this.loading;
|
||||
this.attrs.className = (this.attrs.className || '') + ' Button';
|
||||
|
||||
if (app.data.settings[this.attrs.name + '_path']) {
|
||||
this.attrs.onclick = this.remove.bind(this);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<p>
|
||||
<img src={app.forum.attribute(this.attrs.name + 'Url')} alt="" />
|
||||
</p>
|
||||
<p>{super.view({ ...vnode, children: app.translator.trans('core.admin.upload_image.remove_button') })}</p>
|
||||
</div>
|
||||
);
|
||||
} else {
|
||||
this.attrs.onclick = this.upload.bind(this);
|
||||
}
|
||||
|
||||
return super.view({ ...vnode, children: app.translator.trans('core.admin.upload_image.upload_button') });
|
||||
}
|
||||
|
||||
/**
|
||||
* Prompt the user to upload an image.
|
||||
*/
|
||||
upload() {
|
||||
if (this.loading) return;
|
||||
|
||||
const $input = $('<input type="file">');
|
||||
|
||||
$input
|
||||
.appendTo('body')
|
||||
.hide()
|
||||
.click()
|
||||
.on('change', (e) => {
|
||||
const body = new FormData();
|
||||
body.append(this.attrs.name, $(e.target)[0].files[0]);
|
||||
|
||||
this.loading = true;
|
||||
m.redraw();
|
||||
|
||||
app
|
||||
.request({
|
||||
method: 'POST',
|
||||
url: this.resourceUrl(),
|
||||
serialize: (raw) => raw,
|
||||
body,
|
||||
})
|
||||
.then(this.success.bind(this), this.failure.bind(this));
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove the logo.
|
||||
*/
|
||||
remove() {
|
||||
this.loading = true;
|
||||
m.redraw();
|
||||
|
||||
app
|
||||
.request({
|
||||
method: 'DELETE',
|
||||
url: this.resourceUrl(),
|
||||
})
|
||||
.then(this.success.bind(this), this.failure.bind(this));
|
||||
}
|
||||
|
||||
resourceUrl() {
|
||||
return app.forum.attribute('apiUrl') + '/' + this.attrs.name;
|
||||
}
|
||||
|
||||
/**
|
||||
* After a successful upload/removal, reload the page.
|
||||
*
|
||||
* @param {Object} response
|
||||
* @protected
|
||||
*/
|
||||
success(response) {
|
||||
window.location.reload();
|
||||
}
|
||||
|
||||
/**
|
||||
* If upload/removal fails, stop loading.
|
||||
*
|
||||
* @param {Object} response
|
||||
* @protected
|
||||
*/
|
||||
failure(response) {
|
||||
this.loading = false;
|
||||
m.redraw();
|
||||
}
|
||||
}
|
97
js/src/admin/components/UploadImageButton.tsx
Normal file
97
js/src/admin/components/UploadImageButton.tsx
Normal file
@@ -0,0 +1,97 @@
|
||||
import app from '../app';
|
||||
|
||||
import Button, { ButtonProps } from '../../common/components/Button';
|
||||
|
||||
export default class UploadImageButton<T extends ButtonProps = ButtonProps> extends Button<T> {
|
||||
loading: boolean = false;
|
||||
|
||||
view() {
|
||||
this.props.loading = this.loading;
|
||||
this.props.className = (this.props.className || '') + ' Button';
|
||||
|
||||
if (app.data.settings[this.props.name + '_path']) {
|
||||
this.props.onclick = this.remove.bind(this);
|
||||
this.props.children = app.translator.trans('core.admin.upload_image.remove_button');
|
||||
|
||||
return (
|
||||
<div>
|
||||
<p>
|
||||
<img src={app.forum.attribute(this.props.name + 'Url')} alt="" />
|
||||
</p>
|
||||
<p>{super.view()}</p>
|
||||
</div>
|
||||
);
|
||||
} else {
|
||||
this.props.onclick = this.upload.bind(this);
|
||||
this.props.children = app.translator.trans('core.admin.upload_image.upload_button');
|
||||
}
|
||||
|
||||
return super.view();
|
||||
}
|
||||
|
||||
/**
|
||||
* Prompt the user to upload an image.
|
||||
*/
|
||||
upload() {
|
||||
if (this.loading) return;
|
||||
|
||||
const $input = $('<input type="file">');
|
||||
|
||||
$input
|
||||
.appendTo('body')
|
||||
.hide()
|
||||
.click()
|
||||
.on('change', (e) => {
|
||||
const data = new FormData();
|
||||
data.append(this.props.name, $(e.target)[0].files[0]);
|
||||
|
||||
this.loading = true;
|
||||
m.redraw();
|
||||
|
||||
app.request({
|
||||
method: 'POST',
|
||||
url: this.resourceUrl(),
|
||||
serialize: (raw) => raw,
|
||||
body: data,
|
||||
}).then(this.success.bind(this), this.failure.bind(this));
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove the logo.
|
||||
*/
|
||||
remove() {
|
||||
this.loading = true;
|
||||
m.redraw();
|
||||
|
||||
app.request({
|
||||
method: 'DELETE',
|
||||
url: this.resourceUrl(),
|
||||
}).then(this.success.bind(this), this.failure.bind(this));
|
||||
}
|
||||
|
||||
resourceUrl() {
|
||||
return app.forum.attribute('apiUrl') + '/' + this.props.name;
|
||||
}
|
||||
|
||||
/**
|
||||
* After a successful upload/removal, reload the page.
|
||||
*
|
||||
* @param {Object} response
|
||||
* @protected
|
||||
*/
|
||||
success(response) {
|
||||
window.location.reload();
|
||||
}
|
||||
|
||||
/**
|
||||
* If upload/removal fails, stop loading.
|
||||
*
|
||||
* @param {Object} response
|
||||
* @protected
|
||||
*/
|
||||
failure(response) {
|
||||
this.loading = false;
|
||||
m.redraw();
|
||||
}
|
||||
}
|
@@ -1,17 +0,0 @@
|
||||
import AdminApplication from './AdminApplication';
|
||||
|
||||
const app = new AdminApplication();
|
||||
|
||||
// Backwards compatibility
|
||||
window.app = app;
|
||||
|
||||
export { app };
|
||||
|
||||
// Export public API
|
||||
|
||||
// Export compat API
|
||||
import compat from './compat';
|
||||
|
||||
compat.app = app;
|
||||
|
||||
export { compat };
|
10
js/src/admin/index.ts
Normal file
10
js/src/admin/index.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import app from './app';
|
||||
|
||||
export { app };
|
||||
|
||||
// Export compat API
|
||||
import compat from './compat';
|
||||
|
||||
compat.app = app;
|
||||
|
||||
export { compat };
|
@@ -1,22 +0,0 @@
|
||||
import DashboardPage from './components/DashboardPage';
|
||||
import BasicsPage from './components/BasicsPage';
|
||||
import PermissionsPage from './components/PermissionsPage';
|
||||
import AppearancePage from './components/AppearancePage';
|
||||
import ExtensionsPage from './components/ExtensionsPage';
|
||||
import MailPage from './components/MailPage';
|
||||
|
||||
/**
|
||||
* The `routes` initializer defines the forum app's routes.
|
||||
*
|
||||
* @param {App} app
|
||||
*/
|
||||
export default function (app) {
|
||||
app.routes = {
|
||||
dashboard: { path: '/', component: DashboardPage },
|
||||
basics: { path: '/basics', component: BasicsPage },
|
||||
permissions: { path: '/permissions', component: PermissionsPage },
|
||||
appearance: { path: '/appearance', component: AppearancePage },
|
||||
extensions: { path: '/extensions', component: ExtensionsPage },
|
||||
mail: { path: '/mail', component: MailPage },
|
||||
};
|
||||
}
|
17
js/src/admin/routes.ts
Normal file
17
js/src/admin/routes.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import BasicsPage from './components/BasicsPage';
|
||||
import DashboardPage from './components/DashboardPage';
|
||||
import MailPage from './components/MailPage';
|
||||
import PermissionsPage from './components/PermissionsPage';
|
||||
import AppearancePage from './components/AppearancePage';
|
||||
import ExtensionsPage from './components/ExtensionsPage';
|
||||
|
||||
export default (app) => {
|
||||
app.routes = {
|
||||
dashboard: { path: '/', component: DashboardPage },
|
||||
basics: { path: '/basics', component: BasicsPage },
|
||||
mail: { path: '/mail', component: MailPage },
|
||||
permissions: { path: '/permissions', component: PermissionsPage },
|
||||
appearance: { path: '/appearance', component: AppearancePage },
|
||||
extensions: { path: '/extensions', component: ExtensionsPage },
|
||||
};
|
||||
};
|
@@ -1,16 +0,0 @@
|
||||
export default function saveSettings(settings) {
|
||||
const oldSettings = JSON.parse(JSON.stringify(app.data.settings));
|
||||
|
||||
Object.assign(app.data.settings, settings);
|
||||
|
||||
return app
|
||||
.request({
|
||||
method: 'POST',
|
||||
url: app.forum.attribute('apiUrl') + '/settings',
|
||||
body: settings,
|
||||
})
|
||||
.catch((error) => {
|
||||
app.data.settings = oldSettings;
|
||||
throw error;
|
||||
});
|
||||
}
|
18
js/src/admin/utils/saveSettings.ts
Normal file
18
js/src/admin/utils/saveSettings.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import app from '../app';
|
||||
|
||||
export default function saveSettings(settings) {
|
||||
const oldSettings = JSON.parse(JSON.stringify(app.data.settings));
|
||||
|
||||
Object.assign(app.data.settings, settings);
|
||||
|
||||
return app
|
||||
.request({
|
||||
method: 'POST',
|
||||
url: app.forum.attribute('apiUrl') + '/settings',
|
||||
body: settings,
|
||||
})
|
||||
.catch((error) => {
|
||||
app.data.settings = oldSettings;
|
||||
throw error;
|
||||
});
|
||||
}
|
@@ -1,451 +0,0 @@
|
||||
import ItemList from './utils/ItemList';
|
||||
import Button from './components/Button';
|
||||
import ModalManager from './components/ModalManager';
|
||||
import AlertManager from './components/AlertManager';
|
||||
import RequestErrorModal from './components/RequestErrorModal';
|
||||
import Translator from './Translator';
|
||||
import Store from './Store';
|
||||
import Session from './Session';
|
||||
import extract from './utils/extract';
|
||||
import Drawer from './utils/Drawer';
|
||||
import mapRoutes from './utils/mapRoutes';
|
||||
import RequestError from './utils/RequestError';
|
||||
import ScrollListener from './utils/ScrollListener';
|
||||
import liveHumanTimes from './utils/liveHumanTimes';
|
||||
import { extend } from './extend';
|
||||
|
||||
import Forum from './models/Forum';
|
||||
import User from './models/User';
|
||||
import Discussion from './models/Discussion';
|
||||
import Post from './models/Post';
|
||||
import Group from './models/Group';
|
||||
import Notification from './models/Notification';
|
||||
import { flattenDeep } from 'lodash-es';
|
||||
import PageState from './states/PageState';
|
||||
import ModalManagerState from './states/ModalManagerState';
|
||||
import AlertManagerState from './states/AlertManagerState';
|
||||
|
||||
/**
|
||||
* The `App` class provides a container for an application, as well as various
|
||||
* utilities for the rest of the app to use.
|
||||
*/
|
||||
export default class Application {
|
||||
/**
|
||||
* The forum model for this application.
|
||||
*
|
||||
* @type {Forum}
|
||||
* @public
|
||||
*/
|
||||
forum = null;
|
||||
|
||||
/**
|
||||
* A map of routes, keyed by a unique route name. Each route is an object
|
||||
* containing the following properties:
|
||||
*
|
||||
* - `path` The path that the route is accessed at.
|
||||
* - `component` The Mithril component to render when this route is active.
|
||||
*
|
||||
* @example
|
||||
* app.routes.discussion = {path: '/d/:id', component: DiscussionPage.component()};
|
||||
*
|
||||
* @type {Object}
|
||||
* @public
|
||||
*/
|
||||
routes = {};
|
||||
|
||||
/**
|
||||
* An ordered list of initializers to bootstrap the application.
|
||||
*
|
||||
* @type {ItemList}
|
||||
* @public
|
||||
*/
|
||||
initializers = new ItemList();
|
||||
|
||||
/**
|
||||
* The app's session.
|
||||
*
|
||||
* @type {Session}
|
||||
* @public
|
||||
*/
|
||||
session = null;
|
||||
|
||||
/**
|
||||
* The app's translator.
|
||||
*
|
||||
* @type {Translator}
|
||||
* @public
|
||||
*/
|
||||
translator = new Translator();
|
||||
|
||||
/**
|
||||
* The app's data store.
|
||||
*
|
||||
* @type {Store}
|
||||
* @public
|
||||
*/
|
||||
store = new Store({
|
||||
forums: Forum,
|
||||
users: User,
|
||||
discussions: Discussion,
|
||||
posts: Post,
|
||||
groups: Group,
|
||||
notifications: Notification,
|
||||
});
|
||||
|
||||
/**
|
||||
* A local cache that can be used to store data at the application level, so
|
||||
* that is persists between different routes.
|
||||
*
|
||||
* @type {Object}
|
||||
* @public
|
||||
*/
|
||||
cache = {};
|
||||
|
||||
/**
|
||||
* Whether or not the app has been booted.
|
||||
*
|
||||
* @type {Boolean}
|
||||
* @public
|
||||
*/
|
||||
booted = false;
|
||||
|
||||
/**
|
||||
* The key for an Alert that was shown as a result of an AJAX request error.
|
||||
* If present, it will be dismissed on the next successful request.
|
||||
*
|
||||
* @type {int}
|
||||
* @private
|
||||
*/
|
||||
requestErrorAlert = 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;
|
||||
|
||||
title = '';
|
||||
titleCount = 0;
|
||||
|
||||
load(payload) {
|
||||
this.data = payload;
|
||||
this.translator.locale = payload.locale;
|
||||
}
|
||||
|
||||
boot() {
|
||||
this.initializers.toArray().forEach((initializer) => initializer(this));
|
||||
|
||||
this.store.pushPayload({ data: this.data.resources });
|
||||
|
||||
this.forum = this.store.getById('forums', 1);
|
||||
|
||||
this.session = new Session(this.store.getById('users', this.data.session.userId), this.data.session.csrfToken);
|
||||
|
||||
this.mount();
|
||||
}
|
||||
|
||||
bootExtensions(extensions) {
|
||||
Object.keys(extensions).forEach((name) => {
|
||||
const extension = extensions[name];
|
||||
|
||||
const extenders = flattenDeep(extension.extend);
|
||||
|
||||
for (const extender of extenders) {
|
||||
extender.extend(this, { name, exports: extension });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
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
|
||||
m.mount(document.getElementById('modal'), { view: () => ModalManager.component({ state: this.modal }) });
|
||||
m.mount(document.getElementById('alerts'), { view: () => AlertManager.component({ state: this.alerts }) });
|
||||
|
||||
this.drawer = new Drawer();
|
||||
|
||||
m.route(document.getElementById('content'), basePath + '/', mapRoutes(this.routes, basePath));
|
||||
|
||||
// Add a class to the body which indicates that the page has been scrolled
|
||||
// down.
|
||||
new ScrollListener((top) => {
|
||||
const $app = $('#app');
|
||||
const offset = $app.offset().top;
|
||||
|
||||
$app.toggleClass('affix', top >= offset).toggleClass('scrolled', top > offset);
|
||||
}).start();
|
||||
|
||||
$(() => {
|
||||
$('body').addClass('ontouchstart' in window ? 'touch' : 'no-touch');
|
||||
});
|
||||
|
||||
liveHumanTimes();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the API response document that has been preloaded into the application.
|
||||
*
|
||||
* @return {Object|null}
|
||||
* @public
|
||||
*/
|
||||
preloadedApiDocument() {
|
||||
if (this.data.apiDocument) {
|
||||
const results = this.store.pushPayload(this.data.apiDocument);
|
||||
|
||||
this.data.apiDocument = null;
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
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.
|
||||
*
|
||||
* @param {String} title
|
||||
* @public
|
||||
*/
|
||||
setTitle(title) {
|
||||
this.title = title;
|
||||
this.updateTitle();
|
||||
}
|
||||
|
||||
/**
|
||||
* Set a number to display in the <title> of the page.
|
||||
*
|
||||
* @param {Integer} count
|
||||
*/
|
||||
setTitleCount(count) {
|
||||
this.titleCount = count;
|
||||
this.updateTitle();
|
||||
}
|
||||
|
||||
updateTitle() {
|
||||
const count = this.titleCount ? `(${this.titleCount}) ` : '';
|
||||
const pageTitleWithSeparator = this.title && m.route.get() !== '/' ? this.title + ' - ' : '';
|
||||
const title = this.forum.attribute('title');
|
||||
document.title = count + pageTitleWithSeparator + title;
|
||||
}
|
||||
|
||||
/**
|
||||
* Make an AJAX request, handling any low-level errors that may occur.
|
||||
*
|
||||
* @see https://lhorie.github.io/mithril/mithril.request.html
|
||||
* @param {Object} options
|
||||
* @return {Promise}
|
||||
* @public
|
||||
*/
|
||||
request(originalOptions) {
|
||||
const options = Object.assign({}, originalOptions);
|
||||
|
||||
// Set some default options if they haven't been overridden. We want to
|
||||
// authenticate all requests with the session token. We also want all
|
||||
// requests to run asynchronously in the background, so that they don't
|
||||
// prevent redraws from occurring.
|
||||
options.background = options.background || true;
|
||||
|
||||
extend(options, 'config', (result, xhr) => xhr.setRequestHeader('X-CSRF-Token', this.session.csrfToken));
|
||||
|
||||
// If the method is something like PATCH or DELETE, which not all servers
|
||||
// and clients support, then we'll send it as a POST request with the
|
||||
// intended method specified in the X-HTTP-Method-Override header.
|
||||
if (options.method !== 'GET' && options.method !== 'POST') {
|
||||
const method = options.method;
|
||||
extend(options, 'config', (result, xhr) => xhr.setRequestHeader('X-HTTP-Method-Override', method));
|
||||
options.method = 'POST';
|
||||
}
|
||||
|
||||
// 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
|
||||
// error message to the user instead.
|
||||
options.deserialize = options.deserialize || ((responseText) => responseText);
|
||||
|
||||
options.errorHandler =
|
||||
options.errorHandler ||
|
||||
((error) => {
|
||||
throw error;
|
||||
});
|
||||
|
||||
// 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
|
||||
// awry.
|
||||
const original = options.extract;
|
||||
options.extract = (xhr) => {
|
||||
let responseText;
|
||||
|
||||
if (original) {
|
||||
responseText = original(xhr.responseText);
|
||||
} else {
|
||||
responseText = xhr.responseText || null;
|
||||
}
|
||||
|
||||
const status = xhr.status;
|
||||
|
||||
if (status < 200 || status > 299) {
|
||||
throw new RequestError(status, responseText, options, xhr);
|
||||
}
|
||||
|
||||
if (xhr.getResponseHeader) {
|
||||
const csrfToken = xhr.getResponseHeader('X-CSRF-Token');
|
||||
if (csrfToken) app.session.csrfToken = csrfToken;
|
||||
}
|
||||
|
||||
try {
|
||||
return JSON.parse(responseText);
|
||||
} catch (e) {
|
||||
throw new RequestError(500, responseText, options, xhr);
|
||||
}
|
||||
};
|
||||
|
||||
if (this.requestErrorAlert) this.alerts.dismiss(this.requestErrorAlert);
|
||||
|
||||
// Now make the request. If it's a failure, inspect the error that was
|
||||
// returned and show an alert containing its contents.
|
||||
return m.request(options).then(
|
||||
(response) => response,
|
||||
(error) => {
|
||||
let content;
|
||||
|
||||
switch (error.status) {
|
||||
case 422:
|
||||
content = error.response.errors
|
||||
.map((error) => [error.detail, <br />])
|
||||
.reduce((a, b) => a.concat(b), [])
|
||||
.slice(0, -1);
|
||||
break;
|
||||
|
||||
case 401:
|
||||
case 403:
|
||||
content = app.translator.trans('core.lib.error.permission_denied_message');
|
||||
break;
|
||||
|
||||
case 404:
|
||||
case 410:
|
||||
content = app.translator.trans('core.lib.error.not_found_message');
|
||||
break;
|
||||
|
||||
case 429:
|
||||
content = app.translator.trans('core.lib.error.rate_limit_exceeded_message');
|
||||
break;
|
||||
|
||||
default:
|
||||
content = app.translator.trans('core.lib.error.generic_message');
|
||||
}
|
||||
|
||||
const isDebug = app.forum.attribute('debug');
|
||||
// contains a formatted errors if possible, response must be an JSON API array of errors
|
||||
// the details property is decoded to transform escaped characters such as '\n'
|
||||
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 = {
|
||||
type: 'error',
|
||||
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.content, error.alert);
|
||||
}
|
||||
|
||||
return Promise.reject(error);
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {RequestError} error
|
||||
* @param {string[]} [formattedError]
|
||||
* @private
|
||||
*/
|
||||
showDebug(error, formattedError) {
|
||||
this.alerts.dismiss(this.requestErrorAlert);
|
||||
|
||||
this.modal.show(RequestErrorModal, { error, formattedError });
|
||||
}
|
||||
|
||||
/**
|
||||
* Construct a URL to the route with the given name.
|
||||
*
|
||||
* @param {String} name
|
||||
* @param {Object} params
|
||||
* @return {String}
|
||||
* @public
|
||||
*/
|
||||
route(name, params = {}) {
|
||||
const route = this.routes[name];
|
||||
|
||||
if (!route) throw new Error(`Route '${name}' does not exist`);
|
||||
|
||||
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 : '');
|
||||
}
|
||||
}
|
363
js/src/common/Application.ts
Normal file
363
js/src/common/Application.ts
Normal file
@@ -0,0 +1,363 @@
|
||||
import Mithril from 'mithril';
|
||||
|
||||
import Translator from './Translator';
|
||||
import Session from './Session';
|
||||
import Store from './Store';
|
||||
import { extend } from './extend';
|
||||
|
||||
import extract from './utils/extract';
|
||||
import mapRoutes from './utils/mapRoutes';
|
||||
import Drawer from './utils/Drawer';
|
||||
import RequestError from './utils/RequestError';
|
||||
import ItemList from './utils/ItemList';
|
||||
import ScrollListener from './utils/ScrollListener';
|
||||
|
||||
import Forum from './models/Forum';
|
||||
import Discussion from './models/Discussion';
|
||||
import User from './models/User';
|
||||
import Post from './models/Post';
|
||||
import Group from './models/Group';
|
||||
import Notification from './models/Notification';
|
||||
|
||||
import AlertManager from './components/AlertManager';
|
||||
import Button from './components/Button';
|
||||
import ModalManager from './components/ModalManager';
|
||||
import Page from './components/Page';
|
||||
import RequestErrorModal from './components/RequestErrorModal';
|
||||
|
||||
import AlertState from './states/AlertState';
|
||||
|
||||
import flattenDeep from 'lodash/flattenDeep';
|
||||
|
||||
export type ApplicationData = {
|
||||
apiDocument: any;
|
||||
locale: string;
|
||||
locales: any;
|
||||
resources: any[];
|
||||
session: any;
|
||||
};
|
||||
|
||||
export default abstract class Application {
|
||||
/**
|
||||
* The forum model for this application.
|
||||
*/
|
||||
public forum!: Forum;
|
||||
|
||||
/**
|
||||
* A map of routes, keyed by a unique route name. Each route is an object
|
||||
* containing the following properties:
|
||||
*
|
||||
* - `path` The path that the route is accessed at.
|
||||
* - `component` The Mithril component to render when this route is active.
|
||||
*
|
||||
* @example
|
||||
* app.routes.discussion = {path: '/d/:id', component: DiscussionPage.component()};
|
||||
*/
|
||||
public routes: { [key: string]: { path: string; component: any; [key: string]: any } } = {};
|
||||
|
||||
/**
|
||||
* An ordered list of initializers to bootstrap the application.
|
||||
*/
|
||||
public initializers = new ItemList();
|
||||
|
||||
/**
|
||||
* The app's session.
|
||||
*/
|
||||
public session!: Session;
|
||||
|
||||
/**
|
||||
* The app's translator.
|
||||
*/
|
||||
public translator = new Translator();
|
||||
|
||||
/**
|
||||
* The app's data store.
|
||||
*/
|
||||
public store = new Store({
|
||||
forums: Forum,
|
||||
users: User,
|
||||
discussions: Discussion,
|
||||
posts: Post,
|
||||
groups: Group,
|
||||
notifications: Notification,
|
||||
});
|
||||
|
||||
/**
|
||||
* A local cache that can be used to store data at the application level, so
|
||||
* that is persists between different routes.
|
||||
*/
|
||||
public cache: { [key: string]: any } = {};
|
||||
|
||||
/**
|
||||
* Whether or not the app has been booted.
|
||||
*/
|
||||
public booted: boolean = false;
|
||||
|
||||
/**
|
||||
* An Alert that was shown as a result of an AJAX request error. If present,
|
||||
* it will be dismissed on the next successful request.
|
||||
*/
|
||||
private requestError: RequestError | null = null;
|
||||
|
||||
data!: ApplicationData;
|
||||
|
||||
title = '';
|
||||
titleCount = 0;
|
||||
|
||||
drawer = new Drawer();
|
||||
|
||||
modal!: ModalManager;
|
||||
alerts!: AlertManager;
|
||||
|
||||
current?: Page;
|
||||
previous?: Page;
|
||||
|
||||
load(payload) {
|
||||
this.data = payload;
|
||||
this.translator.locale = payload.locale;
|
||||
}
|
||||
|
||||
boot() {
|
||||
this.initializers.toArray().forEach((initializer) => initializer(this));
|
||||
|
||||
this.store.pushPayload({ data: this.data.resources });
|
||||
|
||||
this.forum = this.store.getById('forums', 1);
|
||||
|
||||
this.session = new Session(this.store.getById('users', this.data.session.userId), this.data.session.csrfToken);
|
||||
|
||||
this.mount();
|
||||
|
||||
this.booted = true;
|
||||
}
|
||||
|
||||
bootExtensions(extensions) {
|
||||
Object.keys(extensions).forEach((name) => {
|
||||
const extension = extensions[name];
|
||||
|
||||
const extenders = flattenDeep(extension.extend);
|
||||
|
||||
for (const extender of extenders) {
|
||||
extender.extend(this, { name, exports: extension });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
mount(basePath = '') {
|
||||
const $modal = document.getElementById('modal');
|
||||
const $alerts = document.getElementById('alerts');
|
||||
const $content = document.getElementById('content');
|
||||
|
||||
if ($modal) m.mount($modal, (this.modal = new ModalManager()));
|
||||
|
||||
if ($alerts) m.mount($alerts, (this.alerts = new AlertManager({ oninit: (vnode) => (this.alerts = vnode.state) })));
|
||||
|
||||
if ($content) m.route($content, basePath + '/', mapRoutes(this.routes, basePath));
|
||||
|
||||
// Add a class to the body which indicates that the page has been scrolled
|
||||
// down.
|
||||
new ScrollListener((top) => {
|
||||
const $app = $('#app');
|
||||
const offset = $app.offset().top;
|
||||
|
||||
$app.toggleClass('affix', top >= offset).toggleClass('scrolled', top > offset);
|
||||
}).start();
|
||||
|
||||
$(() => {
|
||||
$('body').addClass('ontouchstart' in window ? 'touch' : 'no-touch');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the API response document that has been preloaded into the application.
|
||||
*/
|
||||
preloadedApiDocument() {
|
||||
if (this.data.apiDocument) {
|
||||
const results = this.store.pushPayload(this.data.apiDocument);
|
||||
|
||||
this.data.apiDocument = null;
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the <title> of the page.
|
||||
*/
|
||||
setTitle(title: string) {
|
||||
this.title = title;
|
||||
this.updateTitle();
|
||||
}
|
||||
|
||||
/**
|
||||
* Set a number to display in the <title> of the page.
|
||||
*/
|
||||
setTitleCount(count: number) {
|
||||
this.titleCount = count;
|
||||
this.updateTitle();
|
||||
}
|
||||
|
||||
updateTitle() {
|
||||
document.title = (this.titleCount ? `(${this.titleCount}) ` : '') + (this.title ? this.title + ' - ' : '') + this.forum.attribute('title');
|
||||
}
|
||||
|
||||
/**
|
||||
* Construct a URL to the route with the given name.
|
||||
*/
|
||||
route(name: string, params: object = {}): string {
|
||||
const route = this.routes[name];
|
||||
|
||||
if (!route) throw new Error(`Route '${name}' does not exist`);
|
||||
|
||||
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 as Mithril.Params);
|
||||
const prefix = m.route.prefix === '' ? this.forum.attribute('basePath') : '';
|
||||
|
||||
return prefix + url + (queryString ? '?' + queryString : '');
|
||||
}
|
||||
|
||||
/**
|
||||
* Make an AJAX request, handling any low-level errors that may occur.
|
||||
*
|
||||
* @see https://mithril.js.org/request.html
|
||||
*/
|
||||
request(originalOptions: Mithril.RequestOptions<JSON> | any): Promise<any> {
|
||||
const options: Mithril.RequestOptions<JSON> | any = Object.assign({}, originalOptions);
|
||||
|
||||
// Set some default options if they haven't been overridden. We want to
|
||||
// authenticate all requests with the session token. We also want all
|
||||
// requests to run asynchronously in the background, so that they don't
|
||||
// prevent redraws from occurring.
|
||||
options.background = options.background || true;
|
||||
|
||||
extend(options, 'config', (result, xhr: XMLHttpRequest) => xhr.setRequestHeader('X-CSRF-Token', this.session.csrfToken!));
|
||||
|
||||
// If the method is something like PATCH or DELETE, which not all servers
|
||||
// and clients support, then we'll send it as a POST request with the
|
||||
// intended method specified in the X-HTTP-Method-Override header.
|
||||
if (options.method !== 'GET' && options.method !== 'POST') {
|
||||
const method = options.method;
|
||||
extend(options, 'config', (result, xhr: XMLHttpRequest) => xhr.setRequestHeader('X-HTTP-Method-Override', method));
|
||||
options.method = 'POST';
|
||||
}
|
||||
|
||||
// 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
|
||||
// error message to the user instead.
|
||||
options.deserialize = options.deserialize || ((responseText) => responseText);
|
||||
|
||||
options.errorHandler =
|
||||
options.errorHandler ||
|
||||
((error) => {
|
||||
throw error;
|
||||
});
|
||||
|
||||
// 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
|
||||
// awry.
|
||||
const original = options.extract;
|
||||
options.extract = (xhr) => {
|
||||
let responseText;
|
||||
|
||||
if (original) {
|
||||
responseText = original(xhr.responseText);
|
||||
} else {
|
||||
responseText = xhr.responseText || null;
|
||||
}
|
||||
|
||||
const status = xhr.status;
|
||||
|
||||
if (status < 200 || status > 299) {
|
||||
throw new RequestError(status, responseText, options, xhr);
|
||||
}
|
||||
|
||||
if (xhr.getResponseHeader) {
|
||||
const csrfToken = xhr.getResponseHeader('X-CSRF-Token');
|
||||
if (csrfToken) app.session.csrfToken = csrfToken;
|
||||
}
|
||||
|
||||
try {
|
||||
return JSON.parse(responseText);
|
||||
} catch (e) {
|
||||
throw new RequestError(500, responseText, options, xhr);
|
||||
}
|
||||
};
|
||||
|
||||
if (this.requestError) this.alerts.dismiss(this.requestError.alert);
|
||||
|
||||
// Now make the request. If it's a failure, inspect the error that was
|
||||
// returned and show an alert containing its contents.
|
||||
return m.request(options).then(
|
||||
(res) => res,
|
||||
(error) => {
|
||||
this.requestError = error;
|
||||
|
||||
let children;
|
||||
|
||||
switch (error.status) {
|
||||
case 422:
|
||||
children = error.response.errors
|
||||
.map((error) => [error.detail, m('br')])
|
||||
.reduce((a, b) => a.concat(b), [])
|
||||
.slice(0, -1);
|
||||
break;
|
||||
|
||||
case 401:
|
||||
case 403:
|
||||
children = this.translator.trans('core.lib.error.permission_denied_message');
|
||||
break;
|
||||
|
||||
case 404:
|
||||
case 410:
|
||||
children = this.translator.trans('core.lib.error.not_found_message');
|
||||
break;
|
||||
|
||||
case 429:
|
||||
children = this.translator.trans('core.lib.error.rate_limit_exceeded_message');
|
||||
break;
|
||||
|
||||
default:
|
||||
children = this.translator.trans('core.lib.error.generic_message');
|
||||
}
|
||||
|
||||
const isDebug = app.forum.attribute('debug');
|
||||
|
||||
error.alert = new AlertState({
|
||||
type: 'error',
|
||||
children,
|
||||
controls: isDebug && [
|
||||
Button.component({
|
||||
className: 'Button Button--link',
|
||||
onclick: this.showDebug.bind(this, error),
|
||||
children: 'DEBUG', // TODO make translatable
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
try {
|
||||
options.errorHandler(error);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
this.alerts.show(error.alert);
|
||||
}
|
||||
|
||||
return Promise.reject(error);
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
private showDebug(error: RequestError) {
|
||||
this.alerts.dismiss(this.requestError!.alert);
|
||||
|
||||
this.modal.show(RequestErrorModal, { error });
|
||||
}
|
||||
}
|
@@ -1,136 +1,90 @@
|
||||
import * as Mithril from 'mithril';
|
||||
import Mithril, { ClassComponent, Vnode } from 'mithril';
|
||||
|
||||
export type ComponentAttrs = {
|
||||
className?: string;
|
||||
export type ComponentProps = {
|
||||
children?: Mithril.Children;
|
||||
|
||||
[key: string]: any;
|
||||
className?: string;
|
||||
|
||||
[key: string]: any;
|
||||
};
|
||||
|
||||
/**
|
||||
* 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 = any> implements Mithril.ClassComponent<T> {
|
||||
/**
|
||||
* The root DOM element for the component.
|
||||
*/
|
||||
protected element!: Element;
|
||||
export default class Component<T extends ComponentProps = any> implements ClassComponent {
|
||||
element!: HTMLElement;
|
||||
|
||||
/**
|
||||
* The attributes passed into the component.
|
||||
*
|
||||
* @see https://mithril.js.org/components.html#passing-data-to-components
|
||||
*/
|
||||
protected attrs!: T;
|
||||
props: 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.`);
|
||||
}
|
||||
constructor(props: T = <T>{}) {
|
||||
this.props = props.tag ? <T>{} : props;
|
||||
}
|
||||
|
||||
this.attrs = attrs;
|
||||
}
|
||||
view(vnode) {
|
||||
throw new Error('Component#view must be implemented by subclass');
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize the component's attrs.
|
||||
*
|
||||
* This can be used to assign default values for missing, optional attrs.
|
||||
*/
|
||||
protected static initAttrs<T>(attrs: T): void {}
|
||||
oninit(vnode) {
|
||||
this.setProps(vnode);
|
||||
}
|
||||
|
||||
oncreate(vnode) {
|
||||
this.setProps(vnode);
|
||||
this.element = vnode.dom;
|
||||
}
|
||||
|
||||
onbeforeupdate(vnode) {
|
||||
this.setProps(vnode);
|
||||
}
|
||||
|
||||
onupdate(vnode) {
|
||||
this.setProps(vnode);
|
||||
}
|
||||
|
||||
onbeforeremove(vnode) {
|
||||
this.setProps(vnode);
|
||||
}
|
||||
|
||||
onremove(vnode) {
|
||||
this.setProps(vnode);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 selector a jQuery-compatible selector string
|
||||
* @final
|
||||
*/
|
||||
$(selector?: string): ZeptoCollection {
|
||||
const $element = $(this.element);
|
||||
|
||||
return selector ? $element.find(selector) : $element;
|
||||
}
|
||||
|
||||
render() {
|
||||
return m(this.constructor as typeof Component, this.props);
|
||||
}
|
||||
|
||||
static component(props: ComponentProps | any = {}, children?: Mithril.Children) {
|
||||
const componentProps: ComponentProps = Object.assign({}, props);
|
||||
|
||||
if (children) componentProps.children = children;
|
||||
|
||||
return m(this, componentProps);
|
||||
}
|
||||
|
||||
static initProps(props: ComponentProps = {}) {}
|
||||
|
||||
private setProps(vnode: Vnode<T, this>) {
|
||||
const props = vnode.attrs || {};
|
||||
|
||||
(this.constructor as typeof Component).initProps(props);
|
||||
|
||||
if (!props.children) props.children = vnode.children;
|
||||
|
||||
this.props = props;
|
||||
}
|
||||
}
|
||||
|
@@ -1,75 +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 implements Mithril.ClassComponent {
|
||||
/**
|
||||
* 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 {
|
||||
const vdom = this.view();
|
||||
|
||||
vdom.attrs = vdom.attrs || {};
|
||||
|
||||
const originalOnCreate = vdom.attrs.oncreate;
|
||||
|
||||
vdom.attrs.oncreate = (vnode) => {
|
||||
this.element = vnode.dom;
|
||||
if (this.oncreate) this.oncreate.apply(this, vnode);
|
||||
if (originalOnCreate) originalOnCreate.apply(this, vnode);
|
||||
};
|
||||
|
||||
return vdom;
|
||||
}
|
||||
|
||||
/**
|
||||
* @inheritdoc
|
||||
*/
|
||||
abstract view();
|
||||
}
|
@@ -1,319 +0,0 @@
|
||||
/**
|
||||
* The `Model` class represents a local data resource. It provides methods to
|
||||
* persist changes via the API.
|
||||
*
|
||||
* @abstract
|
||||
*/
|
||||
export default class Model {
|
||||
/**
|
||||
* @param {Object} data A resource object from the API.
|
||||
* @param {Store} store The data store that this model should be persisted to.
|
||||
* @public
|
||||
*/
|
||||
constructor(data = {}, store = null) {
|
||||
/**
|
||||
* The resource object from the API.
|
||||
*
|
||||
* @type {Object}
|
||||
* @public
|
||||
*/
|
||||
this.data = data;
|
||||
|
||||
/**
|
||||
* The time at which the model's data was last updated. Watching the value
|
||||
* of this property is a fast way to retain/cache a subtree if data hasn't
|
||||
* changed.
|
||||
*
|
||||
* @type {Date}
|
||||
* @public
|
||||
*/
|
||||
this.freshness = new Date();
|
||||
|
||||
/**
|
||||
* Whether or not the resource exists on the server.
|
||||
*
|
||||
* @type {Boolean}
|
||||
* @public
|
||||
*/
|
||||
this.exists = false;
|
||||
|
||||
/**
|
||||
* The data store that this resource should be persisted to.
|
||||
*
|
||||
* @type {Store}
|
||||
* @protected
|
||||
*/
|
||||
this.store = store;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the model's ID.
|
||||
*
|
||||
* @return {Integer}
|
||||
* @public
|
||||
* @final
|
||||
*/
|
||||
id() {
|
||||
return this.data.id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get one of the model's attributes.
|
||||
*
|
||||
* @param {String} attribute
|
||||
* @return {*}
|
||||
* @public
|
||||
* @final
|
||||
*/
|
||||
attribute(attribute) {
|
||||
return this.data.attributes[attribute];
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge new data into this model locally.
|
||||
*
|
||||
* @param {Object} data A resource object to merge into this model
|
||||
* @public
|
||||
*/
|
||||
pushData(data) {
|
||||
// Since most of the top-level items in a resource object are objects
|
||||
// (e.g. relationships, attributes), we'll need to check and perform the
|
||||
// merge at the second level if that's the case.
|
||||
for (const key in data) {
|
||||
if (typeof data[key] === 'object') {
|
||||
this.data[key] = this.data[key] || {};
|
||||
|
||||
// For every item in a second-level object, we want to check if we've
|
||||
// been handed a Model instance. If so, we will convert it to a
|
||||
// relationship data object.
|
||||
for (const innerKey in data[key]) {
|
||||
if (data[key][innerKey] instanceof Model) {
|
||||
data[key][innerKey] = { data: Model.getIdentifier(data[key][innerKey]) };
|
||||
}
|
||||
this.data[key][innerKey] = data[key][innerKey];
|
||||
}
|
||||
} else {
|
||||
this.data[key] = data[key];
|
||||
}
|
||||
}
|
||||
|
||||
// Now that we've updated the data, we can say that the model is fresh.
|
||||
// This is an easy way to invalidate retained subtrees etc.
|
||||
this.freshness = new Date();
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge new attributes into this model locally.
|
||||
*
|
||||
* @param {Object} attributes The attributes to merge.
|
||||
* @public
|
||||
*/
|
||||
pushAttributes(attributes) {
|
||||
this.pushData({ attributes });
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge new attributes into this model, both locally and with persistence.
|
||||
*
|
||||
* @param {Object} attributes The attributes to save. If a 'relationships' key
|
||||
* exists, it will be extracted and relationships will also be saved.
|
||||
* @param {Object} [options]
|
||||
* @return {Promise}
|
||||
* @public
|
||||
*/
|
||||
save(attributes, options = {}) {
|
||||
const data = {
|
||||
type: this.data.type,
|
||||
id: this.data.id,
|
||||
attributes,
|
||||
};
|
||||
|
||||
// If a 'relationships' key exists, extract it from the attributes hash and
|
||||
// set it on the top-level data object instead. We will be sending this data
|
||||
// object to the API for persistence.
|
||||
if (attributes.relationships) {
|
||||
data.relationships = {};
|
||||
|
||||
for (const key in attributes.relationships) {
|
||||
const model = attributes.relationships[key];
|
||||
|
||||
data.relationships[key] = {
|
||||
data: model instanceof Array ? model.map(Model.getIdentifier) : Model.getIdentifier(model),
|
||||
};
|
||||
}
|
||||
|
||||
delete attributes.relationships;
|
||||
}
|
||||
|
||||
// Before we update the model's data, we should make a copy of the model's
|
||||
// old data so that we can revert back to it if something goes awry during
|
||||
// persistence.
|
||||
const oldData = this.copyData();
|
||||
|
||||
this.pushData(data);
|
||||
|
||||
const request = { data };
|
||||
if (options.meta) request.meta = options.meta;
|
||||
|
||||
return app
|
||||
.request(
|
||||
Object.assign(
|
||||
{
|
||||
method: this.exists ? 'PATCH' : 'POST',
|
||||
url: app.forum.attribute('apiUrl') + this.apiEndpoint(),
|
||||
body: request,
|
||||
},
|
||||
options
|
||||
)
|
||||
)
|
||||
.then(
|
||||
// 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
|
||||
// old data! We'll revert to that and let others handle the error.
|
||||
(response) => {
|
||||
this.pushData(oldData);
|
||||
m.redraw();
|
||||
throw response;
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a request to delete the resource.
|
||||
*
|
||||
* @param {Object} body Data to send along with the DELETE request.
|
||||
* @param {Object} [options]
|
||||
* @return {Promise}
|
||||
* @public
|
||||
*/
|
||||
delete(body, options = {}) {
|
||||
if (!this.exists) return Promise.resolve();
|
||||
|
||||
return app
|
||||
.request(
|
||||
Object.assign(
|
||||
{
|
||||
method: 'DELETE',
|
||||
url: app.forum.attribute('apiUrl') + this.apiEndpoint(),
|
||||
body,
|
||||
},
|
||||
options
|
||||
)
|
||||
)
|
||||
.then(() => {
|
||||
this.exists = false;
|
||||
this.store.remove(this);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Construct a path to the API endpoint for this resource.
|
||||
*
|
||||
* @return {String}
|
||||
* @protected
|
||||
*/
|
||||
apiEndpoint() {
|
||||
return '/' + this.data.type + (this.exists ? '/' + this.data.id : '');
|
||||
}
|
||||
|
||||
copyData() {
|
||||
return JSON.parse(JSON.stringify(this.data));
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a function which returns the value of the given attribute.
|
||||
*
|
||||
* @param {String} name
|
||||
* @param {function} [transform] A function to transform the attribute value
|
||||
* @return {*}
|
||||
* @public
|
||||
*/
|
||||
static attribute(name, transform) {
|
||||
return function () {
|
||||
const value = this.data.attributes && this.data.attributes[name];
|
||||
|
||||
return transform ? transform(value) : value;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a function which returns the value of the given has-one
|
||||
* relationship.
|
||||
*
|
||||
* @param {String} name
|
||||
* @return {Model|Boolean|undefined} false if no information about the
|
||||
* relationship exists; undefined if the relationship exists but the model
|
||||
* has not been loaded; or the model if it has been loaded.
|
||||
* @public
|
||||
*/
|
||||
static hasOne(name) {
|
||||
return function () {
|
||||
if (this.data.relationships) {
|
||||
const relationship = this.data.relationships[name];
|
||||
|
||||
if (relationship) {
|
||||
return app.store.getById(relationship.data.type, relationship.data.id);
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a function which returns the value of the given has-many
|
||||
* relationship.
|
||||
*
|
||||
* @param {String} name
|
||||
* @return {Array|Boolean} false if no information about the relationship
|
||||
* exists; an array if it does, containing models if they have been
|
||||
* loaded, and undefined for those that have not.
|
||||
* @public
|
||||
*/
|
||||
static hasMany(name) {
|
||||
return function () {
|
||||
if (this.data.relationships) {
|
||||
const relationship = this.data.relationships[name];
|
||||
|
||||
if (relationship) {
|
||||
return relationship.data.map((data) => app.store.getById(data.type, data.id));
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Transform the given value into a Date object.
|
||||
*
|
||||
* @param {String} value
|
||||
* @return {Date|null}
|
||||
* @public
|
||||
*/
|
||||
static transformDate(value) {
|
||||
return value ? new Date(value) : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a resource identifier object for the given model.
|
||||
*
|
||||
* @param {Model} model
|
||||
* @return {Object}
|
||||
* @protected
|
||||
*/
|
||||
static getIdentifier(model) {
|
||||
return {
|
||||
type: model.data.type,
|
||||
id: model.data.id,
|
||||
};
|
||||
}
|
||||
}
|
299
js/src/common/Model.ts
Normal file
299
js/src/common/Model.ts
Normal file
@@ -0,0 +1,299 @@
|
||||
import Store from './Store';
|
||||
|
||||
export interface Identifier {
|
||||
type: string;
|
||||
id: string;
|
||||
}
|
||||
|
||||
export interface Data extends Identifier {
|
||||
attributes?: { [key: string]: any };
|
||||
relationships?: { [key: string]: { data: Identifier | Identifier[] } };
|
||||
}
|
||||
|
||||
/**
|
||||
* The `Model` class represents a local data resource. It provides methods to
|
||||
* persist changes via the API.
|
||||
*/
|
||||
export default abstract class Model {
|
||||
/**
|
||||
* The resource object from the API.
|
||||
*/
|
||||
data: Data;
|
||||
|
||||
payload: any;
|
||||
|
||||
/**
|
||||
* The time at which the model's data was last updated. Watching the value
|
||||
* of this property is a fast way to retain/cache a subtree if data hasn't
|
||||
* changed.
|
||||
*/
|
||||
freshness: Date;
|
||||
|
||||
/**
|
||||
* Whether or not the resource exists on the server.
|
||||
*/
|
||||
exists: boolean;
|
||||
|
||||
/**
|
||||
* The data store that this resource should be persisted to.
|
||||
*/
|
||||
protected store?: Store;
|
||||
|
||||
/**
|
||||
* @param data A resource object from the API.
|
||||
* @param store The data store that this model should be persisted to.
|
||||
*/
|
||||
constructor(data = <Data>{}, store?: Store) {
|
||||
this.data = data;
|
||||
this.store = store;
|
||||
|
||||
this.freshness = new Date();
|
||||
this.exists = false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the model's ID.
|
||||
* @final
|
||||
*/
|
||||
id(): string {
|
||||
return this.data.id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get one of the model's attributes.
|
||||
* @final
|
||||
*/
|
||||
attribute(attribute: string): any {
|
||||
return this.data.attributes && this.data.attributes[attribute];
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge new data into this model locally.
|
||||
*
|
||||
* @param data A resource object to merge into this model
|
||||
*/
|
||||
public pushData(data: {}) {
|
||||
// Since most of the top-level items in a resource object are objects
|
||||
// (e.g. relationships, attributes), we'll need to check and perform the
|
||||
// merge at the second level if that's the case.
|
||||
for (const key in data) {
|
||||
if (typeof data[key] === 'object') {
|
||||
this.data[key] = this.data[key] || {};
|
||||
|
||||
// For every item in a second-level object, we want to check if we've
|
||||
// been handed a Model instance. If so, we will convert it to a
|
||||
// relationship data object.
|
||||
for (const innerKey in data[key]) {
|
||||
if (data[key][innerKey] instanceof Model) {
|
||||
data[key][innerKey] = { data: Model.getIdentifier(data[key][innerKey]) };
|
||||
}
|
||||
this.data[key][innerKey] = data[key][innerKey];
|
||||
}
|
||||
} else {
|
||||
this.data[key] = data[key];
|
||||
}
|
||||
}
|
||||
|
||||
// Now that we've updated the data, we can say that the model is fresh.
|
||||
// This is an easy way to invalidate retained subtrees etc.
|
||||
this.freshness = new Date();
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge new attributes into this model locally.
|
||||
*
|
||||
* @param attributes The attributes to merge.
|
||||
*/
|
||||
pushAttributes(attributes: any) {
|
||||
this.pushData({ attributes });
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge new attributes into this model, both locally and with persistence.
|
||||
*
|
||||
* @param attributes The attributes to save. If a 'relationships' key
|
||||
* exists, it will be extracted and relationships will also be saved.
|
||||
* @param [options]
|
||||
*/
|
||||
save(attributes: any, options: any = {}): Promise<Model | Model[]> {
|
||||
const data: Data = {
|
||||
type: this.data.type,
|
||||
id: this.data.id,
|
||||
attributes,
|
||||
};
|
||||
|
||||
// If a 'relationships' key exists, extract it from the attributes hash and
|
||||
// set it on the top-level data object instead. We will be sending this data
|
||||
// object to the API for persistence.
|
||||
if (attributes.relationships) {
|
||||
data.relationships = {};
|
||||
|
||||
for (const key in attributes.relationships) {
|
||||
const model = attributes.relationships[key];
|
||||
|
||||
data.relationships[key] = {
|
||||
data: model instanceof Array ? model.map(Model.getIdentifier) : Model.getIdentifier(model),
|
||||
};
|
||||
}
|
||||
|
||||
delete attributes.relationships;
|
||||
}
|
||||
|
||||
// Before we update the model's data, we should make a copy of the model's
|
||||
// old data so that we can revert back to it if something goes awry during
|
||||
// persistence.
|
||||
const oldData = this.copyData();
|
||||
|
||||
this.pushData(data);
|
||||
|
||||
const request = { data };
|
||||
if (options.meta) request.meta = options.meta;
|
||||
|
||||
return app
|
||||
.request(
|
||||
Object.assign(
|
||||
{
|
||||
method: this.exists ? 'PATCH' : 'POST',
|
||||
url: app.forum.attribute('apiUrl') + this.apiEndpoint(),
|
||||
body: request,
|
||||
},
|
||||
options
|
||||
)
|
||||
)
|
||||
.then(
|
||||
// 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
|
||||
// old data! We'll revert to that and let others handle the error.
|
||||
(response) => {
|
||||
this.pushData(oldData);
|
||||
m.redraw();
|
||||
throw response;
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a request to delete the resource.
|
||||
*
|
||||
* @param {Object} body Data to send along with the DELETE request.
|
||||
* @param {Object} [options]
|
||||
* @return {Promise}
|
||||
* @public
|
||||
*/
|
||||
delete(body = {}, options = {}) {
|
||||
if (!this.exists) return Promise.resolve();
|
||||
|
||||
return app
|
||||
.request(
|
||||
Object.assign(
|
||||
{
|
||||
method: 'DELETE',
|
||||
url: app.forum.attribute('apiUrl') + this.apiEndpoint(),
|
||||
body,
|
||||
},
|
||||
options
|
||||
)
|
||||
)
|
||||
.then(() => {
|
||||
this.exists = false;
|
||||
this.store!.remove(this);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Construct a path to the API endpoint for this resource.
|
||||
*
|
||||
* @return {String}
|
||||
* @protected
|
||||
*/
|
||||
apiEndpoint() {
|
||||
return '/' + this.data.type + (this.exists ? '/' + this.data.id : '');
|
||||
}
|
||||
|
||||
copyData() {
|
||||
return JSON.parse(JSON.stringify(this.data));
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a function which returns the value of the given attribute.
|
||||
*
|
||||
* @param name
|
||||
* @param [transform] A function to transform the attribute value
|
||||
*/
|
||||
static attribute(name: string, transform?: Function): () => any {
|
||||
return function (this: Model) {
|
||||
const value = this.data.attributes && this.data.attributes[name];
|
||||
|
||||
return transform ? transform(value) : value;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a function which returns the value of the given has-one
|
||||
* relationship.
|
||||
*
|
||||
* @return false if no information about the
|
||||
* relationship exists; undefined if the relationship exists but the model
|
||||
* has not been loaded; or the model if it has been loaded.
|
||||
*/
|
||||
static hasOne(name: string): () => Model | boolean {
|
||||
return function (this: Model) {
|
||||
if (this.data.relationships) {
|
||||
const relationship = this.data.relationships[name];
|
||||
|
||||
if (relationship && !Array.isArray(relationship.data)) {
|
||||
return app.store.getById(relationship.data.type, relationship.data.id);
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a function which returns the value of the given has-many
|
||||
* relationship.
|
||||
*
|
||||
* @return false if no information about the relationship
|
||||
* exists; an array if it does, containing models if they have been
|
||||
* loaded, and undefined for those that have not.
|
||||
*/
|
||||
static hasMany(name: string): () => any[] | false {
|
||||
return function (this: Model) {
|
||||
if (this.data.relationships) {
|
||||
const relationship = this.data.relationships[name];
|
||||
|
||||
if (relationship && Array.isArray(relationship.data)) {
|
||||
return relationship.data.map((data) => app.store.getById(data.type, data.id));
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Transform the given value into a Date object.
|
||||
*/
|
||||
static transformDate(value: string): Date | null {
|
||||
return value ? new Date(value) : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a resource identifier object for the given model.
|
||||
*/
|
||||
protected static getIdentifier(model: Model): Identifier {
|
||||
return {
|
||||
type: model.data.type,
|
||||
id: model.data.id,
|
||||
};
|
||||
}
|
||||
}
|
@@ -1,54 +0,0 @@
|
||||
/**
|
||||
* The `Session` class defines the current user session. It stores a reference
|
||||
* to the current authenticated user, and provides methods to log in/out.
|
||||
*/
|
||||
export default class Session {
|
||||
constructor(user, csrfToken) {
|
||||
/**
|
||||
* The current authenticated user.
|
||||
*
|
||||
* @type {User|null}
|
||||
* @public
|
||||
*/
|
||||
this.user = user;
|
||||
|
||||
/**
|
||||
* The CSRF token.
|
||||
*
|
||||
* @type {String|null}
|
||||
* @public
|
||||
*/
|
||||
this.csrfToken = csrfToken;
|
||||
}
|
||||
|
||||
/**
|
||||
* Attempt to log in a user.
|
||||
*
|
||||
* @param {String} identification The username/email.
|
||||
* @param {String} password
|
||||
* @param {Object} [options]
|
||||
* @return {Promise}
|
||||
* @public
|
||||
*/
|
||||
login(body, options = {}) {
|
||||
return app.request(
|
||||
Object.assign(
|
||||
{
|
||||
method: 'POST',
|
||||
url: `${app.forum.attribute('baseUrl')}/login`,
|
||||
body,
|
||||
},
|
||||
options
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Log the user out.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
logout() {
|
||||
window.location = `${app.forum.attribute('baseUrl')}/logout?token=${this.csrfToken}`;
|
||||
}
|
||||
}
|
48
js/src/common/Session.ts
Normal file
48
js/src/common/Session.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
import User from './models/User';
|
||||
|
||||
/**
|
||||
* The `Session` class defines the current user session. It stores a reference
|
||||
* to the current authenticated user, and provides methods to log in/out.
|
||||
*/
|
||||
export default class Session {
|
||||
/**
|
||||
* The current authenticated user.
|
||||
*/
|
||||
user: User;
|
||||
|
||||
/**
|
||||
* The CSRF token.
|
||||
*/
|
||||
csrfToken?: string;
|
||||
|
||||
constructor(user, csrfToken) {
|
||||
this.user = user;
|
||||
|
||||
this.csrfToken = csrfToken;
|
||||
}
|
||||
|
||||
/**
|
||||
* Attempt to log in a user.
|
||||
*/
|
||||
login(body: { identification: string; password: string; remember?: boolean }, options = {}) {
|
||||
return app.request(
|
||||
Object.assign(
|
||||
{
|
||||
method: 'POST',
|
||||
url: `${app.forum.attribute('baseUrl')}/login`,
|
||||
body,
|
||||
},
|
||||
options
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Log the user out.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
logout() {
|
||||
window.location.href = `${app.forum.attribute('baseUrl')}/logout?token=${this.csrfToken}`;
|
||||
}
|
||||
}
|
@@ -1,170 +0,0 @@
|
||||
/**
|
||||
* The `Store` class defines a local data store, and provides methods to
|
||||
* retrieve data from the API.
|
||||
*/
|
||||
export default class Store {
|
||||
constructor(models) {
|
||||
/**
|
||||
* The local data store. A tree of resource types to IDs, such that
|
||||
* accessing data[type][id] will return the model for that type/ID.
|
||||
*
|
||||
* @type {Object}
|
||||
* @protected
|
||||
*/
|
||||
this.data = {};
|
||||
|
||||
/**
|
||||
* The model registry. A map of resource types to the model class that
|
||||
* should be used to represent resources of that type.
|
||||
*
|
||||
* @type {Object}
|
||||
* @public
|
||||
*/
|
||||
this.models = models;
|
||||
}
|
||||
|
||||
/**
|
||||
* Push resources contained within an API payload into the store.
|
||||
*
|
||||
* @param {Object} payload
|
||||
* @return {Model|Model[]} The model(s) representing the resource(s) contained
|
||||
* within the 'data' key of the payload.
|
||||
* @public
|
||||
*/
|
||||
pushPayload(payload) {
|
||||
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);
|
||||
|
||||
// Attach the original payload to the model that we give back. This is
|
||||
// useful to consumers as it allows them to access meta information
|
||||
// associated with their request.
|
||||
result.payload = payload;
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a model to represent a resource object (or update an existing one),
|
||||
* and push it into the store.
|
||||
*
|
||||
* @param {Object} data The resource object
|
||||
* @return {Model|null} The model, or null if no model class has been
|
||||
* registered for this resource type.
|
||||
* @public
|
||||
*/
|
||||
pushObject(data) {
|
||||
if (!this.models[data.type]) return null;
|
||||
|
||||
const type = (this.data[data.type] = this.data[data.type] || {});
|
||||
|
||||
if (type[data.id]) {
|
||||
type[data.id].pushData(data);
|
||||
} else {
|
||||
type[data.id] = this.createRecord(data.type, data);
|
||||
}
|
||||
|
||||
type[data.id].exists = true;
|
||||
|
||||
return type[data.id];
|
||||
}
|
||||
|
||||
/**
|
||||
* Make a request to the API to find record(s) of a specific type.
|
||||
*
|
||||
* @param {String} type The resource type.
|
||||
* @param {Integer|Integer[]|Object} [id] The ID(s) of the model(s) to retrieve.
|
||||
* Alternatively, if an object is passed, it will be handled as the
|
||||
* `query` parameter.
|
||||
* @param {Object} [query]
|
||||
* @param {Object} [options]
|
||||
* @return {Promise}
|
||||
* @public
|
||||
*/
|
||||
find(type, id, query = {}, options = {}) {
|
||||
let params = query;
|
||||
let url = app.forum.attribute('apiUrl') + '/' + type;
|
||||
|
||||
if (id instanceof Array) {
|
||||
url += '?filter[id]=' + id.join(',');
|
||||
} else if (typeof id === 'object') {
|
||||
params = id;
|
||||
} else if (id) {
|
||||
url += '/' + id;
|
||||
}
|
||||
|
||||
return app
|
||||
.request(
|
||||
Object.assign(
|
||||
{
|
||||
method: 'GET',
|
||||
url,
|
||||
params,
|
||||
},
|
||||
options
|
||||
)
|
||||
)
|
||||
.then(this.pushPayload.bind(this));
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a record from the store by ID.
|
||||
*
|
||||
* @param {String} type The resource type.
|
||||
* @param {Integer} id The resource ID.
|
||||
* @return {Model}
|
||||
* @public
|
||||
*/
|
||||
getById(type, id) {
|
||||
return this.data[type] && this.data[type][id];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a record from the store by the value of a model attribute.
|
||||
*
|
||||
* @param {String} type The resource type.
|
||||
* @param {String} key The name of the method on the model.
|
||||
* @param {*} value The value of the model attribute.
|
||||
* @return {Model}
|
||||
* @public
|
||||
*/
|
||||
getBy(type, key, value) {
|
||||
return this.all(type).filter((model) => model[key]() === value)[0];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all loaded records of a specific type.
|
||||
*
|
||||
* @param {String} type
|
||||
* @return {Model[]}
|
||||
* @public
|
||||
*/
|
||||
all(type) {
|
||||
const records = this.data[type];
|
||||
|
||||
return records ? Object.keys(records).map((id) => records[id]) : [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove the given model from the store.
|
||||
*
|
||||
* @param {Model} model
|
||||
*/
|
||||
remove(model) {
|
||||
delete this.data[model.data.type][model.id()];
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new record of the given type.
|
||||
*
|
||||
* @param {String} type The resource type
|
||||
* @param {Object} [data] Any data to initialize the model with
|
||||
* @return {Model}
|
||||
* @public
|
||||
*/
|
||||
createRecord(type, data = {}) {
|
||||
data.type = data.type || type;
|
||||
|
||||
return new this.models[type](data, this);
|
||||
}
|
||||
}
|
152
js/src/common/Store.ts
Normal file
152
js/src/common/Store.ts
Normal file
@@ -0,0 +1,152 @@
|
||||
import Model from './Model';
|
||||
|
||||
/**
|
||||
* The `Store` class defines a local data store, and provides methods to
|
||||
* retrieve data from the API.
|
||||
*/
|
||||
export default class Store {
|
||||
/**
|
||||
* The local data store. A tree of resource types to IDs, such that
|
||||
* accessing data[type][id] will return the model for that type/ID.
|
||||
*/
|
||||
data: { [key: string]: Model[] } = {};
|
||||
|
||||
/**
|
||||
* The model registry. A map of resource types to the model class that
|
||||
* should be used to represent resources of that type.
|
||||
*/
|
||||
models: {};
|
||||
|
||||
constructor(models) {
|
||||
this.models = models;
|
||||
}
|
||||
|
||||
/**
|
||||
* Push resources contained within an API payload into the store.
|
||||
*
|
||||
* @param payload
|
||||
* @return The model(s) representing the resource(s) contained
|
||||
* within the 'data' key of the payload.
|
||||
*/
|
||||
pushPayload(payload: { included?: {}[]; data?: {} | {}[] }): Model | Model[] {
|
||||
if (payload.included) payload.included.map(this.pushObject.bind(this));
|
||||
|
||||
const result: any = 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
|
||||
// useful to consumers as it allows them to access meta information
|
||||
// associated with their request.
|
||||
result.payload = payload;
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a model to represent a resource object (or update an existing one),
|
||||
* and push it into the store.
|
||||
*
|
||||
* @param {Object} data The resource object
|
||||
* @return The model, or null if no model class has been
|
||||
* registered for this resource type.
|
||||
*/
|
||||
pushObject(data): Model | null {
|
||||
if (!this.models[data.type]) return null;
|
||||
|
||||
const type = (this.data[data.type] = this.data[data.type] || {});
|
||||
|
||||
if (type[data.id]) {
|
||||
type[data.id].pushData(data);
|
||||
} else {
|
||||
type[data.id] = this.createRecord(data.type, data);
|
||||
}
|
||||
|
||||
type[data.id].exists = true;
|
||||
|
||||
return type[data.id];
|
||||
}
|
||||
|
||||
/**
|
||||
* Make a request to the API to find record(s) of a specific type.
|
||||
*
|
||||
* @param type The resource type.
|
||||
* @param [id] The ID(s) of the model(s) to retrieve.
|
||||
* Alternatively, if an object is passed, it will be handled as the
|
||||
* `query` parameter.
|
||||
* @param query
|
||||
* @param options
|
||||
*/
|
||||
find<T extends Model = Model>(type: string, id?: number | number[] | any, query = {}, options = {}): Promise<T | T[]> {
|
||||
let params = query;
|
||||
let url = `${app.forum.attribute('apiUrl')}/${type}`;
|
||||
|
||||
if (id instanceof Array) {
|
||||
url += `?filter[id]=${id.join(',')}`;
|
||||
} else if (typeof id === 'object') {
|
||||
params = id;
|
||||
} else if (id) {
|
||||
url += `/${id}`;
|
||||
}
|
||||
|
||||
return <Promise<T | T[]>>app
|
||||
.request(
|
||||
Object.assign(
|
||||
{
|
||||
method: 'GET',
|
||||
url,
|
||||
params,
|
||||
},
|
||||
options
|
||||
)
|
||||
)
|
||||
.then(this.pushPayload.bind(this));
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a record from the store by ID.
|
||||
*
|
||||
* @param type The resource type.
|
||||
* @param id The resource ID.
|
||||
*/
|
||||
getById<T extends Model = Model>(type: string, id: number | string): T {
|
||||
return this.data[type] && (this.data[type][id] as T);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a record from the store by the value of a model attribute.
|
||||
*
|
||||
* @param type The resource type.
|
||||
* @param key The name of the method on the model.
|
||||
* @param value The value of the model attribute.
|
||||
*/
|
||||
getBy<T extends Model = Model>(type: string, key: string, value: any): T {
|
||||
return this.all<T>(type).filter((model) => model[key]() === value)[0];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all loaded records of a specific type.
|
||||
*/
|
||||
all<T extends Model = Model>(type: string): T[] {
|
||||
const records = this.data[type];
|
||||
|
||||
return records ? Object.keys(records).map((id) => records[id]) : [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove the given model from the store.
|
||||
*/
|
||||
remove(model: Model) {
|
||||
delete this.data[model.data.type][model.id()];
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new record of the given type.
|
||||
*
|
||||
* @param {String} type The resource type
|
||||
* @param {Object} [data] Any data to initialize the model with
|
||||
*/
|
||||
createRecord<T extends Model = Model>(type: string, data: any = {}): T {
|
||||
data.type = data.type || type;
|
||||
|
||||
return new this.models[type](data, this);
|
||||
}
|
||||
}
|
@@ -1,305 +0,0 @@
|
||||
import username from './helpers/username';
|
||||
import extract from './utils/extract';
|
||||
|
||||
/**
|
||||
* Translator with the same API as Symfony's.
|
||||
*
|
||||
* Derived from https://github.com/willdurand/BazingaJsTranslationBundle
|
||||
* which is available under the MIT License.
|
||||
* Copyright (c) William Durand <william.durand1@gmail.com>
|
||||
*/
|
||||
export default class Translator {
|
||||
constructor() {
|
||||
/**
|
||||
* A map of translation keys to their translated values.
|
||||
*
|
||||
* @type {Object}
|
||||
* @public
|
||||
*/
|
||||
this.translations = {};
|
||||
|
||||
this.locale = null;
|
||||
}
|
||||
|
||||
addTranslations(translations) {
|
||||
Object.assign(this.translations, translations);
|
||||
}
|
||||
|
||||
trans(id, parameters) {
|
||||
const translation = this.translations[id];
|
||||
|
||||
if (translation) {
|
||||
return this.apply(translation, parameters || {});
|
||||
}
|
||||
|
||||
return id;
|
||||
}
|
||||
|
||||
transChoice(id, number, parameters) {
|
||||
let translation = this.translations[id];
|
||||
|
||||
if (translation) {
|
||||
number = parseInt(number, 10);
|
||||
|
||||
translation = this.pluralize(translation, number);
|
||||
|
||||
return this.apply(translation, parameters || {});
|
||||
}
|
||||
|
||||
return id;
|
||||
}
|
||||
|
||||
apply(translation, input) {
|
||||
// If we've been given a user model as one of the input parameters, then
|
||||
// we'll extract the username and use that for the translation. In the
|
||||
// future there should be a hook here to inspect the user and change the
|
||||
// translation key. This will allow a gender property to determine which
|
||||
// translation key is used.
|
||||
if ('user' in input) {
|
||||
const user = extract(input, 'user');
|
||||
|
||||
if (!input.username) input.username = username(user);
|
||||
}
|
||||
|
||||
translation = translation.split(new RegExp('({[a-z0-9_]+}|</?[a-z0-9_]+>)', 'gi'));
|
||||
|
||||
const hydrated = [];
|
||||
const open = [hydrated];
|
||||
|
||||
translation.forEach((part) => {
|
||||
const match = part.match(new RegExp('{([a-z0-9_]+)}|<(/?)([a-z0-9_]+)>', 'i'));
|
||||
|
||||
if (match) {
|
||||
// Either an opening or closing tag.
|
||||
if (match[1]) {
|
||||
open[0].push(input[match[1]]);
|
||||
} else if (match[3]) {
|
||||
if (match[2]) {
|
||||
// Closing tag. We start by removing all raw children (generally in the form of strings) from the temporary
|
||||
// holding array, then run them through m.fragment to convert them to vnodes. Usually this will just give us a
|
||||
// text vnode, but using m.fragment as opposed to an explicit conversion should be more flexible. This is necessary because
|
||||
// otherwise, our generated vnode will have raw strings as its children, and mithril expects vnodes.
|
||||
// Finally, we add the now-processed vnodes back onto the holding array (which is the same object in memory as the
|
||||
// children array of the vnode we are currently processing), and remove the reference to the holding array so that
|
||||
// further text will be added to the full set of returned elements.
|
||||
const rawChildren = open[0].splice(0, open[0].length);
|
||||
open[0].push(...m.fragment(rawChildren).children);
|
||||
open.shift();
|
||||
} else {
|
||||
// If a vnode with a matching tag was provided in the translator input, we use that. Otherwise, we create a new vnode
|
||||
// with this tag, and an empty children array (since we're expecting to insert children, as that's the point of having this in translator)
|
||||
let tag = input[match[3]] || { tag: match[3], children: [] };
|
||||
open[0].push(tag);
|
||||
// Insert the tag's children array as the first element of open, so that text in between the opening
|
||||
// and closing tags will be added to the tag's children, not to the full set of returned elements.
|
||||
open.unshift(tag.children || tag);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Not an html tag, we add it to open[0], which is either the full set of returned elements (vnodes and text),
|
||||
// or if an html tag is currently being processed, the children attribute of that html tag's vnode.
|
||||
open[0].push(part);
|
||||
}
|
||||
});
|
||||
|
||||
return hydrated.filter((part) => part);
|
||||
}
|
||||
|
||||
pluralize(translation, number) {
|
||||
const sPluralRegex = new RegExp(/^\w+\: +(.+)$/),
|
||||
cPluralRegex = new RegExp(/^\s*((\{\s*(\-?\d+[\s*,\s*\-?\d+]*)\s*\})|([\[\]])\s*(-Inf|\-?\d+)\s*,\s*(\+?Inf|\-?\d+)\s*([\[\]]))\s?(.+?)$/),
|
||||
iPluralRegex = new RegExp(/^\s*(\{\s*(\-?\d+[\s*,\s*\-?\d+]*)\s*\})|([\[\]])\s*(-Inf|\-?\d+)\s*,\s*(\+?Inf|\-?\d+)\s*([\[\]])/),
|
||||
standardRules = [],
|
||||
explicitRules = [];
|
||||
|
||||
translation.split('|').forEach((part) => {
|
||||
if (cPluralRegex.test(part)) {
|
||||
const matches = part.match(cPluralRegex);
|
||||
explicitRules[matches[0]] = matches[matches.length - 1];
|
||||
} else if (sPluralRegex.test(part)) {
|
||||
const matches = part.match(sPluralRegex);
|
||||
standardRules.push(matches[1]);
|
||||
} else {
|
||||
standardRules.push(part);
|
||||
}
|
||||
});
|
||||
|
||||
explicitRules.forEach((rule, e) => {
|
||||
if (iPluralRegex.test(e)) {
|
||||
const matches = e.match(iPluralRegex);
|
||||
|
||||
if (matches[1]) {
|
||||
const ns = matches[2].split(',');
|
||||
|
||||
for (let n in ns) {
|
||||
if (number == ns[n]) {
|
||||
return explicitRules[e];
|
||||
}
|
||||
}
|
||||
} else {
|
||||
var leftNumber = this.convertNumber(matches[4]);
|
||||
var rightNumber = this.convertNumber(matches[5]);
|
||||
|
||||
if (
|
||||
('[' === matches[3] ? number >= leftNumber : number > leftNumber) &&
|
||||
(']' === matches[6] ? number <= rightNumber : number < rightNumber)
|
||||
) {
|
||||
return explicitRules[e];
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return standardRules[this.pluralPosition(number, this.locale)] || standardRules[0] || undefined;
|
||||
}
|
||||
|
||||
convertNumber(number) {
|
||||
if ('-Inf' === number) {
|
||||
return Number.NEGATIVE_INFINITY;
|
||||
} else if ('+Inf' === number || 'Inf' === number) {
|
||||
return Number.POSITIVE_INFINITY;
|
||||
}
|
||||
|
||||
return parseInt(number, 10);
|
||||
}
|
||||
|
||||
pluralPosition(number, locale) {
|
||||
if ('pt_BR' === locale) {
|
||||
locale = 'xbr';
|
||||
}
|
||||
|
||||
if (locale.length > 3) {
|
||||
locale = locale.split('_')[0];
|
||||
}
|
||||
|
||||
switch (locale) {
|
||||
case 'bo':
|
||||
case 'dz':
|
||||
case 'id':
|
||||
case 'ja':
|
||||
case 'jv':
|
||||
case 'ka':
|
||||
case 'km':
|
||||
case 'kn':
|
||||
case 'ko':
|
||||
case 'ms':
|
||||
case 'th':
|
||||
case 'vi':
|
||||
case 'zh':
|
||||
return 0;
|
||||
|
||||
case 'af':
|
||||
case 'az':
|
||||
case 'bn':
|
||||
case 'bg':
|
||||
case 'ca':
|
||||
case 'da':
|
||||
case 'de':
|
||||
case 'el':
|
||||
case 'en':
|
||||
case 'eo':
|
||||
case 'es':
|
||||
case 'et':
|
||||
case 'eu':
|
||||
case 'fa':
|
||||
case 'fi':
|
||||
case 'fo':
|
||||
case 'fur':
|
||||
case 'fy':
|
||||
case 'gl':
|
||||
case 'gu':
|
||||
case 'ha':
|
||||
case 'he':
|
||||
case 'hu':
|
||||
case 'is':
|
||||
case 'it':
|
||||
case 'ku':
|
||||
case 'lb':
|
||||
case 'ml':
|
||||
case 'mn':
|
||||
case 'mr':
|
||||
case 'nah':
|
||||
case 'nb':
|
||||
case 'ne':
|
||||
case 'nl':
|
||||
case 'nn':
|
||||
case 'no':
|
||||
case 'om':
|
||||
case 'or':
|
||||
case 'pa':
|
||||
case 'pap':
|
||||
case 'ps':
|
||||
case 'pt':
|
||||
case 'so':
|
||||
case 'sq':
|
||||
case 'sv':
|
||||
case 'sw':
|
||||
case 'ta':
|
||||
case 'te':
|
||||
case 'tk':
|
||||
case 'tr':
|
||||
case 'ur':
|
||||
case 'zu':
|
||||
return number == 1 ? 0 : 1;
|
||||
|
||||
case 'am':
|
||||
case 'bh':
|
||||
case 'fil':
|
||||
case 'fr':
|
||||
case 'gun':
|
||||
case 'hi':
|
||||
case 'ln':
|
||||
case 'mg':
|
||||
case 'nso':
|
||||
case 'xbr':
|
||||
case 'ti':
|
||||
case 'wa':
|
||||
return number === 0 || number == 1 ? 0 : 1;
|
||||
|
||||
case 'be':
|
||||
case 'bs':
|
||||
case 'hr':
|
||||
case 'ru':
|
||||
case 'sr':
|
||||
case 'uk':
|
||||
return number % 10 == 1 && number % 100 != 11 ? 0 : number % 10 >= 2 && number % 10 <= 4 && (number % 100 < 10 || number % 100 >= 20) ? 1 : 2;
|
||||
|
||||
case 'cs':
|
||||
case 'sk':
|
||||
return number == 1 ? 0 : number >= 2 && number <= 4 ? 1 : 2;
|
||||
|
||||
case 'ga':
|
||||
return number == 1 ? 0 : number == 2 ? 1 : 2;
|
||||
|
||||
case 'lt':
|
||||
return number % 10 == 1 && number % 100 != 11 ? 0 : number % 10 >= 2 && (number % 100 < 10 || number % 100 >= 20) ? 1 : 2;
|
||||
|
||||
case 'sl':
|
||||
return number % 100 == 1 ? 0 : number % 100 == 2 ? 1 : number % 100 == 3 || number % 100 == 4 ? 2 : 3;
|
||||
|
||||
case 'mk':
|
||||
return number % 10 == 1 ? 0 : 1;
|
||||
|
||||
case 'mt':
|
||||
return number == 1 ? 0 : number === 0 || (number % 100 > 1 && number % 100 < 11) ? 1 : number % 100 > 10 && number % 100 < 20 ? 2 : 3;
|
||||
|
||||
case 'lv':
|
||||
return number === 0 ? 0 : number % 10 == 1 && number % 100 != 11 ? 1 : 2;
|
||||
|
||||
case 'pl':
|
||||
return number == 1 ? 0 : number % 10 >= 2 && number % 10 <= 4 && (number % 100 < 12 || number % 100 > 14) ? 1 : 2;
|
||||
|
||||
case 'cy':
|
||||
return number == 1 ? 0 : number == 2 ? 1 : number == 8 || number == 11 ? 2 : 3;
|
||||
|
||||
case 'ro':
|
||||
return number == 1 ? 0 : number === 0 || (number % 100 > 0 && number % 100 < 20) ? 1 : 2;
|
||||
|
||||
case 'ar':
|
||||
return number === 0 ? 0 : number == 1 ? 1 : number == 2 ? 2 : number >= 3 && number <= 10 ? 3 : number >= 11 && number <= 99 ? 4 : 5;
|
||||
|
||||
default:
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
}
|
281
js/src/common/Translator.ts
Normal file
281
js/src/common/Translator.ts
Normal file
@@ -0,0 +1,281 @@
|
||||
import extract from './utils/extract';
|
||||
import extractText from './utils/extractText';
|
||||
import username from './helpers/username';
|
||||
|
||||
type Translations = { [key: string]: string };
|
||||
|
||||
export default class Translator {
|
||||
/**
|
||||
* A map of translation keys to their translated values.
|
||||
*/
|
||||
translations: Translations = {};
|
||||
locale?: string;
|
||||
|
||||
addTranslations(translations) {
|
||||
Object.assign(this.translations, translations);
|
||||
}
|
||||
|
||||
trans(id: string, parameters?: any): string | any[] {
|
||||
const translation = this.translations[id];
|
||||
|
||||
if (translation) {
|
||||
return this.apply(translation, parameters || {});
|
||||
}
|
||||
|
||||
return id;
|
||||
}
|
||||
|
||||
transText(id: string, parameters?: any): string {
|
||||
return extractText(this.trans(id, parameters));
|
||||
}
|
||||
|
||||
transChoice(id: string, number: number, parameters: any): string | any[] {
|
||||
let translation: string = this.translations[id];
|
||||
|
||||
if (translation) {
|
||||
translation = this.pluralize(translation, number);
|
||||
|
||||
return this.apply(translation, parameters || {});
|
||||
}
|
||||
|
||||
return id;
|
||||
}
|
||||
|
||||
apply(translation: string, input: any) {
|
||||
if ('user' in input) {
|
||||
const user = extract(input, 'user');
|
||||
if (!input.username) input.username = username(user);
|
||||
}
|
||||
|
||||
const parts = translation.split(new RegExp('({[a-z0-9_]+}|</?[a-z0-9_]+>)', 'gi'));
|
||||
|
||||
const hydrated: any[] = [];
|
||||
const open: any[][] = [hydrated];
|
||||
|
||||
parts.forEach((part) => {
|
||||
const match = part.match(new RegExp('{([a-z0-9_]+)}|<(/?)([a-z0-9_]+)>', 'i'));
|
||||
|
||||
if (match) {
|
||||
if (match[1]) {
|
||||
open[0].push(input[match[1]]);
|
||||
} else if (match[3]) {
|
||||
if (match[2]) {
|
||||
open.shift();
|
||||
} else {
|
||||
let tag = input[match[3]] || { tag: match[3], children: [] };
|
||||
open[0].push(tag);
|
||||
open.unshift(tag.children || tag);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
open[0].push({ tag: 'span', text: part });
|
||||
}
|
||||
});
|
||||
|
||||
return hydrated.filter((part) => part);
|
||||
}
|
||||
|
||||
pluralize(translation: string, number: number): string | undefined {
|
||||
const sPluralRegex = new RegExp(/^\w+\: +(.+)$/),
|
||||
cPluralRegex = new RegExp(
|
||||
/^\s*((\{\s*(\-?\d+[\s*,\s*\-?\d+]*)\s*\})|([\[\]])\s*(-Inf|\-?\d+)\s*,\s*(\+?Inf|\-?\d+)\s*([\[\]]))\s?(.+?)$/
|
||||
),
|
||||
iPluralRegex = new RegExp(/^\s*(\{\s*(\-?\d+[\s*,\s*\-?\d+]*)\s*\})|([\[\]])\s*(-Inf|\-?\d+)\s*,\s*(\+?Inf|\-?\d+)\s*([\[\]])/),
|
||||
standardRules = [],
|
||||
explicitRules = [];
|
||||
|
||||
translation.split('|').forEach((part) => {
|
||||
if (cPluralRegex.test(part)) {
|
||||
const matches = part.match(cPluralRegex);
|
||||
explicitRules[matches[0]] = matches[matches.length - 1];
|
||||
} else if (sPluralRegex.test(part)) {
|
||||
const matches = part.match(sPluralRegex);
|
||||
standardRules.push(matches[1]);
|
||||
} else {
|
||||
standardRules.push(part);
|
||||
}
|
||||
});
|
||||
|
||||
explicitRules.forEach((rule, e) => {
|
||||
if (iPluralRegex.test(e)) {
|
||||
const matches = e.match(iPluralRegex);
|
||||
|
||||
if (matches[1]) {
|
||||
const ns = matches[2].split(',');
|
||||
|
||||
for (let n in ns) {
|
||||
if (number == ns[n]) {
|
||||
return explicitRules[e];
|
||||
}
|
||||
}
|
||||
} else {
|
||||
const leftNumber = this.convertNumber(matches[4]);
|
||||
const rightNumber = this.convertNumber(matches[5]);
|
||||
|
||||
if (
|
||||
('[' === matches[3] ? number >= leftNumber : number > leftNumber) &&
|
||||
(']' === matches[6] ? number <= rightNumber : number < rightNumber)
|
||||
) {
|
||||
return explicitRules[e];
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return standardRules[this.pluralPosition(number, this.locale)] || standardRules[0] || undefined;
|
||||
}
|
||||
|
||||
convertNumber(number: string): number {
|
||||
if ('-Inf' === number) {
|
||||
return Number.NEGATIVE_INFINITY;
|
||||
} else if ('+Inf' === number || 'Inf' === number) {
|
||||
return Number.POSITIVE_INFINITY;
|
||||
}
|
||||
|
||||
return parseInt(number, 10);
|
||||
}
|
||||
|
||||
pluralPosition(number: number, locale: string): number {
|
||||
if ('pt_BR' === locale) {
|
||||
locale = 'xbr';
|
||||
}
|
||||
|
||||
if (locale.length > 3) {
|
||||
locale = locale.split('_')[0];
|
||||
}
|
||||
|
||||
switch (locale) {
|
||||
case 'bo':
|
||||
case 'dz':
|
||||
case 'id':
|
||||
case 'ja':
|
||||
case 'jv':
|
||||
case 'ka':
|
||||
case 'km':
|
||||
case 'kn':
|
||||
case 'ko':
|
||||
case 'ms':
|
||||
case 'th':
|
||||
case 'vi':
|
||||
case 'zh':
|
||||
return 0;
|
||||
|
||||
case 'af':
|
||||
case 'az':
|
||||
case 'bn':
|
||||
case 'bg':
|
||||
case 'ca':
|
||||
case 'da':
|
||||
case 'de':
|
||||
case 'el':
|
||||
case 'en':
|
||||
case 'eo':
|
||||
case 'es':
|
||||
case 'et':
|
||||
case 'eu':
|
||||
case 'fa':
|
||||
case 'fi':
|
||||
case 'fo':
|
||||
case 'fur':
|
||||
case 'fy':
|
||||
case 'gl':
|
||||
case 'gu':
|
||||
case 'ha':
|
||||
case 'he':
|
||||
case 'hu':
|
||||
case 'is':
|
||||
case 'it':
|
||||
case 'ku':
|
||||
case 'lb':
|
||||
case 'ml':
|
||||
case 'mn':
|
||||
case 'mr':
|
||||
case 'nah':
|
||||
case 'nb':
|
||||
case 'ne':
|
||||
case 'nl':
|
||||
case 'nn':
|
||||
case 'no':
|
||||
case 'om':
|
||||
case 'or':
|
||||
case 'pa':
|
||||
case 'pap':
|
||||
case 'ps':
|
||||
case 'pt':
|
||||
case 'so':
|
||||
case 'sq':
|
||||
case 'sv':
|
||||
case 'sw':
|
||||
case 'ta':
|
||||
case 'te':
|
||||
case 'tk':
|
||||
case 'tr':
|
||||
case 'ur':
|
||||
case 'zu':
|
||||
return number == 1 ? 0 : 1;
|
||||
|
||||
case 'am':
|
||||
case 'bh':
|
||||
case 'fil':
|
||||
case 'fr':
|
||||
case 'gun':
|
||||
case 'hi':
|
||||
case 'ln':
|
||||
case 'mg':
|
||||
case 'nso':
|
||||
case 'xbr':
|
||||
case 'ti':
|
||||
case 'wa':
|
||||
return number === 0 || number == 1 ? 0 : 1;
|
||||
|
||||
case 'be':
|
||||
case 'bs':
|
||||
case 'hr':
|
||||
case 'ru':
|
||||
case 'sr':
|
||||
case 'uk':
|
||||
return number % 10 == 1 && number % 100 != 11
|
||||
? 0
|
||||
: number % 10 >= 2 && number % 10 <= 4 && (number % 100 < 10 || number % 100 >= 20)
|
||||
? 1
|
||||
: 2;
|
||||
|
||||
case 'cs':
|
||||
case 'sk':
|
||||
return number == 1 ? 0 : number >= 2 && number <= 4 ? 1 : 2;
|
||||
|
||||
case 'ga':
|
||||
return number == 1 ? 0 : number == 2 ? 1 : 2;
|
||||
|
||||
case 'lt':
|
||||
return number % 10 == 1 && number % 100 != 11 ? 0 : number % 10 >= 2 && (number % 100 < 10 || number % 100 >= 20) ? 1 : 2;
|
||||
|
||||
case 'sl':
|
||||
return number % 100 == 1 ? 0 : number % 100 == 2 ? 1 : number % 100 == 3 || number % 100 == 4 ? 2 : 3;
|
||||
|
||||
case 'mk':
|
||||
return number % 10 == 1 ? 0 : 1;
|
||||
|
||||
case 'mt':
|
||||
return number == 1 ? 0 : number === 0 || (number % 100 > 1 && number % 100 < 11) ? 1 : number % 100 > 10 && number % 100 < 20 ? 2 : 3;
|
||||
|
||||
case 'lv':
|
||||
return number === 0 ? 0 : number % 10 == 1 && number % 100 != 11 ? 1 : 2;
|
||||
|
||||
case 'pl':
|
||||
return number == 1 ? 0 : number % 10 >= 2 && number % 10 <= 4 && (number % 100 < 12 || number % 100 > 14) ? 1 : 2;
|
||||
|
||||
case 'cy':
|
||||
return number == 1 ? 0 : number == 2 ? 1 : number == 8 || number == 11 ? 2 : 3;
|
||||
|
||||
case 'ro':
|
||||
return number == 1 ? 0 : number === 0 || (number % 100 > 0 && number % 100 < 20) ? 1 : 2;
|
||||
|
||||
case 'ar':
|
||||
return number === 0 ? 0 : number == 1 ? 1 : number == 2 ? 2 : number >= 3 && number <= 10 ? 3 : number >= 11 && number <= 99 ? 4 : 5;
|
||||
|
||||
default:
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
}
|
@@ -1,137 +0,0 @@
|
||||
import * as extend from './extend';
|
||||
import Session from './Session';
|
||||
import Store from './Store';
|
||||
import evented from './utils/evented';
|
||||
import liveHumanTimes from './utils/liveHumanTimes';
|
||||
import ItemList from './utils/ItemList';
|
||||
import mixin from './utils/mixin';
|
||||
import humanTime from './utils/humanTime';
|
||||
import computed from './utils/computed';
|
||||
import Drawer from './utils/Drawer';
|
||||
import anchorScroll from './utils/anchorScroll';
|
||||
import RequestError from './utils/RequestError';
|
||||
import abbreviateNumber from './utils/abbreviateNumber';
|
||||
import * as string from './utils/string';
|
||||
import SubtreeRetainer from './utils/SubtreeRetainer';
|
||||
import setRouteWithForcedRefresh from './utils/setRouteWithForcedRefresh';
|
||||
import extract from './utils/extract';
|
||||
import ScrollListener from './utils/ScrollListener';
|
||||
import stringToColor from './utils/stringToColor';
|
||||
import subclassOf from './utils/subclassOf';
|
||||
import patchMithril from './utils/patchMithril';
|
||||
import classList from './utils/classList';
|
||||
import extractText from './utils/extractText';
|
||||
import formatNumber from './utils/formatNumber';
|
||||
import mapRoutes from './utils/mapRoutes';
|
||||
import withAttr from './utils/withAttr';
|
||||
import Notification from './models/Notification';
|
||||
import User from './models/User';
|
||||
import Post from './models/Post';
|
||||
import Discussion from './models/Discussion';
|
||||
import Group from './models/Group';
|
||||
import Forum from './models/Forum';
|
||||
import Component from './Component';
|
||||
import Translator from './Translator';
|
||||
import AlertManager from './components/AlertManager';
|
||||
import Page from './components/Page';
|
||||
import Switch from './components/Switch';
|
||||
import Badge from './components/Badge';
|
||||
import LoadingIndicator from './components/LoadingIndicator';
|
||||
import Placeholder from './components/Placeholder';
|
||||
import Separator from './components/Separator';
|
||||
import Dropdown from './components/Dropdown';
|
||||
import SplitDropdown from './components/SplitDropdown';
|
||||
import RequestErrorModal from './components/RequestErrorModal';
|
||||
import FieldSet from './components/FieldSet';
|
||||
import Select from './components/Select';
|
||||
import Navigation from './components/Navigation';
|
||||
import Alert from './components/Alert';
|
||||
import LinkButton from './components/LinkButton';
|
||||
import Checkbox from './components/Checkbox';
|
||||
import SelectDropdown from './components/SelectDropdown';
|
||||
import ModalManager from './components/ModalManager';
|
||||
import Button from './components/Button';
|
||||
import Modal from './components/Modal';
|
||||
import GroupBadge from './components/GroupBadge';
|
||||
import Model from './Model';
|
||||
import Application from './Application';
|
||||
import fullTime from './helpers/fullTime';
|
||||
import avatar from './helpers/avatar';
|
||||
import icon from './helpers/icon';
|
||||
import humanTimeHelper from './helpers/humanTime';
|
||||
import punctuateSeries from './helpers/punctuateSeries';
|
||||
import highlight from './helpers/highlight';
|
||||
import username from './helpers/username';
|
||||
import userOnline from './helpers/userOnline';
|
||||
import listItems from './helpers/listItems';
|
||||
import Fragment from './Fragment';
|
||||
|
||||
export default {
|
||||
extend: extend,
|
||||
Session: Session,
|
||||
Store: Store,
|
||||
'utils/evented': evented,
|
||||
'utils/liveHumanTimes': liveHumanTimes,
|
||||
'utils/ItemList': ItemList,
|
||||
'utils/mixin': mixin,
|
||||
'utils/humanTime': humanTime,
|
||||
'utils/computed': computed,
|
||||
'utils/Drawer': Drawer,
|
||||
'utils/anchorScroll': anchorScroll,
|
||||
'utils/RequestError': RequestError,
|
||||
'utils/abbreviateNumber': abbreviateNumber,
|
||||
'utils/string': string,
|
||||
'utils/SubtreeRetainer': SubtreeRetainer,
|
||||
'utils/extract': extract,
|
||||
'utils/ScrollListener': ScrollListener,
|
||||
'utils/stringToColor': stringToColor,
|
||||
'utils/subclassOf': subclassOf,
|
||||
'utils/setRouteWithForcedRefresh': setRouteWithForcedRefresh,
|
||||
'utils/patchMithril': patchMithril,
|
||||
'utils/classList': classList,
|
||||
'utils/extractText': extractText,
|
||||
'utils/formatNumber': formatNumber,
|
||||
'utils/mapRoutes': mapRoutes,
|
||||
'utils/withAttr': withAttr,
|
||||
'models/Notification': Notification,
|
||||
'models/User': User,
|
||||
'models/Post': Post,
|
||||
'models/Discussion': Discussion,
|
||||
'models/Group': Group,
|
||||
'models/Forum': Forum,
|
||||
Component: Component,
|
||||
Fragment: Fragment,
|
||||
Translator: Translator,
|
||||
'components/AlertManager': AlertManager,
|
||||
'components/Page': Page,
|
||||
'components/Switch': Switch,
|
||||
'components/Badge': Badge,
|
||||
'components/LoadingIndicator': LoadingIndicator,
|
||||
'components/Placeholder': Placeholder,
|
||||
'components/Separator': Separator,
|
||||
'components/Dropdown': Dropdown,
|
||||
'components/SplitDropdown': SplitDropdown,
|
||||
'components/RequestErrorModal': RequestErrorModal,
|
||||
'components/FieldSet': FieldSet,
|
||||
'components/Select': Select,
|
||||
'components/Navigation': Navigation,
|
||||
'components/Alert': Alert,
|
||||
'components/LinkButton': LinkButton,
|
||||
'components/Checkbox': Checkbox,
|
||||
'components/SelectDropdown': SelectDropdown,
|
||||
'components/ModalManager': ModalManager,
|
||||
'components/Button': Button,
|
||||
'components/Modal': Modal,
|
||||
'components/GroupBadge': GroupBadge,
|
||||
Model: Model,
|
||||
Application: Application,
|
||||
'helpers/fullTime': fullTime,
|
||||
'helpers/avatar': avatar,
|
||||
'helpers/icon': icon,
|
||||
'helpers/humanTime': humanTimeHelper,
|
||||
'helpers/punctuateSeries': punctuateSeries,
|
||||
'helpers/highlight': highlight,
|
||||
'helpers/username': username,
|
||||
'helpers/userOnline': userOnline,
|
||||
'helpers/listItems': listItems,
|
||||
};
|
129
js/src/common/compat.ts
Normal file
129
js/src/common/compat.ts
Normal file
@@ -0,0 +1,129 @@
|
||||
import Application from './Application';
|
||||
import Component from './Component';
|
||||
import * as extend from './extend';
|
||||
import Model from './Model';
|
||||
import Session from './Session';
|
||||
import Store from './Store';
|
||||
import Translator from './Translator';
|
||||
|
||||
import Evented from './utils/Evented';
|
||||
// import liveHumanTimes from './utils/liveHumanTimes';
|
||||
import ItemList from './utils/ItemList';
|
||||
import humanTime from './utils/humanTime';
|
||||
import computed from './utils/computed';
|
||||
import Drawer from './utils/Drawer';
|
||||
import anchorScroll from './utils/anchorScroll';
|
||||
import RequestError from './utils/RequestError';
|
||||
import abbreviateNumber from './utils/abbreviateNumber';
|
||||
import * as string from './utils/string';
|
||||
import SubtreeRetainer from './utils/SubtreeRetainer';
|
||||
import extract from './utils/extract';
|
||||
import ScrollListener from './utils/ScrollListener';
|
||||
import stringToColor from './utils/stringToColor';
|
||||
import patchMithril from './utils/patchMithril';
|
||||
import extractText from './utils/extractText';
|
||||
import formatNumber from './utils/formatNumber';
|
||||
import mapRoutes from './utils/mapRoutes';
|
||||
|
||||
import Notification from './models/Notification';
|
||||
import User from './models/User';
|
||||
import Post from './models/Post';
|
||||
import Discussion from './models/Discussion';
|
||||
import Group from './models/Group';
|
||||
import Forum from './models/Forum';
|
||||
|
||||
import AlertManager from './components/AlertManager';
|
||||
import Switch from './components/Switch';
|
||||
import Badge from './components/Badge';
|
||||
import LoadingIndicator from './components/LoadingIndicator';
|
||||
import Placeholder from './components/Placeholder';
|
||||
import Separator from './components/Separator';
|
||||
import Dropdown from './components/Dropdown';
|
||||
import SplitDropdown from './components/SplitDropdown';
|
||||
import RequestErrorModal from './components/RequestErrorModal';
|
||||
import FieldSet from './components/FieldSet';
|
||||
import Select from './components/Select';
|
||||
import Navigation from './components/Navigation';
|
||||
import Alert from './components/Alert';
|
||||
import LinkButton from './components/LinkButton';
|
||||
import Checkbox from './components/Checkbox';
|
||||
import SelectDropdown from './components/SelectDropdown';
|
||||
import ModalManager from './components/ModalManager';
|
||||
import Button from './components/Button';
|
||||
import Modal from './components/Modal';
|
||||
import GroupBadge from './components/GroupBadge';
|
||||
|
||||
import fullTime from './helpers/fullTime';
|
||||
import avatar from './helpers/avatar';
|
||||
import icon from './helpers/icon';
|
||||
import humanTimeHelper from './helpers/humanTime';
|
||||
// import punctuateSeries from './helpers/punctuateSeries';
|
||||
import highlight from './helpers/highlight';
|
||||
import username from './helpers/username';
|
||||
import userOnline from './helpers/userOnline';
|
||||
import listItems from './helpers/listItems';
|
||||
|
||||
export default {
|
||||
Application: Application,
|
||||
Component: Component,
|
||||
extend: extend,
|
||||
Model: Model,
|
||||
Session: Session,
|
||||
Store: Store,
|
||||
Translator: Translator,
|
||||
|
||||
'utils/Evented': Evented,
|
||||
// 'utils/liveHumanTimes': liveHumanTimes,
|
||||
'utils/ItemList': ItemList,
|
||||
'utils/humanTime': humanTime,
|
||||
'utils/computed': computed,
|
||||
'utils/Drawer': Drawer,
|
||||
'utils/anchorScroll': anchorScroll,
|
||||
'utils/RequestError': RequestError,
|
||||
'utils/abbreviateNumber': abbreviateNumber,
|
||||
'utils/string': string,
|
||||
'utils/SubtreeRetainer': SubtreeRetainer,
|
||||
'utils/extract': extract,
|
||||
'utils/ScrollListener': ScrollListener,
|
||||
'utils/stringToColor': stringToColor,
|
||||
'utils/patchMithril': patchMithril,
|
||||
'utils/extractText': extractText,
|
||||
'utils/formatNumber': formatNumber,
|
||||
'utils/mapRoutes': mapRoutes,
|
||||
'models/Notification': Notification,
|
||||
'models/User': User,
|
||||
'models/Post': Post,
|
||||
'models/Discussion': Discussion,
|
||||
'models/Group': Group,
|
||||
'models/Forum': Forum,
|
||||
'components/AlertManager': AlertManager,
|
||||
'components/Switch': Switch,
|
||||
'components/Badge': Badge,
|
||||
'components/LoadingIndicator': LoadingIndicator,
|
||||
'components/Placeholder': Placeholder,
|
||||
'components/Separator': Separator,
|
||||
'components/Dropdown': Dropdown,
|
||||
'components/SplitDropdown': SplitDropdown,
|
||||
'components/RequestErrorModal': RequestErrorModal,
|
||||
'components/FieldSet': FieldSet,
|
||||
'components/Select': Select,
|
||||
'components/Navigation': Navigation,
|
||||
'components/Alert': Alert,
|
||||
'components/LinkButton': LinkButton,
|
||||
'components/Checkbox': Checkbox,
|
||||
'components/SelectDropdown': SelectDropdown,
|
||||
'components/ModalManager': ModalManager,
|
||||
'components/Button': Button,
|
||||
'components/Modal': Modal,
|
||||
'components/GroupBadge': GroupBadge,
|
||||
|
||||
'helpers/fullTime': fullTime,
|
||||
'helpers/avatar': avatar,
|
||||
'helpers/icon': icon,
|
||||
'helpers/humanTime': humanTimeHelper,
|
||||
// 'helpers/punctuateSeries': punctuateSeries,
|
||||
'helpers/highlight': highlight,
|
||||
'helpers/username': username,
|
||||
'helpers/userOnline': userOnline,
|
||||
'helpers/listItems': listItems,
|
||||
};
|
@@ -1,48 +0,0 @@
|
||||
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.
|
||||
*
|
||||
* ### Attrs
|
||||
*
|
||||
* - `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 attrs will be assigned as attributes on the DOM element.
|
||||
*/
|
||||
export default class Alert extends Component {
|
||||
view(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') || [];
|
||||
|
||||
// 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>
|
||||
);
|
||||
}
|
||||
}
|
71
js/src/common/components/Alert.tsx
Normal file
71
js/src/common/components/Alert.tsx
Normal file
@@ -0,0 +1,71 @@
|
||||
import * as Mithril from 'mithril';
|
||||
|
||||
import Component, { ComponentProps } from '../Component';
|
||||
import Button from './Button';
|
||||
import listItems from '../helpers/listItems';
|
||||
import extract from '../utils/extract';
|
||||
import AlertState from '../states/AlertState';
|
||||
|
||||
export interface AlertData extends ComponentProps {
|
||||
/**
|
||||
* An array of controls to show in the alert.
|
||||
*/
|
||||
controls?: Mithril.ChildArray;
|
||||
|
||||
/**
|
||||
* The type of alert this is. Will be used to give the alert a class
|
||||
* name of `Alert--{type}`.
|
||||
*/
|
||||
type?: string;
|
||||
|
||||
/**
|
||||
* Whether or not the alert can be dismissed.
|
||||
*/
|
||||
dismissible?: boolean;
|
||||
|
||||
/**
|
||||
* A callback to run when the alert is dismissed.
|
||||
*/
|
||||
ondismiss?: () => any;
|
||||
}
|
||||
|
||||
export interface AlertProps extends AlertData {
|
||||
state: AlertState;
|
||||
}
|
||||
|
||||
/**
|
||||
* The `Alert` component represents an alert box, which contains a message,
|
||||
* some controls, and may be dismissible.
|
||||
*
|
||||
* All other props will be assigned as attributes on the alert element.
|
||||
*/
|
||||
export default class Alert extends Component<AlertProps> {
|
||||
view() {
|
||||
const data = this.props.state?.data || this.props;
|
||||
const attrs: AlertData = Object.assign({}, data);
|
||||
|
||||
const type: string = extract(attrs, 'type');
|
||||
attrs.className = `Alert Alert--${type} ${attrs.className || ''}`;
|
||||
|
||||
const children: Mithril.Children = extract(attrs, 'children');
|
||||
const controls: Mithril.ChildArray = 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: boolean | undefined = extract(attrs, 'dismissible');
|
||||
const ondismiss: () => any = extract(attrs, 'ondismiss');
|
||||
const dismissControl: JSX.Element[] = [];
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
}
|
@@ -1,28 +0,0 @@
|
||||
import Component from '../Component';
|
||||
import Alert from './Alert';
|
||||
|
||||
/**
|
||||
* The `AlertManager` component provides an area in which `Alert` components can
|
||||
* be shown and dismissed.
|
||||
*/
|
||||
export default class AlertManager extends Component {
|
||||
oninit(vnode) {
|
||||
super.oninit(vnode);
|
||||
|
||||
this.state = this.attrs.state;
|
||||
}
|
||||
|
||||
view() {
|
||||
return (
|
||||
<div className="AlertManager">
|
||||
{Object.entries(this.state.getActiveAlerts()).map(([key, alert]) => (
|
||||
<div className="AlertManager-alert">
|
||||
<alert.componentClass {...alert.attrs} ondismiss={this.state.dismiss.bind(this.state, key)}>
|
||||
{alert.children}
|
||||
</alert.componentClass>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
62
js/src/common/components/AlertManager.tsx
Normal file
62
js/src/common/components/AlertManager.tsx
Normal file
@@ -0,0 +1,62 @@
|
||||
import Component from '../Component';
|
||||
import AlertState from '../states/AlertState';
|
||||
import Alert, { AlertData } from './Alert';
|
||||
|
||||
/**
|
||||
* The `AlertManager` component provides an area in which `Alert` components can
|
||||
* be shown and dismissed.
|
||||
*/
|
||||
export default class AlertManager extends Component {
|
||||
/**
|
||||
* An array of Alert components which are currently showing.
|
||||
*/
|
||||
protected states: AlertState[] = [];
|
||||
|
||||
view() {
|
||||
return (
|
||||
<div className="AlertManager">
|
||||
{this.states.map((state) => (
|
||||
<div className="AlertManager-alert">
|
||||
<Alert state={state} ondismiss={this.dismiss.bind(this)} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Show an Alert in the alerts area.
|
||||
*/
|
||||
public show(state: AlertState | AlertData): number {
|
||||
if (!(state instanceof AlertState)) state = new AlertState(state);
|
||||
|
||||
this.states.push(state as AlertState);
|
||||
m.redraw();
|
||||
|
||||
return state.key;
|
||||
}
|
||||
|
||||
/**
|
||||
* Dismiss an alert.
|
||||
*/
|
||||
public dismiss(keyOrState?: AlertState | number) {
|
||||
if (!keyOrState) return;
|
||||
|
||||
const key = keyOrState instanceof AlertState ? keyOrState.key : keyOrState;
|
||||
|
||||
let index = this.states.indexOf(this.states.filter((a) => a.key == key)[0]);
|
||||
|
||||
if (index !== -1) {
|
||||
this.states.splice(index, 1);
|
||||
m.redraw();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all alerts.
|
||||
*/
|
||||
public clear() {
|
||||
this.states = [];
|
||||
m.redraw();
|
||||
}
|
||||
}
|
@@ -1,35 +0,0 @@
|
||||
import Component from '../Component';
|
||||
import icon from '../helpers/icon';
|
||||
import extract from '../utils/extract';
|
||||
|
||||
/**
|
||||
* The `Badge` component represents a user/discussion badge, indicating some
|
||||
* status (e.g. a discussion is stickied, a user is an admin).
|
||||
*
|
||||
* A badge may have the following special attrs:
|
||||
*
|
||||
* - `type` The type of badge this is. This will be used to give the badge a
|
||||
* class name of `Badge--{type}`.
|
||||
* - `icon` The name of an icon to show inside the badge.
|
||||
* - `label`
|
||||
*
|
||||
* All other attrs will be assigned as attributes on the badge element.
|
||||
*/
|
||||
export default class Badge extends Component {
|
||||
view() {
|
||||
const attrs = Object.assign({}, this.attrs);
|
||||
const type = extract(attrs, 'type');
|
||||
const iconName = extract(attrs, 'icon');
|
||||
|
||||
attrs.className = 'Badge ' + (type ? 'Badge--' + type : '') + ' ' + (attrs.className || '');
|
||||
attrs.title = extract(attrs, 'label') || '';
|
||||
|
||||
return <span {...attrs}>{iconName ? icon(iconName, { className: 'Badge-icon' }) : m.trust(' ')}</span>;
|
||||
}
|
||||
|
||||
oncreate(vnode) {
|
||||
super.oncreate(vnode);
|
||||
|
||||
if (this.attrs.label) this.$().tooltip();
|
||||
}
|
||||
}
|
35
js/src/common/components/Badge.tsx
Normal file
35
js/src/common/components/Badge.tsx
Normal file
@@ -0,0 +1,35 @@
|
||||
import Component from '../Component';
|
||||
import icon from '../helpers/icon';
|
||||
import extract from '../utils/extract';
|
||||
|
||||
/**
|
||||
* The `Badge` component represents a user/discussion badge, indicating some
|
||||
* status (e.g. a discussion is stickied, a user is an admin).
|
||||
*
|
||||
* A badge may have the following special props:
|
||||
*
|
||||
* - `type` The type of badge this is. This will be used to give the badge a
|
||||
* class name of `Badge--{type}`.
|
||||
* - `icon` The name of an icon to show inside the badge.
|
||||
* - `label`
|
||||
*
|
||||
* All other props will be assigned as attributes on the badge element.
|
||||
*/
|
||||
export default class Badge extends Component {
|
||||
view(vnode) {
|
||||
const attrs = vnode.attrs;
|
||||
const type = extract(attrs, 'type');
|
||||
const iconName = extract(attrs, 'icon');
|
||||
|
||||
attrs.className = `Badge ${type ? `Badge--${type}` : ''} ${attrs.className || ''}`;
|
||||
attrs.title = extract(attrs, 'label') || '';
|
||||
|
||||
return <span {...attrs}>{iconName ? icon(iconName, { className: 'Badge-icon' }) : m.trust(' ')}</span>;
|
||||
}
|
||||
|
||||
oncreate(vnode) {
|
||||
super.oncreate(vnode);
|
||||
|
||||
if (this.props.label) this.$().tooltip({ container: 'body' });
|
||||
}
|
||||
}
|
@@ -1,70 +0,0 @@
|
||||
import Component from '../Component';
|
||||
import icon from '../helpers/icon';
|
||||
import classList from '../utils/classList';
|
||||
import extract from '../utils/extract';
|
||||
import extractText from '../utils/extractText';
|
||||
import LoadingIndicator from './LoadingIndicator';
|
||||
|
||||
/**
|
||||
* The `Button` component defines an element which, when clicked, performs an
|
||||
* action.
|
||||
*
|
||||
* ### Attrs
|
||||
*
|
||||
* - `icon` The name of the icon class. If specified, the button will be given a
|
||||
* 'has-icon' class name.
|
||||
* - `disabled` Whether or not the button is disabled. If truthy, the button
|
||||
* will be given a 'disabled' class name, and any `onclick` handler will be
|
||||
* removed.
|
||||
* - `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.
|
||||
*
|
||||
* 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.
|
||||
*/
|
||||
export default class Button extends Component {
|
||||
view(vnode) {
|
||||
const attrs = Object.assign({}, this.attrs);
|
||||
|
||||
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 (!attrs.title && vnode.children) {
|
||||
attrs.title = extractText(vnode.children);
|
||||
}
|
||||
|
||||
const iconName = extract(attrs, 'icon');
|
||||
|
||||
const loading = extract(attrs, 'loading');
|
||||
if (attrs.disabled || loading) {
|
||||
delete attrs.onclick;
|
||||
}
|
||||
|
||||
attrs.className = classList([attrs.className, iconName && 'hasIcon', (attrs.disabled || loading) && 'disabled', loading && 'loading']);
|
||||
|
||||
return <button {...attrs}>{this.getButtonContent(vnode.children)}</button>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the template for the button's content.
|
||||
*
|
||||
* @return {*}
|
||||
* @protected
|
||||
*/
|
||||
getButtonContent(children) {
|
||||
const iconName = this.attrs.icon;
|
||||
|
||||
return [
|
||||
iconName && iconName !== true ? icon(iconName, { className: 'Button-icon' }) : '',
|
||||
children ? <span className="Button-label">{children}</span> : '',
|
||||
this.attrs.loading ? <LoadingIndicator size="tiny" className="LoadingIndicator--inline" /> : '',
|
||||
];
|
||||
}
|
||||
}
|
90
js/src/common/components/Button.tsx
Normal file
90
js/src/common/components/Button.tsx
Normal file
@@ -0,0 +1,90 @@
|
||||
import Component, { ComponentProps } from '../Component';
|
||||
import icon from '../helpers/icon';
|
||||
import extract from '../utils/extract';
|
||||
import extractText from '../utils/extractText';
|
||||
import LoadingIndicator from './LoadingIndicator';
|
||||
|
||||
export interface ButtonProps extends ComponentProps {
|
||||
/**
|
||||
* A tooltip for the button.
|
||||
*/
|
||||
title?: string;
|
||||
|
||||
/**
|
||||
* An html type attribute for the button.
|
||||
*/
|
||||
type?: string;
|
||||
|
||||
/**
|
||||
* The name of the icon class. If specified, the button will be given a
|
||||
* 'has-icon' class name.
|
||||
*/
|
||||
icon?: string;
|
||||
|
||||
/**
|
||||
* Whether or not the button should be in a disabled loading state.
|
||||
*/
|
||||
loading?: boolean;
|
||||
|
||||
/**
|
||||
* Whether or not the button is disabled. If truthy, the button
|
||||
* will be given a 'disabled' class name, and any `onclick` handler will be
|
||||
* removed.
|
||||
*/
|
||||
disabled?: boolean;
|
||||
|
||||
/**
|
||||
* A callback to run when the button is clicked.
|
||||
*/
|
||||
onclick?: Function;
|
||||
}
|
||||
|
||||
/**
|
||||
* The `Button` component defines an element which, when clicked, performs an
|
||||
* action.
|
||||
*
|
||||
* 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.
|
||||
*/
|
||||
export default class Button<T extends ButtonProps = ButtonProps> extends Component<T> {
|
||||
view() {
|
||||
const attrs = (({ children, ...o }) => o)(this.props) as T;
|
||||
const children = this.props.children;
|
||||
|
||||
attrs.className = attrs.className || '';
|
||||
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 && !children) {
|
||||
attrs['aria-label'] = attrs.title;
|
||||
}
|
||||
|
||||
// If nothing else is provided, we use the textual button content as tooltip
|
||||
if (!attrs.title && children) {
|
||||
attrs.title = extractText(children);
|
||||
}
|
||||
|
||||
const iconName = extract(attrs, 'icon');
|
||||
if (iconName) attrs.className += ' hasIcon';
|
||||
|
||||
const loading = extract(attrs, 'loading');
|
||||
if (attrs.disabled || loading) {
|
||||
attrs.className = classNames(attrs.className, 'disabled', loading && 'loading');
|
||||
delete attrs.onclick;
|
||||
}
|
||||
|
||||
return <button {...attrs}>{this.getButtonContent(iconName, loading, children)}</button>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the template for the button's content.
|
||||
*/
|
||||
protected getButtonContent(iconName?: string | boolean, loading?: boolean, children?: any): any[] {
|
||||
return [
|
||||
iconName && iconName !== true ? icon(iconName, { className: 'Button-icon' }) : '',
|
||||
children ? <span className="Button-label">{children}</span> : '',
|
||||
loading ? LoadingIndicator.component({ size: 'tiny', className: 'LoadingIndicator--inline' }) : '',
|
||||
];
|
||||
}
|
||||
}
|
@@ -1,61 +0,0 @@
|
||||
import Component from '../Component';
|
||||
import LoadingIndicator from './LoadingIndicator';
|
||||
import icon from '../helpers/icon';
|
||||
import classList from '../utils/classList';
|
||||
import withAttr from '../utils/withAttr';
|
||||
|
||||
/**
|
||||
* The `Checkbox` component defines a checkbox input.
|
||||
*
|
||||
* ### Attrs
|
||||
*
|
||||
* - `state` Whether or not the checkbox is checked.
|
||||
* - `className` The class name for the root element.
|
||||
* - `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.
|
||||
* - `children` A text label to display next to the checkbox.
|
||||
*/
|
||||
export default class Checkbox extends Component {
|
||||
view(vnode) {
|
||||
// Sometimes, false is stored in the DB as '0'. This is a temporary
|
||||
// conversion layer until a more robust settings encoding is introduced
|
||||
if (this.attrs.state === '0') this.attrs.state = false;
|
||||
|
||||
const className = classList([
|
||||
'Checkbox',
|
||||
this.attrs.state ? 'on' : 'off',
|
||||
this.attrs.className,
|
||||
this.attrs.loading && 'loading',
|
||||
this.attrs.disabled && 'disabled',
|
||||
]);
|
||||
|
||||
return (
|
||||
<label className={className}>
|
||||
<input type="checkbox" checked={this.attrs.state} disabled={this.attrs.disabled} onchange={withAttr('checked', this.onchange.bind(this))} />
|
||||
<div className="Checkbox-display">{this.getDisplay()}</div>
|
||||
{vnode.children}
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the template for the checkbox's display (tick/cross icon).
|
||||
*
|
||||
* @return {*}
|
||||
* @protected
|
||||
*/
|
||||
getDisplay() {
|
||||
return this.attrs.loading ? <LoadingIndicator size="tiny" /> : icon(this.attrs.state ? 'fas fa-check' : 'fas fa-times');
|
||||
}
|
||||
|
||||
/**
|
||||
* Run a callback when the state of the checkbox is changed.
|
||||
*
|
||||
* @param {Boolean} checked
|
||||
* @protected
|
||||
*/
|
||||
onchange(checked) {
|
||||
if (this.attrs.onchange) this.attrs.onchange(checked, this);
|
||||
}
|
||||
}
|
67
js/src/common/components/Checkbox.tsx
Normal file
67
js/src/common/components/Checkbox.tsx
Normal file
@@ -0,0 +1,67 @@
|
||||
import Component, { ComponentProps } from '../Component';
|
||||
import LoadingIndicator from './LoadingIndicator';
|
||||
import icon from '../helpers/icon';
|
||||
|
||||
export interface CheckboxProps extends ComponentProps {
|
||||
/**
|
||||
* Whether or not the checkbox is checked
|
||||
*/
|
||||
state: boolean;
|
||||
|
||||
/**
|
||||
* Whether or not the checkbox is disabled.
|
||||
*/
|
||||
disabled: boolean;
|
||||
|
||||
/**
|
||||
* A callback to run when the checkbox is checked/unchecked.
|
||||
*/
|
||||
onchange?: Function;
|
||||
}
|
||||
|
||||
/**
|
||||
* The `Checkbox` component defines a checkbox input.
|
||||
*/
|
||||
export default class Checkbox<T extends CheckboxProps = CheckboxProps> extends Component<CheckboxProps> {
|
||||
/**
|
||||
* Whether or not the checkbox's value is in the process of being saved.
|
||||
*/
|
||||
loading = false;
|
||||
|
||||
view() {
|
||||
const className = classNames(
|
||||
'Checkbox',
|
||||
this.props.className,
|
||||
this.props.state ? 'on' : 'off',
|
||||
this.loading && 'loading',
|
||||
this.props.disabled && 'disabled'
|
||||
);
|
||||
|
||||
return (
|
||||
<label className={className}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={this.props.state}
|
||||
disabled={this.props.disabled}
|
||||
onchange={m.withAttr('checked', this.onchange.bind(this))}
|
||||
/>
|
||||
<div className="Checkbox-display">{this.getDisplay()}</div>
|
||||
{this.props.children}
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the template for the checkbox's display (tick/cross icon).
|
||||
*/
|
||||
protected getDisplay() {
|
||||
return this.loading ? LoadingIndicator.component({ size: 'tiny' }) : icon(this.props.state ? 'fas fa-check' : 'fas fa-times');
|
||||
}
|
||||
|
||||
/**
|
||||
* Run a callback when the state of the checkbox is changed.
|
||||
*/
|
||||
protected onchange(checked: boolean) {
|
||||
if (this.props.onchange) this.props.onchange(checked, this);
|
||||
}
|
||||
}
|
@@ -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];
|
||||
}
|
||||
}
|
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user