mirror of
https://github.com/flarum/framework.git
synced 2025-02-21 19:00:12 +08:00
done a bunch of work, header secondary has some components, app.request works, idk...
This commit is contained in:
parent
3c84f41070
commit
9a5063c083
5240
js/dist/admin.js
vendored
5240
js/dist/admin.js
vendored
File diff suppressed because it is too large
Load Diff
2
js/dist/admin.js.map
vendored
2
js/dist/admin.js.map
vendored
File diff suppressed because one or more lines are too long
9267
js/dist/forum.js
vendored
9267
js/dist/forum.js
vendored
File diff suppressed because it is too large
Load Diff
2
js/dist/forum.js.map
vendored
2
js/dist/forum.js.map
vendored
File diff suppressed because one or more lines are too long
@ -231,7 +231,7 @@ export default class Application {
|
||||
// prevent redraws from occurring.
|
||||
options.background = options.background || true;
|
||||
|
||||
extend(options, 'config', (result, xhr) => xhr.setRequestHeader('X-CSRF-Token', this.session.csrfToken));
|
||||
extend(options, 'config', (result, xhr) => xhr.setRequestHeader('X-CSRF-Token', this.session.csrfToken));
|
||||
|
||||
// If the method is something like PATCH or DELETE, which not all servers
|
||||
// and clients support, then we'll send it as a POST request with the
|
||||
|
@ -19,6 +19,8 @@ import listItems from '../helpers/listItems';
|
||||
* The children will be displayed as a list inside of the dropdown menu.
|
||||
*/
|
||||
export default class Dropdown extends Component {
|
||||
protected showing = false;
|
||||
|
||||
static initProps(props) {
|
||||
super.initProps(props);
|
||||
|
||||
@ -30,6 +32,8 @@ export default class Dropdown extends Component {
|
||||
}
|
||||
|
||||
init() {
|
||||
super.oninit();
|
||||
|
||||
this.showing = false;
|
||||
}
|
||||
|
||||
|
@ -1,3 +1,3 @@
|
||||
export { default as Model } from './Model';
|
||||
export { default as PostTypes } from './PostTypes';
|
||||
export { default as Routes } from './Routes';
|
||||
export { default as Routes } from './R outes';
|
||||
|
@ -33,7 +33,7 @@ export default class HeaderSecondary extends Component {
|
||||
items() {
|
||||
const items = new ItemList();
|
||||
|
||||
items.add('search', app.search.render(), 30);
|
||||
items.add('search', app.search, 30);
|
||||
|
||||
if (app.forum.attribute('showLanguageSelector') && Object.keys(app.data.locales).length > 1) {
|
||||
const locales = [];
|
||||
|
@ -81,7 +81,7 @@ export default class Search extends Component {
|
||||
}
|
||||
|
||||
// Hide the search view if no sources were loaded
|
||||
if (!this.sources.length) return <div></div>;
|
||||
if (!this.sources.length) return <div/>;
|
||||
|
||||
return (
|
||||
<div
|
||||
|
1977
js/package-lock.json
generated
1977
js/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@ -3,6 +3,7 @@
|
||||
"name": "@flarum/core",
|
||||
"dependencies": {
|
||||
"bootstrap": "^3.4.1",
|
||||
"classnames": "^2.2.6",
|
||||
"color-thief-browser": "^2.0.2",
|
||||
"dayjs": "^1.8.16",
|
||||
"expose-loader": "^0.7.5",
|
||||
@ -24,18 +25,11 @@
|
||||
"zepto": "^1.2.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"husky": "^4.2.5",
|
||||
"prettier": "2.0.2"
|
||||
"@types/classnames": "^2.2.9",
|
||||
"@types/zepto": "^1.0.30"
|
||||
},
|
||||
"scripts": {
|
||||
"dev": "webpack --mode development --watch",
|
||||
"build": "webpack --mode production",
|
||||
"format": "prettier --write src",
|
||||
"format-check": "prettier --check src"
|
||||
},
|
||||
"husky": {
|
||||
"hooks": {
|
||||
"pre-commit": "npm run format"
|
||||
}
|
||||
"build": "webpack --mode production"
|
||||
}
|
||||
}
|
||||
|
9
js/shims.d.ts
vendored
Normal file
9
js/shims.d.ts
vendored
Normal file
@ -0,0 +1,9 @@
|
||||
import * as _dayjs from 'dayjs';
|
||||
|
||||
import Forum from './src/forum/Forum';
|
||||
|
||||
declare global {
|
||||
const dayjs: typeof _dayjs;
|
||||
|
||||
const app: Forum;
|
||||
}
|
@ -1,12 +1,24 @@
|
||||
import m from 'mithril';
|
||||
import Mithril from "mithril";
|
||||
|
||||
import Bus from './Bus';
|
||||
import Translator from './Translator';
|
||||
import Session from './Session';
|
||||
import Store from './Store';
|
||||
|
||||
import extract from './utils/extract';
|
||||
import mapRoutes from './utils/mapRoutes';
|
||||
import {extend} from './extend';
|
||||
|
||||
type ApplicationData = {
|
||||
import Forum from './models/Forum';
|
||||
import Discussion from './models/Discussion';
|
||||
import User from './models/User';
|
||||
import Post from './models/Post';
|
||||
import Group from './models/Group';
|
||||
|
||||
import RequestError from './utils/RequestError';
|
||||
import Alert from './components/Alert';
|
||||
|
||||
export type ApplicationData = {
|
||||
apiDocument: any;
|
||||
locale: string;
|
||||
locales: any;
|
||||
@ -15,12 +27,43 @@ type ApplicationData = {
|
||||
};
|
||||
|
||||
export default abstract class Application {
|
||||
public data: ApplicationData | undefined;
|
||||
/**
|
||||
* The forum model for this application.
|
||||
*/
|
||||
forum: Forum;
|
||||
|
||||
public translator = new Translator();
|
||||
public bus = new Bus();
|
||||
data: ApplicationData | undefined;
|
||||
|
||||
public routes = {};
|
||||
translator = new Translator();
|
||||
bus = new Bus();
|
||||
|
||||
/**
|
||||
* The app's session.
|
||||
*/
|
||||
session?: Session;
|
||||
|
||||
/**
|
||||
* The app's data store.
|
||||
*/
|
||||
store = new Store({
|
||||
forums: Forum,
|
||||
users: User,
|
||||
discussions: Discussion,
|
||||
posts: Post,
|
||||
groups: Group,
|
||||
// notifications: Notification
|
||||
});
|
||||
|
||||
routes = {};
|
||||
|
||||
title = '';
|
||||
titleCount = 0;
|
||||
|
||||
/**
|
||||
* An Alert that was shown as a result of an AJAX request error. If present,
|
||||
* it will be dismissed on the next successful request.
|
||||
*/
|
||||
private requestError: Alert = null;
|
||||
|
||||
mount(basePath = '') {
|
||||
// this.modal = m.mount(document.getElementById('modal'), <ModalManager />);
|
||||
@ -28,24 +71,32 @@ export default abstract class Application {
|
||||
|
||||
// this.drawer = new Drawer();
|
||||
|
||||
m.mount(document.getElementById('header'), this.layout);
|
||||
|
||||
m.route(document.getElementById('content'), basePath + '/', mapRoutes(this.routes, basePath));
|
||||
}
|
||||
|
||||
abstract get layout();
|
||||
|
||||
boot(payload: any) {
|
||||
this.data = payload;
|
||||
|
||||
this.store.pushPayload({ data: this.data.resources });
|
||||
|
||||
this.forum = this.store.getById('forums', 1);
|
||||
|
||||
this.session = new Session(
|
||||
this.store.getById('users', this.data.session.userId),
|
||||
this.data.session.csrfToken
|
||||
);
|
||||
|
||||
this.locale();
|
||||
this.plugins();
|
||||
this.setupRoutes();
|
||||
this.mount();
|
||||
|
||||
this.bus.dispatch('app.booting');
|
||||
}
|
||||
|
||||
locale() {
|
||||
this.translator.locale = this.data.locale;
|
||||
|
||||
this.bus.dispatch('app.locale');
|
||||
}
|
||||
|
||||
@ -53,6 +104,47 @@ export default abstract class Application {
|
||||
this.bus.dispatch('app.plugins');
|
||||
}
|
||||
|
||||
setupRoutes() {
|
||||
this.bus.dispatch('app.routes');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the API response document that has been preloaded into the application.
|
||||
*/
|
||||
preloadedApiDocument() {
|
||||
if (this.data.apiDocument) {
|
||||
const results = this.store.pushPayload(this.data.apiDocument);
|
||||
|
||||
this.data.apiDocument = null;
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the <title> of the page.
|
||||
*/
|
||||
setTitle(title: string) {
|
||||
this.title = title;
|
||||
this.updateTitle();
|
||||
}
|
||||
|
||||
/**
|
||||
* Set a number to display in the <title> of the page.
|
||||
*/
|
||||
setTitleCount(count: number) {
|
||||
this.titleCount = count;
|
||||
this.updateTitle();
|
||||
}
|
||||
|
||||
updateTitle() {
|
||||
document.title = (this.titleCount ? `(${this.titleCount}) ` : '') +
|
||||
(this.title ? this.title + ' - ' : '') +
|
||||
this.forum.attribute('title');
|
||||
}
|
||||
|
||||
/**
|
||||
* Construct a URL to the route with the given name.
|
||||
*/
|
||||
@ -63,9 +155,131 @@ export default abstract class Application {
|
||||
|
||||
const url = route.path.replace(/:([^\/]+)/g, (m, key) => extract(params, key));
|
||||
const queryString = m.buildQueryString(params);
|
||||
const prefix = ''; // TODO: use app base path
|
||||
// const prefix = m.route.mode === 'pathname' ? (app: any).forum.attribute('basePath') : '';
|
||||
const prefix = m.route.prefix === '' ? this.forum.attribute('basePath') : '';
|
||||
|
||||
return prefix + url + (queryString ? '?' + queryString : '');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Make an AJAX request, handling any low-level errors that may occur.
|
||||
*
|
||||
* @see https://mithril.js.org/request.html
|
||||
*/
|
||||
request(originalOptions: Mithril.RequestOptions|any): Promise<any> {
|
||||
const options: Mithril.RequestOptions = Object.assign({}, originalOptions);
|
||||
|
||||
// Set some default options if they haven't been overridden. We want to
|
||||
// authenticate all requests with the session token. We also want all
|
||||
// requests to run asynchronously in the background, so that they don't
|
||||
// prevent redraws from occurring.
|
||||
options.background = options.background || true;
|
||||
|
||||
extend(options, 'config', (result, xhr: XMLHttpRequest) => xhr.setRequestHeader('X-CSRF-Token', this.session.csrfToken));
|
||||
|
||||
// If the method is something like PATCH or DELETE, which not all servers
|
||||
// and clients support, then we'll send it as a POST request with the
|
||||
// intended method specified in the X-HTTP-Method-Override header.
|
||||
if (options.method !== 'GET' && options.method !== 'POST') {
|
||||
const method = options.method;
|
||||
extend(options, 'config', (result, xhr: XMLHttpRequest) => xhr.setRequestHeader('X-HTTP-Method-Override', method));
|
||||
options.method = 'POST';
|
||||
}
|
||||
|
||||
// When we deserialize JSON data, if for some reason the server has provided
|
||||
// a dud response, we don't want the application to crash. We'll show an
|
||||
// error message to the user instead.
|
||||
options.deserialize = options.deserialize || (responseText => responseText);
|
||||
|
||||
options.errorHandler = options.errorHandler || (error => {
|
||||
throw error;
|
||||
});
|
||||
|
||||
// When extracting the data from the response, we can check the server
|
||||
// response code and show an error message to the user if something's gone
|
||||
// awry.
|
||||
const original = options.extract;
|
||||
options.extract = xhr => {
|
||||
let responseText;
|
||||
|
||||
if (original) {
|
||||
responseText = original(xhr.responseText);
|
||||
} else {
|
||||
responseText = xhr.responseText || null;
|
||||
}
|
||||
|
||||
const status = xhr.status;
|
||||
|
||||
if (status < 200 || status > 299) {
|
||||
throw new RequestError(status, responseText, options, xhr);
|
||||
}
|
||||
|
||||
if (xhr.getResponseHeader) {
|
||||
const csrfToken = xhr.getResponseHeader('X-CSRF-Token');
|
||||
if (csrfToken) app.session.csrfToken = csrfToken;
|
||||
}
|
||||
|
||||
try {
|
||||
return JSON.parse(responseText);
|
||||
} catch (e) {
|
||||
throw new RequestError(500, responseText, options, xhr);
|
||||
}
|
||||
};
|
||||
|
||||
// TODO: ALERT MANAGER
|
||||
// if (this.requestError) this.alerts.dismiss(this.requestError.alert);
|
||||
|
||||
// Now make the request. If it's a failure, inspect the error that was
|
||||
// returned and show an alert containing its contents.
|
||||
// const deferred = m.deferred();
|
||||
|
||||
// return new Promise((resolve, reject) => )
|
||||
|
||||
return m.request(options)
|
||||
.catch(error => {
|
||||
this.requestError = error;
|
||||
|
||||
let children;
|
||||
|
||||
switch (error.status) {
|
||||
case 422:
|
||||
children = error.response.errors
|
||||
.map(error => [error.detail, m('br')])
|
||||
.reduce((a, b) => a.concat(b), [])
|
||||
.slice(0, -1);
|
||||
break;
|
||||
|
||||
case 401:
|
||||
case 403:
|
||||
children = this.translator.trans('core.lib.error.permission_denied_message');
|
||||
break;
|
||||
|
||||
case 404:
|
||||
case 410:
|
||||
children = this.translator.trans('core.lib.error.not_found_message');
|
||||
break;
|
||||
|
||||
case 429:
|
||||
children = this.translator.trans('core.lib.error.rate_limit_exceeded_message');
|
||||
break;
|
||||
|
||||
default:
|
||||
children = this.translator.trans('core.lib.error.generic_message');
|
||||
}
|
||||
|
||||
error.alert = Alert.component({
|
||||
type: 'error',
|
||||
children
|
||||
});
|
||||
|
||||
try {
|
||||
options.errorHandler(error);
|
||||
} catch (error) {
|
||||
// this.alerts.show(error.alert);
|
||||
}
|
||||
|
||||
return Promise.reject(error);
|
||||
});
|
||||
|
||||
// return deferred.promise;
|
||||
}
|
||||
}
|
||||
|
@ -1,25 +1,45 @@
|
||||
import Mithril from 'mithril';
|
||||
|
||||
export interface ComponentProps {
|
||||
oninit: Function;
|
||||
view: Function;
|
||||
component: Object;
|
||||
props: Object;
|
||||
attrs: { key: string } | null;
|
||||
export type ComponentProps = {
|
||||
children?: Mithril.Children,
|
||||
|
||||
className?: string;
|
||||
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
export default class Component {
|
||||
export default class Component<T extends ComponentProps = any> {
|
||||
protected element: HTMLElement;
|
||||
|
||||
protected props = <T> {};
|
||||
|
||||
view(vnode) {
|
||||
throw new Error('Component#view must be implemented by subclass');
|
||||
}
|
||||
|
||||
oninit(vnode) {
|
||||
this.setProps(vnode.attrs);
|
||||
}
|
||||
|
||||
oncreate(vnode) {
|
||||
this.setProps(vnode.attrs);
|
||||
this.element = vnode.dom;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param vnode
|
||||
*/
|
||||
view(vnode) {
|
||||
throw new Error('Component#view must be implemented by subclass');
|
||||
onbeforeupdate(vnode) {
|
||||
this.setProps(vnode.attrs);
|
||||
}
|
||||
|
||||
onupdate(vnode) {
|
||||
this.setProps(vnode.attrs);
|
||||
}
|
||||
|
||||
onbeforeremove(vnode) {
|
||||
this.setProps(vnode.attrs);
|
||||
}
|
||||
|
||||
onremove(vnode) {
|
||||
this.setProps(vnode.attrs);
|
||||
}
|
||||
|
||||
/**
|
||||
@ -35,20 +55,25 @@ export default class Component {
|
||||
* @returns {jQuery} the jQuery object for the DOM node
|
||||
* @final
|
||||
*/
|
||||
public $(selector?: string) {
|
||||
$(selector?: string) {
|
||||
const $element = $(this.element);
|
||||
|
||||
return selector ? $element.find(selector) : $element;
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated add component via m(Component, props) directly
|
||||
*/
|
||||
public static component(props: any = {}, children?: Mithril.ChildArrayOrPrimitive) {
|
||||
const componentProps = Object.assign({}, props);
|
||||
static component(props: ComponentProps|any = {}, children?: Mithril.Children) {
|
||||
const componentProps: ComponentProps = Object.assign({}, props);
|
||||
|
||||
if (children) componentProps.children = children;
|
||||
|
||||
return m(this, componentProps);
|
||||
}
|
||||
|
||||
static initProps(props: ComponentProps = {}) {}
|
||||
|
||||
private setProps(props: T) {
|
||||
(this.constructor as typeof Component).initProps(props);
|
||||
|
||||
this.props = props;
|
||||
}
|
||||
}
|
||||
|
280
js/src/common/Model.ts
Normal file
280
js/src/common/Model.ts
Normal file
@ -0,0 +1,280 @@
|
||||
/**
|
||||
* The `Model` class represents a local data resource. It provides methods to
|
||||
* persist changes via the API.
|
||||
*
|
||||
* @abstract
|
||||
*/
|
||||
import Store from "./Store";
|
||||
|
||||
export default class Model {
|
||||
/**
|
||||
* The resource object from the API.
|
||||
*/
|
||||
data: any;
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
freshness: Date;
|
||||
|
||||
/**
|
||||
* Whether or not the resource exists on the server.
|
||||
*/
|
||||
exists: boolean;
|
||||
|
||||
/**
|
||||
* The data store that this resource should be persisted to.
|
||||
*/
|
||||
protected store: Store;
|
||||
|
||||
/**
|
||||
* @param {Object} data A resource object from the API.
|
||||
* @param {Store} store The data store that this model should be persisted to.
|
||||
*/
|
||||
constructor(data = {}, store = null) {
|
||||
this.data = data;
|
||||
this.store = store;
|
||||
|
||||
this.freshness = new Date();
|
||||
this.exists = false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the model's ID.
|
||||
* @final
|
||||
*/
|
||||
id(): number {
|
||||
return this.data.id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get one of the model's attributes.
|
||||
* @final
|
||||
*/
|
||||
attribute(attribute: string): any {
|
||||
return this.data.attributes[attribute];
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge new data into this model locally.
|
||||
*
|
||||
* @param data A resource object to merge into this model
|
||||
* @public
|
||||
*/
|
||||
pushData(data: {}) {
|
||||
// Since most of the top-level items in a resource object are objects
|
||||
// (e.g. relationships, attributes), we'll need to check and perform the
|
||||
// merge at the second level if that's the case.
|
||||
for (const key in data) {
|
||||
if (typeof data[key] === 'object') {
|
||||
this.data[key] = this.data[key] || {};
|
||||
|
||||
// For every item in a second-level object, we want to check if we've
|
||||
// been handed a Model instance. If so, we will convert it to a
|
||||
// relationship data object.
|
||||
for (const innerKey in data[key]) {
|
||||
if (data[key][innerKey] instanceof Model) {
|
||||
data[key][innerKey] = {data: Model.getIdentifier(data[key][innerKey])};
|
||||
}
|
||||
this.data[key][innerKey] = data[key][innerKey];
|
||||
}
|
||||
} else {
|
||||
this.data[key] = data[key];
|
||||
}
|
||||
}
|
||||
|
||||
// Now that we've updated the data, we can say that the model is fresh.
|
||||
// This is an easy way to invalidate retained subtrees etc.
|
||||
this.freshness = new Date();
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge new attributes into this model locally.
|
||||
*
|
||||
* @param {Object} attributes The attributes to merge.
|
||||
*/
|
||||
pushAttributes(attributes: any) {
|
||||
this.pushData({attributes});
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge new attributes into this model, both locally and with persistence.
|
||||
*
|
||||
* @param attributes The attributes to save. If a 'relationships' key
|
||||
* exists, it will be extracted and relationships will also be saved.
|
||||
* @param [options]
|
||||
* @return {Promise}
|
||||
*/
|
||||
save(attributes: any, options: any = {}): Promise {
|
||||
const data = {
|
||||
type: this.data.type,
|
||||
id: this.data.id,
|
||||
attributes,
|
||||
relationships: undefined
|
||||
};
|
||||
|
||||
// If a 'relationships' key exists, extract it from the attributes hash and
|
||||
// set it on the top-level data object instead. We will be sending this data
|
||||
// object to the API for persistence.
|
||||
if (attributes.relationships) {
|
||||
data.relationships = {};
|
||||
|
||||
for (const key in attributes.relationships) {
|
||||
const model = attributes.relationships[key];
|
||||
|
||||
data.relationships[key] = {
|
||||
data: model instanceof Array
|
||||
? model.map(Model.getIdentifier)
|
||||
: Model.getIdentifier(model)
|
||||
};
|
||||
}
|
||||
|
||||
delete attributes.relationships;
|
||||
}
|
||||
|
||||
// Before we update the model's data, we should make a copy of the model's
|
||||
// old data so that we can revert back to it if something goes awry during
|
||||
// persistence.
|
||||
const oldData = this.copyData();
|
||||
|
||||
this.pushData(data);
|
||||
|
||||
const request = {data};
|
||||
if (options.meta) request.meta = options.meta;
|
||||
|
||||
return app.request(Object.assign({
|
||||
method: this.exists ? 'PATCH' : 'POST',
|
||||
url: app.forum.attribute('apiUrl') + this.apiEndpoint(),
|
||||
data: request
|
||||
}, options)).then(
|
||||
// If everything went well, we'll make sure the store knows that this
|
||||
// model exists now (if it didn't already), and we'll push the data that
|
||||
// the API returned into the store.
|
||||
payload => {
|
||||
this.store.data[payload.data.type] = this.store.data[payload.data.type] || {};
|
||||
this.store.data[payload.data.type][payload.data.id] = this;
|
||||
return this.store.pushPayload(payload);
|
||||
},
|
||||
|
||||
// If something went wrong, though... good thing we backed up our model's
|
||||
// old data! We'll revert to that and let others handle the error.
|
||||
response => {
|
||||
this.pushData(oldData);
|
||||
m.lazyRedraw();
|
||||
throw response;
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a request to delete the resource.
|
||||
*
|
||||
* @param {Object} data Data to send along with the DELETE request.
|
||||
* @param {Object} [options]
|
||||
* @return {Promise}
|
||||
* @public
|
||||
*/
|
||||
delete(data, options = {}) {
|
||||
if (!this.exists) return m.deferred.resolve().promise;
|
||||
|
||||
return app.request(Object.assign({
|
||||
method: 'DELETE',
|
||||
url: app.forum.attribute('apiUrl') + this.apiEndpoint(),
|
||||
data
|
||||
}, options)).then(() => {
|
||||
this.exists = false;
|
||||
this.store.remove(this);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Construct a path to the API endpoint for this resource.
|
||||
*
|
||||
* @return {String}
|
||||
* @protected
|
||||
*/
|
||||
apiEndpoint() {
|
||||
return '/' + this.data.type + (this.exists ? '/' + this.data.id : '');
|
||||
}
|
||||
|
||||
copyData() {
|
||||
return JSON.parse(JSON.stringify(this.data));
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a function which returns the value of the given attribute.
|
||||
*
|
||||
* @param {String} name
|
||||
* @param [transform] A function to transform the attribute value
|
||||
*/
|
||||
static attribute(name: string, transform?: Function): () => any {
|
||||
return function() {
|
||||
const value = this.data.attributes && this.data.attributes[name];
|
||||
|
||||
return transform ? transform(value) : value;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a function which returns the value of the given has-one
|
||||
* relationship.
|
||||
*
|
||||
* @return 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.
|
||||
*/
|
||||
static hasOne(name: string): () => Model|boolean {
|
||||
return function() {
|
||||
if (this.data.relationships) {
|
||||
const relationship = this.data.relationships[name];
|
||||
|
||||
if (relationship) {
|
||||
return app.store.getById(relationship.data.type, relationship.data.id);
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a function which returns the value of the given has-many
|
||||
* relationship.
|
||||
*
|
||||
* @return 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.
|
||||
*/
|
||||
static hasMany(name: string): () => []|boolean {
|
||||
return function() {
|
||||
if (this.data.relationships) {
|
||||
const relationship = this.data.relationships[name];
|
||||
|
||||
if (relationship) {
|
||||
return relationship.data.map(data => app.store.getById(data.type, data.id));
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Transform the given value into a Date object.
|
||||
*/
|
||||
static transformDate(value: string): Date {
|
||||
return value ? new Date(value) : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a resource identifier object for the given model.
|
||||
*/
|
||||
protected static getIdentifier(model: Model): { type: string, id: string } {
|
||||
return {
|
||||
type: model.data.type,
|
||||
id: model.data.id
|
||||
};
|
||||
}
|
||||
}
|
41
js/src/common/Session.ts
Normal file
41
js/src/common/Session.ts
Normal file
@ -0,0 +1,41 @@
|
||||
/**
|
||||
* The `Session` class defines the current user session. It stores a reference
|
||||
* to the current authenticated user, and provides methods to log in/out.
|
||||
*/
|
||||
export default class Session {
|
||||
/**
|
||||
* The current authenticated user.
|
||||
*/
|
||||
user?: User;
|
||||
|
||||
/**
|
||||
* The CSRF token.
|
||||
*/
|
||||
csrfToken?: string;
|
||||
|
||||
constructor(user, csrfToken) {
|
||||
this.user = user;
|
||||
|
||||
this.csrfToken = csrfToken;
|
||||
}
|
||||
|
||||
/**
|
||||
* Attempt to log in a user.
|
||||
*/
|
||||
login(data: { identification: string, password: string }, options = {}): Promise {
|
||||
return app.request(Object.assign({
|
||||
method: 'POST',
|
||||
url: app.forum.attribute('baseUrl') + '/login',
|
||||
data
|
||||
}, options));
|
||||
}
|
||||
|
||||
/**
|
||||
* Log the user out.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
logout() {
|
||||
window.location = app.forum.attribute('baseUrl') + '/logout?token=' + this.csrfToken;
|
||||
}
|
||||
}
|
147
js/src/common/Store.ts
Normal file
147
js/src/common/Store.ts
Normal file
@ -0,0 +1,147 @@
|
||||
import Model from './Model';
|
||||
|
||||
/**
|
||||
* The `Store` class defines a local data store, and provides methods to
|
||||
* retrieve data from the API.
|
||||
*/
|
||||
export default class Store {
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
protected data: { [key: string]: { [key: number]: Model }} = {};
|
||||
|
||||
/**
|
||||
* The model registry. A map of resource types to the model class that
|
||||
* should be used to represent resources of that type.
|
||||
*/
|
||||
models: {};
|
||||
|
||||
constructor(models) {
|
||||
this.models = models;
|
||||
}
|
||||
|
||||
/**
|
||||
* Push resources contained within an API payload into the store.
|
||||
*
|
||||
* @param payload
|
||||
* @return The model(s) representing the resource(s) contained
|
||||
* within the 'data' key of the payload.
|
||||
*/
|
||||
pushPayload(payload: { included?: {}[], data?: {}|{}[] }): Model|Model[] {
|
||||
if (payload.included) payload.included.map(this.pushObject.bind(this));
|
||||
|
||||
const result: any = 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 The model, or null if no model class has been
|
||||
* registered for this resource type.
|
||||
*/
|
||||
pushObject(data): Model {
|
||||
if (!this.models[data.type]) return null;
|
||||
|
||||
const type = this.data[data.type] = this.data[data.type] || {};
|
||||
|
||||
if (type[data.id]) {
|
||||
type[data.id].pushData(data);
|
||||
} else {
|
||||
type[data.id] = this.createRecord(data.type, data);
|
||||
}
|
||||
|
||||
type[data.id].exists = true;
|
||||
|
||||
return type[data.id];
|
||||
}
|
||||
|
||||
/**
|
||||
* Make a request to the API to find record(s) of a specific type.
|
||||
*
|
||||
* @param type The resource type.
|
||||
* @param [id] The ID(s) of the model(s) to retrieve.
|
||||
* Alternatively, if an object is passed, it will be handled as the
|
||||
* `query` parameter.
|
||||
* @param query
|
||||
* @param options
|
||||
*/
|
||||
find<T extends Model = Model>(type: string, id?: number|Array<number>|any, query = {}, options = {}): Promise<T[]> {
|
||||
let data = query;
|
||||
let url = `${app.forum.attribute('apiUrl')}/${type}`;
|
||||
|
||||
if (id instanceof Array) {
|
||||
url += `?filter[id]=${id.join(',')}`;
|
||||
} else if (typeof id === 'object') {
|
||||
data = id;
|
||||
} else if (id) {
|
||||
url += `/${id}`;
|
||||
}
|
||||
|
||||
return app.request(Object.assign({
|
||||
method: 'GET',
|
||||
url,
|
||||
data
|
||||
}, options)).then(this.pushPayload.bind(this));
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a record from the store by ID.
|
||||
*
|
||||
* @param type The resource type.
|
||||
* @param id The resource ID.
|
||||
*/
|
||||
getById<T extends Model = Model>(type: string, id: number): T {
|
||||
return this.data[type] && this.data[type][id] as T;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a record from the store by the value of a model attribute.
|
||||
*
|
||||
* @param type The resource type.
|
||||
* @param key The name of the method on the model.
|
||||
* @param value The value of the model attribute.
|
||||
*/
|
||||
getBy<T extends Model = Model>(type: string, key: string, value: any): T {
|
||||
return this.all<T>(type).filter(model => model[key]() === value)[0];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all loaded records of a specific type.
|
||||
*/
|
||||
all<T extends Model = Model>(type: string): T[] {
|
||||
const records = this.data[type];
|
||||
|
||||
return records ? Object.keys(records).map(id => records[id]) : [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove the given model from the store.
|
||||
*/
|
||||
remove(model: Model) {
|
||||
delete this.data[model.data.type][model.id()];
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new record of the given type.
|
||||
*
|
||||
* @param {String} type The resource type
|
||||
* @param {Object} [data] Any data to initialize the model with
|
||||
*/
|
||||
createRecord<T extends Model = Model>(type: string, data: any = {}): T {
|
||||
data.type = data.type || type;
|
||||
|
||||
return new (this.models[type])(data, this);
|
||||
}
|
||||
}
|
@ -1,3 +1,6 @@
|
||||
import extract from './utils/extract';
|
||||
import username from './helpers/username';
|
||||
|
||||
type Translations = { [key: string]: string };
|
||||
|
||||
export default class Translator {
|
||||
@ -32,8 +35,8 @@ export default class Translator {
|
||||
|
||||
apply(translation: string, input: any) {
|
||||
if ('user' in input) {
|
||||
// const user = extract(input, 'user');
|
||||
// if (!input.username) input.username = username(user);
|
||||
const user = extract(input, 'user');
|
||||
if (!input.username) input.username = username(user);
|
||||
}
|
||||
|
||||
const parts = translation.split(new RegExp('({[a-z0-9_]+}|</?[a-z0-9_]+>)', 'gi'));
|
||||
|
@ -1,9 +1,5 @@
|
||||
import * as extend from './extend';
|
||||
|
||||
import Navigation from './components/Navigation';
|
||||
|
||||
export default {
|
||||
extend: extend,
|
||||
|
||||
'components/Navigation': Navigation,
|
||||
};
|
||||
|
66
js/src/common/components/Alert.tsx
Normal file
66
js/src/common/components/Alert.tsx
Normal file
@ -0,0 +1,66 @@
|
||||
import Component, {ComponentProps} from '../Component';
|
||||
import Button from './Button';
|
||||
import listItems from '../helpers/listItems';
|
||||
import extract from '../utils/extract';
|
||||
import * as Mithril from "mithril";
|
||||
|
||||
export interface AlertProps extends ComponentProps {
|
||||
controls?: Mithril.ChildArray,
|
||||
type?: string,
|
||||
dismissible?: boolean,
|
||||
|
||||
ondismiss?: () => any,
|
||||
}
|
||||
|
||||
/**
|
||||
* 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<AlertProps> {
|
||||
view() {
|
||||
const attrs: AlertProps = Object.assign({}, this.props);
|
||||
|
||||
const type: string = extract(attrs, 'type');
|
||||
attrs.className = `Alert Alert--${type} ${attrs.className || ''}`;
|
||||
|
||||
const children: Mithril.Children = extract(attrs, 'children');
|
||||
const controls: Mithril.ChildArray = extract(attrs, 'controls') || [];
|
||||
|
||||
// If the alert is meant to be dismissible (which is the case by default),
|
||||
// then we will create a dismiss button to append as the final control in
|
||||
// the alert.
|
||||
const dismissible: boolean|undefined = extract(attrs, 'dismissible');
|
||||
const ondismiss: () => any = extract(attrs, 'ondismiss');
|
||||
const dismissControl = [];
|
||||
|
||||
if (dismissible || dismissible === undefined) {
|
||||
dismissControl.push(
|
||||
<Button
|
||||
icon="fas fa-times"
|
||||
className="Button Button--link Button--icon Alert-dismiss"
|
||||
onclick={ondismiss}/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div {...attrs}>
|
||||
<span className="Alert-body">
|
||||
{children}
|
||||
</span>
|
||||
<ul className="Alert-controls">
|
||||
{listItems(controls.concat(dismissControl))}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
39
js/src/common/components/Badge.tsx
Normal file
39
js/src/common/components/Badge.tsx
Normal file
@ -0,0 +1,39 @@
|
||||
import Component from '../Component';
|
||||
import icon from '../helpers/icon';
|
||||
import extract from '../utils/extract';
|
||||
|
||||
/**
|
||||
* The `Badge` component represents a user/discussion badge, indicating some
|
||||
* status (e.g. a discussion is stickied, a user is an admin).
|
||||
*
|
||||
* A badge may have the following special props:
|
||||
*
|
||||
* - `type` The type of badge this is. This will be used to give the badge a
|
||||
* class name of `Badge--{type}`.
|
||||
* - `icon` The name of an icon to show inside the badge.
|
||||
* - `label`
|
||||
*
|
||||
* All other props will be assigned as attributes on the badge element.
|
||||
*/
|
||||
export default class Badge extends Component {
|
||||
view(vnode) {
|
||||
const attrs = vnode.attrs;
|
||||
const type = extract(attrs, 'type');
|
||||
const iconName = extract(attrs, 'icon');
|
||||
|
||||
attrs.className = `Badge ${type ? `Badge--${type}` : ''} ${attrs.className || ''}`;
|
||||
attrs.title = extract(attrs, 'label') || '';
|
||||
|
||||
return (
|
||||
<span {...attrs}>
|
||||
{iconName ? icon(iconName, {className: 'Badge-icon'}) : m.trust(' ')}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
oncreate(vnode) {
|
||||
super.oncreate(vnode);
|
||||
|
||||
if (this.props.label) this.$().tooltip({container: 'body'});
|
||||
}
|
||||
}
|
70
js/src/common/components/Button.tsx
Normal file
70
js/src/common/components/Button.tsx
Normal file
@ -0,0 +1,70 @@
|
||||
import Component, {ComponentProps} from '../Component';
|
||||
import icon from '../helpers/icon';
|
||||
import extract from '../utils/extract';
|
||||
import extractText from '../utils/extractText';
|
||||
import LoadingIndicator from './LoadingIndicator';
|
||||
|
||||
export interface ButtonProps extends ComponentProps {
|
||||
title?: string,
|
||||
type?: string,
|
||||
icon?: string,
|
||||
|
||||
loading?: boolean,
|
||||
disabled?: boolean,
|
||||
onclick?: Function
|
||||
}
|
||||
|
||||
/**
|
||||
* The `Button` component defines an element which, when clicked, performs an
|
||||
* action. The button may have the following special props:
|
||||
*
|
||||
* - `icon` The name of the icon class. If specified, the button will be given a
|
||||
* 'has-icon' class name.
|
||||
* - `disabled` Whether or not the button is disabled. If truthy, the button
|
||||
* will be given a 'disabled' class name, and any `onclick` handler will be
|
||||
* removed.
|
||||
* - `loading` Whether or not the button should be in a disabled loading state.
|
||||
*
|
||||
* All other props will be assigned as attributes on the button element.
|
||||
*
|
||||
* Note that a Button has no default class names. This is because a Button can
|
||||
* be used to represent any generic clickable control, like a menu item.
|
||||
*/
|
||||
export default class Button<T extends ButtonProps = ButtonProps> extends Component<T> {
|
||||
view(vnode) {
|
||||
const attrs: ButtonProps = vnode.attrs;
|
||||
const children = attrs.children;
|
||||
|
||||
delete attrs.children;
|
||||
|
||||
attrs.className = attrs.className || '';
|
||||
attrs.type = attrs.type || 'button';
|
||||
|
||||
// If nothing else is provided, we use the textual button content as tooltip
|
||||
if (!attrs.title && this.props.children) {
|
||||
attrs.title = extractText(this.props.children);
|
||||
}
|
||||
|
||||
const iconName = extract(attrs, 'icon');
|
||||
if (iconName) attrs.className += ' hasIcon';
|
||||
|
||||
const loading = extract(attrs, 'loading');
|
||||
if (attrs.disabled || loading) {
|
||||
attrs.className += ' disabled' + (loading ? ' loading' : '');
|
||||
delete attrs.onclick;
|
||||
}
|
||||
|
||||
return <button {...attrs}>{this.getButtonContent(attrs.icon, attrs.loading, children)}</button>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the template for the button's content.
|
||||
*/
|
||||
protected getButtonContent(iconName?: string|boolean, loading?: boolean, children?: any) : any[] {
|
||||
return [
|
||||
iconName && iconName !== true ? icon(iconName, {className: 'Button-icon'}) : '',
|
||||
children ? <span className="Button-label">{children}</span> : '',
|
||||
loading ? LoadingIndicator.component({size: 'tiny', className: 'LoadingIndicator--inline'}) : ''
|
||||
];
|
||||
}
|
||||
}
|
139
js/src/common/components/Dropdown.tsx
Normal file
139
js/src/common/components/Dropdown.tsx
Normal file
@ -0,0 +1,139 @@
|
||||
import Component, {ComponentProps} from '../Component';
|
||||
import icon from '../helpers/icon';
|
||||
import listItems from '../helpers/listItems';
|
||||
|
||||
export interface DropdownProps extends ComponentProps {
|
||||
buttonClassName?: string;
|
||||
menuClassName?: string;
|
||||
label?: string;
|
||||
icon?: string;
|
||||
caretIcon?: undefined|string;
|
||||
|
||||
onhide?: Function;
|
||||
onshow?: Function;
|
||||
onclick?: Function;
|
||||
}
|
||||
|
||||
/**
|
||||
* The `Dropdown` component displays a button which, when clicked, shows a
|
||||
* dropdown menu beneath it.
|
||||
*
|
||||
* ### Props
|
||||
*
|
||||
* - `buttonClassName` A class name to apply to the dropdown toggle button.
|
||||
* - `menuClassName` A class name to apply to the dropdown menu.
|
||||
* - `icon` The name of an icon to show in the dropdown toggle button.
|
||||
* - `caretIcon` The name of an icon to show on the right of the button.
|
||||
* - `label` The label of the dropdown toggle button. Defaults to 'Controls'.
|
||||
* - `onhide`
|
||||
* - `onshow`
|
||||
*
|
||||
* The children will be displayed as a list inside of the dropdown menu.
|
||||
*/
|
||||
export default class Dropdown<T extends DropdownProps = DropdownProps> extends Component<T> {
|
||||
showing: boolean;
|
||||
|
||||
static initProps(props: DropdownProps) {
|
||||
props.className = props.className || '';
|
||||
props.buttonClassName = props.buttonClassName || '';
|
||||
props.menuClassName = props.menuClassName || '';
|
||||
props.label = props.label || '';
|
||||
props.caretIcon = typeof props.caretIcon !== 'undefined' ? props.caretIcon : 'fas fa-caret-down';
|
||||
}
|
||||
|
||||
view() {
|
||||
const items = this.props.children ? listItems(this.props.children) : [];
|
||||
|
||||
return (
|
||||
<div className={`ButtonGroup Dropdown dropdown ${this.props.className} itemCount${items.length}${this.showing ? ' open' : ''}`}>
|
||||
{this.getButton()}
|
||||
{this.getMenu(items)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
oncreate(vnode) {
|
||||
super.oncreate(vnode);
|
||||
|
||||
this.$('> .Dropdown-toggle').dropdown();
|
||||
|
||||
// When opening the dropdown menu, work out if the menu goes beyond the
|
||||
// bottom of the viewport. If it does, we will apply class to make it show
|
||||
// above the toggle button instead of below it.
|
||||
this.element.addEventListener('shown.bs.dropdown', () => {
|
||||
this.showing = true;
|
||||
|
||||
if (this.props.onshow) {
|
||||
this.props.onshow();
|
||||
}
|
||||
|
||||
m.redraw();
|
||||
|
||||
const $menu = this.$('.Dropdown-menu');
|
||||
const isRight = $menu.hasClass('Dropdown-menu--right');
|
||||
|
||||
$menu.removeClass('Dropdown-menu--top Dropdown-menu--right');
|
||||
|
||||
$menu.toggleClass(
|
||||
'Dropdown-menu--top',
|
||||
$menu.offset().top + $menu.height() > $(window).scrollTop() + $(window).height()
|
||||
);
|
||||
|
||||
if ($menu.offset().top < 0) {
|
||||
$menu.removeClass('Dropdown-menu--top');
|
||||
}
|
||||
|
||||
$menu.toggleClass(
|
||||
'Dropdown-menu--right',
|
||||
isRight || $menu.offset().left + $menu.width() > $(window).scrollLeft() + $(window).width()
|
||||
);
|
||||
});
|
||||
|
||||
this.element.addEventListener('hidden.bs.dropdown', () => {
|
||||
this.showing = false;
|
||||
|
||||
if (this.props.onhide) {
|
||||
this.props.onhide();
|
||||
}
|
||||
|
||||
m.redraw();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the template for the button.
|
||||
*/
|
||||
protected getButton(): any {
|
||||
return (
|
||||
<button
|
||||
className={'Dropdown-toggle ' + this.props.buttonClassName}
|
||||
data-toggle="dropdown"
|
||||
onclick={this.props.onclick}>
|
||||
{this.getButtonContent()}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the template for the button's content.
|
||||
*
|
||||
* @return {*}
|
||||
*/
|
||||
protected getButtonContent() {
|
||||
const attrs = this.props;
|
||||
|
||||
return [
|
||||
attrs.icon ? icon(attrs.icon, {className: 'Button-icon'}) : '',
|
||||
<span className="Button-label">{attrs.label}</span>,
|
||||
attrs.caretIcon ? icon(attrs.caretIcon, {className: 'Button-caret'}) : ''
|
||||
];
|
||||
}
|
||||
|
||||
protected getMenu(items) {
|
||||
return (
|
||||
<ul className={'Dropdown-menu dropdown-menu ' + this.props.menuClassName}>
|
||||
{items}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
}
|
16
js/src/common/components/GroupBadge.ts
Normal file
16
js/src/common/components/GroupBadge.ts
Normal file
@ -0,0 +1,16 @@
|
||||
import Badge from './Badge';
|
||||
|
||||
export default class GroupBadge extends Badge {
|
||||
static initProps(props) {
|
||||
super.initProps(props);
|
||||
|
||||
if (props.group) {
|
||||
props.icon = props.group.icon();
|
||||
props.style = {backgroundColor: props.group.color()};
|
||||
props.label = typeof props.label === 'undefined' ? props.group.nameSingular() : props.label;
|
||||
props.type = `group--${props.group.id()}`;
|
||||
|
||||
delete props.group;
|
||||
}
|
||||
}
|
||||
}
|
43
js/src/common/components/LinkButton.tsx
Normal file
43
js/src/common/components/LinkButton.tsx
Normal file
@ -0,0 +1,43 @@
|
||||
import Button, {ButtonProps} from './Button';
|
||||
|
||||
interface LinkButtonProps extends ButtonProps {
|
||||
active: boolean;
|
||||
oncreate: Function;
|
||||
href?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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<LinkButtonProps> {
|
||||
static initProps(props: LinkButtonProps) {
|
||||
props.active = this.isActive(props);
|
||||
props.oncreate = props.oncreate || m.route;
|
||||
}
|
||||
|
||||
view(vnode) {
|
||||
const vdom = super.view(vnode);
|
||||
|
||||
vdom.tag = 'a';
|
||||
|
||||
return vdom;
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine whether a component with the given props is 'active'.
|
||||
*/
|
||||
static isActive(props: LinkButtonProps): boolean {
|
||||
return typeof props.active !== 'undefined'
|
||||
? props.active
|
||||
: m.route.get() === props.href;
|
||||
}
|
||||
}
|
43
js/src/common/components/LoadingIndicator.tsx
Normal file
43
js/src/common/components/LoadingIndicator.tsx
Normal file
@ -0,0 +1,43 @@
|
||||
import Component from '../Component';
|
||||
import {Spinner, SpinnerOptions} from 'spin.js';
|
||||
|
||||
/**
|
||||
* The `LoadingIndicator` component displays a loading spinner with spin.js. It
|
||||
* may have the following special props:
|
||||
*
|
||||
* - `size` The spin.js size preset to use. Defaults to 'small'.
|
||||
*
|
||||
* All other props will be assigned as attributes on the element.
|
||||
*/
|
||||
export default class LoadingIndicator extends Component {
|
||||
view(vnode) {
|
||||
const attrs = vnode.attrs;
|
||||
|
||||
attrs.className = 'LoadingIndicator ' + (attrs.className || '');
|
||||
delete attrs.size;
|
||||
|
||||
return <div {...attrs}>{m.trust(' ')}</div>;
|
||||
}
|
||||
|
||||
oncreate(vnode) {
|
||||
super.oncreate(vnode);
|
||||
|
||||
const options: SpinnerOptions = { zIndex: 'auto', color: this.$().css('color') };
|
||||
let sizeOptions: SpinnerOptions = {};
|
||||
|
||||
switch (vnode.attrs.size) {
|
||||
case 'large':
|
||||
sizeOptions = { lines: 10, length: 8, width: 4, radius: 8 };
|
||||
break;
|
||||
|
||||
case 'tiny':
|
||||
sizeOptions = { lines: 8, length: 2, width: 2, radius: 3 };
|
||||
break;
|
||||
|
||||
default:
|
||||
sizeOptions = { lines: 8, length: 4, width: 3, radius: 5 };
|
||||
}
|
||||
|
||||
new Spinner({ ...options, ...sizeOptions }).spin(this.element);
|
||||
}
|
||||
}
|
@ -1,18 +0,0 @@
|
||||
import Component from '../Component';
|
||||
|
||||
export default class Navigation extends Component {
|
||||
view(vnode) {
|
||||
return (
|
||||
<header id="header" className="App-header">
|
||||
<div id="header-navigation" className="Header-navigation"></div>
|
||||
<div className="container">
|
||||
<h1 className="Header-title">
|
||||
<a href="/">{vnode.attrs.title || '[TITLE]'}</a>
|
||||
</h1>
|
||||
<div id="header-primary" className="Header-primary"></div>
|
||||
<div id="header-secondary" className="Header-secondary"></div>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
}
|
38
js/src/common/components/SelectDropdown.tsx
Normal file
38
js/src/common/components/SelectDropdown.tsx
Normal file
@ -0,0 +1,38 @@
|
||||
import Dropdown, {DropdownProps} from './Dropdown';
|
||||
import icon from '../helpers/icon';
|
||||
|
||||
export interface SelectDropdownProps extends DropdownProps {
|
||||
defaultLabel?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* The `SelectDropdown` component is the same as a `Dropdown`, except the toggle
|
||||
* button's label is set as the label of the first child which has a truthy
|
||||
* `active` prop.
|
||||
*
|
||||
* ### Props
|
||||
*
|
||||
* - `caretIcon`
|
||||
* - `defaultLabel`
|
||||
*/
|
||||
export default class SelectDropdown extends Dropdown<SelectDropdownProps> {
|
||||
static initProps(props: SelectDropdownProps) {
|
||||
props.caretIcon = typeof props.caretIcon !== 'undefined' ? props.caretIcon : 'fas fa-sort';
|
||||
|
||||
super.initProps(props);
|
||||
|
||||
props.className += ' Dropdown--select';
|
||||
}
|
||||
|
||||
getButtonContent() {
|
||||
const activeChild = this.props.children.filter(child => child.attrs.active)[0];
|
||||
let label = activeChild && activeChild.attrs.children || this.props.defaultLabel;
|
||||
|
||||
if (label instanceof Array) label = label[0];
|
||||
|
||||
return [
|
||||
<span className="Button-label">{label}</span>,
|
||||
icon(this.props.caretIcon, {className: 'Button-caret'})
|
||||
];
|
||||
}
|
||||
}
|
12
js/src/common/components/Separator.tsx
Normal file
12
js/src/common/components/Separator.tsx
Normal file
@ -0,0 +1,12 @@
|
||||
import Component from '../Component';
|
||||
|
||||
/**
|
||||
* The `Separator` component defines a menu separator item.
|
||||
*/
|
||||
export default class Separator extends Component {
|
||||
static isListItem = true;
|
||||
|
||||
view() {
|
||||
return <li className="Dropdown-separator"/>;
|
||||
}
|
||||
}
|
36
js/src/common/helpers/avatar.tsx
Normal file
36
js/src/common/helpers/avatar.tsx
Normal file
@ -0,0 +1,36 @@
|
||||
/**
|
||||
* 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: any = {}) {
|
||||
attrs.className = 'Avatar ' + (attrs.className || '');
|
||||
let content = '';
|
||||
|
||||
// If the `title` attribute is set to null or false, we don't want to give the
|
||||
// avatar a title. On the other hand, if it hasn't been given at all, we can
|
||||
// safely default it to the user's username.
|
||||
const hasTitle = attrs.title === 'undefined' || attrs.title;
|
||||
if (!hasTitle) delete attrs.title;
|
||||
|
||||
// If a user has been passed, then we will set up an avatar using their
|
||||
// uploaded image, or the first letter of their username if they haven't
|
||||
// uploaded one.
|
||||
if (user) {
|
||||
const username = user.displayName() || '?';
|
||||
const avatarUrl = user.avatarUrl();
|
||||
|
||||
if (hasTitle) attrs.title = attrs.title || username;
|
||||
|
||||
if (avatarUrl) {
|
||||
return <img {...attrs} src={avatarUrl}/>;
|
||||
}
|
||||
|
||||
content = username.charAt(0).toUpperCase();
|
||||
attrs.style = {background: user.color()};
|
||||
}
|
||||
|
||||
return <span {...attrs}>{content}</span>;
|
||||
}
|
36
js/src/common/helpers/highlight.ts
Normal file
36
js/src/common/helpers/highlight.ts
Normal file
@ -0,0 +1,36 @@
|
||||
import { truncate } from '../utils/string';
|
||||
|
||||
/**
|
||||
* The `highlight` helper searches for a word phrase in a string, and wraps
|
||||
* matches with the <mark> tag.
|
||||
*
|
||||
* @param {String} string The string to highlight.
|
||||
* @param {String|RegExp} phrase The word or words to highlight.
|
||||
* @param {Integer} [length] The number of characters to truncate the string to.
|
||||
* The string will be truncated surrounding the first match.
|
||||
*/
|
||||
export default function highlight(string: string, phrase: string|RegExp, length?: number): any {
|
||||
if (!phrase && !length) return string;
|
||||
|
||||
// Convert the word phrase into a global regular expression (if it isn't
|
||||
// already) so we can search the string for matched.
|
||||
const regexp = phrase instanceof RegExp ? phrase : new RegExp(phrase, 'gi');
|
||||
|
||||
let highlighted = string;
|
||||
let start = 0;
|
||||
|
||||
// If a length was given, the truncate the string surrounding the first match.
|
||||
if (length) {
|
||||
if (phrase) start = Math.max(0, string.search(regexp) - length / 2);
|
||||
|
||||
highlighted = truncate(highlighted, length, start);
|
||||
}
|
||||
|
||||
// Convert the string into HTML entities, then highlight all matches with
|
||||
// <mark> tags. Then we will return the result as a trusted HTML string.
|
||||
highlighted = $('<div/>').text(highlighted).html();
|
||||
|
||||
if (phrase) highlighted = highlighted.replace(regexp, '<mark>$&</mark>');
|
||||
|
||||
return m.trust(highlighted);
|
||||
}
|
11
js/src/common/helpers/icon.tsx
Normal file
11
js/src/common/helpers/icon.tsx
Normal file
@ -0,0 +1,11 @@
|
||||
/**
|
||||
* The `icon` helper displays an icon.
|
||||
*
|
||||
* @param {String} fontClass The full icon class, prefix and the icon’s name.
|
||||
* @param {Object} attrs Any other attributes to apply.
|
||||
*/
|
||||
export default function icon(fontClass: string, attrs: any = {}) {
|
||||
attrs.className = 'icon ' + fontClass + ' ' + (attrs.className || '');
|
||||
|
||||
return <i {...attrs}/>;
|
||||
}
|
52
js/src/common/helpers/listItems.tsx
Normal file
52
js/src/common/helpers/listItems.tsx
Normal file
@ -0,0 +1,52 @@
|
||||
import Separator from '../components/Separator';
|
||||
|
||||
export function isSeparator(item) {
|
||||
return item && item.component === Separator;
|
||||
}
|
||||
|
||||
export function withoutUnnecessarySeparators(items) {
|
||||
const newItems = [];
|
||||
let prevItem;
|
||||
|
||||
items.forEach((item, i) => {
|
||||
if (!isSeparator(item) || (prevItem && !isSeparator(prevItem) && i !== items.length - 1)) {
|
||||
prevItem = item;
|
||||
newItems.push(item);
|
||||
}
|
||||
});
|
||||
|
||||
return newItems;
|
||||
}
|
||||
|
||||
/**
|
||||
* The `listItems` helper wraps a collection of components in <li> tags,
|
||||
* stripping out any unnecessary `Separator` components.
|
||||
*
|
||||
* @param {*} items
|
||||
* @return {Array}
|
||||
*/
|
||||
export default function listItems(items) {
|
||||
if (!(items instanceof Array)) items = [items];
|
||||
|
||||
return withoutUnnecessarySeparators(items).map(item => {
|
||||
const isListItem = item.component && item.component.isListItem;
|
||||
const active = item.component && item.component.isActive && item.component.isActive(item.props);
|
||||
const className = item.props ? item.props.itemClassName : item.itemClassName;
|
||||
|
||||
if (isListItem) {
|
||||
item.attrs = item.attrs || {};
|
||||
item.attrs.key = item.attrs.key || item.itemName;
|
||||
}
|
||||
|
||||
return isListItem
|
||||
? item
|
||||
: <li className={classNames([
|
||||
(item.itemName ? 'item-' + item.itemName : ''),
|
||||
className,
|
||||
(active ? 'active' : '')
|
||||
])}
|
||||
key={item.itemName}>
|
||||
{item}
|
||||
</li>;
|
||||
});
|
||||
}
|
11
js/src/common/helpers/username.tsx
Normal file
11
js/src/common/helpers/username.tsx
Normal file
@ -0,0 +1,11 @@
|
||||
/**
|
||||
* 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
|
||||
*/
|
||||
export default function username(user): any {
|
||||
const name = (user && user.displayName()) || app.translator.trans('core.lib.username.deleted_text');
|
||||
|
||||
return <span className="username">{name}</span>;
|
||||
}
|
@ -3,29 +3,28 @@ import 'expose-loader?moment!expose-loader?dayjs!dayjs';
|
||||
import 'expose-loader?m!mithril';
|
||||
import 'expose-loader?m.bidi!m.attrs.bidi';
|
||||
import 'expose-loader?Mousetrap!mousetrap';
|
||||
import 'expose-loader?classNames!classNames';
|
||||
|
||||
import 'zepto/src/selector';
|
||||
import 'zepto/src/data';
|
||||
import 'zepto/src/fx';
|
||||
import 'zepto/src/fx_methods';
|
||||
|
||||
// import './utils/patchZepto';
|
||||
import './utils/patchZepto';
|
||||
|
||||
// import 'hc-sticky';
|
||||
// import 'bootstrap/js/dropdown';
|
||||
// import 'bootstrap/js/transition';
|
||||
import 'hc-sticky';
|
||||
import 'bootstrap/js/dropdown';
|
||||
import 'bootstrap/js/transition';
|
||||
|
||||
import relativeTime from 'dayjs/plugin/relativeTime';
|
||||
import localizedFormat from 'dayjs/plugin/localizedFormat';
|
||||
|
||||
import dayjs from 'dayjs';
|
||||
|
||||
dayjs.extend(relativeTime);
|
||||
dayjs.extend(localizedFormat);
|
||||
|
||||
import patchMithril from './utils/patchMithril';
|
||||
|
||||
patchMithril(window);
|
||||
patchMithril();
|
||||
|
||||
// import * as Extend from './extend/index';
|
||||
|
||||
|
95
js/src/common/models/Discussion.tsx
Normal file
95
js/src/common/models/Discussion.tsx
Normal file
@ -0,0 +1,95 @@
|
||||
import Model from '../Model';
|
||||
import computed from '../utils/computed';
|
||||
import ItemList from '../utils/ItemList';
|
||||
import Badge from '../components/Badge';
|
||||
|
||||
import User from './User';
|
||||
import Post from './Post';
|
||||
|
||||
export default class Discussion extends Model {
|
||||
title = Model.attribute('title') as () => string;
|
||||
slug = Model.attribute('slug') as () => string;
|
||||
|
||||
createdAt = Model.attribute('createdAt', Model.transformDate) as () => Date;
|
||||
user = Model.hasOne('user') as () => User;
|
||||
firstPost = Model.hasOne('firstPost') as () => Post;
|
||||
|
||||
lastPostedAt = Model.attribute('lastPostedAt', Model.transformDate) as () => Date;
|
||||
lastPostedUser = Model.hasOne('lastPostedUser') as () => User;
|
||||
lastPost = Model.hasOne('lastPost') as () => Post;
|
||||
lastPostNumber = Model.attribute('lastPostNumber') as () => number;
|
||||
|
||||
commentCount = Model.attribute('commentCount') as () => number;
|
||||
replyCount = computed('commentCount', commentCount => Math.max(0, commentCount - 1)) as () => string;
|
||||
posts = Model.hasMany('posts') as () => Post[];
|
||||
mostRelevantPost = Model.hasOne('mostRelevantPost') as () => Post;
|
||||
|
||||
lastReadAt = Model.attribute('lastReadAt', Model.transformDate) as () => Date;
|
||||
lastReadPostNumber = Model.attribute('lastReadPostNumber') as () => number;
|
||||
isUnread = computed('unreadCount', unreadCount => !!unreadCount) as () => boolean;
|
||||
isRead = computed('unreadCount', unreadCount => app.session.user && !unreadCount) as () => boolean;
|
||||
|
||||
hiddenAt = Model.attribute('hiddenAt', Model.transformDate) as () => Date;
|
||||
hiddenUser = Model.hasOne('hiddenUser') as () => User;
|
||||
isHidden = computed('hiddenAt', hiddenAt => !!hiddenAt) as () => boolean;
|
||||
|
||||
canReply = Model.attribute('canReply') as () => boolean;
|
||||
canRename = Model.attribute('canRename') as () => boolean;
|
||||
canHide = Model.attribute('canHide') as () => boolean;
|
||||
canDelete = Model.attribute('canDelete') as () => boolean;
|
||||
|
||||
/**
|
||||
* Remove a post from the discussion's posts relationship.
|
||||
*
|
||||
* @param id The ID of the post to remove.
|
||||
*/
|
||||
removePost(id: number) {
|
||||
const relationships = this.data.relationships;
|
||||
const posts = relationships && relationships.posts;
|
||||
|
||||
if (posts) {
|
||||
posts.data.some((data, i) => {
|
||||
if (id === data.id) {
|
||||
posts.data.splice(i, 1);
|
||||
return true;
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the estimated number of unread posts in this discussion for the current
|
||||
* user.
|
||||
*/
|
||||
unreadCount(): number {
|
||||
const user = app.session.user;
|
||||
|
||||
if (user && user.markedAllAsReadAt() < this.lastPostedAt()) {
|
||||
return Math.max(0, this.lastPostNumber() - (this.lastReadPostNumber() || 0));
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the Badge components that apply to this discussion.
|
||||
*/
|
||||
badges(): ItemList {
|
||||
const items = new ItemList();
|
||||
|
||||
if (this.isHidden()) {
|
||||
items.add('hidden', <Badge type="hidden" icon="fas fa-trash" label={app.translator.trans('core.lib.badge.hidden_tooltip')}/>);
|
||||
}
|
||||
|
||||
return items;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a list of all of the post IDs in this discussion.
|
||||
*/
|
||||
postIds(): number[] {
|
||||
const posts = this.data.relationships.posts;
|
||||
|
||||
return posts ? posts.data.map(link => link.id) : [];
|
||||
}
|
||||
}
|
7
js/src/common/models/Forum.ts
Normal file
7
js/src/common/models/Forum.ts
Normal file
@ -0,0 +1,7 @@
|
||||
import Model from '../Model';
|
||||
|
||||
export default class Forum extends Model {
|
||||
apiEndpoint() {
|
||||
return '/';
|
||||
}
|
||||
}
|
12
js/src/common/models/Group.ts
Normal file
12
js/src/common/models/Group.ts
Normal file
@ -0,0 +1,12 @@
|
||||
import Model from '../Model';
|
||||
|
||||
export default class Group extends Model {
|
||||
static ADMINISTRATOR_ID = '1';
|
||||
static GUEST_ID = '2';
|
||||
static MEMBER_ID = '3';
|
||||
|
||||
nameSingular = Model.attribute('nameSingular') as () => string;
|
||||
namePlural = Model.attribute('namePlural') as () => string;
|
||||
color = Model.attribute('color') as () => string;
|
||||
icon = Model.attribute('icon') as () => string;
|
||||
}
|
30
js/src/common/models/Post.ts
Normal file
30
js/src/common/models/Post.ts
Normal file
@ -0,0 +1,30 @@
|
||||
import Model from '../Model';
|
||||
import computed from '../utils/computed';
|
||||
import { getPlainContent } from '../utils/string';
|
||||
|
||||
import Discussion from './Discussion';
|
||||
import User from "./User";
|
||||
|
||||
export default class Post extends Model {
|
||||
number = Model.attribute('number') as () => number;
|
||||
discussion = Model.hasOne('discussion') as () => Discussion;
|
||||
|
||||
createdAt= Model.attribute('createdAt', Model.transformDate) as () => Date;
|
||||
user = Model.hasOne('user') as () => User;
|
||||
contentType = Model.attribute('contentType') as () => string;
|
||||
content = Model.attribute('content') as () => string;
|
||||
contentHtml = Model.attribute('contentHtml') as () => string;
|
||||
contentPlain = computed('contentHtml', getPlainContent) as () => string;
|
||||
|
||||
editedAt = Model.attribute('editedAt', Model.transformDate) as () => Date;
|
||||
editedUser = Model.hasOne('editedUser') as () => User;
|
||||
isEdited = computed('editedAt', editedAt => !!editedAt) as () => boolean;
|
||||
|
||||
hiddenAt = Model.attribute('hiddenAt', Model.transformDate) as () => Date;
|
||||
hiddenUser = Model.hasOne('hiddenUser') as () => User;
|
||||
isHidden = computed('hiddenAt', hiddenAt => !!hiddenAt) as () => boolean;
|
||||
|
||||
canEdit = Model.attribute('canEdit') as () => boolean;
|
||||
canHide = Model.attribute('canHide') as () => boolean;
|
||||
canDelete = Model.attribute('canDelete') as () => boolean;
|
||||
}
|
@ -1,13 +0,0 @@
|
||||
import Component from '../Component';
|
||||
|
||||
export default class Route {
|
||||
public name;
|
||||
public path;
|
||||
public component;
|
||||
|
||||
constructor(name: string, path: string, component?: Component) {
|
||||
this.name = name;
|
||||
this.path = path;
|
||||
this.component = component;
|
||||
}
|
||||
}
|
97
js/src/common/models/User.ts
Normal file
97
js/src/common/models/User.ts
Normal file
@ -0,0 +1,97 @@
|
||||
import Model from '../Model';
|
||||
import stringToColor from '../utils/stringToColor';
|
||||
import ItemList from '../utils/ItemList';
|
||||
import computed from '../utils/computed';
|
||||
import GroupBadge from '../components/GroupBadge';
|
||||
import Group from "./Group";
|
||||
|
||||
export default class User extends Model {
|
||||
username = Model.attribute('username') as () => string;
|
||||
|
||||
displayName = Model.attribute('displayName') as () => string;
|
||||
email = Model.attribute('email') as () => string;
|
||||
isEmailConfirmed = Model.attribute('isEmailConfirmed') as () => boolean;
|
||||
password = Model.attribute('password') as () => string;
|
||||
|
||||
avatarUrl = Model.attribute('avatarUrl') as () => string;
|
||||
preferences = Model.attribute('preferences') as () => string;
|
||||
groups = Model.hasMany('groups') as () => Group[];
|
||||
|
||||
joinTime = Model.attribute('joinTime', Model.transformDate) as () => Date;
|
||||
lastSeenAt = Model.attribute('lastSeenAt', Model.transformDate) as () => Date;
|
||||
markedAllAsReadAt = Model.attribute('markedAllAsReadAt', Model.transformDate) as () => Date;
|
||||
unreadNotificationCount = Model.attribute('unreadNotificationCount') as () => number;
|
||||
newNotificationCount = Model.attribute('newNotificationCount') as () => number;
|
||||
|
||||
discussionCount = Model.attribute('discussionCount') as () => number;
|
||||
commentCount = Model.attribute('commentCount') as () => number;
|
||||
|
||||
canEdit = Model.attribute('canEdit') as () => boolean;
|
||||
canDelete = Model.attribute('canDelete') as () => boolean;
|
||||
|
||||
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);
|
||||
}) as () => string;
|
||||
|
||||
isOnline(): boolean {
|
||||
return this.lastSeenAt() > dayjs().subtract(5, 'minutes').toDate();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the Badge components that apply to this user.
|
||||
*/
|
||||
badges(): ItemList {
|
||||
const items = new ItemList();
|
||||
const groups = this.groups();
|
||||
|
||||
if (groups) {
|
||||
groups.forEach(group => {
|
||||
items.add('group' + group.id(), GroupBadge.component({group}));
|
||||
});
|
||||
}
|
||||
|
||||
return items;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate the dominant color of the user's avatar. The dominant color will
|
||||
* be set to the `avatarColor` property once it has been calculated.
|
||||
*
|
||||
* @protected
|
||||
*/
|
||||
calculateAvatarColor() {
|
||||
const image = new Image();
|
||||
const user = this;
|
||||
|
||||
image.onload = function() {
|
||||
const colorThief = new ColorThief();
|
||||
user.avatarColor = colorThief.getColor(this);
|
||||
user.freshness = new Date();
|
||||
m.redraw();
|
||||
};
|
||||
image.src = this.avatarUrl();
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the user's preferences.
|
||||
*/
|
||||
savePreferences(newPreferences: object): Promise<User> {
|
||||
const preferences = this.preferences();
|
||||
|
||||
Object.assign(preferences, newPreferences);
|
||||
|
||||
return this.save({preferences});
|
||||
}
|
||||
}
|
@ -1,7 +1,7 @@
|
||||
class Item {
|
||||
public content: any;
|
||||
public priority: number;
|
||||
public key: number = 0;
|
||||
content: any;
|
||||
priority: number;
|
||||
key: number = 0;
|
||||
|
||||
constructor(content: any, priority: number) {
|
||||
this.content = content;
|
||||
@ -45,7 +45,7 @@ export default class ItemList {
|
||||
* @return {*}
|
||||
* @public
|
||||
*/
|
||||
get(key: any): any {
|
||||
get(key: any) {
|
||||
return this.items[key].content;
|
||||
}
|
||||
|
||||
@ -59,13 +59,13 @@ export default class ItemList {
|
||||
* @return {ItemList}
|
||||
* @public
|
||||
*/
|
||||
add(key: any, content: any, priority = 0) {
|
||||
add(key: any, content: T, priority = 0) {
|
||||
this.items[key] = new Item(content, priority);
|
||||
|
||||
return this;
|
||||
}
|
||||
|
||||
get toArray() {
|
||||
toArray<T>(): T[] {
|
||||
const items: Item[] = [];
|
||||
|
||||
for (const i in this.items) {
|
||||
|
27
js/src/common/utils/RequestError.ts
Normal file
27
js/src/common/utils/RequestError.ts
Normal file
@ -0,0 +1,27 @@
|
||||
import Mithril from "mithril";
|
||||
|
||||
import Alert from "../components/Alert";
|
||||
|
||||
export default class RequestError {
|
||||
status: number;
|
||||
responseText: string;
|
||||
options: Mithril.RequestOptions;
|
||||
xhr: XMLHttpRequest;
|
||||
response?: JSON;
|
||||
alert?: Alert;
|
||||
|
||||
constructor(status, responseText, options, xhr) {
|
||||
this.status = status;
|
||||
this.responseText = responseText;
|
||||
this.options = options;
|
||||
this.xhr = xhr;
|
||||
|
||||
try {
|
||||
this.response = JSON.parse(responseText);
|
||||
} catch (e) {
|
||||
this.response = null;
|
||||
}
|
||||
|
||||
this.alert = null;
|
||||
}
|
||||
}
|
36
js/src/common/utils/computed.ts
Normal file
36
js/src/common/utils/computed.ts
Normal file
@ -0,0 +1,36 @@
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
export default function computed(...dependentKeys: Array<string|Function>): () => any {
|
||||
const keys = <string[]> dependentKeys.slice(0, -1);
|
||||
const compute = <Function> dependentKeys.slice(-1)[0];
|
||||
|
||||
const dependentValues = {};
|
||||
let computedValue;
|
||||
|
||||
return function() {
|
||||
let recompute = false;
|
||||
|
||||
// Read all of the dependent values. If any of them have changed since last
|
||||
// time, then we'll want to recompute our output.
|
||||
keys.forEach(key => {
|
||||
const value = typeof this[key] === 'function' ? this[key]() : this[key];
|
||||
|
||||
if (dependentValues[key] !== value) {
|
||||
recompute = true;
|
||||
dependentValues[key] = value;
|
||||
}
|
||||
});
|
||||
|
||||
if (recompute) {
|
||||
computedValue = compute.apply(this, keys.map(key => dependentValues[key]));
|
||||
}
|
||||
|
||||
return computedValue;
|
||||
};
|
||||
}
|
15
js/src/common/utils/extractText.ts
Normal file
15
js/src/common/utils/extractText.ts
Normal file
@ -0,0 +1,15 @@
|
||||
/**
|
||||
* Extract the text nodes from a virtual element.
|
||||
*
|
||||
* @param {VirtualElement} vdom
|
||||
* @return {String}
|
||||
*/
|
||||
export default function extractText(vdom: any): string {
|
||||
if (vdom instanceof Array) {
|
||||
return vdom.map(element => extractText(element)).join('');
|
||||
} else if (typeof vdom === 'object' && vdom !== null) {
|
||||
return extractText(vdom.children);
|
||||
} else {
|
||||
return vdom;
|
||||
}
|
||||
}
|
@ -1,46 +1,9 @@
|
||||
import Component from '../Component';
|
||||
import prop from 'mithril/stream';
|
||||
|
||||
export default function patchMithril(global) {
|
||||
// const mo = global.m;
|
||||
export default () => {
|
||||
m.withAttr = (key: string, cb: Function) => function () {
|
||||
cb(this.getAttribute(key));
|
||||
};
|
||||
|
||||
// const m = function(comp, ...args) {
|
||||
// console.log('m', comp, ...args);
|
||||
// if (comp.prototype && comp.prototype instanceof Component) {
|
||||
// let children = args.slice(1);
|
||||
// if (children.length === 1 && Array.isArray(children[0])) {
|
||||
// children = children[0]
|
||||
// }
|
||||
|
||||
// return comp.component(args[0], children);
|
||||
// }
|
||||
|
||||
// const node = mo.apply(this, arguments);
|
||||
|
||||
// if (node.attrs.bidi) {
|
||||
// m.bidi(node, node.attrs.bidi);
|
||||
// }
|
||||
|
||||
// if (node.attrs.route) {
|
||||
// node.attrs.href = node.attrs.route;
|
||||
// node.attrs.config = m.route;
|
||||
|
||||
// delete node.attrs.route;
|
||||
// }
|
||||
|
||||
// return node;
|
||||
// };
|
||||
|
||||
// Object.keys(mo).forEach(key => m[key] = mo[key]);
|
||||
|
||||
// // /**
|
||||
// // * Redraw only if not in the middle of a computation (e.g. a route change).
|
||||
// // *
|
||||
// // * @return {void}
|
||||
// // */
|
||||
// // m.lazyRedraw = function() {
|
||||
// // m.startComputation();
|
||||
// // m.endComputation();
|
||||
// // };
|
||||
|
||||
// global.m = m;
|
||||
}
|
||||
m.prop = prop;
|
||||
}
|
||||
|
138
js/src/common/utils/patchZepto.ts
Normal file
138
js/src/common/utils/patchZepto.ts
Normal file
@ -0,0 +1,138 @@
|
||||
import jump from 'jump.js';
|
||||
import Tooltip from 'tooltip.js';
|
||||
|
||||
// add $.fn.tooltip
|
||||
$.fn.tooltip = function (option) {
|
||||
return this.each(function () {
|
||||
const $this = $(this);
|
||||
let data = $this.data('bs.tooltip');
|
||||
const options = typeof option === 'object' && option || {};
|
||||
|
||||
if ($this.attr('title')) {
|
||||
options.title = $this.attr('title');
|
||||
$this.removeAttr('title');
|
||||
$this.attr('data-original-title', options.title);
|
||||
}
|
||||
|
||||
if (option === 'destroy') option = 'dispose';
|
||||
|
||||
if (!data && ['dispose', 'hide'].includes(option)) return;
|
||||
|
||||
if (!data) $this.data('bs.tooltip', (data = new Tooltip(this, options)));
|
||||
if (typeof option === 'string' && data[option]) data[option]();
|
||||
});
|
||||
};
|
||||
|
||||
// add $.fn.outerWidth and $.fn.outerHeight
|
||||
['width', 'height'].forEach(function(dimension) {
|
||||
const Dimension = dimension.replace(/./, function (m) {
|
||||
return m[0].toUpperCase()
|
||||
});
|
||||
|
||||
$.fn[`outer${Dimension}`] = function(margin) {
|
||||
const elem = this;
|
||||
|
||||
if (elem) {
|
||||
const sides = {'width': ['left', 'right'], 'height': ['top', 'bottom']};
|
||||
let size = elem[dimension]();
|
||||
|
||||
sides[dimension].forEach(function(side) {
|
||||
if (margin) size += parseInt(elem.css('margin-' + side), 10);
|
||||
});
|
||||
|
||||
return size;
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
// allow use of $(':input')
|
||||
// @ts-ignore
|
||||
$.expr[':']['input'] = function() {
|
||||
if (('disabled' in this) || ['INPUT', 'SELECT', 'TEXTAREA', 'BUTTON'].includes(this.tagName)) return this;
|
||||
};
|
||||
|
||||
// add $().hover() method
|
||||
$.fn.hover = function(hover, leave) {
|
||||
return this
|
||||
.on('mouseenter', hover)
|
||||
.on('mouseleave', leave || hover);
|
||||
};
|
||||
|
||||
// add animated scroll
|
||||
$.fn.animatedScrollTop = function (to, duration = $.fx.speeds._default, callback) {
|
||||
if (typeof to === 'number') to -= (window.scrollY || window.pageYOffset);
|
||||
|
||||
jump(to, {
|
||||
duration: $.fx.speeds[duration] || duration,
|
||||
callback
|
||||
});
|
||||
|
||||
return this;
|
||||
};
|
||||
|
||||
// required for compatibility with jquery plugins
|
||||
// ex: bootstrap plugins
|
||||
$.fn.extend = $.extend.bind($);
|
||||
|
||||
/**
|
||||
* Enable special events on Zepto
|
||||
* @license Original Copyright 2013 Enideo. Released under dual MIT and GPL licenses.
|
||||
*/
|
||||
// @ts-ignore
|
||||
$.event.special = $.event.special || {};
|
||||
|
||||
const bindBeforeSpecialEvents = $.fn.bind;
|
||||
|
||||
$.fn.bind = function(eventName, data, callback) {
|
||||
const el = this;
|
||||
|
||||
if (!callback){
|
||||
callback = data;
|
||||
data = null;
|
||||
}
|
||||
|
||||
$.each(eventName.split(/\s/), (key: string, value: any) : boolean => {
|
||||
value = value.split(/\./)[0];
|
||||
|
||||
if(value in $.event.special){
|
||||
let specialEvent = $.event.special[value];
|
||||
|
||||
/// init enable special events on Zepto
|
||||
if(!specialEvent._init) {
|
||||
specialEvent._init = true;
|
||||
|
||||
/// intercept and replace the special event handler to add functionality
|
||||
specialEvent.originalHandler = specialEvent.handler;
|
||||
specialEvent.handler = function(){
|
||||
|
||||
/// make event argument writable, like on jQuery
|
||||
const args = Array.prototype.slice.call(arguments);
|
||||
|
||||
args[0] = $.extend({},args[0]);
|
||||
|
||||
/// define the event handle, $.event.dispatch is only for newer versions of jQuery
|
||||
$.event.handle = function(){
|
||||
|
||||
/// make context of trigger the event element
|
||||
const args = Array.prototype.slice.call(arguments);
|
||||
const event = args[0];
|
||||
const $target = $(event.target);
|
||||
|
||||
$target.trigger.apply( $target, arguments );
|
||||
};
|
||||
|
||||
specialEvent.originalHandler.apply(this,args);
|
||||
}
|
||||
}
|
||||
|
||||
/// setup special events on Zepto
|
||||
specialEvent.setup.apply(el, [data]);
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
|
||||
return bindBeforeSpecialEvents.apply(this, [eventName, callback]);
|
||||
};
|
49
js/src/common/utils/string.ts
Normal file
49
js/src/common/utils/string.ts
Normal file
@ -0,0 +1,49 @@
|
||||
/**
|
||||
* Truncate a string to the given length, appending ellipses if necessary.
|
||||
*/
|
||||
export function truncate(string: string, length: number, start = 0): string {
|
||||
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.
|
||||
*/
|
||||
export function slug(string: string): 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.
|
||||
*/
|
||||
export function getPlainContent(string: string): string {
|
||||
const html = string
|
||||
.replace(/(<\/p>|<br>)/g, '$1 ')
|
||||
.replace(/<img\b[^>]*>/ig, ' ');
|
||||
|
||||
const dom = $('<div/>').html(html);
|
||||
|
||||
dom.find(getPlainContent.removeSelectors.join(',')).remove();
|
||||
|
||||
return dom.text().replace(/\s+/g, ' ').trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* An array of DOM selectors to remove when getting plain content.
|
||||
*
|
||||
* @type {String[]}
|
||||
*/
|
||||
getPlainContent.removeSelectors = ['blockquote', 'script'];
|
||||
|
||||
/**
|
||||
* Make a string's first character uppercase.
|
||||
*/
|
||||
export function ucfirst(string: string): string {
|
||||
return string.substr(0, 1).toUpperCase() + string.substr(1);
|
||||
}
|
46
js/src/common/utils/stringToColor.ts
Normal file
46
js/src/common/utils/stringToColor.ts
Normal file
@ -0,0 +1,46 @@
|
||||
export function hsvToRgb(h: number, s: number, v: number) {
|
||||
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.
|
||||
*/
|
||||
export default function stringToColor(string: string): string {
|
||||
let num = 0;
|
||||
|
||||
// Convert the username into a number based on the ASCII value of each
|
||||
// character.
|
||||
for (let i = 0; i < string.length; i++) {
|
||||
num += string.charCodeAt(i);
|
||||
}
|
||||
|
||||
// Construct a color using the remainder of that number divided by 360, and
|
||||
// some predefined saturation and value values.
|
||||
const hue = num % 360;
|
||||
const rgb = hsvToRgb(hue / 360, 0.3, 0.9);
|
||||
|
||||
return '' + rgb.r.toString(16) + rgb.g.toString(16) + rgb.b.toString(16);
|
||||
}
|
96
js/src/forum/Forum.ts
Normal file
96
js/src/forum/Forum.ts
Normal file
@ -0,0 +1,96 @@
|
||||
import Application from '../common/Application';
|
||||
import History from './utils/History';
|
||||
|
||||
import IndexPage from './components/IndexPage';
|
||||
import HeaderPrimary from './components/HeaderPrimary';
|
||||
import HeaderSecondary from './components/HeaderSecondary';
|
||||
|
||||
export default class Forum extends Application {
|
||||
routes = {
|
||||
'index': { path: '/', component: IndexPage.component() },
|
||||
'index.filter': {path: '/:filter', component: IndexPage.component() },
|
||||
};
|
||||
|
||||
/**
|
||||
* The app's history stack, which keeps track of which routes the user visits
|
||||
* so that they can easily navigate back to the previous route.
|
||||
*/
|
||||
history: History = new History();
|
||||
|
||||
mount() {
|
||||
// Get the configured default route and update that route's path to be '/'.
|
||||
// Push the homepage as the first route, so that the user will always be
|
||||
// able to click on the 'back' button to go home, regardless of which page
|
||||
// they started on.
|
||||
const defaultRoute = this.forum.attribute('defaultRoute');
|
||||
let defaultAction = 'index';
|
||||
|
||||
for (const i in this.routes) {
|
||||
if (this.routes[i].path === defaultRoute) defaultAction = i;
|
||||
}
|
||||
|
||||
this.routes[defaultAction].path = '/';
|
||||
this.history.push(defaultAction, this.translator.trans('core.forum.header.back_to_index_tooltip'), '/');
|
||||
|
||||
// m.mount(document.getElementById('app-navigation'), Navigation.component({className: 'App-backControl', drawer: true}));
|
||||
// m.mount(document.getElementById('header-navigation'), Navigation.component());
|
||||
m.mount(document.getElementById('header-primary'), new HeaderPrimary());
|
||||
m.mount(document.getElementById('header-secondary'), new HeaderSecondary());
|
||||
|
||||
// this.pane = new Pane(document.getElementById('app'));
|
||||
// this.composer = m.mount(document.getElementById('composer'), Composer.component());
|
||||
|
||||
m.route.prefix = '';
|
||||
super.mount(this.forum.attribute('basePath'));
|
||||
|
||||
// alertEmailConfirmation(this);
|
||||
|
||||
// Route the home link back home when clicked. We do not want it to register
|
||||
// if the user is opening it in a new tab, however.
|
||||
$('#home-link').click(e => {
|
||||
if (e.ctrlKey || e.metaKey || e.which === 2) return;
|
||||
e.preventDefault();
|
||||
app.history.home();
|
||||
|
||||
// Reload the current user so that their unread notification count is refreshed.
|
||||
if (app.session.user) {
|
||||
app.store.find('users', app.session.user.id());
|
||||
m.redraw();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
setupRoutes() {
|
||||
super.setupRoutes();
|
||||
|
||||
this.route.discussion = (discussion, near) => {
|
||||
const slug = discussion.slug();
|
||||
return this.route(near && near !== 1 ? 'discussion.near' : 'discussion', {
|
||||
id: discussion.id() + (slug.trim() ? '-' + slug : ''),
|
||||
near: near && near !== 1 ? near : undefined
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Generate a URL to a post.
|
||||
*
|
||||
* @param {Post} post
|
||||
* @return {String}
|
||||
*/
|
||||
this.route.post = post => {
|
||||
return this.route.discussion(post.discussion(), post.number());
|
||||
};
|
||||
|
||||
/**
|
||||
* Generate a URL to a user.
|
||||
*
|
||||
* @param {User} user
|
||||
* @return {String}
|
||||
*/
|
||||
this.route.user = user => {
|
||||
return this.route('user', {
|
||||
username: user.username()
|
||||
});
|
||||
};
|
||||
}
|
||||
}
|
@ -1,14 +0,0 @@
|
||||
import Application from '../common/Application';
|
||||
import Layout from './components/Layout';
|
||||
|
||||
import IndexPage from './components/IndexPage';
|
||||
|
||||
export default class Forum extends Application {
|
||||
public routes = {
|
||||
index: { path: '/', component: IndexPage.component() },
|
||||
};
|
||||
|
||||
get layout() {
|
||||
return Layout;
|
||||
}
|
||||
}
|
@ -2,11 +2,9 @@ import compat from '../common/compat';
|
||||
|
||||
import Forum from './Forum';
|
||||
|
||||
import Layout from './components/Layout';
|
||||
import IndexPage from './components/IndexPage';
|
||||
|
||||
export default Object.assign(compat, {
|
||||
'components/Layout': Layout,
|
||||
'components/IndexPage': IndexPage,
|
||||
Forum: Forum,
|
||||
}) as any;
|
||||
|
55
js/src/forum/components/DiscussionsSearchSource.tsx
Normal file
55
js/src/forum/components/DiscussionsSearchSource.tsx
Normal file
@ -0,0 +1,55 @@
|
||||
import highlight from '../../common/helpers/highlight';
|
||||
import LinkButton from '../../common/components/LinkButton';
|
||||
import SearchSource from "./SearchSource";
|
||||
import Discussion from "../../common/models/Discussion";
|
||||
|
||||
/**
|
||||
* The `DiscussionsSearchSource` finds and displays discussion search results in
|
||||
* the search dropdown.
|
||||
*/
|
||||
export default class DiscussionsSearchSource extends SearchSource {
|
||||
protected results: { [key: string]: Discussion[] } = {};
|
||||
|
||||
search(query: string) {
|
||||
query = query.toLowerCase();
|
||||
|
||||
this.results[query] = [];
|
||||
|
||||
const params = {
|
||||
filter: {q: query},
|
||||
page: {limit: 3},
|
||||
include: 'mostRelevantPost'
|
||||
};
|
||||
|
||||
return app.store.find('discussions', params).then(results => this.results[query] = results);
|
||||
}
|
||||
|
||||
view(query: string) {
|
||||
query = query.toLowerCase();
|
||||
|
||||
const results = this.results[query] || [];
|
||||
|
||||
return [
|
||||
<li className="Dropdown-header">{app.translator.trans('core.forum.search.discussions_heading')}</li>,
|
||||
<li>
|
||||
{LinkButton.component({
|
||||
icon: 'fas fa-search',
|
||||
children: app.translator.trans('core.forum.search.all_discussions_button', {query}),
|
||||
href: app.route('index', {q: query})
|
||||
})}
|
||||
</li>,
|
||||
results.map(discussion => {
|
||||
const mostRelevantPost = discussion.mostRelevantPost();
|
||||
|
||||
return (
|
||||
<li className="DiscussionSearchResult" data-index={'discussions' + discussion.id()}>
|
||||
<a href={app.route.discussion(discussion, mostRelevantPost && mostRelevantPost.number())} config={m.route}>
|
||||
<div className="DiscussionSearchResult-title">{highlight(discussion.title(), query)}</div>
|
||||
{mostRelevantPost ? <div className="DiscussionSearchResult-excerpt">{highlight(mostRelevantPost.contentPlain(), query, 100)}</div> : ''}
|
||||
</a>
|
||||
</li>
|
||||
);
|
||||
})
|
||||
];
|
||||
}
|
||||
}
|
24
js/src/forum/components/HeaderPrimary.tsx
Normal file
24
js/src/forum/components/HeaderPrimary.tsx
Normal file
@ -0,0 +1,24 @@
|
||||
import Component from '../../common/Component';
|
||||
import ItemList from '../../common/utils/ItemList';
|
||||
import listItems from '../../common/helpers/listItems';
|
||||
|
||||
/**
|
||||
* The `HeaderPrimary` component displays primary header controls. On the
|
||||
* default skin, these are shown just to the right of the forum title.
|
||||
*/
|
||||
export default class HeaderPrimary extends Component {
|
||||
view() {
|
||||
return (
|
||||
<ul className="Header-controls">
|
||||
{listItems(this.items().toArray())}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build an item list for the controls.
|
||||
*/
|
||||
items(): ItemList {
|
||||
return new ItemList();
|
||||
}
|
||||
}
|
85
js/src/forum/components/HeaderSecondary.tsx
Normal file
85
js/src/forum/components/HeaderSecondary.tsx
Normal file
@ -0,0 +1,85 @@
|
||||
import Component from '../../common/Component';
|
||||
import Button from '../../common/components/Button';
|
||||
// import LogInModal from './LogInModal';
|
||||
// import SignUpModal from './SignUpModal';
|
||||
// import SessionDropdown from './SessionDropdown';
|
||||
import SelectDropdown from '../../common/components/SelectDropdown';
|
||||
// import NotificationsDropdown from './NotificationsDropdown';
|
||||
import ItemList from '../../common/utils/ItemList';
|
||||
import listItems from '../../common/helpers/listItems';
|
||||
|
||||
import Search from './Search';
|
||||
|
||||
/**
|
||||
* The `HeaderSecondary` component displays secondary header controls, such as
|
||||
* the search box and the user menu. On the default skin, these are shown on the
|
||||
* right side of the header.
|
||||
*/
|
||||
export default class HeaderSecondary extends Component {
|
||||
view() {
|
||||
return (
|
||||
<ul className="Header-controls">
|
||||
{listItems(this.items().toArray())}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build an item list for the controls.
|
||||
*/
|
||||
items(): ItemList {
|
||||
const items = new ItemList();
|
||||
|
||||
items.add('search', Search.component(), 30);
|
||||
|
||||
if (app.forum.attribute("showLanguageSelector") && Object.keys(app.data.locales).length > 0) {
|
||||
const locales = [];
|
||||
|
||||
for (const locale in app.data.locales) {
|
||||
locales.push(Button.component({
|
||||
active: app.data.locale === locale,
|
||||
children: app.data.locales[locale],
|
||||
icon: app.data.locale === locale ? 'fas fa-check' : true,
|
||||
onclick: () => {
|
||||
if (app.session.user) {
|
||||
app.session.user.savePreferences({locale}).then(() => window.location.reload());
|
||||
} else {
|
||||
document.cookie = `locale=${locale}; path=/; expires=Tue, 19 Jan 2038 03:14:07 GMT`;
|
||||
window.location.reload();
|
||||
}
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
items.add('locale', SelectDropdown.component({
|
||||
children: locales,
|
||||
buttonClassName: 'Button Button--link'
|
||||
}), 20);
|
||||
}
|
||||
|
||||
if (app.session.user) {
|
||||
// items.add('notifications', NotificationsDropdown.component(), 10);
|
||||
// items.add('session', SessionDropdown.component(), 0);
|
||||
} else {
|
||||
if (app.forum.attribute('allowSignUp')) {
|
||||
items.add('signUp',
|
||||
Button.component({
|
||||
children: app.translator.trans('core.forum.header.sign_up_link'),
|
||||
className: 'Button Button--link',
|
||||
onclick: () => app.modal.show(new SignUpModal())
|
||||
}), 10
|
||||
);
|
||||
}
|
||||
|
||||
items.add('logIn',
|
||||
Button.component({
|
||||
children: app.translator.trans('core.forum.header.log_in_link'),
|
||||
className: 'Button Button--link',
|
||||
onclick: () => app.modal.show(new LogInModal())
|
||||
}), 0
|
||||
);
|
||||
}
|
||||
|
||||
return items;
|
||||
}
|
||||
}
|
@ -1,9 +0,0 @@
|
||||
import Component from '../../common/Component';
|
||||
import Navigation from '../../common/components/Navigation';
|
||||
|
||||
export default class Layout extends Component {
|
||||
view() {
|
||||
return <Navigation />;
|
||||
// return m(Navigation, {title: "title"}); // trying this
|
||||
}
|
||||
}
|
303
js/src/forum/components/Search.tsx
Normal file
303
js/src/forum/components/Search.tsx
Normal file
@ -0,0 +1,303 @@
|
||||
import Component from '../../common/Component';
|
||||
import LoadingIndicator from '../../common/components/LoadingIndicator';
|
||||
import ItemList from '../../common/utils/ItemList';
|
||||
import extractText from '../../common/utils/extractText';
|
||||
import KeyboardNavigatable from '../utils/KeyboardNavigatable';
|
||||
import icon from '../../common/helpers/icon';
|
||||
import DiscussionsSearchSource from './DiscussionsSearchSource';
|
||||
import UsersSearchSource from './UsersSearchSource';
|
||||
import SearchSource from './SearchSource';
|
||||
|
||||
/**
|
||||
* The `Search` component displays a menu of as-you-type results from a variety
|
||||
* of sources.
|
||||
*
|
||||
* The search box will be 'activated' if the app's current controller implements
|
||||
* a `searching` method that returns a truthy value. If this is the case, an 'x'
|
||||
* button will be shown next to the search field, and clicking it will call the
|
||||
* `clearSearch` method on the controller.
|
||||
*/
|
||||
export default class Search extends Component {
|
||||
/**
|
||||
* The value of the search input.
|
||||
*/
|
||||
value: Function = m.prop('');
|
||||
|
||||
/**
|
||||
* Whether or not the search input has focus.
|
||||
*/
|
||||
hasFocus: boolean = false;
|
||||
|
||||
/**
|
||||
* An array of SearchSources.
|
||||
*/
|
||||
sources: SearchSource[] = null;
|
||||
|
||||
/**
|
||||
* The number of sources that are still loading results.
|
||||
*/
|
||||
loadingSources = 0;
|
||||
|
||||
/**
|
||||
* A list of queries that have been searched for.
|
||||
*/
|
||||
searched: string[] = [];
|
||||
|
||||
/**
|
||||
* The index of the currently-selected <li> in the results list. This can be
|
||||
* a unique string (to account for the fact that an item's position may jump
|
||||
* around as new results load), but otherwise it will be numeric (the
|
||||
* sequential position within the list).
|
||||
*/
|
||||
index: string|number = 0;
|
||||
|
||||
navigator: KeyboardNavigatable;
|
||||
|
||||
searchTimeout: number;
|
||||
|
||||
view() {
|
||||
const currentSearch = this.getCurrentSearch();
|
||||
|
||||
// Initialize search input value in the view rather than the constructor so
|
||||
// that we have access to app.current.
|
||||
if (typeof this.value() === 'undefined') {
|
||||
this.value(currentSearch || '');
|
||||
}
|
||||
|
||||
// Initialize search sources in the view rather than the constructor so
|
||||
// that we have access to app.forum.
|
||||
if (!this.sources) {
|
||||
this.sources = this.sourceItems().toArray();
|
||||
}
|
||||
|
||||
// Hide the search view if no sources were loaded
|
||||
if (!this.sources.length) return <div/>;
|
||||
|
||||
console.log('Search#view - loading:', this.loadingSources)
|
||||
|
||||
return (
|
||||
<div className={'Search ' + classNames({
|
||||
open: this.value() && this.hasFocus,
|
||||
focused: this.hasFocus,
|
||||
active: !!currentSearch,
|
||||
loading: !!this.loadingSources
|
||||
})}>
|
||||
<div className="Search-input">
|
||||
<input className="FormControl"
|
||||
type="search"
|
||||
placeholder={extractText(app.translator.trans('core.forum.header.search_placeholder'))}
|
||||
value={this.value()}
|
||||
oninput={m.withAttr('value', this.value)}
|
||||
onfocus={() => this.hasFocus = true}
|
||||
onblur={() => this.hasFocus = false}/>
|
||||
{this.loadingSources
|
||||
? LoadingIndicator.component({size: 'tiny', className: 'Button Button--icon Button--link'})
|
||||
: currentSearch
|
||||
? <button className="Search-clear Button Button--icon Button--link" onclick={this.clear.bind(this)}>{icon('fas fa-times-circle')}</button>
|
||||
: ''}
|
||||
</div>
|
||||
<ul className="Dropdown-menu Search-results">
|
||||
{this.value() && this.hasFocus
|
||||
? this.sources.map(source => source.view(this.value()))
|
||||
: ''}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
oncreate(vnode) {
|
||||
super.oncreate(vnode);
|
||||
|
||||
// Highlight the item that is currently selected.
|
||||
this.setIndex(this.getCurrentNumericIndex());
|
||||
|
||||
const search = this;
|
||||
|
||||
this.$('.Search-results')
|
||||
.on('mousedown', e => e.preventDefault())
|
||||
.on('click', () => this.$('input').blur())
|
||||
|
||||
// Whenever the mouse is hovered over a search result, highlight it.
|
||||
.on('mouseenter', '> li:not(.Dropdown-header)', function() {
|
||||
search.setIndex(
|
||||
search.selectableItems().index(this)
|
||||
);
|
||||
});
|
||||
|
||||
const $input = this.$('input');
|
||||
|
||||
this.navigator = new KeyboardNavigatable();
|
||||
|
||||
this.navigator
|
||||
.onUp(() => this.setIndex(this.getCurrentNumericIndex() - 1, true))
|
||||
.onDown(() => this.setIndex(this.getCurrentNumericIndex() + 1, true))
|
||||
.onSelect(this.selectResult.bind(this))
|
||||
.onCancel(this.clear.bind(this))
|
||||
.bindTo($input);
|
||||
|
||||
// Handle input key events on the search input, triggering results to load.
|
||||
$input
|
||||
.on('input focus', function() {
|
||||
const query = this.value.toLowerCase();
|
||||
|
||||
if (!query) return;
|
||||
|
||||
clearTimeout(search.searchTimeout);
|
||||
search.searchTimeout = setTimeout(() => {
|
||||
if (search.searched.indexOf(query) !== -1) return;
|
||||
|
||||
if (query.length >= 3) {
|
||||
search.sources.map(source => {
|
||||
if (!source.search) return;
|
||||
|
||||
search.loadingSources++;
|
||||
|
||||
source.search(query).then(() => {
|
||||
search.loadingSources = Math.max(0, search.loadingSources - 1);
|
||||
m.redraw();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
search.searched.push(query);
|
||||
m.redraw();
|
||||
}, 250);
|
||||
})
|
||||
|
||||
.on('focus', function() {
|
||||
$(this).one('mouseup', e => e.preventDefault()).select();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the active search in the app's current controller.
|
||||
*
|
||||
* @return {String}
|
||||
*/
|
||||
getCurrentSearch() {
|
||||
return app.current && typeof app.current.searching === 'function' && app.current.searching();
|
||||
}
|
||||
|
||||
/**
|
||||
* Navigate to the currently selected search result and close the list.
|
||||
*/
|
||||
selectResult() {
|
||||
clearTimeout(this.searchTimeout);
|
||||
this.loadingSources = 0;
|
||||
|
||||
if (this.value()) {
|
||||
m.route(this.getItem(this.index).find('a').attr('href'));
|
||||
} else {
|
||||
this.clear();
|
||||
}
|
||||
|
||||
this.$('input').blur();
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear the search input and the current controller's active search.
|
||||
*/
|
||||
clear() {
|
||||
this.value('');
|
||||
|
||||
if (this.getCurrentSearch()) {
|
||||
app.current.clearSearch();
|
||||
} else {
|
||||
m.redraw();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Build an item list of SearchSources.
|
||||
*
|
||||
* @return {ItemList}
|
||||
*/
|
||||
sourceItems() {
|
||||
const items = new ItemList();
|
||||
|
||||
if (app.forum.attribute('canViewDiscussions')) items.add('discussions', new DiscussionsSearchSource());
|
||||
if (app.forum.attribute('canViewUserList')) items.add('users', new UsersSearchSource());
|
||||
|
||||
return items;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all of the search result items that are selectable.
|
||||
*
|
||||
* @return {jQuery}
|
||||
*/
|
||||
selectableItems() {
|
||||
return this.$('.Search-results > li:not(.Dropdown-header)');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the position of the currently selected search result item.
|
||||
*
|
||||
* @return {Integer}
|
||||
*/
|
||||
getCurrentNumericIndex() {
|
||||
return this.selectableItems().index(
|
||||
this.getItem(this.index)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the <li> in the search results with the given index (numeric or named).
|
||||
*
|
||||
* @param {String} index
|
||||
* @return {DOMElement}
|
||||
*/
|
||||
getItem(index) {
|
||||
const $items = this.selectableItems();
|
||||
let $item = $items.filter(`[data-index="${index}"]`);
|
||||
|
||||
if (!$item.length) {
|
||||
$item = $items.eq(index);
|
||||
}
|
||||
|
||||
return $item;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the currently-selected search result item to the one with the given
|
||||
* index.
|
||||
*
|
||||
* @param index
|
||||
* @param scrollToItem Whether or not to scroll the dropdown so that
|
||||
* the item is in view.
|
||||
*/
|
||||
setIndex(index: number, scrollToItem?: boolean) {
|
||||
const $items = this.selectableItems();
|
||||
const $dropdown = $items.parent();
|
||||
|
||||
let fixedIndex = index;
|
||||
if (index < 0) {
|
||||
fixedIndex = $items.length - 1;
|
||||
} else if (index >= $items.length) {
|
||||
fixedIndex = 0;
|
||||
}
|
||||
|
||||
const $item = $items.removeClass('active').eq(fixedIndex).addClass('active');
|
||||
|
||||
this.index = $item.attr('data-index') || fixedIndex;
|
||||
|
||||
if (scrollToItem) {
|
||||
const dropdownScroll = $dropdown.scrollTop();
|
||||
const dropdownTop = $dropdown.offset().top;
|
||||
const dropdownBottom = dropdownTop + $dropdown.outerHeight();
|
||||
const itemTop = $item.offset().top;
|
||||
const itemBottom = itemTop + $item.outerHeight();
|
||||
|
||||
let scrollTop;
|
||||
if (itemTop < dropdownTop) {
|
||||
scrollTop = dropdownScroll - dropdownTop + itemTop - parseInt($dropdown.css('padding-top'), 10);
|
||||
} else if (itemBottom > dropdownBottom) {
|
||||
scrollTop = dropdownScroll - dropdownBottom + itemBottom + parseInt($dropdown.css('padding-bottom'), 10);
|
||||
}
|
||||
|
||||
if (typeof scrollTop !== 'undefined') {
|
||||
$dropdown.animate({scrollTop}, 100);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
5
js/src/forum/components/SearchSource.ts
Normal file
5
js/src/forum/components/SearchSource.ts
Normal file
@ -0,0 +1,5 @@
|
||||
export default abstract class SearchSource {
|
||||
abstract view(vnode: string);
|
||||
|
||||
abstract search(query: string);
|
||||
}
|
53
js/src/forum/components/UsersSearchSource.tsx
Normal file
53
js/src/forum/components/UsersSearchSource.tsx
Normal file
@ -0,0 +1,53 @@
|
||||
import highlight from '../../common/helpers/highlight';
|
||||
import avatar from '../../common/helpers/avatar';
|
||||
import username from '../../common/helpers/username';
|
||||
import SearchSource from "./SearchSource";
|
||||
import User from '../../common/models/User';
|
||||
|
||||
/**
|
||||
* The `UsersSearchSource` finds and displays user search results in the search
|
||||
* dropdown.
|
||||
*
|
||||
* @implements SearchSource
|
||||
*/
|
||||
export default class UsersSearchSource extends SearchSource {
|
||||
protected results: { [key: string]: User[] } = {};
|
||||
|
||||
search(query: string) {
|
||||
return app.store.find<User>('users', {
|
||||
filter: {q: query},
|
||||
page: {limit: 5}
|
||||
}).then(results => {
|
||||
this.results[query] = results;
|
||||
m.redraw();
|
||||
});
|
||||
}
|
||||
|
||||
view(query: string) {
|
||||
query = query.toLowerCase();
|
||||
|
||||
const results = (this.results[query] || [])
|
||||
.concat(app.store.all<User>('users').filter((user: User) => [user.username(), user.displayName()].some(value => value.toLowerCase().substr(0, query.length) === query)))
|
||||
.filter((e, i, arr) => arr.lastIndexOf(e) === i)
|
||||
.sort((a, b) => a.displayName().localeCompare(b.displayName()));
|
||||
|
||||
if (!results.length) return '';
|
||||
|
||||
return [
|
||||
<li className="Dropdown-header">{app.translator.trans('core.forum.search.users_heading')}</li>,
|
||||
results.map(user => {
|
||||
const name = username(user);
|
||||
name.children[0] = highlight(name.children[0], query);
|
||||
|
||||
return (
|
||||
<li className="UserSearchResult" data-index={'users' + user.id()}>
|
||||
<a href={app.route.user(user)} config={m.route}>
|
||||
{avatar(user)}
|
||||
{name}
|
||||
</a>
|
||||
</li>
|
||||
);
|
||||
})
|
||||
];
|
||||
}
|
||||
}
|
104
js/src/forum/utils/History.ts
Normal file
104
js/src/forum/utils/History.ts
Normal file
@ -0,0 +1,104 @@
|
||||
export interface StackItem {
|
||||
name: string;
|
||||
title: string;
|
||||
url?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* The `History` class keeps track and manages a stack of routes that the user
|
||||
* has navigated to in their session.
|
||||
*
|
||||
* An item can be pushed to the top of the stack using the `push` method. An
|
||||
* item in the stack has a name and a URL. The name need not be unique; if it is
|
||||
* the same as the item before it, that will be overwritten with the new URL. In
|
||||
* this way, if a user visits a discussion, and then visits another discussion,
|
||||
* popping the history stack will still take them back to the discussion list
|
||||
* rather than the previous discussion.
|
||||
*/
|
||||
export default class History {
|
||||
/**
|
||||
* The stack of routes that have been navigated to.
|
||||
*/
|
||||
protected stack: StackItem[] = [];
|
||||
|
||||
/**
|
||||
* Get the item on the top of the stack.
|
||||
*/
|
||||
getCurrent(): StackItem {
|
||||
return this.stack[this.stack.length - 1];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the previous item on the stack.
|
||||
*/
|
||||
getPrevious(): StackItem {
|
||||
return this.stack[this.stack.length - 2];
|
||||
}
|
||||
|
||||
/**
|
||||
* Push an item to the top of the stack.
|
||||
*
|
||||
* @param {String} name The name of the route.
|
||||
* @param {String} title The title of the route.
|
||||
* @param {String} [url] The URL of the route. The current URL will be used if
|
||||
* not provided.
|
||||
*/
|
||||
push(name: string, title: string, url: string = m.route.get()) {
|
||||
// If we're pushing an item with the same name as second-to-top item in the
|
||||
// stack, we will assume that the user has clicked the 'back' button in
|
||||
// their browser. In this case, we don't want to push a new item, so we will
|
||||
// pop off the top item, and then the second-to-top item will be overwritten
|
||||
// below.
|
||||
const secondTop = this.stack[this.stack.length - 2];
|
||||
if (secondTop && secondTop.name === name) {
|
||||
this.stack.pop();
|
||||
}
|
||||
|
||||
// If we're pushing an item with the same name as the top item in the stack,
|
||||
// then we'll overwrite it with the new URL.
|
||||
const top = this.getCurrent();
|
||||
if (top && top.name === name) {
|
||||
Object.assign(top, {url, title});
|
||||
} else {
|
||||
this.stack.push({name, url, title});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check whether or not the history stack is able to be popped.
|
||||
*/
|
||||
canGoBack(): boolean {
|
||||
return this.stack.length > 1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Go back to the previous route in the history stack.
|
||||
*/
|
||||
back() {
|
||||
if (! this.canGoBack()) {
|
||||
return this.home();
|
||||
}
|
||||
|
||||
this.stack.pop();
|
||||
|
||||
m.route.set(this.getCurrent().url);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the URL of the previous page.
|
||||
*/
|
||||
backUrl(): string {
|
||||
const secondTop = this.stack[this.stack.length - 2];
|
||||
|
||||
return secondTop.url;
|
||||
}
|
||||
|
||||
/**
|
||||
* Go to the first route in the history stack.
|
||||
*/
|
||||
home() {
|
||||
this.stack.splice(0);
|
||||
|
||||
m.route.set('/');
|
||||
}
|
||||
}
|
116
js/src/forum/utils/KeyboardNavigatable.ts
Normal file
116
js/src/forum/utils/KeyboardNavigatable.ts
Normal file
@ -0,0 +1,116 @@
|
||||
/**
|
||||
* The `KeyboardNavigatable` class manages lists that can be navigated with the
|
||||
* keyboard, calling callbacks for each actions.
|
||||
*
|
||||
* This helper encapsulates the key binding logic, providing a simple fluent
|
||||
* API for use.
|
||||
*/
|
||||
export default class KeyboardNavigatable {
|
||||
callbacks = {};
|
||||
|
||||
// By default, always handle keyboard navigation.
|
||||
whenCallback = () => true;
|
||||
|
||||
/**
|
||||
* Provide a callback to be executed when navigating upwards.
|
||||
*
|
||||
* This will be triggered by the Up key.
|
||||
*/
|
||||
onUp(callback: Function): this {
|
||||
this.callbacks[38] = e => {
|
||||
e.preventDefault();
|
||||
callback(e);
|
||||
};
|
||||
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Provide a callback to be executed when navigating downwards.
|
||||
*
|
||||
* This will be triggered by the Down key.
|
||||
*/
|
||||
onDown(callback: Function): this {
|
||||
this.callbacks[40] = e => {
|
||||
e.preventDefault();
|
||||
callback(e);
|
||||
};
|
||||
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Provide a callback to be executed when the current item is selected..
|
||||
*
|
||||
* This will be triggered by the Return and Tab keys..
|
||||
*/
|
||||
onSelect(callback: Function): this {
|
||||
this.callbacks[9] = this.callbacks[13] = e => {
|
||||
e.preventDefault();
|
||||
callback(e);
|
||||
};
|
||||
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Provide a callback to be executed when the navigation is canceled.
|
||||
*
|
||||
* This will be triggered by the Escape key.
|
||||
*/
|
||||
onCancel(callback: Function): this {
|
||||
this.callbacks[27] = e => {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
callback(e);
|
||||
};
|
||||
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Provide a callback to be executed when previous input is removed.
|
||||
*
|
||||
* This will be triggered by the Backspace key.
|
||||
*/
|
||||
onRemove(callback: Function): this {
|
||||
this.callbacks[8] = e => {
|
||||
if (e.target.selectionStart === 0 && e.target.selectionEnd === 0) {
|
||||
callback(e);
|
||||
e.preventDefault();
|
||||
}
|
||||
};
|
||||
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Provide a callback that determines whether keyboard input should be handled.
|
||||
*/
|
||||
when(callback: () => boolean): this {
|
||||
this.whenCallback = callback;
|
||||
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set up the navigation key bindings on the given jQuery element.
|
||||
*/
|
||||
bindTo($element: any) {
|
||||
// Handle navigation key events on the navigatable element.
|
||||
$element.on('keydown', this.navigate.bind(this));
|
||||
}
|
||||
|
||||
/**
|
||||
* Interpret the given keyboard event as navigation commands.
|
||||
*/
|
||||
navigate(event: KeyboardEvent) {
|
||||
// This callback determines whether keyboard should be handled or ignored.
|
||||
if (!this.whenCallback()) return;
|
||||
|
||||
const keyCallback = this.callbacks[event.which];
|
||||
if (keyCallback) {
|
||||
keyCallback(event);
|
||||
}
|
||||
}
|
||||
}
|
@ -1,6 +1,3 @@
|
||||
{
|
||||
"extends": "flarum-webpack-config/tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"jsx": "preserve"
|
||||
}
|
||||
"extends": "flarum-webpack-config/tsconfig.json"
|
||||
}
|
||||
|
@ -25,9 +25,9 @@
|
||||
document.getElementById('flarum-loading').style.display = 'none';
|
||||
|
||||
try {
|
||||
flarum.core.app.load(@json($payload));
|
||||
flarum.core.app.bootExtensions(flarum.extensions);
|
||||
flarum.core.app.boot();
|
||||
app.boot(@json($payload));
|
||||
// flarum.core.app.bootExtensions(flarum.extensions);
|
||||
// flarum.core.app.boot();
|
||||
} catch (e) {
|
||||
var error = document.getElementById('flarum-loading-error');
|
||||
error.innerHTML += document.getElementById('flarum-content').textContent;
|
||||
|
Loading…
x
Reference in New Issue
Block a user