add prettier config and prettify javascript

This commit is contained in:
David Sevilla Martin
2020-01-31 17:17:46 -05:00
parent dd13ff4169
commit dfedd585f5
74 changed files with 3132 additions and 3007 deletions

View File

@ -8,4 +8,4 @@
*/
export * from './src/common';
export * from './src/admin';
export * from './src/admin';

File diff suppressed because one or more lines are too long

2
js/dist/forum.js vendored

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -8,4 +8,4 @@
*/
export * from './src/common';
export * from './src/forum';
export * from './src/forum';

View File

@ -27,7 +27,7 @@
"scripts": {
"dev": "webpack --mode development --watch",
"build": "webpack --mode production",
"lint": "prettier --single-quote --trailing-comma es5 --print-width 150 --tab-width 4 --write \"src/**/*\""
"lint": "prettier --single-quote --trailing-comma es5 --print-width 150 --tab-width 4 --write \"src/**/*\" \"*.{ts,js}\""
},
"devDependencies": {
"@types/classnames": "^2.2.9",

2
js/shims.d.ts vendored
View File

@ -3,5 +3,5 @@ import 'flarum-webpack-config/shims';
import Forum from './src/forum/Forum';
declare global {
const app: Forum;
const app: Forum;
}

View File

@ -4,7 +4,7 @@ import Bus from './Bus';
import Translator from './Translator';
import Session from './Session';
import Store from './Store';
import {extend} from './extend';
import { extend } from './extend';
import extract from './utils/extract';
import mapRoutes from './utils/mapRoutes';
@ -56,7 +56,7 @@ export default abstract class Application {
discussions: Discussion,
posts: Post,
groups: Group,
notifications: Notification
notifications: Notification,
});
drawer = new Drawer();
@ -97,10 +97,7 @@ export default abstract class Application {
this.forum = this.store.getById('forums', 1);
this.session = new Session(
this.store.getById('users', this.data.session.userId),
this.data.session.csrfToken
);
this.session = new Session(this.store.getById('users', this.data.session.userId), this.data.session.csrfToken);
this.locale();
this.plugins();
@ -128,15 +125,15 @@ export default abstract class Application {
* Get the API response document that has been preloaded into the application.
*/
preloadedApiDocument() {
if (this.data.apiDocument) {
const results = this.store.pushPayload(this.data.apiDocument);
if (this.data.apiDocument) {
const results = this.store.pushPayload(this.data.apiDocument);
this.data.apiDocument = null;
this.data.apiDocument = null;
return results;
}
return results;
}
return null;
return null;
}
/**
@ -156,9 +153,7 @@ export default abstract class Application {
}
updateTitle() {
document.title = (this.titleCount ? `(${this.titleCount}) ` : '') +
(this.title ? this.title + ' - ' : '') +
this.forum.attribute('title');
document.title = (this.titleCount ? `(${this.titleCount}) ` : '') + (this.title ? this.title + ' - ' : '') + this.forum.attribute('title');
}
/**
@ -181,135 +176,139 @@ export default abstract class Application {
*
* @see https://mithril.js.org/request.html
*/
request(originalOptions: Mithril.RequestOptions|any): Promise<any> {
const options: Mithril.RequestOptions = Object.assign({}, originalOptions);
request(originalOptions: Mithril.RequestOptions | any): Promise<any> {
const options: Mithril.RequestOptions = Object.assign({}, originalOptions);
// Set some default options if they haven't been overridden. We want to
// authenticate all requests with the session token. We also want all
// requests to run asynchronously in the background, so that they don't
// prevent redraws from occurring.
options.background = options.background || true;
// Set some default options if they haven't been overridden. We want to
// authenticate all requests with the session token. We also want all
// requests to run asynchronously in the background, so that they don't
// prevent redraws from occurring.
options.background = options.background || true;
extend(options, 'config', (result, xhr: XMLHttpRequest) => xhr.setRequestHeader('X-CSRF-Token', this.session.csrfToken));
extend(options, 'config', (result, xhr: XMLHttpRequest) => xhr.setRequestHeader('X-CSRF-Token', this.session.csrfToken));
// If the method is something like PATCH or DELETE, which not all servers
// and clients support, then we'll send it as a POST request with the
// intended method specified in the X-HTTP-Method-Override header.
if (options.method !== 'GET' && options.method !== 'POST') {
const method = options.method;
extend(options, 'config', (result, xhr: XMLHttpRequest) => xhr.setRequestHeader('X-HTTP-Method-Override', method));
options.method = 'POST';
}
// When we deserialize JSON data, if for some reason the server has provided
// a dud response, we don't want the application to crash. We'll show an
// error message to the user instead.
options.deserialize = options.deserialize || (responseText => responseText);
options.errorHandler = options.errorHandler || (error => {
throw error;
});
// When extracting the data from the response, we can check the server
// response code and show an error message to the user if something's gone
// awry.
const original = options.extract;
options.extract = xhr => {
let responseText;
if (original) {
responseText = original(xhr.responseText);
} else {
responseText = xhr.responseText || null;
// If the method is something like PATCH or DELETE, which not all servers
// and clients support, then we'll send it as a POST request with the
// intended method specified in the X-HTTP-Method-Override header.
if (options.method !== 'GET' && options.method !== 'POST') {
const method = options.method;
extend(options, 'config', (result, xhr: XMLHttpRequest) => xhr.setRequestHeader('X-HTTP-Method-Override', method));
options.method = 'POST';
}
const status = xhr.status;
// 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);
if (status < 200 || status > 299) {
throw new RequestError(status, responseText, options, xhr);
}
options.errorHandler =
options.errorHandler ||
(error => {
throw error;
});
if (xhr.getResponseHeader) {
const csrfToken = xhr.getResponseHeader('X-CSRF-Token');
if (csrfToken) app.session.csrfToken = csrfToken;
}
// When extracting the data from the response, we can check the server
// response code and show an error message to the user if something's gone
// awry.
const original = options.extract;
options.extract = xhr => {
let responseText;
try {
return JSON.parse(responseText);
} catch (e) {
throw new RequestError(500, responseText, options, xhr);
}
};
if (original) {
responseText = original(xhr.responseText);
} else {
responseText = xhr.responseText || null;
}
// TODO: ALERT MANAGER
// if (this.requestError) this.alerts.dismiss(this.requestError.alert);
const status = xhr.status;
// Now make the request. If it's a failure, inspect the error that was
// returned and show an alert containing its contents.
// const deferred = m.deferred();
if (status < 200 || status > 299) {
throw new RequestError(status, responseText, options, xhr);
}
// return new Promise((resolve, reject) => )
if (xhr.getResponseHeader) {
const csrfToken = xhr.getResponseHeader('X-CSRF-Token');
if (csrfToken) app.session.csrfToken = csrfToken;
}
return m.request(options)
.then(res => res, error => {
this.requestError = error;
try {
return JSON.parse(responseText);
} catch (e) {
throw new RequestError(500, responseText, options, xhr);
}
};
let children;
// TODO: ALERT MANAGER
// if (this.requestError) this.alerts.dismiss(this.requestError.alert);
switch (error.status) {
case 422:
children = error.response.errors
.map(error => [error.detail, m('br')])
.reduce((a, b) => a.concat(b), [])
.slice(0, -1);
break;
// Now make the request. If it's a failure, inspect the error that was
// returned and show an alert containing its contents.
// const deferred = m.deferred();
case 401:
case 403:
children = this.translator.trans('core.lib.error.permission_denied_message');
break;
// return new Promise((resolve, reject) => )
case 404:
case 410:
children = this.translator.trans('core.lib.error.not_found_message');
break;
return m.request(options).then(
res => res,
error => {
this.requestError = error;
case 429:
children = this.translator.trans('core.lib.error.rate_limit_exceeded_message');
break;
let children;
default:
children = this.translator.trans('core.lib.error.generic_message');
}
switch (error.status) {
case 422:
children = error.response.errors
.map(error => [error.detail, m('br')])
.reduce((a, b) => a.concat(b), [])
.slice(0, -1);
break;
const isDebug = app.forum.attribute('debug');
case 401:
case 403:
children = this.translator.trans('core.lib.error.permission_denied_message');
break;
error.alert = Alert.component({
type: 'error',
children,
controls: isDebug && [
Button.component({
className: 'Button Button--link',
onclick: this.showDebug.bind(this, error),
children: 'DEBUG', // TODO make translatable
})
]
});
case 404:
case 410:
children = this.translator.trans('core.lib.error.not_found_message');
break;
try {
options.errorHandler(error);
} catch (error) {
console.error(error);
// this.alerts.show(error.alert);
}
case 429:
children = this.translator.trans('core.lib.error.rate_limit_exceeded_message');
break;
return Promise.reject(error);
});
default:
children = this.translator.trans('core.lib.error.generic_message');
}
const isDebug = app.forum.attribute('debug');
error.alert = Alert.component({
type: 'error',
children,
controls: isDebug && [
Button.component({
className: 'Button Button--link',
onclick: this.showDebug.bind(this, error),
children: 'DEBUG', // TODO make translatable
}),
],
});
try {
options.errorHandler(error);
} catch (error) {
console.error(error);
// this.alerts.show(error.alert);
}
return Promise.reject(error);
}
);
}
private showDebug(error: RequestError) {
// this.alerts.dismiss(this.requestError.alert);
this.modal.show(RequestErrorModal.component({error}));
this.modal.show(RequestErrorModal.component({ error }));
}
}

View File

@ -14,4 +14,4 @@ export default class Bus {
listener(event, args);
});
}
}
}

View File

@ -1,17 +1,17 @@
import Mithril from 'mithril';
export type ComponentProps = {
children?: Mithril.Children,
children?: Mithril.Children;
className?: string;
className?: string;
[key: string]: any;
}
[key: string]: any;
};
export default class Component<T extends ComponentProps = any> {
element: HTMLElement;
props = <T> {};
props = <T>{};
view(vnode) {
throw new Error('Component#view must be implemented by subclass');
@ -60,7 +60,7 @@ export default class Component<T extends ComponentProps = any> {
return selector ? $element.find(selector) : $element;
}
static component(props: ComponentProps|any = {}, children?: Mithril.Children) {
static component(props: ComponentProps | any = {}, children?: Mithril.Children) {
const componentProps: ComponentProps = Object.assign({}, props);
if (children) componentProps.children = children;

View File

@ -4,277 +4,289 @@
*
* @abstract
*/
import Store from "./Store";
import Store from './Store';
export default class Model {
/**
* The resource object from the API.
*/
data: any;
/**
* The resource object from the API.
*/
data: any;
/**
* The time at which the model's data was last updated. Watching the value
* of this property is a fast way to retain/cache a subtree if data hasn't
* changed.
*/
freshness: Date;
/**
* The time at which the model's data was last updated. Watching the value
* of this property is a fast way to retain/cache a subtree if data hasn't
* changed.
*/
freshness: Date;
/**
* Whether or not the resource exists on the server.
*/
exists: boolean;
/**
* Whether or not the resource exists on the server.
*/
exists: boolean;
/**
* The data store that this resource should be persisted to.
*/
protected store: Store;
/**
* The data store that this resource should be persisted to.
*/
protected store: Store;
/**
* @param {Object} data A resource object from the API.
* @param {Store} store The data store that this model should be persisted to.
*/
constructor(data = {}, store = null) {
this.data = data;
this.store = store;
/**
* @param {Object} data A resource object from the API.
* @param {Store} store The data store that this model should be persisted to.
*/
constructor(data = {}, store = null) {
this.data = data;
this.store = store;
this.freshness = new Date();
this.exists = false;
}
/**
* Get the model's ID.
* @final
*/
id(): string|number {
return this.data.id;
}
/**
* Get one of the model's attributes.
* @final
*/
attribute(attribute: string): any {
return this.data.attributes[attribute];
}
/**
* Merge new data into this model locally.
*
* @param data A resource object to merge into this model
* @public
*/
pushData(data: {}) {
// Since most of the top-level items in a resource object are objects
// (e.g. relationships, attributes), we'll need to check and perform the
// merge at the second level if that's the case.
for (const key in data) {
if (typeof data[key] === 'object') {
this.data[key] = this.data[key] || {};
// For every item in a second-level object, we want to check if we've
// been handed a Model instance. If so, we will convert it to a
// relationship data object.
for (const innerKey in data[key]) {
if (data[key][innerKey] instanceof Model) {
data[key][innerKey] = {data: Model.getIdentifier(data[key][innerKey])};
}
this.data[key][innerKey] = data[key][innerKey];
}
} else {
this.data[key] = data[key];
}
this.freshness = new Date();
this.exists = false;
}
// Now that we've updated the data, we can say that the model is fresh.
// This is an easy way to invalidate retained subtrees etc.
this.freshness = new Date();
}
/**
* Get the model's ID.
* @final
*/
id(): string | number {
return this.data.id;
}
/**
* Merge new attributes into this model locally.
*
* @param {Object} attributes The attributes to merge.
*/
pushAttributes(attributes: any) {
this.pushData({attributes});
}
/**
* Get one of the model's attributes.
* @final
*/
attribute(attribute: string): any {
return this.data.attributes[attribute];
}
/**
* Merge new attributes into this model, both locally and with persistence.
*
* @param attributes The attributes to save. If a 'relationships' key
* exists, it will be extracted and relationships will also be saved.
* @param [options]
* @return {Promise}
*/
save(attributes: any, options: any = {}): Promise<Model|Model[]> {
const data = {
type: this.data.type,
id: this.data.id,
attributes,
relationships: undefined
};
/**
* Merge new data into this model locally.
*
* @param data A resource object to merge into this model
* @public
*/
pushData(data: {}) {
// Since most of the top-level items in a resource object are objects
// (e.g. relationships, attributes), we'll need to check and perform the
// merge at the second level if that's the case.
for (const key in data) {
if (typeof data[key] === 'object') {
this.data[key] = this.data[key] || {};
// If a 'relationships' key exists, extract it from the attributes hash and
// set it on the top-level data object instead. We will be sending this data
// object to the API for persistence.
if (attributes.relationships) {
data.relationships = {};
// For every item in a second-level object, we want to check if we've
// been handed a Model instance. If so, we will convert it to a
// relationship data object.
for (const innerKey in data[key]) {
if (data[key][innerKey] instanceof Model) {
data[key][innerKey] = { data: Model.getIdentifier(data[key][innerKey]) };
}
this.data[key][innerKey] = data[key][innerKey];
}
} else {
this.data[key] = data[key];
}
}
for (const key in attributes.relationships) {
const model = attributes.relationships[key];
// Now that we've updated the data, we can say that the model is fresh.
// This is an easy way to invalidate retained subtrees etc.
this.freshness = new Date();
}
data.relationships[key] = {
data: model instanceof Array
? model.map(Model.getIdentifier)
: Model.getIdentifier(model)
/**
* Merge new attributes into this model locally.
*
* @param {Object} attributes The attributes to merge.
*/
pushAttributes(attributes: any) {
this.pushData({ attributes });
}
/**
* Merge new attributes into this model, both locally and with persistence.
*
* @param attributes The attributes to save. If a 'relationships' key
* exists, it will be extracted and relationships will also be saved.
* @param [options]
* @return {Promise}
*/
save(attributes: any, options: any = {}): Promise<Model | Model[]> {
const data = {
type: this.data.type,
id: this.data.id,
attributes,
relationships: undefined,
};
}
delete attributes.relationships;
// If a 'relationships' key exists, extract it from the attributes hash and
// set it on the top-level data object instead. We will be sending this data
// object to the API for persistence.
if (attributes.relationships) {
data.relationships = {};
for (const key in attributes.relationships) {
const model = attributes.relationships[key];
data.relationships[key] = {
data: model instanceof Array ? model.map(Model.getIdentifier) : Model.getIdentifier(model),
};
}
delete attributes.relationships;
}
// Before we update the model's data, we should make a copy of the model's
// old data so that we can revert back to it if something goes awry during
// persistence.
const oldData = this.copyData();
this.pushData(data);
const request = { data };
if (options.meta) request.meta = options.meta;
return app
.request(
Object.assign(
{
method: this.exists ? 'PATCH' : 'POST',
url: app.forum.attribute('apiUrl') + this.apiEndpoint(),
body: request,
},
options
)
)
.then(
// If everything went well, we'll make sure the store knows that this
// model exists now (if it didn't already), and we'll push the data that
// the API returned into the store.
payload => {
this.store.data[payload.data.type] = this.store.data[payload.data.type] || {};
this.store.data[payload.data.type][payload.data.id] = this;
return this.store.pushPayload(payload);
},
// If something went wrong, though... good thing we backed up our model's
// old data! We'll revert to that and let others handle the error.
response => {
this.pushData(oldData);
m.redraw();
throw response;
}
);
}
// Before we update the model's data, we should make a copy of the model's
// old data so that we can revert back to it if something goes awry during
// persistence.
const oldData = this.copyData();
/**
* Send a request to delete the resource.
*
* @param {Object} body Data to send along with the DELETE request.
* @param {Object} [options]
* @return {Promise}
* @public
*/
delete(body = {}, options = {}) {
if (!this.exists) return Promise.resolve();
this.pushData(data);
return app
.request(
Object.assign(
{
method: 'DELETE',
url: app.forum.attribute('apiUrl') + this.apiEndpoint(),
body,
},
options
)
)
.then(() => {
this.exists = false;
this.store.remove(this);
});
}
const request = {data};
if (options.meta) request.meta = options.meta;
/**
* Construct a path to the API endpoint for this resource.
*
* @return {String}
* @protected
*/
apiEndpoint() {
return '/' + this.data.type + (this.exists ? '/' + this.data.id : '');
}
return app.request(Object.assign({
method: this.exists ? 'PATCH' : 'POST',
url: app.forum.attribute('apiUrl') + this.apiEndpoint(),
body: request
}, options)).then(
// If everything went well, we'll make sure the store knows that this
// model exists now (if it didn't already), and we'll push the data that
// the API returned into the store.
payload => {
this.store.data[payload.data.type] = this.store.data[payload.data.type] || {};
this.store.data[payload.data.type][payload.data.id] = this;
return this.store.pushPayload(payload);
},
copyData() {
return JSON.parse(JSON.stringify(this.data));
}
// If something went wrong, though... good thing we backed up our model's
// old data! We'll revert to that and let others handle the error.
response => {
this.pushData(oldData);
m.redraw();
throw response;
}
);
}
/**
* Generate a function which returns the value of the given attribute.
*
* @param {String} name
* @param [transform] A function to transform the attribute value
*/
static attribute(name: string, transform?: Function): () => any {
return function() {
const value = this.data.attributes && this.data.attributes[name];
/**
* Send a request to delete the resource.
*
* @param {Object} body Data to send along with the DELETE request.
* @param {Object} [options]
* @return {Promise}
* @public
*/
delete(body = {}, options = {}) {
if (!this.exists) return Promise.resolve();
return transform ? transform(value) : value;
};
}
return app.request(Object.assign({
method: 'DELETE',
url: app.forum.attribute('apiUrl') + this.apiEndpoint(),
body
}, options)).then(() => {
this.exists = false;
this.store.remove(this);
});
}
/**
* Generate a function which returns the value of the given has-one
* relationship.
*
* @return false if no information about the
* relationship exists; undefined if the relationship exists but the model
* has not been loaded; or the model if it has been loaded.
*/
static hasOne(name: string): () => Model | boolean {
return function() {
if (this.data.relationships) {
const relationship = this.data.relationships[name];
/**
* Construct a path to the API endpoint for this resource.
*
* @return {String}
* @protected
*/
apiEndpoint() {
return '/' + this.data.type + (this.exists ? '/' + this.data.id : '');
}
if (relationship) {
return app.store.getById(relationship.data.type, relationship.data.id);
}
}
copyData() {
return JSON.parse(JSON.stringify(this.data));
}
return false;
};
}
/**
* Generate a function which returns the value of the given attribute.
*
* @param {String} name
* @param [transform] A function to transform the attribute value
*/
static attribute(name: string, transform?: Function): () => any {
return function() {
const value = this.data.attributes && this.data.attributes[name];
/**
* Generate a function which returns the value of the given has-many
* relationship.
*
* @return false if no information about the relationship
* exists; an array if it does, containing models if they have been
* loaded, and undefined for those that have not.
*/
static hasMany(name: string): () => [] | boolean {
return function() {
if (this.data.relationships) {
const relationship = this.data.relationships[name];
return transform ? transform(value) : value;
};
}
if (relationship) {
return relationship.data.map(data => app.store.getById(data.type, data.id));
}
}
/**
* Generate a function which returns the value of the given has-one
* relationship.
*
* @return false if no information about the
* relationship exists; undefined if the relationship exists but the model
* has not been loaded; or the model if it has been loaded.
*/
static hasOne(name: string): () => Model|boolean {
return function() {
if (this.data.relationships) {
const relationship = this.data.relationships[name];
return false;
};
}
if (relationship) {
return app.store.getById(relationship.data.type, relationship.data.id);
}
}
/**
* Transform the given value into a Date object.
*/
static transformDate(value: string): Date {
return value ? new Date(value) : null;
}
return false;
};
}
/**
* Generate a function which returns the value of the given has-many
* relationship.
*
* @return false if no information about the relationship
* exists; an array if it does, containing models if they have been
* loaded, and undefined for those that have not.
*/
static hasMany(name: string): () => []|boolean {
return function() {
if (this.data.relationships) {
const relationship = this.data.relationships[name];
if (relationship) {
return relationship.data.map(data => app.store.getById(data.type, data.id));
}
}
return false;
};
}
/**
* Transform the given value into a Date object.
*/
static transformDate(value: string): Date {
return value ? new Date(value) : null;
}
/**
* Get a resource identifier object for the given model.
*/
protected static getIdentifier(model: Model): { type: string, id: string } {
return {
type: model.data.type,
id: model.data.id
};
}
/**
* Get a resource identifier object for the given model.
*/
protected static getIdentifier(model: Model): { type: string; id: string } {
return {
type: model.data.type,
id: model.data.id,
};
}
}

View File

@ -5,39 +5,44 @@ import User from './models/User';
* to the current authenticated user, and provides methods to log in/out.
*/
export default class Session {
/**
* The current authenticated user.
*/
user?: User;
/**
* The current authenticated user.
*/
user?: User;
/**
* The CSRF token.
*/
csrfToken?: string;
/**
* The CSRF token.
*/
csrfToken?: string;
constructor(user, csrfToken) {
this.user = user;
constructor(user, csrfToken) {
this.user = user;
this.csrfToken = csrfToken;
}
this.csrfToken = csrfToken;
}
/**
* Attempt to log in a user.
*/
login(body: { identification: string, password: string, remember?: string }, options = {}) {
return app.request(Object.assign({
method: 'POST',
url: `${app.forum.attribute('baseUrl')}/login`,
body
}, options));
}
/**
* Attempt to log in a user.
*/
login(body: { identification: string; password: string; remember?: string }, options = {}) {
return app.request(
Object.assign(
{
method: 'POST',
url: `${app.forum.attribute('baseUrl')}/login`,
body,
},
options
)
);
}
/**
* Log the user out.
*
* @public
*/
logout() {
window.location.href = `${app.forum.attribute('baseUrl')}/logout?token=${this.csrfToken}`;
}
/**
* Log the user out.
*
* @public
*/
logout() {
window.location.href = `${app.forum.attribute('baseUrl')}/logout?token=${this.csrfToken}`;
}
}

View File

@ -5,143 +5,148 @@ import Model from './Model';
* retrieve data from the API.
*/
export default class Store {
/**
* The local data store. A tree of resource types to IDs, such that
* accessing data[type][id] will return the model for that type/ID.
*/
data: { [key: string]: { [key: number]: Model }} = {};
/**
* The local data store. A tree of resource types to IDs, such that
* accessing data[type][id] will return the model for that type/ID.
*/
data: { [key: string]: { [key: number]: Model } } = {};
/**
* The model registry. A map of resource types to the model class that
* should be used to represent resources of that type.
*/
models: {};
/**
* The model registry. A map of resource types to the model class that
* should be used to represent resources of that type.
*/
models: {};
constructor(models) {
this.models = models;
}
/**
* Push resources contained within an API payload into the store.
*
* @param payload
* @return The model(s) representing the resource(s) contained
* within the 'data' key of the payload.
*/
pushPayload(payload: { included?: {}[], data?: {}|{}[] }): Model|Model[] {
if (payload.included) payload.included.map(this.pushObject.bind(this));
const result: any = payload.data instanceof Array
? payload.data.map(this.pushObject.bind(this))
: this.pushObject(payload.data);
// Attach the original payload to the model that we give back. This is
// useful to consumers as it allows them to access meta information
// associated with their request.
result.payload = payload;
return result;
}
/**
* Create a model to represent a resource object (or update an existing one),
* and push it into the store.
*
* @param {Object} data The resource object
* @return The model, or null if no model class has been
* registered for this resource type.
*/
pushObject(data): Model {
if (!this.models[data.type]) return null;
const type = this.data[data.type] = this.data[data.type] || {};
if (type[data.id]) {
type[data.id].pushData(data);
} else {
type[data.id] = this.createRecord(data.type, data);
constructor(models) {
this.models = models;
}
type[data.id].exists = true;
/**
* Push resources contained within an API payload into the store.
*
* @param payload
* @return The model(s) representing the resource(s) contained
* within the 'data' key of the payload.
*/
pushPayload(payload: { included?: {}[]; data?: {} | {}[] }): Model | Model[] {
if (payload.included) payload.included.map(this.pushObject.bind(this));
return type[data.id];
}
const result: any = payload.data instanceof Array ? payload.data.map(this.pushObject.bind(this)) : this.pushObject(payload.data);
/**
* Make a request to the API to find record(s) of a specific type.
*
* @param type The resource type.
* @param [id] The ID(s) of the model(s) to retrieve.
* Alternatively, if an object is passed, it will be handled as the
* `query` parameter.
* @param query
* @param options
*/
find<T extends Model = Model>(type: string, id?: number|number[]|any, query = {}, options = {}): Promise<T[]> {
let params = query;
let url = `${app.forum.attribute('apiUrl')}/${type}`;
// Attach the original payload to the model that we give back. This is
// useful to consumers as it allows them to access meta information
// associated with their request.
result.payload = payload;
if (id instanceof Array) {
url += `?filter[id]=${id.join(',')}`;
} else if (typeof id === 'object') {
params = id;
} else if (id) {
url += `/${id}`;
return result;
}
return app.request(Object.assign({
method: 'GET',
url,
params
}, options)).then(this.pushPayload.bind(this));
}
/**
* Create a model to represent a resource object (or update an existing one),
* and push it into the store.
*
* @param {Object} data The resource object
* @return The model, or null if no model class has been
* registered for this resource type.
*/
pushObject(data): Model {
if (!this.models[data.type]) return null;
/**
* Get a record from the store by ID.
*
* @param type The resource type.
* @param id The resource ID.
*/
getById<T extends Model = Model>(type: string, id: number): T {
return this.data[type] && this.data[type][id] as T;
}
const type = (this.data[data.type] = this.data[data.type] || {});
/**
* Get a record from the store by the value of a model attribute.
*
* @param type The resource type.
* @param key The name of the method on the model.
* @param value The value of the model attribute.
*/
getBy<T extends Model = Model>(type: string, key: string, value: any): T {
return this.all<T>(type).filter(model => model[key]() === value)[0];
}
if (type[data.id]) {
type[data.id].pushData(data);
} else {
type[data.id] = this.createRecord(data.type, data);
}
/**
* Get all loaded records of a specific type.
*/
all<T extends Model = Model>(type: string): T[] {
const records = this.data[type];
type[data.id].exists = true;
return records ? Object.keys(records).map(id => records[id]) : [];
}
return type[data.id];
}
/**
* Remove the given model from the store.
*/
remove(model: Model) {
delete this.data[model.data.type][model.id()];
}
/**
* Make a request to the API to find record(s) of a specific type.
*
* @param type The resource type.
* @param [id] The ID(s) of the model(s) to retrieve.
* Alternatively, if an object is passed, it will be handled as the
* `query` parameter.
* @param query
* @param options
*/
find<T extends Model = Model>(type: string, id?: number | number[] | any, query = {}, options = {}): Promise<T[]> {
let params = query;
let url = `${app.forum.attribute('apiUrl')}/${type}`;
/**
* Create a new record of the given type.
*
* @param {String} type The resource type
* @param {Object} [data] Any data to initialize the model with
*/
createRecord<T extends Model = Model>(type: string, data: any = {}): T {
data.type = data.type || type;
if (id instanceof Array) {
url += `?filter[id]=${id.join(',')}`;
} else if (typeof id === 'object') {
params = id;
} else if (id) {
url += `/${id}`;
}
return new (this.models[type])(data, this);
}
return app
.request(
Object.assign(
{
method: 'GET',
url,
params,
},
options
)
)
.then(this.pushPayload.bind(this));
}
/**
* Get a record from the store by ID.
*
* @param type The resource type.
* @param id The resource ID.
*/
getById<T extends Model = Model>(type: string, id: number): T {
return this.data[type] && (this.data[type][id] as T);
}
/**
* Get a record from the store by the value of a model attribute.
*
* @param type The resource type.
* @param key The name of the method on the model.
* @param value The value of the model attribute.
*/
getBy<T extends Model = Model>(type: string, key: string, value: any): T {
return this.all<T>(type).filter(model => model[key]() === value)[0];
}
/**
* Get all loaded records of a specific type.
*/
all<T extends Model = Model>(type: string): T[] {
const records = this.data[type];
return records ? Object.keys(records).map(id => records[id]) : [];
}
/**
* Remove the given model from the store.
*/
remove(model: Model) {
delete this.data[model.data.type][model.id()];
}
/**
* Create a new record of the given type.
*
* @param {String} type The resource type
* @param {Object} [data] Any data to initialize the model with
*/
createRecord<T extends Model = Model>(type: string, data: any = {}): T {
data.type = data.type || type;
return new this.models[type](data, this);
}
}

View File

@ -35,7 +35,7 @@ export default class Translator {
}
transText(id: string, parameters = null) {
return extractText(this.trans(id, parameters));
return extractText(this.trans(id, parameters));
}
apply(translation: string, input: any) {

View File

@ -4,6 +4,6 @@ import Modal from './components/Modal';
export default {
extend: extend,
'components/Modal': Modal
'components/Modal': Modal,
};

View File

@ -1,15 +1,15 @@
import Component, {ComponentProps} from '../Component';
import Component, { ComponentProps } from '../Component';
import Button from './Button';
import listItems from '../helpers/listItems';
import extract from '../utils/extract';
import * as Mithril from "mithril";
import * as Mithril from 'mithril';
export interface AlertProps extends ComponentProps {
controls?: Mithril.ChildArray,
type?: string,
dismissible?: boolean,
controls?: Mithril.ChildArray;
type?: string;
dismissible?: boolean;
ondismiss?: () => any,
ondismiss?: () => any;
}
/**
@ -27,40 +27,31 @@ export interface AlertProps extends ComponentProps {
* All other props will be assigned as attributes on the alert element.
*/
export default class Alert extends Component<AlertProps> {
view() {
const attrs: AlertProps = Object.assign({}, this.props);
view() {
const attrs: AlertProps = Object.assign({}, this.props);
const type: string = extract(attrs, 'type');
attrs.className = `Alert Alert--${type} ${attrs.className || ''}`;
const type: string = extract(attrs, 'type');
attrs.className = `Alert Alert--${type} ${attrs.className || ''}`;
const children: Mithril.Children = extract(attrs, 'children');
const controls: Mithril.ChildArray = extract(attrs, 'controls') || [];
const children: Mithril.Children = extract(attrs, 'children');
const controls: Mithril.ChildArray = extract(attrs, 'controls') || [];
// If the alert is meant to be dismissible (which is the case by default),
// then we will create a dismiss button to append as the final control in
// the alert.
const dismissible: boolean|undefined = extract(attrs, 'dismissible');
const ondismiss: () => any = extract(attrs, 'ondismiss');
const dismissControl = [];
// If the alert is meant to be dismissible (which is the case by default),
// then we will create a dismiss button to append as the final control in
// the alert.
const dismissible: boolean | undefined = extract(attrs, 'dismissible');
const ondismiss: () => any = extract(attrs, 'ondismiss');
const dismissControl = [];
if (dismissible || dismissible === undefined) {
dismissControl.push(
<Button
icon="fas fa-times"
className="Button Button--link Button--icon Alert-dismiss"
onclick={ondismiss}/>
);
if (dismissible || dismissible === undefined) {
dismissControl.push(<Button icon="fas fa-times" className="Button Button--link Button--icon Alert-dismiss" onclick={ondismiss} />);
}
return (
<div {...attrs}>
<span className="Alert-body">{children}</span>
<ul className="Alert-controls">{listItems(controls.concat(dismissControl))}</ul>
</div>
);
}
return (
<div {...attrs}>
<span className="Alert-body">
{children}
</span>
<ul className="Alert-controls">
{listItems(controls.concat(dismissControl))}
</ul>
</div>
);
}
}

View File

@ -16,24 +16,20 @@ import extract from '../utils/extract';
* All other props will be assigned as attributes on the badge element.
*/
export default class Badge extends Component {
view(vnode) {
const attrs = vnode.attrs;
const type = extract(attrs, 'type');
const iconName = extract(attrs, 'icon');
view(vnode) {
const attrs = vnode.attrs;
const type = extract(attrs, 'type');
const iconName = extract(attrs, 'icon');
attrs.className = `Badge ${type ? `Badge--${type}` : ''} ${attrs.className || ''}`;
attrs.title = extract(attrs, 'label') || '';
attrs.className = `Badge ${type ? `Badge--${type}` : ''} ${attrs.className || ''}`;
attrs.title = extract(attrs, 'label') || '';
return (
<span {...attrs}>
{iconName ? icon(iconName, {className: 'Badge-icon'}) : m.trust('&nbsp;')}
</span>
);
}
return <span {...attrs}>{iconName ? icon(iconName, { className: 'Badge-icon' }) : m.trust('&nbsp;')}</span>;
}
oncreate(vnode) {
super.oncreate(vnode);
oncreate(vnode) {
super.oncreate(vnode);
if (this.props.label) this.$().tooltip({container: 'body'});
}
if (this.props.label) this.$().tooltip({ container: 'body' });
}
}

View File

@ -1,17 +1,17 @@
import Component, {ComponentProps} from '../Component';
import Component, { ComponentProps } from '../Component';
import icon from '../helpers/icon';
import extract from '../utils/extract';
import extractText from '../utils/extractText';
import LoadingIndicator from './LoadingIndicator';
export interface ButtonProps extends ComponentProps {
title?: string,
type?: string,
icon?: string,
title?: string;
type?: string;
icon?: string;
loading?: boolean,
disabled?: boolean,
onclick?: Function
loading?: boolean;
disabled?: boolean;
onclick?: Function;
}
/**
@ -31,43 +31,43 @@ export interface ButtonProps extends ComponentProps {
* be used to represent any generic clickable control, like a menu item.
*/
export default class Button<T extends ButtonProps = ButtonProps> extends Component<T> {
view() {
const { children, ...attrs} = this.props;
view() {
const { children, ...attrs } = this.props;
attrs.className = attrs.className || '';
attrs.type = attrs.type || 'button';
attrs.className = attrs.className || '';
attrs.type = attrs.type || 'button';
// If a tooltip was provided for buttons without additional content, we also
// use this tooltip as text for screen readers
if (attrs.title && !this.props.children) {
attrs['aria-label'] = attrs.title;
// If a tooltip was provided for buttons without additional content, we also
// use this tooltip as text for screen readers
if (attrs.title && !this.props.children) {
attrs['aria-label'] = attrs.title;
}
// If nothing else is provided, we use the textual button content as tooltip
if (!attrs.title && this.props.children) {
attrs.title = extractText(this.props.children);
}
const iconName = extract(attrs, 'icon');
if (iconName) attrs.className += ' hasIcon';
const loading = extract(attrs, 'loading');
if (attrs.disabled || loading) {
attrs.className += ' ' + classNames('disabled', loading && 'loading');
delete attrs.onclick;
}
return <button {...attrs}>{this.getButtonContent(iconName, attrs.loading, children)}</button>;
}
// If nothing else is provided, we use the textual button content as tooltip
if (!attrs.title && this.props.children) {
attrs.title = extractText(this.props.children);
/**
* Get the template for the button's content.
*/
protected getButtonContent(iconName?: string | boolean, loading?: boolean, children?: any): any[] {
return [
iconName && iconName !== true ? icon(iconName, { className: 'Button-icon' }) : '',
children ? <span className="Button-label">{children}</span> : '',
loading ? LoadingIndicator.component({ size: 'tiny', className: 'LoadingIndicator--inline' }) : '',
];
}
const iconName = extract(attrs, 'icon');
if (iconName) attrs.className += ' hasIcon';
const loading = extract(attrs, 'loading');
if (attrs.disabled || loading) {
attrs.className += ' ' + classNames('disabled', loading && 'loading');
delete attrs.onclick;
}
return <button {...attrs}>{this.getButtonContent(iconName, attrs.loading, children)}</button>;
}
/**
* Get the template for the button's content.
*/
protected getButtonContent(iconName?: string|boolean, loading?: boolean, children?: any) : any[] {
return [
iconName && iconName !== true ? icon(iconName, {className: 'Button-icon'}) : '',
children ? <span className="Button-label">{children}</span> : '',
loading ? LoadingIndicator.component({size: 'tiny', className: 'LoadingIndicator--inline'}) : ''
];
}
}

View File

@ -1,17 +1,17 @@
import Component, {ComponentProps} from '../Component';
import Component, { ComponentProps } from '../Component';
import icon from '../helpers/icon';
import listItems from '../helpers/listItems';
export interface DropdownProps extends ComponentProps {
buttonClassName?: string;
menuClassName?: string;
label?: string;
icon?: string;
caretIcon?: undefined|string;
buttonClassName?: string;
menuClassName?: string;
label?: string;
icon?: string;
caretIcon?: undefined | string;
onhide?: Function;
onshow?: Function;
onclick?: Function;
onhide?: Function;
onshow?: Function;
onclick?: Function;
}
/**
@ -31,109 +31,96 @@ export interface DropdownProps extends ComponentProps {
* The children will be displayed as a list inside of the dropdown menu.
*/
export default class Dropdown<T extends DropdownProps = DropdownProps> extends Component<T> {
showing: boolean;
showing: boolean;
static initProps(props: DropdownProps) {
props.className = props.className || '';
props.buttonClassName = props.buttonClassName || '';
props.menuClassName = props.menuClassName || '';
props.label = props.label || '';
props.caretIcon = typeof props.caretIcon !== 'undefined' ? props.caretIcon : 'fas fa-caret-down';
}
static initProps(props: DropdownProps) {
props.className = props.className || '';
props.buttonClassName = props.buttonClassName || '';
props.menuClassName = props.menuClassName || '';
props.label = props.label || '';
props.caretIcon = typeof props.caretIcon !== 'undefined' ? props.caretIcon : 'fas fa-caret-down';
}
view() {
const items = this.props.children ? listItems(this.props.children) : [];
view() {
const items = this.props.children ? listItems(this.props.children) : [];
return (
<div className={`ButtonGroup Dropdown dropdown ${this.props.className} itemCount${items.length}${this.showing ? ' open' : ''}`}>
{this.getButton()}
{this.getMenu(items)}
</div>
);
}
return (
<div className={`ButtonGroup Dropdown dropdown ${this.props.className} itemCount${items.length}${this.showing ? ' open' : ''}`}>
{this.getButton()}
{this.getMenu(items)}
</div>
);
}
oncreate(vnode) {
super.oncreate(vnode);
oncreate(vnode) {
super.oncreate(vnode);
this.$('> .Dropdown-toggle').dropdown();
this.$('> .Dropdown-toggle').dropdown();
// When opening the dropdown menu, work out if the menu goes beyond the
// bottom of the viewport. If it does, we will apply class to make it show
// above the toggle button instead of below it.
this.element.addEventListener('shown.bs.dropdown', () => {
this.showing = true;
// When opening the dropdown menu, work out if the menu goes beyond the
// bottom of the viewport. If it does, we will apply class to make it show
// above the toggle button instead of below it.
this.element.addEventListener('shown.bs.dropdown', () => {
this.showing = true;
if (this.props.onshow) {
this.props.onshow();
}
if (this.props.onshow) {
this.props.onshow();
}
m.redraw();
m.redraw();
const $menu = this.$('.Dropdown-menu');
const isRight = $menu.hasClass('Dropdown-menu--right');
const $menu = this.$('.Dropdown-menu');
const isRight = $menu.hasClass('Dropdown-menu--right');
$menu.removeClass('Dropdown-menu--top Dropdown-menu--right');
$menu.removeClass('Dropdown-menu--top Dropdown-menu--right');
$menu.toggleClass(
'Dropdown-menu--top',
$menu.offset().top + $menu.height() > $(window).scrollTop() + $(window).height()
);
$menu.toggleClass('Dropdown-menu--top', $menu.offset().top + $menu.height() > $(window).scrollTop() + $(window).height());
if ($menu.offset().top < 0) {
$menu.removeClass('Dropdown-menu--top');
}
if ($menu.offset().top < 0) {
$menu.removeClass('Dropdown-menu--top');
}
$menu.toggleClass(
'Dropdown-menu--right',
isRight || $menu.offset().left + $menu.width() > $(window).scrollLeft() + $(window).width()
);
});
$menu.toggleClass('Dropdown-menu--right', isRight || $menu.offset().left + $menu.width() > $(window).scrollLeft() + $(window).width());
});
this.element.addEventListener('hidden.bs.dropdown', () => {
this.showing = false;
this.element.addEventListener('hidden.bs.dropdown', () => {
this.showing = false;
if (this.props.onhide) {
this.props.onhide();
}
if (this.props.onhide) {
this.props.onhide();
}
m.redraw();
});
}
m.redraw();
});
}
/**
* Get the template for the button.
*/
protected getButton(): any {
return (
<button
className={'Dropdown-toggle ' + this.props.buttonClassName}
data-toggle="dropdown"
onclick={this.props.onclick}>
{this.getButtonContent()}
</button>
);
}
/**
* Get the template for the button.
*/
protected getButton(): any {
return (
<button className={'Dropdown-toggle ' + this.props.buttonClassName} data-toggle="dropdown" onclick={this.props.onclick}>
{this.getButtonContent()}
</button>
);
}
/**
* Get the template for the button's content.
*
* @return {*}
*/
protected getButtonContent() {
const attrs = this.props;
/**
* Get the template for the button's content.
*
* @return {*}
*/
protected getButtonContent() {
const attrs = this.props;
return [
attrs.icon ? icon(attrs.icon, {className: 'Button-icon'}) : '',
<span className="Button-label">{attrs.label}</span>,
attrs.caretIcon ? icon(attrs.caretIcon, {className: 'Button-caret'}) : ''
];
}
return [
attrs.icon ? icon(attrs.icon, { className: 'Button-icon' }) : '',
<span className="Button-label">{attrs.label}</span>,
attrs.caretIcon ? icon(attrs.caretIcon, { className: 'Button-caret' }) : '',
];
}
protected getMenu(items) {
return (
<ul className={'Dropdown-menu dropdown-menu ' + this.props.menuClassName}>
{items}
</ul>
);
}
protected getMenu(items) {
return <ul className={'Dropdown-menu dropdown-menu ' + this.props.menuClassName}>{items}</ul>;
}
}

View File

@ -1,16 +1,16 @@
import Badge from './Badge';
export default class GroupBadge extends Badge {
static initProps(props) {
super.initProps(props);
static initProps(props) {
super.initProps(props);
if (props.group) {
props.icon = props.group.icon();
props.style = {backgroundColor: props.group.color()};
props.label = typeof props.label === 'undefined' ? props.group.nameSingular() : props.label;
props.type = `group--${props.group.id()}`;
if (props.group) {
props.icon = props.group.icon();
props.style = { backgroundColor: props.group.color() };
props.label = typeof props.label === 'undefined' ? props.group.nameSingular() : props.label;
props.type = `group--${props.group.id()}`;
delete props.group;
delete props.group;
}
}
}
}

View File

@ -1,9 +1,9 @@
import Button, {ButtonProps} from './Button';
import Button, { ButtonProps } from './Button';
interface LinkButtonProps extends ButtonProps {
active: boolean;
oncreate: Function;
href?: string;
active: boolean;
oncreate: Function;
href?: string;
}
/**
@ -19,25 +19,23 @@ interface LinkButtonProps extends ButtonProps {
* the `active` prop will automatically be set to true.
*/
export default class LinkButton extends Button<LinkButtonProps> {
static initProps(props: LinkButtonProps) {
props.active = this.isActive(props);
}
static initProps(props: LinkButtonProps) {
props.active = this.isActive(props);
}
view() {
const vdom = super.view();
view() {
const vdom = super.view();
vdom.tag = m.route.Link;
vdom.attrs.active = String(vdom.attrs.active);
vdom.tag = m.route.Link;
vdom.attrs.active = String(vdom.attrs.active);
return vdom;
}
return vdom;
}
/**
* Determine whether a component with the given props is 'active'.
*/
static isActive(props: LinkButtonProps): boolean {
return typeof props.active !== 'undefined'
? props.active
: m.route.get() === props.href;
}
/**
* Determine whether a component with the given props is 'active'.
*/
static isActive(props: LinkButtonProps): boolean {
return typeof props.active !== 'undefined' ? props.active : m.route.get() === props.href;
}
}

View File

@ -1,5 +1,5 @@
import Component from '../Component';
import {Spinner, SpinnerOptions} from 'spin.js';
import { Spinner, SpinnerOptions } from 'spin.js';
/**
* The `LoadingIndicator` component displays a loading spinner with spin.js. It
@ -10,34 +10,34 @@ import {Spinner, SpinnerOptions} from 'spin.js';
* All other props will be assigned as attributes on the element.
*/
export default class LoadingIndicator extends Component {
view(vnode) {
const attrs = vnode.attrs;
view(vnode) {
const attrs = vnode.attrs;
attrs.className = 'LoadingIndicator ' + (attrs.className || '');
delete attrs.size;
attrs.className = 'LoadingIndicator ' + (attrs.className || '');
delete attrs.size;
return <div {...attrs}>{m.trust('&nbsp;')}</div>;
}
oncreate(vnode) {
super.oncreate(vnode);
const options: SpinnerOptions = { zIndex: 'auto', color: this.$().css('color') };
let sizeOptions: SpinnerOptions = {};
switch (vnode.attrs.size) {
case 'large':
sizeOptions = { lines: 10, length: 8, width: 4, radius: 8 };
break;
case 'tiny':
sizeOptions = { lines: 8, length: 2, width: 2, radius: 3 };
break;
default:
sizeOptions = { lines: 8, length: 4, width: 3, radius: 5 };
return <div {...attrs}>{m.trust('&nbsp;')}</div>;
}
new Spinner({ ...options, ...sizeOptions }).spin(this.element);
}
oncreate(vnode) {
super.oncreate(vnode);
const options: SpinnerOptions = { zIndex: 'auto', color: this.$().css('color') };
let sizeOptions: SpinnerOptions = {};
switch (vnode.attrs.size) {
case 'large':
sizeOptions = { lines: 10, length: 8, width: 4, radius: 8 };
break;
case 'tiny':
sizeOptions = { lines: 8, length: 2, width: 2, radius: 3 };
break;
default:
sizeOptions = { lines: 8, length: 4, width: 3, radius: 5 };
}
new Spinner({ ...options, ...sizeOptions }).spin(this.element);
}
}

View File

@ -1,8 +1,8 @@
import Mithril from 'mithril';
import Component, {ComponentProps} from '../Component';
import Component, { ComponentProps } from '../Component';
import Button from './Button';
import RequestError from "../utils/RequestError";
import RequestError from '../utils/RequestError';
/**
* The `Modal` component displays a modal dialog, wrapped in a form. Subclasses
@ -29,10 +29,12 @@ export default abstract class Modal<T extends ComponentProps = ComponentProps> e
{Button.component({
icon: 'fas fa-times',
onclick: this.hide.bind(this),
className: 'Button Button--icon Button--link'
className: 'Button Button--icon Button--link',
})}
</div>
) : ''}
) : (
''
)}
<form onsubmit={this.onsubmit.bind(this)}>
<div className="Modal-header">
@ -85,7 +87,11 @@ export default abstract class Modal<T extends ComponentProps = ComponentProps> e
* Focus on the first input when the modal is ready to be used.
*/
onready() {
this.$('form').find('input, select, textarea').first().focus().select();
this.$('form')
.find('input, select, textarea')
.first()
.focus()
.select();
}
onhide() {}

View File

@ -2,7 +2,7 @@ import MicroModal from 'micromodal';
import Component from '../Component';
import Modal from './Modal';
import {Vnode} from "mithril";
import { Vnode } from 'mithril';
/**
* The `ModalManager` component manages a modal dialog. Only one modal dialog
@ -48,19 +48,20 @@ export default class ModalManager extends Component {
m.redraw();
if (!$('.modal-backdrop').length) {
$('<div />').addClass('modal-backdrop')
$('<div />')
.addClass('modal-backdrop')
.appendTo('body');
}
MicroModal.show('Modal', {
awaitCloseAnimation: true,
onClose: () => {
$('.modal-backdrop').fadeOut(200, function () {
$('.modal-backdrop').fadeOut(200, function() {
this.remove();
});
this.showing = false;
}
},
});
this.onready();

View File

@ -1,7 +1,7 @@
import Component, {ComponentProps} from '../Component';
import Component, { ComponentProps } from '../Component';
export interface PlaceholderProps extends ComponentProps {
text: string
text: string;
}
/**

View File

@ -1,9 +1,9 @@
import Modal from './Modal';
import {ComponentProps} from '../Component';
import { ComponentProps } from '../Component';
import RequestError from '../utils/RequestError';
export interface RequestErrorModalProps extends ComponentProps {
error: RequestError,
error: RequestError;
}
export default class RequestErrorModal<T extends RequestErrorModalProps = RequestErrorModalProps> extends Modal<T> {
@ -12,9 +12,7 @@ export default class RequestErrorModal<T extends RequestErrorModalProps = Reques
}
title(): string {
return this.props.error.xhr
? `${this.props.error.xhr.status} ${this.props.error.xhr.statusText}`
: '';
return this.props.error.xhr ? `${this.props.error.xhr.status} ${this.props.error.xhr.statusText}` : '';
}
content() {
@ -26,11 +24,15 @@ export default class RequestErrorModal<T extends RequestErrorModalProps = Reques
responseText = this.props.error.responseText;
}
return <div className="Modal-body">
<pre>
{this.props.error.options.method} {this.props.error.options.url}<br/><br/>
{responseText}
</pre>
</div>
return (
<div className="Modal-body">
<pre>
{this.props.error.options.method} {this.props.error.options.url}
<br />
<br />
{responseText}
</pre>
</div>
);
}
}

View File

@ -1,8 +1,8 @@
import Dropdown, {DropdownProps} from './Dropdown';
import Dropdown, { DropdownProps } from './Dropdown';
import icon from '../helpers/icon';
export interface SelectDropdownProps extends DropdownProps {
defaultLabel?: string;
defaultLabel?: string;
}
/**
@ -16,23 +16,20 @@ export interface SelectDropdownProps extends DropdownProps {
* - `defaultLabel`
*/
export default class SelectDropdown extends Dropdown<SelectDropdownProps> {
static initProps(props: SelectDropdownProps) {
props.caretIcon = typeof props.caretIcon !== 'undefined' ? props.caretIcon : 'fas fa-sort';
static initProps(props: SelectDropdownProps) {
props.caretIcon = typeof props.caretIcon !== 'undefined' ? props.caretIcon : 'fas fa-sort';
super.initProps(props);
super.initProps(props);
props.className += ' Dropdown--select';
}
props.className += ' Dropdown--select';
}
getButtonContent() {
const activeChild = this.props.children.filter(child => child.attrs.active)[0];
let label = activeChild && activeChild.attrs.children || this.props.defaultLabel;
getButtonContent() {
const activeChild = this.props.children.filter(child => child.attrs.active)[0];
let label = (activeChild && activeChild.attrs.children) || this.props.defaultLabel;
if (label instanceof Array) label = label[0];
if (label instanceof Array) label = label[0];
return [
<span className="Button-label">{label}</span>,
icon(this.props.caretIcon, {className: 'Button-caret'})
];
}
return [<span className="Button-label">{label}</span>, icon(this.props.caretIcon, { className: 'Button-caret' })];
}
}

View File

@ -4,9 +4,9 @@ import Component from '../Component';
* The `Separator` component defines a menu separator item.
*/
export default class Separator extends Component {
static isListItem = true;
static isListItem = true;
view() {
return <li className="Dropdown-separator"/>;
}
view() {
return <li className="Dropdown-separator" />;
}
}

View File

@ -6,31 +6,31 @@
* @return {Object}
*/
export default function avatar(user, attrs: any = {}) {
attrs.className = 'Avatar ' + (attrs.className || '');
let content = '';
attrs.className = 'Avatar ' + (attrs.className || '');
let content = '';
// If the `title` attribute is set to null or false, we don't want to give the
// avatar a title. On the other hand, if it hasn't been given at all, we can
// safely default it to the user's username.
const hasTitle = attrs.title === 'undefined' || attrs.title;
if (!hasTitle) delete attrs.title;
// If the `title` attribute is set to null or false, we don't want to give the
// avatar a title. On the other hand, if it hasn't been given at all, we can
// safely default it to the user's username.
const hasTitle = attrs.title === 'undefined' || attrs.title;
if (!hasTitle) delete attrs.title;
// If a user has been passed, then we will set up an avatar using their
// uploaded image, or the first letter of their username if they haven't
// uploaded one.
if (user) {
const username = user.displayName() || '?';
const avatarUrl = user.avatarUrl();
// If a user has been passed, then we will set up an avatar using their
// uploaded image, or the first letter of their username if they haven't
// uploaded one.
if (user) {
const username = user.displayName() || '?';
const avatarUrl = user.avatarUrl();
if (hasTitle) attrs.title = attrs.title || username;
if (hasTitle) attrs.title = attrs.title || username;
if (avatarUrl) {
return <img {...attrs} src={avatarUrl}/>;
if (avatarUrl) {
return <img {...attrs} src={avatarUrl} />;
}
content = username.charAt(0).toUpperCase();
attrs.style = { background: user.color() };
}
content = username.charAt(0).toUpperCase();
attrs.style = {background: user.color()};
}
return <span {...attrs}>{content}</span>;
return <span {...attrs}>{content}</span>;
}

View File

@ -9,28 +9,30 @@ import { truncate } from '../utils/string';
* @param {Integer} [length] The number of characters to truncate the string to.
* The string will be truncated surrounding the first match.
*/
export default function highlight(string: string, phrase: string|RegExp, length?: number): any {
if (!phrase && !length) return string;
export default function highlight(string: string, phrase: string | RegExp, length?: number): any {
if (!phrase && !length) return string;
// Convert the word phrase into a global regular expression (if it isn't
// already) so we can search the string for matched.
const regexp = phrase instanceof RegExp ? phrase : new RegExp(phrase, 'gi');
// Convert the word phrase into a global regular expression (if it isn't
// already) so we can search the string for matched.
const regexp = phrase instanceof RegExp ? phrase : new RegExp(phrase, 'gi');
let highlighted = string;
let start = 0;
let highlighted = string;
let start = 0;
// If a length was given, the truncate the string surrounding the first match.
if (length) {
if (phrase) start = Math.max(0, string.search(regexp) - length / 2);
// If a length was given, the truncate the string surrounding the first match.
if (length) {
if (phrase) start = Math.max(0, string.search(regexp) - length / 2);
highlighted = truncate(highlighted, length, start);
}
highlighted = truncate(highlighted, length, start);
}
// Convert the string into HTML entities, then highlight all matches with
// <mark> tags. Then we will return the result as a trusted HTML string.
highlighted = $('<div/>').text(highlighted).html();
// Convert the string into HTML entities, then highlight all matches with
// <mark> tags. Then we will return the result as a trusted HTML string.
highlighted = $('<div/>')
.text(highlighted)
.html();
if (phrase) highlighted = highlighted.replace(regexp, '<mark>$&</mark>');
if (phrase) highlighted = highlighted.replace(regexp, '<mark>$&</mark>');
return m.trust(highlighted);
return m.trust(highlighted);
}

View File

@ -5,7 +5,7 @@
* @param {Object} attrs Any other attributes to apply.
*/
export default function icon(fontClass: string, attrs: any = {}) {
attrs.className = 'icon ' + fontClass + ' ' + (attrs.className || '');
attrs.className = 'icon ' + fontClass + ' ' + (attrs.className || '');
return <i {...attrs}/>;
return <i {...attrs} />;
}

View File

@ -1,21 +1,21 @@
import Separator from '../components/Separator';
export function isSeparator(item) {
return item?.tag === Separator;
return item?.tag === Separator;
}
export function withoutUnnecessarySeparators(items) {
const newItems = [];
let prevItem;
const newItems = [];
let prevItem;
items.forEach((item, i) => {
if (!isSeparator(item) || (prevItem && !isSeparator(prevItem) && i !== items.length - 1)) {
prevItem = item;
newItems.push(item);
}
});
items.forEach((item, i) => {
if (!isSeparator(item) || (prevItem && !isSeparator(prevItem) && i !== items.length - 1)) {
prevItem = item;
newItems.push(item);
}
});
return newItems;
return newItems;
}
/**
@ -26,32 +26,32 @@ export function withoutUnnecessarySeparators(items) {
* @return {Array}
*/
export default function listItems(items) {
if (!(items instanceof Array)) items = [items];
if (!(items instanceof Array)) items = [items];
return withoutUnnecessarySeparators(items).map(item => {
const isListItem = item.tag?.isListItem;
const active = item.tag?.isActive && item.tag.isActive(item.attrs);
const className = item.attrs?.itemClassName || item.itemClassName;
return withoutUnnecessarySeparators(items).map(item => {
const isListItem = item.tag?.isListItem;
const active = item.tag?.isActive && item.tag.isActive(item.attrs);
const className = item.attrs?.itemClassName || item.itemClassName;
if (isListItem) {
item.attrs = item.attrs || {};
item.attrs.key = item.attrs.key || item.itemName;
item.key = item.attrs.key;
}
if (isListItem) {
item.attrs = item.attrs || {};
item.attrs.key = item.attrs.key || item.itemName;
item.key = item.attrs.key;
}
const node = isListItem
? item
: <li className={classNames(className, [
(item.itemName && `item-${item.itemName}`),
active && 'active',
])}
key={item.attrs?.key || item.itemName}>
{item}
</li>;
const node = isListItem ? (
item
) : (
<li
className={classNames(className, [item.itemName && `item-${item.itemName}`, active && 'active'])}
key={item.attrs?.key || item.itemName}
>
{item}
</li>
);
node.state = node.state || {};
node.state = node.state || {};
return node;
});
return node;
});
}

View File

@ -5,7 +5,7 @@
* @param {User} user
*/
export default function username(user): any {
const name = (user && user.displayName()) || app.translator.trans('core.lib.username.deleted_text');
const name = (user && user.displayName()) || app.translator.trans('core.lib.username.deleted_text');
return <span className="username">{name}</span>;
return <span className="username">{name}</span>;
}

View File

@ -7,89 +7,89 @@ import User from './User';
import Post from './Post';
export default class Discussion extends Model {
title = Model.attribute('title') as () => string;
slug = Model.attribute('slug') as () => string;
title = Model.attribute('title') as () => string;
slug = Model.attribute('slug') as () => string;
createdAt = Model.attribute('createdAt', Model.transformDate) as () => Date;
user = Model.hasOne('user') as () => User;
firstPost = Model.hasOne('firstPost') as () => Post;
createdAt = Model.attribute('createdAt', Model.transformDate) as () => Date;
user = Model.hasOne('user') as () => User;
firstPost = Model.hasOne('firstPost') as () => Post;
lastPostedAt = Model.attribute('lastPostedAt', Model.transformDate) as () => Date;
lastPostedUser = Model.hasOne('lastPostedUser') as () => User;
lastPost = Model.hasOne('lastPost') as () => Post;
lastPostNumber = Model.attribute('lastPostNumber') as () => number;
lastPostedAt = Model.attribute('lastPostedAt', Model.transformDate) as () => Date;
lastPostedUser = Model.hasOne('lastPostedUser') as () => User;
lastPost = Model.hasOne('lastPost') as () => Post;
lastPostNumber = Model.attribute('lastPostNumber') as () => number;
commentCount = Model.attribute('commentCount') as () => number;
replyCount = computed('commentCount', commentCount => Math.max(0, commentCount - 1)) as () => string;
posts = Model.hasMany('posts') as () => Post[];
mostRelevantPost = Model.hasOne('mostRelevantPost') as () => Post;
commentCount = Model.attribute('commentCount') as () => number;
replyCount = computed('commentCount', commentCount => Math.max(0, commentCount - 1)) as () => string;
posts = Model.hasMany('posts') as () => Post[];
mostRelevantPost = Model.hasOne('mostRelevantPost') as () => Post;
lastReadAt = Model.attribute('lastReadAt', Model.transformDate) as () => Date;
lastReadPostNumber = Model.attribute('lastReadPostNumber') as () => number;
isUnread = computed('unreadCount', unreadCount => !!unreadCount) as () => boolean;
isRead = computed('unreadCount', unreadCount => app.session.user && !unreadCount) as () => boolean;
lastReadAt = Model.attribute('lastReadAt', Model.transformDate) as () => Date;
lastReadPostNumber = Model.attribute('lastReadPostNumber') as () => number;
isUnread = computed('unreadCount', unreadCount => !!unreadCount) as () => boolean;
isRead = computed('unreadCount', unreadCount => app.session.user && !unreadCount) as () => boolean;
hiddenAt = Model.attribute('hiddenAt', Model.transformDate) as () => Date;
hiddenUser = Model.hasOne('hiddenUser') as () => User;
isHidden = computed('hiddenAt', hiddenAt => !!hiddenAt) as () => boolean;
hiddenAt = Model.attribute('hiddenAt', Model.transformDate) as () => Date;
hiddenUser = Model.hasOne('hiddenUser') as () => User;
isHidden = computed('hiddenAt', hiddenAt => !!hiddenAt) as () => boolean;
canReply = Model.attribute('canReply') as () => boolean;
canRename = Model.attribute('canRename') as () => boolean;
canHide = Model.attribute('canHide') as () => boolean;
canDelete = Model.attribute('canDelete') as () => boolean;
canReply = Model.attribute('canReply') as () => boolean;
canRename = Model.attribute('canRename') as () => boolean;
canHide = Model.attribute('canHide') as () => boolean;
canDelete = Model.attribute('canDelete') as () => boolean;
/**
* Remove a post from the discussion's posts relationship.
*
* @param id The ID of the post to remove.
*/
removePost(id: number) {
const relationships = this.data.relationships;
const posts = relationships && relationships.posts;
/**
* Remove a post from the discussion's posts relationship.
*
* @param id The ID of the post to remove.
*/
removePost(id: number) {
const relationships = this.data.relationships;
const posts = relationships && relationships.posts;
if (posts) {
posts.data.some((data, i) => {
if (id === data.id) {
posts.data.splice(i, 1);
return true;
if (posts) {
posts.data.some((data, i) => {
if (id === data.id) {
posts.data.splice(i, 1);
return true;
}
});
}
});
}
}
/**
* Get the estimated number of unread posts in this discussion for the current
* user.
*/
unreadCount(): number {
const user = app.session.user;
if (user && user.markedAllAsReadAt() < this.lastPostedAt()) {
return Math.max(0, this.lastPostNumber() - (this.lastReadPostNumber() || 0));
}
return 0;
}
/**
* Get the estimated number of unread posts in this discussion for the current
* user.
*/
unreadCount(): number {
const user = app.session.user;
/**
* Get the Badge components that apply to this discussion.
*/
badges(): ItemList {
const items = new ItemList();
if (user && user.markedAllAsReadAt() < this.lastPostedAt()) {
return Math.max(0, this.lastPostNumber() - (this.lastReadPostNumber() || 0));
}
if (this.isHidden()) {
items.add('hidden', <Badge type="hidden" icon="fas fa-trash" label={app.translator.trans('core.lib.badge.hidden_tooltip')}/>);
return 0;
}
return items;
}
/**
* Get the Badge components that apply to this discussion.
*/
badges(): ItemList {
const items = new ItemList();
/**
* Get a list of all of the post IDs in this discussion.
*/
postIds(): number[] {
const posts = this.data.relationships.posts;
if (this.isHidden()) {
items.add('hidden', <Badge type="hidden" icon="fas fa-trash" label={app.translator.trans('core.lib.badge.hidden_tooltip')} />);
}
return posts ? posts.data.map(link => link.id) : [];
}
return items;
}
/**
* Get a list of all of the post IDs in this discussion.
*/
postIds(): number[] {
const posts = this.data.relationships.posts;
return posts ? posts.data.map(link => link.id) : [];
}
}

View File

@ -1,7 +1,7 @@
import Model from '../Model';
export default class Forum extends Model {
apiEndpoint() {
return '/';
}
apiEndpoint() {
return '/';
}
}

View File

@ -1,12 +1,12 @@
import Model from '../Model';
export default class Group extends Model {
static ADMINISTRATOR_ID = '1';
static GUEST_ID = '2';
static MEMBER_ID = '3';
static ADMINISTRATOR_ID = '1';
static GUEST_ID = '2';
static MEMBER_ID = '3';
nameSingular = Model.attribute('nameSingular') as () => string;
namePlural = Model.attribute('namePlural') as () => string;
color = Model.attribute('color') as () => string;
icon = Model.attribute('icon') as () => string;
nameSingular = Model.attribute('nameSingular') as () => string;
namePlural = Model.attribute('namePlural') as () => string;
color = Model.attribute('color') as () => string;
icon = Model.attribute('icon') as () => string;
}

View File

@ -2,17 +2,17 @@ import Model from '../Model';
import User from './User';
export default class Notification extends Model {
static ADMINISTRATOR_ID = '1';
static GUEST_ID = '2';
static MEMBER_ID = '3';
static ADMINISTRATOR_ID = '1';
static GUEST_ID = '2';
static MEMBER_ID = '3';
contentType = Model.attribute('contentType') as () => string;
content = Model.attribute('content') as () => string;
createdAt = Model.attribute('createdAt', Model.transformDate) as () => Date;
contentType = Model.attribute('contentType') as () => string;
content = Model.attribute('content') as () => string;
createdAt = Model.attribute('createdAt', Model.transformDate) as () => Date;
isRead = Model.attribute('isRead') as () => boolean;
isRead = Model.attribute('isRead') as () => boolean;
user = Model.hasOne('user') as () => User;
fromUser = Model.hasOne('fromUser') as () => User;
subject = Model.hasOne('subhect') as () => any;
user = Model.hasOne('user') as () => User;
fromUser = Model.hasOne('fromUser') as () => User;
subject = Model.hasOne('subhect') as () => any;
}

View File

@ -3,28 +3,28 @@ import computed from '../utils/computed';
import { getPlainContent } from '../utils/string';
import Discussion from './Discussion';
import User from "./User";
import User from './User';
export default class Post extends Model {
number = Model.attribute('number') as () => number;
discussion = Model.hasOne('discussion') as () => Discussion;
number = Model.attribute('number') as () => number;
discussion = Model.hasOne('discussion') as () => Discussion;
createdAt= Model.attribute('createdAt', Model.transformDate) as () => Date;
user = Model.hasOne('user') as () => User;
contentType = Model.attribute('contentType') as () => string;
content = Model.attribute('content') as () => string;
contentHtml = Model.attribute('contentHtml') as () => string;
contentPlain = computed('contentHtml', getPlainContent) as () => string;
createdAt = Model.attribute('createdAt', Model.transformDate) as () => Date;
user = Model.hasOne('user') as () => User;
contentType = Model.attribute('contentType') as () => string;
content = Model.attribute('content') as () => string;
contentHtml = Model.attribute('contentHtml') as () => string;
contentPlain = computed('contentHtml', getPlainContent) as () => string;
editedAt = Model.attribute('editedAt', Model.transformDate) as () => Date;
editedUser = Model.hasOne('editedUser') as () => User;
isEdited = computed('editedAt', editedAt => !!editedAt) as () => boolean;
editedAt = Model.attribute('editedAt', Model.transformDate) as () => Date;
editedUser = Model.hasOne('editedUser') as () => User;
isEdited = computed('editedAt', editedAt => !!editedAt) as () => boolean;
hiddenAt = Model.attribute('hiddenAt', Model.transformDate) as () => Date;
hiddenUser = Model.hasOne('hiddenUser') as () => User;
isHidden = computed('hiddenAt', hiddenAt => !!hiddenAt) as () => boolean;
hiddenAt = Model.attribute('hiddenAt', Model.transformDate) as () => Date;
hiddenUser = Model.hasOne('hiddenUser') as () => User;
isHidden = computed('hiddenAt', hiddenAt => !!hiddenAt) as () => boolean;
canEdit = Model.attribute('canEdit') as () => boolean;
canHide = Model.attribute('canHide') as () => boolean;
canDelete = Model.attribute('canDelete') as () => boolean;
canEdit = Model.attribute('canEdit') as () => boolean;
canHide = Model.attribute('canHide') as () => boolean;
canDelete = Model.attribute('canDelete') as () => boolean;
}

View File

@ -6,93 +6,98 @@ import GroupBadge from '../components/GroupBadge';
import Group from './Group';
export default class User extends Model {
username = Model.attribute('username') as () => string;
username = Model.attribute('username') as () => string;
displayName = Model.attribute('displayName') as () => string;
email = Model.attribute('email') as () => string;
isEmailConfirmed = Model.attribute('isEmailConfirmed') as () => boolean;
password = Model.attribute('password') as () => string;
displayName = Model.attribute('displayName') as () => string;
email = Model.attribute('email') as () => string;
isEmailConfirmed = Model.attribute('isEmailConfirmed') as () => boolean;
password = Model.attribute('password') as () => string;
avatarUrl = Model.attribute('avatarUrl') as () => string;
preferences = Model.attribute('preferences') as () => string;
groups = Model.hasMany('groups') as () => Group[];
avatarUrl = Model.attribute('avatarUrl') as () => string;
preferences = Model.attribute('preferences') as () => string;
groups = Model.hasMany('groups') as () => Group[];
joinTime = Model.attribute('joinTime', Model.transformDate) as () => Date;
lastSeenAt = Model.attribute('lastSeenAt', Model.transformDate) as () => Date;
markedAllAsReadAt = Model.attribute('markedAllAsReadAt', Model.transformDate) as () => Date;
unreadNotificationCount = Model.attribute('unreadNotificationCount') as () => number;
newNotificationCount = Model.attribute('newNotificationCount') as () => number;
joinTime = Model.attribute('joinTime', Model.transformDate) as () => Date;
lastSeenAt = Model.attribute('lastSeenAt', Model.transformDate) as () => Date;
markedAllAsReadAt = Model.attribute('markedAllAsReadAt', Model.transformDate) as () => Date;
unreadNotificationCount = Model.attribute('unreadNotificationCount') as () => number;
newNotificationCount = Model.attribute('newNotificationCount') as () => number;
discussionCount = Model.attribute('discussionCount') as () => number;
commentCount = Model.attribute('commentCount') as () => number;
discussionCount = Model.attribute('discussionCount') as () => number;
commentCount = Model.attribute('commentCount') as () => number;
canEdit = Model.attribute('canEdit') as () => boolean;
canDelete = Model.attribute('canDelete') as () => boolean;
canEdit = Model.attribute('canEdit') as () => boolean;
canDelete = Model.attribute('canDelete') as () => boolean;
avatarColor = null;
color = computed(['username', 'avatarUrl', 'avatarColor'], function(username, avatarUrl, avatarColor) {
// If we've already calculated and cached the dominant color of the user's
// avatar, then we can return that in RGB format. If we haven't, we'll want
// to calculate it. Unless the user doesn't have an avatar, in which case
// we generate a color from their username.
if (avatarColor) {
return 'rgb(' + avatarColor.join(', ') + ')';
} else if (avatarUrl) {
this.calculateAvatarColor();
return '';
avatarColor = null;
color = computed(['username', 'avatarUrl', 'avatarColor'], function(username, avatarUrl, avatarColor) {
// If we've already calculated and cached the dominant color of the user's
// avatar, then we can return that in RGB format. If we haven't, we'll want
// to calculate it. Unless the user doesn't have an avatar, in which case
// we generate a color from their username.
if (avatarColor) {
return 'rgb(' + avatarColor.join(', ') + ')';
} else if (avatarUrl) {
this.calculateAvatarColor();
return '';
}
return '#' + stringToColor(username);
}) as () => string;
isOnline(): boolean {
return (
this.lastSeenAt() >
dayjs()
.subtract(5, 'minutes')
.toDate()
);
}
return '#' + stringToColor(username);
}) as () => string;
/**
* Get the Badge components that apply to this user.
*/
badges(): ItemList {
const items = new ItemList();
const groups = this.groups();
isOnline(): boolean {
return this.lastSeenAt() > dayjs().subtract(5, 'minutes').toDate();
}
if (groups) {
groups.forEach(group => {
items.add('group' + group.id(), GroupBadge.component({ group }));
});
}
/**
* Get the Badge components that apply to this user.
*/
badges(): ItemList {
const items = new ItemList();
const groups = this.groups();
if (groups) {
groups.forEach(group => {
items.add('group' + group.id(), GroupBadge.component({group}));
});
return items;
}
return items;
}
/**
* Calculate the dominant color of the user's avatar. The dominant color will
* be set to the `avatarColor` property once it has been calculated.
*
* @protected
*/
calculateAvatarColor() {
const image = new Image();
const user = this;
/**
* Calculate the dominant color of the user's avatar. The dominant color will
* be set to the `avatarColor` property once it has been calculated.
*
* @protected
*/
calculateAvatarColor() {
const image = new Image();
const user = this;
image.onload = function() {
const colorThief = new ColorThief();
user.avatarColor = colorThief.getColor(this);
user.freshness = new Date();
m.redraw();
};
image.crossOrigin = 'anonymous';
image.src = this.avatarUrl();
}
image.onload = function() {
const colorThief = new ColorThief();
user.avatarColor = colorThief.getColor(this);
user.freshness = new Date();
m.redraw();
};
image.crossOrigin = 'anonymous';
image.src = this.avatarUrl();
}
/**
* Update the user's preferences.
*/
savePreferences(newPreferences: object): Promise<User> {
const preferences = this.preferences();
/**
* Update the user's preferences.
*/
savePreferences(newPreferences: object): Promise<User> {
const preferences = this.preferences();
Object.assign(preferences, newPreferences);
Object.assign(preferences, newPreferences);
return <Promise<User>> this.save({preferences});
}
return <Promise<User>>this.save({ preferences });
}
}

View File

@ -4,48 +4,48 @@
* footer.
*/
export default class Drawer {
private $backdrop?: ZeptoCollection;
private $backdrop?: ZeptoCollection;
constructor() {
// Set up an event handler so that whenever the content area is tapped,
// the drawer will close.
$('#content').click(e => {
if (this.isOpen()) {
e.preventDefault();
this.hide();
}
});
}
constructor() {
// Set up an event handler so that whenever the content area is tapped,
// the drawer will close.
$('#content').click(e => {
if (this.isOpen()) {
e.preventDefault();
this.hide();
}
});
}
/**
* Check whether or not the drawer is currently open.
*/
isOpen(): boolean {
return $('#app').hasClass('drawerOpen');
}
/**
* Check whether or not the drawer is currently open.
*/
isOpen(): boolean {
return $('#app').hasClass('drawerOpen');
}
/**
* Hide the drawer.
*/
hide() {
$('#app').removeClass('drawerOpen');
/**
* Hide the drawer.
*/
hide() {
$('#app').removeClass('drawerOpen');
if (this.$backdrop) this.$backdrop.remove();
}
if (this.$backdrop) this.$backdrop.remove();
}
/**
* Show the drawer.
*
* @public
*/
show() {
$('#app').addClass('drawerOpen');
/**
* Show the drawer.
*
* @public
*/
show() {
$('#app').addClass('drawerOpen');
this.$backdrop = $('<div/>')
.addClass('drawer-backdrop fade')
.appendTo('body')
.click(() => this.hide());
this.$backdrop = $('<div/>')
.addClass('drawer-backdrop fade')
.appendTo('body')
.click(() => this.hide());
setTimeout(() => this.$backdrop.addClass('in'));
}
setTimeout(() => this.$backdrop.addClass('in'));
}
}

View File

@ -1,4 +1,4 @@
import Mithril from "mithril";
import Mithril from 'mithril';
export interface RequestErrorResponse extends JSON {
errors?: {
@ -18,17 +18,17 @@ export default class RequestError {
alert?: Mithril.Vnode;
constructor(status, responseText, options, xhr) {
this.status = status;
this.responseText = responseText;
this.options = options;
this.xhr = xhr;
this.status = status;
this.responseText = responseText;
this.options = options;
this.xhr = xhr;
try {
this.response = JSON.parse(responseText);
} catch (e) {
this.response = null;
}
try {
this.response = JSON.parse(responseText);
} catch (e) {
this.response = null;
}
this.alert = null;
this.alert = null;
}
}

View File

@ -6,30 +6,33 @@
* @param {function} compute The function which computes the value using the
* dependent values.
*/
export default function computed(dependentKeys: string|string[], compute: Function): () => any {
const keys = Array.from(dependentKeys);
export default function computed(dependentKeys: string | string[], compute: Function): () => any {
const keys = Array.from(dependentKeys);
const dependentValues = {};
let computedValue;
const dependentValues = {};
let computedValue;
return function() {
let recompute = false;
return function() {
let recompute = false;
// Read all of the dependent values. If any of them have changed since last
// time, then we'll want to recompute our output.
keys.forEach(key => {
const value = typeof this[key] === 'function' ? this[key]() : this[key];
// Read all of the dependent values. If any of them have changed since last
// time, then we'll want to recompute our output.
keys.forEach(key => {
const value = typeof this[key] === 'function' ? this[key]() : this[key];
if (dependentValues[key] !== value) {
recompute = true;
dependentValues[key] = value;
}
});
if (dependentValues[key] !== value) {
recompute = true;
dependentValues[key] = value;
}
});
if (recompute) {
computedValue = compute.apply(this, keys.map(key => dependentValues[key]));
}
if (recompute) {
computedValue = compute.apply(
this,
keys.map(key => dependentValues[key])
);
}
return computedValue;
};
return computedValue;
};
}

View File

@ -5,11 +5,11 @@
* @return {String}
*/
export default function extractText(vdom: any): string {
if (vdom instanceof Array) {
return vdom.map(element => extractText(element)).join('');
} else if (typeof vdom === 'object' && vdom !== null) {
return vdom.text || extractText(vdom.children);
} else {
return vdom;
}
if (vdom instanceof Array) {
return vdom.map(element => extractText(element)).join('');
} else if (typeof vdom === 'object' && vdom !== null) {
return vdom.text || extractText(vdom.children);
} else {
return vdom;
}
}

View File

@ -3,31 +3,31 @@
* ago string.
*/
export default function humanTime(time: Date): string {
let m = dayjs(time);
const now = dayjs();
let m = dayjs(time);
const now = dayjs();
// To prevent showing things like "in a few seconds" due to small offsets
// between client and server time, we always reset future dates to the
// current time. This will result in "just now" being shown instead.
if (m.isAfter(now)) {
m = now;
}
const day = 864e5;
const diff = m.diff(dayjs());
let ago = null;
// If this date was more than a month ago, we'll show the name of the month
// in the string. If it wasn't this year, we'll show the year as well.
if (diff < -30 * day) {
if (m.year() === dayjs().year()) {
ago = m.format('D MMM');
} else {
ago = m.format('MMM \'YY');
// To prevent showing things like "in a few seconds" due to small offsets
// between client and server time, we always reset future dates to the
// current time. This will result in "just now" being shown instead.
if (m.isAfter(now)) {
m = now;
}
} else {
ago = m.fromNow();
}
return ago;
};
const day = 864e5;
const diff = m.diff(dayjs());
let ago = null;
// If this date was more than a month ago, we'll show the name of the month
// in the string. If it wasn't this year, we'll show the year as well.
if (diff < -30 * day) {
if (m.year() === dayjs().year()) {
ago = m.format('D MMM');
} else {
ago = m.format("MMM 'YY");
}
} else {
ago = m.fromNow();
}
return ago;
}

View File

@ -6,7 +6,7 @@ import Component from '../Component';
export default () => {
const mo = window['m'];
const _m = function (comp, ...args) {
const _m = function(comp, ...args) {
if (!arguments[1]) arguments[1] = {};
if (comp.prototype && comp.prototype instanceof Component) {
@ -42,13 +42,14 @@ export default () => {
return node;
};
Object.keys(mo).forEach(key => _m[key] = mo[key]);
Object.keys(mo).forEach(key => (_m[key] = mo[key]));
_m.withAttr = (key: string, cb: Function) => function () {
cb(this.getAttribute(key) || this[key]);
};
_m.withAttr = (key: string, cb: Function) =>
function() {
cb(this.getAttribute(key) || this[key]);
};
_m.prop = prop;
window['m'] = _m;
}
};

View File

@ -2,74 +2,72 @@ import jump from 'jump.js';
import Tooltip from 'tooltip.js';
// add $.fn.tooltip
$.fn.tooltip = function (option) {
return this.each(function () {
const $this = $(this);
let data = $this.data('bs.tooltip');
const options = typeof option === 'object' && option || {};
$.fn.tooltip = function(option) {
return this.each(function() {
const $this = $(this);
let data = $this.data('bs.tooltip');
const options = (typeof option === 'object' && option) || {};
if ($this.attr('title')) {
options.title = $this.attr('title');
$this.removeAttr('title');
$this.attr('data-original-title', options.title);
}
if ($this.attr('title')) {
options.title = $this.attr('title');
$this.removeAttr('title');
$this.attr('data-original-title', options.title);
}
if (option === 'destroy') option = 'dispose';
if (option === 'destroy') option = 'dispose';
if (!data && ['dispose', 'hide'].includes(option)) return;
if (!data && ['dispose', 'hide'].includes(option)) return;
if (!data) $this.data('bs.tooltip', (data = new Tooltip(this, options)));
if (typeof option === 'string' && data[option]) data[option]();
});
if (!data) $this.data('bs.tooltip', (data = new Tooltip(this, options)));
if (typeof option === 'string' && data[option]) data[option]();
});
};
// add $.fn.outerWidth and $.fn.outerHeight
['width', 'height'].forEach(function(dimension) {
const Dimension = dimension.replace(/./, function (m) {
return m[0].toUpperCase()
});
const Dimension = dimension.replace(/./, function(m) {
return m[0].toUpperCase();
});
$.fn[`outer${Dimension}`] = function(margin) {
const elem = this;
$.fn[`outer${Dimension}`] = function(margin) {
const elem = this;
if (elem) {
const sides = {'width': ['left', 'right'], 'height': ['top', 'bottom']};
let size = elem[dimension]();
if (elem) {
const sides = { width: ['left', 'right'], height: ['top', 'bottom'] };
let size = elem[dimension]();
sides[dimension].forEach(function(side) {
if (margin) size += parseInt(elem.css('margin-' + side), 10);
});
sides[dimension].forEach(function(side) {
if (margin) size += parseInt(elem.css('margin-' + side), 10);
});
return size;
} else {
return null;
}
};
return size;
} else {
return null;
}
};
});
// allow use of $(':input')
// @ts-ignore
$.expr[':']['input'] = function() {
if (('disabled' in this) || ['INPUT', 'SELECT', 'TEXTAREA', 'BUTTON'].includes(this.tagName)) return this;
if ('disabled' in this || ['INPUT', 'SELECT', 'TEXTAREA', 'BUTTON'].includes(this.tagName)) return this;
};
// add $().hover() method
$.fn.hover = function(hover, leave) {
return this
.on('mouseenter', hover)
.on('mouseleave', leave || hover);
return this.on('mouseenter', hover).on('mouseleave', leave || hover);
};
// add animated scroll
$.fn.animatedScrollTop = function (to, duration = $.fx.speeds._default, callback) {
if (typeof to === 'number') to -= (window.scrollY || window.pageYOffset);
$.fn.animatedScrollTop = function(to, duration = $.fx.speeds._default, callback) {
if (typeof to === 'number') to -= window.scrollY || window.pageYOffset;
jump(to, {
duration: $.fx.speeds[duration] || duration,
callback
});
jump(to, {
duration: $.fx.speeds[duration] || duration,
callback,
});
return this;
return this;
};
// required for compatibility with jquery plugins
@ -86,53 +84,51 @@ $.event.special = $.event.special || {};
const bindBeforeSpecialEvents = $.fn.bind;
$.fn.bind = function(eventName, data, callback) {
const el = this;
const el = this;
if (!callback){
callback = data;
data = null;
}
$.each(eventName.split(/\s/), (key: string, value: any) : boolean => {
value = value.split(/\./)[0];
if(value in $.event.special){
let specialEvent = $.event.special[value];
/// init enable special events on Zepto
if(!specialEvent._init) {
specialEvent._init = true;
/// intercept and replace the special event handler to add functionality
specialEvent.originalHandler = specialEvent.handler;
specialEvent.handler = function(){
/// make event argument writable, like on jQuery
const args = Array.prototype.slice.call(arguments);
args[0] = $.extend({},args[0]);
/// define the event handle, $.event.dispatch is only for newer versions of jQuery
$.event.handle = function(){
/// make context of trigger the event element
const args = Array.prototype.slice.call(arguments);
const event = args[0];
const $target = $(event.target);
$target.trigger.apply( $target, arguments );
};
specialEvent.originalHandler.apply(this,args);
}
}
/// setup special events on Zepto
specialEvent.setup.apply(el, [data]);
if (!callback) {
callback = data;
data = null;
}
return true;
});
$.each(eventName.split(/\s/), (key: string, value: any): boolean => {
value = value.split(/\./)[0];
return bindBeforeSpecialEvents.apply(this, [eventName, callback]);
if (value in $.event.special) {
let specialEvent = $.event.special[value];
/// init enable special events on Zepto
if (!specialEvent._init) {
specialEvent._init = true;
/// intercept and replace the special event handler to add functionality
specialEvent.originalHandler = specialEvent.handler;
specialEvent.handler = function() {
/// make event argument writable, like on jQuery
const args = Array.prototype.slice.call(arguments);
args[0] = $.extend({}, args[0]);
/// define the event handle, $.event.dispatch is only for newer versions of jQuery
$.event.handle = function() {
/// make context of trigger the event element
const args = Array.prototype.slice.call(arguments);
const event = args[0];
const $target = $(event.target);
$target.trigger.apply($target, arguments);
};
specialEvent.originalHandler.apply(this, args);
};
}
/// setup special events on Zepto
specialEvent.setup.apply(el, [data]);
}
return true;
});
return bindBeforeSpecialEvents.apply(this, [eventName, callback]);
};

View File

@ -2,9 +2,7 @@
* Truncate a string to the given length, appending ellipses if necessary.
*/
export function truncate(string: string, length: number, start = 0): string {
return (start > 0 ? '...' : '') +
string.substring(start, start + length) +
(string.length > start + length ? '...' : '');
return (start > 0 ? '...' : '') + string.substring(start, start + length) + (string.length > start + length ? '...' : '');
}
/**
@ -12,10 +10,11 @@ export function truncate(string: string, length: number, start = 0): string {
* converted to hyphens.
*/
export function slug(string: string): string {
return string.toLowerCase()
.replace(/[^a-z0-9]/gi, '-')
.replace(/-+/g, '-')
.replace(/-$|^-/g, '');
return string
.toLowerCase()
.replace(/[^a-z0-9]/gi, '-')
.replace(/-+/g, '-')
.replace(/-$|^-/g, '');
}
/**
@ -23,15 +22,16 @@ export function slug(string: string): string {
* meaningful punctuation.
*/
export function getPlainContent(string: string): string {
const html = string
.replace(/(<\/p>|<br>)/g, '$1 &nbsp;')
.replace(/<img\b[^>]*>/ig, ' ');
const html = string.replace(/(<\/p>|<br>)/g, '$1 &nbsp;').replace(/<img\b[^>]*>/gi, ' ');
const dom = $('<div/>').html(html);
const dom = $('<div/>').html(html);
dom.find(getPlainContent.removeSelectors.join(',')).remove();
dom.find(getPlainContent.removeSelectors.join(',')).remove();
return dom.text().replace(/\s+/g, ' ').trim();
return dom
.text()
.replace(/\s+/g, ' ')
.trim();
}
/**
@ -45,5 +45,5 @@ getPlainContent.removeSelectors = ['blockquote', 'script'];
* Make a string's first character uppercase.
*/
export function ucfirst(string: string): string {
return string.substr(0, 1).toUpperCase() + string.substr(1);
return string.substr(0, 1).toUpperCase() + string.substr(1);
}

View File

@ -1,46 +1,70 @@
export function hsvToRgb(h: number, s: number, v: number) {
let r;
let g;
let b;
let r;
let g;
let b;
const i = Math.floor(h * 6);
const f = h * 6 - i;
const p = v * (1 - s);
const q = v * (1 - f * s);
const t = v * (1 - (1 - f) * s);
const i = Math.floor(h * 6);
const f = h * 6 - i;
const p = v * (1 - s);
const q = v * (1 - f * s);
const t = v * (1 - (1 - f) * s);
switch (i % 6) {
case 0: r = v; g = t; b = p; break;
case 1: r = q; g = v; b = p; break;
case 2: r = p; g = v; b = t; break;
case 3: r = p; g = q; b = v; break;
case 4: r = t; g = p; b = v; break;
case 5: r = v; g = p; b = q; break;
}
switch (i % 6) {
case 0:
r = v;
g = t;
b = p;
break;
case 1:
r = q;
g = v;
b = p;
break;
case 2:
r = p;
g = v;
b = t;
break;
case 3:
r = p;
g = q;
b = v;
break;
case 4:
r = t;
g = p;
b = v;
break;
case 5:
r = v;
g = p;
b = q;
break;
}
return {
r: Math.floor(r * 255),
g: Math.floor(g * 255),
b: Math.floor(b * 255)
};
return {
r: Math.floor(r * 255),
g: Math.floor(g * 255),
b: Math.floor(b * 255),
};
}
/**
* Convert the given string to a unique color.
*/
export default function stringToColor(string: string): string {
let num = 0;
let num = 0;
// Convert the username into a number based on the ASCII value of each
// character.
for (let i = 0; i < string.length; i++) {
num += string.charCodeAt(i);
}
// Convert the username into a number based on the ASCII value of each
// character.
for (let i = 0; i < string.length; i++) {
num += string.charCodeAt(i);
}
// Construct a color using the remainder of that number divided by 360, and
// some predefined saturation and value values.
const hue = num % 360;
const rgb = hsvToRgb(hue / 360, 0.3, 0.9);
// Construct a color using the remainder of that number divided by 360, and
// some predefined saturation and value values.
const hue = num % 360;
const rgb = hsvToRgb(hue / 360, 0.3, 0.9);
return '' + rgb.r.toString(16) + rgb.g.toString(16) + rgb.b.toString(16);
return '' + rgb.r.toString(16) + rgb.g.toString(16) + rgb.b.toString(16);
}

View File

@ -7,22 +7,22 @@ import HeaderSecondary from './components/HeaderSecondary';
import Page from './components/Page';
import IndexPage from './components/IndexPage';
import PostsUserPage from './components/PostsUserPage';
import User from "../common/models/User";
import Post from "../common/models/Post";
import Discussion from "../common/models/Discussion";
import User from '../common/models/User';
import Post from '../common/models/Post';
import Discussion from '../common/models/Discussion';
export default class Forum extends Application {
routes = {
'index': { path: '/all', component: IndexPage },
index: { path: '/all', component: IndexPage },
'discussion': { path: '/d/:id', component: IndexPage },
discussion: { path: '/d/:id', component: IndexPage },
'discussion.near': { path: '/d/:id/:near', component: IndexPage },
'user': { path: '/u/:username', component: PostsUserPage },
user: { path: '/u/:username', component: PostsUserPage },
'user.posts': { path: '/u/:username', component: PostsUserPage },
'user.discussions': { path: '/u/:username/discussions', component: PostsUserPage },
'settings': { path: '/settings', component: PostsUserPage },
settings: { path: '/settings', component: PostsUserPage },
'index.filter': { path: '/:filter', component: IndexPage },
};
@ -37,73 +37,73 @@ export default class Forum extends Application {
current: Page;
mount() {
// Get the configured default route and update that route's path to be '/'.
// Push the homepage as the first route, so that the user will always be
// able to click on the 'back' button to go home, regardless of which page
// they started on.
const defaultRoute = this.forum.attribute('defaultRoute');
let defaultAction = 'index';
// Get the configured default route and update that route's path to be '/'.
// Push the homepage as the first route, so that the user will always be
// able to click on the 'back' button to go home, regardless of which page
// they started on.
const defaultRoute = this.forum.attribute('defaultRoute');
let defaultAction = 'index';
for (const i in this.routes) {
if (this.routes[i].path === defaultRoute) defaultAction = i;
}
this.routes[defaultAction].path = '/';
this.history.push(defaultAction, this.translator.transText('core.forum.header.back_to_index_tooltip'), '/');
// m.mount(document.getElementById('app-navigation'), Navigation.component({className: 'App-backControl', drawer: true}));
// m.mount(document.getElementById('header-navigation'), Navigation.component());
m.mount(document.getElementById('header-primary'), new HeaderPrimary());
m.mount(document.getElementById('header-secondary'), new HeaderSecondary());
// this.pane = new Pane(document.getElementById('app'));
// this.composer = m.mount(document.getElementById('composer'), Composer.component());
m.route.prefix = '';
super.mount(this.forum.attribute('basePath'));
// alertEmailConfirmation(this);
// Route the home link back home when clicked. We do not want it to register
// if the user is opening it in a new tab, however.
$('#home-link').click((e: MouseEvent) => {
if (e.ctrlKey || e.metaKey || e.which === 2) return;
e.preventDefault();
app.history.home();
// Reload the current user so that their unread notification count is refreshed.
if (app.session.user) {
app.store.find('users', app.session.user.id());
m.redraw();
for (const i in this.routes) {
if (this.routes[i].path === defaultRoute) defaultAction = i;
}
});
this.routes[defaultAction].path = '/';
this.history.push(defaultAction, this.translator.transText('core.forum.header.back_to_index_tooltip'), '/');
// m.mount(document.getElementById('app-navigation'), Navigation.component({className: 'App-backControl', drawer: true}));
// m.mount(document.getElementById('header-navigation'), Navigation.component());
m.mount(document.getElementById('header-primary'), new HeaderPrimary());
m.mount(document.getElementById('header-secondary'), new HeaderSecondary());
// this.pane = new Pane(document.getElementById('app'));
// this.composer = m.mount(document.getElementById('composer'), Composer.component());
m.route.prefix = '';
super.mount(this.forum.attribute('basePath'));
// alertEmailConfirmation(this);
// Route the home link back home when clicked. We do not want it to register
// if the user is opening it in a new tab, however.
$('#home-link').click((e: MouseEvent) => {
if (e.ctrlKey || e.metaKey || e.which === 2) return;
e.preventDefault();
app.history.home();
// Reload the current user so that their unread notification count is refreshed.
if (app.session.user) {
app.store.find('users', app.session.user.id());
m.redraw();
}
});
}
setupRoutes() {
super.setupRoutes();
super.setupRoutes();
this.route.discussion = (discussion: Discussion, near?: number): string => {
const slug = discussion.slug();
return this.route(near && near !== 1 ? 'discussion.near' : 'discussion', {
id: discussion.id() + (slug.trim() ? '-' + slug : ''),
near: near && near !== 1 ? near : undefined
});
};
this.route.discussion = (discussion: Discussion, near?: number): string => {
const slug = discussion.slug();
return this.route(near && near !== 1 ? 'discussion.near' : 'discussion', {
id: discussion.id() + (slug.trim() ? '-' + slug : ''),
near: near && near !== 1 ? near : undefined,
});
};
/**
* Generate a URL to a post.
*/
this.route.post = (post: Post): string => {
return this.route.discussion(post.discussion(), post.number());
};
/**
* Generate a URL to a post.
*/
this.route.post = (post: Post): string => {
return this.route.discussion(post.discussion(), post.number());
};
/**
* Generate a URL to a user.
*/
this.route.user = (user: User): string => {
return this.route('user', {
username: user.username()
});
};
/**
* Generate a URL to a user.
*/
this.route.user = (user: User): string => {
return this.route('user', {
username: user.username(),
});
};
}
}

View File

@ -1,4 +1,4 @@
import Component, {ComponentProps} from '../../common/Component';
import Component, { ComponentProps } from '../../common/Component';
import avatar from '../../common/helpers/avatar';
import icon from '../../common/helpers/icon';
import listItems from '../../common/helpers/listItems';
@ -8,7 +8,7 @@ import LoadingIndicator from '../../common/components/LoadingIndicator';
import User from '../../common/models/User';
export interface AvatarEditorProps extends ComponentProps {
user: User;
user: User;
}
/**
@ -16,206 +16,210 @@ export interface AvatarEditorProps extends ComponentProps {
* menu which allows the user to upload/remove the avatar.
*/
export default class AvatarEditor extends Component<AvatarEditorProps> {
/**
* Whether or not an avatar upload is in progress.
*/
loading = false;
/**
* Whether or not an avatar upload is in progress.
*/
loading = false;
/**
* Whether or not an image has been dragged over the dropzone.
*/
isDraggedOver = false;
/**
* Whether or not an image has been dragged over the dropzone.
*/
isDraggedOver = false;
static initProps(props) {
super.initProps(props);
static initProps(props) {
super.initProps(props);
props.className = props.className || '';
}
view() {
const user = this.props.user;
return (
<div className={'AvatarEditor Dropdown ' + this.props.className + (this.loading ? ' loading' : '') + (this.isDraggedOver ? ' dragover' : '')}>
{avatar(user)}
<a className={ user.avatarUrl() ? "Dropdown-toggle" : "Dropdown-toggle AvatarEditor--noAvatar" }
title={app.translator.trans('core.forum.user.avatar_upload_tooltip')}
data-toggle="dropdown"
onclick={this.quickUpload.bind(this)}
ondragover={this.enableDragover.bind(this)}
ondragenter={this.enableDragover.bind(this)}
ondragleave={this.disableDragover.bind(this)}
ondragend={this.disableDragover.bind(this)}
ondrop={this.dropUpload.bind(this)}>
{this.loading ? LoadingIndicator.component() : (user.avatarUrl() ? icon('fas fa-pencil-alt') : icon('fas fa-plus-circle'))}
</a>
<ul className="Dropdown-menu Menu">
{listItems(this.controlItems().toArray())}
</ul>
</div>
);
}
/**
* Get the items in the edit avatar dropdown menu.
*
* @return {ItemList}
*/
controlItems() {
const items = new ItemList();
items.add('upload',
Button.component({
icon: 'fas fa-upload',
children: app.translator.trans('core.forum.user.avatar_upload_button'),
onclick: this.openPicker.bind(this)
})
);
items.add('remove',
Button.component({
icon: 'fas fa-times',
children: app.translator.trans('core.forum.user.avatar_remove_button'),
onclick: this.remove.bind(this)
})
);
return items;
}
/**
* Enable dragover style
*
* @param {Event} e
*/
enableDragover(e) {
e.preventDefault();
e.stopPropagation();
this.isDraggedOver = true;
}
/**
* Disable dragover style
*
* @param {Event} e
*/
disableDragover(e) {
e.preventDefault();
e.stopPropagation();
this.isDraggedOver = false;
}
/**
* Upload avatar when file is dropped into dropzone.
*
* @param {Event} e
*/
dropUpload(e) {
e.preventDefault();
e.stopPropagation();
this.isDraggedOver = false;
this.upload(e.dataTransfer.files[0]);
}
/**
* If the user doesn't have an avatar, there's no point in showing the
* controls dropdown, because only one option would be viable: uploading.
* Thus, when the avatar editor's dropdown toggle button is clicked, we prompt
* the user to upload an avatar immediately.
*
* @param {Event} e
*/
quickUpload(e) {
if (!this.props.user.avatarUrl()) {
e.preventDefault();
e.stopPropagation();
this.openPicker();
props.className = props.className || '';
}
}
/**
* Upload avatar using file picker
*/
openPicker() {
if (this.loading) return;
view() {
const user = this.props.user;
// Create a hidden HTML input element and click on it so the user can select
// an avatar file. Once they have, we will upload it via the API.
const user = this.props.user;
const $input = $('<input type="file">');
return (
<div
className={
'AvatarEditor Dropdown ' + this.props.className + (this.loading ? ' loading' : '') + (this.isDraggedOver ? ' dragover' : '')
}
>
{avatar(user)}
<a
className={user.avatarUrl() ? 'Dropdown-toggle' : 'Dropdown-toggle AvatarEditor--noAvatar'}
title={app.translator.trans('core.forum.user.avatar_upload_tooltip')}
data-toggle="dropdown"
onclick={this.quickUpload.bind(this)}
ondragover={this.enableDragover.bind(this)}
ondragenter={this.enableDragover.bind(this)}
ondragleave={this.disableDragover.bind(this)}
ondragend={this.disableDragover.bind(this)}
ondrop={this.dropUpload.bind(this)}
>
{this.loading ? LoadingIndicator.component() : user.avatarUrl() ? icon('fas fa-pencil-alt') : icon('fas fa-plus-circle')}
</a>
<ul className="Dropdown-menu Menu">{listItems(this.controlItems().toArray())}</ul>
</div>
);
}
$input.appendTo('body').hide().click().on('change', e => {
this.upload($(e.target)[0].files[0]);
});
}
/**
* Get the items in the edit avatar dropdown menu.
*
* @return {ItemList}
*/
controlItems() {
const items = new ItemList();
/**
* Upload avatar
*
* @param {File} file
*/
upload(file) {
if (this.loading) return;
items.add(
'upload',
Button.component({
icon: 'fas fa-upload',
children: app.translator.trans('core.forum.user.avatar_upload_button'),
onclick: this.openPicker.bind(this),
})
);
const user = this.props.user;
const body = new FormData();
data.append('avatar', file);
items.add(
'remove',
Button.component({
icon: 'fas fa-times',
children: app.translator.trans('core.forum.user.avatar_remove_button'),
onclick: this.remove.bind(this),
})
);
this.loading = true;
m.redraw();
return items;
}
app.request({
method: 'POST',
url: `${app.forum.attribute('apiUrl')}/users/${user.id()}/avatar`,
serialize: raw => raw,
body
}).then(
this.success.bind(this),
this.failure.bind(this)
);
}
/**
* Enable dragover style
*
* @param {Event} e
*/
enableDragover(e) {
e.preventDefault();
e.stopPropagation();
this.isDraggedOver = true;
}
/**
* Remove the user's avatar.
*/
remove() {
const user = this.props.user;
/**
* Disable dragover style
*
* @param {Event} e
*/
disableDragover(e) {
e.preventDefault();
e.stopPropagation();
this.isDraggedOver = false;
}
this.loading = true;
m.redraw();
/**
* Upload avatar when file is dropped into dropzone.
*
* @param {Event} e
*/
dropUpload(e) {
e.preventDefault();
e.stopPropagation();
this.isDraggedOver = false;
this.upload(e.dataTransfer.files[0]);
}
app.request({
method: 'DELETE',
url: `${app.forum.attribute('apiUrl')}/users/${user.id()}/avatar`
}).then(
this.success.bind(this),
this.failure.bind(this)
);
}
/**
* If the user doesn't have an avatar, there's no point in showing the
* controls dropdown, because only one option would be viable: uploading.
* Thus, when the avatar editor's dropdown toggle button is clicked, we prompt
* the user to upload an avatar immediately.
*
* @param {Event} e
*/
quickUpload(e) {
if (!this.props.user.avatarUrl()) {
e.preventDefault();
e.stopPropagation();
this.openPicker();
}
}
/**
* After a successful upload/removal, push the updated user data into the
* store, and force a recomputation of the user's avatar color.
*
* @param {Object} response
* @protected
*/
success(response) {
app.store.pushPayload(response);
delete this.props.user.avatarColor;
/**
* Upload avatar using file picker
*/
openPicker() {
if (this.loading) return;
this.loading = false;
m.redraw();
}
// Create a hidden HTML input element and click on it so the user can select
// an avatar file. Once they have, we will upload it via the API.
const user = this.props.user;
const $input = $('<input type="file">');
/**
* If avatar upload/removal fails, stop loading.
*
* @param {Object} response
* @protected
*/
failure(response) {
this.loading = false;
m.redraw();
}
$input
.appendTo('body')
.hide()
.click()
.on('change', e => {
this.upload($(e.target)[0].files[0]);
});
}
/**
* Upload avatar
*
* @param {File} file
*/
upload(file) {
if (this.loading) return;
const user = this.props.user;
const body = new FormData();
data.append('avatar', file);
this.loading = true;
m.redraw();
app.request({
method: 'POST',
url: `${app.forum.attribute('apiUrl')}/users/${user.id()}/avatar`,
serialize: raw => raw,
body,
}).then(this.success.bind(this), this.failure.bind(this));
}
/**
* Remove the user's avatar.
*/
remove() {
const user = this.props.user;
this.loading = true;
m.redraw();
app.request({
method: 'DELETE',
url: `${app.forum.attribute('apiUrl')}/users/${user.id()}/avatar`,
}).then(this.success.bind(this), this.failure.bind(this));
}
/**
* After a successful upload/removal, push the updated user data into the
* store, and force a recomputation of the user's avatar color.
*
* @param {Object} response
* @protected
*/
success(response) {
app.store.pushPayload(response);
delete this.props.user.avatarColor;
this.loading = false;
m.redraw();
}
/**
* If avatar upload/removal fails, stop loading.
*
* @param {Object} response
* @protected
*/
failure(response) {
this.loading = false;
m.redraw();
}
}

View File

@ -26,7 +26,7 @@ export default class CommentPost extends Post {
// Create an instance of the component that displays the post's author so
// that we can force the post to rerender when the user card is shown.
this.postUser = PostUser.component({post: this.props.post});
this.postUser = PostUser.component({ post: this.props.post });
this.subtree.check(
() => this.postUser.cardVisible,
@ -38,14 +38,18 @@ export default class CommentPost extends Post {
// Note: we avoid using JSX for the <ul> below because it results in some
// weirdness in Mithril.js 0.1.x (see flarum/core#975). This workaround can
// be reverted when we upgrade to Mithril 1.0.
return super.content().concat([
<header className="Post-header">{m('ul', listItems(this.headerItems().toArray()))}</header>,
<div className="Post-body">
{this.isEditing()
? <div className="Post-preview" config={this.configPreview.bind(this)}/>
: m.trust(this.props.post.contentHtml())}
</div>
]);
return super
.content()
.concat([
<header className="Post-header">{m('ul', listItems(this.headerItems().toArray()))}</header>,
<div className="Post-body">
{this.isEditing() ? (
<div className="Post-preview" config={this.configPreview.bind(this)} />
) : (
m.trust(this.props.post.contentHtml())
)}
</div>,
]);
}
onupdate(vnode) {
@ -77,13 +81,16 @@ export default class CommentPost extends Post {
const post = this.props.post;
const attrs = super.attrs();
attrs.className = (attrs.className || '') + ' ' + classNames({
'CommentPost': true,
'Post--hidden': post.isHidden(),
'Post--edited': post.isEdited(),
'revealContent': this.revealContent,
'editing': this.isEditing()
});
attrs.className =
(attrs.className || '') +
' ' +
classNames({
CommentPost: true,
'Post--hidden': post.isHidden(),
'Post--edited': post.isEdited(),
revealContent: this.revealContent,
editing: this.isEditing(),
});
return attrs;
}
@ -125,7 +132,7 @@ export default class CommentPost extends Post {
headerItems() {
const items = new ItemList();
const post = this.props.post;
const props = {post};
const props = { post };
items.add('user', this.postUser, 100);
// items.add('meta', PostMeta.component(props));
@ -137,13 +144,14 @@ export default class CommentPost extends Post {
// If the post is hidden, add a button that allows toggling the visibility
// of the post's content.
if (post.isHidden()) {
items.add('toggle', (
items.add(
'toggle',
Button.component({
className: 'Button Button--default Button--more',
icon: 'fas fa-ellipsis-h',
onclick: this.toggleContent.bind(this)
onclick: this.toggleContent.bind(this),
})
));
);
}
return items;

View File

@ -1,55 +1,59 @@
import highlight from '../../common/helpers/highlight';
import LinkButton from '../../common/components/LinkButton';
import SearchSource from "./SearchSource";
import Discussion from "../../common/models/Discussion";
import SearchSource from './SearchSource';
import Discussion from '../../common/models/Discussion';
/**
* The `DiscussionsSearchSource` finds and displays discussion search results in
* the search dropdown.
*/
export default class DiscussionsSearchSource extends SearchSource {
protected results: { [key: string]: Discussion[] } = {};
protected results: { [key: string]: Discussion[] } = {};
search(query: string) {
query = query.toLowerCase();
search(query: string) {
query = query.toLowerCase();
this.results[query] = [];
this.results[query] = [];
const params = {
filter: {q: query},
page: {limit: 3},
include: 'mostRelevantPost'
};
const params = {
filter: { q: query },
page: { limit: 3 },
include: 'mostRelevantPost',
};
return app.store.find<Discussion>('discussions', params).then(results => this.results[query] = results);
}
return app.store.find<Discussion>('discussions', params).then(results => (this.results[query] = results));
}
view(query: string) {
query = query.toLowerCase();
view(query: string) {
query = query.toLowerCase();
const results = this.results[query] || [];
const results = this.results[query] || [];
return [
<li className="Dropdown-header">{app.translator.trans('core.forum.search.discussions_heading')}</li>,
<li>
{LinkButton.component({
icon: 'fas fa-search',
children: app.translator.trans('core.forum.search.all_discussions_button', {query}),
href: app.route('index', {q: query})
})}
</li>,
results.map(discussion => {
const mostRelevantPost = discussion.mostRelevantPost();
return [
<li className="Dropdown-header">{app.translator.trans('core.forum.search.discussions_heading')}</li>,
<li>
{LinkButton.component({
icon: 'fas fa-search',
children: app.translator.trans('core.forum.search.all_discussions_button', { query }),
href: app.route('index', { q: query }),
})}
</li>,
results.map(discussion => {
const mostRelevantPost = discussion.mostRelevantPost();
return (
<li className="DiscussionSearchResult" data-index={'discussions' + discussion.id()}>
<m.route.Link href={app.route.discussion(discussion, mostRelevantPost && mostRelevantPost.number())}>
<div className="DiscussionSearchResult-title">{highlight(discussion.title(), query)}</div>
{mostRelevantPost ? <div className="DiscussionSearchResult-excerpt">{highlight(mostRelevantPost.contentPlain(), query, 100)}</div> : ''}
</m.route.Link>
</li>
);
})
];
}
return (
<li className="DiscussionSearchResult" data-index={'discussions' + discussion.id()}>
<m.route.Link href={app.route.discussion(discussion, mostRelevantPost && mostRelevantPost.number())}>
<div className="DiscussionSearchResult-title">{highlight(discussion.title(), query)}</div>
{mostRelevantPost ? (
<div className="DiscussionSearchResult-excerpt">{highlight(mostRelevantPost.contentPlain(), query, 100)}</div>
) : (
''
)}
</m.route.Link>
</li>
);
}),
];
}
}

View File

@ -7,18 +7,14 @@ import listItems from '../../common/helpers/listItems';
* default skin, these are shown just to the right of the forum title.
*/
export default class HeaderPrimary extends Component {
view() {
return (
<ul className="Header-controls">
{listItems(this.items().toArray())}
</ul>
);
}
view() {
return <ul className="Header-controls">{listItems(this.items().toArray())}</ul>;
}
/**
* Build an item list for the controls.
*/
items(): ItemList {
return new ItemList();
}
/**
* Build an item list for the controls.
*/
items(): ItemList {
return new ItemList();
}
}

View File

@ -16,70 +16,76 @@ import Search from './Search';
* right side of the header.
*/
export default class HeaderSecondary extends Component {
view() {
return (
<ul className="Header-controls">
{listItems(this.items().toArray())}
</ul>
);
}
view() {
return <ul className="Header-controls">{listItems(this.items().toArray())}</ul>;
}
/**
* Build an item list for the controls.
*/
items(): ItemList {
const items = new ItemList();
/**
* Build an item list for the controls.
*/
items(): ItemList {
const items = new ItemList();
items.add('search', Search.component(), 30);
items.add('search', Search.component(), 30);
if (app.forum.attribute("showLanguageSelector") && Object.keys(app.data.locales).length > 1) {
const locales = [];
if (app.forum.attribute('showLanguageSelector') && Object.keys(app.data.locales).length > 1) {
const locales = [];
for (const locale in app.data.locales) {
locales.push(Button.component({
active: app.data.locale === locale,
children: app.data.locales[locale],
icon: app.data.locale === locale ? 'fas fa-check' : true,
onclick: () => {
if (app.session.user) {
app.session.user.savePreferences({locale}).then(() => window.location.reload());
} else {
document.cookie = `locale=${locale}; path=/; expires=Tue, 19 Jan 2038 03:14:07 GMT`;
window.location.reload();
for (const locale in app.data.locales) {
locales.push(
Button.component({
active: app.data.locale === locale,
children: app.data.locales[locale],
icon: app.data.locale === locale ? 'fas fa-check' : true,
onclick: () => {
if (app.session.user) {
app.session.user.savePreferences({ locale }).then(() => window.location.reload());
} else {
document.cookie = `locale=${locale}; path=/; expires=Tue, 19 Jan 2038 03:14:07 GMT`;
window.location.reload();
}
},
})
);
}
}
}));
}
items.add('locale', SelectDropdown.component({
children: locales,
buttonClassName: 'Button Button--link'
}), 20);
items.add(
'locale',
SelectDropdown.component({
children: locales,
buttonClassName: 'Button Button--link',
}),
20
);
}
if (app.session.user) {
items.add('notifications', NotificationsDropdown.component(), 10);
items.add('session', SessionDropdown.component(), 0);
} else {
if (app.forum.attribute('allowSignUp')) {
items.add(
'signUp',
Button.component({
children: app.translator.trans('core.forum.header.sign_up_link'),
className: 'Button Button--link',
onclick: () => app.modal.show(new SignUpModal()),
}),
10
);
}
items.add(
'logIn',
Button.component({
children: app.translator.trans('core.forum.header.log_in_link'),
className: 'Button Button--link',
onclick: () => app.modal.show(new LogInModal()),
}),
0
);
}
return items;
}
if (app.session.user) {
items.add('notifications', NotificationsDropdown.component(), 10);
items.add('session', SessionDropdown.component(), 0);
} else {
if (app.forum.attribute('allowSignUp')) {
items.add('signUp',
Button.component({
children: app.translator.trans('core.forum.header.sign_up_link'),
className: 'Button Button--link',
onclick: () => app.modal.show(new SignUpModal())
}), 10
);
}
items.add('logIn',
Button.component({
children: app.translator.trans('core.forum.header.log_in_link'),
className: 'Button Button--link',
onclick: () => app.modal.show(new LogInModal())
}), 0
);
}
return items;
}
}

View File

@ -6,9 +6,7 @@ import ItemList from '../../common/utils/ItemList';
*/
export default class LogInButtons extends Component {
view() {
return <div className="LogInButtons">
{this.items().toArray()}
</div>
return <div className="LogInButtons">{this.items().toArray()}</div>;
}
/**

View File

@ -1,9 +1,9 @@
import Stream from 'mithril/stream';
import {ComponentProps} from '../../common/Component';
import { ComponentProps } from '../../common/Component';
import Modal from '../../common/components/Modal';
import ItemList from "../../common/utils/ItemList";
import Button from "../../common/components/Button";
import ItemList from '../../common/utils/ItemList';
import Button from '../../common/components/Button';
import LogInButtons from './LogInButtons';
@ -51,58 +51,71 @@ export default class LogInModal extends Modal<LogInModalProps> {
}
content() {
return [
<div className="Modal-body">
{this.body()}
</div>,
<div className="Modal-footer">
{this.footer()}
</div>
];
return [<div className="Modal-body">{this.body()}</div>, <div className="Modal-footer">{this.footer()}</div>];
}
body() {
return [
<LogInButtons/>,
<div className="Form Form--centered">
{this.fields().toArray()}
</div>
];
return [<LogInButtons />, <div className="Form Form--centered">{this.fields().toArray()}</div>];
}
fields() {
const items = new ItemList();
items.add('identification', <div className="Form-group">
<input className="FormControl" name="identification" type="text" placeholder={app.translator.transText('core.forum.log_in.username_or_email_placeholder')}
bidi={this.identification}
disabled={this.loading} />
</div>, 30);
items.add(
'identification',
<div className="Form-group">
<input
className="FormControl"
name="identification"
type="text"
placeholder={app.translator.transText('core.forum.log_in.username_or_email_placeholder')}
bidi={this.identification}
disabled={this.loading}
/>
</div>,
30
);
items.add('password', <div className="Form-group">
<input className="FormControl" name="password" type="password" placeholder={app.translator.transText('core.forum.log_in.password_placeholder')}
bidi={this.password}
disabled={this.loading} />
</div>, 20);
items.add(
'password',
<div className="Form-group">
<input
className="FormControl"
name="password"
type="password"
placeholder={app.translator.transText('core.forum.log_in.password_placeholder')}
bidi={this.password}
disabled={this.loading}
/>
</div>,
20
);
items.add('remember', <div className="Form-group">
<div>
<label className="checkbox">
<input type="checkbox" bidi={this.remember} disabled={this.loading} />
{app.translator.trans('core.forum.log_in.remember_me_label')}
</label>
</div>
</div>, 10);
items.add(
'remember',
<div className="Form-group">
<div>
<label className="checkbox">
<input type="checkbox" bidi={this.remember} disabled={this.loading} />
{app.translator.trans('core.forum.log_in.remember_me_label')}
</label>
</div>
</div>,
10
);
items.add('submit', <div className="Form-group">
{Button.component({
className: 'Button Button--primary Button--block',
type: 'submit',
loading: this.loading,
children: app.translator.trans('core.forum.log_in.submit_button')
})}
</div>, -10);
items.add(
'submit',
<div className="Form-group">
{Button.component({
className: 'Button Button--primary Button--block',
type: 'submit',
loading: this.loading,
children: app.translator.trans('core.forum.log_in.submit_button'),
})}
</div>,
-10
);
return items;
}
@ -115,9 +128,9 @@ export default class LogInModal extends Modal<LogInModalProps> {
app.forum.attribute('allowSignUp') && (
<p className="LogInModal-signUp">
{app.translator.trans('core.forum.log_in.sign_up_text', {a: <a onclick={this.signUp.bind(this)}/> })}
{app.translator.trans('core.forum.log_in.sign_up_text', { a: <a onclick={this.signUp.bind(this)} /> })}
</p>
)
),
];
}
@ -129,7 +142,7 @@ export default class LogInModal extends Modal<LogInModalProps> {
*/
forgotPassword() {
const email = this.identification();
const props = email.indexOf('@') !== -1 ? {email} : undefined;
const props = email.indexOf('@') !== -1 ? { email } : undefined;
app.modal.show(new ForgotPasswordModal(props));
}
@ -141,7 +154,7 @@ export default class LogInModal extends Modal<LogInModalProps> {
* @public
*/
signUp() {
const props = {password: this.password()};
const props = { password: this.password() };
const identification = this.identification();
props[identification.indexOf('@') !== -1 ? 'email' : 'username'] = identification;
@ -163,11 +176,9 @@ export default class LogInModal extends Modal<LogInModalProps> {
const password = this.password();
const remember = this.remember();
app.session.login({identification, password, remember}, {errorHandler: this.onerror.bind(this)})
.then(
() => window.location.reload(),
this.loaded.bind(this)
);
app.session
.login({ identification, password, remember }, { errorHandler: this.onerror.bind(this) })
.then(() => window.location.reload(), this.loaded.bind(this));
}
onerror(error) {

View File

@ -10,191 +10,198 @@ import Discussion from '../../common/models/Discussion';
* notifications, grouped by discussion.
*/
export default class NotificationList extends Component {
/**
* Whether or not the notifications are loading.
*/
loading: boolean = false;
/**
* Whether or not the notifications are loading.
*/
loading: boolean = false;
/**
* Whether or not there are more results that can be loaded.
*/
moreResults: boolean = false;
/**
* Whether or not there are more results that can be loaded.
*/
moreResults: boolean = false;
private $scrollParent: ZeptoCollection;
private scrollHandler: () => void;
private $scrollParent: ZeptoCollection;
private scrollHandler: () => void;
view() {
const pages = app.cache.notifications || [];
view() {
const pages = app.cache.notifications || [];
return (
<div className="NotificationList">
<div className="NotificationList-header">
<div className="App-primaryControl">
{Button.component({
className: 'Button Button--icon Button--link',
icon: 'fas fa-check',
title: app.translator.transText('core.forum.notifications.mark_all_as_read_tooltip'),
onclick: this.markAllAsRead.bind(this)
})}
</div>
return (
<div className="NotificationList">
<div className="NotificationList-header">
<div className="App-primaryControl">
{Button.component({
className: 'Button Button--icon Button--link',
icon: 'fas fa-check',
title: app.translator.transText('core.forum.notifications.mark_all_as_read_tooltip'),
onclick: this.markAllAsRead.bind(this),
})}
</div>
<h4 className="App-titleControl App-titleControl--text">{app.translator.trans('core.forum.notifications.title')}</h4>
</div>
<div className="NotificationList-content">
{pages.length ? pages.map(notifications => {
const groups = [];
const discussions = {};
notifications.forEach(notification => {
const subject = notification.subject();
if (typeof subject === 'undefined') return;
// Get the discussion that this notification is related to. If it's not
// directly related to a discussion, it may be related to a post or
// other entity which is related to a discussion.
let discussion: any = false;
if (subject instanceof Discussion) discussion = subject;
else if (subject && subject.discussion) discussion = subject.discussion();
// If the notification is not related to a discussion directly or
// indirectly, then we will assign it to a neutral group.
const key = discussion ? discussion.id() : 0;
discussions[key] = discussions[key] || {discussion: discussion, notifications: []};
discussions[key].notifications.push(notification);
if (groups.indexOf(discussions[key]) === -1) {
groups.push(discussions[key]);
}
});
return groups.map(group => {
const badges = group.discussion && group.discussion.badges().toArray();
return (
<div className="NotificationGroup">
{group.discussion
? (
<m.route.Link className="NotificationGroup-header"
href={app.route.discussion(group.discussion)}>
{badges && badges.length ? <ul className="NotificationGroup-badges badges">{listItems(badges)}</ul> : ''}
{group.discussion.title()}
</m.route.Link>
) : (
<div className="NotificationGroup-header">
{app.forum.attribute('title')}
</div>
)}
<ul className="NotificationGroup-content">
{group.notifications.map(notification => {
const NotificationComponent = app.notificationComponents[notification.contentType()];
return NotificationComponent ? <li>{NotificationComponent.component({notification})}</li> : '';
})}
</ul>
<h4 className="App-titleControl App-titleControl--text">{app.translator.trans('core.forum.notifications.title')}</h4>
</div>
);
});
}) : ''}
{this.loading
? <LoadingIndicator className="LoadingIndicator--block" />
: (pages.length ? '' : <div className="NotificationList-empty">{app.translator.trans('core.forum.notifications.empty_text')}</div>)}
</div>
</div>
);
}
oncreate(vnode) {
super.oncreate(vnode);
<div className="NotificationList-content">
{pages.length
? pages.map(notifications => {
const groups = [];
const discussions = {};
const $notifications = this.$('.NotificationList-content');
const $scrollParent = this.$scrollParent = $notifications.css('overflow') === 'auto' ? $notifications : $(window);
notifications.forEach(notification => {
const subject = notification.subject();
this.scrollHandler = () => {
const scrollTop = $scrollParent.scrollTop();
const viewportHeight = $scrollParent.height();
const contentTop = $scrollParent === $notifications ? 0 : $notifications.offset().top;
const contentHeight = $notifications[0].scrollHeight;
if (typeof subject === 'undefined') return;
// Get the discussion that this notification is related to. If it's not
// directly related to a discussion, it may be related to a post or
// other entity which is related to a discussion.
let discussion: any = false;
if (subject instanceof Discussion) discussion = subject;
else if (subject && subject.discussion) discussion = subject.discussion();
// If the notification is not related to a discussion directly or
// indirectly, then we will assign it to a neutral group.
const key = discussion ? discussion.id() : 0;
discussions[key] = discussions[key] || { discussion: discussion, notifications: [] };
discussions[key].notifications.push(notification);
if (groups.indexOf(discussions[key]) === -1) {
groups.push(discussions[key]);
}
});
return groups.map(group => {
const badges = group.discussion && group.discussion.badges().toArray();
return (
<div className="NotificationGroup">
{group.discussion ? (
<m.route.Link className="NotificationGroup-header" href={app.route.discussion(group.discussion)}>
{badges && badges.length ? (
<ul className="NotificationGroup-badges badges">{listItems(badges)}</ul>
) : (
''
)}
{group.discussion.title()}
</m.route.Link>
) : (
<div className="NotificationGroup-header">{app.forum.attribute('title')}</div>
)}
<ul className="NotificationGroup-content">
{group.notifications.map(notification => {
const NotificationComponent = app.notificationComponents[notification.contentType()];
return NotificationComponent ? <li>{NotificationComponent.component({ notification })}</li> : '';
})}
</ul>
</div>
);
});
})
: ''}
{this.loading ? (
<LoadingIndicator className="LoadingIndicator--block" />
) : pages.length ? (
''
) : (
<div className="NotificationList-empty">{app.translator.trans('core.forum.notifications.empty_text')}</div>
)}
</div>
</div>
);
}
oncreate(vnode) {
super.oncreate(vnode);
const $notifications = this.$('.NotificationList-content');
const $scrollParent = (this.$scrollParent = $notifications.css('overflow') === 'auto' ? $notifications : $(window));
this.scrollHandler = () => {
const scrollTop = $scrollParent.scrollTop();
const viewportHeight = $scrollParent.height();
const contentTop = $scrollParent === $notifications ? 0 : $notifications.offset().top;
const contentHeight = $notifications[0].scrollHeight;
if (this.moreResults && !this.loading && scrollTop + viewportHeight >= contentTop + contentHeight) {
this.loadMore();
}
};
$scrollParent.on('scroll', this.scrollHandler);
}
onremove(vnode) {
super.onremove(vnode);
this.$scrollParent.off('scroll', this.scrollHandler);
}
/**
* Load notifications into the application's cache if they haven't already
* been loaded.
*/
load() {
if (app.session.user.newNotificationCount()) {
delete app.cache.notifications;
}
if (app.cache.notifications) {
return;
}
app.session.user.pushAttributes({ newNotificationCount: 0 });
if (this.moreResults && !this.loading && scrollTop + viewportHeight >= contentTop + contentHeight) {
this.loadMore();
}
};
$scrollParent.on('scroll', this.scrollHandler);
}
onremove(vnode) {
super.onremove(vnode);
this.$scrollParent.off('scroll', this.scrollHandler);
}
/**
* Load notifications into the application's cache if they haven't already
* been loaded.
*/
load() {
if (app.session.user.newNotificationCount()) {
delete app.cache.notifications;
}
if (app.cache.notifications) {
return;
}
app.session.user.pushAttributes({newNotificationCount: 0});
this.loadMore();
}
/**
* Load the next page of notification results.
*/
loadMore() {
this.loading = true;
m.redraw();
const params = app.cache.notifications ? {page: {offset: app.cache.notifications.length * 10}} : null;
return app.store.find<Notification>('notifications', params)
.then(this.parseResults.bind(this))
.catch(() => {})
.then(() => {
this.loading = false;
/**
* Load the next page of notification results.
*/
loadMore() {
this.loading = true;
m.redraw();
});
}
/**
* Parse results and append them to the notification list.
*/
parseResults(results: Notification[]): Notification[] {
app.cache.notifications = app.cache.notifications || [];
const params = app.cache.notifications ? { page: { offset: app.cache.notifications.length * 10 } } : null;
if (results.length) app.cache.notifications.push(results);
return app.store
.find<Notification>('notifications', params)
.then(this.parseResults.bind(this))
.catch(() => {})
.then(() => {
this.loading = false;
m.redraw();
});
}
this.moreResults = !!results.payload.links.next;
/**
* Parse results and append them to the notification list.
*/
parseResults(results: Notification[]): Notification[] {
app.cache.notifications = app.cache.notifications || [];
return results;
}
if (results.length) app.cache.notifications.push(results);
/**
* Mark all of the notifications as read.
*/
markAllAsRead() {
if (!app.cache.notifications) return;
this.moreResults = !!results.payload.links.next;
app.session.user.pushAttributes({unreadNotificationCount: 0});
return results;
}
app.cache.notifications.forEach(notifications => {
notifications.forEach(notification => notification.pushAttributes({isRead: true}))
});
/**
* Mark all of the notifications as read.
*/
markAllAsRead() {
if (!app.cache.notifications) return;
app.request({
url: `${app.forum.attribute('apiUrl')}/notifications/read`,
method: 'POST'
});
}
app.session.user.pushAttributes({ unreadNotificationCount: 0 });
app.cache.notifications.forEach(notifications => {
notifications.forEach(notification => notification.pushAttributes({ isRead: true }));
});
app.request({
url: `${app.forum.attribute('apiUrl')}/notifications/read`,
method: 'POST',
});
}
}

View File

@ -3,71 +3,71 @@ import icon from '../../common/helpers/icon';
import NotificationList from './NotificationList';
export default class NotificationsDropdown extends Dropdown {
list = new NotificationList();
list = new NotificationList();
static initProps(props) {
props.className = props.className || 'NotificationsDropdown';
props.buttonClassName = props.buttonClassName || 'Button Button--flat';
props.menuClassName = props.menuClassName || 'Dropdown-menu--right';
props.label = props.label || app.translator.trans('core.forum.notifications.tooltip');
props.icon = props.icon || 'fas fa-bell';
static initProps(props) {
props.className = props.className || 'NotificationsDropdown';
props.buttonClassName = props.buttonClassName || 'Button Button--flat';
props.menuClassName = props.menuClassName || 'Dropdown-menu--right';
props.label = props.label || app.translator.trans('core.forum.notifications.tooltip');
props.icon = props.icon || 'fas fa-bell';
super.initProps(props);
}
getButton() {
const newNotifications = this.getNewCount();
const vdom = super.getButton();
vdom.attrs.title = this.props.label;
vdom.attrs.className += (newNotifications ? ' new' : '');
vdom.attrs.onclick = this.onclick.bind(this);
return vdom;
}
getButtonContent() {
const unread = this.getUnreadCount();
return [
icon(this.props.icon, {className: 'Button-icon'}),
unread ? <span className="NotificationsDropdown-unread">{unread}</span> : '',
<span className="Button-label">{this.props.label}</span>
];
}
getMenu() {
return (
<div className={'Dropdown-menu ' + this.props.menuClassName} onclick={this.menuClick.bind(this)}>
{this.showing ? m(this.list) : ''}
</div>
);
}
onclick() {
if (app.drawer.isOpen()) {
this.goToRoute();
} else {
this.list.load();
super.initProps(props);
}
}
goToRoute() {
m.route.set(app.route('notifications'));
}
getButton() {
const newNotifications = this.getNewCount();
const vdom = super.getButton();
getUnreadCount() {
return app.session.user.unreadNotificationCount();
}
vdom.attrs.title = this.props.label;
getNewCount() {
return app.session.user.newNotificationCount();
}
vdom.attrs.className += newNotifications ? ' new' : '';
vdom.attrs.onclick = this.onclick.bind(this);
menuClick(e) {
// Don't close the notifications dropdown if the user is opening a link in a
// new tab or window.
if (e.shiftKey || e.metaKey || e.ctrlKey || e.which === 2) e.stopPropagation();
}
return vdom;
}
getButtonContent() {
const unread = this.getUnreadCount();
return [
icon(this.props.icon, { className: 'Button-icon' }),
unread ? <span className="NotificationsDropdown-unread">{unread}</span> : '',
<span className="Button-label">{this.props.label}</span>,
];
}
getMenu() {
return (
<div className={'Dropdown-menu ' + this.props.menuClassName} onclick={this.menuClick.bind(this)}>
{this.showing ? m(this.list) : ''}
</div>
);
}
onclick() {
if (app.drawer.isOpen()) {
this.goToRoute();
} else {
this.list.load();
}
}
goToRoute() {
m.route.set(app.route('notifications'));
}
getUnreadCount() {
return app.session.user.unreadNotificationCount();
}
getNewCount() {
return app.session.user.newNotificationCount();
}
menuClick(e) {
// Don't close the notifications dropdown if the user is opening a link in a
// new tab or window.
if (e.shiftKey || e.metaKey || e.ctrlKey || e.which === 2) e.stopPropagation();
}
}

View File

@ -4,32 +4,32 @@ import Component from '../../common/Component';
* The `Page` component
*/
export default abstract class Page extends Component {
/**
* A class name to apply to the body while the route is active.
*/
bodyClass: string = '';
/**
* A class name to apply to the body while the route is active.
*/
bodyClass: string = '';
oninit(vnode) {
super.oninit(vnode);
oninit(vnode) {
super.oninit(vnode);
if (this.bodyClass) {
$('#app').addClass(this.bodyClass);
if (this.bodyClass) {
$('#app').addClass(this.bodyClass);
}
}
}
oncreate(vnode) {
super.oncreate(vnode);
oncreate(vnode) {
super.oncreate(vnode);
app.previous = app.current;
app.current = this;
app.previous = app.current;
app.current = this;
app.drawer.hide();
app.modal.close();
}
app.drawer.hide();
app.modal.close();
}
onremove(vnode) {
super.onremove(vnode);
onremove(vnode) {
super.onremove(vnode);
$('#app').removeClass(this.bodyClass);
}
$('#app').removeClass(this.bodyClass);
}
}

View File

@ -1,13 +1,13 @@
import Component, {ComponentProps} from '../../common/Component';
import Component, { ComponentProps } from '../../common/Component';
import Dropdown from '../../common/components/Dropdown';
import PostControls from '../utils/PostControls';
import listItems from '../../common/helpers/listItems';
import ItemList from '../../common/utils/ItemList';
import SubtreeRetainer from "../../common/utils/SubtreeRetainer";
import SubtreeRetainer from '../../common/utils/SubtreeRetainer';
import PostModel from '../../common/models/Post';
export interface PostProps extends ComponentProps {
post: PostModel
post: PostModel;
}
/**
@ -53,20 +53,27 @@ export default class Post<T extends PostProps = PostProps> extends Component<Pos
<aside className="Post-actions">
<ul>
{listItems(this.actionItems().toArray())}
{controls.length ? <li>
<Dropdown
className="Post-controls"
buttonClassName="Button Button--icon Button--flat"
menuClassName="Dropdown-menu--right"
icon="fas fa-ellipsis-h"
onshow={() => this.$('.Post-actions').addClass('open')}
onhide={() => this.$('.Post-actions').removeClass('open')}>
{controls}
</Dropdown>
</li> : ''}
{controls.length ? (
<li>
<Dropdown
className="Post-controls"
buttonClassName="Button Button--icon Button--flat"
menuClassName="Dropdown-menu--right"
icon="fas fa-ellipsis-h"
onshow={() => this.$('.Post-actions').addClass('open')}
onhide={() => this.$('.Post-actions').removeClass('open')}
>
{controls}
</Dropdown>
</li>
) : (
''
)}
</ul>
</aside>
<footer className="Post-footer"><ul>{listItems(this.footerItems().toArray())}</ul></footer>
<footer className="Post-footer">
<ul>{listItems(this.footerItems().toArray())}</ul>
</footer>
</div>
);
</article>

View File

@ -4,8 +4,8 @@ import avatar from '../../common/helpers/avatar';
import username from '../../common/helpers/username';
import userOnline from '../../common/helpers/userOnline';
import listItems from '../../common/helpers/listItems';
import {PostProps} from "./Post";
import LinkButton from "../../common/components/LinkButton";
import { PostProps } from './Post';
import LinkButton from '../../common/components/LinkButton';
/**
* The `PostUser` component shows the avatar and username of a post's author.
@ -23,7 +23,9 @@ export default class PostUser extends Component<PostProps> {
if (!user) {
return (
<div className="PostUser">
<h3>{avatar(user, {className: 'PostUser-avatar'})} {username(user)}</h3>
<h3>
{avatar(user, { className: 'PostUser-avatar' })} {username(user)}
</h3>
</div>
);
}
@ -34,7 +36,7 @@ export default class PostUser extends Component<PostProps> {
card = UserCard.component({
user,
className: 'UserCard--popover',
controlsButtonClassName: 'Button Button--icon Button--flat'
controlsButtonClassName: 'Button Button--icon Button--flat',
});
}
@ -42,14 +44,12 @@ export default class PostUser extends Component<PostProps> {
<div className="PostUser">
<h3>
<LinkButton href={app.route.user(user)}>
{avatar(user, {className: 'PostUser-avatar'})}
{avatar(user, { className: 'PostUser-avatar' })}
{userOnline(user)}
{username(user)}
</LinkButton>
</h3>
<ul className="PostUser-badges badges">
{listItems(user.badges().toArray())}
</ul>
<ul className="PostUser-badges badges">{listItems(user.badges().toArray())}</ul>
{card}
</div>
);
@ -86,7 +86,8 @@ export default class PostUser extends Component<PostProps> {
* Hide the user card.
*/
hideCard() {
this.$('.UserCard').removeClass('in')
this.$('.UserCard')
.removeClass('in')
.one('transitionend webkitTransitionEnd oTransitionEnd', () => {
this.cardVisible = false;
m.redraw();

View File

@ -10,139 +10,139 @@ import Post from '../../common/models/Post';
* profile.
*/
export default class PostsUserPage extends UserPage {
/**
* Whether or not the activity feed is currently loading.
*/
loading = true;
/**
* Whether or not the activity feed is currently loading.
*/
loading = true;
/**
* Whether or not there are any more activity items that can be loaded.
*/
moreResults = false;
/**
* Whether or not there are any more activity items that can be loaded.
*/
moreResults = false;
/**
* The Post models in the feed.
*/
posts: Post[] = [];
/**
* The Post models in the feed.
*/
posts: Post[] = [];
/**
* The number of activity items to load per request.
*/
loadLimit = 20;
/**
* The number of activity items to load per request.
*/
loadLimit = 20;
oninit(vnode) {
super.oninit(vnode);
oninit(vnode) {
super.oninit(vnode);
this.loadUser(vnode.attrs.username);
}
onupdate(vnode) {
super.onupdate(vnode);
this.loadUser(vnode.attrs.username);
}
content() {
if (this.posts.length === 0 && ! this.loading) {
return (
<div className="PostsUserPage">
<Placeholder text={app.translator.trans('core.forum.user.posts_empty_text')} />
</div>
);
this.loadUser(vnode.attrs.username);
}
let footer;
onupdate(vnode) {
super.onupdate(vnode);
if (this.loading) {
footer = LoadingIndicator.component();
} else if (this.moreResults) {
footer = (
<div className="PostsUserPage-loadMore">
{Button.component({
children: app.translator.trans('core.forum.user.posts_load_more_button'),
className: 'Button',
onclick: this.loadMore.bind(this)
})}
</div>
);
this.loadUser(vnode.attrs.username);
}
return (
<div className="PostsUserPage">
<ul className="PostsUserPage-list">
{this.posts.map(post => (
<li>
<div className="PostsUserPage-discussion">
{app.translator.trans('core.forum.user.in_discussion_text', {discussion: <m.route.Link href={app.route.post(post)}>{post.discussion().title()}</m.route.Link>})}
</div>
{CommentPost.component({post})}
</li>
))}
</ul>
<div className="PostsUserPage-loadMore">
{footer}
</div>
</div>
);
}
content() {
if (this.posts.length === 0 && !this.loading) {
return (
<div className="PostsUserPage">
<Placeholder text={app.translator.trans('core.forum.user.posts_empty_text')} />
</div>
);
}
/**
* Initialize the component with a user, and trigger the loading of their
* activity feed.
*/
show(user) {
super.show(user);
let footer;
this.refresh();
}
if (this.loading) {
footer = LoadingIndicator.component();
} else if (this.moreResults) {
footer = (
<div className="PostsUserPage-loadMore">
{Button.component({
children: app.translator.trans('core.forum.user.posts_load_more_button'),
className: 'Button',
onclick: this.loadMore.bind(this),
})}
</div>
);
}
/**
* Clear and reload the user's activity feed.
*/
refresh() {
this.loading = true;
this.posts = [];
return (
<div className="PostsUserPage">
<ul className="PostsUserPage-list">
{this.posts.map(post => (
<li>
<div className="PostsUserPage-discussion">
{app.translator.trans('core.forum.user.in_discussion_text', {
discussion: <m.route.Link href={app.route.post(post)}>{post.discussion().title()}</m.route.Link>,
})}
</div>
{CommentPost.component({ post })}
</li>
))}
</ul>
<div className="PostsUserPage-loadMore">{footer}</div>
</div>
);
}
m.redraw();
/**
* Initialize the component with a user, and trigger the loading of their
* activity feed.
*/
show(user) {
super.show(user);
this.loadResults().then(this.parseResults.bind(this));
}
this.refresh();
}
/**
* Load a new page of the user's activity feed.
*
* @param offset The position to start getting results from.
*/
protected loadResults(offset?: number): Promise<Post[]> {
return app.store.find<Post>('posts', {
filter: {
user: this.user.id(),
type: 'comment'
},
page: {offset, limit: this.loadLimit},
sort: '-createdAt'
});
}
/**
* Clear and reload the user's activity feed.
*/
refresh() {
this.loading = true;
this.posts = [];
/**
* Load the next page of results.
*/
loadMore() {
this.loading = true;
this.loadResults(this.posts.length).then(this.parseResults.bind(this));
}
m.redraw();
/**
* Parse results and append them to the activity feed.
*/
parseResults(results: Post[]): Post[] {
this.loading = false;
this.loadResults().then(this.parseResults.bind(this));
}
[].push.apply(this.posts, results);
/**
* Load a new page of the user's activity feed.
*
* @param offset The position to start getting results from.
*/
protected loadResults(offset?: number): Promise<Post[]> {
return app.store.find<Post>('posts', {
filter: {
user: this.user.id(),
type: 'comment',
},
page: { offset, limit: this.loadLimit },
sort: '-createdAt',
});
}
this.moreResults = results.length >= this.loadLimit;
m.redraw();
/**
* Load the next page of results.
*/
loadMore() {
this.loading = true;
this.loadResults(this.posts.length).then(this.parseResults.bind(this));
}
return results;
}
/**
* Parse results and append them to the activity feed.
*/
parseResults(results: Post[]): Post[] {
this.loading = false;
[].push.apply(this.posts, results);
this.moreResults = results.length >= this.loadLimit;
m.redraw();
return results;
}
}

View File

@ -19,284 +19,298 @@ import Stream from 'mithril/stream';
* `clearSearch` method on the controller.
*/
export default class Search extends Component {
/**
* The value of the search input.
*/
value: Stream<string> = m.prop('');
/**
* The value of the search input.
*/
value: Stream<string> = m.prop('');
/**
* Whether or not the search input has focus.
*/
hasFocus: boolean = false;
/**
* Whether or not the search input has focus.
*/
hasFocus: boolean = false;
/**
* An array of SearchSources.
*/
sources: SearchSource[] = null;
/**
* An array of SearchSources.
*/
sources: SearchSource[] = null;
/**
* The number of sources that are still loading results.
*/
loadingSources = 0;
/**
* The number of sources that are still loading results.
*/
loadingSources = 0;
/**
* A list of queries that have been searched for.
*/
searched: string[] = [];
/**
* A list of queries that have been searched for.
*/
searched: string[] = [];
/**
* The index of the currently-selected <li> in the results list. This can be
* a unique string (to account for the fact that an item's position may jump
* around as new results load), but otherwise it will be numeric (the
* sequential position within the list).
*/
index: string|number = 0;
/**
* The index of the currently-selected <li> in the results list. This can be
* a unique string (to account for the fact that an item's position may jump
* around as new results load), but otherwise it will be numeric (the
* sequential position within the list).
*/
index: string | number = 0;
navigator: KeyboardNavigatable;
navigator: KeyboardNavigatable;
searchTimeout: number;
searchTimeout: number;
view() {
const currentSearch = this.getCurrentSearch();
view() {
const currentSearch = this.getCurrentSearch();
// Initialize search input value in the view rather than the constructor so
// that we have access to app.current.
if (typeof this.value() === 'undefined') {
this.value(currentSearch || '');
}
// Initialize search input value in the view rather than the constructor so
// that we have access to app.current.
if (typeof this.value() === 'undefined') {
this.value(currentSearch || '');
}
// Initialize search sources in the view rather than the constructor so
// that we have access to app.forum.
if (!this.sources) {
this.sources = this.sourceItems().toArray();
}
// Initialize search sources in the view rather than the constructor so
// that we have access to app.forum.
if (!this.sources) {
this.sources = this.sourceItems().toArray();
}
// Hide the search view if no sources were loaded
if (!this.sources.length) return <div/>;
// Hide the search view if no sources were loaded
if (!this.sources.length) return <div />;
return (
<div className={'Search ' + classNames({
open: this.value() && this.hasFocus,
focused: this.hasFocus,
active: !!currentSearch,
loading: !!this.loadingSources
})}>
<div className="Search-input">
<input className="FormControl"
type="search"
placeholder={app.translator.transText('core.forum.header.search_placeholder')}
value={this.value()}
oninput={m.withAttr('value', this.value)}
onfocus={() => this.hasFocus = true}
onblur={() => this.hasFocus = false}/>
{this.loadingSources
? LoadingIndicator.component({size: 'tiny', className: 'Button Button--icon Button--link'})
: currentSearch
? <button className="Search-clear Button Button--icon Button--link" onclick={this.clear.bind(this)}>{icon('fas fa-times-circle')}</button>
: ''}
</div>
<ul className="Dropdown-menu Search-results">
{this.value() && this.hasFocus
? this.sources.map(source => source.view(this.value()))
: ''}
</ul>
</div>
);
}
oncreate(vnode) {
super.oncreate(vnode);
// Highlight the item that is currently selected.
this.setIndex(this.getCurrentNumericIndex());
const search = this;
this.$('.Search-results')
.on('mousedown', e => e.preventDefault())
.on('click', () => this.$('input').blur())
// Whenever the mouse is hovered over a search result, highlight it.
.on('mouseenter', '> li:not(.Dropdown-header)', function() {
search.setIndex(
search.selectableItems().index(this)
return (
<div
className={
'Search ' +
classNames({
open: this.value() && this.hasFocus,
focused: this.hasFocus,
active: !!currentSearch,
loading: !!this.loadingSources,
})
}
>
<div className="Search-input">
<input
className="FormControl"
type="search"
placeholder={app.translator.transText('core.forum.header.search_placeholder')}
value={this.value()}
oninput={m.withAttr('value', this.value)}
onfocus={() => (this.hasFocus = true)}
onblur={() => (this.hasFocus = false)}
/>
{this.loadingSources ? (
LoadingIndicator.component({ size: 'tiny', className: 'Button Button--icon Button--link' })
) : currentSearch ? (
<button className="Search-clear Button Button--icon Button--link" onclick={this.clear.bind(this)}>
{icon('fas fa-times-circle')}
</button>
) : (
''
)}
</div>
<ul className="Dropdown-menu Search-results">
{this.value() && this.hasFocus ? this.sources.map(source => source.view(this.value())) : ''}
</ul>
</div>
);
});
}
const $input = this.$('input');
oncreate(vnode) {
super.oncreate(vnode);
this.navigator = new KeyboardNavigatable();
// Highlight the item that is currently selected.
this.setIndex(this.getCurrentNumericIndex());
this.navigator
.onUp(() => this.setIndex(this.getCurrentNumericIndex() - 1, true))
.onDown(() => this.setIndex(this.getCurrentNumericIndex() + 1, true))
.onSelect(this.selectResult.bind(this))
.onCancel(this.clear.bind(this))
.bindTo($input);
const search = this;
// Handle input key events on the search input, triggering results to load.
$input
.on('input focus', function() {
const query = this.value.toLowerCase();
this.$('.Search-results')
.on('mousedown', e => e.preventDefault())
.on('click', () => this.$('input').blur())
if (!query) return;
clearTimeout(search.searchTimeout);
search.searchTimeout = setTimeout(() => {
if (search.searched.indexOf(query) !== -1) return;
if (query.length >= 3) {
search.sources.map(source => {
if (!source.search) return;
search.loadingSources++;
source.search(query).then(() => {
search.loadingSources = Math.max(0, search.loadingSources - 1);
m.redraw();
});
// Whenever the mouse is hovered over a search result, highlight it.
.on('mouseenter', '> li:not(.Dropdown-header)', function() {
search.setIndex(search.selectableItems().index(this));
});
}
search.searched.push(query);
m.redraw();
}, 250);
})
const $input = this.$('input');
.on('focus', function() {
$(this).one('mouseup', e => e.preventDefault()).select();
});
}
this.navigator = new KeyboardNavigatable();
/**
* Get the active search in the app's current controller.
*
* @return {String}
*/
getCurrentSearch() {
return app.current && typeof app.current.searching === 'function' && app.current.searching();
}
this.navigator
.onUp(() => this.setIndex(this.getCurrentNumericIndex() - 1, true))
.onDown(() => this.setIndex(this.getCurrentNumericIndex() + 1, true))
.onSelect(this.selectResult.bind(this))
.onCancel(this.clear.bind(this))
.bindTo($input);
/**
* Navigate to the currently selected search result and close the list.
*/
selectResult() {
clearTimeout(this.searchTimeout);
this.loadingSources = 0;
// Handle input key events on the search input, triggering results to load.
$input
.on('input focus', function() {
const query = this.value.toLowerCase();
if (this.value()) {
m.route.set(this.getItem(this.index).find('a').attr('href'));
} else {
this.clear();
if (!query) return;
clearTimeout(search.searchTimeout);
search.searchTimeout = setTimeout(() => {
if (search.searched.indexOf(query) !== -1) return;
if (query.length >= 3) {
search.sources.map(source => {
if (!source.search) return;
search.loadingSources++;
source.search(query).then(() => {
search.loadingSources = Math.max(0, search.loadingSources - 1);
m.redraw();
});
});
}
search.searched.push(query);
m.redraw();
}, 250);
})
.on('focus', function() {
$(this)
.one('mouseup', e => e.preventDefault())
.select();
});
}
this.$('input').blur();
}
/**
* Clear the search input and the current controller's active search.
*/
clear() {
this.value('');
if (this.getCurrentSearch()) {
app.current.clearSearch();
} else {
m.redraw();
}
}
/**
* Build an item list of SearchSources.
*
* @return {ItemList}
*/
sourceItems() {
const items = new ItemList();
if (app.forum.attribute('canViewDiscussions')) items.add('discussions', new DiscussionsSearchSource());
if (app.forum.attribute('canViewUserList')) items.add('users', new UsersSearchSource());
return items;
}
/**
* Get all of the search result items that are selectable.
*
* @return {jQuery}
*/
selectableItems() {
return this.$('.Search-results > li:not(.Dropdown-header)');
}
/**
* Get the position of the currently selected search result item.
*
* @return {Integer}
*/
getCurrentNumericIndex() {
return this.selectableItems().index(
this.getItem(this.index)
);
}
/**
* Get the <li> in the search results with the given index (numeric or named).
*
* @param {String} index
* @return {DOMElement}
*/
getItem(index) {
const $items = this.selectableItems();
let $item = $items.filter(`[data-index="${index}"]`);
if (!$item.length) {
$item = $items.eq(index);
/**
* Get the active search in the app's current controller.
*
* @return {String}
*/
getCurrentSearch() {
return app.current && typeof app.current.searching === 'function' && app.current.searching();
}
return $item;
}
/**
* Navigate to the currently selected search result and close the list.
*/
selectResult() {
clearTimeout(this.searchTimeout);
this.loadingSources = 0;
/**
* Set the currently-selected search result item to the one with the given
* index.
*
* @param index
* @param scrollToItem Whether or not to scroll the dropdown so that
* the item is in view.
*/
setIndex(index: number, scrollToItem?: boolean) {
const $items = this.selectableItems();
const $dropdown = $items.parent();
if (this.value()) {
m.route.set(
this.getItem(this.index)
.find('a')
.attr('href')
);
} else {
this.clear();
}
let fixedIndex = index;
if (index < 0) {
fixedIndex = $items.length - 1;
} else if (index >= $items.length) {
fixedIndex = 0;
this.$('input').blur();
}
const $item = $items.removeClass('active').eq(fixedIndex).addClass('active');
/**
* Clear the search input and the current controller's active search.
*/
clear() {
this.value('');
this.index = $item.attr('data-index') || fixedIndex;
if (scrollToItem) {
const dropdownScroll = $dropdown.scrollTop();
const dropdownTop = $dropdown.offset().top;
const dropdownBottom = dropdownTop + $dropdown.outerHeight();
const itemTop = $item.offset().top;
const itemBottom = itemTop + $item.outerHeight();
let scrollTop;
if (itemTop < dropdownTop) {
scrollTop = dropdownScroll - dropdownTop + itemTop - parseInt($dropdown.css('padding-top'), 10);
} else if (itemBottom > dropdownBottom) {
scrollTop = dropdownScroll - dropdownBottom + itemBottom + parseInt($dropdown.css('padding-bottom'), 10);
}
if (typeof scrollTop !== 'undefined') {
$dropdown.animate({scrollTop}, 100);
}
if (this.getCurrentSearch()) {
app.current.clearSearch();
} else {
m.redraw();
}
}
/**
* Build an item list of SearchSources.
*
* @return {ItemList}
*/
sourceItems() {
const items = new ItemList();
if (app.forum.attribute('canViewDiscussions')) items.add('discussions', new DiscussionsSearchSource());
if (app.forum.attribute('canViewUserList')) items.add('users', new UsersSearchSource());
return items;
}
/**
* Get all of the search result items that are selectable.
*
* @return {jQuery}
*/
selectableItems() {
return this.$('.Search-results > li:not(.Dropdown-header)');
}
/**
* Get the position of the currently selected search result item.
*
* @return {Integer}
*/
getCurrentNumericIndex() {
return this.selectableItems().index(this.getItem(this.index));
}
/**
* Get the <li> in the search results with the given index (numeric or named).
*
* @param {String} index
* @return {DOMElement}
*/
getItem(index) {
const $items = this.selectableItems();
let $item = $items.filter(`[data-index="${index}"]`);
if (!$item.length) {
$item = $items.eq(index);
}
return $item;
}
/**
* Set the currently-selected search result item to the one with the given
* index.
*
* @param index
* @param scrollToItem Whether or not to scroll the dropdown so that
* the item is in view.
*/
setIndex(index: number, scrollToItem?: boolean) {
const $items = this.selectableItems();
const $dropdown = $items.parent();
let fixedIndex = index;
if (index < 0) {
fixedIndex = $items.length - 1;
} else if (index >= $items.length) {
fixedIndex = 0;
}
const $item = $items
.removeClass('active')
.eq(fixedIndex)
.addClass('active');
this.index = $item.attr('data-index') || fixedIndex;
if (scrollToItem) {
const dropdownScroll = $dropdown.scrollTop();
const dropdownTop = $dropdown.offset().top;
const dropdownBottom = dropdownTop + $dropdown.outerHeight();
const itemTop = $item.offset().top;
const itemBottom = itemTop + $item.outerHeight();
let scrollTop;
if (itemTop < dropdownTop) {
scrollTop = dropdownScroll - dropdownTop + itemTop - parseInt($dropdown.css('padding-top'), 10);
} else if (itemBottom > dropdownBottom) {
scrollTop = dropdownScroll - dropdownBottom + itemBottom + parseInt($dropdown.css('padding-bottom'), 10);
}
if (typeof scrollTop !== 'undefined') {
$dropdown.animate({ scrollTop }, 100);
}
}
}
}
}

View File

@ -1,5 +1,5 @@
export default abstract class SearchSource {
abstract view(vnode: string);
abstract view(vnode: string);
abstract search(query: string);
abstract search(query: string);
}

View File

@ -12,77 +12,78 @@ import Group from '../../common/models/Group';
* avatar/name, with a dropdown of session controls.
*/
export default class SessionDropdown extends Dropdown {
static initProps(props) {
super.initProps(props);
static initProps(props) {
super.initProps(props);
props.className = 'SessionDropdown';
props.buttonClassName = 'Button Button--user Button--flat';
props.menuClassName = 'Dropdown-menu--right';
}
view() {
this.props.children = this.items().toArray();
return super.view();
}
getButtonContent() {
const user = app.session.user;
return [
avatar(user), ' ',
<span className="Button-label">{username(user)}</span>
];
}
/**
* Build an item list for the contents of the dropdown menu.
*/
items(): ItemList {
const items = new ItemList();
const user = app.session.user;
items.add('profile',
LinkButton.component({
icon: 'fas fa-user',
children: app.translator.trans('core.forum.header.profile_button'),
href: app.route.user(user)
}),
100
);
items.add('settings',
LinkButton.component({
icon: 'fas fa-cog',
children: app.translator.trans('core.forum.header.settings_button'),
href: app.route('settings')
}),
50
);
if (app.forum.attribute('adminUrl')) {
items.add('administration',
LinkButton.component({
icon: 'fas fa-wrench',
children: app.translator.trans('core.forum.header.admin_button'),
href: app.forum.attribute('adminUrl'),
target: '_blank',
}),
0
);
props.className = 'SessionDropdown';
props.buttonClassName = 'Button Button--user Button--flat';
props.menuClassName = 'Dropdown-menu--right';
}
items.add('separator', Separator.component(), -90);
view() {
this.props.children = this.items().toArray();
items.add('logOut',
Button.component({
icon: 'fas fa-sign-out-alt',
children: app.translator.trans('core.forum.header.log_out_button'),
onclick: app.session.logout.bind(app.session)
}),
-100
);
return super.view();
}
return items;
}
getButtonContent() {
const user = app.session.user;
return [avatar(user), ' ', <span className="Button-label">{username(user)}</span>];
}
/**
* Build an item list for the contents of the dropdown menu.
*/
items(): ItemList {
const items = new ItemList();
const user = app.session.user;
items.add(
'profile',
LinkButton.component({
icon: 'fas fa-user',
children: app.translator.trans('core.forum.header.profile_button'),
href: app.route.user(user),
}),
100
);
items.add(
'settings',
LinkButton.component({
icon: 'fas fa-cog',
children: app.translator.trans('core.forum.header.settings_button'),
href: app.route('settings'),
}),
50
);
if (app.forum.attribute('adminUrl')) {
items.add(
'administration',
LinkButton.component({
icon: 'fas fa-wrench',
children: app.translator.trans('core.forum.header.admin_button'),
href: app.forum.attribute('adminUrl'),
target: '_blank',
}),
0
);
}
items.add('separator', Separator.component(), -90);
items.add(
'logOut',
Button.component({
icon: 'fas fa-sign-out-alt',
children: app.translator.trans('core.forum.header.log_out_button'),
onclick: app.session.logout.bind(app.session),
}),
-100
);
return items;
}
}

View File

@ -1,4 +1,4 @@
import Component, {ComponentProps} from '../../common/Component';
import Component, { ComponentProps } from '../../common/Component';
import humanTime from '../../common/utils/humanTime';
import ItemList from '../../common/utils/ItemList';
import UserControls from '../utils/UserControls';
@ -8,12 +8,12 @@ import icon from '../../common/helpers/icon';
import Dropdown from '../../common/components/Dropdown';
import AvatarEditor from './AvatarEditor';
import listItems from '../../common/helpers/listItems';
import User from "../../common/models/User";
import User from '../../common/models/User';
export interface UserCardProps extends ComponentProps {
user: User;
editable: boolean;
controlsButtonClassName: string;
user: User;
editable: boolean;
controlsButtonClassName: string;
}
/**
@ -22,79 +22,74 @@ export interface UserCardProps extends ComponentProps {
* post author.
*/
export default class UserCard extends Component<UserCardProps> {
view() {
const user = this.props.user;
const controls = UserControls.controls(user, this).toArray();
const color = user.color();
const badges = user.badges().toArray();
view() {
const user = this.props.user;
const controls = UserControls.controls(user, this).toArray();
const color = user.color();
const badges = user.badges().toArray();
return (
<div className={'UserCard ' + (this.props.className || '')}
style={color ? {backgroundColor: color} : ''}>
<div className="darkenBackground">
return (
<div className={'UserCard ' + (this.props.className || '')} style={color ? { backgroundColor: color } : ''}>
<div className="darkenBackground">
<div className="container">
{controls.length
? Dropdown.component({
children: controls,
className: 'UserCard-controls App-primaryControl',
menuClassName: 'Dropdown-menu--right',
buttonClassName: this.props.controlsButtonClassName,
label: app.translator.trans('core.forum.user_controls.button'),
icon: 'fas fa-ellipsis-v',
})
: ''}
<div className="container">
{controls.length ? Dropdown.component({
children: controls,
className: 'UserCard-controls App-primaryControl',
menuClassName: 'Dropdown-menu--right',
buttonClassName: this.props.controlsButtonClassName,
label: app.translator.trans('core.forum.user_controls.button'),
icon: 'fas fa-ellipsis-v'
}) : ''}
<div className="UserCard-profile">
<h2 className="UserCard-identity">
{this.props.editable ? (
[AvatarEditor.component({ user, className: 'UserCard-avatar' }), username(user)]
) : (
<m.route.Link href={app.route.user(user)}>
<div className="UserCard-avatar">{avatar(user)}</div>
{username(user)}
</m.route.Link>
)}
</h2>
<div className="UserCard-profile">
<h2 className="UserCard-identity">
{this.props.editable
? [AvatarEditor.component({user, className: 'UserCard-avatar'}), username(user)]
: (
<m.route.Link href={app.route.user(user)}>
<div className="UserCard-avatar">{avatar(user)}</div>
{username(user)}
</m.route.Link>
)}
</h2>
{badges.length ? <ul className="UserCard-badges badges">{listItems(badges)}</ul> : ''}
{badges.length ? (
<ul className="UserCard-badges badges">
{listItems(badges)}
</ul>
) : ''}
<ul className="UserCard-info">
{listItems(this.infoItems().toArray())}
</ul>
<ul className="UserCard-info">{listItems(this.infoItems().toArray())}</ul>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
);
}
/**
* Build an item list of tidbits of info to show on this user's profile.
*
* @return {ItemList}
*/
infoItems() {
const items = new ItemList();
const user = this.props.user;
const lastSeenAt = user.lastSeenAt();
if (lastSeenAt) {
const online = user.isOnline();
items.add('lastSeen', (
<span className={'UserCard-lastSeen' + (online ? ' online' : '')}>
{online
? [icon('fas fa-circle'), ' ', app.translator.trans('core.forum.user.online_text')]
: [icon('far fa-clock'), ' ', humanTime(lastSeenAt)]}
</span>
));
);
}
items.add('joined', app.translator.trans('core.forum.user.joined_date_text', {ago: humanTime(user.joinTime())}));
/**
* Build an item list of tidbits of info to show on this user's profile.
*
* @return {ItemList}
*/
infoItems() {
const items = new ItemList();
const user = this.props.user;
const lastSeenAt = user.lastSeenAt();
return items;
}
if (lastSeenAt) {
const online = user.isOnline();
items.add(
'lastSeen',
<span className={'UserCard-lastSeen' + (online ? ' online' : '')}>
{online
? [icon('fas fa-circle'), ' ', app.translator.trans('core.forum.user.online_text')]
: [icon('far fa-clock'), ' ', humanTime(lastSeenAt)]}
</span>
);
}
items.add('joined', app.translator.trans('core.forum.user.joined_date_text', { ago: humanTime(user.joinTime()) }));
return items;
}
}

View File

@ -16,143 +16,145 @@ import User from '../../common/models/User';
* examples.
*/
export default abstract class UserPage extends Page {
/**
* The user this page is for.
*/
user: User;
bodyClass: string = 'App--user';
/**
* The user this page is for.
*/
user: User;
bodyClass: string = 'App--user';
/**
* The username of the currently loaded user
*/
username: string;
/**
* The username of the currently loaded user
*/
username: string;
view() {
return (
<div className="UserPage">
{this.user ? [
UserCard.component({
user: this.user,
className: 'Hero UserHero',
editable: this.user.canEdit() || this.user === app.session.user,
controlsButtonClassName: 'Button'
}),
<div className="container">
<div className="sideNavContainer">
<nav className="sideNav UserPage-nav" config={affixSidebar}>
<ul>{listItems(this.sidebarItems().toArray())}</ul>
</nav>
<div className="sideNavOffset UserPage-content">
{this.content()}
</div>
view() {
return (
<div className="UserPage">
{this.user
? [
UserCard.component({
user: this.user,
className: 'Hero UserHero',
editable: this.user.canEdit() || this.user === app.session.user,
controlsButtonClassName: 'Button',
}),
<div className="container">
<div className="sideNavContainer">
<nav className="sideNav UserPage-nav" config={affixSidebar}>
<ul>{listItems(this.sidebarItems().toArray())}</ul>
</nav>
<div className="sideNavOffset UserPage-content">{this.content()}</div>
</div>
</div>,
]
: [LoadingIndicator.component({ lassName: 'LoadingIndicator--block' })]}
</div>
</div>
] : [
LoadingIndicator.component({lassName: 'LoadingIndicator--block'})
]}
</div>
);
}
/**
* Get the content to display in the user page.
*/
abstract content();
/**
* Initialize the component with a user, and trigger the loading of their
* activity feed.
*/
protected show(user: User) {
this.user = user;
app.setTitle(user.displayName());
m.redraw();
}
/**
* Given a username, load the user's profile from the store, or make a request
* if we don't have it yet. Then initialize the profile page with that user.
*/
loadUser(username: string) {
const lowercaseUsername = username.toLowerCase();
// Load the preloaded user object, if any, into the global app store
// We don't use the output of the method because it returns raw JSON
// instead of the parsed models
app.preloadedApiDocument();
if (lowercaseUsername == this.username) return;
this.username = lowercaseUsername;
app.store.all<User>('users').some(user => {
if ((user.username().toLowerCase() === lowercaseUsername || user.id() === username) && user.joinTime()) {
this.show(user);
return true;
}
});
if (!this.user) {
app.store.find('users', username).then(this.show.bind(this));
}
}
/**
* Build an item list for the content of the sidebar.
*/
sidebarItems() {
const items = new ItemList();
items.add('nav',
SelectDropdown.component({
children: this.navItems().toArray(),
className: 'App-titleControl',
buttonClassName: 'Button'
})
);
return items;
}
/**
* Build an item list for the navigation in the sidebar.
*/
navItems() {
const items = new ItemList();
const user = this.user;
items.add('posts',
LinkButton.component({
href: app.route('user.posts', {username: user.username()}),
children: [app.translator.trans('core.forum.user.posts_link'), <span className="Button-badge">{user.commentCount()}</span>],
icon: 'far fa-comment'
}),
100
);
items.add('discussions',
LinkButton.component({
href: app.route('user.discussions', {username: user.username()}),
children: [app.translator.trans('core.forum.user.discussions_link'), <span className="Button-badge">{user.discussionCount()}</span>],
icon: 'fas fa-bars'
}),
90
);
if (app.session.user === user) {
items.add('separator', Separator.component(), -90);
items.add('settings',
LinkButton.component({
href: app.route('settings'),
children: app.translator.trans('core.forum.user.settings_link'),
icon: 'fas fa-cog'
}),
-100
);
);
}
return items;
}
/**
* Get the content to display in the user page.
*/
abstract content();
/**
* Initialize the component with a user, and trigger the loading of their
* activity feed.
*/
protected show(user: User) {
this.user = user;
app.setTitle(user.displayName());
m.redraw();
}
/**
* Given a username, load the user's profile from the store, or make a request
* if we don't have it yet. Then initialize the profile page with that user.
*/
loadUser(username: string) {
const lowercaseUsername = username.toLowerCase();
// Load the preloaded user object, if any, into the global app store
// We don't use the output of the method because it returns raw JSON
// instead of the parsed models
app.preloadedApiDocument();
if (lowercaseUsername == this.username) return;
this.username = lowercaseUsername;
app.store.all<User>('users').some(user => {
if ((user.username().toLowerCase() === lowercaseUsername || user.id() === username) && user.joinTime()) {
this.show(user);
return true;
}
});
if (!this.user) {
app.store.find('users', username).then(this.show.bind(this));
}
}
/**
* Build an item list for the content of the sidebar.
*/
sidebarItems() {
const items = new ItemList();
items.add(
'nav',
SelectDropdown.component({
children: this.navItems().toArray(),
className: 'App-titleControl',
buttonClassName: 'Button',
})
);
return items;
}
/**
* Build an item list for the navigation in the sidebar.
*/
navItems() {
const items = new ItemList();
const user = this.user;
items.add(
'posts',
LinkButton.component({
href: app.route('user.posts', { username: user.username() }),
children: [app.translator.trans('core.forum.user.posts_link'), <span className="Button-badge">{user.commentCount()}</span>],
icon: 'far fa-comment',
}),
100
);
items.add(
'discussions',
LinkButton.component({
href: app.route('user.discussions', { username: user.username() }),
children: [app.translator.trans('core.forum.user.discussions_link'), <span className="Button-badge">{user.discussionCount()}</span>],
icon: 'fas fa-bars',
}),
90
);
if (app.session.user === user) {
items.add('separator', Separator.component(), -90);
items.add(
'settings',
LinkButton.component({
href: app.route('settings'),
children: app.translator.trans('core.forum.user.settings_link'),
icon: 'fas fa-cog',
}),
-100
);
}
return items;
}
}

View File

@ -11,49 +11,55 @@ import User from '../../common/models/User';
* @implements SearchSource
*/
export default class UsersSearchSource extends SearchSource {
protected results: { [key: string]: User[] } = {};
protected results: { [key: string]: User[] } = {};
search(query: string) {
return app.store.find<User>('users', {
filter: {q: query},
page: {limit: 5}
}).then(results => {
this.results[query] = results;
m.redraw();
});
}
search(query: string) {
return app.store
.find<User>('users', {
filter: { q: query },
page: { limit: 5 },
})
.then(results => {
this.results[query] = results;
m.redraw();
});
}
view(query: string) {
query = query.toLowerCase();
view(query: string) {
query = query.toLowerCase();
const results = (this.results[query] || [])
.concat(app.store.all<User>('users').filter(user => [user.username(), user.displayName()].some(value => value.toLowerCase().substr(0, query.length) === query)))
.filter((e, i, arr) => arr.lastIndexOf(e) === i)
.sort((a, b) => a.displayName().localeCompare(b.displayName()));
const results = (this.results[query] || [])
.concat(
app.store
.all<User>('users')
.filter(user => [user.username(), user.displayName()].some(value => value.toLowerCase().substr(0, query.length) === query))
)
.filter((e, i, arr) => arr.lastIndexOf(e) === i)
.sort((a, b) => a.displayName().localeCompare(b.displayName()));
if (!results.length) return '';
if (!results.length) return '';
return [
<li className="Dropdown-header">{app.translator.trans('core.forum.search.users_heading')}</li>,
results.map(user => {
const name = username(user);
return [
<li className="Dropdown-header">{app.translator.trans('core.forum.search.users_heading')}</li>,
results.map(user => {
const name = username(user);
if (!name.children) {
name.children = [name.text];
delete name.text;
}
if (!name.children) {
name.children = [name.text];
delete name.text;
}
name.children[0] = highlight(name.children[0], query);
name.children[0] = highlight(name.children[0], query);
return (
<li className="UserSearchResult" data-index={'users' + user.id()}>
<m.route.Link href={app.route.user(user)}>
{avatar(user)}
{name}
</m.route.Link>
</li>
);
})
];
}
return (
<li className="UserSearchResult" data-index={'users' + user.id()}>
<m.route.Link href={app.route.user(user)}>
{avatar(user)}
{name}
</m.route.Link>
</li>
);
}),
];
}
}

View File

@ -1,7 +1,7 @@
export interface StackItem {
name: string;
title: string;
url?: string;
name: string;
title: string;
url?: string;
}
/**
@ -16,89 +16,89 @@ export interface StackItem {
* rather than the previous discussion.
*/
export default class History {
/**
* The stack of routes that have been navigated to.
*/
protected stack: StackItem[] = [];
/**
* The stack of routes that have been navigated to.
*/
protected stack: StackItem[] = [];
/**
* Get the item on the top of the stack.
*/
getCurrent(): StackItem {
return this.stack[this.stack.length - 1];
}
/**
* Get the previous item on the stack.
*/
getPrevious(): StackItem {
return this.stack[this.stack.length - 2];
}
/**
* Push an item to the top of the stack.
*
* @param {String} name The name of the route.
* @param {String} title The title of the route.
* @param {String} [url] The URL of the route. The current URL will be used if
* not provided.
*/
push(name: string, title: string, url: string = m.route.get()) {
// If we're pushing an item with the same name as second-to-top item in the
// stack, we will assume that the user has clicked the 'back' button in
// their browser. In this case, we don't want to push a new item, so we will
// pop off the top item, and then the second-to-top item will be overwritten
// below.
const secondTop = this.stack[this.stack.length - 2];
if (secondTop && secondTop.name === name) {
this.stack.pop();
/**
* Get the item on the top of the stack.
*/
getCurrent(): StackItem {
return this.stack[this.stack.length - 1];
}
// If we're pushing an item with the same name as the top item in the stack,
// then we'll overwrite it with the new URL.
const top = this.getCurrent();
if (top && top.name === name) {
Object.assign(top, {url, title});
} else {
this.stack.push({name, url, title});
}
}
/**
* Check whether or not the history stack is able to be popped.
*/
canGoBack(): boolean {
return this.stack.length > 1;
}
/**
* Go back to the previous route in the history stack.
*/
back() {
if (! this.canGoBack()) {
return this.home();
/**
* Get the previous item on the stack.
*/
getPrevious(): StackItem {
return this.stack[this.stack.length - 2];
}
this.stack.pop();
/**
* Push an item to the top of the stack.
*
* @param {String} name The name of the route.
* @param {String} title The title of the route.
* @param {String} [url] The URL of the route. The current URL will be used if
* not provided.
*/
push(name: string, title: string, url: string = m.route.get()) {
// If we're pushing an item with the same name as second-to-top item in the
// stack, we will assume that the user has clicked the 'back' button in
// their browser. In this case, we don't want to push a new item, so we will
// pop off the top item, and then the second-to-top item will be overwritten
// below.
const secondTop = this.stack[this.stack.length - 2];
if (secondTop && secondTop.name === name) {
this.stack.pop();
}
m.route.set(this.getCurrent().url);
}
// If we're pushing an item with the same name as the top item in the stack,
// then we'll overwrite it with the new URL.
const top = this.getCurrent();
if (top && top.name === name) {
Object.assign(top, { url, title });
} else {
this.stack.push({ name, url, title });
}
}
/**
* Get the URL of the previous page.
*/
backUrl(): string {
const secondTop = this.stack[this.stack.length - 2];
/**
* Check whether or not the history stack is able to be popped.
*/
canGoBack(): boolean {
return this.stack.length > 1;
}
return secondTop.url;
}
/**
* Go back to the previous route in the history stack.
*/
back() {
if (!this.canGoBack()) {
return this.home();
}
/**
* Go to the first route in the history stack.
*/
home() {
this.stack.splice(0);
this.stack.pop();
m.route.set('/');
}
m.route.set(this.getCurrent().url);
}
/**
* Get the URL of the previous page.
*/
backUrl(): string {
const secondTop = this.stack[this.stack.length - 2];
return secondTop.url;
}
/**
* Go to the first route in the history stack.
*/
home() {
this.stack.splice(0);
m.route.set('/');
}
}

View File

@ -1,4 +1,4 @@
export type KeyboardEventCallback = (KeyboardEvent) => boolean|void;
export type KeyboardEventCallback = (KeyboardEvent) => boolean | void;
/**
* The `KeyboardNavigatable` class manages lists that can be navigated with the
@ -8,118 +8,118 @@ export type KeyboardEventCallback = (KeyboardEvent) => boolean|void;
* API for use.
*/
export default class KeyboardNavigatable {
/**
* Callback to be executed for a specified input.
*
* @callback KeyboardNavigatable~keyCallback
* @param {KeyboardEvent} event
* @returns {boolean}
*/
callbacks: { [key: number]: KeyboardEventCallback } = {};
/**
* Callback to be executed for a specified input.
*
* @callback KeyboardNavigatable~keyCallback
* @param {KeyboardEvent} event
* @returns {boolean}
*/
callbacks: { [key: number]: KeyboardEventCallback } = {};
// By default, always handle keyboard navigation.
whenCallback: KeyboardEventCallback = () => true;
// By default, always handle keyboard navigation.
whenCallback: KeyboardEventCallback = () => true;
/**
* Provide a callback to be executed when navigating upwards.
*
* This will be triggered by the Up key.
*/
onUp(callback: KeyboardEventCallback): this {
this.callbacks[38] = e => {
e.preventDefault();
callback(e);
};
/**
* Provide a callback to be executed when navigating upwards.
*
* This will be triggered by the Up key.
*/
onUp(callback: KeyboardEventCallback): this {
this.callbacks[38] = e => {
e.preventDefault();
callback(e);
};
return this;
}
/**
* Provide a callback to be executed when navigating downwards.
*
* This will be triggered by the Down key.
*/
onDown(callback: KeyboardEventCallback): this {
this.callbacks[40] = e => {
e.preventDefault();
callback(e);
};
return this;
}
/**
* Provide a callback to be executed when the current item is selected..
*
* This will be triggered by the Return and Tab keys..
*/
onSelect(callback: KeyboardEventCallback): this {
this.callbacks[9] = this.callbacks[13] = e => {
e.preventDefault();
callback(e);
};
return this;
}
/**
* Provide a callback to be executed when the navigation is canceled.
*
* This will be triggered by the Escape key.
*/
onCancel(callback: Function): this {
this.callbacks[27] = e => {
e.stopPropagation();
e.preventDefault();
callback(e);
};
return this;
}
/**
* Provide a callback to be executed when previous input is removed.
*
* This will be triggered by the Backspace key.
*/
onRemove(callback: Function): this {
this.callbacks[8] = e => {
if (e.target.selectionStart === 0 && e.target.selectionEnd === 0) {
callback(e);
e.preventDefault();
}
};
return this;
}
/**
* Provide a callback that determines whether keyboard input should be handled.
*/
when(callback: KeyboardEventCallback): this {
this.whenCallback = callback;
return this;
}
/**
* Set up the navigation key bindings on the given jQuery element.
*/
bindTo($element: any) {
// Handle navigation key events on the navigatable element.
$element.on('keydown', this.navigate.bind(this));
}
/**
* Interpret the given keyboard event as navigation commands.
*/
navigate(event: KeyboardEvent) {
// This callback determines whether keyboard should be handled or ignored.
if (!this.whenCallback(event)) return;
const keyCallback = this.callbacks[event.which];
if (keyCallback) {
keyCallback(event);
return this;
}
/**
* Provide a callback to be executed when navigating downwards.
*
* This will be triggered by the Down key.
*/
onDown(callback: KeyboardEventCallback): this {
this.callbacks[40] = e => {
e.preventDefault();
callback(e);
};
return this;
}
/**
* Provide a callback to be executed when the current item is selected..
*
* This will be triggered by the Return and Tab keys..
*/
onSelect(callback: KeyboardEventCallback): this {
this.callbacks[9] = this.callbacks[13] = e => {
e.preventDefault();
callback(e);
};
return this;
}
/**
* Provide a callback to be executed when the navigation is canceled.
*
* This will be triggered by the Escape key.
*/
onCancel(callback: Function): this {
this.callbacks[27] = e => {
e.stopPropagation();
e.preventDefault();
callback(e);
};
return this;
}
/**
* Provide a callback to be executed when previous input is removed.
*
* This will be triggered by the Backspace key.
*/
onRemove(callback: Function): this {
this.callbacks[8] = e => {
if (e.target.selectionStart === 0 && e.target.selectionEnd === 0) {
callback(e);
e.preventDefault();
}
};
return this;
}
/**
* Provide a callback that determines whether keyboard input should be handled.
*/
when(callback: KeyboardEventCallback): this {
this.whenCallback = callback;
return this;
}
/**
* Set up the navigation key bindings on the given jQuery element.
*/
bindTo($element: any) {
// Handle navigation key events on the navigatable element.
$element.on('keydown', this.navigate.bind(this));
}
/**
* Interpret the given keyboard event as navigation commands.
*/
navigate(event: KeyboardEvent) {
// This callback determines whether keyboard should be handled or ignored.
if (!this.whenCallback(event)) return;
const keyCallback = this.callbacks[event.which];
if (keyCallback) {
keyCallback(event);
}
}
}
}

View File

@ -1,11 +1,11 @@
import {Vnode} from "mithril";
import { Vnode } from 'mithril';
// import EditPostComposer from '../components/EditPostComposer';
import Button from '../../common/components/Button';
import Separator from '../../common/components/Separator';
import ItemList from '../../common/utils/ItemList';
import Post from "../../common/models/Post";
import PostComponent from "../../forum/components/Post";
import Post from '../../common/models/Post';
import PostComponent from '../../forum/components/Post';
/**
* The `PostControls` utility constructs a list of buttons for a post which
@ -60,10 +60,16 @@ export default {
if (post.contentType() === 'comment' && post.canEdit()) {
if (!post.isHidden()) {
items.add('edit', Button.component({
icon: 'fas fa-pencil-alt',
onclick: this.editAction.bind(post)
}, app.translator.trans('core.forum.post_controls.edit_button')));
items.add(
'edit',
Button.component(
{
icon: 'fas fa-pencil-alt',
onclick: this.editAction.bind(post),
},
app.translator.trans('core.forum.post_controls.edit_button')
)
);
}
}
@ -83,26 +89,35 @@ export default {
if (post.contentType() === 'comment' && !post.isHidden()) {
if (post.canHide()) {
items.add('hide', Button.component({
icon: 'far fa-trash-alt',
children: app.translator.trans('core.forum.post_controls.delete_button'),
onclick: this.hideAction.bind(post)
}));
items.add(
'hide',
Button.component({
icon: 'far fa-trash-alt',
children: app.translator.trans('core.forum.post_controls.delete_button'),
onclick: this.hideAction.bind(post),
})
);
}
} else {
if (post.contentType() === 'comment' && post.canHide()) {
items.add('restore', Button.component({
icon: 'fas fa-reply',
children: app.translator.trans('core.forum.post_controls.restore_button'),
onclick: this.restoreAction.bind(post)
}));
items.add(
'restore',
Button.component({
icon: 'fas fa-reply',
children: app.translator.trans('core.forum.post_controls.restore_button'),
onclick: this.restoreAction.bind(post),
})
);
}
if (post.canDelete()) {
items.add('delete', Button.component({
icon: 'fas fa-times',
children: app.translator.trans('core.forum.post_controls.delete_forever_button'),
onclick: this.deleteAction.bind(post, context)
}));
items.add(
'delete',
Button.component({
icon: 'fas fa-times',
children: app.translator.trans('core.forum.post_controls.delete_forever_button'),
onclick: this.deleteAction.bind(post, context),
})
);
}
}
@ -171,5 +186,5 @@ export default {
if (context) context.loading = false;
m.redraw();
});
}
},
};

View File

@ -4,112 +4,120 @@ import Separator from '../../common/components/Separator';
// import EditUserModal from '../components/EditUserModal';
import UserPage from '../components/UserPage';
import ItemList from '../../common/utils/ItemList';
import User from "../../common/models/User";
import User from '../../common/models/User';
/**
* The `UserControls` utility constructs a list of buttons for a user which
* perform actions on it.
*/
export default {
/**
* Get a list of controls for a user.
*
* @param user
* @param context The parent component under which the controls menu will
* be displayed.
*/
controls(user: User, context: any): ItemList {
const items = new ItemList();
/**
* Get a list of controls for a user.
*
* @param user
* @param context The parent component under which the controls menu will
* be displayed.
*/
controls(user: User, context: any): ItemList {
const items = new ItemList();
['user', 'moderation', 'destructive'].forEach(section => {
const controls = this[section + 'Controls'](user, context).toArray();
if (controls.length) {
controls.forEach(item => items.add(item.itemName, item));
items.add(section + 'Separator', Separator.component());
}
});
['user', 'moderation', 'destructive'].forEach(section => {
const controls = this[section + 'Controls'](user, context).toArray();
if (controls.length) {
controls.forEach(item => items.add(item.itemName, item));
items.add(section + 'Separator', Separator.component());
}
});
return items;
},
return items;
},
/**
* Get controls for a user pertaining to the current user (e.g. poke, follow).
*/
userControls(): ItemList {
return new ItemList();
},
/**
* Get controls for a user pertaining to the current user (e.g. poke, follow).
*/
userControls(): ItemList {
return new ItemList();
},
/**
* Get controls for a user pertaining to moderation (e.g. suspend, edit).
*/
moderationControls(user: User): ItemList {
const items = new ItemList();
/**
* Get controls for a user pertaining to moderation (e.g. suspend, edit).
*/
moderationControls(user: User): ItemList {
const items = new ItemList();
if (user.canEdit()) {
items.add('edit', Button.component({
icon: 'fas fa-pencil-alt',
children: app.translator.trans('core.forum.user_controls.edit_button'),
onclick: this.editAction.bind(this, user)
}));
}
if (user.canEdit()) {
items.add(
'edit',
Button.component({
icon: 'fas fa-pencil-alt',
children: app.translator.trans('core.forum.user_controls.edit_button'),
onclick: this.editAction.bind(this, user),
})
);
}
return items;
},
return items;
},
/**
* Get controls for a user which are destructive (e.g. delete).
*/
destructiveControls(user: User): ItemList {
const items = new ItemList();
/**
* Get controls for a user which are destructive (e.g. delete).
*/
destructiveControls(user: User): ItemList {
const items = new ItemList();
if (user.id() !== '1' && user.canDelete()) {
items.add('delete', Button.component({
icon: 'fas fa-times',
children: app.translator.trans('core.forum.user_controls.delete_button'),
onclick: this.deleteAction.bind(this, user)
}));
}
if (user.id() !== '1' && user.canDelete()) {
items.add(
'delete',
Button.component({
icon: 'fas fa-times',
children: app.translator.trans('core.forum.user_controls.delete_button'),
onclick: this.deleteAction.bind(this, user),
})
);
}
return items;
},
return items;
},
/**
* Delete the user.
*/
deleteAction(user: User) {
if (!confirm(app.translator.transText('core.forum.user_controls.delete_confirmation'))) {
return;
}
/**
* Delete the user.
*/
deleteAction(user: User) {
if (!confirm(app.translator.transText('core.forum.user_controls.delete_confirmation'))) {
return;
}
user.delete().then(() => {
this.showDeletionAlert(user, 'success');
if (app.current instanceof UserPage && app.current.user === user) {
app.history.back();
} else {
window.location.reload();
}
}).catch(() => this.showDeletionAlert(user, 'error'));
},
user.delete()
.then(() => {
this.showDeletionAlert(user, 'success');
if (app.current instanceof UserPage && app.current.user === user) {
app.history.back();
} else {
window.location.reload();
}
})
.catch(() => this.showDeletionAlert(user, 'error'));
},
/**
* Show deletion alert of user.
*/
showDeletionAlert(user: User, type: string) {
const { username, email } = user.data.attributes;
const message = `core.forum.user_controls.delete_${type}_message`;
/**
* Show deletion alert of user.
*/
showDeletionAlert(user: User, type: string) {
const { username, email } = user.data.attributes;
const message = `core.forum.user_controls.delete_${type}_message`;
app.alerts.show(Alert.component({
type,
children: app.translator.trans(
message, { username, email }
)
}));
},
app.alerts.show(
Alert.component({
type,
children: app.translator.trans(message, { username, email }),
})
);
},
/**
* Edit the user.
*/
editAction(user: User) {
app.modal.show(new EditUserModal({user}));
}
/**
* Edit the user.
*/
editAction(user: User) {
app.modal.show(new EditUserModal({ user }));
},
};

View File

@ -3,15 +3,15 @@
* using hcSticky.
*/
export default function affixSidebar(vnode) {
const element = vnode.dom;
const $sidebar = $(element);
const $header = $('#header');
const $affixElement = $sidebar.find('> ul')[0];
const element = vnode.dom;
const $sidebar = $(element);
const $header = $('#header');
const $affixElement = $sidebar.find('> ul')[0];
$(window).off('.affix');
$(window).off('.affix');
new hcSticky($affixElement, {
stickTo: element,
top: $header.outerHeight(true) + parseInt($sidebar.css('margin-top'), 10),
});
new hcSticky($affixElement, {
stickTo: element,
top: $header.outerHeight(true) + parseInt($sidebar.css('margin-top'), 10),
});
}

View File

@ -3,10 +3,22 @@ const webpack = require('webpack');
const merge = require('webpack-merge');
module.exports = merge(config(), {
output: {
library: 'flarum.core'
},
plugins: [
new webpack.IgnorePlugin(/^\.\/locale$/, /moment$/)
]
output: {
library: 'flarum.core',
},
// use zepto instead of jquery
module: {
rules: [
{
test: require.resolve('zepto'),
use: 'imports-loader?this=>window',
},
],
},
resolve: {
alias: {
jquery: 'zepto',
},
},
});