mirror of
https://github.com/flarum/core.git
synced 2025-07-11 03:46:23 +02:00
Mithril 2 update (#2255)
* Update frontend to Mithril 2 - Update Mithril version to v2.0.4 - Add Typescript typings for Mithril - Rename "props" to "attrs"; "initProps" to "initAttrs"; "m.prop" to "m.stream"; "m.withAttr" to "utils/withAttr". - Use Mithril 2's new lifecycle hooks - SubtreeRetainer has been rewritten to be more useful for the new system - Utils for forcing page re-initializations have been added (force attr in links, setRouteWithForcedRefresh util) - Other mechanical changes, following the upgrade guide - Remove some of the custom stuff in our Component base class - Introduce "fragments" for non-components that control their own DOM - Remove Mithril patches, introduce a few new ones (route attrs in <a>; - Redesign AlertManagerState `show` with 3 overloads: `show(children)`, `show(attrs, children)`, `show(componentClass, attrs, children)` - The `affixedSidebar` util has been replaced with an `AffixedSidebar` component Challenges: - `children` and `tag` are now reserved, and can not be used as attr names - Behavior of links to current page changed in Mithril. If moving to a page that is handled by the same component, the page component WILL NOT be re-initialized by default. Additional code to keep track of the current url is needed (See IndexPage, DiscussionPage, and UserPage for examples) - Native Promise rejections are shown on console when not handled - Instances of components can no longer be stored. The state pattern should be used instead. Refs #1821. Co-authored-by: Alexander Skvortsov <sasha.skvortsov109@gmail.com> Co-authored-by: Matthew Kilgore <tankerkiller125@gmail.com> Co-authored-by: Franz Liedke <franz@develophp.org>
This commit is contained in:
committed by
GitHub
parent
1321b8cc28
commit
71f3379fcc
11
js/package-lock.json
generated
11
js/package-lock.json
generated
@ -1075,6 +1075,11 @@
|
|||||||
"integrity": "sha512-rr+OQyAjxze7GgWrSaJwydHStIhHq2lvY3BOC2Mj7KnzI7XK0Uw1TOOdI9lDoajEbSWLiYgoo4f1R51erQfhPQ==",
|
"integrity": "sha512-rr+OQyAjxze7GgWrSaJwydHStIhHq2lvY3BOC2Mj7KnzI7XK0Uw1TOOdI9lDoajEbSWLiYgoo4f1R51erQfhPQ==",
|
||||||
"dev": true
|
"dev": true
|
||||||
},
|
},
|
||||||
|
"@types/mithril": {
|
||||||
|
"version": "2.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/mithril/-/mithril-2.0.3.tgz",
|
||||||
|
"integrity": "sha512-cZHOdO2IiXYeyjeDYdbOisSdfaJRzfmRo3zVzgu33IWTMA0KEQObp9fdvqcuYdPz93iJ1yCl19GcEjo/9yv+yA=="
|
||||||
|
},
|
||||||
"@types/parse-json": {
|
"@types/parse-json": {
|
||||||
"version": "4.0.0",
|
"version": "4.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/@types/parse-json/-/parse-json-4.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/@types/parse-json/-/parse-json-4.0.0.tgz",
|
||||||
@ -3807,9 +3812,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"mithril": {
|
"mithril": {
|
||||||
"version": "0.2.8",
|
"version": "2.0.4",
|
||||||
"resolved": "https://registry.npmjs.org/mithril/-/mithril-0.2.8.tgz",
|
"resolved": "https://registry.npmjs.org/mithril/-/mithril-2.0.4.tgz",
|
||||||
"integrity": "sha512-9XuGnVmS2OyFexUuP/CcJFFJjHLM+RGYBxyVRNyQ6khbMfDJIF/xyZ4zq18ZRfPagpFmWUFpjHd5ZqPULGZyNg=="
|
"integrity": "sha512-mgw+DMZlhMS4PpprF6dl7ZoeZq5GGcAuWnrg5e12MvaGauc4jzWsDZtVGRCktsiQczOEUr2K5teKbE5k44RlOg=="
|
||||||
},
|
},
|
||||||
"mixin-deep": {
|
"mixin-deep": {
|
||||||
"version": "1.3.2",
|
"version": "1.3.2",
|
||||||
|
@ -3,6 +3,7 @@
|
|||||||
"name": "@flarum/core",
|
"name": "@flarum/core",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@babel/preset-typescript": "^7.10.1",
|
"@babel/preset-typescript": "^7.10.1",
|
||||||
|
"@types/mithril": "^2.0.3",
|
||||||
"bootstrap": "^3.4.1",
|
"bootstrap": "^3.4.1",
|
||||||
"classnames": "^2.2.5",
|
"classnames": "^2.2.5",
|
||||||
"color-thief-browser": "^2.0.2",
|
"color-thief-browser": "^2.0.2",
|
||||||
@ -13,7 +14,7 @@
|
|||||||
"jquery.hotkeys": "^0.1.0",
|
"jquery.hotkeys": "^0.1.0",
|
||||||
"lodash-es": "^4.17.14",
|
"lodash-es": "^4.17.14",
|
||||||
"m.attrs.bidi": "github:tobscure/m.attrs.bidi",
|
"m.attrs.bidi": "github:tobscure/m.attrs.bidi",
|
||||||
"mithril": "^0.2.8",
|
"mithril": "^2.0.4",
|
||||||
"punycode": "^2.1.1",
|
"punycode": "^2.1.1",
|
||||||
"spin.js": "^3.1.0",
|
"spin.js": "^3.1.0",
|
||||||
"webpack": "^4.43.0",
|
"webpack": "^4.43.0",
|
||||||
|
48
js/shims.d.ts
vendored
Normal file
48
js/shims.d.ts
vendored
Normal file
@ -0,0 +1,48 @@
|
|||||||
|
// Mithril
|
||||||
|
import * as Mithril from 'mithril';
|
||||||
|
import Stream from 'mithril/stream';
|
||||||
|
|
||||||
|
// Other third-party libs
|
||||||
|
import * as _dayjs from 'dayjs';
|
||||||
|
import * as _$ from 'jquery';
|
||||||
|
|
||||||
|
// Globals from flarum/core
|
||||||
|
import Application from './src/common/Application';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Helpers that flarum/core patches into Mithril
|
||||||
|
*/
|
||||||
|
interface m extends Mithril.Static {
|
||||||
|
prop: typeof Stream;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Export Mithril typings globally.
|
||||||
|
*
|
||||||
|
* This lets us use these typings without an extra import everywhere we use
|
||||||
|
* Mithril in a TypeScript file.
|
||||||
|
*/
|
||||||
|
export as namespace Mithril;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* flarum/core exposes several extensions globally:
|
||||||
|
*
|
||||||
|
* - jQuery for convenient DOM manipulation
|
||||||
|
* - Mithril for VDOM and components
|
||||||
|
* - dayjs for date/time operations
|
||||||
|
*
|
||||||
|
* Since these are already part of the global namespace, extensions won't need
|
||||||
|
* to (and should not) bundle these themselves.
|
||||||
|
*/
|
||||||
|
declare global {
|
||||||
|
const $: typeof _$;
|
||||||
|
const m: m;
|
||||||
|
const dayjs: typeof _dayjs;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* All global variables owned by flarum/core.
|
||||||
|
*/
|
||||||
|
declare global {
|
||||||
|
const app: Application;
|
||||||
|
}
|
@ -27,13 +27,18 @@ export default class AdminApplication extends Application {
|
|||||||
* @inheritdoc
|
* @inheritdoc
|
||||||
*/
|
*/
|
||||||
mount() {
|
mount() {
|
||||||
m.mount(document.getElementById('app-navigation'), Navigation.component({ className: 'App-backControl', drawer: true }));
|
m.mount(document.getElementById('app-navigation'), { view: () => Navigation.component({ className: 'App-backControl', drawer: true }) });
|
||||||
m.mount(document.getElementById('header-navigation'), Navigation.component());
|
m.mount(document.getElementById('header-navigation'), Navigation);
|
||||||
m.mount(document.getElementById('header-primary'), HeaderPrimary.component());
|
m.mount(document.getElementById('header-primary'), HeaderPrimary);
|
||||||
m.mount(document.getElementById('header-secondary'), HeaderSecondary.component());
|
m.mount(document.getElementById('header-secondary'), HeaderSecondary);
|
||||||
m.mount(document.getElementById('admin-navigation'), AdminNav.component());
|
m.mount(document.getElementById('admin-navigation'), AdminNav);
|
||||||
|
|
||||||
|
// Mithril does not render the home route on https://example.com/admin, so
|
||||||
|
// we need to go to https://example.com/admin#/ explicitly.
|
||||||
|
if (!document.location.hash) document.location.hash = '#/';
|
||||||
|
|
||||||
|
m.route.prefix = '#';
|
||||||
|
|
||||||
m.route.mode = 'hash';
|
|
||||||
super.mount();
|
super.mount();
|
||||||
|
|
||||||
// If an extension has just been enabled, then we will run its settings
|
// If an extension has just been enabled, then we will run its settings
|
||||||
|
@ -10,11 +10,7 @@
|
|||||||
import LinkButton from '../../common/components/LinkButton';
|
import LinkButton from '../../common/components/LinkButton';
|
||||||
|
|
||||||
export default class AdminLinkButton extends LinkButton {
|
export default class AdminLinkButton extends LinkButton {
|
||||||
getButtonContent() {
|
getButtonContent(children) {
|
||||||
const content = super.getButtonContent();
|
return [...super.getButtonContent(children), <div className="AdminLinkButton-description">{this.attrs.description}</div>];
|
||||||
|
|
||||||
content.push(<div className="AdminLinkButton-description">{this.props.description}</div>);
|
|
||||||
|
|
||||||
return content;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -31,62 +31,74 @@ export default class AdminNav extends Component {
|
|||||||
|
|
||||||
items.add(
|
items.add(
|
||||||
'dashboard',
|
'dashboard',
|
||||||
AdminLinkButton.component({
|
AdminLinkButton.component(
|
||||||
href: app.route('dashboard'),
|
{
|
||||||
icon: 'far fa-chart-bar',
|
href: app.route('dashboard'),
|
||||||
children: app.translator.trans('core.admin.nav.dashboard_button'),
|
icon: 'far fa-chart-bar',
|
||||||
description: app.translator.trans('core.admin.nav.dashboard_text'),
|
description: app.translator.trans('core.admin.nav.dashboard_text'),
|
||||||
})
|
},
|
||||||
|
app.translator.trans('core.admin.nav.dashboard_button')
|
||||||
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
items.add(
|
items.add(
|
||||||
'basics',
|
'basics',
|
||||||
AdminLinkButton.component({
|
AdminLinkButton.component(
|
||||||
href: app.route('basics'),
|
{
|
||||||
icon: 'fas fa-pencil-alt',
|
href: app.route('basics'),
|
||||||
children: app.translator.trans('core.admin.nav.basics_button'),
|
icon: 'fas fa-pencil-alt',
|
||||||
description: app.translator.trans('core.admin.nav.basics_text'),
|
description: app.translator.trans('core.admin.nav.basics_text'),
|
||||||
})
|
},
|
||||||
|
app.translator.trans('core.admin.nav.basics_button')
|
||||||
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
items.add(
|
items.add(
|
||||||
'mail',
|
'mail',
|
||||||
AdminLinkButton.component({
|
AdminLinkButton.component(
|
||||||
href: app.route('mail'),
|
{
|
||||||
icon: 'fas fa-envelope',
|
href: app.route('mail'),
|
||||||
children: app.translator.trans('core.admin.nav.email_button'),
|
icon: 'fas fa-envelope',
|
||||||
description: app.translator.trans('core.admin.nav.email_text'),
|
description: app.translator.trans('core.admin.nav.email_text'),
|
||||||
})
|
},
|
||||||
|
app.translator.trans('core.admin.nav.email_button')
|
||||||
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
items.add(
|
items.add(
|
||||||
'permissions',
|
'permissions',
|
||||||
AdminLinkButton.component({
|
AdminLinkButton.component(
|
||||||
href: app.route('permissions'),
|
{
|
||||||
icon: 'fas fa-key',
|
href: app.route('permissions'),
|
||||||
children: app.translator.trans('core.admin.nav.permissions_button'),
|
icon: 'fas fa-key',
|
||||||
description: app.translator.trans('core.admin.nav.permissions_text'),
|
description: app.translator.trans('core.admin.nav.permissions_text'),
|
||||||
})
|
},
|
||||||
|
app.translator.trans('core.admin.nav.permissions_button')
|
||||||
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
items.add(
|
items.add(
|
||||||
'appearance',
|
'appearance',
|
||||||
AdminLinkButton.component({
|
AdminLinkButton.component(
|
||||||
href: app.route('appearance'),
|
{
|
||||||
icon: 'fas fa-paint-brush',
|
href: app.route('appearance'),
|
||||||
children: app.translator.trans('core.admin.nav.appearance_button'),
|
icon: 'fas fa-paint-brush',
|
||||||
description: app.translator.trans('core.admin.nav.appearance_text'),
|
description: app.translator.trans('core.admin.nav.appearance_text'),
|
||||||
})
|
},
|
||||||
|
app.translator.trans('core.admin.nav.appearance_button')
|
||||||
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
items.add(
|
items.add(
|
||||||
'extensions',
|
'extensions',
|
||||||
AdminLinkButton.component({
|
AdminLinkButton.component(
|
||||||
href: app.route('extensions'),
|
{
|
||||||
icon: 'fas fa-puzzle-piece',
|
href: app.route('extensions'),
|
||||||
children: app.translator.trans('core.admin.nav.extensions_button'),
|
icon: 'fas fa-puzzle-piece',
|
||||||
description: app.translator.trans('core.admin.nav.extensions_text'),
|
description: app.translator.trans('core.admin.nav.extensions_text'),
|
||||||
})
|
},
|
||||||
|
app.translator.trans('core.admin.nav.extensions_button')
|
||||||
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
return items;
|
return items;
|
||||||
|
@ -6,15 +6,16 @@ import EditCustomHeaderModal from './EditCustomHeaderModal';
|
|||||||
import EditCustomFooterModal from './EditCustomFooterModal';
|
import EditCustomFooterModal from './EditCustomFooterModal';
|
||||||
import UploadImageButton from './UploadImageButton';
|
import UploadImageButton from './UploadImageButton';
|
||||||
import saveSettings from '../utils/saveSettings';
|
import saveSettings from '../utils/saveSettings';
|
||||||
|
import withAttr from '../../common/utils/withAttr';
|
||||||
|
|
||||||
export default class AppearancePage extends Page {
|
export default class AppearancePage extends Page {
|
||||||
init() {
|
oninit(vnode) {
|
||||||
super.init();
|
super.oninit(vnode);
|
||||||
|
|
||||||
this.primaryColor = m.prop(app.data.settings.theme_primary_color);
|
this.primaryColor = m.stream(app.data.settings.theme_primary_color);
|
||||||
this.secondaryColor = m.prop(app.data.settings.theme_secondary_color);
|
this.secondaryColor = m.stream(app.data.settings.theme_secondary_color);
|
||||||
this.darkMode = m.prop(app.data.settings.theme_dark_mode);
|
this.darkMode = m.stream(app.data.settings.theme_dark_mode);
|
||||||
this.coloredHeader = m.prop(app.data.settings.theme_colored_header);
|
this.coloredHeader = m.stream(app.data.settings.theme_colored_header);
|
||||||
}
|
}
|
||||||
|
|
||||||
view() {
|
view() {
|
||||||
@ -27,40 +28,34 @@ export default class AppearancePage extends Page {
|
|||||||
<div className="helpText">{app.translator.trans('core.admin.appearance.colors_text')}</div>
|
<div className="helpText">{app.translator.trans('core.admin.appearance.colors_text')}</div>
|
||||||
|
|
||||||
<div className="AppearancePage-colors-input">
|
<div className="AppearancePage-colors-input">
|
||||||
<input
|
<input className="FormControl" type="text" placeholder="#aaaaaa" bidi={this.primaryColor} />
|
||||||
className="FormControl"
|
<input className="FormControl" type="text" placeholder="#aaaaaa" bidi={this.secondaryColor} />
|
||||||
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>
|
</div>
|
||||||
|
|
||||||
{Switch.component({
|
{Switch.component(
|
||||||
state: this.darkMode(),
|
{
|
||||||
children: app.translator.trans('core.admin.appearance.dark_mode_label'),
|
state: this.darkMode(),
|
||||||
onchange: this.darkMode,
|
onchange: this.darkMode,
|
||||||
})}
|
},
|
||||||
|
app.translator.trans('core.admin.appearance.dark_mode_label')
|
||||||
|
)}
|
||||||
|
|
||||||
{Switch.component({
|
{Switch.component(
|
||||||
state: this.coloredHeader(),
|
{
|
||||||
children: app.translator.trans('core.admin.appearance.colored_header_label'),
|
state: this.coloredHeader(),
|
||||||
onchange: this.coloredHeader,
|
onchange: this.coloredHeader,
|
||||||
})}
|
},
|
||||||
|
app.translator.trans('core.admin.appearance.colored_header_label')
|
||||||
|
)}
|
||||||
|
|
||||||
{Button.component({
|
{Button.component(
|
||||||
className: 'Button Button--primary',
|
{
|
||||||
type: 'submit',
|
className: 'Button Button--primary',
|
||||||
children: app.translator.trans('core.admin.appearance.submit_button'),
|
type: 'submit',
|
||||||
loading: this.loading,
|
loading: this.loading,
|
||||||
})}
|
},
|
||||||
|
app.translator.trans('core.admin.appearance.submit_button')
|
||||||
|
)}
|
||||||
</fieldset>
|
</fieldset>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
@ -79,31 +74,37 @@ export default class AppearancePage extends Page {
|
|||||||
<fieldset>
|
<fieldset>
|
||||||
<legend>{app.translator.trans('core.admin.appearance.custom_header_heading')}</legend>
|
<legend>{app.translator.trans('core.admin.appearance.custom_header_heading')}</legend>
|
||||||
<div className="helpText">{app.translator.trans('core.admin.appearance.custom_header_text')}</div>
|
<div className="helpText">{app.translator.trans('core.admin.appearance.custom_header_text')}</div>
|
||||||
{Button.component({
|
{Button.component(
|
||||||
className: 'Button',
|
{
|
||||||
children: app.translator.trans('core.admin.appearance.edit_header_button'),
|
className: 'Button',
|
||||||
onclick: () => app.modal.show(EditCustomHeaderModal),
|
onclick: () => app.modal.show(EditCustomHeaderModal),
|
||||||
})}
|
},
|
||||||
|
app.translator.trans('core.admin.appearance.edit_header_button')
|
||||||
|
)}
|
||||||
</fieldset>
|
</fieldset>
|
||||||
|
|
||||||
<fieldset>
|
<fieldset>
|
||||||
<legend>{app.translator.trans('core.admin.appearance.custom_footer_heading')}</legend>
|
<legend>{app.translator.trans('core.admin.appearance.custom_footer_heading')}</legend>
|
||||||
<div className="helpText">{app.translator.trans('core.admin.appearance.custom_footer_text')}</div>
|
<div className="helpText">{app.translator.trans('core.admin.appearance.custom_footer_text')}</div>
|
||||||
{Button.component({
|
{Button.component(
|
||||||
className: 'Button',
|
{
|
||||||
children: app.translator.trans('core.admin.appearance.edit_footer_button'),
|
className: 'Button',
|
||||||
onclick: () => app.modal.show(EditCustomFooterModal),
|
onclick: () => app.modal.show(EditCustomFooterModal),
|
||||||
})}
|
},
|
||||||
|
app.translator.trans('core.admin.appearance.edit_footer_button')
|
||||||
|
)}
|
||||||
</fieldset>
|
</fieldset>
|
||||||
|
|
||||||
<fieldset>
|
<fieldset>
|
||||||
<legend>{app.translator.trans('core.admin.appearance.custom_styles_heading')}</legend>
|
<legend>{app.translator.trans('core.admin.appearance.custom_styles_heading')}</legend>
|
||||||
<div className="helpText">{app.translator.trans('core.admin.appearance.custom_styles_text')}</div>
|
<div className="helpText">{app.translator.trans('core.admin.appearance.custom_styles_text')}</div>
|
||||||
{Button.component({
|
{Button.component(
|
||||||
className: 'Button',
|
{
|
||||||
children: app.translator.trans('core.admin.appearance.edit_css_button'),
|
className: 'Button',
|
||||||
onclick: () => app.modal.show(EditCustomCssModal),
|
onclick: () => app.modal.show(EditCustomCssModal),
|
||||||
})}
|
},
|
||||||
|
app.translator.trans('core.admin.appearance.edit_css_button')
|
||||||
|
)}
|
||||||
</fieldset>
|
</fieldset>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
@ -5,10 +5,11 @@ import Button from '../../common/components/Button';
|
|||||||
import saveSettings from '../utils/saveSettings';
|
import saveSettings from '../utils/saveSettings';
|
||||||
import ItemList from '../../common/utils/ItemList';
|
import ItemList from '../../common/utils/ItemList';
|
||||||
import Switch from '../../common/components/Switch';
|
import Switch from '../../common/components/Switch';
|
||||||
|
import withAttr from '../../common/utils/withAttr';
|
||||||
|
|
||||||
export default class BasicsPage extends Page {
|
export default class BasicsPage extends Page {
|
||||||
init() {
|
oninit(vnode) {
|
||||||
super.init();
|
super.oninit(vnode);
|
||||||
|
|
||||||
this.loading = false;
|
this.loading = false;
|
||||||
|
|
||||||
@ -25,7 +26,7 @@ export default class BasicsPage extends Page {
|
|||||||
this.values = {};
|
this.values = {};
|
||||||
|
|
||||||
const settings = app.data.settings;
|
const settings = app.data.settings;
|
||||||
this.fields.forEach((key) => (this.values[key] = m.prop(settings[key])));
|
this.fields.forEach((key) => (this.values[key] = m.stream(settings[key])));
|
||||||
|
|
||||||
this.localeOptions = {};
|
this.localeOptions = {};
|
||||||
const locales = app.data.locales;
|
const locales = app.data.locales;
|
||||||
@ -49,45 +50,51 @@ export default class BasicsPage extends Page {
|
|||||||
<div className="BasicsPage">
|
<div className="BasicsPage">
|
||||||
<div className="container">
|
<div className="container">
|
||||||
<form onsubmit={this.onsubmit.bind(this)}>
|
<form onsubmit={this.onsubmit.bind(this)}>
|
||||||
{FieldSet.component({
|
{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)} />],
|
label: app.translator.trans('core.admin.basics.forum_title_heading'),
|
||||||
})}
|
},
|
||||||
|
[<input className="FormControl" bidi={this.values.forum_title} />]
|
||||||
|
)}
|
||||||
|
|
||||||
{FieldSet.component({
|
{FieldSet.component(
|
||||||
label: app.translator.trans('core.admin.basics.forum_description_heading'),
|
{
|
||||||
children: [
|
label: app.translator.trans('core.admin.basics.forum_description_heading'),
|
||||||
|
},
|
||||||
|
[
|
||||||
<div className="helpText">{app.translator.trans('core.admin.basics.forum_description_text')}</div>,
|
<div className="helpText">{app.translator.trans('core.admin.basics.forum_description_text')}</div>,
|
||||||
<textarea
|
<textarea className="FormControl" bidi={this.values.forum_description} />,
|
||||||
className="FormControl"
|
]
|
||||||
value={this.values.forum_description()}
|
)}
|
||||||
oninput={m.withAttr('value', this.values.forum_description)}
|
|
||||||
/>,
|
|
||||||
],
|
|
||||||
})}
|
|
||||||
|
|
||||||
{Object.keys(this.localeOptions).length > 1
|
{Object.keys(this.localeOptions).length > 1
|
||||||
? FieldSet.component({
|
? FieldSet.component(
|
||||||
label: app.translator.trans('core.admin.basics.default_language_heading'),
|
{
|
||||||
children: [
|
label: app.translator.trans('core.admin.basics.default_language_heading'),
|
||||||
|
},
|
||||||
|
[
|
||||||
Select.component({
|
Select.component({
|
||||||
options: this.localeOptions,
|
options: this.localeOptions,
|
||||||
value: this.values.default_locale(),
|
value: this.values.default_locale(),
|
||||||
onchange: this.values.default_locale,
|
onchange: this.values.default_locale,
|
||||||
}),
|
}),
|
||||||
Switch.component({
|
Switch.component(
|
||||||
state: this.values.show_language_selector(),
|
{
|
||||||
onchange: this.values.show_language_selector,
|
state: this.values.show_language_selector(),
|
||||||
children: app.translator.trans('core.admin.basics.show_language_selector_label'),
|
onchange: this.values.show_language_selector,
|
||||||
}),
|
},
|
||||||
],
|
app.translator.trans('core.admin.basics.show_language_selector_label')
|
||||||
})
|
),
|
||||||
|
]
|
||||||
|
)
|
||||||
: ''}
|
: ''}
|
||||||
|
|
||||||
{FieldSet.component({
|
{FieldSet.component(
|
||||||
label: app.translator.trans('core.admin.basics.home_page_heading'),
|
{
|
||||||
className: 'BasicsPage-homePage',
|
label: app.translator.trans('core.admin.basics.home_page_heading'),
|
||||||
children: [
|
className: 'BasicsPage-homePage',
|
||||||
|
},
|
||||||
|
[
|
||||||
<div className="helpText">{app.translator.trans('core.admin.basics.home_page_text')}</div>,
|
<div className="helpText">{app.translator.trans('core.admin.basics.home_page_text')}</div>,
|
||||||
this.homePageItems()
|
this.homePageItems()
|
||||||
.toArray()
|
.toArray()
|
||||||
@ -98,51 +105,52 @@ export default class BasicsPage extends Page {
|
|||||||
name="homePage"
|
name="homePage"
|
||||||
value={path}
|
value={path}
|
||||||
checked={this.values.default_route() === path}
|
checked={this.values.default_route() === path}
|
||||||
onclick={m.withAttr('value', this.values.default_route)}
|
onclick={withAttr('value', this.values.default_route)}
|
||||||
/>
|
/>
|
||||||
{label}
|
{label}
|
||||||
</label>
|
</label>
|
||||||
)),
|
)),
|
||||||
],
|
]
|
||||||
})}
|
)}
|
||||||
|
|
||||||
{FieldSet.component({
|
{FieldSet.component(
|
||||||
label: app.translator.trans('core.admin.basics.welcome_banner_heading'),
|
{
|
||||||
className: 'BasicsPage-welcomeBanner',
|
label: app.translator.trans('core.admin.basics.welcome_banner_heading'),
|
||||||
children: [
|
className: 'BasicsPage-welcomeBanner',
|
||||||
|
},
|
||||||
|
[
|
||||||
<div className="helpText">{app.translator.trans('core.admin.basics.welcome_banner_text')}</div>,
|
<div className="helpText">{app.translator.trans('core.admin.basics.welcome_banner_text')}</div>,
|
||||||
<div className="BasicsPage-welcomeBanner-input">
|
<div className="BasicsPage-welcomeBanner-input">
|
||||||
<input className="FormControl" value={this.values.welcome_title()} oninput={m.withAttr('value', this.values.welcome_title)} />
|
<input className="FormControl" bidi={this.values.welcome_title} />
|
||||||
<textarea
|
<textarea className="FormControl" bidi={this.values.welcome_message} />
|
||||||
className="FormControl"
|
|
||||||
value={this.values.welcome_message()}
|
|
||||||
oninput={m.withAttr('value', this.values.welcome_message)}
|
|
||||||
/>
|
|
||||||
</div>,
|
</div>,
|
||||||
],
|
]
|
||||||
})}
|
)}
|
||||||
|
|
||||||
{Object.keys(this.displayNameOptions).length > 1
|
{Object.keys(this.displayNameOptions).length > 1
|
||||||
? FieldSet.component({
|
? FieldSet.component(
|
||||||
label: app.translator.trans('core.admin.basics.display_name_heading'),
|
{
|
||||||
children: [
|
label: app.translator.trans('core.admin.basics.display_name_heading'),
|
||||||
|
},
|
||||||
|
[
|
||||||
<div className="helpText">{app.translator.trans('core.admin.basics.display_name_text')}</div>,
|
<div className="helpText">{app.translator.trans('core.admin.basics.display_name_text')}</div>,
|
||||||
Select.component({
|
Select.component({
|
||||||
options: this.displayNameOptions,
|
options: this.displayNameOptions,
|
||||||
value: this.values.display_name_driver(),
|
bidi: this.values.display_name_driver,
|
||||||
onchange: this.values.display_name_driver,
|
|
||||||
}),
|
}),
|
||||||
],
|
]
|
||||||
})
|
)
|
||||||
: ''}
|
: ''}
|
||||||
|
|
||||||
{Button.component({
|
{Button.component(
|
||||||
type: 'submit',
|
{
|
||||||
className: 'Button Button--primary',
|
type: 'submit',
|
||||||
children: app.translator.trans('core.admin.basics.submit_button'),
|
className: 'Button Button--primary',
|
||||||
loading: this.loading,
|
loading: this.loading,
|
||||||
disabled: !this.changed(),
|
disabled: !this.changed(),
|
||||||
})}
|
},
|
||||||
|
app.translator.trans('core.admin.basics.submit_button')
|
||||||
|
)}
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -185,10 +193,7 @@ export default class BasicsPage extends Page {
|
|||||||
|
|
||||||
saveSettings(settings)
|
saveSettings(settings)
|
||||||
.then(() => {
|
.then(() => {
|
||||||
this.successAlert = app.alerts.show({
|
this.successAlert = app.alerts.show({ type: 'success' }, app.translator.trans('core.admin.basics.saved_message'));
|
||||||
type: 'success',
|
|
||||||
children: app.translator.trans('core.admin.basics.saved_message'),
|
|
||||||
});
|
|
||||||
})
|
})
|
||||||
.catch(() => {})
|
.catch(() => {})
|
||||||
.then(() => {
|
.then(() => {
|
||||||
|
@ -4,20 +4,23 @@ import Badge from '../../common/components/Badge';
|
|||||||
import Group from '../../common/models/Group';
|
import Group from '../../common/models/Group';
|
||||||
import ItemList from '../../common/utils/ItemList';
|
import ItemList from '../../common/utils/ItemList';
|
||||||
import Switch from '../../common/components/Switch';
|
import Switch from '../../common/components/Switch';
|
||||||
|
import withAttr from '../../common/utils/withAttr';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The `EditGroupModal` component shows a modal dialog which allows the user
|
* The `EditGroupModal` component shows a modal dialog which allows the user
|
||||||
* to create or edit a group.
|
* to create or edit a group.
|
||||||
*/
|
*/
|
||||||
export default class EditGroupModal extends Modal {
|
export default class EditGroupModal extends Modal {
|
||||||
init() {
|
oninit(vnode) {
|
||||||
this.group = this.props.group || app.store.createRecord('groups');
|
super.oninit(vnode);
|
||||||
|
|
||||||
this.nameSingular = m.prop(this.group.nameSingular() || '');
|
this.group = this.attrs.group || app.store.createRecord('groups');
|
||||||
this.namePlural = m.prop(this.group.namePlural() || '');
|
|
||||||
this.icon = m.prop(this.group.icon() || '');
|
this.nameSingular = m.stream(this.group.nameSingular() || '');
|
||||||
this.color = m.prop(this.group.color() || '');
|
this.namePlural = m.stream(this.group.namePlural() || '');
|
||||||
this.isHidden = m.prop(this.group.isHidden() || false);
|
this.icon = m.stream(this.group.icon() || '');
|
||||||
|
this.color = m.stream(this.group.color() || '');
|
||||||
|
this.isHidden = m.stream(this.group.isHidden() || false);
|
||||||
}
|
}
|
||||||
|
|
||||||
className() {
|
className() {
|
||||||
@ -53,18 +56,8 @@ export default class EditGroupModal extends Modal {
|
|||||||
<div className="Form-group">
|
<div className="Form-group">
|
||||||
<label>{app.translator.trans('core.admin.edit_group.name_label')}</label>
|
<label>{app.translator.trans('core.admin.edit_group.name_label')}</label>
|
||||||
<div className="EditGroupModal-name-input">
|
<div className="EditGroupModal-name-input">
|
||||||
<input
|
<input className="FormControl" placeholder={app.translator.trans('core.admin.edit_group.singular_placeholder')} bidi={this.nameSingular} />
|
||||||
className="FormControl"
|
<input className="FormControl" placeholder={app.translator.trans('core.admin.edit_group.plural_placeholder')} bidi={this.namePlural} />
|
||||||
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>
|
||||||
</div>,
|
</div>,
|
||||||
30
|
30
|
||||||
@ -74,7 +67,7 @@ export default class EditGroupModal extends Modal {
|
|||||||
'color',
|
'color',
|
||||||
<div className="Form-group">
|
<div className="Form-group">
|
||||||
<label>{app.translator.trans('core.admin.edit_group.color_label')}</label>
|
<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)} />
|
<input className="FormControl" placeholder="#aaaaaa" bidi={this.color} />
|
||||||
</div>,
|
</div>,
|
||||||
20
|
20
|
||||||
);
|
);
|
||||||
@ -86,7 +79,7 @@ export default class EditGroupModal extends Modal {
|
|||||||
<div className="helpText">
|
<div className="helpText">
|
||||||
{app.translator.trans('core.admin.edit_group.icon_text', { a: <a href="https://fontawesome.com/icons?m=free" tabindex="-1" /> })}
|
{app.translator.trans('core.admin.edit_group.icon_text', { a: <a href="https://fontawesome.com/icons?m=free" tabindex="-1" /> })}
|
||||||
</div>
|
</div>
|
||||||
<input className="FormControl" placeholder="fas fa-bolt" value={this.icon()} oninput={m.withAttr('value', this.icon)} />
|
<input className="FormControl" placeholder="fas fa-bolt" bidi={this.icon} />
|
||||||
</div>,
|
</div>,
|
||||||
10
|
10
|
||||||
);
|
);
|
||||||
@ -94,11 +87,13 @@ export default class EditGroupModal extends Modal {
|
|||||||
items.add(
|
items.add(
|
||||||
'hidden',
|
'hidden',
|
||||||
<div className="Form-group">
|
<div className="Form-group">
|
||||||
{Switch.component({
|
{Switch.component(
|
||||||
state: !!Number(this.isHidden()),
|
{
|
||||||
children: app.translator.trans('core.admin.edit_group.hide_label'),
|
state: !!Number(this.isHidden()),
|
||||||
onchange: this.isHidden,
|
onchange: this.isHidden,
|
||||||
})}
|
},
|
||||||
|
app.translator.trans('core.admin.edit_group.hide_label')
|
||||||
|
)}
|
||||||
</div>,
|
</div>,
|
||||||
10
|
10
|
||||||
);
|
);
|
||||||
@ -106,12 +101,14 @@ export default class EditGroupModal extends Modal {
|
|||||||
items.add(
|
items.add(
|
||||||
'submit',
|
'submit',
|
||||||
<div className="Form-group">
|
<div className="Form-group">
|
||||||
{Button.component({
|
{Button.component(
|
||||||
type: 'submit',
|
{
|
||||||
className: 'Button Button--primary EditGroupModal-save',
|
type: 'submit',
|
||||||
loading: this.loading,
|
className: 'Button Button--primary EditGroupModal-save',
|
||||||
children: app.translator.trans('core.admin.edit_group.submit_button'),
|
loading: this.loading,
|
||||||
})}
|
},
|
||||||
|
app.translator.trans('core.admin.edit_group.submit_button')
|
||||||
|
)}
|
||||||
{this.group.exists && this.group.id() !== Group.ADMINISTRATOR_ID ? (
|
{this.group.exists && this.group.id() !== Group.ADMINISTRATOR_ID ? (
|
||||||
<button type="button" className="Button EditGroupModal-delete" onclick={this.deleteGroup.bind(this)}>
|
<button type="button" className="Button EditGroupModal-delete" onclick={this.deleteGroup.bind(this)}>
|
||||||
{app.translator.trans('core.admin.edit_group.delete_button')}
|
{app.translator.trans('core.admin.edit_group.delete_button')}
|
||||||
|
@ -12,12 +12,14 @@ export default class ExtensionsPage extends Page {
|
|||||||
<div className="ExtensionsPage">
|
<div className="ExtensionsPage">
|
||||||
<div className="ExtensionsPage-header">
|
<div className="ExtensionsPage-header">
|
||||||
<div className="container">
|
<div className="container">
|
||||||
{Button.component({
|
{Button.component(
|
||||||
children: app.translator.trans('core.admin.extensions.add_button'),
|
{
|
||||||
icon: 'fas fa-plus',
|
icon: 'fas fa-plus',
|
||||||
className: 'Button Button--primary',
|
className: 'Button Button--primary',
|
||||||
onclick: () => app.modal.show(AddExtensionModal),
|
onclick: () => app.modal.show(AddExtensionModal),
|
||||||
})}
|
},
|
||||||
|
app.translator.trans('core.admin.extensions.add_button')
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -72,31 +74,35 @@ export default class ExtensionsPage extends Page {
|
|||||||
if (app.extensionSettings[name]) {
|
if (app.extensionSettings[name]) {
|
||||||
items.add(
|
items.add(
|
||||||
'settings',
|
'settings',
|
||||||
Button.component({
|
Button.component(
|
||||||
icon: 'fas fa-cog',
|
{
|
||||||
children: app.translator.trans('core.admin.extensions.settings_button'),
|
icon: 'fas fa-cog',
|
||||||
onclick: app.extensionSettings[name],
|
onclick: app.extensionSettings[name],
|
||||||
})
|
},
|
||||||
|
app.translator.trans('core.admin.extensions.settings_button')
|
||||||
|
)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!enabled) {
|
if (!enabled) {
|
||||||
items.add(
|
items.add(
|
||||||
'uninstall',
|
'uninstall',
|
||||||
Button.component({
|
Button.component(
|
||||||
icon: 'far fa-trash-alt',
|
{
|
||||||
children: app.translator.trans('core.admin.extensions.uninstall_button'),
|
icon: 'far fa-trash-alt',
|
||||||
onclick: () => {
|
onclick: () => {
|
||||||
app
|
app
|
||||||
.request({
|
.request({
|
||||||
url: app.forum.attribute('apiUrl') + '/extensions/' + name,
|
url: app.forum.attribute('apiUrl') + '/extensions/' + name,
|
||||||
method: 'DELETE',
|
method: 'DELETE',
|
||||||
})
|
})
|
||||||
.then(() => window.location.reload());
|
.then(() => window.location.reload());
|
||||||
|
|
||||||
app.modal.show(LoadingModal);
|
app.modal.show(LoadingModal);
|
||||||
|
},
|
||||||
},
|
},
|
||||||
})
|
app.translator.trans('core.admin.extensions.uninstall_button')
|
||||||
|
)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -116,7 +122,7 @@ export default class ExtensionsPage extends Page {
|
|||||||
.request({
|
.request({
|
||||||
url: app.forum.attribute('apiUrl') + '/extensions/' + id,
|
url: app.forum.attribute('apiUrl') + '/extensions/' + id,
|
||||||
method: 'PATCH',
|
method: 'PATCH',
|
||||||
data: { enabled: !enabled },
|
body: { enabled: !enabled },
|
||||||
})
|
})
|
||||||
.then(() => {
|
.then(() => {
|
||||||
if (!enabled) localStorage.setItem('enabledExtension', id);
|
if (!enabled) localStorage.setItem('enabledExtension', id);
|
||||||
|
@ -11,13 +11,6 @@ export default class HeaderSecondary extends Component {
|
|||||||
return <ul className="Header-controls">{listItems(this.items().toArray())}</ul>;
|
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.
|
* Build an item list for the controls.
|
||||||
*
|
*
|
||||||
|
@ -5,10 +5,11 @@ import Alert from '../../common/components/Alert';
|
|||||||
import Select from '../../common/components/Select';
|
import Select from '../../common/components/Select';
|
||||||
import LoadingIndicator from '../../common/components/LoadingIndicator';
|
import LoadingIndicator from '../../common/components/LoadingIndicator';
|
||||||
import saveSettings from '../utils/saveSettings';
|
import saveSettings from '../utils/saveSettings';
|
||||||
|
import withAttr from '../../common/utils/withAttr';
|
||||||
|
|
||||||
export default class MailPage extends Page {
|
export default class MailPage extends Page {
|
||||||
init() {
|
oninit(vnode) {
|
||||||
super.init();
|
super.oninit(vnode);
|
||||||
|
|
||||||
this.saving = false;
|
this.saving = false;
|
||||||
this.sendingTest = false;
|
this.sendingTest = false;
|
||||||
@ -24,7 +25,7 @@ export default class MailPage extends Page {
|
|||||||
this.status = { sending: false, errors: {} };
|
this.status = { sending: false, errors: {} };
|
||||||
|
|
||||||
const settings = app.data.settings;
|
const settings = app.data.settings;
|
||||||
this.fields.forEach((key) => (this.values[key] = m.prop(settings[key])));
|
this.fields.forEach((key) => (this.values[key] = m.stream(settings[key])));
|
||||||
|
|
||||||
app
|
app
|
||||||
.request({
|
.request({
|
||||||
@ -39,7 +40,7 @@ export default class MailPage extends Page {
|
|||||||
for (const driver in this.driverFields) {
|
for (const driver in this.driverFields) {
|
||||||
for (const field in this.driverFields[driver]) {
|
for (const field in this.driverFields[driver]) {
|
||||||
this.fields.push(field);
|
this.fields.push(field);
|
||||||
this.values[field] = m.prop(settings[field]);
|
this.values[field] = m.stream(settings[field]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -69,23 +70,27 @@ export default class MailPage extends Page {
|
|||||||
<h2>{app.translator.trans('core.admin.email.heading')}</h2>
|
<h2>{app.translator.trans('core.admin.email.heading')}</h2>
|
||||||
<div className="helpText">{app.translator.trans('core.admin.email.text')}</div>
|
<div className="helpText">{app.translator.trans('core.admin.email.text')}</div>
|
||||||
|
|
||||||
{FieldSet.component({
|
{FieldSet.component(
|
||||||
label: app.translator.trans('core.admin.email.addresses_heading'),
|
{
|
||||||
className: 'MailPage-MailSettings',
|
label: app.translator.trans('core.admin.email.addresses_heading'),
|
||||||
children: [
|
className: 'MailPage-MailSettings',
|
||||||
|
},
|
||||||
|
[
|
||||||
<div className="MailPage-MailSettings-input">
|
<div className="MailPage-MailSettings-input">
|
||||||
<label>
|
<label>
|
||||||
{app.translator.trans('core.admin.email.from_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)} />
|
<input className="FormControl" bidi={this.values.mail_from} />
|
||||||
</label>
|
</label>
|
||||||
</div>,
|
</div>,
|
||||||
],
|
]
|
||||||
})}
|
)}
|
||||||
|
|
||||||
{FieldSet.component({
|
{FieldSet.component(
|
||||||
label: app.translator.trans('core.admin.email.driver_heading'),
|
{
|
||||||
className: 'MailPage-MailSettings',
|
label: app.translator.trans('core.admin.email.driver_heading'),
|
||||||
children: [
|
className: 'MailPage-MailSettings',
|
||||||
|
},
|
||||||
|
[
|
||||||
<div className="MailPage-MailSettings-input">
|
<div className="MailPage-MailSettings-input">
|
||||||
<label>
|
<label>
|
||||||
{app.translator.trans('core.admin.email.driver_label')}
|
{app.translator.trans('core.admin.email.driver_label')}
|
||||||
@ -96,20 +101,24 @@ export default class MailPage extends Page {
|
|||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
</div>,
|
</div>,
|
||||||
],
|
]
|
||||||
})}
|
)}
|
||||||
|
|
||||||
{this.status.sending ||
|
{this.status.sending ||
|
||||||
Alert.component({
|
Alert.component(
|
||||||
children: app.translator.trans('core.admin.email.not_sending_message'),
|
{
|
||||||
dismissible: false,
|
dismissible: false,
|
||||||
})}
|
},
|
||||||
|
app.translator.trans('core.admin.email.not_sending_message')
|
||||||
|
)}
|
||||||
|
|
||||||
{fieldKeys.length > 0 &&
|
{fieldKeys.length > 0 &&
|
||||||
FieldSet.component({
|
FieldSet.component(
|
||||||
label: app.translator.trans(`core.admin.email.${this.values.mail_driver()}_heading`),
|
{
|
||||||
className: 'MailPage-MailSettings',
|
label: app.translator.trans(`core.admin.email.${this.values.mail_driver()}_heading`),
|
||||||
children: [
|
className: 'MailPage-MailSettings',
|
||||||
|
},
|
||||||
|
[
|
||||||
<div className="MailPage-MailSettings-input">
|
<div className="MailPage-MailSettings-input">
|
||||||
{fieldKeys.map((field) => [
|
{fieldKeys.map((field) => [
|
||||||
<label>
|
<label>
|
||||||
@ -119,31 +128,37 @@ export default class MailPage extends Page {
|
|||||||
this.status.errors[field] && <p className="ValidationError">{this.status.errors[field]}</p>,
|
this.status.errors[field] && <p className="ValidationError">{this.status.errors[field]}</p>,
|
||||||
])}
|
])}
|
||||||
</div>,
|
</div>,
|
||||||
],
|
]
|
||||||
})}
|
)}
|
||||||
|
|
||||||
<FieldSet>
|
<FieldSet>
|
||||||
{Button.component({
|
{Button.component(
|
||||||
type: 'submit',
|
{
|
||||||
className: 'Button Button--primary',
|
type: 'submit',
|
||||||
children: app.translator.trans('core.admin.email.submit_button'),
|
className: 'Button Button--primary',
|
||||||
disabled: !this.changed(),
|
disabled: !this.changed(),
|
||||||
})}
|
},
|
||||||
|
app.translator.trans('core.admin.email.submit_button')
|
||||||
|
)}
|
||||||
</FieldSet>
|
</FieldSet>
|
||||||
|
|
||||||
{FieldSet.component({
|
{FieldSet.component(
|
||||||
label: app.translator.trans('core.admin.email.send_test_mail_heading'),
|
{
|
||||||
className: 'MailPage-MailSettings',
|
label: app.translator.trans('core.admin.email.send_test_mail_heading'),
|
||||||
children: [
|
className: 'MailPage-MailSettings',
|
||||||
|
},
|
||||||
|
[
|
||||||
<div className="helpText">{app.translator.trans('core.admin.email.send_test_mail_text', { email: app.session.user.email() })}</div>,
|
<div className="helpText">{app.translator.trans('core.admin.email.send_test_mail_text', { email: app.session.user.email() })}</div>,
|
||||||
Button.component({
|
Button.component(
|
||||||
className: 'Button Button--primary',
|
{
|
||||||
children: app.translator.trans('core.admin.email.send_test_mail_button'),
|
className: 'Button Button--primary',
|
||||||
disabled: this.sendingTest || this.changed(),
|
disabled: this.sendingTest || this.changed(),
|
||||||
onclick: () => this.sendTestEmail(),
|
onclick: () => this.sendTestEmail(),
|
||||||
}),
|
},
|
||||||
],
|
app.translator.trans('core.admin.email.send_test_mail_button')
|
||||||
})}
|
),
|
||||||
|
]
|
||||||
|
)}
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -156,7 +171,7 @@ export default class MailPage extends Page {
|
|||||||
const prop = this.values[name];
|
const prop = this.values[name];
|
||||||
|
|
||||||
if (typeof field === 'string') {
|
if (typeof field === 'string') {
|
||||||
return <input className="FormControl" value={prop() || ''} oninput={m.withAttr('value', prop)} />;
|
return <input className="FormControl" bidi={prop} />;
|
||||||
} else {
|
} else {
|
||||||
return <Select value={prop()} options={field} onchange={prop} />;
|
return <Select value={prop()} options={field} onchange={prop} />;
|
||||||
}
|
}
|
||||||
@ -179,10 +194,7 @@ export default class MailPage extends Page {
|
|||||||
})
|
})
|
||||||
.then((response) => {
|
.then((response) => {
|
||||||
this.sendingTest = false;
|
this.sendingTest = false;
|
||||||
this.testEmailSuccessAlert = app.alerts.show({
|
this.testEmailSuccessAlert = app.alerts.show({ type: 'success' }, app.translator.trans('core.admin.email.send_test_mail_success'));
|
||||||
type: 'success',
|
|
||||||
children: app.translator.trans('core.admin.email.send_test_mail_success'),
|
|
||||||
});
|
|
||||||
})
|
})
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
this.sendingTest = false;
|
this.sendingTest = false;
|
||||||
@ -205,10 +217,7 @@ export default class MailPage extends Page {
|
|||||||
|
|
||||||
saveSettings(settings)
|
saveSettings(settings)
|
||||||
.then(() => {
|
.then(() => {
|
||||||
this.successAlert = app.alerts.show({
|
this.successAlert = app.alerts.show({ type: 'success' }, app.translator.trans('core.admin.basics.saved_message'));
|
||||||
type: 'success',
|
|
||||||
children: app.translator.trans('core.admin.basics.saved_message'),
|
|
||||||
});
|
|
||||||
})
|
})
|
||||||
.catch(() => {})
|
.catch(() => {})
|
||||||
.then(() => {
|
.then(() => {
|
||||||
|
@ -32,101 +32,109 @@ function filterByRequiredPermissions(groupIds, permission) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default class PermissionDropdown extends Dropdown {
|
export default class PermissionDropdown extends Dropdown {
|
||||||
static initProps(props) {
|
static initAttrs(attrs) {
|
||||||
super.initProps(props);
|
super.initAttrs(attrs);
|
||||||
|
|
||||||
props.className = 'PermissionDropdown';
|
attrs.className = 'PermissionDropdown';
|
||||||
props.buttonClassName = 'Button Button--text';
|
attrs.buttonClassName = 'Button Button--text';
|
||||||
}
|
}
|
||||||
|
|
||||||
view() {
|
view(vnode) {
|
||||||
this.props.children = [];
|
const children = [];
|
||||||
|
|
||||||
let groupIds = app.data.permissions[this.props.permission] || [];
|
let groupIds = app.data.permissions[this.attrs.permission] || [];
|
||||||
|
|
||||||
groupIds = filterByRequiredPermissions(groupIds, this.props.permission);
|
groupIds = filterByRequiredPermissions(groupIds, this.attrs.permission);
|
||||||
|
|
||||||
const everyone = groupIds.indexOf(Group.GUEST_ID) !== -1;
|
const everyone = groupIds.indexOf(Group.GUEST_ID) !== -1;
|
||||||
const members = groupIds.indexOf(Group.MEMBER_ID) !== -1;
|
const members = groupIds.indexOf(Group.MEMBER_ID) !== -1;
|
||||||
const adminGroup = app.store.getById('groups', Group.ADMINISTRATOR_ID);
|
const adminGroup = app.store.getById('groups', Group.ADMINISTRATOR_ID);
|
||||||
|
|
||||||
if (everyone) {
|
if (everyone) {
|
||||||
this.props.label = Badge.component({ icon: 'fas fa-globe' });
|
this.attrs.label = Badge.component({ icon: 'fas fa-globe' });
|
||||||
} else if (members) {
|
} else if (members) {
|
||||||
this.props.label = Badge.component({ icon: 'fas fa-user' });
|
this.attrs.label = Badge.component({ icon: 'fas fa-user' });
|
||||||
} else {
|
} else {
|
||||||
this.props.label = [badgeForId(Group.ADMINISTRATOR_ID), groupIds.map(badgeForId)];
|
this.attrs.label = [badgeForId(Group.ADMINISTRATOR_ID), groupIds.map(badgeForId)];
|
||||||
}
|
}
|
||||||
|
|
||||||
if (this.showing) {
|
if (this.showing) {
|
||||||
if (this.props.allowGuest) {
|
if (this.attrs.allowGuest) {
|
||||||
this.props.children.push(
|
children.push(
|
||||||
Button.component({
|
Button.component(
|
||||||
children: [Badge.component({ icon: 'fas fa-globe' }), ' ', app.translator.trans('core.admin.permissions_controls.everyone_button')],
|
{
|
||||||
icon: everyone ? 'fas fa-check' : true,
|
icon: everyone ? 'fas fa-check' : true,
|
||||||
onclick: () => this.save([Group.GUEST_ID]),
|
onclick: () => this.save([Group.GUEST_ID]),
|
||||||
disabled: this.isGroupDisabled(Group.GUEST_ID),
|
disabled: this.isGroupDisabled(Group.GUEST_ID),
|
||||||
})
|
},
|
||||||
|
[Badge.component({ icon: 'fas fa-globe' }), ' ', app.translator.trans('core.admin.permissions_controls.everyone_button')]
|
||||||
|
)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
this.props.children.push(
|
children.push(
|
||||||
Button.component({
|
Button.component(
|
||||||
children: [Badge.component({ icon: 'fas fa-user' }), ' ', app.translator.trans('core.admin.permissions_controls.members_button')],
|
{
|
||||||
icon: members ? 'fas fa-check' : true,
|
icon: members ? 'fas fa-check' : true,
|
||||||
onclick: () => this.save([Group.MEMBER_ID]),
|
onclick: () => this.save([Group.MEMBER_ID]),
|
||||||
disabled: this.isGroupDisabled(Group.MEMBER_ID),
|
disabled: this.isGroupDisabled(Group.MEMBER_ID),
|
||||||
}),
|
},
|
||||||
|
[Badge.component({ icon: 'fas fa-user' }), ' ', app.translator.trans('core.admin.permissions_controls.members_button')]
|
||||||
|
),
|
||||||
|
|
||||||
Separator.component(),
|
Separator.component(),
|
||||||
|
|
||||||
Button.component({
|
Button.component(
|
||||||
children: [badgeForId(adminGroup.id()), ' ', adminGroup.namePlural()],
|
{
|
||||||
icon: !everyone && !members ? 'fas fa-check' : true,
|
icon: !everyone && !members ? 'fas fa-check' : true,
|
||||||
disabled: !everyone && !members,
|
disabled: !everyone && !members,
|
||||||
onclick: (e) => {
|
onclick: (e) => {
|
||||||
if (e.shiftKey) e.stopPropagation();
|
if (e.shiftKey) e.stopPropagation();
|
||||||
this.save([]);
|
this.save([]);
|
||||||
|
},
|
||||||
},
|
},
|
||||||
})
|
[badgeForId(adminGroup.id()), ' ', adminGroup.namePlural()]
|
||||||
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
[].push.apply(
|
[].push.apply(
|
||||||
this.props.children,
|
children,
|
||||||
app.store
|
app.store
|
||||||
.all('groups')
|
.all('groups')
|
||||||
.filter((group) => [Group.ADMINISTRATOR_ID, Group.GUEST_ID, Group.MEMBER_ID].indexOf(group.id()) === -1)
|
.filter((group) => [Group.ADMINISTRATOR_ID, Group.GUEST_ID, Group.MEMBER_ID].indexOf(group.id()) === -1)
|
||||||
.map((group) =>
|
.map((group) =>
|
||||||
Button.component({
|
Button.component(
|
||||||
children: [badgeForId(group.id()), ' ', group.namePlural()],
|
{
|
||||||
icon: groupIds.indexOf(group.id()) !== -1 ? 'fas fa-check' : true,
|
icon: groupIds.indexOf(group.id()) !== -1 ? 'fas fa-check' : true,
|
||||||
onclick: (e) => {
|
onclick: (e) => {
|
||||||
if (e.shiftKey) e.stopPropagation();
|
if (e.shiftKey) e.stopPropagation();
|
||||||
this.toggle(group.id());
|
this.toggle(group.id());
|
||||||
|
},
|
||||||
|
disabled: this.isGroupDisabled(group.id()) && this.isGroupDisabled(Group.MEMBER_ID) && this.isGroupDisabled(Group.GUEST_ID),
|
||||||
},
|
},
|
||||||
disabled: this.isGroupDisabled(group.id()) && this.isGroupDisabled(Group.MEMBER_ID) && this.isGroupDisabled(Group.GUEST_ID),
|
[badgeForId(group.id()), ' ', group.namePlural()]
|
||||||
})
|
)
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return super.view();
|
return super.view({ ...vnode, children });
|
||||||
}
|
}
|
||||||
|
|
||||||
save(groupIds) {
|
save(groupIds) {
|
||||||
const permission = this.props.permission;
|
const permission = this.attrs.permission;
|
||||||
|
|
||||||
app.data.permissions[permission] = groupIds;
|
app.data.permissions[permission] = groupIds;
|
||||||
|
|
||||||
app.request({
|
app.request({
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
url: app.forum.attribute('apiUrl') + '/permission',
|
url: app.forum.attribute('apiUrl') + '/permission',
|
||||||
data: { permission, groupIds },
|
body: { permission, groupIds },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
toggle(groupId) {
|
toggle(groupId) {
|
||||||
const permission = this.props.permission;
|
const permission = this.attrs.permission;
|
||||||
|
|
||||||
let groupIds = app.data.permissions[permission] || [];
|
let groupIds = app.data.permissions[permission] || [];
|
||||||
|
|
||||||
@ -143,6 +151,6 @@ export default class PermissionDropdown extends Dropdown {
|
|||||||
}
|
}
|
||||||
|
|
||||||
isGroupDisabled(id) {
|
isGroupDisabled(id) {
|
||||||
return filterByRequiredPermissions([id], this.props.permission).indexOf(id) === -1;
|
return filterByRequiredPermissions([id], this.attrs.permission).indexOf(id) === -1;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -6,7 +6,9 @@ import ItemList from '../../common/utils/ItemList';
|
|||||||
import icon from '../../common/helpers/icon';
|
import icon from '../../common/helpers/icon';
|
||||||
|
|
||||||
export default class PermissionGrid extends Component {
|
export default class PermissionGrid extends Component {
|
||||||
init() {
|
oninit(vnode) {
|
||||||
|
super.oninit(vnode);
|
||||||
|
|
||||||
this.permissions = this.permissionItems().toArray();
|
this.permissions = this.permissionItems().toArray();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -9,18 +9,16 @@ import ItemList from '../../common/utils/ItemList';
|
|||||||
* avatar/name, with a dropdown of session controls.
|
* avatar/name, with a dropdown of session controls.
|
||||||
*/
|
*/
|
||||||
export default class SessionDropdown extends Dropdown {
|
export default class SessionDropdown extends Dropdown {
|
||||||
static initProps(props) {
|
static initAttrs(attrs) {
|
||||||
super.initProps(props);
|
super.initAttrs(attrs);
|
||||||
|
|
||||||
props.className = 'SessionDropdown';
|
attrs.className = 'SessionDropdown';
|
||||||
props.buttonClassName = 'Button Button--user Button--flat';
|
attrs.buttonClassName = 'Button Button--user Button--flat';
|
||||||
props.menuClassName = 'Dropdown-menu--right';
|
attrs.menuClassName = 'Dropdown-menu--right';
|
||||||
}
|
}
|
||||||
|
|
||||||
view() {
|
view(vnode) {
|
||||||
this.props.children = this.items().toArray();
|
return super.view({ ...vnode, children: this.items().toArray() });
|
||||||
|
|
||||||
return super.view();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
getButtonContent() {
|
getButtonContent() {
|
||||||
@ -39,11 +37,13 @@ export default class SessionDropdown extends Dropdown {
|
|||||||
|
|
||||||
items.add(
|
items.add(
|
||||||
'logOut',
|
'logOut',
|
||||||
Button.component({
|
Button.component(
|
||||||
icon: 'fas fa-sign-out-alt',
|
{
|
||||||
children: app.translator.trans('core.admin.header.log_out_button'),
|
icon: 'fas fa-sign-out-alt',
|
||||||
onclick: app.session.logout.bind(app.session),
|
onclick: app.session.logout.bind(app.session),
|
||||||
}),
|
},
|
||||||
|
app.translator.trans('core.admin.header.log_out_button')
|
||||||
|
),
|
||||||
-100
|
-100
|
||||||
);
|
);
|
||||||
|
|
||||||
|
@ -3,23 +3,30 @@ import Button from '../../common/components/Button';
|
|||||||
import saveSettings from '../utils/saveSettings';
|
import saveSettings from '../utils/saveSettings';
|
||||||
|
|
||||||
export default class SettingDropdown extends SelectDropdown {
|
export default class SettingDropdown extends SelectDropdown {
|
||||||
static initProps(props) {
|
static initAttrs(attrs) {
|
||||||
super.initProps(props);
|
super.initAttrs(attrs);
|
||||||
|
|
||||||
props.className = 'SettingDropdown';
|
attrs.className = 'SettingDropdown';
|
||||||
props.buttonClassName = 'Button Button--text';
|
attrs.buttonClassName = 'Button Button--text';
|
||||||
props.caretIcon = 'fas fa-caret-down';
|
attrs.caretIcon = 'fas fa-caret-down';
|
||||||
props.defaultLabel = 'Custom';
|
attrs.defaultLabel = 'Custom';
|
||||||
|
}
|
||||||
|
|
||||||
props.children = props.options.map(({ value, label }) => {
|
view(vnode) {
|
||||||
const active = app.data.settings[props.key] === value;
|
return super.view({
|
||||||
|
...vnode,
|
||||||
|
children: this.attrs.options.map(({ value, label }) => {
|
||||||
|
const active = app.data.settings[this.attrs.key] === value;
|
||||||
|
|
||||||
return Button.component({
|
return Button.component(
|
||||||
children: label,
|
{
|
||||||
icon: active ? 'fas fa-check' : true,
|
icon: active ? 'fas fa-check' : true,
|
||||||
onclick: saveSettings.bind(this, { [props.key]: value }),
|
onclick: saveSettings.bind(this, { [this.attrs.key]: value }),
|
||||||
active,
|
active,
|
||||||
});
|
},
|
||||||
|
label
|
||||||
|
);
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -3,7 +3,9 @@ import Button from '../../common/components/Button';
|
|||||||
import saveSettings from '../utils/saveSettings';
|
import saveSettings from '../utils/saveSettings';
|
||||||
|
|
||||||
export default class SettingsModal extends Modal {
|
export default class SettingsModal extends Modal {
|
||||||
init() {
|
oninit(vnode) {
|
||||||
|
super.oninit(vnode);
|
||||||
|
|
||||||
this.settings = {};
|
this.settings = {};
|
||||||
this.loading = false;
|
this.loading = false;
|
||||||
}
|
}
|
||||||
@ -33,7 +35,7 @@ export default class SettingsModal extends Modal {
|
|||||||
}
|
}
|
||||||
|
|
||||||
setting(key, fallback = '') {
|
setting(key, fallback = '') {
|
||||||
this.settings[key] = this.settings[key] || m.prop(app.data.settings[key] || fallback);
|
this.settings[key] = this.settings[key] || m.stream(app.data.settings[key] || fallback);
|
||||||
|
|
||||||
return this.settings[key];
|
return this.settings[key];
|
||||||
}
|
}
|
||||||
|
@ -1,32 +1,28 @@
|
|||||||
import Button from '../../common/components/Button';
|
import Button from '../../common/components/Button';
|
||||||
|
|
||||||
export default class UploadImageButton extends Button {
|
export default class UploadImageButton extends Button {
|
||||||
init() {
|
loading = false;
|
||||||
this.loading = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
view() {
|
view(vnode) {
|
||||||
this.props.loading = this.loading;
|
this.attrs.loading = this.loading;
|
||||||
this.props.className = (this.props.className || '') + ' Button';
|
this.attrs.className = (this.attrs.className || '') + ' Button';
|
||||||
|
|
||||||
if (app.data.settings[this.props.name + '_path']) {
|
if (app.data.settings[this.attrs.name + '_path']) {
|
||||||
this.props.onclick = this.remove.bind(this);
|
this.attrs.onclick = this.remove.bind(this);
|
||||||
this.props.children = app.translator.trans('core.admin.upload_image.remove_button');
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<p>
|
<p>
|
||||||
<img src={app.forum.attribute(this.props.name + 'Url')} alt="" />
|
<img src={app.forum.attribute(this.attrs.name + 'Url')} alt="" />
|
||||||
</p>
|
</p>
|
||||||
<p>{super.view()}</p>
|
<p>{super.view({ ...vnode, children: app.translator.trans('core.admin.upload_image.remove_button') })}</p>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
this.props.onclick = this.upload.bind(this);
|
this.attrs.onclick = this.upload.bind(this);
|
||||||
this.props.children = app.translator.trans('core.admin.upload_image.upload_button');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return super.view();
|
return super.view({ ...vnode, children: app.translator.trans('core.admin.upload_image.upload_button') });
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -42,8 +38,8 @@ export default class UploadImageButton extends Button {
|
|||||||
.hide()
|
.hide()
|
||||||
.click()
|
.click()
|
||||||
.on('change', (e) => {
|
.on('change', (e) => {
|
||||||
const data = new FormData();
|
const body = new FormData();
|
||||||
data.append(this.props.name, $(e.target)[0].files[0]);
|
body.append(this.attrs.name, $(e.target)[0].files[0]);
|
||||||
|
|
||||||
this.loading = true;
|
this.loading = true;
|
||||||
m.redraw();
|
m.redraw();
|
||||||
@ -53,7 +49,7 @@ export default class UploadImageButton extends Button {
|
|||||||
method: 'POST',
|
method: 'POST',
|
||||||
url: this.resourceUrl(),
|
url: this.resourceUrl(),
|
||||||
serialize: (raw) => raw,
|
serialize: (raw) => raw,
|
||||||
data,
|
body,
|
||||||
})
|
})
|
||||||
.then(this.success.bind(this), this.failure.bind(this));
|
.then(this.success.bind(this), this.failure.bind(this));
|
||||||
});
|
});
|
||||||
@ -75,7 +71,7 @@ export default class UploadImageButton extends Button {
|
|||||||
}
|
}
|
||||||
|
|
||||||
resourceUrl() {
|
resourceUrl() {
|
||||||
return app.forum.attribute('apiUrl') + '/' + this.props.name;
|
return app.forum.attribute('apiUrl') + '/' + this.attrs.name;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -12,11 +12,11 @@ import MailPage from './components/MailPage';
|
|||||||
*/
|
*/
|
||||||
export default function (app) {
|
export default function (app) {
|
||||||
app.routes = {
|
app.routes = {
|
||||||
dashboard: { path: '/', component: DashboardPage.component() },
|
dashboard: { path: '/', component: DashboardPage },
|
||||||
basics: { path: '/basics', component: BasicsPage.component() },
|
basics: { path: '/basics', component: BasicsPage },
|
||||||
permissions: { path: '/permissions', component: PermissionsPage.component() },
|
permissions: { path: '/permissions', component: PermissionsPage },
|
||||||
appearance: { path: '/appearance', component: AppearancePage.component() },
|
appearance: { path: '/appearance', component: AppearancePage },
|
||||||
extensions: { path: '/extensions', component: ExtensionsPage.component() },
|
extensions: { path: '/extensions', component: ExtensionsPage },
|
||||||
mail: { path: '/mail', component: MailPage.component() },
|
mail: { path: '/mail', component: MailPage },
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
@ -7,7 +7,7 @@ export default function saveSettings(settings) {
|
|||||||
.request({
|
.request({
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
url: app.forum.attribute('apiUrl') + '/settings',
|
url: app.forum.attribute('apiUrl') + '/settings',
|
||||||
data: settings,
|
body: settings,
|
||||||
})
|
})
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
app.data.settings = oldSettings;
|
app.data.settings = oldSettings;
|
||||||
|
@ -189,8 +189,9 @@ export default class Application {
|
|||||||
}
|
}
|
||||||
|
|
||||||
mount(basePath = '') {
|
mount(basePath = '') {
|
||||||
m.mount(document.getElementById('modal'), <ModalManager state={this.modal} />);
|
// An object with a callable view property is used in order to pass arguments to the component; see https://mithril.js.org/mount.html
|
||||||
m.mount(document.getElementById('alerts'), <AlertManager state={this.alerts} />);
|
m.mount(document.getElementById('modal'), { view: () => ModalManager.component({ state: this.modal }) });
|
||||||
|
m.mount(document.getElementById('alerts'), { view: () => AlertManager.component({ state: this.alerts }) });
|
||||||
|
|
||||||
this.drawer = new Drawer();
|
this.drawer = new Drawer();
|
||||||
|
|
||||||
@ -263,7 +264,7 @@ export default class Application {
|
|||||||
|
|
||||||
updateTitle() {
|
updateTitle() {
|
||||||
const count = this.titleCount ? `(${this.titleCount}) ` : '';
|
const count = this.titleCount ? `(${this.titleCount}) ` : '';
|
||||||
const pageTitleWithSeparator = this.title && m.route() !== '/' ? this.title + ' - ' : '';
|
const pageTitleWithSeparator = this.title && m.route.get() !== '/' ? this.title + ' - ' : '';
|
||||||
const title = this.forum.attribute('title');
|
const title = this.forum.attribute('title');
|
||||||
document.title = count + pageTitleWithSeparator + title;
|
document.title = count + pageTitleWithSeparator + title;
|
||||||
}
|
}
|
||||||
@ -342,16 +343,14 @@ export default class Application {
|
|||||||
|
|
||||||
// Now make the request. If it's a failure, inspect the error that was
|
// Now make the request. If it's a failure, inspect the error that was
|
||||||
// returned and show an alert containing its contents.
|
// returned and show an alert containing its contents.
|
||||||
const deferred = m.deferred();
|
return m.request(options).then(
|
||||||
|
(response) => response,
|
||||||
m.request(options).then(
|
|
||||||
(response) => deferred.resolve(response),
|
|
||||||
(error) => {
|
(error) => {
|
||||||
let children;
|
let content;
|
||||||
|
|
||||||
switch (error.status) {
|
switch (error.status) {
|
||||||
case 422:
|
case 422:
|
||||||
children = error.response.errors
|
content = error.response.errors
|
||||||
.map((error) => [error.detail, <br />])
|
.map((error) => [error.detail, <br />])
|
||||||
.reduce((a, b) => a.concat(b), [])
|
.reduce((a, b) => a.concat(b), [])
|
||||||
.slice(0, -1);
|
.slice(0, -1);
|
||||||
@ -359,30 +358,31 @@ export default class Application {
|
|||||||
|
|
||||||
case 401:
|
case 401:
|
||||||
case 403:
|
case 403:
|
||||||
children = app.translator.trans('core.lib.error.permission_denied_message');
|
content = app.translator.trans('core.lib.error.permission_denied_message');
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case 404:
|
case 404:
|
||||||
case 410:
|
case 410:
|
||||||
children = app.translator.trans('core.lib.error.not_found_message');
|
content = app.translator.trans('core.lib.error.not_found_message');
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case 429:
|
case 429:
|
||||||
children = app.translator.trans('core.lib.error.rate_limit_exceeded_message');
|
content = app.translator.trans('core.lib.error.rate_limit_exceeded_message');
|
||||||
break;
|
break;
|
||||||
|
|
||||||
default:
|
default:
|
||||||
children = app.translator.trans('core.lib.error.generic_message');
|
content = app.translator.trans('core.lib.error.generic_message');
|
||||||
}
|
}
|
||||||
|
|
||||||
const isDebug = app.forum.attribute('debug');
|
const isDebug = app.forum.attribute('debug');
|
||||||
// contains a formatted errors if possible, response must be an JSON API array of errors
|
// contains a formatted errors if possible, response must be an JSON API array of errors
|
||||||
// the details property is decoded to transform escaped characters such as '\n'
|
// the details property is decoded to transform escaped characters such as '\n'
|
||||||
const formattedError = error.response && Array.isArray(error.response.errors) && error.response.errors.map((e) => decodeURI(e.detail));
|
const errors = error.response && error.response.errors;
|
||||||
|
const formattedError = Array.isArray(errors) && errors[0] && errors[0].detail && errors.map((e) => decodeURI(e.detail));
|
||||||
|
|
||||||
error.alert = {
|
error.alert = {
|
||||||
type: 'error',
|
type: 'error',
|
||||||
children,
|
content,
|
||||||
controls: isDebug && [
|
controls: isDebug && [
|
||||||
<Button className="Button Button--link" onclick={this.showDebug.bind(this, error, formattedError)}>
|
<Button className="Button Button--link" onclick={this.showDebug.bind(this, error, formattedError)}>
|
||||||
Debug
|
Debug
|
||||||
@ -404,14 +404,12 @@ export default class Application {
|
|||||||
console.groupEnd();
|
console.groupEnd();
|
||||||
}
|
}
|
||||||
|
|
||||||
this.requestErrorAlert = this.alerts.show(error.alert);
|
this.requestErrorAlert = this.alerts.show(error.alert, error.alert.content);
|
||||||
}
|
}
|
||||||
|
|
||||||
deferred.reject(error);
|
return Promise.reject(error);
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
return deferred.promise;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -434,9 +432,19 @@ export default class Application {
|
|||||||
* @public
|
* @public
|
||||||
*/
|
*/
|
||||||
route(name, params = {}) {
|
route(name, params = {}) {
|
||||||
const url = this.routes[name].path.replace(/:([^\/]+)/g, (m, key) => extract(params, key));
|
const route = this.routes[name];
|
||||||
const queryString = m.route.buildQueryString(params);
|
|
||||||
const prefix = m.route.mode === 'pathname' ? app.forum.attribute('basePath') : '';
|
if (!route) throw new Error(`Route '${name}' does not exist`);
|
||||||
|
|
||||||
|
const url = route.path.replace(/:([^\/]+)/g, (m, key) => extract(params, key));
|
||||||
|
|
||||||
|
// Remove falsy values in params to avoid having urls like '/?sort&q'
|
||||||
|
for (const key in params) {
|
||||||
|
if (params.hasOwnProperty(key) && !params[key]) delete params[key];
|
||||||
|
}
|
||||||
|
|
||||||
|
const queryString = m.buildQueryString(params);
|
||||||
|
const prefix = m.route.prefix === '' ? this.forum.attribute('basePath') : '';
|
||||||
|
|
||||||
return prefix + url + (queryString ? '?' + queryString : '');
|
return prefix + url + (queryString ? '?' + queryString : '');
|
||||||
}
|
}
|
||||||
|
@ -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) {}
|
|
||||||
}
|
|
136
js/src/common/Component.ts
Normal file
136
js/src/common/Component.ts
Normal file
@ -0,0 +1,136 @@
|
|||||||
|
import * as Mithril from 'mithril';
|
||||||
|
|
||||||
|
export type ComponentAttrs = {
|
||||||
|
className?: string;
|
||||||
|
|
||||||
|
[key: string]: any;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The `Component` class defines a user interface 'building block'. A component
|
||||||
|
* generates a virtual DOM to be rendered on each redraw.
|
||||||
|
*
|
||||||
|
* Essentially, this is a wrapper for Mithril's components that adds several useful features:
|
||||||
|
*
|
||||||
|
* - In the `oninit` and `onbeforeupdate` lifecycle hooks, we store vnode attrs in `this.attrs.
|
||||||
|
* This allows us to use attrs across components without having to pass the vnode to every single
|
||||||
|
* method.
|
||||||
|
* - The static `initAttrs` method allows a convenient way to provide defaults (or to otherwise modify)
|
||||||
|
* the attrs that have been passed into a component.
|
||||||
|
* - When the component is created in the DOM, we store its DOM element under `this.element`; this lets
|
||||||
|
* us use jQuery to modify child DOM state from internal methods via the `this.$()` method.
|
||||||
|
* - A convenience `component` method, which serves as an alternative to hyperscript and JSX.
|
||||||
|
*
|
||||||
|
* As with other Mithril components, components extending Component can be initialized
|
||||||
|
* and nested using JSX, hyperscript, or a combination of both. The `component` method can also
|
||||||
|
* be used.
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* return m('div', <MyComponent foo="bar"><p>Hello World</p></MyComponent>);
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* return m('div', MyComponent.component({foo: 'bar'), m('p', 'Hello World!'));
|
||||||
|
*
|
||||||
|
* @see https://mithril.js.org/components.html
|
||||||
|
*/
|
||||||
|
export default abstract class Component<T extends ComponentAttrs = any> implements Mithril.ClassComponent<T> {
|
||||||
|
/**
|
||||||
|
* The root DOM element for the component.
|
||||||
|
*/
|
||||||
|
protected element!: Element;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The attributes passed into the component.
|
||||||
|
*
|
||||||
|
* @see https://mithril.js.org/components.html#passing-data-to-components
|
||||||
|
*/
|
||||||
|
protected attrs!: T;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @inheritdoc
|
||||||
|
*/
|
||||||
|
abstract view(vnode: Mithril.Vnode<T, this>): Mithril.Children;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @inheritdoc
|
||||||
|
*/
|
||||||
|
oninit(vnode: Mithril.Vnode<T, this>) {
|
||||||
|
this.setAttrs(vnode.attrs);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @inheritdoc
|
||||||
|
*/
|
||||||
|
oncreate(vnode: Mithril.VnodeDOM<T, this>) {
|
||||||
|
this.element = vnode.dom;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @inheritdoc
|
||||||
|
*/
|
||||||
|
onbeforeupdate(vnode: Mithril.VnodeDOM<T, this>) {
|
||||||
|
this.setAttrs(vnode.attrs);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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
|
||||||
|
*/
|
||||||
|
protected $(selector) {
|
||||||
|
const $element = $(this.element);
|
||||||
|
|
||||||
|
return selector ? $element.find(selector) : $element;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convenience method to attach a component without JSX.
|
||||||
|
* Has the same effect as calling `m(THIS_CLASS, attrs, children)`.
|
||||||
|
*
|
||||||
|
* @see https://mithril.js.org/hyperscript.html#mselector,-attributes,-children
|
||||||
|
*/
|
||||||
|
static component(attrs = {}, children = null): Mithril.Vnode {
|
||||||
|
const componentAttrs = Object.assign({}, attrs);
|
||||||
|
|
||||||
|
return m(this as any, componentAttrs, children);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Saves a reference to the vnode attrs after running them through initAttrs,
|
||||||
|
* and checking for common issues.
|
||||||
|
*/
|
||||||
|
private setAttrs(attrs: T = {} as T): void {
|
||||||
|
(this.constructor as typeof Component).initAttrs(attrs);
|
||||||
|
|
||||||
|
if (attrs) {
|
||||||
|
if ('children' in attrs) {
|
||||||
|
throw new Error(
|
||||||
|
`[${
|
||||||
|
(this.constructor as any).name
|
||||||
|
}] The "children" attribute of attrs should never be used. Either pass children in as the vnode children or rename the attribute`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ('tag' in attrs) {
|
||||||
|
throw new Error(`[${(this.constructor as any).name}] You cannot use the "tag" attribute name with Mithril 2.`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this.attrs = attrs;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initialize the component's attrs.
|
||||||
|
*
|
||||||
|
* This can be used to assign default values for missing, optional attrs.
|
||||||
|
*/
|
||||||
|
protected static initAttrs<T>(attrs: T): void {}
|
||||||
|
}
|
74
js/src/common/Fragment.ts
Normal file
74
js/src/common/Fragment.ts
Normal file
@ -0,0 +1,74 @@
|
|||||||
|
import * as Mithril from 'mithril';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The `Fragment` class represents a chunk of DOM that is rendered once with Mithril and then takes
|
||||||
|
* over control of its own DOM and lifecycle.
|
||||||
|
*
|
||||||
|
* This is very similar to the `Component` wrapper class, but is used for more fine-grained control over
|
||||||
|
* the rendering and display of some significant chunks of the DOM. In contrast to components, fragments
|
||||||
|
* do not offer Mithril's lifecycle hooks.
|
||||||
|
*
|
||||||
|
* Use this when you want to enjoy the benefits of JSX / VDOM for initial rendering, combined with
|
||||||
|
* small helper methods that then make updates to that DOM directly, instead of fully redrawing
|
||||||
|
* everything through Mithril.
|
||||||
|
*
|
||||||
|
* This should only be used when necessary, and only with `m.render`. If you are unsure whether you need
|
||||||
|
* this or `Component, you probably need `Component`.
|
||||||
|
*/
|
||||||
|
export default abstract class Fragment {
|
||||||
|
/**
|
||||||
|
* The root DOM element for the fragment.
|
||||||
|
*/
|
||||||
|
protected element!: Element;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns a jQuery object for this fragment'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 `fragment.$('li')` will return a jQuery object
|
||||||
|
* containing all of the `li` elements inside the DOM element of this
|
||||||
|
* fragment.
|
||||||
|
*
|
||||||
|
* @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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the renderable virtual DOM that represents the fragment's view.
|
||||||
|
*
|
||||||
|
* This should NOT be overridden by subclasses. Subclasses wishing to define
|
||||||
|
* their virtual DOM should override Fragment#view instead.
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* const fragment = new MyFragment();
|
||||||
|
* m.render(document.body, fragment.render());
|
||||||
|
*
|
||||||
|
* @final
|
||||||
|
*/
|
||||||
|
public render(): Mithril.Vnode<Mithril.Attributes, this> {
|
||||||
|
const vdom = this.view();
|
||||||
|
|
||||||
|
vdom.attrs = vdom.attrs || {};
|
||||||
|
|
||||||
|
const originalOnCreate = vdom.attrs.oncreate;
|
||||||
|
|
||||||
|
vdom.attrs.oncreate = (vnode) => {
|
||||||
|
this.element = vnode.dom;
|
||||||
|
if (originalOnCreate) originalOnCreate.apply(this, [vnode]);
|
||||||
|
};
|
||||||
|
|
||||||
|
return vdom;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a view out of virtual elements.
|
||||||
|
*/
|
||||||
|
abstract view(): Mithril.Vnode<Mithril.Attributes, this>;
|
||||||
|
}
|
@ -161,7 +161,7 @@ export default class Model {
|
|||||||
{
|
{
|
||||||
method: this.exists ? 'PATCH' : 'POST',
|
method: this.exists ? 'PATCH' : 'POST',
|
||||||
url: app.forum.attribute('apiUrl') + this.apiEndpoint(),
|
url: app.forum.attribute('apiUrl') + this.apiEndpoint(),
|
||||||
data: request,
|
body: request,
|
||||||
},
|
},
|
||||||
options
|
options
|
||||||
)
|
)
|
||||||
@ -180,7 +180,7 @@ export default class Model {
|
|||||||
// old data! We'll revert to that and let others handle the error.
|
// old data! We'll revert to that and let others handle the error.
|
||||||
(response) => {
|
(response) => {
|
||||||
this.pushData(oldData);
|
this.pushData(oldData);
|
||||||
m.lazyRedraw();
|
m.redraw();
|
||||||
throw response;
|
throw response;
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
@ -189,13 +189,13 @@ export default class Model {
|
|||||||
/**
|
/**
|
||||||
* Send a request to delete the resource.
|
* Send a request to delete the resource.
|
||||||
*
|
*
|
||||||
* @param {Object} data Data to send along with the DELETE request.
|
* @param {Object} body Data to send along with the DELETE request.
|
||||||
* @param {Object} [options]
|
* @param {Object} [options]
|
||||||
* @return {Promise}
|
* @return {Promise}
|
||||||
* @public
|
* @public
|
||||||
*/
|
*/
|
||||||
delete(data, options = {}) {
|
delete(body, options = {}) {
|
||||||
if (!this.exists) return m.deferred().resolve().promise;
|
if (!this.exists) return Promise.resolve();
|
||||||
|
|
||||||
return app
|
return app
|
||||||
.request(
|
.request(
|
||||||
@ -203,7 +203,7 @@ export default class Model {
|
|||||||
{
|
{
|
||||||
method: 'DELETE',
|
method: 'DELETE',
|
||||||
url: app.forum.attribute('apiUrl') + this.apiEndpoint(),
|
url: app.forum.attribute('apiUrl') + this.apiEndpoint(),
|
||||||
data,
|
body,
|
||||||
},
|
},
|
||||||
options
|
options
|
||||||
)
|
)
|
||||||
|
@ -30,13 +30,13 @@ export default class Session {
|
|||||||
* @return {Promise}
|
* @return {Promise}
|
||||||
* @public
|
* @public
|
||||||
*/
|
*/
|
||||||
login(data, options = {}) {
|
login(body, options = {}) {
|
||||||
return app.request(
|
return app.request(
|
||||||
Object.assign(
|
Object.assign(
|
||||||
{
|
{
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
url: app.forum.attribute('baseUrl') + '/login',
|
url: `${app.forum.attribute('baseUrl')}/login`,
|
||||||
data,
|
body,
|
||||||
},
|
},
|
||||||
options
|
options
|
||||||
)
|
)
|
||||||
@ -49,6 +49,6 @@ export default class Session {
|
|||||||
* @public
|
* @public
|
||||||
*/
|
*/
|
||||||
logout() {
|
logout() {
|
||||||
window.location = app.forum.attribute('baseUrl') + '/logout?token=' + this.csrfToken;
|
window.location = `${app.forum.attribute('baseUrl')}/logout?token=${this.csrfToken}`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -82,13 +82,13 @@ export default class Store {
|
|||||||
* @public
|
* @public
|
||||||
*/
|
*/
|
||||||
find(type, id, query = {}, options = {}) {
|
find(type, id, query = {}, options = {}) {
|
||||||
let data = query;
|
let params = query;
|
||||||
let url = app.forum.attribute('apiUrl') + '/' + type;
|
let url = app.forum.attribute('apiUrl') + '/' + type;
|
||||||
|
|
||||||
if (id instanceof Array) {
|
if (id instanceof Array) {
|
||||||
url += '?filter[id]=' + id.join(',');
|
url += '?filter[id]=' + id.join(',');
|
||||||
} else if (typeof id === 'object') {
|
} else if (typeof id === 'object') {
|
||||||
data = id;
|
params = id;
|
||||||
} else if (id) {
|
} else if (id) {
|
||||||
url += '/' + id;
|
url += '/' + id;
|
||||||
}
|
}
|
||||||
@ -99,7 +99,7 @@ export default class Store {
|
|||||||
{
|
{
|
||||||
method: 'GET',
|
method: 'GET',
|
||||||
url,
|
url,
|
||||||
data,
|
params,
|
||||||
},
|
},
|
||||||
options
|
options
|
||||||
)
|
)
|
||||||
|
@ -1,4 +1,3 @@
|
|||||||
import User from './models/User';
|
|
||||||
import username from './helpers/username';
|
import username from './helpers/username';
|
||||||
import extract from './utils/extract';
|
import extract from './utils/extract';
|
||||||
|
|
||||||
@ -71,18 +70,34 @@ export default class Translator {
|
|||||||
const match = part.match(new RegExp('{([a-z0-9_]+)}|<(/?)([a-z0-9_]+)>', 'i'));
|
const match = part.match(new RegExp('{([a-z0-9_]+)}|<(/?)([a-z0-9_]+)>', 'i'));
|
||||||
|
|
||||||
if (match) {
|
if (match) {
|
||||||
|
// Either an opening or closing tag.
|
||||||
if (match[1]) {
|
if (match[1]) {
|
||||||
open[0].push(input[match[1]]);
|
open[0].push(input[match[1]]);
|
||||||
} else if (match[3]) {
|
} else if (match[3]) {
|
||||||
if (match[2]) {
|
if (match[2]) {
|
||||||
|
// Closing tag. We start by removing all raw children (generally in the form of strings) from the temporary
|
||||||
|
// holding array, then run them through m.fragment to convert them to vnodes. Usually this will just give us a
|
||||||
|
// text vnode, but using m.fragment as opposed to an explicit conversion should be more flexible. This is necessary because
|
||||||
|
// otherwise, our generated vnode will have raw strings as its children, and mithril expects vnodes.
|
||||||
|
// Finally, we add the now-processed vnodes back onto the holding array (which is the same object in memory as the
|
||||||
|
// children array of the vnode we are currently processing), and remove the reference to the holding array so that
|
||||||
|
// further text will be added to the full set of returned elements.
|
||||||
|
const rawChildren = open[0].splice(0, open[0].length);
|
||||||
|
open[0].push(...m.fragment(rawChildren).children);
|
||||||
open.shift();
|
open.shift();
|
||||||
} else {
|
} else {
|
||||||
|
// If a vnode with a matching tag was provided in the translator input, we use that. Otherwise, we create a new vnode
|
||||||
|
// with this tag, and an empty children array (since we're expecting to insert children, as that's the point of having this in translator)
|
||||||
let tag = input[match[3]] || { tag: match[3], children: [] };
|
let tag = input[match[3]] || { tag: match[3], children: [] };
|
||||||
open[0].push(tag);
|
open[0].push(tag);
|
||||||
|
// Insert the tag's children array as the first element of open, so that text in between the opening
|
||||||
|
// and closing tags will be added to the tag's children, not to the full set of returned elements.
|
||||||
open.unshift(tag.children || tag);
|
open.unshift(tag.children || tag);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
|
// Not an html tag, we add it to open[0], which is either the full set of returned elements (vnodes and text),
|
||||||
|
// or if an html tag is currently being processed, the children attribute of that html tag's vnode.
|
||||||
open[0].push(part);
|
open[0].push(part);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
@ -13,6 +13,7 @@ import RequestError from './utils/RequestError';
|
|||||||
import abbreviateNumber from './utils/abbreviateNumber';
|
import abbreviateNumber from './utils/abbreviateNumber';
|
||||||
import * as string from './utils/string';
|
import * as string from './utils/string';
|
||||||
import SubtreeRetainer from './utils/SubtreeRetainer';
|
import SubtreeRetainer from './utils/SubtreeRetainer';
|
||||||
|
import setRouteWithForcedRefresh from './utils/setRouteWithForcedRefresh';
|
||||||
import extract from './utils/extract';
|
import extract from './utils/extract';
|
||||||
import ScrollListener from './utils/ScrollListener';
|
import ScrollListener from './utils/ScrollListener';
|
||||||
import stringToColor from './utils/stringToColor';
|
import stringToColor from './utils/stringToColor';
|
||||||
@ -22,6 +23,7 @@ import classList from './utils/classList';
|
|||||||
import extractText from './utils/extractText';
|
import extractText from './utils/extractText';
|
||||||
import formatNumber from './utils/formatNumber';
|
import formatNumber from './utils/formatNumber';
|
||||||
import mapRoutes from './utils/mapRoutes';
|
import mapRoutes from './utils/mapRoutes';
|
||||||
|
import withAttr from './utils/withAttr';
|
||||||
import Notification from './models/Notification';
|
import Notification from './models/Notification';
|
||||||
import User from './models/User';
|
import User from './models/User';
|
||||||
import Post from './models/Post';
|
import Post from './models/Post';
|
||||||
@ -62,6 +64,7 @@ import highlight from './helpers/highlight';
|
|||||||
import username from './helpers/username';
|
import username from './helpers/username';
|
||||||
import userOnline from './helpers/userOnline';
|
import userOnline from './helpers/userOnline';
|
||||||
import listItems from './helpers/listItems';
|
import listItems from './helpers/listItems';
|
||||||
|
import Fragment from './Fragment';
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
extend: extend,
|
extend: extend,
|
||||||
@ -83,11 +86,13 @@ export default {
|
|||||||
'utils/ScrollListener': ScrollListener,
|
'utils/ScrollListener': ScrollListener,
|
||||||
'utils/stringToColor': stringToColor,
|
'utils/stringToColor': stringToColor,
|
||||||
'utils/subclassOf': subclassOf,
|
'utils/subclassOf': subclassOf,
|
||||||
|
'utils/setRouteWithForcedRefresh': setRouteWithForcedRefresh,
|
||||||
'utils/patchMithril': patchMithril,
|
'utils/patchMithril': patchMithril,
|
||||||
'utils/classList': classList,
|
'utils/classList': classList,
|
||||||
'utils/extractText': extractText,
|
'utils/extractText': extractText,
|
||||||
'utils/formatNumber': formatNumber,
|
'utils/formatNumber': formatNumber,
|
||||||
'utils/mapRoutes': mapRoutes,
|
'utils/mapRoutes': mapRoutes,
|
||||||
|
'utils/withAttr': withAttr,
|
||||||
'models/Notification': Notification,
|
'models/Notification': Notification,
|
||||||
'models/User': User,
|
'models/User': User,
|
||||||
'models/Post': Post,
|
'models/Post': Post,
|
||||||
@ -95,6 +100,7 @@ export default {
|
|||||||
'models/Group': Group,
|
'models/Group': Group,
|
||||||
'models/Forum': Forum,
|
'models/Forum': Forum,
|
||||||
Component: Component,
|
Component: Component,
|
||||||
|
Fragment: Fragment,
|
||||||
Translator: Translator,
|
Translator: Translator,
|
||||||
'components/AlertManager': AlertManager,
|
'components/AlertManager': AlertManager,
|
||||||
'components/Page': Page,
|
'components/Page': Page,
|
||||||
|
@ -7,7 +7,7 @@ import extract from '../utils/extract';
|
|||||||
* The `Alert` component represents an alert box, which contains a message,
|
* The `Alert` component represents an alert box, which contains a message,
|
||||||
* some controls, and may be dismissible.
|
* some controls, and may be dismissible.
|
||||||
*
|
*
|
||||||
* The alert may have the following special props:
|
* ### Attrs
|
||||||
*
|
*
|
||||||
* - `type` The type of alert this is. Will be used to give the alert a class
|
* - `type` The type of alert this is. Will be used to give the alert a class
|
||||||
* name of `Alert--{type}`.
|
* name of `Alert--{type}`.
|
||||||
@ -15,16 +15,16 @@ import extract from '../utils/extract';
|
|||||||
* - `dismissible` Whether or not the alert can be dismissed.
|
* - `dismissible` Whether or not the alert can be dismissed.
|
||||||
* - `ondismiss` A callback to run when the alert is dismissed.
|
* - `ondismiss` A callback to run when the alert is dismissed.
|
||||||
*
|
*
|
||||||
* All other props will be assigned as attributes on the alert element.
|
* All other attrs will be assigned as attributes on the DOM element.
|
||||||
*/
|
*/
|
||||||
export default class Alert extends Component {
|
export default class Alert extends Component {
|
||||||
view() {
|
view(vnode) {
|
||||||
const attrs = Object.assign({}, this.props);
|
const attrs = Object.assign({}, this.attrs);
|
||||||
|
|
||||||
const type = extract(attrs, 'type');
|
const type = extract(attrs, 'type');
|
||||||
attrs.className = 'Alert Alert--' + type + ' ' + (attrs.className || '');
|
attrs.className = 'Alert Alert--' + type + ' ' + (attrs.className || '');
|
||||||
|
|
||||||
const children = extract(attrs, 'children');
|
const content = extract(attrs, 'content') || vnode.children;
|
||||||
const controls = extract(attrs, 'controls') || [];
|
const controls = extract(attrs, 'controls') || [];
|
||||||
|
|
||||||
// If the alert is meant to be dismissible (which is the case by default),
|
// If the alert is meant to be dismissible (which is the case by default),
|
||||||
@ -40,7 +40,7 @@ export default class Alert extends Component {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div {...attrs}>
|
<div {...attrs}>
|
||||||
<span className="Alert-body">{children}</span>
|
<span className="Alert-body">{content}</span>
|
||||||
<ul className="Alert-controls">{listItems(controls.concat(dismissControl))}</ul>
|
<ul className="Alert-controls">{listItems(controls.concat(dismissControl))}</ul>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
@ -6,8 +6,10 @@ import Alert from './Alert';
|
|||||||
* be shown and dismissed.
|
* be shown and dismissed.
|
||||||
*/
|
*/
|
||||||
export default class AlertManager extends Component {
|
export default class AlertManager extends Component {
|
||||||
init() {
|
oninit(vnode) {
|
||||||
this.state = this.props.state;
|
super.oninit(vnode);
|
||||||
|
|
||||||
|
this.state = this.attrs.state;
|
||||||
}
|
}
|
||||||
|
|
||||||
view() {
|
view() {
|
||||||
@ -15,17 +17,12 @@ export default class AlertManager extends Component {
|
|||||||
<div className="AlertManager">
|
<div className="AlertManager">
|
||||||
{Object.entries(this.state.getActiveAlerts()).map(([key, alert]) => (
|
{Object.entries(this.state.getActiveAlerts()).map(([key, alert]) => (
|
||||||
<div className="AlertManager-alert">
|
<div className="AlertManager-alert">
|
||||||
{(alert.componentClass || Alert).component({ ...alert.attrs, ondismiss: this.state.dismiss.bind(this.state, key) })}
|
<alert.componentClass {...alert.attrs} ondismiss={this.state.dismiss.bind(this.state, key)}>
|
||||||
|
{alert.children}
|
||||||
|
</alert.componentClass>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</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;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
@ -6,18 +6,18 @@ import extract from '../utils/extract';
|
|||||||
* The `Badge` component represents a user/discussion badge, indicating some
|
* The `Badge` component represents a user/discussion badge, indicating some
|
||||||
* status (e.g. a discussion is stickied, a user is an admin).
|
* status (e.g. a discussion is stickied, a user is an admin).
|
||||||
*
|
*
|
||||||
* A badge may have the following special props:
|
* A badge may have the following special attrs:
|
||||||
*
|
*
|
||||||
* - `type` The type of badge this is. This will be used to give the badge a
|
* - `type` The type of badge this is. This will be used to give the badge a
|
||||||
* class name of `Badge--{type}`.
|
* class name of `Badge--{type}`.
|
||||||
* - `icon` The name of an icon to show inside the badge.
|
* - `icon` The name of an icon to show inside the badge.
|
||||||
* - `label`
|
* - `label`
|
||||||
*
|
*
|
||||||
* All other props will be assigned as attributes on the badge element.
|
* All other attrs will be assigned as attributes on the badge element.
|
||||||
*/
|
*/
|
||||||
export default class Badge extends Component {
|
export default class Badge extends Component {
|
||||||
view() {
|
view() {
|
||||||
const attrs = Object.assign({}, this.props);
|
const attrs = Object.assign({}, this.attrs);
|
||||||
const type = extract(attrs, 'type');
|
const type = extract(attrs, 'type');
|
||||||
const iconName = extract(attrs, 'icon');
|
const iconName = extract(attrs, 'icon');
|
||||||
|
|
||||||
@ -27,9 +27,9 @@ export default class Badge extends Component {
|
|||||||
return <span {...attrs}>{iconName ? icon(iconName, { className: 'Badge-icon' }) : m.trust(' ')}</span>;
|
return <span {...attrs}>{iconName ? icon(iconName, { className: 'Badge-icon' }) : m.trust(' ')}</span>;
|
||||||
}
|
}
|
||||||
|
|
||||||
config(isInitialized) {
|
oncreate(vnode) {
|
||||||
if (isInitialized) return;
|
super.oncreate(vnode);
|
||||||
|
|
||||||
if (this.props.label) this.$().tooltip();
|
if (this.attrs.label) this.$().tooltip();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -1,12 +1,15 @@
|
|||||||
import Component from '../Component';
|
import Component from '../Component';
|
||||||
import icon from '../helpers/icon';
|
import icon from '../helpers/icon';
|
||||||
|
import classList from '../utils/classList';
|
||||||
import extract from '../utils/extract';
|
import extract from '../utils/extract';
|
||||||
import extractText from '../utils/extractText';
|
import extractText from '../utils/extractText';
|
||||||
import LoadingIndicator from './LoadingIndicator';
|
import LoadingIndicator from './LoadingIndicator';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The `Button` component defines an element which, when clicked, performs an
|
* The `Button` component defines an element which, when clicked, performs an
|
||||||
* action. The button may have the following special props:
|
* action.
|
||||||
|
*
|
||||||
|
* ### Attrs
|
||||||
*
|
*
|
||||||
* - `icon` The name of the icon class. If specified, the button will be given a
|
* - `icon` The name of the icon class. If specified, the button will be given a
|
||||||
* 'has-icon' class name.
|
* 'has-icon' class name.
|
||||||
@ -15,41 +18,38 @@ import LoadingIndicator from './LoadingIndicator';
|
|||||||
* removed.
|
* removed.
|
||||||
* - `loading` Whether or not the button should be in a disabled loading state.
|
* - `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.
|
* All other attrs will be assigned as attributes on the button element.
|
||||||
*
|
*
|
||||||
* Note that a Button has no default class names. This is because a Button can
|
* 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.
|
* be used to represent any generic clickable control, like a menu item.
|
||||||
*/
|
*/
|
||||||
export default class Button extends Component {
|
export default class Button extends Component {
|
||||||
view() {
|
view(vnode) {
|
||||||
const attrs = Object.assign({}, this.props);
|
const attrs = Object.assign({}, this.attrs);
|
||||||
|
|
||||||
delete attrs.children;
|
|
||||||
|
|
||||||
attrs.className = attrs.className || '';
|
|
||||||
attrs.type = attrs.type || 'button';
|
attrs.type = attrs.type || 'button';
|
||||||
|
|
||||||
// If a tooltip was provided for buttons without additional content, we also
|
// If a tooltip was provided for buttons without additional content, we also
|
||||||
// use this tooltip as text for screen readers
|
// use this tooltip as text for screen readers
|
||||||
if (attrs.title && !this.props.children) {
|
if (attrs.title && !vnode.children) {
|
||||||
attrs['aria-label'] = attrs.title;
|
attrs['aria-label'] = attrs.title;
|
||||||
}
|
}
|
||||||
|
|
||||||
// If nothing else is provided, we use the textual button content as tooltip
|
// If nothing else is provided, we use the textual button content as tooltip
|
||||||
if (!attrs.title && this.props.children) {
|
if (!attrs.title && vnode.children) {
|
||||||
attrs.title = extractText(this.props.children);
|
attrs.title = extractText(vnode.children);
|
||||||
}
|
}
|
||||||
|
|
||||||
const iconName = extract(attrs, 'icon');
|
const iconName = extract(attrs, 'icon');
|
||||||
if (iconName) attrs.className += ' hasIcon';
|
|
||||||
|
|
||||||
const loading = extract(attrs, 'loading');
|
const loading = extract(attrs, 'loading');
|
||||||
if (attrs.disabled || loading) {
|
if (attrs.disabled || loading) {
|
||||||
attrs.className += ' disabled' + (loading ? ' loading' : '');
|
|
||||||
delete attrs.onclick;
|
delete attrs.onclick;
|
||||||
}
|
}
|
||||||
|
|
||||||
return <button {...attrs}>{this.getButtonContent()}</button>;
|
attrs.className = classList([attrs.className, iconName && 'hasIcon', (attrs.disabled || loading) && 'disabled', loading && 'loading']);
|
||||||
|
|
||||||
|
return <button {...attrs}>{this.getButtonContent(vnode.children)}</button>;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -58,13 +58,13 @@ export default class Button extends Component {
|
|||||||
* @return {*}
|
* @return {*}
|
||||||
* @protected
|
* @protected
|
||||||
*/
|
*/
|
||||||
getButtonContent() {
|
getButtonContent(children) {
|
||||||
const iconName = this.props.icon;
|
const iconName = this.attrs.icon;
|
||||||
|
|
||||||
return [
|
return [
|
||||||
iconName && iconName !== true ? icon(iconName, { className: 'Button-icon' }) : '',
|
iconName && iconName !== true ? icon(iconName, { className: 'Button-icon' }) : '',
|
||||||
this.props.children ? <span className="Button-label">{this.props.children}</span> : '',
|
children ? <span className="Button-label">{children}</span> : '',
|
||||||
this.props.loading ? LoadingIndicator.component({ size: 'tiny', className: 'LoadingIndicator--inline' }) : '',
|
this.attrs.loading ? <LoadingIndicator size="tiny" className="LoadingIndicator--inline" /> : '',
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -1,11 +1,13 @@
|
|||||||
import Component from '../Component';
|
import Component from '../Component';
|
||||||
import LoadingIndicator from './LoadingIndicator';
|
import LoadingIndicator from './LoadingIndicator';
|
||||||
import icon from '../helpers/icon';
|
import icon from '../helpers/icon';
|
||||||
|
import classList from '../utils/classList';
|
||||||
|
import withAttr from '../utils/withAttr';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The `Checkbox` component defines a checkbox input.
|
* The `Checkbox` component defines a checkbox input.
|
||||||
*
|
*
|
||||||
* ### Props
|
* ### Attrs
|
||||||
*
|
*
|
||||||
* - `state` Whether or not the checkbox is checked.
|
* - `state` Whether or not the checkbox is checked.
|
||||||
* - `className` The class name for the root element.
|
* - `className` The class name for the root element.
|
||||||
@ -15,19 +17,24 @@ import icon from '../helpers/icon';
|
|||||||
* - `children` A text label to display next to the checkbox.
|
* - `children` A text label to display next to the checkbox.
|
||||||
*/
|
*/
|
||||||
export default class Checkbox extends Component {
|
export default class Checkbox extends Component {
|
||||||
view() {
|
view(vnode) {
|
||||||
// Sometimes, false is stored in the DB as '0'. This is a temporary
|
// Sometimes, false is stored in the DB as '0'. This is a temporary
|
||||||
// conversion layer until a more robust settings encoding is introduced
|
// conversion layer until a more robust settings encoding is introduced
|
||||||
if (this.props.state === '0') this.props.state = false;
|
if (this.attrs.state === '0') this.attrs.state = false;
|
||||||
let className = 'Checkbox ' + (this.props.state ? 'on' : 'off') + ' ' + (this.props.className || '');
|
|
||||||
if (this.props.loading) className += ' loading';
|
const className = classList([
|
||||||
if (this.props.disabled) className += ' disabled';
|
'Checkbox',
|
||||||
|
this.attrs.state ? 'on' : 'off',
|
||||||
|
this.attrs.className,
|
||||||
|
this.attrs.loading && 'loading',
|
||||||
|
this.attrs.disabled && 'disabled',
|
||||||
|
]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<label className={className}>
|
<label className={className}>
|
||||||
<input type="checkbox" checked={this.props.state} disabled={this.props.disabled} onchange={m.withAttr('checked', this.onchange.bind(this))} />
|
<input type="checkbox" checked={this.attrs.state} disabled={this.attrs.disabled} onchange={withAttr('checked', this.onchange.bind(this))} />
|
||||||
<div className="Checkbox-display">{this.getDisplay()}</div>
|
<div className="Checkbox-display">{this.getDisplay()}</div>
|
||||||
{this.props.children}
|
{vnode.children}
|
||||||
</label>
|
</label>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -39,7 +46,7 @@ export default class Checkbox extends Component {
|
|||||||
* @protected
|
* @protected
|
||||||
*/
|
*/
|
||||||
getDisplay() {
|
getDisplay() {
|
||||||
return this.props.loading ? LoadingIndicator.component({ size: 'tiny' }) : icon(this.props.state ? 'fas fa-check' : 'fas fa-times');
|
return this.attrs.loading ? <LoadingIndicator size="tiny" /> : icon(this.attrs.state ? 'fas fa-check' : 'fas fa-times');
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -49,6 +56,6 @@ export default class Checkbox extends Component {
|
|||||||
* @protected
|
* @protected
|
||||||
*/
|
*/
|
||||||
onchange(checked) {
|
onchange(checked) {
|
||||||
if (this.props.onchange) this.props.onchange(checked, this);
|
if (this.attrs.onchange) this.attrs.onchange(checked, this);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -5,7 +5,7 @@ import Component from '../Component';
|
|||||||
* event handler that prevents closing the browser window/tab based on the
|
* event handler that prevents closing the browser window/tab based on the
|
||||||
* return value of a given callback prop.
|
* return value of a given callback prop.
|
||||||
*
|
*
|
||||||
* ### Props
|
* ### Attrs
|
||||||
*
|
*
|
||||||
* - `when` - a callback returning true when the browser should prompt for
|
* - `when` - a callback returning true when the browser should prompt for
|
||||||
* confirmation before closing the window/tab
|
* confirmation before closing the window/tab
|
||||||
@ -17,21 +17,24 @@ import Component from '../Component';
|
|||||||
*
|
*
|
||||||
*/
|
*/
|
||||||
export default class ConfirmDocumentUnload extends Component {
|
export default class ConfirmDocumentUnload extends Component {
|
||||||
config(isInitialized, context) {
|
handler() {
|
||||||
if (isInitialized) return;
|
return this.attrs.when() || undefined;
|
||||||
|
|
||||||
const handler = () => this.props.when() || undefined;
|
|
||||||
|
|
||||||
$(window).on('beforeunload', handler);
|
|
||||||
|
|
||||||
context.onunload = () => {
|
|
||||||
$(window).off('beforeunload', handler);
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
view() {
|
oncreate(vnode) {
|
||||||
|
super.oncreate(vnode);
|
||||||
|
|
||||||
|
this.boundHandler = this.handler.bind(this);
|
||||||
|
$(window).on('beforeunload', this.boundHandler);
|
||||||
|
}
|
||||||
|
|
||||||
|
onremove() {
|
||||||
|
$(window).off('beforeunload', this.boundHandler);
|
||||||
|
}
|
||||||
|
|
||||||
|
view(vnode) {
|
||||||
// To avoid having to render another wrapping <div> here, we assume that
|
// To avoid having to render another wrapping <div> here, we assume that
|
||||||
// this component is only wrapped around a single element / component.
|
// this component is only wrapped around a single element / component.
|
||||||
return this.props.children[0];
|
return vnode.children[0];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -6,7 +6,7 @@ import listItems from '../helpers/listItems';
|
|||||||
* The `Dropdown` component displays a button which, when clicked, shows a
|
* The `Dropdown` component displays a button which, when clicked, shows a
|
||||||
* dropdown menu beneath it.
|
* dropdown menu beneath it.
|
||||||
*
|
*
|
||||||
* ### Props
|
* ### Attrs
|
||||||
*
|
*
|
||||||
* - `buttonClassName` A class name to apply to the dropdown toggle button.
|
* - `buttonClassName` A class name to apply to the dropdown toggle button.
|
||||||
* - `menuClassName` A class name to apply to the dropdown menu.
|
* - `menuClassName` A class name to apply to the dropdown menu.
|
||||||
@ -19,33 +19,33 @@ import listItems from '../helpers/listItems';
|
|||||||
* The children will be displayed as a list inside of the dropdown menu.
|
* The children will be displayed as a list inside of the dropdown menu.
|
||||||
*/
|
*/
|
||||||
export default class Dropdown extends Component {
|
export default class Dropdown extends Component {
|
||||||
static initProps(props) {
|
static initAttrs(attrs) {
|
||||||
super.initProps(props);
|
attrs.className = attrs.className || '';
|
||||||
|
attrs.buttonClassName = attrs.buttonClassName || '';
|
||||||
props.className = props.className || '';
|
attrs.menuClassName = attrs.menuClassName || '';
|
||||||
props.buttonClassName = props.buttonClassName || '';
|
attrs.label = attrs.label || '';
|
||||||
props.menuClassName = props.menuClassName || '';
|
attrs.caretIcon = typeof attrs.caretIcon !== 'undefined' ? attrs.caretIcon : 'fas fa-caret-down';
|
||||||
props.label = props.label || '';
|
|
||||||
props.caretIcon = typeof props.caretIcon !== 'undefined' ? props.caretIcon : 'fas fa-caret-down';
|
|
||||||
}
|
}
|
||||||
|
|
||||||
init() {
|
oninit(vnode) {
|
||||||
|
super.oninit(vnode);
|
||||||
|
|
||||||
this.showing = false;
|
this.showing = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
view() {
|
view(vnode) {
|
||||||
const items = this.props.children ? listItems(this.props.children) : [];
|
const items = vnode.children ? listItems(vnode.children) : [];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={'ButtonGroup Dropdown dropdown ' + this.props.className + ' itemCount' + items.length + (this.showing ? ' open' : '')}>
|
<div className={'ButtonGroup Dropdown dropdown ' + this.attrs.className + ' itemCount' + items.length + (this.showing ? ' open' : '')}>
|
||||||
{this.getButton()}
|
{this.getButton(vnode.children)}
|
||||||
{this.getMenu(items)}
|
{this.getMenu(items)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
config(isInitialized) {
|
oncreate(vnode) {
|
||||||
if (isInitialized) return;
|
super.oncreate(vnode);
|
||||||
|
|
||||||
// When opening the dropdown menu, work out if the menu goes beyond the
|
// 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
|
// bottom of the viewport. If it does, we will apply class to make it show
|
||||||
@ -53,8 +53,8 @@ export default class Dropdown extends Component {
|
|||||||
this.$().on('shown.bs.dropdown', () => {
|
this.$().on('shown.bs.dropdown', () => {
|
||||||
this.showing = true;
|
this.showing = true;
|
||||||
|
|
||||||
if (this.props.onshow) {
|
if (this.attrs.onshow) {
|
||||||
this.props.onshow();
|
this.attrs.onshow();
|
||||||
}
|
}
|
||||||
|
|
||||||
m.redraw();
|
m.redraw();
|
||||||
@ -76,8 +76,8 @@ export default class Dropdown extends Component {
|
|||||||
this.$().on('hidden.bs.dropdown', () => {
|
this.$().on('hidden.bs.dropdown', () => {
|
||||||
this.showing = false;
|
this.showing = false;
|
||||||
|
|
||||||
if (this.props.onhide) {
|
if (this.attrs.onhide) {
|
||||||
this.props.onhide();
|
this.attrs.onhide();
|
||||||
}
|
}
|
||||||
|
|
||||||
m.redraw();
|
m.redraw();
|
||||||
@ -90,10 +90,10 @@ export default class Dropdown extends Component {
|
|||||||
* @return {*}
|
* @return {*}
|
||||||
* @protected
|
* @protected
|
||||||
*/
|
*/
|
||||||
getButton() {
|
getButton(children) {
|
||||||
return (
|
return (
|
||||||
<button className={'Dropdown-toggle ' + this.props.buttonClassName} data-toggle="dropdown" onclick={this.props.onclick}>
|
<button className={'Dropdown-toggle ' + this.attrs.buttonClassName} data-toggle="dropdown" onclick={this.attrs.onclick}>
|
||||||
{this.getButtonContent()}
|
{this.getButtonContent(children)}
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -104,15 +104,15 @@ export default class Dropdown extends Component {
|
|||||||
* @return {*}
|
* @return {*}
|
||||||
* @protected
|
* @protected
|
||||||
*/
|
*/
|
||||||
getButtonContent() {
|
getButtonContent(children) {
|
||||||
return [
|
return [
|
||||||
this.props.icon ? icon(this.props.icon, { className: 'Button-icon' }) : '',
|
this.attrs.icon ? icon(this.attrs.icon, { className: 'Button-icon' }) : '',
|
||||||
<span className="Button-label">{this.props.label}</span>,
|
<span className="Button-label">{this.attrs.label}</span>,
|
||||||
this.props.caretIcon ? icon(this.props.caretIcon, { className: 'Button-caret' }) : '',
|
this.attrs.caretIcon ? icon(this.attrs.caretIcon, { className: 'Button-caret' }) : '',
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
getMenu(items) {
|
getMenu(items) {
|
||||||
return <ul className={'Dropdown-menu dropdown-menu ' + this.props.menuClassName}>{items}</ul>;
|
return <ul className={'Dropdown-menu dropdown-menu ' + this.attrs.menuClassName}>{items}</ul>;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -11,11 +11,11 @@ import listItems from '../helpers/listItems';
|
|||||||
* The children should be an array of items to show in the fieldset.
|
* The children should be an array of items to show in the fieldset.
|
||||||
*/
|
*/
|
||||||
export default class FieldSet extends Component {
|
export default class FieldSet extends Component {
|
||||||
view() {
|
view(vnode) {
|
||||||
return (
|
return (
|
||||||
<fieldset className={this.props.className}>
|
<fieldset className={this.attrs.className}>
|
||||||
<legend>{this.props.label}</legend>
|
<legend>{this.attrs.label}</legend>
|
||||||
<ul>{listItems(this.props.children)}</ul>
|
<ul>{listItems(vnode.children)}</ul>
|
||||||
</fieldset>
|
</fieldset>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -1,16 +1,16 @@
|
|||||||
import Badge from './Badge';
|
import Badge from './Badge';
|
||||||
|
|
||||||
export default class GroupBadge extends Badge {
|
export default class GroupBadge extends Badge {
|
||||||
static initProps(props) {
|
static initAttrs(attrs) {
|
||||||
super.initProps(props);
|
super.initAttrs(attrs);
|
||||||
|
|
||||||
if (props.group) {
|
if (attrs.group) {
|
||||||
props.icon = props.group.icon();
|
attrs.icon = attrs.group.icon();
|
||||||
props.style = { backgroundColor: props.group.color() };
|
attrs.style = { backgroundColor: attrs.group.color() };
|
||||||
props.label = typeof props.label === 'undefined' ? props.group.nameSingular() : props.label;
|
attrs.label = typeof attrs.label === 'undefined' ? attrs.group.nameSingular() : attrs.label;
|
||||||
props.type = 'group--' + props.group.id();
|
attrs.type = 'group--' + attrs.group.id();
|
||||||
|
|
||||||
delete props.group;
|
delete attrs.group;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -3,9 +3,9 @@ import Button from './Button';
|
|||||||
/**
|
/**
|
||||||
* The `LinkButton` component defines a `Button` which links to a route.
|
* The `LinkButton` component defines a `Button` which links to a route.
|
||||||
*
|
*
|
||||||
* ### Props
|
* ### Attrs
|
||||||
*
|
*
|
||||||
* All of the props accepted by `Button`, plus:
|
* All of the attrs accepted by `Button`, plus:
|
||||||
*
|
*
|
||||||
* - `active` Whether or not the page that this button links to is currently
|
* - `active` Whether or not the page that this button links to is currently
|
||||||
* active.
|
* active.
|
||||||
@ -13,26 +13,28 @@ import Button from './Button';
|
|||||||
* the `active` prop will automatically be set to true.
|
* the `active` prop will automatically be set to true.
|
||||||
*/
|
*/
|
||||||
export default class LinkButton extends Button {
|
export default class LinkButton extends Button {
|
||||||
static initProps(props) {
|
static initAttrs(attrs) {
|
||||||
props.active = this.isActive(props);
|
super.initAttrs(attrs);
|
||||||
props.config = props.config || m.route;
|
|
||||||
|
attrs.active = this.isActive(attrs);
|
||||||
}
|
}
|
||||||
|
|
||||||
view() {
|
view(vnode) {
|
||||||
const vdom = super.view();
|
const vdom = super.view(vnode);
|
||||||
|
|
||||||
vdom.tag = 'a';
|
vdom.tag = m.route.Link;
|
||||||
|
vdom.attrs.active = String(vdom.attrs.active);
|
||||||
|
|
||||||
return vdom;
|
return vdom;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Determine whether a component with the given props is 'active'.
|
* Determine whether a component with the given attrs is 'active'.
|
||||||
*
|
*
|
||||||
* @param {Object} props
|
* @param {Object} attrs
|
||||||
* @return {Boolean}
|
* @return {Boolean}
|
||||||
*/
|
*/
|
||||||
static isActive(props) {
|
static isActive(attrs) {
|
||||||
return typeof props.active !== 'undefined' ? props.active : m.route() === props.href;
|
return typeof attrs.active !== 'undefined' ? attrs.active : m.route.get() === attrs.href;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -2,16 +2,17 @@ import Component from '../Component';
|
|||||||
import { Spinner } from 'spin.js';
|
import { Spinner } from 'spin.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The `LoadingIndicator` component displays a loading spinner with spin.js. It
|
* The `LoadingIndicator` component displays a loading spinner with spin.js.
|
||||||
* may have the following special props:
|
*
|
||||||
|
* ### Attrs
|
||||||
*
|
*
|
||||||
* - `size` The spin.js size preset to use. Defaults to 'small'.
|
* - `size` The spin.js size preset to use. Defaults to 'small'.
|
||||||
*
|
*
|
||||||
* All other props will be assigned as attributes on the element.
|
* All other attrs will be assigned as attributes on the DOM element.
|
||||||
*/
|
*/
|
||||||
export default class LoadingIndicator extends Component {
|
export default class LoadingIndicator extends Component {
|
||||||
view() {
|
view() {
|
||||||
const attrs = Object.assign({}, this.props);
|
const attrs = Object.assign({}, this.attrs);
|
||||||
|
|
||||||
attrs.className = 'LoadingIndicator ' + (attrs.className || '');
|
attrs.className = 'LoadingIndicator ' + (attrs.className || '');
|
||||||
delete attrs.size;
|
delete attrs.size;
|
||||||
@ -19,12 +20,12 @@ export default class LoadingIndicator extends Component {
|
|||||||
return <div {...attrs}>{m.trust(' ')}</div>;
|
return <div {...attrs}>{m.trust(' ')}</div>;
|
||||||
}
|
}
|
||||||
|
|
||||||
config(isInitialized) {
|
oncreate(vnode) {
|
||||||
if (isInitialized) return;
|
super.oncreate(vnode);
|
||||||
|
|
||||||
const options = { zIndex: 'auto', color: this.$().css('color') };
|
const options = { zIndex: 'auto', color: this.$().css('color') };
|
||||||
|
|
||||||
switch (this.props.size) {
|
switch (this.attrs.size) {
|
||||||
case 'large':
|
case 'large':
|
||||||
Object.assign(options, { lines: 10, length: 8, width: 4, radius: 8 });
|
Object.assign(options, { lines: 10, length: 8, width: 4, radius: 8 });
|
||||||
break;
|
break;
|
||||||
|
@ -14,23 +14,21 @@ export default class Modal extends Component {
|
|||||||
*/
|
*/
|
||||||
static isDismissible = true;
|
static isDismissible = true;
|
||||||
|
|
||||||
init() {
|
/**
|
||||||
/**
|
* Attributes for an alert component to show below the header.
|
||||||
* Attributes for an alert component to show below the header.
|
*
|
||||||
*
|
* @type {object}
|
||||||
* @type {object}
|
*/
|
||||||
*/
|
alertAttrs = null;
|
||||||
this.alertAttrs = null;
|
|
||||||
|
oncreate(vnode) {
|
||||||
|
super.oncreate(vnode);
|
||||||
|
|
||||||
|
this.attrs.onshow(() => this.onready());
|
||||||
}
|
}
|
||||||
|
|
||||||
config(isInitialized, context) {
|
onremove() {
|
||||||
if (isInitialized) return;
|
this.attrs.onhide();
|
||||||
|
|
||||||
this.props.onshow(() => this.onready());
|
|
||||||
|
|
||||||
context.onunload = () => {
|
|
||||||
this.props.onhide();
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
view() {
|
view() {
|
||||||
@ -109,7 +107,7 @@ export default class Modal extends Component {
|
|||||||
* Hide the modal.
|
* Hide the modal.
|
||||||
*/
|
*/
|
||||||
hide() {
|
hide() {
|
||||||
this.props.onhide();
|
this.attrs.onhide();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -6,12 +6,8 @@ import Component from '../Component';
|
|||||||
* overwrite the previous one.
|
* overwrite the previous one.
|
||||||
*/
|
*/
|
||||||
export default class ModalManager extends Component {
|
export default class ModalManager extends Component {
|
||||||
init() {
|
|
||||||
this.state = this.props.state;
|
|
||||||
}
|
|
||||||
|
|
||||||
view() {
|
view() {
|
||||||
const modal = this.state.modal;
|
const modal = this.attrs.state.modal;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="ModalManager modal fade">
|
<div className="ModalManager modal fade">
|
||||||
@ -20,22 +16,17 @@ export default class ModalManager extends Component {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
config(isInitialized, context) {
|
oncreate(vnode) {
|
||||||
if (isInitialized) return;
|
super.oncreate(vnode);
|
||||||
|
|
||||||
// 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;
|
|
||||||
|
|
||||||
// Ensure the modal state is notified about a closed modal, even when the
|
// Ensure the modal state is notified about a closed modal, even when the
|
||||||
// DOM-based Bootstrap JavaScript code triggered the closing of the modal,
|
// DOM-based Bootstrap JavaScript code triggered the closing of the modal,
|
||||||
// e.g. via ESC key or a click on the modal backdrop.
|
// e.g. via ESC key or a click on the modal backdrop.
|
||||||
this.$().on('hidden.bs.modal', this.state.close.bind(this.state));
|
this.$().on('hidden.bs.modal', this.attrs.state.close.bind(this.attrs.state));
|
||||||
}
|
}
|
||||||
|
|
||||||
animateShow(readyCallback) {
|
animateShow(readyCallback) {
|
||||||
const dismissible = !!this.state.modal.componentClass.isDismissible;
|
const dismissible = !!this.attrs.state.modal.componentClass.isDismissible;
|
||||||
|
|
||||||
this.$()
|
this.$()
|
||||||
.one('shown.bs.modal', readyCallback)
|
.one('shown.bs.modal', readyCallback)
|
||||||
|
@ -11,7 +11,7 @@ import LinkButton from './LinkButton';
|
|||||||
* If the app has a pane, it will also include a 'pin' button which toggles the
|
* If the app has a pane, it will also include a 'pin' button which toggles the
|
||||||
* pinned state of the pane.
|
* pinned state of the pane.
|
||||||
*
|
*
|
||||||
* Accepts the following props:
|
* Accepts the following attrs:
|
||||||
*
|
*
|
||||||
* - `className` The name of a class to set on the root element.
|
* - `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
|
* - `drawer` Whether or not to show a button to toggle the app's drawer if
|
||||||
@ -23,7 +23,7 @@ export default class Navigation extends Component {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={'Navigation ButtonGroup ' + (this.props.className || '')}
|
className={'Navigation ButtonGroup ' + (this.attrs.className || '')}
|
||||||
onmouseenter={pane && pane.show.bind(pane)}
|
onmouseenter={pane && pane.show.bind(pane)}
|
||||||
onmouseleave={pane && pane.onmouseleave.bind(pane)}
|
onmouseleave={pane && pane.onmouseleave.bind(pane)}
|
||||||
>
|
>
|
||||||
@ -32,13 +32,6 @@ export default class Navigation extends Component {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
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.
|
* Get the back button.
|
||||||
*
|
*
|
||||||
@ -54,7 +47,6 @@ export default class Navigation extends Component {
|
|||||||
href: history.backUrl(),
|
href: history.backUrl(),
|
||||||
icon: 'fas fa-chevron-left',
|
icon: 'fas fa-chevron-left',
|
||||||
title: previous.title,
|
title: previous.title,
|
||||||
config: () => {},
|
|
||||||
onclick: (e) => {
|
onclick: (e) => {
|
||||||
if (e.shiftKey || e.ctrlKey || e.metaKey || e.which === 2) return;
|
if (e.shiftKey || e.ctrlKey || e.metaKey || e.which === 2) return;
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
@ -88,7 +80,7 @@ export default class Navigation extends Component {
|
|||||||
* @protected
|
* @protected
|
||||||
*/
|
*/
|
||||||
getDrawerButton() {
|
getDrawerButton() {
|
||||||
if (!this.props.drawer) return '';
|
if (!this.attrs.drawer) return '';
|
||||||
|
|
||||||
const { drawer } = app;
|
const { drawer } = app;
|
||||||
const user = app.session.user;
|
const user = app.session.user;
|
||||||
|
@ -7,10 +7,14 @@ import PageState from '../states/PageState';
|
|||||||
* @abstract
|
* @abstract
|
||||||
*/
|
*/
|
||||||
export default class Page extends Component {
|
export default class Page extends Component {
|
||||||
init() {
|
oninit(vnode) {
|
||||||
|
super.oninit(vnode);
|
||||||
|
|
||||||
app.previous = app.current;
|
app.previous = app.current;
|
||||||
app.current = new PageState(this.constructor);
|
app.current = new PageState(this.constructor);
|
||||||
|
|
||||||
|
this.onNewRoute();
|
||||||
|
|
||||||
app.drawer.hide();
|
app.drawer.hide();
|
||||||
app.modal.close();
|
app.modal.close();
|
||||||
|
|
||||||
@ -22,13 +26,27 @@ export default class Page extends Component {
|
|||||||
this.bodyClass = '';
|
this.bodyClass = '';
|
||||||
}
|
}
|
||||||
|
|
||||||
config(isInitialized, context) {
|
/**
|
||||||
if (isInitialized) return;
|
* A collections of actions to run when the route changes.
|
||||||
|
* This is extracted here, and not hardcoded in oninit, as oninit is not called
|
||||||
|
* when a different route is handled by the same component, but we still need to
|
||||||
|
* adjust the current route name.
|
||||||
|
*/
|
||||||
|
onNewRoute() {
|
||||||
|
app.current.set('routeName', this.attrs.routeName);
|
||||||
|
}
|
||||||
|
|
||||||
|
oncreate(vnode) {
|
||||||
|
super.oncreate(vnode);
|
||||||
|
|
||||||
if (this.bodyClass) {
|
if (this.bodyClass) {
|
||||||
$('#app').addClass(this.bodyClass);
|
$('#app').addClass(this.bodyClass);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
context.onunload = () => $('#app').removeClass(this.bodyClass);
|
onremove() {
|
||||||
|
if (this.bodyClass) {
|
||||||
|
$('#app').removeClass(this.bodyClass);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -4,7 +4,7 @@ import Component from '../Component';
|
|||||||
* The `Placeholder` component displays a muted text with some call to action,
|
* The `Placeholder` component displays a muted text with some call to action,
|
||||||
* usually used as an empty state.
|
* usually used as an empty state.
|
||||||
*
|
*
|
||||||
* ### Props
|
* ### Attrs
|
||||||
*
|
*
|
||||||
* - `text`
|
* - `text`
|
||||||
*/
|
*/
|
||||||
@ -12,7 +12,7 @@ export default class Placeholder extends Component {
|
|||||||
view() {
|
view() {
|
||||||
return (
|
return (
|
||||||
<div className="Placeholder">
|
<div className="Placeholder">
|
||||||
<p>{this.props.text}</p>
|
<p>{this.attrs.text}</p>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -6,11 +6,11 @@ export default class RequestErrorModal extends Modal {
|
|||||||
}
|
}
|
||||||
|
|
||||||
title() {
|
title() {
|
||||||
return this.props.error.xhr ? `${this.props.error.xhr.status} ${this.props.error.xhr.statusText}` : '';
|
return this.attrs.error.xhr ? `${this.attrs.error.xhr.status} ${this.attrs.error.xhr.statusText}` : '';
|
||||||
}
|
}
|
||||||
|
|
||||||
content() {
|
content() {
|
||||||
const { error, formattedError } = this.props;
|
const { error, formattedError } = this.attrs;
|
||||||
|
|
||||||
let responseText;
|
let responseText;
|
||||||
|
|
||||||
@ -31,7 +31,7 @@ export default class RequestErrorModal extends Modal {
|
|||||||
return (
|
return (
|
||||||
<div className="Modal-body">
|
<div className="Modal-body">
|
||||||
<pre>
|
<pre>
|
||||||
{this.props.error.options.method} {this.props.error.options.url}
|
{this.attrs.error.options.method} {this.attrs.error.options.url}
|
||||||
<br />
|
<br />
|
||||||
<br />
|
<br />
|
||||||
{responseText}
|
{responseText}
|
||||||
|
@ -1,9 +1,10 @@
|
|||||||
import Component from '../Component';
|
import Component from '../Component';
|
||||||
import icon from '../helpers/icon';
|
import icon from '../helpers/icon';
|
||||||
|
import withAttr from '../utils/withAttr';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The `Select` component displays a <select> input, surrounded with some extra
|
* The `Select` component displays a <select> input, surrounded with some extra
|
||||||
* elements for styling. It accepts the following props:
|
* elements for styling. It accepts the following attrs:
|
||||||
*
|
*
|
||||||
* - `options` A map of option values to labels.
|
* - `options` A map of option values to labels.
|
||||||
* - `onchange` A callback to run when the selected value is changed.
|
* - `onchange` A callback to run when the selected value is changed.
|
||||||
@ -12,13 +13,13 @@ import icon from '../helpers/icon';
|
|||||||
*/
|
*/
|
||||||
export default class Select extends Component {
|
export default class Select extends Component {
|
||||||
view() {
|
view() {
|
||||||
const { options, onchange, value, disabled } = this.props;
|
const { options, onchange, value, disabled } = this.attrs;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<span className="Select">
|
<span className="Select">
|
||||||
<select
|
<select
|
||||||
className="Select-input FormControl"
|
className="Select-input FormControl"
|
||||||
onchange={onchange ? m.withAttr('value', onchange.bind(this)) : undefined}
|
onchange={onchange ? withAttr('value', onchange.bind(this)) : undefined}
|
||||||
value={value}
|
value={value}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
>
|
>
|
||||||
|
@ -1,31 +1,49 @@
|
|||||||
import Dropdown from './Dropdown';
|
import Dropdown from './Dropdown';
|
||||||
import icon from '../helpers/icon';
|
import icon from '../helpers/icon';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Determines via a vnode is currently "active".
|
||||||
|
* Due to changes in Mithril 2, attrs will not be instantiated until AFTER view()
|
||||||
|
* is initially called on the parent component, so we can not always depend on the
|
||||||
|
* active attr to determine which element should be displayed as the "active child".
|
||||||
|
*
|
||||||
|
* This is a temporary patch, and as so, is not exported / placed in utils.
|
||||||
|
*/
|
||||||
|
function isActive(vnode) {
|
||||||
|
const tag = vnode.tag;
|
||||||
|
|
||||||
|
if ('initAttrs' in tag) {
|
||||||
|
tag.initAttrs(vnode.attrs);
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'isActive' in tag ? tag.isActive(vnode.attrs) : vnode.attrs.active;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The `SelectDropdown` component is the same as a `Dropdown`, except the toggle
|
* 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
|
* button's label is set as the label of the first child which has a truthy
|
||||||
* `active` prop.
|
* `active` prop.
|
||||||
*
|
*
|
||||||
* ### Props
|
* ### Attrs
|
||||||
*
|
*
|
||||||
* - `caretIcon`
|
* - `caretIcon`
|
||||||
* - `defaultLabel`
|
* - `defaultLabel`
|
||||||
*/
|
*/
|
||||||
export default class SelectDropdown extends Dropdown {
|
export default class SelectDropdown extends Dropdown {
|
||||||
static initProps(props) {
|
static initAttrs(attrs) {
|
||||||
props.caretIcon = typeof props.caretIcon !== 'undefined' ? props.caretIcon : 'fas fa-sort';
|
attrs.caretIcon = typeof attrs.caretIcon !== 'undefined' ? attrs.caretIcon : 'fas fa-sort';
|
||||||
|
|
||||||
super.initProps(props);
|
super.initAttrs(attrs);
|
||||||
|
|
||||||
props.className += ' Dropdown--select';
|
attrs.className += ' Dropdown--select';
|
||||||
}
|
}
|
||||||
|
|
||||||
getButtonContent() {
|
getButtonContent(children) {
|
||||||
const activeChild = this.props.children.filter((child) => child.props.active)[0];
|
const activeChild = children.find(isActive);
|
||||||
let label = (activeChild && activeChild.props.children) || this.props.defaultLabel;
|
let label = (activeChild && activeChild.children) || this.attrs.defaultLabel;
|
||||||
|
|
||||||
if (label instanceof Array) label = label[0];
|
if (label instanceof Array) label = label[0];
|
||||||
|
|
||||||
return [<span className="Button-label">{label}</span>, icon(this.props.caretIcon, { className: 'Button-caret' })];
|
return [<span className="Button-label">{label}</span>, icon(this.attrs.caretIcon, { className: 'Button-caret' })];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -7,25 +7,25 @@ import icon from '../helpers/icon';
|
|||||||
* is displayed as its own button prior to the toggle button.
|
* is displayed as its own button prior to the toggle button.
|
||||||
*/
|
*/
|
||||||
export default class SplitDropdown extends Dropdown {
|
export default class SplitDropdown extends Dropdown {
|
||||||
static initProps(props) {
|
static initAttrs(attrs) {
|
||||||
super.initProps(props);
|
super.initAttrs(attrs);
|
||||||
|
|
||||||
props.className += ' Dropdown--split';
|
attrs.className += ' Dropdown--split';
|
||||||
props.menuClassName += ' Dropdown-menu--right';
|
attrs.menuClassName += ' Dropdown-menu--right';
|
||||||
}
|
}
|
||||||
|
|
||||||
getButton() {
|
getButton(children) {
|
||||||
// Make a copy of the props of the first child component. We will assign
|
// Make a copy of the attrs of the first child component. We will assign
|
||||||
// these props to a new button, so that it has exactly the same behaviour as
|
// these attrs to a new button, so that it has exactly the same behaviour as
|
||||||
// the first child.
|
// the first child.
|
||||||
const firstChild = this.getFirstChild();
|
const firstChild = this.getFirstChild(children);
|
||||||
const buttonProps = Object.assign({}, firstChild.props);
|
const buttonAttrs = Object.assign({}, firstChild.attrs);
|
||||||
buttonProps.className = (buttonProps.className || '') + ' SplitDropdown-button Button ' + this.props.buttonClassName;
|
buttonAttrs.className = (buttonAttrs.className || '') + ' SplitDropdown-button Button ' + this.attrs.buttonClassName;
|
||||||
|
|
||||||
return [
|
return [
|
||||||
Button.component(buttonProps),
|
Button.component(buttonAttrs, firstChild.children),
|
||||||
<button className={'Dropdown-toggle Button Button--icon ' + this.props.buttonClassName} data-toggle="dropdown">
|
<button className={'Dropdown-toggle Button Button--icon ' + this.attrs.buttonClassName} data-toggle="dropdown">
|
||||||
{icon(this.props.icon, { className: 'Button-icon' })}
|
{icon(this.attrs.icon, { className: 'Button-icon' })}
|
||||||
{icon('fas fa-caret-down', { className: 'Button-caret' })}
|
{icon('fas fa-caret-down', { className: 'Button-caret' })}
|
||||||
</button>,
|
</button>,
|
||||||
];
|
];
|
||||||
@ -38,8 +38,8 @@ export default class SplitDropdown extends Dropdown {
|
|||||||
* @return {*}
|
* @return {*}
|
||||||
* @protected
|
* @protected
|
||||||
*/
|
*/
|
||||||
getFirstChild() {
|
getFirstChild(children) {
|
||||||
let firstChild = this.props.children;
|
let firstChild = children;
|
||||||
|
|
||||||
while (firstChild instanceof Array) firstChild = firstChild[0];
|
while (firstChild instanceof Array) firstChild = firstChild[0];
|
||||||
|
|
||||||
|
@ -5,13 +5,13 @@ import Checkbox from './Checkbox';
|
|||||||
* a tick/cross one.
|
* a tick/cross one.
|
||||||
*/
|
*/
|
||||||
export default class Switch extends Checkbox {
|
export default class Switch extends Checkbox {
|
||||||
static initProps(props) {
|
static initAttrs(attrs) {
|
||||||
super.initProps(props);
|
super.initAttrs(attrs);
|
||||||
|
|
||||||
props.className = (props.className || '') + ' Checkbox--switch';
|
attrs.className = (attrs.className || '') + ' Checkbox--switch';
|
||||||
}
|
}
|
||||||
|
|
||||||
getDisplay() {
|
getDisplay() {
|
||||||
return this.props.loading ? super.getDisplay() : '';
|
return this.attrs.loading ? super.getDisplay() : '';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -2,14 +2,14 @@ import Separator from '../components/Separator';
|
|||||||
import classList from '../utils/classList';
|
import classList from '../utils/classList';
|
||||||
|
|
||||||
function isSeparator(item) {
|
function isSeparator(item) {
|
||||||
return item && item.component === Separator;
|
return item.tag === Separator;
|
||||||
}
|
}
|
||||||
|
|
||||||
function withoutUnnecessarySeparators(items) {
|
function withoutUnnecessarySeparators(items) {
|
||||||
const newItems = [];
|
const newItems = [];
|
||||||
let prevItem;
|
let prevItem;
|
||||||
|
|
||||||
items.forEach((item, i) => {
|
items.filter(Boolean).forEach((item, i) => {
|
||||||
if (!isSeparator(item) || (prevItem && !isSeparator(prevItem) && i !== items.length - 1)) {
|
if (!isSeparator(item) || (prevItem && !isSeparator(prevItem) && i !== items.length - 1)) {
|
||||||
prevItem = item;
|
prevItem = item;
|
||||||
newItems.push(item);
|
newItems.push(item);
|
||||||
@ -30,21 +30,27 @@ export default function listItems(items) {
|
|||||||
if (!(items instanceof Array)) items = [items];
|
if (!(items instanceof Array)) items = [items];
|
||||||
|
|
||||||
return withoutUnnecessarySeparators(items).map((item) => {
|
return withoutUnnecessarySeparators(items).map((item) => {
|
||||||
const isListItem = item.component && item.component.isListItem;
|
const isListItem = item.tag && item.tag.isListItem;
|
||||||
const active = item.component && item.component.isActive && item.component.isActive(item.props);
|
const active = item.tag && item.tag.isActive && item.tag.isActive(item.attrs);
|
||||||
const className = item.props ? item.props.itemClassName : item.itemClassName;
|
const className = (item.attrs && item.attrs.itemClassName) || item.itemClassName;
|
||||||
|
|
||||||
if (isListItem) {
|
if (isListItem) {
|
||||||
item.attrs = item.attrs || {};
|
item.attrs = item.attrs || {};
|
||||||
item.attrs.key = item.attrs.key || item.itemName;
|
item.attrs.key = item.attrs.key || item.itemName;
|
||||||
|
item.key = item.attrs.key;
|
||||||
}
|
}
|
||||||
|
|
||||||
return isListItem ? (
|
const node = isListItem ? (
|
||||||
item
|
item
|
||||||
) : (
|
) : (
|
||||||
<li className={classList([item.itemName ? 'item-' + item.itemName : '', className, active ? 'active' : ''])} key={item.itemName}>
|
<li
|
||||||
|
className={classList([className, item.itemName && `item-${item.itemName}`, active && 'active'])}
|
||||||
|
key={(item.attrs && item.attrs.key) || item.itemName}
|
||||||
|
>
|
||||||
{item}
|
{item}
|
||||||
</li>
|
</li>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
return node;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
@ -13,7 +13,21 @@ export default class AlertManagerState {
|
|||||||
/**
|
/**
|
||||||
* Show an Alert in the alerts area.
|
* Show an Alert in the alerts area.
|
||||||
*/
|
*/
|
||||||
show(attrs, componentClass = Alert) {
|
show(arg1, arg2, arg3) {
|
||||||
|
let componentClass = Alert;
|
||||||
|
let attrs = {};
|
||||||
|
let children;
|
||||||
|
if (arguments.length == 1) {
|
||||||
|
children = arg1;
|
||||||
|
} else if (arguments.length == 2) {
|
||||||
|
attrs = arg1;
|
||||||
|
children = arg2;
|
||||||
|
} else if (arguments.length == 3) {
|
||||||
|
componentClass = arg1;
|
||||||
|
attrs = arg2;
|
||||||
|
children = arg3;
|
||||||
|
}
|
||||||
|
|
||||||
// Breaking Change Compliance Warning, Remove in Beta 15.
|
// Breaking Change Compliance Warning, Remove in Beta 15.
|
||||||
// This is applied to the first argument (attrs) because previously, the alert was passed as the first argument.
|
// This is applied to the first argument (attrs) because previously, the alert was passed as the first argument.
|
||||||
if (attrs === Alert || attrs instanceof Alert) {
|
if (attrs === Alert || attrs instanceof Alert) {
|
||||||
@ -22,7 +36,7 @@ export default class AlertManagerState {
|
|||||||
throw new Error('The AlertManager can only show Alerts. Whichever extension triggered this alert should be updated to comply with beta 14.');
|
throw new Error('The AlertManager can only show Alerts. Whichever extension triggered this alert should be updated to comply with beta 14.');
|
||||||
}
|
}
|
||||||
// End Change Compliance Warning, Remove in Beta 15
|
// End Change Compliance Warning, Remove in Beta 15
|
||||||
this.activeAlerts[++this.alertId] = { attrs, componentClass };
|
this.activeAlerts[++this.alertId] = { children, attrs, componentClass };
|
||||||
m.redraw();
|
m.redraw();
|
||||||
|
|
||||||
return this.alertId;
|
return this.alertId;
|
||||||
|
@ -32,7 +32,7 @@ export default class ModalManagerState {
|
|||||||
|
|
||||||
this.modal = { componentClass, attrs };
|
this.modal = { componentClass, attrs };
|
||||||
|
|
||||||
m.redraw(true);
|
m.redraw.sync();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -50,7 +50,7 @@ export default class ModalManagerState {
|
|||||||
// ahead.
|
// ahead.
|
||||||
this.closeTimeout = setTimeout(() => {
|
this.closeTimeout = setTimeout(() => {
|
||||||
this.modal = null;
|
this.modal = null;
|
||||||
m.lazyRedraw();
|
m.redraw();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -1,20 +1,25 @@
|
|||||||
/**
|
/**
|
||||||
* The `SubtreeRetainer` class represents a Mithril virtual DOM subtree. It
|
* The `SubtreeRetainer` class keeps track of a number of pieces of data,
|
||||||
* keeps track of a number of pieces of data, allowing the subtree to be
|
* comparing the values of these pieces at every iteration.
|
||||||
* retained if none of them have changed.
|
*
|
||||||
|
* This is useful for preventing redraws to relatively static (or huge)
|
||||||
|
* components whose VDOM only depends on very few values, when none of them
|
||||||
|
* have changed.
|
||||||
*
|
*
|
||||||
* @example
|
* @example
|
||||||
* // constructor
|
* // Check two callbacks for changes on each update
|
||||||
* this.subtree = new SubtreeRetainer(
|
* this.subtree = new SubtreeRetainer(
|
||||||
* () => this.props.post.freshness,
|
* () => this.attrs.post.freshness,
|
||||||
* () => this.showing
|
* () => this.showing
|
||||||
* );
|
* );
|
||||||
* this.subtree.check(() => this.props.user.freshness);
|
|
||||||
*
|
*
|
||||||
* // view
|
* // Add more callbacks to be checked for updates
|
||||||
* this.subtree.retain() || 'expensive expression'
|
* this.subtree.check(() => this.attrs.user.freshness);
|
||||||
*
|
*
|
||||||
* @see https://lhorie.github.io/mithril/mithril.html#persisting-dom-elements-across-route-changes
|
* // In a component's onbeforeupdate() method:
|
||||||
|
* return this.subtree.needsRebuild()
|
||||||
|
*
|
||||||
|
* @see https://mithril.js.org/lifecycle-methods.html#onbeforeupdate
|
||||||
*/
|
*/
|
||||||
export default class SubtreeRetainer {
|
export default class SubtreeRetainer {
|
||||||
/**
|
/**
|
||||||
@ -26,13 +31,13 @@ export default class SubtreeRetainer {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Return a virtual DOM directive that will retain a subtree if no data has
|
* Return whether any data has changed since the last check.
|
||||||
* changed since the last check.
|
* If so, Mithril needs to re-diff the vnode and its children.
|
||||||
*
|
*
|
||||||
* @return {Object|false}
|
* @return {boolean}
|
||||||
* @public
|
* @public
|
||||||
*/
|
*/
|
||||||
retain() {
|
needsRebuild() {
|
||||||
let needsRebuild = false;
|
let needsRebuild = false;
|
||||||
|
|
||||||
this.callbacks.forEach((callback, i) => {
|
this.callbacks.forEach((callback, i) => {
|
||||||
@ -44,7 +49,7 @@ export default class SubtreeRetainer {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
return needsRebuild ? false : { subtree: 'retain' };
|
return needsRebuild;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -8,7 +8,7 @@ export default function extractText(vdom) {
|
|||||||
if (vdom instanceof Array) {
|
if (vdom instanceof Array) {
|
||||||
return vdom.map((element) => extractText(element)).join('');
|
return vdom.map((element) => extractText(element)).join('');
|
||||||
} else if (typeof vdom === 'object' && vdom !== null) {
|
} else if (typeof vdom === 'object' && vdom !== null) {
|
||||||
return extractText(vdom.children);
|
return vdom.children ? extractText(vdom.children) : vdom.text;
|
||||||
} else {
|
} else {
|
||||||
return vdom;
|
return vdom;
|
||||||
}
|
}
|
||||||
|
@ -2,7 +2,7 @@
|
|||||||
* The `mapRoutes` utility converts a map of named application routes into a
|
* The `mapRoutes` utility converts a map of named application routes into a
|
||||||
* format that can be understood by Mithril.
|
* format that can be understood by Mithril.
|
||||||
*
|
*
|
||||||
* @see https://lhorie.github.io/mithril/mithril.route.html#defining-routes
|
* @see https://mithril.js.org/route.html#signature
|
||||||
* @param {Object} routes
|
* @param {Object} routes
|
||||||
* @param {String} [basePath]
|
* @param {String} [basePath]
|
||||||
* @return {Object}
|
* @return {Object}
|
||||||
@ -13,9 +13,11 @@ export default function mapRoutes(routes, basePath = '') {
|
|||||||
for (const key in routes) {
|
for (const key in routes) {
|
||||||
const route = routes[key];
|
const route = routes[key];
|
||||||
|
|
||||||
if (route.component) route.component.props.routeName = key;
|
map[basePath + route.path] = {
|
||||||
|
render() {
|
||||||
map[basePath + route.path] = route.component;
|
return m(route.component, { routeName: key });
|
||||||
|
},
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
return map;
|
return map;
|
||||||
|
@ -1,27 +1,60 @@
|
|||||||
import Component from '../Component';
|
import Stream from 'mithril/stream';
|
||||||
|
import extract from './extract';
|
||||||
|
|
||||||
export default function patchMithril(global) {
|
export default function patchMithril(global) {
|
||||||
const mo = global.m;
|
const defaultMithril = global.m;
|
||||||
|
|
||||||
const m = function (comp, ...args) {
|
/**
|
||||||
if (comp.prototype && comp.prototype instanceof Component) {
|
* If the href URL of the link is the same as the current page path
|
||||||
let children = args.slice(1);
|
* we will not add a new entry to the browser history.
|
||||||
if (children.length === 1 && Array.isArray(children[0])) {
|
*
|
||||||
children = children[0];
|
* This allows us to still refresh the Page component
|
||||||
|
* without adding endless history entries.
|
||||||
|
*
|
||||||
|
* We also add the `force` attribute that adds a custom state key
|
||||||
|
* for when you want to force a complete refresh of the Page
|
||||||
|
*/
|
||||||
|
const defaultLinkView = defaultMithril.route.Link.view;
|
||||||
|
const modifiedLink = {
|
||||||
|
view: function (vnode) {
|
||||||
|
let { href, options = {} } = vnode.attrs;
|
||||||
|
|
||||||
|
if (href === m.route.get()) {
|
||||||
|
if (!('replace' in options)) options.replace = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
return comp.component(args[0], children);
|
if (extract(vnode.attrs, 'force')) {
|
||||||
}
|
if (!('state' in options)) options.state = {};
|
||||||
|
if (!('key' in options.state)) options.state.key = Date.now();
|
||||||
|
}
|
||||||
|
|
||||||
const node = mo.apply(this, arguments);
|
vnode.attrs.options = options;
|
||||||
|
|
||||||
|
return defaultLinkView(vnode);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const modifiedMithril = function (comp, ...args) {
|
||||||
|
const node = defaultMithril.apply(this, arguments);
|
||||||
|
|
||||||
|
if (!node.attrs) node.attrs = {};
|
||||||
|
|
||||||
|
// Allows the use of the bidi attr.
|
||||||
if (node.attrs.bidi) {
|
if (node.attrs.bidi) {
|
||||||
m.bidi(node, node.attrs.bidi);
|
modifiedMithril.bidi(node, node.attrs.bidi);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Allows us to use a "route" attr on links, which will automatically convert the link to one which
|
||||||
|
// supports linking to other pages in the SPA without refreshing the document.
|
||||||
if (node.attrs.route) {
|
if (node.attrs.route) {
|
||||||
node.attrs.href = node.attrs.route;
|
node.attrs.href = node.attrs.route;
|
||||||
node.attrs.config = m.route;
|
node.tag = modifiedLink;
|
||||||
|
|
||||||
|
// For some reason, m.route.Link does not like vnode.text, so if present, we
|
||||||
|
// need to convert it to text vnodes and store it in children.
|
||||||
|
if (node.text) {
|
||||||
|
node.children = { tag: '#', children: node.text };
|
||||||
|
}
|
||||||
|
|
||||||
delete node.attrs.route;
|
delete node.attrs.route;
|
||||||
}
|
}
|
||||||
@ -29,17 +62,11 @@ export default function patchMithril(global) {
|
|||||||
return node;
|
return node;
|
||||||
};
|
};
|
||||||
|
|
||||||
Object.keys(mo).forEach((key) => (m[key] = mo[key]));
|
Object.keys(defaultMithril).forEach((key) => (modifiedMithril[key] = defaultMithril[key]));
|
||||||
|
|
||||||
/**
|
modifiedMithril.stream = Stream;
|
||||||
* 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;
|
modifiedMithril.route.Link = modifiedLink;
|
||||||
|
|
||||||
|
global.m = modifiedMithril;
|
||||||
}
|
}
|
||||||
|
15
js/src/common/utils/setRouteWithForcedRefresh.ts
Normal file
15
js/src/common/utils/setRouteWithForcedRefresh.ts
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
import Mithril from 'mithril';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mithril 2 does not completely rerender the page if a route change leads to the same route
|
||||||
|
* (or the same component handling a different route). This util calls m.route.set, forcing a reonit.
|
||||||
|
*
|
||||||
|
* @see https://mithril.js.org/route.html#key-parameter
|
||||||
|
*/
|
||||||
|
export default function setRouteWithForcedRefresh(route: string, params = null, options: Mithril.RouteOptions = {}) {
|
||||||
|
const newOptions = { ...options };
|
||||||
|
newOptions.state = newOptions.state || {};
|
||||||
|
newOptions.state.key = Date.now();
|
||||||
|
|
||||||
|
m.route.set(route, params, newOptions);
|
||||||
|
}
|
15
js/src/common/utils/withAttr.ts
Normal file
15
js/src/common/utils/withAttr.ts
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
/**
|
||||||
|
* An event handler factory that makes it simpler to implement data binding
|
||||||
|
* for component event listeners.
|
||||||
|
*
|
||||||
|
* The handler created by this factory passes the DOM element's attribute
|
||||||
|
* identified by the first argument to the callback (usually a bidirectional
|
||||||
|
* Mithril stream: https://mithril.js.org/stream.html#bidirectional-bindings).
|
||||||
|
*
|
||||||
|
* Replaces m.withAttr for Mithril 2.0.
|
||||||
|
* @see https://mithril.js.org/archive/v0.2.5/mithril.withAttr.html
|
||||||
|
*/
|
||||||
|
export default (key: string, cb: Function) =>
|
||||||
|
function (this: Element) {
|
||||||
|
cb(this.getAttribute(key) || this[key]);
|
||||||
|
};
|
@ -115,15 +115,15 @@ export default class ForumApplication extends Application {
|
|||||||
this.routes[defaultAction].path = '/';
|
this.routes[defaultAction].path = '/';
|
||||||
this.history.push(defaultAction, this.translator.trans('core.forum.header.back_to_index_tooltip'), '/');
|
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('app-navigation'), { view: () => Navigation.component({ className: 'App-backControl', drawer: true }) });
|
||||||
m.mount(document.getElementById('header-navigation'), Navigation.component());
|
m.mount(document.getElementById('header-navigation'), Navigation);
|
||||||
m.mount(document.getElementById('header-primary'), HeaderPrimary.component());
|
m.mount(document.getElementById('header-primary'), HeaderPrimary);
|
||||||
m.mount(document.getElementById('header-secondary'), HeaderSecondary.component());
|
m.mount(document.getElementById('header-secondary'), HeaderSecondary);
|
||||||
m.mount(document.getElementById('composer'), Composer.component({ state: this.composer }));
|
m.mount(document.getElementById('composer'), { view: () => Composer.component({ state: this.composer }) });
|
||||||
|
|
||||||
this.pane = new Pane(document.getElementById('app'));
|
this.pane = new Pane(document.getElementById('app'));
|
||||||
|
|
||||||
m.route.mode = 'pathname';
|
m.route.prefix = '';
|
||||||
super.mount(this.forum.attribute('basePath'));
|
super.mount(this.forum.attribute('basePath'));
|
||||||
|
|
||||||
alertEmailConfirmation(this);
|
alertEmailConfirmation(this);
|
||||||
@ -161,8 +161,8 @@ export default class ForumApplication extends Application {
|
|||||||
* will be reloaded. Otherwise, a SignUpModal will be opened, prefilled
|
* will be reloaded. Otherwise, a SignUpModal will be opened, prefilled
|
||||||
* with the provided details.
|
* with the provided details.
|
||||||
*
|
*
|
||||||
* @param {Object} payload A dictionary of props to pass into the sign up
|
* @param {Object} payload A dictionary of attrs to pass into the sign up
|
||||||
* modal. A truthy `loggedIn` prop indicates that the user has logged
|
* modal. A truthy `loggedIn` attr indicates that the user has logged
|
||||||
* in, and thus the page is reloaded.
|
* in, and thus the page is reloaded.
|
||||||
* @public
|
* @public
|
||||||
*/
|
*/
|
||||||
|
@ -3,7 +3,6 @@ import compat from '../common/compat';
|
|||||||
import PostControls from './utils/PostControls';
|
import PostControls from './utils/PostControls';
|
||||||
import KeyboardNavigatable from './utils/KeyboardNavigatable';
|
import KeyboardNavigatable from './utils/KeyboardNavigatable';
|
||||||
import slidable from './utils/slidable';
|
import slidable from './utils/slidable';
|
||||||
import affixSidebar from './utils/affixSidebar';
|
|
||||||
import History from './utils/History';
|
import History from './utils/History';
|
||||||
import DiscussionControls from './utils/DiscussionControls';
|
import DiscussionControls from './utils/DiscussionControls';
|
||||||
import alertEmailConfirmation from './utils/alertEmailConfirmation';
|
import alertEmailConfirmation from './utils/alertEmailConfirmation';
|
||||||
@ -15,6 +14,7 @@ import GlobalSearchState from './states/GlobalSearchState';
|
|||||||
import NotificationListState from './states/NotificationListState';
|
import NotificationListState from './states/NotificationListState';
|
||||||
import PostStreamState from './states/PostStreamState';
|
import PostStreamState from './states/PostStreamState';
|
||||||
import SearchState from './states/SearchState';
|
import SearchState from './states/SearchState';
|
||||||
|
import AffixedSidebar from './components/AffixedSidebar';
|
||||||
import DiscussionPage from './components/DiscussionPage';
|
import DiscussionPage from './components/DiscussionPage';
|
||||||
import LogInModal from './components/LogInModal';
|
import LogInModal from './components/LogInModal';
|
||||||
import ComposerBody from './components/ComposerBody';
|
import ComposerBody from './components/ComposerBody';
|
||||||
@ -61,6 +61,7 @@ import NotificationList from './components/NotificationList';
|
|||||||
import WelcomeHero from './components/WelcomeHero';
|
import WelcomeHero from './components/WelcomeHero';
|
||||||
import SignUpModal from './components/SignUpModal';
|
import SignUpModal from './components/SignUpModal';
|
||||||
import CommentPost from './components/CommentPost';
|
import CommentPost from './components/CommentPost';
|
||||||
|
import ComposerPostPreview from './components/ComposerPostPreview';
|
||||||
import ReplyComposer from './components/ReplyComposer';
|
import ReplyComposer from './components/ReplyComposer';
|
||||||
import NotificationsPage from './components/NotificationsPage';
|
import NotificationsPage from './components/NotificationsPage';
|
||||||
import PostStreamScrubber from './components/PostStreamScrubber';
|
import PostStreamScrubber from './components/PostStreamScrubber';
|
||||||
@ -77,7 +78,6 @@ export default Object.assign(compat, {
|
|||||||
'utils/PostControls': PostControls,
|
'utils/PostControls': PostControls,
|
||||||
'utils/KeyboardNavigatable': KeyboardNavigatable,
|
'utils/KeyboardNavigatable': KeyboardNavigatable,
|
||||||
'utils/slidable': slidable,
|
'utils/slidable': slidable,
|
||||||
'utils/affixSidebar': affixSidebar,
|
|
||||||
'utils/History': History,
|
'utils/History': History,
|
||||||
'utils/DiscussionControls': DiscussionControls,
|
'utils/DiscussionControls': DiscussionControls,
|
||||||
'utils/alertEmailConfirmation': alertEmailConfirmation,
|
'utils/alertEmailConfirmation': alertEmailConfirmation,
|
||||||
@ -89,6 +89,7 @@ export default Object.assign(compat, {
|
|||||||
'states/NotificationListState': NotificationListState,
|
'states/NotificationListState': NotificationListState,
|
||||||
'states/PostStreamState': PostStreamState,
|
'states/PostStreamState': PostStreamState,
|
||||||
'states/SearchState': SearchState,
|
'states/SearchState': SearchState,
|
||||||
|
'components/AffixedSidebar': AffixedSidebar,
|
||||||
'components/DiscussionPage': DiscussionPage,
|
'components/DiscussionPage': DiscussionPage,
|
||||||
'components/LogInModal': LogInModal,
|
'components/LogInModal': LogInModal,
|
||||||
'components/ComposerBody': ComposerBody,
|
'components/ComposerBody': ComposerBody,
|
||||||
@ -135,6 +136,7 @@ export default Object.assign(compat, {
|
|||||||
'components/WelcomeHero': WelcomeHero,
|
'components/WelcomeHero': WelcomeHero,
|
||||||
'components/SignUpModal': SignUpModal,
|
'components/SignUpModal': SignUpModal,
|
||||||
'components/CommentPost': CommentPost,
|
'components/CommentPost': CommentPost,
|
||||||
|
'components/ComposerPostPreview': ComposerPostPreview,
|
||||||
'components/ReplyComposer': ReplyComposer,
|
'components/ReplyComposer': ReplyComposer,
|
||||||
'components/NotificationsPage': NotificationsPage,
|
'components/NotificationsPage': NotificationsPage,
|
||||||
'components/PostStreamScrubber': PostStreamScrubber,
|
'components/PostStreamScrubber': PostStreamScrubber,
|
||||||
|
51
js/src/forum/components/AffixedSidebar.js
Normal file
51
js/src/forum/components/AffixedSidebar.js
Normal file
@ -0,0 +1,51 @@
|
|||||||
|
import Component from '../../common/Component';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The `AffixedSidebar` component uses Bootstrap's "affix" plugin to keep a
|
||||||
|
* sidebar navigation at the top of the viewport when scrolling.
|
||||||
|
*
|
||||||
|
* ### Children
|
||||||
|
*
|
||||||
|
* The component must wrap an element that itself wraps an <ul> element, which
|
||||||
|
* will be "affixed".
|
||||||
|
*
|
||||||
|
* @see https://getbootstrap.com/docs/3.4/javascript/#affix
|
||||||
|
*/
|
||||||
|
export default class AffixedSidebar extends Component {
|
||||||
|
view(vnode) {
|
||||||
|
return vnode.children[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
oncreate(vnode) {
|
||||||
|
super.oncreate(vnode);
|
||||||
|
|
||||||
|
// Register the affix plugin to execute on every window resize (and trigger)
|
||||||
|
this.boundOnresize = this.onresize.bind(this);
|
||||||
|
$(window).on('resize', this.boundOnresize).resize();
|
||||||
|
}
|
||||||
|
|
||||||
|
onremove() {
|
||||||
|
$(window).off('resize', this.boundOnresize);
|
||||||
|
}
|
||||||
|
|
||||||
|
onresize() {
|
||||||
|
const $sidebar = this.$();
|
||||||
|
const $header = $('#header');
|
||||||
|
const $footer = $('#footer');
|
||||||
|
const $affixElement = $sidebar.find('> ul');
|
||||||
|
|
||||||
|
$(window).off('.affix');
|
||||||
|
$affixElement.removeClass('affix affix-top affix-bottom').removeData('bs.affix');
|
||||||
|
|
||||||
|
// Don't affix the sidebar if it is taller than the viewport (otherwise
|
||||||
|
// there would be no way to scroll through its content).
|
||||||
|
if ($sidebar.outerHeight(true) > $(window).height() - $header.outerHeight(true)) return;
|
||||||
|
|
||||||
|
$affixElement.affix({
|
||||||
|
offset: {
|
||||||
|
top: () => $sidebar.offset().top - $header.outerHeight(true) - parseInt($sidebar.css('margin-top'), 10),
|
||||||
|
bottom: () => (this.bottom = $footer.outerHeight(true)),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
@ -3,6 +3,7 @@ import avatar from '../../common/helpers/avatar';
|
|||||||
import icon from '../../common/helpers/icon';
|
import icon from '../../common/helpers/icon';
|
||||||
import listItems from '../../common/helpers/listItems';
|
import listItems from '../../common/helpers/listItems';
|
||||||
import ItemList from '../../common/utils/ItemList';
|
import ItemList from '../../common/utils/ItemList';
|
||||||
|
import classList from '../../common/utils/classList';
|
||||||
import Button from '../../common/components/Button';
|
import Button from '../../common/components/Button';
|
||||||
import LoadingIndicator from '../../common/components/LoadingIndicator';
|
import LoadingIndicator from '../../common/components/LoadingIndicator';
|
||||||
|
|
||||||
@ -10,13 +11,15 @@ import LoadingIndicator from '../../common/components/LoadingIndicator';
|
|||||||
* The `AvatarEditor` component displays a user's avatar along with a dropdown
|
* The `AvatarEditor` component displays a user's avatar along with a dropdown
|
||||||
* menu which allows the user to upload/remove the avatar.
|
* menu which allows the user to upload/remove the avatar.
|
||||||
*
|
*
|
||||||
* ### Props
|
* ### Attrs
|
||||||
*
|
*
|
||||||
* - `className`
|
* - `className`
|
||||||
* - `user`
|
* - `user`
|
||||||
*/
|
*/
|
||||||
export default class AvatarEditor extends Component {
|
export default class AvatarEditor extends Component {
|
||||||
init() {
|
oninit(vnode) {
|
||||||
|
super.oninit(vnode);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Whether or not an avatar upload is in progress.
|
* Whether or not an avatar upload is in progress.
|
||||||
*
|
*
|
||||||
@ -32,17 +35,11 @@ export default class AvatarEditor extends Component {
|
|||||||
this.isDraggedOver = false;
|
this.isDraggedOver = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
static initProps(props) {
|
|
||||||
super.initProps(props);
|
|
||||||
|
|
||||||
props.className = props.className || '';
|
|
||||||
}
|
|
||||||
|
|
||||||
view() {
|
view() {
|
||||||
const user = this.props.user;
|
const user = this.attrs.user;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={'AvatarEditor Dropdown ' + this.props.className + (this.loading ? ' loading' : '') + (this.isDraggedOver ? ' dragover' : '')}>
|
<div className={classList(['AvatarEditor', 'Dropdown', this.attrs.className, this.loading && 'loading', this.isDraggedOver && 'dragover'])}>
|
||||||
{avatar(user)}
|
{avatar(user)}
|
||||||
<a
|
<a
|
||||||
className={user.avatarUrl() ? 'Dropdown-toggle' : 'Dropdown-toggle AvatarEditor--noAvatar'}
|
className={user.avatarUrl() ? 'Dropdown-toggle' : 'Dropdown-toggle AvatarEditor--noAvatar'}
|
||||||
@ -55,7 +52,7 @@ export default class AvatarEditor extends Component {
|
|||||||
ondragend={this.disableDragover.bind(this)}
|
ondragend={this.disableDragover.bind(this)}
|
||||||
ondrop={this.dropUpload.bind(this)}
|
ondrop={this.dropUpload.bind(this)}
|
||||||
>
|
>
|
||||||
{this.loading ? LoadingIndicator.component() : user.avatarUrl() ? icon('fas fa-pencil-alt') : icon('fas fa-plus-circle')}
|
{this.loading ? <LoadingIndicator /> : user.avatarUrl() ? icon('fas fa-pencil-alt') : icon('fas fa-plus-circle')}
|
||||||
</a>
|
</a>
|
||||||
<ul className="Dropdown-menu Menu">{listItems(this.controlItems().toArray())}</ul>
|
<ul className="Dropdown-menu Menu">{listItems(this.controlItems().toArray())}</ul>
|
||||||
</div>
|
</div>
|
||||||
@ -72,20 +69,16 @@ export default class AvatarEditor extends Component {
|
|||||||
|
|
||||||
items.add(
|
items.add(
|
||||||
'upload',
|
'upload',
|
||||||
Button.component({
|
<Button icon="fas fa-upload" onclick={this.openPicker.bind(this)}>
|
||||||
icon: 'fas fa-upload',
|
{app.translator.trans('core.forum.user.avatar_upload_button')}
|
||||||
children: app.translator.trans('core.forum.user.avatar_upload_button'),
|
</Button>
|
||||||
onclick: this.openPicker.bind(this),
|
|
||||||
})
|
|
||||||
);
|
);
|
||||||
|
|
||||||
items.add(
|
items.add(
|
||||||
'remove',
|
'remove',
|
||||||
Button.component({
|
<Button icon="fas fa-times" onclick={this.remove.bind(this)}>
|
||||||
icon: 'fas fa-times',
|
{app.translator.trans('core.forum.user.avatar_remove_button')}
|
||||||
children: app.translator.trans('core.forum.user.avatar_remove_button'),
|
</Button>
|
||||||
onclick: this.remove.bind(this),
|
|
||||||
})
|
|
||||||
);
|
);
|
||||||
|
|
||||||
return items;
|
return items;
|
||||||
@ -134,7 +127,7 @@ export default class AvatarEditor extends Component {
|
|||||||
* @param {Event} e
|
* @param {Event} e
|
||||||
*/
|
*/
|
||||||
quickUpload(e) {
|
quickUpload(e) {
|
||||||
if (!this.props.user.avatarUrl()) {
|
if (!this.attrs.user.avatarUrl()) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
this.openPicker();
|
this.openPicker();
|
||||||
@ -149,7 +142,6 @@ export default class AvatarEditor extends Component {
|
|||||||
|
|
||||||
// Create a hidden HTML input element and click on it so the user can select
|
// Create a hidden HTML input element and click on it so the user can select
|
||||||
// an avatar file. Once they have, we will upload it via the API.
|
// an avatar file. Once they have, we will upload it via the API.
|
||||||
const user = this.props.user;
|
|
||||||
const $input = $('<input type="file">');
|
const $input = $('<input type="file">');
|
||||||
|
|
||||||
$input
|
$input
|
||||||
@ -169,7 +161,7 @@ export default class AvatarEditor extends Component {
|
|||||||
upload(file) {
|
upload(file) {
|
||||||
if (this.loading) return;
|
if (this.loading) return;
|
||||||
|
|
||||||
const user = this.props.user;
|
const user = this.attrs.user;
|
||||||
const data = new FormData();
|
const data = new FormData();
|
||||||
data.append('avatar', file);
|
data.append('avatar', file);
|
||||||
|
|
||||||
@ -179,9 +171,9 @@ export default class AvatarEditor extends Component {
|
|||||||
app
|
app
|
||||||
.request({
|
.request({
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
url: app.forum.attribute('apiUrl') + '/users/' + user.id() + '/avatar',
|
url: `${app.forum.attribute('apiUrl')}/users/${user.id()}/avatar`,
|
||||||
serialize: (raw) => raw,
|
serialize: (raw) => raw,
|
||||||
data,
|
body: data,
|
||||||
})
|
})
|
||||||
.then(this.success.bind(this), this.failure.bind(this));
|
.then(this.success.bind(this), this.failure.bind(this));
|
||||||
}
|
}
|
||||||
@ -190,7 +182,7 @@ export default class AvatarEditor extends Component {
|
|||||||
* Remove the user's avatar.
|
* Remove the user's avatar.
|
||||||
*/
|
*/
|
||||||
remove() {
|
remove() {
|
||||||
const user = this.props.user;
|
const user = this.attrs.user;
|
||||||
|
|
||||||
this.loading = true;
|
this.loading = true;
|
||||||
m.redraw();
|
m.redraw();
|
||||||
@ -198,7 +190,7 @@ export default class AvatarEditor extends Component {
|
|||||||
app
|
app
|
||||||
.request({
|
.request({
|
||||||
method: 'DELETE',
|
method: 'DELETE',
|
||||||
url: app.forum.attribute('apiUrl') + '/users/' + user.id() + '/avatar',
|
url: `${app.forum.attribute('apiUrl')}/users/${user.id()}/avatar`,
|
||||||
})
|
})
|
||||||
.then(this.success.bind(this), this.failure.bind(this));
|
.then(this.success.bind(this), this.failure.bind(this));
|
||||||
}
|
}
|
||||||
@ -212,7 +204,7 @@ export default class AvatarEditor extends Component {
|
|||||||
*/
|
*/
|
||||||
success(response) {
|
success(response) {
|
||||||
app.store.pushPayload(response);
|
app.store.pushPayload(response);
|
||||||
delete this.props.user.avatarColor;
|
delete this.attrs.user.avatarColor;
|
||||||
|
|
||||||
this.loading = false;
|
this.loading = false;
|
||||||
m.redraw();
|
m.redraw();
|
||||||
|
@ -6,8 +6,8 @@ import Button from '../../common/components/Button';
|
|||||||
* to change their email address.
|
* to change their email address.
|
||||||
*/
|
*/
|
||||||
export default class ChangeEmailModal extends Modal {
|
export default class ChangeEmailModal extends Modal {
|
||||||
init() {
|
oninit(vnode) {
|
||||||
super.init();
|
super.oninit(vnode);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Whether or not the email has been changed successfully.
|
* Whether or not the email has been changed successfully.
|
||||||
@ -21,14 +21,14 @@ export default class ChangeEmailModal extends Modal {
|
|||||||
*
|
*
|
||||||
* @type {function}
|
* @type {function}
|
||||||
*/
|
*/
|
||||||
this.email = m.prop(app.session.user.email());
|
this.email = m.stream(app.session.user.email());
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The value of the password input.
|
* The value of the password input.
|
||||||
*
|
*
|
||||||
* @type {function}
|
* @type {function}
|
||||||
*/
|
*/
|
||||||
this.password = m.prop('');
|
this.password = m.stream('');
|
||||||
}
|
}
|
||||||
|
|
||||||
className() {
|
className() {
|
||||||
@ -81,12 +81,14 @@ export default class ChangeEmailModal extends Modal {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="Form-group">
|
<div className="Form-group">
|
||||||
{Button.component({
|
{Button.component(
|
||||||
className: 'Button Button--primary Button--block',
|
{
|
||||||
type: 'submit',
|
className: 'Button Button--primary Button--block',
|
||||||
loading: this.loading,
|
type: 'submit',
|
||||||
children: app.translator.trans('core.forum.change_email.submit_button'),
|
loading: this.loading,
|
||||||
})}
|
},
|
||||||
|
app.translator.trans('core.forum.change_email.submit_button')
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -122,7 +124,7 @@ export default class ChangeEmailModal extends Modal {
|
|||||||
|
|
||||||
onerror(error) {
|
onerror(error) {
|
||||||
if (error.status === 401) {
|
if (error.status === 401) {
|
||||||
error.alert.children = app.translator.trans('core.forum.change_email.incorrect_password_message');
|
error.alert.content = app.translator.trans('core.forum.change_email.incorrect_password_message');
|
||||||
}
|
}
|
||||||
|
|
||||||
super.onerror(error);
|
super.onerror(error);
|
||||||
|
@ -20,12 +20,14 @@ export default class ChangePasswordModal extends Modal {
|
|||||||
<div className="Form Form--centered">
|
<div className="Form Form--centered">
|
||||||
<p className="helpText">{app.translator.trans('core.forum.change_password.text')}</p>
|
<p className="helpText">{app.translator.trans('core.forum.change_password.text')}</p>
|
||||||
<div className="Form-group">
|
<div className="Form-group">
|
||||||
{Button.component({
|
{Button.component(
|
||||||
className: 'Button Button--primary Button--block',
|
{
|
||||||
type: 'submit',
|
className: 'Button Button--primary Button--block',
|
||||||
loading: this.loading,
|
type: 'submit',
|
||||||
children: app.translator.trans('core.forum.change_password.send_button'),
|
loading: this.loading,
|
||||||
})}
|
},
|
||||||
|
app.translator.trans('core.forum.change_password.send_button')
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -41,7 +43,7 @@ export default class ChangePasswordModal extends Modal {
|
|||||||
.request({
|
.request({
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
url: app.forum.attribute('apiUrl') + '/forgot',
|
url: app.forum.attribute('apiUrl') + '/forgot',
|
||||||
data: { email: app.session.user.email() },
|
body: { email: app.session.user.email() },
|
||||||
})
|
})
|
||||||
.then(this.hide.bind(this), this.loaded.bind(this));
|
.then(this.hide.bind(this), this.loaded.bind(this));
|
||||||
}
|
}
|
||||||
|
@ -1,5 +1,3 @@
|
|||||||
/*global s9e, hljs*/
|
|
||||||
|
|
||||||
import Post from './Post';
|
import Post from './Post';
|
||||||
import classList from '../../common/utils/classList';
|
import classList from '../../common/utils/classList';
|
||||||
import PostUser from './PostUser';
|
import PostUser from './PostUser';
|
||||||
@ -9,19 +7,20 @@ import EditPostComposer from './EditPostComposer';
|
|||||||
import ItemList from '../../common/utils/ItemList';
|
import ItemList from '../../common/utils/ItemList';
|
||||||
import listItems from '../../common/helpers/listItems';
|
import listItems from '../../common/helpers/listItems';
|
||||||
import Button from '../../common/components/Button';
|
import Button from '../../common/components/Button';
|
||||||
|
import ComposerPostPreview from './ComposerPostPreview';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The `CommentPost` component displays a standard `comment`-typed post. This
|
* The `CommentPost` component displays a standard `comment`-typed post. This
|
||||||
* includes a number of item lists (controls, header, and footer) surrounding
|
* includes a number of item lists (controls, header, and footer) surrounding
|
||||||
* the post's HTML content.
|
* the post's HTML content.
|
||||||
*
|
*
|
||||||
* ### Props
|
* ### Attrs
|
||||||
*
|
*
|
||||||
* - `post`
|
* - `post`
|
||||||
*/
|
*/
|
||||||
export default class CommentPost extends Post {
|
export default class CommentPost extends Post {
|
||||||
init() {
|
oninit(vnode) {
|
||||||
super.init();
|
super.oninit(vnode);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* If the post has been hidden, then this flag determines whether or not its
|
* If the post has been hidden, then this flag determines whether or not its
|
||||||
@ -41,48 +40,46 @@ export default class CommentPost extends Post {
|
|||||||
|
|
||||||
this.subtree.check(
|
this.subtree.check(
|
||||||
() => this.cardVisible,
|
() => this.cardVisible,
|
||||||
() => this.isEditing()
|
() => this.isEditing(),
|
||||||
|
() => this.revealContent
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
content() {
|
content() {
|
||||||
// Note: we avoid using JSX for the <ul> below because it results in some
|
return super.content().concat([
|
||||||
// weirdness in Mithril.js 0.1.x (see flarum/core#975). This workaround can
|
<header className="Post-header">
|
||||||
// be reverted when we upgrade to Mithril 1.0.
|
<ul>{listItems(this.headerItems().toArray())}</ul>
|
||||||
return super
|
</header>,
|
||||||
.content()
|
<div className="Post-body">
|
||||||
.concat([
|
{this.isEditing() ? <ComposerPostPreview className="Post-preview" composer={app.composer} /> : m.trust(this.attrs.post.contentHtml())}
|
||||||
<header className="Post-header">{m('ul', listItems(this.headerItems().toArray()))}</header>,
|
</div>,
|
||||||
<div className="Post-body">
|
]);
|
||||||
{this.isEditing() ? <div className="Post-preview" config={this.configPreview.bind(this)} /> : m.trust(this.props.post.contentHtml())}
|
|
||||||
</div>,
|
|
||||||
]);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
config(isInitialized, context) {
|
onupdate(vnode) {
|
||||||
super.config(...arguments);
|
super.onupdate();
|
||||||
|
|
||||||
const contentHtml = this.isEditing() ? '' : this.props.post.contentHtml();
|
const contentHtml = this.isEditing() ? '' : this.attrs.post.contentHtml();
|
||||||
|
|
||||||
// If the post content has changed since the last render, we'll run through
|
// If the post content has changed since the last render, we'll run through
|
||||||
// all of the <script> tags in the content and evaluate them. This is
|
// all of the <script> tags in the content and evaluate them. This is
|
||||||
// necessary because TextFormatter outputs them for e.g. syntax highlighting.
|
// necessary because TextFormatter outputs them for e.g. syntax highlighting.
|
||||||
if (context.contentHtml !== contentHtml) {
|
if (this.contentHtml !== contentHtml) {
|
||||||
this.$('.Post-body script').each(function () {
|
this.$('.Post-body script').each(function () {
|
||||||
eval.call(window, $(this).text());
|
eval.call(window, $(this).text());
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
context.contentHtml = contentHtml;
|
this.contentHtml = contentHtml;
|
||||||
}
|
}
|
||||||
|
|
||||||
isEditing() {
|
isEditing() {
|
||||||
return app.composer.bodyMatches(EditPostComposer, { post: this.props.post });
|
return app.composer.bodyMatches(EditPostComposer, { post: this.attrs.post });
|
||||||
}
|
}
|
||||||
|
|
||||||
attrs() {
|
elementAttrs() {
|
||||||
const post = this.props.post;
|
const post = this.attrs.post;
|
||||||
const attrs = super.attrs();
|
const attrs = super.elementAttrs();
|
||||||
|
|
||||||
attrs.className =
|
attrs.className =
|
||||||
(attrs.className || '') +
|
(attrs.className || '') +
|
||||||
@ -98,27 +95,6 @@ export default class CommentPost extends Post {
|
|||||||
return attrs;
|
return attrs;
|
||||||
}
|
}
|
||||||
|
|
||||||
configPreview(element, isInitialized, context) {
|
|
||||||
if (isInitialized) return;
|
|
||||||
|
|
||||||
// Every 50ms, if the composer content has changed, then update the post's
|
|
||||||
// body with a preview.
|
|
||||||
let preview;
|
|
||||||
const updatePreview = () => {
|
|
||||||
const content = app.composer.fields.content();
|
|
||||||
|
|
||||||
if (preview === content) return;
|
|
||||||
|
|
||||||
preview = content;
|
|
||||||
|
|
||||||
s9e.TextFormatter.preview(preview || '', element);
|
|
||||||
};
|
|
||||||
updatePreview();
|
|
||||||
|
|
||||||
const updateInterval = setInterval(updatePreview, 50);
|
|
||||||
context.onunload = () => clearInterval(updateInterval);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Toggle the visibility of a hidden post's content.
|
* Toggle the visibility of a hidden post's content.
|
||||||
*/
|
*/
|
||||||
@ -133,7 +109,7 @@ export default class CommentPost extends Post {
|
|||||||
*/
|
*/
|
||||||
headerItems() {
|
headerItems() {
|
||||||
const items = new ItemList();
|
const items = new ItemList();
|
||||||
const post = this.props.post;
|
const post = this.attrs.post;
|
||||||
|
|
||||||
items.add(
|
items.add(
|
||||||
'user',
|
'user',
|
||||||
|
@ -11,13 +11,15 @@ import ComposerState from '../states/ComposerState';
|
|||||||
* `show`, `hide`, `close`, `minimize`, `fullScreen`, and `exitFullScreen`.
|
* `show`, `hide`, `close`, `minimize`, `fullScreen`, and `exitFullScreen`.
|
||||||
*/
|
*/
|
||||||
export default class Composer extends Component {
|
export default class Composer extends Component {
|
||||||
init() {
|
oninit(vnode) {
|
||||||
|
super.oninit(vnode);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The composer's "state".
|
* The composer's "state".
|
||||||
*
|
*
|
||||||
* @type {ComposerState}
|
* @type {ComposerState}
|
||||||
*/
|
*/
|
||||||
this.state = this.props.state;
|
this.state = this.attrs.state;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Whether or not the composer currently has focus.
|
* Whether or not the composer currently has focus.
|
||||||
@ -45,7 +47,7 @@ export default class Composer extends Component {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={'Composer ' + classList(classes)}>
|
<div className={'Composer ' + classList(classes)}>
|
||||||
<div className="Composer-handle" config={this.configHandle.bind(this)} />
|
<div className="Composer-handle" oncreate={this.configHandle.bind(this)} />
|
||||||
<ul className="Composer-controls">{listItems(this.controlItems().toArray())}</ul>
|
<ul className="Composer-controls">{listItems(this.controlItems().toArray())}</ul>
|
||||||
<div className="Composer-content" onclick={showIfMinimized}>
|
<div className="Composer-content" onclick={showIfMinimized}>
|
||||||
{body.componentClass ? body.componentClass.component({ ...body.attrs, composer: this.state, disabled: classes.minimized }) : ''}
|
{body.componentClass ? body.componentClass.component({ ...body.attrs, composer: this.state, disabled: classes.minimized }) : ''}
|
||||||
@ -54,7 +56,7 @@ export default class Composer extends Component {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
config(isInitialized, context) {
|
onupdate() {
|
||||||
if (this.state.position === this.prevPosition) {
|
if (this.state.position === this.prevPosition) {
|
||||||
// Set the height of the Composer element and its contents on each redraw,
|
// Set the height of the Composer element and its contents on each redraw,
|
||||||
// so that they do not lose it if their DOM elements are recreated.
|
// so that they do not lose it if their DOM elements are recreated.
|
||||||
@ -64,12 +66,10 @@ export default class Composer extends Component {
|
|||||||
|
|
||||||
this.prevPosition = this.state.position;
|
this.prevPosition = this.state.position;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (isInitialized) return;
|
oncreate(vnode) {
|
||||||
|
super.oncreate(vnode);
|
||||||
// Since this component 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.initializeHeight();
|
this.initializeHeight();
|
||||||
this.$().hide().css('bottom', -this.state.computedHeight());
|
this.$().hide().css('bottom', -this.state.computedHeight());
|
||||||
@ -84,36 +84,31 @@ export default class Composer extends Component {
|
|||||||
// When the escape key is pressed on any inputs, close the composer.
|
// When the escape key is pressed on any inputs, close the composer.
|
||||||
this.$().on('keydown', ':input', 'esc', () => this.state.close());
|
this.$().on('keydown', ':input', 'esc', () => this.state.close());
|
||||||
|
|
||||||
const handlers = {};
|
this.handlers = {};
|
||||||
|
|
||||||
$(window)
|
$(window)
|
||||||
.on('resize', (handlers.onresize = this.updateHeight.bind(this)))
|
.on('resize', (this.handlers.onresize = this.updateHeight.bind(this)))
|
||||||
.resize();
|
.resize();
|
||||||
|
|
||||||
$(document)
|
$(document)
|
||||||
.on('mousemove', (handlers.onmousemove = this.onmousemove.bind(this)))
|
.on('mousemove', (this.handlers.onmousemove = this.onmousemove.bind(this)))
|
||||||
.on('mouseup', (handlers.onmouseup = this.onmouseup.bind(this)));
|
.on('mouseup', (this.handlers.onmouseup = this.onmouseup.bind(this)));
|
||||||
|
}
|
||||||
|
|
||||||
context.onunload = () => {
|
onremove() {
|
||||||
$(window).off('resize', handlers.onresize);
|
$(window).off('resize', this.handlers.onresize);
|
||||||
|
|
||||||
$(document).off('mousemove', handlers.onmousemove).off('mouseup', handlers.onmouseup);
|
$(document).off('mousemove', this.handlers.onmousemove).off('mouseup', this.handlers.onmouseup);
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Add the necessary event handlers to the composer's handle so that it can
|
* Add the necessary event handlers to the composer's handle so that it can
|
||||||
* be used to resize the composer.
|
* be used to resize the composer.
|
||||||
*
|
|
||||||
* @param {DOMElement} element
|
|
||||||
* @param {Boolean} isInitialized
|
|
||||||
*/
|
*/
|
||||||
configHandle(element, isInitialized) {
|
configHandle(vnode) {
|
||||||
if (isInitialized) return;
|
|
||||||
|
|
||||||
const composer = this;
|
const composer = this;
|
||||||
|
|
||||||
$(element)
|
$(vnode.dom)
|
||||||
.css('cursor', 'row-resize')
|
.css('cursor', 'row-resize')
|
||||||
.bind('dragstart mousedown', (e) => e.preventDefault())
|
.bind('dragstart mousedown', (e) => e.preventDefault())
|
||||||
.mousedown(function (e) {
|
.mousedown(function (e) {
|
||||||
|
@ -11,7 +11,7 @@ import ItemList from '../../common/utils/ItemList';
|
|||||||
* composer. Subclasses should implement the `onsubmit` method and override
|
* composer. Subclasses should implement the `onsubmit` method and override
|
||||||
* `headerTimes`.
|
* `headerTimes`.
|
||||||
*
|
*
|
||||||
* ### Props
|
* ### Attrs
|
||||||
*
|
*
|
||||||
* - `composer`
|
* - `composer`
|
||||||
* - `originalContent`
|
* - `originalContent`
|
||||||
@ -24,8 +24,10 @@ import ItemList from '../../common/utils/ItemList';
|
|||||||
* @abstract
|
* @abstract
|
||||||
*/
|
*/
|
||||||
export default class ComposerBody extends Component {
|
export default class ComposerBody extends Component {
|
||||||
init() {
|
oninit(vnode) {
|
||||||
this.composer = this.props.composer;
|
super.oninit(vnode);
|
||||||
|
|
||||||
|
this.composer = this.attrs.composer;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Whether or not the component is loading.
|
* Whether or not the component is loading.
|
||||||
@ -37,11 +39,11 @@ export default class ComposerBody extends Component {
|
|||||||
// Let the composer state know to ask for confirmation under certain
|
// Let the composer state know to ask for confirmation under certain
|
||||||
// circumstances, if the body supports / requires it and has a corresponding
|
// circumstances, if the body supports / requires it and has a corresponding
|
||||||
// confirmation question to ask.
|
// confirmation question to ask.
|
||||||
if (this.props.confirmExit) {
|
if (this.attrs.confirmExit) {
|
||||||
this.composer.preventClosingWhen(() => this.hasChanges(), this.props.confirmExit);
|
this.composer.preventClosingWhen(() => this.hasChanges(), this.attrs.confirmExit);
|
||||||
}
|
}
|
||||||
|
|
||||||
this.composer.fields.content(this.props.originalContent || '');
|
this.composer.fields.content(this.attrs.originalContent || '');
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @deprecated BC layer, remove in Beta 15.
|
* @deprecated BC layer, remove in Beta 15.
|
||||||
@ -53,15 +55,15 @@ export default class ComposerBody extends Component {
|
|||||||
view() {
|
view() {
|
||||||
return (
|
return (
|
||||||
<ConfirmDocumentUnload when={this.hasChanges.bind(this)}>
|
<ConfirmDocumentUnload when={this.hasChanges.bind(this)}>
|
||||||
<div className={'ComposerBody ' + (this.props.className || '')}>
|
<div className={'ComposerBody ' + (this.attrs.className || '')}>
|
||||||
{avatar(this.props.user, { className: 'ComposerBody-avatar' })}
|
{avatar(this.attrs.user, { className: 'ComposerBody-avatar' })}
|
||||||
<div className="ComposerBody-content">
|
<div className="ComposerBody-content">
|
||||||
<ul className="ComposerBody-header">{listItems(this.headerItems().toArray())}</ul>
|
<ul className="ComposerBody-header">{listItems(this.headerItems().toArray())}</ul>
|
||||||
<div className="ComposerBody-editor">
|
<div className="ComposerBody-editor">
|
||||||
{TextEditor.component({
|
{TextEditor.component({
|
||||||
submitLabel: this.props.submitLabel,
|
submitLabel: this.attrs.submitLabel,
|
||||||
placeholder: this.props.placeholder,
|
placeholder: this.attrs.placeholder,
|
||||||
disabled: this.loading || this.props.disabled,
|
disabled: this.loading || this.attrs.disabled,
|
||||||
composer: this.composer,
|
composer: this.composer,
|
||||||
preview: this.jumpToPreview && this.jumpToPreview.bind(this),
|
preview: this.jumpToPreview && this.jumpToPreview.bind(this),
|
||||||
onchange: this.composer.fields.content,
|
onchange: this.composer.fields.content,
|
||||||
@ -84,7 +86,7 @@ export default class ComposerBody extends Component {
|
|||||||
hasChanges() {
|
hasChanges() {
|
||||||
const content = this.composer.fields.content();
|
const content = this.composer.fields.content();
|
||||||
|
|
||||||
return content && content !== this.props.originalContent;
|
return content && content !== this.attrs.originalContent;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -5,9 +5,9 @@ import Button from '../../common/components/Button';
|
|||||||
* controls.
|
* controls.
|
||||||
*/
|
*/
|
||||||
export default class ComposerButton extends Button {
|
export default class ComposerButton extends Button {
|
||||||
static initProps(props) {
|
static initAttrs(attrs) {
|
||||||
super.initProps(props);
|
super.initAttrs(attrs);
|
||||||
|
|
||||||
props.className = props.className || 'Button Button--icon Button--link';
|
attrs.className = attrs.className || 'Button Button--icon Button--link';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
54
js/src/forum/components/ComposerPostPreview.js
Normal file
54
js/src/forum/components/ComposerPostPreview.js
Normal file
@ -0,0 +1,54 @@
|
|||||||
|
/*global s9e*/
|
||||||
|
|
||||||
|
import Component from '../../common/Component';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The `ComposerPostPreview` component renders Markdown as HTML using the
|
||||||
|
* TextFormatter library, polling a data source for changes every 50ms. This is
|
||||||
|
* done to prevent expensive redraws on e.g. every single keystroke, while
|
||||||
|
* still retaining the perception of live updates for the user.
|
||||||
|
*
|
||||||
|
* ### Attrs
|
||||||
|
*
|
||||||
|
* - `composer` The state of the composer controlling this preview.
|
||||||
|
* - `className` A CSS class for the element surrounding the preview.
|
||||||
|
* - `surround` A callback that can execute code before and after re-render, e.g. for scroll anchoring.
|
||||||
|
*/
|
||||||
|
export default class ComposerPostPreview extends Component {
|
||||||
|
static initAttrs(attrs) {
|
||||||
|
attrs.className = attrs.className || '';
|
||||||
|
attrs.surround = attrs.surround || ((preview) => preview());
|
||||||
|
}
|
||||||
|
|
||||||
|
view() {
|
||||||
|
return <div className={this.attrs.className} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
oncreate(vnode) {
|
||||||
|
super.oncreate(vnode);
|
||||||
|
|
||||||
|
// Every 50ms, if the composer content has changed, then update the post's
|
||||||
|
// body with a preview.
|
||||||
|
let preview;
|
||||||
|
const updatePreview = () => {
|
||||||
|
// Since we're polling, the composer may have been closed in the meantime,
|
||||||
|
// so we bail in that case.
|
||||||
|
if (!this.attrs.composer.isVisible()) return;
|
||||||
|
|
||||||
|
const content = this.attrs.composer.fields.content();
|
||||||
|
|
||||||
|
if (preview === content) return;
|
||||||
|
|
||||||
|
preview = content;
|
||||||
|
|
||||||
|
this.attrs.surround(() => s9e.TextFormatter.preview(preview || '', vnode.dom));
|
||||||
|
};
|
||||||
|
updatePreview();
|
||||||
|
|
||||||
|
this.updateInterval = setInterval(updatePreview, 50);
|
||||||
|
}
|
||||||
|
|
||||||
|
onremove() {
|
||||||
|
clearInterval(this.updateInterval);
|
||||||
|
}
|
||||||
|
}
|
@ -7,16 +7,26 @@ import extractText from '../../common/utils/extractText';
|
|||||||
* enter the title of their discussion. It also overrides the `submit` and
|
* enter the title of their discussion. It also overrides the `submit` and
|
||||||
* `willExit` actions to account for the title.
|
* `willExit` actions to account for the title.
|
||||||
*
|
*
|
||||||
* ### Props
|
* ### Attrs
|
||||||
*
|
*
|
||||||
* - All of the props for ComposerBody
|
* - All of the attrs for ComposerBody
|
||||||
* - `titlePlaceholder`
|
* - `titlePlaceholder`
|
||||||
*/
|
*/
|
||||||
export default class DiscussionComposer extends ComposerBody {
|
export default class DiscussionComposer extends ComposerBody {
|
||||||
init() {
|
static initAttrs(attrs) {
|
||||||
super.init();
|
super.initAttrs(attrs);
|
||||||
|
|
||||||
this.composer.fields.title = this.composer.fields.title || m.prop('');
|
attrs.placeholder = attrs.placeholder || extractText(app.translator.trans('core.forum.composer_discussion.body_placeholder'));
|
||||||
|
attrs.submitLabel = attrs.submitLabel || app.translator.trans('core.forum.composer_discussion.submit_button');
|
||||||
|
attrs.confirmExit = attrs.confirmExit || extractText(app.translator.trans('core.forum.composer_discussion.discard_confirmation'));
|
||||||
|
attrs.titlePlaceholder = attrs.titlePlaceholder || extractText(app.translator.trans('core.forum.composer_discussion.title_placeholder'));
|
||||||
|
attrs.className = 'ComposerBody--discussion';
|
||||||
|
}
|
||||||
|
|
||||||
|
oninit(vnode) {
|
||||||
|
super.oninit(vnode);
|
||||||
|
|
||||||
|
this.composer.fields.title = this.composer.fields.title || m.stream('');
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The value of the title input.
|
* The value of the title input.
|
||||||
@ -26,16 +36,6 @@ export default class DiscussionComposer extends ComposerBody {
|
|||||||
this.title = this.composer.fields.title;
|
this.title = this.composer.fields.title;
|
||||||
}
|
}
|
||||||
|
|
||||||
static initProps(props) {
|
|
||||||
super.initProps(props);
|
|
||||||
|
|
||||||
props.placeholder = props.placeholder || extractText(app.translator.trans('core.forum.composer_discussion.body_placeholder'));
|
|
||||||
props.submitLabel = props.submitLabel || app.translator.trans('core.forum.composer_discussion.submit_button');
|
|
||||||
props.confirmExit = props.confirmExit || extractText(app.translator.trans('core.forum.composer_discussion.discard_confirmation'));
|
|
||||||
props.titlePlaceholder = props.titlePlaceholder || extractText(app.translator.trans('core.forum.composer_discussion.title_placeholder'));
|
|
||||||
props.className = 'ComposerBody--discussion';
|
|
||||||
}
|
|
||||||
|
|
||||||
headerItems() {
|
headerItems() {
|
||||||
const items = super.headerItems();
|
const items = super.headerItems();
|
||||||
|
|
||||||
@ -46,10 +46,9 @@ export default class DiscussionComposer extends ComposerBody {
|
|||||||
<h3>
|
<h3>
|
||||||
<input
|
<input
|
||||||
className="FormControl"
|
className="FormControl"
|
||||||
value={this.title()}
|
bidi={this.title}
|
||||||
oninput={m.withAttr('value', this.title)}
|
placeholder={this.attrs.titlePlaceholder}
|
||||||
placeholder={this.props.titlePlaceholder}
|
disabled={!!this.attrs.disabled}
|
||||||
disabled={!!this.props.disabled}
|
|
||||||
onkeydown={this.onkeydown.bind(this)}
|
onkeydown={this.onkeydown.bind(this)}
|
||||||
/>
|
/>
|
||||||
</h3>
|
</h3>
|
||||||
@ -71,7 +70,7 @@ export default class DiscussionComposer extends ComposerBody {
|
|||||||
this.composer.editor.moveCursorTo(0);
|
this.composer.editor.moveCursorTo(0);
|
||||||
}
|
}
|
||||||
|
|
||||||
m.redraw.strategy('none');
|
e.redraw = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
hasChanges() {
|
hasChanges() {
|
||||||
@ -101,7 +100,7 @@ export default class DiscussionComposer extends ComposerBody {
|
|||||||
.then((discussion) => {
|
.then((discussion) => {
|
||||||
this.composer.hide();
|
this.composer.hide();
|
||||||
app.discussions.refresh();
|
app.discussions.refresh();
|
||||||
m.route(app.route.discussion(discussion));
|
m.route.set(app.route.discussion(discussion));
|
||||||
}, this.loaded.bind(this));
|
}, this.loaded.bind(this));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -5,7 +5,7 @@ import listItems from '../../common/helpers/listItems';
|
|||||||
/**
|
/**
|
||||||
* The `DiscussionHero` component displays the hero on a discussion page.
|
* The `DiscussionHero` component displays the hero on a discussion page.
|
||||||
*
|
*
|
||||||
* ### Props
|
* ### attrs
|
||||||
*
|
*
|
||||||
* - `discussion`
|
* - `discussion`
|
||||||
*/
|
*/
|
||||||
@ -27,7 +27,7 @@ export default class DiscussionHero extends Component {
|
|||||||
*/
|
*/
|
||||||
items() {
|
items() {
|
||||||
const items = new ItemList();
|
const items = new ItemList();
|
||||||
const discussion = this.props.discussion;
|
const discussion = this.attrs.discussion;
|
||||||
const badges = discussion.badges().toArray();
|
const badges = discussion.badges().toArray();
|
||||||
|
|
||||||
if (badges.length) {
|
if (badges.length) {
|
||||||
|
@ -7,17 +7,13 @@ import Placeholder from '../../common/components/Placeholder';
|
|||||||
/**
|
/**
|
||||||
* The `DiscussionList` component displays a list of discussions.
|
* The `DiscussionList` component displays a list of discussions.
|
||||||
*
|
*
|
||||||
* ### Props
|
* ### Attrs
|
||||||
*
|
*
|
||||||
* - `state` A DiscussionListState object that represents the discussion lists's state.
|
* - `state` A DiscussionListState object that represents the discussion lists's state.
|
||||||
*/
|
*/
|
||||||
export default class DiscussionList extends Component {
|
export default class DiscussionList extends Component {
|
||||||
init() {
|
|
||||||
this.state = this.props.state;
|
|
||||||
}
|
|
||||||
|
|
||||||
view() {
|
view() {
|
||||||
const state = this.state;
|
const state = this.attrs.state;
|
||||||
|
|
||||||
const params = state.getParams();
|
const params = state.getParams();
|
||||||
let loading;
|
let loading;
|
||||||
@ -25,11 +21,13 @@ export default class DiscussionList extends Component {
|
|||||||
if (state.isLoading()) {
|
if (state.isLoading()) {
|
||||||
loading = LoadingIndicator.component();
|
loading = LoadingIndicator.component();
|
||||||
} else if (state.moreResults) {
|
} else if (state.moreResults) {
|
||||||
loading = Button.component({
|
loading = Button.component(
|
||||||
children: app.translator.trans('core.forum.discussion_list.load_more_button'),
|
{
|
||||||
className: 'Button',
|
className: 'Button',
|
||||||
onclick: state.loadMore.bind(state),
|
onclick: state.loadMore.bind(state),
|
||||||
});
|
},
|
||||||
|
app.translator.trans('core.forum.discussion_list.load_more_button')
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (state.empty()) {
|
if (state.empty()) {
|
||||||
|
@ -8,7 +8,6 @@ import ItemList from '../../common/utils/ItemList';
|
|||||||
import abbreviateNumber from '../../common/utils/abbreviateNumber';
|
import abbreviateNumber from '../../common/utils/abbreviateNumber';
|
||||||
import Dropdown from '../../common/components/Dropdown';
|
import Dropdown from '../../common/components/Dropdown';
|
||||||
import TerminalPost from './TerminalPost';
|
import TerminalPost from './TerminalPost';
|
||||||
import PostPreview from './PostPreview';
|
|
||||||
import SubtreeRetainer from '../../common/utils/SubtreeRetainer';
|
import SubtreeRetainer from '../../common/utils/SubtreeRetainer';
|
||||||
import DiscussionControls from '../utils/DiscussionControls';
|
import DiscussionControls from '../utils/DiscussionControls';
|
||||||
import slidable from '../utils/slidable';
|
import slidable from '../utils/slidable';
|
||||||
@ -20,13 +19,15 @@ import { escapeRegExp } from 'lodash-es';
|
|||||||
* The `DiscussionListItem` component shows a single discussion in the
|
* The `DiscussionListItem` component shows a single discussion in the
|
||||||
* discussion list.
|
* discussion list.
|
||||||
*
|
*
|
||||||
* ### Props
|
* ### Attrs
|
||||||
*
|
*
|
||||||
* - `discussion`
|
* - `discussion`
|
||||||
* - `params`
|
* - `params`
|
||||||
*/
|
*/
|
||||||
export default class DiscussionListItem extends Component {
|
export default class DiscussionListItem extends Component {
|
||||||
init() {
|
oninit(vnode) {
|
||||||
|
super.oninit(vnode);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Set up a subtree retainer so that the discussion will not be redrawn
|
* Set up a subtree retainer so that the discussion will not be redrawn
|
||||||
* unless new data comes in.
|
* unless new data comes in.
|
||||||
@ -34,7 +35,7 @@ export default class DiscussionListItem extends Component {
|
|||||||
* @type {SubtreeRetainer}
|
* @type {SubtreeRetainer}
|
||||||
*/
|
*/
|
||||||
this.subtree = new SubtreeRetainer(
|
this.subtree = new SubtreeRetainer(
|
||||||
() => this.props.discussion.freshness,
|
() => this.attrs.discussion.freshness,
|
||||||
() => {
|
() => {
|
||||||
const time = app.session.user && app.session.user.markedAllAsReadAt();
|
const time = app.session.user && app.session.user.markedAllAsReadAt();
|
||||||
return time && time.getTime();
|
return time && time.getTime();
|
||||||
@ -43,37 +44,33 @@ export default class DiscussionListItem extends Component {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
attrs() {
|
elementAttrs() {
|
||||||
return {
|
return {
|
||||||
className: classList([
|
className: classList([
|
||||||
'DiscussionListItem',
|
'DiscussionListItem',
|
||||||
this.active() ? 'active' : '',
|
this.active() ? 'active' : '',
|
||||||
this.props.discussion.isHidden() ? 'DiscussionListItem--hidden' : '',
|
this.attrs.discussion.isHidden() ? 'DiscussionListItem--hidden' : '',
|
||||||
]),
|
]),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
view() {
|
view() {
|
||||||
const retain = this.subtree.retain();
|
const discussion = this.attrs.discussion;
|
||||||
|
|
||||||
if (retain) return retain;
|
|
||||||
|
|
||||||
const discussion = this.props.discussion;
|
|
||||||
const user = discussion.user();
|
const user = discussion.user();
|
||||||
const isUnread = discussion.isUnread();
|
const isUnread = discussion.isUnread();
|
||||||
const isRead = discussion.isRead();
|
const isRead = discussion.isRead();
|
||||||
const showUnread = !this.showRepliesCount() && isUnread;
|
const showUnread = !this.showRepliesCount() && isUnread;
|
||||||
let jumpTo = 0;
|
let jumpTo = 0;
|
||||||
const controls = DiscussionControls.controls(discussion, this).toArray();
|
const controls = DiscussionControls.controls(discussion, this).toArray();
|
||||||
const attrs = this.attrs();
|
const attrs = this.elementAttrs();
|
||||||
|
|
||||||
if (this.props.params.q) {
|
if (this.attrs.params.q) {
|
||||||
const post = discussion.mostRelevantPost();
|
const post = discussion.mostRelevantPost();
|
||||||
if (post) {
|
if (post) {
|
||||||
jumpTo = post.number();
|
jumpTo = post.number();
|
||||||
}
|
}
|
||||||
|
|
||||||
const phrase = escapeRegExp(this.props.params.q);
|
const phrase = escapeRegExp(this.attrs.params.q);
|
||||||
this.highlightRegExp = new RegExp(phrase + '|' + phrase.trim().replace(/\s+/g, '|'), 'gi');
|
this.highlightRegExp = new RegExp(phrase + '|' + phrase.trim().replace(/\s+/g, '|'), 'gi');
|
||||||
} else {
|
} else {
|
||||||
jumpTo = Math.min(discussion.lastPostNumber(), (discussion.lastReadPostNumber() || 0) + 1);
|
jumpTo = Math.min(discussion.lastPostNumber(), (discussion.lastReadPostNumber() || 0) + 1);
|
||||||
@ -82,12 +79,14 @@ export default class DiscussionListItem extends Component {
|
|||||||
return (
|
return (
|
||||||
<div {...attrs}>
|
<div {...attrs}>
|
||||||
{controls.length
|
{controls.length
|
||||||
? Dropdown.component({
|
? Dropdown.component(
|
||||||
icon: 'fas fa-ellipsis-v',
|
{
|
||||||
children: controls,
|
icon: 'fas fa-ellipsis-v',
|
||||||
className: 'DiscussionListItem-controls',
|
className: 'DiscussionListItem-controls',
|
||||||
buttonClassName: 'Button Button--icon Button--flat Slidable-underneath Slidable-underneath--right',
|
buttonClassName: 'Button Button--icon Button--flat Slidable-underneath Slidable-underneath--right',
|
||||||
})
|
},
|
||||||
|
controls
|
||||||
|
)
|
||||||
: ''}
|
: ''}
|
||||||
|
|
||||||
<a
|
<a
|
||||||
@ -99,14 +98,13 @@ export default class DiscussionListItem extends Component {
|
|||||||
|
|
||||||
<div className={'DiscussionListItem-content Slidable-content' + (isUnread ? ' unread' : '') + (isRead ? ' read' : '')}>
|
<div className={'DiscussionListItem-content Slidable-content' + (isUnread ? ' unread' : '') + (isRead ? ' read' : '')}>
|
||||||
<a
|
<a
|
||||||
href={user ? app.route.user(user) : '#'}
|
route={user ? app.route.user(user) : '#'}
|
||||||
className="DiscussionListItem-author"
|
className="DiscussionListItem-author"
|
||||||
title={extractText(
|
title={extractText(
|
||||||
app.translator.trans('core.forum.discussion_list.started_text', { user: user, ago: humanTime(discussion.createdAt()) })
|
app.translator.trans('core.forum.discussion_list.started_text', { user: user, ago: humanTime(discussion.createdAt()) })
|
||||||
)}
|
)}
|
||||||
config={function (element) {
|
oncreate={function (vnode) {
|
||||||
$(element).tooltip({ placement: 'right' });
|
$(vnode.dom).tooltip({ placement: 'right' });
|
||||||
m.route.apply(this, arguments);
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{avatar(user, { title: '' })}
|
{avatar(user, { title: '' })}
|
||||||
@ -114,7 +112,7 @@ export default class DiscussionListItem extends Component {
|
|||||||
|
|
||||||
<ul className="DiscussionListItem-badges badges">{listItems(discussion.badges().toArray())}</ul>
|
<ul className="DiscussionListItem-badges badges">{listItems(discussion.badges().toArray())}</ul>
|
||||||
|
|
||||||
<a href={app.route.discussion(discussion, jumpTo)} config={m.route} className="DiscussionListItem-main">
|
<a route={app.route.discussion(discussion, jumpTo)} className="DiscussionListItem-main">
|
||||||
<h3 className="DiscussionListItem-title">{highlight(discussion.title(), this.highlightRegExp)}</h3>
|
<h3 className="DiscussionListItem-title">{highlight(discussion.title(), this.highlightRegExp)}</h3>
|
||||||
<ul className="DiscussionListItem-info">{listItems(this.infoItems().toArray())}</ul>
|
<ul className="DiscussionListItem-info">{listItems(this.infoItems().toArray())}</ul>
|
||||||
</a>
|
</a>
|
||||||
@ -131,8 +129,8 @@ export default class DiscussionListItem extends Component {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
config(isInitialized) {
|
oncreate(vnode) {
|
||||||
if (isInitialized) return;
|
super.oncreate(vnode);
|
||||||
|
|
||||||
// If we're on a touch device, set up the discussion row to be slidable.
|
// If we're on a touch device, set up the discussion row to be slidable.
|
||||||
// This allows the user to drag the row to either side of the screen to
|
// This allows the user to drag the row to either side of the screen to
|
||||||
@ -144,6 +142,12 @@ export default class DiscussionListItem extends Component {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
onbeforeupdate(vnode, old) {
|
||||||
|
super.onbeforeupdate(vnode, old);
|
||||||
|
|
||||||
|
return this.subtree.needsRebuild();
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Determine whether or not the discussion is currently being viewed.
|
* Determine whether or not the discussion is currently being viewed.
|
||||||
*
|
*
|
||||||
@ -152,7 +156,7 @@ export default class DiscussionListItem extends Component {
|
|||||||
active() {
|
active() {
|
||||||
const idParam = m.route.param('id');
|
const idParam = m.route.param('id');
|
||||||
|
|
||||||
return idParam && idParam.split('-')[0] === this.props.discussion.id();
|
return idParam && idParam.split('-')[0] === this.attrs.discussion.id();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -163,7 +167,7 @@ export default class DiscussionListItem extends Component {
|
|||||||
* @return {Boolean}
|
* @return {Boolean}
|
||||||
*/
|
*/
|
||||||
showFirstPost() {
|
showFirstPost() {
|
||||||
return ['newest', 'oldest'].indexOf(this.props.params.sort) !== -1;
|
return ['newest', 'oldest'].indexOf(this.attrs.params.sort) !== -1;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -173,14 +177,14 @@ export default class DiscussionListItem extends Component {
|
|||||||
* @return {Boolean}
|
* @return {Boolean}
|
||||||
*/
|
*/
|
||||||
showRepliesCount() {
|
showRepliesCount() {
|
||||||
return this.props.params.sort === 'replies';
|
return this.attrs.params.sort === 'replies';
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Mark the discussion as read.
|
* Mark the discussion as read.
|
||||||
*/
|
*/
|
||||||
markAsRead() {
|
markAsRead() {
|
||||||
const discussion = this.props.discussion;
|
const discussion = this.attrs.discussion;
|
||||||
|
|
||||||
if (discussion.isUnread()) {
|
if (discussion.isUnread()) {
|
||||||
discussion.save({ lastReadPostNumber: discussion.lastPostNumber() });
|
discussion.save({ lastReadPostNumber: discussion.lastPostNumber() });
|
||||||
@ -197,8 +201,8 @@ export default class DiscussionListItem extends Component {
|
|||||||
infoItems() {
|
infoItems() {
|
||||||
const items = new ItemList();
|
const items = new ItemList();
|
||||||
|
|
||||||
if (this.props.params.q) {
|
if (this.attrs.params.q) {
|
||||||
const post = this.props.discussion.mostRelevantPost() || this.props.discussion.firstPost();
|
const post = this.attrs.discussion.mostRelevantPost() || this.attrs.discussion.firstPost();
|
||||||
|
|
||||||
if (post && post.contentType() === 'comment') {
|
if (post && post.contentType() === 'comment') {
|
||||||
const excerpt = highlight(post.contentPlain(), this.highlightRegExp, 175);
|
const excerpt = highlight(post.contentPlain(), this.highlightRegExp, 175);
|
||||||
@ -208,7 +212,7 @@ export default class DiscussionListItem extends Component {
|
|||||||
items.add(
|
items.add(
|
||||||
'terminalPost',
|
'terminalPost',
|
||||||
TerminalPost.component({
|
TerminalPost.component({
|
||||||
discussion: this.props.discussion,
|
discussion: this.attrs.discussion,
|
||||||
lastPost: !this.showFirstPost(),
|
lastPost: !this.showFirstPost(),
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
67
js/src/forum/components/DiscussionListPane.js
Normal file
67
js/src/forum/components/DiscussionListPane.js
Normal file
@ -0,0 +1,67 @@
|
|||||||
|
import DiscussionList from './DiscussionList';
|
||||||
|
import Component from '../../common/Component';
|
||||||
|
|
||||||
|
const hotEdge = (e) => {
|
||||||
|
if (e.pageX < 10) app.pane.show();
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The `DiscussionListPane` component displays the list of previously viewed
|
||||||
|
* discussions in a panel that can be displayed by moving the mouse to the left
|
||||||
|
* edge of the screen, where it can also be pinned in place.
|
||||||
|
*
|
||||||
|
* ### Attrs
|
||||||
|
*
|
||||||
|
* - `state` A DiscussionListState object that represents the discussion lists's state.
|
||||||
|
*/
|
||||||
|
export default class DiscussionListPane extends Component {
|
||||||
|
view() {
|
||||||
|
if (!this.attrs.state.hasDiscussions()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
return <div className="DiscussionPage-list">{this.enoughSpace() && <DiscussionList state={this.attrs.state} />}</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
oncreate(vnode) {
|
||||||
|
super.oncreate(vnode);
|
||||||
|
|
||||||
|
const $list = $(vnode.dom);
|
||||||
|
|
||||||
|
// When the mouse enters and leaves the discussions pane, we want to show
|
||||||
|
// and hide the pane respectively. We also create a 10px 'hot edge' on the
|
||||||
|
// left of the screen to activate the pane.
|
||||||
|
const pane = app.pane;
|
||||||
|
$list.hover(pane.show.bind(pane), pane.onmouseleave.bind(pane));
|
||||||
|
|
||||||
|
$(document).on('mousemove', hotEdge);
|
||||||
|
|
||||||
|
// If the discussion we are viewing is listed in the discussion list, then
|
||||||
|
// we will make sure it is visible in the viewport – if it is not we will
|
||||||
|
// scroll the list down to it.
|
||||||
|
const $discussion = $list.find('.DiscussionListItem.active');
|
||||||
|
if ($discussion.length) {
|
||||||
|
const listTop = $list.offset().top;
|
||||||
|
const listBottom = listTop + $list.outerHeight();
|
||||||
|
const discussionTop = $discussion.offset().top;
|
||||||
|
const discussionBottom = discussionTop + $discussion.outerHeight();
|
||||||
|
|
||||||
|
if (discussionTop < listTop || discussionBottom > listBottom) {
|
||||||
|
$list.scrollTop($list.scrollTop() - listTop + discussionTop);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onremove() {
|
||||||
|
$(document).off('mousemove', hotEdge);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Are we on a device that's larger than we consider "mobile"?
|
||||||
|
*
|
||||||
|
* @returns {boolean}
|
||||||
|
*/
|
||||||
|
enoughSpace() {
|
||||||
|
return !$('.App-navigation').is(':visible');
|
||||||
|
}
|
||||||
|
}
|
@ -1,13 +1,13 @@
|
|||||||
import Page from '../../common/components/Page';
|
import Page from '../../common/components/Page';
|
||||||
import ItemList from '../../common/utils/ItemList';
|
import ItemList from '../../common/utils/ItemList';
|
||||||
import DiscussionHero from './DiscussionHero';
|
import DiscussionHero from './DiscussionHero';
|
||||||
|
import DiscussionListPane from './DiscussionListPane';
|
||||||
import PostStream from './PostStream';
|
import PostStream from './PostStream';
|
||||||
import PostStreamScrubber from './PostStreamScrubber';
|
import PostStreamScrubber from './PostStreamScrubber';
|
||||||
import LoadingIndicator from '../../common/components/LoadingIndicator';
|
import LoadingIndicator from '../../common/components/LoadingIndicator';
|
||||||
import SplitDropdown from '../../common/components/SplitDropdown';
|
import SplitDropdown from '../../common/components/SplitDropdown';
|
||||||
import listItems from '../../common/helpers/listItems';
|
import listItems from '../../common/helpers/listItems';
|
||||||
import DiscussionControls from '../utils/DiscussionControls';
|
import DiscussionControls from '../utils/DiscussionControls';
|
||||||
import DiscussionList from './DiscussionList';
|
|
||||||
import PostStreamState from '../states/PostStreamState';
|
import PostStreamState from '../states/PostStreamState';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -15,8 +15,8 @@ import PostStreamState from '../states/PostStreamState';
|
|||||||
* the discussion list pane, the hero, the posts, and the sidebar.
|
* the discussion list pane, the hero, the posts, and the sidebar.
|
||||||
*/
|
*/
|
||||||
export default class DiscussionPage extends Page {
|
export default class DiscussionPage extends Page {
|
||||||
init() {
|
oninit(vnode) {
|
||||||
super.init();
|
super.oninit(vnode);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The discussion that is being viewed.
|
* The discussion that is being viewed.
|
||||||
@ -42,38 +42,16 @@ export default class DiscussionPage extends Page {
|
|||||||
if (app.discussions.hasDiscussions()) {
|
if (app.discussions.hasDiscussions()) {
|
||||||
app.pane.enable();
|
app.pane.enable();
|
||||||
app.pane.hide();
|
app.pane.hide();
|
||||||
|
|
||||||
if (app.previous.matches(DiscussionPage)) {
|
|
||||||
m.redraw.strategy('diff');
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
app.history.push('discussion');
|
app.history.push('discussion');
|
||||||
|
|
||||||
this.bodyClass = 'App--discussion';
|
this.bodyClass = 'App--discussion';
|
||||||
|
|
||||||
|
this.prevRoute = m.route.get();
|
||||||
}
|
}
|
||||||
|
|
||||||
onunload(e) {
|
onremove() {
|
||||||
// If we have routed to the same discussion as we were viewing previously,
|
|
||||||
// cancel the unloading of this controller and instead prompt the post
|
|
||||||
// stream to jump to the new 'near' param.
|
|
||||||
if (this.discussion) {
|
|
||||||
const idParam = m.route.param('id');
|
|
||||||
|
|
||||||
if (idParam && idParam.split('-')[0] === this.discussion.id()) {
|
|
||||||
e.preventDefault();
|
|
||||||
|
|
||||||
const near = m.route.param('near') || '1';
|
|
||||||
|
|
||||||
if (near !== String(this.near)) {
|
|
||||||
this.stream.goToNumber(near);
|
|
||||||
}
|
|
||||||
|
|
||||||
this.near = null;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// If we are indeed navigating away from this discussion, then disable the
|
// If we are indeed navigating away from this discussion, then disable the
|
||||||
// discussion list pane. Also, if we're composing a reply to this
|
// discussion list pane. Also, if we're composing a reply to this
|
||||||
// discussion, minimize the composer – unless it's empty, in which case
|
// discussion, minimize the composer – unless it's empty, in which case
|
||||||
@ -92,14 +70,7 @@ export default class DiscussionPage extends Page {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="DiscussionPage">
|
<div className="DiscussionPage">
|
||||||
{app.discussions.hasDiscussions() ? (
|
<DiscussionListPane state={app.discussions} />
|
||||||
<div className="DiscussionPage-list" config={this.configPane.bind(this)}>
|
|
||||||
{!$('.App-navigation').is(':visible') && <DiscussionList state={app.discussions} />}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
''
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="DiscussionPage-discussion">
|
<div className="DiscussionPage-discussion">
|
||||||
{discussion
|
{discussion
|
||||||
? [
|
? [
|
||||||
@ -124,11 +95,30 @@ export default class DiscussionPage extends Page {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
config(...args) {
|
onbeforeupdate(vnode) {
|
||||||
super.config(...args);
|
super.onbeforeupdate(vnode);
|
||||||
|
|
||||||
if (this.discussion) {
|
if (m.route.get() !== this.prevRoute) {
|
||||||
app.setTitle(this.discussion.title());
|
this.prevRoute = m.route.get();
|
||||||
|
|
||||||
|
// If we have routed to the same discussion as we were viewing previously,
|
||||||
|
// cancel the unloading of this controller and instead prompt the post
|
||||||
|
// stream to jump to the new 'near' param.
|
||||||
|
if (this.discussion) {
|
||||||
|
const idParam = m.route.param('id');
|
||||||
|
|
||||||
|
if (idParam && idParam.split('-')[0] === this.discussion.id()) {
|
||||||
|
const near = m.route.param('near') || '1';
|
||||||
|
|
||||||
|
if (near !== String(this.near)) {
|
||||||
|
this.stream.goToNumber(near);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.near = near;
|
||||||
|
} else {
|
||||||
|
this.oninit(vnode);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -149,7 +139,7 @@ export default class DiscussionPage extends Page {
|
|||||||
app.store.find('discussions', m.route.param('id').split('-')[0], params).then(this.show.bind(this));
|
app.store.find('discussions', m.route.param('id').split('-')[0], params).then(this.show.bind(this));
|
||||||
}
|
}
|
||||||
|
|
||||||
m.lazyRedraw();
|
m.redraw();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -173,6 +163,7 @@ export default class DiscussionPage extends Page {
|
|||||||
this.discussion = discussion;
|
this.discussion = discussion;
|
||||||
|
|
||||||
app.history.push('discussion', discussion.title());
|
app.history.push('discussion', discussion.title());
|
||||||
|
app.setTitle(this.discussion.title());
|
||||||
app.setTitleCount(0);
|
app.setTitleCount(0);
|
||||||
|
|
||||||
// When the API responds with a discussion, it will also include a number of
|
// When the API responds with a discussion, it will also include a number of
|
||||||
@ -209,48 +200,6 @@ export default class DiscussionPage extends Page {
|
|||||||
app.current.set('stream', this.stream);
|
app.current.set('stream', this.stream);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Configure the discussion list pane.
|
|
||||||
*
|
|
||||||
* @param {DOMElement} element
|
|
||||||
* @param {Boolean} isInitialized
|
|
||||||
* @param {Object} context
|
|
||||||
*/
|
|
||||||
configPane(element, isInitialized, context) {
|
|
||||||
if (isInitialized) return;
|
|
||||||
|
|
||||||
context.retain = true;
|
|
||||||
|
|
||||||
const $list = $(element);
|
|
||||||
|
|
||||||
// When the mouse enters and leaves the discussions pane, we want to show
|
|
||||||
// and hide the pane respectively. We also create a 10px 'hot edge' on the
|
|
||||||
// left of the screen to activate the pane.
|
|
||||||
const pane = app.pane;
|
|
||||||
$list.hover(pane.show.bind(pane), pane.onmouseleave.bind(pane));
|
|
||||||
|
|
||||||
const hotEdge = (e) => {
|
|
||||||
if (e.pageX < 10) pane.show();
|
|
||||||
};
|
|
||||||
$(document).on('mousemove', hotEdge);
|
|
||||||
context.onunload = () => $(document).off('mousemove', hotEdge);
|
|
||||||
|
|
||||||
// If the discussion we are viewing is listed in the discussion list, then
|
|
||||||
// we will make sure it is visible in the viewport – if it is not we will
|
|
||||||
// scroll the list down to it.
|
|
||||||
const $discussion = $list.find('.DiscussionListItem.active');
|
|
||||||
if ($discussion.length) {
|
|
||||||
const listTop = $list.offset().top;
|
|
||||||
const listBottom = listTop + $list.outerHeight();
|
|
||||||
const discussionTop = $discussion.offset().top;
|
|
||||||
const discussionBottom = discussionTop + $discussion.outerHeight();
|
|
||||||
|
|
||||||
if (discussionTop < listTop || discussionBottom > listBottom) {
|
|
||||||
$list.scrollTop($list.scrollTop() - listTop + discussionTop);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Build an item list for the contents of the sidebar.
|
* Build an item list for the contents of the sidebar.
|
||||||
*
|
*
|
||||||
@ -261,12 +210,14 @@ export default class DiscussionPage extends Page {
|
|||||||
|
|
||||||
items.add(
|
items.add(
|
||||||
'controls',
|
'controls',
|
||||||
SplitDropdown.component({
|
SplitDropdown.component(
|
||||||
children: DiscussionControls.controls(this.discussion, this).toArray(),
|
{
|
||||||
icon: 'fas fa-ellipsis-v',
|
icon: 'fas fa-ellipsis-v',
|
||||||
className: 'App-primaryControl',
|
className: 'App-primaryControl',
|
||||||
buttonClassName: 'Button--primary',
|
buttonClassName: 'Button--primary',
|
||||||
})
|
},
|
||||||
|
DiscussionControls.controls(this.discussion, this).toArray()
|
||||||
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
items.add(
|
items.add(
|
||||||
@ -295,7 +246,8 @@ export default class DiscussionPage extends Page {
|
|||||||
// replace it into the window's history and our own history stack.
|
// replace it into the window's history and our own history stack.
|
||||||
const url = app.route.discussion(discussion, (this.near = startNumber));
|
const url = app.route.discussion(discussion, (this.near = startNumber));
|
||||||
|
|
||||||
m.route(url, true);
|
this.prevRoute = url;
|
||||||
|
m.route.set(url, null, { replace: true });
|
||||||
window.history.replaceState(null, document.title, url);
|
window.history.replaceState(null, document.title, url);
|
||||||
|
|
||||||
app.history.push('discussion', discussion.title());
|
app.history.push('discussion', discussion.title());
|
||||||
|
@ -4,9 +4,9 @@ import Notification from './Notification';
|
|||||||
* The `DiscussionRenamedNotification` component displays a notification which
|
* The `DiscussionRenamedNotification` component displays a notification which
|
||||||
* indicates that a discussion has had its title changed.
|
* indicates that a discussion has had its title changed.
|
||||||
*
|
*
|
||||||
* ### Props
|
* ### Attrs
|
||||||
*
|
*
|
||||||
* - All of the props for Notification
|
* - All of the attrs for Notification
|
||||||
*/
|
*/
|
||||||
export default class DiscussionRenamedNotification extends Notification {
|
export default class DiscussionRenamedNotification extends Notification {
|
||||||
icon() {
|
icon() {
|
||||||
@ -14,12 +14,12 @@ export default class DiscussionRenamedNotification extends Notification {
|
|||||||
}
|
}
|
||||||
|
|
||||||
href() {
|
href() {
|
||||||
const notification = this.props.notification;
|
const notification = this.attrs.notification;
|
||||||
|
|
||||||
return app.route.discussion(notification.subject(), notification.content().postNumber);
|
return app.route.discussion(notification.subject(), notification.content().postNumber);
|
||||||
}
|
}
|
||||||
|
|
||||||
content() {
|
content() {
|
||||||
return app.translator.trans('core.forum.notifications.discussion_renamed_text', { user: this.props.notification.fromUser() });
|
return app.translator.trans('core.forum.notifications.discussion_renamed_text', { user: this.attrs.notification.fromUser() });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -5,9 +5,9 @@ import extractText from '../../common/utils/extractText';
|
|||||||
* The `DiscussionRenamedPost` component displays a discussion event post
|
* The `DiscussionRenamedPost` component displays a discussion event post
|
||||||
* indicating that the discussion has been renamed.
|
* indicating that the discussion has been renamed.
|
||||||
*
|
*
|
||||||
* ### Props
|
* ### Attrs
|
||||||
*
|
*
|
||||||
* - All of the props for EventPost
|
* - All of the attrs for EventPost
|
||||||
*/
|
*/
|
||||||
export default class DiscussionRenamedPost extends EventPost {
|
export default class DiscussionRenamedPost extends EventPost {
|
||||||
icon() {
|
icon() {
|
||||||
@ -22,7 +22,7 @@ export default class DiscussionRenamedPost extends EventPost {
|
|||||||
}
|
}
|
||||||
|
|
||||||
descriptionData() {
|
descriptionData() {
|
||||||
const post = this.props.post;
|
const post = this.attrs.post;
|
||||||
const oldTitle = post.content()[0];
|
const oldTitle = post.content()[0];
|
||||||
const newTitle = post.content()[1];
|
const newTitle = post.content()[1];
|
||||||
|
|
||||||
|
@ -34,18 +34,20 @@ export default class DiscussionsSearchSource {
|
|||||||
return [
|
return [
|
||||||
<li className="Dropdown-header">{app.translator.trans('core.forum.search.discussions_heading')}</li>,
|
<li className="Dropdown-header">{app.translator.trans('core.forum.search.discussions_heading')}</li>,
|
||||||
<li>
|
<li>
|
||||||
{LinkButton.component({
|
{LinkButton.component(
|
||||||
icon: 'fas fa-search',
|
{
|
||||||
children: app.translator.trans('core.forum.search.all_discussions_button', { query }),
|
icon: 'fas fa-search',
|
||||||
href: app.route('index', { q: query }),
|
href: app.route('index', { q: query }),
|
||||||
})}
|
},
|
||||||
|
app.translator.trans('core.forum.search.all_discussions_button', { query })
|
||||||
|
)}
|
||||||
</li>,
|
</li>,
|
||||||
results.map((discussion) => {
|
results.map((discussion) => {
|
||||||
const mostRelevantPost = discussion.mostRelevantPost();
|
const mostRelevantPost = discussion.mostRelevantPost();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<li className="DiscussionSearchResult" data-index={'discussions' + discussion.id()}>
|
<li className="DiscussionSearchResult" data-index={'discussions' + discussion.id()}>
|
||||||
<a href={app.route.discussion(discussion, mostRelevantPost && mostRelevantPost.number())} config={m.route}>
|
<a route={app.route.discussion(discussion, mostRelevantPost && mostRelevantPost.number())}>
|
||||||
<div className="DiscussionSearchResult-title">{highlight(discussion.title(), query)}</div>
|
<div className="DiscussionSearchResult-title">{highlight(discussion.title(), query)}</div>
|
||||||
{mostRelevantPost ? <div className="DiscussionSearchResult-excerpt">{highlight(mostRelevantPost.contentPlain(), query, 100)}</div> : ''}
|
{mostRelevantPost ? <div className="DiscussionSearchResult-excerpt">{highlight(mostRelevantPost.contentPlain(), query, 100)}</div> : ''}
|
||||||
</a>
|
</a>
|
||||||
|
@ -7,8 +7,8 @@ import DiscussionListState from '../states/DiscussionListState';
|
|||||||
* page.
|
* page.
|
||||||
*/
|
*/
|
||||||
export default class DiscussionsUserPage extends UserPage {
|
export default class DiscussionsUserPage extends UserPage {
|
||||||
init() {
|
oninit(vnode) {
|
||||||
super.init();
|
super.oninit(vnode);
|
||||||
|
|
||||||
this.loadUser(m.route.param('username'));
|
this.loadUser(m.route.param('username'));
|
||||||
}
|
}
|
||||||
|
@ -14,38 +14,32 @@ function minimizeComposerIfFullScreen(e) {
|
|||||||
* post. It sets the initial content to the content of the post that is being
|
* post. It sets the initial content to the content of the post that is being
|
||||||
* edited, and adds a header control to indicate which post is being edited.
|
* edited, and adds a header control to indicate which post is being edited.
|
||||||
*
|
*
|
||||||
* ### Props
|
* ### Attrs
|
||||||
*
|
*
|
||||||
* - All of the props for ComposerBody
|
* - All of the attrs for ComposerBody
|
||||||
* - `post`
|
* - `post`
|
||||||
*/
|
*/
|
||||||
export default class EditPostComposer extends ComposerBody {
|
export default class EditPostComposer extends ComposerBody {
|
||||||
static initProps(props) {
|
static initAttrs(attrs) {
|
||||||
super.initProps(props);
|
super.initAttrs(attrs);
|
||||||
|
|
||||||
props.submitLabel = props.submitLabel || app.translator.trans('core.forum.composer_edit.submit_button');
|
attrs.submitLabel = attrs.submitLabel || app.translator.trans('core.forum.composer_edit.submit_button');
|
||||||
props.confirmExit = props.confirmExit || app.translator.trans('core.forum.composer_edit.discard_confirmation');
|
attrs.confirmExit = attrs.confirmExit || app.translator.trans('core.forum.composer_edit.discard_confirmation');
|
||||||
props.originalContent = props.originalContent || props.post.content();
|
attrs.originalContent = attrs.originalContent || attrs.post.content();
|
||||||
props.user = props.user || props.post.user();
|
attrs.user = attrs.user || attrs.post.user();
|
||||||
|
|
||||||
props.post.editedContent = props.originalContent;
|
attrs.post.editedContent = attrs.originalContent;
|
||||||
}
|
}
|
||||||
|
|
||||||
headerItems() {
|
headerItems() {
|
||||||
const items = super.headerItems();
|
const items = super.headerItems();
|
||||||
const post = this.props.post;
|
const post = this.attrs.post;
|
||||||
|
|
||||||
const routeAndMinimize = function (element, isInitialized) {
|
|
||||||
if (isInitialized) return;
|
|
||||||
$(element).on('click', minimizeComposerIfFullScreen);
|
|
||||||
m.route.apply(this, arguments);
|
|
||||||
};
|
|
||||||
|
|
||||||
items.add(
|
items.add(
|
||||||
'title',
|
'title',
|
||||||
<h3>
|
<h3>
|
||||||
{icon('fas fa-pencil-alt')}{' '}
|
{icon('fas fa-pencil-alt')}{' '}
|
||||||
<a href={app.route.discussion(post.discussion(), post.number())} config={routeAndMinimize}>
|
<a route={app.route.discussion(post.discussion(), post.number())} onclick={minimizeComposerIfFullScreen}>
|
||||||
{app.translator.trans('core.forum.composer_edit.post_link', { number: post.number(), discussion: post.discussion().title() })}
|
{app.translator.trans('core.forum.composer_edit.post_link', { number: post.number(), discussion: post.discussion().title() })}
|
||||||
</a>
|
</a>
|
||||||
</h3>
|
</h3>
|
||||||
@ -60,7 +54,7 @@ export default class EditPostComposer extends ComposerBody {
|
|||||||
jumpToPreview(e) {
|
jumpToPreview(e) {
|
||||||
minimizeComposerIfFullScreen(e);
|
minimizeComposerIfFullScreen(e);
|
||||||
|
|
||||||
m.route(app.route.post(this.props.post));
|
m.route.set(app.route.post(this.attrs.post));
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -75,13 +69,13 @@ export default class EditPostComposer extends ComposerBody {
|
|||||||
}
|
}
|
||||||
|
|
||||||
onsubmit() {
|
onsubmit() {
|
||||||
const discussion = this.props.post.discussion();
|
const discussion = this.attrs.post.discussion();
|
||||||
|
|
||||||
this.loading = true;
|
this.loading = true;
|
||||||
|
|
||||||
const data = this.data();
|
const data = this.data();
|
||||||
|
|
||||||
this.props.post.save(data).then((post) => {
|
this.attrs.post.save(data).then((post) => {
|
||||||
// If we're currently viewing the discussion which this edit was made
|
// If we're currently viewing the discussion which this edit was made
|
||||||
// in, then we can scroll to the post.
|
// in, then we can scroll to the post.
|
||||||
if (app.viewingDiscussion(discussion)) {
|
if (app.viewingDiscussion(discussion)) {
|
||||||
@ -91,19 +85,23 @@ export default class EditPostComposer extends ComposerBody {
|
|||||||
// their edit has been made, containing a button which will
|
// their edit has been made, containing a button which will
|
||||||
// transition to their edited post when clicked.
|
// transition to their edited post when clicked.
|
||||||
let alert;
|
let alert;
|
||||||
const viewButton = Button.component({
|
const viewButton = Button.component(
|
||||||
className: 'Button Button--link',
|
{
|
||||||
children: app.translator.trans('core.forum.composer_edit.view_button'),
|
className: 'Button Button--link',
|
||||||
onclick: () => {
|
onclick: () => {
|
||||||
m.route(app.route.post(post));
|
m.route.set(app.route.post(post));
|
||||||
app.alerts.dismiss(alert);
|
app.alerts.dismiss(alert);
|
||||||
|
},
|
||||||
},
|
},
|
||||||
});
|
app.translator.trans('core.forum.composer_edit.view_button')
|
||||||
alert = app.alerts.show({
|
);
|
||||||
type: 'success',
|
alert = app.alerts.show(
|
||||||
children: app.translator.trans('core.forum.composer_edit.edited_message'),
|
{
|
||||||
controls: [viewButton],
|
type: 'success',
|
||||||
});
|
controls: [viewButton],
|
||||||
|
},
|
||||||
|
app.translator.trans('core.forum.composer_edit.edited_message')
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
this.composer.hide();
|
this.composer.hide();
|
||||||
|
@ -9,22 +9,22 @@ import ItemList from '../../common/utils/ItemList';
|
|||||||
* The `EditUserModal` component displays a modal dialog with a login form.
|
* The `EditUserModal` component displays a modal dialog with a login form.
|
||||||
*/
|
*/
|
||||||
export default class EditUserModal extends Modal {
|
export default class EditUserModal extends Modal {
|
||||||
init() {
|
oninit(vnode) {
|
||||||
super.init();
|
super.oninit(vnode);
|
||||||
|
|
||||||
const user = this.props.user;
|
const user = this.attrs.user;
|
||||||
|
|
||||||
this.username = m.prop(user.username() || '');
|
this.username = m.stream(user.username() || '');
|
||||||
this.email = m.prop(user.email() || '');
|
this.email = m.stream(user.email() || '');
|
||||||
this.isEmailConfirmed = m.prop(user.isEmailConfirmed() || false);
|
this.isEmailConfirmed = m.stream(user.isEmailConfirmed() || false);
|
||||||
this.setPassword = m.prop(false);
|
this.setPassword = m.stream(false);
|
||||||
this.password = m.prop(user.password() || '');
|
this.password = m.stream(user.password() || '');
|
||||||
this.groups = {};
|
this.groups = {};
|
||||||
|
|
||||||
app.store
|
app.store
|
||||||
.all('groups')
|
.all('groups')
|
||||||
.filter((group) => [Group.GUEST_ID, Group.MEMBER_ID].indexOf(group.id()) === -1)
|
.filter((group) => [Group.GUEST_ID, Group.MEMBER_ID].indexOf(group.id()) === -1)
|
||||||
.forEach((group) => (this.groups[group.id()] = m.prop(user.groups().indexOf(group) !== -1)));
|
.forEach((group) => (this.groups[group.id()] = m.stream(user.groups().indexOf(group) !== -1)));
|
||||||
}
|
}
|
||||||
|
|
||||||
className() {
|
className() {
|
||||||
@ -55,7 +55,7 @@ export default class EditUserModal extends Modal {
|
|||||||
40
|
40
|
||||||
);
|
);
|
||||||
|
|
||||||
if (app.session.user !== this.props.user) {
|
if (app.session.user !== this.attrs.user) {
|
||||||
items.add(
|
items.add(
|
||||||
'email',
|
'email',
|
||||||
<div className="Form-group">
|
<div className="Form-group">
|
||||||
@ -65,12 +65,14 @@ export default class EditUserModal extends Modal {
|
|||||||
</div>
|
</div>
|
||||||
{!this.isEmailConfirmed() ? (
|
{!this.isEmailConfirmed() ? (
|
||||||
<div>
|
<div>
|
||||||
{Button.component({
|
{Button.component(
|
||||||
className: 'Button Button--block',
|
{
|
||||||
children: app.translator.trans('core.forum.edit_user.activate_button'),
|
className: 'Button Button--block',
|
||||||
loading: this.loading,
|
loading: this.loading,
|
||||||
onclick: this.activate.bind(this),
|
onclick: this.activate.bind(this),
|
||||||
})}
|
},
|
||||||
|
app.translator.trans('core.forum.edit_user.activate_button')
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
''
|
''
|
||||||
@ -89,9 +91,9 @@ export default class EditUserModal extends Modal {
|
|||||||
type="checkbox"
|
type="checkbox"
|
||||||
onchange={(e) => {
|
onchange={(e) => {
|
||||||
this.setPassword(e.target.checked);
|
this.setPassword(e.target.checked);
|
||||||
m.redraw(true);
|
m.redraw.sync();
|
||||||
if (e.target.checked) this.$('[name=password]').select();
|
if (e.target.checked) this.$('[name=password]').select();
|
||||||
m.redraw.strategy('none');
|
e.redraw = false;
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
{app.translator.trans('core.forum.edit_user.set_password_label')}
|
{app.translator.trans('core.forum.edit_user.set_password_label')}
|
||||||
@ -125,7 +127,7 @@ export default class EditUserModal extends Modal {
|
|||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
bidi={this.groups[group.id()]}
|
bidi={this.groups[group.id()]}
|
||||||
disabled={this.props.user.id() === '1' && group.id() === Group.ADMINISTRATOR_ID}
|
disabled={this.attrs.user.id() === '1' && group.id() === Group.ADMINISTRATOR_ID}
|
||||||
/>
|
/>
|
||||||
{GroupBadge.component({ group, label: '' })} {group.nameSingular()}
|
{GroupBadge.component({ group, label: '' })} {group.nameSingular()}
|
||||||
</label>
|
</label>
|
||||||
@ -138,12 +140,14 @@ export default class EditUserModal extends Modal {
|
|||||||
items.add(
|
items.add(
|
||||||
'submit',
|
'submit',
|
||||||
<div className="Form-group">
|
<div className="Form-group">
|
||||||
{Button.component({
|
{Button.component(
|
||||||
className: 'Button Button--primary',
|
{
|
||||||
type: 'submit',
|
className: 'Button Button--primary',
|
||||||
loading: this.loading,
|
type: 'submit',
|
||||||
children: app.translator.trans('core.forum.edit_user.submit_button'),
|
loading: this.loading,
|
||||||
})}
|
},
|
||||||
|
app.translator.trans('core.forum.edit_user.submit_button')
|
||||||
|
)}
|
||||||
</div>,
|
</div>,
|
||||||
-10
|
-10
|
||||||
);
|
);
|
||||||
@ -157,7 +161,7 @@ export default class EditUserModal extends Modal {
|
|||||||
username: this.username(),
|
username: this.username(),
|
||||||
isEmailConfirmed: true,
|
isEmailConfirmed: true,
|
||||||
};
|
};
|
||||||
this.props.user
|
this.attrs.user
|
||||||
.save(data, { errorHandler: this.onerror.bind(this) })
|
.save(data, { errorHandler: this.onerror.bind(this) })
|
||||||
.then(() => {
|
.then(() => {
|
||||||
this.isEmailConfirmed(true);
|
this.isEmailConfirmed(true);
|
||||||
@ -180,7 +184,7 @@ export default class EditUserModal extends Modal {
|
|||||||
relationships: { groups },
|
relationships: { groups },
|
||||||
};
|
};
|
||||||
|
|
||||||
if (app.session.user !== this.props.user) {
|
if (app.session.user !== this.attrs.user) {
|
||||||
data.email = this.email();
|
data.email = this.email();
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -196,7 +200,7 @@ export default class EditUserModal extends Modal {
|
|||||||
|
|
||||||
this.loading = true;
|
this.loading = true;
|
||||||
|
|
||||||
this.props.user
|
this.attrs.user
|
||||||
.save(this.data(), { errorHandler: this.onerror.bind(this) })
|
.save(this.data(), { errorHandler: this.onerror.bind(this) })
|
||||||
.then(this.hide.bind(this))
|
.then(this.hide.bind(this))
|
||||||
.catch(() => {
|
.catch(() => {
|
||||||
|
@ -8,28 +8,28 @@ import icon from '../../common/helpers/icon';
|
|||||||
* event, like a discussion being renamed or stickied. Subclasses must implement
|
* event, like a discussion being renamed or stickied. Subclasses must implement
|
||||||
* the `icon` and `description` methods.
|
* the `icon` and `description` methods.
|
||||||
*
|
*
|
||||||
* ### Props
|
* ### Attrs
|
||||||
*
|
*
|
||||||
* - All of the props for `Post`
|
* - All of the attrs for `Post`
|
||||||
*
|
*
|
||||||
* @abstract
|
* @abstract
|
||||||
*/
|
*/
|
||||||
export default class EventPost extends Post {
|
export default class EventPost extends Post {
|
||||||
attrs() {
|
elementAttrs() {
|
||||||
const attrs = super.attrs();
|
const attrs = super.elementAttrs();
|
||||||
|
|
||||||
attrs.className = (attrs.className || '') + ' EventPost ' + ucfirst(this.props.post.contentType()) + 'Post';
|
attrs.className = (attrs.className || '') + ' EventPost ' + ucfirst(this.attrs.post.contentType()) + 'Post';
|
||||||
|
|
||||||
return attrs;
|
return attrs;
|
||||||
}
|
}
|
||||||
|
|
||||||
content() {
|
content() {
|
||||||
const user = this.props.post.user();
|
const user = this.attrs.post.user();
|
||||||
const username = usernameHelper(user);
|
const username = usernameHelper(user);
|
||||||
const data = Object.assign(this.descriptionData(), {
|
const data = Object.assign(this.descriptionData(), {
|
||||||
user,
|
user,
|
||||||
username: user ? (
|
username: user ? (
|
||||||
<a className="EventPost-user" href={app.route.user(user)} config={m.route}>
|
<a className="EventPost-user" route={app.route.user(user)}>
|
||||||
{username}
|
{username}
|
||||||
</a>
|
</a>
|
||||||
) : (
|
) : (
|
||||||
|
@ -1,5 +1,4 @@
|
|||||||
import Modal from '../../common/components/Modal';
|
import Modal from '../../common/components/Modal';
|
||||||
import Alert from '../../common/components/Alert';
|
|
||||||
import Button from '../../common/components/Button';
|
import Button from '../../common/components/Button';
|
||||||
import extractText from '../../common/utils/extractText';
|
import extractText from '../../common/utils/extractText';
|
||||||
|
|
||||||
@ -7,20 +6,20 @@ import extractText from '../../common/utils/extractText';
|
|||||||
* The `ForgotPasswordModal` component displays a modal which allows the user to
|
* The `ForgotPasswordModal` component displays a modal which allows the user to
|
||||||
* enter their email address and request a link to reset their password.
|
* enter their email address and request a link to reset their password.
|
||||||
*
|
*
|
||||||
* ### Props
|
* ### Attrs
|
||||||
*
|
*
|
||||||
* - `email`
|
* - `email`
|
||||||
*/
|
*/
|
||||||
export default class ForgotPasswordModal extends Modal {
|
export default class ForgotPasswordModal extends Modal {
|
||||||
init() {
|
oninit(vnode) {
|
||||||
super.init();
|
super.oninit(vnode);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The value of the email input.
|
* The value of the email input.
|
||||||
*
|
*
|
||||||
* @type {Function}
|
* @type {Function}
|
||||||
*/
|
*/
|
||||||
this.email = m.prop(this.props.email || '');
|
this.email = m.stream(this.attrs.email || '');
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Whether or not the password reset email was sent successfully.
|
* Whether or not the password reset email was sent successfully.
|
||||||
@ -64,18 +63,19 @@ export default class ForgotPasswordModal extends Modal {
|
|||||||
name="email"
|
name="email"
|
||||||
type="email"
|
type="email"
|
||||||
placeholder={extractText(app.translator.trans('core.forum.forgot_password.email_placeholder'))}
|
placeholder={extractText(app.translator.trans('core.forum.forgot_password.email_placeholder'))}
|
||||||
value={this.email()}
|
bidi={this.email}
|
||||||
onchange={m.withAttr('value', this.email)}
|
|
||||||
disabled={this.loading}
|
disabled={this.loading}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="Form-group">
|
<div className="Form-group">
|
||||||
{Button.component({
|
{Button.component(
|
||||||
className: 'Button Button--primary Button--block',
|
{
|
||||||
type: 'submit',
|
className: 'Button Button--primary Button--block',
|
||||||
loading: this.loading,
|
type: 'submit',
|
||||||
children: app.translator.trans('core.forum.forgot_password.submit_button'),
|
loading: this.loading,
|
||||||
})}
|
},
|
||||||
|
app.translator.trans('core.forum.forgot_password.submit_button')
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -91,7 +91,7 @@ export default class ForgotPasswordModal extends Modal {
|
|||||||
.request({
|
.request({
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
url: app.forum.attribute('apiUrl') + '/forgot',
|
url: app.forum.attribute('apiUrl') + '/forgot',
|
||||||
data: { email: this.email() },
|
body: { email: this.email() },
|
||||||
errorHandler: this.onerror.bind(this),
|
errorHandler: this.onerror.bind(this),
|
||||||
})
|
})
|
||||||
.then(() => {
|
.then(() => {
|
||||||
@ -104,7 +104,7 @@ export default class ForgotPasswordModal extends Modal {
|
|||||||
|
|
||||||
onerror(error) {
|
onerror(error) {
|
||||||
if (error.status === 404) {
|
if (error.status === 404) {
|
||||||
error.alert.children = app.translator.trans('core.forum.forgot_password.not_found_message');
|
error.alert.content = app.translator.trans('core.forum.forgot_password.not_found_message');
|
||||||
}
|
}
|
||||||
|
|
||||||
super.onerror(error);
|
super.onerror(error);
|
||||||
|
@ -11,13 +11,6 @@ export default class HeaderPrimary extends Component {
|
|||||||
return <ul className="Header-controls">{listItems(this.items().toArray())}</ul>;
|
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.
|
* Build an item list for the controls.
|
||||||
*
|
*
|
||||||
|
@ -19,13 +19,6 @@ export default class HeaderSecondary extends Component {
|
|||||||
return <ul className="Header-controls">{listItems(this.items().toArray())}</ul>;
|
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.
|
* Build an item list for the controls.
|
||||||
*
|
*
|
||||||
@ -41,28 +34,32 @@ export default class HeaderSecondary extends Component {
|
|||||||
|
|
||||||
for (const locale in app.data.locales) {
|
for (const locale in app.data.locales) {
|
||||||
locales.push(
|
locales.push(
|
||||||
Button.component({
|
Button.component(
|
||||||
active: app.data.locale === locale,
|
{
|
||||||
children: app.data.locales[locale],
|
active: app.data.locale === locale,
|
||||||
icon: app.data.locale === locale ? 'fas fa-check' : true,
|
icon: app.data.locale === locale ? 'fas fa-check' : true,
|
||||||
onclick: () => {
|
onclick: () => {
|
||||||
if (app.session.user) {
|
if (app.session.user) {
|
||||||
app.session.user.savePreferences({ locale }).then(() => window.location.reload());
|
app.session.user.savePreferences({ locale }).then(() => window.location.reload());
|
||||||
} else {
|
} else {
|
||||||
document.cookie = `locale=${locale}; path=/; expires=Tue, 19 Jan 2038 03:14:07 GMT`;
|
document.cookie = `locale=${locale}; path=/; expires=Tue, 19 Jan 2038 03:14:07 GMT`;
|
||||||
window.location.reload();
|
window.location.reload();
|
||||||
}
|
}
|
||||||
|
},
|
||||||
},
|
},
|
||||||
})
|
app.data.locales[locale]
|
||||||
|
)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
items.add(
|
items.add(
|
||||||
'locale',
|
'locale',
|
||||||
SelectDropdown.component({
|
SelectDropdown.component(
|
||||||
children: locales,
|
{
|
||||||
buttonClassName: 'Button Button--link',
|
buttonClassName: 'Button Button--link',
|
||||||
}),
|
},
|
||||||
|
locales
|
||||||
|
),
|
||||||
20
|
20
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -74,22 +71,26 @@ export default class HeaderSecondary extends Component {
|
|||||||
if (app.forum.attribute('allowSignUp')) {
|
if (app.forum.attribute('allowSignUp')) {
|
||||||
items.add(
|
items.add(
|
||||||
'signUp',
|
'signUp',
|
||||||
Button.component({
|
Button.component(
|
||||||
children: app.translator.trans('core.forum.header.sign_up_link'),
|
{
|
||||||
className: 'Button Button--link',
|
className: 'Button Button--link',
|
||||||
onclick: () => app.modal.show(SignUpModal),
|
onclick: () => app.modal.show(SignUpModal),
|
||||||
}),
|
},
|
||||||
|
app.translator.trans('core.forum.header.sign_up_link')
|
||||||
|
),
|
||||||
10
|
10
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
items.add(
|
items.add(
|
||||||
'logIn',
|
'logIn',
|
||||||
Button.component({
|
Button.component(
|
||||||
children: app.translator.trans('core.forum.header.log_in_link'),
|
{
|
||||||
className: 'Button Button--link',
|
className: 'Button Button--link',
|
||||||
onclick: () => app.modal.show(LogInModal),
|
onclick: () => app.modal.show(LogInModal),
|
||||||
}),
|
},
|
||||||
|
app.translator.trans('core.forum.header.log_in_link')
|
||||||
|
),
|
||||||
0
|
0
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -19,8 +19,8 @@ import SelectDropdown from '../../common/components/SelectDropdown';
|
|||||||
export default class IndexPage extends Page {
|
export default class IndexPage extends Page {
|
||||||
static providesInitialSearch = true;
|
static providesInitialSearch = true;
|
||||||
|
|
||||||
init() {
|
oninit(vnode) {
|
||||||
super.init();
|
super.oninit(vnode);
|
||||||
|
|
||||||
// If the user is returning from a discussion page, then take note of which
|
// If the user is returning from a discussion page, then take note of which
|
||||||
// discussion they have just visited. After the view is rendered, we will
|
// discussion they have just visited. After the view is rendered, we will
|
||||||
@ -42,12 +42,26 @@ export default class IndexPage extends Page {
|
|||||||
app.history.push('index', app.translator.trans('core.forum.header.back_to_index_tooltip'));
|
app.history.push('index', app.translator.trans('core.forum.header.back_to_index_tooltip'));
|
||||||
|
|
||||||
this.bodyClass = 'App--index';
|
this.bodyClass = 'App--index';
|
||||||
|
|
||||||
|
this.currentPath = m.route.get();
|
||||||
}
|
}
|
||||||
|
|
||||||
onunload() {
|
onbeforeupdate(vnode) {
|
||||||
// Save the scroll position so we can restore it when we return to the
|
super.onbeforeupdate(vnode);
|
||||||
// discussion list.
|
|
||||||
app.cache.scrollTop = $(window).scrollTop();
|
const curPath = m.route.get();
|
||||||
|
|
||||||
|
if (this.currentPath !== curPath) {
|
||||||
|
this.onNewRoute();
|
||||||
|
|
||||||
|
app.discussions.clear();
|
||||||
|
|
||||||
|
app.discussions.refreshParams(app.search.params());
|
||||||
|
|
||||||
|
this.currentPath = curPath;
|
||||||
|
|
||||||
|
this.setTitle();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
view() {
|
view() {
|
||||||
@ -72,15 +86,15 @@ export default class IndexPage extends Page {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
config(isInitialized, context) {
|
setTitle() {
|
||||||
super.config(...arguments);
|
|
||||||
|
|
||||||
if (isInitialized) return;
|
|
||||||
|
|
||||||
extend(context, 'onunload', () => $('#app').css('min-height', ''));
|
|
||||||
|
|
||||||
app.setTitle(app.translator.trans('core.forum.index.meta_title_text'));
|
app.setTitle(app.translator.trans('core.forum.index.meta_title_text'));
|
||||||
app.setTitleCount(0);
|
app.setTitleCount(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
oncreate(vnode) {
|
||||||
|
super.oncreate(vnode);
|
||||||
|
|
||||||
|
this.setTitle();
|
||||||
|
|
||||||
// Work out the difference between the height of this hero and that of the
|
// Work out the difference between the height of this hero and that of the
|
||||||
// previous hero. Maintain the same scroll position relative to the bottom
|
// previous hero. Maintain the same scroll position relative to the bottom
|
||||||
@ -117,6 +131,16 @@ export default class IndexPage extends Page {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
onremove() {
|
||||||
|
super.onremove();
|
||||||
|
|
||||||
|
$('#app').css('min-height', '');
|
||||||
|
|
||||||
|
// Save the scroll position so we can restore it when we return to the
|
||||||
|
// discussion list.
|
||||||
|
app.cache.scrollTop = $(window).scrollTop();
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get the component to display as the hero.
|
* Get the component to display as the hero.
|
||||||
*
|
*
|
||||||
@ -139,25 +163,31 @@ export default class IndexPage extends Page {
|
|||||||
|
|
||||||
items.add(
|
items.add(
|
||||||
'newDiscussion',
|
'newDiscussion',
|
||||||
Button.component({
|
Button.component(
|
||||||
children: app.translator.trans(
|
{
|
||||||
canStartDiscussion ? 'core.forum.index.start_discussion_button' : 'core.forum.index.cannot_start_discussion_button'
|
icon: 'fas fa-edit',
|
||||||
),
|
className: 'Button Button--primary IndexPage-newDiscussion',
|
||||||
icon: 'fas fa-edit',
|
itemClassName: 'App-primaryControl',
|
||||||
className: 'Button Button--primary IndexPage-newDiscussion',
|
onclick: () => {
|
||||||
itemClassName: 'App-primaryControl',
|
// If the user is not logged in, the promise rejects, and a login modal shows up.
|
||||||
onclick: this.newDiscussionAction.bind(this),
|
// Since that's already handled, we dont need to show an error message in the console.
|
||||||
disabled: !canStartDiscussion,
|
return this.newDiscussionAction().catch(() => {});
|
||||||
})
|
},
|
||||||
|
disabled: !canStartDiscussion,
|
||||||
|
},
|
||||||
|
app.translator.trans(canStartDiscussion ? 'core.forum.index.start_discussion_button' : 'core.forum.index.cannot_start_discussion_button')
|
||||||
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
items.add(
|
items.add(
|
||||||
'nav',
|
'nav',
|
||||||
SelectDropdown.component({
|
SelectDropdown.component(
|
||||||
children: this.navItems(this).toArray(),
|
{
|
||||||
buttonClassName: 'Button',
|
buttonClassName: 'Button',
|
||||||
className: 'App-titleControl',
|
className: 'App-titleControl',
|
||||||
})
|
},
|
||||||
|
this.navItems(this).toArray()
|
||||||
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
return items;
|
return items;
|
||||||
@ -175,11 +205,13 @@ export default class IndexPage extends Page {
|
|||||||
|
|
||||||
items.add(
|
items.add(
|
||||||
'allDiscussions',
|
'allDiscussions',
|
||||||
LinkButton.component({
|
LinkButton.component(
|
||||||
href: app.route('index', params),
|
{
|
||||||
children: app.translator.trans('core.forum.index.all_discussions_link'),
|
href: app.route('index', params),
|
||||||
icon: 'far fa-comments',
|
icon: 'far fa-comments',
|
||||||
}),
|
},
|
||||||
|
app.translator.trans('core.forum.index.all_discussions_link')
|
||||||
|
),
|
||||||
100
|
100
|
||||||
);
|
);
|
||||||
|
|
||||||
@ -204,21 +236,25 @@ export default class IndexPage extends Page {
|
|||||||
|
|
||||||
items.add(
|
items.add(
|
||||||
'sort',
|
'sort',
|
||||||
Dropdown.component({
|
Dropdown.component(
|
||||||
buttonClassName: 'Button',
|
{
|
||||||
label: sortOptions[app.search.params().sort] || Object.keys(sortMap).map((key) => sortOptions[key])[0],
|
buttonClassName: 'Button',
|
||||||
children: Object.keys(sortOptions).map((value) => {
|
label: sortOptions[app.search.params().sort] || Object.keys(sortMap).map((key) => sortOptions[key])[0],
|
||||||
|
},
|
||||||
|
Object.keys(sortOptions).map((value) => {
|
||||||
const label = sortOptions[value];
|
const label = sortOptions[value];
|
||||||
const active = (app.search.params().sort || Object.keys(sortMap)[0]) === value;
|
const active = (app.search.params().sort || Object.keys(sortMap)[0]) === value;
|
||||||
|
|
||||||
return Button.component({
|
return Button.component(
|
||||||
children: label,
|
{
|
||||||
icon: active ? 'fas fa-check' : true,
|
icon: active ? 'fas fa-check' : true,
|
||||||
onclick: app.search.changeSort.bind(app.search, value),
|
onclick: app.search.changeSort.bind(app.search, value),
|
||||||
active: active,
|
active: active,
|
||||||
});
|
},
|
||||||
}),
|
label
|
||||||
})
|
);
|
||||||
|
})
|
||||||
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
return items;
|
return items;
|
||||||
@ -270,20 +306,18 @@ export default class IndexPage extends Page {
|
|||||||
* @return {Promise}
|
* @return {Promise}
|
||||||
*/
|
*/
|
||||||
newDiscussionAction() {
|
newDiscussionAction() {
|
||||||
const deferred = m.deferred();
|
return new Promise((resolve, reject) => {
|
||||||
|
if (app.session.user) {
|
||||||
|
app.composer.load(DiscussionComposer, { user: app.session.user });
|
||||||
|
app.composer.show();
|
||||||
|
|
||||||
if (app.session.user) {
|
return resolve(app.composer);
|
||||||
app.composer.load(DiscussionComposer, { user: app.session.user });
|
} else {
|
||||||
app.composer.show();
|
app.modal.show(LogInModal);
|
||||||
|
|
||||||
deferred.resolve(app.composer);
|
return reject();
|
||||||
} else {
|
}
|
||||||
deferred.reject();
|
});
|
||||||
|
|
||||||
app.modal.show(LogInModal);
|
|
||||||
}
|
|
||||||
|
|
||||||
return deferred.promise;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -4,21 +4,21 @@ import Button from '../../common/components/Button';
|
|||||||
* The `LogInButton` component displays a social login button which will open
|
* The `LogInButton` component displays a social login button which will open
|
||||||
* a popup window containing the specified path.
|
* a popup window containing the specified path.
|
||||||
*
|
*
|
||||||
* ### Props
|
* ### Attrs
|
||||||
*
|
*
|
||||||
* - `path`
|
* - `path`
|
||||||
*/
|
*/
|
||||||
export default class LogInButton extends Button {
|
export default class LogInButton extends Button {
|
||||||
static initProps(props) {
|
static initAttrs(attrs) {
|
||||||
props.className = (props.className || '') + ' LogInButton';
|
attrs.className = (attrs.className || '') + ' LogInButton';
|
||||||
|
|
||||||
props.onclick = function () {
|
attrs.onclick = function () {
|
||||||
const width = 580;
|
const width = 580;
|
||||||
const height = 400;
|
const height = 400;
|
||||||
const $window = $(window);
|
const $window = $(window);
|
||||||
|
|
||||||
window.open(
|
window.open(
|
||||||
app.forum.attribute('baseUrl') + props.path,
|
app.forum.attribute('baseUrl') + attrs.path,
|
||||||
'logInPopup',
|
'logInPopup',
|
||||||
`width=${width},` +
|
`width=${width},` +
|
||||||
`height=${height},` +
|
`height=${height},` +
|
||||||
@ -28,6 +28,6 @@ export default class LogInButton extends Button {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
super.initProps(props);
|
super.initAttrs(attrs);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -9,35 +9,35 @@ import ItemList from '../../common/utils/ItemList';
|
|||||||
/**
|
/**
|
||||||
* The `LogInModal` component displays a modal dialog with a login form.
|
* The `LogInModal` component displays a modal dialog with a login form.
|
||||||
*
|
*
|
||||||
* ### Props
|
* ### Attrs
|
||||||
*
|
*
|
||||||
* - `identification`
|
* - `identification`
|
||||||
* - `password`
|
* - `password`
|
||||||
*/
|
*/
|
||||||
export default class LogInModal extends Modal {
|
export default class LogInModal extends Modal {
|
||||||
init() {
|
oninit(vnode) {
|
||||||
super.init();
|
super.oninit(vnode);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The value of the identification input.
|
* The value of the identification input.
|
||||||
*
|
*
|
||||||
* @type {Function}
|
* @type {Function}
|
||||||
*/
|
*/
|
||||||
this.identification = m.prop(this.props.identification || '');
|
this.identification = m.stream(this.attrs.identification || '');
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The value of the password input.
|
* The value of the password input.
|
||||||
*
|
*
|
||||||
* @type {Function}
|
* @type {Function}
|
||||||
*/
|
*/
|
||||||
this.password = m.prop(this.props.password || '');
|
this.password = m.stream(this.attrs.password || '');
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The value of the remember me input.
|
* The value of the remember me input.
|
||||||
*
|
*
|
||||||
* @type {Function}
|
* @type {Function}
|
||||||
*/
|
*/
|
||||||
this.remember = m.prop(!!this.props.remember);
|
this.remember = m.stream(!!this.attrs.remember);
|
||||||
}
|
}
|
||||||
|
|
||||||
className() {
|
className() {
|
||||||
@ -105,12 +105,14 @@ export default class LogInModal extends Modal {
|
|||||||
items.add(
|
items.add(
|
||||||
'submit',
|
'submit',
|
||||||
<div className="Form-group">
|
<div className="Form-group">
|
||||||
{Button.component({
|
{Button.component(
|
||||||
className: 'Button Button--primary Button--block',
|
{
|
||||||
type: 'submit',
|
className: 'Button Button--primary Button--block',
|
||||||
loading: this.loading,
|
type: 'submit',
|
||||||
children: app.translator.trans('core.forum.log_in.submit_button'),
|
loading: this.loading,
|
||||||
})}
|
},
|
||||||
|
app.translator.trans('core.forum.log_in.submit_button')
|
||||||
|
)}
|
||||||
</div>,
|
</div>,
|
||||||
-10
|
-10
|
||||||
);
|
);
|
||||||
@ -140,9 +142,9 @@ export default class LogInModal extends Modal {
|
|||||||
*/
|
*/
|
||||||
forgotPassword() {
|
forgotPassword() {
|
||||||
const email = this.identification();
|
const email = this.identification();
|
||||||
const props = email.indexOf('@') !== -1 ? { email } : undefined;
|
const attrs = email.indexOf('@') !== -1 ? { email } : undefined;
|
||||||
|
|
||||||
app.modal.show(ForgotPasswordModal, props);
|
app.modal.show(ForgotPasswordModal, attrs);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -152,11 +154,11 @@ export default class LogInModal extends Modal {
|
|||||||
* @public
|
* @public
|
||||||
*/
|
*/
|
||||||
signUp() {
|
signUp() {
|
||||||
const props = { password: this.password() };
|
const attrs = { password: this.password() };
|
||||||
const identification = this.identification();
|
const identification = this.identification();
|
||||||
props[identification.indexOf('@') !== -1 ? 'email' : 'username'] = identification;
|
attrs[identification.indexOf('@') !== -1 ? 'email' : 'username'] = identification;
|
||||||
|
|
||||||
app.modal.show(SignUpModal, props);
|
app.modal.show(SignUpModal, attrs);
|
||||||
}
|
}
|
||||||
|
|
||||||
onready() {
|
onready() {
|
||||||
@ -179,7 +181,7 @@ export default class LogInModal extends Modal {
|
|||||||
|
|
||||||
onerror(error) {
|
onerror(error) {
|
||||||
if (error.status === 401) {
|
if (error.status === 401) {
|
||||||
error.alert.children = app.translator.trans('core.forum.log_in.invalid_login_message');
|
error.alert.content = app.translator.trans('core.forum.log_in.invalid_login_message');
|
||||||
}
|
}
|
||||||
|
|
||||||
super.onerror(error);
|
super.onerror(error);
|
||||||
|
@ -8,7 +8,7 @@ import Button from '../../common/components/Button';
|
|||||||
* The `Notification` component abstract displays a single notification.
|
* The `Notification` component abstract displays a single notification.
|
||||||
* Subclasses should implement the `icon`, `href`, and `content` methods.
|
* Subclasses should implement the `icon`, `href`, and `content` methods.
|
||||||
*
|
*
|
||||||
* ### Props
|
* ### Attrs
|
||||||
*
|
*
|
||||||
* - `notification`
|
* - `notification`
|
||||||
*
|
*
|
||||||
@ -16,18 +16,17 @@ import Button from '../../common/components/Button';
|
|||||||
*/
|
*/
|
||||||
export default class Notification extends Component {
|
export default class Notification extends Component {
|
||||||
view() {
|
view() {
|
||||||
const notification = this.props.notification;
|
const notification = this.attrs.notification;
|
||||||
const href = this.href();
|
const href = this.href();
|
||||||
|
|
||||||
|
const linkAttrs = {};
|
||||||
|
linkAttrs[href.indexOf('://') === -1 ? 'route' : 'href'] = href;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<a
|
<a
|
||||||
className={'Notification Notification--' + notification.contentType() + ' ' + (!notification.isRead() ? 'unread' : '')}
|
className={'Notification Notification--' + notification.contentType() + ' ' + (!notification.isRead() ? 'unread' : '')}
|
||||||
href={href}
|
{...linkAttrs}
|
||||||
config={function (element, isInitialized) {
|
onclick={this.markAsRead.bind(this)}
|
||||||
if (href.indexOf('://') === -1) m.route.apply(this, arguments);
|
|
||||||
|
|
||||||
if (!isInitialized) $(element).click(this.markAsRead.bind(this));
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
{!notification.isRead() &&
|
{!notification.isRead() &&
|
||||||
Button.component({
|
Button.component({
|
||||||
@ -86,10 +85,10 @@ export default class Notification extends Component {
|
|||||||
* Mark the notification as read.
|
* Mark the notification as read.
|
||||||
*/
|
*/
|
||||||
markAsRead() {
|
markAsRead() {
|
||||||
if (this.props.notification.isRead()) return;
|
if (this.attrs.notification.isRead()) return;
|
||||||
|
|
||||||
app.session.user.pushAttributes({ unreadNotificationCount: app.session.user.unreadNotificationCount() - 1 });
|
app.session.user.pushAttributes({ unreadNotificationCount: app.session.user.unreadNotificationCount() - 1 });
|
||||||
|
|
||||||
this.props.notification.save({ isRead: true });
|
this.attrs.notification.save({ isRead: true });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -7,12 +7,14 @@ import ItemList from '../../common/utils/ItemList';
|
|||||||
* The `NotificationGrid` component displays a table of notification types and
|
* The `NotificationGrid` component displays a table of notification types and
|
||||||
* methods, allowing the user to toggle each combination.
|
* methods, allowing the user to toggle each combination.
|
||||||
*
|
*
|
||||||
* ### Props
|
* ### Attrs
|
||||||
*
|
*
|
||||||
* - `user`
|
* - `user`
|
||||||
*/
|
*/
|
||||||
export default class NotificationGrid extends Component {
|
export default class NotificationGrid extends Component {
|
||||||
init() {
|
oninit(vnode) {
|
||||||
|
super.oninit(vnode);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Information about the available notification methods.
|
* Information about the available notification methods.
|
||||||
*
|
*
|
||||||
@ -36,7 +38,7 @@ export default class NotificationGrid extends Component {
|
|||||||
}
|
}
|
||||||
|
|
||||||
view() {
|
view() {
|
||||||
const preferences = this.props.user.preferences();
|
const preferences = this.attrs.user.preferences();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<table className="NotificationGrid">
|
<table className="NotificationGrid">
|
||||||
@ -62,12 +64,12 @@ export default class NotificationGrid extends Component {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<td className="NotificationGrid-checkbox">
|
<td className="NotificationGrid-checkbox">
|
||||||
{Checkbox.component({
|
<Checkbox
|
||||||
state: !!preferences[key],
|
state={!!preferences[key]}
|
||||||
loading: this.loading[key],
|
loading={this.loading[key]}
|
||||||
disabled: !(key in preferences),
|
disabled={!(key in preferences)}
|
||||||
onchange: () => this.toggle([key]),
|
onchange={this.toggle.bind(this, [key])}
|
||||||
})}
|
/>
|
||||||
</td>
|
</td>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
@ -78,8 +80,8 @@ export default class NotificationGrid extends Component {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
config(isInitialized) {
|
oncreate(vnode) {
|
||||||
if (isInitialized) return;
|
super.oncreate(vnode);
|
||||||
|
|
||||||
this.$('thead .NotificationGrid-groupToggle').bind('mouseenter mouseleave', function (e) {
|
this.$('thead .NotificationGrid-groupToggle').bind('mouseenter mouseleave', function (e) {
|
||||||
const i = parseInt($(this).index(), 10) + 1;
|
const i = parseInt($(this).index(), 10) + 1;
|
||||||
@ -104,7 +106,7 @@ export default class NotificationGrid extends Component {
|
|||||||
* @param {Array} keys
|
* @param {Array} keys
|
||||||
*/
|
*/
|
||||||
toggle(keys) {
|
toggle(keys) {
|
||||||
const user = this.props.user;
|
const user = this.attrs.user;
|
||||||
const preferences = user.preferences();
|
const preferences = user.preferences();
|
||||||
const enabled = !preferences[keys[0]];
|
const enabled = !preferences[keys[0]];
|
||||||
|
|
||||||
@ -128,7 +130,7 @@ export default class NotificationGrid extends Component {
|
|||||||
* @param {String} method
|
* @param {String} method
|
||||||
*/
|
*/
|
||||||
toggleMethod(method) {
|
toggleMethod(method) {
|
||||||
const keys = this.types.map((type) => this.preferenceKey(type.name, method)).filter((key) => key in this.props.user.preferences());
|
const keys = this.types.map((type) => this.preferenceKey(type.name, method)).filter((key) => key in this.attrs.user.preferences());
|
||||||
|
|
||||||
this.toggle(keys);
|
this.toggle(keys);
|
||||||
}
|
}
|
||||||
@ -139,7 +141,7 @@ export default class NotificationGrid extends Component {
|
|||||||
* @param {String} type
|
* @param {String} type
|
||||||
*/
|
*/
|
||||||
toggleType(type) {
|
toggleType(type) {
|
||||||
const keys = this.methods.map((method) => this.preferenceKey(type, method.name)).filter((key) => key in this.props.user.preferences());
|
const keys = this.methods.map((method) => this.preferenceKey(type, method.name)).filter((key) => key in this.attrs.user.preferences());
|
||||||
|
|
||||||
this.toggle(keys);
|
this.toggle(keys);
|
||||||
}
|
}
|
||||||
|
@ -9,12 +9,9 @@ import Discussion from '../../common/models/Discussion';
|
|||||||
* notifications, grouped by discussion.
|
* notifications, grouped by discussion.
|
||||||
*/
|
*/
|
||||||
export default class NotificationList extends Component {
|
export default class NotificationList extends Component {
|
||||||
init() {
|
|
||||||
this.state = this.props.state;
|
|
||||||
}
|
|
||||||
|
|
||||||
view() {
|
view() {
|
||||||
const pages = this.state.getNotificationPages();
|
const state = this.attrs.state;
|
||||||
|
const pages = state.getNotificationPages();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="NotificationList">
|
<div className="NotificationList">
|
||||||
@ -24,7 +21,7 @@ export default class NotificationList extends Component {
|
|||||||
className: 'Button Button--icon Button--link',
|
className: 'Button Button--icon Button--link',
|
||||||
icon: 'fas fa-check',
|
icon: 'fas fa-check',
|
||||||
title: app.translator.trans('core.forum.notifications.mark_all_as_read_tooltip'),
|
title: app.translator.trans('core.forum.notifications.mark_all_as_read_tooltip'),
|
||||||
onclick: this.state.markAllAsRead.bind(this.state),
|
onclick: state.markAllAsRead.bind(state),
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -66,7 +63,7 @@ export default class NotificationList extends Component {
|
|||||||
return (
|
return (
|
||||||
<div className="NotificationGroup">
|
<div className="NotificationGroup">
|
||||||
{group.discussion ? (
|
{group.discussion ? (
|
||||||
<a className="NotificationGroup-header" href={app.route.discussion(group.discussion)} config={m.route}>
|
<a className="NotificationGroup-header" route={app.route.discussion(group.discussion)}>
|
||||||
{badges && badges.length ? <ul className="NotificationGroup-badges badges">{listItems(badges)}</ul> : ''}
|
{badges && badges.length ? <ul className="NotificationGroup-badges badges">{listItems(badges)}</ul> : ''}
|
||||||
{group.discussion.title()}
|
{group.discussion.title()}
|
||||||
</a>
|
</a>
|
||||||
@ -85,7 +82,7 @@ export default class NotificationList extends Component {
|
|||||||
});
|
});
|
||||||
})
|
})
|
||||||
: ''}
|
: ''}
|
||||||
{this.state.isLoading() ? (
|
{state.isLoading() ? (
|
||||||
<LoadingIndicator className="LoadingIndicator--block" />
|
<LoadingIndicator className="LoadingIndicator--block" />
|
||||||
) : pages.length ? (
|
) : pages.length ? (
|
||||||
''
|
''
|
||||||
@ -97,27 +94,31 @@ export default class NotificationList extends Component {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
config(isInitialized, context) {
|
oncreate(vnode) {
|
||||||
if (isInitialized) return;
|
super.oncreate(vnode);
|
||||||
|
|
||||||
const $notifications = this.$('.NotificationList-content');
|
this.$notifications = this.$('.NotificationList-content');
|
||||||
const $scrollParent = $notifications.css('overflow') === 'auto' ? $notifications : $(window);
|
this.$scrollParent = this.$notifications.css('overflow') === 'auto' ? this.$notifications : $(window);
|
||||||
|
|
||||||
const scrollHandler = () => {
|
this.boundScrollHandler = this.scrollHandler.bind(this);
|
||||||
const scrollTop = $scrollParent.scrollTop();
|
this.$scrollParent.on('scroll', this.boundScrollHandler);
|
||||||
const viewportHeight = $scrollParent.height();
|
}
|
||||||
const contentTop = $scrollParent === $notifications ? 0 : $notifications.offset().top;
|
|
||||||
const contentHeight = $notifications[0].scrollHeight;
|
|
||||||
|
|
||||||
if (this.state.hasMoreResults() && !this.state.isLoading() && scrollTop + viewportHeight >= contentTop + contentHeight) {
|
onremove() {
|
||||||
this.state.loadMore();
|
this.$scrollParent.off('scroll', this.boundScrollHandler);
|
||||||
}
|
}
|
||||||
};
|
|
||||||
|
|
||||||
$scrollParent.on('scroll', scrollHandler);
|
scrollHandler() {
|
||||||
|
const state = this.attrs.state;
|
||||||
|
|
||||||
context.onunload = () => {
|
const scrollTop = this.$scrollParent.scrollTop();
|
||||||
$scrollParent.off('scroll', scrollHandler);
|
const viewportHeight = this.$scrollParent.height();
|
||||||
};
|
|
||||||
|
const contentTop = this.$scrollParent === this.$notifications ? 0 : this.$notifications.offset().top;
|
||||||
|
const contentHeight = this.$notifications[0].scrollHeight;
|
||||||
|
|
||||||
|
if (state.hasMoreResults() && !state.isLoading() && scrollTop + viewportHeight >= contentTop + contentHeight) {
|
||||||
|
state.loadMore();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -3,21 +3,21 @@ import icon from '../../common/helpers/icon';
|
|||||||
import NotificationList from './NotificationList';
|
import NotificationList from './NotificationList';
|
||||||
|
|
||||||
export default class NotificationsDropdown extends Dropdown {
|
export default class NotificationsDropdown extends Dropdown {
|
||||||
static initProps(props) {
|
static initAttrs(attrs) {
|
||||||
props.className = props.className || 'NotificationsDropdown';
|
attrs.className = attrs.className || 'NotificationsDropdown';
|
||||||
props.buttonClassName = props.buttonClassName || 'Button Button--flat';
|
attrs.buttonClassName = attrs.buttonClassName || 'Button Button--flat';
|
||||||
props.menuClassName = props.menuClassName || 'Dropdown-menu--right';
|
attrs.menuClassName = attrs.menuClassName || 'Dropdown-menu--right';
|
||||||
props.label = props.label || app.translator.trans('core.forum.notifications.tooltip');
|
attrs.label = attrs.label || app.translator.trans('core.forum.notifications.tooltip');
|
||||||
props.icon = props.icon || 'fas fa-bell';
|
attrs.icon = attrs.icon || 'fas fa-bell';
|
||||||
|
|
||||||
super.initProps(props);
|
super.initAttrs(attrs);
|
||||||
}
|
}
|
||||||
|
|
||||||
getButton() {
|
getButton() {
|
||||||
const newNotifications = this.getNewCount();
|
const newNotifications = this.getNewCount();
|
||||||
const vdom = super.getButton();
|
const vdom = super.getButton();
|
||||||
|
|
||||||
vdom.attrs.title = this.props.label;
|
vdom.attrs.title = this.attrs.label;
|
||||||
|
|
||||||
vdom.attrs.className += newNotifications ? ' new' : '';
|
vdom.attrs.className += newNotifications ? ' new' : '';
|
||||||
vdom.attrs.onclick = this.onclick.bind(this);
|
vdom.attrs.onclick = this.onclick.bind(this);
|
||||||
@ -29,16 +29,16 @@ export default class NotificationsDropdown extends Dropdown {
|
|||||||
const unread = this.getUnreadCount();
|
const unread = this.getUnreadCount();
|
||||||
|
|
||||||
return [
|
return [
|
||||||
icon(this.props.icon, { className: 'Button-icon' }),
|
icon(this.attrs.icon, { className: 'Button-icon' }),
|
||||||
unread ? <span className="NotificationsDropdown-unread">{unread}</span> : '',
|
unread ? <span className="NotificationsDropdown-unread">{unread}</span> : '',
|
||||||
<span className="Button-label">{this.props.label}</span>,
|
<span className="Button-label">{this.attrs.label}</span>,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
getMenu() {
|
getMenu() {
|
||||||
return (
|
return (
|
||||||
<div className={'Dropdown-menu ' + this.props.menuClassName} onclick={this.menuClick.bind(this)}>
|
<div className={'Dropdown-menu ' + this.attrs.menuClassName} onclick={this.menuClick.bind(this)}>
|
||||||
{this.showing ? NotificationList.component({ state: this.props.state }) : ''}
|
{this.showing ? NotificationList.component({ state: this.attrs.state }) : ''}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -47,12 +47,12 @@ export default class NotificationsDropdown extends Dropdown {
|
|||||||
if (app.drawer.isOpen()) {
|
if (app.drawer.isOpen()) {
|
||||||
this.goToRoute();
|
this.goToRoute();
|
||||||
} else {
|
} else {
|
||||||
this.props.state.load();
|
this.attrs.state.load();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
goToRoute() {
|
goToRoute() {
|
||||||
m.route(app.route('notifications'));
|
m.route.set(app.route('notifications'));
|
||||||
}
|
}
|
||||||
|
|
||||||
getUnreadCount() {
|
getUnreadCount() {
|
||||||
|
@ -6,8 +6,8 @@ import NotificationList from './NotificationList';
|
|||||||
* used on mobile devices where the notifications dropdown is within the drawer.
|
* used on mobile devices where the notifications dropdown is within the drawer.
|
||||||
*/
|
*/
|
||||||
export default class NotificationsPage extends Page {
|
export default class NotificationsPage extends Page {
|
||||||
init() {
|
oninit(vnode) {
|
||||||
super.init();
|
super.oninit(vnode);
|
||||||
|
|
||||||
app.history.push('notifications');
|
app.history.push('notifications');
|
||||||
|
|
||||||
|
@ -10,14 +10,16 @@ import ItemList from '../../common/utils/ItemList';
|
|||||||
* includes a controls dropdown; subclasses must implement `content` and `attrs`
|
* includes a controls dropdown; subclasses must implement `content` and `attrs`
|
||||||
* methods.
|
* methods.
|
||||||
*
|
*
|
||||||
* ### Props
|
* ### Attrs
|
||||||
*
|
*
|
||||||
* - `post`
|
* - `post`
|
||||||
*
|
*
|
||||||
* @abstract
|
* @abstract
|
||||||
*/
|
*/
|
||||||
export default class Post extends Component {
|
export default class Post extends Component {
|
||||||
init() {
|
oninit(vnode) {
|
||||||
|
super.oninit(vnode);
|
||||||
|
|
||||||
this.loading = false;
|
this.loading = false;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -27,9 +29,9 @@ export default class Post extends Component {
|
|||||||
* @type {SubtreeRetainer}
|
* @type {SubtreeRetainer}
|
||||||
*/
|
*/
|
||||||
this.subtree = new SubtreeRetainer(
|
this.subtree = new SubtreeRetainer(
|
||||||
() => this.props.post.freshness,
|
() => this.attrs.post.freshness,
|
||||||
() => {
|
() => {
|
||||||
const user = this.props.post.user();
|
const user = this.attrs.post.user();
|
||||||
return user && user.freshness;
|
return user && user.freshness;
|
||||||
},
|
},
|
||||||
() => this.controlsOpen
|
() => this.controlsOpen
|
||||||
@ -37,51 +39,52 @@ export default class Post extends Component {
|
|||||||
}
|
}
|
||||||
|
|
||||||
view() {
|
view() {
|
||||||
const attrs = this.attrs();
|
const attrs = this.elementAttrs();
|
||||||
|
|
||||||
attrs.className = this.classes(attrs.className).join(' ');
|
attrs.className = this.classes(attrs.className).join(' ');
|
||||||
|
|
||||||
|
const controls = PostControls.controls(this.attrs.post, this).toArray();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<article {...attrs}>
|
<article {...attrs}>
|
||||||
{this.subtree.retain() ||
|
<div>
|
||||||
(() => {
|
{this.content()}
|
||||||
const controls = PostControls.controls(this.props.post, this).toArray();
|
<aside className="Post-actions">
|
||||||
|
<ul>
|
||||||
return (
|
{listItems(this.actionItems().toArray())}
|
||||||
<div>
|
{controls.length ? (
|
||||||
{this.content()}
|
<li>
|
||||||
<aside className="Post-actions">
|
<Dropdown
|
||||||
<ul>
|
className="Post-controls"
|
||||||
{listItems(this.actionItems().toArray())}
|
buttonClassName="Button Button--icon Button--flat"
|
||||||
{controls.length ? (
|
menuClassName="Dropdown-menu--right"
|
||||||
<li>
|
icon="fas fa-ellipsis-h"
|
||||||
<Dropdown
|
onshow={() => this.$('.Post-actions').addClass('open')}
|
||||||
className="Post-controls"
|
onhide={() => this.$('.Post-actions').removeClass('open')}
|
||||||
buttonClassName="Button Button--icon Button--flat"
|
>
|
||||||
menuClassName="Dropdown-menu--right"
|
{controls}
|
||||||
icon="fas fa-ellipsis-h"
|
</Dropdown>
|
||||||
onshow={() => this.$('.Post-actions').addClass('open')}
|
</li>
|
||||||
onhide={() => this.$('.Post-actions').removeClass('open')}
|
) : (
|
||||||
>
|
''
|
||||||
{controls}
|
)}
|
||||||
</Dropdown>
|
</ul>
|
||||||
</li>
|
</aside>
|
||||||
) : (
|
<footer className="Post-footer">
|
||||||
''
|
<ul>{listItems(this.footerItems().toArray())}</ul>
|
||||||
)}
|
</footer>
|
||||||
</ul>
|
</div>
|
||||||
</aside>
|
|
||||||
<footer className="Post-footer">
|
|
||||||
<ul>{listItems(this.footerItems().toArray())}</ul>
|
|
||||||
</footer>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})()}
|
|
||||||
</article>
|
</article>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
config(isInitialized) {
|
onbeforeupdate(vnode) {
|
||||||
|
super.onbeforeupdate(vnode);
|
||||||
|
|
||||||
|
return this.subtree.needsRebuild();
|
||||||
|
}
|
||||||
|
|
||||||
|
onupdate() {
|
||||||
const $actions = this.$('.Post-actions');
|
const $actions = this.$('.Post-actions');
|
||||||
const $controls = this.$('.Post-controls');
|
const $controls = this.$('.Post-controls');
|
||||||
|
|
||||||
@ -93,7 +96,7 @@ export default class Post extends Component {
|
|||||||
*
|
*
|
||||||
* @return {Object}
|
* @return {Object}
|
||||||
*/
|
*/
|
||||||
attrs() {
|
elementAttrs() {
|
||||||
return {};
|
return {};
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -115,8 +118,8 @@ export default class Post extends Component {
|
|||||||
classes(existing) {
|
classes(existing) {
|
||||||
let classes = (existing || '').split(' ').concat(['Post']);
|
let classes = (existing || '').split(' ').concat(['Post']);
|
||||||
|
|
||||||
const user = this.props.post.user();
|
const user = this.attrs.post.user();
|
||||||
const discussion = this.props.post.discussion();
|
const discussion = this.attrs.post.discussion();
|
||||||
|
|
||||||
if (this.loading) {
|
if (this.loading) {
|
||||||
classes.push('Post--loading');
|
classes.push('Post--loading');
|
||||||
|
@ -6,18 +6,20 @@ import extractText from '../../common/utils/extractText';
|
|||||||
* The `PostEdited` component displays information about when and by whom a post
|
* The `PostEdited` component displays information about when and by whom a post
|
||||||
* was edited.
|
* was edited.
|
||||||
*
|
*
|
||||||
* ### Props
|
* ### Attrs
|
||||||
*
|
*
|
||||||
* - `post`
|
* - `post`
|
||||||
*/
|
*/
|
||||||
export default class PostEdited extends Component {
|
export default class PostEdited extends Component {
|
||||||
init() {
|
oninit(vnode) {
|
||||||
|
super.oninit(vnode);
|
||||||
|
|
||||||
this.shouldUpdateTooltip = false;
|
this.shouldUpdateTooltip = false;
|
||||||
this.oldEditedInfo = null;
|
this.oldEditedInfo = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
view() {
|
view() {
|
||||||
const post = this.props.post;
|
const post = this.attrs.post;
|
||||||
const editedUser = post.editedUser();
|
const editedUser = post.editedUser();
|
||||||
const editedInfo = extractText(app.translator.trans('core.forum.post.edited_tooltip', { user: editedUser, ago: humanTime(post.editedAt()) }));
|
const editedInfo = extractText(app.translator.trans('core.forum.post.edited_tooltip', { user: editedUser, ago: humanTime(post.editedAt()) }));
|
||||||
if (editedInfo !== this.oldEditedInfo) {
|
if (editedInfo !== this.oldEditedInfo) {
|
||||||
@ -32,7 +34,17 @@ export default class PostEdited extends Component {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
config(isInitialized) {
|
oncreate(vnode) {
|
||||||
|
super.oncreate(vnode);
|
||||||
|
|
||||||
|
this.rebuildTooltip();
|
||||||
|
}
|
||||||
|
|
||||||
|
onupdate() {
|
||||||
|
this.rebuildTooltip();
|
||||||
|
}
|
||||||
|
|
||||||
|
rebuildTooltip() {
|
||||||
if (this.shouldUpdateTooltip) {
|
if (this.shouldUpdateTooltip) {
|
||||||
this.$().tooltip('destroy').tooltip();
|
this.$().tooltip('destroy').tooltip();
|
||||||
this.shouldUpdateTooltip = false;
|
this.shouldUpdateTooltip = false;
|
||||||
|
@ -7,23 +7,23 @@ import fullTime from '../../common/helpers/fullTime';
|
|||||||
* a dropdown containing more information about the post (number, full time,
|
* a dropdown containing more information about the post (number, full time,
|
||||||
* permalink).
|
* permalink).
|
||||||
*
|
*
|
||||||
* ### Props
|
* ### Attrs
|
||||||
*
|
*
|
||||||
* - `post`
|
* - `post`
|
||||||
*/
|
*/
|
||||||
export default class PostMeta extends Component {
|
export default class PostMeta extends Component {
|
||||||
view() {
|
view() {
|
||||||
const post = this.props.post;
|
const post = this.attrs.post;
|
||||||
const time = post.createdAt();
|
const time = post.createdAt();
|
||||||
const permalink = this.getPermalink(post);
|
const permalink = this.getPermalink(post);
|
||||||
const touch = 'ontouchstart' in document.documentElement;
|
const touch = 'ontouchstart' in document.documentElement;
|
||||||
|
|
||||||
// When the dropdown menu is shown, select the contents of the permalink
|
// When the dropdown menu is shown, select the contents of the permalink
|
||||||
// input so that the user can quickly copy the URL.
|
// input so that the user can quickly copy the URL.
|
||||||
const selectPermalink = function () {
|
const selectPermalink = function (e) {
|
||||||
setTimeout(() => $(this).parent().find('.PostMeta-permalink').select());
|
setTimeout(() => $(this).parent().find('.PostMeta-permalink').select());
|
||||||
|
|
||||||
m.redraw.strategy('none');
|
e.redraw = false;
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
@ -7,18 +7,18 @@ import highlight from '../../common/helpers/highlight';
|
|||||||
* The `PostPreview` component shows a link to a post containing the avatar and
|
* The `PostPreview` component shows a link to a post containing the avatar and
|
||||||
* username of the author, and a short excerpt of the post's content.
|
* username of the author, and a short excerpt of the post's content.
|
||||||
*
|
*
|
||||||
* ### Props
|
* ### Attrs
|
||||||
*
|
*
|
||||||
* - `post`
|
* - `post`
|
||||||
*/
|
*/
|
||||||
export default class PostPreview extends Component {
|
export default class PostPreview extends Component {
|
||||||
view() {
|
view() {
|
||||||
const post = this.props.post;
|
const post = this.attrs.post;
|
||||||
const user = post.user();
|
const user = post.user();
|
||||||
const excerpt = highlight(post.contentPlain(), this.props.highlight, 300);
|
const excerpt = highlight(post.contentPlain(), this.attrs.highlight, 300);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<a className="PostPreview" href={app.route.post(post)} config={m.route} onclick={this.props.onclick}>
|
<a className="PostPreview" route={app.route.post(post)} onclick={this.attrs.onclick}>
|
||||||
<span className="PostPreview-content">
|
<span className="PostPreview-content">
|
||||||
{avatar(user)}
|
{avatar(user)}
|
||||||
{username(user)} <span className="PostPreview-excerpt">{excerpt}</span>
|
{username(user)} <span className="PostPreview-excerpt">{excerpt}</span>
|
||||||
|
@ -8,7 +8,7 @@ import Button from '../../common/components/Button';
|
|||||||
* The `PostStream` component displays an infinitely-scrollable wall of posts in
|
* The `PostStream` component displays an infinitely-scrollable wall of posts in
|
||||||
* a discussion. Posts that have not loaded will be displayed as placeholders.
|
* a discussion. Posts that have not loaded will be displayed as placeholders.
|
||||||
*
|
*
|
||||||
* ### Props
|
* ### Attrs
|
||||||
*
|
*
|
||||||
* - `discussion`
|
* - `discussion`
|
||||||
* - `stream`
|
* - `stream`
|
||||||
@ -16,9 +16,11 @@ import Button from '../../common/components/Button';
|
|||||||
* - `onPositionChange`
|
* - `onPositionChange`
|
||||||
*/
|
*/
|
||||||
export default class PostStream extends Component {
|
export default class PostStream extends Component {
|
||||||
init() {
|
oninit(vnode) {
|
||||||
this.discussion = this.props.discussion;
|
super.oninit(vnode);
|
||||||
this.stream = this.props.stream;
|
|
||||||
|
this.discussion = this.attrs.discussion;
|
||||||
|
this.stream = this.attrs.stream;
|
||||||
|
|
||||||
this.scrollListener = new ScrollListener(this.onscroll.bind(this));
|
this.scrollListener = new ScrollListener(this.onscroll.bind(this));
|
||||||
}
|
}
|
||||||
@ -96,29 +98,33 @@ export default class PostStream extends Component {
|
|||||||
return <div className="PostStream">{items}</div>;
|
return <div className="PostStream">{items}</div>;
|
||||||
}
|
}
|
||||||
|
|
||||||
config(isInitialized, context) {
|
onupdate() {
|
||||||
this.triggerScroll();
|
this.triggerScroll();
|
||||||
|
}
|
||||||
|
|
||||||
if (isInitialized) return;
|
oncreate(vnode) {
|
||||||
|
super.oncreate(vnode);
|
||||||
|
|
||||||
|
this.triggerScroll();
|
||||||
|
|
||||||
// This is wrapped in setTimeout due to the following Mithril issue:
|
// This is wrapped in setTimeout due to the following Mithril issue:
|
||||||
// https://github.com/lhorie/mithril.js/issues/637
|
// https://github.com/lhorie/mithril.js/issues/637
|
||||||
setTimeout(() => this.scrollListener.start());
|
setTimeout(() => this.scrollListener.start());
|
||||||
|
}
|
||||||
|
|
||||||
context.onunload = () => {
|
onremove() {
|
||||||
this.scrollListener.stop();
|
this.scrollListener.stop();
|
||||||
clearTimeout(this.calculatePositionTimeout);
|
clearTimeout(this.calculatePositionTimeout);
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Start scrolling, if appropriate, to a newly-targeted post.
|
* Start scrolling, if appropriate, to a newly-targeted post.
|
||||||
*/
|
*/
|
||||||
triggerScroll() {
|
triggerScroll() {
|
||||||
if (!this.props.targetPost) return;
|
if (!this.attrs.targetPost) return;
|
||||||
|
|
||||||
const oldTarget = this.prevTarget;
|
const oldTarget = this.prevTarget;
|
||||||
const newTarget = this.props.targetPost;
|
const newTarget = this.attrs.targetPost;
|
||||||
|
|
||||||
if (oldTarget) {
|
if (oldTarget) {
|
||||||
if ('number' in oldTarget && oldTarget.number === newTarget.number) return;
|
if ('number' in oldTarget && oldTarget.number === newTarget.number) return;
|
||||||
@ -265,7 +271,7 @@ export default class PostStream extends Component {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (startNumber) {
|
if (startNumber) {
|
||||||
this.props.onPositionChange(startNumber || 1, endNumber, startNumber);
|
this.attrs.onPositionChange(startNumber || 1, endNumber, startNumber);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -348,7 +354,7 @@ export default class PostStream extends Component {
|
|||||||
return Promise.all([$container.promise(), this.stream.loadPromise]).then(() => {
|
return Promise.all([$container.promise(), this.stream.loadPromise]).then(() => {
|
||||||
this.updateScrubber();
|
this.updateScrubber();
|
||||||
const index = $item.data('index');
|
const index = $item.data('index');
|
||||||
m.redraw(true);
|
m.redraw.sync();
|
||||||
const scroll = index == 0 ? 0 : $(`.PostStream-item[data-index=${$item.data('index')}]`).offset().top - this.getMarginTop();
|
const scroll = index == 0 ? 0 : $(`.PostStream-item[data-index=${$item.data('index')}]`).offset().top - this.getMarginTop();
|
||||||
$(window).scrollTop(scroll);
|
$(window).scrollTop(scroll);
|
||||||
this.calculatePosition();
|
this.calculatePosition();
|
||||||
|
@ -7,14 +7,16 @@ import ScrollListener from '../../common/utils/ScrollListener';
|
|||||||
* The `PostStreamScrubber` component displays a scrubber which can be used to
|
* The `PostStreamScrubber` component displays a scrubber which can be used to
|
||||||
* navigate/scrub through a post stream.
|
* navigate/scrub through a post stream.
|
||||||
*
|
*
|
||||||
* ### Props
|
* ### Attrs
|
||||||
*
|
*
|
||||||
* - `stream`
|
* - `stream`
|
||||||
* - `className`
|
* - `className`
|
||||||
*/
|
*/
|
||||||
export default class PostStreamScrubber extends Component {
|
export default class PostStreamScrubber extends Component {
|
||||||
init() {
|
oninit(vnode) {
|
||||||
this.stream = this.props.stream;
|
super.oninit(vnode);
|
||||||
|
|
||||||
|
this.stream = this.attrs.stream;
|
||||||
this.handlers = {};
|
this.handlers = {};
|
||||||
|
|
||||||
this.scrollListener = new ScrollListener(this.updateScrubberValues.bind(this, { fromScroll: true, forceHeightChange: true }));
|
this.scrollListener = new ScrollListener(this.updateScrubberValues.bind(this, { fromScroll: true, forceHeightChange: true }));
|
||||||
@ -32,23 +34,23 @@ export default class PostStreamScrubber extends Component {
|
|||||||
const unreadCount = this.stream.discussion.unreadCount();
|
const unreadCount = this.stream.discussion.unreadCount();
|
||||||
const unreadPercent = count ? Math.min(count - this.stream.index, unreadCount) / count : 0;
|
const unreadPercent = count ? Math.min(count - this.stream.index, unreadCount) / count : 0;
|
||||||
|
|
||||||
function styleUnread(element, isInitialized, context) {
|
function styleUnread(vnode) {
|
||||||
const $element = $(element);
|
const $element = $(vnode.dom);
|
||||||
const newStyle = {
|
const newStyle = {
|
||||||
top: 100 - unreadPercent * 100 + '%',
|
top: 100 - unreadPercent * 100 + '%',
|
||||||
height: unreadPercent * 100 + '%',
|
height: unreadPercent * 100 + '%',
|
||||||
};
|
};
|
||||||
|
|
||||||
if (context.oldStyle) {
|
if (vnode.state.oldStyle) {
|
||||||
$element.stop(true).css(context.oldStyle).animate(newStyle);
|
$element.stop(true).css(vnode.state.oldStyle).animate(newStyle);
|
||||||
} else {
|
} else {
|
||||||
$element.css(newStyle);
|
$element.css(newStyle);
|
||||||
}
|
}
|
||||||
|
|
||||||
context.oldStyle = newStyle;
|
vnode.state.oldStyle = newStyle;
|
||||||
}
|
}
|
||||||
const classNames = ['PostStreamScrubber', 'Dropdown'];
|
const classNames = ['PostStreamScrubber', 'Dropdown'];
|
||||||
if (this.props.className) classNames.push(this.props.className);
|
if (this.attrs.className) classNames.push(this.attrs.className);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={classNames.join(' ')}>
|
<div className={classNames.join(' ')}>
|
||||||
@ -68,12 +70,12 @@ export default class PostStreamScrubber extends Component {
|
|||||||
<div className="Scrubber-bar" />
|
<div className="Scrubber-bar" />
|
||||||
<div className="Scrubber-info">
|
<div className="Scrubber-info">
|
||||||
<strong>{viewing}</strong>
|
<strong>{viewing}</strong>
|
||||||
<span className="Scrubber-description">{this.stream.description}</span>
|
<span className="Scrubber-description"></span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="Scrubber-after" />
|
<div className="Scrubber-after" />
|
||||||
|
|
||||||
<div className="Scrubber-unread" config={styleUnread}>
|
<div className="Scrubber-unread" oncreate={styleUnread} onupdate={styleUnread}>
|
||||||
{app.translator.trans('core.forum.post_scrubber.unread_text', { count: unreadCount })}
|
{app.translator.trans('core.forum.post_scrubber.unread_text', { count: unreadCount })}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -87,11 +89,12 @@ export default class PostStreamScrubber extends Component {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
config(isInitialized, context) {
|
onupdate() {
|
||||||
this.stream.loadPromise.then(() => this.updateScrubberValues({ animate: true, forceHeightChange: true }));
|
this.stream.loadPromise.then(() => this.updateScrubberValues({ animate: true, forceHeightChange: true }));
|
||||||
if (isInitialized) return;
|
}
|
||||||
|
|
||||||
context.onunload = this.ondestroy.bind(this);
|
oncreate(vnode) {
|
||||||
|
super.oncreate(vnode);
|
||||||
|
|
||||||
// Whenever the window is resized, adjust the height of the scrollbar
|
// Whenever the window is resized, adjust the height of the scrollbar
|
||||||
// so that it fills the height of the sidebar.
|
// so that it fills the height of the sidebar.
|
||||||
@ -133,6 +136,15 @@ export default class PostStreamScrubber extends Component {
|
|||||||
.on('mouseup touchend', (this.handlers.onmouseup = this.onmouseup.bind(this)));
|
.on('mouseup touchend', (this.handlers.onmouseup = this.onmouseup.bind(this)));
|
||||||
|
|
||||||
setTimeout(() => this.scrollListener.start());
|
setTimeout(() => this.scrollListener.start());
|
||||||
|
|
||||||
|
this.updateScrubberValues({ animate: true, forceHeightChange: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
onremove() {
|
||||||
|
this.scrollListener.stop();
|
||||||
|
$(window).off('resize', this.handlers.onresize);
|
||||||
|
|
||||||
|
$(document).off('mousemove touchmove', this.handlers.onmousemove).off('mouseup touchend', this.handlers.onmouseup);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -196,13 +208,6 @@ export default class PostStreamScrubber extends Component {
|
|||||||
this.updateScrubberValues({ animate: true, forceHeightChange: true });
|
this.updateScrubberValues({ animate: true, forceHeightChange: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
ondestroy() {
|
|
||||||
this.scrollListener.stop();
|
|
||||||
$(window).off('resize', this.handlers.onresize);
|
|
||||||
|
|
||||||
$(document).off('mousemove touchmove', this.handlers.onmousemove).off('mouseup touchend', this.handlers.onmouseup);
|
|
||||||
}
|
|
||||||
|
|
||||||
onresize() {
|
onresize() {
|
||||||
// Adjust the height of the scrollbar so that it fills the height of
|
// Adjust the height of the scrollbar so that it fills the height of
|
||||||
// the sidebar and doesn't overlap the footer.
|
// the sidebar and doesn't overlap the footer.
|
||||||
|
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user