mirror of
https://github.com/flarum/framework.git
synced 2024-12-02 15:03:44 +08:00
377 lines
9.8 KiB
JavaScript
377 lines
9.8 KiB
JavaScript
import ItemList from './utils/ItemList';
|
|
import Alert from './components/Alert';
|
|
import Button from './components/Button';
|
|
import ModalManager from './components/ModalManager';
|
|
import AlertManager from './components/AlertManager';
|
|
import RequestErrorModal from './components/RequestErrorModal';
|
|
import Translator from './Translator';
|
|
import Store from './Store';
|
|
import Session from './Session';
|
|
import extract from './utils/extract';
|
|
import Drawer from './utils/Drawer';
|
|
import mapRoutes from './utils/mapRoutes';
|
|
import RequestError from './utils/RequestError';
|
|
import ScrollListener from './utils/ScrollListener';
|
|
import { extend } from './extend';
|
|
|
|
import Forum from './models/Forum';
|
|
import User from './models/User';
|
|
import Discussion from './models/Discussion';
|
|
import Post from './models/Post';
|
|
import Group from './models/Group';
|
|
import Notification from './models/Notification';
|
|
import { flattenDeep } from 'lodash-es';
|
|
|
|
/**
|
|
* The `App` class provides a container for an application, as well as various
|
|
* utilities for the rest of the app to use.
|
|
*/
|
|
export default class Application {
|
|
/**
|
|
* The forum model for this application.
|
|
*
|
|
* @type {Forum}
|
|
* @public
|
|
*/
|
|
forum = null;
|
|
|
|
/**
|
|
* A map of routes, keyed by a unique route name. Each route is an object
|
|
* containing the following properties:
|
|
*
|
|
* - `path` The path that the route is accessed at.
|
|
* - `component` The Mithril component to render when this route is active.
|
|
*
|
|
* @example
|
|
* app.routes.discussion = {path: '/d/:id', component: DiscussionPage.component()};
|
|
*
|
|
* @type {Object}
|
|
* @public
|
|
*/
|
|
routes = {};
|
|
|
|
/**
|
|
* An ordered list of initializers to bootstrap the application.
|
|
*
|
|
* @type {ItemList}
|
|
* @public
|
|
*/
|
|
initializers = new ItemList();
|
|
|
|
/**
|
|
* The app's session.
|
|
*
|
|
* @type {Session}
|
|
* @public
|
|
*/
|
|
session = null;
|
|
|
|
/**
|
|
* The app's translator.
|
|
*
|
|
* @type {Translator}
|
|
* @public
|
|
*/
|
|
translator = new Translator();
|
|
|
|
/**
|
|
* The app's data store.
|
|
*
|
|
* @type {Store}
|
|
* @public
|
|
*/
|
|
store = new Store({
|
|
forums: Forum,
|
|
users: User,
|
|
discussions: Discussion,
|
|
posts: Post,
|
|
groups: Group,
|
|
notifications: Notification,
|
|
});
|
|
|
|
/**
|
|
* A local cache that can be used to store data at the application level, so
|
|
* that is persists between different routes.
|
|
*
|
|
* @type {Object}
|
|
* @public
|
|
*/
|
|
cache = {};
|
|
|
|
/**
|
|
* Whether or not the app has been booted.
|
|
*
|
|
* @type {Boolean}
|
|
* @public
|
|
*/
|
|
booted = false;
|
|
|
|
/**
|
|
* An Alert that was shown as a result of an AJAX request error. If present,
|
|
* it will be dismissed on the next successful request.
|
|
*
|
|
* @type {null|Alert}
|
|
* @private
|
|
*/
|
|
requestError = null;
|
|
|
|
data;
|
|
|
|
title = '';
|
|
titleCount = 0;
|
|
|
|
load(payload) {
|
|
this.data = payload;
|
|
this.translator.locale = payload.locale;
|
|
}
|
|
|
|
boot() {
|
|
this.initializers.toArray().forEach((initializer) => initializer(this));
|
|
|
|
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.mount();
|
|
}
|
|
|
|
bootExtensions(extensions) {
|
|
Object.keys(extensions).forEach((name) => {
|
|
const extension = extensions[name];
|
|
|
|
const extenders = flattenDeep(extension.extend);
|
|
|
|
for (const extender of extenders) {
|
|
extender.extend(this, { name, exports: extension });
|
|
}
|
|
});
|
|
}
|
|
|
|
mount(basePath = '') {
|
|
this.modal = m.mount(document.getElementById('modal'), <ModalManager />);
|
|
this.alerts = m.mount(document.getElementById('alerts'), <AlertManager />);
|
|
|
|
this.drawer = new Drawer();
|
|
|
|
m.route(document.getElementById('content'), basePath + '/', mapRoutes(this.routes, basePath));
|
|
|
|
// Add a class to the body which indicates that the page has been scrolled
|
|
// down.
|
|
new ScrollListener((top) => {
|
|
const $app = $('#app');
|
|
const offset = $app.offset().top;
|
|
|
|
$app.toggleClass('affix', top >= offset).toggleClass('scrolled', top > offset);
|
|
}).start();
|
|
|
|
$(() => {
|
|
$('body').addClass('ontouchstart' in window ? 'touch' : 'no-touch');
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Get the API response document that has been preloaded into the application.
|
|
*
|
|
* @return {Object|null}
|
|
* @public
|
|
*/
|
|
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.
|
|
*
|
|
* @param {String} title
|
|
* @public
|
|
*/
|
|
setTitle(title) {
|
|
this.title = title;
|
|
this.updateTitle();
|
|
}
|
|
|
|
/**
|
|
* Set a number to display in the <title> of the page.
|
|
*
|
|
* @param {Integer} count
|
|
*/
|
|
setTitleCount(count) {
|
|
this.titleCount = count;
|
|
this.updateTitle();
|
|
}
|
|
|
|
updateTitle() {
|
|
document.title = (this.titleCount ? `(${this.titleCount}) ` : '') + (this.title ? this.title + ' - ' : '') + this.forum.attribute('title');
|
|
}
|
|
|
|
/**
|
|
* Make an AJAX request, handling any low-level errors that may occur.
|
|
*
|
|
* @see https://lhorie.github.io/mithril/mithril.request.html
|
|
* @param {Object} options
|
|
* @return {Promise}
|
|
* @public
|
|
*/
|
|
request(originalOptions) {
|
|
const options = 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) => 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) => 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);
|
|
}
|
|
};
|
|
|
|
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();
|
|
|
|
m.request(options).then(
|
|
(response) => deferred.resolve(response),
|
|
(error) => {
|
|
this.requestError = error;
|
|
|
|
let children;
|
|
|
|
switch (error.status) {
|
|
case 422:
|
|
children = error.response.errors
|
|
.map((error) => [error.detail, <br />])
|
|
.reduce((a, b) => a.concat(b), [])
|
|
.slice(0, -1);
|
|
break;
|
|
|
|
case 401:
|
|
case 403:
|
|
children = app.translator.trans('core.lib.error.permission_denied_message');
|
|
break;
|
|
|
|
case 404:
|
|
case 410:
|
|
children = app.translator.trans('core.lib.error.not_found_message');
|
|
break;
|
|
|
|
case 429:
|
|
children = app.translator.trans('core.lib.error.rate_limit_exceeded_message');
|
|
break;
|
|
|
|
default:
|
|
children = app.translator.trans('core.lib.error.generic_message');
|
|
}
|
|
|
|
const isDebug = app.forum.attribute('debug');
|
|
|
|
error.alert = new Alert({
|
|
type: 'error',
|
|
children,
|
|
controls: isDebug && [
|
|
<Button className="Button Button--link" onclick={this.showDebug.bind(this, error)}>
|
|
Debug
|
|
</Button>,
|
|
],
|
|
});
|
|
|
|
try {
|
|
options.errorHandler(error);
|
|
} catch (error) {
|
|
this.alerts.show(error.alert);
|
|
}
|
|
|
|
deferred.reject(error);
|
|
}
|
|
);
|
|
|
|
return deferred.promise;
|
|
}
|
|
|
|
/**
|
|
* @param {RequestError} error
|
|
* @private
|
|
*/
|
|
showDebug(error) {
|
|
this.alerts.dismiss(this.requestError.alert);
|
|
|
|
this.modal.show(new RequestErrorModal({ error }));
|
|
}
|
|
|
|
/**
|
|
* Construct a URL to the route with the given name.
|
|
*
|
|
* @param {String} name
|
|
* @param {Object} params
|
|
* @return {String}
|
|
* @public
|
|
*/
|
|
route(name, params = {}) {
|
|
const url = this.routes[name].path.replace(/:([^\/]+)/g, (m, key) => extract(params, key));
|
|
const queryString = m.route.buildQueryString(params);
|
|
const prefix = m.route.mode === 'pathname' ? app.forum.attribute('basePath') : '';
|
|
|
|
return prefix + url + (queryString ? '?' + queryString : '');
|
|
}
|
|
}
|