mirror of
https://github.com/flarum/framework.git
synced 2025-06-04 06:44:33 +08:00
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:
250
js/lib/App.js
Normal file
250
js/lib/App.js
Normal 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
64
js/lib/Translator.js
Normal 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;
|
||||
}
|
||||
}
|
@ -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) {
|
||||
}
|
||||
}
|
||||
|
55
js/lib/components/Button.js
Normal file
55
js/lib/components/Button.js
Normal 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>
|
||||
];
|
||||
}
|
||||
}
|
69
js/lib/components/Checkbox.js
Normal file
69
js/lib/components/Checkbox.js
Normal 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);
|
||||
}
|
||||
}
|
69
js/lib/components/Dropdown.js
Normal file
69
js/lib/components/Dropdown.js
Normal 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'})
|
||||
];
|
||||
}
|
||||
}
|
22
js/lib/components/FieldSet.js
Normal file
22
js/lib/components/FieldSet.js
Normal 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>
|
||||
);
|
||||
}
|
||||
}
|
32
js/lib/components/LinkButton.js
Normal file
32
js/lib/components/LinkButton.js
Normal 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;
|
||||
}
|
||||
}
|
27
js/lib/components/LoadingIndicator.js
Normal file
27
js/lib/components/LoadingIndicator.js
Normal 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(' ')}</div>;
|
||||
}
|
||||
|
||||
config() {
|
||||
const size = this.props.size || 'small';
|
||||
|
||||
$.fn.spin.presets[size].zIndex = 'auto';
|
||||
this.$().spin(size);
|
||||
}
|
||||
}
|
82
js/lib/components/ModalManager.js
Normal file
82
js/lib/components/ModalManager.js
Normal 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.$());
|
||||
}
|
||||
}
|
||||
}
|
96
js/lib/components/Navigation.js
Normal file
96
js/lib/components/Navigation.js
Normal 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'
|
||||
});
|
||||
}
|
||||
}
|
25
js/lib/components/Select.js
Normal file
25
js/lib/components/Select.js
Normal 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>
|
||||
);
|
||||
}
|
||||
}
|
25
js/lib/components/SelectDropdown.js
Normal file
25
js/lib/components/SelectDropdown.js
Normal 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'})
|
||||
];
|
||||
}
|
||||
}
|
50
js/lib/components/SplitDropdown.js
Normal file
50
js/lib/components/SplitDropdown.js
Normal 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;
|
||||
}
|
||||
}
|
17
js/lib/components/Switch.js
Normal file
17
js/lib/components/Switch.js
Normal 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 '';
|
||||
}
|
||||
}
|
@ -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)
|
||||
]);
|
||||
}
|
||||
}
|
@ -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>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
@ -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();
|
||||
|
@ -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;
|
||||
}
|
||||
}
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
@ -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))
|
||||
]);
|
||||
}
|
||||
}
|
@ -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))
|
||||
])
|
||||
}
|
||||
}
|
@ -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)
|
||||
])
|
||||
}
|
||||
}
|
@ -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))
|
||||
]);
|
||||
}
|
||||
}
|
@ -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(' '));
|
||||
}
|
||||
}
|
@ -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.$());
|
||||
}
|
||||
}
|
@ -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;
|
||||
}
|
||||
}
|
@ -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')
|
||||
])
|
||||
}
|
||||
}
|
@ -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;
|
||||
|
@ -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);
|
||||
}
|
||||
}
|
@ -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());
|
||||
}
|
||||
}
|
@ -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
75
js/lib/extend.js
Normal 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
|
||||
}
|
@ -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));
|
||||
}
|
||||
};
|
@ -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>;
|
||||
}
|
||||
|
@ -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);
|
||||
}
|
15
js/lib/helpers/fullTime.js
Normal file
15
js/lib/helpers/fullTime.js
Normal 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>;
|
||||
}
|
@ -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);
|
||||
}
|
||||
|
@ -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);
|
||||
}
|
19
js/lib/helpers/humanTime.js
Normal file
19
js/lib/helpers/humanTime.js
Normal 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>;
|
||||
}
|
@ -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}/>;
|
||||
}
|
||||
|
@ -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), ' ']);
|
||||
};
|
37
js/lib/helpers/listItems.js
Normal file
37
js/lib/helpers/listItems.js
Normal 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>;
|
||||
});
|
||||
};
|
@ -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;
|
||||
};
|
||||
|
@ -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>;
|
||||
}
|
||||
|
18
js/lib/initializers/humanTime.js
Normal file
18
js/lib/initializers/humanTime.js
Normal 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);
|
||||
}
|
@ -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)
|
||||
);
|
||||
}
|
||||
|
@ -1,5 +0,0 @@
|
||||
import Session from 'flarum/session';
|
||||
|
||||
export default function(app) {
|
||||
app.session = new Session();
|
||||
}
|
@ -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);
|
||||
}
|
||||
|
@ -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);
|
||||
}
|
296
js/lib/model.js
296
js/lib/model.js
@ -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
|
||||
};
|
||||
}
|
||||
}
|
||||
|
@ -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')
|
||||
}) {}
|
||||
|
@ -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;
|
||||
|
@ -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 {}
|
||||
|
@ -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;
|
||||
|
@ -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')
|
||||
}) {}
|
||||
|
@ -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')
|
||||
}) {}
|
||||
|
@ -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;
|
||||
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
136
js/lib/store.js
136
js/lib/store.js
@ -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
61
js/lib/utils/ItemList.js
Normal 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);
|
||||
}
|
||||
}
|
||||
|
73
js/lib/utils/ScrollListener.js
Normal file
73
js/lib/utils/ScrollListener.js
Normal 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;
|
||||
}
|
||||
}
|
69
js/lib/utils/SubtreeRetainer.js
Normal file
69
js/lib/utils/SubtreeRetainer.js
Normal 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 = {};
|
||||
}
|
||||
}
|
@ -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();
|
||||
}
|
||||
}
|
20
js/lib/utils/abbreviateNumber.js
Normal file
20
js/lib/utils/abbreviateNumber.js
Normal 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();
|
||||
}
|
||||
}
|
@ -1,7 +0,0 @@
|
||||
export default function anchorScroll(element, callback) {
|
||||
var scrollAnchor = $(element).offset().top - $(window).scrollTop();
|
||||
|
||||
callback();
|
||||
|
||||
$(window).scrollTop($(element).offset().top - scrollAnchor);
|
||||
}
|
22
js/lib/utils/anchorScroll.js
Normal file
22
js/lib/utils/anchorScroll.js
Normal 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);
|
||||
}
|
@ -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;
|
@ -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
20
js/lib/utils/classList.js
Normal 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(' ');
|
||||
}
|
@ -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;
|
||||
};
|
||||
}
|
||||
|
@ -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
15
js/lib/utils/extract.js
Normal 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;
|
||||
}
|
@ -1,3 +0,0 @@
|
||||
export default function(number) {
|
||||
return number.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ',');
|
||||
}
|
14
js/lib/utils/formatNumber.js
Normal file
14
js/lib/utils/formatNumber.js
Normal 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, ',');
|
||||
}
|
@ -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
28
js/lib/utils/humanTime.js
Normal 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;
|
||||
};
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
@ -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
21
js/lib/utils/mapRoutes.js
Normal 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;
|
||||
}
|
@ -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;
|
||||
}
|
||||
|
@ -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();
|
||||
}
|
||||
}
|
||||
}
|
@ -1,2 +0,0 @@
|
||||
export default class ServerError {
|
||||
}
|
@ -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);
|
||||
};
|
@ -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();
|
||||
}
|
||||
|
45
js/lib/utils/stringToColor.js
Normal file
45
js/lib/utils/stringToColor.js
Normal 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);
|
||||
};
|
@ -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 = {};
|
||||
}
|
||||
}
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
@ -1,5 +0,0 @@
|
||||
export default function truncate(string = '', length, start = 0) {
|
||||
return (start > 0 ? '...' : '') +
|
||||
string.substring(start, start + length) +
|
||||
(string.length > start + length ? '...' : '');
|
||||
}
|
Reference in New Issue
Block a user