mirror of
https://github.com/flarum/core.git
synced 2025-07-11 20:06: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==",
|
||||
"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": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/parse-json/-/parse-json-4.0.0.tgz",
|
||||
@ -3807,9 +3812,9 @@
|
||||
}
|
||||
},
|
||||
"mithril": {
|
||||
"version": "0.2.8",
|
||||
"resolved": "https://registry.npmjs.org/mithril/-/mithril-0.2.8.tgz",
|
||||
"integrity": "sha512-9XuGnVmS2OyFexUuP/CcJFFJjHLM+RGYBxyVRNyQ6khbMfDJIF/xyZ4zq18ZRfPagpFmWUFpjHd5ZqPULGZyNg=="
|
||||
"version": "2.0.4",
|
||||
"resolved": "https://registry.npmjs.org/mithril/-/mithril-2.0.4.tgz",
|
||||
"integrity": "sha512-mgw+DMZlhMS4PpprF6dl7ZoeZq5GGcAuWnrg5e12MvaGauc4jzWsDZtVGRCktsiQczOEUr2K5teKbE5k44RlOg=="
|
||||
},
|
||||
"mixin-deep": {
|
||||
"version": "1.3.2",
|
||||
|
@ -3,6 +3,7 @@
|
||||
"name": "@flarum/core",
|
||||
"dependencies": {
|
||||
"@babel/preset-typescript": "^7.10.1",
|
||||
"@types/mithril": "^2.0.3",
|
||||
"bootstrap": "^3.4.1",
|
||||
"classnames": "^2.2.5",
|
||||
"color-thief-browser": "^2.0.2",
|
||||
@ -13,7 +14,7 @@
|
||||
"jquery.hotkeys": "^0.1.0",
|
||||
"lodash-es": "^4.17.14",
|
||||
"m.attrs.bidi": "github:tobscure/m.attrs.bidi",
|
||||
"mithril": "^0.2.8",
|
||||
"mithril": "^2.0.4",
|
||||
"punycode": "^2.1.1",
|
||||
"spin.js": "^3.1.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
|
||||
*/
|
||||
mount() {
|
||||
m.mount(document.getElementById('app-navigation'), Navigation.component({ className: 'App-backControl', drawer: true }));
|
||||
m.mount(document.getElementById('header-navigation'), Navigation.component());
|
||||
m.mount(document.getElementById('header-primary'), HeaderPrimary.component());
|
||||
m.mount(document.getElementById('header-secondary'), HeaderSecondary.component());
|
||||
m.mount(document.getElementById('admin-navigation'), AdminNav.component());
|
||||
m.mount(document.getElementById('app-navigation'), { view: () => Navigation.component({ className: 'App-backControl', drawer: true }) });
|
||||
m.mount(document.getElementById('header-navigation'), Navigation);
|
||||
m.mount(document.getElementById('header-primary'), HeaderPrimary);
|
||||
m.mount(document.getElementById('header-secondary'), HeaderSecondary);
|
||||
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();
|
||||
|
||||
// If an extension has just been enabled, then we will run its settings
|
||||
|
@ -10,11 +10,7 @@
|
||||
import LinkButton from '../../common/components/LinkButton';
|
||||
|
||||
export default class AdminLinkButton extends LinkButton {
|
||||
getButtonContent() {
|
||||
const content = super.getButtonContent();
|
||||
|
||||
content.push(<div className="AdminLinkButton-description">{this.props.description}</div>);
|
||||
|
||||
return content;
|
||||
getButtonContent(children) {
|
||||
return [...super.getButtonContent(children), <div className="AdminLinkButton-description">{this.attrs.description}</div>];
|
||||
}
|
||||
}
|
||||
|
@ -31,62 +31,74 @@ export default class AdminNav extends Component {
|
||||
|
||||
items.add(
|
||||
'dashboard',
|
||||
AdminLinkButton.component({
|
||||
AdminLinkButton.component(
|
||||
{
|
||||
href: app.route('dashboard'),
|
||||
icon: 'far fa-chart-bar',
|
||||
children: app.translator.trans('core.admin.nav.dashboard_button'),
|
||||
description: app.translator.trans('core.admin.nav.dashboard_text'),
|
||||
})
|
||||
},
|
||||
app.translator.trans('core.admin.nav.dashboard_button')
|
||||
)
|
||||
);
|
||||
|
||||
items.add(
|
||||
'basics',
|
||||
AdminLinkButton.component({
|
||||
AdminLinkButton.component(
|
||||
{
|
||||
href: app.route('basics'),
|
||||
icon: 'fas fa-pencil-alt',
|
||||
children: app.translator.trans('core.admin.nav.basics_button'),
|
||||
description: app.translator.trans('core.admin.nav.basics_text'),
|
||||
})
|
||||
},
|
||||
app.translator.trans('core.admin.nav.basics_button')
|
||||
)
|
||||
);
|
||||
|
||||
items.add(
|
||||
'mail',
|
||||
AdminLinkButton.component({
|
||||
AdminLinkButton.component(
|
||||
{
|
||||
href: app.route('mail'),
|
||||
icon: 'fas fa-envelope',
|
||||
children: app.translator.trans('core.admin.nav.email_button'),
|
||||
description: app.translator.trans('core.admin.nav.email_text'),
|
||||
})
|
||||
},
|
||||
app.translator.trans('core.admin.nav.email_button')
|
||||
)
|
||||
);
|
||||
|
||||
items.add(
|
||||
'permissions',
|
||||
AdminLinkButton.component({
|
||||
AdminLinkButton.component(
|
||||
{
|
||||
href: app.route('permissions'),
|
||||
icon: 'fas fa-key',
|
||||
children: app.translator.trans('core.admin.nav.permissions_button'),
|
||||
description: app.translator.trans('core.admin.nav.permissions_text'),
|
||||
})
|
||||
},
|
||||
app.translator.trans('core.admin.nav.permissions_button')
|
||||
)
|
||||
);
|
||||
|
||||
items.add(
|
||||
'appearance',
|
||||
AdminLinkButton.component({
|
||||
AdminLinkButton.component(
|
||||
{
|
||||
href: app.route('appearance'),
|
||||
icon: 'fas fa-paint-brush',
|
||||
children: app.translator.trans('core.admin.nav.appearance_button'),
|
||||
description: app.translator.trans('core.admin.nav.appearance_text'),
|
||||
})
|
||||
},
|
||||
app.translator.trans('core.admin.nav.appearance_button')
|
||||
)
|
||||
);
|
||||
|
||||
items.add(
|
||||
'extensions',
|
||||
AdminLinkButton.component({
|
||||
AdminLinkButton.component(
|
||||
{
|
||||
href: app.route('extensions'),
|
||||
icon: 'fas fa-puzzle-piece',
|
||||
children: app.translator.trans('core.admin.nav.extensions_button'),
|
||||
description: app.translator.trans('core.admin.nav.extensions_text'),
|
||||
})
|
||||
},
|
||||
app.translator.trans('core.admin.nav.extensions_button')
|
||||
)
|
||||
);
|
||||
|
||||
return items;
|
||||
|
@ -6,15 +6,16 @@ import EditCustomHeaderModal from './EditCustomHeaderModal';
|
||||
import EditCustomFooterModal from './EditCustomFooterModal';
|
||||
import UploadImageButton from './UploadImageButton';
|
||||
import saveSettings from '../utils/saveSettings';
|
||||
import withAttr from '../../common/utils/withAttr';
|
||||
|
||||
export default class AppearancePage extends Page {
|
||||
init() {
|
||||
super.init();
|
||||
oninit(vnode) {
|
||||
super.oninit(vnode);
|
||||
|
||||
this.primaryColor = m.prop(app.data.settings.theme_primary_color);
|
||||
this.secondaryColor = m.prop(app.data.settings.theme_secondary_color);
|
||||
this.darkMode = m.prop(app.data.settings.theme_dark_mode);
|
||||
this.coloredHeader = m.prop(app.data.settings.theme_colored_header);
|
||||
this.primaryColor = m.stream(app.data.settings.theme_primary_color);
|
||||
this.secondaryColor = m.stream(app.data.settings.theme_secondary_color);
|
||||
this.darkMode = m.stream(app.data.settings.theme_dark_mode);
|
||||
this.coloredHeader = m.stream(app.data.settings.theme_colored_header);
|
||||
}
|
||||
|
||||
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="AppearancePage-colors-input">
|
||||
<input
|
||||
className="FormControl"
|
||||
type="text"
|
||||
placeholder="#aaaaaa"
|
||||
value={this.primaryColor()}
|
||||
onchange={m.withAttr('value', this.primaryColor)}
|
||||
/>
|
||||
<input
|
||||
className="FormControl"
|
||||
type="text"
|
||||
placeholder="#aaaaaa"
|
||||
value={this.secondaryColor()}
|
||||
onchange={m.withAttr('value', this.secondaryColor)}
|
||||
/>
|
||||
<input className="FormControl" type="text" placeholder="#aaaaaa" bidi={this.primaryColor} />
|
||||
<input className="FormControl" type="text" placeholder="#aaaaaa" bidi={this.secondaryColor} />
|
||||
</div>
|
||||
|
||||
{Switch.component({
|
||||
{Switch.component(
|
||||
{
|
||||
state: this.darkMode(),
|
||||
children: app.translator.trans('core.admin.appearance.dark_mode_label'),
|
||||
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'),
|
||||
onchange: this.coloredHeader,
|
||||
})}
|
||||
},
|
||||
app.translator.trans('core.admin.appearance.colored_header_label')
|
||||
)}
|
||||
|
||||
{Button.component({
|
||||
{Button.component(
|
||||
{
|
||||
className: 'Button Button--primary',
|
||||
type: 'submit',
|
||||
children: app.translator.trans('core.admin.appearance.submit_button'),
|
||||
loading: this.loading,
|
||||
})}
|
||||
},
|
||||
app.translator.trans('core.admin.appearance.submit_button')
|
||||
)}
|
||||
</fieldset>
|
||||
</form>
|
||||
|
||||
@ -79,31 +74,37 @@ export default class AppearancePage extends Page {
|
||||
<fieldset>
|
||||
<legend>{app.translator.trans('core.admin.appearance.custom_header_heading')}</legend>
|
||||
<div className="helpText">{app.translator.trans('core.admin.appearance.custom_header_text')}</div>
|
||||
{Button.component({
|
||||
{Button.component(
|
||||
{
|
||||
className: 'Button',
|
||||
children: app.translator.trans('core.admin.appearance.edit_header_button'),
|
||||
onclick: () => app.modal.show(EditCustomHeaderModal),
|
||||
})}
|
||||
},
|
||||
app.translator.trans('core.admin.appearance.edit_header_button')
|
||||
)}
|
||||
</fieldset>
|
||||
|
||||
<fieldset>
|
||||
<legend>{app.translator.trans('core.admin.appearance.custom_footer_heading')}</legend>
|
||||
<div className="helpText">{app.translator.trans('core.admin.appearance.custom_footer_text')}</div>
|
||||
{Button.component({
|
||||
{Button.component(
|
||||
{
|
||||
className: 'Button',
|
||||
children: app.translator.trans('core.admin.appearance.edit_footer_button'),
|
||||
onclick: () => app.modal.show(EditCustomFooterModal),
|
||||
})}
|
||||
},
|
||||
app.translator.trans('core.admin.appearance.edit_footer_button')
|
||||
)}
|
||||
</fieldset>
|
||||
|
||||
<fieldset>
|
||||
<legend>{app.translator.trans('core.admin.appearance.custom_styles_heading')}</legend>
|
||||
<div className="helpText">{app.translator.trans('core.admin.appearance.custom_styles_text')}</div>
|
||||
{Button.component({
|
||||
{Button.component(
|
||||
{
|
||||
className: 'Button',
|
||||
children: app.translator.trans('core.admin.appearance.edit_css_button'),
|
||||
onclick: () => app.modal.show(EditCustomCssModal),
|
||||
})}
|
||||
},
|
||||
app.translator.trans('core.admin.appearance.edit_css_button')
|
||||
)}
|
||||
</fieldset>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -5,10 +5,11 @@ import Button from '../../common/components/Button';
|
||||
import saveSettings from '../utils/saveSettings';
|
||||
import ItemList from '../../common/utils/ItemList';
|
||||
import Switch from '../../common/components/Switch';
|
||||
import withAttr from '../../common/utils/withAttr';
|
||||
|
||||
export default class BasicsPage extends Page {
|
||||
init() {
|
||||
super.init();
|
||||
oninit(vnode) {
|
||||
super.oninit(vnode);
|
||||
|
||||
this.loading = false;
|
||||
|
||||
@ -25,7 +26,7 @@ export default class BasicsPage extends Page {
|
||||
this.values = {};
|
||||
|
||||
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 = {};
|
||||
const locales = app.data.locales;
|
||||
@ -49,45 +50,51 @@ export default class BasicsPage extends Page {
|
||||
<div className="BasicsPage">
|
||||
<div className="container">
|
||||
<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)} />],
|
||||
})}
|
||||
},
|
||||
[<input className="FormControl" bidi={this.values.forum_title} />]
|
||||
)}
|
||||
|
||||
{FieldSet.component({
|
||||
{FieldSet.component(
|
||||
{
|
||||
label: app.translator.trans('core.admin.basics.forum_description_heading'),
|
||||
children: [
|
||||
},
|
||||
[
|
||||
<div className="helpText">{app.translator.trans('core.admin.basics.forum_description_text')}</div>,
|
||||
<textarea
|
||||
className="FormControl"
|
||||
value={this.values.forum_description()}
|
||||
oninput={m.withAttr('value', this.values.forum_description)}
|
||||
/>,
|
||||
],
|
||||
})}
|
||||
<textarea className="FormControl" bidi={this.values.forum_description} />,
|
||||
]
|
||||
)}
|
||||
|
||||
{Object.keys(this.localeOptions).length > 1
|
||||
? FieldSet.component({
|
||||
? FieldSet.component(
|
||||
{
|
||||
label: app.translator.trans('core.admin.basics.default_language_heading'),
|
||||
children: [
|
||||
},
|
||||
[
|
||||
Select.component({
|
||||
options: this.localeOptions,
|
||||
value: this.values.default_locale(),
|
||||
onchange: this.values.default_locale,
|
||||
}),
|
||||
Switch.component({
|
||||
Switch.component(
|
||||
{
|
||||
state: this.values.show_language_selector(),
|
||||
onchange: this.values.show_language_selector,
|
||||
children: app.translator.trans('core.admin.basics.show_language_selector_label'),
|
||||
}),
|
||||
],
|
||||
})
|
||||
},
|
||||
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',
|
||||
children: [
|
||||
},
|
||||
[
|
||||
<div className="helpText">{app.translator.trans('core.admin.basics.home_page_text')}</div>,
|
||||
this.homePageItems()
|
||||
.toArray()
|
||||
@ -98,51 +105,52 @@ export default class BasicsPage extends Page {
|
||||
name="homePage"
|
||||
value={path}
|
||||
checked={this.values.default_route() === path}
|
||||
onclick={m.withAttr('value', this.values.default_route)}
|
||||
onclick={withAttr('value', this.values.default_route)}
|
||||
/>
|
||||
{label}
|
||||
</label>
|
||||
)),
|
||||
],
|
||||
})}
|
||||
]
|
||||
)}
|
||||
|
||||
{FieldSet.component({
|
||||
{FieldSet.component(
|
||||
{
|
||||
label: app.translator.trans('core.admin.basics.welcome_banner_heading'),
|
||||
className: 'BasicsPage-welcomeBanner',
|
||||
children: [
|
||||
},
|
||||
[
|
||||
<div className="helpText">{app.translator.trans('core.admin.basics.welcome_banner_text')}</div>,
|
||||
<div className="BasicsPage-welcomeBanner-input">
|
||||
<input className="FormControl" value={this.values.welcome_title()} oninput={m.withAttr('value', this.values.welcome_title)} />
|
||||
<textarea
|
||||
className="FormControl"
|
||||
value={this.values.welcome_message()}
|
||||
oninput={m.withAttr('value', this.values.welcome_message)}
|
||||
/>
|
||||
<input className="FormControl" bidi={this.values.welcome_title} />
|
||||
<textarea className="FormControl" bidi={this.values.welcome_message} />
|
||||
</div>,
|
||||
],
|
||||
})}
|
||||
]
|
||||
)}
|
||||
|
||||
{Object.keys(this.displayNameOptions).length > 1
|
||||
? FieldSet.component({
|
||||
? FieldSet.component(
|
||||
{
|
||||
label: app.translator.trans('core.admin.basics.display_name_heading'),
|
||||
children: [
|
||||
},
|
||||
[
|
||||
<div className="helpText">{app.translator.trans('core.admin.basics.display_name_text')}</div>,
|
||||
Select.component({
|
||||
options: this.displayNameOptions,
|
||||
value: this.values.display_name_driver(),
|
||||
onchange: this.values.display_name_driver,
|
||||
bidi: this.values.display_name_driver,
|
||||
}),
|
||||
],
|
||||
})
|
||||
]
|
||||
)
|
||||
: ''}
|
||||
|
||||
{Button.component({
|
||||
{Button.component(
|
||||
{
|
||||
type: 'submit',
|
||||
className: 'Button Button--primary',
|
||||
children: app.translator.trans('core.admin.basics.submit_button'),
|
||||
loading: this.loading,
|
||||
disabled: !this.changed(),
|
||||
})}
|
||||
},
|
||||
app.translator.trans('core.admin.basics.submit_button')
|
||||
)}
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
@ -185,10 +193,7 @@ export default class BasicsPage extends Page {
|
||||
|
||||
saveSettings(settings)
|
||||
.then(() => {
|
||||
this.successAlert = app.alerts.show({
|
||||
type: 'success',
|
||||
children: app.translator.trans('core.admin.basics.saved_message'),
|
||||
});
|
||||
this.successAlert = app.alerts.show({ type: 'success' }, app.translator.trans('core.admin.basics.saved_message'));
|
||||
})
|
||||
.catch(() => {})
|
||||
.then(() => {
|
||||
|
@ -4,20 +4,23 @@ import Badge from '../../common/components/Badge';
|
||||
import Group from '../../common/models/Group';
|
||||
import ItemList from '../../common/utils/ItemList';
|
||||
import Switch from '../../common/components/Switch';
|
||||
import withAttr from '../../common/utils/withAttr';
|
||||
|
||||
/**
|
||||
* The `EditGroupModal` component shows a modal dialog which allows the user
|
||||
* to create or edit a group.
|
||||
*/
|
||||
export default class EditGroupModal extends Modal {
|
||||
init() {
|
||||
this.group = this.props.group || app.store.createRecord('groups');
|
||||
oninit(vnode) {
|
||||
super.oninit(vnode);
|
||||
|
||||
this.nameSingular = m.prop(this.group.nameSingular() || '');
|
||||
this.namePlural = m.prop(this.group.namePlural() || '');
|
||||
this.icon = m.prop(this.group.icon() || '');
|
||||
this.color = m.prop(this.group.color() || '');
|
||||
this.isHidden = m.prop(this.group.isHidden() || false);
|
||||
this.group = this.attrs.group || app.store.createRecord('groups');
|
||||
|
||||
this.nameSingular = m.stream(this.group.nameSingular() || '');
|
||||
this.namePlural = m.stream(this.group.namePlural() || '');
|
||||
this.icon = m.stream(this.group.icon() || '');
|
||||
this.color = m.stream(this.group.color() || '');
|
||||
this.isHidden = m.stream(this.group.isHidden() || false);
|
||||
}
|
||||
|
||||
className() {
|
||||
@ -53,18 +56,8 @@ export default class EditGroupModal extends Modal {
|
||||
<div className="Form-group">
|
||||
<label>{app.translator.trans('core.admin.edit_group.name_label')}</label>
|
||||
<div className="EditGroupModal-name-input">
|
||||
<input
|
||||
className="FormControl"
|
||||
placeholder={app.translator.trans('core.admin.edit_group.singular_placeholder')}
|
||||
value={this.nameSingular()}
|
||||
oninput={m.withAttr('value', this.nameSingular)}
|
||||
/>
|
||||
<input
|
||||
className="FormControl"
|
||||
placeholder={app.translator.trans('core.admin.edit_group.plural_placeholder')}
|
||||
value={this.namePlural()}
|
||||
oninput={m.withAttr('value', this.namePlural)}
|
||||
/>
|
||||
<input className="FormControl" placeholder={app.translator.trans('core.admin.edit_group.singular_placeholder')} bidi={this.nameSingular} />
|
||||
<input className="FormControl" placeholder={app.translator.trans('core.admin.edit_group.plural_placeholder')} bidi={this.namePlural} />
|
||||
</div>
|
||||
</div>,
|
||||
30
|
||||
@ -74,7 +67,7 @@ export default class EditGroupModal extends Modal {
|
||||
'color',
|
||||
<div className="Form-group">
|
||||
<label>{app.translator.trans('core.admin.edit_group.color_label')}</label>
|
||||
<input className="FormControl" placeholder="#aaaaaa" value={this.color()} oninput={m.withAttr('value', this.color)} />
|
||||
<input className="FormControl" placeholder="#aaaaaa" bidi={this.color} />
|
||||
</div>,
|
||||
20
|
||||
);
|
||||
@ -86,7 +79,7 @@ export default class EditGroupModal extends Modal {
|
||||
<div className="helpText">
|
||||
{app.translator.trans('core.admin.edit_group.icon_text', { a: <a href="https://fontawesome.com/icons?m=free" tabindex="-1" /> })}
|
||||
</div>
|
||||
<input className="FormControl" placeholder="fas fa-bolt" value={this.icon()} oninput={m.withAttr('value', this.icon)} />
|
||||
<input className="FormControl" placeholder="fas fa-bolt" bidi={this.icon} />
|
||||
</div>,
|
||||
10
|
||||
);
|
||||
@ -94,11 +87,13 @@ export default class EditGroupModal extends Modal {
|
||||
items.add(
|
||||
'hidden',
|
||||
<div className="Form-group">
|
||||
{Switch.component({
|
||||
{Switch.component(
|
||||
{
|
||||
state: !!Number(this.isHidden()),
|
||||
children: app.translator.trans('core.admin.edit_group.hide_label'),
|
||||
onchange: this.isHidden,
|
||||
})}
|
||||
},
|
||||
app.translator.trans('core.admin.edit_group.hide_label')
|
||||
)}
|
||||
</div>,
|
||||
10
|
||||
);
|
||||
@ -106,12 +101,14 @@ export default class EditGroupModal extends Modal {
|
||||
items.add(
|
||||
'submit',
|
||||
<div className="Form-group">
|
||||
{Button.component({
|
||||
{Button.component(
|
||||
{
|
||||
type: 'submit',
|
||||
className: 'Button Button--primary EditGroupModal-save',
|
||||
loading: this.loading,
|
||||
children: app.translator.trans('core.admin.edit_group.submit_button'),
|
||||
})}
|
||||
},
|
||||
app.translator.trans('core.admin.edit_group.submit_button')
|
||||
)}
|
||||
{this.group.exists && this.group.id() !== Group.ADMINISTRATOR_ID ? (
|
||||
<button type="button" className="Button EditGroupModal-delete" onclick={this.deleteGroup.bind(this)}>
|
||||
{app.translator.trans('core.admin.edit_group.delete_button')}
|
||||
|
@ -12,12 +12,14 @@ export default class ExtensionsPage extends Page {
|
||||
<div className="ExtensionsPage">
|
||||
<div className="ExtensionsPage-header">
|
||||
<div className="container">
|
||||
{Button.component({
|
||||
children: app.translator.trans('core.admin.extensions.add_button'),
|
||||
{Button.component(
|
||||
{
|
||||
icon: 'fas fa-plus',
|
||||
className: 'Button Button--primary',
|
||||
onclick: () => app.modal.show(AddExtensionModal),
|
||||
})}
|
||||
},
|
||||
app.translator.trans('core.admin.extensions.add_button')
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -72,20 +74,22 @@ export default class ExtensionsPage extends Page {
|
||||
if (app.extensionSettings[name]) {
|
||||
items.add(
|
||||
'settings',
|
||||
Button.component({
|
||||
Button.component(
|
||||
{
|
||||
icon: 'fas fa-cog',
|
||||
children: app.translator.trans('core.admin.extensions.settings_button'),
|
||||
onclick: app.extensionSettings[name],
|
||||
})
|
||||
},
|
||||
app.translator.trans('core.admin.extensions.settings_button')
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
if (!enabled) {
|
||||
items.add(
|
||||
'uninstall',
|
||||
Button.component({
|
||||
Button.component(
|
||||
{
|
||||
icon: 'far fa-trash-alt',
|
||||
children: app.translator.trans('core.admin.extensions.uninstall_button'),
|
||||
onclick: () => {
|
||||
app
|
||||
.request({
|
||||
@ -96,7 +100,9 @@ export default class ExtensionsPage extends Page {
|
||||
|
||||
app.modal.show(LoadingModal);
|
||||
},
|
||||
})
|
||||
},
|
||||
app.translator.trans('core.admin.extensions.uninstall_button')
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@ -116,7 +122,7 @@ export default class ExtensionsPage extends Page {
|
||||
.request({
|
||||
url: app.forum.attribute('apiUrl') + '/extensions/' + id,
|
||||
method: 'PATCH',
|
||||
data: { enabled: !enabled },
|
||||
body: { enabled: !enabled },
|
||||
})
|
||||
.then(() => {
|
||||
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>;
|
||||
}
|
||||
|
||||
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.
|
||||
*
|
||||
|
@ -5,10 +5,11 @@ import Alert from '../../common/components/Alert';
|
||||
import Select from '../../common/components/Select';
|
||||
import LoadingIndicator from '../../common/components/LoadingIndicator';
|
||||
import saveSettings from '../utils/saveSettings';
|
||||
import withAttr from '../../common/utils/withAttr';
|
||||
|
||||
export default class MailPage extends Page {
|
||||
init() {
|
||||
super.init();
|
||||
oninit(vnode) {
|
||||
super.oninit(vnode);
|
||||
|
||||
this.saving = false;
|
||||
this.sendingTest = false;
|
||||
@ -24,7 +25,7 @@ export default class MailPage extends Page {
|
||||
this.status = { sending: false, errors: {} };
|
||||
|
||||
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
|
||||
.request({
|
||||
@ -39,7 +40,7 @@ export default class MailPage extends Page {
|
||||
for (const driver in this.driverFields) {
|
||||
for (const field in this.driverFields[driver]) {
|
||||
this.fields.push(field);
|
||||
this.values[field] = m.prop(settings[field]);
|
||||
this.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>
|
||||
<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',
|
||||
children: [
|
||||
},
|
||||
[
|
||||
<div className="MailPage-MailSettings-input">
|
||||
<label>
|
||||
{app.translator.trans('core.admin.email.from_label')}
|
||||
<input className="FormControl" value={this.values.mail_from() || ''} oninput={m.withAttr('value', this.values.mail_from)} />
|
||||
<input className="FormControl" bidi={this.values.mail_from} />
|
||||
</label>
|
||||
</div>,
|
||||
],
|
||||
})}
|
||||
]
|
||||
)}
|
||||
|
||||
{FieldSet.component({
|
||||
{FieldSet.component(
|
||||
{
|
||||
label: app.translator.trans('core.admin.email.driver_heading'),
|
||||
className: 'MailPage-MailSettings',
|
||||
children: [
|
||||
},
|
||||
[
|
||||
<div className="MailPage-MailSettings-input">
|
||||
<label>
|
||||
{app.translator.trans('core.admin.email.driver_label')}
|
||||
@ -96,20 +101,24 @@ export default class MailPage extends Page {
|
||||
/>
|
||||
</label>
|
||||
</div>,
|
||||
],
|
||||
})}
|
||||
]
|
||||
)}
|
||||
|
||||
{this.status.sending ||
|
||||
Alert.component({
|
||||
children: app.translator.trans('core.admin.email.not_sending_message'),
|
||||
Alert.component(
|
||||
{
|
||||
dismissible: false,
|
||||
})}
|
||||
},
|
||||
app.translator.trans('core.admin.email.not_sending_message')
|
||||
)}
|
||||
|
||||
{fieldKeys.length > 0 &&
|
||||
FieldSet.component({
|
||||
FieldSet.component(
|
||||
{
|
||||
label: app.translator.trans(`core.admin.email.${this.values.mail_driver()}_heading`),
|
||||
className: 'MailPage-MailSettings',
|
||||
children: [
|
||||
},
|
||||
[
|
||||
<div className="MailPage-MailSettings-input">
|
||||
{fieldKeys.map((field) => [
|
||||
<label>
|
||||
@ -119,31 +128,37 @@ export default class MailPage extends Page {
|
||||
this.status.errors[field] && <p className="ValidationError">{this.status.errors[field]}</p>,
|
||||
])}
|
||||
</div>,
|
||||
],
|
||||
})}
|
||||
]
|
||||
)}
|
||||
|
||||
<FieldSet>
|
||||
{Button.component({
|
||||
{Button.component(
|
||||
{
|
||||
type: 'submit',
|
||||
className: 'Button Button--primary',
|
||||
children: app.translator.trans('core.admin.email.submit_button'),
|
||||
disabled: !this.changed(),
|
||||
})}
|
||||
},
|
||||
app.translator.trans('core.admin.email.submit_button')
|
||||
)}
|
||||
</FieldSet>
|
||||
|
||||
{FieldSet.component({
|
||||
{FieldSet.component(
|
||||
{
|
||||
label: app.translator.trans('core.admin.email.send_test_mail_heading'),
|
||||
className: 'MailPage-MailSettings',
|
||||
children: [
|
||||
},
|
||||
[
|
||||
<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'),
|
||||
disabled: this.sendingTest || this.changed(),
|
||||
onclick: () => this.sendTestEmail(),
|
||||
}),
|
||||
],
|
||||
})}
|
||||
},
|
||||
app.translator.trans('core.admin.email.send_test_mail_button')
|
||||
),
|
||||
]
|
||||
)}
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
@ -156,7 +171,7 @@ export default class MailPage extends Page {
|
||||
const prop = this.values[name];
|
||||
|
||||
if (typeof field === 'string') {
|
||||
return <input className="FormControl" value={prop() || ''} oninput={m.withAttr('value', prop)} />;
|
||||
return <input className="FormControl" bidi={prop} />;
|
||||
} else {
|
||||
return <Select value={prop()} options={field} onchange={prop} />;
|
||||
}
|
||||
@ -179,10 +194,7 @@ export default class MailPage extends Page {
|
||||
})
|
||||
.then((response) => {
|
||||
this.sendingTest = false;
|
||||
this.testEmailSuccessAlert = app.alerts.show({
|
||||
type: 'success',
|
||||
children: app.translator.trans('core.admin.email.send_test_mail_success'),
|
||||
});
|
||||
this.testEmailSuccessAlert = app.alerts.show({ type: 'success' }, app.translator.trans('core.admin.email.send_test_mail_success'));
|
||||
})
|
||||
.catch((error) => {
|
||||
this.sendingTest = false;
|
||||
@ -205,10 +217,7 @@ export default class MailPage extends Page {
|
||||
|
||||
saveSettings(settings)
|
||||
.then(() => {
|
||||
this.successAlert = app.alerts.show({
|
||||
type: 'success',
|
||||
children: app.translator.trans('core.admin.basics.saved_message'),
|
||||
});
|
||||
this.successAlert = app.alerts.show({ type: 'success' }, app.translator.trans('core.admin.basics.saved_message'));
|
||||
})
|
||||
.catch(() => {})
|
||||
.then(() => {
|
||||
|
@ -32,101 +32,109 @@ function filterByRequiredPermissions(groupIds, permission) {
|
||||
}
|
||||
|
||||
export default class PermissionDropdown extends Dropdown {
|
||||
static initProps(props) {
|
||||
super.initProps(props);
|
||||
static initAttrs(attrs) {
|
||||
super.initAttrs(attrs);
|
||||
|
||||
props.className = 'PermissionDropdown';
|
||||
props.buttonClassName = 'Button Button--text';
|
||||
attrs.className = 'PermissionDropdown';
|
||||
attrs.buttonClassName = 'Button Button--text';
|
||||
}
|
||||
|
||||
view() {
|
||||
this.props.children = [];
|
||||
view(vnode) {
|
||||
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 members = groupIds.indexOf(Group.MEMBER_ID) !== -1;
|
||||
const adminGroup = app.store.getById('groups', Group.ADMINISTRATOR_ID);
|
||||
|
||||
if (everyone) {
|
||||
this.props.label = Badge.component({ icon: 'fas fa-globe' });
|
||||
this.attrs.label = Badge.component({ icon: 'fas fa-globe' });
|
||||
} else if (members) {
|
||||
this.props.label = Badge.component({ icon: 'fas fa-user' });
|
||||
this.attrs.label = Badge.component({ icon: 'fas fa-user' });
|
||||
} 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.props.allowGuest) {
|
||||
this.props.children.push(
|
||||
Button.component({
|
||||
children: [Badge.component({ icon: 'fas fa-globe' }), ' ', app.translator.trans('core.admin.permissions_controls.everyone_button')],
|
||||
if (this.attrs.allowGuest) {
|
||||
children.push(
|
||||
Button.component(
|
||||
{
|
||||
icon: everyone ? 'fas fa-check' : true,
|
||||
onclick: () => this.save([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(
|
||||
Button.component({
|
||||
children: [Badge.component({ icon: 'fas fa-user' }), ' ', app.translator.trans('core.admin.permissions_controls.members_button')],
|
||||
children.push(
|
||||
Button.component(
|
||||
{
|
||||
icon: members ? 'fas fa-check' : true,
|
||||
onclick: () => this.save([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(),
|
||||
|
||||
Button.component({
|
||||
children: [badgeForId(adminGroup.id()), ' ', adminGroup.namePlural()],
|
||||
Button.component(
|
||||
{
|
||||
icon: !everyone && !members ? 'fas fa-check' : true,
|
||||
disabled: !everyone && !members,
|
||||
onclick: (e) => {
|
||||
if (e.shiftKey) e.stopPropagation();
|
||||
this.save([]);
|
||||
},
|
||||
})
|
||||
},
|
||||
[badgeForId(adminGroup.id()), ' ', adminGroup.namePlural()]
|
||||
)
|
||||
);
|
||||
|
||||
[].push.apply(
|
||||
this.props.children,
|
||||
children,
|
||||
app.store
|
||||
.all('groups')
|
||||
.filter((group) => [Group.ADMINISTRATOR_ID, Group.GUEST_ID, Group.MEMBER_ID].indexOf(group.id()) === -1)
|
||||
.map((group) =>
|
||||
Button.component({
|
||||
children: [badgeForId(group.id()), ' ', group.namePlural()],
|
||||
Button.component(
|
||||
{
|
||||
icon: groupIds.indexOf(group.id()) !== -1 ? 'fas fa-check' : true,
|
||||
onclick: (e) => {
|
||||
if (e.shiftKey) e.stopPropagation();
|
||||
this.toggle(group.id());
|
||||
},
|
||||
disabled: this.isGroupDisabled(group.id()) && this.isGroupDisabled(Group.MEMBER_ID) && this.isGroupDisabled(Group.GUEST_ID),
|
||||
})
|
||||
},
|
||||
[badgeForId(group.id()), ' ', group.namePlural()]
|
||||
)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
return super.view();
|
||||
return super.view({ ...vnode, children });
|
||||
}
|
||||
|
||||
save(groupIds) {
|
||||
const permission = this.props.permission;
|
||||
const permission = this.attrs.permission;
|
||||
|
||||
app.data.permissions[permission] = groupIds;
|
||||
|
||||
app.request({
|
||||
method: 'POST',
|
||||
url: app.forum.attribute('apiUrl') + '/permission',
|
||||
data: { permission, groupIds },
|
||||
body: { permission, groupIds },
|
||||
});
|
||||
}
|
||||
|
||||
toggle(groupId) {
|
||||
const permission = this.props.permission;
|
||||
const permission = this.attrs.permission;
|
||||
|
||||
let groupIds = app.data.permissions[permission] || [];
|
||||
|
||||
@ -143,6 +151,6 @@ export default class PermissionDropdown extends Dropdown {
|
||||
}
|
||||
|
||||
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';
|
||||
|
||||
export default class PermissionGrid extends Component {
|
||||
init() {
|
||||
oninit(vnode) {
|
||||
super.oninit(vnode);
|
||||
|
||||
this.permissions = this.permissionItems().toArray();
|
||||
}
|
||||
|
||||
|
@ -9,18 +9,16 @@ import ItemList from '../../common/utils/ItemList';
|
||||
* avatar/name, with a dropdown of session controls.
|
||||
*/
|
||||
export default class SessionDropdown extends Dropdown {
|
||||
static initProps(props) {
|
||||
super.initProps(props);
|
||||
static initAttrs(attrs) {
|
||||
super.initAttrs(attrs);
|
||||
|
||||
props.className = 'SessionDropdown';
|
||||
props.buttonClassName = 'Button Button--user Button--flat';
|
||||
props.menuClassName = 'Dropdown-menu--right';
|
||||
attrs.className = 'SessionDropdown';
|
||||
attrs.buttonClassName = 'Button Button--user Button--flat';
|
||||
attrs.menuClassName = 'Dropdown-menu--right';
|
||||
}
|
||||
|
||||
view() {
|
||||
this.props.children = this.items().toArray();
|
||||
|
||||
return super.view();
|
||||
view(vnode) {
|
||||
return super.view({ ...vnode, children: this.items().toArray() });
|
||||
}
|
||||
|
||||
getButtonContent() {
|
||||
@ -39,11 +37,13 @@ export default class SessionDropdown extends Dropdown {
|
||||
|
||||
items.add(
|
||||
'logOut',
|
||||
Button.component({
|
||||
Button.component(
|
||||
{
|
||||
icon: 'fas fa-sign-out-alt',
|
||||
children: app.translator.trans('core.admin.header.log_out_button'),
|
||||
onclick: app.session.logout.bind(app.session),
|
||||
}),
|
||||
},
|
||||
app.translator.trans('core.admin.header.log_out_button')
|
||||
),
|
||||
-100
|
||||
);
|
||||
|
||||
|
@ -3,23 +3,30 @@ import Button from '../../common/components/Button';
|
||||
import saveSettings from '../utils/saveSettings';
|
||||
|
||||
export default class SettingDropdown extends SelectDropdown {
|
||||
static initProps(props) {
|
||||
super.initProps(props);
|
||||
static initAttrs(attrs) {
|
||||
super.initAttrs(attrs);
|
||||
|
||||
props.className = 'SettingDropdown';
|
||||
props.buttonClassName = 'Button Button--text';
|
||||
props.caretIcon = 'fas fa-caret-down';
|
||||
props.defaultLabel = 'Custom';
|
||||
attrs.className = 'SettingDropdown';
|
||||
attrs.buttonClassName = 'Button Button--text';
|
||||
attrs.caretIcon = 'fas fa-caret-down';
|
||||
attrs.defaultLabel = 'Custom';
|
||||
}
|
||||
|
||||
props.children = props.options.map(({ value, label }) => {
|
||||
const active = app.data.settings[props.key] === value;
|
||||
view(vnode) {
|
||||
return super.view({
|
||||
...vnode,
|
||||
children: this.attrs.options.map(({ value, label }) => {
|
||||
const active = app.data.settings[this.attrs.key] === value;
|
||||
|
||||
return Button.component({
|
||||
children: label,
|
||||
return Button.component(
|
||||
{
|
||||
icon: active ? 'fas fa-check' : true,
|
||||
onclick: saveSettings.bind(this, { [props.key]: value }),
|
||||
onclick: saveSettings.bind(this, { [this.attrs.key]: value }),
|
||||
active,
|
||||
});
|
||||
},
|
||||
label
|
||||
);
|
||||
}),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
@ -3,7 +3,9 @@ import Button from '../../common/components/Button';
|
||||
import saveSettings from '../utils/saveSettings';
|
||||
|
||||
export default class SettingsModal extends Modal {
|
||||
init() {
|
||||
oninit(vnode) {
|
||||
super.oninit(vnode);
|
||||
|
||||
this.settings = {};
|
||||
this.loading = false;
|
||||
}
|
||||
@ -33,7 +35,7 @@ export default class SettingsModal extends Modal {
|
||||
}
|
||||
|
||||
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];
|
||||
}
|
||||
|
@ -1,32 +1,28 @@
|
||||
import Button from '../../common/components/Button';
|
||||
|
||||
export default class UploadImageButton extends Button {
|
||||
init() {
|
||||
this.loading = false;
|
||||
}
|
||||
loading = false;
|
||||
|
||||
view() {
|
||||
this.props.loading = this.loading;
|
||||
this.props.className = (this.props.className || '') + ' Button';
|
||||
view(vnode) {
|
||||
this.attrs.loading = this.loading;
|
||||
this.attrs.className = (this.attrs.className || '') + ' Button';
|
||||
|
||||
if (app.data.settings[this.props.name + '_path']) {
|
||||
this.props.onclick = this.remove.bind(this);
|
||||
this.props.children = app.translator.trans('core.admin.upload_image.remove_button');
|
||||
if (app.data.settings[this.attrs.name + '_path']) {
|
||||
this.attrs.onclick = this.remove.bind(this);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<p>
|
||||
<img src={app.forum.attribute(this.props.name + 'Url')} alt="" />
|
||||
<img src={app.forum.attribute(this.attrs.name + 'Url')} alt="" />
|
||||
</p>
|
||||
<p>{super.view()}</p>
|
||||
<p>{super.view({ ...vnode, children: app.translator.trans('core.admin.upload_image.remove_button') })}</p>
|
||||
</div>
|
||||
);
|
||||
} else {
|
||||
this.props.onclick = this.upload.bind(this);
|
||||
this.props.children = app.translator.trans('core.admin.upload_image.upload_button');
|
||||
this.attrs.onclick = this.upload.bind(this);
|
||||
}
|
||||
|
||||
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()
|
||||
.click()
|
||||
.on('change', (e) => {
|
||||
const data = new FormData();
|
||||
data.append(this.props.name, $(e.target)[0].files[0]);
|
||||
const body = new FormData();
|
||||
body.append(this.attrs.name, $(e.target)[0].files[0]);
|
||||
|
||||
this.loading = true;
|
||||
m.redraw();
|
||||
@ -53,7 +49,7 @@ export default class UploadImageButton extends Button {
|
||||
method: 'POST',
|
||||
url: this.resourceUrl(),
|
||||
serialize: (raw) => raw,
|
||||
data,
|
||||
body,
|
||||
})
|
||||
.then(this.success.bind(this), this.failure.bind(this));
|
||||
});
|
||||
@ -75,7 +71,7 @@ export default class UploadImageButton extends Button {
|
||||
}
|
||||
|
||||
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) {
|
||||
app.routes = {
|
||||
dashboard: { path: '/', component: DashboardPage.component() },
|
||||
basics: { path: '/basics', component: BasicsPage.component() },
|
||||
permissions: { path: '/permissions', component: PermissionsPage.component() },
|
||||
appearance: { path: '/appearance', component: AppearancePage.component() },
|
||||
extensions: { path: '/extensions', component: ExtensionsPage.component() },
|
||||
mail: { path: '/mail', component: MailPage.component() },
|
||||
dashboard: { path: '/', component: DashboardPage },
|
||||
basics: { path: '/basics', component: BasicsPage },
|
||||
permissions: { path: '/permissions', component: PermissionsPage },
|
||||
appearance: { path: '/appearance', component: AppearancePage },
|
||||
extensions: { path: '/extensions', component: ExtensionsPage },
|
||||
mail: { path: '/mail', component: MailPage },
|
||||
};
|
||||
}
|
||||
|
@ -7,7 +7,7 @@ export default function saveSettings(settings) {
|
||||
.request({
|
||||
method: 'POST',
|
||||
url: app.forum.attribute('apiUrl') + '/settings',
|
||||
data: settings,
|
||||
body: settings,
|
||||
})
|
||||
.catch((error) => {
|
||||
app.data.settings = oldSettings;
|
||||
|
@ -189,8 +189,9 @@ export default class Application {
|
||||
}
|
||||
|
||||
mount(basePath = '') {
|
||||
m.mount(document.getElementById('modal'), <ModalManager state={this.modal} />);
|
||||
m.mount(document.getElementById('alerts'), <AlertManager state={this.alerts} />);
|
||||
// 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('modal'), { view: () => ModalManager.component({ state: this.modal }) });
|
||||
m.mount(document.getElementById('alerts'), { view: () => AlertManager.component({ state: this.alerts }) });
|
||||
|
||||
this.drawer = new Drawer();
|
||||
|
||||
@ -263,7 +264,7 @@ export default class Application {
|
||||
|
||||
updateTitle() {
|
||||
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');
|
||||
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
|
||||
// returned and show an alert containing its contents.
|
||||
const deferred = m.deferred();
|
||||
|
||||
m.request(options).then(
|
||||
(response) => deferred.resolve(response),
|
||||
return m.request(options).then(
|
||||
(response) => response,
|
||||
(error) => {
|
||||
let children;
|
||||
let content;
|
||||
|
||||
switch (error.status) {
|
||||
case 422:
|
||||
children = error.response.errors
|
||||
content = error.response.errors
|
||||
.map((error) => [error.detail, <br />])
|
||||
.reduce((a, b) => a.concat(b), [])
|
||||
.slice(0, -1);
|
||||
@ -359,30 +358,31 @@ export default class Application {
|
||||
|
||||
case 401:
|
||||
case 403:
|
||||
children = app.translator.trans('core.lib.error.permission_denied_message');
|
||||
content = app.translator.trans('core.lib.error.permission_denied_message');
|
||||
break;
|
||||
|
||||
case 404:
|
||||
case 410:
|
||||
children = app.translator.trans('core.lib.error.not_found_message');
|
||||
content = app.translator.trans('core.lib.error.not_found_message');
|
||||
break;
|
||||
|
||||
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;
|
||||
|
||||
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');
|
||||
// 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'
|
||||
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 = {
|
||||
type: 'error',
|
||||
children,
|
||||
content,
|
||||
controls: isDebug && [
|
||||
<Button className="Button Button--link" onclick={this.showDebug.bind(this, error, formattedError)}>
|
||||
Debug
|
||||
@ -404,14 +404,12 @@ export default class Application {
|
||||
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
|
||||
*/
|
||||
route(name, params = {}) {
|
||||
const url = this.routes[name].path.replace(/:([^\/]+)/g, (m, key) => extract(params, key));
|
||||
const queryString = m.route.buildQueryString(params);
|
||||
const prefix = m.route.mode === 'pathname' ? app.forum.attribute('basePath') : '';
|
||||
const route = this.routes[name];
|
||||
|
||||
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 : '');
|
||||
}
|
||||
|
@ -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',
|
||||
url: app.forum.attribute('apiUrl') + this.apiEndpoint(),
|
||||
data: request,
|
||||
body: request,
|
||||
},
|
||||
options
|
||||
)
|
||||
@ -180,7 +180,7 @@ export default class Model {
|
||||
// old data! We'll revert to that and let others handle the error.
|
||||
(response) => {
|
||||
this.pushData(oldData);
|
||||
m.lazyRedraw();
|
||||
m.redraw();
|
||||
throw response;
|
||||
}
|
||||
);
|
||||
@ -189,13 +189,13 @@ export default class Model {
|
||||
/**
|
||||
* 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]
|
||||
* @return {Promise}
|
||||
* @public
|
||||
*/
|
||||
delete(data, options = {}) {
|
||||
if (!this.exists) return m.deferred().resolve().promise;
|
||||
delete(body, options = {}) {
|
||||
if (!this.exists) return Promise.resolve();
|
||||
|
||||
return app
|
||||
.request(
|
||||
@ -203,7 +203,7 @@ export default class Model {
|
||||
{
|
||||
method: 'DELETE',
|
||||
url: app.forum.attribute('apiUrl') + this.apiEndpoint(),
|
||||
data,
|
||||
body,
|
||||
},
|
||||
options
|
||||
)
|
||||
|
@ -30,13 +30,13 @@ export default class Session {
|
||||
* @return {Promise}
|
||||
* @public
|
||||
*/
|
||||
login(data, options = {}) {
|
||||
login(body, options = {}) {
|
||||
return app.request(
|
||||
Object.assign(
|
||||
{
|
||||
method: 'POST',
|
||||
url: app.forum.attribute('baseUrl') + '/login',
|
||||
data,
|
||||
url: `${app.forum.attribute('baseUrl')}/login`,
|
||||
body,
|
||||
},
|
||||
options
|
||||
)
|
||||
@ -49,6 +49,6 @@ export default class Session {
|
||||
* @public
|
||||
*/
|
||||
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
|
||||
*/
|
||||
find(type, id, query = {}, options = {}) {
|
||||
let data = query;
|
||||
let params = query;
|
||||
let url = app.forum.attribute('apiUrl') + '/' + type;
|
||||
|
||||
if (id instanceof Array) {
|
||||
url += '?filter[id]=' + id.join(',');
|
||||
} else if (typeof id === 'object') {
|
||||
data = id;
|
||||
params = id;
|
||||
} else if (id) {
|
||||
url += '/' + id;
|
||||
}
|
||||
@ -99,7 +99,7 @@ export default class Store {
|
||||
{
|
||||
method: 'GET',
|
||||
url,
|
||||
data,
|
||||
params,
|
||||
},
|
||||
options
|
||||
)
|
||||
|
@ -1,4 +1,3 @@
|
||||
import User from './models/User';
|
||||
import username from './helpers/username';
|
||||
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'));
|
||||
|
||||
if (match) {
|
||||
// Either an opening or closing tag.
|
||||
if (match[1]) {
|
||||
open[0].push(input[match[1]]);
|
||||
} else if (match[3]) {
|
||||
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();
|
||||
} 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: [] };
|
||||
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);
|
||||
}
|
||||
}
|
||||
} 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);
|
||||
}
|
||||
});
|
||||
|
@ -13,6 +13,7 @@ import RequestError from './utils/RequestError';
|
||||
import abbreviateNumber from './utils/abbreviateNumber';
|
||||
import * as string from './utils/string';
|
||||
import SubtreeRetainer from './utils/SubtreeRetainer';
|
||||
import setRouteWithForcedRefresh from './utils/setRouteWithForcedRefresh';
|
||||
import extract from './utils/extract';
|
||||
import ScrollListener from './utils/ScrollListener';
|
||||
import stringToColor from './utils/stringToColor';
|
||||
@ -22,6 +23,7 @@ import classList from './utils/classList';
|
||||
import extractText from './utils/extractText';
|
||||
import formatNumber from './utils/formatNumber';
|
||||
import mapRoutes from './utils/mapRoutes';
|
||||
import withAttr from './utils/withAttr';
|
||||
import Notification from './models/Notification';
|
||||
import User from './models/User';
|
||||
import Post from './models/Post';
|
||||
@ -62,6 +64,7 @@ import highlight from './helpers/highlight';
|
||||
import username from './helpers/username';
|
||||
import userOnline from './helpers/userOnline';
|
||||
import listItems from './helpers/listItems';
|
||||
import Fragment from './Fragment';
|
||||
|
||||
export default {
|
||||
extend: extend,
|
||||
@ -83,11 +86,13 @@ export default {
|
||||
'utils/ScrollListener': ScrollListener,
|
||||
'utils/stringToColor': stringToColor,
|
||||
'utils/subclassOf': subclassOf,
|
||||
'utils/setRouteWithForcedRefresh': setRouteWithForcedRefresh,
|
||||
'utils/patchMithril': patchMithril,
|
||||
'utils/classList': classList,
|
||||
'utils/extractText': extractText,
|
||||
'utils/formatNumber': formatNumber,
|
||||
'utils/mapRoutes': mapRoutes,
|
||||
'utils/withAttr': withAttr,
|
||||
'models/Notification': Notification,
|
||||
'models/User': User,
|
||||
'models/Post': Post,
|
||||
@ -95,6 +100,7 @@ export default {
|
||||
'models/Group': Group,
|
||||
'models/Forum': Forum,
|
||||
Component: Component,
|
||||
Fragment: Fragment,
|
||||
Translator: Translator,
|
||||
'components/AlertManager': AlertManager,
|
||||
'components/Page': Page,
|
||||
|
@ -7,7 +7,7 @@ import extract from '../utils/extract';
|
||||
* The `Alert` component represents an alert box, which contains a message,
|
||||
* some controls, and may be dismissible.
|
||||
*
|
||||
* The alert may have the following special props:
|
||||
* ### Attrs
|
||||
*
|
||||
* - `type` The type of alert this is. Will be used to give the alert a class
|
||||
* name of `Alert--{type}`.
|
||||
@ -15,16 +15,16 @@ import extract from '../utils/extract';
|
||||
* - `dismissible` Whether or not the alert can be dismissed.
|
||||
* - `ondismiss` A callback to run when the alert is dismissed.
|
||||
*
|
||||
* All other props will be assigned as attributes on the alert element.
|
||||
* All other attrs will be assigned as attributes on the DOM element.
|
||||
*/
|
||||
export default class Alert extends Component {
|
||||
view() {
|
||||
const attrs = Object.assign({}, this.props);
|
||||
view(vnode) {
|
||||
const attrs = Object.assign({}, this.attrs);
|
||||
|
||||
const type = extract(attrs, 'type');
|
||||
attrs.className = 'Alert Alert--' + type + ' ' + (attrs.className || '');
|
||||
|
||||
const children = extract(attrs, 'children');
|
||||
const content = extract(attrs, 'content') || vnode.children;
|
||||
const controls = extract(attrs, 'controls') || [];
|
||||
|
||||
// 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 (
|
||||
<div {...attrs}>
|
||||
<span className="Alert-body">{children}</span>
|
||||
<span className="Alert-body">{content}</span>
|
||||
<ul className="Alert-controls">{listItems(controls.concat(dismissControl))}</ul>
|
||||
</div>
|
||||
);
|
||||
|
@ -6,8 +6,10 @@ import Alert from './Alert';
|
||||
* be shown and dismissed.
|
||||
*/
|
||||
export default class AlertManager extends Component {
|
||||
init() {
|
||||
this.state = this.props.state;
|
||||
oninit(vnode) {
|
||||
super.oninit(vnode);
|
||||
|
||||
this.state = this.attrs.state;
|
||||
}
|
||||
|
||||
view() {
|
||||
@ -15,17 +17,12 @@ export default class AlertManager extends Component {
|
||||
<div className="AlertManager">
|
||||
{Object.entries(this.state.getActiveAlerts()).map(([key, 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>
|
||||
);
|
||||
}
|
||||
|
||||
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
|
||||
* 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
|
||||
* class name of `Badge--{type}`.
|
||||
* - `icon` The name of an icon to show inside the badge.
|
||||
* - `label`
|
||||
*
|
||||
* All other props will be assigned as attributes on the badge element.
|
||||
* All other attrs will be assigned as attributes on the badge element.
|
||||
*/
|
||||
export default class Badge extends Component {
|
||||
view() {
|
||||
const attrs = Object.assign({}, this.props);
|
||||
const attrs = Object.assign({}, this.attrs);
|
||||
const type = extract(attrs, 'type');
|
||||
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>;
|
||||
}
|
||||
|
||||
config(isInitialized) {
|
||||
if (isInitialized) return;
|
||||
oncreate(vnode) {
|
||||
super.oncreate(vnode);
|
||||
|
||||
if (this.props.label) this.$().tooltip();
|
||||
if (this.attrs.label) this.$().tooltip();
|
||||
}
|
||||
}
|
||||
|
@ -1,12 +1,15 @@
|
||||
import Component from '../Component';
|
||||
import icon from '../helpers/icon';
|
||||
import classList from '../utils/classList';
|
||||
import extract from '../utils/extract';
|
||||
import extractText from '../utils/extractText';
|
||||
import LoadingIndicator from './LoadingIndicator';
|
||||
|
||||
/**
|
||||
* The `Button` component defines an element which, when clicked, performs an
|
||||
* action. The button may have the following special props:
|
||||
* action.
|
||||
*
|
||||
* ### Attrs
|
||||
*
|
||||
* - `icon` The name of the icon class. If specified, the button will be given a
|
||||
* 'has-icon' class name.
|
||||
@ -15,41 +18,38 @@ import LoadingIndicator from './LoadingIndicator';
|
||||
* removed.
|
||||
* - `loading` Whether or not the button should be in a disabled loading state.
|
||||
*
|
||||
* All other props will be assigned as attributes on the button element.
|
||||
* 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
|
||||
* be used to represent any generic clickable control, like a menu item.
|
||||
*/
|
||||
export default class Button extends Component {
|
||||
view() {
|
||||
const attrs = Object.assign({}, this.props);
|
||||
view(vnode) {
|
||||
const attrs = Object.assign({}, this.attrs);
|
||||
|
||||
delete attrs.children;
|
||||
|
||||
attrs.className = attrs.className || '';
|
||||
attrs.type = attrs.type || 'button';
|
||||
|
||||
// If a tooltip was provided for buttons without additional content, we also
|
||||
// use this tooltip as text for screen readers
|
||||
if (attrs.title && !this.props.children) {
|
||||
if (attrs.title && !vnode.children) {
|
||||
attrs['aria-label'] = attrs.title;
|
||||
}
|
||||
|
||||
// If nothing else is provided, we use the textual button content as tooltip
|
||||
if (!attrs.title && this.props.children) {
|
||||
attrs.title = extractText(this.props.children);
|
||||
if (!attrs.title && vnode.children) {
|
||||
attrs.title = extractText(vnode.children);
|
||||
}
|
||||
|
||||
const iconName = extract(attrs, 'icon');
|
||||
if (iconName) attrs.className += ' hasIcon';
|
||||
|
||||
const loading = extract(attrs, 'loading');
|
||||
if (attrs.disabled || loading) {
|
||||
attrs.className += ' disabled' + (loading ? ' loading' : '');
|
||||
delete attrs.onclick;
|
||||
}
|
||||
|
||||
return <button {...attrs}>{this.getButtonContent()}</button>;
|
||||
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 {*}
|
||||
* @protected
|
||||
*/
|
||||
getButtonContent() {
|
||||
const iconName = this.props.icon;
|
||||
getButtonContent(children) {
|
||||
const iconName = this.attrs.icon;
|
||||
|
||||
return [
|
||||
iconName && iconName !== true ? icon(iconName, { className: 'Button-icon' }) : '',
|
||||
this.props.children ? <span className="Button-label">{this.props.children}</span> : '',
|
||||
this.props.loading ? LoadingIndicator.component({ size: 'tiny', className: 'LoadingIndicator--inline' }) : '',
|
||||
children ? <span className="Button-label">{children}</span> : '',
|
||||
this.attrs.loading ? <LoadingIndicator size="tiny" className="LoadingIndicator--inline" /> : '',
|
||||
];
|
||||
}
|
||||
}
|
||||
|
@ -1,11 +1,13 @@
|
||||
import Component from '../Component';
|
||||
import LoadingIndicator from './LoadingIndicator';
|
||||
import icon from '../helpers/icon';
|
||||
import classList from '../utils/classList';
|
||||
import withAttr from '../utils/withAttr';
|
||||
|
||||
/**
|
||||
* The `Checkbox` component defines a checkbox input.
|
||||
*
|
||||
* ### Props
|
||||
* ### Attrs
|
||||
*
|
||||
* - `state` Whether or not the checkbox is checked.
|
||||
* - `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.
|
||||
*/
|
||||
export default class Checkbox extends Component {
|
||||
view() {
|
||||
view(vnode) {
|
||||
// Sometimes, false is stored in the DB as '0'. This is a temporary
|
||||
// conversion layer until a more robust settings encoding is introduced
|
||||
if (this.props.state === '0') this.props.state = false;
|
||||
let className = 'Checkbox ' + (this.props.state ? 'on' : 'off') + ' ' + (this.props.className || '');
|
||||
if (this.props.loading) className += ' loading';
|
||||
if (this.props.disabled) className += ' disabled';
|
||||
if (this.attrs.state === '0') this.attrs.state = false;
|
||||
|
||||
const className = classList([
|
||||
'Checkbox',
|
||||
this.attrs.state ? 'on' : 'off',
|
||||
this.attrs.className,
|
||||
this.attrs.loading && 'loading',
|
||||
this.attrs.disabled && 'disabled',
|
||||
]);
|
||||
|
||||
return (
|
||||
<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>
|
||||
{this.props.children}
|
||||
{vnode.children}
|
||||
</label>
|
||||
);
|
||||
}
|
||||
@ -39,7 +46,7 @@ export default class Checkbox extends Component {
|
||||
* @protected
|
||||
*/
|
||||
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
|
||||
*/
|
||||
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
|
||||
* return value of a given callback prop.
|
||||
*
|
||||
* ### Props
|
||||
* ### Attrs
|
||||
*
|
||||
* - `when` - a callback returning true when the browser should prompt for
|
||||
* confirmation before closing the window/tab
|
||||
@ -17,21 +17,24 @@ import Component from '../Component';
|
||||
*
|
||||
*/
|
||||
export default class ConfirmDocumentUnload extends Component {
|
||||
config(isInitialized, context) {
|
||||
if (isInitialized) return;
|
||||
|
||||
const handler = () => this.props.when() || undefined;
|
||||
|
||||
$(window).on('beforeunload', handler);
|
||||
|
||||
context.onunload = () => {
|
||||
$(window).off('beforeunload', handler);
|
||||
};
|
||||
handler() {
|
||||
return this.attrs.when() || undefined;
|
||||
}
|
||||
|
||||
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
|
||||
// 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
|
||||
* dropdown menu beneath it.
|
||||
*
|
||||
* ### Props
|
||||
* ### Attrs
|
||||
*
|
||||
* - `buttonClassName` A class name to apply to the dropdown toggle button.
|
||||
* - `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.
|
||||
*/
|
||||
export default class Dropdown extends Component {
|
||||
static initProps(props) {
|
||||
super.initProps(props);
|
||||
|
||||
props.className = props.className || '';
|
||||
props.buttonClassName = props.buttonClassName || '';
|
||||
props.menuClassName = props.menuClassName || '';
|
||||
props.label = props.label || '';
|
||||
props.caretIcon = typeof props.caretIcon !== 'undefined' ? props.caretIcon : 'fas fa-caret-down';
|
||||
static initAttrs(attrs) {
|
||||
attrs.className = attrs.className || '';
|
||||
attrs.buttonClassName = attrs.buttonClassName || '';
|
||||
attrs.menuClassName = attrs.menuClassName || '';
|
||||
attrs.label = attrs.label || '';
|
||||
attrs.caretIcon = typeof attrs.caretIcon !== 'undefined' ? attrs.caretIcon : 'fas fa-caret-down';
|
||||
}
|
||||
|
||||
init() {
|
||||
oninit(vnode) {
|
||||
super.oninit(vnode);
|
||||
|
||||
this.showing = false;
|
||||
}
|
||||
|
||||
view() {
|
||||
const items = this.props.children ? listItems(this.props.children) : [];
|
||||
view(vnode) {
|
||||
const items = vnode.children ? listItems(vnode.children) : [];
|
||||
|
||||
return (
|
||||
<div className={'ButtonGroup Dropdown dropdown ' + this.props.className + ' itemCount' + items.length + (this.showing ? ' open' : '')}>
|
||||
{this.getButton()}
|
||||
<div className={'ButtonGroup Dropdown dropdown ' + this.attrs.className + ' itemCount' + items.length + (this.showing ? ' open' : '')}>
|
||||
{this.getButton(vnode.children)}
|
||||
{this.getMenu(items)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
config(isInitialized) {
|
||||
if (isInitialized) return;
|
||||
oncreate(vnode) {
|
||||
super.oncreate(vnode);
|
||||
|
||||
// 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
|
||||
@ -53,8 +53,8 @@ export default class Dropdown extends Component {
|
||||
this.$().on('shown.bs.dropdown', () => {
|
||||
this.showing = true;
|
||||
|
||||
if (this.props.onshow) {
|
||||
this.props.onshow();
|
||||
if (this.attrs.onshow) {
|
||||
this.attrs.onshow();
|
||||
}
|
||||
|
||||
m.redraw();
|
||||
@ -76,8 +76,8 @@ export default class Dropdown extends Component {
|
||||
this.$().on('hidden.bs.dropdown', () => {
|
||||
this.showing = false;
|
||||
|
||||
if (this.props.onhide) {
|
||||
this.props.onhide();
|
||||
if (this.attrs.onhide) {
|
||||
this.attrs.onhide();
|
||||
}
|
||||
|
||||
m.redraw();
|
||||
@ -90,10 +90,10 @@ export default class Dropdown extends Component {
|
||||
* @return {*}
|
||||
* @protected
|
||||
*/
|
||||
getButton() {
|
||||
getButton(children) {
|
||||
return (
|
||||
<button className={'Dropdown-toggle ' + this.props.buttonClassName} data-toggle="dropdown" onclick={this.props.onclick}>
|
||||
{this.getButtonContent()}
|
||||
<button className={'Dropdown-toggle ' + this.attrs.buttonClassName} data-toggle="dropdown" onclick={this.attrs.onclick}>
|
||||
{this.getButtonContent(children)}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@ -104,15 +104,15 @@ export default class Dropdown extends Component {
|
||||
* @return {*}
|
||||
* @protected
|
||||
*/
|
||||
getButtonContent() {
|
||||
getButtonContent(children) {
|
||||
return [
|
||||
this.props.icon ? icon(this.props.icon, { className: 'Button-icon' }) : '',
|
||||
<span className="Button-label">{this.props.label}</span>,
|
||||
this.props.caretIcon ? icon(this.props.caretIcon, { className: 'Button-caret' }) : '',
|
||||
this.attrs.icon ? icon(this.attrs.icon, { className: 'Button-icon' }) : '',
|
||||
<span className="Button-label">{this.attrs.label}</span>,
|
||||
this.attrs.caretIcon ? icon(this.attrs.caretIcon, { className: 'Button-caret' }) : '',
|
||||
];
|
||||
}
|
||||
|
||||
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.
|
||||
*/
|
||||
export default class FieldSet extends Component {
|
||||
view() {
|
||||
view(vnode) {
|
||||
return (
|
||||
<fieldset className={this.props.className}>
|
||||
<legend>{this.props.label}</legend>
|
||||
<ul>{listItems(this.props.children)}</ul>
|
||||
<fieldset className={this.attrs.className}>
|
||||
<legend>{this.attrs.label}</legend>
|
||||
<ul>{listItems(vnode.children)}</ul>
|
||||
</fieldset>
|
||||
);
|
||||
}
|
||||
|
@ -1,16 +1,16 @@
|
||||
import Badge from './Badge';
|
||||
|
||||
export default class GroupBadge extends Badge {
|
||||
static initProps(props) {
|
||||
super.initProps(props);
|
||||
static initAttrs(attrs) {
|
||||
super.initAttrs(attrs);
|
||||
|
||||
if (props.group) {
|
||||
props.icon = props.group.icon();
|
||||
props.style = { backgroundColor: props.group.color() };
|
||||
props.label = typeof props.label === 'undefined' ? props.group.nameSingular() : props.label;
|
||||
props.type = 'group--' + props.group.id();
|
||||
if (attrs.group) {
|
||||
attrs.icon = attrs.group.icon();
|
||||
attrs.style = { backgroundColor: attrs.group.color() };
|
||||
attrs.label = typeof attrs.label === 'undefined' ? attrs.group.nameSingular() : attrs.label;
|
||||
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.
|
||||
*
|
||||
* ### 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.
|
||||
@ -13,26 +13,28 @@ import Button from './Button';
|
||||
* the `active` prop will automatically be set to true.
|
||||
*/
|
||||
export default class LinkButton extends Button {
|
||||
static initProps(props) {
|
||||
props.active = this.isActive(props);
|
||||
props.config = props.config || m.route;
|
||||
static initAttrs(attrs) {
|
||||
super.initAttrs(attrs);
|
||||
|
||||
attrs.active = this.isActive(attrs);
|
||||
}
|
||||
|
||||
view() {
|
||||
const vdom = super.view();
|
||||
view(vnode) {
|
||||
const vdom = super.view(vnode);
|
||||
|
||||
vdom.tag = 'a';
|
||||
vdom.tag = m.route.Link;
|
||||
vdom.attrs.active = String(vdom.attrs.active);
|
||||
|
||||
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}
|
||||
*/
|
||||
static isActive(props) {
|
||||
return typeof props.active !== 'undefined' ? props.active : m.route() === props.href;
|
||||
static isActive(attrs) {
|
||||
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';
|
||||
|
||||
/**
|
||||
* The `LoadingIndicator` component displays a loading spinner with spin.js. It
|
||||
* may have the following special props:
|
||||
* The `LoadingIndicator` component displays a loading spinner with spin.js.
|
||||
*
|
||||
* ### Attrs
|
||||
*
|
||||
* - `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 {
|
||||
view() {
|
||||
const attrs = Object.assign({}, this.props);
|
||||
const attrs = Object.assign({}, this.attrs);
|
||||
|
||||
attrs.className = 'LoadingIndicator ' + (attrs.className || '');
|
||||
delete attrs.size;
|
||||
@ -19,12 +20,12 @@ export default class LoadingIndicator extends Component {
|
||||
return <div {...attrs}>{m.trust(' ')}</div>;
|
||||
}
|
||||
|
||||
config(isInitialized) {
|
||||
if (isInitialized) return;
|
||||
oncreate(vnode) {
|
||||
super.oncreate(vnode);
|
||||
|
||||
const options = { zIndex: 'auto', color: this.$().css('color') };
|
||||
|
||||
switch (this.props.size) {
|
||||
switch (this.attrs.size) {
|
||||
case 'large':
|
||||
Object.assign(options, { lines: 10, length: 8, width: 4, radius: 8 });
|
||||
break;
|
||||
|
@ -14,23 +14,21 @@ export default class Modal extends Component {
|
||||
*/
|
||||
static isDismissible = true;
|
||||
|
||||
init() {
|
||||
/**
|
||||
* Attributes for an alert component to show below the header.
|
||||
*
|
||||
* @type {object}
|
||||
*/
|
||||
this.alertAttrs = null;
|
||||
alertAttrs = null;
|
||||
|
||||
oncreate(vnode) {
|
||||
super.oncreate(vnode);
|
||||
|
||||
this.attrs.onshow(() => this.onready());
|
||||
}
|
||||
|
||||
config(isInitialized, context) {
|
||||
if (isInitialized) return;
|
||||
|
||||
this.props.onshow(() => this.onready());
|
||||
|
||||
context.onunload = () => {
|
||||
this.props.onhide();
|
||||
};
|
||||
onremove() {
|
||||
this.attrs.onhide();
|
||||
}
|
||||
|
||||
view() {
|
||||
@ -109,7 +107,7 @@ export default class Modal extends Component {
|
||||
* Hide the modal.
|
||||
*/
|
||||
hide() {
|
||||
this.props.onhide();
|
||||
this.attrs.onhide();
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -6,12 +6,8 @@ import Component from '../Component';
|
||||
* overwrite the previous one.
|
||||
*/
|
||||
export default class ModalManager extends Component {
|
||||
init() {
|
||||
this.state = this.props.state;
|
||||
}
|
||||
|
||||
view() {
|
||||
const modal = this.state.modal;
|
||||
const modal = this.attrs.state.modal;
|
||||
|
||||
return (
|
||||
<div className="ModalManager modal fade">
|
||||
@ -20,22 +16,17 @@ export default class ModalManager extends Component {
|
||||
);
|
||||
}
|
||||
|
||||
config(isInitialized, context) {
|
||||
if (isInitialized) return;
|
||||
|
||||
// Since this component is 'above' the content of the page (that is, it is a
|
||||
// part of the global UI that persists between routes), we will flag the DOM
|
||||
// to be retained across route changes.
|
||||
context.retain = true;
|
||||
oncreate(vnode) {
|
||||
super.oncreate(vnode);
|
||||
|
||||
// Ensure the modal state is notified about a closed modal, even when the
|
||||
// DOM-based Bootstrap JavaScript code triggered the closing of the modal,
|
||||
// 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) {
|
||||
const dismissible = !!this.state.modal.componentClass.isDismissible;
|
||||
const dismissible = !!this.attrs.state.modal.componentClass.isDismissible;
|
||||
|
||||
this.$()
|
||||
.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
|
||||
* 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.
|
||||
* - `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 (
|
||||
<div
|
||||
className={'Navigation ButtonGroup ' + (this.props.className || '')}
|
||||
className={'Navigation ButtonGroup ' + (this.attrs.className || '')}
|
||||
onmouseenter={pane && pane.show.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.
|
||||
*
|
||||
@ -54,7 +47,6 @@ export default class Navigation extends Component {
|
||||
href: history.backUrl(),
|
||||
icon: 'fas fa-chevron-left',
|
||||
title: previous.title,
|
||||
config: () => {},
|
||||
onclick: (e) => {
|
||||
if (e.shiftKey || e.ctrlKey || e.metaKey || e.which === 2) return;
|
||||
e.preventDefault();
|
||||
@ -88,7 +80,7 @@ export default class Navigation extends Component {
|
||||
* @protected
|
||||
*/
|
||||
getDrawerButton() {
|
||||
if (!this.props.drawer) return '';
|
||||
if (!this.attrs.drawer) return '';
|
||||
|
||||
const { drawer } = app;
|
||||
const user = app.session.user;
|
||||
|
@ -7,10 +7,14 @@ import PageState from '../states/PageState';
|
||||
* @abstract
|
||||
*/
|
||||
export default class Page extends Component {
|
||||
init() {
|
||||
oninit(vnode) {
|
||||
super.oninit(vnode);
|
||||
|
||||
app.previous = app.current;
|
||||
app.current = new PageState(this.constructor);
|
||||
|
||||
this.onNewRoute();
|
||||
|
||||
app.drawer.hide();
|
||||
app.modal.close();
|
||||
|
||||
@ -22,13 +26,27 @@ export default class Page extends Component {
|
||||
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) {
|
||||
$('#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,
|
||||
* usually used as an empty state.
|
||||
*
|
||||
* ### Props
|
||||
* ### Attrs
|
||||
*
|
||||
* - `text`
|
||||
*/
|
||||
@ -12,7 +12,7 @@ export default class Placeholder extends Component {
|
||||
view() {
|
||||
return (
|
||||
<div className="Placeholder">
|
||||
<p>{this.props.text}</p>
|
||||
<p>{this.attrs.text}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
@ -6,11 +6,11 @@ export default class RequestErrorModal extends Modal {
|
||||
}
|
||||
|
||||
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() {
|
||||
const { error, formattedError } = this.props;
|
||||
const { error, formattedError } = this.attrs;
|
||||
|
||||
let responseText;
|
||||
|
||||
@ -31,7 +31,7 @@ export default class RequestErrorModal extends Modal {
|
||||
return (
|
||||
<div className="Modal-body">
|
||||
<pre>
|
||||
{this.props.error.options.method} {this.props.error.options.url}
|
||||
{this.attrs.error.options.method} {this.attrs.error.options.url}
|
||||
<br />
|
||||
<br />
|
||||
{responseText}
|
||||
|
@ -1,9 +1,10 @@
|
||||
import Component from '../Component';
|
||||
import icon from '../helpers/icon';
|
||||
import withAttr from '../utils/withAttr';
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* - `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 {
|
||||
view() {
|
||||
const { options, onchange, value, disabled } = this.props;
|
||||
const { options, onchange, value, disabled } = this.attrs;
|
||||
|
||||
return (
|
||||
<span className="Select">
|
||||
<select
|
||||
className="Select-input FormControl"
|
||||
onchange={onchange ? m.withAttr('value', onchange.bind(this)) : undefined}
|
||||
onchange={onchange ? withAttr('value', onchange.bind(this)) : undefined}
|
||||
value={value}
|
||||
disabled={disabled}
|
||||
>
|
||||
|
@ -1,31 +1,49 @@
|
||||
import Dropdown from './Dropdown';
|
||||
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
|
||||
* button's label is set as the label of the first child which has a truthy
|
||||
* `active` prop.
|
||||
*
|
||||
* ### Props
|
||||
* ### Attrs
|
||||
*
|
||||
* - `caretIcon`
|
||||
* - `defaultLabel`
|
||||
*/
|
||||
export default class SelectDropdown extends Dropdown {
|
||||
static initProps(props) {
|
||||
props.caretIcon = typeof props.caretIcon !== 'undefined' ? props.caretIcon : 'fas fa-sort';
|
||||
static initAttrs(attrs) {
|
||||
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() {
|
||||
const activeChild = this.props.children.filter((child) => child.props.active)[0];
|
||||
let label = (activeChild && activeChild.props.children) || this.props.defaultLabel;
|
||||
getButtonContent(children) {
|
||||
const activeChild = children.find(isActive);
|
||||
let label = (activeChild && activeChild.children) || this.attrs.defaultLabel;
|
||||
|
||||
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.
|
||||
*/
|
||||
export default class SplitDropdown extends Dropdown {
|
||||
static initProps(props) {
|
||||
super.initProps(props);
|
||||
static initAttrs(attrs) {
|
||||
super.initAttrs(attrs);
|
||||
|
||||
props.className += ' Dropdown--split';
|
||||
props.menuClassName += ' Dropdown-menu--right';
|
||||
attrs.className += ' Dropdown--split';
|
||||
attrs.menuClassName += ' Dropdown-menu--right';
|
||||
}
|
||||
|
||||
getButton() {
|
||||
// Make a copy of the props of the first child component. We will assign
|
||||
// these props to a new button, so that it has exactly the same behaviour as
|
||||
getButton(children) {
|
||||
// Make a copy of the attrs of the first child component. We will assign
|
||||
// these attrs to a new button, so that it has exactly the same behaviour as
|
||||
// the first child.
|
||||
const firstChild = this.getFirstChild();
|
||||
const buttonProps = Object.assign({}, firstChild.props);
|
||||
buttonProps.className = (buttonProps.className || '') + ' SplitDropdown-button Button ' + this.props.buttonClassName;
|
||||
const firstChild = this.getFirstChild(children);
|
||||
const buttonAttrs = Object.assign({}, firstChild.attrs);
|
||||
buttonAttrs.className = (buttonAttrs.className || '') + ' SplitDropdown-button Button ' + this.attrs.buttonClassName;
|
||||
|
||||
return [
|
||||
Button.component(buttonProps),
|
||||
<button className={'Dropdown-toggle Button Button--icon ' + this.props.buttonClassName} data-toggle="dropdown">
|
||||
{icon(this.props.icon, { className: 'Button-icon' })}
|
||||
Button.component(buttonAttrs, firstChild.children),
|
||||
<button className={'Dropdown-toggle Button Button--icon ' + this.attrs.buttonClassName} data-toggle="dropdown">
|
||||
{icon(this.attrs.icon, { className: 'Button-icon' })}
|
||||
{icon('fas fa-caret-down', { className: 'Button-caret' })}
|
||||
</button>,
|
||||
];
|
||||
@ -38,8 +38,8 @@ export default class SplitDropdown extends Dropdown {
|
||||
* @return {*}
|
||||
* @protected
|
||||
*/
|
||||
getFirstChild() {
|
||||
let firstChild = this.props.children;
|
||||
getFirstChild(children) {
|
||||
let firstChild = children;
|
||||
|
||||
while (firstChild instanceof Array) firstChild = firstChild[0];
|
||||
|
||||
|
@ -5,13 +5,13 @@ import Checkbox from './Checkbox';
|
||||
* a tick/cross one.
|
||||
*/
|
||||
export default class Switch extends Checkbox {
|
||||
static initProps(props) {
|
||||
super.initProps(props);
|
||||
static initAttrs(attrs) {
|
||||
super.initAttrs(attrs);
|
||||
|
||||
props.className = (props.className || '') + ' Checkbox--switch';
|
||||
attrs.className = (attrs.className || '') + ' Checkbox--switch';
|
||||
}
|
||||
|
||||
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';
|
||||
|
||||
function isSeparator(item) {
|
||||
return item && item.component === Separator;
|
||||
return item.tag === Separator;
|
||||
}
|
||||
|
||||
function withoutUnnecessarySeparators(items) {
|
||||
const newItems = [];
|
||||
let prevItem;
|
||||
|
||||
items.forEach((item, i) => {
|
||||
items.filter(Boolean).forEach((item, i) => {
|
||||
if (!isSeparator(item) || (prevItem && !isSeparator(prevItem) && i !== items.length - 1)) {
|
||||
prevItem = item;
|
||||
newItems.push(item);
|
||||
@ -30,21 +30,27 @@ export default function listItems(items) {
|
||||
if (!(items instanceof Array)) items = [items];
|
||||
|
||||
return withoutUnnecessarySeparators(items).map((item) => {
|
||||
const isListItem = item.component && item.component.isListItem;
|
||||
const active = item.component && item.component.isActive && item.component.isActive(item.props);
|
||||
const className = item.props ? item.props.itemClassName : item.itemClassName;
|
||||
const isListItem = item.tag && item.tag.isListItem;
|
||||
const active = item.tag && item.tag.isActive && item.tag.isActive(item.attrs);
|
||||
const className = (item.attrs && item.attrs.itemClassName) || item.itemClassName;
|
||||
|
||||
if (isListItem) {
|
||||
item.attrs = item.attrs || {};
|
||||
item.attrs.key = item.attrs.key || item.itemName;
|
||||
item.key = item.attrs.key;
|
||||
}
|
||||
|
||||
return isListItem ? (
|
||||
const node = isListItem ? (
|
||||
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}
|
||||
</li>
|
||||
);
|
||||
|
||||
return node;
|
||||
});
|
||||
}
|
||||
|
@ -13,7 +13,21 @@ export default class AlertManagerState {
|
||||
/**
|
||||
* 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.
|
||||
// This is applied to the first argument (attrs) because previously, the alert was passed as the first argument.
|
||||
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.');
|
||||
}
|
||||
// End Change Compliance Warning, Remove in Beta 15
|
||||
this.activeAlerts[++this.alertId] = { attrs, componentClass };
|
||||
this.activeAlerts[++this.alertId] = { children, attrs, componentClass };
|
||||
m.redraw();
|
||||
|
||||
return this.alertId;
|
||||
|
@ -32,7 +32,7 @@ export default class ModalManagerState {
|
||||
|
||||
this.modal = { componentClass, attrs };
|
||||
|
||||
m.redraw(true);
|
||||
m.redraw.sync();
|
||||
}
|
||||
|
||||
/**
|
||||
@ -50,7 +50,7 @@ export default class ModalManagerState {
|
||||
// ahead.
|
||||
this.closeTimeout = setTimeout(() => {
|
||||
this.modal = null;
|
||||
m.lazyRedraw();
|
||||
m.redraw();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
@ -1,20 +1,25 @@
|
||||
/**
|
||||
* The `SubtreeRetainer` class represents a Mithril virtual DOM subtree. It
|
||||
* keeps track of a number of pieces of data, allowing the subtree to be
|
||||
* retained if none of them have changed.
|
||||
* The `SubtreeRetainer` class keeps track of a number of pieces of data,
|
||||
* comparing the values of these pieces at every iteration.
|
||||
*
|
||||
* 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
|
||||
* // constructor
|
||||
* // Check two callbacks for changes on each update
|
||||
* this.subtree = new SubtreeRetainer(
|
||||
* () => this.props.post.freshness,
|
||||
* () => this.attrs.post.freshness,
|
||||
* () => this.showing
|
||||
* );
|
||||
* this.subtree.check(() => this.props.user.freshness);
|
||||
*
|
||||
* // view
|
||||
* this.subtree.retain() || 'expensive expression'
|
||||
* // Add more callbacks to be checked for updates
|
||||
* 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 {
|
||||
/**
|
||||
@ -26,13 +31,13 @@ export default class SubtreeRetainer {
|
||||
}
|
||||
|
||||
/**
|
||||
* Return a virtual DOM directive that will retain a subtree if no data has
|
||||
* changed since the last check.
|
||||
* Return whether any data has changed since the last check.
|
||||
* If so, Mithril needs to re-diff the vnode and its children.
|
||||
*
|
||||
* @return {Object|false}
|
||||
* @return {boolean}
|
||||
* @public
|
||||
*/
|
||||
retain() {
|
||||
needsRebuild() {
|
||||
let needsRebuild = false;
|
||||
|
||||
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) {
|
||||
return vdom.map((element) => extractText(element)).join('');
|
||||
} else if (typeof vdom === 'object' && vdom !== null) {
|
||||
return extractText(vdom.children);
|
||||
return vdom.children ? extractText(vdom.children) : vdom.text;
|
||||
} else {
|
||||
return vdom;
|
||||
}
|
||||
|
@ -2,7 +2,7 @@
|
||||
* The `mapRoutes` utility converts a map of named application routes into a
|
||||
* format that can be understood by Mithril.
|
||||
*
|
||||
* @see https://lhorie.github.io/mithril/mithril.route.html#defining-routes
|
||||
* @see https://mithril.js.org/route.html#signature
|
||||
* @param {Object} routes
|
||||
* @param {String} [basePath]
|
||||
* @return {Object}
|
||||
@ -13,9 +13,11 @@ export default function mapRoutes(routes, basePath = '') {
|
||||
for (const key in routes) {
|
||||
const route = routes[key];
|
||||
|
||||
if (route.component) route.component.props.routeName = key;
|
||||
|
||||
map[basePath + route.path] = route.component;
|
||||
map[basePath + route.path] = {
|
||||
render() {
|
||||
return m(route.component, { routeName: key });
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return map;
|
||||
|
@ -1,27 +1,60 @@
|
||||
import Component from '../Component';
|
||||
import Stream from 'mithril/stream';
|
||||
import extract from './extract';
|
||||
|
||||
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) {
|
||||
let children = args.slice(1);
|
||||
if (children.length === 1 && Array.isArray(children[0])) {
|
||||
children = children[0];
|
||||
/**
|
||||
* If the href URL of the link is the same as the current page path
|
||||
* we will not add a new entry to the browser history.
|
||||
*
|
||||
* 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) {
|
||||
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) {
|
||||
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;
|
||||
}
|
||||
@ -29,17 +62,11 @@ export default function patchMithril(global) {
|
||||
return node;
|
||||
};
|
||||
|
||||
Object.keys(mo).forEach((key) => (m[key] = mo[key]));
|
||||
Object.keys(defaultMithril).forEach((key) => (modifiedMithril[key] = defaultMithril[key]));
|
||||
|
||||
/**
|
||||
* Redraw only if not in the middle of a computation (e.g. a route change).
|
||||
*
|
||||
* @return {void}
|
||||
*/
|
||||
m.lazyRedraw = function () {
|
||||
m.startComputation();
|
||||
m.endComputation();
|
||||
};
|
||||
modifiedMithril.stream = Stream;
|
||||
|
||||
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.history.push(defaultAction, this.translator.trans('core.forum.header.back_to_index_tooltip'), '/');
|
||||
|
||||
m.mount(document.getElementById('app-navigation'), Navigation.component({ className: 'App-backControl', drawer: true }));
|
||||
m.mount(document.getElementById('header-navigation'), Navigation.component());
|
||||
m.mount(document.getElementById('header-primary'), HeaderPrimary.component());
|
||||
m.mount(document.getElementById('header-secondary'), HeaderSecondary.component());
|
||||
m.mount(document.getElementById('composer'), Composer.component({ state: this.composer }));
|
||||
m.mount(document.getElementById('app-navigation'), { view: () => Navigation.component({ className: 'App-backControl', drawer: true }) });
|
||||
m.mount(document.getElementById('header-navigation'), Navigation);
|
||||
m.mount(document.getElementById('header-primary'), HeaderPrimary);
|
||||
m.mount(document.getElementById('header-secondary'), HeaderSecondary);
|
||||
m.mount(document.getElementById('composer'), { view: () => Composer.component({ state: this.composer }) });
|
||||
|
||||
this.pane = new Pane(document.getElementById('app'));
|
||||
|
||||
m.route.mode = 'pathname';
|
||||
m.route.prefix = '';
|
||||
super.mount(this.forum.attribute('basePath'));
|
||||
|
||||
alertEmailConfirmation(this);
|
||||
@ -161,8 +161,8 @@ export default class ForumApplication extends Application {
|
||||
* will be reloaded. Otherwise, a SignUpModal will be opened, prefilled
|
||||
* with the provided details.
|
||||
*
|
||||
* @param {Object} payload A dictionary of props to pass into the sign up
|
||||
* modal. A truthy `loggedIn` prop indicates that the user has logged
|
||||
* @param {Object} payload A dictionary of attrs to pass into the sign up
|
||||
* modal. A truthy `loggedIn` attr indicates that the user has logged
|
||||
* in, and thus the page is reloaded.
|
||||
* @public
|
||||
*/
|
||||
|
@ -3,7 +3,6 @@ import compat from '../common/compat';
|
||||
import PostControls from './utils/PostControls';
|
||||
import KeyboardNavigatable from './utils/KeyboardNavigatable';
|
||||
import slidable from './utils/slidable';
|
||||
import affixSidebar from './utils/affixSidebar';
|
||||
import History from './utils/History';
|
||||
import DiscussionControls from './utils/DiscussionControls';
|
||||
import alertEmailConfirmation from './utils/alertEmailConfirmation';
|
||||
@ -15,6 +14,7 @@ import GlobalSearchState from './states/GlobalSearchState';
|
||||
import NotificationListState from './states/NotificationListState';
|
||||
import PostStreamState from './states/PostStreamState';
|
||||
import SearchState from './states/SearchState';
|
||||
import AffixedSidebar from './components/AffixedSidebar';
|
||||
import DiscussionPage from './components/DiscussionPage';
|
||||
import LogInModal from './components/LogInModal';
|
||||
import ComposerBody from './components/ComposerBody';
|
||||
@ -61,6 +61,7 @@ import NotificationList from './components/NotificationList';
|
||||
import WelcomeHero from './components/WelcomeHero';
|
||||
import SignUpModal from './components/SignUpModal';
|
||||
import CommentPost from './components/CommentPost';
|
||||
import ComposerPostPreview from './components/ComposerPostPreview';
|
||||
import ReplyComposer from './components/ReplyComposer';
|
||||
import NotificationsPage from './components/NotificationsPage';
|
||||
import PostStreamScrubber from './components/PostStreamScrubber';
|
||||
@ -77,7 +78,6 @@ export default Object.assign(compat, {
|
||||
'utils/PostControls': PostControls,
|
||||
'utils/KeyboardNavigatable': KeyboardNavigatable,
|
||||
'utils/slidable': slidable,
|
||||
'utils/affixSidebar': affixSidebar,
|
||||
'utils/History': History,
|
||||
'utils/DiscussionControls': DiscussionControls,
|
||||
'utils/alertEmailConfirmation': alertEmailConfirmation,
|
||||
@ -89,6 +89,7 @@ export default Object.assign(compat, {
|
||||
'states/NotificationListState': NotificationListState,
|
||||
'states/PostStreamState': PostStreamState,
|
||||
'states/SearchState': SearchState,
|
||||
'components/AffixedSidebar': AffixedSidebar,
|
||||
'components/DiscussionPage': DiscussionPage,
|
||||
'components/LogInModal': LogInModal,
|
||||
'components/ComposerBody': ComposerBody,
|
||||
@ -135,6 +136,7 @@ export default Object.assign(compat, {
|
||||
'components/WelcomeHero': WelcomeHero,
|
||||
'components/SignUpModal': SignUpModal,
|
||||
'components/CommentPost': CommentPost,
|
||||
'components/ComposerPostPreview': ComposerPostPreview,
|
||||
'components/ReplyComposer': ReplyComposer,
|
||||
'components/NotificationsPage': NotificationsPage,
|
||||
'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 listItems from '../../common/helpers/listItems';
|
||||
import ItemList from '../../common/utils/ItemList';
|
||||
import classList from '../../common/utils/classList';
|
||||
import Button from '../../common/components/Button';
|
||||
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
|
||||
* menu which allows the user to upload/remove the avatar.
|
||||
*
|
||||
* ### Props
|
||||
* ### Attrs
|
||||
*
|
||||
* - `className`
|
||||
* - `user`
|
||||
*/
|
||||
export default class AvatarEditor extends Component {
|
||||
init() {
|
||||
oninit(vnode) {
|
||||
super.oninit(vnode);
|
||||
|
||||
/**
|
||||
* Whether or not an avatar upload is in progress.
|
||||
*
|
||||
@ -32,17 +35,11 @@ export default class AvatarEditor extends Component {
|
||||
this.isDraggedOver = false;
|
||||
}
|
||||
|
||||
static initProps(props) {
|
||||
super.initProps(props);
|
||||
|
||||
props.className = props.className || '';
|
||||
}
|
||||
|
||||
view() {
|
||||
const user = this.props.user;
|
||||
const user = this.attrs.user;
|
||||
|
||||
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)}
|
||||
<a
|
||||
className={user.avatarUrl() ? 'Dropdown-toggle' : 'Dropdown-toggle AvatarEditor--noAvatar'}
|
||||
@ -55,7 +52,7 @@ export default class AvatarEditor extends Component {
|
||||
ondragend={this.disableDragover.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>
|
||||
<ul className="Dropdown-menu Menu">{listItems(this.controlItems().toArray())}</ul>
|
||||
</div>
|
||||
@ -72,20 +69,16 @@ export default class AvatarEditor extends Component {
|
||||
|
||||
items.add(
|
||||
'upload',
|
||||
Button.component({
|
||||
icon: 'fas fa-upload',
|
||||
children: app.translator.trans('core.forum.user.avatar_upload_button'),
|
||||
onclick: this.openPicker.bind(this),
|
||||
})
|
||||
<Button icon="fas fa-upload" onclick={this.openPicker.bind(this)}>
|
||||
{app.translator.trans('core.forum.user.avatar_upload_button')}
|
||||
</Button>
|
||||
);
|
||||
|
||||
items.add(
|
||||
'remove',
|
||||
Button.component({
|
||||
icon: 'fas fa-times',
|
||||
children: app.translator.trans('core.forum.user.avatar_remove_button'),
|
||||
onclick: this.remove.bind(this),
|
||||
})
|
||||
<Button icon="fas fa-times" onclick={this.remove.bind(this)}>
|
||||
{app.translator.trans('core.forum.user.avatar_remove_button')}
|
||||
</Button>
|
||||
);
|
||||
|
||||
return items;
|
||||
@ -134,7 +127,7 @@ export default class AvatarEditor extends Component {
|
||||
* @param {Event} e
|
||||
*/
|
||||
quickUpload(e) {
|
||||
if (!this.props.user.avatarUrl()) {
|
||||
if (!this.attrs.user.avatarUrl()) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
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
|
||||
// an avatar file. Once they have, we will upload it via the API.
|
||||
const user = this.props.user;
|
||||
const $input = $('<input type="file">');
|
||||
|
||||
$input
|
||||
@ -169,7 +161,7 @@ export default class AvatarEditor extends Component {
|
||||
upload(file) {
|
||||
if (this.loading) return;
|
||||
|
||||
const user = this.props.user;
|
||||
const user = this.attrs.user;
|
||||
const data = new FormData();
|
||||
data.append('avatar', file);
|
||||
|
||||
@ -179,9 +171,9 @@ export default class AvatarEditor extends Component {
|
||||
app
|
||||
.request({
|
||||
method: 'POST',
|
||||
url: app.forum.attribute('apiUrl') + '/users/' + user.id() + '/avatar',
|
||||
url: `${app.forum.attribute('apiUrl')}/users/${user.id()}/avatar`,
|
||||
serialize: (raw) => raw,
|
||||
data,
|
||||
body: data,
|
||||
})
|
||||
.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() {
|
||||
const user = this.props.user;
|
||||
const user = this.attrs.user;
|
||||
|
||||
this.loading = true;
|
||||
m.redraw();
|
||||
@ -198,7 +190,7 @@ export default class AvatarEditor extends Component {
|
||||
app
|
||||
.request({
|
||||
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));
|
||||
}
|
||||
@ -212,7 +204,7 @@ export default class AvatarEditor extends Component {
|
||||
*/
|
||||
success(response) {
|
||||
app.store.pushPayload(response);
|
||||
delete this.props.user.avatarColor;
|
||||
delete this.attrs.user.avatarColor;
|
||||
|
||||
this.loading = false;
|
||||
m.redraw();
|
||||
|
@ -6,8 +6,8 @@ import Button from '../../common/components/Button';
|
||||
* to change their email address.
|
||||
*/
|
||||
export default class ChangeEmailModal extends Modal {
|
||||
init() {
|
||||
super.init();
|
||||
oninit(vnode) {
|
||||
super.oninit(vnode);
|
||||
|
||||
/**
|
||||
* Whether or not the email has been changed successfully.
|
||||
@ -21,14 +21,14 @@ export default class ChangeEmailModal extends Modal {
|
||||
*
|
||||
* @type {function}
|
||||
*/
|
||||
this.email = m.prop(app.session.user.email());
|
||||
this.email = m.stream(app.session.user.email());
|
||||
|
||||
/**
|
||||
* The value of the password input.
|
||||
*
|
||||
* @type {function}
|
||||
*/
|
||||
this.password = m.prop('');
|
||||
this.password = m.stream('');
|
||||
}
|
||||
|
||||
className() {
|
||||
@ -81,12 +81,14 @@ export default class ChangeEmailModal extends Modal {
|
||||
/>
|
||||
</div>
|
||||
<div className="Form-group">
|
||||
{Button.component({
|
||||
{Button.component(
|
||||
{
|
||||
className: 'Button Button--primary Button--block',
|
||||
type: 'submit',
|
||||
loading: this.loading,
|
||||
children: app.translator.trans('core.forum.change_email.submit_button'),
|
||||
})}
|
||||
},
|
||||
app.translator.trans('core.forum.change_email.submit_button')
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -122,7 +124,7 @@ export default class ChangeEmailModal extends Modal {
|
||||
|
||||
onerror(error) {
|
||||
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);
|
||||
|
@ -20,12 +20,14 @@ export default class ChangePasswordModal extends Modal {
|
||||
<div className="Form Form--centered">
|
||||
<p className="helpText">{app.translator.trans('core.forum.change_password.text')}</p>
|
||||
<div className="Form-group">
|
||||
{Button.component({
|
||||
{Button.component(
|
||||
{
|
||||
className: 'Button Button--primary Button--block',
|
||||
type: 'submit',
|
||||
loading: this.loading,
|
||||
children: app.translator.trans('core.forum.change_password.send_button'),
|
||||
})}
|
||||
},
|
||||
app.translator.trans('core.forum.change_password.send_button')
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -41,7 +43,7 @@ export default class ChangePasswordModal extends Modal {
|
||||
.request({
|
||||
method: 'POST',
|
||||
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));
|
||||
}
|
||||
|
@ -1,5 +1,3 @@
|
||||
/*global s9e, hljs*/
|
||||
|
||||
import Post from './Post';
|
||||
import classList from '../../common/utils/classList';
|
||||
import PostUser from './PostUser';
|
||||
@ -9,19 +7,20 @@ import EditPostComposer from './EditPostComposer';
|
||||
import ItemList from '../../common/utils/ItemList';
|
||||
import listItems from '../../common/helpers/listItems';
|
||||
import Button from '../../common/components/Button';
|
||||
import ComposerPostPreview from './ComposerPostPreview';
|
||||
|
||||
/**
|
||||
* The `CommentPost` component displays a standard `comment`-typed post. This
|
||||
* includes a number of item lists (controls, header, and footer) surrounding
|
||||
* the post's HTML content.
|
||||
*
|
||||
* ### Props
|
||||
* ### Attrs
|
||||
*
|
||||
* - `post`
|
||||
*/
|
||||
export default class CommentPost extends Post {
|
||||
init() {
|
||||
super.init();
|
||||
oninit(vnode) {
|
||||
super.oninit(vnode);
|
||||
|
||||
/**
|
||||
* 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.cardVisible,
|
||||
() => this.isEditing()
|
||||
() => this.isEditing(),
|
||||
() => this.revealContent
|
||||
);
|
||||
}
|
||||
|
||||
content() {
|
||||
// Note: we avoid using JSX for the <ul> below because it results in some
|
||||
// weirdness in Mithril.js 0.1.x (see flarum/core#975). This workaround can
|
||||
// be reverted when we upgrade to Mithril 1.0.
|
||||
return super
|
||||
.content()
|
||||
.concat([
|
||||
<header className="Post-header">{m('ul', listItems(this.headerItems().toArray()))}</header>,
|
||||
return super.content().concat([
|
||||
<header className="Post-header">
|
||||
<ul>{listItems(this.headerItems().toArray())}</ul>
|
||||
</header>,
|
||||
<div className="Post-body">
|
||||
{this.isEditing() ? <div className="Post-preview" config={this.configPreview.bind(this)} /> : m.trust(this.props.post.contentHtml())}
|
||||
{this.isEditing() ? <ComposerPostPreview className="Post-preview" composer={app.composer} /> : m.trust(this.attrs.post.contentHtml())}
|
||||
</div>,
|
||||
]);
|
||||
}
|
||||
|
||||
config(isInitialized, context) {
|
||||
super.config(...arguments);
|
||||
onupdate(vnode) {
|
||||
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
|
||||
// all of the <script> tags in the content and evaluate them. This is
|
||||
// necessary because TextFormatter outputs them for e.g. syntax highlighting.
|
||||
if (context.contentHtml !== contentHtml) {
|
||||
if (this.contentHtml !== contentHtml) {
|
||||
this.$('.Post-body script').each(function () {
|
||||
eval.call(window, $(this).text());
|
||||
});
|
||||
}
|
||||
|
||||
context.contentHtml = contentHtml;
|
||||
this.contentHtml = contentHtml;
|
||||
}
|
||||
|
||||
isEditing() {
|
||||
return app.composer.bodyMatches(EditPostComposer, { post: this.props.post });
|
||||
return app.composer.bodyMatches(EditPostComposer, { post: this.attrs.post });
|
||||
}
|
||||
|
||||
attrs() {
|
||||
const post = this.props.post;
|
||||
const attrs = super.attrs();
|
||||
elementAttrs() {
|
||||
const post = this.attrs.post;
|
||||
const attrs = super.elementAttrs();
|
||||
|
||||
attrs.className =
|
||||
(attrs.className || '') +
|
||||
@ -98,27 +95,6 @@ export default class CommentPost extends Post {
|
||||
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.
|
||||
*/
|
||||
@ -133,7 +109,7 @@ export default class CommentPost extends Post {
|
||||
*/
|
||||
headerItems() {
|
||||
const items = new ItemList();
|
||||
const post = this.props.post;
|
||||
const post = this.attrs.post;
|
||||
|
||||
items.add(
|
||||
'user',
|
||||
|
@ -11,13 +11,15 @@ import ComposerState from '../states/ComposerState';
|
||||
* `show`, `hide`, `close`, `minimize`, `fullScreen`, and `exitFullScreen`.
|
||||
*/
|
||||
export default class Composer extends Component {
|
||||
init() {
|
||||
oninit(vnode) {
|
||||
super.oninit(vnode);
|
||||
|
||||
/**
|
||||
* The composer's "state".
|
||||
*
|
||||
* @type {ComposerState}
|
||||
*/
|
||||
this.state = this.props.state;
|
||||
this.state = this.attrs.state;
|
||||
|
||||
/**
|
||||
* Whether or not the composer currently has focus.
|
||||
@ -45,7 +47,7 @@ export default class Composer extends Component {
|
||||
|
||||
return (
|
||||
<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>
|
||||
<div className="Composer-content" onclick={showIfMinimized}>
|
||||
{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) {
|
||||
// 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.
|
||||
@ -64,12 +66,10 @@ export default class Composer extends Component {
|
||||
|
||||
this.prevPosition = this.state.position;
|
||||
}
|
||||
}
|
||||
|
||||
if (isInitialized) return;
|
||||
|
||||
// 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;
|
||||
oncreate(vnode) {
|
||||
super.oncreate(vnode);
|
||||
|
||||
this.initializeHeight();
|
||||
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.
|
||||
this.$().on('keydown', ':input', 'esc', () => this.state.close());
|
||||
|
||||
const handlers = {};
|
||||
this.handlers = {};
|
||||
|
||||
$(window)
|
||||
.on('resize', (handlers.onresize = this.updateHeight.bind(this)))
|
||||
.on('resize', (this.handlers.onresize = this.updateHeight.bind(this)))
|
||||
.resize();
|
||||
|
||||
$(document)
|
||||
.on('mousemove', (handlers.onmousemove = this.onmousemove.bind(this)))
|
||||
.on('mouseup', (handlers.onmouseup = this.onmouseup.bind(this)));
|
||||
.on('mousemove', (this.handlers.onmousemove = this.onmousemove.bind(this)))
|
||||
.on('mouseup', (this.handlers.onmouseup = this.onmouseup.bind(this)));
|
||||
}
|
||||
|
||||
context.onunload = () => {
|
||||
$(window).off('resize', handlers.onresize);
|
||||
onremove() {
|
||||
$(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
|
||||
* be used to resize the composer.
|
||||
*
|
||||
* @param {DOMElement} element
|
||||
* @param {Boolean} isInitialized
|
||||
*/
|
||||
configHandle(element, isInitialized) {
|
||||
if (isInitialized) return;
|
||||
|
||||
configHandle(vnode) {
|
||||
const composer = this;
|
||||
|
||||
$(element)
|
||||
$(vnode.dom)
|
||||
.css('cursor', 'row-resize')
|
||||
.bind('dragstart mousedown', (e) => e.preventDefault())
|
||||
.mousedown(function (e) {
|
||||
|
@ -11,7 +11,7 @@ import ItemList from '../../common/utils/ItemList';
|
||||
* composer. Subclasses should implement the `onsubmit` method and override
|
||||
* `headerTimes`.
|
||||
*
|
||||
* ### Props
|
||||
* ### Attrs
|
||||
*
|
||||
* - `composer`
|
||||
* - `originalContent`
|
||||
@ -24,8 +24,10 @@ import ItemList from '../../common/utils/ItemList';
|
||||
* @abstract
|
||||
*/
|
||||
export default class ComposerBody extends Component {
|
||||
init() {
|
||||
this.composer = this.props.composer;
|
||||
oninit(vnode) {
|
||||
super.oninit(vnode);
|
||||
|
||||
this.composer = this.attrs.composer;
|
||||
|
||||
/**
|
||||
* 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
|
||||
// circumstances, if the body supports / requires it and has a corresponding
|
||||
// confirmation question to ask.
|
||||
if (this.props.confirmExit) {
|
||||
this.composer.preventClosingWhen(() => this.hasChanges(), this.props.confirmExit);
|
||||
if (this.attrs.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.
|
||||
@ -53,15 +55,15 @@ export default class ComposerBody extends Component {
|
||||
view() {
|
||||
return (
|
||||
<ConfirmDocumentUnload when={this.hasChanges.bind(this)}>
|
||||
<div className={'ComposerBody ' + (this.props.className || '')}>
|
||||
{avatar(this.props.user, { className: 'ComposerBody-avatar' })}
|
||||
<div className={'ComposerBody ' + (this.attrs.className || '')}>
|
||||
{avatar(this.attrs.user, { className: 'ComposerBody-avatar' })}
|
||||
<div className="ComposerBody-content">
|
||||
<ul className="ComposerBody-header">{listItems(this.headerItems().toArray())}</ul>
|
||||
<div className="ComposerBody-editor">
|
||||
{TextEditor.component({
|
||||
submitLabel: this.props.submitLabel,
|
||||
placeholder: this.props.placeholder,
|
||||
disabled: this.loading || this.props.disabled,
|
||||
submitLabel: this.attrs.submitLabel,
|
||||
placeholder: this.attrs.placeholder,
|
||||
disabled: this.loading || this.attrs.disabled,
|
||||
composer: this.composer,
|
||||
preview: this.jumpToPreview && this.jumpToPreview.bind(this),
|
||||
onchange: this.composer.fields.content,
|
||||
@ -84,7 +86,7 @@ export default class ComposerBody extends Component {
|
||||
hasChanges() {
|
||||
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.
|
||||
*/
|
||||
export default class ComposerButton extends Button {
|
||||
static initProps(props) {
|
||||
super.initProps(props);
|
||||
static initAttrs(attrs) {
|
||||
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
|
||||
* `willExit` actions to account for the title.
|
||||
*
|
||||
* ### Props
|
||||
* ### Attrs
|
||||
*
|
||||
* - All of the props for ComposerBody
|
||||
* - All of the attrs for ComposerBody
|
||||
* - `titlePlaceholder`
|
||||
*/
|
||||
export default class DiscussionComposer extends ComposerBody {
|
||||
init() {
|
||||
super.init();
|
||||
static initAttrs(attrs) {
|
||||
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.
|
||||
@ -26,16 +36,6 @@ export default class DiscussionComposer extends ComposerBody {
|
||||
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() {
|
||||
const items = super.headerItems();
|
||||
|
||||
@ -46,10 +46,9 @@ export default class DiscussionComposer extends ComposerBody {
|
||||
<h3>
|
||||
<input
|
||||
className="FormControl"
|
||||
value={this.title()}
|
||||
oninput={m.withAttr('value', this.title)}
|
||||
placeholder={this.props.titlePlaceholder}
|
||||
disabled={!!this.props.disabled}
|
||||
bidi={this.title}
|
||||
placeholder={this.attrs.titlePlaceholder}
|
||||
disabled={!!this.attrs.disabled}
|
||||
onkeydown={this.onkeydown.bind(this)}
|
||||
/>
|
||||
</h3>
|
||||
@ -71,7 +70,7 @@ export default class DiscussionComposer extends ComposerBody {
|
||||
this.composer.editor.moveCursorTo(0);
|
||||
}
|
||||
|
||||
m.redraw.strategy('none');
|
||||
e.redraw = false;
|
||||
}
|
||||
|
||||
hasChanges() {
|
||||
@ -101,7 +100,7 @@ export default class DiscussionComposer extends ComposerBody {
|
||||
.then((discussion) => {
|
||||
this.composer.hide();
|
||||
app.discussions.refresh();
|
||||
m.route(app.route.discussion(discussion));
|
||||
m.route.set(app.route.discussion(discussion));
|
||||
}, this.loaded.bind(this));
|
||||
}
|
||||
}
|
||||
|
@ -5,7 +5,7 @@ import listItems from '../../common/helpers/listItems';
|
||||
/**
|
||||
* The `DiscussionHero` component displays the hero on a discussion page.
|
||||
*
|
||||
* ### Props
|
||||
* ### attrs
|
||||
*
|
||||
* - `discussion`
|
||||
*/
|
||||
@ -27,7 +27,7 @@ export default class DiscussionHero extends Component {
|
||||
*/
|
||||
items() {
|
||||
const items = new ItemList();
|
||||
const discussion = this.props.discussion;
|
||||
const discussion = this.attrs.discussion;
|
||||
const badges = discussion.badges().toArray();
|
||||
|
||||
if (badges.length) {
|
||||
|
@ -7,17 +7,13 @@ import Placeholder from '../../common/components/Placeholder';
|
||||
/**
|
||||
* The `DiscussionList` component displays a list of discussions.
|
||||
*
|
||||
* ### Props
|
||||
* ### Attrs
|
||||
*
|
||||
* - `state` A DiscussionListState object that represents the discussion lists's state.
|
||||
*/
|
||||
export default class DiscussionList extends Component {
|
||||
init() {
|
||||
this.state = this.props.state;
|
||||
}
|
||||
|
||||
view() {
|
||||
const state = this.state;
|
||||
const state = this.attrs.state;
|
||||
|
||||
const params = state.getParams();
|
||||
let loading;
|
||||
@ -25,11 +21,13 @@ export default class DiscussionList extends Component {
|
||||
if (state.isLoading()) {
|
||||
loading = LoadingIndicator.component();
|
||||
} else if (state.moreResults) {
|
||||
loading = Button.component({
|
||||
children: app.translator.trans('core.forum.discussion_list.load_more_button'),
|
||||
loading = Button.component(
|
||||
{
|
||||
className: 'Button',
|
||||
onclick: state.loadMore.bind(state),
|
||||
});
|
||||
},
|
||||
app.translator.trans('core.forum.discussion_list.load_more_button')
|
||||
);
|
||||
}
|
||||
|
||||
if (state.empty()) {
|
||||
|
@ -8,7 +8,6 @@ import ItemList from '../../common/utils/ItemList';
|
||||
import abbreviateNumber from '../../common/utils/abbreviateNumber';
|
||||
import Dropdown from '../../common/components/Dropdown';
|
||||
import TerminalPost from './TerminalPost';
|
||||
import PostPreview from './PostPreview';
|
||||
import SubtreeRetainer from '../../common/utils/SubtreeRetainer';
|
||||
import DiscussionControls from '../utils/DiscussionControls';
|
||||
import slidable from '../utils/slidable';
|
||||
@ -20,13 +19,15 @@ import { escapeRegExp } from 'lodash-es';
|
||||
* The `DiscussionListItem` component shows a single discussion in the
|
||||
* discussion list.
|
||||
*
|
||||
* ### Props
|
||||
* ### Attrs
|
||||
*
|
||||
* - `discussion`
|
||||
* - `params`
|
||||
*/
|
||||
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
|
||||
* unless new data comes in.
|
||||
@ -34,7 +35,7 @@ export default class DiscussionListItem extends Component {
|
||||
* @type {SubtreeRetainer}
|
||||
*/
|
||||
this.subtree = new SubtreeRetainer(
|
||||
() => this.props.discussion.freshness,
|
||||
() => this.attrs.discussion.freshness,
|
||||
() => {
|
||||
const time = app.session.user && app.session.user.markedAllAsReadAt();
|
||||
return time && time.getTime();
|
||||
@ -43,37 +44,33 @@ export default class DiscussionListItem extends Component {
|
||||
);
|
||||
}
|
||||
|
||||
attrs() {
|
||||
elementAttrs() {
|
||||
return {
|
||||
className: classList([
|
||||
'DiscussionListItem',
|
||||
this.active() ? 'active' : '',
|
||||
this.props.discussion.isHidden() ? 'DiscussionListItem--hidden' : '',
|
||||
this.attrs.discussion.isHidden() ? 'DiscussionListItem--hidden' : '',
|
||||
]),
|
||||
};
|
||||
}
|
||||
|
||||
view() {
|
||||
const retain = this.subtree.retain();
|
||||
|
||||
if (retain) return retain;
|
||||
|
||||
const discussion = this.props.discussion;
|
||||
const discussion = this.attrs.discussion;
|
||||
const user = discussion.user();
|
||||
const isUnread = discussion.isUnread();
|
||||
const isRead = discussion.isRead();
|
||||
const showUnread = !this.showRepliesCount() && isUnread;
|
||||
let jumpTo = 0;
|
||||
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();
|
||||
if (post) {
|
||||
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');
|
||||
} else {
|
||||
jumpTo = Math.min(discussion.lastPostNumber(), (discussion.lastReadPostNumber() || 0) + 1);
|
||||
@ -82,12 +79,14 @@ export default class DiscussionListItem extends Component {
|
||||
return (
|
||||
<div {...attrs}>
|
||||
{controls.length
|
||||
? Dropdown.component({
|
||||
? Dropdown.component(
|
||||
{
|
||||
icon: 'fas fa-ellipsis-v',
|
||||
children: controls,
|
||||
className: 'DiscussionListItem-controls',
|
||||
buttonClassName: 'Button Button--icon Button--flat Slidable-underneath Slidable-underneath--right',
|
||||
})
|
||||
},
|
||||
controls
|
||||
)
|
||||
: ''}
|
||||
|
||||
<a
|
||||
@ -99,14 +98,13 @@ export default class DiscussionListItem extends Component {
|
||||
|
||||
<div className={'DiscussionListItem-content Slidable-content' + (isUnread ? ' unread' : '') + (isRead ? ' read' : '')}>
|
||||
<a
|
||||
href={user ? app.route.user(user) : '#'}
|
||||
route={user ? app.route.user(user) : '#'}
|
||||
className="DiscussionListItem-author"
|
||||
title={extractText(
|
||||
app.translator.trans('core.forum.discussion_list.started_text', { user: user, ago: humanTime(discussion.createdAt()) })
|
||||
)}
|
||||
config={function (element) {
|
||||
$(element).tooltip({ placement: 'right' });
|
||||
m.route.apply(this, arguments);
|
||||
oncreate={function (vnode) {
|
||||
$(vnode.dom).tooltip({ placement: 'right' });
|
||||
}}
|
||||
>
|
||||
{avatar(user, { title: '' })}
|
||||
@ -114,7 +112,7 @@ export default class DiscussionListItem extends Component {
|
||||
|
||||
<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>
|
||||
<ul className="DiscussionListItem-info">{listItems(this.infoItems().toArray())}</ul>
|
||||
</a>
|
||||
@ -131,8 +129,8 @@ export default class DiscussionListItem extends Component {
|
||||
);
|
||||
}
|
||||
|
||||
config(isInitialized) {
|
||||
if (isInitialized) return;
|
||||
oncreate(vnode) {
|
||||
super.oncreate(vnode);
|
||||
|
||||
// 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
|
||||
@ -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.
|
||||
*
|
||||
@ -152,7 +156,7 @@ export default class DiscussionListItem extends Component {
|
||||
active() {
|
||||
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}
|
||||
*/
|
||||
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}
|
||||
*/
|
||||
showRepliesCount() {
|
||||
return this.props.params.sort === 'replies';
|
||||
return this.attrs.params.sort === 'replies';
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark the discussion as read.
|
||||
*/
|
||||
markAsRead() {
|
||||
const discussion = this.props.discussion;
|
||||
const discussion = this.attrs.discussion;
|
||||
|
||||
if (discussion.isUnread()) {
|
||||
discussion.save({ lastReadPostNumber: discussion.lastPostNumber() });
|
||||
@ -197,8 +201,8 @@ export default class DiscussionListItem extends Component {
|
||||
infoItems() {
|
||||
const items = new ItemList();
|
||||
|
||||
if (this.props.params.q) {
|
||||
const post = this.props.discussion.mostRelevantPost() || this.props.discussion.firstPost();
|
||||
if (this.attrs.params.q) {
|
||||
const post = this.attrs.discussion.mostRelevantPost() || this.attrs.discussion.firstPost();
|
||||
|
||||
if (post && post.contentType() === 'comment') {
|
||||
const excerpt = highlight(post.contentPlain(), this.highlightRegExp, 175);
|
||||
@ -208,7 +212,7 @@ export default class DiscussionListItem extends Component {
|
||||
items.add(
|
||||
'terminalPost',
|
||||
TerminalPost.component({
|
||||
discussion: this.props.discussion,
|
||||
discussion: this.attrs.discussion,
|
||||
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 ItemList from '../../common/utils/ItemList';
|
||||
import DiscussionHero from './DiscussionHero';
|
||||
import DiscussionListPane from './DiscussionListPane';
|
||||
import PostStream from './PostStream';
|
||||
import PostStreamScrubber from './PostStreamScrubber';
|
||||
import LoadingIndicator from '../../common/components/LoadingIndicator';
|
||||
import SplitDropdown from '../../common/components/SplitDropdown';
|
||||
import listItems from '../../common/helpers/listItems';
|
||||
import DiscussionControls from '../utils/DiscussionControls';
|
||||
import DiscussionList from './DiscussionList';
|
||||
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.
|
||||
*/
|
||||
export default class DiscussionPage extends Page {
|
||||
init() {
|
||||
super.init();
|
||||
oninit(vnode) {
|
||||
super.oninit(vnode);
|
||||
|
||||
/**
|
||||
* The discussion that is being viewed.
|
||||
@ -42,38 +42,16 @@ export default class DiscussionPage extends Page {
|
||||
if (app.discussions.hasDiscussions()) {
|
||||
app.pane.enable();
|
||||
app.pane.hide();
|
||||
|
||||
if (app.previous.matches(DiscussionPage)) {
|
||||
m.redraw.strategy('diff');
|
||||
}
|
||||
}
|
||||
|
||||
app.history.push('discussion');
|
||||
|
||||
this.bodyClass = 'App--discussion';
|
||||
|
||||
this.prevRoute = m.route.get();
|
||||
}
|
||||
|
||||
onunload(e) {
|
||||
// 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;
|
||||
}
|
||||
}
|
||||
|
||||
onremove() {
|
||||
// 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, minimize the composer – unless it's empty, in which case
|
||||
@ -92,14 +70,7 @@ export default class DiscussionPage extends Page {
|
||||
|
||||
return (
|
||||
<div className="DiscussionPage">
|
||||
{app.discussions.hasDiscussions() ? (
|
||||
<div className="DiscussionPage-list" config={this.configPane.bind(this)}>
|
||||
{!$('.App-navigation').is(':visible') && <DiscussionList state={app.discussions} />}
|
||||
</div>
|
||||
) : (
|
||||
''
|
||||
)}
|
||||
|
||||
<DiscussionListPane state={app.discussions} />
|
||||
<div className="DiscussionPage-discussion">
|
||||
{discussion
|
||||
? [
|
||||
@ -124,11 +95,30 @@ export default class DiscussionPage extends Page {
|
||||
);
|
||||
}
|
||||
|
||||
config(...args) {
|
||||
super.config(...args);
|
||||
onbeforeupdate(vnode) {
|
||||
super.onbeforeupdate(vnode);
|
||||
|
||||
if (m.route.get() !== this.prevRoute) {
|
||||
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) {
|
||||
app.setTitle(this.discussion.title());
|
||||
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));
|
||||
}
|
||||
|
||||
m.lazyRedraw();
|
||||
m.redraw();
|
||||
}
|
||||
|
||||
/**
|
||||
@ -173,6 +163,7 @@ export default class DiscussionPage extends Page {
|
||||
this.discussion = discussion;
|
||||
|
||||
app.history.push('discussion', discussion.title());
|
||||
app.setTitle(this.discussion.title());
|
||||
app.setTitleCount(0);
|
||||
|
||||
// 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);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*
|
||||
@ -261,12 +210,14 @@ export default class DiscussionPage extends Page {
|
||||
|
||||
items.add(
|
||||
'controls',
|
||||
SplitDropdown.component({
|
||||
children: DiscussionControls.controls(this.discussion, this).toArray(),
|
||||
SplitDropdown.component(
|
||||
{
|
||||
icon: 'fas fa-ellipsis-v',
|
||||
className: 'App-primaryControl',
|
||||
buttonClassName: 'Button--primary',
|
||||
})
|
||||
},
|
||||
DiscussionControls.controls(this.discussion, this).toArray()
|
||||
)
|
||||
);
|
||||
|
||||
items.add(
|
||||
@ -295,7 +246,8 @@ export default class DiscussionPage extends Page {
|
||||
// replace it into the window's history and our own history stack.
|
||||
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);
|
||||
|
||||
app.history.push('discussion', discussion.title());
|
||||
|
@ -4,9 +4,9 @@ import Notification from './Notification';
|
||||
* The `DiscussionRenamedNotification` component displays a notification which
|
||||
* 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 {
|
||||
icon() {
|
||||
@ -14,12 +14,12 @@ export default class DiscussionRenamedNotification extends Notification {
|
||||
}
|
||||
|
||||
href() {
|
||||
const notification = this.props.notification;
|
||||
const notification = this.attrs.notification;
|
||||
|
||||
return app.route.discussion(notification.subject(), notification.content().postNumber);
|
||||
}
|
||||
|
||||
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
|
||||
* 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 {
|
||||
icon() {
|
||||
@ -22,7 +22,7 @@ export default class DiscussionRenamedPost extends EventPost {
|
||||
}
|
||||
|
||||
descriptionData() {
|
||||
const post = this.props.post;
|
||||
const post = this.attrs.post;
|
||||
const oldTitle = post.content()[0];
|
||||
const newTitle = post.content()[1];
|
||||
|
||||
|
@ -34,18 +34,20 @@ export default class DiscussionsSearchSource {
|
||||
return [
|
||||
<li className="Dropdown-header">{app.translator.trans('core.forum.search.discussions_heading')}</li>,
|
||||
<li>
|
||||
{LinkButton.component({
|
||||
{LinkButton.component(
|
||||
{
|
||||
icon: 'fas fa-search',
|
||||
children: app.translator.trans('core.forum.search.all_discussions_button', { query }),
|
||||
href: app.route('index', { q: query }),
|
||||
})}
|
||||
},
|
||||
app.translator.trans('core.forum.search.all_discussions_button', { query })
|
||||
)}
|
||||
</li>,
|
||||
results.map((discussion) => {
|
||||
const mostRelevantPost = discussion.mostRelevantPost();
|
||||
|
||||
return (
|
||||
<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>
|
||||
{mostRelevantPost ? <div className="DiscussionSearchResult-excerpt">{highlight(mostRelevantPost.contentPlain(), query, 100)}</div> : ''}
|
||||
</a>
|
||||
|
@ -7,8 +7,8 @@ import DiscussionListState from '../states/DiscussionListState';
|
||||
* page.
|
||||
*/
|
||||
export default class DiscussionsUserPage extends UserPage {
|
||||
init() {
|
||||
super.init();
|
||||
oninit(vnode) {
|
||||
super.oninit(vnode);
|
||||
|
||||
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
|
||||
* 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`
|
||||
*/
|
||||
export default class EditPostComposer extends ComposerBody {
|
||||
static initProps(props) {
|
||||
super.initProps(props);
|
||||
static initAttrs(attrs) {
|
||||
super.initAttrs(attrs);
|
||||
|
||||
props.submitLabel = props.submitLabel || app.translator.trans('core.forum.composer_edit.submit_button');
|
||||
props.confirmExit = props.confirmExit || app.translator.trans('core.forum.composer_edit.discard_confirmation');
|
||||
props.originalContent = props.originalContent || props.post.content();
|
||||
props.user = props.user || props.post.user();
|
||||
attrs.submitLabel = attrs.submitLabel || app.translator.trans('core.forum.composer_edit.submit_button');
|
||||
attrs.confirmExit = attrs.confirmExit || app.translator.trans('core.forum.composer_edit.discard_confirmation');
|
||||
attrs.originalContent = attrs.originalContent || attrs.post.content();
|
||||
attrs.user = attrs.user || attrs.post.user();
|
||||
|
||||
props.post.editedContent = props.originalContent;
|
||||
attrs.post.editedContent = attrs.originalContent;
|
||||
}
|
||||
|
||||
headerItems() {
|
||||
const items = super.headerItems();
|
||||
const post = this.props.post;
|
||||
|
||||
const routeAndMinimize = function (element, isInitialized) {
|
||||
if (isInitialized) return;
|
||||
$(element).on('click', minimizeComposerIfFullScreen);
|
||||
m.route.apply(this, arguments);
|
||||
};
|
||||
const post = this.attrs.post;
|
||||
|
||||
items.add(
|
||||
'title',
|
||||
<h3>
|
||||
{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() })}
|
||||
</a>
|
||||
</h3>
|
||||
@ -60,7 +54,7 @@ export default class EditPostComposer extends ComposerBody {
|
||||
jumpToPreview(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() {
|
||||
const discussion = this.props.post.discussion();
|
||||
const discussion = this.attrs.post.discussion();
|
||||
|
||||
this.loading = true;
|
||||
|
||||
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
|
||||
// in, then we can scroll to the post.
|
||||
if (app.viewingDiscussion(discussion)) {
|
||||
@ -91,19 +85,23 @@ export default class EditPostComposer extends ComposerBody {
|
||||
// their edit has been made, containing a button which will
|
||||
// transition to their edited post when clicked.
|
||||
let alert;
|
||||
const viewButton = Button.component({
|
||||
const viewButton = Button.component(
|
||||
{
|
||||
className: 'Button Button--link',
|
||||
children: app.translator.trans('core.forum.composer_edit.view_button'),
|
||||
onclick: () => {
|
||||
m.route(app.route.post(post));
|
||||
m.route.set(app.route.post(post));
|
||||
app.alerts.dismiss(alert);
|
||||
},
|
||||
});
|
||||
alert = app.alerts.show({
|
||||
},
|
||||
app.translator.trans('core.forum.composer_edit.view_button')
|
||||
);
|
||||
alert = app.alerts.show(
|
||||
{
|
||||
type: 'success',
|
||||
children: app.translator.trans('core.forum.composer_edit.edited_message'),
|
||||
controls: [viewButton],
|
||||
});
|
||||
},
|
||||
app.translator.trans('core.forum.composer_edit.edited_message')
|
||||
);
|
||||
}
|
||||
|
||||
this.composer.hide();
|
||||
|
@ -9,22 +9,22 @@ import ItemList from '../../common/utils/ItemList';
|
||||
* The `EditUserModal` component displays a modal dialog with a login form.
|
||||
*/
|
||||
export default class EditUserModal extends Modal {
|
||||
init() {
|
||||
super.init();
|
||||
oninit(vnode) {
|
||||
super.oninit(vnode);
|
||||
|
||||
const user = this.props.user;
|
||||
const user = this.attrs.user;
|
||||
|
||||
this.username = m.prop(user.username() || '');
|
||||
this.email = m.prop(user.email() || '');
|
||||
this.isEmailConfirmed = m.prop(user.isEmailConfirmed() || false);
|
||||
this.setPassword = m.prop(false);
|
||||
this.password = m.prop(user.password() || '');
|
||||
this.username = m.stream(user.username() || '');
|
||||
this.email = m.stream(user.email() || '');
|
||||
this.isEmailConfirmed = m.stream(user.isEmailConfirmed() || false);
|
||||
this.setPassword = m.stream(false);
|
||||
this.password = m.stream(user.password() || '');
|
||||
this.groups = {};
|
||||
|
||||
app.store
|
||||
.all('groups')
|
||||
.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() {
|
||||
@ -55,7 +55,7 @@ export default class EditUserModal extends Modal {
|
||||
40
|
||||
);
|
||||
|
||||
if (app.session.user !== this.props.user) {
|
||||
if (app.session.user !== this.attrs.user) {
|
||||
items.add(
|
||||
'email',
|
||||
<div className="Form-group">
|
||||
@ -65,12 +65,14 @@ export default class EditUserModal extends Modal {
|
||||
</div>
|
||||
{!this.isEmailConfirmed() ? (
|
||||
<div>
|
||||
{Button.component({
|
||||
{Button.component(
|
||||
{
|
||||
className: 'Button Button--block',
|
||||
children: app.translator.trans('core.forum.edit_user.activate_button'),
|
||||
loading: this.loading,
|
||||
onclick: this.activate.bind(this),
|
||||
})}
|
||||
},
|
||||
app.translator.trans('core.forum.edit_user.activate_button')
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
''
|
||||
@ -89,9 +91,9 @@ export default class EditUserModal extends Modal {
|
||||
type="checkbox"
|
||||
onchange={(e) => {
|
||||
this.setPassword(e.target.checked);
|
||||
m.redraw(true);
|
||||
m.redraw.sync();
|
||||
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')}
|
||||
@ -125,7 +127,7 @@ export default class EditUserModal extends Modal {
|
||||
<input
|
||||
type="checkbox"
|
||||
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()}
|
||||
</label>
|
||||
@ -138,12 +140,14 @@ export default class EditUserModal extends Modal {
|
||||
items.add(
|
||||
'submit',
|
||||
<div className="Form-group">
|
||||
{Button.component({
|
||||
{Button.component(
|
||||
{
|
||||
className: 'Button Button--primary',
|
||||
type: 'submit',
|
||||
loading: this.loading,
|
||||
children: app.translator.trans('core.forum.edit_user.submit_button'),
|
||||
})}
|
||||
},
|
||||
app.translator.trans('core.forum.edit_user.submit_button')
|
||||
)}
|
||||
</div>,
|
||||
-10
|
||||
);
|
||||
@ -157,7 +161,7 @@ export default class EditUserModal extends Modal {
|
||||
username: this.username(),
|
||||
isEmailConfirmed: true,
|
||||
};
|
||||
this.props.user
|
||||
this.attrs.user
|
||||
.save(data, { errorHandler: this.onerror.bind(this) })
|
||||
.then(() => {
|
||||
this.isEmailConfirmed(true);
|
||||
@ -180,7 +184,7 @@ export default class EditUserModal extends Modal {
|
||||
relationships: { groups },
|
||||
};
|
||||
|
||||
if (app.session.user !== this.props.user) {
|
||||
if (app.session.user !== this.attrs.user) {
|
||||
data.email = this.email();
|
||||
}
|
||||
|
||||
@ -196,7 +200,7 @@ export default class EditUserModal extends Modal {
|
||||
|
||||
this.loading = true;
|
||||
|
||||
this.props.user
|
||||
this.attrs.user
|
||||
.save(this.data(), { errorHandler: this.onerror.bind(this) })
|
||||
.then(this.hide.bind(this))
|
||||
.catch(() => {
|
||||
|
@ -8,28 +8,28 @@ import icon from '../../common/helpers/icon';
|
||||
* event, like a discussion being renamed or stickied. Subclasses must implement
|
||||
* the `icon` and `description` methods.
|
||||
*
|
||||
* ### Props
|
||||
* ### Attrs
|
||||
*
|
||||
* - All of the props for `Post`
|
||||
* - All of the attrs for `Post`
|
||||
*
|
||||
* @abstract
|
||||
*/
|
||||
export default class EventPost extends Post {
|
||||
attrs() {
|
||||
const attrs = super.attrs();
|
||||
elementAttrs() {
|
||||
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;
|
||||
}
|
||||
|
||||
content() {
|
||||
const user = this.props.post.user();
|
||||
const user = this.attrs.post.user();
|
||||
const username = usernameHelper(user);
|
||||
const data = Object.assign(this.descriptionData(), {
|
||||
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}
|
||||
</a>
|
||||
) : (
|
||||
|
@ -1,5 +1,4 @@
|
||||
import Modal from '../../common/components/Modal';
|
||||
import Alert from '../../common/components/Alert';
|
||||
import Button from '../../common/components/Button';
|
||||
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
|
||||
* enter their email address and request a link to reset their password.
|
||||
*
|
||||
* ### Props
|
||||
* ### Attrs
|
||||
*
|
||||
* - `email`
|
||||
*/
|
||||
export default class ForgotPasswordModal extends Modal {
|
||||
init() {
|
||||
super.init();
|
||||
oninit(vnode) {
|
||||
super.oninit(vnode);
|
||||
|
||||
/**
|
||||
* The value of the email input.
|
||||
*
|
||||
* @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.
|
||||
@ -64,18 +63,19 @@ export default class ForgotPasswordModal extends Modal {
|
||||
name="email"
|
||||
type="email"
|
||||
placeholder={extractText(app.translator.trans('core.forum.forgot_password.email_placeholder'))}
|
||||
value={this.email()}
|
||||
onchange={m.withAttr('value', this.email)}
|
||||
bidi={this.email}
|
||||
disabled={this.loading}
|
||||
/>
|
||||
</div>
|
||||
<div className="Form-group">
|
||||
{Button.component({
|
||||
{Button.component(
|
||||
{
|
||||
className: 'Button Button--primary Button--block',
|
||||
type: 'submit',
|
||||
loading: this.loading,
|
||||
children: app.translator.trans('core.forum.forgot_password.submit_button'),
|
||||
})}
|
||||
},
|
||||
app.translator.trans('core.forum.forgot_password.submit_button')
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -91,7 +91,7 @@ export default class ForgotPasswordModal extends Modal {
|
||||
.request({
|
||||
method: 'POST',
|
||||
url: app.forum.attribute('apiUrl') + '/forgot',
|
||||
data: { email: this.email() },
|
||||
body: { email: this.email() },
|
||||
errorHandler: this.onerror.bind(this),
|
||||
})
|
||||
.then(() => {
|
||||
@ -104,7 +104,7 @@ export default class ForgotPasswordModal extends Modal {
|
||||
|
||||
onerror(error) {
|
||||
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);
|
||||
|
@ -11,13 +11,6 @@ export default class HeaderPrimary extends Component {
|
||||
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.
|
||||
*
|
||||
|
@ -19,13 +19,6 @@ export default class HeaderSecondary extends Component {
|
||||
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.
|
||||
*
|
||||
@ -41,9 +34,9 @@ export default class HeaderSecondary extends Component {
|
||||
|
||||
for (const locale in app.data.locales) {
|
||||
locales.push(
|
||||
Button.component({
|
||||
Button.component(
|
||||
{
|
||||
active: app.data.locale === locale,
|
||||
children: app.data.locales[locale],
|
||||
icon: app.data.locale === locale ? 'fas fa-check' : true,
|
||||
onclick: () => {
|
||||
if (app.session.user) {
|
||||
@ -53,16 +46,20 @@ export default class HeaderSecondary extends Component {
|
||||
window.location.reload();
|
||||
}
|
||||
},
|
||||
})
|
||||
},
|
||||
app.data.locales[locale]
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
items.add(
|
||||
'locale',
|
||||
SelectDropdown.component({
|
||||
children: locales,
|
||||
SelectDropdown.component(
|
||||
{
|
||||
buttonClassName: 'Button Button--link',
|
||||
}),
|
||||
},
|
||||
locales
|
||||
),
|
||||
20
|
||||
);
|
||||
}
|
||||
@ -74,22 +71,26 @@ export default class HeaderSecondary extends Component {
|
||||
if (app.forum.attribute('allowSignUp')) {
|
||||
items.add(
|
||||
'signUp',
|
||||
Button.component({
|
||||
children: app.translator.trans('core.forum.header.sign_up_link'),
|
||||
Button.component(
|
||||
{
|
||||
className: 'Button Button--link',
|
||||
onclick: () => app.modal.show(SignUpModal),
|
||||
}),
|
||||
},
|
||||
app.translator.trans('core.forum.header.sign_up_link')
|
||||
),
|
||||
10
|
||||
);
|
||||
}
|
||||
|
||||
items.add(
|
||||
'logIn',
|
||||
Button.component({
|
||||
children: app.translator.trans('core.forum.header.log_in_link'),
|
||||
Button.component(
|
||||
{
|
||||
className: 'Button Button--link',
|
||||
onclick: () => app.modal.show(LogInModal),
|
||||
}),
|
||||
},
|
||||
app.translator.trans('core.forum.header.log_in_link')
|
||||
),
|
||||
0
|
||||
);
|
||||
}
|
||||
|
@ -19,8 +19,8 @@ import SelectDropdown from '../../common/components/SelectDropdown';
|
||||
export default class IndexPage extends Page {
|
||||
static providesInitialSearch = true;
|
||||
|
||||
init() {
|
||||
super.init();
|
||||
oninit(vnode) {
|
||||
super.oninit(vnode);
|
||||
|
||||
// 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
|
||||
@ -42,12 +42,26 @@ export default class IndexPage extends Page {
|
||||
app.history.push('index', app.translator.trans('core.forum.header.back_to_index_tooltip'));
|
||||
|
||||
this.bodyClass = 'App--index';
|
||||
|
||||
this.currentPath = m.route.get();
|
||||
}
|
||||
|
||||
onunload() {
|
||||
// Save the scroll position so we can restore it when we return to the
|
||||
// discussion list.
|
||||
app.cache.scrollTop = $(window).scrollTop();
|
||||
onbeforeupdate(vnode) {
|
||||
super.onbeforeupdate(vnode);
|
||||
|
||||
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() {
|
||||
@ -72,15 +86,15 @@ export default class IndexPage extends Page {
|
||||
);
|
||||
}
|
||||
|
||||
config(isInitialized, context) {
|
||||
super.config(...arguments);
|
||||
|
||||
if (isInitialized) return;
|
||||
|
||||
extend(context, 'onunload', () => $('#app').css('min-height', ''));
|
||||
|
||||
setTitle() {
|
||||
app.setTitle(app.translator.trans('core.forum.index.meta_title_text'));
|
||||
app.setTitleCount(0);
|
||||
}
|
||||
|
||||
oncreate(vnode) {
|
||||
super.oncreate(vnode);
|
||||
|
||||
this.setTitle();
|
||||
|
||||
// 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
|
||||
@ -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.
|
||||
*
|
||||
@ -139,25 +163,31 @@ export default class IndexPage extends Page {
|
||||
|
||||
items.add(
|
||||
'newDiscussion',
|
||||
Button.component({
|
||||
children: app.translator.trans(
|
||||
canStartDiscussion ? 'core.forum.index.start_discussion_button' : 'core.forum.index.cannot_start_discussion_button'
|
||||
),
|
||||
Button.component(
|
||||
{
|
||||
icon: 'fas fa-edit',
|
||||
className: 'Button Button--primary IndexPage-newDiscussion',
|
||||
itemClassName: 'App-primaryControl',
|
||||
onclick: this.newDiscussionAction.bind(this),
|
||||
onclick: () => {
|
||||
// If the user is not logged in, the promise rejects, and a login modal shows up.
|
||||
// Since that's already handled, we dont need to show an error message in the console.
|
||||
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(
|
||||
'nav',
|
||||
SelectDropdown.component({
|
||||
children: this.navItems(this).toArray(),
|
||||
SelectDropdown.component(
|
||||
{
|
||||
buttonClassName: 'Button',
|
||||
className: 'App-titleControl',
|
||||
})
|
||||
},
|
||||
this.navItems(this).toArray()
|
||||
)
|
||||
);
|
||||
|
||||
return items;
|
||||
@ -175,11 +205,13 @@ export default class IndexPage extends Page {
|
||||
|
||||
items.add(
|
||||
'allDiscussions',
|
||||
LinkButton.component({
|
||||
LinkButton.component(
|
||||
{
|
||||
href: app.route('index', params),
|
||||
children: app.translator.trans('core.forum.index.all_discussions_link'),
|
||||
icon: 'far fa-comments',
|
||||
}),
|
||||
},
|
||||
app.translator.trans('core.forum.index.all_discussions_link')
|
||||
),
|
||||
100
|
||||
);
|
||||
|
||||
@ -204,21 +236,25 @@ export default class IndexPage extends Page {
|
||||
|
||||
items.add(
|
||||
'sort',
|
||||
Dropdown.component({
|
||||
Dropdown.component(
|
||||
{
|
||||
buttonClassName: 'Button',
|
||||
label: sortOptions[app.search.params().sort] || Object.keys(sortMap).map((key) => sortOptions[key])[0],
|
||||
children: Object.keys(sortOptions).map((value) => {
|
||||
},
|
||||
Object.keys(sortOptions).map((value) => {
|
||||
const label = sortOptions[value];
|
||||
const active = (app.search.params().sort || Object.keys(sortMap)[0]) === value;
|
||||
|
||||
return Button.component({
|
||||
children: label,
|
||||
return Button.component(
|
||||
{
|
||||
icon: active ? 'fas fa-check' : true,
|
||||
onclick: app.search.changeSort.bind(app.search, value),
|
||||
active: active,
|
||||
});
|
||||
}),
|
||||
},
|
||||
label
|
||||
);
|
||||
})
|
||||
)
|
||||
);
|
||||
|
||||
return items;
|
||||
@ -270,20 +306,18 @@ export default class IndexPage extends Page {
|
||||
* @return {Promise}
|
||||
*/
|
||||
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();
|
||||
|
||||
deferred.resolve(app.composer);
|
||||
return resolve(app.composer);
|
||||
} else {
|
||||
deferred.reject();
|
||||
|
||||
app.modal.show(LogInModal);
|
||||
}
|
||||
|
||||
return deferred.promise;
|
||||
return reject();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -4,21 +4,21 @@ import Button from '../../common/components/Button';
|
||||
* The `LogInButton` component displays a social login button which will open
|
||||
* a popup window containing the specified path.
|
||||
*
|
||||
* ### Props
|
||||
* ### Attrs
|
||||
*
|
||||
* - `path`
|
||||
*/
|
||||
export default class LogInButton extends Button {
|
||||
static initProps(props) {
|
||||
props.className = (props.className || '') + ' LogInButton';
|
||||
static initAttrs(attrs) {
|
||||
attrs.className = (attrs.className || '') + ' LogInButton';
|
||||
|
||||
props.onclick = function () {
|
||||
attrs.onclick = function () {
|
||||
const width = 580;
|
||||
const height = 400;
|
||||
const $window = $(window);
|
||||
|
||||
window.open(
|
||||
app.forum.attribute('baseUrl') + props.path,
|
||||
app.forum.attribute('baseUrl') + attrs.path,
|
||||
'logInPopup',
|
||||
`width=${width},` +
|
||||
`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.
|
||||
*
|
||||
* ### Props
|
||||
* ### Attrs
|
||||
*
|
||||
* - `identification`
|
||||
* - `password`
|
||||
*/
|
||||
export default class LogInModal extends Modal {
|
||||
init() {
|
||||
super.init();
|
||||
oninit(vnode) {
|
||||
super.oninit(vnode);
|
||||
|
||||
/**
|
||||
* The value of the identification input.
|
||||
*
|
||||
* @type {Function}
|
||||
*/
|
||||
this.identification = m.prop(this.props.identification || '');
|
||||
this.identification = m.stream(this.attrs.identification || '');
|
||||
|
||||
/**
|
||||
* The value of the password input.
|
||||
*
|
||||
* @type {Function}
|
||||
*/
|
||||
this.password = m.prop(this.props.password || '');
|
||||
this.password = m.stream(this.attrs.password || '');
|
||||
|
||||
/**
|
||||
* The value of the remember me input.
|
||||
*
|
||||
* @type {Function}
|
||||
*/
|
||||
this.remember = m.prop(!!this.props.remember);
|
||||
this.remember = m.stream(!!this.attrs.remember);
|
||||
}
|
||||
|
||||
className() {
|
||||
@ -105,12 +105,14 @@ export default class LogInModal extends Modal {
|
||||
items.add(
|
||||
'submit',
|
||||
<div className="Form-group">
|
||||
{Button.component({
|
||||
{Button.component(
|
||||
{
|
||||
className: 'Button Button--primary Button--block',
|
||||
type: 'submit',
|
||||
loading: this.loading,
|
||||
children: app.translator.trans('core.forum.log_in.submit_button'),
|
||||
})}
|
||||
},
|
||||
app.translator.trans('core.forum.log_in.submit_button')
|
||||
)}
|
||||
</div>,
|
||||
-10
|
||||
);
|
||||
@ -140,9 +142,9 @@ export default class LogInModal extends Modal {
|
||||
*/
|
||||
forgotPassword() {
|
||||
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
|
||||
*/
|
||||
signUp() {
|
||||
const props = { password: this.password() };
|
||||
const attrs = { password: this.password() };
|
||||
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() {
|
||||
@ -179,7 +181,7 @@ export default class LogInModal extends Modal {
|
||||
|
||||
onerror(error) {
|
||||
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);
|
||||
|
@ -8,7 +8,7 @@ import Button from '../../common/components/Button';
|
||||
* The `Notification` component abstract displays a single notification.
|
||||
* Subclasses should implement the `icon`, `href`, and `content` methods.
|
||||
*
|
||||
* ### Props
|
||||
* ### Attrs
|
||||
*
|
||||
* - `notification`
|
||||
*
|
||||
@ -16,18 +16,17 @@ import Button from '../../common/components/Button';
|
||||
*/
|
||||
export default class Notification extends Component {
|
||||
view() {
|
||||
const notification = this.props.notification;
|
||||
const notification = this.attrs.notification;
|
||||
const href = this.href();
|
||||
|
||||
const linkAttrs = {};
|
||||
linkAttrs[href.indexOf('://') === -1 ? 'route' : 'href'] = href;
|
||||
|
||||
return (
|
||||
<a
|
||||
className={'Notification Notification--' + notification.contentType() + ' ' + (!notification.isRead() ? 'unread' : '')}
|
||||
href={href}
|
||||
config={function (element, isInitialized) {
|
||||
if (href.indexOf('://') === -1) m.route.apply(this, arguments);
|
||||
|
||||
if (!isInitialized) $(element).click(this.markAsRead.bind(this));
|
||||
}}
|
||||
{...linkAttrs}
|
||||
onclick={this.markAsRead.bind(this)}
|
||||
>
|
||||
{!notification.isRead() &&
|
||||
Button.component({
|
||||
@ -86,10 +85,10 @@ export default class Notification extends Component {
|
||||
* Mark the notification as read.
|
||||
*/
|
||||
markAsRead() {
|
||||
if (this.props.notification.isRead()) return;
|
||||
if (this.attrs.notification.isRead()) return;
|
||||
|
||||
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
|
||||
* methods, allowing the user to toggle each combination.
|
||||
*
|
||||
* ### Props
|
||||
* ### Attrs
|
||||
*
|
||||
* - `user`
|
||||
*/
|
||||
export default class NotificationGrid extends Component {
|
||||
init() {
|
||||
oninit(vnode) {
|
||||
super.oninit(vnode);
|
||||
|
||||
/**
|
||||
* Information about the available notification methods.
|
||||
*
|
||||
@ -36,7 +38,7 @@ export default class NotificationGrid extends Component {
|
||||
}
|
||||
|
||||
view() {
|
||||
const preferences = this.props.user.preferences();
|
||||
const preferences = this.attrs.user.preferences();
|
||||
|
||||
return (
|
||||
<table className="NotificationGrid">
|
||||
@ -62,12 +64,12 @@ export default class NotificationGrid extends Component {
|
||||
|
||||
return (
|
||||
<td className="NotificationGrid-checkbox">
|
||||
{Checkbox.component({
|
||||
state: !!preferences[key],
|
||||
loading: this.loading[key],
|
||||
disabled: !(key in preferences),
|
||||
onchange: () => this.toggle([key]),
|
||||
})}
|
||||
<Checkbox
|
||||
state={!!preferences[key]}
|
||||
loading={this.loading[key]}
|
||||
disabled={!(key in preferences)}
|
||||
onchange={this.toggle.bind(this, [key])}
|
||||
/>
|
||||
</td>
|
||||
);
|
||||
})}
|
||||
@ -78,8 +80,8 @@ export default class NotificationGrid extends Component {
|
||||
);
|
||||
}
|
||||
|
||||
config(isInitialized) {
|
||||
if (isInitialized) return;
|
||||
oncreate(vnode) {
|
||||
super.oncreate(vnode);
|
||||
|
||||
this.$('thead .NotificationGrid-groupToggle').bind('mouseenter mouseleave', function (e) {
|
||||
const i = parseInt($(this).index(), 10) + 1;
|
||||
@ -104,7 +106,7 @@ export default class NotificationGrid extends Component {
|
||||
* @param {Array} keys
|
||||
*/
|
||||
toggle(keys) {
|
||||
const user = this.props.user;
|
||||
const user = this.attrs.user;
|
||||
const preferences = user.preferences();
|
||||
const enabled = !preferences[keys[0]];
|
||||
|
||||
@ -128,7 +130,7 @@ export default class NotificationGrid extends Component {
|
||||
* @param {String} 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);
|
||||
}
|
||||
@ -139,7 +141,7 @@ export default class NotificationGrid extends Component {
|
||||
* @param {String} 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);
|
||||
}
|
||||
|
@ -9,12 +9,9 @@ import Discussion from '../../common/models/Discussion';
|
||||
* notifications, grouped by discussion.
|
||||
*/
|
||||
export default class NotificationList extends Component {
|
||||
init() {
|
||||
this.state = this.props.state;
|
||||
}
|
||||
|
||||
view() {
|
||||
const pages = this.state.getNotificationPages();
|
||||
const state = this.attrs.state;
|
||||
const pages = state.getNotificationPages();
|
||||
|
||||
return (
|
||||
<div className="NotificationList">
|
||||
@ -24,7 +21,7 @@ export default class NotificationList extends Component {
|
||||
className: 'Button Button--icon Button--link',
|
||||
icon: 'fas fa-check',
|
||||
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>
|
||||
|
||||
@ -66,7 +63,7 @@ export default class NotificationList extends Component {
|
||||
return (
|
||||
<div className="NotificationGroup">
|
||||
{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> : ''}
|
||||
{group.discussion.title()}
|
||||
</a>
|
||||
@ -85,7 +82,7 @@ export default class NotificationList extends Component {
|
||||
});
|
||||
})
|
||||
: ''}
|
||||
{this.state.isLoading() ? (
|
||||
{state.isLoading() ? (
|
||||
<LoadingIndicator className="LoadingIndicator--block" />
|
||||
) : pages.length ? (
|
||||
''
|
||||
@ -97,27 +94,31 @@ export default class NotificationList extends Component {
|
||||
);
|
||||
}
|
||||
|
||||
config(isInitialized, context) {
|
||||
if (isInitialized) return;
|
||||
oncreate(vnode) {
|
||||
super.oncreate(vnode);
|
||||
|
||||
const $notifications = this.$('.NotificationList-content');
|
||||
const $scrollParent = $notifications.css('overflow') === 'auto' ? $notifications : $(window);
|
||||
this.$notifications = this.$('.NotificationList-content');
|
||||
this.$scrollParent = this.$notifications.css('overflow') === 'auto' ? this.$notifications : $(window);
|
||||
|
||||
const scrollHandler = () => {
|
||||
const scrollTop = $scrollParent.scrollTop();
|
||||
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) {
|
||||
this.state.loadMore();
|
||||
this.boundScrollHandler = this.scrollHandler.bind(this);
|
||||
this.$scrollParent.on('scroll', this.boundScrollHandler);
|
||||
}
|
||||
};
|
||||
|
||||
$scrollParent.on('scroll', scrollHandler);
|
||||
onremove() {
|
||||
this.$scrollParent.off('scroll', this.boundScrollHandler);
|
||||
}
|
||||
|
||||
context.onunload = () => {
|
||||
$scrollParent.off('scroll', scrollHandler);
|
||||
};
|
||||
scrollHandler() {
|
||||
const state = this.attrs.state;
|
||||
|
||||
const scrollTop = this.$scrollParent.scrollTop();
|
||||
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';
|
||||
|
||||
export default class NotificationsDropdown extends Dropdown {
|
||||
static initProps(props) {
|
||||
props.className = props.className || 'NotificationsDropdown';
|
||||
props.buttonClassName = props.buttonClassName || 'Button Button--flat';
|
||||
props.menuClassName = props.menuClassName || 'Dropdown-menu--right';
|
||||
props.label = props.label || app.translator.trans('core.forum.notifications.tooltip');
|
||||
props.icon = props.icon || 'fas fa-bell';
|
||||
static initAttrs(attrs) {
|
||||
attrs.className = attrs.className || 'NotificationsDropdown';
|
||||
attrs.buttonClassName = attrs.buttonClassName || 'Button Button--flat';
|
||||
attrs.menuClassName = attrs.menuClassName || 'Dropdown-menu--right';
|
||||
attrs.label = attrs.label || app.translator.trans('core.forum.notifications.tooltip');
|
||||
attrs.icon = attrs.icon || 'fas fa-bell';
|
||||
|
||||
super.initProps(props);
|
||||
super.initAttrs(attrs);
|
||||
}
|
||||
|
||||
getButton() {
|
||||
const newNotifications = this.getNewCount();
|
||||
const vdom = super.getButton();
|
||||
|
||||
vdom.attrs.title = this.props.label;
|
||||
vdom.attrs.title = this.attrs.label;
|
||||
|
||||
vdom.attrs.className += newNotifications ? ' new' : '';
|
||||
vdom.attrs.onclick = this.onclick.bind(this);
|
||||
@ -29,16 +29,16 @@ export default class NotificationsDropdown extends Dropdown {
|
||||
const unread = this.getUnreadCount();
|
||||
|
||||
return [
|
||||
icon(this.props.icon, { className: 'Button-icon' }),
|
||||
icon(this.attrs.icon, { className: 'Button-icon' }),
|
||||
unread ? <span className="NotificationsDropdown-unread">{unread}</span> : '',
|
||||
<span className="Button-label">{this.props.label}</span>,
|
||||
<span className="Button-label">{this.attrs.label}</span>,
|
||||
];
|
||||
}
|
||||
|
||||
getMenu() {
|
||||
return (
|
||||
<div className={'Dropdown-menu ' + this.props.menuClassName} onclick={this.menuClick.bind(this)}>
|
||||
{this.showing ? NotificationList.component({ state: this.props.state }) : ''}
|
||||
<div className={'Dropdown-menu ' + this.attrs.menuClassName} onclick={this.menuClick.bind(this)}>
|
||||
{this.showing ? NotificationList.component({ state: this.attrs.state }) : ''}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -47,12 +47,12 @@ export default class NotificationsDropdown extends Dropdown {
|
||||
if (app.drawer.isOpen()) {
|
||||
this.goToRoute();
|
||||
} else {
|
||||
this.props.state.load();
|
||||
this.attrs.state.load();
|
||||
}
|
||||
}
|
||||
|
||||
goToRoute() {
|
||||
m.route(app.route('notifications'));
|
||||
m.route.set(app.route('notifications'));
|
||||
}
|
||||
|
||||
getUnreadCount() {
|
||||
|
@ -6,8 +6,8 @@ import NotificationList from './NotificationList';
|
||||
* used on mobile devices where the notifications dropdown is within the drawer.
|
||||
*/
|
||||
export default class NotificationsPage extends Page {
|
||||
init() {
|
||||
super.init();
|
||||
oninit(vnode) {
|
||||
super.oninit(vnode);
|
||||
|
||||
app.history.push('notifications');
|
||||
|
||||
|
@ -10,14 +10,16 @@ import ItemList from '../../common/utils/ItemList';
|
||||
* includes a controls dropdown; subclasses must implement `content` and `attrs`
|
||||
* methods.
|
||||
*
|
||||
* ### Props
|
||||
* ### Attrs
|
||||
*
|
||||
* - `post`
|
||||
*
|
||||
* @abstract
|
||||
*/
|
||||
export default class Post extends Component {
|
||||
init() {
|
||||
oninit(vnode) {
|
||||
super.oninit(vnode);
|
||||
|
||||
this.loading = false;
|
||||
|
||||
/**
|
||||
@ -27,9 +29,9 @@ export default class Post extends Component {
|
||||
* @type {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;
|
||||
},
|
||||
() => this.controlsOpen
|
||||
@ -37,17 +39,14 @@ export default class Post extends Component {
|
||||
}
|
||||
|
||||
view() {
|
||||
const attrs = this.attrs();
|
||||
const attrs = this.elementAttrs();
|
||||
|
||||
attrs.className = this.classes(attrs.className).join(' ');
|
||||
|
||||
return (
|
||||
<article {...attrs}>
|
||||
{this.subtree.retain() ||
|
||||
(() => {
|
||||
const controls = PostControls.controls(this.props.post, this).toArray();
|
||||
const controls = PostControls.controls(this.attrs.post, this).toArray();
|
||||
|
||||
return (
|
||||
<article {...attrs}>
|
||||
<div>
|
||||
{this.content()}
|
||||
<aside className="Post-actions">
|
||||
@ -75,13 +74,17 @@ export default class Post extends Component {
|
||||
<ul>{listItems(this.footerItems().toArray())}</ul>
|
||||
</footer>
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
</article>
|
||||
);
|
||||
}
|
||||
|
||||
config(isInitialized) {
|
||||
onbeforeupdate(vnode) {
|
||||
super.onbeforeupdate(vnode);
|
||||
|
||||
return this.subtree.needsRebuild();
|
||||
}
|
||||
|
||||
onupdate() {
|
||||
const $actions = this.$('.Post-actions');
|
||||
const $controls = this.$('.Post-controls');
|
||||
|
||||
@ -93,7 +96,7 @@ export default class Post extends Component {
|
||||
*
|
||||
* @return {Object}
|
||||
*/
|
||||
attrs() {
|
||||
elementAttrs() {
|
||||
return {};
|
||||
}
|
||||
|
||||
@ -115,8 +118,8 @@ export default class Post extends Component {
|
||||
classes(existing) {
|
||||
let classes = (existing || '').split(' ').concat(['Post']);
|
||||
|
||||
const user = this.props.post.user();
|
||||
const discussion = this.props.post.discussion();
|
||||
const user = this.attrs.post.user();
|
||||
const discussion = this.attrs.post.discussion();
|
||||
|
||||
if (this.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
|
||||
* was edited.
|
||||
*
|
||||
* ### Props
|
||||
* ### Attrs
|
||||
*
|
||||
* - `post`
|
||||
*/
|
||||
export default class PostEdited extends Component {
|
||||
init() {
|
||||
oninit(vnode) {
|
||||
super.oninit(vnode);
|
||||
|
||||
this.shouldUpdateTooltip = false;
|
||||
this.oldEditedInfo = null;
|
||||
}
|
||||
|
||||
view() {
|
||||
const post = this.props.post;
|
||||
const post = this.attrs.post;
|
||||
const editedUser = post.editedUser();
|
||||
const editedInfo = extractText(app.translator.trans('core.forum.post.edited_tooltip', { user: editedUser, ago: humanTime(post.editedAt()) }));
|
||||
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) {
|
||||
this.$().tooltip('destroy').tooltip();
|
||||
this.shouldUpdateTooltip = false;
|
||||
|
@ -7,23 +7,23 @@ import fullTime from '../../common/helpers/fullTime';
|
||||
* a dropdown containing more information about the post (number, full time,
|
||||
* permalink).
|
||||
*
|
||||
* ### Props
|
||||
* ### Attrs
|
||||
*
|
||||
* - `post`
|
||||
*/
|
||||
export default class PostMeta extends Component {
|
||||
view() {
|
||||
const post = this.props.post;
|
||||
const post = this.attrs.post;
|
||||
const time = post.createdAt();
|
||||
const permalink = this.getPermalink(post);
|
||||
const touch = 'ontouchstart' in document.documentElement;
|
||||
|
||||
// When the dropdown menu is shown, select the contents of the permalink
|
||||
// input so that the user can quickly copy the URL.
|
||||
const selectPermalink = function () {
|
||||
const selectPermalink = function (e) {
|
||||
setTimeout(() => $(this).parent().find('.PostMeta-permalink').select());
|
||||
|
||||
m.redraw.strategy('none');
|
||||
e.redraw = false;
|
||||
};
|
||||
|
||||
return (
|
||||
|
@ -7,18 +7,18 @@ import highlight from '../../common/helpers/highlight';
|
||||
* 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.
|
||||
*
|
||||
* ### Props
|
||||
* ### Attrs
|
||||
*
|
||||
* - `post`
|
||||
*/
|
||||
export default class PostPreview extends Component {
|
||||
view() {
|
||||
const post = this.props.post;
|
||||
const post = this.attrs.post;
|
||||
const user = post.user();
|
||||
const excerpt = highlight(post.contentPlain(), this.props.highlight, 300);
|
||||
const excerpt = highlight(post.contentPlain(), this.attrs.highlight, 300);
|
||||
|
||||
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">
|
||||
{avatar(user)}
|
||||
{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
|
||||
* a discussion. Posts that have not loaded will be displayed as placeholders.
|
||||
*
|
||||
* ### Props
|
||||
* ### Attrs
|
||||
*
|
||||
* - `discussion`
|
||||
* - `stream`
|
||||
@ -16,9 +16,11 @@ import Button from '../../common/components/Button';
|
||||
* - `onPositionChange`
|
||||
*/
|
||||
export default class PostStream extends Component {
|
||||
init() {
|
||||
this.discussion = this.props.discussion;
|
||||
this.stream = this.props.stream;
|
||||
oninit(vnode) {
|
||||
super.oninit(vnode);
|
||||
|
||||
this.discussion = this.attrs.discussion;
|
||||
this.stream = this.attrs.stream;
|
||||
|
||||
this.scrollListener = new ScrollListener(this.onscroll.bind(this));
|
||||
}
|
||||
@ -96,29 +98,33 @@ export default class PostStream extends Component {
|
||||
return <div className="PostStream">{items}</div>;
|
||||
}
|
||||
|
||||
config(isInitialized, context) {
|
||||
onupdate() {
|
||||
this.triggerScroll();
|
||||
}
|
||||
|
||||
if (isInitialized) return;
|
||||
oncreate(vnode) {
|
||||
super.oncreate(vnode);
|
||||
|
||||
this.triggerScroll();
|
||||
|
||||
// This is wrapped in setTimeout due to the following Mithril issue:
|
||||
// https://github.com/lhorie/mithril.js/issues/637
|
||||
setTimeout(() => this.scrollListener.start());
|
||||
}
|
||||
|
||||
context.onunload = () => {
|
||||
onremove() {
|
||||
this.scrollListener.stop();
|
||||
clearTimeout(this.calculatePositionTimeout);
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Start scrolling, if appropriate, to a newly-targeted post.
|
||||
*/
|
||||
triggerScroll() {
|
||||
if (!this.props.targetPost) return;
|
||||
if (!this.attrs.targetPost) return;
|
||||
|
||||
const oldTarget = this.prevTarget;
|
||||
const newTarget = this.props.targetPost;
|
||||
const newTarget = this.attrs.targetPost;
|
||||
|
||||
if (oldTarget) {
|
||||
if ('number' in oldTarget && oldTarget.number === newTarget.number) return;
|
||||
@ -265,7 +271,7 @@ export default class PostStream extends Component {
|
||||
});
|
||||
|
||||
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(() => {
|
||||
this.updateScrubber();
|
||||
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();
|
||||
$(window).scrollTop(scroll);
|
||||
this.calculatePosition();
|
||||
|
@ -7,14 +7,16 @@ import ScrollListener from '../../common/utils/ScrollListener';
|
||||
* The `PostStreamScrubber` component displays a scrubber which can be used to
|
||||
* navigate/scrub through a post stream.
|
||||
*
|
||||
* ### Props
|
||||
* ### Attrs
|
||||
*
|
||||
* - `stream`
|
||||
* - `className`
|
||||
*/
|
||||
export default class PostStreamScrubber extends Component {
|
||||
init() {
|
||||
this.stream = this.props.stream;
|
||||
oninit(vnode) {
|
||||
super.oninit(vnode);
|
||||
|
||||
this.stream = this.attrs.stream;
|
||||
this.handlers = {};
|
||||
|
||||
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 unreadPercent = count ? Math.min(count - this.stream.index, unreadCount) / count : 0;
|
||||
|
||||
function styleUnread(element, isInitialized, context) {
|
||||
const $element = $(element);
|
||||
function styleUnread(vnode) {
|
||||
const $element = $(vnode.dom);
|
||||
const newStyle = {
|
||||
top: 100 - unreadPercent * 100 + '%',
|
||||
height: unreadPercent * 100 + '%',
|
||||
};
|
||||
|
||||
if (context.oldStyle) {
|
||||
$element.stop(true).css(context.oldStyle).animate(newStyle);
|
||||
if (vnode.state.oldStyle) {
|
||||
$element.stop(true).css(vnode.state.oldStyle).animate(newStyle);
|
||||
} else {
|
||||
$element.css(newStyle);
|
||||
}
|
||||
|
||||
context.oldStyle = newStyle;
|
||||
vnode.state.oldStyle = newStyle;
|
||||
}
|
||||
const classNames = ['PostStreamScrubber', 'Dropdown'];
|
||||
if (this.props.className) classNames.push(this.props.className);
|
||||
if (this.attrs.className) classNames.push(this.attrs.className);
|
||||
|
||||
return (
|
||||
<div className={classNames.join(' ')}>
|
||||
@ -68,12 +70,12 @@ export default class PostStreamScrubber extends Component {
|
||||
<div className="Scrubber-bar" />
|
||||
<div className="Scrubber-info">
|
||||
<strong>{viewing}</strong>
|
||||
<span className="Scrubber-description">{this.stream.description}</span>
|
||||
<span className="Scrubber-description"></span>
|
||||
</div>
|
||||
</div>
|
||||
<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 })}
|
||||
</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 }));
|
||||
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
|
||||
// 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)));
|
||||
|
||||
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 });
|
||||
}
|
||||
|
||||
ondestroy() {
|
||||
this.scrollListener.stop();
|
||||
$(window).off('resize', this.handlers.onresize);
|
||||
|
||||
$(document).off('mousemove touchmove', this.handlers.onmousemove).off('mouseup touchend', this.handlers.onmouseup);
|
||||
}
|
||||
|
||||
onresize() {
|
||||
// Adjust the height of the scrollbar so that it fills the height of
|
||||
// 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