mirror of
https://github.com/flarum/framework.git
synced 2025-01-26 14:51:00 +08:00
chore: rewrite frontend application files to Typescript (#3006)
* Rename files * Rewrite common Application to TS * Improve DefaultResolver typings * Convert mapRoutes to TS * Fix incorrect JSDoc type * Add missing default value * Add debug button string to localisations * WIP Forum application TS rewrite * Use union and intersection to remove property duplication * Address some review comments Co-authored-by: Alexander Skvortsov <38059171+askvortsov1@users.noreply.github.com> * Address some review comments Co-authored-by: Alexander Skvortsov <38059171+askvortsov1@users.noreply.github.com> * Fix build error * Address some review comments Co-authored-by: Alexander Skvortsov <38059171+askvortsov1@users.noreply.github.com> * Add `type` import qualifier Co-authored-by: Alexander Skvortsov <38059171+askvortsov1@users.noreply.github.com>
This commit is contained in:
parent
52b2890633
commit
f7e97f510b
|
@ -26,6 +26,97 @@ import PageState from './states/PageState';
|
|||
import ModalManagerState from './states/ModalManagerState';
|
||||
import AlertManagerState from './states/AlertManagerState';
|
||||
|
||||
import type DefaultResolver from './resolvers/DefaultResolver';
|
||||
import type Mithril from 'mithril';
|
||||
import type Component from './Component';
|
||||
import type { ComponentAttrs } from './Component';
|
||||
|
||||
export type FlarumScreens = 'phone' | 'tablet' | 'desktop' | 'desktop-hd';
|
||||
|
||||
export type FlarumGenericRoute = RouteItem<
|
||||
Record<string, unknown>,
|
||||
Component<{ routeName: string; [key: string]: unknown }>,
|
||||
Record<string, unknown>
|
||||
>;
|
||||
|
||||
export interface FlarumRequestOptions<ResponseType> extends Omit<Mithril.RequestOptions<ResponseType>, 'extract'> {
|
||||
errorHandler: (errorMessage: string) => void;
|
||||
url: string;
|
||||
// TODO: [Flarum 2.0] Remove deprecated option
|
||||
/**
|
||||
* Manipulate the response text before it is parsed into JSON.
|
||||
*
|
||||
* @deprecated Please use `modifyText` instead.
|
||||
*/
|
||||
extract: (responseText: string) => string;
|
||||
/**
|
||||
* Manipulate the response text before it is parsed into JSON.
|
||||
*
|
||||
* This overrides any `extract` method provided.
|
||||
*/
|
||||
modifyText: (responseText: string) => string;
|
||||
}
|
||||
|
||||
/**
|
||||
* A valid route definition.
|
||||
*/
|
||||
export type RouteItem<
|
||||
Attrs extends ComponentAttrs,
|
||||
Comp extends Component<Attrs & { routeName: string }>,
|
||||
RouteArgs extends Record<string, unknown> = {}
|
||||
> = {
|
||||
/**
|
||||
* The path for your route.
|
||||
*
|
||||
* This might be a specific URL path (e.g.,`/myPage`), or it might
|
||||
* contain a variable used by a resolver (e.g., `/myPage/:id`).
|
||||
*
|
||||
* @see https://docs.flarum.org/extend/frontend-pages.html#route-resolvers-advanced
|
||||
*/
|
||||
path: `/${string}`;
|
||||
} & (
|
||||
| {
|
||||
/**
|
||||
* The component to render when this route matches.
|
||||
*/
|
||||
component: { new (): Comp };
|
||||
/**
|
||||
* A custom resolver class.
|
||||
*
|
||||
* This should be the class itself, and **not** an instance of the
|
||||
* class.
|
||||
*/
|
||||
resolverClass?: { new (): DefaultResolver<Attrs, Comp, RouteArgs> };
|
||||
}
|
||||
| {
|
||||
/**
|
||||
* An instance of a route resolver.
|
||||
*/
|
||||
resolver: RouteResolver<Attrs, Comp, RouteArgs>;
|
||||
}
|
||||
);
|
||||
|
||||
export interface RouteResolver<
|
||||
Attrs extends ComponentAttrs,
|
||||
Comp extends Component<Attrs & { routeName: string }>,
|
||||
RouteArgs extends Record<string, unknown> = {}
|
||||
> {
|
||||
/**
|
||||
* A method which selects which component to render based on
|
||||
* conditional logic.
|
||||
*
|
||||
* Returns the component class, and **not** a Vnode or JSX
|
||||
* expression.
|
||||
*/
|
||||
onmatch(this: this, args: RouteArgs, requestedPath: string, route: string): { new (): Comp };
|
||||
/**
|
||||
* A function which renders the provided component.
|
||||
*
|
||||
* Returns a Mithril Vnode or other children.
|
||||
*/
|
||||
render(this: this, vnode: Mithril.Vnode<Attrs, Comp>): Mithril.Children;
|
||||
}
|
||||
|
||||
/**
|
||||
* The `App` class provides a container for an application, as well as various
|
||||
* utilities for the rest of the app to use.
|
||||
|
@ -33,11 +124,8 @@ import AlertManagerState from './states/AlertManagerState';
|
|||
export default class Application {
|
||||
/**
|
||||
* The forum model for this application.
|
||||
*
|
||||
* @type {Forum}
|
||||
* @public
|
||||
*/
|
||||
forum = null;
|
||||
forum!: Forum;
|
||||
|
||||
/**
|
||||
* A map of routes, keyed by a unique route name. Each route is an object
|
||||
|
@ -47,44 +135,31 @@ export default class Application {
|
|||
* - `component` The Mithril component to render when this route is active.
|
||||
*
|
||||
* @example
|
||||
* app.routes.discussion = {path: '/d/:id', component: DiscussionPage.component()};
|
||||
*
|
||||
* @type {Object}
|
||||
* @public
|
||||
* app.routes.discussion = { path: '/d/:id', component: DiscussionPage };
|
||||
*/
|
||||
routes = {};
|
||||
routes: Record<string, FlarumGenericRoute> = {};
|
||||
|
||||
/**
|
||||
* An ordered list of initializers to bootstrap the application.
|
||||
*
|
||||
* @type {ItemList}
|
||||
* @public
|
||||
*/
|
||||
initializers = new ItemList();
|
||||
initializers: ItemList<(app: this) => void> = new ItemList();
|
||||
|
||||
/**
|
||||
* The app's session.
|
||||
*
|
||||
* @type {Session}
|
||||
* @public
|
||||
* Stores info about the current user.
|
||||
*/
|
||||
session = null;
|
||||
session!: Session;
|
||||
|
||||
/**
|
||||
* The app's translator.
|
||||
*
|
||||
* @type {Translator}
|
||||
* @public
|
||||
*/
|
||||
translator = new Translator();
|
||||
translator: Translator = new Translator();
|
||||
|
||||
/**
|
||||
* The app's data store.
|
||||
*
|
||||
* @type {Store}
|
||||
* @public
|
||||
*/
|
||||
store = new Store({
|
||||
store: Store = new Store({
|
||||
forums: Forum,
|
||||
users: User,
|
||||
discussions: Discussion,
|
||||
|
@ -96,28 +171,13 @@ export default class Application {
|
|||
/**
|
||||
* 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 = {};
|
||||
cache: Record<string, unknown> = {};
|
||||
|
||||
/**
|
||||
* Whether or not the app has been booted.
|
||||
*
|
||||
* @type {Boolean}
|
||||
* @public
|
||||
*/
|
||||
booted = false;
|
||||
|
||||
/**
|
||||
* The key for 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 {int}
|
||||
* @private
|
||||
*/
|
||||
requestErrorAlert = null;
|
||||
booted: boolean = false;
|
||||
|
||||
/**
|
||||
* The page the app is currently on.
|
||||
|
@ -125,10 +185,8 @@ export default class Application {
|
|||
* This object holds information about the type of page we are currently
|
||||
* visiting, and sometimes additional arbitrary page state that may be
|
||||
* relevant to lower-level components.
|
||||
*
|
||||
* @type {PageState}
|
||||
*/
|
||||
current = new PageState(null);
|
||||
current: PageState = new PageState(null);
|
||||
|
||||
/**
|
||||
* The page the app was on before the current page.
|
||||
|
@ -136,33 +194,61 @@ export default class Application {
|
|||
* Once the application navigates to another page, the object previously
|
||||
* assigned to this.current will be moved to this.previous, while this.current
|
||||
* is re-initialized.
|
||||
*
|
||||
* @type {PageState}
|
||||
*/
|
||||
previous = new PageState(null);
|
||||
previous: PageState = new PageState(null);
|
||||
|
||||
/*
|
||||
/**
|
||||
* An object that manages modal state.
|
||||
*
|
||||
* @type {ModalManagerState}
|
||||
*/
|
||||
modal = new ModalManagerState();
|
||||
modal: ModalManagerState = new ModalManagerState();
|
||||
|
||||
/**
|
||||
* An object that manages the state of active alerts.
|
||||
*
|
||||
* @type {AlertManagerState}
|
||||
*/
|
||||
alerts = new AlertManagerState();
|
||||
alerts: AlertManagerState = new AlertManagerState();
|
||||
|
||||
data;
|
||||
/**
|
||||
* An object that manages the state of the navigation drawer.
|
||||
*/
|
||||
drawer!: Drawer;
|
||||
|
||||
title = '';
|
||||
titleCount = 0;
|
||||
data!: {
|
||||
apiDocument: Record<string, unknown> | null;
|
||||
locale: string;
|
||||
locales: Record<string, string>;
|
||||
resources: Record<string, unknown>[];
|
||||
session: { userId: number; csrfToken: string };
|
||||
[key: string]: unknown;
|
||||
};
|
||||
|
||||
initialRoute;
|
||||
private _title: string = '';
|
||||
private _titleCount: number = 0;
|
||||
|
||||
load(payload) {
|
||||
private set title(val: string) {
|
||||
this._title = val;
|
||||
}
|
||||
|
||||
get title() {
|
||||
return this._title;
|
||||
}
|
||||
|
||||
private set titleCount(val: number) {
|
||||
this._titleCount = val;
|
||||
}
|
||||
|
||||
get titleCount() {
|
||||
return this._titleCount;
|
||||
}
|
||||
|
||||
/**
|
||||
* The key for 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 requestErrorAlert: number | null = null;
|
||||
|
||||
initialRoute!: string;
|
||||
|
||||
load(payload: Application['data']) {
|
||||
this.data = payload;
|
||||
this.translator.setLocale(payload.locale);
|
||||
}
|
||||
|
@ -182,7 +268,7 @@ export default class Application {
|
|||
}
|
||||
|
||||
// TODO: This entire system needs a do-over for v2
|
||||
bootExtensions(extensions) {
|
||||
bootExtensions(extensions: Record<string, { extend?: unknown[] }>) {
|
||||
Object.keys(extensions).forEach((name) => {
|
||||
const extension = extensions[name];
|
||||
|
||||
|
@ -197,44 +283,43 @@ export default class Application {
|
|||
});
|
||||
}
|
||||
|
||||
mount(basePath = '') {
|
||||
mount(basePath: string = '') {
|
||||
// An object with a callable view property is used in order to pass arguments to the component; see https://mithril.js.org/mount.html
|
||||
m.mount(document.getElementById('modal'), { view: () => ModalManager.component({ state: this.modal }) });
|
||||
m.mount(document.getElementById('alerts'), { view: () => AlertManager.component({ state: this.alerts }) });
|
||||
m.mount(document.getElementById('modal')!, { view: () => ModalManager.component({ state: this.modal }) });
|
||||
m.mount(document.getElementById('alerts')!, { view: () => AlertManager.component({ state: this.alerts }) });
|
||||
|
||||
this.drawer = new Drawer();
|
||||
|
||||
m.route(document.getElementById('content'), basePath + '/', mapRoutes(this.routes, basePath));
|
||||
m.route(document.getElementById('content')!, basePath + '/', mapRoutes(this.routes, basePath));
|
||||
|
||||
const appEl = document.getElementById('app')!;
|
||||
const appHeaderEl = document.querySelector('.App-header')!;
|
||||
|
||||
// Add a class to the body which indicates that the page has been scrolled
|
||||
// down. When this happens, we'll add classes to the header and app body
|
||||
// which will set the navbar's position to fixed. We don't want to always
|
||||
// have it fixed, as that could overlap with custom headers.
|
||||
const scrollListener = new ScrollListener((top) => {
|
||||
const $app = $('#app');
|
||||
const offset = $app.offset().top;
|
||||
const scrollListener = new ScrollListener((top: number) => {
|
||||
const offset = appEl.getBoundingClientRect().top + document.body.scrollTop;
|
||||
|
||||
$app.toggleClass('affix', top >= offset).toggleClass('scrolled', top > offset);
|
||||
$('.App-header').toggleClass('navbar-fixed-top', top >= offset);
|
||||
appEl.classList.toggle('affix', top >= offset);
|
||||
appEl.classList.toggle('scrolled', top > offset);
|
||||
|
||||
appHeaderEl.classList.toggle('navbar-fixed-top', top >= offset);
|
||||
});
|
||||
|
||||
scrollListener.start();
|
||||
scrollListener.update();
|
||||
|
||||
$(() => {
|
||||
$('body').addClass('ontouchstart' in window ? 'touch' : 'no-touch');
|
||||
});
|
||||
document.body.classList.add('ontouchstart' in window ? 'touch' : 'no-touch');
|
||||
|
||||
liveHumanTimes();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the API response document that has been preloaded into the application.
|
||||
*
|
||||
* @return {Object|null}
|
||||
* @public
|
||||
*/
|
||||
preloadedApiDocument() {
|
||||
preloadedApiDocument(): Record<string, unknown> | null {
|
||||
// If the URL has changed, the preloaded Api document is invalid.
|
||||
if (this.data.apiDocument && window.location.href === this.initialRoute) {
|
||||
const results = this.store.pushPayload(this.data.apiDocument);
|
||||
|
@ -249,36 +334,33 @@ export default class Application {
|
|||
|
||||
/**
|
||||
* Determine the current screen mode, based on our media queries.
|
||||
*
|
||||
* @returns {String} - one of "phone", "tablet", "desktop" or "desktop-hd"
|
||||
*/
|
||||
screen() {
|
||||
screen(): FlarumScreens {
|
||||
const styles = getComputedStyle(document.documentElement);
|
||||
return styles.getPropertyValue('--flarum-screen');
|
||||
return styles.getPropertyValue('--flarum-screen') as ReturnType<Application['screen']>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the <title> of the page.
|
||||
* Set the `<title>` of the page.
|
||||
*
|
||||
* @param {String} title
|
||||
* @public
|
||||
* @param title New page title
|
||||
*/
|
||||
setTitle(title) {
|
||||
setTitle(title: string): void {
|
||||
this.title = title;
|
||||
this.updateTitle();
|
||||
}
|
||||
|
||||
/**
|
||||
* Set a number to display in the <title> of the page.
|
||||
* Set a number to display in the `<title>` of the page.
|
||||
*
|
||||
* @param {Integer} count
|
||||
* @param count Number to display in title
|
||||
*/
|
||||
setTitleCount(count) {
|
||||
setTitleCount(count: number): void {
|
||||
this.titleCount = count;
|
||||
this.updateTitle();
|
||||
}
|
||||
|
||||
updateTitle() {
|
||||
updateTitle(): void {
|
||||
const count = this.titleCount ? `(${this.titleCount}) ` : '';
|
||||
const pageTitleWithSeparator = this.title && m.route.get() !== this.forum.attribute('basePath') + '/' ? this.title + ' - ' : '';
|
||||
const title = this.forum.attribute('title');
|
||||
|
@ -289,46 +371,55 @@ export default class Application {
|
|||
* Make an AJAX request, handling any low-level errors that may occur.
|
||||
*
|
||||
* @see https://mithril.js.org/request.html
|
||||
* @param {Object} options
|
||||
*
|
||||
* @param options
|
||||
* @return {Promise}
|
||||
* @public
|
||||
*/
|
||||
request(originalOptions) {
|
||||
const options = Object.assign({}, originalOptions);
|
||||
request<ResponseType>(originalOptions: FlarumRequestOptions<ResponseType>): Promise<ResponseType | string> {
|
||||
const options = { ...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;
|
||||
options.background ||= true;
|
||||
|
||||
extend(options, 'config', (result, xhr) => xhr.setRequestHeader('X-CSRF-Token', this.session.csrfToken));
|
||||
extend(options, 'config', (_: undefined, 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') {
|
||||
if (options.method && !['GET', 'POST'].includes(options.method)) {
|
||||
const method = options.method;
|
||||
extend(options, 'config', (result, xhr) => xhr.setRequestHeader('X-HTTP-Method-Override', method));
|
||||
|
||||
extend(options, 'config', (_: undefined, 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;
|
||||
});
|
||||
// @ts-expect-error Typescript doesn't know we return promisified `ReturnType` OR `string`,
|
||||
// so it errors due to Mithril's typings
|
||||
options.deserialize ||= (responseText: string) => responseText;
|
||||
|
||||
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) => {
|
||||
const original = options.modifyText || options.extract;
|
||||
|
||||
// @ts-expect-error
|
||||
options.extract = (xhr: XMLHttpRequest) => {
|
||||
let responseText;
|
||||
|
||||
if (original) {
|
||||
|
@ -340,7 +431,7 @@ export default class Application {
|
|||
const status = xhr.status;
|
||||
|
||||
if (status < 200 || status > 299) {
|
||||
throw new RequestError(status, responseText, options, xhr);
|
||||
throw new RequestError(`${status}`, `${responseText}`, options, xhr);
|
||||
}
|
||||
|
||||
if (xhr.getResponseHeader) {
|
||||
|
@ -349,9 +440,10 @@ export default class Application {
|
|||
}
|
||||
|
||||
try {
|
||||
// @ts-expect-error
|
||||
return JSON.parse(responseText);
|
||||
} catch (e) {
|
||||
throw new RequestError(500, responseText, options, xhr);
|
||||
throw new RequestError('500', `${responseText}`, options, xhr);
|
||||
}
|
||||
};
|
||||
|
||||
|
@ -366,9 +458,9 @@ export default class Application {
|
|||
|
||||
switch (error.status) {
|
||||
case 422:
|
||||
content = error.response.errors
|
||||
content = (error.response.errors as Record<string, unknown>[])
|
||||
.map((error) => [error.detail, <br />])
|
||||
.reduce((a, b) => a.concat(b), [])
|
||||
.flat()
|
||||
.slice(0, -1);
|
||||
break;
|
||||
|
||||
|
@ -405,7 +497,7 @@ export default class Application {
|
|||
content,
|
||||
controls: isDebug && [
|
||||
<Button className="Button Button--link" onclick={this.showDebug.bind(this, error, formattedError)}>
|
||||
Debug
|
||||
{app.translator.trans('core.lib.debug_button')}
|
||||
</Button>,
|
||||
],
|
||||
};
|
||||
|
@ -432,38 +524,28 @@ export default class Application {
|
|||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {RequestError} error
|
||||
* @param {string[]} [formattedError]
|
||||
* @private
|
||||
*/
|
||||
showDebug(error, formattedError) {
|
||||
this.alerts.dismiss(this.requestErrorAlert);
|
||||
private showDebug(error: RequestError, formattedError?: string[]) {
|
||||
if (this.requestErrorAlert !== null) this.alerts.dismiss(this.requestErrorAlert);
|
||||
|
||||
this.modal.show(RequestErrorModal, { error, formattedError });
|
||||
}
|
||||
|
||||
/**
|
||||
* Construct a URL to the route with the given name.
|
||||
*
|
||||
* @param {String} name
|
||||
* @param {Object} params
|
||||
* @return {String}
|
||||
* @public
|
||||
*/
|
||||
route(name, params = {}) {
|
||||
route(name: string, params: Record<string, unknown> = {}): string {
|
||||
const route = this.routes[name];
|
||||
|
||||
if (!route) throw new Error(`Route '${name}' does not exist`);
|
||||
|
||||
const url = route.path.replace(/:([^\/]+)/g, (m, key) => extract(params, key));
|
||||
const url = route.path.replace(/:([^\/]+)/g, (m, key) => `${extract(params, key)}`);
|
||||
|
||||
// Remove falsy values in params to avoid having urls like '/?sort&q'
|
||||
for (const key in params) {
|
||||
if (params.hasOwnProperty(key) && !params[key]) delete params[key];
|
||||
}
|
||||
|
||||
const queryString = m.buildQueryString(params);
|
||||
const queryString = m.buildQueryString(params as any);
|
||||
const prefix = m.route.prefix === '' ? this.forum.attribute('basePath') : '';
|
||||
|
||||
return prefix + url + (queryString ? '?' + queryString : '');
|
|
@ -1,16 +1,24 @@
|
|||
import type Mithril from 'mithril';
|
||||
import type { RouteResolver } from '../Application';
|
||||
import type { default as Component, ComponentAttrs } from '../Component';
|
||||
|
||||
/**
|
||||
* Generates a route resolver for a given component.
|
||||
*
|
||||
* In addition to regular route resolver functionality:
|
||||
* - It provide the current route name as an attr
|
||||
* - It sets a key on the component so a rerender will be triggered on route change.
|
||||
*/
|
||||
export default class DefaultResolver {
|
||||
component: Mithril.Component;
|
||||
export default class DefaultResolver<
|
||||
Attrs extends ComponentAttrs,
|
||||
Comp extends Component<Attrs & { routeName: string }>,
|
||||
RouteArgs extends Record<string, unknown> = {}
|
||||
> implements RouteResolver<Attrs, Comp, RouteArgs>
|
||||
{
|
||||
component: { new (): Comp };
|
||||
routeName: string;
|
||||
|
||||
constructor(component, routeName) {
|
||||
constructor(component: { new (): Comp }, routeName: string) {
|
||||
this.component = component;
|
||||
this.routeName = routeName;
|
||||
}
|
||||
|
@ -20,22 +28,22 @@ export default class DefaultResolver {
|
|||
* rerender occurs. This method can be overriden in subclasses
|
||||
* to prevent rerenders on some route changes.
|
||||
*/
|
||||
makeKey() {
|
||||
makeKey(): string {
|
||||
return this.routeName + JSON.stringify(m.route.param());
|
||||
}
|
||||
|
||||
makeAttrs(vnode) {
|
||||
makeAttrs(vnode: Mithril.Vnode<Attrs, Comp>): Attrs & { routeName: string } {
|
||||
return {
|
||||
...vnode.attrs,
|
||||
routeName: this.routeName,
|
||||
};
|
||||
}
|
||||
|
||||
onmatch(args, requestedPath, route) {
|
||||
onmatch(args: RouteArgs, requestedPath: string, route: string): { new (): Comp } {
|
||||
return this.component;
|
||||
}
|
||||
|
||||
render(vnode) {
|
||||
render(vnode: Mithril.Vnode<Attrs, Comp>): Mithril.Children {
|
||||
return [{ ...vnode, attrs: this.makeAttrs(vnode), key: this.makeKey() }];
|
||||
}
|
||||
}
|
||||
|
|
|
@ -12,7 +12,7 @@ const later =
|
|||
*/
|
||||
export default class ScrollListener {
|
||||
/**
|
||||
* @param {Function} callback The callback to run when the scroll position
|
||||
* @param {(top: number) => void} callback The callback to run when the scroll position
|
||||
* changes.
|
||||
* @public
|
||||
*/
|
||||
|
|
|
@ -1,3 +1,5 @@
|
|||
import type { FlarumGenericRoute, RouteResolver } from '../Application';
|
||||
import type Component from '../Component';
|
||||
import DefaultResolver from '../resolvers/DefaultResolver';
|
||||
|
||||
/**
|
||||
|
@ -6,12 +8,12 @@ import DefaultResolver from '../resolvers/DefaultResolver';
|
|||
* to provide each route with the current route name.
|
||||
*
|
||||
* @see https://mithril.js.org/route.html#signature
|
||||
* @param {Object} routes
|
||||
* @param {String} [basePath]
|
||||
* @return {Object}
|
||||
*/
|
||||
export default function mapRoutes(routes, basePath = '') {
|
||||
const map = {};
|
||||
export default function mapRoutes(routes: Record<string, FlarumGenericRoute>, basePath: string = '') {
|
||||
const map: Record<
|
||||
string,
|
||||
RouteResolver<Record<string, unknown>, Component<{ routeName: string; [key: string]: unknown }>, Record<string, unknown>>
|
||||
> = {};
|
||||
|
||||
for (const routeName in routes) {
|
||||
const route = routes[routeName];
|
||||
|
@ -19,7 +21,7 @@ export default function mapRoutes(routes, basePath = '') {
|
|||
if ('resolver' in route) {
|
||||
map[basePath + route.path] = route.resolver;
|
||||
} else if ('component' in route) {
|
||||
const resolverClass = 'resolverClass' in route ? route.resolverClass : DefaultResolver;
|
||||
const resolverClass = 'resolverClass' in route ? route.resolverClass! : DefaultResolver;
|
||||
map[basePath + route.path] = new resolverClass(route.component, routeName);
|
||||
} else {
|
||||
throw new Error(`Either a resolver or a component must be provided for the route [${routeName}]`);
|
|
@ -1,4 +1,5 @@
|
|||
import app from '../forum/app';
|
||||
|
||||
import History from './utils/History';
|
||||
import Pane from './utils/Pane';
|
||||
import DiscussionPage from './components/DiscussionPage';
|
||||
|
@ -19,79 +20,62 @@ import DiscussionListState from './states/DiscussionListState';
|
|||
import ComposerState from './states/ComposerState';
|
||||
import isSafariMobile from './utils/isSafariMobile';
|
||||
|
||||
import type Notification from './components/Notification';
|
||||
import type Post from './components/Post';
|
||||
|
||||
export default class ForumApplication extends Application {
|
||||
/**
|
||||
* A map of notification types to their components.
|
||||
*
|
||||
* @type {Object}
|
||||
*/
|
||||
notificationComponents = {
|
||||
notificationComponents: Record<string, typeof Notification> = {
|
||||
discussionRenamed: DiscussionRenamedNotification,
|
||||
};
|
||||
|
||||
/**
|
||||
* A map of post types to their components.
|
||||
*
|
||||
* @type {Object}
|
||||
*/
|
||||
postComponents = {
|
||||
postComponents: Record<string, typeof Post> = {
|
||||
comment: CommentPost,
|
||||
discussionRenamed: DiscussionRenamedPost,
|
||||
};
|
||||
|
||||
/**
|
||||
* An object which controls the state of the page's side pane.
|
||||
*
|
||||
* @type {Pane}
|
||||
*/
|
||||
pane = null;
|
||||
|
||||
/**
|
||||
* An object which controls the state of the page's drawer.
|
||||
*
|
||||
* @type {Drawer}
|
||||
*/
|
||||
drawer = null;
|
||||
pane: Pane | null = null;
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*
|
||||
* @type {History}
|
||||
*/
|
||||
history = new History();
|
||||
history: History = new History();
|
||||
|
||||
/**
|
||||
* An object which controls the state of the user's notifications.
|
||||
*
|
||||
* @type {NotificationListState}
|
||||
*/
|
||||
notifications = new NotificationListState(this);
|
||||
notifications: NotificationListState = new NotificationListState();
|
||||
|
||||
/*
|
||||
/**
|
||||
* An object which stores previously searched queries and provides convenient
|
||||
* tools for retrieving and managing search values.
|
||||
*
|
||||
* @type {GlobalSearchState}
|
||||
*/
|
||||
search = new GlobalSearchState();
|
||||
search: GlobalSearchState = new GlobalSearchState();
|
||||
|
||||
/*
|
||||
/**
|
||||
* An object which controls the state of the composer.
|
||||
*/
|
||||
composer = new ComposerState();
|
||||
composer: ComposerState = new ComposerState();
|
||||
|
||||
/**
|
||||
* An object which controls the state of the cached discussion list, which
|
||||
* is used in the index page and the slideout pane.
|
||||
*/
|
||||
discussions: DiscussionListState = new DiscussionListState({});
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
|
||||
routes(this);
|
||||
|
||||
/**
|
||||
* An object which controls the state of the cached discussion list, which
|
||||
* is used in the index page and the slideout pane.
|
||||
*
|
||||
* @type {DiscussionListState}
|
||||
*/
|
||||
this.discussions = new DiscussionListState({});
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -119,17 +103,17 @@ export default class ForumApplication extends Application {
|
|||
|
||||
// We mount navigation and header components after the page, so components
|
||||
// like the back button can access the updated state when rendering.
|
||||
m.mount(document.getElementById('app-navigation'), { view: () => Navigation.component({ className: 'App-backControl', drawer: true }) });
|
||||
m.mount(document.getElementById('header-navigation'), Navigation);
|
||||
m.mount(document.getElementById('header-primary'), HeaderPrimary);
|
||||
m.mount(document.getElementById('header-secondary'), HeaderSecondary);
|
||||
m.mount(document.getElementById('composer'), { view: () => Composer.component({ state: this.composer }) });
|
||||
m.mount(document.getElementById('app-navigation')!, { view: () => Navigation.component({ className: 'App-backControl', drawer: true }) });
|
||||
m.mount(document.getElementById('header-navigation')!, Navigation);
|
||||
m.mount(document.getElementById('header-primary')!, HeaderPrimary);
|
||||
m.mount(document.getElementById('header-secondary')!, HeaderSecondary);
|
||||
m.mount(document.getElementById('composer')!, { view: () => Composer.component({ state: this.composer }) });
|
||||
|
||||
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) => {
|
||||
document.getElementById('home-link')!.addEventListener('click', (e) => {
|
||||
if (e.ctrlKey || e.metaKey || e.which === 2) return;
|
||||
e.preventDefault();
|
||||
app.history.home();
|
|
@ -5,7 +5,7 @@ import Discussion from '../../common/models/Discussion';
|
|||
export default class DiscussionListState extends PaginatedListState<Discussion> {
|
||||
protected extraDiscussions: Discussion[] = [];
|
||||
|
||||
constructor(params: any, page: number) {
|
||||
constructor(params: any, page: number = 1) {
|
||||
super(params, page, 20);
|
||||
}
|
||||
|
||||
|
|
|
@ -499,6 +499,7 @@ core:
|
|||
|
||||
# Translations in this namespace are used by the forum and admin interfaces.
|
||||
lib:
|
||||
debug_button: Debug
|
||||
|
||||
# These translations are displayed as tooltips for discussion badges.
|
||||
badge:
|
||||
|
|
Loading…
Reference in New Issue
Block a user