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

remove 'old' folder from git

This commit is contained in:
David Sevilla Martin
2020-02-23 09:39:05 -05:00
parent bcaa6f4d8a
commit c03e0f7f75
168 changed files with 0 additions and 15566 deletions

View File

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

View File

@@ -1,63 +0,0 @@
import compat from '../common/compat';
import saveSettings from './utils/saveSettings';
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 Page from './components/Page';
import StatusWidget from './components/StatusWidget';
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 AdminLinkButton from './components/AdminLinkButton';
import PermissionGrid from './components/PermissionGrid';
import Widget from './components/Widget';
import MailPage from './components/MailPage';
import UploadImageButton from './components/UploadImageButton';
import LoadingModal from './components/LoadingModal';
import DashboardPage from './components/DashboardPage';
import BasicsPage from './components/BasicsPage';
import EditCustomHeaderModal from './components/EditCustomHeaderModal';
import PermissionsPage from './components/PermissionsPage';
import PermissionDropdown from './components/PermissionDropdown';
import AdminNav from './components/AdminNav';
import EditCustomCssModal from './components/EditCustomCssModal';
import EditGroupModal from './components/EditGroupModal';
import routes from './routes';
import AdminApplication from './AdminApplication';
export default Object.assign(compat, {
'utils/saveSettings': saveSettings,
'components/SettingDropdown': SettingDropdown,
'components/EditCustomFooterModal': EditCustomFooterModal,
'components/SessionDropdown': SessionDropdown,
'components/HeaderPrimary': HeaderPrimary,
'components/AppearancePage': AppearancePage,
'components/Page': Page,
'components/StatusWidget': StatusWidget,
'components/HeaderSecondary': HeaderSecondary,
'components/SettingsModal': SettingsModal,
'components/DashboardWidget': DashboardWidget,
'components/AddExtensionModal': AddExtensionModal,
'components/ExtensionsPage': ExtensionsPage,
'components/AdminLinkButton': AdminLinkButton,
'components/PermissionGrid': PermissionGrid,
'components/Widget': Widget,
'components/MailPage': MailPage,
'components/UploadImageButton': UploadImageButton,
'components/LoadingModal': LoadingModal,
'components/DashboardPage': DashboardPage,
'components/BasicsPage': BasicsPage,
'components/EditCustomHeaderModal': EditCustomHeaderModal,
'components/PermissionsPage': PermissionsPage,
'components/PermissionDropdown': PermissionDropdown,
'components/AdminNav': AdminNav,
'components/EditCustomCssModal': EditCustomCssModal,
'components/EditGroupModal': EditGroupModal,
routes: routes,
AdminApplication: AdminApplication,
});

View File

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

View File

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

View File

@@ -1,94 +0,0 @@
/*
* This file is part of Flarum.
*
* (c) Toby Zerner <toby.zerner@gmail.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
import Component from '../../common/Component';
import AdminLinkButton from './AdminLinkButton';
import SelectDropdown from '../../common/components/SelectDropdown';
import ItemList from '../../common/utils/ItemList';
export default class AdminNav extends Component {
view() {
return (
<SelectDropdown className="AdminNav App-titleControl" buttonClassName="Button">
{this.items().toArray()}
</SelectDropdown>
);
}
/**
* Build an item list of links to show in the admin navigation.
*
* @return {ItemList}
*/
items() {
const items = new ItemList();
items.add(
'dashboard',
AdminLinkButton.component({
href: app.route('dashboard'),
icon: 'far fa-chart-bar',
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

@@ -1,132 +0,0 @@
import Page from './Page';
import Button from '../../common/components/Button';
import Switch from '../../common/components/Switch';
import EditCustomCssModal from './EditCustomCssModal';
import EditCustomHeaderModal from './EditCustomHeaderModal';
import EditCustomFooterModal from './EditCustomFooterModal';
import UploadImageButton from './UploadImageButton';
import saveSettings from '../utils/saveSettings';
export default class AppearancePage extends Page {
init() {
super.init();
this.primaryColor = m.prop(app.data.settings.theme_primary_color);
this.secondaryColor = m.prop(app.data.settings.theme_secondary_color);
this.darkMode = m.prop(app.data.settings.theme_dark_mode === '1');
this.coloredHeader = m.prop(app.data.settings.theme_colored_header === '1');
}
view() {
return (
<div className="AppearancePage">
<div className="container">
<form onsubmit={this.onsubmit.bind(this)}>
<fieldset className="AppearancePage-colors">
<legend>{app.translator.trans('core.admin.appearance.colors_heading')}</legend>
<div className="helpText">{app.translator.trans('core.admin.appearance.colors_text')}</div>
<div className="AppearancePage-colors-input">
<input
className="FormControl"
type="text"
placeholder="#aaaaaa"
value={this.primaryColor()}
onchange={m.withAttr('value', this.primaryColor)}
/>
<input
className="FormControl"
type="text"
placeholder="#aaaaaa"
value={this.secondaryColor()}
onchange={m.withAttr('value', this.secondaryColor)}
/>
</div>
{Switch.component({
state: this.darkMode(),
children: app.translator.trans('core.admin.appearance.dark_mode_label'),
onchange: this.darkMode,
})}
{Switch.component({
state: this.coloredHeader(),
children: app.translator.trans('core.admin.appearance.colored_header_label'),
onchange: this.coloredHeader,
})}
{Button.component({
className: 'Button Button--primary',
type: 'submit',
children: app.translator.trans('core.admin.appearance.submit_button'),
loading: this.loading,
})}
</fieldset>
</form>
<fieldset>
<legend>{app.translator.trans('core.admin.appearance.logo_heading')}</legend>
<div className="helpText">{app.translator.trans('core.admin.appearance.logo_text')}</div>
<UploadImageButton name="logo" />
</fieldset>
<fieldset>
<legend>{app.translator.trans('core.admin.appearance.favicon_heading')}</legend>
<div className="helpText">{app.translator.trans('core.admin.appearance.favicon_text')}</div>
<UploadImageButton name="favicon" />
</fieldset>
<fieldset>
<legend>{app.translator.trans('core.admin.appearance.custom_header_heading')}</legend>
<div className="helpText">{app.translator.trans('core.admin.appearance.custom_header_text')}</div>
{Button.component({
className: 'Button',
children: app.translator.trans('core.admin.appearance.edit_header_button'),
onclick: () => app.modal.show(new EditCustomHeaderModal()),
})}
</fieldset>
<fieldset>
<legend>{app.translator.trans('core.admin.appearance.custom_footer_heading')}</legend>
<div className="helpText">{app.translator.trans('core.admin.appearance.custom_footer_text')}</div>
{Button.component({
className: 'Button',
children: app.translator.trans('core.admin.appearance.edit_footer_button'),
onclick: () => app.modal.show(new EditCustomFooterModal()),
})}
</fieldset>
<fieldset>
<legend>{app.translator.trans('core.admin.appearance.custom_styles_heading')}</legend>
<div className="helpText">{app.translator.trans('core.admin.appearance.custom_styles_text')}</div>
{Button.component({
className: 'Button',
children: app.translator.trans('core.admin.appearance.edit_css_button'),
onclick: () => app.modal.show(new EditCustomCssModal()),
})}
</fieldset>
</div>
</div>
);
}
onsubmit(e) {
e.preventDefault();
const hex = /^#[0-9a-f]{3}([0-9a-f]{3})?$/i;
if (!hex.test(this.primaryColor()) || !hex.test(this.secondaryColor())) {
alert(app.translator.trans('core.admin.appearance.enter_hex_message'));
return;
}
this.loading = true;
saveSettings({
theme_primary_color: this.primaryColor(),
theme_secondary_color: this.secondaryColor(),
theme_dark_mode: this.darkMode(),
theme_colored_header: this.coloredHeader(),
}).then(() => window.location.reload());
}
}

View File

@@ -1,174 +0,0 @@
import Page from './Page';
import FieldSet from '../../common/components/FieldSet';
import Select from '../../common/components/Select';
import Button from '../../common/components/Button';
import Alert from '../../common/components/Alert';
import saveSettings from '../utils/saveSettings';
import ItemList from '../../common/utils/ItemList';
import Switch from '../../common/components/Switch';
export default class BasicsPage extends Page {
init() {
super.init();
this.loading = false;
this.fields = [
'forum_title',
'forum_description',
'default_locale',
'show_language_selector',
'default_route',
'welcome_title',
'welcome_message',
];
this.values = {};
const settings = app.data.settings;
this.fields.forEach((key) => (this.values[key] = m.prop(settings[key])));
this.localeOptions = {};
const locales = app.data.locales;
for (const i in locales) {
this.localeOptions[i] = `${locales[i]} (${i})`;
}
if (typeof this.values.show_language_selector() !== 'number') this.values.show_language_selector(1);
}
view() {
return (
<div className="BasicsPage">
<div className="container">
<form onsubmit={this.onsubmit.bind(this)}>
{FieldSet.component({
label: app.translator.trans('core.admin.basics.forum_title_heading'),
children: [<input className="FormControl" value={this.values.forum_title()} oninput={m.withAttr('value', this.values.forum_title)} />],
})}
{FieldSet.component({
label: app.translator.trans('core.admin.basics.forum_description_heading'),
children: [
<div className="helpText">{app.translator.trans('core.admin.basics.forum_description_text')}</div>,
<textarea
className="FormControl"
value={this.values.forum_description()}
oninput={m.withAttr('value', this.values.forum_description)}
/>,
],
})}
{Object.keys(this.localeOptions).length > 1
? FieldSet.component({
label: app.translator.trans('core.admin.basics.default_language_heading'),
children: [
Select.component({
options: this.localeOptions,
value: this.values.default_locale(),
onchange: this.values.default_locale,
}),
Switch.component({
state: this.values.show_language_selector(),
onchange: this.values.show_language_selector,
children: app.translator.trans('core.admin.basics.show_language_selector_label'),
}),
],
})
: ''}
{FieldSet.component({
label: app.translator.trans('core.admin.basics.home_page_heading'),
className: 'BasicsPage-homePage',
children: [
<div className="helpText">{app.translator.trans('core.admin.basics.home_page_text')}</div>,
this.homePageItems()
.toArray()
.map(({ path, label }) => (
<label className="checkbox">
<input
type="radio"
name="homePage"
value={path}
checked={this.values.default_route() === path}
onclick={m.withAttr('value', this.values.default_route)}
/>
{label}
</label>
)),
],
})}
{FieldSet.component({
label: app.translator.trans('core.admin.basics.welcome_banner_heading'),
className: 'BasicsPage-welcomeBanner',
children: [
<div className="helpText">{app.translator.trans('core.admin.basics.welcome_banner_text')}</div>,
<div className="BasicsPage-welcomeBanner-input">
<input className="FormControl" value={this.values.welcome_title()} oninput={m.withAttr('value', this.values.welcome_title)} />
<textarea
className="FormControl"
value={this.values.welcome_message()}
oninput={m.withAttr('value', this.values.welcome_message)}
/>
</div>,
],
})}
{Button.component({
type: 'submit',
className: 'Button Button--primary',
children: app.translator.trans('core.admin.basics.submit_button'),
loading: this.loading,
disabled: !this.changed(),
})}
</form>
</div>
</div>
);
}
changed() {
return this.fields.some((key) => this.values[key]() !== app.data.settings[key]);
}
/**
* Build a list of options for the default homepage. Each option must be an
* object with `path` and `label` properties.
*
* @return {ItemList}
* @public
*/
homePageItems() {
const items = new ItemList();
items.add('allDiscussions', {
path: '/all',
label: app.translator.trans('core.admin.basics.all_discussions_label'),
});
return items;
}
onsubmit(e) {
e.preventDefault();
if (this.loading) return;
this.loading = true;
app.alerts.dismiss(this.successAlert);
const settings = {};
this.fields.forEach((key) => (settings[key] = this.values[key]()));
saveSettings(settings)
.then(() => {
app.alerts.show((this.successAlert = new Alert({ type: 'success', children: app.translator.trans('core.admin.basics.saved_message') })));
})
.catch(() => {})
.then(() => {
this.loading = false;
m.redraw();
});
}
}

View File

@@ -1,16 +0,0 @@
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

@@ -1,34 +0,0 @@
/*
* This file is part of Flarum.
*
* (c) Toby Zerner <toby.zerner@gmail.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
import Component from '../../common/Component';
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

@@ -1,28 +0,0 @@
import SettingsModal from './SettingsModal';
export default class EditCustomCssModal extends SettingsModal {
className() {
return 'EditCustomCssModal Modal--large';
}
title() {
return app.translator.trans('core.admin.edit_css.title');
}
form() {
return [
<p>
{app.translator.trans('core.admin.edit_css.customize_text', {
a: <a href="https://github.com/flarum/core/tree/master/less" target="_blank" />,
})}
</p>,
<div className="Form-group">
<textarea className="FormControl" rows="30" bidi={this.setting('custom_less')} />
</div>,
];
}
onsaved() {
window.location.reload();
}
}

View File

@@ -1,24 +0,0 @@
import SettingsModal from './SettingsModal';
export default class EditCustomFooterModal extends SettingsModal {
className() {
return 'EditCustomFooterModal Modal--large';
}
title() {
return app.translator.trans('core.admin.edit_footer.title');
}
form() {
return [
<p>{app.translator.trans('core.admin.edit_footer.customize_text')}</p>,
<div className="Form-group">
<textarea className="FormControl" rows="30" bidi={this.setting('custom_footer')} />
</div>,
];
}
onsaved() {
window.location.reload();
}
}

View File

@@ -1,24 +0,0 @@
import SettingsModal from './SettingsModal';
export default class EditCustomHeaderModal extends SettingsModal {
className() {
return 'EditCustomHeaderModal Modal--large';
}
title() {
return app.translator.trans('core.admin.edit_header.title');
}
form() {
return [
<p>{app.translator.trans('core.admin.edit_header.customize_text')}</p>,
<div className="Form-group">
<textarea className="FormControl" rows="30" bidi={this.setting('custom_header')} />
</div>,
];
}
onsaved() {
window.location.reload();
}
}

View File

@@ -1,144 +0,0 @@
import Modal from '../../common/components/Modal';
import Button from '../../common/components/Button';
import Badge from '../../common/components/Badge';
import Group from '../../common/models/Group';
import ItemList from '../../common/utils/ItemList';
/**
* The `EditGroupModal` component shows a modal dialog which allows the user
* to create or edit a group.
*/
export default class EditGroupModal extends Modal {
init() {
this.group = this.props.group || app.store.createRecord('groups');
this.nameSingular = m.prop(this.group.nameSingular() || '');
this.namePlural = m.prop(this.group.namePlural() || '');
this.icon = m.prop(this.group.icon() || '');
this.color = m.prop(this.group.color() || '');
}
className() {
return 'EditGroupModal Modal--small';
}
title() {
return [
this.color() || this.icon()
? Badge.component({
icon: this.icon(),
style: { backgroundColor: this.color() },
})
: '',
' ',
this.namePlural() || app.translator.trans('core.admin.edit_group.title'),
];
}
content() {
return (
<div className="Modal-body">
<div className="Form">{this.fields().toArray()}</div>
</div>
);
}
fields() {
const items = new ItemList();
items.add(
'name',
<div className="Form-group">
<label>{app.translator.trans('core.admin.edit_group.name_label')}</label>
<div className="EditGroupModal-name-input">
<input
className="FormControl"
placeholder={app.translator.trans('core.admin.edit_group.singular_placeholder')}
value={this.nameSingular()}
oninput={m.withAttr('value', this.nameSingular)}
/>
<input
className="FormControl"
placeholder={app.translator.trans('core.admin.edit_group.plural_placeholder')}
value={this.namePlural()}
oninput={m.withAttr('value', this.namePlural)}
/>
</div>
</div>,
30
);
items.add(
'color',
<div className="Form-group">
<label>{app.translator.trans('core.admin.edit_group.color_label')}</label>
<input className="FormControl" placeholder="#aaaaaa" value={this.color()} oninput={m.withAttr('value', this.color)} />
</div>,
20
);
items.add(
'icon',
<div className="Form-group">
<label>{app.translator.trans('core.admin.edit_group.icon_label')}</label>
<div className="helpText">
{app.translator.trans('core.admin.edit_group.icon_text', { a: <a href="https://fontawesome.com/icons?m=free" tabindex="-1" /> })}
</div>
<input className="FormControl" placeholder="fas fa-bolt" value={this.icon()} oninput={m.withAttr('value', this.icon)} />
</div>,
10
);
items.add(
'submit',
<div className="Form-group">
{Button.component({
type: 'submit',
className: 'Button Button--primary EditGroupModal-save',
loading: this.loading,
children: app.translator.trans('core.admin.edit_group.submit_button'),
})}
{this.group.exists && this.group.id() !== Group.ADMINISTRATOR_ID ? (
<button type="button" className="Button EditGroupModal-delete" onclick={this.deleteGroup.bind(this)}>
{app.translator.trans('core.admin.edit_group.delete_button')}
</button>
) : (
''
)}
</div>,
-10
);
return items;
}
submitData() {
return {
nameSingular: this.nameSingular(),
namePlural: this.namePlural(),
color: this.color(),
icon: this.icon(),
};
}
onsubmit(e) {
e.preventDefault();
this.loading = true;
this.group
.save(this.submitData(), { errorHandler: this.onerror.bind(this) })
.then(this.hide.bind(this))
.catch(() => {
this.loading = false;
m.redraw();
});
}
deleteGroup() {
if (confirm(app.translator.trans('core.admin.edit_group.delete_confirmation'))) {
this.group.delete().then(() => m.redraw());
this.hide();
}
}
}

View File

@@ -1,131 +0,0 @@
import Page from './Page';
import LinkButton from '../../common/components/LinkButton';
import Button from '../../common/components/Button';
import Dropdown from '../../common/components/Dropdown';
import Separator from '../../common/components/Separator';
import AddExtensionModal from './AddExtensionModal';
import LoadingModal from './LoadingModal';
import ItemList from '../../common/utils/ItemList';
import icon from '../../common/helpers/icon';
import listItems from '../../common/helpers/listItems';
export default class ExtensionsPage extends Page {
view() {
return (
<div className="ExtensionsPage">
<div className="ExtensionsPage-header">
<div className="container">
{Button.component({
children: app.translator.trans('core.admin.extensions.add_button'),
icon: 'fas fa-plus',
className: 'Button Button--primary',
onclick: () => app.modal.show(new AddExtensionModal()),
})}
</div>
</div>
<div className="ExtensionsPage-list">
<div className="container">
<ul className="ExtensionList">
{Object.keys(app.data.extensions).map((id) => {
const extension = app.data.extensions[id];
const controls = this.controlItems(extension.id).toArray();
return (
<li className={'ExtensionListItem ' + (!this.isEnabled(extension.id) ? 'disabled' : '')}>
<div className="ExtensionListItem-content">
<span className="ExtensionListItem-icon ExtensionIcon" style={extension.icon}>
{extension.icon ? icon(extension.icon.name) : ''}
</span>
{controls.length ? (
<Dropdown
className="ExtensionListItem-controls"
buttonClassName="Button Button--icon Button--flat"
menuClassName="Dropdown-menu--right"
icon="fas fa-ellipsis-h"
>
{controls}
</Dropdown>
) : (
''
)}
<div className="ExtensionListItem-main">
<label className="ExtensionListItem-title">
<input type="checkbox" checked={this.isEnabled(extension.id)} onclick={this.toggle.bind(this, extension.id)} />{' '}
{extension.extra['flarum-extension'].title}
</label>
<div className="ExtensionListItem-version">{extension.version}</div>
<div className="ExtensionListItem-description">{extension.description}</div>
</div>
</div>
</li>
);
})}
</ul>
</div>
</div>
</div>
);
}
controlItems(name) {
const items = new ItemList();
const enabled = this.isEnabled(name);
if (app.extensionSettings[name]) {
items.add(
'settings',
Button.component({
icon: 'fas fa-cog',
children: app.translator.trans('core.admin.extensions.settings_button'),
onclick: app.extensionSettings[name],
})
);
}
if (!enabled) {
items.add(
'uninstall',
Button.component({
icon: 'far fa-trash-alt',
children: app.translator.trans('core.admin.extensions.uninstall_button'),
onclick: () => {
app
.request({
url: app.forum.attribute('apiUrl') + '/extensions/' + name,
method: 'DELETE',
})
.then(() => window.location.reload());
app.modal.show(new LoadingModal());
},
})
);
}
return items;
}
isEnabled(name) {
const enabled = JSON.parse(app.data.settings.extensions_enabled);
return enabled.indexOf(name) !== -1;
}
toggle(id) {
const enabled = this.isEnabled(id);
app
.request({
url: app.forum.attribute('apiUrl') + '/extensions/' + id,
method: 'PATCH',
data: { enabled: !enabled },
})
.then(() => {
if (!enabled) localStorage.setItem('enabledExtension', id);
window.location.reload();
});
app.modal.show(new LoadingModal());
}
}

View File

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

View File

@@ -1,33 +0,0 @@
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

@@ -1,19 +0,0 @@
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

@@ -1,174 +0,0 @@
import Page from './Page';
import FieldSet from '../../common/components/FieldSet';
import Button from '../../common/components/Button';
import Alert from '../../common/components/Alert';
import Select from '../../common/components/Select';
import LoadingIndicator from '../../common/components/LoadingIndicator';
import saveSettings from '../utils/saveSettings';
export default class MailPage extends Page {
init() {
super.init();
this.saving = false;
this.refresh();
}
refresh() {
this.loading = true;
this.driverFields = {};
this.fields = ['mail_driver', 'mail_from'];
this.values = {};
this.status = { sending: false, errors: {} };
const settings = app.data.settings;
this.fields.forEach((key) => (this.values[key] = m.prop(settings[key])));
app
.request({
method: 'GET',
url: app.forum.attribute('apiUrl') + '/mail-settings',
})
.then((response) => {
this.driverFields = response['data']['attributes']['fields'];
this.status.sending = response['data']['attributes']['sending'];
this.status.errors = response['data']['attributes']['errors'];
for (const driver in this.driverFields) {
for (const field in this.driverFields[driver]) {
this.fields.push(field);
this.values[field] = m.prop(settings[field]);
}
}
this.loading = false;
m.redraw();
});
}
view() {
if (this.loading || this.saving) {
return (
<div className="MailPage">
<div className="container">
<LoadingIndicator />
</div>
</div>
);
}
const fields = this.driverFields[this.values.mail_driver()];
const fieldKeys = Object.keys(fields);
return (
<div className="MailPage">
<div className="container">
<form onsubmit={this.onsubmit.bind(this)}>
<h2>{app.translator.trans('core.admin.email.heading')}</h2>
<div className="helpText">{app.translator.trans('core.admin.email.text')}</div>
{FieldSet.component({
label: app.translator.trans('core.admin.email.addresses_heading'),
className: 'MailPage-MailSettings',
children: [
<div className="MailPage-MailSettings-input">
<label>
{app.translator.trans('core.admin.email.from_label')}
<input className="FormControl" value={this.values.mail_from() || ''} oninput={m.withAttr('value', this.values.mail_from)} />
</label>
</div>,
],
})}
{FieldSet.component({
label: app.translator.trans('core.admin.email.driver_heading'),
className: 'MailPage-MailSettings',
children: [
<div className="MailPage-MailSettings-input">
<label>
{app.translator.trans('core.admin.email.driver_label')}
<Select
value={this.values.mail_driver()}
options={Object.keys(this.driverFields).reduce((memo, val) => ({ ...memo, [val]: val }), {})}
onchange={this.values.mail_driver}
/>
</label>
</div>,
],
})}
{this.status.sending ||
Alert.component({
children: app.translator.trans('core.admin.email.not_sending_message'),
dismissible: false,
})}
{fieldKeys.length > 0 &&
FieldSet.component({
label: app.translator.trans(`core.admin.email.${this.values.mail_driver()}_heading`),
className: 'MailPage-MailSettings',
children: [
<div className="MailPage-MailSettings-input">
{fieldKeys.map((field) => [
<label>
{app.translator.trans(`core.admin.email.${field}_label`)}
{this.renderField(field)}
</label>,
this.status.errors[field] && <p className="ValidationError">{this.status.errors[field]}</p>,
])}
</div>,
],
})}
{Button.component({
type: 'submit',
className: 'Button Button--primary',
children: app.translator.trans('core.admin.email.submit_button'),
disabled: !this.changed(),
})}
</form>
</div>
</div>
);
}
renderField(name) {
const driver = this.values.mail_driver();
const field = this.driverFields[driver][name];
const prop = this.values[name];
if (typeof field === 'string') {
return <input className="FormControl" value={prop() || ''} oninput={m.withAttr('value', prop)} />;
} else {
return <Select value={prop()} options={field} onchange={prop} />;
}
}
changed() {
return this.fields.some((key) => this.values[key]() !== app.data.settings[key]);
}
onsubmit(e) {
e.preventDefault();
if (this.saving) return;
this.saving = true;
app.alerts.dismiss(this.successAlert);
const settings = {};
this.fields.forEach((key) => (settings[key] = this.values[key]()));
saveSettings(settings)
.then(() => {
app.alerts.show((this.successAlert = new Alert({ type: 'success', children: app.translator.trans('core.admin.basics.saved_message') })));
})
.catch(() => {})
.then(() => {
this.saving = false;
this.refresh();
});
}
}

View File

@@ -1,32 +0,0 @@
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

@@ -1,148 +0,0 @@
import Dropdown from '../../common/components/Dropdown';
import Button from '../../common/components/Button';
import Separator from '../../common/components/Separator';
import Group from '../../common/models/Group';
import Badge from '../../common/components/Badge';
import GroupBadge from '../../common/components/GroupBadge';
function badgeForId(id) {
const group = app.store.getById('groups', id);
return group ? GroupBadge.component({ group, label: null }) : '';
}
function filterByRequiredPermissions(groupIds, permission) {
app.getRequiredPermissions(permission).forEach((required) => {
const restrictToGroupIds = app.data.permissions[required] || [];
if (restrictToGroupIds.indexOf(Group.GUEST_ID) !== -1) {
// do nothing
} else if (restrictToGroupIds.indexOf(Group.MEMBER_ID) !== -1) {
groupIds = groupIds.filter((id) => id !== Group.GUEST_ID);
} else if (groupIds.indexOf(Group.MEMBER_ID) !== -1) {
groupIds = restrictToGroupIds;
} else {
groupIds = restrictToGroupIds.filter((id) => groupIds.indexOf(id) !== -1);
}
groupIds = filterByRequiredPermissions(groupIds, required);
});
return groupIds;
}
export default class PermissionDropdown extends Dropdown {
static initProps(props) {
super.initProps(props);
props.className = 'PermissionDropdown';
props.buttonClassName = 'Button Button--text';
}
view() {
this.props.children = [];
let groupIds = app.data.permissions[this.props.permission] || [];
groupIds = filterByRequiredPermissions(groupIds, this.props.permission);
const everyone = groupIds.indexOf(Group.GUEST_ID) !== -1;
const members = groupIds.indexOf(Group.MEMBER_ID) !== -1;
const adminGroup = app.store.getById('groups', Group.ADMINISTRATOR_ID);
if (everyone) {
this.props.label = Badge.component({ icon: 'fas fa-globe' });
} else if (members) {
this.props.label = Badge.component({ icon: 'fas fa-user' });
} else {
this.props.label = [badgeForId(Group.ADMINISTRATOR_ID), groupIds.map(badgeForId)];
}
if (this.showing) {
if (this.props.allowGuest) {
this.props.children.push(
Button.component({
children: [Badge.component({ icon: 'fas fa-globe' }), ' ', app.translator.trans('core.admin.permissions_controls.everyone_button')],
icon: everyone ? 'fas fa-check' : true,
onclick: () => this.save([Group.GUEST_ID]),
disabled: this.isGroupDisabled(Group.GUEST_ID),
})
);
}
this.props.children.push(
Button.component({
children: [Badge.component({ icon: 'fas fa-user' }), ' ', app.translator.trans('core.admin.permissions_controls.members_button')],
icon: members ? 'fas fa-check' : true,
onclick: () => this.save([Group.MEMBER_ID]),
disabled: this.isGroupDisabled(Group.MEMBER_ID),
}),
Separator.component(),
Button.component({
children: [badgeForId(adminGroup.id()), ' ', adminGroup.namePlural()],
icon: !everyone && !members ? 'fas fa-check' : true,
disabled: !everyone && !members,
onclick: (e) => {
if (e.shiftKey) e.stopPropagation();
this.save([]);
},
})
);
[].push.apply(
this.props.children,
app.store
.all('groups')
.filter((group) => [Group.ADMINISTRATOR_ID, Group.GUEST_ID, Group.MEMBER_ID].indexOf(group.id()) === -1)
.map((group) =>
Button.component({
children: [badgeForId(group.id()), ' ', group.namePlural()],
icon: groupIds.indexOf(group.id()) !== -1 ? 'fas fa-check' : true,
onclick: (e) => {
if (e.shiftKey) e.stopPropagation();
this.toggle(group.id());
},
disabled: this.isGroupDisabled(group.id()) && this.isGroupDisabled(Group.MEMBER_ID) && this.isGroupDisabled(Group.GUEST_ID),
})
)
);
}
return super.view();
}
save(groupIds) {
const permission = this.props.permission;
app.data.permissions[permission] = groupIds;
app.request({
method: 'POST',
url: app.forum.attribute('apiUrl') + '/permission',
data: { permission, groupIds },
});
}
toggle(groupId) {
const permission = this.props.permission;
let groupIds = app.data.permissions[permission] || [];
const index = groupIds.indexOf(groupId);
if (index !== -1) {
groupIds.splice(index, 1);
} else {
groupIds.push(groupId);
groupIds = groupIds.filter((id) => [Group.GUEST_ID, Group.MEMBER_ID].indexOf(id) === -1);
}
this.save(groupIds);
}
isGroupDisabled(id) {
return filterByRequiredPermissions([id], this.props.permission).indexOf(id) === -1;
}
}

View File

@@ -1,357 +0,0 @@
import Component from '../../common/Component';
import PermissionDropdown from './PermissionDropdown';
import SettingDropdown from './SettingDropdown';
import Button from '../../common/components/Button';
import ItemList from '../../common/utils/ItemList';
import icon from '../../common/helpers/icon';
export default class PermissionGrid extends Component {
init() {
this.permissions = this.permissionItems().toArray();
}
view() {
const scopes = this.scopeItems().toArray();
const permissionCells = (permission) => {
return scopes.map((scope) => <td>{scope.render(permission)}</td>);
};
return (
<table className="PermissionGrid">
<thead>
<tr>
<td></td>
{scopes.map((scope) => (
<th>
{scope.label}{' '}
{scope.onremove
? Button.component({ icon: 'fas fa-times', className: 'Button Button--text PermissionGrid-removeScope', onclick: scope.onremove })
: ''}
</th>
))}
<th>{this.scopeControlItems().toArray()}</th>
</tr>
</thead>
{this.permissions.map((section) => (
<tbody>
<tr className="PermissionGrid-section">
<th>{section.label}</th>
{permissionCells(section)}
<td />
</tr>
{section.children.map((child) => (
<tr className="PermissionGrid-child">
<th>
{icon(child.icon)}
{child.label}
</th>
{permissionCells(child)}
<td />
</tr>
))}
</tbody>
))}
</table>
);
}
permissionItems() {
const items = new ItemList();
items.add(
'view',
{
label: app.translator.trans('core.admin.permissions.read_heading'),
children: this.viewItems().toArray(),
},
100
);
items.add(
'start',
{
label: app.translator.trans('core.admin.permissions.create_heading'),
children: this.startItems().toArray(),
},
90
);
items.add(
'reply',
{
label: app.translator.trans('core.admin.permissions.participate_heading'),
children: this.replyItems().toArray(),
},
80
);
items.add(
'moderate',
{
label: app.translator.trans('core.admin.permissions.moderate_heading'),
children: this.moderateItems().toArray(),
},
70
);
return items;
}
viewItems() {
const items = new ItemList();
items.add(
'viewDiscussions',
{
icon: 'fas fa-eye',
label: app.translator.trans('core.admin.permissions.view_discussions_label'),
permission: 'viewDiscussions',
allowGuest: true,
},
100
);
items.add(
'viewUserList',
{
icon: 'fas fa-users',
label: app.translator.trans('core.admin.permissions.view_user_list_label'),
permission: 'viewUserList',
allowGuest: true,
},
100
);
items.add(
'signUp',
{
icon: 'fas fa-user-plus',
label: app.translator.trans('core.admin.permissions.sign_up_label'),
setting: () =>
SettingDropdown.component({
key: 'allow_sign_up',
options: [
{ value: '1', label: app.translator.trans('core.admin.permissions_controls.signup_open_button') },
{ value: '0', label: app.translator.trans('core.admin.permissions_controls.signup_closed_button') },
],
}),
},
90
);
items.add('viewLastSeenAt', {
icon: 'far fa-clock',
label: app.translator.trans('core.admin.permissions.view_last_seen_at_label'),
permission: 'user.viewLastSeenAt',
});
return items;
}
startItems() {
const items = new ItemList();
items.add(
'start',
{
icon: 'fas fa-edit',
label: app.translator.trans('core.admin.permissions.start_discussions_label'),
permission: 'startDiscussion',
},
100
);
items.add(
'allowRenaming',
{
icon: 'fas fa-i-cursor',
label: app.translator.trans('core.admin.permissions.allow_renaming_label'),
setting: () => {
const minutes = parseInt(app.data.settings.allow_renaming, 10);
return SettingDropdown.component({
defaultLabel: minutes
? app.translator.transChoice('core.admin.permissions_controls.allow_some_minutes_button', minutes, { count: minutes })
: app.translator.trans('core.admin.permissions_controls.allow_indefinitely_button'),
key: 'allow_renaming',
options: [
{ value: '-1', label: app.translator.trans('core.admin.permissions_controls.allow_indefinitely_button') },
{ value: '10', label: app.translator.trans('core.admin.permissions_controls.allow_ten_minutes_button') },
{ value: 'reply', label: app.translator.trans('core.admin.permissions_controls.allow_until_reply_button') },
],
});
},
},
90
);
return items;
}
replyItems() {
const items = new ItemList();
items.add(
'reply',
{
icon: 'fas fa-reply',
label: app.translator.trans('core.admin.permissions.reply_to_discussions_label'),
permission: 'discussion.reply',
},
100
);
items.add(
'allowPostEditing',
{
icon: 'fas fa-pencil-alt',
label: app.translator.trans('core.admin.permissions.allow_post_editing_label'),
setting: () => {
const minutes = parseInt(app.data.settings.allow_post_editing, 10);
return SettingDropdown.component({
defaultLabel: minutes
? app.translator.transChoice('core.admin.permissions_controls.allow_some_minutes_button', minutes, { count: minutes })
: app.translator.trans('core.admin.permissions_controls.allow_indefinitely_button'),
key: 'allow_post_editing',
options: [
{ value: '-1', label: app.translator.trans('core.admin.permissions_controls.allow_indefinitely_button') },
{ value: '10', label: app.translator.trans('core.admin.permissions_controls.allow_ten_minutes_button') },
{ value: 'reply', label: app.translator.trans('core.admin.permissions_controls.allow_until_reply_button') },
],
});
},
},
90
);
return items;
}
moderateItems() {
const items = new ItemList();
items.add(
'viewIpsPosts',
{
icon: 'fas fa-bullseye',
label: app.translator.trans('core.admin.permissions.view_post_ips_label'),
permission: 'discussion.viewIpsPosts',
},
110
);
items.add(
'renameDiscussions',
{
icon: 'fas fa-i-cursor',
label: app.translator.trans('core.admin.permissions.rename_discussions_label'),
permission: 'discussion.rename',
},
100
);
items.add(
'hideDiscussions',
{
icon: 'far fa-trash-alt',
label: app.translator.trans('core.admin.permissions.delete_discussions_label'),
permission: 'discussion.hide',
},
90
);
items.add(
'deleteDiscussions',
{
icon: 'fas fa-times',
label: app.translator.trans('core.admin.permissions.delete_discussions_forever_label'),
permission: 'discussion.delete',
},
80
);
items.add(
'postWithoutThrottle',
{
icon: 'fas fa-swimmer',
label: app.translator.trans('core.admin.permissions.post_without_throttle_label'),
permission: 'postWithoutThrottle',
},
70
);
items.add(
'editPosts',
{
icon: 'fas fa-pencil-alt',
label: app.translator.trans('core.admin.permissions.edit_posts_label'),
permission: 'discussion.editPosts',
},
70
);
items.add(
'hidePosts',
{
icon: 'far fa-trash-alt',
label: app.translator.trans('core.admin.permissions.delete_posts_label'),
permission: 'discussion.hidePosts',
},
60
);
items.add(
'deletePosts',
{
icon: 'fas fa-times',
label: app.translator.trans('core.admin.permissions.delete_posts_forever_label'),
permission: 'discussion.deletePosts',
},
60
);
items.add(
'userEdit',
{
icon: 'fas fa-user-cog',
label: app.translator.trans('core.admin.permissions.edit_users_label'),
permission: 'user.edit',
},
60
);
return items;
}
scopeItems() {
const items = new ItemList();
items.add(
'global',
{
label: app.translator.trans('core.admin.permissions.global_heading'),
render: (item) => {
if (item.setting) {
return item.setting();
} else if (item.permission) {
return PermissionDropdown.component({
permission: item.permission,
allowGuest: item.allowGuest,
});
}
return '';
},
},
100
);
return items;
}
scopeControlItems() {
return new ItemList();
}
}

View File

@@ -1,40 +0,0 @@
import Page from './Page';
import GroupBadge from '../../common/components/GroupBadge';
import EditGroupModal from './EditGroupModal';
import Group from '../../common/models/Group';
import icon from '../../common/helpers/icon';
import PermissionGrid from './PermissionGrid';
export default class PermissionsPage extends Page {
view() {
return (
<div className="PermissionsPage">
<div className="PermissionsPage-groups">
<div className="container">
{app.store
.all('groups')
.filter((group) => [Group.GUEST_ID, Group.MEMBER_ID].indexOf(group.id()) === -1)
.map((group) => (
<button className="Button Group" onclick={() => app.modal.show(new 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(new EditGroupModal())}>
{icon('fas fa-plus', { className: 'Group-icon' })}
<span className="Group-name">{app.translator.trans('core.admin.permissions.new_group_button')}</span>
</button>
</div>
</div>
<div className="PermissionsPage-permissions">
<div className="container">{PermissionGrid.component()}</div>
</div>
</div>
);
}
}

View File

@@ -1,52 +0,0 @@
import avatar from '../../common/helpers/avatar';
import username from '../../common/helpers/username';
import Dropdown from '../../common/components/Dropdown';
import Button from '../../common/components/Button';
import ItemList from '../../common/utils/ItemList';
/**
* The `SessionDropdown` component shows a button with the current user's
* avatar/name, with a dropdown of session controls.
*/
export default class SessionDropdown extends Dropdown {
static 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

@@ -1,25 +0,0 @@
import SelectDropdown from '../../common/components/SelectDropdown';
import Button from '../../common/components/Button';
import saveSettings from '../utils/saveSettings';
export default class SettingDropdown extends SelectDropdown {
static 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

@@ -1,70 +0,0 @@
import Modal from '../../common/components/Modal';
import Button from '../../common/components/Button';
import saveSettings from '../utils/saveSettings';
export default class SettingsModal extends Modal {
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

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

View File

@@ -1,101 +0,0 @@
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

@@ -1,34 +0,0 @@
/*
* This file is part of Flarum.
*
* (c) Toby Zerner <toby.zerner@gmail.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
import Component from '../../common/Component';
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

@@ -1,18 +0,0 @@
import AdminApplication from './AdminApplication';
const app = new AdminApplication();
// Backwards compatibility
window.app = app;
export { app };
// Export public API
// Export compat API
import compat from './compat';
compat.app = app;
export { compat };

View File

@@ -1,22 +0,0 @@
import DashboardPage from './components/DashboardPage';
import BasicsPage from './components/BasicsPage';
import PermissionsPage from './components/PermissionsPage';
import AppearancePage from './components/AppearancePage';
import ExtensionsPage from './components/ExtensionsPage';
import MailPage from './components/MailPage';
/**
* The `routes` initializer defines the forum app's routes.
*
* @param {App} app
*/
export default function (app) {
app.routes = {
dashboard: { path: '/', component: DashboardPage.component() },
basics: { path: '/basics', component: BasicsPage.component() },
permissions: { path: '/permissions', component: PermissionsPage.component() },
appearance: { path: '/appearance', component: AppearancePage.component() },
extensions: { path: '/extensions', component: ExtensionsPage.component() },
mail: { path: '/mail', component: MailPage.component() },
};
}

View File

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

View File

@@ -1,376 +0,0 @@
import ItemList from './utils/ItemList';
import Alert from './components/Alert';
import Button from './components/Button';
import ModalManager from './components/ModalManager';
import AlertManager from './components/AlertManager';
import RequestErrorModal from './components/RequestErrorModal';
import Translator from './Translator';
import Store from './Store';
import Session from './Session';
import extract from './utils/extract';
import Drawer from './utils/Drawer';
import mapRoutes from './utils/mapRoutes';
import RequestError from './utils/RequestError';
import ScrollListener from './utils/ScrollListener';
import { extend } from './extend';
import Forum from './models/Forum';
import User from './models/User';
import Discussion from './models/Discussion';
import Post from './models/Post';
import Group from './models/Group';
import Notification from './models/Notification';
import { flattenDeep } from 'lodash-es';
/**
* The `App` class provides a container for an application, as well as various
* utilities for the rest of the app to use.
*/
export default class Application {
/**
* The forum model for this application.
*
* @type {Forum}
* @public
*/
forum = null;
/**
* A map of routes, keyed by a unique route name. Each route is an object
* containing the following properties:
*
* - `path` The path that the route is accessed at.
* - `component` The Mithril component to render when this route is active.
*
* @example
* app.routes.discussion = {path: '/d/:id', component: DiscussionPage.component()};
*
* @type {Object}
* @public
*/
routes = {};
/**
* An ordered list of initializers to bootstrap the application.
*
* @type {ItemList}
* @public
*/
initializers = new ItemList();
/**
* The app's session.
*
* @type {Session}
* @public
*/
session = null;
/**
* The app's translator.
*
* @type {Translator}
* @public
*/
translator = new Translator();
/**
* The app's data store.
*
* @type {Store}
* @public
*/
store = new Store({
forums: Forum,
users: User,
discussions: Discussion,
posts: Post,
groups: Group,
notifications: Notification,
});
/**
* A local cache that can be used to store data at the application level, so
* that is persists between different routes.
*
* @type {Object}
* @public
*/
cache = {};
/**
* Whether or not the app has been booted.
*
* @type {Boolean}
* @public
*/
booted = false;
/**
* An Alert that was shown as a result of an AJAX request error. If present,
* it will be dismissed on the next successful request.
*
* @type {null|Alert}
* @private
*/
requestError = null;
data;
title = '';
titleCount = 0;
load(payload) {
this.data = payload;
this.translator.locale = payload.locale;
}
boot() {
this.initializers.toArray().forEach((initializer) => initializer(this));
this.store.pushPayload({ data: this.data.resources });
this.forum = this.store.getById('forums', 1);
this.session = new Session(this.store.getById('users', this.data.session.userId), this.data.session.csrfToken);
this.mount();
}
bootExtensions(extensions) {
Object.keys(extensions).forEach((name) => {
const extension = extensions[name];
const extenders = flattenDeep(extension.extend);
for (const extender of extenders) {
extender.extend(this, { name, exports: extension });
}
});
}
mount(basePath = '') {
this.modal = m.mount(document.getElementById('modal'), <ModalManager />);
this.alerts = m.mount(document.getElementById('alerts'), <AlertManager />);
this.drawer = new Drawer();
m.route(document.getElementById('content'), basePath + '/', mapRoutes(this.routes, basePath));
// Add a class to the body which indicates that the page has been scrolled
// down.
new ScrollListener((top) => {
const $app = $('#app');
const offset = $app.offset().top;
$app.toggleClass('affix', top >= offset).toggleClass('scrolled', top > offset);
}).start();
$(() => {
$('body').addClass('ontouchstart' in window ? 'touch' : 'no-touch');
});
}
/**
* Get the API response document that has been preloaded into the application.
*
* @return {Object|null}
* @public
*/
preloadedApiDocument() {
if (this.data.apiDocument) {
const results = this.store.pushPayload(this.data.apiDocument);
this.data.apiDocument = null;
return results;
}
return null;
}
/**
* Set the <title> of the page.
*
* @param {String} title
* @public
*/
setTitle(title) {
this.title = title;
this.updateTitle();
}
/**
* Set a number to display in the <title> of the page.
*
* @param {Integer} count
*/
setTitleCount(count) {
this.titleCount = count;
this.updateTitle();
}
updateTitle() {
document.title = (this.titleCount ? `(${this.titleCount}) ` : '') + (this.title ? this.title + ' - ' : '') + this.forum.attribute('title');
}
/**
* Make an AJAX request, handling any low-level errors that may occur.
*
* @see https://lhorie.github.io/mithril/mithril.request.html
* @param {Object} options
* @return {Promise}
* @public
*/
request(originalOptions) {
const options = Object.assign({}, originalOptions);
// Set some default options if they haven't been overridden. We want to
// authenticate all requests with the session token. We also want all
// requests to run asynchronously in the background, so that they don't
// prevent redraws from occurring.
options.background = options.background || true;
extend(options, 'config', (result, xhr) => xhr.setRequestHeader('X-CSRF-Token', this.session.csrfToken));
// If the method is something like PATCH or DELETE, which not all servers
// and clients support, then we'll send it as a POST request with the
// intended method specified in the X-HTTP-Method-Override header.
if (options.method !== 'GET' && options.method !== 'POST') {
const method = options.method;
extend(options, 'config', (result, xhr) => xhr.setRequestHeader('X-HTTP-Method-Override', method));
options.method = 'POST';
}
// When we deserialize JSON data, if for some reason the server has provided
// a dud response, we don't want the application to crash. We'll show an
// error message to the user instead.
options.deserialize = options.deserialize || ((responseText) => responseText);
options.errorHandler =
options.errorHandler ||
((error) => {
throw error;
});
// When extracting the data from the response, we can check the server
// response code and show an error message to the user if something's gone
// awry.
const original = options.extract;
options.extract = (xhr) => {
let responseText;
if (original) {
responseText = original(xhr.responseText);
} else {
responseText = xhr.responseText || null;
}
const status = xhr.status;
if (status < 200 || status > 299) {
throw new RequestError(status, responseText, options, xhr);
}
if (xhr.getResponseHeader) {
const csrfToken = xhr.getResponseHeader('X-CSRF-Token');
if (csrfToken) app.session.csrfToken = csrfToken;
}
try {
return JSON.parse(responseText);
} catch (e) {
throw new RequestError(500, responseText, options, xhr);
}
};
if (this.requestError) this.alerts.dismiss(this.requestError.alert);
// Now make the request. If it's a failure, inspect the error that was
// returned and show an alert containing its contents.
const deferred = m.deferred();
m.request(options).then(
(response) => deferred.resolve(response),
(error) => {
this.requestError = error;
let children;
switch (error.status) {
case 422:
children = error.response.errors
.map((error) => [error.detail, <br />])
.reduce((a, b) => a.concat(b), [])
.slice(0, -1);
break;
case 401:
case 403:
children = app.translator.trans('core.lib.error.permission_denied_message');
break;
case 404:
case 410:
children = app.translator.trans('core.lib.error.not_found_message');
break;
case 429:
children = app.translator.trans('core.lib.error.rate_limit_exceeded_message');
break;
default:
children = app.translator.trans('core.lib.error.generic_message');
}
const isDebug = app.forum.attribute('debug');
error.alert = new Alert({
type: 'error',
children,
controls: isDebug && [
<Button className="Button Button--link" onclick={this.showDebug.bind(this, error)}>
Debug
</Button>,
],
});
try {
options.errorHandler(error);
} catch (error) {
this.alerts.show(error.alert);
}
deferred.reject(error);
}
);
return deferred.promise;
}
/**
* @param {RequestError} error
* @private
*/
showDebug(error) {
this.alerts.dismiss(this.requestError.alert);
this.modal.show(new RequestErrorModal({ error }));
}
/**
* Construct a URL to the route with the given name.
*
* @param {String} name
* @param {Object} params
* @return {String}
* @public
*/
route(name, params = {}) {
const url = this.routes[name].path.replace(/:([^\/]+)/g, (m, key) => extract(params, key));
const queryString = m.route.buildQueryString(params);
const prefix = m.route.mode === 'pathname' ? app.forum.attribute('basePath') : '';
return prefix + url + (queryString ? '?' + queryString : '');
}
}

View File

@@ -1,221 +0,0 @@
/*
* This file is part of Flarum.
*
* (c) Toby Zerner <toby.zerner@gmail.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
/**
* The `Component` class defines a user interface 'building block'. A component
* can generate a virtual DOM to be rendered on each redraw.
*
* An instance's virtual DOM can be retrieved directly using the {@link
* Component#render} method.
*
* @example
* this.myComponentInstance = new MyComponent({foo: 'bar'});
* return m('div', this.myComponentInstance.render());
*
* Alternatively, components can be nested, letting Mithril take care of
* instance persistence. For this, the static {@link Component.component} method
* can be used.
*
* @example
* return m('div', MyComponent.component({foo: 'bar'));
*
* @see https://lhorie.github.io/mithril/mithril.component.html
* @abstract
*/
export default class Component {
/**
* @param {Object} props
* @param {Array|Object} children
* @public
*/
constructor(props = {}, children = null) {
if (children) props.children = children;
this.constructor.initProps(props);
/**
* The properties passed into the component.
*
* @type {Object}
*/
this.props = props;
/**
* The root DOM element for the component.
*
* @type DOMElement
* @public
*/
this.element = null;
/**
* Whether or not to retain the component's subtree on redraw.
*
* @type {boolean}
* @public
*/
this.retain = false;
this.init();
}
/**
* Called when the component is constructed.
*
* @protected
*/
init() {}
/**
* Called when the component is destroyed, i.e. after a redraw where it is no
* longer a part of the view.
*
* @see https://lhorie.github.io/mithril/mithril.component.html#unloading-components
* @param {Object} e
* @public
*/
onunload() {}
/**
* Get the renderable virtual DOM that represents the component's view.
*
* This should NOT be overridden by subclasses. Subclasses wishing to define
* their virtual DOM should override Component#view instead.
*
* @example
* this.myComponentInstance = new MyComponent({foo: 'bar'});
* return m('div', this.myComponentInstance.render());
*
* @returns {Object}
* @final
* @public
*/
render() {
const vdom = this.retain ? { subtree: 'retain' } : this.view();
// Override the root element's config attribute with our own function, which
// will set the component instance's element property to the root DOM
// element, and then run the component class' config method.
vdom.attrs = vdom.attrs || {};
const originalConfig = vdom.attrs.config;
vdom.attrs.config = (...args) => {
this.element = args[0];
this.config.apply(this, args.slice(1));
if (originalConfig) originalConfig.apply(this, args);
};
return vdom;
}
/**
* Returns a jQuery object for this component's element. If you pass in a
* selector string, this method will return a jQuery object, using the current
* element as its buffer.
*
* For example, calling `component.$('li')` will return a jQuery object
* containing all of the `li` elements inside the DOM element of this
* component.
*
* @param {String} [selector] a jQuery-compatible selector string
* @returns {jQuery} the jQuery object for the DOM node
* @final
* @public
*/
$(selector) {
const $element = $(this.element);
return selector ? $element.find(selector) : $element;
}
/**
* Called after the component's root element is redrawn. This hook can be used
* to perform any actions on the DOM, both on the initial draw and any
* subsequent redraws. See Mithril's documentation for more information.
*
* @see https://lhorie.github.io/mithril/mithril.html#the-config-attribute
* @param {Boolean} isInitialized
* @param {Object} context
* @param {Object} vdom
* @public
*/
config() {}
/**
* Get the virtual DOM that represents the component's view.
*
* @return {Object} The virtual DOM
* @protected
*/
view() {
throw new Error('Component#view must be implemented by subclass');
}
/**
* Get a Mithril component object for this component, preloaded with props.
*
* @see https://lhorie.github.io/mithril/mithril.component.html
* @param {Object} [props] Properties to set on the component
* @param children
* @return {Object} The Mithril component object
* @property {function} controller
* @property {function} view
* @property {Object} component The class of this component
* @property {Object} props The props that were passed to the component
* @public
*/
static component(props = {}, children = null) {
const componentProps = Object.assign({}, props);
if (children) componentProps.children = children;
this.initProps(componentProps);
// Set up a function for Mithril to get the component's view. It will accept
// the component's controller (which happens to be the component itself, in
// our case), update its props with the ones supplied, and then render the view.
const view = (component) => {
component.props = componentProps;
return component.render();
};
// Mithril uses this property on the view function to cache component
// controllers between redraws, thus persisting component state.
view.$original = this.prototype.view;
// Our output object consists of a controller constructor + a view function
// which Mithril will use to instantiate and render the component. We also
// attach a reference to the props that were passed through and the
// component's class for reference.
const output = {
controller: this.bind(undefined, componentProps),
view: view,
props: componentProps,
component: this,
};
// If a `key` prop was set, then we'll assume that we want that to actually
// show up as an attribute on the component object so that Mithril's key
// algorithm can be applied.
if (componentProps.key) {
output.attrs = { key: componentProps.key };
}
return output;
}
/**
* Initialize the component's props.
*
* @param {Object} props
* @public
*/
static initProps(props) {}
}

View File

@@ -1,319 +0,0 @@
/**
* The `Model` class represents a local data resource. It provides methods to
* persist changes via the API.
*
* @abstract
*/
export default class Model {
/**
* @param {Object} data A resource object from the API.
* @param {Store} store The data store that this model should be persisted to.
* @public
*/
constructor(data = {}, store = null) {
/**
* The resource object from the API.
*
* @type {Object}
* @public
*/
this.data = data;
/**
* The time at which the model's data was last updated. Watching the value
* of this property is a fast way to retain/cache a subtree if data hasn't
* changed.
*
* @type {Date}
* @public
*/
this.freshness = new Date();
/**
* Whether or not the resource exists on the server.
*
* @type {Boolean}
* @public
*/
this.exists = false;
/**
* The data store that this resource should be persisted to.
*
* @type {Store}
* @protected
*/
this.store = store;
}
/**
* Get the model's ID.
*
* @return {Integer}
* @public
* @final
*/
id() {
return this.data.id;
}
/**
* Get one of the model's attributes.
*
* @param {String} attribute
* @return {*}
* @public
* @final
*/
attribute(attribute) {
return this.data.attributes[attribute];
}
/**
* Merge new data into this model locally.
*
* @param {Object} data A resource object to merge into this model
* @public
*/
pushData(data) {
// Since most of the top-level items in a resource object are objects
// (e.g. relationships, attributes), we'll need to check and perform the
// merge at the second level if that's the case.
for (const key in data) {
if (typeof data[key] === 'object') {
this.data[key] = this.data[key] || {};
// For every item in a second-level object, we want to check if we've
// been handed a Model instance. If so, we will convert it to a
// relationship data object.
for (const innerKey in data[key]) {
if (data[key][innerKey] instanceof Model) {
data[key][innerKey] = { data: Model.getIdentifier(data[key][innerKey]) };
}
this.data[key][innerKey] = data[key][innerKey];
}
} else {
this.data[key] = data[key];
}
}
// Now that we've updated the data, we can say that the model is fresh.
// This is an easy way to invalidate retained subtrees etc.
this.freshness = new Date();
}
/**
* Merge new attributes into this model locally.
*
* @param {Object} attributes The attributes to merge.
* @public
*/
pushAttributes(attributes) {
this.pushData({ attributes });
}
/**
* Merge new attributes into this model, both locally and with persistence.
*
* @param {Object} attributes The attributes to save. If a 'relationships' key
* exists, it will be extracted and relationships will also be saved.
* @param {Object} [options]
* @return {Promise}
* @public
*/
save(attributes, options = {}) {
const data = {
type: this.data.type,
id: this.data.id,
attributes,
};
// If a 'relationships' key exists, extract it from the attributes hash and
// set it on the top-level data object instead. We will be sending this data
// object to the API for persistence.
if (attributes.relationships) {
data.relationships = {};
for (const key in attributes.relationships) {
const model = attributes.relationships[key];
data.relationships[key] = {
data: model instanceof Array ? model.map(Model.getIdentifier) : Model.getIdentifier(model),
};
}
delete attributes.relationships;
}
// Before we update the model's data, we should make a copy of the model's
// old data so that we can revert back to it if something goes awry during
// persistence.
const oldData = this.copyData();
this.pushData(data);
const request = { data };
if (options.meta) request.meta = options.meta;
return app
.request(
Object.assign(
{
method: this.exists ? 'PATCH' : 'POST',
url: app.forum.attribute('apiUrl') + this.apiEndpoint(),
data: request,
},
options
)
)
.then(
// If everything went well, we'll make sure the store knows that this
// model exists now (if it didn't already), and we'll push the data that
// the API returned into the store.
(payload) => {
this.store.data[payload.data.type] = this.store.data[payload.data.type] || {};
this.store.data[payload.data.type][payload.data.id] = this;
return this.store.pushPayload(payload);
},
// If something went wrong, though... good thing we backed up our model's
// old data! We'll revert to that and let others handle the error.
(response) => {
this.pushData(oldData);
m.lazyRedraw();
throw response;
}
);
}
/**
* Send a request to delete the resource.
*
* @param {Object} data Data to send along with the DELETE request.
* @param {Object} [options]
* @return {Promise}
* @public
*/
delete(data, options = {}) {
if (!this.exists) return m.deferred().resolve().promise;
return app
.request(
Object.assign(
{
method: 'DELETE',
url: app.forum.attribute('apiUrl') + this.apiEndpoint(),
data,
},
options
)
)
.then(() => {
this.exists = false;
this.store.remove(this);
});
}
/**
* Construct a path to the API endpoint for this resource.
*
* @return {String}
* @protected
*/
apiEndpoint() {
return '/' + this.data.type + (this.exists ? '/' + this.data.id : '');
}
copyData() {
return JSON.parse(JSON.stringify(this.data));
}
/**
* Generate a function which returns the value of the given attribute.
*
* @param {String} name
* @param {function} [transform] A function to transform the attribute value
* @return {*}
* @public
*/
static attribute(name, transform) {
return function () {
const value = this.data.attributes && this.data.attributes[name];
return transform ? transform(value) : value;
};
}
/**
* Generate a function which returns the value of the given has-one
* relationship.
*
* @param {String} name
* @return {Model|Boolean|undefined} false if no information about the
* relationship exists; undefined if the relationship exists but the model
* has not been loaded; or the model if it has been loaded.
* @public
*/
static hasOne(name) {
return function () {
if (this.data.relationships) {
const relationship = this.data.relationships[name];
if (relationship) {
return app.store.getById(relationship.data.type, relationship.data.id);
}
}
return false;
};
}
/**
* Generate a function which returns the value of the given has-many
* relationship.
*
* @param {String} name
* @return {Array|Boolean} false if no information about the relationship
* exists; an array if it does, containing models if they have been
* loaded, and undefined for those that have not.
* @public
*/
static hasMany(name) {
return function () {
if (this.data.relationships) {
const relationship = this.data.relationships[name];
if (relationship) {
return relationship.data.map((data) => app.store.getById(data.type, data.id));
}
}
return false;
};
}
/**
* Transform the given value into a Date object.
*
* @param {String} value
* @return {Date|null}
* @public
*/
static transformDate(value) {
return value ? new Date(value) : null;
}
/**
* Get a resource identifier object for the given model.
*
* @param {Model} model
* @return {Object}
* @protected
*/
static getIdentifier(model) {
return {
type: model.data.type,
id: model.data.id,
};
}
}

View File

@@ -1,54 +0,0 @@
/**
* The `Session` class defines the current user session. It stores a reference
* to the current authenticated user, and provides methods to log in/out.
*/
export default class Session {
constructor(user, csrfToken) {
/**
* The current authenticated user.
*
* @type {User|null}
* @public
*/
this.user = user;
/**
* The CSRF token.
*
* @type {String|null}
* @public
*/
this.csrfToken = csrfToken;
}
/**
* Attempt to log in a user.
*
* @param {String} identification The username/email.
* @param {String} password
* @param {Object} [options]
* @return {Promise}
* @public
*/
login(data, options = {}) {
return app.request(
Object.assign(
{
method: 'POST',
url: app.forum.attribute('baseUrl') + '/login',
data,
},
options
)
);
}
/**
* Log the user out.
*
* @public
*/
logout() {
window.location = app.forum.attribute('baseUrl') + '/logout?token=' + this.csrfToken;
}
}

View File

@@ -1,170 +0,0 @@
/**
* The `Store` class defines a local data store, and provides methods to
* retrieve data from the API.
*/
export default class Store {
constructor(models) {
/**
* The local data store. A tree of resource types to IDs, such that
* accessing data[type][id] will return the model for that type/ID.
*
* @type {Object}
* @protected
*/
this.data = {};
/**
* The model registry. A map of resource types to the model class that
* should be used to represent resources of that type.
*
* @type {Object}
* @public
*/
this.models = models;
}
/**
* Push resources contained within an API payload into the store.
*
* @param {Object} payload
* @return {Model|Model[]} The model(s) representing the resource(s) contained
* within the 'data' key of the payload.
* @public
*/
pushPayload(payload) {
if (payload.included) payload.included.map(this.pushObject.bind(this));
const result = payload.data instanceof Array ? payload.data.map(this.pushObject.bind(this)) : this.pushObject(payload.data);
// Attach the original payload to the model that we give back. This is
// useful to consumers as it allows them to access meta information
// associated with their request.
result.payload = payload;
return result;
}
/**
* Create a model to represent a resource object (or update an existing one),
* and push it into the store.
*
* @param {Object} data The resource object
* @return {Model|null} The model, or null if no model class has been
* registered for this resource type.
* @public
*/
pushObject(data) {
if (!this.models[data.type]) return null;
const type = (this.data[data.type] = this.data[data.type] || {});
if (type[data.id]) {
type[data.id].pushData(data);
} else {
type[data.id] = this.createRecord(data.type, data);
}
type[data.id].exists = true;
return type[data.id];
}
/**
* Make a request to the API to find record(s) of a specific type.
*
* @param {String} type The resource type.
* @param {Integer|Integer[]|Object} [id] The ID(s) of the model(s) to retrieve.
* Alternatively, if an object is passed, it will be handled as the
* `query` parameter.
* @param {Object} [query]
* @param {Object} [options]
* @return {Promise}
* @public
*/
find(type, id, query = {}, options = {}) {
let data = query;
let url = app.forum.attribute('apiUrl') + '/' + type;
if (id instanceof Array) {
url += '?filter[id]=' + id.join(',');
} else if (typeof id === 'object') {
data = id;
} else if (id) {
url += '/' + id;
}
return app
.request(
Object.assign(
{
method: 'GET',
url,
data,
},
options
)
)
.then(this.pushPayload.bind(this));
}
/**
* Get a record from the store by ID.
*
* @param {String} type The resource type.
* @param {Integer} id The resource ID.
* @return {Model}
* @public
*/
getById(type, id) {
return this.data[type] && this.data[type][id];
}
/**
* Get a record from the store by the value of a model attribute.
*
* @param {String} type The resource type.
* @param {String} key The name of the method on the model.
* @param {*} value The value of the model attribute.
* @return {Model}
* @public
*/
getBy(type, key, value) {
return this.all(type).filter((model) => model[key]() === value)[0];
}
/**
* Get all loaded records of a specific type.
*
* @param {String} type
* @return {Model[]}
* @public
*/
all(type) {
const records = this.data[type];
return records ? Object.keys(records).map((id) => records[id]) : [];
}
/**
* Remove the given model from the store.
*
* @param {Model} model
*/
remove(model) {
delete this.data[model.data.type][model.id()];
}
/**
* Create a new record of the given type.
*
* @param {String} type The resource type
* @param {Object} [data] Any data to initialize the model with
* @return {Model}
* @public
*/
createRecord(type, data = {}) {
data.type = data.type || type;
return new this.models[type](data, this);
}
}

View File

@@ -1,290 +0,0 @@
import User from './models/User';
import username from './helpers/username';
import extract from './utils/extract';
/**
* Translator with the same API as Symfony's.
*
* Derived from https://github.com/willdurand/BazingaJsTranslationBundle
* which is available under the MIT License.
* Copyright (c) William Durand <william.durand1@gmail.com>
*/
export default class Translator {
constructor() {
/**
* A map of translation keys to their translated values.
*
* @type {Object}
* @public
*/
this.translations = {};
this.locale = null;
}
addTranslations(translations) {
Object.assign(this.translations, translations);
}
trans(id, parameters) {
const translation = this.translations[id];
if (translation) {
return this.apply(translation, parameters || {});
}
return id;
}
transChoice(id, number, parameters) {
let translation = this.translations[id];
if (translation) {
number = parseInt(number, 10);
translation = this.pluralize(translation, number);
return this.apply(translation, parameters || {});
}
return id;
}
apply(translation, input) {
// If we've been given a user model as one of the input parameters, then
// we'll extract the username and use that for the translation. In the
// future there should be a hook here to inspect the user and change the
// translation key. This will allow a gender property to determine which
// translation key is used.
if ('user' in input) {
const user = extract(input, 'user');
if (!input.username) input.username = username(user);
}
translation = translation.split(new RegExp('({[a-z0-9_]+}|</?[a-z0-9_]+>)', 'gi'));
const hydrated = [];
const open = [hydrated];
translation.forEach((part) => {
const match = part.match(new RegExp('{([a-z0-9_]+)}|<(/?)([a-z0-9_]+)>', 'i'));
if (match) {
if (match[1]) {
open[0].push(input[match[1]]);
} else if (match[3]) {
if (match[2]) {
open.shift();
} else {
let tag = input[match[3]] || { tag: match[3], children: [] };
open[0].push(tag);
open.unshift(tag.children || tag);
}
}
} else {
open[0].push(part);
}
});
return hydrated.filter((part) => part);
}
pluralize(translation, number) {
const sPluralRegex = new RegExp(/^\w+\: +(.+)$/),
cPluralRegex = new RegExp(/^\s*((\{\s*(\-?\d+[\s*,\s*\-?\d+]*)\s*\})|([\[\]])\s*(-Inf|\-?\d+)\s*,\s*(\+?Inf|\-?\d+)\s*([\[\]]))\s?(.+?)$/),
iPluralRegex = new RegExp(/^\s*(\{\s*(\-?\d+[\s*,\s*\-?\d+]*)\s*\})|([\[\]])\s*(-Inf|\-?\d+)\s*,\s*(\+?Inf|\-?\d+)\s*([\[\]])/),
standardRules = [],
explicitRules = [];
translation.split('|').forEach((part) => {
if (cPluralRegex.test(part)) {
const matches = part.match(cPluralRegex);
explicitRules[matches[0]] = matches[matches.length - 1];
} else if (sPluralRegex.test(part)) {
const matches = part.match(sPluralRegex);
standardRules.push(matches[1]);
} else {
standardRules.push(part);
}
});
explicitRules.forEach((rule, e) => {
if (iPluralRegex.test(e)) {
const matches = e.match(iPluralRegex);
if (matches[1]) {
const ns = matches[2].split(',');
for (let n in ns) {
if (number == ns[n]) {
return explicitRules[e];
}
}
} else {
var leftNumber = this.convertNumber(matches[4]);
var rightNumber = this.convertNumber(matches[5]);
if (
('[' === matches[3] ? number >= leftNumber : number > leftNumber) &&
(']' === matches[6] ? number <= rightNumber : number < rightNumber)
) {
return explicitRules[e];
}
}
}
});
return standardRules[this.pluralPosition(number, this.locale)] || standardRules[0] || undefined;
}
convertNumber(number) {
if ('-Inf' === number) {
return Number.NEGATIVE_INFINITY;
} else if ('+Inf' === number || 'Inf' === number) {
return Number.POSITIVE_INFINITY;
}
return parseInt(number, 10);
}
pluralPosition(number, locale) {
if ('pt_BR' === locale) {
locale = 'xbr';
}
if (locale.length > 3) {
locale = locale.split('_')[0];
}
switch (locale) {
case 'bo':
case 'dz':
case 'id':
case 'ja':
case 'jv':
case 'ka':
case 'km':
case 'kn':
case 'ko':
case 'ms':
case 'th':
case 'vi':
case 'zh':
return 0;
case 'af':
case 'az':
case 'bn':
case 'bg':
case 'ca':
case 'da':
case 'de':
case 'el':
case 'en':
case 'eo':
case 'es':
case 'et':
case 'eu':
case 'fa':
case 'fi':
case 'fo':
case 'fur':
case 'fy':
case 'gl':
case 'gu':
case 'ha':
case 'he':
case 'hu':
case 'is':
case 'it':
case 'ku':
case 'lb':
case 'ml':
case 'mn':
case 'mr':
case 'nah':
case 'nb':
case 'ne':
case 'nl':
case 'nn':
case 'no':
case 'om':
case 'or':
case 'pa':
case 'pap':
case 'ps':
case 'pt':
case 'so':
case 'sq':
case 'sv':
case 'sw':
case 'ta':
case 'te':
case 'tk':
case 'tr':
case 'ur':
case 'zu':
return number == 1 ? 0 : 1;
case 'am':
case 'bh':
case 'fil':
case 'fr':
case 'gun':
case 'hi':
case 'ln':
case 'mg':
case 'nso':
case 'xbr':
case 'ti':
case 'wa':
return number === 0 || number == 1 ? 0 : 1;
case 'be':
case 'bs':
case 'hr':
case 'ru':
case 'sr':
case 'uk':
return number % 10 == 1 && number % 100 != 11 ? 0 : number % 10 >= 2 && number % 10 <= 4 && (number % 100 < 10 || number % 100 >= 20) ? 1 : 2;
case 'cs':
case 'sk':
return number == 1 ? 0 : number >= 2 && number <= 4 ? 1 : 2;
case 'ga':
return number == 1 ? 0 : number == 2 ? 1 : 2;
case 'lt':
return number % 10 == 1 && number % 100 != 11 ? 0 : number % 10 >= 2 && (number % 100 < 10 || number % 100 >= 20) ? 1 : 2;
case 'sl':
return number % 100 == 1 ? 0 : number % 100 == 2 ? 1 : number % 100 == 3 || number % 100 == 4 ? 2 : 3;
case 'mk':
return number % 10 == 1 ? 0 : 1;
case 'mt':
return number == 1 ? 0 : number === 0 || (number % 100 > 1 && number % 100 < 11) ? 1 : number % 100 > 10 && number % 100 < 20 ? 2 : 3;
case 'lv':
return number === 0 ? 0 : number % 10 == 1 && number % 100 != 11 ? 1 : 2;
case 'pl':
return number == 1 ? 0 : number % 10 >= 2 && number % 10 <= 4 && (number % 100 < 12 || number % 100 > 14) ? 1 : 2;
case 'cy':
return number == 1 ? 0 : number == 2 ? 1 : number == 8 || number == 11 ? 2 : 3;
case 'ro':
return number == 1 ? 0 : number === 0 || (number % 100 > 0 && number % 100 < 20) ? 1 : 2;
case 'ar':
return number === 0 ? 0 : number == 1 ? 1 : number == 2 ? 2 : number >= 3 && number <= 10 ? 3 : number >= 11 && number <= 99 ? 4 : 5;
default:
return 0;
}
}
}

View File

@@ -1,127 +0,0 @@
import * as extend from './extend';
import Session from './Session';
import Store from './Store';
import evented from './utils/evented';
import liveHumanTimes from './utils/liveHumanTimes';
import ItemList from './utils/ItemList';
import mixin from './utils/mixin';
import humanTime from './utils/humanTime';
import computed from './utils/computed';
import Drawer from './utils/Drawer';
import anchorScroll from './utils/anchorScroll';
import RequestError from './utils/RequestError';
import abbreviateNumber from './utils/abbreviateNumber';
import * as string from './utils/string';
import SubtreeRetainer from './utils/SubtreeRetainer';
import extract from './utils/extract';
import ScrollListener from './utils/ScrollListener';
import stringToColor from './utils/stringToColor';
import patchMithril from './utils/patchMithril';
import classList from './utils/classList';
import extractText from './utils/extractText';
import formatNumber from './utils/formatNumber';
import mapRoutes from './utils/mapRoutes';
import Notification from './models/Notification';
import User from './models/User';
import Post from './models/Post';
import Discussion from './models/Discussion';
import Group from './models/Group';
import Forum from './models/Forum';
import Component from './Component';
import Translator from './Translator';
import AlertManager from './components/AlertManager';
import Switch from './components/Switch';
import Badge from './components/Badge';
import LoadingIndicator from './components/LoadingIndicator';
import Placeholder from './components/Placeholder';
import Separator from './components/Separator';
import Dropdown from './components/Dropdown';
import SplitDropdown from './components/SplitDropdown';
import RequestErrorModal from './components/RequestErrorModal';
import FieldSet from './components/FieldSet';
import Select from './components/Select';
import Navigation from './components/Navigation';
import Alert from './components/Alert';
import LinkButton from './components/LinkButton';
import Checkbox from './components/Checkbox';
import SelectDropdown from './components/SelectDropdown';
import ModalManager from './components/ModalManager';
import Button from './components/Button';
import Modal from './components/Modal';
import GroupBadge from './components/GroupBadge';
import Model from './Model';
import Application from './Application';
import fullTime from './helpers/fullTime';
import avatar from './helpers/avatar';
import icon from './helpers/icon';
import humanTimeHelper from './helpers/humanTime';
import punctuateSeries from './helpers/punctuateSeries';
import highlight from './helpers/highlight';
import username from './helpers/username';
import userOnline from './helpers/userOnline';
import listItems from './helpers/listItems';
export default {
extend: extend,
Session: Session,
Store: Store,
'utils/evented': evented,
'utils/liveHumanTimes': liveHumanTimes,
'utils/ItemList': ItemList,
'utils/mixin': mixin,
'utils/humanTime': humanTime,
'utils/computed': computed,
'utils/Drawer': Drawer,
'utils/anchorScroll': anchorScroll,
'utils/RequestError': RequestError,
'utils/abbreviateNumber': abbreviateNumber,
'utils/string': string,
'utils/SubtreeRetainer': SubtreeRetainer,
'utils/extract': extract,
'utils/ScrollListener': ScrollListener,
'utils/stringToColor': stringToColor,
'utils/patchMithril': patchMithril,
'utils/classList': classList,
'utils/extractText': extractText,
'utils/formatNumber': formatNumber,
'utils/mapRoutes': mapRoutes,
'models/Notification': Notification,
'models/User': User,
'models/Post': Post,
'models/Discussion': Discussion,
'models/Group': Group,
'models/Forum': Forum,
Component: Component,
Translator: Translator,
'components/AlertManager': AlertManager,
'components/Switch': Switch,
'components/Badge': Badge,
'components/LoadingIndicator': LoadingIndicator,
'components/Placeholder': Placeholder,
'components/Separator': Separator,
'components/Dropdown': Dropdown,
'components/SplitDropdown': SplitDropdown,
'components/RequestErrorModal': RequestErrorModal,
'components/FieldSet': FieldSet,
'components/Select': Select,
'components/Navigation': Navigation,
'components/Alert': Alert,
'components/LinkButton': LinkButton,
'components/Checkbox': Checkbox,
'components/SelectDropdown': SelectDropdown,
'components/ModalManager': ModalManager,
'components/Button': Button,
'components/Modal': Modal,
'components/GroupBadge': GroupBadge,
Model: Model,
Application: Application,
'helpers/fullTime': fullTime,
'helpers/avatar': avatar,
'helpers/icon': icon,
'helpers/humanTime': humanTimeHelper,
'helpers/punctuateSeries': punctuateSeries,
'helpers/highlight': highlight,
'helpers/username': username,
'helpers/userOnline': userOnline,
'helpers/listItems': listItems,
};

View File

@@ -1,48 +0,0 @@
import Component from '../Component';
import Button from './Button';
import listItems from '../helpers/listItems';
import extract from '../utils/extract';
/**
* The `Alert` component represents an alert box, which contains a message,
* some controls, and may be dismissible.
*
* The alert may have the following special props:
*
* - `type` The type of alert this is. Will be used to give the alert a class
* name of `Alert--{type}`.
* - `controls` An array of controls to show in the alert.
* - `dismissible` Whether or not the alert can be dismissed.
* - `ondismiss` A callback to run when the alert is dismissed.
*
* All other props will be assigned as attributes on the alert element.
*/
export default class Alert extends Component {
view() {
const attrs = Object.assign({}, this.props);
const type = extract(attrs, 'type');
attrs.className = 'Alert Alert--' + type + ' ' + (attrs.className || '');
const children = extract(attrs, 'children');
const controls = extract(attrs, 'controls') || [];
// If the alert is meant to be dismissible (which is the case by default),
// then we will create a dismiss button to append as the final control in
// the alert.
const dismissible = extract(attrs, 'dismissible');
const ondismiss = extract(attrs, 'ondismiss');
const dismissControl = [];
if (dismissible || dismissible === undefined) {
dismissControl.push(<Button icon="fas fa-times" className="Button Button--link Button--icon Alert-dismiss" onclick={ondismiss} />);
}
return (
<div {...attrs}>
<span className="Alert-body">{children}</span>
<ul className="Alert-controls">{listItems(controls.concat(dismissControl))}</ul>
</div>
);
}
}

View File

@@ -1,77 +0,0 @@
import Component from '../Component';
import Alert from './Alert';
/**
* The `AlertManager` component provides an area in which `Alert` components can
* be shown and dismissed.
*/
export default class AlertManager extends Component {
init() {
/**
* An array of Alert components which are currently showing.
*
* @type {Alert[]}
* @protected
*/
this.components = [];
}
view() {
return (
<div className="AlertManager">
{this.components.map((component) => (
<div className="AlertManager-alert">{component}</div>
))}
</div>
);
}
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;
}
/**
* Show an Alert in the alerts area.
*
* @param {Alert} component
* @public
*/
show(component) {
if (!(component instanceof Alert)) {
throw new Error('The AlertManager component can only show Alert components');
}
component.props.ondismiss = this.dismiss.bind(this, component);
this.components.push(component);
m.redraw();
}
/**
* Dismiss an alert.
*
* @param {Alert} component
* @public
*/
dismiss(component) {
const index = this.components.indexOf(component);
if (index !== -1) {
this.components.splice(index, 1);
m.redraw();
}
}
/**
* Clear all alerts.
*
* @public
*/
clear() {
this.components = [];
m.redraw();
}
}

View File

@@ -1,35 +0,0 @@
import Component from '../Component';
import icon from '../helpers/icon';
import extract from '../utils/extract';
/**
* The `Badge` component represents a user/discussion badge, indicating some
* status (e.g. a discussion is stickied, a user is an admin).
*
* A badge may have the following special props:
*
* - `type` The type of badge this is. This will be used to give the badge a
* class name of `Badge--{type}`.
* - `icon` The name of an icon to show inside the badge.
* - `label`
*
* All other props will be assigned as attributes on the badge element.
*/
export default class Badge extends Component {
view() {
const attrs = Object.assign({}, this.props);
const type = extract(attrs, 'type');
const iconName = extract(attrs, 'icon');
attrs.className = 'Badge ' + (type ? 'Badge--' + type : '') + ' ' + (attrs.className || '');
attrs.title = extract(attrs, 'label') || '';
return <span {...attrs}>{iconName ? icon(iconName, { className: 'Badge-icon' }) : m.trust('&nbsp;')}</span>;
}
config(isInitialized) {
if (isInitialized) return;
if (this.props.label) this.$().tooltip({ container: 'body' });
}
}

View File

@@ -1,70 +0,0 @@
import Component from '../Component';
import icon from '../helpers/icon';
import extract from '../utils/extract';
import extractText from '../utils/extractText';
import LoadingIndicator from './LoadingIndicator';
/**
* The `Button` component defines an element which, when clicked, performs an
* action. The button may have the following special props:
*
* - `icon` The name of the icon class. If specified, the button will be given a
* 'has-icon' class name.
* - `disabled` Whether or not the button is disabled. If truthy, the button
* will be given a 'disabled' class name, and any `onclick` handler will be
* removed.
* - `loading` Whether or not the button should be in a disabled loading state.
*
* All other props will be assigned as attributes on the button element.
*
* Note that a Button has no default class names. This is because a Button can
* be used to represent any generic clickable control, like a menu item.
*/
export default class Button extends Component {
view() {
const attrs = Object.assign({}, this.props);
delete attrs.children;
attrs.className = attrs.className || '';
attrs.type = attrs.type || 'button';
// If a tooltip was provided for buttons without additional content, we also
// use this tooltip as text for screen readers
if (attrs.title && !this.props.children) {
attrs['aria-label'] = attrs.title;
}
// If nothing else is provided, we use the textual button content as tooltip
if (!attrs.title && this.props.children) {
attrs.title = extractText(this.props.children);
}
const iconName = extract(attrs, 'icon');
if (iconName) attrs.className += ' hasIcon';
const loading = extract(attrs, 'loading');
if (attrs.disabled || loading) {
attrs.className += ' disabled' + (loading ? ' loading' : '');
delete attrs.onclick;
}
return <button {...attrs}>{this.getButtonContent()}</button>;
}
/**
* Get the template for the button's content.
*
* @return {*}
* @protected
*/
getButtonContent() {
const iconName = this.props.icon;
return [
iconName && iconName !== true ? icon(iconName, { className: 'Button-icon' }) : '',
this.props.children ? <span className="Button-label">{this.props.children}</span> : '',
this.props.loading ? LoadingIndicator.component({ size: 'tiny', className: 'LoadingIndicator--inline' }) : '',
];
}
}

View File

@@ -1,60 +0,0 @@
import Component from '../Component';
import LoadingIndicator from './LoadingIndicator';
import icon from '../helpers/icon';
/**
* The `Checkbox` component defines a checkbox input.
*
* ### Props
*
* - `state` Whether or not the checkbox is checked.
* - `className` The class name for the root element.
* - `disabled` Whether or not the checkbox is disabled.
* - `onchange` A callback to run when the checkbox is checked/unchecked.
* - `children` A text label to display next to the checkbox.
*/
export default class Checkbox extends Component {
init() {
/**
* Whether or not the checkbox's value is in the process of being saved.
*
* @type {Boolean}
* @public
*/
this.loading = false;
}
view() {
let className = 'Checkbox ' + (this.props.state ? 'on' : 'off') + ' ' + (this.props.className || '');
if (this.loading) className += ' loading';
if (this.props.disabled) className += ' disabled';
return (
<label className={className}>
<input type="checkbox" checked={this.props.state} disabled={this.props.disabled} onchange={m.withAttr('checked', this.onchange.bind(this))} />
<div className="Checkbox-display">{this.getDisplay()}</div>
{this.props.children}
</label>
);
}
/**
* Get the template for the checkbox's display (tick/cross icon).
*
* @return {*}
* @protected
*/
getDisplay() {
return this.loading ? LoadingIndicator.component({ size: 'tiny' }) : icon(this.props.state ? 'fas fa-check' : 'fas fa-times');
}
/**
* Run a callback when the state of the checkbox is changed.
*
* @param {Boolean} checked
* @protected
*/
onchange(checked) {
if (this.props.onchange) this.props.onchange(checked, this);
}
}

View File

@@ -1,122 +0,0 @@
import Component from '../Component';
import icon from '../helpers/icon';
import listItems from '../helpers/listItems';
/**
* The `Dropdown` component displays a button which, when clicked, shows a
* dropdown menu beneath it.
*
* ### Props
*
* - `buttonClassName` A class name to apply to the dropdown toggle button.
* - `menuClassName` A class name to apply to the dropdown menu.
* - `icon` The name of an icon to show in the dropdown toggle button.
* - `caretIcon` The name of an icon to show on the right of the button.
* - `label` The label of the dropdown toggle button. Defaults to 'Controls'.
* - `onhide`
* - `onshow`
*
* The children will be displayed as a list inside of the dropdown menu.
*/
export default class Dropdown extends Component {
protected showing = false;
static initProps(props) {
super.initProps(props);
props.className = props.className || '';
props.buttonClassName = props.buttonClassName || '';
props.menuClassName = props.menuClassName || '';
props.label = props.label || '';
props.caretIcon = typeof props.caretIcon !== 'undefined' ? props.caretIcon : 'fas fa-caret-down';
}
init() {
super.oninit();
this.showing = false;
}
view() {
const items = this.props.children ? listItems(this.props.children) : [];
return (
<div className={'ButtonGroup Dropdown dropdown ' + this.props.className + ' itemCount' + items.length + (this.showing ? ' open' : '')}>
{this.getButton()}
{this.getMenu(items)}
</div>
);
}
config(isInitialized) {
if (isInitialized) return;
// When opening the dropdown menu, work out if the menu goes beyond the
// bottom of the viewport. If it does, we will apply class to make it show
// above the toggle button instead of below it.
this.$().on('shown.bs.dropdown', () => {
this.showing = true;
if (this.props.onshow) {
this.props.onshow();
}
m.redraw();
const $menu = this.$('.Dropdown-menu');
const isRight = $menu.hasClass('Dropdown-menu--right');
$menu.removeClass('Dropdown-menu--top Dropdown-menu--right');
$menu.toggleClass('Dropdown-menu--top', $menu.offset().top + $menu.height() > $(window).scrollTop() + $(window).height());
if ($menu.offset().top < 0) {
$menu.removeClass('Dropdown-menu--top');
}
$menu.toggleClass('Dropdown-menu--right', isRight || $menu.offset().left + $menu.width() > $(window).scrollLeft() + $(window).width());
});
this.$().on('hidden.bs.dropdown', () => {
this.showing = false;
if (this.props.onhide) {
this.props.onhide();
}
m.redraw();
});
}
/**
* Get the template for the button.
*
* @return {*}
* @protected
*/
getButton() {
return (
<button className={'Dropdown-toggle ' + this.props.buttonClassName} data-toggle="dropdown" onclick={this.props.onclick}>
{this.getButtonContent()}
</button>
);
}
/**
* Get the template for the button's content.
*
* @return {*}
* @protected
*/
getButtonContent() {
return [
this.props.icon ? icon(this.props.icon, { className: 'Button-icon' }) : '',
<span className="Button-label">{this.props.label}</span>,
this.props.caretIcon ? icon(this.props.caretIcon, { className: 'Button-caret' }) : '',
];
}
getMenu(items) {
return <ul className={'Dropdown-menu dropdown-menu ' + this.props.menuClassName}>{items}</ul>;
}
}

View File

@@ -1,22 +0,0 @@
import Component from '../Component';
import listItems from '../helpers/listItems';
/**
* The `FieldSet` component defines a collection of fields, displayed in a list
* underneath a title. Accepted properties are:
*
* - `className` The class name for the fieldset.
* - `label` The title of this group of fields.
*
* The children should be an array of items to show in the fieldset.
*/
export default class FieldSet extends Component {
view() {
return (
<fieldset className={this.props.className}>
<legend>{this.props.label}</legend>
<ul>{listItems(this.props.children)}</ul>
</fieldset>
);
}
}

View File

@@ -1,16 +0,0 @@
import Badge from './Badge';
export default class GroupBadge extends Badge {
static initProps(props) {
super.initProps(props);
if (props.group) {
props.icon = props.group.icon();
props.style = { backgroundColor: props.group.color() };
props.label = typeof props.label === 'undefined' ? props.group.nameSingular() : props.label;
props.type = 'group--' + props.group.id();
delete props.group;
}
}
}

View File

@@ -1,38 +0,0 @@
import Button from './Button';
/**
* The `LinkButton` component defines a `Button` which links to a route.
*
* ### Props
*
* All of the props accepted by `Button`, plus:
*
* - `active` Whether or not the page that this button links to is currently
* active.
* - `href` The URL to link to. If the current URL `m.route()` matches this,
* the `active` prop will automatically be set to true.
*/
export default class LinkButton extends Button {
static initProps(props) {
props.active = this.isActive(props);
props.config = props.config || m.route;
}
view() {
const vdom = super.view();
vdom.tag = 'a';
return vdom;
}
/**
* Determine whether a component with the given props is 'active'.
*
* @param {Object} props
* @return {Boolean}
*/
static isActive(props) {
return typeof props.active !== 'undefined' ? props.active : m.route() === props.href;
}
}

View File

@@ -1,42 +0,0 @@
import Component from '../Component';
import { Spinner } from 'spin.js';
/**
* The `LoadingIndicator` component displays a loading spinner with spin.js. It
* may have the following special props:
*
* - `size` The spin.js size preset to use. Defaults to 'small'.
*
* All other props will be assigned as attributes on the element.
*/
export default class LoadingIndicator extends Component {
view() {
const attrs = Object.assign({}, this.props);
attrs.className = 'LoadingIndicator ' + (attrs.className || '');
delete attrs.size;
return <div {...attrs}>{m.trust('&nbsp;')}</div>;
}
config(isInitialized) {
if (isInitialized) return;
const options = { zIndex: 'auto', color: this.$().css('color') };
switch (this.props.size) {
case 'large':
Object.assign(options, { lines: 10, length: 8, width: 4, radius: 8 });
break;
case 'tiny':
Object.assign(options, { lines: 8, length: 2, width: 2, radius: 3 });
break;
default:
Object.assign(options, { lines: 8, length: 4, width: 3, radius: 5 });
}
new Spinner(options).spin(this.element);
}
}

View File

@@ -1,136 +0,0 @@
import Component from '../Component';
import Alert from './Alert';
import Button from './Button';
/**
* The `Modal` component displays a modal dialog, wrapped in a form. Subclasses
* should implement the `className`, `title`, and `content` methods.
*
* @abstract
*/
export default class Modal extends Component {
init() {
/**
* An alert component to show below the header.
*
* @type {Alert}
*/
this.alert = null;
}
view() {
if (this.alert) {
this.alert.props.dismissible = false;
}
return (
<div className={'Modal modal-dialog ' + this.className()}>
<div className="Modal-content">
{this.isDismissible() ? (
<div className="Modal-close App-backControl">
{Button.component({
icon: 'fas fa-times',
onclick: this.hide.bind(this),
className: 'Button Button--icon Button--link',
})}
</div>
) : (
''
)}
<form onsubmit={this.onsubmit.bind(this)}>
<div className="Modal-header">
<h3 className="App-titleControl App-titleControl--text">{this.title()}</h3>
</div>
{alert ? <div className="Modal-alert">{this.alert}</div> : ''}
{this.content()}
</form>
</div>
</div>
);
}
/**
* Determine whether or not the modal should be dismissible via an 'x' button.
*
* @return {Boolean}
*/
isDismissible() {
return true;
}
/**
* Get the class name to apply to the modal.
*
* @return {String}
* @abstract
*/
className() {}
/**
* Get the title of the modal dialog.
*
* @return {String}
* @abstract
*/
title() {}
/**
* Get the content of the modal.
*
* @return {VirtualElement}
* @abstract
*/
content() {}
/**
* Handle the modal form's submit event.
*
* @param {Event} e
*/
onsubmit() {}
/**
* Focus on the first input when the modal is ready to be used.
*/
onready() {
this.$('form').find('input, select, textarea').first().focus().select();
}
onhide() {}
/**
* Hide the modal.
*/
hide() {
app.modal.close();
}
/**
* Stop loading.
*/
loaded() {
this.loading = false;
m.redraw();
}
/**
* Show an alert describing an error returned from the API, and give focus to
* the first relevant field.
*
* @param {RequestError} error
*/
onerror(error) {
this.alert = error.alert;
m.redraw();
if (error.status === 422 && error.response.errors) {
this.$('form [name=' + error.response.errors[0].source.pointer.replace('/data/attributes/', '') + ']').select();
} else {
this.onready();
}
}
}

View File

@@ -1,105 +0,0 @@
import Component from '../Component';
import Modal from './Modal';
/**
* The `ModalManager` component manages a modal dialog. Only one modal dialog
* can be shown at once; loading a new component into the ModalManager will
* overwrite the previous one.
*/
export default class ModalManager extends Component {
init() {
this.showing = false;
this.component = null;
}
view() {
return <div className="ModalManager modal fade">{this.component && this.component.render()}</div>;
}
config(isInitialized, context) {
if (isInitialized) return;
// 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;
this.$().on('hidden.bs.modal', this.clear.bind(this)).on('shown.bs.modal', this.onready.bind(this));
}
/**
* Show a modal dialog.
*
* @param {Modal} component
* @public
*/
show(component) {
if (!(component instanceof Modal)) {
throw new Error('The ModalManager component can only show Modal components');
}
clearTimeout(this.hideTimeout);
this.showing = true;
this.component = component;
if (app.current) app.current.retain = true;
m.redraw(true);
const dismissible = !!this.component.isDismissible();
this.$()
.modal({
backdrop: dismissible || 'static',
keyboard: dismissible,
})
.modal('show');
}
/**
* Close the modal dialog.
*
* @public
*/
close() {
if (!this.showing) return;
// Don't hide the modal immediately, because if the consumer happens to call
// the `show` method straight after to show another modal dialog, it will
// cause Bootstrap's modal JS to misbehave. Instead we will wait for a tiny
// bit to give the `show` method the opportunity to prevent this from going
// ahead.
this.hideTimeout = setTimeout(() => {
this.$().modal('hide');
this.showing = false;
});
}
/**
* Clear content from the modal area.
*
* @protected
*/
clear() {
if (this.component) {
this.component.onhide();
}
this.component = null;
app.current.retain = false;
m.lazyRedraw();
}
/**
* When the modal dialog is ready to be used, tell it!
*
* @protected
*/
onready() {
if (this.component && this.component.onready) {
this.component.onready(this.$());
}
}
}

View File

@@ -1,105 +0,0 @@
import Component from '../Component';
import Button from './Button';
import LinkButton from './LinkButton';
/**
* The `Navigation` component displays a set of navigation buttons. Typically
* this is just a back button which pops the app's History. If the user is on
* the root page and there is no history to pop, then in some instances it may
* show a button that toggles the app's drawer.
*
* If the app has a pane, it will also include a 'pin' button which toggles the
* pinned state of the pane.
*
* Accepts the following props:
*
* - `className` The name of a class to set on the root element.
* - `drawer` Whether or not to show a button to toggle the app's drawer if
* there is no more history to pop.
*/
export default class Navigation extends Component {
view() {
const { history, pane } = app;
return (
<div
className={'Navigation ButtonGroup ' + (this.props.className || '')}
onmouseenter={pane && pane.show.bind(pane)}
onmouseleave={pane && pane.onmouseleave.bind(pane)}
>
{history.canGoBack() ? [this.getBackButton(), this.getPaneButton()] : this.getDrawerButton()}
</div>
);
}
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;
}
/**
* Get the back button.
*
* @return {Object}
* @protected
*/
getBackButton() {
const { history } = app;
const previous = history.getPrevious() || {};
return LinkButton.component({
className: 'Button Navigation-back Button--icon',
href: history.backUrl(),
icon: 'fas fa-chevron-left',
title: previous.title,
config: () => {},
onclick: (e) => {
if (e.shiftKey || e.ctrlKey || e.metaKey || e.which === 2) return;
e.preventDefault();
history.back();
},
});
}
/**
* Get the pane pinned toggle button.
*
* @return {Object|String}
* @protected
*/
getPaneButton() {
const { pane } = app;
if (!pane || !pane.active) return '';
return Button.component({
className: 'Button Button--icon Navigation-pin' + (pane.pinned ? ' active' : ''),
onclick: pane.togglePinned.bind(pane),
icon: 'fas fa-thumbtack',
});
}
/**
* Get the drawer toggle button.
*
* @return {Object|String}
* @protected
*/
getDrawerButton() {
if (!this.props.drawer) return '';
const { drawer } = app;
const user = app.session.user;
return Button.component({
className: 'Button Button--icon Navigation-drawer' + (user && user.newNotificationCount() ? ' new' : ''),
onclick: (e) => {
e.stopPropagation();
drawer.show();
},
icon: 'fas fa-bars',
});
}
}

View File

@@ -1,19 +0,0 @@
import Component from '../Component';
/**
* The `Placeholder` component displays a muted text with some call to action,
* usually used as an empty state.
*
* ### Props
*
* - `text`
*/
export default class Placeholder extends Component {
view() {
return (
<div className="Placeholder">
<p>{this.props.text}</p>
</div>
);
}
}

View File

@@ -1,33 +0,0 @@
import Component from '../Component';
import icon from '../helpers/icon';
/**
* The `Select` component displays a <select> input, surrounded with some extra
* elements for styling. It accepts the following props:
*
* - `options` A map of option values to labels.
* - `onchange` A callback to run when the selected value is changed.
* - `value` The value of the selected option.
* - `disabled` Disabled state for the input.
*/
export default class Select extends Component {
view() {
const { options, onchange, value, disabled } = this.props;
return (
<span className="Select">
<select
className="Select-input FormControl"
onchange={onchange ? m.withAttr('value', onchange.bind(this)) : undefined}
value={value}
disabled={disabled}
>
{Object.keys(options).map((key) => (
<option value={key}>{options[key]}</option>
))}
</select>
{icon('fas fa-sort', { className: 'Select-caret' })}
</span>
);
}
}

View File

@@ -1,31 +0,0 @@
import Dropdown from './Dropdown';
import icon from '../helpers/icon';
/**
* The `SelectDropdown` component is the same as a `Dropdown`, except the toggle
* button's label is set as the label of the first child which has a truthy
* `active` prop.
*
* ### Props
*
* - `caretIcon`
* - `defaultLabel`
*/
export default class SelectDropdown extends Dropdown {
static initProps(props) {
props.caretIcon = typeof props.caretIcon !== 'undefined' ? props.caretIcon : 'fas fa-sort';
super.initProps(props);
props.className += ' Dropdown--select';
}
getButtonContent() {
const activeChild = this.props.children.filter((child) => child.props.active)[0];
let label = (activeChild && activeChild.props.children) || this.props.defaultLabel;
if (label instanceof Array) label = label[0];
return [<span className="Button-label">{label}</span>, icon(this.props.caretIcon, { className: 'Button-caret' })];
}
}

View File

@@ -1,14 +0,0 @@
import Component from '../Component';
/**
* The `Separator` component defines a menu separator item.
*/
class Separator extends Component {
view() {
return <li className="Dropdown-separator" />;
}
}
Separator.isListItem = true;
export default Separator;

View File

@@ -1,48 +0,0 @@
import Dropdown from './Dropdown';
import Button from './Button';
import icon from '../helpers/icon';
/**
* The `SplitDropdown` component is similar to `Dropdown`, but the first child
* is displayed as its own button prior to the toggle button.
*/
export default class SplitDropdown extends Dropdown {
static initProps(props) {
super.initProps(props);
props.className += ' Dropdown--split';
props.menuClassName += ' Dropdown-menu--right';
}
getButton() {
// Make a copy of the props of the first child component. We will assign
// these props to a new button, so that it has exactly the same behaviour as
// the first child.
const firstChild = this.getFirstChild();
const buttonProps = Object.assign({}, firstChild.props);
buttonProps.className = (buttonProps.className || '') + ' SplitDropdown-button Button ' + this.props.buttonClassName;
return [
Button.component(buttonProps),
<button className={'Dropdown-toggle Button Button--icon ' + this.props.buttonClassName} data-toggle="dropdown">
{icon(this.props.icon, { className: 'Button-icon' })}
{icon('fas fa-caret-down', { className: 'Button-caret' })}
</button>,
];
}
/**
* Get the first child. If the first child is an array, the first item in that
* array will be returned.
*
* @return {*}
* @protected
*/
getFirstChild() {
let firstChild = this.props.children;
while (firstChild instanceof Array) firstChild = firstChild[0];
return firstChild;
}
}

View File

@@ -1,17 +0,0 @@
import Checkbox from './Checkbox';
/**
* The `Switch` component is a `Checkbox`, but with a switch display instead of
* a tick/cross one.
*/
export default class Switch extends Checkbox {
static initProps(props) {
super.initProps(props);
props.className = (props.className || '') + ' Checkbox--switch';
}
getDisplay() {
return this.loading ? super.getDisplay() : '';
}
}

View File

@@ -1,65 +0,0 @@
/**
* Extend an object's method by running its output through a mutating callback
* every time it is called.
*
* The callback accepts the method's return value and should perform any
* mutations directly on this value. For this reason, this function will not be
* effective on methods which return scalar values (numbers, strings, booleans).
*
* Care should be taken to extend the correct object in most cases, a class'
* prototype will be the desired target of extension, not the class itself.
*
* @example
* extend(Discussion.prototype, 'badges', function(badges) {
* // do something with `badges`
* });
*
* @param {Object} object The object that owns the method
* @param {String} method The name of the method to extend
* @param {function} callback A callback which mutates the method's output
*/
export function extend(object, method, callback) {
const original = object[method];
object[method] = function (...args) {
const value = original ? original.apply(this, args) : undefined;
callback.apply(this, [value].concat(args));
return value;
};
Object.assign(object[method], original);
}
/**
* Override an object's method by replacing it with a new function, so that the
* new function will be run every time the object's method is called.
*
* The replacement function accepts the original method as its first argument,
* which is like a call to 'super'. Any arguments passed to the original method
* are also passed to the replacement.
*
* Care should be taken to extend the correct object in most cases, a class'
* prototype will be the desired target of extension, not the class itself.
*
* @example
* override(Discussion.prototype, 'badges', function(original) {
* const badges = original();
* // do something with badges
* return badges;
* });
*
* @param {Object} object The object that owns the method
* @param {String} method The name of the method to override
* @param {function} newMethod The method to replace it with
*/
export function override(object, method, newMethod) {
const original = object[method];
object[method] = function (...args) {
return newMethod.apply(this, [original.bind(this)].concat(args));
};
Object.assign(object[method], original);
}

View File

@@ -1,41 +0,0 @@
export default class Routes {
type;
attributes = [];
hasOnes = [];
hasManys = [];
constructor(type, model = null) {
this.type = type;
this.model = model;
}
attribute(name) {
this.attributes.push(name);
return this;
}
hasOne(type) {
this.hasOnes.push(type);
return this;
}
hasMany(type) {
this.hasManys.push(type);
return this;
}
extend(app, extension) {
if (this.model) {
app.store.models[this.type] = this.model;
}
const model = app.store.models[this.type];
this.attributes.forEach((name) => (model.prototype[name] = model.attribute(name)));
this.hasOnes.forEach((name) => (model.prototype[name] = model.hasOne(name)));
this.hasManys.forEach((name) => (model.prototype[name] = model.hasMany(name)));
}
}

View File

@@ -1,13 +0,0 @@
export default class PostTypes {
postComponents = {};
add(name, component) {
this.postComponents[name] = component;
return this;
}
extend(app, extension) {
Object.assign(app.postComponents, this.postComponents);
}
}

View File

@@ -1,13 +0,0 @@
export default class Routes {
routes = {};
add(name, path, component) {
this.routes[name] = { path, component };
return this;
}
extend(app, extension) {
Object.assign(app.routes, this.routes);
}
}

View File

@@ -1,3 +0,0 @@
export { default as Model } from './Model';
export { default as PostTypes } from './PostTypes';
export { default as Routes } from './R outes';

View File

@@ -1,36 +0,0 @@
/**
* The `avatar` helper displays a user's avatar.
*
* @param {User} user
* @param {Object} attrs Attributes to apply to the avatar element
* @return {Object}
*/
export default function avatar(user, attrs = {}) {
attrs.className = 'Avatar ' + (attrs.className || '');
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 = 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 = user.displayName() || '?';
const avatarUrl = user.avatarUrl();
if (hasTitle) attrs.title = attrs.title || username;
if (avatarUrl) {
return <img {...attrs} src={avatarUrl} />;
}
content = username.charAt(0).toUpperCase();
attrs.style = { background: user.color() };
}
return <span {...attrs}>{content}</span>;
}

View File

@@ -1,19 +0,0 @@
/**
* The `fullTime` helper displays a formatted time string wrapped in a <time>
* tag.
*
* @param {Date} time
* @return {Object}
*/
export default function fullTime(time) {
const mo = moment(time);
const datetime = mo.format();
const full = mo.format('LLLL');
return (
<time pubdate datetime={datetime}>
{full}
</time>
);
}

View File

@@ -1,37 +0,0 @@
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} 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, phrase, length) {
if (!phrase && !length) return string;
// Convert the word phrase into a global regular expression (if it isn't
// already) so we can search the string for matched.
const regexp = phrase instanceof RegExp ? phrase : new RegExp(phrase, 'gi');
let highlighted = string;
let start = 0;
// If a length was given, the truncate the string surrounding the first match.
if (length) {
if (phrase) start = Math.max(0, string.search(regexp) - length / 2);
highlighted = truncate(highlighted, length, start);
}
// Convert the string into HTML entities, then highlight all matches with
// <mark> tags. Then we will return the result as a trusted HTML string.
highlighted = $('<div/>').text(highlighted).html();
if (phrase) highlighted = highlighted.replace(regexp, '<mark>$&</mark>');
return m.trust(highlighted);
}

View File

@@ -1,23 +0,0 @@
import humanTimeUtil from '../utils/humanTime';
/**
* The `humanTime` helper displays a time in a human-friendly time-ago format
* (e.g. '12 days ago'), wrapped in a <time> tag with other information about
* the time.
*
* @param {Date} time
* @return {Object}
*/
export default function humanTime(time) {
const mo = moment(time);
const datetime = mo.format();
const full = mo.format('LLLL');
const ago = humanTimeUtil(time);
return (
<time pubdate datetime={datetime} title={full} data-humantime>
{ago}
</time>
);
}

View File

@@ -1,12 +0,0 @@
/**
* The `icon` helper displays an icon.
*
* @param {String} fontClass The full icon class, prefix and the icons name.
* @param {Object} attrs Any other attributes to apply.
* @return {Object}
*/
export default function icon(fontClass, attrs = {}) {
attrs.className = 'icon ' + fontClass + ' ' + (attrs.className || '');
return <i {...attrs} />;
}

View File

@@ -1,50 +0,0 @@
import Separator from '../components/Separator';
import classList from '../utils/classList';
function isSeparator(item) {
return item && item.component === Separator;
}
function withoutUnnecessarySeparators(items) {
const newItems = [];
let prevItem;
items.forEach((item, i) => {
if (!isSeparator(item) || (prevItem && !isSeparator(prevItem) && i !== items.length - 1)) {
prevItem = item;
newItems.push(item);
}
});
return newItems;
}
/**
* 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) {
if (!(items instanceof Array)) items = [items];
return withoutUnnecessarySeparators(items).map((item) => {
const isListItem = item.component && item.component.isListItem;
const active = item.component && item.component.isActive && item.component.isActive(item.props);
const className = item.props ? item.props.itemClassName : item.itemClassName;
if (isListItem) {
item.attrs = item.attrs || {};
item.attrs.key = item.attrs.key || item.itemName;
}
return isListItem ? (
item
) : (
<li className={classList([item.itemName ? 'item-' + item.itemName : '', className, active ? 'active' : ''])} key={item.itemName}>
{item}
</li>
);
});
}

View File

@@ -1,35 +0,0 @@
/**
* The `punctuateSeries` helper formats a list of strings (e.g. names) to read
* fluently in the application's locale.
*
* ```js
* punctuateSeries(['Toby', 'Franz', 'Dominion']) // Toby, Franz, and Dominion
* ```
*
* @param {Array} items
* @return {VirtualElement}
*/
export default function punctuateSeries(items) {
if (items.length === 2) {
return app.translator.trans('core.lib.series.two_text', {
first: items[0],
second: items[1],
});
} else if (items.length >= 3) {
// If there are three or more items, we will join all but the first and
// last items with the equivalent of a comma, and then we will feed that
// into the translator along with the first and last item.
const second = items
.slice(1, items.length - 1)
.reduce((list, item) => list.concat([item, app.translator.trans('core.lib.series.glue_text')]), [])
.slice(0, -1);
return app.translator.trans('core.lib.series.three_text', {
first: items[0],
second,
third: items[items.length - 1],
});
}
return items;
}

View File

@@ -1,13 +0,0 @@
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) {
if (user.lastSeenAt() && user.isOnline()) {
return <span className="UserOnline">{icon('fas fa-circle')}</span>;
}
}

View File

@@ -1,12 +0,0 @@
/**
* 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) {
const name = (user && user.displayName()) || app.translator.trans('core.lib.username.deleted_text');
return <span className="username">{name}</span>;
}

View File

@@ -1,18 +0,0 @@
import 'expose-loader?$!expose-loader?jQuery!jquery';
import 'expose-loader?m!mithril';
import 'expose-loader?moment!moment';
import 'expose-loader?m.bidi!m.attrs.bidi';
import 'bootstrap/js/affix';
import 'bootstrap/js/dropdown';
import 'bootstrap/js/modal';
import 'bootstrap/js/tooltip';
import 'bootstrap/js/transition';
import 'jquery.hotkeys/jquery.hotkeys';
import patchMithril from './utils/patchMithril';
patchMithril(window);
import * as Extend from './extend/index';
export { Extend };

View File

@@ -1,104 +0,0 @@
import Model from '../Model';
import computed from '../utils/computed';
import ItemList from '../utils/ItemList';
import Badge from '../components/Badge';
export default class Discussion extends Model {}
Object.assign(Discussion.prototype, {
title: Model.attribute('title'),
slug: Model.attribute('slug'),
createdAt: Model.attribute('createdAt', Model.transformDate),
user: Model.hasOne('user'),
firstPost: Model.hasOne('firstPost'),
lastPostedAt: Model.attribute('lastPostedAt', Model.transformDate),
lastPostedUser: Model.hasOne('lastPostedUser'),
lastPost: Model.hasOne('lastPost'),
lastPostNumber: Model.attribute('lastPostNumber'),
commentCount: Model.attribute('commentCount'),
replyCount: computed('commentCount', (commentCount) => Math.max(0, commentCount - 1)),
posts: Model.hasMany('posts'),
mostRelevantPost: Model.hasOne('mostRelevantPost'),
lastReadAt: Model.attribute('lastReadAt', Model.transformDate),
lastReadPostNumber: Model.attribute('lastReadPostNumber'),
isUnread: computed('unreadCount', (unreadCount) => !!unreadCount),
isRead: computed('unreadCount', (unreadCount) => app.session.user && !unreadCount),
hiddenAt: Model.attribute('hiddenAt', Model.transformDate),
hiddenUser: Model.hasOne('hiddenUser'),
isHidden: computed('hiddenAt', (hiddenAt) => !!hiddenAt),
canReply: Model.attribute('canReply'),
canRename: Model.attribute('canRename'),
canHide: Model.attribute('canHide'),
canDelete: Model.attribute('canDelete'),
/**
* Remove a post from the discussion's posts relationship.
*
* @param {Integer} id The ID of the post to remove.
* @public
*/
removePost(id) {
const relationships = this.data.relationships;
const posts = relationships && relationships.posts;
if (posts) {
posts.data.some((data, i) => {
if (id === data.id) {
posts.data.splice(i, 1);
return true;
}
});
}
},
/**
* Get the estimated number of unread posts in this discussion for the current
* user.
*
* @return {Integer}
* @public
*/
unreadCount() {
const user = app.session.user;
if (user && user.markedAllAsReadAt() < this.lastPostedAt()) {
return Math.max(0, this.lastPostNumber() - (this.lastReadPostNumber() || 0));
}
return 0;
},
/**
* Get the Badge components that apply to this discussion.
*
* @return {ItemList}
* @public
*/
badges() {
const items = new ItemList();
if (this.isHidden()) {
items.add('hidden', <Badge type="hidden" icon="fas fa-trash" label={app.translator.trans('core.lib.badge.hidden_tooltip')} />);
}
return items;
},
/**
* Get a list of all of the post IDs in this discussion.
*
* @return {Array}
* @public
*/
postIds() {
const posts = this.data.relationships.posts;
return posts ? posts.data.map((link) => link.id) : [];
},
});

View File

@@ -1,7 +0,0 @@
import Model from '../Model';
export default class Forum extends Model {
apiEndpoint() {
return '/';
}
}

View File

@@ -1,16 +0,0 @@
import Model from '../Model';
class Group extends Model {}
Object.assign(Group.prototype, {
nameSingular: Model.attribute('nameSingular'),
namePlural: Model.attribute('namePlural'),
color: Model.attribute('color'),
icon: Model.attribute('icon'),
});
Group.ADMINISTRATOR_ID = '1';
Group.GUEST_ID = '2';
Group.MEMBER_ID = '3';
export default Group;

View File

@@ -1,15 +0,0 @@
import Model from '../Model';
export default class Notification extends Model {}
Object.assign(Notification.prototype, {
contentType: Model.attribute('contentType'),
content: Model.attribute('content'),
createdAt: Model.attribute('createdAt', Model.transformDate),
isRead: Model.attribute('isRead'),
user: Model.hasOne('user'),
fromUser: Model.hasOne('fromUser'),
subject: Model.hasOne('subject'),
});

View File

@@ -1,29 +0,0 @@
import Model from '../Model';
import computed from '../utils/computed';
import { getPlainContent } from '../utils/string';
export default class Post extends Model {}
Object.assign(Post.prototype, {
number: Model.attribute('number'),
discussion: Model.hasOne('discussion'),
createdAt: Model.attribute('createdAt', Model.transformDate),
user: Model.hasOne('user'),
contentType: Model.attribute('contentType'),
content: Model.attribute('content'),
contentHtml: Model.attribute('contentHtml'),
contentPlain: computed('contentHtml', getPlainContent),
editedAt: Model.attribute('editedAt', Model.transformDate),
editedUser: Model.hasOne('editedUser'),
isEdited: computed('editedAt', (editedAt) => !!editedAt),
hiddenAt: Model.attribute('hiddenAt', Model.transformDate),
hiddenUser: Model.hasOne('hiddenUser'),
isHidden: computed('hiddenAt', (hiddenAt) => !!hiddenAt),
canEdit: Model.attribute('canEdit'),
canHide: Model.attribute('canHide'),
canDelete: Model.attribute('canDelete'),
});

View File

@@ -1,111 +0,0 @@
/*global ColorThief*/
import Model from '../Model';
import stringToColor from '../utils/stringToColor';
import ItemList from '../utils/ItemList';
import computed from '../utils/computed';
import GroupBadge from '../components/GroupBadge';
export default class User extends Model {}
Object.assign(User.prototype, {
username: Model.attribute('username'),
displayName: Model.attribute('displayName'),
email: Model.attribute('email'),
isEmailConfirmed: Model.attribute('isEmailConfirmed'),
password: Model.attribute('password'),
avatarUrl: Model.attribute('avatarUrl'),
preferences: Model.attribute('preferences'),
groups: Model.hasMany('groups'),
joinTime: Model.attribute('joinTime', Model.transformDate),
lastSeenAt: Model.attribute('lastSeenAt', Model.transformDate),
markedAllAsReadAt: Model.attribute('markedAllAsReadAt', Model.transformDate),
unreadNotificationCount: Model.attribute('unreadNotificationCount'),
newNotificationCount: Model.attribute('newNotificationCount'),
discussionCount: Model.attribute('discussionCount'),
commentCount: Model.attribute('commentCount'),
canEdit: Model.attribute('canEdit'),
canDelete: Model.attribute('canDelete'),
avatarColor: null,
color: computed('username', 'avatarUrl', 'avatarColor', function (username, avatarUrl, avatarColor) {
// If we've already calculated and cached the dominant color of the user's
// avatar, then we can return that in RGB format. If we haven't, we'll want
// to calculate it. Unless the user doesn't have an avatar, in which case
// we generate a color from their username.
if (avatarColor) {
return 'rgb(' + avatarColor.join(', ') + ')';
} else if (avatarUrl) {
this.calculateAvatarColor();
return '';
}
return '#' + stringToColor(username);
}),
/**
* Check whether or not the user has been seen in the last 5 minutes.
*
* @return {Boolean}
* @public
*/
isOnline() {
return this.lastSeenAt() > moment().subtract(5, 'minutes').toDate();
},
/**
* Get the Badge components that apply to this user.
*
* @return {ItemList}
*/
badges() {
const items = new ItemList();
const groups = this.groups();
if (groups) {
groups.forEach((group) => {
items.add('group' + group.id(), GroupBadge.component({ group }));
});
}
return items;
},
/**
* Calculate the dominant color of the user's avatar. The dominant color will
* be set to the `avatarColor` property once it has been calculated.
*
* @protected
*/
calculateAvatarColor() {
const image = new Image();
const user = this;
image.onload = function () {
const colorThief = new ColorThief();
user.avatarColor = colorThief.getColor(this);
user.freshness = new Date();
m.redraw();
};
image.crossOrigin = 'anonymous';
image.src = this.avatarUrl();
},
/**
* Update the user's preferences.
*
* @param {Object} newPreferences
* @return {Promise}
*/
savePreferences(newPreferences) {
const preferences = this.preferences();
Object.assign(preferences, newPreferences);
return this.save({ preferences });
},
});

View File

@@ -1,54 +0,0 @@
/**
* The `Drawer` class controls the page's drawer. The drawer is the area the
* slides out from the left on mobile devices; it contains the header and the
* footer.
*/
export default class Drawer {
constructor() {
// Set up an event handler so that whenever the content area is tapped,
// the drawer will close.
$('#content').click((e) => {
if (this.isOpen()) {
e.preventDefault();
this.hide();
}
});
}
/**
* Check whether or not the drawer is currently open.
*
* @return {Boolean}
* @public
*/
isOpen() {
return $('#app').hasClass('drawerOpen');
}
/**
* Hide the drawer.
*
* @public
*/
hide() {
$('#app').removeClass('drawerOpen');
if (this.$backdrop) this.$backdrop.remove();
}
/**
* Show the drawer.
*
* @public
*/
show() {
$('#app').addClass('drawerOpen');
this.$backdrop = $('<div/>')
.addClass('drawer-backdrop fade')
.appendTo('body')
.click(() => this.hide());
setTimeout(() => this.$backdrop.addClass('in'));
}
}

View File

@@ -1,161 +0,0 @@
class Item {
constructor(content, priority) {
this.content = content;
this.priority = priority;
}
}
/**
* The `ItemList` class collects items and then arranges them into an array
* by priority.
*/
export default class ItemList {
constructor() {
/**
* The items in the list.
*
* @type {Object}
* @public
*/
this.items = {};
}
/**
* Check whether the list is empty.
*
* @returns {boolean}
* @public
*/
isEmpty() {
for (const i in this.items) {
if (this.items.hasOwnProperty(i)) {
return false;
}
}
return true;
}
/**
* Check whether an item is present in the list.
*
* @param key
* @returns {boolean}
*/
has(key) {
return !!this.items[key];
}
/**
* Get the content of an item.
*
* @param {String} key
* @return {*}
* @public
*/
get(key) {
return this.items[key].content;
}
/**
* Add an item to the list.
*
* @param {String} key A unique key for the item.
* @param {*} content The item's content.
* @param {Integer} [priority] The priority of the item. Items with a higher
* priority will be positioned before items with a lower priority.
* @return {ItemList}
* @public
*/
add(key, content, priority = 0) {
this.items[key] = new Item(content, priority);
return this;
}
/**
* Replace an item in the list, only if it is already present.
*
* @param {String} key
* @param {*} [content]
* @param {Integer} [priority]
* @return {ItemList}
* @public
*/
replace(key, content = null, priority = null) {
if (this.items[key]) {
if (content !== null) {
this.items[key].content = content;
}
if (priority !== null) {
this.items[key].priority = priority;
}
}
return this;
}
/**
* Remove an item from the list.
*
* @param {String} key
* @return {ItemList}
* @public
*/
remove(key) {
delete this.items[key];
return this;
}
/**
* Merge another list's items into this one.
*
* @param {ItemList} items
* @return {ItemList}
* @public
*/
merge(items) {
for (const i in items.items) {
if (items.items.hasOwnProperty(i) && items.items[i] instanceof Item) {
this.items[i] = items.items[i];
}
}
return this;
}
/**
* Convert the list into an array of item content arranged by priority. Each
* item's content will be assigned an `itemName` property equal to the item's
* unique key.
*
* @return {Array}
* @public
*/
toArray() {
const items = [];
for (const i in this.items) {
if (this.items.hasOwnProperty(i) && this.items[i] instanceof Item) {
this.items[i].content = Object(this.items[i].content);
this.items[i].content.itemName = i;
items.push(this.items[i]);
this.items[i].key = items.length;
}
}
return items
.sort((a, b) => {
if (a.priority === b.priority) {
return a.key - b.key;
} else if (a.priority > b.priority) {
return -1;
}
return 1;
})
.map((item) => item.content);
}
}

View File

@@ -1,16 +0,0 @@
export default class RequestError {
constructor(status, responseText, options, xhr) {
this.status = status;
this.responseText = responseText;
this.options = options;
this.xhr = xhr;
try {
this.response = JSON.parse(responseText);
} catch (e) {
this.response = null;
}
this.alert = null;
}
}

View File

@@ -1,75 +0,0 @@
const later =
window.requestAnimationFrame ||
window.webkitRequestAnimationFrame ||
window.mozRequestAnimationFrame ||
window.msRequestAnimationFrame ||
window.oRequestAnimationFrame ||
((callback) => window.setTimeout(callback, 1000 / 60));
/**
* The `ScrollListener` class sets up a listener that handles window scroll
* events.
*/
export default class ScrollListener {
/**
* @param {Function} callback The callback to run when the scroll position
* changes.
* @public
*/
constructor(callback) {
this.callback = callback;
this.ticking = false;
}
/**
* On each animation frame, as long as the listener is active, run the
* `update` method.
*
* @protected
*/
loop() {
// THROTTLE: If the callback is still running (or hasn't yet run), we ignore
// further scroll events.
if (this.ticking) return;
// Schedule the callback to be executed soon (TM), and stop throttling once
// the callback is done.
later(() => {
this.update();
this.ticking = false;
});
this.ticking = true;
}
/**
* Run the callback, whether there was a scroll event or not.
*
* @public
*/
update() {
this.callback(window.pageYOffset);
}
/**
* Start listening to and handling the window's scroll position.
*
* @public
*/
start() {
if (!this.active) {
window.addEventListener('scroll', (this.active = this.loop.bind(this)));
}
}
/**
* Stop listening to and handling the window's scroll position.
*
* @public
*/
stop() {
window.removeEventListener('scroll', this.active);
this.active = null;
}
}

View File

@@ -1,68 +0,0 @@
/**
* The `SubtreeRetainer` class represents a Mithril virtual DOM subtree. It
* keeps track of a number of pieces of data, allowing the subtree to be
* retained if none of them have changed.
*
* @example
* // constructor
* this.subtree = new SubtreeRetainer(
* () => this.props.post.freshness,
* () => this.showing
* );
* this.subtree.check(() => this.props.user.freshness);
*
* // view
* this.subtree.retain() || 'expensive expression'
*
* @see https://lhorie.github.io/mithril/mithril.html#persisting-dom-elements-across-route-changes
*/
export default class SubtreeRetainer {
/**
* @param {...callbacks} callbacks Functions returning data to keep track of.
*/
constructor(...callbacks) {
this.callbacks = callbacks;
this.data = {};
}
/**
* Return a virtual DOM directive that will retain a subtree if no data has
* changed since the last check.
*
* @return {Object|false}
* @public
*/
retain() {
let needsRebuild = false;
this.callbacks.forEach((callback, i) => {
const result = callback();
if (result !== this.data[i]) {
this.data[i] = result;
needsRebuild = true;
}
});
return needsRebuild ? false : { subtree: 'retain' };
}
/**
* Add another callback to be checked.
*
* @param {...Function} callbacks
* @public
*/
check(...callbacks) {
this.callbacks = this.callbacks.concat(callbacks);
}
/**
* Invalidate the subtree, forcing it to be rerendered.
*
* @public
*/
invalidate() {
this.data = {};
}
}

View File

@@ -1,20 +0,0 @@
/**
* The `abbreviateNumber` utility converts a number to a shorter localized form.
*
* @example
* abbreviateNumber(1234);
* // "1.2K"
*
* @param {Integer} number
* @return {String}
*/
export default function abbreviateNumber(number) {
// TODO: translation
if (number >= 1000000) {
return Math.floor(number / 1000000) + app.translator.trans('core.lib.number_suffix.mega_text');
} else if (number >= 1000) {
return Math.floor(number / 1000) + app.translator.trans('core.lib.number_suffix.kilo_text');
} else {
return number.toString();
}
}

View File

@@ -1,21 +0,0 @@
/**
* The `anchorScroll` utility saves the scroll position relative to an element,
* and then restores it after a callback has been run.
*
* This is useful if a redraw will change the page's content above the viewport.
* Normally doing this will result in the content in the viewport being pushed
* down or pulled up. By wrapping the redraw with this utility, the scroll
* position can be anchor to an element that is in or below the viewport, so
* the content in the viewport will stay the same.
*
* @param {DOMElement} element The element to anchor the scroll position to.
* @param {Function} callback The callback to run that will change page content.
*/
export default function anchorScroll(element, callback) {
const $window = $(window);
const relativeScroll = $(element).offset().top - $window.scrollTop();
callback();
$window.scrollTop($(element).offset().top - relativeScroll);
}

View File

@@ -1,26 +0,0 @@
/**
* The `classList` utility creates a list of class names by joining an object's
* keys, but only for values which are truthy.
*
* @example
* classList({ foo: true, bar: false, qux: 'qaz' });
* // "foo qux"
*
* @param {Object} classes
* @return {String}
*/
export default function classList(classes) {
let classNames;
if (classes instanceof Array) {
classNames = classes.filter((name) => name);
} else {
classNames = [];
for (const i in classes) {
if (classes[i]) classNames.push(i);
}
}
return classNames.join(' ');
}

View File

@@ -1,40 +0,0 @@
/**
* The `computed` utility creates a function that will cache its output until
* any of the dependent values are dirty.
*
* @param {...String} dependentKeys The keys of the dependent values.
* @param {function} compute The function which computes the value using the
* dependent values.
* @return {Function}
*/
export default function computed(...dependentKeys) {
const keys = dependentKeys.slice(0, -1);
const compute = dependentKeys.slice(-1)[0];
const dependentValues = {};
let computedValue;
return function () {
let recompute = false;
// Read all of the dependent values. If any of them have changed since last
// time, then we'll want to recompute our output.
keys.forEach((key) => {
const value = typeof this[key] === 'function' ? this[key]() : this[key];
if (dependentValues[key] !== value) {
recompute = true;
dependentValues[key] = value;
}
});
if (recompute) {
computedValue = compute.apply(
this,
keys.map((key) => dependentValues[key])
);
}
return computedValue;
};
}

View File

@@ -1,81 +0,0 @@
/**
* The `evented` mixin provides methods allowing an object to trigger events,
* running externally registered event handlers.
*/
export default {
/**
* Arrays of registered event handlers, grouped by the event name.
*
* @type {Object}
* @protected
*/
handlers: null,
/**
* Get all of the registered handlers for an event.
*
* @param {String} event The name of the event.
* @return {Array}
* @protected
*/
getHandlers(event) {
this.handlers = this.handlers || {};
this.handlers[event] = this.handlers[event] || [];
return this.handlers[event];
},
/**
* Trigger an event.
*
* @param {String} event The name of the event.
* @param {...*} args Arguments to pass to event handlers.
* @public
*/
trigger(event, ...args) {
this.getHandlers(event).forEach((handler) => handler.apply(this, args));
},
/**
* Register an event handler.
*
* @param {String} event The name of the event.
* @param {function} handler The function to handle the event.
*/
on(event, handler) {
this.getHandlers(event).push(handler);
},
/**
* Register an event handler so that it will run only once, and then
* unregister itself.
*
* @param {String} event The name of the event.
* @param {function} handler The function to handle the event.
*/
one(event, handler) {
const wrapper = function () {
handler.apply(this, arguments);
this.off(event, wrapper);
};
this.getHandlers(event).push(wrapper);
},
/**
* Unregister an event handler.
*
* @param {String} event The name of the event.
* @param {function} handler The function that handles the event.
*/
off(event, handler) {
const handlers = this.getHandlers(event);
const index = handlers.indexOf(handler);
if (index !== -1) {
handlers.splice(index, 1);
}
},
};

View File

@@ -1,15 +0,0 @@
/**
* The `extract` utility deletes a property from an object and returns its
* value.
*
* @param {Object} object The object that owns the property
* @param {String} property The name of the property to extract
* @return {*} The value of the property
*/
export default function extract(object, property) {
const value = object[property];
delete object[property];
return value;
}

View File

@@ -1,15 +0,0 @@
/**
* Extract the text nodes from a virtual element.
*
* @param {VirtualElement} vdom
* @return {String}
*/
export default function extractText(vdom) {
if (vdom instanceof Array) {
return vdom.map((element) => extractText(element)).join('');
} else if (typeof vdom === 'object' && vdom !== null) {
return extractText(vdom.children);
} else {
return vdom;
}
}

View File

@@ -1,14 +0,0 @@
/**
* The `formatNumber` utility localizes a number into a string with the
* appropriate punctuation.
*
* @example
* formatNumber(1234);
* // 1,234
*
* @param {Number} number
* @return {String}
*/
export default function formatNumber(number) {
return number.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ',');
}

View File

@@ -1,36 +0,0 @@
/**
* The `humanTime` utility converts a date to a localized, human-readable time-
* ago string.
*
* @param {Date} time
* @return {String}
*/
export default function humanTime(time) {
let m = moment(time);
const now = moment();
// To prevent showing things like "in a few seconds" due to small offsets
// between client and server time, we always reset future dates to the
// current time. This will result in "just now" being shown instead.
if (m.isAfter(now)) {
m = now;
}
const day = 864e5;
const diff = m.diff(moment());
let ago = null;
// If this date was more than a month ago, we'll show the name of the month
// in the string. If it wasn't this year, we'll show the year as well.
if (diff < -30 * day) {
if (m.year() === moment().year()) {
ago = m.format('D MMM');
} else {
ago = m.format('ll');
}
} else {
ago = m.fromNow();
}
return ago;
}

View File

@@ -1,18 +0,0 @@
import humanTimeUtil from './humanTime';
function updateHumanTimes() {
$('[data-humantime]').each(function () {
const $this = $(this);
const ago = humanTimeUtil($this.attr('datetime'));
$this.html(ago);
});
}
/**
* The `humanTime` initializer sets up a loop every 1 second to update
* timestamps rendered with the `humanTime` helper.
*/
export default function humanTime() {
setInterval(updateHumanTimes, 10000);
}

View File

@@ -1,22 +0,0 @@
/**
* The `mapRoutes` utility converts a map of named application routes into a
* format that can be understood by Mithril.
*
* @see https://lhorie.github.io/mithril/mithril.route.html#defining-routes
* @param {Object} routes
* @param {String} [basePath]
* @return {Object}
*/
export default function mapRoutes(routes, basePath = '') {
const map = {};
for (const key in routes) {
const route = routes[key];
if (route.component) route.component.props.routeName = key;
map[basePath + route.path] = route.component;
}
return map;
}

View File

@@ -1,20 +0,0 @@
/**
* The `mixin` utility assigns the properties of a set of 'mixin' objects to
* the prototype of a parent object.
*
* @example
* class MyClass extends mixin(ExistingClass, evented, etc) {}
*
* @param {Class} Parent The class to extend the new class from.
* @param {...Object} mixins The objects to mix in.
* @return {Class} A new class that extends Parent and contains the mixins.
*/
export default function mixin(Parent, ...mixins) {
class Mixed extends Parent {}
mixins.forEach((object) => {
Object.assign(Mixed.prototype, object);
});
return Mixed;
}

View File

@@ -1,45 +0,0 @@
import Component from '../Component';
export default function patchMithril(global) {
const mo = global.m;
const m = function (comp, ...args) {
if (comp.prototype && comp.prototype instanceof Component) {
let children = args.slice(1);
if (children.length === 1 && Array.isArray(children[0])) {
children = children[0];
}
return comp.component(args[0], children);
}
const node = mo.apply(this, arguments);
if (node.attrs.bidi) {
m.bidi(node, node.attrs.bidi);
}
if (node.attrs.route) {
node.attrs.href = node.attrs.route;
node.attrs.config = m.route;
delete node.attrs.route;
}
return node;
};
Object.keys(mo).forEach((key) => (m[key] = mo[key]));
/**
* Redraw only if not in the middle of a computation (e.g. a route change).
*
* @return {void}
*/
m.lazyRedraw = function () {
m.startComputation();
m.endComputation();
};
global.m = m;
}

View File

@@ -1,134 +0,0 @@
import jump from 'jump.js';
import Tooltip from 'tooltip.js';
// add $.fn.tooltip
$.fn.tooltip = function (option) {
return this.each(function () {
const $this = $(this);
let data = $this.data('bs.tooltip');
const options = typeof option === 'object' && option || {};
if ($this.attr('title')) {
options.title = $this.attr('title');
$this.removeAttr('title');
$this.attr('data-original-title', options.title);
}
if (option === 'destroy') option = 'dispose';
if (!data && ['dispose', 'hide'].includes(option)) return;
if (!data) $this.data('bs.tooltip', (data = new Tooltip(this, options)));
if (typeof option === 'string' && data[option]) data[option]();
});
};
// add $.fn.outerWidth and $.fn.outerHeight
['width', 'height'].forEach(function(dimension) {
const Dimension = dimension.replace(/./, function (m) {
return m[0].toUpperCase()
});
$.fn[`outer${Dimension}`] = function(margin) {
const elem = this;
if (elem) {
const sides = {'width': ['left', 'right'], 'height': ['top', 'bottom']};
let size = elem[dimension]();
sides[dimension].forEach(function(side) {
if (margin) size += parseInt(elem.css('margin-' + side), 10);
});
return size;
} else {
return null;
}
};
});
// allow use of $(':input')
$.expr[':']['input'] = function() {
if (('disabled' in this) || ['INPUT', 'SELECT', 'TEXTAREA', 'BUTTON'].includes(this.tagName)) return this;
};
// add $().hover() method
$.fn.hover = function(hover, leave) {
return this
.on('mouseenter', hover)
.on('mouseleave', leave || hover);
};
// add animated scroll
$.fn.animatedScrollTop = function (to, duration = $.fx.speeds._default, callback) {
if (typeof to === 'number') to -= (window.scrollY || window.pageYOffset);
jump(to, {
duration: $.fx.speeds[duration] || duration,
callback
});
return this;
};
// required for compatibility with jquery plugins
// ex: bootstrap plugins
$.fn.extend = $.extend.bind($);
/**
* Enable special events on Zepto
* @license Original Copyright 2013 Enideo. Released under dual MIT and GPL licenses.
*/
$.event.special = $.event.special || {};
const bindBeforeSpecialEvents = $.fn.bind;
$.fn.bind = function(eventName, data, callback) {
const el = this;
if (!callback){
callback = data;
data = null;
}
$.each(eventName.split(/\s/), function(i, eventName) {
eventName = eventName.split(/\./)[0];
if(eventName in $.event.special){
let specialEvent = $.event.special[eventName];
/// init enable special events on Zepto
if(!specialEvent._init) {
specialEvent._init = true;
/// intercept and replace the special event handler to add functionality
specialEvent.originalHandler = specialEvent.handler;
specialEvent.handler = function(){
/// make event argument writable, like on jQuery
const args = Array.prototype.slice.call(arguments);
args[0] = $.extend({},args[0]);
/// define the event handle, $.event.dispatch is only for newer versions of jQuery
$.event.handle = function(){
/// make context of trigger the event element
const args = Array.prototype.slice.call(arguments);
const event = args[0];
const $target = $(event.target);
$target.trigger.apply( $target, arguments );
};
specialEvent.originalHandler.apply(this,args);
}
}
/// setup special events on Zepto
specialEvent.setup.apply(el, [data]);
}
});
return bindBeforeSpecialEvents.apply(this, [eventName, callback]);
};

View File

@@ -1,64 +0,0 @@
/**
* Truncate a string to the given length, appending ellipses if necessary.
*
* @param {String} string
* @param {Number} length
* @param {Number} [start=0]
* @return {String}
*/
export function truncate(string, length, start = 0) {
return (start > 0 ? '...' : '') + string.substring(start, start + length) + (string.length > start + length ? '...' : '');
}
/**
* Create a slug out of the given string. Non-alphanumeric characters are
* converted to hyphens.
*
* NOTE: This method does not use the comparably sophisticated transliteration
* mechanism that is employed in the backend. Therefore, it should only be used
* to *suggest* slugs that can be overridden by the user.
*
* @param {String} string
* @return {String}
*/
export function slug(string) {
return string
.toLowerCase()
.replace(/[^a-z0-9]/gi, '-')
.replace(/-+/g, '-')
.replace(/-$|^-/g, '');
}
/**
* Strip HTML tags and quotes out of the given string, replacing them with
* meaningful punctuation.
*
* @param {String} string
* @return {String}
*/
export function getPlainContent(string) {
const html = string.replace(/(<\/p>|<br>)/g, '$1 &nbsp;').replace(/<img\b[^>]*>/gi, ' ');
const dom = $('<div/>').html(html);
dom.find(getPlainContent.removeSelectors.join(',')).remove();
return dom.text().replace(/\s+/g, ' ').trim();
}
/**
* An array of DOM selectors to remove when getting plain content.
*
* @type {Array}
*/
getPlainContent.removeSelectors = ['blockquote', 'script'];
/**
* Make a string's first character uppercase.
*
* @param {String} string
* @return {String}
*/
export function ucfirst(string) {
return string.substr(0, 1).toUpperCase() + string.substr(1);
}

View File

@@ -1,73 +0,0 @@
function hsvToRgb(h, s, v) {
let r;
let g;
let b;
const i = Math.floor(h * 6);
const f = h * 6 - i;
const p = v * (1 - s);
const q = v * (1 - f * s);
const t = v * (1 - (1 - f) * s);
switch (i % 6) {
case 0:
r = v;
g = t;
b = p;
break;
case 1:
r = q;
g = v;
b = p;
break;
case 2:
r = p;
g = v;
b = t;
break;
case 3:
r = p;
g = q;
b = v;
break;
case 4:
r = t;
g = p;
b = v;
break;
case 5:
r = v;
g = p;
b = q;
break;
}
return {
r: Math.floor(r * 255),
g: Math.floor(g * 255),
b: Math.floor(b * 255),
};
}
/**
* Convert the given string to a unique color.
*
* @param {String} string
* @return {String}
*/
export default function stringToColor(string) {
let num = 0;
// Convert the username into a number based on the ASCII value of each
// character.
for (let i = 0; i < string.length; i++) {
num += string.charCodeAt(i);
}
// Construct a color using the remainder of that number divided by 360, and
// some predefined saturation and value values.
const hue = num % 360;
const rgb = hsvToRgb(hue / 360, 0.3, 0.9);
return '' + rgb.r.toString(16) + rgb.g.toString(16) + rgb.b.toString(16);
}

View File

@@ -1,164 +0,0 @@
import History from './utils/History';
import Pane from './utils/Pane';
import Search from './components/Search';
import ReplyComposer from './components/ReplyComposer';
import DiscussionPage from './components/DiscussionPage';
import SignUpModal from './components/SignUpModal';
import HeaderPrimary from './components/HeaderPrimary';
import HeaderSecondary from './components/HeaderSecondary';
import Composer from './components/Composer';
import DiscussionRenamedNotification from './components/DiscussionRenamedNotification';
import CommentPost from './components/CommentPost';
import DiscussionRenamedPost from './components/DiscussionRenamedPost';
import routes from './routes';
import alertEmailConfirmation from './utils/alertEmailConfirmation';
import Application from '../common/Application';
import Navigation from '../common/components/Navigation';
export default class ForumApplication extends Application {
/**
* A map of notification types to their components.
*
* @type {Object}
*/
notificationComponents = {
discussionRenamed: DiscussionRenamedNotification,
};
/**
* A map of post types to their components.
*
* @type {Object}
*/
postComponents = {
comment: CommentPost,
discussionRenamed: DiscussionRenamedPost,
};
/**
* The page's search component instance.
*
* @type {Search}
*/
search = new Search();
/**
* An object which controls the state of the page's side pane.
*
* @type {Pane}
*/
pane = null;
/**
* An object which controls the state of the page's drawer.
*
* @type {Drawer}
*/
drawer = null;
/**
* The app's history stack, which keeps track of which routes the user visits
* so that they can easily navigate back to the previous route.
*
* @type {History}
*/
history = new History();
constructor() {
super();
routes(this);
}
/**
* @inheritdoc
*/
mount() {
// Get the configured default route and update that route's path to be '/'.
// Push the homepage as the first route, so that the user will always be
// able to click on the 'back' button to go home, regardless of which page
// they started on.
const defaultRoute = this.forum.attribute('defaultRoute');
let defaultAction = 'index';
for (const i in this.routes) {
if (this.routes[i].path === defaultRoute) defaultAction = i;
}
this.routes[defaultAction].path = '/';
this.history.push(defaultAction, this.translator.trans('core.forum.header.back_to_index_tooltip'), '/');
m.mount(document.getElementById('app-navigation'), Navigation.component({ className: 'App-backControl', drawer: true }));
m.mount(document.getElementById('header-navigation'), Navigation.component());
m.mount(document.getElementById('header-primary'), HeaderPrimary.component());
m.mount(document.getElementById('header-secondary'), HeaderSecondary.component());
this.pane = new Pane(document.getElementById('app'));
this.composer = m.mount(document.getElementById('composer'), Composer.component());
m.route.mode = 'pathname';
super.mount(this.forum.attribute('basePath'));
alertEmailConfirmation(this);
// Route the home link back home when clicked. We do not want it to register
// if the user is opening it in a new tab, however.
$('#home-link').click((e) => {
if (e.ctrlKey || e.metaKey || e.which === 2) return;
e.preventDefault();
app.history.home();
// Reload the current user so that their unread notification count is refreshed.
if (app.session.user) {
app.store.find('users', app.session.user.id());
m.redraw();
}
});
}
/**
* Check whether or not the user is currently composing a reply to a
* discussion.
*
* @param {Discussion} discussion
* @return {Boolean}
*/
composingReplyTo(discussion) {
return (
this.composer.component instanceof ReplyComposer &&
this.composer.component.props.discussion === discussion &&
this.composer.position !== Composer.PositionEnum.HIDDEN
);
}
/**
* Check whether or not the user is currently viewing a discussion.
*
* @param {Discussion} discussion
* @return {Boolean}
*/
viewingDiscussion(discussion) {
return this.current instanceof DiscussionPage && this.current.discussion === discussion;
}
/**
* Callback for when an external authenticator (social login) action has
* completed.
*
* If the payload indicates that the user has been logged in, then the page
* will be reloaded. Otherwise, a SignUpModal will be opened, prefilled
* with the provided details.
*
* @param {Object} payload A dictionary of props to pass into the sign up
* modal. A truthy `loggedIn` prop indicates that the user has logged
* in, and thus the page is reloaded.
* @public
*/
authenticationComplete(payload) {
if (payload.loggedIn) {
window.location.reload();
} else {
const modal = new SignUpModal(payload);
this.modal.show(modal);
}
}
}

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