Massive JavaScript cleanup

- Use JSX for templates
- Docblock/comment everything
- Mostly passes ESLint (still some work to do)
- Lots of renaming, refactoring, etc.

CSS hasn't been updated yet.
This commit is contained in:
Toby Zerner
2015-07-15 14:00:11 +09:30
parent 4480e0a83f
commit ab6c03c0cc
220 changed files with 9785 additions and 5919 deletions

250
js/lib/App.js Normal file
View File

@ -0,0 +1,250 @@
import ItemList from 'flarum/utils/ItemList';
import Alert from 'flarum/components/Alert';
import Translator from 'flarum/Translator';
import extract from 'flarum/utils/extract';
/**
* The `App` class provides a container for an application, as well as various
* utilities for the rest of the app to use.
*/
export default class App {
constructor() {
/**
* The forum model for this application.
*
* @type {Forum}
* @public
*/
this.forum = null;
/**
* A map of routes, keyed by a unique route name. Each route is an object
* containing the following properties:
*
* - `path` The path that the route is accessed at.
* - `component` The Mithril component to render when this route is active.
*
* @example
* app.routes.discussion = {path: '/d/:id', component: DiscussionPage.component()};
*
* @type {Object}
* @public
*/
this.routes = {};
/**
* An object containing data to preload into the application.
*
* @type {Object}
* @property {Object} preload.data An array of resource objects to preload
* into the data store.
* @property {Object} preload.document An API response document to be used
* by the route that is first activated.
* @property {Object} preload.session A response from the /api/token
* endpoint containing the session's authentication token and user ID.
* @public
*/
this.preload = {
data: null,
document: null,
session: null
};
/**
* An ordered list of initializers to bootstrap the application.
*
* @type {ItemList}
* @public
*/
this.initializers = new ItemList();
/**
* The app's session.
*
* @type {Session}
* @public
*/
this.session = null;
/**
* The app's translator.
*
* @type {Translator}
* @public
*/
this.translator = new Translator();
/**
* The app's data store.
*
* @type {Store}
* @public
*/
this.store = null;
/**
* A local cache that can be used to store data at the application level, so
* that is persists between different routes.
*
* @type {Object}
* @public
*/
this.cache = {};
/**
* Whether or not the app has been booted.
*
* @type {Boolean}
* @public
*/
this.booted = false;
/**
* An Alert that was shown as a result of an AJAX request error. If present,
* it will be dismissed on the next successful request.
*
* @type {null|Alert}
* @private
*/
this.requestError = null;
}
/**
* Boot the application by running all of the registered initializers.
*
* @public
*/
boot() {
this.initializers.toArray().forEach(initializer => initializer(this));
}
/**
* Get the API response document that has been preloaded into the application.
*
* @return {Object|null}
* @public
*/
preloadedDocument() {
if (app.preload.document) {
const results = app.store.pushPayload(app.preload.document);
app.preload.document = null;
return results;
}
return null;
}
/**
* Set the <title> of the page.
*
* @param {String} title
* @public
*/
setTitle(title) {
document.title = (title ? title + ' - ' : '') + this.forum.attribute('title');
}
/**
* Make an AJAX request, handling any low-level errors that may occur.
*
* @see https://lhorie.github.io/mithril/mithril.request.html
* @param {Object} options
* @return {Promise}
* @public
*/
request(options) {
// Set some default options if they haven't been overridden. We want to
// authenticate all requests with the session token. We also want all
// requests to run asynchronously in the background, so that they don't
// prevent redraws from occurring.
options.config = options.config || this.session.authorize.bind(this.session);
options.background = options.background || true;
// When we deserialize JSON data, if for some reason the server has provided
// a dud response, we don't want the application to crash. We'll show an
// error message to the user instead.
options.deserialize = options.deserialize || (responseText => {
try {
return JSON.parse(responseText);
} catch (e) {
throw new Error('Oops! Something went wrong on the server. Please reload the page and try again.');
}
});
// When extracting the data from the response, we can check the server
// response code and show an error message to the user if something's gone
// awry.
const original = options.extract;
options.extract = xhr => {
const status = xhr.status;
if (status >= 500 && status <= 599) {
throw new Error('Oops! Something went wrong on the server. Please reload the page and try again.');
}
if (original) {
return original(xhr.responseText);
}
return xhr.responseText.length > 0 ? xhr.responseText : null;
};
this.alerts.dismiss(this.requestError);
// Now make the request. If it's a failure, inspect the error that was
// returned and show an alert containing its contents.
return m.request(options).then(null, response => {
if (response instanceof Error) {
this.alerts.show(this.requestError = new Alert({
type: 'warning',
message: response.message
}));
}
throw response;
});
}
/**
* Show alert error messages for each error returned in an API response.
*
* @param {Array} errors
* @public
*/
alertErrors(errors) {
errors.forEach(error => {
this.alerts.show(new Alert({
type: 'warning',
message: error.detail
}));
});
}
/**
* Construct a URL to the route with the given name.
*
* @param {String} name
* @param {Object} params
* @return {String}
* @public
*/
route(name, params = {}) {
const url = this.routes[name].path.replace(/:([^\/]+)/g, (m, key) => extract(params, key));
const queryString = m.route.buildQueryString(params);
return url + (queryString ? '?' + queryString : '');
}
/**
* Shortcut to translate the given key.
*
* @param {String} key
* @param {Object} input
* @return {String}
* @public
*/
trans(key, input) {
return this.translator.trans(key, input);
}
}

64
js/lib/Translator.js Normal file
View File

@ -0,0 +1,64 @@
/**
* The `Translator` class translates strings using the loaded localization.
*/
export default class Translator {
constructor() {
/**
* A map of translation keys to their translated values.
*
* @type {Object}
* @public
*/
this.translations = {};
}
/**
* Determine the key of a translation that should be used for the given count.
* The default implementation is for English plurals. It should be overridden
* by a locale's JavaScript file if necessary.
*
* @param {Integer} count
* @return {String}
* @public
*/
plural(count) {
return count === 1 ? 'one' : 'other';
}
/**
* Translate a string.
*
* @param {String} key
* @param {Object} input
* @return {String}
*/
trans(key, input = {}) {
const parts = key.split('.');
let translation = this.translations;
// Drill down into the translation tree to find the translation for this
// key.
parts.forEach(part => {
translation = translation && translation[part];
});
// If this translation has multiple options and a 'count' has been provided
// in the input, we'll work out which option to choose using the `plural`
// method.
if (typeof translation === 'object' && typeof input.count !== 'undefined') {
translation = translation[this.plural(input.count)];
}
// If we've found the appropriate translation string, then we'll sub in the
// input.
if (typeof translation === 'string') {
for (const i in input) {
translation = translation.replace(new RegExp('{' + i + '}', 'gi'), input[i]);
}
return translation;
}
return key;
}
}

View File

@ -1,65 +1,195 @@
/**
* 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) {
this.props = props || {};
constructor(props = {}, children) {
if (children) props.children = children;
this.element = m.prop();
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;
}
/**
* 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
*/
$(selector) {
return selector ? $(this.element()).find(selector) : $(this.element());
}
config(element, isInitialized, context, vdom) {
//
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() {
var vdom = this.view();
const vdom = 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 || {};
if (!vdom.attrs.config) {
var component = this;
vdom.attrs.config = function() {
var args = [].slice.apply(arguments);
component.element(args[0]);
component.config.apply(component, args);
};
}
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
*/
static component(props) {
props = props || {};
if (this.props) {
this.props(props);
}
var view = function(component) {
$(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
* @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) {
if (children) props.children = children;
this.initProps(props);
// 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 = props;
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;
var output = {
props: props,
component: this,
// 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, props),
view: view
view: view,
props: props,
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 (props.key) {
output.attrs = {key: props.key};
}
return output;
}
/**
* Initialize the component's props.
*
* @param {Object} props
* @public
*/
static initProps(props) {
}
}

View File

@ -0,0 +1,55 @@
import Component from 'flarum/Component';
import icon from 'flarum/helpers/icon';
import extract from 'flarum/utils/extract';
/**
* The `Button` component defines an element which, when clicked, performs an
* action. The button may have the following special props:
*
* - `icon` The name of the icon class. If specified, the button will be given a
* 'has-icon' class name.
* - `disabled` Whether or not the button is disabled. If truthy, the button
* will be given a 'disabled' class name, and any `onclick` handler will be
* removed.
*
* All other props will be assigned as attributes on the button element.
*
* Note that a Button has no default class names. This is because a Button can
* be used to represent any generic clickable control, like a menu item.
*/
export default class Button extends Component {
view() {
const attrs = Object.assign({}, this.props);
delete attrs.children;
attrs.className = (attrs.className || '');
attrs.href = attrs.href || 'javascript:;';
const iconName = extract(attrs, 'icon');
if (iconName) attrs.className += ' has-icon';
const disabled = extract(attrs, 'disabled');
if (disabled) {
attrs.className += ' disabled';
delete attrs.onclick;
}
return <a {...attrs}>{this.getButtonContent()}</a>;
}
/**
* Get the template for the button's content.
*
* @return {*}
* @protected
*/
getButtonContent() {
const iconName = this.props.icon;
return [
iconName ? icon(iconName) : '',
<span className="label">{this.props.children}</span>
];
}
}

View File

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

View File

@ -0,0 +1,69 @@
import Component from 'flarum/Component';
import icon from 'flarum/helpers/icon';
import listItems from 'flarum/helpers/listItems';
/**
* The `Dropdown` component displays a button which, when clicked, shows a
* dropdown menu beneath it.
*
* ### Props
*
* - `buttonClassName` A class name to apply to the dropdown toggle button.
* - `menuClassName` A class name to apply to the dropdown menu.
* - `icon` The name of an icon to show in the dropdown toggle button. Defaults
* to 'ellipsis-v'.
* - `label` The label of the dropdown toggle button. Defaults to 'Controls'.
*
* The children will be displayed as a list inside of the dropdown menu.
*/
export default class Dropdown extends Component {
static initProps(props) {
props.className = props.className || '';
props.buttonClassName = props.buttonClassName || '';
props.contentClassName = props.contentClassName || '';
props.icon = props.icon || 'ellipsis-v';
props.label = props.label || app.trans('controls');
}
view() {
return (
<div className={'dropdown btn-group ' + this.props.className}>
{this.getButton()}
<ul className={'dropdown-menu ' + this.props.menuClassName}>
{listItems(this.props.children)}
</ul>
</div>
);
}
/**
* Get the template for the button.
*
* @return {*}
* @protected
*/
getButton() {
return (
<a href="javascript:;"
className={'dropdown-toggle ' + this.props.buttonClassName}
data-toggle="dropdown"
onclick={this.props.onclick}>
{this.getButtonContent()}
</a>
);
}
/**
* Get the template for the button's content.
*
* @return {*}
* @protected
*/
getButtonContent() {
return [
icon(this.props.icon),
<span className="label">{this.props.label}</span>,
icon('caret-down', {className: 'caret'})
];
}
}

View File

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

View File

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

View File

@ -0,0 +1,27 @@
import Component from 'flarum/Component';
/**
* The `LoadingIndicator` component displays a loading spinner with spin.js. It
* may have the following special props:
*
* - `size` The spin.js size preset to use. Defaults to 'small'.
*
* All other props will be assigned as attributes on the element.
*/
export default class LoadingIndicator extends Component {
view() {
const attrs = Object.assign({}, this.props);
attrs.className = 'loading-indicator ' + (attrs.className || '');
delete attrs.size;
return <div {...attrs}>{m.trust('&nbsp;')}</div>;
}
config() {
const size = this.props.size || 'small';
$.fn.spin.presets[size].zIndex = 'auto';
this.$().spin(size);
}
}

View File

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

View File

@ -0,0 +1,96 @@
import Component from 'flarum/Component';
import Button from 'flarum/components/Button';
/**
* The `Navigation` component displays a set of navigation buttons. Typically
* this is just a back button which pops the app's History. If the user is on
* the root page and there is no history to pop, then in some instances it may
* show a button that toggles the app's drawer.
*
* If the app has a pane, it will also include a 'pin' button which toggles the
* pinned state of the pane.
*
* Accepts the following props:
*
* - `className` The name of a class to set on the root element.
* - `drawer` Whether or not to show a button to toggle the app's drawer if
* there is no more history to pop.
*/
export default class Navigation extends Component {
view() {
const {history, pane} = app;
return (
<div className={'navigation ' + (this.props.className || '')}
onmouseenter={pane && pane.show.bind(pane)}
onmouseleave={pane && pane.onmouseleave.bind(pane)}>
<div className="btn-group">
{history.canGoBack()
? [this.getBackButton(), this.getPaneButton()]
: this.getDrawerButton()}
</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;
}
/**
* Get the back button.
*
* @return {Object}
* @protected
*/
getBackButton() {
const {history} = app;
return Button.component({
className: 'btn btn-default btn-icon navigation-back',
onclick: history.back.bind(history),
icon: 'chevron-left'
});
}
/**
* Get the pane pinned toggle button.
*
* @return {Object|String}
* @protected
*/
getPaneButton() {
const {pane} = app;
if (!pane || !pane.active) return '';
return Button.component({
className: 'btn btn-default btn-icon navigation-pin' + (pane.pinned ? ' active' : ''),
onclick: pane.togglePinned.bind(pane),
icon: 'thumb-tack'
});
}
/**
* Get the drawer toggle button.
*
* @return {Object|String}
* @protected
*/
getDrawerButton() {
if (!this.props.drawer) return '';
const {drawer} = app;
const user = app.session.user;
return Button.component({
className: 'btn btn-default btn-icon navigation-drawer' +
(user && user.unreadNotificationsCount() ? ' unread' : ''),
onclick: drawer.toggle.bind(drawer),
icon: 'reorder'
});
}
}

View File

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

View File

@ -0,0 +1,25 @@
import Dropdown from 'flarum/components/Dropdown';
import icon from 'flarum/helpers/icon';
/**
* The `SelectDropdown` component is the same as a `Dropdown`, except the toggle
* button's label is set as the label of the first child which has a truthy
* `active` prop.
*/
export default class SelectDropdown extends Dropdown {
static initProps(props) {
super.initProps(props);
props.className += ' select-dropdown';
}
getButtonContent() {
const activeChild = this.props.children.filter(child => child.props.active)[0];
const label = activeChild && activeChild.props.label;
return [
<span className="label">{label}</span>,
icon('sort', {className: 'caret'})
];
}
}

View File

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

View File

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

View File

@ -1,27 +0,0 @@
import Component from 'flarum/component';
import icon from 'flarum/helpers/icon';
export default class ActionButton extends Component {
view() {
var attrs = {};
for (var i in this.props) { attrs[i] = this.props[i]; }
var iconName = attrs.icon;
delete attrs.icon;
var label = attrs.label;
delete attrs.label;
if (attrs.disabled) {
attrs.className = (attrs.className || '')+' disabled';
delete attrs.onclick;
delete attrs.disabled;
}
attrs.href = attrs.href || 'javascript:;';
return m('a'+(iconName ? '.has-icon' : ''), attrs, [
iconName ? icon(iconName+' icon') : '', ' ',
m('span.label', label)
]);
}
}

View File

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

View File

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

View File

@ -1,31 +0,0 @@
import Component from 'flarum/component';
import icon from 'flarum/helpers/icon';
/**
The back/pin button group in the top-left corner of Flarum's interface.
*/
export default class BackButton extends Component {
view() {
var history = app.history;
var pane = app.pane;
return m('div.back-button', {
className: this.props.className || '',
onmouseenter: pane && pane.show.bind(pane),
onmouseleave: pane && pane.onmouseleave.bind(pane),
config: this.onload.bind(this)
}, history.canGoBack() ? m('div.btn-group', [
m('button.btn.btn-default.btn-icon.back', {onclick: history.back.bind(history)}, icon('chevron-left icon')),
pane && pane.active ? m('button.btn.btn-default.btn-icon.pin'+(pane.pinned ? '.active' : ''), {onclick: pane.togglePinned.bind(pane)}, icon('thumb-tack icon')) : '',
]) : (this.props.drawer ? [
m('button.btn.btn-default.btn-icon.drawer-toggle', {
onclick: app.drawer.toggle.bind(app.drawer),
className: app.session.user() && app.session.user().unreadNotificationsCount() ? 'unread-notifications' : ''
}, icon('reorder icon'))
] : ''));
}
onload(element, isInitialized, context) {
context.retain = true;
}
}

View File

@ -1,21 +1,42 @@
import Component from 'flarum/component';
import Component from 'flarum/Component';
import icon from 'flarum/helpers/icon';
import extract from 'flarum/utils/extract';
/**
* The `Badge` component represents a user/discussion badge, indicating some
* status (e.g. a discussion is stickied, a user is an admin).
*
* A badge may have the following special props:
*
* - `type` The type of badge this is. This will be used to give the badge a
* class name of `badge-{type}`.
* - `icon` The name of an icon to show inside the badge.
*
* All other props will be assigned as attributes on the badge element.
*/
export default class Badge extends Component {
view(ctrl) {
var iconName = this.props.icon;
var label = this.props.title = this.props.label;
delete this.props.icon, this.props.label;
this.props.config = function(element, isInitialized) {
if (isInitialized) return;
$(element).tooltip();
};
this.props.className = 'badge '+(this.props.className || '');
this.props.key = this.props.className;
view() {
const attrs = Object.assign({}, this.props);
const type = extract(attrs, 'type');
const iconName = extract(attrs, 'icon');
return m('span', this.props, [
icon(iconName+' icon-glyph'),
m('span.label', label)
]);
attrs.className = 'badge badge-' + type + ' ' + (attrs.className || '');
// Give the badge a unique key so that when badges are displayed together,
// and then one is added/removed, Mithril will correctly redraw the series
// of badges.
attrs.key = attrs.className;
return (
<span {...attrs}>
{iconName ? icon(iconName, {className: 'icon'}) : ''}
</span>
);
}
config(isInitialized) {
if (isInitialized) return;
this.$().tooltip();
}
}

View File

@ -1,20 +0,0 @@
import Component from 'flarum/component';
import icon from 'flarum/helpers/icon';
import listItems from 'flarum/helpers/list-items';
export default class DropdownButton extends Component {
view() {
return m('div', {className: 'dropdown btn-group '+(this.props.items ? 'item-count-'+this.props.items.length : '')+' '+(this.props.className || '')}, [
m('a[href=javascript:;]', {
className: 'dropdown-toggle '+(this.props.buttonClass || 'btn btn-default'),
'data-toggle': 'dropdown',
onclick: this.props.buttonClick
}, this.props.buttonContent || [
icon((this.props.icon || 'ellipsis-v')+' icon-glyph icon'),
m('span.label', this.props.label || 'Controls'),
icon('caret-down icon-caret')
]),
m(this.props.menuContent ? 'div' : 'ul', {className: 'dropdown-menu '+(this.props.menuClass || '')}, this.props.menuContent || listItems(this.props.items))
]);
}
}

View File

@ -1,18 +0,0 @@
import Component from 'flarum/component'
import icon from 'flarum/helpers/icon'
import listItems from 'flarum/helpers/list-items';
export default class DropdownSelect extends Component {
view() {
var activeItem = this.props.items.filter((item) => item.component.active && item.component.active(item.props))[0];
var label = activeItem && activeItem.props.label;
return m('div', {className: 'dropdown dropdown-select btn-group item-count-'+this.props.items.length+' '+this.props.className}, [
m('a[href=javascript:;]', {className: 'dropdown-toggle '+(this.props.buttonClass || 'btn btn-default'), 'data-toggle': 'dropdown'}, [
m('span.label', label), ' ',
icon('sort icon-caret')
]),
m('ul', {className: 'dropdown-menu '+this.props.menuClass}, listItems(this.props.items, true))
])
}
}

View File

@ -1,35 +0,0 @@
import Component from 'flarum/component';
import icon from 'flarum/helpers/icon';
import listItems from 'flarum/helpers/list-items';
import ActionButton from 'flarum/components/action-button';
/**
Given a list of items, this component displays a split button: the left side
is the first item in the list, while the right side is a dropdown-toggle
which shows a dropdown menu containing all of the items.
*/
export default class DropdownSplit extends Component {
view() {
var firstItem = this.props.items;
while (firstItem instanceof Array) {
firstItem = firstItem[0];
}
var items = listItems(this.props.items);
var buttonProps = {};
for (var i in firstItem.props) {
buttonProps[i] = firstItem.props[i];
}
buttonProps.className = (buttonProps.className || '')+' '+(this.props.buttonClass || 'btn btn-default');
return m('div', {className: 'dropdown dropdown-split btn-group item-count-'+(items.length)+' '+this.props.className}, [
ActionButton.component(buttonProps),
m('a[href=javascript:;]', {className: 'dropdown-toggle btn-icon '+this.props.buttonClass, 'data-toggle': 'dropdown'}, [
icon('caret-down icon-caret'),
icon((this.props.icon || 'ellipsis-v')+' icon'),
]),
m('ul', {className: 'dropdown-menu '+(this.props.menuClass || 'pull-right')}, items)
])
}
}

View File

@ -1,11 +0,0 @@
import Component from 'flarum/component';
import listItems from 'flarum/helpers/list-items';
export default class FieldSet extends Component {
view() {
return m('fieldset', {className: this.props.className}, [
m('legend', this.props.label),
m('ul', listItems(this.props.fields))
]);
}
}

View File

@ -1,15 +0,0 @@
import Component from 'flarum/component';
export default class LoadingIndicator extends Component {
view() {
var size = this.props.size || 'small';
delete this.props.size;
this.props.config = function(element) {
$.fn.spin.presets[size].zIndex = 'auto';
$(element).spin(size);
};
return m('div.loading-indicator', this.props, m.trust('&nbsp;'));
}
}

View File

@ -1,38 +0,0 @@
import Component from 'flarum/component';
export default class Modal extends Component {
view() {
return m('div.modal.fade', {config: this.onload.bind(this)}, this.component && this.component.render())
}
onload(element, isInitialized) {
if (isInitialized) { return; }
this.element(element);
this.$()
.on('hidden.bs.modal', this.destroy.bind(this))
.on('shown.bs.modal', this.ready.bind(this));
}
show(component) {
clearTimeout(this.hideTimeout);
this.component = component;
m.redraw(true);
this.$().modal('show');
this.ready();
}
close() {
this.hideTimeout = setTimeout(() => this.$().modal('hide'));
}
destroy() {
this.component = null;
m.redraw();
}
ready() {
this.component && this.component.ready && this.component.ready(this.$());
}
}

View File

@ -1,21 +0,0 @@
import Component from 'flarum/component'
import icon from 'flarum/helpers/icon'
export default class NavItem extends Component {
view() {
var active = this.constructor.active(this.props);
return m('li'+(active ? '.active' : ''), m('a.has-icon', {
href: this.props.href,
onclick: this.props.onclick,
config: m.route
}, [
icon(this.props.icon+' icon'),
this.props.label, ' ',
this.props.badge ? m('span.count', this.props.badge) : ''
]))
}
static active(props) {
return typeof props.active !== 'undefined' ? props.active : m.route() === props.href;
}
}

View File

@ -1,13 +0,0 @@
import Component from 'flarum/component'
import icon from 'flarum/helpers/icon';
export default class SelectInput extends Component {
view(ctrl) {
return m('span.select-input', [
m('select.form-control', {onchange: m.withAttr('value', this.props.onchange.bind(ctrl)), value: this.props.value}, [
Object.keys(this.props.options).map(key => m('option', {value: key}, this.props.options[key]))
]),
icon('sort')
])
}
}

View File

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

View File

@ -1,30 +0,0 @@
import Component from 'flarum/component';
import LoadingIndicator from 'flarum/components/loading-indicator';
export default class SwitchInput extends Component {
constructor(props) {
super(props);
this.loading = m.prop(false);
}
view() {
return m('div.checkbox.checkbox-switch', [
m('label', [
m('div.switch-control', [
m('input[type=checkbox]', {
checked: this.props.state,
onchange: m.withAttr('checked', this.onchange.bind(this))
}),
m('div.switch', {className: this.loading() && 'loading'})
]),
this.props.label, ' ',
this.loading() ? LoadingIndicator.component({size: 'tiny'}) : ''
])
])
}
onchange(checked) {
this.props.onchange && this.props.onchange(checked, this);
}
}

View File

@ -1,90 +0,0 @@
import Component from 'flarum/component';
import ItemList from 'flarum/utils/item-list';
import listItems from 'flarum/helpers/list-items';
import ActionButton from 'flarum/components/action-button';
/**
A text editor. Contains a textarea and an item list of `controls`, including
a submit button.
*/
export default class TextEditor extends Component {
constructor(props) {
props.submitLabel = props.submitLabel || 'Submit';
super(props);
this.value = m.prop(this.props.value || '');
}
view() {
return m('div.text-editor', [
m('textarea.form-control.flexible-height', {
config: this.configTextarea.bind(this),
oninput: m.withAttr('value', this.oninput.bind(this)),
placeholder: this.props.placeholder || '',
disabled: !!this.props.disabled,
value: this.value()
}),
m('ul.text-editor-controls', listItems(this.controlItems().toArray()))
]);
}
configTextarea(element, isInitialized) {
if (isInitialized) { return; }
$(element).bind('keydown', 'meta+return', this.onsubmit.bind(this));
}
controlItems() {
var items = new ItemList();
items.add('submit',
ActionButton.component({
label: this.props.submitLabel,
icon: 'check',
className: 'btn btn-primary',
onclick: this.onsubmit.bind(this)
})
);
return items;
}
setContent(content) {
this.value(content);
this.$('textarea').val(content).trigger('input');
}
setSelectionRange(start, end) {
var $textarea = this.$('textarea');
$textarea[0].setSelectionRange(start, end);
$textarea.focus();
}
getSelectionRange() {
var $textarea = this.$('textarea');
return [$textarea[0].selectionStart, $textarea[0].selectionEnd];
}
insertAtCursor(insert) {
var textarea = this.$('textarea')[0];
var content = this.value();
var index = textarea ? textarea.selectionStart : content.length;
this.setContent(content.slice(0, index)+insert+content.slice(index));
if (textarea) {
var pos = index + insert.length;
this.setSelectionRange(pos, pos);
}
}
oninput(value) {
this.value(value);
this.props.onchange(this.value());
m.redraw.strategy('none');
}
onsubmit() {
this.props.onsubmit(this.value());
}
}

View File

@ -1,35 +0,0 @@
import Component from 'flarum/component';
import LoadingIndicator from 'flarum/components/loading-indicator';
import classList from 'flarum/utils/class-list';
import icon from 'flarum/helpers/icon';
export default class YesNoInput extends Component {
constructor(props) {
super(props);
this.loading = m.prop(false);
}
view() {
return m('label.yesno-control', [
m('input[type=checkbox]', {
checked: this.props.state,
disabled: this.props.disabled,
onchange: m.withAttr('checked', this.onchange.bind(this))
}),
m('div.yesno', {className: classList({
loading: this.loading(),
disabled: this.props.disabled,
state: this.props.state ? 'yes' : 'no'
})}, [
this.loading()
? LoadingIndicator.component({size: 'tiny'})
: icon(this.props.state ? 'check' : 'times')
])
]);
}
onchange(checked) {
this.props.onchange && this.props.onchange(checked, this);
}
}

75
js/lib/extend.js Normal file
View File

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

View File

@ -1,17 +0,0 @@
export function extend(object, func, extension) {
var original = object[func];
object[func] = function() {
var args = [].slice.apply(arguments);
var value = original.apply(this, args);
extension.apply(this, [value].concat(args));
return value;
}
};
export function override(object, func, override) {
var original = object[func];
object[func] = function() {
var args = [].slice.apply(arguments);
return override.apply(this, [original.bind(this)].concat(args));
}
};

View File

@ -1,26 +1,36 @@
export default function avatar(user, args) {
args = args || {}
args.className = 'avatar '+(args.className || '')
var content = ''
/**
* The `avatar` helper displays a user's avatar.
*
* @param {User} user
* @param {Object} attrs Attributes to apply to the avatar element
* @return {Object}
*/
export default function avatar(user, attrs = {}) {
attrs.className = 'avatar ' + (attrs.className || '');
let content = '';
var title = typeof args.title === 'undefined' || args.title
if (!title) { delete args.title }
// If the `title` attribute is set to null or false, we don't want to give the
// avatar a title. On the other hand, if it hasn't been given at all, we can
// safely default it to the user's username.
const hasTitle = attrs.title === 'undefined' || attrs.title;
if (!hasTitle) delete attrs.title;
// If a user has been passed, then we will set up an avatar using their
// uploaded image, or the first letter of their username if they haven't
// uploaded one.
if (user) {
var username = user.username() || '?'
const username = user.username() || '?';
const avatarUrl = user.avatarUrl();
if (title) { args.title = args.title || username }
if (hasTitle) attrs.title = attrs.title || username;
var avatarUrl = user.avatarUrl()
if (avatarUrl) {
args.src = avatarUrl
return m('img', args)
return <img {...attrs} src={avatarUrl}/>;
}
content = username.charAt(0).toUpperCase()
args.style = {background: user.color()}
content = username.charAt(0).toUpperCase();
attrs.style = {background: user.color()};
}
if (!args.title) { delete args.title }
return m('span', args, content)
return <span {...attrs}>{content}</span>;
}

View File

@ -1,7 +0,0 @@
export default function fullTime(time) {
var time = moment(time);
var datetime = time.format();
var full = time.format('LLLL');
return m('time', {pubdate: '', datetime}, full);
}

View File

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

View File

@ -1,21 +1,37 @@
import truncate from '../utils/truncate';
import { truncate } from 'flarum/utils/string';
export default function(string, phrase, length) {
if (!phrase) {
return string;
}
/**
* The `highlight` helper searches for a word phrase in a string, and wraps
* matches with the <mark> tag.
*
* @param {String} string The string to highlight.
* @param {String|RegExp} phrase The word or words to highlight.
* @param {Integer} [length] The number of characters to truncate the string to.
* The string will be truncated surrounding the first match.
* @return {Object}
*/
export default function highlight(string, phrase, length) {
if (!phrase && !length) return string;
// Convert the word phrase into a global regular expression (if it isn't
// already) so we can search the string for matched.
const regexp = phrase instanceof RegExp ? phrase : new RegExp(phrase, 'gi');
let highlightedString = string;
let highlighted = string;
let start = 0;
// If a length was given, the truncate the string surrounding the first match.
if (length) {
start = Math.max(0, string.search(regexp) - length / 2);
highlightedString = truncate(highlightedString, length, start);
if (phrase) start = Math.max(0, string.search(regexp) - length / 2);
highlighted = truncate(highlighted, length, start);
}
highlightedString = $('<div/>').text(highlightedString).html().replace(regexp, '<mark>$&</mark>');
// Convert the string into HTML entities, then highlight all matches with
// <mark> tags. Then we will return the result as a trusted HTML string.
highlighted = $('<div/>').text(highlighted).html();
return m.trust(highlightedString);
if (phrase) highlighted = highlighted.replace(regexp, '<mark>$&</mark>');
return m.trust(highlighted);
}

View File

@ -1,11 +0,0 @@
import humanTime from 'flarum/utils/human-time';
export default function humanTimeHelper(time) {
var time = moment(time);
var datetime = time.format();
var full = time.format('LLLL');
var ago = humanTime(time);
return m('time', {pubdate: '', datetime, title: full, 'data-humantime': ''}, ago);
}

View File

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

View File

@ -1,3 +1,12 @@
export default function icon(icon) {
return m('i.fa.fa-fw.fa-'+icon)
/**
* The `icon` helper displays a FontAwesome icon. The fa-fw class is applied.
*
* @param {String} name The name of the icon class, without the `fa-` prefix.
* @param {Object} attrs Any other attributes to apply.
* @return {Object}
*/
export default function icon(name, attrs = {}) {
attrs.className = 'icon fa fa-fw fa-' + name + ' ' + (attrs.className || '');
return <i {...attrs}/>;
}

View File

@ -1,21 +0,0 @@
import Separator from 'flarum/components/separator';
function isSeparator(item) {
return item && item.component === Separator;
}
export default function listItems(array, noWrap) {
// Remove duplicate/unnecessary separators
var prevItem;
var newArray = [];
array.forEach(function(item, i) {
if ((!prevItem || isSeparator(prevItem) || i === array.length - 1) && isSeparator(item)) {
} else {
prevItem = item;
newArray.push(item);
}
});
return newArray.map(item => [(noWrap && !isSeparator(item)) ? item : m('li', {className: 'item-'+item.itemName+' '+(item.wrapperClass || (item.props && item.props.wrapperClass) || (item.component && item.component.wrapperClass) || '')}, item), ' ']);
};

View File

@ -0,0 +1,37 @@
import Separator from 'flarum/components/Separator';
function isSeparator(item) {
return item && item.component === Separator;
}
function withoutUnnecessarySeparators(items) {
const newItems = [];
let prevItem;
items.forEach((item, i) => {
if (!isSeparator(item) || (prevItem && !isSeparator(prevItem) && i !== items.length - 1)) {
prevItem = item;
newItems.push(item);
}
});
return newItems;
}
/**
* The `listItems` helper wraps a collection of components in <li> tags,
* stripping out any unnecessary `Separator` components.
*
* @param {Array} items
* @return {Array}
*/
export default function listItems(items) {
return withoutUnnecessarySeparators(items).map(item => {
const isListItem = item.component && item.component.isListItem;
const className = item.props ? item.props.itemClassName : item.itemClassName;
return isListItem
? item
: <li className={(item.itemName ? 'item-' + item.itemName : '') + ' ' + (className || '')}>{item}</li>;
});
};

View File

@ -1,13 +1,28 @@
/**
* The `punctuate` helper formats a list of strings (e.g. names) to read
* fluently in the application's locale.
*
* @example
* punctuate(['Toby', 'Franz', 'Dominion'])
* // Toby, Franz, and Dominion
*
* @param {Array} items
* @return {Array}
*/
export default function punctuate(items) {
var newItems = [];
const punctuated = [];
// FIXME: update to use translation
items.forEach((item, i) => {
newItems.push(item);
punctuated.push(item);
if (i <= items.length - 2) {
newItems.push((items.length > 2 ? ', ' : '')+(i === items.length - 2 ? ' and ' : ''));
// If this item is not the last one, then we will follow it with some
// punctuation. If the list is more than 2 items long, we'll add a comma.
// And if this is the second-to-last item, we'll add 'and'.
if (i < items.length - 1) {
punctuated.push((items.length > 2 ? ', ' : '') + (i === items.length - 2 ? ' and ' : ''));
}
});
return newItems;
return punctuated;
};

View File

@ -1,5 +1,12 @@
/**
* The `username` helper displays a user's username in a <span class="username">
* tag. If the user doesn't exist, the username will be displayed as [deleted].
*
* @param {User} user
* @return {Object}
*/
export default function username(user) {
var username = (user && user.username()) || '[deleted]';
const name = (user && user.username()) || '[deleted]';
return m('span.username', username);
return <span className="username">{name}</span>;
}

View File

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

View File

@ -1,9 +1,17 @@
export default function(app) {
app.store.pushPayload({data: app.preload.data});
app.forum = app.store.getById('forums', 1);
import Session from 'flarum/Session';
if (app.preload.session) {
app.session.token(app.preload.session.token);
app.session.user(app.store.getById('users', app.preload.session.userId));
}
/**
* The `session` initializer creates the application session and preloads it
* with data that has been set on the application's `preload` property.
*
* `app.preload.session` should be the same as the response from the /api/token
* endpoint: it should contain `token` and `userId` keys.
*
* @param {App} app
*/
export default function session(app) {
app.session = new Session(
app.preload.session.token,
app.store.getById('users', app.preload.session.userId)
);
}

View File

@ -1,5 +0,0 @@
import Session from 'flarum/session';
export default function(app) {
app.session = new Session();
}

View File

@ -1,16 +1,22 @@
import Store from 'flarum/store';
import Forum from 'flarum/models/forum';
import User from 'flarum/models/user';
import Discussion from 'flarum/models/discussion';
import Post from 'flarum/models/post';
import Group from 'flarum/models/group';
import Activity from 'flarum/models/activity';
import Notification from 'flarum/models/notification';
import Store from 'flarum/Store';
import Forum from 'flarum/models/Forum';
import User from 'flarum/models/User';
import Discussion from 'flarum/models/Discussion';
import Post from 'flarum/models/Post';
import Group from 'flarum/models/Group';
import Activity from 'flarum/models/Activity';
import Notification from 'flarum/models/Notification';
export default function(app) {
app.store = new Store();
app.store.models = {
/**
* The `store` initializer creates the application's data store and registers
* the default resource types to their models. It then preloads any data on the
* application's `preload` property into the store. Finally, it sets the
* application's `forum` instance to the one that was preloaded.
*
* @param {App} app
*/
export default function store(app) {
app.store = new Store({
forums: Forum,
users: User,
discussions: Discussion,
@ -18,5 +24,9 @@ export default function(app) {
groups: Group,
activity: Activity,
notifications: Notification
};
});
app.store.pushPayload({data: app.preload.data});
app.forum = app.store.getById('forums', 1);
}

View File

@ -1,10 +0,0 @@
import humanTime from 'flarum/utils/human-time';
export default function(app) {
setInterval(function() {
$('[data-humantime]').each(function() {
var $this = $(this);
$this.html(humanTime($this.attr('datetime')));
});
}, 1000);
}

View File

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

View File

@ -1,12 +1,11 @@
import Model from 'flarum/model';
import Model from 'flarum/Model';
import mixin from 'flarum/utils/mixin';
class Activity extends Model {}
export default class Activity extends mixin(Model, {
contentType: Model.attribute('contentType'),
content: Model.attribute('content'),
time: Model.attribute('time', Model.transformDate),
Activity.prototype.contentType = Model.attribute('contentType');
Activity.prototype.content = Model.attribute('content');
Activity.prototype.time = Model.attribute('time', Model.transformDate);
Activity.prototype.user = Model.hasOne('user');
Activity.prototype.subject = Model.hasOne('subject');
export default Activity;
user: Model.hasOne('user'),
subject: Model.hasOne('subject')
}) {}

View File

@ -1,16 +1,43 @@
import Model from 'flarum/model';
import Model from 'flarum/Model';
import mixin from 'flarum/utils/mixin';
import computed from 'flarum/utils/computed';
import ItemList from 'flarum/utils/item-list';
import ItemList from 'flarum/utils/ItemList';
import { slug } from 'flarum/utils/string';
class Discussion extends Model {
export default class Discussion extends mixin(Model, {
title: Model.attribute('title'),
slug: computed('title', slug),
startTime: Model.attribute('startTime', Model.transformDate),
startUser: Model.hasOne('startUser'),
startPost: Model.hasOne('startPost'),
lastTime: Model.attribute('lastTime', Model.transformDate),
lastUser: Model.hasOne('lastUser'),
lastPost: Model.hasOne('lastPost'),
lastPostNumber: Model.attribute('lastPostNumber'),
commentsCount: Model.attribute('commentsCount'),
repliesCount: computed('commentsCount', commentsCount => Math.max(0, commentsCount - 1)),
posts: Model.hasMany('posts'),
relevantPosts: Model.hasMany('relevantPosts'),
readTime: Model.attribute('readTime', Model.transformDate),
readNumber: Model.attribute('readNumber'),
isUnread: computed('unreadCount', unreadCount => !!unreadCount),
canReply: Model.attribute('canReply'),
canRename: Model.attribute('canRename'),
canDelete: Model.attribute('canDelete')
}) {
/**
* Remove a post from the discussion's posts relationship.
*
* @param {int} id The ID of the post to remove.
* @return {void}
* @param {Integer} id The ID of the post to remove.
* @public
*/
removePost(id) {
const relationships = this.data().relationships;
const relationships = this.data.relationships;
const posts = relationships && relationships.posts;
if (posts) {
@ -23,47 +50,40 @@ class Discussion extends Model {
}
}
/**
* Get the estimated number of unread posts in this discussion for the current
* user.
*
* @return {Integer}
* @public
*/
unreadCount() {
var user = app.session.user();
const user = app.session.user;
if (user && user.readTime() < this.lastTime()) {
return Math.max(0, this.lastPostNumber() - (this.readNumber() || 0))
}
return 0;
}
/**
* Get the Badge components that apply to this discussion.
*
* @return {ItemList}
* @public
*/
badges() {
return new ItemList();
}
/**
* Get a list of all of the post IDs in this discussion.
*
* @return {Array}
* @public
*/
postIds() {
return this.data.relationships.posts.data.map(link => link.id);
}
}
Discussion.prototype.title = Model.attribute('title');
Discussion.prototype.slug = computed('title', title => title.toLowerCase().replace(/[^a-z0-9]/gi, '-').replace(/-+/g, '-').replace(/-$|^-/g, '') || '-');
Discussion.prototype.startTime = Model.attribute('startTime', Model.transformDate);
Discussion.prototype.startUser = Model.hasOne('startUser');
Discussion.prototype.startPost = Model.hasOne('startPost');
Discussion.prototype.lastTime = Model.attribute('lastTime', Model.transformDate);
Discussion.prototype.lastUser = Model.hasOne('lastUser');
Discussion.prototype.lastPost = Model.hasOne('lastPost');
Discussion.prototype.lastPostNumber = Model.attribute('lastPostNumber');
Discussion.prototype.canReply = Model.attribute('canReply');
Discussion.prototype.canRename = Model.attribute('canRename');
Discussion.prototype.canDelete = Model.attribute('canDelete');
Discussion.prototype.commentsCount = Model.attribute('commentsCount');
Discussion.prototype.repliesCount = computed('commentsCount', commentsCount => Math.max(0, commentsCount - 1));
Discussion.prototype.posts = Model.hasMany('posts');
Discussion.prototype.postIds = function() { return this.data().relationships.posts.data.map((link) => link.id); };
Discussion.prototype.relevantPosts = Model.hasMany('relevantPosts');
Discussion.prototype.addedPosts = Model.hasMany('addedPosts');
Discussion.prototype.removedPosts = Model.attribute('removedPosts');
Discussion.prototype.readTime = Model.attribute('readTime', Model.transformDate);
Discussion.prototype.readNumber = Model.attribute('readNumber');
Discussion.prototype.isUnread = computed('unreadCount', unreadCount => !!unreadCount);
export default Discussion;

View File

@ -1,5 +1,3 @@
import Model from 'flarum/model';
import Model from 'flarum/Model';
class Forum extends Model {}
export default Forum;
export default class Forum extends Model {}

View File

@ -1,11 +1,12 @@
import Model from 'flarum/model';
import Model from 'flarum/Model';
import mixin from 'flarum/utils/mixin';
class Group extends Model {}
Group.prototype.nameSingular = Model.attribute('nameSingular');
Group.prototype.namePlural = Model.attribute('namePlural');
Group.prototype.color = Model.attribute('color');
Group.prototype.icon = Model.attribute('icon');
class Group extends mixin(Model, {
nameSingular: Model.attribute('nameSingular'),
namePlural: Model.attribute('namePlural'),
color: Model.attribute('color'),
icon: Model.attribute('icon')
}) {}
Group.ADMINISTRATOR_ID = 1;
Group.GUEST_ID = 2;

View File

@ -1,18 +1,18 @@
import Model from 'flarum/model';
import Model from 'flarum/Model';
import mixin from 'flarum/utils/mixin';
import computed from 'flarum/utils/computed';
class Notification extends Model {}
export default class Notification extends mixin(Model, {
contentType: Model.attribute('contentType'),
subjectId: Model.attribute('subjectId'),
content: Model.attribute('content'),
time: Model.attribute('time', Model.date),
Notification.prototype.contentType = Model.attribute('contentType');
Notification.prototype.subjectId = Model.attribute('subjectId');
Notification.prototype.content = Model.attribute('content');
Notification.prototype.time = Model.attribute('time', Model.date);
Notification.prototype.isRead = Model.attribute('isRead');
Notification.prototype.unreadCount = Model.attribute('unreadCount');
Notification.prototype.additionalUnreadCount = computed('unreadCount', unreadCount => Math.max(0, unreadCount - 1));
isRead: Model.attribute('isRead'),
unreadCount: Model.attribute('unreadCount'),
additionalUnreadCount: computed('unreadCount', unreadCount => Math.max(0, unreadCount - 1)),
Notification.prototype.user = Model.hasOne('user');
Notification.prototype.sender = Model.hasOne('sender');
Notification.prototype.subject = Model.hasOne('subject');
export default Notification;
user: Model.hasOne('user'),
sender: Model.hasOne('sender'),
subject: Model.hasOne('subject')
}) {}

View File

@ -1,27 +1,27 @@
import Model from 'flarum/model';
import Model from 'flarum/Model';
import mixin from 'flarum/utils/mixin';
import computed from 'flarum/utils/computed';
import { getPlainContent } from 'flarum/utils/string';
class Post extends Model {}
export default class Post extends mixin(Model, {
number: Model.attribute('number'),
discussion: Model.hasOne('discussion'),
Post.prototype.number = Model.attribute('number');
Post.prototype.discussion = Model.hasOne('discussion');
time: Model.attribute('time', Model.transformDate),
user: Model.hasOne('user'),
contentType: Model.attribute('contentType'),
content: Model.attribute('content'),
contentHtml: Model.attribute('contentHtml'),
contentPlain: computed('contentHtml', getPlainContent),
Post.prototype.time = Model.attribute('time', Model.transformDate);
Post.prototype.user = Model.hasOne('user');
Post.prototype.contentType = Model.attribute('contentType');
Post.prototype.content = Model.attribute('content');
Post.prototype.contentHtml = Model.attribute('contentHtml');
Post.prototype.contentPlain = computed('contentHtml', contentHtml => $('<div/>').html(contentHtml.replace(/(<\/p>|<br>)/g, '$1 ')).text());
editTime: Model.attribute('editTime', Model.transformDate),
editUser: Model.hasOne('editUser'),
isEdited: computed('editTime', editTime => !!editTime),
Post.prototype.editTime = Model.attribute('editTime', Model.transformDate);
Post.prototype.editUser = Model.hasOne('editUser');
Post.prototype.isEdited = computed('editTime', editTime => !!editTime);
hideTime: Model.attribute('hideTime', Model.transformDate),
hideUser: Model.hasOne('hideUser'),
isHidden: computed('hideTime', hideTime => !!hideTime),
Post.prototype.hideTime = Model.attribute('hideTime', Model.transformDate);
Post.prototype.hideUser = Model.hasOne('hideUser');
Post.prototype.isHidden = computed('hideTime', hideTime => !!hideTime);
Post.prototype.canEdit = Model.attribute('canEdit');
Post.prototype.canDelete = Model.attribute('canDelete');
export default Post;
canEdit: Model.attribute('canEdit'),
canDelete: Model.attribute('canDelete')
}) {}

View File

@ -1,69 +1,98 @@
import Model from 'flarum/model'
import stringToColor from 'flarum/utils/string-to-color';
import ItemList from 'flarum/utils/item-list';
/*global ColorThief*/
import Model from 'flarum/Model';
import mixin from 'flarum/utils/mixin';
import stringToColor from 'flarum/utils/stringToColor';
import ItemList from 'flarum/utils/ItemList';
import computed from 'flarum/utils/computed';
import Badge from 'flarum/components/badge';
import Badge from 'flarum/components/Badge';
class User extends Model {}
export default class User extends mixin(Model, {
username: Model.attribute('username'),
email: Model.attribute('email'),
isConfirmed: Model.attribute('isConfirmed'),
password: Model.attribute('password'),
User.prototype.username = Model.attribute('username');
User.prototype.email = Model.attribute('email');
User.prototype.isConfirmed = Model.attribute('isConfirmed');
User.prototype.password = Model.attribute('password');
User.prototype.avatarUrl = Model.attribute('avatarUrl');
User.prototype.bio = Model.attribute('bio');
User.prototype.bioHtml = Model.attribute('bioHtml');
User.prototype.preferences = Model.attribute('preferences');
avatarUrl: Model.attribute('avatarUrl'),
bio: Model.attribute('bio'),
bioHtml: Model.attribute('bioHtml'),
preferences: Model.attribute('preferences'),
groups: Model.hasMany('groups'),
User.prototype.groups = Model.hasMany('groups');
joinTime: Model.attribute('joinTime', Model.transformDate),
lastSeenTime: Model.attribute('lastSeenTime', Model.transformDate),
readTime: Model.attribute('readTime', Model.transformDate),
unreadNotificationsCount: Model.attribute('unreadNotificationsCount'),
User.prototype.joinTime = Model.attribute('joinTime', Model.transformDate);
User.prototype.lastSeenTime = Model.attribute('lastSeenTime', Model.transformDate);
User.prototype.online = function() { return this.lastSeenTime() > moment().subtract(5, 'minutes').toDate(); };
User.prototype.readTime = Model.attribute('readTime', Model.transformDate);
User.prototype.unreadNotificationsCount = Model.attribute('unreadNotificationsCount');
discussionsCount: Model.attribute('discussionsCount'),
commentsCount: Model.attribute('commentsCount'),
User.prototype.discussionsCount = Model.attribute('discussionsCount');
User.prototype.commentsCount = Model.attribute('commentsCount');
;
User.prototype.canEdit = Model.attribute('canEdit');
User.prototype.canDelete = Model.attribute('canDelete');
canEdit: Model.attribute('canEdit'),
canDelete: Model.attribute('canDelete'),
User.prototype.color = computed('username', 'avatarUrl', 'avatarColor', function(username, avatarUrl, avatarColor) {
if (avatarColor) {
return 'rgb('+avatarColor[0]+', '+avatarColor[1]+', '+avatarColor[2]+')';
} else if (avatarUrl) {
var image = new Image();
var user = this;
image.onload = function() {
var colorThief = new ColorThief();
user.avatarColor = colorThief.getColor(this);
user.freshness = new Date();
m.redraw();
};
image.src = avatarUrl;
return '';
} else {
return '#'+stringToColor(username);
avatarColor: null,
color: computed('username', 'avatarUrl', 'avatarColor', function(username, avatarUrl, avatarColor) {
// If we've already calculated and cached the dominant color of the user's
// avatar, then we can return that in RGB format. If we haven't, we'll want
// to calculate it. Unless the user doesn't have an avatar, in which case
// we generate a color from their username.
if (avatarColor) {
return 'rgb(' + avatarColor.join(', ') + ')';
} else if (avatarUrl) {
this.calculateAvatarColor();
return '';
}
return '#' + stringToColor(username);
})
}) {
/**
* Check whether or not the user has been seen in the last 5 minutes.
*
* @return {Boolean}
* @public
*/
isOnline() {
return this.lastSeenTime() > moment().subtract(5, 'minutes').toDate();
}
});
User.prototype.badges = function() {
var items = new ItemList();
/**
* Get the Badge components that apply to this user.
*
* @return {ItemList}
*/
badges() {
const items = new ItemList();
this.groups().forEach(group => {
if (group.id() != 3) {
items.add('group'+group.id(),
this.groups().forEach(group => {
items.add('group' + group.id(),
Badge.component({
label: group.nameSingular(),
icon: group.icon(),
style: {backgroundColor: group.color()}
})
);
}
});
});
return items;
return items;
}
/**
* Calculate the dominant color of the user's avatar. The dominant color will
* be set to the `avatarColor` property once it has been calculated.
*
* @protected
*/
calculateAvatarColor() {
const image = new Image();
const user = this;
image.onload = function() {
const colorThief = new ColorThief();
user.avatarColor = colorThief.getColor(this);
user.freshness = new Date();
m.redraw();
};
image.src = this.avatarUrl();
}
}
export default User;

View File

@ -1,41 +1,81 @@
import mixin from 'flarum/utils/mixin';
import evented from 'flarum/utils/evented';
/**
* The `Session` class defines the current user session. It stores a reference
* to the current authenticated user, and provides methods to log in/out.
*
* @extends evented
*/
export default class Session extends mixin(class {}, evented) {
constructor() {
constructor(token, user) {
super();
this.user = m.prop();
this.token = m.prop();
/**
* The current authenticated user.
*
* @type {User|null}
* @public
*/
this.user = user;
/**
* The token that was used for authentication.
*
* @type {String|null}
*/
this.token = token;
}
/**
* Attempt to log in a user.
*
* @param {String} identification The username/email.
* @param {String} password
* @return {Promise}
*/
login(identification, password) {
var deferred = m.deferred();
var self = this;
m.request({
const deferred = m.deferred();
app.request({
method: 'POST',
url: app.forum.attribute('baseUrl')+'/login',
data: {identification, password},
background: true
}).then(function(response) {
self.token(response.token);
m.startComputation();
app.store.find('users', response.userId).then(function(user) {
self.user(user);
deferred.resolve(user);
self.trigger('loggedIn', user);
m.endComputation();
});
}, function(response) {
deferred.reject(response);
});
url: app.forum.attribute('baseUrl') + '/login',
data: {identification, password}
}).then(
// FIXME: reload the page on success. Somehow serialize what the user's
// intention was, and then perform that intention after the page reloads.
response => {
this.token = response.token;
app.store.find('users', response.userId).then(user => {
this.user = user;
this.trigger('loggedIn', user);
deferred.resolve(user);
});
},
response => {
deferred.reject(response);
}
);
return deferred.promise;
}
/**
* Log the user out.
*/
logout() {
window.location = app.forum.attribute('baseUrl')+'/logout?token='+this.token();
window.location = app.forum.attribute('baseUrl') + '/logout?token=' + this.token;
}
/**
* Apply an authorization header with the current token to the given
* XMLHttpRequest object.
*
* @param {XMLHttpRequest} xhr
*/
authorize(xhr) {
xhr.setRequestHeader('Authorization', 'Token '+this.token());
xhr.setRequestHeader('Authorization', 'Token ' + this.token);
}
}

View File

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

61
js/lib/utils/ItemList.js Normal file
View File

@ -0,0 +1,61 @@
class Item {
constructor(content, priority) {
this.content = content;
this.priority = priority;
}
}
/**
* The `ItemList` class collects items and then arranges them into an array
* by priority.
*/
export default class ItemList {
/**
* Add an item to the list.
*
* @param {String} key A unique key for the item.
* @param {*} content The item's content.
* @param {Integer} [priority] The priority of the item. Items with a higher
* priority will be positioned before items with a lower priority.
* @public
*/
add(key, content, priority) {
this[key] = new Item(content, priority);
}
/**
* Merge another list's items into this one.
*
* @param {ItemList} items
* @public
*/
merge(items) {
for (const i in items) {
if (items.hasOwnProperty(i) && items[i] instanceof Item) {
this[i] = items[i];
}
}
}
/**
* Convert the list into an array of item content arranged by priority. Each
* item's content will be assigned an `itemName` property equal to the item's
* unique key.
*
* @return {Array}
* @public
*/
toArray() {
const items = [];
for (const i in this) {
if (this.hasOwnProperty(i) && this[i] instanceof Item) {
this[i].content.itemName = i;
items.push(this[i]);
}
}
return items.sort((a, b) => b.priority - a.priority).map(item => item.content);
}
}

View File

@ -0,0 +1,73 @@
const scroll = window.requestAnimationFrame ||
window.webkitRequestAnimationFrame ||
window.mozRequestAnimationFrame ||
window.msRequestAnimationFrame ||
window.oRequestAnimationFrame ||
(callback => window.setTimeout(callback, 1000 / 60));
/**
* The `ScrollListener` class sets up a listener that handles window scroll
* events.
*/
export default class ScrollListener {
/**
* @param {Function} callback The callback to run when the scroll position
* changes.
* @public
*/
constructor(callback) {
this.callback = callback;
this.lastTop = -1;
}
/**
* On each animation frame, as long as the listener is active, run the
* `update` method.
*
* @protected
*/
loop() {
if (!this.active) return;
this.update();
scroll(this.loop.bind(this));
}
/**
* Check if the scroll position has changed; if it has, run the handler.
*
* @param {Boolean} [force=false] Whether or not to force the handler to be
* run, even if the scroll position hasn't changed.
* @public
*/
update(force) {
const top = window.pageYOffset;
if (this.lastTop !== top || force) {
this.callback(top);
this.lastTop = top;
}
}
/**
* Start listening to and handling the window's scroll position.
*
* @public
*/
start() {
if (!this.active) {
this.active = true;
this.loop();
}
}
/**
* Stop listening to and handling the window's scroll position.
*
* @public
*/
stop() {
this.active = false;
}
}

View File

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

View File

@ -1,9 +0,0 @@
export default function(number) {
if (number >= 1000000) {
return Math.floor(number / 1000000)+'M';
} else if (number >= 1000) {
return Math.floor(number / 1000)+'K';
} else {
return number.toString();
}
}

View File

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

View File

@ -1,7 +0,0 @@
export default function anchorScroll(element, callback) {
var scrollAnchor = $(element).offset().top - $(window).scrollTop();
callback();
$(window).scrollTop($(element).offset().top - scrollAnchor);
}

View File

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

View File

@ -1,75 +0,0 @@
import ItemList from 'flarum/utils/item-list';
import Alert from 'flarum/components/alert';
import ServerError from 'flarum/utils/server-error';
import Translator from 'flarum/utils/translator';
class App {
constructor() {
this.initializers = new ItemList();
this.translator = new Translator();
this.cache = {};
this.serverError = null;
}
boot() {
this.initializers.toArray().forEach((initializer) => initializer(this));
}
preloadedDocument() {
if (app.preload.document) {
const results = app.store.pushPayload(app.preload.document);
app.preload.document = null;
return results;
}
}
setTitle(title) {
document.title = (title ? title+' - ' : '')+this.forum.attribute('title');
}
request(options) {
var extract = options.extract;
options.extract = function(xhr, xhrOptions) {
if (xhr.status === 500) {
throw new ServerError;
}
return extract ? extract(xhr.responseText) : (xhr.responseText.length === 0 ? null : xhr.responseText);
};
return m.request(options).then(response => {
this.alerts.dismiss(this.serverError);
return response;
}, response => {
this.alerts.dismiss(this.serverError);
if (response instanceof ServerError) {
this.alerts.show(this.serverError = new Alert({ type: 'warning', message: 'Oops! Something went wrong on the server. Please try again.' }))
}
throw response;
});
}
handleApiErrors(response) {
this.alerts.clear();
response.errors.forEach(error =>
this.alerts.show(new Alert({ type: 'warning', message: error.detail }))
);
}
route(name, params) {
var url = this.routes[name][0].replace(/:([^\/]+)/g, function(m, t) {
var value = params[t];
delete params[t];
return value;
});
var queryString = m.route.buildQueryString(params);
return url+(queryString ? '?'+queryString : '');
}
translate(key, input) {
return this.translator.translate(key, input);
}
}
export default App;

View File

@ -1,12 +0,0 @@
export default function classList(classes) {
var classNames = [];
for (var i in classes) {
var value = classes[i];
if (value === true) {
classNames.push(i);
} else if (value) {
classNames.push(value);
}
}
return classNames.join(' ');
}

20
js/lib/utils/classList.js Normal file
View File

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

View File

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

View File

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

15
js/lib/utils/extract.js Normal file
View File

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

View File

@ -1,3 +0,0 @@
export default function(number) {
return number.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ',');
}

View File

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

View File

@ -1,22 +0,0 @@
export default function humanTime(time) {
var m = moment(time);
var minute = 6e4;
var hour = 36e5;
var day = 864e5;
var ago = null;
var diff = m.diff(moment());
if (diff < -30 * day) {
if (m.year() === moment().year()) {
ago = m.format('D MMM');
} else {
ago = m.format('MMM \'YY');
}
} else {
ago = m.fromNow();
}
return ago;
};

28
js/lib/utils/humanTime.js Normal file
View File

@ -0,0 +1,28 @@
/**
* The `humanTime` utility converts a date to a localized, human-readable time-
* ago string.
*
* @param {Date} time
* @return {String}
*/
export default function humanTime(time) {
const m = moment(time);
const day = 864e5;
const diff = m.diff(moment());
let ago = null;
// If this date was more than a month ago, we'll show the name of the month
// in the string. If it wasn't this year, we'll show the year as well.
if (diff < -30 * day) {
if (m.year() === moment().year()) {
ago = m.format('D MMM');
} else {
ago = m.format('MMM \'YY');
}
} else {
ago = m.fromNow();
}
return ago;
};

View File

@ -1,70 +0,0 @@
export class Item {
constructor(content, position) {
this.content = content;
this.position = position;
}
}
export default class ItemList {
add(key, content, position) {
this[key] = new Item(content, position);
}
merge(items) {
for (var i in items) {
if (items.hasOwnProperty(i) && items[i] instanceof Item) {
this[i] = items[i];
}
}
}
toArray() {
var items = [];
for (var i in this) {
if (this.hasOwnProperty(i) && this[i] instanceof Item) {
this[i].content.itemName = i;
items.push(this[i]);
}
}
var array = [];
var addItems = function(method, position) {
items = items.filter(function(item) {
if ((position && item.position && item.position[position]) || (!position && !item.position)) {
array[method](item);
} else {
return true;
}
});
};
addItems('unshift', 'first');
addItems('push', false);
addItems('push', 'last');
items.forEach(item => {
var key = item.position.before || item.position.after;
var type = item.position.before ? 'before' : 'after';
// TODO: Allow both before and after to be specified, and multiple keys to
// be specified for each.
// e.g. {before: ['foo', 'bar'], after: ['qux', 'qaz']}
// This way extensions can make sure they are positioned where
// they want to be relative to other extensions.
// Alternatively, it might be better to just have a numbered priority
// system, so extensions don't have to make awkward references to each other.
if (key) {
var index = array.indexOf(this[key]);
if (index === -1) {
array.push(item);
} else {
array.splice(index + (type === 'after' ? 1 : 0), 0, item);
}
}
});
array = array.map(item => item.content);
return array;
}
}

View File

@ -1,8 +0,0 @@
export default function mapRoutes(routes) {
var map = {};
for (var r in routes) {
routes[r][1].props.routeName = r;
map[routes[r][0]] = routes[r][1];
}
return map;
}

21
js/lib/utils/mapRoutes.js Normal file
View File

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

View File

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

View File

@ -1,43 +0,0 @@
var scroll = window.requestAnimationFrame ||
window.webkitRequestAnimationFrame ||
window.mozRequestAnimationFrame ||
window.msRequestAnimationFrame ||
window.oRequestAnimationFrame ||
function(callback) { window.setTimeout(callback, 1000/60) };
export default class ScrollListener {
constructor(callback) {
this.callback = callback;
this.lastTop = -1;
}
loop() {
if (!this.active) {
return;
}
this.update();
scroll(this.loop.bind(this));
}
update(force) {
var top = window.pageYOffset;
if (this.lastTop !== top || force) {
this.callback(top);
this.lastTop = top;
}
}
stop() {
this.active = false;
}
start() {
if (!this.active) {
this.active = true;
this.loop();
}
}
}

View File

@ -1,2 +0,0 @@
export default class ServerError {
}

View File

@ -1,34 +0,0 @@
function hsvToRgb(h, s, v) {
var r, g, b, i, f, p, q, t;
if (h && s === undefined && v === undefined) {
s = h.s; v = h.v; h = h.h;
}
i = Math.floor(h * 6);
f = h * 6 - i;
p = v * (1 - s);
q = v * (1 - f * s);
t = v * (1 - (1 - f) * s);
switch (i % 6) {
case 0: r = v; g = t; b = p; break;
case 1: r = q; g = v; b = p; break;
case 2: r = p; g = v; b = t; break;
case 3: r = p; g = q; b = v; break;
case 4: r = t; g = p; b = v; break;
case 5: r = v; g = p; b = q; break;
}
return {
r: Math.floor(r * 255),
g: Math.floor(g * 255),
b: Math.floor(b * 255)
};
}
export default function stringToColor(string) {
var num = 0;
for (var i = 0; i < string.length; i++) {
num += string.charCodeAt(i);
}
var hue = num % 360;
var rgb = hsvToRgb(hue / 360, 0.3, 0.9);
return ''+rgb.r.toString(16)+rgb.g.toString(16)+rgb.b.toString(16);
};

View File

@ -1,5 +1,38 @@
export function dasherize(string) {
return string.replace(/([A-Z])/g, function ($1) {
return '-' + $1.toLowerCase();
});
/**
* Truncate a string to the given length, appending ellipses if necessary.
*
* @param {String} string
* @param {Number} length
* @param {Number} [start=0]
* @return {String}
*/
export function truncate(string, length, start = 0) {
return (start > 0 ? '...' : '') +
string.substring(start, start + length) +
(string.length > start + length ? '...' : '');
}
/**
* Create a slug out of the given string. Non-alphanumeric characters are
* converted to hyphens.
*
* @param {String} string
* @return {String}
*/
export function slug(string) {
return string.toLowerCase()
.replace(/[^a-z0-9]/gi, '-')
.replace(/-+/g, '-')
.replace(/-$|^-/g, '') || '-';
}
/**
* Strip HTML tags and quotes out of the given string, replacing them with
* meaningful punctuation.
*
* @param {String} string
* @return {String}
*/
export function getPlainContent(string) {
return $('<div/>').html(string.replace(/(<\/p>|<br>)/g, '$1 ')).text();
}

View File

@ -0,0 +1,45 @@
function hsvToRgb(h, s, v) {
let r;
let g;
let b;
const i = Math.floor(h * 6);
const f = h * 6 - i;
const p = v * (1 - s);
const q = v * (1 - f * s);
const t = v * (1 - (1 - f) * s);
switch (i % 6) {
case 0: r = v; g = t; b = p; break;
case 1: r = q; g = v; b = p; break;
case 2: r = p; g = v; b = t; break;
case 3: r = p; g = q; b = v; break;
case 4: r = t; g = p; b = v; break;
case 5: r = v; g = p; b = q; break;
}
return {
r: Math.floor(r * 255),
g: Math.floor(g * 255),
b: Math.floor(b * 255)
};
}
/**
* Convert the given string to a unique color.
*
* @param {String} string
* @return {String}
*/
export default function stringToColor(string) {
let num = 0;
for (let i = 0; i < string.length; i++) {
num += string.charCodeAt(i);
}
const hue = num % 360;
const rgb = hsvToRgb(hue / 360, 0.3, 0.9);
return '' + rgb.r.toString(16) + rgb.g.toString(16) + rgb.b.toString(16);
};

View File

@ -1,38 +0,0 @@
/**
// constructor
this.subtree = new SubtreeRetainer(
() => this.props.post.freshness,
() => this.showing
);
this.subtree.check(() => this.props.user.freshness);
// view
this.subtree.retain() || 'expensive expression'
*/
export default class SubtreeRetainer {
constructor() {
this.invalidate();
this.callbacks = [].slice.call(arguments);
this.old = {};
}
retain() {
var needsRebuild = false;
this.callbacks.forEach((callback, i) => {
var result = callback();
if (result !== this.old[i]) {
this.old[i] = result;
needsRebuild = true;
}
});
return needsRebuild ? false : {subtree: 'retain'};
}
check() {
this.callbacks = this.callbacks.concat([].slice.call(arguments));
}
invalidate() {
this.old = {};
}
}

View File

@ -1,32 +0,0 @@
export default class Translator {
constructor() {
this.translations = {};
}
plural(count) {
return count == 1 ? 'one' : 'other';
}
translate(key, input) {
var parts = key.split('.');
var translation = this.translations;
parts.forEach(function(part) {
translation = translation && translation[part];
});
if (typeof translation === 'object' && typeof input.count !== 'undefined') {
translation = translation[this.plural(input.count)];
}
if (typeof translation === 'string') {
for (var i in input) {
translation = translation.replace(new RegExp('{'+i+'}', 'gi'), input[i]);
}
return translation;
} else {
return key;
}
}
}

View File

@ -1,5 +0,0 @@
export default function truncate(string = '', length, start = 0) {
return (start > 0 ? '...' : '') +
string.substring(start, start + length) +
(string.length > start + length ? '...' : '');
}