From 990cdbc5717c7224b7735c91bbcf3a31c5c3b9b0 Mon Sep 17 00:00:00 2001 From: Alexander Skvortsov Date: Wed, 29 Apr 2020 16:22:17 -0400 Subject: [PATCH] Ultra-basic admin site (dashboard page only) --- js/src/admin/Admin.ts | 62 ++++++++++++ js/src/admin/app.ts | 8 ++ js/src/admin/compat.ts | 7 ++ js/src/admin/components/AdminLinkButton.tsx | 24 +++++ js/src/admin/components/AdminNav.tsx | 79 +++++++++++++++ js/src/admin/components/DashboardPage.tsx | 16 +++ js/src/admin/components/DashboardWidget.tsx | 34 +++++++ js/src/admin/components/HeaderPrimary.tsx | 29 ++++++ js/src/admin/components/HeaderSecondary.tsx | 33 +++++++ js/src/admin/components/LoadingModal.tsx | 19 ++++ js/src/admin/components/Page.tsx | 32 ++++++ js/src/admin/components/SessionDropdown.tsx | 52 ++++++++++ js/src/admin/components/SettingDropdown.tsx | 25 +++++ js/src/admin/components/SettingsModal.tsx | 70 +++++++++++++ js/src/admin/components/StatusWidget.tsx | 56 +++++++++++ js/src/admin/components/UploadImageButton.tsx | 97 +++++++++++++++++++ js/src/admin/components/Widget.tsx | 34 +++++++ js/src/admin/index.js | 0 js/src/admin/index.ts | 10 ++ js/src/admin/routes.ts | 7 ++ js/src/admin/utils/saveSettings.ts | 16 +++ 21 files changed, 710 insertions(+) create mode 100644 js/src/admin/Admin.ts create mode 100644 js/src/admin/app.ts create mode 100644 js/src/admin/compat.ts create mode 100644 js/src/admin/components/AdminLinkButton.tsx create mode 100644 js/src/admin/components/AdminNav.tsx create mode 100644 js/src/admin/components/DashboardPage.tsx create mode 100644 js/src/admin/components/DashboardWidget.tsx create mode 100644 js/src/admin/components/HeaderPrimary.tsx create mode 100644 js/src/admin/components/HeaderSecondary.tsx create mode 100644 js/src/admin/components/LoadingModal.tsx create mode 100644 js/src/admin/components/Page.tsx create mode 100644 js/src/admin/components/SessionDropdown.tsx create mode 100644 js/src/admin/components/SettingDropdown.tsx create mode 100644 js/src/admin/components/SettingsModal.tsx create mode 100644 js/src/admin/components/StatusWidget.tsx create mode 100644 js/src/admin/components/UploadImageButton.tsx create mode 100644 js/src/admin/components/Widget.tsx delete mode 100644 js/src/admin/index.js create mode 100644 js/src/admin/index.ts create mode 100644 js/src/admin/routes.ts create mode 100644 js/src/admin/utils/saveSettings.ts diff --git a/js/src/admin/Admin.ts b/js/src/admin/Admin.ts new file mode 100644 index 000000000..409eadbd2 --- /dev/null +++ b/js/src/admin/Admin.ts @@ -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; + } +} diff --git a/js/src/admin/app.ts b/js/src/admin/app.ts new file mode 100644 index 000000000..ca6ca9120 --- /dev/null +++ b/js/src/admin/app.ts @@ -0,0 +1,8 @@ +import Admin from './Admin'; + +const app = new Admin(); + +// @ts-ignore +window.app = app; + +export default app; diff --git a/js/src/admin/compat.ts b/js/src/admin/compat.ts new file mode 100644 index 000000000..dc8d9e421 --- /dev/null +++ b/js/src/admin/compat.ts @@ -0,0 +1,7 @@ +import compat from '../common/compat'; + +import Admin from './Admin'; + +export default Object.assign(compat, { + Admin: Admin, +}) as any; diff --git a/js/src/admin/components/AdminLinkButton.tsx b/js/src/admin/components/AdminLinkButton.tsx new file mode 100644 index 000000000..551bc3f98 --- /dev/null +++ b/js/src/admin/components/AdminLinkButton.tsx @@ -0,0 +1,24 @@ +/* + * This file is part of Flarum. + * + * (c) Toby Zerner + * + * 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 { + getButtonContent() { + const content = super.getButtonContent(this.props.icon, this.props.loading, this.props.children); + + content.push(
{this.props.description}
); + + return content; + } +} diff --git a/js/src/admin/components/AdminNav.tsx b/js/src/admin/components/AdminNav.tsx new file mode 100644 index 000000000..c25d0ec83 --- /dev/null +++ b/js/src/admin/components/AdminNav.tsx @@ -0,0 +1,79 @@ +/* + * This file is part of Flarum. + * + * (c) Toby Zerner + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +import Component from '../../common/Component'; +import AdminLinkButton from './AdminLinkButton'; +import SelectDropdown from '../../common/components/SelectDropdown'; +import ItemList from '../../common/utils/ItemList'; + +export default class AdminNav extends Component { + view() { + return ( + + {this.items().toArray()} + + ); + } + + /** + * 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; + } +} diff --git a/js/src/admin/components/DashboardPage.tsx b/js/src/admin/components/DashboardPage.tsx new file mode 100644 index 000000000..4066459b6 --- /dev/null +++ b/js/src/admin/components/DashboardPage.tsx @@ -0,0 +1,16 @@ +import Page from './Page'; +import StatusWidget from './StatusWidget'; + +export default class DashboardPage extends Page { + view() { + return ( +
+
{this.availableWidgets()}
+
+ ); + } + + availableWidgets() { + return []; + } +} diff --git a/js/src/admin/components/DashboardWidget.tsx b/js/src/admin/components/DashboardWidget.tsx new file mode 100644 index 000000000..56bab5a87 --- /dev/null +++ b/js/src/admin/components/DashboardWidget.tsx @@ -0,0 +1,34 @@ +/* + * This file is part of Flarum. + * + * (c) Toby Zerner + * + * 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
{this.content()}
; + } + + /** + * Get the class name to apply to the widget. + * + * @return {String} + */ + className() { + return ''; + } + + /** + * Get the content of the widget. + * + * @return {VirtualElement} + */ + content() { + return []; + } +} diff --git a/js/src/admin/components/HeaderPrimary.tsx b/js/src/admin/components/HeaderPrimary.tsx new file mode 100644 index 000000000..060672697 --- /dev/null +++ b/js/src/admin/components/HeaderPrimary.tsx @@ -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
    {listItems(this.items().toArray())}
; + } + + 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(); + } +} diff --git a/js/src/admin/components/HeaderSecondary.tsx b/js/src/admin/components/HeaderSecondary.tsx new file mode 100644 index 000000000..514731a1a --- /dev/null +++ b/js/src/admin/components/HeaderSecondary.tsx @@ -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
    {listItems(this.items().toArray())}
; + } + + 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; + } +} diff --git a/js/src/admin/components/LoadingModal.tsx b/js/src/admin/components/LoadingModal.tsx new file mode 100644 index 000000000..65fb516a4 --- /dev/null +++ b/js/src/admin/components/LoadingModal.tsx @@ -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 ''; + } +} diff --git a/js/src/admin/components/Page.tsx b/js/src/admin/components/Page.tsx new file mode 100644 index 000000000..e378aff10 --- /dev/null +++ b/js/src/admin/components/Page.tsx @@ -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); + } + } +} diff --git a/js/src/admin/components/SessionDropdown.tsx b/js/src/admin/components/SessionDropdown.tsx new file mode 100644 index 000000000..3c03c1d65 --- /dev/null +++ b/js/src/admin/components/SessionDropdown.tsx @@ -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), ' ', {username(user)}]; + } + + /** + * 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; + } +} diff --git a/js/src/admin/components/SettingDropdown.tsx b/js/src/admin/components/SettingDropdown.tsx new file mode 100644 index 000000000..2fe682075 --- /dev/null +++ b/js/src/admin/components/SettingDropdown.tsx @@ -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, + }); + }); + } +} diff --git a/js/src/admin/components/SettingsModal.tsx b/js/src/admin/components/SettingsModal.tsx new file mode 100644 index 000000000..93a6f552b --- /dev/null +++ b/js/src/admin/components/SettingsModal.tsx @@ -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 ( +
+
+ {this.form()} + +
{this.submitButton()}
+
+
+ ); + } + + submitButton() { + return ( + + ); + } + + 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(); + } +} diff --git a/js/src/admin/components/StatusWidget.tsx b/js/src/admin/components/StatusWidget.tsx new file mode 100644 index 000000000..6779b55a5 --- /dev/null +++ b/js/src/admin/components/StatusWidget.tsx @@ -0,0 +1,56 @@ +/* + * This file is part of Flarum. + * + * (c) Toby Zerner + * + * 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
    {listItems(this.items().toArray())}
; + } + + items() { + const items = new ItemList(); + + items.add( + 'tools', + + + + ); + + items.add('version-flarum', [Flarum,
, app.forum.attribute('version')]); + items.add('version-php', [PHP,
, app.data.phpVersion]); + items.add('version-mysql', [MySQL,
, 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()); + } +} diff --git a/js/src/admin/components/UploadImageButton.tsx b/js/src/admin/components/UploadImageButton.tsx new file mode 100644 index 000000000..736cebb09 --- /dev/null +++ b/js/src/admin/components/UploadImageButton.tsx @@ -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 ( +
+

+ +

+

{super.view()}

+
+ ); + } 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 + .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(); + } +} diff --git a/js/src/admin/components/Widget.tsx b/js/src/admin/components/Widget.tsx new file mode 100644 index 000000000..5b9ceab9e --- /dev/null +++ b/js/src/admin/components/Widget.tsx @@ -0,0 +1,34 @@ +/* + * This file is part of Flarum. + * + * (c) Toby Zerner + * + * 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
{this.content()}
; + } + + /** + * Get the class name to apply to the widget. + * + * @return {String} + */ + className() { + return ''; + } + + /** + * Get the content of the widget. + * + * @return {VirtualElement} + */ + content() { + return []; + } +} diff --git a/js/src/admin/index.js b/js/src/admin/index.js deleted file mode 100644 index e69de29bb..000000000 diff --git a/js/src/admin/index.ts b/js/src/admin/index.ts new file mode 100644 index 000000000..e543c63d3 --- /dev/null +++ b/js/src/admin/index.ts @@ -0,0 +1,10 @@ +import app from './app'; + +export { app }; + +// Export compat API +import compat from './compat'; + +compat.app = app; + +export { compat }; diff --git a/js/src/admin/routes.ts b/js/src/admin/routes.ts new file mode 100644 index 000000000..cf7af0578 --- /dev/null +++ b/js/src/admin/routes.ts @@ -0,0 +1,7 @@ +import DashboardPage from './components/DashboardPage'; + +export default (app) => { + app.routes = { + dashboard: { path: '/', component: DashboardPage }, + }; +}; diff --git a/js/src/admin/utils/saveSettings.ts b/js/src/admin/utils/saveSettings.ts new file mode 100644 index 000000000..1e0919752 --- /dev/null +++ b/js/src/admin/utils/saveSettings.ts @@ -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; + }); +}