mirror of
https://github.com/flarum/core.git
synced 2025-08-06 16:36:47 +02:00
Merge branch '2.x' into dk/info-v2
This commit is contained in:
1
framework/core/js/dist-typings/admin/components/SettingDropdown.d.ts
generated
vendored
1
framework/core/js/dist-typings/admin/components/SettingDropdown.d.ts
generated
vendored
@@ -7,6 +7,7 @@ export type SettingDropdownOption = {
|
||||
export interface ISettingDropdownAttrs extends ISelectDropdownAttrs {
|
||||
setting?: string;
|
||||
options: Array<SettingDropdownOption>;
|
||||
default: any;
|
||||
}
|
||||
export default class SettingDropdown<CustomAttrs extends ISettingDropdownAttrs = ISettingDropdownAttrs> extends SelectDropdown<CustomAttrs> {
|
||||
static initAttrs(attrs: ISettingDropdownAttrs): void;
|
||||
|
1
framework/core/js/dist-typings/admin/components/UserListPage.d.ts
generated
vendored
1
framework/core/js/dist-typings/admin/components/UserListPage.d.ts
generated
vendored
@@ -68,6 +68,7 @@ export default class UserListPage extends AdminPage {
|
||||
* See `UserListPage.tsx` for examples.
|
||||
*/
|
||||
columns(): ItemList<ColumnData>;
|
||||
userActionItems(user: User): ItemList<Mithril.Children>;
|
||||
headerInfo(): {
|
||||
className: string;
|
||||
icon: string;
|
||||
|
4
framework/core/js/dist-typings/common/SearchManager.d.ts
generated
vendored
4
framework/core/js/dist-typings/common/SearchManager.d.ts
generated
vendored
@@ -5,6 +5,10 @@ export default class SearchManager<State extends SearchState = SearchState> {
|
||||
* The minimum query length before sources are searched.
|
||||
*/
|
||||
static MIN_SEARCH_LEN: number;
|
||||
/**
|
||||
* Time to wait (in milliseconds) after the user stops typing before triggering a search.
|
||||
*/
|
||||
static SEARCH_DEBOUNCE_TIME_MS: number;
|
||||
/**
|
||||
* An object which stores previously searched queries and provides convenient
|
||||
* tools for retrieving and managing search values.
|
||||
|
11
framework/core/js/dist-typings/common/components/Button.d.ts
generated
vendored
11
framework/core/js/dist-typings/common/components/Button.d.ts
generated
vendored
@@ -5,8 +5,10 @@ export interface IButtonAttrs extends ComponentAttrs {
|
||||
* Class(es) of an optional icon to be rendered within the button.
|
||||
*
|
||||
* If provided, the button will gain a `has-icon` class.
|
||||
*
|
||||
* You may also provide a rendered icon element directly.
|
||||
*/
|
||||
icon?: string;
|
||||
icon?: string | boolean | Mithril.Children;
|
||||
/**
|
||||
* Disables button from user input.
|
||||
*
|
||||
@@ -36,6 +38,12 @@ export interface IButtonAttrs extends ComponentAttrs {
|
||||
* @see https://developer.mozilla.org/en-US/docs/Web/HTML/Element/button#attr-type
|
||||
*/
|
||||
type?: string;
|
||||
/**
|
||||
* Helper text. Displayed under the button label.
|
||||
*
|
||||
* Default: `null`
|
||||
*/
|
||||
helperText?: Mithril.Children;
|
||||
}
|
||||
/**
|
||||
* The `Button` component defines an element which, when clicked, performs an
|
||||
@@ -54,4 +62,5 @@ export default class Button<CustomAttrs extends IButtonAttrs = IButtonAttrs> ext
|
||||
* Get the template for the button's content.
|
||||
*/
|
||||
protected getButtonContent(children: Mithril.Children): Mithril.ChildArray;
|
||||
protected getButtonSubContent(): Mithril.Children;
|
||||
}
|
||||
|
3
framework/core/js/dist-typings/common/components/Dropdown.d.ts
generated
vendored
3
framework/core/js/dist-typings/common/components/Dropdown.d.ts
generated
vendored
@@ -13,6 +13,8 @@ export interface IDropdownAttrs extends ComponentAttrs {
|
||||
caretIcon?: string;
|
||||
/** The label of the dropdown toggle button. Defaults to 'Controls'. */
|
||||
label: Mithril.Children;
|
||||
/** The helper text to display under the button label. */
|
||||
helperText: Mithril.Children;
|
||||
/** The label used to describe the dropdown toggle button to assistive readers. Defaults to 'Toggle dropdown menu'. */
|
||||
accessibleToggleLabel?: string;
|
||||
/** An optional tooltip to show when hovering over the dropdown toggle button. */
|
||||
@@ -42,5 +44,6 @@ export default class Dropdown<CustomAttrs extends IDropdownAttrs = IDropdownAttr
|
||||
* Get the template for the button's content.
|
||||
*/
|
||||
getButtonContent(children: Mithril.ChildArray): Mithril.ChildArray;
|
||||
protected getButtonSubContent(): Mithril.Children;
|
||||
getMenu(items: Mithril.Vnode<any, any>[]): Mithril.Vnode<any, any>;
|
||||
}
|
||||
|
21
framework/core/js/dist-typings/common/components/IPAddress.d.ts
generated
vendored
Normal file
21
framework/core/js/dist-typings/common/components/IPAddress.d.ts
generated
vendored
Normal file
@@ -0,0 +1,21 @@
|
||||
import Component, { ComponentAttrs } from '../Component';
|
||||
import ItemList from '../utils/ItemList';
|
||||
import type Mithril from 'mithril';
|
||||
export interface IIPAddressAttrs extends ComponentAttrs {
|
||||
ip: string | undefined | null;
|
||||
}
|
||||
/**
|
||||
* A component to wrap an IP address for display.
|
||||
* Designed to be customizable for different use cases.
|
||||
*
|
||||
* @example
|
||||
* <IPAddress ip="127.0.0.1" />
|
||||
* @example
|
||||
* <IPAddress ip={post.ipAddress()} />
|
||||
*/
|
||||
export default class IPAddress<CustomAttrs extends IIPAddressAttrs = IIPAddressAttrs> extends Component<CustomAttrs> {
|
||||
ip: string;
|
||||
oninit(vnode: Mithril.Vnode<CustomAttrs, this>): void;
|
||||
view(): JSX.Element;
|
||||
viewItems(): ItemList<Mithril.Children>;
|
||||
}
|
1
framework/core/js/dist-typings/common/models/Post.d.ts
generated
vendored
1
framework/core/js/dist-typings/common/models/Post.d.ts
generated
vendored
@@ -11,6 +11,7 @@ export default class Post extends Model {
|
||||
contentHtml(): string | null | undefined;
|
||||
renderFailed(): boolean | undefined;
|
||||
contentPlain(): string | null | undefined;
|
||||
ipAddress(): string | null | undefined;
|
||||
editedAt(): Date | null | undefined;
|
||||
editedUser(): false | User | null;
|
||||
isEdited(): boolean;
|
||||
|
10
framework/core/js/dist-typings/common/states/PaginatedListState.d.ts
generated
vendored
10
framework/core/js/dist-typings/common/states/PaginatedListState.d.ts
generated
vendored
@@ -1,5 +1,5 @@
|
||||
import Model from '../Model';
|
||||
import { ApiQueryParamsPlural, ApiResponsePlural } from '../Store';
|
||||
import type Model from '../Model';
|
||||
import type { ApiQueryParamsPlural, ApiResponsePlural } from '../Store';
|
||||
import type Mithril from 'mithril';
|
||||
export type SortMapItem = string | {
|
||||
sort: string;
|
||||
@@ -8,9 +8,9 @@ export type SortMapItem = string | {
|
||||
export type SortMap = {
|
||||
[key: string]: SortMapItem;
|
||||
};
|
||||
export interface Page<TModel> {
|
||||
export interface Page<TModel extends Model> {
|
||||
number: number;
|
||||
items: TModel[];
|
||||
items: ApiResponsePlural<TModel> | TModel[];
|
||||
hasPrev?: boolean;
|
||||
hasNext?: boolean;
|
||||
}
|
||||
@@ -51,6 +51,7 @@ export default abstract class PaginatedListState<T extends Model, P extends Pagi
|
||||
* Load a new page of results.
|
||||
*/
|
||||
protected loadPage(page?: number): Promise<ApiResponsePlural<T>>;
|
||||
protected mutateRequestParams(params: ApiQueryParamsPlural, page: number): ApiQueryParamsPlural;
|
||||
/**
|
||||
* Get the parameters that should be passed in the API request.
|
||||
* Do not include page offset unless subclass overrides loadPage.
|
||||
@@ -110,4 +111,5 @@ export default abstract class PaginatedListState<T extends Model, P extends Pagi
|
||||
currentSort(): string | undefined;
|
||||
changeSort(sort: string): void;
|
||||
changeFilter(key: string, value: any): void;
|
||||
remove(model: T): void;
|
||||
}
|
||||
|
2
framework/core/js/dist-typings/forum/components/IndexPage.d.ts
generated
vendored
2
framework/core/js/dist-typings/forum/components/IndexPage.d.ts
generated
vendored
@@ -13,6 +13,8 @@ export default class IndexPage<CustomAttrs extends IIndexPageAttrs = IIndexPageA
|
||||
lastDiscussion?: Discussion;
|
||||
oninit(vnode: Mithril.Vnode<CustomAttrs, this>): void;
|
||||
view(): JSX.Element;
|
||||
contentItems(): ItemList<Mithril.Children>;
|
||||
toolbarItems(): ItemList<Mithril.Children>;
|
||||
setTitle(): void;
|
||||
oncreate(vnode: Mithril.VnodeDOM<CustomAttrs, this>): void;
|
||||
onbeforeremove(vnode: Mithril.VnodeDOM<CustomAttrs, this>): void;
|
||||
|
10
framework/core/js/dist-typings/forum/components/PostMeta.d.ts
generated
vendored
10
framework/core/js/dist-typings/forum/components/PostMeta.d.ts
generated
vendored
@@ -1,11 +1,13 @@
|
||||
/// <reference types="mithril" />
|
||||
import Component, { type ComponentAttrs } from '../../common/Component';
|
||||
import Post from '../../common/models/Post';
|
||||
import type Model from '../../common/Model';
|
||||
import type User from '../../common/models/User';
|
||||
import ItemList from '../../common/utils/ItemList';
|
||||
import type Mithril from 'mithril';
|
||||
type ModelType = Post | (Model & {
|
||||
user: () => User | null | false;
|
||||
createdAt: () => Date;
|
||||
ipAddress: undefined | (() => string | null | undefined);
|
||||
});
|
||||
export interface IPostMetaAttrs extends ComponentAttrs {
|
||||
/** Can be a post or similar model like private message */
|
||||
@@ -19,10 +21,16 @@ export interface IPostMetaAttrs extends ComponentAttrs {
|
||||
*/
|
||||
export default class PostMeta<CustomAttrs extends IPostMetaAttrs = IPostMetaAttrs> extends Component<CustomAttrs> {
|
||||
view(): JSX.Element;
|
||||
viewItems(): ItemList<Mithril.Children>;
|
||||
metaItems(): ItemList<Mithril.Children>;
|
||||
/**
|
||||
* Get the permalink for the given post.
|
||||
*/
|
||||
getPermalink(post: ModelType): null | string;
|
||||
/**
|
||||
* Selects the permalink input when the dropdown is shown.
|
||||
*/
|
||||
selectPermalink(e: MouseEvent): void;
|
||||
postIdentifier(post: ModelType): string | null;
|
||||
}
|
||||
export {};
|
||||
|
8
framework/core/js/dist-typings/forum/components/PostPreview.d.ts
generated
vendored
8
framework/core/js/dist-typings/forum/components/PostPreview.d.ts
generated
vendored
@@ -9,5 +9,13 @@
|
||||
export default class PostPreview extends Component<import("../../common/Component").ComponentAttrs, undefined> {
|
||||
constructor();
|
||||
view(): JSX.Element;
|
||||
/**
|
||||
* @returns {string|undefined|null}
|
||||
*/
|
||||
content(): string | undefined | null;
|
||||
/**
|
||||
* @returns {string}
|
||||
*/
|
||||
excerpt(): string;
|
||||
}
|
||||
import Component from "../../common/Component";
|
||||
|
4
framework/core/js/dist-typings/forum/components/PostStream.d.ts
generated
vendored
4
framework/core/js/dist-typings/forum/components/PostStream.d.ts
generated
vendored
@@ -16,6 +16,10 @@ export default class PostStream extends Component<import("../../common/Component
|
||||
stream: any;
|
||||
scrollListener: ScrollListener | undefined;
|
||||
view(): JSX.Element;
|
||||
/**
|
||||
* @returns {ItemList<import('mithril').Children>}
|
||||
*/
|
||||
afterFirstPostItems(): ItemList<import('mithril').Children>;
|
||||
/**
|
||||
* @returns {ItemList<import('mithril').Children>}
|
||||
*/
|
||||
|
3
framework/core/js/dist-typings/forum/components/PostStreamScrubber.d.ts
generated
vendored
3
framework/core/js/dist-typings/forum/components/PostStreamScrubber.d.ts
generated
vendored
@@ -14,6 +14,9 @@ export default class PostStreamScrubber extends Component<import("../../common/C
|
||||
handlers: {} | undefined;
|
||||
scrollListener: ScrollListener | undefined;
|
||||
view(): JSX.Element;
|
||||
firstPostLabel(): string | any[];
|
||||
unreadLabel(unreadCount: any): any[];
|
||||
lastPostLabel(): string | any[];
|
||||
onupdate(vnode: any): void;
|
||||
oncreate(vnode: any): void;
|
||||
dragging: boolean | undefined;
|
||||
|
3
framework/core/js/dist-typings/forum/components/WelcomeHero.d.ts
generated
vendored
3
framework/core/js/dist-typings/forum/components/WelcomeHero.d.ts
generated
vendored
@@ -10,6 +10,8 @@ export interface IWelcomeHeroAttrs {
|
||||
export default class WelcomeHero extends Component<IWelcomeHeroAttrs> {
|
||||
oninit(vnode: Mithril.Vnode<IWelcomeHeroAttrs, this>): void;
|
||||
view(vnode: Mithril.Vnode<IWelcomeHeroAttrs, this>): JSX.Element | null;
|
||||
viewItems(): ItemList<Mithril.Children>;
|
||||
contentItems(): ItemList<Mithril.Children>;
|
||||
/**
|
||||
* Hide the welcome hero.
|
||||
*/
|
||||
@@ -20,5 +22,4 @@ export default class WelcomeHero extends Component<IWelcomeHeroAttrs> {
|
||||
* @returns if the welcome hero is hidden.
|
||||
*/
|
||||
isHidden(): boolean;
|
||||
welcomeItems(): ItemList<Mithril.Children>;
|
||||
}
|
||||
|
5
framework/core/js/dist-typings/forum/utils/PostControls.d.ts
generated
vendored
5
framework/core/js/dist-typings/forum/utils/PostControls.d.ts
generated
vendored
@@ -9,6 +9,11 @@ declare namespace PostControls {
|
||||
* @return {ItemList<import('mithril').Children>}')}
|
||||
*/
|
||||
function controls(post: import("../../common/models/Post").default, context: import("../../common/Component").default<any, any>): ItemList<import("mithril").Children>;
|
||||
function sections(): {
|
||||
user: (post: import("../../common/models/Post").default, context: import("../../common/Component").default<any, any>) => ItemList<import("mithril").Children>;
|
||||
moderation: (post: import("../../common/models/Post").default, context: import("../../common/Component").default<any, any>) => ItemList<import("mithril").Children>;
|
||||
destructive: (post: import("../../common/models/Post").default, context: import("../../common/Component").default<any, any>) => ItemList<import("mithril").Children>;
|
||||
};
|
||||
/**
|
||||
* Get controls for a post pertaining to the current user (e.g. report).
|
||||
*
|
||||
|
2
framework/core/js/dist/admin.js
generated
vendored
2
framework/core/js/dist/admin.js
generated
vendored
File diff suppressed because one or more lines are too long
2
framework/core/js/dist/admin.js.map
generated
vendored
2
framework/core/js/dist/admin.js.map
generated
vendored
File diff suppressed because one or more lines are too long
2
framework/core/js/dist/common/components/SearchModal.js
generated
vendored
2
framework/core/js/dist/common/components/SearchModal.js
generated
vendored
File diff suppressed because one or more lines are too long
2
framework/core/js/dist/common/components/SearchModal.js.map
generated
vendored
2
framework/core/js/dist/common/components/SearchModal.js.map
generated
vendored
File diff suppressed because one or more lines are too long
2
framework/core/js/dist/forum.js
generated
vendored
2
framework/core/js/dist/forum.js
generated
vendored
File diff suppressed because one or more lines are too long
2
framework/core/js/dist/forum.js.map
generated
vendored
2
framework/core/js/dist/forum.js.map
generated
vendored
File diff suppressed because one or more lines are too long
2
framework/core/js/dist/forum/components/PostStream.js
generated
vendored
2
framework/core/js/dist/forum/components/PostStream.js
generated
vendored
File diff suppressed because one or more lines are too long
2
framework/core/js/dist/forum/components/PostStream.js.map
generated
vendored
2
framework/core/js/dist/forum/components/PostStream.js.map
generated
vendored
File diff suppressed because one or more lines are too long
2
framework/core/js/dist/forum/components/PostStreamScrubber.js
generated
vendored
2
framework/core/js/dist/forum/components/PostStreamScrubber.js
generated
vendored
File diff suppressed because one or more lines are too long
2
framework/core/js/dist/forum/components/PostStreamScrubber.js.map
generated
vendored
2
framework/core/js/dist/forum/components/PostStreamScrubber.js.map
generated
vendored
File diff suppressed because one or more lines are too long
2
framework/core/js/dist/forum/components/UserSecurityPage.js
generated
vendored
2
framework/core/js/dist/forum/components/UserSecurityPage.js
generated
vendored
File diff suppressed because one or more lines are too long
2
framework/core/js/dist/forum/components/UserSecurityPage.js.map
generated
vendored
2
framework/core/js/dist/forum/components/UserSecurityPage.js.map
generated
vendored
File diff suppressed because one or more lines are too long
@@ -93,7 +93,7 @@ export default class MailPage<CustomAttrs extends IPageAttrs = IPageAttrs> exten
|
||||
mailSettingItems(): ItemList<Mithril.Children> {
|
||||
const items = new ItemList<Mithril.Children>();
|
||||
|
||||
const fields = this.driverFields![this.setting('mail_driver')()];
|
||||
const fields = this.driverFields![this.setting('mail_driver')()] || {};
|
||||
const fieldKeys = Object.keys(fields);
|
||||
|
||||
if (this.status!.sending) {
|
||||
|
@@ -59,7 +59,12 @@ export default class PermissionGrid<CustomAttrs extends IPermissionGridAttrs = I
|
||||
<th>
|
||||
{scope.label}{' '}
|
||||
{!!scope.onremove && (
|
||||
<Button icon="fas fa-times" className="Button Button--text PermissionGrid-removeScope" onclick={scope.onremove} />
|
||||
<Button
|
||||
icon="fas fa-times"
|
||||
className="Button Button--text PermissionGrid-removeScope"
|
||||
aria-label={app.translator.trans('core.admin.permissions.remove_scope_label', { scope: scope.label })}
|
||||
onclick={scope.onremove}
|
||||
/>
|
||||
)}
|
||||
</th>
|
||||
))}
|
||||
|
@@ -28,7 +28,13 @@ export default class SessionDropdown<CustomAttrs extends ISessionDropdownAttrs =
|
||||
getButtonContent() {
|
||||
const user = app.session.user;
|
||||
|
||||
return [<Avatar user={user} />, ' ', <span className="Button-label">{username(user)}</span>];
|
||||
return [
|
||||
<Avatar user={user} />,
|
||||
' ',
|
||||
<span className="Button-label">
|
||||
<span className="Button-labelText">{username(user)}</span>
|
||||
</span>,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
|
@@ -12,6 +12,7 @@ export type SettingDropdownOption = {
|
||||
export interface ISettingDropdownAttrs extends ISelectDropdownAttrs {
|
||||
setting?: string;
|
||||
options: Array<SettingDropdownOption>;
|
||||
default: any;
|
||||
}
|
||||
|
||||
export default class SettingDropdown<CustomAttrs extends ISettingDropdownAttrs = ISettingDropdownAttrs> extends SelectDropdown<CustomAttrs> {
|
||||
@@ -33,7 +34,7 @@ export default class SettingDropdown<CustomAttrs extends ISettingDropdownAttrs =
|
||||
return super.view({
|
||||
...vnode,
|
||||
children: this.attrs.options.map(({ value, label }) => {
|
||||
const active = app.data.settings[this.attrs.setting!] === value;
|
||||
const active = (app.data.settings[this.attrs.setting!] ?? this.attrs.default) === value;
|
||||
|
||||
return (
|
||||
<Button icon={active ? 'fas fa-check' : true} onclick={saveSettings.bind(this, { [this.attrs.setting!]: value })} active={active}>
|
||||
|
@@ -4,6 +4,7 @@ import app from '../../admin/app';
|
||||
|
||||
import LoadingIndicator from '../../common/components/LoadingIndicator';
|
||||
import Button from '../../common/components/Button';
|
||||
import Dropdown from '../../common/components/Dropdown';
|
||||
|
||||
import listItems from '../../common/helpers/listItems';
|
||||
|
||||
@@ -363,17 +364,18 @@ export default class UserListPage extends AdminPage {
|
||||
);
|
||||
|
||||
columns.add(
|
||||
'editUser',
|
||||
'userActions',
|
||||
{
|
||||
name: app.translator.trans('core.admin.users.grid.columns.edit_user.title'),
|
||||
name: app.translator.trans('core.admin.users.grid.columns.user_actions.title'),
|
||||
content: (user: User) => (
|
||||
<Button
|
||||
className="Button UserList-editModalBtn"
|
||||
title={app.translator.trans('core.admin.users.grid.columns.edit_user.tooltip', { username: user.username() })}
|
||||
onclick={() => app.modal.show(() => import('../../common/components/EditUserModal'), { user })}
|
||||
<Dropdown
|
||||
className="UserList-userActions"
|
||||
buttonClassName="Button UserList-userActionsBtn Button--icon Button--flat"
|
||||
menuClassName="Dropdown-menu--right"
|
||||
icon="fas fa-ellipsis-h"
|
||||
>
|
||||
{app.translator.trans('core.admin.users.grid.columns.edit_user.button')}
|
||||
</Button>
|
||||
{this.userActionItems(user).toArray()}
|
||||
</Dropdown>
|
||||
),
|
||||
},
|
||||
-90
|
||||
@@ -382,6 +384,23 @@ export default class UserListPage extends AdminPage {
|
||||
return columns;
|
||||
}
|
||||
|
||||
userActionItems(user: User): ItemList<Mithril.Children> {
|
||||
const items = new ItemList<Mithril.Children>();
|
||||
|
||||
items.add(
|
||||
'editUser',
|
||||
<Button
|
||||
icon="fas fa-pencil-alt"
|
||||
title={app.translator.trans('core.admin.users.grid.columns.user_actions.edit_user.tooltip', { username: user.displayName() }, true)}
|
||||
onclick={() => app.modal.show(() => import('../../common/components/EditUserModal'), { user })}
|
||||
>
|
||||
{app.translator.trans('core.admin.users.grid.columns.user_actions.edit_user.button')}
|
||||
</Button>
|
||||
);
|
||||
|
||||
return items;
|
||||
}
|
||||
|
||||
headerInfo() {
|
||||
return {
|
||||
className: 'UserListPage',
|
||||
|
@@ -7,6 +7,11 @@ export default class SearchManager<State extends SearchState = SearchState> {
|
||||
*/
|
||||
public static MIN_SEARCH_LEN = 3;
|
||||
|
||||
/**
|
||||
* Time to wait (in milliseconds) after the user stops typing before triggering a search.
|
||||
*/
|
||||
public static SEARCH_DEBOUNCE_TIME_MS = 250;
|
||||
|
||||
/**
|
||||
* An object which stores previously searched queries and provides convenient
|
||||
* tools for retrieving and managing search values.
|
||||
|
@@ -11,8 +11,10 @@ export interface IButtonAttrs extends ComponentAttrs {
|
||||
* Class(es) of an optional icon to be rendered within the button.
|
||||
*
|
||||
* If provided, the button will gain a `has-icon` class.
|
||||
*
|
||||
* You may also provide a rendered icon element directly.
|
||||
*/
|
||||
icon?: string;
|
||||
icon?: string | boolean | Mithril.Children;
|
||||
/**
|
||||
* Disables button from user input.
|
||||
*
|
||||
@@ -42,6 +44,12 @@ export interface IButtonAttrs extends ComponentAttrs {
|
||||
* @see https://developer.mozilla.org/en-US/docs/Web/HTML/Element/button#attr-type
|
||||
*/
|
||||
type?: string;
|
||||
/**
|
||||
* Helper text. Displayed under the button label.
|
||||
*
|
||||
* Default: `null`
|
||||
*/
|
||||
helperText?: Mithril.Children;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -56,7 +64,7 @@ export interface IButtonAttrs extends ComponentAttrs {
|
||||
*/
|
||||
export default class Button<CustomAttrs extends IButtonAttrs = IButtonAttrs> extends Component<CustomAttrs> {
|
||||
view(vnode: Mithril.VnodeDOM<CustomAttrs, this>) {
|
||||
let { type, 'aria-label': ariaLabel, icon: iconName, disabled, loading, className, class: _class, ...attrs } = this.attrs;
|
||||
let { type, 'aria-label': ariaLabel, icon: iconName, disabled, loading, className, class: _class, helperText, ...attrs } = this.attrs;
|
||||
|
||||
// If no `type` attr provided, set to "button"
|
||||
type ||= 'button';
|
||||
@@ -74,6 +82,7 @@ export default class Button<CustomAttrs extends IButtonAttrs = IButtonAttrs> ext
|
||||
hasIcon: iconName,
|
||||
disabled: disabled || loading,
|
||||
loading: loading,
|
||||
hasSubContent: !!this.getButtonSubContent(),
|
||||
});
|
||||
|
||||
const buttonAttrs = {
|
||||
@@ -104,12 +113,21 @@ export default class Button<CustomAttrs extends IButtonAttrs = IButtonAttrs> ext
|
||||
* Get the template for the button's content.
|
||||
*/
|
||||
protected getButtonContent(children: Mithril.Children): Mithril.ChildArray {
|
||||
const iconName = this.attrs.icon;
|
||||
const icon = this.attrs.icon;
|
||||
|
||||
return [
|
||||
iconName && <Icon name={iconName} className="Button-icon" />,
|
||||
children && <span className="Button-label">{children}</span>,
|
||||
icon && (typeof icon === 'string' || icon === true ? <Icon name={icon} className="Button-icon" /> : icon),
|
||||
children && (
|
||||
<span className="Button-label">
|
||||
<span className="Button-labelText">{children}</span>
|
||||
{this.getButtonSubContent()}
|
||||
</span>
|
||||
),
|
||||
this.attrs.loading && <LoadingIndicator size="small" display="inline" />,
|
||||
];
|
||||
}
|
||||
|
||||
protected getButtonSubContent(): Mithril.Children {
|
||||
return this.attrs.helperText ? <span className="Button-helperText">{this.attrs.helperText}</span> : null;
|
||||
}
|
||||
}
|
||||
|
@@ -19,6 +19,8 @@ export interface IDropdownAttrs extends ComponentAttrs {
|
||||
caretIcon?: string;
|
||||
/** The label of the dropdown toggle button. Defaults to 'Controls'. */
|
||||
label: Mithril.Children;
|
||||
/** The helper text to display under the button label. */
|
||||
helperText: Mithril.Children;
|
||||
/** The label used to describe the dropdown toggle button to assistive readers. Defaults to 'Toggle dropdown menu'. */
|
||||
accessibleToggleLabel?: string;
|
||||
/** An optional tooltip to show when hovering over the dropdown toggle button. */
|
||||
@@ -157,11 +159,18 @@ export default class Dropdown<CustomAttrs extends IDropdownAttrs = IDropdownAttr
|
||||
getButtonContent(children: Mithril.ChildArray): Mithril.ChildArray {
|
||||
return [
|
||||
this.attrs.icon ? <Icon name={this.attrs.icon} className="Button-icon" /> : '',
|
||||
<span className="Button-label">{this.attrs.label}</span>,
|
||||
<span className="Button-label">
|
||||
<span className="Button-labelText">{this.attrs.label}</span>
|
||||
{this.getButtonSubContent()}
|
||||
</span>,
|
||||
this.attrs.caretIcon ? <Icon name={this.attrs.caretIcon} className="Button-caret" /> : '',
|
||||
];
|
||||
}
|
||||
|
||||
protected getButtonSubContent(): Mithril.Children {
|
||||
return this.attrs.helperText ? <span className="Button-helperText">{this.attrs.helperText}</span> : null;
|
||||
}
|
||||
|
||||
getMenu(items: Mithril.Vnode<any, any>[]): Mithril.Vnode<any, any> {
|
||||
return <ul className={'Dropdown-menu dropdown-menu ' + this.attrs.menuClassName}>{items}</ul>;
|
||||
}
|
||||
|
38
framework/core/js/src/common/components/IPAddress.tsx
Normal file
38
framework/core/js/src/common/components/IPAddress.tsx
Normal file
@@ -0,0 +1,38 @@
|
||||
import Component, { ComponentAttrs } from '../Component';
|
||||
import ItemList from '../utils/ItemList';
|
||||
import type Mithril from 'mithril';
|
||||
|
||||
export interface IIPAddressAttrs extends ComponentAttrs {
|
||||
ip: string | undefined | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* A component to wrap an IP address for display.
|
||||
* Designed to be customizable for different use cases.
|
||||
*
|
||||
* @example
|
||||
* <IPAddress ip="127.0.0.1" />
|
||||
* @example
|
||||
* <IPAddress ip={post.ipAddress()} />
|
||||
*/
|
||||
export default class IPAddress<CustomAttrs extends IIPAddressAttrs = IIPAddressAttrs> extends Component<CustomAttrs> {
|
||||
ip!: string;
|
||||
|
||||
oninit(vnode: Mithril.Vnode<CustomAttrs, this>) {
|
||||
super.oninit(vnode);
|
||||
|
||||
this.ip = this.attrs.ip || '';
|
||||
}
|
||||
|
||||
view() {
|
||||
return <span className="IPAddress">{this.viewItems().toArray()}</span>;
|
||||
}
|
||||
|
||||
viewItems(): ItemList<Mithril.Children> {
|
||||
const items = new ItemList<Mithril.Children>();
|
||||
|
||||
items.add('ip', <span className="IPAddress-value">{this.ip}</span>, 100);
|
||||
|
||||
return items;
|
||||
}
|
||||
}
|
@@ -23,6 +23,7 @@ export default class Pagination<CustomAttrs extends IPaginationInterface = IPagi
|
||||
<Button
|
||||
disabled={currentPage === 1}
|
||||
title={app.translator.trans('core.lib.pagination.first_button')}
|
||||
aria-label={app.translator.trans('core.lib.pagination.first_button')}
|
||||
onclick={() => onChange(1)}
|
||||
icon="fas fa-step-backward"
|
||||
className="Button Button--icon Pagination-first"
|
||||
@@ -30,6 +31,7 @@ export default class Pagination<CustomAttrs extends IPaginationInterface = IPagi
|
||||
<Button
|
||||
disabled={currentPage === 1}
|
||||
title={app.translator.trans('core.lib.pagination.back_button')}
|
||||
aria-label={app.translator.trans('core.lib.pagination.back_button')}
|
||||
onclick={() => onChange(currentPage - 1)}
|
||||
icon="fas fa-chevron-left"
|
||||
className="Button Button--icon Pagination-back"
|
||||
@@ -77,6 +79,7 @@ export default class Pagination<CustomAttrs extends IPaginationInterface = IPagi
|
||||
<Button
|
||||
disabled={!moreData}
|
||||
title={app.translator.trans('core.lib.pagination.next_button')}
|
||||
aria-label={app.translator.trans('core.lib.pagination.next_button')}
|
||||
onclick={() => onChange(currentPage + 1)}
|
||||
icon="fas fa-chevron-right"
|
||||
className="Button Button--icon Pagination-next"
|
||||
@@ -84,6 +87,7 @@ export default class Pagination<CustomAttrs extends IPaginationInterface = IPagi
|
||||
<Button
|
||||
disabled={!moreData}
|
||||
title={app.translator.trans('core.lib.pagination.last_button')}
|
||||
aria-label={app.translator.trans('core.lib.pagination.last_button')}
|
||||
onclick={() => onChange(totalPageCount)}
|
||||
icon="fas fa-step-forward"
|
||||
className="Button Button--icon Pagination-last"
|
||||
|
@@ -324,7 +324,7 @@ export default class SearchModal<CustomAttrs extends ISearchModalAttrs = ISearch
|
||||
|
||||
this.searchState.cache(query);
|
||||
m.redraw();
|
||||
}, 250);
|
||||
}, SearchManager.SEARCH_DEBOUNCE_TIME_MS);
|
||||
}
|
||||
|
||||
/**
|
||||
|
@@ -50,7 +50,9 @@ export default class SelectDropdown<CustomAttrs extends ISelectDropdownAttrs = I
|
||||
let label = (activeChild && typeof activeChild === 'object' && 'children' in activeChild && activeChild.children) || this.attrs.defaultLabel;
|
||||
|
||||
return [
|
||||
<span className="Button-label">{label}</span>,
|
||||
<span className="Button-label">
|
||||
<span className="Button-labelText">{label}</span>
|
||||
</span>,
|
||||
this.attrs.caretIcon ? <Icon name={this.attrs.caretIcon} className="Button-caret" /> : null,
|
||||
];
|
||||
}
|
||||
|
@@ -41,6 +41,10 @@ export default class Post extends Model {
|
||||
}).call(this);
|
||||
}
|
||||
|
||||
ipAddress() {
|
||||
return Model.attribute<string | null | undefined>('ipAddress').call(this);
|
||||
}
|
||||
|
||||
editedAt() {
|
||||
return Model.attribute('editedAt', Model.transformDate).call(this);
|
||||
}
|
||||
|
@@ -1,8 +1,7 @@
|
||||
import app from '../../common/app';
|
||||
import Model from '../Model';
|
||||
import { ApiQueryParamsPlural, ApiResponsePlural } from '../Store';
|
||||
import type Model from '../Model';
|
||||
import type { ApiQueryParamsPlural, ApiResponsePlural } from '../Store';
|
||||
import type Mithril from 'mithril';
|
||||
import setRouteWithForcedRefresh from '../utils/setRouteWithForcedRefresh';
|
||||
|
||||
export type SortMapItem =
|
||||
| string
|
||||
@@ -15,9 +14,9 @@ export type SortMap = {
|
||||
[key: string]: SortMapItem;
|
||||
};
|
||||
|
||||
export interface Page<TModel> {
|
||||
export interface Page<TModel extends Model> {
|
||||
number: number;
|
||||
items: TModel[];
|
||||
items: ApiResponsePlural<TModel> | TModel[];
|
||||
|
||||
hasPrev?: boolean;
|
||||
hasNext?: boolean;
|
||||
@@ -73,7 +72,7 @@ export default abstract class PaginatedListState<T extends Model, P extends Pagi
|
||||
}
|
||||
|
||||
public loadPrev(): Promise<void> {
|
||||
if (this.loadingPrev || this.getLocation().page === 1) return Promise.resolve();
|
||||
if (this.loadingPrev || !this.hasPrev()) return Promise.resolve();
|
||||
|
||||
this.loadingPrev = true;
|
||||
|
||||
@@ -140,7 +139,7 @@ export default abstract class PaginatedListState<T extends Model, P extends Pagi
|
||||
delete params.include;
|
||||
}
|
||||
|
||||
return app.store.find<T[]>(this.type, params).then((results) => {
|
||||
return app.store.find<T[]>(this.type, this.mutateRequestParams(params, page)).then((results) => {
|
||||
const usedPerPage = results.payload?.meta?.perPage;
|
||||
const usedTotal = results.payload?.meta?.page?.total;
|
||||
|
||||
@@ -160,6 +159,35 @@ export default abstract class PaginatedListState<T extends Model, P extends Pagi
|
||||
});
|
||||
}
|
||||
|
||||
protected mutateRequestParams(params: ApiQueryParamsPlural, page: number): ApiQueryParamsPlural {
|
||||
/*
|
||||
* Support use of page[near]=
|
||||
*/
|
||||
if (params.page?.near && this.hasItems()) {
|
||||
delete params.page?.near;
|
||||
|
||||
const nextPage = this.location.page < page;
|
||||
|
||||
const offsets = this.getPages().map((page) => {
|
||||
if ('payload' in page.items) {
|
||||
return page.items.payload.meta?.page?.offset || 0;
|
||||
}
|
||||
|
||||
return 0;
|
||||
});
|
||||
|
||||
const minOffset = Math.min(...offsets);
|
||||
const maxOffset = Math.max(...offsets);
|
||||
|
||||
const limit = this.pageSize || PaginatedListState.DEFAULT_PAGE_SIZE;
|
||||
|
||||
params.page ||= {};
|
||||
params.page.offset = nextPage ? maxOffset + limit : Math.max(minOffset - limit, 0);
|
||||
}
|
||||
|
||||
return params;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the parameters that should be passed in the API request.
|
||||
* Do not include page offset unless subclass overrides loadPage.
|
||||
@@ -363,4 +391,12 @@ export default abstract class PaginatedListState<T extends Model, P extends Pagi
|
||||
1
|
||||
);
|
||||
}
|
||||
|
||||
remove(model: T): void {
|
||||
const page = this.pages.find((pg) => pg.items.includes(model));
|
||||
|
||||
if (page) {
|
||||
page.items = page.items.filter((item) => item !== model);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@@ -1,6 +1,7 @@
|
||||
import app from '../app';
|
||||
import Component, { ComponentAttrs } from '../../common/Component';
|
||||
import Button from '../../common/components/Button';
|
||||
import IPAddress from '../../common/components/IPAddress';
|
||||
import humanTime from '../../common/helpers/humanTime';
|
||||
import ItemList from '../../common/utils/ItemList';
|
||||
import LabelValue from '../../common/components/LabelValue';
|
||||
@@ -105,7 +106,12 @@ export default class AccessTokensList<CustomAttrs extends IAccessTokensListAttrs
|
||||
token.lastActivityAt() ? (
|
||||
<>
|
||||
{humanTime(token.lastActivityAt())}
|
||||
{token.lastIpAddress() && ` — ${token.lastIpAddress()}`}
|
||||
{token.lastIpAddress() && (
|
||||
<span>
|
||||
{' '}
|
||||
— <IPAddress ip={token.lastIpAddress()} />
|
||||
</span>
|
||||
)}
|
||||
{this.attrs.type === 'developer_token' && token.device() && (
|
||||
<>
|
||||
{' '}
|
||||
|
@@ -51,7 +51,7 @@ export default class DiscussionList extends Component {
|
||||
<ul role="feed" aria-busy={isLoading} className="DiscussionList-discussions">
|
||||
{state.getPages().map((pg, pageNum) => {
|
||||
return pg.items.map((discussion, itemNum) => (
|
||||
<li key={discussion.id()} data-id={discussion.id()} role="article" aria-setsize="-1" aria-posinset={pageNum * pageSize + itemNum}>
|
||||
<li key={discussion.id()} data-id={discussion.id()} role="article" aria-setsize="-1" aria-posinset={pageNum * pageSize + itemNum + 1}>
|
||||
<DiscussionListItem discussion={discussion} params={params} />
|
||||
</li>
|
||||
));
|
||||
|
@@ -44,7 +44,9 @@ export default abstract class HeaderDropdown<CustomAttrs extends IHeaderDropdown
|
||||
return [
|
||||
this.attrs.icon ? <Icon name={this.attrs.icon} className="Button-icon" /> : null,
|
||||
unread !== 0 && <span className="Bubble HeaderDropdownBubble">{unread}</span>,
|
||||
<span className="Button-label">{this.attrs.label}</span>,
|
||||
<span className="Button-label">
|
||||
<span className="Button-labelText">{this.attrs.label}</span>
|
||||
</span>,
|
||||
];
|
||||
}
|
||||
|
||||
|
@@ -63,15 +63,29 @@ export default class IndexPage<CustomAttrs extends IIndexPageAttrs = IIndexPageA
|
||||
view() {
|
||||
return (
|
||||
<PageStructure className="IndexPage" hero={this.hero.bind(this)} sidebar={this.sidebar.bind(this)}>
|
||||
<div className="IndexPage-toolbar">
|
||||
<ul className="IndexPage-toolbar-view">{listItems(this.viewItems().toArray())}</ul>
|
||||
<ul className="IndexPage-toolbar-action">{listItems(this.actionItems().toArray())}</ul>
|
||||
</div>
|
||||
<DiscussionList state={app.discussions} />
|
||||
{this.contentItems().toArray()}
|
||||
</PageStructure>
|
||||
);
|
||||
}
|
||||
|
||||
contentItems(): ItemList<Mithril.Children> {
|
||||
const items = new ItemList<Mithril.Children>();
|
||||
|
||||
items.add('toolbar', <div className="IndexPage-toolbar">{this.toolbarItems().toArray()}</div>, 100);
|
||||
items.add('discussionList', <DiscussionList state={app.discussions} />, 90);
|
||||
|
||||
return items;
|
||||
}
|
||||
|
||||
toolbarItems(): ItemList<Mithril.Children> {
|
||||
const items = new ItemList<Mithril.Children>();
|
||||
|
||||
items.add('view', <ul className="IndexPage-toolbar-view">{listItems(this.viewItems().toArray())}</ul>, 100);
|
||||
items.add('action', <ul className="IndexPage-toolbar-action">{listItems(this.actionItems().toArray())}</ul>, 90);
|
||||
|
||||
return items;
|
||||
}
|
||||
|
||||
setTitle() {
|
||||
app.setTitle(extractText(app.translator.trans('core.forum.index.meta_title_text')));
|
||||
app.setTitleCount(0);
|
||||
|
@@ -45,7 +45,7 @@ export default class PostList<CustomAttrs extends IPostListAttrs = IPostListAttr
|
||||
<ul role="feed" aria-busy={isLoading} className="PostList-discussions">
|
||||
{state.getPages().map((pg, pageNum) => {
|
||||
return pg.items.map((post, itemNum) => (
|
||||
<li key={post.id()} data-id={post.id()} role="article" aria-setsize="-1" aria-posinset={pageNum * pageSize + itemNum}>
|
||||
<li key={post.id()} data-id={post.id()} role="article" aria-setsize="-1" aria-posinset={pageNum * pageSize + itemNum + 1}>
|
||||
<PostListItem post={post} params={params} />
|
||||
</li>
|
||||
));
|
||||
|
@@ -2,12 +2,17 @@ import app from '../../forum/app';
|
||||
import Component, { type ComponentAttrs } from '../../common/Component';
|
||||
import humanTime from '../../common/helpers/humanTime';
|
||||
import fullTime from '../../common/helpers/fullTime';
|
||||
import IPAddress from '../../common/components/IPAddress';
|
||||
import Post from '../../common/models/Post';
|
||||
import type Model from '../../common/Model';
|
||||
import type User from '../../common/models/User';
|
||||
import classList from '../../common/utils/classList';
|
||||
import ItemList from '../../common/utils/ItemList';
|
||||
import type Mithril from 'mithril';
|
||||
|
||||
type ModelType = Post | (Model & { user: () => User | null | false; createdAt: () => Date });
|
||||
type ModelType =
|
||||
| Post
|
||||
| (Model & { user: () => User | null | false; createdAt: () => Date; ipAddress: undefined | (() => string | null | undefined) });
|
||||
|
||||
export interface IPostMetaAttrs extends ComponentAttrs {
|
||||
/** Can be a post or similar model like private message */
|
||||
@@ -22,47 +27,69 @@ export interface IPostMetaAttrs extends ComponentAttrs {
|
||||
*/
|
||||
export default class PostMeta<CustomAttrs extends IPostMetaAttrs = IPostMetaAttrs> extends Component<CustomAttrs> {
|
||||
view() {
|
||||
return <div className="Dropdown PostMeta">{this.viewItems().toArray()}</div>;
|
||||
}
|
||||
|
||||
viewItems(): ItemList<Mithril.Children> {
|
||||
const items = new ItemList<Mithril.Children>();
|
||||
|
||||
const post = this.attrs.post;
|
||||
const permalink = this.getPermalink(post);
|
||||
const time = post.createdAt();
|
||||
|
||||
items.add(
|
||||
'time',
|
||||
<button
|
||||
className={classList({
|
||||
'Button Button--text': true,
|
||||
'Dropdown-toggle Button--link': !!permalink,
|
||||
})}
|
||||
onclick={permalink ? this.selectPermalink.bind(this) : undefined}
|
||||
data-toggle="dropdown"
|
||||
>
|
||||
{humanTime(time)}
|
||||
</button>,
|
||||
100
|
||||
);
|
||||
|
||||
items.add('meta-dropdown', !!permalink && <div className="Dropdown-menu dropdown-menu">{this.metaItems().toArray()}</div>, 90);
|
||||
|
||||
return items;
|
||||
}
|
||||
|
||||
metaItems(): ItemList<Mithril.Children> {
|
||||
const items = new ItemList<Mithril.Children>();
|
||||
|
||||
const post = this.attrs.post;
|
||||
const time = post.createdAt();
|
||||
const permalink = this.getPermalink(post);
|
||||
const touch = 'ontouchstart' in document.documentElement;
|
||||
|
||||
// When the dropdown menu is shown, select the contents of the permalink
|
||||
// input so that the user can quickly copy the URL.
|
||||
const selectPermalink = function (this: Element, e: MouseEvent) {
|
||||
setTimeout(() => $(this).parent().find('.PostMeta-permalink').select());
|
||||
items.add('post-number', <span className="PostMeta-number">{this.postIdentifier(post)}</span>, 100);
|
||||
|
||||
e.redraw = false;
|
||||
};
|
||||
items.add('post-time', <span className="PostMeta-time">{fullTime(time)}</span>, 90);
|
||||
|
||||
return (
|
||||
<div className="Dropdown PostMeta">
|
||||
<button
|
||||
className={classList({
|
||||
'Button Button--text': true,
|
||||
'Dropdown-toggle Button--link': !!permalink,
|
||||
})}
|
||||
onclick={permalink ? selectPermalink : undefined}
|
||||
data-toggle="dropdown"
|
||||
>
|
||||
{humanTime(time)}
|
||||
</button>
|
||||
|
||||
{!!permalink && (
|
||||
<div className="Dropdown-menu dropdown-menu">
|
||||
<span className="PostMeta-number">{this.postIdentifier(post)}</span> <span className="PostMeta-time">{fullTime(time)}</span>{' '}
|
||||
<span className="PostMeta-ip">{post.data.attributes!.ipAddress}</span>
|
||||
{touch ? (
|
||||
<a className="Button PostMeta-permalink" href={permalink}>
|
||||
{permalink}
|
||||
</a>
|
||||
) : (
|
||||
<input className="FormControl PostMeta-permalink" value={permalink} onclick={(e: MouseEvent) => e.stopPropagation()} />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
items.add(
|
||||
'post-ip',
|
||||
<span className="PostMeta-ip">
|
||||
<IPAddress ip={post.ipAddress?.()} />
|
||||
</span>,
|
||||
80
|
||||
);
|
||||
|
||||
items.add(
|
||||
'permalink',
|
||||
touch ? (
|
||||
<a className="Button PostMeta-permalink" href={permalink}>
|
||||
{permalink}
|
||||
</a>
|
||||
) : (
|
||||
<input className="FormControl PostMeta-permalink" value={permalink} onclick={(e: MouseEvent) => e.stopPropagation()} />
|
||||
),
|
||||
0
|
||||
);
|
||||
|
||||
return items;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -76,6 +103,15 @@ export default class PostMeta<CustomAttrs extends IPostMetaAttrs = IPostMetaAttr
|
||||
return this.attrs.permalink?.() || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Selects the permalink input when the dropdown is shown.
|
||||
*/
|
||||
selectPermalink(e: MouseEvent) {
|
||||
const $button = $(e.currentTarget as HTMLElement);
|
||||
setTimeout(() => $button.parent().find('.PostMeta-permalink').select());
|
||||
e.redraw = false;
|
||||
}
|
||||
|
||||
postIdentifier(post: ModelType): string | null {
|
||||
if (post instanceof Post) {
|
||||
return app.translator.trans('core.forum.post.number_tooltip', { number: post.number() }, true);
|
||||
|
@@ -17,16 +17,28 @@ export default class PostPreview extends Component {
|
||||
view() {
|
||||
const post = this.attrs.post;
|
||||
const user = post.user();
|
||||
const content = post.contentType() === 'comment' && post.contentPlain();
|
||||
const excerpt = content ? highlight(content, this.attrs.highlight, 300) : '';
|
||||
|
||||
return (
|
||||
<Link className="PostPreview" href={app.route.post(post)} onclick={this.attrs.onclick}>
|
||||
<span className="PostPreview-content">
|
||||
<Avatar user={user} />
|
||||
{username(user)} <span className="PostPreview-excerpt">{excerpt}</span>
|
||||
{username(user)} <span className="PostPreview-excerpt">{this.excerpt()}</span>
|
||||
</span>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns {string|undefined|null}
|
||||
*/
|
||||
content() {
|
||||
return this.attrs.post.contentType() === 'comment' && this.attrs.post.contentPlain();
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns {string}
|
||||
*/
|
||||
excerpt() {
|
||||
return this.content() ? highlight(this.content(), this.attrs.highlight, 300) : '';
|
||||
}
|
||||
}
|
||||
|
@@ -78,11 +78,24 @@ export default class PostStream extends Component {
|
||||
content = <LoadingPost />;
|
||||
}
|
||||
|
||||
return (
|
||||
const postStreamElement = (
|
||||
<div className="PostStream-item" {...attrs}>
|
||||
{content}
|
||||
</div>
|
||||
);
|
||||
|
||||
const afterPostItems = post && post.id() === this.discussion.data.relationships.firstPost?.data.id ? this.afterFirstPostItems().toArray() : [];
|
||||
|
||||
if (afterPostItems.length > 0) {
|
||||
return m.fragment({ ...attrs }, [
|
||||
postStreamElement,
|
||||
<div className="PostStream-item PostStream-afterFirstPost" key="afterFirstPost">
|
||||
{afterPostItems}
|
||||
</div>,
|
||||
]);
|
||||
}
|
||||
|
||||
return postStreamElement;
|
||||
});
|
||||
|
||||
if (!viewingEnd && posts[this.stream.visibleEnd - this.stream.visibleStart - 1]) {
|
||||
@@ -117,6 +130,15 @@ export default class PostStream extends Component {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns {ItemList<import('mithril').Children>}
|
||||
*/
|
||||
afterFirstPostItems() {
|
||||
const items = new ItemList();
|
||||
|
||||
return items;
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns {ItemList<import('mithril').Children>}
|
||||
*/
|
||||
|
@@ -42,7 +42,7 @@ export default class PostStreamScrubber extends Component {
|
||||
const newStyle = {
|
||||
top: 100 - unreadPercent * 100 + '%',
|
||||
height: unreadPercent * 100 + '%',
|
||||
opacity: unreadPercent ? 1 : 0,
|
||||
opacity: unreadPercent > 0 ? 1 : 0,
|
||||
};
|
||||
|
||||
if (vnode.state.oldStyle) {
|
||||
@@ -65,7 +65,7 @@ export default class PostStreamScrubber extends Component {
|
||||
<div className="Dropdown-menu dropdown-menu">
|
||||
<div className="Scrubber">
|
||||
<Button className="Scrubber-first Button Button--link" onclick={this.goToFirst.bind(this)} icon="fas fa-angle-double-up">
|
||||
{app.translator.trans('core.forum.post_scrubber.original_post_link')}
|
||||
{this.firstPostLabel()}
|
||||
</Button>
|
||||
|
||||
<div className="Scrubber-scrollbar">
|
||||
@@ -80,12 +80,12 @@ export default class PostStreamScrubber extends Component {
|
||||
<div className="Scrubber-after" />
|
||||
|
||||
<div className="Scrubber-unread" oncreate={styleUnread} onupdate={styleUnread}>
|
||||
{app.translator.trans('core.forum.post_scrubber.unread_text', { count: unreadCount })}
|
||||
{this.unreadLabel(unreadCount)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button className="Scrubber-last Button Button--link" onclick={this.goToLast.bind(this)} icon="fas fa-angle-double-down">
|
||||
{app.translator.trans('core.forum.post_scrubber.now_link')}
|
||||
{this.lastPostLabel()}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -93,6 +93,18 @@ export default class PostStreamScrubber extends Component {
|
||||
);
|
||||
}
|
||||
|
||||
firstPostLabel() {
|
||||
return app.translator.trans('core.forum.post_scrubber.original_post_link');
|
||||
}
|
||||
|
||||
unreadLabel(unreadCount) {
|
||||
return app.translator.trans('core.forum.post_scrubber.unread_text', { count: unreadCount });
|
||||
}
|
||||
|
||||
lastPostLabel() {
|
||||
return app.translator.trans('core.forum.post_scrubber.now_link');
|
||||
}
|
||||
|
||||
onupdate(vnode) {
|
||||
super.onupdate(vnode);
|
||||
|
||||
|
@@ -33,7 +33,13 @@ export default class SessionDropdown<CustomAttrs extends ISessionDropdownAttrs =
|
||||
getButtonContent() {
|
||||
const user = app.session.user;
|
||||
|
||||
return [<Avatar user={user} />, ' ', <span className="Button-label">{username(user)}</span>];
|
||||
return [
|
||||
<Avatar user={user} />,
|
||||
' ',
|
||||
<span className="Button-label">
|
||||
<span className="Button-labelText">{username(user)}</span>
|
||||
</span>,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
|
@@ -26,20 +26,43 @@ export default class WelcomeHero extends Component<IWelcomeHeroAttrs> {
|
||||
|
||||
return (
|
||||
<header className="Hero WelcomeHero">
|
||||
<div className="container">
|
||||
<Button
|
||||
icon="fas fa-times"
|
||||
onclick={slideUp}
|
||||
className="Hero-close Button Button--icon Button--link"
|
||||
aria-label={app.translator.trans('core.forum.welcome_hero.hide')}
|
||||
/>
|
||||
|
||||
<div className="containerNarrow">{this.welcomeItems().toArray()}</div>
|
||||
</div>
|
||||
<div className="container">{this.viewItems().toArray()}</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
|
||||
viewItems(): ItemList<Mithril.Children> {
|
||||
const items = new ItemList<Mithril.Children>();
|
||||
|
||||
const slideUp = () => {
|
||||
this.$().slideUp(this.hide.bind(this));
|
||||
};
|
||||
|
||||
items.add(
|
||||
'dismiss-button',
|
||||
<Button
|
||||
icon="fas fa-times"
|
||||
onclick={slideUp}
|
||||
className="Hero-close Button Button--icon Button--link"
|
||||
aria-label={app.translator.trans('core.forum.welcome_hero.hide')}
|
||||
/>,
|
||||
100
|
||||
);
|
||||
|
||||
items.add('content', <div className="containerNarrow">{this.contentItems().toArray()}</div>, 80);
|
||||
|
||||
return items;
|
||||
}
|
||||
|
||||
contentItems(): ItemList<Mithril.Children> {
|
||||
const items = new ItemList<Mithril.Children>();
|
||||
|
||||
items.add('title', <h1 className="Hero-title">{app.forum.attribute('welcomeTitle')}</h1>, 100);
|
||||
items.add('subtitle', <div className="Hero-subtitle">{m.trust(app.forum.attribute('welcomeMessage'))}</div>);
|
||||
|
||||
return items;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hide the welcome hero.
|
||||
*/
|
||||
@@ -58,13 +81,4 @@ export default class WelcomeHero extends Component<IWelcomeHeroAttrs> {
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
welcomeItems(): ItemList<Mithril.Children> {
|
||||
const items = new ItemList<Mithril.Children>();
|
||||
|
||||
items.add('hero-title', <h1 className="Hero-title">{app.forum.attribute('welcomeTitle')}</h1>, 20);
|
||||
items.add('hero-subtitle', <div className="Hero-subtitle">{m.trust(app.forum.attribute('welcomeMessage'))}</div>, 10);
|
||||
|
||||
return items;
|
||||
}
|
||||
}
|
||||
|
@@ -20,8 +20,9 @@ const PostControls = {
|
||||
controls(post, context) {
|
||||
const items = new ItemList();
|
||||
|
||||
['user', 'moderation', 'destructive'].forEach((section) => {
|
||||
const controls = this[section + 'Controls'](post, context).toArray();
|
||||
Object.entries(this.sections()).forEach(([section, method]) => {
|
||||
const controls = method.call(this, post, context).toArray();
|
||||
|
||||
if (controls.length) {
|
||||
controls.forEach((item) => items.add(item.itemName, item));
|
||||
items.add(section + 'Separator', <Separator />);
|
||||
@@ -31,6 +32,14 @@ const PostControls = {
|
||||
return items;
|
||||
},
|
||||
|
||||
sections() {
|
||||
return {
|
||||
user: this.userControls,
|
||||
moderation: this.moderationControls,
|
||||
destructive: this.destructiveControls,
|
||||
};
|
||||
},
|
||||
|
||||
/**
|
||||
* Get controls for a post pertaining to the current user (e.g. report).
|
||||
*
|
||||
|
@@ -1,5 +1,6 @@
|
||||
@import "common/common";
|
||||
|
||||
@import "admin/AdminPage";
|
||||
@import "admin/AdminHeader";
|
||||
@import "admin/AdminNav";
|
||||
@import "admin/AdvancedPage";
|
||||
|
3
framework/core/less/admin/AdminPage.less
Normal file
3
framework/core/less/admin/AdminPage.less
Normal file
@@ -0,0 +1,3 @@
|
||||
.AdminPage {
|
||||
padding-bottom: var(--page-bottom-padding);
|
||||
}
|
@@ -94,23 +94,19 @@
|
||||
}
|
||||
}
|
||||
.Dropdown {
|
||||
display: block;
|
||||
|
||||
.Dropdown-toggle {
|
||||
width: 100%;
|
||||
display: block;
|
||||
text-align: left;
|
||||
float: none;
|
||||
margin: -2px 0;
|
||||
}
|
||||
.Dropdown-menu {
|
||||
margin: 0;
|
||||
margin: 6px 0 0;
|
||||
}
|
||||
}
|
||||
.Button {
|
||||
text-decoration: none;
|
||||
|
||||
.Badge {
|
||||
margin: -3px 2px -3px 0;
|
||||
margin: 0 2px 0 0;
|
||||
}
|
||||
}
|
||||
td:not(:hover) .Select-caret,
|
||||
@@ -126,12 +122,8 @@
|
||||
margin: -1px 0;
|
||||
}
|
||||
.PermissionDropdown {
|
||||
.Dropdown-toggle {
|
||||
padding: 5px 0;
|
||||
margin: -5px 0;
|
||||
}
|
||||
.Badge {
|
||||
margin: -3px 3px -3px 0;
|
||||
margin: 0 3px 0 0;
|
||||
box-shadow: none;
|
||||
}
|
||||
}
|
||||
|
@@ -57,7 +57,7 @@
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
&[data-column-name="editUser"] {
|
||||
&[data-column-name="userActions"] {
|
||||
padding: 0;
|
||||
position: relative;
|
||||
}
|
||||
@@ -81,16 +81,20 @@
|
||||
text-decoration-style: dotted;
|
||||
}
|
||||
|
||||
&-editModalBtn {
|
||||
&-userActions {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
&-userActionsBtn {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
|
||||
border-radius: 0;
|
||||
padding: 0;
|
||||
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
&-email {
|
||||
|
@@ -1,7 +1,7 @@
|
||||
.App {
|
||||
position: relative !important;
|
||||
padding-top: var(--header-height);
|
||||
padding-bottom: 50px;
|
||||
padding-bottom: var(--app-bottom-padding);
|
||||
min-height: 100vh;
|
||||
|
||||
@media @phone {
|
||||
@@ -211,7 +211,7 @@
|
||||
}
|
||||
.FormControl, .ButtonGroup, .Button {
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
justify-content: start;
|
||||
}
|
||||
.Dropdown-menu {
|
||||
.ButtonGroup, .Button {
|
||||
@@ -325,7 +325,6 @@
|
||||
.App-content {
|
||||
background: var(--body-bg);
|
||||
width: 100%;
|
||||
min-height: 100vh;
|
||||
padding-bottom: 50px;
|
||||
min-height: calc(~"100vh - var(--header-height-phone)");
|
||||
}
|
||||
}
|
||||
|
@@ -259,6 +259,12 @@
|
||||
line-height: inherit;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.Button-helperText {
|
||||
font-size: 0.73rem;
|
||||
color: var(--muted-more-color);
|
||||
}
|
||||
.Button-icon {
|
||||
line-height: inherit;
|
||||
|
@@ -27,8 +27,8 @@
|
||||
> a, > button, > span {
|
||||
padding: 8px 15px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 9px;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
color: var(--text-color);
|
||||
border-radius: 0;
|
||||
@@ -51,6 +51,10 @@
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
&.hasSubContent {
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
&.disabled {
|
||||
opacity: 0.4;
|
||||
background: none !important;
|
||||
|
@@ -255,6 +255,8 @@
|
||||
--zindex-alerts: @zindex-alerts;
|
||||
--zindex-tooltip: @zindex-tooltip;
|
||||
|
||||
--page-bottom-padding: 100px;
|
||||
|
||||
// Store the current responsive screen mode in a CSS variable, to make it
|
||||
// available to the JS code.
|
||||
--flarum-screen: none;
|
||||
|
@@ -36,6 +36,10 @@
|
||||
}
|
||||
.DiscussionListItem-author-avatar {
|
||||
display: block;
|
||||
|
||||
+ .tooltip {
|
||||
width: max-content;
|
||||
}
|
||||
}
|
||||
.DiscussionListItem-badges {
|
||||
margin-top: 10px;
|
||||
|
@@ -21,6 +21,7 @@
|
||||
--content-width: 100%;
|
||||
--sidebar-width: 190px;
|
||||
--gap: 50px;
|
||||
padding-bottom: var(--page-bottom-padding);
|
||||
|
||||
&-container {
|
||||
gap: var(--gap);
|
||||
|
@@ -429,7 +429,7 @@
|
||||
cursor: text;
|
||||
overflow: hidden;
|
||||
margin-top: 50px;
|
||||
margin-left: calc(0px - var(--post-padding));
|
||||
margin-left: 0;
|
||||
padding-left: var(--post-padding);
|
||||
border: 2px dashed var(--control-bg);
|
||||
color: var(--muted-color);
|
||||
@@ -439,7 +439,7 @@
|
||||
appearance: none;
|
||||
-webkit-appearance: none;
|
||||
text-align: left;
|
||||
width: calc(100% + var(--post-padding));
|
||||
width: 100%;
|
||||
|
||||
.Post-container {
|
||||
display: grid;
|
||||
@@ -469,6 +469,8 @@
|
||||
.ReplyPlaceholder {
|
||||
border-color: transparent;
|
||||
transition: border-color 0.2s;
|
||||
margin-left: calc(0px - var(--post-padding));
|
||||
width: calc(100% + var(--post-padding));
|
||||
|
||||
.Post-header {
|
||||
position: relative;
|
||||
|
@@ -50,12 +50,12 @@
|
||||
text-transform: uppercase;
|
||||
font-weight: bold;
|
||||
color: var(--muted-color);
|
||||
padding: 20px 20px 20px calc(~"var(--avatar-column-width) + 20px");
|
||||
padding: 20px 20px 20px var(--avatar-column-width);
|
||||
font-size: 12px;
|
||||
|
||||
@media @phone {
|
||||
margin: 0 -15px;
|
||||
padding: 20px 15px;
|
||||
margin: 0;
|
||||
padding: 20px 0;
|
||||
}
|
||||
}
|
||||
|
||||
|
@@ -310,6 +310,7 @@ core:
|
||||
participate_heading: Participate
|
||||
post_without_throttle_label: Reply multiple times without waiting
|
||||
read_heading: Read
|
||||
remove_scope_label: Remove scope of {scope}
|
||||
rename_discussions_label: Rename discussions
|
||||
reply_to_discussions_label: Reply to discussions
|
||||
search_users_label: => core.ref.search_users
|
||||
@@ -359,11 +360,6 @@ core:
|
||||
display_name:
|
||||
title: Display name
|
||||
|
||||
edit_user:
|
||||
button: => core.ref.edit
|
||||
title: => core.ref.edit_user
|
||||
tooltip: Edit {username}
|
||||
|
||||
email:
|
||||
title: => core.ref.email
|
||||
visibility_hide: Hide email address
|
||||
@@ -376,6 +372,12 @@ core:
|
||||
join_time:
|
||||
title: Joined
|
||||
|
||||
user_actions:
|
||||
title: Actions
|
||||
edit_user:
|
||||
button: => core.ref.edit
|
||||
tooltip: Edit {username}
|
||||
|
||||
user_id:
|
||||
title: ID
|
||||
|
||||
|
@@ -33,7 +33,6 @@ use Tobyz\JsonApiServer\Pagination\Pagination;
|
||||
use Tobyz\JsonApiServer\Schema\Concerns\HasMeta;
|
||||
|
||||
use function Tobyz\JsonApiServer\json_api_response;
|
||||
use function Tobyz\JsonApiServer\parse_sort_string;
|
||||
|
||||
class Index extends Endpoint
|
||||
{
|
||||
@@ -69,24 +68,18 @@ class Index extends Endpoint
|
||||
protected function setUp(): void
|
||||
{
|
||||
$this->route('GET', '/')
|
||||
->query(function ($query, ?Pagination $pagination, Context $context): Context {
|
||||
->query(function ($query, ?Pagination $pagination, Context $context, array $filters, ?array $sort, int $offset, ?int $limit): Context {
|
||||
$collection = $context->collection;
|
||||
|
||||
// This model has a searcher API, so we'll use that instead of the default.
|
||||
// The searcher API allows swapping the default search engine for a custom one.
|
||||
/** @var SearchManager $search */
|
||||
$search = $context->api->getContainer()->make(SearchManager::class);
|
||||
$modelClass = $collection instanceof AbstractDatabaseResource ? $collection->model() : null;
|
||||
|
||||
if ($query instanceof Builder && $search->searchable($modelClass)) {
|
||||
$actor = $context->getActor();
|
||||
|
||||
$extracts = $this->defaultExtracts($context);
|
||||
|
||||
$filters = $this->extractFilterValue($context, $extracts);
|
||||
$sort = $this->extractSortValue($context, $extracts);
|
||||
$limit = $this->extractLimitValue($context, $extracts);
|
||||
$offset = $this->extractOffsetValue($context, $extracts);
|
||||
|
||||
$sortIsDefault = ! $context->queryParam('sort');
|
||||
|
||||
$results = $search->query(
|
||||
@@ -100,8 +93,8 @@ class Index extends Endpoint
|
||||
else {
|
||||
$context = $context->withQuery($query);
|
||||
|
||||
$this->applySorts($query, $context);
|
||||
$this->applyFilters($query, $context);
|
||||
$this->applySorts($query, $context, $sort);
|
||||
$this->applyFilters($query, $context, $filters);
|
||||
|
||||
if ($pagination && method_exists($pagination, 'apply')) {
|
||||
$pagination->apply($query);
|
||||
@@ -129,8 +122,20 @@ class Index extends Endpoint
|
||||
|
||||
$pagination = ($this->paginationResolver)($context);
|
||||
|
||||
$extracts = $this->defaultExtracts($context);
|
||||
|
||||
$filters = $this->extractFilterValue($context, $extracts);
|
||||
$sort = $this->extractSortValue($context, $extracts);
|
||||
$limit = $this->extractLimitValue($context, $extracts);
|
||||
$offset = $this->extractOffsetValue($context, $extracts);
|
||||
|
||||
if ($pagination instanceof OffsetPagination) {
|
||||
$pagination->offset = $offset;
|
||||
$pagination->limit = $limit;
|
||||
}
|
||||
|
||||
if ($this->query) {
|
||||
$context = ($this->query)($query, $pagination, $context);
|
||||
$context = ($this->query)($query, $pagination, $context, $filters, $sort, $offset, $limit);
|
||||
|
||||
if (! $context instanceof Context) {
|
||||
throw new RuntimeException('The Index endpoint query closure must return a Context instance.');
|
||||
@@ -139,8 +144,8 @@ class Index extends Endpoint
|
||||
/** @var Context $context */
|
||||
$context = $context->withQuery($query);
|
||||
|
||||
$this->applySorts($query, $context);
|
||||
$this->applyFilters($query, $context);
|
||||
$this->applySorts($query, $context, $sort);
|
||||
$this->applyFilters($query, $context, $filters);
|
||||
|
||||
if ($pagination) {
|
||||
$pagination->apply($query);
|
||||
@@ -205,9 +210,9 @@ class Index extends Endpoint
|
||||
return $this;
|
||||
}
|
||||
|
||||
final protected function applySorts($query, Context $context): void
|
||||
final protected function applySorts($query, Context $context, ?array $sort): void
|
||||
{
|
||||
if (! ($sortString = $context->queryParam('sort', $this->defaultSort))) {
|
||||
if (! $sort) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -219,7 +224,7 @@ class Index extends Endpoint
|
||||
|
||||
$sorts = $collection->resolveSorts();
|
||||
|
||||
foreach (parse_sort_string($sortString) as [$name, $direction]) {
|
||||
foreach ($sort as $name => $direction) {
|
||||
foreach ($sorts as $field) {
|
||||
if ($field->name === $name && $field->isVisible($context)) {
|
||||
$field->apply($query, $direction, $context);
|
||||
@@ -233,18 +238,12 @@ class Index extends Endpoint
|
||||
}
|
||||
}
|
||||
|
||||
final protected function applyFilters($query, Context $context): void
|
||||
final protected function applyFilters($query, Context $context, array $filters): void
|
||||
{
|
||||
if (! ($filters = $context->queryParam('filter'))) {
|
||||
if (empty($filters)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (! is_array($filters)) {
|
||||
throw (new BadRequestException('filter must be an array'))->setSource([
|
||||
'parameter' => 'filter',
|
||||
]);
|
||||
}
|
||||
|
||||
$collection = $context->collection;
|
||||
|
||||
if (! $collection instanceof Listable) {
|
||||
|
@@ -129,16 +129,16 @@ class PostResource extends AbstractDatabaseResource
|
||||
$sort = $defaultExtracts['sort'];
|
||||
$filter = $defaultExtracts['filter'];
|
||||
|
||||
if (count($filter) > 1 || ! isset($filter['discussion']) || $sort) {
|
||||
if (count($filter) > 1 || ! isset($filter['discussion']) || ($sort && $sort !== ['number' => 'asc'])) {
|
||||
throw new BadRequestException(
|
||||
'You can only use page[near] with filter[discussion] and the default sort order'
|
||||
);
|
||||
}
|
||||
|
||||
$limit = $defaultExtracts['limit'];
|
||||
$offset = $this->posts->getIndexForNumber((int) $filter['discussion'], $near, $context->getActor());
|
||||
$index = $this->posts->getIndexForNumber((int) $filter['discussion'], $near, $context->getActor());
|
||||
|
||||
return max(0, $offset - $limit / 2);
|
||||
return max(0, $index - $limit / 2);
|
||||
}
|
||||
|
||||
return $defaultExtracts['offset'];
|
||||
@@ -150,6 +150,7 @@ class PostResource extends AbstractDatabaseResource
|
||||
'hiddenUser',
|
||||
'discussion'
|
||||
])
|
||||
->defaultSort('number')
|
||||
->paginate(static::$defaultLimit),
|
||||
];
|
||||
}
|
||||
|
@@ -35,6 +35,6 @@ class SendmailDriver implements DriverInterface
|
||||
|
||||
public function buildTransport(SettingsRepositoryInterface $settings): TransportInterface
|
||||
{
|
||||
return (new SendmailTransportFactory())->create(new Dsn('', 'sendmail'));
|
||||
return (new SendmailTransportFactory())->create(new Dsn('sendmail', 'default'));
|
||||
}
|
||||
}
|
||||
|
@@ -85,23 +85,14 @@ class PostRepository
|
||||
*/
|
||||
public function getIndexForNumber(int $discussionId, int $number, ?User $actor = null): int
|
||||
{
|
||||
if (! ($discussion = Discussion::find($discussionId))) {
|
||||
if (! ($discussion = Discussion::query()->find($discussionId))) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
$query = $discussion->posts()
|
||||
->whereVisibleTo($actor)
|
||||
->where('created_at', '<', function ($query) use ($discussionId, $number) {
|
||||
$query->select('created_at')
|
||||
->from('posts')
|
||||
->where('discussion_id', $discussionId)
|
||||
->whereNotNull('number')
|
||||
->take(1)
|
||||
|
||||
// We don't add $number as a binding because for some
|
||||
// reason doing so makes the bindings go out of order.
|
||||
->orderByRaw('ABS(CAST(number AS SIGNED) - '.(int) $number.')');
|
||||
});
|
||||
->where('number', '<=', $number)
|
||||
->orderBy('number');
|
||||
|
||||
return $query->count();
|
||||
}
|
||||
|
@@ -40,8 +40,10 @@ class ListTest extends TestCase
|
||||
$this->request('GET', '/api/groups')
|
||||
);
|
||||
|
||||
$this->assertEquals(200, $response->getStatusCode());
|
||||
$data = json_decode($response->getBody()->getContents(), true);
|
||||
$body = $response->getBody()->getContents();
|
||||
|
||||
$this->assertEquals(200, $response->getStatusCode(), $body);
|
||||
$data = json_decode($body, true);
|
||||
|
||||
// The four default groups created by the installer
|
||||
$this->assertEquals(['1', '2', '3', '4'], Arr::pluck($data['data'], 'id'));
|
||||
|
Reference in New Issue
Block a user