Ultra-basic admin site (dashboard page only)

This commit is contained in:
Alexander Skvortsov 2020-04-29 16:22:17 -04:00 committed by David Sevilla Martín
parent a7937edac7
commit 990cdbc571
21 changed files with 710 additions and 0 deletions

62
js/src/admin/Admin.ts Normal file
View File

@ -0,0 +1,62 @@
import HeaderPrimary from './components/HeaderPrimary';
import HeaderSecondary from './components/HeaderSecondary';
import routes from './routes';
import Application from '../common/Application';
import Navigation from '../common/components/Navigation';
import AdminNav from './components/AdminNav';
export default class Admin extends Application {
extensionSettings = {};
history = {
canGoBack: () => true,
getPrevious: () => {},
backUrl: () => this.forum.attribute('baseUrl'),
back: function () {
window.location = this.backUrl();
},
};
constructor() {
super();
routes(this);
}
/**
* @inheritdoc
*/
mount() {
m.mount(document.getElementById('app-navigation'), new Navigation({ className: 'App-backControl', drawer: true }));
m.mount(document.getElementById('header-navigation'), new Navigation());
m.mount(document.getElementById('header-primary'), new HeaderPrimary());
m.mount(document.getElementById('header-secondary'), new HeaderSecondary());
m.mount(document.getElementById('admin-navigation'), new AdminNav());
super.mount();
// If an extension has just been enabled, then we will run its settings
// callback.
const enabled = localStorage.getItem('enabledExtension');
if (enabled && this.extensionSettings[enabled]) {
this.extensionSettings[enabled]();
localStorage.removeItem('enabledExtension');
}
}
getRequiredPermissions(permission) {
const required: string[] = [];
if (permission === 'startDiscussion' || permission.indexOf('discussion.') === 0) {
required.push('viewDiscussions');
}
if (permission === 'discussion.delete') {
required.push('discussion.hide');
}
if (permission === 'discussion.deletePosts') {
required.push('discussion.hidePosts');
}
return required;
}
}

8
js/src/admin/app.ts Normal file
View File

@ -0,0 +1,8 @@
import Admin from './Admin';
const app = new Admin();
// @ts-ignore
window.app = app;
export default app;

7
js/src/admin/compat.ts Normal file
View File

@ -0,0 +1,7 @@
import compat from '../common/compat';
import Admin from './Admin';
export default Object.assign(compat, {
Admin: Admin,
}) as any;

View File

@ -0,0 +1,24 @@
/*
* 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, { LinkButtonProps } from '../../common/components/LinkButton';
interface AdminLinkButtonProps extends LinkButtonProps {
description?: string;
}
export default class AdminLinkButton extends LinkButton<AdminLinkButtonProps> {
getButtonContent() {
const content = super.getButtonContent(this.props.icon, this.props.loading, this.props.children);
content.push(<div className="AdminLinkButton-description">{this.props.description}</div>);
return content;
}
}

View File

@ -0,0 +1,79 @@
/*
* This file is part of Flarum.
*
* (c) Toby Zerner <toby.zerner@gmail.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
import Component from '../../common/Component';
import AdminLinkButton from './AdminLinkButton';
import SelectDropdown from '../../common/components/SelectDropdown';
import ItemList from '../../common/utils/ItemList';
export default class AdminNav<T> extends Component<T> {
view() {
return (
<SelectDropdown className="AdminNav App-titleControl" buttonClassName="Button">
{this.items().toArray()}
</SelectDropdown>
);
}
/**
* Build an item list of links to show in the admin navigation.
*
* @return {ItemList}
*/
items() {
const items = new ItemList();
items.add(
'dashboard',
AdminLinkButton.component({
href: app.route('dashboard'),
icon: 'far fa-chart-bar',
children: app.translator.trans('core.admin.nav.dashboard_button'),
description: app.translator.trans('core.admin.nav.dashboard_text'),
})
);
// items.add('basics', AdminLinkButton.component({
// href: app.route('basics'),
// icon: 'fas fa-pencil-alt',
// children: app.translator.trans('core.admin.nav.basics_button'),
// description: app.translator.trans('core.admin.nav.basics_text')
// }));
// items.add('mail', AdminLinkButton.component({
// href: app.route('mail'),
// icon: 'fas fa-envelope',
// children: app.translator.trans('core.admin.nav.email_button'),
// description: app.translator.trans('core.admin.nav.email_text')
// }));
// items.add('permissions', AdminLinkButton.component({
// href: app.route('permissions'),
// icon: 'fas fa-key',
// children: app.translator.trans('core.admin.nav.permissions_button'),
// description: app.translator.trans('core.admin.nav.permissions_text')
// }));
// items.add('appearance', AdminLinkButton.component({
// href: app.route('appearance'),
// icon: 'fas fa-paint-brush',
// children: app.translator.trans('core.admin.nav.appearance_button'),
// description: app.translator.trans('core.admin.nav.appearance_text')
// }));
// items.add('extensions', AdminLinkButton.component({
// href: app.route('extensions'),
// icon: 'fas fa-puzzle-piece',
// children: app.translator.trans('core.admin.nav.extensions_button'),
// description: app.translator.trans('core.admin.nav.extensions_text')
// }));
return items;
}
}

View File

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

View File

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

View File

@ -0,0 +1,29 @@
import Component from '../../common/Component';
import ItemList from '../../common/utils/ItemList';
import listItems from '../../common/helpers/listItems';
/**
* The `HeaderPrimary` component displays primary header controls. On the
* default skin, these are shown just to the right of the forum title.
*/
export default class HeaderPrimary extends Component {
view() {
return <ul className="Header-controls">{listItems(this.items().toArray())}</ul>;
}
config(isInitialized, context) {
// Since this component is 'above' the content of the page (that is, it is a
// part of the global UI that persists between routes), we will flag the DOM
// to be retained across route changes.
context.retain = true;
}
/**
* Build an item list for the controls.
*
* @return {ItemList}
*/
items() {
return new ItemList();
}
}

View File

@ -0,0 +1,33 @@
import Component from '../../common/Component';
import SessionDropdown from './SessionDropdown';
import ItemList from '../../common/utils/ItemList';
import listItems from '../../common/helpers/listItems';
/**
* The `HeaderSecondary` component displays secondary header controls.
*/
export default class HeaderSecondary extends Component {
view() {
return <ul className="Header-controls">{listItems(this.items().toArray())}</ul>;
}
config(isInitialized, context) {
// Since this component is 'above' the content of the page (that is, it is a
// part of the global UI that persists between routes), we will flag the DOM
// to be retained across route changes.
context.retain = true;
}
/**
* Build an item list for the controls.
*
* @return {ItemList}
*/
items() {
const items = new ItemList();
items.add('session', SessionDropdown.component());
return items;
}
}

View File

@ -0,0 +1,19 @@
import Modal from '../../common/components/Modal';
export default class LoadingModal extends Modal {
isDismissible() {
return false;
}
className() {
return 'LoadingModal Modal--small';
}
title() {
return app.translator.trans('core.admin.loading.title');
}
content() {
return '';
}
}

View File

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

View File

@ -0,0 +1,52 @@
import avatar from '../../common/helpers/avatar';
import username from '../../common/helpers/username';
import Dropdown from '../../common/components/Dropdown';
import Button from '../../common/components/Button';
import ItemList from '../../common/utils/ItemList';
/**
* The `SessionDropdown` component shows a button with the current user's
* avatar/name, with a dropdown of session controls.
*/
export default class SessionDropdown extends Dropdown {
static initProps(props) {
super.initProps(props);
props.className = 'SessionDropdown';
props.buttonClassName = 'Button Button--user Button--flat';
props.menuClassName = 'Dropdown-menu--right';
}
view() {
this.props.children = this.items().toArray();
return super.view();
}
getButtonContent() {
const user = app.session.user;
return [avatar(user), ' ', <span className="Button-label">{username(user)}</span>];
}
/**
* Build an item list for the contents of the dropdown menu.
*
* @return {ItemList}
*/
items() {
const items = new ItemList();
items.add(
'logOut',
Button.component({
icon: 'fas fa-sign-out-alt',
children: app.translator.trans('core.admin.header.log_out_button'),
onclick: app.session.logout.bind(app.session),
}),
-100
);
return items;
}
}

View File

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

View File

@ -0,0 +1,70 @@
import Modal from '../../common/components/Modal';
import Button from '../../common/components/Button';
import saveSettings from '../utils/saveSettings';
export default class SettingsModal extends Modal {
init() {
this.settings = {};
this.loading = false;
}
form() {
return '';
}
content() {
return (
<div className="Modal-body">
<div className="Form">
{this.form()}
<div className="Form-group">{this.submitButton()}</div>
</div>
</div>
);
}
submitButton() {
return (
<Button type="submit" className="Button Button--primary" loading={this.loading} disabled={!this.changed()}>
{app.translator.trans('core.admin.settings.submit_button')}
</Button>
);
}
setting(key, fallback = '') {
this.settings[key] = this.settings[key] || m.prop(app.data.settings[key] || fallback);
return this.settings[key];
}
dirty() {
const dirty = {};
Object.keys(this.settings).forEach((key) => {
const value = this.settings[key]();
if (value !== app.data.settings[key]) {
dirty[key] = value;
}
});
return dirty;
}
changed() {
return Object.keys(this.dirty()).length;
}
onsubmit(e) {
e.preventDefault();
this.loading = true;
saveSettings(this.dirty()).then(this.onsaved.bind(this), this.loaded.bind(this));
}
onsaved() {
this.hide();
}
}

View File

@ -0,0 +1,56 @@
/*
* This file is part of Flarum.
*
* (c) Toby Zerner <toby.zerner@gmail.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
import DashboardWidget from './DashboardWidget';
import listItems from '../../common/helpers/listItems';
import ItemList from '../../common/utils/ItemList';
import Dropdown from '../../common/components/Dropdown';
import Button from '../../common/components/Button';
import LoadingModal from './LoadingModal';
export default class StatusWidget extends DashboardWidget {
className() {
return 'StatusWidget';
}
content() {
return <ul>{listItems(this.items().toArray())}</ul>;
}
items() {
const items = new ItemList();
items.add(
'tools',
<Dropdown
label={app.translator.trans('core.admin.dashboard.tools_button')}
icon="fas fa-cog"
buttonClassName="Button"
menuClassName="Dropdown-menu--right"
>
<Button onclick={this.handleClearCache.bind(this)}>{app.translator.trans('core.admin.dashboard.clear_cache_button')}</Button>
</Dropdown>
);
items.add('version-flarum', [<strong>Flarum</strong>, <br />, app.forum.attribute('version')]);
items.add('version-php', [<strong>PHP</strong>, <br />, app.data.phpVersion]);
items.add('version-mysql', [<strong>MySQL</strong>, <br />, app.data.mysqlVersion]);
return items;
}
handleClearCache(e) {
app.modal.show(new LoadingModal());
app.request({
method: 'DELETE',
url: app.forum.attribute('apiUrl') + '/cache',
}).then(() => window.location.reload());
}
}

View File

@ -0,0 +1,97 @@
import Button from '../../common/components/Button';
export default class UploadImageButton extends Button {
init() {
this.loading = false;
}
view() {
this.props.loading = this.loading;
this.props.className = (this.props.className || '') + ' Button';
if (app.data.settings[this.props.name + '_path']) {
this.props.onclick = this.remove.bind(this);
this.props.children = app.translator.trans('core.admin.upload_image.remove_button');
return (
<div>
<p>
<img src={app.forum.attribute(this.props.name + 'Url')} alt="" />
</p>
<p>{super.view()}</p>
</div>
);
} else {
this.props.onclick = this.upload.bind(this);
this.props.children = app.translator.trans('core.admin.upload_image.upload_button');
}
return super.view();
}
/**
* Prompt the user to upload an image.
*/
upload() {
if (this.loading) return;
const $input = $('<input type="file">');
$input
.appendTo('body')
.hide()
.click()
.on('change', (e) => {
const data = new FormData();
data.append(this.props.name, $(e.target)[0].files[0]);
this.loading = true;
m.redraw();
app.request({
method: 'POST',
url: this.resourceUrl(),
serialize: (raw) => raw,
data,
}).then(this.success.bind(this), this.failure.bind(this));
});
}
/**
* Remove the logo.
*/
remove() {
this.loading = true;
m.redraw();
app.request({
method: 'DELETE',
url: this.resourceUrl(),
}).then(this.success.bind(this), this.failure.bind(this));
}
resourceUrl() {
return app.forum.attribute('apiUrl') + '/' + this.props.name;
}
/**
* After a successful upload/removal, reload the page.
*
* @param {Object} response
* @protected
*/
success(response) {
window.location.reload();
}
/**
* If upload/removal fails, stop loading.
*
* @param {Object} response
* @protected
*/
failure(response) {
this.loading = false;
m.redraw();
}
}

View File

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

View File

10
js/src/admin/index.ts Normal file
View File

@ -0,0 +1,10 @@
import app from './app';
export { app };
// Export compat API
import compat from './compat';
compat.app = app;
export { compat };

7
js/src/admin/routes.ts Normal file
View File

@ -0,0 +1,7 @@
import DashboardPage from './components/DashboardPage';
export default (app) => {
app.routes = {
dashboard: { path: '/', component: DashboardPage },
};
};

View File

@ -0,0 +1,16 @@
export default function saveSettings(settings) {
const oldSettings = JSON.parse(JSON.stringify(app.data.settings));
Object.assign(app.data.settings, settings);
return app
.request({
method: 'POST',
url: app.forum.attribute('apiUrl') + '/settings',
data: settings,
})
.catch((error) => {
app.data.settings = oldSettings;
throw error;
});
}