diff --git a/js/src/admin/AdminApplication.js b/js/src/admin/AdminApplication.js index 1e75a3c308..153ec13a7f 100644 --- a/js/src/admin/AdminApplication.js +++ b/js/src/admin/AdminApplication.js @@ -1,13 +1,29 @@ 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 = { + discussion: 70, + moderation: 60, + feature: 50, + formatting: 40, + theme: 30, + authentication: 20, + language: 10, + other: 0, + }; + history = { canGoBack: () => true, getPrevious: () => {}, @@ -34,7 +50,13 @@ export default class AdminApplication extends Application { m.route.prefix = '#'; super.mount(); - m.mount(document.getElementById('app-navigation'), { view: () => Navigation.component({ className: 'App-backControl', drawer: true }) }); + m.mount(document.getElementById('app-navigation'), { + view: () => + Navigation.component({ + className: 'App-backControl', + drawer: true, + }), + }); m.mount(document.getElementById('header-navigation'), Navigation); m.mount(document.getElementById('header-primary'), HeaderPrimary); m.mount(document.getElementById('header-secondary'), HeaderSecondary); @@ -43,7 +65,7 @@ export default class AdminApplication extends Application { // If an extension has just been enabled, then we will run its settings // callback. const enabled = localStorage.getItem('enabledExtension'); - if (enabled && this.extensionSettings[enabled]) { + if (enabled && this.extensionSettings[enabled] && typeof this.extensionSettings[enabled] === 'function') { this.extensionSettings[enabled](); localStorage.removeItem('enabledExtension'); } diff --git a/js/src/admin/compat.js b/js/src/admin/compat.js index 84bc61671c..117e92d358 100644 --- a/js/src/admin/compat.js +++ b/js/src/admin/compat.js @@ -1,17 +1,21 @@ import compat from '../common/compat'; import saveSettings from './utils/saveSettings'; +import ExtensionData from './utils/ExtensionData'; +import isExtensionEnabled from './utils/isExtensionEnabled'; +import getCategorizedExtensions from './utils/getCategorizedExtensions'; import SettingDropdown from './components/SettingDropdown'; import EditCustomFooterModal from './components/EditCustomFooterModal'; import SessionDropdown from './components/SessionDropdown'; import HeaderPrimary from './components/HeaderPrimary'; import AppearancePage from './components/AppearancePage'; import StatusWidget from './components/StatusWidget'; +import ExtensionsWidget from './components/ExtensionsWidget'; import HeaderSecondary from './components/HeaderSecondary'; import SettingsModal from './components/SettingsModal'; import DashboardWidget from './components/DashboardWidget'; -import AddExtensionModal from './components/AddExtensionModal'; -import ExtensionsPage from './components/ExtensionsPage'; +import ExtensionPage from './components/ExtensionPage'; +import ExtensionLinkButton from './components/ExtensionLinkButton'; import AdminLinkButton from './components/AdminLinkButton'; import PermissionGrid from './components/PermissionGrid'; import MailPage from './components/MailPage'; @@ -23,6 +27,7 @@ import EditCustomHeaderModal from './components/EditCustomHeaderModal'; import PermissionsPage from './components/PermissionsPage'; import PermissionDropdown from './components/PermissionDropdown'; import AdminNav from './components/AdminNav'; +import AdminHeader from './components/AdminHeader'; import EditCustomCssModal from './components/EditCustomCssModal'; import EditGroupModal from './components/EditGroupModal'; import routes from './routes'; @@ -30,17 +35,21 @@ import AdminApplication from './AdminApplication'; export default Object.assign(compat, { 'utils/saveSettings': saveSettings, + 'utils/ExtensionData': ExtensionData, + 'utils/isExtensionEnabled': isExtensionEnabled, + 'utils/getCategorizedExtensions': getCategorizedExtensions, 'components/SettingDropdown': SettingDropdown, 'components/EditCustomFooterModal': EditCustomFooterModal, 'components/SessionDropdown': SessionDropdown, 'components/HeaderPrimary': HeaderPrimary, 'components/AppearancePage': AppearancePage, 'components/StatusWidget': StatusWidget, + 'components/ExtensionsWidget': ExtensionsWidget, 'components/HeaderSecondary': HeaderSecondary, 'components/SettingsModal': SettingsModal, 'components/DashboardWidget': DashboardWidget, - 'components/AddExtensionModal': AddExtensionModal, - 'components/ExtensionsPage': ExtensionsPage, + 'components/ExtensionPage': ExtensionPage, + 'components/ExtensionLinkButton': ExtensionLinkButton, 'components/AdminLinkButton': AdminLinkButton, 'components/PermissionGrid': PermissionGrid, 'components/MailPage': MailPage, @@ -52,6 +61,7 @@ export default Object.assign(compat, { 'components/PermissionsPage': PermissionsPage, 'components/PermissionDropdown': PermissionDropdown, 'components/AdminNav': AdminNav, + 'components/AdminHeader': AdminHeader, 'components/EditCustomCssModal': EditCustomCssModal, 'components/EditGroupModal': EditGroupModal, routes: routes, diff --git a/js/src/admin/components/AdminHeader.js b/js/src/admin/components/AdminHeader.js new file mode 100644 index 0000000000..5472a8064e --- /dev/null +++ b/js/src/admin/components/AdminHeader.js @@ -0,0 +1,19 @@ +import Component from '../../common/Component'; +import classList from '../../common/utils/classList'; +import icon from '../../common/helpers/icon'; + +export default class AdminHeader extends Component { + view(vnode) { + return [ +
+
+

+ {icon(this.attrs.icon)} + {vnode.children} +

+
{this.attrs.description}
+
+
, + ]; + } +} diff --git a/js/src/admin/components/AdminNav.js b/js/src/admin/components/AdminNav.js index f41d2a1a67..ecf96ec830 100644 --- a/js/src/admin/components/AdminNav.js +++ b/js/src/admin/components/AdminNav.js @@ -1,28 +1,28 @@ -/* - * 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 ExtensionLinkButton from './ExtensionLinkButton'; import Component from '../../common/Component'; -import AdminLinkButton from './AdminLinkButton'; +import LinkButton from '../../common/components/LinkButton'; import SelectDropdown from '../../common/components/SelectDropdown'; +import getCategorizedExtensions from '../utils/getCategorizedExtensions'; import ItemList from '../../common/utils/ItemList'; +import Stream from '../../common/utils/Stream'; export default class AdminNav extends Component { + oninit(vnode) { + super.oninit(vnode); + + this.query = Stream(''); + } + view() { return ( - - {this.items().toArray()} + + {this.items().toArray().concat(this.extensionItems().toArray())} ); } /** - * Build an item list of links to show in the admin navigation. + * Build an item list of main links to show in the admin navigation. * * @return {ItemList} */ @@ -31,76 +31,90 @@ export default class AdminNav extends Component { items.add( 'dashboard', - AdminLinkButton.component( - { - href: app.route('dashboard'), - icon: 'far fa-chart-bar', - description: app.translator.trans('core.admin.nav.dashboard_text'), - }, - app.translator.trans('core.admin.nav.dashboard_button') - ) + + {app.translator.trans('core.admin.nav.dashboard_button')} + ); items.add( 'basics', - AdminLinkButton.component( - { - href: app.route('basics'), - icon: 'fas fa-pencil-alt', - description: app.translator.trans('core.admin.nav.basics_text'), - }, - app.translator.trans('core.admin.nav.basics_button') - ) + + {app.translator.trans('core.admin.nav.basics_button')} + ); items.add( 'mail', - AdminLinkButton.component( - { - href: app.route('mail'), - icon: 'fas fa-envelope', - description: app.translator.trans('core.admin.nav.email_text'), - }, - app.translator.trans('core.admin.nav.email_button') - ) + + {app.translator.trans('core.admin.nav.email_button')} + ); items.add( 'permissions', - AdminLinkButton.component( - { - href: app.route('permissions'), - icon: 'fas fa-key', - description: app.translator.trans('core.admin.nav.permissions_text'), - }, - app.translator.trans('core.admin.nav.permissions_button') - ) + + {app.translator.trans('core.admin.nav.permissions_button')} + ); items.add( 'appearance', - AdminLinkButton.component( - { - href: app.route('appearance'), - icon: 'fas fa-paint-brush', - description: app.translator.trans('core.admin.nav.appearance_text'), - }, - app.translator.trans('core.admin.nav.appearance_button') - ) + + {app.translator.trans('core.admin.nav.appearance_button')} + ); items.add( - 'extensions', - AdminLinkButton.component( - { - href: app.route('extensions'), - icon: 'fas fa-puzzle-piece', - description: app.translator.trans('core.admin.nav.extensions_text'), - }, - app.translator.trans('core.admin.nav.extensions_button') - ) + 'search', +
+ +
); return items; } + + extensionItems() { + const items = new ItemList(); + + const categorizedExtensions = getCategorizedExtensions(); + const categories = app.extensionCategories; + + Object.keys(categorizedExtensions).map((category) => { + if (!this.query()) { + items.add( + category, +

{app.translator.trans(`core.admin.nav.categories.${category}`)}

, + categories[category] + ); + } + + categorizedExtensions[category].map((extension) => { + const query = this.query().toUpperCase(); + const title = extension.extra['flarum-extension'].title; + + if (!query || title.toUpperCase().includes(query) || extension.description.toUpperCase().includes(query)) { + items.add( + extension.id, + + {title} + , + categories[category] + ); + } + }); + }); + + return items; + } } diff --git a/js/src/admin/components/AppearancePage.js b/js/src/admin/components/AppearancePage.js index 106a88659c..041d998c89 100644 --- a/js/src/admin/components/AppearancePage.js +++ b/js/src/admin/components/AppearancePage.js @@ -7,6 +7,7 @@ import EditCustomHeaderModal from './EditCustomHeaderModal'; import EditCustomFooterModal from './EditCustomFooterModal'; import UploadImageButton from './UploadImageButton'; import saveSettings from '../utils/saveSettings'; +import AdminHeader from './AdminHeader'; export default class AppearancePage extends Page { oninit(vnode) { @@ -21,6 +22,13 @@ export default class AppearancePage extends Page { view() { return (
+ + {app.translator.trans('core.admin.appearance.title')} +
diff --git a/js/src/admin/components/BasicsPage.js b/js/src/admin/components/BasicsPage.js index 56c1c4cfb4..edf2c59076 100644 --- a/js/src/admin/components/BasicsPage.js +++ b/js/src/admin/components/BasicsPage.js @@ -7,6 +7,7 @@ import ItemList from '../../common/utils/ItemList'; 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 Page { oninit(vnode) { @@ -49,6 +50,9 @@ export default class BasicsPage extends Page { view() { return (
+ + {app.translator.trans('core.admin.basics.title')} +
{FieldSet.component( diff --git a/js/src/admin/components/DashboardPage.js b/js/src/admin/components/DashboardPage.js index fdcade871e..01359e4720 100644 --- a/js/src/admin/components/DashboardPage.js +++ b/js/src/admin/components/DashboardPage.js @@ -1,16 +1,29 @@ 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 listItems from '../../common/helpers/listItems'; export default class DashboardPage extends Page { view() { return (
-
{this.availableWidgets()}
+ + {app.translator.trans('core.admin.dashboard.title')} + +
{this.availableWidgets().toArray()}
); } availableWidgets() { - return []; + const items = new ItemList(); + + items.add('status', , 30); + + items.add('extensions', , 10); + + return items; } } diff --git a/js/src/admin/components/ExtensionLinkButton.js b/js/src/admin/components/ExtensionLinkButton.js new file mode 100644 index 0000000000..6415aa1a18 --- /dev/null +++ b/js/src/admin/components/ExtensionLinkButton.js @@ -0,0 +1,29 @@ +import isExtensionEnabled from '../utils/isExtensionEnabled'; +import LinkButton from '../../common/components/LinkButton'; +import icon from '../../common/helpers/icon'; +import ItemList from '../../common/utils/ItemList'; + +export default class ExtensionLinkButton extends LinkButton { + getButtonContent(children) { + const content = super.getButtonContent(children); + const extension = app.data.extensions[this.attrs.extensionId]; + const statuses = this.statusItems(extension.id).toArray(); + + content.unshift( + + {extension.icon ? icon(extension.icon.name) : ''} + + ); + content.push(statuses); + + return content; + } + + statusItems(name) { + const items = new ItemList(); + + items.add('enabled', ); + + return items; + } +} diff --git a/js/src/admin/components/ExtensionPage.js b/js/src/admin/components/ExtensionPage.js new file mode 100644 index 0000000000..b428bed393 --- /dev/null +++ b/js/src/admin/components/ExtensionPage.js @@ -0,0 +1,376 @@ +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 ExtensionData from '../utils/ExtensionData'; +import isExtensionEnabled from '../utils/isExtensionEnabled'; + +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', + documentation: 'fas fa-book', + support: 'fas fa-life-ring', + website: 'fas fa-link', + donate: 'fas fa-donate', + }; + + // 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() { + return this.extension.id + '-Page'; + } + + view() { + return ( +
+ {this.header()} + {!this.isEnabled() ? ( +
+

{app.translator.trans('core.admin.extension.enable_to_see')}

+
+ ) : ( +
{this.sections().toArray()}
+ )} +
+ ); + } + + header() { + return [ +
+
+
+ + {this.extension.icon ? icon(this.extension.icon.name) : ''} + +
+

{this.extension.extra['flarum-extension'].title}

+
+
+
    {listItems(this.topItems().toArray())}
+
+
+
{this.extension.description}
+
+ + {this.isEnabled(this.extension.id) + ? app.translator.trans('core.admin.extension.enabled') + : app.translator.trans('core.admin.extension.disabled')} + + +
+
+
, + ]; + } + + sections() { + const items = new ItemList(); + + items.add('content', this.content()); + + items.add('permissions', [ +
+
+
+

{app.translator.trans('core.admin.extension.permissions_title')}

+
+
+
+ {app.extensionData.extensionHasPermissions(this.extension.id) ? ( + ExtensionPermissionGrid.component({ extensionId: this.extension.id }) + ) : ( +

{app.translator.trans('core.admin.extension.no_permissions')}

+ )} +
+
, + ]); + + return items; + } + + content() { + const settings = app.extensionData.getSettings(this.extension.id); + + return ( +
+
+ {typeof app.extensionData[this.extension.id] === 'function' ? ( + + ) : settings ? ( +
+ {settings.map(this.buildSettingComponent.bind(this))} +
{this.submitButton()}
+
+ ) : ( +

{app.translator.trans('core.admin.extension.no_settings')}

+ )} +
+
+ ); + } + + topItems() { + const items = new ItemList(); + + items.add('version', {this.extension.version}); + + if (!this.isEnabled()) { + const uninstall = () => { + if (confirm(app.translator.trans('core.admin.extension.confirm_uninstall'))) { + app + .request({ + url: app.forum.attribute('apiUrl') + '/extensions/' + this.extension.id, + method: 'DELETE', + }) + .then(() => window.location.reload()); + + app.modal.show(LoadingModal); + } + }; + + items.add( + 'uninstall', + + ); + } + + return items; + } + + infoItems() { + const items = new ItemList(); + + if (this.extension.authors) { + let authors = []; + + Object.keys(this.extension.authors).map((author, i) => { + const link = this.extension.authors[author].homepage + ? this.extension.authors[author].homepage + : 'mailto:' + this.extension.authors[author].email; + + authors.push( + + {this.extension.authors[author].name} + + ); + }); + + items.add('authors', [icon('fas fa-user'), {punctuateSeries(authors)}]); + } + + const infoData = {}; + + if (this.extension.source || this.extension.support) { + infoData.source = { + icon: 'fas fa-code', + href: this.extension.source ? this.extension.source.url : this.extension.support.source, + }; + } + + Object.keys(this.infoFields).map((field) => { + const info = this.extension.extra['flarum-extension'].info; + + if (info && info[field]) { + infoData[field] = { + icon: this.infoFields[field], + href: info[field], + }; + } + }); + + Object.entries(infoData).map(([field, value]) => { + items.add( + field, + + {app.translator.trans(`core.admin.extension.info_links.${field}`)} + + ); + }); + + return items; + } + + submitButton() { + return ( + + ); + } + + /** + * 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 type. + * + * @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) { + const setting = entry.setting; + const value = this.setting([setting])(); + if (['bool', 'checkbox', 'switch', 'boolean'].includes(entry.type)) { + return ( +
+ + {entry.label} + +
+ ); + } else if (['select', 'dropdown', 'selectdropdown'].includes(entry.type)) { + return ( +
+ + +
+ ); + } + } + + toggle() { + const enabled = this.isEnabled(); + + this.changingState = true; + + app + .request({ + url: app.forum.attribute('apiUrl') + '/extensions/' + this.extension.id, + method: 'PATCH', + body: { enabled: !enabled }, + errorHandler: this.onerror.bind(this), + }) + .then(() => { + if (!enabled) localStorage.setItem('enabledExtension', this.extension.id); + window.location.reload(); + }); + + 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() { + let isEnabled = isExtensionEnabled(this.extension.id); + + return this.changingState ? !isEnabled : isEnabled; + } + + onerror(e) { + // We need to give the modal animation time to start; if we close the modal too early, + // it breaks the bootstrap modal library. + // TODO: This workaround should be removed when we move away from bootstrap JS for modals. + setTimeout(() => { + app.modal.close(); + }, 300); // Bootstrap's Modal.TRANSITION_DURATION is 300 ms. + + if (e.status !== 409) { + throw e; + } + + const error = e.response.errors[0]; + + app.alerts.show( + { type: 'error' }, + app.translator.trans(`core.lib.error.${error.code}_message`, { + extension: error.extension, + extensions: error.extensions.join(', '), + }) + ); + } +} diff --git a/js/src/admin/components/ExtensionPermissionGrid.js b/js/src/admin/components/ExtensionPermissionGrid.js new file mode 100644 index 0000000000..4788a2da9a --- /dev/null +++ b/js/src/admin/components/ExtensionPermissionGrid.js @@ -0,0 +1,39 @@ +import PermissionGrid from './PermissionGrid'; +import ItemList from '../../common/utils/ItemList'; + +export default class ExtensionPermissionGrid extends PermissionGrid { + oninit(vnode) { + super.oninit(vnode); + + this.extensionId = this.attrs.extensionId; + } + + permissionItems() { + const permissionCategories = super.permissionItems(); + + permissionCategories.items = Object.entries(permissionCategories.items) + .filter(([category, info]) => info.content.children.length > 0) + .reduce((obj, [category, info]) => { + obj[category] = info; + return obj; + }, {}); + + return permissionCategories; + } + + viewItems() { + return app.extensionData.getExtensionPermissions(this.extensionId, 'view') || new ItemList(); + } + + startItems() { + return app.extensionData.getExtensionPermissions(this.extensionId, 'start') || new ItemList(); + } + + replyItems() { + return app.extensionData.getExtensionPermissions(this.extensionId, 'reply') || new ItemList(); + } + + moderateItems() { + return app.extensionData.getExtensionPermissions(this.extensionId, 'moderate') || new ItemList(); + } +} diff --git a/js/src/admin/components/ExtensionsPage.js b/js/src/admin/components/ExtensionsPage.js deleted file mode 100644 index c94fede2ac..0000000000 --- a/js/src/admin/components/ExtensionsPage.js +++ /dev/null @@ -1,158 +0,0 @@ -import Page from '../../common/components/Page'; -import Button from '../../common/components/Button'; -import Dropdown from '../../common/components/Dropdown'; -import AddExtensionModal from './AddExtensionModal'; -import LoadingModal from './LoadingModal'; -import ItemList from '../../common/utils/ItemList'; -import icon from '../../common/helpers/icon'; - -export default class ExtensionsPage extends Page { - view() { - return ( -
-
-
- {Button.component( - { - icon: 'fas fa-plus', - className: 'Button Button--primary', - onclick: () => app.modal.show(AddExtensionModal), - }, - app.translator.trans('core.admin.extensions.add_button') - )} -
-
- -
-
-
    - {Object.keys(app.data.extensions).map((id) => { - const extension = app.data.extensions[id]; - const controls = this.controlItems(extension.id).toArray(); - - return ( -
  • -
    - - {extension.icon ? icon(extension.icon.name) : ''} - - {controls.length ? ( - - {controls} - - ) : ( - '' - )} -
    - -
    {extension.version}
    -
    {extension.description}
    -
    -
    -
  • - ); - })} -
-
-
-
- ); - } - - controlItems(name) { - const items = new ItemList(); - const enabled = this.isEnabled(name); - - if (app.extensionSettings[name]) { - items.add( - 'settings', - Button.component( - { - icon: 'fas fa-cog', - onclick: app.extensionSettings[name], - }, - app.translator.trans('core.admin.extensions.settings_button') - ) - ); - } - - if (!enabled) { - items.add( - 'uninstall', - Button.component( - { - icon: 'far fa-trash-alt', - onclick: () => { - app - .request({ - url: app.forum.attribute('apiUrl') + '/extensions/' + name, - method: 'DELETE', - }) - .then(() => window.location.reload()); - - app.modal.show(LoadingModal); - }, - }, - app.translator.trans('core.admin.extensions.uninstall_button') - ) - ); - } - - return items; - } - - isEnabled(name) { - const enabled = JSON.parse(app.data.settings.extensions_enabled); - - return enabled.indexOf(name) !== -1; - } - - toggle(id) { - const enabled = this.isEnabled(id); - - app - .request({ - url: app.forum.attribute('apiUrl') + '/extensions/' + id, - method: 'PATCH', - body: { enabled: !enabled }, - errorHandler: this.onerror.bind(this), - }) - .then(() => { - if (!enabled) localStorage.setItem('enabledExtension', id); - window.location.reload(); - }); - - app.modal.show(LoadingModal); - } - - onerror(e) { - // We need to give the modal animation time to start; if we close the modal too early, - // it breaks the bootstrap modal library. - // TODO: This workaround should be removed when we move away from bootstrap JS for modals. - setTimeout(() => { - app.modal.close(); - }, 300); // Bootstrap's Modal.TRANSITION_DURATION is 300 ms. - - if (e.status !== 409) { - throw e; - } - - const error = e.response.errors[0]; - - app.alerts.show( - { type: 'error' }, - app.translator.trans(`core.lib.error.${error.code}_message`, { - extension: error.extension, - extensions: error.extensions.join(', '), - }) - ); - } -} diff --git a/js/src/admin/components/ExtensionsWidget.js b/js/src/admin/components/ExtensionsWidget.js new file mode 100644 index 0000000000..626fbe54d5 --- /dev/null +++ b/js/src/admin/components/ExtensionsWidget.js @@ -0,0 +1,48 @@ +import DashboardWidget from './DashboardWidget'; +import isExtensionEnabled from '../utils/isExtensionEnabled'; +import getCategorizedExtensions from '../utils/getCategorizedExtensions'; +import Link from '../../common/components/Link'; +import icon from '../../common/helpers/icon'; + +export default class ExtensionsWidget extends DashboardWidget { + className() { + return 'ExtensionsWidget'; + } + + content() { + const categorizedExtensions = getCategorizedExtensions(); + const categories = app.extensionCategories; + + return ( +
+
+ {Object.keys(categories).map((category) => { + if (categorizedExtensions[category]) { + return ( +
+

{app.translator.trans(`core.admin.nav.categories.${category}`)}

+
    + {categorizedExtensions[category].map((extension) => { + return ( +
  • + +
    + + {extension.icon ? icon(extension.icon.name) : ''} + + {extension.extra['flarum-extension'].title} +
    + +
  • + ); + })} +
+
+ ); + } + })} +
+
+ ); + } +} diff --git a/js/src/admin/components/HeaderSecondary.js b/js/src/admin/components/HeaderSecondary.js index 63868bbafa..19f14b70da 100644 --- a/js/src/admin/components/HeaderSecondary.js +++ b/js/src/admin/components/HeaderSecondary.js @@ -1,4 +1,5 @@ import Component from '../../common/Component'; +import LinkButton from '../../common/components/LinkButton'; import SessionDropdown from './SessionDropdown'; import ItemList from '../../common/utils/ItemList'; import listItems from '../../common/helpers/listItems'; @@ -19,6 +20,13 @@ export default class HeaderSecondary extends Component { items() { const items = new ItemList(); + items.add( + 'help', + + {app.translator.trans('core.admin.header.get_help')} + + ); + items.add('session', SessionDropdown.component()); return items; diff --git a/js/src/admin/components/MailPage.js b/js/src/admin/components/MailPage.js index f20027db74..dfd0e3ea29 100644 --- a/js/src/admin/components/MailPage.js +++ b/js/src/admin/components/MailPage.js @@ -6,6 +6,8 @@ import Select from '../../common/components/Select'; import LoadingIndicator from '../../common/components/LoadingIndicator'; 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 Page { oninit(vnode) { @@ -65,11 +67,11 @@ export default class MailPage extends Page { return (
+ + {app.translator.trans('core.admin.email.title')} +
-

{app.translator.trans('core.admin.email.heading')}

-
{app.translator.trans('core.admin.email.text')}
- {FieldSet.component( { label: app.translator.trans('core.admin.email.addresses_heading'), diff --git a/js/src/admin/components/PermissionGrid.js b/js/src/admin/components/PermissionGrid.js index fe45859bda..62fa0fc1bd 100644 --- a/js/src/admin/components/PermissionGrid.js +++ b/js/src/admin/components/PermissionGrid.js @@ -6,12 +6,6 @@ import ItemList from '../../common/utils/ItemList'; import icon from '../../common/helpers/icon'; export default class PermissionGrid extends Component { - oninit(vnode) { - super.oninit(vnode); - - this.permissions = this.permissionItems().toArray(); - } - view() { const scopes = this.scopeItems().toArray(); @@ -35,25 +29,27 @@ export default class PermissionGrid extends Component { {this.scopeControlItems().toArray()} - {this.permissions.map((section) => ( - - - {section.label} - {permissionCells(section)} - - - {section.children.map((child) => ( - - - {icon(child.icon)} - {child.label} - - {permissionCells(child)} + {this.permissionItems() + .toArray() + .map((section) => ( + + + {section.label} + {permissionCells(section)} - ))} - - ))} + {section.children.map((child) => ( + + + {icon(child.icon)} + {child.label} + + {permissionCells(child)} + + + ))} + + ))} ); } @@ -158,6 +154,8 @@ export default class PermissionGrid extends Component { permission: 'user.viewLastSeenAt', }); + items.merge(app.extensionData.getAllExtensionPermissions('view')); + return items; } @@ -198,6 +196,8 @@ export default class PermissionGrid extends Component { 90 ); + items.merge(app.extensionData.getAllExtensionPermissions('start')); + return items; } @@ -238,6 +238,8 @@ export default class PermissionGrid extends Component { 90 ); + items.merge(app.extensionData.getAllExtensionPermissions('reply')); + return items; } @@ -334,6 +336,8 @@ export default class PermissionGrid extends Component { 60 ); + items.merge(app.extensionData.getAllExtensionPermissions('moderate')); + return items; } diff --git a/js/src/admin/components/PermissionsPage.js b/js/src/admin/components/PermissionsPage.js index cc3eb30311..c024845fc5 100644 --- a/js/src/admin/components/PermissionsPage.js +++ b/js/src/admin/components/PermissionsPage.js @@ -4,11 +4,15 @@ import EditGroupModal from './EditGroupModal'; import Group from '../../common/models/Group'; import icon from '../../common/helpers/icon'; import PermissionGrid from './PermissionGrid'; +import AdminHeader from './AdminHeader'; export default class PermissionsPage extends Page { view() { return (
+ + {app.translator.trans('core.admin.permissions.title')} +
{app.store diff --git a/js/src/admin/resolvers/ExtensionPageResolver.ts b/js/src/admin/resolvers/ExtensionPageResolver.ts new file mode 100644 index 0000000000..a8dc93c333 --- /dev/null +++ b/js/src/admin/resolvers/ExtensionPageResolver.ts @@ -0,0 +1,19 @@ +import DefaultResolver from '../../common/resolvers/DefaultResolver'; + +/** + * A custom route resolver for ExtensionPage that generates handles routes + * to default extension pages or a page provided by an extension. + */ +export default class ExtensionPageResolver extends DefaultResolver { + static extension: string | null = null; + + onmatch(args, requestedPath, route) { + const extensionPage = app.extensionData.getPage(args.id); + + if (extensionPage) { + return extensionPage; + } + + return super.onmatch(args, requestedPath, route); + } +} diff --git a/js/src/admin/routes.js b/js/src/admin/routes.js index c2012ddcf9..3f3e295e1c 100644 --- a/js/src/admin/routes.js +++ b/js/src/admin/routes.js @@ -2,8 +2,9 @@ import DashboardPage from './components/DashboardPage'; import BasicsPage from './components/BasicsPage'; import PermissionsPage from './components/PermissionsPage'; import AppearancePage from './components/AppearancePage'; -import ExtensionsPage from './components/ExtensionsPage'; import MailPage from './components/MailPage'; +import ExtensionPage from './components/ExtensionPage'; +import ExtensionPageResolver from './resolvers/ExtensionPageResolver'; /** * The `routes` initializer defines the forum app's routes. @@ -16,7 +17,7 @@ export default function (app) { basics: { path: '/basics', component: BasicsPage }, permissions: { path: '/permissions', component: PermissionsPage }, appearance: { path: '/appearance', component: AppearancePage }, - extensions: { path: '/extensions', component: ExtensionsPage }, mail: { path: '/mail', component: MailPage }, + extension: { path: '/extension/:id', component: ExtensionPage, resolverClass: ExtensionPageResolver }, }; } diff --git a/js/src/admin/utils/ExtensionData.js b/js/src/admin/utils/ExtensionData.js new file mode 100644 index 0000000000..336a45f23a --- /dev/null +++ b/js/src/admin/utils/ExtensionData.js @@ -0,0 +1,167 @@ +import ItemList from '../../common/utils/ItemList'; + +export default class ExtensionData { + constructor() { + this.data = {}; + this.currentExtension = null; + } + + /** + * This function simply takes the extension id + * + * @example + * app.extensionData.load('flarum-tags') + * + * flarum/flags -> flarum-flags | acme/extension -> acme-extension + * + * @param extension + */ + for(extension) { + this.currentExtension = extension; + this.data[extension] = this.data[extension] || {}; + + return this; + } + + /** + * This function registers your settings with Flarum + * + * @example + * + * .registerSetting({ + * setting: 'flarum-flags.guidelines_url', + * type: 'text', // This will be inputted into the input tag for the setting (text/number/etc) + * label: app.translator.trans('flarum-flags.admin.settings.guidelines_url_label') + * }, 15) // priority is optional (ItemList) + * + * + * @param content + * @param priority + * @returns {ExtensionData} + */ + registerSetting(content, priority = 0) { + this.data[this.currentExtension].settings = this.data[this.currentExtension].settings || new ItemList(); + + this.data[this.currentExtension].settings.add(content.setting, content, priority); + + return this; + } + + /** + * This function registers your permission with Flarum + * + * @example + * + * .registerPermission('permissions', { + * icon: 'fas fa-flag', + * label: app.translator.trans('flarum-flags.admin.permissions.view_flags_label'), + * permission: 'discussion.viewFlags' + * }, 'moderate', 65) + * + * @param content + * @param permissionType + * @param priority + * @returns {ExtensionData} + */ + registerPermission(content, permissionType = null, priority = 0) { + this.data[this.currentExtension].permissions = this.data[this.currentExtension].permissions || {}; + + if (!this.data[this.currentExtension].permissions[permissionType]) { + this.data[this.currentExtension].permissions[permissionType] = new ItemList(); + } + + this.data[this.currentExtension].permissions[permissionType].add(content.permission, content, priority); + + return this; + } + + /** + * Replace the default extension page with a custom component. + * This component would typically extend ExtensionPage + * + * @param component + * @returns {ExtensionData} + */ + registerPage(component) { + this.data[this.currentExtension].page = component; + + return this; + } + + /** + * Get an extension's registered settings + * + * @param extensionId + * @returns {boolean|*} + */ + getSettings(extensionId) { + if (this.data[extensionId] && this.data[extensionId].settings) { + return this.data[extensionId].settings.toArray(); + } + + return false; + } + + /** + * + * Get an ItemList of all extensions' registered permissions + * + * @param extension + * @param type + * @returns {ItemList} + */ + getAllExtensionPermissions(type) { + const items = new ItemList(); + + Object.keys(this.data).map((extension) => { + if (this.extensionHasPermissions(extension) && this.data[extension].permissions[type]) { + items.merge(this.data[extension].permissions[type]); + } + }); + + return items; + } + + /** + * Get a singular extension's registered permissions + * + * @param extension + * @param type + * @returns {boolean|*} + */ + getExtensionPermissions(extension, type) { + if (this.extensionHasPermissions(extension) && this.data[extension].permissions[type]) { + return this.data[extension].permissions[type]; + } + + return new ItemList(); + } + + /** + * Checks whether a given extension has registered permissions. + * + * @param extension + * @returns {boolean} + */ + extensionHasPermissions(extension) { + if (this.data[extension] && this.data[extension].permissions) { + return true; + } + + return false; + } + + /** + * Returns an extension's custom page component if it exists. + * + * @param extension + * @returns {boolean|*} + */ + getPage(extension) { + if (this.data[extension]) { + return this.data[extension].page; + } + + return false; + } +} diff --git a/js/src/admin/utils/getCategorizedExtensions.js b/js/src/admin/utils/getCategorizedExtensions.js new file mode 100644 index 0000000000..fd259d97b8 --- /dev/null +++ b/js/src/admin/utils/getCategorizedExtensions.js @@ -0,0 +1,25 @@ +export default function getCategorizedExtensions() { + let extensions = {}; + + Object.keys(app.data.extensions).map((id) => { + const extension = app.data.extensions[id]; + let category = extension.extra['flarum-extension'].category; + + // Wrap languages packs into new system + if (extension.extra['flarum-locale']) { + category = 'language'; + } + + if (category in app.extensionCategories) { + extensions[category] = extensions[category] || []; + + extensions[category].push(extension); + } else { + extensions.other = extensions.other || []; + + extensions.other.push(extension); + } + }); + + return extensions; +} diff --git a/js/src/admin/utils/isExtensionEnabled.js b/js/src/admin/utils/isExtensionEnabled.js new file mode 100644 index 0000000000..953c08fcb5 --- /dev/null +++ b/js/src/admin/utils/isExtensionEnabled.js @@ -0,0 +1,5 @@ +export default function isExtensionEnabled(name) { + const enabled = JSON.parse(app.data.settings.extensions_enabled); + + return enabled.includes(name); +} diff --git a/js/src/common/components/Button.js b/js/src/common/components/Button.js index bf756b0dde..6ae10a5303 100644 --- a/js/src/common/components/Button.js +++ b/js/src/common/components/Button.js @@ -35,6 +35,11 @@ export default class Button extends Component { attrs['aria-label'] = attrs.title; } + // If given a translation object, extract the text. + if (typeof attrs.title === 'object') { + attrs.title = extractText(attrs.title); + } + // If nothing else is provided, we use the textual button content as tooltip if (!attrs.title && vnode.children) { attrs.title = extractText(vnode.children); diff --git a/js/src/common/components/SelectDropdown.js b/js/src/common/components/SelectDropdown.js index c61fdb5f1e..e7111ffe7e 100644 --- a/js/src/common/components/SelectDropdown.js +++ b/js/src/common/components/SelectDropdown.js @@ -12,6 +12,9 @@ import icon from '../helpers/icon'; function isActive(vnode) { const tag = vnode.tag; + // Allow non-selectable dividers/headers to be added. + if (typeof tag === 'string' && tag !== 'a' && tag !== 'button') return false; + if ('initAttrs' in tag) { tag.initAttrs(vnode.attrs); } diff --git a/less/admin.less b/less/admin.less index d2b56ea216..22e4d39c22 100644 --- a/less/admin.less +++ b/less/admin.less @@ -1,10 +1,12 @@ @import "common/common"; +@import "admin/AdminHeader"; @import "admin/AdminNav"; @import "admin/DashboardPage"; @import "admin/BasicsPage"; @import "admin/PermissionsPage"; @import "admin/EditGroupModal"; -@import "admin/ExtensionsPage"; +@import "admin/ExtensionPage"; +@import "admin/ExtensionWidget"; @import "admin/AppearancePage"; @import "admin/MailPage"; diff --git a/less/admin/AdminHeader.less b/less/admin/AdminHeader.less new file mode 100644 index 0000000000..dd9c9414a1 --- /dev/null +++ b/less/admin/AdminHeader.less @@ -0,0 +1,19 @@ +.AdminHeader { + background: @control-bg; + margin-bottom: 20px; + padding: 20px 0; + + h2 { + margin-top: 0; + margin-bottom: 10px; + color: @muted-color; + } + + .AdminHeader-description { + margin: 0; + } + + .icon { + margin-right: 15px; + } +} diff --git a/less/admin/AdminNav.less b/less/admin/AdminNav.less index c0770ac640..2a71e45eeb 100644 --- a/less/admin/AdminNav.less +++ b/less/admin/AdminNav.less @@ -1,17 +1,85 @@ -@admin-pane-width: 300px; +@admin-pane-width: 250px; .App { padding-bottom: 0; } -.AdminLinkButton-description { - display: none; -} + .AdminContent { padding: 20px 0; } .App-content .sideNavOffset { margin-top: 0; } + +.Header-controls { + > li { + margin-left: 10px; + } +} + +@media @phone { + .Dropdown-menu { + height: 70vh; + + .item-search { + margin: 10px; + + .SearchBar { + width: 100% + } + } + } + + .ExtensionNavButton { + .Button-label { + margin-left: 30px; + } + .ExtensionIcon { + margin: 0 0 0 -4px !important; + } + } +} + +@media @tablet { + .item-search{ + display: none; + } + + .ExtensionItem, .item-search { + display: none !important; + } + + .ExtensionListTitle { + display: none !important; + } +} + +@media @phone, @tablet { + .App-nav .AdminNav { + .Dropdown-menu { + > li { + .ExtensionListTitle { + color: @muted-color; + text-transform: uppercase; + margin: 25px 0 10px 15px; + } + + .ExtensionIcon { + margin: -2px -29px; + width: 25px; + height: 25px; + font-size: 12.5px; + + .icon { + margin: 0; + } + } + } + } + } +} + + @media @desktop, @desktop-hd { .App-nav { position: absolute; @@ -20,60 +88,84 @@ width: @admin-pane-width; .box-shadow(0 6px 6px @shadow-color); background: @body-bg; - border-top: 1px solid @control-bg; z-index: @zindex-pane; - overflow: auto; + overflow-y: scroll; + padding-bottom: 40px; .affix & { position: fixed; bottom: 0; - height: auto; } } .App-content .sideNavOffset { margin-left: @admin-pane-width; } .App-nav .AdminNav { - .Dropdown-menu > li { - > a { - padding: 15px 15px 15px 45px; - display: block; - text-decoration: none; - white-space: normal; + .Dropdown-menu { + .item-search { + margin-top: 10px; + margin-bottom: 20px; } - > a, > a:hover, &.active > a { - color: @muted-color; - } - > a:hover { - background: @control-bg; - } - &.active > a { - background: @control-bg; - font-weight: normal; - .Button-label, .Button-icon { + > li { + > a { + padding: 10px 10px 10px 45px; + display: block; + text-decoration: none; + } + > a, + > a:hover, + &.active > a { color: @text-color; - font-weight: bold; + } + > a:hover { + background: @control-bg; + } + &.active > a { + background: @primary-color; + font-weight: normal; + + .Button-label, + .Button-icon { + color: @body-bg; + font-weight: bold; + } + } + .Button-icon { + float: left; + font-size: 13px !important; + margin-left: -25px !important; + margin-top: 4px !important; + } + .Button-label { + padding-left: 5px; + font-size: 14px; + font-weight: normal; + } + + .Search-input, + .SearchBar { + max-width: 215px; + margin: 0 auto; + } + + .ExtensionListTitle { + color: @muted-color; + text-transform: uppercase; + margin: 25px 0 15px 15px; + } + + .ExtensionIcon { + width: 25px; + height: 25px; + font-size: 15px; + margin-left: -29px; + vertical-align: middle; } } } - .Button-icon { - float: left; - margin-left: -30px; - font-size: 14px; - margin-top: 4px !important; - } - .Button-label { - display: block; - font-size: 15px; - font-weight: normal; - margin: 0 0 5px; - } - .AdminLinkButton-description { - display: block; - font-size: 12px; - } } + .container { width: 100%; margin: 0; @@ -85,4 +177,33 @@ padding: 0; } } + +} + +.ExtensionListItem-Dot { + height: 10px; + width: 10px; + border-radius: 50%; + display: inline-block; + right: 13px; + margin: 6px 5px; + position: absolute; +} + +.ExtensionNavButton { + .Button-label { + display: inline-block; + max-width: calc(100% - 18px); + overflow: hidden; + text-overflow: ellipsis; + vertical-align: middle; + margin-left: 5px; + } +} + +.ExtensionListItem-Dot.enabled { + background-color: #2ECC40; +} +.ExtensionListItem-Dot.disabled { + background-color: #FF4136; } diff --git a/less/admin/AppearancePage.less b/less/admin/AppearancePage.less index 5593263c21..6208983ee4 100644 --- a/less/admin/AppearancePage.less +++ b/less/admin/AppearancePage.less @@ -1,8 +1,8 @@ .AppearancePage { + @media @desktop-up { .container { max-width: 600px; - padding: 30px; margin: 0; } } diff --git a/less/admin/BasicsPage.less b/less/admin/BasicsPage.less index c22ad472dc..d82d7cd38b 100644 --- a/less/admin/BasicsPage.less +++ b/less/admin/BasicsPage.less @@ -1,5 +1,4 @@ .BasicsPage { - padding: 20px 0; @media @desktop-up { .container { diff --git a/less/admin/DashboardPage.less b/less/admin/DashboardPage.less index 9bab2986d4..57c623ba75 100644 --- a/less/admin/DashboardPage.less +++ b/less/admin/DashboardPage.less @@ -1,18 +1,11 @@ .DashboardPage { - background: @control-bg; + background: @body-bg; color: @control-color; min-height: 100vh; - - @media @desktop-up { - .container { - padding: 30px; - margin: 0; - } - } } -.DashboardWidget { - background: @body-bg; +.Widget { + background: @control-bg; color: @text-color; border-radius: @border-radius; padding: 20px; diff --git a/less/admin/ExtensionPage.less b/less/admin/ExtensionPage.less new file mode 100644 index 0000000000..5bb5cefb6c --- /dev/null +++ b/less/admin/ExtensionPage.less @@ -0,0 +1,153 @@ +.ExtensionPage { + min-height: 110vh; + + .ExtensionPage-header { + .ExtensionTitle { + display: flex; + align-items: center; + flex-wrap: wrap; + margin: 20px 0 15px; + } + + .helpText { + margin-bottom: 5px; + } + + h2 { + display: inline-block; + margin: 0; + vertical-align: middle; + } + } + + .ExtensionPage-header, + .ExtensionPage-permissions-header { + background: @control-bg; + + h2 { + color: @muted-color; + + span { + font-size: 13px; + color: @muted-color; + font-weight: normal; + } + } + + .Button-icon { + display: unset; + } + + ul { + display: flex; + align-items: center; + list-style-type: none; + padding: 0; + margin: 0; + + > li { + display: inline; + color: @muted-color; + margin-left: 13px; + + + > a { + color: @muted-color; + } + + > .icon { + margin-right: 5px; + } + } + } + + .ExtensionPage-headerItems { + padding: 15px 0; + display: flex; + align-items: center; + flex-wrap: wrap; + + .Checkbox { + margin: 5px 0 0 0; + display: inline-block; + } + } + + .Checkbox.off { + .Checkbox-display { + background: @muted-more-color; + } + } + + .ExtensionInfo { + margin-left: auto; + + .item-authors { + a { + color: @muted-color; + } + } + } + + + .ExtensionName { + display: inline-block; + margin-left: 8px; + } + + .ExtensionIcon { + width: 30px; + height: 30px; + font-size: 15px; + margin-left: 0; + vertical-align: middle; + } + + .ExtensionPage-headerTopItems { + margin-left: auto; + } + + @media (max-width: @screen-phone-max) { + .ExtensionPage-headerTopItems { + float: right; + position: relative; + } + + .item-website, .item-source, .item-documentation { + display: none; + } + } + } + + + .ExtensionPage-settings { + margin-top: 20px; + padding: 10px 0; + + input { + max-width: 400px; + } + } + + .ExtensionPage-subHeader { + color: @muted-color; + font-weight: normal; + text-align: center; + } + + + .ExtensionPage-permissions { + + @media @phone { + > .container { + overflow-x: scroll; + padding-bottom: 20px; + } + } + + .ExtensionPage-permissions-header { + margin: 20px 0 20px; + padding: 5px 0; + } + } +} diff --git a/less/admin/ExtensionWidget.less b/less/admin/ExtensionWidget.less new file mode 100644 index 0000000000..689aec6ec4 --- /dev/null +++ b/less/admin/ExtensionWidget.less @@ -0,0 +1,93 @@ +.ExtensionsWidget { + background-color: @body-bg; + padding: 0; +} + +.ExtensionsWidget-list { + > .container { + 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; + + h3 { + color: @muted-color; + text-transform: uppercase; + font-size: 12px; + margin: 0 0 10px; + } + } + + .ExtensionList { + padding: 0; + list-style: none; + display: grid; + grid-gap: 10px; + grid-template-columns: repeat(auto-fit, 90px); + margin-bottom: 0; + + > li { + text-align: left; + position: relative; + display: block; + } + } + } + + .ExtensionListItem.disabled { + .ExtensionListItem-title { + opacity: 0.5; + color: @muted-color; + } + + .ExtensionListItem-icon { + opacity: 0.5; + } + } + + .ExtensionListItem { + transition: .15s ease-in-out; + + &:hover { + transform: scale(1.05); + } + + .ExtensionListItem-title { + display: block; + text-align: center; + margin-top: 5px; + color: @text-color; + } + + a:hover { + text-decoration: none; + } + } +} + +.ExtensionIcon { + width: 90px; + height: 90px; + background: @control-bg; + color: @control-color; + border-radius: 6px; + display: inline-flex; + font-size: 45px; + text-align: center; + align-items: center; + justify-content: center; + vertical-align: middle; +} diff --git a/less/admin/ExtensionsPage.less b/less/admin/ExtensionsPage.less deleted file mode 100644 index 83142d8d4f..0000000000 --- a/less/admin/ExtensionsPage.less +++ /dev/null @@ -1,115 +0,0 @@ -@extension-list-column-width: 410px; - -.ExtensionsPage-header { - padding: 20px 0; - background: @control-bg; -} - -.ExtensionsPage-list { - padding: 30px 0; -} -.ExtensionGroup { - margin-bottom: 20px; - - h3 { - color: @muted-color; - text-transform: uppercase; - font-size: 12px; - margin: 0 0 10px; - } -} - -.ExtensionList { - columns: 3; - column-width: @extension-list-column-width; - margin: 0; - padding: 0; - list-style: none; - .clearfix(); - - > li { - -webkit-column-break-inside: avoid; - break-inside: avoid-column; - page-break-inside: avoid; - text-align: left; - position: relative; - border-radius: 4px; - transition: background .2s; - } -} -.ExtensionListItem.disabled { - .ExtensionListItem-title { - opacity: 0.5; - color: @muted-color; - } - .ExtensionListItem-icon { - opacity: 0.5; - } -} -.ExtensionListItem { - padding: 10px; -} -.ExtensionListItem:hover { - background: @control-bg; -} -.ExtensionListItem-content { - padding: 0 50px; - min-height: 40px; -} -.ExtensionListItem-main { - overflow: hidden; - text-overflow: ellipsis; -} -.ExtensionListItem-title { - display: inline-block; - font-size: 13px; - font-weight: bold; - white-space: nowrap; - cursor: pointer; - padding-right: 10px; -} -.ExtensionListItem-version { - color: @muted-more-color; - font-size: 11px; - font-weight: normal; - display: inline-flex; -} -.ExtensionListItem-controls { - float: right; - display: none; - margin-right: -50px; - margin-top: 1px; - - .ExtensionListItem:hover &, &.open { - display: block; - } -} -.ExtensionListItem-description { - font-size: 11px; - font-weight: normal; - text-align: justify; -} - -.ExtensionIcon { - width: 40px; - height: 40px; - background: @control-bg; - color: @control-color; - border-radius: 6px; - display: inline-block; - font-size: 20px; - line-height: 40px; - text-align: center; - margin-left: -50px; - position: absolute; -} - -@media (max-width: @extension-list-column-width) { - .ExtensionListItem-description { - display: none; - } - .ExtensionListItem-version { - display: block; - margin-top: 8px; - } -} diff --git a/less/admin/MailPage.less b/less/admin/MailPage.less index 63348b21f8..2b9ee36b20 100644 --- a/less/admin/MailPage.less +++ b/less/admin/MailPage.less @@ -1,5 +1,4 @@ .MailPage { - padding: 20px 0; @media @desktop-up { .container { diff --git a/less/admin/PermissionsPage.less b/less/admin/PermissionsPage.less index 223885965c..ac7b011353 100644 --- a/less/admin/PermissionsPage.less +++ b/less/admin/PermissionsPage.less @@ -1,6 +1,11 @@ .PermissionsPage-groups { background: @control-bg; - padding: 20px 0; + border-radius: @border-radius; + max-width: calc(~'100% - 60px'); + display: block; + margin-left: 30px; + overflow-x: auto; + padding: 8px 0 8px; } .Group { width: 90px;