mirror of
https://github.com/flarum/core.git
synced 2025-08-04 23:47:32 +02:00
feat: Code Splitting (#3860)
* feat: configure webpack to allow splitting chunks * feat: `JsDirectoryCompiler` and expose js assets URL * chore: support es2020 dynamic importing * feat: control which URL to fetch chunks from * feat: allow showing async modals & split 'LogInModal' * feat: split `SignUpModal` * feat: allow rendering async pages & split `UserSecurityPage` * fix: module might not be listed in chunk * feat: lazy load user pages * feat: track the chunk containing each module * chore: lightly warn * chore: split `Composer` * feat: add common frontend (for split common chunks) * fix: jsDoc typing imports should be ignored * feat: split `PostStream` `ForgotPasswordModal` and `EditUserModal` * fix: multiple inline async imports not picked up * chore: new `common` frontend assets only needs a jsdir compiler * feat: add revision hash to chunk import URL * fix: nothing to split for `admin` frontend yet * chore: cleanup registry API * chore: throw an error in debug mode if attempting to import a non-loaded module * feat: defer `extend` & `override` until after module registration * fix: plugin not picking up on all module sources * fix: must override default chunk loader function from webpack plugin * feat: split tags `TagDiscussionModal` and `TagSelectionModal` * fix: wrong export name * feat: import chunked modules from external packages * feat: extensions compatibility * feat: Router frontend extender async component * chore: clean JS output path (removes stale chunks) * fix: common chunks also need flushing * chore: flush backend stale chunks * Apply fixes from StyleCI * feat: loading alert when async page component is loading * chore: `yarn format` * chore: typings * chore: remove exception * Apply fixes from StyleCI * chore(infra): bundlewatch * chore(infra): bundlewatch split chunks * feat: split text editor * chore: tag typings * chore: bundlewatch * fix: windows paths * fix: wrong planned ext import format
This commit is contained in:
14
framework/core/js/src/@types/global.d.ts
vendored
14
framework/core/js/src/@types/global.d.ts
vendored
@@ -54,7 +54,7 @@ declare type VnodeElementTag<Attrs = Record<string, unknown>, C extends Componen
|
||||
* import app from 'flarum/common/app';
|
||||
* ```
|
||||
*/
|
||||
declare const app: never;
|
||||
declare const app: import('../common/Application').default;
|
||||
|
||||
declare const m: import('mithril').Static;
|
||||
declare const dayjs: typeof import('dayjs');
|
||||
@@ -98,8 +98,16 @@ interface FlarumObject {
|
||||
* }
|
||||
*/
|
||||
extensions: Readonly<Record<string, ESModule>>;
|
||||
|
||||
reg: any;
|
||||
/**
|
||||
* Contains a registry of all exported modules,
|
||||
* as well as chunks that can be imported and the modules
|
||||
* each chunk contains.
|
||||
*/
|
||||
reg: import('../common/ExportRegistry').default;
|
||||
/**
|
||||
* For early operations, this object stores whether we are in debug mode or not.
|
||||
*/
|
||||
debug: boolean;
|
||||
}
|
||||
|
||||
declare const flarum: FlarumObject;
|
||||
|
@@ -2,7 +2,6 @@ import Mithril from 'mithril';
|
||||
|
||||
import app from '../../admin/app';
|
||||
|
||||
import EditUserModal from '../../common/components/EditUserModal';
|
||||
import LoadingIndicator from '../../common/components/LoadingIndicator';
|
||||
import Button from '../../common/components/Button';
|
||||
|
||||
@@ -432,7 +431,7 @@ export default class UserListPage extends AdminPage {
|
||||
<Button
|
||||
className="Button UserList-editModalBtn"
|
||||
title={app.translator.trans('core.admin.users.grid.columns.edit_user.tooltip', { username: user.username() })}
|
||||
onclick={() => app.modal.show(EditUserModal, { user })}
|
||||
onclick={() => app.modal.show(() => import('../../common/components/EditUserModal'), { user })}
|
||||
>
|
||||
{app.translator.trans('core.admin.users.grid.columns.edit_user.button')}
|
||||
</Button>
|
||||
|
@@ -16,7 +16,7 @@ export default class ExtensionPageResolver<
|
||||
const extensionPage = app.extensionData.getPage<Attrs>(args.id);
|
||||
|
||||
if (extensionPage) {
|
||||
return extensionPage;
|
||||
return Promise.resolve(extensionPage);
|
||||
}
|
||||
|
||||
return super.onmatch(args, requestedPath, route);
|
||||
|
@@ -60,6 +60,9 @@ export interface FlarumRequestOptions<ResponseType> extends Omit<Mithril.Request
|
||||
modifyText?: (responseText: string) => string;
|
||||
}
|
||||
|
||||
export type NewComponent<Comp> = new () => Comp;
|
||||
export type AsyncNewComponent<Comp> = () => Promise<any & { default: NewComponent<Comp> }>;
|
||||
|
||||
/**
|
||||
* A valid route definition.
|
||||
*/
|
||||
@@ -82,14 +85,14 @@ export type RouteItem<
|
||||
/**
|
||||
* The component to render when this route matches.
|
||||
*/
|
||||
component: new () => Comp;
|
||||
component: NewComponent<Comp> | AsyncNewComponent<Comp>;
|
||||
/**
|
||||
* A custom resolver class.
|
||||
*
|
||||
* This should be the class itself, and **not** an instance of the
|
||||
* class.
|
||||
*/
|
||||
resolverClass?: new (component: new () => Comp, routeName: string) => DefaultResolver<Attrs, Comp, RouteArgs>;
|
||||
resolverClass?: new (component: NewComponent<Comp> | AsyncNewComponent<Comp>, routeName: string) => DefaultResolver<Attrs, Comp, RouteArgs>;
|
||||
}
|
||||
| {
|
||||
/**
|
||||
@@ -113,7 +116,7 @@ export interface RouteResolver<
|
||||
*
|
||||
* @see https://mithril.js.org/route.html#routeresolveronmatch
|
||||
*/
|
||||
onmatch(this: this, args: RouteArgs, requestedPath: string, route: string): { new (): Comp };
|
||||
onmatch(this: this, args: RouteArgs, requestedPath: string, route: string): Promise<{ new (): Comp }>;
|
||||
/**
|
||||
* A function which renders the provided component.
|
||||
*
|
||||
|
@@ -7,6 +7,7 @@ export interface IExportRegistry {
|
||||
|
||||
/**
|
||||
* Add an instance to the registry.
|
||||
* Identified by a namespace (extension ID) and an ID (module path).
|
||||
*/
|
||||
add(namespace: string, id: string, object: any): void;
|
||||
|
||||
@@ -17,14 +18,79 @@ export interface IExportRegistry {
|
||||
onLoad(namespace: string, id: string, handler: Function): void;
|
||||
|
||||
/**
|
||||
* Retrieve an object of type `id` from the registry.
|
||||
* Retrieve a module from the registry by namespace and ID.
|
||||
*/
|
||||
get(namespace: string, id: string): any;
|
||||
}
|
||||
|
||||
export default class ExportRegistry implements IExportRegistry {
|
||||
/**
|
||||
* @internal
|
||||
*/
|
||||
export interface IChunkRegistry {
|
||||
chunks: Map<string, Chunk>;
|
||||
chunkModules: Map<string, Module>;
|
||||
|
||||
/**
|
||||
* Check if a module has been loaded.
|
||||
* Return the module if so, false otherwise.
|
||||
*/
|
||||
checkModule(namespace: string, id: string): any | false;
|
||||
|
||||
/**
|
||||
* Register a module by the chunk ID it belongs to, the webpack module ID it belongs to,
|
||||
* the namespace (extension ID), and its path.
|
||||
*/
|
||||
addChunkModule(chunkId: number | string, moduleId: number | string, namespace: string, urlPath: string): void;
|
||||
|
||||
/**
|
||||
* Get a registered chunk. Each chunk has at least one module (the default one).
|
||||
*/
|
||||
getChunk(chunkId: number | string): Chunk | null;
|
||||
|
||||
/**
|
||||
* The chunk loader which overrides the default Webpack chunk loader.
|
||||
*/
|
||||
loadChunk(original: Function, url: string, done: () => Promise<void>, key: number, chunkId: number | string): Promise<void>;
|
||||
|
||||
/**
|
||||
* Responsible for loading external chunks.
|
||||
* Called automatically when an extension/package tries to async import a chunked module.
|
||||
*/
|
||||
asyncModuleImport(path: string): Promise<any>;
|
||||
}
|
||||
|
||||
type Chunk = {
|
||||
/**
|
||||
* The extension id of the chunk or 'core'.
|
||||
*/
|
||||
namespace: string;
|
||||
/**
|
||||
* The relative URL path to the chunk.
|
||||
*/
|
||||
urlPath: string;
|
||||
/**
|
||||
* An array of modules included in the chunk, by relative module path.
|
||||
*/
|
||||
modules?: string[];
|
||||
};
|
||||
|
||||
type Module = {
|
||||
/**
|
||||
* The chunk ID the module belongs to.
|
||||
*/
|
||||
chunkId: string;
|
||||
/**
|
||||
* The module ID. Not unique, as most chunk modules are concatenated into one module.
|
||||
*/
|
||||
moduleId: string;
|
||||
};
|
||||
|
||||
export default class ExportRegistry implements IExportRegistry, IChunkRegistry {
|
||||
moduleExports = new Map<string, Map<string, any>>();
|
||||
onLoads = new Map<string, Map<string, Function[]>>();
|
||||
chunks = new Map<string, Chunk>();
|
||||
chunkModules = new Map<string, Module>();
|
||||
private _revisions: any = null;
|
||||
|
||||
add(namespace: string, id: string, object: any): void {
|
||||
this.moduleExports.set(namespace, this.moduleExports.get(namespace) || new Map());
|
||||
@@ -36,7 +102,7 @@ export default class ExportRegistry implements IExportRegistry {
|
||||
?.forEach((handler) => handler(object));
|
||||
}
|
||||
|
||||
onLoad(namespace: string, id: string, handler: Function): void {
|
||||
onLoad(namespace: string, id: string, handler: (module: any) => void): void {
|
||||
if (this.moduleExports.has(namespace) && this.moduleExports.get(namespace)?.has(id)) {
|
||||
handler(this.moduleExports.get(namespace)?.get(id));
|
||||
} else {
|
||||
@@ -48,11 +114,126 @@ export default class ExportRegistry implements IExportRegistry {
|
||||
|
||||
get(namespace: string, id: string): any {
|
||||
const module = this.moduleExports.get(namespace)?.get(id);
|
||||
const error = `No module found for ${namespace}:${id}`;
|
||||
|
||||
if (!module) {
|
||||
console.warn(`No module found for ${namespace}:${id}`);
|
||||
// @ts-ignore
|
||||
if (!module && flarum.debug) {
|
||||
throw new Error(error);
|
||||
} else if (!module) {
|
||||
console.warn(error);
|
||||
}
|
||||
|
||||
return module;
|
||||
}
|
||||
|
||||
public checkModule(namespace: string, id: string): any | false {
|
||||
const exists = (this.moduleExports.has(namespace) && this.moduleExports.get(namespace)?.has(id)) || false;
|
||||
|
||||
return exists ? this.get(namespace, id) : false;
|
||||
}
|
||||
|
||||
addChunkModule(chunkId: number | string, moduleId: number | string, namespace: string, urlPath: string): void {
|
||||
if (!this.chunks.has(chunkId.toString())) {
|
||||
this.chunks.set(chunkId.toString(), {
|
||||
namespace,
|
||||
urlPath,
|
||||
modules: [urlPath],
|
||||
});
|
||||
} else {
|
||||
this.chunks.get(chunkId.toString())?.modules?.push(urlPath);
|
||||
}
|
||||
|
||||
this.chunkModules.set(`${namespace}:${urlPath}`, {
|
||||
chunkId: chunkId.toString(),
|
||||
moduleId: moduleId.toString(),
|
||||
});
|
||||
}
|
||||
|
||||
getChunk(chunkId: number | string): Chunk | null {
|
||||
const chunk = this.chunks.get(chunkId.toString()) ?? null;
|
||||
|
||||
if (!chunk) {
|
||||
console.warn(`[Export Registry] No chunk by the ID ${chunkId} found.`);
|
||||
return null;
|
||||
}
|
||||
|
||||
return chunk;
|
||||
}
|
||||
|
||||
async loadChunk(original: Function, url: string, done: (...args: any) => Promise<void>, key: number, chunkId: number | string): Promise<void> {
|
||||
// @ts-ignore
|
||||
app.alerts.showLoading();
|
||||
|
||||
return await original(
|
||||
this.chunkUrl(chunkId) || url,
|
||||
(...args: any) => {
|
||||
// @ts-ignore
|
||||
app.alerts.clearLoading();
|
||||
|
||||
return done(...args);
|
||||
},
|
||||
key,
|
||||
chunkId
|
||||
);
|
||||
}
|
||||
|
||||
chunkUrl(chunkId: number | string): string | null {
|
||||
const chunk = this.getChunk(chunkId.toString());
|
||||
|
||||
if (!chunk) return null;
|
||||
|
||||
this._revisions ??= JSON.parse(document.getElementById('flarum-rev-manifest')?.textContent ?? '{}');
|
||||
|
||||
// @ts-ignore cannot import the app object here, so we use the global one.
|
||||
const path = `${app.forum.attribute<string>('jsChunksBaseUrl')}/${chunk.namespace}/${chunk.urlPath}.js`;
|
||||
|
||||
// The paths in the revision are stored as (relative path from the assets path) + the path.
|
||||
// @ts-ignore
|
||||
const assetsPath = app.forum.attribute<string>('assetsBaseUrl');
|
||||
const key = path.replace(assetsPath, '').replace(/^\//, '');
|
||||
const revision = this._revisions[key];
|
||||
|
||||
return revision ? `${path}?v=${revision}` : path;
|
||||
}
|
||||
|
||||
async asyncModuleImport(path: string): Promise<any> {
|
||||
const [namespace, id] = this.namespaceAndIdFromPath(path);
|
||||
const module = this.chunkModules.get(`${namespace}:${id}`);
|
||||
|
||||
if (!module) {
|
||||
throw new Error(`No chunk found for module ${namespace}:${id}`);
|
||||
}
|
||||
|
||||
// @ts-ignore
|
||||
const wr = __webpack_require__;
|
||||
|
||||
return await wr.e(module.chunkId).then(() => {
|
||||
// Needed to make sure the module is loaded.
|
||||
// Taken care of by webpack.
|
||||
wr.bind(wr, module.moduleId)();
|
||||
|
||||
const moduleExport = this.get(namespace, id);
|
||||
|
||||
// For consistent access to async modules.
|
||||
moduleExport.default = moduleExport.default || moduleExport;
|
||||
|
||||
return moduleExport;
|
||||
});
|
||||
}
|
||||
|
||||
namespaceAndIdFromPath(path: string): [string, string] {
|
||||
// Either we get a path like `flarum/forum/components/LogInModal` or `ext:flarum/tags/forum/components/TagPage`.
|
||||
const matches = /^(?:ext:([^\/]+)\/(?:flarum-(?:ext-)?)?([^\/]+)|(flarum))(?:\/(.+))?$/.exec(path);
|
||||
|
||||
const id = matches![4];
|
||||
let namespace;
|
||||
|
||||
if (matches![1]) {
|
||||
namespace = `${matches![1]}-${matches![2]}`;
|
||||
} else {
|
||||
namespace = 'core';
|
||||
}
|
||||
|
||||
return [namespace, id];
|
||||
}
|
||||
}
|
||||
|
@@ -64,9 +64,7 @@ import './components/ModalManager';
|
||||
import './components/Button';
|
||||
import './components/Modal';
|
||||
import './components/GroupBadge';
|
||||
import './components/TextEditor';
|
||||
import './components/TextEditorButton';
|
||||
import './components/EditUserModal';
|
||||
import './components/Tooltip';
|
||||
|
||||
import './helpers/fullTime';
|
||||
|
@@ -6,6 +6,7 @@ import { disableBodyScroll, clearAllBodyScrollLocks } from 'body-scroll-lock';
|
||||
|
||||
import type ModalManagerState from '../states/ModalManagerState';
|
||||
import type Mithril from 'mithril';
|
||||
import LoadingIndicator from './LoadingIndicator';
|
||||
|
||||
interface IModalManagerAttrs {
|
||||
state: ModalManagerState;
|
||||
@@ -60,13 +61,15 @@ export default class ModalManager extends Component<IModalManagerAttrs> {
|
||||
);
|
||||
})}
|
||||
|
||||
{this.attrs.state.backdropShown && (
|
||||
{(this.attrs.state.backdropShown || this.attrs.state.loadingModal) && (
|
||||
<div
|
||||
className="Modal-backdrop backdrop"
|
||||
ontransitionend={this.onBackdropTransitionEnd.bind(this)}
|
||||
data-showing={!!this.attrs.state.modalList.length}
|
||||
style={{ '--modal-count': this.attrs.state.modalList.length }}
|
||||
/>
|
||||
data-showing={!!this.attrs.state.modalList.length || this.attrs.state.loadingModal}
|
||||
style={{ '--modal-count': this.attrs.state.modalList.length + Number(this.attrs.state.loadingModal) }}
|
||||
>
|
||||
{this.attrs.state.loadingModal && <LoadingIndicator />}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
@@ -7,6 +7,7 @@ import Button from './Button';
|
||||
|
||||
import BasicEditorDriver from '../utils/BasicEditorDriver';
|
||||
import Tooltip from './Tooltip';
|
||||
import LoadingIndicator from './LoadingIndicator';
|
||||
|
||||
/**
|
||||
* The `TextEditor` component displays a textarea with controls, including a
|
||||
@@ -36,17 +37,33 @@ export default class TextEditor extends Component {
|
||||
* Whether the editor is disabled.
|
||||
*/
|
||||
this.disabled = !!this.attrs.disabled;
|
||||
|
||||
/**
|
||||
* Whether the editor is loading.
|
||||
*/
|
||||
this.loading = true;
|
||||
|
||||
/**
|
||||
* Async operations to complete before the editor is ready.
|
||||
*/
|
||||
this._loaders = [];
|
||||
}
|
||||
|
||||
view() {
|
||||
return (
|
||||
<div className="TextEditor">
|
||||
<div className="TextEditor-editorContainer"></div>
|
||||
{this.loading ? (
|
||||
<LoadingIndicator />
|
||||
) : (
|
||||
<>
|
||||
<div className="TextEditor-editorContainer"></div>
|
||||
|
||||
<ul className="TextEditor-controls Composer-footer">
|
||||
{listItems(this.controlItems().toArray())}
|
||||
<li className="TextEditor-toolbar">{this.toolbarItems().toArray()}</li>
|
||||
</ul>
|
||||
<ul className="TextEditor-controls Composer-footer">
|
||||
{listItems(this.controlItems().toArray())}
|
||||
<li className="TextEditor-toolbar">{this.toolbarItems().toArray()}</li>
|
||||
</ul>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -54,6 +71,12 @@ export default class TextEditor extends Component {
|
||||
oncreate(vnode) {
|
||||
super.oncreate(vnode);
|
||||
|
||||
this._load().then(() => {
|
||||
setTimeout(this.onbuild.bind(this), 50);
|
||||
});
|
||||
}
|
||||
|
||||
onbuild() {
|
||||
this.attrs.composer.editor = this.buildEditor(this.$('.TextEditor-editorContainer')[0]);
|
||||
}
|
||||
|
||||
@@ -68,6 +91,13 @@ export default class TextEditor extends Component {
|
||||
}
|
||||
}
|
||||
|
||||
_load() {
|
||||
return Promise.all(this._loaders.map((loader) => loader())).then(() => {
|
||||
this.loading = false;
|
||||
m.redraw();
|
||||
});
|
||||
}
|
||||
|
||||
buildEditorParams() {
|
||||
return {
|
||||
classNames: ['FormControl', 'Composer-flexible', 'TextEditor-editor'],
|
||||
|
@@ -24,10 +24,19 @@
|
||||
* @param callback A callback which mutates the method's output
|
||||
*/
|
||||
export function extend<T extends Record<string, any>, K extends KeyOfType<T, Function>>(
|
||||
object: T,
|
||||
object: T | string,
|
||||
methods: K | K[],
|
||||
callback: (this: T, val: ReturnType<T[K]>, ...args: Parameters<T[K]>) => void
|
||||
) {
|
||||
// A lazy loaded module, only apply the function after the module is loaded.
|
||||
if (typeof object === 'string') {
|
||||
let [namespace, id] = flarum.reg.namespaceAndIdFromPath(object);
|
||||
|
||||
return flarum.reg.onLoad(namespace, id, (module) => {
|
||||
extend(module.prototype, methods, callback);
|
||||
});
|
||||
}
|
||||
|
||||
const allMethods = Array.isArray(methods) ? methods : [methods];
|
||||
|
||||
allMethods.forEach((method: K) => {
|
||||
@@ -73,17 +82,26 @@ export function extend<T extends Record<string, any>, K extends KeyOfType<T, Fun
|
||||
* @param newMethod The method to replace it with
|
||||
*/
|
||||
export function override<T extends Record<any, any>, K extends KeyOfType<T, Function>>(
|
||||
object: T,
|
||||
object: T | string,
|
||||
methods: K | K[],
|
||||
newMethod: (this: T, orig: T[K], ...args: Parameters<T[K]>) => void
|
||||
) {
|
||||
// A lazy loaded module, only apply the function after the module is loaded.
|
||||
if (typeof object === 'string') {
|
||||
let [namespace, id] = flarum.reg.namespaceAndIdFromPath(object);
|
||||
|
||||
return flarum.reg.onLoad(namespace, id, (module) => {
|
||||
override(module.prototype, methods, newMethod);
|
||||
});
|
||||
}
|
||||
|
||||
const allMethods = Array.isArray(methods) ? methods : [methods];
|
||||
|
||||
allMethods.forEach((method) => {
|
||||
const original: Function = object[method];
|
||||
|
||||
object[method] = function (this: T, ...args: Parameters<T[K]>) {
|
||||
return newMethod.apply(this, [original.bind(this), ...args]);
|
||||
return newMethod.apply(this, [original?.bind(this), ...args]);
|
||||
} as T[K];
|
||||
|
||||
Object.assign(object[method], original);
|
||||
|
@@ -1,4 +1,4 @@
|
||||
import Application, { FlarumGenericRoute } from '../Application';
|
||||
import Application, { AsyncNewComponent, FlarumGenericRoute, NewComponent } from '../Application';
|
||||
import IExtender, { IExtensionModule } from './IExtender';
|
||||
|
||||
type HelperRoute = (...args: any) => string;
|
||||
@@ -14,7 +14,7 @@ export default class Routes implements IExtender {
|
||||
* @param path The path of the route.
|
||||
* @param component must extend `Page` component.
|
||||
*/
|
||||
add(name: string, path: `/${string}`, component: any): Routes {
|
||||
add(name: string, path: `/${string}`, component: NewComponent<any> | AsyncNewComponent<any>): Routes {
|
||||
this.routes[name] = { path, component };
|
||||
|
||||
return this;
|
||||
|
@@ -1,6 +1,7 @@
|
||||
import type Mithril from 'mithril';
|
||||
import type { RouteResolver } from '../Application';
|
||||
import type { default as Component, ComponentAttrs } from '../Component';
|
||||
import type { AsyncNewComponent, NewComponent, RouteResolver } from '../Application';
|
||||
import type { ComponentAttrs } from '../Component';
|
||||
import Component from '../Component';
|
||||
|
||||
/**
|
||||
* Generates a route resolver for a given component.
|
||||
@@ -15,10 +16,10 @@ export default class DefaultResolver<
|
||||
RouteArgs extends Record<string, unknown> = {}
|
||||
> implements RouteResolver<Attrs, Comp, RouteArgs>
|
||||
{
|
||||
component: new () => Comp;
|
||||
component: NewComponent<Comp> | AsyncNewComponent<Comp>;
|
||||
routeName: string;
|
||||
|
||||
constructor(component: new () => Comp, routeName: string) {
|
||||
constructor(component: NewComponent<Comp> | AsyncNewComponent<Comp>, routeName: string) {
|
||||
this.component = component;
|
||||
this.routeName = routeName;
|
||||
}
|
||||
@@ -39,8 +40,12 @@ export default class DefaultResolver<
|
||||
};
|
||||
}
|
||||
|
||||
onmatch(args: RouteArgs, requestedPath: string, route: string): { new (): Comp } {
|
||||
return this.component;
|
||||
async onmatch(args: RouteArgs, requestedPath: string, route: string): Promise<NewComponent<Comp>> {
|
||||
if (this.component.prototype instanceof Component) {
|
||||
return this.component as NewComponent<Comp>;
|
||||
}
|
||||
|
||||
return (await (this.component as AsyncNewComponent<Comp>)()).default;
|
||||
}
|
||||
|
||||
render(vnode: Mithril.Vnode<Attrs, Comp>): Mithril.Children {
|
@@ -1,5 +1,6 @@
|
||||
import type Mithril from 'mithril';
|
||||
import Alert, { AlertAttrs } from '../components/Alert';
|
||||
import app from '../app';
|
||||
|
||||
/**
|
||||
* Returned by `AlertManagerState.show`. Used to dismiss alerts.
|
||||
@@ -17,6 +18,7 @@ export interface AlertState {
|
||||
export default class AlertManagerState {
|
||||
protected activeAlerts: AlertArray = {};
|
||||
protected alertId: AlertIdentifier = 0;
|
||||
protected loadingPool: number = 0;
|
||||
|
||||
getActiveAlerts() {
|
||||
return this.activeAlerts;
|
||||
@@ -71,4 +73,30 @@ export default class AlertManagerState {
|
||||
this.activeAlerts = {};
|
||||
m.redraw();
|
||||
}
|
||||
|
||||
/**
|
||||
* Shows a loading alert.
|
||||
*/
|
||||
showLoading(): AlertIdentifier | null {
|
||||
this.loadingPool++;
|
||||
|
||||
if (this.loadingPool > 1) return null;
|
||||
|
||||
return this.show(
|
||||
{
|
||||
type: 'warning',
|
||||
dismissible: false,
|
||||
},
|
||||
app.translator.trans('core.lib.loading_indicator.accessible_label')
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Hides a loading alert.
|
||||
*/
|
||||
clearLoading(): void {
|
||||
this.loadingPool--;
|
||||
|
||||
if (this.loadingPool === 0) this.clear();
|
||||
}
|
||||
}
|
||||
|
@@ -10,6 +10,12 @@ import Modal, { IDismissibleOptions } from '../components/Modal';
|
||||
*/
|
||||
type UnsafeModalClass = ComponentClass<any, Modal> & { get dismissibleOptions(): IDismissibleOptions; component: typeof Component.component };
|
||||
|
||||
/**
|
||||
* Alternatively, `show` takes an async function that returns a modal class.
|
||||
* This is useful for lazy-loading modals.
|
||||
*/
|
||||
type AsyncModalClass = () => Promise<any & { default: UnsafeModalClass }>;
|
||||
|
||||
type ModalItem = {
|
||||
componentClass: UnsafeModalClass;
|
||||
attrs?: Record<string, unknown>;
|
||||
@@ -37,6 +43,11 @@ export default class ModalManagerState {
|
||||
*/
|
||||
backdropShown: boolean = false;
|
||||
|
||||
/**
|
||||
* @internal
|
||||
*/
|
||||
loadingModal: boolean = false;
|
||||
|
||||
/**
|
||||
* Used to force re-initialization of modals if a modal
|
||||
* is replaced by another of the same type.
|
||||
@@ -61,14 +72,24 @@ export default class ModalManagerState {
|
||||
* @example <caption>Stacking modals</caption>
|
||||
* app.modal.show(MyCoolStackedModal, { attr: 'value' }, true);
|
||||
*/
|
||||
show(componentClass: UnsafeModalClass, attrs: Record<string, unknown> = {}, stackModal: boolean = false): void {
|
||||
if (!(componentClass.prototype instanceof Modal)) {
|
||||
async show(componentClass: UnsafeModalClass | AsyncModalClass, attrs: Record<string, unknown> = {}, stackModal: boolean = false): Promise<void> {
|
||||
if (!(componentClass.prototype instanceof Modal) && typeof componentClass !== 'function') {
|
||||
// This is duplicated so that if the error is caught, an error message still shows up in the debug console.
|
||||
const invalidModalWarning = 'The ModalManager can only show Modals.';
|
||||
console.error(invalidModalWarning);
|
||||
throw new Error(invalidModalWarning);
|
||||
}
|
||||
|
||||
if (!(componentClass.prototype instanceof Modal)) {
|
||||
this.loadingModal = true;
|
||||
m.redraw.sync();
|
||||
|
||||
componentClass = componentClass as AsyncModalClass;
|
||||
componentClass = (await componentClass()).default;
|
||||
|
||||
this.loadingModal = false;
|
||||
}
|
||||
|
||||
this.backdropShown = true;
|
||||
m.redraw.sync();
|
||||
|
||||
@@ -79,6 +100,8 @@ export default class ModalManagerState {
|
||||
// skip this RAF call, the hook will attempt to add a focus trap as well as lock scroll
|
||||
// onto the newly added modal before it's in the DOM, creating an extra scrollbar.
|
||||
requestAnimationFrame(() => {
|
||||
componentClass = componentClass as UnsafeModalClass;
|
||||
|
||||
// Set current modal
|
||||
this.modal = { componentClass, attrs, key: this.key++ };
|
||||
|
||||
|
@@ -3,10 +3,8 @@ import app from './app';
|
||||
import History from './utils/History';
|
||||
import Pane from './utils/Pane';
|
||||
import DiscussionPage from './components/DiscussionPage';
|
||||
import SignUpModal from './components/SignUpModal';
|
||||
import HeaderPrimary from './components/HeaderPrimary';
|
||||
import HeaderSecondary from './components/HeaderSecondary';
|
||||
import Composer from './components/Composer';
|
||||
import DiscussionRenamedNotification from './components/DiscussionRenamedNotification';
|
||||
import CommentPost from './components/CommentPost';
|
||||
import DiscussionRenamedPost from './components/DiscussionRenamedPost';
|
||||
@@ -119,7 +117,6 @@ export default class ForumApplication extends Application {
|
||||
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 state={this.composer} /> });
|
||||
|
||||
alertEmailConfirmation(this);
|
||||
|
||||
@@ -164,7 +161,7 @@ export default class ForumApplication extends Application {
|
||||
if (payload.loggedIn) {
|
||||
window.location.reload();
|
||||
} else {
|
||||
this.modal.show(SignUpModal, payload);
|
||||
this.modal.show(() => import('./components/SignUpModal'), payload);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@@ -4,7 +4,6 @@ import classList from '../../common/utils/classList';
|
||||
import PostUser from './PostUser';
|
||||
import PostMeta from './PostMeta';
|
||||
import PostEdited from './PostEdited';
|
||||
import EditPostComposer from './EditPostComposer';
|
||||
import ItemList from '../../common/utils/ItemList';
|
||||
import listItems from '../../common/helpers/listItems';
|
||||
import Button from '../../common/components/Button';
|
||||
@@ -88,6 +87,10 @@ export default class CommentPost extends Post {
|
||||
}
|
||||
|
||||
isEditing() {
|
||||
const EditPostComposer = flarum.reg.checkModule('core', 'forum/components/EditPostComposer');
|
||||
|
||||
if (!EditPostComposer) return false;
|
||||
|
||||
return app.composer.bodyMatches(EditPostComposer, { post: this.attrs.post });
|
||||
}
|
||||
|
||||
|
@@ -5,8 +5,6 @@ import Page, { IPageAttrs } from '../../common/components/Page';
|
||||
import ItemList from '../../common/utils/ItemList';
|
||||
import DiscussionHero from './DiscussionHero';
|
||||
import DiscussionListPane from './DiscussionListPane';
|
||||
import PostStream from './PostStream';
|
||||
import PostStreamScrubber from './PostStreamScrubber';
|
||||
import LoadingIndicator from '../../common/components/LoadingIndicator';
|
||||
import SplitDropdown from '../../common/components/SplitDropdown';
|
||||
import listItems from '../../common/helpers/listItems';
|
||||
@@ -26,6 +24,11 @@ export interface IDiscussionPageAttrs extends IPageAttrs {
|
||||
* the discussion list pane, the hero, the posts, and the sidebar.
|
||||
*/
|
||||
export default class DiscussionPage<CustomAttrs extends IDiscussionPageAttrs = IDiscussionPageAttrs> extends Page<CustomAttrs> {
|
||||
protected loading: boolean = true;
|
||||
|
||||
protected PostStream: any = null;
|
||||
protected PostStreamScrubber: any = null;
|
||||
|
||||
/**
|
||||
* The discussion that is being viewed.
|
||||
*/
|
||||
@@ -83,7 +86,7 @@ export default class DiscussionPage<CustomAttrs extends IDiscussionPageAttrs = I
|
||||
return (
|
||||
<div className="DiscussionPage">
|
||||
<DiscussionListPane state={app.discussions} />
|
||||
<div className="DiscussionPage-discussion">{this.discussion ? this.pageContent().toArray() : this.loadingItems().toArray()}</div>
|
||||
<div className="DiscussionPage-discussion">{!this.loading ? this.pageContent().toArray() : this.loadingItems().toArray()}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -140,7 +143,7 @@ export default class DiscussionPage<CustomAttrs extends IDiscussionPageAttrs = I
|
||||
items.add(
|
||||
'poststream',
|
||||
<div className="DiscussionPage-stream">
|
||||
<PostStream discussion={this.discussion} stream={this.stream} onPositionChange={this.positionChanged.bind(this)} />
|
||||
<this.PostStream discussion={this.discussion} stream={this.stream} onPositionChange={this.positionChanged.bind(this)} />
|
||||
</div>,
|
||||
10
|
||||
);
|
||||
@@ -152,18 +155,23 @@ export default class DiscussionPage<CustomAttrs extends IDiscussionPageAttrs = I
|
||||
* Load the discussion from the API or use the preloaded one.
|
||||
*/
|
||||
load(): void {
|
||||
const preloadedDiscussion = app.preloadedApiDocument<Discussion>();
|
||||
if (preloadedDiscussion) {
|
||||
// We must wrap this in a setTimeout because if we are mounting this
|
||||
// component for the first time on page load, then any calls to m.redraw
|
||||
// will be ineffective and thus any configs (scroll code) will be run
|
||||
// before stuff is drawn to the page.
|
||||
setTimeout(this.show.bind(this, preloadedDiscussion), 0);
|
||||
} else {
|
||||
const params = this.requestParams();
|
||||
Promise.all([import('./PostStream'), import('./PostStreamScrubber')]).then(([PostStreamImport, PostStreamScrubberImport]) => {
|
||||
this.PostStream = PostStreamImport.default;
|
||||
this.PostStreamScrubber = PostStreamScrubberImport.default;
|
||||
|
||||
app.store.find<Discussion>('discussions', m.route.param('id'), params).then(this.show.bind(this));
|
||||
}
|
||||
const preloadedDiscussion = app.preloadedApiDocument<Discussion>();
|
||||
if (preloadedDiscussion) {
|
||||
// We must wrap this in a setTimeout because if we are mounting this
|
||||
// component for the first time on page load, then any calls to m.redraw
|
||||
// will be ineffective and thus any configs (scroll code) will be run
|
||||
// before stuff is drawn to the page.
|
||||
setTimeout(this.show.bind(this, preloadedDiscussion), 0);
|
||||
} else {
|
||||
const params = this.requestParams();
|
||||
|
||||
app.store.find<Discussion>('discussions', m.route.param('id'), params).then(this.show.bind(this));
|
||||
}
|
||||
});
|
||||
|
||||
m.redraw();
|
||||
}
|
||||
@@ -183,6 +191,8 @@ export default class DiscussionPage<CustomAttrs extends IDiscussionPageAttrs = I
|
||||
* Initialize the component to display the given discussion.
|
||||
*/
|
||||
show(discussion: ApiResponseSingle<Discussion>): void {
|
||||
this.loading = false;
|
||||
|
||||
app.history.push('discussion', discussion.title());
|
||||
app.setTitle(discussion.title());
|
||||
app.setTitleCount(0);
|
||||
@@ -249,7 +259,7 @@ export default class DiscussionPage<CustomAttrs extends IDiscussionPageAttrs = I
|
||||
);
|
||||
}
|
||||
|
||||
items.add('scrubber', <PostStreamScrubber stream={this.stream} className="App-titleControl" />, -100);
|
||||
items.add('scrubber', <this.PostStreamScrubber stream={this.stream} className="App-titleControl" />, -100);
|
||||
|
||||
return items;
|
||||
}
|
||||
|
@@ -1,8 +1,6 @@
|
||||
import app from '../../forum/app';
|
||||
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';
|
||||
@@ -71,7 +69,7 @@ export default class HeaderSecondary extends Component {
|
||||
if (app.forum.attribute('allowSignUp')) {
|
||||
items.add(
|
||||
'signUp',
|
||||
<Button className="Button Button--link" onclick={() => app.modal.show(SignUpModal)}>
|
||||
<Button className="Button Button--link" onclick={() => app.modal.show(() => import('./SignUpModal'))}>
|
||||
{app.translator.trans('core.forum.header.sign_up_link')}
|
||||
</Button>,
|
||||
10
|
||||
@@ -80,7 +78,7 @@ export default class HeaderSecondary extends Component {
|
||||
|
||||
items.add(
|
||||
'logIn',
|
||||
<Button className="Button Button--link" onclick={() => app.modal.show(LogInModal)}>
|
||||
<Button className="Button Button--link" onclick={() => app.modal.show(() => import('./LogInModal'))}>
|
||||
{app.translator.trans('core.forum.header.log_in_link')}
|
||||
</Button>,
|
||||
0
|
||||
|
@@ -4,8 +4,6 @@ import ItemList from '../../common/utils/ItemList';
|
||||
import listItems from '../../common/helpers/listItems';
|
||||
import DiscussionList from './DiscussionList';
|
||||
import WelcomeHero from './WelcomeHero';
|
||||
import DiscussionComposer from './DiscussionComposer';
|
||||
import LogInModal from './LogInModal';
|
||||
import DiscussionPage from './DiscussionPage';
|
||||
import Dropdown from '../../common/components/Dropdown';
|
||||
import Button from '../../common/components/Button';
|
||||
@@ -284,12 +282,11 @@ export default class IndexPage<CustomAttrs extends IIndexPageAttrs = IIndexPageA
|
||||
newDiscussionAction(): Promise<unknown> {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (app.session.user) {
|
||||
app.composer.load(DiscussionComposer, { user: app.session.user });
|
||||
app.composer.show();
|
||||
app.composer.load(() => import('./DiscussionComposer'), { user: app.session.user }).then(() => app.composer.show());
|
||||
|
||||
return resolve(app.composer);
|
||||
} else {
|
||||
app.modal.show(LogInModal);
|
||||
app.modal.show(() => import('./LogInModal'));
|
||||
|
||||
return reject();
|
||||
}
|
||||
|
@@ -1,7 +1,5 @@
|
||||
import app from '../../forum/app';
|
||||
import Modal, { IInternalModalAttrs } from '../../common/components/Modal';
|
||||
import ForgotPasswordModal from './ForgotPasswordModal';
|
||||
import SignUpModal from './SignUpModal';
|
||||
import Button from '../../common/components/Button';
|
||||
import LogInButtons from './LogInButtons';
|
||||
import extractText from '../../common/utils/extractText';
|
||||
@@ -141,7 +139,7 @@ export default class LogInModal<CustomAttrs extends ILoginModalAttrs = ILoginMod
|
||||
const email = this.identification();
|
||||
const attrs = email.includes('@') ? { email } : undefined;
|
||||
|
||||
app.modal.show(ForgotPasswordModal, attrs);
|
||||
app.modal.show(() => import('./ForgotPasswordModal'), attrs);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -155,7 +153,7 @@ export default class LogInModal<CustomAttrs extends ILoginModalAttrs = ILoginMod
|
||||
[identification.includes('@') ? 'email' : 'username']: identification,
|
||||
};
|
||||
|
||||
app.modal.show(SignUpModal, attrs);
|
||||
app.modal.show(() => import('./SignUpModal'), attrs);
|
||||
}
|
||||
|
||||
onready() {
|
||||
|
@@ -1,7 +1,7 @@
|
||||
import app from '../../forum/app';
|
||||
import Component from '../../common/Component';
|
||||
import ScrollListener from '../../common/utils/ScrollListener';
|
||||
import PostLoading from './LoadingPost';
|
||||
import LoadingPost from './LoadingPost';
|
||||
import ReplyPlaceholder from './ReplyPlaceholder';
|
||||
import Button from '../../common/components/Button';
|
||||
import ItemList from '../../common/utils/ItemList';
|
||||
@@ -75,7 +75,7 @@ export default class PostStream extends Component {
|
||||
} else {
|
||||
attrs.key = 'post' + postIds[this.stream.visibleStart + i];
|
||||
|
||||
content = <PostLoading />;
|
||||
content = <LoadingPost />;
|
||||
}
|
||||
|
||||
return (
|
||||
|
@@ -1,6 +1,5 @@
|
||||
import app from '../../forum/app';
|
||||
import Modal, { IInternalModalAttrs } from '../../common/components/Modal';
|
||||
import LogInModal from './LogInModal';
|
||||
import Button from '../../common/components/Button';
|
||||
import LogInButtons from './LogInButtons';
|
||||
import extractText from '../../common/utils/extractText';
|
||||
@@ -151,7 +150,7 @@ export default class SignUpModal<CustomAttrs extends ISignupModalAttrs = ISignup
|
||||
identification: this.email() || this.username(),
|
||||
};
|
||||
|
||||
app.modal.show(LogInModal, attrs);
|
||||
app.modal.show(() => import('./LogInModal'), attrs);
|
||||
}
|
||||
|
||||
onready() {
|
||||
|
@@ -8,7 +8,6 @@ import AccessTokensList from './AccessTokensList';
|
||||
import LoadingIndicator from '../../common/components/LoadingIndicator';
|
||||
import Button from '../../common/components/Button';
|
||||
import NewAccessTokenModal from './NewAccessTokenModal';
|
||||
import { camelCaseToSnakeCase } from '../../common/utils/string';
|
||||
import type AccessToken from '../../common/models/AccessToken';
|
||||
import type Mithril from 'mithril';
|
||||
import Tooltip from '../../common/components/Tooltip';
|
||||
|
@@ -14,61 +14,40 @@ import './states/GlobalSearchState';
|
||||
import './states/NotificationListState';
|
||||
import './states/PostStreamState';
|
||||
import './states/SearchState';
|
||||
import './states/UserSecurityPageState';
|
||||
import './components/AffixedSidebar';
|
||||
import './components/DiscussionPage';
|
||||
import './components/DiscussionListPane';
|
||||
import './components/LogInModal';
|
||||
import './components/ComposerBody';
|
||||
import './components/ForgotPasswordModal';
|
||||
import './components/Notification';
|
||||
import './components/LogInButton';
|
||||
import './components/DiscussionsUserPage';
|
||||
import './components/Composer';
|
||||
import './components/SessionDropdown';
|
||||
import './components/HeaderPrimary';
|
||||
import './components/PostEdited';
|
||||
import './components/PostStream';
|
||||
import './components/ChangePasswordModal';
|
||||
import './components/IndexPage';
|
||||
import './components/DiscussionRenamedNotification';
|
||||
import './components/DiscussionsSearchSource';
|
||||
import './components/HeaderSecondary';
|
||||
import './components/ComposerButton';
|
||||
import './components/DiscussionList';
|
||||
import './components/ReplyPlaceholder';
|
||||
import './components/AvatarEditor';
|
||||
import './components/Post';
|
||||
import './components/SettingsPage';
|
||||
import './components/TerminalPost';
|
||||
import './components/ChangeEmailModal';
|
||||
import './components/NotificationsDropdown';
|
||||
import './components/UserPage';
|
||||
import './components/PostUser';
|
||||
import './components/UserCard';
|
||||
import './components/UsersSearchSource';
|
||||
import './components/UserSecurityPage';
|
||||
import './components/NotificationGrid';
|
||||
import './components/PostPreview';
|
||||
import './components/EventPost';
|
||||
import './components/DiscussionHero';
|
||||
import './components/PostMeta';
|
||||
import './components/DiscussionRenamedPost';
|
||||
import './components/DiscussionComposer';
|
||||
import './components/LogInButtons';
|
||||
import './components/NotificationList';
|
||||
import './components/WelcomeHero';
|
||||
import './components/SignUpModal';
|
||||
import './components/CommentPost';
|
||||
import './components/ComposerPostPreview';
|
||||
import './components/ReplyComposer';
|
||||
import './components/NotificationsPage';
|
||||
import './components/PostStreamScrubber';
|
||||
import './components/EditPostComposer';
|
||||
import './components/RenameDiscussionModal';
|
||||
import './components/Search';
|
||||
import './components/DiscussionListItem';
|
||||
import './components/LoadingPost';
|
||||
import './components/PostsUserPage';
|
||||
import './resolvers/DiscussionPageResolver';
|
||||
import './routes';
|
||||
|
@@ -2,14 +2,10 @@ import ForumApplication from './ForumApplication';
|
||||
import IndexPage from './components/IndexPage';
|
||||
import DiscussionPage from './components/DiscussionPage';
|
||||
import PostsUserPage from './components/PostsUserPage';
|
||||
import DiscussionsUserPage from './components/DiscussionsUserPage';
|
||||
import SettingsPage from './components/SettingsPage';
|
||||
import NotificationsPage from './components/NotificationsPage';
|
||||
import DiscussionPageResolver from './resolvers/DiscussionPageResolver';
|
||||
import Discussion from '../common/models/Discussion';
|
||||
import type Post from '../common/models/Post';
|
||||
import type User from '../common/models/User';
|
||||
import UserSecurityPage from './components/UserSecurityPage';
|
||||
|
||||
/**
|
||||
* Helper functions to generate URLs to form pages.
|
||||
@@ -32,11 +28,11 @@ export default function (app: ForumApplication) {
|
||||
|
||||
user: { path: '/u/:username', component: PostsUserPage },
|
||||
'user.posts': { path: '/u/:username', component: PostsUserPage },
|
||||
'user.discussions': { path: '/u/:username/discussions', component: DiscussionsUserPage },
|
||||
'user.discussions': { path: '/u/:username/discussions', component: () => import('./components/DiscussionsUserPage') },
|
||||
|
||||
settings: { path: '/settings', component: SettingsPage },
|
||||
'user.security': { path: '/u/:username/security', component: UserSecurityPage },
|
||||
notifications: { path: '/notifications', component: NotificationsPage },
|
||||
settings: { path: '/settings', component: () => import('./components/SettingsPage') },
|
||||
'user.security': { path: '/u/:username/security', component: () => import('./components/UserSecurityPage') },
|
||||
notifications: { path: '/notifications', component: () => import('./components/NotificationsPage') },
|
||||
};
|
||||
}
|
||||
|
||||
|
@@ -1,7 +1,7 @@
|
||||
import app from '../../forum/app';
|
||||
import subclassOf from '../../common/utils/subclassOf';
|
||||
import Stream from '../../common/utils/Stream';
|
||||
import ReplyComposer from '../components/ReplyComposer';
|
||||
import Component from '../../common/Component';
|
||||
|
||||
class ComposerState {
|
||||
constructor() {
|
||||
@@ -34,15 +34,27 @@ class ComposerState {
|
||||
*/
|
||||
this.editor = null;
|
||||
|
||||
/**
|
||||
* If the composer was loaded and mounted.
|
||||
*
|
||||
* @type {boolean}
|
||||
*/
|
||||
this.mounted = false;
|
||||
|
||||
this.clear();
|
||||
}
|
||||
|
||||
/**
|
||||
* Load a content component into the composer.
|
||||
*
|
||||
* @param {typeof import('../components/ComposerBody').default} componentClass
|
||||
* @param {() => Promise<any & { default: typeof import('../components/ComposerBody') }> | typeof import('../components/ComposerBody').default} componentClass
|
||||
* @param {object} attrs
|
||||
*/
|
||||
load(componentClass, attrs) {
|
||||
async load(componentClass, attrs) {
|
||||
if (!(componentClass.prototype instanceof Component)) {
|
||||
componentClass = (await componentClass()).default;
|
||||
}
|
||||
|
||||
const body = { componentClass, attrs };
|
||||
|
||||
if (this.preventExit()) return;
|
||||
@@ -81,7 +93,13 @@ class ComposerState {
|
||||
/**
|
||||
* Show the composer.
|
||||
*/
|
||||
show() {
|
||||
async show() {
|
||||
if (!this.mounted) {
|
||||
const Composer = (await import('../components/Composer')).default;
|
||||
m.mount(document.getElementById('composer'), { view: () => <Composer state={this} /> });
|
||||
this.mounted = true;
|
||||
}
|
||||
|
||||
if (this.position === ComposerState.Position.NORMAL || this.position === ComposerState.Position.FULLSCREEN) return;
|
||||
|
||||
this.position = ComposerState.Position.NORMAL;
|
||||
@@ -185,6 +203,10 @@ class ComposerState {
|
||||
* @return {boolean}
|
||||
*/
|
||||
composingReplyTo(discussion) {
|
||||
const ReplyComposer = flarum.reg.checkModule('core', 'forum/components/ReplyComposer');
|
||||
|
||||
if (!ReplyComposer) return false;
|
||||
|
||||
return this.isVisible() && this.bodyMatches(ReplyComposer, { discussion });
|
||||
}
|
||||
|
||||
|
@@ -1,7 +1,5 @@
|
||||
import app from '../../forum/app';
|
||||
import DiscussionPage from '../components/DiscussionPage';
|
||||
import ReplyComposer from '../components/ReplyComposer';
|
||||
import LogInModal from '../components/LogInModal';
|
||||
import Button from '../../common/components/Button';
|
||||
import Separator from '../../common/components/Separator';
|
||||
import RenameDiscussionModal from '../components/RenameDiscussionModal';
|
||||
@@ -168,12 +166,15 @@ const DiscussionControls = {
|
||||
if (app.session.user) {
|
||||
if (this.canReply()) {
|
||||
if (!app.composer.composingReplyTo(this) || forceRefresh) {
|
||||
app.composer.load(ReplyComposer, {
|
||||
user: app.session.user,
|
||||
discussion: this,
|
||||
});
|
||||
app.composer
|
||||
.load(() => import('../components/ReplyComposer'), {
|
||||
user: app.session.user,
|
||||
discussion: this,
|
||||
})
|
||||
.then(() => app.composer.show());
|
||||
} else {
|
||||
app.composer.show();
|
||||
}
|
||||
app.composer.show();
|
||||
|
||||
if (goToLast && app.viewingDiscussion(this) && !app.composer.isFullScreen()) {
|
||||
app.current.get('stream').goToNumber('reply');
|
||||
@@ -185,7 +186,7 @@ const DiscussionControls = {
|
||||
}
|
||||
}
|
||||
|
||||
app.modal.show(LogInModal);
|
||||
app.modal.show(() => import('../components/LogInModal'));
|
||||
|
||||
return reject();
|
||||
});
|
||||
|
@@ -1,5 +1,4 @@
|
||||
import app from '../../forum/app';
|
||||
import EditPostComposer from '../components/EditPostComposer';
|
||||
import Button from '../../common/components/Button';
|
||||
import Separator from '../../common/components/Separator';
|
||||
import ItemList from '../../common/utils/ItemList';
|
||||
@@ -121,8 +120,7 @@ const PostControls = {
|
||||
*/
|
||||
editAction() {
|
||||
return new Promise((resolve) => {
|
||||
app.composer.load(EditPostComposer, { post: this });
|
||||
app.composer.show();
|
||||
app.composer.load(() => import('../components/EditPostComposer'), { post: this }).then(() => app.composer.show());
|
||||
|
||||
return resolve();
|
||||
});
|
||||
|
@@ -1,7 +1,6 @@
|
||||
import app from '../../forum/app';
|
||||
import Button from '../../common/components/Button';
|
||||
import Separator from '../../common/components/Separator';
|
||||
import EditUserModal from '../../common/components/EditUserModal';
|
||||
import UserPage from '../components/UserPage';
|
||||
import ItemList from '../../common/utils/ItemList';
|
||||
|
||||
@@ -143,7 +142,7 @@ const UserControls = {
|
||||
* @param {import('../../common/models/User').default} user
|
||||
*/
|
||||
editAction(user) {
|
||||
app.modal.show(EditUserModal, { user });
|
||||
app.modal.show(() => import('../../common/components/EditUserModal'), { user });
|
||||
},
|
||||
};
|
||||
|
||||
|
@@ -3,6 +3,7 @@
|
||||
border-radius: var(--border-radius);
|
||||
line-height: 1.5;
|
||||
|
||||
--loading-indicator-color: var(--alert-color);
|
||||
background: var(--alert-bg);
|
||||
|
||||
&,
|
||||
|
@@ -22,7 +22,7 @@
|
||||
--size: 24px;
|
||||
--thickness: 2px;
|
||||
|
||||
color: var(--muted-color);
|
||||
color: var(--loading-indicator-color);
|
||||
|
||||
// Center vertically and horizontally
|
||||
// Allows people to set `height` and it'll stay centered within the new height
|
||||
|
@@ -20,16 +20,19 @@
|
||||
}
|
||||
|
||||
.Modal-backdrop {
|
||||
--loading-indicator-color: var(--body-bg);
|
||||
|
||||
background: var(--overlay-bg);
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
inset: 0;
|
||||
opacity: 0;
|
||||
transition: opacity 0.2s ease-out;
|
||||
z-index: ~"calc(var(--zindex-modal) + var(--modal-count) - 2)";
|
||||
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
&[data-showing] {
|
||||
opacity: 1;
|
||||
}
|
||||
|
@@ -81,6 +81,8 @@
|
||||
--tooltip-bg: @tooltip-bg;
|
||||
--tooltip-color: @tooltip-color;
|
||||
|
||||
--loading-indicator-color: var(--muted-color);
|
||||
|
||||
--online-user-circle-color: @online-user-circle-color;
|
||||
|
||||
--discussion-title-color: mix(@heading-color, @body-bg, 55%);
|
||||
|
@@ -90,6 +90,7 @@ class ForumSerializer extends AbstractSerializer
|
||||
'canCreateAccessToken' => $this->actor->can('createAccessToken'),
|
||||
'canModerateAccessTokens' => $this->actor->can('moderateAccessTokens'),
|
||||
'assetsBaseUrl' => rtrim($this->assetsFilesystem->url(''), '/'),
|
||||
'jsChunksBaseUrl' => $this->assetsFilesystem->url('js'),
|
||||
];
|
||||
|
||||
if ($this->actor->can('administrate')) {
|
||||
|
@@ -36,6 +36,7 @@ class Frontend implements ExtenderInterface
|
||||
private array $content = [];
|
||||
private array $preloadArrs = [];
|
||||
private ?string $titleDriver = null;
|
||||
private array $jsDirectory = [];
|
||||
|
||||
/**
|
||||
* @param string $frontend: The name of the frontend.
|
||||
@@ -71,6 +72,20 @@ class Frontend implements ExtenderInterface
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a directory of JavaScript files to include in the JS assets public directory.
|
||||
* Primarily used to copy JS chunks.
|
||||
*
|
||||
* @param string $path The path to the specific frontend chunks directory.
|
||||
* @return $this
|
||||
*/
|
||||
public function jsDirectory(string $path): self
|
||||
{
|
||||
$this->jsDirectory[] = $path;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a route to the frontend.
|
||||
*
|
||||
@@ -183,7 +198,7 @@ class Frontend implements ExtenderInterface
|
||||
|
||||
private function registerAssets(Container $container, string $moduleName): void
|
||||
{
|
||||
if (empty($this->css) && empty($this->js)) {
|
||||
if (empty($this->css) && empty($this->js) && empty($this->jsDirectory)) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -209,6 +224,14 @@ class Frontend implements ExtenderInterface
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (! empty($this->jsDirectory)) {
|
||||
$assets->jsDirectory(function (SourceCollector $sources) use ($moduleName) {
|
||||
foreach ($this->jsDirectory as $path) {
|
||||
$sources->addDirectory($path, $moduleName);
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
if (! $container->bound($abstract)) {
|
||||
|
@@ -17,6 +17,7 @@ use Illuminate\Contracts\Container\Container;
|
||||
use Illuminate\Filesystem\Filesystem;
|
||||
use Illuminate\Support\Arr;
|
||||
use Intervention\Image\ImageManager;
|
||||
use League\Flysystem\Visibility;
|
||||
use RuntimeException;
|
||||
|
||||
class FilesystemServiceProvider extends AbstractServiceProvider
|
||||
@@ -33,8 +34,9 @@ class FilesystemServiceProvider extends AbstractServiceProvider
|
||||
return [
|
||||
'flarum-assets' => function (Paths $paths, UrlGenerator $url) {
|
||||
return [
|
||||
'root' => "$paths->public/assets",
|
||||
'url' => $url->to('forum')->path('assets')
|
||||
'root' => "$paths->public/assets",
|
||||
'url' => $url->to('forum')->path('assets'),
|
||||
'visibility' => Visibility::PUBLIC
|
||||
];
|
||||
},
|
||||
'flarum-avatars' => function (Paths $paths, UrlGenerator $url) {
|
||||
|
@@ -110,6 +110,10 @@ class ForumServiceProvider extends AbstractServiceProvider
|
||||
});
|
||||
});
|
||||
|
||||
$assets->jsDirectory(function (SourceCollector $sources) {
|
||||
$sources->addDirectory(__DIR__.'/../../js/dist/forum', 'core');
|
||||
});
|
||||
|
||||
$assets->css(function (SourceCollector $sources) use ($container) {
|
||||
$sources->addFile(__DIR__.'/../../less/forum.less');
|
||||
$sources->addString(function () use ($container) {
|
||||
|
@@ -11,6 +11,7 @@ namespace Flarum\Frontend;
|
||||
|
||||
use Flarum\Frontend\Compiler\CompilerInterface;
|
||||
use Flarum\Frontend\Compiler\JsCompiler;
|
||||
use Flarum\Frontend\Compiler\JsDirectoryCompiler;
|
||||
use Flarum\Frontend\Compiler\LessCompiler;
|
||||
use Flarum\Frontend\Compiler\Source\SourceCollector;
|
||||
use Illuminate\Contracts\Filesystem\Cloud;
|
||||
@@ -29,7 +30,8 @@ class Assets
|
||||
'js' => [],
|
||||
'css' => [],
|
||||
'localeJs' => [],
|
||||
'localeCss' => []
|
||||
'localeCss' => [],
|
||||
'jsDirectory' => [],
|
||||
];
|
||||
|
||||
protected array $lessImportOverrides = [];
|
||||
@@ -72,6 +74,13 @@ class Assets
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function jsDirectory(callable $callback): static
|
||||
{
|
||||
$this->addSources('jsDirectory', $callback);
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
private function addSources(string $type, callable $callback): void
|
||||
{
|
||||
$this->sources[$type][] = $callback;
|
||||
@@ -122,6 +131,15 @@ class Assets
|
||||
return $compiler;
|
||||
}
|
||||
|
||||
public function makeJsDirectory(): JsDirectoryCompiler
|
||||
{
|
||||
$compiler = $this->makeJsDirectoryCompiler('js'.DIRECTORY_SEPARATOR.'{ext}'.DIRECTORY_SEPARATOR.$this->name);
|
||||
|
||||
$this->populate($compiler, 'jsDirectory');
|
||||
|
||||
return $compiler;
|
||||
}
|
||||
|
||||
protected function makeJsCompiler(string $filename): JsCompiler
|
||||
{
|
||||
return resolve(JsCompiler::class, [
|
||||
@@ -158,6 +176,14 @@ class Assets
|
||||
return $compiler;
|
||||
}
|
||||
|
||||
protected function makeJsDirectoryCompiler(string $string): JsDirectoryCompiler
|
||||
{
|
||||
return resolve(JsDirectoryCompiler::class, [
|
||||
'assetsDir' => $this->assetsDir,
|
||||
'destinationPath' => $string
|
||||
]);
|
||||
}
|
||||
|
||||
public function getName(): string
|
||||
{
|
||||
return $this->name;
|
||||
|
@@ -11,7 +11,7 @@ namespace Flarum\Frontend\Compiler;
|
||||
|
||||
interface CompilerInterface
|
||||
{
|
||||
public function getFilename(): string;
|
||||
public function getFilename(): ?string;
|
||||
|
||||
public function setFilename(string $filename): void;
|
||||
|
||||
|
42
framework/core/src/Frontend/Compiler/Concerns/HasSources.php
Normal file
42
framework/core/src/Frontend/Compiler/Concerns/HasSources.php
Normal file
@@ -0,0 +1,42 @@
|
||||
<?php
|
||||
|
||||
/*
|
||||
* This file is part of Flarum.
|
||||
*
|
||||
* For detailed copyright and license information, please view the
|
||||
* LICENSE file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace Flarum\Frontend\Compiler\Concerns;
|
||||
|
||||
use Flarum\Frontend\Compiler\Source\SourceCollector;
|
||||
use Flarum\Frontend\Compiler\Source\SourceInterface;
|
||||
|
||||
trait HasSources
|
||||
{
|
||||
/**
|
||||
* @var callable[]
|
||||
*/
|
||||
protected $sourcesCallbacks = [];
|
||||
|
||||
public function addSources(callable $callback): void
|
||||
{
|
||||
$this->sourcesCallbacks[] = $callback;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return SourceInterface[]
|
||||
*/
|
||||
protected function getSources(): array
|
||||
{
|
||||
$sources = new SourceCollector($this->allowedSourceTypes());
|
||||
|
||||
foreach ($this->sourcesCallbacks as $callback) {
|
||||
$callback($sources);
|
||||
}
|
||||
|
||||
return $sources->getSources();
|
||||
}
|
||||
|
||||
abstract protected function allowedSourceTypes(): array;
|
||||
}
|
@@ -48,4 +48,13 @@ class FileVersioner implements VersionerInterface
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
public function allRevisions(): array
|
||||
{
|
||||
if ($contents = $this->filesystem->get(static::REV_MANIFEST)) {
|
||||
return json_decode($contents, true);
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
137
framework/core/src/Frontend/Compiler/JsDirectoryCompiler.php
Normal file
137
framework/core/src/Frontend/Compiler/JsDirectoryCompiler.php
Normal file
@@ -0,0 +1,137 @@
|
||||
<?php
|
||||
|
||||
/*
|
||||
* This file is part of Flarum.
|
||||
*
|
||||
* For detailed copyright and license information, please view the
|
||||
* LICENSE file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace Flarum\Frontend\Compiler;
|
||||
|
||||
use Flarum\Frontend\Compiler\Concerns\HasSources;
|
||||
use Flarum\Frontend\Compiler\Source\DirectorySource;
|
||||
use Flarum\Frontend\Compiler\Source\SourceCollector;
|
||||
use Illuminate\Contracts\Filesystem\Cloud;
|
||||
use Illuminate\Filesystem\FilesystemAdapter;
|
||||
|
||||
/**
|
||||
* Used to copy JS files from a package directory to the assets' directory.
|
||||
* Without concatenating them. Primarily used for lazy loading JS modules.
|
||||
*
|
||||
* @method DirectorySource[] getSources()
|
||||
*/
|
||||
class JsDirectoryCompiler implements CompilerInterface
|
||||
{
|
||||
use HasSources;
|
||||
|
||||
protected VersionerInterface $versioner;
|
||||
|
||||
public function __construct(
|
||||
protected Cloud $assetsDir,
|
||||
protected string $destinationPath
|
||||
) {
|
||||
$this->versioner = new FileVersioner($assetsDir);
|
||||
}
|
||||
|
||||
public function getFilename(): ?string
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
public function setFilename(string $filename): void
|
||||
{
|
||||
//
|
||||
}
|
||||
|
||||
public function commit(bool $force = false): void
|
||||
{
|
||||
foreach ($this->getSources() as $source) {
|
||||
$this->compileSource($source, $force);
|
||||
}
|
||||
}
|
||||
|
||||
public function getUrl(): ?string
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
public function flush(): void
|
||||
{
|
||||
foreach ($this->getSources() as $source) {
|
||||
$this->flushSource($source);
|
||||
}
|
||||
|
||||
// Delete the remaining empty directory.
|
||||
$this->assetsDir->deleteDirectory($this->destinationPath);
|
||||
}
|
||||
|
||||
protected function allowedSourceTypes(): array
|
||||
{
|
||||
return [DirectorySource::class];
|
||||
}
|
||||
|
||||
protected function compileSource(DirectorySource $source, bool $force = false): void
|
||||
{
|
||||
$this->eachFile($source, fn (JsCompiler $compiler) => $compiler->commit($force));
|
||||
}
|
||||
|
||||
protected function flushSource(DirectorySource $source): void
|
||||
{
|
||||
$this->eachFile($source, fn (JsCompiler $compiler) => $compiler->flush());
|
||||
|
||||
$destinationDir = $this->destinationFor($source);
|
||||
|
||||
// Destination can still contain stale chunks.
|
||||
$this->assetsDir->deleteDirectory($destinationDir);
|
||||
|
||||
// Delete stale revisions.
|
||||
$remainingRevisions = $this->versioner->allRevisions();
|
||||
|
||||
foreach ($remainingRevisions as $filename => $revision) {
|
||||
if (str_starts_with($filename, $destinationDir)) {
|
||||
$this->versioner->putRevision($filename, null);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
protected function eachFile(DirectorySource $source, callable $callback): void
|
||||
{
|
||||
$filesystem = $source->getFilesystem();
|
||||
|
||||
foreach ($filesystem->allFiles() as $relativeFilePath) {
|
||||
// Skip non-JS files.
|
||||
if ($filesystem->mimeType($relativeFilePath) !== 'application/javascript') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$jsCompiler = $this->compilerFor($source, $filesystem, $relativeFilePath);
|
||||
$callback($jsCompiler);
|
||||
}
|
||||
}
|
||||
|
||||
protected function compilerFor(DirectorySource $source, FilesystemAdapter $filesystem, string $relativeFilePath): JsCompiler
|
||||
{
|
||||
// Filesystem's root is the actual directory we want to copy.
|
||||
// The destination path is relative to the assets' filesystem.
|
||||
|
||||
$jsCompiler = resolve(JsCompiler::class, [
|
||||
'assetsDir' => $this->assetsDir,
|
||||
// We put each file in `js/extensionId/frontend` (path provided) `/relativeFilePath` (such as `components/LogInModal.js`).
|
||||
'filename' => $this->destinationFor($source, $relativeFilePath),
|
||||
]);
|
||||
|
||||
$jsCompiler->addSources(
|
||||
fn (SourceCollector $sources) => $sources->addFile($filesystem->path($relativeFilePath), $source->getExtensionId())
|
||||
);
|
||||
|
||||
return $jsCompiler;
|
||||
}
|
||||
|
||||
protected function destinationFor(DirectorySource $source, ?string $relativeFilePath = null): string
|
||||
{
|
||||
$extensionId = $source->getExtensionId() ?? 'core';
|
||||
|
||||
return str_replace('{ext}', $extensionId, $this->destinationPath).DIRECTORY_SEPARATOR.$relativeFilePath;
|
||||
}
|
||||
}
|
@@ -9,8 +9,10 @@
|
||||
|
||||
namespace Flarum\Frontend\Compiler;
|
||||
|
||||
use Flarum\Frontend\Compiler\Source\SourceCollector;
|
||||
use Flarum\Frontend\Compiler\Concerns\HasSources;
|
||||
use Flarum\Frontend\Compiler\Source\FileSource;
|
||||
use Flarum\Frontend\Compiler\Source\SourceInterface;
|
||||
use Flarum\Frontend\Compiler\Source\StringSource;
|
||||
use Illuminate\Contracts\Filesystem\Cloud;
|
||||
|
||||
/**
|
||||
@@ -18,38 +20,17 @@ use Illuminate\Contracts\Filesystem\Cloud;
|
||||
*/
|
||||
class RevisionCompiler implements CompilerInterface
|
||||
{
|
||||
use HasSources;
|
||||
|
||||
const EMPTY_REVISION = 'empty';
|
||||
|
||||
/**
|
||||
* @var Cloud
|
||||
*/
|
||||
protected $assetsDir;
|
||||
protected VersionerInterface $versioner;
|
||||
|
||||
/**
|
||||
* @var VersionerInterface
|
||||
*/
|
||||
protected $versioner;
|
||||
|
||||
/**
|
||||
* @var string
|
||||
*/
|
||||
protected $filename;
|
||||
|
||||
/**
|
||||
* @var callable[]
|
||||
*/
|
||||
protected $sourcesCallbacks = [];
|
||||
|
||||
/**
|
||||
* @param Cloud $assetsDir
|
||||
* @param string $filename
|
||||
* @param VersionerInterface|null $versioner @deprecated nullable will be removed at v2.0
|
||||
*/
|
||||
public function __construct(Cloud $assetsDir, string $filename, VersionerInterface $versioner = null)
|
||||
{
|
||||
$this->assetsDir = $assetsDir;
|
||||
$this->filename = $filename;
|
||||
$this->versioner = $versioner ?: new FileVersioner($assetsDir);
|
||||
public function __construct(
|
||||
protected Cloud $assetsDir,
|
||||
protected string $filename,
|
||||
) {
|
||||
$this->versioner = new FileVersioner($assetsDir);
|
||||
}
|
||||
|
||||
public function getFilename(): string
|
||||
@@ -84,25 +65,6 @@ class RevisionCompiler implements CompilerInterface
|
||||
}
|
||||
}
|
||||
|
||||
public function addSources(callable $callback): void
|
||||
{
|
||||
$this->sourcesCallbacks[] = $callback;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return SourceInterface[]
|
||||
*/
|
||||
protected function getSources(): array
|
||||
{
|
||||
$sources = new SourceCollector;
|
||||
|
||||
foreach ($this->sourcesCallbacks as $callback) {
|
||||
$callback($sources);
|
||||
}
|
||||
|
||||
return $sources->getSources();
|
||||
}
|
||||
|
||||
public function getUrl(): ?string
|
||||
{
|
||||
$revision = $this->versioner->getRevision($this->filename);
|
||||
@@ -197,4 +159,9 @@ class RevisionCompiler implements CompilerInterface
|
||||
$this->assetsDir->delete($file);
|
||||
}
|
||||
}
|
||||
|
||||
protected function allowedSourceTypes(): array
|
||||
{
|
||||
return [FileSource::class, StringSource::class];
|
||||
}
|
||||
}
|
||||
|
@@ -0,0 +1,50 @@
|
||||
<?php
|
||||
|
||||
/*
|
||||
* This file is part of Flarum.
|
||||
*
|
||||
* For detailed copyright and license information, please view the
|
||||
* LICENSE file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace Flarum\Frontend\Compiler\Source;
|
||||
|
||||
use Illuminate\Filesystem\FilesystemAdapter;
|
||||
use League\Flysystem\Filesystem;
|
||||
use League\Flysystem\Local\LocalFilesystemAdapter;
|
||||
|
||||
class DirectorySource implements SourceInterface
|
||||
{
|
||||
protected FilesystemAdapter $filesystem;
|
||||
|
||||
public function __construct(
|
||||
protected string $path,
|
||||
protected ?string $extensionId = null
|
||||
) {
|
||||
$this->filesystem = new FilesystemAdapter(
|
||||
new Filesystem($adapter = new LocalFilesystemAdapter($path)),
|
||||
$adapter,
|
||||
['root' => $path]
|
||||
);
|
||||
}
|
||||
|
||||
public function getContent(): string
|
||||
{
|
||||
return '';
|
||||
}
|
||||
|
||||
public function getCacheDifferentiator(): array
|
||||
{
|
||||
return [$this->path, filemtime($this->path)];
|
||||
}
|
||||
|
||||
public function getFilesystem(): FilesystemAdapter
|
||||
{
|
||||
return $this->filesystem;
|
||||
}
|
||||
|
||||
public function getExtensionId(): ?string
|
||||
{
|
||||
return $this->extensionId;
|
||||
}
|
||||
}
|
@@ -16,6 +16,11 @@ use Closure;
|
||||
*/
|
||||
class SourceCollector
|
||||
{
|
||||
public function __construct(
|
||||
protected array $allowedSourceTypes = []
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* @var SourceInterface[]
|
||||
*/
|
||||
@@ -23,14 +28,27 @@ class SourceCollector
|
||||
|
||||
public function addFile(string $file, string $extensionId = null): static
|
||||
{
|
||||
$this->sources[] = new FileSource($file, $extensionId);
|
||||
$this->sources[] = $this->validateSourceType(
|
||||
new FileSource($file, $extensionId)
|
||||
);
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function addString(Closure $callback): static
|
||||
{
|
||||
$this->sources[] = new StringSource($callback);
|
||||
$this->sources[] = $this->validateSourceType(
|
||||
new StringSource($callback)
|
||||
);
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function addDirectory(string $directory, string $extensionId = null): static
|
||||
{
|
||||
$this->sources[] = $this->validateSourceType(
|
||||
new DirectorySource($directory, $extensionId)
|
||||
);
|
||||
|
||||
return $this;
|
||||
}
|
||||
@@ -42,4 +60,28 @@ class SourceCollector
|
||||
{
|
||||
return $this->sources;
|
||||
}
|
||||
|
||||
protected function validateSourceType(SourceInterface $source): SourceInterface
|
||||
{
|
||||
// allowedSourceTypes is an array of class names (or interface names)
|
||||
// so we need to check if the $source is an instance of one of those classes/interfaces (could be a parent class as well)
|
||||
$isInstanceOfOneOfTheAllowedSourceTypes = false;
|
||||
|
||||
foreach ($this->allowedSourceTypes as $allowedSourceType) {
|
||||
if ($source instanceof $allowedSourceType) {
|
||||
$isInstanceOfOneOfTheAllowedSourceTypes = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (! empty($this->allowedSourceTypes) && ! $isInstanceOfOneOfTheAllowedSourceTypes) {
|
||||
throw new \InvalidArgumentException(sprintf(
|
||||
'Source type %s is not allowed for this collector. Allowed types are: %s',
|
||||
get_class($source),
|
||||
implode(', ', $this->allowedSourceTypes)
|
||||
));
|
||||
}
|
||||
|
||||
return $source;
|
||||
}
|
||||
}
|
||||
|
@@ -14,4 +14,6 @@ interface VersionerInterface
|
||||
public function putRevision(string $file, ?string $revision): void;
|
||||
|
||||
public function getRevision(string $file): ?string;
|
||||
|
||||
public function allRevisions(): array;
|
||||
}
|
||||
|
@@ -20,6 +20,7 @@ use Psr\Http\Message\ServerRequestInterface as Request;
|
||||
class Assets
|
||||
{
|
||||
protected FrontendAssets $assets;
|
||||
protected FrontendAssets $commonAssets;
|
||||
|
||||
public function __construct(
|
||||
protected Container $container,
|
||||
@@ -35,6 +36,7 @@ class Assets
|
||||
public function forFrontend(string $name): self
|
||||
{
|
||||
$this->assets = $this->container->make('flarum.assets.'.$name);
|
||||
$this->commonAssets = $this->container->make('flarum.assets.common');
|
||||
|
||||
return $this;
|
||||
}
|
||||
@@ -59,10 +61,16 @@ class Assets
|
||||
*/
|
||||
protected function assembleCompilers(?string $locale): array
|
||||
{
|
||||
return [
|
||||
'js' => [$this->assets->makeJs(), $this->assets->makeLocaleJs($locale)],
|
||||
$frontendCompilers = [
|
||||
'js' => [$this->assets->makeJs(), $this->assets->makeLocaleJs($locale), $this->assets->makeJsDirectory()],
|
||||
'css' => [$this->assets->makeCss(), $this->assets->makeLocaleCss($locale)]
|
||||
];
|
||||
|
||||
$commonCompilers = [
|
||||
'js' => [$this->commonAssets->makeJsDirectory()],
|
||||
];
|
||||
|
||||
return array_merge_recursive($commonCompilers, $frontendCompilers);
|
||||
}
|
||||
|
||||
/**
|
||||
|
@@ -9,7 +9,11 @@
|
||||
|
||||
namespace Flarum\Frontend;
|
||||
|
||||
use Flarum\Foundation\Config;
|
||||
use Flarum\Frontend\Compiler\FileVersioner;
|
||||
use Flarum\Frontend\Compiler\VersionerInterface;
|
||||
use Flarum\Frontend\Driver\TitleDriverInterface;
|
||||
use Illuminate\Contracts\Filesystem\Factory as FilesystemFactory;
|
||||
use Illuminate\Contracts\Support\Renderable;
|
||||
use Illuminate\Contracts\View\Factory;
|
||||
use Illuminate\Contracts\View\View;
|
||||
@@ -131,12 +135,22 @@ class Document implements Renderable
|
||||
*/
|
||||
public array $preloads = [];
|
||||
|
||||
/**
|
||||
* We need the versioner to get the revisions of split chunks.
|
||||
*/
|
||||
protected VersionerInterface $versioner;
|
||||
|
||||
public function __construct(
|
||||
protected Factory $view,
|
||||
protected array $forumApiDocument,
|
||||
protected Request $request,
|
||||
protected TitleDriverInterface $titleDriver
|
||||
protected TitleDriverInterface $titleDriver,
|
||||
protected Config $config,
|
||||
FilesystemFactory $filesystem
|
||||
) {
|
||||
$this->versioner = new FileVersioner(
|
||||
$filesystem->disk('flarum-assets')
|
||||
);
|
||||
}
|
||||
|
||||
public function render(): string
|
||||
@@ -157,6 +171,8 @@ class Document implements Renderable
|
||||
'js' => $this->makeJs(),
|
||||
'head' => $this->makeHead(),
|
||||
'foot' => $this->makeFoot(),
|
||||
'revisions' => $this->versioner->allRevisions(),
|
||||
'debug' => $this->config->inDebugMode(),
|
||||
]);
|
||||
}
|
||||
|
||||
|
@@ -10,8 +10,7 @@
|
||||
namespace Flarum\Frontend;
|
||||
|
||||
use Flarum\Api\Client;
|
||||
use Flarum\Frontend\Driver\TitleDriverInterface;
|
||||
use Illuminate\Contracts\View\Factory;
|
||||
use Illuminate\Contracts\Container\Container;
|
||||
use Psr\Http\Message\ResponseInterface as Response;
|
||||
use Psr\Http\Message\ServerRequestInterface as Request;
|
||||
|
||||
@@ -23,9 +22,8 @@ class Frontend
|
||||
protected array $content = [];
|
||||
|
||||
public function __construct(
|
||||
protected Factory $view,
|
||||
protected Client $api,
|
||||
protected TitleDriverInterface $titleDriver
|
||||
protected Container $container
|
||||
) {
|
||||
}
|
||||
|
||||
@@ -36,9 +34,9 @@ class Frontend
|
||||
|
||||
public function document(Request $request): Document
|
||||
{
|
||||
$forumDocument = $this->getForumDocument($request);
|
||||
$forumApiDocument = $this->getForumDocument($request);
|
||||
|
||||
$document = new Document($this->view, $forumDocument, $request, $this->titleDriver);
|
||||
$document = $this->container->makeWith(Document::class, compact('forumApiDocument', 'request'));
|
||||
|
||||
$this->populate($document, $request);
|
||||
|
||||
|
@@ -9,15 +9,20 @@
|
||||
|
||||
namespace Flarum\Frontend;
|
||||
|
||||
use Flarum\Extension\Event\Disabled;
|
||||
use Flarum\Extension\Event\Enabled;
|
||||
use Flarum\Foundation\AbstractServiceProvider;
|
||||
use Flarum\Foundation\Event\ClearingCache;
|
||||
use Flarum\Foundation\Paths;
|
||||
use Flarum\Frontend\Compiler\Source\SourceCollector;
|
||||
use Flarum\Frontend\Driver\BasicTitleDriver;
|
||||
use Flarum\Frontend\Driver\TitleDriverInterface;
|
||||
use Flarum\Http\SlugManager;
|
||||
use Flarum\Http\UrlGenerator;
|
||||
use Flarum\Locale\LocaleManager;
|
||||
use Flarum\Settings\SettingsRepositoryInterface;
|
||||
use Illuminate\Contracts\Container\Container;
|
||||
use Illuminate\Contracts\Events\Dispatcher;
|
||||
use Illuminate\Contracts\View\Factory as ViewFactory;
|
||||
|
||||
class FrontendServiceProvider extends AbstractServiceProvider
|
||||
@@ -163,9 +168,20 @@ class FrontendServiceProvider extends AbstractServiceProvider
|
||||
return [];
|
||||
}
|
||||
);
|
||||
|
||||
$this->container->bind('flarum.assets.common', function (Container $container) {
|
||||
/** @var \Flarum\Frontend\Assets $assets */
|
||||
$assets = $container->make('flarum.assets.factory')('common');
|
||||
|
||||
$assets->jsDirectory(function (SourceCollector $sources) {
|
||||
$sources->addDirectory(__DIR__.'/../../js/dist/common', 'core');
|
||||
});
|
||||
|
||||
return $assets;
|
||||
});
|
||||
}
|
||||
|
||||
public function boot(Container $container, ViewFactory $views): void
|
||||
public function boot(Container $container, Dispatcher $events, ViewFactory $views): void
|
||||
{
|
||||
$this->loadViewsFrom(__DIR__.'/../../views', 'flarum');
|
||||
|
||||
@@ -174,6 +190,17 @@ class FrontendServiceProvider extends AbstractServiceProvider
|
||||
'url' => $container->make(UrlGenerator::class),
|
||||
'slugManager' => $container->make(SlugManager::class)
|
||||
]);
|
||||
|
||||
$events->listen(
|
||||
[Enabled::class, Disabled::class, ClearingCache::class],
|
||||
function () use ($container) {
|
||||
$recompile = new RecompileFrontendAssets(
|
||||
$container->make('flarum.assets.common'),
|
||||
$container->make(LocaleManager::class)
|
||||
);
|
||||
$recompile->flush();
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
public function addBaseCss(SourceCollector $sources): void
|
||||
|
@@ -53,5 +53,7 @@ class RecompileFrontendAssets
|
||||
foreach ($this->locales->getLocales() as $locale => $name) {
|
||||
$this->assets->makeLocaleJs($locale)->flush();
|
||||
}
|
||||
|
||||
$this->assets->makeJsDirectory()->flush();
|
||||
}
|
||||
}
|
||||
|
@@ -16,11 +16,13 @@
|
||||
|
||||
<script>
|
||||
document.getElementById('flarum-loading').style.display = 'block';
|
||||
var flarum = {extensions: {}};
|
||||
var flarum = {extensions: {}, debug: @js($debug)};
|
||||
</script>
|
||||
|
||||
{!! $js !!}
|
||||
|
||||
<script id="flarum-rev-manifest" type="application/json">@json($revisions)</script>
|
||||
|
||||
<script id="flarum-json-payload" type="application/json">@json($payload)</script>
|
||||
|
||||
<script>
|
||||
|
Reference in New Issue
Block a user