1
0
mirror of https://github.com/flarum/core.git synced 2025-08-18 06:11:23 +02:00

Compare commits

..

1 Commits

Author SHA1 Message Date
David Sevilla Martin
f6d88bf724 Create Pagination util & make DiscussionListState and DiscussionList use it
I'm 100% sure that this code can be improved by a ton. Just pushing this so we can potentially work off of it.
Some stuff taken from PR #1829.
Does *not* have changing URL query parameter - this code is already a disaster.
2021-01-10 10:56:26 -05:00
335 changed files with 7072 additions and 16868 deletions

View File

@@ -15,5 +15,5 @@ indent_size = 2
[*.{diff,md}]
trim_trailing_whitespace = false
[*.{php,xml,json}]
[*.{php,xml}]
indent_size = 4

View File

@@ -8,7 +8,7 @@ jobs:
strategy:
matrix:
php: [7.3, 7.4, '8.0']
php: [7.2, 7.3, 7.4]
service: ['mysql:5.7', mariadb]
prefix: ['', flarum_]
@@ -21,16 +21,16 @@ jobs:
prefixStr: (prefix)
exclude:
- php: 7.3
- php: 7.2
service: 'mysql:5.7'
prefix: flarum_
- php: 7.3
- php: 7.2
service: mariadb
prefix: flarum_
- php: 8.0
- php: 7.3
service: 'mysql:5.7'
prefix: flarum_
- php: 8.0
- php: 7.3
service: mariadb
prefix: flarum_
@@ -45,22 +45,13 @@ jobs:
steps:
- uses: actions/checkout@master
- name: Setup PHP
uses: shivammathur/setup-php@v2
with:
php-version: ${{ matrix.php }}
coverage: xdebug
extensions: curl, dom, gd, json, mbstring, openssl, pdo_mysql, tokenizer, zip
tools: phpunit, composer:v2
- name: Select PHP version
run: sudo update-alternatives --set php $(which php${{ matrix.php }})
# The authentication alter is necessary because newer mysql versions use the `caching_sha2_password` driver,
# which isn't supported prior to PHP7.4
# When we drop support for PHP7.3, we should remove this from the setup.
- name: Create MySQL Database
run: |
sudo systemctl start mysql
mysql -uroot -proot -e 'CREATE DATABASE flarum_test;' --port 13306
mysql -uroot -proot -e "ALTER USER 'root'@'localhost' IDENTIFIED WITH mysql_native_password BY 'root';" --port 13306
- name: Install Composer dependencies
run: composer install
@@ -74,5 +65,3 @@ jobs:
- name: Run Composer tests
run: composer test
env:
COMPOSER_PROCESS_TIMEOUT: 600

1
.gitignore vendored
View File

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

View File

@@ -1,96 +1,5 @@
# Changelog
## [0.1.0-beta.16](https://github.com/flarum/core/compare/v0.1.0-beta.15...v0.1.0-beta.16)
### Added
- Allow event subscribers (https://github.com/flarum/core/pull/2535)
- Allow Settings extender to have a default value (https://github.com/flarum/core/pull/2495)
- Allow hooking into the sending of notifications before being send (https://github.com/flarum/core/pull/2533)
- PHP 8 support (https://github.com/flarum/core/pull/2507)
- Search extender (https://github.com/flarum/core/pull/2483)
- User badges to post preview (https://github.com/flarum/core/pull/2555)
- Optional extension dependencies allow a booting order (https://github.com/flarum/core/pull/2579)
- Auth extender (https://github.com/flarum/core/pull/2176)
- `X-Powered-By` header added to allow indexers easier data aggregation of Flarum adoption (https://github.com/flarum/core/pull/2618)
### Changed
- Run integration tests in transaction (https://github.com/flarum/core/pull/2304)
- Allow policies to return a boolean for simplified allow/deny (https://github.com/flarum/core/pull/2534)
- Converted highlight helper to typescript (https://github.com/flarum/core/pull/2532)
- Add accessibility attributes to Mark as Read button (https://github.com/flarum/core/pull/2564)
- Dismiss errors on change email modal upon a new request ([00913d5](https://github.com/flarum/core/commit/00913d5b0be2172cfce1f16aaf64a24f3d2e6d4b))
- Disabled extensions now are marked with a red circle instead of a red dot (https://github.com/flarum/core/pull/2562)
- Extension dependency errors now show the extension title instead of the ID (https://github.com/flarum/core/pull/2563)
- Change `mutate` method on ApiSerializer extender to `attributes` (https://github.com/flarum/core/pull/2578)
- Moved locale files to the core from the language pack (https://github.com/flarum/core/pull/2408)
- AdminPage extensibility and generic improvements (https://github.com/flarum/core/pull/2593)
- Remove entry of authors, link to https://flarum.org/team (https://github.com/flarum/core/pull/2625)
- Search and filtering are split (https://github.com/flarum/core/pull/2454)
- Move IP identification into a middleware (https://github.com/flarum/core/pull/2624)
- Editor Driver abstraction introduced (https://github.com/flarum/core/pull/2594)
- Allow overriding routes (https://github.com/flarum/core/pull/2577)
- Split user edit permissions into permissions for editing of user credentials, username, groups and suspending (https://github.com/flarum/core/pull/2620)
- Reduced number of admin extension categories (https://github.com/flarum/core/pull/2604)
- Move search related classes to a dedicated Query namespace (https://github.com/flarum/core/pull/2645)
- Rewrite common helpers into typescript (https://github.com/flarum/core/pull/2541)
- `TextEditor` is moved to the common namespace for use in the admin frontend (https://github.com/flarum/core/pull/2649)
- Update Laravel/Illuminate components to 8 (https://github.com/flarum/core/pull/2576)
- Eager load relations in discussion listing to improve performance (https://github.com/flarum/core/pull/2639)
- Adopt flarum/testing package (https://github.com/flarum/core/pull/2545)
- Replace `user` gambit with `author` gambit ([612a57c](https://github.com/flarum/core/commit/612a57c4664415a3ea120103483645c32acc6f12))
- Posts page of on user profile loads posts using username instead of id ([30017ee](https://github.com/flarum/core/commit/30017eef09ae9e78640c4e2cacd4909fffa8d775))
### Fixed
- Transform css breaks iOS scroll functionality (https://github.com/flarum/core/pull/2527)
- Composer header is hidden on mobile devices (https://github.com/flarum/core/pull/2279)
- Cannot delete a post or discussion of a deleted user (https://github.com/flarum/core/pull/2521)
- DiscussionListPane jumps around not keeping the scroll position (https://github.com/flarum/core/pull/2402)
- Infinite scroll on notifications dropdown broken (https://github.com/flarum/core/pull/2524)
- The show language selector switch remains toggled on ([9347b12](https://github.com/flarum/core/commit/9347b12b47bf4ab97ffb7ca92673604b237c1012))
- Model Visibility extender throws exception on extensions that aren't installed or enabled (https://github.com/flarum/core/pull/2580)
- Extensions are marked as enabled when enabling fails to unmet extension dependencies (https://github.com/flarum/core/pull/2558)
- Routes to admin extension pages without a valid ID break the admin page (https://github.com/flarum/core/pull/2584)
- Disabled fieldset use an incorrect CSS property `disallowed` (https://github.com/flarum/core/pull/2585)
- Scrolling to a post that is already loaded the Load More button shows and does not trigger (https://github.com/flarum/core/pull/2388)
- Opening discussions on some mobile devices require a double tap (https://github.com/flarum/core/pull/2607)
- iOS devices show erratic behavior in the post stream while updating (https://github.com/flarum/core/pull/2548)
- Small mobile screens partially hides the composer when the keyboard is open (https://github.com/flarum/core/pull/2631)
- Clearing cache does not clear the template cache in storage/views (https://github.com/flarum/core/pull/2648)
- Boot errors show critical information (https://github.com/flarum/core/pull/2633)
- List user endpoint discloses last online even if user choose against it (https://github.com/flarum/core/pull/2634)
- Group gambit disclosed hidden groups (https://github.com/flarum/core/pull/2657)
- Search results on small windows not fully visible (https://github.com/flarum/core/pull/2650)
- Composer goes off screen on Safari when starting to type (https://github.com/flarum/core/pull/2660)
- A search that has no results shows the search results dropdown ([b88a7cb](https://github.com/flarum/core/commit/b88a7cb33b56e318f11670e9e2d563aef94db039))
- The composer modal moves around when typing on Safari ([a64c398](https://github.com/flarum/core/commit/a64c39835aba43e831209609f4a9638ae589aa41))
### Removed
- Deprecated CSRF wildcard path match
- Deprecated policy and visibility scoping events
- Deprecated post types event
- Deprecated validation events
- Deprecated notification events
- Deprecated floodgate
- Deprecated user preferences event
- Deprecated formatting events
- Deprecated api events
- Deprecated bootstrap.php support
- PHP 7.2 support (https://github.com/flarum/core/pull/2507)
- Bidi attribute in the rendered HTML (https://github.com/flarum/core/pull/2602)
- `AccessToken::find`, use `AccessToken::findValid` instead (https://github.com/flarum/core/pull/2651)
### Deprecated
- `GetModelIsPrivate` event (https://github.com/flarum/core/pull/2587)
- `CheckingPassword` event (https://github.com/flarum/core/pull/2176)
- `event()` helper (https://github.com/flarum/core/pull/2608)
- `AccessToken::generate` argument `$lifetime` (https://github.com/flarum/core/pull/2651)
- `Rememberer::remember` argument `$token` should receive an instance of `RememberAccessToken` with `AccessToken` being deprecated (https://github.com/flarum/core/pull/2651)
- `Rememberer::rememberUser` (https://github.com/flarum/core/pull/2651)
- `SessionAuthenticator::logIn` argument `$userId`, should be replaced with `AccessToken` (https://github.com/flarum/core/pull/2651)
- `TextEditor` has been moved to `common` (https://github.com/flarum/core/pull/2649)
- `UserFilter` ([91e8b56](https://github.com/flarum/core/commit/91e8b569618957c86757ef89bac666e9102db5ae))
## [0.1.0-beta.15](https://github.com/flarum/core/compare/v0.1.0-beta.14.1...v0.1.0-beta.15)
### Added
@@ -301,7 +210,7 @@
- SES mail support (#2011)
- Backward compatibility layer for `Flarum\Mail\DriverInterface`, new methods from beta.12 are now required
- `Flarum\Util\Str` helper class
- `Flarum\Event\ConfigureMiddleware` event
- `Flarum\Event\ConfigureMiddleware` event
### Deprecated
- `Flarum\Event\AbstractConfigureRoutes` event class

View File

@@ -1,17 +1,36 @@
{
"name": "flarum/core",
"description": "Delightfully simple forum software.",
"keywords": [
"forum",
"discussion"
],
"keywords": ["forum", "discussion"],
"homepage": "https://flarum.org/",
"license": "MIT",
"authors": [
{
"name": "Flarum",
"email": "info@flarum.org",
"homepage": "https://flarum.org/team"
"name": "Franz Liedke",
"email": "franz@develophp.org"
},
{
"name": "Daniël Klabbers",
"email": "daniel@klabbers.email",
"homepage": "https://luceos.com"
},
{
"name": "David Sevilla Martin",
"email": "me+flarum@datitisev.me",
"homepage": "https://datitisev.me"
},
{
"name": "Clark Winkelmann",
"email": "clark.winkelmann@gmail.com",
"homepage": "https://clarkwinkelmann.com"
},
{
"name": "Matthew Kilgore",
"email": "matthew@kilgore.dev"
},
{
"name": "Alexander (Sasha) Skvortsov",
"email": "askvortsov@flarum.org"
}
],
"support": {
@@ -20,36 +39,36 @@
"docs": "https://flarum.org/docs/"
},
"require": {
"php": ">=7.3",
"php": ">=7.2",
"axy/sourcemap": "^0.1.4",
"components/font-awesome": "^5.14.0",
"dflydev/fig-cookies": "^3.0.0",
"dflydev/fig-cookies": "^2.0.1",
"doctrine/dbal": "^2.7",
"franzl/whoops-middleware": "^2.0.0",
"illuminate/bus": "^8.0",
"illuminate/cache": "^8.0",
"illuminate/config": "^8.0",
"illuminate/container": "^8.0",
"illuminate/contracts": "^8.0",
"illuminate/database": "^8.0",
"illuminate/events": "^8.0",
"illuminate/filesystem": "^8.0",
"illuminate/hashing": "^8.0",
"illuminate/mail": "^8.0",
"illuminate/queue": "^8.0",
"illuminate/session": "^8.0",
"illuminate/support": "^8.0",
"illuminate/validation": "^8.0",
"illuminate/view": "^8.0",
"franzl/whoops-middleware": "^0.4.0",
"illuminate/bus": "^6.0",
"illuminate/cache": "^6.0",
"illuminate/config": "^6.0",
"illuminate/container": "^6.0",
"illuminate/contracts": "^6.0",
"illuminate/database": "^6.0",
"illuminate/events": "^6.0",
"illuminate/filesystem": "^6.0",
"illuminate/hashing": "^6.0",
"illuminate/mail": "^6.0",
"illuminate/queue": "^6.0",
"illuminate/session": "^6.0",
"illuminate/support": "^6.0",
"illuminate/validation": "^6.0",
"illuminate/view": "^6.0",
"intervention/image": "^2.5.0",
"laminas/laminas-diactoros": "^2.4.1",
"laminas/laminas-httphandlerrunner": "^1.2.0",
"laminas/laminas-stratigility": "^3.2.2",
"laminas/laminas-diactoros": "^1.8.4",
"laminas/laminas-httphandlerrunner": "^1.0",
"laminas/laminas-stratigility": "^3.0",
"league/flysystem": "^1.0.11",
"matthiasmullie/minify": "^1.3",
"middlewares/base-path": "^2.0.1",
"middlewares/base-path-router": "^2.0.1",
"middlewares/request-handler": "^2.0.1",
"middlewares/base-path": "^1.1",
"middlewares/base-path-router": "^0.2.1",
"middlewares/request-handler": "^1.2",
"monolog/monolog": "^1.16.0",
"nesbot/carbon": "^2.0",
"nikic/fast-route": "^0.6",
@@ -57,25 +76,25 @@
"psr/http-server-handler": "^1.0",
"psr/http-server-middleware": "^1.0",
"s9e/text-formatter": "^2.3.6",
"symfony/config": "^5.2.2",
"symfony/console": "^5.2.2",
"symfony/event-dispatcher": "^5.2.2",
"symfony/config": "^4.3.4",
"symfony/console": "^4.3.4",
"symfony/event-dispatcher": "^4.3.4",
"symfony/mime": "^5.2.0",
"symfony/translation": "^5.1.5",
"symfony/yaml": "^5.2.2",
"symfony/translation": "^4.3.4",
"symfony/yaml": "^4.3.4",
"tobscure/json-api": "^0.3.0",
"wikimedia/less.php": "^3.0"
},
"require-dev": {
"flarum/testing": "^0.1.0-beta.16"
"mockery/mockery": "^1.0",
"phpunit/phpunit": "^7.0"
},
"autoload": {
"psr-4": {
"Flarum\\": "src/"
},
"files": [
"src/helpers.php",
"src/TranslatorInterface.php"
"src/helpers.php"
]
},
"autoload-dev": {

6
js/dist/admin.js vendored

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

8
js/dist/forum.js vendored

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

9490
js/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -13,10 +13,10 @@
"jquery": "^3.5.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",
"textarea-caret": "^3.1.0",
"webpack": "^4.43.0",
"webpack-cli": "^3.3.11",
"webpack-merge": "^4.1.4"

View File

@@ -1,18 +1,27 @@
import HeaderPrimary from './components/HeaderPrimary';
import HeaderSecondary from './components/HeaderSecondary';
import routes from './routes';
import ExtensionPage from './components/ExtensionPage';
import Application from '../common/Application';
import Navigation from '../common/components/Navigation';
import AdminNav from './components/AdminNav';
import ExtensionData from './utils/ExtensionData';
export default class AdminApplication extends Application {
// Deprecated as of beta 15
extensionSettings = {};
extensionData = new ExtensionData();
extensionCategories = {
feature: 30,
theme: 20,
discussion: 70,
moderation: 60,
feature: 50,
formatting: 40,
theme: 30,
authentication: 20,
language: 10,
other: 0,
};
history = {
@@ -52,6 +61,14 @@ export default class AdminApplication extends Application {
m.mount(document.getElementById('header-primary'), HeaderPrimary);
m.mount(document.getElementById('header-secondary'), HeaderSecondary);
m.mount(document.getElementById('admin-navigation'), AdminNav);
// If an extension has just been enabled, then we will run its settings
// callback.
const enabled = localStorage.getItem('enabledExtension');
if (enabled && this.extensionSettings[enabled] && typeof this.extensionSettings[enabled] === 'function') {
this.extensionSettings[enabled]();
localStorage.removeItem('enabledExtension');
}
}
getRequiredPermissions(permission) {

View File

@@ -8,7 +8,6 @@ import SettingDropdown from './components/SettingDropdown';
import EditCustomFooterModal from './components/EditCustomFooterModal';
import SessionDropdown from './components/SessionDropdown';
import HeaderPrimary from './components/HeaderPrimary';
import AdminPage from './components/AdminPage';
import AppearancePage from './components/AppearancePage';
import StatusWidget from './components/StatusWidget';
import ExtensionsWidget from './components/ExtensionsWidget';
@@ -17,6 +16,7 @@ import SettingsModal from './components/SettingsModal';
import DashboardWidget from './components/DashboardWidget';
import ExtensionPage from './components/ExtensionPage';
import ExtensionLinkButton from './components/ExtensionLinkButton';
import AdminLinkButton from './components/AdminLinkButton';
import PermissionGrid from './components/PermissionGrid';
import ExtensionPermissionGrid from './components/ExtensionPermissionGrid';
import MailPage from './components/MailPage';
@@ -43,7 +43,6 @@ export default Object.assign(compat, {
'components/EditCustomFooterModal': EditCustomFooterModal,
'components/SessionDropdown': SessionDropdown,
'components/HeaderPrimary': HeaderPrimary,
'components/AdminPage': AdminPage,
'components/AppearancePage': AppearancePage,
'components/StatusWidget': StatusWidget,
'components/ExtensionsWidget': ExtensionsWidget,
@@ -52,6 +51,7 @@ export default Object.assign(compat, {
'components/DashboardWidget': DashboardWidget,
'components/ExtensionPage': ExtensionPage,
'components/ExtensionLinkButton': ExtensionLinkButton,
'components/AdminLinkButton': AdminLinkButton,
'components/PermissionGrid': PermissionGrid,
'components/ExtensionPermissionGrid': ExtensionPermissionGrid,
'components/MailPage': MailPage,

View File

@@ -0,0 +1,32 @@
/*
* This file is part of Flarum.
*
* (c) Toby Zerner <toby.zerner@gmail.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
import Modal from '../../common/components/Modal';
export default class AddExtensionModal extends Modal {
className() {
return 'AddExtensionModal Modal--small';
}
title() {
return app.translator.trans('core.admin.add_extension.title');
}
content() {
return (
<div className="Modal-body">
<p>{app.translator.trans('core.admin.add_extension.temporary_text')}</p>
<p>
{app.translator.trans('core.admin.add_extension.install_text', { a: <a href="https://discuss.flarum.org/t/extensions" target="_blank" /> })}
</p>
<p>{app.translator.trans('core.admin.add_extension.developer_text', { a: <a href="http://flarum.org/docs/extend" target="_blank" /> })}</p>
</div>
);
}
}

View File

@@ -0,0 +1,16 @@
/*
* This file is part of Flarum.
*
* (c) Toby Zerner <toby.zerner@gmail.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
import LinkButton from '../../common/components/LinkButton';
export default class AdminLinkButton extends LinkButton {
getButtonContent(children) {
return [...super.getButtonContent(children), <div className="AdminLinkButton-description">{this.attrs.description}</div>];
}
}

View File

@@ -1,174 +0,0 @@
import Page from '../../common/components/Page';
import Button from '../../common/components/Button';
import Switch from '../../common/components/Switch';
import Select from '../../common/components/Select';
import classList from '../../common/utils/classList';
import Stream from '../../common/utils/Stream';
import saveSettings from '../utils/saveSettings';
import AdminHeader from './AdminHeader';
export default class AdminPage extends Page {
oninit(vnode) {
super.oninit(vnode);
this.settings = {};
this.loading = false;
}
view() {
const className = classList(['AdminPage', this.headerInfo().className]);
return (
<div className={className}>
{this.header()}
<div className="container">{this.content()}</div>
</div>
);
}
content() {
return '';
}
submitButton() {
return (
<Button onclick={this.saveSettings.bind(this)} className="Button Button--primary" loading={this.loading} disabled={!this.isChanged()}>
{app.translator.trans('core.admin.settings.submit_button')}
</Button>
);
}
header() {
const headerInfo = this.headerInfo();
return (
<AdminHeader icon={headerInfo.icon} description={headerInfo.description} className={headerInfo.className + '-header'}>
{headerInfo.title}
</AdminHeader>
);
}
headerInfo() {
return {
className: '',
icon: '',
title: '',
description: '',
};
}
/**
* buildSettingComponent takes a settings object and turns it into a component.
* Depending on the type of input, you can set the type to 'bool', 'select', or
* any standard <input> type. Any values inside the 'extra' object will be added
* to the component as an attribute.
*
* Alternatively, you can pass a callback that will be executed in ExtensionPage's
* context to include custom JSX elements.
*
* @example
*
* {
* setting: 'acme.checkbox',
* label: app.translator.trans('acme.admin.setting_label'),
* type: 'bool',
* help: app.translator.trans('acme.admin.setting_help'),
* className: 'Setting-item'
* }
*
* @example
*
* {
* setting: 'acme.select',
* label: app.translator.trans('acme.admin.setting_label'),
* type: 'select',
* options: {
* 'option1': 'Option 1 label',
* 'option2': 'Option 2 label',
* },
* default: 'option1',
* }
*
* @param setting
* @returns {JSX.Element}
*/
buildSettingComponent(entry) {
if (typeof entry === 'function') {
return entry.call(this);
}
const setting = entry.setting;
const help = entry.help;
delete entry.help;
const value = this.setting([setting])();
if (['bool', 'checkbox', 'switch', 'boolean'].includes(entry.type)) {
return (
<div className="Form-group">
<Switch state={!!value && value !== '0'} onchange={this.settings[setting]} {...entry}>
{entry.label}
</Switch>
<div className="helpText">{help}</div>
</div>
);
} else if (['select', 'dropdown', 'selectdropdown'].includes(entry.type)) {
return (
<div className="Form-group">
<label>{entry.label}</label>
<div className="helpText">{help}</div>
<Select value={value || entry.default} options={entry.options} buttonClassName="Button" onchange={this.settings[setting]} {...entry} />
</div>
);
} else {
entry.className = classList(['FormControl', entry.className]);
return (
<div className="Form-group">
{entry.label ? <label>{entry.label}</label> : ''}
<div className="helpText">{help}</div>
<input type={entry.type} bidi={this.setting(setting)} {...entry} />
</div>
);
}
}
onsaved() {
this.loading = false;
app.alerts.show({ type: 'success' }, app.translator.trans('core.admin.settings.saved_message'));
}
setting(key, fallback = '') {
this.settings[key] = this.settings[key] || Stream(app.data.settings[key] || fallback);
return this.settings[key];
}
dirty() {
const dirty = {};
Object.keys(this.settings).forEach((key) => {
const value = this.settings[key]();
if (value !== app.data.settings[key]) {
dirty[key] = value;
}
});
return dirty;
}
isChanged() {
return Object.keys(this.dirty()).length;
}
saveSettings(e) {
e.preventDefault();
app.alerts.clear();
this.loading = true;
return saveSettings(this.dirty()).then(this.onsaved.bind(this));
}
}

View File

@@ -1,120 +1,141 @@
import Page from '../../common/components/Page';
import Button from '../../common/components/Button';
import Switch from '../../common/components/Switch';
import Stream from '../../common/utils/Stream';
import EditCustomCssModal from './EditCustomCssModal';
import EditCustomHeaderModal from './EditCustomHeaderModal';
import EditCustomFooterModal from './EditCustomFooterModal';
import UploadImageButton from './UploadImageButton';
import AdminPage from './AdminPage';
import saveSettings from '../utils/saveSettings';
import AdminHeader from './AdminHeader';
export default class AppearancePage extends AdminPage {
headerInfo() {
return {
className: 'AppearancePage',
icon: 'fas fa-paint-brush',
title: app.translator.trans('core.admin.appearance.title'),
description: app.translator.trans('core.admin.appearance.description'),
};
export default class AppearancePage extends Page {
oninit(vnode) {
super.oninit(vnode);
this.primaryColor = Stream(app.data.settings.theme_primary_color);
this.secondaryColor = Stream(app.data.settings.theme_secondary_color);
this.darkMode = Stream(app.data.settings.theme_dark_mode);
this.coloredHeader = Stream(app.data.settings.theme_colored_header);
}
content() {
return [
<div className="Form">
<fieldset className="AppearancePage-colors">
<legend>{app.translator.trans('core.admin.appearance.colors_heading')}</legend>
<div className="helpText">{app.translator.trans('core.admin.appearance.colors_text')}</div>
view() {
return (
<div className="AppearancePage">
<AdminHeader
icon="fas fa-paint-brush"
description={app.translator.trans('core.admin.appearance.description')}
className="AppearancePage-header"
>
{app.translator.trans('core.admin.appearance.title')}
</AdminHeader>
<div className="container">
<form onsubmit={this.onsubmit.bind(this)}>
<fieldset className="AppearancePage-colors">
<legend>{app.translator.trans('core.admin.appearance.colors_heading')}</legend>
<div className="helpText">{app.translator.trans('core.admin.appearance.colors_text')}</div>
<div className="AppearancePage-colors-input">
{this.buildSettingComponent({
type: 'text',
setting: 'theme_primary_color',
placeholder: '#aaaaaa',
})}
{this.buildSettingComponent({
type: 'text',
setting: 'theme_secondary_color',
placeholder: '#aaaaaa',
})}
</div>
<div className="AppearancePage-colors-input">
<input className="FormControl" type="text" placeholder="#aaaaaa" bidi={this.primaryColor} />
<input className="FormControl" type="text" placeholder="#aaaaaa" bidi={this.secondaryColor} />
</div>
{this.buildSettingComponent({
type: 'switch',
setting: 'theme_dark_mode',
label: app.translator.trans('core.admin.appearance.dark_mode_label'),
})}
{Switch.component(
{
state: this.darkMode(),
onchange: this.darkMode,
},
app.translator.trans('core.admin.appearance.dark_mode_label')
)}
{this.buildSettingComponent({
type: 'switch',
setting: 'theme_colored_header',
label: app.translator.trans('core.admin.appearance.colored_header_label'),
})}
{Switch.component(
{
state: this.coloredHeader(),
onchange: this.coloredHeader,
},
app.translator.trans('core.admin.appearance.colored_header_label')
)}
{this.submitButton()}
</fieldset>
</div>,
{Button.component(
{
className: 'Button Button--primary',
type: 'submit',
loading: this.loading,
},
app.translator.trans('core.admin.appearance.submit_button')
)}
</fieldset>
</form>
<fieldset>
<legend>{app.translator.trans('core.admin.appearance.logo_heading')}</legend>
<div className="helpText">{app.translator.trans('core.admin.appearance.logo_text')}</div>
<UploadImageButton name="logo" />
</fieldset>,
<fieldset>
<legend>{app.translator.trans('core.admin.appearance.logo_heading')}</legend>
<div className="helpText">{app.translator.trans('core.admin.appearance.logo_text')}</div>
<UploadImageButton name="logo" />
</fieldset>
<fieldset>
<legend>{app.translator.trans('core.admin.appearance.favicon_heading')}</legend>
<div className="helpText">{app.translator.trans('core.admin.appearance.favicon_text')}</div>
<UploadImageButton name="favicon" />
</fieldset>,
<fieldset>
<legend>{app.translator.trans('core.admin.appearance.favicon_heading')}</legend>
<div className="helpText">{app.translator.trans('core.admin.appearance.favicon_text')}</div>
<UploadImageButton name="favicon" />
</fieldset>
<fieldset>
<legend>{app.translator.trans('core.admin.appearance.custom_header_heading')}</legend>
<div className="helpText">{app.translator.trans('core.admin.appearance.custom_header_text')}</div>
{Button.component(
{
className: 'Button',
onclick: () => app.modal.show(EditCustomHeaderModal),
},
app.translator.trans('core.admin.appearance.edit_header_button')
)}
</fieldset>,
<fieldset>
<legend>{app.translator.trans('core.admin.appearance.custom_header_heading')}</legend>
<div className="helpText">{app.translator.trans('core.admin.appearance.custom_header_text')}</div>
{Button.component(
{
className: 'Button',
onclick: () => app.modal.show(EditCustomHeaderModal),
},
app.translator.trans('core.admin.appearance.edit_header_button')
)}
</fieldset>
<fieldset>
<legend>{app.translator.trans('core.admin.appearance.custom_footer_heading')}</legend>
<div className="helpText">{app.translator.trans('core.admin.appearance.custom_footer_text')}</div>
{Button.component(
{
className: 'Button',
onclick: () => app.modal.show(EditCustomFooterModal),
},
app.translator.trans('core.admin.appearance.edit_footer_button')
)}
</fieldset>,
<fieldset>
<legend>{app.translator.trans('core.admin.appearance.custom_footer_heading')}</legend>
<div className="helpText">{app.translator.trans('core.admin.appearance.custom_footer_text')}</div>
{Button.component(
{
className: 'Button',
onclick: () => app.modal.show(EditCustomFooterModal),
},
app.translator.trans('core.admin.appearance.edit_footer_button')
)}
</fieldset>
<fieldset>
<legend>{app.translator.trans('core.admin.appearance.custom_styles_heading')}</legend>
<div className="helpText">{app.translator.trans('core.admin.appearance.custom_styles_text')}</div>
{Button.component(
{
className: 'Button',
onclick: () => app.modal.show(EditCustomCssModal),
},
app.translator.trans('core.admin.appearance.edit_css_button')
)}
</fieldset>,
];
<fieldset>
<legend>{app.translator.trans('core.admin.appearance.custom_styles_heading')}</legend>
<div className="helpText">{app.translator.trans('core.admin.appearance.custom_styles_text')}</div>
{Button.component(
{
className: 'Button',
onclick: () => app.modal.show(EditCustomCssModal),
},
app.translator.trans('core.admin.appearance.edit_css_button')
)}
</fieldset>
</div>
</div>
);
}
onsaved() {
window.location.reload();
}
saveSettings(e) {
onsubmit(e) {
e.preventDefault();
const hex = /^#[0-9a-f]{3}([0-9a-f]{3})?$/i;
if (!hex.test(this.settings['theme_primary_color']()) || !hex.test(this.settings['theme_secondary_color']())) {
if (!hex.test(this.primaryColor()) || !hex.test(this.secondaryColor())) {
alert(app.translator.trans('core.admin.appearance.enter_hex_message'));
return;
}
super.saveSettings(e);
this.loading = true;
saveSettings({
theme_primary_color: this.primaryColor(),
theme_secondary_color: this.secondaryColor(),
theme_dark_mode: this.darkMode(),
theme_colored_header: this.coloredHeader(),
}).then(() => window.location.reload());
}
}

View File

@@ -1,11 +1,31 @@
import Page from '../../common/components/Page';
import FieldSet from '../../common/components/FieldSet';
import Select from '../../common/components/Select';
import Button from '../../common/components/Button';
import saveSettings from '../utils/saveSettings';
import ItemList from '../../common/utils/ItemList';
import AdminPage from './AdminPage';
import Switch from '../../common/components/Switch';
import Stream from '../../common/utils/Stream';
import withAttr from '../../common/utils/withAttr';
import AdminHeader from './AdminHeader';
export default class BasicsPage extends AdminPage {
export default class BasicsPage extends Page {
oninit(vnode) {
super.oninit(vnode);
this.loading = false;
this.fields = [
'forum_title',
'forum_description',
'default_locale',
'show_language_selector',
'default_route',
'welcome_title',
'welcome_message',
'display_name_driver',
];
this.localeOptions = {};
const locales = app.data.locales;
for (const i in locales) {
@@ -20,99 +40,157 @@ export default class BasicsPage extends AdminPage {
this.slugDriverOptions = {};
Object.keys(app.data.slugDrivers).forEach((model) => {
this.fields.push(`slug_driver_${model}`);
this.slugDriverOptions[model] = {};
app.data.slugDrivers[model].forEach((option) => {
this.slugDriverOptions[model][option] = option;
});
});
this.values = {};
const settings = app.data.settings;
this.fields.forEach((key) => (this.values[key] = Stream(settings[key])));
if (!this.values.display_name_driver() && displayNameDrivers.includes('username')) this.values.display_name_driver('username');
Object.keys(app.data.slugDrivers).forEach((model) => {
if (!this.values[`slug_driver_${model}`]() && 'default' in this.slugDriverOptions[model]) {
this.values[`slug_driver_${model}`]('default');
}
});
if (typeof this.values.show_language_selector() !== 'number') this.values.show_language_selector(1);
}
headerInfo() {
return {
className: 'BasicsPage',
icon: 'fas fa-pencil-alt',
title: app.translator.trans('core.admin.basics.title'),
description: app.translator.trans('core.admin.basics.description'),
};
}
view() {
return (
<div className="BasicsPage">
<AdminHeader icon="fas fa-pencil-alt" description={app.translator.trans('core.admin.basics.description')} className="BasicsPage-header">
{app.translator.trans('core.admin.basics.title')}
</AdminHeader>
<div className="container">
<form onsubmit={this.onsubmit.bind(this)}>
{FieldSet.component(
{
label: app.translator.trans('core.admin.basics.forum_title_heading'),
},
[<input className="FormControl" bidi={this.values.forum_title} />]
)}
content() {
return [
<div className="Form">
{this.buildSettingComponent({
type: 'text',
setting: 'forum_title',
label: app.translator.trans('core.admin.basics.forum_title_heading'),
})}
{this.buildSettingComponent({
type: 'text',
setting: 'forum_description',
label: app.translator.trans('core.admin.basics.forum_description_heading'),
help: app.translator.trans('core.admin.basics.forum_description_text'),
})}
{FieldSet.component(
{
label: app.translator.trans('core.admin.basics.forum_description_heading'),
},
[
<div className="helpText">{app.translator.trans('core.admin.basics.forum_description_text')}</div>,
<textarea className="FormControl" bidi={this.values.forum_description} />,
]
)}
{Object.keys(this.localeOptions).length > 1
? [
this.buildSettingComponent({
type: 'select',
setting: 'default_locale',
options: this.localeOptions,
label: app.translator.trans('core.admin.basics.default_language_heading'),
}),
this.buildSettingComponent({
type: 'switch',
setting: 'show_language_selector',
label: app.translator.trans('core.admin.basics.show_language_selector_label'),
}),
]
: ''}
{Object.keys(this.localeOptions).length > 1
? FieldSet.component(
{
label: app.translator.trans('core.admin.basics.default_language_heading'),
},
[
Select.component({
options: this.localeOptions,
value: this.values.default_locale(),
onchange: this.values.default_locale,
}),
Switch.component(
{
state: this.values.show_language_selector(),
onchange: this.values.show_language_selector,
},
app.translator.trans('core.admin.basics.show_language_selector_label')
),
]
)
: ''}
<FieldSet className="BasicsPage-homePage Form-group" label={app.translator.trans('core.admin.basics.home_page_heading')}>
<div className="helpText">{app.translator.trans('core.admin.basics.home_page_text')}</div>
{this.homePageItems()
.toArray()
.map(({ path, label }) => (
<label className="checkbox">
<input type="radio" name="homePage" value={path} bidi={this.setting('default_route')} />
{label}
</label>
))}
</FieldSet>
{FieldSet.component(
{
label: app.translator.trans('core.admin.basics.home_page_heading'),
className: 'BasicsPage-homePage',
},
[
<div className="helpText">{app.translator.trans('core.admin.basics.home_page_text')}</div>,
this.homePageItems()
.toArray()
.map(({ path, label }) => (
<label className="checkbox">
<input
type="radio"
name="homePage"
value={path}
checked={this.values.default_route() === path}
onclick={withAttr('value', this.values.default_route)}
/>
{label}
</label>
)),
]
)}
<div className="Form-group BasicsPage-welcomeBanner-input">
<label>{app.translator.trans('core.admin.basics.welcome_banner_heading')}</label>
<div className="helpText">{app.translator.trans('core.admin.basics.welcome_banner_text')}</div>
<input type="text" className="FormControl" bidi={this.setting('welcome_title')} />
<textarea className="FormControl" bidi={this.setting('welcome_message')} />
{FieldSet.component(
{
label: app.translator.trans('core.admin.basics.welcome_banner_heading'),
className: 'BasicsPage-welcomeBanner',
},
[
<div className="helpText">{app.translator.trans('core.admin.basics.welcome_banner_text')}</div>,
<div className="BasicsPage-welcomeBanner-input">
<input className="FormControl" bidi={this.values.welcome_title} />
<textarea className="FormControl" bidi={this.values.welcome_message} />
</div>,
]
)}
{Object.keys(this.displayNameOptions).length > 1 ? (
<FieldSet label={app.translator.trans('core.admin.basics.display_name_heading')}>
<div className="helpText">{app.translator.trans('core.admin.basics.display_name_text')}</div>
<Select
options={this.displayNameOptions}
value={this.values.display_name_driver()}
onchange={this.values.display_name_driver}
></Select>
</FieldSet>
) : (
''
)}
{Object.keys(this.slugDriverOptions).map((model) => {
const options = this.slugDriverOptions[model];
if (Object.keys(options).length > 1) {
return (
<FieldSet label={app.translator.trans('core.admin.basics.slug_driver_heading', { model })}>
<div className="helpText">{app.translator.trans('core.admin.basics.slug_driver_text', { model })}</div>
<Select options={options} value={this.values[`slug_driver_${model}`]()} onchange={this.values[`slug_driver_${model}`]}></Select>
</FieldSet>
);
}
})}
{Button.component(
{
type: 'submit',
className: 'Button Button--primary',
loading: this.loading,
disabled: !this.changed(),
},
app.translator.trans('core.admin.basics.submit_button')
)}
</form>
</div>
</div>
);
}
{Object.keys(this.displayNameOptions).length > 1
? this.buildSettingComponent({
type: 'select',
setting: 'display_name_driver',
options: this.displayNameOptions,
label: app.translator.trans('core.admin.basics.display_name_heading'),
help: app.translator.trans('core.admin.basics.display_name_text'),
})
: ''}
{Object.keys(this.slugDriverOptions).map((model) => {
const options = this.slugDriverOptions[model];
if (Object.keys(options).length > 1) {
return this.buildSettingComponent({
type: 'select',
setting: `slug_driver_${model}`,
options,
label: app.translator.trans('core.admin.basics.slug_driver_heading', { model }),
help: app.translator.trans('core.admin.basics.slug_driver_text', { model }),
});
}
})}
{this.submitButton()}
</div>,
];
changed() {
return this.fields.some((key) => this.values[key]() !== app.data.settings[key]);
}
/**
@@ -132,4 +210,27 @@ export default class BasicsPage extends AdminPage {
return items;
}
onsubmit(e) {
e.preventDefault();
if (this.loading) return;
this.loading = true;
app.alerts.dismiss(this.successAlert);
const settings = {};
this.fields.forEach((key) => (settings[key] = this.values[key]()));
saveSettings(settings)
.then(() => {
this.successAlert = app.alerts.show({ type: 'success' }, app.translator.trans('core.admin.basics.saved_message'));
})
.catch(() => {})
.then(() => {
this.loading = false;
m.redraw();
});
}
}

View File

@@ -1,20 +1,20 @@
import Page from '../../common/components/Page';
import StatusWidget from './StatusWidget';
import ExtensionsWidget from './ExtensionsWidget';
import AdminHeader from './AdminHeader';
import ItemList from '../../common/utils/ItemList';
import AdminPage from './AdminPage';
import listItems from '../../common/helpers/listItems';
export default class DashboardPage extends AdminPage {
headerInfo() {
return {
className: 'DashboardPage',
icon: 'fas fa-chart-bar',
title: app.translator.trans('core.admin.dashboard.title'),
description: app.translator.trans('core.admin.dashboard.description'),
};
}
content() {
return this.availableWidgets().toArray();
export default class DashboardPage extends Page {
view() {
return (
<div className="DashboardPage">
<AdminHeader icon="fas fa-chart-bar" description={app.translator.trans('core.admin.dashboard.description')} className="DashboardPage-header">
{app.translator.trans('core.admin.dashboard.title')}
</AdminHeader>
<div className="container">{this.availableWidgets().toArray()}</div>
</div>
);
}
availableWidgets() {

View File

@@ -1,22 +1,27 @@
import Button from '../../common/components/Button';
import Link from '../../common/components/Link';
import LinkButton from '../../common/components/LinkButton';
import Page from '../../common/components/Page';
import Select from '../../common/components/Select';
import Switch from '../../common/components/Switch';
import icon from '../../common/helpers/icon';
import punctuateSeries from '../../common/helpers/punctuateSeries';
import listItems from '../../common/helpers/listItems';
import ItemList from '../../common/utils/ItemList';
import Stream from '../../common/utils/Stream';
import LoadingModal from './LoadingModal';
import ExtensionPermissionGrid from './ExtensionPermissionGrid';
import saveSettings from '../utils/saveSettings';
import isExtensionEnabled from '../utils/isExtensionEnabled';
import AdminPage from './AdminPage';
export default class ExtensionPage extends AdminPage {
export default class ExtensionPage extends Page {
oninit(vnode) {
super.oninit(vnode);
this.loading = false;
this.extension = app.data.extensions[this.attrs.id];
this.changingState = false;
this.settings = {};
this.infoFields = {
discuss: 'fas fa-comment-alt',
@@ -27,20 +32,18 @@ export default class ExtensionPage extends AdminPage {
source: 'fas fa-code',
};
if (!this.extension) {
return m.route.set(app.route('dashboard'));
// Backwards compatibility layer will be removed in
// Beta 16
if (app.extensionSettings[this.extension.id]) {
app.extensionData[this.extension.id] = app.extensionSettings[this.extension.id];
}
}
className() {
if (!this.extension) return '';
return this.extension.id + '-Page';
}
view() {
if (!this.extension) return null;
return (
<div className={'ExtensionPage ' + this.className()}>
{this.header()}
@@ -56,8 +59,6 @@ export default class ExtensionPage extends AdminPage {
}
header() {
const isEnabled = this.isEnabled();
return [
<div className="ExtensionPage-header">
<div className="container">
@@ -74,12 +75,10 @@ export default class ExtensionPage extends AdminPage {
</div>
<div className="helpText">{this.extension.description}</div>
<div className="ExtensionPage-headerItems">
<Switch
state={this.changingState ? !isEnabled : isEnabled}
loading={this.changingState}
onchange={this.toggle.bind(this, this.extension.id)}
>
{isEnabled ? app.translator.trans('core.admin.extension.enabled') : app.translator.trans('core.admin.extension.disabled')}
<Switch state={this.isEnabled()} onchange={this.toggle.bind(this, this.extension.id)}>
{this.isEnabled(this.extension.id)
? app.translator.trans('core.admin.extension.enabled')
: app.translator.trans('core.admin.extension.disabled')}
</Switch>
<aside className="ExtensionInfo">
<ul>{listItems(this.infoItems().toArray())}</ul>
@@ -121,7 +120,11 @@ export default class ExtensionPage extends AdminPage {
return (
<div className="ExtensionPage-settings">
<div className="container">
{settings ? (
{typeof app.extensionData[this.extension.id] === 'function' ? (
<Button onclick={app.extensionData[this.extension.id].bind(this)} className="Button Button--primary">
{app.translator.trans('core.admin.extension.open_modal')}
</Button>
) : settings ? (
<div className="Form">
{settings.map(this.buildSettingComponent.bind(this))}
<div className="Form-group">{this.submitButton()}</div>
@@ -197,6 +200,78 @@ export default class ExtensionPage extends AdminPage {
return items;
}
submitButton() {
return (
<Button onclick={this.saveSettings.bind(this)} className="Button Button--primary" loading={this.loading} disabled={!this.isChanged()}>
{app.translator.trans('core.admin.settings.submit_button')}
</Button>
);
}
/**
* getSetting takes a settings object and turns it into a component.
* Depending on the type of input, you can set the type to 'bool', 'select', or
* any standard <input> type.
*
* Alternatively, you can pass a callback that will be executed in ExtensionPage's
* context to include custom JSX elements.
*
* @example
*
* {
* setting: 'acme.checkbox',
* label: app.translator.trans('acme.admin.setting_label'),
* type: 'bool'
* }
*
* @example
*
* {
* setting: 'acme.select',
* label: app.translator.trans('acme.admin.setting_label'),
* type: 'select',
* options: {
* 'option1': 'Option 1 label',
* 'option2': 'Option 2 label',
* },
* default: 'option1',
* }
*
* @param setting
* @returns {JSX.Element}
*/
buildSettingComponent(entry) {
if (typeof entry === 'function') {
return entry.call(this);
}
const setting = entry.setting;
const value = this.setting([setting])();
if (['bool', 'checkbox', 'switch', 'boolean'].includes(entry.type)) {
return (
<div className="Form-group">
<Switch state={!!value && value !== '0'} onchange={this.settings[setting]}>
{entry.label}
</Switch>
</div>
);
} else if (['select', 'dropdown', 'selectdropdown'].includes(entry.type)) {
return (
<div className="Form-group">
<label>{entry.label}</label>
<Select value={value || entry.default} options={entry.options} buttonClassName="Button" onchange={this.settings[setting]} />
</div>
);
} else {
return (
<div className="Form-group">
<label>{entry.label}</label>
<input type={entry.type} className="FormControl" bidi={this.setting(setting)} />
</div>
);
}
}
toggle() {
const enabled = this.isEnabled();
@@ -217,8 +292,50 @@ export default class ExtensionPage extends AdminPage {
app.modal.show(LoadingModal);
}
dirty() {
const dirty = {};
Object.keys(this.settings).forEach((key) => {
const value = this.settings[key]();
if (value !== app.data.settings[key]) {
dirty[key] = value;
}
});
return dirty;
}
isChanged() {
return Object.keys(this.dirty()).length;
}
saveSettings(e) {
e.preventDefault();
app.alerts.clear();
this.loading = true;
saveSettings(this.dirty()).then(this.onsaved.bind(this));
}
onsaved() {
this.loading = false;
app.alerts.show({ type: 'success' }, app.translator.trans('core.admin.extension.saved_message'));
}
setting(key, fallback = '') {
this.settings[key] = this.settings[key] || Stream(app.data.settings[key] || fallback);
return this.settings[key];
}
isEnabled() {
return isExtensionEnabled(this.extension.id);
let isEnabled = isExtensionEnabled(this.extension.id);
return this.changingState ? !isEnabled : isEnabled;
}
onerror(e) {
@@ -229,8 +346,6 @@ export default class ExtensionPage extends AdminPage {
app.modal.close();
}, 300); // Bootstrap's Modal.TRANSITION_DURATION is 300 ms.
this.changingState = false;
if (e.status !== 409) {
throw e;
}

View File

@@ -1,5 +1,4 @@
import PermissionGrid from './PermissionGrid';
import Button from '../../common/components/Button';
import ItemList from '../../common/utils/ItemList';
export default class ExtensionPermissionGrid extends PermissionGrid {
@@ -37,17 +36,4 @@ export default class ExtensionPermissionGrid extends PermissionGrid {
moderateItems() {
return app.extensionData.getExtensionPermissions(this.extensionId, 'moderate') || new ItemList();
}
scopeControlItems() {
const items = new ItemList();
items.add(
'configureScopes',
<Button className="Button Button--text" onclick={() => m.route.set(app.route('permissions'))}>
{app.translator.trans('core.admin.extension.configure_scopes')}
</Button>
);
return items;
}
}

View File

@@ -5,47 +5,42 @@ import Link from '../../common/components/Link';
import icon from '../../common/helpers/icon';
export default class ExtensionsWidget extends DashboardWidget {
oninit(vnode) {
super.oninit(vnode);
this.categorizedExtensions = getCategorizedExtensions();
}
className() {
return 'ExtensionsWidget';
}
content() {
const categorizedExtensions = getCategorizedExtensions();
const categories = app.extensionCategories;
return (
<div className="ExtensionsWidget-list">
{Object.keys(categories).map((category) => (this.categorizedExtensions[category] ? this.extensionCategory(category) : ''))}
{Object.keys(categories).map((category) => {
if (categorizedExtensions[category]) {
return (
<div className="ExtensionList-Category">
<h4 className="ExtensionList-Label">{app.translator.trans(`core.admin.nav.categories.${category}`)}</h4>
<ul className="ExtensionList">
{categorizedExtensions[category].map((extension) => {
return (
<li className={'ExtensionListItem ' + (!isExtensionEnabled(extension.id) ? 'disabled' : '')}>
<Link href={app.route('extension', { id: extension.id })}>
<div className="ExtensionListItem-content">
<span className="ExtensionListItem-icon ExtensionIcon" style={extension.icon}>
{extension.icon ? icon(extension.icon.name) : ''}
</span>
<span className="ExtensionListItem-title">{extension.extra['flarum-extension'].title}</span>
</div>
</Link>
</li>
);
})}
</ul>
</div>
);
}
})}
</div>
);
}
extensionCategory(category) {
return (
<div className="ExtensionList-Category">
<h4 className="ExtensionList-Label">{app.translator.trans(`core.admin.nav.categories.${category}`)}</h4>
<ul className="ExtensionList">{this.categorizedExtensions[category].map((extension) => this.extensionWidget(extension))}</ul>
</div>
);
}
extensionWidget(extension) {
return (
<li className={'ExtensionListItem ' + (!isExtensionEnabled(extension.id) ? 'disabled' : '')}>
<Link href={app.route('extension', { id: extension.id })}>
<div className="ExtensionListItem-content">
<span className="ExtensionListItem-icon ExtensionIcon" style={extension.icon}>
{extension.icon ? icon(extension.icon.name) : ''}
</span>
<span className="ExtensionListItem-title">{extension.extra['flarum-extension'].title}</span>
</div>
</Link>
</li>
);
}
}

View File

@@ -1,31 +1,34 @@
import Page from '../../common/components/Page';
import FieldSet from '../../common/components/FieldSet';
import Button from '../../common/components/Button';
import Alert from '../../common/components/Alert';
import Select from '../../common/components/Select';
import LoadingIndicator from '../../common/components/LoadingIndicator';
import AdminPage from './AdminPage';
import saveSettings from '../utils/saveSettings';
import Stream from '../../common/utils/Stream';
import icon from '../../common/helpers/icon';
import AdminHeader from './AdminHeader';
export default class MailPage extends AdminPage {
export default class MailPage extends Page {
oninit(vnode) {
super.oninit(vnode);
this.saving = false;
this.sendingTest = false;
this.refresh();
}
headerInfo() {
return {
className: 'MailPage',
icon: 'fas fa-envelope',
title: app.translator.trans('core.admin.email.title'),
description: app.translator.trans('core.admin.email.description'),
};
}
refresh() {
this.loading = true;
this.driverFields = {};
this.fields = ['mail_driver', 'mail_from'];
this.values = {};
this.status = { sending: false, errors: {} };
const settings = app.data.settings;
this.fields.forEach((key) => (this.values[key] = Stream(settings[key])));
app
.request({
method: 'GET',
@@ -36,78 +39,150 @@ export default class MailPage extends AdminPage {
this.status.sending = response['data']['attributes']['sending'];
this.status.errors = response['data']['attributes']['errors'];
for (const driver in this.driverFields) {
for (const field in this.driverFields[driver]) {
this.fields.push(field);
this.values[field] = Stream(settings[field]);
}
}
this.loading = false;
m.redraw();
});
}
content() {
if (this.loading) {
return <LoadingIndicator />;
view() {
if (this.loading || this.saving) {
return (
<div className="MailPage">
<div className="container">
<LoadingIndicator />
</div>
</div>
);
}
const fields = this.driverFields[this.setting('mail_driver')()];
const fields = this.driverFields[this.values.mail_driver()];
const fieldKeys = Object.keys(fields);
return (
<div className="Form">
{this.buildSettingComponent({
type: 'text',
setting: 'mail_from',
label: app.translator.trans('core.admin.email.addresses_heading'),
className: 'MailPage-MailSettings',
})}
{this.buildSettingComponent({
type: 'select',
setting: 'mail_driver',
options: Object.keys(this.driverFields).reduce((memo, val) => ({ ...memo, [val]: val }), {}),
label: app.translator.trans('core.admin.email.driver_heading'),
className: 'MailPage-MailSettings',
})}
{this.status.sending ||
Alert.component(
{
dismissible: false,
},
app.translator.trans('core.admin.email.not_sending_message')
)}
<div className="MailPage">
<AdminHeader icon="fas fa-envelope" description={app.translator.trans('core.admin.email.description')} className="MailPage-header">
{app.translator.trans('core.admin.email.title')}
</AdminHeader>
<div className="container">
<form onsubmit={this.onsubmit.bind(this)}>
{FieldSet.component(
{
label: app.translator.trans('core.admin.email.addresses_heading'),
className: 'MailPage-MailSettings',
},
[
<div className="MailPage-MailSettings-input">
<label>
{app.translator.trans('core.admin.email.from_label')}
<input className="FormControl" bidi={this.values.mail_from} />
</label>
</div>,
]
)}
{fieldKeys.length > 0 && (
<FieldSet label={app.translator.trans(`core.admin.email.${this.setting('mail_driver')()}_heading`)} className="MailPage-MailSettings">
<div className="MailPage-MailSettings-input">
{fieldKeys.map((field) => {
const fieldInfo = fields[field];
{FieldSet.component(
{
label: app.translator.trans('core.admin.email.driver_heading'),
className: 'MailPage-MailSettings',
},
[
<div className="MailPage-MailSettings-input">
<label>
{app.translator.trans('core.admin.email.driver_label')}
<Select
value={this.values.mail_driver()}
options={Object.keys(this.driverFields).reduce((memo, val) => ({ ...memo, [val]: val }), {})}
onchange={this.values.mail_driver}
/>
</label>
</div>,
]
)}
return [
this.buildSettingComponent({
type: typeof this.setting(field)() === 'string' ? 'text' : 'select',
label: app.translator.trans(`core.admin.email.${field}_label`),
setting: field,
options: fieldInfo,
}),
this.status.errors[field] && <p className="ValidationError">{this.status.errors[field]}</p>,
];
})}
</div>
</FieldSet>
)}
{this.submitButton()}
{this.status.sending ||
Alert.component(
{
dismissible: false,
},
app.translator.trans('core.admin.email.not_sending_message')
)}
<FieldSet label={app.translator.trans('core.admin.email.send_test_mail_heading')} className="MailPage-MailSettings">
<div className="helpText">{app.translator.trans('core.admin.email.send_test_mail_text', { email: app.session.user.email() })}</div>
{Button.component(
{
className: 'Button Button--primary',
disabled: this.sendingTest || this.isChanged(),
onclick: () => this.sendTestEmail(),
},
app.translator.trans('core.admin.email.send_test_mail_button')
)}
</FieldSet>
{fieldKeys.length > 0 &&
FieldSet.component(
{
label: app.translator.trans(`core.admin.email.${this.values.mail_driver()}_heading`),
className: 'MailPage-MailSettings',
},
[
<div className="MailPage-MailSettings-input">
{fieldKeys.map((field) => [
<label>
{app.translator.trans(`core.admin.email.${field}_label`)}
{this.renderField(field)}
</label>,
this.status.errors[field] && <p className="ValidationError">{this.status.errors[field]}</p>,
])}
</div>,
]
)}
<FieldSet>
{Button.component(
{
type: 'submit',
className: 'Button Button--primary',
disabled: !this.changed(),
},
app.translator.trans('core.admin.email.submit_button')
)}
</FieldSet>
{FieldSet.component(
{
label: app.translator.trans('core.admin.email.send_test_mail_heading'),
className: 'MailPage-MailSettings',
},
[
<div className="helpText">{app.translator.trans('core.admin.email.send_test_mail_text', { email: app.session.user.email() })}</div>,
Button.component(
{
className: 'Button Button--primary',
disabled: this.sendingTest || this.changed(),
onclick: () => this.sendTestEmail(),
},
app.translator.trans('core.admin.email.send_test_mail_button')
),
]
)}
</form>
</div>
</div>
);
}
renderField(name) {
const driver = this.values.mail_driver();
const field = this.driverFields[driver][name];
const prop = this.values[name];
if (typeof field === 'string') {
return <input className="FormControl" bidi={prop} />;
} else {
return <Select value={prop()} options={field} onchange={prop} />;
}
}
changed() {
return this.fields.some((key) => this.values[key]() !== app.data.settings[key]);
}
sendTestEmail() {
if (this.saving || this.sendingTest) return;
@@ -130,7 +205,26 @@ export default class MailPage extends AdminPage {
});
}
saveSettings(e) {
super.saveSettings(e).then(this.refresh());
onsubmit(e) {
e.preventDefault();
if (this.saving || this.sendingTest) return;
this.saving = true;
app.alerts.dismiss(this.successAlert);
const settings = {};
this.fields.forEach((key) => (settings[key] = this.values[key]()));
saveSettings(settings)
.then(() => {
this.successAlert = app.alerts.show({ type: 'success' }, app.translator.trans('core.admin.basics.saved_message'));
})
.catch(() => {})
.then(() => {
this.saving = false;
this.refresh();
});
}
}

View File

@@ -326,30 +326,10 @@ export default class PermissionGrid extends Component {
60
);
items.add(
'userEditCredentials',
{
icon: 'fas fa-user-cog',
label: app.translator.trans('core.admin.permissions.edit_users_credentials_label'),
permission: 'user.editCredentials',
},
60
);
items.add(
'userEditGroups',
{
icon: 'fas fa-users-cog',
label: app.translator.trans('core.admin.permissions.edit_users_groups_label'),
permission: 'user.editGroups',
},
60
);
items.add(
'userEdit',
{
icon: 'fas fa-address-card',
icon: 'fas fa-user-cog',
label: app.translator.trans('core.admin.permissions.edit_users_label'),
permission: 'user.edit',
},

View File

@@ -1,43 +1,44 @@
import Page from '../../common/components/Page';
import GroupBadge from '../../common/components/GroupBadge';
import EditGroupModal from './EditGroupModal';
import Group from '../../common/models/Group';
import icon from '../../common/helpers/icon';
import PermissionGrid from './PermissionGrid';
import AdminPage from './AdminPage';
import AdminHeader from './AdminHeader';
export default class PermissionsPage extends AdminPage {
headerInfo() {
return {
className: 'PermissionsPage',
icon: 'fas fa-key',
title: app.translator.trans('core.admin.permissions.title'),
description: app.translator.trans('core.admin.permissions.description'),
};
}
content() {
return [
<div className="PermissionsPage-groups">
{app.store
.all('groups')
.filter((group) => [Group.GUEST_ID, Group.MEMBER_ID].indexOf(group.id()) === -1)
.map((group) => (
<button className="Button Group" onclick={() => app.modal.show(EditGroupModal, { group })}>
{GroupBadge.component({
group,
className: 'Group-icon',
label: null,
})}
<span className="Group-name">{group.namePlural()}</span>
export default class PermissionsPage extends Page {
view() {
return (
<div className="PermissionsPage">
<AdminHeader icon="fas fa-key" description={app.translator.trans('core.admin.permissions.description')} className="PermissionsPage-header">
{app.translator.trans('core.admin.permissions.title')}
</AdminHeader>
<div className="PermissionsPage-groups">
<div className="container">
{app.store
.all('groups')
.filter((group) => [Group.GUEST_ID, Group.MEMBER_ID].indexOf(group.id()) === -1)
.map((group) => (
<button className="Button Group" onclick={() => app.modal.show(EditGroupModal, { group })}>
{GroupBadge.component({
group,
className: 'Group-icon',
label: null,
})}
<span className="Group-name">{group.namePlural()}</span>
</button>
))}
<button className="Button Group Group--add" onclick={() => app.modal.show(EditGroupModal)}>
{icon('fas fa-plus', { className: 'Group-icon' })}
<span className="Group-name">{app.translator.trans('core.admin.permissions.new_group_button')}</span>
</button>
))}
<button className="Button Group Group--add" onclick={() => app.modal.show(EditGroupModal)}>
{icon('fas fa-plus', { className: 'Group-icon' })}
<span className="Group-name">{app.translator.trans('core.admin.permissions.new_group_button')}</span>
</button>
</div>,
</div>
</div>
<div className="PermissionsPage-permissions">{PermissionGrid.component()}</div>,
];
<div className="PermissionsPage-permissions">
<div className="container">{PermissionGrid.component()}</div>
</div>
</div>
);
}
}

View File

@@ -15,9 +15,9 @@ export default function getCategorizedExtensions() {
extensions[category].push(extension);
} else {
extensions.feature = extensions.feature || [];
extensions.other = extensions.other || [];
extensions.feature.push(extension);
extensions.other.push(extension);
}
});

View File

@@ -1,7 +1,6 @@
import * as extend from './extend';
import Session from './Session';
import Store from './Store';
import BasicEditorDriver from './utils/BasicEditorDriver';
import evented from './utils/evented';
import liveHumanTimes from './utils/liveHumanTimes';
import ItemList from './utils/ItemList';
@@ -20,6 +19,7 @@ import extract from './utils/extract';
import ScrollListener from './utils/ScrollListener';
import stringToColor from './utils/stringToColor';
import subclassOf from './utils/subclassOf';
import SuperTextarea from './utils/SuperTextarea';
import patchMithril from './utils/patchMithril';
import proxifyCompat from './utils/proxifyCompat';
import classList from './utils/classList';
@@ -57,8 +57,6 @@ import ModalManager from './components/ModalManager';
import Button from './components/Button';
import Modal from './components/Modal';
import GroupBadge from './components/GroupBadge';
import TextEditor from './components/TextEditor';
import TextEditorButton from './components/TextEditorButton';
import Model from './Model';
import Application from './Application';
import fullTime from './helpers/fullTime';
@@ -77,7 +75,6 @@ export default {
extend: extend,
Session: Session,
Store: Store,
'utils/BasicEditorDriver': BasicEditorDriver,
'utils/evented': evented,
'utils/liveHumanTimes': liveHumanTimes,
'utils/ItemList': ItemList,
@@ -95,6 +92,7 @@ export default {
'utils/stringToColor': stringToColor,
'utils/Stream': Stream,
'utils/subclassOf': subclassOf,
'utils/SuperTextarea': SuperTextarea,
'utils/setRouteWithForcedRefresh': setRouteWithForcedRefresh,
'utils/patchMithril': patchMithril,
'utils/proxifyCompat': proxifyCompat,
@@ -134,8 +132,6 @@ export default {
'components/Button': Button,
'components/Modal': Modal,
'components/GroupBadge': GroupBadge,
'components/TextEditor': TextEditor,
'components/TextEditorButton': TextEditorButton,
Model: Model,
Application: Application,
'helpers/fullTime': fullTime,

View File

@@ -1,28 +1,26 @@
import * as Mithril from 'mithril';
import User from '../models/User';
/**
* The `avatar` helper displays a user's avatar.
*
* @param user
* @param attrs Attributes to apply to the avatar element
* @param {User} user
* @param {Object} attrs Attributes to apply to the avatar element
* @return {Object}
*/
export default function avatar(user: User, attrs: Object = {}): Mithril.Vnode {
export default function avatar(user, attrs = {}) {
attrs.className = 'Avatar ' + (attrs.className || '');
let content: string = '';
let content = '';
// If the `title` attribute is set to null or false, we don't want to give the
// avatar a title. On the other hand, if it hasn't been given at all, we can
// safely default it to the user's username.
const hasTitle: boolean | string = attrs.title === 'undefined' || attrs.title;
const hasTitle = attrs.title === 'undefined' || attrs.title;
if (!hasTitle) delete attrs.title;
// If a user has been passed, then we will set up an avatar using their
// uploaded image, or the first letter of their username if they haven't
// uploaded one.
if (user) {
const username: string = user.displayName() || '?';
const avatarUrl: string = user.avatarUrl();
const username = user.displayName() || '?';
const avatarUrl = user.avatarUrl();
if (hasTitle) attrs.title = attrs.title || username;

View File

@@ -1,16 +1,16 @@
import * as Mithril from 'mithril';
import { truncate } from '../utils/string';
/**
* The `highlight` helper searches for a word phrase in a string, and wraps
* matches with the <mark> tag.
*
* @param string The string to highlight.
* @param phrase The word or words to highlight.
* @param [length] The number of characters to truncate the string to.
* @param {String} string The string to highlight.
* @param {String|RegExp} phrase The word or words to highlight.
* @param {Integer} [length] The number of characters to truncate the string to.
* The string will be truncated surrounding the first match.
* @return {Object}
*/
export default function highlight(string: string, phrase: string | RegExp, length?: number): Mithril.Vnode<any, any> | string {
export default function highlight(string, phrase, length) {
if (!phrase && !length) return string;
// Convert the word phrase into a global regular expression (if it isn't

View File

@@ -1,16 +1,15 @@
import * as Mithril from 'mithril';
import Separator from '../components/Separator';
import classList from '../utils/classList';
function isSeparator(item): boolean {
function isSeparator(item) {
return item.tag === Separator;
}
function withoutUnnecessarySeparators(items: Array<Mithril.Vnode>): Array<Mithril.Vnode> {
function withoutUnnecessarySeparators(items) {
const newItems = [];
let prevItem;
items.filter(Boolean).forEach((item: Mithril.Vnode, i: number) => {
items.filter(Boolean).forEach((item, i) => {
if (!isSeparator(item) || (prevItem && !isSeparator(prevItem) && i !== items.length - 1)) {
prevItem = item;
newItems.push(item);
@@ -23,11 +22,14 @@ function withoutUnnecessarySeparators(items: Array<Mithril.Vnode>): Array<Mithri
/**
* The `listItems` helper wraps a collection of components in <li> tags,
* stripping out any unnecessary `Separator` components.
*
* @param {*} items
* @return {Array}
*/
export default function listItems(items: Mithril.Vnode | Array<Mithril.Vnode>): Array<Mithril.Vnode> {
export default function listItems(items) {
if (!(items instanceof Array)) items = [items];
return withoutUnnecessarySeparators(items).map((item: Mithril.Vnode) => {
return withoutUnnecessarySeparators(items).map((item) => {
const isListItem = item.tag && item.tag.isListItem;
const active = item.tag && item.tag.isActive && item.tag.isActive(item.attrs);
const className = (item.attrs && item.attrs.itemClassName) || item.itemClassName;
@@ -38,7 +40,7 @@ export default function listItems(items: Mithril.Vnode | Array<Mithril.Vnode>):
item.key = item.attrs.key;
}
const node: Mithril.Vnode = isListItem ? (
const node = isListItem ? (
item
) : (
<li

View File

@@ -1,11 +1,12 @@
import * as Mithril from 'mithril';
import User from '../models/User';
import icon from './icon';
/**
* The `useronline` helper displays a green circle if the user is online
*
* @param {User} user
* @return {Object}
*/
export default function userOnline(user: User): Mithril.Vnode {
export default function userOnline(user) {
if (user.lastSeenAt() && user.isOnline()) {
return <span className="UserOnline">{icon('fas fa-circle')}</span>;
}

View File

@@ -1,11 +1,11 @@
import * as Mithril from 'mithril';
import User from '../models/User';
/**
* The `username` helper displays a user's username in a <span class="username">
* tag. If the user doesn't exist, the username will be displayed as [deleted].
*
* @param {User} user
* @return {Object}
*/
export default function username(user: User): Mithril.Vnode {
export default function username(user) {
const name = (user && user.displayName()) || app.translator.trans('core.lib.username.deleted_text');
return <span className="username">{name}</span>;

View File

@@ -1,6 +1,7 @@
import 'expose-loader?$!expose-loader?jQuery!jquery';
import 'expose-loader?m!mithril';
import 'expose-loader?dayjs!dayjs';
import 'expose-loader?m.bidi!m.attrs.bidi';
import 'bootstrap/js/affix';
import 'bootstrap/js/dropdown';
import 'bootstrap/js/modal';

View File

@@ -30,8 +30,6 @@ Object.assign(User.prototype, {
commentCount: Model.attribute('commentCount'),
canEdit: Model.attribute('canEdit'),
canEditCredentials: Model.attribute('canEditCredentials'),
canEditGroups: Model.attribute('canEditGroups'),
canDelete: Model.attribute('canDelete'),
avatarColor: null,

View File

@@ -1,124 +0,0 @@
import getCaretCoordinates from 'textarea-caret';
import EditorDriverInterface, { EditorDriverParams } from './EditorDriverInterface';
export default class BasicEditorDriver implements EditorDriverInterface {
el: HTMLTextAreaElement;
constructor(dom: HTMLElement, params: EditorDriverParams) {
this.el = document.createElement('textarea');
this.build(dom, params);
}
build(dom: HTMLElement, params: EditorDriverParams) {
this.el.className = params.classNames.join(' ');
this.el.disabled = params.disabled;
this.el.placeholder = params.placeholder;
this.el.value = params.value;
const callInputListeners = (e) => {
params.inputListeners.forEach((listener) => {
listener();
});
e.redraw = false;
};
this.el.oninput = (e) => {
params.oninput(this.el.value);
callInputListeners(e);
};
this.el.onclick = callInputListeners;
this.el.onkeyup = callInputListeners;
this.el.addEventListener('keydown', function (e) {
if ((e.metaKey || e.ctrlKey) && e.key === 'Enter') {
params.onsubmit();
}
});
dom.append(this.el);
}
protected setValue(value: string) {
$(this.el).val(value).trigger('input');
this.el.dispatchEvent(new CustomEvent('input', { bubbles: true, cancelable: true }));
}
moveCursorTo(position: number) {
this.setSelectionRange(position, position);
}
getSelectionRange(): Array<number> {
return [this.el.selectionStart, this.el.selectionEnd];
}
getLastNChars(n: number): string {
const value = this.el.value;
return value.slice(Math.max(0, this.el.selectionStart - n), this.el.selectionStart);
}
insertAtCursor(text: string) {
this.insertAt(this.el.selectionStart, text);
}
insertAt(pos: number, text: string) {
this.insertBetween(pos, pos, text);
}
insertBetween(start: number, end: number, text: string) {
const value = this.el.value;
const before = value.slice(0, start);
const after = value.slice(end);
this.setValue(`${before}${text}${after}`);
// Move the textarea cursor to the end of the content we just inserted.
this.moveCursorTo(start + text.length);
}
replaceBeforeCursor(start: number, text: string) {
this.insertBetween(start, this.el.selectionStart, text);
}
protected setSelectionRange(start: number, end: number) {
this.el.setSelectionRange(start, end);
this.focus();
}
getCaretCoordinates(position: number) {
const relCoords = getCaretCoordinates(this.el, position);
return {
top: relCoords.top - this.el.scrollTop,
left: relCoords.left,
};
}
// DOM Interactions
/**
* Set the disabled status of the editor.
*/
disabled(disabled: boolean) {
this.el.disabled = disabled;
}
/**
* Focus on the editor.
*/
focus() {
this.el.focus();
}
/**
* Destroy the editor
*/
destroy() {
this.el.remove();
}
}

View File

@@ -1,105 +0,0 @@
export interface EditorDriverParams {
/**
* An array of HTML class names to apply to the editor's main DOM element.
*/
classNames: string[];
/**
* Whether the editor should be initially disabled.
*/
disabled: boolean;
/**
* An optional placeholder for the editor.
*/
placeholder: string;
/**
* An optional initial value for the editor.
*/
value: string;
/**
* This is separate from inputListeners since the full serialized content will be passed to it.
* It is considered private API, and should not be used/modified by extensions not implementing
* EditorDriverInterface.
*/
oninput: Function;
/**
* Each of these functions will be called on click, input, and keyup.
* No arguments will be passed.
*/
inputListeners: Function[];
/**
* This function will be called if submission is triggered programmatically via keybind.
* No arguments should be passed.
*/
onsubmit: Function;
}
export default interface EditorDriverInterface {
/**
* Focus the editor and place the cursor at the given position.
*/
moveCursorTo(position: number): void;
/**
* Get the selected range of the editor.
*/
getSelectionRange(): Array<number>;
/**
* Get the last N characters from the current "text block".
*
* A textarea-based driver would just return the last N characters,
* but more advanced implementations might restrict to the current block.
*
* This is useful for monitoring recent user input to trigger autocomplete.
*/
getLastNChars(n: number): string;
/**
* Insert content into the editor at the position of the cursor.
*/
insertAtCursor(text: string, escape: boolean): void;
/**
* Insert content into the editor at the given position.
*/
insertAt(pos: number, text: string, escape: boolean): void;
/**
* Insert content into the editor between the given positions.
*
* If the start and end positions are different, any text between them will be
* overwritten.
*/
insertBetween(start: number, end: number, text: string, escape: boolean): void;
/**
* Replace existing content from the start to the current cursor position.
*/
replaceBeforeCursor(start: number, text: string, escape: boolean): void;
/**
* Get left and top coordinates of the caret relative to the editor viewport.
*/
getCaretCoordinates(position: number): { left: number; top: number };
/**
* Set the disabled status of the editor.
*/
disabled(disabled: boolean): void;
/**
* Focus on the editor.
*/
focus(): void;
/**
* Destroy the editor
*/
destroy(): void;
}

View File

@@ -0,0 +1,80 @@
export default class Pagination<T> {
private readonly loadFunction: (page: number) => Promise<any>;
public loading = {
prev: false,
next: false,
};
public page: number;
public data: { [page: number]: T } = {};
public pages: {
first: number;
last: number;
};
constructor(load: (page: number) => Promise<any>, page: number = 1) {
this.loadFunction = load;
this.page = page;
this.pages = {
first: page,
last: page,
};
}
clear() {
this.data = {};
}
refresh(page: number) {
this.clear();
this.page = page;
this.pages.last = page - 1;
this.pages.first = page;
return this.loadNext();
}
loadNext() {
this.loading.next = true;
const page = this.pages.last + 1;
return this.load(
page,
() => (this.loading.next = false),
() => (this.pages.last = this.page = page)
);
}
loadPrev() {
this.loading.prev = true;
const page = this.pages.first - 1;
return this.load(
page,
() => (this.loading.prev = false),
() => (this.pages.first = this.page = page)
);
}
private load(page, done, success) {
return this.loadFunction(page)
.then((out) => {
done();
success();
this.data[this.page] = out;
return out;
})
.catch((err) => {
done();
return Promise.reject(err);
});
}
}

View File

@@ -0,0 +1,109 @@
/**
* A textarea wrapper with powerful helpers for text manipulation.
*
* This wraps a <textarea> DOM element and allows directly manipulating its text
* contents and cursor positions.
*
* I apologize for the pretentious name. :)
*/
export default class SuperTextarea {
/**
* @param {HTMLTextAreaElement} textarea
*/
constructor(textarea) {
this.el = textarea;
this.$ = $(textarea);
}
/**
* Set the value of the text editor.
*
* @param {String} value
*/
setValue(value) {
this.$.val(value).trigger('input');
this.el.dispatchEvent(new CustomEvent('input', { bubbles: true, cancelable: true }));
}
/**
* Focus the textarea and place the cursor at the given index.
*
* @param {number} position
*/
moveCursorTo(position) {
this.setSelectionRange(position, position);
}
/**
* Get the selected range of the textarea.
*
* @return {Array}
*/
getSelectionRange() {
return [this.el.selectionStart, this.el.selectionEnd];
}
/**
* Insert content into the textarea at the position of the cursor.
*
* @param {String} text
*/
insertAtCursor(text) {
this.insertAt(this.el.selectionStart, text);
}
/**
* Insert content into the textarea at the given position.
*
* @param {number} pos
* @param {String} text
*/
insertAt(pos, text) {
this.insertBetween(pos, pos, text);
}
/**
* Insert content into the textarea between the given positions.
*
* If the start and end positions are different, any text between them will be
* overwritten.
*
* @param start
* @param end
* @param text
*/
insertBetween(start, end, text) {
const value = this.el.value;
const before = value.slice(0, start);
const after = value.slice(end);
this.setValue(`${before}${text}${after}`);
// Move the textarea cursor to the end of the content we just inserted.
this.moveCursorTo(start + text.length);
}
/**
* Replace existing content from the start to the current cursor position.
*
* @param start
* @param text
*/
replaceBeforeCursor(start, text) {
this.insertBetween(start, this.el.selectionStart, text);
}
/**
* Set the selected range of the textarea.
*
* @param {number} start
* @param {number} end
* @private
*/
setSelectionRange(start, end) {
this.el.setSelectionRange(start, end);
this.$.focus();
}
}

View File

@@ -1,50 +0,0 @@
function bidi(node, prop) {
var type = node.tag === 'select' ? (node.attrs.multi ? 'multi' : 'select') : node.attrs.type;
// Setup: bind listeners
if (type === 'multi') {
node.attrs.onchange = function () {
prop(
[].slice.call(this.selectedOptions, function (x) {
return x.value;
})
);
};
} else if (type === 'select') {
node.attrs.onchange = function (e) {
prop(this.selectedOptions[0].value);
};
} else if (type === 'checkbox') {
node.attrs.onchange = function (e) {
prop(this.checked);
};
} else {
node.attrs.onchange = node.attrs.oninput = function (e) {
prop(this.value);
};
}
if (node.tag === 'select') {
node.children.forEach(function (option) {
if (option.attrs.value === prop() || option.children[0] === prop()) {
option.attrs.selected = true;
}
});
} else if (type === 'checkbox') {
node.attrs.checked = prop();
} else if (type === 'radio') {
node.attrs.checked = prop() === node.attrs.value;
} else {
node.attrs.value = prop();
}
node.attrs.bidi = null;
return node;
}
bidi.view = function (ctrl, node, prop) {
return bidi(node, node.attrs.bidi);
};
export default bidi;

View File

@@ -1,5 +1,3 @@
import bidi from './bidi';
export default function patchMithril(global) {
const defaultMithril = global.m;
@@ -10,7 +8,7 @@ export default function patchMithril(global) {
// Allows the use of the bidi attr.
if (node.attrs.bidi) {
bidi(node, node.attrs.bidi);
modifiedMithril.bidi(node, node.attrs.bidi);
}
return node;

View File

@@ -16,7 +16,6 @@ import NotificationListState from './states/NotificationListState';
import GlobalSearchState from './states/GlobalSearchState';
import DiscussionListState from './states/DiscussionListState';
import ComposerState from './states/ComposerState';
import isSafariMobile from './utils/isSafariMobile';
export default class ForumApplication extends Application {
/**
@@ -139,12 +138,6 @@ export default class ForumApplication extends Application {
m.redraw();
}
});
if (isSafariMobile()) {
$(() => {
$('.App').addClass('mobile-safari');
});
}
}
/**

View File

@@ -16,7 +16,6 @@ import PostStreamState from './states/PostStreamState';
import SearchState from './states/SearchState';
import AffixedSidebar from './components/AffixedSidebar';
import DiscussionPage from './components/DiscussionPage';
import DiscussionListPane from './components/DiscussionListPane';
import LogInModal from './components/LogInModal';
import ComposerBody from './components/ComposerBody';
import ForgotPasswordModal from './components/ForgotPasswordModal';
@@ -36,8 +35,8 @@ import HeaderSecondary from './components/HeaderSecondary';
import ComposerButton from './components/ComposerButton';
import DiscussionList from './components/DiscussionList';
import ReplyPlaceholder from './components/ReplyPlaceholder';
import TextEditor from '../common/components/TextEditor'; // @deprecated beta 16, remove beta 17. Moved to common.
import TextEditorButton from '../common/components/TextEditorButton'; // @deprecated beta 16, remove beta 17. Moved to common.
import TextEditor from './components/TextEditor';
import TextEditorButton from './components/TextEditorButton';
import AvatarEditor from './components/AvatarEditor';
import Post from './components/Post';
import SettingsPage from './components/SettingsPage';
@@ -73,7 +72,6 @@ import DiscussionListItem from './components/DiscussionListItem';
import LoadingPost from './components/LoadingPost';
import PostsUserPage from './components/PostsUserPage';
import DiscussionPageResolver from './resolvers/DiscussionPageResolver';
import BasicEditorDriver from '../common/utils/BasicEditorDriver';
import routes from './routes';
import ForumApplication from './ForumApplication';
@@ -86,8 +84,6 @@ export default Object.assign(compat, {
'utils/alertEmailConfirmation': alertEmailConfirmation,
'utils/UserControls': UserControls,
'utils/Pane': Pane,
'utils/BasicEditorDriver': BasicEditorDriver,
'utils/SuperTextarea': BasicEditorDriver, // @deprecated beta 16, remove beta 17
'states/ComposerState': ComposerState,
'states/DiscussionListState': DiscussionListState,
'states/GlobalSearchState': GlobalSearchState,
@@ -96,7 +92,6 @@ export default Object.assign(compat, {
'states/SearchState': SearchState,
'components/AffixedSidebar': AffixedSidebar,
'components/DiscussionPage': DiscussionPage,
'components/DiscussionListPane': DiscussionListPane,
'components/LogInModal': LogInModal,
'components/ComposerBody': ComposerBody,
'components/ForgotPasswordModal': ForgotPasswordModal,
@@ -116,8 +111,8 @@ export default Object.assign(compat, {
'components/ComposerButton': ComposerButton,
'components/DiscussionList': DiscussionList,
'components/ReplyPlaceholder': ReplyPlaceholder,
'components/TextEditor': TextEditor, // @deprecated beta 16, remove beta 17. Moved to common.
'components/TextEditorButton': TextEditorButton, // @deprecated beta 16, remove beta 17. Moved to common.
'components/TextEditor': TextEditor,
'components/TextEditorButton': TextEditorButton,
'components/AvatarEditor': AvatarEditor,
'components/Post': Post,
'components/SettingsPage': SettingsPage,

View File

@@ -106,8 +106,9 @@ export default class ChangeEmailModal extends Modal {
return;
}
const oldEmail = app.session.user.email();
this.loading = true;
this.alertAttrs = null;
app.session.user
.save(
@@ -119,6 +120,7 @@ export default class ChangeEmailModal extends Modal {
)
.then(() => {
this.success = true;
this.alertAttrs = null;
})
.catch(() => {})
.then(this.loaded.bind(this));

View File

@@ -76,13 +76,13 @@ export default class Composer extends Component {
// Whenever any of the inputs inside the composer are have focus, we want to
// add a class to the composer to draw attention to it.
this.$().on('focus blur', ':input,.TextEditor-editorContainer', (e) => {
this.$().on('focus blur', ':input', (e) => {
this.active = e.type === 'focusin';
m.redraw();
});
// When the escape key is pressed on any inputs, close the composer.
this.$().on('keydown', ':input,.TextEditor-editorContainer', 'esc', () => this.state.close());
this.$().on('keydown', ':input', 'esc', () => this.state.close());
this.handlers = {};
@@ -157,7 +157,7 @@ export default class Composer extends Component {
* Draw focus to the first focusable content element (the text editor).
*/
focus() {
this.$('.Composer-content :input:enabled:visible, .TextEditor-editor').first().focus();
this.$('.Composer-content :input:enabled:visible:first').focus();
}
/**
@@ -265,17 +265,7 @@ export default class Composer extends Component {
this.animateHeightChange().then(() => this.focus());
if (app.screen() === 'phone') {
// On safari fixed position doesn't properly work on mobile,
// So we use absolute and set the top value.
// https://github.com/flarum/core/issues/2652
// Due to another safari bug, `scrollTop` is unreliable when
// at the very bottom of the page AND opening the composer.
// So we fallback to a calculated version of scrollTop.
// https://github.com/flarum/core/issues/2683
const scrollElement = document.documentElement;
const topOfViewport = Math.min(scrollElement.scrollTop, scrollElement.scrollHeight - scrollElement.clientHeight);
this.$().css('top', $('.App').is('.mobile-safari') ? topOfViewport : 0);
this.$().css('top', 0);
this.showBackdrop();
}
}

View File

@@ -1,7 +1,7 @@
import Component from '../../common/Component';
import LoadingIndicator from '../../common/components/LoadingIndicator';
import ConfirmDocumentUnload from '../../common/components/ConfirmDocumentUnload';
import TextEditor from '../../common/components/TextEditor';
import TextEditor from './TextEditor';
import avatar from '../../common/helpers/avatar';
import listItems from '../../common/helpers/listItems';
import ItemList from '../../common/utils/ItemList';

View File

@@ -21,13 +21,7 @@ export default class DiscussionList extends Component {
if (state.isLoading()) {
loading = LoadingIndicator.component();
} else if (state.moreResults) {
loading = Button.component(
{
className: 'Button',
onclick: state.loadMore.bind(state),
},
app.translator.trans('core.forum.discussion_list.load_more_button')
);
loading = this.getLoadButton('more', state.loadMore.bind(state));
}
if (state.empty()) {
@@ -35,8 +29,18 @@ export default class DiscussionList extends Component {
return <div className="DiscussionList">{Placeholder.component({ text })}</div>;
}
console.log(state);
return (
<div className={'DiscussionList' + (state.isSearchResults() ? ' DiscussionList--searchResults' : '')}>
{state.isLoadingPrev() ? (
<LoadingIndicator />
) : state.pagination.pages.first !== 1 ? (
<div className="DiscussionList-loadMore">{this.getLoadButton('prev', state.loadPrev.bind(state))}</div>
) : (
''
)}
<ul className="DiscussionList-discussions">
{state.discussions.map((discussion) => {
return (
@@ -46,8 +50,17 @@ export default class DiscussionList extends Component {
);
})}
</ul>
<div className="DiscussionList-loadMore">{loading}</div>
</div>
);
}
getLoadButton(key, onclick) {
return (
<Button className="Button" onclick={onclick}>
{app.translator.trans(`core.forum.discussion_list.load_${key}_button`)}
</Button>
);
}
}

View File

@@ -121,8 +121,6 @@ export default class DiscussionListItem extends Component {
</Link>
<span
tabindex="0"
role="button"
className="DiscussionListItem-count"
onclick={this.markAsRead.bind(this)}
title={showUnread ? app.translator.trans('core.forum.discussion_list.mark_as_read_tooltip') : ''}

View File

@@ -1,6 +1,5 @@
import DiscussionList from './DiscussionList';
import Component from '../../common/Component';
import DiscussionPage from './DiscussionPage';
const hotEdge = (e) => {
if (e.pageX < 10) app.pane.show();
@@ -37,31 +36,23 @@ export default class DiscussionListPane extends Component {
$(document).on('mousemove', hotEdge);
// When coming from another discussion, scroll to the previous postition
// to prevent the discussion list jumping around.
if (app.previous.matches(DiscussionPage)) {
const top = app.cache.discussionListPaneScrollTop || 0;
$list.scrollTop(top);
} else {
// If the discussion we are viewing is listed in the discussion list, then
// we will make sure it is visible in the viewport if it is not we will
// scroll the list down to it.
const $discussion = $list.find('.DiscussionListItem.active');
if ($discussion.length) {
const listTop = $list.offset().top;
const listBottom = listTop + $list.outerHeight();
const discussionTop = $discussion.offset().top;
const discussionBottom = discussionTop + $discussion.outerHeight();
// If the discussion we are viewing is listed in the discussion list, then
// we will make sure it is visible in the viewport if it is not we will
// scroll the list down to it.
const $discussion = $list.find('.DiscussionListItem.active');
if ($discussion.length) {
const listTop = $list.offset().top;
const listBottom = listTop + $list.outerHeight();
const discussionTop = $discussion.offset().top;
const discussionBottom = discussionTop + $discussion.outerHeight();
if (discussionTop < listTop || discussionBottom > listBottom) {
$list.scrollTop($list.scrollTop() - listTop + discussionTop);
}
if (discussionTop < listTop || discussionBottom > listBottom) {
$list.scrollTop($list.scrollTop() - listTop + discussionTop);
}
}
}
onremove(vnode) {
app.cache.discussionListPaneScrollTop = $(vnode.dom).scrollTop();
onremove() {
$(document).off('mousemove', hotEdge);
}

View File

@@ -37,10 +37,9 @@ export default class EditUserModal extends Modal {
}
content() {
const fields = this.fields().toArray();
return (
<div className="Modal-body">
{fields.length > 1 ? <div className="Form">{this.fields().toArray()}</div> : app.translator.trans('core.forum.edit_user.nothing_available')}
<div className="Form">{this.fields().toArray()}</div>
</div>
);
}
@@ -48,112 +47,96 @@ export default class EditUserModal extends Modal {
fields() {
const items = new ItemList();
if (app.session.user.canEditCredentials()) {
items.add(
'username',
<div className="Form-group">
<label>{app.translator.trans('core.forum.edit_user.username_heading')}</label>
<input className="FormControl" placeholder={extractText(app.translator.trans('core.forum.edit_user.username_label'))} bidi={this.username} />
</div>,
40
);
if (app.session.user !== this.attrs.user) {
items.add(
'username',
'email',
<div className="Form-group">
<label>{app.translator.trans('core.forum.edit_user.username_heading')}</label>
<input
className="FormControl"
placeholder={extractText(app.translator.trans('core.forum.edit_user.username_label'))}
bidi={this.username}
disabled={this.nonAdminEditingAdmin()}
/>
<label>{app.translator.trans('core.forum.edit_user.email_heading')}</label>
<div>
<input className="FormControl" placeholder={extractText(app.translator.trans('core.forum.edit_user.email_label'))} bidi={this.email} />
</div>
{!this.isEmailConfirmed() ? (
<div>
{Button.component(
{
className: 'Button Button--block',
loading: this.loading,
onclick: this.activate.bind(this),
},
app.translator.trans('core.forum.edit_user.activate_button')
)}
</div>
) : (
''
)}
</div>,
40
30
);
if (app.session.user !== this.attrs.user) {
items.add(
'email',
<div className="Form-group">
<label>{app.translator.trans('core.forum.edit_user.email_heading')}</label>
<div>
items.add(
'password',
<div className="Form-group">
<label>{app.translator.trans('core.forum.edit_user.password_heading')}</label>
<div>
<label className="checkbox">
<input
type="checkbox"
onchange={(e) => {
this.setPassword(e.target.checked);
m.redraw.sync();
if (e.target.checked) this.$('[name=password]').select();
e.redraw = false;
}}
/>
{app.translator.trans('core.forum.edit_user.set_password_label')}
</label>
{this.setPassword() ? (
<input
className="FormControl"
placeholder={extractText(app.translator.trans('core.forum.edit_user.email_label'))}
bidi={this.email}
disabled={this.nonAdminEditingAdmin()}
type="password"
name="password"
placeholder={extractText(app.translator.trans('core.forum.edit_user.password_label'))}
bidi={this.password}
/>
</div>
{!this.isEmailConfirmed() && this.userIsAdmin(app.session.user) ? (
<div>
{Button.component(
{
className: 'Button Button--block',
loading: this.loading,
onclick: this.activate.bind(this),
},
app.translator.trans('core.forum.edit_user.activate_button')
)}
</div>
) : (
''
)}
</div>,
30
);
</div>
</div>,
20
);
}
items.add(
'password',
<div className="Form-group">
<label>{app.translator.trans('core.forum.edit_user.password_heading')}</label>
<div>
items.add(
'groups',
<div className="Form-group EditUserModal-groups">
<label>{app.translator.trans('core.forum.edit_user.groups_heading')}</label>
<div>
{Object.keys(this.groups)
.map((id) => app.store.getById('groups', id))
.map((group) => (
<label className="checkbox">
<input
type="checkbox"
onchange={(e) => {
this.setPassword(e.target.checked);
m.redraw.sync();
if (e.target.checked) this.$('[name=password]').select();
e.redraw = false;
}}
disabled={this.nonAdminEditingAdmin()}
bidi={this.groups[group.id()]}
disabled={this.attrs.user.id() === '1' && group.id() === Group.ADMINISTRATOR_ID}
/>
{app.translator.trans('core.forum.edit_user.set_password_label')}
{GroupBadge.component({ group, label: '' })} {group.nameSingular()}
</label>
{this.setPassword() ? (
<input
className="FormControl"
type="password"
name="password"
placeholder={extractText(app.translator.trans('core.forum.edit_user.password_label'))}
bidi={this.password}
disabled={this.nonAdminEditingAdmin()}
/>
) : (
''
)}
</div>
</div>,
20
);
}
}
if (app.session.user.canEditGroups()) {
items.add(
'groups',
<div className="Form-group EditUserModal-groups">
<label>{app.translator.trans('core.forum.edit_user.groups_heading')}</label>
<div>
{Object.keys(this.groups)
.map((id) => app.store.getById('groups', id))
.map((group) => (
<label className="checkbox">
<input
type="checkbox"
bidi={this.groups[group.id()]}
disabled={group.id() === Group.ADMINISTRATOR_ID && (this.attrs.user === app.session.user || !this.userIsAdmin(app.session.user))}
/>
{GroupBadge.component({ group, label: '' })} {group.nameSingular()}
</label>
))}
</div>
</div>,
10
);
}
))}
</div>
</div>,
10
);
items.add(
'submit',
@@ -193,26 +176,21 @@ export default class EditUserModal extends Modal {
}
data() {
const groups = Object.keys(this.groups)
.filter((id) => this.groups[id]())
.map((id) => app.store.getById('groups', id));
const data = {
relationships: {},
username: this.username(),
relationships: { groups },
};
if (this.attrs.user.canEditCredentials() && !this.nonAdminEditingAdmin()) {
data.username = this.username();
if (app.session.user !== this.attrs.user) {
data.email = this.email();
}
if (this.setPassword()) {
data.password = this.password();
}
if (app.session.user !== this.attrs.user) {
data.email = this.email();
}
if (this.attrs.user.canEditGroups()) {
data.relationships.groups = Object.keys(this.groups)
.filter((id) => this.groups[id]())
.map((id) => app.store.getById('groups', id));
if (this.setPassword()) {
data.password = this.password();
}
return data;
@@ -231,16 +209,4 @@ export default class EditUserModal extends Modal {
m.redraw();
});
}
nonAdminEditingAdmin() {
return this.userIsAdmin(this.attrs.user) && !this.userIsAdmin(app.session.user);
}
/**
* @internal
* @protected
*/
userIsAdmin(user) {
return user.groups().some((g) => g.id() === Group.ADMINISTRATOR_ID);
}
}

View File

@@ -99,9 +99,7 @@ export default class NotificationList extends Component {
super.oncreate(vnode);
this.$notifications = this.$('.NotificationList-content');
// If we are on the notifications page, the window will be scrolling and not the $notifications element.
this.$scrollParent = this.inPanel() ? this.$notifications : $(window);
this.$scrollParent = this.$notifications.css('overflow') === 'auto' ? this.$notifications : $(window);
this.boundScrollHandler = this.scrollHandler.bind(this);
this.$scrollParent.on('scroll', this.boundScrollHandler);
@@ -114,24 +112,14 @@ export default class NotificationList extends Component {
scrollHandler() {
const state = this.attrs.state;
// Whole-page scroll events are listened to on `window`, but we need to get the actual
// scrollHeight, scrollTop, and clientHeight from the document element.
const scrollParent = this.inPanel() ? this.$scrollParent[0] : document.documentElement;
const scrollTop = this.$scrollParent.scrollTop();
const viewportHeight = this.$scrollParent.height();
// On very short screens, the scrollHeight + scrollTop might not reach the clientHeight
// by a fraction of a pixel, so we compensate for that.
const atBottom = Math.abs(scrollParent.scrollHeight - scrollParent.scrollTop - scrollParent.clientHeight) <= 1;
const contentTop = this.$scrollParent === this.$notifications ? 0 : this.$notifications.offset().top;
const contentHeight = this.$notifications[0].scrollHeight;
if (state.hasMoreResults() && !state.isLoading() && atBottom) {
if (state.hasMoreResults() && !state.isLoading() && scrollTop + viewportHeight >= contentTop + contentHeight) {
state.loadMore();
}
}
/**
* If the NotificationList component isn't in a panel (e.g. on NotificationPage when mobile),
* we need to listen to scroll events on the window, and get scroll state from the body.
*/
inPanel() {
return this.$notifications.css('overflow') === 'auto';
}
}

View File

@@ -142,29 +142,18 @@ export default class PostStream extends Component {
}
/**
* When the window is scrolled, check if either extreme of the post stream is
* in the viewport, and if so, trigger loading the next/previous page.
*
* @param {Integer} top
*/
onscroll(top = window.pageYOffset) {
if (this.stream.paused || this.stream.pagesLoading) return;
if (this.stream.paused) return;
this.updateScrubber(top);
this.loadPostsIfNeeded(top);
if (this.stream.pagesLoading) return;
// Throttle calculation of our position (start/end numbers of posts in the
// viewport) to 100ms.
clearTimeout(this.calculatePositionTimeout);
this.calculatePositionTimeout = setTimeout(this.calculatePosition.bind(this, top), 100);
}
/**
* Check if either extreme of the post stream is in the viewport,
* and if so, trigger loading the next/previous page.
*
* @param {Integer} top
*/
loadPostsIfNeeded(top = window.pageYOffset) {
const marginTop = this.getMarginTop();
const viewportHeight = $(window).height() - marginTop;
const viewportTop = top + marginTop;
@@ -185,6 +174,11 @@ export default class PostStream extends Component {
this.stream.loadNext();
}
}
// Throttle calculation of our position (start/end numbers of posts in the
// viewport) to 100ms.
clearTimeout(this.calculatePositionTimeout);
this.calculatePositionTimeout = setTimeout(this.calculatePosition.bind(this, top), 100);
}
updateScrubber(top = window.pageYOffset) {
@@ -405,8 +399,6 @@ export default class PostStream extends Component {
this.calculatePosition();
this.stream.paused = false;
// Check if we need to load more posts after scrolling.
this.loadPostsIfNeeded();
});
}

View File

@@ -121,7 +121,7 @@ export default class PostsUserPage extends UserPage {
loadResults(offset) {
return app.store.find('posts', {
filter: {
author: this.user.username(),
user: this.user.id(),
type: 'comment',
},
page: { offset, limit: this.loadLimit },

View File

@@ -5,7 +5,6 @@ import avatar from '../../common/helpers/avatar';
import username from '../../common/helpers/username';
import DiscussionControls from '../utils/DiscussionControls';
import ComposerPostPreview from './ComposerPostPreview';
import listItems from '../../common/helpers/listItems';
/**
* The `ReplyPlaceholder` component displays a placeholder for a reply, which,
@@ -26,7 +25,6 @@ export default class ReplyPlaceholder extends Component {
{avatar(app.session.user, { className: 'PostUser-avatar' })}
{username(app.session.user)}
</h3>
<ul className="PostUser-badges badges">{listItems(app.session.user.badges().toArray())}</ul>
</div>
</header>
<ComposerPostPreview className="Post-body" composer={app.composer} surround={this.anchorPreview.bind(this)} />

View File

@@ -113,16 +113,6 @@ export default class Search extends Component {
onupdate() {
// Highlight the item that is currently selected.
this.setIndex(this.getCurrentNumericIndex());
// If there are no sources, the search view is not shown.
if (!this.sources.length) return;
// Since extensions might add elements above the search box on mobile,
// we need to calculate and set the max height dynamically.
const resultsElementMargin = 14;
const maxHeight =
window.innerHeight - this.element.querySelector('.Search-input>.FormControl').getBoundingClientRect().bottom - resultsElementMargin;
this.element.querySelector('.Search-results').style['max-height'] = `${maxHeight}px`;
}
oncreate(vnode) {

View File

@@ -1,9 +1,8 @@
import Component from '../Component';
import ItemList from '../utils/ItemList';
import listItems from '../helpers/listItems';
import Button from './Button';
import BasicEditorDriver from '../utils/BasicEditorDriver';
import Component from '../../common/Component';
import ItemList from '../../common/utils/ItemList';
import SuperTextarea from '../../common/utils/SuperTextarea';
import listItems from '../../common/helpers/listItems';
import Button from '../../common/components/Button';
/**
* The `TextEditor` component displays a textarea with controls, including a
@@ -23,22 +22,25 @@ export default class TextEditor extends Component {
super.oninit(vnode);
/**
* The value of the editor.
* The value of the textarea.
*
* @type {String}
*/
this.value = this.attrs.value || '';
/**
* Whether the editor is disabled.
*/
this.disabled = !!this.attrs.disabled;
}
view() {
return (
<div className="TextEditor">
<div className="TextEditor-editorContainer"></div>
<textarea
className="FormControl Composer-flexible"
oninput={(e) => {
this.oninput(e.target.value, e);
}}
placeholder={this.attrs.placeholder || ''}
disabled={!!this.attrs.disabled}
value={this.value}
/>
<ul className="TextEditor-controls Composer-footer">
{listItems(this.controlItems().toArray())}
@@ -51,35 +53,15 @@ export default class TextEditor extends Component {
oncreate(vnode) {
super.oncreate(vnode);
this.attrs.composer.editor = this.buildEditor(this.$('.TextEditor-editorContainer')[0]);
}
onupdate() {
const newDisabled = !!this.attrs.disabled;
if (this.disabled !== newDisabled) {
this.disabled = newDisabled;
this.attrs.composer.editor.disabled(newDisabled);
}
}
buildEditorParams() {
return {
classNames: ['FormControl', 'Composer-flexible', 'TextEditor-editor'],
disabled: this.disabled,
placeholder: this.attrs.placeholder || '',
value: this.value,
oninput: this.oninput.bind(this),
inputListeners: [],
onsubmit: () => {
this.onsubmit();
m.redraw();
},
const handler = () => {
this.onsubmit();
m.redraw();
};
}
buildEditor(dom) {
return new BasicEditorDriver(dom, this.buildEditorParams());
this.$('textarea').bind('keydown', 'meta+return', handler);
this.$('textarea').bind('keydown', 'ctrl+return', handler);
this.attrs.composer.editor = new SuperTextarea(this.$('textarea')[0]);
}
/**
@@ -133,10 +115,12 @@ export default class TextEditor extends Component {
*
* @param {String} value
*/
oninput(value) {
oninput(value, e) {
this.value = value;
this.attrs.onchange(this.value);
e.redraw = false;
}
/**

View File

@@ -1,4 +1,4 @@
import Button from './Button';
import Button from '../../common/components/Button';
/**
* The `TextEditorButton` component displays a button suitable for the text

View File

@@ -1,7 +1,6 @@
import subclassOf from '../../common/utils/subclassOf';
import Stream from '../../common/utils/Stream';
import ReplyComposer from '../components/ReplyComposer';
import EditorDriverInterface from '../../common/utils/EditorDriverInterface';
class ComposerState {
constructor() {
@@ -30,7 +29,7 @@ class ComposerState {
/**
* A reference to the text editor that allows text manipulation.
*
* @type {EditorDriverInterface|null}
* @type {SuperTextArea|null}
*/
this.editor = null;
@@ -67,16 +66,12 @@ class ComposerState {
clear() {
this.position = ComposerState.Position.HIDDEN;
this.body = { attrs: {} };
this.editor = null;
this.onExit = null;
this.fields = {
content: Stream(''),
};
if (this.editor) {
this.editor.destroy();
}
this.editor = null;
}
/**

View File

@@ -1,4 +1,8 @@
import Pagination from '../../common/utils/Pagination';
export default class DiscussionListState {
static DISCUSSIONS_PER_PAGE = 20;
constructor(params = {}, app = window.app) {
this.params = params;
@@ -8,7 +12,7 @@ export default class DiscussionListState {
this.moreResults = false;
this.loading = false;
this.pagination = new Pagination(this.load.bind(this));
}
/**
@@ -82,33 +86,16 @@ export default class DiscussionListState {
* This can be used to refresh discussions without loading animations.
*/
refresh({ deferClear = false } = {}) {
this.loading = true;
this.pagination.loading.next = true;
if (!deferClear) {
this.clear();
}
return this.loadResults().then(
(results) => {
// This ensures that any changes made while waiting on this request
// are ignored. Otherwise, we could get duplicate discussions.
// We don't use `this.clear()` to avoid an unnecessary redraw.
this.discussions = [];
this.parseResults(results);
},
() => {
this.loading = false;
m.redraw();
}
);
return this.pagination.refresh(Number(m.route.param('page')) || 1).then(this.parse.bind(this));
}
/**
* Load a new page of discussion results.
*
* @param offset The index to start the page at.
*/
loadResults(offset) {
load(page) {
const preloadedDiscussions = this.app.preloadedApiDocument();
if (preloadedDiscussions) {
@@ -116,44 +103,54 @@ export default class DiscussionListState {
}
const params = this.requestParams();
params.page = { offset };
params.page = { offset: DiscussionListState.DISCUSSIONS_PER_PAGE * (page - 1) };
params.include = params.include.join(',');
return this.app.store.find('discussions', params);
}
/**
* Load the next page of discussion results.
*/
loadMore() {
this.loading = true;
loadPrev() {
return this.pagination.loadPrev().then(this.parse.bind(this));
}
this.loadResults(this.discussions.length).then(this.parseResults.bind(this));
loadMore() {
return this.pagination.loadNext().then(this.parse.bind(this));
}
/**
* Parse results and append them to the discussion list.
*/
parseResults(results) {
this.discussions.push(...results);
parse() {
const discussions = [];
const { first, last } = this.pagination.pages;
this.loading = false;
this.moreResults = !!results.payload.links && !!results.payload.links.next;
for (let page = first; page <= last; page++) {
const results = this.pagination.data[page];
if (Array.isArray(results)) discussions.push(...results);
}
this.discussions = discussions;
const results = this.pagination.data[last];
this.moreResults = !!results.payload.links.next;
m.redraw();
return results;
return discussions;
}
/**
* Remove a discussion from the list if it is present.
*/
removeDiscussion(discussion) {
const index = this.discussions.indexOf(discussion);
Object.keys(this.pagination.data).forEach((key) => {
const index = this.pagination.data[key].indexOf(discussion);
if (index !== -1) {
this.discussions.splice(index, 1);
}
this.pagination.data[key].splice(index, 1);
});
this.parse();
m.redraw();
}
@@ -177,7 +174,11 @@ export default class DiscussionListState {
* Are discussions currently being loaded?
*/
isLoading() {
return this.loading;
return this.pagination.loading.next;
}
isLoadingPrev() {
return this.pagination.loading.prev;
}
/**

View File

@@ -1,4 +1,3 @@
import { throttle } from 'lodash-es';
import anchorScroll from '../../common/utils/anchorScroll';
class PostStreamState {
@@ -50,9 +49,6 @@ class PostStreamState {
*/
this.forceUpdateScrubber = false;
this.loadNext = throttle(this._loadNext, 300);
this.loadPrevious = throttle(this._loadPrevious, 300);
this.show(includedPosts);
}
@@ -191,7 +187,7 @@ class PostStreamState {
/**
* Load the next page of posts.
*/
_loadNext() {
loadNext() {
const start = this.visibleEnd;
const end = (this.visibleEnd = this.sanitizeIndex(this.visibleEnd + this.constructor.loadCount));
@@ -214,7 +210,7 @@ class PostStreamState {
/**
* Load the previous page of posts.
*/
_loadPrevious() {
loadPrevious() {
const end = this.visibleStart;
const start = (this.visibleStart = this.sanitizeIndex(this.visibleStart - this.constructor.loadCount));

View File

@@ -57,7 +57,7 @@ export default {
moderationControls(user) {
const items = new ItemList();
if (user.canEdit() || user.canEditCredentials() || user.canEditGroups()) {
if (user.canEdit()) {
items.add(
'edit',
<Button icon="fas fa-pencil-alt" onclick={this.editAction.bind(this, user)}>

View File

@@ -1,13 +0,0 @@
/**
* @see https://stackoverflow.com/a/31732310
*/
export default function isSafariMobile(): boolean {
return (
'ontouchstart' in window &&
navigator.vendor &&
navigator.vendor.includes('Apple') &&
navigator.userAgent &&
!navigator.userAgent.includes('CriOS') &&
!navigator.userAgent.includes('FxiOS')
);
}

View File

@@ -206,7 +206,7 @@
.ExtensionNavButton {
.Button-label {
display: inline-block;
max-width: ~"calc(100% - 18px)";
max-width: calc(100% - 18px);
overflow: hidden;
text-overflow: ellipsis;
vertical-align: middle;
@@ -218,6 +218,5 @@
background-color: #2ECC40;
}
.ExtensionListItem-Dot.disabled {
border: 2px solid #FF4136;
box-sizing: border-box;
background-color: #FF4136;
}

View File

@@ -1,5 +1,4 @@
.AppearancePage {
padding-bottom: 30px;
@media @desktop-up {
.container {
@@ -15,16 +14,8 @@
.AppearancePage-colors-input {
overflow: hidden;
.Form-group {
display: inline-block;
}
.Form-group:last-child {
margin-bottom: 24px !important;
margin-left: 10px;
}
input {
width: 49%;
float: left;
&:first-child {
@@ -32,7 +23,7 @@
}
}
}
.AppearancePage-colors-input,
.AppearancePage-colors .Checkbox {
margin-bottom: 15px;
}

View File

@@ -1,5 +1,4 @@
.BasicsPage {
padding-bottom: 30px;
@media @desktop-up {
.container {
@@ -8,27 +7,26 @@
}
}
.Form-group {
fieldset {
margin-bottom: 20px;
}
ul {
list-style: none;
margin: 0;
padding: 0;
> ul {
list-style: none;
margin: 0;
padding: 0;
}
}
}
.BasicsPage-welcomeBanner-input {
input {
:first-child {
margin-bottom: 1px;
border-bottom-right-radius: 0;
border-bottom-left-radius: 0;
font-weight: bold;
}
textarea {
:last-child {
border-top-right-radius: 0;
border-top-left-radius: 0;
margin-bottom: 10px;
}
}

View File

@@ -2,7 +2,6 @@
background: @body-bg;
color: @control-color;
min-height: 100vh;
padding-bottom: 30px;
}
.Widget {

View File

@@ -1,4 +1,5 @@
.ExtensionPage {
min-height: 110vh;
.ExtensionPage-header {
.ExtensionTitle {
@@ -141,13 +142,11 @@
.ExtensionPage-permissions {
.PermissionGrid-removeScope {
display: none;
}
> .container {
overflow-x: auto;
padding-bottom: 25vh;
@media @phone {
> .container {
overflow-x: scroll;
padding-bottom: 20px;
}
}
.ExtensionPage-permissions-header {

View File

@@ -7,6 +7,18 @@
padding: 0;
background-color: @body-bg;
.ExtensionList-Category {
background: @control-bg;
padding: 20px 0 20px 20px;
margin-bottom: 20px;
border-radius: @border-radius;
.ExtensionList-Label {
margin-top: 0;
color: @muted-color;
}
}
.ExtensionGroup {
margin-bottom: 20px;
@@ -34,18 +46,6 @@
}
}
.ExtensionList-Category {
background: @control-bg;
padding: 20px 0 20px 20px;
margin-bottom: 20px;
border-radius: @border-radius;
}
.ExtensionList-Label {
margin-top: 0;
color: @muted-color;
}
.ExtensionListItem.disabled {
.ExtensionListItem-title {
opacity: 0.5;
@@ -64,18 +64,18 @@
transform: scale(1.05);
}
.ExtensionListItem-title {
display: block;
text-align: center;
margin-top: 5px;
color: @text-color;
}
a:hover {
text-decoration: none;
}
}
.ExtensionListItem-title {
display: block;
text-align: center;
margin-top: 5px;
color: @text-color;
}
.ExtensionIcon {
width: 90px;
height: 90px;

View File

@@ -1,5 +1,4 @@
.MailPage {
padding-bottom: 30px;
@media @desktop-up {
.container {
@@ -8,11 +7,11 @@
}
}
button, .Alert {
fieldset, .Alert {
margin-bottom: 20px;
}
ul {
fieldset > ul {
list-style: none;
margin: 0;
padding: 0;

View File

@@ -1,9 +1,15 @@
.PermissionsPage-groups {
background: @control-bg;
border-radius: @border-radius;
max-width: calc(~'100% - 60px');
display: block;
margin-left: 30px;
overflow-x: auto;
padding: 10px;
padding: 10px 0 8px;
> .container {
padding: 0 10px;
}
}
.Group {
width: 90px;
@@ -40,6 +46,7 @@
.PermissionGrid {
white-space: nowrap;
padding-left: 0 12px;
td, th {
padding: 5px;

View File

@@ -32,7 +32,7 @@
&[disabled],
fieldset[disabled] & {
cursor: not-allowed;
cursor: disallowed;
}
textarea& {

View File

@@ -15,6 +15,7 @@
}
}
.Search-results {
max-height: 70vh;
overflow: auto;
left: auto;
right: 0;

View File

@@ -1,49 +0,0 @@
.TextEditor .TextEditor-editor {
border-radius: 0;
padding: 0 0 10px;
border: 0;
resize: none;
color: @text-color;
font-size: 14px;
line-height: 1.7;
&, &:focus, &[disabled] {
background: none;
border: 0;
}
@media @phone {
font-size: 16px; // minimum font-size required to prevent page zoom on focus in iOS 10
}
}
.TextEditor-controls {
margin: 0;
padding: 10px 0;
list-style-type: none;
overflow-x: auto;
white-space: nowrap;
li {
display: inline-block;
margin-right: 10px;
}
}
.TextEditor-toolbar {
.Button--icon {
width: 28px;
}
}
@media @tablet-up {
.TextEditor-controls {
margin: 0 -20px 0 -105px;
padding: 10px 20px;
border-top: 1px solid @control-bg;
.fullScreen & {
margin: 0;
border-top: 0;
padding: 20px 0;
}
}
}

View File

@@ -25,6 +25,5 @@
@import "Placeholder";
@import "Search";
@import "Select";
@import "TextEditor";
@import "Tooltip";
@import "ValidationError";

View File

@@ -117,15 +117,8 @@
position: fixed;
top: 0;
height: 350px !important;
max-height: 100%;
padding-top: @header-height-phone;
// Fixes a bug where fixed position doesn't properly work in Safari mobile
// https://github.com/flarum/core/issues/2652
.mobile-safari & {
position: absolute;
}
&:before {
content: " ";
.header-background();
@@ -299,7 +292,7 @@
}
}
.ComposerBody-editor {
.fullScreen & .TextEditor-editor {
.fullScreen & textarea {
font-size: 16px;
}
}
@@ -325,3 +318,56 @@
left: @pane-width;
}
}
// ------------------------------------
// Text Editor
.TextEditor textarea {
border-radius: 0;
padding: 0 0 10px;
border: 0;
resize: none;
color: @text-color;
font-size: 14px;
line-height: 1.7;
&, &:focus, &[disabled] {
background: none;
border: 0;
}
@media @phone {
font-size: 16px; // minimum font-size required to prevent page zoom on focus in iOS 10
}
}
.TextEditor-controls {
margin: 0;
padding: 10px 0;
list-style-type: none;
overflow-x: auto;
white-space: nowrap;
li {
display: inline-block;
margin-right: 10px;
}
}
.TextEditor-toolbar {
.Button--icon {
width: 28px;
}
}
@media @tablet-up {
.TextEditor-controls {
margin: 0 -20px 0 -105px;
padding: 10px 20px;
border-top: 1px solid @control-bg;
.fullScreen & {
margin: 0;
border-top: 0;
padding: 20px 0;
}
}
}

View File

@@ -110,11 +110,6 @@
}
}
@media (any-hover: none) {
.DiscussionListItem-controls > .Dropdown-toggle {
display: none;
}
}
@media @phone {
.DiscussionListItem-controls {

View File

@@ -1,653 +0,0 @@
core:
##
# UNIQUE KEYS - The following keys are used in only one location each.
##
# Translations in this namespace are used by the admin interface.
admin:
# These translations are used in the Appearance page.
appearance:
colored_header_label: Colored Header
colors_heading: Colors
colors_text: "Choose two colors to theme your forum with. The first will be used as a highlight color, while the second will be used to style background elements."
custom_footer_heading: Custom Footer
custom_footer_text: => core.ref.custom_footer_text
custom_header_heading: Custom Header
custom_header_text: => core.ref.custom_header_text
custom_styles_heading: Custom Styles
custom_styles_text: Customize your forum's appearance by adding your own LESS/CSS code to be applied on top of Flarum's default styles.
dark_mode_label: Dark Mode
description: "Customize your forum's colors, logos, and other variables."
edit_css_button: Edit Custom CSS
edit_footer_button: => core.ref.custom_footer_title
edit_header_button: => core.ref.custom_header_title
enter_hex_message: Please enter a hexadecimal color code.
favicon_heading: Favicon
favicon_text: Upload an image to be displayed as the forum's shortcut icon.
logo_heading: Logo
logo_text: Upload an image to be displayed in place of the forum title.
title: Appearance
# These translations are used in the Basics page.
basics:
all_discussions_label: => core.ref.all_discussions
default_language_heading: Default Language
description: "Set your forum title, language, and other basic settings."
display_name_heading: User Display Name
display_name_text: Select the driver that should be used for users' display names. By default, the username is shown.
forum_description_heading: Forum Description
forum_description_text: Enter a short sentence or two that describes your community. This will appear in the meta tag and show up in search engines.
forum_title_heading: Forum Title
home_page_heading: Home Page
home_page_text: Choose the page which users will first see when they visit your forum.
show_language_selector_label: Show language selector
slug_driver_heading: "Slug Driver: {model}"
slug_driver_text: Select a driver to be used for slugging this model.
title: Basics
welcome_banner_heading: Welcome Banner
welcome_banner_text: Configure the text that displays in the banner on the All Discussions page. Use this to welcome guests to your forum.
# These translations are used in the Dashboard page.
dashboard:
clear_cache_button: Clear Cache
description: Your forum at a glance.
title: Dashboard
tools_button: Tools
# These translations are used in the Edit Custom CSS modal dialog.
edit_css:
customize_text: "Customize your forum's appearance by adding your own LESS/CSS code to be applied on top of Flarum's <a>default styles</a>."
submit_button: => core.ref.save_changes
title: Edit Custom CSS
# These translations are used in the Edit Custom Footer modal dialog.
edit_footer:
customize_text: => core.ref.custom_footer_text
submit_button: => core.ref.save_changes
title: => core.ref.custom_footer_title
# These translations are used in the Edit Group modal dialog.
edit_group:
color_label: => core.ref.color
delete_button: Delete Group
delete_confirmation: "Are you sure you want to delete this group? The group members will NOT be deleted."
hide_label: Hide on forum
icon_label: => core.ref.icon
icon_text: => core.ref.icon_text
name_label: Name
plural_placeholder: Plural (e.g. Mods)
singular_placeholder: Singular (e.g. Mod)
submit_button: => core.ref.save_changes
title: Create Group
# These translations are used in the Edit Custom Header modal dialog.
edit_header:
customize_text: => core.ref.custom_header_text
submit_button: => core.ref.save_changes
title: => core.ref.custom_header_title
# These translations are used in the email page of the admin interface.
email:
addresses_heading: Addresses
description: "Configure the driver, settings and addresses your forum will use to send email."
driver_heading: Choose a Driver
driver_label: Driver
from_label: Sender
mail_encryption_label: Encryption
mail_host_label: Host
mail_mailgun_domain_label: Domain
mail_mailgun_region_label: Region
mail_mailgun_secret_label: Secret key
mail_password_label: => core.ref.password
mail_port_label: Port
mail_username_label: => core.ref.username
mailgun_heading: Mailgun Settings
not_sending_message: Flarum currently does not send emails. This can be due to the selected driver, or errors in its configuration.
send_test_mail_button: Send
send_test_mail_heading: Send Test Mail
send_test_mail_success: "Test mail sent successfully!"
send_test_mail_text: "This will send an email using the above configuration to your email, {email}."
smtp_heading: SMTP Settings
title: => core.ref.email
# These translations are used on default extension pages.
extension:
configure_scopes: Configure Scopes
confirm_uninstall: Uninstalling will remove all database entries and assets related to the extension. Are you sure you want to continue?
disabled: Disabled
enable_to_see: Enable the extension to view and change settings.
enabled: Enabled
info_links:
discuss: Discuss
documentation: Documentation
donate: Donate
source: Source
support: Support
website: Website
no_permissions: This extension has no permissions.
no_settings: This extension has no settings.
open_modal: Open Settings
permissions_title: Permissions
uninstall_button: Uninstall
# These translations are used in the secondary header.
header:
get_help: Get Help
log_out_button: => core.ref.log_out
# These translations are used in the modal dialog displayed when loading extensions.
loading:
title: Please Wait...
# These translations are used in the navigation bar.
nav:
appearance_button: => core.admin.appearance.title
appearance_title: => core.admin.appearance.description
basics_button: => core.admin.basics.title
basics_title: => core.admin.basics.description
categories:
authentication: Authentication
core: Core Configuration
discussion: Discussion
feature: Features
formatting: Formatting
language: Languages
moderation: Moderation
other: Other Extensions
theme: Themes
dashboard_button: => core.admin.dashboard.title
dashboard_title: => core.admin.dashboard.description
email_button: => core.ref.email
email_title: => core.admin.email.description
permissions_button: => core.admin.permissions.title
permissions_title: => core.admin.permissions.description
search_placeholder: Search Extensions
# These translations are used in the Permissions page of the admin interface.
permissions:
allow_post_editing_label: Allow post editing
allow_renaming_label: Allow renaming
create_heading: Create
delete_discussions_forever_label: Delete discussions forever
delete_discussions_label: Delete discussions
delete_posts_forever_label: Delete posts forever
delete_posts_label: Delete posts
description: Configure who can see and do what.
edit_posts_label: Edit posts
edit_users_label: Edit user attributes
edit_users_credentials_label: Edit user credentials
edit_users_groups_label: Edit user groups
global_heading: Global
moderate_heading: Moderate
new_group_button: New Group
participate_heading: Participate
post_without_throttle_label: Reply multiple times without waiting
read_heading: Read
rename_discussions_label: Rename discussions
reply_to_discussions_label: Reply to discussions
sign_up_label: Sign up
start_discussions_label: Start discussions
title: Permissions
view_discussions_label: View discussions
view_hidden_groups_label: View hidden group badges
view_last_seen_at_label: Always view user last seen time
view_post_ips_label: View post IP addresses
view_user_list_label: View user list
# These translations are used in the dropdown menus on the Permissions page.
permissions_controls:
allow_indefinitely_button: Indefinitely
allow_some_minutes_button: "For {count} minute|For {count} minutes"
allow_ten_minutes_button: For 10 minutes
allow_until_reply_button: Until next reply
everyone_button: Everyone
members_button: => core.group.members
signup_closed_button: Closed
signup_open_button: Open
# These translations are used generically in setting fields.
settings:
saved_message: Your changes were saved.
submit_button: => core.ref.save_changes
# These translations are used in image upload buttons.
upload_image:
remove_button: => core.ref.remove
upload_button: Choose an Image...
# Translations in this namespace are used by the forum user interface.
forum:
# These translations are used in the Change Email modal dialog.
change_email:
confirm_password_placeholder: => core.ref.confirm_password
confirmation_message: => core.ref.confirmation_email_sent
dismiss_button: => core.ref.okay
incorrect_password_message: The password you entered is incorrect.
submit_button: => core.ref.save_changes
title: => core.ref.change_email
# These translations are used in the Change Password modal dialog.
change_password:
send_button: Send Password Reset Email
text: Click the button below and check your email for a link to change your password.
title: => core.ref.change_password
# These translations are used by the composer controls.
composer:
close_tooltip: Close
exit_full_screen_tooltip: Exit Full Screen
full_screen_tooltip: Full Screen
minimize_tooltip: Minimize
preview_tooltip: Preview
# These translations are used by the composer when starting a discussion.
composer_discussion:
body_placeholder: Write a Post...
discard_confirmation: "You have not posted your discussion. Do you wish to discard it?"
submit_button: Post Discussion
title: => core.ref.start_a_discussion
title_placeholder: Discussion Title
# These translations are used by the composer when editing a post.
composer_edit:
discard_confirmation: "You have not saved your changes. Do you wish to discard them?"
edited_message: Your edit was made.
post_link: "Post #{number} in {discussion}"
submit_button: => core.ref.save_changes
view_button: => core.ref.view
# These translations are used by the composer when replying to a discussion.
composer_reply:
body_placeholder: => core.ref.write_a_reply
discard_confirmation: "You have not posted your reply. Do you wish to discard it?"
posted_message: Your reply was posted.
submit_button: Post Reply
view_button: => core.ref.view
# These translations are used by the discussion control buttons.
discussion_controls:
cannot_reply_button: Can't Reply
cannot_reply_text: You don't have permission to reply to this discussion.
delete_button: => core.ref.delete
delete_confirmation: "Are you sure you want to delete this discussion?"
delete_forever_button: => core.ref.delete_forever
log_in_to_reply_button: Log In to Reply
rename_button: => core.ref.rename
reply_button: => core.ref.reply
restore_button: => core.ref.restore
# These translations are used in the discussion list.
discussion_list:
empty_text: It looks as though there are no discussions here.
load_more_button: => core.ref.load_more
mark_as_read_tooltip: Mark as Read
replied_text: "{username} replied {ago}"
started_text: "{username} started {ago}"
# These translations are used in the Edit User modal dialog (admin function).
edit_user:
activate_button: Activate User
email_heading: => core.ref.email
email_label: => core.ref.email
groups_heading: Groups
nothing_available: There is nothing available for you to edit at this time.
password_heading: => core.ref.password
password_label: => core.ref.password
set_password_label: Set new password
submit_button: => core.ref.save_changes
title: Edit User
username_heading: => core.ref.username
username_label: => core.ref.username
# These translations are used in the Forgot Password modal dialog.
forgot_password:
dismiss_button: => core.ref.okay
email_placeholder: => core.ref.email
email_sent_message: We've sent you an email containing a link to reset your password. Check your spam folder if you don't receive it within the next minute or two.
not_found_message: There is no user registered with that email address.
submit_button: Recover Password
text: Enter your email address and we will send you a link to reset your password.
title: Forgot Password
# These translations are used in the header and session dropdown menu.
header:
admin_button: Administration
back_to_index_tooltip: Back to Discussion List
log_in_link: => core.ref.log_in
log_out_button: => core.ref.log_out
profile_button: Profile
search_placeholder: Search Forum
settings_button: => core.ref.settings
sign_up_link: => core.ref.sign_up
# These translations are used on the index page, peripheral to the discussion list.
index:
all_discussions_link: => core.ref.all_discussions
cannot_start_discussion_button: Can't Start Discussion
mark_all_as_read_confirmation: "Are you sure you want to mark all discussions as read?"
mark_all_as_read_tooltip: => core.ref.mark_all_as_read
meta_title_text: => core.ref.all_discussions
refresh_tooltip: Refresh
start_discussion_button: => core.ref.start_a_discussion
# These translations are used by the sorting control above the discussion list.
index_sort:
latest_button: Latest
newest_button: Newest
oldest_button: Oldest
relevance_button: Relevance
top_button: Top
# These translations are used in the Log In modal dialog.
log_in:
forgot_password_link: "Forgot password?"
invalid_login_message: Your login details were incorrect.
password_placeholder: => core.ref.password
remember_me_label: Remember Me
sign_up_text: "Don't have an account? <a>Sign Up</a>"
submit_button: => core.ref.log_in
title: => core.ref.log_in
username_or_email_placeholder: Username or Email
# These translations are used by the Notifications dropdown, a.k.a. "the bell".
notifications:
discussion_renamed_text: "{username} changed the title"
empty_text: No Notifications
mark_all_as_read_tooltip: => core.ref.mark_all_as_read
mark_as_read_tooltip: Mark as Read
title: => core.ref.notifications
tooltip: => core.ref.notifications
# These translations are used by tooltips displayed for individual posts.
post:
edited_text: Edited
edited_tooltip: "{username} edited {ago}"
number_tooltip: "Post #{number}"
# These translations are used by the post control buttons.
post_controls:
delete_button: => core.ref.delete
delete_confirmation: "Are you sure you want to delete this post forever? This action cannot be undone."
delete_forever_button: => core.ref.delete_forever
edit_button: => core.ref.edit
hide_confirmation: "Are you sure you want to delete this post?"
restore_button: => core.ref.restore
# These translations are used in the scrubber to the right of the post stream.
post_scrubber:
now_link: Now
original_post_link: Original Post
unread_text: "{count} unread"
viewing_text: "{index} of {count} post|{index} of {count} posts"
# These translations are displayed between posts in the post stream.
post_stream:
discussion_renamed_old_tooltip: 'The old title was: "{old}"'
discussion_renamed_text: "{username} changed the title to {new}."
load_more_button: => core.ref.load_more
reply_placeholder: => core.ref.write_a_reply
time_lapsed_text: "{period} later"
# These translations are used by the rename discussion modal.
rename_discussion:
submit_button: => core.ref.rename
title: Rename Discussion
# These translations are used by the search results dropdown list.
search:
all_discussions_button: 'Search all discussions for "{query}"'
discussions_heading: => core.ref.discussions
users_heading: => core.ref.users
# These translations are used in the Settings page.
settings:
account_heading: Account
change_email_button: => core.ref.change_email
change_password_button: => core.ref.change_password
notifications_heading: => core.ref.notifications
notify_by_email_heading: => core.ref.email
notify_by_web_heading: Web
notify_discussion_renamed_label: Someone renames a discussion I started
privacy_disclose_online_label: Allow others to see when I am online
privacy_heading: Privacy
title: => core.ref.settings
# These translations are used in the Sign Up modal dialog.
sign_up:
dismiss_button: => core.ref.okay
email_placeholder: => core.ref.email
log_in_text: "Already have an account? <a>Log In</a>"
password_placeholder: => core.ref.password
submit_button: => core.ref.sign_up
title: => core.ref.sign_up
username_placeholder: => core.ref.username
welcome_text: "Welcome, {username}!"
# These translations are used in the user profile page and profile popup.
user:
avatar_remove_button: => core.ref.remove
avatar_upload_button: Upload
avatar_upload_tooltip: Upload a new avatar
discussions_link: => core.ref.discussions
in_discussion_text: "In {discussion}"
joined_date_text: "Joined {ago}"
online_text: Online
posts_empty_text: It looks like there are no posts here.
posts_link: => core.ref.posts
posts_load_more_button: => core.ref.load_more
settings_link: => core.ref.settings
# These translations are found on the user profile page (admin function).
user_controls:
button: Controls
delete_button: => core.ref.delete
delete_confirmation: "Are you sure you want to delete this user? The user's posts will NOT be deleted."
delete_error_message: "Deletion of user <i>{username} ({email})</i> failed"
delete_success_message: "User <i>{username} ({email})</i> was deleted"
edit_button: => core.ref.edit
# These translations are used in the alert that is shown when a new user has not confirmed their email address.
user_email_confirmation:
alert_message: => core.ref.confirmation_email_sent
resend_button: Resend Confirmation Email
sent_message: Sent
# Translations in this namespace are used by the forum and admin interfaces.
lib:
# These translations are displayed as tooltips for discussion badges.
badge:
hidden_tooltip: Hidden
# These translations are displayed as error messages.
error:
dependent_extensions_message: "Cannot disable {extension} until the following dependent extensions are disabled: {extensions}"
generic_message: "Oops! Something went wrong. Please reload the page and try again."
missing_dependencies_message: "Cannot enable {extension} until the following dependencies are enabled: {extensions}"
not_found_message: The requested resource was not found.
permission_denied_message: You do not have permission to do that.
rate_limit_exceeded_message: You're going a little too quickly. Please try again in a few seconds.
# These translations are used as suffixes when abbreviating numbers.
number_suffix:
kilo_text: K
mega_text: M
# These translations are used to punctuate a series of items.
series:
glue_text: ", "
three_text: "{first}, {second}, and {third}"
two_text: "{first} and {second}"
# These translations are used to modify usernames.
username:
deleted_text: "[deleted]"
# Translations in this namespace are used in views other than Flarum's normal JS client.
views:
# Translations in this namespace are displayed by the basic HTML content loader.
content:
javascript_disabled_message: This site is best viewed in a modern browser with JavaScript enabled.
load_error_message: Something went wrong while trying to load the full version of this site. Try hard-refreshing this page to fix the error.
loading_text: Loading...
# Translations in this namespace are displayed in the basic HTML discussion view.
discussion:
next_page_button: => core.ref.next_page
previous_page_button: => core.ref.previous_page
# Translations in this namespace are displayed when Flarum encounters an error.
error:
csrf_token_mismatch: You have been inactive for too long.
csrf_token_mismatch_return_link: Go back, to try again
invalid_confirmation_token: This confirmation link has already been used or is invalid.
not_authenticated: You do not have permission to access this page. Try again after logging in.
not_found: The page you requested could not be found.
not_found_return_link: "Return to {forum}"
permission_denied: You do not have permission to access this page.
unknown: An error occurred while trying to load this page.
# Translations in this namespace are displayed by the basic HTML discussion index.
index:
all_discussions_heading: => core.ref.all_discussions
next_page_button: => core.ref.next_page
previous_page_button: => core.ref.previous_page
# Translations in this namespace are displayed by the Log Out confirmation interface.
log_out:
log_out_button: => core.ref.log_out
log_out_confirmation: "Are you sure you want to log out of {forum}?"
title: => core.ref.log_out
# Translations in this namespace are displayed by the Reset Password interface.
reset_password:
confirm_password_label: Confirm New Password
new_password_label: New Password
submit_button: => core.ref.save_changes
title: => core.ref.reset_your_password
# Translations in this namespace are used in messages output by the API.
api:
invalid_username_message: "The username may only contain letters, numbers, and dashes."
# Translations in this namespace are used in emails sent by the forum.
email:
# These translations are used in emails sent when users register new accounts.
activate_account:
subject: Activate Your New Account
body: |
Hey {username}!
Someone (hopefully you!) has signed up to {forum} with this email address.
If this was you, simply click the following link and your account will be activated:
{url}
If you did not sign up, please ignore this email.
# These translations are used in emails sent when users change their email address.
confirm_email:
subject: Confirm Your New Email Address
body: |
Hey {username}!
Someone (hopefully you!) has changed their email address on {forum} to this one.
If this was you, simply click the following link and your email will be confirmed:
{url}
If this was not you, please ignore this email.
# These translations are used in emails sent when users ask to reset their passwords.
reset_password:
subject: => core.ref.reset_your_password
body: |
Hey {username}!
Someone (hopefully you!) has submitted a forgotten password request for your account on {forum}.
If this was you, click the following link to reset your password:
{url}
If you do not wish to change your password, just ignore this email and nothing will happen.
# These translations are used when testing mailing configuration
send_test:
subject: Flarum Email Test
body: |
Hey {username}!
This is a test email to confirm that your Flarum email configuration is working properly.
If this was you, this email means that your configuration works!
If this was not you, please ignore this email.
##
# REUSED TRANSLATIONS - These keys should not be used directly in code!
##
# Translations in this namespace are referenced by two or more unique keys.
ref:
all_discussions: All Discussions
change_email: Change Email
change_password: Change Password
color: Color # Referenced by flarum-tags.yml
confirm_password: Confirm Password
confirmation_email_sent: "We've sent a confirmation email to {email}. If it doesn't arrive soon, check your spam folder."
custom_footer_text: Add HTML to be displayed at the very bottom of the page.
custom_footer_title: Edit Custom Footer
custom_header_text: "Add HTML to be displayed at the very top of the page, above Flarum's own header."
custom_header_title: Edit Custom Header
delete: Delete
delete_forever: Delete Forever
discussions: Discussions # Referenced by flarum-statistics.yml
edit: Edit
email: Email
icon: Icon
icon_text: "Enter the name of any <a>FontAwesome</a> icon class, <em>including</em> the <code>fas fa-</code> prefix."
load_more: Load More
log_in: Log In
log_out: Log Out
mark_all_as_read: Mark All as Read
next_page: Next Page
notifications: Notifications
okay: OK # Referenced by flarum-tags.yml
password: Password
posts: Posts # Referenced by flarum-statistics.yml
previous_page: Previous Page
remove: Remove
rename: Rename
reply: Reply # Referenced by flarum-mentions.yml
reset_your_password: Reset Your Password
restore: Restore
save_changes: Save Changes # Referenced by flarum-suspend.yml, flarum-tags.yml
settings: Settings
sign_up: Sign Up
some_others: "{count} other|{count} others" # Referenced by flarum-likes.yml, flarum-mentions.yml
start_a_discussion: Start a Discussion
username: Username
users: Users # Referenced by flarum-statistics.yml
view: View
write_a_reply: Write a Reply...
you: You # Referenced by flarum-likes.yml, flarum-mentions.yml
##
# GROUP NAMES - These keys are translated at the back end.
##
# Translations in this namespace are used to translate default group names.
group:
admin: Admin
admins: Admins
guest: Guest
guests: Guests
member: Member
members: Members
mod: Mod
mods: Mods

View File

@@ -1,108 +0,0 @@
validation:
accepted: "The :attribute must be accepted."
active_url: "The :attribute is not a valid URL."
after: "The :attribute must be a date after :date."
after_or_equal: "The :attribute must be a date after or equal to :date."
alpha: "The :attribute may only contain letters."
alpha_dash: "The :attribute may only contain letters, numbers, dashes and underscores."
alpha_num: "The :attribute may only contain letters and numbers."
array: "The :attribute must be an array."
before: "The :attribute must be a date before :date."
before_or_equal: "The :attribute must be a date before or equal to :date."
between:
numeric: "The :attribute must be between :min and :max."
file: "The :attribute must be between :min and :max kilobytes."
string: "The :attribute must be between :min and :max characters."
array: "The :attribute must have between :min and :max items."
boolean: "The :attribute field must be true or false."
confirmed: "The :attribute confirmation does not match."
date: "The :attribute is not a valid date."
date_equals: "The :attribute must be a date equal to :date."
date_format: "The :attribute does not match the format :format."
different: "The :attribute and :other must be different."
digits: "The :attribute must be :digits digits."
digits_between: "The :attribute must be between :min and :max digits."
dimensions: "The :attribute has invalid image dimensions."
distinct: "The :attribute field has a duplicate value."
email: "The :attribute must be a valid email address."
ends_with: "The :attribute must end with one of the following: :values."
exists: "The selected :attribute is invalid."
file: "The :attribute must be a file."
filled: "The :attribute field must have a value."
gt:
numeric: "The :attribute must be greater than :value."
file: "The :attribute must be greater than :value kilobytes."
string: "The :attribute must be greater than :value characters."
array: "The :attribute must have more than :value items."
gte:
numeric: "The :attribute must be greater than or equal :value."
file: "The :attribute must be greater than or equal :value kilobytes."
string: "The :attribute must be greater than or equal :value characters."
array: "The :attribute must have :value items or more."
image: "The :attribute must be an image."
in: "The selected :attribute is invalid."
in_array: "The :attribute field does not exist in :other."
integer: "The :attribute must be an integer."
ip: "The :attribute must be a valid IP address."
ipv4: "The :attribute must be a valid IPv4 address."
ipv6: "The :attribute must be a valid IPv6 address."
json: "The :attribute must be a valid JSON string."
lt:
numeric: "The :attribute must be less than :value."
file: "The :attribute must be less than :value kilobytes."
string: "The :attribute must be less than :value characters."
array: "The :attribute must have less than :value items."
lte:
numeric: "The :attribute must be less than or equal :value."
file: "The :attribute must be less than or equal :value kilobytes."
string: "The :attribute must be less than or equal :value characters."
array: "The :attribute must not have more than :value items."
max:
numeric: "The :attribute may not be greater than :max."
file: "The :attribute may not be greater than :max kilobytes."
string: "The :attribute may not be greater than :max characters."
array: "The :attribute may not have more than :max items."
mimes: "The :attribute must be a file of type: :values."
mimetypes: "The :attribute must be a file of type: :values."
min:
numeric: "The :attribute must be at least :min."
file: "The :attribute must be at least :min kilobytes."
string: "The :attribute must be at least :min characters."
array: "The :attribute must have at least :min items."
not_in: "The selected :attribute is invalid."
not_regex: "The :attribute format is invalid."
numeric: "The :attribute must be a number."
password: "The password is incorrect."
present: "The :attribute field must be present."
regex: "The :attribute format is invalid."
required: "The :attribute field is required."
required_if: "The :attribute field is required when :other is :value."
required_unless: "The :attribute field is required unless :other is in :values."
required_with: "The :attribute field is required when :values is present."
required_with_all: "The :attribute field is required when :values are present."
required_without: "The :attribute field is required when :values is not present."
required_without_all: "The :attribute field is required when none of :values are present."
same: "The :attribute and :other must match."
size:
numeric: "The :attribute must be :size."
file: "The :attribute must be :size kilobytes."
string: "The :attribute must be :size characters."
array: "The :attribute must contain :size items."
starts_with: "The :attribute must start with one of the following: :values."
string: "The :attribute must be a string."
timezone: "The :attribute must be a valid zone."
unique: "The :attribute has already been taken."
uploaded: "The :attribute failed to upload."
url: "The :attribute format is invalid."
uuid: "The :attribute must be a valid UUID."
attributes:
username: username
password: password
email: email
title: title
content: content
name_singular: singular name
name_plural: plural name
tag_count_primary: number of primary tags
tag_count_secondary: number of secondary tags

View File

@@ -1,43 +0,0 @@
<?php
/*
* This file is part of Flarum.
*
* For detailed copyright and license information, please view the
* LICENSE file that was distributed with this source code.
*/
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Database\Schema\Builder;
return [
'up' => function (Builder $schema) {
$schema->table('access_tokens', function (Blueprint $table) {
$table->string('type', 100)->index();
});
// Since all active sessions will stop working on update due to switching from user_id to access_token
// We can do things simple here by terminating all tokens that have the previously default lifetime
$schema->getConnection()->table('access_tokens')
->where('lifetime_seconds', 3600)
->delete();
// We will then assume that all remaining tokens are remember tokens
// This will include tokens that previously had a custom lifetime
$schema->getConnection()->table('access_tokens')
->update([
'type' => 'session_remember',
]);
$schema->table('access_tokens', function (Blueprint $table) {
$table->dropColumn('lifetime_seconds');
});
},
'down' => function (Builder $schema) {
$schema->table('access_tokens', function (Blueprint $table) {
$table->dropColumn('type');
$table->integer('lifetime_seconds');
});
}
];

View File

@@ -1,35 +0,0 @@
<?php
/*
* This file is part of Flarum.
*
* For detailed copyright and license information, please view the
* LICENSE file that was distributed with this source code.
*/
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Database\Schema\Builder;
return [
'up' => function (Builder $schema) {
$schema->table('access_tokens', function (Blueprint $table) {
// Replace primary key with unique index so we can create a new primary
$table->dropPrimary('token');
$table->unique('token');
});
// This needs to be done in a second statement because of the order Laravel runs operations in
$schema->table('access_tokens', function (Blueprint $table) {
// Introduce new increment-based ID
$table->increments('id')->first();
});
},
'down' => function (Builder $schema) {
$schema->table('access_tokens', function (Blueprint $table) {
$table->dropColumn('id');
$table->dropIndex('token');
$table->primary('token');
});
}
];

View File

@@ -1,21 +0,0 @@
<?php
/*
* This file is part of Flarum.
*
* For detailed copyright and license information, please view the
* LICENSE file that was distributed with this source code.
*/
use Flarum\Database\Migration;
return Migration::addColumns('access_tokens', [
'title' => ['string', 'length' => 150, 'nullable' => true],
// Accommodates both IPv4 and IPv6 as strings
'last_ip_address' => ['string', 'length' => 45, 'nullable' => true],
// Technically, there's no limit to a user agent length
// Most are around 150 in length, and the general recommendation seems to be below 200
// We're going to use the longest string possible to be safe
// There will still be exceptions, we'll just truncate them
'last_user_agent' => ['string', 'length' => 255, 'nullable' => true],
]);

View File

@@ -36,18 +36,18 @@ class AdminServiceProvider extends AbstractServiceProvider
*/
public function register()
{
$this->container->extend(UrlGenerator::class, function (UrlGenerator $url) {
return $url->addCollection('admin', $this->container->make('flarum.admin.routes'), 'admin');
$this->app->extend(UrlGenerator::class, function (UrlGenerator $url) {
return $url->addCollection('admin', $this->app->make('flarum.admin.routes'), 'admin');
});
$this->container->singleton('flarum.admin.routes', function () {
$this->app->singleton('flarum.admin.routes', function () {
$routes = new RouteCollection;
$this->populateRoutes($routes);
return $routes;
});
$this->container->singleton('flarum.admin.middleware', function () {
$this->app->singleton('flarum.admin.middleware', function () {
return [
'flarum.admin.error_handler',
HttpMiddleware\ParseJsonBody::class,
@@ -61,23 +61,23 @@ class AdminServiceProvider extends AbstractServiceProvider
];
});
$this->container->bind('flarum.admin.error_handler', function () {
$this->app->bind('flarum.admin.error_handler', function () {
return new HttpMiddleware\HandleErrors(
$this->container->make(Registry::class),
$this->container['flarum.config']->inDebugMode() ? $this->container->make(WhoopsFormatter::class) : $this->container->make(ViewFormatter::class),
$this->container->tagged(Reporter::class)
$this->app->make(Registry::class),
$this->app['flarum.config']->inDebugMode() ? $this->app->make(WhoopsFormatter::class) : $this->app->make(ViewFormatter::class),
$this->app->tagged(Reporter::class)
);
});
$this->container->bind('flarum.admin.route_resolver', function () {
return new HttpMiddleware\ResolveRoute($this->container->make('flarum.admin.routes'));
$this->app->bind('flarum.admin.route_resolver', function () {
return new HttpMiddleware\ResolveRoute($this->app->make('flarum.admin.routes'));
});
$this->container->singleton('flarum.admin.handler', function () {
$this->app->singleton('flarum.admin.handler', function () {
$pipe = new MiddlewarePipe;
foreach ($this->container->make('flarum.admin.middleware') as $middleware) {
$pipe->pipe($this->container->make($middleware));
foreach ($this->app->make('flarum.admin.middleware') as $middleware) {
$pipe->pipe($this->app->make($middleware));
}
$pipe->pipe(new HttpMiddleware\ExecuteRoute());
@@ -85,9 +85,9 @@ class AdminServiceProvider extends AbstractServiceProvider
return $pipe;
});
$this->container->bind('flarum.assets.admin', function () {
$this->app->bind('flarum.assets.admin', function () {
/** @var \Flarum\Frontend\Assets $assets */
$assets = $this->container->make('flarum.assets.factory')('admin');
$assets = $this->app->make('flarum.assets.factory')('admin');
$assets->js(function (SourceCollector $sources) {
$sources->addFile(__DIR__.'/../../js/dist/admin.js');
@@ -97,17 +97,17 @@ class AdminServiceProvider extends AbstractServiceProvider
$sources->addFile(__DIR__.'/../../less/admin.less');
});
$this->container->make(AddTranslations::class)->forFrontend('admin')->to($assets);
$this->container->make(AddLocaleAssets::class)->to($assets);
$this->app->make(AddTranslations::class)->forFrontend('admin')->to($assets);
$this->app->make(AddLocaleAssets::class)->to($assets);
return $assets;
});
$this->container->bind('flarum.frontend.admin', function () {
$this->app->bind('flarum.frontend.admin', function () {
/** @var \Flarum\Frontend\Frontend $frontend */
$frontend = $this->container->make('flarum.frontend.factory')('admin');
$frontend = $this->app->make('flarum.frontend.factory')('admin');
$frontend->content($this->container->make(Content\AdminPayload::class));
$frontend->content($this->app->make(Content\AdminPayload::class));
return $frontend;
});
@@ -120,14 +120,14 @@ class AdminServiceProvider extends AbstractServiceProvider
{
$this->loadViewsFrom(__DIR__.'/../../views', 'flarum.admin');
$events = $this->container->make('events');
$events = $this->app->make('events');
$events->listen(
[Enabled::class, Disabled::class, ClearingCache::class],
function () {
$recompile = new RecompileFrontendAssets(
$this->container->make('flarum.assets.admin'),
$this->container->make(LocaleManager::class)
$this->app->make('flarum.assets.admin'),
$this->app->make(LocaleManager::class)
);
$recompile->flush();
}
@@ -137,8 +137,8 @@ class AdminServiceProvider extends AbstractServiceProvider
Saved::class,
function (Saved $event) {
$recompile = new RecompileFrontendAssets(
$this->container->make('flarum.assets.admin'),
$this->container->make(LocaleManager::class)
$this->app->make('flarum.assets.admin'),
$this->app->make(LocaleManager::class)
);
$recompile->whenSettingsSaved($event);
}
@@ -150,7 +150,7 @@ class AdminServiceProvider extends AbstractServiceProvider
*/
protected function populateRoutes(RouteCollection $routes)
{
$factory = $this->container->make(RouteHandlerFactory::class);
$factory = $this->app->make(RouteHandlerFactory::class);
$callback = include __DIR__.'/routes.php';
$callback($routes, $factory);

View File

@@ -13,6 +13,7 @@ use Flarum\Api\Controller\AbstractSerializeController;
use Flarum\Api\Serializer\AbstractSerializer;
use Flarum\Api\Serializer\BasicDiscussionSerializer;
use Flarum\Api\Serializer\NotificationSerializer;
use Flarum\Event\ConfigureNotificationTypes;
use Flarum\Foundation\AbstractServiceProvider;
use Flarum\Foundation\ErrorHandling\JsonApiFormatter;
use Flarum\Foundation\ErrorHandling\Registry;
@@ -30,18 +31,18 @@ class ApiServiceProvider extends AbstractServiceProvider
*/
public function register()
{
$this->container->extend(UrlGenerator::class, function (UrlGenerator $url) {
return $url->addCollection('api', $this->container->make('flarum.api.routes'), 'api');
$this->app->extend(UrlGenerator::class, function (UrlGenerator $url) {
return $url->addCollection('api', $this->app->make('flarum.api.routes'), 'api');
});
$this->container->singleton('flarum.api.routes', function () {
$this->app->singleton('flarum.api.routes', function () {
$routes = new RouteCollection;
$this->populateRoutes($routes);
return $routes;
});
$this->container->singleton('flarum.api.throttlers', function () {
$this->app->singleton('flarum.api.throttlers', function () {
return [
'bypassThrottlingAttribute' => function ($request) {
if ($request->getAttribute('bypassThrottling')) {
@@ -51,11 +52,11 @@ class ApiServiceProvider extends AbstractServiceProvider
];
});
$this->container->bind(Middleware\ThrottleApi::class, function ($container) {
return new Middleware\ThrottleApi($container->make('flarum.api.throttlers'));
$this->app->bind(Middleware\ThrottleApi::class, function ($app) {
return new Middleware\ThrottleApi($app->make('flarum.api.throttlers'));
});
$this->container->singleton('flarum.api.middleware', function () {
$this->app->singleton('flarum.api.middleware', function () {
return [
'flarum.api.error_handler',
HttpMiddleware\ParseJsonBody::class,
@@ -71,23 +72,23 @@ class ApiServiceProvider extends AbstractServiceProvider
];
});
$this->container->bind('flarum.api.error_handler', function () {
$this->app->bind('flarum.api.error_handler', function () {
return new HttpMiddleware\HandleErrors(
$this->container->make(Registry::class),
new JsonApiFormatter($this->container['flarum.config']->inDebugMode()),
$this->container->tagged(Reporter::class)
$this->app->make(Registry::class),
new JsonApiFormatter($this->app['flarum.config']->inDebugMode()),
$this->app->tagged(Reporter::class)
);
});
$this->container->bind('flarum.api.route_resolver', function () {
return new HttpMiddleware\ResolveRoute($this->container->make('flarum.api.routes'));
$this->app->bind('flarum.api.route_resolver', function () {
return new HttpMiddleware\ResolveRoute($this->app->make('flarum.api.routes'));
});
$this->container->singleton('flarum.api.handler', function () {
$this->app->singleton('flarum.api.handler', function () {
$pipe = new MiddlewarePipe;
foreach ($this->container->make('flarum.api.middleware') as $middleware) {
$pipe->pipe($this->container->make($middleware));
foreach ($this->app->make('flarum.api.middleware') as $middleware) {
$pipe->pipe($this->app->make($middleware));
}
$pipe->pipe(new HttpMiddleware\ExecuteRoute());
@@ -95,7 +96,7 @@ class ApiServiceProvider extends AbstractServiceProvider
return $pipe;
});
$this->container->singleton('flarum.api.notification_serializers', function () {
$this->app->singleton('flarum.api.notification_serializers', function () {
return [
'discussionRenamed' => BasicDiscussionSerializer::class
];
@@ -109,9 +110,11 @@ class ApiServiceProvider extends AbstractServiceProvider
{
$this->setNotificationSerializers();
AbstractSerializeController::setContainer($this->container);
AbstractSerializeController::setContainer($this->app);
AbstractSerializeController::setEventDispatcher($events = $this->app->make('events'));
AbstractSerializer::setContainer($this->container);
AbstractSerializer::setContainer($this->app);
AbstractSerializer::setEventDispatcher($events);
}
/**
@@ -119,7 +122,13 @@ class ApiServiceProvider extends AbstractServiceProvider
*/
protected function setNotificationSerializers()
{
$serializers = $this->container->make('flarum.api.notification_serializers');
$blueprints = [];
$serializers = $this->app->make('flarum.api.notification_serializers');
// Deprecated in beta 15, remove in beta 16
$this->app->make('events')->dispatch(
new ConfigureNotificationTypes($blueprints, $serializers)
);
foreach ($serializers as $type => $serializer) {
NotificationSerializer::setSubjectSerializer($type, $serializer);
@@ -133,7 +142,7 @@ class ApiServiceProvider extends AbstractServiceProvider
*/
protected function populateRoutes(RouteCollection $routes)
{
$factory = $this->container->make(RouteHandlerFactory::class);
$factory = $this->app->make(RouteHandlerFactory::class);
$callback = include __DIR__.'/routes.php';
$callback($routes, $factory);

View File

@@ -9,8 +9,11 @@
namespace Flarum\Api\Controller;
use Flarum\Api\Event\WillGetData;
use Flarum\Api\Event\WillSerializeData;
use Flarum\Api\JsonApiResponse;
use Illuminate\Contracts\Container\Container;
use Illuminate\Contracts\Events\Dispatcher;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Server\RequestHandlerInterface;
@@ -74,6 +77,11 @@ abstract class AbstractSerializeController implements RequestHandlerInterface
*/
protected static $container;
/**
* @var Dispatcher
*/
protected static $events;
/**
* @var array
*/
@@ -99,6 +107,11 @@ abstract class AbstractSerializeController implements RequestHandlerInterface
}
}
// Deprected in beta 15, removed in beta 16
static::$events->dispatch(
new WillGetData($this)
);
$data = $this->data($request, $document);
foreach (array_reverse(array_merge([static::class], class_parents($this))) as $class) {
@@ -109,6 +122,11 @@ abstract class AbstractSerializeController implements RequestHandlerInterface
}
}
// Deprecated in beta 15, removed in beta 16
static::$events->dispatch(
new WillSerializeData($this, $data, $request, $document)
);
$serializer = static::$container->make($this->serializer);
$serializer->setRequest($request);
@@ -307,6 +325,22 @@ abstract class AbstractSerializeController implements RequestHandlerInterface
$this->sort = $sort;
}
/**
* @return Dispatcher
*/
public static function getEventDispatcher()
{
return static::$events;
}
/**
* @param Dispatcher $events
*/
public static function setEventDispatcher(Dispatcher $events)
{
static::$events = $events;
}
/**
* @return Container
*/

View File

@@ -12,6 +12,7 @@ namespace Flarum\Api\Controller;
use Flarum\Api\Serializer\DiscussionSerializer;
use Flarum\Discussion\Command\ReadDiscussion;
use Flarum\Discussion\Command\StartDiscussion;
use Flarum\Post\Floodgate;
use Illuminate\Contracts\Bus\Dispatcher;
use Illuminate\Support\Arr;
use Psr\Http\Message\ServerRequestInterface;
@@ -41,11 +42,18 @@ class CreateDiscussionController extends AbstractCreateController
protected $bus;
/**
* @param Dispatcher $bus
* @var Floodgate
*/
public function __construct(Dispatcher $bus)
protected $floodgate;
/**
* @param Dispatcher $bus
* @param Floodgate $floodgate
*/
public function __construct(Dispatcher $bus, Floodgate $floodgate)
{
$this->bus = $bus;
$this->floodgate = $floodgate;
}
/**
@@ -54,7 +62,14 @@ class CreateDiscussionController extends AbstractCreateController
protected function data(ServerRequestInterface $request, Document $document)
{
$actor = $request->getAttribute('actor');
$ipAddress = $request->getAttribute('ipAddress');
$ipAddress = Arr::get($request->getServerParams(), 'REMOTE_ADDR', '127.0.0.1');
/**
* @deprecated, remove in beta 15.
*/
if (! $request->getAttribute('bypassFloodgate')) {
$this->floodgate->assertNotFlooding($actor);
}
$discussion = $this->bus->dispatch(
new StartDiscussion($actor, Arr::get($request->getParsedBody(), 'data', []), $ipAddress)

View File

@@ -12,6 +12,7 @@ namespace Flarum\Api\Controller;
use Flarum\Api\Serializer\PostSerializer;
use Flarum\Discussion\Command\ReadDiscussion;
use Flarum\Post\Command\PostReply;
use Flarum\Post\Floodgate;
use Illuminate\Contracts\Bus\Dispatcher;
use Illuminate\Support\Arr;
use Psr\Http\Message\ServerRequestInterface;
@@ -40,11 +41,18 @@ class CreatePostController extends AbstractCreateController
protected $bus;
/**
* @param Dispatcher $bus
* @var \Flarum\Post\Floodgate
*/
public function __construct(Dispatcher $bus)
protected $floodgate;
/**
* @param Dispatcher $bus
* @param \Flarum\Post\Floodgate $floodgate
*/
public function __construct(Dispatcher $bus, Floodgate $floodgate)
{
$this->bus = $bus;
$this->floodgate = $floodgate;
}
/**
@@ -55,7 +63,14 @@ class CreatePostController extends AbstractCreateController
$actor = $request->getAttribute('actor');
$data = Arr::get($request->getParsedBody(), 'data', []);
$discussionId = Arr::get($data, 'relationships.discussion.data.id');
$ipAddress = $request->getAttribute('ipAddress');
$ipAddress = Arr::get($request->getServerParams(), 'REMOTE_ADDR', '127.0.0.1');
/**
* @deprecated, remove in beta 15.
*/
if (! $request->getAttribute('bypassFloodgate')) {
$this->floodgate->assertNotFlooding($actor);
}
$post = $this->bus->dispatch(
new PostReply($discussionId, $actor, $data, $ipAddress)

View File

@@ -9,8 +9,7 @@
namespace Flarum\Api\Controller;
use Flarum\Http\RememberAccessToken;
use Flarum\Http\SessionAccessToken;
use Flarum\Http\AccessToken;
use Flarum\User\Exception\NotAuthenticatedException;
use Flarum\User\UserRepository;
use Illuminate\Contracts\Bus\Dispatcher as BusDispatcher;
@@ -67,20 +66,8 @@ class CreateTokenController implements RequestHandlerInterface
throw new NotAuthenticatedException;
}
// Use of lifetime attribute is deprecated in beta 16, removed in beta 17
// For backward compatibility with custom integrations, longer lifetimes will be interpreted as remember tokens
if ($lifetime > 3600 || Arr::get($body, 'remember')) {
if ($lifetime > 3600) {
trigger_error('Use of parameter lifetime is deprecated in beta 16, will be removed in beta 17. Use remember parameter to start a remember session', E_USER_DEPRECATED);
}
$token = RememberAccessToken::generate($user->id);
} else {
$token = SessionAccessToken::generate($user->id);
}
// We do a first update here to log the IP/agent of the token creator, even if the token is never used afterwards
$token->touch($request);
$token = AccessToken::generate($user->id, $lifetime);
$token->save();
return new JsonResponse([
'token' => $token->token,

View File

@@ -11,10 +11,10 @@ namespace Flarum\Api\Controller;
use Flarum\Api\Serializer\DiscussionSerializer;
use Flarum\Discussion\Discussion;
use Flarum\Discussion\Filter\DiscussionFilterer;
use Flarum\Discussion\Search\DiscussionSearcher;
use Flarum\Http\UrlGenerator;
use Flarum\Query\QueryCriteria;
use Flarum\Search\SearchCriteria;
use Illuminate\Support\Arr;
use Psr\Http\Message\ServerRequestInterface;
use Tobscure\JsonApi\Document;
@@ -43,21 +43,11 @@ class ListDiscussionsController extends AbstractListController
'lastPost'
];
/**
* {@inheritDoc}
*/
public $sort = ['lastPostedAt' => 'desc'];
/**
* {@inheritdoc}
*/
public $sortFields = ['lastPostedAt', 'commentCount', 'createdAt'];
/**
* @var DiscussionFilterer
*/
protected $filterer;
/**
* @var DiscussionSearcher
*/
@@ -69,13 +59,11 @@ class ListDiscussionsController extends AbstractListController
protected $url;
/**
* @param DiscussionFilterer $filterer
* @param DiscussionSearcher $searcher
* @param UrlGenerator $url
*/
public function __construct(DiscussionFilterer $filterer, DiscussionSearcher $searcher, UrlGenerator $url)
public function __construct(DiscussionSearcher $searcher, UrlGenerator $url)
{
$this->filterer = $filterer;
$this->searcher = $searcher;
$this->url = $url;
}
@@ -86,19 +74,16 @@ class ListDiscussionsController extends AbstractListController
protected function data(ServerRequestInterface $request, Document $document)
{
$actor = $request->getAttribute('actor');
$filters = $this->extractFilter($request);
$query = Arr::get($this->extractFilter($request), 'q');
$sort = $this->extractSort($request);
$criteria = new SearchCriteria($actor, $query, $sort);
$limit = $this->extractLimit($request);
$offset = $this->extractOffset($request);
$include = array_merge($this->extractInclude($request), ['state']);
$load = array_merge($this->extractInclude($request), ['state']);
$criteria = new QueryCriteria($actor, $filters, $sort);
if (array_key_exists('q', $filters)) {
$results = $this->searcher->search($criteria, $limit, $offset);
} else {
$results = $this->filterer->filter($criteria, $limit, $offset);
}
$results = $this->searcher->search($criteria, $limit, $offset);
$document->addPaginationLinks(
$this->url->to('api')->route('discussions.index'),
@@ -110,20 +95,9 @@ class ListDiscussionsController extends AbstractListController
Discussion::setStateUser($actor);
// Eager load groups for use in the policies (isAdmin check)
if (in_array('mostRelevantPost.user', $include)) {
$include[] = 'mostRelevantPost.user.groups';
$results = $results->getResults()->load($load);
// If the first level of the relationship wasn't explicitly included,
// add it so the code below can look for it
if (! in_array('mostRelevantPost', $include)) {
$include[] = 'mostRelevantPost';
}
}
$results = $results->getResults()->load($include);
if ($relations = array_intersect($include, ['firstPost', 'lastPost', 'mostRelevantPost'])) {
if ($relations = array_intersect($load, ['firstPost', 'lastPost'])) {
foreach ($results as $discussion) {
foreach ($relations as $relation) {
if ($discussion->$relation) {

View File

@@ -10,11 +10,11 @@
namespace Flarum\Api\Controller;
use Flarum\Api\Serializer\PostSerializer;
use Flarum\Http\UrlGenerator;
use Flarum\Post\Filter\PostFilterer;
use Flarum\Event\ConfigurePostsQuery;
use Flarum\Post\PostRepository;
use Flarum\Query\QueryCriteria;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Arr;
use Illuminate\Support\Str;
use Psr\Http\Message\ServerRequestInterface;
use Tobscure\JsonApi\Document;
use Tobscure\JsonApi\Exception\InvalidParameterException;
@@ -43,30 +43,16 @@ class ListPostsController extends AbstractListController
public $sortFields = ['createdAt'];
/**
* @var PostFilterer
*/
protected $filterer;
/**
* @var PostRepository
* @var \Flarum\Post\PostRepository
*/
protected $posts;
/**
* @var UrlGenerator
* @param \Flarum\Post\PostRepository $posts
*/
protected $url;
/**
* @param PostFilterer $filterer
* @param PostRepository $posts
* @param UrlGenerator $url
*/
public function __construct(PostFilterer $filterer, PostRepository $posts, UrlGenerator $url)
public function __construct(PostRepository $posts)
{
$this->filterer = $filterer;
$this->posts = $posts;
$this->url = $url;
}
/**
@@ -75,25 +61,18 @@ class ListPostsController extends AbstractListController
protected function data(ServerRequestInterface $request, Document $document)
{
$actor = $request->getAttribute('actor');
$filters = $this->extractFilter($request);
$sort = $this->extractSort($request);
$limit = $this->extractLimit($request);
$offset = $this->extractOffset($request);
$filter = $this->extractFilter($request);
$include = $this->extractInclude($request);
$results = $this->filterer->filter(new QueryCriteria($actor, $filters, $sort), $limit, $offset);
if ($postIds = Arr::get($filter, 'id')) {
$postIds = explode(',', $postIds);
} else {
$postIds = $this->getPostIds($request);
}
$document->addPaginationLinks(
$this->url->to('api')->route('posts.index'),
$request->getQueryParams(),
$offset,
$limit,
$results->areMoreResults() ? null : 0
);
$posts = $this->posts->findByIds($postIds, $actor);
return $results->getResults()->load($include);
return $posts->load($include);
}
/**
@@ -121,4 +100,55 @@ class ListPostsController extends AbstractListController
return parent::extractOffset($request);
}
/**
* @param ServerRequestInterface $request
* @return array
* @throws InvalidParameterException
*/
private function getPostIds(ServerRequestInterface $request)
{
$actor = $request->getAttribute('actor');
$filter = $this->extractFilter($request);
$sort = $this->extractSort($request);
$limit = $this->extractLimit($request);
$offset = $this->extractOffset($request);
$query = $this->posts->query()->whereVisibleTo($actor);
$this->applyFilters($query, $filter);
$query->skip($offset)->take($limit);
foreach ((array) $sort as $field => $order) {
$query->orderBy(Str::snake($field), $order);
}
return $query->pluck('id')->all();
}
/**
* @param Builder $query
* @param array $filter
*/
private function applyFilters(Builder $query, array $filter)
{
if ($discussionId = Arr::get($filter, 'discussion')) {
$query->where('discussion_id', $discussionId);
}
if ($number = Arr::get($filter, 'number')) {
$query->where('number', $number);
}
if ($userId = Arr::get($filter, 'user')) {
$query->where('user_id', $userId);
}
if ($type = Arr::get($filter, 'type')) {
$query->where('type', $type);
}
event(new ConfigurePostsQuery($query, $filter));
}
}

View File

@@ -11,9 +11,9 @@ namespace Flarum\Api\Controller;
use Flarum\Api\Serializer\UserSerializer;
use Flarum\Http\UrlGenerator;
use Flarum\Query\QueryCriteria;
use Flarum\User\Filter\UserFilterer;
use Flarum\Search\SearchCriteria;
use Flarum\User\Search\UserSearcher;
use Illuminate\Support\Arr;
use Psr\Http\Message\ServerRequestInterface;
use Tobscure\JsonApi\Document;
@@ -40,11 +40,6 @@ class ListUsersController extends AbstractListController
'joinedAt'
];
/**
* @var UserFilterer
*/
protected $filterer;
/**
* @var UserSearcher
*/
@@ -56,13 +51,11 @@ class ListUsersController extends AbstractListController
protected $url;
/**
* @param UserFilterer $filterer
* @param UserSearcher $searcher
* @param UrlGenerator $url
*/
public function __construct(UserFilterer $filterer, UserSearcher $searcher, UrlGenerator $url)
public function __construct(UserSearcher $searcher, UrlGenerator $url)
{
$this->filterer = $filterer;
$this->searcher = $searcher;
$this->url = $url;
}
@@ -76,26 +69,16 @@ class ListUsersController extends AbstractListController
$actor->assertCan('viewUserList');
if (! $actor->hasPermission('user.viewLastSeenAt')) {
// If a user cannot see everyone's last online date, we prevent them from sorting by it
// Otherwise this sort field would defeat the privacy setting discloseOnline
// We use remove instead of add so that extensions can still completely disable the sort using the extender
$this->removeSortField('lastSeenAt');
}
$filters = $this->extractFilter($request);
$query = Arr::get($this->extractFilter($request), 'q');
$sort = $this->extractSort($request);
$criteria = new SearchCriteria($actor, $query, $sort);
$limit = $this->extractLimit($request);
$offset = $this->extractOffset($request);
$include = $this->extractInclude($request);
$load = $this->extractInclude($request);
$criteria = new QueryCriteria($actor, $filters, $sort);
if (array_key_exists('q', $filters)) {
$results = $this->searcher->search($criteria, $limit, $offset);
} else {
$results = $this->filterer->filter($criteria, $limit, $offset);
}
$results = $this->searcher->search($criteria, $limit, $offset, $load);
$document->addPaginationLinks(
$this->url->to('api')->route('users.index'),
@@ -105,6 +88,6 @@ class ListUsersController extends AbstractListController
$results->areMoreResults() ? null : 0
);
return $results->getResults()->load($include);
return $results->getResults();
}
}

View File

@@ -20,7 +20,7 @@ use Laminas\Diactoros\Response\EmptyResponse;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Server\RequestHandlerInterface;
use Symfony\Contracts\Translation\TranslatorInterface;
use Symfony\Component\Translation\TranslatorInterface;
class SendConfirmationEmailController implements RequestHandlerInterface
{

View File

@@ -16,7 +16,7 @@ use Laminas\Diactoros\Response\EmptyResponse;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Server\RequestHandlerInterface;
use Symfony\Contracts\Translation\TranslatorInterface;
use Symfony\Component\Translation\TranslatorInterface;
class SendTestMailController implements RequestHandlerInterface
{

View File

@@ -0,0 +1,83 @@
<?php
/*
* This file is part of Flarum.
*
* For detailed copyright and license information, please view the
* LICENSE file that was distributed with this source code.
*/
namespace Flarum\Api\Event;
use DateTime;
use Flarum\Api\Serializer\AbstractSerializer;
/**
* Prepare API attributes.
*
* This event is fired when a serializer is constructing an array of resource
* attributes for API output.
*
* @deprecated in beta 15, removed in beta 16
*/
class Serializing
{
/**
* The class doing the serializing.
*
* @var AbstractSerializer
*/
public $serializer;
/**
* The model being serialized.
*
* @var object
*/
public $model;
/**
* The serialized attributes of the resource.
*
* @var array
*/
public $attributes;
/**
* @var \Flarum\User\User
*/
public $actor;
/**
* @param AbstractSerializer $serializer The class doing the serializing.
* @param object|array $model The model being serialized.
* @param array $attributes The serialized attributes of the resource.
*/
public function __construct(AbstractSerializer $serializer, $model, array &$attributes)
{
$this->serializer = $serializer;
$this->model = $model;
$this->attributes = &$attributes;
$this->actor = $serializer->getActor();
}
/**
* @param string $serializer
* @return bool
*/
public function isSerializer($serializer)
{
return $this->serializer instanceof $serializer;
}
/**
* @param DateTime|null $date
* @return string|null
*/
public function formatDate(DateTime $date = null)
{
if ($date) {
return $date->format(DateTime::RFC3339);
}
}
}

View File

@@ -0,0 +1,141 @@
<?php
/*
* This file is part of Flarum.
*
* For detailed copyright and license information, please view the
* LICENSE file that was distributed with this source code.
*/
namespace Flarum\Api\Event;
use Flarum\Api\Controller\AbstractSerializeController;
use Illuminate\Support\Arr;
/**
* @deprecated in beta 15, removed in beta 16
*/
class WillGetData
{
/**
* @var AbstractSerializeController
*/
public $controller;
/**
* @param AbstractSerializeController $controller
*/
public function __construct(AbstractSerializeController $controller)
{
$this->controller = $controller;
}
/**
* @param string $controller
* @return bool
*/
public function isController($controller)
{
return $this->controller instanceof $controller;
}
/**
* Set the serializer that will serialize data for the endpoint.
*
* @param string $serializer
*/
public function setSerializer($serializer)
{
$this->controller->serializer = $serializer;
}
/**
* Include the given relationship by default.
*
* @param string|array $name
*/
public function addInclude($name)
{
$this->controller->include = array_merge($this->controller->include, (array) $name);
}
/**
* Don't include the given relationship by default.
*
* @param string $name
*/
public function removeInclude($name)
{
Arr::forget($this->controller->include, $name);
}
/**
* Make the given relationship available for inclusion.
*
* @param string $name
*/
public function addOptionalInclude($name)
{
$this->controller->optionalInclude[] = $name;
}
/**
* Don't allow the given relationship to be included.
*
* @param string $name
*/
public function removeOptionalInclude($name)
{
Arr::forget($this->controller->optionalInclude, $name);
}
/**
* Set the default number of results.
*
* @param int $limit
*/
public function setLimit($limit)
{
$this->controller->limit = $limit;
}
/**
* Set the maximum number of results.
*
* @param int $max
*/
public function setMaxLimit($max)
{
$this->controller->maxLimit = $max;
}
/**
* Allow sorting results by the given field.
*
* @param string $field
*/
public function addSortField($field)
{
$this->controller->sortFields[] = $field;
}
/**
* Disallow sorting results by the given field.
*
* @param string $field
*/
public function removeSortField($field)
{
Arr::forget($this->controller->sortFields, $field);
}
/**
* Set the default sort order for the results.
*
* @param array $sort
*/
public function setSort(array $sort)
{
$this->controller->sort = $sort;
}
}

View File

@@ -0,0 +1,73 @@
<?php
/*
* This file is part of Flarum.
*
* For detailed copyright and license information, please view the
* LICENSE file that was distributed with this source code.
*/
namespace Flarum\Api\Event;
use Flarum\Api\Controller\AbstractSerializeController;
use Psr\Http\Message\ServerRequestInterface;
use Tobscure\JsonApi\Document;
/**
* @deprecated in beta 15, removed in beta 16
*/
class WillSerializeData
{
/**
* @var AbstractSerializeController
*/
public $controller;
/**
* @var mixed
*/
public $data;
/**
* @var ServerRequestInterface
*/
public $request;
/**
* @var Document
*/
public $document;
/**
* @var \Flarum\User\User
*/
public $actor;
/**
* @param AbstractSerializeController $controller
* @param mixed $data
* @param ServerRequestInterface $request
* @param Document $document
*/
public function __construct(
AbstractSerializeController $controller,
&$data,
ServerRequestInterface $request,
Document $document
) {
$this->controller = $controller;
$this->data = &$data;
$this->request = $request;
$this->document = $document;
$this->actor = $request->getAttribute('actor');
}
/**
* @param string $controller
* @return bool
*/
public function isController($controller)
{
return $this->controller instanceof $controller;
}
}

View File

@@ -11,8 +11,11 @@ namespace Flarum\Api\Serializer;
use Closure;
use DateTime;
use Flarum\Api\Event\Serializing;
use Flarum\Event\GetApiRelationship;
use Flarum\User\User;
use Illuminate\Contracts\Container\Container;
use Illuminate\Contracts\Events\Dispatcher;
use Illuminate\Support\Arr;
use InvalidArgumentException;
use LogicException;
@@ -35,6 +38,11 @@ abstract class AbstractSerializer extends BaseAbstractSerializer
*/
protected $actor;
/**
* @var Dispatcher
*/
protected static $dispatcher;
/**
* @var Container
*/
@@ -43,7 +51,7 @@ abstract class AbstractSerializer extends BaseAbstractSerializer
/**
* @var callable[]
*/
protected static $attributeMutators = [];
protected static $mutators = [];
/**
* @var array
@@ -87,8 +95,8 @@ abstract class AbstractSerializer extends BaseAbstractSerializer
$attributes = $this->getDefaultAttributes($model);
foreach (array_reverse(array_merge([static::class], class_parents($this))) as $class) {
if (isset(static::$attributeMutators[$class])) {
foreach (static::$attributeMutators[$class] as $callback) {
if (isset(static::$mutators[$class])) {
foreach (static::$mutators[$class] as $callback) {
$attributes = array_merge(
$attributes,
$callback($this, $model, $attributes)
@@ -97,6 +105,11 @@ abstract class AbstractSerializer extends BaseAbstractSerializer
}
}
// Deprecated in beta 15, removed in beta 16
static::$dispatcher->dispatch(
new Serializing($this, $model, $attributes)
);
return $attributes;
}
@@ -140,21 +153,27 @@ abstract class AbstractSerializer extends BaseAbstractSerializer
*/
protected function getCustomRelationship($model, $name)
{
// Deprecated in beta 15, removed in beta 16
$relationship = static::$dispatcher->until(
new GetApiRelationship($this, $name, $model)
);
foreach (array_merge([static::class], class_parents($this)) as $class) {
$callback = Arr::get(static::$customRelations, "$class.$name");
if (is_callable($callback)) {
$relationship = $callback($this, $model);
if (isset($relationship) && ! ($relationship instanceof Relationship)) {
throw new LogicException(
'GetApiRelationship handler must return an instance of '.Relationship::class
);
}
return $relationship;
break;
}
}
if ($relationship && ! ($relationship instanceof Relationship)) {
throw new LogicException(
'GetApiRelationship handler must return an instance of '.Relationship::class
);
}
return $relationship;
}
/**
@@ -263,6 +282,22 @@ abstract class AbstractSerializer extends BaseAbstractSerializer
return $serializer;
}
/**
* @return Dispatcher
*/
public static function getEventDispatcher()
{
return static::$dispatcher;
}
/**
* @param Dispatcher $dispatcher
*/
public static function setEventDispatcher(Dispatcher $dispatcher)
{
static::$dispatcher = $dispatcher;
}
/**
* @return Container
*/
@@ -281,15 +316,15 @@ abstract class AbstractSerializer extends BaseAbstractSerializer
/**
* @param string $serializerClass
* @param callable $callback
* @param callable $mutator
*/
public static function addAttributeMutator(string $serializerClass, callable $callback)
public static function addMutator(string $serializerClass, callable $mutator)
{
if (! isset(static::$attributeMutators[$serializerClass])) {
static::$attributeMutators[$serializerClass] = [];
if (! isset(static::$mutators[$serializerClass])) {
static::$mutators[$serializerClass] = [];
}
static::$attributeMutators[$serializerClass][] = $callback;
static::$mutators[$serializerClass][] = $mutator;
}
/**

View File

@@ -11,7 +11,7 @@ namespace Flarum\Api\Serializer;
use Flarum\Group\Group;
use InvalidArgumentException;
use Symfony\Contracts\Translation\TranslatorInterface;
use Symfony\Component\Translation\TranslatorInterface;
class GroupSerializer extends AbstractSerializer
{

View File

@@ -19,14 +19,14 @@ class UserSerializer extends BasicUserSerializer
{
$attributes = parent::getDefaultAttributes($user);
$canEdit = $this->actor->can('edit', $user);
$attributes += [
'joinTime' => $this->formatDate($user->joined_at),
'discussionCount' => (int) $user->discussion_count,
'commentCount' => (int) $user->comment_count,
'canEdit' => $this->actor->can('edit', $user),
'canEditCredentials' => $this->actor->can('editCredentials', $user),
'canEditGroups' => $this->actor->can('editGroups', $user),
'canDelete' => $this->actor->can('delete', $user),
'joinTime' => $this->formatDate($user->joined_at),
'discussionCount' => (int) $user->discussion_count,
'commentCount' => (int) $user->comment_count,
'canEdit' => $canEdit,
'canDelete' => $this->actor->can('delete', $user),
];
if ($user->getPreference('discloseOnline') || $this->actor->can('viewLastSeenAt', $user)) {
@@ -35,7 +35,7 @@ class UserSerializer extends BasicUserSerializer
];
}
if ($attributes['canEditCredentials'] || $this->actor->id === $user->id) {
if ($canEdit || $this->actor->id === $user->id) {
$attributes += [
'isEmailConfirmed' => (bool) $user->is_email_confirmed,
'email' => $user->email

View File

@@ -19,18 +19,18 @@ class BusServiceProvider extends AbstractServiceProvider
{
public function register()
{
$this->container->bind(BaseDispatcher::class, function ($container) {
return new Dispatcher($container, function ($connection = null) use ($container) {
return $container[QueueFactoryContract::class]->connection($connection);
$this->app->bind(BaseDispatcher::class, function ($app) {
return new Dispatcher($app, function ($connection = null) use ($app) {
return $app[QueueFactoryContract::class]->connection($connection);
});
});
$this->container->alias(
$this->app->alias(
BaseDispatcher::class,
DispatcherContract::class
);
$this->container->alias(
$this->app->alias(
BaseDispatcher::class,
QueueingDispatcherContract::class
);

View File

@@ -34,7 +34,7 @@ abstract class AbstractCommand extends Command
$this->input = $input;
$this->output = $output;
return $this->fire() ?: 0;
$this->fire();
}
/**

View File

@@ -23,7 +23,7 @@ class ConsoleServiceProvider extends AbstractServiceProvider
*/
public function register()
{
$this->container->singleton('flarum.console.commands', function () {
$this->app->singleton('flarum.console.commands', function () {
return [
CacheClearCommand::class,
GenerateMigrationCommand::class,

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